From 22a4e1ff111f639e3680575165b6211d432b428d Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 09:13:07 +0200 Subject: [PATCH 01/21] refactor: simplify modular architecture, keep only MVC views/models/viewmodels and split gui.py --- .gitignore | 8 +- MANIFEST.in | 4 +- cellacdc/__init__.py | 14 +- cellacdc/_run.py | 2 +- cellacdc/core.py | 2 +- cellacdc/docs/source/models.rst | 4 +- cellacdc/gui.py | 33226 +--------------- cellacdc/models/__init__.py | 6 +- cellacdc/models/actions_model.py | 31 + cellacdc/models/annotation_display_model.py | 542 + cellacdc/models/app_shell_model.py | 38 + cellacdc/models/brush_tools_model.py | 123 + cellacdc/models/canvas_context_menu_model.py | 52 + cellacdc/models/canvas_drawing_model.py | 42 + cellacdc/models/canvas_events_model.py | 41 + cellacdc/models/canvas_hover_model.py | 121 + cellacdc/models/canvas_right_image_model.py | 15 + cellacdc/models/canvas_selection_model.py | 69 + cellacdc/models/canvas_tool_model.py | 51 + cellacdc/models/cell_cycle_model.py | 98 + cellacdc/models/combine_model.py | 58 + cellacdc/models/curvature_model.py | 97 + cellacdc/models/custom_annotations_model.py | 97 + cellacdc/models/data_loading_model.py | 265 + cellacdc/models/deleted_rois_model.py | 40 + cellacdc/models/display_decorations_model.py | 47 + cellacdc/models/draw_clear_region_model.py | 61 + cellacdc/models/exporting_model.py | 110 + cellacdc/models/frame_navigation_model.py | 165 + cellacdc/models/graphics_model.py | 182 + cellacdc/models/image_controls_model.py | 44 + cellacdc/models/image_display_model.py | 76 + cellacdc/models/label_editing_model.py | 54 + cellacdc/models/label_roi_model.py | 135 + .../models/label_transform_tools_model.py | 35 + cellacdc/models/layout_controls_model.py | 38 + cellacdc/models/lineage_interactions_model.py | 131 + cellacdc/models/magic_prompts_model.py | 77 + cellacdc/models/main_menu_model.py | 20 + cellacdc/models/main_toolbar_model.py | 18 + cellacdc/models/measurements_model.py | 53 + cellacdc/models/mode_controls_model.py | 41 + cellacdc/models/object_cleanup_model.py | 17 + cellacdc/models/object_properties_model.py | 170 + cellacdc/models/object_search_model.py | 25 + cellacdc/models/points_layers_model.py | 65 + cellacdc/models/preprocessing_model.py | 74 + cellacdc/models/quick_settings_model.py | 37 + cellacdc/models/saving_model.py | 155 + cellacdc/models/seg_for_lost_ids_model.py | 125 + cellacdc/models/segmentation_model.py | 68 + cellacdc/models/session_model.py | 56 + cellacdc/models/status_hover_model.py | 122 + cellacdc/models/tool_activation_model.py | 47 + cellacdc/models/tracking_model.py | 89 + cellacdc/models/undo_redo_model.py | 32 + cellacdc/models/whitelist_model.py | 100 + cellacdc/models/window_events_model.py | 7 + cellacdc/models/worker_model.py | 34 + cellacdc/myutils.py | 12 +- .../{models => segmenters}/BABY/__init__.py | 0 .../BABY/acdcSegment.py | 0 .../Cellpose_germlineNuclei/__init__.py | 0 .../Cellpose_germlineNuclei/acdcSegment.py | 0 .../DeepSea/__init__.py | 4 +- .../DeepSea/acdcSegment.py | 0 .../InstanSeg/__init__.py | 0 .../InstanSeg/acdcSegment.py | 0 .../StarDist/__init__.py | 0 .../StarDist/acdcSegment.py | 0 .../{models => segmenters}/YeaZ/__init__.py | 0 .../YeaZ/acdcSegment.py | 0 .../YeaZ/unet/LaunchBatchPrediction.py | 0 .../YeaZ/unet/__init__.py | 0 .../YeaZ/unet/hungarian.py | 0 .../{models => segmenters}/YeaZ/unet/model.py | 0 .../YeaZ/unet/neural_network.py | 0 .../YeaZ/unet/segment.py | 0 .../YeaZ/unet/tracking.py | 0 .../YeaZ_v2/__init__.py | 0 .../YeaZ_v2/acdcSegment.py | 0 .../YeastMate/__init__.py | 0 .../YeastMate/acdcSegment.py | 0 cellacdc/segmenters/__init__.py | 5 + .../_cellpose_base/__init__.py | 0 .../_cellpose_base/_directML.py | 0 .../_cellpose_base/acdcSegment.py | 6 +- .../cellpose_v2/__init__.py | 0 .../cellpose_v2/acdcSegment.py | 2 +- .../cellpose_v3/__init__.py | 0 .../cellpose_v3/_denoise.py | 4 +- .../cellpose_v3/acdcSegment.py | 8 +- .../cellpose_v4/__init__.py | 0 .../cellpose_v4/acdcSegment.py | 4 +- .../cellsam/__init__.py | 0 .../cellsam/acdcSegment.py | 0 .../{models => segmenters}/delta/__init__.py | 0 .../delta/acdcSegment.py | 0 .../omnipose/__init__.py | 0 .../omnipose/acdcSegment.py | 0 .../omnipose_custom/__init__.py | 0 .../omnipose_custom/acdcSegment.py | 2 +- .../pomBseen/__init__.py | 0 .../pomBseen/acdcSegment.py | 0 .../pomBseen_nuclear/__init__.py | 0 .../pomBseen_nuclear/acdcSegment.py | 0 .../{models => segmenters}/sam2/__init__.py | 2 +- .../sam2/acdcSegment.py | 4 +- .../segment_anything/__init__.py | 2 +- .../segment_anything/acdcSegment.py | 4 +- .../skip_segmentation/__init__.py | 0 .../skip_segmentation/acdcSegment.py | 0 .../thresholding/__init__.py | 0 .../thresholding/acdcSegment.py | 0 .../__init__.py | 0 .../micro-sam/__init__.py | 0 .../micro-sam/acdcSegment.py | 0 .../nnInteractive/__init__.py | 0 .../nnInteractive/acdcPromptSegment.py | 2 +- .../sam2/__init__.py | 0 .../sam2/acdcPromptSegment.py | 6 +- .../segment_anything/__init__.py | 0 .../segment_anything/acdcPromptSegment.py | 6 +- .../utils.py | 0 cellacdc/trackers/DeepSea/DeepSea_tracker.py | 6 +- cellacdc/trackers/DeepSea/__init__.py | 2 +- cellacdc/trackers/delta/__init__.py | 2 +- cellacdc/viewmodels/__init__.py | 183 + cellacdc/viewmodels/actions_viewmodel.py | 56 + .../annotation_display_viewmodel.py | 448 + cellacdc/viewmodels/app_shell_viewmodel.py | 29 + cellacdc/viewmodels/brush_tools_viewmodel.py | 87 + .../canvas_context_menu_viewmodel.py | 51 + .../viewmodels/canvas_drawing_viewmodel.py | 60 + .../viewmodels/canvas_events_viewmodel.py | 55 + cellacdc/viewmodels/canvas_hover_viewmodel.py | 59 + .../canvas_right_image_viewmodel.py | 25 + .../viewmodels/canvas_selection_viewmodel.py | 49 + cellacdc/viewmodels/canvas_tool_viewmodel.py | 66 + cellacdc/viewmodels/canvas_tools.py | 7 + cellacdc/viewmodels/cca_edits.py | 249 + cellacdc/viewmodels/cca_workflows.py | 160 + cellacdc/viewmodels/cell_cycle_viewmodel.py | 67 + cellacdc/viewmodels/combine_viewmodel.py | 29 + cellacdc/viewmodels/curvature_viewmodel.py | 98 + .../custom_annotations_viewmodel.py | 93 + cellacdc/viewmodels/data_loading_viewmodel.py | 73 + cellacdc/viewmodels/deleted_rois_viewmodel.py | 48 + .../display_decorations_viewmodel.py | 57 + .../viewmodels/draw_clear_region_viewmodel.py | 55 + cellacdc/viewmodels/edit_id.py | 81 + cellacdc/viewmodels/exporting_viewmodel.py | 58 + cellacdc/viewmodels/formatting.py | 57 + cellacdc/viewmodels/frame_metadata.py | 52 + .../viewmodels/frame_navigation_viewmodel.py | 66 + cellacdc/viewmodels/geometry.py | 174 + cellacdc/viewmodels/graphics_viewmodel.py | 117 + .../viewmodels/image_controls_viewmodel.py | 32 + .../viewmodels/image_display_viewmodel.py | 57 + .../viewmodels/label_editing_viewmodel.py | 82 + cellacdc/viewmodels/label_edits.py | 339 + cellacdc/viewmodels/label_roi_viewmodel.py | 121 + .../label_transform_tools_viewmodel.py | 51 + .../viewmodels/layout_controls_viewmodel.py | 40 + cellacdc/viewmodels/lineage.py | 61 + .../lineage_interactions_viewmodel.py | 108 + .../viewmodels/magic_prompts_viewmodel.py | 57 + cellacdc/viewmodels/main.py | 224 + cellacdc/viewmodels/main_menu_viewmodel.py | 20 + cellacdc/viewmodels/main_toolbar_viewmodel.py | 17 + cellacdc/viewmodels/measurements.py | 5 + cellacdc/viewmodels/measurements_viewmodel.py | 49 + .../viewmodels/mode_controls_viewmodel.py | 36 + cellacdc/viewmodels/model_registry.py | 134 + .../viewmodels/object_cleanup_viewmodel.py | 30 + cellacdc/viewmodels/object_counts.py | 38 + .../viewmodels/object_properties_viewmodel.py | 180 + .../viewmodels/object_search_viewmodel.py | 28 + cellacdc/viewmodels/points.py | 135 + .../viewmodels/points_layers_viewmodel.py | 67 + .../viewmodels/preprocessing_viewmodel.py | 65 + .../viewmodels/quick_settings_viewmodel.py | 33 + cellacdc/viewmodels/saving_viewmodel.py | 137 + .../viewmodels/seg_for_lost_ids_viewmodel.py | 53 + cellacdc/viewmodels/segmentation_viewmodel.py | 82 + cellacdc/viewmodels/session.py | 7 + cellacdc/viewmodels/session_viewmodel.py | 144 + cellacdc/viewmodels/status_hover_viewmodel.py | 53 + cellacdc/viewmodels/tables.py | 17 + .../viewmodels/tool_activation_viewmodel.py | 60 + cellacdc/viewmodels/tracking.py | 7 + cellacdc/viewmodels/tracking_viewmodel.py | 101 + cellacdc/viewmodels/undo_redo_viewmodel.py | 39 + cellacdc/viewmodels/whitelist_viewmodel.py | 46 + .../viewmodels/window_events_viewmodel.py | 35 + cellacdc/viewmodels/worker_viewmodel.py | 36 + cellacdc/viewmodels/workspace.py | 51 + cellacdc/views/__init__.py | 1 + cellacdc/views/actions_view.py | 1043 + cellacdc/views/annotation_display_view.py | 1107 + cellacdc/views/app_shell_view.py | 353 + cellacdc/views/brush_tools_view.py | 578 + cellacdc/views/canvas_context_menu_view.py | 125 + cellacdc/views/canvas_drawing_view.py | 683 + cellacdc/views/canvas_events_view.py | 992 + cellacdc/views/canvas_hover_view.py | 551 + cellacdc/views/canvas_right_image_view.py | 48 + cellacdc/views/canvas_selection_view.py | 854 + cellacdc/views/canvas_tool_view.py | 63 + cellacdc/views/cell_cycle_view.py | 2392 ++ cellacdc/views/combine_view.py | 646 + cellacdc/views/curvature_tools_view.py | 256 + cellacdc/views/custom_annotations_view.py | 619 + cellacdc/views/data_loading_view.py | 1659 + cellacdc/views/deleted_rois_view.py | 625 + cellacdc/views/display_decorations_view.py | 200 + cellacdc/views/draw_clear_region_view.py | 91 + cellacdc/views/exporting_view.py | 387 + cellacdc/views/frame_navigation_view.py | 1214 + cellacdc/views/graphics_view.py | 2908 ++ cellacdc/views/image_controls_view.py | 413 + cellacdc/views/image_display_view.py | 1441 + cellacdc/views/label_editing_view.py | 806 + cellacdc/views/label_roi_view.py | 538 + cellacdc/views/label_transform_tools_view.py | 218 + cellacdc/views/layout_controls_view.py | 854 + cellacdc/views/lineage_interactions_view.py | 717 + cellacdc/views/magic_prompts_view.py | 412 + cellacdc/views/main_menu_view.py | 222 + cellacdc/views/main_toolbar_view.py | 596 + cellacdc/views/measurements_view.py | 173 + cellacdc/views/mode_controls_view.py | 466 + cellacdc/views/object_cleanup_view.py | 107 + cellacdc/views/object_properties_view.py | 915 + cellacdc/views/object_search_view.py | 280 + cellacdc/views/points_layers_view.py | 1259 + cellacdc/views/preprocessing_view.py | 493 + cellacdc/views/quick_settings_view.py | 211 + cellacdc/views/saving_view.py | 1104 + cellacdc/views/seg_for_lost_ids_view.py | 260 + cellacdc/views/segmentation_view.py | 770 + cellacdc/views/session_view.py | 742 + cellacdc/views/status_hover_view.py | 185 + cellacdc/views/tool_activation_view.py | 893 + cellacdc/views/tracking_view.py | 1382 + cellacdc/views/undo_redo_view.py | 432 + cellacdc/views/whitelist_view.py | 849 + cellacdc/views/window_events_view.py | 1098 + cellacdc/views/worker_view.py | 475 + cellacdc/widgets.py | 4 +- cellacdc/workers.py | 2 +- 251 files changed, 45954 insertions(+), 32983 deletions(-) mode change 100755 => 100644 cellacdc/models/__init__.py create mode 100644 cellacdc/models/actions_model.py create mode 100644 cellacdc/models/annotation_display_model.py create mode 100644 cellacdc/models/app_shell_model.py create mode 100644 cellacdc/models/brush_tools_model.py create mode 100644 cellacdc/models/canvas_context_menu_model.py create mode 100644 cellacdc/models/canvas_drawing_model.py create mode 100644 cellacdc/models/canvas_events_model.py create mode 100644 cellacdc/models/canvas_hover_model.py create mode 100644 cellacdc/models/canvas_right_image_model.py create mode 100644 cellacdc/models/canvas_selection_model.py create mode 100644 cellacdc/models/canvas_tool_model.py create mode 100644 cellacdc/models/cell_cycle_model.py create mode 100644 cellacdc/models/combine_model.py create mode 100644 cellacdc/models/curvature_model.py create mode 100644 cellacdc/models/custom_annotations_model.py create mode 100644 cellacdc/models/data_loading_model.py create mode 100644 cellacdc/models/deleted_rois_model.py create mode 100644 cellacdc/models/display_decorations_model.py create mode 100644 cellacdc/models/draw_clear_region_model.py create mode 100644 cellacdc/models/exporting_model.py create mode 100644 cellacdc/models/frame_navigation_model.py create mode 100644 cellacdc/models/graphics_model.py create mode 100644 cellacdc/models/image_controls_model.py create mode 100644 cellacdc/models/image_display_model.py create mode 100644 cellacdc/models/label_editing_model.py create mode 100644 cellacdc/models/label_roi_model.py create mode 100644 cellacdc/models/label_transform_tools_model.py create mode 100644 cellacdc/models/layout_controls_model.py create mode 100644 cellacdc/models/lineage_interactions_model.py create mode 100644 cellacdc/models/magic_prompts_model.py create mode 100644 cellacdc/models/main_menu_model.py create mode 100644 cellacdc/models/main_toolbar_model.py create mode 100644 cellacdc/models/measurements_model.py create mode 100644 cellacdc/models/mode_controls_model.py create mode 100644 cellacdc/models/object_cleanup_model.py create mode 100644 cellacdc/models/object_properties_model.py create mode 100644 cellacdc/models/object_search_model.py create mode 100644 cellacdc/models/points_layers_model.py create mode 100644 cellacdc/models/preprocessing_model.py create mode 100644 cellacdc/models/quick_settings_model.py create mode 100644 cellacdc/models/saving_model.py create mode 100644 cellacdc/models/seg_for_lost_ids_model.py create mode 100644 cellacdc/models/segmentation_model.py create mode 100644 cellacdc/models/session_model.py create mode 100644 cellacdc/models/status_hover_model.py create mode 100644 cellacdc/models/tool_activation_model.py create mode 100644 cellacdc/models/tracking_model.py create mode 100644 cellacdc/models/undo_redo_model.py create mode 100644 cellacdc/models/whitelist_model.py create mode 100644 cellacdc/models/window_events_model.py create mode 100644 cellacdc/models/worker_model.py rename cellacdc/{models => segmenters}/BABY/__init__.py (100%) rename cellacdc/{models => segmenters}/BABY/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/Cellpose_germlineNuclei/__init__.py (100%) rename cellacdc/{models => segmenters}/Cellpose_germlineNuclei/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/DeepSea/__init__.py (90%) rename cellacdc/{models => segmenters}/DeepSea/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/InstanSeg/__init__.py (100%) rename cellacdc/{models => segmenters}/InstanSeg/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/StarDist/__init__.py (100%) rename cellacdc/{models => segmenters}/StarDist/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/YeaZ/__init__.py (100%) rename cellacdc/{models => segmenters}/YeaZ/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/YeaZ/unet/LaunchBatchPrediction.py (100%) rename cellacdc/{models => segmenters}/YeaZ/unet/__init__.py (100%) rename cellacdc/{models => segmenters}/YeaZ/unet/hungarian.py (100%) rename cellacdc/{models => segmenters}/YeaZ/unet/model.py (100%) rename cellacdc/{models => segmenters}/YeaZ/unet/neural_network.py (100%) rename cellacdc/{models => segmenters}/YeaZ/unet/segment.py (100%) rename cellacdc/{models => segmenters}/YeaZ/unet/tracking.py (100%) rename cellacdc/{models => segmenters}/YeaZ_v2/__init__.py (100%) rename cellacdc/{models => segmenters}/YeaZ_v2/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/YeastMate/__init__.py (100%) rename cellacdc/{models => segmenters}/YeastMate/acdcSegment.py (100%) create mode 100755 cellacdc/segmenters/__init__.py rename cellacdc/{models => segmenters}/_cellpose_base/__init__.py (100%) rename cellacdc/{models => segmenters}/_cellpose_base/_directML.py (100%) rename cellacdc/{models => segmenters}/_cellpose_base/acdcSegment.py (99%) rename cellacdc/{models => segmenters}/cellpose_v2/__init__.py (100%) rename cellacdc/{models => segmenters}/cellpose_v2/acdcSegment.py (99%) rename cellacdc/{models => segmenters}/cellpose_v3/__init__.py (100%) rename cellacdc/{models => segmenters}/cellpose_v3/_denoise.py (99%) rename cellacdc/{models => segmenters}/cellpose_v3/acdcSegment.py (98%) rename cellacdc/{models => segmenters}/cellpose_v4/__init__.py (100%) rename cellacdc/{models => segmenters}/cellpose_v4/acdcSegment.py (98%) rename cellacdc/{models => segmenters}/cellsam/__init__.py (100%) rename cellacdc/{models => segmenters}/cellsam/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/delta/__init__.py (100%) rename cellacdc/{models => segmenters}/delta/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/omnipose/__init__.py (100%) rename cellacdc/{models => segmenters}/omnipose/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/omnipose_custom/__init__.py (100%) rename cellacdc/{models => segmenters}/omnipose_custom/acdcSegment.py (95%) rename cellacdc/{models => segmenters}/pomBseen/__init__.py (100%) rename cellacdc/{models => segmenters}/pomBseen/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/pomBseen_nuclear/__init__.py (100%) rename cellacdc/{models => segmenters}/pomBseen_nuclear/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/sam2/__init__.py (88%) rename cellacdc/{models => segmenters}/sam2/acdcSegment.py (99%) rename cellacdc/{models => segmenters}/segment_anything/__init__.py (79%) rename cellacdc/{models => segmenters}/segment_anything/acdcSegment.py (99%) rename cellacdc/{models => segmenters}/skip_segmentation/__init__.py (100%) rename cellacdc/{models => segmenters}/skip_segmentation/acdcSegment.py (100%) rename cellacdc/{models => segmenters}/thresholding/__init__.py (100%) rename cellacdc/{models => segmenters}/thresholding/acdcSegment.py (100%) rename cellacdc/{promptable_models => segmenters_promptable}/__init__.py (100%) rename cellacdc/{promptable_models => segmenters_promptable}/micro-sam/__init__.py (100%) rename cellacdc/{promptable_models => segmenters_promptable}/micro-sam/acdcSegment.py (100%) rename cellacdc/{promptable_models => segmenters_promptable}/nnInteractive/__init__.py (100%) rename cellacdc/{promptable_models => segmenters_promptable}/nnInteractive/acdcPromptSegment.py (99%) rename cellacdc/{promptable_models => segmenters_promptable}/sam2/__init__.py (100%) rename cellacdc/{promptable_models => segmenters_promptable}/sam2/acdcPromptSegment.py (97%) rename cellacdc/{promptable_models => segmenters_promptable}/segment_anything/__init__.py (100%) rename cellacdc/{promptable_models => segmenters_promptable}/segment_anything/acdcPromptSegment.py (97%) rename cellacdc/{promptable_models => segmenters_promptable}/utils.py (100%) create mode 100644 cellacdc/viewmodels/__init__.py create mode 100644 cellacdc/viewmodels/actions_viewmodel.py create mode 100644 cellacdc/viewmodels/annotation_display_viewmodel.py create mode 100644 cellacdc/viewmodels/app_shell_viewmodel.py create mode 100644 cellacdc/viewmodels/brush_tools_viewmodel.py create mode 100644 cellacdc/viewmodels/canvas_context_menu_viewmodel.py create mode 100644 cellacdc/viewmodels/canvas_drawing_viewmodel.py create mode 100644 cellacdc/viewmodels/canvas_events_viewmodel.py create mode 100644 cellacdc/viewmodels/canvas_hover_viewmodel.py create mode 100644 cellacdc/viewmodels/canvas_right_image_viewmodel.py create mode 100644 cellacdc/viewmodels/canvas_selection_viewmodel.py create mode 100644 cellacdc/viewmodels/canvas_tool_viewmodel.py create mode 100644 cellacdc/viewmodels/canvas_tools.py create mode 100644 cellacdc/viewmodels/cca_edits.py create mode 100644 cellacdc/viewmodels/cca_workflows.py create mode 100644 cellacdc/viewmodels/cell_cycle_viewmodel.py create mode 100644 cellacdc/viewmodels/combine_viewmodel.py create mode 100644 cellacdc/viewmodels/curvature_viewmodel.py create mode 100644 cellacdc/viewmodels/custom_annotations_viewmodel.py create mode 100644 cellacdc/viewmodels/data_loading_viewmodel.py create mode 100644 cellacdc/viewmodels/deleted_rois_viewmodel.py create mode 100644 cellacdc/viewmodels/display_decorations_viewmodel.py create mode 100644 cellacdc/viewmodels/draw_clear_region_viewmodel.py create mode 100644 cellacdc/viewmodels/edit_id.py create mode 100644 cellacdc/viewmodels/exporting_viewmodel.py create mode 100644 cellacdc/viewmodels/formatting.py create mode 100644 cellacdc/viewmodels/frame_metadata.py create mode 100644 cellacdc/viewmodels/frame_navigation_viewmodel.py create mode 100644 cellacdc/viewmodels/geometry.py create mode 100644 cellacdc/viewmodels/graphics_viewmodel.py create mode 100644 cellacdc/viewmodels/image_controls_viewmodel.py create mode 100644 cellacdc/viewmodels/image_display_viewmodel.py create mode 100644 cellacdc/viewmodels/label_editing_viewmodel.py create mode 100644 cellacdc/viewmodels/label_edits.py create mode 100644 cellacdc/viewmodels/label_roi_viewmodel.py create mode 100644 cellacdc/viewmodels/label_transform_tools_viewmodel.py create mode 100644 cellacdc/viewmodels/layout_controls_viewmodel.py create mode 100644 cellacdc/viewmodels/lineage.py create mode 100644 cellacdc/viewmodels/lineage_interactions_viewmodel.py create mode 100644 cellacdc/viewmodels/magic_prompts_viewmodel.py create mode 100644 cellacdc/viewmodels/main.py create mode 100644 cellacdc/viewmodels/main_menu_viewmodel.py create mode 100644 cellacdc/viewmodels/main_toolbar_viewmodel.py create mode 100644 cellacdc/viewmodels/measurements.py create mode 100644 cellacdc/viewmodels/measurements_viewmodel.py create mode 100644 cellacdc/viewmodels/mode_controls_viewmodel.py create mode 100644 cellacdc/viewmodels/model_registry.py create mode 100644 cellacdc/viewmodels/object_cleanup_viewmodel.py create mode 100644 cellacdc/viewmodels/object_counts.py create mode 100644 cellacdc/viewmodels/object_properties_viewmodel.py create mode 100644 cellacdc/viewmodels/object_search_viewmodel.py create mode 100644 cellacdc/viewmodels/points.py create mode 100644 cellacdc/viewmodels/points_layers_viewmodel.py create mode 100644 cellacdc/viewmodels/preprocessing_viewmodel.py create mode 100644 cellacdc/viewmodels/quick_settings_viewmodel.py create mode 100644 cellacdc/viewmodels/saving_viewmodel.py create mode 100644 cellacdc/viewmodels/seg_for_lost_ids_viewmodel.py create mode 100644 cellacdc/viewmodels/segmentation_viewmodel.py create mode 100644 cellacdc/viewmodels/session.py create mode 100644 cellacdc/viewmodels/session_viewmodel.py create mode 100644 cellacdc/viewmodels/status_hover_viewmodel.py create mode 100644 cellacdc/viewmodels/tables.py create mode 100644 cellacdc/viewmodels/tool_activation_viewmodel.py create mode 100644 cellacdc/viewmodels/tracking.py create mode 100644 cellacdc/viewmodels/tracking_viewmodel.py create mode 100644 cellacdc/viewmodels/undo_redo_viewmodel.py create mode 100644 cellacdc/viewmodels/whitelist_viewmodel.py create mode 100644 cellacdc/viewmodels/window_events_viewmodel.py create mode 100644 cellacdc/viewmodels/worker_viewmodel.py create mode 100644 cellacdc/viewmodels/workspace.py create mode 100644 cellacdc/views/__init__.py create mode 100644 cellacdc/views/actions_view.py create mode 100644 cellacdc/views/annotation_display_view.py create mode 100644 cellacdc/views/app_shell_view.py create mode 100644 cellacdc/views/brush_tools_view.py create mode 100644 cellacdc/views/canvas_context_menu_view.py create mode 100644 cellacdc/views/canvas_drawing_view.py create mode 100644 cellacdc/views/canvas_events_view.py create mode 100644 cellacdc/views/canvas_hover_view.py create mode 100644 cellacdc/views/canvas_right_image_view.py create mode 100644 cellacdc/views/canvas_selection_view.py create mode 100644 cellacdc/views/canvas_tool_view.py create mode 100644 cellacdc/views/cell_cycle_view.py create mode 100644 cellacdc/views/combine_view.py create mode 100644 cellacdc/views/curvature_tools_view.py create mode 100644 cellacdc/views/custom_annotations_view.py create mode 100644 cellacdc/views/data_loading_view.py create mode 100644 cellacdc/views/deleted_rois_view.py create mode 100644 cellacdc/views/display_decorations_view.py create mode 100644 cellacdc/views/draw_clear_region_view.py create mode 100644 cellacdc/views/exporting_view.py create mode 100644 cellacdc/views/frame_navigation_view.py create mode 100644 cellacdc/views/graphics_view.py create mode 100644 cellacdc/views/image_controls_view.py create mode 100644 cellacdc/views/image_display_view.py create mode 100644 cellacdc/views/label_editing_view.py create mode 100644 cellacdc/views/label_roi_view.py create mode 100644 cellacdc/views/label_transform_tools_view.py create mode 100644 cellacdc/views/layout_controls_view.py create mode 100644 cellacdc/views/lineage_interactions_view.py create mode 100644 cellacdc/views/magic_prompts_view.py create mode 100644 cellacdc/views/main_menu_view.py create mode 100644 cellacdc/views/main_toolbar_view.py create mode 100644 cellacdc/views/measurements_view.py create mode 100644 cellacdc/views/mode_controls_view.py create mode 100644 cellacdc/views/object_cleanup_view.py create mode 100644 cellacdc/views/object_properties_view.py create mode 100644 cellacdc/views/object_search_view.py create mode 100644 cellacdc/views/points_layers_view.py create mode 100644 cellacdc/views/preprocessing_view.py create mode 100644 cellacdc/views/quick_settings_view.py create mode 100644 cellacdc/views/saving_view.py create mode 100644 cellacdc/views/seg_for_lost_ids_view.py create mode 100644 cellacdc/views/segmentation_view.py create mode 100644 cellacdc/views/session_view.py create mode 100644 cellacdc/views/status_hover_view.py create mode 100644 cellacdc/views/tool_activation_view.py create mode 100644 cellacdc/views/tracking_view.py create mode 100644 cellacdc/views/undo_redo_view.py create mode 100644 cellacdc/views/whitelist_view.py create mode 100644 cellacdc/views/window_events_view.py create mode 100644 cellacdc/views/worker_view.py diff --git a/.gitignore b/.gitignore index 6d90763c1..50dfc2302 100755 --- a/.gitignore +++ b/.gitignore @@ -62,10 +62,10 @@ cellacdc/metrics/* !cellacdc/metrics/CV.py !cellacdc/metrics/combine_metrics_example.py !cellacdc/metrics/channel_indipendent_metric_example.py -cellacdc/models/beno -cellacdc/models/Simone_cellpose +cellacdc/segmenters/beno +cellacdc/segmenters/Simone_cellpose cellacdc/timon_tests -cellacdc/models/test_segm_model +cellacdc/segmenters/test_segm_model cellacdc/trackers/example cellacdc/test_qt_app.py cellacdc/test_qthread.py @@ -98,7 +98,7 @@ UserManual/* # Ignore models folder but keep the folder with placeholder.txt dummy file models/* !models/placeholder.txt -cellacdc/models/*/model/* +cellacdc/segmenters/*/model/* # Hide placeholder.txt dummy file (probably works only on Linux) !models/.placeholder.txt diff --git a/MANIFEST.in b/MANIFEST.in index edbfa4229..da7028e54 100755 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -26,8 +26,8 @@ prune publications prine notebooks prune FijiMacros -exclude cellacdc/models/YeastMate/detectron2 -exclude cellacdc/models/YeastMate/pycocotools +exclude cellacdc/segmenters/YeastMate/detectron2 +exclude cellacdc/segmenters/YeastMate/pycocotools exclude requirements.txt exclude not_installed_requirements.txt diff --git a/cellacdc/__init__.py b/cellacdc/__init__.py index c4fcaf553..1d92a1807 100755 --- a/cellacdc/__init__.py +++ b/cellacdc/__init__.py @@ -410,8 +410,8 @@ def printl(*objects, pretty=False, is_decorator=False, idx=1, **kwargs): parent_path = os.path.dirname(cellacdc_path) html_path = os.path.join(cellacdc_path, '_html') -models_path = os.path.join(cellacdc_path, 'models') -promptable_models_path = os.path.join(cellacdc_path, 'promptable_models') +segmenters_path = os.path.join(cellacdc_path, 'segmenters') +segmenters_promptable_path = os.path.join(cellacdc_path, 'segmenters_promptable') data_path = os.path.join(parent_path, 'data') resources_folderpath = os.path.join(cellacdc_path, 'resources') resources_filepath = os.path.join(cellacdc_path, 'resources_light.qrc') @@ -419,10 +419,16 @@ def printl(*objects, pretty=False, is_decorator=False, idx=1, **kwargs): acdc_fiji_path = os.path.join(user_profile_path, 'acdc-fiji') acdc_ffmpeg_path = os.path.join(user_profile_path, 'acdc-ffmpeg') resources_path = os.path.join(cellacdc_path, 'resources_light.qrc') -models_list_file_path = os.path.join(settings_folderpath, 'custom_models_paths.ini') -promptable_models_list_file_path = os.path.join( +segmenters_list_file_path = os.path.join( + settings_folderpath, 'custom_models_paths.ini' +) +segmenters_promptable_list_file_path = os.path.join( settings_folderpath, 'custom_promptable_models_paths.ini' ) +models_path = segmenters_path +promptable_models_path = segmenters_promptable_path +models_list_file_path = segmenters_list_file_path +promptable_models_list_file_path = segmenters_promptable_list_file_path favourite_func_metrics_csv_path = os.path.join( settings_folderpath, 'favourite_func_metrics.csv' ) diff --git a/cellacdc/_run.py b/cellacdc/_run.py index 3e615cf9c..027334eb7 100644 --- a/cellacdc/_run.py +++ b/cellacdc/_run.py @@ -369,7 +369,7 @@ def download_model_params(): if parser_args['StarDistModelsDownload'] or parser_args['AllModelsDownload']: print('[INFO]: Downloading StarDist models...') try: - from cellacdc.models import STARDIST_MODELS + from cellacdc.segmenters import STARDIST_MODELS from stardist.models import StarDist2D, StarDist3D for model_type in [StarDist2D, StarDist3D]: for model_name in STARDIST_MODELS: diff --git a/cellacdc/core.py b/cellacdc/core.py index 80ae1df0e..7044d0144 100755 --- a/cellacdc/core.py +++ b/cellacdc/core.py @@ -2167,7 +2167,7 @@ def cellpose_v3_run_denoise( isZstack=False ): if denoise_model is None: - from cellacdc.models.cellpose_v3 import _denoise + from cellacdc.segmenters.cellpose_v3 import _denoise denoise_model = _denoise.CellposeDenoiseModel(**init_params) denoised_img = denoise_model.run(image, timelapse=timelapse,isZstack=isZstack, **run_params)# may have to give rgb stuff too! diff --git a/cellacdc/docs/source/models.rst b/cellacdc/docs/source/models.rst index 8396f3b97..1e31bb208 100644 --- a/cellacdc/docs/source/models.rst +++ b/cellacdc/docs/source/models.rst @@ -41,7 +41,7 @@ Adding a segmentation model Adding a segmentation model in a few steps: -1. Create a **new folder** with the models's name (e.g., YeastMate) inside the ``/cellacdc/models`` folder. +1. Create a **new folder** with the models's name (e.g., YeastMate) inside the ``/cellacdc/segmenters`` folder. .. tip:: If you **don't know where Cell-ACDC was installed**, open the main launcher and click on the ``Help --> About Cell-ACDC`` menu on the top menu bar. @@ -90,7 +90,7 @@ Adding a segmentation model in a few steps: The **model parameters** will be **automatically inferred from the class you created** in the ``acdcSegment.py`` file, and a widget with those parameters will pop-up. In this widget you can set the model parameters (or press Ok without changing anything if you want to go with default parameters). -Have a loot at the ``/cellacdc/models`` folder `here `__ for **examples**. You can for example see the ``__init__.py`` file `here `__ and the ``acdcSegment.py`` file `here `__ for YeaZ2. +Have a loot at the ``/cellacdc/segmenters`` folder `here `__ for **examples**. You can for example see the ``__init__.py`` file `here `__ and the ``acdcSegment.py`` file `here `__ for YeaZ2. Adding a tracker diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 7247f462e..e04835c12 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -10,14 +10,11 @@ import inspect import logging import uuid -import json from collections import defaultdict, Counter -import psutil import zipfile from functools import partial -from tqdm import tqdm from natsort import natsorted -from typing import Literal, Iterable, Dict, Any, List, Union, Tuple, Set +from typing import Literal, Iterable, Dict, Any, List, Union, Set import time import cv2 @@ -25,12 +22,10 @@ import numpy as np import pandas as pd import matplotlib -import scipy.optimize import scipy.interpolate import scipy.ndimage import skimage import skimage.io -import skimage.measure import skimage.morphology import skimage.draw import skimage.exposure @@ -42,12 +37,12 @@ from qtpy.QtCore import ( Qt, QPoint, QTextStream, QSize, QRect, QRectF, - QEventLoop, QTimer, QEvent, QObject, Signal, + QEventLoop, QTimer, QEvent, Signal, QThread, QMutex, QWaitCondition, QSettings, PYQT6 ) from qtpy.QtGui import ( - QIcon, QKeySequence, QCursor, QGuiApplication, QPixmap, QColor, - QFont, QKeyEvent, QMouseEvent + QIcon, QCursor, QGuiApplication, QColor, + QFont, QMouseEvent ) from qtpy.QtWidgets import ( QAction, QLabel, QPushButton, QHBoxLayout, QSizePolicy, @@ -64,42 +59,31 @@ simplefilter(action="ignore", category=pd.errors.PerformanceWarning) # Custom modules -from . import exception_handler, disableWindow -from . import base_cca_dict, lineage_tree_cols, lineage_tree_cols_std_val +from . import ( + base_cca_dict, +) from . import graphLayoutBkgrColor, darkBkgrColor from . import cca_df_colnames from . import load, prompts, apps, workers, html_utils from . import core, myutils, dataPrep, widgets -from . import _warnings, issues_url +from . import _warnings from . import measurements, printl from . import colors, annotate from . import user_manual_url -from . import recentPaths_path, settings_folderpath, settings_csv_path +from . import settings_folderpath, settings_csv_path from . import favourite_func_metrics_csv_path from . import qutils, autopilot, QtScoped -from . import _palettes -from . import transformation -from . import measure -from . import cca_functions from . import data_structure_docs_url from . import exporters -from . import preprocess from . import io from . import whitelist from . import cli -from . import is_mac from .trackers.CellACDC import CellACDC_tracker -from .cca_functions import _calc_rot_vol from .myutils import exec_time, setupLogger, ArgSpec -from .help import welcome, about -from .trackers.CellACDC_normal_division.CellACDC_normal_division_tracker import ( - normal_division_lineage_tree)#, reorg_sister_cells_for_export) from . import debugutils - from .plot import imshow from . import gui_utils -from . import gui_combine np.seterr(invalid='ignore') @@ -112,11 +96,6 @@ except Exception as e: pass -GREEN_HEX = _palettes.green() - -custom_annot_path = os.path.join(settings_folderpath, 'custom_annotations.json') -shortcut_filepath = os.path.join(settings_folderpath, 'shortcuts.ini') - _font = QFont() _font.setPixelSize(11) @@ -129,80 +108,10 @@ SliderPageStepSub = QtScoped.SliderPageStepSub() SliderMove = QtScoped.SliderMove() -def qt_debug_trace(): - from qtpy.QtCore import pyqtRemoveInputHook - pyqtRemoveInputHook() - import pdb; pdb.set_trace() +from .viewmodels import MainGuiViewModel -def get_data_exception_handler(func): - @wraps(func) - def inner_function(self, *args, **kwargs): - try: - if func.__code__.co_argcount==1 and func.__defaults__ is None: - result = func(self) - elif func.__code__.co_argcount>1 and func.__defaults__ is None: - result = func(self, *args) - else: - result = func(self, *args, **kwargs) - except Exception as e: - try: - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - except AttributeError: - pass - result = None - posData = self.data[self.pos_i] - acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) - segm_filename = os.path.basename(posData.segm_npz_path) - traceback_str = traceback.format_exc() - self.logger.exception(traceback_str) - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...') - msg.setDetailedText(traceback_str) - err_msg = html_utils.paragraph(f""" - Error in function {func.__name__}.

- One possbile explanation is that either the - {acdc_df_filename} file
- or the segmentation file {segm_filename}
- are being synchronized by a cloud service (e.g., Google Drive - or OneDrive) or they are corrupted/damaged.

- Try moving these files (one by one) outside of the - {os.path.dirname(posData.relPath)} folder -
and reloading the data.

- More details below or in the terminal/console.

- Note that the error details from this session are - also saved in the following file:

- {self.log_path}

- Please send the log file when reporting a bug, thanks! - Please restart Cell-ACDC, we apologise for any inconvenience.

- """) - - msg.critical(self, 'Critical error', err_msg) - self.is_error_state = True - raise e - return result - return inner_function - -def resetViewRange(func): - @wraps(func) - def inner_function(self, *args, **kwargs): - self.storeViewRange() - if func.__code__.co_argcount==1 and func.__defaults__ is None: - result = func(self) - elif func.__code__.co_argcount>1 and func.__defaults__ is None: - result = func(self, *args) - else: - result = func(self, *args, **kwargs) - QTimer.singleShot(200, self.resetRange) - return result - return inner_function - -class guiWin(QMainWindow, whitelist.WhitelistGUIElements, - gui_combine.CombineGuiElements, - gui_combine.CombineGUIWorker): +class guiWin(QMainWindow): """Main Window.""" sigClosed = Signal(object) @@ -229,7 +138,239 @@ def __init__( self.mainWin = mainWin self.app = app self.closeGUI = False - self._acdc_version = myutils.read_version() + self.view_model = MainGuiViewModel() + self.window_events_view = WindowEventsView( + self, + self.view_model.window_events, + ) + self.window_events_view.bind_legacy_methods() + self.tracking_view = TrackingView( + self, + self.view_model.tracking, + ) + self.tracking_view.bind_legacy_methods() + self.image_display_view = ImageDisplayView( + self, + self.view_model.image_display, + ) + self.image_display_view.bind_legacy_methods() + self.data_loading_view = DataLoadingView( + self, + self.view_model.data_loading, + ) + self.data_loading_view.bind_legacy_methods() + self.cell_cycle_view = CellCycleView( + self, + self.view_model.cell_cycle, + ) + self.cell_cycle_view.bind_legacy_methods() + self.graphics_view = GraphicsView( + self, + self.view_model.graphics, + ) + self.graphics_view.bind_legacy_methods() + self.actions_view = ActionsView( + self, + self.view_model.actions, + ) + self.actions_view.bind_legacy_methods() + self.app_shell_view = AppShellView( + self, + self.view_model.app_shell, + ) + self.app_shell_view.bind_legacy_methods() + self.annotation_display_view = AnnotationDisplayView( + self, + self.view_model.annotation_display, + ) + self.annotation_display_view.bind_legacy_methods() + self.session_view = SessionView( + self, + self.view_model.session, + ) + self.session_view.bind_legacy_methods() + self.frame_navigation_view = FrameNavigationView( + self, + self.view_model.frame_navigation, + ) + self.frame_navigation_view.bind_legacy_methods() + self.canvas_drawing_view = CanvasDrawingView( + self, + self.view_model.canvas_drawing, + ) + self.canvas_drawing_view.bind_legacy_methods() + self.canvas_events_view = CanvasEventsView( + self, + self.view_model.canvas_events, + ) + self.canvas_events_view.bind_legacy_methods() + self.canvas_selection_view = CanvasSelectionView( + self, + self.view_model.canvas_selection, + ) + self.canvas_selection_view.bind_legacy_methods() + self.canvas_context_menu_view = CanvasContextMenuView( + self, + self.view_model.canvas_context_menu, + ) + self.canvas_right_image_view = CanvasRightImageView( + self, + self.view_model.canvas_right_image, + ) + self.canvas_hover_view = CanvasHoverView( + self, + self.view_model.canvas_hover, + ) + self.canvas_hover_view.bind_legacy_methods() + self.label_roi_view = LabelRoiView( + self, + self.view_model.label_roi, + ) + self.label_roi_view.bind_legacy_methods() + self.label_editing_view = LabelEditingView( + self, + self.view_model.label_editing, + ) + self.label_editing_view.bind_legacy_methods() + self.lineage_interactions_view = LineageInteractionsView( + self, + self.view_model.lineage_interactions, + ) + self.lineage_interactions_view.bind_legacy_methods() + self.custom_annotations_view = CustomAnnotationsView( + self, + self.view_model.custom_annotations, + ) + self.undo_redo_view = UndoRedoView( + self, + self.view_model.undo_redo, + ) + self.undo_redo_view.bind_legacy_methods() + self.worker_view = WorkerView( + self, + self.view_model.worker, + ) + self.worker_view.bind_legacy_methods() + self.brush_tools_view = BrushToolsView( + self, + self.view_model.brush_tools, + ) + self.brush_tools_view.bind_legacy_methods() + self.deleted_rois_view = DeletedRoisView( + self, + self.view_model.deleted_rois, + ) + self.deleted_rois_view.bind_legacy_methods() + self.draw_clear_region_view = DrawClearRegionView( + self, + self.view_model.draw_clear_region, + ) + self.display_decorations_view = DisplayDecorationsView( + self, + self.view_model.display_decorations, + ) + self.object_cleanup_view = ObjectCleanupView( + self, + self.view_model.object_cleanup, + ) + self.object_properties_view = ObjectPropertiesView( + self, + self.view_model.object_properties, + ) + self.object_properties_view.bind_legacy_methods() + self.object_search_view = ObjectSearchView( + self, + self.view_model.object_search, + ) + self.curvature_tools_view = CurvatureToolsView( + self, + self.view_model.curvature, + ) + self.seg_for_lost_ids_view = SegForLostIdsView( + self, + self.view_model.seg_for_lost_ids, + ) + self.segmentation_view = SegmentationView( + self, + self.view_model.segmentation, + ) + self.segmentation_view.bind_legacy_methods() + self.saving_view = SavingView( + self, + self.view_model.saving, + ) + self.saving_view.bind_legacy_methods() + self.mode_controls_view = ModeControlsView( + self, + self.view_model.mode_controls, + ) + self.image_controls_view = ImageControlsView( + self, + self.view_model.image_controls, + ) + self.preprocessing_view = PreprocessingView( + self, + self.view_model.preprocessing, + ) + self.magic_prompts_view = MagicPromptsView( + self, + self.view_model.magic_prompts, + ) + self.exporting_view = ExportingView( + self, + self.view_model.exporting, + ) + self.main_toolbar_view = MainToolbarView( + self, + self.view_model.main_toolbar, + ) + self.main_menu_view = MainMenuView( + self, + self.view_model.main_menu, + ) + self.label_transform_tools_view = LabelTransformToolsView( + self, + self.view_model.label_transform_tools, + ) + self.measurements_view = MeasurementsView( + self, + self.view_model.measurements, + ) + self.quick_settings_view = QuickSettingsView( + self, + self.view_model.quick_settings, + ) + self.status_hover_view = StatusHoverView( + self, + self.view_model.status_hover, + ) + self.points_layers_view = PointsLayersView( + self, + self.view_model.points_layers, + ) + self.points_layers_view.bind_legacy_methods() + self.tool_activation_view = ToolActivationView( + self, + self.view_model.tool_activation, + ) + self.tool_activation_view.bind_legacy_methods() + self.layout_controls_view = LayoutControlsView( + self, + self.view_model.layout_controls, + ) + self.layout_controls_view.bind_legacy_methods() + self.combine_view = CombineView( + self, + self.view_model.combine, + ) + self.combine_view.bind_legacy_methods() + self.whitelist_view = WhitelistView( + self, + self.view_model.whitelist, + ) + self.whitelist_view.bind_legacy_methods() + self.canvas_tool_view = CanvasToolView(self.view_model.canvas_tools) + self._acdc_version = self.view_model.app_shell.read_version() self.setAcceptDrops(True) self._appName = 'Cell-ACDC' @@ -240,29 +381,6 @@ def __init__( self.original_df_lin_tree = None self.original_df_lin_tree_i = None - def setTooltips(self): - tooltips = load.get_tooltips_from_docs() - - for key, tooltip in tooltips.items(): - setShortcut = getattr(self, key).shortcut().toString() - if 'Shortcut: ' in tooltip: - tooltip = tooltip.replace('Shortcut: ', '\nShortcut: ') - elif setShortcut != "": - tooltip = re.sub( - r'Shortcut: \"(.*)\"', - f"Shortcut: \"{setShortcut}\"", - tooltip - ) - else: - tooltip = re.sub( - r'Shortcut: \"(.*)\"', - f"Shortcut: \"No shortcut\"", - tooltip - ) - - getattr(self, key).setToolTip(tooltip) - getattr(self, key)._tooltip = tooltip - def run(self, module='acdc_gui', logs_path=None): self.setWindowIcon() self.setWindowTitle() @@ -374,13 +492,13 @@ def run(self, module='acdc_gui', logs_path=None): self.gui_createCursors() self.gui_createActions() - self.gui_createMenuBar() + self.main_menu_view.create_menu_bar() - self.gui_createToolBars() + self.main_toolbar_view.gui_createToolBars() self.gui_createControlsToolbar() - self.gui_createShowPropsButton() + self.quick_settings_view.create_show_props_button() self.gui_createRegionPropsDockWidget() - self.gui_createQuickSettingsWidgets() + self.quick_settings_view.create_widgets() self.setTooltips() self.gui_populateToolSettingsMenu() @@ -391,12 +509,12 @@ def run(self, module='acdc_gui', logs_path=None): self.gui_createStatusBar() # self.gui_createTerminalWidget() - self.gui_createGraphicsPlots() + self.image_controls_view.gui_createGraphicsPlots() self.gui_addGraphicsItems() - self.gui_createImg1Widgets() - self.gui_createLabWidgets() - self.gui_createBottomWidgetsToBottomLayout() + self.image_controls_view.gui_createImg1Widgets() + self.image_controls_view.gui_createLabWidgets() + self.image_controls_view.gui_createBottomWidgetsToBottomLayout() mainContainer = QWidget() self.setCentralWidget(mainContainer) @@ -414,32794 +532,58 @@ def run(self, module='acdc_gui', logs_path=None): self.show() QTimer.singleShot(100, self.resizeRangeWelcomeText) # self.installEventFilter(self) - - self.logger.info('GUI ready.') - - def initGlobalAttr(self): - self.setOverlayColors() - - self.initImgCmap() - - # Colormap - self.setLut() - - self.fluoDataChNameActions = [] - - self.splineHoverON = False - self.tempSegmentON = False - self.xyOnCtrlPressedFirstTime = None - self.typingEditID = False - self.prevAnnotOptions = None - self.ghostObject = None - self.autoContourHoverON = False - self.navigateScrollBarStartedMoving = True - self.zSliceScrollBarStartedMoving = True - self.labelRoiRunning = False - self.isRangeReset = True - self.lastManualSeparateState = None - self.editIDmergeIDs = True - self.doNotAskAgainExistingID = False - self.doubleRightClickTimeElapsed = False - self.isRealTimeTrackerInitialized = False - self.isWarningCcaIntegrity = False - self.isDoubleRightClick = False - self.isExportingVideo = False - self.pointsLayersNeverToggled = True - self.highlightedIDopts = None - self.timestampStartTimedelta = timedelta(seconds=0) - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - self._ZprojWidgersEnabledState = None - self.imgValueFormatter = 'd' - self.rawValueFormatter = 'd' - self.lastHoverID = -1 - self.annotOptionsToRestore = None - self.annotOptionsToRestoreRight = None - self.rescaleIntensChannelHowMapper = { - self.user_ch_name: 'Rescale each 2D image' - } - self.timestampDialog = None - self.scaleBarDialog = None - self.countObjsWindow = None - self.initLabelRoiModelDialog = None - - # Second channel used by cellpose - self.secondChannelName = None - - self.ax1_viewRange = None - self.measurementsWin = None - - self.model_kwargs = None - self.segmModelName = None - self.labelRoiModel = None - self.autoSegmDoNotAskAgain = False - self.labelRoiGarbageWorkers = [] - self.labelRoiActiveWorkers = [] - - self.clickedOnBud = False - self.postProcessSegmWin = None - - self.UserEnforced_DisabledTracking = False - self.UserEnforced_Tracking = False - - self.ax1BrushHoverID = 0 - - self.disabled_cca_warnings = set() - - self.last_pos_i = -1 - self.last_frame_i = -1 - - # Plots items - self.isMouseDragImg2 = False - self.isMouseDragImg1 = False - self.isMovingLabel = False - self.isRightClickDragImg1 = False - self.clickObjYc, self.clickObjXc = None, None - - self.cca_df_colnames = cca_df_colnames - self.cca_df_dtypes = [ - str, int, int, str, int, int, bool, bool, int - ] - self.cca_df_default_values = list(base_cca_dict.values()) - self.cca_df_int_cols = [ - col for col in cca_df_colnames if type(base_cca_dict[col]) == int - ] - self.lin_tree_df_bool_col = [ - col for col in cca_df_colnames - if isinstance(base_cca_dict[col], bool) - ] - - self.lin_tree_col_checks = [ - 'generation_num', - ] - # self.lin_tree_df_colnames = set(base_cca_df.keys()) | set(lineage_tree_cols) - # # self.lin_tree_df_dtypes = [ #dk if i need this, for now ignored - # # str, int, int, str, int, int, bool, bool, int - # # ] - # self.lin_tree_df_default_values = list(base_cca_df.values()) + lineage_tree_cols_std_val - self.lin_tree_df_int_cols = [ - 'generation_num', - 'relative_ID', - 'emerg_frame_i', - 'division_frame_i', - 'corrected_on_frame_i' - ] - self.lin_tree_df_bool_col = [ - 'is_history_known', - ] - - self.lin_tree_col_checks = [ - 'generation_num', - ] - - self.lin_tree_df_colnames = self.lin_tree_df_int_cols + self.lin_tree_df_bool_col + self.lin_tree_col_checks - self.SegForLostIDsSettings = {} - - def setWindowIcon(self, icon=None): - if icon is None: - icon = QIcon(":icon.ico") - super().setWindowIcon(icon) - - def setWindowTitle(self, title=None): - if title is None: - title = f'Cell-ACDC v{self._acdc_version} - GUI' - super().setWindowTitle(title) - - def initProfileModels(self): - self.logger.info('Initiliazing profilers...') - - from ._profile.spline_to_obj import model - - self.splineToObjModel = model.Model() - - self.splineToObjModel.fit() - - def setDisabled(self, disabled:bool, keepDisabled:bool=None, force:bool=False): - if force: - if disabled: - super().setDisabled(disabled) - return - else: - self.keepDisabled = False - super().setDisabled(disabled) - return - - if keepDisabled is not None: - self.keepDisabled = keepDisabled - - if self.keepDisabled: - if disabled: - super().setDisabled(disabled) - return - else: - return - else: - super().setDisabled(disabled) - - def readRecentPaths(self, recent_paths_path=None): - # Step 0. Remove the old options from the menu - self.openRecentMenu.clear() - - # Step 1. Read recent Paths - if recent_paths_path is None: - recent_paths_path = recentPaths_path - - if os.path.exists(recent_paths_path): - df = pd.read_csv(recent_paths_path, index_col='index') - df['path'] = df['path'].str.replace('\\', '/') - df = df.drop_duplicates(subset=['path']) - df.to_csv(recent_paths_path) - if 'opened_last_on' in df.columns: - df = df.sort_values('opened_last_on', ascending=False) - recentPaths = df['path'].to_list() - else: - recentPaths = [] - - # Step 2. Dynamically create the actions - actions = [] - for path in recentPaths: - if not os.path.exists(path): - continue - action = QAction(path, self) - action.triggered.connect(partial(self.openRecentFile, path)) - actions.append(action) - - # Step 3. Add the actions to the menu - self.openRecentMenu.addActions(actions) - - def addPathToOpenRecentMenu(self, path): - for action in self.openRecentMenu.actions(): - if path == action.text(): - break - else: - action = QAction(path, self) - action.triggered.connect(partial(self.openRecentFile, path)) - - try: - firstAction = self.openRecentMenu.actions()[0] - self.openRecentMenu.insertAction(firstAction, action) - except Exception as e: - pass - - def loadLastSessionSettings(self): - self.settings_csv_path = settings_csv_path - if os.path.exists(settings_csv_path): - self.df_settings = pd.read_csv( - settings_csv_path, index_col='setting' - ) - if 'is_bw_inverted' not in self.df_settings.index: - self.df_settings.at['is_bw_inverted', 'value'] = 'No' - else: - self.df_settings.loc['is_bw_inverted'] = ( - self.df_settings.loc['is_bw_inverted'].astype(str) - ) - if 'fontSize' not in self.df_settings.index: - self.df_settings.at['fontSize', 'value'] = 12 - if 'overlayColor' not in self.df_settings.index: - self.df_settings.at['overlayColor', 'value'] = '255-255-0' - if 'how_normIntensities' not in self.df_settings.index: - raw = 'Do not normalize. Display raw image' - self.df_settings.at['how_normIntensities', 'value'] = raw - else: - idx = ['is_bw_inverted', 'fontSize', 'overlayColor', 'how_normIntensities'] - values = ['No', 12, '255-255-0', 'raw'] - self.df_settings = pd.DataFrame({ - 'setting': idx,'value': values} - ).set_index('setting') - - if 'isLabelsVisible' not in self.df_settings.index: - self.df_settings.at['isLabelsVisible', 'value'] = 'No' - - if 'isNextFrameVisible' not in self.df_settings.index: - self.df_settings.at['isNextFrameVisible', 'value'] = 'No' - - if 'isRightImageVisible' not in self.df_settings.index: - self.df_settings.at['isRightImageVisible', 'value'] = 'Yes' - - if 'manual_separate_draw_mode' not in self.df_settings.index: - col = 'manual_separate_draw_mode' - self.df_settings.at[col, 'value'] = 'threepoints_arc' - - if 'colorScheme' in self.df_settings.index: - col = 'colorScheme' - self._colorScheme = self.df_settings.at[col, 'value'] - else: - self._colorScheme = 'light' - - self.doNotShowAgainMissingCca = False - if 'doNotShowAgainMissingCca' not in self.df_settings.index: - self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'No' - else: - val = self.df_settings.at['doNotShowAgainMissingCca', 'value'] - self.doNotShowAgainMissingCca = val=='Yes' - - def dragEnterEvent(self, event): - file_path = event.mimeData().urls()[0].toLocalFile() - if os.path.isdir(file_path): - exp_path = file_path - basename = os.path.basename(file_path) - if basename.find('Position_')!=-1 or basename=='Images': - event.acceptProposedAction() - else: - event.ignore() - else: - event.acceptProposedAction() - - def dropEvent(self, event): - event.setDropAction(Qt.CopyAction) - file_path = event.mimeData().urls()[0].toLocalFile() - self.logger.info(f'Dragged and dropped path "{file_path}"') - basename = os.path.basename(file_path) - if os.path.isdir(file_path): - exp_path = file_path - self.openFolder(exp_path=exp_path) - else: - self.openFile(file_path=file_path) - - def changeEvent(self, event): - try: - self.delObjToolAction.setChecked(False) - except Exception as err: - return - - def leaveEvent(self, event): - if self.slideshowWin is not None: - posData = self.data[self.pos_i] - mainWinGeometry = self.geometry() - mainWinLeft = mainWinGeometry.left() - mainWinTop = mainWinGeometry.top() - mainWinWidth = mainWinGeometry.width() - mainWinHeight = mainWinGeometry.height() - mainWinRight = mainWinLeft+mainWinWidth - mainWinBottom = mainWinTop+mainWinHeight - - slideshowWinGeometry = self.slideshowWin.geometry() - slideshowWinLeft = slideshowWinGeometry.left() - slideshowWinTop = slideshowWinGeometry.top() - slideshowWinWidth = slideshowWinGeometry.width() - slideshowWinHeight = slideshowWinGeometry.height() - - # Determine if overlap - overlap = ( - (slideshowWinTop < mainWinBottom) and - (slideshowWinLeft < mainWinRight) - ) - - autoActivate = ( - self.isDataLoaded and not - overlap and not - posData.disableAutoActivateViewerWindow - ) - - if autoActivate: - self.slideshowWin.setFocus() - self.slideshowWin.activateWindow() - - def enterEvent(self, event): - event.accept() - if self.slideshowWin is not None: - posData = self.data[self.pos_i] - mainWinGeometry = self.geometry() - mainWinLeft = mainWinGeometry.left() - mainWinTop = mainWinGeometry.top() - mainWinWidth = mainWinGeometry.width() - mainWinHeight = mainWinGeometry.height() - mainWinRight = mainWinLeft+mainWinWidth - mainWinBottom = mainWinTop+mainWinHeight - - slideshowWinGeometry = self.slideshowWin.geometry() - slideshowWinLeft = slideshowWinGeometry.left() - slideshowWinTop = slideshowWinGeometry.top() - slideshowWinWidth = slideshowWinGeometry.width() - slideshowWinHeight = slideshowWinGeometry.height() - - # Determine if overlap - overlap = ( - (slideshowWinTop < mainWinBottom) and - (slideshowWinLeft < mainWinRight) - ) - - autoActivate = ( - self.isDataLoaded and not - overlap and not - posData.disableAutoActivateViewerWindow - ) - - if autoActivate: - # self.setFocus() - self.activateWindow() - - def isPanImageClick(self, mouseEvent, modifiers): - left_click = mouseEvent.button() == Qt.MouseButton.LeftButton - return modifiers == Qt.AltModifier and left_click - - def middleClickText(self): - if self.delObjAction is None and is_mac: - return 'Command + Left Click' - - if self.delObjAction is None: - return 'Middle Click' - - delObjKeySequence, delObjQtButton = self.delObjAction - - if delObjQtButton == Qt.MouseButton.LeftButton: - buttonName = 'Left click' - elif delObjQtButton == Qt.MouseButton.RightButton: - buttonName = 'Right click' - else: - buttonName = 'Middle click' - - if delObjKeySequence is None: - return buttonName - - return f'{delObjKeySequence.toString()} + {buttonName}' - - def isDefaultMiddleClick(self, mouseEvent, modifiers): - if is_mac: - middle_click = ( - mouseEvent.button() == Qt.MouseButton.LeftButton - and modifiers == Qt.ControlModifier - and not self.brushButton.isChecked() - ) - else: - middle_click = mouseEvent.button() == Qt.MouseButton.MiddleButton - return middle_click - - def isMiddleClick(self, mouseEvent, modifiers): - if self.delObjAction is None: - return self.isDefaultMiddleClick(mouseEvent, modifiers) - - delObjKeySequence, delObjQtButton = self.delObjAction - if delObjKeySequence is None: - # Setting only middle click on mac is allowed, however the - # delObjKeySequence is None and the tool button is never checked - isDelObjectActive = True - else: - isDelObjectActive = self.delObjToolAction.isChecked() - - mouseEventButton = self.changeRightClickToLeftOnMac(mouseEvent) - - middle_click = ( - mouseEventButton == delObjQtButton and isDelObjectActive - ) - - return middle_click - - def gui_createCursors(self): - pixmap = QPixmap(":wand_cursor.svg") - self.wandCursor = QCursor(pixmap, 16, 16) - - pixmap = QPixmap(":curv_cursor.svg") - self.curvCursor = QCursor(pixmap, 16, 16) - - pixmap = QPixmap(":addDelPolyLineRoi_cursor.svg") - self.polyLineRoiCursor = QCursor(pixmap, 16, 16) - - pixmap = QPixmap(":cross_cursor.svg") - self.addPointsCursor = QCursor(pixmap, 16, 16) - - def gui_createMenuBar(self): - menuBar = self.menuBar() - menuBar.setNativeMenuBar(False) - - # File menu - fileMenu = QMenu("&File", self) - self.fileMenu = fileMenu - menuBar.addMenu(fileMenu) - if self.debug: - fileMenu.addAction(self.createEmptyDataAction) - fileMenu.addAction(self.newAction) - fileMenu.addAction(self.newWindowAction) - fileMenu.addSeparator() - fileMenu.addAction(self.openFolderAction) - fileMenu.addAction(self.openFileAction) - # Open Recent submenu - self.openRecentMenu = fileMenu.addMenu("Open Recent") - fileMenu.addSeparator() - fileMenu.addAction(self.manageVersionsAction) - fileMenu.addAction(self.saveAction) - fileMenu.addAction(self.saveAsAction) - fileMenu.addAction(self.quickSaveAction) - fileMenu.addSeparator() - - self.exportMenu = fileMenu.addMenu('Export') - self.exportMenu.addAction(self.exportToVideoAction) - self.exportMenu.addAction(self.exportToImageAction) - fileMenu.addSeparator() - fileMenu.addAction(self.loadFluoAction) - fileMenu.addAction(self.loadPosAction) - # Separator - self.fileMenu.lastSeparator = fileMenu.addSeparator() - fileMenu.addAction(self.exitAction) - - # Edit menu - editMenu = menuBar.addMenu("&Edit") - editMenu.addSeparator() - - editMenu.addAction(self.editShortcutsAction) - editMenu.addAction(self.editTextIDsColorAction) - editMenu.addAction(self.editOverlayColorAction) - editMenu.addAction(self.manuallyEditCcaAction) - editMenu.addAction(self.enableSmartTrackAction) - editMenu.addAction(self.enableAutoZoomToCellsAction) - - # View menu - self.viewMenu = menuBar.addMenu("&View") - self.viewMenu.addSeparator() - self.viewMenu.addAction(self.viewCcaTableAction) - - # Image menu - ImageMenu = menuBar.addMenu("&Image") - ImageMenu.addSeparator() - ImageMenu.addAction(self.imgPropertiesAction) - self.defaultRescaleIntensLutMenu = ImageMenu.addMenu( - "Default method to rescale intensities (LUT)" - ) - - self.defaultRescaleIntensActionGroup = QActionGroup( - self.defaultRescaleIntensLutMenu - ) - howTexts = ( - 'Rescale each 2D image', - 'Rescale across z-stack', - 'Rescale across time frames', - 'Do no rescale, display raw image' - ) - try: - self.defaultRescaleIntensHow = ( - self.df_settings.at['default_rescale_intens_how', 'value'] - ) - except Exception as err: - self.defaultRescaleIntensHow = howTexts[0] - - for howText in howTexts: - action = QAction(howText, self.defaultRescaleIntensLutMenu) - action.setCheckable(True) - if howText == self.defaultRescaleIntensHow: - action.setChecked(True) - - self.defaultRescaleIntensActionGroup.addAction(action) - self.defaultRescaleIntensLutMenu.addAction(action) - - ImageMenu.addAction(self.addScaleBarAction) - ImageMenu.addAction(self.addTimestampAction) - - self.rescaleIntensMenu = ImageMenu.addMenu('Rescale intensities (LUT)') - - ImageMenu.addAction(self.preprocessAction) - ImageMenu.addAction(self.combineChannelsAction) - ImageMenu.addAction(self.saveLabColormapAction) - ImageMenu.addAction(self.shuffleCmapAction) - ImageMenu.addAction(self.greedyShuffleCmapAction) - ImageMenu.addAction(self.zoomToObjsAction) - ImageMenu.addAction(self.zoomOutAction) - - # Segment menu - SegmMenu = menuBar.addMenu("&Segment") - self.segmentMenu = SegmMenu - SegmMenu.addSeparator() - self.segmSingleFrameMenu = SegmMenu.addMenu('Segment displayed frame') - for action in self.segmActions: - self.segmSingleFrameMenu.addAction(action) - - self.segmSingleFrameMenu.addSeparator() - self.segmSingleFrameMenu.addAction(self.addCustomModelFrameAction) - - self.segmVideoMenu = SegmMenu.addMenu('Segment multiple frames') - for action in self.segmActionsVideo: - self.segmVideoMenu.addAction(action) - - self.segmVideoMenu.addSeparator() - self.segmVideoMenu.addAction(self.addCustomModelVideoAction) - - self.segmWithPromptableModelMenu = SegmMenu.addMenu( - 'Segment with promptable model' - ) - - self.segmWithPromptableModelMenu.addAction( - self.segmWithPromptableModelAction - ) - - self.segmWithPromptableModelMenu.addSeparator() - self.segmWithPromptableModelMenu.addAction( - self.addCustomPromptModelAction - ) - - SegmMenu.addAction(self.EditSegForLostIDsSetSettings) - SegmMenu.addAction(self.postProcessSegmAction) - SegmMenu.addAction(self.autoSegmAction) - SegmMenu.addAction(self.relabelSequentialAction) - SegmMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) - - # Tracking menu - trackingMenu = menuBar.addMenu("&Tracking") - self.trackingMenu = trackingMenu - trackingMenu.addSeparator() - selectTrackAlgoMenu = trackingMenu.addMenu( - 'Select real-time tracking algorithm' - ) - for rtTrackerAction in self.trackingAlgosGroup.actions(): - selectTrackAlgoMenu.addAction(rtTrackerAction) - - trackingMenu.addAction(self.editRtTrackerParamsAction) - trackingMenu.addAction(self.repeatTrackingVideoAction) - - trackingMenu.addAction(self.repeatTrackingMenuAction) - trackingMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) - - if self.mainWin is not None: - trackingMenu.addAction( - self.mainWin.applyTrackingFromTableAction - ) - trackingMenu.addAction( - self.mainWin.applyTrackingFromTrackMateXMLAction - ) - - # Measurements menu - measurementsMenu = menuBar.addMenu("&Measurements") - self.measurementsMenu = measurementsMenu - measurementsMenu.addSeparator() - measurementsMenu.addAction(self.setMeasurementsAction) - measurementsMenu.addAction(self.addCustomMetricAction) - measurementsMenu.addAction(self.addCombineMetricAction) - measurementsMenu.setDisabled(True) - - # Settings menu - self.settingsMenu = QMenu("Settings", self) - menuBar.addMenu(self.settingsMenu) - self.settingsMenu.addAction(self.invertBwAction) - self.settingsMenu.addAction(self.toggleColorSchemeAction) - self.settingsMenu.addSeparator() - self.settingsMenu.addAction(self.pxModeAction) - self.settingsMenu.addAction(self.highLowResAction) - self.settingsMenu.addAction(self.editShortcutsAction) - self.settingsMenu.addAction(self.showMirroredCursorAction) - self.settingsMenu.addSeparator() - self.settingsMenu.addAction(self.editAutoSaveIntervalAction) - self.settingsMenu.addSeparator() - - # Mode menu (actions added when self.modeComboBox is created) - self.modeMenu = menuBar.addMenu('Mode') - self.modeMenu.menuAction().setVisible(False) - - # Help menu - helpMenu = menuBar.addMenu("&Help") - helpMenu.addAction(self.openLogFileAction) - helpMenu.addAction(self.showLogFilesAction) - helpMenu.addAction(self.tipsAction) - helpMenu.addAction(self.UserManualAction) - helpMenu.addSeparator() - helpMenu.addAction(self.aboutAction) - self.helpMenu = helpMenu - - def gui_createToolBars(self): - # File toolbar - fileToolBar = self.addToolBar("File") - # fileToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) - fileToolBar.setMovable(False) - - self.segmNdimIndicatorAction = fileToolBar.addWidget( - self.segmNdimIndicator - ) - self.segmNdimIndicatorAction.setVisible(False) - fileToolBar.addAction(self.newAction) - fileToolBar.addAction(self.openFolderAction) - fileToolBar.addAction(self.openFileAction) - fileToolBar.addAction(self.manageVersionsAction) - fileToolBar.addAction(self.saveAction) - fileToolBar.addAction(self.showInExplorerAction) - # fileToolBar.addAction(self.reloadAction) - fileToolBar.addAction(self.undoAction) - fileToolBar.addAction(self.redoAction) - self.fileToolBar = fileToolBar - self.setEnabledFileToolbar(False) - - self.undoAction.setEnabled(False) - self.redoAction.setEnabled(False) - - # Navigation toolbar - navigateToolBar = widgets.ToolBar("Navigation", self) - navigateToolBar.setContextMenuPolicy(Qt.PreventContextMenu) - # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) - self.addToolBar(navigateToolBar) - navigateToolBar.addAction(self.findIdAction) - - navigateToolBar.addWidget(self.zoomRectButton) - - self.slideshowButton = QToolButton(self) - self.slideshowButton.setIcon(QIcon(":eye-plus.svg")) - self.slideshowButton.setCheckable(True) - self.slideshowButton.setShortcut('Ctrl+W') - navigateToolBar.addWidget(self.slideshowButton) - - navigateToolBar.addAction(self.autoPilotButton) - - # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) - navigateToolBar.addAction(self.skipToNewIdAction) - - self.preprocessImageAction = QAction('Preprocess image', self) - self.preprocessImageAction.setIcon(QIcon(":filter_image.svg")) - navigateToolBar.addAction(self.preprocessImageAction) - - self.overlayButton = widgets.rightClickToolButton(parent=self) - self.overlayButton.setIcon(QIcon(":overlay.svg")) - self.overlayButton.setCheckable(True) - - self.overlayButtonAction = navigateToolBar.addWidget(self.overlayButton) - # self.checkableButtons.append(self.overlayButton) - # self.checkableQButtonsGroup.addButton(self.overlayButton) - - self.countObjsButton = QToolButton(self) - self.countObjsButton.setIcon(QIcon(":count_objects.svg")) - self.countObjsButton.setCheckable(True) - self.countObjsButton.setShortcut('Ctrl+Shift+C') - self.countObjsButtonAction = navigateToolBar.addWidget( - self.countObjsButton - ) - - self.togglePointsLayerAction = QAction('Activate points layer', self) - self.togglePointsLayerAction.setCheckable(True) - self.togglePointsLayerAction.setIcon(QIcon(":pointsLayer.svg")) - navigateToolBar.addAction(self.togglePointsLayerAction) - - self.overlayLabelsButton = widgets.rightClickToolButton(parent=self) - self.overlayLabelsButton.setIcon(QIcon(":overlay_labels.svg")) - self.overlayLabelsButton.setCheckable(True) - # self.overlayLabelsButton.setVisible(False) - self.overlayLabelsButtonAction = navigateToolBar.addWidget( - self.overlayLabelsButton - ) - self.overlayLabelsButtonAction.setVisible(False) - - self.rulerButton = QToolButton(self) - self.rulerButton.setIcon(QIcon(":ruler.svg")) - self.rulerButton.setCheckable(True) - navigateToolBar.addWidget(self.rulerButton) - self.checkableButtons.append(self.rulerButton) - self.LeftClickButtons.append(self.rulerButton) - - # fluorescence image color widget - colorsToolBar = widgets.ToolBar("Colors", self) - - self.overlayColorButton = pg.ColorButton(self, color=(230,230,230)) - self.overlayColorButton.setDisabled(True) - colorsToolBar.addWidget(self.overlayColorButton) - - self.textIDsColorButton = pg.ColorButton(self) - colorsToolBar.addWidget(self.textIDsColorButton) - - self.addToolBar(colorsToolBar) - colorsToolBar.setVisible(False) - - self.navigateToolBar = navigateToolBar - - # cca toolbar - ccaToolBar = widgets.ToolBar("Cell cycle annotations", self) - self.addToolBar(ccaToolBar) + self.logger.info('GUI ready.') - # Assign mother to bud button - self.assignBudMothButton = QToolButton(self) - self.assignBudMothButton.setIcon(QIcon(":assign-motherbud.svg")) - self.assignBudMothButton.setCheckable(True) - self.assignBudMothButton.setShortcut('A') - self.assignBudMothButton.setVisible(False) - self.assignBudMothButton.action = ccaToolBar.addWidget( - self.assignBudMothButton - ) - self.checkableButtons.append(self.assignBudMothButton) - self.checkableQButtonsGroup.addButton(self.assignBudMothButton) - self.functionsNotTested3D.append(self.assignBudMothButton) - - # Set is_history_known button - self.setIsHistoryKnownButton = QToolButton(self) - self.setIsHistoryKnownButton.setIcon(QIcon(":history.svg")) - self.setIsHistoryKnownButton.setCheckable(True) - self.setIsHistoryKnownButton.setShortcut('U') - self.setIsHistoryKnownButton.setVisible(False) - self.setIsHistoryKnownButton.action = ccaToolBar.addWidget( - self.setIsHistoryKnownButton - ) - self.checkableButtons.append(self.setIsHistoryKnownButton) - self.checkableQButtonsGroup.addButton(self.setIsHistoryKnownButton) - self.functionsNotTested3D.append(self.setIsHistoryKnownButton) - - ccaToolBar.addAction(self.assignBudMothAutoAction) - ccaToolBar.addAction(self.editCcaToolAction) - ccaToolBar.addAction(self.reInitCcaAction) - ccaToolBar.setVisible(False) - self.ccaToolBar = ccaToolBar - self.functionsNotTested3D.append(self.assignBudMothAutoAction) - self.functionsNotTested3D.append(self.reInitCcaAction) - self.functionsNotTested3D.append(self.editCcaToolAction) - - # Edit toolbar - editToolBar = widgets.ToolBar("Edit", self) - editToolBar.setContextMenuPolicy(Qt.PreventContextMenu) - - self.addToolBar(editToolBar) - - self.manulAnnotToolButtons = set() - - self.brushButton = QToolButton(self) - self.brushButton.setIcon(QIcon(":brush.svg")) - self.brushButton.setCheckable(True) - editToolBar.addWidget(self.brushButton) - self.checkableButtons.append(self.brushButton) - self.LeftClickButtons.append(self.brushButton) - self.brushButton.keyPressShortcut = Qt.Key_B - self.widgetsWithShortcut['Brush'] = self.brushButton - self.manulAnnotToolButtons.add(self.brushButton) - - self.eraserButton = QToolButton(self) - self.eraserButton.setIcon(QIcon(":eraser.svg")) - self.eraserButton.setCheckable(True) - editToolBar.addWidget(self.eraserButton) - self.eraserButton.keyPressShortcut = Qt.Key_X - self.widgetsWithShortcut['Eraser'] = self.eraserButton - self.checkableButtons.append(self.eraserButton) - self.LeftClickButtons.append(self.eraserButton) - self.manulAnnotToolButtons.add(self.eraserButton) - - self.curvToolButton = QToolButton(self) - self.curvToolButton.setIcon(QIcon(":curvature-tool.svg")) - self.curvToolButton.setCheckable(True) - self.curvToolButton.setShortcut('C') - self.curvToolButton.action = editToolBar.addWidget(self.curvToolButton) - self.LeftClickButtons.append(self.curvToolButton) - # self.functionsNotTested3D.append(self.curvToolButton) - self.widgetsWithShortcut['Curvature tool'] = self.curvToolButton - # self.checkableButtons.append(self.curvToolButton) - self.manulAnnotToolButtons.add(self.curvToolButton) - - self.wandToolButton = QToolButton(self) - self.wandToolButton.setIcon(QIcon(":magic_wand.svg")) - self.wandToolButton.setCheckable(True) - self.wandToolButton.setShortcut('Ctrl+D') - self.wandToolButton.action = editToolBar.addWidget(self.wandToolButton) - self.LeftClickButtons.append(self.wandToolButton) - self.checkableButtons.append(self.eraserButton) - self.widgetsWithShortcut['Magic wand'] = self.wandToolButton - - self.magicPromptsToolButton = QToolButton(self) - self.magicPromptsToolButton.setIcon(QIcon(":magic-prompts.svg")) - self.magicPromptsToolButton.setCheckable(True) - self.magicPromptsToolButton.setShortcut('W') - self.magicPromptsToolButton.action = editToolBar.addWidget( - self.magicPromptsToolButton - ) - self.widgetsWithShortcut['Magic prompts'] = self.magicPromptsToolButton - - self.drawClearRegionButton = QToolButton(self) - self.drawClearRegionButton.setCheckable(True) - self.drawClearRegionButton.setIcon(QIcon(":clear_freehand_region.svg")) - self.widgetsWithShortcut['Clear freehand region'] = ( - self.drawClearRegionButton - ) - self.toolsActiveInProj3Dsegm.add(self.drawClearRegionButton) - - self.checkableButtons.append(self.drawClearRegionButton) - self.LeftClickButtons.append(self.drawClearRegionButton) - - self.drawClearRegionAction = editToolBar.addWidget( - self.drawClearRegionButton - ) - - self.widgetsWithShortcut['Annotate mother/daughter pairing'] = ( - self.assignBudMothButton - ) - self.widgetsWithShortcut['Annotate unknown history'] = ( - self.setIsHistoryKnownButton - ) - - self.copyLostObjButton = QToolButton(self) - self.copyLostObjButton.setIcon(QIcon(":copyContour.svg")) - self.copyLostObjButton.setCheckable(True) - self.copyLostObjButton.setShortcut('V') - self.copyLostObjButton.action = editToolBar.addWidget( - self.copyLostObjButton - ) - self.checkableButtons.append(self.copyLostObjButton) - self.checkableQButtonsGroup.addButton(self.copyLostObjButton) - self.widgetsWithShortcut['Copy lost object contour'] = ( - self.copyLostObjButton - ) - self.functionsNotTested3D.append(self.copyLostObjButton) - - self.labelRoiButton = widgets.rightClickToolButton(parent=self) - self.labelRoiButton.setIcon(QIcon(":label_roi.svg")) - self.labelRoiButton.setCheckable(True) - self.labelRoiButton.setShortcut('L') - self.labelRoiButton.action = editToolBar.addWidget(self.labelRoiButton) - self.LeftClickButtons.append(self.labelRoiButton) - self.checkableButtons.append(self.labelRoiButton) - self.checkableQButtonsGroup.addButton(self.labelRoiButton) - self.widgetsWithShortcut['Label ROI'] = self.labelRoiButton - # self.functionsNotTested3D.append(self.labelRoiButton) - - self.manualAnnotPastButton = QToolButton(self) - self.manualAnnotPastButton.setIcon(QIcon(":lock_id_annotate_future.svg")) - self.manualAnnotPastButton.setCheckable(True) - self.manualAnnotPastButton.setShortcut('Y') - self.manualAnnotPastButton.action = editToolBar.addWidget( - self.manualAnnotPastButton - ) - self.checkableButtons.append(self.manualAnnotPastButton) - self.widgetsWithShortcut['Lock ID and annotate single object'] = ( - self.manualAnnotPastButton - ) - self.functionsNotTested3D.append(self.manualAnnotPastButton) - self.manulAnnotToolButtons.add(self.manualAnnotPastButton) - - self.segmentToolAction = QAction('Segment with last used model', self) - self.segmentToolAction.setIcon(QIcon(":segment.svg")) - self.segmentToolAction.setShortcut('R') - self.widgetsWithShortcut['Repeat segmentation'] = self.segmentToolAction - editToolBar.addAction(self.segmentToolAction) - - self.segForLostIDsButton = QToolButton(self) - self.segForLostIDsButton.setIcon(QIcon(":segForLostIDs.svg")) - self.segForLostIDsAction = editToolBar.addWidget( - self.segForLostIDsButton - ) - self.segForLostIDsButton.clicked.connect( - self.segForLostIDsButtonClicked - ) - - # self.SegForLostIDsButton.setShortcut('U') - # self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.SegForLostIDsButton - - self.manualBackgroundButton = QToolButton(self) - self.manualBackgroundButton.setIcon(QIcon(":manual_background.svg")) - self.manualBackgroundButton.setCheckable(True) - self.manualBackgroundButton.setShortcut('G') - self.LeftClickButtons.append(self.manualBackgroundButton) - self.checkableButtons.append(self.manualBackgroundButton) - self.checkableQButtonsGroup.addButton(self.manualBackgroundButton) - self.widgetsWithShortcut['Manual background'] = self.manualBackgroundButton - - self.manualBackgroundAction = editToolBar.addWidget( - self.manualBackgroundButton - ) - - self.delObjsOutSegmMaskAction = QAction( - QIcon(":del_objs_out_segm.svg"), - 'Select a segmentation file and delete all objects on the background', - self - ) - self.delObjsOutSegmMaskAction.setShortcut('I') - self.widgetsWithShortcut['Delete all objects outside segm'] = ( - self.delObjsOutSegmMaskAction - ) - editToolBar.addAction(self.delObjsOutSegmMaskAction) - - self.hullContToolButton = QToolButton(self) - self.hullContToolButton.setIcon(QIcon(":hull.svg")) - self.hullContToolButton.setCheckable(True) - self.hullContToolButton.setShortcut('O') - self.hullContToolButton.action = editToolBar.addWidget(self.hullContToolButton) - self.checkableButtons.append(self.hullContToolButton) - self.checkableQButtonsGroup.addButton(self.hullContToolButton) - self.functionsNotTested3D.append(self.hullContToolButton) - self.widgetsWithShortcut['Hull contour'] = self.hullContToolButton - - self.fillHolesToolButton = QToolButton(self) - self.fillHolesToolButton.setIcon(QIcon(":fill_holes.svg")) - self.fillHolesToolButton.setCheckable(True) - self.fillHolesToolButton.setShortcut('F') - self.fillHolesToolButton.action = editToolBar.addWidget( - self.fillHolesToolButton - ) - self.checkableButtons.append(self.fillHolesToolButton) - self.checkableQButtonsGroup.addButton(self.fillHolesToolButton) - self.functionsNotTested3D.append(self.fillHolesToolButton) - self.widgetsWithShortcut['Fill holes'] = self.fillHolesToolButton - - self.moveLabelToolButton = QToolButton(self) - self.moveLabelToolButton.setIcon(QIcon(":moveLabel.svg")) - self.moveLabelToolButton.setCheckable(True) - self.moveLabelToolButton.setShortcut('P') - self.moveLabelToolButton.action = editToolBar.addWidget(self.moveLabelToolButton) - self.checkableButtons.append(self.moveLabelToolButton) - self.checkableQButtonsGroup.addButton(self.moveLabelToolButton) - self.widgetsWithShortcut['Move label'] = self.moveLabelToolButton - - self.expandLabelToolButton = QToolButton(self) - self.expandLabelToolButton.setIcon(QIcon(":expandLabel.svg")) - self.expandLabelToolButton.setCheckable(True) - self.expandLabelToolButton.setShortcut('E') - self.expandLabelToolButton.action = editToolBar.addWidget(self.expandLabelToolButton) - self.expandLabelToolButton.hide() - self.checkableButtons.append(self.expandLabelToolButton) - self.LeftClickButtons.append(self.expandLabelToolButton) - self.checkableQButtonsGroup.addButton(self.expandLabelToolButton) - self.widgetsWithShortcut['Expand/shrink label'] = self.expandLabelToolButton - - self.editIDbutton = QToolButton(self) - self.editIDbutton.setIcon(QIcon(":edit-id.svg")) - self.editIDbutton.setCheckable(True) - self.editIDbutton.setShortcut('N') - editToolBar.addWidget(self.editIDbutton) - self.checkableButtons.append(self.editIDbutton) - self.checkableQButtonsGroup.addButton(self.editIDbutton) - self.widgetsWithShortcut['Edit ID'] = self.editIDbutton - - self.separateBudButton = QToolButton(self) - self.separateBudButton.setIcon(QIcon(":separate-bud.svg")) - self.separateBudButton.setCheckable(True) - self.separateBudButton.setShortcut('S') - self.separateBudButton.action = editToolBar.addWidget(self.separateBudButton) - self.checkableButtons.append(self.separateBudButton) - self.checkableQButtonsGroup.addButton(self.separateBudButton) - # self.functionsNotTested3D.append(self.separateBudButton) - self.widgetsWithShortcut['Separate objects'] = self.separateBudButton - - self.mergeIDsButton = QToolButton(self) - self.mergeIDsButton.setIcon(QIcon(":merge-IDs.svg")) - self.mergeIDsButton.setCheckable(True) - self.mergeIDsButton.setShortcut('M') - self.mergeIDsButton.action = editToolBar.addWidget(self.mergeIDsButton) - self.checkableButtons.append(self.mergeIDsButton) - self.checkableQButtonsGroup.addButton(self.mergeIDsButton) - # self.functionsNotTested3D.append(self.mergeIDsButton) - self.widgetsWithShortcut['Merge objects'] = self.mergeIDsButton - - self.keepIDsButton = QToolButton(self) - self.keepIDsButton.setIcon(QIcon(":keep_objects.svg")) - self.keepIDsButton.setCheckable(True) - self.keepIDsButton.action = editToolBar.addWidget(self.keepIDsButton) - self.keepIDsButton.setShortcut('K') - self.checkableButtons.append(self.keepIDsButton) - self.checkableQButtonsGroup.addButton(self.keepIDsButton) - # self.functionsNotTested3D.append(self.keepIDsButton) - self.widgetsWithShortcut['Select objects to keep'] = self.keepIDsButton - - self.whitelistIDsButton = QToolButton(self) - self.whitelistIDsButton.setIcon(QIcon(":whitelist.svg")) - self.whitelistIDsButton.setCheckable(True) - self.whitelistIDsButton.action = editToolBar.addWidget( - self.whitelistIDsButton - ) - self.whitelistIDsButton.setShortcut('Ctrl+K') - self.checkableButtons.append(self.whitelistIDsButton) - self.checkableQButtonsGroup.addButton(self.whitelistIDsButton) - self.LeftClickButtons.append(self.whitelistIDsButton) - # self.functionsNotTested3D.append(self.whitelistIDsButton) - self.widgetsWithShortcut['Select objects to add to a tracking whitelist'] = ( - self.whitelistIDsButton - ) - - self.binCellButton = QToolButton(self) - self.binCellButton.setIcon(QIcon(":bin.svg")) - self.binCellButton.setCheckable(True) - # self.binCellButton.setShortcut('R') - self.binCellButton.action = editToolBar.addWidget(self.binCellButton) - self.checkableButtons.append(self.binCellButton) - self.checkableQButtonsGroup.addButton(self.binCellButton) - # self.functionsNotTested3D.append(self.binCellButton) - - self.manualTrackingButton = QToolButton(self) - self.manualTrackingButton.setIcon(QIcon(":manual_tracking.svg")) - self.manualTrackingButton.setCheckable(True) - self.manualTrackingButton.setShortcut('T') - self.checkableQButtonsGroup.addButton(self.manualTrackingButton) - self.checkableButtons.append(self.manualTrackingButton) - self.widgetsWithShortcut['Manual tracking'] = self.manualTrackingButton - - self.ripCellButton = QToolButton(self) - self.ripCellButton.setIcon(QIcon(":rip.svg")) - self.ripCellButton.setCheckable(True) - self.ripCellButton.setShortcut('D') - self.ripCellButton.action = editToolBar.addWidget(self.ripCellButton) - self.checkableButtons.append(self.ripCellButton) - self.checkableQButtonsGroup.addButton(self.ripCellButton) - self.functionsNotTested3D.append(self.ripCellButton) - self.widgetsWithShortcut['Annotate cell as dead'] = self.ripCellButton - - editToolBar.addAction(self.addDelRoiAction) - # editToolBar.addAction(self.addDelPolyLineRoiAction) - - self.addDelPolyLineRoiAction = editToolBar.addWidget( - self.addDelPolyLineRoiButton - ) - self.addDelPolyLineRoiAction.roiType = 'polyline' - - editToolBar.addAction(self.delBorderObjAction) - self.delBorderObjAction.button = editToolBar.widgetForAction( - self.delBorderObjAction - ) - editToolBar.addAction(self.delNewObjAction) - self.delNewObjAction.button = editToolBar.widgetForAction( - self.delNewObjAction - ) - - self.addDelRoiAction.toolbar = editToolBar - self.functionsNotTested3D.append(self.addDelRoiAction) - - self.addDelPolyLineRoiAction.toolbar = editToolBar - self.functionsNotTested3D.append(self.addDelPolyLineRoiAction) - - self.delBorderObjAction.toolbar = editToolBar - self.functionsNotTested3D.append(self.delBorderObjAction) - - self.delNewObjAction.toolbar = editToolBar - # self.functionsNotTested3D.append(self.delNewObjAction) so id this doesnt work in 3d i dont know anymore - - editToolBar.addAction(self.repeatTrackingAction) - - self.manualTrackingAction = editToolBar.addWidget( - self.manualTrackingButton - ) - - self.functionsNotTested3D.append(self.repeatTrackingAction) - self.functionsNotTested3D.append(self.manualTrackingAction) - - self.reinitLastSegmFrameAction = QAction(self) - self.reinitLastSegmFrameAction.setIcon(QIcon(":reinitLastSegm.svg")) - self.reinitLastSegmFrameAction.setVisible(False) - editToolBar.addAction(self.reinitLastSegmFrameAction) - editToolBar.setVisible(False) - self.reinitLastSegmFrameAction.toolbar = editToolBar - self.functionsNotTested3D.append(self.reinitLastSegmFrameAction) - - - self.editLin_TreeBar = widgets.ToolBar("Lin Tree Edit", self) - self.editLin_TreeBar.setContextMenuPolicy(Qt.PreventContextMenu) - - self.addToolBar(self.editLin_TreeBar) - self.editLin_TreeGroup = QButtonGroup() - self.editLin_TreeGroup.setExclusive(True) - - self.findNextMotherButton = QToolButton(self) - self.findNextMotherButton.setIcon(QIcon(":magnGlass.svg")) - self.findNextMotherButton.setCheckable(True) - self.editLin_TreeBar.addWidget(self.findNextMotherButton) - self.editLin_TreeGroup.addButton(self.findNextMotherButton) - self.findNextMotherButton.setShortcut('F') - self.widgetsWithShortcut['Find next potential mother (lineage tree)'] = self.findNextMotherButton - - self.unknownLineageButton = QToolButton(self) - self.unknownLineageButton.setIcon(QIcon(":history.svg")) - self.unknownLineageButton.setCheckable(True) - self.editLin_TreeBar.addWidget(self.unknownLineageButton) - self.editLin_TreeGroup.addButton(self.unknownLineageButton) - self.unknownLineageButton.setShortcut('U') - self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.unknownLineageButton - - self.noToolLinTreeButton = QToolButton(self) - self.noToolLinTreeButton.setIcon(QIcon(":arrow_cursor.svg")) - self.noToolLinTreeButton.setCheckable(True) - self.editLin_TreeBar.addWidget(self.noToolLinTreeButton) - self.editLin_TreeGroup.addButton(self.noToolLinTreeButton) - self.noToolLinTreeButton.setShortcut('N') - self.widgetsWithShortcut['No tool (lineage tree)'] = self.noToolLinTreeButton - - self.propagateLinTreeButton = QToolButton(self) - self.propagateLinTreeButton.setIcon(QIcon(":compute.svg")) - self.editLin_TreeBar.addWidget(self.propagateLinTreeButton) - self.propagateLinTreeButton.setShortcut('P') - self.widgetsWithShortcut['Propagate (lineage tree)'] = self.propagateLinTreeButton - self.propagateLinTreeButton.clicked.connect(self.propagateLinTreeAction) - - self.viewLinTreeInfoButton = QToolButton(self) - self.viewLinTreeInfoButton.setIcon(QIcon(":addCustomAnnotation.svg")) - self.editLin_TreeBar.addWidget(self.viewLinTreeInfoButton) - self.viewLinTreeInfoButton.setShortcut('S') - self.widgetsWithShortcut['View Changes (lineage tree)'] = self.viewLinTreeInfoButton - self.viewLinTreeInfoButton.clicked.connect(self.viewLinTreeInfoAction) - - - modes_available = [ - 'Segmentation and Tracking', - 'Cell cycle analysis', - 'Viewer', - 'Custom annotations', - 'Normal division: Lineage tree' - ] - self.modeItems = modes_available - - self.modeActionGroup = QActionGroup(self.modeMenu) - for mode in self.modeItems: - action = QAction(mode) - action.setCheckable(True) - self.modeActionGroup.addAction(action) - self.modeMenu.addAction(action) - if mode == 'Viewer': - action.setChecked(True) - - self.editToolBar = editToolBar - self.editToolBar.setVisible(False) - self.navigateToolBar.setVisible(False) - self.editLin_TreeBar.setVisible(False) - - self.gui_createAnnotateToolbar() - - def gui_createAnnotateToolbar(self): - # Edit toolbar - self.annotateToolbar = widgets.ToolBar("Custom annotations", self) - self.annotateToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - self.addToolBar(Qt.LeftToolBarArea, self.annotateToolbar) - self.annotateToolbar.addAction(self.loadCustomAnnotationsAction) - self.annotateToolbar.addAction(self.addCustomAnnotationAction) - self.annotateToolbar.addAction(self.viewAllCustomAnnotAction) - self.annotateToolbar.setVisible(False) - - def gui_createLazyLoader(self): - if not self.lazyLoader is None: - return - - self.lazyLoaderThread = QThread() - self.lazyLoaderMutex = QMutex() - self.lazyLoaderWaitCond = QWaitCondition() - self.waitReadH5cond = QWaitCondition() - self.readH5mutex = QMutex() - self.lazyLoader = workers.LazyLoader( - self.lazyLoaderMutex, self.lazyLoaderWaitCond, - self.waitReadH5cond, self.readH5mutex - ) - self.lazyLoader.moveToThread(self.lazyLoaderThread) - self.lazyLoader.wait = True - - self.lazyLoader.signals.finished.connect(self.lazyLoaderThread.quit) - self.lazyLoader.signals.finished.connect(self.lazyLoader.deleteLater) - self.lazyLoaderThread.finished.connect(self.lazyLoaderThread.deleteLater) - - self.lazyLoader.signals.progress.connect(self.workerProgress) - self.lazyLoader.signals.sigLoadingNewChunk.connect(self.loadingNewChunk) - self.lazyLoader.sigLoadingFinished.connect(self.lazyLoaderFinished) - self.lazyLoader.signals.critical.connect(self.lazyLoaderCritical) - self.lazyLoader.signals.finished.connect(self.lazyLoaderWorkerClosed) - - self.lazyLoaderThread.started.connect(self.lazyLoader.run) - self.lazyLoaderThread.start() - - def gui_createStoreStateWorker(self): - self.storeStateWorker = None - return - self.storeStateThread = QThread() - self.autoSaveMutex = QMutex() - self.autoSaveWaitCond = QWaitCondition() - - self.storeStateWorker = workers.StoreGuiStateWorker( - self.autoSaveMutex, self.autoSaveWaitCond - ) - - self.storeStateWorker.moveToThread(self.storeStateThread) - self.storeStateWorker.finished.connect(self.storeStateThread.quit) - self.storeStateWorker.finished.connect(self.storeStateWorker.deleteLater) - self.storeStateThread.finished.connect(self.storeStateThread.deleteLater) - - self.storeStateWorker.sigDone.connect(self.storeStateWorkerDone) - self.storeStateWorker.progress.connect(self.workerProgress) - self.storeStateWorker.finished.connect(self.storeStateWorkerClosed) - - self.storeStateThread.started.connect(self.storeStateWorker.run) - self.storeStateThread.start() - - self.logger.info('Store state worker started.') - - def storeStateWorkerDone(self): - if self.storeStateWorker.callbackOnDone is not None: - self.storeStateWorker.callbackOnDone() - self.storeStateWorker.callbackOnDone = None - - def storeStateWorkerClosed(self): - self.logger.info('Store state worker started.') - - def gui_createAutoSaveWorker(self): - if not hasattr(self, 'data'): - return - - if not self.isDataLoaded: - return - - if self.autoSaveActiveWorkers: - garbage = self.autoSaveActiveWorkers[-1] - self.autoSaveGarbageWorkers.append(garbage) - worker = garbage[0] - worker._stop() - - posData = self.data[self.pos_i] - autoSaveThread = QThread() - self.autoSaveMutex = QMutex() - self.autoSaveWaitCond = QWaitCondition() - - savedSegmData = posData.segm_data.copy() - autoSaveWorker = workers.AutoSaveWorker( - self.autoSaveMutex, self.autoSaveWaitCond, savedSegmData - ) - autoSaveWorker.isAutoSaveON = self.autoSaveToggle.isChecked() - - autoSaveWorker.moveToThread(autoSaveThread) - autoSaveWorker.finished.connect(autoSaveThread.quit) - autoSaveWorker.finished.connect(autoSaveWorker.deleteLater) - autoSaveThread.finished.connect(autoSaveThread.deleteLater) - - autoSaveWorker.sigDone.connect(self.autoSaveWorkerDone) - autoSaveWorker.progress.connect(self.workerProgress) - autoSaveWorker.finished.connect(self.autoSaveWorkerClosed) - autoSaveWorker.sigAutoSaveCannotProceed.connect( - self.turnOffAutoSaveWorker - ) - - autoSaveThread.started.connect(autoSaveWorker.run) - autoSaveThread.start() - - self.autoSaveActiveWorkers.append((autoSaveWorker, autoSaveThread)) - - self.logger.info('Autosaving worker started.') - - def autoSaveWorkerStartTimer(self, worker, posData): - self.autoSaveWorkerTimer = QTimer() - self.autoSaveWorkerTimer.timeout.connect( - partial(self.autoSaveWorkerTimerCallback, worker, posData) - ) - self.autoSaveWorkerTimer.start(150) - - def autoSaveWorkerTimerCallback(self, worker, posData): - if not self.isSaving: - self.autoSaveWorkerTimer.stop() - worker._enqueue(posData) - - def autoSaveWorkerDone(self): - self.setStatusBarLabel(log=False) - - def ccaCheckerWorkerDone(self): - self.setStatusBarLabel(log=False) - - def preprocWorkerIsQueueEmpty(self, isEmpty: bool): - if isEmpty: - self.preprocessDialog.appliedFinished() - else: - self.preprocessDialog.setDisabled(True) - self.preprocessDialog.infoLabel.setText( - 'Computing preview...
' - '(Feel free to use Cell-ACDC while waiting)' - ) - - def preprocWorkerPreviewDone( - self, processed_data: np.ndarray, - key: Tuple[int, int, Union[int, str]] - ): - pos_i, frame_i, z_slice = key - posData = self.data[pos_i] - if not hasattr(posData, 'preproc_img_data'): - posData.preproc_img_data = preprocess.PreprocessedData( - image_data=np.zeros(posData.img_data.shape) - ) - - posData.preproc_img_data[frame_i][z_slice] = processed_data - self.img1.updateMinMaxValuesPreprocessedData( - self.data, pos_i, frame_i, z_slice - ) - - self.setImageImg1() - - def preprocWorkerDone( - self, - processed_data: np.ndarray, - how: str, - ): - self.setStatusBarLabel(log=False) - self.preprocessDialog.appliedFinished() - - posData = self.data[self.pos_i] - if not hasattr(posData, 'preproc_img_data'): - posData.preproc_img_data = preprocess.PreprocessedData() - - if how == 'current_image': - if posData.SizeZ > 1: - z_slice = self.z_slice_index() - posData.preproc_img_data[posData.frame_i][z_slice] = ( - processed_data - ) - else: - posData.preproc_img_data[posData.frame_i] = processed_data - z_slice = 0 - self.img1.updateMinMaxValuesPreprocessedData( - self.data, self.pos_i, posData.frame_i, z_slice - ) - elif how == 'z_stack': - for z_slice, processed_img in enumerate(processed_data): - posData.preproc_img_data[posData.frame_i][z_slice] = ( - processed_img - ) - self.img1.updateMinMaxValuesPreprocessedData( - self.data, self.pos_i, posData.frame_i, z_slice - ) - self.img1.updateMinMaxValuesPreprocessedProjections( - self.data, self.pos_i, posData.frame_i - ) - elif how == 'all_frames': - for frame_i, processed_frame in enumerate(processed_data): - if processed_frame.ndim == 2: - processed_frame = (processed_frame,) - - for z_slice, processed_img in enumerate(processed_frame): - posData.preproc_img_data[frame_i][z_slice] = ( - processed_img - ) - self.img1.updateMinMaxValuesPreprocessedData( - self.data, self.pos_i, frame_i, z_slice - ) - self.img1.updateMinMaxValuesPreprocessedProjections( - self.data, self.pos_i, frame_i - ) - elif how == 'all_pos': - for pos_i, processed_pos_data in enumerate(processed_data): - if processed_pos_data.ndim == 2: - processed_pos_data = (processed_pos_data,) - - posData = self.data[pos_i] - if not hasattr(posData, 'preproc_img_data'): - posData.preproc_img_data = preprocess.PreprocessedData() - for z_slice, processed_img in enumerate(processed_pos_data): - posData.preproc_img_data[0][z_slice] = ( - processed_img - ) - self.img1.updateMinMaxValuesPreprocessedData( - self.data, pos_i, 0, z_slice - ) - - if posData.SizeZ > 1: - self.img1.updateMinMaxValuesPreprocessedProjections( - self.data, pos_i, frame_i - ) - - if not self.viewPreprocDataToggle.isChecked(): - self.viewPreprocDataToggle.setChecked(True) - else: - self.setImageImg1() - - def goToFrameNumber(self, frame_n): - posData = self.data[self.pos_i] - posData.frame_i = frame_n - 1 - self.get_data() - self.updateAllImages() - self.updateScrollbars() - - def warnCcaIntegrity(self, txt, category): - self.logger.warning(f'{html_utils.to_plain_text(txt)}') - - if 'disable_all' in self.disabled_cca_warnings: - return - - if category in self.disabled_cca_warnings: - return - - if txt in self.disabled_cca_warnings: - return - - if self.isWarningCcaIntegrity: - # Some other warning is still open --> avoid opening another one - return - - self.isWarningCcaIntegrity = True - disabled_warning = _warnings.warn_cca_integrity( - txt, category, self, - go_to_frame_callback=self.goToFrameNumber - ) - if disabled_warning: - self.disabled_cca_warnings.add(disabled_warning) - - self.isWarningCcaIntegrity = False - - def fixWillDivide(self, warning_txt, IDs_will_divide_wrong): - self.logger.info(warning_txt) - self.logger.info('Fixing `will_divide` information...') - - global_cca_df = self.getConcatCcaDf() - global_cca_df = ( - global_cca_df.reset_index() - .set_index(['Cell_ID', 'generation_num']) - ) - global_cca_df.loc[IDs_will_divide_wrong, 'will_divide'] = 0 - global_cca_df = ( - global_cca_df.reset_index() - .set_index(['frame_i', 'Cell_ID']) - ) - self.storeFromConcatCcaDf(global_cca_df) - - def autoSaveWorkerClosed(self, worker): - if self.autoSaveActiveWorkers: - self.logger.info('Autosaving worker closed.') - try: - self.autoSaveActiveWorkers.remove(worker) - except Exception as e: - pass - - def ccaCheckerWorkerClosed(self, worker): - self.logger.info('Cell cycle annotations integrity checker stopped.') - self.ccaCheckerRunning = False - - def preprocWorkerClosed(self, worker): - self.logger.info('Pre-processing worker stopped.') - - def gui_createMainLayout(self): - mainLayout = QGridLayout() - row, col = 0, 1 # Leave column 1 for the overlay labels gradient editor - mainLayout.addLayout(self.leftSideDocksLayout, row, col, 2, 1) - - row = 0 - col = 2 - mainLayout.addWidget(self.graphLayout, row, col, 1, 2) - mainLayout.setRowStretch(row, 2) - - col = 4 # graphLayout spans two columns - mainLayout.addWidget(self.labelsGrad, row, col) - - col = 5 - mainLayout.addLayout(self.rightSideDocksLayout, row, col, 2, 1) - - col = 2 - row += 1 - self.resizeBottomLayoutLine = widgets.VerticalResizeHline() - mainLayout.addWidget(self.resizeBottomLayoutLine, row, col, 1, 2) - self.resizeBottomLayoutLine.dragged.connect( - self.resizeBottomLayoutLineDragged - ) - self.resizeBottomLayoutLine.clicked.connect( - self.resizeBottomLayoutLineClicked - ) - self.resizeBottomLayoutLine.released.connect( - self.resizeBottomLayoutLineReleased - ) - - # row += 1 - # mainLayout.addItem(QSpacerItem(5,5), row+1, col, 1, 2) - - # row, col = 1, 2 - # mainLayout.addLayout( - # self.bottomLayout, row, col, 1, 2, alignment=Qt.AlignLeft - # ) - - row += 1 - mainLayout.addWidget(self.bottomScrollArea, row, col, 1, 2) - mainLayout.setRowStretch(row, 0) - - # row, col = 2, 1 - # mainLayout.addWidget(self.terminal, row, col, 1, 4) - # self.terminal.hide() - - return mainLayout - - def gui_createRegionPropsDockWidget(self, side=Qt.LeftDockWidgetArea): - self.propsDockWidget = QDockWidget('Cell-ACDC objects', self) - self.guiTabControl = widgets.guiTabControl(self.propsDockWidget) - - # self.guiTabControl.setFont(_font) - - self.propsDockWidget.setWidget(self.guiTabControl) - self.propsDockWidget.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetFloatable - | QDockWidget.DockWidgetFeature.DockWidgetMovable - ) - self.propsDockWidget.setAllowedAreas( - Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea - ) - - self.addDockWidget(side, self.propsDockWidget) - self.propsDockWidget.hide() - - def gui_createControlsToolbar(self): - self.controlToolBars = [] - self.addToolBarBreak() - - # Edit toolbar - modeToolBar = widgets.ToolBar("Mode", self) - self.addToolBar(modeToolBar) - - self.modeComboBox = widgets.ComboBox() - self.modeComboBox.addItems(self.modeItems) - self.modeComboBoxLabel = QLabel(' Mode: ') - self.modeComboBoxLabel.setBuddy(self.modeComboBox) - modeToolBar.addWidget(self.modeComboBoxLabel) - modeToolBar.addWidget(self.modeComboBox) - modeToolBar.setVisible(False) - - self.modeToolBar = modeToolBar - - self.overlayToolbar = widgets.OverlayToolbar(parent=self) - self.addToolBar(Qt.TopToolBarArea, self.overlayToolbar) - self.overlayToolbar.setVisible(False) - self.overlayToolbar.sigSetTranspacency.connect( - self.setOverlayTransparency - ) - self.overlayToolbar.sigSetSingleChannel.connect( - self.setOverlaySingleChannel - ) - - self.autoPilotZoomToObjToolbar = widgets.ToolBar( - "Auto-zoom to objects", self - ) - self.autoPilotZoomToObjToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - self.autoPilotZoomToObjToolbar.setMovable(False) - self.addToolBar(Qt.TopToolBarArea, self.autoPilotZoomToObjToolbar) - # self.autoPilotZoomToObjToolbar.setIconSize(QSize(16, 16)) - self.autoPilotZoomToObjToolbar.setVisible(False) - self.autoPilotZoomToObjToolbar.keepVisibleWhenActive = True - self.controlToolBars.append(self.autoPilotZoomToObjToolbar) - - # Highlighted ID or searched ID toolbar - self.highlightIDToolbar = widgets.HighlightedIDToolbar( - parent=self - ) - self.addToolBar(Qt.TopToolBarArea, self.highlightIDToolbar) - self.highlightIDToolbar.setVisible(False) - self.highlightIDToolbar.keepVisibleWhenActive = True - self.controlToolBars.append(self.highlightIDToolbar) - - self.highlightIDToolbar.sigIDChanged.connect( - self.setHighlighedIDfromToolbar - ) - - # Widgets toolbar - brushEraserToolBar = widgets.ToolBar("Widgets", self) - self.addToolBar(Qt.TopToolBarArea, brushEraserToolBar) - self.controlToolBars.append(brushEraserToolBar) - - self.editIDspinbox = widgets.SpinBox() - # self.editIDspinbox.setMaximum(2**32-1) - editIDLabel = QLabel(' ID: ') - self.editIDLabelAction = brushEraserToolBar.addWidget(editIDLabel) - self.editIDspinboxAction = brushEraserToolBar.addWidget( - self.editIDspinbox - ) - self.editIDLabelAction.setVisible(False) - self.editIDspinboxAction.setVisible(False) - self.editIDspinboxAction.setDisabled(True) - self.editIDLabelAction.setDisabled(True) - - brushEraserToolBar.addWidget(QLabel(' ')) - self.autoIDcheckbox = QCheckBox('Auto-ID') - self.autoIDcheckbox.setChecked(True) - self.autoIDcheckboxAction = brushEraserToolBar.addWidget(self.autoIDcheckbox) - self.autoIDcheckboxAction.setVisible(False) - - self.brushSizeSpinbox = widgets.SpinBox( - disableKeyPress=True, - allowNegative=False - ) - self.brushSizeSpinbox.setValue(4) - brushSizeLabel = QLabel(' Size: ') - brushSizeLabel.setBuddy(self.brushSizeSpinbox) - self.brushSizeLabelAction = brushEraserToolBar.addWidget(brushSizeLabel) - self.brushSizeAction = brushEraserToolBar.addWidget(self.brushSizeSpinbox) - self.brushSizeLabelAction.setVisible(False) - self.brushSizeAction.setVisible(False) - - brushEraserToolBar.addWidget(QLabel(' ')) - self.brushAutoFillCheckbox = QCheckBox('Auto-fill holes') - self.brushAutoFillAction = brushEraserToolBar.addWidget( - self.brushAutoFillCheckbox - ) - self.brushAutoFillAction.setVisible(False) - if 'brushAutoFill' in self.df_settings.index: - checked = self.df_settings.at['brushAutoFill', 'value'] == 'Yes' - self.brushAutoFillCheckbox.setChecked(checked) - - brushEraserToolBar.addWidget(QLabel(' ')) - self.brushAutoHideCheckbox = QCheckBox('Hide objects when hovering') - self.brushAutoHideAction = brushEraserToolBar.addWidget( - self.brushAutoHideCheckbox - ) - self.brushAutoHideCheckbox.setChecked(True) - self.brushAutoHideAction.setVisible(False) - if 'brushAutoHide' in self.df_settings.index: - checked = self.df_settings.at['brushAutoHide', 'value'] == 'Yes' - self.brushAutoHideCheckbox.setChecked(checked) - - brushEraserToolBar.setVisible(False) - self.brushEraserToolBar = brushEraserToolBar - - self.wandControlsToolbar = widgets.WandControlsToolbar( - parent=self - ) - - self.addToolBar(Qt.TopToolBarArea , self.wandControlsToolbar) - self.wandControlsToolbar.setVisible(False) - self.controlToolBars.append(self.wandControlsToolbar) - - separatorW = 5 - self.labelRoiToolbar = widgets.ToolBar("Magic labeller controls", self) - self.labelRoiToolbar.addWidget(QLabel('ROI n. of z-slices: ')) - self.labelRoiZdepthSpinbox = widgets.SpinBox(disableKeyPress=True) - self.labelRoiToolbar.addWidget(self.labelRoiZdepthSpinbox) - - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - self.labelRoiToolbar.addWidget(widgets.QVLine()) - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - - self.labelRoiReplaceExistingObjectsCheckbox = QCheckBox( - 'Remove objs. touched by new ones' - ) - self.labelRoiToolbar.addWidget(self.labelRoiReplaceExistingObjectsCheckbox) - self.labelRoiAutoClearBorderCheckbox = QCheckBox( - 'Clear ROI borders before adding new objs.' - ) - self.labelRoiAutoClearBorderCheckbox.setChecked(True) - self.labelRoiToolbar.addWidget(self.labelRoiAutoClearBorderCheckbox) - - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - self.labelRoiToolbar.addWidget(widgets.QVLine()) - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - - group = QButtonGroup() - group.setExclusive(True) - self.labelRoiIsRectRadioButton = QRadioButton('Rect. ROI') - self.labelRoiIsRectRadioButton.setChecked(True) - self.labelRoiIsFreeHandRadioButton = QRadioButton('Freehand ROI') - self.labelRoiIsCircularRadioButton = QRadioButton('Circular ROI') - group.addButton(self.labelRoiIsRectRadioButton) - group.addButton(self.labelRoiIsFreeHandRadioButton) - group.addButton(self.labelRoiIsCircularRadioButton) - self.labelRoiToolbar.addWidget(self.labelRoiIsRectRadioButton) - self.labelRoiToolbar.addWidget(self.labelRoiIsFreeHandRadioButton) - self.labelRoiToolbar.addWidget(self.labelRoiIsCircularRadioButton) - self.labelRoiToolbar.addWidget(QLabel(' | Radius (pixel): ')) - self.labelRoiCircularRadiusSpinbox = widgets.SpinBox(disableKeyPress=True) - self.labelRoiCircularRadiusSpinbox.setMinimum(1) - self.labelRoiCircularRadiusSpinbox.setValue(11) - self.labelRoiCircularRadiusSpinbox.setDisabled(True) - self.labelRoiToolbar.addWidget(self.labelRoiCircularRadiusSpinbox) - - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - self.labelRoiToolbar.addWidget(widgets.QVLine()) - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - - startFrameLabel = QLabel('Start frame n. ') - startFrameLabel.setDisabled(True) - self.labelRoiToolbar.addWidget(startFrameLabel) - self.labelRoiStartFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) - self.labelRoiStartFrameNoSpinbox.label = startFrameLabel - self.labelRoiStartFrameNoSpinbox.setValue(1) - self.labelRoiStartFrameNoSpinbox.setMinimum(1) - self.labelRoiToolbar.addWidget(self.labelRoiStartFrameNoSpinbox) - self.labelRoiStartFrameNoSpinbox.setDisabled(True) - - self.labelRoiFromCurrentFrameAction = QAction(self) - self.labelRoiFromCurrentFrameAction.setText('Segment from current frame') - self.labelRoiFromCurrentFrameAction.setIcon(QIcon(":frames_current.svg")) - self.labelRoiToolbar.addAction(self.labelRoiFromCurrentFrameAction) - self.labelRoiFromCurrentFrameAction.setDisabled(True) - - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=3)) - stopFrameLabel = QLabel(' Stop frame n. ') - stopFrameLabel.setDisabled(True) - self.labelRoiToolbar.addWidget(stopFrameLabel) - self.labelRoiStopFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) - self.labelRoiStopFrameNoSpinbox.label = stopFrameLabel - self.labelRoiStopFrameNoSpinbox.setValue(1) - self.labelRoiStopFrameNoSpinbox.setMinimum(1) - self.labelRoiToolbar.addWidget(self.labelRoiStopFrameNoSpinbox) - self.labelRoiStopFrameNoSpinbox.setDisabled(True) - - self.labelRoiToEndFramesAction = QAction(self) - self.labelRoiToEndFramesAction.setText('Segment all remaining frames') - self.labelRoiToEndFramesAction.setIcon(QIcon(":frames_end.svg")) - self.labelRoiToolbar.addAction(self.labelRoiToEndFramesAction) - self.labelRoiToEndFramesAction.setDisabled(True) - - self.labelRoiTrangeCheckbox = QCheckBox('Segment range of frames') - self.labelRoiToolbar.addWidget(self.labelRoiTrangeCheckbox) - - self.labelRoiViewCurrentModelAction = QAction(self) - self.labelRoiViewCurrentModelAction.setText( - 'View current model\'s parameters' - ) - self.labelRoiViewCurrentModelAction.setIcon(QIcon(":view.svg")) - self.labelRoiToolbar.addAction(self.labelRoiViewCurrentModelAction) - self.labelRoiViewCurrentModelAction.setDisabled(True) - - self.addToolBar(Qt.TopToolBarArea, self.labelRoiToolbar) - self.controlToolBars.append(self.labelRoiToolbar) - self.labelRoiToolbar.setVisible(False) - self.labelRoiTypesGroup = group - - self.loadLabelRoiLastParams() - - self.labelRoiTrangeCheckbox.toggled.connect( - self.labelRoiTrangeCheckboxToggled - ) - self.labelRoiReplaceExistingObjectsCheckbox.toggled.connect( - self.storeLabelRoiParams - ) - self.labelRoiIsCircularRadioButton.toggled.connect( - self.labelRoiIsCircularRadioButtonToggled - ) - self.labelRoiCircularRadiusSpinbox.valueChanged.connect( - self.updateLabelRoiCircularSize - ) - self.labelRoiCircularRadiusSpinbox.valueChanged.connect( - self.storeLabelRoiParams - ) - self.labelRoiZdepthSpinbox.valueChanged.connect( - self.storeLabelRoiParams - ) - self.labelRoiAutoClearBorderCheckbox.toggled.connect( - self.storeLabelRoiParams - ) - group.buttonToggled.connect(self.storeLabelRoiParams) - - self.labelRoiToEndFramesAction.triggered.connect( - self.labelRoiToEndFramesTriggered - ) - self.labelRoiFromCurrentFrameAction.triggered.connect( - self.labelRoiFromCurrentFrameTriggered - ) - self.labelRoiViewCurrentModelAction.triggered.connect( - self.labelRoiViewCurrentModel - ) - - self.keepIDsToolbar = widgets.ToolBar("Keep IDs controls", self) - self.keepIDsConfirmAction = QAction() - self.keepIDsConfirmAction.setIcon(QIcon(":greenTick.svg")) - self.keepIDsConfirmAction.setToolTip('Apply "keep IDs" selection') - self.keepIDsConfirmAction.setDisabled(True) - self.keepIDsToolbar.addAction(self.keepIDsConfirmAction) - self.keepIDsToolbar.addWidget(QLabel(' IDs to keep: ')) - instructionsText = ( - ' (Separate IDs by comma. Use a dash to denote a range of IDs)' - ) - instructionsLabel = QLabel(instructionsText) - self.keptIDsLineEdit = widgets.KeepIDsLineEdit( - instructionsLabel, parent=self - ) - self.keepIDsToolbar.addWidget(self.keptIDsLineEdit) - self.keepIDsToolbar.addWidget(instructionsLabel) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - self.keepIDsToolbar.addWidget(spacer) - self.addToolBar(Qt.TopToolBarArea, self.keepIDsToolbar) - self.keepIDsToolbar.setVisible(False) - self.controlToolBars.append(self.keepIDsToolbar) - - self.keptIDsLineEdit.sigEnterPressed.connect(self.applyKeepObjects) - self.keptIDsLineEdit.sigIDsChanged.connect(self.updateKeepIDs) - self.keepIDsConfirmAction.triggered.connect(self.applyKeepObjects) - - # closeToolbarAction = QAction( - # QIcon(":cancelButton.svg"), "Close toolbar...", self - # ) - # closeToolbarAction.triggered.connect(self.closeToolbars) - # self.autoPilotZoomToObjToolbar.addAction(closeToolbarAction) - - self.autoPilotZoomToObjToolbar.addWidget(widgets.QVLine()) - self.autoPilotZoomToObjToolbar.addWidget( - widgets.QHWidgetSpacer(width=separatorW) - ) - - spinBox = widgets.SpinBox() - spinBox.setMinimum(1) - spinBox.label = QLabel(' Zoom to ID: ') - spinBox.labelAction = self.autoPilotZoomToObjToolbar.addWidget(spinBox.label) - spinBox.action = self.autoPilotZoomToObjToolbar.addWidget(spinBox) - spinBox.editingFinished.connect(self.zoomToObj) - spinBox.sigUpClicked.connect(self.autoZoomNextObj) - spinBox.sigDownClicked.connect(self.autoZoomPrevObj) - self.autoPilotZoomToObjSpinBox = spinBox - toggle = widgets.Toggle() - self.autoPilotZoomToObjToggle = toggle - toggle.toggled.connect(self.autoPilotZoomToObjToggled) - toggle.label = QLabel(' Auto-pilot: ') - tooltip = ( - 'When auto-pilot is active, you can use Up/Down arrows to ' - 'automatically zoom to the next/previous object.\n\n' - 'Alternatively, you can type the ID of the object you want to ' - 'zoom to.' - ) - toggle.label.setToolTip(tooltip) - toggle.setToolTip(tooltip) - self.autoPilotZoomToObjToolbar.addWidget(toggle.label) - self.autoPilotZoomToObjToolbar.addWidget(toggle) - - self.pointsLayersToolbars = [] - - self.pointsLayersToolbar = widgets.PointsLayersToolbar(parent=self) - self.pointsLayersToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - - self.pointsLayersToolbar.sigAddPointsLayer.connect( - self.addPointsLayerTriggered - ) - - self.addToolBar(Qt.TopToolBarArea, self.pointsLayersToolbar) - - self.pointsLayersToolbar.setVisible(False) - self.pointsLayersToolbar.keepVisibleWhenActive = True - self.controlToolBars.append(self.pointsLayersToolbar) - - self.pointsLayersToolbars.append( - self.pointsLayersToolbar - ) - - self.manualTrackingToolbar = widgets.ManualTrackingToolBar( - "Manual tracking controls", self - ) - self.manualTrackingToolbar.sigIDchanged.connect(self.initGhostObject) - self.manualTrackingToolbar.sigDisableGhost.connect(self.clearGhost) - self.manualTrackingToolbar.sigClearGhostContour.connect( - self.clearGhostContour - ) - self.manualTrackingToolbar.sigClearGhostMask.connect( - self.clearGhostMask - ) - self.manualTrackingToolbar.sigGhostOpacityChanged.connect( - self.updateGhostMaskOpacity - ) - - self.addToolBar(Qt.TopToolBarArea, self.manualTrackingToolbar) - self.manualTrackingToolbar.setVisible(False) - self.controlToolBars.append(self.manualTrackingToolbar) - - self.manualBackgroundToolbar = widgets.ManualBackgroundToolBar( - "Manual background controls", self - ) - self.manualBackgroundToolbar.sigIDchanged.connect( - self.initManualBackgroundObject - ) - self.addToolBar(Qt.TopToolBarArea, self.manualBackgroundToolbar) - self.manualBackgroundToolbar.setVisible(False) - self.controlToolBars.append(self.manualBackgroundToolbar) - - # Copy lost object contour toolbar - self.copyLostObjToolbar = widgets.CopyLostObjectToolbar( - "Copy lost object controls", self - ) - for name, action in self.copyLostObjToolbar.widgetsWithShortcut.items(): - self.widgetsWithShortcut[name] = action - - self.copyLostObjToolbar.sigCopyAllObjects.connect( - self.copyAllLostObjects - ) - - self.addToolBar(Qt.TopToolBarArea, self.copyLostObjToolbar) - self.copyLostObjToolbar.setVisible(False) - # self.controlToolBars.append(self.copyLostObjToolbar) - - # Copy lost object contour toolbar - self.drawClearRegionToolbar = widgets.DrawClearRegionToolbar( - "Draw freehand region and clear objects controls", self - ) - - self.addToolBar(Qt.TopToolBarArea, self.drawClearRegionToolbar) - self.drawClearRegionToolbar.setVisible(False) - self.controlToolBars.append(self.drawClearRegionToolbar) - - try: - addNewIDToggleState = self.df_settings.at['addNewIDsWhitelistToggle', 'value'] == 'Yes' - except KeyError: - addNewIDToggleState = True - - self.whitelistIDsToolbar = widgets.WhitelistIDsToolbar( - addNewIDToggleState, self - ) - for name, action in self.whitelistIDsToolbar.widgetsWithShortcut.items(): - self.widgetsWithShortcut[name] = action - - self.addToolBar(Qt.TopToolBarArea, self.whitelistIDsToolbar) - self.whitelistIDsToolbar.setVisible(False) - self.controlToolBars.append(self.whitelistIDsToolbar) - - self.magicPromptsToolbar = widgets.MagicPromptsToolbar(self) - for name, action in self.magicPromptsToolbar.widgetsWithShortcut.items(): - self.widgetsWithShortcut[name] = action - - self.magicPromptsToolbar.sigComputeOnZoom.connect( - self.magicPromptsComputeOnZoomTriggered - ) - self.magicPromptsToolbar.sigComputeOnImage.connect( - self.magicPromptsComputeOnImageTriggered - ) - self.magicPromptsToolbar.sigInitSelectedModel.connect( - self.magicPromptsInitModel - ) - self.magicPromptsToolbar.sigViewModelParams.connect( - self.viewSetMagicPromptModelParams - ) - self.magicPromptsToolbar.sigClearPoints.connect( - partial(self.magicPromptsClearPoints, only_zoom=False) - ) - self.magicPromptsToolbar.sigClearPointsOnZmom.connect( - partial(self.magicPromptsClearPoints, only_zoom=True) - ) - self.magicPromptsToolbar.sigInterpolateZslice.connect( - self.magicPromptsInterpolateZsliceToggled - ) - - self.addToolBar(Qt.TopToolBarArea, self.magicPromptsToolbar) - self.magicPromptsToolbar.setVisible(False) - self.magicPromptsToolbar.keepVisibleWhenActive = True - self.controlToolBars.append(self.magicPromptsToolbar) - - self.promptSegmentPointsLayerToolbar = ( - widgets.PromptableModelPointsLayerToolbar(parent=self) - ) - self.promptSegmentPointsLayerToolbar.setContextMenuPolicy( - Qt.PreventContextMenu - ) - - self.addToolBar(Qt.TopToolBarArea, self.promptSegmentPointsLayerToolbar) - self.promptSegmentPointsLayerToolbar.setVisible(False) - - self.pointsLayersToolbars.append( - self.promptSegmentPointsLayerToolbar - ) - - # Second level toolbar - secondLevelToolbar = widgets.ToolBar("Second level toolbar", self) - self.addToolBar(Qt.TopToolBarArea, secondLevelToolbar) - self.delObjToolAction = QAction(self) - self.delObjToolAction.setIcon(QIcon(":del_obj_click.svg")) - self.delObjToolAction.setCheckable(True) - self.delObjToolAction.setToolTip( - 'Customisable delete object action\n\n' - 'Go to the `Settings --> Customise keyboard shortcuts...` menu ' - 'on the top menubar\n' - 'to customise the action required to delete ' - 'an object with a click.\n\n' - 'When working with 3D segmentations, to delete only the z-slice mask, hold "Shift" while clicking.' - ) - secondLevelToolbar.addAction(self.delObjToolAction) - secondLevelToolbar.setMovable(False) - self.secondLevelToolbar = secondLevelToolbar - self.secondLevelToolbar.setVisible(False) - - def gui_populateToolSettingsMenu(self): - brushHoverModeActionGroup = QActionGroup(self) - brushHoverModeActionGroup.setExclusive(True) - self.brushHoverCenterModeAction = QAction() - self.brushHoverCenterModeAction.setCheckable(True) - self.brushHoverCenterModeAction.setText( - 'Use center of the brush/eraser cursor to determine hover ID' - ) - self.brushHoverCircleModeAction = QAction() - self.brushHoverCircleModeAction.setCheckable(True) - self.brushHoverCircleModeAction.setText( - 'Use the entire circle of the brush/eraser cursor to determine hover ID' - ) - brushHoverModeActionGroup.addAction(self.brushHoverCenterModeAction) - brushHoverModeActionGroup.addAction(self.brushHoverCircleModeAction) - brushHoverModeMenu = self.settingsMenu.addMenu( - 'Brush/eraser cursor hovering mode' - ) - brushHoverModeMenu.addAction(self.brushHoverCenterModeAction) - brushHoverModeMenu.addAction(self.brushHoverCircleModeAction) - - if 'useCenterBrushCursorHoverID' not in self.df_settings.index: - self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' - - useCenterBrushCursorHoverID = self.df_settings.at[ - 'useCenterBrushCursorHoverID', 'value' - ] == 'Yes' - self.brushHoverCenterModeAction.setChecked(useCenterBrushCursorHoverID) - self.brushHoverCircleModeAction.setChecked(not useCenterBrushCursorHoverID) - - self.brushHoverCenterModeAction.toggled.connect( - self.useCenterBrushCursorHoverIDtoggled - ) - - self.settingsMenu.addSeparator() - - keepToolActiveNames = { - 'Segment range of frames': self.labelRoiTrangeCheckbox - } - for button in self.checkableQButtonsGroup.buttons(): - if button.toolTip() == "": - toolName = "MISSING" - continue - else: - toolName = re.findall(r'Name: (.*)', button.toolTip())[0] - keepToolActiveNames[toolName] = button - - keepToolActiveNames = dict(natsorted(keepToolActiveNames.items())) - - applyToNewFrameNames = { - 'Segmenting for lost IDs': self.segForLostIDsButton, - 'Delete bordering objects': self.delBorderObjAction.button, - 'Delete newly segmented objects': self.delNewObjAction.button, - } - - allToolsList = list(keepToolActiveNames.keys()) + list(applyToNewFrameNames.keys()) - allToolsList = natsorted(allToolsList) - - menus = {} - - for toolName in allToolsList: - menuItemText = f'{toolName} tool'.replace(' ', ' ') - menus[toolName] = self.settingsMenu.addMenu(menuItemText) - - self.keepToolActiveActions = dict() - self.applyToolNewFrameActions = dict() - self.applyToolNewFrameButtons = dict() - all_checked = True - - for toolName, button in keepToolActiveNames.items(): - menu = menus[toolName] - action = QAction(button) - action.setText('Keep tool active after using it') - action.setCheckable(True) - if toolName in self.df_settings.index: - action.setChecked(True) - else: - all_checked = False - action.toggled.connect(self.keepToolActiveActionToggled) - menu.addAction(action) - self.keepToolActiveActions[toolName] = action - - for toolName, button in applyToNewFrameNames.items(): - menu = menus[toolName] - action = QAction(button) - action.setText('Apply when visitng new frame') - action.setCheckable(True) - action.toggled.connect(self.applyToolNewFrameActionToggled) - menu.addAction(action) - self.applyToolNewFrameActions[toolName] = action - self.applyToolNewFrameButtons[toolName] = button - - for toolName in self.applyToolNewFrameActions.keys(): - settingString = toolName.strip() - settingString = toolName.replace(' ', '_') - settingString = f'{settingString}_applyNewFrame' - if settingString in self.df_settings.index: - val = self.df_settings.at[settingString, 'value'] - if val == 'applyNewFrame': - self.applyToolNewFrameActions[toolName].setChecked(True) - - self.settingsMenu.addSeparator() - - self.keepAllToolsActiveToggle = QAction() - self.keepAllToolsActiveToggle.setText( - 'Keep all tools active after using them' - ) - self.keepAllToolsActiveToggle.setCheckable(True) - self.keepAllToolsActiveToggle.setChecked(all_checked) - self.keepAllToolsActiveToggle.toggled.connect( - self.keepAllToolsActiveActionToggled - ) - self.settingsMenu.addAction(self.keepAllToolsActiveToggle) - self.settingsMenu.addSeparator() - - askHowFutureFramesMenu = self.settingsMenu.addMenu( - 'Ask how to propagate changes to future frames' - ) - self.askHowFutureFramesActions = {} - askHowFutureFramesActionsKeys = ( - 'Delete ID', - 'Exclude cell from analysis', - 'Annotate cell as dead', - 'Edit ID', - 'Keep ID' - ) - for key in askHowFutureFramesActionsKeys: - askHowFutureFramesAction = QAction() - askHowFutureFramesAction.setText(f'Ask for "{key}" action') - askHowFutureFramesAction.setCheckable(True) - askHowFutureFramesAction.setChecked(True) - askHowFutureFramesAction.setDisabled(True) - askHowFutureFramesMenu.addAction(askHowFutureFramesAction) - self.askHowFutureFramesActions[key] = askHowFutureFramesAction - - warningsMenu = self.settingsMenu.addMenu('Warnings and pop-ups') - self.warnLostCellsAction = QAction() - self.warnLostCellsAction.setText('Show pop-up warning for lost cells') - self.warnLostCellsAction.setCheckable(True) - self.warnLostCellsAction.setChecked(True) - warningsMenu.addAction(self.warnLostCellsAction) - - warnEditingWithAnnotTexts = { - 'Delete ID': 'Show warning when deleting ID that has annotations', - 'Separate IDs': 'Show warning when separating IDs that have annotations', - 'Edit ID': 'Show warning when editing ID that has annotations', - 'Annotate ID as dead': - 'Show warning when annotating dead ID that has annotations', - 'Delete ID with eraser': - 'Show warning when erasing ID that has annotations', - 'Add new ID with brush tool': - 'Show warning when adding new ID (brush) that has annotations', - 'Merge IDs': - 'Show warning when merging IDs that have annotations', - 'Add new ID with curvature tool': - 'Show warning when adding new ID (curv. tool) that has annotations', - 'Add new ID with magic-wand': - 'Show warning when adding new ID (magic-wand) that has annotations', - 'Delete IDs using ROI': - 'Show warning when using ROIs to delete IDs that have annotations', - } - self.warnEditingWithAnnotActions = {} - for key, desc in warnEditingWithAnnotTexts.items(): - action = QAction() - action.setText(desc) - action.setCheckable(True) - action.setChecked(True) - action.removeAnnot = False - self.warnEditingWithAnnotActions[key] = action - warningsMenu.addAction(action) - - - def gui_createStatusBar(self): - self.statusbar = self.statusBar() - # Permanent widget - self.wcLabel = QLabel('') - self.statusbar.addPermanentWidget(self.wcLabel) - - # self.toggleTerminalButton = widgets.ToggleTerminalButton() - # self.statusbar.addWidget(self.toggleTerminalButton) - # self.toggleTerminalButton.sigClicked.connect( - # self.gui_terminalButtonClicked - # ) - - self.statusBarLabel = QLabel('') - self.statusbar.addWidget(self.statusBarLabel) - - def gui_createTerminalWidget(self): - self.terminal = widgets.QLog(logger=self.logger) - self.terminal.connect() - self.terminalDock = QDockWidget('Log', self) - - self.terminalDock.setWidget(self.terminal) - self.terminalDock.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetFloatable | QDockWidget.DockWidgetFeature.DockWidgetMovable - ) - self.terminalDock.setAllowedAreas(Qt.BottomDockWidgetArea) - self.addDockWidget(Qt.BottomDockWidgetArea, self.terminalDock) - # self.terminalDock.widget().layout().setContentsMargins(10,0,10,0) - self.terminalDock.setVisible(False) - - @resetViewRange - def gui_terminalButtonClicked(self, terminalVisible): - self.terminalDock.setVisible(terminalVisible) - - def gui_createActions(self): - # File actions - self.segmNdimIndicator = widgets.ToolButtonTextIcon(text='') - self.segmNdimIndicator.setCheckable(True) - self.segmNdimIndicator.setChecked(True) - # self.segmNdimIndicator.setDisabled(True) - - if self.debug: - self.createEmptyDataAction = QAction(self) - self.createEmptyDataAction.setText("DEBUG: Create empty data") - - self.newWindowAction = QAction("New Window", self) - - self.newAction = QAction(self) - self.newAction.setText("&New Segmentation File...") - self.newAction.setIcon(QIcon(":file-new.svg")) - self.openFolderAction = QAction( - QIcon(":folder-open.svg"), "&Load Folder...", self - ) - self.openFileAction = QAction( - QIcon(":image.svg"),"&Open Image/Video File...", self - ) - self.manageVersionsAction = QAction( - QIcon(":manage_versions.svg"), "Load Older Versions...", self - ) - self.manageVersionsAction.setDisabled(True) - self.saveAction = QAction(QIcon(":file-save.svg"), "Save", self) - self.saveAsAction = QAction("Save as...", self) - self.exportToVideoAction = QAction("&Video...", self) - self.exportToImageAction = QAction("&Image...", self) - self.quickSaveAction = QAction("Save Only Segmentation Masks", self) - self.loadFluoAction = QAction("Load Fluorescence Images...", self) - self.loadPosAction = QAction("Load Different Position...", self) - # self.reloadAction = QAction( - # QIcon(":reload.svg"), "Reload segmentation file", self - # ) - self.nextAction = QAction('Next', self) - self.prevAction = QAction('Previous', self) - self.showInExplorerAction = QAction( - QIcon(":drawer.svg"), f"&{self.openFolderText}", self - ) - self.exitAction = QAction("&Exit", self) - self.undoAction = QAction(QIcon(":undo.svg"), "Undo", self) - self.redoAction = QAction(QIcon(":redo.svg"), "Redo", self) - # String-based key sequences - self.newWindowAction.setShortcut('Ctrl+Shift+N') - self.newAction.setShortcut('Ctrl+N') - self.openFolderAction.setShortcut('Ctrl+O') - self.loadPosAction.setShortcut('Shift+P') - self.saveAsAction.setShortcut('Ctrl+Shift+S') - self.exportToVideoAction.setShortcut('Ctrl+Shift+V') - self.exportToImageAction.setShortcut('Ctrl+Shift+I') - self.saveAction.setShortcut('Ctrl+Alt+S') - self.quickSaveAction.setShortcut('Ctrl+S') - self.undoAction.setShortcut('Ctrl+Z') - self.redoAction.setShortcut('Ctrl+Y') - self.nextAction.setShortcut(Qt.Key_Right) - self.prevAction.setShortcut(Qt.Key_Left) - self.addAction(self.nextAction) - self.addAction(self.prevAction) - # Help tips - newTip = "Create a new segmentation file" - self.newAction.setStatusTip(newTip) - self.newAction.setWhatsThis("Create a new empty segmentation file") - - self.autoPilotButton = QAction(self) - self.autoPilotButton.setIcon(QIcon(":auto-pilot.svg")) - self.autoPilotButton.setCheckable(True) - self.autoPilotButton.setShortcut('Ctrl+Shift+A') - - self.findIdAction = QAction(self) - self.findIdAction.setIcon(QIcon(":find.svg")) - self.findIdAction.setShortcut('Ctrl+F') - - self.zoomRectButton = QToolButton(self) - self.zoomRectButton.setIcon(QIcon(":zoom_rect.svg")) - self.zoomRectButton.setCheckable(True) - self.zoomRectButton.setShortcut('Shift+Z') - self.LeftClickButtons.append(self.zoomRectButton) - self.checkableButtons.append(self.zoomRectButton) - self.checkableQButtonsGroup.addButton(self.zoomRectButton) - self.widgetsWithShortcut['Zoom to rectangular area'] = ( - self.zoomRectButton - ) - - self.skipToNewIdAction = QAction(self) - self.skipToNewIdAction.setIcon(QIcon(":skip_forward_new_ID.svg")) - self.skipToNewIdAction.setShortcut( - widgets.KeySequenceFromText(Qt.Key_PageUp) - ) - - self.skipToNewIdAction.setDisabled(True) - - # Edit actions - models = myutils.get_list_of_models() - models = [*models, 'local_seg'] # Add local_seg for SegForLostIDsAction - self.segmActions = [] - self.modelNames = [] - self.acdcSegment_li = [] - self.models = [] - for model_name in models: - action = QAction(f"{model_name}...") - self.segmActions.append(action) - self.modelNames.append(model_name) - self.models.append(None) - self.acdcSegment_li.append(None) - action.setDisabled(True) - - self.addCustomModelFrameAction = QAction('Add custom model...', self) - self.addCustomModelVideoAction = QAction('Add custom model...', self) - - self.segmWithPromptableModelAction = QAction( - 'Select promptable model...', self - ) - self.addCustomPromptModelAction = QAction( - 'Add custom promptable model...', self - ) - - self.segmActionsVideo = [] - for model_name in models: - action = QAction(f"{model_name}...") - self.segmActionsVideo.append(action) - action.setDisabled(True) - - self.postProcessSegmAction = QAction( - "Segmentation post-processing...", self - ) - self.postProcessSegmAction.setDisabled(True) - self.postProcessSegmAction.setCheckable(True) - - self.EditSegForLostIDsSetSettings = QAction( - "Edit settings for Segmenting lost IDs...", self - ) - self.EditSegForLostIDsSetSettings.triggered.connect( - self.SegForLostIDsSetSettings - ) - - self.repeatTrackingAction = QAction( - QIcon(":repeat-tracking.svg"), "Repeat tracking", self - ) - self.repeatTrackingAction.setShortcut('Shift+T') - self.widgetsWithShortcut['Repeat Tracking'] = self.repeatTrackingAction - - - self.editRtTrackerParamsAction = QAction( - 'Edit real-time tracker parameters...', self - ) - - self.repeatTrackingMenuAction = QAction( - 'Track current frame with real-time tracker...', self - ) - self.repeatTrackingMenuAction.setDisabled(True) - self.repeatTrackingMenuAction.setShortcut('Shift+T') - - self.repeatTrackingVideoAction = QAction( - 'Select a tracker and track multiple frames...', self - ) - self.repeatTrackingVideoAction.setDisabled(True) - self.repeatTrackingVideoAction.setShortcut('Alt+Shift+T') - - self.trackingAlgosGroup = QActionGroup(self) - self.trackWithAcdcAction = QAction('Cell-ACDC', self) - self.trackWithAcdcAction.setCheckable(True) - self.trackingAlgosGroup.addAction(self.trackWithAcdcAction) - - self.trackWithYeazAction = QAction('YeaZ', self) - self.trackWithYeazAction.setCheckable(True) - self.trackingAlgosGroup.addAction(self.trackWithYeazAction) - - rt_trackers = myutils.get_list_of_real_time_trackers() - for rt_tracker in rt_trackers: - rtTrackerAction = QAction(rt_tracker, self) - rtTrackerAction.setCheckable(True) - self.trackingAlgosGroup.addAction(rtTrackerAction) - - self.trackWithAcdcAction.setChecked(True) - aliases = myutils.aliases_real_time_trackers() - - if 'tracking_algorithm' in self.df_settings.index: - trackingAlgo = self.df_settings.at['tracking_algorithm', 'value'] - if trackingAlgo in aliases: - trackingAlgo = aliases[trackingAlgo] - if trackingAlgo == 'Cell-ACDC': - self.trackWithAcdcAction.setChecked(True) - elif trackingAlgo == 'YeaZ': - self.trackWithYeazAction.setChecked(True) - else: - for rtTrackerAction in self.trackingAlgosGroup.actions(): - if rtTrackerAction.text() == trackingAlgo: - rtTrackerAction.setChecked(True) - break - - self.setMeasurementsAction = QAction('Set measurements...') - self.addCustomMetricAction = QAction('Add custom measurement...') - self.addCombineMetricAction = QAction('Add combined measurement...') - - # Standard key sequence - # self.copyAction.setShortcut(QKeySequence.StandardKey.Copy) - # self.pasteAction.setShortcut(QKeySequence.StandardKey.Paste) - # self.cutAction.setShortcut(QKeySequence.StandardKey.Cut) - # Help actions - self.tipsAction = QAction("Tips and tricks...", self) - self.UserManualAction = QAction("User Documentation...", self) - self.openLogFileAction = QAction("Open log file...", self) - self.showLogFilesAction = QAction("Show log files...", self) - self.aboutAction = QAction("About Cell-ACDC", self) - # self.aboutAction = QAction("&About...", self) - - # Assign mother to bud button - self.assignBudMothAutoAction = QAction(self) - self.assignBudMothAutoAction.setIcon(QIcon(":autoAssign.svg")) - self.assignBudMothAutoAction.setVisible(False) - - self.editCcaToolAction = QAction(self) - self.editCcaToolAction.setIcon(QIcon(":edit_cca.svg")) - # self.editCcaToolAction.setDisabled(True) - self.editCcaToolAction.setVisible(False) - - self.reInitCcaAction = QAction(self) - self.reInitCcaAction.setIcon(QIcon(":reinitCca.svg")) - self.reInitCcaAction.setVisible(False) - - self.toggleColorSchemeAction = QAction( - 'Switch to light theme' - ) - self.gui_updateSwitchColorSchemeActionText() - - self.pxModeAction = widgets.CheckableAction( - 'Fixed size text annotations' - ) - self.pxModeAction.setChecked(True) - pxModeTooltip = ( - 'When the text annotations are with fixed size they scale relative ' - 'to the object when zooming in/out (fixed size in pixels).\n' - 'This is typically faster to render, but it makes annotations ' - 'smaller/larger when zooming in/out, respectively.\n\n' - 'Try activating it to speed up the annotation of many objects ' - 'in high resolution mode.\n\n' - 'After activating it, you might need to increase the font size ' - 'from the menu on the top menubar `Edit --> Font size`.' - ) - self.pxModeAction.setToolTip(pxModeTooltip) - - self.highLowResAction = widgets.CheckableAction( - 'High resolution text annotations' - ) - highLowResTooltip = ( - 'Resolution of the text annotations. High resolution results ' - 'in slower update of the annotations.\n' - 'Not recommended with a number of segmented objects > 500.\n\n' - ) - self.highLowResAction.setToolTip(highLowResTooltip) - - self.editAutoSaveIntervalAction = QAction( - 'Change autosave interval (minutes or frames)...', self - ) - - self.editShortcutsAction = QAction( - 'Customize keyboard shortcuts...', self - ) - self.editShortcutsAction.setShortcut('Ctrl+K') - - self.showMirroredCursorAction = QAction( - 'Show mirrored cursor on images', self - ) - self.showMirroredCursorAction.setCheckable(True) - if 'showMirroredCursor' in self.df_settings.index: - checked = self.df_settings.at['showMirroredCursor', 'value'] == 'Yes' - self.showMirroredCursorAction.setChecked(checked) - else: - self.showMirroredCursorAction.setChecked(True) - self.showMirroredCursorAction.setShortcut('Ctrl+M') - - self.editTextIDsColorAction = QAction('Text annotation color...', self) - self.editTextIDsColorAction.setDisabled(True) - - self.editOverlayColorAction = QAction('Overlay color...', self) - self.editOverlayColorAction.setDisabled(True) - - self.manuallyEditCcaAction = QAction( - 'Edit cell cycle annotations...', self - ) - self.manuallyEditCcaAction.setShortcut('Ctrl+Shift+P') - self.manuallyEditCcaAction.setDisabled(True) - - self.viewCcaTableAction = QAction( - 'View cell cycle annotations...', self - ) - self.viewCcaTableAction.setDisabled(True) - self.viewCcaTableAction.setShortcut('Ctrl+P') - - - self.addScaleBarAction = QAction('Add scale bar', self) - self.addScaleBarAction.setCheckable(True) - - self.addTimestampAction = QAction('Add timestamp', self) - self.addTimestampAction.setCheckable(True) - - self.invertBwAction = QAction('Invert black/white', self) - self.invertBwAction.setCheckable(True) - checked = self.df_settings.at['is_bw_inverted', 'value'] == 'Yes' - self.invertBwAction.setChecked(checked) - - self.shuffleCmapAction = QAction('Randomly shuffle colormap', self) - self.shuffleCmapAction.setShortcut('Shift+S') - - self.greedyShuffleCmapAction = QAction( - 'Greedily shuffle colormap', self - ) - self.greedyShuffleCmapAction.setShortcut('Alt+Shift+S') - - self.saveLabColormapAction = QAction( - 'Save labels colormap...', self - ) - - self.normalizeRawAction = QAction( - 'Do not normalize. Display raw image', self) - self.normalizeToFloatAction = QAction( - 'Convert to floating point format with values [0, 1]', self) - # self.normalizeToUbyteAction = QAction( - # 'Rescale to 8-bit unsigned integer format with values [0, 255]', self) - self.normalizeRescale0to1Action = QAction( - 'Rescale to [0, 1]', self) - self.normalizeByMaxAction = QAction( - 'Normalize by max value', self) - self.normalizeRawAction.setCheckable(True) - self.normalizeToFloatAction.setCheckable(True) - # self.normalizeToUbyteAction.setCheckable(True) - self.normalizeRescale0to1Action.setCheckable(True) - self.normalizeByMaxAction.setCheckable(True) - self.normalizeQActionGroup = QActionGroup(self) - self.normalizeQActionGroup.addAction(self.normalizeRawAction) - self.normalizeQActionGroup.addAction(self.normalizeToFloatAction) - # self.normalizeQActionGroup.addAction(self.normalizeToUbyteAction) - self.normalizeQActionGroup.addAction(self.normalizeRescale0to1Action) - self.normalizeQActionGroup.addAction(self.normalizeByMaxAction) - - self.preprocessAction = QAction( - 'Pre-processing...', self - ) - self.preprocessAction.setShortcut('Alt+Shift+P') - - self.combineChannelsAction = QAction( - 'Combine and manipulate channels and/or segmentation files...', self - ) - self.combineChannelsAction.setShortcut('Alt+Shift+C') - - self.zoomToObjsAction = QAction( - 'Zoom to objects (Shortcut: H key)', self - ) - self.zoomOutAction = QAction( - 'Zoom out (Shortcut: double press H key)', self - ) - - self.relabelSequentialAction = QAction( - 'Relabel IDs sequentially...', self - ) - self.relabelSequentialAction.setShortcut('Ctrl+L') - self.relabelSequentialAction.setDisabled(True) - - self.setLastUserNormAction() - - self.autoSegmAction = QAction( - 'Enable automatic segmentation', self) - self.autoSegmAction.setCheckable(True) - self.autoSegmAction.setDisabled(True) - - self.enableSmartTrackAction = QAction( - 'Smart handling of enabling/disabling tracking', self) - self.enableSmartTrackAction.setCheckable(True) - self.enableSmartTrackAction.setChecked(True) - - self.enableAutoZoomToCellsAction = QAction( - 'Automatic zoom to all cells when pressing "Next/Previous"', self) - self.enableAutoZoomToCellsAction.setCheckable(True) - - self.imgPropertiesAction = QAction('Properties...', self) - self.imgPropertiesAction.setDisabled(True) - - self.addDelRoiAction = QAction(self) - self.addDelRoiAction.roiType = 'rect' - self.addDelRoiAction.setIcon(QIcon(":addDelRoi.svg")) - - self.addDelPolyLineRoiButton = QToolButton(self) - self.addDelPolyLineRoiButton.setCheckable(True) - self.addDelPolyLineRoiButton.setIcon(QIcon(":addDelPolyLineRoi.svg")) - - self.checkableButtons.append(self.addDelPolyLineRoiButton) - self.LeftClickButtons.append(self.addDelPolyLineRoiButton) - - self.delBorderObjAction = QAction(self) - self.delBorderObjAction.setIcon(QIcon(":delBorderObj.svg")) - - self.delNewObjAction = QAction(self) - self.delNewObjAction.setIcon(QIcon(":delNewObj.svg")) - - self.loadCustomAnnotationsAction = QAction(self) - self.loadCustomAnnotationsAction.setIcon(QIcon(":load_annotation.svg")) - self.loadCustomAnnotationsAction.setToolTip( - 'Load previously used custom annotations' - ) - - self.addCustomAnnotationAction = QAction(self) - self.addCustomAnnotationAction.setIcon(QIcon(":addCustomAnnotation.svg")) - self.addCustomAnnotationAction.setToolTip('Add custom annotation') - # self.functionsNotTested3D.append(self.addCustomAnnotationAction) - - self.viewAllCustomAnnotAction = QAction(self) - self.viewAllCustomAnnotAction.setCheckable(True) - self.viewAllCustomAnnotAction.setIcon(QIcon(":eye.svg")) - self.viewAllCustomAnnotAction.setToolTip('Show all custom annotations') - # self.functionsNotTested3D.append(self.viewAllCustomAnnotAction) - - # self.imgGradLabelsAlphaUpAction = QAction(self) - # self.imgGradLabelsAlphaUpAction.setVisible(False) - # self.imgGradLabelsAlphaUpAction.setShortcut('Ctrl+Up') - - def gui_updateSwitchColorSchemeActionText(self): - if self._colorScheme == 'dark': - txt = 'Switch to light theme' - else: - txt = 'Switch to dark theme' - self.toggleColorSchemeAction.setText(txt) - - def gui_connectActions(self): - # Connect File actions - if self.debug: - self.createEmptyDataAction.triggered.connect(self._createEmptyData) - self.segmNdimIndicator.clicked.connect(self.segmNdimIndicatorClicked) - self.newWindowAction.triggered.connect(self.openNewWindow) - self.newAction.triggered.connect(self.newFile) - self.openFolderAction.triggered.connect(self.openFolder) - self.openFileAction.triggered.connect(self.openFile) - self.manageVersionsAction.triggered.connect(self.manageVersions) - self.saveAction.triggered.connect(self.saveData) - self.saveAsAction.triggered.connect(self.saveAsData) - self.exportToVideoAction.triggered.connect(self.exportToVideoTriggered) - self.exportToImageAction.triggered.connect(self.exportToImageTriggered) - self.quickSaveAction.triggered.connect(self.quickSave) - self.viewPreprocDataToggle.toggled.connect( - self.viewPreprocDataToggled - ) - self.viewCombineChannelDataToggle.toggled.connect( - self.viewCombineChannelDataToggled - ) - self.autoSaveToggle.toggled.connect(self.autoSaveToggled) - self.autoSaveAnnotToggle.toggled.connect(self.autoSaveAnnotToggled) - self.autoSaveIntervalDialog.sigValueChanged.connect( - self.autoSaveIntervalValueChanged - ) - self.autoSaveIntervalEditButton.clicked.connect( - self.autoSaveIntervalEdit - ) - self.ccaIntegrCheckerToggle.toggled.connect( - self.ccaIntegrCheckerToggled - ) - self.annotLostObjsToggle.toggled.connect(self.annotLostObjsToggled) - self.highLowResAction.clicked.connect(self.highLowResToggled) - self.showInExplorerAction.triggered.connect(self.showInExplorer_cb) - self.exitAction.triggered.connect(self.close) - self.undoAction.triggered.connect(self.undo) - self.redoAction.triggered.connect(self.redo) - self.nextAction.triggered.connect(self.nextActionTriggered) - self.prevAction.triggered.connect(self.prevActionTriggered) - - self.invertBwAction.toggled.connect(self.invertBw) - self.toggleColorSchemeAction.triggered.connect(self.onToggleColorScheme) - self.pxModeAction.clicked.connect(self.pxModeActionToggled) - self.editShortcutsAction.triggered.connect(self.editShortcuts_cb) - self.editAutoSaveIntervalAction.triggered.connect( - self.autoSaveIntervalEditButton.click - ) - self.showMirroredCursorAction.toggled.connect( - self.showMirroredCursorToggled - ) - - # Connect Help actions - self.tipsAction.triggered.connect(self.showTipsAndTricks) - self.UserManualAction.triggered.connect(myutils.browse_docs) - self.openLogFileAction.triggered.connect(self.openLogFile) - self.showLogFilesAction.triggered.connect(self.showLogFiles) - self.aboutAction.triggered.connect(self.showAbout) - # Connect Open Recent to dynamically populate it - # self.openRecentMenu.aboutToShow.connect(self.populateOpenRecent) - self.checkableQButtonsGroup.buttonClicked.connect(self.uncheckQButton) - - self.showPropsDockButton.sigClicked.connect(self.showPropsDockWidget) - - self.loadCustomAnnotationsAction.triggered.connect( - self.loadCustomAnnotations - ) - self.addCustomAnnotationAction.triggered.connect( - self.addCustomAnnotation - ) - self.viewAllCustomAnnotAction.toggled.connect( - self.viewAllCustomAnnot - ) - self.addCustomModelVideoAction.triggered.connect( - self.showInstructionsCustomModel - ) - self.addCustomModelFrameAction.triggered.connect( - self.showInstructionsCustomModel - ) - self.addCustomModelFrameAction.callback = self.segmFrameCallback - self.addCustomModelVideoAction.callback = self.segmVideoCallback - - self.addCustomPromptModelAction.triggered.connect( - self.showInstructionsCustomPromptModel - ) - self.segmWithPromptableModelAction.triggered.connect( - self.segmWithPromptableModelActionTriggered - ) - - def zProjLockViewToggled(self, checked): - self.updateZproj(self.zProjComboBox.currentText()) - - def rescaleIntensExportToVideoDialog(self, how, channel, setImage=True): - if channel == self.user_ch_name: - lutItem = self.imgGrad - else: - lutItem = self.overlayLayersItems[channel][1] - - for action in lutItem.rescaleActionGroup.actions(): - if action.text() == how: - action.trigger() - # self.rescaleIntensitiesLut(setImage=setImage) - break - - def customLevelsLutChanged(self, levels, imageItem=None): - imageItem.setLevels(levels) - - def getPreComputedMinMaxZstack(self, channel: str): - if channel != self.user_ch_name: - return None - - posData = self.data[self.pos_i] - zstack_min, zstack_max = np.inf, 0 - for z in range(posData.SizeZ): - key = (self.pos_i, posData.frame_i, z) - levels = self.img1.minMaxValuesMapper.get(key) - if levels is None: - return - - img_min, img_max = levels - if img_min < zstack_min: - zstack_min = img_min - - if img_max > zstack_max: - zstack_max = img_max - - return (zstack_min, zstack_max) - - # @exec_time - def rescaleIntensitiesLut( - self, - action: QAction=None, - setImage: bool=True, - imageItem=None - ): - if not self.isDataLoaded: - self.logger.info( - 'WARNING: Data is not loaded. ' - 'Intensities will be rescaled later.' - ) - return - - posData = self.data[self.pos_i] - if imageItem is None: - imageItem = self.img1 - channel = self.user_ch_name - image_data = posData.img_data - else: - channel = imageItem.channelName - _, filename = self.getPathFromChName(channel, posData) - image_data = posData.fluo_data_dict[filename] - - triggeredByUser = True - if action is None: - triggeredByUser = False - action = imageItem.lutItem.rescaleActionGroup.checkedAction() - - how = action.text() - - self.df_settings.at[f'how_rescale_intensities_{channel}', 'value'] = how - self.df_settings.to_csv(self.settings_csv_path) - - if how == 'Rescale each 2D image': - if how == self.rescaleIntensChannelHowMapper[channel]: - # No need to update since we have autoscale - return - - imageItem.setEnableAutoLevels(True) - if setImage: - imageItem.setImage(imageItem.image) - return - - lutLevelsCh = posData.lutLevels[channel] - - if how == 'Rescale across z-stack': - imageItem.setEnableAutoLevels(False) - levels_key = (how, posData.frame_i) - levels = lutLevelsCh.get(levels_key) - if levels is None: - levels = self.getPreComputedMinMaxZstack(channel) - - if levels is None: - image_zstack = image_data[posData.frame_i] - levels = (image_zstack.min(), image_zstack.max()) - lutLevelsCh[levels_key] = levels - imageItem.setLevels(levels) - elif how == 'Rescale across time frames': - imageItem.setEnableAutoLevels(False) - levels_key = (how, None) - levels = lutLevelsCh.get(levels_key) - if levels is None: - levels = (image_data.min(), image_data.max()) - - lutLevelsCh[levels_key] = levels - imageItem.setLevels(levels) - elif how == 'Choose custom levels...': - autoLevelsEnabledBefore = imageItem.autoLevelsEnabled - imageItem.setEnableAutoLevels(False) - if triggeredByUser: - current_min, current_max = imageItem.getLevels() - dtype_max = np.iinfo(image_data.dtype).max - max_value = image_data.max() - min_value = image_data.min() - win = apps.SetCustomLevelsLut( - init_min_value=current_min, - init_max_value=current_max, - maximum_max_value=max_value, - minimum_min_value=min_value, - parent=self - ) - win.sigLevelsChanged.connect( - partial(self.customLevelsLutChanged, imageItem=imageItem) - ) - win.exec_() - if win.cancel: - imageItem.setEnableAutoLevels(autoLevelsEnabledBefore) - self.logger.info('Custom LUT levels setting cancelled.') - self.updateAllImages() - return - selectedLevels = win.selectedLevels - else: - selectedLevels = imageItem.getLevels() - imageItem.setLevels(selectedLevels) - elif how == 'Do no rescale, display raw image': - imageItem.setEnableAutoLevels(False) - levels_key = (how, None) - levels = lutLevelsCh.get(levels_key) - if levels is None: - dtype_max = np.iinfo(image_data.dtype).max - levels = (0, dtype_max) - lutLevelsCh[levels_key] = levels - imageItem.setLevels(levels) - - self.rescaleIntensChannelHowMapper[channel] = how - - if setImage: - imageItem.setImage(imageItem.image) - - def onToggleColorScheme(self): - if self.toggleColorSchemeAction.text().find('light') != -1: - self._colorScheme = 'light' - setDarkModeToggleChecked = False - else: - self._colorScheme = 'dark' - setDarkModeToggleChecked = True - self.gui_updateSwitchColorSchemeActionText() - _warnings.warnRestartCellACDCcolorModeToggled( - self._colorScheme, app_name=self._appName, parent=self - ) - load.rename_qrc_resources_file(self._colorScheme) - self.statusBarLabel.setText(html_utils.paragraph( - f'Restart {self._appName} for the change to take effect', - font_color='red' - )) - self.df_settings.at['colorScheme', 'value'] = self._colorScheme - self.df_settings.to_csv(settings_csv_path) - - def showMirroredCursorToggled(self, checked): - value = 'Yes' if checked else 'No' - self.df_settings.at['showMirroredCursor', 'value'] = value - self.df_settings.to_csv(settings_csv_path) - - if not checked: - self.clearCursors() - - def clearCursors(self): - self.ax1_cursor.setData([], []) - self.ax2_cursor.setData([], []) - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - eraserCursors = ( - self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX - ) - self.setHoverToolSymbolData([], [], eraserCursors) - - def activeEraserCircleCursors(self, isHoverImg1): - if self.showMirroredCursorAction.isChecked(): - return self.ax1_EraserCircle, self.ax2_EraserCircle - - if isHoverImg1: - return self.ax1_EraserCircle, - else: - return self.ax2_EraserCircle, - - def activeEraserXCursors(self, isHoverImg1): - if self.showMirroredCursorAction.isChecked(): - return self.ax1_EraserX, self.ax2_EraserX - - if isHoverImg1: - return self.ax1_EraserX, - else: - return self.ax2_EraserX, - - def activeBrushCircleCursors(self, isHoverImg1): - if self.showMirroredCursorAction.isChecked(): - return self.ax1_BrushCircle, self.ax2_BrushCircle - - if isHoverImg1: - return self.ax1_BrushCircle, - else: - return self.ax2_BrushCircle, - - def gui_connectEditActions(self): - self.showInExplorerAction.setEnabled(True) - self.setEnabledFileToolbar(True) - self.loadFluoAction.setEnabled(True) - self.isEditActionsConnected = True - - self.preprocessImageAction.triggered.connect( - self.preprocessAction.trigger - ) - self.combineChannelsAction.triggered.connect( - self.combineChannelsActionTriggered - ) - - self.overlayButton.toggled.connect(self.overlay_cb) - self.countObjsButton.toggled.connect(self.countObjectsCb) - self.togglePointsLayerAction.toggled.connect(self.pointsLayerToggled) - self.overlayLabelsButton.toggled.connect(self.overlayLabels_cb) - self.overlayButton.sigRightClick.connect(self.showOverlayContextMenu) - self.labelRoiButton.sigRightClick.connect(self.showLabelRoiContextMenu) - self.overlayLabelsButton.sigRightClick.connect( - self.showOverlayLabelsContextMenu - ) - self.rulerButton.toggled.connect(self.ruler_cb) - self.loadFluoAction.triggered.connect(self.loadFluo_cb) - self.loadPosAction.triggered.connect(self.loadPosTriggered) - # self.reloadAction.triggered.connect(self.reload_cb) - self.findIdAction.triggered.connect(self.findID) - self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) - self.autoPilotButton.toggled.connect(self.autoPilotToggled) - self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID) - self.slideshowButton.toggled.connect(self.launchSlideshow) - - self.copyLostObjButton.toggled.connect(self.copyLostObjContour_cb) - self.manualAnnotPastButton.toggled.connect( - self.manualAnnotPast_cb - ) - - self.segmSingleFrameMenu.triggered.connect(self.segmFrameCallback) - self.segmVideoMenu.triggered.connect(self.segmVideoCallback) - - self.postProcessSegmAction.toggled.connect(self.postProcessSegm) - self.autoSegmAction.toggled.connect(self.autoSegm_cb) - self.realTimeTrackingToggle.clicked.connect(self.realTimeTrackingClicked) - self.repeatTrackingAction.triggered.connect(self.repeatTracking) - self.manualTrackingButton.toggled.connect(self.manualTracking_cb) - self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) - self.repeatTrackingMenuAction.triggered.connect(self.repeatTracking) - self.repeatTrackingVideoAction.triggered.connect( - self.repeatTrackingVideo - ) - for rtTrackerAction in self.trackingAlgosGroup.actions(): - rtTrackerAction.toggled.connect(self.rtTrackerActionToggled) - self.editRtTrackerParamsAction.triggered.connect( - self.initRealTimeTracker - ) - self.delObjsOutSegmMaskAction.triggered.connect( - self.delObjsOutSegmMaskActionTriggered - ) - self.mergeIDsButton.toggled.connect(self.mergeObjs_cb) - self.brushButton.toggled.connect(self.Brush_cb) - self.eraserButton.toggled.connect(self.Eraser_cb) - self.curvToolButton.toggled.connect(self.curvTool_cb) - self.wandToolButton.toggled.connect(self.wand_cb) - self.labelRoiButton.toggled.connect(self.labelRoi_cb) - self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) - self.drawClearRegionButton.toggled.connect(self.drawClearRegion_cb) - self.reInitCcaAction.triggered.connect(self.reInitCca) - self.moveLabelToolButton.toggled.connect(self.moveLabelButtonToggled) - self.editCcaToolAction.triggered.connect( - self.manualEditCcaToolbarActionTriggered - ) - self.assignBudMothAutoAction.triggered.connect( - self.autoAssignBud_YeastMate - ) - self.keepIDsButton.toggled.connect(self.keepIDs_cb) - - self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) - - self.whitelistIDsToolbar.sigWhitelistChanged.connect( - self.whitelistIDsChanged - ) - - self.whitelistIDsToolbar.sigWhitelistAccepted.connect( - self.whitelistIDsAccepted - ) - - self.whitelistIDsToolbar.sigViewOGIDs.connect(self.whitelistViewOGIDs) - - self.whitelistIDsToolbar.sigAddNewIDs.connect(self.whitelistAddNewIDsToggled) - - self.whitelistIDsToolbar.sigLoadOGLabs.connect(self.whitelistLoadOGLabs_cb) - - self.whitelistIDsToolbar.sigTrackOGagainstPreviousFrame.connect( - self.whitelistTrackOGagainstPreviousFrame_cb - ) - - self.expandLabelToolButton.toggled.connect(self.expandLabelCallback) - - self.reinitLastSegmFrameAction.triggered.connect( - self.reInitLastSegmFrame - ) - - - self.defaultRescaleIntensActionGroup.triggered.connect( - self.defaultRescaleIntensLutActionToggled - ) - - # self.repeatAutoCcaAction.triggered.connect(self.repeatAutoCca) - self.manuallyEditCcaAction.triggered.connect(self.manualEditCca) - self.addScaleBarAction.toggled.connect(self.addScaleBar) - self.addTimestampAction.toggled.connect(self.addTimestamp) - self.saveLabColormapAction.triggered.connect(self.saveLabelsColormap) - - self.enableSmartTrackAction.toggled.connect(self.enableSmartTrack) - # Brush/Eraser size action - self.brushSizeSpinbox.valueChanged.connect(self.brushSize_cb) - self.autoIDcheckbox.toggled.connect(self.autoIDtoggled) - # Mode - self.modeActionGroup.triggered.connect(self.changeModeFromMenu) - self.modeComboBox.sigTextChanged.connect(self.changeMode) - self.modeComboBox.activated.connect(self.clearComboBoxFocus) - self.equalizeHistPushButton.toggled.connect(self.equalizeHist) - - self.editOverlayColorAction.triggered.connect(self.toggleOverlayColorButton) - self.editTextIDsColorAction.triggered.connect(self.toggleTextIDsColorButton) - self.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) - self.overlayColorButton.sigColorChanged.connect(self.saveOverlayColor) - self.textIDsColorButton.sigColorChanging.connect(self.updateTextAnnotColor) - self.textIDsColorButton.sigColorChanged.connect(self.saveTextIDsColors) - - self.setMeasurementsAction.triggered.connect(self.showSetMeasurements) - self.addCustomMetricAction.triggered.connect(self.addCustomMetric) - self.addCombineMetricAction.triggered.connect(self.addCombineMetric) - - self.labelsGrad.colorButton.sigColorChanging.connect(self.updateBkgrColor) - self.labelsGrad.colorButton.sigColorChanged.connect(self.saveBkgrColor) - self.labelsGrad.sigGradientChangeFinished.connect(self.updateLabelsCmap) - self.labelsGrad.sigGradientChanged.connect(self.ticksCmapMoved) - self.labelsGrad.textColorButton.sigColorChanging.connect( - self.updateTextLabelsColor - ) - self.labelsGrad.textColorButton.sigColorChanged.connect( - self.saveTextLabelsColor - ) - # self.addFontSizeActions( - # self.labelsGrad.fontSizeMenu, self.setFontSizeActionChecked - # ) - - self.labelsGrad.shuffleCmapAction.triggered.connect(self.shuffle_cmap) - self.labelsGrad.greedyShuffleCmapAction.triggered.connect( - self.greedyShuffleCmap - ) - self.labelsGrad.permanentGreedyCmapAction.toggled.connect( - self.permanentGreedyCmapToggled - ) - self.shuffleCmapAction.triggered.connect(self.shuffle_cmap) - self.greedyShuffleCmapAction.triggered.connect(self.greedyShuffleCmap) - self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) - self.labelsGrad.sigShowLabelsImgToggled.connect(self.showLabelImageItem) - self.labelsGrad.sigShowRightImgToggled.connect(self.showRightImageItem) - self.labelsGrad.sigShowNextFrameToggled.connect(self.showNextFrameImageItem) - - self.labelsGrad.defaultSettingsAction.triggered.connect( - self.restoreDefaultSettings - ) - - # self.addFontSizeActions( - # self.imgGrad.fontSizeMenu, self.setFontSizeActionChecked - # ) - self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) - self.imgGrad.textColorButton.disconnect() - self.imgGrad.textColorButton.clicked.connect( - self.editTextIDsColorAction.trigger - ) - self.imgGrad.labelsAlphaSlider.valueChanged.connect( - self.updateLabelsAlpha - ) - self.imgGrad.defaultSettingsAction.triggered.connect( - self.restoreDefaultSettings - ) - - # Drawing mode - self.drawIDsContComboBox.currentIndexChanged.connect( - self.drawIDsContComboBox_cb - ) - self.drawIDsContComboBox.activated.connect(self.clearComboBoxFocus) - - self.annotateRightHowCombobox.currentIndexChanged.connect( - self.annotateRightHowCombobox_cb - ) - self.annotateRightHowCombobox.activated.connect(self.clearComboBoxFocus) - - self.showTreeInfoCheckbox.toggled.connect(self.setAnnotInfoMode) - - # Left - self.annotIDsCheckbox.clicked.connect(self.annotOptionClicked) - self.annotCcaInfoCheckbox.clicked.connect(self.annotOptionClicked) - self.annotContourCheckbox.clicked.connect(self.annotOptionClicked) - self.annotSegmMasksCheckbox.clicked.connect(self.annotOptionClicked) - self.drawMothBudLinesCheckbox.clicked.connect(self.annotOptionClicked) - self.drawNothingCheckbox.clicked.connect(self.annotOptionClicked) - self.annotNumZslicesCheckbox.clicked.connect(self.annotOptionClicked) - - # Right - self.annotIDsCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotCcaInfoCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotContourCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotSegmMasksCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.drawMothBudLinesCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.drawNothingCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotNumZslicesCheckboxRight.clicked.connect( - self.annotOptionClickedRight - ) - - self.segmentToolAction.triggered.connect(self.segmentToolActionTriggered) - - self.addDelRoiAction.triggered.connect(self.addDelROI) - self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) - self.delBorderObjAction.triggered.connect(self.delBorderObj) - self.delNewObjAction.triggered.connect(self.delNewObj) - - self.brushAutoFillCheckbox.toggled.connect(self.brushAutoFillToggled) - self.brushAutoHideCheckbox.toggled.connect(self.brushAutoHideToggled) - - self.imgGrad.sigAddScaleBar.connect(self.addScaleBarAction.setChecked) - self.imgGrad.sigAddTimestamp.connect(self.addTimestampAction.setChecked) - self.imgGrad.gradient.sigGradientChangeFinished.connect( - self.imgGradLUTfinished_cb - ) - - # self.normalizeQActionGroup.triggered.connect( - # self.normaliseIntensitiesActionTriggered - # ) - self.imgPropertiesAction.triggered.connect(self.editImgProperties) - - self.relabelSequentialAction.triggered.connect( - self.relabelSequentialCallback - ) - - self.zoomToObjsAction.triggered.connect(self.zoomToObjsActionCallback) - self.zoomOutAction.triggered.connect(self.zoomOut) - self.preprocessAction.triggered.connect(self.preprocessActionTriggered) - self.combineChannelsAction.triggered.connect(self.combineChannelsActionTriggered) - - self.viewCcaTableAction.triggered.connect(self.viewCcaTable) - - self.guiTabControl.propsQGBox.idSB.valueChanged.connect( - self.propsWidgetIDvalueChanged - ) - self.guiTabControl.highlightCheckbox.toggled.connect( - self.highlightIDonHoverCheckBoxToggled - ) - self.guiTabControl.highlightSearchedCheckbox.toggled.connect( - self.highlightSearchedIDcheckBoxToggled - ) - intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox - intensMeasurQGBox.additionalMeasCombobox.currentTextChanged.connect( - self.updatePropsWidget - ) - intensMeasurQGBox.channelCombobox.currentTextChanged.connect( - self.updatePropsWidget - ) - - propsQGBox = self.guiTabControl.propsQGBox - propsQGBox.additionalPropsCombobox.currentTextChanged.connect( - self.updatePropsWidget - ) - - def gui_createShowPropsButton(self, side='left'): - self.leftSideDocksLayout = QVBoxLayout() - self.leftSideDocksLayout.setSpacing(0) - self.leftSideDocksLayout.setContentsMargins(0,0,0,0) - self.rightSideDocksLayout = QVBoxLayout() - self.rightSideDocksLayout.setSpacing(0) - self.rightSideDocksLayout.setContentsMargins(0,0,0,0) - self.showPropsDockButton = widgets.expandCollapseButton() - self.showPropsDockButton.setDisabled(True) - self.showPropsDockButton.setFocusPolicy(Qt.NoFocus) - self.showPropsDockButton.setToolTip('Show object properties') - if side == 'left': - self.leftSideDocksLayout.addWidget(self.showPropsDockButton) - else: - self.rightSideDocksLayout.addWidget(self.showPropsDockButton) - - def gui_createQuickSettingsWidgets(self): - self.quickSettingsLayout = QVBoxLayout() - self.quickSettingsGroupbox = widgets.GroupBox() - self.quickSettingsGroupbox.setTitle('Quick settings') - - layout = QFormLayout() - layout.setFieldGrowthPolicy( - QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint - ) - layout.setFormAlignment(Qt.AlignRight | Qt.AlignVCenter) - - self.viewPreprocDataToggle = widgets.Toggle() - viewPreprocDataToggleTooltip = ( - 'View pre-processed data. See menu `Image --> Pre-processing...`\n' - 'on the top menubar.' - ) - self.viewPreprocDataToggle.setChecked(False) - self.viewPreprocDataToggle.setToolTip(viewPreprocDataToggleTooltip) - viewPreprocDataToggleLabel = QLabel('View pre-processed image') - viewPreprocDataToggleLabel.setToolTip(viewPreprocDataToggleTooltip) - layout.addRow(viewPreprocDataToggleLabel, self.viewPreprocDataToggle) - - self.viewCombineChannelDataToggle = widgets.Toggle() - viewCombineChannelDataToggleTooltip = ( - 'View combined channel. See menu `Image --> combing channels...`\n' - 'on the top menubar.' - ) - self.viewCombineChannelDataToggle.setChecked(False) - self.viewCombineChannelDataToggle.setToolTip( - viewCombineChannelDataToggleTooltip - ) - viewCombineChannelDataToggleLabel = QLabel('View combined channels') - viewCombineChannelDataToggleLabel.setToolTip( - viewCombineChannelDataToggleTooltip - ) - layout.addRow( - viewCombineChannelDataToggleLabel, - self.viewCombineChannelDataToggle - ) - - self.autoSaveToggle = widgets.Toggle() - autoSaveTooltip = ( - 'Automatically store a copy of the segmentation data ' - 'in the `.recovery` folder after every edit.' - ) - self.autoSaveToggle.setChecked(True) - self.autoSaveToggle.setToolTip(autoSaveTooltip) - autoSaveLabel = QLabel('Autosave segmentation') - autoSaveLabel.setToolTip(autoSaveTooltip) - layout.addRow(autoSaveLabel, self.autoSaveToggle) - - self.autoSaveAnnotToggle = widgets.Toggle() - autoSaveAnnotTooltip = ( - 'Automatically store a copy of the annotations (acdc_output CSV file) ' - 'in the `.recovery` folder after every edit.' - ) - self.autoSaveAnnotToggle.setChecked(True) - self.autoSaveAnnotToggle.setToolTip(autoSaveAnnotTooltip) - autoSaveAnnotLabel = QLabel('Autosave annotations') - autoSaveAnnotLabel.setToolTip(autoSaveAnnotTooltip) - layout.addRow(autoSaveAnnotLabel, self.autoSaveAnnotToggle) - - self.autoSaveIntervalEditButton = widgets.editPushButton( - flat=True, hoverable=True - ) - self.autoSaveIntervalLabel = QLabel('Autosave interval') - self.autoSaveIntervalSetTooltip() - layout.addRow( - self.autoSaveIntervalLabel, self.autoSaveIntervalEditButton - ) - - self.autoSaveIntervalDialog = apps.AutoSaveIntervalDialog(parent=self) - self.autoSaveIntervalDialog.setValues(*self.autoSaveIntevalValueUnit) - - self.ccaIntegrCheckerToggle = widgets.Toggle() - ccaIntegrCheckerToggleTooltip = ( - 'Toggle background cell cycle annotations integrity checker ON/OFF' - ) - self.ccaIntegrCheckerToggle.setChecked(False) - self.ccaIntegrCheckerToggle.setToolTip(ccaIntegrCheckerToggleTooltip) - label = QLabel('Cc annot. checker') - label.setToolTip(ccaIntegrCheckerToggleTooltip) - layout.addRow(label, self.ccaIntegrCheckerToggle) - if 'is_cca_integrity_checker_activated' in self.df_settings.index: - idx = 'is_cca_integrity_checker_activated' - val = int(self.df_settings.at[idx, 'value']) - self.ccaIntegrCheckerToggle.setChecked(not val) - - self.annotLostObjsToggle = widgets.Toggle() - annotLostObjsToggleTooltip = ( - 'Toggle annotation of lost objects mode ON/OFF' - ) - self.annotLostObjsToggle.setChecked(True) - self.annotLostObjsToggle.setToolTip(annotLostObjsToggleTooltip) - label = QLabel('Annot. lost objects') - label.setToolTip(annotLostObjsToggleTooltip) - layout.addRow(label, self.annotLostObjsToggle) - - self.realTimeTrackingToggle = widgets.Toggle() - self.realTimeTrackingToggle.setChecked(True) - self.realTimeTrackingToggle.setDisabled(True) - label = QLabel('Real-time tracking') - label.setDisabled(True) - self.realTimeTrackingToggle.label = label - layout.addRow(label, self.realTimeTrackingToggle) - - self.showAllContoursToggle = widgets.Toggle() - showAllContoursTooltip = ( - 'If active, all contours will be displayed, including inner contours' - '(e.g. holes and sub-objects)' - ) - self.showAllContoursToggle.setToolTip(showAllContoursTooltip) - showAllContourLabel = QLabel('Show all contours') - showAllContourLabel.setToolTip(showAllContoursTooltip) - layout.addRow(showAllContourLabel, self.showAllContoursToggle) - self.showAllContoursToggle.toggled.connect( - self.showAllContoursToggled - ) - - # Font size - self.fontSizeSpinBox = widgets.SpinBox() - self.fontSizeSpinBox.setMinimum(1) - self.fontSizeSpinBox.setMaximum(99) - layout.addRow('Font size', self.fontSizeSpinBox) - savedFontSize = str(self.df_settings.at['fontSize', 'value']) - if savedFontSize.find('pt') != -1: - savedFontSize = savedFontSize[:-2] - self.fontSize = int(savedFontSize) - if 'pxMode' not in self.df_settings.index: - # Users before introduction of pxMode had pxMode=False, but now - # the new default is True. This requires larger font size. - self.fontSize = 2*self.fontSize - self.df_settings.at['pxMode', 'value'] = 1 - self.df_settings.to_csv(settings_csv_path) - self.fontSizeSpinBox.setValue(self.fontSize) - self.fontSizeSpinBox.editingFinished.connect(self.changeFontSize) - self.fontSizeSpinBox.sigUpClicked.connect(self.changeFontSize) - self.fontSizeSpinBox.sigDownClicked.connect(self.changeFontSize) - - self.quickSettingsGroupbox.setLayout(layout) - self.quickSettingsLayout.addWidget(self.quickSettingsGroupbox) - self.quickSettingsLayout.addStretch(1) - - def showAllContoursToggled(self): - if not self.isDataLoaded: - return - - self.computeAllContours() - self.updateAllImages() - - def gui_createImg1Widgets(self): - # Toggle contours/ID combobox - self.drawIDsContComboBoxSegmItems = [ - 'Draw IDs and contours', - 'Draw IDs and overlay segm. masks', - 'Draw only cell cycle info', - 'Draw cell cycle info and contours', - 'Draw cell cycle info and overlay segm. masks', - 'Draw only mother-bud lines', - 'Draw only IDs', - 'Draw only contours', - 'Draw only overlay segm. masks', - 'Draw nothing' - ] - self.drawIDsContComboBox = widgets.ComboBox() - self.drawIDsContComboBox.setFont(_font) - self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) - self.drawIDsContComboBox.setVisible(False) - - self.annotIDsCheckbox = widgets.CheckBox( - 'IDs', keyPressCallback=self.resetFocus) - self.annotCcaInfoCheckbox = widgets.CheckBox( - 'Cell cycle info', keyPressCallback=self.resetFocus) - self.annotNumZslicesCheckbox = widgets.CheckBox( - 'No. z-slices/object', keyPressCallback=self.resetFocus) - - self.annotContourCheckbox = widgets.CheckBox( - 'Contours', keyPressCallback=self.resetFocus) - self.annotSegmMasksCheckbox = widgets.CheckBox( - 'Segm. masks', keyPressCallback=self.resetFocus) - - self.drawMothBudLinesCheckbox = widgets.CheckBox( - 'Only mother-daughter line', keyPressCallback=self.resetFocus - ) - - self.drawNothingCheckbox = widgets.CheckBox( - 'Do not annotate', keyPressCallback=self.resetFocus - ) - - self.annotOptionsWidget = QWidget() - annotOptionsLayout = QHBoxLayout() - - # Show tree info checkbox - self.showTreeInfoCheckbox = widgets.CheckBox( - 'Show tree info', keyPressCallback=self.resetFocus - ) - self.showTreeInfoCheckbox.setFont(_font) - sp = self.showTreeInfoCheckbox.sizePolicy() - sp.setRetainSizeWhenHidden(True) - self.showTreeInfoCheckbox.setSizePolicy(sp) - self.showTreeInfoCheckbox.hide() - - annotOptionsLayout.addWidget(self.showTreeInfoCheckbox) - annotOptionsLayout.addWidget(QLabel(' | ')) - annotOptionsLayout.addWidget(self.annotIDsCheckbox) - annotOptionsLayout.addWidget(self.annotCcaInfoCheckbox) - annotOptionsLayout.addWidget(self.drawMothBudLinesCheckbox) - annotOptionsLayout.addWidget(self.annotNumZslicesCheckbox) - annotOptionsLayout.addWidget(QLabel(' | ')) - annotOptionsLayout.addWidget(self.annotContourCheckbox) - annotOptionsLayout.addWidget(self.annotSegmMasksCheckbox) - annotOptionsLayout.addWidget(QLabel(' | ')) - annotOptionsLayout.addWidget(self.drawNothingCheckbox) - annotOptionsLayout.addWidget(self.drawIDsContComboBox) - self.annotOptionsLayout = annotOptionsLayout - - # Toggle highlight z+-1 objects combobox - self.highlightZneighObjCheckbox = widgets.CheckBox( - 'Highlight objects in neighbouring z-slices', - keyPressCallback=self.resetFocus - ) - self.highlightZneighObjCheckbox.setFont(_font) - self.highlightZneighObjCheckbox.hide() - - annotOptionsLayout.addWidget(self.highlightZneighObjCheckbox) - self.annotOptionsWidget.setLayout(annotOptionsLayout) - - # Annotations options right image - self.annotIDsCheckboxRight = widgets.CheckBox( - 'IDs', keyPressCallback=self.resetFocus) - self.annotCcaInfoCheckboxRight = widgets.CheckBox( - 'Cell cycle info', keyPressCallback=self.resetFocus) - self.annotNumZslicesCheckboxRight = widgets.CheckBox( - 'No. z-slices/object', keyPressCallback=self.resetFocus - ) - - self.annotContourCheckboxRight = widgets.CheckBox( - 'Contours', keyPressCallback=self.resetFocus) - self.annotSegmMasksCheckboxRight = widgets.CheckBox( - 'Segm. masks', keyPressCallback=self.resetFocus) - - self.drawMothBudLinesCheckboxRight = widgets.CheckBox( - 'Only mother-daughter line', keyPressCallback=self.resetFocus - ) - - self.drawNothingCheckboxRight = widgets.CheckBox( - 'Do not annotate', keyPressCallback=self.resetFocus) - - self.annotOptionsWidgetRight = QWidget() - annotOptionsLayoutRight = QHBoxLayout() - - annotOptionsLayoutRight.addWidget(QLabel(' ')) - annotOptionsLayoutRight.addWidget(QLabel(' | ')) - annotOptionsLayoutRight.addWidget(self.annotIDsCheckboxRight) - annotOptionsLayoutRight.addWidget(self.annotCcaInfoCheckboxRight) - annotOptionsLayoutRight.addWidget(self.drawMothBudLinesCheckboxRight) - annotOptionsLayoutRight.addWidget(self.annotNumZslicesCheckboxRight) - annotOptionsLayoutRight.addWidget(QLabel(' | ')) - annotOptionsLayoutRight.addWidget(self.annotContourCheckboxRight) - annotOptionsLayoutRight.addWidget(self.annotSegmMasksCheckboxRight) - annotOptionsLayoutRight.addWidget(QLabel(' | ')) - annotOptionsLayoutRight.addWidget(self.drawNothingCheckboxRight) - self.annotOptionsLayoutRight = annotOptionsLayoutRight - - self.annotOptionsWidgetRight.setLayout(annotOptionsLayoutRight) - - # Frames scrollbar - self.navigateScrollBar = widgets.navigateQScrollBar(Qt.Horizontal) - self.navigateScrollBar.setDisabled(True) - self.navigateScrollBar.setMinimum(1) - self.navigateScrollBar.setMaximum(1) - self.navigateScrollBar.setToolTip( - 'NOTE: The maximum frame number that can be visualized with this ' - 'scrollbar\n' - 'is the last visited frame with the selected mode\n' - '(see "Mode" selector on the top-right).\n\n' - 'If the scrollbar does not move it means that you never visited\n' - 'any frame with current mode.\n\n' - 'Note that the "Viewer" mode allows you to scroll ALL frames.' - ) - t_label = QLabel('frame n. ') - t_label.setFont(_font) - self.t_label = t_label - - # z-slice scrollbars - self.zSliceScrollBar = widgets.linkedQScrollbar(Qt.Horizontal) - - self.zProjComboBox = widgets.ComboBox() - self.zProjComboBox.setFont(_font) - self.zProjComboBox.addItems([ - 'single z-slice', - 'max z-projection', - 'mean z-projection', - 'median z-proj.' - ]) - self.zProjLockViewButton = widgets.LockPushButton() - self.zProjLockViewButton.setCheckable(True) - self.zProjLockViewButton.setToolTip( - 'If active, the selected z-slice view is applied to all frames' - ) - self.zProjLockViewButton.hide() - - self.switchPlaneCombobox = widgets.SwitchPlaneCombobox() - self.switchPlaneCombobox.setToolTip( - 'Switch viewed plane' - ) - - self.zSliceOverlay_SB = widgets.ScrollBar(Qt.Horizontal) - _z_label = QLabel('Overlay z-slice ') - _z_label.setFont(_font) - _z_label.setDisabled(True) - self.overlay_z_label = _z_label - - self.zProjOverlay_CB = widgets.ComboBox() - self.zProjOverlay_CB.setFont(_font) - self.zProjOverlay_CB.addItems([ - 'single z-slice', 'max z-projection', 'mean z-projection', - 'median z-proj.', 'same as above' - ]) - self.zProjOverlay_CB.setCurrentIndex(4) - self.zSliceOverlay_SB.setDisabled(True) - - self.img1BottomGroupbox = self.gui_getImg1BottomWidgets() - - def gui_getImg1BottomWidgets(self): - bottomLeftLayout = QGridLayout() - self.bottomLeftLayout = bottomLeftLayout - container = QGroupBox('Navigate and annotate left image') - - row = 0 - bottomLeftLayout.addWidget(self.annotOptionsWidget, row, 0, 1, 4) - # bottomLeftLayout.addWidget( - # self.drawIDsContComboBox, row, 1, 1, 2, - # alignment=Qt.AlignCenter - # ) - - # bottomLeftLayout.addWidget( - # self.showTreeInfoCheckbox, row, 0, 1, 1, - # alignment=Qt.AlignCenter - # ) - - row += 1 - navWidgetsLayout = QHBoxLayout() - self.navSpinBox = widgets.SpinBox(disableKeyPress=True) - self.navSpinBox.setMinimum(1) - self.navSpinBox.setMaximum(100) - self.navSizeLabel = QLabel('/ND') - navWidgetsLayout.addWidget(self.t_label) - navWidgetsLayout.addWidget(self.navSpinBox) - navWidgetsLayout.addWidget(self.navSizeLabel) - bottomLeftLayout.addLayout( - navWidgetsLayout, row, 0, alignment=Qt.AlignRight - ) - bottomLeftLayout.addWidget(self.navigateScrollBar, row, 1, 1, 2) - sp = self.navigateScrollBar.sizePolicy() - sp.setRetainSizeWhenHidden(True) - self.navigateScrollBar.setSizePolicy(sp) - self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) - self.navSpinBox.editingFinished.connect( - self.navigateSpinboxEditingFinished - ) - self.navSpinBox.sigUpClicked.connect( - self.navigateSpinboxEditingFinished - ) - self.navSpinBox.sigDownClicked.connect( - self.navigateSpinboxEditingFinished - ) - - self.lastTrackedFrameLabel = QLabel() - self.lastTrackedFrameLabel.setFont(_font) - bottomLeftLayout.addWidget(self.lastTrackedFrameLabel, row, 3) - - row += 1 - zSliceCheckboxLayout = QHBoxLayout() - self.zSliceCheckbox = QCheckBox('z-slice') - self.zSliceSpinbox = widgets.SpinBox(disableKeyPress=True) - self.zSliceSpinbox.setMinimum(1) - self.SizeZlabel = QLabel('/ND') - self.zSliceCheckbox.setToolTip( - 'Activate/deactivate control of the z-slices with keyboard arrows.\n\n' - 'SHORTCUT to toggle ON/OFF: "Z" key' - ) - zSliceCheckboxLayout.addWidget(self.zSliceCheckbox) - zSliceCheckboxLayout.addWidget(self.zSliceSpinbox) - zSliceCheckboxLayout.addWidget(self.SizeZlabel) - bottomLeftLayout.addLayout( - zSliceCheckboxLayout, row, 0, alignment=Qt.AlignRight - ) - bottomLeftLayout.addWidget(self.zSliceScrollBar, row, 1, 1, 2) - bottomLeftLayout.addWidget(self.zProjComboBox, row, 3) - bottomLeftLayout.addWidget(self.zProjLockViewButton, row, 4) - bottomLeftLayout.addWidget(self.switchPlaneCombobox, row, 5) - self.zSliceSpinbox.connectValueChanged(self.onZsliceSpinboxValueChange) - self.zSliceSpinbox.editingFinished.connect(self.zSliceScrollBarReleased) - - row += 1 - bottomLeftLayout.addWidget( - self.overlay_z_label, row, 0, alignment=Qt.AlignRight - ) - bottomLeftLayout.addWidget(self.zSliceOverlay_SB, row, 1, 1, 2) - - bottomLeftLayout.addWidget(self.zProjOverlay_CB, row, 3) - - row += 1 - self.alphaScrollbarRow = row - - bottomLeftLayout.setColumnStretch(0,0) - bottomLeftLayout.setColumnStretch(1,3) - bottomLeftLayout.setColumnStretch(2,0) - - container.setLayout(bottomLeftLayout) - return container - - def gui_createLabWidgets(self): - bottomRightLayout = QVBoxLayout() - self.rightBottomGroupbox = widgets.GroupBox( - 'Annotate right image independent of left image', - keyPressCallback=self.resetFocus - ) - self.rightBottomGroupbox.setCheckable(True) - self.rightBottomGroupbox.setChecked(False) - self.rightBottomGroupbox.hide() - - self.annotateRightHowCombobox = widgets.ComboBox() - self.annotateRightHowCombobox.setFont(_font) - self.annotateRightHowCombobox.addItems(self.drawIDsContComboBoxSegmItems) - self.annotateRightHowCombobox.setCurrentIndex( - self.drawIDsContComboBox.currentIndex() - ) - self.annotateRightHowCombobox.setVisible(False) - - self.annotOptionsLayoutRight.addWidget(self.annotateRightHowCombobox) - - self.rightImageFramesScrollbar = widgets.ScrollBarWithNumericControl( - labelText='Frame n. ' - ) - self.rightImageFramesScrollbar.setVisible(False) - - bottomRightLayout.addWidget(self.annotOptionsWidgetRight) - bottomRightLayout.addWidget(self.rightImageFramesScrollbar) - bottomRightLayout.addStretch(1) - - self.rightBottomGroupbox.setLayout(bottomRightLayout) - - self.rightBottomGroupbox.toggled.connect(self.rightImageControlsToggled) - - def rightImageControlsToggled(self, checked): - if self.isDataLoading: - return - if checked: - self.annotateRightHowCombobox.setCurrentText( - self.drawIDsContComboBox.currentText() - ) - self.updateAllImages() - - def setFocusGraphics(self): - self.graphLayout.setFocus() - - def setFocusMain(self): - # on macOS with Qt6 setFocus causes crashes. Disabled for now. - return - - def resetFocus(self): - self.setFocusGraphics() - self.setFocusMain() - - def gui_createBottomWidgetsToBottomLayout(self): - # self.bottomDockWidget = QDockWidget(self) - bottomScrollArea = widgets.ScrollArea(resizeVerticalOnShow=True) - bottomScrollArea.sigLeaveEvent.connect(self.setFocusMain) - bottomWidget = QWidget() - bottomScrollAreaLayout = QVBoxLayout() - self.bottomLayout = QHBoxLayout() - self.bottomLayout.addLayout(self.quickSettingsLayout) - self.bottomLayout.addStretch(1) - self.bottomLayout.addWidget(self.img1BottomGroupbox) - self.bottomLayout.addStretch(1) - self.bottomLayout.addWidget(self.rightBottomGroupbox) - self.bottomLayout.addStretch(1) - - bottomScrollAreaLayout.addLayout(self.bottomLayout) - bottomScrollAreaLayout.addStretch(1) - - bottomWidget.setLayout(bottomScrollAreaLayout) - bottomScrollArea.setWidgetResizable(True) - bottomScrollArea.setWidget(bottomWidget) - self.bottomScrollArea = bottomScrollArea - - if 'bottom_sliders_zoom_perc' in self.df_settings.index: - val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value']) - zoom_perc = val - else: - zoom_perc = 100 - self.bottomLayoutContextMenu = QMenu('Bottom layout', self) - zoomMenu = self.bottomLayoutContextMenu.addMenu('Zoom') - actions = [] - self.bottomLayoutContextMenu.zoomActionGroup = QActionGroup(zoomMenu) - for perc in np.arange(50, 151, 10): - action = QAction(f'{perc}%', zoomMenu) - action.setCheckable(True) - if perc == zoom_perc: - action.setChecked(True) - action.toggled.connect(self.zoomBottomLayoutActionTriggered) - actions.append(action) - self.bottomLayoutContextMenu.zoomActionGroup.addAction(action) - zoomMenu.addActions(actions) - resetAction = self.bottomLayoutContextMenu.addAction( - 'Reset default height' - ) - resetAction.triggered.connect(self.resizeGui) - retainSpaceAction = self.bottomLayoutContextMenu.addAction( - 'Retain space of hidden sliders' - ) - retainSpaceAction.setCheckable(True) - if 'retain_space_hidden_sliders' in self.df_settings.index: - retainSpaceChecked = ( - self.df_settings.at['retain_space_hidden_sliders', 'value'] - == 'Yes' - ) - else: - retainSpaceChecked = True - retainSpaceAction.setChecked(retainSpaceChecked) - retainSpaceAction.toggled.connect(self.retainSpaceSlidersToggled) - self.retainSpaceSlidersAction = retainSpaceAction - self.setBottomLayoutStretch() - - def gui_resetBottomLayoutHeight(self): - self.h = self.defaultWidgetHeightBottomLayout - self.checkBoxesHeight = 14 - self.fontPixelSize = 11 - self.resizeSlidersArea() - - def gui_createGraphicsPlots(self): - self.graphLayout = pg.GraphicsLayoutWidget() - if self.invertBwAction.isChecked(): - self.graphLayout.setBackground(graphLayoutBkgrColor) - self.titleColor = 'black' - else: - self.graphLayout.setBackground(darkBkgrColor) - self.titleColor = 'white' - - self.lutItemsLayout = self.graphLayout.addLayout(row=1, col=0) - # self.lutItemsLayout.setBorder('w') - - # Left plot - self.ax1 = widgets.MainPlotItem(showWelcomeText=True) - self.ax1.invertY(True) - self.ax1.setAspectLocked(True) - self.ax1.hideAxis('bottom') - self.ax1.hideAxis('left') - self.plotsCol = 1 - self.graphLayout.addItem(self.ax1, row=1, col=1) - - # Right plot - self.ax2 = widgets.MainPlotItem() - self.ax2.setAspectLocked(True) - self.ax2.invertY(True) - self.ax2.hideAxis('bottom') - self.ax2.hideAxis('left') - # self.currentFrameLabelItem = pg.LabelItem( - # color=self.titleColor, size='13px' - # ) - self.graphLayout.addItem(self.ax2, row=1, col=2) - - def gui_addGraphicsItems(self): - # Auto image adjustment button - proxy = QGraphicsProxyWidget() - equalizeHistPushButton = QPushButton("Enhance contrast") - widthHint = equalizeHistPushButton.sizeHint().width() - equalizeHistPushButton.setMaximumWidth(widthHint) - equalizeHistPushButton.setCheckable(True) - if not self.invertBwAction.isChecked(): - equalizeHistPushButton.setStyleSheet( - 'QPushButton {background-color: #282828; color: #F0F0F0;}' - ) - self.equalizeHistPushButton = equalizeHistPushButton - proxy.setWidget(equalizeHistPushButton) - self.graphLayout.addItem(proxy, row=0, col=0) - self.equalizeHistPushButton = equalizeHistPushButton - - # Left image histogram - self.imgGrad = widgets.myHistogramLUTitem(parent=self, name='image') - self.imgGrad.restoreState(self.df_settings) - self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) - for action in self.imgGrad.rescaleActionGroup.actions(): - if action.text() == self.defaultRescaleIntensHow: - action.setChecked(True) - self.rescaleIntensMenu.addAction(action) - - # Colormap gradient widget - self.labelsGrad = widgets.labelsGradientWidget(parent=self) - try: - stateFound = self.labelsGrad.restoreState(self.df_settings) - except Exception as e: - self.logger.exception(traceback.format_exc()) - print('======================================') - self.logger.info( - 'Failed to restore previously used colormap. ' - 'Using default colormap "viridis"' - ) - self.labelsGrad.item.loadPreset('viridis') - - # Add actions to imgGrad gradient item - self.imgGrad.gradient.menu.addAction( - self.labelsGrad.showLabelsImgAction - ) - self.imgGrad.gradient.menu.addAction( - self.labelsGrad.showRightImgAction - ) - self.imgGrad.gradient.menu.addAction( - self.labelsGrad.showNextFrameAction - ) - - self.imgGrad.gradient.menu.addSeparator() - - self.imgGrad.gradient.menu.addMenu(self.exportMenu) - - # Add actions to view menu - self.viewMenu.addAction(self.labelsGrad.showLabelsImgAction) - self.viewMenu.addAction(self.labelsGrad.showRightImgAction) - - # Right image histogram - self.imgGradRight = widgets.baseHistogramLUTitem( - name='image', parent=self, gradientPosition='left' - ) - self.imgGradRight.gradient.menu.addAction( - self.labelsGrad.showLabelsImgAction - ) - self.imgGradRight.gradient.menu.addAction( - self.labelsGrad.showRightImgAction - ) - self.imgGradRight.gradient.menu.addAction( - self.labelsGrad.showNextFrameAction - ) - - self.imgGrad.setChildLutItem(self.imgGradRight) - - # Title - self.titleLabel = pg.LabelItem( - justify='center', color=self.titleColor, size='14pt' - ) - self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) - - def gui_createTextAnnotColors(self, r, g, b, custom=False): - if custom: - self.objLabelAnnotRgb = (int(r), int(g), int(b)) - self.SphaseAnnotRgb = (int(r*0.9), int(r*0.9), int(b*0.9)) - self.G1phaseAnnotRgba = (int(r*0.8), int(g*0.8), int(b*0.8), 220) - else: - self.objLabelAnnotRgb = (255, 255, 255) # white - self.SphaseAnnotRgb = (229, 229, 229) - self.G1phaseAnnotRgba = (204, 204, 204, 220) - self.dividedAnnotRgb = (245, 188, 1) # orange - - self.emptyBrush = pg.mkBrush((0,0,0,0)) - self.emptyPen = pg.mkPen((0,0,0,0)) - - def gui_setTextAnnotColors(self): - self.textAnnot[0].setColors( - self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, - self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb - ) - - self.textAnnot[1].setColors( - self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, - self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb - ) - - - def gui_createPlotItems(self): - if 'textIDsColor' in self.df_settings.index: - rgbString = self.df_settings.at['textIDsColor', 'value'] - r, g, b = colors.rgb_str_to_values(rgbString) - self.gui_createTextAnnotColors(r, g, b, custom=True) - self.textIDsColorButton.setColor((r, g, b)) - else: - self.gui_createTextAnnotColors(0,0,0, custom=False) - - if 'labels_text_color' in self.df_settings.index: - rgbString = self.df_settings.at['labels_text_color', 'value'] - r, g, b = colors.rgb_str_to_values(rgbString) - self.ax2_textColor = (r, g, b) - else: - self.ax2_textColor = (255, 0, 0) - - self.emptyLab = np.zeros((2,2), dtype=np.uint8) - - # Right image item linked to left - self.rightImageItem = widgets.ChildImageItem( - linkedScrollbar=self.rightImageFramesScrollbar - ) - self.imgGradRight.setImageItem(self.rightImageItem) - self.ax2.addItem(self.rightImageItem) - - # Left image - self.img1 = widgets.ParentImageItem( - linkedImageItem=self.rightImageItem, - activatingActions=( - self.labelsGrad.showRightImgAction, - self.labelsGrad.showNextFrameAction - ) - ) - self.imgGrad.setImageItem(self.img1) - self.img1.lutItem = self.imgGrad - self.imgGrad.sigRescaleIntes.connect(self.rescaleIntensitiesLut) - self.ax1.addBaseImageItem(self.img1) - - # RGBA image for true transparency mode - self.rgbaImg1 = pg.ImageItem() - - # self.rgbaImg1.setImage(self.emptyLab) - - # Right image - self.img2 = widgets.labImageItem() - self.ax2.addItem(self.img2) - - self.topLayerItems = [] - self.topLayerItemsRight = [] - - self.gui_createContourPens() - self.gui_createMothBudLinePens() - - self.eraserCirclePen = pg.mkPen(width=1.5, color='r') - - # Temporary line item connecting bud to new mother - self.BudMothTempLine = pg.PlotDataItem(pen=self.NewBudMoth_Pen) - self.topLayerItems.append(self.BudMothTempLine) - - # Temporary line item connecting objects to merge - self.mergeObjsTempLine = widgets.PlotCurveItem(pen=self.redDashLinePen) - self.topLayerItems.append(self.mergeObjsTempLine) - - # Overlay segm. masks item - self.labelsLayerImg1 = widgets.BaseLabelsImageItem() - self.ax1.addItem(self.labelsLayerImg1) - - self.labelsLayerRightImg = widgets.BaseLabelsImageItem() - self.ax2.addItem(self.labelsLayerRightImg) - - # Red/green border rect item - self.GreenLinePen = pg.mkPen(color='g', width=2) - self.RedLinePen = pg.mkPen(color='r', width=2) - self.ax1BorderLine = pg.PlotDataItem() - self.topLayerItems.append(self.ax1BorderLine) - self.ax2BorderLine = pg.PlotDataItem(pen=pg.mkPen(color='r', width=2)) - self.topLayerItems.append(self.ax2BorderLine) - - # Brush/Eraser/Wand.. layer item - self.tempLayerRightImage = pg.ImageItem() - self.tempLayerImg1 = widgets.ParentImageItem( - linkedImageItem=self.tempLayerRightImage, - activatingAction=(self.labelsGrad.showRightImgAction, ) - ) - self.topLayerItems.append(self.tempLayerImg1) - self.topLayerItemsRight.append(self.tempLayerRightImage) - - # Highlighted ID layer items - self.highLightIDLayerImg1 = pg.ImageItem() - self.topLayerItems.append(self.highLightIDLayerImg1) - - # Highlighted ID layer items - self.highLightIDLayerRightImage = pg.ImageItem() - self.topLayerItemsRight.append(self.highLightIDLayerRightImage) - - # Keep IDs temp layers - self.keepIDsTempLayerRight = pg.ImageItem() - self.keepIDsTempLayerLeft = widgets.ParentImageItem( - linkedImageItem=self.keepIDsTempLayerRight, - activatingAction=self.labelsGrad.showRightImgAction - ) - self.topLayerItems.append(self.keepIDsTempLayerLeft) - self.topLayerItemsRight.append(self.keepIDsTempLayerRight) - - # Searched ID contour - self.searchedIDitemRight = pg.ScatterPlotItem() - self.searchedIDitemRight.setData( - [], [], symbol='s', pxMode=False, size=1, - brush=pg.mkBrush(color=(255,0,0,150)), - pen=pg.mkPen(width=2, color='r'), tip=None - ) - self.searchedIDitemLeft = pg.ScatterPlotItem() - self.searchedIDitemLeft.setData( - [], [], symbol='s', pxMode=False, size=1, - brush=pg.mkBrush(color=(255,0,0,150)), - pen=pg.mkPen(width=2, color='r'), tip=None - ) - self.topLayerItems.append(self.searchedIDitemLeft) - self.topLayerItemsRight.append(self.searchedIDitemRight) - - - # Brush circle img1 - self.ax1_BrushCircle = pg.ScatterPlotItem() - self.ax1_BrushCircle.setData( - [], [], symbol='o', pxMode=False, - brush=pg.mkBrush((255,255,255,50)), - pen=pg.mkPen(width=2), tip=None - ) - self.topLayerItems.append(self.ax1_BrushCircle) - - # Eraser circle img1 - self.ax1_EraserCircle = pg.ScatterPlotItem() - self.ax1_EraserCircle.setData( - [], [], symbol='o', pxMode=False, - brush=None, pen=self.eraserCirclePen, tip=None - ) - self.topLayerItems.append(self.ax1_EraserCircle) - - self.ax1_EraserX = pg.ScatterPlotItem() - self.ax1_EraserX.setData( - [], [], symbol='x', pxMode=False, size=3, - brush=pg.mkBrush(color=(255,0,0,50)), - pen=pg.mkPen(width=1, color='r'), tip=None - ) - self.topLayerItems.append(self.ax1_EraserX) - - # Brush circle img1 - self.labelRoiCircItemLeft = widgets.LabelRoiCircularItem() - self.labelRoiCircItemLeft.cleared = False - self.labelRoiCircItemLeft.setData( - [], [], symbol='o', pxMode=False, - brush=pg.mkBrush(color=(255,0,0,0)), - pen=pg.mkPen(color='r', width=2), tip=None - ) - self.labelRoiCircItemRight = widgets.LabelRoiCircularItem() - self.labelRoiCircItemRight.cleared = False - self.labelRoiCircItemRight.setData( - [], [], symbol='o', pxMode=False, - brush=pg.mkBrush(color=(255,0,0,0)), - pen=pg.mkPen(color='r', width=2), tip=None - ) - self.topLayerItems.append(self.labelRoiCircItemLeft) - self.topLayerItemsRight.append(self.labelRoiCircItemRight) - - self.ax1_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() - self.ax1_binnedIDs_ScatterPlot.setData( - [], [], symbol='t', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=3, color='r'), tip=None - ) - self.topLayerItems.append(self.ax1_binnedIDs_ScatterPlot) - - self.ax1_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() - self.ax1_ripIDs_ScatterPlot.setData( - [], [], symbol='x', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=2, color='r'), tip=None - ) - self.topLayerItems.append(self.ax1_ripIDs_ScatterPlot) - - # Ruler plotItem and scatterItem - rulerPen = pg.mkPen(color='r', style=Qt.DashLine, width=2) - self.ax1_rulerPlotItem = widgets.RulerPlotItem(pen=rulerPen) - self.ax1_rulerAnchorsItem = pg.ScatterPlotItem( - symbol='o', size=9, - brush=pg.mkBrush((255,0,0,50)), - pen=pg.mkPen((255,0,0), width=2), tip=None - ) - self.topLayerItems.append(self.ax1_rulerPlotItem) - self.topLayerItems.append(self.ax1_rulerPlotItem.labelItem) - self.topLayerItems.append(self.ax1_rulerAnchorsItem) - - # Start point of polyline roi - self.ax1_point_ScatterPlot = pg.ScatterPlotItem() - self.ax1_point_ScatterPlot.setData( - [], [], symbol='o', pxMode=False, size=3, - pen=pg.mkPen(width=2, color='r'), - brush=pg.mkBrush((255,0,0,50)), tip=None - ) - self.topLayerItems.append(self.ax1_point_ScatterPlot) - - # Experimental: scatter plot to add a point marker - self.startPointPolyLineItem = pg.ScatterPlotItem() - self.startPointPolyLineItem.setData( - [], [], symbol='o', size=9, - pen=pg.mkPen(width=2, color='r'), - brush=pg.mkBrush((255,0,0,50)), - hoverable=True, hoverBrush=pg.mkBrush((255,0,0,255)), tip=None - ) - self.topLayerItems.append(self.startPointPolyLineItem) - - # Eraser circle img2 - self.ax2_EraserCircle = pg.ScatterPlotItem() - self.ax2_EraserCircle.setData( - [], [], symbol='o', pxMode=False, brush=None, - pen=self.eraserCirclePen, tip=None - ) - self.ax2.addItem(self.ax2_EraserCircle) - self.ax2_EraserX = pg.ScatterPlotItem() - self.ax2_EraserX.setData( - [], [], symbol='x', pxMode=False, size=3, - brush=pg.mkBrush(color=(255,0,0,50)), - pen=pg.mkPen(width=1.5, color='r') - ) - self.ax2.addItem(self.ax2_EraserX) - - # Brush circle img2 - self.ax2_BrushCirclePen = pg.mkPen(width=2) - self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50)) - self.ax2_BrushCircle = pg.ScatterPlotItem() - self.ax2_BrushCircle.setData( - [], [], symbol='o', pxMode=False, - brush=self.ax2_BrushCircleBrush, - pen=self.ax2_BrushCirclePen, tip=None - ) - self.ax2.addItem(self.ax2_BrushCircle) - - # Annotated metadata markers (ScatterPlotItem) - self.ax2_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() - self.ax2_binnedIDs_ScatterPlot.setData( - [], [], symbol='t', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=3, color='r'), tip=None - ) - self.ax2.addItem(self.ax2_binnedIDs_ScatterPlot) - - self.ax2_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() - self.ax2_ripIDs_ScatterPlot.setData( - [], [], symbol='x', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=2, color='r'), tip=None - ) - self.ax2.addItem(self.ax2_ripIDs_ScatterPlot) - - self.freeRoiItem = widgets.PlotCurveItem( - pen=pg.mkPen(color='r', width=2) - ) - self.topLayerItems.append(self.freeRoiItem) - - self.warnPairingItem = widgets.PlotCurveItem( - pen=pg.mkPen(color='r', width=5, style=Qt.DashLine), - pxMode=False - ) - self.topLayerItems.append(self.warnPairingItem) - - self.exportMaskImageItem = pg.ImageItem() - - self.ghostContourItemLeft = widgets.GhostContourItem(self.ax1) - self.ghostContourItemRight = widgets.GhostContourItem(self.ax2) - - self.ghostMaskItemLeft = widgets.GhostMaskItem(self.ax1) - self.ghostMaskItemRight = widgets.GhostMaskItem(self.ax2) - - self.manualBackgroundObjItem = widgets.GhostContourItem( - self.ax1, penColor='r', textColor='r' - ) - self.manualBackgroundImageItem = pg.ImageItem() - - def gui_createZoomRectItem(self): - Y, X = self.currentLab2D.shape - # Label ROI rectangle - pen = pg.mkPen('r', width=3, style=Qt.DashLine) - self.zoomRectItem = widgets.ZoomROI( - (0,0), (0,0), - maxBounds=QRectF(QRect(0,0,X,Y)), - scaleSnap=True, - translateSnap=True, - pen=pen, hoverPen=pen - ) - - def gui_createLabelRoiItem(self): - Y, X = self.currentLab2D.shape - # Label ROI rectangle - pen = pg.mkPen('r', width=3) - self.labelRoiItem = widgets.ROI( - (0,0), (0,0), - maxBounds=QRectF(QRect(0,0,X,Y)), - scaleSnap=True, - translateSnap=True, - pen=pen, hoverPen=pen - ) - - posData = self.data[self.pos_i] - if self.labelRoiZdepthSpinbox.value() == 0: - self.labelRoiZdepthSpinbox.setValue(posData.SizeZ) - self.labelRoiZdepthSpinbox.setMaximum(posData.SizeZ+1) - - def gui_createOverlayColors(self): - fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] - self.logger.info( - f'Number of TIFF files detected: {len(fluoChannels)}' - ) - self.overlayColors = {} - for c, ch in enumerate(fluoChannels): - if f'{ch}_rgb' in self.df_settings.index: - rgb_text = self.df_settings.at[f'{ch}_rgb', 'value'] - rgb = tuple([int(val) for val in rgb_text.split('_')]) - self.overlayColors[ch] = rgb - else: - if c >= len(self.overlayRGBs) -1: - i = c/len(fluoChannels) - additional_color_num = c - len(self.overlayRGBs) + 1 - rgbs = [ - tuple([round(c*255) for c in self.overlayCmap(i)][:3]) - for _ in range(additional_color_num) - ] - self.overlayRGBs.extend(rgbs) - rgb = colors.FLUO_CHANNELS_COLORS.get(ch, self.overlayRGBs[c]) - self.overlayColors[ch] = rgb - - def gui_createOverlayItems(self): - self.imgGrad.setAxisLabel(self.user_ch_name) - self.baseLayerToolbutton = widgets.OverlayChannelToolButton( - self.user_ch_name, self.imgGrad - ) - self.baseLayerToolbutton.setChecked(True) - self.baseLayerToolbutton.clicked.connect( - self.overlayChannelToolbuttonClicked - ) - self.allOverlayToolbuttons = { - self.user_ch_name: self.baseLayerToolbutton - } - self.allOverlayToolbuttonsByIdx = { - 0: self.baseLayerToolbutton - } - self.baseLayerToolbutton.action = ( - self.overlayToolbar.addWidget(self.baseLayerToolbutton) - ) - self.overlayLayersItems = {} - self.overlayToolbarAreChannelsChecked = {} - fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] - for c, ch in enumerate(fluoChannels): - overlayItems = self.getOverlayItems(ch, c+1) - self.overlayLayersItems[ch] = overlayItems - imageItem, lutItem = overlayItems[:2] - self.ax1.addItem(imageItem) - self.lutItemsLayout.addItem(lutItem, row=0, col=c+1) - toolbutton = overlayItems[3] - self.allOverlayToolbuttons[ch] = toolbutton - self.allOverlayToolbuttonsByIdx[c+1] = toolbutton - - self.overlayToolbuttonsSep = self.overlayToolbar.addSeparator() - self.plotsCol = len(self.ch_names) - - self.ax1.addImageItem(self.rgbaImg1) - - def gui_getLostObjScatterItem(self): - self.objLostAnnotRgb = (245, 184, 0) - brush = pg.mkBrush((*self.objLostAnnotRgb, 150)) - pen = pg.mkPen(self.objLostAnnotRgb, width=1) - lostObjScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight+1, pen=pen, - brush=brush, pxMode=False, symbol='s' - ) - return lostObjScatterItem - - def gui_getTrackedLostObjScatterItem(self): - self.objLostTrackedAnnotRgb = (0, 255, 0) - brush = pg.mkBrush((*self.objLostTrackedAnnotRgb, 150)) - pen = pg.mkPen(self.objLostTrackedAnnotRgb, width=1) - lostObjScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight+1, pen=pen, - brush=brush, pxMode=False, symbol='s' - ) - return lostObjScatterItem - - def _gui_createGraphicsItems(self): - for _posData in self.data: - _posData.allData_li = [None]*_posData.SizeT - - posData = self.data[self.pos_i] - - allIDs, posData = core.count_objects(posData, self.logger.info) - - self.highLowResAction.setChecked(True) - numItems = len(allIDs) - if numItems > 1500: - cancel, switchToLowRes = _warnings.warnTooManyItems( - self, numItems, self.progressWin - ) - if cancel: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.loadingDataAborted() - return - if switchToLowRes: - self.highLowResAction.setChecked(False) - else: - # Many items requires pxMode active to be fast enough - self.pxModeAction.setChecked(True) - - self.logger.info(f'Creating graphical items...') - - self.ax1_contoursImageItem = pg.ImageItem() - - self.ax1_lostObjImageItem = pg.ImageItem() - self.ax2_lostObjImageItem = pg.ImageItem() - - self.ax1_lostTrackedObjImageItem = pg.ImageItem() - self.ax2_lostTrackedObjImageItem = pg.ImageItem() - - self.ax1_oldMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, - size=self.mothBudLineWeight, pen=None - ) - self.ax1_newMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.newMothBudLineBrush, - size=self.mothBudLineWeight, pen=None - ) - self.ax1_lostObjScatterItem = self.gui_getLostObjScatterItem() - self.yellowContourScatterItem = self.gui_getLostObjScatterItem() - - self.ax1_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() - self.greenContourScatterItem = self.gui_getTrackedLostObjScatterItem() - - brush = pg.mkBrush((0,255,0,200)) - pen = pg.mkPen('g', width=1) - self.ccaFailedScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight+1, pen=pen, - brush=brush, pxMode=False, symbol='s' - ) - - self.ax2_contoursImageItem = pg.ImageItem() - self.ax2_oldMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, - size=self.mothBudLineWeight, pen=None - ) - self.ax2_newMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.newMothBudLineBrush, - size=self.mothBudLineWeight, pen=None - ) - self.ax2_lostObjScatterItem = self.gui_getLostObjScatterItem() - self.ax2_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() - - self.gui_createTextAnnotItems(allIDs) # here - self.gui_setTextAnnotColors()# here - - self.setDisabledAnnotOptions(False) - - self.progressWin.mainPbar.setMaximum(0) - self.gui_addOverlayLayerItems() - self.gui_addTopLayerItems() - - self.gui_addCreatedAxesItems() - self.gui_add_ax_cursors() - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.loadingDataCompleted() - - def gui_createTextAnnotItems(self, allIDs): - self.textAnnot = {} - isHighResolution = self.highLowResAction.isChecked() - pxMode = self.pxModeAction.isChecked() - for ax in range(2): - ax_textAnnot = annotate.TextAnnotations() - ax_textAnnot.initFonts(self.fontSize) - ax_textAnnot.createItems( - isHighResolution, allIDs, pxMode=pxMode - ) - self.textAnnot[ax] = ax_textAnnot - - def gui_addOverlayLayerItems(self): - for items in self.overlayLabelsItems.values(): - imageItem, contoursItem, gradItem = items - self.ax1.addItem(imageItem) - self.ax1.addItem(contoursItem) - - def gui_addTopLayerItems(self): - for item in self.topLayerItems: - self.ax1.addItem(item) - - for item in self.topLayerItemsRight: - self.ax2.addItem(item) - - # self.ax2.addItem(self.currentFrameLabelItem) - - def gui_createMothBudLinePens(self): - if 'mothBudLineSize' in self.df_settings.index: - val = self.df_settings.at['mothBudLineSize', 'value'] - self.mothBudLineWeight = int(val) - else: - self.mothBudLineWeight = 2 - - self.newMothBudlineColor = (255, 0, 0) - if 'mothBudLineColor' in self.df_settings.index: - val = self.df_settings.at['mothBudLineColor', 'value'] - rgba = colors.rgba_str_to_values(val) - self.mothBudLineColor = rgba[0:3] - else: - self.mothBudLineColor = (255,165,0) - - try: - self.imgGrad.mothBudLineColorButton.sigColorChanging.disconnect() - self.imgGrad.mothBudLineColorButton.sigColorChanged.disconnect() - except Exception as e: - pass - try: - for act in self.imgGrad.mothBudLineWightActionGroup.actions(): - act.toggled.disconnect() - except Exception as e: - pass - for act in self.imgGrad.mothBudLineWightActionGroup.actions(): - if act.lineWeight == self.mothBudLineWeight: - act.setChecked(True) - else: - act.setChecked(False) - self.imgGrad.mothBudLineColorButton.setColor(self.mothBudLineColor[:3]) - - self.imgGrad.mothBudLineColorButton.sigColorChanging.connect( - self.updateMothBudLineColour - ) - self.imgGrad.mothBudLineColorButton.sigColorChanged.connect( - self.saveMothBudLineColour - ) - for act in self.imgGrad.mothBudLineWightActionGroup.actions(): - act.toggled.connect(self.mothBudLineWeightToggled) - - # MOther-bud lines brushes - self.NewBudMoth_Pen = pg.mkPen( - color=self.newMothBudlineColor, width=self.mothBudLineWeight+1, - style=Qt.DashLine - ) - self.OldBudMoth_Pen = pg.mkPen( - color=self.mothBudLineColor, width=self.mothBudLineWeight, - style=Qt.DashLine - ) - - self.redDashLinePen = pg.mkPen( - color='r', width=2, style=Qt.DashLine - ) - - self.oldMothBudLineBrush = pg.mkBrush(self.mothBudLineColor) - self.newMothBudLineBrush = pg.mkBrush(self.newMothBudlineColor) - - def gui_createContourPens(self): - if 'contLineWeight' in self.df_settings.index: - val = self.df_settings.at['contLineWeight', 'value'] - self.contLineWeight = int(val) - else: - self.contLineWeight = 1 - if 'contLineColor' in self.df_settings.index: - val = self.df_settings.at['contLineColor', 'value'] - rgba = colors.rgba_str_to_values(val) - self.contLineColor = rgba - self.newIDlineColor = [min(255, v+50) for v in self.contLineColor] - else: - self.contLineColor = (255, 0, 0, 200) - self.newIDlineColor = (255, 0, 0, 255) - - try: - self.imgGrad.contoursColorButton.sigColorChanging.disconnect() - self.imgGrad.contoursColorButton.sigColorChanged.disconnect() - except Exception as e: - pass - try: - for act in self.imgGrad.contLineWightActionGroup.actions(): - act.toggled.disconnect() - except Exception as e: - pass - for act in self.imgGrad.contLineWightActionGroup.actions(): - if act.lineWeight == self.contLineWeight: - act.setChecked(True) - self.imgGrad.contoursColorButton.setColor(self.contLineColor[:3]) - - self.imgGrad.contoursColorButton.sigColorChanging.connect( - self.updateContColour - ) - self.imgGrad.contoursColorButton.sigColorChanged.connect( - self.saveContColour - ) - for act in self.imgGrad.contLineWightActionGroup.actions(): - act.toggled.connect(self.contLineWeightToggled) - - # Contours pens - self.oldIDs_cpen = pg.mkPen( - color=self.contLineColor, width=self.contLineWeight - ) - self.newIDs_cpen = pg.mkPen( - color=self.newIDlineColor, width=self.contLineWeight+1 - ) - self.tempNewIDs_cpen = pg.mkPen( - color='g', width=self.contLineWeight+1 - ) - - def gui_createGraphicsItems(self): - # Create enough PlotDataItems and LabelItems to draw contours and IDs. - self.progressWin = apps.QDialogWorkerProgress( - title='Creating axes items', parent=self, - pbarDesc='Creating axes items (see progress in the terminal)...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - QTimer.singleShot(50, self._gui_createGraphicsItems) - - def gui_connectGraphicsEvents(self): - self.img1.hoverEvent = self.gui_hoverEventImg1 - self.img2.hoverEvent = self.gui_hoverEventImg2 - self.img1.mousePressEvent = self.gui_mousePressEventImg1 - self.img1.mouseMoveEvent = self.gui_mouseDragEventImg1 - self.img1.mouseReleaseEvent = self.gui_mouseReleaseEventImg1 - self.img2.mousePressEvent = self.gui_mousePressEventImg2 - self.img2.mouseMoveEvent = self.gui_mouseDragEventImg2 - self.img2.mouseReleaseEvent = self.gui_mouseReleaseEventImg2 - self.rightImageItem.mousePressEvent = self.gui_mousePressRightImage - self.rightImageItem.mouseMoveEvent = self.gui_mouseDragRightImage - self.rightImageItem.mouseReleaseEvent = self.gui_mouseReleaseRightImage - self.rightImageItem.hoverEvent = self.gui_hoverEventRightImage - # self.imgGrad.gradient.showMenu = self.gui_gradientContextMenuEvent - self.imgGradRight.gradient.showMenu = self.gui_rightImageShowContextMenu - # self.imgGrad.vb.contextMenuEvent = self.gui_gradientContextMenuEvent - self.ax1.sigRangeChanged.connect(self.viewRangeChanged) - - def gui_initImg1BottomWidgets(self): - self.zSliceScrollBar.hide() - self.zProjComboBox.hide() - self.zProjLockViewButton.hide() - self.zSliceOverlay_SB.hide() - self.zProjOverlay_CB.hide() - self.overlay_z_label.hide() - self.zSliceCheckbox.hide() - self.zSliceSpinbox.hide() - self.SizeZlabel.hide() - - @exception_handler - def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): - modifiers = QGuiApplication.keyboardModifiers() - alt = modifiers == Qt.AltModifier - shift = modifiers == Qt.ShiftModifier - shift_regardless = bool(modifiers & Qt.ShiftModifier) - isMod = alt - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - left_click = event.button() == Qt.MouseButton.LeftButton and not alt - middle_click = self.isMiddleClick(event, modifiers) - right_click = event.button() == Qt.MouseButton.RightButton and not alt - isPanImageClick = self.isPanImageClick(event, modifiers) - eraserON = self.eraserButton.isChecked() - brushON = self.brushButton.isChecked() - separateON = self.separateBudButton.isChecked() - self.typingEditID = False - - # Drag image if neither brush or eraser are On pressed - dragImg = ( - left_click and not eraserON and not - brushON and not middle_click - ) - if isPanImageClick: - dragImg = True - - # Enable dragging of the image window like pyqtgraph original code - if dragImg: - pg.ImageItem.mousePressEvent(self.img2, event) - event.ignore() - return - - if mode == 'Viewer' and middle_click: - self.startBlinkingModeCB() - event.ignore() - return - - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - Y, X = self.get_2Dlab(posData.lab).shape - if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - else: - return - - # Check if right click on ROI - isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click) - if isClickOnDelRoi: - return - - # show gradient widget menu if none of the right-click actions are ON - # and event is not coming from image 1 - is_right_click_action_ON = any([ - b.isChecked() for b in self.checkableQButtonsGroup.buttons() - ]) - is_right_click_custom_ON = any([ - b.isChecked() for b in self.customAnnotDict.keys() - ]) - is_event_from_img1 = False - if hasattr(event, 'isImg1Sender'): - is_event_from_img1 = event.isImg1Sender - - is_only_right_click = ( - right_click and not is_right_click_action_ON and not middle_click - ) - - showLabelsGradMenu = ( - is_only_right_click and not is_event_from_img1 - ) - - if showLabelsGradMenu: - self.labelsGrad.showMenu(event) - event.ignore() - return - - editInViewerMode = ( - (is_right_click_action_ON or is_right_click_custom_ON) - and (right_click or middle_click) and mode=='Viewer' - ) - - if editInViewerMode: - self.startBlinkingModeCB() - event.ignore() - return - - # Left-click is used for brush, eraser, separate bud, curvature tool - # and magic labeller - # Brush and eraser are mutually exclusive but we want to keep the eraser - # or brush ON and disable them temporarily to allow left-click with - # separate ON - canDelete = mode == 'Segmentation and Tracking' or self.isSnapshot - - # Delete ID (set to 0) - if middle_click and canDelete: - t0 = time.perf_counter() - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - delID = self.get_2Dlab(posData.lab)[ydata, xdata] - if delID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - delID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.
' - 'Enter here ID(s) that you want to delete

' - 'You can enter multiple IDs separated by comma', - parent=self, - allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - allowList=True, - isInteger=True - ) - delID_prompt.exec_() - if delID_prompt.cancel: - return - delIDs = delID_prompt.EntryID - else: - delIDs = [delID] - - # Ask to propagate change to all future visited frames - key = 'Delete ID' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - delIDs, key, doNotShow, - posData.UndoFutFrames_DelID, posData.applyFutFrames_DelID - ) - - if UndoFutFrames is None: - return - - # Store undo state before modifying stuff - self.storeUndoRedoStates(UndoFutFrames) - posData.doNotShowAgain_DelID = doNotShowAgain - posData.UndoFutFrames_DelID = UndoFutFrames - posData.applyFutFrames_DelID = applyFutFrames - includeUnvisited = posData.includeUnvisitedInfo['Delete ID'] - - delID_mask = self.deleteIDmiddleClick( - delIDs, applyFutFrames, includeUnvisited, shift=shift_regardless - ) - if delID_mask.ndim == 3: - delID_mask = delID_mask[self.z_lab()] - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete ID') - else: - self.warnEditingWithCca_df('Delete ID', update_images=False) - - self.setImageImg2() - delROIsIDs = self.setAllTextAnnotations() - self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False) - - how = self.drawIDsContComboBox.currentText() - if how.find('overlay segm. masks') != -1: - self.labelsLayerImg1.image[delID_mask] = 0 - self.labelsLayerImg1.setImage(self.labelsLayerImg1.image) - - how_ax2 = self.getAnnotateHowRightImage() - if how_ax2.find('overlay segm. masks') != -1: - self.labelsLayerRightImg.image[delID_mask] = 0 - self.labelsLayerRightImg.setImage(self.labelsLayerRightImg.image) - - self.highlightLostNew() - - # Separate bud or objects with same ID - elif right_click and separateON: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x) - sepID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here ID that you want to split', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - sepID_prompt.exec_() - if sepID_prompt.cancel: - return - else: - ID = sepID_prompt.EntryID - y, x = posData.rp[posData.IDs_idxs[ID]].centroid[-2:] - xdata, ydata = int(x), int(y) - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - max_ID = max(posData.IDs, default=1) - - if self.isSegm3D and not shift: - z = self.zSliceScrollBar.sliderPosition() - posData.lab, splittedIDs = measure.separate_with_label( - posData.lab, posData.rp, [ID], max_ID, - click_coords_list=[(z, ydata, xdata)] - ) - success = True - # self.set_2Dlab(lab2D) - elif not shift: - result = core.split_along_convexity_defects( - ID, self.get_2Dlab(posData.lab), max_ID - ) - lab2D, success, splittedIDs = result - self.set_2Dlab(lab2D) - else: - success = False - - # If automatic bud separation was not successfull call manual one - if not success: - posData.disableAutoActivateViewerWindow = True - img = self.getDisplayedImg1() - col = 'manual_separate_draw_mode' - drawMode = self.df_settings.at[col, 'value'] - manualSep = apps.manualSeparateGui( - self.get_2Dlab(posData.lab), ID, img, - fontSize=self.fontSize, - IDcolor=self.lut[ID], - parent=self, - drawMode=drawMode - ) - manualSep.setState(self.lastManualSeparateState) - manualSep.show() - manualSep.centerWindow() - manualSep.show(block=True) - if manualSep.cancel: - posData.disableAutoActivateViewerWindow = False - if not self.separateBudButton.findChild(QAction).isChecked(): - self.separateBudButton.setChecked(False) - return - self.lastManualSeparateState = manualSep.state() - lab2D = self.get_2Dlab(posData.lab) - lab2D[manualSep.lab!=0] = manualSep.lab[manualSep.lab!=0] - self.set_2Dlab(lab2D) - splittedIDs = [obj.label for obj in manualSep.rp] - posData.disableAutoActivateViewerWindow = False - self.storeManualSeparateDrawMode(manualSep.drawMode) - - # Update data (rp, etc) - self.update_rp() - - # Repeat tracking - self.trackSubsetIDs(splittedIDs) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Separate IDs') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Separate IDs') - - self.store_data() - - if not self.separateBudButton.findChild(QAction).isChecked(): - self.separateBudButton.setChecked(False) - - # Fill holes - elif right_click and self.fillHolesToolButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - clickedBkgrID = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here the ID that you want to ' - 'fill the holes of', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - clickedBkgrID.exec_() - if clickedBkgrID.cancel: - return - else: - ID = clickedBkgrID.EntryID - - if ID in posData.lab: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - obj_idx = posData.IDs.index(ID) - obj = posData.rp[obj_idx] - objMask = self.getObjImage(obj.image, obj.bbox) - localFill = scipy.ndimage.binary_fill_holes(objMask) - posData.lab[self.getObjSlice(obj.slice)][localFill] = ID - - self.update_rp() - self.updateAllImages() - - if not self.fillHolesToolButton.findChild(QAction).isChecked(): - self.fillHolesToolButton.setChecked(False) - - # Hull contour - elif right_click and self.hullContToolButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - mergeID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here the ID that you want to ' - 'replace with Hull contour', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - mergeID_prompt.exec_() - if mergeID_prompt.cancel: - return - else: - ID = mergeID_prompt.EntryID - - if ID in posData.lab: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - obj_idx = posData.IDs.index(ID) - obj = posData.rp[obj_idx] - objMask = self.getObjImage(obj.image, obj.bbox) - localHull = skimage.morphology.convex_hull_image(objMask) - posData.lab[self.getObjSlice(obj.slice)][localHull] = ID - - self.update_rp() - self.updateAllImages() - - if not self.hullContToolButton.findChild(QAction).isChecked(): - self.hullContToolButton.setChecked(False) - - # Move label - elif right_click and self.moveLabelToolButton.isChecked(): - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - x, y = event.pos().x(), event.pos().y() - self.startMovingLabel(x, y) - - # Fill holes - elif right_click and self.fillHolesToolButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - clickedBkgrID = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here the ID that you want to ' - 'fill the holes of', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - clickedBkgrID.exec_() - if clickedBkgrID.cancel: - return - else: - ID = clickedBkgrID.EntryID - - # Merge IDs - elif right_click and self.mergeIDsButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - mergeID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here first ID that you want to merge', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - mergeID_prompt.exec_() - if mergeID_prompt.cancel: - self.mergeObjsTempLine.setData([], []) - return - else: - ID = mergeID_prompt.EntryID - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - self.firstID = ID - - obj_idx = posData.IDs_idxs[ID] - obj = posData.rp[obj_idx] - yc, xc = self.getObjCentroid(obj.centroid) - self.clickObjYc, self.clickObjXc = int(yc), int(xc) - - # Edit ID - elif right_click and self.editIDbutton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - editID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here ID that you want to replace with a new one', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - editID_prompt.show(block=True) - - if editID_prompt.cancel: - return - else: - ID = editID_prompt.EntryID - - obj_idx = posData.IDs_idxs[ID] - y, x = posData.rp[obj_idx].centroid[-2:] - xdata, ydata = int(x), int(y) - - posData.disableAutoActivateViewerWindow = True - currentIDs = posData.IDs.copy() - self.setAllIDs(onlyVisited=True) - addPropagateCheckbox = ( - not self.isSnapshot - and posData.frame_i == self.navigateScrollBar.maximum() - 1 - and posData.frame_i < posData.SizeT - 1 - ) - editID = apps.EditIDDialog( - ID, posData.IDs, - doNotShowAgain=self.doNotAskAgainExistingID, - parent=self, - entryID=self.getNearestLostObjID(y, x), - nextUniqueID=self.setBrushID(return_val=True), - allIDs=posData.allIDs, - addPropagateCheckbox=addPropagateCheckbox - ) - editID.show(block=True) - if editID.cancel: - posData.disableAutoActivateViewerWindow = False - if not self.editIDbutton.findChild(QAction).isChecked(): - self.editIDbutton.setChecked(False) - return - - if editID.assignNewID: - self.assignNewIDfromClickedID(ID, event) - return - - if not self.doNotAskAgainExistingID: - self.editIDmergeIDs = editID.mergeWithExistingID - self.doNotAskAgainExistingID = editID.doNotAskAgainExistingID - - self.applyEditID( - ID, currentIDs, editID.how, x, y, - shift=shift, - doPropagateUnvisited=editID.doPropagateFutureFrames - ) - - elif (right_click or left_click) and self.keepIDsButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - keepID_win = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to keep', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - keepID_win.exec_() - if keepID_win.cancel: - return - else: - ID = keepID_win.EntryID - - if ID in self.keptObjectsIDs: - self.keptObjectsIDs.remove(ID) - self.clearHighlightedText() - else: - self.keptObjectsIDs.append(ID) - self.highlightLabelID(ID) - - self.updateTempLayerKeepIDs() - - # Annotate cell as removed from the analysis - elif right_click and self.binCellButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - binID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to remove from the analysis', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - binID_prompt.exec_() - if binID_prompt.cancel: - return - else: - ID = binID_prompt.EntryID - - # Ask to propagate change to all future visited frames - key = 'Exclude cell from analysis' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - ID, key, doNotShow, - posData.UndoFutFrames_BinID, - posData.applyFutFrames_BinID - ) - - if UndoFutFrames is None: - # User cancelled the process - return - - posData.doNotShowAgain_BinID = doNotShowAgain - posData.UndoFutFrames_BinID = UndoFutFrames - posData.applyFutFrames_BinID = applyFutFrames - - self.current_frame_i = posData.frame_i - - # Apply Exclude cell from analysis to future frames if requested - if applyFutFrames: - # Store current data before going to future frames - self.store_data() - for i in range(posData.frame_i+1, endFrame_i+1): - posData.frame_i = i - self.get_data() - if ID in posData.binnedIDs: - posData.binnedIDs.remove(ID) - else: - posData.binnedIDs.add(ID) - self.update_rp_metadata(draw=False) - self.store_data(autosave=i==endFrame_i) - - self.app.restoreOverrideCursor() - - # Back to current frame - if applyFutFrames: - posData.frame_i = self.current_frame_i - self.get_data() - - # Store undo state before modifying stuff - self.storeUndoRedoStates(UndoFutFrames) - - if ID in posData.binnedIDs: - posData.binnedIDs.remove(ID) - else: - posData.binnedIDs.add(ID) - - self.annotate_rip_and_bin_IDs(updateLabel=True) - - # Gray out ore restore binned ID - self.updateLookuptable() - - if not self.binCellButton.findChild(QAction).isChecked(): - self.binCellButton.setChecked(False) - - # Annotate cell as dead - elif right_click and self.ripCellButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - ripID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as dead', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - ripID_prompt.exec_() - if ripID_prompt.cancel: - return - else: - ID = ripID_prompt.EntryID - - # Ask to propagate change to all future visited frames - key = 'Annotate cell as dead' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - ID, key, doNotShow, - posData.UndoFutFrames_RipID, - posData.applyFutFrames_RipID - ) - - if UndoFutFrames is None: - return - - posData.doNotShowAgain_RipID = doNotShowAgain - posData.UndoFutFrames_RipID = UndoFutFrames - posData.applyFutFrames_RipID = applyFutFrames - - self.current_frame_i = posData.frame_i - - # Apply Edit ID to future frames if requested - if applyFutFrames: - # Store current data before going to future frames - self.store_data() - for i in range(posData.frame_i+1, endFrame_i+1): - posData.frame_i = i - self.get_data() - if ID in posData.ripIDs: - posData.ripIDs.remove(ID) - else: - posData.ripIDs.add(ID) - self.update_rp_metadata(draw=False) - self.store_data(autosave=i==endFrame_i) - self.app.restoreOverrideCursor() - - # Back to current frame - if applyFutFrames: - posData.frame_i = self.current_frame_i - self.get_data() - - # Store undo state before modifying stuff - self.storeUndoRedoStates(UndoFutFrames) - - if ID in posData.ripIDs: - posData.ripIDs.remove(ID) - else: - posData.ripIDs.add(ID) - - self.annotate_rip_and_bin_IDs(updateLabel=True) - - # Gray out dead ID - self.updateLookuptable() - self.store_data() - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Annotate ID as dead') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Annotate ID as dead') - - if not self.ripCellButton.findChild(QAction).isChecked(): - self.ripCellButton.setChecked(False) - - def resetExpandLabel(self): - self.expandingID = -1 - - def expandLabelCallback(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - self.connectLeftClickButtons() - self.expandFootprintSize = 1 - else: - self.clearHighlightedID() - alpha = self.imgGrad.labelsAlphaSlider.value() - self.labelsLayerImg1.setOpacity(alpha) - self.labelsLayerRightImg.setOpacity(alpha) - self.hoverLabelID = 0 - self.expandingID = 0 - self.updateAllImages() - - def expandLabel(self, dilation=True): - posData = self.data[self.pos_i] - if self.hoverLabelID == 0: - self.isExpandingLabel = False - return - - # Re-initialize label to expand when we hover on a different ID - # or we change direction - reinitExpandingLab = ( - self.expandingID != self.hoverLabelID - or dilation != self.isDilation - ) - - ID = self.hoverLabelID - - obj = posData.rp[posData.IDs.index(ID)] - - if reinitExpandingLab: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - # hoverLabelID different from previously expanded ID --> reinit - self.isExpandingLabel = True - self.expandingID = ID - self.expandingLab = np.zeros_like(self.currentLab2D) - self.expandingLab[obj.coords[:,-2], obj.coords[:,-1]] = ID - self.expandFootprintSize = 1 - - prevCoords = (obj.coords[:,-2], obj.coords[:,-1]) - self.currentLab2D[obj.coords[:,-2], obj.coords[:,-1]] = 0 - lab_2D = self.get_2Dlab(posData.lab) - lab_2D[obj.coords[:,-2], obj.coords[:,-1]] = 0 - - footprint = skimage.morphology.disk(self.expandFootprintSize) - if dilation: - expandedLab = skimage.morphology.dilation( - self.expandingLab, footprint - ) - self.isDilation = True - else: - expandedLab = skimage.morphology.erosion( - self.expandingLab, footprint - ) - self.isDilation = False - - # Prevent expanding into neighbouring labels - expandedLab[self.currentLab2D>0] = 0 - - # Get coords of the dilated/eroded object - expandedObj = skimage.measure.regionprops(expandedLab)[0] - expandedObjCoords = (expandedObj.coords[:,-2], expandedObj.coords[:,-1]) - - # Add the dilated/erored object - self.currentLab2D[expandedObjCoords] = self.expandingID - lab_2D[expandedObjCoords] = self.expandingID - - self.set_2Dlab(lab_2D) - self.currentLab2D = lab_2D - - self.update_rp() - - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.img2.setImage(img=self.currentLab2D, autoLevels=False) - - self.setTempImgExpandLabel(prevCoords, expandedObjCoords) - - def startMovingLabel(self, xPos, yPos): - posData = self.data[self.pos_i] - xdata, ydata = int(xPos), int(yPos) - lab_2D = self.get_2Dlab(posData.lab) - ID = lab_2D[ydata, xdata] - if ID == 0: - self.isMovingLabel = False - return - - posData = self.data[self.pos_i] - self.isMovingLabel = True - - self.searchedIDitemRight.setData([], []) - self.searchedIDitemLeft.setData([], []) - self.movingID = ID - self.prevMovePos = (xdata, ydata) - movingObj = posData.rp[posData.IDs.index(ID)] - self.movingObjCoords = movingObj.coords.copy() - yy, xx = movingObj.coords[:,-2], movingObj.coords[:,-1] - self.currentLab2D[yy, xx] = 0 - - def moveLabel(self, xPos, yPos): - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - xdata, ydata = int(xPos), int(yPos) - if xdata<0 or ydata<0 or xdata>=X or ydata>=Y: - return - - self.clearObjContour(ID=self.movingID, ax=0) - - xStart, yStart = self.prevMovePos - deltaX = xdata-xStart - deltaY = ydata-yStart - - yy, xx = self.movingObjCoords[:,-2], self.movingObjCoords[:,-1] - - if self.isSegm3D: - zz = self.movingObjCoords[:,0] - posData.lab[zz, yy, xx] = 0 - else: - posData.lab[yy, xx] = 0 - - self.movingObjCoords[:,-2] = self.movingObjCoords[:,-2]+deltaY - self.movingObjCoords[:,-1] = self.movingObjCoords[:,-1]+deltaX - - yy, xx = self.movingObjCoords[:,-2], self.movingObjCoords[:,-1] - - yy[yy<0] = 0 - xx[xx<0] = 0 - yy[yy>=Y] = Y-1 - xx[xx>=X] = X-1 - - if self.isSegm3D: - zz = self.movingObjCoords[:,0] - posData.lab[zz, yy, xx] = self.movingID - else: - posData.lab[yy, xx] = self.movingID - - self.currentLab2D = self.get_2Dlab(posData.lab) - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.img2.setImage(self.currentLab2D, autoLevels=False) - - self.setTempImg1MoveLabel() - - self.prevMovePos = (xdata, ydata) - - @exception_handler - def gui_mouseDragEventImg1(self, event): - x, y = event.pos().x(), event.pos().y() - - if hasattr(self, 'scaleBar'): - if self.scaleBarDialog is not None: - self.scaleBarDialog.locCombobox.setCurrentText('Custom') - if self.scaleBar.isHighlighted() and self.scaleBar.clicked: - self.scaleBar.setLocationProperty('custom') - self.scaleBar.move(x, y) - return - - if hasattr(self, 'timestamp'): - if self.timestampDialog is not None: - self.timestampDialog.locCombobox.setCurrentText('Custom') - if self.timestamp.isHighlighted() and self.timestamp.clicked: - self.timestamp.setLocationProperty('custom') - self.timestamp.move(x, y) - return - - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - return - - posData = self.data[self.pos_i] - Y, X = self.get_2Dlab(posData.lab).shape - xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): - return - - if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): - self.drawAutoContour(y, x) - - # Brush dragging mouse --> keep brushing - elif self.isMouseDragImg1 and self.brushButton.isChecked(): - lab_2D = self.get_2Dlab(posData.lab) - - # t1 = time.perf_counter() - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) - - # t2 = time.perf_counter() - - diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) - - # Build brush mask - mask = np.zeros(lab_2D.shape, bool) - mask[diskSlice][diskMask] = True - mask[rrPoly, ccPoly] = True - - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - - # t3 = time.perf_counter() - if not self.isPowerBrush() and not ctrl: - mask[lab_2D!=0] = False - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush - ) - - # t4 = time.perf_counter() - - # Apply brush mask - self.applyBrushMask(mask, posData.brushID) - - self.setImageImg2(updateLookuptable=False) - - # t5 = time.perf_counter() - - lab2D = self.get_2Dlab(posData.lab) - brushMask = np.logical_and( - lab2D[diskSlice] == posData.brushID, diskMask - ) - self.setTempImg1Brush( - False, brushMask, posData.brushID, - toLocalSlice=diskSlice - ) - - # t6 = time.perf_counter() - - # printl( - # 'Brush exec times =\n' - # f' * {(t1-t0)*1000 = :.4f} ms\n' - # f' * {(t2-t1)*1000 = :.4f} ms\n' - # f' * {(t3-t2)*1000 = :.4f} ms\n' - # f' * {(t4-t3)*1000 = :.4f} ms\n' - # f' * {(t5-t4)*1000 = :.4f} ms\n' - # f' * {(t6-t5)*1000 = :.4f} ms\n' - # f' * {(t6-t0)*1000 = :.4f} ms' - # ) - - # Eraser dragging mouse --> keep erasing - elif self.isMouseDragImg1 and self.eraserButton.isChecked(): - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - - diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) - - # Build eraser mask - mask = np.zeros(lab_2D.shape, bool) - mask[ymin:ymax, xmin:xmax][diskMask] = True - mask[rrPoly, ccPoly] = True - - if self.eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False - self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, - (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], - ID=self.erasedID - ) - - self.erasedIDs.update(lab_2D[mask]) - self.applyEraserMask(mask) - - self.setImageImg2() - - for erasedID in self.erasedIDs: - if erasedID == 0: - continue - self.erasedLab[lab_2D==erasedID] = erasedID - self.erasedLab[mask] = 0 - - eraserMask = mask[diskSlice] - self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice) - self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice, ax=1) - - # Move label dragging mouse --> keep moving - elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - self.moveLabel(x, y) - - # Wand dragging mouse --> keep doing the magic - elif self.isMouseDragImg1 and self.wandToolButton.isChecked(): - tol = self.getMagicWandFloodTolerance() - if self.isSegm3D: - z_slice = self.zSliceScrollBar.sliderPosition() - seed = (z_slice, ydata, xdata) - else: - seed = (ydata, xdata) - - flood_mask = skimage.segmentation.flood( - self.flood_img, seed, tolerance=tol - ) - drawUnderMask = np.logical_or( - posData.lab==0, posData.lab==posData.brushID - ) - flood_mask = np.logical_and(flood_mask, drawUnderMask) - - self.flood_mask[flood_mask] = True - - if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): - self.flood_mask = core.binary_fill_holes(self.flood_mask) - - if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): - self.flood_mask = core.convex_hull_mask(self.flood_mask) - - self.setTempBrushMaskFromWand(self.flood_mask) - - # Label ROI dragging mouse --> draw ROI - elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): - if self.labelRoiIsRectRadioButton.isChecked(): - x0, y0 = self.labelRoiItem.pos() - w, h = (xdata-x0), (ydata-y0) - self.labelRoiItem.setSize((w, h)) - elif self.labelRoiIsFreeHandRadioButton.isChecked(): - self.freeRoiItem.addPoint(xdata, ydata) - - # Draw freehand clear region --> draw region - elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): - self.freeRoiItem.addPoint(xdata, ydata) - - # Label ROI dragging mouse --> draw ROI - elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): - x0, y0 = self.zoomRectItem.pos() - w, h = (xdata-x0), (ydata-y0) - self.zoomRectItem.setSize((w, h)) - - # @exec_time - def fillHolesID(self, ID, sender='brush'): - posData = self.data[self.pos_i] - if sender == 'brush': - if not self.brushAutoFillCheckbox.isChecked(): - return False - - lab2D = self.get_2Dlab(posData.lab) - mask = lab2D == ID - filledMask = scipy.ndimage.binary_fill_holes(mask) - lab2D[filledMask] = ID - - self.set_2Dlab(lab2D) - return True - return False - - def highlightIDonHoverCheckBoxToggled(self, checked): - doHighlight = ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() - ) - if not doHighlight: - self.highlightedID = 0 - self.initLookupTableLab() - else: - self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - self.highlightSearchedID(self.highlightedID, force=True) - self.updatePropsWidget(self.highlightedID) - self.updateAllImages() - - def highlightSearchedIDcheckBoxToggled(self, checked): - self.highlightIDonHoverCheckBoxToggled(checked) - if checked: - posData = self.data[self.pos_i] - self.highlightedID = self.getHighlightedID() - if self.highlightedID == 0: - return - objIdx = posData.IDs_idxs[self.highlightedID] - obj_idx = posData.IDs_idxs.get(self.highlightedID) - if obj_idx is None: - return - obj = posData.rp[objIdx] - self.goToZsliceSearchedID(obj) - - def setHighlightID(self, doHighlight): - if not doHighlight: - self.highlightedID = 0 - self.initLookupTableLab() - else: - self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - self.highlightSearchedID(self.highlightedID, force=True) - self.updatePropsWidget(self.highlightedID) - self.updateAllImages() - - def propsWidgetIDvalueChanged(self, ID): - posData = self.data[self.pos_i] - if ID == 0: - self.updatePropsWidget(int(ID)) - return - - propsQGBox = self.guiTabControl.propsQGBox - obj_idx = posData.IDs_idxs.get(ID) - if obj_idx is None: - s = f'Object ID {int(ID):d} does not exist' - propsQGBox.notExistingIDLabel.setText(s) - return - - obj = posData.rp[obj_idx] - self.goToZsliceSearchedID(obj) - self.updatePropsWidget(int(ID)) - - def updatePropsWidget(self, ID, fromHover=False): - if isinstance(ID, str): - # Function called by currentTextChanged of channelCombobox or - # additionalMeasCombobox. We set self.currentPropsID = 0 to force update - ID = self.guiTabControl.propsQGBox.idSB.value() - self.currentPropsID = -1 - - ID = int(ID) - - update = ( - self.propsDockWidget.isVisible() - and ID != 0 and ID!=self.currentPropsID - ) - if not update: - return - - posData = self.data[self.pos_i] - if not hasattr(posData, 'rp'): - return - - if posData.rp is None: - self.update_rp() - - if not posData.IDs: - # empty segmentation mask - return - - if fromHover and not self.guiTabControl.highlightCheckbox.isChecked(): - # Do not highlight on hover - return - - propsQGBox = self.guiTabControl.propsQGBox - - obj_idx = posData.IDs_idxs.get(ID) - if obj_idx is None: - s = f'Object ID {int(ID):d} does not exist' - propsQGBox.notExistingIDLabel.setText(s) - return - - propsQGBox.notExistingIDLabel.setText('') - self.currentPropsID = ID - propsQGBox.idSB.setValue(ID) - - doHighlight = ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() - ) - if doHighlight: - self.highlightSearchedID(ID) - - obj = posData.rp[obj_idx] - - if self.isSegm3D: - if self.zProjComboBox.currentText() == 'single z-slice': - local_z = self.z_lab() - obj.bbox[0] - area_pxl = np.count_nonzero(obj.image[local_z]) - else: - area_pxl = np.count_nonzero(obj.image.max(axis=0)) - else: - area_pxl = obj.area - - propsQGBox.cellAreaPxlSB.setValue(area_pxl) - - pixelSizeQGBox = self.guiTabControl.pixelSizeQGBox - PhysicalSizeX = pixelSizeQGBox.pixelWidthWidget.value() - PhysicalSizeY = pixelSizeQGBox.pixelHeightWidget.value() - PhysicalSizeZ = pixelSizeQGBox.voxelDepthWidget.value() - - yx_pxl_to_um2 = PhysicalSizeY*PhysicalSizeX - - area_um2 = area_pxl*yx_pxl_to_um2 - - propsQGBox.cellAreaUm2DSB.setValue(area_um2) - - if self.isSegm3D: - PhysicalSizeZ = posData.PhysicalSizeZ - vol_vox_3D = obj.area - vol_fl_3D = vol_vox_3D*PhysicalSizeZ*PhysicalSizeY*PhysicalSizeX - propsQGBox.cellVolVox3D_SB.setValue(vol_vox_3D) - propsQGBox.cellVolFl3D_DSB.setValue(vol_fl_3D) - - vol_vox, vol_fl = _calc_rot_vol( - obj, PhysicalSizeY, PhysicalSizeX - ) - propsQGBox.cellVolVoxSB.setValue(int(vol_vox)) - propsQGBox.cellVolFlDSB.setValue(vol_fl) - - - minor_axis_length = max(1, obj.minor_axis_length) - elongation = obj.major_axis_length/minor_axis_length - propsQGBox.elongationDSB.setValue(elongation) - - solidity = obj.solidity - propsQGBox.solidityDSB.setValue(solidity) - - additionalPropName = propsQGBox.additionalPropsCombobox.currentText() - additionalPropValue = getattr(obj, additionalPropName) - propsQGBox.additionalPropsCombobox.indicator.setValue(additionalPropValue) - - intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox - selectedChannel = intensMeasurQGBox.channelCombobox.currentText() - - try: - _, filename = self.getPathFromChName(selectedChannel, posData) - image = posData.ol_data_dict[filename][posData.frame_i] - except Exception as e: - image = posData.img_data[posData.frame_i] - - if posData.SizeZ > 1 and not self.isSegm3D: - z = self.zSliceScrollBar.sliderPosition() - objData = image[z][obj.slice][obj.image] - img = self.img1.image - else: - objData = image[obj.slice][obj.image] - img = image - - intensMeasurQGBox.minimumDSB.setValue(np.min(objData)) - intensMeasurQGBox.maximumDSB.setValue(np.max(objData)) - intensMeasurQGBox.meanDSB.setValue(np.mean(objData)) - intensMeasurQGBox.medianDSB.setValue(np.median(objData)) - - funcDesc = intensMeasurQGBox.additionalMeasCombobox.currentText() - func = intensMeasurQGBox.additionalMeasCombobox.functions[funcDesc] - if funcDesc == 'Concentration': - bkgrVal = np.median(img[posData.lab == 0]) - amount = func(objData, bkgrVal, obj.area) - value = amount/vol_vox - elif funcDesc == 'Amount': - bkgrVal = np.median(img[posData.lab == 0]) - amount = func(objData, bkgrVal, obj.area) - value = amount - else: - value = func(objData) - - intensMeasurQGBox.additionalMeasCombobox.indicator.setValue(value) - - def gui_hoverEventRightImage(self, event): - try: - posData = self.data[self.pos_i] - except AttributeError: - return - - if event.isExit(): - self.resetCursors() - - self.gui_hoverEventImg1(event, isHoverImg1=False) - setMirroredCursor = ( - self.app.overrideCursor() is None and not event.isExit() - and self.showMirroredCursorAction.isChecked() - ) - if setMirroredCursor: - x, y = event.pos() - self.ax1_cursor.setData([x], [y]) - - def onCtrlPressedFirstTime(self): - x, y = self.xHoverImg, self.yHoverImg - if x is None: - self.xyOnCtrlPressedFirstTime = None - return - - xdata, ydata = int(x), int(y) - Y, X = self.currentLab2D.shape - - if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): - self.xyOnCtrlPressedFirstTime = None - return - - ID = self.currentLab2D[ydata, xdata] - if ID == 0: - self.xyOnCtrlPressedFirstTime = None - return - - self.xyOnCtrlPressedFirstTime = (xdata, ydata) - - def onCtrlReleased(self): - self.xyOnCtrlPressedFirstTime = None - - def gui_hoverEventImg1(self, event, isHoverImg1=True): - try: - posData = self.data[self.pos_i] - except AttributeError: - return - - # Update x, y, value label bottom right - if not event.isExit(): - self.xHoverImg, self.yHoverImg = event.pos() - else: - self.xHoverImg, self.yHoverImg = None, None - - if event.isExit(): - self.resetCursor() - - if not event.isExit() and self.slideshowWin is not None: - self.slideshowWin.setMirroredCursorPos(*event.pos()) - - # Alt key was released --> restore cursor - modifiers = QGuiApplication.keyboardModifiers() - cursorsInfo = self.gui_setCursor(modifiers, event) - self.highlightHoverLostObj(modifiers, event) - - drawRulerLine = ( - (self.rulerButton.isChecked() - or self.addDelPolyLineRoiButton.isChecked()) - and self.tempSegmentON and not event.isExit() - ) - if drawRulerLine: - self.drawTempRulerLine(event) - - if not event.isExit(): - x, y = event.pos() - xdata, ydata = int(x), int(y) - _img = self.img1.image - Y, X = _img.shape[:2] - if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: - ID = self.currentLab2D[ydata, xdata] - self.updatePropsWidget(ID, fromHover=True) - activeToolButton = self.getActiveToolButton() - hoverText = self.hoverValuesFormatted( - xdata, ydata, activeToolButton, isHoverImg1 - ) - self.checkHighlightScaleBar(x, y, activeToolButton) - self.checkHighlightTimestamp(x, y, activeToolButton) - self.wcLabel.setText(hoverText) - else: - self.clickedOnBud = False - self.BudMothTempLine.setData([], []) - self.wcLabel.setText('') - - if cursorsInfo['setKeepObjCursor']: - x, y = event.pos() - self.highlightHoverIDsKeptObj(x, y) - - if cursorsInfo['setManualTrackingCursor']: - x, y = event.pos() - # self.highlightHoverID(x, y) - self.drawManualTrackingGhost(x, y) - - if cursorsInfo['setManualBackgroundCursor']: - x, y = event.pos() - # self.highlightHoverID(x, y) - self.drawManualBackgroundObj(x, y) - - if ( - not cursorsInfo['setManualTrackingCursor'] - and not cursorsInfo['setManualBackgroundCursor'] - ): - self.clearGhost() - - setMoveLabelCursor = cursorsInfo['setMoveLabelCursor'] - setExpandLabelCursor = cursorsInfo['setExpandLabelCursor'] - if setMoveLabelCursor or setExpandLabelCursor: - x, y = event.pos() - self.updateHoverLabelCursor(x, y) - - # Draw eraser circle - if cursorsInfo['setEraserCursor']: - x, y = event.pos() - self.updateEraserCursor(x, y, isHoverImg1=isHoverImg1) - self.hideItemsHoverBrush(xy=(x, y)) - elif self.eraserButton.isChecked() and not event.isExit(): - if self.xyOnCtrlPressedFirstTime is not None: - self.updateEraserCursor( - x, y, xyLocked=self.xyOnCtrlPressedFirstTime, - isHoverImg1=isHoverImg1 - ) - self.hideItemsHoverBrush(xy=(x, y)) - else: - eraserCursors = ( - self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX - ) - self.setHoverToolSymbolData([], [], eraserCursors) - - # Draw Brush circle - if cursorsInfo['setBrushCursor']: - x, y = event.pos() - self.updateBrushCursor(x, y, isHoverImg1=isHoverImg1) - self.hideItemsHoverBrush(xy=(x, y)) - elif cursorsInfo['setAddPointCursor']: - x, y = event.pos() - self.setHoverCircleAddPoint(x, y) - else: - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - - # Draw label ROi circular cursor - setLabelRoiCircCursor = cursorsInfo['setLabelRoiCircCursor'] - if setLabelRoiCircCursor: - x, y = event.pos() - else: - x, y = None, None - self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) - - drawMothBudLine = ( - self.assignBudMothButton.isChecked() and self.clickedOnBud - and not event.isExit() - ) - if drawMothBudLine: - self.drawTempMothBudLine(event, posData) - - drawMergeObjsLine = ( - self.mergeIDsButton.isChecked() and not event.isExit() - ) - if drawMergeObjsLine: - self.drawTempMergeObjsLine(event, posData, modifiers) - - # Temporarily draw spline curve - # see https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy - drawSpline = ( - self.curvToolButton.isChecked() and self.splineHoverON - and not event.isExit() - ) - if drawSpline: - self.hoverEventDrawSpline(event) - - setMirroredCursor = ( - self.app.overrideCursor() is None and not event.isExit() - and isHoverImg1 and self.showMirroredCursorAction.isChecked() - ) - if setMirroredCursor: - x, y = event.pos() - self.ax2_cursor.setData([x], [y]) - else: - self.ax2_cursor.setData([], []) - - return cursorsInfo - - def drawTempMothBudLine(self, event, posData): - x, y = event.pos() - y2, x2 = y, x - xdata, ydata = int(x), int(y) - y1, x1 = self.yClickBud, self.xClickBud - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - self.BudMothTempLine.setData([x1, x2], [y1, y2]) - else: - obj_idx = posData.IDs_idxs[ID] - obj = posData.rp[obj_idx] - y2, x2 = self.getObjCentroid(obj.centroid) - self.BudMothTempLine.setData([x1, x2], [y1, y2]) - - def drawTempMergeObjsLine(self, event, posData, modifiers): - if self.clickObjYc is None: - return - modifier = modifiers == Qt.ShiftModifier - x, y = event.pos() - y2, x2 = y, x - xdata, ydata = int(x), int(y) - y1, x1 = self.clickObjYc, self.clickObjXc - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID != 0: - obj_idx = posData.IDs_idxs[ID] - obj = posData.rp[obj_idx] - y2, x2 = self.getObjCentroid(obj.centroid) - - if modifier and ID > 0: - self.mergeObjsTempLine.addPoint(x2, y2) - elif not modifier: - self.mergeObjsTempLine.setData([x1, x2], [y1, y2]) - - def gui_add_ax_cursors(self): - try: - self.ax1.removeItem(self.ax1_cursor) - self.ax2.removeItem(self.ax2_cursor) - except Exception as e: - pass - - self.ax2_cursor = pg.ScatterPlotItem( - symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), - brush=pg.mkBrush('w'), size=16, tip=None - ) - self.ax2.addItem(self.ax2_cursor) - - self.ax1_cursor = pg.ScatterPlotItem( - symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), - brush=pg.mkBrush('w'), size=16, tip=None - ) - self.ax1.addItem(self.ax1_cursor) - - def gui_setCursor(self, modifiers, event): - noModifier = modifiers == Qt.NoModifier - shift = modifiers == Qt.ShiftModifier - ctrl = modifiers == Qt.ControlModifier - alt = modifiers == Qt.AltModifier - - # Alt key was released --> restore cursor - if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: - self.app.restoreOverrideCursor() - - setBrushCursor = ( - self.brushButton.isChecked() and not event.isExit() - and (noModifier or shift or ctrl) - ) - setEraserCursor = ( - self.eraserButton.isChecked() and not event.isExit() - and noModifier - ) - setAddDelPolyLineCursor = ( - self.addDelPolyLineRoiButton.isChecked() and not event.isExit() - and noModifier - ) - setLabelRoiCircCursor = ( - self.labelRoiButton.isChecked() and not event.isExit() - and (noModifier or shift or ctrl) - and self.labelRoiIsCircularRadioButton.isChecked() - ) - setWandCursor = ( - self.wandToolButton.isChecked() and not event.isExit() - and noModifier - ) - setLabelRoiCursor = ( - self.labelRoiButton.isChecked() and not event.isExit() - and noModifier - ) - setMoveLabelCursor = ( - self.moveLabelToolButton.isChecked() and not event.isExit() - and noModifier - ) - setExpandLabelCursor = ( - self.expandLabelToolButton.isChecked() and not event.isExit() - and noModifier - ) - setCurvCursor = ( - self.curvToolButton.isChecked() and not event.isExit() - and noModifier - ) - setKeepObjCursor = ( - self.keepIDsButton.isChecked() and not event.isExit() - and noModifier - ) - setCustomAnnotCursor = ( - self.customAnnotButton is not None and not event.isExit() - and noModifier - ) - setManualTrackingCursor = ( - self.manualTrackingButton.isChecked() - and not event.isExit() - and noModifier - ) - setManualBackgroundCursor = ( - self.manualBackgroundButton.isChecked() - and not event.isExit() - and noModifier - ) - setZoomRectCursor = ( - self.zoomRectButton.isChecked() and not event.isExit() - and noModifier - ) - setEditIDCursor = ( - self.editIDbutton.isChecked() and not event.isExit() - ) - magicPromptsON = self.magicPromptsToolButton.isChecked() - pointsLayerON = self.togglePointsLayerAction.isChecked() - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - setAddPointCursor = ( - (pointsLayerON or magicPromptsON) - and addPointsByClickingButton is not None - and not event.isExit() - and noModifier - ) - overrideCursor = self.app.overrideCursor() - setPanImageCursor = alt and not event.isExit() - if setPanImageCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.SizeAllCursor) - elif setBrushCursor or setEraserCursor or setLabelRoiCircCursor: - self.app.setOverrideCursor(Qt.CrossCursor) - elif setWandCursor and overrideCursor is None: - self.app.setOverrideCursor(self.wandCursor) - elif setLabelRoiCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.CrossCursor) - elif setCurvCursor and overrideCursor is None: - self.app.setOverrideCursor(self.curvCursor) - elif setCustomAnnotCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) - elif setAddDelPolyLineCursor: - self.app.setOverrideCursor(self.polyLineRoiCursor) - elif setCustomAnnotCursor: - x, y = event.pos() - self.highlightHoverID(x, y) - elif setKeepObjCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) - elif setManualTrackingCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) - elif setManualBackgroundCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) - elif setAddPointCursor: - self.app.setOverrideCursor(self.addPointsCursor) - elif setZoomRectCursor: - self.app.setOverrideCursor(Qt.CrossCursor) - elif setEditIDCursor and overrideCursor is None: - if shift: - self.app.setOverrideCursor(Qt.CrossCursor) - else: - self.app.restoreOverrideCursor() - - return { - 'setBrushCursor': setBrushCursor, - 'setEraserCursor': setEraserCursor, - 'setAddDelPolyLineCursor': setAddDelPolyLineCursor, - 'setLabelRoiCircCursor': setLabelRoiCircCursor, - 'setWandCursor': setWandCursor, - 'setLabelRoiCursor': setLabelRoiCursor, - 'setMoveLabelCursor': setMoveLabelCursor, - 'setExpandLabelCursor': setExpandLabelCursor, - 'setCurvCursor': setCurvCursor, - 'setKeepObjCursor': setKeepObjCursor, - 'setCustomAnnotCursor': setCustomAnnotCursor, - 'setManualTrackingCursor': setManualTrackingCursor, - 'setManualBackgroundCursor': setManualBackgroundCursor, - 'setAddPointCursor': setAddPointCursor, - 'setZoomRectCursor': setZoomRectCursor, - 'setEditIDCursor': setEditIDCursor - } - - def warnAddingPointWithExistingId(self, point_id, table_endname=''): - posData = self.data[self.pos_i] - if not point_id in posData.IDs_idxs: - return True - - msg = widgets.myMessageBox(wrapText=False) - txt = (f""" - Cell ID {point_id} already exists!

- Are you sure you want to add this point? - """) - if table_endname: - txt = (f""" - The loaded table {table_endname} has point id - {point_id}. -

However, {txt} - """) - txt = html_utils.paragraph(txt) - _, _, yesButton = msg.warning( - self, f'Cell ID {point_id} already exist', txt, - buttonsTexts=( - 'Cancel', 'No, do not add', f'Yes, add point id {point_id}' - ) - ) - return msg.clickedButton == yesButton - - def gui_hoverEventImg2(self, event): - try: - posData = self.data[self.pos_i] - except AttributeError: - return - - if not event.isExit(): - self.xHoverImg, self.yHoverImg = event.pos() - else: - self.xHoverImg, self.yHoverImg = None, None - - # Cursor left image --> restore cursor - if event.isExit() and self.app.overrideCursor() is not None: - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - # Alt key was released --> restore cursor - modifiers = QGuiApplication.keyboardModifiers() - noModifier = modifiers == Qt.NoModifier - shift = modifiers == Qt.ShiftModifier - ctrl = modifiers == Qt.ControlModifier - if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: - self.app.restoreOverrideCursor() - - setBrushCursor = ( - self.brushButton.isChecked() and not event.isExit() - and (noModifier or shift or ctrl) - ) - setEraserCursor = ( - self.eraserButton.isChecked() and not event.isExit() - and noModifier - ) - setLabelRoiCircCursor = ( - self.labelRoiButton.isChecked() and not event.isExit() - and (noModifier or shift or ctrl) - and self.labelRoiIsCircularRadioButton.isChecked() - ) - if setBrushCursor or setEraserCursor or setLabelRoiCircCursor: - self.app.setOverrideCursor(Qt.CrossCursor) - - setMoveLabelCursor = ( - self.moveLabelToolButton.isChecked() and not event.isExit() - and noModifier - ) - - setExpandLabelCursor = ( - self.expandLabelToolButton.isChecked() and not event.isExit() - and noModifier - ) - - # Cursor is moving on image while Alt key is pressed --> pan cursor - alt = QGuiApplication.keyboardModifiers() == Qt.AltModifier - setPanImageCursor = alt and not event.isExit() - if setPanImageCursor and self.app.overrideCursor() is None: - self.app.setOverrideCursor(Qt.SizeAllCursor) - - setKeepObjCursor = ( - self.keepIDsButton.isChecked() and not event.isExit() - and noModifier - ) - if setKeepObjCursor and self.app.overrideCursor() is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) - - # Update x, y, value label bottom right - if not event.isExit(): - x, y = event.pos() - xdata, ydata = int(x), int(y) - _img = self.currentLab2D - Y, X = _img.shape - # hoverText = self.hoverValuesFormatted(xdata, ydata) - # self.wcLabel.setText(hoverText) - else: - if self.eraserButton.isChecked() or self.brushButton.isChecked(): - self.gui_mouseReleaseEventImg2(event) - self.wcLabel.setText(f'') - - if setMoveLabelCursor or setExpandLabelCursor: - x, y = event.pos() - self.updateHoverLabelCursor(x, y) - - if setKeepObjCursor: - x, y = event.pos() - self.highlightHoverIDsKeptObj(x, y) - - # Draw eraser circle - if setEraserCursor: - x, y = event.pos() - self.updateEraserCursor(x, y, isHoverImg1=False) - else: - self.setHoverToolSymbolData( - [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX) - ) - - # Draw Brush circle - if setBrushCursor: - x, y = event.pos() - self.updateBrushCursor(x, y, isHoverImg1=False) - else: - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - - # Draw label ROi circular cursor - if setLabelRoiCircCursor: - x, y = event.pos() - else: - x, y = None, None - self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) - - def gui_imgGradShowContextMenu(self, x, y): - if hasattr(self, 'scaleBar'): - if self.scaleBar.isHighlighted(): - self.scaleBar.showContextMenu(x, y) - return - - if hasattr(self, 'timestamp'): - if self.timestamp.isHighlighted(): - self.timestamp.showContextMenu(x, y) - return - - self.imgGrad.gradient.menu.popup(QPoint(int(x), int(y))) - - def gui_rightImageShowContextMenu(self, event): - try: - # Convert QPointF to QPoint - self.imgGradRight.gradient.menu.popup(event.screenPos().toPoint()) - except AttributeError: - self.imgGradRight.gradient.menu.popup(event.screenPos()) - - @exception_handler - def gui_mouseDragEventImg2(self, event): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - return - - Y, X = self.get_2Dlab(posData.lab).shape - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): - return - - # Eraser dragging mouse --> keep erasing - if self.isMouseDragImg2 and self.eraserButton.isChecked(): - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - brushSize = self.brushSizeSpinbox.value() - rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - - # Build eraser mask - mask = np.zeros(lab_2D.shape, bool) - mask[ymin:ymax, xmin:xmax][diskMask] = True - mask[rrPoly, ccPoly] = True - - if self.eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False - self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, - (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], - ID=self.erasedID - ) - - self.erasedIDs.update(lab_2D[mask]) - - self.applyEraserMask(mask) - self.setImageImg2(updateLookuptable=False) - - # Brush paint dragging mouse --> keep painting - if self.isMouseDragImg2 and self.brushButton.isChecked(): - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) - - # Build brush mask - mask = np.zeros(lab_2D.shape, bool) - mask[ymin:ymax, xmin:xmax][diskMask] = True - mask[rrPoly, ccPoly] = True - - # If user double-pressed 'b' then draw over the labels - color = self.brushButton.palette().button().color().name() - if color != self.doublePressKeyButtonColor: - mask[lab_2D!=0] = False - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.eraserButton, brush=self.ax2_BrushCircleBrush - ) - - # Apply brush mask - self.applyBrushMask(mask, self.ax2BrushID) - - self.setImageImg2() - - # Move label dragging mouse --> keep moving - elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - self.moveLabel(x, y) - - @exception_handler - def gui_mouseReleaseEventImg2(self, event): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - return - - Y, X = self.get_2Dlab(posData.lab).shape - try: - x, y = event.pos().x(), event.pos().y() - except Exception as e: - return - - xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): - self.isMouseDragImg2 = False - self.updateAllImages() - return - - # Move label mouse released, update move - if self.isMovingLabel and self.moveLabelToolButton.isChecked(): - self.isMovingLabel = False - - # Update data (rp, etc) - self.update_rp() - - # Repeat tracking - self.tracking(enforce=True, assign_unique_new_IDs=False) - - self.updateAllImages() - - if not self.moveLabelToolButton.findChild(QAction).isChecked(): - self.moveLabelToolButton.setChecked(False) - - # Merge IDs - elif self.mergeIDsButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - lab2D = self.get_2Dlab(posData.lab) - ID = lab2D[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - lab2D, y, x - ) - mergeID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to merge with ID ' - f'{self.firstID}', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - mergeID_prompt.exec_() - if mergeID_prompt.cancel: - return - else: - ID = mergeID_prompt.EntryID - obj_idx = posData.IDs_idxs[ID] - obj = posData.rp[obj_idx] - y2, x2 = self.getObjCentroid(obj.centroid) - self.mergeObjsTempLine.addPoint(x2, y2) - - xx, yy = self.mergeObjsTempLine.getData() - IDs_to_merge = lab2D[yy.astype(int), xx.astype(int)] - for ID in IDs_to_merge: - if ID == 0: - continue - posData.lab[posData.lab==ID] = self.firstID - - self.mergeObjsTempLine.setData([], []) - self.clickObjYc, self.clickObjXc = None, None - - # Update data (rp, etc) - self.update_rp() - - ask_back_prop = True - - if posData.frame_i == 0: - ask_back_prop = False - prev_IDs = [] - else: - prev_IDs = posData.allData_li[posData.frame_i-1]['IDs'] - - if all(ID not in prev_IDs for ID in IDs_to_merge): - ask_back_prop = False - - if not self.isFrameCcaAnnotated() and ask_back_prop: - proceed = self.askPropagateChangePast(f'Merge IDs {IDs_to_merge}') - if proceed: - self.propagateMergeObjsPast(IDs_to_merge) - self.whitelistPropagateIDs(only_future_frames=False, update_lab=True) # in the update_rp() call, this should also be done - - # Repeat tracking - self.tracking( - enforce=True, assign_unique_new_IDs=False, - separateByLabel=False - ) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Merge IDs') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Merge IDs') - - if not self.mergeIDsButton.findChild(QAction).isChecked(): - self.mergeIDsButton.setChecked(False) - self.store_data() - - @exception_handler - def gui_mouseReleaseEventImg1(self, event): - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - alt = modifiers == Qt.AltModifier - right_click = event.button() == Qt.MouseButton.RightButton and not alt - - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - return - - Y, X = self.get_2Dlab(posData.lab).shape - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): - self.isMouseDragImg2 = False - self.updateAllImages() - return - - if hasattr(self, 'scaleBar'): - if self.scaleBar.isHighlighted() and self.scaleBar.clicked: - self.scaleBar.clicked = False - return - - if hasattr(self, 'timestamp'): - if self.timestamp.isHighlighted() and self.timestamp.clicked: - self.timestamp.clicked = False - return - - sendRightClickImg2 = ( - (mode=='Segmentation and Tracking' or self.isSnapshot) - and right_click - ) - if sendRightClickImg2: - # Allow right-click actions on both images - self.gui_mouseReleaseEventImg2(event) - - # Right-click curvature tool mouse release - if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): - self.isRightClickDragImg1 = False - try: - self.curvToolSplineToObj(isRightClick=True) - self.update_rp() - if self.autoIDcheckbox.isChecked(): - self.trackManuallyAddedObject(posData.brushID, True) - if self.isSnapshot: - self.fixCcaDfAfterEdit('Add new ID with curvature tool') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Add new ID with curvature tool') - self.clearCurvItems() - self.curvTool_cb(True) - except ValueError: - self.clearCurvItems() - self.curvTool_cb(True) - pass - - # Eraser mouse release --> update IDs and contours - elif self.isMouseDragImg1 and self.eraserButton.isChecked(): - self.isMouseDragImg1 = False - - self.clearTempBrushImage() - - # Update data (rp, etc) - self.update_rp() - - doUpdateImages = self.checkWarnDeletedIDwithEraser() - - if doUpdateImages: - self.updateAllImages() - - # Brush button mouse release - elif self.isMouseDragImg1 and self.brushButton.isChecked(): - self.isMouseDragImg1 = False - - self.clearTempBrushImage() - - self.brushReleased() - - # Wand tool release, add new object - elif self.isMouseDragImg1 and self.wandToolButton.isChecked(): - self.isMouseDragImg1 = False - - self.clearTempBrushImage() - - posData = self.data[self.pos_i] - posData.lab[self.flood_mask] = posData.brushID - - # Update data (rp, etc) - self.update_rp() - - # Repeat tracking - self.trackManuallyAddedObject(posData.brushID, self.isNewID) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Add new ID with magic-wand') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Add new ID with magic-wand') - - # Label ROI mouse release --> label the ROI with labelRoiWorker - elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): - self.labelRoiRunning = True - self.app.setOverrideCursor(Qt.WaitCursor) - self.isMouseDragImg1 = False - - if self.labelRoiIsFreeHandRadioButton.isChecked(): - self.freeRoiItem.closeCurve() - - proceed = self.labelRoiCheckStartStopFrame() - if not proceed: - self.labelRoiCancelled() - return - - roiImg, self.labelRoiSlice = self.getLabelRoiImage() - - if roiImg.size == 0: - self.labelRoiCancelled() - return - - if self.labelRoiModel is None: - cancel = self.initLabelRoiModel() - if cancel: - self.labelRoiCancelled() - return - - # Restore state of button because it was maybe unchecked by - # using other tools that are allowed --> see "elif" case in - # labelRoi_cb - self.labelRoiButton.blockSignals(True) - self.labelRoiButton.setChecked(True) - self.labelRoiToolbar.setVisible(True) - self.labelRoiButton.blockSignals(False) - - roiSecondChannel = None - if self.secondChannelName is not None: - secondChannelData = self.getSecondChannelData() - roiSecondChannel = secondChannelData[self.labelRoiSlice] - - isTimelapse = self.labelRoiTrangeCheckbox.isChecked() - if isTimelapse: - start_n = self.labelRoiStartFrameNoSpinbox.value() - stop_n = self.labelRoiStopFrameNoSpinbox.value() - self.progressWin = apps.QDialogWorkerProgress( - title='ROI segmentation', parent=self, - pbarDesc=f'Segmenting frames n. {start_n} to {stop_n}...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stop_n-start_n) - - - self.app.restoreOverrideCursor() - labelRoiWorker = self.labelRoiActiveWorkers[-1] - labelRoiWorker.start( - roiImg, posData, - roiSecondChannel=roiSecondChannel, - isTimelapse=isTimelapse - ) - self.app.setOverrideCursor(Qt.WaitCursor) - self.logger.info( - f'Magic labeller started on image ROI = {self.labelRoiSlice}...' - ) - self.titleLabel.setText('Magic labeller is doing its magic...') - self.setDisabled(True) - - # Move label mouse released, update move - elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): - self.isMovingLabel = False - - # Update data (rp, etc) - self.update_rp() - - # Repeat tracking - self.tracking(enforce=True, assign_unique_new_IDs=False) - - if not self.moveLabelToolButton.findChild(QAction).isChecked(): - self.moveLabelToolButton.setChecked(False) - else: - self.updateAllImages() - - # Assign mother to bud - elif self.assignBudMothButton.isChecked() and self.clickedOnBud: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == self.get_2Dlab(posData.lab)[self.yClickBud, self.xClickBud]: - return - - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - mothID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as mother cell', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - mothID_prompt.exec_() - if mothID_prompt.cancel: - return - else: - ID = mothID_prompt.EntryID - obj_idx = posData.IDs.index(ID) - y, x = posData.rp[obj_idx].centroid - xdata, ydata = int(x), int(y) - - if self.isSnapshot: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - relationship = posData.cca_df.at[ID, 'relationship'] - ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] - is_history_known = posData.cca_df.at[ID, 'is_history_known'] - # We allow assiging a cell in G1 as mother only on first frame - # OR if the history is unknown - if relationship == 'bud' and posData.frame_i > 0 and is_history_known: - self.assignBudMothButton.setChecked(False) - txt = html_utils.paragraph( - f'You clicked on ID {ID} which is a BUD.

' - 'To assign a bud start by clicking on the bud ' - 'and release on a cell in G1' - ) - msg = widgets.myMessageBox() - msg.critical( - self, 'Released on a bud', txt - ) - self.assignBudMothButton.setChecked(True) - return - - elif posData.frame_i == 0: - # Check that clicked bud actually is smaller that mother - # otherwise warn the user that he might have clicked first - # on a mother - budID = self.get_2Dlab(posData.lab)[self.yClickBud, self.xClickBud] - new_mothID = self.get_2Dlab(posData.lab)[ydata, xdata] - bud_obj_idx = posData.IDs.index(budID) - new_moth_obj_idx = posData.IDs.index(new_mothID) - rp_budID = posData.rp[bud_obj_idx] - rp_new_mothID = posData.rp[new_moth_obj_idx] - if rp_budID.area >= rp_new_mothID.area: - self.assignBudMothButton.setChecked(False) - msg = widgets.myMessageBox() - txt = ( - f'You clicked FIRST on ID {budID} and then on {new_mothID}.
' - f'For me this means that you want ID {budID} to be the ' - f'BUD of ID {new_mothID}.
' - f'However ID {budID} is bigger than {new_mothID} ' - f'so maybe you should have clicked FIRST on {new_mothID}?

' - 'What do you want me to do?' - ) - txt = html_utils.paragraph(txt) - swapButton, keepButton = msg.warning( - self, 'Which one is bud?', txt, - buttonsTexts=( - f'Assign ID {new_mothID} as the bud of ID {budID}', - f'Keep ID {budID} as the bud of ID {new_mothID}' - ) - ) - if msg.clickedButton == swapButton: - (xdata, ydata, - self.xClickBud, self.yClickBud) = ( - self.xClickBud, self.yClickBud, - xdata, ydata - ) - self.assignBudMothButton.setChecked(True) - - elif is_history_known and not self.clickedOnHistoryKnown: - self.assignBudMothButton.setChecked(False) - budID = self.get_2Dlab(posData.lab)[ydata, xdata] - # Allow assigning an unknown cell ONLY to another unknown cell - txt = ( - f'You started by clicking on ID {budID} which has ' - 'UNKNOWN history, but you then clicked/released on ' - f'ID {ID} which has KNOWN history.\n\n' - 'Only two cells with UNKNOWN history can be assigned as ' - 'relative of each other.' - ) - msg = QMessageBox() - msg.critical( - self, 'Released on a cell with KNOWN history', txt, msg.Ok - ) - self.assignBudMothButton.setChecked(True) - return - - self.clickedOnHistoryKnown = is_history_known - self.xClickMoth, self.yClickMoth = xdata, ydata - - if ccs != 'G1' and posData.frame_i > 0: - self.assignBudMothButton.setChecked(False) - self.onMotherNotInG1(ID) - self.assignBudMothButton.setChecked(True) - else: - self.annotateBudToDifferentMother() - - if not self.assignBudMothButton.findChild(QAction).isChecked(): - self.assignBudMothButton.setChecked(False) - - self.clickedOnBud = False - self.BudMothTempLine.setData([], []) - - # Draw clear region mouse release - elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): - self.isMouseDragImg1 = False - self.freeRoiItem.closeCurve() - self.clearObjsFreehandRegion() - - # Zoom rect mouse release - elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): - self.isMouseDragImg1 = False - self.zoomRectDone() - - def gui_clickedDelRoi(self, event, left_click, right_click): - posData = self.data[self.pos_i] - x, y = event.pos().x(), event.pos().y() - - # Check if right click on ROI - delROIs = ( - posData.allData_li[posData.frame_i]['delROIs_info']['rois'].copy() - ) - for r, roi in enumerate(delROIs): - ROImask = self.getDelRoiMask(roi) - if self.isSegm3D: - clickedOnROI = ROImask[self.z_lab(), int(y), int(x)] - else: - clickedOnROI = ROImask[int(y), int(x)] - raiseContextMenuRoi = right_click and clickedOnROI - dragRoi = left_click and clickedOnROI - if raiseContextMenuRoi: - self.roi_to_del = roi - self.roiContextMenu = QMenu(self) - separator = QAction(self) - separator.setSeparator(True) - self.roiContextMenu.addAction(separator) - action = QAction('Remove ROI') - action.triggered.connect(self.removeDelROI) - self.roiContextMenu.addAction(action) - try: - # Convert QPointF to QPoint - self.roiContextMenu.exec_(event.screenPos().toPoint()) - except AttributeError: - self.roiContextMenu.exec_(event.screenPos()) - return True - elif dragRoi: - event.ignore() - return True - return False - - def gui_getHoveredSegmentsPolyLineRoi(self): - posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - segments = [] - for roi in delROIs_info['rois']: - if not isinstance(roi, pg.PolyLineROI): - continue - for seg in roi.segments: - if seg.currentPen == seg.hoverPen: - seg.roi = roi - segments.append(seg) - return segments - - def gui_getHoveredHandlesPolyLineRoi(self): - posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - handles = [] - for roi in delROIs_info['rois']: - if not isinstance(roi, pg.PolyLineROI): - continue - for handle in roi.getHandles(): - if handle.currentPen == handle.hoverPen: - handle.roi = roi - handles.append(handle) - return handles - - @exception_handler - def gui_mousePressRightImage(self, event): - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - alt = modifiers == Qt.AltModifier - isMod = alt - right_click = event.button() == Qt.MouseButton.RightButton and not isMod - is_right_click_action_ON = any([ - b.isChecked() for b in self.checkableQButtonsGroup.buttons() - ]) - self.typingEditID = False - showLabelsGradMenu = right_click and not is_right_click_action_ON - if showLabelsGradMenu: - self.gui_rightImageShowContextMenu(event) - event.ignore() - else: - self.gui_mousePressEventImg1(event) - - @exception_handler - def gui_mouseDragRightImage(self, event): - self.gui_mouseDragEventImg1(event) - - @exception_handler - def gui_mouseReleaseRightImage(self, event): - self.gui_mouseReleaseEventImg1(event) - - def drawTempRulerLine(self, event): - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - x, y = event.pos() - x1, y1 = int(x), int(y) - xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() - x0, y0 = xxRA[0], yyRA[0] - if ctrl: - x1, y1 = transformation.snap_xy_to_closest_angle( - x0, y0, x1, y1 - ) - self.ax1_rulerPlotItem.setData([x0, x1], [y0, y1]) - - @exception_handler - def gui_mousePressEventImg1(self, event: QMouseEvent): - self.typingEditID = False - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - alt = modifiers == Qt.AltModifier - isMod = alt - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - isCcaMode = mode == 'Cell cycle analysis' - isCustomAnnotMode = mode == 'Custom annotations' - left_click = event.button() == Qt.MouseButton.LeftButton and not isMod - middle_click = self.isMiddleClick(event, modifiers) - right_click = event.button() == Qt.MouseButton.RightButton - isPanImageClick = self.isPanImageClick(event, modifiers) - brushON = self.brushButton.isChecked() - curvToolON = self.curvToolButton.isChecked() - histON = self.setIsHistoryKnownButton.isChecked() - eraserON = self.eraserButton.isChecked() - rulerON = self.rulerButton.isChecked() - wandON = self.wandToolButton.isChecked() and not isPanImageClick - polyLineRoiON = self.addDelPolyLineRoiButton.isChecked() - labelRoiON = self.labelRoiButton.isChecked() - keepObjON = self.keepIDsButton.isChecked() - whitelistIDsON = self.whitelistIDsButton.isChecked() - separateON = self.separateBudButton.isChecked() - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - manualBackgroundON = self.manualBackgroundButton.isChecked() - magicPromptsON = self.magicPromptsToolButton.isChecked() - pointsLayerON = self.togglePointsLayerAction.isChecked() - copyContourON = ( - self.copyLostObjButton.isChecked() - and self.ax1_lostObjScatterItem.hoverLostID>0 - ) - findNextMotherButtonON = self.findNextMotherButton.isChecked() - unknownLineageButtonON = self.unknownLineageButton.isChecked() - drawClearRegionON = self.drawClearRegionButton.isChecked() - zoomRectON = self.zoomRectButton.isChecked() - - # Check if right-click on segment of polyline roi to add segment - segments = self.gui_getHoveredSegmentsPolyLineRoi() - if len(segments) == 1 and right_click: - seg = segments[0] - seg.roi.segmentClicked(seg, event) - return - - # Check if right-click on handle of polyline roi to remove it - handles = self.gui_getHoveredHandlesPolyLineRoi() - if len(handles) == 1 and right_click: - handle = handles[0] - handle.roi.removeHandle(handle) - return - - # Check if click on ROI - isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click) - if isClickOnDelRoi: - return - - dragImgLeft = ( - left_click and not brushON and not histON - and not curvToolON and not eraserON and not rulerON - and not wandON and not polyLineRoiON and not labelRoiON - and not middle_click and not keepObjON and not separateON - and not manualBackgroundON and not drawClearRegionON - and addPointsByClickingButton is None and not whitelistIDsON - and not zoomRectON - ) - if isPanImageClick: - dragImgLeft = True - - is_right_click_custom_ON = any([ - b.isChecked() for b in self.customAnnotDict.keys() - ]) - - canAnnotateDivision = ( - not self.assignBudMothButton.isChecked() - and not self.setIsHistoryKnownButton.isChecked() - and not self.curvToolButton.isChecked() - and not is_right_click_custom_ON - and not labelRoiON - and not separateON - ) - - # In timelapse mode division can be annotated if isCcaMode and right-click - # while in snapshot mode with Ctrl+right-click - isAnnotateDivision = ( - (right_click and isCcaMode and canAnnotateDivision) - or (right_click and ctrl and self.isSnapshot) - ) - - isCustomAnnot = ( - (right_click or dragImgLeft) - and (isCustomAnnotMode or self.isSnapshot) - and self.customAnnotButton is not None - ) - - is_right_click_action_ON = any([ - b.isChecked() for b in self.checkableQButtonsGroup.buttons() - ]) - - isOnlyRightClick = ( - right_click and canAnnotateDivision and not isAnnotateDivision - and not isMod and not is_right_click_action_ON - and not is_right_click_custom_ON and not copyContourON - and not findNextMotherButtonON and not unknownLineageButtonON - and not middle_click - ) - - if isOnlyRightClick: - # Start timer or check if it is a double-right-click - if self.countRightClicks == 0: - self.isDoubleRightClick = False - self.countRightClicks = 1 - self.doubleRightClickTimeElapsed = False - screenPos = event.screenPos() - self._img1_click_xy = (screenPos.x(), screenPos.y()) - QTimer.singleShot(400, self.doubleRightClickTimerCallBack) - return - elif ( - self.countRightClicks == 1 - and not self.doubleRightClickTimeElapsed - ): - self.isDoubleRightClick = True - self.countRightClicks = 0 - self.editIDbutton.setChecked(True) - - # Left click actions - canCurv = ( - curvToolON and not self.assignBudMothButton.isChecked() - and not brushON and not dragImgLeft and not eraserON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON - ) - canBrush = ( - brushON and not curvToolON and not rulerON - and not dragImgLeft and not eraserON and not wandON - and not labelRoiON and not manualBackgroundON - and addPointsByClickingButton is None and not drawClearRegionON - and not magicPromptsON and not zoomRectON - ) - canErase = ( - eraserON and not curvToolON and not rulerON - and not dragImgLeft and not brushON and not wandON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON - ) - canRuler = ( - rulerON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not wandON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON - ) - canWand = ( - wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON - ) - canPolyLine = ( - polyLineRoiON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not labelRoiON and not manualBackgroundON - and addPointsByClickingButton is None - and not drawClearRegionON and not magicPromptsON - and not zoomRectON - ) - canLabelRoi = ( - labelRoiON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not keepObjON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not whitelistIDsON and not magicPromptsON - and not zoomRectON - ) - canKeep = ( - keepObjON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not whitelistIDsON and not magicPromptsON - and not zoomRectON - ) - canWhitelistIDs = ( - whitelistIDsON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not keepObjON and not magicPromptsON - and not zoomRectON - ) - canAddPoint = ( - (pointsLayerON or magicPromptsON) - and addPointsByClickingButton is not None and not wandON - and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON and not keepObjON - and not manualBackgroundON and not drawClearRegionON - and not zoomRectON - ) - canAddManualBackgroundObj = ( - manualBackgroundON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not keepObjON and not drawClearRegionON - and not magicPromptsON and not whitelistIDsON - and not zoomRectON - ) - canDrawClearRegion = ( - drawClearRegionON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not labelRoiON and not manualBackgroundON - and addPointsByClickingButton is None - and not polyLineRoiON and not magicPromptsON - and not whitelistIDsON and not zoomRectON - ) - canZoomRect = ( - zoomRectON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not wandON and not whitelistIDsON and not magicPromptsON - ) - - # Enable dragging of the image window or the scalebar - if dragImgLeft and not isCustomAnnot: - x, y = event.pos().x(), event.pos().y() - if hasattr(self, 'scaleBar'): - if self.scaleBar.isHighlighted(): - self.scaleBar.mousePressed(x, y) - return - if hasattr(self, 'timestamp'): - if self.timestamp.isHighlighted(): - self.timestamp.mousePressed(x, y) - return - pg.ImageItem.mousePressEvent(self.img1, event) - event.ignore() - return - - isAllowedActionViewer = (canAddPoint or canRuler) - - if mode == 'Viewer' and not isAllowedActionViewer: - self.startBlinkingModeCB() - event.ignore() - return - - # Allow right-click or middle-click actions on both images - eventOnImg2 = ( - ( - right_click or (middle_click and not canAddPoint) - # or (left_click and separateON) - ) - and (mode=='Segmentation and Tracking' or self.isSnapshot) - and not isAnnotateDivision and not manualBackgroundON - ) - if eventOnImg2: - event.isImg1Sender = True - self.gui_mousePressEventImg2(event) - - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - Y, X = self.get_2Dlab(posData.lab).shape - if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - else: - return - - # Paint new IDs with brush and left click on the left image - if left_click and canBrush: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False, storeOnlyZoom=True) - - ID = self.getHoverID(xdata, ydata) - - if ID > 0: - posData.brushID = ID - self.isNewID = False - else: - # Update brush ID. Take care of disappearing cells to remember - # to not use their IDs anymore in the future - self.isNewID = True - self.setBrushID() - self.updateLookuptable(lenNewLut=posData.brushID+1) - - self.brushColor = self.lut[posData.brushID]/255 - - self.yPressAx2, self.xPressAx2 = y, x - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) - - self.isMouseDragImg1 = True - - # Draw new objects - localLab = lab_2D[diskSlice] - mask = diskMask.copy() - if not self.isPowerBrush() and not ctrl: - mask[localLab!=0] = False - - self.applyBrushMask(mask, posData.brushID, toLocalSlice=diskSlice) - - self.setImageImg2(updateLookuptable=False) - - how = self.drawIDsContComboBox.currentText() - lab2D = self.get_2Dlab(posData.lab) - self.globalBrushMask = np.zeros(lab2D.shape, dtype=bool) - brushMask = localLab == posData.brushID - brushMask = np.logical_and(brushMask, diskMask) - self.setTempImg1Brush( - True, brushMask, posData.brushID, toLocalSlice=diskSlice - ) - - self.lastHoverID = -1 - - elif left_click and canErase: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False, storeOnlyZoom=True) - - self.yPressAx2, self.xPressAx2 = y, x - # Keep a list of erased IDs got erased - self.erasedIDs = set() - - if self.xyOnCtrlPressedFirstTime is not None: - self.erasedID = self.getHoverID(*self.xyOnCtrlPressedFirstTime) - else: - self.erasedID = self.getHoverID(xdata, ydata) - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - - # Build eraser mask - mask = np.zeros(lab_2D.shape, bool) - mask[ymin:ymax, xmin:xmax][diskMask] = True - - - # If user double-pressed 'b' then erase over ALL labels - color = self.eraserButton.palette().button().color().name() - eraseOnlyOneID = ( - color != self.doublePressKeyButtonColor - and self.erasedID != 0 - ) - - self.eraseOnlyOneID = eraseOnlyOneID - - if eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False - - self.setTempImg1Eraser(mask, init=True) - self.applyEraserMask(mask) - - self.erasedIDs.update(lab_2D[mask]) - - for erasedID in self.erasedIDs: - if erasedID == 0: - continue - self.erasedLab[lab_2D==erasedID] = erasedID - - self.isMouseDragImg1 = True - - elif canAddPoint: - action = addPointsByClickingButton.action - self.storeUndoAddPoint(action) - x, y = event.pos().x(), event.pos().y() - hoveredPoints = action.scatterItem.pointsAt(event.pos()) - if len(hoveredPoints) > 0: - removed_ids = self.removeClickedPoints(action, hoveredPoints) - if not magicPromptsON: - removed_id = min(removed_ids) - addPointsByClickingButton.pointIdSpinbox.setValue(removed_id) - addPointsByClickingButton.pointIdSpinbox.removedId = ( - removed_id - ) - else: - self.restorePrevPointIdRightClick(addPointsByClickingButton) - self.drawPointsLayers(computePointsLayers=False) - else: - point_id = self.getAddedPointId( - magicPromptsON, addPointsByClickingButton, - right_click, left_click, middle_click - ) - if point_id is None: - return - - self.addClickedPoint(action, x, y, point_id) - self.drawPointsLayers(computePointsLayers=False) - - point_id = self.getClickedPointNewId( - action, point_id, - addPointsByClickingButton.pointIdSpinbox, - isMagicPrompts=magicPromptsON - ) - addPointsByClickingButton.pointIdSpinbox.setValue( - point_id, setLinkedWidget=False - ) - - elif left_click and canDrawClearRegion: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - self.freeRoiItem.addPoint(xdata, ydata) - - self.isMouseDragImg1 = True - - elif left_click and canRuler or canPolyLine: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - closePolyLine = ( - len(self.startPointPolyLineItem.pointsAt(event.pos())) > 0 - ) - if not self.tempSegmentON or canPolyLine: - # Keep adding anchor points for polyline - self.ax1_rulerAnchorsItem.setData([xdata], [ydata]) - self.tempSegmentON = True - else: - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - self.tempSegmentON = False - xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() - x0, y0 = xxRA[0], yyRA[0] - if ctrl: - x1, y1 = transformation.snap_xy_to_closest_angle( - x0, y0, xdata, ydata - ) - else: - x1, y1 = xdata, ydata - lengthText = self.getRulerLengthText() - self.ax1_rulerPlotItem.setData( - [x0, x1], [y0, y1], lengthText=lengthText - ) - self.ax1_rulerAnchorsItem.setData([x0, x1], [y0, y1]) - - xxPolyLine = self.startPointPolyLineItem.getData()[0] - if canPolyLine and len(xxPolyLine) == 0: - # Create and add roi item - self.createDelPolyLineRoi() - # Add start point of polyline roi - self.startPointPolyLineItem.setData([xdata], [ydata]) - self.polyLineRoi.points.append((xdata, ydata)) - elif canPolyLine: - # Add points to polyline roi and eventually close it - if not closePolyLine: - self.polyLineRoi.points.append((xdata, ydata)) - self.addPointsPolyLineRoi(closed=closePolyLine) - if closePolyLine: - # Close polyline ROI - if len(self.polyLineRoi.getLocalHandlePositions()) == 2: - self.polyLineRoi = self.replacePolyLineRoiWithLineRoi( - self.polyLineRoi - ) - self.tempSegmentON = False - self.ax1_rulerAnchorsItem.setData([], []) - self.ax1_rulerPlotItem.setData([], []) - self.startPointPolyLineItem.setData([], []) - self.addRoiToDelRoiInfo(self.polyLineRoi) - # Call roi moving on closing ROI - self.delROImoving(self.polyLineRoi) - self.delROImovingFinished(self.polyLineRoi) - - elif left_click and canKeep: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - keepID_win = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to keep', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - keepID_win.exec_() - if keepID_win.cancel: - return - else: - ID = keepID_win.EntryID - - if ID in self.keptObjectsIDs: - self.keptObjectsIDs.remove(ID) - self.clearHighlightedText() - else: - self.keptObjectsIDs.append(ID) - self.highlightLabelID(ID) - - self.updateTempLayerKeepIDs() - - elif left_click and canWhitelistIDs: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - keepID_win = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to select', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - keepID_win.exec_() - if keepID_win.cancel: - return - else: - ID = keepID_win.EntryID - - posData = self.data[self.pos_i] - - if not posData.whitelist: - wl_init = False - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() # not updated, only use in this context - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = self.tempWhitelistIDs - else: - wl_init = True - current_whitelist = posData.whitelist.get(posData.frame_i) - - if ID in current_whitelist: - current_whitelist.remove(ID) - self.removeHighlightLabelID(IDs=[ID]) - else: - current_whitelist.add(ID) - self.highlightLabelID(ID) - - self.whitelistIDsToolbar.whitelistLineEdit.setText( - current_whitelist - ) - - if wl_init: - posData.whitelist[posData.frame_i] = current_whitelist - else: - self.tempWhitelistIDs = current_whitelist - - self.whitelistUpdateTempLayer() - - elif right_click and copyContourON: - hoverLostID = self.ax1_lostObjScatterItem.hoverLostID - self.copyLostObjectMask(hoverLostID) - self.update_rp() - self.updateAllImages() - self.store_data() - - elif right_click and canCurv: - # Draw manually assisted auto contour - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - Y, X = self.get_2Dlab(posData.lab).shape - - self.autoCont_x0 = xdata - self.autoCont_y0 = ydata - self.xxA_autoCont, self.yyA_autoCont = [], [] - self.curvAnchors.addPoints([x], [y]) - img = self.getDisplayedImg1() - self.autoContObjMask = np.zeros(img.shape, np.uint8) - self.isRightClickDragImg1 = True - - elif left_click and canCurv: - # Draw manual spline - x, y = event.pos().x(), event.pos().y() - Y, X = self.get_2Dlab(posData.lab).shape - - # Check if user clicked on starting anchor again --> close spline - closeSpline = False - clickedAnchors = self.curvAnchors.pointsAt(event.pos()) - xxA, yyA = self.curvAnchors.getData() - if len(xxA)>0: - if len(xxA) == 1: - self.splineHoverON = True - x0, y0 = xxA[0], yyA[0] - if len(clickedAnchors)>0: - xA_clicked, yA_clicked = clickedAnchors[0].pos() - if x0==xA_clicked and y0==yA_clicked: - x = x0 - y = y0 - closeSpline = True - - # Add anchors - self.curvAnchors.addPoints([x], [y]) - try: - xx, yy = self.curvHoverPlotItem.getData() - self.curvPlotItem.setData(xx, yy) - except Exception as e: - # traceback.print_exc() - pass - - if closeSpline: - self.splineHoverON = False - self.curvToolSplineToObj() - self.update_rp() - if self.autoIDcheckbox.isChecked(): - self.trackManuallyAddedObject(posData.brushID, True) - if self.isSnapshot: - self.fixCcaDfAfterEdit('Add new ID with curvature tool') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Add new ID with curvature tool') - self.clearCurvItems() - self.curvTool_cb(True) - - elif left_click and canWand: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - Y, X = self.get_2Dlab(posData.lab).shape - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - self.isNewID = False - posData.brushID = self.get_2Dlab(posData.lab)[ydata, xdata] - if posData.brushID == 0: - self.setBrushID() - self.updateLookuptable( - lenNewLut=posData.brushID+1 - ) - self.isNewID = True - self.brushColor = self.img2.lut[posData.brushID]/255 - - # NOTE: flood is on mousedrag or release - tol = self.getMagicWandFloodTolerance() - self.initFloodMaskImage() - if self.isSegm3D: - z_slice = self.zSliceScrollBar.sliderPosition() - seed = (z_slice, ydata, xdata) - else: - seed = (ydata, xdata) - - flood_mask = skimage.segmentation.flood( - self.flood_img, seed, tolerance=tol - ) - - drawUnderMask = np.logical_or( - posData.lab==0, posData.lab==posData.brushID - ) - self.flood_mask = np.logical_and(flood_mask, drawUnderMask) - - if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): - self.flood_mask = core.binary_fill_holes(self.flood_mask) - - if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): - self.flood_mask = core.convex_hull_mask(self.flood_mask) - - self.setTempBrushMaskFromWand(self.flood_mask, init=True) - self.isMouseDragImg1 = True - - elif right_click and self.manualTrackingButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - manualTrackID = self.manualTrackingToolbar.spinboxID.value() - clickedID = self.getClickedID( - xdata, ydata, text=f'that you want to assign to {manualTrackID}' - ) - if clickedID is None: - return - - if clickedID == manualTrackID: - self.manualTrackingToolbar.showWarning( - f'The clicked object already has ID = {manualTrackID}' - ) - return - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - posData = self.data[self.pos_i] - currentIDs = posData.IDs.copy() - if manualTrackID in currentIDs: - tempID = max(currentIDs) + 1 - posData.lab[posData.lab == clickedID] = tempID - posData.lab[posData.lab == manualTrackID] = clickedID - posData.lab[posData.lab == tempID] = manualTrackID - self.manualTrackingToolbar.showWarning( - f'The ID {manualTrackID} already exists --> ' - f'ID {manualTrackID} has been swapped with {clickedID}' - ) - else: - posData.lab[posData.lab == clickedID] = manualTrackID - self.manualTrackingToolbar.showInfo( - f'ID {clickedID} changed to {manualTrackID}.' - ) - - self.update_rp() - self.updateAllImages() - - elif right_click and manualBackgroundON: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - - delID = posData.manualBackgroundLab[ydata, xdata] - if delID == 0: - return - - self.clearManualBackgroundObject(delID) - textItem = self.manualBackgroundTextItems.pop(delID) - self.ax1.removeItem(textItem) - self.setManualBackgroundImage() - - elif left_click and canAddManualBackgroundObj: - x, y = event.pos().x(), event.pos().y() - - self.addManualBackgroundObject(x, y) - self.setManualBackgroundImage() - self.setManualBackgrounNextID() - - # Label ROI mouse press - elif (left_click or right_click) and canLabelRoi: - if right_click: - # Force model initialization on mouse release - self.labelRoiModel = None - - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - - if self.labelRoiIsRectRadioButton.isChecked(): - self.labelRoiItem.setPos((xdata, ydata)) - elif self.labelRoiIsFreeHandRadioButton.isChecked(): - self.freeRoiItem.addPoint(xdata, ydata) - - self.isMouseDragImg1 = True - - # Annotate cell cycle division - elif isAnnotateDivision: - if posData.cca_df is None: - return - - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - divID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as divided', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - divID_prompt.exec_() - if divID_prompt.cancel: - return - else: - ID = divID_prompt.EntryID - obj_idx = posData.IDs.index(ID) - y, x = posData.rp[obj_idx].centroid - xdata, ydata = int(x), int(y) - - if not self.isSnapshot: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - # Annotate or undo division - self.manualCellCycleAnnotation(ID) - else: - self.undoBudMothAssignment(ID) - - # Assign bud to mother (mouse down on bud) - elif right_click and self.assignBudMothButton.isChecked(): - if self.clickedOnBud: - # NOTE: self.clickedOnBud is set to False when assigning a mother - # is successfull in mouse release event - # We still have to click on a mother - return - - if posData.cca_df is None: - return - - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - budID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID of a bud you want to correct mother assignment', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - budID_prompt.exec_() - if budID_prompt.cancel: - return - else: - ID = budID_prompt.EntryID - - obj_idx = posData.IDs.index(ID) - y, x = posData.rp[obj_idx].centroid - xdata, ydata = int(x), int(y) - - relationship = posData.cca_df.at[ID, 'relationship'] - is_history_known = posData.cca_df.at[ID, 'is_history_known'] - self.clickedOnHistoryKnown = is_history_known - # We allow assiging a cell in G1 as bud only on first frame - # OR if the history is unknown - if relationship != 'bud' and posData.frame_i > 0 and is_history_known: - txt = (f'You clicked on ID {ID} which is NOT a bud.\n' - 'To assign a bud to a cell start by clicking on a bud ' - 'and release on a cell in G1') - msg = QMessageBox() - msg.critical( - self, 'Not a bud', txt, msg.Ok - ) - return - - self.clickedOnBud = True - self.xClickBud, self.yClickBud = xdata, ydata - - # Annotate (or undo) that cell has unknown history - elif right_click and self.setIsHistoryKnownButton.isChecked(): - if posData.cca_df is None: - return - - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - unknownID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as ' - '"history UNKNOWN/KNOWN"', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - unknownID_prompt.exec_() - if unknownID_prompt.cancel: - return - else: - ID = unknownID_prompt.EntryID - obj_idx = posData.IDs.index(ID) - y, x = posData.rp[obj_idx].centroid - xdata, ydata = int(x), int(y) - - self.annotateIsHistoryKnown(ID) - if not self.setIsHistoryKnownButton.findChild(QAction).isChecked(): - self.setIsHistoryKnownButton.setChecked(False) - - elif isCustomAnnot: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - clickedBkgrDialog = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as divided', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - clickedBkgrDialog.exec_() - if clickedBkgrDialog.cancel: - return - else: - ID = clickedBkgrDialog.EntryID - obj_idx = posData.IDs.index(ID) - y, x = posData.rp[obj_idx].centroid - xdata, ydata = int(x), int(y) - - button = self.doCustomAnnotation(ID) - if button is None: - return - - keepActive = self.customAnnotDict[button]['state']['keepActive'] - if not keepActive: - button.setChecked(False) - - elif right_click and findNextMotherButtonON: - if posData.frame_i == 0: - return - - self.find_mother_action(posData, event, ydata, xdata) - - elif right_click and unknownLineageButtonON: - if posData.frame_i == 0: - return - - self.annotate_unknown_lineage_action(posData, event, ydata, xdata) - - elif (left_click or right_click) and canZoomRect: - if left_click: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - - self.zoomRectItem.setPos((xdata, ydata)) - - self.isMouseDragImg1 = True - else: - try: - xRange, yRange = self.zoomRectItem.getLastRange() - self.ax1.setRange( - xRange=xRange, - yRange=yRange, - padding=0 - ) - except Exception as err: - QTimer.singleShot(100, self.autoRange) - - def repeat_click_and_backup(self, posData, event, ydata, xdata): - """ - This function is part of the lin_tree edit functionality. - It handles the back up of the original self.lineage_tree.lineage_list - df and the repeated clicking on the same ID to cycle through pssible mothers. - - Parameters - ---------- - posData : cellacdc.load.loadData - The position data. - event : QtGui.QMouseEvent - The event object. - ydata : int - The y-coordinate data. - xdata : int - The x-coordinate data. - - Returns - ------- - tuple - A tuple containing the point(tuple: (x, y) coords) and ID of clicked cell. - """ - if self.original_df_lin_tree is None: - self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() - self.original_df_lin_tree_i = posData.frame_i - elif self.original_df_lin_tree_i != posData.frame_i: - self.logger.info( - '[WARNING]: !!! Original lineage tree df changed, resetting original_df_lin_tree !!!' - ) - self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() - self.original_df_lin_tree_i = posData.frame_i - - if not self.right_click_ID: - self.right_click_i = 0 - self.right_click_ID = 0 - - x, y = event.pos().x(), event.pos().y() - point = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - - if ID == 0: - return None, None - - if self.right_click_ID != ID: - self.right_click_i = 0 - self.right_click_ID = ID - self.original_mother_skipped = False - elif event.modifiers() & Qt.ShiftModifier: - self.right_click_i -= 1 - else: - self.right_click_i += 1 - - return point, ID - - def getDistanceListMissingIDs(self, point, ID): - posData = self.data[self.pos_i] - frame_i = posData.frame_i - if self.getDistanceListMissingIDsCachedFrame != frame_i: - self.distanceListMissingIDs = dict() - self.getDistanceListMissingIDsCachedFrame = frame_i - # self.store_data(autosave=False) - # self.get_data() - - if ID not in self.distanceListMissingIDs.keys(): - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - relevant_rp = [ - obj for obj in prev_rp if obj.label not in posData.IDs - ] - len_relevant_rp = len(relevant_rp) - if len_relevant_rp == 0: - self.logger.info('No missing IDs found in previous frame.') - return [] - elif len_relevant_rp == 1: - self.distanceListMissingIDs[ID] = [relevant_rp[0].label] - return [relevant_rp[0].label] - else: - sorted_missing_IDs = myutils.sort_IDs_dist(relevant_rp, point=point) - self.distanceListMissingIDs[ID] = sorted_missing_IDs - return sorted_missing_IDs - else: - return self.distanceListMissingIDs[ID] - - def find_mother_action(self, posData, event, ydata, xdata): - """ - This function is part of the lin_tree edit functionality. - Associated with the right-click action of the 'findNextMotherButton' button. - Handles the right click action, which cycles through possible mothers of the clicked cell. - Changes the parent ID of the clicked cell to the next possible mother in self.lineage_tree.lineage_list. - - Parameters - ---------- - posData : cellacdc.load.loadData - The position data object. - event : QtGui.QMouseEvent - The event object. - ydata : int - The y-coordinate data. - xdata : int - The x-coordinate data. - """ - point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata) - - if point is None: - return - posData = self.data[self.pos_i] - acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] - filtered_IDs = self.getDistanceListMissingIDs(point, ID) - if len(filtered_IDs) == 0: - self.logger.info('No mother candidates found.') - return - - i = self.right_click_i % len(filtered_IDs) - i = abs(i) # Ensure i is non-negative - new_mother = filtered_IDs[i] - - if acdc_df_frame.loc[ID]['parent_ID_tree'] == new_mother and self.original_mother_skipped == False: # if a mother is already present, skip it - self.right_click_i += 1 - self.original_mother_skipped = True - - i = self.right_click_i % len(filtered_IDs) - i = abs(i) # Ensure i is non-negative - new_mother = filtered_IDs[i] - - acdc_df_frame.at[ID, 'parent_ID_tree'] = new_mother # update mother in the df, no need to propagate or stuff lile this - # dont need to update alldata_li as acdc_df_frame is just a view - self.drawAllLineageTreeLines() - - def annotate_unknown_lineage_action(self, posData, event, ydata, xdata): - """ - This function is part of the lin_tree edit functionality. - Associated with the right-click action of the 'unknownLineageButton' button. - Annotates an unknown lineage by setting its parent ID to -1 in the lineage tree (self.lineage_tree.lineage_list) - - Parameters - ---------- - posData : cellacdc.load.loadData - The position data. - event : QtGui.QMouseEvent - The event that triggered the annotation. - ydata : int - The y-coordinate data. - xdata : int - The x-coordinate data. - """ - point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata) - - if point is None: - return - posData = self.data[self.pos_i] - acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] - acdc_df_frame.at[ID, 'parent_ID_tree'] = -1 - self.drawAllLineageTreeLines() - - def gui_addCreatedAxesItems(self): - self.ax1.addItem(self.ax1_contoursImageItem) - self.ax1.addItem(self.ax1_lostObjImageItem) - self.ax1.addItem(self.ax1_lostTrackedObjImageItem) - self.ax1.addItem(self.ax1_oldMothBudLinesItem) - self.ax1.addItem(self.ax1_newMothBudLinesItem) - self.ax1.addItem(self.ax1_lostObjScatterItem) - self.ax1.addItem(self.ax1_lostTrackedScatterItem) - self.ax1.addItem(self.ccaFailedScatterItem) - self.ax1.addItem(self.yellowContourScatterItem) - - self.ax2.addItem(self.ax2_contoursImageItem) - self.ax2.addItem(self.ax2_lostObjImageItem) - self.ax2.addItem(self.ax2_lostTrackedObjImageItem) - self.ax2.addItem(self.ax2_oldMothBudLinesItem) - self.ax2.addItem(self.ax2_newMothBudLinesItem) - self.ax2.addItem(self.ax2_lostObjScatterItem) - - self.textAnnot[0].addToPlotItem(self.ax1) - self.textAnnot[1].addToPlotItem(self.ax2) - - self.ax1.addItem(self.exportMaskImageItem) - self.ax1.exportMaskImageItem = self.exportMaskImageItem - - def SegForLostIDsSetSettings(self): - - try: - prev_model = str(self.df_settings.at['SegForLostIDsModel', 'value']) - except KeyError: - prev_model = None - win = apps.QDialogSelectModel(parent=self, customFirst=prev_model) - win.exec_() - if win.cancel: - self.logger.info('Seg for lost IDs cancelled.') - return - base_model_name = win.selectedModel - - if base_model_name: - self.df_settings.at['SegForLostIDsModel', 'value'] = base_model_name - self.df_settings.to_csv(self.settings_csv_path) - - model_name = 'local_seg' - - idx = self.modelNames.index(model_name) - acdcSegment = self.acdcSegment_li[idx] - - try: - if acdcSegment is None or base_model_name != self.local_seg_base_model_name: - self.logger.info(f'Importing {base_model_name}...') - acdcSegment = myutils.import_segment_module(base_model_name) - self.acdcSegment_li[idx] = acdcSegment - self.local_seg_base_model_name = base_model_name - except (IndexError, ImportError, KeyError) as e: - self.logger.error(f'Error importing {base_model_name}: {e}') - return - - extra_params = ['overlap_threshold', - 'padding', - 'size_perc_diff', - 'distance_filler_growth', - 'max_iterations', - 'allow_only_tracked_cells'] - - extra_types = [float, float, float, float, int, bool] - - extra_defaults = [0.5, 0.8, 0.3, 1., 2, False] - - extra_desc = ['Overlap threshold with other already segemented cells over which newly segmented cells are discarded', - 'Padding of the box used for new segmentation around the segmentation from the previous frame', - 'Relative size difference acceptable compared to previous frames', - """Cells which are already segmented are filled with random noise sampled from background - to ensure that they don't get segmented again. - This parameter controls the additional padding around the already segmented cells.""", - """The algorithm will try and segment the maximum amount - of cells in the image by running the model several - times and filling new found cells with background noise. - How many of these iterations should be run?""", - "If no new cell IDs should be permitted (based on real time tracking)"] - - extra_ArgSpec = [] - for i, param in enumerate(extra_params): - param = ArgSpec(name=param, - default=extra_defaults[i], - type=extra_types[i], - desc=extra_desc[i], - docstring='') - - extra_ArgSpec.append(param) - - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) - segment_params = [arg for arg in segment_params if arg[0] != 'diameter'] - - extraParamsTitle = 'Settings for local segmentation' - win = self.initSegmModelParams( - base_model_name, acdcSegment, init_params, segment_params, - extraParams=extra_ArgSpec, extraParamsTitle=extraParamsTitle, - initLastParams=True, ini_filename='segmentation_for_lostIDs.ini', - ) - - if win is None: - self.logger.info('Segmentation for lost IDs cancelled.') - return - - init_kwargs_new = {} - args_new = {} - for key, val in win.init_kwargs.items(): - if key in extra_params: - args_new[key] = val - else: - init_kwargs_new[key] = val - - for key, val in win.extra_kwargs.items(): - if key in extra_params: - args_new[key] = val - - self.SegForLostIDsSettings = { - 'win': win, - 'init_kwargs_new': init_kwargs_new, - 'args_new': args_new, - 'base_model_name': base_model_name, - } - - def segForLostIDsButtonClicked(self): - - self.setFrameNavigationDisabled(disable=True, why='Segmentation for lost IDs') - posData = self.data[self.pos_i] - if posData.frame_i == 0: - self.logger.info('Segmentation for lost IDs not available on first frame.') - self.setFrameNavigationDisabled(disable=False, why='Segmentation for lost IDs') - return - self.storeUndoRedoStates(False) - self.progressWin = apps.QDialogWorkerProgress( - title='Segmenting for lost IDs', parent=self, - pbarDesc=f'Segmenting for lost IDs...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.startSegForLostIDsWorker() - - def onSegForLostInit(self): - self.logger.info('Settings for segmentation for lost IDs not set.') - self.SegForLostIDsSetSettings() - self.SegForLostIDsWaitCond.wakeAll() - - def SegForLostIDsWorkerAskInstallModel(self, model_name): - myutils.check_install_package(model_name) - self.SegForLostIDsWaitCond.wakeAll() - - def startSegForLostIDsWorker(self): - self.SegForLostIDsMutex = QMutex() - self.SegForLostIDsWaitCond = QWaitCondition() - self._thread = QThread() - - # Initialize the worker with mutex and wait condition - self.SegForLostIDsWorker = workers.SegForLostIDsWorker( - self, self.SegForLostIDsMutex, self.SegForLostIDsWaitCond - ) - - # Connect the worker's signal to the main thread's slot - self.SegForLostIDsWorker.sigAskInit.connect(self.onSegForLostInit) - self.SegForLostIDsWorker.sigAskInstallModel.connect( - self.SegForLostIDsWorkerAskInstallModel - ) - self.SegForLostIDsWorker.sigshowImageDebug.connect( - self.showImageDebug - ) - - self.SegForLostIDsWorker.sigSegForLostIDsWorkerAskInstallGPU.connect( - self.SegForLostIDsWorkerAskInstallGPU - ) - - self.SegForLostIDsWorker.sigStoreData.connect(self.onSigStoreDataSegForLostIDsWorker) - self.SegForLostIDsWorker.sigUpdateRP.connect(self.onSigUpdateRPSegForLostIDsWorker) - # self.SegForLostIDsWorker.sigGetData.connect(self.onSigGetDataSegForLostIDsWorker) - # self.SegForLostIDsWorker.sigGet2Dlab.connect(self.onSigGet2DlabSegForLostIDsWorker) - # self.SegForLostIDsWorker.sigGetTrackedLostIDs.connect(self.onSigGetTrackedSegForLostIDsWorker) - # self.SegForLostIDsWorker.sigGetBrushID.connect(self.onSigGetBrushIDSegForLostIDsWorker) - self.SegForLostIDsWorker.sigTrackManuallyAddedObject.connect(self.onSigTrackManuallyAddedObjectSegForLostIDsWorker) - - # Move the worker to the thread - self.SegForLostIDsWorker.moveToThread(self._thread) - - # Manage thread lifecycle - self.SegForLostIDsWorker.signals.finished.connect(self._thread.quit) - self.SegForLostIDsWorker.signals.finished.connect(self.SegForLostIDsWorker.deleteLater) - self._thread.finished.connect(self._thread.deleteLater) - - # Connect other worker signals to the appropriate slots - self.SegForLostIDsWorker.signals.finished.connect(self.SegForLostIDsWorkerFinished) - self.SegForLostIDsWorker.signals.progress.connect(self.workerProgress) - self.SegForLostIDsWorker.signals.initProgressBar.connect(self.workerInitProgressbar) - self.SegForLostIDsWorker.signals.progressBar.connect(self.workerUpdateProgressbar) - self.SegForLostIDsWorker.signals.critical.connect(self.workerCritical) - - # Start the thread and worker - self._thread.started.connect(self.SegForLostIDsWorker.run) - self._thread.start() - - def SegForLostIDsWorkerAskInstallGPU(self, model_name, use_gpu): - result = myutils.check_gpu_available(model_name, use_gpu, qparent=self) - self.SegForLostIDsWorker.gpu_go = result - dont_force_cpu = myutils.check_gpu_available( - model_name, use_gpu, do_not_warn=True) - self.SegForLostIDsWorker.dont_force_cpu = dont_force_cpu - self.SegForLostIDsWaitCond.wakeAll() - - def onSigStoreDataSegForLostIDsWorker(self, autosave): - self.onSigStoreData( - self.SegForLostIDsWaitCond, autosave=autosave) - - def onSigUpdateRPSegForLostIDsWorker(self, wl_update, wl_track_og_curr): - self.onSigUpdateRP(self.SegForLostIDsWaitCond, - wl_update=wl_update, - wl_track_og_curr=wl_track_og_curr) - - # def onSigGetDataSegForLostIDsWorker(self): - # self.onSigGetData( - # self.SegForLostIDsWaitCond) - - # def onSigGet2DlabSegForLostIDsWorker(self): - # posData = self.data[self.pos_i] - # lab = self.get_2Dlab(posData.lab) - # self.SegForLostIDsWorker.lab = lab - # self.SegForLostIDsWaitCond.wakeAll() - - # def onSigGetTrackedSegForLostIDsWorker(self): - # self.SegForLostIDsWorker.trackedLostIDs = self.getTrackedLostIDs() - # self.SegForLostIDsWaitCond.wakeAll() - - # def onSigGetBrushIDSegForLostIDsWorker(self): - # self.SegForLostIDsWorker.brushID = self.setBrushID(useCurrentLab=True, return_val=True) - # self.SegForLostIDsWaitCond.wakeAll() - - def onSigTrackManuallyAddedObjectSegForLostIDsWorker(self, added_IDs, isNewID, wl_update, wl_track_og_curr): - self.trackManuallyAddedObject(added_IDs, isNewID, wl_update=wl_update, wl_track_og_curr=wl_track_og_curr) - self.SegForLostIDsWaitCond.wakeAll() - - - def onSigStoreData( - self, waitcond, pos_i=None, enforce=True, debug=False, - mainThread=True, autosave=True, store_cca_df_copy=False - ): - self.store_data(pos_i=pos_i, enforce=enforce, debug=debug, mainThread=mainThread, - autosave=autosave, store_cca_df_copy=store_cca_df_copy) - waitcond.wakeAll() - - def onSigUpdateRP(self, waitcond, draw=True, debug=False, update_IDs=True, - wl_update=True, wl_track_og_curr=False): - self.update_rp(draw=draw, debug=debug, update_IDs=update_IDs, - wl_update=wl_update, wl_track_og_curr=wl_track_og_curr) - waitcond.wakeAll() - - def onSigGetData(self, waitcond, debug=False): - self.get_data(debug=debug) - waitcond.wakeAll() - - def SegForLostIDsWorkerFinished(self): - self.updateAllImages() - self.update_rp() - self.store_data(autosave=True) - self.setFrameNavigationDisabled(disable=False, why='Segmentation for lost IDs') - - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - def showImageDebug(self, img): - imshow(img) - - def gui_raiseBottomLayoutContextMenu(self, event): - try: - # Convert QPointF to QPoint - self.bottomLayoutContextMenu.popup(event.globalPos().toPoint()) - except AttributeError: - self.bottomLayoutContextMenu.popup(event.globalPos()) - - def areContoursRequested(self, ax): - if ax == 0 and self.annotContourCheckbox.isChecked(): - return True - - if ax == 1: - if not self.labelsGrad.showRightImgAction.isChecked(): - return False - - isRightDifferentAnnot = self.rightBottomGroupbox.isChecked() - areContRequestedRight = self.annotContourCheckboxRight.isChecked() - - if isRightDifferentAnnot and areContRequestedRight: - return True - - areContRequestedLeft = self.annotContourCheckbox.isChecked() - if not isRightDifferentAnnot and areContRequestedLeft: - return True - return False - - def areMothBudLinesRequested(self, ax): - if ax == 0: - if self.annotCcaInfoCheckbox.isChecked(): - return True - if self.drawMothBudLinesCheckbox.isChecked(): - return True - else: - if not self.labelsGrad.showRightImgAction.isChecked(): - return False - - isRightDifferentAnnot = self.rightBottomGroupbox.isChecked() - areLinesRequestedRight = ( - self.annotCcaInfoCheckboxRight.isChecked() - or self.drawMothBudLinesCheckboxRight.isChecked() - ) - - if isRightDifferentAnnot and areLinesRequestedRight: - return True - - areLinesRequestedLeft = ( - self.drawMothBudLinesCheckbox.isChecked() - or self.annotCcaInfoCheckbox.isChecked() - ) - if not isRightDifferentAnnot and areLinesRequestedLeft: - return True - return False - - def getMothBudLineScatterItem(self, ax, new): - if ax == 0: - if new: - return self.ax1_newMothBudLinesItem - else: - return self.ax1_oldMothBudLinesItem - else: - if new: - return self.ax2_newMothBudLinesItem - else: - return self.ax2_oldMothBudLinesItem - - def labelRoiIsCircularRadioButtonToggled(self, checked): - if checked: - self.labelRoiCircularRadiusSpinbox.setDisabled(False) - else: - self.labelRoiCircularRadiusSpinbox.setDisabled(True) - - def pxModeActionToggled(self, checked): - self.df_settings.at['pxMode', 'value'] = int(checked) - self.df_settings.to_csv(self.settings_csv_path) - - if not self.isDataLoaded: - return - - if self.highLowResAction.isChecked(): - for ax in range(2): - self.textAnnot[ax].setPxMode(checked) - - self.updateAllImages() - - def relabelSequentialCallback(self): - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer' or mode == 'Cell cycle analysis': - self.startBlinkingModeCB() - return - - posData = self.data[self.pos_i] - selectedPos = (posData.pos_foldername, ) - if len(self.data) > 1: - selectedPos = self.askSelectPos(action='to process') - if selectedPos is None: - self.logger.info('Re-labelling process stopped.') - return - - self.store_data() - # acdc_df_concat = self.getConcatAcdcDf() - # load.store_unsaved_acdc_df( - # posData, acdc_df_concat, - # log_func=self.logger.info - # ) - # if posData.SizeT > 1: - self.progressWin = apps.QDialogWorkerProgress( - title='Re-labelling sequential', parent=self, - pbarDesc='Relabelling sequential...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - self.startRelabellingWorker(selectedPos) - - # elif posData: - # self.storeUndoRedoStates(False) - # posData.lab, oldIDs, newIDs = core.relabel_sequential(posData.lab) - # # Update annotations based on relabelling - # self.update_cca_df_relabelling(posData, oldIDs, newIDs) - # self.updateAnnotatedIDs(oldIDs, newIDs, logger=self.logger.info) - # self.store_data() - # self.update_rp() - # li = list(zip(oldIDs, newIDs)) - # s = '\n'.join([str(pair).replace(',', ' -->') for pair in li]) - # s = f'IDs relabelled as follows:\n{s}' - # self.logger.info(s) - # self.updateAllImages() - - def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print): - logger('Updating annotated IDs...') - posData = self.data[self.pos_i] - - mapper = dict(zip(oldIDs, newIDs)) - posData.ripIDs = set([mapper[ripID] for ripID in posData.ripIDs]) - posData.binnedIDs = set([mapper[binID] for binID in posData.binnedIDs]) - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - - customAnnotButtons = list(self.customAnnotDict.keys()) - for button in customAnnotButtons: - customAnnotValues = self.customAnnotDict[button] - annotatedIDs = customAnnotValues['annotatedIDs'][self.pos_i] - mappedAnnotIDs = {} - for frame_i, annotIDs_i in annotatedIDs.items(): - mappedIDs = [mapper[ID] for ID in annotIDs_i] - mappedAnnotIDs[frame_i] = mappedIDs - customAnnotValues['annotatedIDs'][self.pos_i] = mappedAnnotIDs - - def rtTrackerActionToggled(self, checked): - if not checked: - return - - aliases = myutils.aliases_real_time_trackers(reverse=True) - if self.sender().text() in aliases: - trackingAlgo = aliases[self.sender().text()] - else: - trackingAlgo = self.sender().text() - self.df_settings.at['tracking_algorithm', 'value'] = trackingAlgo - self.df_settings.to_csv(self.settings_csv_path) - - if self.sender().text() == 'YeaZ': - msg = widgets.myMessageBox(wrapText=False) - info_txt = html_utils.paragraph(f""" - Note that YeaZ tracking algorithm tends to be sliglhtly more accurate - overall, but it is less capable of detecting segmentation - errors.

- If you need to correct as many segmentation errors as possible - we recommend using Cell-ACDC tracking algorithm. - """) - msg.information(self, 'Info about YeaZ', info_txt) - - self.isRealTimeTrackerInitialized = False - self.initRealTimeTracker() - - def autoPilotToggled(self, checked): - self.autoPilotZoomToObjToolbar.setVisible(checked) - if checked: - self.autoPilotZoomToObjToggle.setChecked(False) - self.autoPilotZoomToObjToggle.toggle() - - def zoomRectActionToggled(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - self.connectLeftClickButtons() - self.ax1.addItem(self.zoomRectItem) - else: - self.zoomRectItem.setPos((0,0)) - self.zoomRectItem.setSize((0,0)) - self.ax1.removeItem(self.zoomRectItem) - - def zoomRectDone(self): - xRange, yRange = self.ax1.viewRange() - self.zoomRectItem.storeLastRange(xRange, yRange) - - ymin, xmin, ymax, xmax = self.zoomRectItem.bbox() - - self.zoomRectItem.setPos((0,0)) - self.zoomRectItem.setSize((0,0)) - - self.ax1.setRange( - xRange=(xmin, xmax), - yRange=(ymin, ymax), - padding=0 - ) - - def zoomRectCancelled(self): - self.isMouseDragImg1 = False - self.zoomRectItem.setPos((0,0)) - self.zoomRectItem.setSize((0,0)) - - def findID(self, checked=False, ID=None): - posData = self.data[self.pos_i] - if ID is None: - searchIDdialog = apps.FindIDDialog( - title='Search object by ID', - msg='Enter object ID to find and highlight', - parent=self, - isInteger=True - ) - searchIDdialog.exec_() - if searchIDdialog.cancel: - return - - searchedID = searchIDdialog.EntryID - else: - searchedID = ID - - if searchedID in posData.IDs: - self.goToObjectID(searchedID) - return - - if posData.SizeT == 1: - self.warnIDnotFound(searchedID) - return - - if searchedID in posData.lost_IDs: - self.goToLostObjectID(searchedID) - return - - tracked_lost_IDs = self.getTrackedLostIDs() - if searchedID in tracked_lost_IDs: - self.goToAcceptedLostObjectID(searchedID) - return - - self.logger.info(f'Searching ID {searchedID} in other frames...') - - frame_i_found = self.startSearchIDworker(searchedID) - if frame_i_found is None: - self.warnIDnotFound(searchedID) - return - - self.logger.info( - f'Object ID {searchedID} found at frame n. {frame_i_found+1}.' - ) - proceed = self.askGoToFrameFoundID(searchedID, frame_i_found) - if not proceed: - return - - posData.frame_i = frame_i_found - self.get_data() - self.updateAllImages() - self.updateScrollbars() - - self.goToObjectID(searchedID) - - @disableWindow - def startSearchIDworker(self, searchedID): - posData = self.data[self.pos_i] - - desc = 'Searching ID in all frames...' - - self.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self.mainWin, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(posData.SizeT) - self.progressWin.show(self.app) - - self.searchIDthread = QThread() - self.searchIDworker = workers.SimpleWorker( - posData, self.searchIDworkerCallback, - func_args=(searchedID, ) - ) - self.searchIDworker.frame_i_found = None - self.searchIDworker.moveToThread(self.searchIDthread) - - self.searchIDworker.signals.finished.connect( - self.searchIDthread.quit - ) - self.searchIDworker.signals.finished.connect( - self.searchIDworker.deleteLater - ) - self.searchIDthread.finished.connect(self.searchIDthread.deleteLater) - - self.searchIDworker.signals.critical.connect( - self.searchIDworkerCritical - ) - self.searchIDworker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.searchIDworker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.searchIDworker.signals.progress.connect( - self.workerProgress - ) - self.searchIDworker.signals.finished.connect( - self.searchIDworkerFinished - ) - - self.searchIDthread.started.connect(self.searchIDworker.run) - self.searchIDthread.start() - - self.searchIDworkerLoop = QEventLoop() - self.searchIDworkerLoop.exec_() - - return self.searchIDworker.frame_i_found - - def searchIDworkerCritical(self, error): - self.searchIDworkerLoop.exit() - self.workerCritical(error) - - def searchIDworkerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.searchIDworkerLoop.exit() - - def searchIDworkerCallback(self, posData, searchedID): - self.searchIDworker.signals.initProgressBar.emit(0) - self.setAllIDs() - self.searchIDworker.signals.initProgressBar.emit(posData.SizeT) - frame_i_found = None - for frame_i in range(len(posData.segm_data)): - if frame_i >= len(posData.allData_li): - break - lab = posData.allData_li[frame_i]['labels'] - if lab is None: - rp = skimage.measure.regionprops(posData.segm_data[frame_i]) - IDs = set([obj.label for obj in rp]) - else: - IDs = posData.allData_li[frame_i]['IDs'] - - if searchedID in IDs: - frame_i_found = frame_i - break - - self.searchIDworker.signals.progressBar.emit(1) - - self.searchIDworker.frame_i_found = frame_i_found - - def warnIDnotFound(self, searchedID): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(f""" - Object ID {searchedID} was not found.

- """) - msg.warning(self, f'ID {searchedID} not found', txt) - - def goToObjectID(self, ID): - posData = self.data[self.pos_i] - objIdx = posData.IDs_idxs[ID] - obj = posData.rp[objIdx] - self.goToZsliceSearchedID(obj) - - self.highlightSearchedID(ID) - propsQGBox = self.guiTabControl.propsQGBox - propsQGBox.idSB.setValue(ID) - - def goToLostObjectID(self, lostID, color=(255, 165, 0, 255)): - posData = self.data[self.pos_i] - frame_i = posData.frame_i - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] - obj = prev_rp[prev_IDs_idxs[lostID]] - self.goToZsliceSearchedID(obj) - - imageItem = self.getLostObjImageItem(0) - thickness = 1 - if not hasattr(self, 'lostObjContoursImage'): - self.initLostObjContoursImage() - else: - self.lostObjContoursImage[:] = 0 - - contours = [] - obj_contours = self.getObjContours(obj, all_external=True) - contours.extend(obj_contours) - - self.addLostObjsToLostObjImage(obj, lostID) - self.drawLostObjContoursImage( - imageItem, contours, thickness=2, color=color - ) - - def goToAcceptedLostObjectID(self, acceptedLostID): - posData = self.data[self.pos_i] - frame_i = posData.frame_i - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] - obj = prev_rp[prev_IDs_idxs[acceptedLostID]] - self.goToZsliceSearchedID(obj) - - self.updateLostTrackedContoursImage(tracked_lost_IDs=[acceptedLostID]) - - def askGoToFrameFoundID(self, searchedID, frame_i_found): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(f""" - Object ID {searchedID} was found at frame n. {frame_i_found+1}.

- Do you want to go to frame n. {frame_i_found+1}. - """) - noButton, yesButton = msg.information( - self, f'ID {searchedID} found at frame n. {frame_i_found+1}', txt, - buttonsTexts=( - 'No, stay on current frame', - f'Yes, go to frame n. {frame_i_found+1}' - ) - ) - return msg.clickedButton == yesButton - - def skipForwardToNewID(self): - self.progressWin = apps.QDialogWorkerProgress( - title='Searching the next frame with a new object', parent=self, - pbarDesc=f'Searching the next frame with a new object...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.startFindNextNewIdWorker() - - def startFindNextNewIdWorker(self): - posData = self.data[self.pos_i] - self._thread = QThread() - self.findNextNewIdWorker = workers.FindNextNewIdWorker(posData, self) - self.findNextNewIdWorker.moveToThread(self._thread) - - self.findNextNewIdWorker.signals.finished.connect(self._thread.quit) - self.findNextNewIdWorker.signals.finished.connect( - self.findNextNewIdWorker.deleteLater - ) - self._thread.finished.connect(self._thread.deleteLater) - - self.findNextNewIdWorker.signals.finished.connect( - self.findNextNewIdWorkerFinished - ) - self.findNextNewIdWorker.signals.progress.connect(self.workerProgress) - self.findNextNewIdWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.findNextNewIdWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.findNextNewIdWorker.signals.critical.connect( - self.workerCritical - ) - - self._thread.started.connect(self.findNextNewIdWorker.run) - self._thread.start() - - def findNextNewIdWorkerFinished(self, next_frame_i): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.navSpinBox.setValue(next_frame_i+1) - self.framesScrollBarReleased() - - def workerProgress(self, text, loggerLevel='INFO'): # used in cca and lin tree - if self.progressWin is not None: - self.progressWin.logConsole.append(text) - self.logger.log(getattr(logging, loggerLevel), text) - - def workerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.logger.info('Worker process ended.') - self.updateAllImages() - self.titleLabel.setText('Done', color='w') - - def savePreprocWorkerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.setStatusBarLabel() - self.logger.info('Pre-processed data saved!') - self.titleLabel.setText('Pre-processed data saved!', color='w') - - def delObjsOutSegmMaskWorkerFinished(self, result): - posData = self.data[self.pos_i] - worker, cleared_segm_data, delIDs = result - if posData.SizeT == 1: - cleared_segm_data = cleared_segm_data[np.newaxis] - - self.update_cca_df_deletedIDs(posData, delIDs) - - current_frame_i = posData.frame_i - for frame_i, cleared_lab in enumerate(cleared_segm_data): - # Store change - posData.allData_li[frame_i]['labels'] = cleared_lab - # Get the rest of the stored metadata based on the new lab - posData.frame_i = frame_i - self.get_data() - self.store_data(autosave=False) - - # Back to current frame - posData.frame_i = current_frame_i - self.get_data() - - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.logger.info('Deleting objects outside of ROIs finished.') - self.titleLabel.setText( - 'Deleting objects outside of ROIs finished.', color='w' - ) - self.updateAllImages() - - def loadingNewChunk(self, chunk_range): - coord0_chunk, coord1_chunk = chunk_range - desc = ( - f'Loading new window, range = ({coord0_chunk}, {coord1_chunk})...' - ) - self.progressWin = apps.QDialogWorkerProgress( - title='Loading data...', parent=self, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.show(self.app) - - def lazyLoaderFinished(self): - self.logger.info('Load chunk data worker done.') - if self.lazyLoader.updateImgOnFinished: - self.updateAllImages() - - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - @exception_handler - def trackingWorkerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.logger.info('Worker process ended.') - askDisableRealTimeTracking = ( - self.trackingWorker.trackingOnNeverVisitedFrames - and self.realTimeTrackingToggle.isChecked() - ) - if askDisableRealTimeTracking: - msg = widgets.myMessageBox() - title = 'Disable real-time tracking?' - txt = ( - 'You perfomed tracking on frames that you have ' - 'never visited.

' - 'Cell-ACDC default behaviour is to track them again when you ' - 'will visit them.

' - 'However, you can overwrite this behaviour and explicitly ' - 'disable tracking for all of the frames you already tracked.

' - 'NOTE: you can reactivate real-time tracking by clicking on the ' - '"Reset last segmented frame" button on the top toolbar.

' - 'What do you want me to do?' - ) - _, disableTrackingButton = msg.information( - self, title, html_utils.paragraph(txt), - buttonsTexts=( - 'Keep real-time tracking active (recommended)', - 'Disable real-time tracking' - ) - ) - if msg.clickedButton == disableTrackingButton: - self.logger.info('Disabling real time tracking...') - self.realTimeTrackingToggle.setChecked(False) - # posData = self.data[self.pos_i] - # current_frame_i = posData.frame_i - # for frame_i in range(self.start_n-1, self.stop_n): - # posData.frame_i = frame_i - # self.get_data() - # self.store_data(autosave=frame_i==self.stop_n-1) - # posData.last_tracked_i = frame_i - # self.setNavigateScrollBarMaximum() - - # # Back to current frame - # posData.frame_i = current_frame_i - # self.get_data() - posData = self.data[self.pos_i] - self.updateAllImages() - self.titleLabel.setText('Done', color='w') - - def workerInitProgressbar(self, totalIter): - self.progressWin.mainPbar.setValue(0) - if totalIter == 1: - totalIter = 0 - self.progressWin.mainPbar.setMaximum(totalIter) - - def workerUpdateProgressbar(self, step): - self.progressWin.mainPbar.update(step) - - def workerInitInnerPbar(self, totalIter): - self.progressWin.innerPbar.setValue(0) - if totalIter == 1: - totalIter = 0 - self.progressWin.innerPbar.setMaximum(totalIter) - - def workerUpdateInnerPbar(self, step): - self.progressWin.innerPbar.update(step) - - def startTrackingWorker(self, posData, video_to_track): - self.thread = QThread() - self.trackingWorker = workers.trackingWorker( - posData, self, video_to_track - ) - self.trackingWorker.moveToThread(self.thread) - self.trackingWorker.finished.connect(self.thread.quit) - self.trackingWorker.finished.connect(self.trackingWorker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - # Custom signals - self.trackingWorker.signals.progress = self.trackingWorker.progress - self.trackingWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.trackingWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.trackingWorker.signals.sigInitInnerPbar.connect( - self.workerInitInnerPbar - ) - self.trackingWorker.progress.connect(self.workerProgress) - self.trackingWorker.critical.connect(self.workerCritical) - self.trackingWorker.finished.connect(self.trackingWorkerFinished) - - self.trackingWorker.debug.connect(self.workerDebug) - - self.thread.started.connect(self.trackingWorker.run) - self.thread.start() - - def startRelabellingWorker(self, posFoldernames): - self.thread = QThread() - self.worker = workers.relabelSequentialWorker(self, posFoldernames) - self.worker.moveToThread(self.thread) - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - self.worker.progress.connect(self.workerProgress) - self.worker.critical.connect(self.workerCritical) - self.worker.finished.connect(self.workerFinished) - self.worker.finished.connect(self.relabelWorkerFinished) - - self.worker.debug.connect(self.workerDebug) - - self.thread.started.connect(self.worker.run) - self.thread.start() - - def startPostProcessSegmWorker( - self, postProcessKwargs, customPostProcessGroupedFeatures, - customPostProcessFeatures - ): - self.thread = QThread() - self.postProcessWorker = workers.PostProcessSegmWorker( - postProcessKwargs, customPostProcessGroupedFeatures, - customPostProcessFeatures, self - ) - - self.postProcessWorker.moveToThread(self.thread) - self.postProcessWorker.signals.finished.connect(self.thread.quit) - self.postProcessWorker.signals.finished.connect( - self.postProcessWorker.deleteLater - ) - self.thread.finished.connect(self.thread.deleteLater) - - self.postProcessWorker.signals.finished.connect( - self.postProcessSegmWorkerFinished - ) - self.postProcessWorker.signals.progress.connect(self.workerProgress) - self.postProcessWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.postProcessWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.postProcessWorker.signals.critical.connect( - self.workerCritical - ) - - self.thread.started.connect(self.postProcessWorker.run) - self.thread.start() - - def relabelWorkerFinished(self): - self.updateAllImages() - - def workerDebug(self, item): - tracked_video, worker = item - from cellacdc.plot import imshow - imshow(tracked_video) - worker.waitCond.wakeAll() - - def keepToolActiveActionToggled(self, checked, toolName=None): - if toolName is None: - parentToolButton = self.sender().parent() - toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] - - if checked: - self.df_settings.at[toolName, 'value'] = 'keepActive' - else: - self.df_settings = self.df_settings.drop( - index=toolName, errors='ignore' - ) - self.df_settings.to_csv(self.settings_csv_path) - - def applyToolNewFrameActionToggled(self, checked, toolName=None): - if toolName is None: - parentToolButton = self.sender().parent() - toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] - toolName = toolName.strip() - button = self.applyToolNewFrameButtons[toolName] - toolName = toolName.replace(' ', '_') - settingName = f'{toolName}_applyNewFrame' - if checked: - self.df_settings.at[settingName, 'value'] = 'applyNewFrame' - button.setStyleSheet(f'background-color: {GREEN_HEX}') - else: - self.df_settings = self.df_settings.drop( - index=settingName, errors='ignore' - ) - button.setStyleSheet('background-color: none') - self.df_settings.to_csv(self.settings_csv_path) - - def keepAllToolsActiveActionToggled(self, checked): - for action in self.keepToolActiveActions.values(): - action.setChecked(checked) - - data_loaded = True - if not hasattr(self, 'data'): - data_loaded = False - try: - self.labelRoiTrangeCheckbox.disconnect() - except TypeError: - pass - self.labelRoiTrangeCheckbox.setChecked(checked) # why this is not wrapped in a QAction? - - if data_loaded: - self.labelRoiTrangeCheckbox.toggled.connect( - self.labelRoiTrangeCheckboxToggled - ) - - def determineSlideshowWinPos(self): - screens = self.app.screens() - self.numScreens = len(screens) - winScreen = self.screen() - - # Center main window and determine location of slideshow window - # depending on number of screens available - if self.numScreens > 1: - for screen in screens: - if screen != winScreen: - winScreen = screen - break - - winScreenGeom = winScreen.geometry() - winScreenCenter = winScreenGeom.center() - winScreenCenterX = winScreenCenter.x() - winScreenCenterY = winScreenCenter.y() - winScreenLeft = winScreenGeom.left() - winScreenTop = winScreenGeom.top() - self.slideshowWinLeft = winScreenCenterX - int(850/2) - self.slideshowWinTop = winScreenCenterY - int(800/2) - - def nonViewerEditMenuOpened(self): - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - self.startBlinkingModeCB() - - def getDistantGray(self, desiredGray, bkgrGray): - isDesiredSimilarToBkgr = ( - abs(desiredGray-bkgrGray) < 0.3 - ) - if isDesiredSimilarToBkgr: - return 1-desiredGray - else: - return desiredGray - - def RGBtoGray(self, R, G, B): - # see https://stackoverflow.com/questions/17615963/standard-rgb-to-grayscale-conversion - C_linear = (0.2126*R + 0.7152*G + 0.0722*B)/255 - if C_linear <= 0.0031309: - gray = 12.92*C_linear - else: - gray = 1.055*(C_linear)**(1/2.4) - 0.055 - return gray - - def ruler_cb(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - self.connectLeftClickButtons() - else: - self.tempSegmentON = False - self.ax1_rulerPlotItem.setData([], []) - self.ax1_rulerAnchorsItem.setData([], []) - - def editImgProperties(self, checked=True): - posData = self.data[self.pos_i] - posData.askInputMetadata( - len(self.data), - ask_SizeT=True, - ask_TimeIncrement=True, - ask_PhysicalSizes=True, - save=True, singlePos=True, - askSegm3D=False - ) - if hasattr(self, 'timestamp'): - self.timestamp.setSecondsPerFrame(posData.TimeIncrement) - self.updateTimestampFrame() - - if hasattr(self, 'scaleBar'): - self.scaleBar.updatePhysicalLength(posData.PhysicalSizeX) - - def setHoverToolSymbolData(self, xx, yy, ScatterItems, size=None): - if not xx: - self.ax1_lostObjScatterItem.setVisible(True) - self.ax2_lostObjScatterItem.setVisible(True) - - self.ax1_lostTrackedScatterItem.setVisible(True) - self.ax2_lostTrackedScatterItem.setVisible(True) - - for item in ScatterItems: - if size is None: - item.setData(xx, yy) - else: - item.setData(xx, yy, size=size) - - def updateLabelRoiCircularSize(self, value): - self.labelRoiCircItemLeft.setSize(value) - self.labelRoiCircItemRight.setSize(value) - - def updateLabelRoiCircularCursor(self, x, y, checked): - if not self.labelRoiButton.isChecked(): - return - if not self.labelRoiIsCircularRadioButton.isChecked(): - return - if self.labelRoiRunning: - return - - size = self.labelRoiCircularRadiusSpinbox.value() - if not checked: - xx, yy = [], [] - else: - xx, yy = [x], [y] - - if not xx and len(self.labelRoiCircItemLeft.getData()[0]) == 0: - return - - self.labelRoiCircItemLeft.setData(xx, yy, size=size) - self.labelRoiCircItemRight.setData(xx, yy, size=size) - - def getLabelRoiImage(self): - posData = self.data[self.pos_i] - - if self.labelRoiTrangeCheckbox.isChecked(): - start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 - stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() - tRangeLen = stop_frame_n-start_frame_i - else: - tRangeLen = 1 - - if tRangeLen > 1: - tRange = (start_frame_i, stop_frame_n) - else: - tRange = None - - if self.isSegm3D: - if tRangeLen > 1: - imgData = posData.img_data - else: - # Filtered data not existing - imgData = posData.img_data[posData.frame_i] - - roi_zdepth = self.labelRoiZdepthSpinbox.value() - if roi_zdepth == posData.SizeZ: - z0 = 0 - z1 = posData.SizeZ - elif roi_zdepth == 1: - z0 = self.zSliceScrollBar.sliderPosition() - z1 = z0 + 1 - else: - if roi_zdepth%2 != 0: - roi_zdepth +=1 - half_zdepth = int(roi_zdepth/2) - zc = self.zSliceScrollBar.sliderPosition() + 1 - z0 = zc-half_zdepth - z0 = z0 if z0>=0 else 0 - z1 = zc+half_zdepth - z1 = z1 if z1 1: - imgData = posData.img_data - else: - imgData = self.img1.image - - roiImg = imgData[labelRoiSlice] - if self.labelRoiIsFreeHandRadioButton.isChecked(): - mask = self.freeRoiItem.mask() - elif self.labelRoiIsCircularRadioButton.isChecked(): - mask = self.labelRoiCircItemLeft.mask() - else: - mask = None - - if mask is not None: - # Copy roiImg otherwise we are replacing minimum inside original image - roiImg = roiImg.copy() - # Fill outside of freehand roi with minimum of the ROI image - if tRangeLen > 1: - for i in range(tRangeLen): - ith_roiImg = roiImg[i] - if self.isSegm3D: - roiImg[i, :, ~mask] = ith_roiImg.min() - else: - roiImg[i, ~mask] = ith_roiImg.min() - else: - if self.isSegm3D: - roiImg[:, ~mask] = roiImg.min() - else: - roiImg[~mask] = roiImg.min() - - return roiImg, labelRoiSlice - - def getClickedID(self, xdata, ydata, text=''): - posData = self.data[self.pos_i] - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - msg = ( - 'You clicked on the background.\n' - f'Enter here the ID {text}' - ) - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), xdata, ydata - ) - clickedBkgrID = apps.QLineEditDialog( - title='Clicked on background', - msg=msg, parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - clickedBkgrID.exec_() - if clickedBkgrID.cancel: - return - else: - ID = clickedBkgrID.EntryID - return ID - - # @exec_time - def applyEditID( - self, clickedID, currentIDs, oldIDnewIDMapper, clicked_x, clicked_y, shift=False, doPropagateUnvisited=False - ): - posData = self.data[self.pos_i] - - # Ask to propagate change to all future visited frames - key = 'Edit ID' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - clickedID, key, doNotShow, - posData.UndoFutFrames_EditID, posData.applyFutFrames_EditID, - applyTrackingB=True - ) - - if UndoFutFrames is None: - return - - if shift and self.isSegm3D: - lab = self.get_2Dlab(posData.lab) - else: - lab = posData.lab - - # Store undo state before modifying stuff - self.storeUndoRedoStates(UndoFutFrames) - maxID = max(posData.IDs, default=0) - for old_ID, new_ID in oldIDnewIDMapper: - if new_ID in currentIDs and not self.editIDmergeIDs: - tempID = maxID + 1 - lab[lab == old_ID] = maxID + 1 - lab[lab == new_ID] = old_ID - lab[lab == tempID] = new_ID - maxID += 1 - - old_ID_idx = currentIDs.index(old_ID) - new_ID_idx = currentIDs.index(new_ID) - - # Append information for replicating the edit in tracking - # List of tuples (y, x, replacing ID) - objo = posData.rp[old_ID_idx] - yo, xo = self.getObjCentroid(objo.centroid) - objn = posData.rp[new_ID_idx] - yn, xn = self.getObjCentroid(objn.centroid) - if not math.isnan(yo) and not math.isnan(yn): - yn, xn = int(yn), int(xn) - posData.editID_info.append((yn, xn, new_ID)) - yo, xo = int(clicked_y), int(clicked_x) - posData.editID_info.append((yo, xo, old_ID)) - else: - lab[lab == old_ID] = new_ID - if new_ID > maxID: - maxID = new_ID - old_ID_idx = posData.IDs.index(old_ID) - - # Append information for replicating the edit in tracking - # List of tuples (y, x, replacing ID) - obj = posData.rp[old_ID_idx] - y, x = self.getObjCentroid(obj.centroid) - if not math.isnan(y) and not math.isnan(y): - y, x = int(y), int(x) - posData.editID_info.append((y, x, new_ID)) - - self.updateAssignedObjsAcdcTrackerSecondStep(new_ID) - - if shift and self.isSegm3D: - self.set_2Dlab(lab) - - # Update rps - self.update_rp() - - # Since we manually changed an ID we don't want to repeat tracking - self.setAllTextAnnotations() - self.highlightLostNew() - # self.checkIDsMultiContour() - - # Update colors for the edited IDs - self.updateLookuptable() - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Edit ID') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Edit ID', update_images=False) - - if not self.editIDbutton.findChild(QAction).isChecked(): - self.editIDbutton.setChecked(False) - - posData.disableAutoActivateViewerWindow = True - - # Perform desired action on future frames - posData.doNotShowAgain_EditID = doNotShowAgain - posData.UndoFutFrames_EditID = UndoFutFrames - posData.applyFutFrames_EditID = applyFutFrames - includeUnvisited = ( - posData.includeUnvisitedInfo['Edit ID'] - or doPropagateUnvisited - ) - - if not applyFutFrames and not doPropagateUnvisited: - return - - self.changeIDfutureFrames( - endFrame_i, oldIDnewIDMapper, includeUnvisited, - shift=shift - ) - - def getLastHoveredID(self): - if self.xHoverImg is None: - return 0 - - xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) - ID = self.currentLab2D[ydata, xdata] - return ID - - def getHoverID(self, xdata, ydata, byPassShiftCheck=False): - if not hasattr(self, 'diskMask'): - return 0 - - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - if byPassShiftCheck: - shift = False - else: - shift = modifiers == Qt.ShiftModifier - - if self.isPowerBrush() and not ctrl: - return 0 - - if not self.autoIDcheckbox.isChecked(): - return self.editIDspinbox.value() - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - ID = lab_2D[ydata, xdata] - self.isHoverZneighID = False - if self.isSegm3D: - z = self.z_lab() - SizeZ = posData.lab.shape[0] - doNotLinkThroughZ = self.brushButton.isChecked() and shift - if doNotLinkThroughZ: - if self.brushHoverCenterModeAction.isChecked() or ID>0: - hoverID = ID - else: - masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] - hoverID = np.bincount(masked_lab).argmax() - else: - if z > 0: - ID_z_under = posData.lab[z-1, ydata, xdata] - if self.brushHoverCenterModeAction.isChecked() or ID_z_under>0: - hoverIDa = ID_z_under - else: - lab = posData.lab - masked_lab_a = lab[z-1, ymin:ymax, xmin:xmax][diskMask] - hoverIDa = np.bincount(masked_lab_a).argmax() - else: - hoverIDa = 0 - - if self.brushHoverCenterModeAction.isChecked() or ID>0: - hoverIDb = lab_2D[ydata, xdata] - else: - masked_lab_b = lab_2D[ymin:ymax, xmin:xmax][diskMask] - hoverIDb = np.bincount(masked_lab_b).argmax() - - if z < SizeZ-1: - ID_z_above = posData.lab[z+1, ydata, xdata] - if self.brushHoverCenterModeAction.isChecked() or ID_z_above>0: - hoverIDc = ID_z_above - else: - lab = posData.lab - masked_lab_c = lab[z+1, ymin:ymax, xmin:xmax][diskMask] - hoverIDc = np.bincount(masked_lab_c).argmax() - else: - hoverIDc = 0 - - if hoverIDa > 0: - hoverID = hoverIDa - self.isHoverZneighID = True - elif hoverIDb > 0: - hoverID = hoverIDb - elif hoverIDc > 0: - hoverID = hoverIDc - self.isHoverZneighID = True - else: - hoverID = 0 - else: - if self.brushButton.isChecked() and shift: - # Force new ID with brush and Shift - hoverID = 0 - elif self.brushHoverCenterModeAction.isChecked() or ID>0: - hoverID = ID - else: - masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] - hoverID = np.bincount(masked_lab).argmax() - - self.editIDspinbox.setValue(hoverID) - - return hoverID - - def setHoverToolSymbolColor( - self, xdata, ydata, pen, ScatterItems, button, - brush=None, hoverRGB=None, ID=None, byPassShiftCheck=False - ): - modifiers = QGuiApplication.keyboardModifiers() - if byPassShiftCheck: - shift = False - else: - shift = modifiers == Qt.ShiftModifier - - posData = self.data[self.pos_i] - Y, X = self.get_2Dlab(posData.lab).shape - if not myutils.is_in_bounds(xdata, ydata, X, Y): - return - - self.isHoverZneighID = False - if ID is None: - hoverID = self.getHoverID( - xdata, ydata, byPassShiftCheck=byPassShiftCheck - ) - else: - hoverID = ID - - if hoverID == 0: - for item in ScatterItems: - item.setPen(pen) - item.setBrush(brush) - else: - try: - rgb = self.lut[hoverID] - rgb = rgb if hoverRGB is None else hoverRGB - rgbPen = np.clip(rgb*1.1, 0, 255) - for item in ScatterItems: - item.setPen(*rgbPen, width=2) - item.setBrush(*rgb, 100) - except IndexError: - pass - - checkChangeID = ( - self.isHoverZneighID and not shift - and self.lastHoverID != hoverID - ) - if checkChangeID: - # We are hovering an ID in z+1 or z-1 - self.restoreBrushID = hoverID - # self.changeBrushID() - - self.lastHoverID = hoverID - - def isPowerBrush(self): - color = self.brushButton.palette().button().color().name() - return color == self.doublePressKeyButtonColor - - def isPowerEraser(self): - color = self.eraserButton.palette().button().color().name() - return color == self.doublePressKeyButtonColor - - def isPowerButton(self, button): - color = button.palette().button().color().name() - return color == self.doublePressKeyButtonColor - - def getCheckNormAction(self): - normalize = False - how = '' - for action in self.normalizeQActionGroup.actions(): - if action.isChecked(): - how = action.text() - normalize = True - break - return action, normalize, how - - def normalizeIntensities(self, img): - action, normalize, how = self.getCheckNormAction() - if not normalize: - return img - - if how == 'Do not normalize. Display raw image': - img = img - elif how == 'Convert to floating point format with values [0, 1]': - img = myutils.img_to_float(img) - # elif how == 'Rescale to 8-bit unsigned integer format with values [0, 255]': - # img = skimage.img_as_float(img) - # img = (img*255).astype(np.uint8) - # return img - elif how == 'Rescale to [0, 1]': - img = skimage.img_as_float(img) - img = skimage.exposure.rescale_intensity(img) - elif how == 'Normalize by max value': - img = img/np.max(img) - return img - - def removeAlldelROIsCurrentFrame(self): - posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - rois = delROIs_info['rois'].copy() - for roi in rois: - self.ax2.removeDelRoiItem(roi) - - for item in self.ax2.items: - if isinstance(item, pg.ROI): - self.ax2.removeDelRoiItem(item) - - for item in self.ax1.items: - if isinstance(item, pg.ROI) and item != self.labelRoiItem: - self.ax1.removeDelRoiItem(item) - - def removeDelROI(self, event): - posData = self.data[self.pos_i] - - for ax in (self.ax1, self.ax2): - try: - self.ax1.removeDelRoiItem(self.roi_to_del) - except Exception as err: - pass - - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - idx = delROIs_info['rois'].index(self.roi_to_del) - delROIs_info['rois'].pop(idx) - delROIs_info['delMasks'].pop(idx) - delROIs_info['delIDsROI'].pop(idx) - delROIs_info['state'].pop(idx) - - self.removeDelROIFromFutureFrames(self.roi_to_del) - self.updateAllImages() - - def removeDelROIFromFutureFrames(self, roi_to_del): - posData = self.data[self.pos_i] - - # Restore deleted IDs from already visited future frames - current_frame_i = posData.frame_i - for i in range(posData.frame_i+1, posData.SizeT): - if posData.allData_li[i]['labels'] is None: - break - - delROIs_info = posData.allData_li[i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi_to_del) - except IndexError: - continue - - posData.frame_i = i - idx = delROIs_info['rois'].index(roi_to_del) - if delROIs_info['delIDsROI'][idx]: - posData.lab = posData.allData_li[i]['labels'] - self.restoreAnnotDelROI(roi_to_del, enforce=True, draw=False) - posData.allData_li[i]['labels'] = posData.lab - self.get_data() - self.store_data(autosave=False) - delROIs_info['rois'].pop(idx) - delROIs_info['delMasks'].pop(idx) - delROIs_info['delIDsROI'].pop(idx) - delROIs_info['state'].pop(idx) - - if isinstance(self.roi_to_del, pg.PolyLineROI): - # PolyLine ROIs are only on ax1 - self.ax1.removeItem(self.roi_to_del) - elif not self.labelsGrad.showLabelsImgAction.isChecked(): - # Rect ROI is on ax1 because ax2 is hidden - self.ax1.removeItem(self.roi_to_del) - else: - # Rect ROI is on ax2 because ax2 is visible - self.ax2.removeItem(self.roi_to_del) - - # Back to current frame - posData.frame_i = current_frame_i - posData.lab = posData.allData_li[posData.frame_i]['labels'] - self.get_data() - self.store_data() - - def updateDelROIinFutureFrames(self, roi: pg.ROI): - posData = self.data[self.pos_i] - restore_current_frame = False - - roiState = roi.getState() - # Restore deleted IDs from already visited future frames - current_frame_i = posData.frame_i - delROIs_info = posData.allData_li[current_frame_i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi) - delROIs_info['state'][idx] = roiState - except Exception as err: - pass - - self.store_data() - - for i in range(posData.frame_i+1, posData.SizeT): - delROIs_info = posData.allData_li[i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi) - except Exception as err: - continue - delROIs_info['state'][idx] = roiState - if posData.allData_li[i]['labels'] is None: - continue - - posData.frame_i = i - posData.lab = posData.allData_li[i]['labels'] - self.restoreAnnotDelROI(roi, enforce=False, draw=False) - posData.allData_li[i]['labels'] = posData.lab - self.get_data() - self.store_data(autosave=False) - restore_current_frame = True - - if not restore_current_frame: - return - - # Back to current frame - posData.frame_i = current_frame_i - posData.lab = posData.allData_li[posData.frame_i]['labels'] - self.get_data() - self.store_data() - - # @exec_time - def getPolygonBrush(self, yxc2, Y, X): - # see https://en.wikipedia.org/wiki/Tangent_lines_to_circles - y1, x1 = self.yPressAx2, self.xPressAx2 - y2, x2 = yxc2 - R = self.brushSizeSpinbox.value() - r = R - - arcsin_den = np.sqrt((x2-x1)**2+(y2-y1)**2) - arctan_den = (x2-x1) - if arcsin_den!=0 and arctan_den!=0: - beta = np.arcsin((R-r)/arcsin_den) - gamma = -np.arctan((y2-y1)/arctan_den) - alpha = gamma-beta - x3 = x1 + r*np.sin(alpha) - y3 = y1 + r*np.cos(alpha) - x4 = x2 + R*np.sin(alpha) - y4 = y2 + R*np.cos(alpha) - - alpha = gamma+beta - x5 = x1 - r*np.sin(alpha) - y5 = y1 - r*np.cos(alpha) - x6 = x2 - R*np.sin(alpha) - y6 = y2 - R*np.cos(alpha) - - rr_poly, cc_poly = skimage.draw.polygon([y3, y4, y6, y5], - [x3, x4, x6, x5], - shape=(Y, X)) - else: - rr_poly, cc_poly = [], [] - - self.yPressAx2, self.xPressAx2 = y2, x2 - return rr_poly, cc_poly - - def get_dir_coords(self, alfa_dir, yd, xd, shape, connectivity=1): - h, w = shape - y_above = yd+1 if yd+1 < h else yd - y_below = yd-1 if yd > 0 else yd - x_right = xd+1 if xd+1 < w else xd - x_left = xd-1 if xd > 0 else xd - if alfa_dir == 0: - yy = [y_below, y_below, yd, y_above, y_above] - xx = [xd, x_right, x_right, x_right, xd] - elif alfa_dir == 45: - yy = [y_below, y_below, y_below, yd, y_above] - xx = [x_left, xd, x_right, x_right, x_right] - elif alfa_dir == 90: - yy = [yd, y_below, y_below, y_below, yd] - xx = [x_left, x_left, xd, x_right, x_right] - elif alfa_dir == 135: - yy = [y_above, yd, y_below, y_below, y_below] - xx = [x_left, x_left, x_left, xd, x_right] - elif alfa_dir == -180 or alfa_dir == 180: - yy = [y_above, y_above, yd, y_below, y_below] - xx = [xd, x_left, x_left, x_left, xd] - elif alfa_dir == -135: - yy = [y_below, yd, y_above, y_above, y_above] - xx = [x_left, x_left, x_left, xd, x_right] - elif alfa_dir == -90: - yy = [yd, y_above, y_above, y_above, yd] - xx = [x_left, x_left, xd, x_right, x_right] - else: - yy = [y_above, y_above, y_above, yd, y_below] - xx = [x_left, xd, x_right, x_right, x_right] - if connectivity == 1: - return yy[1:4], xx[1:4] - else: - return yy, xx - - def drawAutoContour(self, y2, x2): - y1, x1 = self.autoCont_y0, self.autoCont_x0 - Dy = abs(y2-y1) - Dx = abs(x2-x1) - edge = self.getDisplayedImg1() - if Dy != 0 or Dx != 0: - # NOTE: numIter takes care of any lag in mouseMoveEvent - numIter = int(round(max((Dy, Dx)))) - alfa = np.arctan2(y1-y2, x2-x1) - base = np.pi/4 - alfa_dir = round((base * round(alfa/base))*180/np.pi) - for _ in range(numIter): - y1, x1 = self.autoCont_y0, self.autoCont_x0 - yy, xx = self.get_dir_coords(alfa_dir, y1, x1, edge.shape) - a_dir = edge[yy, xx] - min_int = np.max(a_dir) - min_i = list(a_dir).index(min_int) - y, x = yy[min_i], xx[min_i] - try: - xx, yy = self.curvHoverPlotItem.getData() - except TypeError: - xx, yy = [], [] - - if xx is None or yy is None or len(xx) == 0 or len(yy) == 0: - xx, yy = [], [] - elif x == xx[-1] and y == yy[-1]: - # Do not append point equal to last point - return - - xx = np.r_[xx, x] - yy = np.r_[yy, y] - try: - self.curvHoverPlotItem.setData(xx, yy) - self.curvPlotItem.setData(xx, yy) - except TypeError: - pass - self.autoCont_y0, self.autoCont_x0 = y, x - # self.smoothAutoContWithSpline() - - def smoothAutoContWithSpline(self, n=3): - try: - xx, yy = self.curvHoverPlotItem.getData() - if xx is None or yy is None: - return - # Downsample by taking every nth coord - xxA, yyA = xx[::n], yy[::n] - rr, cc = skimage.draw.polygon(yyA, xxA) - self.autoContObjMask[rr, cc] = 1 - rp = skimage.measure.regionprops(self.autoContObjMask) - if not rp: - return - obj = rp[0] - cont = self.getObjContours(obj) - xxC, yyC = cont[:,0], cont[:,1] - xxA, yyA = xxC[::n], yyC[::n] - self.xxA_autoCont, self.yyA_autoCont = xxA, yyA - xxS, yyS = self.getSpline(xxA, yyA, per=True, appendFirst=True) - if len(xxS)>0: - self.curvPlotItem.setData(xxS, yyS) - except (TypeError, ValueError): - pass - - def updateIsHistoryKnown(): - """ - This function is called every time the user saves and it is used - for updating the status of cells where we don't know the history - - There are three possibilities: - - 1. The cell with unknown history is a BUD - --> we don't know when that bud emerged --> 'emerg_frame_i' = -1 - 2. The cell with unknown history is a MOTHER cell - --> we don't know emerging frame --> 'emerg_frame_i' = -1 - AND generation number --> we start from 'generation_num' = 2 - 3. The cell with unknown history is a CELL in G1 - --> we don't know emerging frame --> 'emerg_frame_i' = -1 - AND generation number --> we start from 'generation_num' = 2 - AND relative's ID in the previous cell cycle --> 'relative_ID' = -1 - """ - pass - - def getStatusKnownHistoryBud(self, ID): - posData = self.data[self.pos_i] - cca_df_ID = None - for i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - is_cell_existing = is_bud_existing = ID in cca_df_i.index - if not is_cell_existing: - bud_cca_dict = base_cca_dict.copy() - bud_cca_dict['cell_cycle_stage'] = 'S' - bud_cca_dict['generation_num'] = 0 - bud_cca_dict['relationship'] = 'bud' - bud_cca_dict['emerg_frame_i'] = i+1 - bud_cca_dict['is_history_known'] = True - cca_df_ID = pd.Series(bud_cca_dict) - return cca_df_ID - - def setHistoryKnowledge(self, ID, cca_df): - posData = self.data[self.pos_i] - is_history_known = cca_df.at[ID, 'is_history_known'] - if is_history_known: - cca_df.at[ID, 'is_history_known'] = False - cca_df.at[ID, 'cell_cycle_stage'] = 'G1' - cca_df.at[ID, 'generation_num'] += 2 - cca_df.at[ID, 'emerg_frame_i'] = -1 - cca_df.at[ID, 'relative_ID'] = -1 - cca_df.at[ID, 'relationship'] = 'mother' - else: - cca_df.loc[ID] = posData.ccaStatus_whenEmerged[ID] - - def annotateIsHistoryKnown(self, ID): - """ - This function is used for annotating that a cell has unknown or known - history. Cells with unknown history are for example the cells already - present in the first frame or cells that appear in the frame from - outside of the field of view. - - With this function we simply set 'is_history_known' to False. - When the users saves instead we update the entire staus of the cell - with unknown history with the function "updateIsHistoryKnown()" - """ - posData = self.data[self.pos_i] - is_history_known = posData.cca_df.at[ID, 'is_history_known'] - relID = posData.cca_df.at[ID, 'relative_ID'] - if relID in posData.cca_df.index: - relID_cca = self.getStatus_RelID_BeforeEmergence(ID, relID) - - if is_history_known: - # Save status of ID when emerged to allow undoing - statusID_whenEmerged = self.getStatusKnownHistoryBud(ID) - if statusID_whenEmerged is None: - return - posData.ccaStatus_whenEmerged[ID] = statusID_whenEmerged - - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - - if ID not in posData.ccaStatus_whenEmerged: - self.warnSettingHistoryKnownCellsFirstFrame(ID) - return - - self.setHistoryKnowledge(ID, posData.cca_df) - - if relID in posData.cca_df.index: - # If the cell with unknown history has a relative ID assigned to it - # we set the cca of it to the status it had BEFORE the assignment - posData.cca_df.loc[relID] = relID_cca - - # Update cell cycle info LabelItems - obj_idx = posData.IDs.index(ID) - rp_ID = posData.rp[obj_idx] - - if relID in posData.IDs: - relObj_idx = posData.IDs.index(relID) - rp_relID = posData.rp[relObj_idx] - - self.setAllTextAnnotations() - self.drawAllMothBudLines() - - self.store_cca_df() - - if self.ccaTableWin is not None: - zoomIDs = self.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - # Correct future frames - for i in range(posData.frame_i+1, posData.SizeT): - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - self.storeUndoRedoCca(i, cca_df_i, undoId) - IDs = cca_df_i.index - if ID not in IDs: - # For some reason ID disappeared from this frame - continue - else: - self.setHistoryKnowledge(ID, cca_df_i) - if relID in IDs: - cca_df_i.loc[relID] = relID_cca - self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - - - # Correct past frames - for i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - self.storeUndoRedoCca(i, cca_df_i, undoId) - IDs = cca_df_i.index - if ID not in IDs: - # we reached frame where ID was not existing yet - break - else: - relID = cca_df_i.at[ID, 'relative_ID'] - self.setHistoryKnowledge(ID, cca_df_i) - if relID in IDs: - cca_df_i.loc[relID] = relID_cca - self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - - self.enqAutosave() - - def annotateWillDivide(self, ID, relID, frame_i=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - # Store in the past frames that division has been annotated - for past_frame_i in range(frame_i-1, -1, -1): - past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True) - if past_cca_df is None: - return - - if ID not in past_cca_df.index: - # ID is a bud and is not emerged yet here - return - - if frame_i-1 == past_frame_i: - # Get generation number at first iteration - gen_num = past_cca_df.at[ID, 'generation_num'] - - if past_cca_df.at[ID, 'generation_num'] != gen_num: - # ID is a mother and the cell cycle is finished here - return - - past_cca_df.at[ID, 'will_divide'] = 1 - past_cca_df.at[relID, 'will_divide'] = 1 - - self.store_cca_df( - cca_df=past_cca_df, frame_i=past_frame_i, autosave=False - ) - - def annotateDivisionFutureFramesSwapMothers( - self, cca_df_at_future_division, mothIDofDisappearedBud, frame_i - ): - """This method is called as part of `guiWin.swapMothers`. - - It annotates cell division and propagates that to future frames to the - mother cell that stops having the correct bud because division between - wrong bud and other wrong mother was annotated in the future. - - Parameters - ---------- - cca_df_at_future_division : pd.DataFrame - _description_ - mothIDofDisappearedBud : int - Mother ID of the disappeared bud - frame_i : int - Frame since when the mother ID stops having the correct bud because - the correct bud was assigned as divided from the wrong mother - """ - posData = self.data[self.pos_i] - - relativeIDofMothID = cca_df_at_future_division.at[ - mothIDofDisappearedBud, 'relative_ID' - ] - if relativeIDofMothID not in cca_df_at_future_division.index: - # Also wrong bud ID disappeared - return - - relativeIDofMothIDrelationship = cca_df_at_future_division.at[ - relativeIDofMothID, 'relationship' - ] - if relativeIDofMothIDrelationship != 'bud': - # The wrong bud ID is a cell in G1 from future cycle --> - # the actual wrong bud ID disappeared too. - return - - wrongBudID = relativeIDofMothID - - self.annotateDivision( - cca_df_at_future_division, mothIDofDisappearedBud, wrongBudID, - frame_i=frame_i - ) - cca_df_at_future_division.at[ - mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i - self.store_cca_df( - frame_i=frame_i, cca_df=cca_df_at_future_division, autosave=False - ) - - ccaStatusToRestore = cca_df_at_future_division.loc[mothIDofDisappearedBud] - for future_i in range(frame_i+1, posData.SizeT): - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - ccs = cca_df_i.at[mothIDofDisappearedBud, 'cell_cycle_stage'] - if ccs == 'G1': - # Mother cell in G1 again, stop correcting - break - - cca_df_i.loc[mothIDofDisappearedBud] = ccaStatusToRestore - cca_df_i.at[mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i - - self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) - - def annotateDivision(self, cca_df, ID, relID, frame_i=None): - # Correct as follows: - # For frame_i > 0 --> assign to G1 and +1 on generation number - # For frame == 0 --> reinitialize to unknown cells - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - self.annotateWillDivide(ID, relID) - - store = False - cca_df.at[ID, 'cell_cycle_stage'] = 'G1' - cca_df.at[relID, 'cell_cycle_stage'] = 'G1' - - if frame_i > 0: - gen_num_clickedID = cca_df.at[ID, 'generation_num'] - cca_df.at[ID, 'generation_num'] += 1 - cca_df.at[ID, 'division_frame_i'] = frame_i - gen_num_relID = cca_df.at[relID, 'generation_num'] - cca_df.at[relID, 'generation_num'] = gen_num_relID+1 - cca_df.at[relID, 'division_frame_i'] = frame_i - if gen_num_clickedID < gen_num_relID: - cca_df.at[ID, 'relationship'] = 'mother' - else: - cca_df.at[relID, 'relationship'] = 'mother' - else: - cca_df.at[ID, 'generation_num'] = 2 - cca_df.at[relID, 'generation_num'] = 2 - - cca_df.at[ID, 'division_frame_i'] = -1 - cca_df.at[relID, 'division_frame_i'] = -1 - - cca_df.at[ID, 'relationship'] = 'mother' - cca_df.at[relID, 'relationship'] = 'mother' - - store = True - return store - - def undoDivisionAnnotation(self, cca_df, ID, relID): - # Correct as follows: - # If G1 then correct to S and -1 on generation number - store = False - cca_df.at[ID, 'cell_cycle_stage'] = 'S' - gen_num_clickedID = cca_df.at[ID, 'generation_num'] - cca_df.at[ID, 'generation_num'] -= 1 - cca_df.at[ID, 'division_frame_i'] = -1 - cca_df.at[relID, 'cell_cycle_stage'] = 'S' - gen_num_relID = cca_df.at[relID, 'generation_num'] - cca_df.at[relID, 'generation_num'] -= 1 - cca_df.at[relID, 'division_frame_i'] = -1 - if gen_num_clickedID < gen_num_relID: - cca_df.at[ID, 'relationship'] = 'bud' - else: - cca_df.at[relID, 'relationship'] = 'bud' - cca_df.at[ID, 'will_divide'] = 0 - cca_df.at[relID, 'will_divide'] = 0 - store = True - return store - - def undoBudMothAssignment(self, ID): - posData = self.data[self.pos_i] - relID = posData.cca_df.at[ID, 'relative_ID'] - ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] - if ccs == 'G1': - return - posData.cca_df.at[ID, 'relative_ID'] = -1 - posData.cca_df.at[ID, 'generation_num'] = 2 - posData.cca_df.at[ID, 'cell_cycle_stage'] = 'G1' - posData.cca_df.at[ID, 'relationship'] = 'mother' - if relID in posData.cca_df.index: - posData.cca_df.at[relID, 'relative_ID'] = -1 - posData.cca_df.at[relID, 'generation_num'] = 2 - posData.cca_df.at[relID, 'cell_cycle_stage'] = 'G1' - posData.cca_df.at[relID, 'relationship'] = 'mother' - - obj_idx = posData.IDs.index(ID) - relObj_idx = posData.IDs.index(relID) - rp_ID = posData.rp[obj_idx] - rp_relID = posData.rp[relObj_idx] - - self.store_cca_df() - - # Update cell cycle info LabelItems - self.setAllTextAnnotations() - - if self.ccaTableWin is not None: - zoomIDs = self.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - @exception_handler - def manualCellCycleAnnotation(self, ID): - """ - This function is used for both annotating division or undoing the - annotation. It can be called on any frame. - - If we annotate division (right click on a cell in S) then it will - check if there are future frames to correct. - Frames to correct are those frames where both the mother and the bud - are annotated as S phase cells. - In this case we assign all those frames to G1, relationship to mother, - and +1 generation number - - If we undo the annotation (right click on a cell in G1) then it will - correct both past and future annotated frames (if present). - Frames to correct are those frames where both the mother and the bud - are annotated as G1 phase cells. - In this case we assign all those frames to G1, relationship back to - bud, and -1 generation number - """ - posData = self.data[self.pos_i] - - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - - # Correct current frame - clicked_ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] - relID = posData.cca_df.at[ID, 'relative_ID'] - - if relID not in posData.IDs: - return - - if clicked_ccs == 'G1' and posData.frame_i == 0: - # We do not allow undoing division annotation on first frame - return - - if clicked_ccs == 'G1': - issue_frame_i = self.checkDivisionCanBeUndone(ID, relID) - if issue_frame_i is not None: - _warnings.warnDivisionAnnotationCannotBeUndone( - ID, relID, issue_frame_i, qparent=self - ) - return - - if clicked_ccs == 'S': - self.annotateDivision(posData.cca_df, ID, relID) - self.store_cca_df() - else: - self.undoDivisionAnnotation(posData.cca_df, ID, relID) - self.store_cca_df() - - # Update cell cycle info LabelItems - self.ax1_newMothBudLinesItem.setData([], []) - self.ax1_oldMothBudLinesItem.setData([], []) - self.ax2_newMothBudLinesItem.setData([], []) - self.ax2_oldMothBudLinesItem.setData([], []) - self.drawAllMothBudLines() - self.setAllTextAnnotations() - - if self.ccaTableWin is not None: - zoomIDs = self.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - # Correct future frames - for future_i in range(posData.frame_i+1, posData.SizeT): - cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - self.storeUndoRedoCca(future_i, cca_df_i, undoId) - IDs = cca_df_i.index - if ID not in IDs: - # For some reason ID disappeared from this frame - continue - - ccs = cca_df_i.at[ID, 'cell_cycle_stage'] - relID = cca_df_i.at[ID, 'relative_ID'] - if clicked_ccs == 'S': - if ccs == 'G1': - # Cell is in G1 in the future again so stop annotating - break - self.annotateDivision(cca_df_i, ID, relID) - self.store_cca_df( - frame_i=future_i, cca_df=cca_df_i, autosave=False - ) - elif ccs == 'S': - # Cell is in S in the future again so stop undoing (break) - # also leave a 1 frame duration G1 to avoid a continuous - # S phase - self.annotateDivision(cca_df_i, ID, relID) - self.store_cca_df( - frame_i=future_i, cca_df=cca_df_i, autosave=False - ) - break - else: - self.undoDivisionAnnotation(cca_df_i, ID, relID) - self.store_cca_df( - frame_i=future_i, cca_df=cca_df_i, autosave=False - ) - - # Correct past frames - for past_i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - if ID not in cca_df_i.index or relID not in cca_df_i.index: - # Bud did not exist at frame_i = i - break - - self.storeUndoRedoCca(past_i, cca_df_i, undoId) - ccs = cca_df_i.at[ID, 'cell_cycle_stage'] - relID = cca_df_i.at[ID, 'relative_ID'] - if ccs == 'S': - # We correct only those frames in which the ID was in 'G1' - break - else: - store = self.undoDivisionAnnotation(cca_df_i, ID, relID) - self.store_cca_df( - frame_i=past_i, cca_df=cca_df_i, autosave=False - ) - - self.enqAutosave() - - def warnMotherNotEligible(self, new_mothID, budID, i, why): - if why == 'not_G1_in_the_future': - err_msg = html_utils.paragraph(f""" - The requested cell in G1 (ID={new_mothID}) - at future frame {i+1} has a bud assigned to it, - therefore it cannot be assigned as the mother - of bud ID {budID}.

- You can assign a cell as the mother of bud ID {budID} - only if this cell is in G1 for the - entire life of the bud.

- One possible solution is to click on "cancel", go to - frame {i+1} and assign the bud of cell {new_mothID} - to another cell.\n' - A second solution is to assign bud ID {budID} to cell - {new_mothID} anyway by clicking "Apply".

- However to ensure correctness of - future assignments Cell-ACDC will delete any cell cycle - information from frame {i+1} to the end. Therefore, you - will have to visit those frames again.

- The deletion of cell cycle information - CANNOT BE UNDONE! - Saved data is not changed of course.

- Apply assignment or cancel process? - """) - applyButton = widgets.okPushButton(isDefault=False) - applyButton.setText('Apply and remove future annotations') - msg = widgets.myMessageBox() - _, applyButton = msg.warning( - self, 'Cell not eligible', err_msg, - buttonsTexts=('Cancel', applyButton) - ) - cancel = msg.cancel - apply = msg.clickedButton == applyButton - elif why == 'not_G1_in_the_past': - err_msg = html_utils.paragraph(f""" - The requested cell in G1 - (ID={new_mothID}) at past frame {i+1} - has a bud assigned to it, therefore it cannot be - assigned as mother of bud ID {budID}.
- You can assign a cell as the mother of bud ID {budID} - only if this cell is in G1 for the entire life of the bud.
- One possible solution is to first go to frame {i+1} and - assign the bud of cell {new_mothID} to another cell. - """) - msg = widgets.myMessageBox() - msg.warning( - self, 'Cell not eligible', err_msg - ) - cancel = msg.cancel - apply = False - elif why == 'single_frame_G1_duration': - err_msg = html_utils.paragraph(f""" - Assigning bud ID {budID} to cell ID {new_mothID} would result - in no G1 phase at all between previous cell cycle and - current cell cycle (see frame n. {i+1}).

- - The solution is to annotate division on cell ID {new_mothID} - on any frame before the frame number {i+1}, and then - proceed to correcting the bud assignment.

- - This will gurantee a G1 duration for the cell {new_mothID} - of at least 1 frame.

- Thank you for your patience! - """) - msg = widgets.myMessageBox() - msg.warning( - self, 'Cell not eligible', err_msg - ) - cancel = msg.cancel - apply = False - return cancel, apply - - def warnSettingHistoryKnownCellsFirstFrame(self, ID): - txt = html_utils.paragraph(f""" - Cell ID {ID} is a cell that is present since the first - frame.

- These cells already have history UNKNOWN assigned and the - history status cannot be changed. - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning( - self, 'First frame cells', txt - ) - - def checkMothEligibility(self, budID, new_mothID): - """ - Check that the new mother is in G1 for the entire life of the bud - and that the G1 duration is > than 1 frame - """ - last_cca_frame_i = self.navigateScrollBar.maximum()-1 - posData = self.data[self.pos_i] - eligible = True - - # Check future frames - G1_duration_future = 0 - for future_i in range(posData.frame_i, posData.SizeT): - cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) - - if cca_df_i is None: - # ith frame was not visited yet - break - - if budID not in cca_df_i.index: - # Bud disappeared - break - - is_still_bud = cca_df_i.at[budID, 'relationship'] == 'bud' - if not is_still_bud: - break - - ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] - if ccs != 'G1': - cancel, apply = self.warnMotherNotEligible( - new_mothID, budID, future_i, 'not_G1_in_the_future' - ) - if apply: - self.resetCcaFuture(future_i) - break - isG1singleFrame = G1_duration_future == 1 - isFutureFrameNotLastAnnot = future_i != last_cca_frame_i - if cancel or (isG1singleFrame and isFutureFrameNotLastAnnot): - eligible = False - return eligible - - G1_duration_future += 1 - - # Check past frames - for past_i in range(posData.frame_i-1, -1, -1): - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - - is_bud_existing = budID in cca_df_i.index - is_moth_existing = new_mothID in cca_df_i.index - - if not is_moth_existing: - # Mother not existing because it appeared from outside FOV - break - - ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] - if ccs != 'G1' and is_bud_existing: - # Requested mother not in G1 in the past - # during the life of the bud (is_bud_existing = True) - self.warnMotherNotEligible( - new_mothID, budID, past_i, 'not_G1_in_the_past' - ) - eligible = False - return eligible - - if not is_bud_existing: - # Bud stop existing --> check that mother is still in G1 - if ccs != 'G1': - eligible = False - self.warnMotherNotEligible( - new_mothID, budID, past_i, 'single_frame_G1_duration' - ) - break - - return eligible - - def checkMothersExcludedOrDead(self): - try: - posData = self.data[self.pos_i] - buds_df = posData.cca_df[ - (posData.cca_df.relationship == 'bud') - & (posData.cca_df.emerg_frame_i == posData.frame_i) - ] - acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] - moth_df = acdc_df_i.loc[buds_df.relative_ID.to_list()] - excluded_df = moth_df[ - (moth_df.is_cell_dead > 0) | (moth_df.is_cell_excluded > 0) - ] - excludedMothIDs = excluded_df.index.to_list() - if not excludedMothIDs: - self.stopBlinkingPairItem() - return True - budIDsOfExcludedMoth = excluded_df.relative_ID.to_list() - proceed = self.warnDeadOrExcludedMothers( - budIDsOfExcludedMoth, excludedMothIDs - ) - return proceed - except Exception as e: - self.logger.info(traceback.format_exc()) - print('-'*100) - self.logger.warning( - 'Checking if mother cell is excluded or dead failed.' - ) - print('^'*100) - return False - - def checkDivisionCanBeUndone(self, ID, relID): - """Check that division annotation can be undone (see Notes section) - - Parameters - ---------- - ID : int - Cell ID of the clicked cell in G1 - relID : _type_ - Relative ID of the cell that was clicked - - Notes - ----- - Division annotation can be undone only if `relID` is also in G1 for the - entire duration of the correction - """ - posData = self.data[self.pos_i] - - ccs_relID = posData.cca_df.at[relID, 'cell_cycle_stage'] - if ccs_relID == 'S': - return posData.frame_i - - # Check future frames - for future_i in range(posData.frame_i+1, posData.SizeT): - cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - ccs_relID = cca_df_i.at[relID, 'cell_cycle_stage'] - if ccs_relID == 'S': - return future_i - - # Check past frames - for past_i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - if ID not in cca_df_i.index or relID not in cca_df_i.index: - # Bud did not exist at frame_i = i - break - - ccs = cca_df_i.at[ID, 'cell_cycle_stage'] - if ccs == 'S': - break - - ccs_relID = cca_df_i.at[relID, 'cell_cycle_stage'] - if ccs_relID == 'S': - return future_i - - - def stopBlinkingPairItem(self): - self.ax1_newMothBudLinesItem.setOpacity(1.0) - self.ax1_oldMothBudLinesItem.setOpacity(1.0) - - self.warnPairingItem.setData([], []) - try: - self.blinkPairingItemTimer.stop() - except Exception as e: - pass - - def warnDeadOrExcludedMothers(self, budIDs, mothIDs): - self.startBlinkingPairingItem(budIDs, mothIDs) - msg = widgets.myMessageBox(wrapText=False) - pairings = [ - f'Mother ID {mID} --> bud ID {bID}' - for mID, bID in zip(mothIDs, budIDs) - ] - txt = html_utils.paragraph(f""" - The mother cell in the following mother-bud pairings - (blinking line on the image) is
- excluded from the analysis or dead: - {html_utils.to_list(pairings)} - """) - msg.warning( - self, 'Mother cell is excluded or dead', txt, - buttonsTexts=('Cancel', 'Ok') - ) - return not msg.cancel - - def startBlinkingPairingItem(self, budIDs, mothIDs): - self.ax1_newMothBudLinesItem.setOpacity(0.2) - self.ax1_oldMothBudLinesItem.setOpacity(0.2) - - posData = self.data[self.pos_i] - acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] - - # Blink one pairing at the time (the first found) - xc_b = acdc_df_i.loc[budIDs[0], 'x_centroid'] - yc_b = acdc_df_i.loc[budIDs[0], 'y_centroid'] - - xc_m = acdc_df_i.loc[mothIDs[0], 'x_centroid'] - yc_m = acdc_df_i.loc[mothIDs[0], 'y_centroid'] - - self.warnPairingItem.setData([xc_b, xc_m], [yc_b, yc_m]) - - self.blinkPairingItemTimer = QTimer() - self.blinkPairingItemTimer.flag = True - self.blinkPairingItemTimer.timeout.connect(self.blinkPairingItem) - self.blinkPairingItemTimer.start(300) - - def blinkPairingItem(self): - if self.blinkPairingItemTimer.flag: - opacity = 0.3 - self.blinkPairingItemTimer.flag = False - else: - opacity = 1.0 - self.blinkPairingItemTimer.flag = True - self.warnPairingItem.setOpacity(opacity) - - def getStatus_RelID_BeforeEmergence(self, budID, curr_mothID): - posData = self.data[self.pos_i] - # Get status of the current mother before it had budID assigned to it - cca_status_before_bud_emerg = None - for i in range(posData.frame_i-1, -1, -1): - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - - is_bud_existing = budID in cca_df_i.index - if not is_bud_existing: - # Bud was not emerged yet - if curr_mothID in cca_df_i.index: - cca_status_before_bud_emerg = cca_df_i.loc[curr_mothID] - return cca_status_before_bud_emerg - else: - # The bud emerged together with the mother because - # they appeared together from outside of the fov - # and they were trated as new IDs bud in S0 - bud_cca_dict = base_cca_dict.copy() - bud_cca_dict['cell_cycle_stage'] = 'S' - bud_cca_dict['generation_num'] = 0 - bud_cca_dict['relationship'] = 'bud' - bud_cca_dict['emerg_frame_i'] = i+1 - bud_cca_dict['is_history_known'] = True - cca_status_before_bud_emerg = pd.Series(bud_cca_dict) - return cca_status_before_bud_emerg - - # Mother did not have a status before bud emergence because it was - # already paired with bud at first frame --> reinit to default - cca_status_before_bud_emerg = ( - core.getBaseCca_df([curr_mothID]).loc[curr_mothID] - ) - return cca_status_before_bud_emerg - - - def annotateBudToDifferentMother(self): - """ - This function is used for correcting automatic mother-bud assignment. - - It can be called at any frame of the bud life. - - There are three cells involved: bud, current mother, new mother. - - Eligibility: - - User clicked first on a bud (checked at click time) - - User released mouse button on a cell in G1 (checked at release time) - - The new mother MUST be in G1 for all the frames of the bud life - --> if not warn - - The new mother MUST have appeared in current frame OR be already - in G1 in previous frame, otherwise there would be no G1 cycle - - Result: - - The bud only changes relative ID to the new mother - - The new mother changes relative ID and stage to 'S' - - The old mother changes its entire status to the status it had - before being assigned to the clicked bud - """ - posData = self.data[self.pos_i] - lab2D = self.get_2Dlab(posData.lab) - budID = lab2D[self.yClickBud, self.xClickBud] - new_mothID = lab2D[self.yClickMoth, self.xClickMoth] - - if budID == new_mothID: - return - - if not self.isSnapshot: - eligible = self.checkMothEligibility(budID, new_mothID) - if not eligible: - return - - budEligible = self.checkChangeMotherBudEligible( - budID, posData.frame_i - ) - if not budEligible: - return - - # Allow partial initialization of cca_df with mouse - if posData.frame_i == 0: - newMothCcs = posData.cca_df.at[new_mothID, 'cell_cycle_stage'] - if not newMothCcs == 'G1': - err_msg = ( - 'You are assigning the bud to a cell that is not in G1!' - ) - msg = QMessageBox() - msg.critical( - self, 'New mother not in G1!', err_msg, msg.Ok - ) - return - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(0, posData.cca_df, undoId) - currentRelID = posData.cca_df.at[budID, 'relative_ID'] - if currentRelID in posData.cca_df.index: - posData.cca_df.at[currentRelID, 'relative_ID'] = -1 - posData.cca_df.at[currentRelID, 'generation_num'] = 2 - posData.cca_df.at[currentRelID, 'cell_cycle_stage'] = 'G1' - posData.cca_df.at[budID, 'relationship'] = 'bud' - posData.cca_df.at[budID, 'generation_num'] = 0 - posData.cca_df.at[budID, 'relative_ID'] = new_mothID - posData.cca_df.at[budID, 'cell_cycle_stage'] = 'S' - posData.cca_df.at[new_mothID, 'relative_ID'] = budID - posData.cca_df.at[new_mothID, 'generation_num'] = 2 - posData.cca_df.at[new_mothID, 'cell_cycle_stage'] = 'S' - self.updateAllImages() - self.store_cca_df() - return - - curr_mothID = posData.cca_df.at[budID, 'relative_ID'] - if curr_mothID in posData.cca_df.index: - curr_moth_cca = self.getStatus_RelID_BeforeEmergence( - budID, curr_mothID - ) - - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - - # Correct current frames and update LabelItems - posData.cca_df.at[budID, 'relative_ID'] = new_mothID - posData.cca_df.at[budID, 'generation_num'] = 0 - posData.cca_df.at[budID, 'relative_ID'] = new_mothID - posData.cca_df.at[budID, 'relationship'] = 'bud' - posData.cca_df.at[budID, 'corrected_on_frame_i'] = posData.frame_i - posData.cca_df.at[budID, 'cell_cycle_stage'] = 'S' - - posData.cca_df.at[new_mothID, 'relative_ID'] = budID - posData.cca_df.at[new_mothID, 'cell_cycle_stage'] = 'S' - posData.cca_df.at[new_mothID, 'relationship'] = 'mother' - - - if curr_mothID in posData.cca_df.index: - # Cells with UNKNOWN history has relative's ID = -1 - # which is not an existing cell - posData.cca_df.loc[curr_mothID] = curr_moth_cca - - self.updateAllImages() - - # self.checkMultiBudMoth(draw=True) - self.store_cca_df() - proceed = self.checkMothersExcludedOrDead() - if not proceed: - # User clicked on cancel in the message box - self.UndoCca() - return - - if self.ccaTableWin is not None: - zoomIDs = self.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - # Correct future frames - for i in range(posData.frame_i+1, posData.SizeT): - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - IDs = cca_df_i.index - if budID not in IDs or new_mothID not in IDs: - # For some reason ID disappeared from this frame - continue - - self.storeUndoRedoCca(i, cca_df_i, undoId) - bud_relationship = cca_df_i.at[budID, 'relationship'] - bud_ccs = cca_df_i.at[budID, 'cell_cycle_stage'] - - if bud_relationship == 'mother' and bud_ccs == 'S': - # The bud at the ith frame budded itself --> stop - break - - cca_df_i.at[budID, 'relative_ID'] = new_mothID - cca_df_i.at[budID, 'generation_num'] = 0 - cca_df_i.at[budID, 'relative_ID'] = new_mothID - cca_df_i.at[budID, 'relationship'] = 'bud' - cca_df_i.at[budID, 'cell_cycle_stage'] = 'S' - - newMoth_bud_ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] - if newMoth_bud_ccs == 'G1': - # Assign bud to new mother only if the new mother is in G1 - # This can happen if the bud already has a G1 annotated - cca_df_i.at[new_mothID, 'relative_ID'] = budID - cca_df_i.at[new_mothID, 'cell_cycle_stage'] = 'S' - cca_df_i.at[new_mothID, 'relationship'] = 'mother' - - if curr_mothID in cca_df_i.index: - # Cells with UNKNOWN history has relative's ID = -1 - # which is not an existing cell - cca_df_i.loc[curr_mothID] = curr_moth_cca - - self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - - # Correct past frames - for i in range(posData.frame_i-1, -1, -1): - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - - is_bud_existing = budID in cca_df_i.index - if not is_bud_existing: - # Bud was not emerged yet - break - - self.storeUndoRedoCca(i, cca_df_i, undoId) - cca_df_i.at[budID, 'relative_ID'] = new_mothID - cca_df_i.at[budID, 'generation_num'] = 0 - cca_df_i.at[budID, 'relative_ID'] = new_mothID - cca_df_i.at[budID, 'relationship'] = 'bud' - cca_df_i.at[budID, 'cell_cycle_stage'] = 'S' - - cca_df_i.at[new_mothID, 'relative_ID'] = budID - cca_df_i.at[new_mothID, 'cell_cycle_stage'] = 'S' - cca_df_i.at[new_mothID, 'relationship'] = 'mother' - - if curr_mothID in cca_df_i.index: - # Cells with UNKNOWN history has relative's ID = -1 - # which is not an existing cell - cca_df_i.loc[curr_mothID] = curr_moth_cca - - self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - - self.enqAutosave() - - def onMotherNotInG1(self, mothID): - txt = html_utils.paragraph( - f'You clicked on ID={mothID} which is NOT in G1

' - 'Do you want to proceed with swapping the mother cells?

' - 'NOTE: To assign a bud start by clicking on the bud ' - 'and release on a cell in G1' - ) - msg = widgets.myMessageBox() - swapMothersButton = widgets.reloadPushButton('Swap mother cells') - _, swapMothersButton = msg.warning( - self, 'Released on a cell NOT in G1', txt, - buttonsTexts=('Cancel', swapMothersButton) - ) - if msg.cancel: - return - - pairings = self.checkSwapMothersEligibility() - if pairings is None: - self.logger.info('Swapping mothers is not possible.') - return - - self.swapMothers(*pairings) - - def _checkBudFutureNoDivision(self, budID, start_frame_i): - posData = self.data[self.pos_i] - - future_i = start_frame_i - for future_i in range(start_frame_i, posData.SizeT): - if future_i == 0: - continue - - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - return - - if budID not in cca_df_i.index: - # Bud disappears in the future --> fine - return - - ccs = cca_df_i.at[budID, 'cell_cycle_stage'] - if ccs == 'G1': - return future_i, cca_df_i.at[budID, 'relative_ID'] - - def warnBudAnnotatedDividedInFuture( - self, budID, motherID, future_division_frame_i, - action='swap mother cells' - ): - posData = self.data[self.pos_i] - - txt = html_utils.paragraph(f""" - Bud ID {budID} is annotated as divided from mother ID {motherID} - at frame n. {future_division_frame_i+1},
- therefore it is not possible to {action}.

- We recommend reinitializing cell cycle annotations on any - frame
between frames number {posData.frame_i+1} and - {future_division_frame_i} before attempting to {action}.

- Thank you for your patience! - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, f'{action} not possible'.title(), txt) - return - - def _checkMothInG1beforeBudEmergence( - self, motherID, budID, wrongBudID, start_frame_i - ): - """Check that mother is in G1 on the frame before bud emergence - - Parameters - ---------- - motherID : int - ID of mother cell - budID : int - ID of bud - start_frame_i : int - Frame index from which to start checking in the past - """ - for past_i in range(start_frame_i, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - if budID not in cca_df_i.index: - if cca_df_i.at[motherID, 'cell_cycle_stage'] == 'G1': - return - - budID_prev_cycle = cca_df_i.at[motherID, 'relative_ID'] - if budID_prev_cycle != wrongBudID: - return past_i + 1 - - break - - def warnMotherNotAtLeastOneFrameG1(self, budID, motherID, frame_no_G1): - posData = self.data[self.pos_i] - - txt = html_utils.paragraph(f""" - Assigning bud ID {budID} to cell ID {motherID} cannot be - done because cell ID {motherID} is not in G1 at frame n. - {frame_no_G1}.

- This would result in no G1 phase between previous cell cycle of - cell ID {motherID} and current one. - This is unfortunately not allowed.

- One possible solution is to annotate division on cell ID - {motherID} on any frame before frame n. {frame_no_G1}.

- Thank you for your patience! - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Swap mothers not possible', txt) - return - - def checkChangeMotherBudEligible(self, budID, frame_i): - result = self._checkBudFutureNoDivision(budID, frame_i) - if result is None: - return True - - self.warnBudAnnotatedDividedInFuture( - budID, *result, action='change mother cell' - ) - return False - - def checkSwapMothersEligibility(self): - posData = self.data[self.pos_i] - - lab2D = self.get_2Dlab(posData.lab) - budID = lab2D[self.yClickBud, self.xClickBud] - otherMothID = lab2D[self.yClickMoth, self.xClickMoth] - mothID = posData.cca_df.at[budID, 'relative_ID'] - otherBudID = posData.cca_df.at[otherMothID, 'relative_ID'] - - for _budID in (budID, otherBudID): - result = self._checkBudFutureNoDivision( - _budID, posData.frame_i - ) - if result is None: - continue - - self.warnBudAnnotatedDividedInFuture(_budID, *result) - return - - correct_pairings = { - otherBudID: mothID, budID: otherMothID - } - wrong_pairings = { - mothID: budID, otherMothID: otherBudID - } - for correctBudID, correctMothID in correct_pairings.items(): - wrongBudID = wrong_pairings[correctMothID] - frame_no_G1 = self._checkMothInG1beforeBudEmergence( - correctMothID, correctBudID, wrongBudID, posData.frame_i - ) - if frame_no_G1 is None: - continue - - self.warnMotherNotAtLeastOneFrameG1( - correctBudID, correctMothID, frame_no_G1 - ) - return - - return budID, otherBudID, otherMothID, mothID - - @exception_handler - def swapMothers(self, budID, otherBudID, otherMothID, mothID): - posData = self.data[self.pos_i] - - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - - self.logger.info( - f'Swapping assignments (requested at frame n. {posData.frame_i+1}):\n' - f' * Bud ID {budID} --> mother ID {otherMothID}\n' - f' * Bud ID {otherBudID} --> mother ID {mothID}' - ) - - correct_pairings = { - otherBudID: mothID, - budID: otherMothID - } - - for correct_budID, correct_mothID in correct_pairings.items(): - posData.cca_df.at[correct_budID, 'relative_ID'] = correct_mothID - posData.cca_df.at[correct_mothID, 'relative_ID'] = correct_budID - posData.cca_df.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i - posData.cca_df.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i - self.store_cca_df() - - # Correct past frames - corrected_budIDs_past = set() - for past_i in range(posData.frame_i-1, -1, -1): - if len(corrected_budIDs_past) == 2: - break - - for correct_budID, correct_mothID in correct_pairings.items(): - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - - if correct_budID in corrected_budIDs_past: - continue - - if correct_budID not in cca_df_i.index: - # Bud does not exist anymore in the past - corrected_budIDs_past.add(correct_budID) - - if len(corrected_budIDs_past) < 2: - self.restoreMotherToBeforeWrongBudWasAssignedToIt( - correct_mothID, cca_df_i, past_i - ) - continue - - cca_df_i.at[correct_budID, 'relative_ID'] = correct_mothID - cca_df_i.at[correct_mothID, 'relative_ID'] = correct_budID - cca_df_i.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i - cca_df_i.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i - - # Set mother cell cycle stage to S in case it is not - if cca_df_i.at[correct_mothID, 'cell_cycle_stage'] == 'G1': - cca_df_i.at[correct_mothID, 'cell_cycle_stage'] = 'S' - # cca_df_i.at[correct_mothID, 'generation_num'] -= 1 - - self.store_cca_df( - frame_i=past_i, cca_df=cca_df_i, autosave=False - ) - - # Correct future frames - corrected_budIDs_future = set() - for future_i in range(posData.frame_i+1, posData.SizeT): - if len(corrected_budIDs_future) == 2: - break - - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - for correct_budID, correct_mothID in correct_pairings.items(): - if correct_budID in corrected_budIDs_future: - # Bud already corrected in the future - continue - - if correct_budID not in cca_df_i.index: - # Bud disappeared in the future - corrected_budIDs_future.add(correct_budID) - continue - - ccs_bud = cca_df_i.at[correct_budID, 'cell_cycle_stage'] - if ccs_bud == 'G1': - # Bud divided in the future, annotate division between - # correct mother and wrong bud and then stop correcting - if correct_budID not in corrected_budIDs_future: - corrected_budIDs_future.add(correct_budID) - - if len(corrected_budIDs_future) < 2: - self.annotateDivisionFutureFramesSwapMothers( - cca_df_i, correct_mothID, future_i - ) - continue - - cca_df_i.at[correct_budID, 'relative_ID'] = correct_mothID - cca_df_i.at[correct_mothID, 'relative_ID'] = correct_budID - cca_df_i.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i - cca_df_i.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i - - # Set mother cell cycle stage to S in case it is not - if cca_df_i.at[correct_mothID, 'cell_cycle_stage'] == 'G1': - cca_df_i.at[correct_mothID, 'cell_cycle_stage'] = 'S' - # cca_df_i.at[correct_mothID, 'generation_num'] -= 1 - - self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) - - self.updateAllImages() - - def restoreMotherToBeforeWrongBudWasAssignedToIt( - self, mothIDofDisappearedBud, - cca_df_at_correct_bud_ID_disappearance, - frame_i - ): - """This method is called as part of `guiWin.swapMothers`. - - Parameters - ---------- - mothIDofDisappearedBud : int - Mother ID of the disappeared bud - cca_df_at_correct_bud_ID_disappearance : pd.DataFrame - Cell cycle annotations DataFrame when the correct bud ID stopped - existing (before emergence) - frame_i : int - Frame index when the correct bud ID stopped existing - (before emergence) - - Note - ---- - It restores the mother cell cycle annotations to the status it had - before the wrong bud was assigned to it. - - We need to do it only if the swapMothers past frames loop is still - iterating to correct the other bud. - - We also need to do this only if the wrong bud ID is actually a bud. - - When we swap mothers in the past frames it can be that the correct bud - ID stops existing (before emergence). In this case the correct mother - still has the wrong bud assigned to ID so we need to restore the status - it had before the wrong bud was assigned to it. - - To determine the status we go back until the wrong bud disappear. That - is the frame before the wrong bud was assigned to the mother we want to - correct. This is the status we want to restore. - - When we go back in time it could be that the wrong bud never disappears - becuase it is already emerged at frame 0. In this case the status we - want to restore at is the default G1 status at frame 0. - """ - relativeIDofMothID = cca_df_at_correct_bud_ID_disappearance.at[ - mothIDofDisappearedBud, 'relative_ID' - ] - if relativeIDofMothID not in cca_df_at_correct_bud_ID_disappearance.index: - # Also wrong bud ID disappeared - return - - relativeIDofMothIDrelationship = cca_df_at_correct_bud_ID_disappearance.at[ - relativeIDofMothID, 'relationship' - ] - if relativeIDofMothIDrelationship != 'bud': - # The wrong bud ID is a cell in G1 from previous cycle --> - # the actual wrong bud ID disappeared too. - return - - wrongBudID = relativeIDofMothID - - mothCcaBeforeWrongBudID = base_cca_dict - # Search in the past for status of mother before wrong bud emerged - for past_i in range(frame_i, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - if wrongBudID not in cca_df_i.index: - mothCcaBeforeWrongBudID = cca_df_i.loc[mothIDofDisappearedBud] - break - - # Restore in past frames the correct mother status - for past_i in range(frame_i, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - if wrongBudID in cca_df_i.index: - cca_df_i.loc[mothIDofDisappearedBud] = mothCcaBeforeWrongBudID - cca_df_i.at[mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i - self.store_cca_df( - frame_i=past_i, cca_df=cca_df_i, autosave=False - ) - else: - break - - def getClosedSplineCoords(self): - xxS, yyS = self.curvPlotItem.getData() - bbox_area = (xxS.max()-xxS.min())*(yyS.max()-yyS.min()) - if bbox_area < 26_000: - # Using 1000 is fast enough according to profiling - return xxS, yyS - - optimalSpaceSize = self.splineToObjModel.predict( - bbox_area, max_exec_time=150 - ) - if optimalSpaceSize >= 1000: - # Using 1000 is fast enough according to model - return xxS, yyS - - if optimalSpaceSize < 100: - # Do not allow a rough spline - optimalSpaceSize = 100 - - # Get spline with optimal space size so that exec time - # or skimage.draw.polygon is less than 150 ms - xx, yy = self.curvAnchors.getData() - resolutionSpace = np.linspace(0, 1, int(optimalSpaceSize)) - xxS, yyS = self.getSpline( - xx, yy, resolutionSpace=resolutionSpace, per=True - ) - return xxS, yyS - - - def getSpline(self, xx, yy, resolutionSpace=None, per=False, appendFirst=False): - # Remove duplicates - valid = np.where(np.abs(np.diff(xx)) + np.abs(np.diff(yy)) > 0) - xx = np.r_[xx[valid], xx[-1]] - yy = np.r_[yy[valid], yy[-1]] - if appendFirst: - xx = np.r_[xx, xx[0]] - yy = np.r_[yy, yy[0]] - per = True - - # Interpolate splice - if resolutionSpace is None: - resolutionSpace = self.hoverLinSpace - k = 2 if len(xx) == 3 else 3 - - try: - tck, u = scipy.interpolate.splprep( - [xx, yy], s=0, k=k, per=per - ) - xi, yi = scipy.interpolate.splev(resolutionSpace, tck) - return xi, yi - except (ValueError, TypeError): - # Catch errors where we know why splprep fails - return [], [] - - def uncheckQButton(self, button): - # Manual exclusive where we allow to uncheck all buttons - for b in self.checkableQButtonsGroup.buttons(): - if b != button: - b.setChecked(False) - - def delBorderObj(self, checked): - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - posData = self.data[self.pos_i] - posData.lab = skimage.segmentation.clear_border( - posData.lab, buffer_size=1 - ) - oldIDs = posData.IDs.copy() - self.update_rp() - removedIDs = [ID for ID in oldIDs if ID not in posData.IDs] - if posData.cca_df is not None: - posData.cca_df = posData.cca_df.drop(index=removedIDs) - self.store_data() - self.updateAllImages() - - def delNewObj(self, checked): - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - posData = self.data[self.pos_i] - frame_i = posData.frame_i - - if frame_i == 0: - return - - prev_IDs = posData.allData_li[frame_i-1]['IDs'] - curr_IDs = posData.IDs - new_IDs = list(set(curr_IDs) - set(prev_IDs)) - - lab = posData.lab - del_mask = np.isin(lab, new_IDs) - lab[del_mask] = 0 - posData.lab = lab - - self.update_rp() - - if posData.cca_df is not None: - posData.cca_df = posData.cca_df.drop(index=new_IDs) - self.store_data() - self.updateAllImages() - - def brushAutoFillToggled(self, checked): - val = 'Yes' if checked else 'No' - self.df_settings.at['brushAutoFill', 'value'] = val - self.df_settings.to_csv(self.settings_csv_path) - - def brushAutoHideToggled(self, checked): - val = 'Yes' if checked else 'No' - self.df_settings.at['brushAutoHide', 'value'] = val - self.df_settings.to_csv(self.settings_csv_path) - - def brushReleased(self): - posData = self.data[self.pos_i] - self.fillHolesID(posData.brushID, sender='brush') - - # Update data (rp, etc) - self.update_rp(update_IDs=self.isNewID,) - - # Repeat tracking - if self.autoIDcheckbox.isChecked(): - self.trackManuallyAddedObject(posData.brushID, self.isNewID) - else: - self.update_rp(update_IDs=posData.brushID not in posData.IDs_idxs) - - # Update images - if self.isNewID: - editTxt = 'Add new ID with brush tool' - if self.isSnapshot: - self.fixCcaDfAfterEdit(editTxt) - self.updateAllImages() - else: - self.warnEditingWithCca_df(editTxt) - else: - self.updateAllImages() - - self.isNewID = False - - def addDelROI(self, event): - roi, key = self.createDelROI() - self.addRoiToDelRoiInfo(roi) - if not self.labelsGrad.showLabelsImgAction.isChecked(): - self.ax1.addDelRoiItem(roi, key) - else: - self.ax2.addDelRoiItem(roi, key) - self.applyDelROIimg1(roi, init=True) - self.applyDelROIimg1(roi, init=True, ax=1) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete IDs using ROI') - self.updateAllImages() - else: - self.warnEditingWithCca_df( - 'Delete IDs using ROI', get_cancelled=True - ) - - def replacePolyLineRoiWithLineRoi(self, roi): - x0, y0 = roi.pos().x(), roi.pos().y() - (_, point1), (_, point2) = roi.getLocalHandlePositions() - xr1, yr1 = point1.x(), point1.y() - xr2, yr2 = point2.x(), point2.y() - x1, y1 = xr1+x0, yr1+y0 - x2, y2 = xr2+x0, yr2+x0 - lineRoi = pg.LineROI((x1, y1), (x2, y2), width=0.5) - lineRoi.handleSize = 7 - self.ax1.removeItem(self.polyLineRoi) - self.ax1.addItem(lineRoi) - lineRoi.removeHandle(2) - # Connect closed ROI - lineRoi.sigRegionChanged.connect(self.delROImoving) - lineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished) - return lineRoi - - def addRoiToDelRoiInfo(self, roi: pg.ROI): - posData = self.data[self.pos_i] - for i in range(posData.frame_i, posData.SizeT): - delROIs_info = posData.allData_li[i]['delROIs_info'] - delROIs_info['rois'].append(roi) - delROIs_info['state'].append(roi.getState()) - delROIs_info['delMasks'].append(np.zeros_like(self.currentLab2D)) - delROIs_info['delIDsROI'].append(set()) - - def addDelPolyLineRoi_cb(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.addDelPolyLineRoiButton) - self.connectLeftClickButtons() - if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete IDs using ROI') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Delete IDs using ROI') - else: - self.tempSegmentON = False - self.ax1_rulerPlotItem.setData([], []) - self.ax1_rulerAnchorsItem.setData([], []) - self.startPointPolyLineItem.setData([], []) - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - def createDelPolyLineRoi(self): - Y, X = self.currentLab2D.shape - self.polyLineRoi = pg.PolyLineROI( - [], rotatable=False, - removable=True, - pen=pg.mkPen(color='r') - ) - self.polyLineRoi.handleSize = 7 - self.polyLineRoi.points = [] - key = uuid.uuid4() - self.ax1.addDelRoiItem(self.polyLineRoi, key) - - def addPointsPolyLineRoi(self, closed=False): - self.polyLineRoi.setPoints(self.polyLineRoi.points, closed=closed) - if not closed: - return - - # Connect closed ROI - self.polyLineRoi.sigRegionChanged.connect(self.delROImoving) - self.polyLineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished) - - def getViewRange(self): - Y, X = self.img1.image.shape[:2] - xRange, yRange = self.ax1.viewRange() - xmin = 0 if xRange[0] < 0 else xRange[0] - ymin = 0 if yRange[0] < 0 else yRange[0] - - xmax = X if xRange[1] >= X else xRange[1] - ymax = Y if yRange[1] >= Y else yRange[1] - return int(ymin), int(ymax), int(xmin), int(xmax) - - def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None): - posData = self.data[self.pos_i] - if xl is None: - xRange, yRange = self.ax1.viewRange() - xl = 0 if xRange[0] < 0 else xRange[0] - yb = 0 if yRange[0] < 0 else yRange[0] - Y, X = self.currentLab2D.shape - if anchors is None: - roi = widgets.DelROI( - [xl, yb], [w, h], - rotatable=False, - removable=True, - pen=pg.mkPen(color='r'), - maxBounds=QRectF(QRect(0,0,X,Y)) - ) - ## handles scaling horizontally around center - roi.addScaleHandle([1, 0.5], [0, 0.5]) - roi.addScaleHandle([0, 0.5], [1, 0.5]) - - ## handles scaling vertically from opposite edge - roi.addScaleHandle([0.5, 0], [0.5, 1]) - roi.addScaleHandle([0.5, 1], [0.5, 0]) - - ## handles scaling both vertically and horizontally - roi.addScaleHandle([1, 1], [0, 0]) - roi.addScaleHandle([0, 0], [1, 1]) - roi.addScaleHandle([0, 1], [1, 0]) - roi.addScaleHandle([1, 0], [0, 1]) - - roi.handleSize = 7 - roi.sigRegionChanged.connect(self.delROImoving) - roi.sigRegionChanged.connect(self.delROIstartedMoving) - roi.sigRegionChangeFinished.connect(self.delROImovingFinished) - - key = uuid.uuid4() - - return roi, key - - def delROIstartedMoving(self, roi): - self.clearLostObjContoursItems() - - def clearLostObjContoursItems(self): - self.ax1_lostObjScatterItem.setData([], []) - self.ax2_lostObjScatterItem.setData([], []) - - self.ax1_lostTrackedScatterItem.setData([], []) - self.ax2_lostTrackedScatterItem.setData([], []) - - self.ax2_lostObjImageItem.clear() - self.ax2_lostTrackedObjImageItem.clear() - - self.ax1_lostObjImageItem.clear() - self.ax1_lostTrackedObjImageItem.clear() - - def delROImoving(self, roi): - roi.setPen(color=(255,255,0)) - # First bring back IDs if the ROI moved away - self.restoreAnnotDelROI(roi) - self.setImageImg2() - self.applyDelROIimg1(roi) - self.applyDelROIimg1(roi, ax=1) - - def delROImovingFinished(self, roi: pg.ROI): - roi.setPen(color='r') - self.update_rp() - self.updateAllImages() - QTimer.singleShot( - 300, partial(self.updateDelROIinFutureFrames, roi) - ) - - def restoreAnnotDelROI(self, roi, enforce=True, draw=True): - posData = self.data[self.pos_i] - ROImask = self.getDelRoiMask(roi) - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi) - except Exception as err: - return - - delMask = delROIs_info['delMasks'][idx] - delIDs = delROIs_info['delIDsROI'][idx] - overlapROIdelIDs = np.unique(delMask[ROImask]) - lab2D = self.get_2Dlab(posData.lab) - restoredIDs = set() - for ID in delIDs: - if ID in overlapROIdelIDs and not enforce: - continue - - restoredIDs.add(ID) - - delMaskID = delMask==ID - self.currentLab2D[delMaskID] = ID - lab2D[delMaskID] = ID - - if draw: - self.restoreDelROIimg1(delMaskID, ID, ax=0) - self.restoreDelROIimg1(delMaskID, ID, ax=1) - - delMask[delMaskID] = 0 - - delROIs_info['delIDsROI'][idx] = delIDs - restoredIDs - self.set_2Dlab(lab2D) - self.update_rp() - - def restoreDelROIimg1(self, delMaskID, delID, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - if how.find('nothing') != -1: - return - - if how.find('contours') != -1: - rp_delmask = skimage.measure.regionprops(delMaskID.astype(np.uint8)) - if len(rp_delmask) > 0: - obj = rp_delmask[0] - self.addObjContourToContoursImage(obj=obj, ax=ax) - elif how.find('overlay segm. masks') != -1: - if ax == 0: - self.labelsLayerImg1.setImage( - self.currentLab2D, autoLevels=False - ) - else: - self.labelsLayerRightImg.setImage( - self.currentLab2D, autoLevels=False - ) - - def getDelRoisIDs(self): - posData = self.data[self.pos_i] - if posData.frame_i > 0: - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - allDelIDs = set() - for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: - if ( - not self.ax1.isDelRoiItemPresent(roi) - and not self.ax2.isDelRoiItemPresent(roi) - ): - continue - - ROImask = self.getDelRoiMask(roi) - delIDs = posData.lab[ROImask] - allDelIDs.update(delIDs) - if posData.frame_i > 0: - delIDsPrevFrame = prev_lab[ROImask] - allDelIDs.update(delIDsPrevFrame) - return allDelIDs - - def getStoredDelRoiIDs(self, frame_i=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - allDelIDs = set() - delROIs_info = posData.allData_li[frame_i]['delROIs_info'] - delIDs_rois = delROIs_info['delIDsROI'] - for delIDs in delIDs_rois: - allDelIDs.update(delIDs) - return allDelIDs - - # @exec_time - def getDelROIlab(self, input_lab_2D=None): - posData = self.data[self.pos_i] - if self.delRoiLab is None: - self.initDelRoiLab() - - out_lab = self.delRoiLab - if input_lab_2D is None: - out_lab[:] = self.get_2Dlab(posData.lab, force_z=False) - else: - out_lab[:] = input_lab_2D - - allDelIDs = set() - # Iterate rois and delete IDs - for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: - if ( - not self.ax1.isDelRoiItemPresent(roi) - and not self.ax2.isDelRoiItemPresent(roi) - ): - continue - ROImask = self.getDelRoiMask(roi) - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - idx = delROIs_info['rois'].index(roi) - delObjROImask = delROIs_info['delMasks'][idx] - delIDsROI = delROIs_info['delIDsROI'][idx] - delROIlabRp = skimage.measure.regionprops(out_lab) - for delObj in delROIlabRp: - isDelObj = np.any(ROImask[delObj.slice][delObj.image]) - if not isDelObj: - continue - - delObjROImask[delObj.slice][delObj.image] = delObj.label - out_lab[delObj.slice][delObj.image] = 0 - - delIDsROI.add(delObj.label) - allDelIDs.add(delObj.label) - - # Keep a mask of deleted IDs to bring them back when roi moves - delROIs_info['delMasks'][idx] = delObjROImask - delROIs_info['delIDsROI'][idx] = delIDsROI - - # printl( - # f't1-t0: {(t1-t0)*1000:.3f} ms,', - # f't2-t1: {(t2-t1)*1000:.3f} ms,', - # f't3-t2: {(t3-t2)*1000:.3f} ms,', - # # f't4-t3: {(t4-t3)*1000:.3f} ms,', - # # f't5-t4: {(t5-t4)*1000:.3f} ms,', - # # f't6-t5: {(t6-t5)*1000:.3f} ms', - # sep='\n' - # ) - - return allDelIDs, out_lab - - def getDelRoiMask(self, roi, posData=None, z_slice=None): - if posData is None: - posData = self.data[self.pos_i] - if z_slice is None: - z_slice = self.z_lab() - ROImask = np.zeros(posData.lab.shape, bool) - if isinstance(roi, pg.PolyLineROI): - r, c = [], [] - x0, y0 = roi.pos().x(), roi.pos().y() - for _, point in roi.getLocalHandlePositions(): - xr, yr = point.x(), point.y() - r.append(int(yr+y0)) - c.append(int(xr+x0)) - if not r or not c: - return ROImask - - if len(r) == 2: - rr, cc, val = skimage.draw.line_aa(r[0], c[0], r[1], c[1]) - else: - rr, cc = skimage.draw.polygon(r, c, shape=self.currentLab2D.shape) - - Y, X = self.currentLab2D.shape - rr = rr[(rr>=0) & (rr=0) & (cc{descr} {channel}: value={value:{ff}}' - ) - return txt - - def _addOverlayHoverValuesFormatted(self, txt, xdata, ydata): - posData = self.data[self.pos_i] - if posData.ol_data is None: - return txt - - for filename in posData.ol_data: - chName = myutils.get_chname_from_basename( - filename, posData.basename, remove_ext=False - ) - if chName not in self.checkedOverlayChannels: - continue - - raw_overlay_img = self.getRawImage(filename=filename) - raw_overlay_value = raw_overlay_img[ydata, xdata] - # raw_overlay_max_value = raw_overlay_img.max() - - raw_txt = self._channelHoverValues('Raw', chName, raw_overlay_value) - - txt = f'{txt} | {raw_txt}' - return txt - - def getActiveToolButton(self): - for button in self.LeftClickButtons: - if button.isChecked(): - return button - - def getConcatAcdcDf(self): - acdc_dfs = [] - keys = [] - posData = self.data[self.pos_i] - for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] - if lab is None: - break - - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - break - - acdc_dfs.append(acdc_df) - keys.append(frame_i) - - if not acdc_dfs: - return - - return pd.concat(acdc_dfs, keys=keys, names=['frame_i']) - - - def checkHighlightTimestamp(self, x, y, activeToolButton): - if not hasattr(self, 'timestamp'): - return - - if not self.addTimestampAction.isChecked(): - return - - if activeToolButton is not None: - return - - if hasattr(self, 'scaleBar'): - if self.scaleBar.isHighlighted(): - return - - ymin, xmin, ymax, xmax = self.timestamp.bbox() - if x < xmin: - self.timestamp.setHighlighted(False) - return - - if x > xmax: - self.timestamp.setHighlighted(False) - return - - if y < ymin: - self.timestamp.setHighlighted(False) - return - - if y > ymax: - self.timestamp.setHighlighted(False) - return - - self.timestamp.setHighlighted(True) - - def checkHighlightScaleBar(self, x, y, activeToolButton): - if not hasattr(self, 'scaleBar'): - return - - if not self.addScaleBarAction.isChecked(): - return - - if activeToolButton is not None: - return - - ymin, xmin, ymax, xmax = self.scaleBar.bbox() - if x < xmin: - self.scaleBar.setHighlighted(False) - return - - if x > xmax: - self.scaleBar.setHighlighted(False) - return - - if y < ymin: - self.scaleBar.setHighlighted(False) - return - - if y > ymax: - self.scaleBar.setHighlighted(False) - return - - self.scaleBar.setHighlighted(True) - - def getMouseDataCoordsRightImage(self): - text = self.wcLabel.text() - if not text: - return - - ax_idx = int(re.findall(r'\(ax(\d)\)', text)[0]) - if ax_idx == 0: - return - - coords = re.findall(r'x=(\d+), y=(\d+) \|', text)[0] - - return tuple([int(val) for val in coords]) - - def updateValuesStatusBar(self): - (xl, xr), (yt, yb) = self.ax1ViewRange(integers=True) - W = round(xr - xl) - H = round(yb - yt) - txt = self.wcLabel.text() - pattern = ( - r'W=.*?, H=.*? \| ' - r'x_left=.*?, y_top=.*? \| ' - r'x_right=.*?, y_bottom=.*? \| ' - ) - replacing = ( - f'W={W:d}, H={H:d} | ' - f'x_left={xl:d}, y_top={yt:d} | ' - f'x_right={xr:d}, y_bottom={yb:d} | ' - ) - txt = re.sub(pattern, replacing, txt) - self.wcLabel.setText(txt) - - def hoverValuesFormatted(self, xdata, ydata, activeToolButton, is_ax0): - (xl, xr), (yt, yb) = self.ax1ViewRange(integers=True) - W = round(xr - xl) - H = round(yb - yt) - ax_idx = 0 if is_ax0 else 1 - txt = ( - f'x={xdata:d}, y={ydata:d} | ' - f'W={W:d}, H={H:d} | ' - f'x_left={xl:d}, y_top={yt:d} | ' - f'x_right={xr:d}, y_bottom={yb:d} | ' - f'(ax{ax_idx})' - ) - if activeToolButton == self.rulerButton: - txt = self._addRulerMeasurementText(txt) - return txt - elif activeToolButton is not None: - return txt - - posData = self.data[self.pos_i] - - raw_img = self.getRawImage() - raw_value = raw_img[ydata, xdata] - # raw_max_value = raw_img.max() - - ch = self.user_ch_name - raw_txt = self._channelHoverValues('Raw', ch, raw_value) - - txt = f'{txt} | {raw_txt}' - - txt = self._addOverlayHoverValuesFormatted(txt, xdata, ydata) - - ID = self.currentLab2D[ydata, xdata] - maxID = max(posData.IDs, default=0) - - num_obj = len(posData.IDs) - lab_txt = ( - f'Objects: ID={ID}, max ID={maxID}, ' - f'num. of objects={num_obj}' - ) - txt = f'{txt} | {lab_txt}' - - txt = self._addRulerMeasurementText(txt) - return txt - - def getRulerLengthText(self): - text = self.wcLabel.text() - lengthText = re.findall(r'length = (.*)\)', text)[0] - lengthText = lengthText.replace('pxl', 'pixels') - return f'{lengthText})' - - def _addRulerMeasurementText(self, txt): - posData = self.data[self.pos_i] - xx, yy = self.ax1_rulerPlotItem.getData() - if xx is None: - return txt - - lenPxl = math.sqrt((xx[0]-xx[1])**2 + (yy[0]-yy[1])**2) - depthAxes = self.switchPlaneCombobox.depthAxes() - if depthAxes != 'z': - pxlToUm = posData.PhysicalSizeZ - else: - pxlToUm = posData.PhysicalSizeX - - length_txt = ( - f'length = {int(lenPxl)} pxl ({lenPxl*pxlToUm:.2f} μm)' - ) - txt = f'{txt} | Measurement: {length_txt}' - return txt - - def updateImageValueFormatter(self): - if self.img1.image is not None: - dtype = self.img1.image.dtype - n_digits = len(str(int(self.img1.image.max()))) - self.imgValueFormatter = myutils.get_number_fstring_formatter( - dtype, precision=abs(n_digits-5) - ) - - rawImgData = self.data[self.pos_i].img_data - dtype = rawImgData.dtype - n_digits = len(str(int(rawImgData.max()))) - self.rawValueFormatter = myutils.get_number_fstring_formatter( - dtype, precision=abs(n_digits-5) - ) - - def normaliseIntensitiesActionTriggered(self, action): - how = action.text() - self.df_settings.at['how_normIntensities', 'value'] = how - self.df_settings.to_csv(self.settings_csv_path) - self.updateAllImages() - self.updateImageValueFormatter() - - def setLastUserNormAction(self): - how = self.df_settings.at['how_normIntensities', 'value'] - for action in self.normalizeQActionGroup.actions(): - if action.text() == how: - action.setChecked(True) - break - - def saveLabelsColormap(self): - self.labelsGrad.saveColormap() - - def addFontSizeActions(self, menu, slot): - fontActionGroup = QActionGroup(self) - fontActionGroup.setExclusive(True) - for fontSize in range(4,27): - action = QAction(self) - action.setText(str(fontSize)) - action.setCheckable(True) - if fontSize == self.fontSize: - action.setChecked(True) - fontActionGroup.addAction(action) - menu.addAction(action) - action.triggered.connect(slot) - return fontActionGroup - - @exception_handler - def changeFontSize(self): - fontSize = self.fontSizeSpinBox.value() - if fontSize == self.fontSize: - return - - self.fontSize = fontSize - - self.df_settings.at['fontSize', 'value'] = self.fontSize - self.df_settings.to_csv(self.settings_csv_path) - - self.setAllIDs() - posData = self.data[self.pos_i] - for ax in range(2): - self.textAnnot[ax].changeFontSize(self.fontSize) - if self.highLowResAction.isChecked(): - self.setAllTextAnnotations() - else: - self.updateAllImages() - - def enableZstackWidgets(self, enabled): - if enabled: - myutils.setRetainSizePolicy(self.zSliceScrollBar) - myutils.setRetainSizePolicy(self.zProjComboBox) - myutils.setRetainSizePolicy(self.zSliceOverlay_SB) - myutils.setRetainSizePolicy(self.zProjOverlay_CB) - myutils.setRetainSizePolicy(self.overlay_z_label) - self.zSliceScrollBar.setDisabled(False) - self.zProjComboBox.show() - if self.data[self.pos_i].SizeT > 1: - self.zProjLockViewButton.show() - self.zSliceScrollBar.show() - self.zSliceCheckbox.show() - self.zSliceSpinbox.show() - self.switchPlaneCombobox.show() - self.switchPlaneCombobox.setDisabled(False) - self.SizeZlabel.show() - else: - myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=False) - myutils.setRetainSizePolicy(self.zProjComboBox, retain=False) - myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=False) - myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=False) - myutils.setRetainSizePolicy(self.overlay_z_label, retain=False) - self.zSliceScrollBar.setDisabled(True) - self.zProjComboBox.hide() - self.zProjComboBox.hide() - self.zSliceScrollBar.hide() - self.zSliceCheckbox.hide() - self.zSliceSpinbox.hide() - self.SizeZlabel.hide() - self.switchPlaneCombobox.hide() - self.switchPlaneCombobox.setDisabled(True) - - self.imgGrad.rescaleAcrossZstackAction.setDisabled(not enabled) - for ch, overlayItems in self.overlayLayersItems.items(): - lutItem = overlayItems[1] - lutItem.rescaleAcrossZstackAction.setDisabled(not enabled) - - def reInitCca(self): - if not self.isSnapshot: - txt = html_utils.paragraph( - 'If you decide to continue ALL cell cycle annotations from ' - 'this frame to the end will be erased from current session ' - '(saved data is not touched of course).

' - 'To annotate future frames again you will have to revisit them.

' - 'Do you want to continue?' - ) - msg = widgets.myMessageBox() - msg.warning( - self, 'Re-initialize annnotations?', txt, - buttonsTexts=('Cancel', 'Yes') - ) - posData = self.data[self.pos_i] - if msg.cancel: - return - - # Reset all future frames - self.resetCcaFuture(posData.frame_i+1) - if posData.frame_i == 0: - # Reset everything since we are on first frame - posData.cca_df = self.getBaseCca_df() - self.store_data() - self.updateAllImages() - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) - else: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - posData = self.data[self.pos_i] - posData.cca_df = self.getBaseCca_df() - self.store_data() - self.updateAllImages() - - - def repeatAutoCca(self): - # Do not allow automatic bud assignment if there are future - # frames that already contain anotations - posData = self.data[self.pos_i] - next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] - if next_df is not None: - if 'cell_cycle_stage' in next_df.columns: - msg = QMessageBox() - warn_cca = msg.critical( - self, 'Future visited frames detected!', - 'Automatic bud assignment CANNOT be performed becasue ' - 'there are future frames that already contain cell cycle ' - 'annotations. The behaviour in this case cannot be predicted.\n\n' - 'We suggest assigning the bud manually OR use the ' - '"Re-initialize cell cycle annotations" button which properly ' - 're-initialize future frames.', - msg.Ok - ) - return - - correctedAssignIDs = ( - posData.cca_df[posData.cca_df['corrected_on_frame_i']>=0].index - ) - NeverCorrectedAssignIDs = [ - ID for ID in posData.new_IDs if ID not in correctedAssignIDs - ] - - # Store cca_df temporarily if attempt_auto_cca fails - posData.cca_df_beforeRepeat = posData.cca_df.copy() - - if not all(NeverCorrectedAssignIDs): - notEnoughG1Cells, proceed = self.attempt_auto_cca() - if notEnoughG1Cells or not proceed: - posData.cca_df = posData.cca_df_beforeRepeat - else: - self.updateAllImages() - return - - msg = QMessageBox() - msg.setIcon(msg.Question) - msg.setText( - 'Do you want to automatically assign buds to mother cells for ' - 'ALL the new cells in this frame (excluding cells with unknown history) ' - 'OR only the cells where you never clicked on?' - ) - msg.setDetailedText( - f'New cells that you never touched:\n\n{NeverCorrectedAssignIDs}') - enforceAllButton = QPushButton('ALL new cells') - b = QPushButton('Only cells that I never corrected assignment') - msg.addButton(b, msg.YesRole) - msg.addButton(enforceAllButton, msg.NoRole) - msg.exec_() - if msg.clickedButton() == enforceAllButton: - notEnoughG1Cells, proceed = self.attempt_auto_cca(enforceAll=True) - else: - notEnoughG1Cells, proceed = self.attempt_auto_cca() - if notEnoughG1Cells or not proceed: - posData.cca_df = posData.cca_df_beforeRepeat - else: - self.updateAllImages() - - def manualEditCcaToolbarActionTriggered(self): - self.manualEditCca() - - def askGet2Dor3Dimage(self): - txt = html_utils.paragraph(""" - Do you want to test the denoising on the visualized 2D image or - on the entire 3D z-stack? - """) - msg = widgets.myMessageBox(wrapText=False) - _, use3Dbutton, use2Dbutton = msg.question( - self, '3D denoising?', txt, - buttonsTexts=('Cancel', 'Denoise 3D z-stack', 'Denoise 2D image') - ) - if msg.cancel: - return - - if msg.clickedButton == use3Dbutton: - posData = self.data[self.pos_i] - zslice = self.zSliceScrollBar.sliderPosition() - return posData.img_data[posData.frame_i, zslice] - else: - return self.getDisplayedImg1() - - def manualEditCca(self, checked=True): - posData = self.data[self.pos_i] - editCcaWidget = apps.editCcaTableWidget( - posData.cca_df, posData.SizeT, current_frame_i=posData.frame_i, - parent=self - ) - editCcaWidget.sigApplyChangesFutureFrames.connect( - self.applyManualCcaChangesFutureFrames - ) - editCcaWidget.exec_() - if editCcaWidget.cancel: - return - posData.cca_df = editCcaWidget.cca_df - self.store_cca_df() - # self.checkMultiBudMoth() - self.updateAllImages() - - @exception_handler - def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i): - self.store_data(autosave=False) - posData = self.data[self.pos_i] - undoId = uuid.uuid4() - for i in range(posData.frame_i, stop_frame_i): - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - self.storeUndoRedoCca(i, cca_df_i, undoId) - - for ID, changes_ID in changes.items(): - if ID not in cca_df_i.index: - continue - for col, (oldValue, newValue) in changes_ID.items(): - cca_df_i.at[ID, col] = newValue - self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - self.get_data() - self.updateAllImages() - - def annotateRightHowCombobox_cb(self, idx): - how = self.annotateRightHowCombobox.currentText() - saveSettings = True - if hasattr(self.annotateRightHowCombobox, 'saveSettings'): - saveSettings = self.annotateRightHowCombobox.saveSettings - - if saveSettings: - self.df_settings.at['how_draw_right_annotations', 'value'] = how - self.df_settings.to_csv(self.settings_csv_path) - - mode = self.modeComboBox.currentText() - isCcaAnnot = ( - self.annotCcaInfoCheckboxRight.isChecked() and - mode != 'Normal division: Lineage tree' - ) - isIDAnnot = (self.annotIDsCheckboxRight.isChecked() or ( - self.annotCcaInfoCheckboxRight.isChecked() and - mode == 'Normal division: Lineage tree' - )) - self.textAnnot[1].setCcaAnnot( - isCcaAnnot - ) - - self.textAnnot[1].setLabelAnnot( - isIDAnnot - ) - if not self.isDataLoading: - self.updateAllImages() - - def drawIDsContComboBox_cb(self, idx): - how = self.drawIDsContComboBox.currentText() - saveSettings = True - if hasattr(self.drawIDsContComboBox, 'saveSettings'): - saveSettings = self.drawIDsContComboBox.saveSettings - - if saveSettings: - self.df_settings.at['how_draw_annotations', 'value'] = how - self.df_settings.to_csv(self.settings_csv_path) - - mode = self.modeComboBox.currentText() - isCcaAnnot = ( - self.annotCcaInfoCheckbox.isChecked() and - mode != 'Normal division: Lineage tree' - ) - isIDAnnot = (self.annotIDsCheckbox.isChecked() or ( - self.annotCcaInfoCheckbox.isChecked() and - mode == 'Normal division: Lineage tree' - )) - self.textAnnot[0].setCcaAnnot( - isCcaAnnot - ) - - self.textAnnot[0].setLabelAnnot( - isIDAnnot - ) - - if not self.isDataLoading: - self.updateAllImages() - - if self.eraserButton.isChecked(): - self.setTempImg1Eraser(None, init=True) - - def mousePressColorButton(self, event): - posData = self.data[self.pos_i] - items = list(self.checkedOverlayChannels) - if len(items)>1: - selectFluo = widgets.QDialogListbox( - 'Select image', - 'Select which fluorescence image you want to update the color of\n', - items, multiSelection=False, parent=self - ) - selectFluo.exec_() - keys = selectFluo.selectedItemsText - if selectFluo.cancel or not keys: - return - else: - self.overlayColorButton.channel = keys[0] - else: - self.overlayColorButton.channel = items[0] - self.overlayColorButton.selectColor() - - def setEnabledCcaToolbar(self, enabled=False): - self.manuallyEditCcaAction.setDisabled(False) - self.viewCcaTableAction.setDisabled(False) - self.ccaToolBar.setVisible(enabled) - for action in self.ccaToolBar.actions(): - button = self.ccaToolBar.widgetForAction(action) - action.setVisible(enabled) - button.setEnabled(enabled) - - # def setEnabledCcaToolbar(self, enabled=False): - # self.manuallyEditCcaAction.setDisabled(False) - # self.viewCcaTableAction.setDisabled(False) - # self.ccaToolBar.setVisible(enabled) - # for action in self.ccaToolBar.actions(): - # button = self.ccaToolBar.widgetForAction(action) - # action.setVisible(enabled) - # button.setEnabled(enabled) - - def setEnabledEditToolbarButton(self, enabled=False): - for action in self.segmActions: - action.setEnabled(enabled) - - for action in self.segmActionsVideo: - action.setEnabled(enabled) - - self.relabelSequentialAction.setEnabled(enabled) - self.repeatTrackingMenuAction.setEnabled(enabled) - self.repeatTrackingVideoAction.setEnabled(enabled) - self.postProcessSegmAction.setEnabled(enabled) - self.autoSegmAction.setEnabled(enabled) - self.editToolBar.setVisible(enabled) - mode = self.modeComboBox.currentText() - ccaON = mode == 'Cell cycle analysis' - for action in self.editToolBar.actions(): - button = self.editToolBar.widgetForAction(action) - # Keep binCellButton active in cca mode - if button==self.binCellButton and not enabled and ccaON: - action.setVisible(True) - button.setEnabled(True) - else: - action.setVisible(enabled) - button.setEnabled(enabled) - if not enabled: - self.setUncheckedAllButtons() - - def setEnabledFileToolbar(self, enabled): - for action in self.fileToolBar.actions(): - button = self.fileToolBar.widgetForAction(action) - if action == self.openFolderAction or action == self.newAction: - continue - if action == self.manageVersionsAction: - continue - if action == self.openFileAction: - continue - action.setEnabled(enabled) - button.setEnabled(enabled) - - def reconnectUndoRedo(self): - try: - self.undoAction.triggered.disconnect() - self.redoAction.triggered.disconnect() - except Exception as e: - pass - mode = self.modeComboBox.currentText() - if mode == 'Segmentation and Tracking' or mode == 'Snapshot': - self.undoAction.triggered.connect(self.undo) - self.redoAction.triggered.connect(self.redo) - elif mode == 'Cell cycle analysis': - self.undoAction.triggered.connect(self.UndoCca) - elif mode == 'Custom annotations': - self.undoAction.triggered.connect(self.undoCustomAnnotation) - else: - self.undoAction.setDisabled(True) - self.redoAction.setDisabled(True) - - def enableSizeSpinbox(self, enabled): - self.brushSizeLabelAction.setVisible(enabled) - self.brushSizeAction.setVisible(enabled) - self.brushAutoFillAction.setVisible(enabled) - self.brushAutoHideAction.setVisible(enabled) - self.brushEraserToolBar.setVisible(enabled) - self.disableNonFunctionalButtons() - - def reload_cb(self): - posData = self.data[self.pos_i] - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - labData = np.load(posData.segm_npz_path) - # Keep compatibility with .npy and .npz files - try: - lab = labData['arr_0'][posData.frame_i] - except Exception as e: - lab = labData[posData.frame_i] - posData.segm_data[posData.frame_i] = lab.copy() - self.get_data() - self.tracking() - self.updateAllImages() - - def clearComboBoxFocus(self, mode): - # Remove focus from modeComboBox to avoid the key_up changes its value - self.sender().clearFocus() - try: - self.timer.stop() - self.modeComboBox.setStyleSheet('background-color: none') - except Exception as e: - pass - - def updateModeMenuAction(self): - self.modeActionGroup.triggered.disconnect() - for action in self.modeActionGroup.actions(): - if action.text() != self.modeComboBox.currentText(): - continue - action.setChecked(True) - break - self.modeActionGroup.triggered.connect(self.changeModeFromMenu) - - def changeModeFromMenu(self, action): - self.modeComboBox.setCurrentText(action.text()) - - def restorePrevAnnotOptions(self): - if self.prevAnnotOptions is None: - return - self.restoreAnnotOptions_ax1(options=self.prevAnnotOptions) - self.setDrawAnnotComboboxText() - self.prevAnnotOptions = None - - def uncheckAllButtonsFromButtonGroup(self, buttonGroup): - for button in buttonGroup.buttons(): - if not button.isCheckable(): - continue - - if not button.isChecked(): - continue - - button.setChecked(False) - - @disableWindow - def changeMode(self, text): - self.reconnectUndoRedo() - self.updateModeMenuAction() - self.clearCustomAnnot() - posData = self.data[self.pos_i] - mode = text - prevMode = self.modeComboBox.previousText() - self.annotateToolbar.setVisible(False) - if prevMode != 'Viewer': - self.store_data(autosave=True) - - self.copyLostObjButton.setChecked(False) - self.stopCcaIntegrityCheckerWorker() - self.setAutoSaveSegmentationEnabled(False) - self.setAutoSaveAnnotationsEnabled(False) - if prevMode == 'Normal division: Lineage tree': - self.askLineageTreeChanges() - self.lineage_tree = None - self.editLin_TreeBar.setVisible(False) - self.uncheckAllButtonsFromButtonGroup(self.editLin_TreeGroup) - - elif prevMode == 'Cell cycle analysis': - self.setEnabledCcaToolbar(enabled=False) - - if mode == 'Segmentation and Tracking': - self.setAutoSaveSegmentationEnabled(True) - self.setSwitchViewedPlaneDisabled(True) - self.trackingMenu.setDisabled(False) - self.modeToolBar.setVisible(True) - self.lastTrackedFrameLabel.setText('') - self.initSegmTrackMode() - self.setEnabledEditToolbarButton(enabled=True) - self.addExistingDelROIs() - self.isFirstTimeOnNextFrame() - self.setEnabledCcaToolbar(enabled=False) - self.clearComputedContours() - self.realTimeTrackingToggle.setDisabled(False) - self.realTimeTrackingToggle.label.setDisabled(False) - if posData.cca_df is not None: - self.store_cca_df() - self.restorePrevAnnotOptions() - self.whitelistViewOGIDs(False) - elif mode == 'Cell cycle analysis': - self.setAutoSaveAnnotationsEnabled(True) - self.setSwitchViewedPlaneDisabled(True) - self.startCcaIntegrityCheckerWorker() - proceed = self.initCca() - if proceed: - self.applyDelROIs() - self.modeToolBar.setVisible(True) - self.realTimeTrackingToggle.setDisabled(True) - self.realTimeTrackingToggle.label.setDisabled(True) - self.computeAllContours() - # RAWR!!!!! - # self.computeAllObjToObjCostPairs() - if proceed: - self.setEnabledEditToolbarButton(enabled=False) - if self.isSnapshot: - self.editToolBar.setVisible(True) - self.setEnabledCcaToolbar(enabled=True) - self.removeAlldelROIsCurrentFrame() - self.setAnnotOptionsCcaMode() - self.clearGhost() - elif mode == 'Viewer': - self.autoSaveTimer.stop() - self.setSwitchViewedPlaneDisabled(False) - self.modeToolBar.setVisible(True) - self.realTimeTrackingToggle.setDisabled(True) - self.realTimeTrackingToggle.label.setDisabled(True) - self.setEnabledEditToolbarButton(enabled=False) - self.setEnabledCcaToolbar(enabled=False) - self.removeAlldelROIsCurrentFrame() - self.setStatusBarLabel() - self.navigateScrollBar.setMaximum(posData.SizeT) - self.navSpinBox.setMaximum(posData.SizeT) - self.clearGhost() - self.computeAllContours() - elif mode == 'Custom annotations': - self.setAutoSaveAnnotationsEnabled(True) - self.setSwitchViewedPlaneDisabled(True) - self.modeToolBar.setVisible(True) - self.realTimeTrackingToggle.setDisabled(True) - self.realTimeTrackingToggle.label.setDisabled(True) - self.setEnabledEditToolbarButton(enabled=False) - self.setEnabledCcaToolbar(enabled=False) - self.removeAlldelROIsCurrentFrame() - self.annotateToolbar.setVisible(True) - self.clearGhost() - self.doCustomAnnotation(0) - self.computeAllContours() - elif mode == 'Snapshot': - self.setAutoSaveAnnotationsEnabled(True) - self.setSwitchViewedPlaneDisabled(False) - self.reconnectUndoRedo() - self.setEnabledSnapshotMode() - self.doCustomAnnotation(0) - self.clearComputedContours() - elif mode == 'Normal division: Lineage tree': # Mode activation for lineage tree - # self.startLinTreeIntegrityCheckerWorker() # need to replace (postponed) - proceed = self.initLinTree() - self.setEnabledCcaToolbar(enabled=False) - self.setNavigateScrollBarMaximum() - if proceed: - self.applyDelROIs() - self.modeToolBar.setVisible(True) - self.realTimeTrackingToggle.setDisabled(True) - self.realTimeTrackingToggle.label.setDisabled(True) - if proceed: - self.setAutoSaveAnnotationsEnabled(True) - self.setEnabledEditToolbarButton(enabled=False) - if self.isSnapshot: - self.editToolBar.setVisible(True) - self.removeAlldelROIsCurrentFrame() - self.setAnnotOptionsLin_treeMode() - self.clearGhost() - self.editLin_TreeBar.setVisible(True) - - self.disableNonFunctionalButtons() - - def disableEditingViewPlaneNotXY(self): - posData = self.data[self.pos_i] - self.manuallyEditCcaAction.setDisabled(True) - for action in self.segmActions: - action.setDisabled(True) - if posData.SizeT == 1: - self.segmVideoMenu.setDisabled(True) - self.postProcessSegmAction.setDisabled(True) - self.autoSegmAction.setDisabled(True) - self.ccaToolBar.setVisible(False) - self.editToolBar.setVisible(False) - for action in self.ccaToolBar.actions(): - button = self.editToolBar.widgetForAction(action) - if button is not None: - button.setDisabled(True) - action.setVisible(False) - for action in self.editToolBar.actions(): - button = self.editToolBar.widgetForAction(action) - action.setVisible(False) - if button is not None: - button.setDisabled(True) - - def setEnabledSnapshotMode(self): - posData = self.data[self.pos_i] - self.manuallyEditCcaAction.setDisabled(False) - self.viewCcaTableAction.setDisabled(False) - for action in self.segmActions: - action.setDisabled(False) - - self.segmVideoMenu.setDisabled(True) - self.trackingMenu.setDisabled(True) - self.modeToolBar.setVisible(False) - - self.relabelSequentialAction.setDisabled(False) - self.postProcessSegmAction.setDisabled(False) - self.autoSegmAction.setDisabled(False) - self.ccaToolBar.setVisible(True) - self.editToolBar.setVisible(True) - self.reinitLastSegmFrameAction.setVisible(False) - for action in self.ccaToolBar.actions(): - button = self.ccaToolBar.widgetForAction(action) - if button == self.assignBudMothButton: - button.setDisabled(False) - action.setVisible(True) - elif action == self.reInitCcaAction: - action.setVisible(True) - elif action == self.assignBudMothAutoAction and posData.SizeT==1: - action.setVisible(True) - for action in self.editToolBar.actions(): - button = self.editToolBar.widgetForAction(action) - action.setVisible(True) - button.setEnabled(True) - self.realTimeTrackingToggle.setDisabled(True) - self.realTimeTrackingToggle.label.setDisabled(True) - self.repeatTrackingAction.setVisible(False) - self.manualTrackingAction.setVisible(False) - button = self.editToolBar.widgetForAction(self.repeatTrackingAction) - button.setDisabled(True) - button = self.editToolBar.widgetForAction(self.manualTrackingAction) - button.setDisabled(True) - self.disableNonFunctionalButtons() - self.reinitLastSegmFrameAction.setVisible(False) - - def launchSlideshow(self): - posData = self.data[self.pos_i] - self.determineSlideshowWinPos() - if self.slideshowButton.isChecked(): - self.slideshowWin = apps.imageViewer( - parent=self, - button_toUncheck=self.slideshowButton, - linkWindow=posData.SizeT > 1, - enableOverlay=True, - enableMirroredCursor=True - ) - self.slideshowWin.img.minMaxValuesMapper = ( - self.img1.minMaxValuesMapper - ) - self.slideshowWin.img.setCurrentPosIndex(self.pos_i) - h = self.drawIDsContComboBox.size().height() - self.slideshowWin.framesScrollBar.setFixedHeight(h) - self.slideshowWin.overlayButton.setChecked( - self.overlayButton.isChecked() - ) - self.slideshowWin.sigHoveringImage.connect( - self.setMirroredCursorFromSecondWindow - ) - if posData.SizeZ > 1: - z_slice = self.zSliceScrollBar.sliderPosition() - self.slideshowWin.img.setCurrentZsliceIndex(z_slice) - self.slideshowWin.zSliceScrollBar.setSliderPosition(z_slice) - self.slideshowWin.z_label.setText( - f'z-slice {z_slice+1:02}/{posData.SizeZ}' - ) - self.slideshowWin.update_img() - self.slideshowWin.show( - left=self.slideshowWinLeft, top=self.slideshowWinTop - ) - else: - self.slideshowWin.close() - self.slideshowWin = None - - def setMirroredCursorFromSecondWindow(self, x, y): - if x is None: - xx, yy = [], [] - else: - xx, yy = [x], [y] - self.ax1_cursor.setData(xx, yy) - if not self.isTwoImageLayout: - return - self.ax2_cursor.setData(xx, yy) - - def goToZsliceSearchedID(self, obj): - if not self.isSegm3D: - return - - current_z = self.z_lab() - nearest_nonzero_z = core.nearest_nonzero_z_idx_from_z_centroid( - obj, current_z=current_z - ) - if nearest_nonzero_z == current_z: - self.drawPointsLayers(computePointsLayers=True) - return - - self.zSliceScrollBar.setSliderPosition(nearest_nonzero_z) - self.update_z_slice(nearest_nonzero_z) - - def disconnectLeftClickButtons(self): - for button in self.LeftClickButtons: - try: - button.toggled.disconnect() - except Exception as e: - # Not all the LeftClickButtons have toggled connected - pass - - def uncheckLeftClickButtons(self, sender): - for button in self.LeftClickButtons: - if button != sender: - button.setChecked(False) - - if button != self.labelRoiButton: - # self.labelRoiButton is disconnected so we manually call uncheck - self.labelRoi_cb(False) - self.secondLevelToolbar.setVisible(True) - for toolbar in self.controlToolBars: - try: - toolbar.keepVisibleWhenActive - if toolbar.isVisible(): - self.secondLevelToolbar.setVisible(False) - continue - except: - pass - toolbar.setVisible(False) - - self.enableSizeSpinbox(False) - if sender is not None: - self.keepIDsButton.setChecked(False) - - def connectLeftClickButtonsPointsLayersToolbar(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - if action.layerTypeIdx != 4: - continue - action.button.toggled.connect( - self.addPointsByClickingButtonToggled - ) - - def connectLeftClickButtons(self): - self.brushButton.toggled.connect(self.Brush_cb) - self.curvToolButton.toggled.connect(self.curvTool_cb) - self.rulerButton.toggled.connect(self.ruler_cb) - self.eraserButton.toggled.connect(self.Eraser_cb) - self.wandToolButton.toggled.connect(self.wand_cb) - self.labelRoiButton.toggled.connect(self.labelRoi_cb) - self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) - self.drawClearRegionButton.toggled.connect(self.drawClearRegion_cb) - self.expandLabelToolButton.toggled.connect(self.expandLabelCallback) - self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) - self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) - self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) - self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) - self.connectLeftClickButtonsPointsLayersToolbar() - - def brushSize_cb(self, value): - self.ax2_EraserCircle.setSize(value*2) - self.ax1_BrushCircle.setSize(value*2) - self.ax2_BrushCircle.setSize(value*2) - self.ax1_EraserCircle.setSize(value*2) - self.ax2_EraserX.setSize(value) - self.ax1_EraserX.setSize(value) - self.setDiskMask() - - def autoIDtoggled(self, checked): - self.editIDspinboxAction.setDisabled(checked) - self.editIDLabelAction.setDisabled(checked) - if not checked and self.editIDspinbox.value() == 0: - newID = self.setBrushID(return_val=True) - self.editIDspinbox.setValue(newID) - - def wand_cb(self, checked): - posData = self.data[self.pos_i] - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.wandToolButton) - self.connectLeftClickButtons() - self.wandControlsToolbar.setVisible(True) - # self.secondLevelToolbar.setVisible(False) - else: - self.resetCursors() - # self.secondLevelToolbar.setVisible(True) - self.wandControlsToolbar.setVisible(False) - - def magicPrompts_cb(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.magicPromptsToolButton) - self.connectLeftClickButtons() - self.magicPromptsToolbar.setVisible(True) - self.promptSegmentPointsLayerToolbar.setVisible(True) - if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit: - self.addPointsLayerTriggered( - toolbar=self.promptSegmentPointsLayerToolbar - ) - else: - self.resetCursors() - self.promptSegmentPointsLayerToolbar.setVisible(False) - self.magicPromptsToolbar.setVisible(False) - - def copyLostObjContour_cb(self, checked): - self.copyLostObjToolbar.setVisible(checked) - - self.ax1_lostObjScatterItem.hoverLostID = 0 - if not checked: - return - - self.lostObjImage = np.zeros_like(self.currentLab2D) - self.updateLostContoursImage(0) - - def manualAnnotPast_cb(self, checked): - posData = self.data[self.pos_i] - if checked: - for _ in range(3): - self.onEscape( - buttonsToNotUncheck=[self.manualAnnotPastButton], - doAutoRange=False - ) - - self.brushButton.setChecked(True) - self.store_data() - self.manualAnnotState = { - 'editID': self.editIDspinbox.value(), - 'isAutoID': self.autoIDcheckbox.isChecked(), - 'doWarnLostObj': self.warnLostCellsAction.isChecked(), - } - self.autoIDcheckbox.setChecked(False) - self.warnLostCellsAction.setChecked(False) - hoverID = self.getLastHoveredID() - if hoverID == 0: - win = apps.QLineEditDialog( - title='Not hovering any ID', - msg='You are not hovering on any ID.\n' - 'Enter the ID that you want to lock.', - parent=self, - isInteger=True, - defaultTxt=self.setBrushID(return_val=True) - ) - win.exec_() - if win.cancel: - self.manualAnnotPastButton.setChecked(False) - return - hoverID = win.EntryID - self.logger.info( - 'Setting manual annotation for ID = ' - f'{hoverID}, at frame n. {posData.frame_i+1}' - ) - self.editIDspinbox.setValue(hoverID) - try: - obj_idx = posData.IDs_idxs[hoverID] - obj = posData.rp[obj_idx] - radius = 0.9 * obj.minor_axis_length / 2 # math.sqrt(obj.area/math.pi)*0.9 - self.brushSizeSpinbox.setValue(round(radius)) - except Exception as err: - pass - - self.manualAnnotState['frame_i_to_restore'] = posData.frame_i - self.manualAnnotState['last_tracked_i'] = ( - self.navigateScrollBar.maximum()-1 - ) - self.ax1.sigRangeChanged.connect(self.highlightManualAnnotMode) - self.ax1.setHighlighted(True, color='green') - else: - self.setStatusBarLabel() - self.autoIDcheckbox.setChecked(self.manualAnnotState['isAutoID']) - self.editIDspinbox.setValue(self.manualAnnotState['editID']) - self.warnLostCellsAction.setChecked( - self.manualAnnotState['doWarnLostObj'] - ) - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - if frame_to_restore is None: - return - - self.store_data() - self.store_manual_annot_data() - - last_tracked_i_to_restore = self.manualAnnotState['last_tracked_i'] - self.manualAnnotRestoreLastTrackedFrame(last_tracked_i_to_restore) - - self.logger.info( - f'Restoring view to frame n. {posData.frame_i+1}...' - ) - posData.frame_i = frame_to_restore - self.get_data() - self.updateAllImages() - self.updateScrollbars() - self.ax1.sigRangeChanged.disconnect() - self.ax1.setHighlighted(False) - QTimer.singleShot(150, self.autoRange) - - self.setManualAnnotModeEnabledTools(checked) - - def copyLostObjectMask(self, ID: int): - posData = self.data[self.pos_i] - mask = self.lostObjImage == ID - lab2D = self.get_2Dlab(posData.lab) - lab2D[mask] = ID - self.lostObjImage[mask] = 0 - self.set_2Dlab(lab2D) - - def highlightManualAnnotMode(self, viewBox, viewRange): - self.ax1.setHighlighted(True) - - def updateHighlightedAxis(self): - if not self.manualAnnotPastButton.isChecked(): - return - - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - posData = self.data[self.pos_i] - if posData.frame_i == frame_to_restore: - color = 'green' - elif posData.frame_i < frame_to_restore: - color = 'gold' - else: - color = 'red' - - self.ax1.setHighlightingRectItemsColor(color) - - def updateLostNewCurrentIDs(self): - posData = self.data[self.pos_i] - - prev_IDs = self.getPrevFrameIDs() - tracked_lost_IDs = self.getTrackedLostIDs() - curr_IDs = posData.IDs - curr_delRoiIDs = self.getStoredDelRoiIDs() - prev_delRoiIDs = self.getStoredDelRoiIDs(frame_i=posData.frame_i-1) - lost_IDs = [ - ID for ID in prev_IDs if ID not in curr_IDs - and ID not in prev_delRoiIDs and ID not in tracked_lost_IDs - ] - new_IDs = [ - ID for ID in curr_IDs if ID not in prev_IDs - and ID not in curr_delRoiIDs - ] - IDs_with_holes = [] - posData.lost_IDs = lost_IDs - posData.new_IDs = new_IDs - posData.old_IDs = prev_IDs - posData.IDs = curr_IDs - - out = ( - lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs - ) - return out - - def _copyAllLostObjects_navigateToFrame(self, frame_i): - posData = self.data[self.pos_i] - self.store_data(mainThread=False, autosave=False) - - posData.frame_i = frame_i - self.get_data() - self.tracking(wl_update=False) - self.currentLab2D = self.get_2Dlab(posData.lab) - self.update_rp() - self.updateLostNewCurrentIDs() - self.store_data(mainThread=False, autosave=False) - - self.lostObjContoursImage[:] = 0 - self.lostObjImage[:] = 0 - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] # need to change this when merging with opt. - for lostID in posData.lost_IDs: - obj = prev_rp[prev_IDs_idxs[lostID]] - self.addLostObjsToLostObjImage(obj, lostID, force=True) - - def _copyAllLostObjects_returnToFrame(self, frame_i): - posData = self.data[self.pos_i] - self.store_data(autosave=False, mainThread=False) - posData.frame_i = frame_i - self.get_data() - - def _copyAllLostObjects_refreshRp(self): - self.update_rp(draw=False, wl_update=False) # need to change this when merging with opt. - - @disableWindow - def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): - if not self.copyLostObjButton.isChecked(): - return - - posData = self.data[self.pos_i] - - desc = 'Copying all lost objects...' - - self.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self.mainWin, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(for_future_frame_n+1) - self.progressWin.show(self.app) - - self.copyAllLostObjectsThread = QThread() - - self.copyAllLostObjectsWorker = workers.CopyAllLostObjectsWorker( - self, posData, for_future_frame_n, max_overlap_perc - ) - self.copyAllLostObjectsWorker.moveToThread(self.copyAllLostObjectsThread) - - self.copyAllLostObjectsWorker.navigateToFrame.connect( - self._copyAllLostObjects_navigateToFrame, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.returnToFrame.connect( - self._copyAllLostObjects_returnToFrame, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.copyLostObjectMask.connect( - self.copyLostObjectMask, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.refreshRp.connect( - self._copyAllLostObjects_refreshRp, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.progressBar.connect( - self.workerUpdateProgressbar - ) - self.copyAllLostObjectsWorker.critical.connect( - self.copyAllLostObjectsWorkerCritical - ) - self.copyAllLostObjectsWorker.finished.connect( - self.copyAllLostObjectsThread.quit - ) - self.copyAllLostObjectsWorker.finished.connect( - self.copyAllLostObjectsWorker.deleteLater - ) - self.copyAllLostObjectsThread.finished.connect( - self.copyAllLostObjectsThread.deleteLater - ) - self.copyAllLostObjectsWorker.finished.connect( - self.copyAllLostObjectsWorkerFinished - ) - - self.copyAllLostObjectsThread.started.connect( - self.copyAllLostObjectsWorker.run - ) - self.copyAllLostObjectsThread.start() - - self.copyAllLostObjectsWorkerLoop = QEventLoop() - self.copyAllLostObjectsWorkerLoop.exec_() - - def copyAllLostObjectsWorkerCritical(self, error): - self.copyAllLostObjectsWorkerLoop.exit() - self.workerCritical(error) - - def copyAllLostObjectsWorkerFinished(self, output): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - if output.get('doReinitLastSegmFrame', False): - self.reInitLastSegmFrame( - from_frame_i=output.get('last_visited_frame_i'), - updateImages=False, - force=True - ) - - if output.get('overlap_warning', False): - self.blinker = qutils.QControlBlink( - self.copyLostObjToolbar.maxOverlapNumberControl, - qparent=self.mainWin - ) - self.blinker.start() - - self.copyAllLostObjectsWorkerLoop.exit() - self.update_rp() - self.updateAllImages() - self.store_data() - - def labelRoiTrangeCheckboxToggled(self, checked): - disabled = not checked - self.labelRoiStartFrameNoSpinbox.setDisabled(disabled) - self.labelRoiStopFrameNoSpinbox.setDisabled(disabled) - self.labelRoiStartFrameNoSpinbox.label.setDisabled(disabled) - self.labelRoiStopFrameNoSpinbox.label.setDisabled(disabled) - self.labelRoiToEndFramesAction.setDisabled(disabled) - self.labelRoiFromCurrentFrameAction.setDisabled(disabled) - - if disabled: - return - - posData = self.data[self.pos_i] - - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) - self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) - - def drawClearRegion_cb(self, checked): - posData = self.data[self.pos_i] - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.drawClearRegionButton) - self.connectLeftClickButtons() - - self.drawClearRegionToolbar.setVisible(checked) - - if not self.isSegm3D: - self.drawClearRegionToolbar.setZslicesControlEnabled(False) - return - - if not checked: - return - - self.drawClearRegionToolbar.setZslicesControlEnabled( - True, SizeZ=posData.SizeZ - ) - - def labelRoi_cb(self, checked): - posData = self.data[self.pos_i] - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.labelRoiButton) - self.connectLeftClickButtons() - - self.labelRoiStartFrameNoSpinbox.setMaximum(posData.SizeT) - self.labelRoiStopFrameNoSpinbox.setMaximum(posData.SizeT) - - if self.labelRoiActiveWorkers: - lastActiveWorker = self.labelRoiActiveWorkers[-1] - self.labelRoiGarbageWorkers.append(lastActiveWorker) - lastActiveWorker.finished.emit() - self.logger.info('Collected garbage w5orker (magic labeller).') - - self.labelRoiToolbar.setVisible(True) - if self.isSegm3D: - self.labelRoiZdepthSpinbox.setDisabled(False) - else: - self.labelRoiZdepthSpinbox.setDisabled(True) - - # Start thread and pause it - self.labelRoiThread = QThread() - self.labelRoiMutex = QMutex() - self.labelRoiWaitCond = QWaitCondition() - - labelRoiWorker = workers.LabelRoiWorker(self) - - labelRoiWorker.moveToThread(self.labelRoiThread) - labelRoiWorker.finished.connect(self.labelRoiThread.quit) - labelRoiWorker.finished.connect(labelRoiWorker.deleteLater) - self.labelRoiThread.finished.connect( - self.labelRoiThread.deleteLater - ) - - labelRoiWorker.finished.connect(self.labelRoiWorkerFinished) - labelRoiWorker.sigLabellingDone.connect(self.labelRoiDone) - labelRoiWorker.sigProgressBar.connect(self.workerUpdateProgressbar) - - labelRoiWorker.progress.connect(self.workerProgress) - labelRoiWorker.critical.connect(self.workerCritical) - - self.labelRoiActiveWorkers.append(labelRoiWorker) - - self.labelRoiThread.started.connect(labelRoiWorker.run) - self.labelRoiThread.start() - - # Add the rectROI to ax1 - self.ax1.addItem(self.labelRoiItem) - elif self.initLabelRoiModelDialog is not None: - # User is using other tools while the dialog is still open - # --> we allow this because it's useful to be able to use - # the ruler or check things --> do nothing - pass - else: - self.labelRoiToolbar.setVisible(False) - - for worker in self.labelRoiActiveWorkers: - worker._stop() - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) - self.freeRoiItem.clear() - self.ax1.removeItem(self.labelRoiItem) - self.updateLabelRoiCircularCursor(None, None, False) - - def clearObjsFreehandRegion(self): - self.logger.info('Clearing objects inside freehand region...') - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False, storeImage=False, storeOnlyZoom=True) - - posData = self.data[self.pos_i] - zRange = None - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - z_slice = self.z_lab() - zRange = self.drawClearRegionToolbar.zRange( - z_slice, posData.SizeZ - ) - else: - zRange = (0, posData.SizeZ) - - regionSlice = self.freeRoiItem.slice(zRange=zRange) - mask = self.freeRoiItem.mask() - - regionLab = posData.lab[(...,) + regionSlice].copy() - - clearBorders = ( - self.drawClearRegionToolbar - .clearOnlyEnclosedObjsRadioButton.isChecked() - ) - if clearBorders: - if regionLab.ndim == 2: - regionLab = transformation.clear_objects_not_in_mask( - regionLab, mask - ) - regionRp = skimage.measure.regionprops(regionLab) - for obj in regionRp: - if np.all(mask[obj.slice][obj.image]): - continue - - regionLab[obj.slice][obj.image] = 0 - else: - for z, regionLab_z in enumerate(regionLab): - regionLab[z] = transformation.clear_objects_not_in_mask( - regionLab_z, mask - ) - else: - regionLab[..., ~mask] = 0 - - regionRp = skimage.measure.regionprops(regionLab) - clearIDs = [obj.label for obj in regionRp] - - if not clearIDs: - if clearBorders: - self.logger.warning( - 'None of the objects in the freehand region are ' - 'fully enclosed' - ) - else: - self.logger.warning( - 'None of the objects are touching the freehand region' - ) - return - - self.deleteIDmiddleClick(clearIDs, False, False) - self.update_cca_df_deletedIDs(posData, clearIDs) - - self.freeRoiItem.clear() - - self.updateAllImages() - - def labelRoiWorkerFinished(self): - self.logger.info('Magic labeller closed.') - worker = self.labelRoiActiveWorkers.pop(-1) - - def indexRoiLab(self, roiLab, roiLabSlice, lab, brushID): - # Delete only objects touching borders in X and Y not in Z - if self.labelRoiAutoClearBorderCheckbox.isChecked(): - mask = np.zeros(roiLab.shape, dtype=bool) - mask[..., 1:-1, 1:-1] = True - roiLab = skimage.segmentation.clear_border(roiLab, mask=mask) - - roiLabMask = roiLab>0 - roiLab[roiLabMask] += (brushID-1) - if self.labelRoiReplaceExistingObjectsCheckbox.isChecked(): - IDs_touched_by_new_objects = np.unique(lab[roiLabSlice][roiLabMask]) - for ID in IDs_touched_by_new_objects: - lab[lab==ID] = 0 - - lab[roiLabSlice][roiLabMask] = roiLab[roiLabMask] - return lab - - @exception_handler - def labelRoiDone(self, roiSegmData, isTimeLapse): - self.setDisabled(False) - - posData = self.data[self.pos_i] - self.setBrushID() - - if isTimeLapse: - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.mainPbar.setValue(0) - current_frame_i = posData.frame_i - start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 - for i, roiLab in enumerate(roiSegmData): - frame_i = start_frame_i + i - lab = posData.allData_li[frame_i]['labels'] - store = True - if lab is None: - if frame_i >= len(posData.segm_data): - lab = np.zeros_like(posData.segm_data[0]) - posData.segm_data = np.append( - posData.segm_data, lab[np.newaxis], axis=0 - ) - else: - lab = posData.segm_data[frame_i] - store = False - roiLabSlice = self.labelRoiSlice[1:] - lab = self.indexRoiLab( - roiLab, roiLabSlice, lab, posData.brushID - ) - if store: - posData.frame_i = frame_i - posData.allData_li[frame_i]['labels'] = lab.copy() - self.get_data() - self.store_data(autosave=False) - - # Back to current frame - posData.frame_i = current_frame_i - self.get_data() - else: - roiLab = roiSegmData - posData.lab = self.indexRoiLab( - roiLab, self.labelRoiSlice, posData.lab, posData.brushID - ) - - self.update_rp() - - # Repeat tracking - if self.autoIDcheckbox.isChecked(): - self.tracking(enforce=True, assign_unique_new_IDs=False) - - self.store_data() - self.updateAllImages() - - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) - self.freeRoiItem.clear() - self.logger.info('Magic labeller done!') - self.app.restoreOverrideCursor() - - self.labelRoiRunning = False - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - uncheckLabelRoiTRange = ( - self.labelRoiTrangeCheckbox.isChecked() - and not self.labelRoiTrangeCheckbox.findChild(QAction).isChecked() - ) - if uncheckLabelRoiTRange: - self.labelRoiTrangeCheckbox.setChecked(False) - - def restoreHoverObjBrush(self): - posData = self.data[self.pos_i] - if self.ax1BrushHoverID in posData.IDs: - obj_idx = posData.IDs_idxs[self.ax1BrushHoverID] - obj = posData.rp[obj_idx] - if not self.isObjVisible(obj.bbox): - return - - self.addObjContourToContoursImage(obj=obj, ax=0) - self.addObjContourToContoursImage(obj=obj, ax=1) - - def hideItemsHoverBrush(self, xy=None, ID=None, force=False): - if xy is not None: - x, y = xy - if x is None: - return - - xdata, ydata = int(x), int(y) - Y, X = self.currentLab2D.shape - - if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): - return - - if not self.brushAutoHideCheckbox.isChecked() and not force: - return - - posData = self.data[self.pos_i] - size = self.brushSizeSpinbox.value()*2 - - if xy is not None: - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - - if self.ax1_lostObjScatterItem.isVisible(): - self.ax1_lostObjScatterItem.setVisible(False) - - if self.ax1_lostTrackedScatterItem.isVisible(): - self.ax1_lostTrackedScatterItem.setVisible(False) - - if self.ax2_lostObjScatterItem.isVisible(): - self.ax2_lostObjScatterItem.setVisible(False) - - if self.ax2_lostTrackedScatterItem.isVisible(): - self.ax2_lostTrackedScatterItem.setVisible(False) - - # Restore ID previously hovered - if ID != self.ax1BrushHoverID and not self.isMouseDragImg1: - try: - self.restoreHoverObjBrush() - except Exception as e: - self.ax1BrushHoverID = 0 - return - - # Hide items hover ID - if ID != 0: - self.clearObjContour(ID=ID, ax=0) - self.clearObjContour(ID=ID, ax=1) - self.ax1BrushHoverID = ID - else: - self.ax1BrushHoverID = 0 - - def updateBrushCursor(self, x, y, isHoverImg1=True): - if x is None: - return - - xdata, ydata = int(x), int(y) - _img = self.currentLab2D - Y, X = _img.shape - - if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): - return - - size = self.brushSizeSpinbox.value()*2 - self.setHoverToolSymbolData( - [x], [y], self.activeBrushCircleCursors(isHoverImg1), - size=size - ) - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - self.activeBrushCircleCursors(isHoverImg1), - self.brushButton, brush=self.ax2_BrushCircleBrush - ) - - def moveLabelButtonToggled(self, checked): - if not checked: - self.hoverLabelID = 0 - self.highlightedID = 0 - self.highLightIDLayerImg1.clear() - self.highLightIDLayerRightImage.clear() - self.setHighlightID(False) - - def setAllIDs(self, onlyVisited=False): - for posData in self.data: - posData.allIDs = set() - for frame_i in range(len(posData.segm_data)): - if frame_i >= len(posData.allData_li): - break - lab = posData.allData_li[frame_i]['labels'] - if lab is None and onlyVisited: - break - - if lab is None: - rp = skimage.measure.regionprops(posData.segm_data[frame_i]) - else: - rp = posData.allData_li[frame_i]['regionprops'] - posData.allIDs.update([obj.label for obj in rp]) - - def countObjectsTimelapse(self): - if self.countObjsWindow is None: - activeCategories = { - 'In current frame', - 'In all visited frames', - 'In entire video', - 'Unique objects in all visited frames', - 'Unique objects in entire video' - } - else: - activeCategories = self.countObjsWindow.activeCategories() - - posData = self.data[self.pos_i] - allCategoryCountMapper = posData.countObjectsInSegmTimelapse( - activeCategories - ) - if self.countObjsWindow is None: - return allCategoryCountMapper - - categoryCountMapper = {} - for category in activeCategories: - categoryCountMapper[category] = allCategoryCountMapper[category] - - return categoryCountMapper - - - def countObjectsSnapshots(self): - posData = self.data[self.pos_i] - if self.countObjsWindow is None: - activeCategories = { - 'In current position', - 'In all visited positions (current session)', - 'In all visited positions (previous sessions)', - 'In all loaded positions', - } - if self.isSegm3D: - activeCategories.add('In current z-slice') - else: - activeCategories = self.countObjsWindow.activeCategories() - - numObjectsCurrentPos = len(posData.IDs) - numObjectsAllPos = 0 - numObjectsVisitedPosPrevious = 0 - numObjectsVisitedPosCurrent = 0 - numObjectsCurrentZslice = None - if 'In current z-slice' in activeCategories: - numObjectsCurrentZslice = len( - skimage.measure.regionprops(self.currentLab2D) - ) - - for pos_i, _posData in enumerate(self.data): - IDs = _posData.allData_li[0]['IDs'] - if os.path.exists(_posData.acdc_output_csv_path): - numObjectsVisitedPosPrevious += len(IDs) - if IDs: - numObjs = len(IDs) - numObjectsAllPos += len(IDs) - else: - lab = _posData.segm_data[0] - rp = skimage.measure.regionprops(lab) - numObjs = len(rp) - numObjectsAllPos += numObjs - - if _posData.visited: - numObjectsVisitedPosCurrent += numObjs - - allCategoryCountMapper = { - 'In current position': numObjectsCurrentPos, - 'In all visited positions (current session)': - numObjectsVisitedPosCurrent, - 'In all visited positions (previous sessions)': - numObjectsVisitedPosPrevious, - 'In all loaded positions': numObjectsAllPos, - } - if numObjectsCurrentZslice is not None: - allCategoryCountMapper['In current z-slice'] = ( - numObjectsCurrentZslice - ) - - if self.countObjsWindow is None: - return allCategoryCountMapper - - categoryCountMapper = {} - for category in activeCategories: - categoryCountMapper[category] = allCategoryCountMapper[category] - - return categoryCountMapper - - def countObjects(self): - self.logger.info('Counting objects...') - - posData = self.data[self.pos_i] - if posData.SizeT > 1: - return self.countObjectsTimelapse() - - return self.countObjectsSnapshots() - - - def updateObjectCounts(self): - if self.countObjsWindow is None: - return - - if not self.countObjsWindow.isVisible(): - return - - if not self.countObjsWindow.livePreviewCheckbox.isChecked(): - return - - categoryCountMapper = self.countObjects() - self.countObjsWindow.updateCounts(categoryCountMapper) - - def keepIDs_cb(self, checked): - if checked: - self.highlightedLab = np.zeros_like(self.currentLab2D) - if self.annotCcaInfoCheckbox.isChecked(): - self.annotCcaInfoCheckbox.setChecked(False) - self.annotIDsCheckbox.setChecked(True) - self.setDrawAnnotComboboxText() - self.uncheckLeftClickButtons(None) - self.initKeepObjLabelsLayers() - self.setAllIDs() - else: - # restore items to non-grayed out - self.clearTempBrushImage() - alpha = self.imgGrad.labelsAlphaSlider.value() - self.labelsLayerImg1.setOpacity(alpha) - self.labelsLayerRightImg.setOpacity(alpha) - self.ax1_contoursImageItem.setOpacity(1.0) - self.ax2_contoursImageItem.setOpacity(1.0) - self.ax1_lostObjImageItem.setOpacity(1.0) - self.ax2_lostObjImageItem.setOpacity(1.0) - self.ax1_lostTrackedObjImageItem.setOpacity(1.0) - self.ax2_lostTrackedObjImageItem.setOpacity(1.0) - - self.keepIDsToolbar.setVisible(checked) - self.highlightedIDopts = None - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - self.updateAllImages() - - # QTimer.singleShot(300, self.autoRange) - - def get_curr_lab(self, curr_lab: np.ndarray|None = None, frame_i: int|None = None): - """Get the current labels for the position data. Hirarchically checks: - 1. If `curr_lab` is provided, use it. - 2. If `posData.lab` is not None, use it. - 3. If `posData.allData_li[frame_i]['labels']` exists, use it. - 4. If `posData.segm_data[frame_i]` exists, use it. - - If frame_i is None, uses the current frame index from `posData`. - - Parameters - ---------- - curr_lab : np.ndarray, optional - Current labels for the position data if it should be checked - if its not None first, by default None - frame_i : int, optional - Frame index to use for retrieving labels, by default None - - Returns - ------- - np.ndarray - Current labels for the position data - """ - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - if curr_lab is None and frame_i == posData.frame_i: - curr_lab = posData.lab - - if curr_lab is None: - try: - curr_lab = posData.allData_li[frame_i]['labels'].copy() - except: - pass - - if curr_lab is None: - try: - curr_lab = posData.segm_data[frame_i].copy() - except: - pass - - return curr_lab - - def setFrameNavigationDisabled(self, disable: bool, why: str): - """Disables the frame navigation buttons and scrollbar. - This is used when the user is not allowed to navigate through frames - Call again to unlock it again. Also sets tooltips to inform the user - - Parameters - ---------- - disable : bool - if the navigation should be disabled - why : str - the reason for disabeling the navigation. - """ - - if disable: - self.whyNavigateDisabled.add(why) - else: - try: - self.whyNavigateDisabled.remove(why) - except KeyError: - pass - - if len(self.whyNavigateDisabled) == 0: - disable = False - else: - disable = True - - # Apply the disable/enable state - self.prevAction.setDisabled(disable) - self.nextAction.setDisabled(disable) - self.navigateScrollBar.setDisabled(disable) - - # Set appropriate tooltip - if not disable: - self.navigateScrollBar.setToolTip( - 'NOTE: The maximum frame number that can be visualized with this ' - 'scrollbar\n' - 'is the last visited frame with the selected mode\n' - '(see "Mode" selector on the top-right).\n\n' - 'If the scrollbar does not move it means that you never visited\n' - 'any frame with current mode.\n\n' - 'Note that the "Viewer" mode allows you to scroll ALL frames.' - ) - return - - txt = f'Frame navigation disabled: {self.whyNavigateDisabled}' - self.logger.info(txt) - self.navigateScrollBar.setToolTip(txt) - - def delObjsOutSegmMaskActionTriggered(self): - posData = self.data[self.pos_i] - segm_files = load.get_segm_files(posData.images_path) - existingSegmEndnames = load.get_endnames( - posData.basename, segm_files - ) - selectSegmWin = widgets.QDialogListbox( - 'Select segmentation file', - 'Select segmentation file to use as ROI:\n', - existingSegmEndnames, multiSelection=False, parent=self - ) - selectSegmWin.exec_() - if selectSegmWin.cancel: - self.logger.info('Delete objects process cancelled.') - return - - selectedSegmEndname = selectSegmWin.selectedItemsText[0] - - self.startDelObjsOutSegmMaskWorker(selectedSegmEndname) - - def startDelObjsOutSegmMaskWorker(self, selectedSegmEndname): - self.store_data(autosave=False) - posData = self.data[self.pos_i] - segm_data = np.squeeze(self.getStoredSegmData()) - - self.progressWin = apps.QDialogWorkerProgress( - title='Deleting objects outside of ROIs', parent=self, - pbarDesc='Deleting objects outside of ROIs...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.thread = QThread() - self.worker = workers.DelObjectsOutsideSegmROIWorker( - selectedSegmEndname, segm_data, posData.images_path - ) - self.worker.moveToThread(self.thread) - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - self.worker.progress.connect(self.workerProgress) - self.worker.critical.connect(self.workerCritical) - self.worker.finished.connect(self.delObjsOutSegmMaskWorkerFinished) - - self.worker.debug.connect(self.workerDebug) - - self.thread.started.connect(self.worker.run) - self.thread.start() - - def storeViewRange(self): - if not hasattr(self, 'isRangeReset'): - return - - if not self.isRangeReset: - return - self.ax1_viewRange = self.ax1.viewRange() - self.isRangeReset = False - - def mergeObjs_cb(self, checked): - if not checked: - self.mergeObjsTempLine.setData([], []) - - def Brush_cb(self, checked): - if checked: - self.typingEditID = False - self.setDiskMask() - self.setHoverToolSymbolData( - [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX) - ) - self.updateBrushCursor(self.xHoverImg, self.yHoverImg) - self.setBrushID() - - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - c = self.defaultToolBarButtonColor - self.eraserButton.setStyleSheet(f'background-color: {c}') - self.connectLeftClickButtons() - self.setFocusGraphics() - else: - self.ax1_lostObjScatterItem.setVisible(True) - self.ax2_lostObjScatterItem.setVisible(True) - self.ax1_lostTrackedScatterItem.setVisible(True) - self.ax2_lostTrackedScatterItem.setVisible(True) - - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - self.resetCursors() - - self.showEditIDwidgets(checked) - self.enableSizeSpinbox(checked) - - def showEditIDwidgets(self, visible): - self.editIDLabelAction.setVisible(visible) - self.editIDspinboxAction.setVisible(visible) - self.autoIDcheckboxAction.setVisible(visible) - showToolbar = ( - visible - or self.brushSizeAction.isVisible() - or self.brushAutoFillAction.isVisible() - or self.brushAutoHideAction.isVisible() - ) - self.brushEraserToolBar.setVisible(showToolbar) - - def resetCursors(self): - self.ax1_cursor.setData([], []) - self.ax2_cursor.setData([], []) - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - def setDiskMask(self): - brushSize = self.brushSizeSpinbox.value() - # diam = brushSize*2 - # center = (brushSize, brushSize) - # diskShape = (diam+1, diam+1) - # diskMask = np.zeros(diskShape, bool) - # rr, cc = skimage.draw.disk(center, brushSize+1, shape=diskShape) - # diskMask[rr, cc] = True - self.diskMask = skimage.morphology.disk(brushSize, dtype=bool) - - def getDiskMask(self, xdata, ydata): - Y, X = self.currentLab2D.shape[-2:] - - brushSize = self.brushSizeSpinbox.value() - yBottom, xLeft = ydata-brushSize, xdata-brushSize - yTop, xRight = ydata+brushSize+1, xdata+brushSize+1 - - if xLeft<0: - if yBottom<0: - # Disk mask out of bounds top-left - diskMask = self.diskMask.copy() - diskMask = diskMask[-yBottom:, -xLeft:] - yBottom = 0 - elif yTop>Y: - # Disk mask out of bounds bottom-left - diskMask = self.diskMask.copy() - diskMask = diskMask[0:Y-yBottom, -xLeft:] - yTop = Y - else: - # Disk mask out of bounds on the left - diskMask = self.diskMask.copy() - diskMask = diskMask[:, -xLeft:] - xLeft = 0 - - elif xRight>X: - if yBottom<0: - # Disk mask out of bounds top-right - diskMask = self.diskMask.copy() - diskMask = diskMask[-yBottom:, 0:X-xLeft] - yBottom = 0 - elif yTop>Y: - # Disk mask out of bounds bottom-right - diskMask = self.diskMask.copy() - diskMask = diskMask[0:Y-yBottom, 0:X-xLeft] - yTop = Y - else: - # Disk mask out of bounds on the right - diskMask = self.diskMask.copy() - diskMask = diskMask[:, 0:X-xLeft] - xRight = X - - elif yBottom<0: - # Disk mask out of bounds on top - diskMask = self.diskMask.copy() - diskMask = diskMask[-yBottom:] - yBottom = 0 - - elif yTop>Y: - # Disk mask out of bounds on bottom - diskMask = self.diskMask.copy() - diskMask = diskMask[0:Y-yBottom] - yTop = Y - - else: - # Disk mask fully inside the image - diskMask = self.diskMask - - return yBottom, xLeft, yTop, xRight, diskMask - - def setBrushID(self, useCurrentLab=True, return_val=False): - # Make sure that the brushed ID is always a new one based on - # already visited frames - posData = self.data[self.pos_i] - wl_init = posData.whitelist and posData.whitelist.whitelistIDs - if useCurrentLab: - IDs_tot = set(posData.IDs) - if wl_init: - try: - IDs_tot.update(posData.whitelist.originalLabsIDs[posData.frame_i]) - except: - pass - try: - if posData.whitelist.whitelistIDs[posData.frame_i]: - IDs_tot.update(posData.whitelist.whitelistIDs[posData.frame_i]) - except: - pass - newID = max(IDs_tot, default=0) - else: - newID = 0 - for frame_i, storedData in enumerate(posData.allData_li): - if frame_i == posData.frame_i: - continue - lab = storedData['labels'] - if lab is not None: - rp = storedData['regionprops'] - IDs_tot = {obj.label for obj in rp} - if wl_init: - if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): - IDs_tot.update(posData.whitelist.originalLabsIDs[frame_i]) - if posData.whitelist.whitelistIDs[frame_i]: - IDs_tot.update(posData.whitelist.whitelistIDs[frame_i]) - _max = max(IDs_tot, default=0) - if _max > newID: - newID = _max - else: - break - - for y, x, manual_ID in posData.editID_info: - if manual_ID > newID: - newID = manual_ID - posData.brushID = newID+1 - if return_val: - return posData.brushID - - @disableWindow - def equalizeHist(self, checked=True): - self.img1.useEqualized = checked - - if not checked: - self.updateAllImages() - return - - self.logger.info('Equalizing image histogram...') - for pos_i, _posData in enumerate(self.data): - n_dim_img = _posData.img_data.ndim - _posData.equalized_img_data = preprocess.PreprocessedData() - for frame_i, img_frame in enumerate(_posData.img_data): - if n_dim_img == 4: - for z, img_z in enumerate(img_frame): - eq_img = skimage.exposure.equalize_adapthist(img_z) - _posData.equalized_img_data[frame_i][z] = eq_img - self.img1.updateMinMaxValuesEqualizedData( - self.data, pos_i, frame_i, z - ) - self.img1.updateMinMaxValuesEqualizedDataProjections( - self.data, pos_i, frame_i - ) - else: - eq_img = skimage.exposure.equalize_adapthist(img_frame) - _posData.equalized_img_data[frame_i] = eq_img - self.img1.updateMinMaxValuesEqualizedData( - self.data, pos_i, frame_i, None - ) - - self.updateAllImages() - - def curvTool_cb(self, checked): - posData = self.data[self.pos_i] - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.curvToolButton) - self.connectLeftClickButtons() - self.hoverLinSpace = np.linspace(0, 1, 1000) - self.curvPlotItem = pg.PlotDataItem(pen=self.newIDs_cpen) - self.curvHoverPlotItem = pg.PlotDataItem(pen=self.oldIDs_cpen) - self.curvAnchors = pg.ScatterPlotItem( - symbol='o', size=9, - brush=pg.mkBrush((255,0,0,50)), - pen=pg.mkPen((255,0,0), width=2), - hoverable=True, hoverPen=pg.mkPen((255,0,0), width=3), - hoverBrush=pg.mkBrush((255,0,0)), tip=None - ) - self.ax1.addItem(self.curvAnchors) - self.ax1.addItem(self.curvPlotItem) - self.ax1.addItem(self.curvHoverPlotItem) - self.splineHoverON = True - posData.curvPlotItems.append(self.curvPlotItem) - posData.curvAnchorsItems.append(self.curvAnchors) - posData.curvHoverItems.append(self.curvHoverPlotItem) - else: - self.splineHoverON = False - self.isRightClickDragImg1 = False - self.clearCurvItems() - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - self.showEditIDwidgets(checked) - - def updateHoverLabelCursor(self, x, y): - if x is None: - self.hoverLabelID = 0 - return - - xdata, ydata = int(x), int(y) - Y, X = self.currentLab2D.shape - if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): - return - - ID = self.currentLab2D[ydata, xdata] - self.hoverLabelID = ID - - if ID == 0: - if self.highlightedID != 0: - self.updateAllImages() - self.highlightedID = 0 - return - - if self.app.overrideCursor() != Qt.SizeAllCursor: - self.app.setOverrideCursor(Qt.SizeAllCursor) - - if not self.isMovingLabel: - self.highlightSearchedID(ID) - - def updateEraserCursor(self, x, y, xyLocked=None, isHoverImg1=True): - if x is None: - return - - xdata, ydata = int(x), int(y) - _img = self.currentLab2D - Y, X = _img.shape - - if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): - return - - size = self.brushSizeSpinbox.value()*2 - self.setHoverToolSymbolData( - [x], [y], self.activeEraserCircleCursors(isHoverImg1), - size=size - ) - self.setHoverToolSymbolData( - [x], [y], self.activeEraserXCursors(isHoverImg1), - size=int(size/2) - ) - - isMouseDrag = ( - self.isMouseDragImg1 or self.isMouseDragImg2 - ) - if isMouseDrag: - return - - if xyLocked is not None: - xdata, ydata = xyLocked - - self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, - self.activeEraserCircleCursors(isHoverImg1), - self.eraserButton, hoverRGB=None - ) - - def Eraser_cb(self, checked): - if checked: - self.setDiskMask() - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - self.updateEraserCursor(self.xHoverImg, self.yHoverImg) - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - c = self.defaultToolBarButtonColor - self.brushButton.setStyleSheet(f'background-color: {c}') - self.connectLeftClickButtons() - else: - self.setHoverToolSymbolData( - [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX) - ) - self.resetCursors() - self.updateAllImages() - - self.showEditIDwidgets(checked) - self.enableSizeSpinbox(checked) - - def storeCurrentAnnotOptions_ax1(self, return_value=False): - if self.annotOptionsToRestore is not None: - return - - checkboxes = [ - 'annotIDsCheckbox', - 'annotCcaInfoCheckbox', - 'annotContourCheckbox', - 'annotSegmMasksCheckbox', - 'drawMothBudLinesCheckbox', - 'annotNumZslicesCheckbox', - 'drawNothingCheckbox', - ] - annotOptions = {} - for checkboxName in checkboxes: - checkbox = getattr(self, checkboxName) - annotOptions[checkboxName] = checkbox.isChecked() - if return_value: - return annotOptions - self.annotOptionsToRestore = annotOptions - - def storeCurrentAnnotOptions_ax2(self): - if self.annotOptionsToRestoreRight is not None: - return - - checkboxes = [ - 'annotIDsCheckboxRight', - 'annotCcaInfoCheckboxRight', - 'annotContourCheckboxRight', - 'annotSegmMasksCheckboxRight', - 'drawMothBudLinesCheckboxRight', - 'annotNumZslicesCheckboxRight', - 'drawNothingCheckboxRight', - ] - self.annotOptionsToRestoreRight = {} - for checkboxName in checkboxes: - checkbox = getattr(self, checkboxName) - self.annotOptionsToRestoreRight[checkboxName] = checkbox.isChecked() - - def restoreAnnotOptions_ax1(self, options=None): - if options is None and not hasattr(self, 'annotOptionsToRestore'): - return - - if options is None: - options = self.annotOptionsToRestore - - if options is None: - return - - for option, state in options.items(): - checkbox = getattr(self, option) - checkbox.setChecked(state) - - self.setDrawAnnotComboboxText() - self.annotOptionsToRestore = None - - def restoreAnnotOptions_ax2(self): - if not hasattr(self, 'annotOptionsToRestoreRight'): - return - - if self.annotOptionsToRestoreRight is None: - return - - for option, state in self.annotOptionsToRestoreRight.items(): - checkbox = getattr(self, option) - checkbox.setChecked(state) - - self.setDrawAnnotComboboxTextRight() - self.annotOptionsToRestoreRight = None - - def setDrawNothingAnnotations(self): - self.storeCurrentAnnotOptions_ax1() - self.storeCurrentAnnotOptions_ax2() - self.drawNothingCheckbox.setChecked(True) - self.annotOptionClicked( - sender=self.drawNothingCheckbox, saveSettings=False) - self.drawNothingCheckboxRight.setChecked(True) - self.annotOptionClickedRight( - sender=self.drawNothingCheckboxRight, saveSettings=False - ) - - def restoreAnnotationsOptions(self): - self.restoreAnnotOptions_ax1() - self.restoreAnnotOptions_ax2() - - def onDoubleSpaceBar(self): - how = self.drawIDsContComboBox.currentText() - if how.find('nothing') == -1: - self.storeCurrentAnnotOptions_ax1() - self.drawNothingCheckbox.setChecked(True) - self.annotOptionClicked( - sender=self.drawNothingCheckbox, saveSettings=False - ) - else: - self.restoreAnnotOptions_ax1() - - how = self.annotateRightHowCombobox.currentText() - if how.find('nothing') == -1: - self.storeCurrentAnnotOptions_ax2() - self.drawNothingCheckboxRight.setChecked(True) - self.annotOptionClickedRight( - sender=self.drawNothingCheckboxRight, saveSettings=False - ) - else: - self.restoreAnnotOptions_ax2() - - - def resizeBottomLayoutLineClicked(self, event): - pass - - def resizeBottomLayoutLineDragged(self, event): - if not self.img1BottomGroupbox.isVisible(): - return - newBottomLayoutHeight = self.bottomScrollArea.minimumHeight() - event.y() - self.bottomScrollArea.setFixedHeight(newBottomLayoutHeight) - - def resizeBottomLayoutLineReleased(self): - QTimer.singleShot(100, self.autoRange) - - def mousePressEvent(self, event) -> None: - if event.button() == Qt.MouseButton.RightButton: - pos = self.resizeBottomLayoutLine.mapFromGlobal(event.globalPos()) - if pos.y()>=0: - self.gui_raiseBottomLayoutContextMenu(event) - return super().mousePressEvent(event) - - def zoomBottomLayoutActionTriggered(self, checked): - if not checked: - return - perc = int(re.findall(r'(\d+)%', self.sender().text())[0]) - if perc != 100: - fontSizeFactor = perc/100 - heightFactor = perc/100 - self.resizeSlidersArea( - fontSizeFactor=fontSizeFactor, heightFactor=heightFactor - ) - else: - self.gui_resetBottomLayoutHeight() - self.df_settings.at['bottom_sliders_zoom_perc', 'value'] = perc - self.df_settings.to_csv(self.settings_csv_path) - QTimer.singleShot(150, self.resizeGui) - - def defaultRescaleIntensLutActionToggled(self, action): - how = action.text() - for rescaleIntensAction in self.imgGrad.rescaleActionGroup.actions(): - if how == rescaleIntensAction.text(): - rescaleIntensAction.setChecked(True) - rescaleIntensAction.trigger() - break - - for channel, items in self.overlayLayersItems.items(): - lutItem = items[1] - for rescaleIntensAction in lutItem.rescaleActionGroup.actions(): - if how == rescaleIntensAction.text(): - rescaleIntensAction.setChecked(True) - rescaleIntensAction.trigger() - break - - self.df_settings.at['default_rescale_intens_how', 'value'] = how - self.df_settings.to_csv(self.settings_csv_path) - - def retainSpaceSlidersToggled(self, checked): - if checked: - self.df_settings.at['retain_space_hidden_sliders', 'value'] = 'Yes' - else: - self.df_settings.at['retain_space_hidden_sliders', 'value'] = 'No' - self.df_settings.to_csv(self.settings_csv_path) - if not self.zSliceScrollBar.isEnabled(): - retainSpaceZ = False - else: - retainSpaceZ = checked - myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.zProjComboBox, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.overlay_z_label, retain=retainSpaceZ) - - QTimer.singleShot(200, self.resizeGui) - - def resizeLeaveSpaceTerminalBelow(self): - self.setWindowState(Qt.WindowMaximized) - QTimer.singleShot(200, self._resizeLeaveSpaceTerminalBelow) - - def _resizeLeaveSpaceTerminalBelow(self): - geometry = self.geometry() - left = geometry.left() - top = geometry.top() - width = geometry.width() - height = geometry.height() - self.setGeometry(left, top+10, width, height-200) - - def checkSetDelObjActionActive(self, event): - if self.delObjAction is None and self.is_win: - return - - if self.delObjAction is None: - # On mac we check for Key_Control - if event.key() == Qt.Key_Control: - self.delObjToolAction.setChecked(True) - return - - delObjKeySequence, delObjQtButton = self.delObjAction - keySequenceText = widgets.QKeyEventToString(event).rstrip('+') - - if delObjKeySequence is None: - # self.delObjToolAction.setChecked(True) - return - - delObjKeySequenceText = widgets.macShortcutToWindows( - delObjKeySequence.toString() - ) - keySequenceText = widgets.macShortcutToWindows(keySequenceText) - - # printl( - # delObjKeySequence.toString(), - # keySequenceText, - # delObjKeySequenceText - # ) - - if keySequenceText == delObjKeySequenceText: - self.delObjToolAction.setChecked(True) - - def changeRightClickToLeftOnMac(self, mouseEvent): - button = mouseEvent.button() - if not is_mac: - return button - - delObjKeySequence, delObjQtButton = self.delObjAction - if delObjKeySequence is None: - return button - - if not delObjKeySequence.toString() == 'Control': - return button - - if button != Qt.MouseButton.RightButton: - return button - - if delObjQtButton == Qt.MouseButton.LeftButton: - # On mac, pressing "Control" and clicking with left button changes - # it to a right click button --> here, left click is required for - # delete object --> force return of left click - return Qt.MouseButton.LeftButton - - return button - - - def checkTriggerKeyPressShortcuts(self, event: QKeyEvent): - isBrushKey = event.key() == self.brushButton.keyPressShortcut - isEraserKey = event.key() == self.eraserButton.keyPressShortcut - if isBrushKey or isEraserKey: - return isBrushKey, isEraserKey - - modifierText = widgets.modifierKeyToText(event.modifiers()) - for widget in self.widgetsWithShortcut.values(): - if not hasattr(widget, 'keyPressShortcut'): - continue - - if event.key() == widget.keyPressShortcut: - if widget.isCheckable(): - widget.setChecked(True) - else: - widget.trigger() - continue - - shortcutText = widget.keyPressShortcut.toString() - try: - mod, key = shortcutText.split('+') - if modifierText == mod and event.key() == QKeySequence(key): - widget.trigger() - - except Exception as e: - pass - - return isBrushKey, isEraserKey - - def _temp_debug(self, id=None): - posData = self.data[self.pos_i] - imshow(posData.lab, annotate_labels_idxs=[0]) - - def checkOverlayToolbuttonClicked(self, event): - success = False - try: - n = int(event.text()) - toolbutton = self.allOverlayToolbuttonsByIdx.get(n, None) - toolbutton.click() - success = True - except Exception as e: - # printl(traceback.format_exc()) - success = False - return success - - def keyPressCheckSetSpinboxValue(self, event, spinbox): - """Check if the key pressed is a digit and set the spinbox value - accordingly.""" - try: - n = int(event.text()) - if self.typingEditID: - value = int(f'{spinbox.value()}{n}') - else: - value = n - self.typingEditID = True - spinbox.setValue(value) - - try: - spinbox.timer.stop() - except Exception as err: - pass - - spinbox.timer = QTimer(spinbox) - spinbox.timer.timeout.connect( - self.editingSpinboxValueTimerCallback - ) - spinbox.timer.start(2000) - spinbox.timer.setSingleShot(True) - success = True - except Exception as e: - # printl(traceback.format_exc()) - success = False - return success - - def editingSpinboxValueTimerCallback(self): - self.typingEditID = False - - @exception_handler - def keyPressEvent(self, ev): - ctrl = ev.modifiers() == Qt.ControlModifier - if ctrl and ev.key() == Qt.Key_D: - self.resizeLeaveSpaceTerminalBelow() - return - - if ev.key() == Qt.Key_Q and self.debug: - try: - from . import _q_debug - _q_debug.q_debug(self) - except Exception as err: - printl(traceback.format_exc()) - printl('[ERROR]: Error with "_qdebug" module. See Traceback above.') - pass - - if not self.isDataLoaded: - self.logger.warning( - 'Data not loaded yet. Key pressing events are not connected.' - ) - return - - if ev.key() == Qt.Key_Control: - if not ctrl: - self.wasCtrlPressedFirstTime = True - self.onCtrlPressedFirstTime() - - if ev.key() == Qt.Key_PageDown: - self.onKeyPageDown() - - if ev.key() == Qt.Key_PageUp: - self.onKeyPageUp() - - if ev.key() == Qt.Key_Home: - self.onKeyHome() - - if ev.key() == Qt.Key_End: - self.onKeyEnd() - - modifiers = ev.modifiers() - isAltModifier = modifiers == Qt.AltModifier - isCtrlModifier = modifiers == Qt.ControlModifier - isShiftModifier = modifiers == Qt.ShiftModifier - - self.checkSetDelObjActionActive(ev) - - self.isZmodifier = ( - ev.key()== Qt.Key_Z and not isAltModifier - and not isCtrlModifier and not isShiftModifier - ) - if isShiftModifier: - if self.brushButton.isChecked(): - # Force default brush symbol with shift down - self.setHoverToolSymbolColor( - 1, 1, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush, - ID=0 - ) - if self.isSegm3D: - self.changeBrushID() - - isAnyModifier = isAltModifier or isCtrlModifier or isShiftModifier - if not isAnyModifier and self.overlayButton.isChecked(): - isButtonClicked = self.checkOverlayToolbuttonClicked(ev) - if isButtonClicked: - return - - isBrushActive = ( - self.brushButton.isChecked() or self.eraserButton.isChecked() - ) - isManualTrackingActive = self.manualTrackingButton.isChecked() - isManualBackgroundActive = self.manualBackgroundButton.isChecked() - isTypingIDFunctionChecked = False - if self.brushButton.isChecked() and not self.autoIDcheckbox.isChecked(): - success = self.keyPressCheckSetSpinboxValue(ev, self.editIDspinbox) - isTypingIDFunctionChecked = True - - if isManualTrackingActive: - isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( - ev, self.manualTrackingToolbar.spinboxID - ) - - elif isManualBackgroundActive: - isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( - ev, self.manualBackgroundToolbar.spinboxID - ) - - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - if ( - addPointsByClickingButton is not None - and addPointsByClickingButton.toolbar.isVisible() - ): - isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( - ev, addPointsByClickingButton.rightClickIDSpinbox - ) - - isBrushKey, isEraserKey = self.checkTriggerKeyPressShortcuts(ev) - isExpandLabelActive = self.expandLabelToolButton.isChecked() - isWandActive = self.wandToolButton.isChecked() - isLabelRoiCircActive = ( - self.labelRoiButton.isChecked() - and self.labelRoiIsCircularRadioButton.isChecked() - ) - how = self.drawIDsContComboBox.currentText() - isOverlaySegm = how.find('overlay segm. masks') != -1 - if ev.key()==Qt.Key_Up and not isCtrlModifier: - self.keyUpCallback( - isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive - ) - elif ev.key()==Qt.Key_Down and not isCtrlModifier: - self.keyDownCallback( - isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive - ) - elif ev.key() == Qt.Key_Enter or ev.key() == Qt.Key_Return: - if isTypingIDFunctionChecked: - self.typingEditID = False - elif self.keepIDsButton.isChecked(): - self.keepIDsConfirmAction.trigger() - elif ev.key() == Qt.Key_Escape: - self.onEscape(isTypingIDFunctionChecked=isTypingIDFunctionChecked) - elif isAltModifier: - isCursorSizeAll = self.app.overrideCursor() == Qt.SizeAllCursor - # Alt is pressed while cursor is on images --> set SizeAllCursor - if self.xHoverImg is not None and not isCursorSizeAll: - self.app.setOverrideCursor(Qt.SizeAllCursor) - elif isCtrlModifier and isOverlaySegm: - if ev.key() == Qt.Key_Up: - val = self.imgGrad.labelsAlphaSlider.value() - delta = 5/self.imgGrad.labelsAlphaSlider.maximum() - val = val+delta - self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) - elif ev.key() == Qt.Key_Down: - val = self.imgGrad.labelsAlphaSlider.value() - delta = 5/self.imgGrad.labelsAlphaSlider.maximum() - val = val-delta - self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) - elif ev.key() == self.zoomOutKeyValue: - self.zoomToCells(enforce=True) - if self.countKeyPress == 0: - self.isKeyDoublePress = False - self.countKeyPress = 1 - self.doubleKeyTimeElapsed = False - self.Button = None - QTimer.singleShot(400, self.doubleKeyTimerCallBack) - elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed: - self.ax1.autoRange() - self.isKeyDoublePress = True - self.countKeyPress = 0 - elif ev.key() == Qt.Key_Space: - if self.countKeyPress == 0: - # Single press --> wait that it's not double press - self.isKeyDoublePress = False - self.countKeyPress = 1 - self.doubleKeyTimeElapsed = False - QTimer.singleShot(300, self.doubleKeySpacebarTimerCallback) - elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed: - self.isKeyDoublePress = True - # Double press --> toggle draw nothing - self.onDoubleSpaceBar() - self.countKeyPress = 0 - elif isBrushKey or isEraserKey: - if isBrushKey: - self.Button = self.brushButton - else: - self.Button = self.eraserButton - - if not self.Button.isVisible(): - return - - if self.countKeyPress == 0: - # If first time clicking B activate brush and start timer - # to catch double press of B - if not self.Button.isChecked(): - self.uncheck = False - self.Button.setChecked(True) - else: - self.uncheck = True - self.countKeyPress = 1 - self.isKeyDoublePress = False - self.doubleKeyTimeElapsed = False - - QTimer.singleShot(400, self.doubleKeyTimerCallBack) - elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed: - self.isKeyDoublePress = True - color = self.Button.palette().button().color().name() - if color == self.doublePressKeyButtonColor: - c = self.defaultToolBarButtonColor - else: - c = self.doublePressKeyButtonColor - self.Button.setStyleSheet(f'background-color: {c}') - self.countKeyPress = 0 - if self.xHoverImg is not None: - xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) - if isBrushKey: - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush - ) - elif isEraserKey: - self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, - (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton - ) - - def doubleRightClickTimerCallBack(self): - if self.isDoubleRightClick: - self.doubleRightClickTimeElapsed = False - return - self.doubleRightClickTimeElapsed = True - self.countRightClicks = 0 - - # Time to double right click on img1 expired --> single right-click - self.gui_imgGradShowContextMenu(*self._img1_click_xy) - - def doubleKeyTimerCallBack(self): - if self.isKeyDoublePress: - self.doubleKeyTimeElapsed = False - return - self.doubleKeyTimeElapsed = True - self.countKeyPress = 0 - if self.Button is None: - return - - isBrushChecked = self.Button.isChecked() - if isBrushChecked and self.uncheck: - self.Button.setChecked(False) - c = self.defaultToolBarButtonColor - self.Button.setStyleSheet(f'background-color: {c}') - - def doubleKeySpacebarTimerCallback(self): - if self.isKeyDoublePress: - self.doubleKeyTimeElapsed = False - return - self.doubleKeyTimeElapsed = True - self.countKeyPress = 0 - - # # Spacebar single press --> toggle next visualization - # currentIndex = self.drawIDsContComboBox.currentIndex() - # nItems = self.drawIDsContComboBox.count() - # nextIndex = currentIndex+1 - # if nextIndex < nItems: - # self.drawIDsContComboBox.setCurrentIndex(nextIndex) - # else: - # self.drawIDsContComboBox.setCurrentIndex(0) - - def updateBrushCursorOnShiftRelease(self): - xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush, - byPassShiftCheck=True - ) - if self.isSegm3D: - self.changeBrushID() - - def onShiftReleased(self): - if self.brushButton.isChecked() and self.xHoverImg is not None: - self.updateBrushCursorOnShiftRelease() - - def keyReleaseEvent(self, ev): - if self.app.overrideCursor() == Qt.SizeAllCursor: - self.app.restoreOverrideCursor() - if ev.key() == Qt.Key_Control: - self.onCtrlReleased() - elif ev.key() == Qt.Key_Shift: - self.onShiftReleased() - - canRepeat = ( - ev.key() == Qt.Key_Left - or ev.key() == Qt.Key_Right - or ev.key() == Qt.Key_Up - or ev.key() == Qt.Key_Down - or ev.key() == Qt.Key_Control - or ev.key() == Qt.Key_Backspace - or self.delObjToolAction.isChecked() - ) - - if canRepeat and ev.isAutoRepeat(): - return - - self.delObjToolAction.setChecked(False) - - if ev.isAutoRepeat() and not ev.key() == Qt.Key_Z: - if self.warnKeyPressedMsg is not None: - return - self.warnKeyPressedMsg = widgets.myMessageBox( - showCentered=False, wrapText=False - ) - txt = html_utils.paragraph(f""" - Please, do not keep the key "{ev.text().upper()}" - pressed.

- It confuses me :)

- Thanks! - """) - self.warnKeyPressedMsg.warning(self, 'Release the key, please', txt) - self.warnKeyPressedMsg = None - elif ev.isAutoRepeat() and ev.key() == Qt.Key_Z and self.isZmodifier: - self.zKeptDown = True - elif ev.key() == Qt.Key_Z and self.isZmodifier: - posData = self.data[self.pos_i] - self.isZmodifier = False - if not self.zKeptDown and posData.SizeZ > 1: - self.zSliceCheckbox.setChecked(not self.zSliceCheckbox.isChecked()) - self.zKeptDown = False - - def setUncheckedAllButtons(self, buttonsToNotUncheck=None): - self.clickedOnBud = False - if buttonsToNotUncheck is None: - buttonsToNotUncheck = set() - - try: - self.BudMothTempLine.setData([], []) - except Exception as e: - pass - for button in self.checkableButtons: - if button in buttonsToNotUncheck: - continue - button.setChecked(False) - - if self.countObjsButton not in buttonsToNotUncheck: - self.countObjsButton.setChecked(False) - self.splineHoverON = False - self.tempSegmentON = False - self.isRightClickDragImg1 = False - self.clearCurvItems(removeItems=False) - - def setUncheckedAllCustomAnnotButtons(self): - for button in self.customAnnotDict.keys(): - button.setChecked(False) - - def askPropagateChangePast(self, change_txt): - txt = html_utils.paragraph(f""" - Do you want to propagate the change "{change_txt}" to the past frames? - """) - msg = widgets.myMessageBox(wrapText=False) - yesButton, _ = msg.question( - self, 'Propagate change to past frames', txt, - buttonsTexts=('Yes', 'No') - ) - return msg.clickedButton == yesButton - - def propagateMergeObjsPast(self, IDs_to_merge): - self.store_data(autosave=False) - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - for past_frame_i in range(posData.frame_i-1, -1, -1): - posData.frame_i = past_frame_i - self.get_data() - - IDs = posData.allData_li[past_frame_i]['IDs'] - stop_loop = False - for ID in IDs_to_merge: - if ID not in IDs: - stop_loop = True - break - - if ID == 0: - continue - posData.lab[posData.lab==ID] = self.firstID - self.update_rp() - - self.store_data(autosave=False) - - if stop_loop: - break - - posData.frame_i = current_frame_i - self.get_data() - - def propagateChange( - self, modID, modTxt, doNotShow, UndoFutFrames, - applyFutFrames, applyTrackingB=False, force=False - ): - """ - This function determines whether there are already visited future frames - that contains "modID". If so, it triggers a pop-up asking the user - what to do (propagate change to future frames o not) - """ - posData = self.data[self.pos_i] - # Do not check the future for the last frame - if posData.frame_i+1 == posData.SizeT: - # No future frames to propagate the change to - return False, False, None, doNotShow - - includeUnvisited = posData.includeUnvisitedInfo.get(modTxt, False) - areFutureIDs_affected = [] - # Get number of future frames already visited and check if future - # frames has an ID affected by the change - last_tracked_i_found = False - segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i+1, segmSizeT): - if posData.allData_li[i]['labels'] is None: - if not last_tracked_i_found: - # We set last tracked frame at -1 first None found - last_tracked_i = i - 1 - last_tracked_i_found = True - if not includeUnvisited: - # Stop at last visited frame since includeUnvisited = False - break - else: - lab = posData.segm_data[i] - else: - lab = posData.allData_li[i]['labels'] - - if modID in lab: - areFutureIDs_affected.append(True) - - if not last_tracked_i_found: - # All frames have been visited in segm&track mode - last_tracked_i = posData.SizeT - 1 - - if last_tracked_i == posData.frame_i and not includeUnvisited: - # No future frames to propagate the change to - return False, False, None, doNotShow - - if not areFutureIDs_affected and not force: - # There are future frames but they are not affected by the change - return UndoFutFrames, False, None, doNotShow - - # Ask what to do unless the user has previously checked doNotShowAgain - if doNotShow: - endFrame_i = last_tracked_i - if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': - self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) - return UndoFutFrames, applyFutFrames, endFrame_i, doNotShow - else: - addApplyAllButton = ( - modTxt == 'Delete ID' or modTxt == 'Edit ID' - or modTxt == 'Assign new ID' - ) - ffa = apps.FutureFramesAction_QDialog( - posData.frame_i+1, last_tracked_i, modTxt, - applyTrackingB=applyTrackingB, parent=self, - addApplyAllButton=addApplyAllButton - ) - ffa.exec_() - decision = ffa.decision - - if decision is None: - return None, None, None, doNotShow - - endFrame_i = ffa.endFrame_i - doNotShowAgain = ffa.doNotShowCheckbox.isChecked() - askAction = self.askHowFutureFramesActions[modTxt] - askAction.setChecked( not doNotShowAgain) - askAction.setDisabled(False) - - self.onlyTracking = False - if decision == 'apply_and_reinit': - UndoFutFrames = True - applyFutFrames = False - elif decision == 'apply_and_NOTreinit': - UndoFutFrames = False - applyFutFrames = False - elif decision == 'apply_to_all_visited': - UndoFutFrames = False - applyFutFrames = True - elif decision == 'only_tracking': - UndoFutFrames = False - applyFutFrames = True - self.onlyTracking = True - elif decision == 'apply_to_all': - UndoFutFrames = False - applyFutFrames = True - posData.includeUnvisitedInfo[modTxt] = True - - if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': - self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) - return UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain - - def addCcaState(self, frame_i, cca_df, undoId): - posData = self.data[self.pos_i] - posData.UndoRedoCcaStates[frame_i].insert( - 0, {'id': undoId, 'cca_df': cca_df.copy()} - ) - - def addCurrentState(self, storeImage=False, storeOnlyZoom=False): - posData = self.data[self.pos_i] - if posData.cca_df is not None: - cca_df = posData.cca_df.copy() - else: - cca_df = None - - if storeImage: - image = self.img1.image.copy() - else: - image = None - - if storeOnlyZoom: - labels, crop_slice = transformation.crop_2D( - self.currentLab2D, self.ax1.viewRange(), tolerance=10, - return_copy=False - ) - if self.isSegm3D: - z = self.z_lab(checkIfProj=True) - if z is None: - z_slice = slice(0, len(posData.lab)) - crop_slice = (z_slice, *crop_slice) - labels = posData.lab[crop_slice].copy() - else: - z_slice = z - crop_slice = (z_slice, *crop_slice) - labels = labels.copy() - else: - labels = labels.copy() - else: - labels = posData.lab.copy() - crop_slice = None - - state = { - 'image': image, - 'labels': labels, - 'editID_info': posData.editID_info.copy(), - 'binnedIDs': posData.binnedIDs.copy(), - 'keptObejctsIDs': self.keptObjectsIDs.copy(), - 'ripIDs': posData.ripIDs.copy(), - 'cca_df': cca_df, - 'crop_slice': crop_slice - } - posData.UndoRedoStates[posData.frame_i].insert(0, state) - - # posData.storedLab = np.array(posData.lab, order='K', copy=True) - # self.storeStateWorker.callbackOnDone = callbackOnDone - # self.storeStateWorker.enqueue(posData, self.img1.image) - - def getCurrentState(self): - posData = self.data[self.pos_i] - i = posData.frame_i - c = self.UndoCount - state = posData.UndoRedoStates[i][c] - if state['image'] is None: - image_left = None - else: - image_left = state['image'].copy() - - crop_slice = state['crop_slice'] - if crop_slice is None: - posData.lab = state['labels'].copy() - elif self.isSegm3D: - z_slice, slice_y, slice_x = crop_slice - posData.lab[..., z_slice, slice_y, slice_x] = state['labels'].copy() - else: - slice_y, slice_x = crop_slice - posData.lab[..., slice_y, slice_x] = state['labels'].copy() - - posData.editID_info = state['editID_info'].copy() - posData.binnedIDs = state['binnedIDs'].copy() - posData.ripIDs = state['ripIDs'].copy() - self.keptObjectsIDs = state['keptObejctsIDs'].copy() - cca_df = state['cca_df'] - if cca_df is not None: - posData.cca_df = state['cca_df'].copy() - else: - posData.cca_df = None - return image_left - - def storeLabelRoiParams(self, value=None, checked=True): - checkedRoiType = self.labelRoiTypesGroup.checkedButton().text() - circRoiRadius = self.labelRoiCircularRadiusSpinbox.value() - roiZdepth = self.labelRoiZdepthSpinbox.value() - autoClearBorder = self.labelRoiAutoClearBorderCheckbox.isChecked() - clearBorder = 'Yes' if autoClearBorder else 'No' - self.df_settings.at['labelRoi_checkedRoiType', 'value'] = checkedRoiType - self.df_settings.at['labelRoi_circRoiRadius', 'value'] = circRoiRadius - self.df_settings.at['labelRoi_roiZdepth', 'value'] = roiZdepth - self.df_settings.at['labelRoi_autoClearBorder', 'value'] = clearBorder - self.df_settings.at['labelRoi_replaceExistingObjects', 'value'] = ( - 'Yes' if self.labelRoiReplaceExistingObjectsCheckbox.isChecked() - else 'No' - ) - self.df_settings.to_csv(self.settings_csv_path) - - def loadLabelRoiLastParams(self): - idx = 'labelRoi_checkedRoiType' - if idx in self.df_settings.index: - checkedRoiType = self.df_settings.at[idx, 'value'] - for button in self.labelRoiTypesGroup.buttons(): - if button.text() == checkedRoiType: - button.setChecked(True) - break - - idx = 'labelRoi_circRoiRadius' - if idx in self.df_settings.index: - circRoiRadius = self.df_settings.at[idx, 'value'] - self.labelRoiCircularRadiusSpinbox.setValue(int(circRoiRadius)) - - idx = 'labelRoi_roiZdepth' - if idx in self.df_settings.index: - roiZdepth = self.df_settings.at[idx, 'value'] - self.labelRoiZdepthSpinbox.setValue(int(roiZdepth)) - - idx = 'labelRoi_autoClearBorder' - if idx in self.df_settings.index: - clearBorder = self.df_settings.at[idx, 'value'] - checked = clearBorder == 'Yes' - self.labelRoiAutoClearBorderCheckbox.setChecked(checked) - - idx = 'labelRoi_replaceExistingObjects' - if idx in self.df_settings.index: - val = self.df_settings.at[idx, 'value'] - checked = val == 'Yes' - self.labelRoiReplaceExistingObjectsCheckbox.setChecked(checked) - - if self.labelRoiIsCircularRadioButton.isChecked(): - self.labelRoiCircularRadiusSpinbox.setDisabled(False) - - # @exec_time - def storeUndoRedoStates( - self, UndoFutFrames, storeImage=False, storeOnlyZoom=False - ): - posData = self.data[self.pos_i] - if UndoFutFrames: - # Since we modified current frame all future frames that were already - # visited are not valid anymore. Undo changes there - self.reInitLastSegmFrame(updateImages=False) - - # Keep only 5 Undo/Redo states - if len(posData.UndoRedoStates[posData.frame_i]) > 5: - posData.UndoRedoStates[posData.frame_i].pop(-1) - - # Restart count from the most recent state (index 0) - # NOTE: index 0 is most recent state before doing last change - self.UndoCount = 0 - self.undoAction.setEnabled(True) - self.addCurrentState( - storeImage=storeImage, storeOnlyZoom=storeOnlyZoom - ) - - def storeUndoRedoCca(self, frame_i, cca_df, undoId): - if self.isSnapshot: - # For snapshot mode we don't store anything because we have only - # segmentation undo action active - return - """ - Store current cca_df along with a unique id to know which cca_df needs - to be restored - """ - - posData = self.data[self.pos_i] - - # Restart count from the most recent state (index 0) - # NOTE: index 0 is most recent state before doing last change - self.UndoCcaCount = 0 - self.undoAction.setEnabled(True) - - self.addCcaState(frame_i, cca_df, undoId) - - # Keep only 10 Undo/Redo states - if len(posData.UndoRedoCcaStates[frame_i]) > 10: - posData.UndoRedoCcaStates[frame_i].pop(-1) - - def undoCustomAnnotation(self): - pass - - def UndoCca(self): - posData = self.data[self.pos_i] - # Undo current ccaState - storeState = False - if self.UndoCount == 0: - undoId = uuid.uuid4() - self.addCcaState(posData.frame_i, posData.cca_df, undoId) - storeState = True - - - # Get previously stored state - self.UndoCount += 1 - currentCcaStates = posData.UndoRedoCcaStates[posData.frame_i] - prevCcaState = currentCcaStates[self.UndoCount] - posData.cca_df = prevCcaState['cca_df'] - self.store_cca_df() - self.updateAllImages() - - # Check if we have undone all states - if len(currentCcaStates) > self.UndoCount: - # There are no states left to undo for current frame_i - self.undoAction.setEnabled(False) - - # Undo all past and future frames that has a last status inserted - # when modyfing current frame - prevStateId = prevCcaState['id'] - for frame_i in range(0, posData.SizeT): - if storeState: - cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) - if cca_df_i is None: - break - # Store current state to enable redoing it - self.addCcaState(frame_i, cca_df_i, undoId) - - CcaStates_i = posData.UndoRedoCcaStates[frame_i] - if len(CcaStates_i) <= self.UndoCount: - # There are no states to undo for frame_i - continue - - CcaState_i = CcaStates_i[self.UndoCount] - id_i = CcaState_i['id'] - if id_i != prevStateId: - # The id of the state in frame_i is different from current frame - continue - - cca_df_i = CcaState_i['cca_df'] - self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) - - self.resetWillDivideInfo() - self.enqAutosave() - - def undo(self): - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - if addPointsByClickingButton is not None: - done = self.undoAddPoint(addPointsByClickingButton.action) - if done: - return - - if self.UndoCount == 0: - # Store current state to enable redoing it - self.addCurrentState() - - posData = self.data[self.pos_i] - # Get previously stored state - if self.UndoCount < len(posData.UndoRedoStates[posData.frame_i])-1: - self.UndoCount += 1 - # Since we have undone then it is possible to redo - self.redoAction.setEnabled(True) - - # Restore state - image_left = self.getCurrentState() - self.update_rp() - self.updateAllImages(image=image_left) - self.store_data() - - if not self.UndoCount < len(posData.UndoRedoStates[posData.frame_i])-1: - # We have undone all available states - self.undoAction.setEnabled(False) - - if self.whitelistIDsButton.isChecked(): - self.whitelistHighlightIDs() - - def redo(self): - posData = self.data[self.pos_i] - # Get previously stored state - if self.UndoCount > 0: - self.UndoCount -= 1 - # Since we have redone then it is possible to undo - self.undoAction.setEnabled(True) - - # Restore state - image_left = self.getCurrentState() - self.update_rp() - self.updateAllImages(image=image_left) - self.store_data() - - if not self.UndoCount > 0: - # We have redone all available states - self.redoAction.setEnabled(False) - - if self.whitelistIDsButton.isChecked(): - self.whitelistHighlightIDs() - - def realTimeTrackingClicked(self, checked): - # Event called ONLY if the user click on Disable tracking - # NOT called if setChecked is called. This allows to keep track - # of the user choice. This way user con enforce tracking - # NOTE: I know two booleans doing the same thing is overkill - # but the code is more readable when we actually need them - - posData = self.data[self.pos_i] - isRealTimeTrackingDisabled = not checked - - # Turn off smart tracking - self.enableSmartTrackAction.toggled.disconnect() - self.enableSmartTrackAction.setChecked(False) - if isRealTimeTrackingDisabled: - self.UserEnforced_DisabledTracking = True - self.UserEnforced_Tracking = False - else: - txt = html_utils.paragraph(""" - - Do you want to keep tracking always active including on already - visited frames?

- Note: To re-activate automatic handling of tracking go to
- Edit --> Smart handling of enabling/disabling tracking. - - """) - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - yesButton, noButton = msg.question( - self, 'Keep tracking always active?', txt, - buttonsTexts=('Yes', 'No') - ) - if msg.clickedButton == yesButton: - self.repeatTracking() - self.UserEnforced_DisabledTracking = False - self.UserEnforced_Tracking = True - else: - self.enableSmartTrackAction.setChecked(True) - - @exception_handler - def repeatTrackingVideo(self, checked=False): - posData = self.data[self.pos_i] - win = widgets.selectTrackerGUI( - posData.SizeT, currentFrameNo=posData.frame_i+1 - ) - win.exec_() - if win.cancel: - self.logger.info('Tracking aborted.') - return - - trackerName = win.selectedItemsText[0] - start_n = win.startFrame - stop_n = win.stopFrame - video_to_track = posData.segm_data - for frame_i in range(start_n-1, stop_n): - data_dict = posData.allData_li[frame_i] - lab = data_dict['labels'] - if lab is None: - break - - video_to_track[frame_i] = lab - video_to_track = video_to_track[start_n-1:stop_n] - - self.logger.info(f'Importing {trackerName} tracker...') - self.tracker, self.track_params, init_params = myutils.init_tracker( - posData, trackerName, qparent=self, return_init_params=True - ) - if self.track_params is None: - self.logger.info('Tracking aborted.') - return - - warningText = myutils.validate_tracker_input( - self.tracker, video_to_track - ) - if warningText is not None: - self.logger.info(warningText) - self.warnTrackerInputNotValid(trackerName, warningText) - return - - if 'image_channel_name' in self.track_params: - # Remove the channel name since it was already loaded in init_tracker - del self.track_params['image_channel_name'] - - track_params_log = { - key: value for key, value in self.track_params.items() - if key != 'image' - } - self.logger.info( - 'Tracking parameters:\n\n' - f'Initialization parameters: {init_params}\n' - f'Track parameters: {track_params_log}' - ) - - last_cca_i = self.get_last_cca_frame_i() - if start_n-2 <= last_cca_i and start_n>1: - proceed = self.warnRepeatTrackingVideoWithAnnotations( - last_cca_i, start_n - ) - if not proceed: - self.logger.info('Tracking aborted.') - return - - self.logger.info(f'Removing annotations from frame n. {start_n}.') - self.resetCcaFuture(start_n-1) - - self.start_n = start_n - self.stop_n = stop_n - - info_txt = f'Tracking from frame n. {start_n} to {stop_n}...' - self.logger.info(info_txt) - - self.progressWin = apps.QDialogWorkerProgress( - title='Tracking', parent=self, pbarDesc=info_txt - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stop_n-start_n) - self.startTrackingWorker(posData, video_to_track) - - def warnTrackerInputNotValid(self, trackerName, warningText): - msg = widgets.myMessageBox(wrapText=False) - txt = warningText.replace('\n', '
') - txt = html_utils.paragraph( - f'{txt}

' - 'Tracking process will be cancelled. Thank you for your patience!' - ) - msg.warning(self, 'Invalid input for tracker', txt) - - def repeatTracking(self): - posData = self.data[self.pos_i] - prev_lab = self.get_2Dlab(posData.lab).copy() - self.tracking(enforce=True, DoManualEdit=False) - if posData.editID_info: - editedIDsInfo = { - posData.lab[y,x]:newID - for y, x, newID in posData.editID_info - if posData.lab[y,x] != newID - } - editedIDsInfoItems = [ - f'ID {oldID} --> {newID}' - for oldID, newID in editedIDsInfo.items() - ] - editIDul = html_utils.to_list(editedIDsInfoItems) - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - You requested to repeat tracking but there are manually - edited IDs (see edited IDs in the details section below) -

- Do you want to keep these edits or ignore them? - """) - keepManualEditButton = widgets.okPushButton( - 'Keep manually edited IDs' - ) - ignoreButton = widgets.noPushButton( - 'Ignore manually edited IDs' - ) - msg.question( - self, 'Repeat tracking mode', txt, - buttonsTexts=(keepManualEditButton, ignoreButton), - detailsText=editIDul - ) - if msg.cancel: - return - if msg.clickedButton == keepManualEditButton: - allIDs = [obj.label for obj in posData.rp] - lab2D = self.get_2Dlab(posData.lab) - self.manuallyEditTracking(lab2D, allIDs) - self.update_rp() - self.setAllTextAnnotations() - self.highlightLostNew() - # self.checkIDsMultiContour() - else: - posData.editID_info = [] - if np.any(posData.lab != prev_lab): - if self.isSnapshot: - self.fixCcaDfAfterEdit('Repeat tracking') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Repeat tracking') - else: - self.updateAllImages() - - def updateGhostMaskOpacity(self, alpha_percentage=None): - if alpha_percentage is None: - alpha_percentage = ( - self.manualTrackingToolbar.ghostMaskOpacitySpinbox.value() - ) - alpha = alpha_percentage/100 - self.ghostMaskItemLeft.setOpacity(alpha) - self.ghostMaskItemRight.setOpacity(alpha) - - def addManualTrackingItems(self): - self.ghostContourItemLeft.addToPlotItem() - self.ghostContourItemRight.addToPlotItem() - - self.ghostMaskItemLeft.addToPlotItem() - self.ghostMaskItemRight.addToPlotItem() - - Y, X = self.img1.image.shape[:2] - self.ghostMaskItemLeft.initImage((Y, X)) - self.ghostMaskItemRight.initImage((Y, X)) - - self.updateGhostMaskOpacity() - - def removeManualTrackingItems(self): - self.ghostContourItemLeft.removeFromPlotItem() - self.ghostContourItemRight.removeFromPlotItem() - - self.ghostMaskItemLeft.removeFromPlotItem() - self.ghostMaskItemRight.removeFromPlotItem() - - def addManualBackgroundItems(self): - self.manualBackgroundObjItem.addToPlotItem() - self.ax1.addItem(self.manualBackgroundImageItem) - - def removeManualBackgroundItems(self): - self.manualBackgroundObjItem.removeFromPlotItem() - self.ax1.removeItem(self.manualBackgroundImageItem) - - def resetManualBackgroundSpinboxID(self): - if not self.manualBackgroundButton.isChecked(): - self.manualBackgroundObj = None - return - - posData = self.data[self.pos_i] - minID = min(posData.IDs, default=0) - self.manualBackgroundToolbar.spinboxID.setValue(minID) - - def initManualBackgroundObject(self, ID=None): - if not self.manualBackgroundButton.isChecked(): - self.manualBackgroundObj = None - return - - if ID is None: - ID = self.manualBackgroundToolbar.spinboxID.value() - - posData = self.data[self.pos_i] - if ID not in posData.IDs: - self.manualBackgroundObj = None - self.manualBackgroundToolbar.showWarning( - f'The ID {ID} does not exist' - ) - self.manualBackgroundObjItem.clear() - return - - ID_idx = posData.IDs_idxs[ID] - self.manualBackgroundObj = posData.rp[ID_idx] - - self.manualBackgroundToolbar.clearInfoText() - self.manualBackgroundObj.contour = self.getObjContours( - self.manualBackgroundObj, local=True - ) - xx_contour = self.manualBackgroundObj.contour[:,0] - yy_contour = self.manualBackgroundObj.contour[:,1] - self.manualBackgroundObj.xx_contour = xx_contour - self.manualBackgroundObj.yy_contour = yy_contour - - def initGhostObject(self, ID=None): - mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': - self.ghostObject = None - return - - if not self.manualTrackingButton.isChecked(): - self.ghostObject = None - return - - if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): - self.ghostObject = None - return - - if ID is None: - ID = self.manualTrackingToolbar.spinboxID.value() - - posData = self.data[self.pos_i] - if posData.frame_i == 0: - self.ghostObject = None - return - - prevFrameRp = posData.allData_li[posData.frame_i-1]['regionprops'] - if prevFrameRp is None: - self.ghostObject = None - return - - for obj in prevFrameRp: - if obj.label != ID: - continue - self.ghostObject = obj - break - else: - self.ghostObject = None - self.manualTrackingToolbar.showWarning( - f'The ID {ID} does not exist in previous frame ' - '--> starting a new track.' - ) - return - - self.manualTrackingToolbar.clearInfoText() - - self.ghostObject.contour = self.getObjContours( - self.ghostObject, local=True - ) - self.ghostObject.xx_contour = self.ghostObject.contour[:,0] - self.ghostObject.yy_contour = self.ghostObject.contour[:,1] - - self.ghostMaskItemLeft.initLookupTable(self.lut[ID]) - self.ghostMaskItemRight.initLookupTable(self.lut[ID]) - - def clearGhost(self): - self.clearGhostContour() - self.clearGhostMask() - - def clearManualBackgroundAnnotations(self): - try: - for textItem in self.manualBackgroundTextItems.values(): - textItem.setText('') - except Exception as error: - pass - - def clearGhostContour(self): - self.ghostContourItemLeft.clear() - self.ghostContourItemRight.clear() - self.manualBackgroundObjItem.clear() - - def clearGhostMask(self): - self.ghostMaskItemLeft.clear() - self.ghostMaskItemRight.clear() - - @disableWindow - def _importInitMagicPromptModel( - self, model_name, posData, win, acdcPromptSegment, toolbar - ): - self.logger.info(f'Initializing promptable model {model_name}...') - init_kwargs = win.init_kwargs - model = myutils.init_prompt_segm_model( - acdcPromptSegment, posData, win.init_kwargs - ) - toolbar.model = model - toolbar.model_segment_kwargs = win.model_kwargs - toolbar.model_name = model_name - toolbar.viewModelParamsAction.setDisabled(False) - - self.magicPromptsToolbar.setInitializedModel( - init_kwargs, toolbar.model_segment_kwargs - ) - - self.logger.info( - f'Promptable model {model_name} successfully initialised!' - ) - - @exception_handler - def magicPromptsInitModel( - self, - model_name, - acdcPromptSegment, - init_argspecs, - segment_argspecs, - help_url, - toolbar, - ): - posData = self.data[self.pos_i] - - out = prompts.init_prompt_model_params( - posData, model_name, init_argspecs, segment_argspecs, - help_url=help_url, qparent=self, init_last_params=True - ) - win = out.get('win') - if win.cancel: - self.logger.info( - f'Initialization of {model_name} promptable model cancelled.' - ) - return - - self._importInitMagicPromptModel( - model_name, posData, win, acdcPromptSegment, toolbar - ) - - def viewSetMagicPromptModelParams( - self, - model_name, - acdcPromptSegment, - init_argspecs, - segment_argspecs, - help_url, - init_kwargs, - segment_kwargs, - toolbar - ): - posData = self.data[self.pos_i] - - init_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( - init_argspecs, init_kwargs - ) - segment_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( - segment_argspecs, segment_kwargs - ) - - out = prompts.init_prompt_model_params( - posData, model_name, init_argspecs, segment_argspecs, - help_url=help_url, qparent=self, init_last_params=False - ) - win = out.get('win') - if win.cancel: - return - - if win.model_kwargs != segment_kwargs or win.init_kwargs != init_kwargs: - self._importInitMagicPromptModel( - model_name, posData, win, acdcPromptSegment, toolbar - ) - - def getMagicPromptsInputs(self, toolbar): - if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit: - _warnings.warnPromptSegmentPointsLayerNotInit(qparent=self) - return - - if not self.magicPromptsToolbar.viewModelParamsAction.isEnabled(): - _warnings.warnPromptSegmentModelNotInit(qparent=self) - return - - posData = self.data[self.pos_i] - image = self.getDisplayedZstack() - df_points = self.promptSegmentPointsLayerToolbar.pointsLayerDf( - posData, isSegm3D=self.isSegm3D - ) - - self.logger.info( - f'Starting {toolbar.model_name} promptable segmentation with the ' - f'following prompts:\n\n{df_points}' - ) - - return image, df_points - - @disableWindow - def magicPromptsComputeOnZoomTriggered(self, toolbar): - inputs = self.getMagicPromptsInputs(toolbar) - if inputs is None: - self.logger.info( - '"Computing promptable segmentation on zoom" process cancelled.' - ) - return - - posData = self.data[self.pos_i] - image, df_points = inputs - - ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange() - Y, X = image.shape[-2:] - - xmin = int(max(0, xmin)) - xmax = int(min(X, xmax)) - ymin = int(max(0, ymin)) - ymax = int(min(Y, ymax)) - - self.logger.info( - f'Zoom range: xmin={xmin}, xmax={xmax}, ymin={ymin}, ymax={ymax}' - ) - - zoom_slice = (slice(ymin, ymax), slice(xmin, xmax)) - - image = image[..., ymin:ymax, xmin:xmax] - image_origin = (0, ymin, xmin) - - df_points = df_points[df_points['y'] >= ymin] - df_points = df_points[df_points['x'] >= xmin] - df_points = df_points[df_points['y'] < ymax] - df_points = df_points[df_points['x'] < xmax] - - df_points['y'] -= ymin - df_points['x'] -= xmin - - df_points = df_points[ df_points['frame_i'] == posData.frame_i] - - self.logger.info( - f'Image origin = {image_origin}\n' - f'Image shape = {image.shape}' - ) - - self.startMagicPromptsWorkerAndWait( - image, df_points, toolbar.model, toolbar.model_segment_kwargs, - image_origin=image_origin, zoom_slice=zoom_slice - ) - - def magicPromptsInterpolateZsliceToggled(self, checked): - # See 'self.promptSegmentPointsLayerToolbar.addPointsZslicesInterpolation' - self.promptSegmentPointsLayerToolbar.doAddPointsZslicesInterpolation = ( - checked - ) - - def magicPromptsClearPoints(self, toolbar, only_zoom=False): - posData = self.data[self.pos_i] - scatterItem = self.promptSegmentPointsLayerToolbar.scatterItem() - action = scatterItem.action - - pointsDataPos = action.pointsData.get(self.pos_i) - if pointsDataPos is None: - return - - framePointsData = action.pointsData[self.pos_i].pop( - posData.frame_i, None - ) - if framePointsData is None: - return - - if not only_zoom: - scatterItem.clear() - return - - ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange() - Y, X = posData.img_data.shape[-2:] - - xmin = int(max(0, xmin)) - xmax = int(min(X, xmax)) - ymin = int(max(0, ymin)) - ymax = int(min(Y, ymax)) - - if 'x' in framePointsData: - newFramePointsData = {'x': [], 'y': [], 'id': []} - xx = framePointsData['x'] - yy = framePointsData['y'] - ids = framePointsData['id'] - for x, y, point_id in zip(xx, yy, ids): - if x < xmin or x >= xmax or y < ymin or y >= ymax: - newFramePointsData['x'].append(x) - newFramePointsData['y'].append(y) - newFramePointsData['id'].append(point_id) - else: - newFramePointsData = {} - for z, zSliceFramePointsData in framePointsData.items(): - newFramePointsData[z] = {'x': [], 'y': [], 'id': []} - xx = zSliceFramePointsData['x'] - yy = zSliceFramePointsData['y'] - ids = zSliceFramePointsData['id'] - for x, y, point_id in zip(xx, yy, ids): - if x < xmin or x >= xmax or y < ymin or y >= ymax: - newFramePointsData[z]['x'].append(x) - newFramePointsData[z]['y'].append(y) - newFramePointsData[z]['id'].append(point_id) - - action.pointsData[self.pos_i][posData.frame_i] = newFramePointsData - self.drawPointsLayers() - - @disableWindow - def magicPromptsComputeOnImageTriggered(self, toolbar): - inputs = self.getMagicPromptsInputs(toolbar) - if inputs is None: - self.logger.info( - '"Computing promptable segmentation on entire image" ' - 'process cancelled.' - ) - return - - image, df_points = inputs - - self.startMagicPromptsWorkerAndWait( - image, df_points, toolbar.model, toolbar.model_segment_kwargs - ) - - def startMagicPromptsWorkerAndWait( - self, image, df_points, model, model_segment_kwargs, - image_origin=(0, 0, 0), zoom_slice=None - ): - desc = ( - 'Running promptable segmentation model...' - ) - self.logger.info(desc) - posData = self.data[self.pos_i] - - self.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.show(self.app) - - self.magicPromptsThread = QThread() - self.magicPromptsWorker = workers.MagicPromptsWorker( - posData, image, df_points, model, model_segment_kwargs, - image_origin=image_origin, - global_image=posData.img_data[posData.frame_i] - ) - - self.magicPromptsWorker.moveToThread( - self.magicPromptsThread - ) - - self.magicPromptsWorker.signals.finished.connect( - self.magicPromptsThread.quit - ) - self.magicPromptsWorker.signals.finished.connect( - self.magicPromptsWorker.deleteLater - ) - self.magicPromptsThread.finished.connect( - self.magicPromptsThread.deleteLater - ) - - self.magicPromptsWorker.signals.critical.connect( - self.magicPromptsWorkerCritical - ) - self.magicPromptsWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.magicPromptsWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.magicPromptsWorker.signals.progress.connect( - self.workerProgress - ) - self.magicPromptsWorker.signals.finished.connect( - partial(self.magicPromptsWorkerFinished, zoom_slice=zoom_slice) - ) - - self.magicPromptsThread.started.connect( - self.magicPromptsWorker.run - ) - self.magicPromptsThread.start() - - self.magicPromptsWorkerLoop = QEventLoop() - self.magicPromptsWorkerLoop.exec_() - - def magicPromptsWorkerCritical(self, error): - self.magicPromptsWorkerLoop.exit() - self.workerCritical(error) - - def magicPromptsWorkerFinished(self, output, zoom_slice=None): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.magicPromptsWorkerLoop.exit() - - lab_new, lab_union, lab_interesection = output - - posData = self.data[self.pos_i] - - is_zoom = True - if zoom_slice is None: - zoom_slice = (slice(None), slice(None)) - is_zoom = False - - img = ( - posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]] - ) - images = [img, img, img, img] - labels_overlays = [ - posData.lab[..., zoom_slice[0], zoom_slice[1]], - lab_new[..., zoom_slice[0], zoom_slice[1]], - lab_union[..., zoom_slice[0], zoom_slice[1]], - lab_interesection[..., zoom_slice[0], zoom_slice[1]], - ] - labels_overlays_lut = self.getLabelsImageLut() - labels_overlays_luts = [ - labels_overlays_lut, - labels_overlays_lut, - labels_overlays_lut, - labels_overlays_lut, - ] - axis_titles = [ - 'Original masks', - 'New masks', - 'Union of original and new masks', - 'Intersection of original and new masks' - ] - - from cellacdc.plot import imshow - promptSegmResultsWindow = imshow( - *images, - labels_overlays=labels_overlays, - labels_overlays_luts=labels_overlays_luts, - axis_titles=axis_titles, - window_title='Promptable segmentation results', - figure_title='Ctrl+Click to select the result to use', - annotate_labels_idxs=[0, 1, 2, 3], - selectable_images=True, - max_ncols=2, - lut='gray', - infer_rgb=False - ) - if promptSegmResultsWindow.selected_idx is None: - self.logger.info( - 'Selection of the promptable model segmentation ' - 'result cancelled.' - ) - return - - if promptSegmResultsWindow.selected_idx == 0: - self.logger.info( - 'No selection of a promptable model segmentation ' - 'result was made' - ) - return - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - results = (None, lab_new, lab_union, lab_interesection) - selected_idx = promptSegmResultsWindow.selected_idx - zoom_out_lab = results[selected_idx][..., zoom_slice[0], zoom_slice[1]] - zoom_out_lab_mask = zoom_out_lab > 0 - - lab = posData.allData_li[posData.frame_i]['labels'] - lab[..., zoom_slice[0], zoom_slice[1]][zoom_out_lab_mask] = ( - zoom_out_lab[zoom_out_lab_mask] - ) - - posData.allData_li[posData.frame_i]['labels'] = lab - self.get_data() - self.store_data(autosave=False) - self.updateAllImages() - - def manualTracking_cb(self, checked): - self.manualTrackingToolbar.setVisible(checked) - if checked: - self.realTimeTrackingToggle.previousStatus = ( - self.realTimeTrackingToggle.isChecked() - ) - self.realTimeTrackingToggle.setChecked(False) - self.UserEnforced_DisabledTracking_previousStatus = ( - self.UserEnforced_DisabledTracking - ) - self.UserEnforced_Tracking_previousStatus = ( - self.UserEnforced_Tracking - ) - - self.UserEnforced_DisabledTracking = True - self.UserEnforced_Tracking = False - self.initGhostObject() - self.addManualTrackingItems() - else: - self.realTimeTrackingToggle.setChecked( - self.realTimeTrackingToggle.previousStatus - ) - self.UserEnforced_DisabledTracking = ( - self.UserEnforced_DisabledTracking_previousStatus - ) - self.UserEnforced_Tracking = ( - self.UserEnforced_Tracking_previousStatus - ) - self.removeManualTrackingItems() - self.clearGhost() - - def manualBackground_cb(self, checked): - if checked: - posData = self.data[self.pos_i] - minID = min(posData.IDs, default=0) - if minID == self.manualBackgroundToolbar.spinboxID.value(): - self.initManualBackgroundObject() - else: - self.manualBackgroundToolbar.spinboxID.setValue(minID) - # self.initManualBackgroundObject() - # self.initManualBackgroundImage() - self.addManualBackgroundItems() - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.manualBackgroundButton) - self.connectLeftClickButtons() - self.updateAllImages() - else: - self.removeManualTrackingItems() - self.clearGhost() - self.clearManualBackgroundAnnotations() - self.manualBackgroundToolbar.setVisible(checked) - - def autoSegm_cb(self, checked): - if checked: - self.askSegmParam = True - # Ask which model - models = myutils.get_list_of_models() - win = widgets.QDialogListbox( - 'Select model', - 'Select model to use for segmentation: ', - models, - multiSelection=False, - parent=self - ) - win.exec_() - if win.cancel: - return - model_name = win.selectedItemsText[0] - self.segmModelName = model_name - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - self.updateAllImages() - self.computeSegm() - self.askSegmParam = False - else: - self.segmModelName = None - - def postProcessSegm(self, checked): - if self.isSegm3D: - SizeZ = max([posData.SizeZ for posData in self.data]) - else: - SizeZ = None - if checked: - posData = self.data[self.pos_i] - self.postProcessSegmWin = apps.PostProcessSegmDialog( - posData, mainWin=self - ) - self.postProcessSegmWin.sigClosed.connect( - self.postProcessSegmWinClosed - ) - self.postProcessSegmWin.sigValueChanged.connect( - self.postProcessSegmValueChanged - ) - self.postProcessSegmWin.sigEditingFinished.connect( - self.postProcessSegmEditingFinished - ) - self.postProcessSegmWin.sigApplyToAllFutureFrames.connect( - self.postProcessSegmApplyToAllFutureFrames - ) - self.postProcessSegmWin.show() - self.postProcessSegmWin.valueChanged(None) - else: - self.postProcessSegmWin.close() - self.postProcessSegmWin = None - - def postProcessSegmApplyToAllFutureFrames( - self, postProcessKwargs, - customPostProcessGroupedFeatures, - customPostProcessFeatures - ): - proceed = self.warnEditingWithCca_df( - 'post-processing segmentation', update_images=False - ) - if not proceed: - self.logger.info('Post-processing segmentation cancelled.') - return - - self.progressWin = apps.QDialogWorkerProgress( - title='Post-processing segmentation', parent=self, - pbarDesc=f'Post-processing segmentation masks...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.startPostProcessSegmWorker( - postProcessKwargs, customPostProcessGroupedFeatures, - customPostProcessFeatures - ) - - def postProcessSegmEditingFinished(self): - self.update_rp() - self.store_data() - self.updateAllImages() - - def postProcessSegmWorkerFinished(self): - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.get_data() - self.updateAllImages() - self.titleLabel.setText('Post-processing segmentation done!', color='w') - self.logger.info('Post-processing segmentation done!') - - def postProcessSegmWinClosed(self): - self.postProcessSegmWin = None - self.postProcessSegmAction.toggled.disconnect() - self.postProcessSegmAction.setChecked(False) - self.postProcessSegmAction.toggled.connect(self.postProcessSegm) - - def postProcessSegmValueChanged(self, lab, delObjs: dict): - for delObj in delObjs.values(): - self.clearObjContour(obj=delObj, ax=0) - self.clearObjContour(obj=delObj, ax=1) - - posData = self.data[self.pos_i] - - labelsToSkip = {} - for ID in posData.IDs: - if ID in delObjs: - labelsToSkip[ID] = True - continue - - restoreObj = self.postProcessSegmWin.origObjs[ID] - self.addObjContourToContoursImage(obj=restoreObj, ax=0) - self.addObjContourToContoursImage(obj=restoreObj, ax=1) - - # self.setAllTextAnnotations(labelsToSkip=labelsToSkip) - - posData.lab = lab - self.setImageImg2() - if self.annotSegmMasksCheckbox.isChecked(): - self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) - if self.annotSegmMasksCheckboxRight.isChecked(): - self.labelsLayerRightImg.setImage(self.currentLab2D, autoLevels=False) - - def readSavedCustomAnnot(self): - tempAnnot = {} - if os.path.exists(custom_annot_path): - self.logger.info('Loading saved custom annotations...') - tempAnnot = load.read_json( - custom_annot_path, logger_func=self.logger.info - ) - - posData = self.data[self.pos_i] - self.savedCustomAnnot = tempAnnot - for pos_i, posData in enumerate(self.data): - self.savedCustomAnnot = { - **self.savedCustomAnnot, **posData.customAnnot - } - - def addCustomAnnotButtonAllLoadedPos(self): - allPosCustomAnnot = {} - for pos_i, posData in enumerate(self.data): - self.addCustomAnnotationSavedPos(pos_i=pos_i) - allPosCustomAnnot = {**allPosCustomAnnot, **posData.customAnnot} - for posData in self.data: - posData.customAnnot = allPosCustomAnnot - - def addCustomAnnotationSavedPos(self, pos_i=None): - if pos_i is None: - pos_i = self.pos_i - - posData = self.data[pos_i] - for name, annotState in posData.customAnnot.items(): - # Check if button is already present and update only annotated IDs - buttons = [b for b in self.customAnnotDict.keys() if b.name==name] - if buttons: - toolButton = buttons[0] - allAnnotedIDs = self.customAnnotDict[toolButton]['annotatedIDs'] - allAnnotedIDs[pos_i] = posData.customAnnotIDs.get(name, {}) - continue - - try: - symbol = re.findall(r"\'(.+)\'", annotState['symbol'])[0] - except Exception as e: - self.logger.info(traceback.format_exc()) - symbol = 'o' - - symbolColor = QColor(*annotState['symbolColor']) - shortcut = annotState['shortcut'] - if shortcut is not None: - keySequence = widgets.macShortcutToWindows(shortcut) - keySequence = widgets.KeySequenceFromText(keySequence) - else: - keySequence = None - toolTip = myutils.getCustomAnnotTooltip(annotState) - keepActive = annotState.get('keepActive', True) - isHideChecked = annotState.get('isHideChecked', True) - - toolButton, action = self.addCustomAnnotationButton( - symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked - ) - allPosAnnotIDs = [ - pos.customAnnotIDs.get(name, defaultdict(list)) - for pos in self.data - ] - self.customAnnotDict[toolButton] = { - 'action': action, - 'state': annotState, - 'annotatedIDs': allPosAnnotIDs - } - - self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) - - def addCustomAnnotationButton( - self, symbol, symbolColor, keySequence, toolTip, annotName, - keepActive, isHideChecked - ): - toolButton = widgets.customAnnotToolButton( - symbol, symbolColor, parent=self, keepToolActive=keepActive, - isHideChecked=isHideChecked - ) - toolButton.setCheckable(True) - self.checkableQButtonsGroup.addButton(toolButton) - if keySequence is not None: - toolButton.setShortcut(keySequence) - toolButton.setToolTip(toolTip) - toolButton.name = annotName - toolButton.toggled.connect(self.customAnnotButtonToggled) - toolButton.sigRemoveAction.connect(self.removeCustomAnnotButton) - toolButton.sigKeepActiveAction.connect(self.customAnnotKeepActive) - toolButton.sigHideAction.connect(self.customAnnotHide) - toolButton.sigModifyAction.connect(self.customAnnotModify) - action = self.annotateToolbar.addWidget(toolButton) - return toolButton, action - - def addCustomAnnnotScatterPlot( - self, symbolColor, symbol, toolButton - ): - # Add scatter plot item - symbolColorBrush = [0, 0, 0, 50] - symbolColorBrush[:3] = symbolColor.getRgb()[:3] - scatterPlotItem = widgets.CustomAnnotationScatterPlotItem() - scatterPlotItem.setData( - [], [], symbol=symbol, pxMode=False, - brush=pg.mkBrush(symbolColorBrush), size=15, - pen=pg.mkPen(width=3, color=symbolColor), - hoverable=True, hoverBrush=pg.mkBrush(symbolColor), - tip=None - ) - scatterPlotItem.sigHovered.connect(self.customAnnotHovered) - scatterPlotItem.button = toolButton - self.customAnnotDict[toolButton]['scatterPlotItem'] = scatterPlotItem - self.ax1.addItem(scatterPlotItem) - - def addCustomAnnotationItems( - self, symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked, state - ): - toolButton, action = self.addCustomAnnotationButton( - symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked - ) - - self.customAnnotDict[toolButton] = { - 'action': action, - 'state': state, - 'annotatedIDs': [defaultdict(list) for _ in range(len(self.data))] - } - - # Save custom annotation to cellacdc/temp/custom_annotations.json - state_to_save = state.copy() - state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) - self.savedCustomAnnot[name] = state_to_save - for posData in self.data: - posData.customAnnot[name] = state_to_save - - # Add scatter plot item - self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) - - customAnnotButton = self.customAnnotDict[toolButton] - allPosAnnotatedIDs = customAnnotButton['annotatedIDs'] - # Add 0s column to acdc_df - for pos_i, posData in enumerate(self.data): - for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - if name not in acdc_df.columns: - acdc_df[name] = 0 - else: - acdc_df[name] = acdc_df[name].astype(int) - acdc_df_annot = acdc_df[acdc_df[name] == 1].reset_index() - annot_IDs = acdc_df_annot['Cell_ID'].to_list() - allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs) - - if posData.acdc_df is not None: - if name not in posData.acdc_df.columns: - posData.acdc_df[name] = 0 - else: - posData.acdc_df[name] = posData.acdc_df[name].astype(int) - acdc_df_annot = ( - posData.acdc_df[posData.acdc_df[name] == 1] - .reset_index() - ) - annot_IDs = acdc_df_annot['Cell_ID'].to_list() - allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs) - - def customAnnotHovered(self, scatterPlotItem, points, event): - # Show tool tip when hovering an annotation with annotation name and ID - vb = scatterPlotItem.getViewBox() - if vb is None: - return - if len(points) > 0: - posData = self.data[self.pos_i] - point = points[0] - x, y = point.pos().x(), point.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - vb.setToolTip( - f'Annotation name: {scatterPlotItem.button.name}\n' - f'ID = {ID}' - ) - else: - vb.setToolTip('') - - def loadCustomAnnotations(self): - items = list(self.savedCustomAnnot.keys()) - if len(items) == 0: - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - There are no custom annotations saved.

- Click on "Add custom annotation" button to start adding new - annotations. - """) - msg.warning(self, 'No annotations saved', txt) - return - - self.selectAnnotWin = widgets.QDialogListbox( - 'Load previously used custom annotation(s)', - 'Select annotations to load:', items, - additionalButtons=('Delete selected annnotations', ), - parent=self, multiSelection=True - ) - for button in self.selectAnnotWin._additionalButtons: - button.disconnect() - button.clicked.connect(self.deleteSavedAnnotation) - self.selectAnnotWin.exec_() - if self.selectAnnotWin.cancel: - return - - for selectedAnnotName in self.selectAnnotWin.selectedItemsText: - selectedAnnot = self.savedCustomAnnot[selectedAnnotName] - - symbol = selectedAnnot['symbol'] - symbol = re.findall(r"\'(.+)\'", symbol)[0] - symbolColor = selectedAnnot['symbolColor'] - symbolColor = pg.mkColor(symbolColor) - keySequence = widgets.KeySequenceFromText(selectedAnnot['shortcut']) - Type = selectedAnnot['type'] - toolTip = ( - f'Name: {selectedAnnotName}\n\n' - f'Type: {Type}\n\n' - f'Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n' - f'Description: {selectedAnnot["description"]}\n\n' - f'Shortcut: "{keySequence}"' - ) - keepActive = selectedAnnot['keepActive'] - isHideChecked = selectedAnnot['isHideChecked'] - state = { - 'type': Type, - 'name': selectedAnnotName, - 'symbol': selectedAnnot['symbol'], - 'shortcut': selectedAnnot['shortcut'], - 'description': selectedAnnot["description"], - 'keepActive': keepActive, - 'isHideChecked': isHideChecked, - 'symbolColor': symbolColor - } - self.addCustomAnnotationItems( - symbol, symbolColor, keySequence, toolTip, selectedAnnotName, - keepActive, isHideChecked, state - ) - for pos_i, posData in enumerate(self.data): - posData.customAnnot[selectedAnnotName] = selectedAnnot - - self.saveCustomAnnot() - - def deleteSavedAnnotation(self): - for item in self.selectAnnotWin.listBox.selectedItems(): - name = item.text() - self.savedCustomAnnot.pop(name) - self.deleteSelectedAnnot(self.selectAnnotWin.listBox.selectedItems()) - items = list(self.savedCustomAnnot.keys()) - self.selectAnnotWin.listBox.clear() - self.selectAnnotWin.listBox.addItems(items) - - def addCustomAnnotation(self): - self.readSavedCustomAnnot() - - self.addAnnotWin = apps.customAnnotationDialog( - self.savedCustomAnnot, parent=self - ) - self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) - self.addAnnotWin.exec_() - if self.addAnnotWin.cancel: - self.logger.info('Custom annotation process cancelled.') - return - - symbol = self.addAnnotWin.symbol - symbolColor = self.addAnnotWin.state['symbolColor'] - keySequence = self.addAnnotWin.shortcutWidget.widget.keySequence - toolTip = self.addAnnotWin.toolTip - name = self.addAnnotWin.state['name'] - keepActive = self.addAnnotWin.state.get('keepActive', True) - isHideChecked = self.addAnnotWin.state.get('isHideChecked', True) - - proceed = self.checkNameExists(name) - if not proceed: - self.logger.info('Custom annotation process cancelled.') - return - - self.addCustomAnnotationItems( - symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked, self.addAnnotWin.state - ) - self.saveCustomAnnot() - self.doCustomAnnotation(0) - - def askCustomAnnotationNameExists(self, name): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(f""" - The annotationa called {name} already exists in the - acdc_output CSV file.

- If you continue, this column will be used to initialize - pre-annotated objects.

- Do you want to continue? - """ - ) - noButton, yesButton = msg.question( - self, 'Custom annotation name already exists', txt, - buttonsTexts=('No, stop process', 'Yes, use existing column') - ) - return msg.clickedButton == yesButton - - - def checkNameExists(self, name): - posData = self.data[self.pos_i] - for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - if name in acdc_df.columns: - return self.askCustomAnnotationNameExists(name) - - if posData.acdc_df is not None and name in posData.acdc_df.columns: - return self.askCustomAnnotationNameExists(name) - - return True - - - def viewAllCustomAnnot(self, checked): - if not checked: - # Clear all annotations before showing only checked - for button in self.customAnnotDict.keys(): - self.clearScatterPlotCustomAnnotButton(button) - self.doCustomAnnotation(0) - - def clearScatterPlotCustomAnnotButton(self, button): - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] - scatterPlotItem.setData([], []) - - def saveCustomAnnot(self, only_temp=False): - if not hasattr(self, 'savedCustomAnnot'): - return - - if not self.savedCustomAnnot: - return - - # Save to cell acdc temp path - with open(custom_annot_path, mode='w') as file: - json.dump(self.savedCustomAnnot, file, indent=2) - - if only_temp: - return - - self.logger.info('Saving custom annotations parameters...') - # Save to pos path - for _posData in self.data: - _posData.saveCustomAnnotationParams() - - def customAnnotKeepActive(self, button): - self.customAnnotDict[button]['state']['keepActive'] = button.keepToolActive - - def customAnnotHide(self, button): - self.customAnnotDict[button]['state']['isHideChecked'] = button.isHideChecked - clearAnnot = ( - not button.isChecked() and button.isHideChecked - and not self.viewAllCustomAnnotAction.isChecked() - ) - if clearAnnot: - # User checked hide annot with the button not active --> clear - self.clearScatterPlotCustomAnnotButton(button) - elif not button.isChecked(): - # User uncheked hide annot with the button not active --> show - self.doCustomAnnotation(0) - - def deleteSelectedAnnot(self, itemsToDelete): - self.saveCustomAnnot(only_temp=True) - - def customAnnotModify(self, button): - state = self.customAnnotDict[button]['state'] - self.addAnnotWin = apps.customAnnotationDialog( - self.savedCustomAnnot, state=state - ) - self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) - self.addAnnotWin.exec_() - if self.addAnnotWin.cancel: - return - - # Rename column if existing - posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - if acdc_df is not None: - old_name = self.customAnnotDict[button]['state']['name'] - new_name = self.addAnnotWin.state['name'] - acdc_df = acdc_df.rename(columns={old_name: new_name}) - posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df - - self.customAnnotDict[button]['state'] = self.addAnnotWin.state - - name = self.addAnnotWin.state['name'] - state_to_save = self.addAnnotWin.state.copy() - symbolColor = self.addAnnotWin.state['symbolColor'] - state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) - self.savedCustomAnnot[name] = self.addAnnotWin.state - self.saveCustomAnnot() - - symbol = self.addAnnotWin.symbol - symbolColor = self.customAnnotDict[button]['state']['symbolColor'] - button.setColor(symbolColor) - button.update() - symbolColorBrush = [0, 0, 0, 50] - symbolColorBrush[:3] = symbolColor.getRgb()[:3] - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] - xx, yy = scatterPlotItem.getData() - if xx is None: - xx, yy = [], [] - scatterPlotItem.setData( - xx, yy, symbol=symbol, pxMode=False, - brush=pg.mkBrush(symbolColorBrush), size=15, - pen=pg.mkPen(width=3, color=symbolColor) - ) - - def doCustomAnnotation(self, ID): - mode = self.modeComboBox.currentText() - if not self.isSnapshot and mode != 'Custom annotations': - # Do not show annotations if timelapse and mode not annotations - return - - if self.switchPlaneCombobox.depthAxes() != 'z': - return - - # NOTE: pass 0 for ID to not add - posData = self.data[self.pos_i] - if self.viewAllCustomAnnotAction.isChecked(): - # User requested to show all annotations --> iterate all buttons - # Unless it actively clicked to annotate --> avoid annotating object - # with all the annotations present - buttons = list(self.customAnnotDict.keys()) - else: - # Annotate if the button is active or isHideChecked is False - buttons = [ - b for b in self.customAnnotDict.keys() - if (b.isChecked() or not b.isHideChecked) - ] - if not buttons: - return - - for button in buttons: - annotatedIDs = ( - self.customAnnotDict[button]['annotatedIDs'][self.pos_i] - ) - annotIDs_frame_i = annotatedIDs.get(posData.frame_i, []) - state = self.customAnnotDict[button]['state'] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - - if button.isChecked() and ID > 0: - # Annotate only if existing ID and the button is checked - if ID in annotIDs_frame_i: - annotIDs_frame_i.remove(ID) - acdc_df.at[ID, state['name']] = 0 - elif ID != 0: - annotIDs_frame_i.append(ID) - - annotPerButton = self.customAnnotDict[button] - allAnnotedIDs = annotPerButton['annotatedIDs'] - posAnnotedIDs = allAnnotedIDs[self.pos_i] - posAnnotedIDs[posData.frame_i] = annotIDs_frame_i - - if acdc_df is None: - self.store_data(autosave=False) - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - - xx, yy = [], [] - for annotID in annotIDs_frame_i: - if annotID not in posData.IDs_idxs: - continue - - obj_idx = posData.IDs_idxs[annotID] - obj = posData.rp[obj_idx] - acdc_df.at[annotID, state['name']] = 1 - if not self.isObjVisible(obj.bbox): - continue - y, x = self.getObjCentroid(obj.centroid) - xx.append(x) - yy.append(y) - - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] - scatterPlotItem.setData(xx, yy) - - posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df - - # if self.highlightedID != 0: - # self.highlightedID = 0 - # self.setHighlightID(False) - - if buttons: - return buttons[0] - - def removeCustomAnnotButton(self, button, askHow=True, save=True): - if askHow: - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - Do you want to remove also the column with annotations or - only the annotation button?
- """) - _, removeOnlyButton, removeColButton = msg.question( - self, 'Remove only button?', txt, - buttonsTexts=( - 'Cancel', 'Remove only button', - ' Remove also column with annotations ' - ) - ) - if msg.cancel: - return - removeOnlyButton = msg.clickedButton == removeOnlyButton - else: - removeOnlyButton = True - - name = self.customAnnotDict[button]['state']['name'] - # remove annotation from position - for posData in self.data: - try: - posData.customAnnot.pop(name) - posData.saveCustomAnnotationParams() - except KeyError as e: - # Current pos doesn't have any annotation button. Continue - continue - - if posData.acdc_df is None: - continue - - if removeOnlyButton: - continue - - posData.acdc_df = posData.acdc_df.drop( - columns=name, errors='ignore' - ) - for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - acdc_df = acdc_df.drop(columns=name, errors='ignore') - posData.allData_li[frame_i]['acdc_df'] = acdc_df - - self.clearScatterPlotCustomAnnotButton(button) - - action = self.customAnnotDict[button]['action'] - self.annotateToolbar.removeAction(action) - self.checkableQButtonsGroup.removeButton(button) - self.customAnnotDict.pop(button) - # self.savedCustomAnnot.pop(name) - - self.saveCustomAnnot(only_temp=True) - - def customAnnotButtonToggled(self, checked): - if checked: - self.customAnnotButton = self.sender() - # Uncheck the other buttons - for button in self.customAnnotDict.keys(): - if button == self.sender(): - continue - - button.toggled.disconnect() - self.clearScatterPlotCustomAnnotButton(button) - button.setChecked(False) - button.toggled.connect(self.customAnnotButtonToggled) - self.doCustomAnnotation(0) - else: - self.customAnnotButton = None - button = self.sender() - clearAnnotation = ( - button.isHideChecked - or not self.viewAllCustomAnnotAction.isChecked() - ) - if clearAnnotation: - self.clearScatterPlotCustomAnnotButton(button) - self.setHighlightID(False) - self.resetCursor() - - def resetCursor(self): - if self.app.overrideCursor() is not None: - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - def segmFrameCallback(self, action): - if action == self.addCustomModelFrameAction: - return - - idx = self.segmActions.index(action) - model_name = self.modelNames[idx] - self.repeatSegm(model_name=model_name, askSegmParams=True) - - def segmVideoCallback(self, action): - if action == self.addCustomModelVideoAction: - return - - posData = self.data[self.pos_i] - win = apps.startStopFramesDialog( - posData.SizeT, currentFrameNum=posData.frame_i+1 - ) - win.exec_() - if win.cancel: - self.logger.info('Segmentation on multiple frames aborted.') - return - - idx = self.segmActionsVideo.index(action) - model_name = self.modelNames[idx] - self.repeatSegmVideo(model_name, win.startFrame, win.stopFrame) - - def segmentToolActionTriggered(self): - if self.segmModelName is None: - win = apps.QDialogSelectModel(parent=self) - win.exec_() - if win.cancel: - self.logger.info('Repeat segmentation cancelled.') - return - model_name = win.selectedModel - self.repeatSegm( - model_name=model_name, askSegmParams=True - ) - else: - self.repeatSegm(model_name=self.segmModelName) - - def initSegmModelParams( - self, model_name, acdcSegment, init_params, segment_params, - is_label_roi=False, initLastParams=False, - extraParams=None, extraParamsTitle=None,ini_filename=None - - ): - posData = self.data[self.pos_i] - try: - url = acdcSegment.url_help() - except AttributeError: - url = None - - text_if_cancelled = 'Segmentation process cancelled.' - out = prompts.init_segm_model_params( - posData, model_name, init_params, segment_params, - help_url=url, qparent=self, init_last_params=initLastParams, - check_sam_embeddings=not is_label_roi, is_gui_caller=True, - extraParams=extraParams,extraParamsTitle=extraParamsTitle, - ini_filename=ini_filename, - ) - if out.get('load_sam_embeddings', False): - self.logger.info('Loading Segment Anything image embeddings...') - for _posData in self.data: - _posData.loadSamEmbeddings(logger_func=None) - text_if_cancelled = 'SAM embeddings loaded.' - - win = out.get('win') - if win is None: - self.logger.info(text_if_cancelled) - self.titleLabel.setText(text_if_cancelled) - return - - if win.cancel: - self.logger.info(text_if_cancelled) - self.titleLabel.setText(text_if_cancelled) - return - - if model_name != 'thresholding': - self.model_kwargs = win.model_kwargs - - return win - - @exception_handler - def repeatSegm( - self, model_name='', askSegmParams=False, is_label_roi=False - ): - if model_name == 'thresholding': - # thresholding model is stored as 'Automatic thresholding' - # at line of code `models.append('Automatic thresholding')` - model_name = 'Automatic thresholding' - - idx = self.modelNames.index(model_name) - # Ask segm parameters if not already set - # and not called by segmSingleFrameMenu (askSegmParams=False) - if not askSegmParams: - askSegmParams = self.model_kwargs is None - - self.downloadWin = apps.downloadModel(model_name, parent=self) - self.downloadWin.download() - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - if model_name == 'Automatic thresholding': - # Automatic thresholding is the name of the models as stored - # in self.modelNames, but the actual model is called thresholding - # (see cellacdc/models/thresholding) - model_name = 'thresholding' - - posData = self.data[self.pos_i] - # Check if model needs to be imported - acdcSegment = self.acdcSegment_li[idx] - if acdcSegment is None: - self.logger.info(f'Importing {model_name}...') - acdcSegment = myutils.import_segment_module(model_name) - self.acdcSegment_li[idx] = acdcSegment - - # Ask parameters if the user clicked on the action - # Otherwise this function is called by "computeSegm" function and - # we use loaded parameters - if askSegmParams: - if self.app.overrideCursor() == Qt.WaitCursor: - self.app.restoreOverrideCursor() - self.segmModelName = model_name - # Read all models parameters - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) - # Prompt user to enter the model parameters - try: - url = acdcSegment.url_help() - except AttributeError: - url = None - - self.preproc_recipe = None - initLastParams = True - if model_name == 'thresholding': - win = apps.QDialogAutomaticThresholding( - parent=self, isSegm3D=self.isSegm3D - ) - win.exec_() - if win.cancel: - return - self.model_kwargs = win.segment_kwargs - thresh_method = self.model_kwargs['threshold_method'] - gauss_sigma = self.model_kwargs['gauss_sigma'] - segment_params = myutils.insertModelArgSpec( - segment_params, 'threshold_method', thresh_method - ) - segment_params = myutils.insertModelArgSpec( - segment_params, 'gauss_sigma', gauss_sigma - ) - initLastParams = False - - win = self.initSegmModelParams( - model_name, acdcSegment, init_params, segment_params, - is_label_roi=is_label_roi, - initLastParams=initLastParams - ) - if win is None: - return - - self.standardPostProcessKwargs = win.standardPostProcessKwargs - self.customPostProcessFeatures = win.customPostProcessFeatures - self.customPostProcessGroupedFeatures = ( - win.customPostProcessGroupedFeatures - ) - self.applyPostProcessing = win.applyPostProcessing - self.secondChannelName = win.secondChannelName - self.preproc_recipe = win.preproc_recipe - - myutils.log_segm_params( - model_name, win.init_kwargs, win.model_kwargs, - logger_func=self.logger.info, - preproc_recipe=win.preproc_recipe, - apply_post_process=self.applyPostProcessing, - standard_postprocess_kwargs=self.standardPostProcessKwargs, - custom_postprocess_features=self.customPostProcessFeatures - ) - - use_gpu = win.init_kwargs.get('gpu', False) - proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) - if not proceed: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - - model = myutils.init_segm_model( - acdcSegment, posData, win.init_kwargs - ) - if model is None: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - try: - model.setupLogger(self.logger) - except Exception as e: - pass - self.models[idx] = model - model.model_name = model_name - else: - model = self.models[idx] - - if is_label_roi: - return model - - self.titleLabel.setText( - f'Segmenting with {model_name}... ' - '(check progress in terminal/console)', color=self.titleColor - ) - - post_process_params = { - 'applied_postprocessing': self.applyPostProcessing - } - post_process_params = { - **post_process_params, - **self.standardPostProcessKwargs, - **self.customPostProcessFeatures - } - if askSegmParams: - posData.saveSegmHyperparams( - model_name, win.init_kwargs, win.model_kwargs, - post_process_params=post_process_params, - preproc_recipe=self.preproc_recipe - ) - - if self.askRepeatSegment3D: - self.segment3D = False - if self.isSegm3D and self.askRepeatSegment3D: - msg = widgets.myMessageBox(showCentered=False) - msg.addDoNotShowAgainCheckbox(text='Do not ask again') - txt = html_utils.paragraph( - 'Do you want to segment the entire z-stack or only the ' - 'current z-slice?' - ) - _, segment3DButton, _ = msg.question( - self, '3D segmentation?', txt, - buttonsTexts=( - 'Cancel', 'Segment 3D z-stack', 'Segment 2D z-slice' - ) - ) - if msg.cancel: - self.titleLabel.setText('Segmentation process aborted.') - self.logger.info('Segmentation process aborted.') - return - self.segment3D = msg.clickedButton == segment3DButton - if msg.doNotShowAgainCheckbox.isChecked(): - self.askRepeatSegment3D = False - - if self.askZrangeSegm3D: - self.z_range = None - if self.isSegm3D and self.segment3D and self.askZrangeSegm3D: - idx = (posData.filename, posData.frame_i) - try: - orignal_z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] - except ValueError as e: - orignal_z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] - selectZtool = apps.QCropZtool( - posData.SizeZ, parent=self, cropButtonText='Ok', - addDoNotShowAgain=True, title='Select z-slice range to segment' - ) - selectZtool.sigZvalueChanged.connect(self.selectZtoolZvalueChanged) - selectZtool.sigCrop.connect(selectZtool.close) - selectZtool.exec_() - self.update_z_slice(orignal_z) - if selectZtool.cancel: - self.titleLabel.setText('Segmentation process aborted.') - self.logger.info('Segmentation process aborted.') - return - startZ = selectZtool.lowerZscrollbar.value() - stopZ = selectZtool.upperZscrollbar.value() - self.z_range = (startZ, stopZ) - if selectZtool.doNotShowAgainCheckbox.isChecked(): - self.askZrangeSegm3D = False - - secondChannelData = None - if self.secondChannelName is not None: - secondChannelData = self.getSecondChannelData() - - self.titleLabel.setText( - f'{model_name} is thinking... ' - '(check progress in terminal/console)', color=self.titleColor - ) - - self.model = model - - self.segmWorkerMutex = QMutex() - self.segmWorkerWaitCond = QWaitCondition() - self.thread = QThread() - self.worker = workers.segmWorker( - self, - secondChannelData=secondChannelData, - mutex=self.segmWorkerMutex, - waitCond=self.segmWorkerWaitCond - ) - self.worker.z_range = self.z_range - self.worker.moveToThread(self.thread) - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - if self.debug: - self.worker.debug.connect(self.debugSegmWorker) - self.thread.finished.connect(self.thread.deleteLater) - - # Custom signals - self.worker.critical.connect(self.workerCritical) - self.worker.finished.connect(self.segmWorkerFinished) - - self.thread.started.connect(self.worker.run) - self.thread.start() - - def debugSegmWorker(self, to_debug): - img, _lab, lab = to_debug - printl(img.shape, _lab.shape, lab.shape) - imshow(img, _lab, lab) - self.segmWorkerWaitCond.wakeAll() - - def selectZtoolZvalueChanged(self, whichZ, z): - self.update_z_slice(z) - - @exception_handler - def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): - if model_name == 'thresholding': - # thresholding model is stored as 'Automatic thresholding' - # at line of code `models.append('Automatic thresholding')` - model_name = 'Automatic thresholding' - - idx = self.modelNames.index(model_name) - - self.downloadWin = apps.downloadModel(model_name, parent=self) - self.downloadWin.download() - - if model_name == 'Automatic thresholding': - # Automatic thresholding is the name of the models as stored - # in self.modelNames, but the actual model is called thresholding - # (see cellacdc/models/thresholding) - model_name = 'thresholding' - - posData = self.data[self.pos_i] - # Check if model needs to be imported - acdcSegment = self.acdcSegment_li[idx] - if acdcSegment is None: - self.logger.info(f'Importing {model_name}...') - acdcSegment = myutils.import_segment_module(model_name) - self.acdcSegment_li[idx] = acdcSegment - - # Read all models parameters - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) - # Prompt user to enter the model parameters - try: - url = acdcSegment.url_help() - except AttributeError: - url = None - - if model_name == 'thresholding': - autoThreshWin = apps.QDialogAutomaticThresholding( - parent=self, isSegm3D=self.isSegm3D - ) - autoThreshWin.exec_() - if autoThreshWin.cancel: - return - - win = self.initSegmModelParams( - model_name, acdcSegment, init_params, segment_params - ) - if win is None: - return - - self.standardPostProcessKwargs = win.standardPostProcessKwargs - self.customPostProcessFeatures = win.customPostProcessFeatures - self.customPostProcessGroupedFeatures = ( - win.customPostProcessGroupedFeatures - ) - self.applyPostProcessing = win.applyPostProcessing - self.preproc_recipe = win.preproc_recipe - - myutils.log_segm_params( - model_name, win.init_kwargs, win.model_kwargs, - logger_func=self.logger.info, - preproc_recipe=win.preproc_recipe, - apply_post_process=self.applyPostProcessing, - standard_postprocess_kwargs=self.standardPostProcessKwargs, - custom_postprocess_features=self.customPostProcessFeatures - ) - - secondChannelData = None - if win.secondChannelName is not None: - secondChannelData = self.getSecondChannelData() - - use_gpu = win.init_kwargs.get('gpu', False) - proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) - if not proceed: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - - model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) - if model is None: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - try: - model.setupLogger(self.logger) - except Exception as e: - pass - - self.extendSegmDataIfNeeded(stopFrameNum) - self.reInitLastSegmFrame( - from_frame_i=startFrameNum-1, updateImages=False - ) - - self.titleLabel.setText( - f'{model_name} is thinking... ' - '(check progress in terminal/console)', color=self.titleColor - ) - - self.progressWin = apps.QDialogWorkerProgress( - title='Segmenting video', parent=self, - pbarDesc=f'Segmenting from frame n. {startFrameNum} to {stopFrameNum}...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stopFrameNum-startFrameNum) - - self.thread = QThread() - self.worker = workers.segmVideoWorker( - posData, win, model, startFrameNum, stopFrameNum - ) - self.worker.secondChannelData = secondChannelData - self.worker.moveToThread(self.thread) - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - # Custom signals - self.worker.critical.connect(self.workerCritical) - self.worker.finished.connect(self.segmVideoWorkerFinished) - self.worker.progressBar.connect(self.workerUpdateProgressbar) - self.worker.progress.connect(self.workerProgress) - - self.thread.started.connect(self.worker.run) - self.thread.start() - - def segmVideoWorkerFinished(self, exec_time): - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.activateAnnotations() - - self.get_data() - self.tracking(enforce=True) - self.updateAllImages() - - txt = f'Done. Segmentation computed in {exec_time:.3f} s' - self.logger.info('-----------------') - self.logger.info(txt) - self.logger.info('=================') - self.titleLabel.setText(txt, color='g') - - @exception_handler - def lazyLoaderCritical(self, error): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.lazyLoader.pause() - raise error - - def ccaIntegrityWorkerCritical(self, error): - try: - raise error - except Exception as err: - self.logger.exception(traceback.format_exc()) - - href = f'GitHub page' - txt = html_utils.paragraph(f""" - Unfortunately the experimental feature - check cell cycle annotations integrity raised a - critical error.

- Cell-ACDC will now disable this feature to allow you to keep - using the software.

- However, we kindly ask you to report the issue on our - {href}, thank you very much!

- Please, include the log file when reporting the issue.

- Log file location: - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning( - self, 'Experimental feature error', txt, - commands=(self.log_path,), - path_to_browse=self.logs_path - ) - self.disableCcaIntegrityChecker() - - @exception_handler - def workerCritical(self, out: Tuple[QObject, Exception]): - self.setDisabled(False) - try: - worker, error = out - except TypeError as err: - error = out - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.logger.info(error) - try: - worker.thread().quit() - worker.deleteLater() - worker.thread().deleteLater() - except Exception as err: - # Worker already closed - pass - raise error - - def workerLog(self, text): - self.logger.info(text) - - def saveDataWorkerCritical(self, error): - self.logger.warning( - 'Saving process stopped because of critical error.' - ) - self.saveWin.aborted = True - self.worker.finished.emit() - self.workerCritical(error) - - def lazyLoaderWorkerClosed(self): - if self.lazyLoader.salute: - self.logger.info('Cell-ACDC GUI closed.') - self.sigClosed.emit(self) - - self.lazyLoader = None - - def segmWorkerFinished(self, lab, exec_time): - posData = self.data[self.pos_i] - - if posData.segmInfo_df is not None and posData.SizeZ>1: - idx = (posData.filename, posData.frame_i) - posData.segmInfo_df.at[idx, 'resegmented_in_gui'] = True - - if lab.ndim == 2 and self.isSegm3D: - self.set_2Dlab(lab) - else: - posData.lab = lab.copy() - - self.activateAnnotations() - - self.update_rp(wl_update=False) - self.tracking(enforce=True, against_next=posData.frame_i==0) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Repeat segmentation') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Repeat segmentation') - - txt = f'Done. Segmentation computed in {exec_time:.3f} s' - self.logger.info('-----------------') - self.logger.info(txt) - self.logger.info('=================') - self.titleLabel.setText(txt, color='g') - self.checkIfAutoSegm() - - QTimer.singleShot(200, self.resizeGui) - def activateAnnotations(self): - if self.annotContourCheckbox.isChecked(): - return - if self.annotSegmMasksCheckbox.isChecked(): - return - - self.annotSegmMasksCheckbox.setChecked(True) - self.setDrawAnnotComboboxText() - - # @exec_time - def getDisplayedImg1(self): - return self.img1.image - - def getDisplayedZstack(self): - posData = self.data[self.pos_i] - return posData.img_data[posData.frame_i] - - def autoAssignBud_YeastMate(self): - if not self.is_win: - txt = ( - 'YeastMate is available only on Windows OS.' - 'We are working on expading support also on macOS and Linux.\n\n' - 'Thank you for your patience!' - ) - msg = QMessageBox() - msg.critical( - self, 'Supported only on Windows', txt, msg.Ok - ) - return - - - model_name = 'YeastMate' - idx = self.modelNames.index(model_name) - - self.titleLabel.setText( - f'{model_name} is thinking... ' - '(check progress in terminal/console)', color=self.titleColor - ) - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - posData = self.data[self.pos_i] - # Check if model needs to be imported - acdcSegment = self.acdcSegment_li[idx] - if acdcSegment is None: - acdcSegment = myutils.import_segment_module(model_name) - self.acdcSegment_li[idx] = acdcSegment - - # Read all models parameters - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) - # Prompt user to enter the model parameters - try: - url = acdcSegment.url_help() - except AttributeError: - url = None - - _SizeZ = None - if self.isSegm3D: - _SizeZ = posData.SizeZ - win = apps.QDialogModelParams( - init_params, - segment_params, - model_name, - url=url, - posData=posData, - df_metadata=posData.metadata_df - ) - win.exec_() - if win.cancel: - self.titleLabel.setText('Segmentation aborted.') - return - - use_gpu = win.init_kwargs.get('gpu', False) - proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) - if not proceed: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - - self.model_kwargs = win.model_kwargs - model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) - if model is None: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - try: - model.setupLogger(self.logger) - except Exception as e: - pass - - self.models[idx] = model - - img = self.getDisplayedImg1() - - posData.cca_df = model.predictCcaState(img, posData.lab) - self.store_data() - self.updateAllImages() - - self.titleLabel.setText('Budding event prediction done.', color='g') - - def isNavigateActionOnNextFrame(self): - posData = self.data[self.pos_i] - if posData.SizeT == 1: - return False - - ax1_coords = self.getMouseDataCoordsRightImage() - if ax1_coords is None: - return False - - if not self.labelsGrad.showNextFrameAction.isEnabled(): - return False - - if not self.labelsGrad.showNextFrameAction.isChecked(): - return - - # Mouse is on right image and next frame action is checked - return True - - def rightImageFramesScrollbarValueChanged(self, value): - img = self.nextFrameImage(current_frame_i=value-2) - self.img1.linkedImageItem.frame_i = value - self.img1.linkedImageItem.setImage(img) - - def nextActionTriggered(self): - if self.isNavigateActionOnNextFrame(): - self.rightImageFramesScrollbar.setValue( - self.rightImageFramesScrollbar.value()+1 - ) - return - - stepAddAction = QAbstractSlider.SliderAction.SliderSingleStepAdd - if self.zKeptDown or self.zSliceCheckbox.isChecked(): - self.zSliceScrollBar.triggerAction(stepAddAction) - else: - self.navigateScrollBar.triggerAction(stepAddAction) - - def prevActionTriggered(self): - if self.isNavigateActionOnNextFrame(): - self.rightImageFramesScrollbar.setValue( - self.rightImageFramesScrollbar.value()-1 - ) - return - - stepSubAction = QAbstractSlider.SliderAction.SliderSingleStepSub - if self.zKeptDown or self.zSliceCheckbox.isChecked(): - self.zSliceScrollBar.triggerAction(stepSubAction) - else: - self.navigateScrollBar.triggerAction(stepSubAction) - - def resetNavigateScrollbar(self): - try: - self.navigateScrollBar.blockSignals(True) - self.navigateScrollBar.actionTriggered.disconnect() - self.navigateScrollBar.sliderReleased.disconnect() - self.navigateScrollBar.sliderMoved.disconnect() - # self.navigateScrollBar.valueChanged.disconnect() - self.navigateScrollBar.setSliderPosition(self.navSpinBox.value()) - except Exception as e: - if "disconnect()" not in str(e): - printl(e) - pass - - self.navigateScrollBar.blockSignals(False) - self.navigateScrollBar.actionTriggered.connect( - self.framesScrollBarActionTriggered - ) - self.navigateScrollBar.sliderReleased.connect(self.framesScrollBarReleased) - self.navigateScrollBar.sliderMoved.connect(self.framesScrollBarMoved) - - @exception_handler - def next_cb(self): - if self.isSnapshot: - self.next_pos() - else: - self.next_frame() - if self.curvToolButton.isChecked(): - self.curvTool_cb(True) - - self.updatePropsWidget('') - - @exception_handler - def prev_cb(self): - if self.isSnapshot: - self.prev_pos() - else: - self.prev_frame() - if self.curvToolButton.isChecked(): - self.curvTool_cb(True) - - self.updatePropsWidget('') - - def zoomOut(self): - self.ax1.autoRange() - - def preprocessActionTriggered(self): - self.preprocessDialog.show() - self.preprocessDialog.raise_() - self.preprocessDialog.activateWindow() - self.preprocessDialog.emitSigPreviewToggled() - - def zoomToObjsActionCallback(self): - self.zoomToCells(enforce=True) - - def zoomToCells(self, enforce=False): - if not self.enableAutoZoomToCellsAction.isChecked() and not enforce: - return - - posData = self.data[self.pos_i] - lab_mask = (self.currentLab2D>0).astype(np.uint8) - rp = skimage.measure.regionprops(lab_mask) - if not rp: - Y, X = lab_mask.shape - xRange = -0.5, X+0.5 - yRange = -0.5, Y+0.5 - else: - obj = rp[0] - min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) - xRange = min_col-10, max_col+10 - yRange = max_row+10, min_row-10 - - self.ax1.setRange(xRange=xRange, yRange=yRange) - - def viewCcaTable(self): - posData = self.data[self.pos_i] - zoomIDs = self.getZoomIDs() - - df = posData.allData_li[posData.frame_i]['acdc_df'] - current_cca_df = posData.cca_df - if zoomIDs is not None: - df = df.loc[zoomIDs] - current_cca_df = current_cca_df.loc[zoomIDs] - - for column in current_cca_df.columns: - header = ( - '================================================\n' - f'CURRENT vs STORED `{column}` column' - f'for frame number {posData.frame_i+1}:\n' - ) - df_compare = current_cca_df[[column]].copy() - df_compare[f'STORED_{column}'] = df[column] - text = f'{header}{df_compare}' - self.logger.info(text) - - if 'cell_cycle_stage' in df.columns: - cca_df = df[self.cca_df_colnames] - cca_df = cca_df.merge( - current_cca_df, how='outer', left_index=True, right_index=True, - suffixes=('_STORED', '_CURRENT') - ) - cca_df = cca_df.reindex(sorted(cca_df.columns), axis=1) - num_cols = len(cca_df.columns) - for j in range(0,num_cols,2): - df_j_x = cca_df.iloc[:,j] - df_j_y = cca_df.iloc[:,j+1] - if any(df_j_x!=df_j_y): - self.logger.info('------------------------') - self.logger.info('DIFFERENCES:') - diff_df = cca_df.iloc[:,j:j+2] - diff_mask = diff_df.iloc[:,0]!=diff_df.iloc[:,1] - self.logger.info(diff_df[diff_mask]) - else: - cca_df = None - self.logger.info(cca_df) - self.logger.info('========================') - if current_cca_df is None: - return - if current_cca_df.empty: - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Cell cycle annotations\' table is empty.
' - ) - msg.warning(self, 'Table empty', txt) - return - - df = posData.add_tree_cols_to_cca_df( - current_cca_df, frame_i=posData.frame_i - ) - if self.ccaTableWin is None: - self.ccaTableWin = apps.ViewCcaTableWindow(df, parent=self) - self.ccaTableWin.show() - self.ccaTableWin.setGeometryWindow() - self.ccaTableWin.sigUpdateCcaTable.connect( - self.onSigUpdateCcaTableWindow - ) - else: - self.ccaTableWin.setFocus() - self.ccaTableWin.activateWindow() - self.ccaTableWin.updateTable(current_cca_df) - - def updateScrollbars(self): - self.updateItemsMousePos() - self.updateFramePosLabel() - posData = self.data[self.pos_i] - navPos = self.pos_i+1 if self.isSnapshot else posData.frame_i+1 - self.navigateScrollBar.setSliderPosition(navPos) - if posData.SizeZ > 1: - self.updateZsliceScrollbar(posData.frame_i) - idx = (posData.filename, posData.frame_i) - self.zSliceScrollBar.setMaximum(posData.SizeZ-1) - self.zSliceSpinbox.setMaximum(posData.SizeZ) - self.SizeZlabel.setText(f'/{posData.SizeZ}') - - def updateItemsMousePos(self): - if self.brushButton.isChecked(): - self.updateBrushCursor(self.xHoverImg, self.yHoverImg) - - if self.eraserButton.isChecked(): - self.updateEraserCursor(self.xHoverImg, self.yHoverImg) - - @exception_handler - def postProcessing(self): - if self.postProcessSegmWin is None: - return - - self.postProcessSegmWin.setPosData() - posData = self.data[self.pos_i] - lab, delIDs = self.postProcessSegmWin.apply() - if posData.allData_li[posData.frame_i]['labels'] is None: - posData.lab = lab.copy() - self.update_rp() - else: - posData.allData_li[posData.frame_i]['labels'] = lab - self.get_data() - - def preprocessDialogRecipeChanged(self, recipe):# why does this need the recepie as an arg - recipe = self.preprocessDialog.recipe() - if recipe is None: - self.logger.warning('Pre-processing recipe not initialized yet.') - return - - self.updatePreprocessPreview(recipe=recipe) - - def debugShowImg(self, img): - imshow(img) - - def preprocessDialogSavePreprocessedData(self, dialog): - posData = self.data[self.pos_i] - - try: - posData.preprocessedDataArray() - except TypeError as e: - if 'Not all frames have been processed.' in str(e): - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Not all frames have been processed.
' - 'Please process all frames before saving.' - ) - msg.warning(self, 'Process all data before saving', txt) - return - - - helpText = ( - """ - The preprocessed image file will be saved with a different - file name.

- Insert a name to append to the end of the new file name. The rest of - the name will be the same as the original file. - """ - ) - - - win = apps.filenameDialog( - basename=f'{posData.basename}{self.user_ch_name}', - ext=".tif", - hintText='Insert a name for the preprocessed image file:', - defaultEntry='preprocessed', - helpText=helpText, - allowEmpty=False, - parent=dialog - ) - win.exec_() - if win.cancel: - return - - appendedText = win.entryText - - self.progressWin = apps.QDialogWorkerProgress( - title='Saving pre-processed image(s)', - parent=self, - pbarDesc='Saving pre-processed image(s)' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.statusBarLabel.setText('Saving pre-processed data...') - - self.savePreprocWorker = workers.SaveProcessedDataWorker( - self.data, appendedText, ext=".tif" - ) - - self.savePreprocThread = QThread() - self.savePreprocWorker.moveToThread(self.savePreprocThread) - self.savePreprocWorker.signals.finished.connect( - self.savePreprocThread.quit - ) - self.savePreprocWorker.signals.finished.connect( - self.savePreprocWorker.deleteLater - ) - self.savePreprocThread.finished.connect( - self.savePreprocThread.deleteLater - ) - - self.savePreprocWorker.signals.critical.connect( - self.workerCritical - ) - self.savePreprocWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.savePreprocWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.savePreprocWorker.signals.progress.connect( - self.workerProgress - ) - self.savePreprocWorker.signals.finished.connect( - self.savePreprocWorkerFinished - ) - - self.savePreprocThread.started.connect( - self.savePreprocWorker.run - ) - self.savePreprocThread.start() - - - def preprocessEnqueueCurrentImage(self, recipe): - posData = self.data[self.pos_i] - func = core.preprocess_image_from_recipe - image_data = self.getImage(raw=True) - if posData.SizeZ > 1: - z_slice = self.z_slice_index() - else: - z_slice = 0 - - recipe = core.validate_multidimensional_recipe(recipe) - - key = (self.pos_i, posData.frame_i, z_slice) - self.preprocWorker.enqueue( - func, - image_data, - recipe, - key - ) - - def getChData(self, requ_ch=None, pos_i=None): - if not pos_i: - pos_i = self.pos_i - - posData = self.data[pos_i] - - if not requ_ch: - requ_ch = set(self.ch_names) - else: - requ_ch = set(requ_ch) - - posData.setLoadedChannelNames() - - loaded_channels = set(posData.loadedChNames) - missing_channels = requ_ch - loaded_channels - - self.loadFluo_cb(fluo_channels=missing_channels) - - def updatePreprocessPreview(self, *args, **kwargs): - force = kwargs.get('force', False) - - if not self.preprocessDialog.isVisible() and not force: - return - - if not self.preprocessDialog.previewCheckbox.isChecked() and not force: - return - - if kwargs.get('recipe') is None: - recipe = self.preprocessDialog.recipe() - else: - recipe = kwargs.get('recipe') - - if recipe is None: - self.logger.warning('Pre-processing recipe not initialized yet.') - return - - txt = 'Pre-processing current image...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - self.preprocessEnqueueCurrentImage(recipe) - - def next_pos(self): - self.store_data(debug=True, autosave=False) - prev_pos_i = self.pos_i - if self.pos_i < self.num_pos-1: - self.pos_i += 1 - self.updateSegmDataAutoSaveWorker() - else: - self.logger.info('You reached last position.') - self.pos_i = 0 - self.updatePos() - - def resetManualBackgroundItems(self): - self.initManualBackgroundImage() - self.resetManualBackgroundSpinboxID() - self.drawManualTrackingGhost(self.xHoverImg, self.yHoverImg) - self.drawManualBackgroundObj(self.xHoverImg, self.yHoverImg) - - def clearUndoQueue(self): - posData = self.data[self.pos_i] - self.UndoCount = 0 - self.redoAction.setEnabled(False) - self.undoAction.setEnabled(False) - posData.UndoRedoStates = [[] for _ in range(posData.SizeT)] - posData.UndoRedoCcaStates = [[] for _ in range(posData.SizeT)] - if hasattr(self, 'undoAddPointQueueMapper'): - self.undoAddPointQueueMapper = defaultdict(list) - - def updatePos(self): - self.clearUndoQueue() - self.setStatusBarLabel() - self.checkManageVersions() - self.removeAlldelROIsCurrentFrame() - self.resetManualBackgroundItems() - proceed_cca, never_visited = self.get_data(debug=False) - self.pointsLayerLoadedDfsToData() - self.flushDirtyPointsLayersAutosave() - self.initContoursImage() - self.initDelRoiLab() - self.initTextAnnot() - self.postProcessing() - self.updateScrollbars() - self.updatePreprocessPreview() - self.updateCombineChannelsPreview() - self.updateAllImages() - self.computeSegm() - self.zoomOut() - self.restartZoomAutoPilot() - self.initManualBackgroundObject() - self.updateObjectCounts() - self.updateItemsMousePos() - - def prev_pos(self): - self.store_data(debug=False, autosave=False) - prev_pos_i = self.pos_i - if self.pos_i > 0: - self.pos_i -= 1 - self.updateSegmDataAutoSaveWorker() - else: - self.logger.info('You reached first position.') - self.pos_i = self.num_pos-1 - self.updatePos() - - def updateViewerWindow(self): - if self.slideshowWin is None: - return - - if self.slideshowWin.linkWindow is None: - return - - if not self.slideshowWin.linkWindowCheckbox.isChecked(): - return - - posData = self.data[self.pos_i] - self.slideshowWin.frame_i = posData.frame_i - self.slideshowWin.update_img() - - def warnLostObjects(self, do_warn=True): - if not do_warn: - return True - - if not self.warnLostCellsAction.isChecked(): - return True - - mode = str(self.modeComboBox.currentText()) - if not mode == 'Segmentation and Tracking': - return True - - posData = self.data[self.pos_i] - if not posData.lost_IDs: - return True - - frame_i = posData.frame_i - try: - accepted_lost_IDs = posData.accepted_lost_IDs.get(frame_i, []) - already_accepted_lost = ( - Counter(accepted_lost_IDs) == Counter(posData.lost_IDs) - ) - except AttributeError as err: - already_accepted_lost = False - - if already_accepted_lost: - return True - - self.nextAction.setDisabled(True) - self.prevAction.setDisabled(True) - self.navigateScrollBar.setDisabled(True) - - msg = widgets.myMessageBox() - warn_msg = html_utils.paragraph( - 'Current frame (compared to previous frame) ' - 'has lost the following cells:

' - f'{posData.lost_IDs}

' - 'Are you sure you want to continue?
' - ) - checkBox = QCheckBox('Do not show again') - noButton, yesButton = msg.warning( - self, 'Lost cells!', warn_msg, - buttonsTexts=('No', 'Yes'), - widgets=checkBox - ) - doNotWarnLostCells = not checkBox.isChecked() - self.warnLostCellsAction.setChecked(doNotWarnLostCells) - if msg.clickedButton == noButton: - self.nextAction.setDisabled(False) - self.prevAction.setDisabled(False) - self.navigateScrollBar.setDisabled(False) - return False - - self.nextAction.setDisabled(False) - self.prevAction.setDisabled(False) - self.navigateScrollBar.setDisabled(False) - if not hasattr(posData, 'accepted_lost_IDs'): - posData.accepted_lost_IDs = {} - if frame_i not in posData.accepted_lost_IDs: - posData.accepted_lost_IDs[frame_i] = [] - - posData.accepted_lost_IDs[frame_i].extend(posData.lost_IDs) - # This section is adding the lost cells to tracked_lost_centroids... TBH I dont know why this wasnt done in the first place - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] - accepted_lost_centroids = { - tuple(int(val) for val in prev_rp[prev_IDs_idxs[ID]].centroid) - for ID in posData.lost_IDs - } - try: - posData.tracked_lost_centroids[frame_i] = ( - posData.tracked_lost_centroids[frame_i] | (accepted_lost_centroids) - ) - except KeyError: - posData.tracked_lost_centroids[frame_i] = accepted_lost_centroids - return True - - def askInitCcaFirstFrame(self): - mode = str(self.modeComboBox.currentText()) - if mode != 'Cell cycle analysis': - return True - - posData = self.data[self.pos_i] - if posData.frame_i != 0: - return True - - editCcaWidget = apps.editCcaTableWidget( - posData.cca_df, posData.SizeT, parent=self, - title='Initialize cell cycle annotations' - ) - editCcaWidget.sigApplyChangesFutureFrames.connect( - self.applyManualCcaChangesFutureFrames - ) - editCcaWidget.exec_() - if editCcaWidget.cancel: - self.resetNavigateFramesScrollbar() - return False - - if posData.cca_df is not None: - is_cca_same_as_stored = ( - (posData.cca_df == editCcaWidget.cca_df).all(axis=None) - ) - if not is_cca_same_as_stored: - reinit_cca = self.warnEditingWithCca_df( - 'Re-initialize cell cyle annotations first frame', - return_answer=True - ) - if reinit_cca: - self.resetCcaFuture(0) - - posData.cca_df = editCcaWidget.cca_df - self.store_cca_df() - - return True - - def askInitLinTreeFirstFrame(self): - mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree': - return True - - posData = self.data[self.pos_i] - if posData.frame_i != 0: - return True - - if self.lineage_tree is None: - self.initLinTree() - - return True - - def checkIfFutureFrameManualAnnotPastFrames(self): - if not self.manualAnnotPastButton.isChecked(): - return True - - posData = self.data[self.pos_i] - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - if posData.frame_i <= frame_to_restore: - return True - - warn_txt = ( - 'WARNING: Cannot navigate to future frames while in ' - 'manual annotation mode.' - ) - self.logger.info(warn_txt) - self.statusBarLabel.setText(f'

{warn_txt}

') - - return False - - # @exec_time - def next_frame(self, warn=True): - proceed = self.checkIfFutureFrameManualAnnotPastFrames() - if not proceed: - return - - proceed = self.askInitCcaFirstFrame() - if not proceed: - return - - proceed = self.askInitLinTreeFirstFrame() - if not proceed: - return - - mode = str(self.modeComboBox.currentText()) - posData = self.data[self.pos_i] - - if posData.frame_i >= posData.SizeT-1: - # Store data for current frame - if mode != 'Viewer': - self.store_data(debug=False) - msg = 'You reached the last segmented frame!' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - return - - proceed = self.warnLostObjects() - if not proceed: - self.resetNavigateScrollbar() - return - - # Store data for current frame - if mode != 'Viewer': - self.store_data(debug=False) - - self.askLineageTreeChanges() - posData.frame_i += 1 - self.removeAlldelROIsCurrentFrame() - proceed_cca, never_visited = self.get_data() - if not proceed_cca: - posData.frame_i -= 1 - self.get_data() - self.logger.info( - 'No data for current frame. ' - ) - return - - if mode == 'Segmentation and Tracking' or self.isSnapshot: - self.addExistingDelROIs() - - self.updatePreprocessPreview() - self.updateCombineChannelsPreview() - self.postProcessing() - self.tracking(storeUndo=True, wl_update=False) - notEnoughG1Cells, proceed = self.attempt_auto_cca() - if notEnoughG1Cells or not proceed: - posData.frame_i -= 1 - self.get_data() - self.setAllTextAnnotations() - self.logger.info( - 'Not enough G1 cells to compute cell cycle annotations.' - ) - return - - self.store_zslices_rp() - self.resetExpandLabel() - self.updateAllImages() - self.updateHighlightedAxis() - self.updateViewerWindow() - self.updateLastVisitedFrame(last_visited_frame_i=posData.frame_i-1) - self.setNavigateScrollBarMaximum() - self.updateScrollbars() - self.computeSegm() - self.initGhostObject() - self.whitelistPropagateIDs() - self.zoomToCells() - self.updateItemsMousePos() - self.updateObjectCounts() - - self.apply_tools_on_new_frame() - - def apply_tools_on_new_frame(self): - mode = str(self.modeComboBox.currentText()) - if mode != 'Segmentation and Tracking': - return - posData = self.data[self.pos_i] - if not (posData.last_tracked_i <= posData.frame_i) or posData.frame_i == self.lastFrameRanOnFirstVisitTools: - return - - self.lastFrameRanOnFirstVisitTools = posData.frame_i - for name, checkbox in self.applyToolNewFrameActions.items(): - if not checkbox.isChecked(): - continue - - tool_button = self.applyToolNewFrameButtons[name] - try: - if hasattr(tool_button, 'click'): - tool_button.click() - elif hasattr(tool_button, 'trigger'): - tool_button.trigger() - else: - printl( - f"Warning: {name} has no click or trigger method" - ) - except Exception as e: - self.logger.info(f"Error applying tool {name}: {e}") - - @disableWindow - def get_difference_table(self, return_css_separated=False, return_differece=False): - - if self.original_df_lin_tree is None: - return - - posData = self.data[self.pos_i] - - new_df = posData.allData_li[posData.frame_i]['acdc_df'] - original_df = self.original_df_lin_tree.copy() - - if original_df.equals(new_df): - return - - compare_columns = ['parent_ID_tree'] - - new_df = new_df[original_df.columns] - new_df = myutils.checked_reset_index_Cell_ID(new_df) - new_df = new_df[compare_columns] - new_df = new_df.sort_index() - original_df = myutils.checked_reset_index_Cell_ID(original_df) - original_df = original_df[compare_columns] - original_df = original_df.sort_index() - - differences = original_df.compare(new_df) - if differences.empty: - return - - differences = myutils.checked_reset_index_Cell_ID(differences) - - differences = differences['parent_ID_tree'] - differences = differences.reset_index() - - txt = """ - - - - - """ - - for diff in differences.itertuples(): - ID = str(int(diff.Cell_ID)) - old_parent = str(int(diff.self)) - new_parent = str(int(diff.other)) - - txt += f''' - - - - ''' - txt += '
IDold parent -->new parent
{ID}{old_parent}{new_parent}
' - - css = r''' - - ''' - if return_css_separated and not return_differece: - return css, txt - elif return_css_separated and return_differece: - return css, txt, differences - elif not return_css_separated and return_differece: - return txt, differences - else: - txt = css + html_utils.paragraph(txt) - return txt - - def viewLinTreeInfoAction(self): - mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree': - self.logger.info('This action is only available in the "Normal division: Lineage tree" mode.') - return - - if not self.lineage_tree: - self.logger.info('No lineage tree found.') - return - - posData = self.data[self.pos_i] - - if self.original_df_lin_tree_i != posData.frame_i: - # could be that this is not entirley true and self.curr_original_df_i just didnt get set right though! - txt_changes = '
No changes were made in this frame.

' - - else: - result = self.get_difference_table(return_css_separated=True) - - if result is None: - txt_changes = 'No changes were made in this frame.' - else: - css, txt_changes = result - - txt_changes = 'Changes made in this frame:' + txt_changes + '

' - - cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i) - - if orphan_cells == []: - txt_orphan_cells = 'No orphan Cells!' - else: - txt_orphan_cells = ', '.join([str(cell) for cell in orphan_cells]) - txt_orphan = f'Orphan cells:
{txt_orphan_cells}

' - - lost_cells = list(lost_cells) - if lost_cells == []: - txt_lost_cells = 'No lost Cells!' - else: - txt_lost_cells = ', '.join([str(cell) for cell in lost_cells]) - txt_lost = f'Lost cells:
{txt_lost_cells}

' - - if cells_with_parent == []: - table_cells_with_parent = '
No cells with parents!' - else: - table_cells_with_parent = """ - - - - """ - - for cell, parent in cells_with_parent: - table_cells_with_parent += f''' - - - ''' - table_cells_with_parent += '
Parent IDID
{parent}{cell}
' - - txt_cells_with_parents = f'Cells with parents:{table_cells_with_parent}

' - - css = r''' - - ''' - - txt = css + html_utils.paragraph(txt_changes + txt_orphan + txt_lost + txt_cells_with_parents) - - msg = widgets.myMessageBox() - msg.information(self, - 'lineage tree information', - txt - ) - - @disableWindow - def askLineageTreeChanges(self): - """ - Asks the user for changes in the lineage tree. - - This method is called when the user selects the 'Normal division: Lineage tree' mode. - It compared the backed up df (self.original_df from repeat_click_and_backup) with the current df (self.lineage_tree.export_df(posData.frame_i)) and propts the user to keep, propagate or discard the changes. - - """ - mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree': - return - - if not self.lineage_tree: - return - - posData = self.data[self.pos_i] - - if self.original_df_lin_tree_i is not None and self.original_df_lin_tree_i != posData.frame_i: - printl("!This should not happen!") - self.store_data(autosave=False) - og_frame = posData.frame_i - posData.frame_i = self.original_df_lin_tree_i - self.get_data() - self.logger.info('Lineage tree changes were not propagated, going back to original frame.') - self.askLineageTreeChanges() - self.store_data(autosave=False) - posData.frame_i = og_frame - self.get_data() - return - - result = self.get_difference_table(return_css_separated=True, return_differece=True) - if result is None: - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - return - - css, txt, differences = result - changed_IDs = differences['Cell_ID'].unique() - - if posData.frame_i == max(self.lineage_tree.frames_for_dfs): - # here we can just propagate the cahnged. This is super fast, since there is no recursion, no children and fast finding of parents - self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - return - - txt = txt + 'Do you want to keep, propgagte or discard the changes?' - txt = css + html_utils.paragraph('Changes made in this frame
' + txt) - - msg = widgets.myMessageBox() - - propagate_btn, discard_btn, _ = msg.question(self, - 'Changes in lineage tree', - txt, - buttonsTexts=('Propagate', 'Discard', 'Cancel'),) - - if msg.clickedButton == propagate_btn: - self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - self.logger.info('Lineage tree propagated.') - - elif msg.clickedButton == discard_btn: - posData.allData_li[posData.frame_i]['acdc_df'] = self.original_df_lin_tree.copy() - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - self.logger.info('Lineage tree changes discarded.') - - - elif msg.cancel: - # Go back to current frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(''' - Changes were kept but not propagated! - Please make sure to come back and propagate them, - otherwise your table might be inconsistent! - There is a button for this next to the edit buttons. - Please also do not visit new frames! - - ''') - msg.warning(self, 'Changes kept but not propagated!', txt) - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - self.logger.info('Lineage tree changes discarded.') - - def manualAnnotRestoreLastTrackedFrame(self, last_tracked_i_to_restore): - if self.navigateScrollBar.maximum()-1 <= last_tracked_i_to_restore: - return - - posData = self.data[self.pos_i] - for frame_i in range(last_tracked_i_to_restore+1, posData.SizeT): - data_frame_i = myutils.get_empty_stored_data_dict() - - data_frame_i['manually_edited_lab'] = ( - posData.allData_li[frame_i]['manually_edited_lab'] - ) - - posData.allData_li[frame_i] = data_frame_i - - self.navigateScrollBar.setMaximum(last_tracked_i_to_restore+1) - self.navSpinBox.setMaximum(last_tracked_i_to_restore+1) - - def setNavigateScrollBarMaximum(self): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Segmentation and Tracking': - if posData.last_tracked_i is not None: - if posData.frame_i > posData.last_tracked_i: - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) - else: - self.navigateScrollBar.setMaximum(posData.last_tracked_i+1) - self.navSpinBox.setMaximum(posData.last_tracked_i+1) - else: - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) - - self.updateLastCheckedFrameWidgets(self.navSpinBox.maximum()-1) - elif mode == 'Cell cycle analysis': - if posData.frame_i > self.last_cca_frame_i: - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) - else: - self.navigateScrollBar.setMaximum(self.last_cca_frame_i+1) - self.navSpinBox.setMaximum(self.last_cca_frame_i+1) - self.lastTrackedFrameLabel.setText( - f'Last cc annot. frame n. = {self.navSpinBox.maximum()}' - ) - elif mode == 'Normal division: Lineage tree': - if self.lineage_tree is None: - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) - else: - if self.lineage_tree.frames_for_dfs: - i = max(self.lineage_tree.frames_for_dfs) - else: - i = 0 - self.navigateScrollBar.setMaximum(i+1) - self.navSpinBox.setMaximum(i+1) - - # @exec_time - def prev_frame(self): - posData = self.data[self.pos_i] - if posData.frame_i <= 0: - msg = 'You reached the first frame!' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - return - - # Store data for current frame - mode = str(self.modeComboBox.currentText()) - if mode != 'Viewer': - self.store_data(debug=False) - - self.removeAlldelROIsCurrentFrame() - self.askLineageTreeChanges() - posData.frame_i -= 1 - _, never_visited = self.get_data() - - if mode == 'Segmentation and Tracking' or self.isSnapshot: - self.addExistingDelROIs() - - self.resetExpandLabel() - self.updatePreprocessPreview() - self.updateCombineChannelsPreview() - self.postProcessing() - self.tracking() - self.whitelistPropagateIDs(update_lab=True) - self.updateAllImages() - self.updateScrollbars() - self.updateHighlightedAxis() - self.zoomToCells() - self.initGhostObject() - self.updateViewerWindow() - self.updateItemsMousePos() - self.updateObjectCounts() - - def loadSelectedData(self, user_ch_file_paths, user_ch_name): - data = [] - numPos = len(user_ch_file_paths) - self.user_ch_file_paths = user_ch_file_paths - - self.logger.info(f'Reading {user_ch_name} channel metadata...') - # Get information from first loaded position - posData = load.loadData(user_ch_file_paths[0], user_ch_name, log_func=self.logger.info) - posData.getBasenameAndChNames(qparent=self) - posData.buildPaths() - - if posData.ext != '.h5': - self.lazyLoader.salute = False - self.lazyLoader.exit = True - self.lazyLoaderWaitCond.wakeAll() - self.waitReadH5cond.wakeAll() - - # Get end name of every existing segmentation file - existingSegmEndNames = set() - for filePath in user_ch_file_paths: - _posData = load.loadData(filePath, user_ch_name, log_func=self.logger.info) - _posData.getBasenameAndChNames(qparent=self) - segm_files = load.get_segm_files(_posData.images_path) - _existingEndnames = load.get_endnames( - _posData.basename, segm_files - ) - existingSegmEndNames.update(_existingEndnames) - - selectedSegmEndName = '' - self.newSegmEndName = '' - if self.isNewFile or not existingSegmEndNames: - self.isNewFile = True - # Remove the 'segm_' part to allow filenameDialog to check if - # a new file is existing (since we only ask for the part after - # 'segm_') - existingEndNames = [ - n.replace('segm', '', 1).replace('_', '', 1) - for n in existingSegmEndNames - ] - if posData.basename.endswith('_'): - basename = f'{posData.basename}segm' - else: - basename = f'{posData.basename}_segm' - win = apps.filenameDialog( - basename=basename, - hintText='Insert a filename for the segmentation file:', - existingNames=existingEndNames - ) - win.exec_() - if win.cancel: - self.loadingDataAborted() - return - self.newSegmEndName = win.entryText - else: - if len(existingSegmEndNames) > 0: - win = apps.SelectSegmFileDialog( - existingSegmEndNames, self.exp_path, parent=self, - addNewFileButton=True, basename=posData.basename - ) - win.exec_() - if win.cancel: - self.loadingDataAborted() - return - if win.newSegmEndName is None: - selectedSegmEndName = win.selectedItemText - self.AutoPilotProfile.storeSelectedSegmFile( - selectedSegmEndName - ) - else: - self.newSegmEndName = win.newSegmEndName - self.isNewFile = True - elif len(existingSegmEndNames) == 1: - selectedSegmEndName = list(existingSegmEndNames)[0] - - posData.loadImgData() - - required_ram = posData.getBytesImageData() - if required_ram >= 5e8: - # Disable autosave for data > 500MB - self.autoSaveToggle.setChecked(False) - - proceed = self.checkMemoryRequirements(required_ram) - if not proceed: - self.loadingDataAborted() - return - - posData.loadOtherFiles( - load_segm_data=True, - load_metadata=True, - create_new_segm=self.isNewFile, - new_endname=self.newSegmEndName, - end_filename_segm=selectedSegmEndName, - ) - self.selectedSegmEndName = selectedSegmEndName - self.labelBoolSegm = posData.labelBoolSegm - posData.labelSegmData() - - print('') - self.logger.info( - f'Segmentation filename: {posData.segm_npz_path}' - ) - - proceed = posData.askInputMetadata( - self.num_pos, - ask_SizeT=self.num_pos==1, - ask_TimeIncrement=True, - ask_PhysicalSizes=True, - singlePos=False, - save=True, - warnMultiPos=True - ) - if not proceed: - self.loadingDataAborted() - return - - self.AutoPilotProfile.storeOkAskInputMetadata() - - if posData.isSegm3D is None: - self.isSegm3D = False - else: - self.isSegm3D = posData.isSegm3D - self.SizeT = posData.SizeT - self.SizeZ = posData.SizeZ - self.TimeIncrement = posData.TimeIncrement - self.PhysicalSizeZ = posData.PhysicalSizeZ - self.PhysicalSizeY = posData.PhysicalSizeY - self.PhysicalSizeX = posData.PhysicalSizeX - self.loadSizeS = posData.loadSizeS - self.loadSizeT = posData.loadSizeT - self.loadSizeZ = posData.loadSizeZ - - self.overlayLabelsItems = {} - self.drawModeOverlayLabelsChannels = {} - - self.existingSegmEndNames = existingSegmEndNames - self.createOverlayLabelsContextMenu(existingSegmEndNames) - self.overlayLabelsButtonAction.setVisible(True) - self.createOverlayLabelsItems(existingSegmEndNames) - self.disableNonFunctionalButtons() - - self.isH5chunk = ( - posData.ext == '.h5' - and (self.loadSizeT != self.SizeT - or self.loadSizeZ != self.SizeZ) - ) - - required_ram = posData.checkH5memoryFootprint()*self.loadSizeS - if required_ram > 0: - proceed = self.checkMemoryRequirements(required_ram) - if not proceed: - self.loadingDataAborted() - return - - if posData.SizeT == 1: - self.isSnapshot = True - else: - self.isSnapshot = False - - self.progressWin = apps.QDialogWorkerProgress( - title='Loading data...', parent=self, - pbarDesc=f'Loading "{user_ch_file_paths[0]}"...' - ) - self.progressWin.show(self.app) - - func = partial( - self.startLoadDataWorker, user_ch_file_paths, user_ch_name, - posData - ) - - - QTimer.singleShot(150, func) - - def setManualAnnotModeEnabledTools(self, enabled): - for action in self.editToolBar.actions(): - toolButton = self.editToolBar.widgetForAction(action) - if toolButton in self.manulAnnotToolButtons: - continue - - toolButton.setDisabled(enabled) - action.setDisabled(enabled) - - def disableNonFunctionalButtons(self): - if not self.isSegm3D: - return - - for item in self.functionsNotTested3D: - if hasattr(item, 'action'): - toolButton = item - action = toolButton.action - toolButton.setDisabled(True) - elif hasattr(item, 'toolbar'): - toolbar = item.toolbar - action = item - toolButton = toolbar.widgetForAction(action) - toolButton.setDisabled(True) - else: - action = item - action.setDisabled(True) - - @exception_handler - def startLoadDataWorker( - self, user_ch_file_paths, user_ch_name, firstPosData - ): - self.funcDescription = 'loading data' - - self.guiTabControl.propsQGBox.idSB.setValue(0) - - self.thread = QThread() - self.loadDataMutex = QMutex() - self.loadDataWaitCond = QWaitCondition() - - self.loadDataWorker = workers.loadDataWorker( - self, user_ch_file_paths, user_ch_name, firstPosData - ) - - self.loadDataWorker.moveToThread(self.thread) - self.loadDataWorker.signals.finished.connect(self.thread.quit) - self.loadDataWorker.signals.finished.connect( - self.loadDataWorker.deleteLater - ) - self.thread.finished.connect(self.thread.deleteLater) - - self.loadDataWorker.signals.finished.connect( - self.loadDataWorkerFinished - ) - self.loadDataWorker.signals.progress.connect(self.workerProgress) - self.loadDataWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.loadDataWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.loadDataWorker.signals.critical.connect( - self.workerCritical - ) - self.loadDataWorker.signals.dataIntegrityCritical.connect( - self.loadDataWorkerDataIntegrityCritical - ) - self.loadDataWorker.signals.dataIntegrityWarning.connect( - self.loadDataWorkerDataIntegrityWarning - ) - self.loadDataWorker.signals.sigPermissionError.connect( - self.workerPermissionError - ) - self.loadDataWorker.signals.sigWarnMismatchSegmDataShape.connect( - self.askMismatchSegmDataShape - ) - self.loadDataWorker.signals.sigRecovery.connect( - self.askRecoverNotSavedData - ) - - self.thread.started.connect(self.loadDataWorker.run) - self.thread.start() - - def askRecoverNotSavedData(self, posData): - last_modified_time_unsaved = 'NEVER' - if os.path.exists(posData.segm_npz_temp_path): - recovered_file_path = posData.segm_npz_temp_path - if os.path.exists(posData.segm_npz_path): - last_modified_time_unsaved = ( - datetime.fromtimestamp( - os.path.getmtime(posData.segm_npz_path) - ).strftime("%a %d. %b. %y - %H:%M:%S") - ) - else: - posData.setTempPaths() - if os.path.exists(posData.unsaved_acdc_df_autosave_path): - zip_path = posData.unsaved_acdc_df_autosave_path - with zipfile.ZipFile(zip_path, mode='r') as zip: - csv_names = natsorted(set(zip.namelist())) - iso_key = csv_names[-1][:-4] - most_recent_unsaved_acdc_df_datetime = datetime.strptime( - iso_key, load.ISO_TIMESTAMP_FORMAT - ) - last_modified_time_unsaved = ( - most_recent_unsaved_acdc_df_datetime - ).strftime("%a %d. %b. %y - %H:%M:%S") - - if os.path.exists(posData.acdc_output_csv_path): - acdc_df_mtime = os.path.getmtime(posData.acdc_output_csv_path) - timestamp = datetime.fromtimestamp(acdc_df_mtime) - last_modified_time_saved = timestamp.strftime( - "%a %d. %b. %y - %H:%M:%S" - ) - else: - last_modified_time_saved = 'Null' - - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = html_utils.paragraph(""" - Cell-ACDC detected unsaved data.

- Do you want to load and recover the unsaved data or - load the data that was last saved by the user? - """) - details = (f""" - The unsaved data was created on {last_modified_time_unsaved}\n\n - The user saved the data last time on {last_modified_time_saved} - """) - msg.setDetailedText(details) - loadUnsavedButton = widgets.reloadPushButton('Recover unsaved data') - loadSavedButton = widgets.savePushButton('Load saved data') - infoButton = widgets.infoPushButton('More info...') - loadSafeNpzButton = '' - if posData.isSafeNpzOverwritePresent(): - loadSafeNpzButton = widgets.reloadPushButton( - 'Load .safe.npz file from crash' - ) - buttons = ( - loadSavedButton, loadUnsavedButton, loadSafeNpzButton, - infoButton - ) - else: - buttons = (loadSavedButton, loadUnsavedButton, infoButton) - msg.question( - self.progressWin, 'Recover unsaved data?', txt, - buttonsTexts=('Cancel', *buttons), - showDialog=False - ) - infoButton.disconnect() - infoButton.clicked.connect(partial(self.showInfoAutosave, posData)) - msg.exec_() - if msg.cancel: - self.loadDataWorker.abort = True - elif msg.clickedButton == loadUnsavedButton: - self.loadDataWorker.loadUnsaved = True - elif msg.clickedButton == loadSafeNpzButton: - self.loadDataWorker.loadSafeOverwriteNpz = True - - self.loadDataWorker.waitCond.wakeAll() - # self.AutoPilotProfile.storeLoadSavedData() - - def showInfoAutosave(self, posData): - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = (f""" - Cell-ACDC either detected unsaved data in a previous session and it - stored it because the Autosave
- function was active, or it crashed during saving.

- You can toggle Autosave ON and OFF from the menu on the top menubar - File --> Autosave. - """) - txt = (f""" - {txt}

- If Cell-ACDC crashed during saving, the segmentation file ending - with .new.npz
- is present and you might be able to recover the data from there. - """) - - txt = (f""" - {txt}

- You can find additional recovered data in the following folder: - """) - txt = html_utils.paragraph(txt) - msg.information( - self, 'Autosave info', txt, - path_to_browse=posData.recoveryFolderPath, - commands=(posData.recoveryFolderPath,) - ) - - def askMismatchSegmDataShape(self, posData): - msg = widgets.myMessageBox(wrapText=False) - title = 'Segm. data shape mismatch' - f = '3D' if self.isSegm3D else '2D' - f = f'{f} over time' if posData.SizeT > 1 else f - r = '2D' if self.isSegm3D else '3D' - r = f'{r} over time' if posData.SizeT > 1 else r - text = html_utils.paragraph(f""" - The segmentation masks of the first Position that you loaded is - {f},
- while {posData.pos_foldername} is {r}.

- The loaded segmentation masks must be either all 3D - or all 2D.

- Do you want to skip loading this position or cancel the process? - """) - _, skipPosButton = msg.warning( - self, title, text, buttonsTexts=('Cancel', 'Skip this Position') - ) - if skipPosButton == msg.clickedButton: - self.loadDataWorker.skipPos = True - self.loadDataWorker.waitCond.wakeAll() - - def workerPermissionError(self, txt, waitCond): - msg = widgets.myMessageBox(parent=self) - msg.setIcon(iconName='SP_MessageBoxCritical') - msg.setWindowTitle('Permission denied') - msg.addText(txt) - msg.addButton(' Ok ') - msg.exec_() - waitCond.wakeAll() - - def loadDataWorkerDataIntegrityCritical(self): - errTitle = 'All loaded positions contains frames over time!' - self.titleLabel.setText(errTitle, color='r') - - msg = widgets.myMessageBox(parent=self) - - err_msg = html_utils.paragraph(f""" - {errTitle}.

- To load data that contains frames over time you have to select - only ONE position. - """) - msg.setIcon(iconName='SP_MessageBoxCritical') - msg.setWindowTitle('Loaded multiple positions with frames!') - msg.addText(err_msg) - msg.addButton('Ok') - msg.show(block=True) - - @exception_handler - def loadDataWorkerFinished(self, data): - self.funcDescription = 'loading data worker finished' - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - if data is None or data=='abort': - self.loadingDataAborted() - return - - if data[0].onlyEditMetadata: - self.loadingDataAborted() - return - - self.pos_i = 0 - self.data = data - self.gui_createGraphicsItems() - return True - - def checkManageVersions(self): - posData = self.data[self.pos_i] - posData.setTempPaths(createFolder=False) - loaded_acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) - - if os.path.exists(posData.recoveryFolderpath()): - self.manageVersionsAction.setDisabled(False) - self.manageVersionsAction.setToolTip( - f'Load an older version of the `{loaded_acdc_df_filename}` file ' - '(table with annotations and measurements).' - ) - else: - self.manageVersionsAction.setDisabled(True) - - def preprocessPreviewToggled(self, checked): - self.viewPreprocDataToggle.setChecked(checked) - self.updatePreprocessPreview() - - - - def preprocessCurrentImage(self, recipe: List[Dict[str, Any]], *args): - txt = 'Pre-processing current image...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - func = core.preprocess_image_from_recipe - recipe = core.validate_multidimensional_recipe(recipe) - - image_data = self.getImage(raw=True) - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'current_image' - ) - - self.preprocWorker.wakeUp() - - def preprocessZStack(self, recipe: List[Dict[str, Any]], *args): - txt = 'Pre-processing z-stack...' - self.statusBarLabel.setText(txt) - self.logger.info(txt) - - posData = self.data[self.pos_i] - func = core.preprocess_zstack_from_recipe - recipe = core.validate_multidimensional_recipe( - recipe, apply_to_all_frames=False - ) - image_data = posData.img_data[posData.frame_i] - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'z_stack' - ) - - self.preprocWorker.wakeUp() - - def preprocessAllFrames(self, recipe: List[Dict[str, Any]]): - txt = 'Pre-processing all frames...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - posData = self.data[self.pos_i] - func = core.preprocess_video_from_recipe - image_data = posData.img_data - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'all_frames' - ) - self.preprocWorker.wakeUp() - - def preprocessAllPos(self, recipe: List[Dict[str, Any]]): - txt = 'Pre-processing all Positions...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - func = core.preprocess_multi_pos_from_recipe - recipe = core.validate_multidimensional_recipe( - recipe, apply_to_all_frames=False - ) - image_data = [posData.img_data[0] for posData in self.data] - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'all_pos' - ) - - self.preprocWorker.wakeUp() - - def setupPreprocessing(self): - posData = self.data[self.pos_i] - if self.preprocessDialog is not None: - self.preprocessDialog.close() - - self.preprocessDialog = apps.PreProcessRecipeDialog( - isTimelapse=posData.SizeT>1, - isZstack=posData.SizeZ>1, - isMultiPos=len(self.data)>1, - df_metadata=posData.metadata_df, - hideOnClosing=True, - addApplyButton=True, - parent=self - ) - self.doPreviewPreprocImage = False - self.preprocessDialog.sigApplyImage.connect( - self.preprocessCurrentImage - ) - self.preprocessDialog.sigApplyZstack.connect( - self.preprocessZStack - ) - self.preprocessDialog.sigApplyAllFrames.connect( - self.preprocessAllFrames - ) - self.preprocessDialog.sigApplyAllPos.connect( - self.preprocessAllPos - ) - self.preprocessDialog.sigPreviewToggled.connect( - self.preprocessPreviewToggled - ) - self.preprocessDialog.sigValuesChanged.connect( - self.preprocessDialogRecipeChanged - ) - self.preprocessDialog.sigSavePreprocData.connect( - self.preprocessDialogSavePreprocessedData - ) - - if self.preprocWorker is not None: - return - - self.preprocThread = QThread() - self.preprocMutex = QMutex() - self.preprocWaitCond = QWaitCondition() - - self.preprocWorker = workers.CustomPreprocessWorkerGUI( - self.preprocMutex, self.preprocWaitCond - ) - - self.preprocWorker.moveToThread(self.preprocThread) - self.preprocWorker.signals.finished.connect(self.preprocThread.quit) - self.preprocWorker.signals.finished.connect( - self.preprocWorker.deleteLater - ) - self.preprocThread.finished.connect(self.preprocThread.deleteLater) - - self.preprocWorker.sigDone.connect(self.preprocWorkerDone) - self.preprocWorker.sigIsQueueEmpty.connect( - self.preprocWorkerIsQueueEmpty - ) - self.preprocWorker.sigPreviewDone.connect(self.preprocWorkerPreviewDone) - self.preprocWorker.signals.progress.connect(self.workerProgress) - self.preprocWorker.signals.critical.connect(self.workerCritical) - self.preprocWorker.signals.finished.connect(self.preprocWorkerClosed) - - self.preprocThread.started.connect(self.preprocWorker.run) - self.preprocThread.start() - - self.logger.info('Pre-processing worker started.') - - def preprocWorkerCritical(self, error): - self.preprocessDialog.appliedFinished() - self.workerCritical(error) - - @exception_handler - def loadingDataCompleted(self): - self.isDataLoading = True - posData = self.data[self.pos_i] - - files_format = '\n'.join([ - f' - {file}' for file in posData.images_folder_files - ]) - sep = '-'*100 - self.logger.info( - f'{sep}\nFiles present in the first Position folder loaded:\n\n' - f'{files_format}\n{sep}' - ) - self.logger.info(f'Basename of the first Position: {posData.basename}') - self.secondLevelToolbar.setVisible(True) - self.updateImageValueFormatter() - self.checkManageVersions() - self.initManualBackgroundImage() - self.initPixelSizePropsDockWidget() - - self.setWindowTitle( - f'Cell-ACDC v{self._acdc_version} - GUI - "{posData.exp_path}"' - ) - - self.setupPreprocessing() - self.setupCombiningChannels() - - if self.isSegm3D: - self.segmNdimIndicator.setText('3D') - else: - self.segmNdimIndicator.setText('2D') - - self.segmNdimIndicatorAction.setVisible(True) - - self.guiTabControl.addChannels([posData.user_ch_name]) - self.showPropsDockButton.setDisabled(False) - - self.bottomScrollArea.show() - self.gui_createStoreStateWorker() - self.init_segmInfo_df() - self.connectScrollbars() - self.initPosAttr() - - self.logger.info('Pre-computing min and max values of the images...') - self.img1.preComputedMinMaxValues(self.data) - self.img2.minMaxValuesMapper = self.img1.minMaxValuesMapper - - self.initMetrics() - self.initFluoData() - self.createChannelNamesActions() - self.addActionsLutItemContextMenu(self.imgGrad) - - # Scrollbar for opacity of img1 (when overlaying) - self.img1.alphaScrollbar = self.addAlphaScrollbar( - self.user_ch_name, self.img1 - ) - - self.navigateScrollBar.setSliderPosition(posData.frame_i+1) - - # Connect events at the end of loading data process - self.gui_connectGraphicsEvents() - if not self.isEditActionsConnected: - self.gui_connectEditActions() - self.normalizeToFloatAction.setChecked(True) - - self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) - - self.setFramesSnapshotMode() - if self.isSnapshot: - self.navSizeLabel.setText(f'/{len(self.data)}') - else: - self.navSizeLabel.setText(f'/{posData.SizeT}') - - self.enableZstackWidgets(posData.SizeZ > 1) - # self.showHighlightZneighCheckbox() - - self.exportToVideoAction.setDisabled( - posData.SizeZ == 1 and posData.SizeT == 1 - ) - - self.img1BottomGroupbox.show() - - isLabVisible = self.df_settings.at['isLabelsVisible', 'value'] == 'Yes' - isRightImgVisible = ( - self.df_settings.at['isRightImageVisible', 'value'] == 'Yes' - ) - isNextFrameVisible = ( - self.df_settings.at['isNextFrameVisible', 'value'] == 'Yes' - ) - isNextFrameActive = ( - isNextFrameVisible and self.labelsGrad.showNextFrameAction.isEnabled() - ) - self.updateScrollbars() - self.openFolderAction.setEnabled(True) - self.editTextIDsColorAction.setDisabled(False) - self.imgPropertiesAction.setEnabled(True) - self.navigateToolBar.setVisible(True) - self.labelsGrad.showLabelsImgAction.setChecked(isLabVisible) - self.labelsGrad.showRightImgAction.setChecked(isRightImgVisible) - self.labelsGrad.showNextFrameAction.setChecked(isNextFrameActive) - if isRightImgVisible or isNextFrameActive: - self.rightBottomGroupbox.setChecked(True) - - isTwoImagesLayout = ( - isRightImgVisible or isLabVisible or isNextFrameActive - ) - self.setTwoImagesLayout(isTwoImagesLayout) - - self.setBottomLayoutStretch() - - if isNextFrameActive: - self.rightBottomGroupbox.show() - self.rightBottomGroupbox.setChecked(True) - self.drawNothingCheckboxRight.click() - - self.readSavedCustomAnnot() - self.addCustomAnnotButtonAllLoadedPos() - self.setStatusBarLabel() - - self.initLookupTableLab() - if self.invertBwAction.isChecked() and not self.invertBwAlreadyCalledOnce: - self.invertBw(True) - self.restoreSavedSettings() - - self.initContoursImage() - self.initTextAnnot() - self.initDelRoiLab() - - self.update_rp() - self.updateAllImages() - if posData.SizeT > 1: - self.rightImageFramesScrollbar.setValueNoSignal(posData.frame_i+2) - self.setMetricsFunc() - - self.gui_createLabelRoiItem() - self.gui_createZoomRectItem() - - self.titleLabel.setText( - 'Data successfully loaded.', - color=self.titleColor - ) - - self.disableNonFunctionalButtons() - self.setVisible3DsegmWidgets() - - if len(self.data) == 1 and posData.SizeZ > 1 and posData.SizeT == 1: - self.zSliceCheckbox.setChecked(True) - else: - self.zSliceCheckbox.setChecked(False) - - self.labelRoiCircItemLeft.setImageShape(self.currentLab2D.shape) - self.labelRoiCircItemRight.setImageShape(self.currentLab2D.shape) - - self.retainSpaceSlidersToggled(self.retainSpaceSlidersAction.isChecked()) - - self.stopAutomaticLoadingPos() - self.viewAllCustomAnnotAction.setChecked(True) - - self.updateImageValueFormatter() - - posData.loadWhitelist() - - self.setFocusGraphics() - self.setFocusMain() - - # Overwrite axes viewbox context menu - self.ax1.vb.menu = self.imgGrad.gradient.menu - self.ax2.vb.menu = self.labelsGrad.menu - - QTimer.singleShot(200, self.resizeGui) - - self.isDataLoaded = True - self.isDataLoading = False - - self.initImgGradRescaleIntensitiesHowPreference() - - self.rescaleIntensitiesLut(setImage=False) - - self.gui_createAutoSaveWorker() - - def initImgGradRescaleIntensitiesHowPreference(self): - posData = self.data[self.pos_i] - channelName = posData.user_ch_name - if f'how_rescale_intensities_{channelName}' not in self.df_settings.index: - return - - how = self.df_settings.at[ - f'how_rescale_intensities_{channelName}', 'value' - ] - self.imgGrad.setRescaleIntensitiesHow(how) - - def removeAxLimits(self): - self.ax1.vb.state['limits']['xLimits'] = [-1E307, +1E307] - self.ax1.vb.state['limits']['yLimits'] = [-1E307, +1E307] - - def resizeGui(self): - self.ax1.vb.state['limits']['xRange'] = [None, None] - self.ax1.vb.state['limits']['yRange'] = [None, None] - self.autoRange() - if self.ax1.getViewBox().state['limits']['xRange'][0] is not None: - self.bottomScrollArea._resizeVertical() - return - (xmin, xmax), (ymin, ymax) = self.ax1.viewRange() - maxYRange = int((ymax-ymin)*1.5) - maxXRange = int((xmax-xmin)*1.5) - self.ax1.setLimits( - maxYRange=maxYRange, - maxXRange=maxXRange - ) - self.bottomScrollArea._resizeVertical() - QTimer.singleShot(200, self.autoRange) - - def setVisible3DsegmWidgets(self): - self.annotNumZslicesCheckbox.setVisible(self.isSegm3D) - self.annotNumZslicesCheckboxRight.setVisible(self.isSegm3D) - if not self.isSegm3D: - self.annotNumZslicesCheckbox.setChecked(False) - self.annotNumZslicesCheckboxRight.setChecked(False) - - def showHighlightZneighCheckbox(self): - if self.isSegm3D: - layout = self.bottomLeftLayout - # layout.addWidget(self.annotOptionsWidget, 0, 1, 1, 2) - # # layout.removeWidget(self.drawIDsContComboBox) - # # layout.addWidget(self.drawIDsContComboBox, 0, 1, 1, 1, - # # alignment=Qt.AlignCenter - # # ) - # layout.addWidget(self.highlightZneighObjCheckbox, 0, 2, 1, 2, - # alignment=Qt.AlignRight - # ) - self.highlightZneighObjCheckbox.show() - self.highlightZneighObjCheckbox.setChecked(True) - self.highlightZneighObjCheckbox.toggled.connect( - self.highlightZneighLabels_cb - ) - - def restoreSavedSettings(self): - if 'how_draw_annotations' in self.df_settings.index: - how = self.df_settings.at['how_draw_annotations', 'value'] - self.drawIDsContComboBox.setCurrentText(how) - else: - self.drawIDsContComboBox.setCurrentText('Draw IDs and contours') - - if 'how_draw_right_annotations' in self.df_settings.index: - how = self.df_settings.at['how_draw_right_annotations', 'value'] - self.annotateRightHowCombobox.setCurrentText(how) - else: - self.annotateRightHowCombobox.setCurrentText( - 'Draw IDs and overlay segm. masks' - ) - - if 'addNewIDsWhitelistToggle' in self.df_settings.index: - self.addNewIDsWhitelistToggle = ( - self.df_settings.at['addNewIDsWhitelistToggle', 'value'] - ) == 'Yes' - else: - self.addNewIDsWhitelistToggle = True - - self.drawAnnotCombobox_to_options() - self.drawIDsContComboBox_cb(0) - self.annotateRightHowCombobox_cb(0) - - def uncheckAnnotOptions(self, left=True, right=True): - # Left - if left: - self.annotIDsCheckbox.setChecked(False) - self.annotCcaInfoCheckbox.setChecked(False) - self.annotContourCheckbox.setChecked(False) - self.annotSegmMasksCheckbox.setChecked(False) - self.drawMothBudLinesCheckbox.setChecked(False) - self.drawNothingCheckbox.setChecked(False) - - # Right - if right: - self.annotIDsCheckboxRight.setChecked(False) - self.annotCcaInfoCheckboxRight.setChecked(False) - self.annotContourCheckboxRight.setChecked(False) - self.annotSegmMasksCheckboxRight.setChecked(False) - self.drawMothBudLinesCheckboxRight.setChecked(False) - self.drawNothingCheckboxRight.setChecked(False) - - def setDisabledAnnotOptions(self, disabled): - # Left - self.annotIDsCheckbox.setDisabled(disabled) - self.annotCcaInfoCheckbox.setDisabled(disabled) - self.annotContourCheckbox.setDisabled(disabled) - # self.annotSegmMasksCheckbox.setDisabled(disabled) - self.drawMothBudLinesCheckbox.setDisabled(disabled) - # self.drawNothingCheckbox.setDisabled(disabled) - - # Right - self.annotIDsCheckboxRight.setDisabled(disabled) - self.annotCcaInfoCheckboxRight.setDisabled(disabled) - self.annotContourCheckboxRight.setDisabled(disabled) - # self.annotSegmMasksCheckboxRight.setDisabled(disabled) - self.drawMothBudLinesCheckboxRight.setDisabled(disabled) - # self.drawNothingCheckboxRight.setDisabled(disabled) - - def drawAnnotCombobox_to_options(self): - self.uncheckAnnotOptions() - - # Left - how = self.drawIDsContComboBox.currentText() - if how.find('IDs') != -1: - self.annotIDsCheckbox.setChecked(True) - if how.find('cell cycle info') != -1: - self.annotCcaInfoCheckbox.setChecked(True) - if how.find('contours') != -1: - self.annotContourCheckbox.setChecked(True) - if how.find('segm. masks') != -1: - self.annotSegmMasksCheckbox.setChecked(True) - if how.find('mother-bud lines') != -1: - self.drawMothBudLinesCheckbox.setChecked(True) - if how.find('nothing') != -1: - self.drawNothingCheckbox.setChecked(True) - - # Right - how = self.annotateRightHowCombobox.currentText() - if how.find('IDs') != -1: - self.annotIDsCheckboxRight.setChecked(True) - if how.find('cell cycle info') != -1: - self.annotCcaInfoCheckboxRight.setChecked(True) - if how.find('contours') != -1: - self.annotContourCheckboxRight.setChecked(True) - if how.find('segm. masks') != -1: - self.annotSegmMasksCheckboxRight.setChecked(True) - if how.find('mother-bud lines') != -1: - self.drawMothBudLinesCheckboxRight.setChecked(True) - if how.find('nothing') != -1: - self.drawNothingCheckboxRight.setChecked(True) - - def setStatusBarLabel(self, log=True): - self.statusbar.clearMessage() - posData = self.data[self.pos_i] - segmentedChannelname = posData.filename[len(posData.basename):] - segmFilename = os.path.basename(posData.segm_npz_path) - segmEndName = segmFilename[len(posData.basename):] - txt = ( - f'{posData.pos_foldername} || ' - f'Basename: {posData.basename} || ' - f'Segmented channel: {segmentedChannelname} || ' - f'Segmentation file name: {segmEndName}' - ) - mode = str(self.modeComboBox.currentText()) - if log: - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - def autoRange(self): - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.ax2.autoRange() - self.ax1.autoRange() - - def resetRange(self): - if self.ax1_viewRange is None: - return - xRange, yRange = self.ax1_viewRange - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.ax2.vb.setRange(xRange=xRange, yRange=yRange) - self.ax1.vb.setRange(xRange=xRange, yRange=yRange) - self.ax1_viewRange = None - self.isRangeReset = True - - def setFramesSnapshotMode(self): - self.measurementsMenu.setDisabled(False) - self.setPermanentGreedyCmapPreferences() - if self.isSnapshot: - self.realTimeTrackingToggle.setDisabled(True) - self.realTimeTrackingToggle.label.setDisabled(True) - try: - self.drawIDsContComboBox.currentIndexChanged.disconnect() - except Exception as e: - pass - - self.imgGrad.rescaleAcrossTimeAction.setDisabled(True) - self.repeatTrackingAction.setDisabled(True) - self.manualTrackingAction.setDisabled(True) - self.logger.info('Setting GUI mode to "Snapshots"...') - self.modeComboBox.clear() - self.modeComboBox.addItems(['Snapshot']) - self.modeComboBox.setDisabled(True) - self.modeMenu.menuAction().setVisible(False) - self.drawIDsContComboBox.clear() - self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) - self.drawIDsContComboBox.setCurrentIndex(1) - self.modeToolBar.setVisible(False) - self.skipToNewIdAction.setVisible(False) - self.skipToNewIdAction.setDisabled(True) - self.modeComboBox.setCurrentText('Snapshot') - self.annotateToolbar.setVisible(True) - self.labelsGrad.showNextFrameAction.setDisabled(True) - self.drawIDsContComboBox.currentIndexChanged.connect( - self.drawIDsContComboBox_cb - ) - self.showTreeInfoCheckbox.hide() - self.rightImageFramesScrollbar.setVisible(False) - self.rightImageFramesScrollbar.setDisabled(True) - if not self.isSegm3D: - self.manualBackgroundAction.setVisible(True) - self.manualBackgroundAction.setDisabled(False) - else: - self.manualBackgroundAction.setVisible(False) - self.manualBackgroundAction.setDisabled(True) - self.manualAnnotPastButton.setDisabled(True) - self.manualAnnotPastButton.action.setDisabled(True) - self.manualAnnotPastButton.setVisible(False) - self.manualAnnotPastButton.action.setVisible(False) - self.copyLostObjButton.setDisabled(True) - self.copyLostObjButton.action.setDisabled(True) - self.copyLostObjButton.setVisible(False) - self.copyLostObjButton.action.setVisible(False) - self.segForLostIDsAction.setVisible(False) - self.segForLostIDsAction.setDisabled(True) - self.delNewObjAction.setVisible(False) - self.delNewObjAction.setDisabled(True) - else: - self.imgGrad.rescaleAcrossTimeAction.setDisabled(False) - self.annotateToolbar.setVisible(False) - self.realTimeTrackingToggle.setDisabled(False) - self.repeatTrackingAction.setDisabled(False) - self.manualTrackingAction.setDisabled(False) - self.modeComboBox.setDisabled(False) - self.modeMenu.menuAction().setVisible(True) - self.skipToNewIdAction.setVisible(True) - self.skipToNewIdAction.setDisabled(False) - try: - self.modeComboBox.activated.disconnect() - self.modeComboBox.sigTextChanged.disconnect() - self.drawIDsContComboBox.currentIndexChanged.disconnect() - except Exception as e: - pass - # traceback.print_exc() - self.modeComboBox.clear() - self.modeComboBox.addItems(self.modeItems) - self.drawIDsContComboBox.clear() - self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) - self.modeComboBox.sigTextChanged.connect(self.changeMode) - self.modeComboBox.activated.connect(self.clearComboBoxFocus) - self.drawIDsContComboBox.currentIndexChanged.connect( - self.drawIDsContComboBox_cb) - self.modeComboBox.setCurrentText('Viewer') - self.showTreeInfoCheckbox.show() - self.manualBackgroundAction.setVisible(False) - self.manualBackgroundAction.setDisabled(True) - self.labelsGrad.showNextFrameAction.setDisabled(False) - self.manualAnnotPastButton.setDisabled(False) - self.manualAnnotPastButton.action.setDisabled(False) - self.manualAnnotPastButton.setVisible(True) - self.manualAnnotPastButton.action.setVisible(True) - self.copyLostObjButton.setDisabled(False) - self.copyLostObjButton.action.setDisabled(False) - self.copyLostObjButton.setVisible(True) - self.copyLostObjButton.action.setVisible(True) - self.segForLostIDsAction.setVisible(True) - self.segForLostIDsAction.setDisabled(False) - self.delNewObjAction.setVisible(True) - self.delNewObjAction.setDisabled(False) - - for ch, overlayItems in self.overlayLayersItems.items(): - lutItem = overlayItems[1] - lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot) - - def checkIfAutoSegm(self): - """ - If there are any frame or position with empty segmentation mask - ask whether automatic segmentation should be turned ON - """ - if self.autoSegmAction.isChecked(): - return - if self.autoSegmDoNotAskAgain: - return - - ask = False - for posData in self.data: - if posData.SizeT > 1: - for lab in posData.segm_data: - if not np.any(lab): - ask = True - txt = 'frames' - break - else: - if not np.any(posData.segm_data): - ask = True - txt = 'positions' - break - - if not ask: - return - - questionTxt = html_utils.paragraph( - f'Some or all loaded {txt} contain empty segmentation masks.

' - 'Do you want to activate automatic segmentation* ' - f'when visiting these {txt}?

' - '* Automatic segmentation can always be turned ON/OFF from the menu
' - ' Edit --> Segmentation --> Enable automatic segmentation

' - f'NOTE: you can automatically segment all {txt} using the
' - ' segmentation module.' - ) - msg = widgets.myMessageBox(wrapText=False) - noButton, yesButton = msg.question( - self, 'Automatic segmentation?', questionTxt, - buttonsTexts=('No', 'Yes') - ) - if msg.clickedButton == yesButton: - self.autoSegmAction.setChecked(True) - else: - self.autoSegmDoNotAskAgain = True - self.autoSegmAction.setChecked(False) - - def init_segmInfo_df(self): - for posData in self.data: - if posData is None: - # posData is None when computing measurements with the utility - # and with timelapse data - continue - posData.init_segmInfo_df() - - def connectScrollbars(self): - self.t_label.show() - self.navigateScrollBar.show() - self.navigateScrollBar.setDisabled(False) - - if self.data[0].SizeZ > 1: - self.enableZstackWidgets(True) - self.zSliceScrollBar.setMaximum(self.data[0].SizeZ-1) - self.zSliceSpinbox.setMaximum(self.data[0].SizeZ) - self.SizeZlabel.setText(f'/{self.data[0].SizeZ}') - try: - self.zSliceScrollBar.actionTriggered.disconnect() - self.zSliceScrollBar.sliderReleased.disconnect() - self.zProjComboBox.currentTextChanged.disconnect() - self.zProjComboBox.activated.disconnect() - self.switchPlaneCombobox.sigPlaneChanged.disconnect() - self.zProjLockViewButton.toggled.disconnect() - except Exception as e: - pass - self.zSliceScrollBar.actionTriggered.connect( - self.zSliceScrollBarActionTriggered - ) - self.zSliceScrollBar.sliderReleased.connect( - self.zSliceScrollBarReleased - ) - self.zProjComboBox.currentTextChanged.connect(self.updateZproj) - self.zProjComboBox.activated.connect(self.clearComboBoxFocus) - self.switchPlaneCombobox.sigPlaneChanged.connect( - self.switchViewedPlane - ) - self.zProjLockViewButton.toggled.connect(self.zProjLockViewToggled) - - posData = self.data[self.pos_i] - if posData.SizeT == 1: - self.t_label.setText('Position n.') - self.navigateScrollBar.setMinimum(1) - self.navigateScrollBar.setMaximum(len(self.data)) - self.navigateScrollBar.setAbsoluteMaximum(len(self.data)) - self.navSpinBox.setMaximum(len(self.data)) - self.navigateScrollBar.connectEvents({ - 'sliderMoved': self.PosScrollBarMoved, - 'sliderReleased': self.PosScrollBarReleased, - 'actionTriggered': self.PosScrollBarAction - }) - else: - self.navigateScrollBar.setMinimum(1) - self.navigateScrollBar.setAbsoluteMaximum(posData.SizeT) - self.rightImageFramesScrollbar.setMinimum(1) - self.rightImageFramesScrollbar.setMaximum(posData.SizeT) - if posData.last_tracked_i is not None: - self.navigateScrollBar.setMaximum(posData.last_tracked_i+1) - self.navSpinBox.setMaximum(posData.last_tracked_i+1) - self.t_label.setText('Frame n.') - self.navigateScrollBar.connectEvents({ - 'sliderMoved': self.framesScrollBarMoved, - 'sliderReleased': self.framesScrollBarReleased, - 'actionTriggered': self.framesScrollBarActionTriggered - }) - self.rightImageFramesScrollbar.connectValueChanged( - self.rightImageFramesScrollbarValueChanged - ) - - def zSliceScrollBarActionTriggered(self, action): - singleMove = ( - action == SliderSingleStepAdd - or action == SliderSingleStepSub - or action == SliderPageStepAdd - or action == SliderPageStepSub - ) - if singleMove: - self.update_z_slice(self.zSliceScrollBar.sliderPosition()) - elif action == SliderMove: - if self.zSliceScrollBarStartedMoving and self.isSegm3D: - self.clearAx1Items(onlyHideText=True) - self.clearAx2Items(onlyHideText=True) - posData = self.data[self.pos_i] - idx = (posData.filename, posData.frame_i) - z = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == 'z': - posData.segmInfo_df.at[idx, 'z_slice_used_gui'] = z - self.zSliceSpinbox.setValueNoEmit(z+1) - img = self._getImageupdateAllImages(None) - self.img1.setCurrentZsliceIndex(z) - self.img1.setImage( - img, next_frame_image=self.nextFrameImage(), - scrollbar_value=posData.frame_i+2 - ) - try: - self.setOverlayImages() - except Exception as err: - pass - - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.img2.setImage(posData.lab, z=z, autoLevels=False) - self.updateViewerWindow() - self.setTextAnnotZsliceScrolling() - self.setGraphicalAnnotZsliceScrolling() - self.setOverlayLabelsItems() - self.drawPointsLayers(computePointsLayers=False) - self.zSliceScrollBarStartedMoving = False - self.highlightSearchedID(self.highlightedID, force=True) - - def zSliceScrollBarReleased(self): - self.clearTempBrushImage() - self.zSliceScrollBarStartedMoving = True - self.update_z_slice(self.zSliceScrollBar.sliderPosition()) - - def setSwitchViewedPlaneDisabled(self, disabled): - posData = self.data[self.pos_i] - if posData.SizeZ == 1: - return - - self.switchPlaneCombobox.setDisabled(disabled) - if disabled: - self.switchPlaneCombobox.setCurrentIndex(0) - - def _setViewRangeSwitchPlane(self, previousPlane): - posData = self.data[self.pos_i] - SizeZ = posData.SizeZ - SizeY, SizeX = self.img1.image.shape[:2] - currentPlane = self.switchPlaneCombobox.plane() - if previousPlane == 'xy': - if currentPlane == 'zy': - self.ax1.setRange(xRange=self.yRangePrev) - unusedRange = np.clip(self.xRangePrev, 0, SizeX) - elif currentPlane == 'zx': - self.ax1.setRange(xRange=self.xRangePrev) - unusedRange = np.clip(self.yRangePrev, 0, SizeY) - elif previousPlane == 'zy': - if currentPlane == 'xy': - self.ax1.setRange(yRange=self.xRangePrev) - unusedRange = np.clip(self.yRangePrev, 0, SizeZ) - elif currentPlane == 'zx': - self.ax1.setRange(yRange=self.yRangePrev) - unusedRange = np.clip(self.xRangePrev, 0, SizeY) - elif previousPlane == 'zx': - if currentPlane == 'xy': - self.ax1.setRange(xRange=self.xRangePrev) - unusedRange = np.clip(self.yRangePrev, 0, SizeZ) - elif currentPlane == 'zy': - self.ax1.setRange(yRange=self.yRangePrev) - unusedRange = np.clip(self.xRangePrev, 0, SizeX) - - sliceValue = round((unusedRange[0] + unusedRange[1])/2) - self.zSliceScrollBar.setSliderPosition(sliceValue) - self.update_z_slice(self.zSliceScrollBar.sliderPosition()) - - def setViewRangeSwitchPlane(self, previousPlane): - self.autoRange() - QTimer.singleShot( - 100, partial(self._setViewRangeSwitchPlane, previousPlane) - ) - - def switchViewedPlane(self, previousPlane, currentPlane): - posData = self.data[self.pos_i] - self.xRangePrev, self.yRangePrev = self.ax1.viewRange() - self.zSlicePrev = self.zSliceScrollBar.sliderPosition() - - self.zProjComboBox.setCurrentText('single z-slice') - depthAxes = self.switchPlaneCombobox.depthAxes() - self.onEscape() - self.initDelRoiLab() - if depthAxes != 'z': - # Disable projections on plane that is not xy - self.zProjComboBox.setCurrentText('single z-slice') - self.zProjComboBox.setDisabled(True) - - # Clear annotations - self.clearAllItems() - self.setHighlightID(False) - - # Disable annotations on a plane that is not yz - self.setDrawNothingAnnotations() - self.setDisabledAnnotCheckBoxesLeft(True) - self.setDisabledAnnotCheckBoxesRight(True) - self.setEnabledAnnotCheckBoxesLeftZdepthAxes() - self.overlayButtonPrevState = self.overlayButton.isChecked() - self.overlayButton.setChecked(False) - self.overlayButton.setDisabled(True) - else: - self.zProjComboBox.setDisabled(False) - self.restoreAnnotationsOptions() - self.setDisabledAnnotCheckBoxesLeft(False) - self.setDisabledAnnotCheckBoxesRight(False) - self.overlayButton.setDisabled(False) - if self.overlayButtonPrevState: - self.overlayButton.setChecked(self.overlayButtonPrevState) - self.updateZsliceScrollbar(posData.frame_i) - - SizeY, SizeX = posData.img_data[posData.frame_i].shape[-2:] - - if depthAxes != 'z' and self.isSnapshot: - # Disable editing when the plane is not xy - self.disableEditingViewPlaneNotXY() - elif self.isSnapshot: - # Re-enable editing in snapshot mode when the plane is xy - self.setEnabledSnapshotMode() - - if depthAxes == 'z': - maxSliceNum = posData.SizeZ - elif depthAxes == 'y': - maxSliceNum = SizeY - else: - maxSliceNum = SizeX - - maxSliceText = f'/{maxSliceNum}' - self.SizeZlabel.setText(maxSliceText) - self.zSliceCheckbox.setText(f'{depthAxes}-slice') - self.zSliceScrollBar.setMaximum(maxSliceNum-1) - self.zSliceSpinbox.setMaximum(maxSliceNum) - - self.initContoursImage() - self.updateAllImages() - QTimer.singleShot( - 200, partial(self.setViewRangeSwitchPlane, previousPlane) - ) - - def onZsliceSpinboxValueChange(self, value): - self.zSliceScrollBar.setSliderPosition(value-1) - - def update_z_slice(self, z): - posData = self.data[self.pos_i] - if self.switchPlaneCombobox.depthAxes() == 'z': - if self.zProjLockViewButton.isChecked(): - idx = [ - (posData.filename, frame_i) - for frame_i in range(posData.SizeT) - ] - else: - idx = [ - (posData.filename, frame_i) - for frame_i in range(posData.frame_i, posData.SizeT) - ] - posData.segmInfo_df.loc[idx, 'z_slice_used_gui'] = z - - self.updatePreprocessPreview() - self.updateCombineChannelsPreview() - self.highlightedID = self.getHighlightedID() - self.updateAllImages( - computePointsLayers=False, - computeContours=False, - updateLookuptable=True - ) - self.updateItemsMousePos() - if self.isSegm3D: - self.updateObjectCounts() - - def updateOverlayZslice(self, z): - self.setOverlayImages() - - def updateOverlayZproj(self, how): - if how.find('max') != -1 or how == 'same as above': - self.overlay_z_label.setDisabled(True) - self.zSliceOverlay_SB.setDisabled(True) - else: - self.overlay_z_label.setDisabled(False) - self.zSliceOverlay_SB.setDisabled(False) - self.setOverlayImages() - - def updateZproj(self, how): - for p, posData in enumerate(self.data[self.pos_i:]): - if self.zProjLockViewButton.isChecked(): - idx = [ - (posData.filename, frame_i) - for frame_i in range(posData.SizeT) - ] - else: - idx = [(posData.filename, posData.frame_i)] - posData.segmInfo_df.loc[idx, 'which_z_proj_gui'] = how - posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) - - posData = self.data[self.pos_i] - if how == 'single z-slice': - self.zSliceScrollBar.setDisabled(False) - self.zSliceSpinbox.setDisabled(False) - self.zSliceCheckbox.setDisabled(False) - self.setZprojDisabled(False) - self.update_z_slice(self.zSliceScrollBar.sliderPosition()) - else: - self.zSliceScrollBar.setDisabled(True) - self.zSliceSpinbox.setDisabled(True) - self.zSliceCheckbox.setDisabled(True) - self.setZprojDisabled(self.isSegm3D) - self.updateAllImages() - - def setZprojDisabled(self, disabled, storePrevState=False): - self.combineChannelsAction.setDisabled(disabled) - for action in self.editToolBar.actions(): - button = self.editToolBar.widgetForAction(action) - if button == self.eraserButton: - continue - - if button in self.toolsActiveInProj3Dsegm: - continue - - try: - tooltip = button.toolTip() - prefix = 'WARNING: Disabled due to projection mode\n\n' - if disabled: - if not tooltip.startswith(prefix): - button.setToolTip(prefix + tooltip) - else: - if tooltip.startswith(prefix): - button.setToolTip(tooltip[len(prefix):]) - except: - pass - action.setDisabled(disabled) - try: - button.setChecked(False) - except Exception as err: - pass - - def clearAx2Items(self, onlyHideText=False): - self.ax2_binnedIDs_ScatterPlot.clear() - self.ax2_ripIDs_ScatterPlot.clear() - self.ax2_contoursImageItem.clear() - self.ax2_lostObjImageItem.clear() - self.ax2_lostTrackedObjImageItem.clear() - self.textAnnot[1].clear() - self.ax2_newMothBudLinesItem.setData([], []) - self.ax2_oldMothBudLinesItem.setData([], []) - self.ax2_lostObjScatterItem.setData([], []) - - def clearAx1Items(self, onlyHideText=False): - self.ax1_binnedIDs_ScatterPlot.clear() - self.ax1_ripIDs_ScatterPlot.clear() - self.labelsLayerImg1.clear() - self.labelsLayerRightImg.clear() - self.keepIDsTempLayerLeft.clear() - self.keepIDsTempLayerRight.clear() - self.highLightIDLayerImg1.clear() - self.highLightIDLayerRightImage.clear() - self.searchedIDitemLeft.clear() - self.searchedIDitemRight.clear() - self.ax1_contoursImageItem.clear() - self.ax1_lostObjImageItem.clear() - self.ax1_lostTrackedObjImageItem.clear() - self.textAnnot[0].clear() - self.ax1_newMothBudLinesItem.setData([], []) - self.ax1_oldMothBudLinesItem.setData([], []) - self.ax1_lostObjScatterItem.setData([], []) - self.ax1_lostTrackedScatterItem.setData([], []) - self.ccaFailedScatterItem.setData([], []) - self.yellowContourScatterItem.setData([], []) - - self.clearPointsLayers() - - self.clearOverlayLabelsItems() - self.clearManualBackgroundAnnotations() - self.clearCustomAnnot() - - def clearPointsLayers(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - try: - action.scatterItem.clear() - except Exception as e: - continue - - def clearOverlayLabelsItems(self): - for segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): - items = self.overlayLabelsItems[segmEndname] - imageItem, contoursItem, gradItem = items - imageItem.clear() - contoursItem.clear() - - def clearAllItems(self): - self.clearAx1Items() - self.clearAx2Items() - - def clearCustomAnnot(self): - for button in self.customAnnotDict.keys(): - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] - scatterPlotItem.setData([], []) - - def clearCurvItems(self, removeItems=True): - try: - posData = self.data[self.pos_i] - curvItems = zip(posData.curvPlotItems, - posData.curvAnchorsItems, - posData.curvHoverItems) - for plotItem, curvAnchors, hoverItem in curvItems: - plotItem.setData([], []) - curvAnchors.setData([], []) - hoverItem.setData([], []) - if removeItems: - self.ax1.removeItem(plotItem) - self.ax1.removeItem(curvAnchors) - self.ax1.removeItem(hoverItem) - - if removeItems: - posData.curvPlotItems = [] - posData.curvAnchorsItems = [] - posData.curvHoverItems = [] - except AttributeError: - # traceback.print_exc() - pass - - # @exec_time - def curvToolSplineToObj(self, xxA=None, yyA=None, isRightClick=False): - posData = self.data[self.pos_i] - # Store undo state before modifying stuff - self.storeUndoRedoStates(False, storeOnlyZoom=True) - - if isRightClick: - xxS, yyS = self.curvPlotItem.getData() - if xxS is None: - self.setUncheckedAllButtons() - return - self.smoothAutoContWithSpline() - - xxS, yyS = self.getClosedSplineCoords() - - if self.autoIDcheckbox.isChecked(): - self.setBrushID() - curvToolID = posData.brushID - else: - curvToolID = self.editIDspinbox.value() - posData.brushID = curvToolID - - if curvToolID <= 0: - self.setBrushID() - curvToolID = posData.brushID - - lab2D = self.get_2Dlab(posData.lab).copy() - newIDMask = np.zeros(lab2D.shape, bool) - rr, cc = skimage.draw.polygon(yyS, xxS, shape=lab2D.shape) - newIDMask[rr, cc] = True - newIDMask[lab2D!=0] = False - lab2D[newIDMask] = curvToolID - self.set_2Dlab(lab2D) - self.currentLab2D = lab2D - - def addFluoChNameContextMenuAction(self, ch_name): - posData = self.data[self.pos_i] - allTexts = [ - action.text() for action in self.chNamesQActionGroup.actions() - ] - if ch_name not in allTexts: - action = QAction(self) - action.setText(ch_name) - action.setCheckable(True) - self.chNamesQActionGroup.addAction(action) - action.setChecked(True) - self.fluoDataChNameActions.append(action) - - def computeSegm(self, force=False): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer' or mode == 'Cell cycle analysis': - return - - if np.any(posData.lab) and not force: - # Do not compute segm if there is already a mask - return - - if not self.autoSegmAction.isChecked(): - return - - self.repeatSegm(model_name=self.segmModelName) - - def initImgCmap(self): - if not 'img_cmap' in self.df_settings.index: - self.df_settings.at['img_cmap', 'value'] = 'grey' - self.imgCmapName = self.df_settings.at['img_cmap', 'value'] - self.imgCmap = self.imgGrad.cmaps[self.imgCmapName] - if self.imgCmapName != 'grey': - # To ensure mapping to colors we need to normalize image - self.normalizeByMaxAction.setChecked(True) - - def initMetricsToSave(self, posData): - self._measurements_kernel._init_metrics_to_save(posData) - - def initMetrics(self): - self.logger.info('Initializing measurements...') - posData = self.data[self.pos_i] - self._measurements_kernel = cli.ComputeMeasurementsKernel( - self.logger, self.log_path, False - ) - self._measurements_kernel.init_args( - posData.chNames, posData.getSegmEndname() - ) - self._measurements_kernel._init_metrics(posData, self.isSegm3D) - - def initPosAttr(self): - exp_path = self.data[self.pos_i].exp_path - pos_foldernames = myutils.get_pos_foldernames(exp_path) - if len(pos_foldernames) == 1: - self.loadPosAction.setDisabled(True) - else: - self.loadPosAction.setDisabled(False) - - for p, posData in enumerate(self.data): - self.pos_i = p - posData.curvPlotItems = [] - posData.curvAnchorsItems = [] - posData.curvHoverItems = [] - posData.trackedLostIDs = set() - - posData.HDDmaxID = np.max(posData.segm_data) - - # Decision on what to do with changes to future frames attr - posData.doNotShowAgain_EditID = False - posData.UndoFutFrames_EditID = False - posData.applyFutFrames_EditID = False - - posData.doNotShowAgain_RipID = False - posData.UndoFutFrames_RipID = False - posData.applyFutFrames_RipID = False - - posData.doNotShowAgain_DelID = False - posData.UndoFutFrames_DelID = False - posData.applyFutFrames_DelID = False - - posData.doNotShowAgain_keepID = False - posData.UndoFutFrames_keepID = False - posData.applyFutFrames_keepID = False - - posData.doNotShowAgainAssignNewID = False - posData.UndoFutFramesAssignNewID = False - posData.applyFutFramesAssignNewID = False - - posData.includeUnvisitedInfo = { - 'Delete ID': False, 'Edit ID': False, 'Keep ID': False - } - - posData.loadTrackedLostCentroids() - posData.acdcTracker2stepsAnnotInfo = {} - - posData.doNotShowAgain_BinID = False - posData.UndoFutFrames_BinID = False - posData.applyFutFrames_BinID = False - - posData.disableAutoActivateViewerWindow = False - posData.new_IDs = [] - posData.lost_IDs = [] - posData.multiBud_mothIDs = [2] - posData.UndoRedoStates = [[] for _ in range(posData.SizeT)] - posData.UndoRedoCcaStates = [[] for _ in range(posData.SizeT)] - - posData.ol_data_dict = {} - posData.ol_data = None - - posData.ol_labels_data = None - - missing_frames = posData.SizeT - len(posData.allData_li) - if missing_frames > 0: - posData.allData_li.extend([None] * missing_frames) - for i in range(posData.SizeT): - if posData.allData_li[i] is None: - posData.allData_li[i] = ( - myutils.get_empty_stored_data_dict() - ) - - posData.lutLevels = {channel: {} for channel in self.ch_names} - - posData.ccaStatus_whenEmerged = {} - - posData.frame_i = 0 - posData.brushID = 0 - posData.binnedIDs = set() - posData.ripIDs = set() - posData.cca_df = None - if posData.last_tracked_i is not None: - last_tracked_num = posData.last_tracked_i+1 - # Load previous session data - # Keep track of which ROIs have already been added - # in previous frame - delROIshapes = [[] for _ in range(posData.SizeT)] - for i in range(last_tracked_num): - posData.frame_i = i - self.get_data(debug=True) - self.store_data( - enforce=True, autosave=False, store_cca_df_copy=True - ) - - # Ask whether to resume from last frame - if last_tracked_num>1: - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Cell-ACDC detected a previous session ended ' - f'at frame {last_tracked_num}.

' - f'Do you want to resume from frame ' - f'{last_tracked_num}?' - ) - noButton, yesButton = msg.question( - self, 'Start from last session?', txt, - buttonsTexts=(' No ', 'Yes') - ) - self.AutoPilotProfile.storeClickMessageBox( - 'Start from last session?', msg.clickedButton.text() - ) - if msg.clickedButton == yesButton: - posData.frame_i = posData.last_tracked_i - self.lastFrameRanOnFirstVisitTools = posData.frame_i - else: - posData.frame_i = 0 - - posData.img_data_min_max = ( - posData.img_data.min(), posData.img_data.max() - ) - - # Back to first position - self.pos_i = 0 - self.get_data(debug=False) - self.store_data(autosave=False) - # self.updateAllImages() - - # Link Y and X axis of both plots to scroll zoom and pan together - self.ax2.vb.setYLink(self.ax1.vb) - self.ax2.vb.setXLink(self.ax1.vb) - - self.setAllIDs() - - def navigateSpinboxValueChanged(self, value): - self.navigateScrollBar.setSliderPosition(value) - if self.isSnapshot: - self.PosScrollBarMoved(value) - else: - self.navigateScrollBarStartedMoving = True - self.framesScrollBarMoved(value) - - def navigateSpinboxEditingFinished(self): - if self.isSnapshot: - self.PosScrollBarReleased() - else: - self.framesScrollBarReleased() - - def PosScrollBarAction(self, action): - if action == SliderSingleStepAdd: - self.next_cb() - elif action == SliderSingleStepSub: - self.prev_cb() - elif action == SliderPageStepAdd: - self.PosScrollBarReleased() - elif action == SliderPageStepSub: - self.PosScrollBarReleased() - - def PosScrollBarMoved(self, pos_n): - if self.navigateScrollBarStartedMoving: - self.store_data() - - self.pos_i = pos_n-1 - self.updateFramePosLabel() - proceed_cca, never_visited = self.get_data() - self.updateAllImages() - self.setStatusBarLabel() - self.navigateScrollBarStartedMoving = False - - def PosScrollBarReleased(self): - self.navigateScrollBarStartedMoving = True - if self.pos_i == self.navigateScrollBar.sliderPosition()-1: - # Slider released without changing value --> do nothing - return - - self.pos_i = self.navigateScrollBar.sliderPosition()-1 - self.updateFramePosLabel() - self.updatePos() - - def resetNavigateFramesScrollbar(self, frame_i=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - self.navigateScrollBar.setValueNoSignal(frame_i+1) - - def framesScrollBarActionTriggered(self, action): - if action == SliderSingleStepAdd: - # Clicking on dialogs triggered by next_cb might trigger - # pressEvent of navigateQScrollBar, avoid that - self.navigateScrollBar.disableCustomPressEvent() - self.next_cb() - QTimer.singleShot(100, self.navigateScrollBar.enableCustomPressEvent) - elif action == SliderSingleStepSub: - self.prev_cb() - elif action == SliderPageStepAdd: - self.framesScrollBarReleased(do_store_data=True) - elif action == SliderPageStepSub: - self.framesScrollBarReleased(do_store_data=True) - - def framesScrollBarMoved(self, frame_n): - if self.navigateScrollBarStartedMoving: - mode = str(self.modeComboBox.currentText()) - if mode != 'Viewer': - self.store_data(debug=False) - - posData = self.data[self.pos_i] - posData.frame_i = frame_n-1 - if posData.allData_li[posData.frame_i]['labels'] is None: - if posData.frame_i < len(posData.segm_data): - posData.lab = posData.segm_data[posData.frame_i] - else: - posData.lab = np.zeros_like(posData.segm_data[0]) - else: - posData.lab = posData.allData_li[posData.frame_i]['labels'] - - self.setImageImg1() - if self.overlayButton.isChecked(): - self.setOverlayImages() - - if self.navigateScrollBarStartedMoving: - self.clearAllItems() - - self.navSpinBox.setValueNoEmit(posData.frame_i+1) - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.img2.setImage(posData.lab, z=self.z_lab(), autoLevels=False) - self.updateLookuptable() - self.updateFramePosLabel() - self.updateViewerWindow() - self.updateTimestampFrame() - self.updateHighlightedAxis() - self.navigateScrollBarStartedMoving = False - - def framesScrollBarReleased(self, do_store_data=False): - posData = self.data[self.pos_i] - if posData.frame_i == self.navigateScrollBar.sliderPosition()-1: - # Slider released without changing value --> do nothing - return - - mode = str(self.modeComboBox.currentText()) - if mode != 'Viewer' and do_store_data: - self.store_data(debug=False) - - self.navigateScrollBarStartedMoving = True - posData.frame_i = self.navigateScrollBar.sliderPosition()-1 - self.updateFramePosLabel() - proceed_cca, never_visited = self.get_data() - self.updateAllImages() - - def unstore_data(self): - posData = self.data[self.pos_i] - posData.allData_li[posData.frame_i] = myutils.get_empty_stored_data_dict() - - def getStoredSegmData(self): - posData = self.data[self.pos_i] - segm_data = [] - for data_frame_i in posData.allData_li: - lab = data_frame_i['labels'] - if lab is None: - break - segm_data.append(lab) - return np.array(segm_data) - - def trackNewIDtoNewIDsFutureFrame(self, newID, newIDmask): - posData = self.data[self.pos_i] - try: - nextLab = posData.allData_li[posData.frame_i+1]['labels'] - except IndexError: - # This is last frame --> there are no future frames - return - - if nextLab is None: - return - - newID_lab = np.zeros_like(posData.lab) - newID_lab[newIDmask] = newID - newLab_rp = [posData.rp[posData.IDs_idxs[newID]]] - newLab_IDs = [newID] - nextRp = posData.allData_li[posData.frame_i+1]['regionprops'] - - tracked_lab = self.trackFrame( - nextLab, nextRp, newID_lab, newLab_rp, newLab_IDs, - assign_unique_new_IDs=False - ) - trackedID = tracked_lab[newID_lab>0][0] - if trackedID == newID: - # Object does not exist in future frame --> do not track - return - - if posData.IDs_idxs.get(trackedID) is not None: - # Tracked ID already exists --> do not track to avoid merging - return - - return trackedID - - def store_manual_annot_data( - self, posData=None, data_frame_i=None - ): - if posData is None: - posData = self.data[self.pos_i] - - if data_frame_i is None: - data_frame_i = posData.allData_li[posData.frame_i] - - if not self.isSegm3D: - lab = [posData.lab] - else: - lab = posData.lab - - for z, lab_2D in enumerate(lab): - data_frame_i['manually_edited_lab']['lab'][z] = lab_2D - - # data_frame_i['manually_edited_lab']['zoom_slice'] = zoom_slice - - @exception_handler - def store_data( - self, pos_i=None, enforce=True, debug=False, mainThread=True, - autosave=True, store_cca_df_copy=False - ): - pos_i = self.pos_i if pos_i is None else pos_i - posData = self.data[pos_i] - if posData.frame_i < 0: - # In some cases we set frame_i = -1 and then call next_frame - # to visualize frame 0. In that case we don't store data - # for frame_i = -1 - return - - mode = str(self.modeComboBox.currentText()) - - if mode == 'Viewer' and not enforce: - return - - # if not mainThread: - # self.lin_tree_ask_changes() - - allData_li = posData.allData_li[posData.frame_i] - allData_li['regionprops'] = posData.rp.copy() - allData_li['labels'] = posData.lab.copy() - allData_li['IDs'] = posData.IDs.copy() - allData_li['manualBackgroundLab'] = ( - posData.manualBackgroundLab - ) - allData_li['IDs_idxs'] = ( - posData.IDs_idxs.copy() - ) - if self.manualAnnotPastButton.isChecked(): - self.store_manual_annot_data( - posData=posData, data_frame_i=allData_li - ) - - self.store_zslices_rp() - - # Store dynamic metadata - is_cell_dead_li = [False]*len(posData.rp) - is_cell_excluded_li = [False]*len(posData.rp) - IDs = [0]*len(posData.rp) - xx_centroid = [0]*len(posData.rp) - yy_centroid = [0]*len(posData.rp) - if self.isSegm3D: - zz_centroid = [0]*len(posData.rp) - areManuallyEdited = [0]*len(posData.rp) - editedNewIDs = [vals[2] for vals in posData.editID_info] - for i, obj in enumerate(posData.rp): - is_cell_dead_li[i] = obj.dead - is_cell_excluded_li[i] = obj.excluded - IDs[i] = obj.label - try: - xx_centroid[i] = int(self.getObjCentroid(obj.centroid)[1]) - yy_centroid[i] = int(self.getObjCentroid(obj.centroid)[0]) - except Exception as err: - printl(obj, obj.centroid, obj.label, posData.frame_i) - if self.isSegm3D: - zz_centroid[i] = int(obj.centroid[0]) - if obj.label in editedNewIDs: - areManuallyEdited[i] = 1 - - posData.STOREDmaxID = max(IDs, default=0) - - acdc_df = allData_li['acdc_df'] - if acdc_df is None: - allData_li['acdc_df'] = pd.DataFrame( - { - 'Cell_ID': IDs, - 'is_cell_dead': is_cell_dead_li, - 'is_cell_excluded': is_cell_excluded_li, - 'x_centroid': xx_centroid, - 'y_centroid': yy_centroid, - 'was_manually_edited': areManuallyEdited - } - ).set_index('Cell_ID') - - if self.isSegm3D: - allData_li['acdc_df']['z_centroid'] = ( - zz_centroid - ) - else: - # Filter or add IDs that were not stored yet - acdc_df = acdc_df.drop(columns=['time_seconds'], errors='ignore') - acdc_df = acdc_df.reindex(IDs, fill_value=0) - acdc_df['is_cell_dead'] = is_cell_dead_li - acdc_df['is_cell_excluded'] = is_cell_excluded_li - acdc_df['x_centroid'] = xx_centroid - acdc_df['y_centroid'] = yy_centroid - if self.isSegm3D: - acdc_df['z_centroid'] = zz_centroid - acdc_df['was_manually_edited'] = areManuallyEdited - allData_li['acdc_df'] = acdc_df - - if mainThread: - self.pointsLayerDataToDf(posData) - - self.store_cca_df( - pos_i=pos_i, mainThread=mainThread, autosave=autosave, - store_cca_df_copy=store_cca_df_copy - ) - - def nearest_point_2Dyx(self, points, all_others): - """ - Given 2D array of [y, x] coordinates points and all_others return the - [y, x] coordinates of the two points (one from points and one from all_others) - that have the absolute minimum distance - """ - # Compute 3D array where each ith row of each kth page is the element-wise - # difference between kth row of points and ith row in all_others array. - # (i.e. diff[k,i] = points[k] - all_others[i]) - diff = points[:, np.newaxis] - all_others - # Compute 2D array of distances where - # dist[i, j] = euclidean dist (points[i],all_others[j]) - dist = np.linalg.norm(diff, axis=2) - # Compute i, j indexes of the absolute minimum distance - i, j = np.unravel_index(dist.argmin(), dist.shape) - nearest_point = all_others[j] - point = points[i] - min_dist = np.min(dist) - return min_dist, nearest_point - - def isCurrentFrameCcaVisited(self): - posData = self.data[self.pos_i] - curr_df = posData.allData_li[posData.frame_i]['acdc_df'] - return curr_df is not None and 'cell_cycle_stage' in curr_df.columns - - def warnScellsGone(self, ScellsIDsGone, frame_i): - msg = widgets.myMessageBox() - text = html_utils.paragraph(f""" - In the next frame the followning cells' IDs in S/G2/M - (highlighted with a yellow contour) will disappear:

- {ScellsIDsGone}

- If the cell does not exist you might have deleted it at some point. - If that's the case, then try to go to some previous frames and reset - the cell cycle annotations there (button on the top toolbar).

- These cells are either buds or mother whose related IDs will not - disappear. This is likely due to cell division happening in - previous frame and the divided bud or mother will be - washed away.

- If you decide to continue these cells will be automatically - annotated as divided at frame number {frame_i}.

- Do you want to continue? - """) - _, yesButton, noButton = msg.warning( - self, 'Cells in "S/G2/M" disappeared!', text, - buttonsTexts=('Cancel', 'Yes', 'No') - ) - return msg.clickedButton == yesButton - - def checkScellsGone(self): - """Check if there are cells in S phase whose relative disappear in - current frame. Allow user to choose between automatically assign - division to these cells or cancel and not visit the frame. - - Returns - ------- - bool - False if there are no cells disappeared or the user decided - to accept automatic division. - """ - automaticallyDividedIDs = [] - - mode = str(self.modeComboBox.currentText()) - if mode.find('Cell cycle') == -1: - # No cell cycle analysis mode --> do nothing - return False, automaticallyDividedIDs - - posData = self.data[self.pos_i] - - if posData.allData_li[posData.frame_i]['labels'] is None: - # Frame never visited/checked in segm mode --> autoCca_df will raise - # a critical message - return False, automaticallyDividedIDs - - # Check if there are S cells that either only mother or only - # bud disappeared and automatically assign division to it - # or abort visiting this frame - prev_acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_cca_df = prev_acdc_df[self.cca_df_colnames].copy() - - ScellsIDsGone = [] - for ccSeries in prev_cca_df.itertuples(): - ID = ccSeries.Index - ccs = ccSeries.cell_cycle_stage - if ccs != 'S': - continue - - relID = ccSeries.relative_ID - if relID == -1: - continue - - # Check is relID is gone while ID stays - if relID not in posData.IDs and ID in posData.IDs: - ScellsIDsGone.append(relID) - - if not ScellsIDsGone: - # No cells in S that disappears --> do nothing - return False, automaticallyDividedIDs - - self.highlightNewIDs_ccaFailed(ScellsIDsGone, rp=prev_rp) - proceed = self.warnScellsGone(ScellsIDsGone, posData.frame_i) - self.clearLostObjContoursItems() - - if not proceed: - return True, automaticallyDividedIDs - - for IDgone in ScellsIDsGone: - relID = prev_cca_df.at[IDgone, 'relative_ID'] - self.annotateDisappearedBeforeDivision(relID, IDgone, prev_cca_df) - self.annotateDivision( - prev_cca_df, IDgone, relID, frame_i=posData.frame_i-1 - ) - self.annotateDivisionCurrentFrameRelativeIDgone(relID) - automaticallyDividedIDs.append(relID) - - self.store_cca_df(frame_i=posData.frame_i-1, cca_df=prev_cca_df) - - return False, automaticallyDividedIDs - - def annotateDivisionCurrentFrameRelativeIDgone(self, IDwhoseRelativeIsGone): - posData = self.data[self.pos_i] - if posData.cca_df is None: - return - ID = IDwhoseRelativeIsGone - posData.cca_df.at[ID, 'generation_num'] += 1 - posData.cca_df.at[ID, 'division_frame_i'] = posData.frame_i-1 - posData.cca_df.at[ID, 'relationship'] = 'mother' - - def annotateDisappearedBeforeDivision( - self, relID, IDgone, cca_df, frame_i=None - ): - posData = self.data[self.pos_i] - gen_num = cca_df.at[relID, 'generation_num'] - if frame_i is None: - frame_i = posData.frame_i - - for past_frame_i in range(frame_i-1, -1, -1): - past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True) - if past_cca_df is None: - return - - try: - if past_cca_df.at[relID, 'generation_num'] != gen_num: - # ID is a mother and the cell cycle is finished here - return - except Exception as err: - # Bud stops existing --> stop process - return - - past_cca_df.at[IDgone, 'disappears_before_division'] = 1 - past_cca_df.at[relID, 'daughter_disappears_before_division'] = 1 - - self.store_cca_df( - cca_df=past_cca_df, frame_i=past_frame_i, autosave=False - ) - - @exception_handler - def attempt_auto_cca(self, enforceAll=False): - mode = str(self.modeComboBox.currentText()) - posData = self.data[self.pos_i] - - if mode == 'Cell cycle analysis': - notEnoughG1Cells, proceed = self.autoCca_df( - enforceAll=enforceAll - ) - if not proceed: - return notEnoughG1Cells, proceed - - # mode = str(self.modeComboBox.currentText()) - if posData.cca_df is None: # ??? - notEnoughG1Cells = False - proceed = True - return notEnoughG1Cells, proceed - if posData.cca_df.isna().any(axis=None): - raise ValueError('Cell cycle analysis table contains NaNs') - # self.checkMultiBudMoth() - proceed = self.checkMothersExcludedOrDead() - return notEnoughG1Cells, proceed - - elif mode == 'Normal division: Lineage tree': - self.autoLinTree_df() - notEnoughG1Cells = False - proceed = True - return notEnoughG1Cells, proceed - - else: - notEnoughG1Cells = False - proceed = True - return notEnoughG1Cells, proceed - - - - def highlightIDs(self, IDs, pen): - pass - - def warnFrameNeverVisitedSegmMode(self): - msg = widgets.myMessageBox() - warn_cca = msg.critical( - self, 'Next frame NEVER visited', - 'Next frame was never visited in "Segmentation and Tracking"' - 'mode.\n You cannot perform cell cycle analysis on frames' - 'where segmentation and/or tracking errors were not' - 'checked/corrected.\n\n' - 'Switch to "Segmentation and Tracking" mode ' - 'and check/correct next frame,\n' - 'before attempting cell cycle analysis again', - ) - return False - - def checkCcaPastFramesNewIDs(self): - posData = self.data[self.pos_i] - if not posData.new_IDs: - return - - found_cca_df_IDs = [] - for frame_i in range(posData.frame_i-2, -1, -1): - acdc_df = posData.allData_li[frame_i]['acdc_df'] - cca_df_i = acdc_df[self.cca_df_colnames] - intersect_idx = cca_df_i.index.intersection(posData.new_IDs) - cca_df_i = cca_df_i.loc[intersect_idx] - if cca_df_i.empty: - continue - found_cca_df_IDs.append(cca_df_i) - - # Remove IDs found in past frames from new_IDs list - newIDs = np.array(posData.new_IDs, dtype=np.uint32) - mask_index = np.in1d(newIDs, cca_df_i.index) - posData.new_IDs = list(newIDs[~mask_index]) - if not posData.new_IDs: - return found_cca_df_IDs - return found_cca_df_IDs - - def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): - self.logger.info( - 'Initialising cell cycle annotations of missing past frames...' - ) - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - - annotated_cca_dfs = [] - for frame_i in range(last_cca_frame_i+1): - acdc_df = posData.allData_li[frame_i]['acdc_df'] - if 'cell_cycle_stage' in acdc_df.columns: - continue - - acdc_df[self.cca_df_colnames] = '' - - annotated_cca_dfs = [ - posData.allData_li[i]['acdc_df'][self.cca_df_colnames] - for i in range(last_cca_frame_i+1) - ] - keys = range(last_cca_frame_i+1) - names = ['frame_i', 'Cell_ID'] - annotated_cca_df = ( - pd.concat(annotated_cca_dfs, keys=keys, names=names) - .reset_index() - .set_index(['Cell_ID', 'frame_i']) - .sort_index() - ) - - last_annotated_cca_df = annotated_cca_df.groupby(level=0).last() - cca_df_colnames = self.cca_df_colnames - pbar = tqdm(total=current_frame_i-last_cca_frame_i+1, ncols=100) - for frame_i in range(last_cca_frame_i, current_frame_i+1): - posData.frame_i = frame_i - self.get_data() - cca_df = self.getBaseCca_df() - - idx = last_annotated_cca_df.index.intersection(cca_df.index) - cca_df.loc[idx, cca_df_colnames] = last_annotated_cca_df.loc[idx] - - self.store_cca_df(cca_df=cca_df, frame_i=frame_i, autosave=False) - pbar.update() - pbar.close() - - posData.frame_i = current_frame_i - self.get_data() - - def initMissingFramesLinTree(self, current_frame_i): # done Need to add partially missing previous frames and loading - """ - When not starting from the first frame, automatically creates lineage tree dfs for all "skipped" frames and initializes the tree if not done so before. - - Parameters - ---------- - current_frame_i : int - The index of the current frame. - - Returns - ------- - None - - Notes - ----- - This method initializes the lineage tree annotations of missing past frames. If the lineage tree has not been initialized before, it creates a new lineage tree based on the labels of the first frame. It then iterates over the missing frames and updates the lineage tree with the labels and region properties of each frame. - """ - - self.logger.info( - 'Initialising lineage tree annotations of missing past frames...' - ) - - self.store_data(autosave=False) - self.get_data() - - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - - if not self.lineage_tree: # init lin tree if not done already - self.lineage_tree = normal_division_lineage_tree(gui=self) # here frame_i!=0 - - missing_frames = list(range(current_frame_i+1)) - present_frames = list(self.lineage_tree.frames_for_dfs) if self.lineage_tree else [] - present_frames = [] if not present_frames else present_frames # deal with None - missing_frames = [frame_i for frame_i in missing_frames if frame_i not in present_frames] - missing_frames.sort() - - for frame_i in missing_frames: - lab = posData.allData_li[frame_i]['labels'] - prev_lab = posData.allData_li[frame_i-1]['labels'] - rp = posData.allData_li[frame_i]['regionprops'] - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - # i might need to change this if I need support for only partially missing frames... Although I probably never have to care about that though - self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) - - posData.frame_i = current_frame_i - self.store_data() - - def _getCcaCostMatrix( - self, numCellsG1, numNewCells, IDsCellsG1, newIDs_contours - ): - posData = self.data[self.pos_i] - dataDict = posData.allData_li[posData.frame_i] - dist_matrix_df = dataDict.get('obj_to_obj_dist_cost_matrix_df') - if dist_matrix_df is None: - cost = np.full((numCellsG1, numNewCells), np.inf) - for obj in posData.rp: - ID = obj.label - try: - i = IDsCellsG1.index(ID) - except ValueError: - continue - - cont = self.getObjContours(obj) - i = IDsCellsG1.index(ID) - - # Get distance from cell in G1 and all other new cells - for j, newID_cont in enumerate(newIDs_contours): - min_dist, nearest_xy = self.nearest_point_2Dyx( - cont, newID_cont - ) - cost[i, j] = min_dist - - return cost - - cost = dist_matrix_df.loc[IDsCellsG1, posData.new_IDs].values - - return cost - - def autoCca_df(self, enforceAll=False): - """ - Assign each bud to a mother with scipy linear sum assignment - (Hungarian or Munkres algorithm). First we build a cost matrix where - each (i, j) element is the minimum distance between bud i and mother j. - Then we minimize the cost of assigning each bud to a mother, and finally - we write the assignment info into cca_df - """ - proceed = True - notEnoughG1Cells = False - ScellsGone = False - - posData = self.data[self.pos_i] - - # Skip cca if not the right mode - mode = str(self.modeComboBox.currentText()) - if mode.find('Cell cycle') == -1: - return notEnoughG1Cells, proceed - - - # Make sure that this is a visited frame in segmentation tracking mode - if posData.allData_li[posData.frame_i]['labels'] is None: - proceed = self.warnFrameNeverVisitedSegmMode() - return notEnoughG1Cells, proceed - - # Determine if this is the last visited frame for repeating - # bud assignment on non manually correct (corrected_on_frame_i>0) buds. - # The idea is that the user could have assigned division on a cell - # by going previous and we want to check if this cell could be a - # "better" mother for those non manually corrected buds - curr_df = posData.allData_li[posData.frame_i]['acdc_df'] - isLastVisitedAgain = self.isLastVisitedAgainCca( - curr_df, enforceAll=enforceAll - ) - - frameAlreadyAnnotated = ( - posData.cca_df is not None - and not enforceAll - and not isLastVisitedAgain - ) - # Use stored cca_df and do not modify it with automatic stuff - if frameAlreadyAnnotated: - return notEnoughG1Cells, proceed - - # Keep only correctedAssignIDs if requested - # For the last visited frame we perform assignment again only on - # IDs where we didn't manually correct assignment - correctedAssignIDs = set() - if isLastVisitedAgain and not enforceAll: - try: - correctedAssignIDs = curr_df[ - curr_df['corrected_on_frame_i']>0 - ].index - except Exception as e: - correctedAssignIDs = [] - posData.new_IDs = [ - ID for ID in posData.new_IDs - if ID not in correctedAssignIDs - ] - - # Check if new IDs exist some time in the past - found_cca_df_IDs = self.checkCcaPastFramesNewIDs() - - # Check if there are some S cells that disappeared - abort, automaticallyDividedIDs = self.checkScellsGone() - if abort: - notEnoughG1Cells = False - proceed = False - return notEnoughG1Cells, proceed - - # Get previous dataframe - acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] - prev_cca_df = acdc_df[self.cca_df_colnames].copy() - - if posData.cca_df is None: - posData.cca_df = prev_cca_df.copy() - else: - posData.cca_df = curr_df[self.cca_df_colnames].copy() - - # concatenate new IDs found in past frames (before frame_i-1) - if found_cca_df_IDs is not None: - cca_df = pd.concat([posData.cca_df, *found_cca_df_IDs]) - unique_idx = ~cca_df.index.duplicated(keep='first') - posData.cca_df = cca_df[unique_idx] - - # If there are no new IDs we are done - if not posData.new_IDs: - proceed = True - self.store_cca_df() - return notEnoughG1Cells, proceed - - # Get cells in G1 (exclude dead) and check if there are enough cells in G1 - try: - prev_df_G1 = prev_cca_df[prev_cca_df['cell_cycle_stage']=='G1'] - prev_df_G1 = prev_df_G1[~acdc_df.loc[prev_df_G1.index]['is_cell_dead']] - IDsCellsG1 = set(prev_df_G1.index) - except Exception as err: - IDsCellsG1 = set() - - if isLastVisitedAgain or enforceAll: - # If we are repeating auto cca for last visited frame - # then we also add the cells in G1 that appears in current frame - # and we remove the ones that are already in S in current frame - # if they were manually corrected (i.e., they cannot be mother). - # Note that potential mother cells must be either appearing in - # current frame or in G1 also at previous frame. - # If we would consider cells that are in G1 at current frame - # but not in previous frame, assigning a bud to it would - # result in no G1 at all for the mother cell. - df_G1 = posData.cca_df[posData.cca_df['cell_cycle_stage']=='G1'] - current_G1_IDs = df_G1.index - new_cell_G1 = [ - ID for ID in current_G1_IDs if ID not in prev_cca_df.index - ] - IDsCellsG1.update(new_cell_G1) - cells_S_current = posData.cca_df[ - (posData.cca_df['cell_cycle_stage']=='S') - & (posData.cca_df['corrected_on_frame_i']==posData.frame_i) - ].index - IDsCellsG1 = IDsCellsG1 - set(cells_S_current) - - # Remove cells that disappeared - IDsCellsG1 = [ID for ID in IDsCellsG1 if ID in posData.IDs] - - numCellsG1 = len(IDsCellsG1) - numNewCells = len(posData.new_IDs) - if numCellsG1 < numNewCells: - notEnoughG1Cells, proceed = self.handleNoCellsInG1( - numCellsG1, numNewCells - ) - return notEnoughG1Cells, proceed - - # Compute new IDs contours - newIDs_contours = [] - for obj in posData.rp: - ID = obj.label - if ID in posData.new_IDs: - cont = self.getObjContours(obj) - newIDs_contours.append(cont) - - # Compute cost matrix - cost = self._getCcaCostMatrix( - numCellsG1, numNewCells, IDsCellsG1, newIDs_contours - ) - - # Run hungarian (munkres) assignment algorithm - row_idx, col_idx = scipy.optimize.linear_sum_assignment(cost) - - # New mother cells - newMothIDs = {IDsCellsG1[i] for i in row_idx} - - # Assign buds to mothers - for i, j in zip(row_idx, col_idx): - mothID = IDsCellsG1[i] - budID = posData.new_IDs[j] - - relID = None - # If we are repeating assignment for the bud then we also have to - # correct the possibily wrong mother --> it goes back to - # G1 if it's not a mother that we assign now - if budID in posData.cca_df.index: - relID = posData.cca_df.at[budID, 'relative_ID'] - if relID in prev_cca_df.index and relID not in newMothIDs: - posData.cca_df.loc[relID] = prev_cca_df.loc[relID] - - posData.cca_df.at[mothID, 'relative_ID'] = budID - posData.cca_df.at[mothID, 'cell_cycle_stage'] = 'S' - - bud_cca_dict = base_cca_dict.copy() - bud_cca_dict['cell_cycle_stage'] = 'S' - bud_cca_dict['generation_num'] = 0 - bud_cca_dict['relative_ID'] = mothID - bud_cca_dict['relationship'] = 'bud' - bud_cca_dict['emerg_frame_i'] = posData.frame_i - bud_cca_dict['is_history_known'] = True - bud_cca_dict['corrected_on_frame_i'] = -1 - posData.cca_df.loc[budID] = pd.Series(bud_cca_dict) - - # Keep only existing IDs - posData.cca_df = posData.cca_df.loc[posData.IDs] - - self.store_cca_df() - proceed = True - return notEnoughG1Cells, proceed - - def autoLinTree_df(self, enforceAll=False): - """Automatically generates a lineage tree dataframe. - - This method generates a lineage tree dataframe based on the current mode and data. - It checks if the mode is set to 'Normal division: Lineage tree' and if the current frame - is not already processed. If the conditions are met, it retrieves the necessary data - from the current position data and previous position data, and passes it to the - `real_time` method of the `lineage_tree` object. Finally, it converts the lineage tree - to an ACDC dataframe and adds the current frame to the set of frames that have been - processed. - - Parameters - ---------- - enforceAll : bool, optional - If True, enforces processing of all frames, even if they have been processed before. - If False, only processes frames that have not been processed before. Default is False. - - Returns - ------- - bool - True if there are not enough G1 cells for lineage tree generation, False otherwise. - bool - True if the lineage tree generation should proceed, False otherwise. - """ - proceed = True - notEnoughG1Cells = False - mode = str(self.modeComboBox.currentText()) - - # Skip if not the right mode - if mode != 'Normal division: Lineage tree': - return notEnoughG1Cells, proceed - - posData = self.data[self.pos_i] - frame_i = posData.frame_i - - if frame_i in self.lineage_tree.frames_for_dfs: - return notEnoughG1Cells, proceed - - # Make sure that this is a visited frame in segmentation tracking mode - if posData.allData_li[frame_i]['labels'] is None: # may need to change this - proceed = self.warnFrameNeverVisitedSegmMode() - return notEnoughG1Cells, proceed - - self.store_data(autosave=False) - self.get_data() - lab = posData.lab - prev_lab = posData.allData_li[frame_i-1]['labels'] - rp = posData.rp - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - - self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) - self.store_data() - - def getObjBbox(self, obj_bbox): - if self.isSegm3D and len(obj_bbox)==6: - obj_bbox = (obj_bbox[1], obj_bbox[2], obj_bbox[4], obj_bbox[5]) - return obj_bbox - else: - return obj_bbox - - def z_lab(self, checkIfProj=False): - if checkIfProj and self.zProjComboBox.currentText() != 'single z-slice': - return - - if not self.isSegm3D: - return - - posData = self.data[self.pos_i] - - idx = self.zSliceScrollBar.sliderPosition() - - # ensure idx doesnt exceed the number of z-slices of the position - idx_z = min(idx, posData.SizeZ-1) - - if not self.switchPlaneCombobox.isEnabled(): - return idx_z - - depthAxes = self.switchPlaneCombobox.depthAxes() - if depthAxes == 'z': - return idx_z - elif depthAxes == 'y': - idx_y = min(idx, posData.SizeY-1) - return (slice(None), idx_y) - else: - idx_x = min(idx, posData.SizeX-1) - return (slice(None), slice(None), idx_x) - - def get_2Dlab(self, lab, force_z=True): - if self.isSegm3D: - if force_z: - return lab[self.z_lab()] - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - return lab[self.z_lab()] - else: - return lab.max(axis=0) - else: - return lab - - # @exec_time - def applyEraserMask(self, mask): - posData = self.data[self.pos_i] - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - posData.lab[self.z_lab(), mask] = 0 - else: - posData.lab[:, mask] = 0 - else: - posData.lab[mask] = 0 - - def changeBrushID(self): - """Function called when pressing or releasing shift - """ - if not self.isSegm3D: - # Changing brush ID with shift is only for 3D segm - return - - if not self.brushButton.isChecked(): - # Brush if not active - return - - if not self.isMouseDragImg2 and not self.isMouseDragImg1: - # Mouse is not brushing at the moment - return - - posData = self.data[self.pos_i] - forceNewObj = not self.isNewID - - if forceNewObj: - # Shift is down --> force new object with brush - # e.g., 24 --> 28: - # 24 is hovering ID that we store as self.prevBrushID - # 24 object becomes 28 that is the new posData.brushID - self.isNewID = True - self.changedID = posData.brushID - self.restoreBrushID = posData.brushID - # Set a new ID - self.setBrushID() - else: - # Shift released or hovering on ID in z+-1 - # --> restore brush ID from before shift was pressed or from - # when we started brushing from outside an object - # but we hovered on ID in z+-1 while dragging. - # We change the entire 28 object to 24 so before changing the - # brush ID back to 24 we builg the mask with 28 to change it to 24 - self.isNewID = False - self.changedID = posData.brushID - # Restore ID - posData.brushID = self.restoreBrushID - - brushID = posData.brushID - brushIDmask = self.get_2Dlab(posData.lab) == self.changedID - self.applyBrushMask(brushIDmask, brushID) - if self.isMouseDragImg1: - self.brushColor = self.lut[posData.brushID]/255 - self.setTempImg1Brush(True, brushIDmask, posData.brushID) - - def applyBrushMask(self, mask, ID, toLocalSlice=None): - posData = self.data[self.pos_i] - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - if toLocalSlice is not None: - toLocalSlice = (self.z_lab(), *toLocalSlice) - posData.lab[toLocalSlice][mask] = ID - else: - posData.lab[self.z_lab()][mask] = ID - else: - if toLocalSlice is not None: - for z in range(len(posData.lab)): - _slice = (z, *toLocalSlice) - posData.lab[_slice][mask] = ID - else: - posData.lab[:, mask] = ID - else: - if toLocalSlice is not None: - posData.lab[toLocalSlice][mask] = ID - else: - posData.lab[mask] = ID - - def assignNewIDfromClickedID( - self, clickedID: int, event: QGraphicsSceneMouseEvent - ): - posData = self.data[self.pos_i] - x, y = event.pos().x(), event.pos().y() - newID = self.setBrushID(return_val=True) - mapper = [(clickedID, newID)] - self.applyEditID(clickedID, posData.IDs.copy(), mapper, x, y) - - def get_2Drp(self, lab=None): - if self.isSegm3D: - if lab is None: - # self.currentLab2D is defined at self.setImageImg2() - lab = self.currentLab2D - lab = self.get_2Dlab(lab) - rp = skimage.measure.regionprops(lab) - return rp - else: - return self.data[self.pos_i].rp - - def set_2Dlab(self, lab2D, lab3D=None): - posData = self.data[self.pos_i] - - if lab3D is None: - lab3D = posData.lab - - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - lab3D[self.z_lab()] = lab2D - else: - lab3D[:] = lab2D - else: - if lab3D.shape == lab2D.shape: - lab3D[...] = lab2D - else: - posData.lab = lab2D - - def get_labels( - self, - from_store=False, - frame_i=None, - return_existing=False, - return_copy=True - ): - """Get the labels array. - - Parameters - ---------- - from_store : bool, optional - If True load the labels array from the stored posData.allData_li, - i.e., from RAM. Default is False - frame_i : int, optional - If None, use the current frame index. Default is None - return_existing : bool, optional - If True, the second return element will be a boolean that - is True if the labels array was found stored in `posData.allData_li`. - Default is False - return_copy : bool, optional - If True returns a copy of the labels array - - Returns - ------- - numpy.ndarray or tuple of (numpy.ndarray, bool) - The first element is the labels array requested. If `return_existing` - is True then this method also returns a second boolean element that - is True if the labels array was found in in `posData.allData_li`. - - Note - ---- - - If `from_store` is True then this method will try to get the stored - labels array. If any error occurs then the returned labels are the - saved ones in the segmentation file (i.e., from hard drive). - - """ - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - existing = True - if from_store: - try: - labels = posData.allData_li[frame_i]['labels'] - if labels is None: - from_store = False - except Exception as err: - from_store = False - - if not from_store: - try: - labels = posData.segm_data[frame_i] - except IndexError: - existing = False - # Visting a frame that was not segmented --> empty masks - if self.isSegm3D: - shape = (posData.SizeZ, posData.SizeY, posData.SizeX) - else: - shape = (posData.SizeY, posData.SizeX) - labels = np.zeros(shape, dtype=np.uint32) - return_copy = False - - if return_copy: - labels = labels.copy() - - if return_existing: - return labels, existing - else: - return labels - - def addYXcentroidToDf(self, df): - posData = self.data[self.pos_i] - for obj in posData.rp: - y_centroid = int(self.getObjCentroid(obj.centroid)[0]) - x_centroid = int(self.getObjCentroid(obj.centroid)[1]) - df.at[obj.label, 'y_centroid'] = y_centroid - df.at[obj.label, 'x_centroid'] = x_centroid - return df - - def _get_editID_info(self, df): - if 'was_manually_edited' not in df.columns: - return [] - - if 'y_centroid' not in df.columns or 'x_centroid' not in df.columns: - df = self.addYXcentroidToDf(df) - - manually_edited_df = df[df['was_manually_edited'] > 0] - editID_info = [ - (row.y_centroid, row.x_centroid, row.Index) - for row in manually_edited_df.itertuples() - ] - return editID_info - - def apply_manual_edits_to_lab_if_needed(self, lab): - posData = self.data[self.pos_i] - data_frame_i = posData.allData_li[posData.frame_i] - edited_lab_dict = data_frame_i['manually_edited_lab']['lab'] - if not edited_lab_dict: - return lab - - # zoom_slice = data_frame_i['manually_edited_lab']['zoom_slice'] - for z, lab_edited in edited_lab_dict.items(): - if not self.isSegm3D: - # lab[zoom_slice] = lab_edited - lab = lab_edited - break - - lab[z] = lab_edited - - # lab[z, zoom_slice[0], zoom_slice[1]] = zoom_lab - - return lab - - def _get_data_unvisited(self, posData, debug=False, lin_tree_init=True,): - posData.editID_info = [] - proceed_cca = True - never_visited = True - if str(self.modeComboBox.currentText()) == 'Cell cycle analysis': - # Warn that we are visiting a frame that was never segm-checked - # on cell cycle analysis mode - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Segmentation and Tracking was never checked from ' - f'frame {posData.frame_i+1} onwards.

' - 'To ensure correct cell cell cycle analysis you have to ' - 'first visit the frames after ' - f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' - ) - warn_cca = msg.critical( - self, 'Never checked segmentation on requested frame', txt - ) - proceed_cca = False - return proceed_cca, never_visited - - elif str(self.modeComboBox.currentText()) == 'Normal division: Lineage tree': - # Warn that we are visiting a frame that was never segm-checked - # on cell cycle analysis mode - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Segmentation and Tracking was never checked from ' - f'frame {posData.frame_i+1} onwards.

' - 'To ensure correct lineage tree analysis you have to ' - 'first visit the frames after ' - f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' - ) - warn_cca = msg.critical(#??? - self, 'Never checked segmentation on requested frame', txt - ) - proceed_cca = False - return proceed_cca, never_visited - - # Requested frame was never visited before. Load from HDD - labels = self.get_labels() - posData.lab = self.apply_manual_edits_to_lab_if_needed( - labels - ) - posData.rp = skimage.measure.regionprops(posData.lab) - self.setManualBackgroundLab() - - if posData.acdc_df is not None: - frames = posData.acdc_df.index.get_level_values(0) - if posData.frame_i in frames: - # Since there was already segmentation metadata from - # previous closed session add it to current metadata - df = posData.acdc_df.loc[posData.frame_i].copy() - binnedIDs_df = df[df['is_cell_excluded']>0] - binnedIDs = set(binnedIDs_df.index).union(posData.binnedIDs) - posData.binnedIDs = binnedIDs - ripIDs_df = df[df['is_cell_dead']>0] - ripIDs = set(ripIDs_df.index).union(posData.ripIDs) - posData.ripIDs = ripIDs - posData.editID_info.extend(self._get_editID_info(df)) - # Load cca df into current metadata - if 'cell_cycle_stage' in df.columns: - cca_cols = df.columns.intersection(self.cca_df_colnames) - cca_df = df[cca_cols].dropna() - if cca_df.empty: - df = df.drop( - columns=self.cca_df_colnames, errors='ignore' - ) - else: - df = df.loc[cca_df.index] - cols = self.cca_df_int_cols - df[cols] = df[cols].astype('Int64') - - i = posData.frame_i - posData.allData_li[i]['acdc_df'] = df.copy() - - if self.lineage_tree is None and lin_tree_init: - self.initLinTree() - - self.get_cca_df() - - return proceed_cca, never_visited - - def _get_data_visited(self, posData, debug=False, lin_tree_init=True,): - # Requested frame was already visited. Load from RAM. - never_visited = False - posData.lab = self.get_labels(from_store=True) - posData.rp = skimage.measure.regionprops(posData.lab) - df = posData.allData_li[posData.frame_i]['acdc_df'] - if df is None: - posData.binnedIDs = set() - posData.ripIDs = set() - posData.editID_info = [] - else: - try: - binnedIDs_df = df[df['is_cell_excluded']>0] - except Exception as err: - df = myutils.fix_acdc_df_dtypes(df) - binnedIDs_df = df[df['is_cell_excluded']>0] - posData.binnedIDs = set(binnedIDs_df.index) - ripIDs_df = df[df['is_cell_dead']>0] - posData.ripIDs = set(ripIDs_df.index) - posData.editID_info = self._get_editID_info(df) - self.setManualBackgroundLab(load_from_store=True, debug=debug) - if self.lineage_tree is None and lin_tree_init: - self.initLinTree() - - self.get_cca_df(debug=debug) - - return True, never_visited - - @get_data_exception_handler - def get_data(self, debug=False, lin_tree_init=True): - posData = self.data[self.pos_i] - proceed_cca = True - never_visited = False - if posData.frame_i > 2: - # Remove undo states from 4 frames back to avoid memory issues - posData.UndoRedoStates[posData.frame_i-4] = [] - # Check if current frame contains undo states (not empty list) - if posData.UndoRedoStates[posData.frame_i]: - self.undoAction.setDisabled(False) - elif posData.UndoRedoCcaStates[posData.frame_i]: - self.undoAction.setDisabled(False) - else: - self.undoAction.setDisabled(True) - self.UndoCount = 0 - # If stored labels is None then it is the first time we visit this frame - if posData.allData_li[posData.frame_i]['labels'] is None: - proceed_cca, never_visited = self._get_data_unvisited( - posData, lin_tree_init=lin_tree_init, - ) - if not proceed_cca: - return proceed_cca, never_visited - else: - proceed_cca, never_visited = self._get_data_visited( - posData, lin_tree_init=lin_tree_init, debug=debug - ) - - self.update_rp_metadata(draw=False) - posData.IDs = [obj.label for obj in posData.rp] - posData.IDs_idxs = { - ID:i for ID, i in zip(posData.IDs, range(len(posData.IDs))) - } - self.get_zslices_rp() - self.pointsLayerDfsToData(posData) - return proceed_cca, never_visited - - def addIDBaseCca_df(self, posData, ID): - if ID <= 0: - # When calling update_cca_df_deletedIDs we add relative IDs - # but they could be -1 for cells in G1 - return - - _zip = zip( - self.cca_df_colnames, - self.cca_df_default_values, - ) - if posData.cca_df.empty: - posData.cca_df = pd.DataFrame( - {col: val for col, val in _zip}, - index=[ID] - ) - else: - for col, val in _zip: - posData.cca_df.at[ID, col] = val - self.store_cca_df() - - def getBaseCca_df(self, with_tree_cols=False): - posData = self.data[self.pos_i] - IDs = [obj.label for obj in posData.rp] - cca_df = core.getBaseCca_df(IDs, with_tree_cols=with_tree_cols) - return cca_df - - def get_last_tracked_i(self): - posData = self.data[self.pos_i] - last_tracked_i = 0 - for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] - if lab is None and frame_i == 0: - last_tracked_i = 0 - break - elif lab is None: - last_tracked_i = frame_i-1 - break - else: - last_tracked_i = posData.segmSizeT-1 - return last_tracked_i - - def get_last_cca_frame_i(self): - posData = self.data[self.pos_i] - - i = 0 - # Determine last annotated frame index - for i, dict_frame_i in enumerate(posData.allData_li): - df = dict_frame_i['acdc_df'] - if df is None: - break - elif 'cell_cycle_stage' not in df.columns: - break - - last_cca_frame_i = i if i==0 or i+1==len(posData.allData_li) else i-1 - - return last_cca_frame_i - - def initSegmTrackMode(self): - posData = self.data[self.pos_i] - last_tracked_i = self.get_last_tracked_i() - - if posData.frame_i > last_tracked_i: - # Prompt user to go to last tracked frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - f'The last visited frame in "Segmentation and Tracking mode" ' - f'is frame {last_tracked_i+1}.\n\n' - f'We recommend to resume from that frame.

' - 'How do you want to proceed?' - ) - goToButton, stayButton = msg.warning( - self, 'Go to last visited frame?', txt, - buttonsTexts=( - f'Resume from frame {last_tracked_i+1} (RECOMMENDED)', - f'Stay on current frame {posData.frame_i+1}' - ) - ) - if msg.clickedButton == goToButton: - posData.frame_i = last_tracked_i - self.lastFrameRanOnFirstVisitTools = posData.frame_i - self.get_data() - self.updateAllImages() - self.updateScrollbars() - else: - last_tracked_i = posData.frame_i - current_frame_i = posData.frame_i - self.lastFrameRanOnFirstVisitTools = posData.frame_i - self.logger.info( - f'Storing data up until frame n. {current_frame_i+1}...' - ) - pbar = tqdm(total=current_frame_i+1, ncols=100) - for i in range(current_frame_i): - posData.frame_i = i - self.get_data() - self.store_data(autosave=i==current_frame_i-1) - pbar.update() - pbar.close() - - posData.frame_i = current_frame_i - self.get_data() - - self.highlightLostNew() - self.updateLastCheckedFrameWidgets(last_tracked_i) - - self.isFirstTimeOnNextFrame() - self.initRealTimeTracker() - - def updateLastCheckedFrameWidgets(self, last_tracked_i): - self.navigateScrollBar.setMaximum(last_tracked_i+1) - self.navSpinBox.setMaximum(last_tracked_i+1) - self.lastTrackedFrameLabel.setText( - f'Last checked frame n. = {last_tracked_i+1}' - ) - - @exception_handler - def initCca(self): - posData = self.data[self.pos_i] - last_tracked_i = self.get_last_tracked_i() - defaultMode = 'Viewer' - if last_tracked_i == 0: - txt = html_utils.paragraph( - 'On this dataset either you never checked that the segmentation ' - 'and tracking are correct or you did not save yet.

' - 'If you already visited some frames with "Segmentation and Tracking" ' - 'mode save data before switching to "Cell cycle analysis mode".

' - 'Otherwise you first have to check (and eventually correct) some frames ' - 'in "Segmentation and Tracking" mode before proceeding ' - 'with cell cycle analysis.') - msg = widgets.myMessageBox() - msg.critical( - self, 'Tracking was never checked', txt - ) - self.modeComboBox.setCurrentText(defaultMode) - return - - proceed = True - - last_cca_frame_i = self.get_last_cca_frame_i() - if last_cca_frame_i == 0: - # Remove undoable actions from segmentation mode - posData.UndoRedoStates[0] = [] - self.undoAction.setEnabled(False) - self.redoAction.setEnabled(False) - - if posData.frame_i > last_cca_frame_i: - # Prompt user to go to last annotated frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_cca_frame_i+1}.

- Do you want to restart cell cycle analysis from frame - {last_cca_frame_i+1}?
- """) - _, goToFrameButton, stayButton = msg.warning( - self, 'Go to last annotated frame?', txt, - buttonsTexts=( - 'Cancel', f'Yes, go to frame {last_cca_frame_i+1}', - 'No, stay on current frame') - ) - if goToFrameButton == msg.clickedButton: - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - msg = 'Looking good!' - self.last_cca_frame_i = last_cca_frame_i - posData.frame_i = last_cca_frame_i - self.titleLabel.setText(msg, color=self.titleColor) - self.get_data() - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - self.updateAllImages() - self.updateScrollbars() - elif stayButton == msg.clickedButton: - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - self.initMissingFramesCca(last_cca_frame_i, posData.frame_i) - last_cca_frame_i = posData.frame_i - msg = 'Cell cycle analysis initialised!' - self.titleLabel.setText(msg, color='g') - elif msg.cancel: - msg = 'Cell cycle analysis aborted.' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - self.modeComboBox.setCurrentText(defaultMode) - proceed = False - return - elif posData.frame_i < last_cca_frame_i: - # Prompt user to go to last annotated frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_cca_frame_i+1}.

- Do you want to restart cell cycle analysis from frame - {last_cca_frame_i+1}?
- """) - yesButton, noButton, _ = msg.question( - self, 'Go to last annotated frame?', txt, - buttonsTexts=('Yes', 'No', 'Cancel') - ) - if msg.cancel: - msg = 'Cell cycle analysis aborted.' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - self.modeComboBox.setCurrentText(defaultMode) - proceed = False - return - - self.addMissingIDs_cca_df(posData) - if msg.clickedButton == yesButton: - self.addMissingIDs_cca_df(posData) - msg = 'Looking good!' - self.titleLabel.setText(msg, color=self.titleColor) - self.last_cca_frame_i = last_cca_frame_i - posData.frame_i = last_cca_frame_i - self.get_data() - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - self.updateAllImages() - self.updateScrollbars() - else: - self.get_data() - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - - self.last_cca_frame_i = last_cca_frame_i - - self.navigateScrollBar.setMaximum(last_cca_frame_i+1) - self.navSpinBox.setMaximum(last_cca_frame_i+1) - self.lastTrackedFrameLabel.setText( - f'Last cc annot. frame n. = {last_cca_frame_i+1}' - ) - - if posData.cca_df is None: - posData.cca_df = self.getBaseCca_df() - self.store_cca_df() - msg = 'Cell cycle analysis initialized!' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - else: - self.get_cca_df() - - self.enqCcaIntegrityChecker() - - return proceed - @exception_handler - def initLinTree(self, force=False): - """ - Initializes the lineage tree analysis. - - This method checks if the tracking has been previously checked and saved. If not, it displays a message to the user. - It also prompts the user to go to the last annotated frame and restart the lineage tree analysis if necessary. - Finally, it initializes the necessary data structures and updates the GUI. - - Returns - ------- - proceed : bool - True if the initialization is successful, nothing otherwise. - """ - - if not force and self.lineage_tree is not None: - return - - mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree' and not force: - return - - posData = self.data[self.pos_i] - last_tracked_i = self.get_last_tracked_i() - defaultMode = 'Viewer' - if last_tracked_i == 0: - # Display message to the user - txt = html_utils.paragraph( - 'On this dataset either you never checked that the segmentation ' - 'and tracking are correct or you did not save yet.

' - 'If you already visited some frames with "Segmentation and Tracking" ' - 'mode save data before switching to "Normal division: Lineage Tree".

' - 'Otherwise you first have to check (and eventually correct) some frames ' - 'in "Segmentation and Tracking" mode before proceeding ' - 'with lineage tree analysis.') - msg = widgets.myMessageBox() - msg.critical( - self, 'Tracking was never checked', txt - ) - self.modeComboBox.setCurrentText(defaultMode) - return - - proceed = True - last_lin_tree_frame_i = 0 - # Determine last annotated frame index - for i, dict_frame_i in enumerate(posData.allData_li): - df = dict_frame_i['acdc_df'] - if (df is None or - 'generation_num_tree' not in df.columns - or df['generation_num_tree'].isin([np.nan, 0]).all() - ): - break - else: - last_lin_tree_frame_i = i - - if last_lin_tree_frame_i == 0: - # Remove undoable actions from segmentation mode - posData.UndoRedoStates[0] = [] - self.undoAction.setEnabled(False) - self.redoAction.setEnabled(False) - - if posData.frame_i > last_lin_tree_frame_i: - # Prompt user to go to last annotated frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_lin_tree_frame_i+1}.

- Do you want to restart lineage tree analysis from frame - {last_lin_tree_frame_i+1}?
- """) - _, yesButton, stayButton = msg.warning( - self, 'Go to last annotated frame?', txt, - buttonsTexts=( - 'Cancel', f'Yes, go to frame {last_lin_tree_frame_i+1}', - 'No, stay on current frame') - ) - if yesButton == msg.clickedButton: - msg = 'Looking good!' - self.last_lin_tree_frame_i = last_lin_tree_frame_i - posData.frame_i = last_lin_tree_frame_i - self.titleLabel.setText(msg, color=self.titleColor) - self.get_data(lin_tree_init=False) - self.updateAllImages() # i dont think I need to change this - self.updateScrollbars() # i dont think I need to change this - elif stayButton == msg.clickedButton: - self.initMissingFramesLinTree(posData.frame_i) #!!! - last_lin_tree_frame_i = posData.frame_i - msg = 'Lineage tree analysis initialised!' - self.titleLabel.setText(msg, color='g') - elif msg.cancel: - msg = 'Lineage tree analysis aborted.' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - self.modeComboBox.setCurrentText(defaultMode) - proceed = False - return - - elif posData.frame_i < last_lin_tree_frame_i: - # Prompt user to go to last annotated frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_lin_tree_frame_i+1}.

- Do you want to restart lineage tree analysis from frame - {last_lin_tree_frame_i+1}?
- """) - goTo_last_annotated_frame_i = msg.question( - self, 'Go to last annotated frame?', txt, - buttonsTexts=('Yes', 'No', 'Cancel') - )[0] - if goTo_last_annotated_frame_i == msg.clickedButton: - msg = 'Looking good!' - self.titleLabel.setText(msg, color=self.titleColor) - self.last_lin_tree_frame_i = last_lin_tree_frame_i - posData.frame_i = last_lin_tree_frame_i - self.get_data(lin_tree_init=False) - self.updateAllImages() # i dont think I need to change this - self.updateScrollbars() # i dont think I need to change this - elif msg.cancel: - msg = 'Lineage tree analysis aborted.' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - self.modeComboBox.setCurrentText(defaultMode) - proceed = False - return - else: - self.get_data(lin_tree_init=False) - - self.last_lin_tree_frame_i = last_lin_tree_frame_i - - self.navigateScrollBar.setMaximum(last_lin_tree_frame_i+1) - self.navSpinBox.setMaximum(last_lin_tree_frame_i+1) - - if self.lineage_tree is None or force: - self.store_data(autosave=False) - self.get_data(lin_tree_init=False) - self.lineage_tree = normal_division_lineage_tree(gui=self) - - msg = 'Lineage tree analysis initialized!' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - - return proceed - - @disableWindow - def propagateLinTreeAction(self, dummy_for_button=None): - """ - Propagates the lineage tree based on the current frame_i. Used in self.propagateLinTreeButton. - """ - posData = self.data[self.pos_i] - self.lineage_tree.propagate(posData.frame_i) - if posData.frame_i == self.original_df_lin_tree_i: - self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() - - self.logger.info('Lineage tree propagated.') - - def isCcaCheckerChecking(self): - if not self.ccaCheckerRunning: - return False - - return self.ccaIntegrityCheckerWorker.isChecking - - def getConcatCcaDf(self): - posData = self.data[self.pos_i] - cca_dfs = [] - keys = [] - for frame_i in range(0, posData.SizeT): - cca_df = self.get_cca_df(frame_i=frame_i, return_df=True) - if cca_df is None: - break - - cca_dfs.append(cca_df) - keys.append(frame_i) - - if not cca_dfs: - return - - global_cca_df = pd.concat(cca_dfs, keys=keys, names=['frame_i']) - return global_cca_df - - def storeFromConcatCcaDf(self, global_cca_df): - posData = self.data[self.pos_i] - for frame_i in range(0, posData.SizeT): - try: - cca_df = global_cca_df.loc[frame_i] - except KeyError as err: - break - - self.store_cca_df(frame_i=frame_i, cca_df=cca_df, autosave=False) - - self.get_cca_df() - - def resetWillDivideInfo(self): - global_cca_df = self.getConcatCcaDf() - if global_cca_df is None: - return - - global_cca_df = load._fix_will_divide(global_cca_df) - self.storeFromConcatCcaDf(global_cca_df) - - def ccaCheckerStopChecking(self): - if not self.ccaCheckerRunning: - return - - self.ccaIntegrityCheckerWorker.clearQueue() - - if self.ccaIntegrityCheckerWorker.isChecking: - self.ccaIntegrityCheckerWorker.abortChecking = True - - def updateLastVisitedFrame(self, last_visited_frame_i=None): - if last_visited_frame_i is None: - posData = self.data[self.pos_i] - last_visited_frame_i = posData.frame_i - - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - return - elif mode == 'Segmentation and Tracking': - posData = self.data[self.pos_i] - if posData.last_tracked_i >= last_visited_frame_i: - return - posData.last_tracked_i = last_visited_frame_i - elif mode == 'Cell cycle analysis': - if self.last_cca_frame_i >= last_visited_frame_i: - return - self.last_cca_frame_i = last_visited_frame_i - - def resetCcaFuture(self, from_frame_i): - posData = self.data[self.pos_i] - self.last_cca_frame_i = from_frame_i-1 - self.ccaCheckerStopChecking() - - self.setNavigateScrollBarMaximum() - for i in range(from_frame_i, posData.SizeT): - posData.allData_li[i].pop('cca_df', None) - posData.allData_li[i].pop('cca_df_checker', None) - - df = posData.allData_li[i]['acdc_df'] - if df is None: - # No more saved info to delete - break - - if 'cell_cycle_stage' not in df.columns: - # No cell cycle info present - continue - - df = df.drop(columns=self.cca_df_colnames) - posData.allData_li[i]['acdc_df'] = df - - if posData.acdc_df is not None: - frames = posData.acdc_df.index.get_level_values(0) - if from_frame_i in frames: - posData.acdc_df = posData.acdc_df.loc[:from_frame_i] - - self.resetWillDivideInfo() - - def removeCcaAnnotationsCurrentFrame(self): - posData = self.data[self.pos_i] - posData.cca_df = None - - posData.allData_li[posData.frame_i].pop('cca_df', None) - posData.allData_li[posData.frame_i].pop('cca_df_checker', None) - - df = posData.allData_li[posData.frame_i]['acdc_df'] - if df is None: - # No more saved info to delete - return False - - if 'cell_cycle_stage' not in df.columns: - # No cell cycle info present - return False - - df = df.drop(columns=self.cca_df_colnames) - posData.allData_li[posData.frame_i]['acdc_df'] = df - - return True - - def resetFutureCcaColCurrentFrame(self): - posData = self.data[self.pos_i] - - cca_df_S_mask = posData.cca_df.cell_cycle_stage == 'S' - posData.cca_df.loc[cca_df_S_mask, 'will_divide'] = 0 - - mothers_mask = ( - (posData.cca_df.relationship == 'mother') - & cca_df_S_mask - ) - bud_mask = posData.cca_df.relationship == 'bud' - - posData.cca_df.loc[mothers_mask, 'daughter_disappears_before_division'] = 0 - posData.cca_df.loc[bud_mask, 'disappears_before_division'] = 0 - - cca_df = self.get_cca_df(frame_i=posData.frame_i, return_df=True) - if cca_df is not None: - cca_df_S_mask = cca_df.cell_cycle_stage == 'S' - cca_df.loc[cca_df_S_mask, 'will_divide'] = 0 - - mothers_mask = ( - (cca_df.relationship == 'mother') - & cca_df_S_mask - ) - bud_mask = cca_df.relationship == 'bud' - - cca_df.loc[mothers_mask, 'daughter_disappears_before_division'] = 0 - cca_df.loc[bud_mask, 'disappears_before_division'] = 0 - - self.store_data() - - def resetLin_tree_future(self): - posData = self.data[self.pos_i] - frame_i = posData.frame_i - - for i in range(frame_i, posData.SizeT): - if self.lineage_tree is not None: - self.lineage_tree.frames_for_dfs.discard(frame_i) - df = posData.allData_li[i]['acdc_df'] - # reste lineage tree columns - if df is None: - continue - df = df.drop(columns=lineage_tree_cols, errors='ignore') - posData.allData_li[i]['acdc_df'] = df - - def get_cca_df(self, frame_i=None, return_df=False, debug=False): - # cca_df is None unless the metadata contains cell cycle annotations - # NOTE: cell cycle annotations are either from the current session - # or loaded from HDD in "initPosAttr" with a .question to the user - posData = self.data[self.pos_i] - cca_df = None - i = posData.frame_i if frame_i is None else frame_i - df = posData.allData_li[i]['acdc_df'] - if df is not None: - if 'cell_cycle_stage' in df.columns: - cca_df = df[self.cca_df_colnames].copy() - - if cca_df is None and self.isSnapshot: - cca_df = self.getBaseCca_df() - posData.cca_df = cca_df - - if cca_df is not None: - cca_df = cca_df.dropna() - - if return_df: - return cca_df - else: - posData.cca_df = cca_df - - def changeIDfutureFrames( - self, endFrame_i, oldIDnewIDMapper, includeUnvisited, - shift=False - ): - posData = self.data[self.pos_i] - self.current_frame_i = posData.frame_i - - # Store data for current frame - self.store_data() - if endFrame_i is None: - self.app.restoreOverrideCursor() - return - - segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i+1, segmSizeT): - lab = posData.allData_li[i]['labels'] - if lab is None and not includeUnvisited: - self.enqAutosave() - break - - if lab is not None: - # Visited frame - posData.frame_i = i - self.get_data(lin_tree_init=False) - if shift and self.isSegm3D: - lab = self.get_2Dlab(posData.lab) - else: - lab = posData.lab - - if self.onlyTracking: - self.tracking(enforce=True) - elif not posData.IDs: - continue - else: - maxID = max(posData.IDs, default=0) + 1 - for old_ID, new_ID in oldIDnewIDMapper: - if new_ID in lab: - tempID = maxID + 1 # lab.max() + 1 - lab[lab == old_ID] = tempID - lab[lab == new_ID] = old_ID - lab[lab == tempID] = new_ID - maxID += 1 - else: - lab[lab == old_ID] = new_ID - - if shift and self.isSegm3D: - self.set_2Dlab(lab) - - self.update_rp(draw=False) - self.store_data(autosave=i==endFrame_i) - elif includeUnvisited: - # Unvisited frame (includeUnvisited = True) - lab = posData.segm_data[i] - if shift and self.isSegm3D: - lab = self.get_2Dlab(lab) - else: - lab = lab - - for old_ID, new_ID in oldIDnewIDMapper: - if new_ID in lab: - tempID = lab.max() + 1 - lab[lab == old_ID] = tempID - lab[lab == new_ID] = old_ID - lab[lab == tempID] = new_ID - else: - lab[lab == old_ID] = new_ID - - if shift and self.isSegm3D: - posData.segm_data[i][self.z_lab()] = lab - - # Back to current frame - posData.frame_i = self.current_frame_i - self.get_data() - self.app.restoreOverrideCursor() - - def unstore_cca_df(self): - posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - for col in self.cca_df_colnames: - if col not in acdc_df.columns: - continue - acdc_df.drop(col, axis=1, inplace=True) - - def store_cca_df_checker(self, posData, frame_i, cca_df): - if not self.ccaCheckerRunning: - return - - if cca_df is None: - return - - posData.allData_li[frame_i]['cca_df_checker'] = cca_df.copy() - - def store_cca_df( - self, pos_i=None, frame_i=None, cca_df=None, mainThread=True, - autosave=True, store_cca_df_copy=False - ): - pos_i = self.pos_i if pos_i is None else pos_i - posData = self.data[pos_i] - i = posData.frame_i if frame_i is None else frame_i - if cca_df is None: - cca_df = posData.cca_df - if self.ccaTableWin is not None and mainThread: - zoomIDs = self.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - acdc_df = posData.allData_li[i]['acdc_df'] - if acdc_df is None: - current_frame_i = None - if frame_i is not None and frame_i != posData.frame_i: - current_frame_i = posData.frame_i - posData.frame_i = frame_i - self.get_data() - self.store_data() - acdc_df = posData.allData_li[i]['acdc_df'] - if current_frame_i is not None: - # Back to current frame - posData.frame_i = current_frame_i - self.get_data(debug=False) - - if 'cell_cycle_stage' in acdc_df.columns: - # Cell cycle info already present --> overwrite with new - acdc_df[self.cca_df_colnames] = cca_df[self.cca_df_colnames] - posData.allData_li[i]['acdc_df'] = acdc_df - elif cca_df is not None: - df = acdc_df.drop(cca_df.columns, axis=1, errors='ignore') - df = df.join(cca_df, how='left') - posData.allData_li[i]['acdc_df'] = df - - # Store copy for cca integrity worker - self.store_cca_df_checker(posData, i, cca_df) - - if store_cca_df_copy and cca_df is not None: - posData.allData_li[i]['cca_df'] = cca_df.copy() - - if autosave: - self.enqAutosave() - self.enqCcaIntegrityChecker() - - # def lin_tree_to_acdc_df(self, force_all=False, ignore=set(), force=set(), specific=set()): - # """ - # Syncs the lineage tree DataFrame with the acdc_df DataFrame. By default, it will only try to sync frames which have not been synced before. - # This can be changed using the optional arguments. - - # Parameters - # ---------- - # force_all : bool, optional - # If True, forces synchronization for all frames. Defaults to False. - # ignore : set, optional - # Set of frames to ignore during synchronization. Defaults to set(). - # force : set, optional - # Set of frames to force synchronization. Defaults to set(). - # specific : set, optional - # Set of frames to specifically synchronize. In this case it will ignore all other inputs and sync those no matter what. Defaults to set(). - # """ - - # if self.lineage_tree is None: - # return - - # # df_for_sync = [] - # # lineage_copy = self.lineage_tree.lineage_list.copy() - # lin_tree_set = self.lineage_tree.frames_for_dfs.copy() - - # if not force_all and not specific: - # dont_sync = self.already_synced_lin_tree - # dont_sync = {frame for frame in dont_sync if not frame in force} - # dont_sync.update(ignore) - - # lin_tree_set = lin_tree_set.difference(dont_sync) - - # if specific: - # lin_tree_set = lin_tree_set.intersection(specific) - - - # if lin_tree_set == []: - # return - - # posData = self.data[self.pos_i] - - # lin_tree_colnames = None - # self.store_data(autosave=False) - # for frame_i in lin_tree_set: - # acdc_df = posData.allData_li[frame_i]['acdc_df'] - - # lin_tree_df = self.lineage_tree.export_df(frame_i) - # if lin_tree_colnames is None: - # lin_tree_colnames = lin_tree_df.columns - - # acdc_df.loc[lin_tree_df.index, lin_tree_colnames] = lin_tree_df[lin_tree_colnames] - - # try: - # try: - # if (acdc_df['generation_num'] == 2).all() and not (acdc_df['generation_num_tree'].isna().all()): # check if generation_num is all just the default value and if yes, replace it with the tree values - # acdc_df['generation_num'] = acdc_df['generation_num_tree'] - # except KeyError: - # acdc_df['generation_num'] = acdc_df['generation_num_tree'] - # except Exception as e: - # self.logger.error(f'Error while syncing generation_num from lineage tree: {e} \n please save and restart') - - # posData.allData_li[frame_i]['acdc_df'] = acdc_df - # self.already_synced_lin_tree.add(frame_i) - - def turnOffAutoSaveWorker(self): - self.autoSaveToggle.setChecked(False) - - def autoSaveTimerTimedOut(self): - if not hasattr(self, 'data'): - # This happes when the self.autoSaveTimer times out after - # the GUI has been closed --> we simply ignore it - self.autoSaveTimer.stop() - return - - self.autoSaveTimer.stop() - self.flushDirtyPointsLayersAutosave() - self._enqueueAutoSave() - - def autoSaveTimerCountFrames(self): - if not hasattr(self, 'data'): - # This happes when the self.autoSaveTimer times out after - # the GUI has been closed --> we simply ignore it - return - - posData = self.data[self.pos_i] - autoSaveIntevalValue, autoSaveIntervalUnit = ( - self.autoSaveIntevalValueUnit - ) - isTimeToAutoSave = ( - abs(posData.frame_i - self.autoSaveTimeStartFrameIdx) - >= autoSaveIntevalValue - ) - if not isTimeToAutoSave: - return - - self.autoSaveTimeStartFrameIdx = posData.frame_i - self.flushDirtyPointsLayersAutosave() - self._enqueueAutoSave() - - def enqAutosave(self): - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - if self.statusBarLabel.text().endswith('Autosaving...'): - self.statusBarLabel.setText( - self.statusBarLabel.text().replace(' | Autosaving...', '') - ) - return - - if not self.autoSaveActiveWorkers: - self.gui_createAutoSaveWorker() - - if not self.autoSaveActiveWorkers: - return - - if self.autoSaveTimer.isActive(): - return - - self._enqueueAutoSave() - autoSaveIntevalValue, autoSaveIntervalUnit = ( - self.autoSaveIntevalValueUnit - ) - if autoSaveIntevalValue == 0: - return - - try: - self.autoSaveTimer.timeout.disconnect() - except Exception as err: - pass - - - if autoSaveIntervalUnit == 'minutes': - autosave_interval_ms = round(autoSaveIntevalValue*60*1000) - self.autoSaveTimer.timeout.connect(self.autoSaveTimerTimedOut) - self.autoSaveTimer.start(autosave_interval_ms) - else: - self.startAutoSaveEveryNframesTimer() - - def startAutoSaveEveryNframesTimer(self): - posData = self.data[self.pos_i] - self.autoSaveTimeStartFrameIdx = posData.frame_i - self.autoSaveTimer.timeout.connect( - self.autoSaveTimerCountFrames - ) - self.autoSaveTimer.start(500) - - def _enqueueAutoSave(self): - if not self.statusBarLabel.text().endswith('Autosaving...'): - self.statusBarLabel.setText( - f'{self.statusBarLabel.text()} | Autosaving...' - ) - - timestamp = datetime.now().strftime(r'%H:%M:%S.%f')[:-3] - self.logger.info(f'Autosaving... - {timestamp}') - - posData = self.data[self.pos_i] - worker, thread = self.autoSaveActiveWorkers[-1] - worker.enqueue(posData) - - def enqCcaIntegrityChecker(self): - if not self.ccaCheckerRunning: - return - posData = self.data[self.pos_i] - self.ccaIntegrityCheckerWorker.enqueue(posData) - - def drawAllMothBudLines(self): - posData = self.data[self.pos_i] - for obj in posData.rp: - self.drawObjMothBudLines(obj, posData, ax=0) - self.drawObjMothBudLines(obj, posData, ax=1) - - def drawObjMothBudLines(self, obj, posData, ax=0): - areMothBudLinesRequested = self.areMothBudLinesRequested(ax) - if not areMothBudLinesRequested: - return - - if posData.cca_df is None: - return - - mode = str(self.modeComboBox.currentText()) - if mode == 'Normal division: Lineage Tree': - return - - ID = obj.label - try: - cca_df_ID = posData.cca_df.loc[ID] - except KeyError: - return - - isObjVisible = self.isObjVisible(obj.bbox) - if not isObjVisible: - return - - ccs_ID = cca_df_ID['cell_cycle_stage'] - if ccs_ID == 'G1': - return - - relationship = cca_df_ID['relationship'] - if relationship != 'bud': - return - - emerg_frame_i = cca_df_ID['emerg_frame_i'] - isNew = emerg_frame_i == posData.frame_i - scatterItem = self.getMothBudLineScatterItem(ax, isNew) - relative_ID = cca_df_ID['relative_ID'] - - try: - relative_rp_idx = posData.IDs_idxs[relative_ID] - except KeyError: - return - - relative_ID_obj = posData.rp[relative_rp_idx] - y1, x1 = self.getObjCentroid(obj.centroid) - y2, x2 = self.getObjCentroid(relative_ID_obj.centroid) - xx, yy = core.get_line(y1, x1, y2, x2, dashed=True) - scatterItem.addPoints(xx, yy) - - def clearAllCellToCellLines(self): - self.ax1_newMothBudLinesItem.setData([], []) - self.ax1_oldMothBudLinesItem.setData([], []) - self.ax2_newMothBudLinesItem.setData([], []) - self.ax2_oldMothBudLinesItem.setData([], []) - - def drawAllLineageTreeLines(self): - """ - Draw all lineage tree lines on the GUI. - - This method retrieves the lineage tree data and draws the lineage tree lines - connecting cells and their respective mothers when the mother has split. - """ - if self.lineage_tree is None: - return - - if len(self.lineage_tree.frames_for_dfs) < 2: - return - - self.clearAllCellToCellLines() - posData = self.data[self.pos_i] - frame_i = posData.frame_i - lin_tree_df = posData.allData_li[frame_i]['acdc_df'] - lin_tree_df_prev = posData.allData_li[frame_i-1]['acdc_df'] - rp = posData.rp - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - - self.setTitleText() - - new_cells = lin_tree_df.index.difference(lin_tree_df_prev.index) # I could use this for the if already but this is probably faster for frames where nothing changes - if new_cells.shape[0] == 0: - return - - for ax in (0, 1): - if not self.areMothBudLinesRequested(ax): - continue - - for ID in new_cells: - curr_obj = myutils.get_obj_by_label(rp, ID) - lin_tree_df_ID = lin_tree_df.loc[ID] - - # lin_tree_df_mother_ID = lin_tree_df_prev.loc[lin_tree_df_ID["parent_ID_tree"]] - if lin_tree_df_ID["parent_ID_tree"] == -1: # make sure that new obj where the parents are not known get skipped - continue - - mother_obj = myutils.get_obj_by_label(prev_rp, lin_tree_df_ID["parent_ID_tree"]) - - emerg_frame_i = lin_tree_df_ID["emerg_frame_i"] - isNew = emerg_frame_i == frame_i - - self.drawObjLin_TreeMothBudLines(ax, curr_obj, mother_obj, isNew, ID=ID) - - def drawObjLin_TreeMothBudLines(self, ax, obj, mother_obj, isNew, ID=None): - """ - Draw moth-bud lines between an object and its mother object. - - Parameters - ---------- - ax : cellacdc.widgets.MainPlotItem - The Cell-ACDC GUI axes object to draw on. - obj : Object - The object for which to draw the moth-bud lines. - mother_obj : Object - The mother object to connect with. - isNew : bool - Indicates whether the object is new or not. - ID : int, optional - The ID of the object, by default None. - """ - if not self.areMothBudLinesRequested(ax): - return - - if not ID: - ID = obj.label - - isObjVisible = self.isObjVisible(obj.bbox) - - if not isObjVisible: - return - - scatterItem = self.getMothBudLineScatterItem(ax, isNew) - - y1, x1 = self.getObjCentroid(obj.centroid) - y2, x2 = self.getObjCentroid(mother_obj.centroid) - xx, yy = core.get_line(y1, x1, y2, x2, dashed=True) - scatterItem.addPoints(xx, yy) - - def getObjCentroid(self, obj_centroid): - if self.isSegm3D: - depthAxes = self.switchPlaneCombobox.depthAxes() - zc, yc, xc = obj_centroid - if depthAxes == 'z': - return yc, xc - elif depthAxes == 'y': - return zc, xc - else: - return zc, yc - else: - return obj_centroid - - def getAnnotateHowRightImage(self): - if not self.labelsGrad.showRightImgAction.isChecked(): - return 'nothing' - - if self.rightBottomGroupbox.isChecked(): - how = self.annotateRightHowCombobox.currentText() - else: - how = self.drawIDsContComboBox.currentText() - return how - - def getObjOptsSegmLabels(self, obj): - if not self.labelsGrad.showLabelsImgAction.isChecked(): - return - - objOpts = self.getObjTextAnnotOpts(obj, 'Draw only IDs', ax=1) - return objOpts - - def store_zslices_rp(self, force_update=False): - if not self.isSegm3D: - return - - posData = self.data[self.pos_i] - are_zslices_rp_stored = ( - posData.allData_li[posData.frame_i].get('z_slices_rp') is not None - ) - if force_update or not are_zslices_rp_stored: - self._update_zslices_rp() - - posData.allData_li[posData.frame_i]['z_slices_rp'] = posData.zSlicesRp - - def removeObjectFromRp(self, delID): - posData = self.data[self.pos_i] - rp = [] - IDs = [] - IDs_idxs = {} - idx = 0 - for obj in posData.rp: - if obj.label == delID: - continue - rp.append(obj) - IDs.append(obj.label) - IDs_idxs[obj.label] = idx - idx += 1 - - posData.rp = rp - posData.IDs = IDs - posData.IDs_idxs = IDs_idxs - - if not self.isSegm3D: - return - - zSlicesRp = {} - for z, zSliceRp in posData.zSlicesRp.items(): - if delID in zSliceRp: - continue - - zSlicesRp[z] = zSlicesRp - - posData.zSlicesRp = zSlicesRp - self.store_zslices_rp(force_update=True) - - def get_zslices_rp(self): - if not self.isSegm3D: - return - - posData = self.data[self.pos_i] - self.store_zslices_rp() - posData.zSlicesRp = posData.allData_li[posData.frame_i]['z_slices_rp'] - - # @exec_time - def _update_zslices_rp(self): - if not self.isSegm3D: - return - - posData = self.data[self.pos_i] - posData.zSlicesRp = {} - for z, lab2d in enumerate(posData.lab): - lab2d_rp = skimage.measure.regionprops(lab2d) - posData.zSlicesRp[z] = {obj.label:obj for obj in lab2d_rp} - - def instructHowDeleteID(self): - if 'showInfoDeleteObject' not in self.df_settings.index: - self.df_settings.at['showInfoDeleteObject', 'value'] = 'Yes' - - showInfoDeleteObject = ( - self.df_settings.at['showInfoDeleteObject', 'value'] == 'Yes' - ) - if not showInfoDeleteObject: - return - - actionText = self.middleClickText() - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - 'You have deleted an object using the eraser tool.

' - 'Did you know that you can use the "Delete object" action
' - 'to delete an object with a single click?

' - f'To do so, use the following action: {actionText}

' - 'Note: You can also set a custom shortcut by going to the menu
' - 'Settings --> Customise keyboard shortcuts....' - ) - doNotShowAgainCheckbox = QCheckBox('Do not show again') - msg.information( - self, 'Delete objects with single click', txt, - widgets=doNotShowAgainCheckbox - ) - - showInfoDeleteObjectValue = ( - 'No' if doNotShowAgainCheckbox.isChecked() else 'Yes' - ) - self.df_settings.at['showInfoDeleteObject', 'value'] = ( - showInfoDeleteObjectValue - ) - self.df_settings.to_csv(settings_csv_path) - - - def checkWarnDeletedIDwithEraser(self): - posData = self.data[self.pos_i] - - for ID in self.erasedIDs: - if ID == 0: - continue - if ID in posData.IDs_idxs: - continue - - self.instructHowDeleteID() - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete ID with eraser') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Delete ID with eraser') - - return True - - return False - - @exception_handler - def update_rp( - self, draw=True, debug=False, update_IDs=True, - wl_update=True, wl_track_og_curr=False,wl_update_lab=False - ): - - posData = self.data[self.pos_i] - # Update rp for current posData.lab (e.g. after any change) - - if wl_update: - if self.whitelistOriginalIDs is None: - old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() # for whitelist stuff - else: - old_IDs = self.whitelistOriginalIDs.copy() - self.whitelistOriginalIDs = None - elif self.whitelistOriginalIDs is None: - self.whitelist_old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() - - posData.rp = skimage.measure.regionprops(posData.lab) - if update_IDs: - IDs = [] - IDs_idxs = {} - for idx, obj in enumerate(posData.rp): - IDs.append(obj.label) - IDs_idxs[obj.label] = idx - posData.IDs = IDs - posData.IDs_idxs = IDs_idxs - self.update_rp_metadata(draw=draw) - self.store_zslices_rp(force_update=True) - - if not wl_update: - return - - # Update tracking whitelist - accepted_lost_centroids = self.getTrackedLostIDs() - new_IDs = posData.IDs - added_IDs = set(new_IDs) - set(old_IDs) - removed_IDs = ( - set(old_IDs) - - set(new_IDs) - - set(accepted_lost_centroids) - ) - - self.whitelistPropagateIDs( - IDs_to_add=added_IDs, IDs_to_remove=removed_IDs, - curr_frame_only=True, IDs_curr=new_IDs, - track_og_curr=wl_track_og_curr, - curr_lab=posData.lab, curr_rp=posData.rp, - update_lab=wl_update_lab - ) - - def extendLabelsLUT(self, lenNewLut): - posData = self.data[self.pos_i] - # Build a new lut to include IDs > than original len of lut - if lenNewLut > len(self.lut): - numNewColors = lenNewLut-len(self.lut) - # Index original lut - _lut = np.zeros((lenNewLut, 3), np.uint8) - _lut[:len(self.lut)] = self.lut - # Pick random colors and append them at the end to recycle them - randomIdx = np.random.randint(0,len(self.lut),size=numNewColors) - for i, idx in enumerate(randomIdx): - rgb = self.lut[idx] - _lut[len(self.lut)+i] = rgb - self.lut = _lut - self.initLabelsImageItems() - return True - return False - - def initLookupTableLab(self): - self.img2.setLookupTable(self.lut) - self.img2.setLevels([0, len(self.lut)]) - self.initLabelsImageItems() - - def getLabelsImageLut(self): - lut = np.zeros((len(self.lut), 4), dtype=np.uint8) - lut[:,-1] = 255 - lut[:,:-1] = self.lut - lut[0] = [0,0,0,0] - return lut - - def initLabelsImageItems(self): - lut = self.getLabelsImageLut() - self.labelsLayerImg1.setLevels([0, len(lut)]) - self.labelsLayerRightImg.setLevels([0, len(lut)]) - self.labelsLayerImg1.setLookupTable(lut) - self.labelsLayerRightImg.setLookupTable(lut) - alpha = self.imgGrad.labelsAlphaSlider.value() - self.labelsLayerImg1.setOpacity(alpha) - self.labelsLayerRightImg.setOpacity(alpha) - - def initKeepObjLabelsLayers(self): - lut = np.zeros((len(self.lut), 4), dtype=np.uint8) - lut[:,:-1] = self.lut - lut[:,-1:] = 255 - lut[0] = [0,0,0,0] - self.keepIDsTempLayerLeft.setLevels([0, len(lut)]) - self.keepIDsTempLayerLeft.setLookupTable(lut) - - - def updateTempLayerKeepIDs(self): - if not self.keepIDsButton.isChecked(): - return - - keptLab = np.zeros_like(self.currentLab2D) - - posData = self.data[self.pos_i] - for obj in posData.rp: - if obj.label not in self.keptObjectsIDs: - continue - - if not self.isObjVisible(obj.bbox): - continue - - _slice = self.getObjSlice(obj.slice) - _objMask = self.getObjImage(obj.image, obj.bbox) - - keptLab[_slice][_objMask] = obj.label - - self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) - - def highlightLabelID(self, ID, ax=0): - posData = self.data[self.pos_i] - try: - obj = posData.rp[posData.IDs_idxs[ID]] - except KeyError: - return - - self.textAnnot[ax].highlightObject(obj) - - def _keepObjects(self, keepIDs=None, lab=None, rp=None): - posData = self.data[self.pos_i] - if lab is None: - lab = posData.lab - - if rp is None: - rp = posData.rp - - if keepIDs is None: - keepIDs = self.keptObjectsIDs - - for obj in rp: - if obj.label in keepIDs: - continue - - lab[obj.slice][obj.image] = 0 - - return lab - - def clearHighlightedText(self): - pass - - def removeHighlightLabelID(self, IDs=None, ax=0): - posData = self.data[self.pos_i] - if IDs is None: - IDs = posData.IDs - - for ID in IDs: - obj = posData.rp[posData.IDs_idxs[ID]] - self.textAnnot[ax].removeHighlightObject(obj) - - def updateKeepIDs(self, IDs): - posData = self.data[self.pos_i] - - self.clearHighlightedText() - - isAnyIDnotExisting = False - # Check if IDs from line edit are present in current keptObjectIDs list - for ID in IDs: - if ID not in posData.allIDs: - isAnyIDnotExisting = True - continue - if ID not in self.keptObjectsIDs: - self.keptObjectsIDs.append(ID, editText=False) - self.highlightLabelID(ID) - - # Check if IDs in current keptObjectsIDs are present in IDs from line edit - for ID in self.keptObjectsIDs: - if ID not in posData.allIDs: - isAnyIDnotExisting = True - continue - if ID not in IDs: - self.keptObjectsIDs.remove(ID, editText=False) - - self.updateTempLayerKeepIDs() - if isAnyIDnotExisting: - self.keptIDsLineEdit.warnNotExistingID() - else: - self.keptIDsLineEdit.setInstructionsText() - - @exception_handler - def applyKeepObjects(self): - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - self._keepObjects() - self.highlightHoverIDsKeptObj(0, 0, hoverID=0) - - posData = self.data[self.pos_i] - - self.update_rp() - # Repeat tracking - self.tracking(enforce=True, assign_unique_new_IDs=False) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Deleted non-selected objects') - self.updateAllImages() - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - return - else: - removeAnnot = self.warnEditingWithCca_df( - 'Deleted non-selected objects', get_answer=True - ) - if not removeAnnot: - # We can propagate changes only if the user agrees on - # removing annotations - return - - self.current_frame_i = posData.frame_i - if posData.frame_i > 0: - txt = html_utils.paragraph(""" - Do you want to remove un-kept objects in the past frames too? - """) - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - _, _, applyToPastButton = msg.question( - self, 'Propagate to past frames?', txt, - buttonsTexts=('Cancel', 'No', 'Yes, apply to past frames') - ) - if msg.cancel: - return - if msg.clickedButton == applyToPastButton: - self.store_data() - self.logger.info('Applying keep objects to past frames...') - if not removeAnnot and posData.cca_df is not None: - delIDs = [ - ID for ID in posData.cca_df.index - if ID not in posData.IDs - ] - self.update_cca_df_deletedIDs(posData, delIDs) - - for i in tqdm(range(posData.frame_i), ncols=100): - lab = posData.allData_li[i]['labels'] - rp = posData.allData_li[i]['regionprops'] - keepLab = self._keepObjects(lab=lab, rp=rp) - # Store change - posData.allData_li[i]['labels'] = keepLab.copy() - # Get the rest of the stored metadata based on the new lab - posData.frame_i = i - self.get_data() - self.store_data(autosave=False) - - posData.frame_i = self.current_frame_i - self.get_data() - - # Ask to propagate change to all future visited frames - key = 'Keep ID' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - self.keptObjectsIDs, key, doNotShow, - posData.UndoFutFrames_keepID, posData.applyFutFrames_keepID, - force=True, applyTrackingB=True - ) - - if UndoFutFrames is None: - # Empty keep object list - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - return - - posData.doNotShowAgain_keepID = doNotShowAgain - posData.UndoFutFrames_keepID = UndoFutFrames - posData.applyFutFrames_keepID = applyFutFrames - includeUnvisited = posData.includeUnvisitedInfo['Keep ID'] - - if applyFutFrames: - self.store_data() - - self.logger.info('Applying to future frames...') - pbar = tqdm(total=posData.SizeT-posData.frame_i-1, ncols=100) - segmSizeT = len(posData.segm_data) - if not removeAnnot and posData.cca_df is not None: - delIDs = [ - ID for ID in posData.cca_df.index - if ID not in posData.IDs - ] - self.update_cca_df_deletedIDs(posData, delIDs) - - for i in range(posData.frame_i+1, segmSizeT): - lab = posData.allData_li[i]['labels'] - if lab is None and not includeUnvisited: - self.enqAutosave() - pbar.update(posData.SizeT-i) - break - - rp = posData.allData_li[i]['regionprops'] - - if lab is not None: - keepLab = self._keepObjects(lab=lab, rp=rp) - # Store change - posData.allData_li[i]['labels'] = keepLab.copy() - # Get the rest of the stored metadata based on the new lab - posData.frame_i = i - self.get_data() - self.store_data(autosave=False) - elif includeUnvisited: - # Unvisited frame (includeUnvisited = True) - lab = posData.segm_data[i] - rp = skimage.measure.regionprops(lab) - keepLab = self._keepObjects(lab=lab, rp=rp) - posData.segm_data[i] = keepLab - - pbar.update() - pbar.close() - - # Back to current frame - if applyFutFrames: - posData.frame_i = self.current_frame_i - self.get_data() - - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - - def updateLookuptable(self, lenNewLut=None, delIDs=None): - posData = self.data[self.pos_i] - if lenNewLut is None: - try: - if delIDs is None: - IDs = posData.IDs - else: - # Remove IDs removed with ROI from LUT - IDs = [ID for ID in posData.IDs if ID not in delIDs] - lenNewLut = max(IDs, default=0) + 1 - except ValueError: - # Empty segmentation mask - lenNewLut = 1 - # Build a new lut to include IDs > than original len of lut - updateLevels = self.extendLabelsLUT(lenNewLut) - lut = self.lut.copy() - - try: - # lut = self.lut[:lenNewLut].copy() - for ID in posData.binnedIDs: - lut[ID] = lut[ID]*0.2 - - for ID in posData.ripIDs: - lut[ID] = lut[ID]*0.2 - except Exception as e: - err_str = traceback.format_exc() - print('='*30) - self.logger.info(err_str) - print('='*30) - - if updateLevels: - self.img2.setLevels([0, len(lut)]) - - if self.keepIDsButton.isChecked(): - lut = np.round(lut*0.3).astype(np.uint8) - keptLut = np.round(lut[self.keptObjectsIDs]/0.3).astype(np.uint8) - lut[self.keptObjectsIDs] = keptLut - - self.img2.setLookupTable(lut) - - # @exec_time - def update_rp_metadata(self, draw=True): - posData = self.data[self.pos_i] - # Add to rp dynamic metadata (e.g. cells annotated as dead) - for i, obj in enumerate(posData.rp): - ID = obj.label - obj.excluded = ID in posData.binnedIDs - obj.dead = ID in posData.ripIDs - - def annotate_rip_and_bin_IDs(self, updateLabel=False): - depthAxes = self.switchPlaneCombobox.depthAxes() - if self.switchPlaneCombobox.isEnabled() and depthAxes != 'z': - return - - posData = self.data[self.pos_i] - binnedIDs_xx = [] - binnedIDs_yy = [] - ripIDs_xx = [] - ripIDs_yy = [] - for obj in posData.rp: - obj.excluded = obj.label in posData.binnedIDs - obj.dead = obj.label in posData.ripIDs - if not self.isObjVisible(obj.bbox): - continue - - if obj.excluded: - y, x = self.getObjCentroid(obj.centroid) - binnedIDs_xx.append(x) - binnedIDs_yy.append(y) - if updateLabel: - self.getObjOptsSegmLabels(obj) - how = self.drawIDsContComboBox.currentText() - - if obj.dead: - y, x = self.getObjCentroid(obj.centroid) - ripIDs_xx.append(x) - ripIDs_yy.append(y) - if updateLabel: - self.getObjOptsSegmLabels(obj) - how = self.drawIDsContComboBox.currentText() - - self.ax2_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) - self.ax2_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) - self.ax1_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) - self.ax1_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) - - def loadNonAlignedFluoChannel(self, fluo_path): - posData = self.data[self.pos_i] - if posData.filename.find('aligned') != -1: - filename, _ = os.path.splitext(os.path.basename(fluo_path)) - path = f'.../{posData.pos_foldername}/Images/{filename}_aligned.npz' - msg = widgets.myMessageBox() - msg.critical( - self, 'Aligned fluo channel not found!', - 'Aligned data for fluorescence channel not found!\n\n' - f'You loaded aligned data for the cells channel, therefore ' - 'loading NON-aligned fluorescence data is not allowed.\n\n' - 'Run the script "dataPrep.py" to create the following file:\n\n' - f'{path}' - ) - return None - fluo_data = np.squeeze(skimage.io.imread(fluo_path)) - return fluo_data - - def load_fluo_data(self, fluo_path, isGuiThread=True): - self.logger.info(f'Loading fluorescence image data from "{fluo_path}"...') - bkgrData = None - posData = self.data[self.pos_i] - # Load overlay frames and align if needed - filename = os.path.basename(fluo_path) - filename_noEXT, ext = os.path.splitext(filename) - if ext == '.npy' or ext == '.npz': - fluo_data = np.load(fluo_path) - try: - fluo_data = np.squeeze(fluo_data['arr_0']) - except Exception as e: - fluo_data = np.squeeze(fluo_data) - - # Load background data - bkgrData_path = os.path.join( - posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' - ) - if os.path.exists(bkgrData_path): - bkgrData = np.load(bkgrData_path) - elif ext == '.tif' or ext == '.tiff': - aligned_filename = f'{filename_noEXT}_aligned.npz' - aligned_path = os.path.join(posData.images_path, aligned_filename) - if os.path.exists(aligned_path): - fluo_data = np.load(aligned_path)['arr_0'] - - # Load background data - bkgrData_path = os.path.join( - posData.images_path, f'{aligned_filename}_bkgrRoiData.npz' - ) - if os.path.exists(bkgrData_path): - bkgrData = np.load(bkgrData_path) - else: - fluo_data = self.loadNonAlignedFluoChannel(fluo_path) - if fluo_data is None: - return None, None - - # Load background data - bkgrData_path = os.path.join( - posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' - ) - if os.path.exists(bkgrData_path): - bkgrData = np.load(bkgrData_path) - elif isGuiThread: - txt = html_utils.paragraph( - f'File format {ext} is not supported!\n' - 'Choose either .tif or .npz files.' - ) - msg = widgets.myMessageBox() - msg.critical(self, 'File not supported', txt) - return None, None - - return fluo_data, bkgrData - - def setOverlayColors(self): - self.overlayRGBs = [ - (255, 255, 0), - (252, 72, 254), - (49, 222, 134), - (22, 108, 27) - ] - self.overlayCmap = matplotlib.colormaps['hsv'] - self.overlayRGBs.extend( - [tuple([round(c*255) for c in self.overlayCmap(i)][:3]) - for i in np.linspace(0,1,8)] - ) - - def getFileExtensions(self, images_path): - alignedFound = any([f.find('_aligned.np')!=-1 - for f in myutils.listdir(images_path)]) - if alignedFound: - extensions = ( - 'Aligned channels (*npz *npy);; Tif channels(*tiff *tif)' - ';;All Files (*)' - ) - else: - extensions = ( - 'Tif channels(*tiff *tif);; All Files (*)' - ) - return extensions - - def loadOverlayData(self, ol_channels, addToExisting=False): - posData = self.data[self.pos_i] - for ol_ch in ol_channels: - if ol_ch not in list(posData.loadedFluoChannels): - # Requested channel was never loaded --> load it at first - # iter i == 0 - success = self.loadFluo_cb(fluo_channels=[ol_ch]) - if not success: - return False - - lastChannelName = ol_channels[-1] - for action in self.fluoDataChNameActions: - if action.text() == lastChannelName: - action.setChecked(True) - - for p, posData in enumerate(self.data): - if addToExisting: - ol_data = posData.ol_data - else: - ol_data = {} - for i, ol_ch in enumerate(ol_channels): - _, filename = self.getPathFromChName(ol_ch, posData) - ol_data[filename] = ( - posData.ol_data_dict[filename].copy() - ) - self.addFluoChNameContextMenuAction(ol_ch) - posData.ol_data = ol_data - - return True - - def askSelectOverlayChannel(self): - ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] - selectFluo = widgets.QDialogListbox( - 'Select channel', - 'Select channel names to overlay:\n', - ch_names, multiSelection=True, parent=self - ) - selectFluo.exec_() - if selectFluo.cancel: - return - - return selectFluo.selectedItemsText - - def overlayLabels_cb(self, checked, selectedLabelsEndnames=None): - if checked: - if not self.drawModeOverlayLabelsChannels: - if selectedLabelsEndnames is None: - selectedLabelsEndnames = self.askLabelsToOverlay() - if selectedLabelsEndnames is None: - self.logger.info('Overlay labels cancelled.') - self.overlayLabelsButton.setChecked(False) - return - for selectedEndname in selectedLabelsEndnames: - self.loadOverlayLabelsData(selectedEndname) - for action in self.overlayLabelsContextMenu.actions(): - if not action.isCheckable(): - continue - if action.text() == selectedEndname: - action.setChecked(True) - lastSelectedName = selectedLabelsEndnames[-1] - for action in self.selectOverlayLabelsActionGroup.actions(): - if action.text() == lastSelectedName: - action.setChecked(True) - self.updateAllImages() - - def askLabelsToOverlay(self): - selectOverlayLabels = widgets.QDialogListbox( - 'Select segmentation to overlay', - 'Select segmentation file to overlay:\n', - natsorted(self.existingSegmEndNames), - multiSelection=True, - parent=self - ) - selectOverlayLabels.exec_() - if selectOverlayLabels.cancel: - return - - return selectOverlayLabels.selectedItemsText - - def closeToolbars(self): - for toolbar in self.sender().toolbars: - toolbar.setVisible(False) - for action in toolbar.actions(): - try: - action.button.setChecked(False) - except Exception as e: - pass - - def askSaveAddedPoints(self): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - 'Do you want to save the annotated points?' - ) - _, noButton, yesButton = msg.question( - self, 'Save?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') - ) - if msg.clickedButton != yesButton: - return - - for toolbar in self.pointsLayersToolbars: - for action in self.pointsLayersToolbar.actions(): - try: - if 'Save annotated' in action.text(): - action.trigger() - except Exception as err: - pass - - def pointsLayerToggled(self, checked): - if not checked: - for action in self.pointsLayersToolbar.actions(): - try: - if 'Save annotated' in action.text(): - self.askSaveAddedPoints() - break - except Exception as err: - pass - self.pointsLayersToolbar.setVisible(checked) - self.autoPilotZoomToObjToolbar.setVisible(checked) - if self.pointsLayersNeverToggled: - self.pointsLayersToolbar.sigAddPointsLayer.emit() - self.pointsLayersNeverToggled = False - QTimer.singleShot(200, self.autoRange) - - def addPointsLayerTriggered(self, checked=False, toolbar=None): - if toolbar is None: - toolbar = self.pointsLayersToolbar - - if self.addPointsWin is not None: - self.logger.info( - 'Add points layer window is already open. Cannot add now.' - ) - return - - onlyMouseClicks = toolbar == self.promptSegmentPointsLayerToolbar - posData = self.data[self.pos_i] - self.addPointsWin = apps.AddPointsLayerDialog( - channelNames=posData.chNames, - imagesPath=posData.images_path, - hideCentroidsSection=onlyMouseClicks, - hideWeightedCentroidsSection=onlyMouseClicks, - hideFromTableSection=onlyMouseClicks, - hideManualEntrySection=onlyMouseClicks, - hideWithMouseClicksSection=False, - parent=self, - ) - cmap = matplotlib.colormaps['gist_rainbow'] - i = np.random.default_rng(seed=123).uniform() - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - rgb = [round(c*255) for c in cmap(i)][:3] - self.addPointsWin.appearanceGroupbox.colorButton.setColor(rgb) - break - - self.addPointsWin.sigCriticalReadTable.connect(self.logger.info) - self.addPointsWin.sigLoadedTable.connect(self.logLoadedTablePointsLayer) - self.addPointsWin.sigClosed.connect( - partial(self.addPointsLayer, toolbar=toolbar) - ) - self.addPointsWin.sigCheckClickEntryTableEndnameExists.connect( - self.checkClickEntryTableEndnameExists - ) - self.addPointsWin.show() - if self.addPointsWin.clickEntryRadiobutton.isChecked(): - QTimer.singleShot( - 200, - partial( - self.addPointsWin.sigCheckClickEntryTableEndnameExists.emit, - self.addPointsWin.clickEntryTableEndname.text(), - False - ) - ) - - def logLoadedTablePointsLayer(self, df, filename: str): - separator = f'-'*100 - header = f'First 10 rows of loaded table - "{filename}":' - footer = f'Number of points: {len(df)}' - text = ( - f'{separator}\n' - f'{header}\n\n' - f'{df.head(10)}\n\n' - f'{footer}\n' - f'{separator}' - ) - if filename: - text = f'{text}\nFilename: {filename}' - self.logger.info(text) - - def buttonAddPointsByClickingActive(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - if action.layerTypeIdx == 4 and action.button.isChecked(): - return action.button - - def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): - self.LeftClickButtons.append(toolButton) - posData = self.data[self.pos_i] - tableEndName = self.addPointsWin.clickEntryTableEndnameText - if isLoadedDf is not None: - posData = self.data[self.pos_i] - tableEndName = tableEndName[len(posData.basename):] - self.loadClickEntryDfs(tableEndName) - - toolButton.toolbar = toolbar - toolButton.clickEntryTableEndName = tableEndName - self.checkableQButtonsGroup.addButton(toolButton) - toolButton.toggled.connect(self.addPointsByClickingButtonToggled) - - self.addPointsByClickingButtonToggled(sender=toolButton) - - toolButton.setToolTip(tableEndName) - - pointIdSpinbox = widgets.SpinBox() - pointIdSpinbox.setMinimum(0) - pointIdSpinbox.setValue(1) - pointIdSpinbox.label = QLabel(' Left-click ID: ') - pointIdSpinbox.labelAction = toolbar.addWidget(pointIdSpinbox.label) - if toolbar == self.promptSegmentPointsLayerToolbar: - newID = self.setBrushID(return_val=True) - pointIdSpinbox.setValue(newID) - pointIdSpinbox.setReadOnly(True) - pointIdSpinbox.setToolTip( - 'The ids added with left-click cannot be manually edited. ' - 'They are always a new, non-existing id.' - ) - - toolButton.actions.append(pointIdSpinbox.labelAction) - pointIdSpinbox.action = toolbar.addWidget(pointIdSpinbox) - toolButton.actions.append(pointIdSpinbox.action) - pointIdSpinbox.toolButton = toolButton - toolButton.pointIdSpinbox = pointIdSpinbox - - rightClickIDSpinbox = widgets.SpinBox() - pointIdSpinbox.setLinkedValueWidget(rightClickIDSpinbox) - rightClickIDSpinbox.setMaximumWidth(pointIdSpinbox.sizeHint().width()) - rightClickIDSpinbox.setValue(pointIdSpinbox.value()) - rightClickIDSpinbox.setMinimum(0) - rightClickIDSpinbox.label = QLabel(' | Right-click ID: ') - rightClickIDSpinbox.labelAction = toolbar.addWidget( - rightClickIDSpinbox.label - ) - toolButton.actions.append(rightClickIDSpinbox.labelAction) - rightClickIDSpinbox.action = toolbar.addWidget(rightClickIDSpinbox) - toolButton.actions.append(rightClickIDSpinbox.action) - rightClickIDSpinbox.toolButton = toolButton - toolButton.rightClickIDSpinbox = rightClickIDSpinbox - - saveToolbutton = widgets.SavePointsLayerButton( - tableEndName, parent=self - ) - saveToolbutton.sigRenameTableAction.connect( - self.updatePointsLayerClickEntryTableEndname - ) - saveToolbutton.sigLeftClick.connect(self.savePointsAddedByClicking) - saveAction = toolbar.addWidget(saveToolbutton) - saveToolbutton.action = saveAction - saveAction.saveToolbutton = saveToolbutton - saveAction.toolButton = toolButton - toolButton.saveAction = saveAction - toolButton.saveToolbutton = saveToolbutton - - toolButton.actions.append(saveAction) - - vlineAction = toolbar.addWidget(widgets.QVLine()) - spacerAction = toolbar.addWidget( - widgets.QHWidgetSpacer(width=5) - ) - - toolButton.actions.append(vlineAction) - toolButton.actions.append(spacerAction) - - action = toolButton.action - scatterItem = action.scatterItem - scatterItem.sigHoverEntered.connect( - self.addPointsByClickingScatterItemHoverEntered - ) - - self.pointsLayerClicksDfsToData(posData, toolbar=toolbar) - - def storeUndoAddPoint(self, action): - if not hasattr(self, 'undoAddPointQueueMapper'): - self.undoAddPointQueueMapper = defaultdict(list) - - posData = self.data[self.pos_i] - pointsDataPos = action.pointsData.get(self.pos_i) - if pointsDataPos is None: - return - - state = deepcopy(pointsDataPos) - self.undoAddPointQueueMapper[action].append(state) - self.undoAction.setEnabled(True) - - def undoAddPoint(self, action): - undoAddPointQueue = self.undoAddPointQueueMapper.get(action) - if undoAddPointQueue is None: - return False - - if len(undoAddPointQueue) == 0: - return False - - posData = self.data[self.pos_i] - state = undoAddPointQueue.pop(-1) - action.pointsData[self.pos_i] = state - self.markPointsLayerDirty(action=action) - - self.drawPointsLayers(computePointsLayers=False) - - if len(self.undoAddPointQueueMapper[action]) == 0: - self.undoAction.setEnabled(True) - - return True - - def getAddedPointId( - self, isMagicPrompts, addPointsByClickingButton, - right_click, left_click, middle_click - ): - action = addPointsByClickingButton.action - if right_click: - id = addPointsByClickingButton.rightClickIDSpinbox.value() - elif left_click: - id = addPointsByClickingButton.pointIdSpinbox.value() - id = self.getClickedPointNewId( - action, id, addPointsByClickingButton.pointIdSpinbox, - isMagicPrompts=isMagicPrompts - ) - if isMagicPrompts: - proceed = self.warnAddingPointWithExistingId(id) - if not proceed: - return - - addPointsByClickingButton.pointIdSpinbox.setValue(id) - elif middle_click: - id = 0 - - return id - - def addPointsByClickingScatterItemHoverEntered(self, item, points, event): - point = points[0] - point_id = point.data() - toolButton = item.action.button - toolButton.rightClickIDSpinbox.prevId = ( - toolButton.rightClickIDSpinbox.value() - ) - toolButton.rightClickIDSpinbox.setValue(point_id) - - def autoPilotZoomToObjToggled(self, checked): - if not checked: - self.zoomOut() - return - - posData = self.data[self.pos_i] - if not posData.IDs: - self.logger.info('There are no objects in current segmentation mask') - return - self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) - self.zoomToObj(posData.rp[0]) - - def savePointsAddedByClickingFromEndname(self, tableEndName, recovery=False): - self.pointsLayerDataToDf(self.data[self.pos_i]) - for posData in self.data: - if not posData.basename.endswith('_'): - basename = f'{posData.basename}_' - else: - basename = posData.basename - tableFilename = f'{basename}{tableEndName}.csv' - if recovery: - tableFilepath = os.path.join( - posData.recoveryFolderpath(), tableFilename - ) - else: - tableFilepath = os.path.join(posData.images_path, tableFilename) - df = posData.clickEntryPointsDfs.get(tableEndName) - if df is None: - continue - df = df.sort_values(['frame_i', 'Cell_ID']) - df.to_csv(tableFilepath, index=False) - - def markPointsLayerDirty(self, tableEndName=None, action=None): - if tableEndName is None and action is not None: - tableEndName = getattr(action, 'clickEntryTableEndName', None) - - if tableEndName is None: - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - if addPointsByClickingButton is None: - return - tableEndName = addPointsByClickingButton.clickEntryTableEndName - - self.dirtyPointsLayerTableEndNames.add(tableEndName) - - def flushDirtyPointsLayersAutosave(self): - if not self.dirtyPointsLayerTableEndNames: - return - - for tableEndName in tuple(self.dirtyPointsLayerTableEndNames): # avoid runtime error - self.savePointsAddedByClickingFromEndname( - tableEndName, recovery=True - ) - - self.dirtyPointsLayerTableEndNames.clear() - - @exception_handler - def savePointsAddedByClicking(self, button, event): - sender = button.action - toolButton = sender.toolButton - tableEndName = toolButton.clickEntryTableEndName - - self.logger.info(f'Saving _{tableEndName}.csv table...') - - self.savePointsAddedByClickingFromEndname(tableEndName) - - self.logger.info(f'{tableEndName}.csv saved!') - self.titleLabel.setText(f'{tableEndName}.csv saved!', color='g') - - def updatePointsLayerClickEntryTableEndname( - self, saveToolbutton, table_endname - ): - saveAction = saveToolbutton.action - toolButton = saveAction.toolButton - toolButton.clickEntryTableEndName = table_endname - - self.logger.info( - f'Done. Click entry table endname updated to "{table_endname}"' - ) - - def pointsLayerDfsToData(self, posData): - self.pointsLayerClicksDfsToData(posData) - - def pointsLayerLoadedDfsToData(self): - posData = self.data[self.pos_i] - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'loadedDfInfo'): - continue - - if action.loadedDfInfo is None: - continue - - endname = action.loadedDfInfo.get('endname') - if endname is None: - continue - - filename = f'{posData.basename}{endname}' - filepath = os.path.join(posData.images_path, filename) - if not os.path.exists(filepath): - action.pointsData[self.pos_i] = {} - - df = load.load_df_points_layer(filepath) - action.pointsData[self.pos_i] = ( - load.loaded_df_to_points_data( - df, action.loadedDfInfo['t'], action.loadedDfInfo['z'], - action.loadedDfInfo['y'], action.loadedDfInfo['x'] - ) - ) - self.logLoadedTablePointsLayer(df, filename=filename) - - def setPointsLayerLoadedDfEndanme(self, action): - if action.loadedDfInfo is None: - return - - posData = self.data[self.pos_i] - images_path = posData.images_path.replace('\\', '/') - - df_folderpath = os.path.dirname( - action.loadedDfInfo['filepath'].replace('\\', '/') - ) - - if images_path != df_folderpath: - return - - df_filename = os.path.basename(action.loadedDfInfo['filepath']) - - if not df_filename.startswith(posData.basename): - return - - endname = df_filename[len(posData.basename):] - action.loadedDfInfo['endname'] = endname - - action.button.setToolTip(endname) - - def pointsLayerClicksDfsToData(self, posData, toolbar=None): - if toolbar is None: - toolbar = self.pointsLayersToolbar - - for action in toolbar.actions()[1:]: - if not hasattr(action, 'button'): - continue - - if not hasattr(action.button, 'clickEntryTableEndName'): - continue - tableEndName = action.button.clickEntryTableEndName - action.pointsData[self.pos_i] = {} - if posData.clickEntryPointsDfs.get(tableEndName) is None: - continue - - df = posData.clickEntryPointsDfs[tableEndName] - - if posData.SizeZ > 1 and df['z'].isna().any(): - self.warnLoadedPointsTableIsNot3D(tableEndName) - return - - for frame_i, df_frame in df.groupby('frame_i'): - action.pointsData[self.pos_i][frame_i] = {} - if posData.SizeZ > 1: - for z, df_zlice in df_frame.groupby('z'): - xx = df_zlice['x'].to_list() - yy = df_zlice['y'].to_list() - ids = df_zlice['id'].to_list() - action.pointsData[self.pos_i][frame_i][z] = { - 'x': xx, 'y': yy, 'id': ids - } - else: - xx = df_frame['x'].to_list() - yy = df_frame['y'].to_list() - ids = df_frame['id'].to_list() - action.pointsData[self.pos_i][frame_i] = { - 'x': xx, 'y': yy, 'id': ids - } - - def pointsLayerDataToDf(self, posData, getOnlyActive=False, toolbar=None): - df = None - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'button'): - continue - if not hasattr(action.button, 'clickEntryTableEndName'): - continue - - tableEndName = action.button.clickEntryTableEndName - if getOnlyActive and not action.button.isChecked(): - continue - - df = toolbar.fromActionToDataFrame( - action, posData, isSegm3D=self.isSegm3D - ) - posData.clickEntryPointsDfs[tableEndName] = df - return df - - def restartZoomAutoPilot(self): - if not self.autoPilotZoomToObjToggle.isChecked(): - return - - posData = self.data[self.pos_i] - if not posData.IDs: - return - - self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) - self.zoomToObj(posData.rp[0]) - - def resizeRangeWelcomeText(self): - xRange, yRange = self.ax1.viewRange() - deltaX = xRange[1] - xRange[0] - deltaY = yRange[1] - yRange[0] - self.ax1.setXRange(0, deltaX) - self.ax1.setYRange(0, deltaY) - self.ax1.setLimits( - xMin=0, xMax=deltaX, yMin=0, yMax=deltaY - ) - # self.ax1.setXRange(0, 0) - # self.ax1.setYRange(0, 0) - - def zoomToObj(self, obj=None): - if not hasattr(self, 'data'): - return - posData = self.data[self.pos_i] - if obj is None: - ID = self.sender().value() - try: - ID_idx = posData.IDs_idxs[ID] - obj = obj = posData.rp[ID_idx] - except Exception as e: - self.logger.warning( - f'ID {ID} does not exist (add points by clicking)' - ) - - if obj is None: - return - - self.goToZsliceSearchedID(obj) - min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) - xRange = min_col-5, max_col+5 - yRange = max_row+5, min_row-5 - - self.ax1.setRange(xRange=xRange, yRange=yRange) - - def addPointsByClickingButtonToggled(self, checked=True, sender=None): - if sender is None: - sender = self.sender() - if not sender.isChecked(): - action = sender.action - action.scatterItem.setVisible(False) - return - - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(sender) - self.connectLeftClickButtons() - action = sender.action - action.scatterItem.setVisible(True) - self.ax1_BrushCircle.setBrush(action.brushColor) - self.ax1_BrushCircle.setPen(action.penColor) - - def autoZoomNextObj(self): - self.sender().setValue(self.sender().value() - 1) - self.pointsLayerAutoPilot('next') - self.setFocusMain() - self.setFocusGraphics() - - def autoZoomPrevObj(self): - self.sender().setValue(self.sender().value() + 1) - self.pointsLayerAutoPilot('prev') - self.setFocusMain() - self.setFocusGraphics() - - def pointsLayerAutoPilot(self, direction): - if not self.autoPilotZoomToObjToggle.isChecked(): - return - ID = self.autoPilotZoomToObjSpinBox.value() - posData = self.data[self.pos_i] - if not posData.IDs: - return - - try: - ID_idx = posData.IDs_idxs[ID] - if direction == 'next': - nextID_idx = ID_idx + 1 - else: - nextID_idx = ID_idx - 1 - obj = posData.rp[nextID_idx] - except Exception as e: - self.logger.info( - f'Auto-pilot restarted from first ID' - ) - obj = posData.rp[0] - - self.autoPilotZoomToObjSpinBox.setValue(obj.label) - self.zoomToObj(obj) - - def getClickEntryTableFilepaths(self, posData, tableEndName): - if posData.basename.endswith('_'): - basename = posData.basename - else: - basename = f'{posData.basename}_' - - csv_filename = f'{basename}{tableEndName}' - if not csv_filename.endswith('.csv'): - csv_filename = f'{csv_filename}.csv' - - filepath = os.path.join(posData.images_path, csv_filename) - recovery_filepath = os.path.join( - posData.images_path, 'recovery', csv_filename - ) - return filepath, recovery_filepath - - def getClickEntryNewerRecoveryFilepaths(self, tableEndName): - newer_recovery_filepaths = [] - for posData in self.data: - filepath, recovery_filepath = self.getClickEntryTableFilepaths( - posData, tableEndName - ) - if not os.path.exists(filepath) or not os.path.exists(recovery_filepath): - continue - - if os.path.getmtime(recovery_filepath) <= os.path.getmtime(filepath) + 15: # add a 15 second tolerance - continue - - newer_recovery_filepaths.append((filepath, recovery_filepath)) - - return newer_recovery_filepaths - - def askLoadNewerRecoveryClickEntryDfs( - self, tableEndName, newer_recovery_filepaths - ): - if not newer_recovery_filepaths: - return False - - num_tables = len(newer_recovery_filepaths) - filepath, recovery_filepath = newer_recovery_filepaths[0] - main_timestamp = datetime.fromtimestamp( - os.path.getmtime(filepath) - ).strftime('%a %d. %b. %y - %H:%M:%S') - recovery_timestamp = datetime.fromtimestamp( - os.path.getmtime(recovery_filepath) - ).strftime('%a %d. %b. %y - %H:%M:%S') - - if num_tables == 1: - text = html_utils.paragraph( - f'A newer recovery version of {tableEndName}.csv ' - 'was found.

' - f'Main table save date: {main_timestamp}
' - f'Recovery save date: {recovery_timestamp}

' - 'Do you want to load the newer recovery version?' - ) - else: - text = html_utils.paragraph( - f'Newer recovery versions of {tableEndName}.csv ' - f'were found for {num_tables} positions.

' - f'Example main table save date: {main_timestamp}
' - f'Example recovery save date: {recovery_timestamp}

' - 'Do you want to load the newer recovery version where available?' - ) - - msg = widgets.myMessageBox(wrapText=False) - _, yesButton, _ = msg.warning( - self.addPointsWin, 'Newer recovery table found', text, - buttonsTexts=('Cancel', 'Yes, load newer recovery', 'No, load main table') - ) - return msg.clickedButton == yesButton - - def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading): - doesTableExists = False - for posData in self.data: - filepath, _ = self.getClickEntryTableFilepaths(posData, tableEndName) - if os.path.exists(filepath): - doesTableExists = True - break - - if not doesTableExists: - return - - if not forceLoading: - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - f'The table {tableEndName}.csv already exists!

' - 'Do you want to load it?' - ) - _, yesButton, _ = msg.warning( - self.addPointsWin, 'Table exists!', txt, - buttonsTexts=('Cancel', 'Yes, load it', 'No, let me enter a new name') - ) - if msg.clickedButton != yesButton: - return - - newer_recovery_filepaths = self.getClickEntryNewerRecoveryFilepaths( - tableEndName - ) - load_recovery_if_newer = self.askLoadNewerRecoveryClickEntryDfs( - tableEndName, newer_recovery_filepaths - ) - - self.loadClickEntryDfs( - tableEndName, loadRecoveryIfNewer=load_recovery_if_newer - ) - - def checkLoadedTableIds(self, toolbar): - if toolbar != self.promptSegmentPointsLayerToolbar: - return True - - for posData in self.data: - for tableEndName, df in posData.clickEntryPointsDfs.items(): - for point_id in df['id'].values: - if point_id in posData.IDs_idxs: - proceed = self.warnAddingPointWithExistingId( - point_id, table_endname=tableEndName - ) - return proceed - - return True - - @exception_handler - def addPointsLayer(self, toolbar=None): - proceed = self.checkLoadedTableIds(toolbar) - - if self.addPointsWin.cancel or not proceed: - self.addPointsWin = None - self.logger.info('Adding points layer cancelled.') - return - - if toolbar is None: - toolbar = self.pointsLayersToolbar - - symbol = self.addPointsWin.symbol - color = self.addPointsWin.color - pointSize = self.addPointsWin.pointSize - zRadius = int((self.addPointsWin.zHeight-1)/2) - r,g,b,a = color.getRgb() - - scatterItem = widgets.PointsScatterPlotItem( - [], [], ax=self.ax1, symbol=symbol, pxMode=False, size=pointSize, - brush=pg.mkBrush(color=(r,g,b,100)), - pen=pg.mkPen(width=2, color=(r,g,b)), - hoverable=True, hoverBrush=pg.mkBrush((r,g,b,200)), - tip=None, show_data_as_tip=True - ) - self.ax1.addItem(scatterItem) - - toolButton = widgets.PointsLayerToolButton(symbol, color, parent=self) - toolButton.actions = [] - toolButton.setCheckable(True) - toolButton.setChecked(True) - if self.addPointsWin.keySequence is not None: - toolButton.setShortcut(self.addPointsWin.keySequence) - toolButton.toggled.connect(self.pointLayerToolbuttonToggled) - toolButton.sigEditAppearance.connect(self.editPointsLayerAppearance) - toolButton.sigShowIdsToggled.connect(self.showPointsLayerIdsToggled) - toolButton.sigRemove.connect( - partial(self.removePointsLayer, toolbar=toolbar) - ) - - action = toolbar.addWidget(toolButton) - action.state = self.addPointsWin.state() - - toolButton.action = action - action.brushColor = (r,g,b,100) - action.brushColorId0 = ( - *colors.hex_to_rgb( - colors.lighten_color( - np.array(action.brushColor)/255, 0.3 - ) - ), 100 - ) - action.penColor = (r,g,b) - action.penColorId0 = colors.lighten_color( - np.array(action.penColor)/255, 0.3 - ) - action.pointSize = pointSize - action.zRadius = zRadius - action.button = toolButton - action.scatterItem = scatterItem - scatterItem.action = action - action.layerType = self.addPointsWin.layerType - action.layerTypeIdx = self.addPointsWin.layerTypeIdx - action.loadedDf = self.addPointsWin.loadedDf - posData = self.data[self.pos_i] - action.pointsData = {} - action.pointsData[self.pos_i] = self.addPointsWin.pointsData - action.snapToMax = False - action.loadedDfInfo = self.addPointsWin.loadedDfInfo - self.setPointsLayerLoadedDfEndanme(action) - - if self.addPointsWin.layerType.startswith('Click to annotate point'): - action.snapToMax = self.addPointsWin.snapToMaxToggle.isChecked() - isLoadedDf = self.addPointsWin.clickEntryIsLoadedDf - self.setupAddPointsByClicking( - toolButton, isLoadedDf, toolbar=toolbar - ) - if self.addPointsWin.autoPilotToggle.isChecked(): - self.autoPilotZoomToObjToggle.setChecked(True) - - weighingChannel = self.addPointsWin.weighingChannel - self.loadPointsLayerWeighingData(action, weighingChannel) - - self.drawPointsLayers() - - if toolbar == self.promptSegmentPointsLayerToolbar: - self.promptSegmentPointsLayerToolbar.isPointsLayerInit = True - self.magicPromptsToolbar.clearPointsAction.setDisabled(False) - self.magicPromptsToolbar.clearPointsActionOnZoom.setDisabled(False) - QTimer.singleShot( - 200, self.magicPromptsToolbar.selectModelAction.trigger - ) - - self.addPointsWin = None - - def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): - for posData in self.data: - filepath, recovery_filepath = self.getClickEntryTableFilepaths( - posData, tableEndName - ) - - if loadRecoveryIfNewer: - recovery_exists = os.path.exists(recovery_filepath) - main_exists = os.path.exists(filepath) - if ( - recovery_exists - and ( - not main_exists - or os.path.getmtime(recovery_filepath) - > os.path.getmtime(filepath) + 15 - ) - ): - filepath = recovery_filepath - elif not main_exists: - continue - - if not os.path.exists(filepath): - continue - - self.logger.info(f'Loading points from "{filepath}"...') - df = pd.read_csv(filepath) - if 'id' not in df.columns: - df['id'] = range(1, len(df)+1) - posData.clickEntryPointsDfs[tableEndName] = df - - try: - self.addPointsWin.loadButton.confirmAction() - except Exception as err: - pass - - def removeClickedPoints(self, action, points): - posData = self.data[self.pos_i] - framePointsData = action.pointsData[self.pos_i][posData.frame_i] - if posData.SizeZ > 1: - zProjHow = self.zProjComboBox.currentText() - if zProjHow != 'single z-slice': - _warnings.warnCannotAddRemovePointsProjection() - return - zSlice = self.zSliceScrollBar.sliderPosition() - else: - zSlice = None - - removed_ids = [] - for point in points: - pos = point.pos() - x, y = pos.x(), pos.y() - if zSlice is not None: - zSliceRad = action.zRadius - sliceFramePointsData = [framePointsData[z] for z in range( - zSlice-zSliceRad, zSlice+zSliceRad+1 - ) if z in framePointsData.keys()] - else: - sliceFramePointsData = [framePointsData] - - - for sliceFramePointsData in sliceFramePointsData: - if point.data() in sliceFramePointsData['id']: - sliceFramePointsData['x'].remove(x) - sliceFramePointsData['y'].remove(y) - sliceFramePointsData['id'].remove(point.data()) - removed_ids.append(point.data()) - - if removed_ids: - self.markPointsLayerDirty(action=action) - - return removed_ids - - def restorePrevPointIdRightClick(self, addPointsByClickingButton): - # Try to restore the id that was there before hovering - # because the hovering was required only to delete the - # point - try: - prevId = addPointsByClickingButton.rightClickIDSpinbox.prevId - addPointsByClickingButton.rightClickIDSpinbox.setValue(prevId) - except Exception as err: - addPointsByClickingButton.rightClickIDSpinbox.prevId = None - - def getClickedPointNewId( - self, action, current_id, pointIdSpinbox, isMagicPrompts=False - ): - removed_id = getattr(pointIdSpinbox, 'removedId', None) - if removed_id is not None: - pointIdSpinbox.removedId = None - return removed_id - - posData = self.data[self.pos_i] - if isMagicPrompts: - is_already_new = self.isPointIdAlreadyNew(current_id, action) - if is_already_new: - return current_id - - new_ID = self.setBrushID(return_val=True) - new_id = max(current_id, new_ID) + 1 - return new_id - else: - pointsDataPos = action.pointsData.get(self.pos_i) - if pointsDataPos is None: - return 1 - - framePointsData = pointsDataPos.get(posData.frame_i) - if framePointsData is None: - return 1 - if posData.SizeZ > 1: - new_id = 1 - for z_data in framePointsData.values(): - max_id = max(z_data.get('id', 0), default=0) + 1 - if max_id > new_id: - new_id = max_id - else: - new_id = max(framePointsData.get('id', 0), default=0) + 1 - if current_id >= new_id: - return current_id - return new_id - - def setHoverCircleAddPoint(self, x, y): - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - if addPointsByClickingButton is None: - return - action = addPointsByClickingButton.action - self.setHoverToolSymbolData( - [x], [y], (self.ax1_BrushCircle,), - size=action.pointSize - ) - - def isPointIdAlreadyNew(self, point_id, action): - posData = self.data[self.pos_i] - if point_id in posData.IDs_idxs: - return False - - is_ID = point_id in posData.IDs_idxs - pointsDataPos = action.pointsData.get(self.pos_i) - if pointsDataPos is None: - return not is_ID - - framePointsData = pointsDataPos.get(posData.frame_i) - if framePointsData is None: - return not is_ID - - if 'x' not in framePointsData: - is_id_already_added = False - for z, z_data in framePointsData.items(): - if point_id in z_data['id']: - is_id_already_added = True - break - else: - is_id_already_added = point_id in framePointsData['id'] - - is_already_new = not is_ID and not is_id_already_added - return is_already_new - - def addClickedPoint(self, action, x, y, id): - x, y = round(x, 2), round(y, 2) - posData = self.data[self.pos_i] - pointsDataPos = action.pointsData.get(self.pos_i) - if pointsDataPos is None: - action.pointsData[self.pos_i] = {} - - framePointsData = action.pointsData[self.pos_i].get(posData.frame_i) - if action.snapToMax: - radius = round(action.pointSize/2) - rr, cc = skimage.draw.disk((round(y), round(x)), radius) - idx_max = (self.img1.image[rr, cc]).argmax() - y, x = rr[idx_max], cc[idx_max] - - if framePointsData is None: - if posData.SizeZ > 1: - zSlice = self.zSliceScrollBar.sliderPosition() - action.pointsData[self.pos_i][posData.frame_i] = { - zSlice: {'x': [x], 'y': [y], 'id': [id]} - } - else: - action.pointsData[self.pos_i][posData.frame_i] = { - 'x': [x], 'y': [y], 'id': [id] - } - else: - if posData.SizeZ > 1: - zSlice = self.zSliceScrollBar.sliderPosition() - z_data = framePointsData.get(zSlice) - if z_data is None: - framePointsData[zSlice] = {'x': [x], 'y': [y], 'id': [id]} - else: - framePointsData[zSlice]['x'].append(x) - framePointsData[zSlice]['y'].append(y) - framePointsData[zSlice]['id'].append(id) - action.pointsData[self.pos_i][posData.frame_i] = ( - framePointsData - ) - else: - pointsDataPos = action.pointsData[self.pos_i] - framePointsData = pointsDataPos[posData.frame_i] - framePointsData['x'].append(x) - framePointsData['y'].append(y) - framePointsData['id'].append(id) - - self.markPointsLayerDirty(action=action) - - def showPointsLayerIdsToggled(self, button, checked): - button.action.scatterItem.drawIds = checked - self.drawPointsLayers() - - def removePointsLayer(self, button, toolbar=None): - button.setChecked(False) - button.action.scatterItem.setData([], []) - button.action.loadedDfInfo = None - self.ax1.removeItem(button.action.scatterItem) - toolbar.removeAction(button.action) - for action in button.actions: - toolbar.removeAction(action) - - if toolbar == self.promptSegmentPointsLayerToolbar: - self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False - - def editPointsLayerAppearance(self, button): - win = apps.EditPointsLayerAppearanceDialog(parent=self) - win.restoreState(button.action.state) - win.exec_() - if win.cancel: - return - - symbol = win.symbol - color = win.color - pointSize = win.pointSize - zRadius = int((win.zHeight-1)/2) - r,g,b,a = color.getRgb() - - scatterItem = button.action.scatterItem - scatterItem.opts['hoverBrush'] = pg.mkBrush((r,g,b,200)) - scatterItem.setSymbol(symbol, update=False) - scatterItem.setBrush(pg.mkBrush(color=(r,g,b,100)), update=False) - scatterItem.setPen(pg.mkPen(width=2, color=(r,g,b)), update=False) - scatterItem.setSize(pointSize, update=True) - - button.action.brushColor = (r,g,b,100) - button.action.penColor = (r,g,b) - button.action.pointSize = pointSize - button.action.zRadius = zRadius - - button.action.state = win.state() - - def loadPointsLayerWeighingData(self, action, weighingChannel): - if not weighingChannel: - return - - self.logger.info(f'Loading "{weighingChannel}" weighing data...') - action.weighingData = [] - for p, posData in enumerate(self.data): - if weighingChannel == posData.user_ch_name: - wData = posData.img_data - action.weighingData.append(wData) - continue - - path, filename = self.getPathFromChName(weighingChannel, posData) - if path is None: - self.criticalFluoChannelNotFound(weighingChannel, posData) - action.weighingData = [] - return - - if filename in posData.fluo_data_dict: - # Weighing data already loaded as additional fluo channel - wData = posData.fluo_data_dict[filename] - else: - # Weighing data never loaded --> load now - wData, _ = self.load_fluo_data(path) - if posData.SizeT == 1: - wData = wData[np.newaxis] - action.weighingData.append(wData) - - def pointLayerToolbuttonToggled(self, checked): - action = self.sender().action - action.scatterItem.setVisible(checked) - - def getCentroidsPointsData(self, action): - # Centroids (either weighted or not) - # NOTE: if user requested to draw from table we load that in - # apps.AddPointsLayerDialog.ok_cb() - posData = self.data[self.pos_i] - action.pointsData[self.pos_i] = {posData.frame_i: {}} - if hasattr(action, 'weighingData'): - lab = posData.lab - img = action.weighingData[self.pos_i][posData.frame_i] - rp = skimage.measure.regionprops(lab, intensity_image=img) - attr = 'weighted_centroid' - else: - rp = posData.rp - attr = 'centroid' - for i, obj in enumerate(rp): - centroid = getattr(obj, attr) - if len(centroid) == 3: - zc, yc, xc = centroid - z_int = round(zc) - if z_int not in action.pointsData[self.pos_i][posData.frame_i]: - action.pointsData[self.pos_i][posData.frame_i][z_int] = { - 'x': [xc], 'y': [yc], 'id': [obj.label] - } - else: - z_data = action.pointsData[self.pos_i][posData.frame_i][z_int] - z_data['x'].append(xc) - z_data['y'].append(yc) - z_data['id'].append(obj.label) - else: - yc, xc = centroid - if 'y' not in action.pointsData[self.pos_i][posData.frame_i]: - action.pointsData[self.pos_i][posData.frame_i]['y'] = [yc] - action.pointsData[self.pos_i][posData.frame_i]['x'] = [xc] - action.pointsData[self.pos_i][posData.frame_i]['id'] = ( - [obj.label] - ) - else: - action.pointsData[self.pos_i][posData.frame_i]['y'].append(yc) - action.pointsData[self.pos_i][posData.frame_i]['x'].append(xc) - action.pointsData[self.pos_i][posData.frame_i]['id'].append( - obj.label - ) - - def drawPointsLayers(self, computePointsLayers=True): - posData = self.data[self.pos_i] - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - - if action.layerTypeIdx < 2 and computePointsLayers: - self.getCentroidsPointsData(action) - - if not action.button.isChecked(): - continue - - frames = action.pointsData.get(self.pos_i, set()) - if posData.frame_i not in frames: - if action.layerTypeIdx != 4: - self.logger.info( - f'Frame number {posData.frame_i+1} does not have any ' - f'"{action.layerType}" point to display.' - ) - continue - - framePointsData = action.pointsData[self.pos_i][posData.frame_i] - - if 'x' not in framePointsData: - # 3D points - zProjHow = self.zProjComboBox.currentText() - isZslice = ( - zProjHow == 'single z-slice' and posData.SizeZ > 1 - ) - if isZslice: - xx, yy, ids, data = [], [], [], [] - zSlice = self.zSliceScrollBar.sliderPosition() - zRadius = action.zRadius - zRange = range(zSlice-zRadius, zSlice+zRadius+1) - for z in zRange: - z_data = framePointsData.get(z) - if z_data is None: - continue - xx.extend(z_data['x']) - yy.extend(z_data['y']) - ids.extend(z_data['id']) - try: - data.extend(z_data['data']) - except KeyError as err: - # data is needed only for loaded tables - pass - else: - xx, yy, ids, data = [], [], [], [] - # z-projection --> draw all points - for z, z_data in framePointsData.items(): - xx.extend(z_data['x']) - yy.extend(z_data['y']) - ids.extend(z_data['id']) - try: - data.extend(z_data['data']) - except KeyError as err: - # data is needed only for loaded tables - pass - else: - # 2D segmentation - xx = framePointsData['x'] - yy = framePointsData['y'] - ids = framePointsData['id'] - try: - data = framePointsData['data'] - except KeyError as err: - # data is needed only for loaded tables - pass - - brushColors = [ - action.brushColor if id != 0 else action.brushColorId0 - for id in ids - ] - brushes = [pg.mkBrush(color) for color in brushColors] - - pensColor = [ - action.penColor if id != 0 else action.penColorId0 - for id in ids - ] - pens = [pg.mkPen(color) for color in pensColor] - - if action.layerTypeIdx == 2: - # For loaded table show the rest of the table as a tooltip - data = data - show_data_as_tip = True - else: - data = ids - show_data_as_tip = False - - xx = np.array(xx) # + 0.5 - yy = np.array(yy) # + 0.5 - - action.scatterItem.show_data_as_tip = show_data_as_tip - action.scatterItem.setData( - xx, yy, data=data, brush=brushes, pen=pens - ) - - def setOverlaySingleChannel(self, *args, **kwargs): - if self.overlayToolbar.isSingleChannel(): - self.overlayToolbarAreChannelsChecked = { - channel:toolbutton.isChecked() - for channel, toolbutton in self.allOverlayToolbuttons.items() - } - firstActiveToolbutton = [ - toolbutton for toolbutton in self.allOverlayToolbuttons.values() - if toolbutton.isChecked() - ][0] - firstActiveToolbutton.click() - else: - for ch, checked in self.overlayToolbarAreChannelsChecked.items(): - toolbutton = self.allOverlayToolbuttons[ch] - toolbutton.setChecked(checked) - - self.setOverlayItemsOpacities() - - def updateTransparentOverlayRgba(self, *args, **kwargs): - self.setOverlayImages() - - def setOverlayTransparency(self, transparent: bool): - opacity = float(transparent) - opacity = opacity if opacity < 1.0 else 0.999 - self.rgbaImg1.setOpacity(opacity) - - if transparent: - self.img1.setOpacity(0.001, applyToLinked=False) - self.imgGrad.sigLookupTableChanged.connect( - self.updateTransparentOverlayRgba - ) - self.imgGrad.sigLevelsChanged.connect( - self.updateTransparentOverlayRgba - ) - - for channel, items in self.overlayLayersItems.items(): - imageItem, lutItem, alphaSB = items[:3] - if transparent: - alphaSB.valueChanged.disconnect() - alphaSB.valueChanged.connect( - self.updateTransparentOverlayRgba - ) - lutItem.sigLookupTableChanged.connect( - self.updateTransparentOverlayRgba - ) - lutItem.sigLevelsChanged.connect( - self.updateTransparentOverlayRgba - ) - imageItem.setOpacity(0) - - if not transparent: - self.setOverlayItemsOpacities() - - self.setOverlayImages() - - def overlay_cb(self, checked): - self.overlayToolbar.setVisible(checked) - - self.UserNormAction, _, _ = self.getCheckNormAction() - posData = self.data[self.pos_i] - if checked: - if posData.ol_data is None: - selectedChannels = self.askSelectOverlayChannel() - if selectedChannels is None: - self.overlayButton.toggled.disconnect() - self.overlayButton.setChecked(False) - self.overlayButton.toggled.connect(self.overlay_cb) - return - - success = self.loadOverlayData(selectedChannels) - if not success: - return False - lastChannel = selectedChannels[-1] - self.setCheckedOverlayContextMenusActions(selectedChannels) - imageItem = self.overlayLayersItems[lastChannel][0] - self.setOpacityOverlayLayersItems(None, imageItem=imageItem) - self.setOverlayChannelsToolbuttonsChecked() - - self.setRetainSizePolicyLutItems() - self.normalizeRescale0to1Action.setChecked(True) - - self.updateAllImages() - self.updateImageValueFormatter() - self.enableOverlayWidgets(True) - else: - self.img1.setOpacity(1.0) - self.updateAllImages() - self.updateImageValueFormatter() - self.enableOverlayWidgets(False) - self.clearOverlayImageItems() - - - self.setOverlayItemsVisible() - - def countObjectsCb(self, checked): - if self.countObjsWindow is None: - categoryCountMapper = self.countObjects() - self.countObjsWindow = apps.ObjectCountDialog( - categoryCountMapper=categoryCountMapper, - parent=self, - data=self.data - ) - self.countObjsWindow.sigShowEvent.connect(self.updateObjectCounts) - self.countObjsWindow.sigUpdateCounts.connect(self.updateObjectCounts) - - if checked: - self.countObjsWindow.show() - else: - self.countObjsWindow.hide() - - def showLabelRoiContextMenu(self, event): - menu = QMenu(self.labelRoiButton) - action = QAction('Re-initialize magic labeller model...') - action.triggered.connect(self.initLabelRoiModel) - menu.addAction(action) - menu.exec_(QCursor.pos()) - - def initLabelRoiModel(self): - self.app.restoreOverrideCursor() - # Ask which model - self.initLabelRoiModelDialog = apps.QDialogSelectModel(parent=self) - self.initLabelRoiModelDialog.exec_() - if self.initLabelRoiModelDialog.cancel: - self.logger.info('Magic labeller aborted.') - self.initLabelRoiModelDialog = None - return True - self.app.setOverrideCursor(Qt.WaitCursor) - model_name = self.initLabelRoiModelDialog.selectedModel - self.labelRoiModel = self.repeatSegm( - model_name=model_name, askSegmParams=True, - is_label_roi=True - ) - if self.labelRoiModel is None: - self.initLabelRoiModelDialog = None - return True - self.labelRoiViewCurrentModelAction.setDisabled(False) - self.initLabelRoiModelDialog = None - return False - - def showOverlayContextMenu(self, event): - if not self.overlayButton.isChecked(): - return - - self.overlayContextMenu.exec_(QCursor.pos()) - - def showOverlayLabelsContextMenu(self, event): - if not self.overlayLabelsButton.isChecked(): - return - - self.overlayLabelsContextMenu.exec_(QCursor.pos()) - - def showInstructionsCustomModel(self): - modelFilePath = apps.addCustomModelMessages(self) - if modelFilePath is None: - self.logger.info('Adding custom model process stopped.') - return - - myutils.store_custom_model_path(modelFilePath) - modelName = os.path.basename(os.path.dirname(modelFilePath)) - customModelAction = QAction(modelName) - self.segmSingleFrameMenu.addAction(customModelAction) - self.segmActions.append(customModelAction) - self.segmActionsVideo.append(customModelAction) - self.modelNames.append(modelName) - self.models.append(None) - self.sender().callback(customModelAction) - - def showInstructionsCustomPromptModel(self): - modelFilePath = apps.addCustomPromptModelMessages(QParent=self) - if modelFilePath is None: - self.logger.info('Adding custom promptable model process stopped.') - return - - myutils.store_custom_promptable_model_path(modelFilePath) - - msg = widgets.myMessageBox(wrapText=False) - info_txt = html_utils.paragraph(f""" - Done!

- The custom promptable model has been added to the list of models.

- Use the Magic prompts button (top toolbar) to use it.

- Have fun! - """) - msg.information(self, 'Custom promptable model added', info_txt) - - def segmWithPromptableModelActionTriggered(self): - self.blinker = qutils.QControlBlink( - self.magicPromptsToolButton, qparent=self - ) - self.blinker.start() - - def setCheckedOverlayContextMenusActions(self, channelNames): - for action in self.overlayContextMenu.actions(): - if action.text() in channelNames: - action.setChecked(True) - self.checkedOverlayChannels.add(action.text()) - - def enableOverlayWidgets(self, enabled): - posData = self.data[self.pos_i] - if enabled: - self.overlayColorButton.setDisabled(False) - self.editOverlayColorAction.setDisabled(False) - - if posData.SizeZ == 1: - return - - self.zSliceOverlay_SB.setMaximum(posData.SizeZ-1) - if self.zProjOverlay_CB.currentText().find('max') != -1: - self.overlay_z_label.setDisabled(True) - self.zSliceOverlay_SB.setDisabled(True) - else: - z = self.zSliceOverlay_SB.sliderPosition() - self.overlay_z_label.setText(f'Overlay z-slice {z+1:02}/{posData.SizeZ}') - self.zSliceOverlay_SB.setDisabled(False) - self.overlay_z_label.setDisabled(False) - self.zSliceOverlay_SB.show() - self.overlay_z_label.show() - self.zProjOverlay_CB.show() - self.zSliceOverlay_SB.valueChanged.connect(self.updateOverlayZslice) - self.zProjOverlay_CB.currentTextChanged.connect(self.updateOverlayZproj) - self.zProjOverlay_CB.activated.connect(self.clearComboBoxFocus) - else: - self.zSliceOverlay_SB.setDisabled(True) - self.zSliceOverlay_SB.hide() - self.overlay_z_label.hide() - self.zProjOverlay_CB.hide() - self.overlayColorButton.setDisabled(True) - self.editOverlayColorAction.setDisabled(True) - - if posData.SizeZ == 1: - return - - self.zSliceOverlay_SB.valueChanged.disconnect() - self.zProjOverlay_CB.currentTextChanged.disconnect() - self.zProjOverlay_CB.activated.disconnect() - - - def criticalFluoChannelNotFound(self, fluo_ch, posData): - msg = widgets.myMessageBox(showCentered=False) - ls = "\n".join(myutils.listdir(posData.images_path)) - msg.setDetailedText( - f'Files present in the {posData.relPath} folder:\n' - f'{ls}' - ) - title = 'Requested channel data not found!' - txt = html_utils.paragraph( - f'The folder {posData.pos_path} ' - 'does not contain ' - 'either one of the following files:

' - f'{posData.basename}{fluo_ch}.tif
' - f'{posData.basename}{fluo_ch}_aligned.npz

' - 'Data loading aborted.' - ) - msg.addShowInFileManagerButton(posData.images_path) - okButton = msg.warning( - self, title, txt, buttonsTexts=('Ok') - ) - - def imgGradLUTfinished_cb(self): - posData = self.data[self.pos_i] - ticks = self.imgGrad.gradient.listTicks() - - self.img1ChannelGradients[self.user_ch_name] = { - 'ticks': [(x, t.color.getRgb()) for t,x in ticks], - 'mode': 'rgb' - } - - self.df_settings = self.imgGrad.saveState(self.df_settings) - self.df_settings.to_csv(self.settings_csv_path) - - def updateContColour(self, colorButton): - color = colorButton.color().getRgb() - self.df_settings.at['contLineColor', 'value'] = str(color) - self._updateContColour(color) - self.updateAllImages() - - def _updateContColour(self, color): - self.gui_createContourPens() - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.contoursColorButton.setColor(color) - - def saveContColour(self, colorButton): - self.df_settings.to_csv(self.settings_csv_path) - - def updateMothBudLineColour(self, colorButton): - color = colorButton.color().getRgb() - self.df_settings.at['mothBudLineColor', 'value'] = str(color) - self._updateMothBudLineColour(color) - self.updateAllImages() - - def _updateMothBudLineColour(self, color): - self.gui_createMothBudLinePens() - self.ax1_newMothBudLinesItem.setBrush(self.newMothBudLineBrush) - self.ax1_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush) - self.ax2_newMothBudLinesItem.setBrush(self.newMothBudLineBrush) - self.ax2_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush) - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.mothBudLineColorButton.setColor(color) - - def saveMothBudLineColour(self, colorButton): - self.df_settings.to_csv(self.settings_csv_path) - - def contLineWeightToggled(self, checked=True): - if not checked: - return - self.imgGrad.uncheckContLineWeightActions() - w = self.sender().lineWeight - self.df_settings.at['contLineWeight', 'value'] = w - self.df_settings.to_csv(self.settings_csv_path) - self._updateContLineThickness() - self.updateAllImages() - - def _updateContLineThickness(self): - self.gui_createContourPens() - for act in self.imgGrad.contLineWightActionGroup.actions(): - if act == self.sender(): - act.setChecked(True) - act.toggled.connect(self.contLineWeightToggled) - - def mothBudLineWeightToggled(self, checked=True): - if not checked: - return - self.imgGrad.uncheckContLineWeightActions() - w = self.sender().lineWeight - self.df_settings.at['mothBudLineSize', 'value'] = w - self.df_settings.to_csv(self.settings_csv_path) - self._updateMothBudLineSize(w) - self.updateAllImages() - - def _updateMothBudLineSize(self, size): - self.gui_createMothBudLinePens() - - for act in self.imgGrad.mothBudLineWightActionGroup.actions(): - if act == self.sender(): - act.setChecked(True) - act.toggled.connect(self.mothBudLineWeightToggled) - - self.ax1_oldMothBudLinesItem.setSize(size) - self.ax1_newMothBudLinesItem.setSize(size) - self.ax2_oldMothBudLinesItem.setSize(size) - self.ax2_newMothBudLinesItem.setSize(size) - - def getOlImg(self, key, frame_i=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - img = posData.ol_data[key][frame_i] - if posData.SizeZ > 1: - zProjHow = self.zProjOverlay_CB.currentText() - z = self.zSliceOverlay_SB.sliderPosition() - if zProjHow == 'same as above': - zProjHow = self.zProjComboBox.currentText() - z = self.zSliceScrollBar.sliderPosition() - reconnect = False - try: - self.zSliceOverlay_SB.valueChanged.disconnect() - reconnect = True - except TypeError: - pass - self.zSliceOverlay_SB.setSliderPosition(z) - if reconnect: - self.zSliceOverlay_SB.valueChanged.connect( - self.updateOverlayZslice - ) - if zProjHow == 'single z-slice': - self.overlay_z_label.setText(f'Overlay z-slice {z+1:02}/{posData.SizeZ}') - ol_img = img[z].copy() - elif zProjHow == 'max z-projection': - ol_img = img.max(axis=0) - elif zProjHow == 'mean z-projection': - ol_img = img.mean(axis=0) - elif zProjHow == 'median z-proj.': - ol_img = np.median(img, axis=0) - else: - ol_img = img.copy() - - return ol_img - - def setTextAnnotZsliceScrolling(self): - pass - - def setGraphicalAnnotZsliceScrolling(self): - posData = self.data[self.pos_i] - if self.isSegm3D: - self.currentLab2D = posData.lab[self.z_lab()] - self.setOverlaySegmMasks() - self.doCustomAnnotation(0) - self.update_rp_metadata() - else: - self.currentLab2D = posData.lab - self.setOverlaySegmMasks() - self.updateContoursImage(0) - self.updateContoursImage(1) - - def initContoursImage(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.contoursImage = np.zeros((Y, X, 4), dtype=np.uint8) - - def initDelRoiLab(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.delRoiLab = np.zeros((Y, X), dtype=np.uint32) - - def initLostObjContoursImage(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.lostObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) - - def initExportMaskImage(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.exportMaskImage = np.zeros((Y, X, 4), dtype=np.uint8) - - def initLostTrackedObjContoursImage(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.lostTrackedObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) - - def initManualBackgroundImage(self): - posData = self.data[self.pos_i] - if hasattr(posData, 'lab'): - Y, X = posData.lab.shape[-2:] - else: - Y, X = posData.img_data.shape[-2:] - if not hasattr(self, 'manualBackgroundTextItems'): - self.manualBackgroundTextItems = {} - posData.manualBackgroundImage = np.zeros((Y, X, 4), dtype=np.uint8) - if posData.manualBackgroundLab is None: - posData.manualBackgroundLab = np.zeros((Y, X), dtype=np.uint32) - - def initTextAnnot(self, force=False): - posData = self.data[self.pos_i] - if hasattr(posData, 'lab'): - Y, X = posData.lab.shape[-2:] - else: - Y, X = posData.img_data.shape[-2:] - self.textAnnot[0].initItem((Y, X)) - self.textAnnot[1].initItem((Y, X)) - - def getObjContours( - self, obj, all_external=False, local=False, force_calc=True, - include_internal=False - ): - posData = self.data[self.pos_i] - dataDict = posData.allData_li[posData.frame_i] - allContours = dataDict.get('contours') - if allContours is not None and not force_calc: - z = self.z_lab() - key = (obj.label, str(z), all_external, local) - contours = allContours.get(key) - if contours is not None: - return contours - - obj_image = self.getObjImage(obj.image, obj.bbox).astype(np.uint8) - obj_bbox = self.getObjBbox(obj.bbox) - try: - contours = core.get_obj_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, - local=local, - all_external=all_external - ) - except Exception as e: - if all_external: - contours = [] - else: - contours = None - self.logger.warning( - f'Object ID {obj.label} contours drawing failed. ' - f'(bounding box = {obj.bbox})' - ) - return contours - - def clearComputedContours(self): - for posData in self.data: - for frame_i, dataDict in enumerate(posData.allData_li): - dataDict['contours'] = {} - - def _computeAllContours2D( - self, dataDict, obj, z, obj_bbox, include_internal=False - ): - obj_image = self.getObjImage(obj.image, obj.bbox, z_slice=z) - if obj_image is None: - return - - all_external = False - local = False - contours = core.get_obj_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, - local=local, - all_external=all_external - ) - key = (obj.label, str(z), all_external, local) - dataDict['contours'][key] = contours - - all_external = True - local = False - contours = core.get_obj_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, - local=local, - all_external=all_external, - all=include_internal - ) - key = (obj.label, str(z), all_external, local) - dataDict['contours'][key] = contours - - return dataDict - - def computeAllContours(self): - self.logger.info('Computing all contours...') - posData = self.data[self.pos_i] - zz = [None] - if self.isSegm3D: - zz.extend(range(posData.SizeZ)) - - include_internal = self.showAllContoursToggle.isChecked() - for frame_i, dataDict in enumerate(posData.allData_li): - lab = dataDict['labels'] - if lab is None: - break - - rp = dataDict['regionprops'] - if rp is None: - rp = skimage.measure.regionprops(lab) - - dataDict['contours'] = {} - for obj in rp: - obj_bbox = self.getObjBbox(obj.bbox) - for z in zz: - if not self.isObjVisible(obj.bbox, z_slice=z): - continue - - try: - self._computeAllContours2D( - dataDict, obj, z, obj_bbox, - include_internal=include_internal - ) - except Exception as err: - # Contours computation fails on weird objects - pass - - def computeAllObjToObjCostPairs(self): - desc = ( - 'Computing all object-to-object cost matrices...' - ) - self.logger.info(desc) - posData = self.data[self.pos_i] - - - self.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.show(self.app) - - self.computeAllObjCostPairsThread = QThread() - self.computeAllObjCostPairsWorker = workers.SimpleWorker( - posData, self._computeAllObjToObjCostPairs - ) - - self.computeAllObjCostPairsWorker.moveToThread( - self.computeAllObjCostPairsThread - ) - - self.computeAllObjCostPairsWorker.signals.finished.connect( - self.computeAllObjCostPairsThread.quit - ) - self.computeAllObjCostPairsWorker.signals.finished.connect( - self.computeAllObjCostPairsWorker.deleteLater - ) - self.computeAllObjCostPairsThread.finished.connect( - self.computeAllObjCostPairsThread.deleteLater - ) - - self.computeAllObjCostPairsWorker.signals.critical.connect( - self.computeAllObjCostPairsWorkerCritical - ) - self.computeAllObjCostPairsWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.computeAllObjCostPairsWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.computeAllObjCostPairsWorker.signals.progress.connect( - self.workerProgress - ) - self.computeAllObjCostPairsWorker.signals.finished.connect( - self.computeAllObjCostPairsWorkerFinished - ) - - self.computeAllObjCostPairsThread.started.connect( - self.computeAllObjCostPairsWorker.run - ) - self.computeAllObjCostPairsThread.start() - - self.computeAllObjCostPairsWorkerLoop = QEventLoop() - self.computeAllObjCostPairsWorkerLoop.exec_() - - def _computeAllObjToObjCostPairs(self, posData): - self.computeAllObjCostPairsWorker.signals.initProgressBar.emit( - len(posData.allData_li) - ) - for frame_i, dataDict in enumerate(posData.allData_li): - if frame_i == 0: - continue - - rp = dataDict['regionprops'] - if rp is None: - break - - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - dist_matrix = core._compute_all_obj_to_obj_contour_dist_pairs( - dataDict['contours'], rp, - prev_rp=prev_rp, - restrict_search=True - ) - dataDict['obj_to_obj_dist_cost_matrix_df'] = dist_matrix - self.computeAllObjCostPairsWorker.signals.progressBar.emit(1) - self.computeAllObjCostPairsWorker.signals.initProgressBar.emit(0) - - def computeAllObjCostPairsWorkerCritical(self, error): - self.computeAllObjCostPairsWorkerLoop.exit() - self.workerCritical(error) - - def computeAllObjCostPairsWorkerFinished(self, output): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.computeAllObjCostPairsWorkerLoop.exit() - - def setOverlaySegmMasks(self, force=False, forceIfNotActive=False): - if not hasattr(self, 'currentLab2D'): - return - - how = self.drawIDsContComboBox.currentText() - isOverlaySegmLeftActive = how.find('overlay segm. masks') != -1 - - how_ax2 = self.getAnnotateHowRightImage() - isOverlaySegmRightActive = ( - how_ax2.find('overlay segm. masks') != -1 - and self.labelsGrad.showRightImgAction.isChecked() - ) - - isOverlaySegmActive = ( - isOverlaySegmLeftActive or isOverlaySegmRightActive - or force - ) - if not isOverlaySegmActive and not forceIfNotActive: - return - - alpha = self.imgGrad.labelsAlphaSlider.value() - if alpha == 0: - return - - posData = self.data[self.pos_i] - maxID = max(posData.IDs, default=0) - - if maxID >= len(self.lut): - self.extendLabelsLUT(maxID+10) - - currentLab2D = self.currentLab2D - if isOverlaySegmLeftActive: - self.labelsLayerImg1.setImage(currentLab2D, autoLevels=False) - - if isOverlaySegmRightActive: - self.labelsLayerRightImg.setImage(currentLab2D, autoLevels=False) - - def getObject2DimageFromZ(self, z, obj): - posData = self.data[self.pos_i] - z_min = obj.bbox[0] - local_z = z - z_min - if local_z >= posData.SizeZ or local_z < 0: - return - return obj.image[local_z] - - def getObject2DsliceFromZ(self, z, obj): - posData = self.data[self.pos_i] - z_min = obj.bbox[0] - local_z = z - z_min - if local_z >= posData.SizeZ or local_z < 0: - return - return obj.image[local_z] - - def isObjVisible(self, obj_bbox, debug=False, z_slice=None): - if z_slice is None: - z_slice = self.z_lab() - - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if not isZslice: - # required a projection --> all obj are visible - return True - - depthAxes = self.switchPlaneCombobox.depthAxes() - - min_z, min_y, min_x, max_z, max_y, max_x = obj_bbox - if depthAxes == 'z': - min_val, max_val = min_z, max_z - val = z_slice - elif depthAxes == 'y': - min_val, max_val = min_y, max_y - val = z_slice[-1] - else: - min_val, max_val = min_x, max_x - val = z_slice[-1] - - if val >= min_val and val < max_val: - return True - else: - return False - else: - return True - - def getObjImage(self, obj_image, obj_bbox, z_slice=None): - if self.isSegm3D and len(obj_bbox)==6: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if not isZslice: - # required a projection - return obj_image.max(axis=0) - - min_z = obj_bbox[0] - if z_slice is None: - z_slice = self.z_lab() - if isinstance(z_slice, tuple): - z_slice = z_slice[-1] - - local_z = z_slice - min_z - try: - obi_image_2d = obj_image[local_z] - except Exception as err: - obi_image_2d = None - return obi_image_2d - else: - return obj_image - - def getObjSlice(self, obj_slice): - if self.isSegm3D: - return obj_slice[1:3] - else: - return obj_slice - - def setOverlayImages(self, frame_i=None): - if not self.overlayButton.isChecked(): - return - - posData = self.data[self.pos_i] - if posData.ol_data is None: - return - - rgba_imgs_info = {} - for filename in posData.ol_data: - chName = myutils.get_chname_from_basename( - filename, posData.basename, remove_ext=False - ) - if chName not in self.checkedOverlayChannels: - continue - - items = self.overlayLayersItems[chName] - imageItem, lutItem, alphaSB = items[:3] - - ol_img = self.getOlImg(filename, frame_i=frame_i) - - if self.overlayToolbar.isTransparent(): - toolbutton = items[3] - if not toolbutton.isChecked(): - continue - alpha_val = alphaSB.value()/alphaSB.maximum() - ol_img = skimage.exposure.rescale_intensity( - ol_img, out_range=(0.0, 1.0) - ) - out_range_min, out_range_max = lutItem.getLevels() - rgba_imgs_info[chName] = (ol_img, alpha_val, lutItem) - else: - self.rescaleIntensitiesLut(setImage=False, imageItem=imageItem) - imageItem.setImage(ol_img) - - if not self.overlayToolbar.isTransparent(): - return - - alpha_values = [] - images = [] - luts = [] - for channel, info in rgba_imgs_info.items(): - ol_img, alpha_val, lutItem = info - alpha_values.append(alpha_val) - images.append(ol_img) - luts.append(lutItem.gradient.getLookupTable(256, alpha=255)/255) - - weights = colors.hierarchical_weights(alpha_values) - - if self.baseLayerToolbutton.isChecked(): - image1 = self._getImageupdateAllImages() - image1 = skimage.exposure.rescale_intensity( - image1, out_range=(0.0, 1.0) - ) - images.append(image1) - baseLut = ( - self.imgGrad.gradient.getLookupTable(256, alpha=255)/255 - ) - luts.append(baseLut) - - images_rgba = [] - for img, lut in zip(images, luts): - rgba = colors.grayscale_apply_lut(img, lut) - images_rgba.append(rgba) - - rgba_merge = colors.hierarchical_blend(images_rgba, weights) - self.rgbaImg1.setImage(rgba_merge) - - def getOpacitiesFromAlphaScrollbarValues(self): - alpha_values = [] - activeOverlayImageItems = [] - for items in self.overlayLayersItems.values(): - imgItem, lutItem, alphaSB = items[:3] - _toolbutton = alphaSB.toolbutton - if not _toolbutton.isChecked() or not _toolbutton.isVisible(): - continue - - alpha_values.append(alphaSB.value()/alphaSB.maximum()) - activeOverlayImageItems.append(imgItem) - - opacities = colors.hierarchical_weights(alpha_values)[::-1] - channel_opacity_mapper = {} - for i, imgItem in enumerate(activeOverlayImageItems): - channel_opacity_mapper[imgItem.channelName] = opacities[i+1] - - channel_opacity_mapper[self.user_ch_name] = opacities[0] - - return channel_opacity_mapper - - def initShortcuts(self): - from . import config - cp = config.ConfigParser() - if os.path.exists(shortcut_filepath): - cp.read(shortcut_filepath) - - if 'keyboard.shortcuts' not in cp: - cp['keyboard.shortcuts'] = {} - - if cp.has_option('keyboard.shortcuts', 'Zoom out'): - zoomOutKeyValueStr = cp['keyboard.shortcuts']['Zoom out'] - try: - self.zoomOutKeyValue = int(zoomOutKeyValueStr) - except Exception as err: - self.logger.warning( - f'{zoomOutKeyValueStr} is not a valid key ' - 'zooming out action. Restoring default key "H".' - ) - - if 'delete_object.action' not in cp: - self.delObjAction = None - else: - delObjKeySequenceText = cp['delete_object.action']['Key sequence'] - delObjButtonText = cp['delete_object.action']['Mouse button'] - delObjQtButton = ( - Qt.MouseButton.LeftButton if delObjButtonText == 'Left click' - else Qt.MouseButton.MiddleButton - ) - if not delObjKeySequenceText: - delObjKeySequence = None - else: - delObjKeySequence = widgets.KeySequenceFromText( - delObjKeySequenceText - ) - self.delObjToolAction.setChecked(True) - self.delObjAction = delObjKeySequence, delObjQtButton - - shortcuts = {} - for name, widget in self.widgetsWithShortcut.items(): - if name not in cp.options('keyboard.shortcuts'): - if hasattr(widget, 'keyPressShortcut'): - key = widget.keyPressShortcut - shortcut = widgets.KeySequenceFromText(key) - else: - shortcut = widget.shortcut() - shortcut_text = shortcut.toString() - cp['keyboard.shortcuts'][name] = shortcut_text - else: - shortcut_text = cp['keyboard.shortcuts'][name] - shortcut = widgets.KeySequenceFromText(shortcut_text) - - shortcuts[name] = (shortcut_text, shortcut) - self.setShortcuts(shortcuts, save=False) - with open(shortcut_filepath, 'w') as ini: - cp.write(ini) - - def setShortcuts(self, shortcuts: dict, save=True): - for name, (text, shortcut) in shortcuts.items(): - widget = self.widgetsWithShortcut[name] - if shortcut is None: - shortcut = QKeySequence() - if hasattr(widget, 'keyPressShortcut'): - widget.keyPressShortcut = shortcut - else: - widget.setShortcut(shortcut) - s = widget.toolTip() - toolTip = re.sub(r'Shortcut: "(.*)"', f'Shortcut: "{text}"', s) - widget.setToolTip(toolTip) - - if not save: - return - - from . import config - cp = config.ConfigParser() - if os.path.exists(shortcut_filepath): - cp.read(shortcut_filepath) - - if 'keyboard.shortcuts' not in cp: - cp['keyboard.shortcuts'] = {} - - for name, (text, shortcut) in shortcuts.items(): - cp['keyboard.shortcuts'][name] = text - - cp['keyboard.shortcuts']['Zoom out'] = str(self.zoomOutKeyValue) - - if self.delObjAction is None: - with open(shortcut_filepath, 'w') as ini: - cp.write(ini) - return - - delObjKeySequence, delObjQtButton = self.delObjAction - try: - if delObjKeySequence is None: - delObjKeySequenceText = '' - else: - delObjKeySequenceText = delObjKeySequence.toString() - - delObjKeySequenceText = ( - delObjKeySequenceText - .encode('ascii', 'ignore') - .decode('utf-8') - ) - delObjButtonText = ( - 'Left click' if delObjQtButton == Qt.MouseButton.LeftButton - else 'Middle click' - ) - cp['delete_object.action'] = { - 'Key sequence': delObjKeySequenceText, - 'Mouse button': delObjButtonText - } - except Exception as err: - self.logger.warning( - f'{delObjKeySequence} is not a valid keys sequence for ' - 'deleting objects. Setting default action' - ) - self.delObjAction = None - cp.remove_section('delete_object.action') - - with open(shortcut_filepath, 'w') as ini: - cp.write(ini) - - def editShortcuts_cb(self): - if is_mac: - delObjKeySequenceText = 'Ctrl' - delObjButtonText = 'Left click' - else: - delObjKeySequenceText = '' - delObjButtonText = 'Middle click' - - if self.delObjAction is not None: - delObjKeySequence, delObjQtButton = self.delObjAction - if delObjKeySequence is None: - delObjKeySequenceText = '' - else: - delObjKeySequenceText = delObjKeySequence.toString() - delObjKeySequenceText = ( - delObjKeySequenceText.encode('ascii', 'ignore').decode('utf-8') - ) - delObjButtonText = ( - 'Left click' if delObjQtButton == Qt.MouseButton.LeftButton - else 'Middle click' - ) - - win = apps.ShortcutEditorDialog( - self.widgetsWithShortcut, - delObjectKey=delObjKeySequenceText, - delObjectButton=delObjButtonText, - zoomOutKeyValue=self.zoomOutKeyValue, - parent=self - ) - win.exec_() - if win.cancel: - return - - self.delObjAction = win.delObjAction - self.zoomOutKeyValue = win.zoomOutKeyValue - self.setShortcuts(win.customShortcuts) - - def toggleOverlayColorButton(self, checked=True): - self.mousePressColorButton(None) - - def toggleTextIDsColorButton(self, checked=True): - self.textIDsColorButton.selectColor() - - def updateTextAnnotColor(self, button): - r, g, b = np.array(self.textIDsColorButton.color().getRgb()[:3]) - self.imgGrad.textColorButton.setColor((r, g, b)) - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.textColorButton.setColor((r, g, b)) - self.gui_createTextAnnotColors(r,g,b, custom=True) - self.gui_setTextAnnotColors() - self.updateAllImages() - - def saveTextIDsColors(self, button): - self.df_settings.at['textIDsColor', 'value'] = self.objLabelAnnotRgb - self.df_settings.to_csv(self.settings_csv_path) - - def setLut(self, shuffle=True): - self.lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) - if shuffle: - np.random.shuffle(self.lut) - - # Insert background color - if 'labels_bkgrColor' in self.df_settings.index: - rgbString = self.df_settings.at['labels_bkgrColor', 'value'] - try: - r, g, b = rgbString - except Exception as e: - r, g, b = colors.rgb_str_to_values(rgbString) - else: - r, g, b = 25, 25, 25 - self.df_settings.at['labels_bkgrColor', 'value'] = (r, g, b) - - self.lut = np.insert(self.lut, 0, [r, g, b], axis=0) - - def useCenterBrushCursorHoverIDtoggled(self, checked): - if checked: - self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' - else: - self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'No' - self.df_settings.to_csv(self.settings_csv_path) - - def shuffle_cmap(self): - np.random.shuffle(self.lut[1:]) - self.initLabelsImageItems() - self.updateAllImages() - - def setPermanentGreedyCmapPreferences(self): - if self.isSnapshot: - option_name = 'permanent_greedy_lut_snapshots' - else: - option_name = 'permanent_greedy_lut_timelapse' - - if option_name not in self.df_settings.index: - return - - checked = self.df_settings.at[option_name, 'value'] == 'yes' - self.labelsGrad.permanentGreedyCmapAction.setChecked(checked) - - def permanentGreedyCmapToggled(self, checked): - if checked: - settings_value = 'yes' - else: - self.setLut() - self.updateLookuptable() - self.initLabelsImageItems() - settings_value = 'no' - - self.updateAllImages() - - if self.isSnapshot: - option_name = 'permanent_greedy_lut_snapshots' - else: - option_name = 'permanent_greedy_lut_timelapse' - - self.df_settings.at[option_name, 'value'] = settings_value - self.df_settings.to_csv(self.settings_csv_path) - - def greedyShuffleCmap(self, updateImages=True): - lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) - greedy_lut = colors.get_greedy_lut(self.currentLab2D, lut) - self.lut = greedy_lut - self.initLabelsImageItems() - if updateImages: - self.updateAllImages() - - def highlightZneighLabels_cb(self, checked): - if checked: - pass - else: - pass - - def setTwoImagesLayout(self, isTwoImages): - self.isTwoImageLayout = isTwoImages - if isTwoImages: - self.graphLayout.removeItem(self.titleLabel) - self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) - # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignLeft) - self.ax2.show() - self.ax2.vb.setYLink(self.ax1.vb) - self.ax2.vb.setXLink(self.ax1.vb) - else: - self.graphLayout.removeItem(self.titleLabel) - self.graphLayout.addItem(self.titleLabel, row=0, col=1) - # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignCenter) - self.ax2.hide() - oldLink = self.ax2.vb.linkedView(self.ax1.vb.YAxis) - try: - oldLink.sigYRangeChanged.disconnect() - oldLink.sigXRangeChanged.disconnect() - except TypeError: - pass - - def showNextFrameImageItem(self, checked): - self.rightImageFramesScrollbar.setVisible(checked) - self.rightImageFramesScrollbar.setDisabled(not checked) - self.setTwoImagesLayout(checked) - if checked: - self.df_settings.at['isNextFrameVisible', 'value'] = 'Yes' - self.df_settings.at['isRightImageVisible', 'value'] = 'No' - self.df_settings.at['isLabelsVisible', 'value'] = 'No' - self.graphLayout.addItem( - self.imgGradRight, row=1, col=self.plotsCol+2 - ) - self.rightBottomGroupbox.show() - self.rightBottomGroupbox.setChecked(True) - self.drawNothingCheckboxRight.click() - if not self.isDataLoading: - self.updateAllImages() - else: - self.clearAx2Items() - self.rightBottomGroupbox.hide() - self.df_settings.at['isNextFrameVisible', 'value'] = 'No' - try: - self.graphLayout.removeItem(self.imgGradRight) - except Exception: - return - self.rightImageItem.clear() - - self.df_settings.to_csv(self.settings_csv_path) - - QTimer.singleShot(300, self.resizeGui) - - self.setBottomLayoutStretch() - - - def showRightImageItem(self, checked): - self.rightImageFramesScrollbar.setVisible(not checked) - self.rightImageFramesScrollbar.setDisabled(checked) - self.setTwoImagesLayout(checked) - if checked: - self.df_settings.at['isRightImageVisible', 'value'] = 'Yes' - self.df_settings.at['isNextFrameVisible', 'value'] = 'No' - self.df_settings.at['isLabelsVisible', 'value'] = 'No' - self.graphLayout.addItem( - self.imgGradRight, row=1, col=self.plotsCol+2 - ) - self.rightBottomGroupbox.show() - if not self.isDataLoading: - self.updateAllImages() - else: - self.clearAx2Items() - self.rightBottomGroupbox.hide() - self.df_settings.at['isRightImageVisible', 'value'] = 'No' - try: - self.graphLayout.removeItem(self.imgGradRight) - except Exception: - return - self.rightImageItem.clear() - - self.df_settings.to_csv(self.settings_csv_path) - - QTimer.singleShot(300, self.resizeGui) - - self.setBottomLayoutStretch() - - def showLabelImageItem(self, checked): - self.rightImageFramesScrollbar.setVisible(not checked) - self.rightImageFramesScrollbar.setDisabled(checked) - self.setTwoImagesLayout(checked) - self.setAnnotOptionsRightImageLabelsDisabled(checked) - if checked: - self.df_settings.at['isLabelsVisible', 'value'] = 'Yes' - self.df_settings.at['isNextFrameVisible', 'value'] = 'No' - self.df_settings.at['isRightImageVisible', 'value'] = 'No' - self.rightBottomGroupbox.show() - self.rightBottomGroupbox.setChecked(True) - if not self.isDataLoading: - self.updateAllImages() - else: - self.clearAx2Items() - self.img2.clear() - self.df_settings.at['isLabelsVisible', 'value'] = 'No' - self.rightBottomGroupbox.hide() - self.moveDelRoisToLeft() - - self.df_settings.to_csv(self.settings_csv_path) - QTimer.singleShot(200, self.resizeGui) - - self.setBottomLayoutStretch() - - def setAnnotOptionsRightImageLabelsDisabled(self, disabled): - self.annotContourCheckboxRight.setDisabled(disabled) - self.annotSegmMasksCheckboxRight.setDisabled(disabled) - if disabled: - self.annotSegmMasksCheckboxRight.setChecked(False) - self.annotSegmMasksCheckboxRight.setChecked(False) - self.annotIDsCheckboxRight.setChecked(True) - - def moveDelRoisToLeft(self): - # Move del ROIs to the left image - for posData in self.data: - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - for roi in delROIs_info['rois']: - if not self.ax2.isDelRoiItemPresent(roi): - continue - - self.ax1.addDelRoiItem(roi, roi.key) - self.ax2.removeDelRoiItem(roi) - - def setBottomLayoutStretch(self): - if ( - self.labelsGrad.showRightImgAction.isChecked() - or self.labelsGrad.showNextFrameAction.isChecked() - ): - # Equally share space between the two control groupboxes - self.bottomLayout.setStretch(1, 1) - self.bottomLayout.setStretch(2, 5) - self.bottomLayout.setStretch(3, 1) - self.bottomLayout.setStretch(4, 5) - self.bottomLayout.setStretch(5, 1) - elif self.labelsGrad.showLabelsImgAction.isChecked(): - # Left control takes only left space - self.bottomLayout.setStretch(1, 1) - self.bottomLayout.setStretch(2, 5) - self.bottomLayout.setStretch(3, 5) - self.bottomLayout.setStretch(4, 1) - self.bottomLayout.setStretch(5, 1) - else: - # Left control takes all the space - self.bottomLayout.setStretch(1, 3) - self.bottomLayout.setStretch(2, 10) - self.bottomLayout.setStretch(3, 1) - self.bottomLayout.setStretch(4, 1) - self.bottomLayout.setStretch(5, 1) - - def setCheckedInvertBW(self, checked): - self.invertBwAction.setChecked(checked) - - def ticksCmapMoved(self, gradient): - pass - # posData = self.data[self.pos_i] - # self.setLut(posData, shuffle=False) - # self.updateLookuptable() - - def updateLabelsCmap(self, gradient): - self.setLut() - self.updateLookuptable() - self.initLabelsImageItems() - - self.df_settings = self.labelsGrad.saveState(self.df_settings) - self.df_settings.to_csv(self.settings_csv_path) - - self.updateAllImages() - - def updateBkgrColor(self, button): - color = button.color().getRgb()[:3] - self.lut[0] = color - self.updateLookuptable() - - def updateTextLabelsColor(self, button): - self.ax2_textColor = button.color().getRgb()[:3] - posData = self.data[self.pos_i] - if posData.rp is None: - return - - for obj in posData.rp: - self.getObjOptsSegmLabels(obj) - - def saveTextLabelsColor(self, button): - color = button.color().getRgb()[:3] - self.df_settings.at['labels_text_color', 'value'] = color - self.df_settings.to_csv(self.settings_csv_path) - - def saveBkgrColor(self, button): - color = button.color().getRgb()[:3] - self.df_settings.at['labels_bkgrColor', 'value'] = color - self.df_settings.to_csv(self.settings_csv_path) - self.updateAllImages() - - def changeOverlayColor(self, button): - rgb = button.color().getRgb()[:3] - lutItem = self.overlayLayersItems[button.channel][1] - self.initColormapOverlayLayerItem(rgb, lutItem) - lutItem.overlayColorButton.setColor(rgb) - - def saveOverlayColor(self, button): - rgb = button.color().getRgb()[:3] - rgb_text = '_'.join([str(val) for val in rgb]) - self.df_settings.at[f'{button.channel}_rgb', 'value'] = rgb_text - self.df_settings.to_csv(self.settings_csv_path) - - def getImageDataFromFilename(self, filename): - posData = self.data[self.pos_i] - if filename == posData.filename: - return posData.img_data[posData.frame_i] - else: - return posData.ol_data_dict.get(filename) - - def z_slice_index(self): - posData = self.data[self.pos_i] - if posData.SizeZ == 1: - return None - zProjHow = self.zProjComboBox.currentText() - if zProjHow != 'single z-slice': - return zProjHow - - axis_slice = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == 'x': - z_slice = ( - slice(None, None, None), slice(None, None, None), axis_slice - ) - elif self.switchPlaneCombobox.depthAxes() == 'y': - z_slice = ( - slice(None, None, None), axis_slice - ) - else: - z_slice = axis_slice - - return z_slice - - def get_2Dimg_from_3D(self, imgData, isLayer0=True, frame_i=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - if frame_i < 0: - frame_i = 0 - frame_i = posData.frame_i = 0 - - axis_slice = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == 'x': - return imgData[:, :, axis_slice].copy() - elif self.switchPlaneCombobox.depthAxes() == 'y': - return imgData[:, axis_slice].copy() - - idx = (posData.filename, frame_i) - zProjHow_L0 = self.zProjComboBox.currentText() - if isLayer0: - try: - z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] - except ValueError as e: - z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] - zProjHow = zProjHow_L0 - else: - z = self.zSliceOverlay_SB.sliderPosition() - zProjHow_L1 = self.zProjOverlay_CB.currentText() - if zProjHow_L1 == 'same as above': - zProjHow = zProjHow_L0 - else: - zProjHow = zProjHow_L1 - - if zProjHow == 'single z-slice': - img = imgData[z] #.copy() - elif zProjHow == 'max z-projection': - img = imgData.max(axis=0) - elif zProjHow == 'mean z-projection': - img = imgData.mean(axis=0) - elif zProjHow == 'median z-proj.': - img = np.median(imgData, axis=0) - return img - - def updateZsliceScrollbar(self, frame_i): - posData = self.data[self.pos_i] - if self.switchPlaneCombobox.depthAxes() != 'z': - return - - idx = (posData.filename, frame_i) - try: - z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] - except ValueError as e: - z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] - try: - zProjHow = posData.segmInfo_df.at[idx, 'which_z_proj_gui'] - except ValueError as e: - zProjHow = posData.segmInfo_df.loc[idx, 'which_z_proj_gui'].iloc[0] - - self.zProjComboBox.setCurrentText(zProjHow) - - reconnect = False - try: - self.zSliceScrollBar.actionTriggered.disconnect() - self.zSliceScrollBar.sliderReleased.disconnect() - reconnect = True - except TypeError: - pass - self.zSliceScrollBar.setSliderPosition(z) - if reconnect: - self.zSliceScrollBar.actionTriggered.connect( - self.zSliceScrollBarActionTriggered - ) - self.zSliceScrollBar.sliderReleased.connect( - self.zSliceScrollBarReleased - ) - self.zSliceSpinbox.setValueNoEmit(z+1) - - def getRawImage(self, frame_i=None, filename=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - if filename is None: - rawImgData = posData.img_data[frame_i] - isLayer0 = True - else: - rawImgData = posData.ol_data[filename][frame_i] - isLayer0 = False - if posData.SizeZ > 1: - rawImg = self.get_2Dimg_from_3D(rawImgData, isLayer0=isLayer0) - else: - rawImg = rawImgData - return rawImg - - def getRawImageLayer0(self, frame_i): - posData = self.data[self.pos_i] - - if posData.SizeZ > 1: - img = posData.img_data[frame_i] - self.updateZsliceScrollbar(frame_i) - img = self.get_2Dimg_from_3D(img) - else: - img = posData.img_data[frame_i].copy() - - if img.ndim == 2: - return img - if img.ndim == 3 and img.shape[-1] in (3, 4): - return img - - raise ValueError( - 'Raw image for display must be 2D (Y, X) or RGB/A (Y, X, 3 or 4); ' - f'got shape={getattr(img, "shape", None)}, ndim={getattr(img, "ndim", None)} ' - f'for frame_i={frame_i} (metadata SizeT={posData.SizeT}, SizeZ={posData.SizeZ}). ' - 'Check that metadata SizeT/SizeZ matches the loaded array (e.g. squeezed TIFF vs CSV).' - ) - - def initFloodMaskImage(self): - posData = self.data[self.pos_i] - self.flood_img = posData.img_data[posData.frame_i] - if not self.isSegm3D and posData.SizeZ > 1: - self.flood_img = self.get_2Dimg_from_3D(self.flood_img) - return - - def getMagicWandFloodTolerance(self): - tol_perc = self.wandControlsToolbar.toleranceSpinbox.value() - if tol_perc == 0: - return - - posData = self.data[self.pos_i] - _min, _max = posData.img_data_min_max - tol_fraction = tol_perc/100 - tol = (_max - _min) * tol_fraction - - return tol - - def getImage(self, frame_i=None, raw=False): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - if raw: - return self.getRawImageLayer0(frame_i) - - if self.viewPreprocDataToggle.isChecked(): - try: - img = posData.preproc_img_data[frame_i] - if posData.SizeZ == 1: - return np.array(img) - - self.updateZsliceScrollbar(frame_i) - z_slice = self.z_slice_index() - img = img[z_slice] - return img - except Exception as err: - # self.logger.warning( - # 'Pre-processed image not existing --> returning raw image' - # ) - return self.getRawImageLayer0(frame_i) - - viewCombinedImageData = ( - self.viewCombineChannelDataToggle.isChecked() - and self.combineDialog is not None - and not self.combineDialog.saveAsSegm() - ) - - if viewCombinedImageData: - try: - img = posData.combine_img_data[frame_i] - if posData.SizeZ == 1: - return np.array(img) - - self.updateZsliceScrollbar(frame_i) - z_slice = self.z_slice_index() - img = img[z_slice] - return img - except Exception as err: - # self.logger.warning( - # 'combined image not existing --> returning raw image' - # ) - return self.getRawImageLayer0(frame_i) - - if self.equalizeHistPushButton.isChecked(): - img = posData.equalized_img_data[frame_i] - if posData.SizeZ == 1: - return np.array(img) - - self.updateZsliceScrollbar(frame_i) - z_slice = self.z_slice_index() - img = img[z_slice] - return img - - return self.getRawImageLayer0(frame_i) - - def setImageImg2(self, updateLookuptable=True, set_image=True): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Segmentation and Tracking' or self.isSnapshot: - # self.addExistingDelROIs() - allDelIDs, lab2D = self.getDelROIlab() - else: - lab2D = self.get_2Dlab(posData.lab, force_z=False) - allDelIDs = set() - - self.currentLab2D = lab2D - if self.labelsGrad.permanentGreedyCmapAction.isChecked() and updateLookuptable: - self.greedyShuffleCmap(updateImages=False) - - if self.labelsGrad.showLabelsImgAction.isChecked() and set_image: - self.img2.setImage(lab2D, z=self.z_lab(), autoLevels=False) - - if updateLookuptable: - self.updateLookuptable(delIDs=allDelIDs) - - def applyDelROIimg1(self, roi, init=False, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): - return - - if init and how.find('contours') == -1: - self.setOverlaySegmMasks(force=True) - return - - posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi) - except Exception as err: - try: - ax.removeDelRoiItem(roi) - except Exception as err: - pass - return - delIDs = delROIs_info['delIDsROI'][idx] - delMask = delROIs_info['delMasks'][idx] - if how.find('nothing') != -1: - return - elif how.find('contours') != -1: - self.updateContoursImage(ax=ax) - - if not delIDs: - return - - if how.find('overlay segm. masks') != -1: - lab = self.currentLab2D.copy() - lab[delMask > 0] = 0 - if ax == 0: - self.labelsLayerImg1.setImage(lab, autoLevels=False) - else: - self.labelsLayerRightImg.setImage(lab, autoLevels=False) - - self.setAllTextAnnotations(labelsToSkip={ID:True for ID in delIDs}) - - def applyDelROIs(self): - self.logger.info('Applying deletion ROIs (if present)...') - - for posData in self.data: - self.current_frame_i = posData.frame_i - for frame_i in range(posData.SizeT): - lab = posData.allData_li[frame_i]['labels'] - if lab is None: - break - delROIs_info = posData.allData_li[frame_i]['delROIs_info'] - delIDs_rois = delROIs_info['delIDsROI'] - if not delIDs_rois: - continue - for delIDs in delIDs_rois: - for delID in delIDs: - lab[lab==delID] = 0 - posData.allData_li[frame_i]['labels'] = lab - # Get the rest of the metadata and store data based on the new lab - posData.frame_i = frame_i - self.get_data() - self.store_data(autosave=False) - - # Back to current frame - posData.frame_i = self.current_frame_i - self.get_data() - - def initTempLayerBrush(self, ID, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - self.hideItemsHoverBrush(ID=ID, force=True) - Y, X = self.img1.image.shape[:2] - tempImage = np.zeros((Y, X), dtype=np.uint32) - if how.find('contours') != -1: - tempImage[self.currentLab2D==ID] = ID - self.brushImage = tempImage.copy() - self.brushContourImage = np.zeros((Y, X, 4), dtype=np.uint8) - color = self.imgGrad.contoursColorButton.color() - self.brushContoursRgba = color.getRgb() - opacity = 1.0 - else: - opacity = self.imgGrad.labelsAlphaSlider.value() - color = self.lut[ID] - lut = np.zeros((2, 4), dtype=np.uint8) - lut[1,-1] = 255 - lut[1,:-1] = color - self.tempLayerImg1.setLookupTable(lut) - self.tempLayerImg1.setOpacity(opacity) - self.tempLayerImg1.setImage(tempImage, force_set_linked=True) - - def _setTempImageBrushContour(self): - pass - - def setTempBrushMaskFromWand(self, flood_mask, init=False): - if not np.any(flood_mask): - return - - posData = self.data[self.pos_i] - mask = np.logical_or( - flood_mask, - posData.lab==posData.brushID - ) - if mask.ndim == 3: - z_slice = self.zSliceScrollBar.sliderPosition() - mask = mask[z_slice] - - self.setTempImg1Brush(init, mask, posData.brushID) - - # @exec_time - def setTempImg1Brush(self, init: bool, mask, ID, toLocalSlice=None, ax=0): - if init: - self.initTempLayerBrush(ID, ax=ax) - - if self.annotContourCheckbox.isChecked(): - brushImage = self.brushImage - else: - brushImage = self.tempLayerImg1.image - - if toLocalSlice is None: - brushImage[mask] = ID - else: - brushImage[toLocalSlice][mask] = ID - - if self.annotContourCheckbox.isChecked(): - try: - obj = skimage.measure.regionprops(brushImage)[0] - except IndexError: - return - objContour = [self.getObjContours(obj)] - # objContour = core.get_obj_contours( - # obj_image=(brushImage>0).astype(np.uint8), local=True - # ) - self.brushContourImage[:] = 0 - img = self.brushContourImage - color = self.brushContoursRgba - cv2.drawContours(img, objContour, -1, color, 1) - self.tempLayerImg1.setImage(img, force_set_linked=True) - else: - self.tempLayerImg1.setImage(brushImage, force_set_linked=True) - - def getLabelsLayerImage(self, ax=0): - if ax == 0: - return self.labelsLayerImg1.image - else: - return self.labelsLayerRightImg.image - - def clearObjFromMask(self, image, mask, toLocalSlice=None): - if mask is None: - return image - - if toLocalSlice is None: - image[mask] = 0 - else: - image[toLocalSlice][mask] = 0 - - return image - - # @exec_time - def setTempImg1Eraser(self, mask, init=False, toLocalSlice=None, ax=0): - if init: - self.erasedLab = np.zeros_like(self.currentLab2D) - - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): - return - - if how.find('contours') != -1: - self.clearObjFromMask( - self.contoursImage, mask, toLocalSlice=toLocalSlice - ) - erasedRp = skimage.measure.regionprops(self.erasedLab) - for obj in erasedRp: - self.addObjContourToContoursImage(obj=obj, ax=ax) - elif how.find('overlay segm. masks') != -1: - labelsImage = self.getLabelsLayerImage(ax=ax) - self.clearObjFromMask(labelsImage, mask, toLocalSlice=toLocalSlice) - if ax == 0: - self.labelsLayerImg1.setImage( - self.labelsLayerImg1.image, autoLevels=False - ) - else: - self.labelsLayerRightImg.setImage( - self.labelsLayerRightImg.image, autoLevels=False - ) - - def _setTempImgExpandLabelSegmMasks(self, prevCoords, ax=0): - # Remove previous overlaid mask - labelsImage = self.getLabelsLayerImage(ax=ax) - labelsImage[prevCoords] = 0 - - # Overlay new moved mask - labelsImage[prevCoords] = self.expandingID - - if ax == 0: - self.labelsLayerImg1.setImage( - self.labelsLayerImg1.image, autoLevels=False) - else: - self.labelsLayerRightImg.setImage( - self.labelsLayerRightImg.image, autoLevels=False) - - def _setTempImgExpandLabelContours(self, prevCoords, ax=0): - self.contoursImage[prevCoords] = [0,0,0,0] - currentLab2Drp = skimage.measure.regionprops(self.currentLab2D) - for obj in currentLab2Drp: - if obj.label == self.expandingID: - # self.clearObjContour(obj=obj, ax=ax) - self.addObjContourToContoursImage(obj=obj, ax=ax, force=True) - break - - def setTempImgExpandLabel(self, prevCoords, expandedObjCoords, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - self._setTempImgExpandLabelContours(prevCoords, ax=ax) - - # if how.find('overlay segm. masks') != -1: - # self._setTempImgExpandLabelSegmMasks(ax=ax) - # else: - # self._setTempImgExpandLabelContours(ax=ax) - - def setTempImg1MoveLabel(self, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - if how.find('contours') != -1: - currentLab2Drp = skimage.measure.regionprops(self.currentLab2D) - for obj in currentLab2Drp: - if obj.label == self.movingID: - self.addObjContourToContoursImage(obj=obj, ax=ax) - break - elif how.find('overlay segm. masks') != -1: - if ax == 0: - self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) - self.highLightIDLayerImg1.image[:] = 0 - mask = self.currentLab2D==self.movingID - self.highLightIDLayerImg1.image[mask] = self.movingID - highlightedImage = self.highLightIDLayerImg1.image - self.highLightIDLayerImg1.setImage(highlightedImage) - else: - self.labelsLayerRightImg.setImage( - self.currentLab2D, autoLevels=False - ) - self.highLightIDLayerRightImage.image[:] = 0 - mask = self.currentLab2D==self.movingID - self.highLightIDLayerRightImage.image[mask] = self.movingID - highlightedImage = self.highLightIDLayerRightImage.image - self.highLightIDLayerRightImage.setImage(highlightedImage) - - def addMissingIDs_cca_df(self, posData): - base_cca_df = self.getBaseCca_df() - if posData.cca_df is None: - posData.cca_df = base_cca_df - return - - posData.cca_df = posData.cca_df.combine_first(base_cca_df) - - def update_cca_df_relabelling(self, posData, oldIDs, newIDs): - relIDs = posData.cca_df['relative_ID'] - posData.cca_df['relative_ID'] = relIDs.replace(oldIDs, newIDs) - mapper = dict(zip(oldIDs, newIDs)) - posData.cca_df = posData.cca_df.rename(index=mapper) - - def update_cca_df_deletedIDs( - self, posData, deletedIDs, dropInPast=True, dropInFuture=True - ): - if posData.cca_df is None: - return - - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - - try: - relIDs = ( - posData.cca_df.reindex(deletedIDs, fill_value=-1) - ['relative_ID'] - ) - except KeyError as err: - return - - posData.cca_df = posData.cca_df.drop(deletedIDs, errors='ignore') - if self.isSnapshot: - self.update_cca_df_newIDs(posData, relIDs) - else: - self.updateCcaDfDeletedIDsTimelapse( - posData, relIDs, deletedIDs, undoId, dropInPast, dropInFuture - ) - - @disableWindow - def updateCcaDfDeletedIDsTimelapse( - self, posData, relIDsOfDelIDs, deletedIDs, undoId, - dropInPast, dropInFuture - ): - # Get status of the relIDs (of deleted IDs) to restore - relIDsCcaStatus = {} - for relID in relIDsOfDelIDs: - try: - ccs = posData.cca_df.at[relID, 'cell_cycle_stage'] - relationship = posData.cca_df.at[relID, 'relationship'] - except Exception as err: - continue - - ccaStatus = core.getBaseCca_df([relID]).loc[relID] - if relationship == 'mother' and ccs == 'S': - for past_frame_i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df( - frame_i=past_frame_i, return_df=True - ) - ccs_past = cca_df_i.at[relID, 'cell_cycle_stage'] - if ccs_past == 'G1': - ccaStatus = cca_df_i.loc[relID] - break - - posData.cca_df.loc[relID] = ccaStatus - self.store_data(autosave=False) - relIDsCcaStatus[relID] = ccaStatus - - for fut_frame_i in range(posData.frame_i+1, posData.SizeT): - cca_df_i = self.get_cca_df(frame_i=fut_frame_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - self.storeUndoRedoCca(fut_frame_i, cca_df_i, undoId) - - if dropInFuture: - cca_df_i = cca_df_i.drop(deletedIDs, errors='ignore') - else: - for delID in deletedIDs: - dataDict = posData.allData_li[fut_frame_i] - delIDexists = dataDict['IDs_idxs'].get(delID, False) - if not delIDexists: - continue - - cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID] - - areRelIDsPresent = False - for relID in relIDsOfDelIDs: - try: - ccs = cca_df_i.at[relID, 'cell_cycle_stage'] - relationship = cca_df_i.at[relID, 'relationship'] - ccaStatus = relIDsCcaStatus[relID] - cca_df_i.loc[relID] = ccaStatus - areRelIDsPresent = True - except Exception as err: - continue - - if not areRelIDsPresent: - break - - self.store_cca_df( - frame_i=fut_frame_i, cca_df=cca_df_i, autosave=False - ) - - # Correct past frames - for past_frame_i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_frame_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - self.storeUndoRedoCca(past_frame_i, cca_df_i, undoId) - if dropInPast: - cca_df_i = cca_df_i.drop(deletedIDs, errors='ignore') - else: - for delID in deletedIDs: - dataDict = posData.allData_li[past_frame_i] - delIDexists = dataDict['IDs_idxs'].get(delID, False) - if not delIDexists: - continue - - cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID] - - areRelIDsPresent = False - for relID in relIDsOfDelIDs: - try: - ccs = cca_df_i.at[relID, 'cell_cycle_stage'] - relationship = cca_df_i.at[relID, 'relationship'] - ccaStatus = relIDsCcaStatus[relID] - cca_df_i.loc[relID] = ccaStatus - areRelIDsPresent = True - except Exception as err: - continue - - if not areRelIDsPresent: - break - - self.store_cca_df( - frame_i=past_frame_i, cca_df=cca_df_i, autosave=False - ) - - def update_cca_df_newIDs(self, posData, new_IDs): - for newID in new_IDs: - self.addIDBaseCca_df(posData, newID) - - def update_cca_df_snapshots(self, editTxt, posData): - cca_df = posData.cca_df - cca_df_IDs = cca_df.index - if editTxt == 'Delete ID': - deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, deleted_IDs) - - elif editTxt == 'Separate IDs': - new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] - self.update_cca_df_newIDs(posData, new_IDs) - deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, deleted_IDs) - - elif editTxt == 'Edit ID': - new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] - self.update_cca_df_newIDs(posData, new_IDs) - old_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, old_IDs) - - elif editTxt == 'Annotate ID as dead': - return - - elif editTxt == 'Deleted non-selected objects': - deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, deleted_IDs) - - elif editTxt == 'Delete ID with eraser': - deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, deleted_IDs) - - elif editTxt == 'Add new ID with brush tool': - new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] - self.update_cca_df_newIDs(posData, new_IDs) - - elif editTxt == 'Merge IDs': - deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, deleted_IDs) - - elif editTxt == 'Add new ID with curvature tool': - new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] - self.update_cca_df_newIDs(posData, new_IDs) - - elif editTxt == 'Delete IDs using ROI': - deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, deleted_IDs) - - elif editTxt == 'Repeat segmentation': - posData.cca_df = self.getBaseCca_df() - - def fixCcaDfAfterEdit(self, editTxt): - posData = self.data[self.pos_i] - if posData.cca_df is not None: - # For snapshot mode we fix or reinit cca_df depending on the edit - self.update_cca_df_snapshots(editTxt, posData) - self.store_data() - - def isFrameCcaAnnotated(self): - posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - if acdc_df is None: - return False - - return 'cell_cycle_stage' in acdc_df.columns - - def warnEditingWithCca_df( - self, editTxt, return_answer=False, get_answer=False, - get_cancelled=False, update_images=True - ): - # Function used to warn that the user is editing in "Segmentation and - # Tracking" mode a frame that contains cca annotations. - # Ask whether to remove annotations from all future frames - if self.isSnapshot: - return True - - posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - - if acdc_df is None and self.lineage_tree is None: - if update_images: - self.updateAllImages() - return True - - cell_cycle_stage_present = ( - acdc_df is not None and 'cell_cycle_stage' in acdc_df.columns - ) - lineage_tree_present = ( - self.lineage_tree is not None or 'parent_ID_tree' in acdc_df.columns - ) - if not cell_cycle_stage_present and not lineage_tree_present: - if update_images: - self.updateAllImages() - return True - - action = self.warnEditingWithAnnotActions.get(editTxt, None) - if action is not None and not action.isChecked(): - # user has checked that he does not want to be asked again AND he doesnt want to delete - if update_images: - self.updateAllImages() - return True - - msg = widgets.myMessageBox() - warn_type = 'cell cycle annotations' if cell_cycle_stage_present else 'lineage tree annotations' - txt = html_utils.paragraph( - f'You modified a frame that has {warn_type}.

' - f'The change "{editTxt}" most likely makes the ' - 'annotations wrong.

' - 'If you really want to apply this change we reccommend to remove' - f'ALL {warn_type}
' - 'from current frame to the end.

' - 'What do you want to do?' - ) - if action is not None: - checkBox = QCheckBox('Remember my choice and do not ask again') - else: - checkBox = None - - dropDelIDsNoteText = ( - '' if editTxt.find('Delete') == -1 else ' (drop removed IDs)' - ) - _, removeAnnotButton, _ = msg.warning( - self, 'Edited segmentation with annotations!', txt, - buttonsTexts=( - 'Cancel', - 'Remove annotations from future frames (RECOMMENDED)', - f'Do not remove annotations{dropDelIDsNoteText}' - ), widgets=checkBox - ) - if msg.cancel: - if get_cancelled: - return 'cancelled' - removeAnnotations = False - return removeAnnotations - - if action is not None: - action.setChecked(not checkBox.isChecked()) - action.removeAnnot = msg.clickedButton == removeAnnotButton - - if return_answer: - return msg.clickedButton == removeAnnotButton - - if (msg.clickedButton == removeAnnotButton) and cell_cycle_stage_present: - self.resetFutureCcaColCurrentFrame() - self.resetCcaFuture(posData.frame_i+1) - self.updateAllImages() - elif (msg.clickedButton == removeAnnotButton) and lineage_tree_present: - self.resetLin_tree_future() - self.updateAllImages() - else: - if dropDelIDsNoteText and posData.cca_df is not None: - delIDs = [ - ID for ID in posData.cca_df.index if ID not in posData.IDs - ] - self.update_cca_df_deletedIDs( - posData, delIDs, dropInPast=False - ) - self.addMissingIDs_cca_df(posData) - self.updateAllImages() - self.store_data() - # if action is not None: - # if action.removeAnnot: - # self.store_data() - # posData.frame_i -= 1 - # self.get_data() - # if lineage_tree_present: - # self.resetLin_tree_future() - # self.resetCcaFuture(posData.frame_i) - # self.next_frame() - - if get_answer: - return msg.clickedButton == removeAnnotButton - else: - return True - - def warnRepeatTrackingVideoOnVisitedFrames(self, last_tracked_i, start_n): - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'You are repeating tracking on frames that have already ' - 'been visited/tracked before.

' - 'This will very likely make the annotations wrong.

' - 'If you really want to repeat tracking on the frames before ' - f'{last_tracked_i+1} the annotations from frame ' - f'{start_n} to frame {last_tracked_i+1} ' - 'will be removed.

' - 'Do you want to continue?' - ) - noButton, yesButton = msg.warning( - self, 'Repating tracking with annotations!', txt, - buttonsTexts=( - ' No, stop tracking and keep annotations.', - ' Yes, repeat tracking and DELETE annotations.' - ) - ) - if msg.cancel: - return False - - if msg.clickedButton == noButton: - return False - else: - return True - - def warnRepeatTrackingVideoWithAnnotations(self, last_tracked_i, start_n): - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'You are repeating tracking on frames that have cell cycle ' - 'annotations.

' - 'This will very likely make the annotations wrong.

' - 'If you really want to repeat tracking on the frames before ' - f'{last_tracked_i+1} the annotations from frame ' - f'{start_n} to frame {last_tracked_i+1} ' - 'will be removed.

' - 'Do you want to continue?' - ) - noButton, yesButton = msg.warning( - self, 'Repating tracking with annotations!', txt, - buttonsTexts=( - ' No, stop tracking and keep annotations.', - ' Yes, repeat tracking and DELETE annotations.' - ) - ) - if msg.cancel: - return False - - if msg.clickedButton == noButton: - return False - else: - return True - - def setDelRoiState(self, roi: pg.ROI, state): - roi.sigRegionChanged.disconnect() - roi.sigRegionChangeFinished.disconnect() - roi.setState(state) - roi.sigRegionChanged.connect(self.delROImoving) - roi.sigRegionChangeFinished.connect(self.delROImovingFinished) - - def addExistingDelROIs(self): - posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - isAx2hidden = not self.labelsGrad.showLabelsImgAction.isChecked() - - for r, roi in enumerate(delROIs_info['rois']): - if isinstance(roi, pg.PolyLineROI) or isAx2hidden: - # PolyLine ROIs are only on ax1 - self.ax1.addDelRoiItem(roi, roi.key) - else: - # Rect ROI is on ax2 because ax2 is visible - self.ax2.addDelRoiItem(roi, roi.key) - - self.setDelRoiState(roi, delROIs_info['state'][r]) - - def updateFramePosLabel(self): - if self.isSnapshot: - posData = self.data[self.pos_i] - self.navSpinBox.setValueNoEmit(self.pos_i+1) - else: - posData = self.data[0] - self.navSpinBox.setValueNoEmit(posData.frame_i+1) - - def highlightHoverID(self, x, y, hoverID=None): - if hoverID is None: - try: - hoverID = self.currentLab2D[int(y), int(x)] - except IndexError: - return - - if hoverID == 0: - return - - posData = self.data[self.pos_i] - objIdx = posData.IDs_idxs[hoverID] - obj = posData.rp[objIdx] - self.goToZsliceSearchedID(obj) - self.highlightSearchedID(hoverID) - - def grayOutHighlightedLabels(self, nonGrayedIDs=None, alpha=None): - if nonGrayedIDs is None: - nonGrayedIDs = set() - - posData = self.data[self.pos_i] - if alpha is None: - alpha = self.imgGrad.labelsAlphaSlider.value() - - if not hasattr(self, 'highlightedLab'): - self.highlightedLab = np.zeros_like(self.currentLab2D) - else: - self.highlightedLab[:] = 0 - - lut = np.zeros((2, 4), dtype=np.uint8) - for _obj in posData.rp: - if not self.isObjVisible(_obj.bbox): - continue - if _obj.label not in nonGrayedIDs: - continue - _slice = self.getObjSlice(_obj.slice) - _objMask = self.getObjImage(_obj.image, _obj.bbox) - self.highlightedLab[_slice][_objMask] = _obj.label - rgb = self.lut[_obj.label].copy() - lut[1, :-1] = rgb - # Set alpha to 0.7 - lut[1, -1] = 178 - - return lut - - def grayOutOverlaySegm(self, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - isOverlaySegmActive = how.find('segm. masks') != -1 - if not isOverlaySegmActive: - return - - grayedLut = self.grayOutHighlightedLabels() - - def highlightHoverIDsKeptObj(self, x, y, hoverID=None): - if hoverID is None: - try: - hoverID = self.currentLab2D[int(y), int(x)] - except IndexError: - return - - self.highlightSearchedID(hoverID, greyOthers=False) - - if hoverID == 0 and self.highlightedID == 0: - return - - if hoverID == 0 and self.highlightedID != 0: - self.clearHighlightedKeepIDs() - for ID in self.keptObjectsIDs: - self.highlightLabelID(ID) - return - - posData = self.data[self.pos_i] - try: - objIdx = posData.IDs_idxs[hoverID] - except KeyError as err: - return - - obj = posData.rp[objIdx] - self.goToZsliceSearchedID(obj) - - for ID in self.keptObjectsIDs: - self.highlightLabelID(ID) - - def getHighlightedID(self): - if self.highlightedID > 0: - return self.highlightedID - - doHighlight = ( - self.propsDockWidget.isVisible() - and ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() - ) - ) - if not doHighlight: - return 0 - - return self.guiTabControl.propsQGBox.idSB.value() - - def clearHighlightedKeepIDs(self): - self.setAllTextAnnotations() - self.highlightedID = 0 - self.searchedIDitemRight.setData([], []) - self.searchedIDitemLeft.setData([], []) - self.highLightIDLayerImg1.clear() - self.highLightIDLayerRightImage.clear() - - def setHighlighedIDfromToolbar(self, ID: int): - self.findID(ID=ID) - - def highlightSearchedID(self, ID, force=False, greyOthers=True): - self.highlightIDToolbar.setIDNoSignals(ID) - - if ID == 0: - self.highlightIDToolbar.setVisible(False) - return - - if ID == self.highlightedID and not force: - return - - doHighlight = ( - self.propsDockWidget.isVisible() - and ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() - ) - ) - if doHighlight: - self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - ID = self.highlightedID - - if self.highlightedID > 0: - self.clearHighlightedText() - - self.searchedIDitemRight.setData([], []) - self.searchedIDitemLeft.setData([], []) - - posData = self.data[self.pos_i] - - self.highlightedID = ID - self.highlightIDToolbar.setVisible(True) - - objIdx = posData.IDs_idxs.get(ID) - if objIdx is None: - return - - obj = posData.rp[objIdx] - isObjVisible = self.isObjVisible(obj.bbox) - if not isObjVisible: - return - - if greyOthers: - self.textAnnot[0].grayOutAnnotations() - self.textAnnot[1].grayOutAnnotations() - - how_ax1 = self.drawIDsContComboBox.currentText() - how_ax2 = self.getAnnotateHowRightImage() - isOverlaySegm_ax1 = how_ax1.find('segm. masks') != -1 - isOverlaySegm_ax2 = how_ax2.find('segm. masks') != -1 - alpha = self.imgGrad.labelsAlphaSlider.value() - - if isOverlaySegm_ax1 or isOverlaySegm_ax2: - grayedLut = self.grayOutHighlightedLabels( - nonGrayedIDs={obj.label}, - alpha=alpha - ) - - cont = None - contours = None - if isOverlaySegm_ax1: - self.highLightIDLayerImg1.setLookupTable(grayedLut) - self.highLightIDLayerImg1.setImage(self.highlightedLab) - self.labelsLayerImg1.setOpacity(alpha/3) - else: - contours = self.getObjContours(obj, all_external=True) - for cont in contours: - self.searchedIDitemLeft.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) - - if isOverlaySegm_ax2: - self.highLightIDLayerRightImage.setLookupTable(grayedLut) - self.highLightIDLayerRightImage.setImage(self.highlightedLab) - self.labelsLayerRightImg.setOpacity(alpha/3) - else: - if contours is None: - contours = self.getObjContours(obj, all_external=True) - for cont in contours: - self.searchedIDitemRight.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) - - # Gray out all IDs excpet searched one - lut = self.lut.copy() # [:max(posData.IDs)+1] - lut[:ID] = lut[:ID]*0.2 - lut[ID+1:] = lut[ID+1:]*0.2 - self.img2.setLookupTable(lut) - - # Highlight text - self.highlightLabelID(ID, ax=0) - self.highlightLabelID(ID, ax=1) - - def _drawGhostContour(self, x, y): - if self.ghostObject is None: - return - - ID = self.ghostObject.label - yc, xc = self.ghostObject.local_centroid - Dx = x-xc - Dy = y-yc - xx = self.ghostObject.xx_contour + Dx - yy = self.ghostObject.yy_contour + Dy - self.ghostContourItemLeft.setData( - xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) - self.ghostContourItemRight.setData( - xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) - - def _drawManualBackgroundObjContour(self, x, y): - if self.manualBackgroundObj is None: - return - - ID = self.manualBackgroundObj.label - yc, xc = self.manualBackgroundObj.local_centroid - Dx = x-xc - Dy = y-yc - xx = self.manualBackgroundObj.xx_contour + Dx - yy = self.manualBackgroundObj.yy_contour + Dy - self.manualBackgroundObjItem.setData( - xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) - - def _drawGhostMask(self, x, y): - if self.ghostObject is None: - return - - self.clearGhostMask() - ID = self.ghostObject.label - h, w = self.ghostObject.image.shape[-2:] - yc, xc = self.ghostObject.local_centroid - Dx = int(x-xc) - Dy = int(y-yc) - bbox = ((Dy, Dy+h), (Dx, Dx+w)) - - Y, X = self.currentLab2D.shape - slices = myutils.get_slices_local_into_global_arr(bbox, (Y, X)) - slice_global_to_local, slice_crop_local = slices - - obj_image = self.ghostObject.image[slice_crop_local] - - self.ghostMaskItemLeft.image[slice_global_to_local][obj_image] = ID - self.ghostMaskItemLeft.updateGhostImage( - fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) - - self.ghostMaskItemRight.image[slice_global_to_local][obj_image] = ID - self.ghostMaskItemRight.updateGhostImage( - fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) - - def drawManualBackgroundObj(self, x, y): - if x is None or y is None: - self.clearGhost() - return - - self._drawManualBackgroundObjContour(x, y) - - def drawManualTrackingGhost(self, x, y): - if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): - return - - if x is None or y is None: - self.clearGhost() - return - - if self.manualTrackingToolbar.ghostContourRadiobutton.isChecked(): - self._drawGhostContour(x, y) - else: - self._drawGhostMask(x, y) - - def restoreDefaultSettings(self): - df = self.df_settings - df.at['contLineWeight', 'value'] = 1 - df.at['mothBudLineSize', 'value'] = 1 - df.at['mothBudLineColor', 'value'] = (255, 165, 0, 255) - df.at['contLineColor', 'value'] = (205, 0, 0, 220) - - self._updateContColour((205, 0, 0, 220)) - self._updateMothBudLineColour((255, 165, 0, 255)) - self._updateMothBudLineSize(1) - self._updateContLineThickness() - - df.at['overlaySegmMasksAlpha', 'value'] = 0.3 - df.at['img_cmap', 'value'] = 'grey' - self.imgCmap = self.imgGrad.cmaps['grey'] - self.imgCmapName = 'grey' - self.labelsGrad.item.loadPreset('viridis') - df.at['labels_bkgrColor', 'value'] = (25, 25, 25) - - if df.at['is_bw_inverted', 'value'] == 'Yes': - self.invertBw(update=False) - - df = df[~df.index.str.contains('lab_cmap')] - df.to_csv(self.settings_csv_path) - self.imgGrad.restoreState(df) - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.restoreState(df) - - self.labelsGrad.saveState(df) - self.labelsGrad.restoreState(df, loadCmap=False) - - self.df_settings.to_csv(self.settings_csv_path) - self.updateAllImages() - - def updateLabelsAlpha(self, value): - self.df_settings.at['overlaySegmMasksAlpha', 'value'] = value - self.df_settings.to_csv(self.settings_csv_path) - if self.keepIDsButton.isChecked(): - value = value/3 - self.labelsLayerImg1.setOpacity(value) - self.labelsLayerRightImg.setOpacity(value) - - - def _getImageupdateAllImages(self, image=None): - if image is not None: - return image - - img = self.getImage() - return img - - def setImageImg1(self, image=None): - img = self._getImageupdateAllImages(image=image) - posData = self.data[self.pos_i] - self.img1.setCurrentPosIndex(self.pos_i) - self.img1.setCurrentFrameIndex(posData.frame_i) - if posData.SizeZ > 1: - zProjHow = self.zProjComboBox.currentText() - if zProjHow == 'single z-slice': - z = self.zSliceScrollBar.sliderPosition() - else: - z = zProjHow - - self.img1.setCurrentZsliceIndex(z) - - self.img1.setImage( - img, next_frame_image=self.nextFrameImage(), - scrollbar_value=posData.frame_i+2 - ) - - def getContoursImageItem(self, ax, force=False): - if not self.areContoursRequested(ax) and not force: - return - - if ax == 0: - return self.ax1_contoursImageItem - else: - return self.ax2_contoursImageItem - - def getLostObjImageItem(self, ax): - if ax == 0: - return self.ax1_lostObjImageItem - else: - return self.ax1_lostTrackedObjImageItem - - def getLostTrackedObjImageItem(self, ax): - if ax == 0: - return self.ax1_lostTrackedObjImageItem - else: - return self.ax2_lostTrackedObjImageItem - - def setManualBackgroundImage(self): - if not self.manualBackgroundButton.isChecked(): - return - - posData = self.data[self.pos_i] - if not hasattr(posData, 'manualBackgroundImage'): - self.initManualBackgroundImage() - - contours = [] - for obj in skimage.measure.regionprops(posData.manualBackgroundLab): - obj_contours = self.getObjContours(obj, all_external=True) - contours.extend(obj_contours) - textItem = self.manualBackgroundTextItems[obj.label] - textItem.setText(f'{obj.label}') - self.ax1.addItem(textItem) - yc, xc = obj.centroid - textItem.setPos(xc, yc) - - cv2.drawContours( - posData.manualBackgroundImage, contours, -1, (255, 0, 0, 200), 1 - ) - self.manualBackgroundImageItem.setImage(posData.manualBackgroundImage) - - def setManualBackgrounNextID(self): - posData = self.data[self.pos_i] - currentID = self.manualBackgroundObj.label - idx = posData.IDs_idxs[currentID] - next_idx = idx + 1 - if next_idx >= len(posData.IDs): - return - next_ID = posData.IDs[next_idx] - self.manualBackgroundToolbar.spinboxID.setValue(next_ID) - - def clearManualBackgroundObject(self, ID): - posData = self.data[self.pos_i] - mask = posData.manualBackgroundLab==ID - posData.manualBackgroundImage[mask, :] = 0 - posData.manualBackgroundLab[mask] = 0 - - def addManualBackgroundObject(self, x, y): - posData = self.data[self.pos_i] - - if not hasattr(self, 'manualBackgroundObj'): - self.initManualBackgroundObject() - - Y, X = self.currentLab2D.shape - ymin, xmin, ymax, xmax = self.manualBackgroundObj.bbox - width, height = xmax-xmin, ymax-ymin - yc, xc = self.manualBackgroundObj.local_centroid - xstart, ystart = round(x-xc), round(y-yc) - xstart = xstart if xstart >= 0 else 0 - ystart = ystart if ystart >= 0 else 0 - - xend = xstart+width - yend = ystart+height - xend = xend if xend <= X else X - yend = yend if yend <= Y else Y - - width = xend-xstart - height = yend-ystart - - obj_image = self.manualBackgroundObj.image[:height, :width] - obj_slice = (slice(ystart, yend), slice(xstart, xend)) - ID = self.manualBackgroundObj.label - self.clearManualBackgroundObject(ID) - posData.manualBackgroundLab[obj_slice][obj_image] = ID - - if ID in self.manualBackgroundTextItems: - self.manualBackgroundTextItems[ID].setPos(x, y) - return - - textItem = pg.TextItem( - text=str(ID), color='r', anchor=(0.5, 0.5) - ) - textItem.setFont(font_13px) - textItem.setPos(x, y) - self.manualBackgroundTextItems[ID] = textItem - - self.ax1.addItem(textItem) - - def setManualBackgroundLab(self, load_from_store=False, debug=True): - posData = self.data[self.pos_i] - if posData.manualBackgroundLab is None: - self.initManualBackgroundImage() - - for obj in skimage.measure.regionprops(posData.manualBackgroundLab): - textItem = pg.TextItem(text='', color='r', anchor=(0.5, 0.5)) - if obj.label in self.manualBackgroundTextItems: - continue - self.manualBackgroundTextItems[obj.label] = textItem - - def updateContoursImage(self, ax, delROIsIDs=None, compute=True): - imageItem = self.getContoursImageItem(ax) - if imageItem is None: - return - - if not hasattr(self, 'contoursImage'): - self.initContoursImage() - else: - self.contoursImage[:] = 0 - - contours = [] - for obj in skimage.measure.regionprops(self.currentLab2D): - obj_contours = self.getObjContours( - obj, - all_external=True, - force_calc=compute, - include_internal=self.showAllContoursToggle.isChecked() - ) - contours.extend(obj_contours) - - thickness = self.contLineWeight - color = self.contLineColor - self.setContoursImage(imageItem, contours, thickness, color) - - def setContoursImage(self, imageItem, contours, thickness, color): - cv2.drawContours(self.contoursImage, contours, -1, color, thickness) - imageItem.setImage(self.contoursImage) - - def getObjFromID(self, ID): - posData = self.data[self.pos_i] - try: - idx = posData.IDs_idxs[ID] - except KeyError as e: - # Object already cleared - return - - obj = posData.rp[idx] - return obj - - def setLostObjectContour(self, obj): - allContours = self.getObjContours(obj, all_external=True) - for objContours in allContours: - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - data = [obj.label]*len(xx) - self.ax1_lostObjScatterItem.addPoints(xx, yy, data=data) - self.ax2_lostObjScatterItem.addPoints(xx, yy) - - def setTrackedLostObjectContour(self, obj): - if self.isExportingVideo: - return - - allContours = self.getObjContours(obj, all_external=True) - for objContours in allContours: - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - data = [obj.label]*len(xx) - self.ax1_lostTrackedScatterItem.addPoints(xx, yy, data=data) - self.ax2_lostTrackedScatterItem.addPoints(xx, yy) - - def updateLostContoursImage(self, ax, draw=True, delROIsIDs=None): - if draw: - imageItem = self.getLostObjImageItem(ax) - if imageItem is None: - return - - if not hasattr(self, 'lostObjContoursImage'): - self.initLostObjContoursImage() - else: - self.lostObjContoursImage[:] = 0 - - if delROIsIDs is None: - delROIsIDs = set() - - posData = self.data[self.pos_i] - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] - if posData.whitelist is not None and posData.whitelist.whitelistIDs is not None: - whitelist = posData.whitelist.whitelistIDs[posData.frame_i-1] - else: - whitelist = None - - contours = [] - for lostID in posData.lost_IDs: - if lostID in delROIsIDs or (whitelist is not None and lostID not in whitelist): - continue - - obj = prev_rp[prev_IDs_idxs[lostID]] - if not self.isObjVisible(obj.bbox): - continue - - obj_contours = self.getObjContours(obj, all_external=True) - - if ax == 0: - self.addLostObjsToLostObjImage(obj, lostID) - - contours.extend(obj_contours) - - if not draw: - return - - self.drawLostObjContoursImage(imageItem, contours) - - def drawLostObjContoursImage( - self, imageItem, contours, - thickness=1, - color=(255, 165, 0, 255) # orange - ): - img = self.lostObjContoursImage - cv2.drawContours(img, contours, -1, color, thickness) - imageItem.setImage(img) - - def updateLostTrackedContoursImage( - self, ax, delROIsIDs=None, tracked_lost_IDs=None - ): - imageItem = self.getLostTrackedObjImageItem(ax) - if imageItem is None: - return - - if not hasattr(self, 'lostTrackedObjContoursImage'): - self.initLostTrackedObjContoursImage() - else: - self.lostTrackedObjContoursImage[:] = 0 - - if delROIsIDs is None: - delROIsIDs = set() - - posData = self.data[self.pos_i] - if tracked_lost_IDs is None: - tracked_lost_IDs = self.getTrackedLostIDs() - - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] - contours = [] - for tracked_lost_ID in tracked_lost_IDs: - if tracked_lost_ID in delROIsIDs: - continue - - obj = prev_rp[prev_IDs_idxs[tracked_lost_ID]] - if not self.isObjVisible(obj.bbox): - continue - - obj_contours = self.getObjContours(obj, all_external=True) - contours.extend(obj_contours) - - self.drawLostTrackedObjContoursImage(imageItem, contours) - - def drawLostTrackedObjContoursImage(self, imageItem, contours): - thickness = 1 - color = (0, 255, 0, 255) # green - img = self.lostTrackedObjContoursImage - cv2.drawContours(img, contours, -1, color, thickness) - imageItem.setImage(img) - - def getNearestLostObjID(self, y, x): - if not self.annotLostObjsToggle.isChecked(): - return - - posData = self.data[self.pos_i] - if not posData.lost_IDs: - return - - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - if prev_lab is None: - return - - # if not hasattr(self, 'lostObjContoursImage'): - # self.store_data() - # posData.frame_i -= 1 - # self.get_data() - # self.store_data() - # posData.frame_i += 1 - # self.get_data() - # self.updateLostNewCurrentIDs() - # self.updateLostContoursImage(ax=0) - # self.updateLostContoursImage(ax=1) - # self.updateLostNewCurrentIDs() - - yy, xx, _ = np.nonzero(self.lostObjContoursImage) - lostObjsContourMask = np.zeros(self.currentLab2D.shape, dtype=bool) - lostObjsContourMask[yy.astype(int), xx.astype(int)] = True - - # Add accepted lost IDs - try: - yy, xx, _ = np.nonzero(self.lostTrackedObjContoursImage) - lostObjsContourMask[yy.astype(int), xx.astype(int)] = True - except Exception as err: - pass - - _, y_nearest, x_nearest = core.nearest_nonzero_2D( - lostObjsContourMask, y, x, return_coords=True - ) - nearest_ID = self.get_2Dlab(prev_lab)[y_nearest, x_nearest] - - if nearest_ID == 0: - return - - return nearest_ID - - def setCcaIssueContour(self, obj): - objContours = self.getObjContours(obj, all_external=True) - for cont in objContours: - xx = cont[:,0] + 0.5 - yy = cont[:,1] + 0.5 - self.ax1_lostObjScatterItem.addPoints(xx, yy) - self.textAnnot[0].addObjAnnotation( - obj, 'lost_object', f'{obj.label}?', False - ) - - def isLastVisitedAgainCca(self, curr_df, enforceAll=False): - # Determine if this is the last visited frame for repeating - # bud assignment on non manually corrected_on_frame_i buds. - # The idea is that the user could have assigned division on a cell - # by going previous and we want to check if this cell could be a - # "better" mother for those non manually corrected buds - posData = self.data[self.pos_i] - if curr_df is None: - return False - - if 'cell_cycle_stage' not in curr_df.columns: - return False - - if enforceAll: - return False - - lastVisited = False - posData.new_IDs = [ - ID for ID in posData.new_IDs - if curr_df.at[ID, 'is_history_known'] - and curr_df.at[ID, 'cell_cycle_stage'] == 'S' - ] - if posData.frame_i+1 < posData.SizeT: - next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] - if next_df is None: - lastVisited = True - else: - if 'cell_cycle_stage' not in next_df.columns: - lastVisited = True - else: - lastVisited = True - - return lastVisited - - def highlightNewCellNotEnoughG1cells(self, IDsCellsG1): - posData = self.data[self.pos_i] - for obj in posData.rp: - if obj.label not in IDsCellsG1: - continue - objContours = self.getObjContours(obj) - if objContours is not None: - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - self.ccaFailedScatterItem.addPoints(xx, yy) - self.textAnnot[0].addObjAnnotation( - obj, 'green', f'{obj.label}?', False - ) - - def handleNoCellsInG1(self, numCellsG1, numNewCells): - posData = self.data[self.pos_i] - self.highlightNewCellNotEnoughG1cells(posData.new_IDs) - continueAnyway = _warnings.warnNotEnoughG1Cells( - numCellsG1, posData.frame_i, numNewCells, qparent=self - ) - if continueAnyway: - notEnoughG1Cells = False - proceed = True - # Annotate the new IDs with unknown history - for ID in posData.new_IDs: - posData.cca_df.loc[ID] = pd.Series(base_cca_dict) - cca_df_ID = self.getStatusKnownHistoryBud(ID) - posData.ccaStatus_whenEmerged[ID] = cca_df_ID - else: - notEnoughG1Cells = True - proceed = False - - # Clear new cells annotations - self.ccaFailedScatterItem.setData([], []) - return notEnoughG1Cells, proceed - - def addObjContourToContoursImage( - self, ID=0, obj=None, ax=0, thickness=None, color=None, - force=False - ): - imageItem = self.getContoursImageItem(ax, force=force) - if imageItem is None: - return - - if obj is None: - obj = self.getObjFromID(ID) - if obj is None: - return - - contours = self.getObjContours(obj, all_external=True) - if thickness is None: - thickness = self.contLineWeight - if color is None: - color = self.contLineColor - - self.setContoursImage(imageItem, contours, thickness, color) - - def clearObjContour( - self, ID=0, obj=None, ax=0, debug=False, updateImage=True - ): - imageItem = self.getContoursImageItem(ax) - if imageItem is None: - return - - if ID > 0: - self.contoursImage[self.currentLab2D==ID] = [0,0,0,0] - else: - obj_slice = self.getObjSlice(obj.slice) - obj_image = self.getObjImage(obj.image, obj.bbox) - self.contoursImage[obj_slice][obj_image] = [0,0,0,0] - - if not updateImage: - return - - imageItem.setImage(self.contoursImage) - - def clearAnnotItems(self): - self.textAnnot[0].clear() - self.textAnnot[1].clear() - - # @exec_time - def setAllTextAnnotations(self, labelsToSkip=None): - delROIsIDs = self.setLostNewOldPrevIDs() - posData = self.data[self.pos_i] - self.textAnnot[0].setAnnotations( - posData=posData, - labelsToSkip=labelsToSkip, - isVisibleCheckFunc=self.isObjVisible, - highlightedID=self.highlightedID, - delROIsIDs=delROIsIDs, - annotateLost=self.annotLostObjsToggle.isChecked(), - getCurrentZfunc=self.z_lab, - getObjCentroidFunc=self.getObjCentroid - ) - self.textAnnot[1].setAnnotations( - posData=posData, labelsToSkip=labelsToSkip, - isVisibleCheckFunc=self.isObjVisible, - highlightedID=self.highlightedID, - delROIsIDs=delROIsIDs, - annotateLost=self.annotLostObjsToggle.isChecked(), - getCurrentZfunc=self.z_lab, - getObjCentroidFunc=self.getObjCentroid - ) - self.textAnnot[0].update() - self.textAnnot[1].update() - return delROIsIDs - - def setAllContoursImages(self, delROIsIDs=None, compute=True): - if compute: - self.computeAllContours() - self.updateContoursImage(ax=0, delROIsIDs=delROIsIDs, compute=compute) - self.updateContoursImage(ax=1, delROIsIDs=delROIsIDs, compute=compute) - - def setAllLostObjContoursImage(self, delROIsIDs=None): - self.updateLostContoursImage(ax=0, delROIsIDs=None) - self.updateLostContoursImage(ax=1, delROIsIDs=None) - - def setAllLostTrackedObjContoursImage(self, delROIsIDs=None): - self.updateLostTrackedContoursImage(ax=0, delROIsIDs=None) - self.updateLostTrackedContoursImage(ax=1, delROIsIDs=None) - - def nextFrameImage(self, current_frame_i=None): - if not self.labelsGrad.showNextFrameAction.isEnabled(): - return - - if not self.labelsGrad.showNextFrameAction.isChecked(): - return - - posData = self.data[self.pos_i] - if current_frame_i is None: - current_frame_i = posData.frame_i - - next_frame_i = current_frame_i + 1 - if next_frame_i >= len(posData.img_data): - img = posData.img_data[-1] - else: - img = posData.img_data[next_frame_i] - - if posData.SizeZ > 1: - img = self.get_2Dimg_from_3D(img, isLayer0=True) - - # img = self.normalizeIntensities(img) - - return img - - def onKeyHome(self): - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepAdd - ) - - def onKeyEnd(self): - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepSub - ) - - def onKeyPageUp(self): - isAutoPilotActive = ( - self.autoPilotZoomToObjToggle.isChecked() - and self.autoPilotZoomToObjToolbar.isVisible() - ) - if isAutoPilotActive: - self.pointsLayerAutoPilot('next') - elif self.zSliceScrollBar.isVisible(): - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepAdd - ) - - def onKeyPageDown(self): - isAutoPilotActive = ( - self.autoPilotZoomToObjToggle.isChecked() - and self.autoPilotZoomToObjToolbar.isVisible() - ) - if isAutoPilotActive: - self.pointsLayerAutoPilot('prev') - elif self.zSliceScrollBar.isVisible(): - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepAdd - ) - - def keyUpCallback( - self, isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive - ): - isAutoPilotActive = ( - self.autoPilotZoomToObjToggle.isChecked() - and self.autoPilotZoomToObjToolbar.isVisible() - ) - if isBrushActive: - brushSize = self.brushSizeSpinbox.value() - self.brushSizeSpinbox.setValue(brushSize+1) - elif isWandActive: - wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() - self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance+1) - elif isExpandLabelActive: - self.expandLabel(dilation=True) - self.expandFootprintSize += 1 - elif isLabelRoiCircActive: - val = self.labelRoiCircularRadiusSpinbox.value() - self.labelRoiCircularRadiusSpinbox.setValue(val+1) - elif isAutoPilotActive: - self.pointsLayerAutoPilot('next') - else: - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepAdd - ) - - def keyDownCallback( - self, isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive - ): - isAutoPilotActive = ( - self.autoPilotZoomToObjToggle.isChecked() - and self.autoPilotZoomToObjToolbar.isVisible() - ) - if isBrushActive: - brushSize = self.brushSizeSpinbox.value() - self.brushSizeSpinbox.setValue(brushSize-1) - elif isWandActive: - wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() - self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance-1) - elif isExpandLabelActive: - self.expandLabel(dilation=False) - self.expandFootprintSize += 1 - elif isLabelRoiCircActive: - val = self.labelRoiCircularRadiusSpinbox.value() - self.labelRoiCircularRadiusSpinbox.setValue(val-1) - elif isAutoPilotActive: - self.pointsLayerAutoPilot('prev') - elif self.isNavigateActionOnNextFrame(): - posData = self.data[self.pos_i] - self.rightImageFramesScrollbar.setValue(posData.frame_i+2) - else: - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepSub - ) - - # @exec_time - @exception_handler - def updateAllImages( - self, image=None, computePointsLayers=True, computeContours=True, - updateLookuptable=True - ): - self.clearAllItems() - - posData = self.data[self.pos_i] - - self.last_pos_i = self.pos_i - self.last_frame_i = posData.frame_i - - self.rescaleIntensitiesLut(setImage=False) - - self.setImageImg1(image=image) - self.setImageImg2(updateLookuptable=updateLookuptable) - - self.setOverlayImages() - - self.setOverlayLabelsItems() - self.setOverlaySegmMasks() - - if self.slideshowWin is not None: - self.slideshowWin.frame_i = posData.frame_i - self.slideshowWin.update_img() - - # self.update_rp() - - # Annotate ID and draw contours - delROIsIDs = self.setAllTextAnnotations() - self.setAllContoursImages( - delROIsIDs=delROIsIDs, compute=False - ) - - mode = self.modeComboBox.currentText() - self.drawAllMothBudLines() - if mode == 'Normal division: Lineage tree': - self.drawAllLineageTreeLines() - - self.highlightLostNew() - - if self.ccaTableWin is not None: # need to add for lin tree, later - zoomIDs = self.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - self.doCustomAnnotation(0) - - self.annotate_rip_and_bin_IDs() - self.updateTempLayerKeepIDs() - self.whitelistUpdateTempLayer() - self.drawPointsLayers(computePointsLayers=computePointsLayers) - self.setManualBackgroundImage() - self.annotateAssignedObjsAcdcTrackerSecondStep() - - self.highlightSearchedID(self.highlightedID, force=True) - self.updateTimestampFrame() - - posData.visited = True - - def updateTimestampFrame(self): - if not hasattr(self, 'timestamp'): - return - - if not self.addTimestampAction.isChecked(): - return - - posData = self.data[self.pos_i] - self.timestamp.setText(posData.frame_i) - - def deleteIDFromLab( - self, lab, delID, frame_i=None, delMask=None, shift=False - ): - posData = self.data[self.pos_i] - frame_i = posData.frame_i if frame_i is None else frame_i - - if shift and self.isSegm3D: - lab3D = lab - delMask3D = delMask - lab = self.get_2Dlab(lab) - if delMask is not None: - delMask = self.get_2Dlab(delMask) - rp = skimage.measure.regionprops(lab) - IDs_idxs = {obj.label: idx for idx, obj in enumerate(rp)} - else: - if frame_i==posData.frame_i: - rp = posData.rp - IDs_idxs = posData.IDs_idxs - else: - rp = posData.allData_li[frame_i]['regionprops'] - IDs_idxs = posData.allData_li[frame_i]['IDs_idxs'] - - if isinstance(delID, int): - delID = [delID] - - is_any_id_present = False - for _delID in delID: - if _delID in IDs_idxs: - is_any_id_present = True - break - - if not is_any_id_present: - return lab, delMask - - if delMask is None: - delMask = np.zeros(lab.shape, dtype=bool) - else: - delMask[:] = False - - for _delID in delID: - idx = IDs_idxs.get(_delID, None) - if idx is None: - continue - obj = rp[idx] - delMask[obj.slice][obj.image] = True - lab[delMask] = 0 - - if shift and self.isSegm3D: - self.set_2Dlab(lab, lab3D=lab3D) - lab = lab3D - if delMask3D is not None: - self.set_2Dlab(delMask, lab3D=delMask3D) - delMask = delMask3D - - return lab, delMask - - def removeStoredContours(self, delID, frame_i=None, z_slice=None): - posData = self.data[self.pos_i] - - if frame_i is None: - frame_i = posData.frame_i - - dataDict = posData.allData_li[posData.frame_i] - try: - newContours = {} - for key, contours in dataDict['contours'].items(): - ID = key[0] - if ID == delID: - continue - - if z_slice is not None: - z_slice_i = key[1] - if z_slice_i != z_slice: - continue - - newContours[key] = contours - - dataDict['contours'] = newContours - except KeyError as err: - pass - - @disableWindow - def deleteIDmiddleClick( - self, delIDs: Iterable, applyFutFrames, includeUnvisited, - shift=False - ): - self.clearHighlightedID() - - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - - # Apply Delete ID to future frames if requested - if applyFutFrames: - delMask = np.zeros(posData.lab.shape, dtype=bool) - # Store current data before going to future frames - self.store_data() - segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i+1, segmSizeT): - lab = posData.allData_li[i]['labels'] - if lab is None and not includeUnvisited: - self.enqAutosave() - break - - if lab is not None: - # Visited frame - lab, _ = self.deleteIDFromLab( - lab, delIDs, frame_i=i, delMask=delMask, shift=shift - ) - - # Store change - posData.allData_li[i]['labels'] = lab - # Get the rest of the stored metadata based on the new lab - posData.frame_i = i - self.get_data() - self.store_data(autosave=False) - elif includeUnvisited: - # Unvisited frame (includeUnvisited = True) - lab = posData.segm_data[i] - lab, _ = self.deleteIDFromLab( - lab, delIDs, frame_i=i, delMask=delMask, shift=shift - ) - - # Back to current frame - if applyFutFrames: - posData.frame_i = current_frame_i - self.get_data() - - z_slice = None - if shift and self.isSegm3D: - z_slice = self.z_lab() - - posData.lab, delID_mask = self.deleteIDFromLab( - posData.lab, delIDs, shift=shift - ) - for _delID in delIDs: - self.clearObjContour(ID=_delID, ax=0) - self.clearObjContour(ID=_delID, ax=1) - if z_slice is None: - self.removeObjectFromRp(_delID) - self.removeStoredContours(_delID, z_slice=z_slice) - - if shift and self.isSegm3D: - self.update_rp() - - self.store_data(autosave=False) - self.whitelistPropagateIDs(IDs_to_remove=delIDs, curr_frame_only=(not applyFutFrames)) - return delID_mask - - def hideOverlayLabelsItems(self, specific=None): - if specific is None: - specific = self.overlayLabelsItems.keys() - for segmEndname in specific: - imageItem, contoursItem, gradItem = self.overlayLabelsItems[segmEndname] - imageItem.setVisible(False) - contoursItem.setVisible(False) - gradItem.setVisible(False) - - def showOverlayLabelsItems(self, specific=None): - if specific is None: - specific = self.overlayLabelsItems.keys() - for segmEndname in specific: - imageItem, contoursItem, gradItem = self.overlayLabelsItems[segmEndname] - drawMode = self.drawModeOverlayLabelsChannels[segmEndname] - if drawMode == 'Draw contours': - contoursItem.setVisible(True) - elif drawMode == 'Overlay labels': - imageItem.setVisible(True) - gradItem.setVisible(True) - - def setOverlayLabelsItems(self, specific=None): - if not self.overlayLabelsButton.isChecked(): - self.hideOverlayLabelsItems(specific=specific) - return - - if specific is None: - specific = self.drawModeOverlayLabelsChannels.keys() - - for segmEndname in specific: - drawMode = self.drawModeOverlayLabelsChannels[segmEndname] - ol_lab = self.getOverlayLabelsData(segmEndname) - items = self.overlayLabelsItems[segmEndname] - imageItem, contoursItem, gradItem = items - contoursItem.clear() - if drawMode == 'Draw contours': - for obj in skimage.measure.regionprops(ol_lab): - contours = self.getObjContours( - obj, all_external=True - ) - for cont in contours: - contoursItem.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) - elif drawMode == 'Overlay labels': - imageItem.setImage(ol_lab, autoLevels=False) - self.showOverlayLabelsItems(specific=specific) - - def getOverlayLabelsData(self, segmEndname): - posData = self.data[self.pos_i] - - if posData.ol_labels_data is None: - self.loadOverlayLabelsData(segmEndname) - elif segmEndname not in posData.ol_labels_data: - self.loadOverlayLabelsData(segmEndname) - - comb_seg = False - if 'combined segm.' == segmEndname: - comb_seg = True - if not self.isSegm3D: - zStackImg = self.data[0].SizeZ > 1 - if zStackImg: - selected_z_stack = self.zSliceScrollBar.sliderPosition() - else: - selected_z_stack = 0 - out = posData.ol_labels_data['combined segm.'][posData.frame_i][selected_z_stack] - return out.astype(np.uint32) - - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - z = self.zSliceScrollBar.sliderPosition() - ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i][z] - if comb_seg: - ol_lab = ol_lab.astype(np.uint32) - return ol_lab - else: - ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i].max(axis=0) - if comb_seg: - ol_lab = ol_lab.astype(np.uint32) - return ol_lab - else: - return posData.ol_labels_data[segmEndname][posData.frame_i] - - def loadOverlayLabelsData(self, segmEndname, pos_i=None): - if pos_i is None: - pos_i = self.pos_i - posData = self.data[pos_i] - - if posData.ol_labels_data is None: - posData.ol_labels_data = {} - if segmEndname == 'combined segm.': - posData.ol_labels_data['combined segm.'] = posData.combine_img_data - return - filePath, filename = load.get_path_from_endname( - segmEndname, posData.images_path - ) - self.logger.info(f'Loading "{segmEndname}.npz"...') - labelsData = np.load(filePath)['arr_0'] - if posData.SizeT == 1: - labelsData = labelsData[np.newaxis] - if self.isSegm3D and labelsData.ndim == 3: - # 2D segm --> stack to 3D - T, Y, X = labelsData.shape - repeat = [labelsData]*posData.SizeZ - labelsData = np.stack(repeat, axis=1) - - - posData.ol_labels_data[segmEndname] = labelsData - - def startBlinkingModeCB(self): - try: - self.timer.stop() - self.stopBlinkTimer.stop() - except Exception as e: - pass - if self.rulerButton.isChecked(): - return - self.timer = QTimer(self) - self.timer.timeout.connect(self.blinkModeComboBox) - self.timer.start(200) - self.stopBlinkTimer = QTimer(self) - self.stopBlinkTimer.timeout.connect(self.stopBlinkingCB) - self.stopBlinkTimer.start(2000) - - def blinkModeComboBox(self): - if self.flag: - self.modeComboBox.setStyleSheet('background-color: orange') - else: - self.modeComboBox.setStyleSheet('background-color: none') - self.flag = not self.flag - - def stopBlinkingCB(self): - self.timer.stop() - self.modeComboBox.setStyleSheet('background-color: none') - - def highlightNewIDs_ccaFailed(self, IDsWithIssue, rp=None): - if rp is None: - posData = self.data[self.pos_i] - rp = posData.rp - for obj in rp: - if obj.label not in IDsWithIssue: - continue - self.setCcaIssueContour(obj) - - # @exec_time - def highlightLostNew(self): - if self.modeComboBox.currentText() == 'Viewer': - return - - posData = self.data[self.pos_i] - delROIsIDs = self.getDelRoisIDs() - - # self.setAllContoursImages(delROIsIDs=delROIsIDs) - if posData.frame_i == 0: - return - - if not self.annotLostObjsToggle.isChecked(): - return - - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - - if prev_rp is None: - return - - self.setAllLostObjContoursImage(delROIsIDs=delROIsIDs) - self.setAllLostTrackedObjContoursImage(delROIsIDs=delROIsIDs) - - def addLostObjsToLostObjImage(self, lostObj, lostID, force=False): - if not force: - if not self.copyLostObjButton.isChecked(): - return - - obj_slice = self.getObjSlice(lostObj.slice) - obj_image = self.getObjImage(lostObj.image, lostObj.bbox) - self.lostObjImage[obj_slice][obj_image] = lostID - - def highlightHoverLostObj(self, modifiers, event): - noModifier = modifiers == Qt.NoModifier - if not noModifier: - return - - if not self.copyLostObjButton.isChecked(): - return - - if event.isExit(): - return - - posData = self.data[self.pos_i] - x, y = event.pos() - xdata, ydata = int(x), int(y) - try: - hoverLostID = self.lostObjImage[ydata, xdata] - except IndexError: - return - - self.ax1_lostObjScatterItem.hoverLostID = hoverLostID - if hoverLostID == 0: - self.ax1_lostObjScatterItem.setSize(self.contLineWeight+1) - self.ax1_lostObjScatterItem.setData([], []) - else: - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] - lostObj = prev_rp[prev_IDs_idxs[hoverLostID]] - obj_contours = self.getObjContours(lostObj, all_external=True) - for cont in obj_contours: - xx = cont[:,0] - yy = cont[:,1] - self.ax1_lostObjScatterItem.addPoints(xx, yy) - self.ax1_lostObjScatterItem.setSize(self.contLineWeight+2) - - def annotLostObjsToggled(self, checked): - if not self.isDataLoaded: - return - self.updateAllImages() - - def getPrevFrameIDs(self, current_frame_i=None): - posData = self.data[self.pos_i] - if current_frame_i is None: - current_frame_i = posData.frame_i - - if current_frame_i is None: - return [] - - prev_frame_i = current_frame_i - 1 - prevIDs = posData.allData_li[prev_frame_i]['IDs'] - - if prevIDs: - return prevIDs - - # IDs in previous frame were not stored --> load prev lab from HDD - prev_lab = self.get_labels( - from_store=False, - frame_i=prev_frame_i, - return_copy=False - ) - rp = skimage.measure.regionprops(prev_lab) - prevIDs = [obj.label for obj in rp] - return prevIDs - - # @exec_time - def setLostNewOldPrevIDs(self): - posData = self.data[self.pos_i] - if posData.frame_i == 0: - posData.lost_IDs = [] - posData.new_IDs = [] - posData.old_IDs = [] - # posData.multiContIDs = set() - self.titleLabel.setText('Looking good!', color=self.titleColor) - return [] - - # elif self.modeComboBox.currentText() == 'Viewer': - # pass - - out = self.updateLostNewCurrentIDs() - lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs = ( - out - ) - self.setTitleText( - lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs - ) - return curr_delRoiIDs - - - def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): - if not IDs: - return htmlTxt_li, htmlTxtFull_li - - if isinstance(IDs, set): - IDs = list(IDs) - - trim_IDs = myutils.get_trimmed_list(IDs) - txt = f'{pretxt}: {trim_IDs}' - txt_full = f'{pretxt}:
{IDs}' - - txt = f'{txt}' - txt_full = f'{txt_full}' - - htmlTxt_li.append(txt) - htmlTxtFull_li.append(txt_full) - - return htmlTxt_li, htmlTxtFull_li - - def setTitleText( - self, lost_IDs=None, new_IDs=None, IDs_with_holes=None, - tracked_lost_IDs=None - ): - if self.manualAnnotPastButton.isChecked(): - lockedID = self.editIDspinbox.value() - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - txt = ( - f'Locked ID {lockedID} ' - f'since frame n. {frame_to_restore+1}' - ) - htmlTxt = f'{txt}' - self.titleLabel.setText(htmlTxt) - return - - mode = self.modeComboBox.currentText() - try: - posData = self.data[self.pos_i] - posData.segm_data[posData.frame_i] - prev_segmented = True - except IndexError: - prev_segmented = False - - if prev_segmented: - htmlTxt_li = [] - htmlTxtFull_li = [] - else: - htmlTxt = f'Never segmented frame. ' - self.titleLabel.setText(htmlTxt) - self.titleLabel.setToolTip(htmlTxt) - return - - if mode != 'Normal division: Lineage tree': - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'IDs lost', 'orange', lost_IDs - ) - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'New IDs', 'red', new_IDs - ) - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'Acc. IDs lost', 'green', - tracked_lost_IDs - ) - - for i, htmlTxtFull in enumerate(htmlTxtFull_li): - htmlTxtFull_li[i] = htmlTxtFull.replace('Acc.', 'Accepted') - - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'IDs with holes', 'red', - IDs_with_holes - ) - else: - try: - cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i) - except IndexError or KeyError: - title = 'Processing lineage tree...' - htmlTxt = f'{title}' - self.titleLabel.setText(htmlTxt) - self.titleLabel.setToolTip(htmlTxt) - return - except AttributeError: - title = 'Lineage tree still initializing...' - htmlTxt = f'{title}' - self.titleLabel.setText(htmlTxt) - self.titleLabel.setToolTip(htmlTxt) - return - - parent_cell_txt_raw = [] - if cells_with_parent: - # aggregate same parents - parent_cell_groups = dict() - for cell, parent in cells_with_parent: - if parent not in parent_cell_groups: - parent_cell_groups[parent] = [] - parent_cell_groups[parent].append(cell) - for parent, daughters in parent_cell_groups.items(): - cells_str = ','.join([str(daughter) for daughter in daughters]) - parent_cell_txt_raw.append(f'({parent}>{cells_str})') - - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'New w/out mother', 'red', - orphan_cells - ) - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'Lost', 'yellow', lost_cells - ) - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'Parent > Cell', 'green', - parent_cell_txt_raw - ) - - if not htmlTxt_li: - title = 'Looking good' - htmlTxt = f'{title}' - self.titleLabel.setText(htmlTxt) - self.titleLabel.setToolTip(htmlTxt) - return - - htmlTxt = ', '.join(htmlTxt_li) - htmlTxtFull = '
'.join(htmlTxtFull_li) - - self.titleLabel.setText(htmlTxt) - self.titleLabel.setToolTip(htmlTxtFull) - - def separateByLabelling(self, lab, rp, maxID=None): - """ - Label each single object in posData.lab and if the result is more than - one object then we insert the separated object into posData.lab - """ - setRp = False - posData = self.data[self.pos_i] - if maxID is None: - maxID = max(posData.IDs, default=1) - for obj in rp: - lab_obj = skimage.measure.label(obj.image) - rp_lab_obj = skimage.measure.regionprops(lab_obj) - if len(rp_lab_obj)<=1: - continue - lab_obj += maxID - _slice = obj.slice # self.getObjSlice(obj.slice) - _objMask = obj.image # self.getObjImage(obj.image) - lab[_slice][_objMask] = lab_obj[_objMask] - setRp = True - maxID += 1 - return setRp - - def isFirstTimeOnNextFrame(self): - posData = self.data[self.pos_i] - posData.last_tracked_i = self.navigateScrollBar.maximum()-1 - return posData.frame_i > posData.last_tracked_i - - def trackManuallyAddedObject( - self, added_IDs: List[int] | int | Set[int], isNewID: bool, - wl_update:bool=True, wl_track_og_curr:bool=False - ): - """Track object added manually on frame that was already visited. - - Parameters - ---------- - added_IDs : int | list of int | set - ID or IDs of the object added manually - isNewID : bool - If True, the added object is new - - Notes - ----- - This method tracks the new added object against the previous frame - labels. If the ID determined by tracking is different from `added_ID` - (meaning that tracking thinks the new ID should be changed to the - tracked ID) and the tracked ID is not already existing (which would - otherwise causing merging) we assign the tracked ID to the object with - `added_ID`. - - If instead the tracked ID is the same as `added_ID` we are dealing - with a truly new object. In this case we want to try tracking it against - the next frame (since the next frame was already validated). - As before, we assign the tracked ID (against the next frame) only if - not already existing in current frame (to avoid merging). - """ - if self.isSnapshot: - return - - if not isNewID: - return - - if isinstance(added_IDs, int): - added_IDs = [added_IDs] - - posData = self.data[self.pos_i] - tracked_lab = self.tracking( - enforce=True, assign_unique_new_IDs=False, return_lab=True, - IDs=added_IDs - ) - self.clearAssignedObjsSecondStep() - if tracked_lab is None: - return - - # Track only new object - prevIDs = posData.allData_li[posData.frame_i-1]['IDs'] - - # mask = np.zeros(posData.lab.shape, dtype=bool) - update_rp = False - - for added_ID in added_IDs: - # try: - # obj = posData.rp[added_ID] # ID not present - # mask[obj.slice][obj.image] = True - - # except IndexError as err: - mask = posData.lab == added_ID - try: - trackedID = tracked_lab[mask][0] - except IndexError as err: - # added_ID is not present - continue - - isTrackedIDalreadyPresentAndNotNew = ( - posData.IDs_idxs.get(trackedID) is not None - and added_ID != trackedID - ) - if isTrackedIDalreadyPresentAndNotNew: - continue - - isTrackedIDinPrevIDs = trackedID in prevIDs - if isTrackedIDinPrevIDs: - posData.lab[mask] = trackedID - else: - # New object where we can try to track against next frame - trackedID = self.trackNewIDtoNewIDsFutureFrame(added_ID, mask) - if trackedID is None: - self.clearAssignedObjsSecondStep() - continue - posData.lab[mask] = trackedID - - self.keepOnlyNewIDAssignedObjsSecondStep(trackedID) - update_rp = True - - if update_rp: - self.update_rp(wl_update=wl_update) - - def trackFrameCustomTracker( - self, prev_lab, currentLab, IDs=None, unique_ID=None - ): - if unique_ID is None: - unique_ID = self.setBrushID() - try: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - unique_ID=unique_ID, - IDs=IDs, - **self.track_frame_params, - ) - except TypeError as err: - if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: - try: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, IDs=IDs, - **self.track_frame_params - ) - except TypeError as err: - if str(err).find('an unexpected keyword argument \'IDs\'') != -1: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - **self.track_frame_params) - else: - raise err - elif str(err).find('an unexpected keyword argument \'IDs\'') != -1: - try: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - unique_ID=unique_ID, - **self.track_frame_params - ) - except TypeError as err: - if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - **self.track_frame_params - ) - else: - raise err - else: - raise err - return tracked_result - - def trackFrame( - self, prev_lab, prev_rp, curr_lab, curr_rp, curr_IDs, - assign_unique_new_IDs=True, IDs=None, unique_ID=None - ): - if self.trackWithAcdcAction.isChecked(): - tracked_result = CellACDC_tracker.track_frame( - prev_lab, prev_rp, curr_lab, curr_rp, - IDs_curr_untracked=curr_IDs, - setBrushID_func=self.setBrushID, - posData=self.data[self.pos_i], - assign_unique_new_IDs=assign_unique_new_IDs, - IDs=IDs, - unique_ID=unique_ID - ) - elif self.trackWithYeazAction.isChecked(): - tracked_result = self.tracking_yeaz.correspondence( - prev_lab, curr_lab, use_modified_yeaz=True, - use_scipy=True - ) - else: - tracked_result = self.trackFrameCustomTracker( - prev_lab, curr_lab, IDs=IDs, unique_ID=unique_ID - ) - - # Check if tracker also returns additional info - if isinstance(tracked_result, tuple): - tracked_lab, tracked_lost_IDs = tracked_result - self.handleAdditionalInfoRealTimeTracker(prev_rp, tracked_lost_IDs) - else: - tracked_lab = tracked_result - - return tracked_lab - - def clearAssignedObjsSecondStep(self): - posData = self.data[self.pos_i] - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None - - def trackSubsetIDs(self, subsetIDs: Iterable[int]): - posData = self.data[self.pos_i] - if posData.frame_i == 0: - return - - subsetLab = np.zeros_like(posData.lab) - for subsetID in subsetIDs: - subsetLab[posData.lab == subsetID] = subsetID - - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - tracked_lab = self.trackFrame( - prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, - assign_unique_new_IDs=True - ) - doUpdateRp = False - for subsetID in subsetIDs: - subsetIDmask = posData.lab == subsetID - trackedID = tracked_lab[subsetIDmask][0] - if trackedID == subsetID: - continue - - is_manually_edited = False - for y, x, new_ID in posData.editID_info: - if new_ID == subsetID: - # Do not track because it was manually edited - break - - posData.lab[subsetIDmask] = tracked_lab[subsetIDmask] - doUpdateRp = True - - if not doUpdateRp: - return - - self.update_rp() - - def doSkipTracking(self, against_next: bool, enforce: bool): - if self.isSnapshot: - return True - - mode = str(self.modeComboBox.currentText()) - if mode != 'Segmentation and Tracking': - return True - - if self.UserEnforced_DisabledTracking: - return True - - if not self.realTimeTrackingToggle.isChecked(): - return True - - posData = self.data[self.pos_i] - if against_next: - reference_lab = posData.allData_li[posData.frame_i+1]['labels'] - if reference_lab is None: - # Next frame never visited --> cannot track against next - return True - - if posData.frame_i == posData.SizeT - 1: - # Last frame --> cannot track against next - return True - - else: - # check that we are not on the last frame - if posData.frame_i == 0: - return True - - if enforce or self.UserEnforced_Tracking: - # Enforce even if not last visited frame - return False - - is_first_time_on_next_frame = self.isFirstTimeOnNextFrame() - skip_tracking = not is_first_time_on_next_frame - - return skip_tracking - - - # @exec_time - @exception_handler - def tracking( - self, enforce=False, DoManualEdit=True, - storeUndo=False, prev_lab=None, prev_rp=None, - return_lab=False, assign_unique_new_IDs=True, - separateByLabel=True, wl_update=True, - IDs=None, against_next=False, - ): - posData = self.data[self.pos_i] - - if self.doSkipTracking(against_next, enforce): - self.setLostNewOldPrevIDs() - return - - """Tracking starts here""" - staturBarLabelText = self.statusBarLabel.text() - self.statusBarLabel.setText('Tracking...') - - if storeUndo: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - # First separate by labelling - if separateByLabel: - maxID = max(posData.IDs, default=1) - setRp = core.split_connected_components( - posData.lab, rp=posData.rp, max_ID=maxID - ) - if setRp: - self.update_rp(wl_update=wl_update, ) - - if prev_lab is None: - if not against_next: - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - else: - prev_lab = posData.allData_li[posData.frame_i+1]['labels'] - if prev_rp is None: - if not against_next: - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - else: - prev_rp = posData.allData_li[posData.frame_i+1]['regionprops'] - - unique_ID = None - if posData.frame_i < self.get_last_tracked_i(): - unique_ID = self.setBrushID(return_val=True) - - tracked_lab = self.trackFrame( - prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, - assign_unique_new_IDs=assign_unique_new_IDs, IDs=IDs, - unique_ID=unique_ID - ) - - if DoManualEdit: - # Correct tracking with manually changed IDs - rp = skimage.measure.regionprops(tracked_lab) - IDs = [obj.label for obj in rp] - self.manuallyEditTracking(tracked_lab, IDs) - - if return_lab: - QTimer.singleShot(50, partial( - self.statusBarLabel.setText, staturBarLabelText - )) - return tracked_lab - - # Update labels, regionprops and determine new and lost IDs - posData.lab = tracked_lab - self.update_rp(wl_update=wl_update, ) - self.setAllTextAnnotations() - QTimer.singleShot(50, partial( - self.statusBarLabel.setText, staturBarLabelText - )) - - def handleAdditionalInfoRealTimeTracker(self, prev_rp, *args): - if self._rtTrackerName == 'CellACDC_normal_division': - tracked_lost_IDs = args[0] - self.setTrackedLostCentroids(prev_rp, tracked_lost_IDs) - elif self._rtTrackerName == 'CellACDC_2steps': - if args[0] is None: - return - posData = self.data[self.pos_i] - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = args[0] - - def keepOnlyNewIDAssignedObjsSecondStep(self, trackedID): - posData = self.data[self.pos_i] - annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) - - if annotInfo is None: - return - - new_objs_1st_step, lost_objs_1st_step = annotInfo - correct_new_objs, correct_lost_objs = [], [] - for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): - newObj_ID = posData.lab[newObj.slice][newObj.image][0] - if newObj_ID != trackedID: - continue - - correct_new_objs.append(newObj) - correct_lost_objs.append(lostObj) - - if not correct_new_objs: - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None - else: - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( - correct_new_objs, correct_lost_objs - ) - # self.annotateAssignedObjsAcdcTrackerSecondStep() - - def updateAssignedObjsAcdcTrackerSecondStep(self, newID): - posData = self.data[self.pos_i] - annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) - if annotInfo is None: - return - - new_objs_1st_step, lost_objs_1st_step = annotInfo - correct_new_objs, correct_lost_objs = [], [] - for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): - newObj_ID = posData.lab[newObj.slice][newObj.image][0] - if newObj_ID == newID: - # The ID of the new object tracked with 2nd step was - # manually edit --> do not annotate its linking to lost obj anymore - continue - correct_new_objs.append(newObj) - correct_lost_objs.append(lostObj) - - if not correct_new_objs: - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None - else: - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( - correct_new_objs, correct_lost_objs - ) - self.annotateAssignedObjsAcdcTrackerSecondStep() - - - def annotateAssignedObjsAcdcTrackerSecondStep(self): - posData = self.data[self.pos_i] - annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) - if annotInfo is None: - return - - new_objs_1st_step, lost_objs_1st_step = annotInfo - for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): - allContours = self.getObjContours(lostObj, all_external=True) - for objContours in allContours: - isObjVisible = self.isObjVisible(newObj.bbox) - if not isObjVisible: - continue - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - self.yellowContourScatterItem.addPoints(xx, yy) - - y1, x1 = self.getObjCentroid(lostObj.centroid) - y2, x2 = self.getObjCentroid(newObj.centroid) - xx, yy = core.get_line(y1, x1, y2, x2, dashed=False) - self.ax1_oldMothBudLinesItem.addPoints(xx, yy) - - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None - - def setTrackedLostCentroids(self, prev_rp, tracked_lost_IDs): - """Store centroids of those IDs the tracker decided is fine to lose - (e.g., upon standard cell division the ID of the mother is fine) - - Parameters - ---------- - prev_rp : skimage.measure.RegionProperties - List of region properties of the object in previous frame - tracked_lost_IDs : iterable - List-like container of the IDs that is fine to lose from previous - frame to current frame - - Note - ---- - This function stores the centroids because the user could change IDs - in multiple ways. Storing centroids is more robust. - """ - posData = self.data[self.pos_i] - frame_i = posData.frame_i - - for obj in prev_rp: - if obj.label not in tracked_lost_IDs: - continue - - int_centroid = tuple([int(val) for val in obj.centroid]) - try: - posData.tracked_lost_centroids[frame_i].add(int_centroid) - except KeyError: - posData.tracked_lost_centroids[frame_i] = {int_centroid} - - def getTrackedLostIDs(self, prev_lab=None, IDs_in_frames=None, frame_i=None): - trackedLostIDs = set() - posData = self.data[self.pos_i] - if self.isExportingVideo: - posData.trackedLostIDs = trackedLostIDs - return trackedLostIDs - - retrackedLostcent = set() - if frame_i is None: - frame_i = posData.frame_i - - if prev_lab is None: - prev_lab = self.get_labels( - from_store=True, - frame_i=posData.frame_i-1, - return_existing=False, - return_copy=False - ) - - if IDs_in_frames is None: - IDs_in_frames = posData.IDs - - try: - tracked_lost_centroids = posData.tracked_lost_centroids[frame_i] - except KeyError: - tracked_lost_centroids = set() - - for centroid in tracked_lost_centroids: - if len(centroid) < 3 and prev_lab.ndim == 3: - # Ignore wrongly stored centroids - continue - - ID = prev_lab[centroid] - if ID == 0: - continue - - if ID in IDs_in_frames: - retrackedLostcent.add(centroid) - continue - - trackedLostIDs.add(ID) - - posData.tracked_lost_centroids[frame_i] = ( - tracked_lost_centroids - retrackedLostcent - ) - posData.trackedLostIDs = trackedLostIDs - - return trackedLostIDs - - def manuallyEditTracking(self, tracked_lab, allIDs): - posData = self.data[self.pos_i] - infoToRemove = [] - # Correct tracking with manually changed IDs - maxID = max(allIDs, default=1) - for y, x, new_ID in posData.editID_info: - old_ID = tracked_lab[y, x] - if old_ID == 0 or old_ID == new_ID: - infoToRemove.append((y, x, new_ID)) - continue - if new_ID in allIDs: - tempID = maxID+1 - tracked_lab[tracked_lab == old_ID] = tempID - tracked_lab[tracked_lab == new_ID] = old_ID - tracked_lab[tracked_lab == tempID] = new_ID - else: - tracked_lab[tracked_lab == old_ID] = new_ID - if new_ID > maxID: - maxID = new_ID - for info in infoToRemove: - posData.editID_info.remove(info) - - def warnReinitLastSegmFrame(self): - current_frame_n = self.navigateScrollBar.value() - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - Are you sure you want to re-initialize the last visited and - validated frame to number {current_frame_n}?

- WARNING: If you save, all annotations after frame number - {current_frame_n} will be lost! - """) - msg.warning( - self, 'WARNING: Potential loss of data', txt, - buttonsTexts=('Cancel', 'Yes, I am sure') - ) - return msg.cancel - - def extendSegmDataIfNeeded(self, stopFrameNum): - posData = self.data[self.pos_i] - segmSizeT = len(posData.segm_data) - if stopFrameNum <= segmSizeT: - return - numFramesToAdd = stopFrameNum - segmSizeT - posData.allData_li.extend( - [myutils.get_empty_stored_data_dict() for i in range(numFramesToAdd)] - ) - lab_shape = posData.segm_data[0].shape - shapeToAdd = (numFramesToAdd, *lab_shape) - additionalSegmData = np.zeros(shapeToAdd, dtype=posData.segm_data.dtype) - extendedSegmData = np.concatenate((posData.segm_data, additionalSegmData)) - posData.segm_data = extendedSegmData - - def reInitLastSegmFrame( - self, checked=True, from_frame_i=None, updateImages=True, - force=False - ): - if not force: - cancel = self.warnReinitLastSegmFrame() - if cancel: - self.logger.info( - 'Re-initialization of last validated frame cancelled.' - ) - return - - posData = self.data[self.pos_i] - if from_frame_i is None: - from_frame_i = posData.frame_i - - self.lastFrameRanOnFirstVisitTools = posData.frame_i - - self.updateLastCheckedFrameWidgets(from_frame_i) - posData.last_tracked_i = from_frame_i - self.navigateScrollBar.setMaximum(from_frame_i+1) - self.navSpinBox.setMaximum(from_frame_i+1) - # self.navigateScrollBar.setMinimum(1) - - # posData.tracked_lost_centroids[from_frame_i-1] = set() - for i in range(from_frame_i, posData.SizeT): - if posData.allData_li[i]['labels'] is None: - break - - posData.segm_data[i] = posData.allData_li[i]['labels'] - posData.allData_li[i] = myutils.get_empty_stored_data_dict() - - posData.tracked_lost_centroids[i] = set() - posData.acdcTracker2stepsAnnotInfo.pop(i, None) - - if posData.acdc_df is not None: - frames = posData.acdc_df.index.get_level_values(0) - if from_frame_i in frames: - posData.acdc_df = posData.acdc_df.loc[:from_frame_i] - - self.removeAlldelROIsCurrentFrame() - - if not updateImages: - return - - self.updateAllImages() - - def resetAcceptedLostIDs(self, from_frame_i=None): - posData = self.data[self.pos_i] - if from_frame_i is None: - from_frame_i = posData.frame_i - - posData.tracked_lost_centroids[from_frame_i-1] = set() - for i in range(from_frame_i, posData.SizeT): - posData.tracked_lost_centroids[i] = set() - - def removeAllItems(self): - self.ax1.clear() - self.ax2.clear() - try: - self.chNamesQActionGroup.removeAction(self.userChNameAction) - except Exception as e: - pass - try: - posData = self.data[self.pos_i] - for action in self.fluoDataChNameActions: - self.chNamesQActionGroup.removeAction(action) - except Exception as e: - pass - try: - self.overlayButton.setChecked(False) - except Exception as e: - pass - - if hasattr(self, 'contoursImage'): - self.initContoursImage() - - def createUserChannelNameAction(self): - self.userChNameAction = QAction(self) - self.userChNameAction.setCheckable(True) - self.userChNameAction.setText(self.user_ch_name) - - def createChannelNamesActions(self): - # LUT histogram channel name context menu actions - self.chNamesQActionGroup = QActionGroup(self) - self.chNamesQActionGroup.addAction(self.userChNameAction) - posData = self.data[self.pos_i] - for action in self.fluoDataChNameActions: - self.chNamesQActionGroup.addAction(action) - action.setChecked(False) - - self.userChNameAction.setChecked(True) - - for action in self.overlayContextMenu.actions(): - action.setChecked(False) - - def restoreDefaultColors(self): - try: - color = self.defaultToolBarButtonColor - self.overlayButton.setStyleSheet(f'background-color: {color}') - except AttributeError: - # traceback.print_exc() - pass - - @exception_handler - def _createEmptyData(self): - self.MostRecentPath = self.getMostRecentPath() - exp_path = QFileDialog.getExistingDirectory( - self, - 'Select experiment folder where to create empty data', - self.MostRecentPath - ) - if not exp_path: - return - - pos_path = os.path.join(exp_path, 'Position_1') - images_path = os.path.join(pos_path, 'Images') - if os.path.exists(images_path): - raise FileExistsError(f'The following path already exists "{images_path}"') - - os.makedirs(images_path, exist_ok=True) - - basename = 'test_empty_' - tif_filename = f'{basename}channel_1.tif' - tif_filepath = os.path.join(images_path, tif_filename) - empty_img = np.zeros((256,256), dtype=np.uint8) - empty_img[0,0] = 255 - skimage.io.imsave(tif_filepath, empty_img) - - metadata_filename = f'{basename}metadata.csv' - metadata_filepath = os.path.join(images_path, metadata_filename) - df_metadata = pd.DataFrame({ - 'Description': ['basename'], - 'values': [basename] - }) - df_metadata.to_csv(metadata_filepath, index=False) - - self.isNewFile = True - self._openFolder(exp_path=images_path) - - - def segmNdimIndicatorClicked(self): - ndimText = self.segmNdimIndicator.text() - if ndimText == '2D': - alternativeNdimText = '3D' - toggleText = 'activate' - else: - alternativeNdimText = '2D' - toggleText = 'de-activate' - msg = widgets.myMessageBox(wrapText=False) - important_txt = (""" - The toggle to activate 3D segmentation is visible only when - the Number of z-slices is greater than 1. - """) - txt = html_utils.paragraph(f""" - This indicator shows that you are working with {ndimText} - segmentation masks.

- - If instead, you want to work with {alternativeNdimText} segmentation, - you need to initialize a new segmentation file.

- - To do so, go the menu on the top menubar File --> - New Segmentation File... and,
- at the dialog where you insert the metadata (Number of z-slices, - pixel size, etc.),
- {toggleText} the parameter called Work with 3D - segmentation masks (z-stack)
- as indicated in the screenshot below
. - {html_utils.to_admonition(important_txt, admonition_type='note')} -
- """) - msg.information( - self, 'Segmentation nmber of dimensions info', txt, - image_paths=':toggle_3D_screenshot.png' - ) - self.segmNdimIndicator.setChecked(True) - - def newFile(self): - self.newSegmEndName = '' - self.isNewFile = True - msg = widgets.myMessageBox(parent=self, showCentered=False) - msg.setWindowTitle('File or folder?') - msg.addText(html_utils.paragraph(f""" - Do you want to load an image file or Position - folder(s)? - """)) - loadPosButton = QPushButton('Load Position folder', msg) - loadPosButton.setIcon(QIcon(":folder-open.svg")) - loadFileButton = QPushButton('Load image file', msg) - loadFileButton.setIcon(QIcon(":image.svg")) - helpButton = widgets.helpPushButton('Help...') - msg.addButton(helpButton) - helpButton.disconnect() - helpButton.clicked.connect(self.helpNewFile) - msg.addCancelButton(connect=True) - msg.addButton(loadFileButton) - msg.addButton(loadPosButton) - loadPosButton.setDefault(True) - msg.exec_() - if msg.cancel: - return - - if msg.clickedButton == loadPosButton: - self._openFolder() - else: - self._openFile() - - def openNewWindow(self): - self.logger.info('Opening a new window...') - if self.launcherSlot is not None: - self.launcherSlot() - return - - winClass = self.__class__ - win = winClass( - self.app, parent=self, mainWin=self.mainWin, version=self._version - ) - win.run() - self.newWindows.append(win) - - def helpNewFile(self): - msg = widgets.myMessageBox(showCentered=False) - href = f'user manual' - txt = html_utils.paragraph(f""" - Cell-ACDC can open both a single image file or files structured - into Position folders.

- If you are just testing out you can load a single image file, but - in general we reccommend structuring your data into Position - folders.

- More info about Position folders in the {href} at the section - called "Create required data structure from microscopy file(s)". - """) - msg.information( - self, 'Help on Position folders', txt - ) - - def openFile(self, checked=False, file_path=None): - self.logger.info(f'Opening FILE "{file_path}"') - - self.isNewFile = False - self._openFile(file_path=file_path) - - def manageVersions(self): - posData = self.data[self.pos_i] - selectVersion = apps.SelectAcdcDfVersionToRestore(posData, parent=self) - selectVersion.exec_() - - if selectVersion.cancel: - return - - undoId = uuid.uuid4() - if posData.cca_df is not None: - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - - selectedTime = selectVersion.selectedTimestamp - - self.modeComboBox.setCurrentText('Viewer') - self.logger.info(f'Loading file from {selectedTime}...') - - acdc_df = load.read_acdc_df_from_archive( - selectVersion.archiveFilePath, selectVersion.selectedKey - ) - posData.acdc_df = acdc_df - frames = acdc_df.index.get_level_values(0) - last_visited_frame_i = frames.max() - current_frame_i = posData.frame_i - pbar = tqdm(total=last_visited_frame_i+1, ncols=100) - for frame_i in range(last_visited_frame_i+1): - posData.frame_i = frame_i - self.get_data() - if posData.cca_df is not None: - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - if posData.allData_li[frame_i]['labels'] is None: - pbar.update() - continue - - if frame_i not in frames: - acdc_df_i = pd.DataFrame(columns=acdc_df.columns) - acdc_df_i.drop(self.cca_df_colnames, axis=1, errors='ignore') - acdc_df_i.index.name = 'Cell_ID' - else: - acdc_df_i = acdc_df.loc[frame_i].dropna(axis=1, how='all') - - posData.allData_li[frame_i]['acdc_df'] = acdc_df_i - pbar.update() - pbar.close() - - # Back to current frame - posData.frame_i = current_frame_i - self.get_data(debug=False) - self.updateAllImages() - self.logger.info('Annotations correctly recovered.') - - def askUserChannelName(self, filename_no_ext, ext): - help_txt = html_utils.paragraph(f""" - Cell-ACDC requires that every image file has a basename and some - additional text, typically the channel name.

- The basename will be common to all created files, while the additional text is used to identify the image files. - """) - - basename = filename_no_ext - underscore_splits = filename_no_ext.split('_') - if len(underscore_splits) > 1: - channel_name = underscore_splits[-1] - basename = '_'.join(underscore_splits[:-1]) - else: - channel_name = 'channel_1' - - txt = html_utils.paragraph(f""" - Provide some text (e.g., the channel name) to append at the end of the image file. - """) - win = apps.filenameDialog( - basename=basename, - ext=ext, - hintText=txt, - defaultEntry=channel_name, - helpText=help_txt, - allowEmpty=False, - parent=self, - title='Provide channel name for image file', - ) - win.exec_() - if win.cancel: - return False, '' - - return True, win.entryText - - def warnUserCreationImagesFolder(self, images_path, ext): - msg = widgets.myMessageBox(wrapText=False) - txt = (f""" - Cell-ACDC requires a specific folder structure to load the data.

- Specifically, it requires the image(s) to be located in a - folder called Images.

- The file format of the images must be TIFF or NPZ - (.tif or .npz extension).

- You can choose to let Cell-ACDC create the required data structure - from your file,
- or you can stop the - process and manually place the image(s) into a folder called - Images.

- If you choose to proceed, Cell-ACDC will create the following - folder: - {images_path} -
- """) - - if ext == '.tif' or ext == '.npz': - txt = f'{txt}How do you want to proceed?' - else: - txt = f'{txt}Do you want to proceed?' - txt = html_utils.paragraph(txt) - - if ext == '.tif' or ext == '.npz': - copyButton = widgets.copyPushButton( - 'Copy the image into the new folder' - ) - moveButton = widgets.movePushButton( - 'Move the image into the new folder' - ) - _, copyButton, moveButton = msg.information( - self, 'Creating Images folder', txt, - buttonsTexts=('Cancel', copyButton, moveButton) - ) - if msg.cancel: - return False, None - - if msg.clickedButton == copyButton: - return True, True - elif msg.clickedButton == moveButton: - return True, False - - else: - msg.information( - self, 'Creating Images folder', txt, - buttonsTexts=('Cancel', 'Yes, proceed') - ) - if msg.cancel: - return False, None - - return True, True - - @exception_handler - def _openFile(self, file_path=None): - """ - Function used for loading an image file directly. - """ - if file_path is None: - self.MostRecentPath = self.getMostRecentPath() - file_path = QFileDialog.getOpenFileName( - self, 'Select image file', self.MostRecentPath, - "Image/Video Files (*.png *.tif *.tiff *.jpg *.jpeg *.mov *.avi *.mp4)" - ";;All Files (*)")[0] - if not file_path: - return - - filename, ext = os.path.splitext(os.path.basename(file_path)) - ext = ext.lower() - dirpath = os.path.dirname(file_path) - dirname = os.path.basename(dirpath) - filename = filename.rstrip('_') - channel_name = None - do_copy = True - if dirname != 'Images': - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - acdc_folder = f'{timestamp}_acdc' - exp_path = os.path.join(dirpath, acdc_folder, 'Images') - proceed, do_copy = self.warnUserCreationImagesFolder(exp_path, ext) - if not proceed: - self.logger.info('Loading image file cancelled.') - return - - proceed, channel_name = self.askUserChannelName( - filename, '.tif' - ) - if not proceed: - self.logger.info('Loading image file cancelled.') - return - - os.makedirs(exp_path, exist_ok=True) - else: - exp_path = dirpath - - if channel_name is not None: - # Check if user wants to use the existing channel name - underscore_splits = filename.split('_') - if len(underscore_splits) > 1: - default_ch_name = underscore_splits[-1] - if channel_name == default_ch_name: - filename = '_'.join(underscore_splits[:-1]) - - basename = f'{filename}_' - new_filename = f'{filename}_{channel_name}{ext}' - df_metadata = pd.DataFrame({ - 'Description': ['basename'], - 'values': [basename] - }) - metadata_csv_filename = f'{basename}metadata.csv' - metadata_csv_filepath = os.path.join( - exp_path, metadata_csv_filename - ) - df_metadata.to_csv(metadata_csv_filepath, index=False) - else: - new_filename = f'{filename}{ext}' - - if do_copy: - action_text = 'Copying' - else: - action_text = 'Moving' - - if ext == '.tif' or ext == '.npz': - new_filepath = os.path.join(exp_path, new_filename) - if not os.path.exists(new_filepath): - self.logger.info(f'{action_text} file to Images folder...') - if do_copy: - shutil.copy2(file_path, new_filepath) - else: - shutil.move(file_path, new_filepath) - self._openFolder(exp_path=exp_path, imageFilePath=new_filepath) - else: - self.logger.info(f'{action_text} file to .tif format...') - data = load.loadData(file_path, '', log_func=self.logger.info) - data.loadImgData() - img = data.img_data - if img.ndim == 3 and (img.shape[-1] == 3 or img.shape[-1] == 4): - self.logger.info('Converting RGB image to grayscale...') - if img.shape[-1] == 3: - data.img_data = skimage.color.rgb2gray(data.img_data) - else: - data.img_data = cv2.cvtColor( - data.img_data, cv2.COLOR_RGBA2GRAY - ) - data.img_data = skimage.img_as_ubyte(data.img_data) - new_filename_no_ext, ext = os.path.splitext(new_filename) - tif_filename = f'{new_filename_no_ext}.tif' - tif_path = os.path.join(exp_path, tif_filename) - if data.img_data.ndim == 3: - SizeT = data.img_data.shape[0] - SizeZ = 1 - elif data.img_data.ndim == 4: - SizeT = data.img_data.shape[0] - SizeZ = data.img_data.shape[1] - else: - SizeT = 1 - SizeZ = 1 - is_imageJ_dtype = ( - data.img_data.dtype == np.uint8 - or data.img_data.dtype == np.uint32 - or data.img_data.dtype == np.uint32 - or data.img_data.dtype == np.float32 - ) - if not is_imageJ_dtype: - data.img_data = skimage.img_as_ubyte(data.img_data) - - myutils.to_tiff(tif_path, data.img_data) - self._openFolder(exp_path=exp_path, imageFilePath=tif_path) - - def criticalNoTifFound(self, images_path): - err_title = 'No .tif files found in folder.' - err_msg = html_utils.paragraph( - 'The following folder

' - f'{images_path}

' - 'does not contain .tif or .h5 files.

' - 'Only .tif or .h5 files can be loaded with "Open Folder" button.

' - 'Try with File --> Open image/video file... ' - 'and directly select the file you want to load.' - ) - msg = widgets.myMessageBox() - msg.addShowInFileManagerButton(images_path) - msg.critical(self, err_title, err_msg) - - def reinitStoredSegmModels(self): - self.models = [None]*len(self.models) - - def checkAskSavePointsLayers(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - if action.layerTypeIdx != 4: - continue - - scatterItem = action.scatterItem - xx, yy = scatterItem.getData() - - if xx is None or len(xx) == 0: - toolButton = action.button - tableEndName = toolButton.clickEntryTableEndName - # Check in other loaded pos - are_there_points_to_save = False - for pos_i, _posData in enumerate(self.data): - if pos_i == self.pos_i: - continue - - df = _posData.clickEntryPointsDfs.get(tableEndName) - if df is None: - continue - - are_there_points_to_save = True - break - - if not are_there_points_to_save: - continue - - cancel = self.askSavePointsLayer(action) - if cancel: - return cancel - - return False - - def askSavePointsLayer(self, action): - toolButton = action.button - tableEndName = toolButton.clickEntryTableEndName - saveAction = toolButton.saveAction - - txt = html_utils.paragraph(f""" - Do you want to save the points you added - (table called {tableEndName}.csv)? - """ - ) - msg = widgets.myMessageBox(wrapText=False) - _, _, saveButton = msg.question( - self, 'Save points layer?', txt, - buttonsTexts=('Cancel', 'No, do not save', 'Yes, save points') - ) - if msg.clickedButton == saveButton: - self.savePointsAddedByClicking(saveAction.saveToolbutton, None) - - return msg.cancel - - def removeOverlayItems(self): - self.lutItemsLayout.clear() - - try: - for toolbutton in self.allOverlayToolbuttonsByIdx.values(): - self.overlayToolbar.removeAction(toolbutton.action) - - self.overlayToolbuttonsSep.removeFromToolbar() - except Exception as err: - pass - - def clearOverlayImageItems(self): - for items in self.overlayLayersItems.values(): - imageItem = items[0] - imageItem.clear() - - self.rgbaImg1.clear() - - def reInitGui(self): - cancel = self.checkAskSavePointsLayers() - if cancel: - return False - - if self.overlayToolbar.isTransparent(): - self.overlayToolbar.setTransparent(False) - - self.secondLevelToolbar.setVisible(False) - - self.gui_createLazyLoader() - - try: - self.navSpinBox.valueChanged.disconnect() - except Exception as e: - pass - - try: - self.scaleBar.removeFromAxis(self.ax1) - except Exception as e: - pass - - self.lineage_tree = None - self.getDistanceListMissingIDsCachedFrame = None - self.isZmodifier = False - self.zKeptDown = False - self.askRepeatSegment3D = True - self.askZrangeSegm3D = True - self.isDataLoaded = False - self.retainSizeLutItems = False - self.setMeasWinState = None - self.addPointsWin = None - self.delRoiLab = None - self.showPropsDockButton.setDisabled(True) - self.removeOverlayItems() - self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) - - self.reinitWidgetsPos() - self.removeAllItems() - self.reinitCustomAnnot() - self.reinitPointsLayers() - self.gui_createPlotItems() - self.setUncheckedAllButtons() - self.setUncheckedPointsLayers() - self.restoreDefaultColors() - self.reinitStoredSegmModels() - self.removeAxLimits() - self.curvToolButton.setChecked(False) - - self.wandControlsToolbar.setVisible(False) - self.wandToolButton.setChecked(False) - self.segmNdimIndicatorAction.setVisible(False) - - self.navigateToolBar.hide() - self.ccaToolBar.hide() - self.editToolBar.hide() - self.brushEraserToolBar.hide() - self.modeToolBar.hide() - - self.modeComboBox.setCurrentText('Viewer') - - alpha = self.imgGrad.labelsAlphaSlider.value() - self.labelsLayerImg1.setOpacity(alpha) - self.labelsLayerRightImg.setOpacity(alpha) - self.lastTrackedFrameLabel.setText('') - - self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False - - for action in self.askHowFutureFramesActions.values(): - action.setChecked(True) - action.setDisabled(True) - - return True - - def reinitPointsLayers(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - toolbar.removeAction(action) - toolbar.setVisible(False) - self.autoPilotZoomToObjToolbar.setVisible(False) - - def reinitWidgetsPos(self): - pass - # try: - # # self.highlightZneighObjCheckbox will be connected in - # # self.showHighlightZneighCheckbox() - # self.highlightZneighObjCheckbox.toggled.disconnect() - # except Exception as e: - # pass - # layout = self.bottomLeftLayout - # self.highlightZneighObjCheckbox.hide() - # try: - # layout.removeWidget(self.highlightZneighObjCheckbox) - # except Exception as e: - # pass - # self.highlightZneighObjCheckbox.hide() - # # layout.addWidget( - # # self.drawIDsContComboBox, 0, 1, 1, 2, - # # alignment=Qt.AlignCenter - # # ) - - def reinitCustomAnnot(self): - buttons = list(self.customAnnotDict.keys()) - for button in buttons: - self.clearScatterPlotCustomAnnotButton(button) - action = self.customAnnotDict[button]['action'] - self.annotateToolbar.removeAction(action) - self.checkableQButtonsGroup.removeButton(button) - self.customAnnotDict.pop(button) - # self.savedCustomAnnot.pop(name) - - self.saveCustomAnnot(only_temp=True) - - def loadingDataAborted(self): - self.openFolderAction.setEnabled(True) - self.titleLabel.setText('Loading data aborted.') - - def cleanUpOnError(self): - self.onEscape() - caller = 'Cell-ACDC' - if self.module.startswith('spotmax'): - caller = 'spotMAX' - txt = f'WARNING: {caller} is in error state. Please, restart.' - _hl = '*'*100 - self.titleLabel.setText(txt, color='r') - self.logger.info(f'{_hl}\n{txt}\n{_hl}') - - def openFolder( - self, checked=False, exp_path=None, imageFilePath='' - ): - if exp_path is None: - self.logger.info('Asking to select a folder path...') - else: - self.logger.info(f'Opening FOLDER "{exp_path}"...') - - self.isNewFile = False - if hasattr(self, 'data') and self.titleLabel.text != 'Saved!': - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Do you want to save before loading another dataset?' - ) - _, no, yes = msg.question( - self, 'Save?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') - ) - if msg.clickedButton == yes: - func = partial(self._openFolder, exp_path, imageFilePath) - cancel = self.saveData(finishedCallback=func) - return - elif msg.cancel: - self.store_data() - return - else: - self.store_data(autosave=False) - - self._openFolder( - exp_path=exp_path, imageFilePath=imageFilePath - ) - - def addToRecentPaths(self, path, logger=None): - myutils.addToRecentPaths(path, logger=self.logger) - - def getMostRecentPath(self): - return myutils.getMostRecentPath() - - @exception_handler - def _openFolder( - self, checked=False, exp_path=None, imageFilePath='' - ): - """Main function to load data. - - Parameters - ---------- - checked : bool - kwarg needed because openFolder can be called by openFolderAction. - exp_path : string or None - Path selected by the user either directly, through openFile, - or drag and drop image file. - imageFilePath : string - Path of the image file that was either drag and dropped or opened - from File --> Open image/video file (openFileAction). - - Returns - ------- - None - """ - - if exp_path is None: - self.MostRecentPath = self.getMostRecentPath() - exp_path = QFileDialog.getExistingDirectory( - self, - 'Select experiment folder containing Position_n folders ' - 'or specific Position_n folder', - self.MostRecentPath - ) - - if not exp_path: - self.openFolderAction.setEnabled(True) - return - - proceed = self.reInitGui() - if not proceed: - self.openFolderAction.setEnabled(True) - return - - self.openFolderAction.setEnabled(False) - - if self.slideshowWin is not None: - self.slideshowWin.close() - - if self.ccaTableWin is not None: - self.ccaTableWin.close() - - self.exp_path = exp_path - self.logger.info(f'Loading from {self.exp_path}') - self.addToRecentPaths(exp_path, logger=self.logger) - self.addPathToOpenRecentMenu(exp_path) - - folder_type = myutils.determine_folder_type(exp_path) - is_pos_folder, is_images_folder, exp_path = folder_type - - self.titleLabel.setText('Loading data...', color=self.titleColor) - - skip_channels = [] - ch_name_selector = prompts.select_channel_name( - which_channel='segm', allow_abort=False - ) - user_ch_name = None - if not is_pos_folder and not is_images_folder and not imageFilePath: - images_paths = self._loadFromExperimentFolder(exp_path) - if not images_paths: - self.loadingDataAborted() - return - - elif is_pos_folder and not imageFilePath: - pos_foldername = os.path.basename(exp_path) - exp_path = os.path.dirname(exp_path) - images_paths = [os.path.join(exp_path, pos_foldername, 'Images')] - - elif is_images_folder and not imageFilePath: - images_paths = [exp_path] - pos_path = os.path.dirname(exp_path) - exp_path = os.path.dirname(pos_path) - - elif imageFilePath: - # images_path = exp_path because called by openFile func - filenames = myutils.listdir(exp_path) - ch_names, basenameNotFound = ( - ch_name_selector.get_available_channels(filenames, exp_path) - ) - filename = os.path.basename(imageFilePath) - self.ch_names = ch_names - user_ch_name = [ - chName for chName in ch_names if filename.find(chName)!=-1 - ][0] - images_paths = [exp_path] - pos_path = os.path.dirname(exp_path) - exp_path = os.path.dirname(pos_path) - - self.images_paths = images_paths - - # Get info from first position selected - images_path = self.images_paths[0] - filenames = myutils.listdir(images_path) - if ch_name_selector.is_first_call and user_ch_name is None: - ch_names, _ = ch_name_selector.get_available_channels( - filenames, images_path - ) - self.ch_names = ch_names - if not ch_names: - self.openFolderAction.setEnabled(True) - self.criticalNoTifFound(images_path) - return - if len(ch_names) > 1: - CbLabel='Select channel name to load: ' - ch_name_selector.QtPrompt( - self, ch_names, CbLabel=CbLabel - ) - if ch_name_selector.was_aborted: - self.openFolderAction.setEnabled(True) - return - skip_channels.extend([ - ch for ch in ch_names if ch!=ch_name_selector.channel_name - ]) - else: - ch_name_selector.channel_name = ch_names[0] - ch_name_selector.setUserChannelName() - user_ch_name = ch_name_selector.user_ch_name - else: - # File opened directly with self.openFile - ch_name_selector.channel_name = user_ch_name - - user_ch_file_paths = [] - not_allowed_ends = ['btrack_tracks.h5'] - for images_path in self.images_paths: - channel_file_path = load.get_filename_from_channel( - images_path, user_ch_name, skip_channels=skip_channels, - not_allowed_ends=not_allowed_ends, logger=self.logger.info - ) - if not channel_file_path: - self.criticalImgPathNotFound(images_path) - return - user_ch_file_paths.append(channel_file_path) - - ch_name_selector.setUserChannelName() - self.user_ch_name = user_ch_name - self.img1.channelName = user_ch_name - - self.AutoPilotProfile.storeSelectedChannel(self.user_ch_name) - - self.initGlobalAttr() - self.createOverlayContextMenu() - self.createUserChannelNameAction() - self.gui_createOverlayColors() - self.gui_createOverlayItems() - lastRow = self.bottomLeftLayout.rowCount() - self.bottomLeftLayout.setRowStretch(lastRow+1, 1) - - self.num_pos = len(user_ch_file_paths) - proceed = self.loadSelectedData(user_ch_file_paths, user_ch_name) - if not proceed: - self.openFolderAction.setEnabled(True) - return - - def _loadFromExperimentFolder(self, exp_path): - select_folder = load.select_exp_folder() - values = select_folder.get_values_segmGUI(exp_path) - if not values: - self.criticalInvalidPosFolder(exp_path) - self.openFolderAction.setEnabled(True) - return [] - - if len(values) > 1: - select_folder.QtPrompt(self, values, allow_cancel=False) - if select_folder.cancel: - return [] - else: - select_folder.cancel = False - select_folder.selected_pos = select_folder.pos_foldernames - - images_paths = [] - for pos in select_folder.selected_pos: - images_paths.append(os.path.join(exp_path, pos, 'Images')) - return images_paths - - def criticalInvalidPosFolder(self, exp_path): - href = html_utils.href_tag('here', data_structure_docs_url) - txt = html_utils.paragraph(f""" - The selected folder:

- - {exp_path}

- - is not a valid folder.

- - Select a folder that contains the Position_n folders, - or a specific Position.

- - If you are trying to load a single image file go to - File --> Open image/video file....

- - To load a folder containing multiple .tif files the folder must - be called either Position_n
- (with n being an integer) or Images.

- - For more information about the correct folder structure see {href}. - """) - msg = widgets.myMessageBox(wrapText=False) - helpButton = widgets.helpPushButton('Help...') - msg.addButton(helpButton) - helpButton.clicked.disconnect() - helpButton.clicked.connect( - partial(myutils.browse_url, data_structure_docs_url) - ) - msg.addShowInFileManagerButton(exp_path) - msg.critical( - self, 'Incompatible folder', txt - ) - - def createOverlayContextMenu(self): - ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] - self.overlayContextMenu = QMenu() - self.overlayContextMenu.addSeparator() - self.checkedOverlayChannels = set() - for chName in ch_names: - action = QAction(chName, self.overlayContextMenu) - action.setCheckable(True) - action.toggled.connect(self.overlayChannelToggled) - self.overlayContextMenu.addAction(action) - - def createOverlayLabelsContextMenu(self, segmEndnames): - self.overlayLabelsContextMenu = QMenu() - self.overlayLabelsContextMenu.addSeparator() - self.drawModeOverlayLabelsChannels = {} - segmEndnames_extended = list(segmEndnames.copy()) - segmEndnames_extended = ['combined segm.'] + segmEndnames_extended - for segmEndname in segmEndnames_extended: - action = QAction(segmEndname, self.overlayLabelsContextMenu) - if segmEndname == 'combined segm.': - action.setCheckable(False) - self.combineSegmViewToggle = action - else: - action.setCheckable(True) - action.toggled.connect(self.addOverlayLabelsToggled) - self.overlayLabelsContextMenu.addAction(action) - - self.overlayLabelsContextMenu.addSeparator() - action = QAction('Edit appearance...', self.overlayLabelsContextMenu) - action.triggered.connect(self.editOverlayLabelsAppearance) - self.overlayLabelsContextMenu.addAction(action) - - def editOverlayLabelsAppearance(self, *args): - segmEndname = list(self.overlayLabelsItems.keys())[0] - contoursItem = self.overlayLabelsItems[segmEndname][1] - win = apps.OverlayLabelsAppearanceDialog( - scatterPlotItem=contoursItem, parent=self - ) - win.exec_() - if win.cancel: - return - - brush = win.properties['brush'] - pen = win.properties['pen'] - for items in self.overlayLabelsItems.values(): - imageItem, contoursItem, gradItem = items - contoursItem.setBrush(brush, update=False) - contoursItem.setPen(pen) - - def createOverlayLabelsItems(self, segmEndnames): - selectActionGroup = QActionGroup(self) - segmEndnames_extended = list(segmEndnames.copy()) - segmEndnames_extended = ['combined segm.'] + segmEndnames_extended - for segmEndname in segmEndnames_extended: - action = QAction(segmEndname) - if segmEndname == 'combined segm.': - action.setCheckable(False) - else: - action.setCheckable(True) - action.toggled.connect(self.setOverlayLabelsItemsVisible) - selectActionGroup.addAction(action) - self.selectOverlayLabelsActionGroup = selectActionGroup - - self.overlayLabelsItems = {} - for segmEndname in segmEndnames_extended: - imageItem = pg.ImageItem() - - gradItem = widgets.overlayLabelsGradientWidget( - imageItem, selectActionGroup, segmEndname - ) - gradItem.hide() - gradItem.drawModeActionGroup.triggered.connect( - self.overlayLabelsDrawModeToggled - ) - self.mainLayout.addWidget(gradItem, 0, 0) - - contoursItem = pg.ScatterPlotItem() - color = colors.get_complementary_color(self.contLineColor) - r, g, b, a = colors.rgba_str_to_values(color) - qcolor = QColor(r, g, b, a) - contoursItem.setData( - [], [], symbol='s', pxMode=False, size=self.contLineWeight*2, - brush=pg.mkBrush(color=qcolor), - pen=pg.mkPen(width=3, color=qcolor), tip=None - ) - - items = (imageItem, contoursItem, gradItem) - self.overlayLabelsItems[segmEndname] = items - - def addOverlayLabelsToggled(self, checked, name=None): - if name is None: - name = self.sender().text() - if checked: - gradItem = self.overlayLabelsItems[name][-1] - drawMode = gradItem.drawModeActionGroup.checkedAction().text() - self.drawModeOverlayLabelsChannels[name] = drawMode - else: - self.drawModeOverlayLabelsChannels.pop(name) - self.hideOverlayLabelsItems(specific=[name]) - self.setOverlayLabelsItems() - - def overlayLabelsDrawModeToggled(self, action): - segmEndname = action.segmEndname - drawMode = action.text() - if segmEndname in self.drawModeOverlayLabelsChannels: - self.drawModeOverlayLabelsChannels[segmEndname] = drawMode - self.setOverlayLabelsItems() - - def overlayChannelToggled(self, checked): - # Action toggled from overlayButton context menu - channelName = self.sender().text() - posData = self.data[self.pos_i] - if checked: - if channelName not in posData.loadedFluoChannels: - self.loadOverlayData([channelName], addToExisting=True) - else: - _, filename = self.getPathFromChName(channelName, posData) - posData.ol_data[filename] = ( - posData.ol_data_dict[filename].copy() - ) - - self.checkedOverlayChannels.add(channelName) - else: - self.checkedOverlayChannels.remove(channelName) - imageItem = self.overlayLayersItems[channelName][0] - imageItem.clear() - - self.setOverlayChannelsToolbuttonsChecked() - self.setOverlayItemsVisible() - self.setRetainSizePolicyLutItems() - self.updateAllImages() - - @exception_handler - def loadDataWorkerDataIntegrityWarning(self, pos_foldername): - err_msg = ( - 'WARNING: Segmentation mask file ("..._segm.npz") not found. ' - 'You could run segmentation module first.' - ) - self.workerProgress(err_msg, 'INFO') - self.titleLabel.setText(err_msg, color='r') - abort = False - msg = widgets.myMessageBox(parent=self) - warn_msg = html_utils.paragraph(f""" - The folder {pos_foldername} does not contain a - pre-computed segmentation mask.

- You can continue with a blank mask or cancel and - pre-compute the mask with the segmentation module.

- Do you want to continue? - """) - msg.setIcon(iconName='SP_MessageBoxWarning') - msg.setWindowTitle('Segmentation file not found') - msg.addText(warn_msg) - msg.addButton('Ok') - continueWithBlankSegm = msg.addButton(' Cancel ') - msg.show(block=True) - if continueWithBlankSegm == msg.clickedButton: - abort = True - self.loadDataWorker.abort = abort - self.loadDataWaitCond.wakeAll() - - def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): - total_ram = myutils._bytes_to_GB(total_ram) - available_ram = myutils._bytes_to_GB(available_ram) - required_ram = myutils._bytes_to_GB(required_ram) - required_perc = round(100*required_ram/available_ram) - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The total amount of data that you requested to load is about - {required_ram:.2f} GB ({required_perc}% of the available memory) - but there are only {available_ram:.2f} GB available.

- For optimal operation, we recommend loading maximum 30% - of the available memory. To do so, try to close open apps to - free up some memory. Another option is to crop the images - using the data prep module.

- If you choose to continue, the system might freeze - or your OS could simply kill the process.

- What do you want to do? - """) - cancelButton, continueButton = msg.warning( - self, 'Memory not sufficient', txt, - buttonsTexts=('Cancel', 'Continue anyway') - ) - if msg.clickedButton == continueButton: - # Disable autosaving since it would keep a copy of the data and - # we cannot afford it with low memory - self.autoSaveToggle.setChecked(False) - return True - else: - return False - - def checkMemoryRequirements(self, required_ram): - memory = psutil.virtual_memory() - total_ram = memory.total - available_ram = memory.available - if required_ram/available_ram > 0.3: - proceed = self.warnMemoryNotSufficient( - total_ram, available_ram, required_ram - ) - return proceed - else: - return True - - def criticalImgPathNotFound(self, images_path): - self.logger.info( - 'The following folder does not contain valid image files: ' - f'"{images_path}"\n\n' - 'Check that all the positions loaded contain the same channel name. ' - 'Make sure to double check for spelling mistakes or types in the ' - 'channel names.' - ) - msg = widgets.myMessageBox() - msg.addShowInFileManagerButton(images_path) - err_msg = html_utils.paragraph(f""" - The folder

- {images_path}

- does not contain any valid image file!

- Valid file formats are .h5, .tif, _aligned.h5, _aligned.npz. - """) - okButton = msg.critical( - self, 'No valid files found!', err_msg, buttonsTexts=('Ok',) - ) - - def initRealTimeTracker(self, force=False): - for rtTrackerAction in self.trackingAlgosGroup.actions(): - if rtTrackerAction.isChecked(): - break - - aliases = myutils.aliases_real_time_trackers(reverse=True) - - rtTracker = rtTrackerAction.text() - rtTracker_txt = rtTracker - - if rtTracker in aliases: - rtTracker = aliases[rtTracker] - - if rtTracker == 'Cell-ACDC': - return - if rtTracker == 'YeaZ': - return - - if self.isRealTimeTrackerInitialized and not force: - return - - self.logger.info(f'Initializing {rtTracker_txt} tracker...') - self._rtTrackerName = rtTracker - posData = self.data[self.pos_i] - realTimeTracker, track_frame_params = myutils.init_tracker( - posData, rtTracker, qparent=self, realTime=True - ) - if realTimeTracker is None: - self.logger.info(f'{rtTracker} tracker initialization cancelled.') - return - - self.realTimeTracker = realTimeTracker - self.track_frame_params = track_frame_params - self.logger.info(f'{rtTracker} tracker successfully initialized.') - if 'image_channel_name' in self.track_frame_params: - # Remove the channel name since it was already loaded in init_tracker - del self.track_frame_params['image_channel_name'] - - def initFluoData(self): - if len(self.ch_names) <= 1: - return - - if 'ask_load_fluo_at_init' in self.df_settings.index: - if self.df_settings.at['ask_load_fluo_at_init', 'value'] == 'No': - return - msg = widgets.myMessageBox(allowClose=False) - txt = ( - 'Do you also want to load fluorescence images?
' - 'You can load as many channels as you want.

' - 'If you load fluorescence images then the software will ' - 'calculate metrics for each loaded fluorescence channel ' - 'such as min, max, mean, quantiles, etc. ' - 'of each segmented object.

' - 'NOTE: You can always load them later from the menu ' - 'File --> Load fluorescence images... or when you set ' - 'measurements from the menu ' - 'Measurements --> Set measurements...' - ) - msg.addDoNotShowAgainCheckbox(text="Don't ask again") - no, yes = msg.question( - self, 'Load fluorescence images?', html_utils.paragraph(txt), - buttonsTexts=('No', 'Yes') - ) - if msg.doNotShowAgainCheckbox.isChecked(): - self.df_settings.at['ask_load_fluo_at_init', 'value'] = 'No' - self.df_settings.to_csv(self.settings_csv_path) - if msg.clickedButton == yes: - self.loadFluo_cb(None) - self.AutoPilotProfile.storeClickMessageBox( - 'Load fluorescence images?', msg.clickedButton.text() - ) - - def getPathFromChName(self, chName, posData): - ls = myutils.listdir(posData.images_path) - endnames = {f[len(posData.basename):]:f for f in ls} - validEnds = ['_aligned.npz', '_aligned.h5', '.h5', '.tif', '.npz'] - for end in validEnds: - files = [ - filename for endname, filename in endnames.items() - if endname == f'{chName}{end}' - ] - if files: - filename = files[0] - break - else: - self.criticalFluoChannelNotFound(chName, posData) - self.app.restoreOverrideCursor() - return None, None - - fluo_path = os.path.join(posData.images_path, filename) - filename, _ = os.path.splitext(filename) - return fluo_path, filename - - def loadPosTriggered(self): - if not self.isDataLoaded: - return - - self.startAutomaticLoadingPos() - - def startAutomaticLoadingPos(self): - self.AutoPilot = autopilot.AutoPilot(self) - self.AutoPilot.execLoadPos() - - def stopAutomaticLoadingPos(self): - if self.AutoPilot is None: - return - - if self.AutoPilot.timer.isActive(): - self.AutoPilot.timer.stop() - self.AutoPilot = None - - def startCcaIntegrityCheckerWorker(self): - if not hasattr(self, 'data'): - return - - if not self.isDataLoaded: - return - - if not self.ccaIntegrCheckerToggle.isChecked(): - return - - ccaCheckerThread = QThread() - self.ccaCheckerMutex = QMutex() - self.ccaCheckerWaitCond = QWaitCondition() - - worker = workers.CcaIntegrityCheckerWorker( - self.ccaCheckerMutex, self.ccaCheckerWaitCond - ) - self.ccaIntegrityCheckerWorker = worker - self.ccaCheckerThread = ccaCheckerThread - - worker.moveToThread(ccaCheckerThread) - worker.finished.connect(ccaCheckerThread.quit) - worker.finished.connect(worker.deleteLater) - ccaCheckerThread.finished.connect(ccaCheckerThread.deleteLater) - - worker.sigDone.connect(self.ccaCheckerWorkerDone) - worker.progress.connect(self.workerProgress) - worker.critical.connect(self.ccaIntegrityWorkerCritical) - worker.finished.connect(self.ccaCheckerWorkerClosed) - worker.sigWarning.connect(self.warnCcaIntegrity) - worker.sigFixWillDivide.connect(self.fixWillDivide) - - ccaCheckerThread.started.connect(worker.run) - ccaCheckerThread.start() - - self.ccaCheckerRunning = True - - self.initCcaIntegrityChecker() - - self.logger.info('Cell cycle annotations integrity checker started.') - - def initCcaIntegrityChecker(self): - posData = self.data[self.pos_i] - for frame_i, data_frame_i in enumerate(posData.allData_li): - lab = data_frame_i['labels'] - if lab is None: - break - - cca_df = self.get_cca_df(frame_i, return_df=True) - self.store_cca_df_checker(posData, frame_i, cca_df) - - self.enqCcaIntegrityChecker() - - def initCcaIntegrityChecker(self): - posData = self.data[self.pos_i] - for frame_i, data_frame_i in enumerate(posData.allData_li): - lab = data_frame_i['labels'] - if lab is None: - break - - cca_df = self.get_cca_df(frame_i, return_df=True) - self.store_cca_df_checker(posData, frame_i, cca_df) - - self.enqCcaIntegrityChecker() - - def disableCcaIntegrityChecker(self): - self.stopCcaIntegrityCheckerWorker() - - def stopCcaIntegrityCheckerWorker(self): - try: - self.ccaIntegrityCheckerWorker._stop() - except Exception as err: - pass - - def loadFluo_cb(self, checked=True, fluo_channels=None): - if fluo_channels is None: - posData = self.data[self.pos_i] - ch_names = [ - ch for ch in self.ch_names if ch != self.user_ch_name - and ch not in posData.loadedFluoChannels - ] - if not ch_names: - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'You already loaded ALL channels.

' - 'To change the overlaid channel ' - 'right-click on the overlay button.' - ) - msg.information(self, 'All channels are loaded', txt) - return False - selectFluo = widgets.QDialogListbox( - 'Select channel to load', - 'Select channel names to load:\n', - ch_names, multiSelection=True, parent=self - ) - selectFluo.exec_() - - if selectFluo.cancel: - return False - - fluo_channels = selectFluo.selectedItemsText - self.AutoPilotProfile.storeLoadedFluoChannels(fluo_channels) - - for p, posData in enumerate(self.data): - # posData.ol_data = None - for fluo_ch in fluo_channels: - fluo_path, filename = self.getPathFromChName(fluo_ch, posData) - if fluo_path is None: - self.criticalFluoChannelNotFound(fluo_ch, posData) - return False - fluo_data, bkgrData = self.load_fluo_data(fluo_path) - if fluo_data is None: - return False - posData.loadedFluoChannels.add(fluo_ch) - - if posData.SizeT == 1: - fluo_data = fluo_data[np.newaxis] - - posData.fluo_data_dict[filename] = fluo_data - posData.fluo_bkgrData_dict[filename] = bkgrData - posData.ol_data_dict[filename] = fluo_data.copy() - - self.overlayButton.setStyleSheet(f'background-color: {GREEN_HEX}') - self.guiTabControl.addChannels([ - posData.user_ch_name, *posData.loadedFluoChannels - ]) - return True - - def labelRoiCancelled(self): - self.labelRoiRunning = False - self.app.restoreOverrideCursor() - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) - self.freeRoiItem.clear() - self.logger.info('Magic labeller process cancelled.') - - def labelRoiCheckStartStopFrame(self): - if not self.labelRoiTrangeCheckbox.isChecked(): - return True - - start_n = self.labelRoiStartFrameNoSpinbox.value() - stop_n = self.labelRoiStopFrameNoSpinbox.value() - if start_n <= stop_n: - return True - - self.blinker = qutils.QControlBlink( - self.labelRoiStopFrameNoSpinbox, - qparent=self - ) - self.blinker.start() - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - Stop frame number is less than start frame number!

- What do you want to do? - """) - msg.warning( - self, 'Stop frame number lower than start', txt, - buttonsTexts=('Cancel', 'Segment only current frame') - ) - if msg.cancel: - return False - - posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) - self.labelRoiStopFrameNoSpinbox.setValue(posData.frame_i+1) - - def getSecondChannelData(self): - if self.secondChannelName is None: - return - - posData = self.data[self.pos_i] - - fluo_ch = self.secondChannelName - fluo_path, filename = self.getPathFromChName(fluo_ch, posData) - if filename in posData.fluo_data_dict: - fluo_data = posData.fluo_data_dict[filename] - else: - fluo_data, bkgrData = self.load_fluo_data(fluo_path) - posData.fluo_data_dict[filename] = fluo_data - posData.fluo_bkgrData_dict[filename] = bkgrData - - if self.labelRoiTrangeCheckbox.isChecked(): - start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 - stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() - tRangeLen = stop_frame_n-start_frame_i - else: - tRangeLen = 1 - - if tRangeLen > 1: - # fluo_img_data = fluo_data[start_frame_i:stop_frame_n] - if self.isSegm3D or posData.SizeZ == 1: - return fluo_data - else: - T, Z, Y, X = fluo_data.shape - secondChannelData = np.zeros((T, Y, X), dtype=fluo_data.dtype) - for frame_i, fluo_img in enumerate(fluo_data): - secondChannelData[frame_i] = self.get_2Dimg_from_3D( - fluo_data, frame_i=frame_i - ) - return secondChannelData - else: - if posData.SizeT > 1: - fluo_img_data = fluo_data[posData.frame_i] - else: - fluo_img_data = fluo_data - - if self.isSegm3D or posData.SizeZ == 1: - return fluo_img_data - else: - return self.get_2Dimg_from_3D(fluo_img_data) - - def addActionsLutItemContextMenu(self, lutItem): - lutItem.gradient.menu.addSection('Visible channels: ') - for action in self.overlayContextMenu.actions(): - if action.isSeparator(): - continue - lutItem.gradient.menu.addAction(action) - lutItem.gradient.menu.addSeparator() - - annotationMenu = lutItem.gradient.menu.addMenu('Annotations settings') - ID_menu = annotationMenu.addMenu('IDs') - self.annotSettingsIDmenu = QActionGroup(annotationMenu) - labID_action = QAction("Show label's ID") - labID_action.setCheckable(True) - labID_action.setChecked(True) - labID_action.toggled.connect(self.annotLabelIDtreeToggled) - treeID_action = QAction("Show tree's ID") - treeID_action.setCheckable(True) - treeID_action.toggled.connect(self.annotLabelIDtreeToggled) - self.annotSettingsIDmenu.addAction(labID_action) - self.annotSettingsIDmenu.addAction(treeID_action) - ID_menu.addAction(labID_action) - ID_menu.addAction(treeID_action) - - ID_menu = annotationMenu.addMenu('Generation number') - self.annotSettingsGenNumMenu = QActionGroup(annotationMenu) - gen_num_action = QAction("Show default generation number") - gen_num_action.setCheckable(True) - gen_num_action.setChecked(True) - gen_num_action.toggled.connect(self.annotGenNumTreeToggled) - tree_gen_num_action = QAction("Show tree generation number") - tree_gen_num_action.setCheckable(True) - tree_gen_num_action.toggled.connect(self.annotGenNumTreeToggled) - self.annotSettingsGenNumMenu.addAction(gen_num_action) - self.annotSettingsGenNumMenu.addAction(tree_gen_num_action) - ID_menu.addAction(gen_num_action) - ID_menu.addAction(tree_gen_num_action) - - def annotGenNumTreeToggled(self, checked): - self.textAnnot[0].setGenNumTreeAnnotationsEnabled(checked) - - def annotLabelIDtreeToggled(self, checked): - self.textAnnot[0].setLabelTreeAnnotationsEnabled(checked) - - def setAnnotInfoMode(self, checked): - if checked: - for action in self.annotSettingsIDmenu.actions(): - if action.text().find('tree') != -1: - self.textAnnot[0].setLabelTreeAnnotationsEnabled(True) - action.setChecked(True) - break - for action in self.annotSettingsGenNumMenu.actions(): - if action.text().find('tree') != -1: - self.textAnnot[0].setGenNumTreeAnnotationsEnabled(True) - action.setChecked(True) - break - else: - for action in self.annotSettingsIDmenu.actions(): - if action.text().find('tree') == -1: - action.setChecked(False) - self.textAnnot[0].setLabelTreeAnnotationsEnabled(False) - break - for action in self.annotSettingsGenNumMenu.actions(): - if action.text().find('tree') == -1: - action.setChecked(False) - self.textAnnot[0].setGenNumTreeAnnotationsEnabled(False) - break - self.setAllTextAnnotations() - - def annotOptionClicked(self, clicked=True, sender=None, saveSettings=True): - if sender is None: - sender = self.sender() - # First manually set exclusive with uncheckable - clickedIDs = sender == self.annotIDsCheckbox - clickedCca = sender == self.annotCcaInfoCheckbox - clickedMBline = sender == self.drawMothBudLinesCheckbox - if self.annotIDsCheckbox.isChecked() and clickedIDs: - if self.annotCcaInfoCheckbox.isChecked(): - self.annotCcaInfoCheckbox.setChecked(False) - if self.drawMothBudLinesCheckbox.isChecked(): - self.drawMothBudLinesCheckbox.setChecked(False) - - if self.annotCcaInfoCheckbox.isChecked() and clickedCca: - if self.annotIDsCheckbox.isChecked(): - self.annotIDsCheckbox.setChecked(False) - if self.drawMothBudLinesCheckbox.isChecked(): - self.drawMothBudLinesCheckbox.setChecked(False) - - if self.drawMothBudLinesCheckbox.isChecked() and clickedMBline: - if self.annotIDsCheckbox.isChecked(): - self.annotIDsCheckbox.setChecked(False) - if self.annotCcaInfoCheckbox.isChecked(): - self.annotCcaInfoCheckbox.setChecked(False) - - clickedCont = sender == self.annotContourCheckbox - clickedSegm = sender == self.annotSegmMasksCheckbox - if self.annotContourCheckbox.isChecked() and clickedCont: - if self.annotSegmMasksCheckbox.isChecked(): - self.annotSegmMasksCheckbox.setChecked(False) - - if self.annotSegmMasksCheckbox.isChecked() and clickedSegm: - if self.annotContourCheckbox.isChecked(): - self.annotContourCheckbox.setChecked(False) - - clickedDoNot = sender == self.drawNothingCheckbox - if clickedDoNot: - self.annotIDsCheckbox.setChecked(False) - self.annotCcaInfoCheckbox.setChecked(False) - self.annotContourCheckbox.setChecked(False) - self.annotSegmMasksCheckbox.setChecked(False) - self.drawMothBudLinesCheckbox.setChecked(False) - self.annotNumZslicesCheckbox.setChecked(False) - else: - self.drawNothingCheckbox.setChecked(False) - - if sender == self.annotNumZslicesCheckbox: - self.annotIDsCheckbox.setChecked(True) - self.drawNothingCheckbox.setChecked(False) - - self.setDrawAnnotComboboxText(saveSettings=saveSettings) - - def setDisabledAnnotCheckBoxesLeft(self, disabled): - self.annotIDsCheckbox.setDisabled(disabled) - self.annotCcaInfoCheckbox.setDisabled(disabled) - self.annotContourCheckbox.setDisabled(disabled) - self.annotSegmMasksCheckbox.setDisabled(disabled) - self.drawMothBudLinesCheckbox.setDisabled(disabled) - self.annotNumZslicesCheckbox.setDisabled(disabled) - self.drawNothingCheckbox.setDisabled(disabled) - - def setEnabledAnnotCheckBoxesLeftZdepthAxes(self): - if not self.isSegm3D: - return - - self.annotIDsCheckbox.setDisabled(False) - self.annotContourCheckbox.setDisabled(False) - self.annotIDsCheckbox.setChecked(True) - self.annotContourCheckbox.setChecked(True) - - self.annotOptionClicked( - sender=self.annotIDsCheckbox, saveSettings=False) - - def setDisabledAnnotCheckBoxesRight(self, disabled): - self.annotIDsCheckboxRight.setDisabled(disabled) - self.annotCcaInfoCheckboxRight.setDisabled(disabled) - self.annotContourCheckboxRight.setDisabled(disabled) - self.annotSegmMasksCheckboxRight.setDisabled(disabled) - self.drawMothBudLinesCheckboxRight.setDisabled(disabled) - self.annotNumZslicesCheckboxRight.setDisabled(disabled) - self.drawNothingCheckboxRight.setDisabled(disabled) - - def annotOptionClickedRight( - self, clicked=True, sender=None, saveSettings=True - ): - if sender is None: - sender = self.sender() - # First manually set exclusive with uncheckable - clickedIDs = sender == self.annotIDsCheckboxRight - clickedCca = sender == self.annotCcaInfoCheckboxRight - clickedMBline = sender == self.drawMothBudLinesCheckboxRight - if self.annotIDsCheckboxRight.isChecked() and clickedIDs: - if self.annotCcaInfoCheckboxRight.isChecked(): - self.annotCcaInfoCheckboxRight.setChecked(False) - if self.drawMothBudLinesCheckboxRight.isChecked(): - self.drawMothBudLinesCheckboxRight.setChecked(False) - - if self.annotCcaInfoCheckboxRight.isChecked() and clickedCca: - if self.annotIDsCheckboxRight.isChecked(): - self.annotIDsCheckboxRight.setChecked(False) - if self.drawMothBudLinesCheckboxRight.isChecked(): - self.drawMothBudLinesCheckboxRight.setChecked(False) - - if self.drawMothBudLinesCheckboxRight.isChecked() and clickedMBline: - if self.annotIDsCheckboxRight.isChecked(): - self.annotIDsCheckboxRight.setChecked(False) - if self.annotCcaInfoCheckboxRight.isChecked(): - self.annotCcaInfoCheckboxRight.setChecked(False) - - clickedCont = sender == self.annotContourCheckboxRight - clickedSegm = sender == self.annotSegmMasksCheckboxRight - if self.annotContourCheckboxRight.isChecked() and clickedCont: - if self.annotSegmMasksCheckboxRight.isChecked(): - self.annotSegmMasksCheckboxRight.setChecked(False) - - if self.annotSegmMasksCheckboxRight.isChecked() and clickedSegm: - if self.annotContourCheckboxRight.isChecked(): - self.annotContourCheckboxRight.setChecked(False) - - clickedDoNot = sender == self.drawNothingCheckboxRight - if clickedDoNot: - self.annotIDsCheckboxRight.setChecked(False) - self.annotCcaInfoCheckboxRight.setChecked(False) - self.annotContourCheckboxRight.setChecked(False) - self.annotSegmMasksCheckboxRight.setChecked(False) - self.drawMothBudLinesCheckboxRight.setChecked(False) - self.annotNumZslicesCheckboxRight.setChecked(False) - else: - self.drawNothingCheckboxRight.setChecked(False) - - if sender == self.annotNumZslicesCheckboxRight: - self.annotIDsCheckboxRight.setChecked(True) - self.drawNothingCheckboxRight.setChecked(False) - - self.setDrawAnnotComboboxTextRight(saveSettings=saveSettings) - - def setAnnotOptionsCcaMode(self): - self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( - return_value=True - ) - self.annotCcaInfoCheckbox.setChecked(True) - self.annotIDsCheckbox.setChecked(False) - self.drawMothBudLinesCheckbox.setChecked(False) - self.setDrawAnnotComboboxText() - - def setAnnotOptionsLin_treeMode(self): - # self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( - # return_value=True - # ) - self.annotCcaInfoCheckbox.setChecked(True) - self.annotIDsCheckbox.setChecked(False) - self.drawMothBudLinesCheckbox.setChecked(False) - self.setDrawAnnotComboboxText() - self.showTreeInfoCheckbox.setChecked(True) - - def setDrawAnnotComboboxText(self, saveSettings=True): - if self.annotIDsCheckbox.isChecked(): - if self.annotContourCheckbox.isChecked(): - t = 'Draw IDs and contours' - elif self.annotSegmMasksCheckbox.isChecked(): - t = 'Draw IDs and overlay segm. masks' - else: - t = 'Draw only IDs' - - elif self.annotCcaInfoCheckbox.isChecked(): - if self.annotContourCheckbox.isChecked(): - t = 'Draw cell cycle info and contours' - elif self.annotSegmMasksCheckbox.isChecked(): - t = 'Draw cell cycle info and overlay segm. masks' - else: - t = 'Draw only cell cycle info' - - elif self.annotSegmMasksCheckbox.isChecked(): - t = 'Draw only overlay segm. masks' - - elif self.annotContourCheckbox.isChecked(): - t = 'Draw only contours' - - elif self.drawMothBudLinesCheckbox.isChecked(): - t = 'Draw only mother-bud lines' - - elif self.drawNothingCheckbox.isChecked(): - t = 'Draw nothing' - else: - t = 'Draw nothing' - - if t == self.drawIDsContComboBox.currentText(): - self.drawIDsContComboBox_cb(0) - - self.drawIDsContComboBox.saveSettings = saveSettings - self.drawIDsContComboBox.setCurrentText(t) - - def setDrawAnnotComboboxTextRight(self, saveSettings=True): - if self.annotIDsCheckboxRight.isChecked(): - if self.annotContourCheckboxRight.isChecked(): - t = 'Draw IDs and contours' - elif self.annotSegmMasksCheckboxRight.isChecked(): - t = 'Draw IDs and overlay segm. masks' - else: - t = 'Draw only IDs' - - elif self.annotCcaInfoCheckboxRight.isChecked(): - if self.annotContourCheckboxRight.isChecked(): - t = 'Draw cell cycle info and contours' - elif self.annotSegmMasksCheckboxRight.isChecked(): - t = 'Draw cell cycle info and overlay segm. masks' - else: - t = 'Draw only cell cycle info' - - elif self.annotSegmMasksCheckboxRight.isChecked(): - t = 'Draw only overlay segm. masks' - - elif self.annotContourCheckboxRight.isChecked(): - t = 'Draw only contours' - - elif self.drawMothBudLinesCheckboxRight.isChecked(): - t = 'Draw only mother-bud lines' - - elif self.drawNothingCheckboxRight.isChecked(): - t = 'Draw nothing' - else: - t = 'Draw nothing' - - if t == self.annotateRightHowCombobox.currentText(): - self.annotateRightHowCombobox_cb(0) - - self.annotateRightHowCombobox.saveSettings = saveSettings - self.annotateRightHowCombobox.setCurrentText(t) - - def getOverlayItems(self, channelName, index): - imageItem = widgets.OverlayImageItem() - imageItem.setOpacity(0.5) - imageItem.channelName = channelName - - lutItem = widgets.myHistogramLUTitem( - parent=self, name='image', axisLabel=channelName - ) - imageItem.lutItem = lutItem - for action in lutItem.rescaleActionGroup.actions(): - if action.text() == self.defaultRescaleIntensHow: - action.setChecked(True) - break - - lutItem.removeAddScaleBarAction() - lutItem.removeAddTimestampAction() - lutItem.restoreState(self.df_settings) - lutItem.setImageItem(imageItem) - lutItem.vb.raiseContextMenu = lambda x: None - initColor = self.overlayColors[channelName] - self.initColormapOverlayLayerItem(initColor, lutItem) - lutItem.addOverlayColorButton(initColor, channelName) - lutItem.initColor = initColor - lutItem.hide() - - lutItem.overlayColorButton.sigColorChanging.connect( - self.changeOverlayColor - ) - lutItem.overlayColorButton.sigColorChanged.connect( - self.saveOverlayColor - ) - - lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW) - - lutItem.contoursColorButton.disconnect() - lutItem.contoursColorButton.clicked.connect( - self.imgGrad.contoursColorButton.click - ) - for act in lutItem.contLineWightActionGroup.actions(): - act.toggled.connect(self.contLineWeightToggled) - - lutItem.mothBudLineColorButton.disconnect() - lutItem.mothBudLineColorButton.clicked.connect( - self.imgGrad.mothBudLineColorButton.click - ) - for act in lutItem.mothBudLineWightActionGroup.actions(): - act.toggled.connect(self.mothBudLineWeightToggled) - - lutItem.textColorButton.disconnect() - lutItem.textColorButton.clicked.connect( - self.editTextIDsColorAction.trigger - ) - - lutItem.defaultSettingsAction.triggered.connect( - self.restoreDefaultSettings - ) - lutItem.labelsAlphaSlider.valueChanged.connect( - self.setValueLabelsAlphaSlider - ) - lutItem.sigRescaleIntes.connect( - partial(self.rescaleIntensitiesLut, imageItem=imageItem) - ) - if f'how_rescale_intensities_{channelName}' in self.df_settings.index: - how = self.df_settings.at[ - f'how_rescale_intensities_{channelName}', 'value' - ] - lutItem.setRescaleIntensitiesHow(how) - - self.rescaleIntensChannelHowMapper[channelName] = ( - 'Rescale each 2D image' - ) - - self.addActionsLutItemContextMenu(lutItem) - - alphaScrollBar = self.addAlphaScrollbar(channelName, imageItem) - - toolbutton = widgets.OverlayChannelToolButton( - channelName, lutItem, shortcut=str(index) - ) - toolbutton.action = self.overlayToolbar.addWidget(toolbutton) - toolbutton.setVisible(False) - - toolbutton.clicked.connect(self.overlayChannelToolbuttonClicked) - - alphaScrollBar.toolbutton = toolbutton - - return imageItem, lutItem, alphaScrollBar, toolbutton - - def addAlphaScrollbar(self, channelName, imageItem): - alphaScrollBar = widgets.ScrollBar(Qt.Horizontal) - imageItem.alphaScrollBar = alphaScrollBar - alphaScrollBar.channelName = channelName - - label = QLabel(f'Alpha {channelName}') - label.setFont(_font) - label.hide() - alphaScrollBar.imageItem = imageItem - alphaScrollBar.label = label - alphaScrollBar.setFixedHeight(self.h) - alphaScrollBar.hide() - alphaScrollBar.setMinimum(0) - alphaScrollBar.setMaximum(40) - alphaScrollBar.setValue(20) - alphaScrollBar.setToolTip( - f'Control the alpha value of the overlaid channel {channelName}.\n' - 'alpha=0 results in NO overlay,\n' - 'alpha=1 results in only fluorescence data visible' - ) - self.bottomLeftLayout.addWidget( - alphaScrollBar.label, self.alphaScrollbarRow, 0, - alignment=Qt.AlignRight - ) - self.bottomLeftLayout.addWidget( - alphaScrollBar, self.alphaScrollbarRow, 1, 1, 2 - ) - - alphaScrollBar.valueChanged.connect( - partial(self.setOpacityOverlayLayersItems, scrollbar=alphaScrollBar) - ) - - self.alphaScrollbarRow += 1 - return alphaScrollBar - - def setValueLabelsAlphaSlider(self, value): - self.imgGrad.labelsAlphaSlider.setValue(value) - self.updateLabelsAlpha(value) - - def setOverlayLabelsItemsVisible(self, checked): - for _segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): - items = self.overlayLabelsItems[_segmEndname] - gradItem = items[-1] - gradItem.hide() - - if checked: - segmEndname = self.sender().text() - gradItem = self.overlayLabelsItems[segmEndname][-1] - gradItem.show() - - def setRetainSizePolicyLutItems(self): - if not self.retainSizeLutItems: - return - for channel, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB = items[:3] - myutils.setRetainSizePolicy(lutItem, retain=True) - QTimer.singleShot(300, self.autoRange) - - def setOverlayChannelsToolbuttonsChecked(self): - for channel, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB, toolbutton = items[:4] - toolbutton.setChecked( - not self.overlayToolbar.isSingleChannel() - and channel in self.checkedOverlayChannels - ) - - def setOverlayItemsVisible(self): - for channel, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB, toolbutton = items[:4] - lutItem.hide() - alphaSB.hide() - alphaSB.label.hide() - toolbutton.setVisible(False) - - if not self.overlayButton.isChecked(): - return - - for channel, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB, toolbutton = items[:4] - if channel in self.checkedOverlayChannels: - lutItem.show() - alphaSB.show() - alphaSB.label.show() - toolbutton.setVisible(True) - - def overlayChannelToolbuttonClicked(self, checked=False, toolbutton=None): - if toolbutton is None: - toolbutton = self.sender() - - n_checked_buttons = ( - sum([b.isChecked() for b in self.allOverlayToolbuttons.values()]) - ) - - channelName = toolbutton.channelName() - - if n_checked_buttons == 0 or self.overlayToolbar.isSingleChannel(): - # At least one button must be checked - toolbutton.setChecked(True) - - if self.overlayToolbar.isSingleChannel(): - # Exclusive buttons - for channel, otherToolbutton in self.allOverlayToolbuttons.items(): - if channel == channelName: - continue - - otherToolbutton.setChecked(False) - - if self.overlayToolbar.isTransparent(): - self.setOverlayImages() - return - - self.setOverlayItemsOpacities() - - def setOverlayItemsOpacities(self): - n_checked_buttons = ( - sum([b.isChecked() for b in self.allOverlayToolbuttons.values()]) - ) - - isSingleChannel = ( - self.overlayToolbar.isSingleChannel() - or n_checked_buttons == 1 - ) - - channel_opacity_mapper = self.getOpacitiesFromAlphaScrollbarValues() - - # Set opacity of every layer accordingly - for channel, otherToolbutton in self.allOverlayToolbuttons.items(): - if channel == self.user_ch_name: - otherImageItem = self.img1 - alphaScrollbar = None - # alpha_value = channel_opacity_mapper[channel] - else: - otherItems = self.overlayLayersItems[channel] - otherImageItem = otherItems[0] - alphaScrollbar = otherItems[2] - # alpha_value = alphaScrollbar.value()/alphaScrollbar.maximum() - - if otherToolbutton.isChecked() and isSingleChannel: - op_val = 1.0 - elif otherToolbutton.isChecked(): - op_val = channel_opacity_mapper[channel] - else: - op_val = 0.0 - - if op_val == 0: - op_val = 0.01 - - op_val = op_val if op_val < 1.0 else 0.999 - - otherImageItem.setOpacity(op_val, applyToLinked=False) - - if alphaScrollbar is None: - continue - - alphaScrollbar.setDisabled(bool(op_val == 0)) - - def initColormapOverlayLayerItem(self, foregrColor, lutItem): - if self.invertBwAction.isChecked(): - bkgrColor = (255,255,255,255) - else: - bkgrColor = (0,0,0,255) - gradient = colors.get_pg_gradient((bkgrColor, foregrColor)) - lutItem.setGradient(gradient) - - def setOpacityOverlayLayersItems(self, value, imageItem=None, scrollbar=None): - if scrollbar is None: - scrollbar = imageItem.alphaScrollBar - - channel = scrollbar.channelName - toolbutton = self.allOverlayToolbuttons[channel] - if not toolbutton.isChecked() or not toolbutton.isVisible(): - return - - if value is None: - value = scrollbar.value() - - if imageItem is None: - imageItem = scrollbar.imageItem - alpha = value/scrollbar.maximum() - elif value > 1: - alpha = value/scrollbar.maximum() - else: - alpha = value - - alpha_values = [] - activeOverlayImageItems = [] - for items in self.overlayLayersItems.values(): - imgItem, lutItem, alphaSB = items[:3] - _toolbutton = alphaSB.toolbutton - if alphaSB.channelName == channel: - alpha_values.append(alpha) - elif not _toolbutton.isChecked() or not _toolbutton.isVisible(): - continue - else: - alpha_values.append(alphaSB.value()/alphaSB.maximum()) - - activeOverlayImageItems.append(imgItem) - - opacities = colors.hierarchical_weights(alpha_values)[::-1] - - for i, imgItem in enumerate(activeOverlayImageItems): - imgItem.setOpacity(opacities[i+1]) - - self.img1.setOpacity(opacities[0], applyToLinked=False) - - def showInExplorer_cb(self): - posData = self.data[self.pos_i] - path = posData.images_path - myutils.showInExplorer(path) - - def zSliceAbsent(self, filename, posData): - self.app.restoreOverrideCursor() - SizeZ = posData.SizeZ - chNames = posData.chNames - filenamesPresent = posData.segmInfo_df.index.get_level_values(0).unique() - chNamesPresent = [ - ch for ch in chNames - for file in filenamesPresent - if file.endswith(ch) or file.endswith(f'{ch}_aligned') - ] - win = apps.QDialogZsliceAbsent(filename, SizeZ, chNamesPresent) - win.exec_() - if win.cancel: - self.worker.abort = True - self.waitCond.wakeAll() - return - if win.useMiddleSlice: - user_ch_name = filename[len(posData.basename):] - for _posData in self.data: - if _posData is None: - continue - _, filename = self.getPathFromChName(user_ch_name, _posData) - df = myutils.getDefault_SegmInfo_df(_posData, filename) - _posData.segmInfo_df = pd.concat([df, _posData.segmInfo_df]) - unique_idx = ~_posData.segmInfo_df.index.duplicated() - _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] - _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) - elif win.useSameAsCh: - user_ch_name = filename[len(posData.basename):] - for _posData in self.data: - if _posData is None: - continue - _, srcFilename = self.getPathFromChName( - win.selectedChannel, _posData - ) - cellacdc_df = _posData.segmInfo_df.loc[srcFilename].copy() - _, dstFilename = self.getPathFromChName(user_ch_name, _posData) - if dstFilename is None: - self.worker.abort = True - self.waitCond.wakeAll() - return - dst_df = myutils.getDefault_SegmInfo_df(_posData, dstFilename) - for z_info in cellacdc_df.itertuples(): - frame_i = z_info.Index - zProjHow = z_info.which_z_proj - if zProjHow == 'single z-slice': - src_idx = (srcFilename, frame_i) - if _posData.segmInfo_df.at[src_idx, 'resegmented_in_gui']: - col = 'z_slice_used_gui' - else: - col = 'z_slice_used_dataPrep' - z_slice = _posData.segmInfo_df.at[src_idx, col] - dst_idx = (dstFilename, frame_i) - dst_df.at[dst_idx, 'z_slice_used_dataPrep'] = z_slice - dst_df.at[dst_idx, 'z_slice_used_gui'] = z_slice - _posData.segmInfo_df = pd.concat([dst_df, _posData.segmInfo_df]) - unique_idx = ~_posData.segmInfo_df.index.duplicated() - _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] - _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) - elif win.runDataPrep: - user_ch_file_paths = [] - user_ch_name = filename[len(self.data[self.pos_i].basename):] - for _posData in self.data: - if _posData is None: - continue - user_ch_path = load.get_filename_from_channel( - _posData.images_path, user_ch_name - ) - if user_ch_path is None: - self.worker.abort = True - self.waitCond.wakeAll() - return - user_ch_file_paths.append(user_ch_path) - exp_path = os.path.dirname(_posData.pos_path) - - dataPrepWin = dataPrep.dataPrepWin() - dataPrepWin.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - dataPrepWin.titleText = ( - """ - Select z-slice (or projection) for each frame/position.
- Once happy, close the window. - """) - dataPrepWin.show() - dataPrepWin.initLoading() - dataPrepWin.SizeT = self.data[0].SizeT - dataPrepWin.SizeZ = self.data[0].SizeZ - dataPrepWin.metadataAlreadyAsked = True - self.logger.info(f'Loading channel {user_ch_name} data...') - dataPrepWin.loadFiles( - exp_path, user_ch_file_paths, user_ch_name - ) - dataPrepWin.startAction.setDisabled(True) - dataPrepWin.onlySelectingZslice = True - - loop = QEventLoop(self) - dataPrepWin.loop = loop - loop.exec_() - - self.waitCond.wakeAll() - - def showSetMeasurements(self, checked=False, qparent=None): - qparent = qparent if qparent is not None else self - if self.measurementsWin is not None: - self.measurementsWin.show() - self.measurementsWin.raise_() - self.measurementsWin.activateWindow() - return - - try: - df_favourite_funcs = pd.read_csv(favourite_func_metrics_csv_path) - favourite_funcs = df_favourite_funcs['favourite_func_name'].to_list() - except Exception as e: - favourite_funcs = None - - posData = self.data[self.pos_i] - allPos_acdc_df_cols = set() - for _posData in self.data: - for frame_i, data_dict in enumerate(_posData.allData_li): - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - - allPos_acdc_df_cols.update(acdc_df.columns) - loadedChNames = posData.setLoadedChannelNames(returnList=True) - posData.fluo_data_dict.pop(self.user_ch_name, None) - if self.user_ch_name not in loadedChNames: - loadedChNames.insert(0, self.user_ch_name) - notLoadedChNames = [c for c in self.ch_names if c not in loadedChNames] - self.notLoadedChNames = notLoadedChNames - self.measurementsWin = apps.SetMeasurementsDialog( - loadedChNames, notLoadedChNames, posData.SizeZ > 1, self.isSegm3D, - favourite_funcs=favourite_funcs, - allPos_acdc_df_cols=list(allPos_acdc_df_cols), - acdc_df_path=posData.images_path, posData=posData, - addCombineMetricCallback=self.addCombineMetric, - allPosData=self.data, - parent=qparent, - state=self.setMeasWinState - ) - self.measurementsWin.sigCancel.connect(self.setMeasurementsCancelled) - self.measurementsWin.sigClosed.connect(self.setMeasurements) - self.measurementsWin.show() - - def setMeasurementsCancelled(self): - self.measurementsWin = None - - def setMeasurements(self): - posData = self.data[self.pos_i] - if self.measurementsWin.delExistingCols: - self.logger.info('Removing existing unchecked measurements...') - delCols = self.measurementsWin.existingUncheckedColnames - delRps = self.measurementsWin.existingUncheckedRps - delCols_format = [f' * {colname}' for colname in delCols] - delRps_format = [f' * {colname}' for colname in delRps] - delCols_format.extend(delRps_format) - delCols_format = '\n'.join(delCols_format) - self.logger.info(delCols_format) - for _posData in self.data: - for frame_i, data_dict in enumerate(_posData.allData_li): - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - - acdc_df = acdc_df.drop(columns=delCols, errors='ignore') - for col_rp in delRps: - drop_df_rp = acdc_df.filter(regex=fr'{col_rp}.*', axis=1) - drop_cols_rp = drop_df_rp.columns - acdc_df = acdc_df.drop(columns=drop_cols_rp, errors='ignore') - _posData.allData_li[frame_i]['acdc_df'] = acdc_df - self.setMeasWinState = self.measurementsWin.state() - self.logger.info('Setting measurements...') - self._setMetrics(self.measurementsWin) - self.logger.info('Metrics successfully set.') - self.measurementsWin = None - - def _setMetrics(self, measurementsWin): - self._measurements_kernel.set_metrics_from_set_measurements_dialog( - measurementsWin - ) - for ch in self._measurements_kernel.chNamesToProcess: - if ch not in self.notLoadedChNames: - continue - - success = self.loadFluo_cb(fluo_channels=[ch]) - if not success: - continue - - def addCustomMetric(self, checked=False): - txt = measurements.add_metrics_instructions() - metrics_path = measurements.metrics_path - msg = widgets.myMessageBox() - msg.addShowInFileManagerButton(metrics_path, 'Show example...') - title = 'Add custom metrics instructions' - msg.information(self, title, txt, buttonsTexts=('Ok',)) - - def addCombineMetric(self): - posData = self.data[self.pos_i] - isZstack = posData.SizeZ > 1 - win = apps.combineMetricsEquationDialog( - self.ch_names, isZstack, self.isSegm3D, parent=self - ) - win.sigOk.connect(self.saveCombineMetricsToPosData) - win.exec_() - win.sigOk.disconnect() - - def saveCombineMetricsToPosData(self, window): - for posData in self.data: - equationsDict, isMixedChannels = window.getEquationsDict() - for newColName, equation in equationsDict.items(): - posData.addEquationCombineMetrics( - equation, newColName, isMixedChannels - ) - posData.saveCombineMetrics() - - if self.measurementsWin is None: - return - - self.measurementsWinState = self.measurementsWin.state() - self.measurementsWin.close() - self.showSetMeasurements() - self.measurementsWin.restoreState(self.measurementsWinState) - - def labelRoiToEndFramesTriggered(self): - posData = self.data[self.pos_i] - self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) - - def labelRoiFromCurrentFrameTriggered(self): - posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) - - def labelRoiViewCurrentModel(self): - from . import config - ini_path = os.path.join( - settings_folderpath, 'last_params_segm_models.ini' - ) - configPars = config.ConfigParser() - configPars.read(ini_path) - model_name = self.labelRoiModel.model_name - txt = f'Model: {model_name}' - SECTION = f'{model_name}.init' - txt = f'{txt}

[Initialization parameters]
' - for option in configPars.options(SECTION): - value = configPars[SECTION][option] - param_txt = f'{option} = {value}
' - txt = f'{txt}{param_txt}' - - SECTION = f'{model_name}.segment' - txt = f'{txt}
[Segmentation parameters]
' - for option in configPars.options(SECTION): - value = configPars[SECTION][option] - param_txt = f'{option} = {value}
' - txt = f'{txt}{param_txt}' - - win = apps.ViewTextDialog(txt, parent=self) - win.exec_() - - def setMetricsFunc(self): - posData = self.data[self.pos_i] - self._measurements_kernel._set_metrics_func_from_posData(posData) - - def getLastTrackedFrame(self, posData): - last_tracked_i = 0 - for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] - if lab is None: - frame_i -= 1 - break - if frame_i > 0: - return frame_i - else: - return last_tracked_i - - def computeVolumeRegionprop(self): - if 'cell_vol_vox' not in self._measurements_kernel.sizeMetricsToSave: - return - - # We compute the cell volume in the main thread because calling - # skimage.transform.rotate in a separate thread causes crashes - # with segmentation fault on macOS. I don't know why yet. - self.logger.info('Computing cell volume...') - end_i = self.save_until_frame_i - pos_iter = tqdm(self.data, ncols=100) - for p, posData in enumerate(pos_iter): - if self.posToSave is not None: - if posData.pos_foldername not in self.posToSave: - continue - - PhysicalSizeY = posData.PhysicalSizeY - PhysicalSizeX = posData.PhysicalSizeX - frame_iter = tqdm( - posData.allData_li[:end_i+1], ncols=100, position=1, leave=False - ) - for frame_i, data_dict in enumerate(frame_iter): - lab = data_dict['labels'] - if lab is None: - break - rp = data_dict['regionprops'] - obj_iter = tqdm(rp, ncols=100, position=2, leave=False) - for i, obj in enumerate(obj_iter): - vol_vox, vol_fl = _calc_rot_vol( - obj, PhysicalSizeY, PhysicalSizeX - ) - obj.vol_vox = vol_vox - obj.vol_fl = vol_fl - posData.allData_li[frame_i]['regionprops'] = rp - - def askSaveOriginalSegm(self, isQuickSave=False): - if isQuickSave: - return "", True, True - - posData = self.data[self.pos_i] - if not posData.whitelist: - return "", True, True - - help_txt = html_utils.paragraph(f""" - You have whitelisted IDs in the current position.
- Do you want to save the not whitelisted segmentation data
- This will allow you to revisit the original segmentation.
- """) - - txt = html_utils.paragraph(f""" - You have whitelisted IDs in the current position.
- Do you want to save the not whitelisted segmentation data?
- """) - - found_files = load.get_segm_files(posData.images_path) - existingEndnames = load.get_endnames( - posData.basename, found_files - ) - - segmFilename = os.path.basename(posData.segm_npz_path) - segmFilename = f"{segmFilename[:-4]}_not_whitelisted" - win = apps.filenameDialog( - basename=posData.basename, - hintText=txt, - defaultEntry=segmFilename, - existingNames=existingEndnames, - helpText=help_txt, - allowEmpty=False, - parent=self, - title='Save not whitelisted segmentation data', - addDoNotSaveButton=True - ) - win.exec_() - if win.cancel: - return "", False, True - if win.doNotSave: - return "", True, True - return win.entryText, True, False - - def askSaveLastVisitedCcaMode(self, isQuickSave=False): - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - frame_i = 0 - last_tracked_i = 0 - self.save_until_frame_i = 0 - if self.isSnapshot: - return True - - for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] - if lab is None: - frame_i -= 1 - break - - self.save_until_frame_i = frame_i - self.save_cca_until_frame_i = frame_i - self.last_tracked_i = frame_i - - if isQuickSave: - return True - - last_cca_frame_i = self.navigateScrollBar.maximum()-1 - # Ask to save last visited frame or not - txt = html_utils.paragraph(f""" - You annotated the cell cycle stages up - until frame number {last_cca_frame_i+1}.

- Enter up to which frame number you want to save the - cell cycle annotations: - """) - lastFrameDialog = apps.QLineEditDialog( - title='Last annoated frame number to save', - defaultTxt=str(last_cca_frame_i+1), - msg=txt, parent=self, allowedValues=(1, last_cca_frame_i+1), - warnLastFrame=True, isInteger=True, stretchEntry=False, - lastVisitedFrame=last_cca_frame_i+1, - ) - lastFrameDialog.exec_() - if lastFrameDialog.cancel: - return False - - last_save_cca_frame_i = lastFrameDialog.enteredValue - 1 - - if last_save_cca_frame_i < last_cca_frame_i: - self.resetCcaFuture(last_cca_frame_i) - - self.save_cca_until_frame_i = last_save_cca_frame_i - - return True - - def askSaveLastVisitedSegmMode(self, isQuickSave=False): - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - frame_i = 0 - last_tracked_i = 0 - self.save_until_frame_i = 0 - self.save_cca_until_frame_i = 0 - if self.isSnapshot: - return True - - for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] - if lab is None: - frame_i -= 1 - break - - if isQuickSave: - self.save_until_frame_i = frame_i - self.save_cca_until_frame_i = frame_i - self.last_tracked_i = frame_i - return True - - # Ask to save last visited frame or not - txt = html_utils.paragraph(f""" - You visualised and corrected segmentation and tracking data up - until frame number {frame_i+1}.

- Enter up to which frame number you want to save data: - """) - lastFrameDialog = apps.QLineEditDialog( - title='Last frame number to save', defaultTxt=str(frame_i+1), - msg=txt, parent=self, allowedValues=(1, posData.SizeT), - warnLastFrame=True, isInteger=True, stretchEntry=False, - lastVisitedFrame=frame_i+1, - ) - lastFrameDialog.exec_() - if lastFrameDialog.cancel: - return False - - self.save_until_frame_i = lastFrameDialog.enteredValue - 1 - self.save_cca_until_frame_i = self.save_until_frame_i - if self.save_until_frame_i > frame_i: - self.logger.info( - f'Storing frames {frame_i+1}-{self.save_until_frame_i+1}...' - ) - current_frame_i = posData.frame_i - # User is requesting to save past the last visited frame --> - # store data as if they were visited - for i in range(frame_i+1, self.save_until_frame_i+1): - posData.frame_i = i - self.get_data() - self.store_data(autosave=False) - - # Go back to current frame - posData.frame_i = current_frame_i - self.get_data() - last_tracked_i = self.save_until_frame_i - - self.last_tracked_i = last_tracked_i - return True - - def askSaveMetrics(self): - txt = html_utils.paragraph( - """ - Do you also want to save the measurements - (e.g., cell volume, mean, amount etc.)?

- - You can find more information by clicking on the - "Set measurements" button below
- where you will be able to select which measurements - you want to save.

- If you already set the measurements and you want to save them click "Yes".

- - NOTE: Saving metrics might be slow, - we recommend doing it only when you need it.
- """) - msg = widgets.myMessageBox( - parent=self, resizeButtons=False, wrapText=False - ) - setMeasurementsButton = widgets.setPushButton('Set measurements...') - _, yesButton, noButton, _ = msg.question( - self, 'Save measurements?', txt, - buttonsTexts=('Cancel', 'Yes', 'No', setMeasurementsButton), - showDialog=False - ) - setMeasurementsButton.disconnect() - setMeasurementsButton.clicked.connect( - partial( - self.showSetMeasurements, - qparent=msg, - ) - ) - msg.exec_() - save_metrics = msg.clickedButton == yesButton - return save_metrics, msg.cancel - - def askSelectPos(self, action='to save'): - last_pos = 1 - for p, posData in enumerate(self.data): - acdc_df = posData.allData_li[0]['acdc_df'] - if acdc_df is None: - last_pos = p - break - else: - last_pos = len(self.data) - - items = [posData.pos_foldername for posData in self.data] - selectPosWin = widgets.QDialogListbox( - f'Select Positions {action}', f'Select Positions {action}:\n', - items, multiSelection=True, parent=self, - preSelectedItems=items[:last_pos] - ) - selectPosWin.exec_() - if selectPosWin.cancel: - return - - return selectPosWin.selectedItemsText - - def askPosToSave(self): - return self.askSelectPos() - - def saveMetricsCritical(self, traceback_format): - print('\n====================================') - self.logger.exception(traceback_format) - print('====================================\n') - self.logger.info('Warning: calculating metrics failed see above...') - print('------------------------------') - - msg = widgets.myMessageBox(wrapText=False) - err_msg = html_utils.paragraph(f""" - Error while saving metrics.

- More details below or in the terminal/console.

- Note that the error details from this session are also saved - in the file
- {self.log_path}

- Please send the log file when reporting a bug, thanks! - Please restart Cell-ACDC, we apologise for any inconvenience.

- - """) - msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...') - msg.setDetailedText(traceback_format, visible=True) - msg.critical(self, 'Critical error while saving metrics', err_msg) - - self.is_error_state = True - self.waitCond.wakeAll() - - def saveAsData(self, checked=True): - try: - posData = self.data[self.pos_i] - except AttributeError: - return - - existingFilenames = set() - for _posData in self.data: - segm_files = load.get_segm_files(_posData.images_path) - _existingEndnames = load.get_endnames( - _posData.basename, segm_files - ) - existingFilenames.update([ - f'{_posData.basename}{endname}.npz' - for endname in _existingEndnames - ]) - posData = self.data[self.pos_i] - if posData.basename.endswith('_'): - basename = f'{posData.basename}segm' - else: - basename = f'{posData.basename}_segm' - win = apps.filenameDialog( - basename=basename, - hintText='Insert a filename for the segmentation file:
', - existingNames=existingFilenames - ) - win.exec_() - if win.cancel: - return - - for posData in self.data: - posData.setFilePaths(new_endname=win.entryText) - - self.setStatusBarLabel() - self.saveData() - - def startExportToVideoWorker(self, preferences): - self.isExportingVideo = True - self.isTransparent = self.overlayToolbar.isTransparent() - if not self.isTransparent: - # SVG export works only with RGBA not with setOpacity - # --> only true transparency mode can be used - self.overlayToolbar.setTransparent(True) - - self.setDisabled(True) - - self.progressWin = apps.QDialogWorkerProgress( - title='Exporting to video', parent=self.mainWin, - pbarDesc='Exporting to video...' - ) - self.progressWin.show(self.app) - self.exportToVideoStopNavVarNum = preferences['stop_nav_var_num'] - self.numFramesExported = 0 - self.progressWin.mainPbar.setMaximum( - preferences['stop_nav_var_num'] - - preferences['start_nav_var_num'] + 1 - ) - self.exportToVideoPreferences = preferences - - self.store_data() - posData = self.data[self.pos_i] - if self.exportToVideoPreferences['is_timelapse']: - # Go to requested start frame - posData.frame_i = preferences['start_nav_var_num'] - 1 - self.get_data() - self.updateAllImages() - self.exportToVideoNavVarIdxToRestore = posData.frame_i - else: - self.update_z_slice(preferences['start_nav_var_num'] - 1) - self.exportToVideoNavVarIdxToRestore = ( - self.zSliceScrollBar.sliderPosition() - ) - self.exportToVideoCurrentNavVarIdx = ( - preferences['start_nav_var_num'] - 1 - ) - - self.exportToVideoImageExporter = exporters.ImageExporter( - self.ax1, - save_pngs=preferences['save_pngs'], - dpi=preferences['dpi'] - ) - self.exportToVideoExporter = exporters.VideoExporter( - preferences['avi_filepath'], preferences['fps'] - ) - - QTimer.singleShot(200, self.updateAndExportFrame) - - def updateAndExportFrame(self): - didVideoExporterFinish = ( - self.exportToVideoCurrentNavVarIdx - == self.exportToVideoStopNavVarNum - ) - if didVideoExporterFinish: - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.mainPbar.setValue(0) - QTimer.singleShot(50, self.exportingFramesFinished) - return - - posData = self.data[self.pos_i] - if self.exportToVideoPreferences['is_timelapse']: - self.goToFrameNumber(self.exportToVideoCurrentNavVarIdx+1) - else: - self.update_z_slice(self.exportToVideoCurrentNavVarIdx) - - success = self.exportFrame() - if success is None: - self.exportingVideoCritical() - return - - self.exportToVideoCurrentNavVarIdx += 1 - self.progressWin.mainPbar.update(1) - - QTimer.singleShot(50, self.updateAndExportFrame) - - @exception_handler - def exportFrame(self): - nd = self.exportToVideoPreferences['num_digits'] - idx = str(self.exportToVideoCurrentNavVarIdx).zfill(nd) - filename = self.exportToVideoPreferences['filename'] - png_filename = f'{idx}_{filename}.png' - pngs_folderpath = self.exportToVideoPreferences['pngs_folderpath'] - - png_filepath = os.path.join(pngs_folderpath, png_filename) - img_bgr = self.exportToVideoImageExporter.export(png_filepath) - self.exportToVideoExporter.add_frame(img_bgr) - return True - - def exportingVideoCritical(self): - self.setDisabled(False) - - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.logger.info('Exporting video process failed.') - - def exportingFramesFinished(self): - if not self.exportToVideoPreferences['save_pngs']: - self.logger.info('Removing PNGs...') - try: - shutil.rmtree(self.exportToVideoPreferences['pngs_folderpath']) - except Exception as err: - pass - - self.logger.info('Saving video...') - - self.exportToVideoExporter.release() - - # Run ffmpeg new process - conversion_to_mp4_successful = True - if self.exportToVideoPreferences['filepath'].endswith('.mp4'): - try: - self.exportToVideoExporter.avi_to_mp4() - try: - os.remove(self.exportToVideoPreferences['avi_filepath']) - except Exception as err: - pass - except Exception as err: - self.logger.exception(traceback.format_exc()) - self.logger.info( - 'Conversion to MP4 failed. See traceback above.' - ) - conversion_to_mp4_successful = False - self.exportToVideoPreferences['filepath'] = ( - self.exportToVideoExporter._avi_filepath - ) - - self.exportToVideoFinished(conversion_to_mp4_successful) - - def exportToVideoFinished(self, conversion_to_mp4_successful): - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - # Back to current frame - if self.exportToVideoPreferences['is_timelapse']: - posData = self.data[self.pos_i] - posData.frame_i = self.exportToVideoNavVarIdxToRestore - self.get_data() - self.store_data() - self.updateAllImages() - self.navigateScrollBar.setSliderPosition(posData.frame_i+1) - self.navSpinBox.setValue(posData.frame_i+1) - else: - self.update_z_slice(self.exportToVideoNavVarIdxToRestore) - - self.setDisabled(False) - self.isExportingVideo = False - - if not self.isTransparent: - # True transparency mode was activated programmatically - # --> restore what the user had before starting to export - self.overlayToolbar.setTransparent(False) - - prompts.exportToVideoFinished( - self.exportToVideoPreferences, conversion_to_mp4_successful, - qparent=self - ) - - def exportAddScaleBar(self, checked): - self.addScaleBarAction.setChecked(checked) - - def exportToVideoAddTimestamp(self, checked): - self.addTimestampAction.setChecked(checked) - - def askTimelapseOrZslicesVideo(self): - txt = html_utils.paragraph(""" - Do you want to record a video of scrolling through the z-slices or - a Timelapse video? - """) - msg = widgets.myMessageBox(wrapText=False) - _, timelapseButton = msg.question( - self, 'Z-slices or Timelapse video?', txt, - buttonsTexts=('Z-slices', 'Timelapse') - ) - if msg.cancel: - return - - return msg.clickedButton == timelapseButton - - def exportToVideoTriggered(self): - posData = self.data[self.pos_i] - - doTimelapseVideo = posData.SizeT > 1 - if posData.SizeT > 1 and posData.SizeZ > 1: - doTimelapseVideo = self.askTimelapseOrZslicesVideo() - - if doTimelapseVideo is None: - self.logger.info('Export to video process cancelled') - return - - channels = [self.user_ch_name, *self.checkedOverlayChannels] - mode = 'timelapse' if doTimelapseVideo else 'z_slices' - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = f'{timestamp}_acdc_exported_{mode}_video' - win = apps.ExportToVideoParametersDialog( - channels, - parent=self, - startFolderpath=posData.pos_path, - startFilename=filename, - startFrameNum=posData.frame_i+1, - SizeT=posData.SizeT, - SizeZ=posData.SizeZ, - isTimelapseVideo=doTimelapseVideo, - isScaleBarPresent=self.addScaleBarAction.isChecked(), - isTimestampPresent=self.addTimestampAction.isChecked(), - rescaleIntensChannelHowMapper=self.rescaleIntensChannelHowMapper - ) - win.sigAddScaleBar.connect(self.exportAddScaleBar) - win.sigAddTimestamp.connect(self.exportToVideoAddTimestamp) - win.sigRescaleIntensLut.connect(self.rescaleIntensExportToVideoDialog) - win.exec_() - if win.cancel: - self.logger.info('Export to video process cancelled') - return - - cancel = _warnings.warnExportToVideo(qparent=self) - if cancel: - self.logger.info('Export to video process cancelled') - return - - self.startExportToVideoWorker(win.selected_preferences) - - def setExportMaskImage(self, viewRange): - if not hasattr(self, 'exportMaskImage'): - self.initExportMaskImage() - else: - self.exportMaskImage[:] = 0 - - xRange, yRange = viewRange - x0, x1 = map(round, xRange) - y0, y1 = map(round, yRange) - - if self.invertBwAction.isChecked(): - self.exportMaskImage[:, :, :3] = 255 - - if x0 > 0: - self.exportMaskImage[:, :x0, 3] = 255 - if x1 < self.exportMaskImage.shape[1]: - self.exportMaskImage[:, x1:, 3] = 255 - if y0 > 0: - self.exportMaskImage[:y0, :, 3] = 255 - if y1 < self.exportMaskImage.shape[0]: - self.exportMaskImage[y1:, :, 3] = 255 - - self.exportMaskImageItem.setImage(self.exportMaskImage) - - def setViewRangeFromExportToImageDialog(self, viewRange, win=None): - xRange, yRange = viewRange - # self.ax1.sigRangeChanged.disconnect(self.viewRangeChanged) - self.ax1.setRange(xRange=xRange, yRange=yRange) - # self.ax1.sigRangeChanged.connect(self.viewRangeChanged) - # self.viewRangeChanged( - # self.ax1.vb, viewRange, updateExportMaskImage=False - # ) - self.setExportMaskImage(viewRange) - - def getZoomIDs(self, viewRange=None): - if viewRange is None: - viewRange = self.ax1.viewRange() - - lab = self.currentLab2D - Y, X = lab.shape - ((xmin, xmax), (ymin, ymax)) = viewRange - if xmin <= 0 and ymin <= 0 and xmax >= X and ymax >= Y: - posData = self.data[self.pos_i] - return None - - xmin = xmin if xmin >= 0 else 0 - ymin = ymin if ymin >= 0 else 0 - xmax = xmax if xmax < X else X - ymax = ymax if ymax < Y else Y - - zoomSlice = ( - slice(round(ymin), round(ymax)), - slice(round(xmin), round(xmax)), - ) - - zoomLab = skimage.segmentation.clear_border(lab[zoomSlice]) - zoomRp = skimage.measure.regionprops(zoomLab) - zoomIDs = [obj.label for obj in zoomRp] - return zoomIDs - - def onSigUpdateCcaTableWindow(self, *args): - if not self.isDataLoaded: - return - - if self.ccaTableWin is None: - return - - viewRange = self.ax1.viewRange() - posData = self.data[self.pos_i] - zoomIDs = self.getZoomIDs(viewRange=viewRange) - - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - @disableWindow - def exportToImage(self, preferences): - filepath = preferences['filepath'] - self.logger.info(f'Saving image to "{filepath}"...') - - if filepath.endswith('.svg'): - exporter = exporters.SVGExporter(self.ax1) - else: - exporter = exporters.ImageExporter(self.ax1, dpi=preferences['dpi']) - exporter.export(filepath) - self.logger.info(f'Image saved.') - - self.setDisabled(False) - self.exportMaskImage[:] = 0 - self.exportMaskImageItem.setImage(self.exportMaskImage) - prompts.exportToImageFinished(filepath, qparent=self) - - def exportToImageTriggered(self): - posData = self.data[self.pos_i] - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = f'{timestamp}_acdc_exported_image' - win = apps.ExportToImageParametersDialog( - parent=self, - startFolderpath=posData.pos_path, - startFilename=filename, - startViewRange=self.ax1.viewRange(), - isScaleBarPresent=self.addScaleBarAction.isChecked(), - ) - win.sigAddScaleBar.connect(self.exportAddScaleBar) - win.sigRangeChanged.connect( - partial(self.setViewRangeFromExportToImageDialog, win=win) - ) - # self.ax1.vb.sigRangeChanged.connect( - # win.updateViewRangeExportToImageDialog - # ) - self.setExportMaskImage(self.ax1.viewRange()) - self.exportToImageWindow = win - win.exec_() - # self.ax1.vb.sigRangeChanged.disconnect() - if win.cancel: - self.exportMaskImage[:] = 0 - self.exportMaskImageItem.setImage(self.exportMaskImage) - self.exportToImageWindow = None - self.logger.info('Export to image process cancelled') - return - - isTransparent = self.overlayToolbar.isTransparent() - if not isTransparent: - # SVG export works only with RGBA not with setOpacity - # --> only true transparency mode can be used - self.overlayToolbar.setTransparent(True) - - self.exportToImage(win.selected_preferences) - self.exportToImageWindow = None - - if not isTransparent: - self.overlayToolbar.setTransparent(False) - - def saveDataPermissionError(self, err_msg): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - msg = QMessageBox() - msg.critical(self, 'Permission denied', err_msg, msg.Ok) - self.waitCond.wakeAll() - - def saveDataProgress(self, text): - self.logger.info(text) - self.saveWin.progressLabel.setText(text) - - def saveDataCustomMetricsCritical(self, traceback_format, func_name): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - self.logger.info('') - _hl = '====================================' - self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') - self.worker.customMetricsErrors[func_name] = traceback_format - - def saveDataCombinedMetricsMissingColumn(self, error_msg, func_name): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - self.logger.info('') - warning = f'[WARNING]: {error_msg}. Metric {func_name} was skipped.' - _hl = '====================================' - self.logger.info(f'{_hl}\n{warning}\n{_hl}') - self.worker.customMetricsErrors[func_name] = warning - - def saveDataAddMetricsCritical(self, traceback_format, error_message): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - self.logger.info('') - _hl = '====================================' - self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') - self.worker.addMetricsErrors[error_message] = traceback_format - - def saveDataRegionPropsCritical(self, traceback_format, error_message): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - self.logger.info('') - _hl = '====================================' - self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') - self.worker.regionPropsErrors[error_message] = traceback_format - - def saveDataUpdateMetricsPbar(self, max, step): - if max > 0: - self.saveWin.metricsQPbar.setMaximum(max) - self.saveWin.metricsQPbar.setValue(0) - self.saveWin.metricsQPbar.setValue( - self.saveWin.metricsQPbar.value()+step - ) - - def saveDataUpdatePbar(self, step, max=-1, exec_time=0.0): - if max >= 0: - self.saveWin.QPbar.setMaximum(max) - else: - self.saveWin.QPbar.setValue(self.saveWin.QPbar.value()+step) - steps_left = self.saveWin.QPbar.maximum()-self.saveWin.QPbar.value() - seconds = round(exec_time*steps_left) - ETA = myutils.seconds_to_ETA(seconds) - self.saveWin.ETA_label.setText(f'ETA: {ETA}') - - def quickSave(self): - self.saveData(isQuickSave=True) - - def checkMissingCca(self): - proceed = True - ignore = False - doNotShowAgain = False - if not self.doNotShowAgainMissingCca: - return proceed, ignore, doNotShowAgain - - missing_cca_items = [] - for posData in self.data: - for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - - if 'cell_cycle_stage' not in acdc_df.columns: - continue - - cca_df = acdc_df[cca_df_colnames] - if cca_df.isnull().values.any(): - i = frame_i if not self.isSnapshot else None - missing_cca_items.append((cca_df, posData, i)) - - if not missing_cca_items: - return proceed, ignore, doNotShowAgain - - proceed = False - ignore, doNotShowAgain =_warnings.warnMissingCca( - missing_cca_items, qparent=self - ) - - if doNotShowAgain: - self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'Yes' - self.df_settings.to_csv(self.settings_csv_path) - - return proceed, ignore, doNotShowAgain - - def warnDifferentSegmChannel( - self, loaded_channel, segm_channel_hyperparams, segmEndName - ): - txt = html_utils.paragraph(f""" - You loaded the segmentation file ending with _{segmEndName}.npz - which corresponds to the channel - {segm_channel_hyperparams}.

- However, in this session you loaded the channel - {loaded_channel}.

- If you proceed with saving, the segmentation file ending with - _{segmEndName}.npz will be OVERWRITTEN.

- Are you sure you want to proceed? - """) - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.warning( - self, 'WARNING: Potential for data loss', txt, - buttonsTexts=('Cancel', 'Yes') - ) - return msg.cancel - - def waitAutoSaveWorker(self, worker): - if worker.isFinished or worker.isPaused or len(worker.dataQ) == 0: - self.waitAutoSaveWorkerLoop.exit() - self.waitAutoSaveWorkerTimer.stop() - self.setStatusBarLabel(log=False) - - @exception_handler - def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): - self.setDisabled(True, keepDisabled=True) - - self.askLineageTreeChanges() - - self.store_data(autosave=False) - self.applyDelROIs() - self.store_data() - self._isQuickSave = isQuickSave - - # Wait autosave worker to finish - for worker, thread in self.autoSaveActiveWorkers: - self.logger.info('Stopping autosaving process...') - self.statusBarLabel.setText('Stopping autosaving process...') - worker.stop() - self.waitAutoSaveWorkerTimer = QTimer() - self.waitAutoSaveWorkerTimer.timeout.connect( - partial(self.waitAutoSaveWorker, worker) - ) - self.waitAutoSaveWorkerTimer.start(100) - self.waitAutoSaveWorkerLoop = QEventLoop() - self.waitAutoSaveWorkerLoop.exec_() - - self.titleLabel.setText( - 'Saving data... (check progress in the terminal)', - color=self.titleColor - ) - - # Check channel name correspondence to warn - posData = self.data[self.pos_i] - lastSegmChannel, segmEndName = posData.getSegmentedChannelHyperparams() - if lastSegmChannel != self.user_ch_name and lastSegmChannel: - cancel = self.warnDifferentSegmChannel( - self.user_ch_name, lastSegmChannel, segmEndName - ) - if cancel: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return True - posData.updateSegmentedChannelHyperparams(self.user_ch_name) - - # Check missing cca annotations in snaphots - proceed, ignore, self.doNotShowAgainMissingCca = self.checkMissingCca() - if not proceed and not ignore: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return - - self.save_metrics = False - if not isQuickSave: - self.save_metrics, cancel = self.askSaveMetrics() - if cancel: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return True - - self.posToSave = None - if self.isSnapshot and not isQuickSave and len(self.data) > 1: - self.posToSave = self.askPosToSave() - if self.posToSave is None: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return True - - if isQuickSave: - # Quick save only current pos - self.posToSave = {self.data[self.pos_i].pos_foldername} - - if self.isSnapshot: - self.store_data(mainThread=False) - - mode = self.modeComboBox.currentText() - if mode == 'Cell cycle analysis': - proceed = self.askSaveLastVisitedCcaMode(isQuickSave=isQuickSave) - if not proceed: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return True - else: - proceed = self.askSaveLastVisitedSegmMode(isQuickSave=isQuickSave) - if not proceed: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return True - - append_name_og_whitelist, proceed, do_not_save_og_whitelist = self.askSaveOriginalSegm(isQuickSave=isQuickSave) - if not proceed: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return True - - if self.save_metrics or mode == 'Cell cycle analysis': - self.computeVolumeRegionprop() - - infoTxt = html_utils.paragraph( - f'Saving {self.exp_path}...
', font_size='14px' - ) - - self.saveWin = apps.QDialogPbar( - parent=self, title='Saving data', infoTxt=infoTxt - ) - self.saveWin.setFont(_font) - # if not self.save_metrics: - self.saveWin.metricsQPbar.hide() - self.saveWin.progressLabel.setText('Preparing data...') - self.saveWin.show() - - # Set up separate thread for saving and show progress bar widget - self.mutex = QMutex() - self.waitCond = QWaitCondition() - self.thread = QThread() - self.worker = workers.saveDataWorker(self) - self.worker.mode = mode - self.worker.isQuickSave = isQuickSave - self.worker.append_name_og_whitelist = append_name_og_whitelist - self.worker.do_not_save_og_whitelist = do_not_save_og_whitelist - - self.worker.moveToThread(self.thread) - - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - # Custom signals - self.worker.finished.connect(self.saveDataFinished) - if finishedCallback is not None: - self.worker.finished.connect(finishedCallback) - self.worker.progress.connect(self.saveDataProgress) - self.worker.sigLog.connect(self.workerLog) - self.worker.progressBar.connect(self.saveDataUpdatePbar) - # self.worker.metricsPbarProgress.connect(self.saveDataUpdateMetricsPbar) - self.worker.critical.connect(self.saveDataWorkerCritical) - self.worker.customMetricsCritical.connect( - self.saveDataCustomMetricsCritical - ) - self.worker.sigCombinedMetricsMissingColumn.connect( - self.saveDataCombinedMetricsMissingColumn - ) - self.worker.addMetricsCritical.connect(self.saveDataAddMetricsCritical) - self.worker.regionPropsCritical.connect( - self.saveDataRegionPropsCritical - ) - self.worker.criticalPermissionError.connect(self.saveDataPermissionError) - self.worker.askZsliceAbsent.connect(self.zSliceAbsent) - self.worker.sigDebug.connect(self._workerDebug) - - self.thread.started.connect(self.worker.run) - - self.thread.start() - - return False - - def _workerDebug(self, stuff_to_debug): - pass - # from acdctools.plot import imshow - # lab, frame_i, autoBkgr_masks = stuff_to_debug - # autoBkgr_mask, autoBkgr_mask_proj = autoBkgr_masks - # imshow(lab, autoBkgr_mask) - # self.worker.waitCond.wakeAll() - - def changeTextResolution(self): - mode = 'high' if self.highLowResAction.isChecked() else 'low' - self.logger.info( - f'Switching to {mode} for the text annnotations...' - ) - self.pxModeAction.setDisabled(not self.highLowResAction.isChecked()) - if not self.isDataLoaded: - return - - self.setAllIDs() - posData = self.data[self.pos_i] - allIDs = posData.allIDs - img_shape = self.img1.image.shape[:2] - self.textAnnot[0].changeResolution(mode, allIDs, self.ax1, img_shape) - self.textAnnot[1].changeResolution(mode, allIDs, self.ax2, img_shape) - self.updateAllImages() - - def highLowResToggled(self, clicked=True): - self.changeTextResolution() - - def autoSaveClose(self): - for worker, thread in self.autoSaveActiveWorkers: - worker._stop() - - def viewPreprocDataToggled(self, checked): - self.img1.setUsePreprocessed(checked) - self.setImageImg1() - - if self.viewCombineChannelDataToggle.isChecked(): - self.viewCombineChannelDataToggle.toggled.disconnect() - self.viewCombineChannelDataToggle.setChecked(False) - self.viewCombineChannelDataToggle.toggled.connect( - self.viewCombineChannelDataToggled - ) - - def setAutoSaveSegmentationEnabled(self, enabled): - if not self.autoSaveActiveWorkers: - return - - worker, thread = self.autoSaveActiveWorkers[-1] - - if enabled: - worker.isAutoSaveON = self.autoSaveToggle.isChecked() - else: - worker.isAutoSaveON = False - - def setAutoSaveAnnotationsEnabled(self, enabled): - if not self.autoSaveActiveWorkers: - return - - worker, thread = self.autoSaveActiveWorkers[-1] - - if enabled: - worker.isAutoSaveAnnotON = self.autoSaveToggle.isChecked() - else: - worker.isAutoSaveAnnotON = False - - def autoSaveToggled(self, checked): - if not self.autoSaveActiveWorkers: - self.gui_createAutoSaveWorker() - - if not self.autoSaveActiveWorkers: - return - - worker, thread = self.autoSaveActiveWorkers[-1] - - mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': - # Autosaving segmentation makes sense only in - # "Segmentation and Tracking" mode - checked = False - - worker.isAutoSaveON = checked - - def autoSaveAnnotToggled(self, checked): - if not self.autoSaveActiveWorkers: - self.gui_createAutoSaveWorker() - - if not self.autoSaveActiveWorkers: - return - - worker, thread = self.autoSaveActiveWorkers[-1] - - mode = self.modeComboBox.currentText() - if mode != 'Viewer': - # No reason to save in viewer mode - checked = False - - worker.isAutoSaveAnnotON = checked - - def autoSaveIntervalEdit(self): - self.autoSaveIntervalDialog.show() - self.autoSaveIntervalDialog.raise_() - self.autoSaveIntervalDialog.activateWindow() - - def autoSaveIntervalValueChanged( - self, value: float, unit: Literal['minutes', 'frames'] - ): - self.autoSaveIntevalValueUnit = (value, unit) - self.autoSaveTimer.stop() - - self.df_settings.at['autoSaveIntevalValue', 'value'] = str(value) - self.df_settings.at['autoSaveIntervalUnit', 'value'] = unit - self.df_settings.to_csv(settings_csv_path) - - self.logger.info( - f'Autosave interval changed to: {value} {unit}' - ) - self.autoSaveIntervalSetTooltip() - - if unit == 'frames': - self.startAutoSaveEveryNframesTimer() - - def autoSaveIntervalSetTooltip(self): - value, unit = self.autoSaveIntevalValueUnit - autoSaveIntervalEditTooltip = ( - 'Change autosave interval to every N frames or minutes\n\n' - f'Current autosave interval: {value} {unit}' - ) - self.autoSaveIntervalLabel.setToolTip(autoSaveIntervalEditTooltip) - self.autoSaveIntervalEditButton.setToolTip(autoSaveIntervalEditTooltip) - - def ccaIntegrCheckerToggled(self, checked): - self.df_settings.at['is_cca_integrity_checker_activated', 'value'] = ( - int(checked) - ) - self.df_settings.to_csv(self.settings_csv_path) - mode = self.modeComboBox.currentText() - if mode != 'Cell cycle analysis': - return - - if checked: - self.startCcaIntegrityCheckerWorker() - else: - self.disableCcaIntegrityChecker() - - def warnErrorsCustomMetrics(self): - win = apps.ComputeMetricsErrorsDialog( - self.worker.customMetricsErrors, self.logs_path, - log_type='custom_metrics', parent=self - ) - win.exec_() - - def warnErrorsAddMetrics(self): - win = apps.ComputeMetricsErrorsDialog( - self.worker.addMetricsErrors, self.logs_path, - log_type='standard_metrics', parent=self - ) - win.exec_() - - def warnErrorsRegionProps(self): - win = apps.ComputeMetricsErrorsDialog( - self.worker.regionPropsErrors, self.logs_path, - log_type='region_props', parent=self - ) - win.exec_() - - def askConcatenate(self): - if self.mainWin is None: - return - - if self._isQuickSave: - return - - if 'showAskConcatenate' not in self.df_settings.index: - self.df_settings.at['showAskConcatenate', 'value'] = 'Yes' - - showAskConcatenate = ( - self.df_settings.at['showAskConcatenate', 'value'] == 'Yes' - ) - if not showAskConcatenate: - return - - txt = html_utils.paragraph(f""" - Do you want to concatenate the `acdc_output.csv` tables from - multiple Positions into one single CSV file?
- """) - doNotShowAgainCheckbox = QCheckBox('Do not show again') - msg = widgets.myMessageBox(wrapText=False) - noButton, yesButton = msg.question( - self, 'Concatenate tables?', txt, - buttonsTexts=('No', 'Yes'), - widgets=doNotShowAgainCheckbox - ) - showAskConcatenate = ( - 'No' if doNotShowAgainCheckbox.isChecked() else 'Yes' - ) - self.df_settings.at['showAskConcatenate', 'value'] = ( - showAskConcatenate - ) - self.df_settings.to_csv(settings_csv_path) - - if not msg.clickedButton == yesButton: - return - - txt = html_utils.paragraph(f""" - To concatenate the `acdc_output.csv` tables from - multiple Positions and multiple experiments
- launch the concatenation utility from the top menubar of the Cell-ACDC main launcher:

- Utilities --> Concatenate --> Concatenate acdc output tables from multiple Positions and experiments.... - """) - msg = widgets.myMessageBox(wrapText=False) - msg.information(self, 'How to concatenate tables', txt) - - def updateSegmDataAutoSaveWorker(self): - # Update savedSegmData in autosave worker - posData = self.data[self.pos_i] - for worker, thread in self.autoSaveActiveWorkers: - worker.savedSegmData = posData.segm_data.copy() - - def saveDataFinished(self): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - if self.saveWin.aborted or self.worker.abort: - self.titleLabel.setText('Saving process cancelled.', color='r') - elif self._isQuickSave: - self.titleLabel.setText('Saved segmentation file and annotations') - else: - self.titleLabel.setText('Saved!') - self.saveWin.workerFinished = True - self.saveWin.close() - - if not self.closeGUI: - # Update savedSegmData in autosave worker - self.updateSegmDataAutoSaveWorker() - - if self.worker.addMetricsErrors: - self.warnErrorsAddMetrics() - if self.worker.regionPropsErrors: - self.warnErrorsRegionProps() - if self.worker.customMetricsErrors: - self.warnErrorsCustomMetrics() - - self.checkManageVersions() - - self.askConcatenate() - - if self.closeGUI: - salute_string = myutils.get_salute_string() - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Data saved!. The GUI will now close.

' - f'{salute_string}' - ) - msg.information(self, 'Data saved', txt) - self.close() - - def copyContent(self): - pass - - def pasteContent(self): - pass - - def cutContent(self): - pass - - def showAbout(self): - self.aboutWin = about.QDialogAbout(parent=self) - self.aboutWin.show() - - def openLogFile(self): - self.logger.info(f'Opening log file "{self.log_path}"...') - myutils.showInExplorer(self.log_path) - - def showLogFiles(self): - log_files_path = os.path.dirname(self.log_path) - self.logger.info(f'Opening log files folder "{log_files_path}"...') - myutils.showInExplorer(log_files_path) - - def showTipsAndTricks(self): - self.welcomeWin = welcome.welcomeWin() - self.welcomeWin.showAndSetSize() - self.welcomeWin.showPage(self.welcomeWin.quickStartItem) - - def about(self): - pass - - def openRecentFile(self, path): - self.logger.info(f'Opening recent folder: {path}') - self.addToRecentPaths(path, logger=self.logger) - self.openFolder(exp_path=path) - - def _waitCloseAutoSaveWorker(self): - didWorkersFinished = [True] - for worker, thread in self.autoSaveActiveWorkers: - if worker.isFinished: - didWorkersFinished.append(True) - else: - didWorkersFinished.append(False) - if all(didWorkersFinished): - self.waitCloseAutoSaveWorkerLoop.stop() - - def cancelSavingInitialisation(self): - self.titleLabel.setText( - 'Saving data process cancelled.', color=self.titleColor - ) - self.closeGUI = False - - @disableWindow - def askSaveOnClosing(self, event): - if not self.saveAction.isEnabled(): - return True - if self.titleLabel.text == 'Saved!': - return True - if not self.isDataLoaded: - return True - - msg = widgets.myMessageBox() - txt = html_utils.paragraph('Do you want to save before closing?') - _, noButton, yesButton = msg.question( - self, 'Save?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') - ) - if msg.cancel: - event.ignore() - return False - - if msg.clickedButton == yesButton: - self.closeGUI = True - QTimer.singleShot(100, self.saveAction.trigger) - event.ignore() - return False - return True - - def clearMemory(self): - if not hasattr(self, 'data'): - return - self.logger.info('Clearing memory...') - for posData in self.data: - try: - del posData.img_data - except Exception as e: - pass - try: - del posData.segm_data - except Exception as e: - pass - try: - del posData.ol_data_dict - except Exception as e: - pass - try: - del posData.fluo_data_dict - except Exception as e: - pass - try: - del posData.ol_data - except Exception as e: - pass - del self.data - - def setUncheckedPointsLayers(self): - self.togglePointsLayerAction.setChecked(False) - self.magicPromptsToolButton.setChecked(False) - - def clearHighlightedID(self): - self.highlightIDToolbar.setVisible(False) - - try: - self.updateLostContoursImage(ax=0, delROIsIDs=None) - except Exception as err: - pass - - if self.highlightedID == 0: - return - - self.highlightedID = 0 - self.guiTabControl.highlightCheckbox.setChecked(False) - self.guiTabControl.highlightSearchedCheckbox.setChecked(False) - self.setHighlightID(False) - - def onEscape( - self, - isTypingIDFunctionChecked=False, - buttonsToNotUncheck=None, - doAutoRange=True - ): - if buttonsToNotUncheck is None: - buttonsToNotUncheck = set() - - if self.keepIDsButton.isChecked() and self.keptObjectsIDs: - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - self.highlightHoverIDsKeptObj(0, 0, hoverID=0) - QTimer.singleShot(300, self.autoRange) - return - - if self.brushButton.isChecked() and self.typingEditID: - self.autoIDcheckbox.setChecked(True) - self.typingEditID = False - QTimer.singleShot(300, self.autoRange) - return - - if isTypingIDFunctionChecked and self.typingEditID: - self.typingEditID = False - QTimer.singleShot(300, self.autoRange) - return - - if self.labelRoiButton.isChecked() and self.isMouseDragImg1: - self.isMouseDragImg1 = False - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) - self.freeRoiItem.clear() - QTimer.singleShot(300, self.autoRange) - return - - if self.zoomRectButton.isChecked(): - self.zoomRectCancelled() - QTimer.singleShot(300, self.autoRange) - return - - self.setUncheckedAllButtons(buttonsToNotUncheck=buttonsToNotUncheck) - self.setUncheckedAllCustomAnnotButtons() - self.setUncheckedPointsLayers() - self.clearTempBrushImage() - self.isMouseDragImg1 = False - self.typingEditID = False - self.clearHighlightedID() - try: - self.polyLineRoi.clearPoints() - except Exception as e: - pass - - if doAutoRange: - QTimer.singleShot(11, self.autoRange) - - def clearTempBrushImage(self, forceClearLinked=True): - if not hasattr(self, 'tempLayerImg1'): - return - - self.tempLayerImg1.setImage( - self.emptyLab, force_set_linked=forceClearLinked - ) - - try: - self.brushContourImage[:] = 0 - except Exception as err: - pass - - try: - self.brushImage[:] = 0 - except Exception as err: - pass - - def askCloseAllWindows(self): - txt = html_utils.paragraph(""" - There are other open windows that were created from this window. -

- If you proceed, the other windows will be closed too.
- """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning( - self, 'Open windows', txt, - buttonsTexts=('Cancel', 'Ok, close now') - ) - return msg.cancel - - def stopPreprocWorker(self): - self.logger.info('Closing pre-processing worker...') - try: - self.preprocWorker.stop() - except Exception as err: - pass - - def closeEvent(self, event): - self.setDisabled(False) - cancel = self.checkAskSavePointsLayers() - if cancel: - event.ignore() - return - - self.onEscape() - self.saveWindowGeometry() - - if self.newWindows: - cancel = self.askCloseAllWindows() - if cancel: - event.ignore() - return - - for window in self.newWindows: - window.close() - - if self.slideshowWin is not None: - self.slideshowWin.close() - if self.ccaTableWin is not None: - self.ccaTableWin.close() - - proceed = self.askSaveOnClosing(event) - if not proceed: - event.ignore() - return - - self.autoSaveClose() - - if self.autoSaveActiveWorkers: - progressWin = apps.QDialogWorkerProgress( - title='Closing autosaving worker', parent=self, - pbarDesc='Closing autosaving worker...' - ) - progressWin.show(self.app) - progressWin.mainPbar.setMaximum(0) - self.waitCloseAutoSaveWorkerLoop = qutils.QWhileLoop( - self._waitCloseAutoSaveWorker, period=250 - ) - self.waitCloseAutoSaveWorkerLoop.exec_() - progressWin.workerFinished = True - progressWin.close() - - self.stopPreprocWorker() - self.stopCombineWorker() - self.stopCcaIntegrityCheckerWorker() - - # Close the inifinte loop of the thread - if self.lazyLoader is not None: - self.lazyLoader.exit = True - self.lazyLoaderWaitCond.wakeAll() - self.waitReadH5cond.wakeAll() - - if self.storeStateWorker is not None: - # Close storeStateWorker - self.storeStateWorker._stop() - while self.storeStateWorker.isFinished: - time.sleep(0.05) - - # Block main thread while separate threads closes - time.sleep(0.1) - - self.clearMemory() - - self.logger.info('Closing GUI logger...') - self.logger.close() - - if self.lazyLoader is None: - self.sigClosed.emit(self) - - gc.collect() - - def storeManualSeparateDrawMode(self, mode): - self.df_settings.at['manual_separate_draw_mode', 'value'] = mode - self.df_settings.to_csv(self.settings_csv_path) - - def readSettings(self): - settings = QSettings('schmollerlab', 'acdc_gui') - if settings.value('geometry') is not None: - self.restoreGeometry(settings.value("geometry")) - # self.restoreState(settings.value("windowState")) - - def saveWindowGeometry(self): - settings = QSettings('schmollerlab', 'acdc_gui') - settings.setValue("geometry", self.saveGeometry()) - # settings.setValue("windowState", self.saveState()) - - def storeDefaultAndCustomColors(self): - c = self.overlayButton.palette().button().color().name() - self.defaultToolBarButtonColor = c - self.doublePressKeyButtonColor = '#fa693b' - - def initPixelSizePropsDockWidget(self): - posData = self.data[self.pos_i] - PhysicalSizeX = posData.PhysicalSizeX - PhysicalSizeY = posData.PhysicalSizeY - PhysicalSizeZ = posData.PhysicalSizeZ - self.guiTabControl.initPixelSize( - PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ - ) - - def showPropsDockWidget(self, checked=False): - if self.showPropsDockButton.isExpand: - self.propsDockWidget.setVisible(False) - self.setHighlightID(False) - else: - self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - if self.isSegm3D: - self.guiTabControl.propsQGBox.cellVolVox3D_SB.show() - self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.show() - self.guiTabControl.propsQGBox.cellVolFl3D_DSB.show() - self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.show() - else: - self.guiTabControl.propsQGBox.cellVolVox3D_SB.hide() - self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.hide() - self.guiTabControl.propsQGBox.cellVolFl3D_DSB.hide() - self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.hide() - - self.propsDockWidget.setVisible(True) - self.propsDockWidget.setEnabled(True) - self.updateAllImages() - - def showEvent(self, event): - if self.mainWin is not None: - if not self.mainWin.isMinimized(): - return - self.mainWin.showAllWindows() - # self.setFocus() - self.activateWindow() - - def super_show(self): - super().show() - - def show(self): - self.setFont(_font) - QMainWindow.show(self) - - self.setWindowState(Qt.WindowNoState) - self.setWindowState(Qt.WindowActive) - self.raise_() - - self.readSettings() - self.storeDefaultAndCustomColors() - - self.h = self.navSpinBox.size().height() - fontSizeFactor = None - heightFactor = None - if 'bottom_sliders_zoom_perc' in self.df_settings.index: - val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value']) - if val != 100: - fontSizeFactor = val/100 - heightFactor = val/100 - - self.defaultWidgetHeightBottomLayout = self.h - self.checkBoxesHeight = 14 - self.fontPixelSize = 11 - self.defaultBottomLayoutHeight = self.img1BottomGroupbox.height() - - self.bottomLayout.setStretch(0, 0) - self.bottomLayout.addSpacing(self.quickSettingsGroupbox.width()) - self.resizeSlidersArea( - fontSizeFactor=fontSizeFactor, heightFactor=heightFactor - ) - self.bottomScrollArea.hide() - - self.gui_initImg1BottomWidgets() - self.img1BottomGroupbox.hide() - - w = self.showPropsDockButton.width() - h = self.showPropsDockButton.height() - - self.showPropsDockButton.setMaximumWidth(15) - self.showPropsDockButton.setMaximumHeight(120) - - for toolbar in self.controlToolBars: - toolbar.setMinimumHeight( - self.secondLevelToolbar.sizeHint().height() - ) - - self.graphLayout.setFocus() - - def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): - global _font - if heightFactor is None: - self.newCheckBoxesHeight = self.checkBoxesHeight - self.newHeight = self.h - else: - self.newHeight = round(self.h*heightFactor) - self.newCheckBoxesHeight = round(self.checkBoxesHeight*heightFactor) - - if fontSizeFactor is None: - newFontSize = self.fontPixelSize - else: - newFontSize = round(self.fontPixelSize*fontSizeFactor) - newFont = QFont() - newFont.setPixelSize(newFontSize) - _font = newFont - self.zProjComboBox.setFont(newFont) - self.t_label.setFont(newFont) - self.zProjOverlay_CB.setFont(newFont) - self.annotateRightHowCombobox.setFont(newFont) - self.drawIDsContComboBox.setFont(newFont) - self.showTreeInfoCheckbox.setFont(newFont) - self.highlightZneighObjCheckbox.setFont(newFont) - self.navSpinBox.setFont(newFont) - self.zSliceSpinbox.setFont(newFont) - self.SizeZlabel.setFont(newFont) - self.navSizeLabel.setFont(newFont) - self.overlay_z_label.setFont(newFont) - self.img1BottomGroupbox.setFont(newFont) - self.rightBottomGroupbox.setFont(newFont) - try: - self.img1.alphaScrollbar.label.setFont(newFont) - except Exception as e: - pass - for i in range(self.annotOptionsLayout.count()): - widget = self.annotOptionsLayout.itemAt(i).widget() - widget.setFont(newFont) - for i in range(self.annotOptionsLayoutRight.count()): - widget = self.annotOptionsLayoutRight.itemAt(i).widget() - widget.setFont(newFont) - try: - for channel, items in self.overlayLayersItems.items(): - alphaScrollbar = items[2] - alphaScrollbar.label.setFont(newFont) - except: - pass - QTimer.singleShot(100, self._resizeSlidersArea) - - def _resizeSlidersArea(self): - self.navigateScrollBar.setFixedHeight(self.newHeight) - self.zSliceScrollBar.setFixedHeight(self.newHeight) - self.zSliceOverlay_SB.setFixedHeight(self.newHeight) - self.zProjComboBox.setFixedHeight(self.newHeight) - self.zProjOverlay_CB.setFixedHeight(self.newHeight) - self.navSpinBox.setFixedHeight(self.newHeight) - self.zSliceSpinbox.setFixedHeight(self.newHeight) - try: - self.img1.alphaScrollbar.setFixedHeight(self.newHeight) - except Exception as e: - pass - try: - for channel, items in self.overlayLayersItems.items(): - alphaScrollbar = items[2] - alphaScrollbar.setFixedHeight(self.newHeight) - except: - pass - checkBoxStyleSheet = ( - 'QCheckBox::indicator {' - f'width: {self.newCheckBoxesHeight}px;' - f'height: {self.newCheckBoxesHeight}px' - '}' - ) - for i in range(self.annotOptionsLayout.count()): - widget = self.annotOptionsLayout.itemAt(i).widget() - if isinstance(widget, QCheckBox): - widget.setStyleSheet(checkBoxStyleSheet) - for i in range(self.annotOptionsLayoutRight.count()): - widget = self.annotOptionsLayoutRight.itemAt(i).widget() - if isinstance(widget, QCheckBox): - widget.setStyleSheet(checkBoxStyleSheet) - self.zSliceCheckbox.setStyleSheet(checkBoxStyleSheet) - - def resizeEvent(self, event): - if hasattr(self, 'ax1'): - self.ax1.autoRange() - - def hoverEventDrawSpline(self, event): - x, y = event.pos() - xx, yy = self.curvAnchors.getData() - hoverAnchors = self.curvAnchors.pointsAt(event.pos()) - per = False - # If we are hovering the starting point we generate - # a closed spline - if len(xx) < 2: - return - - if len(hoverAnchors)>0: - xA_hover, yA_hover = hoverAnchors[0].pos() - if xx[0]==xA_hover and yy[0]==yA_hover: - per=True - if per: - # Append start coords and close spline - xx = np.r_[xx, xx[0]] - yy = np.r_[yy, yy[0]] - xi, yi = self.getSpline(xx, yy, per=per) - # self.curvPlotItem.setData([], []) - else: - # Append mouse coords - xx = np.r_[xx, x] - yy = np.r_[yy, y] - xi, yi = self.getSpline(xx, yy, per=per) - self.curvHoverPlotItem.setData(xi, yi) - - def updateViewRangeExportToImage(self, viewRange): - if self.exportToImageWindow is None: - return - - # prevViewRange = self.exportToImageWindow.viewRange() - prevViewRange = self._viewRange - prevXRange = prevViewRange[0] - prevYRange = prevViewRange[1] - currXRange = viewRange[0] - currYRange = viewRange[1] - - prevX0, prevX1 = prevXRange - currX0, currX1 = currXRange - prevY0, prevY1 = prevYRange - currY0, currY1 = currYRange - - deltaX = currX0 - prevX0 - deltaY = currY0 - prevY0 - - winViewRange = self.exportToImageWindow.viewRange() - winXRange = winViewRange[0] - winYRange = winViewRange[1] - winX0, winX1 = winXRange - winY0, winY1 = winYRange - - newX0 = winX0 + deltaX - newX1 = winX1 + deltaX - newY0 = winY0 + deltaY - newY1 = winY1 + deltaY - - self.exportToImageWindow.setViewRange( - (newX0, newX1), (newY0, newY1), emitSignal=False - ) - - def viewRangeChanged(self, viewBox, viewRange, updateExportImageMask=True): - # self.updateViewRangeExportToImage(viewRange) - self.updateValuesStatusBar() - - if hasattr(self, 'scaleBar'): - isScaleBarMoveWithZoom = ( - self.scaleBar.properties()['move_with_zoom'] - ) - else: - isScaleBarMoveWithZoom = False - doMoveScaleBar = ( - self.scaleBarDialog is not None or isScaleBarMoveWithZoom - ) - if doMoveScaleBar: - self.scaleBar.updatePosViewRangeChanged(viewRange) - - if hasattr(self, 'timestamp'): - isTimestampMoveWithZoom = ( - self.timestamp.properties()['move_with_zoom'] - ) - else: - isTimestampMoveWithZoom = False - - doMoveTimestamp = ( - self.timestampDialog is not None or isTimestampMoveWithZoom - ) - if doMoveTimestamp: - self.timestamp.updatePosViewRangeChanged(viewRange) - - self._viewRange = viewRange +from .views.combine_view import CombineView +from .views.whitelist_view import WhitelistView +from .views.app_shell_view import AppShellView +from .views.actions_view import ActionsView +from .views.brush_tools_view import BrushToolsView +from .views.annotation_display_view import AnnotationDisplayView +from .views.canvas_context_menu_view import CanvasContextMenuView +from .views.canvas_drawing_view import CanvasDrawingView +from .views.canvas_events_view import CanvasEventsView +from .views.canvas_selection_view import CanvasSelectionView +from .views.canvas_right_image_view import CanvasRightImageView +from .views.canvas_tool_view import CanvasToolView +from .views.canvas_hover_view import CanvasHoverView +from .views.cell_cycle_view import CellCycleView +from .views.custom_annotations_view import CustomAnnotationsView +from .views.curvature_tools_view import CurvatureToolsView +from .views.display_decorations_view import DisplayDecorationsView +from .views.status_hover_view import StatusHoverView +from .views.deleted_rois_view import DeletedRoisView +from .views.data_loading_view import DataLoadingView +from .views.draw_clear_region_view import DrawClearRegionView +from .views.exporting_view import ExportingView +from .views.graphics_view import GraphicsView +from .views.frame_navigation_view import FrameNavigationView +from .views.image_controls_view import ImageControlsView +from .views.image_display_view import ImageDisplayView +from .views.label_editing_view import LabelEditingView +from .views.label_roi_view import LabelRoiView +from .views.label_transform_tools_view import LabelTransformToolsView +from .views.lineage_interactions_view import LineageInteractionsView +from .views.magic_prompts_view import MagicPromptsView +from .views.measurements_view import MeasurementsView +from .views.layout_controls_view import LayoutControlsView +from .views.main_menu_view import MainMenuView +from .views.mode_controls_view import ModeControlsView +from .views.object_search_view import ObjectSearchView +from .views.object_properties_view import ObjectPropertiesView +from .views.object_cleanup_view import ObjectCleanupView +from .views.points_layers_view import PointsLayersView +from .views.preprocessing_view import PreprocessingView +from .views.quick_settings_view import QuickSettingsView +from .views.saving_view import SavingView +from .views.seg_for_lost_ids_view import SegForLostIdsView +from .views.segmentation_view import SegmentationView +from .views.session_view import SessionView +from .views.tool_activation_view import ToolActivationView +from .views.main_toolbar_view import MainToolbarView +from .views.tracking_view import TrackingView +from .views.undo_redo_view import UndoRedoView +from .views.window_events_view import WindowEventsView +from .views.worker_view import WorkerView diff --git a/cellacdc/models/__init__.py b/cellacdc/models/__init__.py old mode 100755 new mode 100644 index b9c59c7b0..8b1378917 --- a/cellacdc/models/__init__.py +++ b/cellacdc/models/__init__.py @@ -1,5 +1 @@ -STARDIST_MODELS = [ - '2D_versatile_fluo', - '2D_versatile_he', - '2D_paper_dsb2018' -] \ No newline at end of file + diff --git a/cellacdc/models/actions_model.py b/cellacdc/models/actions_model.py new file mode 100644 index 000000000..2610c08a5 --- /dev/null +++ b/cellacdc/models/actions_model.py @@ -0,0 +1,31 @@ +"""Scriptable model rules for GUI actions and shortcuts.""" + +from __future__ import annotations + + +class ActionsModel: + """Headless decisions for action and shortcut workflows.""" + + keyboard_shortcuts_section = 'keyboard.shortcuts' + delete_object_section = 'delete_object.action' + delete_key_option = 'Key sequence' + delete_button_option = 'Mouse button' + + def default_delete_object_texts(self, *, is_mac: bool) -> tuple[str, str]: + if is_mac: + return 'Ctrl', 'Left click' + return '', 'Middle click' + + def sanitize_key_sequence_text(self, text) -> str: + if text is None: + return '' + return str(text).encode('ascii', 'ignore').decode('utf-8') + + def delete_object_button_text(self, *, is_left_click: bool) -> str: + return 'Left click' if is_left_click else 'Middle click' + + def delete_object_button_is_left_click(self, text: str) -> bool: + return text == 'Left click' + + def should_restore_default_delete_action(self, *, had_error: bool) -> bool: + return had_error diff --git a/cellacdc/models/annotation_display_model.py b/cellacdc/models/annotation_display_model.py new file mode 100644 index 000000000..ecfd878c1 --- /dev/null +++ b/cellacdc/models/annotation_display_model.py @@ -0,0 +1,542 @@ +"""Qt-free model rules for annotation display workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Mapping + + +AnnotationSide = Literal['left', 'right'] +AnnotationOption = Literal[ + 'ids', + 'cca', + 'mother_bud_lines', + 'contours', + 'segm_masks', + 'nothing', + 'num_zslices', +] + + +@dataclass(frozen=True) +class AnnotationOptionState: + """Checkbox state for one annotation side.""" + + ids: bool = False + cca: bool = False + contours: bool = False + segm_masks: bool = False + mother_bud_lines: bool = False + num_zslices: bool = False + nothing: bool = False + + +@dataclass(frozen=True) +class AnnotationModeChangePlan: + """Pure outcome of changing the annotation mode for one image side.""" + + side: AnnotationSide + setting_update: tuple[str, str] | None + text_annotation_index: int + is_cca_annotation: bool + is_id_annotation: bool + should_refresh_images: bool + should_reset_eraser_temp: bool = False + + +@dataclass(frozen=True) +class AnnotationOptionChangePlan: + """Pure outcome of changing annotation option checkboxes.""" + + side: AnnotationSide + state: AnnotationOptionState + mode_text: str + save_settings: bool + + +@dataclass(frozen=True) +class AnnotationOptionsFromModeTextPlan: + """Pure outcome of syncing option checkboxes from combobox text.""" + + state_updates: tuple[tuple[AnnotationSide, AnnotationOptionState], ...] + + +@dataclass(frozen=True) +class AnnotationDisplaySettingsRestorePlan: + """Pure outcome of restoring annotation display settings.""" + + left_mode: str + right_mode: str + add_new_ids_whitelist_toggle: bool + + +@dataclass(frozen=True) +class PixelModeChangePlan: + """Pure outcome of toggling annotation pixel mode.""" + + setting_update: tuple[str, int] + should_update_text_pixel_mode: bool + should_refresh_images: bool + + +@dataclass(frozen=True) +class TextResolutionChangePlan: + """Pure outcome of toggling annotation text resolution.""" + + mode: str + log_message: str + pixel_mode_disabled: bool + should_update_annotations: bool + should_refresh_images: bool + + +@dataclass(frozen=True) +class TreeAnnotationInfoModePlan: + """Pure outcome of toggling tree annotation info mode.""" + + enabled: bool + action_text_contains: str + action_checked: bool + label_tree_annotations_enabled: bool + gen_num_tree_annotations_enabled: bool + should_refresh_annotations: bool + + +@dataclass(frozen=True) +class ZDepthAnnotationOptionsPlan: + """Pure outcome of enabling left annotation options for z-depth axes.""" + + should_apply: bool + disabled_updates: tuple[tuple[AnnotationOption, bool], ...] = () + state: AnnotationOptionState | None = None + clicked_option: AnnotationOption | None = None + save_settings: bool = False + + +@dataclass(frozen=True) +class Visible3DSegmentationWidgetsPlan: + """Pure outcome of updating 3D-only annotation option widgets.""" + + visible_updates: tuple[tuple[AnnotationSide, AnnotationOption, bool], ...] + checked_updates: tuple[tuple[AnnotationSide, AnnotationOption, bool], ...] + + +@dataclass(frozen=True) +class ZNeighborHighlightCheckboxPlan: + """Pure outcome of updating z-neighbor highlight checkbox state.""" + + should_apply: bool + visible: bool = False + checked: bool = False + should_connect_toggle: bool = False + + +class AnnotationDisplayModel: + """Headless annotation display decisions.""" + + def right_annotation_mode( + self, + *, + show_right_image: bool, + use_right_specific_mode: bool, + right_mode: str, + left_mode: str, + ) -> str: + if not show_right_image: + return 'nothing' + return right_mode if use_right_specific_mode else left_mode + + def text_annotation_flags( + self, + *, + annot_cca_checked: bool, + annot_ids_checked: bool, + mode: str, + ) -> tuple[bool, bool]: + is_lineage_mode = mode == 'Normal division: Lineage tree' + is_cca = annot_cca_checked and not is_lineage_mode + is_id = annot_ids_checked or (annot_cca_checked and is_lineage_mode) + return is_cca, is_id + + def annotation_mode_text( + self, + *, + ids: bool = False, + cca: bool = False, + contours: bool = False, + segm_masks: bool = False, + mother_bud_lines: bool = False, + nothing: bool = False, + ) -> str: + if ids: + if contours: + return 'Draw IDs and contours' + if segm_masks: + return 'Draw IDs and overlay segm. masks' + return 'Draw only IDs' + if cca: + if contours: + return 'Draw cell cycle info and contours' + if segm_masks: + return 'Draw cell cycle info and overlay segm. masks' + return 'Draw only cell cycle info' + if segm_masks: + return 'Draw only overlay segm. masks' + if contours: + return 'Draw only contours' + if mother_bud_lines: + return 'Draw only mother-bud lines' + if nothing: + return 'Draw nothing' + return 'Draw nothing' + + def annotation_flags_from_mode_text(self, text: str) -> dict[str, bool]: + return { + 'ids': 'IDs' in text, + 'cca': 'cell cycle info' in text, + 'contours': 'contours' in text, + 'segm_masks': 'segm. masks' in text, + 'mother_bud_lines': 'mother-bud lines' in text, + 'nothing': 'nothing' in text, + } + + def annotation_option_state_from_mode_text( + self, + text: str, + *, + num_zslices: bool = False, + ) -> AnnotationOptionState: + flags = self.annotation_flags_from_mode_text(text) + return AnnotationOptionState( + ids=flags['ids'], + cca=flags['cca'], + contours=flags['contours'], + segm_masks=flags['segm_masks'], + mother_bud_lines=flags['mother_bud_lines'], + num_zslices=num_zslices, + nothing=flags['nothing'], + ) + + def annotation_options_from_mode_text_plan( + self, + *, + left_text: str, + right_text: str, + left_num_zslices: bool = False, + right_num_zslices: bool = False, + ) -> AnnotationOptionsFromModeTextPlan: + return AnnotationOptionsFromModeTextPlan( + state_updates=( + ( + 'left', + self.annotation_option_state_from_mode_text( + left_text, + num_zslices=left_num_zslices, + ), + ), + ( + 'right', + self.annotation_option_state_from_mode_text( + right_text, + num_zslices=right_num_zslices, + ), + ), + ) + ) + + def restore_saved_settings_plan( + self, + settings_values: Mapping[str, object], + ) -> AnnotationDisplaySettingsRestorePlan: + return AnnotationDisplaySettingsRestorePlan( + left_mode=str( + settings_values.get( + 'how_draw_annotations', + 'Draw IDs and contours', + ) + ), + right_mode=str( + settings_values.get( + 'how_draw_right_annotations', + 'Draw IDs and overlay segm. masks', + ) + ), + add_new_ids_whitelist_toggle=( + settings_values.get('addNewIDsWhitelistToggle', 'Yes') == 'Yes' + ), + ) + + def contours_requested( + self, + *, + ax: int, + left_contours: bool, + right_image_visible: bool, + right_specific_mode: bool, + right_contours: bool, + ) -> bool: + if ax == 0: + return left_contours + if not right_image_visible: + return False + if right_specific_mode: + return right_contours + return left_contours + + def moth_bud_lines_requested( + self, + *, + ax: int, + left_cca: bool, + left_mother_bud_lines: bool, + right_image_visible: bool, + right_specific_mode: bool, + right_cca: bool, + right_mother_bud_lines: bool, + ) -> bool: + if ax == 0: + return left_cca or left_mother_bud_lines + if not right_image_visible: + return False + if right_specific_mode: + return right_cca or right_mother_bud_lines + return left_cca or left_mother_bud_lines + + def should_draw_moth_bud_line( + self, + *, + cca_df_available: bool, + mode: str, + object_visible: bool, + cell_cycle_stage: str, + relationship: str, + ) -> bool: + return ( + cca_df_available + and mode != 'Normal division: Lineage Tree' + and object_visible + and cell_cycle_stage != 'G1' + and relationship == 'bud' + ) + + def should_draw_lineage_tree_lines( + self, + *, + lineage_tree_available: bool, + frames_count: int, + ) -> bool: + return lineage_tree_available and frames_count >= 2 + + def annotation_mode_setting_update( + self, + side: AnnotationSide, + how: str, + ) -> tuple[str, str]: + setting = ( + 'how_draw_right_annotations' + if side == 'right' + else 'how_draw_annotations' + ) + return setting, how + + def annotation_mode_change_plan( + self, + *, + side: AnnotationSide, + how: str, + save_settings: bool, + annot_cca_checked: bool, + annot_ids_checked: bool, + mode: str, + is_data_loading: bool, + eraser_checked: bool = False, + ) -> AnnotationModeChangePlan: + setting_update = None + if save_settings: + setting_update = self.annotation_mode_setting_update(side, how) + + is_cca, is_id = self.text_annotation_flags( + annot_cca_checked=annot_cca_checked, + annot_ids_checked=annot_ids_checked, + mode=mode, + ) + return AnnotationModeChangePlan( + side=side, + setting_update=setting_update, + text_annotation_index=1 if side == 'right' else 0, + is_cca_annotation=is_cca, + is_id_annotation=is_id, + should_refresh_images=not is_data_loading, + should_reset_eraser_temp=side == 'left' and eraser_checked, + ) + + def annotation_option_change_plan( + self, + *, + side: AnnotationSide, + state: AnnotationOptionState, + clicked_option: AnnotationOption | None, + save_settings: bool, + ) -> AnnotationOptionChangePlan: + values = { + 'ids': state.ids, + 'cca': state.cca, + 'contours': state.contours, + 'segm_masks': state.segm_masks, + 'mother_bud_lines': state.mother_bud_lines, + 'num_zslices': state.num_zslices, + 'nothing': state.nothing, + } + + if values['ids'] and clicked_option == 'ids': + values['cca'] = False + values['mother_bud_lines'] = False + + if values['cca'] and clicked_option == 'cca': + values['ids'] = False + values['mother_bud_lines'] = False + + if ( + values['mother_bud_lines'] + and clicked_option == 'mother_bud_lines' + ): + values['ids'] = False + values['cca'] = False + + if values['contours'] and clicked_option == 'contours': + values['segm_masks'] = False + + if values['segm_masks'] and clicked_option == 'segm_masks': + values['contours'] = False + + if clicked_option == 'nothing': + values['ids'] = False + values['cca'] = False + values['contours'] = False + values['segm_masks'] = False + values['mother_bud_lines'] = False + values['num_zslices'] = False + else: + values['nothing'] = False + + if clicked_option == 'num_zslices': + values['ids'] = True + values['nothing'] = False + + new_state = AnnotationOptionState(**values) + return AnnotationOptionChangePlan( + side=side, + state=new_state, + mode_text=self.annotation_mode_text( + ids=new_state.ids, + cca=new_state.cca, + contours=new_state.contours, + segm_masks=new_state.segm_masks, + mother_bud_lines=new_state.mother_bud_lines, + nothing=new_state.nothing, + ), + save_settings=save_settings, + ) + + def pixel_mode_setting_value(self, checked: bool) -> int: + return int(checked) + + def pixel_mode_change_plan( + self, + *, + checked: bool, + is_data_loaded: bool, + high_resolution: bool, + ) -> PixelModeChangePlan: + return PixelModeChangePlan( + setting_update=('pxMode', self.pixel_mode_setting_value(checked)), + should_update_text_pixel_mode=is_data_loaded and high_resolution, + should_refresh_images=is_data_loaded, + ) + + def text_resolution_change_plan( + self, + *, + high_resolution: bool, + is_data_loaded: bool, + ) -> TextResolutionChangePlan: + mode = 'high' if high_resolution else 'low' + return TextResolutionChangePlan( + mode=mode, + log_message=f'Switching to {mode} for the text annnotations...', + pixel_mode_disabled=not high_resolution, + should_update_annotations=is_data_loaded, + should_refresh_images=is_data_loaded, + ) + + def tree_annotation_info_mode_plan( + self, + checked: bool, + ) -> TreeAnnotationInfoModePlan: + return TreeAnnotationInfoModePlan( + enabled=checked, + action_text_contains='tree', + action_checked=checked, + label_tree_annotations_enabled=checked, + gen_num_tree_annotations_enabled=checked, + should_refresh_annotations=True, + ) + + def z_depth_annotation_options_plan( + self, + *, + is_3d: bool, + state: AnnotationOptionState, + ) -> ZDepthAnnotationOptionsPlan: + if not is_3d: + return ZDepthAnnotationOptionsPlan(should_apply=False) + + return ZDepthAnnotationOptionsPlan( + should_apply=True, + disabled_updates=(('ids', False), ('contours', False)), + state=AnnotationOptionState( + ids=True, + cca=state.cca, + contours=True, + segm_masks=state.segm_masks, + mother_bud_lines=state.mother_bud_lines, + num_zslices=state.num_zslices, + nothing=state.nothing, + ), + clicked_option='ids', + save_settings=False, + ) + + def visible_3d_segmentation_widgets_plan( + self, + *, + is_3d: bool, + ) -> Visible3DSegmentationWidgetsPlan: + visible_updates = ( + ('left', 'num_zslices', is_3d), + ('right', 'num_zslices', is_3d), + ) + checked_updates = () + if not is_3d: + checked_updates = ( + ('left', 'num_zslices', False), + ('right', 'num_zslices', False), + ) + return Visible3DSegmentationWidgetsPlan( + visible_updates=visible_updates, + checked_updates=checked_updates, + ) + + def z_neighbor_highlight_checkbox_plan( + self, + *, + is_3d: bool, + ) -> ZNeighborHighlightCheckboxPlan: + if not is_3d: + return ZNeighborHighlightCheckboxPlan(should_apply=False) + return ZNeighborHighlightCheckboxPlan( + should_apply=True, + visible=True, + checked=True, + should_connect_toggle=True, + ) diff --git a/cellacdc/models/app_shell_model.py b/cellacdc/models/app_shell_model.py new file mode 100644 index 000000000..c842b3cf4 --- /dev/null +++ b/cellacdc/models/app_shell_model.py @@ -0,0 +1,38 @@ +"""Scriptable model services for the application shell.""" + +from __future__ import annotations + +from cellacdc import myutils + + +def get_tooltips_from_docs(): + from cellacdc.load.selection_omexml import get_tooltips_from_docs as func + + return func() + + +def rename_qrc_resources_file(color_scheme: str): + from cellacdc.load.selection_omexml import ( + rename_qrc_resources_file as func, + ) + + return func(color_scheme) + + +class AppShellModel: + """Headless application shell service wrappers.""" + + def read_version(self) -> str: + return myutils.read_version() + + def tooltips_from_docs(self) -> dict: + return get_tooltips_from_docs() + + def browse_docs(self): + return myutils.browse_docs() + + def show_in_file_manager(self, path: str): + return myutils.showInExplorer(path) + + def rename_qrc_resources_file(self, color_scheme: str): + return rename_qrc_resources_file(color_scheme) diff --git a/cellacdc/models/brush_tools_model.py b/cellacdc/models/brush_tools_model.py new file mode 100644 index 000000000..b9f1ccf95 --- /dev/null +++ b/cellacdc/models/brush_tools_model.py @@ -0,0 +1,123 @@ +"""Scriptable model rules for brush and eraser tools.""" + +from __future__ import annotations + +from typing import Any + +import skimage.morphology + + +class BrushToolsModel: + """Headless decisions and geometry for brush/eraser tools.""" + + yes_value = 'Yes' + no_value = 'No' + + def checked_setting_value(self, checked: bool) -> str: + return self.yes_value if checked else self.no_value + + def default_delete_object_info_value(self) -> str: + return self.yes_value + + def should_show_delete_object_info(self, setting_value: Any) -> bool: + return setting_value == self.yes_value + + def delete_object_info_value( + self, + do_not_show_again_checked: bool, + ) -> str: + return ( + self.no_value + if do_not_show_again_checked + else self.yes_value + ) + + def should_fill_holes( + self, + sender: str, + *, + auto_fill_checked: bool, + ) -> bool: + return sender == 'brush' and auto_fill_checked + + def brush_toolbar_visible( + self, + edit_id_visible: bool, + *, + brush_size_visible: bool, + auto_fill_visible: bool, + auto_hide_visible: bool, + ) -> bool: + return any( + ( + edit_id_visible, + brush_size_visible, + auto_fill_visible, + auto_hide_visible, + ) + ) + + def disk_mask(self, brush_size: int): + return skimage.morphology.disk(brush_size, dtype=bool) + + def disk_mask_bounds( + self, + image_shape: tuple[int, int], + brush_size: int, + xdata: int, + ydata: int, + disk_mask, + ): + y_size, x_size = image_shape + y_bottom, x_left = ydata - brush_size, xdata - brush_size + y_top, x_right = ydata + brush_size + 1, xdata + brush_size + 1 + + if x_left < 0: + if y_bottom < 0: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[-y_bottom:, -x_left:] + y_bottom = 0 + elif y_top > y_size: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[0:y_size - y_bottom, -x_left:] + y_top = y_size + else: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[:, -x_left:] + x_left = 0 + + elif x_right > x_size: + if y_bottom < 0: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[-y_bottom:, 0:x_size - x_left] + y_bottom = 0 + elif y_top > y_size: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[0:y_size - y_bottom, 0:x_size - x_left] + y_top = y_size + else: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[:, 0:x_size - x_left] + x_right = x_size + + elif y_bottom < 0: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[-y_bottom:] + y_bottom = 0 + + elif y_top > y_size: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[0:y_size - y_bottom] + y_top = y_size + + return y_bottom, x_left, y_top, x_right, disk_mask + + def magic_wand_flood_tolerance( + self, + tolerance_percent: float, + image_min: float, + image_max: float, + ): + if tolerance_percent == 0: + return None + return (image_max - image_min) * (tolerance_percent / 100) diff --git a/cellacdc/models/canvas_context_menu_model.py b/cellacdc/models/canvas_context_menu_model.py new file mode 100644 index 000000000..3998945e1 --- /dev/null +++ b/cellacdc/models/canvas_context_menu_model.py @@ -0,0 +1,52 @@ +"""Scriptable model rules for canvas context menus.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DeletedRoiClickDecision: + """Decision for clicks on deleted-ROI overlays.""" + + handled: bool + show_context_menu: bool = False + drag_roi: bool = False + + +class CanvasContextMenuModel: + """Headless canvas context-menu decision rules.""" + + scale_bar_target = 'scale_bar' + timestamp_target = 'timestamp' + gradient_target = 'gradient' + + def image_gradient_menu_target( + self, + *, + scale_bar_highlighted: bool, + timestamp_highlighted: bool, + ) -> str: + if scale_bar_highlighted: + return self.scale_bar_target + if timestamp_highlighted: + return self.timestamp_target + return self.gradient_target + + def deleted_roi_click_decision( + self, + *, + clicked_on_roi: bool, + left_click: bool, + right_click: bool, + ) -> DeletedRoiClickDecision: + if not clicked_on_roi: + return DeletedRoiClickDecision(handled=False) + if right_click: + return DeletedRoiClickDecision( + handled=True, + show_context_menu=True, + ) + if left_click: + return DeletedRoiClickDecision(handled=True, drag_roi=True) + return DeletedRoiClickDecision(handled=False) diff --git a/cellacdc/models/canvas_drawing_model.py b/cellacdc/models/canvas_drawing_model.py new file mode 100644 index 000000000..772dd604e --- /dev/null +++ b/cellacdc/models/canvas_drawing_model.py @@ -0,0 +1,42 @@ +"""Scriptable model rules for canvas drawing interactions.""" + +from __future__ import annotations + +import numpy as np + + +class CanvasDrawingModel: + """Headless decisions for canvas drawing workflows.""" + + viewer_mode = 'Viewer' + + def should_process_canvas_event( + self, + *, + mode: str, + in_bounds: bool, + ) -> bool: + return mode != self.viewer_mode and in_bounds + + def should_clear_after_out_of_bounds(self, *, image: str) -> bool: + return image == 'img1' + + def calculate_brush_mask( + self, + image_shape: tuple[int, int], + ymin: int, + xmin: int, + ymax: int, + xmax: int, + disk_mask: np.ndarray, + rr_poly: np.ndarray | None = None, + cc_poly: np.ndarray | None = None, + ) -> np.ndarray: + """Computes a 2D boolean mask for brush/eraser updates.""" + mask = np.zeros(image_shape, dtype=bool) + disk_slice = (slice(ymin, ymax), slice(xmin, xmax)) + mask[disk_slice][disk_mask] = True + if rr_poly is not None and cc_poly is not None: + mask[rr_poly, cc_poly] = True + return mask + diff --git a/cellacdc/models/canvas_events_model.py b/cellacdc/models/canvas_events_model.py new file mode 100644 index 000000000..aa93d0546 --- /dev/null +++ b/cellacdc/models/canvas_events_model.py @@ -0,0 +1,41 @@ +"""Qt-free model rules for canvas event routing.""" + +from __future__ import annotations + +import numpy as np + + +class CanvasEventsModel: + """Headless canvas event routing rules and brush mask computations.""" + + def calculate_brush_mask( + self, + image_shape: tuple[int, int], + ymin: int, + xmin: int, + ymax: int, + xmax: int, + disk_mask: np.ndarray, + rr_poly: np.ndarray | None = None, + cc_poly: np.ndarray | None = None, + ) -> np.ndarray: + """Computes a 2D boolean mask for brush/eraser updates.""" + mask = np.zeros(image_shape, dtype=bool) + disk_slice = (slice(ymin, ymax), slice(xmin, xmax)) + mask[disk_slice][disk_mask] = True + if rr_poly is not None and cc_poly is not None: + mask[rr_poly, cc_poly] = True + return mask + + def map_mouse_coordinates_to_label_id( + self, + mouse_pos: tuple[float, float], + label_matrix: np.ndarray, + ) -> int: + """Resolves float pixel coordinate lookup to integer label ID.""" + x, y = mouse_pos + xdata, ydata = int(x), int(y) + height, width = label_matrix.shape + if 0 <= xdata < width and 0 <= ydata < height: + return int(label_matrix[ydata, xdata]) + return 0 diff --git a/cellacdc/models/canvas_hover_model.py b/cellacdc/models/canvas_hover_model.py new file mode 100644 index 000000000..01558ddbb --- /dev/null +++ b/cellacdc/models/canvas_hover_model.py @@ -0,0 +1,121 @@ +"""Scriptable model rules for canvas hover interactions.""" + +from __future__ import annotations + +from typing import Any + + +class CanvasHoverModel: + """Headless decisions for hover and cursor state.""" + + def point_in_bounds( + self, + image_shape: tuple[int, int], + xdata: int, + ydata: int, + ) -> bool: + y_size, x_size = image_shape + return 0 <= xdata < x_size and 0 <= ydata < y_size + + def hover_position(self, is_exit: bool, position) -> tuple[Any, Any]: + if is_exit: + return None, None + return position + + def should_set_mirrored_cursor( + self, + *, + override_cursor_is_none: bool, + is_exit: bool, + mirrored_cursor_enabled: bool, + is_hover_img1: bool = True, + ) -> bool: + return ( + override_cursor_is_none + and not is_exit + and is_hover_img1 + and mirrored_cursor_enabled + ) + + def should_draw_ruler_line( + self, + *, + ruler_checked: bool, + add_deleted_polyline_checked: bool, + temp_segment_on: bool, + is_exit: bool, + ) -> bool: + return ( + (ruler_checked or add_deleted_polyline_checked) + and temp_segment_on + and not is_exit + ) + + def cursor_flags( + self, + *, + is_exit: bool, + no_modifier: bool, + shift: bool, + ctrl: bool, + alt: bool, + brush_checked: bool, + eraser_checked: bool, + add_deleted_polyline_checked: bool, + label_roi_checked: bool, + label_roi_circular_checked: bool, + wand_checked: bool, + move_label_checked: bool, + expand_label_checked: bool, + curvature_checked: bool, + keep_ids_checked: bool, + custom_annotation_available: bool, + manual_tracking_checked: bool, + manual_background_checked: bool, + zoom_rect_checked: bool, + edit_id_checked: bool, + magic_prompts_checked: bool, + points_layer_checked: bool, + add_points_by_clicking_active: bool, + ) -> dict[str, bool]: + return { + 'setBrushCursor': ( + brush_checked and not is_exit and (no_modifier or shift or ctrl) + ), + 'setEraserCursor': eraser_checked and not is_exit and no_modifier, + 'setAddDelPolyLineCursor': ( + add_deleted_polyline_checked and not is_exit and no_modifier + ), + 'setLabelRoiCircCursor': ( + label_roi_checked + and not is_exit + and (no_modifier or shift or ctrl) + and label_roi_circular_checked + ), + 'setWandCursor': wand_checked and not is_exit and no_modifier, + 'setLabelRoiCursor': label_roi_checked and not is_exit and no_modifier, + 'setMoveLabelCursor': move_label_checked and not is_exit and no_modifier, + 'setExpandLabelCursor': ( + expand_label_checked and not is_exit and no_modifier + ), + 'setCurvCursor': curvature_checked and not is_exit and no_modifier, + 'setKeepObjCursor': keep_ids_checked and not is_exit and no_modifier, + 'setCustomAnnotCursor': ( + custom_annotation_available and not is_exit and no_modifier + ), + 'setManualTrackingCursor': ( + manual_tracking_checked and not is_exit and no_modifier + ), + 'setManualBackgroundCursor': ( + manual_background_checked and not is_exit and no_modifier + ), + 'setAddPointCursor': ( + (points_layer_checked or magic_prompts_checked) + and add_points_by_clicking_active + and not is_exit + and no_modifier + ), + 'setZoomRectCursor': zoom_rect_checked and not is_exit and no_modifier, + 'setEditIDCursor': edit_id_checked and not is_exit, + 'setPanImageCursor': alt and not is_exit, + } diff --git a/cellacdc/models/canvas_right_image_model.py b/cellacdc/models/canvas_right_image_model.py new file mode 100644 index 000000000..aa9fc9bbc --- /dev/null +++ b/cellacdc/models/canvas_right_image_model.py @@ -0,0 +1,15 @@ +"""Scriptable model rules for duplicated right-image interactions.""" + +from __future__ import annotations + + +class CanvasRightImageModel: + """Headless duplicated right-image event rules.""" + + def should_show_context_menu( + self, + *, + right_click: bool, + is_right_click_action_on: bool, + ) -> bool: + return right_click and not is_right_click_action_on diff --git a/cellacdc/models/canvas_selection_model.py b/cellacdc/models/canvas_selection_model.py new file mode 100644 index 000000000..6750d2300 --- /dev/null +++ b/cellacdc/models/canvas_selection_model.py @@ -0,0 +1,69 @@ +"""Qt-free model rules for canvas selection interactions.""" + +from __future__ import annotations + + +class CanvasSelectionModel: + """Headless decisions for canvas selection workflows.""" + + viewer_mode = 'Viewer' + segmentation_mode = 'Segmentation and Tracking' + + def should_drag_image( + self, + *, + left_click: bool, + eraser_on: bool, + brush_on: bool, + middle_click: bool, + pan_click: bool, + ) -> bool: + return pan_click or ( + left_click and not eraser_on and not brush_on and not middle_click + ) + + def should_blink_viewer_mode( + self, + *, + mode: str, + middle_click: bool, + right_action_on: bool = False, + custom_action_on: bool = False, + right_click: bool = False, + ) -> bool: + if mode != self.viewer_mode: + return False + if middle_click: + return True + return (right_action_on or custom_action_on) and ( + right_click or middle_click + ) + + def should_show_labels_menu( + self, + *, + right_click: bool, + right_action_on: bool, + middle_click: bool, + event_from_img1: bool, + ) -> bool: + return ( + right_click + and not right_action_on + and not middle_click + and not event_from_img1 + ) + + def can_delete(self, *, mode: str, is_snapshot: bool) -> bool: + return mode == self.segmentation_mode or is_snapshot + + def is_viewer_mode(self, mode: str) -> bool: + return mode == self.viewer_mode + + def should_process_release( + self, + *, + mode: str, + in_bounds: bool, + ) -> bool: + return mode != self.viewer_mode and in_bounds diff --git a/cellacdc/models/canvas_tool_model.py b/cellacdc/models/canvas_tool_model.py new file mode 100644 index 000000000..51723da4c --- /dev/null +++ b/cellacdc/models/canvas_tool_model.py @@ -0,0 +1,51 @@ +"""Scriptable model rules for canvas tool interaction decisions.""" + +from __future__ import annotations + + +class CanvasToolModel: + """Headless canvas tool decision rules.""" + + manual_separate_draw_mode_key = 'manual_separate_draw_mode' + + def viewer_mode_allows_press( + self, + mode: str, + *, + can_add_point: bool = False, + can_ruler: bool = False, + ) -> bool: + return mode != 'Viewer' or can_add_point or can_ruler + + def should_forward_img1_press_to_img2( + self, + *, + right_click: bool, + middle_click: bool, + can_add_point: bool, + mode: str, + is_snapshot: bool, + is_annotate_division: bool, + manual_background_on: bool, + ) -> bool: + return ( + (right_click or (middle_click and not can_add_point)) + and (mode == 'Segmentation and Tracking' or is_snapshot) + and not is_annotate_division + and not manual_background_on + ) + + def should_forward_img1_release_to_img2( + self, + *, + right_click: bool, + mode: str, + is_snapshot: bool, + ) -> bool: + return ( + (mode == 'Segmentation and Tracking' or is_snapshot) + and right_click + ) + + def manual_separate_draw_mode_update(self, mode) -> tuple[str, object]: + return self.manual_separate_draw_mode_key, mode diff --git a/cellacdc/models/cell_cycle_model.py b/cellacdc/models/cell_cycle_model.py new file mode 100644 index 000000000..e5e9095cb --- /dev/null +++ b/cellacdc/models/cell_cycle_model.py @@ -0,0 +1,98 @@ +"""Qt-free model rules for cell-cycle GUI workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass +import pandas as pd + + + +@dataclass(frozen=True) +class AnnotatedEditWarningPlan: + """Decision for editing a frame with existing annotations.""" + + proceed_without_warning: bool + update_images: bool = False + should_prompt: bool = False + warn_type: str | None = None + + +class CellCycleModel: + """Headless cell-cycle workflow rules.""" + + def annotated_edit_warning_plan( + self, + *, + is_snapshot: bool, + acdc_df_missing: bool, + lineage_tree_missing: bool, + cell_cycle_stage_present: bool, + lineage_tree_present: bool, + remembered_skip_warning: bool, + ) -> AnnotatedEditWarningPlan: + if is_snapshot: + return AnnotatedEditWarningPlan(proceed_without_warning=True) + + no_annotation_source = acdc_df_missing and lineage_tree_missing + no_annotations = not cell_cycle_stage_present and not lineage_tree_present + if no_annotation_source or no_annotations or remembered_skip_warning: + return AnnotatedEditWarningPlan( + proceed_without_warning=True, + update_images=True, + ) + + warn_type = ( + 'cell cycle annotations' + if cell_cycle_stage_present + else 'lineage tree annotations' + ) + return AnnotatedEditWarningPlan( + proceed_without_warning=False, + should_prompt=True, + warn_type=warn_type, + ) + + def check_mothers_exclusion_or_dead( + self, + acdc_df: pd.DataFrame, + mother_ids: list[int], + ) -> list[int]: + """Checks tracking rules for cell exclusions or deaths.""" + if acdc_df is None or not mother_ids: + return [] + + valid_ids = [m_id for m_id in mother_ids if m_id in acdc_df.index] + if not valid_ids: + return [] + + mothers_df = acdc_df.loc[valid_ids] + excluded_mask = ( + (mothers_df.get('is_cell_dead', 0) > 0) + | (mothers_df.get('is_cell_excluded', 0) > 0) + ) + return mothers_df[excluded_mask].index.tolist() + + def evaluate_sister_relations( + self, + prev_cca_df: pd.DataFrame, + current_ids: set[int], + ) -> list[int]: + """Determines S-phase mother-bud dependencies and sister relation tracking rules.""" + if prev_cca_df is None or not current_ids: + return [] + + current_ids_set = set(current_ids) + disappeared_ids = [] + for cc_series in prev_cca_df.itertuples(): + if getattr(cc_series, 'cell_cycle_stage', None) != 'S': + continue + + cell_id = cc_series.Index + relative_id = getattr(cc_series, 'relative_ID', -1) + if relative_id == -1: + continue + if relative_id not in current_ids_set and cell_id in current_ids_set: + disappeared_ids.append(relative_id) + + return disappeared_ids + diff --git a/cellacdc/models/combine_model.py b/cellacdc/models/combine_model.py new file mode 100644 index 000000000..2bde98927 --- /dev/null +++ b/cellacdc/models/combine_model.py @@ -0,0 +1,58 @@ +"""Headless rules and helpers for the Combine Channels feature.""" + +from __future__ import annotations + +from dataclasses import dataclass +import numpy as np + + +@dataclass(frozen=True) +class CombineModel: + """Headless state and helpers for combining channel and image arrays.""" + + def initialize_combine_image_data(self, pos_data) -> np.ndarray: + """Initializes pos_data.combine_img_data if not already present.""" + if not hasattr(pos_data, 'combine_img_data'): + from cellacdc import preprocess + pos_data.combine_img_data = preprocess.PreprocessedData( + image_data=np.zeros(pos_data.img_data.shape) + ) + return pos_data.combine_img_data + + def validate_dimensions(self, ndim: int) -> bool: + """Asserts that image data dimensions are valid for combining (3D or 4D).""" + if ndim not in (3, 4): + raise ValueError('Invalid number of dimensions in img_data.') + return True + + def group_processed_data_by_pos( + self, + processed_data: list[np.ndarray], + keys: list[tuple[int, int, int]] + ) -> dict[int, list[tuple[tuple[int, int, int], np.ndarray]]]: + """Groups raw processed preview output arrays by position index.""" + unique_pos = {key[0] for key in keys} + per_pos_data = {pos_i: [] for pos_i in unique_pos} + for key, img in zip(keys, processed_data): + pos_i, frame_i, z_slice = key + per_pos_data[pos_i].append((key, img)) + return per_pos_data + + def update_combine_image_data( + self, + pos_data, + pos_i_data: list[tuple[tuple[int, int, int], np.ndarray]] + ): + """Updates preprocessed combined image data container frames and z-slices.""" + n_dim_img = pos_data.img_data.ndim + self.initialize_combine_image_data(pos_data) + self.validate_dimensions(n_dim_img) + + if n_dim_img == 4: + for key, img in pos_i_data: + _, frame_i, z_slice = key + pos_data.combine_img_data[frame_i][z_slice] = img + elif n_dim_img == 3: + for key, img in pos_i_data: + _, frame_i, _ = key + pos_data.combine_img_data[frame_i] = img diff --git a/cellacdc/models/curvature_model.py b/cellacdc/models/curvature_model.py new file mode 100644 index 000000000..6597ebd4d --- /dev/null +++ b/cellacdc/models/curvature_model.py @@ -0,0 +1,97 @@ +"""Scriptable model rules for curvature and spline editing tools.""" + +from __future__ import annotations + +import numpy as np + +from cellacdc.domain.curvature import ( + CurvatureLabelPaintResult, + closed_spline_coords, + directional_coords, + paint_spline_to_labels, + spline_coords, + tangent_brush_polygon, +) + + +class CurvatureModel: + """Headless spline drawing and label-painting operations.""" + + def tangent_brush_polygon( + self, + yx_start, + yx_end, + radius: int | float, + shape: tuple[int, int], + ) -> tuple[np.ndarray, np.ndarray]: + return tangent_brush_polygon(yx_start, yx_end, radius, shape) + + def directional_coords( + self, + alfa_dir: int, + y: int, + x: int, + shape: tuple[int, int], + *, + connectivity: int = 1, + ) -> tuple[list[int], list[int]]: + return directional_coords( + alfa_dir, + y, + x, + shape, + connectivity=connectivity, + ) + + def spline_coords( + self, + xx, + yy, + *, + resolution_space=None, + per: bool = False, + append_first: bool = False, + ): + return spline_coords( + xx, + yy, + resolution_space=resolution_space, + per=per, + append_first=append_first, + ) + + def closed_spline_coords( + self, + xx_spline, + yy_spline, + *, + anchor_xx=None, + anchor_yy=None, + predictor=None, + max_exec_time: int = 150, + ): + return closed_spline_coords( + xx_spline, + yy_spline, + anchor_xx=anchor_xx, + anchor_yy=anchor_yy, + predictor=predictor, + max_exec_time=max_exec_time, + ) + + def paint_spline_to_labels( + self, + labels_2d: np.ndarray, + xx_spline, + yy_spline, + label_id: int, + *, + empty_only: bool = True, + ) -> CurvatureLabelPaintResult: + return paint_spline_to_labels( + labels_2d, + xx_spline, + yy_spline, + label_id, + empty_only=empty_only, + ) diff --git a/cellacdc/models/custom_annotations_model.py b/cellacdc/models/custom_annotations_model.py new file mode 100644 index 000000000..4baf157b5 --- /dev/null +++ b/cellacdc/models/custom_annotations_model.py @@ -0,0 +1,97 @@ +"""Scriptable model rules for custom annotations.""" + +from __future__ import annotations + +import os + +import pandas as pd + +from cellacdc import load, myutils +from cellacdc.domain.custom_annotations import ( + CustomAnnotationColumnResult, + CustomAnnotationFrameUpdate, + custom_annotation_column_exists, + drop_custom_annotation_column, + ensure_custom_annotation_column, + remap_custom_annotation_ids, + rename_custom_annotation_column, + update_custom_annotation_frame, +) + + +class CustomAnnotationsModel: + """Headless custom annotation table updates.""" + + def read_saved_annotations( + self, + annotations_path: str, + *, + logger_func=None, + ) -> dict: + if not os.path.exists(annotations_path): + return {} + return load.read_json(annotations_path, logger_func=logger_func) + + def tooltip(self, annotation_state: dict) -> str: + return myutils.getCustomAnnotTooltip(annotation_state) + + def ensure_column( + self, + acdc_df: pd.DataFrame, + annotation_name: str, + ) -> CustomAnnotationColumnResult: + return ensure_custom_annotation_column(acdc_df, annotation_name) + + def column_exists( + self, + frame_records, + annotation_name: str, + *, + summary_acdc_df: pd.DataFrame | None = None, + ) -> bool: + return custom_annotation_column_exists( + frame_records, + annotation_name, + summary_acdc_df=summary_acdc_df, + ) + + def drop_column( + self, + acdc_df: pd.DataFrame | None, + annotation_name: str, + ) -> pd.DataFrame | None: + return drop_custom_annotation_column(acdc_df, annotation_name) + + def rename_column( + self, + acdc_df: pd.DataFrame | None, + old_name: str, + new_name: str, + ) -> pd.DataFrame | None: + return rename_custom_annotation_column(acdc_df, old_name, new_name) + + def remap_ids(self, annotated_ids_by_frame, old_ids, new_ids) -> dict: + return remap_custom_annotation_ids( + annotated_ids_by_frame, + old_ids, + new_ids, + ) + + def update_frame( + self, + acdc_df: pd.DataFrame, + annotation_name: str, + annotated_ids, + *, + clicked_id: int = 0, + click_is_active: bool = False, + existing_ids=None, + ) -> CustomAnnotationFrameUpdate: + return update_custom_annotation_frame( + acdc_df, + annotation_name, + annotated_ids, + clicked_id=clicked_id, + click_is_active=click_is_active, + existing_ids=existing_ids, + ) diff --git a/cellacdc/models/data_loading_model.py b/cellacdc/models/data_loading_model.py new file mode 100644 index 000000000..ca503ef68 --- /dev/null +++ b/cellacdc/models/data_loading_model.py @@ -0,0 +1,265 @@ +"""Qt-free model rules for data loading workflows.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from datetime import datetime + +import cv2 +import numpy as np +import pandas as pd +import skimage +import skimage.color + + +@dataclass(frozen=True) +class ChannelNameSuggestion: + """Default basename/channel split for a user-selected image filename.""" + + basename: str + channel_name: str + + +@dataclass(frozen=True) +class OpenImageFileContext: + """Path context for opening a single image/video file.""" + + file_path: str + filename_no_ext: str + extension: str + source_dirpath: str + source_dirname: str + exp_path: str + acdc_folder: str | None + requires_images_folder: bool + + +@dataclass(frozen=True) +class OpenImageFileTarget: + """Destination paths and metadata names for an opened image/video file.""" + + context: OpenImageFileContext + filename_no_ext: str + channel_name: str | None + basename: str | None + new_filename: str + new_filepath: str + metadata_csv_filename: str | None + metadata_csv_filepath: str | None + tif_filename: str + tif_path: str + direct_copy_supported: bool + + @property + def has_metadata(self) -> bool: + return self.basename is not None + + +@dataclass(frozen=True) +class EmptyDataPlan: + """Path and filename plan for creating an empty dataset.""" + + exp_path: str + pos_path: str + images_path: str + basename: str + tif_filename: str + tif_filepath: str + metadata_filename: str + metadata_filepath: str + + +@dataclass(frozen=True) +class ImageDataPreparation: + """Prepared image data and conversion facts for TIFF writing.""" + + image: np.ndarray + converted_rgb_to_gray: bool + converted_dtype: bool + + +class DataLoadingModel: + """Headless data-loading rules and path plans.""" + + def open_image_file_context( + self, file_path: str, timestamp: str | None = None + ) -> OpenImageFileContext: + filename_no_ext, ext = os.path.splitext(os.path.basename(file_path)) + filename_no_ext = filename_no_ext.rstrip('_') + ext = ext.lower() + dirpath = os.path.dirname(file_path) + dirname = os.path.basename(dirpath) + requires_images_folder = dirname != 'Images' + acdc_folder = None + + if requires_images_folder: + timestamp = timestamp or datetime.now().strftime('%Y%m%d_%H%M%S') + acdc_folder = f'{timestamp}_acdc' + exp_path = os.path.join(dirpath, acdc_folder, 'Images') + else: + exp_path = dirpath + + return OpenImageFileContext( + file_path=file_path, + filename_no_ext=filename_no_ext, + extension=ext, + source_dirpath=dirpath, + source_dirname=dirname, + exp_path=exp_path, + acdc_folder=acdc_folder, + requires_images_folder=requires_images_folder, + ) + + def channel_name_suggestion( + self, filename_no_ext: str + ) -> ChannelNameSuggestion: + underscore_splits = filename_no_ext.split('_') + if len(underscore_splits) > 1: + return ChannelNameSuggestion( + basename='_'.join(underscore_splits[:-1]), + channel_name=underscore_splits[-1], + ) + + return ChannelNameSuggestion( + basename=filename_no_ext, + channel_name='channel_1', + ) + + def open_image_file_target( + self, + context: OpenImageFileContext, + channel_name: str | None = None, + ) -> OpenImageFileTarget: + filename_no_ext = context.filename_no_ext + basename = None + metadata_csv_filename = None + metadata_csv_filepath = None + + if channel_name is not None: + underscore_splits = filename_no_ext.split('_') + if len(underscore_splits) > 1: + default_ch_name = underscore_splits[-1] + if channel_name == default_ch_name: + filename_no_ext = '_'.join(underscore_splits[:-1]) + + basename = f'{filename_no_ext}_' + metadata_csv_filename = f'{basename}metadata.csv' + metadata_csv_filepath = os.path.join( + context.exp_path, metadata_csv_filename + ) + new_filename = ( + f'{filename_no_ext}_{channel_name}{context.extension}' + ) + else: + new_filename = f'{filename_no_ext}{context.extension}' + + new_filepath = os.path.join(context.exp_path, new_filename) + tif_filename_no_ext = os.path.splitext(new_filename)[0] + tif_filename = f'{tif_filename_no_ext}.tif' + tif_path = os.path.join(context.exp_path, tif_filename) + + return OpenImageFileTarget( + context=context, + filename_no_ext=filename_no_ext, + channel_name=channel_name, + basename=basename, + new_filename=new_filename, + new_filepath=new_filepath, + metadata_csv_filename=metadata_csv_filename, + metadata_csv_filepath=metadata_csv_filepath, + tif_filename=tif_filename, + tif_path=tif_path, + direct_copy_supported=context.extension in ('.tif', '.npz'), + ) + + def empty_data_plan(self, exp_path: str) -> EmptyDataPlan: + pos_path = os.path.join(exp_path, 'Position_1') + images_path = os.path.join(pos_path, 'Images') + basename = 'test_empty_' + tif_filename = f'{basename}channel_1.tif' + metadata_filename = f'{basename}metadata.csv' + + return EmptyDataPlan( + exp_path=exp_path, + pos_path=pos_path, + images_path=images_path, + basename=basename, + tif_filename=tif_filename, + tif_filepath=os.path.join(images_path, tif_filename), + metadata_filename=metadata_filename, + metadata_filepath=os.path.join(images_path, metadata_filename), + ) + + def copy_action_text(self, do_copy: bool) -> str: + return 'Copying' if do_copy else 'Moving' + + def is_imagej_dtype(self, dtype: np.dtype) -> bool: + return dtype in (np.uint8, np.uint32, np.float32) + + def prepare_tiff_image_data(self, image: np.ndarray) -> ImageDataPreparation: + converted_rgb_to_gray = False + converted_dtype = False + prepared_image = image + + if ( + prepared_image.ndim == 3 + and (prepared_image.shape[-1] == 3 + or prepared_image.shape[-1] == 4) + ): + converted_rgb_to_gray = True + if prepared_image.shape[-1] == 3: + prepared_image = skimage.color.rgb2gray(prepared_image) + else: + prepared_image = cv2.cvtColor( + prepared_image, cv2.COLOR_RGBA2GRAY + ) + prepared_image = skimage.img_as_ubyte(prepared_image) + + if not self.is_imagej_dtype(prepared_image.dtype): + converted_dtype = True + prepared_image = skimage.img_as_ubyte(prepared_image) + + return ImageDataPreparation( + image=prepared_image, + converted_rgb_to_gray=converted_rgb_to_gray, + converted_dtype=converted_dtype, + ) + + def merge_default_segm_info( + self, + existing_df: pd.DataFrame, + default_df: pd.DataFrame, + ) -> pd.DataFrame: + merged_df = pd.concat([default_df, existing_df]) + unique_idx = ~merged_df.index.duplicated() + return merged_df[unique_idx] + + def copy_single_zslice_segm_info( + self, + existing_df: pd.DataFrame, + default_dst_df: pd.DataFrame, + *, + src_filename: str, + dst_filename: str, + ) -> pd.DataFrame: + dst_df = default_dst_df.copy() + src_df = existing_df.loc[src_filename].copy() + + for z_info in src_df.itertuples(): + frame_i = z_info.Index + if z_info.which_z_proj != 'single z-slice': + continue + + src_idx = (src_filename, frame_i) + if existing_df.at[src_idx, 'resegmented_in_gui']: + col = 'z_slice_used_gui' + else: + col = 'z_slice_used_dataPrep' + + z_slice = existing_df.at[src_idx, col] + dst_idx = (dst_filename, frame_i) + dst_df.at[dst_idx, 'z_slice_used_dataPrep'] = z_slice + dst_df.at[dst_idx, 'z_slice_used_gui'] = z_slice + + return self.merge_default_segm_info(existing_df, dst_df) diff --git a/cellacdc/models/deleted_rois_model.py b/cellacdc/models/deleted_rois_model.py new file mode 100644 index 000000000..6efa147d7 --- /dev/null +++ b/cellacdc/models/deleted_rois_model.py @@ -0,0 +1,40 @@ +"""Scriptable model rules for deleted ROI workflows.""" + +from __future__ import annotations + +from collections.abc import Iterable + + +class DeletedRoisModel: + """Headless decisions for deleted-ROI display and propagation.""" + + def roi_axis( + self, + *, + is_polyline: bool, + labels_image_visible: bool, + ) -> str: + if is_polyline or not labels_image_visible: + return 'left' + return 'right' + + def should_render_deleted_roi(self, annotation_mode: str) -> bool: + return 'nothing' not in annotation_mode + + def should_render_deleted_roi_contours(self, annotation_mode: str) -> bool: + return 'contours' in annotation_mode + + def should_render_deleted_roi_overlay(self, annotation_mode: str) -> bool: + return 'overlay segm. masks' in annotation_mode + + def should_initialize_overlay_masks( + self, + init: bool, + annotation_mode: str, + ) -> bool: + return init and not self.should_render_deleted_roi_contours( + annotation_mode + ) + + def labels_to_skip(self, deleted_ids: Iterable[int]) -> dict[int, bool]: + return {deleted_id: True for deleted_id in deleted_ids} diff --git a/cellacdc/models/display_decorations_model.py b/cellacdc/models/display_decorations_model.py new file mode 100644 index 000000000..8ad40e195 --- /dev/null +++ b/cellacdc/models/display_decorations_model.py @@ -0,0 +1,47 @@ +"""Scriptable model rules for display decorations.""" + +from __future__ import annotations + + +class DisplayDecorationsModel: + """Headless display-decoration decision rules.""" + + def clamped_view_range(self, image_shape, view_range): + y_size, x_size = image_shape[:2] + x_range, y_range = view_range + x_min = 0 if x_range[0] < 0 else x_range[0] + y_min = 0 if y_range[0] < 0 else y_range[0] + x_max = x_size if x_range[1] >= x_size else x_range[1] + y_max = y_size if y_range[1] >= y_size else y_range[1] + return int(y_min), int(y_max), int(x_min), int(x_max) + + def integer_view_range(self, view_range): + x_range, y_range = view_range + return ( + [round(x_range[0]), round(x_range[1])], + [round(y_range[0]), round(y_range[1])], + ) + + def should_move_decoration( + self, + *, + dialog_open: bool, + move_with_zoom: bool, + ) -> bool: + return dialog_open or move_with_zoom + + def should_store_view_range( + self, + *, + has_range_reset_state: bool, + is_range_reset: bool = False, + ) -> bool: + return has_range_reset_state and is_range_reset + + def should_update_timestamp_frame( + self, + *, + has_timestamp: bool, + timestamp_enabled: bool, + ) -> bool: + return has_timestamp and timestamp_enabled diff --git a/cellacdc/models/draw_clear_region_model.py b/cellacdc/models/draw_clear_region_model.py new file mode 100644 index 000000000..c36b1f7b7 --- /dev/null +++ b/cellacdc/models/draw_clear_region_model.py @@ -0,0 +1,61 @@ +"""Scriptable model rules for draw-clear-region workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DrawClearRegionToolbarState: + """Desired z-slice toolbar state for the draw-clear tool.""" + + update_z_control: bool + z_control_enabled: bool = False + size_z: int | None = None + + +class DrawClearRegionModel: + """Headless draw-clear-region decision rules.""" + + single_z_slice_projection = 'single z-slice' + + def toolbar_state( + self, + *, + checked: bool, + is_segm_3d: bool, + size_z: int, + ) -> DrawClearRegionToolbarState: + if not is_segm_3d: + return DrawClearRegionToolbarState(update_z_control=True) + if not checked: + return DrawClearRegionToolbarState(update_z_control=False) + return DrawClearRegionToolbarState( + update_z_control=True, + z_control_enabled=True, + size_z=size_z, + ) + + def z_range_for_projection( + self, + *, + is_segm_3d: bool, + z_projection: str, + size_z: int, + single_z_range, + ): + if not is_segm_3d: + return None + if z_projection == self.single_z_slice_projection: + return single_z_range + return (0, size_z) + + def is_single_z_projection(self, z_projection: str) -> bool: + return z_projection == self.single_z_slice_projection + + def empty_selection_warning(self, *, enclosed_only: bool) -> str: + if enclosed_only: + return ( + 'None of the objects in the freehand region are fully enclosed' + ) + return 'None of the objects are touching the freehand region' diff --git a/cellacdc/models/exporting_model.py b/cellacdc/models/exporting_model.py new file mode 100644 index 000000000..93cfcebe9 --- /dev/null +++ b/cellacdc/models/exporting_model.py @@ -0,0 +1,110 @@ +"""Scriptable model rules for image and video export workflows.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from datetime import datetime + +import numpy as np +import skimage.measure +import skimage.segmentation + + +@dataclass(frozen=True) +class ExportFramePlan: + """Destination naming for one exported video frame.""" + + frame_index_text: str + png_filename: str + png_filepath: str + + +class ExportingModel: + """Headless export naming, mask, and zoom selection rules.""" + + def timestamped_export_filename(self, kind: str, *, timestamp=None): + if timestamp is None: + timestamp = datetime.now() + return f"{timestamp.strftime('%Y%m%d_%H%M%S')}_acdc_exported_{kind}" + + def export_frame_plan( + self, + *, + current_index: int, + num_digits: int, + filename: str, + pngs_folderpath: str, + ) -> ExportFramePlan: + frame_index_text = str(current_index).zfill(num_digits) + png_filename = f'{frame_index_text}_{filename}.png' + return ExportFramePlan( + frame_index_text=frame_index_text, + png_filename=png_filename, + png_filepath=os.path.join(pngs_folderpath, png_filename), + ) + + def export_mask_image_shape(self, image_shape) -> tuple[int, int, int]: + height, width = image_shape[-2:] + return height, width, 4 + + def build_export_mask_image( + self, + image_shape, + view_range, + *, + invert_bw=False, + ): + mask_image = np.zeros( + self.export_mask_image_shape(image_shape), + dtype=np.uint8, + ) + x_range, y_range = view_range + x0, x1 = map(round, x_range) + y0, y1 = map(round, y_range) + + if invert_bw: + mask_image[:, :, :3] = 255 + + if x0 > 0: + mask_image[:, :x0, 3] = 255 + if x1 < mask_image.shape[1]: + mask_image[:, x1:, 3] = 255 + if y0 > 0: + mask_image[:y0, :, 3] = 255 + if y1 < mask_image.shape[0]: + mask_image[y1:, :, 3] = 255 + + return mask_image + + def zoom_ids(self, labels_2d, view_range): + height, width = labels_2d.shape + ((xmin, xmax), (ymin, ymax)) = view_range + if xmin <= 0 and ymin <= 0 and xmax >= width and ymax >= height: + return None + + xmin = max(xmin, 0) + ymin = max(ymin, 0) + xmax = min(xmax, width) + ymax = min(ymax, height) + + zoom_slice = ( + slice(round(ymin), round(ymax)), + slice(round(xmin), round(xmax)), + ) + zoom_labels = skimage.segmentation.clear_border(labels_2d[zoom_slice]) + zoom_regionprops = skimage.measure.regionprops(zoom_labels) + return [obj.label for obj in zoom_regionprops] + + def shifted_view_range(self, previous_range, current_range, window_range): + prev_x_range, prev_y_range = previous_range + curr_x_range, curr_y_range = current_range + win_x_range, win_y_range = window_range + + delta_x = curr_x_range[0] - prev_x_range[0] + delta_y = curr_y_range[0] - prev_y_range[0] + + return ( + (win_x_range[0] + delta_x, win_x_range[1] + delta_x), + (win_y_range[0] + delta_y, win_y_range[1] + delta_y), + ) diff --git a/cellacdc/models/frame_navigation_model.py b/cellacdc/models/frame_navigation_model.py new file mode 100644 index 000000000..434bacaff --- /dev/null +++ b/cellacdc/models/frame_navigation_model.py @@ -0,0 +1,165 @@ +"""Scriptable model rules for frame and position navigation.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class NavigationLimit: + """Maximum navigation frame and optional status label.""" + + maximum: int + last_checked_frame_i: int | None = None + status_text: str | None = None + + +class FrameNavigationModel: + """Headless decisions for frame/position navigation workflows.""" + + viewer_mode = 'Viewer' + segmentation_mode = 'Segmentation and Tracking' + cell_cycle_mode = 'Cell cycle analysis' + lineage_mode = 'Normal division: Lineage tree' + + def should_show_next_frame_image( + self, + *, + size_t: int, + has_right_image_coords: bool, + action_enabled: bool, + action_checked: bool, + ) -> bool: + return ( + size_t > 1 + and has_right_image_coords + and action_enabled + and action_checked + ) + + def next_frame_index(self, *, current_frame_i: int, frames_count: int) -> int: + next_frame_i = current_frame_i + 1 + if next_frame_i >= frames_count: + return frames_count - 1 + return next_frame_i + + def navigation_position( + self, + *, + is_snapshot: bool, + position_i: int, + frame_i: int, + ) -> int: + return position_i + 1 if is_snapshot else frame_i + 1 + + def navigation_limit( + self, + *, + mode: str, + frame_i: int, + last_tracked_i: int | None, + last_cca_frame_i: int, + lineage_tree_frames, + ) -> NavigationLimit | None: + if mode == self.segmentation_mode: + if last_tracked_i is None or frame_i > last_tracked_i: + maximum = frame_i + 1 + else: + maximum = last_tracked_i + 1 + return NavigationLimit( + maximum=maximum, + last_checked_frame_i=maximum - 1, + ) + if mode == self.cell_cycle_mode: + maximum = max(frame_i, last_cca_frame_i) + 1 + return NavigationLimit( + maximum=maximum, + status_text=f'Last cc annot. frame n. = {maximum}', + ) + if mode == self.lineage_mode: + if lineage_tree_frames: + maximum = max(lineage_tree_frames) + 1 + else: + maximum = frame_i + 1 + return NavigationLimit(maximum=maximum) + return None + + def should_store_when_slider_moves(self, *, mode: str) -> bool: + return mode != self.viewer_mode + + def should_warn_lost_objects( + self, + *, + requested: bool, + action_checked: bool, + mode: str, + lost_ids, + already_accepted: bool, + ) -> bool: + if not requested: + return False + if not action_checked: + return False + if mode != self.segmentation_mode: + return False + if not lost_ids: + return False + return not already_accepted + + def blocks_future_manual_annotation( + self, + *, + manual_annotation_enabled: bool, + current_frame_i: int, + frame_to_restore, + ) -> bool: + if not manual_annotation_enabled: + return False + if frame_to_restore is None: + return False + return current_frame_i > frame_to_restore + + def should_apply_new_frame_tools( + self, + *, + mode: str, + last_tracked_i: int, + frame_i: int, + last_frame_ran: int, + ) -> bool: + return ( + mode == self.segmentation_mode + and last_tracked_i is not None + and last_tracked_i <= frame_i + and frame_i != last_frame_ran + ) + + def is_single_z_slice_projection(self, how: str) -> bool: + return how == 'single z-slice' + + def should_disable_overlay_z_slice(self, how: str) -> bool: + return how.find('max') != -1 or how == 'same as above' + + def projection_frame_indices( + self, + *, + filename, + frame_i: int, + size_t: int, + locked: bool, + ) -> list[tuple[object, int]]: + if locked: + return [(filename, i) for i in range(size_t)] + return [(filename, frame_i)] + + def z_slice_frame_indices( + self, + *, + filename, + frame_i: int, + size_t: int, + locked: bool, + ) -> list[tuple[object, int]]: + if locked: + return [(filename, i) for i in range(size_t)] + return [(filename, i) for i in range(frame_i, size_t)] diff --git a/cellacdc/models/graphics_model.py b/cellacdc/models/graphics_model.py new file mode 100644 index 000000000..160203697 --- /dev/null +++ b/cellacdc/models/graphics_model.py @@ -0,0 +1,182 @@ +"""Qt-free model rules for graphics workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass +from collections.abc import Iterable, Mapping +import numpy as np + + +@dataclass(frozen=True) +class OverlayOpacityPlan: + """Opacity and scrollbar state for overlay image items.""" + + opacities: dict[str, float] + alpha_scrollbar_disabled: dict[str, bool] + + +@dataclass(frozen=True) +class OverlayVisibilityPlan: + """Visibility state for overlay controls by channel.""" + + channel_visible: dict[str, bool] + + +class GraphicsModel: + """Headless graphics workflow rules.""" + + def overlay_toolbutton_checked( + self, + channel: str, + *, + checked_channels: Iterable[str], + is_single_channel: bool, + ) -> bool: + return not is_single_channel and channel in set(checked_channels) + + def overlay_toolbutton_click_checked_channels( + self, + *, + clicked_channel: str, + all_channels: Iterable[str], + checked_channels: Iterable[str], + toolbar_single_channel: bool, + ) -> set[str]: + all_channels = set(all_channels) + checked_channels = set(checked_channels) + if not checked_channels or toolbar_single_channel: + checked_channels.add(clicked_channel) + + if toolbar_single_channel: + return {clicked_channel} + + return checked_channels & all_channels + + def overlay_visibility_plan( + self, + *, + all_channels: Iterable[str], + checked_channels: Iterable[str], + overlay_enabled: bool, + ) -> OverlayVisibilityPlan: + checked_channels = set(checked_channels) + return OverlayVisibilityPlan( + channel_visible={ + channel: overlay_enabled and channel in checked_channels + for channel in all_channels + } + ) + + def overlay_channel_opacity_map( + self, + base_channel: str, + active_channel_alpha_values: Mapping[str, float], + ) -> dict[str, float]: + channels = list(active_channel_alpha_values) + alpha_values = list(active_channel_alpha_values.values()) + opacities = self._base_first_hierarchical_opacities(alpha_values) + channel_opacity_mapper = { + channel: opacities[i + 1] + for i, channel in enumerate(channels) + } + channel_opacity_mapper[base_channel] = opacities[0] + return channel_opacity_mapper + + def overlay_item_opacity_plan( + self, + *, + all_channels: Iterable[str], + base_channel: str, + checked_channels: Iterable[str], + toolbar_single_channel: bool, + active_channel_alpha_values: Mapping[str, float], + ) -> OverlayOpacityPlan: + checked_channels = set(checked_channels) + channel_opacity_mapper = self.overlay_channel_opacity_map( + base_channel, + active_channel_alpha_values, + ) + is_single_channel = toolbar_single_channel or len(checked_channels) == 1 + + opacities = {} + alpha_scrollbar_disabled = {} + for channel in all_channels: + if channel in checked_channels and is_single_channel: + op_val = 1.0 + elif channel in checked_channels: + op_val = channel_opacity_mapper[channel] + else: + op_val = 0.0 + + if op_val == 0: + op_val = 0.01 + + opacities[channel] = min(op_val, 0.999) + if channel != base_channel: + alpha_scrollbar_disabled[channel] = op_val == 0 + + return OverlayOpacityPlan( + opacities=opacities, + alpha_scrollbar_disabled=alpha_scrollbar_disabled, + ) + + def _base_first_hierarchical_opacities( + self, + alpha_values: Iterable[float], + ) -> list[float]: + alphas = [1.0, *alpha_values] + if not alphas: + return alphas + + weights = [] + for i, alpha_ref in enumerate(alphas): + weight = alpha_ref + for alpha in alphas[i + 1:]: + weight *= 1 - alpha + weights.append(weight) + + return weights + + def generate_labels_image_lut(self, base_lut: np.ndarray) -> np.ndarray: + """Converts a 3-channel base LUT to a 4-channel RGBA LUT with background as transparent.""" + import numpy as np + lut = np.zeros((len(base_lut), 4), dtype=np.uint8) + lut[:, -1] = 255 + lut[:, :-1] = base_lut + lut[0] = [0, 0, 0, 0] + return lut + + def extend_labels_lut(self, base_lut: np.ndarray, len_new_lut: int) -> np.ndarray: + """Extends base_lut to include IDs greater than original length of base_lut.""" + import numpy as np + if len_new_lut <= len(base_lut): + return base_lut + + num_new_colors = len_new_lut - len(base_lut) + _lut = np.zeros((len_new_lut, 3), np.uint8) + _lut[:len(base_lut)] = base_lut + + random_idx = np.random.randint(0, len(base_lut), size=num_new_colors) + for i, idx in enumerate(random_idx): + rgb = base_lut[idx] + _lut[len(base_lut) + i] = rgb + return _lut + + def apply_lut_dimming_for_kept_objects( + self, + lut: np.ndarray, + kept_object_ids: list[int], + keep_ids_enabled: bool, + ) -> np.ndarray: + """Applies dimming to non-kept objects in the LUT if keep_ids is enabled.""" + import numpy as np + if not keep_ids_enabled: + return lut + + dimmed_lut = np.round(lut * 0.3).astype(np.uint8) + valid_ids = [idx for idx in kept_object_ids if idx < len(lut)] + if valid_ids: + kept_lut = np.round(dimmed_lut[valid_ids] / 0.3).astype(np.uint8) + dimmed_lut[valid_ids] = kept_lut + return dimmed_lut + diff --git a/cellacdc/models/image_controls_model.py b/cellacdc/models/image_controls_model.py new file mode 100644 index 000000000..3cf1a5ca1 --- /dev/null +++ b/cellacdc/models/image_controls_model.py @@ -0,0 +1,44 @@ +"""Scriptable model rules for image control widgets.""" + +from __future__ import annotations + + +class ImageControlsModel: + """Headless defaults for image-control UI construction.""" + + draw_ids_cont_combo_items = ( + 'Draw IDs and contours', + 'Draw IDs and overlay segm. masks', + 'Draw only cell cycle info', + 'Draw cell cycle info and contours', + 'Draw cell cycle info and overlay segm. masks', + 'Draw only mother-bud lines', + 'Draw only IDs', + 'Draw only contours', + 'Draw only overlay segm. masks', + 'Draw nothing', + ) + z_projection_options = ( + 'single z-slice', + 'max z-projection', + 'mean z-projection', + 'median z-proj.', + ) + overlay_z_projection_options = ( + 'single z-slice', + 'max z-projection', + 'mean z-projection', + 'median z-proj.', + 'same as above', + ) + bottom_layout_zoom_values = tuple(range(50, 151, 10)) + + def bottom_layout_zoom_percent(self, df_settings) -> int: + if 'bottom_sliders_zoom_perc' not in df_settings.index: + return 100 + return int(df_settings.at['bottom_sliders_zoom_perc', 'value']) + + def retain_space_hidden_sliders(self, df_settings) -> bool: + if 'retain_space_hidden_sliders' not in df_settings.index: + return True + return df_settings.at['retain_space_hidden_sliders', 'value'] == 'Yes' diff --git a/cellacdc/models/image_display_model.py b/cellacdc/models/image_display_model.py new file mode 100644 index 000000000..d0ab31129 --- /dev/null +++ b/cellacdc/models/image_display_model.py @@ -0,0 +1,76 @@ +"""Qt-free model rules for image display workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +RightPaneMode = Literal['next_frame', 'right_image', 'labels'] + + +@dataclass(frozen=True) +class RightPaneVisibilityPlan: + """Settings update for a right-pane visibility toggle.""" + + mode: RightPaneMode + checked: bool + settings_updates: dict[str, str] + + +@dataclass(frozen=True) +class LabelsAlphaPlan: + """Settings and effective opacity for labels overlay alpha.""" + + setting_value: float + opacity: float + + +class ImageDisplayModel: + """Headless display settings and image-display rules.""" + + def right_pane_visibility_plan( + self, + mode: RightPaneMode, + checked: bool, + ) -> RightPaneVisibilityPlan: + settings_updates = { + 'isNextFrameVisible': 'No', + 'isRightImageVisible': 'No', + 'isLabelsVisible': 'No', + } + if checked: + setting_key = { + 'next_frame': 'isNextFrameVisible', + 'right_image': 'isRightImageVisible', + 'labels': 'isLabelsVisible', + }[mode] + settings_updates[setting_key] = 'Yes' + + return RightPaneVisibilityPlan( + mode=mode, + checked=checked, + settings_updates=settings_updates, + ) + + def invert_bw_setting_value(self, checked: bool) -> str: + return 'Yes' if checked else 'No' + + def labels_alpha_plan( + self, + value: float, + *, + keep_ids_checked: bool, + ) -> LabelsAlphaPlan: + opacity = value / 3 if keep_ids_checked else value + return LabelsAlphaPlan(setting_value=value, opacity=opacity) + + def intensity_normalization_setting_value(self, how: str) -> str: + return how + + def rescale_intensity_setting_update( + self, + channel: str, + how: str, + ) -> tuple[str, str]: + return f'how_rescale_intensities_{channel}', how diff --git a/cellacdc/models/label_editing_model.py b/cellacdc/models/label_editing_model.py new file mode 100644 index 000000000..7f14e84c5 --- /dev/null +++ b/cellacdc/models/label_editing_model.py @@ -0,0 +1,54 @@ +"""Scriptable model rules for label-editing workflows.""" + +from __future__ import annotations + + +class LabelEditingModel: + """Headless decisions for manual label editing.""" + + def should_apply_manual_edits(self, edited_labels_by_z) -> bool: + return bool(edited_labels_by_z) + + def should_store_zslice_regionprops(self, *, is_segm_3d: bool) -> bool: + return is_segm_3d + + def should_update_zslice_regionprops( + self, + *, + force_update: bool, + already_stored: bool, + ) -> bool: + return force_update or not already_stored + + def should_prompt_for_background_id(self, clicked_id: int) -> bool: + return clicked_id == 0 + + def is_power_button_color( + self, + *, + button_color: str, + power_color: str, + ) -> bool: + return button_color == power_color + + def should_force_new_hover_id( + self, + *, + brush_active: bool, + shift_pressed: bool, + ) -> bool: + return brush_active and shift_pressed + + def should_restore_brush_id_from_hover( + self, + *, + is_hover_z_neighbor: bool, + shift_pressed: bool, + last_hover_id: int, + hover_id: int, + ) -> bool: + return ( + is_hover_z_neighbor + and not shift_pressed + and last_hover_id != hover_id + ) diff --git a/cellacdc/models/label_roi_model.py b/cellacdc/models/label_roi_model.py new file mode 100644 index 000000000..116028efa --- /dev/null +++ b/cellacdc/models/label_roi_model.py @@ -0,0 +1,135 @@ +"""Scriptable model rules for label-ROI workflows.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LabelRoiParamsSettings: + """Settings updates for Magic Labeller ROI options.""" + + updates: dict[str, object] + + +class LabelRoiModel: + """Headless decisions for Magic Labeller ROI workflows.""" + + yes_value = 'Yes' + no_value = 'No' + + def checked_setting_value(self, checked: bool) -> str: + return self.yes_value if checked else self.no_value + + def checked_from_setting_value(self, value) -> bool: + return value == self.yes_value + + def model_params_ini_path(self, settings_folderpath: str) -> str: + return os.path.join(settings_folderpath, 'last_params_segm_models.ini') + + def params_settings( + self, + *, + checked_roi_type: str, + circ_roi_radius: int, + roi_zdepth: int, + auto_clear_border: bool, + replace_existing_objects: bool, + ) -> LabelRoiParamsSettings: + return LabelRoiParamsSettings( + updates={ + 'labelRoi_checkedRoiType': checked_roi_type, + 'labelRoi_circRoiRadius': circ_roi_radius, + 'labelRoi_roiZdepth': roi_zdepth, + 'labelRoi_autoClearBorder': self.checked_setting_value( + auto_clear_border + ), + 'labelRoi_replaceExistingObjects': ( + self.checked_setting_value(replace_existing_objects) + ), + } + ) + + def is_frame_range_valid( + self, + enabled: bool, + start_frame_number: int, + stop_frame_number: int, + ) -> bool: + return not enabled or start_frame_number <= stop_frame_number + + def frame_range_length( + self, + enabled: bool, + start_frame_index: int, + stop_frame_number: int, + ) -> int: + if not enabled: + return 1 + return stop_frame_number - start_frame_index + + def time_range( + self, + enabled: bool, + start_frame_index: int, + stop_frame_number: int, + ): + if self.frame_range_length( + enabled, + start_frame_index, + stop_frame_number, + ) > 1: + return start_frame_index, stop_frame_number + return None + + def should_enable_range_controls(self, checked: bool) -> bool: + return checked + + def should_show_circular_cursor( + self, + *, + label_roi_checked: bool, + circular_roi_checked: bool, + label_roi_running: bool, + cursor_checked: bool, + existing_cursor_empty: bool, + ) -> bool: + return ( + label_roi_checked + and circular_roi_checked + and not label_roi_running + and (cursor_checked or not existing_cursor_empty) + ) + + def cursor_points(self, x, y, checked: bool): + if not checked: + return [], [] + return [x], [y] + + def should_uncheck_time_range( + self, + *, + time_range_checked: bool, + persistent_action_checked: bool, + ) -> bool: + return time_range_checked and not persistent_action_checked + + def z_range( + self, + roi_zdepth: int, + size_z: int, + current_z_index: int, + ) -> tuple[int, int]: + if roi_zdepth == size_z: + return 0, size_z + if roi_zdepth == 1: + return current_z_index, current_z_index + 1 + + if roi_zdepth % 2 != 0: + roi_zdepth += 1 + half_zdepth = int(roi_zdepth / 2) + zc = current_z_index + 1 + z0 = max(zc - half_zdepth, 0) + z1 = min(zc + half_zdepth, size_z) + return z0, z1 diff --git a/cellacdc/models/label_transform_tools_model.py b/cellacdc/models/label_transform_tools_model.py new file mode 100644 index 000000000..aa9ccc1a4 --- /dev/null +++ b/cellacdc/models/label_transform_tools_model.py @@ -0,0 +1,35 @@ +"""Scriptable model rules for label transform tools.""" + +from __future__ import annotations + + +class LabelTransformToolsModel: + """Headless decision rules for label transform tools.""" + + def reset_expand_label_id(self) -> int: + return -1 + + def should_reinitialize_expansion( + self, + *, + expanding_id: int, + hover_label_id: int, + dilation: bool, + is_dilation: bool, + ) -> bool: + return expanding_id != hover_label_id or dilation != is_dilation + + def should_start_moving_label(self, label_id: int) -> bool: + return label_id != 0 + + def point_in_shape(self, *, x: int, y: int, shape) -> bool: + y_size, x_size = shape + return x >= 0 and y >= 0 and x < x_size and y < y_size + + def move_delta(self, *, previous_pos, current_pos) -> tuple[int, int]: + x_start, y_start = previous_pos + x_current, y_current = current_pos + return x_current - x_start, y_current - y_start + + def should_clear_move_state(self, *, checked: bool) -> bool: + return not checked diff --git a/cellacdc/models/layout_controls_model.py b/cellacdc/models/layout_controls_model.py new file mode 100644 index 000000000..493f5d336 --- /dev/null +++ b/cellacdc/models/layout_controls_model.py @@ -0,0 +1,38 @@ +"""Scriptable model rules for layout-control workflows.""" + +from __future__ import annotations + +import re + + +class LayoutControlsModel: + """Headless decisions for GUI layout controls.""" + + yes_value = 'Yes' + no_value = 'No' + + def zoom_percentage_from_text(self, text: str) -> int: + return int(re.findall(r'(\d+)%', text)[0]) + + def zoom_factors(self, percentage: int) -> tuple[float, float] | None: + if percentage == 100: + return None + factor = percentage / 100 + return factor, factor + + def checked_setting_value(self, checked: bool) -> str: + return self.yes_value if checked else self.no_value + + def checked_from_setting_value(self, value) -> bool: + return value == self.yes_value + + def should_retain_z_slider_space( + self, + *, + checked: bool, + z_slice_enabled: bool, + ) -> bool: + return checked and z_slice_enabled + + def tool_name_from_tooltip(self, tooltip: str) -> str: + return re.findall(r'Name: (.*)', tooltip)[0] diff --git a/cellacdc/models/lineage_interactions_model.py b/cellacdc/models/lineage_interactions_model.py new file mode 100644 index 000000000..a5f3636ec --- /dev/null +++ b/cellacdc/models/lineage_interactions_model.py @@ -0,0 +1,131 @@ +"""Scriptable model rules for lineage-tree interaction workflows.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Sequence + +import numpy as np +import pandas as pd + + +class LineageInteractionsModel: + """Headless decisions for lineage-tree interaction workflows.""" + + lineage_mode = 'Normal division: Lineage tree' + viewer_mode = 'Viewer' + + def is_lineage_mode(self, mode: str) -> bool: + return mode == self.lineage_mode + + def should_initialize( + self, + *, + force: bool, + mode: str, + lineage_tree_exists: bool, + ) -> bool: + if not force and lineage_tree_exists: + return False + return force or self.is_lineage_mode(mode) + + def default_mode_after_failed_init(self) -> str: + return self.viewer_mode + + def last_annotated_frame_index( + self, + frame_records: Iterable[dict], + *, + acdc_key: str = 'acdc_df', + generation_column: str = 'generation_num_tree', + ) -> int: + last_frame_i = 0 + for frame_i, record in enumerate(frame_records): + acdc_df = record[acdc_key] + if ( + acdc_df is None + or generation_column not in acdc_df.columns + or acdc_df[generation_column].isin([np.nan, 0]).all() + ): + break + last_frame_i = frame_i + return last_frame_i + + def missing_frame_indices( + self, + current_frame_i: int, + present_frames: Iterable[int] | None, + ) -> list[int]: + present = set(present_frames or []) + missing = [ + frame_i for frame_i in range(current_frame_i + 1) + if frame_i not in present + ] + missing.sort() + return missing + + def should_process_auto_frame( + self, + *, + mode: str, + frame_i: int, + processed_frames: Iterable[int], + ) -> bool: + if not self.is_lineage_mode(mode): + return False + return frame_i not in processed_frames + + def should_backup_original( + self, + original_frame_i: int | None, + current_frame_i: int, + ) -> bool: + return original_frame_i is None or original_frame_i != current_frame_i + + def next_candidate_index( + self, + click_index: int, + candidates_count: int, + ) -> int: + if candidates_count <= 0: + return 0 + return abs(click_index % candidates_count) + + def should_skip_original_mother( + self, + current_parent_id, + candidate_parent_id, + *, + original_mother_skipped: bool, + ) -> bool: + return ( + current_parent_id == candidate_parent_id + and not original_mother_skipped + ) + + def parent_id_differences( + self, + original_df: pd.DataFrame, + new_df: pd.DataFrame, + reset_index_cell_id: Callable[[pd.DataFrame], pd.DataFrame], + *, + compare_columns: Sequence[str] = ('parent_ID_tree',), + ) -> pd.DataFrame | None: + if original_df.equals(new_df): + return None + + new_df = new_df[original_df.columns] + new_df = reset_index_cell_id(new_df) + new_df = new_df[list(compare_columns)] + new_df = new_df.sort_index() + + original_df = reset_index_cell_id(original_df) + original_df = original_df[list(compare_columns)] + original_df = original_df.sort_index() + + differences = original_df.compare(new_df) + if differences.empty: + return None + + differences = reset_index_cell_id(differences) + differences = differences[compare_columns[0]] + return differences.reset_index() diff --git a/cellacdc/models/magic_prompts_model.py b/cellacdc/models/magic_prompts_model.py new file mode 100644 index 000000000..37c9af182 --- /dev/null +++ b/cellacdc/models/magic_prompts_model.py @@ -0,0 +1,77 @@ +"""Scriptable model rules for promptable segmentation workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping + + +@dataclass(frozen=True) +class MagicPromptZoom: + """Computed zoom region for promptable segmentation.""" + + bounds: tuple[int, int, int, int] + image_origin: tuple[int, int, int] + zoom_slice: tuple[slice, slice] + + +class MagicPromptsModel: + """Headless promptable-segmentation geometry and point rules.""" + + def zoom_region(self, view_range, image_shape) -> MagicPromptZoom: + (xmin, xmax), (ymin, ymax) = view_range + height, width = image_shape[-2:] + + xmin = int(max(0, xmin)) + xmax = int(min(width, xmax)) + ymin = int(max(0, ymin)) + ymax = int(min(height, ymax)) + + return MagicPromptZoom( + bounds=(xmin, xmax, ymin, ymax), + image_origin=(0, ymin, xmin), + zoom_slice=(slice(ymin, ymax), slice(xmin, xmax)), + ) + + def points_in_zoom(self, df_points, zoom: MagicPromptZoom, frame_i): + xmin, xmax, ymin, ymax = zoom.bounds + filtered = df_points[ + (df_points['y'] >= ymin) + & (df_points['x'] >= xmin) + & (df_points['y'] < ymax) + & (df_points['x'] < xmax) + & (df_points['frame_i'] == frame_i) + ].copy() + filtered['y'] -= ymin + filtered['x'] -= xmin + return filtered + + def retained_points_outside_zoom( + self, + frame_points_data: Mapping, + zoom: MagicPromptZoom, + ): + if 'x' in frame_points_data: + return self._retained_points_outside_zoom_2d( + frame_points_data, + zoom, + ) + + return { + z: self._retained_points_outside_zoom_2d(z_points, zoom) + for z, z_points in frame_points_data.items() + } + + def _retained_points_outside_zoom_2d(self, points_data, zoom): + xmin, xmax, ymin, ymax = zoom.bounds + retained = {'x': [], 'y': [], 'id': []} + for x, y, point_id in zip( + points_data['x'], + points_data['y'], + points_data['id'], + ): + if x < xmin or x >= xmax or y < ymin or y >= ymax: + retained['x'].append(x) + retained['y'].append(y) + retained['id'].append(point_id) + return retained diff --git a/cellacdc/models/main_menu_model.py b/cellacdc/models/main_menu_model.py new file mode 100644 index 000000000..f500b6d7c --- /dev/null +++ b/cellacdc/models/main_menu_model.py @@ -0,0 +1,20 @@ +"""Scriptable model rules for the main menu.""" + +from __future__ import annotations + + +class MainMenuModel: + """Headless main-menu decision rules.""" + + default_rescale_intensity_options = ( + 'Rescale each 2D image', + 'Rescale across z-stack', + 'Rescale across time frames', + 'Do no rescale, display raw image', + ) + + def default_rescale_intensity_how(self, settings): + try: + return settings.at['default_rescale_intens_how', 'value'] + except Exception: + return self.default_rescale_intensity_options[0] diff --git a/cellacdc/models/main_toolbar_model.py b/cellacdc/models/main_toolbar_model.py new file mode 100644 index 000000000..178a3cd27 --- /dev/null +++ b/cellacdc/models/main_toolbar_model.py @@ -0,0 +1,18 @@ +"""Scriptable model rules for the main GUI toolbars.""" + +from __future__ import annotations + + +class MainToolbarModel: + """Headless toolbar metadata used by the main toolbar view.""" + + mode_items = ( + 'Segmentation and Tracking', + 'Cell cycle analysis', + 'Viewer', + 'Custom annotations', + 'Normal division: Lineage tree', + ) + + def default_mode_items(self) -> tuple[str, ...]: + return self.mode_items diff --git a/cellacdc/models/measurements_model.py b/cellacdc/models/measurements_model.py new file mode 100644 index 000000000..038745200 --- /dev/null +++ b/cellacdc/models/measurements_model.py @@ -0,0 +1,53 @@ +"""Scriptable model rules for measurement workflows.""" + +from __future__ import annotations + +from cellacdc.cca_functions import _calc_rot_vol +from cellacdc import measurements + + +class MeasurementsModel: + """Headless measurement calculation and setup rules.""" + + def rotational_volume( + self, + obj, + physical_size_y=1, + physical_size_x=1, + logger=None, + ): + return _calc_rot_vol( + obj, + physical_size_y, + physical_size_x, + logger=logger, + ) + + def custom_metrics_instructions(self): + return measurements.add_metrics_instructions() + + def metrics_examples_path(self): + return measurements.metrics_path + + def all_acdc_df_columns(self, all_pos_data): + columns = set() + for pos_data in all_pos_data: + for data_dict in pos_data.allData_li: + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + continue + columns.update(acdc_df.columns) + return columns + + def not_loaded_channels(self, all_channel_names, loaded_channel_names): + return [c for c in all_channel_names if c not in loaded_channel_names] + + def drop_unchecked_measurements(self, acdc_df, columns, regionprops): + if acdc_df is None: + return None + acdc_df = acdc_df.drop(columns=columns, errors='ignore') + for col_rp in regionprops: + drop_df_rp = acdc_df.filter(regex=fr'{col_rp}.*', axis=1) + drop_cols_rp = drop_df_rp.columns + acdc_df = acdc_df.drop(columns=drop_cols_rp, errors='ignore') + return acdc_df diff --git a/cellacdc/models/mode_controls_model.py b/cellacdc/models/mode_controls_model.py new file mode 100644 index 000000000..ac2e164a5 --- /dev/null +++ b/cellacdc/models/mode_controls_model.py @@ -0,0 +1,41 @@ +"""Scriptable model rules for GUI mode controls.""" + +from __future__ import annotations + + +class ModeControlsModel: + """Headless decisions for mode toolbar and action state.""" + + viewer_mode = 'Viewer' + segmentation_mode = 'Segmentation and Tracking' + snapshot_mode = 'Snapshot' + cca_mode = 'Cell cycle analysis' + custom_annotations_mode = 'Custom annotations' + + def should_start_blinking( + self, + mode: str, + *, + ruler_checked: bool = False, + ) -> bool: + return mode == self.viewer_mode and not ruler_checked + + def blink_styles(self, flag: bool) -> tuple[str, bool]: + if flag: + return 'background-color: orange', False + return 'background-color: none', True + + def should_store_on_mode_change(self, previous_mode: str) -> bool: + return previous_mode != self.viewer_mode + + def is_cca_mode(self, mode: str) -> bool: + return mode == self.cca_mode + + def undo_redo_target(self, mode: str) -> str: + if mode in {self.segmentation_mode, self.snapshot_mode}: + return 'labels' + if mode == self.cca_mode: + return 'cca' + if mode == self.custom_annotations_mode: + return 'custom_annotations' + return 'disabled' diff --git a/cellacdc/models/object_cleanup_model.py b/cellacdc/models/object_cleanup_model.py new file mode 100644 index 000000000..b29157e3f --- /dev/null +++ b/cellacdc/models/object_cleanup_model.py @@ -0,0 +1,17 @@ +"""Scriptable model rules for object cleanup workflows.""" + +from __future__ import annotations + +import numpy as np + + +class ObjectCleanupModel: + """Headless object-cleanup result shaping.""" + + def cleared_segmentation_frames(self, cleared_segm_data, *, size_t: int): + if size_t == 1: + return cleared_segm_data[np.newaxis] + return cleared_segm_data + + def frame_labels(self, cleared_segm_data): + return list(enumerate(cleared_segm_data)) diff --git a/cellacdc/models/object_properties_model.py b/cellacdc/models/object_properties_model.py new file mode 100644 index 000000000..42d149a97 --- /dev/null +++ b/cellacdc/models/object_properties_model.py @@ -0,0 +1,170 @@ +"""Scriptable model rules for object-property workflows.""" + +from __future__ import annotations + +import numpy as np + + +class ObjectPropertiesModel: + """Headless decisions for object-property and highlight workflows.""" + + def timelapse_default_categories(self) -> set[str]: + return { + 'In current frame', + 'In all visited frames', + 'In entire video', + 'Unique objects in all visited frames', + 'Unique objects in entire video', + } + + def snapshot_default_categories(self, *, is_segm_3d: bool) -> set[str]: + categories = { + 'In current position', + 'In all visited positions (current session)', + 'In all visited positions (previous sessions)', + 'In all loaded positions', + } + if is_segm_3d: + categories.add('In current z-slice') + return categories + + def should_update_object_counts( + self, + *, + window_exists: bool, + is_visible: bool, + live_preview_checked: bool, + ) -> bool: + return window_exists and is_visible and live_preview_checked + + def should_show_3d_property_controls(self, is_segm_3d: bool) -> bool: + return is_segm_3d + + def should_highlight_props_id( + self, + *, + dock_visible: bool, + highlight_checked: bool, + searched_highlight_checked: bool, + ) -> bool: + return ( + dock_visible + and (highlight_checked or searched_highlight_checked) + ) + + def should_update_props_widget( + self, + *, + dock_visible: bool, + object_id: int, + current_props_id: int, + ) -> bool: + return dock_visible and object_id != 0 and object_id != current_props_id + + def calculate_area_pxl( + self, + *, + is_segm_3d: bool, + z_proj_text: str, + z_lab: int, + bbox_0: int, + obj_image: np.ndarray, + obj_area: int, + ) -> int: + if is_segm_3d: + if z_proj_text == 'single z-slice': + local_z = z_lab - bbox_0 + return int(np.count_nonzero(obj_image[local_z])) + else: + return int(np.count_nonzero(obj_image.max(axis=0))) + else: + return obj_area + + def calculate_area_um2( + self, + *, + area_pxl: int, + physical_size_x: float, + physical_size_y: float, + ) -> float: + return area_pxl * physical_size_y * physical_size_x + + def calculate_vol_3d( + self, + *, + obj_area: int, + physical_size_x: float, + physical_size_y: float, + physical_size_z: float, + ) -> tuple[float, float]: + vol_vox_3D = obj_area + vol_fl_3D = vol_vox_3D * physical_size_z * physical_size_y * physical_size_x + return float(vol_vox_3D), float(vol_fl_3D) + + def calculate_elongation( + self, + *, + major_axis_length: float, + minor_axis_length: float, + ) -> float: + minor_axis = max(1.0, minor_axis_length) + return major_axis_length / minor_axis + + def get_object_and_background_images( + self, + *, + image: np.ndarray, + is_segm_3d: bool, + pos_data_size_z: int, + z_slice: int, + obj_slice: tuple, + obj_image: np.ndarray, + img1_image: np.ndarray | None = None, + ) -> tuple[np.ndarray, np.ndarray]: + if pos_data_size_z > 1 and not is_segm_3d: + obj_data = image[z_slice][obj_slice][obj_image] + img = img1_image if img1_image is not None else image[z_slice] + else: + obj_data = image[obj_slice][obj_image] + img = image + return obj_data, img + + def calculate_intensity_statistics( + self, + obj_data: np.ndarray, + ) -> dict[str, float]: + if obj_data.size == 0: + return {'min': 0.0, 'max': 0.0, 'mean': 0.0, 'median': 0.0} + return { + 'min': float(np.min(obj_data)), + 'max': float(np.max(obj_data)), + 'mean': float(np.mean(obj_data)), + 'median': float(np.median(obj_data)), + } + + def calculate_additional_measure( + self, + *, + func_desc: str, + func: callable, + obj_data: np.ndarray, + img: np.ndarray, + lab: np.ndarray, + obj_area: int, + vol_vox: float, + ) -> float: + if func_desc in ('Concentration', 'Amount'): + background_pixels = img[lab == 0] + bkgr_val = ( + float(np.median(background_pixels)) + if background_pixels.size > 0 + else 0.0 + ) + amount = func(obj_data, bkgr_val, obj_area) + if func_desc == 'Concentration': + return amount / vol_vox + else: + return amount + else: + return float(func(obj_data)) + diff --git a/cellacdc/models/object_search_model.py b/cellacdc/models/object_search_model.py new file mode 100644 index 000000000..4828109a8 --- /dev/null +++ b/cellacdc/models/object_search_model.py @@ -0,0 +1,25 @@ +"""Scriptable model rules for object search.""" + +from __future__ import annotations + +from collections.abc import Callable + +from cellacdc.domain.object_search import find_frame_with_id + + +class ObjectSearchModel: + """Headless object-search operations.""" + + def find_frame_with_id( + self, + pos_data, + searched_id: int, + *, + progress_callback: Callable[[int], None] | None = None, + ) -> int | None: + return find_frame_with_id( + pos_data.segm_data, + pos_data.allData_li, + searched_id, + progress_callback=progress_callback, + ) diff --git a/cellacdc/models/points_layers_model.py b/cellacdc/models/points_layers_model.py new file mode 100644 index 000000000..73541bc5b --- /dev/null +++ b/cellacdc/models/points_layers_model.py @@ -0,0 +1,65 @@ +"""Scriptable model rules for points-layer workflows.""" + +from __future__ import annotations + +from collections.abc import Mapping + + +class PointsLayersModel: + """Headless decisions for points-layer GUI workflows.""" + + recovery_tolerance_seconds = 15 + + def click_entry_table_filename( + self, + basename: str, + table_endname: str, + ) -> str: + table_basename = basename if basename.endswith('_') else f'{basename}_' + filename = f'{table_basename}{table_endname}' + if not filename.endswith('.csv'): + filename = f'{filename}.csv' + return filename + + def should_load_recovery_table( + self, + *, + recovery_exists: bool, + main_exists: bool, + recovery_mtime: float | None, + main_mtime: float | None, + ) -> bool: + if not recovery_exists: + return False + if not main_exists: + return True + if recovery_mtime is None or main_mtime is None: + return False + return ( + recovery_mtime + > main_mtime + self.recovery_tolerance_seconds + ) + + def should_compute_points_layer( + self, + *, + layer_type_index: int, + compute_points_layers: bool, + ) -> bool: + return layer_type_index < 2 and compute_points_layers + + def should_log_missing_frame_points(self, layer_type_index: int) -> bool: + return layer_type_index != 4 + + def should_use_z_slice( + self, + *, + z_projection_mode: str, + size_z: int, + frame_points_data: Mapping, + ) -> bool: + return ( + z_projection_mode == 'single z-slice' + and size_z > 1 + and 'x' not in frame_points_data + ) diff --git a/cellacdc/models/preprocessing_model.py b/cellacdc/models/preprocessing_model.py new file mode 100644 index 000000000..9fe182ec6 --- /dev/null +++ b/cellacdc/models/preprocessing_model.py @@ -0,0 +1,74 @@ +"""Scriptable model commands for image preprocessing recipes.""" + +from __future__ import annotations + +from typing import Any + +from cellacdc.core import ( + preprocess_image_from_recipe as core_preprocess_image_from_recipe, + preprocess_multi_pos_from_recipe as core_preprocess_multi_pos_from_recipe, + preprocess_video_from_recipe as core_preprocess_video_from_recipe, + preprocess_zstack_from_recipe as core_preprocess_zstack_from_recipe, + validate_multidimensional_recipe as core_validate_multidimensional_recipe, +) +from cellacdc.domain.display_images import normalize_display_image +from cellacdc.myutils import img_to_float +from cellacdc.preprocess import PreprocessedData + + +class PreprocessingModel: + """Headless preprocessing operations used by GUI and scripts.""" + + def validate_multidimensional_recipe( + self, + recipe: list[dict[str, Any]], + *, + apply_to_all_zslices: bool = False, + apply_to_all_frames: bool = False, + ): + return core_validate_multidimensional_recipe( + recipe, + apply_to_all_zslices=apply_to_all_zslices, + apply_to_all_frames=apply_to_all_frames, + ) + + def preprocess_image_from_recipe(self, image, recipe: list[dict[str, Any]]): + return core_preprocess_image_from_recipe(image, recipe) + + def preprocess_zstack_from_recipe(self, image, recipe: list[dict[str, Any]]): + return core_preprocess_zstack_from_recipe(image, recipe) + + def preprocess_video_from_recipe(self, image, recipe: list[dict[str, Any]]): + return core_preprocess_video_from_recipe(image, recipe) + + def preprocess_multi_pos_from_recipe( + self, + images, + recipe: list[dict[str, Any]], + ): + return core_preprocess_multi_pos_from_recipe(images, recipe) + + def image_to_float( + self, + image, + *, + force_dtype=None, + force_missing_dtype=None, + warn=True, + ): + return img_to_float( + image, + force_dtype=force_dtype, + force_missing_dtype=force_missing_dtype, + warn=warn, + ) + + def normalize_display_image(self, image, how: str): + return normalize_display_image( + image, + how, + image_to_float=self.image_to_float, + ) + + def create_preprocessed_data(self, image_data=None): + return PreprocessedData(image_data=image_data) diff --git a/cellacdc/models/quick_settings_model.py b/cellacdc/models/quick_settings_model.py new file mode 100644 index 000000000..7195534ac --- /dev/null +++ b/cellacdc/models/quick_settings_model.py @@ -0,0 +1,37 @@ +"""Scriptable model rules for quick settings.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FontSizeSetting: + """Parsed font-size setting and migration requirement.""" + + value: int + add_px_mode_setting: bool = False + + +class QuickSettingsModel: + """Headless quick-settings decision rules.""" + + def font_size_setting( + self, + saved_font_size, + *, + has_px_mode: bool, + ) -> FontSizeSetting: + saved_font_size = str(saved_font_size) + if saved_font_size.find('pt') != -1: + saved_font_size = saved_font_size[:-2] + font_size = int(saved_font_size) + if has_px_mode: + return FontSizeSetting(value=font_size) + return FontSizeSetting( + value=2*font_size, + add_px_mode_setting=True, + ) + + def should_update_all_contours(self, *, is_data_loaded: bool) -> bool: + return is_data_loaded diff --git a/cellacdc/models/saving_model.py b/cellacdc/models/saving_model.py new file mode 100644 index 000000000..5278ba44f --- /dev/null +++ b/cellacdc/models/saving_model.py @@ -0,0 +1,155 @@ +"""Scriptable model rules for save and autosave workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True) +class AutosaveSchedule: + """Autosave timer decision.""" + + use_frame_timer: bool + interval_ms: int | None = None + + +@dataclass(frozen=True) +class AutosaveIntervalChange: + """Settings and UI text for an autosave interval change.""" + + value: float + unit: Literal['minutes', 'frames'] + settings_updates: dict[str, str] + log_message: str + tooltip: str + start_frame_timer: bool + + +@dataclass(frozen=True) +class ConcatenatePromptPlan: + """Decision for showing the concatenate-output prompt.""" + + should_prompt: bool + ensure_setting: bool + + +class SavingModel: + """Headless decisions for save and autosave workflows.""" + + viewer_mode = 'Viewer' + segmentation_mode = 'Segmentation and Tracking' + cell_cycle_mode = 'Cell cycle analysis' + + def should_clear_autosave_status(self, *, mode: str) -> bool: + return mode == self.viewer_mode + + def should_enqueue_autosave(self, *, mode: str, has_active_workers: bool): + return mode != self.viewer_mode and has_active_workers + + def autosave_schedule( + self, + value: float, + unit: Literal['minutes', 'frames'], + ) -> AutosaveSchedule | None: + if value == 0: + return None + if unit == 'frames': + return AutosaveSchedule(use_frame_timer=True) + return AutosaveSchedule( + use_frame_timer=False, + interval_ms=round(value * 60 * 1000), + ) + + def autosave_interval_change( + self, + value: float, + unit: Literal['minutes', 'frames'], + ) -> AutosaveIntervalChange: + return AutosaveIntervalChange( + value=value, + unit=unit, + settings_updates={ + 'autoSaveIntevalValue': str(value), + 'autoSaveIntervalUnit': unit, + }, + log_message=f'Autosave interval changed to: {value} {unit}', + tooltip=( + 'Change autosave interval to every N frames or minutes\n\n' + f'Current autosave interval: {value} {unit}' + ), + start_frame_timer=unit == 'frames', + ) + + def concatenate_prompt_plan( + self, + *, + has_main_window: bool, + is_quick_save: bool, + setting_exists: bool, + show_setting_value: str | None, + ) -> ConcatenatePromptPlan: + if not has_main_window or is_quick_save: + return ConcatenatePromptPlan( + should_prompt=False, + ensure_setting=False, + ) + + should_prompt = show_setting_value != 'No' + return ConcatenatePromptPlan( + should_prompt=should_prompt, + ensure_setting=not setting_exists, + ) + + def concatenate_prompt_setting(self, *, do_not_show_again: bool) -> str: + if do_not_show_again: + return 'No' + return 'Yes' + + def autosave_segmentation_enabled(self, *, mode: str, checked: bool) -> bool: + if mode != self.segmentation_mode: + return False + return checked + + def autosave_annotations_enabled(self, *, mode: str, checked: bool) -> bool: + if mode != self.viewer_mode: + return False + return checked + + def save_as_basename(self, basename: str) -> str: + if basename.endswith('_'): + return f'{basename}segm' + return f'{basename}_segm' + + def quick_save_positions(self, position_foldername: str) -> set[str]: + return {position_foldername} + + def should_ask_positions( + self, + *, + is_snapshot: bool, + is_quick_save: bool, + position_count: int, + ) -> bool: + return is_snapshot and not is_quick_save and position_count > 1 + + def should_compute_volume_metrics( + self, + *, + save_metrics: bool, + mode: str, + ) -> bool: + return save_metrics or mode == self.cell_cycle_mode + + def save_finished_title( + self, + *, + aborted: bool, + worker_aborted: bool, + is_quick_save: bool, + ) -> tuple[str, str | None]: + if aborted or worker_aborted: + return 'Saving process cancelled.', 'r' + if is_quick_save: + return 'Saved segmentation file and annotations', None + return 'Saved!', None diff --git a/cellacdc/models/seg_for_lost_ids_model.py b/cellacdc/models/seg_for_lost_ids_model.py new file mode 100644 index 000000000..d2f0407bf --- /dev/null +++ b/cellacdc/models/seg_for_lost_ids_model.py @@ -0,0 +1,125 @@ +"""Scriptable model rules for segmenting lost IDs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from cellacdc.myutils import ArgSpec + + +@dataclass(frozen=True) +class SegForLostIdsSettings: + """Settings selected for the segment-lost-IDs worker.""" + + win: Any + init_kwargs_new: dict[str, Any] + args_new: dict[str, Any] + base_model_name: str + + +class SegForLostIdsModel: + """Headless settings and launch rules for lost-ID segmentation.""" + + settings_key = 'SegForLostIDsModel' + worker_model_name = 'local_seg' + + def previous_model_name(self, df_settings) -> str | None: + try: + return str(df_settings.at[self.settings_key, 'value']) + except KeyError: + return None + + def should_persist_model_choice(self, base_model_name: str | None) -> bool: + return bool(base_model_name) + + def extra_arg_specs(self) -> list[ArgSpec]: + extra_params = ( + 'overlap_threshold', + 'padding', + 'size_perc_diff', + 'distance_filler_growth', + 'max_iterations', + 'allow_only_tracked_cells', + ) + extra_types = (float, float, float, float, int, bool) + extra_defaults = (0.5, 0.8, 0.3, 1.0, 2, False) + extra_desc = ( + ( + 'Overlap threshold with other already segemented cells over ' + 'which newly segmented cells are discarded' + ), + ( + 'Padding of the box used for new segmentation around the ' + 'segmentation from the previous frame' + ), + ( + 'Relative size difference acceptable compared to previous ' + 'frames' + ), + ( + 'Cells which are already segmented are filled with random ' + 'noise sampled from background to ensure that they do not get ' + 'segmented again. This parameter controls the additional ' + 'padding around the already segmented cells.' + ), + ( + 'The algorithm will try and segment the maximum amount of ' + 'cells in the image by running the model several times and ' + 'filling new found cells with background noise. How many of ' + 'these iterations should be run?' + ), + ( + 'If no new cell IDs should be permitted ' + '(based on real time tracking)' + ), + ) + + return [ + ArgSpec( + name=name, + default=default, + type=arg_type, + desc=desc, + docstring='', + ) + for name, default, arg_type, desc in zip( + extra_params, extra_defaults, extra_types, extra_desc + ) + ] + + def split_model_kwargs( + self, + init_kwargs: dict[str, Any], + extra_kwargs: dict[str, Any], + ) -> tuple[dict[str, Any], dict[str, Any]]: + extra_param_names = {arg.name for arg in self.extra_arg_specs()} + init_kwargs_new = {} + args_new = {} + + for key, val in init_kwargs.items(): + if key in extra_param_names: + args_new[key] = val + else: + init_kwargs_new[key] = val + + for key, val in extra_kwargs.items(): + if key in extra_param_names: + args_new[key] = val + + return init_kwargs_new, args_new + + def settings_from_dialog(self, win, base_model_name: str): + init_kwargs_new, args_new = self.split_model_kwargs( + win.init_kwargs, + win.extra_kwargs, + ) + return SegForLostIdsSettings( + win=win, + init_kwargs_new=init_kwargs_new, + args_new=args_new, + base_model_name=base_model_name, + ) + + def can_start_from_frame(self, frame_i: int) -> bool: + return frame_i > 0 diff --git a/cellacdc/models/segmentation_model.py b/cellacdc/models/segmentation_model.py new file mode 100644 index 000000000..68887e365 --- /dev/null +++ b/cellacdc/models/segmentation_model.py @@ -0,0 +1,68 @@ +"""Scriptable model rules for segmentation workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass(frozen=True) +class EmptySegmentationPrompt: + """Prompt decision for enabling automatic segmentation.""" + + should_ask: bool + scope_text: str = '' + + +class SegmentationModel: + """Headless decisions for segmentation orchestration.""" + + thresholding_backend_name = 'thresholding' + thresholding_action_name = 'Automatic thresholding' + + def action_model_name(self, model_name: str) -> str: + if model_name == self.thresholding_backend_name: + return self.thresholding_action_name + return model_name + + def backend_model_name(self, model_name: str) -> str: + if model_name == self.thresholding_action_name: + return self.thresholding_backend_name + return model_name + + def should_compute_segmentation( + self, + *, + mode: str, + has_labels: bool, + force: bool, + auto_enabled: bool, + ) -> bool: + if mode in {'Viewer', 'Cell cycle analysis'}: + return False + if has_labels and not force: + return False + return auto_enabled + + def post_process_params( + self, + *, + apply_postprocessing, + standard_postprocess_kwargs=None, + custom_postprocess_features=None, + ) -> dict: + params = {'applied_postprocessing': apply_postprocessing} + params.update(standard_postprocess_kwargs or {}) + params.update(custom_postprocess_features or {}) + return params + + def empty_segmentation_prompt(self, position_data) -> EmptySegmentationPrompt: + for pos_data in position_data: + if pos_data.SizeT > 1: + for lab in pos_data.segm_data: + if not np.any(lab): + return EmptySegmentationPrompt(True, 'frames') + elif not np.any(pos_data.segm_data): + return EmptySegmentationPrompt(True, 'positions') + return EmptySegmentationPrompt(False) diff --git a/cellacdc/models/session_model.py b/cellacdc/models/session_model.py new file mode 100644 index 000000000..aa6f6020e --- /dev/null +++ b/cellacdc/models/session_model.py @@ -0,0 +1,56 @@ +"""Scriptable model rules for session workflows.""" + +from __future__ import annotations + +import numpy as np + + +class SessionModel: + """Headless decisions for session and frame storage workflows.""" + + def should_store_frame_data( + self, + *, + frame_i: int, + mode: str, + enforce: bool, + ) -> bool: + if frame_i < 0: + return False + if mode == 'Viewer' and not enforce: + return False + return True + + def should_disable_load_position(self, position_count: int) -> bool: + return position_count <= 1 + + def labels_shape( + self, + *, + is_3d: bool, + size_z: int, + size_y: int, + size_x: int, + ) -> tuple[int, ...]: + if is_3d: + return (size_z, size_y, size_x) + return (size_y, size_x) + + def empty_labels( + self, + *, + is_3d: bool, + size_z: int, + size_y: int, + size_x: int, + ) -> np.ndarray: + shape = self.labels_shape( + is_3d=is_3d, + size_z=size_z, + size_y=size_y, + size_x=size_x, + ) + return np.zeros(shape, dtype=np.uint32) + + def should_resume_last_session_prompt(self, last_tracked_num: int) -> bool: + return last_tracked_num > 1 diff --git a/cellacdc/models/status_hover_model.py b/cellacdc/models/status_hover_model.py new file mode 100644 index 000000000..31b4d1baf --- /dev/null +++ b/cellacdc/models/status_hover_model.py @@ -0,0 +1,122 @@ +"""Scriptable model rules for hover and status-bar text.""" + +from __future__ import annotations + +import math +import os +import re + + +class StatusHoverModel: + """Headless status-bar and hover formatting rules.""" + + def channel_hover_text(self, description, channel, value, format_spec): + return f'{description} {channel}: value={value:{format_spec}}' + + def object_hover_text(self, *, label_id, max_id, object_count): + return ( + f'Objects: ID={label_id}, max ID={max_id}, ' + f'num. of objects={object_count}' + ) + + def base_hover_text( + self, + *, + x, + y, + width, + height, + x_left, + y_top, + x_right, + y_bottom, + axis_index, + ): + return ( + f'x={x:d}, y={y:d} | ' + f'W={width:d}, H={height:d} | ' + f'x_left={x_left:d}, y_top={y_top:d} | ' + f'x_right={x_right:d}, y_bottom={y_bottom:d} | ' + f'(ax{axis_index})' + ) + + def replace_view_range_status( + self, + text, + *, + width, + height, + x_left, + y_top, + x_right, + y_bottom, + ): + pattern = ( + r'W=.*?, H=.*? \| ' + r'x_left=.*?, y_top=.*? \| ' + r'x_right=.*?, y_bottom=.*? \| ' + ) + replacing = ( + f'W={width:d}, H={height:d} | ' + f'x_left={x_left:d}, y_top={y_top:d} | ' + f'x_right={x_right:d}, y_bottom={y_bottom:d} | ' + ) + return re.sub(pattern, replacing, text) + + def highlight_state( + self, + *, + x, + y, + bbox, + enabled, + active_tool, + blocked_by_other_highlight=False, + ): + if not enabled or active_tool is not None or blocked_by_other_highlight: + return None + y_min, x_min, y_max, x_max = bbox + return x_min <= x <= x_max and y_min <= y <= y_max + + def mouse_data_coords_right_image(self, text): + if not text: + return None + ax_idx = int(re.findall(r'\(ax(\d)\)', text)[0]) + if ax_idx == 0: + return None + coords = re.findall(r'x=(\d+), y=(\d+) \|', text)[0] + return tuple([int(val) for val in coords]) + + def ruler_length_text(self, text): + length_text = re.findall(r'length = (.*)\)', text)[0] + length_text = length_text.replace('pxl', 'pixels') + return f'{length_text})' + + def ruler_measurement_text(self, *, length_pixels, pixel_to_um): + return ( + f'length = {int(length_pixels)} pxl ' + f'({length_pixels*pixel_to_um:.2f} μm)' + ) + + def euclidean_length(self, x_values, y_values): + return math.sqrt( + (x_values[0]-x_values[1])**2 + (y_values[0]-y_values[1])**2 + ) + + def status_bar_text( + self, + *, + pos_foldername, + basename, + filename, + segm_npz_path, + ): + segmented_channel_name = filename[len(basename):] + segm_filename = os.path.basename(segm_npz_path) + segm_end_name = segm_filename[len(basename):] + return ( + f'{pos_foldername} || ' + f'Basename: {basename} || ' + f'Segmented channel: {segmented_channel_name} || ' + f'Segmentation file name: {segm_end_name}' + ) diff --git a/cellacdc/models/tool_activation_model.py b/cellacdc/models/tool_activation_model.py new file mode 100644 index 000000000..696babbb3 --- /dev/null +++ b/cellacdc/models/tool_activation_model.py @@ -0,0 +1,47 @@ +"""Scriptable model rules for active-tool workflows.""" + +from __future__ import annotations + + +class ToolActivationModel: + """Headless decisions for active-tool and hover workflows.""" + + def manual_annotation_highlight_color( + self, + *, + current_frame_i: int, + frame_to_restore: int | None, + ) -> str: + if current_frame_i == frame_to_restore: + return 'green' + if frame_to_restore is not None and current_frame_i < frame_to_restore: + return 'gold' + return 'red' + + def should_highlight_hover_lost_object( + self, + *, + has_no_modifier: bool, + copy_lost_object_checked: bool, + is_exit_event: bool, + ) -> bool: + return ( + has_no_modifier + and copy_lost_object_checked + and not is_exit_event + ) + + def point_in_shape(self, x: int, y: int, shape: tuple[int, int]) -> bool: + height, width = shape + return x >= 0 and x < width and y >= 0 and y < height + + def should_hide_hover_objects( + self, + *, + brush_auto_hide_checked: bool, + force: bool, + ) -> bool: + return brush_auto_hide_checked or force + + def should_disable_non_functional_buttons(self, is_segm_3d: bool) -> bool: + return is_segm_3d diff --git a/cellacdc/models/tracking_model.py b/cellacdc/models/tracking_model.py new file mode 100644 index 000000000..a757d89d9 --- /dev/null +++ b/cellacdc/models/tracking_model.py @@ -0,0 +1,89 @@ +"""Qt-free model rules for tracking metadata.""" + +from __future__ import annotations + +from cellacdc.domain.tracking import ( + FutureIdPropagationScan, + LostNewIdsResult, + TrackedLostIdsResult, + compute_lost_new_ids, + last_tracked_frame_index, + scan_future_id_propagation, + tracked_lost_centroids_from_regionprops, + tracked_lost_ids_from_centroids, +) + + +class TrackingModel: + """Headless tracking state calculations.""" + + def compute_lost_new_ids( + self, + previous_ids, + current_ids, + *, + current_deleted_roi_ids=(), + previous_deleted_roi_ids=(), + tracked_lost_ids=(), + ) -> LostNewIdsResult: + return compute_lost_new_ids( + previous_ids, + current_ids, + current_deleted_roi_ids=current_deleted_roi_ids, + previous_deleted_roi_ids=previous_deleted_roi_ids, + tracked_lost_ids=tracked_lost_ids, + ) + + def tracked_lost_centroids_from_regionprops( + self, + regionprops, + tracked_lost_ids, + ) -> set[tuple[int, ...]]: + return tracked_lost_centroids_from_regionprops( + regionprops, + tracked_lost_ids, + ) + + def tracked_lost_ids_from_centroids( + self, + previous_labels, + tracked_lost_centroids, + ids_in_frame, + ) -> TrackedLostIdsResult: + return tracked_lost_ids_from_centroids( + previous_labels, + tracked_lost_centroids, + ids_in_frame, + ) + + def last_tracked_frame_index( + self, + frame_labels, + *, + first_frame_fallback: int = 0, + total_frames: int | None = None, + ) -> int: + return last_tracked_frame_index( + frame_labels, + first_frame_fallback=first_frame_fallback, + total_frames=total_frames, + ) + + def scan_future_id_propagation( + self, + target_id: int, + *, + current_frame_i: int, + frame_labels, + fallback_frame_labels, + include_unvisited: bool = False, + total_frames: int | None = None, + ) -> FutureIdPropagationScan: + return scan_future_id_propagation( + target_id, + current_frame_i=current_frame_i, + frame_labels=frame_labels, + fallback_frame_labels=fallback_frame_labels, + include_unvisited=include_unvisited, + total_frames=total_frames, + ) diff --git a/cellacdc/models/undo_redo_model.py b/cellacdc/models/undo_redo_model.py new file mode 100644 index 000000000..e9d5ba63a --- /dev/null +++ b/cellacdc/models/undo_redo_model.py @@ -0,0 +1,32 @@ +"""Scriptable model rules for undo and redo stacks.""" + +from __future__ import annotations + +from collections import defaultdict + + +class UndoRedoModel: + """Headless undo/redo stack operations.""" + + def empty_frame_stacks(self, size_t: int) -> list[list]: + return [[] for _ in range(size_t)] + + def empty_add_point_queue(self): + return defaultdict(list) + + def trim_stack(self, states: list, *, max_size: int) -> None: + if len(states) > max_size: + states.pop(-1) + + def can_undo_labels(self, undo_count: int, states: list) -> bool: + return undo_count < len(states) - 1 + + def can_redo_labels(self, undo_count: int) -> bool: + return undo_count > 0 + + def should_disable_undo_after_cca( + self, + undo_count: int, + states: list, + ) -> bool: + return len(states) > undo_count diff --git a/cellacdc/models/whitelist_model.py b/cellacdc/models/whitelist_model.py new file mode 100644 index 000000000..200630f52 --- /dev/null +++ b/cellacdc/models/whitelist_model.py @@ -0,0 +1,100 @@ +"""Scriptable model rules for the Whitelist feature.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Set, List +import numpy as np + + +@dataclass(frozen=True) +class WhitelistModel: + """Headless decisions and calculations for Whitelist management.""" + + def filter_existing_ids(self, current_whitelist: Set[int], possible_ids: Set[int]) -> tuple[Set[int], bool]: + """Filters out non-existing IDs from the current whitelist. + + Returns a tuple: (filtered_whitelist, is_any_id_non_existing) + """ + is_any_id_non_existing = False + filtered_whitelist = set(current_whitelist) + for ID in current_whitelist: + if ID not in possible_ids: + is_any_id_non_existing = True + filtered_whitelist.discard(ID) + return filtered_whitelist, is_any_id_non_existing + + def get_missing_ids(self, current_ids: Set[int], previous_ids: Set[int]) -> Set[int]: + """Returns the set of IDs present in current frame but missing from previous frame.""" + return set(current_ids) - set(previous_ids) + + def check_original_labels(self, whitelist_obj, frame_i: int) -> bool: + """Checks if original label data is allocated and valid for the frame.""" + if whitelist_obj is None: + return False + if whitelist_obj.originalLabsIDs is None: + return False + if frame_i >= len(whitelist_obj.originalLabsIDs) or whitelist_obj.originalLabsIDs[frame_i] is None: + return False + return True + + def get_frames_range(self, frame_i: int) -> list[int]: + """Calculates navigation frame ranges for label loading.""" + if frame_i > 0: + return [frame_i - 1, frame_i] + return [frame_i] + + def get_diff_ids(self, old_ids: Set[int], prev_ids: Set[int], new_ids: Set[int]) -> Set[int]: + """Computes tracking difference intersection (new_ids - old_ids) & prev_ids.""" + return (new_ids - old_ids) & prev_ids + + def get_whitelist_missing_and_removed_ids(self, whitelist: Set[int], current_ids: Set[int]) -> tuple[list[int], list[int]]: + """Finds IDs that are missing from current_ids and IDs to be removed from current_ids.""" + missing_ids = list(whitelist - current_ids) + to_be_removed_ids = list(current_ids - whitelist) + return missing_ids, to_be_removed_ids + + def apply_id_mask( + self, + curr_lab: np.ndarray, + og_lab: np.ndarray | None, + missing_ids: list[int] | np.ndarray, + to_be_removed_ids: list[int] | np.ndarray + ) -> np.ndarray: + """Applies missing and removed ID masks to the label array.""" + updated_lab = curr_lab.copy().astype(np.int32) + missing_ids = np.array(missing_ids, dtype=np.int32) + to_be_removed_ids = np.array(to_be_removed_ids, dtype=np.int32) + + if missing_ids.size > 0 and og_lab is not None: + mask = np.isin(og_lab, missing_ids) + updated_lab[mask] = og_lab[mask] + + if to_be_removed_ids.size > 0: + updated_lab[np.isin(updated_lab, to_be_removed_ids)] = 0 + + return updated_lab + + def construct_og_frame( + self, + pos_lab: np.ndarray, + og_frame_base: np.ndarray, + whitelist_ids: Set[int], + og_ids: Set[int] + ) -> np.ndarray: + """Constructs original labels overlay using np.isin masking.""" + og_frame = og_frame_base.copy() + + ids_to_update = whitelist_ids & og_ids + if ids_to_update: + mask = np.isin(og_frame, list(ids_to_update)) + og_frame[mask] = 0 + mask = np.isin(pos_lab, list(ids_to_update)) + og_frame[mask] = pos_lab[mask] + + ids_to_add = whitelist_ids - og_ids + if ids_to_add: + mask = np.isin(pos_lab, list(ids_to_add)) + og_frame[mask] = pos_lab[mask] + + return og_frame diff --git a/cellacdc/models/window_events_model.py b/cellacdc/models/window_events_model.py new file mode 100644 index 000000000..8e7e0be64 --- /dev/null +++ b/cellacdc/models/window_events_model.py @@ -0,0 +1,7 @@ +"""Qt-free model rules for main-window event handling.""" + +from __future__ import annotations + + +class WindowEventsModel: + """Headless placeholder for main-window event rules.""" diff --git a/cellacdc/models/worker_model.py b/cellacdc/models/worker_model.py new file mode 100644 index 000000000..7afe4e395 --- /dev/null +++ b/cellacdc/models/worker_model.py @@ -0,0 +1,34 @@ +"""Scriptable model rules for GUI worker lifecycle handling.""" + +from __future__ import annotations + + +class WorkerModel: + """Headless worker progress and lifecycle decisions.""" + + def progress_log_level(self, logger_level: str = 'INFO') -> str: + return logger_level or 'INFO' + + def progressbar_maximum(self, total_iterations: int) -> int: + if total_iterations == 1: + return 0 + return total_iterations + + def lazy_loader_progress_description(self, chunk_range) -> str: + coord0_chunk, coord1_chunk = chunk_range + return ( + f'Loading new window, range = ({coord0_chunk}, {coord1_chunk})...' + ) + + def should_enqueue_autosave(self, is_saving: bool) -> bool: + return not is_saving + + def should_disable_realtime_tracking( + self, + tracking_on_never_visited_frames: bool, + realtime_tracking_enabled: bool, + ) -> bool: + return ( + tracking_on_never_visited_frames + and realtime_tracking_enabled + ) diff --git a/cellacdc/myutils.py b/cellacdc/myutils.py index d04c49739..fd43207ae 100644 --- a/cellacdc/myutils.py +++ b/cellacdc/myutils.py @@ -55,7 +55,7 @@ from . import urls from . import qrc_resources_path from . import settings_folderpath -from .models._cellpose_base import min_target_versions_cp +from .segmenters._cellpose_base import min_target_versions_cp if GUI_INSTALLED: from qtpy.QtWidgets import QMessageBox @@ -1721,7 +1721,7 @@ def get_model_path(model_name, create_temp_dir=True): if model_name == 'Automatic thresholding': model_name == 'thresholding' - model_info_path = os.path.join(cellacdc_path, 'models', model_name, 'model') + model_info_path = os.path.join(models_path, model_name, 'model') if os.path.exists(model_info_path): for file in listdir(model_info_path): @@ -2038,7 +2038,7 @@ def check_v123_model_path(model_name): # while from v1.2.4 we save them on user folder. If we find the # weights in the package we move them to user folder without downloading # new ones. - v123_model_path = os.path.join(cellacdc_path, 'models', model_name, 'model') + v123_model_path = os.path.join(models_path, model_name, 'model') exists = check_model_exists(v123_model_path, model_name) if exists: return v123_model_path @@ -2067,7 +2067,7 @@ def migrate_to_new_user_profile_path(path_to_migrate: os.PathLike): return os.path.join(user_profile_path, folder) def _write_model_location_to_txt(model_name): - model_info_path = os.path.join(cellacdc_path, 'models', model_name, 'model') + model_info_path = os.path.join(models_path, model_name, 'model') model_path = os.path.join(user_profile_path, f'acdc-{model_name}') file = 'weights_location_path.txt' with open(os.path.join(model_info_path, file), 'w') as txt: @@ -4008,7 +4008,7 @@ def run_fiji_command(command=None, logger_func=print): def import_promptable_segment_module(model_name): try: acdcPromptSegment = import_module( - f'cellacdc.promptable_models.{model_name}.acdcPromptSegment' + f'cellacdc.segmenters_promptable.{model_name}.acdcPromptSegment' ) except ModuleNotFoundError as e: # Check if custom model @@ -4128,7 +4128,7 @@ def init_tracker( def import_segment_module(model_name): try: - acdcSegment = import_module(f'cellacdc.models.{model_name}.acdcSegment') + acdcSegment = import_module(f'cellacdc.segmenters.{model_name}.acdcSegment') except ModuleNotFoundError as e: # Check if custom model cp = config.ConfigParser() diff --git a/cellacdc/models/BABY/__init__.py b/cellacdc/segmenters/BABY/__init__.py similarity index 100% rename from cellacdc/models/BABY/__init__.py rename to cellacdc/segmenters/BABY/__init__.py diff --git a/cellacdc/models/BABY/acdcSegment.py b/cellacdc/segmenters/BABY/acdcSegment.py similarity index 100% rename from cellacdc/models/BABY/acdcSegment.py rename to cellacdc/segmenters/BABY/acdcSegment.py diff --git a/cellacdc/models/Cellpose_germlineNuclei/__init__.py b/cellacdc/segmenters/Cellpose_germlineNuclei/__init__.py similarity index 100% rename from cellacdc/models/Cellpose_germlineNuclei/__init__.py rename to cellacdc/segmenters/Cellpose_germlineNuclei/__init__.py diff --git a/cellacdc/models/Cellpose_germlineNuclei/acdcSegment.py b/cellacdc/segmenters/Cellpose_germlineNuclei/acdcSegment.py similarity index 100% rename from cellacdc/models/Cellpose_germlineNuclei/acdcSegment.py rename to cellacdc/segmenters/Cellpose_germlineNuclei/acdcSegment.py diff --git a/cellacdc/models/DeepSea/__init__.py b/cellacdc/segmenters/DeepSea/__init__.py similarity index 90% rename from cellacdc/models/DeepSea/__init__.py rename to cellacdc/segmenters/DeepSea/__init__.py index d2fcbf0be..45929f3be 100644 --- a/cellacdc/models/DeepSea/__init__.py +++ b/cellacdc/segmenters/DeepSea/__init__.py @@ -14,7 +14,7 @@ from PIL import Image -_, deepsea_models_path = myutils.get_model_path('deepsea', create_temp_dir=False) +_, deepsea_segmenters_path = myutils.get_model_path('deepsea', create_temp_dir=False) image_size = [383,512] image_means = [0.5] @@ -46,7 +46,7 @@ def _init_model( torch_device = torch.device(device) # Initialize checkpoint - checkpoint_path = os.path.join(deepsea_models_path, checkpoint_filename) + checkpoint_path = os.path.join(deepsea_segmenters_path, checkpoint_filename) checkpoint = torch.load(checkpoint_path, map_location=torch_device) model = DeepSeaClass( diff --git a/cellacdc/models/DeepSea/acdcSegment.py b/cellacdc/segmenters/DeepSea/acdcSegment.py similarity index 100% rename from cellacdc/models/DeepSea/acdcSegment.py rename to cellacdc/segmenters/DeepSea/acdcSegment.py diff --git a/cellacdc/models/InstanSeg/__init__.py b/cellacdc/segmenters/InstanSeg/__init__.py similarity index 100% rename from cellacdc/models/InstanSeg/__init__.py rename to cellacdc/segmenters/InstanSeg/__init__.py diff --git a/cellacdc/models/InstanSeg/acdcSegment.py b/cellacdc/segmenters/InstanSeg/acdcSegment.py similarity index 100% rename from cellacdc/models/InstanSeg/acdcSegment.py rename to cellacdc/segmenters/InstanSeg/acdcSegment.py diff --git a/cellacdc/models/StarDist/__init__.py b/cellacdc/segmenters/StarDist/__init__.py similarity index 100% rename from cellacdc/models/StarDist/__init__.py rename to cellacdc/segmenters/StarDist/__init__.py diff --git a/cellacdc/models/StarDist/acdcSegment.py b/cellacdc/segmenters/StarDist/acdcSegment.py similarity index 100% rename from cellacdc/models/StarDist/acdcSegment.py rename to cellacdc/segmenters/StarDist/acdcSegment.py diff --git a/cellacdc/models/YeaZ/__init__.py b/cellacdc/segmenters/YeaZ/__init__.py similarity index 100% rename from cellacdc/models/YeaZ/__init__.py rename to cellacdc/segmenters/YeaZ/__init__.py diff --git a/cellacdc/models/YeaZ/acdcSegment.py b/cellacdc/segmenters/YeaZ/acdcSegment.py similarity index 100% rename from cellacdc/models/YeaZ/acdcSegment.py rename to cellacdc/segmenters/YeaZ/acdcSegment.py diff --git a/cellacdc/models/YeaZ/unet/LaunchBatchPrediction.py b/cellacdc/segmenters/YeaZ/unet/LaunchBatchPrediction.py similarity index 100% rename from cellacdc/models/YeaZ/unet/LaunchBatchPrediction.py rename to cellacdc/segmenters/YeaZ/unet/LaunchBatchPrediction.py diff --git a/cellacdc/models/YeaZ/unet/__init__.py b/cellacdc/segmenters/YeaZ/unet/__init__.py similarity index 100% rename from cellacdc/models/YeaZ/unet/__init__.py rename to cellacdc/segmenters/YeaZ/unet/__init__.py diff --git a/cellacdc/models/YeaZ/unet/hungarian.py b/cellacdc/segmenters/YeaZ/unet/hungarian.py similarity index 100% rename from cellacdc/models/YeaZ/unet/hungarian.py rename to cellacdc/segmenters/YeaZ/unet/hungarian.py diff --git a/cellacdc/models/YeaZ/unet/model.py b/cellacdc/segmenters/YeaZ/unet/model.py similarity index 100% rename from cellacdc/models/YeaZ/unet/model.py rename to cellacdc/segmenters/YeaZ/unet/model.py diff --git a/cellacdc/models/YeaZ/unet/neural_network.py b/cellacdc/segmenters/YeaZ/unet/neural_network.py similarity index 100% rename from cellacdc/models/YeaZ/unet/neural_network.py rename to cellacdc/segmenters/YeaZ/unet/neural_network.py diff --git a/cellacdc/models/YeaZ/unet/segment.py b/cellacdc/segmenters/YeaZ/unet/segment.py similarity index 100% rename from cellacdc/models/YeaZ/unet/segment.py rename to cellacdc/segmenters/YeaZ/unet/segment.py diff --git a/cellacdc/models/YeaZ/unet/tracking.py b/cellacdc/segmenters/YeaZ/unet/tracking.py similarity index 100% rename from cellacdc/models/YeaZ/unet/tracking.py rename to cellacdc/segmenters/YeaZ/unet/tracking.py diff --git a/cellacdc/models/YeaZ_v2/__init__.py b/cellacdc/segmenters/YeaZ_v2/__init__.py similarity index 100% rename from cellacdc/models/YeaZ_v2/__init__.py rename to cellacdc/segmenters/YeaZ_v2/__init__.py diff --git a/cellacdc/models/YeaZ_v2/acdcSegment.py b/cellacdc/segmenters/YeaZ_v2/acdcSegment.py similarity index 100% rename from cellacdc/models/YeaZ_v2/acdcSegment.py rename to cellacdc/segmenters/YeaZ_v2/acdcSegment.py diff --git a/cellacdc/models/YeastMate/__init__.py b/cellacdc/segmenters/YeastMate/__init__.py similarity index 100% rename from cellacdc/models/YeastMate/__init__.py rename to cellacdc/segmenters/YeastMate/__init__.py diff --git a/cellacdc/models/YeastMate/acdcSegment.py b/cellacdc/segmenters/YeastMate/acdcSegment.py similarity index 100% rename from cellacdc/models/YeastMate/acdcSegment.py rename to cellacdc/segmenters/YeastMate/acdcSegment.py diff --git a/cellacdc/segmenters/__init__.py b/cellacdc/segmenters/__init__.py new file mode 100755 index 000000000..b9c59c7b0 --- /dev/null +++ b/cellacdc/segmenters/__init__.py @@ -0,0 +1,5 @@ +STARDIST_MODELS = [ + '2D_versatile_fluo', + '2D_versatile_he', + '2D_paper_dsb2018' +] \ No newline at end of file diff --git a/cellacdc/models/_cellpose_base/__init__.py b/cellacdc/segmenters/_cellpose_base/__init__.py similarity index 100% rename from cellacdc/models/_cellpose_base/__init__.py rename to cellacdc/segmenters/_cellpose_base/__init__.py diff --git a/cellacdc/models/_cellpose_base/_directML.py b/cellacdc/segmenters/_cellpose_base/_directML.py similarity index 100% rename from cellacdc/models/_cellpose_base/_directML.py rename to cellacdc/segmenters/_cellpose_base/_directML.py diff --git a/cellacdc/models/_cellpose_base/acdcSegment.py b/cellacdc/segmenters/_cellpose_base/acdcSegment.py similarity index 99% rename from cellacdc/models/_cellpose_base/acdcSegment.py rename to cellacdc/segmenters/_cellpose_base/acdcSegment.py index e971cde02..abb3a8e36 100644 --- a/cellacdc/models/_cellpose_base/acdcSegment.py +++ b/cellacdc/segmenters/_cellpose_base/acdcSegment.py @@ -629,7 +629,7 @@ def check_directml_gpu_gpu(model_name, directml_gpu, gpu, ask_install=True): if not proceed: return directml_gpu, gpu, proceed if directml_gpu: - from cellacdc.models._cellpose_base._directML import init_directML + from cellacdc.segmenters._cellpose_base._directML import init_directML directml_gpu = init_directML() if directml_gpu and gpu: @@ -646,14 +646,14 @@ def check_directml_gpu_gpu(model_name, directml_gpu, gpu, ask_install=True): def setup_gpu_direct_ml(self, directml_gpu, gpu, device): if directml_gpu: - from cellacdc.models._cellpose_base._directML import setup_directML + from cellacdc.segmenters._cellpose_base._directML import setup_directML setup_directML(self) from cellacdc.core import fix_sparse_directML fix_sparse_directML() if gpu: # sometimes gpu is not properly set up ^^ - from cellacdc.models._cellpose_base._directML import setup_custom_device + from cellacdc.segmenters._cellpose_base._directML import setup_custom_device if device is None: device = 0 try: diff --git a/cellacdc/models/cellpose_v2/__init__.py b/cellacdc/segmenters/cellpose_v2/__init__.py similarity index 100% rename from cellacdc/models/cellpose_v2/__init__.py rename to cellacdc/segmenters/cellpose_v2/__init__.py diff --git a/cellacdc/models/cellpose_v2/acdcSegment.py b/cellacdc/segmenters/cellpose_v2/acdcSegment.py similarity index 99% rename from cellacdc/models/cellpose_v2/acdcSegment.py rename to cellacdc/segmenters/cellpose_v2/acdcSegment.py index 561a587fb..76661af12 100644 --- a/cellacdc/models/cellpose_v2/acdcSegment.py +++ b/cellacdc/segmenters/cellpose_v2/acdcSegment.py @@ -1,5 +1,5 @@ import os -from cellacdc.models._cellpose_base.acdcSegment import Model as CellposeBaseModel +from cellacdc.segmenters._cellpose_base.acdcSegment import Model as CellposeBaseModel import torch from cellacdc import myutils from . import AvailableModelsv2 diff --git a/cellacdc/models/cellpose_v3/__init__.py b/cellacdc/segmenters/cellpose_v3/__init__.py similarity index 100% rename from cellacdc/models/cellpose_v3/__init__.py rename to cellacdc/segmenters/cellpose_v3/__init__.py diff --git a/cellacdc/models/cellpose_v3/_denoise.py b/cellacdc/segmenters/cellpose_v3/_denoise.py similarity index 99% rename from cellacdc/models/cellpose_v3/_denoise.py rename to cellacdc/segmenters/cellpose_v3/_denoise.py index 46f74f8aa..975df573d 100644 --- a/cellacdc/models/cellpose_v3/_denoise.py +++ b/cellacdc/segmenters/cellpose_v3/_denoise.py @@ -7,7 +7,7 @@ import os from cellacdc import myutils -from cellacdc.models._cellpose_base.acdcSegment import (_initialize_image, GPUDirectMLGPUCPU, +from cellacdc.segmenters._cellpose_base.acdcSegment import (_initialize_image, GPUDirectMLGPUCPU, cpu_gpu_directml_gpu, check_directml_gpu_gpu, setup_gpu_direct_ml, _get_normalize_params, DealWithSecondChannelOptions, check_deal_with_second_channel) @@ -412,4 +412,4 @@ def _acdc_eval(self, image, eval_kwargs): def url_help(): - return 'https://www.biorxiv.org/content/10.1101/2024.02.10.579780v1' \ No newline at end of file + return 'https://www.biorxiv.org/content/10.1101/2024.02.10.579780v1' diff --git a/cellacdc/models/cellpose_v3/acdcSegment.py b/cellacdc/segmenters/cellpose_v3/acdcSegment.py similarity index 98% rename from cellacdc/models/cellpose_v3/acdcSegment.py rename to cellacdc/segmenters/cellpose_v3/acdcSegment.py index 66e4d034e..4881782dc 100644 --- a/cellacdc/models/cellpose_v3/acdcSegment.py +++ b/cellacdc/segmenters/cellpose_v3/acdcSegment.py @@ -1,13 +1,13 @@ import os from cellacdc import myutils, printl -from cellacdc.models._cellpose_base.acdcSegment import Model as CellposeBaseModel -from cellacdc.models._cellpose_base.acdcSegment import (BackboneOptions, GPUDirectMLGPUCPU, cpu_gpu_directml_gpu, +from cellacdc.segmenters._cellpose_base.acdcSegment import Model as CellposeBaseModel +from cellacdc.segmenters._cellpose_base.acdcSegment import (BackboneOptions, GPUDirectMLGPUCPU, cpu_gpu_directml_gpu, check_directml_gpu_gpu, setup_gpu_direct_ml, _get_normalize_params, DealWithSecondChannelOptions) import torch from . import AvailableModelsv3, AvailableModelsv3Denoise -from cellacdc.models._cellpose_base.acdcSegment import _initialize_image +from cellacdc.segmenters._cellpose_base.acdcSegment import _initialize_image from cellacdc._types import NotGUIParam @@ -157,7 +157,7 @@ def __init__( self.denoiseModel = None if denoise_before_segmentation: - from cellacdc.models.cellpose_v3 import _denoise + from cellacdc.segmenters.cellpose_v3 import _denoise self.denoiseModel = _denoise.CellposeDenoiseModel( device_type=device_type, device=device, denoise_model=denoise_model, diff --git a/cellacdc/models/cellpose_v4/__init__.py b/cellacdc/segmenters/cellpose_v4/__init__.py similarity index 100% rename from cellacdc/models/cellpose_v4/__init__.py rename to cellacdc/segmenters/cellpose_v4/__init__.py diff --git a/cellacdc/models/cellpose_v4/acdcSegment.py b/cellacdc/segmenters/cellpose_v4/acdcSegment.py similarity index 98% rename from cellacdc/models/cellpose_v4/acdcSegment.py rename to cellacdc/segmenters/cellpose_v4/acdcSegment.py index d0eb2e007..191afb6ed 100644 --- a/cellacdc/models/cellpose_v4/acdcSegment.py +++ b/cellacdc/segmenters/cellpose_v4/acdcSegment.py @@ -1,7 +1,7 @@ import os from cellacdc import myutils, printl import torch -from cellacdc.models._cellpose_base.acdcSegment import (Model as CellposeBaseModel, +from cellacdc.segmenters._cellpose_base.acdcSegment import (Model as CellposeBaseModel, GPUDirectMLGPUCPU, cpu_gpu_directml_gpu, check_directml_gpu_gpu, @@ -359,4 +359,4 @@ def segment3DT(self, video_data, signals=None, **kwargs): return labels def url_help(): - return 'https://cellpose.readthedocs.io/en/latest/api.html' \ No newline at end of file + return 'https://cellpose.readthedocs.io/en/latest/api.html' diff --git a/cellacdc/models/cellsam/__init__.py b/cellacdc/segmenters/cellsam/__init__.py similarity index 100% rename from cellacdc/models/cellsam/__init__.py rename to cellacdc/segmenters/cellsam/__init__.py diff --git a/cellacdc/models/cellsam/acdcSegment.py b/cellacdc/segmenters/cellsam/acdcSegment.py similarity index 100% rename from cellacdc/models/cellsam/acdcSegment.py rename to cellacdc/segmenters/cellsam/acdcSegment.py diff --git a/cellacdc/models/delta/__init__.py b/cellacdc/segmenters/delta/__init__.py similarity index 100% rename from cellacdc/models/delta/__init__.py rename to cellacdc/segmenters/delta/__init__.py diff --git a/cellacdc/models/delta/acdcSegment.py b/cellacdc/segmenters/delta/acdcSegment.py similarity index 100% rename from cellacdc/models/delta/acdcSegment.py rename to cellacdc/segmenters/delta/acdcSegment.py diff --git a/cellacdc/models/omnipose/__init__.py b/cellacdc/segmenters/omnipose/__init__.py similarity index 100% rename from cellacdc/models/omnipose/__init__.py rename to cellacdc/segmenters/omnipose/__init__.py diff --git a/cellacdc/models/omnipose/acdcSegment.py b/cellacdc/segmenters/omnipose/acdcSegment.py similarity index 100% rename from cellacdc/models/omnipose/acdcSegment.py rename to cellacdc/segmenters/omnipose/acdcSegment.py diff --git a/cellacdc/models/omnipose_custom/__init__.py b/cellacdc/segmenters/omnipose_custom/__init__.py similarity index 100% rename from cellacdc/models/omnipose_custom/__init__.py rename to cellacdc/segmenters/omnipose_custom/__init__.py diff --git a/cellacdc/models/omnipose_custom/acdcSegment.py b/cellacdc/segmenters/omnipose_custom/acdcSegment.py similarity index 95% rename from cellacdc/models/omnipose_custom/acdcSegment.py rename to cellacdc/segmenters/omnipose_custom/acdcSegment.py index 949f435b6..7150d7b76 100644 --- a/cellacdc/models/omnipose_custom/acdcSegment.py +++ b/cellacdc/segmenters/omnipose_custom/acdcSegment.py @@ -9,7 +9,7 @@ from cellpose_omni import models -from cellacdc.models.omnipose import acdcSegment as cp_omni +from cellacdc.segmenters.omnipose import acdcSegment as cp_omni from omnipose.core import OMNI_MODELS from cellacdc import printl diff --git a/cellacdc/models/pomBseen/__init__.py b/cellacdc/segmenters/pomBseen/__init__.py similarity index 100% rename from cellacdc/models/pomBseen/__init__.py rename to cellacdc/segmenters/pomBseen/__init__.py diff --git a/cellacdc/models/pomBseen/acdcSegment.py b/cellacdc/segmenters/pomBseen/acdcSegment.py similarity index 100% rename from cellacdc/models/pomBseen/acdcSegment.py rename to cellacdc/segmenters/pomBseen/acdcSegment.py diff --git a/cellacdc/models/pomBseen_nuclear/__init__.py b/cellacdc/segmenters/pomBseen_nuclear/__init__.py similarity index 100% rename from cellacdc/models/pomBseen_nuclear/__init__.py rename to cellacdc/segmenters/pomBseen_nuclear/__init__.py diff --git a/cellacdc/models/pomBseen_nuclear/acdcSegment.py b/cellacdc/segmenters/pomBseen_nuclear/acdcSegment.py similarity index 100% rename from cellacdc/models/pomBseen_nuclear/acdcSegment.py rename to cellacdc/segmenters/pomBseen_nuclear/acdcSegment.py diff --git a/cellacdc/models/sam2/__init__.py b/cellacdc/segmenters/sam2/__init__.py similarity index 88% rename from cellacdc/models/sam2/__init__.py rename to cellacdc/segmenters/sam2/__init__.py index 65b4ab001..4760da4c5 100644 --- a/cellacdc/models/sam2/__init__.py +++ b/cellacdc/segmenters/sam2/__init__.py @@ -8,7 +8,7 @@ # Get SAM2 models path # Using the same pattern as segment_anything -_, sam_models_path = myutils.get_model_path('sam2', create_temp_dir=False) +_, sam_segmenters_path = myutils.get_model_path('sam2', create_temp_dir=False) # SAM2 model configurations # Format: 'Display Name': ('config_file', 'checkpoint_filename') diff --git a/cellacdc/models/sam2/acdcSegment.py b/cellacdc/segmenters/sam2/acdcSegment.py similarity index 99% rename from cellacdc/models/sam2/acdcSegment.py rename to cellacdc/segmenters/sam2/acdcSegment.py index d50bcb5d3..b05a264f4 100644 --- a/cellacdc/models/sam2/acdcSegment.py +++ b/cellacdc/segmenters/sam2/acdcSegment.py @@ -8,7 +8,7 @@ import skimage.measure -from . import model_types, sam_models_path +from . import model_types, sam_segmenters_path from sam2.build_sam import build_sam2 from sam2.sam2_image_predictor import SAM2ImagePredictor @@ -156,7 +156,7 @@ def __init__( self._input_points_df = input_points_df config_file, sam_checkpoint = model_types[model_type] - sam_checkpoint = os.path.join(sam_models_path, sam_checkpoint) + sam_checkpoint = os.path.join(sam_segmenters_path, sam_checkpoint) sam = build_sam2(config_file=config_file, ckpt_path=sam_checkpoint, device=device) if input_points_df is None: diff --git a/cellacdc/models/segment_anything/__init__.py b/cellacdc/segmenters/segment_anything/__init__.py similarity index 79% rename from cellacdc/models/segment_anything/__init__.py rename to cellacdc/segmenters/segment_anything/__init__.py index 807d1a8ec..26d42f299 100644 --- a/cellacdc/models/segment_anything/__init__.py +++ b/cellacdc/segmenters/segment_anything/__init__.py @@ -5,7 +5,7 @@ import os from cellacdc import segment_anything_weights_filenames -_, sam_models_path = myutils.get_model_path('segment_anything', create_temp_dir=False) +_, sam_segmenters_path = myutils.get_model_path('segment_anything', create_temp_dir=False) model_types = { 'Large': ('default', segment_anything_weights_filenames[0]), diff --git a/cellacdc/models/segment_anything/acdcSegment.py b/cellacdc/segmenters/segment_anything/acdcSegment.py similarity index 99% rename from cellacdc/models/segment_anything/acdcSegment.py rename to cellacdc/segmenters/segment_anything/acdcSegment.py index d0e49480c..9c965b5fd 100644 --- a/cellacdc/models/segment_anything/acdcSegment.py +++ b/cellacdc/segmenters/segment_anything/acdcSegment.py @@ -10,7 +10,7 @@ import skimage.measure -from . import model_types, sam_models_path +from . import model_types, sam_segmenters_path from segment_anything import ( sam_model_registry, SamAutomaticMaskGenerator, SamPredictor @@ -157,7 +157,7 @@ def __init__( self._input_points_df = input_points_df model_type, sam_checkpoint = model_types[model_type] - sam_checkpoint = os.path.join(sam_models_path, sam_checkpoint) + sam_checkpoint = os.path.join(sam_segmenters_path, sam_checkpoint) sam = sam_model_registry[model_type](checkpoint=sam_checkpoint) sam.to(device=device) if input_points_df is None: diff --git a/cellacdc/models/skip_segmentation/__init__.py b/cellacdc/segmenters/skip_segmentation/__init__.py similarity index 100% rename from cellacdc/models/skip_segmentation/__init__.py rename to cellacdc/segmenters/skip_segmentation/__init__.py diff --git a/cellacdc/models/skip_segmentation/acdcSegment.py b/cellacdc/segmenters/skip_segmentation/acdcSegment.py similarity index 100% rename from cellacdc/models/skip_segmentation/acdcSegment.py rename to cellacdc/segmenters/skip_segmentation/acdcSegment.py diff --git a/cellacdc/models/thresholding/__init__.py b/cellacdc/segmenters/thresholding/__init__.py similarity index 100% rename from cellacdc/models/thresholding/__init__.py rename to cellacdc/segmenters/thresholding/__init__.py diff --git a/cellacdc/models/thresholding/acdcSegment.py b/cellacdc/segmenters/thresholding/acdcSegment.py similarity index 100% rename from cellacdc/models/thresholding/acdcSegment.py rename to cellacdc/segmenters/thresholding/acdcSegment.py diff --git a/cellacdc/promptable_models/__init__.py b/cellacdc/segmenters_promptable/__init__.py similarity index 100% rename from cellacdc/promptable_models/__init__.py rename to cellacdc/segmenters_promptable/__init__.py diff --git a/cellacdc/promptable_models/micro-sam/__init__.py b/cellacdc/segmenters_promptable/micro-sam/__init__.py similarity index 100% rename from cellacdc/promptable_models/micro-sam/__init__.py rename to cellacdc/segmenters_promptable/micro-sam/__init__.py diff --git a/cellacdc/promptable_models/micro-sam/acdcSegment.py b/cellacdc/segmenters_promptable/micro-sam/acdcSegment.py similarity index 100% rename from cellacdc/promptable_models/micro-sam/acdcSegment.py rename to cellacdc/segmenters_promptable/micro-sam/acdcSegment.py diff --git a/cellacdc/promptable_models/nnInteractive/__init__.py b/cellacdc/segmenters_promptable/nnInteractive/__init__.py similarity index 100% rename from cellacdc/promptable_models/nnInteractive/__init__.py rename to cellacdc/segmenters_promptable/nnInteractive/__init__.py diff --git a/cellacdc/promptable_models/nnInteractive/acdcPromptSegment.py b/cellacdc/segmenters_promptable/nnInteractive/acdcPromptSegment.py similarity index 99% rename from cellacdc/promptable_models/nnInteractive/acdcPromptSegment.py rename to cellacdc/segmenters_promptable/nnInteractive/acdcPromptSegment.py index 6fe921112..7800d867a 100644 --- a/cellacdc/promptable_models/nnInteractive/acdcPromptSegment.py +++ b/cellacdc/segmenters_promptable/nnInteractive/acdcPromptSegment.py @@ -3,7 +3,7 @@ import numpy as np -from cellacdc.promptable_models.utils import build_combined_mask +from cellacdc.segmenters_promptable.utils import build_combined_mask import torch diff --git a/cellacdc/promptable_models/sam2/__init__.py b/cellacdc/segmenters_promptable/sam2/__init__.py similarity index 100% rename from cellacdc/promptable_models/sam2/__init__.py rename to cellacdc/segmenters_promptable/sam2/__init__.py diff --git a/cellacdc/promptable_models/sam2/acdcPromptSegment.py b/cellacdc/segmenters_promptable/sam2/acdcPromptSegment.py similarity index 97% rename from cellacdc/promptable_models/sam2/acdcPromptSegment.py rename to cellacdc/segmenters_promptable/sam2/acdcPromptSegment.py index a9c7d69c6..29d2245c1 100644 --- a/cellacdc/promptable_models/sam2/acdcPromptSegment.py +++ b/cellacdc/segmenters_promptable/sam2/acdcPromptSegment.py @@ -1,7 +1,7 @@ import os from collections import defaultdict -from cellacdc.promptable_models.utils import build_combined_mask, log_mask_selection +from cellacdc.segmenters_promptable.utils import build_combined_mask, log_mask_selection import numpy as np import cv2 @@ -10,7 +10,7 @@ from sam2.sam2_image_predictor import SAM2ImagePredictor from cellacdc import myutils -from cellacdc.models.sam2 import model_types, sam_models_path +from cellacdc.segmenters.sam2 import model_types, sam_segmenters_path class AvailableModels: @@ -43,7 +43,7 @@ def __init__(self, model_type: AvailableModels = "Large", gpu: bool = True): device = "cpu" config_file, sam_checkpoint = model_types[model_type] - sam_checkpoint = os.path.join(sam_models_path, sam_checkpoint) + sam_checkpoint = os.path.join(sam_segmenters_path, sam_checkpoint) sam = build_sam2(config_file=config_file, ckpt_path=sam_checkpoint, device=device) self.model = SAM2ImagePredictor(sam) diff --git a/cellacdc/promptable_models/segment_anything/__init__.py b/cellacdc/segmenters_promptable/segment_anything/__init__.py similarity index 100% rename from cellacdc/promptable_models/segment_anything/__init__.py rename to cellacdc/segmenters_promptable/segment_anything/__init__.py diff --git a/cellacdc/promptable_models/segment_anything/acdcPromptSegment.py b/cellacdc/segmenters_promptable/segment_anything/acdcPromptSegment.py similarity index 97% rename from cellacdc/promptable_models/segment_anything/acdcPromptSegment.py rename to cellacdc/segmenters_promptable/segment_anything/acdcPromptSegment.py index 20ee1014b..b4421cc1e 100644 --- a/cellacdc/promptable_models/segment_anything/acdcPromptSegment.py +++ b/cellacdc/segmenters_promptable/segment_anything/acdcPromptSegment.py @@ -1,7 +1,7 @@ import os from collections import defaultdict -from cellacdc.promptable_models.utils import build_combined_mask, log_mask_selection +from cellacdc.segmenters_promptable.utils import build_combined_mask, log_mask_selection import numpy as np import cv2 @@ -9,7 +9,7 @@ from segment_anything import sam_model_registry, SamPredictor from cellacdc import myutils -from cellacdc.models.segment_anything import model_types, sam_models_path +from cellacdc.segmenters.segment_anything import model_types, sam_segmenters_path class AvailableModels: @@ -42,7 +42,7 @@ def __init__(self, model_type: AvailableModels = "Large", gpu: bool = False): device = "cpu" model_key, sam_checkpoint = model_types[model_type] - sam_checkpoint = os.path.join(sam_models_path, sam_checkpoint) + sam_checkpoint = os.path.join(sam_segmenters_path, sam_checkpoint) sam = sam_model_registry[model_key](checkpoint=sam_checkpoint) sam.to(device=device) diff --git a/cellacdc/promptable_models/utils.py b/cellacdc/segmenters_promptable/utils.py similarity index 100% rename from cellacdc/promptable_models/utils.py rename to cellacdc/segmenters_promptable/utils.py diff --git a/cellacdc/trackers/DeepSea/DeepSea_tracker.py b/cellacdc/trackers/DeepSea/DeepSea_tracker.py index dd3815bb7..fa378c994 100644 --- a/cellacdc/trackers/DeepSea/DeepSea_tracker.py +++ b/cellacdc/trackers/DeepSea/DeepSea_tracker.py @@ -15,9 +15,9 @@ from deepsea.utils import track_cells from cellacdc import myutils, printl -from cellacdc.models.DeepSea import _init_model, _resize_img -from cellacdc.models.DeepSea import image_size as segm_image_size -from cellacdc.models.DeepSea import _get_segm_transforms +from cellacdc.segmenters.DeepSea import _init_model, _resize_img +from cellacdc.segmenters.DeepSea import image_size as segm_image_size +from cellacdc.segmenters.DeepSea import _get_segm_transforms from cellacdc.core import get_labels_to_IDs_mapper from . import _get_tracker_transforms diff --git a/cellacdc/trackers/DeepSea/__init__.py b/cellacdc/trackers/DeepSea/__init__.py index 477a8e19b..3fc285b4f 100644 --- a/cellacdc/trackers/DeepSea/__init__.py +++ b/cellacdc/trackers/DeepSea/__init__.py @@ -1,4 +1,4 @@ -from cellacdc.models import DeepSea +from cellacdc.segmenters import DeepSea from deepsea import tracker_transforms diff --git a/cellacdc/trackers/delta/__init__.py b/cellacdc/trackers/delta/__init__.py index 5d7863ed5..9dde410d6 100644 --- a/cellacdc/trackers/delta/__init__.py +++ b/cellacdc/trackers/delta/__init__.py @@ -4,4 +4,4 @@ @author: jroberts / jamesr787 """ -from cellacdc.models import delta \ No newline at end of file +from cellacdc.segmenters import delta \ No newline at end of file diff --git a/cellacdc/viewmodels/__init__.py b/cellacdc/viewmodels/__init__.py new file mode 100644 index 000000000..94ccc4f91 --- /dev/null +++ b/cellacdc/viewmodels/__init__.py @@ -0,0 +1,183 @@ +"""GUI view models.""" + +from cellacdc.domain.custom_annotations import ( + CustomAnnotationColumnResult, + CustomAnnotationFrameUpdate, +) +from cellacdc.domain.curvature import CurvatureLabelPaintResult +from cellacdc.domain.edit_id import ManualEditTrackingResult +from cellacdc.domain.frame_metadata import AcdcFrameMetadataResult +from cellacdc.domain.labels import ( + DeletedRoiApplyResult, + DeletedRoiRestoreResult, + LabelBorderClearResult, + LabelHoleFillResult, + LabelIdMappingResult, + LabelIdsRemovalResult, + LabelMoveResult, + LabelRegionSelectionResult, + LabelResizeResult, + LabelRoiIndexResult, +) +from cellacdc.domain.lineage import ( + LineageAnnotationsRemovalResult, + LineageFutureRemovalResult, +) +from cellacdc.domain.tracking import ( + FutureIdPropagationScan, + LostNewIdsResult, + TrackedLostIdsResult, +) +from cellacdc.domain.visited_frames import LastVisitedFrameUpdate + +from .app_shell_viewmodel import AppShellViewModel +from .actions_viewmodel import ActionsViewModel +from .annotation_display_viewmodel import AnnotationDisplayViewModel +from .brush_tools_viewmodel import BrushToolsViewModel +from .canvas_context_menu_viewmodel import CanvasContextMenuViewModel +from .canvas_drawing_viewmodel import CanvasDrawingViewModel +from .canvas_events_viewmodel import CanvasEventsViewModel +from .canvas_hover_viewmodel import CanvasHoverViewModel +from .canvas_right_image_viewmodel import CanvasRightImageViewModel +from .canvas_selection_viewmodel import CanvasSelectionViewModel +from .canvas_tool_viewmodel import CanvasToolViewModel +from .cell_cycle_viewmodel import CellCycleViewModel +from .cca_edits import CcaEditViewModel, CcaFrameEditResult +from .cca_workflows import CcaWorkflowViewModel +from .curvature_viewmodel import CurvatureViewModel +from .custom_annotations_viewmodel import CustomAnnotationsViewModel +from .data_loading_viewmodel import DataLoadingViewModel +from .deleted_rois_viewmodel import DeletedRoisViewModel +from .display_decorations_viewmodel import DisplayDecorationsViewModel +from .draw_clear_region_viewmodel import DrawClearRegionViewModel +from .edit_id import EditIdViewModel +from .exporting_viewmodel import ExportingViewModel +from .frame_metadata import FrameMetadataViewModel +from .frame_navigation_viewmodel import FrameNavigationViewModel +from .formatting import FormattingViewModel +from .geometry import GeometryViewModel +from .graphics_viewmodel import GraphicsViewModel +from .image_controls_viewmodel import ImageControlsViewModel +from .image_display_viewmodel import ImageDisplayViewModel +from .label_editing_viewmodel import LabelEditingViewModel +from .label_edits import LabelEditViewModel +from .label_roi_viewmodel import LabelRoiViewModel +from .label_transform_tools_viewmodel import LabelTransformToolsViewModel +from .layout_controls_viewmodel import LayoutControlsViewModel +from .lineage import LineageViewModel +from .lineage_interactions_viewmodel import LineageInteractionsViewModel +from .magic_prompts_viewmodel import MagicPromptsViewModel +from .main_menu_viewmodel import MainMenuViewModel +from .main_toolbar_viewmodel import MainToolbarViewModel +from .main import MainGuiViewModel +from .measurements_viewmodel import MeasurementsViewModel +from .mode_controls_viewmodel import ModeControlsViewModel +from .model_registry import ModelRegistryViewModel +from .object_counts import ObjectCountViewModel +from .object_cleanup_viewmodel import ObjectCleanupViewModel +from .object_properties_viewmodel import ObjectPropertiesViewModel +from .object_search_viewmodel import ObjectSearchViewModel +from .points import PointsViewModel +from .points_layers_viewmodel import PointsLayersViewModel +from .preprocessing_viewmodel import PreprocessingViewModel +from .quick_settings_viewmodel import QuickSettingsViewModel +from .saving_viewmodel import SavingViewModel +from .seg_for_lost_ids_viewmodel import SegForLostIdsViewModel +from .segmentation_viewmodel import SegmentationViewModel +from .session_viewmodel import SessionViewModel +from .status_hover_viewmodel import StatusHoverViewModel +from .tables import TableViewModel +from .tool_activation_viewmodel import ToolActivationViewModel +from .tracking_viewmodel import TrackingViewModel +from .undo_redo_viewmodel import UndoRedoViewModel +from .worker_viewmodel import WorkerViewModel +from .window_events_viewmodel import WindowEventsViewModel +from .workspace import WorkspaceViewModel + +__all__ = [ + 'AcdcFrameMetadataResult', + 'ActionsViewModel', + 'AnnotationDisplayViewModel', + 'AppShellViewModel', + 'BrushToolsViewModel', + 'CanvasContextMenuViewModel', + 'CanvasDrawingViewModel', + 'CanvasEventsViewModel', + 'CanvasHoverViewModel', + 'CanvasRightImageViewModel', + 'CanvasSelectionViewModel', + 'CanvasToolViewModel', + 'CellCycleViewModel', + 'CcaEditViewModel', + 'CcaFrameEditResult', + 'CcaWorkflowViewModel', + 'CurvatureLabelPaintResult', + 'CurvatureViewModel', + 'CustomAnnotationColumnResult', + 'CustomAnnotationFrameUpdate', + 'CustomAnnotationsViewModel', + 'DataLoadingViewModel', + 'DeletedRoisViewModel', + 'DeletedRoiApplyResult', + 'DeletedRoiRestoreResult', + 'DisplayDecorationsViewModel', + 'DrawClearRegionViewModel', + 'EditIdViewModel', + 'ExportingViewModel', + 'FrameMetadataViewModel', + 'FrameNavigationViewModel', + 'FormattingViewModel', + 'GeometryViewModel', + 'GraphicsViewModel', + 'ImageControlsViewModel', + 'ImageDisplayViewModel', + 'FutureIdPropagationScan', + 'LabelBorderClearResult', + 'LabelEditingViewModel', + 'LabelHoleFillResult', + 'LabelEditViewModel', + 'LabelIdMappingResult', + 'LabelIdsRemovalResult', + 'LabelMoveResult', + 'LabelRegionSelectionResult', + 'LabelResizeResult', + 'LabelRoiIndexResult', + 'LabelRoiViewModel', + 'LabelTransformToolsViewModel', + 'LayoutControlsViewModel', + 'LastVisitedFrameUpdate', + 'LineageAnnotationsRemovalResult', + 'LineageFutureRemovalResult', + 'LineageInteractionsViewModel', + 'LineageViewModel', + 'MagicPromptsViewModel', + 'MainMenuViewModel', + 'MainToolbarViewModel', + 'LostNewIdsResult', + 'MainGuiViewModel', + 'ManualEditTrackingResult', + 'MeasurementsViewModel', + 'ModeControlsViewModel', + 'ModelRegistryViewModel', + 'ObjectCountViewModel', + 'ObjectCleanupViewModel', + 'ObjectPropertiesViewModel', + 'ObjectSearchViewModel', + 'PointsViewModel', + 'PointsLayersViewModel', + 'PreprocessingViewModel', + 'QuickSettingsViewModel', + 'SavingViewModel', + 'SegForLostIdsViewModel', + 'SegmentationViewModel', + 'SessionViewModel', + 'StatusHoverViewModel', + 'TableViewModel', + 'ToolActivationViewModel', + 'TrackedLostIdsResult', + 'TrackingViewModel', + 'UndoRedoViewModel', + 'WorkerViewModel', + 'WindowEventsViewModel', + 'WorkspaceViewModel', +] diff --git a/cellacdc/viewmodels/actions_viewmodel.py b/cellacdc/viewmodels/actions_viewmodel.py new file mode 100644 index 000000000..d02f3eb40 --- /dev/null +++ b/cellacdc/viewmodels/actions_viewmodel.py @@ -0,0 +1,56 @@ +"""View-model contracts for GUI actions and shortcuts.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.actions_model import ActionsModel + +from .app_shell_viewmodel import AppShellViewModel +from .model_registry import ModelRegistryViewModel + + +@dataclass(frozen=True) +class ActionsViewModel: + """Application-facing actions and shortcut decisions.""" + + model: ActionsModel = field(default_factory=ActionsModel) + app_shell: AppShellViewModel = field(default_factory=AppShellViewModel) + model_registry: ModelRegistryViewModel = field( + default_factory=ModelRegistryViewModel + ) + + @property + def keyboard_shortcuts_section(self) -> str: + return self.model.keyboard_shortcuts_section + + @property + def delete_object_section(self) -> str: + return self.model.delete_object_section + + @property + def delete_key_option(self) -> str: + return self.model.delete_key_option + + @property + def delete_button_option(self) -> str: + return self.model.delete_button_option + + def default_delete_object_texts(self, *, is_mac: bool) -> tuple[str, str]: + return self.model.default_delete_object_texts(is_mac=is_mac) + + def sanitize_key_sequence_text(self, text) -> str: + return self.model.sanitize_key_sequence_text(text) + + def delete_object_button_text(self, *, is_left_click: bool) -> str: + return self.model.delete_object_button_text( + is_left_click=is_left_click + ) + + def delete_object_button_is_left_click(self, text: str) -> bool: + return self.model.delete_object_button_is_left_click(text) + + def should_restore_default_delete_action(self, *, had_error: bool) -> bool: + return self.model.should_restore_default_delete_action( + had_error=had_error + ) diff --git a/cellacdc/viewmodels/annotation_display_viewmodel.py b/cellacdc/viewmodels/annotation_display_viewmodel.py new file mode 100644 index 000000000..26f3a4897 --- /dev/null +++ b/cellacdc/viewmodels/annotation_display_viewmodel.py @@ -0,0 +1,448 @@ +"""View-model contracts for annotation display workflows.""" + +from __future__ import annotations + +from typing import Mapping + +try: + from qtpy.QtCore import QObject, Signal +except ModuleNotFoundError: # pragma: no cover - exercised without GUI extras + class QObject: + def __init__(self, *args, **kwargs) -> None: + super().__init__() + + class _FallbackBoundSignal: + def __init__(self) -> None: + self._slots = [] + + def connect(self, slot) -> None: + self._slots.append(slot) + + def emit(self, *args) -> None: + for slot in tuple(self._slots): + slot(*args) + + class Signal: + def __init__(self, *args) -> None: + self._name = '' + + def __set_name__(self, owner, name) -> None: + self._name = f'__signal_{name}' + + def __get__(self, instance, owner): + if instance is None: + return self + return instance.__dict__.setdefault( + self._name, + _FallbackBoundSignal(), + ) + +from cellacdc.models.annotation_display_model import ( + AnnotationModeChangePlan, + AnnotationDisplaySettingsRestorePlan, + AnnotationOption, + AnnotationOptionChangePlan, + AnnotationOptionsFromModeTextPlan, + AnnotationOptionState, + AnnotationDisplayModel, + AnnotationSide, + PixelModeChangePlan, + TextResolutionChangePlan, + TreeAnnotationInfoModePlan, + Visible3DSegmentationWidgetsPlan, + ZDepthAnnotationOptionsPlan, + ZNeighborHighlightCheckboxPlan, +) + +from .custom_annotations_viewmodel import CustomAnnotationsViewModel +from .edit_id import EditIdViewModel +from .geometry import GeometryViewModel +from .label_edits import LabelEditViewModel +from .lineage import LineageViewModel +from .model_registry import ModelRegistryViewModel + + +class AnnotationDisplayViewModel(QObject): + """Application-facing annotation display decisions and commands.""" + + settingUpdateRequested = Signal(str, object) + textAnnotationFlagsChanged = Signal(int, bool, bool) + imageRefreshRequested = Signal() + eraserTempResetRequested = Signal() + annotationOptionStatesChanged = Signal(str, object) + annotationModeTextUpdateRequested = Signal(str, str, bool) + textAnnotationPixelModeChanged = Signal(bool) + logInfoRequested = Signal(str) + pixelModeActionDisabledChanged = Signal(bool) + textResolutionChangeRequested = Signal(str) + treeAnnotationMenuActionRequested = Signal(str, str, bool, bool) + labelTreeAnnotationsEnabledChanged = Signal(bool) + genNumTreeAnnotationsEnabledChanged = Signal(bool) + allTextAnnotationsRefreshRequested = Signal() + annotationOptionDisabledChanged = Signal(str, str, bool) + annotationOptionVisibleChanged = Signal(str, str, bool) + annotationOptionCheckedChanged = Signal(str, str, bool) + zNeighborHighlightVisibleChanged = Signal(bool) + zNeighborHighlightCheckedChanged = Signal(bool) + zNeighborHighlightToggleConnectionRequested = Signal() + annotationModeComboboxRestoreRequested = Signal(str, str) + addNewIdsWhitelistToggleChanged = Signal(bool) + annotationModeRestoreCallbackRequested = Signal(str) + + def __init__( + self, + model: AnnotationDisplayModel | None = None, + custom_annotations: CustomAnnotationsViewModel | None = None, + edit_id: EditIdViewModel | None = None, + geometry: GeometryViewModel | None = None, + label_edits: LabelEditViewModel | None = None, + lineage: LineageViewModel | None = None, + model_registry: ModelRegistryViewModel | None = None, + ) -> None: + super().__init__() + self.model = model or AnnotationDisplayModel() + self.custom_annotations = ( + custom_annotations or CustomAnnotationsViewModel() + ) + self.edit_id = edit_id or EditIdViewModel() + self.geometry = geometry or GeometryViewModel() + self.label_edits = label_edits or LabelEditViewModel() + self.lineage = lineage or LineageViewModel() + self.model_registry = model_registry or ModelRegistryViewModel() + + def right_annotation_mode(self, **kwargs) -> str: + return self.model.right_annotation_mode(**kwargs) + + def text_annotation_flags(self, **kwargs) -> tuple[bool, bool]: + return self.model.text_annotation_flags(**kwargs) + + def annotation_mode_text(self, **kwargs) -> str: + return self.model.annotation_mode_text(**kwargs) + + def annotation_flags_from_mode_text(self, text: str) -> dict[str, bool]: + return self.model.annotation_flags_from_mode_text(text) + + def annotation_option_state_from_mode_text( + self, + text: str, + *, + num_zslices: bool = False, + ) -> AnnotationOptionState: + return self.model.annotation_option_state_from_mode_text( + text, + num_zslices=num_zslices, + ) + + def contours_requested(self, **kwargs) -> bool: + return self.model.contours_requested(**kwargs) + + def moth_bud_lines_requested(self, **kwargs) -> bool: + return self.model.moth_bud_lines_requested(**kwargs) + + def should_draw_moth_bud_line(self, **kwargs) -> bool: + return self.model.should_draw_moth_bud_line(**kwargs) + + def should_draw_lineage_tree_lines(self, **kwargs) -> bool: + return self.model.should_draw_lineage_tree_lines(**kwargs) + + def annotation_mode_setting_update( + self, + side: AnnotationSide, + how: str, + ) -> tuple[str, str]: + return self.model.annotation_mode_setting_update(side, how) + + def change_annotation_mode( + self, + *, + side: AnnotationSide, + how: str, + save_settings: bool, + annot_cca_checked: bool, + annot_ids_checked: bool, + mode: str, + is_data_loading: bool, + eraser_checked: bool = False, + ) -> AnnotationModeChangePlan: + plan = self.model.annotation_mode_change_plan( + side=side, + how=how, + save_settings=save_settings, + annot_cca_checked=annot_cca_checked, + annot_ids_checked=annot_ids_checked, + mode=mode, + is_data_loading=is_data_loading, + eraser_checked=eraser_checked, + ) + if plan.setting_update is not None: + setting, value = plan.setting_update + self.settingUpdateRequested.emit(setting, value) + self.textAnnotationFlagsChanged.emit( + plan.text_annotation_index, + plan.is_cca_annotation, + plan.is_id_annotation, + ) + if plan.should_refresh_images: + self.imageRefreshRequested.emit() + if plan.should_reset_eraser_temp: + self.eraserTempResetRequested.emit() + return plan + + def change_annotation_options( + self, + *, + side: AnnotationSide, + clicked_option: AnnotationOption | None, + save_settings: bool, + ids: bool, + cca: bool, + contours: bool, + segm_masks: bool, + mother_bud_lines: bool, + num_zslices: bool, + nothing: bool, + ) -> AnnotationOptionChangePlan: + plan = self.model.annotation_option_change_plan( + side=side, + clicked_option=clicked_option, + save_settings=save_settings, + state=AnnotationOptionState( + ids=ids, + cca=cca, + contours=contours, + segm_masks=segm_masks, + mother_bud_lines=mother_bud_lines, + num_zslices=num_zslices, + nothing=nothing, + ), + ) + self.annotationOptionStatesChanged.emit(side, plan.state) + self.annotationModeTextUpdateRequested.emit( + side, + plan.mode_text, + plan.save_settings, + ) + return plan + + def refresh_annotation_mode_text( + self, + *, + side: AnnotationSide, + save_settings: bool, + ids: bool, + cca: bool, + contours: bool, + segm_masks: bool, + mother_bud_lines: bool, + nothing: bool, + ) -> str: + mode_text = self.model.annotation_mode_text( + ids=ids, + cca=cca, + contours=contours, + segm_masks=segm_masks, + mother_bud_lines=mother_bud_lines, + nothing=nothing, + ) + self.annotationModeTextUpdateRequested.emit( + side, + mode_text, + save_settings, + ) + return mode_text + + def sync_annotation_options_from_mode_text( + self, + *, + left_text: str, + right_text: str, + left_num_zslices: bool = False, + right_num_zslices: bool = False, + ) -> AnnotationOptionsFromModeTextPlan: + plan = self.model.annotation_options_from_mode_text_plan( + left_text=left_text, + right_text=right_text, + left_num_zslices=left_num_zslices, + right_num_zslices=right_num_zslices, + ) + for side, state in plan.state_updates: + self.annotationOptionStatesChanged.emit(side, state) + return plan + + def restore_saved_settings( + self, + *, + settings_values: Mapping[str, object], + left_num_zslices: bool = False, + right_num_zslices: bool = False, + ) -> AnnotationDisplaySettingsRestorePlan: + plan = self.model.restore_saved_settings_plan(settings_values) + self.annotationModeComboboxRestoreRequested.emit( + 'left', + plan.left_mode, + ) + self.annotationModeComboboxRestoreRequested.emit( + 'right', + plan.right_mode, + ) + self.addNewIdsWhitelistToggleChanged.emit( + plan.add_new_ids_whitelist_toggle + ) + self.sync_annotation_options_from_mode_text( + left_text=plan.left_mode, + right_text=plan.right_mode, + left_num_zslices=left_num_zslices, + right_num_zslices=right_num_zslices, + ) + self.annotationModeRestoreCallbackRequested.emit('left') + self.annotationModeRestoreCallbackRequested.emit('right') + return plan + + def pixel_mode_setting_value(self, checked: bool) -> int: + return self.model.pixel_mode_setting_value(checked) + + def change_pixel_mode( + self, + *, + checked: bool, + is_data_loaded: bool, + high_resolution: bool, + ) -> PixelModeChangePlan: + plan = self.model.pixel_mode_change_plan( + checked=checked, + is_data_loaded=is_data_loaded, + high_resolution=high_resolution, + ) + setting, value = plan.setting_update + self.settingUpdateRequested.emit(setting, value) + if plan.should_update_text_pixel_mode: + self.textAnnotationPixelModeChanged.emit(checked) + if plan.should_refresh_images: + self.imageRefreshRequested.emit() + return plan + + def change_text_resolution( + self, + *, + high_resolution: bool, + is_data_loaded: bool, + ) -> TextResolutionChangePlan: + plan = self.model.text_resolution_change_plan( + high_resolution=high_resolution, + is_data_loaded=is_data_loaded, + ) + self.logInfoRequested.emit(plan.log_message) + self.pixelModeActionDisabledChanged.emit(plan.pixel_mode_disabled) + if plan.should_update_annotations: + self.textResolutionChangeRequested.emit(plan.mode) + if plan.should_refresh_images: + self.imageRefreshRequested.emit() + return plan + + def change_label_tree_annotations(self, checked: bool) -> None: + self.labelTreeAnnotationsEnabledChanged.emit(checked) + + def change_gen_num_tree_annotations(self, checked: bool) -> None: + self.genNumTreeAnnotationsEnabledChanged.emit(checked) + + def change_tree_annotation_info_mode( + self, + checked: bool, + ) -> TreeAnnotationInfoModePlan: + plan = self.model.tree_annotation_info_mode_plan(checked) + self.treeAnnotationMenuActionRequested.emit( + 'id', + plan.action_text_contains, + plan.enabled, + plan.action_checked, + ) + self.treeAnnotationMenuActionRequested.emit( + 'gen_num', + plan.action_text_contains, + plan.enabled, + plan.action_checked, + ) + self.labelTreeAnnotationsEnabledChanged.emit( + plan.label_tree_annotations_enabled + ) + self.genNumTreeAnnotationsEnabledChanged.emit( + plan.gen_num_tree_annotations_enabled + ) + if plan.should_refresh_annotations: + self.allTextAnnotationsRefreshRequested.emit() + return plan + + def enable_z_depth_annotation_options( + self, + *, + is_3d: bool, + ids: bool, + cca: bool, + contours: bool, + segm_masks: bool, + mother_bud_lines: bool, + num_zslices: bool, + nothing: bool, + ) -> ZDepthAnnotationOptionsPlan: + plan = self.model.z_depth_annotation_options_plan( + is_3d=is_3d, + state=AnnotationOptionState( + ids=ids, + cca=cca, + contours=contours, + segm_masks=segm_masks, + mother_bud_lines=mother_bud_lines, + num_zslices=num_zslices, + nothing=nothing, + ), + ) + if not plan.should_apply: + return plan + + for option, disabled in plan.disabled_updates: + self.annotationOptionDisabledChanged.emit( + 'left', + option, + disabled, + ) + self.annotationOptionStatesChanged.emit('left', plan.state) + option_plan = self.model.annotation_option_change_plan( + side='left', + state=plan.state, + clicked_option=plan.clicked_option, + save_settings=plan.save_settings, + ) + self.annotationOptionStatesChanged.emit('left', option_plan.state) + self.annotationModeTextUpdateRequested.emit( + 'left', + option_plan.mode_text, + option_plan.save_settings, + ) + return plan + + def update_visible_3d_segmentation_widgets( + self, + *, + is_3d: bool, + ) -> Visible3DSegmentationWidgetsPlan: + plan = self.model.visible_3d_segmentation_widgets_plan(is_3d=is_3d) + for side, option, visible in plan.visible_updates: + self.annotationOptionVisibleChanged.emit(side, option, visible) + for side, option, checked in plan.checked_updates: + self.annotationOptionCheckedChanged.emit(side, option, checked) + return plan + + def update_z_neighbor_highlight_checkbox( + self, + *, + is_3d: bool, + ) -> ZNeighborHighlightCheckboxPlan: + plan = self.model.z_neighbor_highlight_checkbox_plan(is_3d=is_3d) + if not plan.should_apply: + return plan + + self.zNeighborHighlightVisibleChanged.emit(plan.visible) + self.zNeighborHighlightCheckedChanged.emit(plan.checked) + if plan.should_connect_toggle: + self.zNeighborHighlightToggleConnectionRequested.emit() + return plan diff --git a/cellacdc/viewmodels/app_shell_viewmodel.py b/cellacdc/viewmodels/app_shell_viewmodel.py new file mode 100644 index 000000000..958b74ffb --- /dev/null +++ b/cellacdc/viewmodels/app_shell_viewmodel.py @@ -0,0 +1,29 @@ +"""View-model contracts for application shell services.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.app_shell_model import AppShellModel + + +@dataclass(frozen=True) +class AppShellViewModel: + """Application-facing commands for app metadata and help services.""" + + model: AppShellModel = field(default_factory=AppShellModel) + + def read_version(self) -> str: + return self.model.read_version() + + def tooltips_from_docs(self) -> dict: + return self.model.tooltips_from_docs() + + def browse_docs(self): + return self.model.browse_docs() + + def show_in_file_manager(self, path: str): + return self.model.show_in_file_manager(path) + + def rename_qrc_resources_file(self, color_scheme: str): + return self.model.rename_qrc_resources_file(color_scheme) diff --git a/cellacdc/viewmodels/brush_tools_viewmodel.py b/cellacdc/viewmodels/brush_tools_viewmodel.py new file mode 100644 index 000000000..52ff986c6 --- /dev/null +++ b/cellacdc/viewmodels/brush_tools_viewmodel.py @@ -0,0 +1,87 @@ +"""View-model contracts for brush and eraser tools.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from cellacdc.models.brush_tools_model import BrushToolsModel + + +@dataclass(frozen=True) +class BrushToolsViewModel: + """Application-facing brush/eraser decisions.""" + + model: BrushToolsModel = field(default_factory=BrushToolsModel) + + def checked_setting_value(self, checked: bool) -> str: + return self.model.checked_setting_value(checked) + + def default_delete_object_info_value(self) -> str: + return self.model.default_delete_object_info_value() + + def should_show_delete_object_info(self, setting_value: Any) -> bool: + return self.model.should_show_delete_object_info(setting_value) + + def delete_object_info_value( + self, + do_not_show_again_checked: bool, + ) -> str: + return self.model.delete_object_info_value(do_not_show_again_checked) + + def should_fill_holes( + self, + sender: str, + *, + auto_fill_checked: bool, + ) -> bool: + return self.model.should_fill_holes( + sender, + auto_fill_checked=auto_fill_checked, + ) + + def brush_toolbar_visible( + self, + edit_id_visible: bool, + *, + brush_size_visible: bool, + auto_fill_visible: bool, + auto_hide_visible: bool, + ) -> bool: + return self.model.brush_toolbar_visible( + edit_id_visible, + brush_size_visible=brush_size_visible, + auto_fill_visible=auto_fill_visible, + auto_hide_visible=auto_hide_visible, + ) + + def disk_mask(self, brush_size: int): + return self.model.disk_mask(brush_size) + + def disk_mask_bounds( + self, + image_shape: tuple[int, int], + brush_size: int, + xdata: int, + ydata: int, + disk_mask, + ): + return self.model.disk_mask_bounds( + image_shape, + brush_size, + xdata, + ydata, + disk_mask, + ) + + def magic_wand_flood_tolerance( + self, + tolerance_percent: float, + image_min: float, + image_max: float, + ): + return self.model.magic_wand_flood_tolerance( + tolerance_percent, + image_min, + image_max, + ) diff --git a/cellacdc/viewmodels/canvas_context_menu_viewmodel.py b/cellacdc/viewmodels/canvas_context_menu_viewmodel.py new file mode 100644 index 000000000..68ce07ad0 --- /dev/null +++ b/cellacdc/viewmodels/canvas_context_menu_viewmodel.py @@ -0,0 +1,51 @@ +"""View-model contracts for canvas context menus.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.canvas_context_menu_model import ( + CanvasContextMenuModel, + DeletedRoiClickDecision, +) + + +@dataclass(frozen=True) +class CanvasContextMenuViewModel: + """Application-facing canvas context-menu commands.""" + + model: CanvasContextMenuModel = field( + default_factory=CanvasContextMenuModel + ) + + @property + def scale_bar_target(self) -> str: + return self.model.scale_bar_target + + @property + def timestamp_target(self) -> str: + return self.model.timestamp_target + + def image_gradient_menu_target( + self, + *, + scale_bar_highlighted: bool, + timestamp_highlighted: bool, + ) -> str: + return self.model.image_gradient_menu_target( + scale_bar_highlighted=scale_bar_highlighted, + timestamp_highlighted=timestamp_highlighted, + ) + + def deleted_roi_click_decision( + self, + *, + clicked_on_roi: bool, + left_click: bool, + right_click: bool, + ) -> DeletedRoiClickDecision: + return self.model.deleted_roi_click_decision( + clicked_on_roi=clicked_on_roi, + left_click=left_click, + right_click=right_click, + ) diff --git a/cellacdc/viewmodels/canvas_drawing_viewmodel.py b/cellacdc/viewmodels/canvas_drawing_viewmodel.py new file mode 100644 index 000000000..ed55661c6 --- /dev/null +++ b/cellacdc/viewmodels/canvas_drawing_viewmodel.py @@ -0,0 +1,60 @@ +"""View-model contracts for canvas drawing interactions.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import numpy as np + +from cellacdc.models.canvas_drawing_model import CanvasDrawingModel + +from .geometry import GeometryViewModel +from .label_edits import LabelEditViewModel + + +@dataclass(frozen=True) +class CanvasDrawingViewModel: + """Application-facing canvas drawing decisions and transforms.""" + + model: CanvasDrawingModel = field(default_factory=CanvasDrawingModel) + geometry: GeometryViewModel = field(default_factory=GeometryViewModel) + label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) + + def is_viewer_mode(self, mode: str) -> bool: + return mode == self.model.viewer_mode + + def is_in_bounds(self, x: int, y: int, width: int, height: int) -> bool: + return self.geometry.is_in_bounds(x, y, width, height) + + def should_process_canvas_event(self, *, mode: str, in_bounds: bool) -> bool: + return self.model.should_process_canvas_event( + mode=mode, + in_bounds=in_bounds, + ) + + def should_clear_after_out_of_bounds(self, *, image: str) -> bool: + return self.model.should_clear_after_out_of_bounds(image=image) + + def binary_fill_holes(self, mask): + return self.label_edits.binary_fill_holes(mask) + + def convex_hull_mask(self, mask): + return self.label_edits.convex_hull_mask(mask) + + def nearest_nonzero_2d(self, labels, y, x): + return self.label_edits.nearest_nonzero_2d(labels, y, x) + + def calculate_brush_mask( + self, + image_shape: tuple[int, int], + ymin: int, + xmin: int, + ymax: int, + xmax: int, + disk_mask: np.ndarray, + rr_poly: np.ndarray | None = None, + cc_poly: np.ndarray | None = None, + ) -> np.ndarray: + return self.model.calculate_brush_mask( + image_shape, ymin, xmin, ymax, xmax, disk_mask, rr_poly, cc_poly + ) + diff --git a/cellacdc/viewmodels/canvas_events_viewmodel.py b/cellacdc/viewmodels/canvas_events_viewmodel.py new file mode 100644 index 000000000..f2b60496a --- /dev/null +++ b/cellacdc/viewmodels/canvas_events_viewmodel.py @@ -0,0 +1,55 @@ +"""View-model behavior for canvas event routing.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.canvas_events_model import CanvasEventsModel + +from .geometry import GeometryViewModel +from .label_edits import LabelEditViewModel + + +@dataclass(frozen=True) +class CanvasEventsViewModel: + """GUI-facing helpers for canvas event routing.""" + + model: CanvasEventsModel = field(default_factory=CanvasEventsModel) + geometry: GeometryViewModel = field(default_factory=GeometryViewModel) + label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) + + def snap_xy_to_closest_angle(self, x0, y0, x1, y1): + return self.geometry.snap_xy_to_closest_angle(x0, y0, x1, y1) + + def nearest_nonzero_2d(self, labels, y, x): + return self.label_edits.nearest_nonzero_2d(labels, y, x) + + def binary_fill_holes(self, labels): + return self.label_edits.binary_fill_holes(labels) + + def convex_hull_mask(self, labels): + return self.label_edits.convex_hull_mask(labels) + + def calculate_brush_mask( + self, + image_shape: tuple[int, int], + ymin: int, + xmin: int, + ymax: int, + xmax: int, + disk_mask, + rr_poly=None, + cc_poly=None, + ): + return self.model.calculate_brush_mask( + image_shape, ymin, xmin, ymax, xmax, disk_mask, rr_poly, cc_poly + ) + + def map_mouse_coordinates_to_label_id( + self, + mouse_pos: tuple[float, float], + label_matrix, + ) -> int: + return self.model.map_mouse_coordinates_to_label_id( + mouse_pos, label_matrix + ) diff --git a/cellacdc/viewmodels/canvas_hover_viewmodel.py b/cellacdc/viewmodels/canvas_hover_viewmodel.py new file mode 100644 index 000000000..563d6d3ad --- /dev/null +++ b/cellacdc/viewmodels/canvas_hover_viewmodel.py @@ -0,0 +1,59 @@ +"""View-model contracts for canvas hover interactions.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from cellacdc.models.canvas_hover_model import CanvasHoverModel + + +@dataclass(frozen=True) +class CanvasHoverViewModel: + """Application-facing canvas hover decisions.""" + + model: CanvasHoverModel = field(default_factory=CanvasHoverModel) + + def point_in_bounds( + self, + image_shape: tuple[int, int], + xdata: int, + ydata: int, + ) -> bool: + return self.model.point_in_bounds(image_shape, xdata, ydata) + + def hover_position(self, is_exit: bool, position) -> tuple[Any, Any]: + return self.model.hover_position(is_exit, position) + + def should_set_mirrored_cursor( + self, + *, + override_cursor_is_none: bool, + is_exit: bool, + mirrored_cursor_enabled: bool, + is_hover_img1: bool = True, + ) -> bool: + return self.model.should_set_mirrored_cursor( + override_cursor_is_none=override_cursor_is_none, + is_exit=is_exit, + mirrored_cursor_enabled=mirrored_cursor_enabled, + is_hover_img1=is_hover_img1, + ) + + def should_draw_ruler_line( + self, + *, + ruler_checked: bool, + add_deleted_polyline_checked: bool, + temp_segment_on: bool, + is_exit: bool, + ) -> bool: + return self.model.should_draw_ruler_line( + ruler_checked=ruler_checked, + add_deleted_polyline_checked=add_deleted_polyline_checked, + temp_segment_on=temp_segment_on, + is_exit=is_exit, + ) + + def cursor_flags(self, **kwargs) -> dict[str, bool]: + return self.model.cursor_flags(**kwargs) diff --git a/cellacdc/viewmodels/canvas_right_image_viewmodel.py b/cellacdc/viewmodels/canvas_right_image_viewmodel.py new file mode 100644 index 000000000..cc3afb796 --- /dev/null +++ b/cellacdc/viewmodels/canvas_right_image_viewmodel.py @@ -0,0 +1,25 @@ +"""View-model contracts for duplicated right-image interactions.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.canvas_right_image_model import CanvasRightImageModel + + +@dataclass(frozen=True) +class CanvasRightImageViewModel: + """Application-facing duplicated right-image commands.""" + + model: CanvasRightImageModel = field(default_factory=CanvasRightImageModel) + + def should_show_context_menu( + self, + *, + right_click: bool, + is_right_click_action_on: bool, + ) -> bool: + return self.model.should_show_context_menu( + right_click=right_click, + is_right_click_action_on=is_right_click_action_on, + ) diff --git a/cellacdc/viewmodels/canvas_selection_viewmodel.py b/cellacdc/viewmodels/canvas_selection_viewmodel.py new file mode 100644 index 000000000..f41a1ea71 --- /dev/null +++ b/cellacdc/viewmodels/canvas_selection_viewmodel.py @@ -0,0 +1,49 @@ +"""View-model behavior for canvas selection interactions.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.canvas_selection_model import CanvasSelectionModel + +from .geometry import GeometryViewModel +from .label_edits import LabelEditViewModel + + +@dataclass(frozen=True) +class CanvasSelectionViewModel: + """GUI-facing canvas selection decisions and transforms.""" + + model: CanvasSelectionModel = field(default_factory=CanvasSelectionModel) + geometry: GeometryViewModel = field(default_factory=GeometryViewModel) + label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) + + def is_in_bounds(self, x: int, y: int, width: int, height: int) -> bool: + return self.geometry.is_in_bounds(x, y, width, height) + + def should_drag_image(self, **kwargs) -> bool: + return self.model.should_drag_image(**kwargs) + + def should_blink_viewer_mode(self, **kwargs) -> bool: + return self.model.should_blink_viewer_mode(**kwargs) + + def should_show_labels_menu(self, **kwargs) -> bool: + return self.model.should_show_labels_menu(**kwargs) + + def can_delete(self, **kwargs) -> bool: + return self.model.can_delete(**kwargs) + + def is_viewer_mode(self, mode: str) -> bool: + return self.model.is_viewer_mode(mode) + + def should_process_release(self, **kwargs) -> bool: + return self.model.should_process_release(**kwargs) + + def nearest_nonzero_2d(self, labels, y, x): + return self.label_edits.nearest_nonzero_2d(labels, y, x) + + def separate_with_label(self, *args, **kwargs): + return self.label_edits.separate_with_label(*args, **kwargs) + + def split_along_convexity_defects(self, *args, **kwargs): + return self.label_edits.split_along_convexity_defects(*args, **kwargs) diff --git a/cellacdc/viewmodels/canvas_tool_viewmodel.py b/cellacdc/viewmodels/canvas_tool_viewmodel.py new file mode 100644 index 000000000..52222ebef --- /dev/null +++ b/cellacdc/viewmodels/canvas_tool_viewmodel.py @@ -0,0 +1,66 @@ +"""View-model contracts for canvas tool interaction decisions.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.canvas_tool_model import CanvasToolModel + + +@dataclass(frozen=True) +class CanvasToolViewModel: + """Application-facing canvas tool commands.""" + + model: CanvasToolModel = field(default_factory=CanvasToolModel) + + def viewer_mode_allows_press( + self, + mode: str, + *, + can_add_point: bool = False, + can_ruler: bool = False, + ) -> bool: + return self.model.viewer_mode_allows_press( + mode, + can_add_point=can_add_point, + can_ruler=can_ruler, + ) + + def should_forward_img1_press_to_img2( + self, + *, + right_click: bool, + middle_click: bool, + can_add_point: bool, + mode: str, + is_snapshot: bool, + is_annotate_division: bool, + manual_background_on: bool, + ) -> bool: + return self.model.should_forward_img1_press_to_img2( + right_click=right_click, + middle_click=middle_click, + can_add_point=can_add_point, + mode=mode, + is_snapshot=is_snapshot, + is_annotate_division=is_annotate_division, + manual_background_on=manual_background_on, + ) + + def should_forward_img1_release_to_img2( + self, + *, + right_click: bool, + mode: str, + is_snapshot: bool, + ) -> bool: + return self.model.should_forward_img1_release_to_img2( + right_click=right_click, + mode=mode, + is_snapshot=is_snapshot, + ) + + def apply_manual_separate_draw_mode(self, settings, mode): + key, value = self.model.manual_separate_draw_mode_update(mode) + settings.at[key, 'value'] = value + return settings diff --git a/cellacdc/viewmodels/canvas_tools.py b/cellacdc/viewmodels/canvas_tools.py new file mode 100644 index 000000000..4b501c4d6 --- /dev/null +++ b/cellacdc/viewmodels/canvas_tools.py @@ -0,0 +1,7 @@ +"""Compatibility import for the canonical canvas tool view-model module.""" + +from __future__ import annotations + +from .canvas_tool_viewmodel import CanvasToolViewModel + +__all__ = ['CanvasToolViewModel'] diff --git a/cellacdc/viewmodels/cca_edits.py b/cellacdc/viewmodels/cca_edits.py new file mode 100644 index 000000000..83e223983 --- /dev/null +++ b/cellacdc/viewmodels/cca_edits.py @@ -0,0 +1,249 @@ +"""View-model commands for CCA table edits.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from cellacdc.domain.cell_cycle import ( + add_base_cell_cycle_annotation, + build_base_cell_cycle_annotations, + concat_cell_cycle_annotations, + CcaSnapshotIdEditResult, + apply_manual_cca_changes, + apply_snapshot_cca_id_edits, + has_cell_cycle_annotations, + last_annotated_cell_cycle_frame_index, + merge_missing_cca_ids, + relabel_cca_ids, + remove_cell_cycle_annotations, + remove_future_cell_cycle_annotations, + reset_cca_future_flags, + split_concat_cell_cycle_annotations, +) +from cellacdc.domain.cell_cycle_deletions import ( + CcaDeletedIdsResult, + delete_cca_ids, +) +from cellacdc.domain.cell_cycle_frames import ( + normalize_loaded_cell_cycle_frame_annotations, + prepare_cell_cycle_checker_annotations, + resolve_cell_cycle_annotations, + store_cell_cycle_frame_annotations, +) + + +@dataclass(frozen=True) +class CcaFrameEditResult: + """Result of a current-frame CCA edit command.""" + + cca_df: pd.DataFrame + + +class CcaEditViewModel: + """Application-facing commands for editing CCA tables. + + The Qt view owns undo, persistence, and dialogs. This view model owns the + command shape that binds view events to scriptable domain operations. + """ + + def add_missing_ids( + self, + cca_df: pd.DataFrame | None, + base_cca_df: pd.DataFrame, + ) -> CcaFrameEditResult: + return CcaFrameEditResult( + cca_df=merge_missing_cca_ids(cca_df, base_cca_df), + ) + + def relabel_ids( + self, + cca_df: pd.DataFrame, + old_ids, + new_ids, + ) -> CcaFrameEditResult: + return CcaFrameEditResult( + cca_df=relabel_cca_ids(cca_df, old_ids, new_ids), + ) + + def delete_ids( + self, + cca_df: pd.DataFrame, + deleted_ids, + ) -> CcaDeletedIdsResult: + return delete_cca_ids(cca_df, deleted_ids) + + def apply_snapshot_id_edits( + self, + cca_df: pd.DataFrame, + edit_text: str, + current_ids, + base_cca_df: pd.DataFrame, + *, + base_values: dict | None = None, + ) -> CcaSnapshotIdEditResult: + return apply_snapshot_cca_id_edits( + cca_df, + edit_text, + current_ids, + base_cca_df, + base_values=base_values, + ) + + def apply_manual_changes( + self, + cca_df: pd.DataFrame, + changes, + ) -> pd.DataFrame: + return apply_manual_cca_changes(cca_df, changes) + + def normalize_loaded_frame_annotations( + self, + acdc_df: pd.DataFrame | None, + cca_colnames, + int_colnames=(), + ) -> pd.DataFrame | None: + return normalize_loaded_cell_cycle_frame_annotations( + acdc_df, + cca_colnames, + int_colnames, + ) + + def add_base_annotation( + self, + cca_df: pd.DataFrame, + cell_ids, + *, + base_values: dict | None = None, + ) -> pd.DataFrame: + return add_base_cell_cycle_annotation( + cca_df, + cell_ids, + base_values=base_values, + ) + + def build_base_annotations( + self, + cell_ids, + *, + with_tree_cols: bool = False, + base_values: dict | None = None, + tree_values: dict | None = None, + ) -> pd.DataFrame: + return build_base_cell_cycle_annotations( + cell_ids, + with_tree_cols=with_tree_cols, + base_values=base_values, + tree_values=tree_values, + ) + + def last_annotated_frame_index(self, acdc_dfs) -> int: + return last_annotated_cell_cycle_frame_index(acdc_dfs) + + def concat_annotations( + self, + frame_records, + cca_colnames, + *, + acdc_key: str = 'acdc_df', + size_t: int | None = None, + ) -> pd.DataFrame | None: + return concat_cell_cycle_annotations( + frame_records, + cca_colnames, + acdc_key=acdc_key, + size_t=size_t, + ) + + def split_concat_annotations( + self, + global_cca_df: pd.DataFrame | None, + *, + size_t: int | None = None, + frame_level: str = 'frame_i', + ) -> list[tuple[int, pd.DataFrame]]: + return split_concat_cell_cycle_annotations( + global_cca_df, + size_t=size_t, + frame_level=frame_level, + ) + + def remove_future_annotations( + self, + frame_records, + cca_colnames, + from_frame_i: int, + *, + size_t: int | None = None, + concatenated_acdc_df: pd.DataFrame | None = None, + acdc_key: str = 'acdc_df', + ): + return remove_future_cell_cycle_annotations( + frame_records, + cca_colnames, + from_frame_i, + size_t=size_t, + concatenated_acdc_df=concatenated_acdc_df, + acdc_key=acdc_key, + ) + + def remove_annotations(self, acdc_df: pd.DataFrame | None, cca_colnames): + return remove_cell_cycle_annotations(acdc_df, cca_colnames) + + def reset_future_flags(self, cca_df: pd.DataFrame) -> pd.DataFrame: + return reset_cca_future_flags(cca_df) + + def resolve_annotations( + self, + acdc_df: pd.DataFrame | None, + cca_colnames, + *, + is_snapshot: bool = False, + snapshot_cell_ids=(), + dropna: bool = True, + base_values: dict | None = None, + tree_values: dict | None = None, + with_tree_cols: bool = False, + ): + return resolve_cell_cycle_annotations( + acdc_df, + cca_colnames, + is_snapshot=is_snapshot, + snapshot_cell_ids=snapshot_cell_ids, + dropna=dropna, + base_values=base_values, + tree_values=tree_values, + with_tree_cols=with_tree_cols, + ) + + def prepare_checker_annotations( + self, + cca_df: pd.DataFrame | None, + *, + checker_running: bool = True, + ) -> pd.DataFrame | None: + return prepare_cell_cycle_checker_annotations( + cca_df, + checker_running=checker_running, + ) + + def store_frame_annotations( + self, + acdc_df: pd.DataFrame | None, + cca_df: pd.DataFrame | None, + cca_colnames, + *, + store_checker_copy: bool = False, + store_cca_df_copy: bool = False, + ): + return store_cell_cycle_frame_annotations( + acdc_df, + cca_df, + cca_colnames, + store_checker_copy=store_checker_copy, + store_cca_df_copy=store_cca_df_copy, + ) + + def has_annotations(self, acdc_df: pd.DataFrame | None) -> bool: + return has_cell_cycle_annotations(acdc_df) diff --git a/cellacdc/viewmodels/cca_workflows.py b/cellacdc/viewmodels/cca_workflows.py new file mode 100644 index 000000000..98f5cb7f1 --- /dev/null +++ b/cellacdc/viewmodels/cca_workflows.py @@ -0,0 +1,160 @@ +"""View-model commands for CCA workflow operations.""" + +from __future__ import annotations + +from cellacdc.domain.cell_cycle import ( + annotate_division, + base_cell_cycle_annotation_status, + collect_existing_new_id_cca_rows_from_frames, + dead_or_excluded_mother_pairs, + division_undo_blocking_frame, + extract_cell_cycle_annotations, + fix_will_divide_without_next_generation, + missing_cell_cycle_annotation_items, + overlay_last_annotated_cca, + propagate_s_phase_disappearance_divisions, + reset_will_divide_for_generations, + s_phase_relative_ids_gone, + undo_bud_mother_assignment, + undo_division_annotation, +) +from cellacdc.domain.cell_cycle_auto import ( + apply_auto_cca_assignments, + auto_cca_assignments_from_cost, + auto_cca_candidate_mother_ids, + auto_cca_cost_matrix_from_contours, + auto_cca_cost_matrix_from_distances, + auto_cca_repeat_frame_state, + nearest_point_2d_yx, + prepare_auto_cca_current_frame, + uncorrected_new_ids_for_auto_cca, +) +from cellacdc.domain.cell_cycle_deletions import ( + propagate_deleted_cell_cycle_ids, +) +from cellacdc.domain.cell_cycle_divisions import ( + bud_mother_change_eligibility, + mother_assignment_eligibility, + previous_relative_status_before_bud_emergence, + propagate_bud_mother_assignment, + propagate_manual_division_annotation, + propagate_swap_mothers_assignment, + propagate_will_divide, + swap_mothers_eligibility, +) +from cellacdc.domain.cell_cycle_frames import ( + prepare_missing_cell_cycle_frame_annotations, +) +from cellacdc.domain.cell_cycle_history import ( + known_history_status_for_bud, + propagate_history_knowledge, +) + + +class CcaWorkflowViewModel: + """Application-facing commands for CCA workflows and propagation.""" + + def base_status(self, base_values=None): + return base_cell_cycle_annotation_status(base_values) + + def collect_existing_new_id_rows(self, *args, **kwargs): + return collect_existing_new_id_cca_rows_from_frames(*args, **kwargs) + + def dead_or_excluded_mother_pairs(self, *args, **kwargs): + return dead_or_excluded_mother_pairs(*args, **kwargs) + + def division_undo_blocking_frame(self, *args, **kwargs): + return division_undo_blocking_frame(*args, **kwargs) + + def extract_annotations(self, *args, **kwargs): + return extract_cell_cycle_annotations(*args, **kwargs) + + def fix_will_divide_without_next_generation(self, *args, **kwargs): + return fix_will_divide_without_next_generation(*args, **kwargs) + + def missing_annotation_items(self, *args, **kwargs): + return missing_cell_cycle_annotation_items(*args, **kwargs) + + def overlay_last_annotated(self, *args, **kwargs): + return overlay_last_annotated_cca(*args, **kwargs) + + def propagate_s_phase_disappearance_divisions(self, *args, **kwargs): + return propagate_s_phase_disappearance_divisions(*args, **kwargs) + + def reset_will_divide_for_generations(self, *args, **kwargs): + return reset_will_divide_for_generations(*args, **kwargs) + + def s_phase_relative_ids_gone(self, *args, **kwargs): + return s_phase_relative_ids_gone(*args, **kwargs) + + def annotate_division(self, *args, **kwargs): + return annotate_division(*args, **kwargs) + + def undo_division_annotation(self, *args, **kwargs): + return undo_division_annotation(*args, **kwargs) + + def undo_bud_mother_assignment(self, *args, **kwargs): + return undo_bud_mother_assignment(*args, **kwargs) + + def apply_auto_assignments(self, *args, **kwargs): + return apply_auto_cca_assignments(*args, **kwargs) + + def auto_assignments_from_cost(self, *args, **kwargs): + return auto_cca_assignments_from_cost(*args, **kwargs) + + def auto_candidate_mother_ids(self, *args, **kwargs): + return auto_cca_candidate_mother_ids(*args, **kwargs) + + def auto_cost_matrix_from_contours(self, *args, **kwargs): + return auto_cca_cost_matrix_from_contours(*args, **kwargs) + + def auto_cost_matrix_from_distances(self, *args, **kwargs): + return auto_cca_cost_matrix_from_distances(*args, **kwargs) + + def auto_repeat_frame_state(self, *args, **kwargs): + return auto_cca_repeat_frame_state(*args, **kwargs) + + def nearest_point_2d_yx(self, *args, **kwargs): + return nearest_point_2d_yx(*args, **kwargs) + + def prepare_auto_current_frame(self, *args, **kwargs): + return prepare_auto_cca_current_frame(*args, **kwargs) + + def uncorrected_new_ids_for_auto(self, *args, **kwargs): + return uncorrected_new_ids_for_auto_cca(*args, **kwargs) + + def propagate_deleted_ids(self, *args, **kwargs): + return propagate_deleted_cell_cycle_ids(*args, **kwargs) + + def prepare_missing_frame_annotations(self, *args, **kwargs): + return prepare_missing_cell_cycle_frame_annotations(*args, **kwargs) + + def previous_relative_status_before_bud_emergence(self, *args, **kwargs): + return previous_relative_status_before_bud_emergence(*args, **kwargs) + + def bud_mother_change_eligibility(self, *args, **kwargs): + return bud_mother_change_eligibility(*args, **kwargs) + + def mother_assignment_eligibility(self, *args, **kwargs): + return mother_assignment_eligibility(*args, **kwargs) + + def propagate_bud_mother_assignment(self, *args, **kwargs): + return propagate_bud_mother_assignment(*args, **kwargs) + + def propagate_manual_division_annotation(self, *args, **kwargs): + return propagate_manual_division_annotation(*args, **kwargs) + + def propagate_swap_mothers_assignment(self, *args, **kwargs): + return propagate_swap_mothers_assignment(*args, **kwargs) + + def propagate_will_divide(self, *args, **kwargs): + return propagate_will_divide(*args, **kwargs) + + def swap_mothers_eligibility(self, *args, **kwargs): + return swap_mothers_eligibility(*args, **kwargs) + + def known_history_status_for_bud(self, *args, **kwargs): + return known_history_status_for_bud(*args, **kwargs) + + def propagate_history_knowledge(self, *args, **kwargs): + return propagate_history_knowledge(*args, **kwargs) diff --git a/cellacdc/viewmodels/cell_cycle_viewmodel.py b/cellacdc/viewmodels/cell_cycle_viewmodel.py new file mode 100644 index 000000000..899fe869f --- /dev/null +++ b/cellacdc/viewmodels/cell_cycle_viewmodel.py @@ -0,0 +1,67 @@ +"""View-model composition for cell-cycle annotation workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import pandas as pd + +from cellacdc.models.cell_cycle_model import ( + AnnotatedEditWarningPlan, + CellCycleModel, +) + +from .cca_edits import CcaEditViewModel +from .cca_workflows import CcaWorkflowViewModel +from .lineage import LineageViewModel +from .model_registry import ModelRegistryViewModel + + +@dataclass(frozen=True) +class CellCycleViewModel: + """GUI-facing commands for cell-cycle annotation workflows.""" + + model: CellCycleModel = field(default_factory=CellCycleModel) + cca_edits: CcaEditViewModel = field(default_factory=CcaEditViewModel) + cca_workflows: CcaWorkflowViewModel = field( + default_factory=CcaWorkflowViewModel + ) + lineage: LineageViewModel = field(default_factory=LineageViewModel) + model_registry: ModelRegistryViewModel = field( + default_factory=ModelRegistryViewModel + ) + + def annotated_edit_warning_plan( + self, + *, + is_snapshot: bool, + acdc_df_missing: bool, + lineage_tree_missing: bool, + cell_cycle_stage_present: bool, + lineage_tree_present: bool, + remembered_skip_warning: bool, + ) -> AnnotatedEditWarningPlan: + return self.model.annotated_edit_warning_plan( + is_snapshot=is_snapshot, + acdc_df_missing=acdc_df_missing, + lineage_tree_missing=lineage_tree_missing, + cell_cycle_stage_present=cell_cycle_stage_present, + lineage_tree_present=lineage_tree_present, + remembered_skip_warning=remembered_skip_warning, + ) + + def check_mothers_exclusion_or_dead( + self, + acdc_df: pd.DataFrame, + mother_ids: list[int], + ) -> list[int]: + """Wrap check_mothers_exclusion_or_dead model call.""" + return self.model.check_mothers_exclusion_or_dead(acdc_df, mother_ids) + + def evaluate_sister_relations( + self, + prev_cca_df: pd.DataFrame, + current_ids: set[int], + ) -> list[int]: + """Wrap evaluate_sister_relations model call.""" + return self.model.evaluate_sister_relations(prev_cca_df, current_ids) + diff --git a/cellacdc/viewmodels/combine_viewmodel.py b/cellacdc/viewmodels/combine_viewmodel.py new file mode 100644 index 000000000..0047ebab0 --- /dev/null +++ b/cellacdc/viewmodels/combine_viewmodel.py @@ -0,0 +1,29 @@ +"""View-model contract for the Combine Channels feature.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from cellacdc.models.combine_model import CombineModel + + +@dataclass(frozen=True) +class CombineViewModel: + """Presentation logic and commands for the Combine Channels feature.""" + + model: CombineModel = field(default_factory=CombineModel) + + def initialize_combine_image_data(self, pos_data): + """Delegate initialization to model.""" + return self.model.initialize_combine_image_data(pos_data) + + def validate_dimensions(self, ndim: int) -> bool: + """Delegate validation to model.""" + return self.model.validate_dimensions(ndim) + + def group_processed_data_by_pos(self, processed_data, keys): + """Delegate grouping to model.""" + return self.model.group_processed_data_by_pos(processed_data, keys) + + def update_combine_image_data(self, pos_data, pos_i_data): + """Delegate combined image data update to model.""" + return self.model.update_combine_image_data(pos_data, pos_i_data) diff --git a/cellacdc/viewmodels/curvature_viewmodel.py b/cellacdc/viewmodels/curvature_viewmodel.py new file mode 100644 index 000000000..4ccd5baff --- /dev/null +++ b/cellacdc/viewmodels/curvature_viewmodel.py @@ -0,0 +1,98 @@ +"""View-model contracts for curvature and spline editing tools.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np + +from cellacdc.domain.curvature import CurvatureLabelPaintResult +from cellacdc.models.curvature_model import CurvatureModel + + +@dataclass(frozen=True) +class CurvatureViewModel: + """Application-facing commands for spline drawing and label painting.""" + + model: CurvatureModel = field(default_factory=CurvatureModel) + + def tangent_brush_polygon( + self, + yx_start, + yx_end, + radius: int | float, + shape: tuple[int, int], + ) -> tuple[np.ndarray, np.ndarray]: + return self.model.tangent_brush_polygon( + yx_start, yx_end, radius, shape + ) + + def directional_coords( + self, + alfa_dir: int, + y: int, + x: int, + shape: tuple[int, int], + *, + connectivity: int = 1, + ) -> tuple[list[int], list[int]]: + return self.model.directional_coords( + alfa_dir, + y, + x, + shape, + connectivity=connectivity, + ) + + def spline_coords( + self, + xx, + yy, + *, + resolution_space=None, + per: bool = False, + append_first: bool = False, + ): + return self.model.spline_coords( + xx, + yy, + resolution_space=resolution_space, + per=per, + append_first=append_first, + ) + + def closed_spline_coords( + self, + xx_spline, + yy_spline, + *, + anchor_xx=None, + anchor_yy=None, + predictor=None, + max_exec_time: int = 150, + ): + return self.model.closed_spline_coords( + xx_spline, + yy_spline, + anchor_xx=anchor_xx, + anchor_yy=anchor_yy, + predictor=predictor, + max_exec_time=max_exec_time, + ) + + def paint_spline_to_labels( + self, + labels_2d: np.ndarray, + xx_spline, + yy_spline, + label_id: int, + *, + empty_only: bool = True, + ) -> CurvatureLabelPaintResult: + return self.model.paint_spline_to_labels( + labels_2d, + xx_spline, + yy_spline, + label_id, + empty_only=empty_only, + ) diff --git a/cellacdc/viewmodels/custom_annotations_viewmodel.py b/cellacdc/viewmodels/custom_annotations_viewmodel.py new file mode 100644 index 000000000..0ba01ec27 --- /dev/null +++ b/cellacdc/viewmodels/custom_annotations_viewmodel.py @@ -0,0 +1,93 @@ +"""View-model contracts for custom annotations.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import pandas as pd + +from cellacdc.domain.custom_annotations import ( + CustomAnnotationColumnResult, + CustomAnnotationFrameUpdate, +) +from cellacdc.models.custom_annotations_model import CustomAnnotationsModel + + +@dataclass(frozen=True) +class CustomAnnotationsViewModel: + """Application-facing custom annotation table commands.""" + + model: CustomAnnotationsModel = field( + default_factory=CustomAnnotationsModel + ) + + def read_saved_annotations( + self, + annotations_path: str, + *, + logger_func=None, + ) -> dict: + return self.model.read_saved_annotations( + annotations_path, + logger_func=logger_func, + ) + + def tooltip(self, annotation_state: dict) -> str: + return self.model.tooltip(annotation_state) + + def ensure_column( + self, + acdc_df: pd.DataFrame, + annotation_name: str, + ) -> CustomAnnotationColumnResult: + return self.model.ensure_column(acdc_df, annotation_name) + + def column_exists( + self, + frame_records, + annotation_name: str, + *, + summary_acdc_df: pd.DataFrame | None = None, + ) -> bool: + return self.model.column_exists( + frame_records, + annotation_name, + summary_acdc_df=summary_acdc_df, + ) + + def drop_column( + self, + acdc_df: pd.DataFrame | None, + annotation_name: str, + ) -> pd.DataFrame | None: + return self.model.drop_column(acdc_df, annotation_name) + + def rename_column( + self, + acdc_df: pd.DataFrame | None, + old_name: str, + new_name: str, + ) -> pd.DataFrame | None: + return self.model.rename_column(acdc_df, old_name, new_name) + + def remap_ids(self, annotated_ids_by_frame, old_ids, new_ids) -> dict: + return self.model.remap_ids(annotated_ids_by_frame, old_ids, new_ids) + + def update_frame( + self, + acdc_df: pd.DataFrame, + annotation_name: str, + annotated_ids, + *, + clicked_id: int = 0, + click_is_active: bool = False, + existing_ids=None, + ) -> CustomAnnotationFrameUpdate: + return self.model.update_frame( + acdc_df, + annotation_name, + annotated_ids, + clicked_id=clicked_id, + click_is_active=click_is_active, + existing_ids=existing_ids, + ) diff --git a/cellacdc/viewmodels/data_loading_viewmodel.py b/cellacdc/viewmodels/data_loading_viewmodel.py new file mode 100644 index 000000000..f2ee6d946 --- /dev/null +++ b/cellacdc/viewmodels/data_loading_viewmodel.py @@ -0,0 +1,73 @@ +"""View-model behavior for data loading workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.data_loading_model import ( + ChannelNameSuggestion, + DataLoadingModel, + EmptyDataPlan, + ImageDataPreparation, + OpenImageFileContext, + OpenImageFileTarget, +) + +from .formatting import FormattingViewModel +from .workspace import WorkspaceViewModel + + +@dataclass(frozen=True) +class DataLoadingViewModel: + """GUI-facing helpers for data loading workflows.""" + + model: DataLoadingModel = field(default_factory=DataLoadingModel) + formatting: FormattingViewModel = field(default_factory=FormattingViewModel) + workspace: WorkspaceViewModel = field(default_factory=WorkspaceViewModel) + + def open_image_file_context( + self, file_path: str, timestamp: str | None = None + ) -> OpenImageFileContext: + return self.model.open_image_file_context(file_path, timestamp) + + def channel_name_suggestion( + self, filename_no_ext: str + ) -> ChannelNameSuggestion: + return self.model.channel_name_suggestion(filename_no_ext) + + def open_image_file_target( + self, + context: OpenImageFileContext, + channel_name: str | None = None, + ) -> OpenImageFileTarget: + return self.model.open_image_file_target(context, channel_name) + + def empty_data_plan(self, exp_path: str) -> EmptyDataPlan: + return self.model.empty_data_plan(exp_path) + + def copy_action_text(self, do_copy: bool) -> str: + return self.model.copy_action_text(do_copy) + + def is_imagej_dtype(self, dtype) -> bool: + return self.model.is_imagej_dtype(dtype) + + def prepare_tiff_image_data(self, image) -> ImageDataPreparation: + return self.model.prepare_tiff_image_data(image) + + def merge_default_segm_info(self, existing_df, default_df): + return self.model.merge_default_segm_info(existing_df, default_df) + + def copy_single_zslice_segm_info( + self, + existing_df, + default_dst_df, + *, + src_filename: str, + dst_filename: str, + ): + return self.model.copy_single_zslice_segm_info( + existing_df, + default_dst_df, + src_filename=src_filename, + dst_filename=dst_filename, + ) diff --git a/cellacdc/viewmodels/deleted_rois_viewmodel.py b/cellacdc/viewmodels/deleted_rois_viewmodel.py new file mode 100644 index 000000000..ed5a11b71 --- /dev/null +++ b/cellacdc/viewmodels/deleted_rois_viewmodel.py @@ -0,0 +1,48 @@ +"""View-model contracts for deleted ROI workflows.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field + +from cellacdc.models.deleted_rois_model import DeletedRoisModel + + +@dataclass(frozen=True) +class DeletedRoisViewModel: + """Application-facing deleted-ROI decisions.""" + + model: DeletedRoisModel = field(default_factory=DeletedRoisModel) + + def roi_axis( + self, + *, + is_polyline: bool, + labels_image_visible: bool, + ) -> str: + return self.model.roi_axis( + is_polyline=is_polyline, + labels_image_visible=labels_image_visible, + ) + + def should_render_deleted_roi(self, annotation_mode: str) -> bool: + return self.model.should_render_deleted_roi(annotation_mode) + + def should_render_deleted_roi_contours(self, annotation_mode: str) -> bool: + return self.model.should_render_deleted_roi_contours(annotation_mode) + + def should_render_deleted_roi_overlay(self, annotation_mode: str) -> bool: + return self.model.should_render_deleted_roi_overlay(annotation_mode) + + def should_initialize_overlay_masks( + self, + init: bool, + annotation_mode: str, + ) -> bool: + return self.model.should_initialize_overlay_masks( + init, + annotation_mode, + ) + + def labels_to_skip(self, deleted_ids: Iterable[int]) -> dict[int, bool]: + return self.model.labels_to_skip(deleted_ids) diff --git a/cellacdc/viewmodels/display_decorations_viewmodel.py b/cellacdc/viewmodels/display_decorations_viewmodel.py new file mode 100644 index 000000000..0a8a1718f --- /dev/null +++ b/cellacdc/viewmodels/display_decorations_viewmodel.py @@ -0,0 +1,57 @@ +"""View-model contracts for display decorations.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.display_decorations_model import ( + DisplayDecorationsModel, +) + + +@dataclass(frozen=True) +class DisplayDecorationsViewModel: + """Application-facing display-decoration commands.""" + + model: DisplayDecorationsModel = field( + default_factory=DisplayDecorationsModel + ) + + def clamped_view_range(self, image_shape, view_range): + return self.model.clamped_view_range(image_shape, view_range) + + def integer_view_range(self, view_range): + return self.model.integer_view_range(view_range) + + def should_move_decoration( + self, + *, + dialog_open: bool, + move_with_zoom: bool, + ) -> bool: + return self.model.should_move_decoration( + dialog_open=dialog_open, + move_with_zoom=move_with_zoom, + ) + + def should_store_view_range( + self, + *, + has_range_reset_state: bool, + is_range_reset: bool = False, + ) -> bool: + return self.model.should_store_view_range( + has_range_reset_state=has_range_reset_state, + is_range_reset=is_range_reset, + ) + + def should_update_timestamp_frame( + self, + *, + has_timestamp: bool, + timestamp_enabled: bool, + ) -> bool: + return self.model.should_update_timestamp_frame( + has_timestamp=has_timestamp, + timestamp_enabled=timestamp_enabled, + ) diff --git a/cellacdc/viewmodels/draw_clear_region_viewmodel.py b/cellacdc/viewmodels/draw_clear_region_viewmodel.py new file mode 100644 index 000000000..1080c267c --- /dev/null +++ b/cellacdc/viewmodels/draw_clear_region_viewmodel.py @@ -0,0 +1,55 @@ +"""View-model contracts for draw-clear-region workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.draw_clear_region_model import ( + DrawClearRegionModel, + DrawClearRegionToolbarState, +) + + +@dataclass(frozen=True) +class DrawClearRegionViewModel: + """Application-facing draw-clear-region commands.""" + + model: DrawClearRegionModel = field( + default_factory=DrawClearRegionModel + ) + + def toolbar_state( + self, + *, + checked: bool, + is_segm_3d: bool, + size_z: int, + ) -> DrawClearRegionToolbarState: + return self.model.toolbar_state( + checked=checked, + is_segm_3d=is_segm_3d, + size_z=size_z, + ) + + def z_range_for_projection( + self, + *, + is_segm_3d: bool, + z_projection: str, + size_z: int, + single_z_range, + ): + return self.model.z_range_for_projection( + is_segm_3d=is_segm_3d, + z_projection=z_projection, + size_z=size_z, + single_z_range=single_z_range, + ) + + def is_single_z_projection(self, z_projection: str) -> bool: + return self.model.is_single_z_projection(z_projection) + + def empty_selection_warning(self, *, enclosed_only: bool) -> str: + return self.model.empty_selection_warning( + enclosed_only=enclosed_only + ) diff --git a/cellacdc/viewmodels/edit_id.py b/cellacdc/viewmodels/edit_id.py new file mode 100644 index 000000000..c20d3acfd --- /dev/null +++ b/cellacdc/viewmodels/edit_id.py @@ -0,0 +1,81 @@ +"""View-model commands for manual edit-ID operations.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +from cellacdc.domain.edit_id import ( + ManualEditTrackingResult, + add_yx_centroids_to_df, + apply_manual_edit_tracking, + edit_id_info_from_df, + manual_edit_conflicts, + project_centroid, +) + + +class EditIdViewModel: + """Application-facing commands for manual ID edit metadata.""" + + def project_centroid( + self, + centroid, + *, + is_3d: bool = False, + depth_axis: str = 'z', + ) -> tuple[float, float]: + return project_centroid( + centroid, + is_3d=is_3d, + depth_axis=depth_axis, + ) + + def add_yx_centroids_to_df( + self, + df: pd.DataFrame, + regionprops, + *, + is_3d: bool = False, + depth_axis: str = 'z', + ) -> pd.DataFrame: + return add_yx_centroids_to_df( + df, + regionprops, + is_3d=is_3d, + depth_axis=depth_axis, + ) + + def edit_id_info_from_df( + self, + df: pd.DataFrame, + regionprops=None, + *, + is_3d: bool = False, + depth_axis: str = 'z', + ) -> list[tuple[int, int, int]]: + return edit_id_info_from_df( + df, + regionprops, + is_3d=is_3d, + depth_axis=depth_axis, + ) + + def manual_edit_conflicts( + self, + labels: np.ndarray, + edit_id_info, + ) -> dict[int, int]: + return manual_edit_conflicts(labels, edit_id_info) + + def apply_manual_edit_tracking( + self, + tracked_labels: np.ndarray, + edit_id_info, + all_ids, + ) -> ManualEditTrackingResult: + return apply_manual_edit_tracking( + tracked_labels, + edit_id_info, + all_ids, + ) diff --git a/cellacdc/viewmodels/exporting_viewmodel.py b/cellacdc/viewmodels/exporting_viewmodel.py new file mode 100644 index 000000000..c7a40b613 --- /dev/null +++ b/cellacdc/viewmodels/exporting_viewmodel.py @@ -0,0 +1,58 @@ +"""View-model contracts for image and video export workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.exporting_model import ExportingModel + + +@dataclass(frozen=True) +class ExportingViewModel: + """Application-facing export commands.""" + + model: ExportingModel = field(default_factory=ExportingModel) + + def timestamped_export_filename(self, kind: str, *, timestamp=None): + return self.model.timestamped_export_filename( + kind, + timestamp=timestamp, + ) + + def export_frame_plan( + self, + *, + current_index: int, + num_digits: int, + filename: str, + pngs_folderpath: str, + ): + return self.model.export_frame_plan( + current_index=current_index, + num_digits=num_digits, + filename=filename, + pngs_folderpath=pngs_folderpath, + ) + + def build_export_mask_image( + self, + image_shape, + view_range, + *, + invert_bw=False, + ): + return self.model.build_export_mask_image( + image_shape, + view_range, + invert_bw=invert_bw, + ) + + def zoom_ids(self, labels_2d, view_range): + return self.model.zoom_ids(labels_2d, view_range) + + def shifted_view_range(self, previous_range, current_range, window_range): + return self.model.shifted_view_range( + previous_range, + current_range, + window_range, + ) diff --git a/cellacdc/viewmodels/formatting.py b/cellacdc/viewmodels/formatting.py new file mode 100644 index 000000000..54585a13f --- /dev/null +++ b/cellacdc/viewmodels/formatting.py @@ -0,0 +1,57 @@ +"""View-model commands for UI-neutral formatting helpers.""" + +from __future__ import annotations + +from cellacdc.domain.display_images import distant_gray, rgb_to_gray +from cellacdc.myutils import ( + _bytes_to_GB, + get_chname_from_basename, + get_number_fstring_formatter, + get_salute_string, + seconds_to_ETA, +) + + +class FormattingViewModel: + """Application-facing commands for display string formatting.""" + + def number_fstring_formatter(self, dtype, *, precision=4): + return get_number_fstring_formatter(dtype, precision=precision) + + def channel_name_from_basename( + self, + filename, + basename, + *, + remove_ext=True, + ): + return get_chname_from_basename( + filename, + basename, + remove_ext=remove_ext, + ) + + def bytes_to_gb(self, size_bytes): + return _bytes_to_GB(size_bytes) + + def seconds_to_eta(self, seconds): + return seconds_to_ETA(seconds) + + def salute_string(self): + return get_salute_string() + + def distant_gray( + self, + desired_gray, + background_gray, + *, + threshold=0.3, + ): + return distant_gray( + desired_gray, + background_gray, + threshold=threshold, + ) + + def rgb_to_gray(self, red, green, blue): + return rgb_to_gray(red, green, blue) diff --git a/cellacdc/viewmodels/frame_metadata.py b/cellacdc/viewmodels/frame_metadata.py new file mode 100644 index 000000000..a8c9813e1 --- /dev/null +++ b/cellacdc/viewmodels/frame_metadata.py @@ -0,0 +1,52 @@ +"""View-model commands for ACDC frame metadata.""" + +from __future__ import annotations + +import pandas as pd + +from cellacdc.domain.frame_metadata import ( + AcdcFrameMetadataResult, + build_acdc_frame_metadata, + concat_visited_acdc_frames, +) +from cellacdc.myutils import get_empty_stored_data_dict + + +class FrameMetadataViewModel: + """Application-facing commands for per-frame ACDC metadata tables.""" + + def build_acdc_frame_metadata( + self, + regionprops, + *, + edit_id_info=(), + existing_df: pd.DataFrame | None = None, + is_3d: bool = False, + depth_axis: str = 'z', + ) -> AcdcFrameMetadataResult: + return build_acdc_frame_metadata( + regionprops, + edit_id_info=edit_id_info, + existing_df=existing_df, + is_3d=is_3d, + depth_axis=depth_axis, + ) + + def concat_visited_acdc_frames( + self, + frame_records, + *, + labels_key: str = 'labels', + acdc_key: str = 'acdc_df', + ) -> pd.DataFrame | None: + return concat_visited_acdc_frames( + frame_records, + labels_key=labels_key, + acdc_key=acdc_key, + ) + + def empty_frame_record(self): + return get_empty_stored_data_dict() + + def empty_frame_records(self, count: int): + return [self.empty_frame_record() for _ in range(count)] diff --git a/cellacdc/viewmodels/frame_navigation_viewmodel.py b/cellacdc/viewmodels/frame_navigation_viewmodel.py new file mode 100644 index 000000000..961bbc378 --- /dev/null +++ b/cellacdc/viewmodels/frame_navigation_viewmodel.py @@ -0,0 +1,66 @@ +"""View-model contracts for frame and position navigation.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.frame_navigation_model import FrameNavigationModel + +from .frame_metadata import FrameMetadataViewModel +from .label_edits import LabelEditViewModel + + +@dataclass(frozen=True) +class FrameNavigationViewModel: + """Application-facing frame/position navigation decisions.""" + + model: FrameNavigationModel = field(default_factory=FrameNavigationModel) + frame_metadata: FrameMetadataViewModel = field( + default_factory=FrameMetadataViewModel + ) + label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) + + def should_show_next_frame_image(self, **kwargs) -> bool: + return self.model.should_show_next_frame_image(**kwargs) + + def next_frame_index(self, **kwargs) -> int: + return self.model.next_frame_index(**kwargs) + + def navigation_position(self, **kwargs) -> int: + return self.model.navigation_position(**kwargs) + + def navigation_limit(self, **kwargs): + return self.model.navigation_limit(**kwargs) + + def should_store_when_slider_moves(self, *, mode: str) -> bool: + return self.model.should_store_when_slider_moves(mode=mode) + + def should_warn_lost_objects(self, **kwargs) -> bool: + return self.model.should_warn_lost_objects(**kwargs) + + def blocks_future_manual_annotation(self, **kwargs) -> bool: + return self.model.blocks_future_manual_annotation(**kwargs) + + def should_apply_new_frame_tools(self, **kwargs) -> bool: + return self.model.should_apply_new_frame_tools(**kwargs) + + def is_single_z_slice_projection(self, how: str) -> bool: + return self.model.is_single_z_slice_projection(how) + + def should_disable_overlay_z_slice(self, how: str) -> bool: + return self.model.should_disable_overlay_z_slice(how) + + def projection_frame_indices(self, **kwargs): + return self.model.projection_frame_indices(**kwargs) + + def z_slice_frame_indices(self, **kwargs): + return self.model.z_slice_frame_indices(**kwargs) + + def nearest_nonzero_z_from_centroid(self, *args, **kwargs): + return self.label_edits.nearest_nonzero_z_from_centroid(*args, **kwargs) + + def empty_frame_record(self): + return self.frame_metadata.empty_frame_record() + + def empty_frame_records(self, count: int): + return self.frame_metadata.empty_frame_records(count) diff --git a/cellacdc/viewmodels/geometry.py b/cellacdc/viewmodels/geometry.py new file mode 100644 index 000000000..65c655c95 --- /dev/null +++ b/cellacdc/viewmodels/geometry.py @@ -0,0 +1,174 @@ +"""View-model commands for geometric interaction helpers.""" + +from __future__ import annotations + +from cellacdc.core import get_line, get_obj_contours +from cellacdc.core._legacy import _compute_all_obj_to_obj_contour_dist_pairs +from cellacdc.myutils import get_slices_local_into_global_arr, is_in_bounds +from cellacdc.transformation import crop_2D, snap_xy_to_closest_angle + + +class GeometryViewModel: + """Application-facing commands for geometric interaction transforms.""" + + def snap_xy_to_closest_angle( + self, + x0, + y0, + x1, + y1, + angle_factor=15, + ): + return snap_xy_to_closest_angle( + x0, + y0, + x1, + y1, + angle_factor=angle_factor, + ) + + def crop_2d( + self, + image, + xy_range, + *, + tolerance=0, + return_copy=True, + ): + return crop_2D( + image, + xy_range, + tolerance=tolerance, + return_copy=return_copy, + ) + + def line_coords(self, y1, x1, y2, x2, *, dashed=True): + return get_line(y1, x1, y2, x2, dashed=dashed) + + def is_in_bounds(self, x, y, width, height): + return is_in_bounds(x, y, width, height) + + def windows_overlap_from_bounds( + self, + *, + main_left, + main_top, + main_width, + main_height, + other_left, + other_top, + ) -> bool: + main_right = main_left + main_width + main_bottom = main_top + main_height + return (other_top < main_bottom) and (other_left < main_right) + + def should_auto_activate_viewer( + self, + *, + is_data_loaded: bool, + windows_overlap: bool, + disable_auto_activate: bool, + ) -> bool: + return ( + is_data_loaded + and not windows_overlap + and not disable_auto_activate + ) + + def is_pan_image_click( + self, + *, + mouse_button, + left_button, + modifiers, + alt_modifier, + ) -> bool: + return modifiers == alt_modifier and mouse_button == left_button + + def is_default_middle_click( + self, + *, + mouse_button, + modifiers, + is_mac: bool, + brush_is_checked: bool, + left_button, + middle_button, + control_modifier, + ) -> bool: + if is_mac: + return ( + mouse_button == left_button + and modifiers == control_modifier + and not brush_is_checked + ) + return mouse_button == middle_button + + def is_configured_middle_click( + self, + *, + mouse_button, + configured_button, + key_sequence_is_none: bool, + tool_is_checked: bool, + ) -> bool: + if key_sequence_is_none: + is_del_object_active = True + else: + is_del_object_active = tool_is_checked + return mouse_button == configured_button and is_del_object_active + + def middle_click_text( + self, + *, + has_del_object_action: bool, + is_mac: bool, + button_name: str | None = None, + key_sequence_text: str | None = None, + ) -> str: + if not has_del_object_action and is_mac: + return 'Command + Left Click' + if not has_del_object_action: + return 'Middle Click' + if key_sequence_text is None: + return button_name + return f'{key_sequence_text} + {button_name}' + + def object_contours( + self, + *, + obj=None, + obj_image=None, + obj_bbox=None, + all_external=False, + all=False, + only_longest_contour=True, + local=False, + ): + return get_obj_contours( + obj=obj, + obj_image=obj_image, + obj_bbox=obj_bbox, + all_external=all_external, + all=all, + only_longest_contour=only_longest_contour, + local=local, + ) + + def object_to_object_contour_distance_matrix( + self, + all_contours, + regionprops, + *, + previous_regionprops=None, + restrict_search=True, + ): + return _compute_all_obj_to_obj_contour_dist_pairs( + all_contours, + regionprops, + prev_rp=previous_regionprops, + restrict_search=restrict_search, + ) + + def local_to_global_slices(self, bbox_coords, global_shape): + return get_slices_local_into_global_arr(bbox_coords, global_shape) diff --git a/cellacdc/viewmodels/graphics_viewmodel.py b/cellacdc/viewmodels/graphics_viewmodel.py new file mode 100644 index 000000000..0d62df4a1 --- /dev/null +++ b/cellacdc/viewmodels/graphics_viewmodel.py @@ -0,0 +1,117 @@ +"""View-model composition for graphics workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from collections.abc import Iterable, Mapping +import numpy as np + +from cellacdc.models.graphics_model import ( + GraphicsModel, + OverlayOpacityPlan, + OverlayVisibilityPlan, +) + +from .formatting import FormattingViewModel +from .geometry import GeometryViewModel +from .label_edits import LabelEditViewModel +from .workspace import WorkspaceViewModel + + +@dataclass(frozen=True) +class GraphicsViewModel: + """GUI-facing commands for graphics item construction workflows.""" + + model: GraphicsModel = field(default_factory=GraphicsModel) + formatting: FormattingViewModel = field(default_factory=FormattingViewModel) + geometry: GeometryViewModel = field(default_factory=GeometryViewModel) + label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) + workspace: WorkspaceViewModel = field(default_factory=WorkspaceViewModel) + + def overlay_toolbutton_checked( + self, + channel: str, + *, + checked_channels: Iterable[str], + is_single_channel: bool, + ) -> bool: + return self.model.overlay_toolbutton_checked( + channel, + checked_channels=checked_channels, + is_single_channel=is_single_channel, + ) + + def overlay_toolbutton_click_checked_channels( + self, + *, + clicked_channel: str, + all_channels: Iterable[str], + checked_channels: Iterable[str], + toolbar_single_channel: bool, + ) -> set[str]: + return self.model.overlay_toolbutton_click_checked_channels( + clicked_channel=clicked_channel, + all_channels=all_channels, + checked_channels=checked_channels, + toolbar_single_channel=toolbar_single_channel, + ) + + def overlay_visibility_plan( + self, + *, + all_channels: Iterable[str], + checked_channels: Iterable[str], + overlay_enabled: bool, + ) -> OverlayVisibilityPlan: + return self.model.overlay_visibility_plan( + all_channels=all_channels, + checked_channels=checked_channels, + overlay_enabled=overlay_enabled, + ) + + def overlay_channel_opacity_map( + self, + base_channel: str, + active_channel_alpha_values: Mapping[str, float], + ) -> dict[str, float]: + return self.model.overlay_channel_opacity_map( + base_channel, + active_channel_alpha_values, + ) + + def overlay_item_opacity_plan( + self, + *, + all_channels: Iterable[str], + base_channel: str, + checked_channels: Iterable[str], + toolbar_single_channel: bool, + active_channel_alpha_values: Mapping[str, float], + ) -> OverlayOpacityPlan: + return self.model.overlay_item_opacity_plan( + all_channels=all_channels, + base_channel=base_channel, + checked_channels=checked_channels, + toolbar_single_channel=toolbar_single_channel, + active_channel_alpha_values=active_channel_alpha_values, + ) + + def generate_labels_image_lut(self, base_lut: np.ndarray) -> np.ndarray: + """Wrap generate_labels_image_lut model call.""" + return self.model.generate_labels_image_lut(base_lut) + + def extend_labels_lut(self, base_lut: np.ndarray, len_new_lut: int) -> np.ndarray: + """Wrap extend_labels_lut model call.""" + return self.model.extend_labels_lut(base_lut, len_new_lut) + + def apply_lut_dimming_for_kept_objects( + self, + lut: np.ndarray, + kept_object_ids: list[int], + keep_ids_enabled: bool, + ) -> np.ndarray: + """Wrap apply_lut_dimming_for_kept_objects model call.""" + return self.model.apply_lut_dimming_for_kept_objects( + lut, kept_object_ids, keep_ids_enabled + ) + diff --git a/cellacdc/viewmodels/image_controls_viewmodel.py b/cellacdc/viewmodels/image_controls_viewmodel.py new file mode 100644 index 000000000..08140f20f --- /dev/null +++ b/cellacdc/viewmodels/image_controls_viewmodel.py @@ -0,0 +1,32 @@ +"""View-model contracts for image control widgets.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.image_controls_model import ImageControlsModel + + +@dataclass(frozen=True) +class ImageControlsViewModel: + """Application-facing image-control defaults.""" + + model: ImageControlsModel = field(default_factory=ImageControlsModel) + + def draw_ids_cont_combo_items(self) -> tuple[str, ...]: + return self.model.draw_ids_cont_combo_items + + def z_projection_options(self) -> tuple[str, ...]: + return self.model.z_projection_options + + def overlay_z_projection_options(self) -> tuple[str, ...]: + return self.model.overlay_z_projection_options + + def bottom_layout_zoom_values(self) -> tuple[int, ...]: + return self.model.bottom_layout_zoom_values + + def bottom_layout_zoom_percent(self, df_settings) -> int: + return self.model.bottom_layout_zoom_percent(df_settings) + + def retain_space_hidden_sliders(self, df_settings) -> bool: + return self.model.retain_space_hidden_sliders(df_settings) diff --git a/cellacdc/viewmodels/image_display_viewmodel.py b/cellacdc/viewmodels/image_display_viewmodel.py new file mode 100644 index 000000000..c9ec8de41 --- /dev/null +++ b/cellacdc/viewmodels/image_display_viewmodel.py @@ -0,0 +1,57 @@ +"""View-model behavior for image display workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.image_display_model import ( + ImageDisplayModel, + LabelsAlphaPlan, + RightPaneMode, + RightPaneVisibilityPlan, +) + +from .formatting import FormattingViewModel +from .preprocessing_viewmodel import PreprocessingViewModel + + +@dataclass(frozen=True) +class ImageDisplayViewModel: + """GUI-facing helpers for image display workflows.""" + + model: ImageDisplayModel = field(default_factory=ImageDisplayModel) + formatting: FormattingViewModel = field(default_factory=FormattingViewModel) + preprocessing: PreprocessingViewModel = field( + default_factory=PreprocessingViewModel + ) + + def right_pane_visibility_plan( + self, + mode: RightPaneMode, + checked: bool, + ) -> RightPaneVisibilityPlan: + return self.model.right_pane_visibility_plan(mode, checked) + + def invert_bw_setting_value(self, checked: bool) -> str: + return self.model.invert_bw_setting_value(checked) + + def labels_alpha_plan( + self, + value: float, + *, + keep_ids_checked: bool, + ) -> LabelsAlphaPlan: + return self.model.labels_alpha_plan( + value, + keep_ids_checked=keep_ids_checked, + ) + + def intensity_normalization_setting_value(self, how: str) -> str: + return self.model.intensity_normalization_setting_value(how) + + def rescale_intensity_setting_update( + self, + channel: str, + how: str, + ) -> tuple[str, str]: + return self.model.rescale_intensity_setting_update(channel, how) diff --git a/cellacdc/viewmodels/label_editing_viewmodel.py b/cellacdc/viewmodels/label_editing_viewmodel.py new file mode 100644 index 000000000..b6a18921d --- /dev/null +++ b/cellacdc/viewmodels/label_editing_viewmodel.py @@ -0,0 +1,82 @@ +"""View-model contracts for label-editing workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.label_editing_model import LabelEditingModel + +from .cca_edits import CcaEditViewModel +from .edit_id import EditIdViewModel +from .geometry import GeometryViewModel +from .label_edits import LabelEditViewModel + + +@dataclass(frozen=True) +class LabelEditingViewModel: + """Application-facing label-editing decisions and commands.""" + + model: LabelEditingModel = field(default_factory=LabelEditingModel) + cca_edits: CcaEditViewModel = field(default_factory=CcaEditViewModel) + edit_id: EditIdViewModel = field(default_factory=EditIdViewModel) + geometry: GeometryViewModel = field(default_factory=GeometryViewModel) + label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) + + def should_apply_manual_edits(self, edited_labels_by_z) -> bool: + return self.model.should_apply_manual_edits(edited_labels_by_z) + + def should_store_zslice_regionprops(self, *, is_segm_3d: bool) -> bool: + return self.model.should_store_zslice_regionprops( + is_segm_3d=is_segm_3d + ) + + def should_update_zslice_regionprops( + self, + *, + force_update: bool, + already_stored: bool, + ) -> bool: + return self.model.should_update_zslice_regionprops( + force_update=force_update, + already_stored=already_stored, + ) + + def should_prompt_for_background_id(self, clicked_id: int) -> bool: + return self.model.should_prompt_for_background_id(clicked_id) + + def is_power_button_color( + self, + *, + button_color: str, + power_color: str, + ) -> bool: + return self.model.is_power_button_color( + button_color=button_color, + power_color=power_color, + ) + + def should_force_new_hover_id( + self, + *, + brush_active: bool, + shift_pressed: bool, + ) -> bool: + return self.model.should_force_new_hover_id( + brush_active=brush_active, + shift_pressed=shift_pressed, + ) + + def should_restore_brush_id_from_hover( + self, + *, + is_hover_z_neighbor: bool, + shift_pressed: bool, + last_hover_id: int, + hover_id: int, + ) -> bool: + return self.model.should_restore_brush_id_from_hover( + is_hover_z_neighbor=is_hover_z_neighbor, + shift_pressed=shift_pressed, + last_hover_id=last_hover_id, + hover_id=hover_id, + ) diff --git a/cellacdc/viewmodels/label_edits.py b/cellacdc/viewmodels/label_edits.py new file mode 100644 index 000000000..722c24a99 --- /dev/null +++ b/cellacdc/viewmodels/label_edits.py @@ -0,0 +1,339 @@ +"""View-model commands for label image edits.""" + +from __future__ import annotations + +import numpy as np + +from cellacdc.core import ( + binary_fill_holes as core_binary_fill_holes, + convex_hull_mask as core_convex_hull_mask, + count_objects as core_count_objects, + nearest_nonzero_2D, + nearest_nonzero_z_idx_from_z_centroid, + split_connected_components as core_split_connected_components, + split_along_convexity_defects, +) +from cellacdc.domain.labels import ( + DeletedRoiApplyResult, + DeletedRoiRestoreResult, + LabelBorderClearResult, + LabelHoleFillResult, + LabelIdMappingResult, + LabelIdsRemovalResult, + LabelMoveResult, + LabelRegionSelectionResult, + LabelResizeResult, + LabelRoiIndexResult, + apply_deleted_roi_masks, + apply_label_id_mapping, + clear_border_labels, + collect_deleted_roi_ids, + fill_label_holes, + index_label_roi, + label_ids_from_labels, + label_ids_in_masks, + line_roi_mask, + move_label_object, + next_available_label_id, + polygon_roi_mask, + rectangle_roi_mask, + remap_id_set, + remove_new_label_ids, + resize_label_object, + restore_deleted_roi_labels, + select_labels_in_region, +) +from cellacdc.measure import separate_with_label +from cellacdc.myutils import get_trimmed_list + + +class LabelEditViewModel: + """Application-facing commands for editing label arrays.""" + + def clear_border_labels( + self, + labels: np.ndarray, + *, + buffer_size: int = 0, + ) -> LabelBorderClearResult: + return clear_border_labels(labels, buffer_size=buffer_size) + + def remove_new_labels( + self, + labels: np.ndarray, + previous_ids, + current_ids, + ) -> LabelIdsRemovalResult: + return remove_new_label_ids(labels, previous_ids, current_ids) + + def fill_label_holes( + self, + labels_2d: np.ndarray, + label_id: int, + ) -> LabelHoleFillResult: + return fill_label_holes(labels_2d, label_id) + + def select_labels_in_region( + self, + labels: np.ndarray, + mask: np.ndarray, + *, + enclosed_only: bool = False, + ) -> LabelRegionSelectionResult: + return select_labels_in_region( + labels, + mask, + enclosed_only=enclosed_only, + ) + + def index_label_roi( + self, + labels: np.ndarray, + roi_labels: np.ndarray, + roi_slice, + brush_id: int, + *, + clear_border: bool = False, + replace_existing: bool = False, + ) -> LabelRoiIndexResult: + return index_label_roi( + labels, + roi_labels, + roi_slice, + brush_id, + clear_border=clear_border, + replace_existing=replace_existing, + ) + + def resize_label_object( + self, + labels_2d: np.ndarray, + active_labels_2d: np.ndarray, + object_coords: np.ndarray, + label_id: int, + footprint_size: int, + *, + dilation: bool = True, + seed_labels: np.ndarray | None = None, + ) -> LabelResizeResult: + return resize_label_object( + labels_2d, + active_labels_2d, + object_coords, + label_id, + footprint_size, + dilation=dilation, + seed_labels=seed_labels, + ) + + def move_label_object( + self, + labels: np.ndarray, + object_coords: np.ndarray, + label_id: int, + *, + delta_y: int, + delta_x: int, + shape: tuple[int, int] | None = None, + ) -> LabelMoveResult: + return move_label_object( + labels, + object_coords, + label_id, + delta_y=delta_y, + delta_x=delta_x, + shape=shape, + ) + + def apply_id_mapping( + self, + labels: np.ndarray, + old_new_pairs, + *, + existing_ids=None, + merge_existing: bool = False, + start_max_id: int | None = None, + ) -> LabelIdMappingResult: + return apply_label_id_mapping( + labels, + old_new_pairs, + existing_ids=existing_ids, + merge_existing=merge_existing, + start_max_id=start_max_id, + ) + + def restore_deleted_roi_labels( + self, + labels_2d: np.ndarray, + display_labels_2d: np.ndarray, + deleted_mask: np.ndarray, + roi_mask: np.ndarray, + deleted_ids, + *, + enforce: bool = True, + ) -> DeletedRoiRestoreResult: + return restore_deleted_roi_labels( + labels_2d, + display_labels_2d, + deleted_mask, + roi_mask, + deleted_ids, + enforce=enforce, + ) + + def label_ids_in_masks( + self, + labels: np.ndarray, + masks, + *, + additional_labels: np.ndarray | None = None, + ) -> set[int]: + return label_ids_in_masks( + labels, + masks, + additional_labels=additional_labels, + ) + + def collect_deleted_roi_ids(self, deleted_ids_by_roi) -> set[int]: + return collect_deleted_roi_ids(deleted_ids_by_roi) + + def apply_deleted_roi_masks( + self, + labels_2d: np.ndarray, + roi_masks, + deleted_masks, + deleted_ids_by_roi, + ) -> DeletedRoiApplyResult: + return apply_deleted_roi_masks( + labels_2d, + roi_masks, + deleted_masks, + deleted_ids_by_roi, + ) + + def polygon_roi_mask( + self, + shape: tuple[int, ...], + points, + *, + z_slice=None, + ) -> np.ndarray: + return polygon_roi_mask(shape, points, z_slice=z_slice) + + def line_roi_mask( + self, + shape: tuple[int, ...], + point1, + point2, + *, + z_slice=None, + ) -> np.ndarray: + return line_roi_mask(shape, point1, point2, z_slice=z_slice) + + def rectangle_roi_mask( + self, + shape: tuple[int, ...], + origin, + size, + *, + z_slice=None, + ) -> np.ndarray: + return rectangle_roi_mask(shape, origin, size, z_slice=z_slice) + + def next_available_label_id( + self, + id_groups=(), + *, + manual_edit_info=(), + base_id: int = 0, + ) -> int: + return next_available_label_id( + id_groups, + manual_edit_info=manual_edit_info, + base_id=base_id, + ) + + def label_ids_from_labels(self, labels: np.ndarray) -> list[int]: + return label_ids_from_labels(labels) + + def remap_id_set(self, ids, old_ids, new_ids) -> set[int]: + return remap_id_set(ids, old_ids, new_ids) + + def separate_with_label( + self, + labels, + regionprops, + ids_to_separate, + max_id: int, + *, + click_coords_list=None, + ): + return separate_with_label( + labels, + regionprops, + ids_to_separate, + max_id, + click_coords_list=click_coords_list, + ) + + def nearest_nonzero_2d( + self, + labels_2d: np.ndarray, + y, + x, + *, + max_dist=None, + return_coords: bool = False, + ): + return nearest_nonzero_2D( + labels_2d, + y, + x, + max_dist=max_dist, + return_coords=return_coords, + ) + + def nearest_nonzero_z_from_centroid(self, obj, *, current_z: int = -1): + return nearest_nonzero_z_idx_from_z_centroid(obj, current_z=current_z) + + def split_along_convexity_defects( + self, + label_id: int, + labels_2d: np.ndarray, + max_id: int, + *, + max_i: int = 1, + eps_percent: float = 0.01, + ): + return split_along_convexity_defects( + label_id, + labels_2d, + max_id, + max_i=max_i, + eps_percent=eps_percent, + ) + + def split_connected_components( + self, + labels: np.ndarray, + *, + regionprops=None, + max_id=None, + ): + return core_split_connected_components( + labels, + rp=regionprops, + max_ID=max_id, + ) + + def binary_fill_holes(self, mask: np.ndarray, *, slice_by_slice: bool = True): + return core_binary_fill_holes(mask, slice_by_slice=slice_by_slice) + + def convex_hull_mask(self, mask: np.ndarray, *, slice_by_slice: bool = True): + return core_convex_hull_mask(mask, slice_by_slice=slice_by_slice) + + def count_objects(self, position_data, logger_func): + return core_count_objects(position_data, logger_func) + + def format_trimmed_ids(self, ids, *, max_num_digits=10): + return get_trimmed_list(list(ids), max_num_digits=max_num_digits) diff --git a/cellacdc/viewmodels/label_roi_viewmodel.py b/cellacdc/viewmodels/label_roi_viewmodel.py new file mode 100644 index 000000000..62868affd --- /dev/null +++ b/cellacdc/viewmodels/label_roi_viewmodel.py @@ -0,0 +1,121 @@ +"""View-model contracts for label-ROI workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.label_roi_model import ( + LabelRoiModel, + LabelRoiParamsSettings, +) + + +@dataclass(frozen=True) +class LabelRoiViewModel: + """Application-facing Magic Labeller ROI decisions.""" + + model: LabelRoiModel = field(default_factory=LabelRoiModel) + + def checked_setting_value(self, checked: bool) -> str: + return self.model.checked_setting_value(checked) + + def checked_from_setting_value(self, value) -> bool: + return self.model.checked_from_setting_value(value) + + def model_params_ini_path(self, settings_folderpath: str) -> str: + return self.model.model_params_ini_path(settings_folderpath) + + def params_settings( + self, + *, + checked_roi_type: str, + circ_roi_radius: int, + roi_zdepth: int, + auto_clear_border: bool, + replace_existing_objects: bool, + ) -> LabelRoiParamsSettings: + return self.model.params_settings( + checked_roi_type=checked_roi_type, + circ_roi_radius=circ_roi_radius, + roi_zdepth=roi_zdepth, + auto_clear_border=auto_clear_border, + replace_existing_objects=replace_existing_objects, + ) + + def is_frame_range_valid( + self, + enabled: bool, + start_frame_number: int, + stop_frame_number: int, + ) -> bool: + return self.model.is_frame_range_valid( + enabled, + start_frame_number, + stop_frame_number, + ) + + def frame_range_length( + self, + enabled: bool, + start_frame_index: int, + stop_frame_number: int, + ) -> int: + return self.model.frame_range_length( + enabled, + start_frame_index, + stop_frame_number, + ) + + def time_range( + self, + enabled: bool, + start_frame_index: int, + stop_frame_number: int, + ): + return self.model.time_range( + enabled, + start_frame_index, + stop_frame_number, + ) + + def should_enable_range_controls(self, checked: bool) -> bool: + return self.model.should_enable_range_controls(checked) + + def should_show_circular_cursor( + self, + *, + label_roi_checked: bool, + circular_roi_checked: bool, + label_roi_running: bool, + cursor_checked: bool, + existing_cursor_empty: bool, + ) -> bool: + return self.model.should_show_circular_cursor( + label_roi_checked=label_roi_checked, + circular_roi_checked=circular_roi_checked, + label_roi_running=label_roi_running, + cursor_checked=cursor_checked, + existing_cursor_empty=existing_cursor_empty, + ) + + def cursor_points(self, x, y, checked: bool): + return self.model.cursor_points(x, y, checked) + + def should_uncheck_time_range( + self, + *, + time_range_checked: bool, + persistent_action_checked: bool, + ) -> bool: + return self.model.should_uncheck_time_range( + time_range_checked=time_range_checked, + persistent_action_checked=persistent_action_checked, + ) + + def z_range( + self, + roi_zdepth: int, + size_z: int, + current_z_index: int, + ) -> tuple[int, int]: + return self.model.z_range(roi_zdepth, size_z, current_z_index) diff --git a/cellacdc/viewmodels/label_transform_tools_viewmodel.py b/cellacdc/viewmodels/label_transform_tools_viewmodel.py new file mode 100644 index 000000000..ca0a8eae8 --- /dev/null +++ b/cellacdc/viewmodels/label_transform_tools_viewmodel.py @@ -0,0 +1,51 @@ +"""View-model contracts for label transform tools.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.label_transform_tools_model import ( + LabelTransformToolsModel, +) + + +@dataclass(frozen=True) +class LabelTransformToolsViewModel: + """Application-facing label transform commands.""" + + model: LabelTransformToolsModel = field( + default_factory=LabelTransformToolsModel + ) + + def reset_expand_label_id(self) -> int: + return self.model.reset_expand_label_id() + + def should_reinitialize_expansion( + self, + *, + expanding_id: int, + hover_label_id: int, + dilation: bool, + is_dilation: bool, + ) -> bool: + return self.model.should_reinitialize_expansion( + expanding_id=expanding_id, + hover_label_id=hover_label_id, + dilation=dilation, + is_dilation=is_dilation, + ) + + def should_start_moving_label(self, label_id: int) -> bool: + return self.model.should_start_moving_label(label_id) + + def point_in_shape(self, *, x: int, y: int, shape) -> bool: + return self.model.point_in_shape(x=x, y=y, shape=shape) + + def move_delta(self, *, previous_pos, current_pos) -> tuple[int, int]: + return self.model.move_delta( + previous_pos=previous_pos, + current_pos=current_pos, + ) + + def should_clear_move_state(self, *, checked: bool) -> bool: + return self.model.should_clear_move_state(checked=checked) diff --git a/cellacdc/viewmodels/layout_controls_viewmodel.py b/cellacdc/viewmodels/layout_controls_viewmodel.py new file mode 100644 index 000000000..ec6151281 --- /dev/null +++ b/cellacdc/viewmodels/layout_controls_viewmodel.py @@ -0,0 +1,40 @@ +"""View-model contracts for layout-control workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.layout_controls_model import LayoutControlsModel + + +@dataclass(frozen=True) +class LayoutControlsViewModel: + """Application-facing decisions for GUI layout controls.""" + + model: LayoutControlsModel = field(default_factory=LayoutControlsModel) + + def zoom_percentage_from_text(self, text: str) -> int: + return self.model.zoom_percentage_from_text(text) + + def zoom_factors(self, percentage: int) -> tuple[float, float] | None: + return self.model.zoom_factors(percentage) + + def checked_setting_value(self, checked: bool) -> str: + return self.model.checked_setting_value(checked) + + def checked_from_setting_value(self, value) -> bool: + return self.model.checked_from_setting_value(value) + + def should_retain_z_slider_space( + self, + *, + checked: bool, + z_slice_enabled: bool, + ) -> bool: + return self.model.should_retain_z_slider_space( + checked=checked, + z_slice_enabled=z_slice_enabled, + ) + + def tool_name_from_tooltip(self, tooltip: str) -> str: + return self.model.tool_name_from_tooltip(tooltip) diff --git a/cellacdc/viewmodels/lineage.py b/cellacdc/viewmodels/lineage.py new file mode 100644 index 000000000..deef9fb47 --- /dev/null +++ b/cellacdc/viewmodels/lineage.py @@ -0,0 +1,61 @@ +"""View-model commands for lineage tree annotations.""" + +from __future__ import annotations + +import pandas as pd + +from cellacdc.domain.lineage import ( + LineageAnnotationsRemovalResult, + LineageFutureRemovalResult, + has_lineage_tree_annotations, + remove_future_lineage_tree_annotations, + remove_lineage_tree_annotations, +) +from cellacdc.myutils import get_obj_by_label, sort_IDs_dist + + +class LineageViewModel: + """Application-facing commands for lineage annotation tables.""" + + def has_lineage_tree_annotations( + self, + acdc_df: pd.DataFrame | None, + lineage_tree=None, + *, + parent_column: str = 'parent_ID_tree', + ) -> bool: + return has_lineage_tree_annotations( + acdc_df, + lineage_tree, + parent_column=parent_column, + ) + + def remove_lineage_tree_annotations( + self, + acdc_df: pd.DataFrame | None, + lineage_tree_colnames, + ) -> LineageAnnotationsRemovalResult: + return remove_lineage_tree_annotations(acdc_df, lineage_tree_colnames) + + def remove_future_lineage_tree_annotations( + self, + frame_records, + lineage_tree_colnames, + from_frame_i: int, + *, + size_t: int | None = None, + acdc_key: str = 'acdc_df', + ) -> LineageFutureRemovalResult: + return remove_future_lineage_tree_annotations( + frame_records, + lineage_tree_colnames, + from_frame_i, + size_t=size_t, + acdc_key=acdc_key, + ) + + def object_by_label(self, regionprops, label): + return get_obj_by_label(regionprops, label) + + def sort_ids_by_distance(self, regionprops, *, point=None, label=None): + return sort_IDs_dist(regionprops, point=point, ID=label) diff --git a/cellacdc/viewmodels/lineage_interactions_viewmodel.py b/cellacdc/viewmodels/lineage_interactions_viewmodel.py new file mode 100644 index 000000000..f8da43e2c --- /dev/null +++ b/cellacdc/viewmodels/lineage_interactions_viewmodel.py @@ -0,0 +1,108 @@ +"""View-model contracts for lineage-tree interaction workflows.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field + +import pandas as pd + +from cellacdc.models.lineage_interactions_model import ( + LineageInteractionsModel, +) + + +@dataclass(frozen=True) +class LineageInteractionsViewModel: + """Application-facing lineage-tree interaction decisions.""" + + model: LineageInteractionsModel = field( + default_factory=LineageInteractionsModel + ) + + def is_lineage_mode(self, mode: str) -> bool: + return self.model.is_lineage_mode(mode) + + def should_initialize( + self, + *, + force: bool, + mode: str, + lineage_tree_exists: bool, + ) -> bool: + return self.model.should_initialize( + force=force, + mode=mode, + lineage_tree_exists=lineage_tree_exists, + ) + + def default_mode_after_failed_init(self) -> str: + return self.model.default_mode_after_failed_init() + + def last_annotated_frame_index(self, frame_records: Iterable[dict]) -> int: + return self.model.last_annotated_frame_index(frame_records) + + def missing_frame_indices( + self, + current_frame_i: int, + present_frames: Iterable[int] | None, + ) -> list[int]: + return self.model.missing_frame_indices( + current_frame_i, + present_frames, + ) + + def should_process_auto_frame( + self, + *, + mode: str, + frame_i: int, + processed_frames: Iterable[int], + ) -> bool: + return self.model.should_process_auto_frame( + mode=mode, + frame_i=frame_i, + processed_frames=processed_frames, + ) + + def should_backup_original( + self, + original_frame_i: int | None, + current_frame_i: int, + ) -> bool: + return self.model.should_backup_original( + original_frame_i, + current_frame_i, + ) + + def next_candidate_index( + self, + click_index: int, + candidates_count: int, + ) -> int: + return self.model.next_candidate_index(click_index, candidates_count) + + def should_skip_original_mother( + self, + current_parent_id, + candidate_parent_id, + *, + original_mother_skipped: bool, + ) -> bool: + return self.model.should_skip_original_mother( + current_parent_id, + candidate_parent_id, + original_mother_skipped=original_mother_skipped, + ) + + def parent_id_differences( + self, + original_df: pd.DataFrame, + new_df: pd.DataFrame, + reset_index_cell_id: Callable[[pd.DataFrame], pd.DataFrame], + ) -> pd.DataFrame | None: + return self.model.parent_id_differences( + original_df, + new_df, + reset_index_cell_id, + ) diff --git a/cellacdc/viewmodels/magic_prompts_viewmodel.py b/cellacdc/viewmodels/magic_prompts_viewmodel.py new file mode 100644 index 000000000..f494dd57f --- /dev/null +++ b/cellacdc/viewmodels/magic_prompts_viewmodel.py @@ -0,0 +1,57 @@ +"""View-model contracts for promptable segmentation workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.magic_prompts_model import ( + MagicPromptsModel, + MagicPromptZoom, +) +from cellacdc.viewmodels.model_registry import ModelRegistryViewModel + + +@dataclass(frozen=True) +class MagicPromptsViewModel: + """Application-facing promptable-segmentation commands.""" + + model: MagicPromptsModel = field(default_factory=MagicPromptsModel) + registry: ModelRegistryViewModel = field( + default_factory=ModelRegistryViewModel + ) + + def store_custom_promptable_model_path(self, model_file_path): + return self.registry.store_custom_promptable_model_path( + model_file_path + ) + + def init_prompt_segmentation_model( + self, + acdc_prompt_segment, + position_data, + init_kwargs, + ): + return self.registry.init_prompt_segmentation_model( + acdc_prompt_segment, + position_data, + init_kwargs, + ) + + def set_default_arg_specs_from_kwargs(self, params, kwargs): + return self.registry.set_default_arg_specs_from_kwargs(params, kwargs) + + def zoom_region(self, view_range, image_shape) -> MagicPromptZoom: + return self.model.zoom_region(view_range, image_shape) + + def points_in_zoom(self, df_points, zoom: MagicPromptZoom, frame_i): + return self.model.points_in_zoom(df_points, zoom, frame_i) + + def retained_points_outside_zoom( + self, + frame_points_data, + zoom: MagicPromptZoom, + ): + return self.model.retained_points_outside_zoom( + frame_points_data, + zoom, + ) diff --git a/cellacdc/viewmodels/main.py b/cellacdc/viewmodels/main.py new file mode 100644 index 000000000..881d74a4b --- /dev/null +++ b/cellacdc/viewmodels/main.py @@ -0,0 +1,224 @@ +"""Main GUI view-model composition root.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .app_shell_viewmodel import AppShellViewModel +from .actions_viewmodel import ActionsViewModel +from .annotation_display_viewmodel import AnnotationDisplayViewModel +from .brush_tools_viewmodel import BrushToolsViewModel +from .canvas_context_menu_viewmodel import CanvasContextMenuViewModel +from .canvas_drawing_viewmodel import CanvasDrawingViewModel +from .canvas_events_viewmodel import CanvasEventsViewModel +from .canvas_hover_viewmodel import CanvasHoverViewModel +from .canvas_right_image_viewmodel import CanvasRightImageViewModel +from .canvas_selection_viewmodel import CanvasSelectionViewModel +from .canvas_tool_viewmodel import CanvasToolViewModel +from .combine_viewmodel import CombineViewModel +from .cell_cycle_viewmodel import CellCycleViewModel +from .cca_edits import CcaEditViewModel +from .cca_workflows import CcaWorkflowViewModel +from .curvature_viewmodel import CurvatureViewModel +from .custom_annotations_viewmodel import CustomAnnotationsViewModel +from .data_loading_viewmodel import DataLoadingViewModel +from .deleted_rois_viewmodel import DeletedRoisViewModel +from .display_decorations_viewmodel import DisplayDecorationsViewModel +from .draw_clear_region_viewmodel import DrawClearRegionViewModel +from .edit_id import EditIdViewModel +from .exporting_viewmodel import ExportingViewModel +from .frame_metadata import FrameMetadataViewModel +from .frame_navigation_viewmodel import FrameNavigationViewModel +from .formatting import FormattingViewModel +from .geometry import GeometryViewModel +from .graphics_viewmodel import GraphicsViewModel +from .image_controls_viewmodel import ImageControlsViewModel +from .image_display_viewmodel import ImageDisplayViewModel +from .label_editing_viewmodel import LabelEditingViewModel +from .label_edits import LabelEditViewModel +from .label_roi_viewmodel import LabelRoiViewModel +from .label_transform_tools_viewmodel import LabelTransformToolsViewModel +from .layout_controls_viewmodel import LayoutControlsViewModel +from .lineage import LineageViewModel +from .lineage_interactions_viewmodel import LineageInteractionsViewModel +from .magic_prompts_viewmodel import MagicPromptsViewModel +from .main_menu_viewmodel import MainMenuViewModel +from .main_toolbar_viewmodel import MainToolbarViewModel +from .measurements_viewmodel import MeasurementsViewModel +from .mode_controls_viewmodel import ModeControlsViewModel +from .model_registry import ModelRegistryViewModel +from .object_counts import ObjectCountViewModel +from .object_cleanup_viewmodel import ObjectCleanupViewModel +from .object_properties_viewmodel import ObjectPropertiesViewModel +from .object_search_viewmodel import ObjectSearchViewModel +from .points import PointsViewModel +from .points_layers_viewmodel import PointsLayersViewModel +from .preprocessing_viewmodel import PreprocessingViewModel +from .quick_settings_viewmodel import QuickSettingsViewModel +from .saving_viewmodel import SavingViewModel +from .seg_for_lost_ids_viewmodel import SegForLostIdsViewModel +from .segmentation_viewmodel import SegmentationViewModel +from .session_viewmodel import SessionViewModel +from .status_hover_viewmodel import StatusHoverViewModel +from .tables import TableViewModel +from .tool_activation_viewmodel import ToolActivationViewModel +from .tracking_viewmodel import TrackingViewModel +from .undo_redo_viewmodel import UndoRedoViewModel +from .whitelist_viewmodel import WhitelistViewModel +from .worker_viewmodel import WorkerViewModel +from .window_events_viewmodel import WindowEventsViewModel +from .workspace import WorkspaceViewModel + + +@dataclass(frozen=True) +class MainGuiViewModel: + """Application-facing commands available to the Qt GUI.""" + + actions: ActionsViewModel = field(default_factory=ActionsViewModel) + annotation_display: AnnotationDisplayViewModel = field( + default_factory=AnnotationDisplayViewModel + ) + app_shell: AppShellViewModel = field(default_factory=AppShellViewModel) + brush_tools: BrushToolsViewModel = field(default_factory=BrushToolsViewModel) + canvas_context_menu: CanvasContextMenuViewModel = field( + default_factory=CanvasContextMenuViewModel + ) + canvas_drawing: CanvasDrawingViewModel = field( + default_factory=CanvasDrawingViewModel + ) + canvas_events: CanvasEventsViewModel = field( + default_factory=CanvasEventsViewModel + ) + canvas_hover: CanvasHoverViewModel = field( + default_factory=CanvasHoverViewModel + ) + canvas_right_image: CanvasRightImageViewModel = field( + default_factory=CanvasRightImageViewModel + ) + canvas_selection: CanvasSelectionViewModel = field( + default_factory=CanvasSelectionViewModel + ) + canvas_tools: CanvasToolViewModel = field( + default_factory=CanvasToolViewModel + ) + combine: CombineViewModel = field( + default_factory=CombineViewModel + ) + cell_cycle: CellCycleViewModel = field( + default_factory=CellCycleViewModel + ) + cca_edits: CcaEditViewModel = field(default_factory=CcaEditViewModel) + cca_workflows: CcaWorkflowViewModel = field( + default_factory=CcaWorkflowViewModel + ) + curvature: CurvatureViewModel = field(default_factory=CurvatureViewModel) + custom_annotations: CustomAnnotationsViewModel = field( + default_factory=CustomAnnotationsViewModel + ) + data_loading: DataLoadingViewModel = field( + default_factory=DataLoadingViewModel + ) + deleted_rois: DeletedRoisViewModel = field( + default_factory=DeletedRoisViewModel + ) + display_decorations: DisplayDecorationsViewModel = field( + default_factory=DisplayDecorationsViewModel + ) + draw_clear_region: DrawClearRegionViewModel = field( + default_factory=DrawClearRegionViewModel + ) + edit_id: EditIdViewModel = field(default_factory=EditIdViewModel) + exporting: ExportingViewModel = field(default_factory=ExportingViewModel) + frame_navigation: FrameNavigationViewModel = field( + default_factory=FrameNavigationViewModel + ) + frame_metadata: FrameMetadataViewModel = field( + default_factory=FrameMetadataViewModel + ) + formatting: FormattingViewModel = field(default_factory=FormattingViewModel) + geometry: GeometryViewModel = field(default_factory=GeometryViewModel) + graphics: GraphicsViewModel = field(default_factory=GraphicsViewModel) + image_controls: ImageControlsViewModel = field( + default_factory=ImageControlsViewModel + ) + image_display: ImageDisplayViewModel = field( + default_factory=ImageDisplayViewModel + ) + label_editing: LabelEditingViewModel = field( + default_factory=LabelEditingViewModel + ) + label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) + label_roi: LabelRoiViewModel = field(default_factory=LabelRoiViewModel) + label_transform_tools: LabelTransformToolsViewModel = field( + default_factory=LabelTransformToolsViewModel + ) + layout_controls: LayoutControlsViewModel = field( + default_factory=LayoutControlsViewModel + ) + lineage: LineageViewModel = field(default_factory=LineageViewModel) + lineage_interactions: LineageInteractionsViewModel = field( + default_factory=LineageInteractionsViewModel + ) + magic_prompts: MagicPromptsViewModel = field( + default_factory=MagicPromptsViewModel + ) + main_menu: MainMenuViewModel = field(default_factory=MainMenuViewModel) + main_toolbar: MainToolbarViewModel = field( + default_factory=MainToolbarViewModel + ) + measurements: MeasurementsViewModel = field( + default_factory=MeasurementsViewModel + ) + mode_controls: ModeControlsViewModel = field( + default_factory=ModeControlsViewModel + ) + model_registry: ModelRegistryViewModel = field( + default_factory=ModelRegistryViewModel + ) + object_search: ObjectSearchViewModel = field( + default_factory=ObjectSearchViewModel + ) + object_counts: ObjectCountViewModel = field( + default_factory=ObjectCountViewModel + ) + object_cleanup: ObjectCleanupViewModel = field( + default_factory=ObjectCleanupViewModel + ) + object_properties: ObjectPropertiesViewModel = field( + default_factory=ObjectPropertiesViewModel + ) + points: PointsViewModel = field(default_factory=PointsViewModel) + points_layers: PointsLayersViewModel = field( + default_factory=PointsLayersViewModel + ) + preprocessing: PreprocessingViewModel = field( + default_factory=PreprocessingViewModel + ) + quick_settings: QuickSettingsViewModel = field( + default_factory=QuickSettingsViewModel + ) + saving: SavingViewModel = field(default_factory=SavingViewModel) + seg_for_lost_ids: SegForLostIdsViewModel = field( + default_factory=SegForLostIdsViewModel + ) + segmentation: SegmentationViewModel = field( + default_factory=SegmentationViewModel + ) + session: SessionViewModel = field(default_factory=SessionViewModel) + status_hover: StatusHoverViewModel = field( + default_factory=StatusHoverViewModel + ) + tables: TableViewModel = field(default_factory=TableViewModel) + tool_activation: ToolActivationViewModel = field( + default_factory=ToolActivationViewModel + ) + tracking: TrackingViewModel = field(default_factory=TrackingViewModel) + undo_redo: UndoRedoViewModel = field(default_factory=UndoRedoViewModel) + whitelist: WhitelistViewModel = field( + default_factory=WhitelistViewModel + ) + worker: WorkerViewModel = field(default_factory=WorkerViewModel) + window_events: WindowEventsViewModel = field( + default_factory=WindowEventsViewModel + ) + workspace: WorkspaceViewModel = field(default_factory=WorkspaceViewModel) diff --git a/cellacdc/viewmodels/main_menu_viewmodel.py b/cellacdc/viewmodels/main_menu_viewmodel.py new file mode 100644 index 000000000..e4be3273b --- /dev/null +++ b/cellacdc/viewmodels/main_menu_viewmodel.py @@ -0,0 +1,20 @@ +"""View-model contracts for the main menu.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.main_menu_model import MainMenuModel + + +@dataclass(frozen=True) +class MainMenuViewModel: + """Application-facing main-menu commands.""" + + model: MainMenuModel = field(default_factory=MainMenuModel) + + def default_rescale_intensity_options(self): + return self.model.default_rescale_intensity_options + + def default_rescale_intensity_how(self, settings): + return self.model.default_rescale_intensity_how(settings) diff --git a/cellacdc/viewmodels/main_toolbar_viewmodel.py b/cellacdc/viewmodels/main_toolbar_viewmodel.py new file mode 100644 index 000000000..87df58903 --- /dev/null +++ b/cellacdc/viewmodels/main_toolbar_viewmodel.py @@ -0,0 +1,17 @@ +"""View-model contracts for the main GUI toolbars.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.main_toolbar_model import MainToolbarModel + + +@dataclass(frozen=True) +class MainToolbarViewModel: + """Application-facing toolbar metadata.""" + + model: MainToolbarModel = field(default_factory=MainToolbarModel) + + def mode_items(self) -> tuple[str, ...]: + return self.model.default_mode_items() diff --git a/cellacdc/viewmodels/measurements.py b/cellacdc/viewmodels/measurements.py new file mode 100644 index 000000000..3d0e5b1a9 --- /dev/null +++ b/cellacdc/viewmodels/measurements.py @@ -0,0 +1,5 @@ +"""Compatibility imports for measurement view-models.""" + +from cellacdc.viewmodels.measurements_viewmodel import MeasurementsViewModel + +__all__ = ['MeasurementsViewModel'] diff --git a/cellacdc/viewmodels/measurements_viewmodel.py b/cellacdc/viewmodels/measurements_viewmodel.py new file mode 100644 index 000000000..91e17f8df --- /dev/null +++ b/cellacdc/viewmodels/measurements_viewmodel.py @@ -0,0 +1,49 @@ +"""View-model contracts for measurement workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.measurements_model import MeasurementsModel + + +@dataclass(frozen=True) +class MeasurementsViewModel: + """Application-facing commands for measurement calculations.""" + + model: MeasurementsModel = field(default_factory=MeasurementsModel) + + def rotational_volume( + self, + obj, + physical_size_y=1, + physical_size_x=1, + logger=None, + ): + return self.model.rotational_volume( + obj, + physical_size_y, + physical_size_x, + logger=logger, + ) + + def custom_metrics_instructions(self): + return self.model.custom_metrics_instructions() + + def metrics_examples_path(self): + return self.model.metrics_examples_path() + + def all_acdc_df_columns(self, all_pos_data): + return self.model.all_acdc_df_columns(all_pos_data) + + def not_loaded_channels(self, all_channel_names, loaded_channel_names): + return self.model.not_loaded_channels( + all_channel_names, loaded_channel_names + ) + + def drop_unchecked_measurements(self, acdc_df, columns, regionprops): + return self.model.drop_unchecked_measurements( + acdc_df, + columns, + regionprops, + ) diff --git a/cellacdc/viewmodels/mode_controls_viewmodel.py b/cellacdc/viewmodels/mode_controls_viewmodel.py new file mode 100644 index 000000000..756ba4288 --- /dev/null +++ b/cellacdc/viewmodels/mode_controls_viewmodel.py @@ -0,0 +1,36 @@ +"""View-model contracts for GUI mode controls.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.mode_controls_model import ModeControlsModel + + +@dataclass(frozen=True) +class ModeControlsViewModel: + """Application-facing mode-control decisions.""" + + model: ModeControlsModel = field(default_factory=ModeControlsModel) + + def should_start_blinking( + self, + mode: str, + *, + ruler_checked: bool = False, + ) -> bool: + return self.model.should_start_blinking( + mode, ruler_checked=ruler_checked + ) + + def blink_styles(self, flag: bool) -> tuple[str, bool]: + return self.model.blink_styles(flag) + + def should_store_on_mode_change(self, previous_mode: str) -> bool: + return self.model.should_store_on_mode_change(previous_mode) + + def is_cca_mode(self, mode: str) -> bool: + return self.model.is_cca_mode(mode) + + def undo_redo_target(self, mode: str) -> str: + return self.model.undo_redo_target(mode) diff --git a/cellacdc/viewmodels/model_registry.py b/cellacdc/viewmodels/model_registry.py new file mode 100644 index 000000000..80714139b --- /dev/null +++ b/cellacdc/viewmodels/model_registry.py @@ -0,0 +1,134 @@ +"""View-model commands for model registry discovery.""" + +from __future__ import annotations + +from cellacdc.myutils import ( + aliases_real_time_trackers, + check_gpu_available, + check_install_package, + getModelArgSpec, + get_list_of_models, + get_list_of_real_time_trackers, + import_segment_module, + init_prompt_segm_model, + init_segm_model, + init_tracker, + insertModelArgSpec, + log_segm_params, + setDefaultValueArgSpecsFromKwargs, + store_custom_model_path, + store_custom_promptable_model_path, + validate_tracker_input, +) + + +class ModelRegistryViewModel: + """Application-facing commands for available model registries.""" + + def segmentation_models(self, *, include_local_seg: bool = False): + models = list(get_list_of_models()) + if include_local_seg and 'local_seg' not in models: + models.append('local_seg') + return models + + def real_time_trackers(self): + return get_list_of_real_time_trackers() + + def real_time_tracker_aliases(self, *, reverse: bool = False): + return aliases_real_time_trackers(reverse=reverse) + + def model_arg_specs(self, acdc_segment): + return getModelArgSpec(acdc_segment) + + def import_segmentation_module(self, model_name): + return import_segment_module(model_name) + + def check_install_package(self, model_name): + return check_install_package(model_name) + + def check_gpu_available( + self, + model_name, + use_gpu, + *, + qparent=None, + do_not_warn=False, + ): + return check_gpu_available( + model_name, + use_gpu, + qparent=qparent, + do_not_warn=do_not_warn, + ) + + def init_segmentation_model(self, acdc_segment, position_data, init_kwargs): + return init_segm_model(acdc_segment, position_data, init_kwargs) + + def init_prompt_segmentation_model( + self, + acdc_prompt_segment, + position_data, + init_kwargs, + ): + return init_prompt_segm_model( + acdc_prompt_segment, + position_data, + init_kwargs, + ) + + def init_tracker(self, position_data, tracker_name, **kwargs): + return init_tracker(position_data, tracker_name, **kwargs) + + def validate_tracker_input(self, tracker, segmentation_video): + return validate_tracker_input(tracker, segmentation_video) + + def log_segmentation_params( + self, + model_name, + init_params, + segment_params, + *, + logger_func=print, + preproc_recipe=None, + apply_post_process=False, + standard_postprocess_kwargs=None, + custom_postprocess_features=None, + ): + return log_segm_params( + model_name, + init_params, + segment_params, + logger_func=logger_func, + preproc_recipe=preproc_recipe, + apply_post_process=apply_post_process, + standard_postprocess_kwargs=standard_postprocess_kwargs, + custom_postprocess_features=custom_postprocess_features, + ) + + def store_custom_model_path(self, model_file_path): + return store_custom_model_path(model_file_path) + + def store_custom_promptable_model_path(self, model_file_path): + return store_custom_promptable_model_path(model_file_path) + + def set_default_arg_specs_from_kwargs(self, params, kwargs): + return setDefaultValueArgSpecsFromKwargs(params, kwargs) + + def insert_model_arg_spec( + self, + params, + param_name, + param_value, + *, + param_type=None, + desc='', + docstring='', + ): + return insertModelArgSpec( + params, + param_name, + param_value, + param_type=param_type, + desc=desc, + docstring=docstring, + ) diff --git a/cellacdc/viewmodels/object_cleanup_viewmodel.py b/cellacdc/viewmodels/object_cleanup_viewmodel.py new file mode 100644 index 000000000..ed6aad71a --- /dev/null +++ b/cellacdc/viewmodels/object_cleanup_viewmodel.py @@ -0,0 +1,30 @@ +"""View-model contracts for object cleanup workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.object_cleanup_model import ObjectCleanupModel + +from .workspace import WorkspaceViewModel + + +@dataclass(frozen=True) +class ObjectCleanupViewModel: + """Application-facing object-cleanup commands.""" + + model: ObjectCleanupModel = field(default_factory=ObjectCleanupModel) + workspace: WorkspaceViewModel = field(default_factory=WorkspaceViewModel) + + def segmentation_roi_endnames(self, *, basename, images_path): + segm_files = self.workspace.segmentation_files(images_path) + return self.workspace.endnames(basename, segm_files) + + def cleared_segmentation_frames(self, cleared_segm_data, *, size_t: int): + return self.model.cleared_segmentation_frames( + cleared_segm_data, + size_t=size_t, + ) + + def frame_labels(self, cleared_segm_data): + return self.model.frame_labels(cleared_segm_data) diff --git a/cellacdc/viewmodels/object_counts.py b/cellacdc/viewmodels/object_counts.py new file mode 100644 index 000000000..bdff7a08b --- /dev/null +++ b/cellacdc/viewmodels/object_counts.py @@ -0,0 +1,38 @@ +"""View-model commands for object counts and label frame selection.""" + +from __future__ import annotations + +import os + +from cellacdc.domain.object_counts import ( + collect_all_ids, + current_labels, + snapshot_object_counts, +) + + +class ObjectCountViewModel: + """Application-facing object count and label-frame commands.""" + + def current_labels(self, pos_data, *, curr_lab=None, frame_i=None): + return current_labels(pos_data, curr_lab=curr_lab, frame_i=frame_i) + + def collect_all_ids(self, pos_data, *, only_visited: bool = False) -> set[int]: + return collect_all_ids(pos_data, only_visited=only_visited) + + def snapshot_object_counts( + self, + positions, + current_pos_i: int, + *, + current_lab_2d=None, + include_current_z_slice: bool = False, + path_exists=os.path.exists, + ) -> dict[str, int]: + return snapshot_object_counts( + positions, + current_pos_i, + current_lab_2d=current_lab_2d, + include_current_z_slice=include_current_z_slice, + path_exists=path_exists, + ) diff --git a/cellacdc/viewmodels/object_properties_viewmodel.py b/cellacdc/viewmodels/object_properties_viewmodel.py new file mode 100644 index 000000000..6daacf4e7 --- /dev/null +++ b/cellacdc/viewmodels/object_properties_viewmodel.py @@ -0,0 +1,180 @@ +"""View-model contracts for object-property workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import numpy as np + +from cellacdc.models.object_properties_model import ObjectPropertiesModel + +from .measurements_viewmodel import MeasurementsViewModel +from .object_counts import ObjectCountViewModel + + + +@dataclass(frozen=True) +class ObjectPropertiesViewModel: + """Application-facing object-property decisions and commands.""" + + model: ObjectPropertiesModel = field(default_factory=ObjectPropertiesModel) + measurements: MeasurementsViewModel = field( + default_factory=MeasurementsViewModel + ) + object_counts: ObjectCountViewModel = field( + default_factory=ObjectCountViewModel + ) + + def timelapse_default_categories(self) -> set[str]: + return self.model.timelapse_default_categories() + + def snapshot_default_categories(self, *, is_segm_3d: bool) -> set[str]: + return self.model.snapshot_default_categories(is_segm_3d=is_segm_3d) + + def should_update_object_counts( + self, + *, + window_exists: bool, + is_visible: bool, + live_preview_checked: bool, + ) -> bool: + return self.model.should_update_object_counts( + window_exists=window_exists, + is_visible=is_visible, + live_preview_checked=live_preview_checked, + ) + + def should_show_3d_property_controls(self, is_segm_3d: bool) -> bool: + return self.model.should_show_3d_property_controls(is_segm_3d) + + def should_highlight_props_id( + self, + *, + dock_visible: bool, + highlight_checked: bool, + searched_highlight_checked: bool, + ) -> bool: + return self.model.should_highlight_props_id( + dock_visible=dock_visible, + highlight_checked=highlight_checked, + searched_highlight_checked=searched_highlight_checked, + ) + + def should_update_props_widget( + self, + *, + dock_visible: bool, + object_id: int, + current_props_id: int, + ) -> bool: + return self.model.should_update_props_widget( + dock_visible=dock_visible, + object_id=object_id, + current_props_id=current_props_id, + ) + + def calculate_area_pxl( + self, + *, + is_segm_3d: bool, + z_proj_text: str, + z_lab: int, + bbox_0: int, + obj_image: np.ndarray, + obj_area: int, + ) -> int: + return self.model.calculate_area_pxl( + is_segm_3d=is_segm_3d, + z_proj_text=z_proj_text, + z_lab=z_lab, + bbox_0=bbox_0, + obj_image=obj_image, + obj_area=obj_area, + ) + + def calculate_area_um2( + self, + *, + area_pxl: int, + physical_size_x: float, + physical_size_y: float, + ) -> float: + return self.model.calculate_area_um2( + area_pxl=area_pxl, + physical_size_x=physical_size_x, + physical_size_y=physical_size_y, + ) + + def calculate_vol_3d( + self, + *, + obj_area: int, + physical_size_x: float, + physical_size_y: float, + physical_size_z: float, + ) -> tuple[float, float]: + return self.model.calculate_vol_3d( + obj_area=obj_area, + physical_size_x=physical_size_x, + physical_size_y=physical_size_y, + physical_size_z=physical_size_z, + ) + + def calculate_elongation( + self, + *, + major_axis_length: float, + minor_axis_length: float, + ) -> float: + return self.model.calculate_elongation( + major_axis_length=major_axis_length, + minor_axis_length=minor_axis_length, + ) + + def get_object_and_background_images( + self, + *, + image: np.ndarray, + is_segm_3d: bool, + pos_data_size_z: int, + z_slice: int, + obj_slice: tuple, + obj_image: np.ndarray, + img1_image: np.ndarray | None = None, + ) -> tuple[np.ndarray, np.ndarray]: + return self.model.get_object_and_background_images( + image=image, + is_segm_3d=is_segm_3d, + pos_data_size_z=pos_data_size_z, + z_slice=z_slice, + obj_slice=obj_slice, + obj_image=obj_image, + img1_image=img1_image, + ) + + def calculate_intensity_statistics( + self, + obj_data: np.ndarray, + ) -> dict[str, float]: + return self.model.calculate_intensity_statistics(obj_data) + + def calculate_additional_measure( + self, + *, + func_desc: str, + func: callable, + obj_data: np.ndarray, + img: np.ndarray, + lab: np.ndarray, + obj_area: int, + vol_vox: float, + ) -> float: + return self.model.calculate_additional_measure( + func_desc=func_desc, + func=func, + obj_data=obj_data, + img=img, + lab=lab, + obj_area=obj_area, + vol_vox=vol_vox, + ) + diff --git a/cellacdc/viewmodels/object_search_viewmodel.py b/cellacdc/viewmodels/object_search_viewmodel.py new file mode 100644 index 000000000..4b7c8b56a --- /dev/null +++ b/cellacdc/viewmodels/object_search_viewmodel.py @@ -0,0 +1,28 @@ +"""View-model contracts for object search and navigation.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field + +from cellacdc.models.object_search_model import ObjectSearchModel + + +@dataclass(frozen=True) +class ObjectSearchViewModel: + """Application-facing commands for finding object IDs across frames.""" + + model: ObjectSearchModel = field(default_factory=ObjectSearchModel) + + def find_frame_with_id( + self, + pos_data, + searched_id: int, + *, + progress_callback: Callable[[int], None] | None = None, + ) -> int | None: + return self.model.find_frame_with_id( + pos_data, + searched_id, + progress_callback=progress_callback, + ) diff --git a/cellacdc/viewmodels/points.py b/cellacdc/viewmodels/points.py new file mode 100644 index 000000000..3165b5dfe --- /dev/null +++ b/cellacdc/viewmodels/points.py @@ -0,0 +1,135 @@ +"""View-model commands for point-layer data.""" + +from __future__ import annotations + +from cellacdc.io.readers.points import load_click_points_table, load_points_table +from cellacdc.io.writers.points import ( + click_points_table_filename, + save_click_points_table, +) + +from cellacdc.domain.points import ( + add_click_point, + click_points_table_to_data, + flatten_frame_points_data, + next_click_point_id, + point_id_already_new, + points_table_to_data, + remove_click_points, +) + + +class PointsViewModel: + """Application-facing commands for point-layer data transforms.""" + + def click_points_table_filename( + self, + basename: str, + table_endname: str, + ) -> str: + return click_points_table_filename(basename, table_endname) + + def load_click_points_table(self, filepath): + return load_click_points_table(filepath) + + def load_points_table(self, filepath): + return load_points_table(filepath) + + def loaded_table_to_points_data( + self, + df, + t_col, + z_col, + y_col, + x_col, + ): + return points_table_to_data(df, t_col, z_col, y_col, x_col) + + def save_click_points_table( + self, + filepath, + df, + sort_by=('frame_i', 'Cell_ID'), + ): + return save_click_points_table(filepath, df, sort_by=sort_by) + + def click_points_table_to_data(self, df, *, size_z: int = 1): + return click_points_table_to_data(df, size_z=size_z) + + def remove_click_points( + self, + frame_points_data, + points, + *, + z_slice: int | None = None, + z_radius: int = 0, + ) -> list[int]: + return remove_click_points( + frame_points_data, + points, + z_slice=z_slice, + z_radius=z_radius, + ) + + def next_click_point_id( + self, + points_data_pos, + frame_i: int, + current_id: int, + *, + size_z: int = 1, + ) -> int: + return next_click_point_id( + points_data_pos, + frame_i, + current_id, + size_z=size_z, + ) + + def point_id_already_new( + self, + points_data_pos, + frame_i: int, + point_id: int, + known_ids, + ) -> bool: + return point_id_already_new( + points_data_pos, + frame_i, + point_id, + known_ids, + ) + + def add_click_point( + self, + points_data_pos, + frame_i: int, + x: float, + y: float, + point_id: int, + *, + size_z: int = 1, + z_slice: int | None = None, + ): + return add_click_point( + points_data_pos, + frame_i, + x, + y, + point_id, + size_z=size_z, + z_slice=z_slice, + ) + + def flatten_frame_points_data( + self, + frame_points_data, + *, + z_slice: int | None = None, + z_radius: int = 0, + ): + return flatten_frame_points_data( + frame_points_data, + z_slice=z_slice, + z_radius=z_radius, + ) diff --git a/cellacdc/viewmodels/points_layers_viewmodel.py b/cellacdc/viewmodels/points_layers_viewmodel.py new file mode 100644 index 000000000..9ba391b88 --- /dev/null +++ b/cellacdc/viewmodels/points_layers_viewmodel.py @@ -0,0 +1,67 @@ +"""View-model contracts for points-layer workflows.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field + +from cellacdc.models.points_layers_model import PointsLayersModel + +from .points import PointsViewModel + + +@dataclass(frozen=True) +class PointsLayersViewModel: + """Application-facing points-layer decisions and data transforms.""" + + model: PointsLayersModel = field(default_factory=PointsLayersModel) + points: PointsViewModel = field(default_factory=PointsViewModel) + + def click_entry_table_filename( + self, + basename: str, + table_endname: str, + ) -> str: + return self.model.click_entry_table_filename(basename, table_endname) + + def should_load_recovery_table( + self, + *, + recovery_exists: bool, + main_exists: bool, + recovery_mtime: float | None, + main_mtime: float | None, + ) -> bool: + return self.model.should_load_recovery_table( + recovery_exists=recovery_exists, + main_exists=main_exists, + recovery_mtime=recovery_mtime, + main_mtime=main_mtime, + ) + + def should_compute_points_layer( + self, + *, + layer_type_index: int, + compute_points_layers: bool, + ) -> bool: + return self.model.should_compute_points_layer( + layer_type_index=layer_type_index, + compute_points_layers=compute_points_layers, + ) + + def should_log_missing_frame_points(self, layer_type_index: int) -> bool: + return self.model.should_log_missing_frame_points(layer_type_index) + + def should_use_z_slice( + self, + *, + z_projection_mode: str, + size_z: int, + frame_points_data: Mapping, + ) -> bool: + return self.model.should_use_z_slice( + z_projection_mode=z_projection_mode, + size_z=size_z, + frame_points_data=frame_points_data, + ) diff --git a/cellacdc/viewmodels/preprocessing_viewmodel.py b/cellacdc/viewmodels/preprocessing_viewmodel.py new file mode 100644 index 000000000..f7403f66b --- /dev/null +++ b/cellacdc/viewmodels/preprocessing_viewmodel.py @@ -0,0 +1,65 @@ +"""View-model contracts for image preprocessing recipes.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from cellacdc.models.preprocessing_model import PreprocessingModel + + +@dataclass(frozen=True) +class PreprocessingViewModel: + """Application-facing commands for preprocessing recipe execution.""" + + model: PreprocessingModel = field(default_factory=PreprocessingModel) + + def validate_multidimensional_recipe( + self, + recipe: list[dict[str, Any]], + *, + apply_to_all_zslices: bool = False, + apply_to_all_frames: bool = False, + ): + return self.model.validate_multidimensional_recipe( + recipe, + apply_to_all_zslices=apply_to_all_zslices, + apply_to_all_frames=apply_to_all_frames, + ) + + def preprocess_image_from_recipe(self, image, recipe: list[dict[str, Any]]): + return self.model.preprocess_image_from_recipe(image, recipe) + + def preprocess_zstack_from_recipe(self, image, recipe: list[dict[str, Any]]): + return self.model.preprocess_zstack_from_recipe(image, recipe) + + def preprocess_video_from_recipe(self, image, recipe: list[dict[str, Any]]): + return self.model.preprocess_video_from_recipe(image, recipe) + + def preprocess_multi_pos_from_recipe( + self, + images, + recipe: list[dict[str, Any]], + ): + return self.model.preprocess_multi_pos_from_recipe(images, recipe) + + def image_to_float( + self, + image, + *, + force_dtype=None, + force_missing_dtype=None, + warn=True, + ): + return self.model.image_to_float( + image, + force_dtype=force_dtype, + force_missing_dtype=force_missing_dtype, + warn=warn, + ) + + def normalize_display_image(self, image, how: str): + return self.model.normalize_display_image(image, how) + + def create_preprocessed_data(self, image_data=None): + return self.model.create_preprocessed_data(image_data=image_data) diff --git a/cellacdc/viewmodels/quick_settings_viewmodel.py b/cellacdc/viewmodels/quick_settings_viewmodel.py new file mode 100644 index 000000000..98f5ae306 --- /dev/null +++ b/cellacdc/viewmodels/quick_settings_viewmodel.py @@ -0,0 +1,33 @@ +"""View-model contracts for quick settings.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.quick_settings_model import ( + FontSizeSetting, + QuickSettingsModel, +) + + +@dataclass(frozen=True) +class QuickSettingsViewModel: + """Application-facing quick-settings commands.""" + + model: QuickSettingsModel = field(default_factory=QuickSettingsModel) + + def font_size_setting( + self, + saved_font_size, + *, + has_px_mode: bool, + ) -> FontSizeSetting: + return self.model.font_size_setting( + saved_font_size, + has_px_mode=has_px_mode, + ) + + def should_update_all_contours(self, *, is_data_loaded: bool) -> bool: + return self.model.should_update_all_contours( + is_data_loaded=is_data_loaded + ) diff --git a/cellacdc/viewmodels/saving_viewmodel.py b/cellacdc/viewmodels/saving_viewmodel.py new file mode 100644 index 000000000..fdae14fb1 --- /dev/null +++ b/cellacdc/viewmodels/saving_viewmodel.py @@ -0,0 +1,137 @@ +"""View-model contracts for save and autosave workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +from cellacdc.models.saving_model import ( + AutosaveIntervalChange, + ConcatenatePromptPlan, + SavingModel, +) + +from .cca_workflows import CcaWorkflowViewModel +from .formatting import FormattingViewModel +from .measurements_viewmodel import MeasurementsViewModel +from .tracking_viewmodel import TrackingViewModel +from .workspace import WorkspaceViewModel + + +@dataclass(frozen=True) +class SavingViewModel: + """Application-facing save/autosave commands and decisions.""" + + model: SavingModel = field(default_factory=SavingModel) + cca_workflows: CcaWorkflowViewModel = field( + default_factory=CcaWorkflowViewModel + ) + formatting: FormattingViewModel = field(default_factory=FormattingViewModel) + measurements: MeasurementsViewModel = field( + default_factory=MeasurementsViewModel + ) + tracking: TrackingViewModel = field(default_factory=TrackingViewModel) + workspace: WorkspaceViewModel = field(default_factory=WorkspaceViewModel) + + def should_clear_autosave_status(self, *, mode: str) -> bool: + return self.model.should_clear_autosave_status(mode=mode) + + def should_enqueue_autosave( + self, + *, + mode: str, + has_active_workers: bool, + ): + return self.model.should_enqueue_autosave( + mode=mode, + has_active_workers=has_active_workers, + ) + + def autosave_schedule( + self, + value: float, + unit: Literal['minutes', 'frames'], + ): + return self.model.autosave_schedule(value, unit) + + def autosave_interval_change( + self, + value: float, + unit: Literal['minutes', 'frames'], + ) -> AutosaveIntervalChange: + return self.model.autosave_interval_change(value, unit) + + def concatenate_prompt_plan( + self, + *, + has_main_window: bool, + is_quick_save: bool, + setting_exists: bool, + show_setting_value: str | None, + ) -> ConcatenatePromptPlan: + return self.model.concatenate_prompt_plan( + has_main_window=has_main_window, + is_quick_save=is_quick_save, + setting_exists=setting_exists, + show_setting_value=show_setting_value, + ) + + def concatenate_prompt_setting(self, *, do_not_show_again: bool) -> str: + return self.model.concatenate_prompt_setting( + do_not_show_again=do_not_show_again + ) + + def autosave_segmentation_enabled(self, *, mode: str, checked: bool) -> bool: + return self.model.autosave_segmentation_enabled( + mode=mode, + checked=checked, + ) + + def autosave_annotations_enabled(self, *, mode: str, checked: bool) -> bool: + return self.model.autosave_annotations_enabled( + mode=mode, + checked=checked, + ) + + def save_as_basename(self, basename: str) -> str: + return self.model.save_as_basename(basename) + + def quick_save_positions(self, position_foldername: str) -> set[str]: + return self.model.quick_save_positions(position_foldername) + + def should_ask_positions( + self, + *, + is_snapshot: bool, + is_quick_save: bool, + position_count: int, + ) -> bool: + return self.model.should_ask_positions( + is_snapshot=is_snapshot, + is_quick_save=is_quick_save, + position_count=position_count, + ) + + def should_compute_volume_metrics( + self, + *, + save_metrics: bool, + mode: str, + ) -> bool: + return self.model.should_compute_volume_metrics( + save_metrics=save_metrics, + mode=mode, + ) + + def save_finished_title( + self, + *, + aborted: bool, + worker_aborted: bool, + is_quick_save: bool, + ) -> tuple[str, str | None]: + return self.model.save_finished_title( + aborted=aborted, + worker_aborted=worker_aborted, + is_quick_save=is_quick_save, + ) diff --git a/cellacdc/viewmodels/seg_for_lost_ids_viewmodel.py b/cellacdc/viewmodels/seg_for_lost_ids_viewmodel.py new file mode 100644 index 000000000..f46fb0cdc --- /dev/null +++ b/cellacdc/viewmodels/seg_for_lost_ids_viewmodel.py @@ -0,0 +1,53 @@ +"""View-model contracts for segmenting lost IDs.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from cellacdc.models.seg_for_lost_ids_model import ( + SegForLostIdsModel, + SegForLostIdsSettings, +) +from cellacdc.myutils import ArgSpec + + +@dataclass(frozen=True) +class SegForLostIdsViewModel: + """Application-facing commands for lost-ID segmentation settings.""" + + model: SegForLostIdsModel = field(default_factory=SegForLostIdsModel) + + @property + def settings_key(self) -> str: + return self.model.settings_key + + @property + def worker_model_name(self) -> str: + return self.model.worker_model_name + + def previous_model_name(self, df_settings) -> str | None: + return self.model.previous_model_name(df_settings) + + def should_persist_model_choice(self, base_model_name: str | None) -> bool: + return self.model.should_persist_model_choice(base_model_name) + + def extra_arg_specs(self) -> list[ArgSpec]: + return self.model.extra_arg_specs() + + def split_model_kwargs( + self, + init_kwargs: dict[str, Any], + extra_kwargs: dict[str, Any], + ) -> tuple[dict[str, Any], dict[str, Any]]: + return self.model.split_model_kwargs(init_kwargs, extra_kwargs) + + def settings_from_dialog( + self, + win, + base_model_name: str, + ) -> SegForLostIdsSettings: + return self.model.settings_from_dialog(win, base_model_name) + + def can_start_from_frame(self, frame_i: int) -> bool: + return self.model.can_start_from_frame(frame_i) diff --git a/cellacdc/viewmodels/segmentation_viewmodel.py b/cellacdc/viewmodels/segmentation_viewmodel.py new file mode 100644 index 000000000..f2f5cf27b --- /dev/null +++ b/cellacdc/viewmodels/segmentation_viewmodel.py @@ -0,0 +1,82 @@ +"""View-model contracts for segmentation workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.segmentation_model import SegmentationModel + +from .model_registry import ModelRegistryViewModel + + +@dataclass(frozen=True) +class SegmentationViewModel: + """Application-facing segmentation commands and decisions.""" + + model: SegmentationModel = field(default_factory=SegmentationModel) + model_registry: ModelRegistryViewModel = field( + default_factory=ModelRegistryViewModel + ) + + def action_model_name(self, model_name: str) -> str: + return self.model.action_model_name(model_name) + + def backend_model_name(self, model_name: str) -> str: + return self.model.backend_model_name(model_name) + + def should_compute_segmentation( + self, + *, + mode: str, + has_labels: bool, + force: bool, + auto_enabled: bool, + ) -> bool: + return self.model.should_compute_segmentation( + mode=mode, + has_labels=has_labels, + force=force, + auto_enabled=auto_enabled, + ) + + def post_process_params( + self, + *, + apply_postprocessing, + standard_postprocess_kwargs=None, + custom_postprocess_features=None, + ) -> dict: + return self.model.post_process_params( + apply_postprocessing=apply_postprocessing, + standard_postprocess_kwargs=standard_postprocess_kwargs, + custom_postprocess_features=custom_postprocess_features, + ) + + def empty_segmentation_prompt(self, position_data): + return self.model.empty_segmentation_prompt(position_data) + + def segmentation_models(self, *, include_local_seg: bool = False): + return self.model_registry.segmentation_models( + include_local_seg=include_local_seg + ) + + def store_custom_model_path(self, model_file_path): + return self.model_registry.store_custom_model_path(model_file_path) + + def import_segmentation_module(self, model_name): + return self.model_registry.import_segmentation_module(model_name) + + def model_arg_specs(self, acdc_segment): + return self.model_registry.model_arg_specs(acdc_segment) + + def insert_model_arg_spec(self, *args, **kwargs): + return self.model_registry.insert_model_arg_spec(*args, **kwargs) + + def log_segmentation_params(self, *args, **kwargs): + return self.model_registry.log_segmentation_params(*args, **kwargs) + + def check_gpu_available(self, *args, **kwargs): + return self.model_registry.check_gpu_available(*args, **kwargs) + + def init_segmentation_model(self, *args, **kwargs): + return self.model_registry.init_segmentation_model(*args, **kwargs) diff --git a/cellacdc/viewmodels/session.py b/cellacdc/viewmodels/session.py new file mode 100644 index 000000000..f46db6999 --- /dev/null +++ b/cellacdc/viewmodels/session.py @@ -0,0 +1,7 @@ +"""Compatibility re-export for session view-models.""" + +from __future__ import annotations + +from .session_viewmodel import DEFAULT_SESSION_SETTINGS, SessionViewModel + +__all__ = ['DEFAULT_SESSION_SETTINGS', 'SessionViewModel'] diff --git a/cellacdc/viewmodels/session_viewmodel.py b/cellacdc/viewmodels/session_viewmodel.py new file mode 100644 index 000000000..972c0a2b4 --- /dev/null +++ b/cellacdc/viewmodels/session_viewmodel.py @@ -0,0 +1,144 @@ +"""View-model commands for session frame state.""" + +from __future__ import annotations + +import os + +import pandas as pd + +from cellacdc.models.session_model import SessionModel +from cellacdc.domain import PositionSession +from cellacdc.domain.visited_frames import ( + LastVisitedFrameUpdate, + update_last_visited_frame_state, +) + +from .cca_edits import CcaEditViewModel +from .frame_metadata import FrameMetadataViewModel +from .tables import TableViewModel +from .workspace import WorkspaceViewModel + + +DEFAULT_SESSION_SETTINGS = { + 'is_bw_inverted': 'No', + 'fontSize': 12, + 'overlayColor': '255-255-0', + 'how_normIntensities': 'raw', + 'isLabelsVisible': 'No', + 'isNextFrameVisible': 'No', + 'isRightImageVisible': 'Yes', + 'manual_separate_draw_mode': 'threepoints_arc', +} + + +class SessionViewModel: + """Application-facing commands for session progress state.""" + + def __init__( + self, + model: SessionModel | None = None, + *, + cca_edits: CcaEditViewModel | None = None, + frame_metadata: FrameMetadataViewModel | None = None, + tables: TableViewModel | None = None, + workspace: WorkspaceViewModel | None = None, + ): + self.model = model or SessionModel() + self.cca_edits = cca_edits or CcaEditViewModel() + self.frame_metadata = frame_metadata or FrameMetadataViewModel() + self.tables = tables or TableViewModel() + self.workspace = workspace or WorkspaceViewModel() + + def recent_paths(self, recent_paths_path) -> list[str]: + if not os.path.exists(recent_paths_path): + return [] + + recent_paths_df = pd.read_csv(recent_paths_path, index_col='index') + recent_paths_df['path'] = recent_paths_df['path'].str.replace('\\', '/') + recent_paths_df = recent_paths_df.drop_duplicates(subset=['path']) + recent_paths_df.to_csv(recent_paths_path) + if 'opened_last_on' in recent_paths_df.columns: + recent_paths_df = recent_paths_df.sort_values( + 'opened_last_on', + ascending=False, + ) + return recent_paths_df['path'].to_list() + + def load_settings(self, settings_csv_path) -> pd.DataFrame: + if os.path.exists(settings_csv_path): + settings_df = pd.read_csv(settings_csv_path, index_col='setting') + settings_df['value'] = settings_df['value'].astype(object) + if 'is_bw_inverted' in settings_df.index: + settings_df.loc['is_bw_inverted'] = ( + settings_df.loc['is_bw_inverted'].astype(str) + ) + else: + settings_df.at['is_bw_inverted', 'value'] = 'No' + if 'how_normIntensities' not in settings_df.index: + raw = 'Do not normalize. Display raw image' + settings_df.at['how_normIntensities', 'value'] = raw + else: + settings_df = pd.DataFrame( + { + 'setting': list(DEFAULT_SESSION_SETTINGS.keys())[:4], + 'value': list(DEFAULT_SESSION_SETTINGS.values())[:4], + } + ).set_index('setting') + + for key, value in DEFAULT_SESSION_SETTINGS.items(): + if key not in settings_df.index: + settings_df.at[key, 'value'] = value + + return settings_df + + def position_session_from_load_data(self, pos_data) -> PositionSession: + return PositionSession.from_loadData(pos_data) + + def update_last_visited_frame( + self, + mode: str, + last_visited_frame_i: int, + *, + last_tracked_i: int, + last_cca_frame_i: int, + ) -> LastVisitedFrameUpdate: + return update_last_visited_frame_state( + mode, + last_visited_frame_i, + last_tracked_i=last_tracked_i, + last_cca_frame_i=last_cca_frame_i, + ) + + def should_store_frame_data( + self, + *, + frame_i: int, + mode: str, + enforce: bool, + ) -> bool: + return self.model.should_store_frame_data( + frame_i=frame_i, + mode=mode, + enforce=enforce, + ) + + def should_disable_load_position(self, position_count: int) -> bool: + return self.model.should_disable_load_position(position_count) + + def empty_labels( + self, + *, + is_3d: bool, + size_z: int, + size_y: int, + size_x: int, + ): + return self.model.empty_labels( + is_3d=is_3d, + size_z=size_z, + size_y=size_y, + size_x=size_x, + ) + + def should_resume_last_session_prompt(self, last_tracked_num: int) -> bool: + return self.model.should_resume_last_session_prompt(last_tracked_num) diff --git a/cellacdc/viewmodels/status_hover_viewmodel.py b/cellacdc/viewmodels/status_hover_viewmodel.py new file mode 100644 index 000000000..e42655cd4 --- /dev/null +++ b/cellacdc/viewmodels/status_hover_viewmodel.py @@ -0,0 +1,53 @@ +"""View-model contracts for hover and status-bar text.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.status_hover_model import StatusHoverModel + + +@dataclass(frozen=True) +class StatusHoverViewModel: + """Application-facing status/hover formatting commands.""" + + model: StatusHoverModel = field(default_factory=StatusHoverModel) + + def channel_hover_text(self, description, channel, value, format_spec): + return self.model.channel_hover_text( + description, channel, value, format_spec + ) + + def object_hover_text(self, *, label_id, max_id, object_count): + return self.model.object_hover_text( + label_id=label_id, + max_id=max_id, + object_count=object_count, + ) + + def base_hover_text(self, **kwargs): + return self.model.base_hover_text(**kwargs) + + def replace_view_range_status(self, text, **kwargs): + return self.model.replace_view_range_status(text, **kwargs) + + def highlight_state(self, **kwargs): + return self.model.highlight_state(**kwargs) + + def mouse_data_coords_right_image(self, text): + return self.model.mouse_data_coords_right_image(text) + + def ruler_length_text(self, text): + return self.model.ruler_length_text(text) + + def ruler_measurement_text(self, *, length_pixels, pixel_to_um): + return self.model.ruler_measurement_text( + length_pixels=length_pixels, + pixel_to_um=pixel_to_um, + ) + + def euclidean_length(self, x_values, y_values): + return self.model.euclidean_length(x_values, y_values) + + def status_bar_text(self, **kwargs): + return self.model.status_bar_text(**kwargs) diff --git a/cellacdc/viewmodels/tables.py b/cellacdc/viewmodels/tables.py new file mode 100644 index 000000000..09458f1b0 --- /dev/null +++ b/cellacdc/viewmodels/tables.py @@ -0,0 +1,17 @@ +"""View-model commands for table normalization.""" + +from __future__ import annotations + +import pandas as pd + +from cellacdc.myutils import checked_reset_index_Cell_ID, fix_acdc_df_dtypes + + +class TableViewModel: + """Application-facing commands for dataframe normalization.""" + + def checked_reset_index_cell_id(self, dataframe: pd.DataFrame) -> pd.DataFrame: + return checked_reset_index_Cell_ID(dataframe) + + def fix_acdc_df_dtypes(self, dataframe: pd.DataFrame) -> pd.DataFrame: + return fix_acdc_df_dtypes(dataframe) diff --git a/cellacdc/viewmodels/tool_activation_viewmodel.py b/cellacdc/viewmodels/tool_activation_viewmodel.py new file mode 100644 index 000000000..38219ef55 --- /dev/null +++ b/cellacdc/viewmodels/tool_activation_viewmodel.py @@ -0,0 +1,60 @@ +"""View-model contracts for active-tool workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.tool_activation_model import ToolActivationModel + +from .label_edits import LabelEditViewModel +from .tracking_viewmodel import TrackingViewModel + + +@dataclass(frozen=True) +class ToolActivationViewModel: + """Application-facing decisions for active tools.""" + + model: ToolActivationModel = field(default_factory=ToolActivationModel) + label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) + tracking: TrackingViewModel = field(default_factory=TrackingViewModel) + + def manual_annotation_highlight_color( + self, + *, + current_frame_i: int, + frame_to_restore: int | None, + ) -> str: + return self.model.manual_annotation_highlight_color( + current_frame_i=current_frame_i, + frame_to_restore=frame_to_restore, + ) + + def should_highlight_hover_lost_object( + self, + *, + has_no_modifier: bool, + copy_lost_object_checked: bool, + is_exit_event: bool, + ) -> bool: + return self.model.should_highlight_hover_lost_object( + has_no_modifier=has_no_modifier, + copy_lost_object_checked=copy_lost_object_checked, + is_exit_event=is_exit_event, + ) + + def point_in_shape(self, x: int, y: int, shape: tuple[int, int]) -> bool: + return self.model.point_in_shape(x, y, shape) + + def should_hide_hover_objects( + self, + *, + brush_auto_hide_checked: bool, + force: bool, + ) -> bool: + return self.model.should_hide_hover_objects( + brush_auto_hide_checked=brush_auto_hide_checked, + force=force, + ) + + def should_disable_non_functional_buttons(self, is_segm_3d: bool) -> bool: + return self.model.should_disable_non_functional_buttons(is_segm_3d) diff --git a/cellacdc/viewmodels/tracking.py b/cellacdc/viewmodels/tracking.py new file mode 100644 index 000000000..933a3927c --- /dev/null +++ b/cellacdc/viewmodels/tracking.py @@ -0,0 +1,7 @@ +"""Compatibility exports for tracking view-models.""" + +from __future__ import annotations + +from .tracking_viewmodel import TrackingViewModel + +__all__ = ['TrackingViewModel'] diff --git a/cellacdc/viewmodels/tracking_viewmodel.py b/cellacdc/viewmodels/tracking_viewmodel.py new file mode 100644 index 000000000..44a294846 --- /dev/null +++ b/cellacdc/viewmodels/tracking_viewmodel.py @@ -0,0 +1,101 @@ +"""View-model commands for tracking workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.domain.tracking import ( + FutureIdPropagationScan, + LostNewIdsResult, + TrackedLostIdsResult, +) +from cellacdc.models.tracking_model import TrackingModel + +from .edit_id import EditIdViewModel +from .geometry import GeometryViewModel +from .label_edits import LabelEditViewModel +from .model_registry import ModelRegistryViewModel + + +@dataclass(frozen=True) +class TrackingViewModel: + """Application-facing commands for tracking state calculations.""" + + model: TrackingModel = field(default_factory=TrackingModel) + edit_id: EditIdViewModel = field(default_factory=EditIdViewModel) + geometry: GeometryViewModel = field(default_factory=GeometryViewModel) + label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) + model_registry: ModelRegistryViewModel = field( + default_factory=ModelRegistryViewModel + ) + + def compute_lost_new_ids( + self, + previous_ids, + current_ids, + *, + current_deleted_roi_ids=(), + previous_deleted_roi_ids=(), + tracked_lost_ids=(), + ) -> LostNewIdsResult: + return self.model.compute_lost_new_ids( + previous_ids, + current_ids, + current_deleted_roi_ids=current_deleted_roi_ids, + previous_deleted_roi_ids=previous_deleted_roi_ids, + tracked_lost_ids=tracked_lost_ids, + ) + + def tracked_lost_centroids_from_regionprops( + self, + regionprops, + tracked_lost_ids, + ) -> set[tuple[int, ...]]: + return self.model.tracked_lost_centroids_from_regionprops( + regionprops, + tracked_lost_ids, + ) + + def tracked_lost_ids_from_centroids( + self, + previous_labels, + tracked_lost_centroids, + ids_in_frame, + ) -> TrackedLostIdsResult: + return self.model.tracked_lost_ids_from_centroids( + previous_labels, + tracked_lost_centroids, + ids_in_frame, + ) + + def last_tracked_frame_index( + self, + frame_labels, + *, + first_frame_fallback: int = 0, + total_frames: int | None = None, + ) -> int: + return self.model.last_tracked_frame_index( + frame_labels, + first_frame_fallback=first_frame_fallback, + total_frames=total_frames, + ) + + def scan_future_id_propagation( + self, + target_id: int, + *, + current_frame_i: int, + frame_labels, + fallback_frame_labels, + include_unvisited: bool = False, + total_frames: int | None = None, + ) -> FutureIdPropagationScan: + return self.model.scan_future_id_propagation( + target_id, + current_frame_i=current_frame_i, + frame_labels=frame_labels, + fallback_frame_labels=fallback_frame_labels, + include_unvisited=include_unvisited, + total_frames=total_frames, + ) diff --git a/cellacdc/viewmodels/undo_redo_viewmodel.py b/cellacdc/viewmodels/undo_redo_viewmodel.py new file mode 100644 index 000000000..1f814fe21 --- /dev/null +++ b/cellacdc/viewmodels/undo_redo_viewmodel.py @@ -0,0 +1,39 @@ +"""View-model contracts for undo and redo stack handling.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.undo_redo_model import UndoRedoModel + + +@dataclass(frozen=True) +class UndoRedoViewModel: + """Application-facing commands for undo/redo stack decisions.""" + + model: UndoRedoModel = field(default_factory=UndoRedoModel) + + def empty_frame_stacks(self, size_t: int) -> list[list]: + return self.model.empty_frame_stacks(size_t) + + def empty_add_point_queue(self): + return self.model.empty_add_point_queue() + + def trim_label_states(self, states: list) -> None: + self.model.trim_stack(states, max_size=5) + + def trim_cca_states(self, states: list) -> None: + self.model.trim_stack(states, max_size=10) + + def can_undo_labels(self, undo_count: int, states: list) -> bool: + return self.model.can_undo_labels(undo_count, states) + + def can_redo_labels(self, undo_count: int) -> bool: + return self.model.can_redo_labels(undo_count) + + def should_disable_undo_after_cca( + self, + undo_count: int, + states: list, + ) -> bool: + return self.model.should_disable_undo_after_cca(undo_count, states) diff --git a/cellacdc/viewmodels/whitelist_viewmodel.py b/cellacdc/viewmodels/whitelist_viewmodel.py new file mode 100644 index 000000000..96ada888d --- /dev/null +++ b/cellacdc/viewmodels/whitelist_viewmodel.py @@ -0,0 +1,46 @@ +"""View-model contract for the Whitelist feature.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Set +from cellacdc.models.whitelist_model import WhitelistModel + + +@dataclass(frozen=True) +class WhitelistViewModel: + """Presentation logic and commands for Whitelist management.""" + + model: WhitelistModel = field(default_factory=WhitelistModel) + + def filter_existing_ids(self, current_whitelist: Set[int], possible_ids: Set[int]) -> tuple[Set[int], bool]: + """Filters out non-existing IDs from the current whitelist.""" + return self.model.filter_existing_ids(current_whitelist, possible_ids) + + def get_missing_ids(self, current_ids: Set[int], previous_ids: Set[int]) -> Set[int]: + """Returns the set of IDs present in current frame but missing from previous frame.""" + return self.model.get_missing_ids(current_ids, previous_ids) + + def check_original_labels(self, whitelist_obj, frame_i: int) -> bool: + """Delegate label check to model.""" + return self.model.check_original_labels(whitelist_obj, frame_i) + + def get_frames_range(self, frame_i: int) -> list[int]: + """Delegate range calculation to model.""" + return self.model.get_frames_range(frame_i) + + def get_diff_ids(self, old_ids: Set[int], prev_ids: Set[int], new_ids: Set[int]) -> Set[int]: + """Delegate difference to model.""" + return self.model.get_diff_ids(old_ids, prev_ids, new_ids) + + def get_whitelist_missing_and_removed_ids(self, whitelist: Set[int], current_ids: Set[int]) -> tuple[list[int], list[int]]: + """Delegate ID evaluation to model.""" + return self.model.get_whitelist_missing_and_removed_ids(whitelist, current_ids) + + def apply_id_mask(self, curr_lab, og_lab, missing_ids, to_be_removed_ids): + """Delegate mask updates to model.""" + return self.model.apply_id_mask(curr_lab, og_lab, missing_ids, to_be_removed_ids) + + def construct_og_frame(self, pos_lab, og_frame_base, whitelist_ids, og_ids): + """Delegate overlay construction to model.""" + return self.model.construct_og_frame(pos_lab, og_frame_base, whitelist_ids, og_ids) diff --git a/cellacdc/viewmodels/window_events_viewmodel.py b/cellacdc/viewmodels/window_events_viewmodel.py new file mode 100644 index 000000000..e6c18fb04 --- /dev/null +++ b/cellacdc/viewmodels/window_events_viewmodel.py @@ -0,0 +1,35 @@ +"""View-model behavior for main-window event handling.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.window_events_model import WindowEventsModel + +from .geometry import GeometryViewModel + + +@dataclass(frozen=True) +class WindowEventsViewModel: + """GUI-facing helpers for main-window event handling.""" + + model: WindowEventsModel = field(default_factory=WindowEventsModel) + geometry: GeometryViewModel = field(default_factory=GeometryViewModel) + + def windows_overlap_from_bounds(self, **kwargs): + return self.geometry.windows_overlap_from_bounds(**kwargs) + + def should_auto_activate_viewer(self, **kwargs): + return self.geometry.should_auto_activate_viewer(**kwargs) + + def is_pan_image_click(self, **kwargs): + return self.geometry.is_pan_image_click(**kwargs) + + def is_default_middle_click(self, **kwargs): + return self.geometry.is_default_middle_click(**kwargs) + + def is_configured_middle_click(self, **kwargs): + return self.geometry.is_configured_middle_click(**kwargs) + + def middle_click_text(self, **kwargs): + return self.geometry.middle_click_text(**kwargs) diff --git a/cellacdc/viewmodels/worker_viewmodel.py b/cellacdc/viewmodels/worker_viewmodel.py new file mode 100644 index 000000000..9a56d8630 --- /dev/null +++ b/cellacdc/viewmodels/worker_viewmodel.py @@ -0,0 +1,36 @@ +"""View-model contracts for GUI worker lifecycle handling.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from cellacdc.models.worker_model import WorkerModel + + +@dataclass(frozen=True) +class WorkerViewModel: + """Application-facing commands for worker progress decisions.""" + + model: WorkerModel = field(default_factory=WorkerModel) + + def progress_log_level(self, logger_level: str = 'INFO') -> str: + return self.model.progress_log_level(logger_level) + + def progressbar_maximum(self, total_iterations: int) -> int: + return self.model.progressbar_maximum(total_iterations) + + def lazy_loader_progress_description(self, chunk_range) -> str: + return self.model.lazy_loader_progress_description(chunk_range) + + def should_enqueue_autosave(self, is_saving: bool) -> bool: + return self.model.should_enqueue_autosave(is_saving) + + def should_disable_realtime_tracking( + self, + tracking_on_never_visited_frames: bool, + realtime_tracking_enabled: bool, + ) -> bool: + return self.model.should_disable_realtime_tracking( + tracking_on_never_visited_frames, + realtime_tracking_enabled, + ) diff --git a/cellacdc/viewmodels/workspace.py b/cellacdc/viewmodels/workspace.py new file mode 100644 index 000000000..fd1ec2f65 --- /dev/null +++ b/cellacdc/viewmodels/workspace.py @@ -0,0 +1,51 @@ +"""View-model commands for workspace path helpers.""" + +from __future__ import annotations + +from cellacdc import load +from cellacdc.myutils import ( + addToRecentPaths, + determine_folder_type, + getMostRecentPath, + get_pos_foldernames, + listdir, +) + + +class WorkspaceViewModel: + """Application-facing commands for filesystem workspace discovery.""" + + def position_folder_names( + self, + exp_path, + *, + check_if_is_sub_folder=False, + ): + return get_pos_foldernames( + str(exp_path), + check_if_is_sub_folder=check_if_is_sub_folder, + ) + + def listdir(self, path): + return listdir(str(path)) + + def add_recent_path(self, path, *, logger=None): + return addToRecentPaths(str(path), logger=logger) + + def most_recent_path(self): + return getMostRecentPath() + + def determine_folder_type(self, folder_path): + is_pos_folder, is_images_folder, folder_path = determine_folder_type( + str(folder_path) + ) + return is_pos_folder, bool(is_images_folder), folder_path + + def segmentation_files(self, images_path): + return load.get_segm_files(str(images_path)) + + def endnames(self, basename, files): + return load.get_endnames(basename, files) + + def path_from_endname(self, end_name, images_path, *, ext=None): + return load.get_path_from_endname(end_name, str(images_path), ext=ext) diff --git a/cellacdc/views/__init__.py b/cellacdc/views/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/cellacdc/views/__init__.py @@ -0,0 +1 @@ + diff --git a/cellacdc/views/actions_view.py b/cellacdc/views/actions_view.py new file mode 100644 index 000000000..c25952944 --- /dev/null +++ b/cellacdc/views/actions_view.py @@ -0,0 +1,1043 @@ +"""Qt view adapter for action and shortcut workflows.""" + +from __future__ import annotations + +import os +import re + +from qtpy.QtCore import Qt +from qtpy.QtGui import QIcon, QKeySequence +from qtpy.QtWidgets import QAction, QActionGroup, QToolButton + +from cellacdc import apps, is_mac, settings_folderpath, widgets +from cellacdc.viewmodels.actions_viewmodel import ActionsViewModel + +shortcut_filepath = os.path.join(settings_folderpath, 'shortcuts.ini') + + +class ActionsView: + """Qt-facing adapter around action construction and shortcut editing.""" + + LEGACY_METHODS = ( + 'gui_createActions', + 'gui_updateSwitchColorSchemeActionText', + 'gui_connectActions', + 'initShortcuts', + 'setShortcuts', + 'editShortcuts_cb', + 'gui_connectEditActions', + ) + + def __init__(self, host, view_model: ActionsViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def gui_createActions(self): + # File actions + self.segmNdimIndicator = widgets.ToolButtonTextIcon(text='') + self.segmNdimIndicator.setCheckable(True) + self.segmNdimIndicator.setChecked(True) + # self.segmNdimIndicator.setDisabled(True) + + if self.debug: + self.createEmptyDataAction = QAction(self.host) + self.createEmptyDataAction.setText("DEBUG: Create empty data") + + self.newWindowAction = QAction("New Window", self.host) + + self.newAction = QAction(self.host) + self.newAction.setText("&New Segmentation File...") + self.newAction.setIcon(QIcon(":file-new.svg")) + self.openFolderAction = QAction( + QIcon(":folder-open.svg"), "&Load Folder...", self.host + ) + self.openFileAction = QAction( + QIcon(":image.svg"),"&Open Image/Video File...", self.host + ) + self.manageVersionsAction = QAction( + QIcon(":manage_versions.svg"), "Load Older Versions...", self.host + ) + self.manageVersionsAction.setDisabled(True) + self.saveAction = QAction(QIcon(":file-save.svg"), "Save", self.host) + self.saveAsAction = QAction("Save as...", self.host) + self.exportToVideoAction = QAction("&Video...", self.host) + self.exportToImageAction = QAction("&Image...", self.host) + self.quickSaveAction = QAction("Save Only Segmentation Masks", self.host) + self.loadFluoAction = QAction("Load Fluorescence Images...", self.host) + self.loadPosAction = QAction("Load Different Position...", self.host) + # self.reloadAction = QAction( + # QIcon(":reload.svg"), "Reload segmentation file", self + # ) + self.nextAction = QAction('Next', self.host) + self.prevAction = QAction('Previous', self.host) + self.showInExplorerAction = QAction( + QIcon(":drawer.svg"), f"&{self.openFolderText}", self.host + ) + self.exitAction = QAction("&Exit", self.host) + self.undoAction = QAction(QIcon(":undo.svg"), "Undo", self.host) + self.redoAction = QAction(QIcon(":redo.svg"), "Redo", self.host) + # String-based key sequences + self.newWindowAction.setShortcut('Ctrl+Shift+N') + self.newAction.setShortcut('Ctrl+N') + self.openFolderAction.setShortcut('Ctrl+O') + self.loadPosAction.setShortcut('Shift+P') + self.saveAsAction.setShortcut('Ctrl+Shift+S') + self.exportToVideoAction.setShortcut('Ctrl+Shift+V') + self.exportToImageAction.setShortcut('Ctrl+Shift+I') + self.saveAction.setShortcut('Ctrl+Alt+S') + self.quickSaveAction.setShortcut('Ctrl+S') + self.undoAction.setShortcut('Ctrl+Z') + self.redoAction.setShortcut('Ctrl+Y') + self.nextAction.setShortcut(Qt.Key_Right) + self.prevAction.setShortcut(Qt.Key_Left) + self.addAction(self.nextAction) + self.addAction(self.prevAction) + # Help tips + newTip = "Create a new segmentation file" + self.newAction.setStatusTip(newTip) + self.newAction.setWhatsThis("Create a new empty segmentation file") + + self.autoPilotButton = QAction(self.host) + self.autoPilotButton.setIcon(QIcon(":auto-pilot.svg")) + self.autoPilotButton.setCheckable(True) + self.autoPilotButton.setShortcut('Ctrl+Shift+A') + + self.findIdAction = QAction(self.host) + self.findIdAction.setIcon(QIcon(":find.svg")) + self.findIdAction.setShortcut('Ctrl+F') + + self.zoomRectButton = QToolButton(self.host) + self.zoomRectButton.setIcon(QIcon(":zoom_rect.svg")) + self.zoomRectButton.setCheckable(True) + self.zoomRectButton.setShortcut('Shift+Z') + self.LeftClickButtons.append(self.zoomRectButton) + self.checkableButtons.append(self.zoomRectButton) + self.checkableQButtonsGroup.addButton(self.zoomRectButton) + self.widgetsWithShortcut['Zoom to rectangular area'] = ( + self.zoomRectButton + ) + + self.skipToNewIdAction = QAction(self.host) + self.skipToNewIdAction.setIcon(QIcon(":skip_forward_new_ID.svg")) + self.skipToNewIdAction.setShortcut( + widgets.KeySequenceFromText(Qt.Key_PageUp) + ) + + self.skipToNewIdAction.setDisabled(True) + + # Edit actions + models = self.view_model.model_registry.segmentation_models( + include_local_seg=True + ) + self.segmActions = [] + self.modelNames = [] + self.acdcSegment_li = [] + self.models = [] + for model_name in models: + action = QAction(f"{model_name}...") + self.segmActions.append(action) + self.modelNames.append(model_name) + self.models.append(None) + self.acdcSegment_li.append(None) + action.setDisabled(True) + + self.addCustomModelFrameAction = QAction('Add custom model...', self.host) + self.addCustomModelVideoAction = QAction('Add custom model...', self.host) + + self.segmWithPromptableModelAction = QAction( + 'Select promptable model...', self.host + ) + self.addCustomPromptModelAction = QAction( + 'Add custom promptable model...', self.host + ) + + self.segmActionsVideo = [] + for model_name in models: + action = QAction(f"{model_name}...") + self.segmActionsVideo.append(action) + action.setDisabled(True) + + self.postProcessSegmAction = QAction( + "Segmentation post-processing...", self.host + ) + self.postProcessSegmAction.setDisabled(True) + self.postProcessSegmAction.setCheckable(True) + + self.EditSegForLostIDsSetSettings = QAction( + "Edit settings for Segmenting lost IDs...", self.host + ) + self.EditSegForLostIDsSetSettings.triggered.connect( + self.seg_for_lost_ids_view.SegForLostIDsSetSettings + ) + + self.repeatTrackingAction = QAction( + QIcon(":repeat-tracking.svg"), "Repeat tracking", self.host + ) + self.repeatTrackingAction.setShortcut('Shift+T') + self.widgetsWithShortcut['Repeat Tracking'] = self.repeatTrackingAction + + + self.editRtTrackerParamsAction = QAction( + 'Edit real-time tracker parameters...', self.host + ) + + self.repeatTrackingMenuAction = QAction( + 'Track current frame with real-time tracker...', self.host + ) + self.repeatTrackingMenuAction.setDisabled(True) + self.repeatTrackingMenuAction.setShortcut('Shift+T') + + self.repeatTrackingVideoAction = QAction( + 'Select a tracker and track multiple frames...', self.host + ) + self.repeatTrackingVideoAction.setDisabled(True) + self.repeatTrackingVideoAction.setShortcut('Alt+Shift+T') + + self.trackingAlgosGroup = QActionGroup(self.host) + self.trackWithAcdcAction = QAction('Cell-ACDC', self.host) + self.trackWithAcdcAction.setCheckable(True) + self.trackingAlgosGroup.addAction(self.trackWithAcdcAction) + + self.trackWithYeazAction = QAction('YeaZ', self.host) + self.trackWithYeazAction.setCheckable(True) + self.trackingAlgosGroup.addAction(self.trackWithYeazAction) + + rt_trackers = self.view_model.model_registry.real_time_trackers() + for rt_tracker in rt_trackers: + rtTrackerAction = QAction(rt_tracker, self.host) + rtTrackerAction.setCheckable(True) + self.trackingAlgosGroup.addAction(rtTrackerAction) + + self.trackWithAcdcAction.setChecked(True) + aliases = self.view_model.model_registry.real_time_tracker_aliases() + + if 'tracking_algorithm' in self.df_settings.index: + trackingAlgo = self.df_settings.at['tracking_algorithm', 'value'] + if trackingAlgo in aliases: + trackingAlgo = aliases[trackingAlgo] + if trackingAlgo == 'Cell-ACDC': + self.trackWithAcdcAction.setChecked(True) + elif trackingAlgo == 'YeaZ': + self.trackWithYeazAction.setChecked(True) + else: + for rtTrackerAction in self.trackingAlgosGroup.actions(): + if rtTrackerAction.text() == trackingAlgo: + rtTrackerAction.setChecked(True) + break + + self.setMeasurementsAction = QAction('Set measurements...') + self.addCustomMetricAction = QAction('Add custom measurement...') + self.addCombineMetricAction = QAction('Add combined measurement...') + + # Standard key sequence + # self.copyAction.setShortcut(QKeySequence.StandardKey.Copy) + # self.pasteAction.setShortcut(QKeySequence.StandardKey.Paste) + # self.cutAction.setShortcut(QKeySequence.StandardKey.Cut) + # Help actions + self.tipsAction = QAction("Tips and tricks...", self.host) + self.UserManualAction = QAction("User Documentation...", self.host) + self.openLogFileAction = QAction("Open log file...", self.host) + self.showLogFilesAction = QAction("Show log files...", self.host) + self.aboutAction = QAction("About Cell-ACDC", self.host) + # self.aboutAction = QAction("&About...", self.host) + + # Assign mother to bud button + self.assignBudMothAutoAction = QAction(self.host) + self.assignBudMothAutoAction.setIcon(QIcon(":autoAssign.svg")) + self.assignBudMothAutoAction.setVisible(False) + + self.editCcaToolAction = QAction(self.host) + self.editCcaToolAction.setIcon(QIcon(":edit_cca.svg")) + # self.editCcaToolAction.setDisabled(True) + self.editCcaToolAction.setVisible(False) + + self.reInitCcaAction = QAction(self.host) + self.reInitCcaAction.setIcon(QIcon(":reinitCca.svg")) + self.reInitCcaAction.setVisible(False) + + self.toggleColorSchemeAction = QAction( + 'Switch to light theme' + ) + self.gui_updateSwitchColorSchemeActionText() + + self.pxModeAction = widgets.CheckableAction( + 'Fixed size text annotations' + ) + self.pxModeAction.setChecked(True) + pxModeTooltip = ( + 'When the text annotations are with fixed size they scale relative ' + 'to the object when zooming in/out (fixed size in pixels).\n' + 'This is typically faster to render, but it makes annotations ' + 'smaller/larger when zooming in/out, respectively.\n\n' + 'Try activating it to speed up the annotation of many objects ' + 'in high resolution mode.\n\n' + 'After activating it, you might need to increase the font size ' + 'from the menu on the top menubar `Edit --> Font size`.' + ) + self.pxModeAction.setToolTip(pxModeTooltip) + + self.highLowResAction = widgets.CheckableAction( + 'High resolution text annotations' + ) + highLowResTooltip = ( + 'Resolution of the text annotations. High resolution results ' + 'in slower update of the annotations.\n' + 'Not recommended with a number of segmented objects > 500.\n\n' + ) + self.highLowResAction.setToolTip(highLowResTooltip) + + self.editAutoSaveIntervalAction = QAction( + 'Change autosave interval (minutes or frames)...', self.host + ) + + self.editShortcutsAction = QAction( + 'Customize keyboard shortcuts...', self.host + ) + self.editShortcutsAction.setShortcut('Ctrl+K') + + self.showMirroredCursorAction = QAction( + 'Show mirrored cursor on images', self.host + ) + self.showMirroredCursorAction.setCheckable(True) + if 'showMirroredCursor' in self.df_settings.index: + checked = self.df_settings.at['showMirroredCursor', 'value'] == 'Yes' + self.showMirroredCursorAction.setChecked(checked) + else: + self.showMirroredCursorAction.setChecked(True) + self.showMirroredCursorAction.setShortcut('Ctrl+M') + + self.editTextIDsColorAction = QAction('Text annotation color...', self.host) + self.editTextIDsColorAction.setDisabled(True) + + self.editOverlayColorAction = QAction('Overlay color...', self.host) + self.editOverlayColorAction.setDisabled(True) + + self.manuallyEditCcaAction = QAction( + 'Edit cell cycle annotations...', self.host + ) + self.manuallyEditCcaAction.setShortcut('Ctrl+Shift+P') + self.manuallyEditCcaAction.setDisabled(True) + + self.viewCcaTableAction = QAction( + 'View cell cycle annotations...', self.host + ) + self.viewCcaTableAction.setDisabled(True) + self.viewCcaTableAction.setShortcut('Ctrl+P') + + + self.addScaleBarAction = QAction('Add scale bar', self.host) + self.addScaleBarAction.setCheckable(True) + + self.addTimestampAction = QAction('Add timestamp', self.host) + self.addTimestampAction.setCheckable(True) + + self.invertBwAction = QAction('Invert black/white', self.host) + self.invertBwAction.setCheckable(True) + checked = self.df_settings.at['is_bw_inverted', 'value'] == 'Yes' + self.invertBwAction.setChecked(checked) + + self.shuffleCmapAction = QAction('Randomly shuffle colormap', self.host) + self.shuffleCmapAction.setShortcut('Shift+S') + + self.greedyShuffleCmapAction = QAction( + 'Greedily shuffle colormap', self.host + ) + self.greedyShuffleCmapAction.setShortcut('Alt+Shift+S') + + self.saveLabColormapAction = QAction( + 'Save labels colormap...', self.host + ) + + self.normalizeRawAction = QAction( + 'Do not normalize. Display raw image', self.host) + self.normalizeToFloatAction = QAction( + 'Convert to floating point format with values [0, 1]', self.host) + # self.normalizeToUbyteAction = QAction( + # 'Rescale to 8-bit unsigned integer format with values [0, 255]', self.host) + self.normalizeRescale0to1Action = QAction( + 'Rescale to [0, 1]', self.host) + self.normalizeByMaxAction = QAction( + 'Normalize by max value', self.host) + self.normalizeRawAction.setCheckable(True) + self.normalizeToFloatAction.setCheckable(True) + # self.normalizeToUbyteAction.setCheckable(True) + self.normalizeRescale0to1Action.setCheckable(True) + self.normalizeByMaxAction.setCheckable(True) + self.normalizeQActionGroup = QActionGroup(self.host) + self.normalizeQActionGroup.addAction(self.normalizeRawAction) + self.normalizeQActionGroup.addAction(self.normalizeToFloatAction) + # self.normalizeQActionGroup.addAction(self.normalizeToUbyteAction) + self.normalizeQActionGroup.addAction(self.normalizeRescale0to1Action) + self.normalizeQActionGroup.addAction(self.normalizeByMaxAction) + + self.preprocessAction = QAction( + 'Pre-processing...', self.host + ) + self.preprocessAction.setShortcut('Alt+Shift+P') + + self.combineChannelsAction = QAction( + 'Combine and manipulate channels and/or segmentation files...', self.host + ) + self.combineChannelsAction.setShortcut('Alt+Shift+C') + + self.zoomToObjsAction = QAction( + 'Zoom to objects (Shortcut: H key)', self.host + ) + self.zoomOutAction = QAction( + 'Zoom out (Shortcut: double press H key)', self.host + ) + + self.relabelSequentialAction = QAction( + 'Relabel IDs sequentially...', self.host + ) + self.relabelSequentialAction.setShortcut('Ctrl+L') + self.relabelSequentialAction.setDisabled(True) + + self.setLastUserNormAction() + + self.autoSegmAction = QAction( + 'Enable automatic segmentation', self.host) + self.autoSegmAction.setCheckable(True) + self.autoSegmAction.setDisabled(True) + + self.enableSmartTrackAction = QAction( + 'Smart handling of enabling/disabling tracking', self.host) + self.enableSmartTrackAction.setCheckable(True) + self.enableSmartTrackAction.setChecked(True) + + self.enableAutoZoomToCellsAction = QAction( + 'Automatic zoom to all cells when pressing "Next/Previous"', self.host) + self.enableAutoZoomToCellsAction.setCheckable(True) + + self.imgPropertiesAction = QAction('Properties...', self.host) + self.imgPropertiesAction.setDisabled(True) + + self.addDelRoiAction = QAction(self.host) + self.addDelRoiAction.roiType = 'rect' + self.addDelRoiAction.setIcon(QIcon(":addDelRoi.svg")) + + self.addDelPolyLineRoiButton = QToolButton(self.host) + self.addDelPolyLineRoiButton.setCheckable(True) + self.addDelPolyLineRoiButton.setIcon(QIcon(":addDelPolyLineRoi.svg")) + + self.checkableButtons.append(self.addDelPolyLineRoiButton) + self.LeftClickButtons.append(self.addDelPolyLineRoiButton) + + self.delBorderObjAction = QAction(self.host) + self.delBorderObjAction.setIcon(QIcon(":delBorderObj.svg")) + + self.delNewObjAction = QAction(self.host) + self.delNewObjAction.setIcon(QIcon(":delNewObj.svg")) + + self.loadCustomAnnotationsAction = QAction(self.host) + self.loadCustomAnnotationsAction.setIcon(QIcon(":load_annotation.svg")) + self.loadCustomAnnotationsAction.setToolTip( + 'Load previously used custom annotations' + ) + + self.addCustomAnnotationAction = QAction(self.host) + self.addCustomAnnotationAction.setIcon(QIcon(":addCustomAnnotation.svg")) + self.addCustomAnnotationAction.setToolTip('Add custom annotation') + # self.functionsNotTested3D.append(self.addCustomAnnotationAction) + + self.viewAllCustomAnnotAction = QAction(self.host) + self.viewAllCustomAnnotAction.setCheckable(True) + self.viewAllCustomAnnotAction.setIcon(QIcon(":eye.svg")) + self.viewAllCustomAnnotAction.setToolTip('Show all custom annotations') + # self.functionsNotTested3D.append(self.viewAllCustomAnnotAction) + + # self.imgGradLabelsAlphaUpAction = QAction(self.host) + # self.imgGradLabelsAlphaUpAction.setVisible(False) + # self.imgGradLabelsAlphaUpAction.setShortcut('Ctrl+Up') + + def gui_updateSwitchColorSchemeActionText(self): + if self._colorScheme == 'dark': + txt = 'Switch to light theme' + else: + txt = 'Switch to dark theme' + self.toggleColorSchemeAction.setText(txt) + + def gui_connectActions(self): + # Connect File actions + if self.debug: + self.createEmptyDataAction.triggered.connect(self._createEmptyData) + self.segmNdimIndicator.clicked.connect(self.segmNdimIndicatorClicked) + self.newWindowAction.triggered.connect(self.openNewWindow) + self.newAction.triggered.connect(self.newFile) + self.openFolderAction.triggered.connect(self.openFolder) + self.openFileAction.triggered.connect(self.openFile) + self.manageVersionsAction.triggered.connect(self.manageVersions) + self.saveAction.triggered.connect(self.saveData) + self.saveAsAction.triggered.connect(self.saveAsData) + self.exportToVideoAction.triggered.connect( + self.exporting_view.exportToVideoTriggered + ) + self.exportToImageAction.triggered.connect( + self.exporting_view.exportToImageTriggered + ) + self.quickSaveAction.triggered.connect(self.quickSave) + self.viewPreprocDataToggle.toggled.connect( + self.preprocessing_view.viewPreprocDataToggled + ) + self.viewCombineChannelDataToggle.toggled.connect( + self.viewCombineChannelDataToggled + ) + self.autoSaveToggle.toggled.connect(self.autoSaveToggled) + self.autoSaveAnnotToggle.toggled.connect(self.autoSaveAnnotToggled) + self.autoSaveIntervalDialog.sigValueChanged.connect( + self.autoSaveIntervalValueChanged + ) + self.autoSaveIntervalEditButton.clicked.connect( + self.autoSaveIntervalEdit + ) + self.ccaIntegrCheckerToggle.toggled.connect( + self.ccaIntegrCheckerToggled + ) + self.annotLostObjsToggle.toggled.connect(self.annotLostObjsToggled) + self.highLowResAction.clicked.connect(self.highLowResToggled) + self.showInExplorerAction.triggered.connect(self.showInExplorer_cb) + self.exitAction.triggered.connect(self.close) + self.undoAction.triggered.connect(self.undo_redo_view.undo) + self.redoAction.triggered.connect(self.undo_redo_view.redo) + self.nextAction.triggered.connect(self.nextActionTriggered) + self.prevAction.triggered.connect(self.prevActionTriggered) + + self.invertBwAction.toggled.connect(self.invertBw) + self.toggleColorSchemeAction.triggered.connect(self.onToggleColorScheme) + self.pxModeAction.clicked.connect(self.pxModeActionToggled) + self.editShortcutsAction.triggered.connect(self.editShortcuts_cb) + self.editAutoSaveIntervalAction.triggered.connect( + self.autoSaveIntervalEditButton.click + ) + self.showMirroredCursorAction.toggled.connect( + self.showMirroredCursorToggled + ) + + # Connect Help actions + self.tipsAction.triggered.connect(self.showTipsAndTricks) + self.UserManualAction.triggered.connect( + self.view_model.app_shell.browse_docs + ) + self.openLogFileAction.triggered.connect(self.openLogFile) + self.showLogFilesAction.triggered.connect(self.showLogFiles) + self.aboutAction.triggered.connect(self.showAbout) + # Connect Open Recent to dynamically populate it + # self.openRecentMenu.aboutToShow.connect(self.populateOpenRecent) + self.checkableQButtonsGroup.buttonClicked.connect(self.uncheckQButton) + + self.showPropsDockButton.sigClicked.connect(self.showPropsDockWidget) + + self.loadCustomAnnotationsAction.triggered.connect( + self.custom_annotations_view.loadCustomAnnotations + ) + self.addCustomAnnotationAction.triggered.connect( + self.custom_annotations_view.addCustomAnnotation + ) + self.viewAllCustomAnnotAction.toggled.connect( + self.custom_annotations_view.viewAllCustomAnnot + ) + self.addCustomModelVideoAction.triggered.connect( + self.showInstructionsCustomModel + ) + self.addCustomModelFrameAction.triggered.connect( + self.showInstructionsCustomModel + ) + self.addCustomModelFrameAction.callback = self.segmFrameCallback + self.addCustomModelVideoAction.callback = self.segmVideoCallback + + self.addCustomPromptModelAction.triggered.connect( + self.magic_prompts_view.showInstructionsCustomPromptModel + ) + self.segmWithPromptableModelAction.triggered.connect( + self.magic_prompts_view.segmWithPromptableModelActionTriggered + ) + + def initShortcuts(self): + from cellacdc import config + cp = config.ConfigParser() + if os.path.exists(shortcut_filepath): + cp.read(shortcut_filepath) + + shortcuts_section = self.view_model.keyboard_shortcuts_section + delete_section = self.view_model.delete_object_section + if shortcuts_section not in cp: + cp[shortcuts_section] = {} + + if cp.has_option(shortcuts_section, 'Zoom out'): + zoomOutKeyValueStr = cp[shortcuts_section]['Zoom out'] + try: + self.zoomOutKeyValue = int(zoomOutKeyValueStr) + except Exception as err: + self.logger.warning( + f'{zoomOutKeyValueStr} is not a valid key ' + 'zooming out action. Restoring default key "H".' + ) + + if delete_section not in cp: + self.delObjAction = None + else: + delObjKeySequenceText = ( + cp[delete_section][self.view_model.delete_key_option] + ) + delObjButtonText = ( + cp[delete_section][self.view_model.delete_button_option] + ) + delObjQtButton = ( + Qt.MouseButton.LeftButton + if self.view_model.delete_object_button_is_left_click( + delObjButtonText + ) + else Qt.MouseButton.MiddleButton + ) + if not delObjKeySequenceText: + delObjKeySequence = None + else: + delObjKeySequence = widgets.KeySequenceFromText( + delObjKeySequenceText + ) + self.delObjToolAction.setChecked(True) + self.delObjAction = delObjKeySequence, delObjQtButton + + shortcuts = {} + for name, widget in self.widgetsWithShortcut.items(): + if name not in cp.options(shortcuts_section): + if hasattr(widget, 'keyPressShortcut'): + key = widget.keyPressShortcut + shortcut = widgets.KeySequenceFromText(key) + else: + shortcut = widget.shortcut() + shortcut_text = shortcut.toString() + cp[shortcuts_section][name] = shortcut_text + else: + shortcut_text = cp[shortcuts_section][name] + shortcut = widgets.KeySequenceFromText(shortcut_text) + + shortcuts[name] = (shortcut_text, shortcut) + self.setShortcuts(shortcuts, save=False) + with open(shortcut_filepath, 'w') as ini: + cp.write(ini) + + def setShortcuts(self, shortcuts: dict, save=True): + for name, (text, shortcut) in shortcuts.items(): + widget = self.widgetsWithShortcut[name] + if shortcut is None: + shortcut = QKeySequence() + if hasattr(widget, 'keyPressShortcut'): + widget.keyPressShortcut = shortcut + else: + widget.setShortcut(shortcut) + s = widget.toolTip() + toolTip = re.sub(r'Shortcut: "(.*)"', f'Shortcut: "{text}"', s) + widget.setToolTip(toolTip) + + if not save: + return + + from cellacdc import config + cp = config.ConfigParser() + if os.path.exists(shortcut_filepath): + cp.read(shortcut_filepath) + + shortcuts_section = self.view_model.keyboard_shortcuts_section + delete_section = self.view_model.delete_object_section + if shortcuts_section not in cp: + cp[shortcuts_section] = {} + + for name, (text, shortcut) in shortcuts.items(): + cp[shortcuts_section][name] = text + + cp[shortcuts_section]['Zoom out'] = str(self.zoomOutKeyValue) + + if self.delObjAction is None: + with open(shortcut_filepath, 'w') as ini: + cp.write(ini) + return + + delObjKeySequence, delObjQtButton = self.delObjAction + try: + if delObjKeySequence is None: + delObjKeySequenceText = '' + else: + delObjKeySequenceText = delObjKeySequence.toString() + + delObjKeySequenceText = ( + self.view_model.sanitize_key_sequence_text( + delObjKeySequenceText + ) + ) + delObjButtonText = self.view_model.delete_object_button_text( + is_left_click=( + delObjQtButton == Qt.MouseButton.LeftButton + ) + ) + cp[delete_section] = { + self.view_model.delete_key_option: delObjKeySequenceText, + self.view_model.delete_button_option: delObjButtonText + } + except Exception as err: + self.logger.warning( + f'{delObjKeySequence} is not a valid keys sequence for ' + 'deleting objects. Setting default action' + ) + self.delObjAction = None + cp.remove_section(delete_section) + + with open(shortcut_filepath, 'w') as ini: + cp.write(ini) + + def editShortcuts_cb(self): + delObjKeySequenceText, delObjButtonText = ( + self.view_model.default_delete_object_texts(is_mac=is_mac) + ) + + if self.delObjAction is not None: + delObjKeySequence, delObjQtButton = self.delObjAction + if delObjKeySequence is None: + delObjKeySequenceText = '' + else: + delObjKeySequenceText = delObjKeySequence.toString() + delObjKeySequenceText = ( + self.view_model.sanitize_key_sequence_text( + delObjKeySequenceText + ) + ) + delObjButtonText = self.view_model.delete_object_button_text( + is_left_click=( + delObjQtButton == Qt.MouseButton.LeftButton + ) + ) + + win = apps.ShortcutEditorDialog( + self.widgetsWithShortcut, + delObjectKey=delObjKeySequenceText, + delObjectButton=delObjButtonText, + zoomOutKeyValue=self.zoomOutKeyValue, + parent=self.host + ) + win.exec_() + if win.cancel: + return + + self.delObjAction = win.delObjAction + self.zoomOutKeyValue = win.zoomOutKeyValue + self.setShortcuts(win.customShortcuts) + + def gui_connectEditActions(self): + self.showInExplorerAction.setEnabled(True) + self.mode_controls_view.setEnabledFileToolbar(True) + self.loadFluoAction.setEnabled(True) + self.isEditActionsConnected = True + + self.preprocessImageAction.triggered.connect( + self.preprocessAction.trigger + ) + self.combineChannelsAction.triggered.connect( + self.combineChannelsActionTriggered + ) + + self.overlayButton.toggled.connect(self.overlay_cb) + self.countObjsButton.toggled.connect(self.countObjectsCb) + self.togglePointsLayerAction.toggled.connect(self.pointsLayerToggled) + self.overlayLabelsButton.toggled.connect(self.overlayLabels_cb) + self.overlayButton.sigRightClick.connect(self.showOverlayContextMenu) + self.labelRoiButton.sigRightClick.connect(self.showLabelRoiContextMenu) + self.overlayLabelsButton.sigRightClick.connect( + self.showOverlayLabelsContextMenu + ) + self.rulerButton.toggled.connect(self.ruler_cb) + self.loadFluoAction.triggered.connect(self.loadFluo_cb) + self.loadPosAction.triggered.connect(self.loadPosTriggered) + # self.reloadAction.triggered.connect(self.reload_cb) + self.findIdAction.triggered.connect(self.object_search_view.findID) + self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) + self.autoPilotButton.toggled.connect(self.autoPilotToggled) + self.skipToNewIdAction.triggered.connect( + self.object_search_view.skipForwardToNewID + ) + self.slideshowButton.toggled.connect(self.launchSlideshow) + + self.copyLostObjButton.toggled.connect(self.copyLostObjContour_cb) + self.manualAnnotPastButton.toggled.connect( + self.manualAnnotPast_cb + ) + + self.segmSingleFrameMenu.triggered.connect(self.segmFrameCallback) + self.segmVideoMenu.triggered.connect(self.segmVideoCallback) + + self.postProcessSegmAction.toggled.connect(self.postProcessSegm) + self.autoSegmAction.toggled.connect(self.autoSegm_cb) + self.realTimeTrackingToggle.clicked.connect(self.realTimeTrackingClicked) + self.repeatTrackingAction.triggered.connect(self.repeatTracking) + self.manualTrackingButton.toggled.connect(self.manualTracking_cb) + self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) + self.repeatTrackingMenuAction.triggered.connect(self.repeatTracking) + self.repeatTrackingVideoAction.triggered.connect( + self.repeatTrackingVideo + ) + for rtTrackerAction in self.trackingAlgosGroup.actions(): + rtTrackerAction.toggled.connect(self.rtTrackerActionToggled) + self.editRtTrackerParamsAction.triggered.connect( + self.initRealTimeTracker + ) + self.delObjsOutSegmMaskAction.triggered.connect( + self.object_cleanup_view + .delete_objects_outside_mask_action_triggered + ) + self.mergeIDsButton.toggled.connect(self.mergeObjs_cb) + self.brushButton.toggled.connect(self.Brush_cb) + self.eraserButton.toggled.connect(self.Eraser_cb) + self.curvToolButton.toggled.connect( + self.curvature_tools_view.curvTool_cb + ) + self.wandToolButton.toggled.connect(self.wand_cb) + self.labelRoiButton.toggled.connect(self.labelRoi_cb) + self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) + self.drawClearRegionButton.toggled.connect( + self.draw_clear_region_view.toggle + ) + self.reInitCcaAction.triggered.connect(self.reInitCca) + self.moveLabelToolButton.toggled.connect( + self.label_transform_tools_view.move_label_button_toggled + ) + self.editCcaToolAction.triggered.connect( + self.manualEditCcaToolbarActionTriggered + ) + self.assignBudMothAutoAction.triggered.connect( + self.autoAssignBud_YeastMate + ) + self.keepIDsButton.toggled.connect(self.keepIDs_cb) + + self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) + + self.whitelistIDsToolbar.sigWhitelistChanged.connect( + self.whitelistIDsChanged + ) + + self.whitelistIDsToolbar.sigWhitelistAccepted.connect( + self.whitelistIDsAccepted + ) + + self.whitelistIDsToolbar.sigViewOGIDs.connect(self.whitelistViewOGIDs) + + self.whitelistIDsToolbar.sigAddNewIDs.connect(self.whitelistAddNewIDsToggled) + + self.whitelistIDsToolbar.sigLoadOGLabs.connect(self.whitelistLoadOGLabs_cb) + + self.whitelistIDsToolbar.sigTrackOGagainstPreviousFrame.connect( + self.whitelistTrackOGagainstPreviousFrame_cb + ) + + self.expandLabelToolButton.toggled.connect( + self.label_transform_tools_view.expand_label_callback + ) + + self.reinitLastSegmFrameAction.triggered.connect( + self.reInitLastSegmFrame + ) + + + self.defaultRescaleIntensActionGroup.triggered.connect( + self.defaultRescaleIntensLutActionToggled + ) + + # self.repeatAutoCcaAction.triggered.connect(self.repeatAutoCca) + self.manuallyEditCcaAction.triggered.connect(self.manualEditCca) + self.addScaleBarAction.toggled.connect( + self.display_decorations_view.add_scale_bar + ) + self.addTimestampAction.toggled.connect( + self.display_decorations_view.add_timestamp + ) + self.saveLabColormapAction.triggered.connect(self.saveLabelsColormap) + + self.enableSmartTrackAction.toggled.connect(self.enableSmartTrack) + # Brush/Eraser size action + self.brushSizeSpinbox.valueChanged.connect(self.brushSize_cb) + self.autoIDcheckbox.toggled.connect(self.autoIDtoggled) + # Mode + self.modeActionGroup.triggered.connect( + self.mode_controls_view.changeModeFromMenu + ) + self.modeComboBox.sigTextChanged.connect( + self.mode_controls_view.changeMode + ) + self.modeComboBox.activated.connect( + self.mode_controls_view.clearComboBoxFocus + ) + self.equalizeHistPushButton.toggled.connect(self.equalizeHist) + + self.editOverlayColorAction.triggered.connect(self.toggleOverlayColorButton) + self.editTextIDsColorAction.triggered.connect(self.toggleTextIDsColorButton) + self.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) + self.overlayColorButton.sigColorChanged.connect(self.saveOverlayColor) + self.textIDsColorButton.sigColorChanging.connect(self.updateTextAnnotColor) + self.textIDsColorButton.sigColorChanged.connect(self.saveTextIDsColors) + + self.setMeasurementsAction.triggered.connect( + self.measurements_view.show_set_measurements + ) + self.addCustomMetricAction.triggered.connect( + self.measurements_view.add_custom_metric + ) + self.addCombineMetricAction.triggered.connect( + self.measurements_view.add_combine_metric + ) + + self.labelsGrad.colorButton.sigColorChanging.connect(self.updateBkgrColor) + self.labelsGrad.colorButton.sigColorChanged.connect(self.saveBkgrColor) + self.labelsGrad.sigGradientChangeFinished.connect(self.updateLabelsCmap) + self.labelsGrad.sigGradientChanged.connect(self.ticksCmapMoved) + self.labelsGrad.textColorButton.sigColorChanging.connect( + self.updateTextLabelsColor + ) + self.labelsGrad.textColorButton.sigColorChanged.connect( + self.saveTextLabelsColor + ) + # self.addFontSizeActions( + # self.labelsGrad.fontSizeMenu, self.setFontSizeActionChecked + # ) + + self.labelsGrad.shuffleCmapAction.triggered.connect(self.shuffle_cmap) + self.labelsGrad.greedyShuffleCmapAction.triggered.connect( + self.greedyShuffleCmap + ) + self.labelsGrad.permanentGreedyCmapAction.toggled.connect( + self.permanentGreedyCmapToggled + ) + self.shuffleCmapAction.triggered.connect(self.shuffle_cmap) + self.greedyShuffleCmapAction.triggered.connect(self.greedyShuffleCmap) + self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + self.labelsGrad.sigShowLabelsImgToggled.connect(self.showLabelImageItem) + self.labelsGrad.sigShowRightImgToggled.connect(self.showRightImageItem) + self.labelsGrad.sigShowNextFrameToggled.connect(self.showNextFrameImageItem) + + self.labelsGrad.defaultSettingsAction.triggered.connect( + self.restoreDefaultSettings + ) + + # self.addFontSizeActions( + # self.imgGrad.fontSizeMenu, self.setFontSizeActionChecked + # ) + self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + self.imgGrad.textColorButton.disconnect() + self.imgGrad.textColorButton.clicked.connect( + self.editTextIDsColorAction.trigger + ) + self.imgGrad.labelsAlphaSlider.valueChanged.connect( + self.updateLabelsAlpha + ) + self.imgGrad.defaultSettingsAction.triggered.connect( + self.restoreDefaultSettings + ) + + # Drawing mode + self.drawIDsContComboBox.currentIndexChanged.connect( + self.drawIDsContComboBox_cb + ) + self.drawIDsContComboBox.activated.connect( + self.mode_controls_view.clearComboBoxFocus + ) + + self.annotateRightHowCombobox.currentIndexChanged.connect( + self.annotateRightHowCombobox_cb + ) + self.annotateRightHowCombobox.activated.connect( + self.mode_controls_view.clearComboBoxFocus + ) + + self.showTreeInfoCheckbox.toggled.connect(self.setAnnotInfoMode) + + # Left + self.annotIDsCheckbox.clicked.connect(self.annotOptionClicked) + self.annotCcaInfoCheckbox.clicked.connect(self.annotOptionClicked) + self.annotContourCheckbox.clicked.connect(self.annotOptionClicked) + self.annotSegmMasksCheckbox.clicked.connect(self.annotOptionClicked) + self.drawMothBudLinesCheckbox.clicked.connect(self.annotOptionClicked) + self.drawNothingCheckbox.clicked.connect(self.annotOptionClicked) + self.annotNumZslicesCheckbox.clicked.connect(self.annotOptionClicked) + + # Right + self.annotIDsCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.annotCcaInfoCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.annotContourCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.annotSegmMasksCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.drawMothBudLinesCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.drawNothingCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.annotNumZslicesCheckboxRight.clicked.connect( + self.annotOptionClickedRight + ) + + self.segmentToolAction.triggered.connect(self.segmentToolActionTriggered) + + self.addDelRoiAction.triggered.connect(self.addDelROI) + self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) + self.delBorderObjAction.triggered.connect(self.delBorderObj) + self.delNewObjAction.triggered.connect(self.delNewObj) + + self.brushAutoFillCheckbox.toggled.connect(self.brushAutoFillToggled) + self.brushAutoHideCheckbox.toggled.connect(self.brushAutoHideToggled) + + self.imgGrad.sigAddScaleBar.connect(self.addScaleBarAction.setChecked) + self.imgGrad.sigAddTimestamp.connect(self.addTimestampAction.setChecked) + self.imgGrad.gradient.sigGradientChangeFinished.connect( + self.imgGradLUTfinished_cb + ) + + # self.normalizeQActionGroup.triggered.connect( + # self.normaliseIntensitiesActionTriggered + # ) + self.imgPropertiesAction.triggered.connect(self.editImgProperties) + + self.relabelSequentialAction.triggered.connect( + self.relabelSequentialCallback + ) + + self.zoomToObjsAction.triggered.connect(self.zoomToObjsActionCallback) + self.zoomOutAction.triggered.connect(self.zoomOut) + self.preprocessAction.triggered.connect( + self.preprocessing_view.preprocessActionTriggered + ) + self.combineChannelsAction.triggered.connect(self.combineChannelsActionTriggered) + + self.viewCcaTableAction.triggered.connect(self.viewCcaTable) + + self.guiTabControl.propsQGBox.idSB.valueChanged.connect( + self.propsWidgetIDvalueChanged + ) + self.guiTabControl.highlightCheckbox.toggled.connect( + self.highlightIDonHoverCheckBoxToggled + ) + self.guiTabControl.highlightSearchedCheckbox.toggled.connect( + self.highlightSearchedIDcheckBoxToggled + ) + intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox + intensMeasurQGBox.additionalMeasCombobox.currentTextChanged.connect( + self.updatePropsWidget + ) + intensMeasurQGBox.channelCombobox.currentTextChanged.connect( + self.updatePropsWidget + ) + + propsQGBox = self.guiTabControl.propsQGBox + propsQGBox.additionalPropsCombobox.currentTextChanged.connect( + self.updatePropsWidget + ) diff --git a/cellacdc/views/annotation_display_view.py b/cellacdc/views/annotation_display_view.py new file mode 100644 index 000000000..9cb48f0bd --- /dev/null +++ b/cellacdc/views/annotation_display_view.py @@ -0,0 +1,1107 @@ +"""Qt view adapter for annotation display workflows.""" + +from __future__ import annotations + +import re + +from cellacdc import _palettes, apps, html_utils, widgets +from cellacdc.viewmodels.annotation_display_viewmodel import ( + AnnotationDisplayViewModel, +) + +GREEN_HEX = _palettes.green() + + +class AnnotationDisplayView: + """Qt-facing adapter around annotation display and tool state.""" + + LEGACY_METHODS = ( + 'getAnnotateHowRightImage', + 'activateAnnotations', + 'gui_raiseBottomLayoutContextMenu', + 'annotateRightHowCombobox_cb', + 'drawIDsContComboBox_cb', + 'areContoursRequested', + 'areMothBudLinesRequested', + 'getMothBudLineScatterItem', + 'drawAllMothBudLines', + 'drawObjMothBudLines', + 'clearAllCellToCellLines', + 'drawAllLineageTreeLines', + 'drawObjLin_TreeMothBudLines', + 'getObjCentroid', + 'getObjOptsSegmLabels', + 'update_rp_metadata', + 'annotate_rip_and_bin_IDs', + 'clearAnnotItems', + 'setAllTextAnnotations', + 'labelRoiIsCircularRadioButtonToggled', + 'pxModeActionToggled', + 'changeTextResolution', + 'highLowResToggled', + 'annotGenNumTreeToggled', + 'annotLabelIDtreeToggled', + 'setAnnotInfoMode', + 'annotOptionClicked', + 'setDisabledAnnotCheckBoxesLeft', + 'setEnabledAnnotCheckBoxesLeftZdepthAxes', + 'setDisabledAnnotCheckBoxesRight', + 'annotOptionClickedRight', + 'setAnnotOptionsCcaMode', + 'setAnnotOptionsLin_treeMode', + 'setDrawAnnotComboboxText', + 'setDrawAnnotComboboxTextRight', + 'relabelSequentialCallback', + 'updateAnnotatedIDs', + 'rtTrackerActionToggled', + 'autoPilotToggled', + 'storeCurrentAnnotOptions_ax1', + 'storeCurrentAnnotOptions_ax2', + 'restoreAnnotOptions_ax1', + 'restoreAnnotOptions_ax2', + 'setDrawNothingAnnotations', + 'restoreAnnotationsOptions', + 'onDoubleSpaceBar', + 'zoomRectActionToggled', + 'zoomRectDone', + 'zoomRectCancelled', + 'keepToolActiveActionToggled', + 'applyToolNewFrameActionToggled', + 'keepAllToolsActiveActionToggled', + 'setVisible3DsegmWidgets', + 'showHighlightZneighCheckbox', + 'highlightZneighLabels_cb', + 'restoreSavedSettings', + 'uncheckAnnotOptions', + 'setDisabledAnnotOptions', + 'drawAnnotCombobox_to_options', + ) + + def __init__(self, host, view_model: AnnotationDisplayViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + self._connect_view_model_signals() + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def _connect_view_model_signals(self): + self.view_model.settingUpdateRequested.connect( + self._apply_view_model_setting_update + ) + self.view_model.textAnnotationFlagsChanged.connect( + self._apply_text_annotation_flags + ) + self.view_model.imageRefreshRequested.connect( + self._refresh_images_from_view_model + ) + self.view_model.eraserTempResetRequested.connect( + self._reset_eraser_temp_from_view_model + ) + self.view_model.annotationOptionStatesChanged.connect( + self._apply_annotation_option_states + ) + self.view_model.annotationModeTextUpdateRequested.connect( + self._apply_annotation_mode_text_update + ) + self.view_model.textAnnotationPixelModeChanged.connect( + self._apply_text_annotation_pixel_mode + ) + self.view_model.logInfoRequested.connect(self.logger.info) + self.view_model.pixelModeActionDisabledChanged.connect( + self.pxModeAction.setDisabled + ) + self.view_model.textResolutionChangeRequested.connect( + self._apply_text_resolution_change + ) + self.view_model.treeAnnotationMenuActionRequested.connect( + self._apply_tree_annotation_menu_action + ) + self.view_model.labelTreeAnnotationsEnabledChanged.connect( + self._apply_label_tree_annotations_enabled + ) + self.view_model.genNumTreeAnnotationsEnabledChanged.connect( + self._apply_gen_num_tree_annotations_enabled + ) + self.view_model.allTextAnnotationsRefreshRequested.connect( + self.setAllTextAnnotations + ) + self.view_model.annotationOptionDisabledChanged.connect( + self._apply_annotation_option_disabled + ) + self.view_model.annotationOptionVisibleChanged.connect( + self._apply_annotation_option_visible + ) + self.view_model.annotationOptionCheckedChanged.connect( + self._apply_annotation_option_checked + ) + self.view_model.zNeighborHighlightVisibleChanged.connect( + self._apply_z_neighbor_highlight_visible + ) + self.view_model.zNeighborHighlightCheckedChanged.connect( + self._apply_z_neighbor_highlight_checked + ) + self.view_model.zNeighborHighlightToggleConnectionRequested.connect( + self._connect_z_neighbor_highlight_toggle + ) + self.view_model.annotationModeComboboxRestoreRequested.connect( + self._apply_annotation_mode_combobox_restore + ) + self.view_model.addNewIdsWhitelistToggleChanged.connect( + self._apply_add_new_ids_whitelist_toggle + ) + self.view_model.annotationModeRestoreCallbackRequested.connect( + self._apply_annotation_mode_restore_callback + ) + + def _apply_view_model_setting_update(self, setting, value): + self.df_settings.at[setting, 'value'] = value + self.df_settings.to_csv(self.settings_csv_path) + + def _apply_text_annotation_flags( + self, + ax, + is_cca_annotation, + is_id_annotation, + ): + self.textAnnot[ax].setCcaAnnot(is_cca_annotation) + self.textAnnot[ax].setLabelAnnot(is_id_annotation) + + def _refresh_images_from_view_model(self): + self.updateAllImages() + + def _reset_eraser_temp_from_view_model(self): + self.setTempImg1Eraser(None, init=True) + + def _apply_text_annotation_pixel_mode(self, checked): + for ax in range(2): + self.textAnnot[ax].setPxMode(checked) + + def _apply_text_resolution_change(self, mode): + self.setAllIDs() + posData = self.data[self.pos_i] + allIDs = posData.allIDs + img_shape = self.img1.image.shape[:2] + self.textAnnot[0].changeResolution(mode, allIDs, self.ax1, img_shape) + self.textAnnot[1].changeResolution(mode, allIDs, self.ax2, img_shape) + + def _apply_label_tree_annotations_enabled(self, checked): + self.textAnnot[0].setLabelTreeAnnotationsEnabled(checked) + + def _apply_gen_num_tree_annotations_enabled(self, checked): + self.textAnnot[0].setGenNumTreeAnnotationsEnabled(checked) + + def _apply_tree_annotation_menu_action( + self, + menu_name, + text, + should_contain_text, + checked, + ): + if menu_name == 'id': + menu = self.annotSettingsIDmenu + else: + menu = self.annotSettingsGenNumMenu + + for action in menu.actions(): + text_found = action.text().find(text) != -1 + if text_found == should_contain_text: + action.setChecked(checked) + break + + def _annotation_option_widgets(self, side): + if side == 'right': + return { + 'ids': self.annotIDsCheckboxRight, + 'cca': self.annotCcaInfoCheckboxRight, + 'contours': self.annotContourCheckboxRight, + 'segm_masks': self.annotSegmMasksCheckboxRight, + 'mother_bud_lines': self.drawMothBudLinesCheckboxRight, + 'num_zslices': self.annotNumZslicesCheckboxRight, + 'nothing': self.drawNothingCheckboxRight, + } + return { + 'ids': self.annotIDsCheckbox, + 'cca': self.annotCcaInfoCheckbox, + 'contours': self.annotContourCheckbox, + 'segm_masks': self.annotSegmMasksCheckbox, + 'mother_bud_lines': self.drawMothBudLinesCheckbox, + 'num_zslices': self.annotNumZslicesCheckbox, + 'nothing': self.drawNothingCheckbox, + } + + def _annotation_option_state(self, side): + widgets = self._annotation_option_widgets(side) + return { + name: widget.isChecked() + for name, widget in widgets.items() + } + + def _annotation_clicked_option(self, side, sender): + for name, widget in self._annotation_option_widgets(side).items(): + if sender == widget: + return name + return None + + def _apply_annotation_option_states(self, side, state): + widgets = self._annotation_option_widgets(side) + widgets['ids'].setChecked(state.ids) + widgets['cca'].setChecked(state.cca) + widgets['contours'].setChecked(state.contours) + widgets['segm_masks'].setChecked(state.segm_masks) + widgets['mother_bud_lines'].setChecked(state.mother_bud_lines) + widgets['num_zslices'].setChecked(state.num_zslices) + widgets['nothing'].setChecked(state.nothing) + + def _apply_annotation_option_disabled(self, side, option, disabled): + widgets = self._annotation_option_widgets(side) + widgets[option].setDisabled(disabled) + + def _apply_annotation_option_visible(self, side, option, visible): + widgets = self._annotation_option_widgets(side) + widgets[option].setVisible(visible) + + def _apply_annotation_option_checked(self, side, option, checked): + widgets = self._annotation_option_widgets(side) + widgets[option].setChecked(checked) + + def _apply_z_neighbor_highlight_visible(self, visible): + self.highlightZneighObjCheckbox.setVisible(visible) + + def _apply_z_neighbor_highlight_checked(self, checked): + self.highlightZneighObjCheckbox.setChecked(checked) + + def _connect_z_neighbor_highlight_toggle(self): + self.highlightZneighObjCheckbox.toggled.connect( + self.highlightZneighLabels_cb + ) + + def _apply_annotation_mode_combobox_restore(self, side, text): + if side == 'right': + self.annotateRightHowCombobox.setCurrentText(text) + else: + self.drawIDsContComboBox.setCurrentText(text) + + def _apply_add_new_ids_whitelist_toggle(self, checked): + self.addNewIDsWhitelistToggle = checked + + def _apply_annotation_mode_restore_callback(self, side): + if side == 'right': + self.annotateRightHowCombobox_cb(0) + else: + self.drawIDsContComboBox_cb(0) + + def _apply_annotation_mode_text_update( + self, + side, + text, + save_settings, + ): + if side == 'right': + combo = self.annotateRightHowCombobox + callback = self.annotateRightHowCombobox_cb + else: + combo = self.drawIDsContComboBox + callback = self.drawIDsContComboBox_cb + + if text == combo.currentText(): + callback(0) + + combo.saveSettings = save_settings + combo.setCurrentText(text) + + def getAnnotateHowRightImage(self): + return self.view_model.right_annotation_mode( + show_right_image=self.labelsGrad.showRightImgAction.isChecked(), + use_right_specific_mode=self.rightBottomGroupbox.isChecked(), + right_mode=self.annotateRightHowCombobox.currentText(), + left_mode=self.drawIDsContComboBox.currentText(), + ) + + def activateAnnotations(self): + if self.annotContourCheckbox.isChecked(): + return + if self.annotSegmMasksCheckbox.isChecked(): + return + + self.annotSegmMasksCheckbox.setChecked(True) + self.setDrawAnnotComboboxText() + + def gui_raiseBottomLayoutContextMenu(self, event): + try: + # Convert QPointF to QPoint + self.bottomLayoutContextMenu.popup(event.globalPos().toPoint()) + except AttributeError: + self.bottomLayoutContextMenu.popup(event.globalPos()) + + def annotateRightHowCombobox_cb(self, idx): + how = self.annotateRightHowCombobox.currentText() + saveSettings = True + if hasattr(self.annotateRightHowCombobox, 'saveSettings'): + saveSettings = self.annotateRightHowCombobox.saveSettings + + self.view_model.change_annotation_mode( + side='right', + how=how, + save_settings=saveSettings, + annot_cca_checked=self.annotCcaInfoCheckboxRight.isChecked(), + annot_ids_checked=self.annotIDsCheckboxRight.isChecked(), + mode=self.modeComboBox.currentText(), + is_data_loading=self.isDataLoading, + ) + + def drawIDsContComboBox_cb(self, idx): + how = self.drawIDsContComboBox.currentText() + saveSettings = True + if hasattr(self.drawIDsContComboBox, 'saveSettings'): + saveSettings = self.drawIDsContComboBox.saveSettings + + self.view_model.change_annotation_mode( + side='left', + how=how, + save_settings=saveSettings, + annot_cca_checked=self.annotCcaInfoCheckbox.isChecked(), + annot_ids_checked=self.annotIDsCheckbox.isChecked(), + mode=self.modeComboBox.currentText(), + is_data_loading=self.isDataLoading, + eraser_checked=self.eraserButton.isChecked(), + ) + + def areContoursRequested(self, ax): + return self.view_model.contours_requested( + ax=ax, + left_contours=self.annotContourCheckbox.isChecked(), + right_image_visible=self.labelsGrad.showRightImgAction.isChecked(), + right_specific_mode=self.rightBottomGroupbox.isChecked(), + right_contours=self.annotContourCheckboxRight.isChecked(), + ) + + def areMothBudLinesRequested(self, ax): + return self.view_model.moth_bud_lines_requested( + ax=ax, + left_cca=self.annotCcaInfoCheckbox.isChecked(), + left_mother_bud_lines=self.drawMothBudLinesCheckbox.isChecked(), + right_image_visible=self.labelsGrad.showRightImgAction.isChecked(), + right_specific_mode=self.rightBottomGroupbox.isChecked(), + right_cca=self.annotCcaInfoCheckboxRight.isChecked(), + right_mother_bud_lines=( + self.drawMothBudLinesCheckboxRight.isChecked() + ), + ) + + def getMothBudLineScatterItem(self, ax, new): + if ax == 0: + if new: + return self.ax1_newMothBudLinesItem + else: + return self.ax1_oldMothBudLinesItem + else: + if new: + return self.ax2_newMothBudLinesItem + else: + return self.ax2_oldMothBudLinesItem + + def drawAllMothBudLines(self): + posData = self.data[self.pos_i] + for obj in posData.rp: + self.drawObjMothBudLines(obj, posData, ax=0) + self.drawObjMothBudLines(obj, posData, ax=1) + + def drawObjMothBudLines(self, obj, posData, ax=0): + areMothBudLinesRequested = self.areMothBudLinesRequested(ax) + if not areMothBudLinesRequested: + return + + mode = str(self.modeComboBox.currentText()) + + if posData.cca_df is None: + return + + ID = obj.label + try: + cca_df_ID = posData.cca_df.loc[ID] + except KeyError: + return + + ccs_ID = cca_df_ID['cell_cycle_stage'] + relationship = cca_df_ID['relationship'] + if not self.view_model.should_draw_moth_bud_line( + cca_df_available=posData.cca_df is not None, + mode=mode, + object_visible=self.isObjVisible(obj.bbox), + cell_cycle_stage=ccs_ID, + relationship=relationship, + ): + return + + emerg_frame_i = cca_df_ID['emerg_frame_i'] + isNew = emerg_frame_i == posData.frame_i + scatterItem = self.getMothBudLineScatterItem(ax, isNew) + relative_ID = cca_df_ID['relative_ID'] + + try: + relative_rp_idx = posData.IDs_idxs[relative_ID] + except KeyError: + return + + relative_ID_obj = posData.rp[relative_rp_idx] + y1, x1 = self.getObjCentroid(obj.centroid) + y2, x2 = self.getObjCentroid(relative_ID_obj.centroid) + xx, yy = self.view_model.geometry.line_coords( + y1, x1, y2, x2, dashed=True + ) + scatterItem.addPoints(xx, yy) + + def clearAllCellToCellLines(self): + self.ax1_newMothBudLinesItem.setData([], []) + self.ax1_oldMothBudLinesItem.setData([], []) + self.ax2_newMothBudLinesItem.setData([], []) + self.ax2_oldMothBudLinesItem.setData([], []) + + def drawAllLineageTreeLines(self): + """ + Draw all lineage tree lines on the GUI. + + This method retrieves the lineage tree data and draws the lineage tree lines + connecting cells and their respective mothers when the mother has split. + """ + if not self.view_model.should_draw_lineage_tree_lines( + lineage_tree_available=self.lineage_tree is not None, + frames_count=( + 0 if self.lineage_tree is None + else len(self.lineage_tree.frames_for_dfs) + ), + ): + return + + self.clearAllCellToCellLines() + posData = self.data[self.pos_i] + frame_i = posData.frame_i + lin_tree_df = posData.allData_li[frame_i]['acdc_df'] + lin_tree_df_prev = posData.allData_li[frame_i-1]['acdc_df'] + rp = posData.rp + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + + self.setTitleText() + + new_cells = lin_tree_df.index.difference(lin_tree_df_prev.index) # I could use this for the if already but this is probably faster for frames where nothing changes + if new_cells.shape[0] == 0: + return + + for ax in (0, 1): + if not self.areMothBudLinesRequested(ax): + continue + + for ID in new_cells: + curr_obj = self.view_model.lineage.object_by_label(rp, ID) + lin_tree_df_ID = lin_tree_df.loc[ID] + + # lin_tree_df_mother_ID = lin_tree_df_prev.loc[lin_tree_df_ID["parent_ID_tree"]] + if lin_tree_df_ID["parent_ID_tree"] == -1: # make sure that new obj where the parents are not known get skipped + continue + + mother_obj = self.view_model.lineage.object_by_label( + prev_rp, lin_tree_df_ID["parent_ID_tree"] + ) + + emerg_frame_i = lin_tree_df_ID["emerg_frame_i"] + isNew = emerg_frame_i == frame_i + + self.drawObjLin_TreeMothBudLines(ax, curr_obj, mother_obj, isNew, ID=ID) + + def drawObjLin_TreeMothBudLines(self, ax, obj, mother_obj, isNew, ID=None): + """ + Draw moth-bud lines between an object and its mother object. + + Parameters + ---------- + ax : cellacdc.widgets.MainPlotItem + The Cell-ACDC GUI axes object to draw on. + obj : Object + The object for which to draw the moth-bud lines. + mother_obj : Object + The mother object to connect with. + isNew : bool + Indicates whether the object is new or not. + ID : int, optional + The ID of the object, by default None. + """ + if not self.areMothBudLinesRequested(ax): + return + + if not ID: + ID = obj.label + + isObjVisible = self.isObjVisible(obj.bbox) + + if not isObjVisible: + return + + scatterItem = self.getMothBudLineScatterItem(ax, isNew) + + y1, x1 = self.getObjCentroid(obj.centroid) + y2, x2 = self.getObjCentroid(mother_obj.centroid) + xx, yy = self.view_model.geometry.line_coords( + y1, x1, y2, x2, dashed=True + ) + scatterItem.addPoints(xx, yy) + + def getObjCentroid(self, obj_centroid): + depth_axis = ( + self.switchPlaneCombobox.depthAxes() if self.isSegm3D else 'z' + ) + return self.view_model.edit_id.project_centroid( + obj_centroid, + is_3d=self.isSegm3D, + depth_axis=depth_axis, + ) + + def getObjOptsSegmLabels(self, obj): + if not self.labelsGrad.showLabelsImgAction.isChecked(): + return + + objOpts = self.getObjTextAnnotOpts(obj, 'Draw only IDs', ax=1) + return objOpts + + # @exec_time + def update_rp_metadata(self, draw=True): + posData = self.data[self.pos_i] + # Add to rp dynamic metadata (e.g. cells annotated as dead) + for i, obj in enumerate(posData.rp): + ID = obj.label + obj.excluded = ID in posData.binnedIDs + obj.dead = ID in posData.ripIDs + + def annotate_rip_and_bin_IDs(self, updateLabel=False): + depthAxes = self.switchPlaneCombobox.depthAxes() + if self.switchPlaneCombobox.isEnabled() and depthAxes != 'z': + return + + posData = self.data[self.pos_i] + binnedIDs_xx = [] + binnedIDs_yy = [] + ripIDs_xx = [] + ripIDs_yy = [] + for obj in posData.rp: + obj.excluded = obj.label in posData.binnedIDs + obj.dead = obj.label in posData.ripIDs + if not self.isObjVisible(obj.bbox): + continue + + if obj.excluded: + y, x = self.getObjCentroid(obj.centroid) + binnedIDs_xx.append(x) + binnedIDs_yy.append(y) + if updateLabel: + self.getObjOptsSegmLabels(obj) + how = self.drawIDsContComboBox.currentText() + + if obj.dead: + y, x = self.getObjCentroid(obj.centroid) + ripIDs_xx.append(x) + ripIDs_yy.append(y) + if updateLabel: + self.getObjOptsSegmLabels(obj) + how = self.drawIDsContComboBox.currentText() + + self.ax2_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) + self.ax2_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) + self.ax1_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) + self.ax1_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) + + def clearAnnotItems(self): + self.textAnnot[0].clear() + self.textAnnot[1].clear() + + # @exec_time + def setAllTextAnnotations(self, labelsToSkip=None): + delROIsIDs = self.setLostNewOldPrevIDs() + posData = self.data[self.pos_i] + self.textAnnot[0].setAnnotations( + posData=posData, + labelsToSkip=labelsToSkip, + isVisibleCheckFunc=self.isObjVisible, + highlightedID=self.highlightedID, + delROIsIDs=delROIsIDs, + annotateLost=self.annotLostObjsToggle.isChecked(), + getCurrentZfunc=self.z_lab, + getObjCentroidFunc=self.getObjCentroid + ) + self.textAnnot[1].setAnnotations( + posData=posData, labelsToSkip=labelsToSkip, + isVisibleCheckFunc=self.isObjVisible, + highlightedID=self.highlightedID, + delROIsIDs=delROIsIDs, + annotateLost=self.annotLostObjsToggle.isChecked(), + getCurrentZfunc=self.z_lab, + getObjCentroidFunc=self.getObjCentroid + ) + self.textAnnot[0].update() + self.textAnnot[1].update() + return delROIsIDs + + def labelRoiIsCircularRadioButtonToggled(self, checked): + if checked: + self.labelRoiCircularRadiusSpinbox.setDisabled(False) + else: + self.labelRoiCircularRadiusSpinbox.setDisabled(True) + + def pxModeActionToggled(self, checked): + self.view_model.change_pixel_mode( + checked=checked, + is_data_loaded=self.isDataLoaded, + high_resolution=self.highLowResAction.isChecked(), + ) + + def changeTextResolution(self): + self.view_model.change_text_resolution( + high_resolution=self.highLowResAction.isChecked(), + is_data_loaded=self.isDataLoaded, + ) + + def highLowResToggled(self, clicked=True): + self.changeTextResolution() + + def annotGenNumTreeToggled(self, checked): + self.view_model.change_gen_num_tree_annotations(checked) + + def annotLabelIDtreeToggled(self, checked): + self.view_model.change_label_tree_annotations(checked) + + def setAnnotInfoMode(self, checked): + self.view_model.change_tree_annotation_info_mode(checked) + + def annotOptionClicked(self, clicked=True, sender=None, saveSettings=True): + if sender is None: + sender = self.sender() + self.view_model.change_annotation_options( + side='left', + clicked_option=self._annotation_clicked_option('left', sender), + save_settings=saveSettings, + **self._annotation_option_state('left'), + ) + + def setDisabledAnnotCheckBoxesLeft(self, disabled): + self.annotIDsCheckbox.setDisabled(disabled) + self.annotCcaInfoCheckbox.setDisabled(disabled) + self.annotContourCheckbox.setDisabled(disabled) + self.annotSegmMasksCheckbox.setDisabled(disabled) + self.drawMothBudLinesCheckbox.setDisabled(disabled) + self.annotNumZslicesCheckbox.setDisabled(disabled) + self.drawNothingCheckbox.setDisabled(disabled) + + def setEnabledAnnotCheckBoxesLeftZdepthAxes(self): + self.view_model.enable_z_depth_annotation_options( + is_3d=self.isSegm3D, + **self._annotation_option_state('left'), + ) + + def setDisabledAnnotCheckBoxesRight(self, disabled): + self.annotIDsCheckboxRight.setDisabled(disabled) + self.annotCcaInfoCheckboxRight.setDisabled(disabled) + self.annotContourCheckboxRight.setDisabled(disabled) + self.annotSegmMasksCheckboxRight.setDisabled(disabled) + self.drawMothBudLinesCheckboxRight.setDisabled(disabled) + self.annotNumZslicesCheckboxRight.setDisabled(disabled) + self.drawNothingCheckboxRight.setDisabled(disabled) + + def annotOptionClickedRight( + self, clicked=True, sender=None, saveSettings=True + ): + if sender is None: + sender = self.sender() + self.view_model.change_annotation_options( + side='right', + clicked_option=self._annotation_clicked_option('right', sender), + save_settings=saveSettings, + **self._annotation_option_state('right'), + ) + + def setAnnotOptionsCcaMode(self): + self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( + return_value=True + ) + self.annotCcaInfoCheckbox.setChecked(True) + self.annotIDsCheckbox.setChecked(False) + self.drawMothBudLinesCheckbox.setChecked(False) + self.setDrawAnnotComboboxText() + + def setAnnotOptionsLin_treeMode(self): + # self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( + # return_value=True + # ) + self.annotCcaInfoCheckbox.setChecked(True) + self.annotIDsCheckbox.setChecked(False) + self.drawMothBudLinesCheckbox.setChecked(False) + self.setDrawAnnotComboboxText() + self.showTreeInfoCheckbox.setChecked(True) + + def setDrawAnnotComboboxText(self, saveSettings=True): + state = self._annotation_option_state('left') + state.pop('num_zslices') + self.view_model.refresh_annotation_mode_text( + side='left', + save_settings=saveSettings, + **state, + ) + + def setDrawAnnotComboboxTextRight(self, saveSettings=True): + state = self._annotation_option_state('right') + state.pop('num_zslices') + self.view_model.refresh_annotation_mode_text( + side='right', + save_settings=saveSettings, + **state, + ) + + def relabelSequentialCallback(self): + mode = str(self.modeComboBox.currentText()) + if mode == 'Viewer' or mode == 'Cell cycle analysis': + self.mode_controls_view.startBlinkingModeCB() + return + + posData = self.data[self.pos_i] + selectedPos = (posData.pos_foldername, ) + if len(self.data) > 1: + selectedPos = self.askSelectPos(action='to process') + if selectedPos is None: + self.logger.info('Re-labelling process stopped.') + return + + self.store_data() + # acdc_df_concat = self.status_hover_view.concat_acdc_df() + # load.store_unsaved_acdc_df( + # posData, acdc_df_concat, + # log_func=self.logger.info + # ) + # if posData.SizeT > 1: + self.progressWin = apps.QDialogWorkerProgress( + title='Re-labelling sequential', parent=self.host, + pbarDesc='Relabelling sequential...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + self.startRelabellingWorker(selectedPos) + + def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print): + logger('Updating annotated IDs...') + posData = self.data[self.pos_i] + + posData.ripIDs = self.view_model.label_edits.remap_id_set( + posData.ripIDs, oldIDs, newIDs + ) + posData.binnedIDs = self.view_model.label_edits.remap_id_set( + posData.binnedIDs, oldIDs, newIDs + ) + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + + customAnnotButtons = list(self.customAnnotDict.keys()) + for button in customAnnotButtons: + customAnnotValues = self.customAnnotDict[button] + annotatedIDs = customAnnotValues['annotatedIDs'][self.pos_i] + mappedAnnotIDs = self.view_model.custom_annotations.remap_ids( + annotatedIDs, + oldIDs, + newIDs, + ) + customAnnotValues['annotatedIDs'][self.pos_i] = mappedAnnotIDs + + def rtTrackerActionToggled(self, checked): + if not checked: + return + + aliases = self.view_model.model_registry.real_time_tracker_aliases( + reverse=True + ) + if self.sender().text() in aliases: + trackingAlgo = aliases[self.sender().text()] + else: + trackingAlgo = self.sender().text() + self.df_settings.at['tracking_algorithm', 'value'] = trackingAlgo + self.df_settings.to_csv(self.settings_csv_path) + + if self.sender().text() == 'YeaZ': + msg = widgets.myMessageBox(wrapText=False) + info_txt = html_utils.paragraph(f""" + Note that YeaZ tracking algorithm tends to be sliglhtly more accurate + overall, but it is less capable of detecting segmentation + errors.

+ If you need to correct as many segmentation errors as possible + we recommend using Cell-ACDC tracking algorithm. + """) + msg.information(self.host, 'Info about YeaZ', info_txt) + + self.isRealTimeTrackerInitialized = False + self.initRealTimeTracker() + + def autoPilotToggled(self, checked): + self.autoPilotZoomToObjToolbar.setVisible(checked) + if checked: + self.autoPilotZoomToObjToggle.setChecked(False) + self.autoPilotZoomToObjToggle.toggle() + + def storeCurrentAnnotOptions_ax1(self, return_value=False): + if self.annotOptionsToRestore is not None: + return + + checkboxes = [ + 'annotIDsCheckbox', + 'annotCcaInfoCheckbox', + 'annotContourCheckbox', + 'annotSegmMasksCheckbox', + 'drawMothBudLinesCheckbox', + 'annotNumZslicesCheckbox', + 'drawNothingCheckbox', + ] + annotOptions = {} + for checkboxName in checkboxes: + checkbox = getattr(self, checkboxName) + annotOptions[checkboxName] = checkbox.isChecked() + if return_value: + return annotOptions + self.annotOptionsToRestore = annotOptions + + def storeCurrentAnnotOptions_ax2(self): + if self.annotOptionsToRestoreRight is not None: + return + + checkboxes = [ + 'annotIDsCheckboxRight', + 'annotCcaInfoCheckboxRight', + 'annotContourCheckboxRight', + 'annotSegmMasksCheckboxRight', + 'drawMothBudLinesCheckboxRight', + 'annotNumZslicesCheckboxRight', + 'drawNothingCheckboxRight', + ] + self.annotOptionsToRestoreRight = {} + for checkboxName in checkboxes: + checkbox = getattr(self, checkboxName) + self.annotOptionsToRestoreRight[checkboxName] = checkbox.isChecked() + + def restoreAnnotOptions_ax1(self, options=None): + if options is None and not hasattr(self, 'annotOptionsToRestore'): + return + + if options is None: + options = self.annotOptionsToRestore + + if options is None: + return + + for option, state in options.items(): + checkbox = getattr(self, option) + checkbox.setChecked(state) + + self.setDrawAnnotComboboxText() + self.annotOptionsToRestore = None + + def restoreAnnotOptions_ax2(self): + if not hasattr(self, 'annotOptionsToRestoreRight'): + return + + if self.annotOptionsToRestoreRight is None: + return + + for option, state in self.annotOptionsToRestoreRight.items(): + checkbox = getattr(self, option) + checkbox.setChecked(state) + + self.setDrawAnnotComboboxTextRight() + self.annotOptionsToRestoreRight = None + + def setDrawNothingAnnotations(self): + self.storeCurrentAnnotOptions_ax1() + self.storeCurrentAnnotOptions_ax2() + self.drawNothingCheckbox.setChecked(True) + self.annotOptionClicked( + sender=self.drawNothingCheckbox, saveSettings=False) + self.drawNothingCheckboxRight.setChecked(True) + self.annotOptionClickedRight( + sender=self.drawNothingCheckboxRight, saveSettings=False + ) + + def restoreAnnotationsOptions(self): + self.restoreAnnotOptions_ax1() + self.restoreAnnotOptions_ax2() + + def onDoubleSpaceBar(self): + how = self.drawIDsContComboBox.currentText() + if how.find('nothing') == -1: + self.storeCurrentAnnotOptions_ax1() + self.drawNothingCheckbox.setChecked(True) + self.annotOptionClicked( + sender=self.drawNothingCheckbox, saveSettings=False + ) + else: + self.restoreAnnotOptions_ax1() + + how = self.annotateRightHowCombobox.currentText() + if how.find('nothing') == -1: + self.storeCurrentAnnotOptions_ax2() + self.drawNothingCheckboxRight.setChecked(True) + self.annotOptionClickedRight( + sender=self.drawNothingCheckboxRight, saveSettings=False + ) + else: + self.restoreAnnotOptions_ax2() + + + def zoomRectActionToggled(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + self.connectLeftClickButtons() + self.ax1.addItem(self.zoomRectItem) + else: + self.zoomRectItem.setPos((0,0)) + self.zoomRectItem.setSize((0,0)) + self.ax1.removeItem(self.zoomRectItem) + + def zoomRectDone(self): + xRange, yRange = self.ax1.viewRange() + self.zoomRectItem.storeLastRange(xRange, yRange) + + ymin, xmin, ymax, xmax = self.zoomRectItem.bbox() + + self.zoomRectItem.setPos((0,0)) + self.zoomRectItem.setSize((0,0)) + + self.ax1.setRange( + xRange=(xmin, xmax), + yRange=(ymin, ymax), + padding=0 + ) + + def zoomRectCancelled(self): + self.isMouseDragImg1 = False + self.zoomRectItem.setPos((0,0)) + self.zoomRectItem.setSize((0,0)) + + def keepToolActiveActionToggled(self, checked, toolName=None): + if toolName is None: + parentToolButton = self.sender().parent() + toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] + + if checked: + self.df_settings.at[toolName, 'value'] = 'keepActive' + else: + self.df_settings = self.df_settings.drop( + index=toolName, errors='ignore' + ) + self.df_settings.to_csv(self.settings_csv_path) + + def applyToolNewFrameActionToggled(self, checked, toolName=None): + if toolName is None: + parentToolButton = self.sender().parent() + toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] + toolName = toolName.strip() + button = self.applyToolNewFrameButtons[toolName] + toolName = toolName.replace(' ', '_') + settingName = f'{toolName}_applyNewFrame' + if checked: + self.df_settings.at[settingName, 'value'] = 'applyNewFrame' + button.setStyleSheet(f'background-color: {GREEN_HEX}') + else: + self.df_settings = self.df_settings.drop( + index=settingName, errors='ignore' + ) + button.setStyleSheet('background-color: none') + self.df_settings.to_csv(self.settings_csv_path) + + def keepAllToolsActiveActionToggled(self, checked): + for action in self.keepToolActiveActions.values(): + action.setChecked(checked) + + data_loaded = True + if not hasattr(self, 'data'): + data_loaded = False + try: + self.labelRoiTrangeCheckbox.disconnect() + except TypeError: + pass + self.labelRoiTrangeCheckbox.setChecked(checked) # why this is not wrapped in a QAction? + + if data_loaded: + self.labelRoiTrangeCheckbox.toggled.connect( + self.labelRoiTrangeCheckboxToggled + ) + + def setVisible3DsegmWidgets(self): + self.view_model.update_visible_3d_segmentation_widgets( + is_3d=self.isSegm3D, + ) + + def showHighlightZneighCheckbox(self): + self.view_model.update_z_neighbor_highlight_checkbox( + is_3d=self.isSegm3D, + ) + + def highlightZneighLabels_cb(self, checked): + if checked: + pass + else: + pass + + def restoreSavedSettings(self): + self.view_model.restore_saved_settings( + settings_values=self.df_settings['value'].to_dict(), + left_num_zslices=self.annotNumZslicesCheckbox.isChecked(), + right_num_zslices=self.annotNumZslicesCheckboxRight.isChecked(), + ) + + def uncheckAnnotOptions(self, left=True, right=True): + # Left + if left: + self.annotIDsCheckbox.setChecked(False) + self.annotCcaInfoCheckbox.setChecked(False) + self.annotContourCheckbox.setChecked(False) + self.annotSegmMasksCheckbox.setChecked(False) + self.drawMothBudLinesCheckbox.setChecked(False) + self.drawNothingCheckbox.setChecked(False) + + # Right + if right: + self.annotIDsCheckboxRight.setChecked(False) + self.annotCcaInfoCheckboxRight.setChecked(False) + self.annotContourCheckboxRight.setChecked(False) + self.annotSegmMasksCheckboxRight.setChecked(False) + self.drawMothBudLinesCheckboxRight.setChecked(False) + self.drawNothingCheckboxRight.setChecked(False) + + def setDisabledAnnotOptions(self, disabled): + # Left + self.annotIDsCheckbox.setDisabled(disabled) + self.annotCcaInfoCheckbox.setDisabled(disabled) + self.annotContourCheckbox.setDisabled(disabled) + # self.annotSegmMasksCheckbox.setDisabled(disabled) + self.drawMothBudLinesCheckbox.setDisabled(disabled) + # self.drawNothingCheckbox.setDisabled(disabled) + + # Right + self.annotIDsCheckboxRight.setDisabled(disabled) + self.annotCcaInfoCheckboxRight.setDisabled(disabled) + self.annotContourCheckboxRight.setDisabled(disabled) + # self.annotSegmMasksCheckboxRight.setDisabled(disabled) + self.drawMothBudLinesCheckboxRight.setDisabled(disabled) + # self.drawNothingCheckboxRight.setDisabled(disabled) + + def drawAnnotCombobox_to_options(self): + self.view_model.sync_annotation_options_from_mode_text( + left_text=self.drawIDsContComboBox.currentText(), + right_text=self.annotateRightHowCombobox.currentText(), + left_num_zslices=self.annotNumZslicesCheckbox.isChecked(), + right_num_zslices=self.annotNumZslicesCheckboxRight.isChecked(), + ) diff --git a/cellacdc/views/app_shell_view.py b/cellacdc/views/app_shell_view.py new file mode 100644 index 000000000..5961b82c7 --- /dev/null +++ b/cellacdc/views/app_shell_view.py @@ -0,0 +1,353 @@ +"""Qt view adapter for the application shell.""" + +from __future__ import annotations + +import os +import re +from datetime import timedelta + +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import QWidget + +from cellacdc import ( + _warnings, base_cca_dict, cca_df_colnames, html_utils, + settings_csv_path, widgets, +) +from cellacdc.help import about, welcome +from cellacdc.viewmodels.app_shell_viewmodel import AppShellViewModel + + +class AppShellView: + """Qt-facing adapter around application shell lifecycle actions.""" + + LEGACY_METHODS = ( + 'initGlobalAttr', + 'initProfileModels', + 'setDisabled', + 'determineSlideshowWinPos', + 'setTooltips', + 'setWindowIcon', + 'setWindowTitle', + 'onToggleColorScheme', + 'showAbout', + 'openLogFile', + 'showLogFiles', + 'showInExplorer_cb', + 'showTipsAndTricks', + 'openNewWindow', + 'cleanUpOnError', + 'copyContent', + 'pasteContent', + 'cutContent', + 'about', + ) + + def __init__(self, host, view_model: AppShellViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def _set_qwidget_disabled(self, disabled: bool): + QWidget.setDisabled(self.host, disabled) + + def initGlobalAttr(self): + self.setOverlayColors() + + self.initImgCmap() + + # Colormap + self.setLut() + + self.fluoDataChNameActions = [] + + self.splineHoverON = False + self.tempSegmentON = False + self.xyOnCtrlPressedFirstTime = None + self.typingEditID = False + self.prevAnnotOptions = None + self.ghostObject = None + self.autoContourHoverON = False + self.navigateScrollBarStartedMoving = True + self.zSliceScrollBarStartedMoving = True + self.labelRoiRunning = False + self.isRangeReset = True + self.lastManualSeparateState = None + self.editIDmergeIDs = True + self.doNotAskAgainExistingID = False + self.doubleRightClickTimeElapsed = False + self.isRealTimeTrackerInitialized = False + self.isWarningCcaIntegrity = False + self.isDoubleRightClick = False + self.isExportingVideo = False + self.pointsLayersNeverToggled = True + self.highlightedIDopts = None + self.timestampStartTimedelta = timedelta(seconds=0) + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + self._ZprojWidgersEnabledState = None + self.imgValueFormatter = 'd' + self.rawValueFormatter = 'd' + self.lastHoverID = -1 + self.annotOptionsToRestore = None + self.annotOptionsToRestoreRight = None + self.rescaleIntensChannelHowMapper = { + self.user_ch_name: 'Rescale each 2D image' + } + self.timestampDialog = None + self.scaleBarDialog = None + self.countObjsWindow = None + self.initLabelRoiModelDialog = None + + # Second channel used by cellpose + self.secondChannelName = None + + self.ax1_viewRange = None + self.measurementsWin = None + + self.model_kwargs = None + self.segmModelName = None + self.labelRoiModel = None + self.autoSegmDoNotAskAgain = False + self.labelRoiGarbageWorkers = [] + self.labelRoiActiveWorkers = [] + + self.clickedOnBud = False + self.postProcessSegmWin = None + + self.UserEnforced_DisabledTracking = False + self.UserEnforced_Tracking = False + + self.ax1BrushHoverID = 0 + + self.disabled_cca_warnings = set() + + self.last_pos_i = -1 + self.last_frame_i = -1 + + # Plots items + self.isMouseDragImg2 = False + self.isMouseDragImg1 = False + self.isMovingLabel = False + self.isRightClickDragImg1 = False + self.clickObjYc, self.clickObjXc = None, None + + self.cca_df_colnames = cca_df_colnames + self.cca_df_dtypes = [ + str, int, int, str, int, int, bool, bool, int + ] + self.cca_df_default_values = list(base_cca_dict.values()) + self.cca_df_int_cols = [ + col for col in cca_df_colnames if type(base_cca_dict[col]) == int + ] + self.lin_tree_df_bool_col = [ + col for col in cca_df_colnames + if isinstance(base_cca_dict[col], bool) + ] + + self.lin_tree_col_checks = [ + 'generation_num', + ] + + # self.lin_tree_df_colnames = set(base_cca_df.keys()) | set(lineage_tree_cols) + # # self.lin_tree_df_dtypes = [ #dk if i need this, for now ignored + # # str, int, int, str, int, int, bool, bool, int + # # ] + # self.lin_tree_df_default_values = list(base_cca_df.values()) + lineage_tree_cols_std_val + self.lin_tree_df_int_cols = [ + 'generation_num', + 'relative_ID', + 'emerg_frame_i', + 'division_frame_i', + 'corrected_on_frame_i' + ] + self.lin_tree_df_bool_col = [ + 'is_history_known', + ] + + self.lin_tree_col_checks = [ + 'generation_num', + ] + + self.lin_tree_df_colnames = ( + self.lin_tree_df_int_cols + + self.lin_tree_df_bool_col + + self.lin_tree_col_checks + ) + self.SegForLostIDsSettings = {} + + def initProfileModels(self): + self.logger.info('Initiliazing profilers...') + + from cellacdc._profile.spline_to_obj import model + + self.splineToObjModel = model.Model() + + self.splineToObjModel.fit() + + def setDisabled(self, disabled:bool, keepDisabled:bool=None, force:bool=False): + if force: + if disabled: + self._set_qwidget_disabled(disabled) + return + else: + self.keepDisabled = False + self._set_qwidget_disabled(disabled) + return + + if keepDisabled is not None: + self.keepDisabled = keepDisabled + + if self.keepDisabled: + if disabled: + self._set_qwidget_disabled(disabled) + return + else: + return + else: + self._set_qwidget_disabled(disabled) + + def determineSlideshowWinPos(self): + screens = self.app.screens() + self.numScreens = len(screens) + winScreen = self.screen() + + # Center main window and determine location of slideshow window + # depending on number of screens available + if self.numScreens > 1: + for screen in screens: + if screen != winScreen: + winScreen = screen + break + + winScreenGeom = winScreen.geometry() + winScreenCenter = winScreenGeom.center() + winScreenCenterX = winScreenCenter.x() + winScreenCenterY = winScreenCenter.y() + winScreenLeft = winScreenGeom.left() + winScreenTop = winScreenGeom.top() + self.slideshowWinLeft = winScreenCenterX - int(850/2) + self.slideshowWinTop = winScreenCenterY - int(800/2) + + def setTooltips(self): + tooltips = self.view_model.tooltips_from_docs() + + for key, tooltip in tooltips.items(): + setShortcut = getattr(self, key).shortcut().toString() + if 'Shortcut: ' in tooltip: + tooltip = tooltip.replace('Shortcut: ', '\nShortcut: ') + elif setShortcut != "": + tooltip = re.sub( + r'Shortcut: \"(.*)\"', + f"Shortcut: \"{setShortcut}\"", + tooltip + ) + else: + tooltip = re.sub( + r'Shortcut: \"(.*)\"', + f"Shortcut: \"No shortcut\"", + tooltip + ) + + getattr(self, key).setToolTip(tooltip) + getattr(self, key)._tooltip = tooltip + + def setWindowIcon(self, icon=None): + if icon is None: + icon = QIcon(":icon.ico") + QWidget.setWindowIcon(self.host, icon) + + def setWindowTitle(self, title=None): + if title is None: + title = f'Cell-ACDC v{self._acdc_version} - GUI' + QWidget.setWindowTitle(self.host, title) + + def onToggleColorScheme(self): + if self.toggleColorSchemeAction.text().find('light') != -1: + self._colorScheme = 'light' + else: + self._colorScheme = 'dark' + self.gui_updateSwitchColorSchemeActionText() + _warnings.warnRestartCellACDCcolorModeToggled( + self._colorScheme, app_name=self._appName, parent=self.host + ) + self.view_model.rename_qrc_resources_file(self._colorScheme) + self.statusBarLabel.setText(html_utils.paragraph( + f'Restart {self._appName} for the change to take effect', + font_color='red' + )) + self.df_settings.at['colorScheme', 'value'] = self._colorScheme + self.df_settings.to_csv(settings_csv_path) + + def showAbout(self): + self.aboutWin = about.QDialogAbout(parent=self.host) + self.aboutWin.show() + + def openLogFile(self): + self.logger.info(f'Opening log file "{self.log_path}"...') + self.view_model.show_in_file_manager(self.log_path) + + def showLogFiles(self): + log_files_path = os.path.dirname(self.log_path) + self.logger.info(f'Opening log files folder "{log_files_path}"...') + self.view_model.show_in_file_manager(log_files_path) + + def showInExplorer_cb(self): + posData = self.data[self.pos_i] + path = posData.images_path + self.view_model.show_in_file_manager(path) + + def showTipsAndTricks(self): + self.welcomeWin = welcome.welcomeWin() + self.welcomeWin.showAndSetSize() + self.welcomeWin.showPage(self.welcomeWin.quickStartItem) + + def openNewWindow(self): + self.logger.info('Opening a new window...') + if self.launcherSlot is not None: + self.launcherSlot() + return + + winClass = self.__class__ + win = winClass( + self.app, + parent=self.host, + mainWin=self.mainWin, + version=self._version, + ) + win.run() + self.newWindows.append(win) + + def cleanUpOnError(self): + self.onEscape() + caller = 'Cell-ACDC' + if self.module.startswith('spotmax'): + caller = 'spotMAX' + txt = f'WARNING: {caller} is in error state. Please, restart.' + _hl = '*'*100 + self.titleLabel.setText(txt, color='r') + self.logger.info(f'{_hl}\n{txt}\n{_hl}') + + def copyContent(self): + pass + + def pasteContent(self): + pass + + def cutContent(self): + pass + + def about(self): + pass diff --git a/cellacdc/views/brush_tools_view.py b/cellacdc/views/brush_tools_view.py new file mode 100644 index 000000000..781cab0d1 --- /dev/null +++ b/cellacdc/views/brush_tools_view.py @@ -0,0 +1,578 @@ +"""Qt view adapter for brush and eraser tools.""" + +from __future__ import annotations + +import cv2 +import numpy as np +import skimage.measure +from qtpy.QtWidgets import QCheckBox + +from cellacdc import html_utils, settings_csv_path, widgets +from cellacdc.viewmodels.brush_tools_viewmodel import BrushToolsViewModel + + +class BrushToolsView: + """Qt-facing adapter around brush and eraser tool workflows.""" + + LEGACY_METHODS = ( + 'instructHowDeleteID', + 'checkWarnDeletedIDwithEraser', + 'brushAutoFillToggled', + 'brushAutoHideToggled', + 'fillHolesID', + 'brushReleased', + 'brushSize_cb', + 'autoIDtoggled', + 'Brush_cb', + 'showEditIDwidgets', + 'resetCursors', + 'updateEraserCursor', + 'setDiskMask', + 'getDiskMask', + 'applyEraserMask', + 'changeBrushID', + 'applyBrushMask', + 'setBrushID', + 'initFloodMaskImage', + 'getMagicWandFloodTolerance', + 'initTempLayerBrush', + '_setTempImageBrushContour', + 'setTempBrushMaskFromWand', + 'setTempImg1Brush', + 'getLabelsLayerImage', + 'clearObjFromMask', + 'setTempImg1Eraser', + 'Eraser_cb', + ) + + def __init__(self, host, view_model: BrushToolsViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def instructHowDeleteID(self): + if 'showInfoDeleteObject' not in self.df_settings.index: + self.df_settings.at['showInfoDeleteObject', 'value'] = ( + self.view_model.default_delete_object_info_value() + ) + + showInfoDeleteObject = self.view_model.should_show_delete_object_info( + self.df_settings.at['showInfoDeleteObject', 'value'] + ) + if not showInfoDeleteObject: + return + + actionText = self.middleClickText() + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + 'You have deleted an object using the eraser tool.

' + 'Did you know that you can use the "Delete object" action
' + 'to delete an object with a single click?

' + f'To do so, use the following action: {actionText}

' + 'Note: You can also set a custom shortcut by going to the menu
' + 'Settings --> Customise keyboard shortcuts....' + ) + doNotShowAgainCheckbox = QCheckBox('Do not show again') + msg.information( + self.host, 'Delete objects with single click', txt, + widgets=doNotShowAgainCheckbox + ) + + showInfoDeleteObjectValue = self.view_model.delete_object_info_value( + doNotShowAgainCheckbox.isChecked() + ) + self.df_settings.at['showInfoDeleteObject', 'value'] = ( + showInfoDeleteObjectValue + ) + self.df_settings.to_csv(settings_csv_path) + + def checkWarnDeletedIDwithEraser(self): + posData = self.data[self.pos_i] + + for ID in self.erasedIDs: + if ID == 0: + continue + if ID in posData.IDs_idxs: + continue + + self.instructHowDeleteID() + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Delete ID with eraser') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Delete ID with eraser') + + return True + + return False + + def brushAutoFillToggled(self, checked): + val = self.view_model.checked_setting_value(checked) + self.df_settings.at['brushAutoFill', 'value'] = val + self.df_settings.to_csv(self.settings_csv_path) + + def brushAutoHideToggled(self, checked): + val = self.view_model.checked_setting_value(checked) + self.df_settings.at['brushAutoHide', 'value'] = val + self.df_settings.to_csv(self.settings_csv_path) + + # @exec_time + def fillHolesID(self, ID, sender='brush'): + posData = self.data[self.pos_i] + if sender == 'brush': + if not self.view_model.should_fill_holes( + sender, + auto_fill_checked=self.brushAutoFillCheckbox.isChecked(), + ): + return False + + lab2D = self.get_2Dlab(posData.lab) + result = self.host.view_model.label_edits.fill_label_holes( + lab2D, + ID, + ) + self.set_2Dlab(result.labels) + return True + return False + + def brushReleased(self): + posData = self.data[self.pos_i] + self.fillHolesID(posData.brushID, sender='brush') + + # Update data (rp, etc) + self.update_rp(update_IDs=self.isNewID,) + + # Repeat tracking + if self.autoIDcheckbox.isChecked(): + self.trackManuallyAddedObject(posData.brushID, self.isNewID) + else: + self.update_rp(update_IDs=posData.brushID not in posData.IDs_idxs) + + # Update images + if self.isNewID: + editTxt = 'Add new ID with brush tool' + if self.isSnapshot: + self.fixCcaDfAfterEdit(editTxt) + self.updateAllImages() + else: + self.warnEditingWithCca_df(editTxt) + else: + self.updateAllImages() + + self.isNewID = False + + def brushSize_cb(self, value): + self.ax2_EraserCircle.setSize(value*2) + self.ax1_BrushCircle.setSize(value*2) + self.ax2_BrushCircle.setSize(value*2) + self.ax1_EraserCircle.setSize(value*2) + self.ax2_EraserX.setSize(value) + self.ax1_EraserX.setSize(value) + self.setDiskMask() + + def autoIDtoggled(self, checked): + self.editIDspinboxAction.setDisabled(checked) + self.editIDLabelAction.setDisabled(checked) + if not checked and self.editIDspinbox.value() == 0: + newID = self.setBrushID(return_val=True) + self.editIDspinbox.setValue(newID) + + def Brush_cb(self, checked): + if checked: + self.typingEditID = False + self.setDiskMask() + self.setHoverToolSymbolData( + [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX) + ) + self.updateBrushCursor(self.xHoverImg, self.yHoverImg) + self.setBrushID() + + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + c = self.defaultToolBarButtonColor + self.eraserButton.setStyleSheet(f'background-color: {c}') + self.connectLeftClickButtons() + self.image_controls_view.setFocusGraphics() + else: + self.ax1_lostObjScatterItem.setVisible(True) + self.ax2_lostObjScatterItem.setVisible(True) + self.ax1_lostTrackedScatterItem.setVisible(True) + self.ax2_lostTrackedScatterItem.setVisible(True) + + self.setHoverToolSymbolData( + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + self.resetCursors() + + self.showEditIDwidgets(checked) + self.mode_controls_view.enableSizeSpinbox(checked) + + def showEditIDwidgets(self, visible): + self.editIDLabelAction.setVisible(visible) + self.editIDspinboxAction.setVisible(visible) + self.autoIDcheckboxAction.setVisible(visible) + showToolbar = ( + self.view_model.brush_toolbar_visible( + visible, + brush_size_visible=self.brushSizeAction.isVisible(), + auto_fill_visible=self.brushAutoFillAction.isVisible(), + auto_hide_visible=self.brushAutoHideAction.isVisible(), + ) + ) + self.brushEraserToolBar.setVisible(showToolbar) + + def resetCursors(self): + self.ax1_cursor.setData([], []) + self.ax2_cursor.setData([], []) + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + def updateEraserCursor(self, x, y, xyLocked=None, isHoverImg1=True): + if x is None: + return + + xdata, ydata = int(x), int(y) + _img = self.currentLab2D + Y, X = _img.shape + + if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): + return + + size = self.brushSizeSpinbox.value()*2 + self.setHoverToolSymbolData( + [x], [y], self.activeEraserCircleCursors(isHoverImg1), + size=size + ) + self.setHoverToolSymbolData( + [x], [y], self.activeEraserXCursors(isHoverImg1), + size=int(size/2) + ) + + isMouseDrag = ( + self.isMouseDragImg1 or self.isMouseDragImg2 + ) + if isMouseDrag: + return + + if xyLocked is not None: + xdata, ydata = xyLocked + + self.setHoverToolSymbolColor( + xdata, ydata, self.eraserCirclePen, + self.activeEraserCircleCursors(isHoverImg1), + self.eraserButton, hoverRGB=None + ) + + def setDiskMask(self): + brushSize = self.brushSizeSpinbox.value() + self.diskMask = self.view_model.disk_mask(brushSize) + + def getDiskMask(self, xdata, ydata): + brushSize = self.brushSizeSpinbox.value() + return self.view_model.disk_mask_bounds( + self.currentLab2D.shape[-2:], + brushSize, + xdata, + ydata, + self.diskMask, + ) + + # @exec_time + def applyEraserMask(self, mask): + posData = self.data[self.pos_i] + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if isZslice: + posData.lab[self.z_lab(), mask] = 0 + else: + posData.lab[:, mask] = 0 + else: + posData.lab[mask] = 0 + + def changeBrushID(self): + """Function called when pressing or releasing shift + """ + if not self.isSegm3D: + # Changing brush ID with shift is only for 3D segm + return + + if not self.brushButton.isChecked(): + # Brush if not active + return + + if not self.isMouseDragImg2 and not self.isMouseDragImg1: + # Mouse is not brushing at the moment + return + + posData = self.data[self.pos_i] + forceNewObj = not self.isNewID + + if forceNewObj: + # Shift is down --> force new object with brush + # e.g., 24 --> 28: + # 24 is hovering ID that we store as self.prevBrushID + # 24 object becomes 28 that is the new posData.brushID + self.isNewID = True + self.changedID = posData.brushID + self.restoreBrushID = posData.brushID + # Set a new ID + self.setBrushID() + else: + # Shift released or hovering on ID in z+-1 + # --> restore brush ID from before shift was pressed or from + # when we started brushing from outside an object + # but we hovered on ID in z+-1 while dragging. + # We change the entire 28 object to 24 so before changing the + # brush ID back to 24 we builg the mask with 28 to change it to 24 + self.isNewID = False + self.changedID = posData.brushID + # Restore ID + posData.brushID = self.restoreBrushID + + brushID = posData.brushID + brushIDmask = self.get_2Dlab(posData.lab) == self.changedID + self.applyBrushMask(brushIDmask, brushID) + if self.isMouseDragImg1: + self.brushColor = self.lut[posData.brushID]/255 + self.setTempImg1Brush(True, brushIDmask, posData.brushID) + + def applyBrushMask(self, mask, ID, toLocalSlice=None): + posData = self.data[self.pos_i] + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if isZslice: + if toLocalSlice is not None: + toLocalSlice = (self.z_lab(), *toLocalSlice) + posData.lab[toLocalSlice][mask] = ID + else: + posData.lab[self.z_lab()][mask] = ID + else: + if toLocalSlice is not None: + for z in range(len(posData.lab)): + _slice = (z, *toLocalSlice) + posData.lab[_slice][mask] = ID + else: + posData.lab[:, mask] = ID + else: + if toLocalSlice is not None: + posData.lab[toLocalSlice][mask] = ID + else: + posData.lab[mask] = ID + + def setBrushID(self, useCurrentLab=True, return_val=False): + # Make sure that the brushed ID is always a new one based on + # already visited frames + posData = self.data[self.pos_i] + wl_init = posData.whitelist and posData.whitelist.whitelistIDs + id_groups = [] + if useCurrentLab: + IDs_tot = set(posData.IDs) + if wl_init: + try: + IDs_tot.update(posData.whitelist.originalLabsIDs[posData.frame_i]) + except: + pass + try: + if posData.whitelist.whitelistIDs[posData.frame_i]: + IDs_tot.update(posData.whitelist.whitelistIDs[posData.frame_i]) + except: + pass + id_groups.append(IDs_tot) + for frame_i, storedData in enumerate(posData.allData_li): + if frame_i == posData.frame_i: + continue + lab = storedData['labels'] + if lab is not None: + rp = storedData['regionprops'] + IDs_tot = {obj.label for obj in rp} + if wl_init: + if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): + IDs_tot.update(posData.whitelist.originalLabsIDs[frame_i]) + if posData.whitelist.whitelistIDs[frame_i]: + IDs_tot.update(posData.whitelist.whitelistIDs[frame_i]) + id_groups.append(IDs_tot) + else: + break + + posData.brushID = ( + self.host.view_model.label_edits.next_available_label_id( + id_groups, + manual_edit_info=posData.editID_info, + ) + ) + if return_val: + return posData.brushID + + def initFloodMaskImage(self): + posData = self.data[self.pos_i] + self.flood_img = posData.img_data[posData.frame_i] + if not self.isSegm3D and posData.SizeZ > 1: + self.flood_img = self.get_2Dimg_from_3D(self.flood_img) + return + + def getMagicWandFloodTolerance(self): + tol_perc = self.wandControlsToolbar.toleranceSpinbox.value() + posData = self.data[self.pos_i] + _min, _max = posData.img_data_min_max + return self.view_model.magic_wand_flood_tolerance(tol_perc, _min, _max) + + def initTempLayerBrush(self, ID, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + self.hideItemsHoverBrush(ID=ID, force=True) + Y, X = self.img1.image.shape[:2] + tempImage = np.zeros((Y, X), dtype=np.uint32) + if how.find('contours') != -1: + tempImage[self.currentLab2D==ID] = ID + self.brushImage = tempImage.copy() + self.brushContourImage = np.zeros((Y, X, 4), dtype=np.uint8) + color = self.imgGrad.contoursColorButton.color() + self.brushContoursRgba = color.getRgb() + opacity = 1.0 + else: + opacity = self.imgGrad.labelsAlphaSlider.value() + color = self.lut[ID] + lut = np.zeros((2, 4), dtype=np.uint8) + lut[1,-1] = 255 + lut[1,:-1] = color + self.tempLayerImg1.setLookupTable(lut) + self.tempLayerImg1.setOpacity(opacity) + self.tempLayerImg1.setImage(tempImage, force_set_linked=True) + + def _setTempImageBrushContour(self): + pass + + def setTempBrushMaskFromWand(self, flood_mask, init=False): + if not np.any(flood_mask): + return + + posData = self.data[self.pos_i] + mask = np.logical_or( + flood_mask, + posData.lab==posData.brushID + ) + if mask.ndim == 3: + z_slice = self.zSliceScrollBar.sliderPosition() + mask = mask[z_slice] + + self.setTempImg1Brush(init, mask, posData.brushID) + + # @exec_time + def setTempImg1Brush(self, init: bool, mask, ID, toLocalSlice=None, ax=0): + if init: + self.initTempLayerBrush(ID, ax=ax) + + if self.annotContourCheckbox.isChecked(): + brushImage = self.brushImage + else: + brushImage = self.tempLayerImg1.image + + if toLocalSlice is None: + brushImage[mask] = ID + else: + brushImage[toLocalSlice][mask] = ID + + if self.annotContourCheckbox.isChecked(): + try: + obj = skimage.measure.regionprops(brushImage)[0] + except IndexError: + return + objContour = [self.getObjContours(obj)] + self.brushContourImage[:] = 0 + img = self.brushContourImage + color = self.brushContoursRgba + cv2.drawContours(img, objContour, -1, color, 1) + self.tempLayerImg1.setImage(img, force_set_linked=True) + else: + self.tempLayerImg1.setImage(brushImage, force_set_linked=True) + + def getLabelsLayerImage(self, ax=0): + if ax == 0: + return self.labelsLayerImg1.image + else: + return self.labelsLayerRightImg.image + + def clearObjFromMask(self, image, mask, toLocalSlice=None): + if mask is None: + return image + + if toLocalSlice is None: + image[mask] = 0 + else: + image[toLocalSlice][mask] = 0 + + return image + + # @exec_time + def setTempImg1Eraser(self, mask, init=False, toLocalSlice=None, ax=0): + if init: + self.erasedLab = np.zeros_like(self.currentLab2D) + + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): + return + + if how.find('contours') != -1: + self.clearObjFromMask( + self.contoursImage, mask, toLocalSlice=toLocalSlice + ) + erasedRp = skimage.measure.regionprops(self.erasedLab) + for obj in erasedRp: + self.addObjContourToContoursImage(obj=obj, ax=ax) + elif how.find('overlay segm. masks') != -1: + labelsImage = self.getLabelsLayerImage(ax=ax) + self.clearObjFromMask(labelsImage, mask, toLocalSlice=toLocalSlice) + if ax == 0: + self.labelsLayerImg1.setImage( + self.labelsLayerImg1.image, autoLevels=False + ) + else: + self.labelsLayerRightImg.setImage( + self.labelsLayerRightImg.image, autoLevels=False + ) + + def Eraser_cb(self, checked): + if checked: + self.setDiskMask() + self.setHoverToolSymbolData( + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + self.updateEraserCursor(self.xHoverImg, self.yHoverImg) + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + c = self.defaultToolBarButtonColor + self.brushButton.setStyleSheet(f'background-color: {c}') + self.connectLeftClickButtons() + else: + self.setHoverToolSymbolData( + [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX) + ) + self.resetCursors() + self.updateAllImages() + + self.showEditIDwidgets(checked) + self.mode_controls_view.enableSizeSpinbox(checked) diff --git a/cellacdc/views/canvas_context_menu_view.py b/cellacdc/views/canvas_context_menu_view.py new file mode 100644 index 000000000..4a864b92f --- /dev/null +++ b/cellacdc/views/canvas_context_menu_view.py @@ -0,0 +1,125 @@ +"""View adapter for canvas context menus and deleted-ROI clicks.""" + +from __future__ import annotations + +import pyqtgraph as pg +from qtpy.QtCore import QPoint +from qtpy.QtWidgets import QAction, QMenu + +from cellacdc.viewmodels.canvas_context_menu_viewmodel import ( + CanvasContextMenuViewModel, +) + + +class CanvasContextMenuView: + """Qt-facing adapter around canvas context-menu contracts.""" + + def __init__(self, host, view_model: CanvasContextMenuViewModel): + self.host = host + self.view_model = view_model + + def show_img_gradient_context_menu(self, x, y): + target = self.view_model.image_gradient_menu_target( + scale_bar_highlighted=self._scale_bar_highlighted(), + timestamp_highlighted=self._timestamp_highlighted(), + ) + if target == self.view_model.scale_bar_target: + self.host.scaleBar.showContextMenu(x, y) + return + if target == self.view_model.timestamp_target: + self.host.timestamp.showContextMenu(x, y) + return + self.host.imgGrad.gradient.menu.popup(QPoint(int(x), int(y))) + + def show_right_image_context_menu(self, event): + try: + screen_pos = event.screenPos().toPoint() + except AttributeError: + screen_pos = event.screenPos() + self.host.imgGradRight.gradient.menu.popup(screen_pos) + + def clicked_deleted_roi(self, event, left_click, right_click): + pos_data = self.host.data[self.host.pos_i] + x, y = event.pos().x(), event.pos().y() + + del_rois = ( + pos_data.allData_li[pos_data.frame_i]['delROIs_info']['rois'] + .copy() + ) + for roi in del_rois: + roi_mask = self.host.getDelRoiMask(roi) + if self.host.isSegm3D: + clicked_on_roi = roi_mask[ + self.host.z_lab(), int(y), int(x) + ] + else: + clicked_on_roi = roi_mask[int(y), int(x)] + decision = self.view_model.deleted_roi_click_decision( + clicked_on_roi=clicked_on_roi, + left_click=left_click, + right_click=right_click, + ) + if decision.show_context_menu: + self.host.roi_to_del = roi + self._show_deleted_roi_context_menu(event) + return True + if decision.drag_roi: + event.ignore() + return True + return False + + def hovered_segments_polyline_roi(self): + pos_data = self.host.data[self.host.pos_i] + del_rois_info = ( + pos_data.allData_li[pos_data.frame_i]['delROIs_info'] + ) + segments = [] + for roi in del_rois_info['rois']: + if not isinstance(roi, pg.PolyLineROI): + continue + for segment in roi.segments: + if segment.currentPen == segment.hoverPen: + segment.roi = roi + segments.append(segment) + return segments + + def hovered_handles_polyline_roi(self): + pos_data = self.host.data[self.host.pos_i] + del_rois_info = ( + pos_data.allData_li[pos_data.frame_i]['delROIs_info'] + ) + handles = [] + for roi in del_rois_info['rois']: + if not isinstance(roi, pg.PolyLineROI): + continue + for handle in roi.getHandles(): + if handle.currentPen == handle.hoverPen: + handle.roi = roi + handles.append(handle) + return handles + + def _show_deleted_roi_context_menu(self, event): + self.host.roiContextMenu = QMenu(self.host) + separator = QAction(self.host) + separator.setSeparator(True) + self.host.roiContextMenu.addAction(separator) + action = QAction('Remove ROI') + action.triggered.connect(self.host.removeDelROI) + self.host.roiContextMenu.addAction(action) + try: + screen_pos = event.screenPos().toPoint() + except AttributeError: + screen_pos = event.screenPos() + self.host.roiContextMenu.exec_(screen_pos) + + def _scale_bar_highlighted(self): + return ( + hasattr(self.host, 'scaleBar') + and self.host.scaleBar.isHighlighted() + ) + + def _timestamp_highlighted(self): + return ( + hasattr(self.host, 'timestamp') + and self.host.timestamp.isHighlighted() + ) diff --git a/cellacdc/views/canvas_drawing_view.py b/cellacdc/views/canvas_drawing_view.py new file mode 100644 index 000000000..7223ec7ce --- /dev/null +++ b/cellacdc/views/canvas_drawing_view.py @@ -0,0 +1,683 @@ +"""Qt view adapter for canvas drawing interactions.""" + +from __future__ import annotations + +import numpy as np +import skimage.segmentation + +from qtpy.QtCore import Qt +from qtpy.QtGui import QGuiApplication +from qtpy.QtWidgets import QAction, QMessageBox + +from cellacdc import apps, exception_handler, html_utils, widgets +from cellacdc.viewmodels.canvas_drawing_viewmodel import ( + CanvasDrawingViewModel, +) + + +class CanvasDrawingView: + """Qt-facing adapter for canvas drawing workflows.""" + + LEGACY_METHODS = ( + 'gui_addCreatedAxesItems', + 'gui_mouseDragEventImg1', + 'gui_mouseDragEventImg2', + 'gui_mouseReleaseEventImg1', + ) + + def __init__(self, host, view_model: CanvasDrawingViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def gui_addCreatedAxesItems(self): + self.ax1.addItem(self.ax1_contoursImageItem) + self.ax1.addItem(self.ax1_lostObjImageItem) + self.ax1.addItem(self.ax1_lostTrackedObjImageItem) + self.ax1.addItem(self.ax1_oldMothBudLinesItem) + self.ax1.addItem(self.ax1_newMothBudLinesItem) + self.ax1.addItem(self.ax1_lostObjScatterItem) + self.ax1.addItem(self.ax1_lostTrackedScatterItem) + self.ax1.addItem(self.ccaFailedScatterItem) + self.ax1.addItem(self.yellowContourScatterItem) + + self.ax2.addItem(self.ax2_contoursImageItem) + self.ax2.addItem(self.ax2_lostObjImageItem) + self.ax2.addItem(self.ax2_lostTrackedObjImageItem) + self.ax2.addItem(self.ax2_oldMothBudLinesItem) + self.ax2.addItem(self.ax2_newMothBudLinesItem) + self.ax2.addItem(self.ax2_lostObjScatterItem) + + self.textAnnot[0].addToPlotItem(self.ax1) + self.textAnnot[1].addToPlotItem(self.ax2) + + self.ax1.addItem(self.exportMaskImageItem) + self.ax1.exportMaskImageItem = self.exportMaskImageItem + + @exception_handler + def gui_mouseDragEventImg1(self, event): + x, y = event.pos().x(), event.pos().y() + + if hasattr(self, 'scaleBar'): + if self.scaleBarDialog is not None: + self.scaleBarDialog.locCombobox.setCurrentText('Custom') + if self.scaleBar.isHighlighted() and self.scaleBar.clicked: + self.scaleBar.setLocationProperty('custom') + self.scaleBar.move(x, y) + return + + if hasattr(self, 'timestamp'): + if self.timestampDialog is not None: + self.timestampDialog.locCombobox.setCurrentText('Custom') + if self.timestamp.isHighlighted() and self.timestamp.clicked: + self.timestamp.setLocationProperty('custom') + self.timestamp.move(x, y) + return + + mode = str(self.modeComboBox.currentText()) + posData = self.data[self.pos_i] + Y, X = self.get_2Dlab(posData.lab).shape + xdata, ydata = int(x), int(y) + in_bounds = self.view_model.is_in_bounds(xdata, ydata, X, Y) + if not self.view_model.should_process_canvas_event( + mode=mode, + in_bounds=in_bounds, + ): + return + + if self._dispatch_tool_event_if_enabled(event, phase='drag', image='img1'): + return + + if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): + self.curvature_tools_view.drawAutoContour(y, x) + + # Brush dragging mouse --> keep brushing + elif self.isMouseDragImg1 and self.brushButton.isChecked(): + lab_2D = self.get_2Dlab(posData.lab) + + # t1 = time.perf_counter() + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + rrPoly, ccPoly = self.curvature_tools_view.getPolygonBrush( + (y, x), Y, X + ) + + # t2 = time.perf_counter() + + diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) + + # Build brush mask + mask = self.view_model.calculate_brush_mask( + lab_2D.shape, ymin, xmin, ymax, xmax, diskMask, rrPoly, ccPoly + ) + + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + + # t3 = time.perf_counter() + if not self.isPowerBrush() and not ctrl: + mask[lab_2D!=0] = False + self.setHoverToolSymbolColor( + xdata, ydata, self.ax2_BrushCirclePen, + (self.ax2_BrushCircle, self.ax1_BrushCircle), + self.brushButton, brush=self.ax2_BrushCircleBrush + ) + + # t4 = time.perf_counter() + + # Apply brush mask + self.applyBrushMask(mask, posData.brushID) + + self.setImageImg2(updateLookuptable=False) + + # t5 = time.perf_counter() + + lab2D = self.get_2Dlab(posData.lab) + brushMask = np.logical_and( + lab2D[diskSlice] == posData.brushID, diskMask + ) + self.setTempImg1Brush( + False, brushMask, posData.brushID, + toLocalSlice=diskSlice + ) + + # t6 = time.perf_counter() + + # printl( + # 'Brush exec times =\n' + # f' * {(t1-t0)*1000 = :.4f} ms\n' + # f' * {(t2-t1)*1000 = :.4f} ms\n' + # f' * {(t3-t2)*1000 = :.4f} ms\n' + # f' * {(t4-t3)*1000 = :.4f} ms\n' + # f' * {(t5-t4)*1000 = :.4f} ms\n' + # f' * {(t6-t5)*1000 = :.4f} ms\n' + # f' * {(t6-t0)*1000 = :.4f} ms' + # ) + + # Eraser dragging mouse --> keep erasing + elif self.isMouseDragImg1 and self.eraserButton.isChecked(): + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + rrPoly, ccPoly = self.curvature_tools_view.getPolygonBrush( + (y, x), Y, X + ) + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + + diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) + + # Build eraser mask + mask = self.view_model.calculate_brush_mask( + lab_2D.shape, ymin, xmin, ymax, xmax, diskMask, rrPoly, ccPoly + ) + + if self.eraseOnlyOneID: + mask[lab_2D!=self.erasedID] = False + self.setHoverToolSymbolColor( + xdata, ydata, self.eraserCirclePen, + (self.ax2_EraserCircle, self.ax1_EraserCircle), + self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], + ID=self.erasedID + ) + + self.erasedIDs.update(lab_2D[mask]) + self.applyEraserMask(mask) + + self.setImageImg2() + + for erasedID in self.erasedIDs: + if erasedID == 0: + continue + self.erasedLab[lab_2D==erasedID] = erasedID + self.erasedLab[mask] = 0 + + eraserMask = mask[diskSlice] + self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice) + self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice, ax=1) + + # Move label dragging mouse --> keep moving + elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + self.label_transform_tools_view.move_label(x, y) + + # Wand dragging mouse --> keep doing the magic + elif self.isMouseDragImg1 and self.wandToolButton.isChecked(): + tol = self.getMagicWandFloodTolerance() + if self.isSegm3D: + z_slice = self.zSliceScrollBar.sliderPosition() + seed = (z_slice, ydata, xdata) + else: + seed = (ydata, xdata) + + flood_mask = skimage.segmentation.flood( + self.flood_img, seed, tolerance=tol + ) + drawUnderMask = np.logical_or( + posData.lab==0, posData.lab==posData.brushID + ) + flood_mask = np.logical_and(flood_mask, drawUnderMask) + + self.flood_mask[flood_mask] = True + + if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): + self.flood_mask = self.view_model.binary_fill_holes( + self.flood_mask + ) + + if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): + self.flood_mask = self.view_model.convex_hull_mask( + self.flood_mask + ) + + self.setTempBrushMaskFromWand(self.flood_mask) + + # Label ROI dragging mouse --> draw ROI + elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): + if self.labelRoiIsRectRadioButton.isChecked(): + x0, y0 = self.labelRoiItem.pos() + w, h = (xdata-x0), (ydata-y0) + self.labelRoiItem.setSize((w, h)) + elif self.labelRoiIsFreeHandRadioButton.isChecked(): + self.freeRoiItem.addPoint(xdata, ydata) + + # Draw freehand clear region --> draw region + elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): + self.freeRoiItem.addPoint(xdata, ydata) + + # Label ROI dragging mouse --> draw ROI + elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): + x0, y0 = self.zoomRectItem.pos() + w, h = (xdata-x0), (ydata-y0) + self.zoomRectItem.setSize((w, h)) + + @exception_handler + def gui_mouseDragEventImg2(self, event): + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + + Y, X = self.get_2Dlab(posData.lab).shape + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + in_bounds = self.view_model.is_in_bounds(xdata, ydata, X, Y) + if not self.view_model.should_process_canvas_event( + mode=mode, + in_bounds=in_bounds, + ): + return + + if self._dispatch_tool_event_if_enabled(event, phase='drag', image='img2'): + return + + # Eraser dragging mouse --> keep erasing + if self.isMouseDragImg2 and self.eraserButton.isChecked(): + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + Y, X = lab_2D.shape + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + brushSize = self.brushSizeSpinbox.value() + rrPoly, ccPoly = self.curvature_tools_view.getPolygonBrush( + (y, x), Y, X + ) + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + + # Build eraser mask + mask = self.view_model.calculate_brush_mask( + lab_2D.shape, ymin, xmin, ymax, xmax, diskMask, rrPoly, ccPoly + ) + + if self.eraseOnlyOneID: + mask[lab_2D!=self.erasedID] = False + self.setHoverToolSymbolColor( + xdata, ydata, self.eraserCirclePen, + (self.ax2_EraserCircle, self.ax1_EraserCircle), + self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], + ID=self.erasedID + ) + + self.erasedIDs.update(lab_2D[mask]) + + self.applyEraserMask(mask) + self.setImageImg2(updateLookuptable=False) + + # Brush paint dragging mouse --> keep painting + if self.isMouseDragImg2 and self.brushButton.isChecked(): + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + Y, X = lab_2D.shape + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + rrPoly, ccPoly = self.curvature_tools_view.getPolygonBrush( + (y, x), Y, X + ) + + # Build brush mask + mask = self.view_model.calculate_brush_mask( + lab_2D.shape, ymin, xmin, ymax, xmax, diskMask, rrPoly, ccPoly + ) + + # If user double-pressed 'b' then draw over the labels + color = self.brushButton.palette().button().color().name() + if color != self.doublePressKeyButtonColor: + mask[lab_2D!=0] = False + self.setHoverToolSymbolColor( + xdata, ydata, self.ax2_BrushCirclePen, + (self.ax2_BrushCircle, self.ax1_BrushCircle), + self.eraserButton, brush=self.ax2_BrushCircleBrush + ) + + # Apply brush mask + self.applyBrushMask(mask, self.ax2BrushID) + + self.setImageImg2() + + # Move label dragging mouse --> keep moving + elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + self.label_transform_tools_view.move_label(x, y) + + @exception_handler + def gui_mouseReleaseEventImg1(self, event): + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + alt = modifiers == Qt.AltModifier + right_click = event.button() == Qt.MouseButton.RightButton and not alt + + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + if self.view_model.is_viewer_mode(mode): + return + + if self._dispatch_tool_event_if_enabled(event, phase='release', image='img1'): + return + + Y, X = self.get_2Dlab(posData.lab).shape + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + in_bounds = self.view_model.is_in_bounds(xdata, ydata, X, Y) + if not self.view_model.should_process_canvas_event( + mode=mode, + in_bounds=in_bounds, + ): + if self.view_model.should_clear_after_out_of_bounds(image='img1'): + self.isMouseDragImg2 = False + self.updateAllImages() + return + + if hasattr(self, 'scaleBar'): + if self.scaleBar.isHighlighted() and self.scaleBar.clicked: + self.scaleBar.clicked = False + return + + if hasattr(self, 'timestamp'): + if self.timestamp.isHighlighted() and self.timestamp.clicked: + self.timestamp.clicked = False + return + + sendRightClickImg2 = ( + self.canvas_tool_view.should_forward_img1_release_to_img2( + right_click=right_click, + mode=mode, + is_snapshot=self.isSnapshot, + ) + ) + if sendRightClickImg2: + # Allow right-click actions on both images + self.gui_mouseReleaseEventImg2(event) + + # Right-click curvature tool mouse release + if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): + self.isRightClickDragImg1 = False + try: + self.curvature_tools_view.curvToolSplineToObj( + isRightClick=True + ) + self.update_rp() + if self.autoIDcheckbox.isChecked(): + self.trackManuallyAddedObject(posData.brushID, True) + if self.isSnapshot: + self.fixCcaDfAfterEdit('Add new ID with curvature tool') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Add new ID with curvature tool') + self.curvature_tools_view.clearCurvItems() + self.curvature_tools_view.curvTool_cb(True) + except ValueError: + self.curvature_tools_view.clearCurvItems() + self.curvature_tools_view.curvTool_cb(True) + pass + + # Eraser mouse release --> update IDs and contours + elif self.isMouseDragImg1 and self.eraserButton.isChecked(): + self.isMouseDragImg1 = False + + self.clearTempBrushImage() + + # Update data (rp, etc) + self.update_rp() + + doUpdateImages = self.checkWarnDeletedIDwithEraser() + + if doUpdateImages: + self.updateAllImages() + + # Brush button mouse release + elif self.isMouseDragImg1 and self.brushButton.isChecked(): + self.isMouseDragImg1 = False + + self.clearTempBrushImage() + + self.brushReleased() + + # Wand tool release, add new object + elif self.isMouseDragImg1 and self.wandToolButton.isChecked(): + self.isMouseDragImg1 = False + + self.clearTempBrushImage() + + posData = self.data[self.pos_i] + posData.lab[self.flood_mask] = posData.brushID + + # Update data (rp, etc) + self.update_rp() + + # Repeat tracking + self.trackManuallyAddedObject(posData.brushID, self.isNewID) + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Add new ID with magic-wand') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Add new ID with magic-wand') + + # Label ROI mouse release --> label the ROI with labelRoiWorker + elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): + self.labelRoiRunning = True + self.app.setOverrideCursor(Qt.WaitCursor) + self.isMouseDragImg1 = False + + if self.labelRoiIsFreeHandRadioButton.isChecked(): + self.freeRoiItem.closeCurve() + + proceed = self.labelRoiCheckStartStopFrame() + if not proceed: + self.labelRoiCancelled() + return + + roiImg, self.labelRoiSlice = self.getLabelRoiImage() + + if roiImg.size == 0: + self.labelRoiCancelled() + return + + if self.labelRoiModel is None: + cancel = self.initLabelRoiModel() + if cancel: + self.labelRoiCancelled() + return + + # Restore state of button because it was maybe unchecked by + # using other tools that are allowed --> see "elif" case in + # labelRoi_cb + self.labelRoiButton.blockSignals(True) + self.labelRoiButton.setChecked(True) + self.labelRoiToolbar.setVisible(True) + self.labelRoiButton.blockSignals(False) + + roiSecondChannel = None + if self.secondChannelName is not None: + secondChannelData = self.getSecondChannelData() + roiSecondChannel = secondChannelData[self.labelRoiSlice] + + isTimelapse = self.labelRoiTrangeCheckbox.isChecked() + if isTimelapse: + start_n = self.labelRoiStartFrameNoSpinbox.value() + stop_n = self.labelRoiStopFrameNoSpinbox.value() + self.progressWin = apps.QDialogWorkerProgress( + title='ROI segmentation', parent=self.host, + pbarDesc=f'Segmenting frames n. {start_n} to {stop_n}...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(stop_n-start_n) + + + self.app.restoreOverrideCursor() + labelRoiWorker = self.labelRoiActiveWorkers[-1] + labelRoiWorker.start( + roiImg, posData, + roiSecondChannel=roiSecondChannel, + isTimelapse=isTimelapse + ) + self.app.setOverrideCursor(Qt.WaitCursor) + self.logger.info( + f'Magic labeller started on image ROI = {self.labelRoiSlice}...' + ) + self.titleLabel.setText('Magic labeller is doing its magic...') + self.setDisabled(True) + + # Move label mouse released, update move + elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): + self.isMovingLabel = False + + # Update data (rp, etc) + self.update_rp() + + # Repeat tracking + self.tracking(enforce=True, assign_unique_new_IDs=False) + + if not self.moveLabelToolButton.findChild(QAction).isChecked(): + self.moveLabelToolButton.setChecked(False) + else: + self.updateAllImages() + + # Assign mother to bud + elif self.assignBudMothButton.isChecked() and self.clickedOnBud: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == self.get_2Dlab(posData.lab)[self.yClickBud, self.xClickBud]: + return + + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + mothID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as mother cell', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + mothID_prompt.exec_() + if mothID_prompt.cancel: + return + else: + ID = mothID_prompt.EntryID + obj_idx = posData.IDs.index(ID) + y, x = posData.rp[obj_idx].centroid + xdata, ydata = int(x), int(y) + + if self.isSnapshot: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + relationship = posData.cca_df.at[ID, 'relationship'] + ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] + is_history_known = posData.cca_df.at[ID, 'is_history_known'] + # We allow assiging a cell in G1 as mother only on first frame + # OR if the history is unknown + if relationship == 'bud' and posData.frame_i > 0 and is_history_known: + self.assignBudMothButton.setChecked(False) + txt = html_utils.paragraph( + f'You clicked on ID {ID} which is a BUD.

' + 'To assign a bud start by clicking on the bud ' + 'and release on a cell in G1' + ) + msg = widgets.myMessageBox() + msg.critical( + self.host, 'Released on a bud', txt + ) + self.assignBudMothButton.setChecked(True) + return + + elif posData.frame_i == 0: + # Check that clicked bud actually is smaller that mother + # otherwise warn the user that he might have clicked first + # on a mother + budID = self.get_2Dlab(posData.lab)[self.yClickBud, self.xClickBud] + new_mothID = self.get_2Dlab(posData.lab)[ydata, xdata] + bud_obj_idx = posData.IDs.index(budID) + new_moth_obj_idx = posData.IDs.index(new_mothID) + rp_budID = posData.rp[bud_obj_idx] + rp_new_mothID = posData.rp[new_moth_obj_idx] + if rp_budID.area >= rp_new_mothID.area: + self.assignBudMothButton.setChecked(False) + msg = widgets.myMessageBox() + txt = ( + f'You clicked FIRST on ID {budID} and then on {new_mothID}.
' + f'For me this means that you want ID {budID} to be the ' + f'BUD of ID {new_mothID}.
' + f'However ID {budID} is bigger than {new_mothID} ' + f'so maybe you should have clicked FIRST on {new_mothID}?

' + 'What do you want me to do?' + ) + txt = html_utils.paragraph(txt) + swapButton, keepButton = msg.warning( + self.host, 'Which one is bud?', txt, + buttonsTexts=( + f'Assign ID {new_mothID} as the bud of ID {budID}', + f'Keep ID {budID} as the bud of ID {new_mothID}' + ) + ) + if msg.clickedButton == swapButton: + (xdata, ydata, + self.xClickBud, self.yClickBud) = ( + self.xClickBud, self.yClickBud, + xdata, ydata + ) + self.assignBudMothButton.setChecked(True) + + elif is_history_known and not self.clickedOnHistoryKnown: + self.assignBudMothButton.setChecked(False) + budID = self.get_2Dlab(posData.lab)[ydata, xdata] + # Allow assigning an unknown cell ONLY to another unknown cell + txt = ( + f'You started by clicking on ID {budID} which has ' + 'UNKNOWN history, but you then clicked/released on ' + f'ID {ID} which has KNOWN history.\n\n' + 'Only two cells with UNKNOWN history can be assigned as ' + 'relative of each other.' + ) + msg = QMessageBox() + msg.critical( + self.host, + 'Released on a cell with KNOWN history', + txt, + msg.Ok, + ) + self.assignBudMothButton.setChecked(True) + return + + self.clickedOnHistoryKnown = is_history_known + self.xClickMoth, self.yClickMoth = xdata, ydata + + if ccs != 'G1' and posData.frame_i > 0: + self.assignBudMothButton.setChecked(False) + self.onMotherNotInG1(ID) + self.assignBudMothButton.setChecked(True) + else: + self.annotateBudToDifferentMother() + + if not self.assignBudMothButton.findChild(QAction).isChecked(): + self.assignBudMothButton.setChecked(False) + + self.clickedOnBud = False + self.BudMothTempLine.setData([], []) + + # Draw clear region mouse release + elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): + self.isMouseDragImg1 = False + self.freeRoiItem.closeCurve() + self.draw_clear_region_view.clear_objects_in_freehand_region() + + # Zoom rect mouse release + elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): + self.isMouseDragImg1 = False + self.zoomRectDone() diff --git a/cellacdc/views/canvas_events_view.py b/cellacdc/views/canvas_events_view.py new file mode 100644 index 000000000..4813fa9f9 --- /dev/null +++ b/cellacdc/views/canvas_events_view.py @@ -0,0 +1,992 @@ +"""Qt view adapter for canvas mouse events.""" + +from __future__ import annotations + +import numpy as np +import pyqtgraph as pg +import skimage.segmentation + +from qtpy.QtCore import Qt, QTimer +from qtpy.QtGui import QGuiApplication, QMouseEvent +from qtpy.QtWidgets import QAction, QMessageBox + +from cellacdc import apps, exception_handler +from cellacdc.viewmodels.canvas_events_viewmodel import CanvasEventsViewModel + + +class CanvasEventsView: + """Qt-facing adapter for canvas mouse event routing.""" + + LEGACY_METHODS = ( + 'gui_mousePressEventImg1', + ) + + def __init__(self, host, view_model: CanvasEventsViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + @exception_handler + def gui_mousePressEventImg1(self, event: QMouseEvent): + if self._dispatch_tool_event_if_enabled(event, phase='press', image='img1'): + return + self.typingEditID = False + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + alt = modifiers == Qt.AltModifier + isMod = alt + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + isCcaMode = mode == 'Cell cycle analysis' + isCustomAnnotMode = mode == 'Custom annotations' + left_click = event.button() == Qt.MouseButton.LeftButton and not isMod + middle_click = self.isMiddleClick(event, modifiers) + right_click = event.button() == Qt.MouseButton.RightButton + isPanImageClick = self.isPanImageClick(event, modifiers) + brushON = self.brushButton.isChecked() + curvToolON = self.curvToolButton.isChecked() + histON = self.setIsHistoryKnownButton.isChecked() + eraserON = self.eraserButton.isChecked() + rulerON = self.rulerButton.isChecked() + wandON = self.wandToolButton.isChecked() and not isPanImageClick + polyLineRoiON = self.addDelPolyLineRoiButton.isChecked() + labelRoiON = self.labelRoiButton.isChecked() + keepObjON = self.keepIDsButton.isChecked() + whitelistIDsON = self.whitelistIDsButton.isChecked() + separateON = self.separateBudButton.isChecked() + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + manualBackgroundON = self.manualBackgroundButton.isChecked() + magicPromptsON = self.magicPromptsToolButton.isChecked() + pointsLayerON = self.togglePointsLayerAction.isChecked() + copyContourON = ( + self.copyLostObjButton.isChecked() + and self.ax1_lostObjScatterItem.hoverLostID>0 + ) + findNextMotherButtonON = self.findNextMotherButton.isChecked() + unknownLineageButtonON = self.unknownLineageButton.isChecked() + drawClearRegionON = self.drawClearRegionButton.isChecked() + zoomRectON = self.zoomRectButton.isChecked() + + # Check if right-click on segment of polyline roi to add segment + segments = self.canvas_context_menu_view.hovered_segments_polyline_roi() + if len(segments) == 1 and right_click: + seg = segments[0] + seg.roi.segmentClicked(seg, event) + return + + # Check if right-click on handle of polyline roi to remove it + handles = self.canvas_context_menu_view.hovered_handles_polyline_roi() + if len(handles) == 1 and right_click: + handle = handles[0] + handle.roi.removeHandle(handle) + return + + # Check if click on ROI + isClickOnDelRoi = self.canvas_context_menu_view.clicked_deleted_roi( + event, + left_click, + right_click, + ) + if isClickOnDelRoi: + return + + dragImgLeft = ( + left_click and not brushON and not histON + and not curvToolON and not eraserON and not rulerON + and not wandON and not polyLineRoiON and not labelRoiON + and not middle_click and not keepObjON and not separateON + and not manualBackgroundON and not drawClearRegionON + and addPointsByClickingButton is None and not whitelistIDsON + and not zoomRectON + ) + if isPanImageClick: + dragImgLeft = True + + is_right_click_custom_ON = any([ + b.isChecked() for b in self.customAnnotDict.keys() + ]) + + canAnnotateDivision = ( + not self.assignBudMothButton.isChecked() + and not self.setIsHistoryKnownButton.isChecked() + and not self.curvToolButton.isChecked() + and not is_right_click_custom_ON + and not labelRoiON + and not separateON + ) + + # In timelapse mode division can be annotated if isCcaMode and right-click + # while in snapshot mode with Ctrl+right-click + isAnnotateDivision = ( + (right_click and isCcaMode and canAnnotateDivision) + or (right_click and ctrl and self.isSnapshot) + ) + + isCustomAnnot = ( + (right_click or dragImgLeft) + and (isCustomAnnotMode or self.isSnapshot) + and self.customAnnotButton is not None + ) + + is_right_click_action_ON = any([ + b.isChecked() for b in self.checkableQButtonsGroup.buttons() + ]) + + isOnlyRightClick = ( + right_click and canAnnotateDivision and not isAnnotateDivision + and not isMod and not is_right_click_action_ON + and not is_right_click_custom_ON and not copyContourON + and not findNextMotherButtonON and not unknownLineageButtonON + and not middle_click + ) + + if isOnlyRightClick: + # Start timer or check if it is a double-right-click + if self.countRightClicks == 0: + self.isDoubleRightClick = False + self.countRightClicks = 1 + self.doubleRightClickTimeElapsed = False + screenPos = event.screenPos() + self._img1_click_xy = (screenPos.x(), screenPos.y()) + QTimer.singleShot(400, self.doubleRightClickTimerCallBack) + return + elif ( + self.countRightClicks == 1 + and not self.doubleRightClickTimeElapsed + ): + self.isDoubleRightClick = True + self.countRightClicks = 0 + self.editIDbutton.setChecked(True) + + # Left click actions + canCurv = ( + curvToolON and not self.assignBudMothButton.isChecked() + and not brushON and not dragImgLeft and not eraserON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not magicPromptsON and not zoomRectON + ) + canBrush = ( + brushON and not curvToolON and not rulerON + and not dragImgLeft and not eraserON and not wandON + and not labelRoiON and not manualBackgroundON + and addPointsByClickingButton is None and not drawClearRegionON + and not magicPromptsON and not zoomRectON + ) + canErase = ( + eraserON and not curvToolON and not rulerON + and not dragImgLeft and not brushON and not wandON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not magicPromptsON and not zoomRectON + ) + canRuler = ( + rulerON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not wandON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not magicPromptsON and not zoomRectON + ) + canWand = ( + wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not magicPromptsON and not zoomRectON + ) + canPolyLine = ( + polyLineRoiON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not labelRoiON and not manualBackgroundON + and addPointsByClickingButton is None + and not drawClearRegionON and not magicPromptsON + and not zoomRectON + ) + canLabelRoi = ( + labelRoiON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not keepObjON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not whitelistIDsON and not magicPromptsON + and not zoomRectON + ) + canKeep = ( + keepObjON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not whitelistIDsON and not magicPromptsON + and not zoomRectON + ) + canWhitelistIDs = ( + whitelistIDsON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not keepObjON and not magicPromptsON + and not zoomRectON + ) + canAddPoint = ( + (pointsLayerON or magicPromptsON) + and addPointsByClickingButton is not None and not wandON + and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON and not keepObjON + and not manualBackgroundON and not drawClearRegionON + and not zoomRectON + ) + canAddManualBackgroundObj = ( + manualBackgroundON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not keepObjON and not drawClearRegionON + and not magicPromptsON and not whitelistIDsON + and not zoomRectON + ) + canDrawClearRegion = ( + drawClearRegionON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not labelRoiON and not manualBackgroundON + and addPointsByClickingButton is None + and not polyLineRoiON and not magicPromptsON + and not whitelistIDsON and not zoomRectON + ) + canZoomRect = ( + zoomRectON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not wandON and not whitelistIDsON and not magicPromptsON + ) + + # Enable dragging of the image window or the scalebar + if dragImgLeft and not isCustomAnnot: + x, y = event.pos().x(), event.pos().y() + if hasattr(self, 'scaleBar'): + if self.scaleBar.isHighlighted(): + self.scaleBar.mousePressed(x, y) + return + if hasattr(self, 'timestamp'): + if self.timestamp.isHighlighted(): + self.timestamp.mousePressed(x, y) + return + pg.ImageItem.mousePressEvent(self.img1, event) + event.ignore() + return + + canPressInViewer = self.canvas_tool_view.viewer_mode_allows_press( + mode, + can_add_point=canAddPoint, + can_ruler=canRuler, + ) + if not canPressInViewer: + self.mode_controls_view.startBlinkingModeCB() + event.ignore() + return + + # Allow right-click or middle-click actions on both images + eventOnImg2 = self.canvas_tool_view.should_forward_img1_press_to_img2( + right_click=right_click, + middle_click=middle_click, + can_add_point=canAddPoint, + mode=mode, + is_snapshot=self.isSnapshot, + is_annotate_division=isAnnotateDivision, + manual_background_on=manualBackgroundON, + ) + if eventOnImg2: + event.isImg1Sender = True + self.gui_mousePressEventImg2(event) + + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + Y, X = self.get_2Dlab(posData.lab).shape + if self.view_model.geometry.is_in_bounds(xdata, ydata, X, Y): + ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + else: + return + + # Paint new IDs with brush and left click on the left image + if left_click and canBrush: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + lab_2D = self.get_2Dlab(posData.lab) + Y, X = lab_2D.shape + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False, storeOnlyZoom=True) + + ID = self.getHoverID(xdata, ydata) + + if ID > 0: + posData.brushID = ID + self.isNewID = False + else: + # Update brush ID. Take care of disappearing cells to remember + # to not use their IDs anymore in the future + self.isNewID = True + self.setBrushID() + self.updateLookuptable(lenNewLut=posData.brushID+1) + + self.brushColor = self.lut[posData.brushID]/255 + + self.yPressAx2, self.xPressAx2 = y, x + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) + + self.isMouseDragImg1 = True + + # Draw new objects + localLab = lab_2D[diskSlice] + mask = diskMask.copy() + if not self.isPowerBrush() and not ctrl: + mask[localLab!=0] = False + + self.applyBrushMask(mask, posData.brushID, toLocalSlice=diskSlice) + + self.setImageImg2(updateLookuptable=False) + + how = self.drawIDsContComboBox.currentText() + lab2D = self.get_2Dlab(posData.lab) + self.globalBrushMask = np.zeros(lab2D.shape, dtype=bool) + brushMask = localLab == posData.brushID + brushMask = np.logical_and(brushMask, diskMask) + self.setTempImg1Brush( + True, brushMask, posData.brushID, toLocalSlice=diskSlice + ) + + self.lastHoverID = -1 + + elif left_click and canErase: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + lab_2D = self.get_2Dlab(posData.lab) + Y, X = lab_2D.shape + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False, storeOnlyZoom=True) + + self.yPressAx2, self.xPressAx2 = y, x + # Keep a list of erased IDs got erased + self.erasedIDs = set() + + if self.xyOnCtrlPressedFirstTime is not None: + self.erasedID = self.getHoverID(*self.xyOnCtrlPressedFirstTime) + else: + self.erasedID = self.getHoverID(xdata, ydata) + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + + # Build eraser mask + mask = np.zeros(lab_2D.shape, bool) + mask[ymin:ymax, xmin:xmax][diskMask] = True + + + # If user double-pressed 'b' then erase over ALL labels + color = self.eraserButton.palette().button().color().name() + eraseOnlyOneID = ( + color != self.doublePressKeyButtonColor + and self.erasedID != 0 + ) + + self.eraseOnlyOneID = eraseOnlyOneID + + if eraseOnlyOneID: + mask[lab_2D!=self.erasedID] = False + + self.setTempImg1Eraser(mask, init=True) + self.applyEraserMask(mask) + + self.erasedIDs.update(lab_2D[mask]) + + for erasedID in self.erasedIDs: + if erasedID == 0: + continue + self.erasedLab[lab_2D==erasedID] = erasedID + + self.isMouseDragImg1 = True + + elif canAddPoint: + action = addPointsByClickingButton.action + self.storeUndoAddPoint(action) + x, y = event.pos().x(), event.pos().y() + hoveredPoints = action.scatterItem.pointsAt(event.pos()) + if len(hoveredPoints) > 0: + removed_ids = self.removeClickedPoints(action, hoveredPoints) + if not magicPromptsON: + removed_id = min(removed_ids) + addPointsByClickingButton.pointIdSpinbox.setValue(removed_id) + addPointsByClickingButton.pointIdSpinbox.removedId = ( + removed_id + ) + else: + self.restorePrevPointIdRightClick(addPointsByClickingButton) + self.drawPointsLayers(computePointsLayers=False) + else: + point_id = self.getAddedPointId( + magicPromptsON, addPointsByClickingButton, + right_click, left_click, middle_click + ) + if point_id is None: + return + + self.addClickedPoint(action, x, y, point_id) + self.drawPointsLayers(computePointsLayers=False) + + point_id = self.getClickedPointNewId( + action, point_id, + addPointsByClickingButton.pointIdSpinbox, + isMagicPrompts=magicPromptsON + ) + addPointsByClickingButton.pointIdSpinbox.setValue( + point_id, setLinkedWidget=False + ) + + elif left_click and canDrawClearRegion: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + self.freeRoiItem.addPoint(xdata, ydata) + + self.isMouseDragImg1 = True + + elif left_click and canRuler or canPolyLine: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + closePolyLine = ( + len(self.startPointPolyLineItem.pointsAt(event.pos())) > 0 + ) + if not self.tempSegmentON or canPolyLine: + # Keep adding anchor points for polyline + self.ax1_rulerAnchorsItem.setData([xdata], [ydata]) + self.tempSegmentON = True + else: + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + self.tempSegmentON = False + xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() + x0, y0 = xxRA[0], yyRA[0] + if ctrl: + x1, y1 = self.view_model.snap_xy_to_closest_angle( + x0, y0, xdata, ydata + ) + else: + x1, y1 = xdata, ydata + lengthText = self.status_hover_view.ruler_length_text() + self.ax1_rulerPlotItem.setData( + [x0, x1], [y0, y1], lengthText=lengthText + ) + self.ax1_rulerAnchorsItem.setData([x0, x1], [y0, y1]) + + xxPolyLine = self.startPointPolyLineItem.getData()[0] + if canPolyLine and len(xxPolyLine) == 0: + # Create and add roi item + self.createDelPolyLineRoi() + # Add start point of polyline roi + self.startPointPolyLineItem.setData([xdata], [ydata]) + self.polyLineRoi.points.append((xdata, ydata)) + elif canPolyLine: + # Add points to polyline roi and eventually close it + if not closePolyLine: + self.polyLineRoi.points.append((xdata, ydata)) + self.addPointsPolyLineRoi(closed=closePolyLine) + if closePolyLine: + # Close polyline ROI + if len(self.polyLineRoi.getLocalHandlePositions()) == 2: + self.polyLineRoi = self.replacePolyLineRoiWithLineRoi( + self.polyLineRoi + ) + self.tempSegmentON = False + self.ax1_rulerAnchorsItem.setData([], []) + self.ax1_rulerPlotItem.setData([], []) + self.startPointPolyLineItem.setData([], []) + self.addRoiToDelRoiInfo(self.polyLineRoi) + # Call roi moving on closing ROI + self.delROImoving(self.polyLineRoi) + self.delROImovingFinished(self.polyLineRoi) + + elif left_click and canKeep: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + keepID_win = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to keep', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + keepID_win.exec_() + if keepID_win.cancel: + return + else: + ID = keepID_win.EntryID + + if ID in self.keptObjectsIDs: + self.keptObjectsIDs.remove(ID) + self.clearHighlightedText() + else: + self.keptObjectsIDs.append(ID) + self.highlightLabelID(ID) + + self.updateTempLayerKeepIDs() + + elif left_click and canWhitelistIDs: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + keepID_win = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to select', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + keepID_win.exec_() + if keepID_win.cancel: + return + else: + ID = keepID_win.EntryID + + posData = self.data[self.pos_i] + + if not posData.whitelist: + wl_init = False + if not hasattr(self, 'tempWhitelistIDs'): + self.tempWhitelistIDs = set() # not updated, only use in this context + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = self.tempWhitelistIDs + else: + wl_init = True + current_whitelist = posData.whitelist.get(posData.frame_i) + + if ID in current_whitelist: + current_whitelist.remove(ID) + self.removeHighlightLabelID(IDs=[ID]) + else: + current_whitelist.add(ID) + self.highlightLabelID(ID) + + self.whitelistIDsToolbar.whitelistLineEdit.setText( + current_whitelist + ) + + if wl_init: + posData.whitelist[posData.frame_i] = current_whitelist + else: + self.tempWhitelistIDs = current_whitelist + + self.whitelistUpdateTempLayer() + + elif right_click and copyContourON: + hoverLostID = self.ax1_lostObjScatterItem.hoverLostID + self.copyLostObjectMask(hoverLostID) + self.update_rp() + self.updateAllImages() + self.store_data() + + elif right_click and canCurv: + # Draw manually assisted auto contour + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + Y, X = self.get_2Dlab(posData.lab).shape + + self.autoCont_x0 = xdata + self.autoCont_y0 = ydata + self.xxA_autoCont, self.yyA_autoCont = [], [] + self.curvAnchors.addPoints([x], [y]) + img = self.getDisplayedImg1() + self.autoContObjMask = np.zeros(img.shape, np.uint8) + self.isRightClickDragImg1 = True + + elif left_click and canCurv: + # Draw manual spline + x, y = event.pos().x(), event.pos().y() + Y, X = self.get_2Dlab(posData.lab).shape + + # Check if user clicked on starting anchor again --> close spline + closeSpline = False + clickedAnchors = self.curvAnchors.pointsAt(event.pos()) + xxA, yyA = self.curvAnchors.getData() + if len(xxA)>0: + if len(xxA) == 1: + self.splineHoverON = True + x0, y0 = xxA[0], yyA[0] + if len(clickedAnchors)>0: + xA_clicked, yA_clicked = clickedAnchors[0].pos() + if x0==xA_clicked and y0==yA_clicked: + x = x0 + y = y0 + closeSpline = True + + # Add anchors + self.curvAnchors.addPoints([x], [y]) + try: + xx, yy = self.curvHoverPlotItem.getData() + self.curvPlotItem.setData(xx, yy) + except Exception as e: + # traceback.print_exc() + pass + + if closeSpline: + self.splineHoverON = False + self.curvature_tools_view.curvToolSplineToObj() + self.update_rp() + if self.autoIDcheckbox.isChecked(): + self.trackManuallyAddedObject(posData.brushID, True) + if self.isSnapshot: + self.fixCcaDfAfterEdit('Add new ID with curvature tool') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Add new ID with curvature tool') + self.curvature_tools_view.clearCurvItems() + self.curvature_tools_view.curvTool_cb(True) + + elif left_click and canWand: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + Y, X = self.get_2Dlab(posData.lab).shape + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + self.isNewID = False + posData.brushID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + if posData.brushID == 0: + self.setBrushID() + self.updateLookuptable( + lenNewLut=posData.brushID+1 + ) + self.isNewID = True + self.brushColor = self.img2.lut[posData.brushID]/255 + + # NOTE: flood is on mousedrag or release + tol = self.getMagicWandFloodTolerance() + self.initFloodMaskImage() + if self.isSegm3D: + z_slice = self.zSliceScrollBar.sliderPosition() + seed = (z_slice, ydata, xdata) + else: + seed = (ydata, xdata) + + flood_mask = skimage.segmentation.flood( + self.flood_img, seed, tolerance=tol + ) + + drawUnderMask = np.logical_or( + posData.lab==0, posData.lab==posData.brushID + ) + self.flood_mask = np.logical_and(flood_mask, drawUnderMask) + + if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): + self.flood_mask = ( + self.view_model.binary_fill_holes( + self.flood_mask + ) + ) + + if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): + self.flood_mask = ( + self.view_model.convex_hull_mask( + self.flood_mask + ) + ) + + self.setTempBrushMaskFromWand(self.flood_mask, init=True) + self.isMouseDragImg1 = True + + elif right_click and self.manualTrackingButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + manualTrackID = self.manualTrackingToolbar.spinboxID.value() + clickedID = self.getClickedID( + xdata, ydata, text=f'that you want to assign to {manualTrackID}' + ) + if clickedID is None: + return + + if clickedID == manualTrackID: + self.manualTrackingToolbar.showWarning( + f'The clicked object already has ID = {manualTrackID}' + ) + return + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + posData = self.data[self.pos_i] + currentIDs = posData.IDs.copy() + if manualTrackID in currentIDs: + tempID = max(currentIDs) + 1 + posData.lab[posData.lab == clickedID] = tempID + posData.lab[posData.lab == manualTrackID] = clickedID + posData.lab[posData.lab == tempID] = manualTrackID + self.manualTrackingToolbar.showWarning( + f'The ID {manualTrackID} already exists --> ' + f'ID {manualTrackID} has been swapped with {clickedID}' + ) + else: + posData.lab[posData.lab == clickedID] = manualTrackID + self.manualTrackingToolbar.showInfo( + f'ID {clickedID} changed to {manualTrackID}.' + ) + + self.update_rp() + self.updateAllImages() + + elif right_click and manualBackgroundON: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + + delID = self.view_model.map_mouse_coordinates_to_label_id((x, y), posData.manualBackgroundLab) + if delID == 0: + return + + self.clearManualBackgroundObject(delID) + textItem = self.manualBackgroundTextItems.pop(delID) + self.ax1.removeItem(textItem) + self.setManualBackgroundImage() + + elif left_click and canAddManualBackgroundObj: + x, y = event.pos().x(), event.pos().y() + + self.addManualBackgroundObject(x, y) + self.setManualBackgroundImage() + self.setManualBackgrounNextID() + + # Label ROI mouse press + elif (left_click or right_click) and canLabelRoi: + if right_click: + # Force model initialization on mouse release + self.labelRoiModel = None + + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + + if self.labelRoiIsRectRadioButton.isChecked(): + self.labelRoiItem.setPos((xdata, ydata)) + elif self.labelRoiIsFreeHandRadioButton.isChecked(): + self.freeRoiItem.addPoint(xdata, ydata) + + self.isMouseDragImg1 = True + + # Annotate cell cycle division + elif isAnnotateDivision: + if posData.cca_df is None: + return + + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + divID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as divided', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + divID_prompt.exec_() + if divID_prompt.cancel: + return + else: + ID = divID_prompt.EntryID + obj_idx = posData.IDs.index(ID) + y, x = posData.rp[obj_idx].centroid + xdata, ydata = int(x), int(y) + + if not self.isSnapshot: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + # Annotate or undo division + self.manualCellCycleAnnotation(ID) + else: + self.undoBudMothAssignment(ID) + + # Assign bud to mother (mouse down on bud) + elif right_click and self.assignBudMothButton.isChecked(): + if self.clickedOnBud: + # NOTE: self.clickedOnBud is set to False when assigning a mother + # is successfull in mouse release event + # We still have to click on a mother + return + + if posData.cca_df is None: + return + + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + budID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID of a bud you want to correct mother assignment', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + budID_prompt.exec_() + if budID_prompt.cancel: + return + else: + ID = budID_prompt.EntryID + + obj_idx = posData.IDs.index(ID) + y, x = posData.rp[obj_idx].centroid + xdata, ydata = int(x), int(y) + + relationship = posData.cca_df.at[ID, 'relationship'] + is_history_known = posData.cca_df.at[ID, 'is_history_known'] + self.clickedOnHistoryKnown = is_history_known + # We allow assiging a cell in G1 as bud only on first frame + # OR if the history is unknown + if relationship != 'bud' and posData.frame_i > 0 and is_history_known: + txt = (f'You clicked on ID {ID} which is NOT a bud.\n' + 'To assign a bud to a cell start by clicking on a bud ' + 'and release on a cell in G1') + msg = QMessageBox() + msg.critical( + self.host, 'Not a bud', txt, msg.Ok + ) + return + + self.clickedOnBud = True + self.xClickBud, self.yClickBud = xdata, ydata + + # Annotate (or undo) that cell has unknown history + elif right_click and self.setIsHistoryKnownButton.isChecked(): + if posData.cca_df is None: + return + + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + unknownID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as ' + '"history UNKNOWN/KNOWN"', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + unknownID_prompt.exec_() + if unknownID_prompt.cancel: + return + else: + ID = unknownID_prompt.EntryID + obj_idx = posData.IDs.index(ID) + y, x = posData.rp[obj_idx].centroid + xdata, ydata = int(x), int(y) + + self.annotateIsHistoryKnown(ID) + if not self.setIsHistoryKnownButton.findChild(QAction).isChecked(): + self.setIsHistoryKnownButton.setChecked(False) + + elif isCustomAnnot: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + clickedBkgrDialog = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as divided', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + clickedBkgrDialog.exec_() + if clickedBkgrDialog.cancel: + return + else: + ID = clickedBkgrDialog.EntryID + obj_idx = posData.IDs.index(ID) + y, x = posData.rp[obj_idx].centroid + xdata, ydata = int(x), int(y) + + button = self.custom_annotations_view.doCustomAnnotation(ID) + if button is None: + return + + keepActive = self.customAnnotDict[button]['state']['keepActive'] + if not keepActive: + button.setChecked(False) + + elif right_click and findNextMotherButtonON: + if posData.frame_i == 0: + return + + self.find_mother_action(posData, event, ydata, xdata) + + elif right_click and unknownLineageButtonON: + if posData.frame_i == 0: + return + + self.annotate_unknown_lineage_action(posData, event, ydata, xdata) + + elif (left_click or right_click) and canZoomRect: + if left_click: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + + self.zoomRectItem.setPos((xdata, ydata)) + + self.isMouseDragImg1 = True + else: + try: + xRange, yRange = self.zoomRectItem.getLastRange() + self.ax1.setRange( + xRange=xRange, + yRange=yRange, + padding=0 + ) + except Exception as err: + QTimer.singleShot(100, self.autoRange) diff --git a/cellacdc/views/canvas_hover_view.py b/cellacdc/views/canvas_hover_view.py new file mode 100644 index 000000000..03bbc707f --- /dev/null +++ b/cellacdc/views/canvas_hover_view.py @@ -0,0 +1,551 @@ +"""Qt view adapter for canvas hover and cursor interactions.""" + +from __future__ import annotations + +import pyqtgraph as pg +from qtpy.QtCore import Qt +from qtpy.QtGui import QGuiApplication + +from cellacdc import html_utils, widgets +from cellacdc.viewmodels.canvas_hover_viewmodel import CanvasHoverViewModel + + +class CanvasHoverView: + """Qt-facing adapter around canvas hover workflows.""" + + LEGACY_METHODS = ( + 'updateHoverLabelCursor', + 'gui_hoverEventRightImage', + 'onCtrlPressedFirstTime', + 'onCtrlReleased', + 'gui_hoverEventImg1', + 'drawTempMothBudLine', + 'drawTempMergeObjsLine', + 'gui_add_ax_cursors', + 'gui_setCursor', + 'warnAddingPointWithExistingId', + 'gui_hoverEventImg2', + 'drawTempRulerLine', + ) + + def __init__(self, host, view_model: CanvasHoverViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def updateHoverLabelCursor(self, x, y): + if x is None: + self.hoverLabelID = 0 + return + + xdata, ydata = int(x), int(y) + if not self.view_model.point_in_bounds( + self.currentLab2D.shape, + xdata, + ydata, + ): + return + + ID = self.currentLab2D[ydata, xdata] + self.hoverLabelID = ID + + if ID == 0: + if self.highlightedID != 0: + self.updateAllImages() + self.highlightedID = 0 + return + + if self.app.overrideCursor() != Qt.SizeAllCursor: + self.app.setOverrideCursor(Qt.SizeAllCursor) + + if not self.isMovingLabel: + self.highlightSearchedID(ID) + + def gui_hoverEventRightImage(self, event): + try: + posData = self.data[self.pos_i] + except AttributeError: + return + + if event.isExit(): + self.resetCursors() + + self.gui_hoverEventImg1(event, isHoverImg1=False) + setMirroredCursor = self.view_model.should_set_mirrored_cursor( + override_cursor_is_none=self.app.overrideCursor() is None, + is_exit=event.isExit(), + mirrored_cursor_enabled=self.showMirroredCursorAction.isChecked(), + is_hover_img1=True, + ) + if setMirroredCursor: + x, y = event.pos() + self.ax1_cursor.setData([x], [y]) + + def onCtrlPressedFirstTime(self): + x, y = self.xHoverImg, self.yHoverImg + if x is None: + self.xyOnCtrlPressedFirstTime = None + return + + xdata, ydata = int(x), int(y) + if not self.view_model.point_in_bounds( + self.currentLab2D.shape, + xdata, + ydata, + ): + self.xyOnCtrlPressedFirstTime = None + return + + ID = self.currentLab2D[ydata, xdata] + if ID == 0: + self.xyOnCtrlPressedFirstTime = None + return + + self.xyOnCtrlPressedFirstTime = (xdata, ydata) + + def onCtrlReleased(self): + self.xyOnCtrlPressedFirstTime = None + + def gui_hoverEventImg1(self, event, isHoverImg1=True): + try: + posData = self.data[self.pos_i] + except AttributeError: + return + + self.xHoverImg, self.yHoverImg = self.view_model.hover_position( + event.isExit(), + event.pos(), + ) + + if event.isExit(): + self.resetCursor() + + if not event.isExit() and self.slideshowWin is not None: + self.slideshowWin.setMirroredCursorPos(*event.pos()) + + # Alt key was released --> restore cursor + modifiers = QGuiApplication.keyboardModifiers() + cursorsInfo = self.gui_setCursor(modifiers, event) + self.highlightHoverLostObj(modifiers, event) + + drawRulerLine = self.view_model.should_draw_ruler_line( + ruler_checked=self.rulerButton.isChecked(), + add_deleted_polyline_checked=( + self.addDelPolyLineRoiButton.isChecked() + ), + temp_segment_on=self.tempSegmentON, + is_exit=event.isExit(), + ) + if drawRulerLine: + self.drawTempRulerLine(event) + + if not event.isExit(): + x, y = event.pos() + xdata, ydata = int(x), int(y) + if self.view_model.point_in_bounds( + self.img1.image.shape[:2], + xdata, + ydata, + ): + ID = self.currentLab2D[ydata, xdata] + self.updatePropsWidget(ID, fromHover=True) + activeToolButton = self.status_hover_view.active_tool_button() + hoverText = self.status_hover_view.hover_values_formatted( + xdata, ydata, activeToolButton, isHoverImg1 + ) + self.status_hover_view.check_highlight_scale_bar( + x, y, activeToolButton + ) + self.status_hover_view.check_highlight_timestamp( + x, y, activeToolButton + ) + self.wcLabel.setText(hoverText) + else: + self.clickedOnBud = False + self.BudMothTempLine.setData([], []) + self.wcLabel.setText('') + + if cursorsInfo['setKeepObjCursor']: + x, y = event.pos() + self.highlightHoverIDsKeptObj(x, y) + + if cursorsInfo['setManualTrackingCursor']: + x, y = event.pos() + # self.highlightHoverID(x, y) + self.drawManualTrackingGhost(x, y) + + if cursorsInfo['setManualBackgroundCursor']: + x, y = event.pos() + # self.highlightHoverID(x, y) + self.drawManualBackgroundObj(x, y) + + if ( + not cursorsInfo['setManualTrackingCursor'] + and not cursorsInfo['setManualBackgroundCursor'] + ): + self.clearGhost() + + setMoveLabelCursor = cursorsInfo['setMoveLabelCursor'] + setExpandLabelCursor = cursorsInfo['setExpandLabelCursor'] + if setMoveLabelCursor or setExpandLabelCursor: + x, y = event.pos() + self.updateHoverLabelCursor(x, y) + + # Draw eraser circle + if cursorsInfo['setEraserCursor']: + x, y = event.pos() + self.updateEraserCursor(x, y, isHoverImg1=isHoverImg1) + self.hideItemsHoverBrush(xy=(x, y)) + elif self.eraserButton.isChecked() and not event.isExit(): + if self.xyOnCtrlPressedFirstTime is not None: + self.updateEraserCursor( + x, y, xyLocked=self.xyOnCtrlPressedFirstTime, + isHoverImg1=isHoverImg1 + ) + self.hideItemsHoverBrush(xy=(x, y)) + else: + eraserCursors = ( + self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX + ) + self.setHoverToolSymbolData([], [], eraserCursors) + + # Draw Brush circle + if cursorsInfo['setBrushCursor']: + x, y = event.pos() + self.updateBrushCursor(x, y, isHoverImg1=isHoverImg1) + self.hideItemsHoverBrush(xy=(x, y)) + elif cursorsInfo['setAddPointCursor']: + x, y = event.pos() + self.setHoverCircleAddPoint(x, y) + else: + self.setHoverToolSymbolData( + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + + # Draw label ROi circular cursor + setLabelRoiCircCursor = cursorsInfo['setLabelRoiCircCursor'] + if setLabelRoiCircCursor: + x, y = event.pos() + else: + x, y = None, None + self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) + + drawMothBudLine = ( + self.assignBudMothButton.isChecked() and self.clickedOnBud + and not event.isExit() + ) + if drawMothBudLine: + self.drawTempMothBudLine(event, posData) + + drawMergeObjsLine = ( + self.mergeIDsButton.isChecked() and not event.isExit() + ) + if drawMergeObjsLine: + self.drawTempMergeObjsLine(event, posData, modifiers) + + # Temporarily draw spline curve + # see https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy + drawSpline = ( + self.curvToolButton.isChecked() and self.splineHoverON + and not event.isExit() + ) + if drawSpline: + self.curvature_tools_view.hoverEventDrawSpline(event) + + setMirroredCursor = self.view_model.should_set_mirrored_cursor( + override_cursor_is_none=self.app.overrideCursor() is None, + is_exit=event.isExit(), + mirrored_cursor_enabled=self.showMirroredCursorAction.isChecked(), + is_hover_img1=isHoverImg1, + ) + if setMirroredCursor: + x, y = event.pos() + self.ax2_cursor.setData([x], [y]) + else: + self.ax2_cursor.setData([], []) + + return cursorsInfo + + def drawTempMothBudLine(self, event, posData): + x, y = event.pos() + y2, x2 = y, x + xdata, ydata = int(x), int(y) + y1, x1 = self.yClickBud, self.xClickBud + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + self.BudMothTempLine.setData([x1, x2], [y1, y2]) + else: + obj_idx = posData.IDs_idxs[ID] + obj = posData.rp[obj_idx] + y2, x2 = self.getObjCentroid(obj.centroid) + self.BudMothTempLine.setData([x1, x2], [y1, y2]) + + def drawTempMergeObjsLine(self, event, posData, modifiers): + if self.clickObjYc is None: + return + modifier = modifiers == Qt.ShiftModifier + x, y = event.pos() + y2, x2 = y, x + xdata, ydata = int(x), int(y) + y1, x1 = self.clickObjYc, self.clickObjXc + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID != 0: + obj_idx = posData.IDs_idxs[ID] + obj = posData.rp[obj_idx] + y2, x2 = self.getObjCentroid(obj.centroid) + + if modifier and ID > 0: + self.mergeObjsTempLine.addPoint(x2, y2) + elif not modifier: + self.mergeObjsTempLine.setData([x1, x2], [y1, y2]) + + def gui_add_ax_cursors(self): + try: + self.ax1.removeItem(self.ax1_cursor) + self.ax2.removeItem(self.ax2_cursor) + except Exception as e: + pass + + self.ax2_cursor = pg.ScatterPlotItem( + symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), + brush=pg.mkBrush('w'), size=16, tip=None + ) + self.ax2.addItem(self.ax2_cursor) + + self.ax1_cursor = pg.ScatterPlotItem( + symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), + brush=pg.mkBrush('w'), size=16, tip=None + ) + self.ax1.addItem(self.ax1_cursor) + + def _cursor_flags(self, modifiers, event): + return self.view_model.cursor_flags( + is_exit=event.isExit(), + no_modifier=modifiers == Qt.NoModifier, + shift=modifiers == Qt.ShiftModifier, + ctrl=modifiers == Qt.ControlModifier, + alt=modifiers == Qt.AltModifier, + brush_checked=self.brushButton.isChecked(), + eraser_checked=self.eraserButton.isChecked(), + add_deleted_polyline_checked=( + self.addDelPolyLineRoiButton.isChecked() + ), + label_roi_checked=self.labelRoiButton.isChecked(), + label_roi_circular_checked=( + self.labelRoiIsCircularRadioButton.isChecked() + ), + wand_checked=self.wandToolButton.isChecked(), + move_label_checked=self.moveLabelToolButton.isChecked(), + expand_label_checked=self.expandLabelToolButton.isChecked(), + curvature_checked=self.curvToolButton.isChecked(), + keep_ids_checked=self.keepIDsButton.isChecked(), + custom_annotation_available=self.customAnnotButton is not None, + manual_tracking_checked=self.manualTrackingButton.isChecked(), + manual_background_checked=self.manualBackgroundButton.isChecked(), + zoom_rect_checked=self.zoomRectButton.isChecked(), + edit_id_checked=self.editIDbutton.isChecked(), + magic_prompts_checked=self.magicPromptsToolButton.isChecked(), + points_layer_checked=self.togglePointsLayerAction.isChecked(), + add_points_by_clicking_active=( + self.buttonAddPointsByClickingActive() is not None + ), + ) + + def gui_setCursor(self, modifiers, event): + noModifier = modifiers == Qt.NoModifier + shift = modifiers == Qt.ShiftModifier + + # Alt key was released --> restore cursor + if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: + self.app.restoreOverrideCursor() + + flags = self._cursor_flags(modifiers, event) + setBrushCursor = flags['setBrushCursor'] + setEraserCursor = flags['setEraserCursor'] + setAddDelPolyLineCursor = flags['setAddDelPolyLineCursor'] + setLabelRoiCircCursor = flags['setLabelRoiCircCursor'] + setWandCursor = flags['setWandCursor'] + setLabelRoiCursor = flags['setLabelRoiCursor'] + setCurvCursor = flags['setCurvCursor'] + setKeepObjCursor = flags['setKeepObjCursor'] + setCustomAnnotCursor = flags['setCustomAnnotCursor'] + setManualTrackingCursor = flags['setManualTrackingCursor'] + setManualBackgroundCursor = flags['setManualBackgroundCursor'] + setAddPointCursor = flags['setAddPointCursor'] + setZoomRectCursor = flags['setZoomRectCursor'] + setEditIDCursor = flags['setEditIDCursor'] + overrideCursor = self.app.overrideCursor() + setPanImageCursor = flags['setPanImageCursor'] + if setPanImageCursor and overrideCursor is None: + self.app.setOverrideCursor(Qt.SizeAllCursor) + elif setBrushCursor or setEraserCursor or setLabelRoiCircCursor: + self.app.setOverrideCursor(Qt.CrossCursor) + elif setWandCursor and overrideCursor is None: + self.app.setOverrideCursor(self.wandCursor) + elif setLabelRoiCursor and overrideCursor is None: + self.app.setOverrideCursor(Qt.CrossCursor) + elif setCurvCursor and overrideCursor is None: + self.app.setOverrideCursor(self.curvCursor) + elif setCustomAnnotCursor and overrideCursor is None: + self.app.setOverrideCursor(Qt.PointingHandCursor) + elif setAddDelPolyLineCursor: + self.app.setOverrideCursor(self.polyLineRoiCursor) + elif setCustomAnnotCursor: + x, y = event.pos() + self.highlightHoverID(x, y) + elif setKeepObjCursor and overrideCursor is None: + self.app.setOverrideCursor(Qt.PointingHandCursor) + elif setManualTrackingCursor and overrideCursor is None: + self.app.setOverrideCursor(Qt.PointingHandCursor) + elif setManualBackgroundCursor and overrideCursor is None: + self.app.setOverrideCursor(Qt.PointingHandCursor) + elif setAddPointCursor: + self.app.setOverrideCursor(self.addPointsCursor) + elif setZoomRectCursor: + self.app.setOverrideCursor(Qt.CrossCursor) + elif setEditIDCursor and overrideCursor is None: + if shift: + self.app.setOverrideCursor(Qt.CrossCursor) + else: + self.app.restoreOverrideCursor() + + return flags + + def warnAddingPointWithExistingId(self, point_id, table_endname=''): + posData = self.data[self.pos_i] + if not point_id in posData.IDs_idxs: + return True + + msg = widgets.myMessageBox(wrapText=False) + txt = (f""" + Cell ID {point_id} already exists!

+ Are you sure you want to add this point? + """) + if table_endname: + txt = (f""" + The loaded table {table_endname} has point id + {point_id}. +

However, {txt} + """) + txt = html_utils.paragraph(txt) + _, _, yesButton = msg.warning( + self.host, f'Cell ID {point_id} already exist', txt, + buttonsTexts=( + 'Cancel', 'No, do not add', f'Yes, add point id {point_id}' + ) + ) + return msg.clickedButton == yesButton + + def gui_hoverEventImg2(self, event): + try: + posData = self.data[self.pos_i] + except AttributeError: + return + + self.xHoverImg, self.yHoverImg = self.view_model.hover_position( + event.isExit(), + event.pos(), + ) + + # Cursor left image --> restore cursor + if event.isExit() and self.app.overrideCursor() is not None: + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + # Alt key was released --> restore cursor + modifiers = QGuiApplication.keyboardModifiers() + noModifier = modifiers == Qt.NoModifier + shift = modifiers == Qt.ShiftModifier + if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: + self.app.restoreOverrideCursor() + + flags = self._cursor_flags(modifiers, event) + setBrushCursor = flags['setBrushCursor'] + setEraserCursor = flags['setEraserCursor'] + setLabelRoiCircCursor = flags['setLabelRoiCircCursor'] + if setBrushCursor or setEraserCursor or setLabelRoiCircCursor: + self.app.setOverrideCursor(Qt.CrossCursor) + + # Cursor is moving on image while Alt key is pressed --> pan cursor + setPanImageCursor = flags['setPanImageCursor'] + if setPanImageCursor and self.app.overrideCursor() is None: + self.app.setOverrideCursor(Qt.SizeAllCursor) + + setKeepObjCursor = flags['setKeepObjCursor'] + if setKeepObjCursor and self.app.overrideCursor() is None: + self.app.setOverrideCursor(Qt.PointingHandCursor) + + # Update x, y, value label bottom right + if not event.isExit(): + x, y = event.pos() + xdata, ydata = int(x), int(y) + _img = self.currentLab2D + Y, X = _img.shape + # hoverText = self.status_hover_view.hover_values_formatted( + # xdata, ydata + # ) + # self.wcLabel.setText(hoverText) + else: + if self.eraserButton.isChecked() or self.brushButton.isChecked(): + self.gui_mouseReleaseEventImg2(event) + self.wcLabel.setText(f'') + + if flags['setMoveLabelCursor'] or flags['setExpandLabelCursor']: + x, y = event.pos() + self.updateHoverLabelCursor(x, y) + + if setKeepObjCursor: + x, y = event.pos() + self.highlightHoverIDsKeptObj(x, y) + + # Draw eraser circle + if setEraserCursor: + x, y = event.pos() + self.updateEraserCursor(x, y, isHoverImg1=False) + else: + self.setHoverToolSymbolData( + [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX) + ) + + # Draw Brush circle + if setBrushCursor: + x, y = event.pos() + self.updateBrushCursor(x, y, isHoverImg1=False) + else: + self.setHoverToolSymbolData( + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + + # Draw label ROi circular cursor + if setLabelRoiCircCursor: + x, y = event.pos() + else: + x, y = None, None + self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) + + def drawTempRulerLine(self, event): + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + x, y = event.pos() + x1, y1 = int(x), int(y) + xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() + x0, y0 = xxRA[0], yyRA[0] + if ctrl: + x1, y1 = self.host.view_model.geometry.snap_xy_to_closest_angle( + x0, y0, x1, y1 + ) + self.ax1_rulerPlotItem.setData([x0, x1], [y0, y1]) diff --git a/cellacdc/views/canvas_right_image_view.py b/cellacdc/views/canvas_right_image_view.py new file mode 100644 index 000000000..35ce33fa4 --- /dev/null +++ b/cellacdc/views/canvas_right_image_view.py @@ -0,0 +1,48 @@ +"""View adapter for duplicated right-image interactions.""" + +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtGui import QGuiApplication + +from cellacdc import exception_handler +from cellacdc.viewmodels.canvas_right_image_viewmodel import ( + CanvasRightImageViewModel, +) + + +class CanvasRightImageView: + """Qt-facing adapter for duplicated right-image mouse events.""" + + def __init__(self, host, view_model: CanvasRightImageViewModel): + self.host = host + self.view_model = view_model + + @exception_handler + def mouse_press(self, event): + modifiers = QGuiApplication.keyboardModifiers() + alt = modifiers == Qt.AltModifier + right_click = event.button() == Qt.MouseButton.RightButton and not alt + is_right_click_action_on = any([ + b.isChecked() for b in self.host.checkableQButtonsGroup.buttons() + ]) + self.host.typingEditID = False + show_menu = self.view_model.should_show_context_menu( + right_click=right_click, + is_right_click_action_on=is_right_click_action_on, + ) + if show_menu: + self.host.canvas_context_menu_view.show_right_image_context_menu( + event + ) + event.ignore() + else: + self.host.gui_mousePressEventImg1(event) + + @exception_handler + def mouse_drag(self, event): + self.host.gui_mouseDragEventImg1(event) + + @exception_handler + def mouse_release(self, event): + self.host.gui_mouseReleaseEventImg1(event) diff --git a/cellacdc/views/canvas_selection_view.py b/cellacdc/views/canvas_selection_view.py new file mode 100644 index 000000000..846e277eb --- /dev/null +++ b/cellacdc/views/canvas_selection_view.py @@ -0,0 +1,854 @@ +"""Qt view adapter for canvas selection interactions.""" + +from __future__ import annotations + +import time + +import pyqtgraph as pg +import scipy.ndimage +import skimage.morphology + +from qtpy.QtCore import Qt +from qtpy.QtGui import QGuiApplication +from qtpy.QtWidgets import QAction, QGraphicsSceneMouseEvent + +from cellacdc import apps, exception_handler +from cellacdc.viewmodels.canvas_selection_viewmodel import ( + CanvasSelectionViewModel, +) + + +class CanvasSelectionView: + """Qt-facing adapter for canvas selection workflows.""" + + LEGACY_METHODS = ( + 'gui_mousePressEventImg2', + 'gui_mouseReleaseEventImg2', + ) + + def __init__(self, host, view_model: CanvasSelectionViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + @exception_handler + def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): + if self._dispatch_tool_event_if_enabled(event, phase='press', image='img2'): + return + modifiers = QGuiApplication.keyboardModifiers() + alt = modifiers == Qt.AltModifier + shift = modifiers == Qt.ShiftModifier + shift_regardless = bool(modifiers & Qt.ShiftModifier) + isMod = alt + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + left_click = event.button() == Qt.MouseButton.LeftButton and not alt + middle_click = self.isMiddleClick(event, modifiers) + right_click = event.button() == Qt.MouseButton.RightButton and not alt + isPanImageClick = self.isPanImageClick(event, modifiers) + eraserON = self.eraserButton.isChecked() + brushON = self.brushButton.isChecked() + separateON = self.separateBudButton.isChecked() + self.typingEditID = False + + # Drag image if neither brush or eraser are On pressed + dragImg = self.view_model.should_drag_image( + left_click=left_click, + eraser_on=eraserON, + brush_on=brushON, + middle_click=middle_click, + pan_click=isPanImageClick, + ) + + # Enable dragging of the image window like pyqtgraph original code + if dragImg: + pg.ImageItem.mousePressEvent(self.img2, event) + event.ignore() + return + + if self.view_model.should_blink_viewer_mode( + mode=mode, + middle_click=middle_click, + ): + self.mode_controls_view.startBlinkingModeCB() + event.ignore() + return + + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + Y, X = self.get_2Dlab(posData.lab).shape + if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + else: + return + + # Check if right click on ROI + isClickOnDelRoi = self.canvas_context_menu_view.clicked_deleted_roi( + event, + left_click, + right_click, + ) + if isClickOnDelRoi: + return + + # show gradient widget menu if none of the right-click actions are ON + # and event is not coming from image 1 + is_right_click_action_ON = any([ + b.isChecked() for b in self.checkableQButtonsGroup.buttons() + ]) + is_right_click_custom_ON = any([ + b.isChecked() for b in self.customAnnotDict.keys() + ]) + is_event_from_img1 = False + if hasattr(event, 'isImg1Sender'): + is_event_from_img1 = event.isImg1Sender + + is_only_right_click = ( + right_click and not is_right_click_action_ON and not middle_click + ) + + showLabelsGradMenu = self.view_model.should_show_labels_menu( + right_click=right_click, + right_action_on=is_right_click_action_ON, + middle_click=middle_click, + event_from_img1=is_event_from_img1, + ) + + if showLabelsGradMenu: + self.labelsGrad.showMenu(event) + event.ignore() + return + + editInViewerMode = self.view_model.should_blink_viewer_mode( + mode=mode, + middle_click=middle_click, + right_action_on=is_right_click_action_ON, + custom_action_on=is_right_click_custom_ON, + right_click=right_click, + ) + + if editInViewerMode: + self.mode_controls_view.startBlinkingModeCB() + event.ignore() + return + + # Left-click is used for brush, eraser, separate bud, curvature tool + # and magic labeller + # Brush and eraser are mutually exclusive but we want to keep the eraser + # or brush ON and disable them temporarily to allow left-click with + # separate ON + canDelete = self.view_model.can_delete( + mode=mode, + is_snapshot=self.isSnapshot, + ) + + # Delete ID (set to 0) + if middle_click and canDelete: + t0 = time.perf_counter() + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + delID = self.get_2Dlab(posData.lab)[ydata, xdata] + if delID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + delID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.
' + 'Enter here ID(s) that you want to delete

' + 'You can enter multiple IDs separated by comma', + parent=self.host, + allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + allowList=True, + isInteger=True + ) + delID_prompt.exec_() + if delID_prompt.cancel: + return + delIDs = delID_prompt.EntryID + else: + delIDs = [delID] + + # Ask to propagate change to all future visited frames + key = 'Delete ID' + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + delIDs, key, doNotShow, + posData.UndoFutFrames_DelID, posData.applyFutFrames_DelID + ) + + if UndoFutFrames is None: + return + + # Store undo state before modifying stuff + self.storeUndoRedoStates(UndoFutFrames) + posData.doNotShowAgain_DelID = doNotShowAgain + posData.UndoFutFrames_DelID = UndoFutFrames + posData.applyFutFrames_DelID = applyFutFrames + includeUnvisited = posData.includeUnvisitedInfo['Delete ID'] + + delID_mask = self.deleteIDmiddleClick( + delIDs, applyFutFrames, includeUnvisited, shift=shift_regardless + ) + if delID_mask.ndim == 3: + delID_mask = delID_mask[self.z_lab()] + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Delete ID') + else: + self.warnEditingWithCca_df('Delete ID', update_images=False) + + self.setImageImg2() + delROIsIDs = self.setAllTextAnnotations() + self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False) + + how = self.drawIDsContComboBox.currentText() + if how.find('overlay segm. masks') != -1: + self.labelsLayerImg1.image[delID_mask] = 0 + self.labelsLayerImg1.setImage(self.labelsLayerImg1.image) + + how_ax2 = self.getAnnotateHowRightImage() + if how_ax2.find('overlay segm. masks') != -1: + self.labelsLayerRightImg.image[delID_mask] = 0 + self.labelsLayerRightImg.setImage(self.labelsLayerRightImg.image) + + self.highlightLostNew() + + # Separate bud or objects with same ID + elif right_click and separateON: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x) + sepID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here ID that you want to split', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + sepID_prompt.exec_() + if sepID_prompt.cancel: + return + else: + ID = sepID_prompt.EntryID + y, x = posData.rp[posData.IDs_idxs[ID]].centroid[-2:] + xdata, ydata = int(x), int(y) + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + max_ID = max(posData.IDs, default=1) + + if self.isSegm3D and not shift: + z = self.zSliceScrollBar.sliderPosition() + posData.lab, splittedIDs = ( + self.view_model.separate_with_label( + posData.lab, posData.rp, [ID], max_ID, + click_coords_list=[(z, ydata, xdata)] + ) + ) + success = True + # self.set_2Dlab(lab2D) + elif not shift: + result = self.view_model.split_along_convexity_defects( + ID, self.get_2Dlab(posData.lab), max_ID + ) + lab2D, success, splittedIDs = result + self.set_2Dlab(lab2D) + else: + success = False + + # If automatic bud separation was not successfull call manual one + if not success: + posData.disableAutoActivateViewerWindow = True + img = self.getDisplayedImg1() + col = 'manual_separate_draw_mode' + drawMode = self.df_settings.at[col, 'value'] + manualSep = apps.manualSeparateGui( + self.get_2Dlab(posData.lab), ID, img, + fontSize=self.fontSize, + IDcolor=self.lut[ID], + parent=self.host, + drawMode=drawMode + ) + manualSep.setState(self.lastManualSeparateState) + manualSep.show() + manualSep.centerWindow() + manualSep.show(block=True) + if manualSep.cancel: + posData.disableAutoActivateViewerWindow = False + if not self.separateBudButton.findChild(QAction).isChecked(): + self.separateBudButton.setChecked(False) + return + self.lastManualSeparateState = manualSep.state() + lab2D = self.get_2Dlab(posData.lab) + lab2D[manualSep.lab!=0] = manualSep.lab[manualSep.lab!=0] + self.set_2Dlab(lab2D) + splittedIDs = [obj.label for obj in manualSep.rp] + posData.disableAutoActivateViewerWindow = False + self.canvas_tool_view.store_manual_separate_draw_mode( + self.df_settings, + self.settings_csv_path, + manualSep.drawMode, + ) + + # Update data (rp, etc) + self.update_rp() + + # Repeat tracking + self.trackSubsetIDs(splittedIDs) + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Separate IDs') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Separate IDs') + + self.store_data() + + if not self.separateBudButton.findChild(QAction).isChecked(): + self.separateBudButton.setChecked(False) + + # Fill holes + elif right_click and self.fillHolesToolButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + clickedBkgrID = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here the ID that you want to ' + 'fill the holes of', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + clickedBkgrID.exec_() + if clickedBkgrID.cancel: + return + else: + ID = clickedBkgrID.EntryID + + if ID in posData.lab: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + obj_idx = posData.IDs.index(ID) + obj = posData.rp[obj_idx] + objMask = self.getObjImage(obj.image, obj.bbox) + localFill = scipy.ndimage.binary_fill_holes(objMask) + posData.lab[self.getObjSlice(obj.slice)][localFill] = ID + + self.update_rp() + self.updateAllImages() + + if not self.fillHolesToolButton.findChild(QAction).isChecked(): + self.fillHolesToolButton.setChecked(False) + + # Hull contour + elif right_click and self.hullContToolButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + mergeID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here the ID that you want to ' + 'replace with Hull contour', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + mergeID_prompt.exec_() + if mergeID_prompt.cancel: + return + else: + ID = mergeID_prompt.EntryID + + if ID in posData.lab: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + obj_idx = posData.IDs.index(ID) + obj = posData.rp[obj_idx] + objMask = self.getObjImage(obj.image, obj.bbox) + localHull = skimage.morphology.convex_hull_image(objMask) + posData.lab[self.getObjSlice(obj.slice)][localHull] = ID + + self.update_rp() + self.updateAllImages() + + if not self.hullContToolButton.findChild(QAction).isChecked(): + self.hullContToolButton.setChecked(False) + + # Move label + elif right_click and self.moveLabelToolButton.isChecked(): + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + x, y = event.pos().x(), event.pos().y() + self.label_transform_tools_view.start_moving_label(x, y) + + # Fill holes + elif right_click and self.fillHolesToolButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + clickedBkgrID = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here the ID that you want to ' + 'fill the holes of', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + clickedBkgrID.exec_() + if clickedBkgrID.cancel: + return + else: + ID = clickedBkgrID.EntryID + + # Merge IDs + elif right_click and self.mergeIDsButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + mergeID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here first ID that you want to merge', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + mergeID_prompt.exec_() + if mergeID_prompt.cancel: + self.mergeObjsTempLine.setData([], []) + return + else: + ID = mergeID_prompt.EntryID + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + self.firstID = ID + + obj_idx = posData.IDs_idxs[ID] + obj = posData.rp[obj_idx] + yc, xc = self.getObjCentroid(obj.centroid) + self.clickObjYc, self.clickObjXc = int(yc), int(xc) + + # Edit ID + elif right_click and self.editIDbutton.isChecked(): + if self._dispatch_tool_event_if_enabled(event, phase='press', image='img2'): + return + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + editID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here ID that you want to replace with a new one', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + editID_prompt.show(block=True) + + if editID_prompt.cancel: + return + else: + ID = editID_prompt.EntryID + + obj_idx = posData.IDs_idxs[ID] + y, x = posData.rp[obj_idx].centroid[-2:] + xdata, ydata = int(x), int(y) + + posData.disableAutoActivateViewerWindow = True + currentIDs = posData.IDs.copy() + self.setAllIDs(onlyVisited=True) + addPropagateCheckbox = ( + not self.isSnapshot + and posData.frame_i == self.navigateScrollBar.maximum() - 1 + and posData.frame_i < posData.SizeT - 1 + ) + editID = apps.EditIDDialog( + ID, posData.IDs, + doNotShowAgain=self.doNotAskAgainExistingID, + parent=self.host, + entryID=self.getNearestLostObjID(y, x), + nextUniqueID=self.setBrushID(return_val=True), + allIDs=posData.allIDs, + addPropagateCheckbox=addPropagateCheckbox + ) + editID.show(block=True) + if editID.cancel: + posData.disableAutoActivateViewerWindow = False + if not self.editIDbutton.findChild(QAction).isChecked(): + self.editIDbutton.setChecked(False) + return + + if editID.assignNewID: + self.assignNewIDfromClickedID(ID, event) + return + + if not self.doNotAskAgainExistingID: + self.editIDmergeIDs = editID.mergeWithExistingID + self.doNotAskAgainExistingID = editID.doNotAskAgainExistingID + + self.applyEditID( + ID, currentIDs, editID.how, x, y, + shift=shift, + doPropagateUnvisited=editID.doPropagateFutureFrames + ) + + elif (right_click or left_click) and self.keepIDsButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + keepID_win = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to keep', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + keepID_win.exec_() + if keepID_win.cancel: + return + else: + ID = keepID_win.EntryID + + if ID in self.keptObjectsIDs: + self.keptObjectsIDs.remove(ID) + self.clearHighlightedText() + else: + self.keptObjectsIDs.append(ID) + self.highlightLabelID(ID) + + self.updateTempLayerKeepIDs() + + # Annotate cell as removed from the analysis + elif right_click and self.binCellButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + binID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to remove from the analysis', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + binID_prompt.exec_() + if binID_prompt.cancel: + return + else: + ID = binID_prompt.EntryID + + # Ask to propagate change to all future visited frames + key = 'Exclude cell from analysis' + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + ID, key, doNotShow, + posData.UndoFutFrames_BinID, + posData.applyFutFrames_BinID + ) + + if UndoFutFrames is None: + # User cancelled the process + return + + posData.doNotShowAgain_BinID = doNotShowAgain + posData.UndoFutFrames_BinID = UndoFutFrames + posData.applyFutFrames_BinID = applyFutFrames + + self.current_frame_i = posData.frame_i + + # Apply Exclude cell from analysis to future frames if requested + if applyFutFrames: + # Store current data before going to future frames + self.store_data() + for i in range(posData.frame_i+1, endFrame_i+1): + posData.frame_i = i + self.get_data() + if ID in posData.binnedIDs: + posData.binnedIDs.remove(ID) + else: + posData.binnedIDs.add(ID) + self.update_rp_metadata(draw=False) + self.store_data(autosave=i==endFrame_i) + + self.app.restoreOverrideCursor() + + # Back to current frame + if applyFutFrames: + posData.frame_i = self.current_frame_i + self.get_data() + + # Store undo state before modifying stuff + self.storeUndoRedoStates(UndoFutFrames) + + if ID in posData.binnedIDs: + posData.binnedIDs.remove(ID) + else: + posData.binnedIDs.add(ID) + + self.annotate_rip_and_bin_IDs(updateLabel=True) + + # Gray out ore restore binned ID + self.updateLookuptable() + + if not self.binCellButton.findChild(QAction).isChecked(): + self.binCellButton.setChecked(False) + + # Annotate cell as dead + elif right_click and self.ripCellButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), y, x + ) + ripID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as dead', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + ripID_prompt.exec_() + if ripID_prompt.cancel: + return + else: + ID = ripID_prompt.EntryID + + # Ask to propagate change to all future visited frames + key = 'Annotate cell as dead' + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + ID, key, doNotShow, + posData.UndoFutFrames_RipID, + posData.applyFutFrames_RipID + ) + + if UndoFutFrames is None: + return + + posData.doNotShowAgain_RipID = doNotShowAgain + posData.UndoFutFrames_RipID = UndoFutFrames + posData.applyFutFrames_RipID = applyFutFrames + + self.current_frame_i = posData.frame_i + + # Apply Edit ID to future frames if requested + if applyFutFrames: + # Store current data before going to future frames + self.store_data() + for i in range(posData.frame_i+1, endFrame_i+1): + posData.frame_i = i + self.get_data() + if ID in posData.ripIDs: + posData.ripIDs.remove(ID) + else: + posData.ripIDs.add(ID) + self.update_rp_metadata(draw=False) + self.store_data(autosave=i==endFrame_i) + self.app.restoreOverrideCursor() + + # Back to current frame + if applyFutFrames: + posData.frame_i = self.current_frame_i + self.get_data() + + # Store undo state before modifying stuff + self.storeUndoRedoStates(UndoFutFrames) + + if ID in posData.ripIDs: + posData.ripIDs.remove(ID) + else: + posData.ripIDs.add(ID) + + self.annotate_rip_and_bin_IDs(updateLabel=True) + + # Gray out dead ID + self.updateLookuptable() + self.store_data() + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Annotate ID as dead') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Annotate ID as dead') + + if not self.ripCellButton.findChild(QAction).isChecked(): + self.ripCellButton.setChecked(False) + + @exception_handler + def gui_mouseReleaseEventImg2(self, event): + if self._dispatch_tool_event_if_enabled(event, phase='release', image='img2'): + return + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + + Y, X = self.get_2Dlab(posData.lab).shape + try: + x, y = event.pos().x(), event.pos().y() + except Exception as e: + return + + xdata, ydata = int(x), int(y) + in_bounds = self.view_model.is_in_bounds(xdata, ydata, X, Y) + if self.view_model.is_viewer_mode(mode): + return + + should_process = self.view_model.should_process_release( + mode=mode, + in_bounds=in_bounds, + ) + if not should_process: + self.isMouseDragImg2 = False + self.updateAllImages() + return + + # Move label mouse released, update move + if self.isMovingLabel and self.moveLabelToolButton.isChecked(): + self.isMovingLabel = False + + # Update data (rp, etc) + self.update_rp() + + # Repeat tracking + self.tracking(enforce=True, assign_unique_new_IDs=False) + + self.updateAllImages() + + if not self.moveLabelToolButton.findChild(QAction).isChecked(): + self.moveLabelToolButton.setChecked(False) + + # Merge IDs + elif self.mergeIDsButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + lab2D = self.get_2Dlab(posData.lab) + ID = lab2D[ydata, xdata] + if ID == 0: + nearest_ID = self.view_model.nearest_nonzero_2d( + lab2D, y, x + ) + mergeID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to merge with ID ' + f'{self.firstID}', + parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + mergeID_prompt.exec_() + if mergeID_prompt.cancel: + return + else: + ID = mergeID_prompt.EntryID + obj_idx = posData.IDs_idxs[ID] + obj = posData.rp[obj_idx] + y2, x2 = self.getObjCentroid(obj.centroid) + self.mergeObjsTempLine.addPoint(x2, y2) + + xx, yy = self.mergeObjsTempLine.getData() + IDs_to_merge = lab2D[yy.astype(int), xx.astype(int)] + for ID in IDs_to_merge: + if ID == 0: + continue + posData.lab[posData.lab==ID] = self.firstID + + self.mergeObjsTempLine.setData([], []) + self.clickObjYc, self.clickObjXc = None, None + + # Update data (rp, etc) + self.update_rp() + + ask_back_prop = True + + if posData.frame_i == 0: + ask_back_prop = False + prev_IDs = [] + else: + prev_IDs = posData.allData_li[posData.frame_i-1]['IDs'] + + if all(ID not in prev_IDs for ID in IDs_to_merge): + ask_back_prop = False + + if not self.isFrameCcaAnnotated() and ask_back_prop: + proceed = self.askPropagateChangePast(f'Merge IDs {IDs_to_merge}') + if proceed: + self.propagateMergeObjsPast(IDs_to_merge) + self.whitelistPropagateIDs(only_future_frames=False, update_lab=True) # in the update_rp() call, this should also be done + + # Repeat tracking + self.tracking( + enforce=True, assign_unique_new_IDs=False, + separateByLabel=False + ) + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Merge IDs') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Merge IDs') + + if not self.mergeIDsButton.findChild(QAction).isChecked(): + self.mergeIDsButton.setChecked(False) + self.store_data() diff --git a/cellacdc/views/canvas_tool_view.py b/cellacdc/views/canvas_tool_view.py new file mode 100644 index 000000000..1e5bd310b --- /dev/null +++ b/cellacdc/views/canvas_tool_view.py @@ -0,0 +1,63 @@ +"""View adapter for canvas tool interaction decisions.""" + +from __future__ import annotations + +from cellacdc.viewmodels.canvas_tool_viewmodel import CanvasToolViewModel + + +class CanvasToolView: + """Qt-facing adapter around the scriptable canvas tool view-model.""" + + def __init__(self, view_model: CanvasToolViewModel): + self.view_model = view_model + + def viewer_mode_allows_press( + self, + mode: str, + *, + can_add_point: bool = False, + can_ruler: bool = False, + ) -> bool: + return self.view_model.viewer_mode_allows_press( + mode, + can_add_point=can_add_point, + can_ruler=can_ruler, + ) + + def should_forward_img1_press_to_img2( + self, + *, + right_click: bool, + middle_click: bool, + can_add_point: bool, + mode: str, + is_snapshot: bool, + is_annotate_division: bool, + manual_background_on: bool, + ) -> bool: + return self.view_model.should_forward_img1_press_to_img2( + right_click=right_click, + middle_click=middle_click, + can_add_point=can_add_point, + mode=mode, + is_snapshot=is_snapshot, + is_annotate_division=is_annotate_division, + manual_background_on=manual_background_on, + ) + + def should_forward_img1_release_to_img2( + self, + *, + right_click: bool, + mode: str, + is_snapshot: bool, + ) -> bool: + return self.view_model.should_forward_img1_release_to_img2( + right_click=right_click, + mode=mode, + is_snapshot=is_snapshot, + ) + + def store_manual_separate_draw_mode(self, settings, settings_csv_path, mode): + self.view_model.apply_manual_separate_draw_mode(settings, mode) + settings.to_csv(settings_csv_path) diff --git a/cellacdc/views/cell_cycle_view.py b/cellacdc/views/cell_cycle_view.py new file mode 100644 index 000000000..29fef422e --- /dev/null +++ b/cellacdc/views/cell_cycle_view.py @@ -0,0 +1,2392 @@ +"""Qt view adapter for cell-cycle annotation workflows.""" + +from __future__ import annotations + +import traceback +import uuid + +from tqdm import tqdm +from qtpy.QtCore import QMutex, QThread, QTimer, QWaitCondition +from qtpy.QtWidgets import QCheckBox, QMessageBox, QPushButton + +from cellacdc import ( + apps, _warnings, base_cca_dict, base_cca_tree_dict, disableWindow, + exception_handler, html_utils, +) +from cellacdc import widgets, workers +from cellacdc.viewmodels.cell_cycle_viewmodel import CellCycleViewModel + + +class CellCycleView: + """Qt-facing adapter for cell-cycle annotation workflows.""" + + LEGACY_METHODS = ( + 'nearest_point_2Dyx', + 'isCurrentFrameCcaVisited', + 'warnScellsGone', + 'checkScellsGone', + 'attempt_auto_cca', + 'highlightIDs', + 'warnFrameNeverVisitedSegmMode', + 'checkCcaPastFramesNewIDs', + 'addIDBaseCca_df', + 'getBaseCca_df', + 'get_last_cca_frame_i', + 'initCca', + '_getCcaCostMatrix', + 'autoCca_df', + 'initMissingFramesCca', + 'addMissingIDs_cca_df', + 'update_cca_df_relabelling', + 'update_cca_df_deletedIDs', + 'updateCcaDfDeletedIDsTimelapse', + 'update_cca_df_newIDs', + 'update_cca_df_snapshots', + 'fixCcaDfAfterEdit', + 'setCcaIssueContour', + 'isLastVisitedAgainCca', + 'highlightNewCellNotEnoughG1cells', + 'highlightNewIDs_ccaFailed', + 'handleNoCellsInG1', + 'isFrameCcaAnnotated', + 'warnEditingWithCca_df', + 'ccaIntegrCheckerToggled', + 'startCcaIntegrityCheckerWorker', + 'initCcaIntegrityChecker', + 'disableCcaIntegrityChecker', + 'stopCcaIntegrityCheckerWorker', + 'isCcaCheckerChecking', + 'getConcatCcaDf', + 'storeFromConcatCcaDf', + 'resetWillDivideInfo', + 'ccaCheckerStopChecking', + 'enqCcaIntegrityChecker', + 'resetCcaFuture', + 'removeCcaAnnotationsCurrentFrame', + 'resetFutureCcaColCurrentFrame', + 'get_cca_df', + 'unstore_cca_df', + 'store_cca_df_checker', + 'store_cca_df', + 'viewCcaTable', + 'autoAssignBud_YeastMate', + 'reInitCca', + 'repeatAutoCca', + 'manualEditCcaToolbarActionTriggered', + 'manualEditCca', + 'applyManualCcaChangesFutureFrames', + 'ccaCheckerWorkerDone', + 'goToFrameNumber', + 'warnCcaIntegrity', + 'fixWillDivide', + 'ccaCheckerWorkerClosed', + 'updateIsHistoryKnown', + 'annotateIsHistoryKnown', + 'annotateWillDivide', + 'annotateDivision', + 'undoDivisionAnnotation', + 'undoBudMothAssignment', + 'manualCellCycleAnnotation', + 'warnMotherNotEligible', + 'warnSettingHistoryKnownCellsFirstFrame', + 'checkMothEligibility', + 'checkMothersExcludedOrDead', + 'checkDivisionCanBeUndone', + 'stopBlinkingPairItem', + 'warnDeadOrExcludedMothers', + 'startBlinkingPairingItem', + 'blinkPairingItem', + 'annotateBudToDifferentMother', + 'onMotherNotInG1', + 'warnBudAnnotatedDividedInFuture', + 'warnMotherNotAtLeastOneFrameG1', + 'checkChangeMotherBudEligible', + 'checkSwapMothersEligibility', + 'swapMothers', + ) + + def __init__(self, host, view_model: CellCycleViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def nearest_point_2Dyx(self, points, all_others): + """ + Given 2D array of [y, x] coordinates points and all_others return the + [y, x] coordinates of the two points (one from points and one from all_others) + that have the absolute minimum distance + """ + return self.view_model.cca_workflows.nearest_point_2d_yx(points, all_others) + + def isCurrentFrameCcaVisited(self): + posData = self.data[self.pos_i] + curr_df = posData.allData_li[posData.frame_i]['acdc_df'] + return self.view_model.cca_edits.has_annotations(curr_df) + + def warnScellsGone(self, ScellsIDsGone, frame_i): + msg = widgets.myMessageBox() + text = html_utils.paragraph(f""" + In the next frame the followning cells' IDs in S/G2/M + (highlighted with a yellow contour) will disappear:

+ {ScellsIDsGone}

+ If the cell does not exist you might have deleted it at some point. + If that's the case, then try to go to some previous frames and reset + the cell cycle annotations there (button on the top toolbar).

+ These cells are either buds or mother whose related IDs will not + disappear. This is likely due to cell division happening in + previous frame and the divided bud or mother will be + washed away.

+ If you decide to continue these cells will be automatically + annotated as divided at frame number {frame_i}.

+ Do you want to continue? + """) + _, yesButton, noButton = msg.warning( + self.host, 'Cells in "S/G2/M" disappeared!', text, + buttonsTexts=('Cancel', 'Yes', 'No') + ) + return msg.clickedButton == yesButton + + def checkScellsGone(self): + """Check if there are cells in S phase whose relative disappear in + current frame. Allow user to choose between automatically assign + division to these cells or cancel and not visit the frame. + + Returns + ------- + bool + False if there are no cells disappeared or the user decided + to accept automatic division. + """ + automaticallyDividedIDs = [] + + mode = str(self.modeComboBox.currentText()) + if mode.find('Cell cycle') == -1: + # No cell cycle analysis mode --> do nothing + return False, automaticallyDividedIDs + + posData = self.data[self.pos_i] + + if posData.allData_li[posData.frame_i]['labels'] is None: + # Frame never visited/checked in segm mode --> autoCca_df will raise + # a critical message + return False, automaticallyDividedIDs + + # Check if there are S cells that either only mother or only + # bud disappeared and automatically assign division to it + # or abort visiting this frame + prev_acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_cca_df = prev_acdc_df[self.cca_df_colnames].copy() + + ScellsIDsGone = self.view_model.evaluate_sister_relations(prev_cca_df, posData.IDs) + + + if not ScellsIDsGone: + # No cells in S that disappears --> do nothing + return False, automaticallyDividedIDs + + self.highlightNewIDs_ccaFailed(ScellsIDsGone, rp=prev_rp) + proceed = self.warnScellsGone(ScellsIDsGone, posData.frame_i) + self.clearLostObjContoursItems() + + if not proceed: + return True, automaticallyDividedIDs + + past_cca_frames = ( + (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) + for frame_i in range(posData.frame_i-2, -1, -1) + ) + propagation_result = self.view_model.cca_workflows.propagate_s_phase_disappearance_divisions( + prev_cca_df, + posData.cca_df, + posData.frame_i, + posData.IDs, + past_cca_frames=past_cca_frames, + disappeared_ids=ScellsIDsGone, + ) + if propagation_result.current_cca_df is not None: + posData.cca_df = propagation_result.current_cca_df + + automaticallyDividedIDs.extend( + propagation_result.automatically_divided_ids + ) + updated_cca_dfs = propagation_result.updated_cca_dfs_by_frame + for frame_i, cca_df_i in updated_cca_dfs.items(): + self.store_cca_df( + frame_i=frame_i, + cca_df=cca_df_i, + autosave=False, + ) + + return False, automaticallyDividedIDs + + @exception_handler + def attempt_auto_cca(self, enforceAll=False): + mode = str(self.modeComboBox.currentText()) + posData = self.data[self.pos_i] + + if mode == 'Cell cycle analysis': + notEnoughG1Cells, proceed = self.autoCca_df( + enforceAll=enforceAll + ) + if not proceed: + return notEnoughG1Cells, proceed + + # mode = str(self.modeComboBox.currentText()) + if posData.cca_df is None: # ??? + notEnoughG1Cells = False + proceed = True + return notEnoughG1Cells, proceed + if posData.cca_df.isna().any(axis=None): + raise ValueError('Cell cycle analysis table contains NaNs') + # self.checkMultiBudMoth() + proceed = self.checkMothersExcludedOrDead() + return notEnoughG1Cells, proceed + + elif mode == 'Normal division: Lineage tree': + self.autoLinTree_df() + notEnoughG1Cells = False + proceed = True + return notEnoughG1Cells, proceed + + else: + notEnoughG1Cells = False + proceed = True + return notEnoughG1Cells, proceed + + def highlightIDs(self, IDs, pen): + pass + + def warnFrameNeverVisitedSegmMode(self): + msg = widgets.myMessageBox() + warn_cca = msg.critical( + self.host, 'Next frame NEVER visited', + 'Next frame was never visited in "Segmentation and Tracking"' + 'mode.\n You cannot perform cell cycle analysis on frames' + 'where segmentation and/or tracking errors were not' + 'checked/corrected.\n\n' + 'Switch to "Segmentation and Tracking" mode ' + 'and check/correct next frame,\n' + 'before attempting cell cycle analysis again', + ) + return False + + def checkCcaPastFramesNewIDs(self): + posData = self.data[self.pos_i] + if not posData.new_IDs: + return + + past_acdc_frames = ( + (frame_i, posData.allData_li[frame_i]['acdc_df']) + for frame_i in range(posData.frame_i-2, -1, -1) + ) + result = self.view_model.cca_workflows.collect_existing_new_id_rows( + posData.new_IDs, + past_acdc_frames, + self.cca_df_colnames, + ) + posData.new_IDs = result.remaining_new_ids + return result.found_cca_dfs + + def addIDBaseCca_df(self, posData, ID): + if ID <= 0: + # When calling update_cca_df_deletedIDs we add relative IDs + # but they could be -1 for cells in G1 + return + + posData.cca_df = self.view_model.cca_edits.add_base_annotation( + posData.cca_df, + ID, + base_values=base_cca_dict, + ) + self.store_cca_df() + + def getBaseCca_df(self, with_tree_cols=False): + posData = self.data[self.pos_i] + IDs = [obj.label for obj in posData.rp] + return self.view_model.cca_edits.build_base_annotations( + IDs, + with_tree_cols=with_tree_cols, + base_values=base_cca_dict, + tree_values=base_cca_tree_dict, + ) + + def get_last_cca_frame_i(self): + posData = self.data[self.pos_i] + return self.view_model.cca_edits.last_annotated_frame_index( + dict_frame_i['acdc_df'] + for dict_frame_i in posData.allData_li + ) + + @exception_handler + def initCca(self): + posData = self.data[self.pos_i] + last_tracked_i = self.get_last_tracked_i() + defaultMode = 'Viewer' + if last_tracked_i == 0: + txt = html_utils.paragraph( + 'On this dataset either you never checked that the segmentation ' + 'and tracking are correct or you did not save yet.

' + 'If you already visited some frames with "Segmentation and Tracking" ' + 'mode save data before switching to "Cell cycle analysis mode".

' + 'Otherwise you first have to check (and eventually correct) some frames ' + 'in "Segmentation and Tracking" mode before proceeding ' + 'with cell cycle analysis.') + msg = widgets.myMessageBox() + msg.critical( + self.host, 'Tracking was never checked', txt + ) + self.modeComboBox.setCurrentText(defaultMode) + return + + proceed = True + + last_cca_frame_i = self.get_last_cca_frame_i() + if last_cca_frame_i == 0: + # Remove undoable actions from segmentation mode + posData.UndoRedoStates[0] = [] + self.undoAction.setEnabled(False) + self.redoAction.setEnabled(False) + + if posData.frame_i > last_cca_frame_i: + # Prompt user to go to last annotated frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + The last annotated frame is frame {last_cca_frame_i+1}.

+ Do you want to restart cell cycle analysis from frame + {last_cca_frame_i+1}?
+ """) + _, goToFrameButton, stayButton = msg.warning( + self.host, 'Go to last annotated frame?', txt, + buttonsTexts=( + 'Cancel', f'Yes, go to frame {last_cca_frame_i+1}', + 'No, stay on current frame') + ) + if goToFrameButton == msg.clickedButton: + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + msg = 'Looking good!' + self.last_cca_frame_i = last_cca_frame_i + posData.frame_i = last_cca_frame_i + self.titleLabel.setText(msg, color=self.titleColor) + self.get_data() + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + self.updateAllImages() + self.updateScrollbars() + elif stayButton == msg.clickedButton: + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + self.initMissingFramesCca(last_cca_frame_i, posData.frame_i) + last_cca_frame_i = posData.frame_i + msg = 'Cell cycle analysis initialised!' + self.titleLabel.setText(msg, color='g') + elif msg.cancel: + msg = 'Cell cycle analysis aborted.' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + self.modeComboBox.setCurrentText(defaultMode) + proceed = False + return + elif posData.frame_i < last_cca_frame_i: + # Prompt user to go to last annotated frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + The last annotated frame is frame {last_cca_frame_i+1}.

+ Do you want to restart cell cycle analysis from frame + {last_cca_frame_i+1}?
+ """) + yesButton, noButton, _ = msg.question( + self.host, 'Go to last annotated frame?', txt, + buttonsTexts=('Yes', 'No', 'Cancel') + ) + if msg.cancel: + msg = 'Cell cycle analysis aborted.' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + self.modeComboBox.setCurrentText(defaultMode) + proceed = False + return + + self.addMissingIDs_cca_df(posData) + if msg.clickedButton == yesButton: + self.addMissingIDs_cca_df(posData) + msg = 'Looking good!' + self.titleLabel.setText(msg, color=self.titleColor) + self.last_cca_frame_i = last_cca_frame_i + posData.frame_i = last_cca_frame_i + self.get_data() + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + self.updateAllImages() + self.updateScrollbars() + else: + self.get_data() + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + + self.last_cca_frame_i = last_cca_frame_i + + self.navigateScrollBar.setMaximum(last_cca_frame_i+1) + self.navSpinBox.setMaximum(last_cca_frame_i+1) + self.lastTrackedFrameLabel.setText( + f'Last cc annot. frame n. = {last_cca_frame_i+1}' + ) + + if posData.cca_df is None: + posData.cca_df = self.getBaseCca_df() + self.store_cca_df() + msg = 'Cell cycle analysis initialized!' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + else: + self.get_cca_df() + + self.enqCcaIntegrityChecker() + + return proceed + + def _getCcaCostMatrix( + self, numCellsG1, numNewCells, IDsCellsG1, newIDs_contours + ): + posData = self.data[self.pos_i] + dataDict = posData.allData_li[posData.frame_i] + dist_matrix_df = dataDict.get('obj_to_obj_dist_cost_matrix_df') + if dist_matrix_df is None: + mother_contours = {} + for obj in posData.rp: + ID = obj.label + if ID not in IDsCellsG1: + continue + mother_contours[ID] = self.getObjContours(obj) + + bud_contours = dict(zip(posData.new_IDs, newIDs_contours)) + return self.view_model.cca_workflows.auto_cost_matrix_from_contours( + IDsCellsG1, + posData.new_IDs, + mother_contours, + bud_contours, + ) + + return self.view_model.cca_workflows.auto_cost_matrix_from_distances( + dist_matrix_df, + IDsCellsG1, + posData.new_IDs, + ) + + def autoCca_df(self, enforceAll=False): + """ + Assign each bud to a mother with scipy linear sum assignment + (Hungarian or Munkres algorithm). First we build a cost matrix where + each (i, j) element is the minimum distance between bud i and mother j. + Then we minimize the cost of assigning each bud to a mother, and finally + we write the assignment info into cca_df + """ + proceed = True + notEnoughG1Cells = False + ScellsGone = False + + posData = self.data[self.pos_i] + + # Skip cca if not the right mode + mode = str(self.modeComboBox.currentText()) + if mode.find('Cell cycle') == -1: + return notEnoughG1Cells, proceed + + # Make sure that this is a visited frame in segmentation tracking mode + if posData.allData_li[posData.frame_i]['labels'] is None: + proceed = self.warnFrameNeverVisitedSegmMode() + return notEnoughG1Cells, proceed + + # Determine if this is the last visited frame for repeating + # bud assignment on non manually correct (corrected_on_frame_i>0) buds. + # The idea is that the user could have assigned division on a cell + # by going previous and we want to check if this cell could be a + # "better" mother for those non manually corrected buds + curr_df = posData.allData_li[posData.frame_i]['acdc_df'] + isLastVisitedAgain = self.isLastVisitedAgainCca( + curr_df, enforceAll=enforceAll + ) + + frameAlreadyAnnotated = ( + posData.cca_df is not None + and not enforceAll + and not isLastVisitedAgain + ) + # Use stored cca_df and do not modify it with automatic stuff + if frameAlreadyAnnotated: + return notEnoughG1Cells, proceed + + # Keep only correctedAssignIDs if requested + # For the last visited frame we perform assignment again only on + # IDs where we didn't manually correct assignment + if isLastVisitedAgain and not enforceAll: + posData.new_IDs = self.view_model.cca_workflows.uncorrected_new_ids_for_auto( + posData.new_IDs, + curr_df, + ) + + # Check if new IDs exist some time in the past + found_cca_df_IDs = self.checkCcaPastFramesNewIDs() + + # Check if there are some S cells that disappeared + abort, automaticallyDividedIDs = self.checkScellsGone() + if abort: + notEnoughG1Cells = False + proceed = False + return notEnoughG1Cells, proceed + + # Get previous dataframe + acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] + prev_cca_df = acdc_df[self.cca_df_colnames].copy() + + init_result = self.view_model.cca_workflows.prepare_auto_current_frame( + prev_cca_df, + curr_df, + self.cca_df_colnames, + current_cca_df=posData.cca_df, + found_cca_dfs=found_cca_df_IDs or (), + ) + posData.cca_df = init_result.cca_df + + # If there are no new IDs we are done + if not posData.new_IDs: + proceed = True + self.store_cca_df() + return notEnoughG1Cells, proceed + + # Get cells in G1 (exclude dead) and check if there are enough cells in G1 + IDsCellsG1 = self.view_model.cca_workflows.auto_candidate_mother_ids( + prev_cca_df, + acdc_df, + posData.IDs, + current_cca_df=posData.cca_df, + include_current_g1=isLastVisitedAgain or enforceAll, + current_frame_i=posData.frame_i, + ) + + numCellsG1 = len(IDsCellsG1) + numNewCells = len(posData.new_IDs) + if numCellsG1 < numNewCells: + notEnoughG1Cells, proceed = self.handleNoCellsInG1( + numCellsG1, numNewCells + ) + return notEnoughG1Cells, proceed + + # Compute new IDs contours + newIDs_contours = [] + for obj in posData.rp: + ID = obj.label + if ID in posData.new_IDs: + cont = self.getObjContours(obj) + newIDs_contours.append(cont) + + # Compute cost matrix + cost = self._getCcaCostMatrix( + numCellsG1, numNewCells, IDsCellsG1, newIDs_contours + ) + + # Assign buds to mothers + assignments = self.view_model.cca_workflows.auto_assignments_from_cost( + cost, + IDsCellsG1, + posData.new_IDs, + ) + posData.cca_df = self.view_model.cca_workflows.apply_auto_assignments( + posData.cca_df, + assignments, + posData.frame_i, + self.view_model.cca_workflows.base_status(base_cca_dict), + previous_cca_df=prev_cca_df, + current_ids=posData.IDs, + ) + + self.store_cca_df() + proceed = True + return notEnoughG1Cells, proceed + + def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): + self.logger.info( + 'Initialising cell cycle annotations of missing past frames...' + ) + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + + prep_result = self.view_model.cca_workflows.prepare_missing_frame_annotations( + posData.allData_li, + self.cca_df_colnames, + last_cca_frame_i, + ) + for frame_i, acdc_df in prep_result.acdc_dfs_by_frame.items(): + posData.allData_li[frame_i]['acdc_df'] = acdc_df + + last_annotated_cca_df = prep_result.last_annotated_cca_df + cca_df_colnames = self.cca_df_colnames + pbar = tqdm(total=current_frame_i-last_cca_frame_i+1, ncols=100) + for frame_i in range(last_cca_frame_i, current_frame_i+1): + posData.frame_i = frame_i + self.get_data() + cca_df = self.getBaseCca_df() + cca_df = self.view_model.cca_workflows.overlay_last_annotated( + cca_df, + last_annotated_cca_df, + cca_df_colnames, + ) + + self.store_cca_df(cca_df=cca_df, frame_i=frame_i, autosave=False) + pbar.update() + pbar.close() + + posData.frame_i = current_frame_i + self.get_data() + + def addMissingIDs_cca_df(self, posData): + base_cca_df = self.getBaseCca_df() + result = self.view_model.cca_edits.add_missing_ids( + posData.cca_df, + base_cca_df, + ) + posData.cca_df = result.cca_df + + def update_cca_df_relabelling(self, posData, oldIDs, newIDs): + result = self.view_model.cca_edits.relabel_ids( + posData.cca_df, + oldIDs, + newIDs, + ) + posData.cca_df = result.cca_df + + def update_cca_df_deletedIDs( + self, posData, deletedIDs, dropInPast=True, dropInFuture=True + ): + if posData.cca_df is None: + return + + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + try: + deletion_result = self.view_model.cca_edits.delete_ids( + posData.cca_df, + deletedIDs, + ) + except KeyError: + return + + posData.cca_df = deletion_result.cca_df + relIDs = deletion_result.relative_ids + if self.isSnapshot: + self.update_cca_df_newIDs(posData, relIDs) + else: + self.updateCcaDfDeletedIDsTimelapse( + posData, relIDs, deletedIDs, undoId, dropInPast, dropInFuture + ) + + @disableWindow + def updateCcaDfDeletedIDsTimelapse( + self, posData, relIDsOfDelIDs, deletedIDs, undoId, + dropInPast, dropInFuture + ): + future_cca_frames = ( + (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) + for frame_i in range(posData.frame_i + 1, posData.SizeT) + ) + past_cca_frames = ( + (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) + for frame_i in range(posData.frame_i - 1, -1, -1) + ) + existing_ids_by_frame = None + if not dropInPast or not dropInFuture: + existing_ids_by_frame = {} + for frame_i in range(posData.SizeT): + dataDict = posData.allData_li[frame_i] + existingIDs = dataDict.get('IDs_idxs', {}) + if hasattr(existingIDs, 'items'): + existing_ids_by_frame[frame_i] = { + ID for ID, exists in existingIDs.items() if exists + } + else: + existing_ids_by_frame[frame_i] = set(existingIDs) + + propagation_result = self.view_model.cca_workflows.propagate_deleted_ids( + None, + posData.frame_i, + deletedIDs, + relIDsOfDelIDs, + current_cca_df=posData.cca_df, + future_cca_frames=future_cca_frames, + past_cca_frames=past_cca_frames, + drop_in_past=dropInPast, + drop_in_future=dropInFuture, + existing_ids_by_frame=existing_ids_by_frame, + base_values=base_cca_dict, + ) + for frame_i in propagation_result.undo_frame_indices: + cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) + self.storeUndoRedoCca(frame_i, cca_df_i, undoId) + + updated_cca_dfs = propagation_result.updated_cca_dfs_by_frame + if posData.frame_i in updated_cca_dfs: + posData.cca_df = propagation_result.current_cca_df + self.store_data(autosave=False) + + for frame_i, cca_df_i in updated_cca_dfs.items(): + if frame_i == posData.frame_i: + continue + self.store_cca_df( + frame_i=frame_i, cca_df=cca_df_i, autosave=False + ) + + def update_cca_df_newIDs(self, posData, new_IDs): + for newID in new_IDs: + self.addIDBaseCca_df(posData, newID) + + def update_cca_df_snapshots(self, editTxt, posData): + result = self.view_model.cca_edits.apply_snapshot_id_edits( + posData.cca_df, + editTxt, + posData.IDs, + self.getBaseCca_df(), + base_values=base_cca_dict, + ) + + if result.changes.deleted_ids: + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + posData.cca_df = result.cca_df + + def fixCcaDfAfterEdit(self, editTxt): + posData = self.data[self.pos_i] + if posData.cca_df is not None: + # For snapshot mode we fix or reinit cca_df depending on the edit + self.update_cca_df_snapshots(editTxt, posData) + self.store_data() + + def setCcaIssueContour(self, obj): + objContours = self.getObjContours(obj, all_external=True) + for cont in objContours: + xx = cont[:,0] + 0.5 + yy = cont[:,1] + 0.5 + self.ax1_lostObjScatterItem.addPoints(xx, yy) + self.textAnnot[0].addObjAnnotation( + obj, 'lost_object', f'{obj.label}?', False + ) + + def isLastVisitedAgainCca(self, curr_df, enforceAll=False): + # Determine if this is the last visited frame for repeating + # bud assignment on non manually corrected_on_frame_i buds. + # The idea is that the user could have assigned division on a cell + # by going previous and we want to check if this cell could be a + # "better" mother for those non manually corrected buds + posData = self.data[self.pos_i] + next_df = None + if posData.frame_i+1 < posData.SizeT: + next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] + result = self.view_model.cca_workflows.auto_repeat_frame_state( + curr_df, + next_df, + posData.new_IDs, + enforce_all=enforceAll, + ) + posData.new_IDs = result.new_ids + return result.is_last_visited_again + + def highlightNewCellNotEnoughG1cells(self, IDsCellsG1): + posData = self.data[self.pos_i] + for obj in posData.rp: + if obj.label not in IDsCellsG1: + continue + objContours = self.getObjContours(obj) + if objContours is not None: + xx = objContours[:,0] + 0.5 + yy = objContours[:,1] + 0.5 + self.ccaFailedScatterItem.addPoints(xx, yy) + self.textAnnot[0].addObjAnnotation( + obj, 'green', f'{obj.label}?', False + ) + + def highlightNewIDs_ccaFailed(self, IDsWithIssue, rp=None): + if rp is None: + posData = self.data[self.pos_i] + rp = posData.rp + for obj in rp: + if obj.label not in IDsWithIssue: + continue + self.setCcaIssueContour(obj) + + def handleNoCellsInG1(self, numCellsG1, numNewCells): + posData = self.data[self.pos_i] + self.highlightNewCellNotEnoughG1cells(posData.new_IDs) + continueAnyway = _warnings.warnNotEnoughG1Cells( + numCellsG1, posData.frame_i, numNewCells, qparent=self.host + ) + if continueAnyway: + notEnoughG1Cells = False + proceed = True + # Annotate the new IDs with unknown history + for ID in posData.new_IDs: + posData.cca_df = self.view_model.cca_edits.add_base_annotation( + posData.cca_df, + ID, + base_values=base_cca_dict, + ) + cca_df_ID = self.view_model.cca_workflows.known_history_status_for_bud( + ID, + ( + (i, self.get_cca_df(frame_i=i, return_df=True)) + for i in range(posData.frame_i-1, -1, -1) + ), + self.view_model.cca_workflows.base_status(base_cca_dict), + ) + posData.ccaStatus_whenEmerged[ID] = cca_df_ID + else: + notEnoughG1Cells = True + proceed = False + + # Clear new cells annotations + self.ccaFailedScatterItem.setData([], []) + return notEnoughG1Cells, proceed + + def isFrameCcaAnnotated(self): + posData = self.data[self.pos_i] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + return self.view_model.cca_edits.has_annotations(acdc_df) + + def warnEditingWithCca_df( + self, editTxt, return_answer=False, get_answer=False, + get_cancelled=False, update_images=True + ): + # Function used to warn that the user is editing in "Segmentation and + # Tracking" mode a frame that contains cca annotations. + # Ask whether to remove annotations from all future frames + if self.isSnapshot: + return True + + posData = self.data[self.pos_i] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + + cell_cycle_stage_present = self.view_model.cca_edits.has_annotations( + acdc_df + ) + lineage_tree_present = ( + self.view_model.lineage.has_lineage_tree_annotations( + acdc_df, + self.lineage_tree, + ) + ) + + action = self.warnEditingWithAnnotActions.get(editTxt, None) + warning_plan = self.view_model.annotated_edit_warning_plan( + is_snapshot=self.isSnapshot, + acdc_df_missing=acdc_df is None, + lineage_tree_missing=self.lineage_tree is None, + cell_cycle_stage_present=cell_cycle_stage_present, + lineage_tree_present=lineage_tree_present, + remembered_skip_warning=( + action is not None and not action.isChecked() + ), + ) + if warning_plan.proceed_without_warning: + if update_images: + if warning_plan.update_images: + self.updateAllImages() + return True + + msg = widgets.myMessageBox() + warn_type = warning_plan.warn_type + txt = html_utils.paragraph( + f'You modified a frame that has {warn_type}.

' + f'The change "{editTxt}" most likely makes the ' + 'annotations wrong.

' + 'If you really want to apply this change we reccommend to remove' + f'ALL {warn_type}
' + 'from current frame to the end.

' + 'What do you want to do?' + ) + if action is not None: + checkBox = QCheckBox('Remember my choice and do not ask again') + else: + checkBox = None + + dropDelIDsNoteText = ( + '' if editTxt.find('Delete') == -1 else ' (drop removed IDs)' + ) + _, removeAnnotButton, _ = msg.warning( + self.host, 'Edited segmentation with annotations!', txt, + buttonsTexts=( + 'Cancel', + 'Remove annotations from future frames (RECOMMENDED)', + f'Do not remove annotations{dropDelIDsNoteText}' + ), widgets=checkBox + ) + if msg.cancel: + if get_cancelled: + return 'cancelled' + removeAnnotations = False + return removeAnnotations + + if action is not None: + action.setChecked(not checkBox.isChecked()) + action.removeAnnot = msg.clickedButton == removeAnnotButton + + if return_answer: + return msg.clickedButton == removeAnnotButton + + if (msg.clickedButton == removeAnnotButton) and cell_cycle_stage_present: + self.resetFutureCcaColCurrentFrame() + self.resetCcaFuture(posData.frame_i+1) + self.updateAllImages() + elif (msg.clickedButton == removeAnnotButton) and lineage_tree_present: + self.resetLin_tree_future() + self.updateAllImages() + else: + if dropDelIDsNoteText and posData.cca_df is not None: + delIDs = [ + ID for ID in posData.cca_df.index if ID not in posData.IDs + ] + self.update_cca_df_deletedIDs( + posData, delIDs, dropInPast=False + ) + self.addMissingIDs_cca_df(posData) + self.updateAllImages() + self.store_data() + # if action is not None: + # if action.removeAnnot: + # self.store_data() + # posData.frame_i -= 1 + # self.get_data() + # if lineage_tree_present: + # self.resetLin_tree_future() + # self.resetCcaFuture(posData.frame_i) + # self.next_frame() + + if get_answer: + return msg.clickedButton == removeAnnotButton + else: + return True + + def ccaIntegrCheckerToggled(self, checked): + self.df_settings.at['is_cca_integrity_checker_activated', 'value'] = ( + int(checked) + ) + self.df_settings.to_csv(self.settings_csv_path) + mode = self.modeComboBox.currentText() + if mode != 'Cell cycle analysis': + return + + if checked: + self.startCcaIntegrityCheckerWorker() + else: + self.disableCcaIntegrityChecker() + + def startCcaIntegrityCheckerWorker(self): + if not hasattr(self, 'data'): + return + + if not self.isDataLoaded: + return + + if not self.ccaIntegrCheckerToggle.isChecked(): + return + + ccaCheckerThread = QThread() + self.ccaCheckerMutex = QMutex() + self.ccaCheckerWaitCond = QWaitCondition() + + worker = workers.CcaIntegrityCheckerWorker( + self.ccaCheckerMutex, self.ccaCheckerWaitCond + ) + self.ccaIntegrityCheckerWorker = worker + self.ccaCheckerThread = ccaCheckerThread + + worker.moveToThread(ccaCheckerThread) + worker.finished.connect(ccaCheckerThread.quit) + worker.finished.connect(worker.deleteLater) + ccaCheckerThread.finished.connect(ccaCheckerThread.deleteLater) + + worker.sigDone.connect(self.ccaCheckerWorkerDone) + worker.progress.connect(self.workerProgress) + worker.critical.connect(self.ccaIntegrityWorkerCritical) + worker.finished.connect(self.ccaCheckerWorkerClosed) + worker.sigWarning.connect(self.warnCcaIntegrity) + worker.sigFixWillDivide.connect(self.fixWillDivide) + + ccaCheckerThread.started.connect(worker.run) + ccaCheckerThread.start() + + self.ccaCheckerRunning = True + + self.initCcaIntegrityChecker() + + self.logger.info('Cell cycle annotations integrity checker started.') + + def initCcaIntegrityChecker(self): + posData = self.data[self.pos_i] + for frame_i, data_frame_i in enumerate(posData.allData_li): + lab = data_frame_i['labels'] + if lab is None: + break + + cca_df = self.get_cca_df(frame_i, return_df=True) + self.store_cca_df_checker(posData, frame_i, cca_df) + + self.enqCcaIntegrityChecker() + + def disableCcaIntegrityChecker(self): + self.stopCcaIntegrityCheckerWorker() + + def stopCcaIntegrityCheckerWorker(self): + try: + self.ccaIntegrityCheckerWorker._stop() + except Exception as err: + pass + + def isCcaCheckerChecking(self): + if not self.ccaCheckerRunning: + return False + + return self.ccaIntegrityCheckerWorker.isChecking + + def getConcatCcaDf(self): + posData = self.data[self.pos_i] + return self.view_model.cca_edits.concat_annotations( + posData.allData_li, + self.cca_df_colnames, + size_t=posData.SizeT, + ) + + def storeFromConcatCcaDf(self, global_cca_df): + posData = self.data[self.pos_i] + for frame_i, cca_df in self.view_model.cca_edits.split_concat_annotations( + global_cca_df, + size_t=posData.SizeT, + ): + self.store_cca_df(frame_i=frame_i, cca_df=cca_df, autosave=False) + + self.get_cca_df() + + def resetWillDivideInfo(self): + global_cca_df = self.getConcatCcaDf() + if global_cca_df is None: + return + + global_cca_df = self.view_model.cca_workflows.fix_will_divide_without_next_generation(global_cca_df) + self.storeFromConcatCcaDf(global_cca_df) + + def ccaCheckerStopChecking(self): + if not self.ccaCheckerRunning: + return + + self.ccaIntegrityCheckerWorker.clearQueue() + + if self.ccaIntegrityCheckerWorker.isChecking: + self.ccaIntegrityCheckerWorker.abortChecking = True + + def enqCcaIntegrityChecker(self): + if not self.ccaCheckerRunning: + return + posData = self.data[self.pos_i] + self.ccaIntegrityCheckerWorker.enqueue(posData) + + def resetCcaFuture(self, from_frame_i): + posData = self.data[self.pos_i] + self.last_cca_frame_i = from_frame_i-1 + self.ccaCheckerStopChecking() + + self.setNavigateScrollBarMaximum() + removal_result = self.view_model.cca_edits.remove_future_annotations( + posData.allData_li, + self.cca_df_colnames, + from_frame_i, + size_t=posData.SizeT, + concatenated_acdc_df=posData.acdc_df, + ) + for i in removal_result.cache_frame_indices: + posData.allData_li[i].pop('cca_df', None) + posData.allData_li[i].pop('cca_df_checker', None) + for i, acdc_df in removal_result.acdc_dfs_by_frame.items(): + posData.allData_li[i]['acdc_df'] = acdc_df + posData.acdc_df = removal_result.concatenated_acdc_df + + self.resetWillDivideInfo() + + def removeCcaAnnotationsCurrentFrame(self): + posData = self.data[self.pos_i] + posData.cca_df = None + + posData.allData_li[posData.frame_i].pop('cca_df', None) + posData.allData_li[posData.frame_i].pop('cca_df_checker', None) + + df = posData.allData_li[posData.frame_i]['acdc_df'] + result = self.view_model.cca_edits.remove_annotations( + df, self.cca_df_colnames + ) + if result.missing_frame or not result.removed: + return False + + posData.allData_li[posData.frame_i]['acdc_df'] = result.acdc_df + return True + + def resetFutureCcaColCurrentFrame(self): + posData = self.data[self.pos_i] + posData.cca_df = self.view_model.cca_edits.reset_future_flags( + posData.cca_df + ) + self.store_data() + + def get_cca_df(self, frame_i=None, return_df=False, debug=False): + # cca_df is None unless the metadata contains cell cycle annotations + # NOTE: cell cycle annotations are either from the current session + # or loaded from HDD in "initPosAttr" with a .question to the user + posData = self.data[self.pos_i] + i = posData.frame_i if frame_i is None else frame_i + df = posData.allData_li[i]['acdc_df'] + result = self.view_model.cca_edits.resolve_annotations( + df, + self.cca_df_colnames, + is_snapshot=self.isSnapshot, + snapshot_cell_ids=(obj.label for obj in posData.rp), + base_values=base_cca_dict, + tree_values=base_cca_tree_dict, + ) + cca_df = result.cca_df + + if return_df: + return cca_df + else: + posData.cca_df = cca_df + + def unstore_cca_df(self): + posData = self.data[self.pos_i] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + result = self.view_model.cca_edits.remove_annotations( + acdc_df, self.cca_df_colnames + ) + if result.acdc_df is not None: + posData.allData_li[posData.frame_i]['acdc_df'] = result.acdc_df + + def store_cca_df_checker(self, posData, frame_i, cca_df): + checker_cca_df = self.view_model.cca_edits.prepare_checker_annotations( + cca_df, + checker_running=self.ccaCheckerRunning, + ) + if checker_cca_df is None: + return + + posData.allData_li[frame_i]['cca_df_checker'] = checker_cca_df + + def store_cca_df( + self, pos_i=None, frame_i=None, cca_df=None, mainThread=True, + autosave=True, store_cca_df_copy=False + ): + pos_i = self.pos_i if pos_i is None else pos_i + posData = self.data[pos_i] + i = posData.frame_i if frame_i is None else frame_i + if cca_df is None: + cca_df = posData.cca_df + if self.ccaTableWin is not None and mainThread: + zoomIDs = self.exporting_view.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + acdc_df = posData.allData_li[i]['acdc_df'] + if acdc_df is None: + current_frame_i = None + if frame_i is not None and frame_i != posData.frame_i: + current_frame_i = posData.frame_i + posData.frame_i = frame_i + self.get_data() + self.store_data() + acdc_df = posData.allData_li[i]['acdc_df'] + if current_frame_i is not None: + # Back to current frame + posData.frame_i = current_frame_i + self.get_data(debug=False) + + store_result = self.view_model.cca_edits.store_frame_annotations( + acdc_df, + cca_df, + self.cca_df_colnames, + store_checker_copy=self.ccaCheckerRunning, + store_cca_df_copy=store_cca_df_copy, + ) + if store_result.acdc_df is not None: + posData.allData_li[i]['acdc_df'] = store_result.acdc_df + + # Store copy for cca integrity worker + if store_result.checker_cca_df is not None: + posData.allData_li[i]['cca_df_checker'] = ( + store_result.checker_cca_df + ) + + if store_result.cached_cca_df is not None: + posData.allData_li[i]['cca_df'] = store_result.cached_cca_df + + if autosave: + self.enqAutosave() + self.enqCcaIntegrityChecker() + + def viewCcaTable(self): + posData = self.data[self.pos_i] + zoomIDs = self.exporting_view.getZoomIDs() + + df = posData.allData_li[posData.frame_i]['acdc_df'] + current_cca_df = posData.cca_df + if zoomIDs is not None: + df = df.loc[zoomIDs] + current_cca_df = current_cca_df.loc[zoomIDs] + + for column in current_cca_df.columns: + header = ( + '================================================\n' + f'CURRENT vs STORED `{column}` column' + f'for frame number {posData.frame_i+1}:\n' + ) + df_compare = current_cca_df[[column]].copy() + df_compare[f'STORED_{column}'] = df[column] + text = f'{header}{df_compare}' + self.logger.info(text) + + if self.view_model.cca_edits.has_annotations(df): + cca_df = df[self.cca_df_colnames] + cca_df = cca_df.merge( + current_cca_df, how='outer', left_index=True, right_index=True, + suffixes=('_STORED', '_CURRENT') + ) + cca_df = cca_df.reindex(sorted(cca_df.columns), axis=1) + num_cols = len(cca_df.columns) + for j in range(0,num_cols,2): + df_j_x = cca_df.iloc[:,j] + df_j_y = cca_df.iloc[:,j+1] + if any(df_j_x!=df_j_y): + self.logger.info('------------------------') + self.logger.info('DIFFERENCES:') + diff_df = cca_df.iloc[:,j:j+2] + diff_mask = diff_df.iloc[:,0]!=diff_df.iloc[:,1] + self.logger.info(diff_df[diff_mask]) + else: + cca_df = None + self.logger.info(cca_df) + self.logger.info('========================') + if current_cca_df is None: + return + if current_cca_df.empty: + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Cell cycle annotations\' table is empty.
' + ) + msg.warning(self.host, 'Table empty', txt) + return + + df = posData.add_tree_cols_to_cca_df( + current_cca_df, frame_i=posData.frame_i + ) + if self.ccaTableWin is None: + self.ccaTableWin = apps.ViewCcaTableWindow(df, parent=self.host) + self.ccaTableWin.show() + self.ccaTableWin.setGeometryWindow() + self.ccaTableWin.sigUpdateCcaTable.connect( + self.exporting_view.onSigUpdateCcaTableWindow + ) + else: + self.ccaTableWin.setFocus() + self.ccaTableWin.activateWindow() + self.ccaTableWin.updateTable(current_cca_df) + + def autoAssignBud_YeastMate(self): + if not self.is_win: + txt = ( + 'YeastMate is available only on Windows OS.' + 'We are working on expading support also on macOS and Linux.\n\n' + 'Thank you for your patience!' + ) + msg = QMessageBox() + msg.critical( + self.host, 'Supported only on Windows', txt, msg.Ok + ) + return + + model_name = 'YeastMate' + idx = self.modelNames.index(model_name) + + self.titleLabel.setText( + f'{model_name} is thinking... ' + '(check progress in terminal/console)', color=self.titleColor + ) + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + posData = self.data[self.pos_i] + # Check if model needs to be imported + acdcSegment = self.acdcSegment_li[idx] + if acdcSegment is None: + acdcSegment = ( + self.view_model.model_registry.import_segmentation_module( + model_name + ) + ) + self.acdcSegment_li[idx] = acdcSegment + + # Read all models parameters + init_params, segment_params = ( + self.view_model.model_registry.model_arg_specs(acdcSegment) + ) + # Prompt user to enter the model parameters + try: + url = acdcSegment.url_help() + except AttributeError: + url = None + + _SizeZ = None + if self.isSegm3D: + _SizeZ = posData.SizeZ + win = apps.QDialogModelParams( + init_params, + segment_params, + model_name, + url=url, + posData=posData, + df_metadata=posData.metadata_df + ) + win.exec_() + if win.cancel: + self.titleLabel.setText('Segmentation aborted.') + return + + use_gpu = win.init_kwargs.get('gpu', False) + proceed = self.view_model.model_registry.check_gpu_available( + model_name, use_gpu, qparent=self.host + ) + if not proceed: + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return + + self.model_kwargs = win.model_kwargs + model = self.view_model.model_registry.init_segmentation_model( + acdcSegment, posData, win.init_kwargs + ) + if model is None: + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return + try: + model.setupLogger(self.logger) + except Exception as e: + pass + + self.models[idx] = model + + img = self.getDisplayedImg1() + + posData.cca_df = model.predictCcaState(img, posData.lab) + self.store_data() + self.updateAllImages() + + self.titleLabel.setText('Budding event prediction done.', color='g') + + def reInitCca(self): + if not self.isSnapshot: + txt = html_utils.paragraph( + 'If you decide to continue ALL cell cycle annotations from ' + 'this frame to the end will be erased from current session ' + '(saved data is not touched of course).

' + 'To annotate future frames again you will have to revisit them.

' + 'Do you want to continue?' + ) + msg = widgets.myMessageBox() + msg.warning( + self.host, 'Re-initialize annnotations?', txt, + buttonsTexts=('Cancel', 'Yes') + ) + posData = self.data[self.pos_i] + if msg.cancel: + return + + # Reset all future frames + self.resetCcaFuture(posData.frame_i+1) + if posData.frame_i == 0: + # Reset everything since we are on first frame + posData.cca_df = self.getBaseCca_df() + self.store_data() + self.updateAllImages() + self.navigateScrollBar.setMaximum(posData.frame_i+1) + self.navSpinBox.setMaximum(posData.frame_i+1) + else: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + posData = self.data[self.pos_i] + posData.cca_df = self.getBaseCca_df() + self.store_data() + self.updateAllImages() + + + def repeatAutoCca(self): + # Do not allow automatic bud assignment if there are future + # frames that already contain anotations + posData = self.data[self.pos_i] + next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] + if self.view_model.cca_edits.has_annotations(next_df): + msg = QMessageBox() + warn_cca = msg.critical( + self.host, 'Future visited frames detected!', + 'Automatic bud assignment CANNOT be performed becasue ' + 'there are future frames that already contain cell cycle ' + 'annotations. The behaviour in this case cannot be predicted.\n\n' + 'We suggest assigning the bud manually OR use the ' + '"Re-initialize cell cycle annotations" button which properly ' + 're-initialize future frames.', + msg.Ok + ) + return + + correctedAssignIDs = ( + posData.cca_df[posData.cca_df['corrected_on_frame_i']>=0].index + ) + NeverCorrectedAssignIDs = [ + ID for ID in posData.new_IDs if ID not in correctedAssignIDs + ] + + # Store cca_df temporarily if attempt_auto_cca fails + posData.cca_df_beforeRepeat = posData.cca_df.copy() + + if not all(NeverCorrectedAssignIDs): + notEnoughG1Cells, proceed = self.attempt_auto_cca() + if notEnoughG1Cells or not proceed: + posData.cca_df = posData.cca_df_beforeRepeat + else: + self.updateAllImages() + return + + msg = QMessageBox() + msg.setIcon(msg.Question) + msg.setText( + 'Do you want to automatically assign buds to mother cells for ' + 'ALL the new cells in this frame (excluding cells with unknown history) ' + 'OR only the cells where you never clicked on?' + ) + msg.setDetailedText( + f'New cells that you never touched:\n\n{NeverCorrectedAssignIDs}') + enforceAllButton = QPushButton('ALL new cells') + b = QPushButton('Only cells that I never corrected assignment') + msg.addButton(b, msg.YesRole) + msg.addButton(enforceAllButton, msg.NoRole) + msg.exec_() + if msg.clickedButton() == enforceAllButton: + notEnoughG1Cells, proceed = self.attempt_auto_cca(enforceAll=True) + else: + notEnoughG1Cells, proceed = self.attempt_auto_cca() + if notEnoughG1Cells or not proceed: + posData.cca_df = posData.cca_df_beforeRepeat + else: + self.updateAllImages() + + def manualEditCcaToolbarActionTriggered(self): + self.manualEditCca() + + def manualEditCca(self, checked=True): + posData = self.data[self.pos_i] + editCcaWidget = apps.editCcaTableWidget( + posData.cca_df, posData.SizeT, current_frame_i=posData.frame_i, + parent=self.host + ) + editCcaWidget.sigApplyChangesFutureFrames.connect( + self.applyManualCcaChangesFutureFrames + ) + editCcaWidget.exec_() + if editCcaWidget.cancel: + return + posData.cca_df = editCcaWidget.cca_df + self.store_cca_df() + # self.checkMultiBudMoth() + self.updateAllImages() + + @exception_handler + def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i): + self.store_data(autosave=False) + posData = self.data[self.pos_i] + undoId = uuid.uuid4() + for i in range(posData.frame_i, stop_frame_i): + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + self.storeUndoRedoCca(i, cca_df_i, undoId) + + cca_df_i = self.view_model.cca_edits.apply_manual_changes( + cca_df_i, changes + ) + self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) + self.get_data() + self.updateAllImages() + + def ccaCheckerWorkerDone(self): + self.status_hover_view.set_status_bar_label(log=False) + + def goToFrameNumber(self, frame_n): + posData = self.data[self.pos_i] + posData.frame_i = frame_n - 1 + self.get_data() + self.updateAllImages() + self.updateScrollbars() + + def warnCcaIntegrity(self, txt, category): + self.logger.warning(f'{html_utils.to_plain_text(txt)}') + + if 'disable_all' in self.disabled_cca_warnings: + return + + if category in self.disabled_cca_warnings: + return + + if txt in self.disabled_cca_warnings: + return + + if self.isWarningCcaIntegrity: + # Some other warning is still open --> avoid opening another one + return + + self.isWarningCcaIntegrity = True + disabled_warning = _warnings.warn_cca_integrity( + txt, category, self.host, + go_to_frame_callback=self.goToFrameNumber + ) + if disabled_warning: + self.disabled_cca_warnings.add(disabled_warning) + + self.isWarningCcaIntegrity = False + + def fixWillDivide(self, warning_txt, IDs_will_divide_wrong): + self.logger.info(warning_txt) + self.logger.info('Fixing `will_divide` information...') + + global_cca_df = self.getConcatCcaDf() + global_cca_df = self.view_model.cca_workflows.reset_will_divide_for_generations( + global_cca_df, + IDs_will_divide_wrong, + ) + self.storeFromConcatCcaDf(global_cca_df) + + def ccaCheckerWorkerClosed(self, worker): + self.logger.info('Cell cycle annotations integrity checker stopped.') + self.ccaCheckerRunning = False + + def updateIsHistoryKnown(): + """ + This function is called every time the user saves and it is used + for updating the status of cells where we don't know the history + + There are three possibilities: + + 1. The cell with unknown history is a BUD + --> we don't know when that bud emerged --> 'emerg_frame_i' = -1 + 2. The cell with unknown history is a MOTHER cell + --> we don't know emerging frame --> 'emerg_frame_i' = -1 + AND generation number --> we start from 'generation_num' = 2 + 3. The cell with unknown history is a CELL in G1 + --> we don't know emerging frame --> 'emerg_frame_i' = -1 + AND generation number --> we start from 'generation_num' = 2 + AND relative's ID in the previous cell cycle --> 'relative_ID' = -1 + """ + pass + + def annotateIsHistoryKnown(self, ID): + """ + This function is used for annotating that a cell has unknown or known + history. Cells with unknown history are for example the cells already + present in the first frame or cells that appear in the frame from + outside of the field of view. + + With this function we simply set 'is_history_known' to False. + When the users saves instead we update the entire staus of the cell + with unknown history with the function "updateIsHistoryKnown()" + """ + posData = self.data[self.pos_i] + is_history_known = posData.cca_df.at[ID, 'is_history_known'] + relID = posData.cca_df.at[ID, 'relative_ID'] + relID_cca = None + if relID in posData.cca_df.index: + relID_cca = self.view_model.cca_workflows.previous_relative_status_before_bud_emergence( + ID, + relID, + ( + (i, self.get_cca_df(frame_i=i, return_df=True)) + for i in range(posData.frame_i-1, -1, -1) + ), + self.view_model.cca_workflows.base_status(base_cca_dict), + ) + + if is_history_known: + # Save status of ID when emerged to allow undoing + statusID_whenEmerged = self.view_model.cca_workflows.known_history_status_for_bud( + ID, + ( + (i, self.get_cca_df(frame_i=i, return_df=True)) + for i in range(posData.frame_i-1, -1, -1) + ), + self.view_model.cca_workflows.base_status(base_cca_dict), + ) + if statusID_whenEmerged is None: + return + posData.ccaStatus_whenEmerged[ID] = statusID_whenEmerged + + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + if ID not in posData.ccaStatus_whenEmerged: + self.warnSettingHistoryKnownCellsFirstFrame(ID) + return + + future_cca_frames = ( + (i, self.get_cca_df(frame_i=i, return_df=True)) + for i in range(posData.frame_i+1, posData.SizeT) + ) + past_cca_frames = ( + (i, self.get_cca_df(frame_i=i, return_df=True)) + for i in range(posData.frame_i-1, -1, -1) + ) + propagation_result = self.view_model.cca_workflows.propagate_history_knowledge( + posData.cca_df, + posData.frame_i, + ID, + future_cca_frames=future_cca_frames, + past_cca_frames=past_cca_frames, + status_when_emerged=posData.ccaStatus_whenEmerged.get(ID), + relative_id=relID, + relative_status=relID_cca, + ) + posData.cca_df = propagation_result.current_cca_df + + # Update cell cycle info LabelItems + obj_idx = posData.IDs.index(ID) + rp_ID = posData.rp[obj_idx] + + if relID in posData.IDs: + relObj_idx = posData.IDs.index(relID) + rp_relID = posData.rp[relObj_idx] + + self.setAllTextAnnotations() + self.drawAllMothBudLines() + + self.store_cca_df() + + if self.ccaTableWin is not None: + zoomIDs = self.exporting_view.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + for frame_i in propagation_result.undo_frame_indices: + cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) + self.storeUndoRedoCca(frame_i, cca_df_i, undoId) + + for frame_i, cca_df_i in propagation_result.updated_cca_dfs_by_frame.items(): + if frame_i == posData.frame_i: + continue + self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) + + self.enqAutosave() + + def annotateWillDivide(self, ID, relID, frame_i=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + past_cca_frames = ( + (i, self.get_cca_df(frame_i=i, return_df=True)) + for i in range(frame_i-1, -1, -1) + ) + propagation_result = self.view_model.cca_workflows.propagate_will_divide( + None, + frame_i, + ID, + relID, + past_cca_frames=past_cca_frames, + ) + for past_frame_i, cca_df_i in ( + propagation_result.updated_cca_dfs_by_frame.items() + ): + self.store_cca_df( + cca_df=cca_df_i, + frame_i=past_frame_i, + autosave=False, + ) + + def annotateDivision(self, cca_df, ID, relID, frame_i=None): + # Correct as follows: + # For frame_i > 0 --> assign to G1 and +1 on generation number + # For frame == 0 --> reinitialize to unknown cells + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + self.annotateWillDivide(ID, relID) + return self.view_model.cca_workflows.annotate_division(cca_df, ID, relID, frame_i) + + def undoDivisionAnnotation(self, cca_df, ID, relID): + # Correct as follows: + # If G1 then correct to S and -1 on generation number + return self.view_model.cca_workflows.undo_division_annotation(cca_df, ID, relID) + + def undoBudMothAssignment(self, ID): + posData = self.data[self.pos_i] + changed = self.view_model.cca_workflows.undo_bud_mother_assignment(posData.cca_df, ID) + if not changed: + return + + self.store_cca_df() + + # Update cell cycle info LabelItems + self.setAllTextAnnotations() + + if self.ccaTableWin is not None: + zoomIDs = self.exporting_view.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + @exception_handler + def manualCellCycleAnnotation(self, ID): + """ + This function is used for both annotating division or undoing the + annotation. It can be called on any frame. + + If we annotate division (right click on a cell in S) then it will + check if there are future frames to correct. + Frames to correct are those frames where both the mother and the bud + are annotated as S phase cells. + In this case we assign all those frames to G1, relationship to mother, + and +1 generation number + + If we undo the annotation (right click on a cell in G1) then it will + correct both past and future annotated frames (if present). + Frames to correct are those frames where both the mother and the bud + are annotated as G1 phase cells. + In this case we assign all those frames to G1, relationship back to + bud, and -1 generation number + """ + posData = self.data[self.pos_i] + + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + # Correct current frame + clicked_ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] + relID = posData.cca_df.at[ID, 'relative_ID'] + + if relID not in posData.IDs: + return + + if clicked_ccs == 'G1' and posData.frame_i == 0: + # We do not allow undoing division annotation on first frame + return + + if clicked_ccs == 'G1': + issue_frame_i = self.checkDivisionCanBeUndone(ID, relID) + if issue_frame_i is not None: + _warnings.warnDivisionAnnotationCannotBeUndone( + ID, relID, issue_frame_i, qparent=self.host + ) + return + + future_cca_frames = ( + (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) + for frame_i in range(posData.frame_i + 1, posData.SizeT) + ) + past_cca_frames = ( + (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) + for frame_i in range(posData.frame_i - 1, -1, -1) + ) + propagation_result = self.view_model.cca_workflows.propagate_manual_division_annotation( + None, + posData.frame_i, + ID, + current_cca_df=posData.cca_df, + future_cca_frames=future_cca_frames, + past_cca_frames=past_cca_frames, + ) + posData.cca_df = propagation_result.current_cca_df + self.store_cca_df() + + # Update cell cycle info LabelItems + self.ax1_newMothBudLinesItem.setData([], []) + self.ax1_oldMothBudLinesItem.setData([], []) + self.ax2_newMothBudLinesItem.setData([], []) + self.ax2_oldMothBudLinesItem.setData([], []) + self.drawAllMothBudLines() + self.setAllTextAnnotations() + + if self.ccaTableWin is not None: + zoomIDs = self.exporting_view.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + for frame_i in propagation_result.undo_frame_indices: + cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) + self.storeUndoRedoCca(frame_i, cca_df_i, undoId) + + for frame_i, cca_df_i in propagation_result.updated_cca_dfs_by_frame.items(): + if frame_i == posData.frame_i: + continue + self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) + + self.enqAutosave() + + def warnMotherNotEligible(self, new_mothID, budID, i, why): + if why == 'not_G1_in_the_future': + err_msg = html_utils.paragraph(f""" + The requested cell in G1 (ID={new_mothID}) + at future frame {i+1} has a bud assigned to it, + therefore it cannot be assigned as the mother + of bud ID {budID}.

+ You can assign a cell as the mother of bud ID {budID} + only if this cell is in G1 for the + entire life of the bud.

+ One possible solution is to click on "cancel", go to + frame {i+1} and assign the bud of cell {new_mothID} + to another cell.\n' + A second solution is to assign bud ID {budID} to cell + {new_mothID} anyway by clicking "Apply".

+ However to ensure correctness of + future assignments Cell-ACDC will delete any cell cycle + information from frame {i+1} to the end. Therefore, you + will have to visit those frames again.

+ The deletion of cell cycle information + CANNOT BE UNDONE! + Saved data is not changed of course.

+ Apply assignment or cancel process? + """) + applyButton = widgets.okPushButton(isDefault=False) + applyButton.setText('Apply and remove future annotations') + msg = widgets.myMessageBox() + _, applyButton = msg.warning( + self.host, 'Cell not eligible', err_msg, + buttonsTexts=('Cancel', applyButton) + ) + cancel = msg.cancel + apply = msg.clickedButton == applyButton + elif why == 'not_G1_in_the_past': + err_msg = html_utils.paragraph(f""" + The requested cell in G1 + (ID={new_mothID}) at past frame {i+1} + has a bud assigned to it, therefore it cannot be + assigned as mother of bud ID {budID}.
+ You can assign a cell as the mother of bud ID {budID} + only if this cell is in G1 for the entire life of the bud.
+ One possible solution is to first go to frame {i+1} and + assign the bud of cell {new_mothID} to another cell. + """) + msg = widgets.myMessageBox() + msg.warning( + self.host, 'Cell not eligible', err_msg + ) + cancel = msg.cancel + apply = False + elif why == 'single_frame_G1_duration': + err_msg = html_utils.paragraph(f""" + Assigning bud ID {budID} to cell ID {new_mothID} would result + in no G1 phase at all between previous cell cycle and + current cell cycle (see frame n. {i+1}).

+ + The solution is to annotate division on cell ID {new_mothID} + on any frame before the frame number {i+1}, and then + proceed to correcting the bud assignment.

+ + This will gurantee a G1 duration for the cell {new_mothID} + of at least 1 frame.

+ Thank you for your patience! + """) + msg = widgets.myMessageBox() + msg.warning( + self.host, 'Cell not eligible', err_msg + ) + cancel = msg.cancel + apply = False + return cancel, apply + + def warnSettingHistoryKnownCellsFirstFrame(self, ID): + txt = html_utils.paragraph(f""" + Cell ID {ID} is a cell that is present since the first + frame.

+ These cells already have history UNKNOWN assigned and the + history status cannot be changed. + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning( + self.host, 'First frame cells', txt + ) + + def checkMothEligibility(self, budID, new_mothID): + """ + Check that the new mother is in G1 for the entire life of the bud + and that the G1 duration is > than 1 frame + """ + last_cca_frame_i = self.navigateScrollBar.maximum()-1 + posData = self.data[self.pos_i] + future_cca_frames = ( + (future_i, self.get_cca_df(frame_i=future_i, return_df=True)) + for future_i in range(posData.frame_i, posData.SizeT) + ) + past_cca_frames = ( + (past_i, self.get_cca_df(frame_i=past_i, return_df=True)) + for past_i in range(posData.frame_i-1, -1, -1) + ) + result = self.view_model.cca_workflows.mother_assignment_eligibility( + budID, + new_mothID, + future_cca_frames, + past_cca_frames, + last_cca_frame_i, + ) + if result.future_issue is not None: + issue = result.future_issue + cancel, apply = self.warnMotherNotEligible( + new_mothID, budID, issue.frame_i, issue.reason + ) + if apply: + self.resetCcaFuture(issue.frame_i) + elif cancel or issue.blocks_assignment: + return False + + if result.past_issue is not None: + issue = result.past_issue + self.warnMotherNotEligible( + new_mothID, budID, issue.frame_i, issue.reason + ) + return False + + return True + + def checkMothersExcludedOrDead(self): + try: + posData = self.data[self.pos_i] + acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] + + buds_df = posData.cca_df[ + (posData.cca_df.relationship == 'bud') + & (posData.cca_df.emerg_frame_i == posData.frame_i) + ] + mother_ids = buds_df.relative_ID.to_list() if not buds_df.empty else [] + excluded_mother_ids = self.view_model.check_mothers_exclusion_or_dead( + acdc_df_i, mother_ids + ) + + if not excluded_mother_ids: + self.stopBlinkingPairItem() + return True + + bud_ids = [] + for m_id in excluded_mother_ids: + b_id = buds_df[buds_df.relative_ID == m_id].index.tolist()[0] + bud_ids.append(b_id) + + proceed = self.warnDeadOrExcludedMothers( + bud_ids, excluded_mother_ids + ) + return proceed + except Exception as e: + self.logger.info(traceback.format_exc()) + print('-'*100) + self.logger.warning( + 'Checking if mother cell is excluded or dead failed.' + ) + print('^'*100) + return False + + def checkDivisionCanBeUndone(self, ID, relID): + """Check that division annotation can be undone (see Notes section) + + Parameters + ---------- + ID : int + Cell ID of the clicked cell in G1 + relID : _type_ + Relative ID of the cell that was clicked + + Notes + ----- + Division annotation can be undone only if `relID` is also in G1 for the + entire duration of the correction + """ + posData = self.data[self.pos_i] + future_cca_frames = ( + (future_i, self.get_cca_df(frame_i=future_i, return_df=True)) + for future_i in range(posData.frame_i+1, posData.SizeT) + ) + past_cca_frames = ( + (past_i, self.get_cca_df(frame_i=past_i, return_df=True)) + for past_i in range(posData.frame_i-1, -1, -1) + ) + return self.view_model.cca_workflows.division_undo_blocking_frame( + ID, + relID, + posData.frame_i, + posData.cca_df, + future_cca_frames=future_cca_frames, + past_cca_frames=past_cca_frames, + ) + + + def stopBlinkingPairItem(self): + self.ax1_newMothBudLinesItem.setOpacity(1.0) + self.ax1_oldMothBudLinesItem.setOpacity(1.0) + + self.warnPairingItem.setData([], []) + try: + self.blinkPairingItemTimer.stop() + except Exception as e: + pass + + def warnDeadOrExcludedMothers(self, budIDs, mothIDs): + self.startBlinkingPairingItem(budIDs, mothIDs) + msg = widgets.myMessageBox(wrapText=False) + pairings = [ + f'Mother ID {mID} --> bud ID {bID}' + for mID, bID in zip(mothIDs, budIDs) + ] + txt = html_utils.paragraph(f""" + The mother cell in the following mother-bud pairings + (blinking line on the image) is
+ excluded from the analysis or dead: + {html_utils.to_list(pairings)} + """) + msg.warning( + self.host, 'Mother cell is excluded or dead', txt, + buttonsTexts=('Cancel', 'Ok') + ) + return not msg.cancel + + def startBlinkingPairingItem(self, budIDs, mothIDs): + self.ax1_newMothBudLinesItem.setOpacity(0.2) + self.ax1_oldMothBudLinesItem.setOpacity(0.2) + + posData = self.data[self.pos_i] + acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] + + # Blink one pairing at the time (the first found) + xc_b = acdc_df_i.loc[budIDs[0], 'x_centroid'] + yc_b = acdc_df_i.loc[budIDs[0], 'y_centroid'] + + xc_m = acdc_df_i.loc[mothIDs[0], 'x_centroid'] + yc_m = acdc_df_i.loc[mothIDs[0], 'y_centroid'] + + self.warnPairingItem.setData([xc_b, xc_m], [yc_b, yc_m]) + + self.blinkPairingItemTimer = QTimer() + self.blinkPairingItemTimer.flag = True + self.blinkPairingItemTimer.timeout.connect(self.blinkPairingItem) + self.blinkPairingItemTimer.start(300) + + def blinkPairingItem(self): + if self.blinkPairingItemTimer.flag: + opacity = 0.3 + self.blinkPairingItemTimer.flag = False + else: + opacity = 1.0 + self.blinkPairingItemTimer.flag = True + self.warnPairingItem.setOpacity(opacity) + + def annotateBudToDifferentMother(self): + """ + This function is used for correcting automatic mother-bud assignment. + + It can be called at any frame of the bud life. + + There are three cells involved: bud, current mother, new mother. + + Eligibility: + - User clicked first on a bud (checked at click time) + - User released mouse button on a cell in G1 (checked at release time) + - The new mother MUST be in G1 for all the frames of the bud life + --> if not warn + - The new mother MUST have appeared in current frame OR be already + in G1 in previous frame, otherwise there would be no G1 cycle + + Result: + - The bud only changes relative ID to the new mother + - The new mother changes relative ID and stage to 'S' + - The old mother changes its entire status to the status it had + before being assigned to the clicked bud + """ + posData = self.data[self.pos_i] + lab2D = self.get_2Dlab(posData.lab) + budID = lab2D[self.yClickBud, self.xClickBud] + new_mothID = lab2D[self.yClickMoth, self.xClickMoth] + + if budID == new_mothID: + return + + if not self.isSnapshot: + eligible = self.checkMothEligibility(budID, new_mothID) + if not eligible: + return + + budEligible = self.checkChangeMotherBudEligible( + budID, posData.frame_i + ) + if not budEligible: + return + + # Allow partial initialization of cca_df with mouse + if posData.frame_i == 0: + newMothCcs = posData.cca_df.at[new_mothID, 'cell_cycle_stage'] + if not newMothCcs == 'G1': + err_msg = ( + 'You are assigning the bud to a cell that is not in G1!' + ) + msg = QMessageBox() + msg.critical( + self.host, 'New mother not in G1!', err_msg, msg.Ok + ) + return + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(0, posData.cca_df, undoId) + propagation_result = self.view_model.cca_workflows.propagate_bud_mother_assignment( + posData.cca_df, + posData.frame_i, + budID, + new_mothID, + ) + posData.cca_df = propagation_result.current_cca_df + self.updateAllImages() + self.store_cca_df() + return + + curr_moth_cca = None + curr_mothID = posData.cca_df.at[budID, 'relative_ID'] + if curr_mothID in posData.cca_df.index: + curr_moth_cca = self.view_model.cca_workflows.previous_relative_status_before_bud_emergence( + budID, + curr_mothID, + ( + (i, self.get_cca_df(frame_i=i, return_df=True)) + for i in range(posData.frame_i-1, -1, -1) + ), + self.view_model.cca_workflows.base_status(base_cca_dict), + ) + + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + future_cca_frames = ( + (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) + for frame_i in range(posData.frame_i + 1, posData.SizeT) + ) + past_cca_frames = ( + (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) + for frame_i in range(posData.frame_i - 1, -1, -1) + ) + propagation_result = self.view_model.cca_workflows.propagate_bud_mother_assignment( + posData.cca_df, + posData.frame_i, + budID, + new_mothID, + future_cca_frames=future_cca_frames, + past_cca_frames=past_cca_frames, + previous_mother_status=curr_moth_cca, + ) + posData.cca_df = propagation_result.current_cca_df + + self.updateAllImages() + + # self.checkMultiBudMoth(draw=True) + self.store_cca_df() + proceed = self.checkMothersExcludedOrDead() + if not proceed: + # User clicked on cancel in the message box + self.UndoCca() + return + + if self.ccaTableWin is not None: + zoomIDs = self.exporting_view.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + for frame_i in propagation_result.undo_frame_indices: + cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) + self.storeUndoRedoCca(frame_i, cca_df_i, undoId) + + for frame_i, cca_df_i in propagation_result.updated_cca_dfs_by_frame.items(): + if frame_i == posData.frame_i: + continue + self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) + + self.enqAutosave() + + def onMotherNotInG1(self, mothID): + txt = html_utils.paragraph( + f'You clicked on ID={mothID} which is NOT in G1

' + 'Do you want to proceed with swapping the mother cells?

' + 'NOTE: To assign a bud start by clicking on the bud ' + 'and release on a cell in G1' + ) + msg = widgets.myMessageBox() + swapMothersButton = widgets.reloadPushButton('Swap mother cells') + _, swapMothersButton = msg.warning( + self.host, 'Released on a cell NOT in G1', txt, + buttonsTexts=('Cancel', swapMothersButton) + ) + if msg.cancel: + return + + pairings = self.checkSwapMothersEligibility() + if pairings is None: + self.logger.info('Swapping mothers is not possible.') + return + + self.swapMothers(*pairings) + + def warnBudAnnotatedDividedInFuture( + self, budID, motherID, future_division_frame_i, + action='swap mother cells' + ): + posData = self.data[self.pos_i] + + txt = html_utils.paragraph(f""" + Bud ID {budID} is annotated as divided from mother ID {motherID} + at frame n. {future_division_frame_i+1},
+ therefore it is not possible to {action}.

+ We recommend reinitializing cell cycle annotations on any + frame
between frames number {posData.frame_i+1} and + {future_division_frame_i} before attempting to {action}.

+ Thank you for your patience! + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self.host, f'{action} not possible'.title(), txt) + return + + def warnMotherNotAtLeastOneFrameG1(self, budID, motherID, frame_no_G1): + posData = self.data[self.pos_i] + + txt = html_utils.paragraph(f""" + Assigning bud ID {budID} to cell ID {motherID} cannot be + done because cell ID {motherID} is not in G1 at frame n. + {frame_no_G1}.

+ This would result in no G1 phase between previous cell cycle of + cell ID {motherID} and current one. + This is unfortunately not allowed.

+ One possible solution is to annotate division on cell ID + {motherID} on any frame before frame n. {frame_no_G1}.

+ Thank you for your patience! + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self.host, 'Swap mothers not possible', txt) + return + + def checkChangeMotherBudEligible(self, budID, frame_i): + posData = self.data[self.pos_i] + future_cca_frames = ( + (future_i, self.get_cca_df(frame_i=future_i, return_df=True)) + for future_i in range(frame_i, posData.SizeT) + ) + result = self.view_model.cca_workflows.bud_mother_change_eligibility(budID, future_cca_frames) + if result.can_change: + return True + + future_division = result.future_division + self.warnBudAnnotatedDividedInFuture( + budID, + future_division.mother_id, + future_division.frame_i, + action='change mother cell', + ) + return False + + def checkSwapMothersEligibility(self): + posData = self.data[self.pos_i] + + lab2D = self.get_2Dlab(posData.lab) + budID = lab2D[self.yClickBud, self.xClickBud] + otherMothID = lab2D[self.yClickMoth, self.xClickMoth] + mothID = posData.cca_df.at[budID, 'relative_ID'] + otherBudID = posData.cca_df.at[otherMothID, 'relative_ID'] + + future_cca_frames = [ + (future_i, self.get_cca_df(frame_i=future_i, return_df=True)) + for future_i in range(posData.frame_i, posData.SizeT) + ] + past_cca_frames = [ + (past_i, self.get_cca_df(frame_i=past_i, return_df=True)) + for past_i in range(posData.frame_i, -1, -1) + ] + result = self.view_model.cca_workflows.swap_mothers_eligibility( + budID, + otherBudID, + otherMothID, + mothID, + future_cca_frames, + past_cca_frames, + ) + if result.future_division_frame_i is not None: + self.warnBudAnnotatedDividedInFuture( + result.future_division_bud_id, + result.future_division_mother_id, + result.future_division_frame_i, + ) + return + + if result.mother_not_g1_frame_i is not None: + self.warnMotherNotAtLeastOneFrameG1( + result.mother_not_g1_bud_id, + result.mother_not_g1_mother_id, + result.mother_not_g1_frame_i, + ) + return + + return budID, otherBudID, otherMothID, mothID + + @exception_handler + def swapMothers(self, budID, otherBudID, otherMothID, mothID): + posData = self.data[self.pos_i] + + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + self.logger.info( + f'Swapping assignments (requested at frame n. {posData.frame_i+1}):\n' + f' * Bud ID {budID} --> mother ID {otherMothID}\n' + f' * Bud ID {otherBudID} --> mother ID {mothID}' + ) + + past_cca_frames = [ + (past_i, self.get_cca_df(frame_i=past_i, return_df=True)) + for past_i in range(posData.frame_i-1, -1, -1) + ] + future_cca_frames = [ + (future_i, self.get_cca_df(frame_i=future_i, return_df=True)) + for future_i in range(posData.frame_i+1, posData.SizeT) + ] + propagation_result = self.view_model.cca_workflows.propagate_swap_mothers_assignment( + posData.cca_df, + posData.frame_i, + budID, + otherBudID, + otherMothID, + mothID, + past_cca_frames=past_cca_frames, + future_cca_frames=future_cca_frames, + base_status=self.view_model.cca_workflows.base_status(base_cca_dict), + ) + posData.cca_df = propagation_result.current_cca_df + self.store_cca_df() + + for frame_i, cca_df_i in propagation_result.updated_cca_dfs_by_frame.items(): + if frame_i == posData.frame_i: + continue + self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) + + self.updateAllImages() diff --git a/cellacdc/views/combine_view.py b/cellacdc/views/combine_view.py new file mode 100644 index 000000000..f4fe42e11 --- /dev/null +++ b/cellacdc/views/combine_view.py @@ -0,0 +1,646 @@ +"""Qt view adapter for the Combine Channels feature.""" + +from __future__ import annotations + +from typing import List, Dict, Any, Tuple +from qtpy.QtCore import QThread, QTimer, QMutex, QWaitCondition +from natsort import natsorted +import numpy as np + +from cellacdc import core, workers, widgets, html_utils, apps, preprocess, myutils, printl +from cellacdc.viewmodels.combine_viewmodel import CombineViewModel + + +class CombineView: + """Qt-facing adapter for the Combine Channels feature.""" + + LEGACY_METHODS = ( + '_setup_vars_combine', + 'combineDialogSaveCombinedData', + 'combineDialogStepsChanged', + 'updateCombineChannelsPreview', + 'viewCombineChannelDataToggled', + 'setupCombiningChannels', + 'combineDialogClosed', + '_combineDialogClosed', + 'combineViewAsSegmSetup', + 'combineChannelsActionTriggered', + 'combineEnqueueCurrentImage', + 'combinePreviewToggled', + 'combinePreviewViewAsSegmToggled', + 'combineCurrentImage', + 'combineZStack', + 'combineAllFrames', + 'combineAllPos', + 'stopCombineWorker', + 'combineWorkerCritical', + 'combineWorkerIsQueueEmpty', + 'combineWorkerPreviewDone', + 'combineWorkerAskLoadChannels', + 'combineWorkerDone', + 'combineWorkerClosed', + 'saveCombinedChannelsWorkerFinished', + 'saveCombineWorkerFinished', + ) + + def __init__(self, host, view_model: CombineViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def _setup_vars_combine(self): + self.combineWorker = None + self.combineDialog = None + self.combineSegmViewToggle = None + + def combineDialogSaveCombinedData(self, dialog): + posData = self.data[self.pos_i] + + try: + posData.combinedChannelsDataArray() + except TypeError as e: + if 'Not all frames have been processed.' in str(e): + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Not all frames have been processed.
' + 'Please process all frames before saving.' + ) + msg.warning(self, 'Process all data before saving', txt) + return + + helpText = ( + """ + The segm/img file will be saved with a different + file name.

+ Insert a name to append to the end of the new file name. The rest of + the name will be the same as the original file base. + """ + ) + hintText = 'Insert a name for the combined channels file:' + basename = posData.basename + if self.combineDialog.saveAsSegm(): + ext = '.npz' + hintText = hintText.replace('channels', 'segmentation') + helpText = helpText.replace('channels', 'segmentation') + basename = f'{basename}segm' + else: + ext = '.tif' + + win = apps.filenameDialog( + basename=basename, + ext=ext, + hintText=hintText, + defaultEntry='combined', + helpText=helpText, + allowEmpty=False, + parent=dialog + ) + win.exec_() + if win.cancel: + return + + appendedText = win.entryText + if appendedText: + filename = f'{basename}_{appendedText}{ext}' + else: + filename = f'{basename}{ext}' + + self.progressWin = apps.QDialogWorkerProgress( + title='Saving combined channels(s)', + parent=self, + pbarDesc='Saving combined channels(s)' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + self.statusBarLabel.setText('Saving combined channels...') + + self.saveCombinedChannelsWorker = workers.SaveCombinedChannelsWorker( + self.data, filename, + ) + + self.saveCombinedChannelsThread = QThread() + self.saveCombinedChannelsWorker.moveToThread(self.saveCombinedChannelsThread) + self.saveCombinedChannelsWorker.signals.finished.connect( + self.saveCombinedChannelsThread.quit + ) + self.saveCombinedChannelsWorker.signals.finished.connect( + self.saveCombinedChannelsWorker.deleteLater + ) + self.saveCombinedChannelsThread.finished.connect( + self.saveCombinedChannelsWorker.deleteLater + ) + + self.saveCombinedChannelsWorker.signals.critical.connect( + self.workerCritical + ) + self.saveCombinedChannelsWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.saveCombinedChannelsWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.saveCombinedChannelsWorker.signals.progress.connect( + self.workerProgress + ) + self.saveCombinedChannelsWorker.signals.finished.connect( + self.saveCombinedChannelsWorkerFinished + ) + + self.saveCombinedChannelsThread.started.connect( + self.saveCombinedChannelsWorker.run + ) + + self.saveCombinedChannelsWorker.sigDebugShowImg.connect( + self.preprocessing_view.debugShowImg + ) + + self.saveCombinedChannelsThread.start() + + def combineDialogStepsChanged(self): + steps, keep_input_data_type, formula = self.combineDialog.steps(return_keepInputDataType=True) + if steps is None: + self.logger.warning('Combine channels recipe not initialized yet.') + return + + self.updateCombineChannelsPreview(steps=steps, keep_input_data_type=keep_input_data_type, formula=formula) + + def updateCombineChannelsPreview(self, *args, **kwargs): + force = kwargs.get('force', False) + + if self.combineDialog is None: + return + + if not self.combineDialog.isVisible() and not force: + return + + if not self.combineDialog.previewCheckbox.isChecked() and not force: + return + + if kwargs.get('steps') is None: + steps, keep_input_data_type, formula = self.combineDialog.steps(return_keepInputDataType=True) + else: + steps = kwargs.get('steps') + keep_input_data_type = kwargs.get('keep_input_data_type') + formula = kwargs.get('formula') + + if steps is None: + self.logger.warning('Combine channels recipe not initialized yet.') + return + + txt = 'Combining...' + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + self.combineEnqueueCurrentImage(steps, keep_input_data_type, formula) + + def viewCombineChannelDataToggled(self, checked): + self.img1.setUseCombined(checked) + + if checked: + self.combineViewAsSegmSetup() + else: + self.setImageImg1() + + if self.viewPreprocDataToggle.isChecked(): + self.viewPreprocDataToggle.toggled.disconnect() + self.viewPreprocDataToggle.setChecked(False) + self.viewPreprocDataToggle.toggled.connect( + self.preprocessing_view.viewPreprocDataToggled + ) + + def setupCombiningChannels(self): + posData = self.data[self.pos_i] + if self.combineDialog is not None: + self.combineDialog.close() + + ordered_channels = [ch for ch in posData.chNames if ch != self.user_ch_name] + ordered_channels = natsorted(ordered_channels) + ordered_channels = [self.user_ch_name] + ordered_channels + + segmentations = [segm for segm in self.existingSegmEndNames] + segmentations = natsorted(segmentations) + segmentations = ['current segm.'] + segmentations + ordered_channels.extend(segmentations) + + self.combineDialog = apps.CombineChannelsSetupDialogGUI( + ordered_channels, + isTimelapse=posData.SizeT>1, + isZstack=posData.SizeZ>1, + isMultiPos=len(self.data)>1, + df_metadata=posData.metadata_df, + hideOnClosing=True, + parent=self + ) + self.doPreviewPreprocImage = False + self.combineDialog.sigApplyImage.connect( + self.combineCurrentImage + ) + self.combineDialog.sigApplyZstack.connect( + self.combineZStack + ) + self.combineDialog.sigApplyAllFrames.connect( + self.combineAllFrames + ) + self.combineDialog.sigApplyAllPos.connect( + self.combineAllPos + ) + self.combineDialog.sigPreviewToggled.connect( + self.combinePreviewToggled + ) + self.combineDialog.sigSaveAsSegmCheckboxToggled.connect( + self.combinePreviewViewAsSegmToggled + ) + self.combineDialog.sigValuesChanged.connect( + self.combineDialogStepsChanged + ) + self.combineDialog.sigSavePreprocData.connect( + self.combineDialogSaveCombinedData + ) + self.combineDialog.sigClose.connect( + self.combineDialogClosed + ) + + if self.combineWorker is not None: + return + + self.combineThread = QThread() + self.combineMutex = QMutex() + self.combineWaitCond = QWaitCondition() + + self.combineWorker = workers.CombineChannelsWorkerGUI( + self.combineMutex, self.combineWaitCond, + logger_func=self.logger.info, + ) + + self.combineWorker.moveToThread(self.combineThread) + self.combineWorker.signals.finished.connect(self.combineThread.quit) + self.combineWorker.signals.finished.connect( + self.combineWorker.deleteLater + ) + self.combineThread.finished.connect(self.combineWorker.deleteLater) + + self.combineWorker.sigDone.connect(self.combineWorkerDone) + self.combineWorker.sigIsQueueEmpty.connect( + self.combineWorkerIsQueueEmpty + ) + self.combineWorker.sigPreviewDone.connect(self.combineWorkerPreviewDone) + self.combineWorker.signals.progress.connect(self.workerProgress) + self.combineWorker.signals.critical.connect(self.workerCritical) + self.combineWorker.signals.finished.connect(self.combineWorkerClosed) + + self.combineWorker.sigAskLoadChannels.connect( + self.combineWorkerAskLoadChannels + ) + + self.combineThread.started.connect(self.combineWorker.run) + self.combineThread.start() + + self.logger.info('Combine channels worker started.') + + def combineDialogClosed(self, window): + QTimer.singleShot(200, self._combineDialogClosed) + + def _combineDialogClosed(self): + self.combineDialog = None + + def combineViewAsSegmSetup(self): + if self.combineDialog is None: + return + combineViewAsSegm = self.combineDialog.saveAsSegm() + if not combineViewAsSegm: + self.img1.setUseCombined(True) + if self.combineSegmViewToggle.isChecked(): + self.combineSegmViewToggle.setChecked(False) + self.combineSegmViewToggle.setCheckable(False) + + if not self.overlayLabelsButton.isChecked() and combineViewAsSegm: + self.overlayLabelsButton.blockSignals(True) + self.overlayLabelsButton.setChecked(True) + self.overlayLabels_cb(checked=True, selectedLabelsEndnames=['combined segm.']) + self.overlayLabelsButton.blockSignals(False) + + if combineViewAsSegm: + if not self.combineSegmViewToggle.isChecked(): + self.combineSegmViewToggle.setCheckable(True) + + self.combineSegmViewToggle.setChecked(False) + self.combineSegmViewToggle.setChecked(True) + + self.img1.setUseCombined(False) + self.setImageImg1() + + def combineChannelsActionTriggered(self): + if self.zProjComboBox is not None: + curr_proj = self.zProjComboBox.currentText() + if curr_proj != 'single z-slice': + self.zProjComboBox.setCurrentText('single z-slice') + + if self.switchPlaneCombobox is not None: + depthAxes = self.switchPlaneCombobox.depthAxes() + if depthAxes != 'z': + self.switchPlaneCombobox.setCurrentText('xy') + + if self.combineDialog is None: + self.setupCombiningChannels() + self.combineDialog.show() + self.combineDialog.raise_() + self.combineDialog.activateWindow() + self.combineDialog.emitSigPreviewToggled() + + def combineEnqueueCurrentImage(self, steps, keep_input_data_type, formula): + posData = self.data[self.pos_i] + + if posData.SizeZ > 1: + z_slice = self.z_slice_index() + else: + z_slice = 0 + + key = (self.pos_i, posData.frame_i, z_slice) + self.combineWorker.enqueue( + self.data, + steps, + key, + keep_input_data_type, + output_as_segm=self.combineDialog.saveAsSegm(), + formula=formula, + ) + + def combinePreviewToggled(self, checked): + self.viewCombineChannelDataToggle.setChecked(checked) + self.updateCombineChannelsPreview() + + def combinePreviewViewAsSegmToggled(self, checked): + self.updateCombineChannelsPreview() + self.combineViewAsSegmSetup() + + def combineCurrentImage( + self, + steps: List[Dict[str, Any]]=None, + keep_input_data_type:bool=None, + formula: str=None, + ): + if steps and keep_input_data_type is None: + raise ValueError('keep_input_data_type must be set if steps is set') + + if steps is None: + steps, keep_input_data_type, formula = self.combineDialog.steps( + return_keepInputDataType=True + ) + txt = 'Combining current image...' + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + selected_channel = core.get_selected_channels(steps) + self.preprocessing_view.getChData(requ_ch=selected_channel) + + z_slice = self.zSliceScrollBar.sliderPosition() + pos_i = self.pos_i + + key = (pos_i, self.data[pos_i].frame_i, z_slice) + + self.combineWorker.setupJob( + self.data, + steps, + keep_input_data_type, + key, + output_as_segm=self.combineDialog.saveAsSegm(), + formula=formula, + ) + + self.combineWorker.wakeUp() + + def combineZStack( + self, + steps: List[Dict[str, Any]]=None, + keep_input_data_type:bool=None, + formula: str=None, + ): + if self.combineDialog is not None: + keep_input_data_type = ( + self.combineDialog.keepInputDataTypeToggle.isChecked() + ) + + if steps and keep_input_data_type is None: + raise ValueError('keep_input_data_type must be set if steps is set') + + if steps is None: + steps, keep_input_data_type, formula = self.combineDialog.steps( + return_keepInputDataType=True + ) + txt = 'Combining z-stack...' + self.statusBarLabel.setText(txt) + self.logger.info(txt) + + selected_channel = core.get_selected_channels(steps) + self.preprocessing_view.getChData(requ_ch=selected_channel) + + posData = self.data[self.pos_i] + key = (self.pos_i, posData.frame_i, None) + self.combineWorker.setupJob( + self.data, + steps, + keep_input_data_type, + key, + output_as_segm=self.combineDialog.saveAsSegm(), + formula=formula, + ) + + self.combineWorker.wakeUp() + + def combineAllFrames(self, + steps: List[Dict[str, Any]]=None, + keep_input_data_type:bool=None, + formula: str=None, + ): + if steps and not keep_input_data_type: + raise ValueError('keep_input_data_type must be set if steps is set') + + if steps is None: + steps, keep_input_data_type, formula = self.combineDialog.steps(return_keepInputDataType=True) + txt = 'Combining all frames...' + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + selected_channel = core.get_selected_channels(steps) + self.preprocessing_view.getChData(requ_ch=selected_channel) + + key = (self.pos_i, None, None) + self.combineWorker.setupJob( + self.data, + steps, + keep_input_data_type, + key, + output_as_segm=self.combineDialog.saveAsSegm(), + formula=formula, + ) + + self.combineWorker.wakeUp() + + def combineAllPos(self, + steps: List[Dict[str, Any]]=None, + keep_input_data_type:bool=None, + formula: str=None, + ): + if steps and not keep_input_data_type: + raise ValueError('keep_input_data_type must be set if steps is set') + + if steps is None: + steps, keep_input_data_type, formula = self.combineDialog.steps(return_keepInputDataType=True) + txt = 'Combining all Positions...' + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + selected_channel = core.get_selected_channels(steps) + + for pos_i in range(len(self.data)): + self.preprocessing_view.getChData( + requ_ch=selected_channel, pos_i=pos_i + ) + + key = (None, None, None) + self.combineWorker.setupJob( + self.data, + steps, + keep_input_data_type, + key, + output_as_segm=self.combineDialog.saveAsSegm(), + formula=formula, + ) + + self.combineWorker.wakeUp() + + def stopCombineWorker(self): + self.logger.info('Closing combine worker...') + try: + self.combineWorker.stop() + except Exception as err: + pass + + def combineWorkerCritical(self, error): + self.combineDialog.appliedFinished() + self.workerCritical(error) + + def combineWorkerIsQueueEmpty(self, isEmpty: bool): + if isEmpty: + self.combineDialog.appliedFinished() + else: + self.combineDialog.setDisabled(True) + self.combineDialog.infoLabel.setText( + 'Computing preview...
' + '(Feel free to use Cell-ACDC while waiting)' + ) + + def combineWorkerPreviewDone( + self, + processed_data: List[np.ndarray], + keys: List[Tuple[int, int, int]] + ): + per_pos_data = self.view_model.group_processed_data_by_pos(processed_data, keys) + + for pos_i, pos_i_data in per_pos_data.items(): + posData = self.data[pos_i] + self.view_model.update_combine_image_data(posData, pos_i_data) + + if not self.combineDialog.saveAsSegm(): + for key, _ in pos_i_data: + _, frame_i, z_slice = key + self.img1.updateMinMaxValuesCombinedData( + self.data, pos_i, frame_i, z_slice + ) + if posData.img_data.ndim == 4: + self.img1.updateMinMaxValuesCombinedDataProjections( + self.data, pos_i, frame_i + ) + + posData = self.data[self.pos_i] + curr_pos_i, curr_frame_i, curr_z_slice = ( + self.pos_i, self.data[self.pos_i].frame_i, self.z_slice_index() + ) + if not self.combineDialog.saveAsSegm(): + self.img1.updateMinMaxValuesCombinedData( + self.data, curr_pos_i, curr_frame_i, curr_z_slice + ) + + self.combineViewAsSegmSetup() + + def combineWorkerAskLoadChannels(self, requ_channels, pos_i): + segms_to_load, channels_to_load, current_segm = myutils.separate_fluo_segment_channels(requ_channels) + if pos_i is None: + pos_i = list(range(len(self.data))) + elif not isinstance(pos_i, list): + pos_i = [pos_i] + + for i in pos_i: + if channels_to_load: + self.preprocessing_view.getChData( + requ_ch=channels_to_load, pos_i=i + ) + for segm in segms_to_load: + self.loadOverlayLabelsData(segm, pos_i=i) + self.combineWorker.wake_waitCondLoadFluoChannels() + + def combineWorkerDone( + self, + processed_data: List[np.ndarray], + keys: List[Tuple[int, int, int]] + ): + self.status_hover_view.set_status_bar_label(log=False) + self.combineDialog.appliedFinished() + + per_pos_data = self.view_model.group_processed_data_by_pos(processed_data, keys) + + for pos_i, pos_i_data in per_pos_data.items(): + posData = self.data[pos_i] + self.view_model.update_combine_image_data(posData, pos_i_data) + + if not self.combineDialog.saveAsSegm(): + for key, _ in pos_i_data: + _, frame_i, z_slice = key + self.img1.updateMinMaxValuesCombinedData( + self.data, pos_i, frame_i, z_slice + ) + if posData.img_data.ndim == 4: + self.img1.updateMinMaxValuesCombinedDataProjections( + self.data, pos_i, frame_i + ) + + if not self.viewCombineChannelDataToggle.isChecked(): + self.viewCombineChannelDataToggle.setChecked(True) + else: + self.setImageImg1() + + def combineWorkerClosed(self, worker): + self.logger.info('Combine worker stopped.') + + def saveCombinedChannelsWorkerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.status_hover_view.set_status_bar_label() + self.logger.info('Combined channels data saved!') + self.titleLabel.setText('Combined channels data saved!', color='w') + + def saveCombineWorkerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.status_hover_view.set_status_bar_label() + self.logger.info('Combined channels saved!') + self.titleLabel.setText('Combined channels saved!', color='w') diff --git a/cellacdc/views/curvature_tools_view.py b/cellacdc/views/curvature_tools_view.py new file mode 100644 index 000000000..4f662a8aa --- /dev/null +++ b/cellacdc/views/curvature_tools_view.py @@ -0,0 +1,256 @@ +"""Qt view adapter for curvature and spline tools.""" + +from __future__ import annotations + +import numpy as np +import pyqtgraph as pg +import skimage.draw +import skimage.measure + +from cellacdc.viewmodels.curvature_viewmodel import CurvatureViewModel + + +class CurvatureToolsView: + """Qt-facing adapter around curvature tool contracts.""" + + def __init__(self, host, view_model: CurvatureViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + # @exec_time + def getPolygonBrush(self, yxc2, Y, X): + # see https://en.wikipedia.org/wiki/Tangent_lines_to_circles + y1, x1 = self.yPressAx2, self.xPressAx2 + y2, x2 = yxc2 + rr_poly, cc_poly = self.view_model.tangent_brush_polygon( + (y1, x1), + (y2, x2), + self.brushSizeSpinbox.value(), + (Y, X), + ) + + self.yPressAx2, self.xPressAx2 = y2, x2 + return rr_poly, cc_poly + + def get_dir_coords(self, alfa_dir, yd, xd, shape, connectivity=1): + return self.view_model.directional_coords( + alfa_dir, + yd, + xd, + shape, + connectivity=connectivity, + ) + + def drawAutoContour(self, y2, x2): + y1, x1 = self.autoCont_y0, self.autoCont_x0 + Dy = abs(y2-y1) + Dx = abs(x2-x1) + edge = self.getDisplayedImg1() + if Dy != 0 or Dx != 0: + # NOTE: numIter takes care of any lag in mouseMoveEvent + numIter = int(round(max((Dy, Dx)))) + alfa = np.arctan2(y1-y2, x2-x1) + base = np.pi/4 + alfa_dir = round((base * round(alfa/base))*180/np.pi) + for _ in range(numIter): + y1, x1 = self.autoCont_y0, self.autoCont_x0 + yy, xx = self.get_dir_coords(alfa_dir, y1, x1, edge.shape) + a_dir = edge[yy, xx] + min_int = np.max(a_dir) + min_i = list(a_dir).index(min_int) + y, x = yy[min_i], xx[min_i] + try: + xx, yy = self.curvHoverPlotItem.getData() + except TypeError: + xx, yy = [], [] + + if xx is None or yy is None or len(xx) == 0 or len(yy) == 0: + xx, yy = [], [] + elif x == xx[-1] and y == yy[-1]: + # Do not append point equal to last point + return + + xx = np.r_[xx, x] + yy = np.r_[yy, y] + try: + self.curvHoverPlotItem.setData(xx, yy) + self.curvPlotItem.setData(xx, yy) + except TypeError: + pass + self.autoCont_y0, self.autoCont_x0 = y, x + # self.smoothAutoContWithSpline() + + def smoothAutoContWithSpline(self, n=3): + try: + xx, yy = self.curvHoverPlotItem.getData() + if xx is None or yy is None: + return + # Downsample by taking every nth coord + xxA, yyA = xx[::n], yy[::n] + rr, cc = skimage.draw.polygon(yyA, xxA) + self.autoContObjMask[rr, cc] = 1 + rp = skimage.measure.regionprops(self.autoContObjMask) + if not rp: + return + obj = rp[0] + cont = self.getObjContours(obj) + xxC, yyC = cont[:,0], cont[:,1] + xxA, yyA = xxC[::n], yyC[::n] + self.xxA_autoCont, self.yyA_autoCont = xxA, yyA + xxS, yyS = self.getSpline(xxA, yyA, per=True, appendFirst=True) + if len(xxS)>0: + self.curvPlotItem.setData(xxS, yyS) + except (TypeError, ValueError): + pass + + def getClosedSplineCoords(self): + xxS, yyS = self.curvPlotItem.getData() + xx, yy = self.curvAnchors.getData() + return self.view_model.closed_spline_coords( + xxS, + yyS, + anchor_xx=xx, + anchor_yy=yy, + predictor=self.splineToObjModel, + ) + + + def getSpline(self, xx, yy, resolutionSpace=None, per=False, appendFirst=False): + if resolutionSpace is None: + resolutionSpace = self.hoverLinSpace + return self.view_model.spline_coords( + xx, + yy, + resolution_space=resolutionSpace, + per=per, + append_first=appendFirst, + ) + + def curvTool_cb(self, checked): + posData = self.data[self.pos_i] + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.curvToolButton) + self.connectLeftClickButtons() + self.hoverLinSpace = np.linspace(0, 1, 1000) + self.curvPlotItem = pg.PlotDataItem(pen=self.newIDs_cpen) + self.curvHoverPlotItem = pg.PlotDataItem(pen=self.oldIDs_cpen) + self.curvAnchors = pg.ScatterPlotItem( + symbol='o', size=9, + brush=pg.mkBrush((255,0,0,50)), + pen=pg.mkPen((255,0,0), width=2), + hoverable=True, hoverPen=pg.mkPen((255,0,0), width=3), + hoverBrush=pg.mkBrush((255,0,0)), tip=None + ) + self.ax1.addItem(self.curvAnchors) + self.ax1.addItem(self.curvPlotItem) + self.ax1.addItem(self.curvHoverPlotItem) + self.splineHoverON = True + posData.curvPlotItems.append(self.curvPlotItem) + posData.curvAnchorsItems.append(self.curvAnchors) + posData.curvHoverItems.append(self.curvHoverPlotItem) + else: + self.splineHoverON = False + self.isRightClickDragImg1 = False + self.clearCurvItems() + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + self.showEditIDwidgets(checked) + + def clearCurvItems(self, removeItems=True): + try: + posData = self.data[self.pos_i] + curvItems = zip(posData.curvPlotItems, + posData.curvAnchorsItems, + posData.curvHoverItems) + for plotItem, curvAnchors, hoverItem in curvItems: + plotItem.setData([], []) + curvAnchors.setData([], []) + hoverItem.setData([], []) + if removeItems: + self.ax1.removeItem(plotItem) + self.ax1.removeItem(curvAnchors) + self.ax1.removeItem(hoverItem) + + if removeItems: + posData.curvPlotItems = [] + posData.curvAnchorsItems = [] + posData.curvHoverItems = [] + except AttributeError: + # traceback.print_exc() + pass + + # @exec_time + def curvToolSplineToObj(self, xxA=None, yyA=None, isRightClick=False): + posData = self.data[self.pos_i] + # Store undo state before modifying stuff + self.storeUndoRedoStates(False, storeOnlyZoom=True) + + if isRightClick: + xxS, yyS = self.curvPlotItem.getData() + if xxS is None: + self.setUncheckedAllButtons() + return + self.smoothAutoContWithSpline() + + xxS, yyS = self.getClosedSplineCoords() + + if self.autoIDcheckbox.isChecked(): + self.setBrushID() + curvToolID = posData.brushID + else: + curvToolID = self.editIDspinbox.value() + posData.brushID = curvToolID + + if curvToolID <= 0: + self.setBrushID() + curvToolID = posData.brushID + + lab2D = self.get_2Dlab(posData.lab).copy() + result = self.view_model.paint_spline_to_labels( + lab2D, + xxS, + yyS, + curvToolID, + empty_only=True, + ) + lab2D = result.labels_2d + self.set_2Dlab(lab2D) + self.currentLab2D = lab2D + + def hoverEventDrawSpline(self, event): + x, y = event.pos() + xx, yy = self.curvAnchors.getData() + hoverAnchors = self.curvAnchors.pointsAt(event.pos()) + per = False + # If we are hovering the starting point we generate + # a closed spline + if len(xx) < 2: + return + + if len(hoverAnchors)>0: + xA_hover, yA_hover = hoverAnchors[0].pos() + if xx[0]==xA_hover and yy[0]==yA_hover: + per=True + if per: + # Append start coords and close spline + xx = np.r_[xx, xx[0]] + yy = np.r_[yy, yy[0]] + xi, yi = self.getSpline(xx, yy, per=per) + # self.curvPlotItem.setData([], []) + else: + # Append mouse coords + xx = np.r_[xx, x] + yy = np.r_[yy, y] + xi, yi = self.getSpline(xx, yy, per=per) + self.curvHoverPlotItem.setData(xi, yi) diff --git a/cellacdc/views/custom_annotations_view.py b/cellacdc/views/custom_annotations_view.py new file mode 100644 index 000000000..a4d0d0ed6 --- /dev/null +++ b/cellacdc/views/custom_annotations_view.py @@ -0,0 +1,619 @@ +"""Qt view adapter for custom annotations.""" + +from __future__ import annotations + +import json +import os +import re +import traceback +from collections import defaultdict + +import pyqtgraph as pg +from qtpy.QtGui import QColor + +from cellacdc import apps, html_utils, settings_folderpath, widgets +from cellacdc.viewmodels.custom_annotations_viewmodel import ( + CustomAnnotationsViewModel, +) + + +custom_annot_path = os.path.join(settings_folderpath, 'custom_annotations.json') + + +class CustomAnnotationsView: + """Qt-facing adapter around custom annotation buttons and dialogs.""" + + def __init__(self, host, view_model: CustomAnnotationsViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def readSavedCustomAnnot(self): + if os.path.exists(custom_annot_path): + self.logger.info('Loading saved custom annotations...') + tempAnnot = self.view_model.read_saved_annotations( + custom_annot_path, logger_func=self.logger.info + ) + + posData = self.data[self.pos_i] + self.savedCustomAnnot = tempAnnot + for pos_i, posData in enumerate(self.data): + self.savedCustomAnnot = { + **self.savedCustomAnnot, **posData.customAnnot + } + + def addCustomAnnotButtonAllLoadedPos(self): + allPosCustomAnnot = {} + for pos_i, posData in enumerate(self.data): + self.addCustomAnnotationSavedPos(pos_i=pos_i) + allPosCustomAnnot = {**allPosCustomAnnot, **posData.customAnnot} + for posData in self.data: + posData.customAnnot = allPosCustomAnnot + + def addCustomAnnotationSavedPos(self, pos_i=None): + if pos_i is None: + pos_i = self.pos_i + + posData = self.data[pos_i] + for name, annotState in posData.customAnnot.items(): + # Check if button is already present and update only annotated IDs + buttons = [b for b in self.customAnnotDict.keys() if b.name==name] + if buttons: + toolButton = buttons[0] + allAnnotedIDs = self.customAnnotDict[toolButton]['annotatedIDs'] + allAnnotedIDs[pos_i] = posData.customAnnotIDs.get(name, {}) + continue + + try: + symbol = re.findall(r"\'(.+)\'", annotState['symbol'])[0] + except Exception as e: + self.logger.info(traceback.format_exc()) + symbol = 'o' + + symbolColor = QColor(*annotState['symbolColor']) + shortcut = annotState['shortcut'] + if shortcut is not None: + keySequence = widgets.macShortcutToWindows(shortcut) + keySequence = widgets.KeySequenceFromText(keySequence) + else: + keySequence = None + toolTip = self.view_model.tooltip(annotState) + keepActive = annotState.get('keepActive', True) + isHideChecked = annotState.get('isHideChecked', True) + + toolButton, action = self.addCustomAnnotationButton( + symbol, symbolColor, keySequence, toolTip, name, + keepActive, isHideChecked + ) + allPosAnnotIDs = [ + pos.customAnnotIDs.get(name, defaultdict(list)) + for pos in self.data + ] + self.customAnnotDict[toolButton] = { + 'action': action, + 'state': annotState, + 'annotatedIDs': allPosAnnotIDs + } + + self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) + + def addCustomAnnotationButton( + self, symbol, symbolColor, keySequence, toolTip, annotName, + keepActive, isHideChecked + ): + toolButton = widgets.customAnnotToolButton( + symbol, symbolColor, parent=self.host, keepToolActive=keepActive, + isHideChecked=isHideChecked + ) + toolButton.setCheckable(True) + self.checkableQButtonsGroup.addButton(toolButton) + if keySequence is not None: + toolButton.setShortcut(keySequence) + toolButton.setToolTip(toolTip) + toolButton.name = annotName + toolButton.toggled.connect(self.customAnnotButtonToggled) + toolButton.sigRemoveAction.connect(self.removeCustomAnnotButton) + toolButton.sigKeepActiveAction.connect(self.customAnnotKeepActive) + toolButton.sigHideAction.connect(self.customAnnotHide) + toolButton.sigModifyAction.connect(self.customAnnotModify) + action = self.annotateToolbar.addWidget(toolButton) + return toolButton, action + + def addCustomAnnnotScatterPlot( + self, symbolColor, symbol, toolButton + ): + # Add scatter plot item + symbolColorBrush = [0, 0, 0, 50] + symbolColorBrush[:3] = symbolColor.getRgb()[:3] + scatterPlotItem = widgets.CustomAnnotationScatterPlotItem() + scatterPlotItem.setData( + [], [], symbol=symbol, pxMode=False, + brush=pg.mkBrush(symbolColorBrush), size=15, + pen=pg.mkPen(width=3, color=symbolColor), + hoverable=True, hoverBrush=pg.mkBrush(symbolColor), + tip=None + ) + scatterPlotItem.sigHovered.connect(self.customAnnotHovered) + scatterPlotItem.button = toolButton + self.customAnnotDict[toolButton]['scatterPlotItem'] = scatterPlotItem + self.ax1.addItem(scatterPlotItem) + + def addCustomAnnotationItems( + self, symbol, symbolColor, keySequence, toolTip, name, + keepActive, isHideChecked, state + ): + toolButton, action = self.addCustomAnnotationButton( + symbol, symbolColor, keySequence, toolTip, name, + keepActive, isHideChecked + ) + + self.customAnnotDict[toolButton] = { + 'action': action, + 'state': state, + 'annotatedIDs': [defaultdict(list) for _ in range(len(self.data))] + } + + # Save custom annotation to cellacdc/temp/custom_annotations.json + state_to_save = state.copy() + state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) + self.savedCustomAnnot[name] = state_to_save + for posData in self.data: + posData.customAnnot[name] = state_to_save + + # Add scatter plot item + self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) + + customAnnotButton = self.customAnnotDict[toolButton] + allPosAnnotatedIDs = customAnnotButton['annotatedIDs'] + # Add 0s column to acdc_df + for pos_i, posData in enumerate(self.data): + for frame_i, data_dict in enumerate(posData.allData_li): + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + continue + result = self.view_model.ensure_column( + acdc_df, name + ) + data_dict['acdc_df'] = result.dataframe + allPosAnnotatedIDs[pos_i][frame_i].extend( + result.annotated_ids + ) + + if posData.acdc_df is not None: + result = self.view_model.ensure_column( + posData.acdc_df, + name, + ) + posData.acdc_df = result.dataframe + allPosAnnotatedIDs[pos_i][frame_i].extend( + result.annotated_ids + ) + + def customAnnotHovered(self, scatterPlotItem, points, event): + # Show tool tip when hovering an annotation with annotation name and ID + vb = scatterPlotItem.getViewBox() + if vb is None: + return + if len(points) > 0: + posData = self.data[self.pos_i] + point = points[0] + x, y = point.pos().x(), point.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + vb.setToolTip( + f'Annotation name: {scatterPlotItem.button.name}\n' + f'ID = {ID}' + ) + else: + vb.setToolTip('') + + def loadCustomAnnotations(self): + items = list(self.savedCustomAnnot.keys()) + if len(items) == 0: + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + There are no custom annotations saved.

+ Click on "Add custom annotation" button to start adding new + annotations. + """) + msg.warning(self.host, 'No annotations saved', txt) + return + + self.selectAnnotWin = widgets.QDialogListbox( + 'Load previously used custom annotation(s)', + 'Select annotations to load:', items, + additionalButtons=('Delete selected annnotations', ), + parent=self.host, multiSelection=True + ) + for button in self.selectAnnotWin._additionalButtons: + button.disconnect() + button.clicked.connect(self.deleteSavedAnnotation) + self.selectAnnotWin.exec_() + if self.selectAnnotWin.cancel: + return + + for selectedAnnotName in self.selectAnnotWin.selectedItemsText: + selectedAnnot = self.savedCustomAnnot[selectedAnnotName] + + symbol = selectedAnnot['symbol'] + symbol = re.findall(r"\'(.+)\'", symbol)[0] + symbolColor = selectedAnnot['symbolColor'] + symbolColor = pg.mkColor(symbolColor) + keySequence = widgets.KeySequenceFromText(selectedAnnot['shortcut']) + Type = selectedAnnot['type'] + toolTip = ( + f'Name: {selectedAnnotName}\n\n' + f'Type: {Type}\n\n' + f'Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n' + f'Description: {selectedAnnot["description"]}\n\n' + f'Shortcut: "{keySequence}"' + ) + keepActive = selectedAnnot['keepActive'] + isHideChecked = selectedAnnot['isHideChecked'] + state = { + 'type': Type, + 'name': selectedAnnotName, + 'symbol': selectedAnnot['symbol'], + 'shortcut': selectedAnnot['shortcut'], + 'description': selectedAnnot["description"], + 'keepActive': keepActive, + 'isHideChecked': isHideChecked, + 'symbolColor': symbolColor + } + self.addCustomAnnotationItems( + symbol, symbolColor, keySequence, toolTip, selectedAnnotName, + keepActive, isHideChecked, state + ) + for pos_i, posData in enumerate(self.data): + posData.customAnnot[selectedAnnotName] = selectedAnnot + + self.saveCustomAnnot() + + def deleteSavedAnnotation(self): + for item in self.selectAnnotWin.listBox.selectedItems(): + name = item.text() + self.savedCustomAnnot.pop(name) + self.deleteSelectedAnnot(self.selectAnnotWin.listBox.selectedItems()) + items = list(self.savedCustomAnnot.keys()) + self.selectAnnotWin.listBox.clear() + self.selectAnnotWin.listBox.addItems(items) + + def addCustomAnnotation(self): + self.readSavedCustomAnnot() + + self.addAnnotWin = apps.customAnnotationDialog( + self.savedCustomAnnot, parent=self.host + ) + self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) + self.addAnnotWin.exec_() + if self.addAnnotWin.cancel: + self.logger.info('Custom annotation process cancelled.') + return + + symbol = self.addAnnotWin.symbol + symbolColor = self.addAnnotWin.state['symbolColor'] + keySequence = self.addAnnotWin.shortcutWidget.widget.keySequence + toolTip = self.addAnnotWin.toolTip + name = self.addAnnotWin.state['name'] + keepActive = self.addAnnotWin.state.get('keepActive', True) + isHideChecked = self.addAnnotWin.state.get('isHideChecked', True) + + proceed = self.checkNameExists(name) + if not proceed: + self.logger.info('Custom annotation process cancelled.') + return + + self.addCustomAnnotationItems( + symbol, symbolColor, keySequence, toolTip, name, + keepActive, isHideChecked, self.addAnnotWin.state + ) + self.saveCustomAnnot() + self.doCustomAnnotation(0) + + def askCustomAnnotationNameExists(self, name): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(f""" + The annotationa called {name} already exists in the + acdc_output CSV file.

+ If you continue, this column will be used to initialize + pre-annotated objects.

+ Do you want to continue? + """ + ) + noButton, yesButton = msg.question( + self.host, 'Custom annotation name already exists', txt, + buttonsTexts=('No, stop process', 'Yes, use existing column') + ) + return msg.clickedButton == yesButton + + + def checkNameExists(self, name): + posData = self.data[self.pos_i] + if self.view_model.column_exists( + posData.allData_li, + name, + summary_acdc_df=posData.acdc_df, + ): + return self.askCustomAnnotationNameExists(name) + + return True + + + def viewAllCustomAnnot(self, checked): + if not checked: + # Clear all annotations before showing only checked + for button in self.customAnnotDict.keys(): + self.clearScatterPlotCustomAnnotButton(button) + self.doCustomAnnotation(0) + + def clearScatterPlotCustomAnnotButton(self, button): + scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + scatterPlotItem.setData([], []) + + def saveCustomAnnot(self, only_temp=False): + if not hasattr(self, 'savedCustomAnnot'): + return + + if not self.savedCustomAnnot: + return + + # Save to cell acdc temp path + with open(custom_annot_path, mode='w') as file: + json.dump(self.savedCustomAnnot, file, indent=2) + + if only_temp: + return + + self.logger.info('Saving custom annotations parameters...') + # Save to pos path + for _posData in self.data: + _posData.saveCustomAnnotationParams() + + def customAnnotKeepActive(self, button): + self.customAnnotDict[button]['state']['keepActive'] = button.keepToolActive + + def customAnnotHide(self, button): + self.customAnnotDict[button]['state']['isHideChecked'] = button.isHideChecked + clearAnnot = ( + not button.isChecked() and button.isHideChecked + and not self.viewAllCustomAnnotAction.isChecked() + ) + if clearAnnot: + # User checked hide annot with the button not active --> clear + self.clearScatterPlotCustomAnnotButton(button) + elif not button.isChecked(): + # User uncheked hide annot with the button not active --> show + self.doCustomAnnotation(0) + + def deleteSelectedAnnot(self, itemsToDelete): + self.saveCustomAnnot(only_temp=True) + + def customAnnotModify(self, button): + state = self.customAnnotDict[button]['state'] + self.addAnnotWin = apps.customAnnotationDialog( + self.savedCustomAnnot, parent=self.host, state=state + ) + self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) + self.addAnnotWin.exec_() + if self.addAnnotWin.cancel: + return + + # Rename column if existing + posData = self.data[self.pos_i] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + if acdc_df is not None: + old_name = self.customAnnotDict[button]['state']['name'] + new_name = self.addAnnotWin.state['name'] + posData.allData_li[posData.frame_i]['acdc_df'] = ( + self.view_model.rename_column( + acdc_df, old_name, new_name + ) + ) + + self.customAnnotDict[button]['state'] = self.addAnnotWin.state + + name = self.addAnnotWin.state['name'] + state_to_save = self.addAnnotWin.state.copy() + symbolColor = self.addAnnotWin.state['symbolColor'] + state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) + self.savedCustomAnnot[name] = self.addAnnotWin.state + self.saveCustomAnnot() + + symbol = self.addAnnotWin.symbol + symbolColor = self.customAnnotDict[button]['state']['symbolColor'] + button.setColor(symbolColor) + button.update() + symbolColorBrush = [0, 0, 0, 50] + symbolColorBrush[:3] = symbolColor.getRgb()[:3] + scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + xx, yy = scatterPlotItem.getData() + if xx is None: + xx, yy = [], [] + scatterPlotItem.setData( + xx, yy, symbol=symbol, pxMode=False, + brush=pg.mkBrush(symbolColorBrush), size=15, + pen=pg.mkPen(width=3, color=symbolColor) + ) + + def doCustomAnnotation(self, ID): + mode = self.modeComboBox.currentText() + if not self.isSnapshot and mode != 'Custom annotations': + # Do not show annotations if timelapse and mode not annotations + return + + if self.switchPlaneCombobox.depthAxes() != 'z': + return + + # NOTE: pass 0 for ID to not add + posData = self.data[self.pos_i] + if self.viewAllCustomAnnotAction.isChecked(): + # User requested to show all annotations --> iterate all buttons + # Unless it actively clicked to annotate --> avoid annotating object + # with all the annotations present + buttons = list(self.customAnnotDict.keys()) + else: + # Annotate if the button is active or isHideChecked is False + buttons = [ + b for b in self.customAnnotDict.keys() + if (b.isChecked() or not b.isHideChecked) + ] + if not buttons: + return + + for button in buttons: + annotatedIDs = ( + self.customAnnotDict[button]['annotatedIDs'][self.pos_i] + ) + annotIDs_frame_i = annotatedIDs.get(posData.frame_i, []) + state = self.customAnnotDict[button]['state'] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + if acdc_df is None: + self.store_data(autosave=False) + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + + result = self.view_model.update_frame( + acdc_df, + state['name'], + annotIDs_frame_i, + clicked_id=ID, + click_is_active=button.isChecked(), + existing_ids=posData.IDs_idxs, + ) + + annotPerButton = self.customAnnotDict[button] + allAnnotedIDs = annotPerButton['annotatedIDs'] + posAnnotedIDs = allAnnotedIDs[self.pos_i] + posAnnotedIDs[posData.frame_i] = result.annotated_ids + acdc_df = result.dataframe + + xx, yy = [], [] + for annotID in result.present_annotated_ids: + obj_idx = posData.IDs_idxs[annotID] + obj = posData.rp[obj_idx] + if not self.isObjVisible(obj.bbox): + continue + y, x = self.getObjCentroid(obj.centroid) + xx.append(x) + yy.append(y) + + scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + scatterPlotItem.setData(xx, yy) + + posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df + + # if self.highlightedID != 0: + # self.highlightedID = 0 + # self.setHighlightID(False) + + if buttons: + return buttons[0] + + def removeCustomAnnotButton(self, button, askHow=True, save=True): + if askHow: + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + Do you want to remove also the column with annotations or + only the annotation button?
+ """) + _, removeOnlyButton, removeColButton = msg.question( + self.host, 'Remove only button?', txt, + buttonsTexts=( + 'Cancel', 'Remove only button', + ' Remove also column with annotations ' + ) + ) + if msg.cancel: + return + removeOnlyButton = msg.clickedButton == removeOnlyButton + else: + removeOnlyButton = True + + name = self.customAnnotDict[button]['state']['name'] + # remove annotation from position + for posData in self.data: + try: + posData.customAnnot.pop(name) + posData.saveCustomAnnotationParams() + except KeyError as e: + # Current pos doesn't have any annotation button. Continue + continue + + if posData.acdc_df is None: + continue + + if removeOnlyButton: + continue + + posData.acdc_df = self.view_model.drop_column( + posData.acdc_df, + name, + ) + for frame_i, data_dict in enumerate(posData.allData_li): + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + continue + posData.allData_li[frame_i]['acdc_df'] = ( + self.view_model.drop_column( + acdc_df, name + ) + ) + + self.clearScatterPlotCustomAnnotButton(button) + + action = self.customAnnotDict[button]['action'] + self.annotateToolbar.removeAction(action) + self.checkableQButtonsGroup.removeButton(button) + self.customAnnotDict.pop(button) + # self.savedCustomAnnot.pop(name) + + self.saveCustomAnnot(only_temp=True) + + def reinitCustomAnnot(self): + buttons = list(self.customAnnotDict.keys()) + for button in buttons: + self.clearScatterPlotCustomAnnotButton(button) + action = self.customAnnotDict[button]['action'] + self.annotateToolbar.removeAction(action) + self.checkableQButtonsGroup.removeButton(button) + self.customAnnotDict.pop(button) + # self.savedCustomAnnot.pop(name) + + self.saveCustomAnnot(only_temp=True) + + def clearCustomAnnot(self): + for button in self.customAnnotDict.keys(): + scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + scatterPlotItem.setData([], []) + + def customAnnotButtonToggled(self, checked): + if checked: + self.customAnnotButton = self.sender() + # Uncheck the other buttons + for button in self.customAnnotDict.keys(): + if button == self.sender(): + continue + + button.toggled.disconnect() + self.clearScatterPlotCustomAnnotButton(button) + button.setChecked(False) + button.toggled.connect(self.customAnnotButtonToggled) + self.doCustomAnnotation(0) + else: + self.customAnnotButton = None + button = self.sender() + clearAnnotation = ( + button.isHideChecked + or not self.viewAllCustomAnnotAction.isChecked() + ) + if clearAnnotation: + self.clearScatterPlotCustomAnnotButton(button) + self.setHighlightID(False) + self.resetCursor() diff --git a/cellacdc/views/data_loading_view.py b/cellacdc/views/data_loading_view.py new file mode 100644 index 000000000..01da177b2 --- /dev/null +++ b/cellacdc/views/data_loading_view.py @@ -0,0 +1,1659 @@ +"""Qt view adapter for data loading and recovery workflows.""" + +from __future__ import annotations + +import os +import shutil +import zipfile +from functools import partial + +import numpy as np +import pandas as pd +import psutil +import skimage +import skimage.io +from natsort import natsorted +from qtpy.QtCore import QEventLoop, QMutex, Qt, QThread, QTimer, QWaitCondition +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import QFileDialog, QPushButton + +from cellacdc import ( + _palettes, + apps, + autopilot, + dataPrep, + data_structure_docs_url, + exception_handler, + html_utils, + load, + myutils, + prompts, + user_manual_url, + widgets, + workers, +) +from cellacdc.viewmodels.data_loading_viewmodel import DataLoadingViewModel + +GREEN_HEX = _palettes.green() + + +class DataLoadingView: + """Qt-facing adapter for data loading and recovery workflows.""" + + LEGACY_METHODS = ( + 'reload_cb', + 'getFileExtensions', + 'zSliceAbsent', + '_workerDebug', + 'warnMemoryNotSufficient', + 'checkMemoryRequirements', + 'loadPosTriggered', + 'startAutomaticLoadingPos', + 'stopAutomaticLoadingPos', + 'loadNonAlignedFluoChannel', + 'load_fluo_data', + 'initFluoData', + 'getPathFromChName', + 'loadFluo_cb', + 'criticalFluoChannelNotFound', + 'loadSelectedData', + 'startLoadDataWorker', + 'askRecoverNotSavedData', + 'showInfoAutosave', + 'askMismatchSegmDataShape', + 'workerPermissionError', + 'loadDataWorkerDataIntegrityCritical', + 'loadDataWorkerFinished', + 'checkManageVersions', + 'loadingDataCompleted', + 'loadingDataAborted', + 'loadDataWorkerDataIntegrityWarning', + 'openFolder', + 'addToRecentPaths', + 'getMostRecentPath', + '_openFolder', + '_loadFromExperimentFolder', + 'criticalInvalidPosFolder', + 'openFile', + 'askUserChannelName', + 'warnUserCreationImagesFolder', + '_openFile', + 'criticalNoTifFound', + '_createEmptyData', + 'newFile', + 'helpNewFile', + 'criticalImgPathNotFound', + 'openRecentFile', + ) + + def __init__(self, host, view_model: DataLoadingViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def reload_cb(self): + posData = self.data[self.pos_i] + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + labData = np.load(posData.segm_npz_path) + # Keep compatibility with .npy and .npz files + try: + lab = labData['arr_0'][posData.frame_i] + except Exception as e: + lab = labData[posData.frame_i] + posData.segm_data[posData.frame_i] = lab.copy() + self.get_data() + self.tracking() + self.updateAllImages() + + def getFileExtensions(self, images_path): + alignedFound = any( + f.find('_aligned.np') != -1 + for f in self.view_model.workspace.listdir(images_path) + ) + if alignedFound: + extensions = ( + 'Aligned channels (*npz *npy);; Tif channels(*tiff *tif)' + ';;All Files (*)' + ) + else: + extensions = ( + 'Tif channels(*tiff *tif);; All Files (*)' + ) + return extensions + + def zSliceAbsent(self, filename, posData): + self.app.restoreOverrideCursor() + SizeZ = posData.SizeZ + chNames = posData.chNames + filenamesPresent = posData.segmInfo_df.index.get_level_values(0).unique() + chNamesPresent = [ + ch for ch in chNames + for file in filenamesPresent + if file.endswith(ch) or file.endswith(f'{ch}_aligned') + ] + win = apps.QDialogZsliceAbsent(filename, SizeZ, chNamesPresent) + win.exec_() + if win.cancel: + self.worker.abort = True + self.waitCond.wakeAll() + return + if win.useMiddleSlice: + user_ch_name = filename[len(posData.basename):] + for _posData in self.data: + if _posData is None: + continue + _, filename = self.getPathFromChName(user_ch_name, _posData) + df = myutils.getDefault_SegmInfo_df(_posData, filename) + _posData.segmInfo_df = ( + self.view_model.merge_default_segm_info( + _posData.segmInfo_df, df + ) + ) + _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) + elif win.useSameAsCh: + user_ch_name = filename[len(posData.basename):] + for _posData in self.data: + if _posData is None: + continue + _, srcFilename = self.getPathFromChName( + win.selectedChannel, _posData + ) + _, dstFilename = self.getPathFromChName(user_ch_name, _posData) + if dstFilename is None: + self.worker.abort = True + self.waitCond.wakeAll() + return + dst_df = myutils.getDefault_SegmInfo_df(_posData, dstFilename) + _posData.segmInfo_df = ( + self.view_model.copy_single_zslice_segm_info( + _posData.segmInfo_df, + dst_df, + src_filename=srcFilename, + dst_filename=dstFilename, + ) + ) + _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) + elif win.runDataPrep: + user_ch_file_paths = [] + user_ch_name = filename[len(self.data[self.pos_i].basename):] + for _posData in self.data: + if _posData is None: + continue + user_ch_path = load.get_filename_from_channel( + _posData.images_path, user_ch_name + ) + if user_ch_path is None: + self.worker.abort = True + self.waitCond.wakeAll() + return + user_ch_file_paths.append(user_ch_path) + exp_path = os.path.dirname(_posData.pos_path) + + dataPrepWin = dataPrep.dataPrepWin() + dataPrepWin.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + dataPrepWin.titleText = ( + """ + Select z-slice (or projection) for each frame/position.
+ Once happy, close the window. + """) + dataPrepWin.show() + dataPrepWin.initLoading() + dataPrepWin.SizeT = self.data[0].SizeT + dataPrepWin.SizeZ = self.data[0].SizeZ + dataPrepWin.metadataAlreadyAsked = True + self.logger.info(f'Loading channel {user_ch_name} data...') + dataPrepWin.loadFiles( + exp_path, user_ch_file_paths, user_ch_name + ) + dataPrepWin.startAction.setDisabled(True) + dataPrepWin.onlySelectingZslice = True + + loop = QEventLoop(self.host) + dataPrepWin.loop = loop + loop.exec_() + + self.waitCond.wakeAll() + + def _workerDebug(self, stuff_to_debug): + pass + # from acdctools.plot import imshow + # lab, frame_i, autoBkgr_masks = stuff_to_debug + # autoBkgr_mask, autoBkgr_mask_proj = autoBkgr_masks + # imshow(lab, autoBkgr_mask) + # self.worker.waitCond.wakeAll() + + def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): + total_ram = self.view_model.formatting.bytes_to_gb(total_ram) + available_ram = self.view_model.formatting.bytes_to_gb(available_ram) + required_ram = self.view_model.formatting.bytes_to_gb(required_ram) + required_perc = round(100*required_ram/available_ram) + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + The total amount of data that you requested to load is about + {required_ram:.2f} GB ({required_perc}% of the available memory) + but there are only {available_ram:.2f} GB available.

+ For optimal operation, we recommend loading maximum 30% + of the available memory. To do so, try to close open apps to + free up some memory. Another option is to crop the images + using the data prep module.

+ If you choose to continue, the system might freeze + or your OS could simply kill the process.

+ What do you want to do? + """) + cancelButton, continueButton = msg.warning( + self.host, 'Memory not sufficient', txt, + buttonsTexts=('Cancel', 'Continue anyway') + ) + if msg.clickedButton == continueButton: + # Disable autosaving since it would keep a copy of the data and + # we cannot afford it with low memory + self.autoSaveToggle.setChecked(False) + return True + else: + return False + + def checkMemoryRequirements(self, required_ram): + memory = psutil.virtual_memory() + total_ram = memory.total + available_ram = memory.available + if required_ram/available_ram > 0.3: + proceed = self.warnMemoryNotSufficient( + total_ram, available_ram, required_ram + ) + return proceed + else: + return True + + def loadPosTriggered(self): + if not self.isDataLoaded: + return + + self.startAutomaticLoadingPos() + + def startAutomaticLoadingPos(self): + self.AutoPilot = autopilot.AutoPilot(self.host) + self.AutoPilot.execLoadPos() + + def stopAutomaticLoadingPos(self): + if self.AutoPilot is None: + return + + if self.AutoPilot.timer.isActive(): + self.AutoPilot.timer.stop() + self.AutoPilot = None + + def loadNonAlignedFluoChannel(self, fluo_path): + posData = self.data[self.pos_i] + if posData.filename.find('aligned') != -1: + filename, _ = os.path.splitext(os.path.basename(fluo_path)) + path = f'.../{posData.pos_foldername}/Images/{filename}_aligned.npz' + msg = widgets.myMessageBox() + msg.critical( + self.host, 'Aligned fluo channel not found!', + 'Aligned data for fluorescence channel not found!\n\n' + f'You loaded aligned data for the cells channel, therefore ' + 'loading NON-aligned fluorescence data is not allowed.\n\n' + 'Run the script "dataPrep.py" to create the following file:\n\n' + f'{path}' + ) + return None + fluo_data = np.squeeze(skimage.io.imread(fluo_path)) + return fluo_data + + def load_fluo_data(self, fluo_path, isGuiThread=True): + self.logger.info(f'Loading fluorescence image data from "{fluo_path}"...') + bkgrData = None + posData = self.data[self.pos_i] + # Load overlay frames and align if needed + filename = os.path.basename(fluo_path) + filename_noEXT, ext = os.path.splitext(filename) + if ext == '.npy' or ext == '.npz': + fluo_data = np.load(fluo_path) + try: + fluo_data = np.squeeze(fluo_data['arr_0']) + except Exception as e: + fluo_data = np.squeeze(fluo_data) + + # Load background data + bkgrData_path = os.path.join( + posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' + ) + if os.path.exists(bkgrData_path): + bkgrData = np.load(bkgrData_path) + elif ext == '.tif' or ext == '.tiff': + aligned_filename = f'{filename_noEXT}_aligned.npz' + aligned_path = os.path.join(posData.images_path, aligned_filename) + if os.path.exists(aligned_path): + fluo_data = np.load(aligned_path)['arr_0'] + + # Load background data + bkgrData_path = os.path.join( + posData.images_path, f'{aligned_filename}_bkgrRoiData.npz' + ) + if os.path.exists(bkgrData_path): + bkgrData = np.load(bkgrData_path) + else: + fluo_data = self.loadNonAlignedFluoChannel(fluo_path) + if fluo_data is None: + return None, None + + # Load background data + bkgrData_path = os.path.join( + posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' + ) + if os.path.exists(bkgrData_path): + bkgrData = np.load(bkgrData_path) + elif isGuiThread: + txt = html_utils.paragraph( + f'File format {ext} is not supported!\n' + 'Choose either .tif or .npz files.' + ) + msg = widgets.myMessageBox() + msg.critical(self.host, 'File not supported', txt) + return None, None + + return fluo_data, bkgrData + + def initFluoData(self): + if len(self.ch_names) <= 1: + return + + if 'ask_load_fluo_at_init' in self.df_settings.index: + if self.df_settings.at['ask_load_fluo_at_init', 'value'] == 'No': + return + msg = widgets.myMessageBox(allowClose=False) + txt = ( + 'Do you also want to load fluorescence images?
' + 'You can load as many channels as you want.

' + 'If you load fluorescence images then the software will ' + 'calculate metrics for each loaded fluorescence channel ' + 'such as min, max, mean, quantiles, etc. ' + 'of each segmented object.

' + 'NOTE: You can always load them later from the menu ' + 'File --> Load fluorescence images... or when you set ' + 'measurements from the menu ' + 'Measurements --> Set measurements...' + ) + msg.addDoNotShowAgainCheckbox(text="Don't ask again") + no, yes = msg.question( + self.host, 'Load fluorescence images?', html_utils.paragraph(txt), + buttonsTexts=('No', 'Yes') + ) + if msg.doNotShowAgainCheckbox.isChecked(): + self.df_settings.at['ask_load_fluo_at_init', 'value'] = 'No' + self.df_settings.to_csv(self.settings_csv_path) + if msg.clickedButton == yes: + self.loadFluo_cb(None) + self.AutoPilotProfile.storeClickMessageBox( + 'Load fluorescence images?', msg.clickedButton.text() + ) + + def getPathFromChName(self, chName, posData): + ls = self.view_model.workspace.listdir(posData.images_path) + endnames = {f[len(posData.basename):]:f for f in ls} + validEnds = ['_aligned.npz', '_aligned.h5', '.h5', '.tif', '.npz'] + for end in validEnds: + files = [ + filename for endname, filename in endnames.items() + if endname == f'{chName}{end}' + ] + if files: + filename = files[0] + break + else: + self.criticalFluoChannelNotFound(chName, posData) + self.app.restoreOverrideCursor() + return None, None + + fluo_path = os.path.join(posData.images_path, filename) + filename, _ = os.path.splitext(filename) + return fluo_path, filename + + def loadFluo_cb(self, checked=True, fluo_channels=None): + if fluo_channels is None: + posData = self.data[self.pos_i] + ch_names = [ + ch for ch in self.ch_names if ch != self.user_ch_name + and ch not in posData.loadedFluoChannels + ] + if not ch_names: + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'You already loaded ALL channels.

' + 'To change the overlaid channel ' + 'right-click on the overlay button.' + ) + msg.information(self.host, 'All channels are loaded', txt) + return False + selectFluo = widgets.QDialogListbox( + 'Select channel to load', + 'Select channel names to load:\n', + ch_names, multiSelection=True, parent=self.host + ) + selectFluo.exec_() + + if selectFluo.cancel: + return False + + fluo_channels = selectFluo.selectedItemsText + self.AutoPilotProfile.storeLoadedFluoChannels(fluo_channels) + + for p, posData in enumerate(self.data): + # posData.ol_data = None + for fluo_ch in fluo_channels: + fluo_path, filename = self.getPathFromChName(fluo_ch, posData) + if fluo_path is None: + self.criticalFluoChannelNotFound(fluo_ch, posData) + return False + fluo_data, bkgrData = self.load_fluo_data(fluo_path) + if fluo_data is None: + return False + posData.loadedFluoChannels.add(fluo_ch) + + if posData.SizeT == 1: + fluo_data = fluo_data[np.newaxis] + + posData.fluo_data_dict[filename] = fluo_data + posData.fluo_bkgrData_dict[filename] = bkgrData + posData.ol_data_dict[filename] = fluo_data.copy() + + self.overlayButton.setStyleSheet(f'background-color: {GREEN_HEX}') + self.guiTabControl.addChannels([ + posData.user_ch_name, *posData.loadedFluoChannels + ]) + return True + + def criticalFluoChannelNotFound(self, fluo_ch, posData): + msg = widgets.myMessageBox(showCentered=False) + ls = "\n".join(self.view_model.workspace.listdir(posData.images_path)) + msg.setDetailedText( + f'Files present in the {posData.relPath} folder:\n' + f'{ls}' + ) + title = 'Requested channel data not found!' + txt = html_utils.paragraph( + f'The folder {posData.pos_path} ' + 'does not contain ' + 'either one of the following files:

' + f'{posData.basename}{fluo_ch}.tif
' + f'{posData.basename}{fluo_ch}_aligned.npz

' + 'Data loading aborted.' + ) + msg.addShowInFileManagerButton(posData.images_path) + okButton = msg.warning( + self.host, title, txt, buttonsTexts=('Ok') + ) + + def loadSelectedData(self, user_ch_file_paths, user_ch_name): + self.user_ch_file_paths = user_ch_file_paths + + self.logger.info(f'Reading {user_ch_name} channel metadata...') + # Get information from first loaded position + posData = load.loadData(user_ch_file_paths[0], user_ch_name, log_func=self.logger.info) + posData.getBasenameAndChNames(qparent=self.host) + posData.buildPaths() + + if posData.ext != '.h5': + self.lazyLoader.salute = False + self.lazyLoader.exit = True + self.lazyLoaderWaitCond.wakeAll() + self.waitReadH5cond.wakeAll() + + # Get end name of every existing segmentation file + existingSegmEndNames = set() + for filePath in user_ch_file_paths: + _posData = load.loadData(filePath, user_ch_name, log_func=self.logger.info) + _posData.getBasenameAndChNames(qparent=self.host) + segm_files = self.view_model.workspace.segmentation_files( + _posData.images_path + ) + _existingEndnames = self.view_model.workspace.endnames( + _posData.basename, segm_files + ) + existingSegmEndNames.update(_existingEndnames) + + selectedSegmEndName = '' + self.newSegmEndName = '' + if self.isNewFile or not existingSegmEndNames: + self.isNewFile = True + # Remove the 'segm_' part to allow filenameDialog to check if + # a new file is existing (since we only ask for the part after + # 'segm_') + existingEndNames = [ + n.replace('segm', '', 1).replace('_', '', 1) + for n in existingSegmEndNames + ] + if posData.basename.endswith('_'): + basename = f'{posData.basename}segm' + else: + basename = f'{posData.basename}_segm' + win = apps.filenameDialog( + basename=basename, + hintText='Insert a filename for the segmentation file:', + existingNames=existingEndNames + ) + win.exec_() + if win.cancel: + self.loadingDataAborted() + return + self.newSegmEndName = win.entryText + else: + if len(existingSegmEndNames) > 0: + win = apps.SelectSegmFileDialog( + existingSegmEndNames, self.exp_path, parent=self.host, + addNewFileButton=True, basename=posData.basename + ) + win.exec_() + if win.cancel: + self.loadingDataAborted() + return + if win.newSegmEndName is None: + selectedSegmEndName = win.selectedItemText + self.AutoPilotProfile.storeSelectedSegmFile( + selectedSegmEndName + ) + else: + self.newSegmEndName = win.newSegmEndName + self.isNewFile = True + elif len(existingSegmEndNames) == 1: + selectedSegmEndName = list(existingSegmEndNames)[0] + + posData.loadImgData() + + required_ram = posData.getBytesImageData() + if required_ram >= 5e8: + # Disable autosave for data > 500MB + self.autoSaveToggle.setChecked(False) + + proceed = self.checkMemoryRequirements(required_ram) + if not proceed: + self.loadingDataAborted() + return + + posData.loadOtherFiles( + load_segm_data=True, + load_metadata=True, + create_new_segm=self.isNewFile, + new_endname=self.newSegmEndName, + end_filename_segm=selectedSegmEndName, + ) + self.selectedSegmEndName = selectedSegmEndName + self.labelBoolSegm = posData.labelBoolSegm + posData.labelSegmData() + + print('') + self.logger.info( + f'Segmentation filename: {posData.segm_npz_path}' + ) + + proceed = posData.askInputMetadata( + self.num_pos, + ask_SizeT=self.num_pos==1, + ask_TimeIncrement=True, + ask_PhysicalSizes=True, + singlePos=False, + save=True, + warnMultiPos=True + ) + if not proceed: + self.loadingDataAborted() + return + + self.AutoPilotProfile.storeOkAskInputMetadata() + + if posData.isSegm3D is None: + self.isSegm3D = False + else: + self.isSegm3D = posData.isSegm3D + self.SizeT = posData.SizeT + self.SizeZ = posData.SizeZ + self.TimeIncrement = posData.TimeIncrement + self.PhysicalSizeZ = posData.PhysicalSizeZ + self.PhysicalSizeY = posData.PhysicalSizeY + self.PhysicalSizeX = posData.PhysicalSizeX + self.loadSizeS = posData.loadSizeS + self.loadSizeT = posData.loadSizeT + self.loadSizeZ = posData.loadSizeZ + + self.overlayLabelsItems = {} + self.drawModeOverlayLabelsChannels = {} + + self.existingSegmEndNames = existingSegmEndNames + self.createOverlayLabelsContextMenu(existingSegmEndNames) + self.overlayLabelsButtonAction.setVisible(True) + self.createOverlayLabelsItems(existingSegmEndNames) + self.disableNonFunctionalButtons() + + self.isH5chunk = ( + posData.ext == '.h5' + and (self.loadSizeT != self.SizeT + or self.loadSizeZ != self.SizeZ) + ) + + required_ram = posData.checkH5memoryFootprint()*self.loadSizeS + if required_ram > 0: + proceed = self.checkMemoryRequirements(required_ram) + if not proceed: + self.loadingDataAborted() + return + + if posData.SizeT == 1: + self.isSnapshot = True + else: + self.isSnapshot = False + + self.progressWin = apps.QDialogWorkerProgress( + title='Loading data...', parent=self.host, + pbarDesc=f'Loading "{user_ch_file_paths[0]}"...' + ) + self.progressWin.show(self.app) + + func = partial( + self.startLoadDataWorker, user_ch_file_paths, user_ch_name, + posData + ) + + + QTimer.singleShot(150, func) + + @exception_handler + def startLoadDataWorker( + self, user_ch_file_paths, user_ch_name, firstPosData + ): + self.funcDescription = 'loading data' + + self.guiTabControl.propsQGBox.idSB.setValue(0) + + self.thread = QThread() + self.loadDataMutex = QMutex() + self.loadDataWaitCond = QWaitCondition() + + self.loadDataWorker = workers.loadDataWorker( + self.host, user_ch_file_paths, user_ch_name, firstPosData + ) + + self.loadDataWorker.moveToThread(self.thread) + self.loadDataWorker.signals.finished.connect(self.thread.quit) + self.loadDataWorker.signals.finished.connect( + self.loadDataWorker.deleteLater + ) + self.thread.finished.connect(self.thread.deleteLater) + + self.loadDataWorker.signals.finished.connect( + self.loadDataWorkerFinished + ) + self.loadDataWorker.signals.progress.connect(self.workerProgress) + self.loadDataWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.loadDataWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.loadDataWorker.signals.critical.connect( + self.workerCritical + ) + self.loadDataWorker.signals.dataIntegrityCritical.connect( + self.loadDataWorkerDataIntegrityCritical + ) + self.loadDataWorker.signals.dataIntegrityWarning.connect( + self.loadDataWorkerDataIntegrityWarning + ) + self.loadDataWorker.signals.sigPermissionError.connect( + self.workerPermissionError + ) + self.loadDataWorker.signals.sigWarnMismatchSegmDataShape.connect( + self.askMismatchSegmDataShape + ) + self.loadDataWorker.signals.sigRecovery.connect( + self.askRecoverNotSavedData + ) + + self.thread.started.connect(self.loadDataWorker.run) + self.thread.start() + + def askRecoverNotSavedData(self, posData): + last_modified_time_unsaved = 'NEVER' + if os.path.exists(posData.segm_npz_temp_path): + recovered_file_path = posData.segm_npz_temp_path + if os.path.exists(posData.segm_npz_path): + last_modified_time_unsaved = ( + datetime.fromtimestamp( + os.path.getmtime(posData.segm_npz_path) + ).strftime("%a %d. %b. %y - %H:%M:%S") + ) + else: + posData.setTempPaths() + if os.path.exists(posData.unsaved_acdc_df_autosave_path): + zip_path = posData.unsaved_acdc_df_autosave_path + with zipfile.ZipFile(zip_path, mode='r') as zip: + csv_names = natsorted(set(zip.namelist())) + iso_key = csv_names[-1][:-4] + most_recent_unsaved_acdc_df_datetime = datetime.strptime( + iso_key, load.ISO_TIMESTAMP_FORMAT + ) + last_modified_time_unsaved = ( + most_recent_unsaved_acdc_df_datetime + ).strftime("%a %d. %b. %y - %H:%M:%S") + + if os.path.exists(posData.acdc_output_csv_path): + acdc_df_mtime = os.path.getmtime(posData.acdc_output_csv_path) + timestamp = datetime.fromtimestamp(acdc_df_mtime) + last_modified_time_saved = timestamp.strftime( + "%a %d. %b. %y - %H:%M:%S" + ) + else: + last_modified_time_saved = 'Null' + + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = html_utils.paragraph(""" + Cell-ACDC detected unsaved data.

+ Do you want to load and recover the unsaved data or + load the data that was last saved by the user? + """) + details = (f""" + The unsaved data was created on {last_modified_time_unsaved}\n\n + The user saved the data last time on {last_modified_time_saved} + """) + msg.setDetailedText(details) + loadUnsavedButton = widgets.reloadPushButton('Recover unsaved data') + loadSavedButton = widgets.savePushButton('Load saved data') + infoButton = widgets.infoPushButton('More info...') + loadSafeNpzButton = '' + if posData.isSafeNpzOverwritePresent(): + loadSafeNpzButton = widgets.reloadPushButton( + 'Load .safe.npz file from crash' + ) + buttons = ( + loadSavedButton, loadUnsavedButton, loadSafeNpzButton, + infoButton + ) + else: + buttons = (loadSavedButton, loadUnsavedButton, infoButton) + msg.question( + self.progressWin, 'Recover unsaved data?', txt, + buttonsTexts=('Cancel', *buttons), + showDialog=False + ) + infoButton.disconnect() + infoButton.clicked.connect(partial(self.showInfoAutosave, posData)) + msg.exec_() + if msg.cancel: + self.loadDataWorker.abort = True + elif msg.clickedButton == loadUnsavedButton: + self.loadDataWorker.loadUnsaved = True + elif msg.clickedButton == loadSafeNpzButton: + self.loadDataWorker.loadSafeOverwriteNpz = True + + self.loadDataWorker.waitCond.wakeAll() + # self.AutoPilotProfile.storeLoadSavedData() + + def showInfoAutosave(self, posData): + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = (f""" + Cell-ACDC either detected unsaved data in a previous session and it + stored it because the Autosave
+ function was active, or it crashed during saving.

+ You can toggle Autosave ON and OFF from the menu on the top menubar + File --> Autosave. + """) + txt = (f""" + {txt}

+ If Cell-ACDC crashed during saving, the segmentation file ending + with .new.npz
+ is present and you might be able to recover the data from there. + """) + + txt = (f""" + {txt}

+ You can find additional recovered data in the following folder: + """) + txt = html_utils.paragraph(txt) + msg.information( + self.host, 'Autosave info', txt, + path_to_browse=posData.recoveryFolderPath, + commands=(posData.recoveryFolderPath,) + ) + + def askMismatchSegmDataShape(self, posData): + msg = widgets.myMessageBox(wrapText=False) + title = 'Segm. data shape mismatch' + f = '3D' if self.isSegm3D else '2D' + f = f'{f} over time' if posData.SizeT > 1 else f + r = '2D' if self.isSegm3D else '3D' + r = f'{r} over time' if posData.SizeT > 1 else r + text = html_utils.paragraph(f""" + The segmentation masks of the first Position that you loaded is + {f},
+ while {posData.pos_foldername} is {r}.

+ The loaded segmentation masks must be either all 3D + or all 2D.

+ Do you want to skip loading this position or cancel the process? + """) + _, skipPosButton = msg.warning( + self.host, title, text, + buttonsTexts=('Cancel', 'Skip this Position') + ) + if skipPosButton == msg.clickedButton: + self.loadDataWorker.skipPos = True + self.loadDataWorker.waitCond.wakeAll() + + def workerPermissionError(self, txt, waitCond): + msg = widgets.myMessageBox(parent=self.host) + msg.setIcon(iconName='SP_MessageBoxCritical') + msg.setWindowTitle('Permission denied') + msg.addText(txt) + msg.addButton(' Ok ') + msg.exec_() + waitCond.wakeAll() + + def loadDataWorkerDataIntegrityCritical(self): + errTitle = 'All loaded positions contains frames over time!' + self.titleLabel.setText(errTitle, color='r') + + msg = widgets.myMessageBox(parent=self.host) + + err_msg = html_utils.paragraph(f""" + {errTitle}.

+ To load data that contains frames over time you have to select + only ONE position. + """) + msg.setIcon(iconName='SP_MessageBoxCritical') + msg.setWindowTitle('Loaded multiple positions with frames!') + msg.addText(err_msg) + msg.addButton('Ok') + msg.show(block=True) + + @exception_handler + def loadDataWorkerFinished(self, data): + self.funcDescription = 'loading data worker finished' + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + if data is None or data=='abort': + self.loadingDataAborted() + return + + if data[0].onlyEditMetadata: + self.loadingDataAborted() + return + + self.pos_i = 0 + self.data = data + self.sessions = [None] * len(data) + self.gui_createGraphicsItems() + return True + + def checkManageVersions(self): + posData = self.data[self.pos_i] + posData.setTempPaths(createFolder=False) + loaded_acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) + + if os.path.exists(posData.recoveryFolderpath()): + self.manageVersionsAction.setDisabled(False) + self.manageVersionsAction.setToolTip( + f'Load an older version of the `{loaded_acdc_df_filename}` file ' + '(table with annotations and measurements).' + ) + else: + self.manageVersionsAction.setDisabled(True) + + @exception_handler + def loadingDataCompleted(self): + self.isDataLoading = True + posData = self.data[self.pos_i] + + files_format = '\n'.join([ + f' - {file}' for file in posData.images_folder_files + ]) + sep = '-'*100 + self.logger.info( + f'{sep}\nFiles present in the first Position folder loaded:\n\n' + f'{files_format}\n{sep}' + ) + self.logger.info(f'Basename of the first Position: {posData.basename}') + self.secondLevelToolbar.setVisible(True) + self.updateImageValueFormatter() + self.checkManageVersions() + self.initManualBackgroundImage() + self.initPixelSizePropsDockWidget() + + self.setWindowTitle( + f'Cell-ACDC v{self._acdc_version} - GUI - "{posData.exp_path}"' + ) + + self.preprocessing_view.setupPreprocessing() + self.setupCombiningChannels() + + if self.isSegm3D: + self.segmNdimIndicator.setText('3D') + else: + self.segmNdimIndicator.setText('2D') + + self.segmNdimIndicatorAction.setVisible(True) + + self.guiTabControl.addChannels([posData.user_ch_name]) + self.showPropsDockButton.setDisabled(False) + + self.bottomScrollArea.show() + self.gui_createStoreStateWorker() + self.init_segmInfo_df() + self.connectScrollbars() + self.initPosAttr() + + self.logger.info('Pre-computing min and max values of the images...') + self.img1.preComputedMinMaxValues(self.data) + self.img2.minMaxValuesMapper = self.img1.minMaxValuesMapper + + self.measurements_view.init_metrics() + self.initFluoData() + self.createChannelNamesActions() + self.addActionsLutItemContextMenu(self.imgGrad) + + # Scrollbar for opacity of img1 (when overlaying) + self.img1.alphaScrollbar = self.addAlphaScrollbar( + self.user_ch_name, self.img1 + ) + + self.navigateScrollBar.setSliderPosition(posData.frame_i+1) + + # Connect events at the end of loading data process + self.gui_connectGraphicsEvents() + if not self.isEditActionsConnected: + self.gui_connectEditActions() + self.normalizeToFloatAction.setChecked(True) + + self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) + + self.mode_controls_view.setFramesSnapshotMode() + if self.isSnapshot: + self.navSizeLabel.setText(f'/{len(self.data)}') + else: + self.navSizeLabel.setText(f'/{posData.SizeT}') + + self.enableZstackWidgets(posData.SizeZ > 1) + # self.showHighlightZneighCheckbox() + + self.exportToVideoAction.setDisabled( + posData.SizeZ == 1 and posData.SizeT == 1 + ) + + self.img1BottomGroupbox.show() + + isLabVisible = self.df_settings.at['isLabelsVisible', 'value'] == 'Yes' + isRightImgVisible = ( + self.df_settings.at['isRightImageVisible', 'value'] == 'Yes' + ) + isNextFrameVisible = ( + self.df_settings.at['isNextFrameVisible', 'value'] == 'Yes' + ) + isNextFrameActive = ( + isNextFrameVisible and self.labelsGrad.showNextFrameAction.isEnabled() + ) + self.updateScrollbars() + self.openFolderAction.setEnabled(True) + self.editTextIDsColorAction.setDisabled(False) + self.imgPropertiesAction.setEnabled(True) + self.navigateToolBar.setVisible(True) + self.labelsGrad.showLabelsImgAction.setChecked(isLabVisible) + self.labelsGrad.showRightImgAction.setChecked(isRightImgVisible) + self.labelsGrad.showNextFrameAction.setChecked(isNextFrameActive) + if isRightImgVisible or isNextFrameActive: + self.rightBottomGroupbox.setChecked(True) + + isTwoImagesLayout = ( + isRightImgVisible or isLabVisible or isNextFrameActive + ) + self.setTwoImagesLayout(isTwoImagesLayout) + + self.setBottomLayoutStretch() + + if isNextFrameActive: + self.rightBottomGroupbox.show() + self.rightBottomGroupbox.setChecked(True) + self.drawNothingCheckboxRight.click() + + self.custom_annotations_view.readSavedCustomAnnot() + self.custom_annotations_view.addCustomAnnotButtonAllLoadedPos() + self.status_hover_view.set_status_bar_label() + + self.initLookupTableLab() + if self.invertBwAction.isChecked() and not self.invertBwAlreadyCalledOnce: + self.invertBw(True) + self.restoreSavedSettings() + + self.initContoursImage() + self.initTextAnnot() + self.initDelRoiLab() + + self.update_rp() + self.updateAllImages() + if posData.SizeT > 1: + self.rightImageFramesScrollbar.setValueNoSignal(posData.frame_i+2) + self.measurements_view.set_metrics_func() + + self.gui_createLabelRoiItem() + self.gui_createZoomRectItem() + + self.titleLabel.setText( + 'Data successfully loaded.', + color=self.titleColor + ) + + self.disableNonFunctionalButtons() + self.setVisible3DsegmWidgets() + + if len(self.data) == 1 and posData.SizeZ > 1 and posData.SizeT == 1: + self.zSliceCheckbox.setChecked(True) + else: + self.zSliceCheckbox.setChecked(False) + + self.labelRoiCircItemLeft.setImageShape(self.currentLab2D.shape) + self.labelRoiCircItemRight.setImageShape(self.currentLab2D.shape) + + self.retainSpaceSlidersToggled(self.retainSpaceSlidersAction.isChecked()) + + self.stopAutomaticLoadingPos() + self.viewAllCustomAnnotAction.setChecked(True) + + self.updateImageValueFormatter() + + posData.loadWhitelist() + + self.image_controls_view.setFocusGraphics() + self.image_controls_view.setFocusMain() + + # Overwrite axes viewbox context menu + self.ax1.vb.menu = self.imgGrad.gradient.menu + self.ax2.vb.menu = self.labelsGrad.menu + + QTimer.singleShot(200, self.resizeGui) + + self.isDataLoaded = True + self._sync_all_sessions() + self._init_tool_dispatcher() + self.isDataLoading = False + + self.initImgGradRescaleIntensitiesHowPreference() + + self.rescaleIntensitiesLut(setImage=False) + + self.gui_createAutoSaveWorker() + + def loadingDataAborted(self): + self.openFolderAction.setEnabled(True) + self.titleLabel.setText('Loading data aborted.') + + @exception_handler + def loadDataWorkerDataIntegrityWarning(self, pos_foldername): + err_msg = ( + 'WARNING: Segmentation mask file ("..._segm.npz") not found. ' + 'You could run segmentation module first.' + ) + self.workerProgress(err_msg, 'INFO') + self.titleLabel.setText(err_msg, color='r') + abort = False + msg = widgets.myMessageBox(parent=self.host) + warn_msg = html_utils.paragraph(f""" + The folder {pos_foldername} does not contain a + pre-computed segmentation mask.

+ You can continue with a blank mask or cancel and + pre-compute the mask with the segmentation module.

+ Do you want to continue? + """) + msg.setIcon(iconName='SP_MessageBoxWarning') + msg.setWindowTitle('Segmentation file not found') + msg.addText(warn_msg) + msg.addButton('Ok') + continueWithBlankSegm = msg.addButton(' Cancel ') + msg.show(block=True) + if continueWithBlankSegm == msg.clickedButton: + abort = True + self.loadDataWorker.abort = abort + self.loadDataWaitCond.wakeAll() + + def openFolder( + self, checked=False, exp_path=None, imageFilePath='' + ): + if exp_path is None: + self.logger.info('Asking to select a folder path...') + else: + self.logger.info(f'Opening FOLDER "{exp_path}"...') + + self.isNewFile = False + if hasattr(self, 'data') and self.titleLabel.text != 'Saved!': + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Do you want to save before loading another dataset?' + ) + _, no, yes = msg.question( + self.host, 'Save?', txt, + buttonsTexts=('Cancel', 'No', 'Yes') + ) + if msg.clickedButton == yes: + func = partial(self._openFolder, exp_path, imageFilePath) + cancel = self.saveData(finishedCallback=func) + return + elif msg.cancel: + self.store_data() + return + else: + self.store_data(autosave=False) + + self._openFolder( + exp_path=exp_path, imageFilePath=imageFilePath + ) + + def addToRecentPaths(self, path, logger=None): + self.view_model.workspace.add_recent_path(path, logger=self.logger) + + def getMostRecentPath(self): + return self.view_model.workspace.most_recent_path() + + @exception_handler + def _openFolder( + self, checked=False, exp_path=None, imageFilePath='' + ): + """Main function to load data. + + Parameters + ---------- + checked : bool + kwarg needed because openFolder can be called by openFolderAction. + exp_path : string or None + Path selected by the user either directly, through openFile, + or drag and drop image file. + imageFilePath : string + Path of the image file that was either drag and dropped or opened + from File --> Open image/video file (openFileAction). + + Returns + ------- + None + """ + + if exp_path is None: + self.MostRecentPath = self.getMostRecentPath() + exp_path = QFileDialog.getExistingDirectory( + self.host, + 'Select experiment folder containing Position_n folders ' + 'or specific Position_n folder', + self.MostRecentPath + ) + + if not exp_path: + self.openFolderAction.setEnabled(True) + return + + proceed = self.reInitGui() + if not proceed: + self.openFolderAction.setEnabled(True) + return + + self.openFolderAction.setEnabled(False) + + if self.slideshowWin is not None: + self.slideshowWin.close() + + if self.ccaTableWin is not None: + self.ccaTableWin.close() + + self.exp_path = exp_path + self.logger.info(f'Loading from {self.exp_path}') + self.addToRecentPaths(exp_path, logger=self.logger) + self.addPathToOpenRecentMenu(exp_path) + + folder_type = self.view_model.workspace.determine_folder_type(exp_path) + is_pos_folder, is_images_folder, exp_path = folder_type + + self.titleLabel.setText('Loading data...', color=self.titleColor) + + skip_channels = [] + ch_name_selector = prompts.select_channel_name( + which_channel='segm', allow_abort=False + ) + user_ch_name = None + if not is_pos_folder and not is_images_folder and not imageFilePath: + images_paths = self._loadFromExperimentFolder(exp_path) + if not images_paths: + self.loadingDataAborted() + return + + elif is_pos_folder and not imageFilePath: + pos_foldername = os.path.basename(exp_path) + exp_path = os.path.dirname(exp_path) + images_paths = [os.path.join(exp_path, pos_foldername, 'Images')] + + elif is_images_folder and not imageFilePath: + images_paths = [exp_path] + pos_path = os.path.dirname(exp_path) + exp_path = os.path.dirname(pos_path) + + elif imageFilePath: + # images_path = exp_path because called by openFile func + filenames = self.view_model.workspace.listdir(exp_path) + ch_names, basenameNotFound = ( + ch_name_selector.get_available_channels(filenames, exp_path) + ) + filename = os.path.basename(imageFilePath) + self.ch_names = ch_names + user_ch_name = [ + chName for chName in ch_names if filename.find(chName)!=-1 + ][0] + images_paths = [exp_path] + pos_path = os.path.dirname(exp_path) + exp_path = os.path.dirname(pos_path) + + self.images_paths = images_paths + + # Get info from first position selected + images_path = self.images_paths[0] + filenames = self.view_model.workspace.listdir(images_path) + if ch_name_selector.is_first_call and user_ch_name is None: + ch_names, _ = ch_name_selector.get_available_channels( + filenames, images_path + ) + self.ch_names = ch_names + if not ch_names: + self.openFolderAction.setEnabled(True) + self.criticalNoTifFound(images_path) + return + if len(ch_names) > 1: + CbLabel='Select channel name to load: ' + ch_name_selector.QtPrompt( + self.host, ch_names, CbLabel=CbLabel + ) + if ch_name_selector.was_aborted: + self.openFolderAction.setEnabled(True) + return + skip_channels.extend([ + ch for ch in ch_names if ch!=ch_name_selector.channel_name + ]) + else: + ch_name_selector.channel_name = ch_names[0] + ch_name_selector.setUserChannelName() + user_ch_name = ch_name_selector.user_ch_name + else: + # File opened directly with self.openFile + ch_name_selector.channel_name = user_ch_name + + user_ch_file_paths = [] + not_allowed_ends = ['btrack_tracks.h5'] + for images_path in self.images_paths: + channel_file_path = load.get_filename_from_channel( + images_path, user_ch_name, skip_channels=skip_channels, + not_allowed_ends=not_allowed_ends, logger=self.logger.info + ) + if not channel_file_path: + self.criticalImgPathNotFound(images_path) + return + user_ch_file_paths.append(channel_file_path) + + ch_name_selector.setUserChannelName() + self.user_ch_name = user_ch_name + self.img1.channelName = user_ch_name + + self.AutoPilotProfile.storeSelectedChannel(self.user_ch_name) + + self.initGlobalAttr() + self.createOverlayContextMenu() + self.createUserChannelNameAction() + self.gui_createOverlayColors() + self.gui_createOverlayItems() + lastRow = self.bottomLeftLayout.rowCount() + self.bottomLeftLayout.setRowStretch(lastRow+1, 1) + + self.num_pos = len(user_ch_file_paths) + proceed = self.loadSelectedData(user_ch_file_paths, user_ch_name) + if not proceed: + self.openFolderAction.setEnabled(True) + return + + def _loadFromExperimentFolder(self, exp_path): + select_folder = load.select_exp_folder() + values = select_folder.get_values_segmGUI(exp_path) + if not values: + self.criticalInvalidPosFolder(exp_path) + self.openFolderAction.setEnabled(True) + return [] + + if len(values) > 1: + select_folder.QtPrompt(self.host, values, allow_cancel=False) + if select_folder.cancel: + return [] + else: + select_folder.cancel = False + select_folder.selected_pos = select_folder.pos_foldernames + + images_paths = [] + for pos in select_folder.selected_pos: + images_paths.append(os.path.join(exp_path, pos, 'Images')) + return images_paths + + def criticalInvalidPosFolder(self, exp_path): + href = html_utils.href_tag('here', data_structure_docs_url) + txt = html_utils.paragraph(f""" + The selected folder:

+ + {exp_path}

+ + is not a valid folder.

+ + Select a folder that contains the Position_n folders, + or a specific Position.

+ + If you are trying to load a single image file go to + File --> Open image/video file....

+ + To load a folder containing multiple .tif files the folder must + be called either Position_n
+ (with n being an integer) or Images.

+ + For more information about the correct folder structure see {href}. + """) + msg = widgets.myMessageBox(wrapText=False) + helpButton = widgets.helpPushButton('Help...') + msg.addButton(helpButton) + helpButton.clicked.disconnect() + helpButton.clicked.connect( + partial(myutils.browse_url, data_structure_docs_url) + ) + msg.addShowInFileManagerButton(exp_path) + msg.critical( + self.host, 'Incompatible folder', txt + ) + + def openFile(self, checked=False, file_path=None): + self.logger.info(f'Opening FILE "{file_path}"') + + self.isNewFile = False + self._openFile(file_path=file_path) + + def askUserChannelName(self, filename_no_ext, ext): + help_txt = html_utils.paragraph(f""" + Cell-ACDC requires that every image file has a basename and some + additional text, typically the channel name.

+ The basename will be common to all created files, while the additional text is used to identify the image files. + """) + + suggestion = self.view_model.channel_name_suggestion(filename_no_ext) + basename = suggestion.basename + channel_name = suggestion.channel_name + + txt = html_utils.paragraph(f""" + Provide some text (e.g., the channel name) to append at the end of the image file. + """) + win = apps.filenameDialog( + basename=basename, + ext=ext, + hintText=txt, + defaultEntry=channel_name, + helpText=help_txt, + allowEmpty=False, + parent=self.host, + title='Provide channel name for image file', + ) + win.exec_() + if win.cancel: + return False, '' + + return True, win.entryText + + def warnUserCreationImagesFolder(self, images_path, ext): + msg = widgets.myMessageBox(wrapText=False) + txt = (f""" + Cell-ACDC requires a specific folder structure to load the data.

+ Specifically, it requires the image(s) to be located in a + folder called Images.

+ The file format of the images must be TIFF or NPZ + (.tif or .npz extension).

+ You can choose to let Cell-ACDC create the required data structure + from your file,
+ or you can stop the + process and manually place the image(s) into a folder called + Images.

+ If you choose to proceed, Cell-ACDC will create the following + folder: + {images_path} +
+ """) + + if ext == '.tif' or ext == '.npz': + txt = f'{txt}How do you want to proceed?' + else: + txt = f'{txt}Do you want to proceed?' + txt = html_utils.paragraph(txt) + + if ext == '.tif' or ext == '.npz': + copyButton = widgets.copyPushButton( + 'Copy the image into the new folder' + ) + moveButton = widgets.movePushButton( + 'Move the image into the new folder' + ) + _, copyButton, moveButton = msg.information( + self.host, 'Creating Images folder', txt, + buttonsTexts=('Cancel', copyButton, moveButton) + ) + if msg.cancel: + return False, None + + if msg.clickedButton == copyButton: + return True, True + elif msg.clickedButton == moveButton: + return True, False + + else: + msg.information( + self.host, 'Creating Images folder', txt, + buttonsTexts=('Cancel', 'Yes, proceed') + ) + if msg.cancel: + return False, None + + return True, True + + @exception_handler + def _openFile(self, file_path=None): + """ + Function used for loading an image file directly. + """ + if file_path is None: + self.MostRecentPath = self.getMostRecentPath() + file_path = QFileDialog.getOpenFileName( + self.host, 'Select image file', self.MostRecentPath, + "Image/Video Files (*.png *.tif *.tiff *.jpg *.jpeg *.mov *.avi *.mp4)" + ";;All Files (*)")[0] + if not file_path: + return + + context = self.view_model.open_image_file_context(file_path) + channel_name = None + do_copy = True + if context.requires_images_folder: + proceed, do_copy = self.warnUserCreationImagesFolder( + context.exp_path, context.extension + ) + if not proceed: + self.logger.info('Loading image file cancelled.') + return + + proceed, channel_name = self.askUserChannelName( + context.filename_no_ext, '.tif' + ) + if not proceed: + self.logger.info('Loading image file cancelled.') + return + + os.makedirs(context.exp_path, exist_ok=True) + + target = self.view_model.open_image_file_target( + context, channel_name=channel_name + ) + if target.has_metadata: + df_metadata = pd.DataFrame({ + 'Description': ['basename'], + 'values': [target.basename] + }) + df_metadata.to_csv(target.metadata_csv_filepath, index=False) + + action_text = self.view_model.copy_action_text(do_copy) + + if target.direct_copy_supported: + if not os.path.exists(target.new_filepath): + self.logger.info(f'{action_text} file to Images folder...') + if do_copy: + shutil.copy2(file_path, target.new_filepath) + else: + shutil.move(file_path, target.new_filepath) + self._openFolder( + exp_path=context.exp_path, imageFilePath=target.new_filepath + ) + else: + self.logger.info(f'{action_text} file to .tif format...') + data = load.loadData( + context.file_path, '', log_func=self.logger.info + ) + data.loadImgData() + preparation = self.view_model.prepare_tiff_image_data( + data.img_data + ) + if preparation.converted_rgb_to_gray: + self.logger.info('Converting RGB image to grayscale...') + data.img_data = preparation.image + + myutils.to_tiff(target.tif_path, data.img_data) + self._openFolder( + exp_path=context.exp_path, imageFilePath=target.tif_path + ) + + def criticalNoTifFound(self, images_path): + err_title = 'No .tif files found in folder.' + err_msg = html_utils.paragraph( + 'The following folder

' + f'{images_path}

' + 'does not contain .tif or .h5 files.

' + 'Only .tif or .h5 files can be loaded with "Open Folder" button.

' + 'Try with File --> Open image/video file... ' + 'and directly select the file you want to load.' + ) + msg = widgets.myMessageBox() + msg.addShowInFileManagerButton(images_path) + msg.critical(self.host, err_title, err_msg) + + @exception_handler + def _createEmptyData(self): + self.MostRecentPath = self.getMostRecentPath() + exp_path = QFileDialog.getExistingDirectory( + self.host, + 'Select experiment folder where to create empty data', + self.MostRecentPath + ) + if not exp_path: + return + + plan = self.view_model.empty_data_plan(exp_path) + if os.path.exists(plan.images_path): + raise FileExistsError( + f'The following path already exists "{plan.images_path}"' + ) + + os.makedirs(plan.images_path, exist_ok=True) + + empty_img = np.zeros((256,256), dtype=np.uint8) + empty_img[0,0] = 255 + skimage.io.imsave(plan.tif_filepath, empty_img) + + df_metadata = pd.DataFrame({ + 'Description': ['basename'], + 'values': [plan.basename] + }) + df_metadata.to_csv(plan.metadata_filepath, index=False) + + self.isNewFile = True + self._openFolder(exp_path=plan.images_path) + + def newFile(self): + self.newSegmEndName = '' + self.isNewFile = True + msg = widgets.myMessageBox(parent=self.host, showCentered=False) + msg.setWindowTitle('File or folder?') + msg.addText(html_utils.paragraph(f""" + Do you want to load an image file or Position + folder(s)? + """)) + loadPosButton = QPushButton('Load Position folder', msg) + loadPosButton.setIcon(QIcon(":folder-open.svg")) + loadFileButton = QPushButton('Load image file', msg) + loadFileButton.setIcon(QIcon(":image.svg")) + helpButton = widgets.helpPushButton('Help...') + msg.addButton(helpButton) + helpButton.disconnect() + helpButton.clicked.connect(self.helpNewFile) + msg.addCancelButton(connect=True) + msg.addButton(loadFileButton) + msg.addButton(loadPosButton) + loadPosButton.setDefault(True) + msg.exec_() + if msg.cancel: + return + + if msg.clickedButton == loadPosButton: + self._openFolder() + else: + self._openFile() + + def helpNewFile(self): + msg = widgets.myMessageBox(showCentered=False) + href = f'user manual' + txt = html_utils.paragraph(f""" + Cell-ACDC can open both a single image file or files structured + into Position folders.

+ If you are just testing out you can load a single image file, but + in general we reccommend structuring your data into Position + folders.

+ More info about Position folders in the {href} at the section + called "Create required data structure from microscopy file(s)". + """) + msg.information( + self.host, 'Help on Position folders', txt + ) + + def criticalImgPathNotFound(self, images_path): + self.logger.info( + 'The following folder does not contain valid image files: ' + f'"{images_path}"\n\n' + 'Check that all the positions loaded contain the same channel name. ' + 'Make sure to double check for spelling mistakes or types in the ' + 'channel names.' + ) + msg = widgets.myMessageBox() + msg.addShowInFileManagerButton(images_path) + err_msg = html_utils.paragraph(f""" + The folder

+ {images_path}

+ does not contain any valid image file!

+ Valid file formats are .h5, .tif, _aligned.h5, _aligned.npz. + """) + okButton = msg.critical( + self.host, 'No valid files found!', err_msg, buttonsTexts=('Ok',) + ) + + def openRecentFile(self, path): + self.logger.info(f'Opening recent folder: {path}') + self.addToRecentPaths(path, logger=self.logger) + self.openFolder(exp_path=path) diff --git a/cellacdc/views/deleted_rois_view.py b/cellacdc/views/deleted_rois_view.py new file mode 100644 index 000000000..08dac618d --- /dev/null +++ b/cellacdc/views/deleted_rois_view.py @@ -0,0 +1,625 @@ +"""Qt view adapter for deleted-ROI workflows.""" + +from __future__ import annotations + +from functools import partial +import uuid + +import numpy as np +import pyqtgraph as pg +import skimage.measure +from qtpy.QtCore import QRect, QRectF, QTimer + +from cellacdc import widgets +from cellacdc.viewmodels.deleted_rois_viewmodel import DeletedRoisViewModel + + +class DeletedRoisView: + """Qt-facing adapter around deleted-ROI workflows.""" + + LEGACY_METHODS = ( + 'removeAlldelROIsCurrentFrame', + 'removeDelROI', + 'removeDelROIFromFutureFrames', + 'updateDelROIinFutureFrames', + 'addDelROI', + 'replacePolyLineRoiWithLineRoi', + 'addRoiToDelRoiInfo', + 'addDelPolyLineRoi_cb', + 'createDelPolyLineRoi', + 'addPointsPolyLineRoi', + 'createDelROI', + 'delROIstartedMoving', + 'clearLostObjContoursItems', + 'delROImoving', + 'delROImovingFinished', + 'restoreAnnotDelROI', + 'restoreDelROIimg1', + 'getDelRoisIDs', + 'getStoredDelRoiIDs', + 'getDelROIlab', + 'getDelRoiMask', + 'initDelRoiLab', + 'moveDelRoisToLeft', + 'applyDelROIimg1', + 'applyDelROIs', + 'setDelRoiState', + 'addExistingDelROIs', + ) + + def __init__(self, host, view_model: DeletedRoisViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def removeAlldelROIsCurrentFrame(self): + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + rois = delROIs_info['rois'].copy() + for roi in rois: + self.ax2.removeDelRoiItem(roi) + + for item in self.ax2.items: + if isinstance(item, pg.ROI): + self.ax2.removeDelRoiItem(item) + + for item in self.ax1.items: + if isinstance(item, pg.ROI) and item != self.labelRoiItem: + self.ax1.removeDelRoiItem(item) + + def removeDelROI(self, event): + posData = self.data[self.pos_i] + + for ax in (self.ax1, self.ax2): + try: + self.ax1.removeDelRoiItem(self.roi_to_del) + except Exception as err: + pass + + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + idx = delROIs_info['rois'].index(self.roi_to_del) + delROIs_info['rois'].pop(idx) + delROIs_info['delMasks'].pop(idx) + delROIs_info['delIDsROI'].pop(idx) + delROIs_info['state'].pop(idx) + + self.removeDelROIFromFutureFrames(self.roi_to_del) + self.updateAllImages() + + def removeDelROIFromFutureFrames(self, roi_to_del): + posData = self.data[self.pos_i] + + # Restore deleted IDs from already visited future frames + current_frame_i = posData.frame_i + for i in range(posData.frame_i+1, posData.SizeT): + if posData.allData_li[i]['labels'] is None: + break + + delROIs_info = posData.allData_li[i]['delROIs_info'] + try: + idx = delROIs_info['rois'].index(roi_to_del) + except IndexError: + continue + + posData.frame_i = i + idx = delROIs_info['rois'].index(roi_to_del) + if delROIs_info['delIDsROI'][idx]: + posData.lab = posData.allData_li[i]['labels'] + self.restoreAnnotDelROI(roi_to_del, enforce=True, draw=False) + posData.allData_li[i]['labels'] = posData.lab + self.get_data() + self.store_data(autosave=False) + delROIs_info['rois'].pop(idx) + delROIs_info['delMasks'].pop(idx) + delROIs_info['delIDsROI'].pop(idx) + delROIs_info['state'].pop(idx) + + target_axis = self.view_model.roi_axis( + is_polyline=isinstance(self.roi_to_del, pg.PolyLineROI), + labels_image_visible=self.labelsGrad.showLabelsImgAction.isChecked(), + ) + if target_axis == 'left': + self.ax1.removeItem(self.roi_to_del) + else: + self.ax2.removeItem(self.roi_to_del) + + # Back to current frame + posData.frame_i = current_frame_i + posData.lab = posData.allData_li[posData.frame_i]['labels'] + self.get_data() + self.store_data() + + def updateDelROIinFutureFrames(self, roi: pg.ROI): + posData = self.data[self.pos_i] + restore_current_frame = False + + roiState = roi.getState() + # Restore deleted IDs from already visited future frames + current_frame_i = posData.frame_i + delROIs_info = posData.allData_li[current_frame_i]['delROIs_info'] + try: + idx = delROIs_info['rois'].index(roi) + delROIs_info['state'][idx] = roiState + except Exception as err: + pass + + self.store_data() + + for i in range(posData.frame_i+1, posData.SizeT): + delROIs_info = posData.allData_li[i]['delROIs_info'] + try: + idx = delROIs_info['rois'].index(roi) + except Exception as err: + continue + delROIs_info['state'][idx] = roiState + if posData.allData_li[i]['labels'] is None: + continue + + posData.frame_i = i + posData.lab = posData.allData_li[i]['labels'] + self.restoreAnnotDelROI(roi, enforce=False, draw=False) + posData.allData_li[i]['labels'] = posData.lab + self.get_data() + self.store_data(autosave=False) + restore_current_frame = True + + if not restore_current_frame: + return + + # Back to current frame + posData.frame_i = current_frame_i + posData.lab = posData.allData_li[posData.frame_i]['labels'] + self.get_data() + self.store_data() + + def addDelROI(self, event): + roi, key = self.createDelROI() + self.addRoiToDelRoiInfo(roi) + target_axis = self.view_model.roi_axis( + is_polyline=False, + labels_image_visible=self.labelsGrad.showLabelsImgAction.isChecked(), + ) + if target_axis == 'left': + self.ax1.addDelRoiItem(roi, key) + else: + self.ax2.addDelRoiItem(roi, key) + self.applyDelROIimg1(roi, init=True) + self.applyDelROIimg1(roi, init=True, ax=1) + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Delete IDs using ROI') + self.updateAllImages() + else: + self.warnEditingWithCca_df( + 'Delete IDs using ROI', get_cancelled=True + ) + + def replacePolyLineRoiWithLineRoi(self, roi): + x0, y0 = roi.pos().x(), roi.pos().y() + (_, point1), (_, point2) = roi.getLocalHandlePositions() + xr1, yr1 = point1.x(), point1.y() + xr2, yr2 = point2.x(), point2.y() + x1, y1 = xr1+x0, yr1+y0 + x2, y2 = xr2+x0, yr2+x0 + lineRoi = pg.LineROI((x1, y1), (x2, y2), width=0.5) + lineRoi.handleSize = 7 + self.ax1.removeItem(self.polyLineRoi) + self.ax1.addItem(lineRoi) + lineRoi.removeHandle(2) + # Connect closed ROI + lineRoi.sigRegionChanged.connect(self.delROImoving) + lineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished) + return lineRoi + + def addRoiToDelRoiInfo(self, roi: pg.ROI): + posData = self.data[self.pos_i] + for i in range(posData.frame_i, posData.SizeT): + delROIs_info = posData.allData_li[i]['delROIs_info'] + delROIs_info['rois'].append(roi) + delROIs_info['state'].append(roi.getState()) + delROIs_info['delMasks'].append(np.zeros_like(self.currentLab2D)) + delROIs_info['delIDsROI'].append(set()) + + def addDelPolyLineRoi_cb(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.addDelPolyLineRoiButton) + self.connectLeftClickButtons() + if self.isSnapshot: + self.fixCcaDfAfterEdit('Delete IDs using ROI') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Delete IDs using ROI') + else: + self.tempSegmentON = False + self.ax1_rulerPlotItem.setData([], []) + self.ax1_rulerAnchorsItem.setData([], []) + self.startPointPolyLineItem.setData([], []) + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + def createDelPolyLineRoi(self): + Y, X = self.currentLab2D.shape + self.polyLineRoi = pg.PolyLineROI( + [], rotatable=False, + removable=True, + pen=pg.mkPen(color='r') + ) + self.polyLineRoi.handleSize = 7 + self.polyLineRoi.points = [] + key = uuid.uuid4() + self.ax1.addDelRoiItem(self.polyLineRoi, key) + + def addPointsPolyLineRoi(self, closed=False): + self.polyLineRoi.setPoints(self.polyLineRoi.points, closed=closed) + if not closed: + return + + # Connect closed ROI + self.polyLineRoi.sigRegionChanged.connect(self.delROImoving) + self.polyLineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished) + + def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None): + posData = self.data[self.pos_i] + if xl is None: + xRange, yRange = self.ax1.viewRange() + xl = 0 if xRange[0] < 0 else xRange[0] + yb = 0 if yRange[0] < 0 else yRange[0] + Y, X = self.currentLab2D.shape + if anchors is None: + roi = widgets.DelROI( + [xl, yb], [w, h], + rotatable=False, + removable=True, + pen=pg.mkPen(color='r'), + maxBounds=QRectF(QRect(0,0,X,Y)) + ) + ## handles scaling horizontally around center + roi.addScaleHandle([1, 0.5], [0, 0.5]) + roi.addScaleHandle([0, 0.5], [1, 0.5]) + + ## handles scaling vertically from opposite edge + roi.addScaleHandle([0.5, 0], [0.5, 1]) + roi.addScaleHandle([0.5, 1], [0.5, 0]) + + ## handles scaling both vertically and horizontally + roi.addScaleHandle([1, 1], [0, 0]) + roi.addScaleHandle([0, 0], [1, 1]) + roi.addScaleHandle([0, 1], [1, 0]) + roi.addScaleHandle([1, 0], [0, 1]) + + roi.handleSize = 7 + roi.sigRegionChanged.connect(self.delROImoving) + roi.sigRegionChanged.connect(self.delROIstartedMoving) + roi.sigRegionChangeFinished.connect(self.delROImovingFinished) + + key = uuid.uuid4() + + return roi, key + + def delROIstartedMoving(self, roi): + self.clearLostObjContoursItems() + + def clearLostObjContoursItems(self): + self.ax1_lostObjScatterItem.setData([], []) + self.ax2_lostObjScatterItem.setData([], []) + + self.ax1_lostTrackedScatterItem.setData([], []) + self.ax2_lostTrackedScatterItem.setData([], []) + + self.ax2_lostObjImageItem.clear() + self.ax2_lostTrackedObjImageItem.clear() + + self.ax1_lostObjImageItem.clear() + self.ax1_lostTrackedObjImageItem.clear() + + def delROImoving(self, roi): + roi.setPen(color=(255,255,0)) + # First bring back IDs if the ROI moved away + self.restoreAnnotDelROI(roi) + self.setImageImg2() + self.applyDelROIimg1(roi) + self.applyDelROIimg1(roi, ax=1) + + def delROImovingFinished(self, roi: pg.ROI): + roi.setPen(color='r') + self.update_rp() + self.updateAllImages() + QTimer.singleShot( + 300, partial(self.updateDelROIinFutureFrames, roi) + ) + + def restoreAnnotDelROI(self, roi, enforce=True, draw=True): + posData = self.data[self.pos_i] + ROImask = self.getDelRoiMask(roi) + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + try: + idx = delROIs_info['rois'].index(roi) + except Exception as err: + return + + delMask = delROIs_info['delMasks'][idx] + delIDs = delROIs_info['delIDsROI'][idx] + lab2D = self.get_2Dlab(posData.lab) + result = self.host.view_model.label_edits.restore_deleted_roi_labels( + lab2D, + self.currentLab2D, + delMask, + ROImask, + delIDs, + enforce=enforce, + ) + if draw: + for ID, delMaskID in result.restored_masks: + self.restoreDelROIimg1(delMaskID, ID, ax=0) + self.restoreDelROIimg1(delMaskID, ID, ax=1) + + delROIs_info['delIDsROI'][idx] = result.remaining_deleted_ids + self.set_2Dlab(result.labels_2d) + self.update_rp() + + def restoreDelROIimg1(self, delMaskID, delID, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + if not self.view_model.should_render_deleted_roi(how): + return + + if self.view_model.should_render_deleted_roi_contours(how): + rp_delmask = skimage.measure.regionprops(delMaskID.astype(np.uint8)) + if len(rp_delmask) > 0: + obj = rp_delmask[0] + self.addObjContourToContoursImage(obj=obj, ax=ax) + elif self.view_model.should_render_deleted_roi_overlay(how): + if ax == 0: + self.labelsLayerImg1.setImage( + self.currentLab2D, autoLevels=False + ) + else: + self.labelsLayerRightImg.setImage( + self.currentLab2D, autoLevels=False + ) + + def getDelRoisIDs(self): + posData = self.data[self.pos_i] + roi_masks = [] + if posData.frame_i > 0: + prev_lab = posData.allData_li[posData.frame_i-1]['labels'] + for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: + if ( + not self.ax1.isDelRoiItemPresent(roi) + and not self.ax2.isDelRoiItemPresent(roi) + ): + continue + roi_masks.append(self.getDelRoiMask(roi)) + + return self.host.view_model.label_edits.label_ids_in_masks( + posData.lab, + roi_masks, + additional_labels=prev_lab if posData.frame_i > 0 else None, + ) + + def getStoredDelRoiIDs(self, frame_i=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + delROIs_info = posData.allData_li[frame_i]['delROIs_info'] + return self.host.view_model.label_edits.collect_deleted_roi_ids( + delROIs_info['delIDsROI'] + ) + + # @exec_time + def getDelROIlab(self, input_lab_2D=None): + posData = self.data[self.pos_i] + if self.delRoiLab is None: + self.initDelRoiLab() + + out_lab = self.delRoiLab + if input_lab_2D is None: + out_lab[:] = self.get_2Dlab(posData.lab, force_z=False) + else: + out_lab[:] = input_lab_2D + + roi_masks = [] + deleted_masks = [] + deleted_ids_by_roi = [] + roi_indices = [] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: + if ( + not self.ax1.isDelRoiItemPresent(roi) + and not self.ax2.isDelRoiItemPresent(roi) + ): + continue + idx = delROIs_info['rois'].index(roi) + roi_indices.append(idx) + roi_masks.append(self.getDelRoiMask(roi)) + deleted_masks.append(delROIs_info['delMasks'][idx]) + deleted_ids_by_roi.append(delROIs_info['delIDsROI'][idx]) + + result = self.host.view_model.label_edits.apply_deleted_roi_masks( + out_lab, + roi_masks, + deleted_masks, + deleted_ids_by_roi, + ) + for result_i, roi_i in enumerate(roi_indices): + # Keep a mask of deleted IDs to bring them back when ROI moves. + delROIs_info['delMasks'][roi_i] = result.deleted_masks[result_i] + delROIs_info['delIDsROI'][roi_i] = ( + result.deleted_ids_by_roi[result_i] + ) + + # printl( + # f't1-t0: {(t1-t0)*1000:.3f} ms,', + # f't2-t1: {(t2-t1)*1000:.3f} ms,', + # f't3-t2: {(t3-t2)*1000:.3f} ms,', + # # f't4-t3: {(t4-t3)*1000:.3f} ms,', + # # f't5-t4: {(t5-t4)*1000:.3f} ms,', + # # f't6-t5: {(t6-t5)*1000:.3f} ms', + # sep='\n' + # ) + + return result.deleted_ids, result.labels_2d + + def getDelRoiMask(self, roi, posData=None, z_slice=None): + if posData is None: + posData = self.data[self.pos_i] + if z_slice is None: + z_slice = self.z_lab() + if isinstance(roi, pg.PolyLineROI): + x0, y0 = roi.pos().x(), roi.pos().y() + points = [] + for _, point in roi.getLocalHandlePositions(): + xr, yr = point.x(), point.y() + points.append((int(xr+x0), int(yr+y0))) + return self.host.view_model.label_edits.polygon_roi_mask( + posData.lab.shape, + points, + z_slice=z_slice, + ) + elif isinstance(roi, pg.LineROI): + (_, point1), (_, point2) = roi.getSceneHandlePositions() + point1 = self.ax1.vb.mapSceneToView(point1) + point2 = self.ax1.vb.mapSceneToView(point2) + return self.host.view_model.label_edits.line_roi_mask( + posData.lab.shape, + (point1.x(), point1.y()), + (point2.x(), point2.y()), + z_slice=z_slice, + ) + else: + return self.host.view_model.label_edits.rectangle_roi_mask( + posData.lab.shape, + roi.pos(), + roi.size(), + z_slice=z_slice, + ) + + def initDelRoiLab(self): + posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] + + self.delRoiLab = np.zeros((Y, X), dtype=np.uint32) + + def moveDelRoisToLeft(self): + # Move del ROIs to the left image + for posData in self.data: + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + for roi in delROIs_info['rois']: + if not self.ax2.isDelRoiItemPresent(roi): + continue + + self.ax1.addDelRoiItem(roi, roi.key) + self.ax2.removeDelRoiItem(roi) + + def applyDelROIimg1(self, roi, init=False, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): + return + + if self.view_model.should_initialize_overlay_masks(init, how): + self.setOverlaySegmMasks(force=True) + return + + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + try: + idx = delROIs_info['rois'].index(roi) + except Exception as err: + try: + ax.removeDelRoiItem(roi) + except Exception as err: + pass + return + delIDs = delROIs_info['delIDsROI'][idx] + delMask = delROIs_info['delMasks'][idx] + if not self.view_model.should_render_deleted_roi(how): + return + elif self.view_model.should_render_deleted_roi_contours(how): + self.updateContoursImage(ax=ax) + + if not delIDs: + return + + if self.view_model.should_render_deleted_roi_overlay(how): + lab = self.currentLab2D.copy() + lab[delMask > 0] = 0 + if ax == 0: + self.labelsLayerImg1.setImage(lab, autoLevels=False) + else: + self.labelsLayerRightImg.setImage(lab, autoLevels=False) + + self.setAllTextAnnotations( + labelsToSkip=self.view_model.labels_to_skip(delIDs) + ) + + def applyDelROIs(self): + self.logger.info('Applying deletion ROIs (if present)...') + + for posData in self.data: + self.current_frame_i = posData.frame_i + for frame_i in range(posData.SizeT): + lab = posData.allData_li[frame_i]['labels'] + if lab is None: + break + delROIs_info = posData.allData_li[frame_i]['delROIs_info'] + delIDs_rois = delROIs_info['delIDsROI'] + if not delIDs_rois: + continue + for delIDs in delIDs_rois: + for delID in delIDs: + lab[lab==delID] = 0 + posData.allData_li[frame_i]['labels'] = lab + # Get the rest of the metadata and store data based on the new lab + posData.frame_i = frame_i + self.get_data() + self.store_data(autosave=False) + + # Back to current frame + posData.frame_i = self.current_frame_i + self.get_data() + + def setDelRoiState(self, roi: pg.ROI, state): + roi.sigRegionChanged.disconnect() + roi.sigRegionChangeFinished.disconnect() + roi.setState(state) + roi.sigRegionChanged.connect(self.delROImoving) + roi.sigRegionChangeFinished.connect(self.delROImovingFinished) + + def addExistingDelROIs(self): + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + for r, roi in enumerate(delROIs_info['rois']): + target_axis = self.view_model.roi_axis( + is_polyline=isinstance(roi, pg.PolyLineROI), + labels_image_visible=( + self.labelsGrad.showLabelsImgAction.isChecked() + ), + ) + if target_axis == 'left': + self.ax1.addDelRoiItem(roi, roi.key) + else: + self.ax2.addDelRoiItem(roi, roi.key) + + self.setDelRoiState(roi, delROIs_info['state'][r]) diff --git a/cellacdc/views/display_decorations_view.py b/cellacdc/views/display_decorations_view.py new file mode 100644 index 000000000..27ff009ac --- /dev/null +++ b/cellacdc/views/display_decorations_view.py @@ -0,0 +1,200 @@ +"""View adapter for timestamp, scale-bar, and view-range decorations.""" + +from __future__ import annotations + +import numpy as np + +from cellacdc import apps, widgets +from cellacdc.viewmodels.display_decorations_viewmodel import ( + DisplayDecorationsViewModel, +) + + +class DisplayDecorationsView: + """Qt-facing adapter around display-decoration contracts.""" + + def __init__(self, host, view_model: DisplayDecorationsViewModel): + self.host = host + self.view_model = view_model + + def get_view_range(self): + return self.view_model.clamped_view_range( + self.host.img1.image.shape, + self.host.ax1.viewRange(), + ) + + def ax1_view_range(self, integers=False): + view_range = self._ax1_raw_view_range() + if not integers: + return view_range + return self.view_model.integer_view_range(view_range) + + def view_range_changed( + self, + view_box, + view_range, + updateExportImageMask=True, + ): + self.host.status_hover_view.update_values_status_bar() + + if hasattr(self.host, 'scaleBar'): + scale_bar_move_with_zoom = ( + self.host.scaleBar.properties()['move_with_zoom'] + ) + else: + scale_bar_move_with_zoom = False + if self.view_model.should_move_decoration( + dialog_open=self.host.scaleBarDialog is not None, + move_with_zoom=scale_bar_move_with_zoom, + ): + self.host.scaleBar.updatePosViewRangeChanged(view_range) + + if hasattr(self.host, 'timestamp'): + timestamp_move_with_zoom = ( + self.host.timestamp.properties()['move_with_zoom'] + ) + else: + timestamp_move_with_zoom = False + if self.view_model.should_move_decoration( + dialog_open=self.host.timestampDialog is not None, + move_with_zoom=timestamp_move_with_zoom, + ): + self.host.timestamp.updatePosViewRangeChanged(view_range) + + self.host._viewRange = view_range + + def store_view_range(self): + if not self.view_model.should_store_view_range( + has_range_reset_state=hasattr(self.host, 'isRangeReset'), + is_range_reset=getattr(self.host, 'isRangeReset', False), + ): + return + self.host.ax1_viewRange = self.host.ax1.viewRange() + self.host.isRangeReset = False + + def add_timestamp(self, checked): + if checked: + pos_data = self.host.data[self.host.pos_i] + y_size, x_size = self.host.img1.image.shape[:2] + view_range = self.ax1_view_range() + self.host.timestampDialog = apps.TimestampPropertiesDialog( + parent=self.host + ) + self.host.timestampDialog.show() + self.host.timestamp = widgets.TimestampItem( + y_size, + x_size, + view_range, + secondsPerFrame=pos_data.TimeIncrement, + start_timedelta=self.host.timestampStartTimedelta, + ) + self.host.timestamp.sigEditProperties.connect( + self.edit_timestamp_properties + ) + self.host.timestamp.sigRemove.connect( + self.edit_timestamp_remove + ) + self.host.timestamp.addToAxis(self.host.ax1) + self.host.timestamp.draw( + pos_data.frame_i, **self.host.timestampDialog.kwargs() + ) + self.host.timestampDialog.sigValueChanged.connect( + self.update_timestamp + ) + self.host.timestampDialog.exec_() + else: + self.host.timestamp.removeFromAxis(self.host.ax1) + + self.host.timestampDialog = None + self.host.imgGrad.addTimestampAction.setChecked(checked) + + def add_scale_bar(self, checked): + if checked: + pos_data = self.host.data[self.host.pos_i] + y_size, x_size = self.host.img1.image.shape[:2] + view_range = self.ax1_view_range() + self.host.scaleBarDialog = apps.ScaleBarPropertiesDialog( + x_size, + y_size, + pos_data.PhysicalSizeX, + parent=self.host, + ) + self.host.scaleBarDialog.show() + self.host.scaleBar = widgets.ScaleBar( + (y_size, x_size), view_range, parent=self.host.ax1 + ) + self.host.scaleBar.sigEditProperties.connect( + self.edit_scale_bar_properties + ) + self.host.scaleBar.sigRemove.connect(self.edit_scale_bar_remove) + self.host.scaleBar.addToAxis(self.host.ax1) + self.host.scaleBar.draw(**self.host.scaleBarDialog.kwargs()) + self.host.scaleBarDialog.sigValueChanged.connect( + self.update_scale_bar + ) + self.host.scaleBarDialog.exec_() + if self.host.scaleBarDialog.cancel: + self.host.addScaleBarAction.setChecked(False) + return + else: + self.host.scaleBar.removeFromAxis(self.host.ax1) + + self.host.scaleBarDialog = None + self.host.imgGrad.addScaleBarAction.setChecked(checked) + + def update_scale_bar(self, scale_bar_kwargs): + self.host.scaleBar.draw(**scale_bar_kwargs) + + def update_timestamp(self, timestamp_kwargs): + pos_data = self.host.data[self.host.pos_i] + self.host.timestamp.draw(pos_data.frame_i, **timestamp_kwargs) + + def edit_scale_bar_remove(self, timestamp): + self.host.addScaleBarAction.setChecked(False) + + def edit_scale_bar_properties(self, properties): + y_size, x_size = self.host.img1.image.shape[:2] + pos_data = self.host.data[self.host.pos_i] + self.host.scaleBarDialog = apps.ScaleBarPropertiesDialog( + x_size, + y_size, + pos_data.PhysicalSizeX, + parent=self.host, + **properties, + ) + self.host.scaleBarDialog.sigValueChanged.connect( + self.update_scale_bar + ) + self.host.scaleBarDialog.exec_() + + def edit_timestamp_remove(self, timestamp): + self.host.addTimestampAction.setChecked(False) + + def edit_timestamp_properties(self, properties): + self.host.timestampDialog = apps.TimestampPropertiesDialog( + parent=self.host, **properties + ) + self.host.timestampDialog.sigValueChanged.connect( + self.update_timestamp + ) + self.host.timestampDialog.show() + + def update_timestamp_frame(self): + if not self.view_model.should_update_timestamp_frame( + has_timestamp=hasattr(self.host, 'timestamp'), + timestamp_enabled=self.host.addTimestampAction.isChecked(), + ): + return + + pos_data = self.host.data[self.host.pos_i] + self.host.timestamp.setText(pos_data.frame_i) + + def _ax1_raw_view_range(self): + if self.host.exportToImageWindow is None: + return self.host.ax1.viewRange() + export_mask = np.all( + self.host.exportMaskImage == [0, 0, 0, 0], axis=-1 + ) + if np.all(export_mask): + return self.host.ax1.viewRange() + return self.host.ax1.viewRange(export_mask) diff --git a/cellacdc/views/draw_clear_region_view.py b/cellacdc/views/draw_clear_region_view.py new file mode 100644 index 000000000..5739a1900 --- /dev/null +++ b/cellacdc/views/draw_clear_region_view.py @@ -0,0 +1,91 @@ +"""View adapter for draw-clear-region workflows.""" + +from __future__ import annotations + +from cellacdc.viewmodels.draw_clear_region_viewmodel import ( + DrawClearRegionViewModel, +) + + +class DrawClearRegionView: + """Qt-facing adapter around the scriptable draw-clear view-model.""" + + def __init__(self, host, view_model: DrawClearRegionViewModel): + self.host = host + self.view_model = view_model + + def toggle(self, checked): + pos_data = self.host.data[self.host.pos_i] + if checked: + self.host.disconnectLeftClickButtons() + self.host.uncheckLeftClickButtons(self.host.drawClearRegionButton) + self.host.connectLeftClickButtons() + + self.host.drawClearRegionToolbar.setVisible(checked) + state = self.view_model.toolbar_state( + checked=checked, + is_segm_3d=self.host.isSegm3D, + size_z=pos_data.SizeZ, + ) + if not state.update_z_control: + return + if state.z_control_enabled: + self.host.drawClearRegionToolbar.setZslicesControlEnabled( + True, SizeZ=state.size_z + ) + return + self.host.drawClearRegionToolbar.setZslicesControlEnabled(False) + + def clear_objects_in_freehand_region(self): + self.host.logger.info('Clearing objects inside freehand region...') + self.host.storeUndoRedoStates( + False, storeImage=False, storeOnlyZoom=True + ) + + pos_data = self.host.data[self.host.pos_i] + z_range = self._z_range(pos_data.SizeZ) + region_slice = self.host.freeRoiItem.slice(zRange=z_range) + mask = self.host.freeRoiItem.mask() + region_lab = pos_data.lab[(...,) + region_slice].copy() + + enclosed_only = ( + self.host.drawClearRegionToolbar + .clearOnlyEnclosedObjsRadioButton.isChecked() + ) + selection_result = ( + self.host.view_model.label_edits.select_labels_in_region( + region_lab, + mask, + enclosed_only=enclosed_only, + ) + ) + clear_ids = selection_result.selected_ids + + if not clear_ids: + self.host.logger.warning( + self.view_model.empty_selection_warning( + enclosed_only=enclosed_only + ) + ) + return + + self.host.deleteIDmiddleClick(clear_ids, False, False) + self.host.update_cca_df_deletedIDs(pos_data, clear_ids) + self.host.freeRoiItem.clear() + self.host.updateAllImages() + + def _z_range(self, size_z): + z_projection = None + single_z_range = None + if self.host.isSegm3D: + z_projection = self.host.zProjComboBox.currentText() + if self.view_model.is_single_z_projection(z_projection): + single_z_range = self.host.drawClearRegionToolbar.zRange( + self.host.z_lab(), size_z + ) + return self.view_model.z_range_for_projection( + is_segm_3d=self.host.isSegm3D, + z_projection=z_projection, + size_z=size_z, + single_z_range=single_z_range, + ) diff --git a/cellacdc/views/exporting_view.py b/cellacdc/views/exporting_view.py new file mode 100644 index 000000000..6d9dfcc96 --- /dev/null +++ b/cellacdc/views/exporting_view.py @@ -0,0 +1,387 @@ +"""Qt view adapter for image and video export workflows.""" + +from __future__ import annotations + +import os +import shutil +import traceback +from functools import partial + +from qtpy.QtCore import QTimer + +from cellacdc import _warnings, apps, disableWindow, exception_handler +from cellacdc import exporters, html_utils, prompts, widgets +from cellacdc.viewmodels.exporting_viewmodel import ExportingViewModel + + +class ExportingView: + """Qt-facing adapter around export dialogs, exporters, and progress UI.""" + + def __init__(self, host, view_model: ExportingViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def startExportToVideoWorker(self, preferences): + self.isExportingVideo = True + self.isTransparent = self.overlayToolbar.isTransparent() + if not self.isTransparent: + # SVG export works only with RGBA not with setOpacity + # --> only true transparency mode can be used + self.overlayToolbar.setTransparent(True) + + self.setDisabled(True) + + self.progressWin = apps.QDialogWorkerProgress( + title='Exporting to video', parent=self.host.mainWin, + pbarDesc='Exporting to video...' + ) + self.progressWin.show(self.app) + self.exportToVideoStopNavVarNum = preferences['stop_nav_var_num'] + self.numFramesExported = 0 + self.progressWin.mainPbar.setMaximum( + preferences['stop_nav_var_num'] + - preferences['start_nav_var_num'] + 1 + ) + self.exportToVideoPreferences = preferences + + self.store_data() + posData = self.data[self.pos_i] + if self.exportToVideoPreferences['is_timelapse']: + # Go to requested start frame + posData.frame_i = preferences['start_nav_var_num'] - 1 + self.get_data() + self.updateAllImages() + self.exportToVideoNavVarIdxToRestore = posData.frame_i + else: + self.update_z_slice(preferences['start_nav_var_num'] - 1) + self.exportToVideoNavVarIdxToRestore = ( + self.zSliceScrollBar.sliderPosition() + ) + self.exportToVideoCurrentNavVarIdx = ( + preferences['start_nav_var_num'] - 1 + ) + + self.exportToVideoImageExporter = exporters.ImageExporter( + self.ax1, + save_pngs=preferences['save_pngs'], + dpi=preferences['dpi'] + ) + self.exportToVideoExporter = exporters.VideoExporter( + preferences['avi_filepath'], preferences['fps'] + ) + + QTimer.singleShot(200, self.updateAndExportFrame) + + def updateAndExportFrame(self): + didVideoExporterFinish = ( + self.exportToVideoCurrentNavVarIdx + == self.exportToVideoStopNavVarNum + ) + if didVideoExporterFinish: + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.mainPbar.setValue(0) + QTimer.singleShot(50, self.exportingFramesFinished) + return + + posData = self.data[self.pos_i] + if self.exportToVideoPreferences['is_timelapse']: + self.goToFrameNumber(self.exportToVideoCurrentNavVarIdx+1) + else: + self.update_z_slice(self.exportToVideoCurrentNavVarIdx) + + success = self.exportFrame() + if success is None: + self.exportingVideoCritical() + return + + self.exportToVideoCurrentNavVarIdx += 1 + self.progressWin.mainPbar.update(1) + + QTimer.singleShot(50, self.updateAndExportFrame) + + @exception_handler + def exportFrame(self): + plan = self.view_model.export_frame_plan( + current_index=self.exportToVideoCurrentNavVarIdx, + num_digits=self.exportToVideoPreferences['num_digits'], + filename=self.exportToVideoPreferences['filename'], + pngs_folderpath=self.exportToVideoPreferences['pngs_folderpath'], + ) + img_bgr = self.exportToVideoImageExporter.export(plan.png_filepath) + self.exportToVideoExporter.add_frame(img_bgr) + return True + + def exportingVideoCritical(self): + self.setDisabled(False) + + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.logger.info('Exporting video process failed.') + + def exportingFramesFinished(self): + if not self.exportToVideoPreferences['save_pngs']: + self.logger.info('Removing PNGs...') + try: + shutil.rmtree(self.exportToVideoPreferences['pngs_folderpath']) + except Exception as err: + pass + + self.logger.info('Saving video...') + + self.exportToVideoExporter.release() + + # Run ffmpeg new process + conversion_to_mp4_successful = True + if self.exportToVideoPreferences['filepath'].endswith('.mp4'): + try: + self.exportToVideoExporter.avi_to_mp4() + try: + os.remove(self.exportToVideoPreferences['avi_filepath']) + except Exception as err: + pass + except Exception as err: + self.logger.exception(traceback.format_exc()) + self.logger.info( + 'Conversion to MP4 failed. See traceback above.' + ) + conversion_to_mp4_successful = False + self.exportToVideoPreferences['filepath'] = ( + self.exportToVideoExporter._avi_filepath + ) + + self.exportToVideoFinished(conversion_to_mp4_successful) + + def exportToVideoFinished(self, conversion_to_mp4_successful): + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + # Back to current frame + if self.exportToVideoPreferences['is_timelapse']: + posData = self.data[self.pos_i] + posData.frame_i = self.exportToVideoNavVarIdxToRestore + self.get_data() + self.store_data() + self.updateAllImages() + self.navigateScrollBar.setSliderPosition(posData.frame_i+1) + self.navSpinBox.setValue(posData.frame_i+1) + else: + self.update_z_slice(self.exportToVideoNavVarIdxToRestore) + + self.setDisabled(False) + self.isExportingVideo = False + + if not self.isTransparent: + # True transparency mode was activated programmatically + # --> restore what the user had before starting to export + self.overlayToolbar.setTransparent(False) + + prompts.exportToVideoFinished( + self.exportToVideoPreferences, conversion_to_mp4_successful, + qparent=self.host + ) + + def exportAddScaleBar(self, checked): + self.addScaleBarAction.setChecked(checked) + + def exportToVideoAddTimestamp(self, checked): + self.addTimestampAction.setChecked(checked) + + def askTimelapseOrZslicesVideo(self): + txt = html_utils.paragraph(""" + Do you want to record a video of scrolling through the z-slices or + a Timelapse video? + """) + msg = widgets.myMessageBox(wrapText=False) + _, timelapseButton = msg.question( + self.host, 'Z-slices or Timelapse video?', txt, + buttonsTexts=('Z-slices', 'Timelapse') + ) + if msg.cancel: + return + + return msg.clickedButton == timelapseButton + + def exportToVideoTriggered(self): + posData = self.data[self.pos_i] + + doTimelapseVideo = posData.SizeT > 1 + if posData.SizeT > 1 and posData.SizeZ > 1: + doTimelapseVideo = self.askTimelapseOrZslicesVideo() + + if doTimelapseVideo is None: + self.logger.info('Export to video process cancelled') + return + + channels = [self.user_ch_name, *self.checkedOverlayChannels] + mode = 'timelapse' if doTimelapseVideo else 'z_slices' + filename = self.view_model.timestamped_export_filename( + f'{mode}_video' + ) + win = apps.ExportToVideoParametersDialog( + channels, + parent=self.host, + startFolderpath=posData.pos_path, + startFilename=filename, + startFrameNum=posData.frame_i+1, + SizeT=posData.SizeT, + SizeZ=posData.SizeZ, + isTimelapseVideo=doTimelapseVideo, + isScaleBarPresent=self.addScaleBarAction.isChecked(), + isTimestampPresent=self.addTimestampAction.isChecked(), + rescaleIntensChannelHowMapper=self.rescaleIntensChannelHowMapper + ) + win.sigAddScaleBar.connect(self.exportAddScaleBar) + win.sigAddTimestamp.connect(self.exportToVideoAddTimestamp) + win.sigRescaleIntensLut.connect(self.rescaleIntensExportToVideoDialog) + win.exec_() + if win.cancel: + self.logger.info('Export to video process cancelled') + return + + cancel = _warnings.warnExportToVideo(qparent=self.host) + if cancel: + self.logger.info('Export to video process cancelled') + return + + self.startExportToVideoWorker(win.selected_preferences) + + def initExportMaskImage(self): + posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + self.exportMaskImage = self.view_model.build_export_mask_image( + img[z_slice].shape, + self.ax1.viewRange(), + invert_bw=False, + ) + + def setExportMaskImage(self, viewRange): + if not hasattr(self, 'exportMaskImage'): + self.initExportMaskImage() + + self.exportMaskImage[:] = self.view_model.build_export_mask_image( + self.exportMaskImage.shape[:2], + viewRange, + invert_bw=self.invertBwAction.isChecked(), + ) + + self.exportMaskImageItem.setImage(self.exportMaskImage) + + def setViewRangeFromExportToImageDialog(self, viewRange, win=None): + xRange, yRange = viewRange + # self.ax1.sigRangeChanged.disconnect( + # self.display_decorations_view.view_range_changed + # ) + self.ax1.setRange(xRange=xRange, yRange=yRange) + # self.ax1.sigRangeChanged.connect( + # self.display_decorations_view.view_range_changed + # ) + # self.display_decorations_view.view_range_changed( + # self.ax1.vb, viewRange, updateExportMaskImage=False + # ) + self.setExportMaskImage(viewRange) + + def updateViewRangeExportToImage(self, viewRange): + if self.exportToImageWindow is None: + return + + # prevViewRange = self.exportToImageWindow.viewRange() + prevViewRange = self._viewRange + winViewRange = self.exportToImageWindow.viewRange() + x_range, y_range = self.view_model.shifted_view_range( + prevViewRange, + viewRange, + winViewRange, + ) + + self.exportToImageWindow.setViewRange( + x_range, y_range, emitSignal=False + ) + + def getZoomIDs(self, viewRange=None): + if viewRange is None: + viewRange = self.ax1.viewRange() + + return self.view_model.zoom_ids(self.currentLab2D, viewRange) + + def onSigUpdateCcaTableWindow(self, *args): + if not self.isDataLoaded: + return + + if self.ccaTableWin is None: + return + + viewRange = self.ax1.viewRange() + posData = self.data[self.pos_i] + zoomIDs = self.getZoomIDs(viewRange=viewRange) + + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + @disableWindow + def exportToImage(self, preferences): + filepath = preferences['filepath'] + self.logger.info(f'Saving image to "{filepath}"...') + + if filepath.endswith('.svg'): + exporter = exporters.SVGExporter(self.ax1) + else: + exporter = exporters.ImageExporter(self.ax1, dpi=preferences['dpi']) + exporter.export(filepath) + self.logger.info(f'Image saved.') + + self.setDisabled(False) + self.exportMaskImage[:] = 0 + self.exportMaskImageItem.setImage(self.exportMaskImage) + prompts.exportToImageFinished(filepath, qparent=self.host) + + def exportToImageTriggered(self): + posData = self.data[self.pos_i] + filename = self.view_model.timestamped_export_filename('image') + win = apps.ExportToImageParametersDialog( + parent=self.host, + startFolderpath=posData.pos_path, + startFilename=filename, + startViewRange=self.ax1.viewRange(), + isScaleBarPresent=self.addScaleBarAction.isChecked(), + ) + win.sigAddScaleBar.connect(self.exportAddScaleBar) + win.sigRangeChanged.connect( + partial(self.setViewRangeFromExportToImageDialog, win=win) + ) + # self.ax1.vb.sigRangeChanged.connect( + # win.updateViewRangeExportToImageDialog + # ) + self.setExportMaskImage(self.ax1.viewRange()) + self.exportToImageWindow = win + win.exec_() + # self.ax1.vb.sigRangeChanged.disconnect() + if win.cancel: + self.exportMaskImage[:] = 0 + self.exportMaskImageItem.setImage(self.exportMaskImage) + self.exportToImageWindow = None + self.logger.info('Export to image process cancelled') + return + + isTransparent = self.overlayToolbar.isTransparent() + if not isTransparent: + # SVG export works only with RGBA not with setOpacity + # --> only true transparency mode can be used + self.overlayToolbar.setTransparent(True) + + self.exportToImage(win.selected_preferences) + self.exportToImageWindow = None + + if not isTransparent: + self.overlayToolbar.setTransparent(False) diff --git a/cellacdc/views/frame_navigation_view.py b/cellacdc/views/frame_navigation_view.py new file mode 100644 index 000000000..af0354f32 --- /dev/null +++ b/cellacdc/views/frame_navigation_view.py @@ -0,0 +1,1214 @@ +"""Qt view adapter for frame and position navigation.""" + +from __future__ import annotations + +from collections import Counter +from functools import partial + +import numpy as np +from qtpy.QtCore import QTimer +from qtpy.QtWidgets import QAbstractSlider, QCheckBox + +from cellacdc import QtScoped, apps, exception_handler, html_utils, printl, widgets +from cellacdc.viewmodels.frame_navigation_viewmodel import ( + FrameNavigationViewModel, +) + + +SliderSingleStepAdd = QtScoped.SliderSingleStepAdd() +SliderSingleStepSub = QtScoped.SliderSingleStepSub() +SliderPageStepAdd = QtScoped.SliderPageStepAdd() +SliderPageStepSub = QtScoped.SliderPageStepSub() +SliderMove = QtScoped.SliderMove() + + +class FrameNavigationView: + """Qt-facing adapter for frame and position navigation workflows.""" + + LEGACY_METHODS = ( + 'goToZsliceSearchedID', + 'isNavigateActionOnNextFrame', + 'nextFrameImage', + 'rightImageFramesScrollbarValueChanged', + 'nextActionTriggered', + 'prevActionTriggered', + 'resetNavigateScrollbar', + 'next_cb', + 'prev_cb', + 'updateScrollbars', + 'updateFramePosLabel', + 'updateItemsMousePos', + 'setNavigateScrollBarMaximum', + 'setFrameNavigationDisabled', + 'navigateSpinboxValueChanged', + 'navigateSpinboxEditingFinished', + 'PosScrollBarAction', + 'PosScrollBarMoved', + 'PosScrollBarReleased', + 'resetNavigateFramesScrollbar', + 'framesScrollBarActionTriggered', + 'framesScrollBarMoved', + 'framesScrollBarReleased', + 'next_pos', + 'updatePos', + 'prev_pos', + 'updateViewerWindow', + 'warnLostObjects', + 'warnReinitLastSegmFrame', + 'extendSegmDataIfNeeded', + 'reInitLastSegmFrame', + 'resetAcceptedLostIDs', + 'askInitCcaFirstFrame', + 'askInitLinTreeFirstFrame', + 'checkIfFutureFrameManualAnnotPastFrames', + 'next_frame', + 'apply_tools_on_new_frame', + 'manualAnnotRestoreLastTrackedFrame', + 'prev_frame', + 'connectScrollbars', + 'zSliceScrollBarActionTriggered', + 'zSliceScrollBarReleased', + 'setSwitchViewedPlaneDisabled', + '_setViewRangeSwitchPlane', + 'setViewRangeSwitchPlane', + 'switchViewedPlane', + 'onZsliceSpinboxValueChange', + 'update_z_slice', + 'updateOverlayZslice', + 'updateOverlayZproj', + 'updateZproj', + 'setZprojDisabled', + ) + + def __init__(self, host, view_model: FrameNavigationViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def goToZsliceSearchedID(self, obj): + if not self.isSegm3D: + return + + current_z = self.z_lab() + nearest_nonzero_z = ( + self.view_model.nearest_nonzero_z_from_centroid( + obj, current_z=current_z + ) + ) + if nearest_nonzero_z == current_z: + self.drawPointsLayers(computePointsLayers=True) + return + + self.zSliceScrollBar.setSliderPosition(nearest_nonzero_z) + self.update_z_slice(nearest_nonzero_z) + + def isNavigateActionOnNextFrame(self): + posData = self.data[self.pos_i] + ax1_coords = self.status_hover_view.mouse_data_coords_right_image() + return self.view_model.should_show_next_frame_image( + size_t=posData.SizeT, + has_right_image_coords=ax1_coords is not None, + action_enabled=self.labelsGrad.showNextFrameAction.isEnabled(), + action_checked=self.labelsGrad.showNextFrameAction.isChecked(), + ) + + def nextFrameImage(self, current_frame_i=None): + if not self.labelsGrad.showNextFrameAction.isEnabled(): + return + + if not self.labelsGrad.showNextFrameAction.isChecked(): + return + + posData = self.data[self.pos_i] + if current_frame_i is None: + current_frame_i = posData.frame_i + + next_frame_i = self.view_model.next_frame_index( + current_frame_i=current_frame_i, + frames_count=len(posData.img_data), + ) + img = posData.img_data[next_frame_i] + + if posData.SizeZ > 1: + img = self.get_2Dimg_from_3D(img, isLayer0=True) + + # img = self.normalizeIntensities(img) + + return img + + def rightImageFramesScrollbarValueChanged(self, value): + img = self.nextFrameImage(current_frame_i=value-2) + self.img1.linkedImageItem.frame_i = value + self.img1.linkedImageItem.setImage(img) + + def nextActionTriggered(self): + if self.isNavigateActionOnNextFrame(): + self.rightImageFramesScrollbar.setValue( + self.rightImageFramesScrollbar.value()+1 + ) + return + + stepAddAction = QAbstractSlider.SliderAction.SliderSingleStepAdd + if self.zKeptDown or self.zSliceCheckbox.isChecked(): + self.zSliceScrollBar.triggerAction(stepAddAction) + else: + self.navigateScrollBar.triggerAction(stepAddAction) + + def prevActionTriggered(self): + if self.isNavigateActionOnNextFrame(): + self.rightImageFramesScrollbar.setValue( + self.rightImageFramesScrollbar.value()-1 + ) + return + + stepSubAction = QAbstractSlider.SliderAction.SliderSingleStepSub + if self.zKeptDown or self.zSliceCheckbox.isChecked(): + self.zSliceScrollBar.triggerAction(stepSubAction) + else: + self.navigateScrollBar.triggerAction(stepSubAction) + + def resetNavigateScrollbar(self): + try: + self.navigateScrollBar.blockSignals(True) + self.navigateScrollBar.actionTriggered.disconnect() + self.navigateScrollBar.sliderReleased.disconnect() + self.navigateScrollBar.sliderMoved.disconnect() + # self.navigateScrollBar.valueChanged.disconnect() + self.navigateScrollBar.setSliderPosition(self.navSpinBox.value()) + except Exception as e: + if "disconnect()" not in str(e): + printl(e) + pass + + self.navigateScrollBar.blockSignals(False) + self.navigateScrollBar.actionTriggered.connect( + self.framesScrollBarActionTriggered + ) + self.navigateScrollBar.sliderReleased.connect(self.framesScrollBarReleased) + self.navigateScrollBar.sliderMoved.connect(self.framesScrollBarMoved) + + @exception_handler + def next_cb(self): + if self.isSnapshot: + self.next_pos() + else: + self.next_frame() + if self.curvToolButton.isChecked(): + self.curvature_tools_view.curvTool_cb(True) + + self.updatePropsWidget('') + + @exception_handler + def prev_cb(self): + if self.isSnapshot: + self.prev_pos() + else: + self.prev_frame() + if self.curvToolButton.isChecked(): + self.curvature_tools_view.curvTool_cb(True) + + self.updatePropsWidget('') + + def updateScrollbars(self): + self.updateItemsMousePos() + self.updateFramePosLabel() + posData = self.data[self.pos_i] + navPos = self.view_model.navigation_position( + is_snapshot=self.isSnapshot, + position_i=self.pos_i, + frame_i=posData.frame_i, + ) + self.navigateScrollBar.setSliderPosition(navPos) + if posData.SizeZ > 1: + self.updateZsliceScrollbar(posData.frame_i) + idx = (posData.filename, posData.frame_i) + self.zSliceScrollBar.setMaximum(posData.SizeZ-1) + self.zSliceSpinbox.setMaximum(posData.SizeZ) + self.SizeZlabel.setText(f'/{posData.SizeZ}') + + def updateFramePosLabel(self): + if self.isSnapshot: + posData = self.data[self.pos_i] + self.navSpinBox.setValueNoEmit(self.pos_i+1) + else: + posData = self.data[0] + self.navSpinBox.setValueNoEmit(posData.frame_i+1) + + def updateItemsMousePos(self): + if self.brushButton.isChecked(): + self.updateBrushCursor(self.xHoverImg, self.yHoverImg) + + if self.eraserButton.isChecked(): + self.updateEraserCursor(self.xHoverImg, self.yHoverImg) + + def setNavigateScrollBarMaximum(self): + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + lineage_frames = None + if self.lineage_tree is not None: + lineage_frames = self.lineage_tree.frames_for_dfs + limit = self.view_model.navigation_limit( + mode=mode, + frame_i=posData.frame_i, + last_tracked_i=posData.last_tracked_i, + last_cca_frame_i=self.last_cca_frame_i, + lineage_tree_frames=lineage_frames, + ) + if limit is None: + return + + self.navigateScrollBar.setMaximum(limit.maximum) + self.navSpinBox.setMaximum(limit.maximum) + if limit.last_checked_frame_i is not None: + self.updateLastCheckedFrameWidgets(limit.last_checked_frame_i) + if limit.status_text is not None: + self.lastTrackedFrameLabel.setText(limit.status_text) + + def setFrameNavigationDisabled(self, disable: bool, why: str): + """Disables the frame navigation buttons and scrollbar. + This is used when the user is not allowed to navigate through frames + Call again to unlock it again. Also sets tooltips to inform the user + + Parameters + ---------- + disable : bool + if the navigation should be disabled + why : str + the reason for disabeling the navigation. + """ + + if disable: + self.whyNavigateDisabled.add(why) + else: + try: + self.whyNavigateDisabled.remove(why) + except KeyError: + pass + + if len(self.whyNavigateDisabled) == 0: + disable = False + else: + disable = True + + # Apply the disable/enable state + self.prevAction.setDisabled(disable) + self.nextAction.setDisabled(disable) + self.navigateScrollBar.setDisabled(disable) + + # Set appropriate tooltip + if not disable: + self.navigateScrollBar.setToolTip( + 'NOTE: The maximum frame number that can be visualized with this ' + 'scrollbar\n' + 'is the last visited frame with the selected mode\n' + '(see "Mode" selector on the top-right).\n\n' + 'If the scrollbar does not move it means that you never visited\n' + 'any frame with current mode.\n\n' + 'Note that the "Viewer" mode allows you to scroll ALL frames.' + ) + return + + txt = f'Frame navigation disabled: {self.whyNavigateDisabled}' + self.logger.info(txt) + self.navigateScrollBar.setToolTip(txt) + + def navigateSpinboxValueChanged(self, value): + self.navigateScrollBar.setSliderPosition(value) + if self.isSnapshot: + self.PosScrollBarMoved(value) + else: + self.navigateScrollBarStartedMoving = True + self.framesScrollBarMoved(value) + + def navigateSpinboxEditingFinished(self): + if self.isSnapshot: + self.PosScrollBarReleased() + else: + self.framesScrollBarReleased() + + def PosScrollBarAction(self, action): + if action == SliderSingleStepAdd: + self.next_cb() + elif action == SliderSingleStepSub: + self.prev_cb() + elif action == SliderPageStepAdd: + self.PosScrollBarReleased() + elif action == SliderPageStepSub: + self.PosScrollBarReleased() + + def PosScrollBarMoved(self, pos_n): + if self.navigateScrollBarStartedMoving: + self.store_data() + + self.pos_i = pos_n-1 + self.updateFramePosLabel() + proceed_cca, never_visited = self.get_data() + self.updateAllImages() + self.status_hover_view.set_status_bar_label() + self.navigateScrollBarStartedMoving = False + + def PosScrollBarReleased(self): + self.navigateScrollBarStartedMoving = True + if self.pos_i == self.navigateScrollBar.sliderPosition()-1: + # Slider released without changing value --> do nothing + return + + self.pos_i = self.navigateScrollBar.sliderPosition()-1 + self.updateFramePosLabel() + self.updatePos() + + def resetNavigateFramesScrollbar(self, frame_i=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + self.navigateScrollBar.setValueNoSignal(frame_i+1) + + def framesScrollBarActionTriggered(self, action): + if action == SliderSingleStepAdd: + # Clicking on dialogs triggered by next_cb might trigger + # pressEvent of navigateQScrollBar, avoid that + self.navigateScrollBar.disableCustomPressEvent() + self.next_cb() + QTimer.singleShot(100, self.navigateScrollBar.enableCustomPressEvent) + elif action == SliderSingleStepSub: + self.prev_cb() + elif action == SliderPageStepAdd: + self.framesScrollBarReleased(do_store_data=True) + elif action == SliderPageStepSub: + self.framesScrollBarReleased(do_store_data=True) + + def framesScrollBarMoved(self, frame_n): + if self.navigateScrollBarStartedMoving: + mode = str(self.modeComboBox.currentText()) + if self.view_model.should_store_when_slider_moves(mode=mode): + self.store_data(debug=False) + + posData = self.data[self.pos_i] + posData.frame_i = frame_n-1 + if posData.allData_li[posData.frame_i]['labels'] is None: + if posData.frame_i < len(posData.segm_data): + posData.lab = posData.segm_data[posData.frame_i] + else: + posData.lab = np.zeros_like(posData.segm_data[0]) + else: + posData.lab = posData.allData_li[posData.frame_i]['labels'] + + self.setImageImg1() + if self.overlayButton.isChecked(): + self.setOverlayImages() + + if self.navigateScrollBarStartedMoving: + self.clearAllItems() + + self.navSpinBox.setValueNoEmit(posData.frame_i+1) + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.img2.setImage(posData.lab, z=self.z_lab(), autoLevels=False) + self.updateLookuptable() + self.updateFramePosLabel() + self.updateViewerWindow() + self.display_decorations_view.update_timestamp_frame() + self.updateHighlightedAxis() + self.navigateScrollBarStartedMoving = False + + def framesScrollBarReleased(self, do_store_data=False): + posData = self.data[self.pos_i] + if posData.frame_i == self.navigateScrollBar.sliderPosition()-1: + # Slider released without changing value --> do nothing + return + + mode = str(self.modeComboBox.currentText()) + if ( + self.view_model.should_store_when_slider_moves(mode=mode) + and do_store_data + ): + self.store_data(debug=False) + + self.navigateScrollBarStartedMoving = True + posData.frame_i = self.navigateScrollBar.sliderPosition()-1 + self.updateFramePosLabel() + proceed_cca, never_visited = self.get_data() + self.updateAllImages() + + def next_pos(self): + self.store_data(debug=True, autosave=False) + prev_pos_i = self.pos_i + if self.pos_i < self.num_pos-1: + self.pos_i += 1 + self.updateSegmDataAutoSaveWorker() + else: + self.logger.info('You reached last position.') + self.pos_i = 0 + self.updatePos() + + def updatePos(self): + self.clearUndoQueue() + self.status_hover_view.set_status_bar_label() + self.checkManageVersions() + self.removeAlldelROIsCurrentFrame() + self.resetManualBackgroundItems() + proceed_cca, never_visited = self.get_data(debug=False) + self.pointsLayerLoadedDfsToData() + self.flushDirtyPointsLayersAutosave() + self.initContoursImage() + self.initDelRoiLab() + self.initTextAnnot() + self.postProcessing() + self.updateScrollbars() + self.preprocessing_view.updatePreprocessPreview() + self.updateCombineChannelsPreview() + self.updateAllImages() + self.computeSegm() + self.zoomOut() + self.restartZoomAutoPilot() + self.initManualBackgroundObject() + self.updateObjectCounts() + self.updateItemsMousePos() + + self._sync_session(self.pos_i) + self._sync_session_frame_i(self.pos_i) + + def prev_pos(self): + self.store_data(debug=False, autosave=False) + prev_pos_i = self.pos_i + if self.pos_i > 0: + self.pos_i -= 1 + self.updateSegmDataAutoSaveWorker() + else: + self.logger.info('You reached first position.') + self.pos_i = self.num_pos-1 + self.updatePos() + + def updateViewerWindow(self): + if self.slideshowWin is None: + return + + if self.slideshowWin.linkWindow is None: + return + + if not self.slideshowWin.linkWindowCheckbox.isChecked(): + return + + posData = self.data[self.pos_i] + self.slideshowWin.frame_i = posData.frame_i + self.slideshowWin.update_img() + + def warnLostObjects(self, do_warn=True): + mode = str(self.modeComboBox.currentText()) + posData = self.data[self.pos_i] + + frame_i = posData.frame_i + try: + accepted_lost_IDs = posData.accepted_lost_IDs.get(frame_i, []) + already_accepted_lost = ( + Counter(accepted_lost_IDs) == Counter(posData.lost_IDs) + ) + except AttributeError as err: + already_accepted_lost = False + + should_warn = self.view_model.should_warn_lost_objects( + requested=do_warn, + action_checked=self.warnLostCellsAction.isChecked(), + mode=mode, + lost_ids=posData.lost_IDs, + already_accepted=already_accepted_lost, + ) + if not should_warn: + return True + + self.nextAction.setDisabled(True) + self.prevAction.setDisabled(True) + self.navigateScrollBar.setDisabled(True) + + msg = widgets.myMessageBox() + warn_msg = html_utils.paragraph( + 'Current frame (compared to previous frame) ' + 'has lost the following cells:

' + f'{posData.lost_IDs}

' + 'Are you sure you want to continue?
' + ) + checkBox = QCheckBox('Do not show again') + noButton, yesButton = msg.warning( + self.host, 'Lost cells!', warn_msg, + buttonsTexts=('No', 'Yes'), + widgets=checkBox + ) + doNotWarnLostCells = not checkBox.isChecked() + self.warnLostCellsAction.setChecked(doNotWarnLostCells) + if msg.clickedButton == noButton: + self.nextAction.setDisabled(False) + self.prevAction.setDisabled(False) + self.navigateScrollBar.setDisabled(False) + return False + + self.nextAction.setDisabled(False) + self.prevAction.setDisabled(False) + self.navigateScrollBar.setDisabled(False) + if not hasattr(posData, 'accepted_lost_IDs'): + posData.accepted_lost_IDs = {} + if frame_i not in posData.accepted_lost_IDs: + posData.accepted_lost_IDs[frame_i] = [] + + posData.accepted_lost_IDs[frame_i].extend(posData.lost_IDs) + # This section is adding the lost cells to tracked_lost_centroids... TBH I dont know why this wasnt done in the first place + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] + accepted_lost_centroids = { + tuple(int(val) for val in prev_rp[prev_IDs_idxs[ID]].centroid) + for ID in posData.lost_IDs + } + try: + posData.tracked_lost_centroids[frame_i] = ( + posData.tracked_lost_centroids[frame_i] | (accepted_lost_centroids) + ) + except KeyError: + posData.tracked_lost_centroids[frame_i] = accepted_lost_centroids + return True + + def warnReinitLastSegmFrame(self): + current_frame_n = self.navigateScrollBar.value() + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + Are you sure you want to re-initialize the last visited and + validated frame to number {current_frame_n}?

+ WARNING: If you save, all annotations after frame number + {current_frame_n} will be lost! + """) + msg.warning( + self.host, 'WARNING: Potential loss of data', txt, + buttonsTexts=('Cancel', 'Yes, I am sure') + ) + return msg.cancel + + def extendSegmDataIfNeeded(self, stopFrameNum): + posData = self.data[self.pos_i] + segmSizeT = len(posData.segm_data) + if stopFrameNum <= segmSizeT: + return + numFramesToAdd = stopFrameNum - segmSizeT + posData.allData_li.extend( + self.view_model.empty_frame_records(numFramesToAdd) + ) + lab_shape = posData.segm_data[0].shape + shapeToAdd = (numFramesToAdd, *lab_shape) + additionalSegmData = np.zeros(shapeToAdd, dtype=posData.segm_data.dtype) + extendedSegmData = np.concatenate((posData.segm_data, additionalSegmData)) + posData.segm_data = extendedSegmData + + def reInitLastSegmFrame( + self, checked=True, from_frame_i=None, updateImages=True, + force=False + ): + if not force: + cancel = self.warnReinitLastSegmFrame() + if cancel: + self.logger.info( + 'Re-initialization of last validated frame cancelled.' + ) + return + + posData = self.data[self.pos_i] + if from_frame_i is None: + from_frame_i = posData.frame_i + + self.lastFrameRanOnFirstVisitTools = posData.frame_i + + self.updateLastCheckedFrameWidgets(from_frame_i) + posData.last_tracked_i = from_frame_i + self.navigateScrollBar.setMaximum(from_frame_i+1) + self.navSpinBox.setMaximum(from_frame_i+1) + # self.navigateScrollBar.setMinimum(1) + + # posData.tracked_lost_centroids[from_frame_i-1] = set() + for i in range(from_frame_i, posData.SizeT): + if posData.allData_li[i]['labels'] is None: + break + + posData.segm_data[i] = posData.allData_li[i]['labels'] + posData.allData_li[i] = ( + self.view_model.empty_frame_record() + ) + + posData.tracked_lost_centroids[i] = set() + posData.acdcTracker2stepsAnnotInfo.pop(i, None) + + if posData.acdc_df is not None: + frames = posData.acdc_df.index.get_level_values(0) + if from_frame_i in frames: + posData.acdc_df = posData.acdc_df.loc[:from_frame_i] + + self.removeAlldelROIsCurrentFrame() + + if not updateImages: + return + + self.updateAllImages() + + def resetAcceptedLostIDs(self, from_frame_i=None): + posData = self.data[self.pos_i] + if from_frame_i is None: + from_frame_i = posData.frame_i + + posData.tracked_lost_centroids[from_frame_i-1] = set() + for i in range(from_frame_i, posData.SizeT): + posData.tracked_lost_centroids[i] = set() + + def askInitCcaFirstFrame(self): + mode = str(self.modeComboBox.currentText()) + if mode != 'Cell cycle analysis': + return True + + posData = self.data[self.pos_i] + if posData.frame_i != 0: + return True + + editCcaWidget = apps.editCcaTableWidget( + posData.cca_df, posData.SizeT, parent=self.host, + title='Initialize cell cycle annotations' + ) + editCcaWidget.sigApplyChangesFutureFrames.connect( + self.applyManualCcaChangesFutureFrames + ) + editCcaWidget.exec_() + if editCcaWidget.cancel: + self.resetNavigateFramesScrollbar() + return False + + if posData.cca_df is not None: + is_cca_same_as_stored = ( + (posData.cca_df == editCcaWidget.cca_df).all(axis=None) + ) + if not is_cca_same_as_stored: + reinit_cca = self.warnEditingWithCca_df( + 'Re-initialize cell cyle annotations first frame', + return_answer=True + ) + if reinit_cca: + self.resetCcaFuture(0) + + posData.cca_df = editCcaWidget.cca_df + self.store_cca_df() + + return True + + def askInitLinTreeFirstFrame(self): + mode = str(self.modeComboBox.currentText()) + if mode != 'Normal division: Lineage tree': + return True + + posData = self.data[self.pos_i] + if posData.frame_i != 0: + return True + + if self.lineage_tree is None: + self.initLinTree() + + return True + + def checkIfFutureFrameManualAnnotPastFrames(self): + posData = self.data[self.pos_i] + frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + blocked = self.view_model.blocks_future_manual_annotation( + manual_annotation_enabled=self.manualAnnotPastButton.isChecked(), + current_frame_i=posData.frame_i, + frame_to_restore=frame_to_restore, + ) + if not blocked: + return True + + warn_txt = ( + 'WARNING: Cannot navigate to future frames while in ' + 'manual annotation mode.' + ) + self.logger.info(warn_txt) + self.statusBarLabel.setText(f'

{warn_txt}

') + + return False + + # @exec_time + def next_frame(self, warn=True): + proceed = self.checkIfFutureFrameManualAnnotPastFrames() + if not proceed: + return + + proceed = self.askInitCcaFirstFrame() + if not proceed: + return + + proceed = self.askInitLinTreeFirstFrame() + if not proceed: + return + + mode = str(self.modeComboBox.currentText()) + posData = self.data[self.pos_i] + + if posData.frame_i >= posData.SizeT-1: + # Store data for current frame + if mode != 'Viewer': + self.store_data(debug=False) + msg = 'You reached the last segmented frame!' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + return + + proceed = self.warnLostObjects() + if not proceed: + self.resetNavigateScrollbar() + return + + # Store data for current frame + if mode != 'Viewer': + self.store_data(debug=False) + + self.askLineageTreeChanges() + posData.frame_i += 1 + self.removeAlldelROIsCurrentFrame() + proceed_cca, never_visited = self.get_data() + if not proceed_cca: + posData.frame_i -= 1 + self.get_data() + self.logger.info( + 'No data for current frame. ' + ) + return + + if mode == 'Segmentation and Tracking' or self.isSnapshot: + self.addExistingDelROIs() + + self.preprocessing_view.updatePreprocessPreview() + self.updateCombineChannelsPreview() + self.postProcessing() + self.tracking(storeUndo=True, wl_update=False) + notEnoughG1Cells, proceed = self.attempt_auto_cca() + if notEnoughG1Cells or not proceed: + posData.frame_i -= 1 + self.get_data() + self.setAllTextAnnotations() + self.logger.info( + 'Not enough G1 cells to compute cell cycle annotations.' + ) + return + + self.store_zslices_rp() + self.label_transform_tools_view.reset_expand_label() + self.updateAllImages() + self.updateHighlightedAxis() + self.updateViewerWindow() + self.updateLastVisitedFrame(last_visited_frame_i=posData.frame_i-1) + self.setNavigateScrollBarMaximum() + self.updateScrollbars() + self.computeSegm() + self.initGhostObject() + self.whitelistPropagateIDs() + self.zoomToCells() + self.updateItemsMousePos() + self.updateObjectCounts() + + self.apply_tools_on_new_frame() + self._sync_session_frame_i() + + def apply_tools_on_new_frame(self): + mode = str(self.modeComboBox.currentText()) + posData = self.data[self.pos_i] + should_apply = self.view_model.should_apply_new_frame_tools( + mode=mode, + last_tracked_i=posData.last_tracked_i, + frame_i=posData.frame_i, + last_frame_ran=self.lastFrameRanOnFirstVisitTools, + ) + if not should_apply: + return + + self.lastFrameRanOnFirstVisitTools = posData.frame_i + for name, checkbox in self.applyToolNewFrameActions.items(): + if not checkbox.isChecked(): + continue + + tool_button = self.applyToolNewFrameButtons[name] + try: + if hasattr(tool_button, 'click'): + tool_button.click() + elif hasattr(tool_button, 'trigger'): + tool_button.trigger() + else: + printl( + f"Warning: {name} has no click or trigger method" + ) + except Exception as e: + self.logger.info(f"Error applying tool {name}: {e}") + + def manualAnnotRestoreLastTrackedFrame(self, last_tracked_i_to_restore): + if self.navigateScrollBar.maximum()-1 <= last_tracked_i_to_restore: + return + + posData = self.data[self.pos_i] + for frame_i in range(last_tracked_i_to_restore+1, posData.SizeT): + data_frame_i = self.view_model.empty_frame_record() + + data_frame_i['manually_edited_lab'] = ( + posData.allData_li[frame_i]['manually_edited_lab'] + ) + + posData.allData_li[frame_i] = data_frame_i + + self.navigateScrollBar.setMaximum(last_tracked_i_to_restore+1) + self.navSpinBox.setMaximum(last_tracked_i_to_restore+1) + + # @exec_time + def prev_frame(self): + posData = self.data[self.pos_i] + if posData.frame_i <= 0: + msg = 'You reached the first frame!' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + return + + # Store data for current frame + mode = str(self.modeComboBox.currentText()) + if mode != 'Viewer': + self.store_data(debug=False) + + self.removeAlldelROIsCurrentFrame() + self.askLineageTreeChanges() + posData.frame_i -= 1 + _, never_visited = self.get_data() + + if mode == 'Segmentation and Tracking' or self.isSnapshot: + self.addExistingDelROIs() + + self.label_transform_tools_view.reset_expand_label() + self.preprocessing_view.updatePreprocessPreview() + self.updateCombineChannelsPreview() + self.postProcessing() + self.tracking() + self.whitelistPropagateIDs(update_lab=True) + self.updateAllImages() + self.updateScrollbars() + self.updateHighlightedAxis() + self.zoomToCells() + self.initGhostObject() + self.updateViewerWindow() + self.updateItemsMousePos() + self.updateObjectCounts() + self._sync_session_frame_i() + + def connectScrollbars(self): + self.t_label.show() + self.navigateScrollBar.show() + self.navigateScrollBar.setDisabled(False) + + if self.data[0].SizeZ > 1: + self.enableZstackWidgets(True) + self.zSliceScrollBar.setMaximum(self.data[0].SizeZ-1) + self.zSliceSpinbox.setMaximum(self.data[0].SizeZ) + self.SizeZlabel.setText(f'/{self.data[0].SizeZ}') + try: + self.zSliceScrollBar.actionTriggered.disconnect() + self.zSliceScrollBar.sliderReleased.disconnect() + self.zProjComboBox.currentTextChanged.disconnect() + self.zProjComboBox.activated.disconnect() + self.switchPlaneCombobox.sigPlaneChanged.disconnect() + self.zProjLockViewButton.toggled.disconnect() + except Exception as e: + pass + self.zSliceScrollBar.actionTriggered.connect( + self.zSliceScrollBarActionTriggered + ) + self.zSliceScrollBar.sliderReleased.connect( + self.zSliceScrollBarReleased + ) + self.zProjComboBox.currentTextChanged.connect(self.updateZproj) + self.zProjComboBox.activated.connect( + self.mode_controls_view.clearComboBoxFocus + ) + self.switchPlaneCombobox.sigPlaneChanged.connect( + self.switchViewedPlane + ) + self.zProjLockViewButton.toggled.connect(self.zProjLockViewToggled) + + posData = self.data[self.pos_i] + if posData.SizeT == 1: + self.t_label.setText('Position n.') + self.navigateScrollBar.setMinimum(1) + self.navigateScrollBar.setMaximum(len(self.data)) + self.navigateScrollBar.setAbsoluteMaximum(len(self.data)) + self.navSpinBox.setMaximum(len(self.data)) + self.navigateScrollBar.connectEvents({ + 'sliderMoved': self.PosScrollBarMoved, + 'sliderReleased': self.PosScrollBarReleased, + 'actionTriggered': self.PosScrollBarAction + }) + else: + self.navigateScrollBar.setMinimum(1) + self.navigateScrollBar.setAbsoluteMaximum(posData.SizeT) + self.rightImageFramesScrollbar.setMinimum(1) + self.rightImageFramesScrollbar.setMaximum(posData.SizeT) + if posData.last_tracked_i is not None: + self.navigateScrollBar.setMaximum(posData.last_tracked_i+1) + self.navSpinBox.setMaximum(posData.last_tracked_i+1) + self.t_label.setText('Frame n.') + self.navigateScrollBar.connectEvents({ + 'sliderMoved': self.framesScrollBarMoved, + 'sliderReleased': self.framesScrollBarReleased, + 'actionTriggered': self.framesScrollBarActionTriggered + }) + self.rightImageFramesScrollbar.connectValueChanged( + self.rightImageFramesScrollbarValueChanged + ) + + def zSliceScrollBarActionTriggered(self, action): + singleMove = ( + action == SliderSingleStepAdd + or action == SliderSingleStepSub + or action == SliderPageStepAdd + or action == SliderPageStepSub + ) + if singleMove: + self.update_z_slice(self.zSliceScrollBar.sliderPosition()) + elif action == SliderMove: + if self.zSliceScrollBarStartedMoving and self.isSegm3D: + self.clearAx1Items(onlyHideText=True) + self.clearAx2Items(onlyHideText=True) + posData = self.data[self.pos_i] + idx = (posData.filename, posData.frame_i) + z = self.zSliceScrollBar.sliderPosition() + if self.switchPlaneCombobox.depthAxes() == 'z': + posData.segmInfo_df.at[idx, 'z_slice_used_gui'] = z + self.zSliceSpinbox.setValueNoEmit(z+1) + img = self._getImageupdateAllImages(None) + self.img1.setCurrentZsliceIndex(z) + self.img1.setImage( + img, next_frame_image=self.nextFrameImage(), + scrollbar_value=posData.frame_i+2 + ) + try: + self.setOverlayImages() + except Exception as err: + pass + + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.img2.setImage(posData.lab, z=z, autoLevels=False) + self.updateViewerWindow() + self.setTextAnnotZsliceScrolling() + self.setGraphicalAnnotZsliceScrolling() + self.setOverlayLabelsItems() + self.drawPointsLayers(computePointsLayers=False) + self.zSliceScrollBarStartedMoving = False + self.highlightSearchedID(self.highlightedID, force=True) + + def zSliceScrollBarReleased(self): + self.clearTempBrushImage() + self.zSliceScrollBarStartedMoving = True + self.update_z_slice(self.zSliceScrollBar.sliderPosition()) + + def setSwitchViewedPlaneDisabled(self, disabled): + posData = self.data[self.pos_i] + if posData.SizeZ == 1: + return + + self.switchPlaneCombobox.setDisabled(disabled) + if disabled: + self.switchPlaneCombobox.setCurrentIndex(0) + + def _setViewRangeSwitchPlane(self, previousPlane): + posData = self.data[self.pos_i] + SizeZ = posData.SizeZ + SizeY, SizeX = self.img1.image.shape[:2] + currentPlane = self.switchPlaneCombobox.plane() + if previousPlane == 'xy': + if currentPlane == 'zy': + self.ax1.setRange(xRange=self.yRangePrev) + unusedRange = np.clip(self.xRangePrev, 0, SizeX) + elif currentPlane == 'zx': + self.ax1.setRange(xRange=self.xRangePrev) + unusedRange = np.clip(self.yRangePrev, 0, SizeY) + elif previousPlane == 'zy': + if currentPlane == 'xy': + self.ax1.setRange(yRange=self.xRangePrev) + unusedRange = np.clip(self.yRangePrev, 0, SizeZ) + elif currentPlane == 'zx': + self.ax1.setRange(yRange=self.yRangePrev) + unusedRange = np.clip(self.xRangePrev, 0, SizeY) + elif previousPlane == 'zx': + if currentPlane == 'xy': + self.ax1.setRange(xRange=self.xRangePrev) + unusedRange = np.clip(self.yRangePrev, 0, SizeZ) + elif currentPlane == 'zy': + self.ax1.setRange(yRange=self.yRangePrev) + unusedRange = np.clip(self.xRangePrev, 0, SizeX) + + sliceValue = round((unusedRange[0] + unusedRange[1])/2) + self.zSliceScrollBar.setSliderPosition(sliceValue) + self.update_z_slice(self.zSliceScrollBar.sliderPosition()) + + def setViewRangeSwitchPlane(self, previousPlane): + self.autoRange() + QTimer.singleShot( + 100, partial(self._setViewRangeSwitchPlane, previousPlane) + ) + + def switchViewedPlane(self, previousPlane, currentPlane): + posData = self.data[self.pos_i] + self.xRangePrev, self.yRangePrev = self.ax1.viewRange() + self.zSlicePrev = self.zSliceScrollBar.sliderPosition() + + self.zProjComboBox.setCurrentText('single z-slice') + depthAxes = self.switchPlaneCombobox.depthAxes() + self.onEscape() + self.initDelRoiLab() + if depthAxes != 'z': + # Disable projections on plane that is not xy + self.zProjComboBox.setCurrentText('single z-slice') + self.zProjComboBox.setDisabled(True) + + # Clear annotations + self.clearAllItems() + self.setHighlightID(False) + + # Disable annotations on a plane that is not yz + self.setDrawNothingAnnotations() + self.setDisabledAnnotCheckBoxesLeft(True) + self.setDisabledAnnotCheckBoxesRight(True) + self.setEnabledAnnotCheckBoxesLeftZdepthAxes() + self.overlayButtonPrevState = self.overlayButton.isChecked() + self.overlayButton.setChecked(False) + self.overlayButton.setDisabled(True) + else: + self.zProjComboBox.setDisabled(False) + self.restoreAnnotationsOptions() + self.setDisabledAnnotCheckBoxesLeft(False) + self.setDisabledAnnotCheckBoxesRight(False) + self.overlayButton.setDisabled(False) + if self.overlayButtonPrevState: + self.overlayButton.setChecked(self.overlayButtonPrevState) + self.updateZsliceScrollbar(posData.frame_i) + + SizeY, SizeX = posData.img_data[posData.frame_i].shape[-2:] + + if depthAxes != 'z' and self.isSnapshot: + # Disable editing when the plane is not xy + self.mode_controls_view.disableEditingViewPlaneNotXY() + elif self.isSnapshot: + # Re-enable editing in snapshot mode when the plane is xy + self.mode_controls_view.setEnabledSnapshotMode() + + if depthAxes == 'z': + maxSliceNum = posData.SizeZ + elif depthAxes == 'y': + maxSliceNum = SizeY + else: + maxSliceNum = SizeX + + maxSliceText = f'/{maxSliceNum}' + self.SizeZlabel.setText(maxSliceText) + self.zSliceCheckbox.setText(f'{depthAxes}-slice') + self.zSliceScrollBar.setMaximum(maxSliceNum-1) + self.zSliceSpinbox.setMaximum(maxSliceNum) + + self.initContoursImage() + self.updateAllImages() + QTimer.singleShot( + 200, partial(self.setViewRangeSwitchPlane, previousPlane) + ) + + def onZsliceSpinboxValueChange(self, value): + self.zSliceScrollBar.setSliderPosition(value-1) + + def update_z_slice(self, z): + posData = self.data[self.pos_i] + if self.switchPlaneCombobox.depthAxes() == 'z': + idx = self.view_model.z_slice_frame_indices( + filename=posData.filename, + frame_i=posData.frame_i, + size_t=posData.SizeT, + locked=self.zProjLockViewButton.isChecked(), + ) + posData.segmInfo_df.loc[idx, 'z_slice_used_gui'] = z + + self.preprocessing_view.updatePreprocessPreview() + self.updateCombineChannelsPreview() + self.highlightedID = self.getHighlightedID() + self.updateAllImages( + computePointsLayers=False, + computeContours=False, + updateLookuptable=True + ) + self.updateItemsMousePos() + if self.isSegm3D: + self.updateObjectCounts() + + def updateOverlayZslice(self, z): + self.setOverlayImages() + + def updateOverlayZproj(self, how): + if self.view_model.should_disable_overlay_z_slice(how): + self.overlay_z_label.setDisabled(True) + self.zSliceOverlay_SB.setDisabled(True) + else: + self.overlay_z_label.setDisabled(False) + self.zSliceOverlay_SB.setDisabled(False) + self.setOverlayImages() + + def updateZproj(self, how): + for p, posData in enumerate(self.data[self.pos_i:]): + idx = self.view_model.projection_frame_indices( + filename=posData.filename, + frame_i=posData.frame_i, + size_t=posData.SizeT, + locked=self.zProjLockViewButton.isChecked(), + ) + posData.segmInfo_df.loc[idx, 'which_z_proj_gui'] = how + posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) + + posData = self.data[self.pos_i] + if self.view_model.is_single_z_slice_projection(how): + self.zSliceScrollBar.setDisabled(False) + self.zSliceSpinbox.setDisabled(False) + self.zSliceCheckbox.setDisabled(False) + self.setZprojDisabled(False) + self.update_z_slice(self.zSliceScrollBar.sliderPosition()) + else: + self.zSliceScrollBar.setDisabled(True) + self.zSliceSpinbox.setDisabled(True) + self.zSliceCheckbox.setDisabled(True) + self.setZprojDisabled(self.isSegm3D) + self.updateAllImages() + + def setZprojDisabled(self, disabled, storePrevState=False): + self.combineChannelsAction.setDisabled(disabled) + for action in self.editToolBar.actions(): + button = self.editToolBar.widgetForAction(action) + if button == self.eraserButton: + continue + + if button in self.toolsActiveInProj3Dsegm: + continue + + try: + tooltip = button.toolTip() + prefix = 'WARNING: Disabled due to projection mode\n\n' + if disabled: + if not tooltip.startswith(prefix): + button.setToolTip(prefix + tooltip) + else: + if tooltip.startswith(prefix): + button.setToolTip(tooltip[len(prefix):]) + except: + pass + action.setDisabled(disabled) + try: + button.setChecked(False) + except Exception as err: + pass diff --git a/cellacdc/views/graphics_view.py b/cellacdc/views/graphics_view.py new file mode 100644 index 000000000..935cacd50 --- /dev/null +++ b/cellacdc/views/graphics_view.py @@ -0,0 +1,2908 @@ +"""Qt view adapter for graphics item construction workflows.""" + +from __future__ import annotations + +import traceback +from functools import partial + +import cv2 +import matplotlib +import numpy as np +import pyqtgraph as pg +import skimage.exposure +import skimage.measure +from natsort import natsorted +from qtpy.QtCore import QEventLoop, QRect, QRectF, Qt, QThread, QTimer +from qtpy.QtGui import QColor, QCursor, QFont +from qtpy.QtWidgets import QAction, QActionGroup, QLabel, QMenu +from qtpy.QtWidgets import QGraphicsProxyWidget, QPushButton + +from cellacdc import ( + _warnings, + annotate, + apps, + colors, + html_utils, + myutils, + widgets, + workers, +) +from cellacdc.viewmodels.graphics_viewmodel import GraphicsViewModel + +_font = QFont() +_font.setPixelSize(11) + + +class GraphicsView: + """Qt-facing adapter for graphics item construction workflows.""" + + LEGACY_METHODS = ( + 'defaultRescaleIntensLutActionToggled', + 'mousePressColorButton', + 'gui_addGraphicsItems', + 'gui_createTextAnnotColors', + 'gui_setTextAnnotColors', + 'gui_createPlotItems', + 'gui_createZoomRectItem', + 'gui_createLabelRoiItem', + 'gui_createOverlayColors', + 'gui_createOverlayItems', + 'addActionsLutItemContextMenu', + 'getOverlayItems', + 'removeAllItems', + 'clearAx2Items', + 'clearAx1Items', + 'clearOverlayLabelsItems', + 'clearAllItems', + 'createUserChannelNameAction', + 'createChannelNamesActions', + 'addFluoChNameContextMenuAction', + 'restoreDefaultColors', + 'segmNdimIndicatorClicked', + 'addAlphaScrollbar', + 'createOverlayContextMenu', + 'createOverlayLabelsContextMenu', + 'editOverlayLabelsAppearance', + 'createOverlayLabelsItems', + 'addOverlayLabelsToggled', + 'overlayLabelsDrawModeToggled', + 'overlayChannelToggled', + 'overlayLabels_cb', + 'askLabelsToOverlay', + 'showOverlayContextMenu', + 'showOverlayLabelsContextMenu', + 'setCheckedOverlayContextMenusActions', + 'enableOverlayWidgets', + 'hideOverlayLabelsItems', + 'showOverlayLabelsItems', + 'setOverlayLabelsItems', + 'getOverlayLabelsData', + 'loadOverlayLabelsData', + 'removeOverlayItems', + 'clearOverlayImageItems', + 'setOverlayColors', + 'loadOverlayData', + 'askSelectOverlayChannel', + 'setOverlaySingleChannel', + 'updateTransparentOverlayRgba', + 'setOverlayTransparency', + 'overlay_cb', + 'getOlImg', + 'setOverlayImages', + 'getOpacitiesFromAlphaScrollbarValues', + 'toggleOverlayColorButton', + 'toggleTextIDsColorButton', + 'updateTextAnnotColor', + 'saveTextIDsColors', + 'ticksCmapMoved', + 'updateLabelsCmap', + 'extendLabelsLUT', + 'initLookupTableLab', + 'getLabelsImageLut', + 'initLabelsImageItems', + 'updateLookuptable', + 'setLut', + 'shuffle_cmap', + 'setPermanentGreedyCmapPreferences', + 'permanentGreedyCmapToggled', + 'greedyShuffleCmap', + 'updateBkgrColor', + 'updateTextLabelsColor', + 'saveTextLabelsColor', + 'saveBkgrColor', + 'changeOverlayColor', + 'saveOverlayColor', + 'setValueLabelsAlphaSlider', + 'setOverlaySegmMasks', + 'setOverlayLabelsItemsVisible', + 'setRetainSizePolicyLutItems', + 'setOverlayChannelsToolbuttonsChecked', + 'setOverlayItemsVisible', + 'overlayChannelToolbuttonClicked', + 'setOverlayItemsOpacities', + 'initColormapOverlayLayerItem', + 'setOpacityOverlayLayersItems', + 'gui_getLostObjScatterItem', + 'gui_getTrackedLostObjScatterItem', + '_gui_createGraphicsItems', + 'gui_createTextAnnotItems', + 'gui_addOverlayLayerItems', + 'gui_addTopLayerItems', + 'updateContoursImage', + 'setContoursImage', + 'getObjFromID', + 'setLostObjectContour', + 'setTrackedLostObjectContour', + 'updateLostContoursImage', + 'drawLostObjContoursImage', + 'updateLostTrackedContoursImage', + 'drawLostTrackedObjContoursImage', + 'getNearestLostObjID', + 'addObjContourToContoursImage', + 'clearObjContour', + 'setAllContoursImages', + 'setAllLostObjContoursImage', + 'setAllLostTrackedObjContoursImage', + 'getObjContours', + 'clearComputedContours', + '_computeAllContours2D', + 'computeAllContours', + 'computeAllObjToObjCostPairs', + '_computeAllObjToObjCostPairs', + 'computeAllObjCostPairsWorkerCritical', + 'computeAllObjCostPairsWorkerFinished', + 'gui_createMothBudLinePens', + 'imgGradLUTfinished_cb', + 'restoreDefaultSettings', + 'updateMothBudLineColour', + '_updateMothBudLineColour', + 'saveMothBudLineColour', + 'mothBudLineWeightToggled', + '_updateMothBudLineSize', + 'gui_createContourPens', + 'updateContColour', + '_updateContColour', + 'saveContColour', + 'contLineWeightToggled', + '_updateContLineThickness', + 'gui_createGraphicsItems', + 'gui_connectGraphicsEvents', + 'gui_initImg1BottomWidgets', + ) + + def __init__(self, host, view_model: GraphicsViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def defaultRescaleIntensLutActionToggled(self, action): + how = action.text() + for rescaleIntensAction in self.imgGrad.rescaleActionGroup.actions(): + if how == rescaleIntensAction.text(): + rescaleIntensAction.setChecked(True) + rescaleIntensAction.trigger() + break + + for channel, items in self.overlayLayersItems.items(): + lutItem = items[1] + for rescaleIntensAction in lutItem.rescaleActionGroup.actions(): + if how == rescaleIntensAction.text(): + rescaleIntensAction.setChecked(True) + rescaleIntensAction.trigger() + break + + self.df_settings.at['default_rescale_intens_how', 'value'] = how + self.df_settings.to_csv(self.settings_csv_path) + + def mousePressColorButton(self, event): + posData = self.data[self.pos_i] + items = list(self.checkedOverlayChannels) + if len(items)>1: + selectFluo = widgets.QDialogListbox( + 'Select image', + 'Select which fluorescence image you want to update the color of\n', + items, multiSelection=False, parent=self.host + ) + selectFluo.exec_() + keys = selectFluo.selectedItemsText + if selectFluo.cancel or not keys: + return + else: + self.overlayColorButton.channel = keys[0] + else: + self.overlayColorButton.channel = items[0] + self.overlayColorButton.selectColor() + + def gui_addGraphicsItems(self): + # Auto image adjustment button + proxy = QGraphicsProxyWidget() + equalizeHistPushButton = QPushButton("Enhance contrast") + widthHint = equalizeHistPushButton.sizeHint().width() + equalizeHistPushButton.setMaximumWidth(widthHint) + equalizeHistPushButton.setCheckable(True) + if not self.invertBwAction.isChecked(): + equalizeHistPushButton.setStyleSheet( + 'QPushButton {background-color: #282828; color: #F0F0F0;}' + ) + self.equalizeHistPushButton = equalizeHistPushButton + proxy.setWidget(equalizeHistPushButton) + self.graphLayout.addItem(proxy, row=0, col=0) + self.equalizeHistPushButton = equalizeHistPushButton + + # Left image histogram + self.imgGrad = widgets.myHistogramLUTitem(parent=self.host, name='image') + self.imgGrad.restoreState(self.df_settings) + self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) + for action in self.imgGrad.rescaleActionGroup.actions(): + if action.text() == self.defaultRescaleIntensHow: + action.setChecked(True) + self.rescaleIntensMenu.addAction(action) + + # Colormap gradient widget + self.labelsGrad = widgets.labelsGradientWidget(parent=self.host) + try: + stateFound = self.labelsGrad.restoreState(self.df_settings) + except Exception as e: + self.logger.exception(traceback.format_exc()) + print('======================================') + self.logger.info( + 'Failed to restore previously used colormap. ' + 'Using default colormap "viridis"' + ) + self.labelsGrad.item.loadPreset('viridis') + + # Add actions to imgGrad gradient item + self.imgGrad.gradient.menu.addAction( + self.labelsGrad.showLabelsImgAction + ) + self.imgGrad.gradient.menu.addAction( + self.labelsGrad.showRightImgAction + ) + self.imgGrad.gradient.menu.addAction( + self.labelsGrad.showNextFrameAction + ) + + self.imgGrad.gradient.menu.addSeparator() + + self.imgGrad.gradient.menu.addMenu(self.exportMenu) + + # Add actions to view menu + self.viewMenu.addAction(self.labelsGrad.showLabelsImgAction) + self.viewMenu.addAction(self.labelsGrad.showRightImgAction) + + # Right image histogram + self.imgGradRight = widgets.baseHistogramLUTitem( + name='image', parent=self.host, gradientPosition='left' + ) + self.imgGradRight.gradient.menu.addAction( + self.labelsGrad.showLabelsImgAction + ) + self.imgGradRight.gradient.menu.addAction( + self.labelsGrad.showRightImgAction + ) + self.imgGradRight.gradient.menu.addAction( + self.labelsGrad.showNextFrameAction + ) + + self.imgGrad.setChildLutItem(self.imgGradRight) + + # Title + self.titleLabel = pg.LabelItem( + justify='center', color=self.titleColor, size='14pt' + ) + self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) + + def gui_createTextAnnotColors(self, r, g, b, custom=False): + if custom: + self.objLabelAnnotRgb = (int(r), int(g), int(b)) + self.SphaseAnnotRgb = (int(r*0.9), int(r*0.9), int(b*0.9)) + self.G1phaseAnnotRgba = (int(r*0.8), int(g*0.8), int(b*0.8), 220) + else: + self.objLabelAnnotRgb = (255, 255, 255) # white + self.SphaseAnnotRgb = (229, 229, 229) + self.G1phaseAnnotRgba = (204, 204, 204, 220) + self.dividedAnnotRgb = (245, 188, 1) # orange + + self.emptyBrush = pg.mkBrush((0,0,0,0)) + self.emptyPen = pg.mkPen((0,0,0,0)) + + def gui_setTextAnnotColors(self): + self.textAnnot[0].setColors( + self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, + self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb + ) + + self.textAnnot[1].setColors( + self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, + self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb + ) + + + def gui_createPlotItems(self): + if 'textIDsColor' in self.df_settings.index: + rgbString = self.df_settings.at['textIDsColor', 'value'] + r, g, b = colors.rgb_str_to_values(rgbString) + self.gui_createTextAnnotColors(r, g, b, custom=True) + self.textIDsColorButton.setColor((r, g, b)) + else: + self.gui_createTextAnnotColors(0,0,0, custom=False) + + if 'labels_text_color' in self.df_settings.index: + rgbString = self.df_settings.at['labels_text_color', 'value'] + r, g, b = colors.rgb_str_to_values(rgbString) + self.ax2_textColor = (r, g, b) + else: + self.ax2_textColor = (255, 0, 0) + + self.emptyLab = np.zeros((2,2), dtype=np.uint8) + + # Right image item linked to left + self.rightImageItem = widgets.ChildImageItem( + linkedScrollbar=self.rightImageFramesScrollbar + ) + self.imgGradRight.setImageItem(self.rightImageItem) + self.ax2.addItem(self.rightImageItem) + + # Left image + self.img1 = widgets.ParentImageItem( + linkedImageItem=self.rightImageItem, + activatingActions=( + self.labelsGrad.showRightImgAction, + self.labelsGrad.showNextFrameAction + ) + ) + self.imgGrad.setImageItem(self.img1) + self.img1.lutItem = self.imgGrad + self.imgGrad.sigRescaleIntes.connect(self.rescaleIntensitiesLut) + self.ax1.addBaseImageItem(self.img1) + + # RGBA image for true transparency mode + self.rgbaImg1 = pg.ImageItem() + + # self.rgbaImg1.setImage(self.emptyLab) + + # Right image + self.img2 = widgets.labImageItem() + self.ax2.addItem(self.img2) + + self.topLayerItems = [] + self.topLayerItemsRight = [] + + self.gui_createContourPens() + self.gui_createMothBudLinePens() + + self.eraserCirclePen = pg.mkPen(width=1.5, color='r') + + # Temporary line item connecting bud to new mother + self.BudMothTempLine = pg.PlotDataItem(pen=self.NewBudMoth_Pen) + self.topLayerItems.append(self.BudMothTempLine) + + # Temporary line item connecting objects to merge + self.mergeObjsTempLine = widgets.PlotCurveItem(pen=self.redDashLinePen) + self.topLayerItems.append(self.mergeObjsTempLine) + + # Overlay segm. masks item + self.labelsLayerImg1 = widgets.BaseLabelsImageItem() + self.ax1.addItem(self.labelsLayerImg1) + + self.labelsLayerRightImg = widgets.BaseLabelsImageItem() + self.ax2.addItem(self.labelsLayerRightImg) + + # Red/green border rect item + self.GreenLinePen = pg.mkPen(color='g', width=2) + self.RedLinePen = pg.mkPen(color='r', width=2) + self.ax1BorderLine = pg.PlotDataItem() + self.topLayerItems.append(self.ax1BorderLine) + self.ax2BorderLine = pg.PlotDataItem(pen=pg.mkPen(color='r', width=2)) + self.topLayerItems.append(self.ax2BorderLine) + + # Brush/Eraser/Wand.. layer item + self.tempLayerRightImage = pg.ImageItem() + self.tempLayerImg1 = widgets.ParentImageItem( + linkedImageItem=self.tempLayerRightImage, + activatingAction=(self.labelsGrad.showRightImgAction, ) + ) + self.topLayerItems.append(self.tempLayerImg1) + self.topLayerItemsRight.append(self.tempLayerRightImage) + + # Highlighted ID layer items + self.highLightIDLayerImg1 = pg.ImageItem() + self.topLayerItems.append(self.highLightIDLayerImg1) + + # Highlighted ID layer items + self.highLightIDLayerRightImage = pg.ImageItem() + self.topLayerItemsRight.append(self.highLightIDLayerRightImage) + + # Keep IDs temp layers + self.keepIDsTempLayerRight = pg.ImageItem() + self.keepIDsTempLayerLeft = widgets.ParentImageItem( + linkedImageItem=self.keepIDsTempLayerRight, + activatingAction=self.labelsGrad.showRightImgAction + ) + self.topLayerItems.append(self.keepIDsTempLayerLeft) + self.topLayerItemsRight.append(self.keepIDsTempLayerRight) + + # Searched ID contour + self.searchedIDitemRight = pg.ScatterPlotItem() + self.searchedIDitemRight.setData( + [], [], symbol='s', pxMode=False, size=1, + brush=pg.mkBrush(color=(255,0,0,150)), + pen=pg.mkPen(width=2, color='r'), tip=None + ) + self.searchedIDitemLeft = pg.ScatterPlotItem() + self.searchedIDitemLeft.setData( + [], [], symbol='s', pxMode=False, size=1, + brush=pg.mkBrush(color=(255,0,0,150)), + pen=pg.mkPen(width=2, color='r'), tip=None + ) + self.topLayerItems.append(self.searchedIDitemLeft) + self.topLayerItemsRight.append(self.searchedIDitemRight) + + + # Brush circle img1 + self.ax1_BrushCircle = pg.ScatterPlotItem() + self.ax1_BrushCircle.setData( + [], [], symbol='o', pxMode=False, + brush=pg.mkBrush((255,255,255,50)), + pen=pg.mkPen(width=2), tip=None + ) + self.topLayerItems.append(self.ax1_BrushCircle) + + # Eraser circle img1 + self.ax1_EraserCircle = pg.ScatterPlotItem() + self.ax1_EraserCircle.setData( + [], [], symbol='o', pxMode=False, + brush=None, pen=self.eraserCirclePen, tip=None + ) + self.topLayerItems.append(self.ax1_EraserCircle) + + self.ax1_EraserX = pg.ScatterPlotItem() + self.ax1_EraserX.setData( + [], [], symbol='x', pxMode=False, size=3, + brush=pg.mkBrush(color=(255,0,0,50)), + pen=pg.mkPen(width=1, color='r'), tip=None + ) + self.topLayerItems.append(self.ax1_EraserX) + + # Brush circle img1 + self.labelRoiCircItemLeft = widgets.LabelRoiCircularItem() + self.labelRoiCircItemLeft.cleared = False + self.labelRoiCircItemLeft.setData( + [], [], symbol='o', pxMode=False, + brush=pg.mkBrush(color=(255,0,0,0)), + pen=pg.mkPen(color='r', width=2), tip=None + ) + self.labelRoiCircItemRight = widgets.LabelRoiCircularItem() + self.labelRoiCircItemRight.cleared = False + self.labelRoiCircItemRight.setData( + [], [], symbol='o', pxMode=False, + brush=pg.mkBrush(color=(255,0,0,0)), + pen=pg.mkPen(color='r', width=2), tip=None + ) + self.topLayerItems.append(self.labelRoiCircItemLeft) + self.topLayerItemsRight.append(self.labelRoiCircItemRight) + + self.ax1_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() + self.ax1_binnedIDs_ScatterPlot.setData( + [], [], symbol='t', pxMode=False, + brush=pg.mkBrush((255,0,0,50)), size=15, + pen=pg.mkPen(width=3, color='r'), tip=None + ) + self.topLayerItems.append(self.ax1_binnedIDs_ScatterPlot) + + self.ax1_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() + self.ax1_ripIDs_ScatterPlot.setData( + [], [], symbol='x', pxMode=False, + brush=pg.mkBrush((255,0,0,50)), size=15, + pen=pg.mkPen(width=2, color='r'), tip=None + ) + self.topLayerItems.append(self.ax1_ripIDs_ScatterPlot) + + # Ruler plotItem and scatterItem + rulerPen = pg.mkPen(color='r', style=Qt.DashLine, width=2) + self.ax1_rulerPlotItem = widgets.RulerPlotItem(pen=rulerPen) + self.ax1_rulerAnchorsItem = pg.ScatterPlotItem( + symbol='o', size=9, + brush=pg.mkBrush((255,0,0,50)), + pen=pg.mkPen((255,0,0), width=2), tip=None + ) + self.topLayerItems.append(self.ax1_rulerPlotItem) + self.topLayerItems.append(self.ax1_rulerPlotItem.labelItem) + self.topLayerItems.append(self.ax1_rulerAnchorsItem) + + # Start point of polyline roi + self.ax1_point_ScatterPlot = pg.ScatterPlotItem() + self.ax1_point_ScatterPlot.setData( + [], [], symbol='o', pxMode=False, size=3, + pen=pg.mkPen(width=2, color='r'), + brush=pg.mkBrush((255,0,0,50)), tip=None + ) + self.topLayerItems.append(self.ax1_point_ScatterPlot) + + # Experimental: scatter plot to add a point marker + self.startPointPolyLineItem = pg.ScatterPlotItem() + self.startPointPolyLineItem.setData( + [], [], symbol='o', size=9, + pen=pg.mkPen(width=2, color='r'), + brush=pg.mkBrush((255,0,0,50)), + hoverable=True, hoverBrush=pg.mkBrush((255,0,0,255)), tip=None + ) + self.topLayerItems.append(self.startPointPolyLineItem) + + # Eraser circle img2 + self.ax2_EraserCircle = pg.ScatterPlotItem() + self.ax2_EraserCircle.setData( + [], [], symbol='o', pxMode=False, brush=None, + pen=self.eraserCirclePen, tip=None + ) + self.ax2.addItem(self.ax2_EraserCircle) + self.ax2_EraserX = pg.ScatterPlotItem() + self.ax2_EraserX.setData( + [], [], symbol='x', pxMode=False, size=3, + brush=pg.mkBrush(color=(255,0,0,50)), + pen=pg.mkPen(width=1.5, color='r') + ) + self.ax2.addItem(self.ax2_EraserX) + + # Brush circle img2 + self.ax2_BrushCirclePen = pg.mkPen(width=2) + self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50)) + self.ax2_BrushCircle = pg.ScatterPlotItem() + self.ax2_BrushCircle.setData( + [], [], symbol='o', pxMode=False, + brush=self.ax2_BrushCircleBrush, + pen=self.ax2_BrushCirclePen, tip=None + ) + self.ax2.addItem(self.ax2_BrushCircle) + + # Annotated metadata markers (ScatterPlotItem) + self.ax2_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() + self.ax2_binnedIDs_ScatterPlot.setData( + [], [], symbol='t', pxMode=False, + brush=pg.mkBrush((255,0,0,50)), size=15, + pen=pg.mkPen(width=3, color='r'), tip=None + ) + self.ax2.addItem(self.ax2_binnedIDs_ScatterPlot) + + self.ax2_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() + self.ax2_ripIDs_ScatterPlot.setData( + [], [], symbol='x', pxMode=False, + brush=pg.mkBrush((255,0,0,50)), size=15, + pen=pg.mkPen(width=2, color='r'), tip=None + ) + self.ax2.addItem(self.ax2_ripIDs_ScatterPlot) + + self.freeRoiItem = widgets.PlotCurveItem( + pen=pg.mkPen(color='r', width=2) + ) + self.topLayerItems.append(self.freeRoiItem) + + self.warnPairingItem = widgets.PlotCurveItem( + pen=pg.mkPen(color='r', width=5, style=Qt.DashLine), + pxMode=False + ) + self.topLayerItems.append(self.warnPairingItem) + + self.exportMaskImageItem = pg.ImageItem() + + self.ghostContourItemLeft = widgets.GhostContourItem(self.ax1) + self.ghostContourItemRight = widgets.GhostContourItem(self.ax2) + + self.ghostMaskItemLeft = widgets.GhostMaskItem(self.ax1) + self.ghostMaskItemRight = widgets.GhostMaskItem(self.ax2) + + self.manualBackgroundObjItem = widgets.GhostContourItem( + self.ax1, penColor='r', textColor='r' + ) + self.manualBackgroundImageItem = pg.ImageItem() + + def gui_createZoomRectItem(self): + Y, X = self.currentLab2D.shape + # Label ROI rectangle + pen = pg.mkPen('r', width=3, style=Qt.DashLine) + self.zoomRectItem = widgets.ZoomROI( + (0,0), (0,0), + maxBounds=QRectF(QRect(0,0,X,Y)), + scaleSnap=True, + translateSnap=True, + pen=pen, hoverPen=pen + ) + + def gui_createLabelRoiItem(self): + Y, X = self.currentLab2D.shape + # Label ROI rectangle + pen = pg.mkPen('r', width=3) + self.labelRoiItem = widgets.ROI( + (0,0), (0,0), + maxBounds=QRectF(QRect(0,0,X,Y)), + scaleSnap=True, + translateSnap=True, + pen=pen, hoverPen=pen + ) + + posData = self.data[self.pos_i] + if self.labelRoiZdepthSpinbox.value() == 0: + self.labelRoiZdepthSpinbox.setValue(posData.SizeZ) + self.labelRoiZdepthSpinbox.setMaximum(posData.SizeZ+1) + + def gui_createOverlayColors(self): + fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] + self.logger.info( + f'Number of TIFF files detected: {len(fluoChannels)}' + ) + self.overlayColors = {} + for c, ch in enumerate(fluoChannels): + if f'{ch}_rgb' in self.df_settings.index: + rgb_text = self.df_settings.at[f'{ch}_rgb', 'value'] + rgb = tuple([int(val) for val in rgb_text.split('_')]) + self.overlayColors[ch] = rgb + else: + if c >= len(self.overlayRGBs) -1: + i = c/len(fluoChannels) + additional_color_num = c - len(self.overlayRGBs) + 1 + rgbs = [ + tuple([round(c*255) for c in self.overlayCmap(i)][:3]) + for _ in range(additional_color_num) + ] + self.overlayRGBs.extend(rgbs) + rgb = colors.FLUO_CHANNELS_COLORS.get(ch, self.overlayRGBs[c]) + self.overlayColors[ch] = rgb + + def gui_createOverlayItems(self): + self.imgGrad.setAxisLabel(self.user_ch_name) + self.baseLayerToolbutton = widgets.OverlayChannelToolButton( + self.user_ch_name, self.imgGrad + ) + self.baseLayerToolbutton.setChecked(True) + self.baseLayerToolbutton.clicked.connect( + self.overlayChannelToolbuttonClicked + ) + self.allOverlayToolbuttons = { + self.user_ch_name: self.baseLayerToolbutton + } + self.allOverlayToolbuttonsByIdx = { + 0: self.baseLayerToolbutton + } + self.baseLayerToolbutton.action = ( + self.overlayToolbar.addWidget(self.baseLayerToolbutton) + ) + self.overlayLayersItems = {} + self.overlayToolbarAreChannelsChecked = {} + fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] + for c, ch in enumerate(fluoChannels): + overlayItems = self.getOverlayItems(ch, c+1) + self.overlayLayersItems[ch] = overlayItems + imageItem, lutItem = overlayItems[:2] + self.ax1.addItem(imageItem) + self.lutItemsLayout.addItem(lutItem, row=0, col=c+1) + toolbutton = overlayItems[3] + self.allOverlayToolbuttons[ch] = toolbutton + self.allOverlayToolbuttonsByIdx[c+1] = toolbutton + + self.overlayToolbuttonsSep = self.overlayToolbar.addSeparator() + self.plotsCol = len(self.ch_names) + + self.ax1.addImageItem(self.rgbaImg1) + + def addActionsLutItemContextMenu(self, lutItem): + lutItem.gradient.menu.addSection('Visible channels: ') + for action in self.overlayContextMenu.actions(): + if action.isSeparator(): + continue + lutItem.gradient.menu.addAction(action) + lutItem.gradient.menu.addSeparator() + + annotationMenu = lutItem.gradient.menu.addMenu('Annotations settings') + ID_menu = annotationMenu.addMenu('IDs') + self.annotSettingsIDmenu = QActionGroup(annotationMenu) + labID_action = QAction("Show label's ID") + labID_action.setCheckable(True) + labID_action.setChecked(True) + labID_action.toggled.connect(self.annotLabelIDtreeToggled) + treeID_action = QAction("Show tree's ID") + treeID_action.setCheckable(True) + treeID_action.toggled.connect(self.annotLabelIDtreeToggled) + self.annotSettingsIDmenu.addAction(labID_action) + self.annotSettingsIDmenu.addAction(treeID_action) + ID_menu.addAction(labID_action) + ID_menu.addAction(treeID_action) + + ID_menu = annotationMenu.addMenu('Generation number') + self.annotSettingsGenNumMenu = QActionGroup(annotationMenu) + gen_num_action = QAction("Show default generation number") + gen_num_action.setCheckable(True) + gen_num_action.setChecked(True) + gen_num_action.toggled.connect(self.annotGenNumTreeToggled) + tree_gen_num_action = QAction("Show tree generation number") + tree_gen_num_action.setCheckable(True) + tree_gen_num_action.toggled.connect(self.annotGenNumTreeToggled) + self.annotSettingsGenNumMenu.addAction(gen_num_action) + self.annotSettingsGenNumMenu.addAction(tree_gen_num_action) + ID_menu.addAction(gen_num_action) + ID_menu.addAction(tree_gen_num_action) + + def getOverlayItems(self, channelName, index): + imageItem = widgets.OverlayImageItem() + imageItem.setOpacity(0.5) + imageItem.channelName = channelName + + lutItem = widgets.myHistogramLUTitem( + parent=self.host, name='image', axisLabel=channelName + ) + imageItem.lutItem = lutItem + for action in lutItem.rescaleActionGroup.actions(): + if action.text() == self.defaultRescaleIntensHow: + action.setChecked(True) + break + + lutItem.removeAddScaleBarAction() + lutItem.removeAddTimestampAction() + lutItem.restoreState(self.df_settings) + lutItem.setImageItem(imageItem) + lutItem.vb.raiseContextMenu = lambda x: None + initColor = self.overlayColors[channelName] + self.initColormapOverlayLayerItem(initColor, lutItem) + lutItem.addOverlayColorButton(initColor, channelName) + lutItem.initColor = initColor + lutItem.hide() + + lutItem.overlayColorButton.sigColorChanging.connect( + self.changeOverlayColor + ) + lutItem.overlayColorButton.sigColorChanged.connect( + self.saveOverlayColor + ) + + lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW) + + lutItem.contoursColorButton.disconnect() + lutItem.contoursColorButton.clicked.connect( + self.imgGrad.contoursColorButton.click + ) + for act in lutItem.contLineWightActionGroup.actions(): + act.toggled.connect(self.contLineWeightToggled) + + lutItem.mothBudLineColorButton.disconnect() + lutItem.mothBudLineColorButton.clicked.connect( + self.imgGrad.mothBudLineColorButton.click + ) + for act in lutItem.mothBudLineWightActionGroup.actions(): + act.toggled.connect(self.mothBudLineWeightToggled) + + lutItem.textColorButton.disconnect() + lutItem.textColorButton.clicked.connect( + self.editTextIDsColorAction.trigger + ) + + lutItem.defaultSettingsAction.triggered.connect( + self.restoreDefaultSettings + ) + lutItem.labelsAlphaSlider.valueChanged.connect( + self.setValueLabelsAlphaSlider + ) + lutItem.sigRescaleIntes.connect( + partial(self.rescaleIntensitiesLut, imageItem=imageItem) + ) + if f'how_rescale_intensities_{channelName}' in self.df_settings.index: + how = self.df_settings.at[ + f'how_rescale_intensities_{channelName}', 'value' + ] + lutItem.setRescaleIntensitiesHow(how) + + self.rescaleIntensChannelHowMapper[channelName] = ( + 'Rescale each 2D image' + ) + + self.addActionsLutItemContextMenu(lutItem) + + alphaScrollBar = self.addAlphaScrollbar(channelName, imageItem) + + toolbutton = widgets.OverlayChannelToolButton( + channelName, lutItem, shortcut=str(index) + ) + toolbutton.action = self.overlayToolbar.addWidget(toolbutton) + toolbutton.setVisible(False) + + toolbutton.clicked.connect(self.overlayChannelToolbuttonClicked) + + alphaScrollBar.toolbutton = toolbutton + + return imageItem, lutItem, alphaScrollBar, toolbutton + + def removeAllItems(self): + self.ax1.clear() + self.ax2.clear() + try: + self.chNamesQActionGroup.removeAction(self.userChNameAction) + except Exception as e: + pass + try: + posData = self.data[self.pos_i] + for action in self.fluoDataChNameActions: + self.chNamesQActionGroup.removeAction(action) + except Exception as e: + pass + try: + self.overlayButton.setChecked(False) + except Exception as e: + pass + + if hasattr(self, 'contoursImage'): + self.initContoursImage() + + def clearAx2Items(self, onlyHideText=False): + self.ax2_binnedIDs_ScatterPlot.clear() + self.ax2_ripIDs_ScatterPlot.clear() + self.ax2_contoursImageItem.clear() + self.ax2_lostObjImageItem.clear() + self.ax2_lostTrackedObjImageItem.clear() + self.textAnnot[1].clear() + self.ax2_newMothBudLinesItem.setData([], []) + self.ax2_oldMothBudLinesItem.setData([], []) + self.ax2_lostObjScatterItem.setData([], []) + + def clearAx1Items(self, onlyHideText=False): + self.ax1_binnedIDs_ScatterPlot.clear() + self.ax1_ripIDs_ScatterPlot.clear() + self.labelsLayerImg1.clear() + self.labelsLayerRightImg.clear() + self.keepIDsTempLayerLeft.clear() + self.keepIDsTempLayerRight.clear() + self.highLightIDLayerImg1.clear() + self.highLightIDLayerRightImage.clear() + self.searchedIDitemLeft.clear() + self.searchedIDitemRight.clear() + self.ax1_contoursImageItem.clear() + self.ax1_lostObjImageItem.clear() + self.ax1_lostTrackedObjImageItem.clear() + self.textAnnot[0].clear() + self.ax1_newMothBudLinesItem.setData([], []) + self.ax1_oldMothBudLinesItem.setData([], []) + self.ax1_lostObjScatterItem.setData([], []) + self.ax1_lostTrackedScatterItem.setData([], []) + self.ccaFailedScatterItem.setData([], []) + self.yellowContourScatterItem.setData([], []) + + self.clearPointsLayers() + + self.clearOverlayLabelsItems() + self.clearManualBackgroundAnnotations() + self.custom_annotations_view.clearCustomAnnot() + + def clearOverlayLabelsItems(self): + for segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): + items = self.overlayLabelsItems[segmEndname] + imageItem, contoursItem, gradItem = items + imageItem.clear() + contoursItem.clear() + + def clearAllItems(self): + self.clearAx1Items() + self.clearAx2Items() + + def createUserChannelNameAction(self): + self.userChNameAction = QAction(self.host) + self.userChNameAction.setCheckable(True) + self.userChNameAction.setText(self.user_ch_name) + + def createChannelNamesActions(self): + # LUT histogram channel name context menu actions + self.chNamesQActionGroup = QActionGroup(self.host) + self.chNamesQActionGroup.addAction(self.userChNameAction) + posData = self.data[self.pos_i] + for action in self.fluoDataChNameActions: + self.chNamesQActionGroup.addAction(action) + action.setChecked(False) + + self.userChNameAction.setChecked(True) + + for action in self.overlayContextMenu.actions(): + action.setChecked(False) + + def addFluoChNameContextMenuAction(self, ch_name): + posData = self.data[self.pos_i] + allTexts = [ + action.text() for action in self.chNamesQActionGroup.actions() + ] + if ch_name not in allTexts: + action = QAction(self.host) + action.setText(ch_name) + action.setCheckable(True) + self.chNamesQActionGroup.addAction(action) + action.setChecked(True) + self.fluoDataChNameActions.append(action) + + def restoreDefaultColors(self): + try: + color = self.defaultToolBarButtonColor + self.overlayButton.setStyleSheet(f'background-color: {color}') + except AttributeError: + # traceback.print_exc() + pass + + def segmNdimIndicatorClicked(self): + ndimText = self.segmNdimIndicator.text() + if ndimText == '2D': + alternativeNdimText = '3D' + toggleText = 'activate' + else: + alternativeNdimText = '2D' + toggleText = 'de-activate' + msg = widgets.myMessageBox(wrapText=False) + important_txt = (""" + The toggle to activate 3D segmentation is visible only when + the Number of z-slices is greater than 1. + """) + txt = html_utils.paragraph(f""" + This indicator shows that you are working with {ndimText} + segmentation masks.

+ + If instead, you want to work with {alternativeNdimText} segmentation, + you need to initialize a new segmentation file.

+ + To do so, go the menu on the top menubar File --> + New Segmentation File... and,
+ at the dialog where you insert the metadata (Number of z-slices, + pixel size, etc.),
+ {toggleText} the parameter called Work with 3D + segmentation masks (z-stack)
+ as indicated in the screenshot below
. + {html_utils.to_admonition(important_txt, admonition_type='note')} +
+ """) + msg.information( + self.host, 'Segmentation nmber of dimensions info', txt, + image_paths=':toggle_3D_screenshot.png' + ) + self.segmNdimIndicator.setChecked(True) + + def addAlphaScrollbar(self, channelName, imageItem): + alphaScrollBar = widgets.ScrollBar(Qt.Horizontal) + imageItem.alphaScrollBar = alphaScrollBar + alphaScrollBar.channelName = channelName + + label = QLabel(f'Alpha {channelName}') + label.setFont(_font) + label.hide() + alphaScrollBar.imageItem = imageItem + alphaScrollBar.label = label + alphaScrollBar.setFixedHeight(self.h) + alphaScrollBar.hide() + alphaScrollBar.setMinimum(0) + alphaScrollBar.setMaximum(40) + alphaScrollBar.setValue(20) + alphaScrollBar.setToolTip( + f'Control the alpha value of the overlaid channel {channelName}.\n' + 'alpha=0 results in NO overlay,\n' + 'alpha=1 results in only fluorescence data visible' + ) + self.bottomLeftLayout.addWidget( + alphaScrollBar.label, self.alphaScrollbarRow, 0, + alignment=Qt.AlignRight + ) + self.bottomLeftLayout.addWidget( + alphaScrollBar, self.alphaScrollbarRow, 1, 1, 2 + ) + + alphaScrollBar.valueChanged.connect( + partial(self.setOpacityOverlayLayersItems, scrollbar=alphaScrollBar) + ) + + self.alphaScrollbarRow += 1 + return alphaScrollBar + + def createOverlayContextMenu(self): + ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] + self.overlayContextMenu = QMenu() + self.overlayContextMenu.addSeparator() + self.checkedOverlayChannels = set() + for chName in ch_names: + action = QAction(chName, self.overlayContextMenu) + action.setCheckable(True) + action.toggled.connect(self.overlayChannelToggled) + self.overlayContextMenu.addAction(action) + + def createOverlayLabelsContextMenu(self, segmEndnames): + self.overlayLabelsContextMenu = QMenu() + self.overlayLabelsContextMenu.addSeparator() + self.drawModeOverlayLabelsChannels = {} + segmEndnames_extended = list(segmEndnames.copy()) + segmEndnames_extended = ['combined segm.'] + segmEndnames_extended + for segmEndname in segmEndnames_extended: + action = QAction(segmEndname, self.overlayLabelsContextMenu) + if segmEndname == 'combined segm.': + action.setCheckable(False) + self.combineSegmViewToggle = action + else: + action.setCheckable(True) + action.toggled.connect(self.addOverlayLabelsToggled) + self.overlayLabelsContextMenu.addAction(action) + + self.overlayLabelsContextMenu.addSeparator() + action = QAction('Edit appearance...', self.overlayLabelsContextMenu) + action.triggered.connect(self.editOverlayLabelsAppearance) + self.overlayLabelsContextMenu.addAction(action) + + def editOverlayLabelsAppearance(self, *args): + segmEndname = list(self.overlayLabelsItems.keys())[0] + contoursItem = self.overlayLabelsItems[segmEndname][1] + win = apps.OverlayLabelsAppearanceDialog( + scatterPlotItem=contoursItem, parent=self.host + ) + win.exec_() + if win.cancel: + return + + brush = win.properties['brush'] + pen = win.properties['pen'] + for items in self.overlayLabelsItems.values(): + imageItem, contoursItem, gradItem = items + contoursItem.setBrush(brush, update=False) + contoursItem.setPen(pen) + + def createOverlayLabelsItems(self, segmEndnames): + selectActionGroup = QActionGroup(self.host) + segmEndnames_extended = list(segmEndnames.copy()) + segmEndnames_extended = ['combined segm.'] + segmEndnames_extended + for segmEndname in segmEndnames_extended: + action = QAction(segmEndname) + if segmEndname == 'combined segm.': + action.setCheckable(False) + else: + action.setCheckable(True) + action.toggled.connect(self.setOverlayLabelsItemsVisible) + selectActionGroup.addAction(action) + self.selectOverlayLabelsActionGroup = selectActionGroup + + self.overlayLabelsItems = {} + for segmEndname in segmEndnames_extended: + imageItem = pg.ImageItem() + + gradItem = widgets.overlayLabelsGradientWidget( + imageItem, selectActionGroup, segmEndname + ) + gradItem.hide() + gradItem.drawModeActionGroup.triggered.connect( + self.overlayLabelsDrawModeToggled + ) + self.mainLayout.addWidget(gradItem, 0, 0) + + contoursItem = pg.ScatterPlotItem() + color = colors.get_complementary_color(self.contLineColor) + r, g, b, a = colors.rgba_str_to_values(color) + qcolor = QColor(r, g, b, a) + contoursItem.setData( + [], [], symbol='s', pxMode=False, size=self.contLineWeight*2, + brush=pg.mkBrush(color=qcolor), + pen=pg.mkPen(width=3, color=qcolor), tip=None + ) + + items = (imageItem, contoursItem, gradItem) + self.overlayLabelsItems[segmEndname] = items + + def addOverlayLabelsToggled(self, checked, name=None): + if name is None: + name = self.sender().text() + if checked: + gradItem = self.overlayLabelsItems[name][-1] + drawMode = gradItem.drawModeActionGroup.checkedAction().text() + self.drawModeOverlayLabelsChannels[name] = drawMode + else: + self.drawModeOverlayLabelsChannels.pop(name) + self.hideOverlayLabelsItems(specific=[name]) + self.setOverlayLabelsItems() + + def overlayLabelsDrawModeToggled(self, action): + segmEndname = action.segmEndname + drawMode = action.text() + if segmEndname in self.drawModeOverlayLabelsChannels: + self.drawModeOverlayLabelsChannels[segmEndname] = drawMode + self.setOverlayLabelsItems() + + def overlayChannelToggled(self, checked): + # Action toggled from overlayButton context menu + channelName = self.sender().text() + posData = self.data[self.pos_i] + if checked: + if channelName not in posData.loadedFluoChannels: + self.loadOverlayData([channelName], addToExisting=True) + else: + _, filename = self.getPathFromChName(channelName, posData) + posData.ol_data[filename] = ( + posData.ol_data_dict[filename].copy() + ) + + self.checkedOverlayChannels.add(channelName) + else: + self.checkedOverlayChannels.remove(channelName) + imageItem = self.overlayLayersItems[channelName][0] + imageItem.clear() + + self.setOverlayChannelsToolbuttonsChecked() + self.setOverlayItemsVisible() + self.setRetainSizePolicyLutItems() + self.updateAllImages() + + def overlayLabels_cb(self, checked, selectedLabelsEndnames=None): + if checked: + if not self.drawModeOverlayLabelsChannels: + if selectedLabelsEndnames is None: + selectedLabelsEndnames = self.askLabelsToOverlay() + if selectedLabelsEndnames is None: + self.logger.info('Overlay labels cancelled.') + self.overlayLabelsButton.setChecked(False) + return + for selectedEndname in selectedLabelsEndnames: + self.loadOverlayLabelsData(selectedEndname) + for action in self.overlayLabelsContextMenu.actions(): + if not action.isCheckable(): + continue + if action.text() == selectedEndname: + action.setChecked(True) + lastSelectedName = selectedLabelsEndnames[-1] + for action in self.selectOverlayLabelsActionGroup.actions(): + if action.text() == lastSelectedName: + action.setChecked(True) + self.updateAllImages() + + def askLabelsToOverlay(self): + selectOverlayLabels = widgets.QDialogListbox( + 'Select segmentation to overlay', + 'Select segmentation file to overlay:\n', + natsorted(self.existingSegmEndNames), + multiSelection=True, + parent=self.host + ) + selectOverlayLabels.exec_() + if selectOverlayLabels.cancel: + return + + return selectOverlayLabels.selectedItemsText + + def showOverlayContextMenu(self, event): + if not self.overlayButton.isChecked(): + return + + self.overlayContextMenu.exec_(QCursor.pos()) + + def showOverlayLabelsContextMenu(self, event): + if not self.overlayLabelsButton.isChecked(): + return + + self.overlayLabelsContextMenu.exec_(QCursor.pos()) + + def setCheckedOverlayContextMenusActions(self, channelNames): + for action in self.overlayContextMenu.actions(): + if action.text() in channelNames: + action.setChecked(True) + self.checkedOverlayChannels.add(action.text()) + + def enableOverlayWidgets(self, enabled): + posData = self.data[self.pos_i] + if enabled: + self.overlayColorButton.setDisabled(False) + self.editOverlayColorAction.setDisabled(False) + + if posData.SizeZ == 1: + return + + self.zSliceOverlay_SB.setMaximum(posData.SizeZ-1) + if self.zProjOverlay_CB.currentText().find('max') != -1: + self.overlay_z_label.setDisabled(True) + self.zSliceOverlay_SB.setDisabled(True) + else: + z = self.zSliceOverlay_SB.sliderPosition() + self.overlay_z_label.setText( + f'Overlay z-slice {z+1:02}/{posData.SizeZ}' + ) + self.zSliceOverlay_SB.setDisabled(False) + self.overlay_z_label.setDisabled(False) + self.zSliceOverlay_SB.show() + self.overlay_z_label.show() + self.zProjOverlay_CB.show() + self.zSliceOverlay_SB.valueChanged.connect(self.updateOverlayZslice) + self.zProjOverlay_CB.currentTextChanged.connect( + self.updateOverlayZproj + ) + self.zProjOverlay_CB.activated.connect( + self.mode_controls_view.clearComboBoxFocus + ) + else: + self.zSliceOverlay_SB.setDisabled(True) + self.zSliceOverlay_SB.hide() + self.overlay_z_label.hide() + self.zProjOverlay_CB.hide() + self.overlayColorButton.setDisabled(True) + self.editOverlayColorAction.setDisabled(True) + + if posData.SizeZ == 1: + return + + self.zSliceOverlay_SB.valueChanged.disconnect() + self.zProjOverlay_CB.currentTextChanged.disconnect() + self.zProjOverlay_CB.activated.disconnect() + + def hideOverlayLabelsItems(self, specific=None): + if specific is None: + specific = self.overlayLabelsItems.keys() + for segmEndname in specific: + imageItem, contoursItem, gradItem = self.overlayLabelsItems[ + segmEndname + ] + imageItem.setVisible(False) + contoursItem.setVisible(False) + gradItem.setVisible(False) + + def showOverlayLabelsItems(self, specific=None): + if specific is None: + specific = self.overlayLabelsItems.keys() + for segmEndname in specific: + imageItem, contoursItem, gradItem = self.overlayLabelsItems[ + segmEndname + ] + drawMode = self.drawModeOverlayLabelsChannels[segmEndname] + if drawMode == 'Draw contours': + contoursItem.setVisible(True) + elif drawMode == 'Overlay labels': + imageItem.setVisible(True) + gradItem.setVisible(True) + + def setOverlayLabelsItems(self, specific=None): + if not self.overlayLabelsButton.isChecked(): + self.hideOverlayLabelsItems(specific=specific) + return + + if specific is None: + specific = self.drawModeOverlayLabelsChannels.keys() + + for segmEndname in specific: + drawMode = self.drawModeOverlayLabelsChannels[segmEndname] + ol_lab = self.getOverlayLabelsData(segmEndname) + items = self.overlayLabelsItems[segmEndname] + imageItem, contoursItem, gradItem = items + contoursItem.clear() + if drawMode == 'Draw contours': + for obj in skimage.measure.regionprops(ol_lab): + contours = self.getObjContours( + obj, all_external=True + ) + for cont in contours: + contoursItem.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) + elif drawMode == 'Overlay labels': + imageItem.setImage(ol_lab, autoLevels=False) + self.showOverlayLabelsItems(specific=specific) + + def getOverlayLabelsData(self, segmEndname): + posData = self.data[self.pos_i] + + if posData.ol_labels_data is None: + self.loadOverlayLabelsData(segmEndname) + elif segmEndname not in posData.ol_labels_data: + self.loadOverlayLabelsData(segmEndname) + + comb_seg = False + if 'combined segm.' == segmEndname: + comb_seg = True + if not self.isSegm3D: + zStackImg = self.data[0].SizeZ > 1 + if zStackImg: + selected_z_stack = self.zSliceScrollBar.sliderPosition() + else: + selected_z_stack = 0 + out = posData.ol_labels_data['combined segm.'][ + posData.frame_i + ][selected_z_stack] + return out.astype(np.uint32) + + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if isZslice: + z = self.zSliceScrollBar.sliderPosition() + ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i][z] + if comb_seg: + ol_lab = ol_lab.astype(np.uint32) + return ol_lab + else: + ol_lab = posData.ol_labels_data[segmEndname][ + posData.frame_i + ].max(axis=0) + if comb_seg: + ol_lab = ol_lab.astype(np.uint32) + return ol_lab + else: + return posData.ol_labels_data[segmEndname][posData.frame_i] + + def loadOverlayLabelsData(self, segmEndname, pos_i=None): + if pos_i is None: + pos_i = self.pos_i + posData = self.data[pos_i] + + if posData.ol_labels_data is None: + posData.ol_labels_data = {} + if segmEndname == 'combined segm.': + posData.ol_labels_data['combined segm.'] = posData.combine_img_data + return + filePath, filename = self.view_model.workspace.path_from_endname( + segmEndname, posData.images_path + ) + self.logger.info(f'Loading "{segmEndname}.npz"...') + labelsData = np.load(filePath)['arr_0'] + if posData.SizeT == 1: + labelsData = labelsData[np.newaxis] + if self.isSegm3D and labelsData.ndim == 3: + # 2D segm --> stack to 3D + T, Y, X = labelsData.shape + repeat = [labelsData]*posData.SizeZ + labelsData = np.stack(repeat, axis=1) + + posData.ol_labels_data[segmEndname] = labelsData + + def removeOverlayItems(self): + self.lutItemsLayout.clear() + + try: + for toolbutton in self.allOverlayToolbuttonsByIdx.values(): + self.overlayToolbar.removeAction(toolbutton.action) + + self.overlayToolbuttonsSep.removeFromToolbar() + except Exception as err: + pass + + def clearOverlayImageItems(self): + for items in self.overlayLayersItems.values(): + imageItem = items[0] + imageItem.clear() + + self.rgbaImg1.clear() + + def setOverlayColors(self): + self.overlayRGBs = [ + (255, 255, 0), + (252, 72, 254), + (49, 222, 134), + (22, 108, 27) + ] + self.overlayCmap = matplotlib.colormaps['hsv'] + self.overlayRGBs.extend( + [tuple([round(c*255) for c in self.overlayCmap(i)][:3]) + for i in np.linspace(0,1,8)] + ) + + def loadOverlayData(self, ol_channels, addToExisting=False): + posData = self.data[self.pos_i] + for ol_ch in ol_channels: + if ol_ch not in list(posData.loadedFluoChannels): + # Requested channel was never loaded --> load it at first + # iter i == 0 + success = self.loadFluo_cb(fluo_channels=[ol_ch]) + if not success: + return False + + lastChannelName = ol_channels[-1] + for action in self.fluoDataChNameActions: + if action.text() == lastChannelName: + action.setChecked(True) + + for p, posData in enumerate(self.data): + if addToExisting: + ol_data = posData.ol_data + else: + ol_data = {} + for i, ol_ch in enumerate(ol_channels): + _, filename = self.getPathFromChName(ol_ch, posData) + ol_data[filename] = ( + posData.ol_data_dict[filename].copy() + ) + self.addFluoChNameContextMenuAction(ol_ch) + posData.ol_data = ol_data + + return True + + def askSelectOverlayChannel(self): + ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] + selectFluo = widgets.QDialogListbox( + 'Select channel', + 'Select channel names to overlay:\n', + ch_names, multiSelection=True, parent=self.host + ) + selectFluo.exec_() + if selectFluo.cancel: + return + + return selectFluo.selectedItemsText + + def setOverlaySingleChannel(self, *args, **kwargs): + if self.overlayToolbar.isSingleChannel(): + self.overlayToolbarAreChannelsChecked = { + channel:toolbutton.isChecked() + for channel, toolbutton in self.allOverlayToolbuttons.items() + } + firstActiveToolbutton = [ + toolbutton for toolbutton in self.allOverlayToolbuttons.values() + if toolbutton.isChecked() + ][0] + firstActiveToolbutton.click() + else: + for ch, checked in self.overlayToolbarAreChannelsChecked.items(): + toolbutton = self.allOverlayToolbuttons[ch] + toolbutton.setChecked(checked) + + self.setOverlayItemsOpacities() + + def updateTransparentOverlayRgba(self, *args, **kwargs): + self.setOverlayImages() + + def setOverlayTransparency(self, transparent: bool): + opacity = float(transparent) + opacity = opacity if opacity < 1.0 else 0.999 + self.rgbaImg1.setOpacity(opacity) + + if transparent: + self.img1.setOpacity(0.001, applyToLinked=False) + self.imgGrad.sigLookupTableChanged.connect( + self.updateTransparentOverlayRgba + ) + self.imgGrad.sigLevelsChanged.connect( + self.updateTransparentOverlayRgba + ) + + for channel, items in self.overlayLayersItems.items(): + imageItem, lutItem, alphaSB = items[:3] + if transparent: + alphaSB.valueChanged.disconnect() + alphaSB.valueChanged.connect( + self.updateTransparentOverlayRgba + ) + lutItem.sigLookupTableChanged.connect( + self.updateTransparentOverlayRgba + ) + lutItem.sigLevelsChanged.connect( + self.updateTransparentOverlayRgba + ) + imageItem.setOpacity(0) + + if not transparent: + self.setOverlayItemsOpacities() + + self.setOverlayImages() + + def overlay_cb(self, checked): + self.overlayToolbar.setVisible(checked) + + self.UserNormAction, _, _ = self.getCheckNormAction() + posData = self.data[self.pos_i] + if checked: + if posData.ol_data is None: + selectedChannels = self.askSelectOverlayChannel() + if selectedChannels is None: + self.overlayButton.toggled.disconnect() + self.overlayButton.setChecked(False) + self.overlayButton.toggled.connect(self.overlay_cb) + return + + success = self.loadOverlayData(selectedChannels) + if not success: + return False + lastChannel = selectedChannels[-1] + self.setCheckedOverlayContextMenusActions(selectedChannels) + imageItem = self.overlayLayersItems[lastChannel][0] + self.setOpacityOverlayLayersItems(None, imageItem=imageItem) + self.setOverlayChannelsToolbuttonsChecked() + + self.setRetainSizePolicyLutItems() + self.normalizeRescale0to1Action.setChecked(True) + + self.updateAllImages() + self.updateImageValueFormatter() + self.enableOverlayWidgets(True) + else: + self.img1.setOpacity(1.0) + self.updateAllImages() + self.updateImageValueFormatter() + self.enableOverlayWidgets(False) + self.clearOverlayImageItems() + + self.setOverlayItemsVisible() + + def getOlImg(self, key, frame_i=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + img = posData.ol_data[key][frame_i] + if posData.SizeZ > 1: + zProjHow = self.zProjOverlay_CB.currentText() + z = self.zSliceOverlay_SB.sliderPosition() + if zProjHow == 'same as above': + zProjHow = self.zProjComboBox.currentText() + z = self.zSliceScrollBar.sliderPosition() + reconnect = False + try: + self.zSliceOverlay_SB.valueChanged.disconnect() + reconnect = True + except TypeError: + pass + self.zSliceOverlay_SB.setSliderPosition(z) + if reconnect: + self.zSliceOverlay_SB.valueChanged.connect( + self.updateOverlayZslice + ) + if zProjHow == 'single z-slice': + self.overlay_z_label.setText( + f'Overlay z-slice {z+1:02}/{posData.SizeZ}' + ) + ol_img = img[z].copy() + elif zProjHow == 'max z-projection': + ol_img = img.max(axis=0) + elif zProjHow == 'mean z-projection': + ol_img = img.mean(axis=0) + elif zProjHow == 'median z-proj.': + ol_img = np.median(img, axis=0) + else: + ol_img = img.copy() + + return ol_img + + def setOverlayImages(self, frame_i=None): + if not self.overlayButton.isChecked(): + return + + posData = self.data[self.pos_i] + if posData.ol_data is None: + return + + rgba_imgs_info = {} + for filename in posData.ol_data: + chName = self.view_model.formatting.channel_name_from_basename( + filename, posData.basename, remove_ext=False + ) + if chName not in self.checkedOverlayChannels: + continue + + items = self.overlayLayersItems[chName] + imageItem, lutItem, alphaSB = items[:3] + + ol_img = self.getOlImg(filename, frame_i=frame_i) + + if self.overlayToolbar.isTransparent(): + toolbutton = items[3] + if not toolbutton.isChecked(): + continue + alpha_val = alphaSB.value()/alphaSB.maximum() + ol_img = skimage.exposure.rescale_intensity( + ol_img, out_range=(0.0, 1.0) + ) + out_range_min, out_range_max = lutItem.getLevels() + rgba_imgs_info[chName] = (ol_img, alpha_val, lutItem) + else: + self.rescaleIntensitiesLut(setImage=False, imageItem=imageItem) + imageItem.setImage(ol_img) + + if not self.overlayToolbar.isTransparent(): + return + + alpha_values = [] + images = [] + luts = [] + for channel, info in rgba_imgs_info.items(): + ol_img, alpha_val, lutItem = info + alpha_values.append(alpha_val) + images.append(ol_img) + luts.append(lutItem.gradient.getLookupTable(256, alpha=255)/255) + + weights = colors.hierarchical_weights(alpha_values) + + if self.baseLayerToolbutton.isChecked(): + image1 = self._getImageupdateAllImages() + image1 = skimage.exposure.rescale_intensity( + image1, out_range=(0.0, 1.0) + ) + images.append(image1) + baseLut = ( + self.imgGrad.gradient.getLookupTable(256, alpha=255)/255 + ) + luts.append(baseLut) + + images_rgba = [] + for img, lut in zip(images, luts): + rgba = colors.grayscale_apply_lut(img, lut) + images_rgba.append(rgba) + + rgba_merge = colors.hierarchical_blend(images_rgba, weights) + self.rgbaImg1.setImage(rgba_merge) + + def getOpacitiesFromAlphaScrollbarValues(self): + active_channel_alpha_values = {} + for items in self.overlayLayersItems.values(): + imgItem, lutItem, alphaSB = items[:3] + _toolbutton = alphaSB.toolbutton + if not _toolbutton.isChecked() or not _toolbutton.isVisible(): + continue + + active_channel_alpha_values[imgItem.channelName] = ( + alphaSB.value()/alphaSB.maximum() + ) + + return self.view_model.overlay_channel_opacity_map( + self.user_ch_name, + active_channel_alpha_values, + ) + + def toggleOverlayColorButton(self, checked=True): + self.mousePressColorButton(None) + + def toggleTextIDsColorButton(self, checked=True): + self.textIDsColorButton.selectColor() + + def updateTextAnnotColor(self, button): + r, g, b = np.array(self.textIDsColorButton.color().getRgb()[:3]) + self.imgGrad.textColorButton.setColor((r, g, b)) + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.textColorButton.setColor((r, g, b)) + self.gui_createTextAnnotColors(r,g,b, custom=True) + self.gui_setTextAnnotColors() + self.updateAllImages() + + def saveTextIDsColors(self, button): + self.df_settings.at['textIDsColor', 'value'] = self.objLabelAnnotRgb + self.df_settings.to_csv(self.settings_csv_path) + + def ticksCmapMoved(self, gradient): + pass + # posData = self.data[self.pos_i] + # self.setLut(posData, shuffle=False) + # self.updateLookuptable() + + def updateLabelsCmap(self, gradient): + self.setLut() + self.updateLookuptable() + self.initLabelsImageItems() + + self.df_settings = self.labelsGrad.saveState(self.df_settings) + self.df_settings.to_csv(self.settings_csv_path) + + self.updateAllImages() + + def extendLabelsLUT(self, lenNewLut): + if lenNewLut > len(self.lut): + self.lut = self.view_model.extend_labels_lut(self.lut, lenNewLut) + self.initLabelsImageItems() + return True + return False + + def initLookupTableLab(self): + self.img2.setLookupTable(self.lut) + self.img2.setLevels([0, len(self.lut)]) + self.initLabelsImageItems() + + def getLabelsImageLut(self): + return self.view_model.generate_labels_image_lut(self.lut) + + def initLabelsImageItems(self): + lut = self.getLabelsImageLut() + self.labelsLayerImg1.setLevels([0, len(lut)]) + self.labelsLayerRightImg.setLevels([0, len(lut)]) + self.labelsLayerImg1.setLookupTable(lut) + self.labelsLayerRightImg.setLookupTable(lut) + alpha = self.imgGrad.labelsAlphaSlider.value() + self.labelsLayerImg1.setOpacity(alpha) + self.labelsLayerRightImg.setOpacity(alpha) + + def updateLookuptable(self, lenNewLut=None, delIDs=None): + posData = self.data[self.pos_i] + if lenNewLut is None: + try: + if delIDs is None: + IDs = posData.IDs + else: + # Remove IDs removed with ROI from LUT + IDs = [ID for ID in posData.IDs if ID not in delIDs] + lenNewLut = max(IDs, default=0) + 1 + except ValueError: + # Empty segmentation mask + lenNewLut = 1 + # Build a new lut to include IDs > than original len of lut + updateLevels = self.extendLabelsLUT(lenNewLut) + lut = self.lut.copy() + + try: + # lut = self.lut[:lenNewLut].copy() + for ID in posData.binnedIDs: + lut[ID] = lut[ID]*0.2 + + for ID in posData.ripIDs: + lut[ID] = lut[ID]*0.2 + except Exception as e: + err_str = traceback.format_exc() + print('='*30) + self.logger.info(err_str) + print('='*30) + + if updateLevels: + self.img2.setLevels([0, len(lut)]) + + lut = self.view_model.apply_lut_dimming_for_kept_objects( + lut, + getattr(self, 'keptObjectsIDs', []), + self.keepIDsButton.isChecked(), + ) + + self.img2.setLookupTable(lut) + + def setLut(self, shuffle=True): + self.lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) + if shuffle: + np.random.shuffle(self.lut) + + # Insert background color + if 'labels_bkgrColor' in self.df_settings.index: + rgbString = self.df_settings.at['labels_bkgrColor', 'value'] + try: + r, g, b = rgbString + except Exception as e: + r, g, b = colors.rgb_str_to_values(rgbString) + else: + r, g, b = 25, 25, 25 + self.df_settings.at['labels_bkgrColor', 'value'] = (r, g, b) + + self.lut = np.insert(self.lut, 0, [r, g, b], axis=0) + + def shuffle_cmap(self): + np.random.shuffle(self.lut[1:]) + self.initLabelsImageItems() + self.updateAllImages() + + def setPermanentGreedyCmapPreferences(self): + if self.isSnapshot: + option_name = 'permanent_greedy_lut_snapshots' + else: + option_name = 'permanent_greedy_lut_timelapse' + + if option_name not in self.df_settings.index: + return + + checked = self.df_settings.at[option_name, 'value'] == 'yes' + self.labelsGrad.permanentGreedyCmapAction.setChecked(checked) + + def permanentGreedyCmapToggled(self, checked): + if checked: + settings_value = 'yes' + else: + self.setLut() + self.updateLookuptable() + self.initLabelsImageItems() + settings_value = 'no' + + self.updateAllImages() + + if self.isSnapshot: + option_name = 'permanent_greedy_lut_snapshots' + else: + option_name = 'permanent_greedy_lut_timelapse' + + self.df_settings.at[option_name, 'value'] = settings_value + self.df_settings.to_csv(self.settings_csv_path) + + def greedyShuffleCmap(self, updateImages=True): + lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) + greedy_lut = colors.get_greedy_lut(self.currentLab2D, lut) + self.lut = greedy_lut + self.initLabelsImageItems() + if updateImages: + self.updateAllImages() + + def updateBkgrColor(self, button): + color = button.color().getRgb()[:3] + self.lut[0] = color + self.updateLookuptable() + + def updateTextLabelsColor(self, button): + self.ax2_textColor = button.color().getRgb()[:3] + posData = self.data[self.pos_i] + if posData.rp is None: + return + + for obj in posData.rp: + self.getObjOptsSegmLabels(obj) + + def saveTextLabelsColor(self, button): + color = button.color().getRgb()[:3] + self.df_settings.at['labels_text_color', 'value'] = color + self.df_settings.to_csv(self.settings_csv_path) + + def saveBkgrColor(self, button): + color = button.color().getRgb()[:3] + self.df_settings.at['labels_bkgrColor', 'value'] = color + self.df_settings.to_csv(self.settings_csv_path) + self.updateAllImages() + + def changeOverlayColor(self, button): + rgb = button.color().getRgb()[:3] + lutItem = self.overlayLayersItems[button.channel][1] + self.initColormapOverlayLayerItem(rgb, lutItem) + lutItem.overlayColorButton.setColor(rgb) + + def saveOverlayColor(self, button): + rgb = button.color().getRgb()[:3] + rgb_text = '_'.join([str(val) for val in rgb]) + self.df_settings.at[f'{button.channel}_rgb', 'value'] = rgb_text + self.df_settings.to_csv(self.settings_csv_path) + + def setValueLabelsAlphaSlider(self, value): + self.imgGrad.labelsAlphaSlider.setValue(value) + self.updateLabelsAlpha(value) + + def setOverlaySegmMasks(self, force=False, forceIfNotActive=False): + if not hasattr(self, 'currentLab2D'): + return + + how = self.drawIDsContComboBox.currentText() + isOverlaySegmLeftActive = how.find('overlay segm. masks') != -1 + + how_ax2 = self.getAnnotateHowRightImage() + isOverlaySegmRightActive = ( + how_ax2.find('overlay segm. masks') != -1 + and self.labelsGrad.showRightImgAction.isChecked() + ) + + isOverlaySegmActive = ( + isOverlaySegmLeftActive or isOverlaySegmRightActive + or force + ) + if not isOverlaySegmActive and not forceIfNotActive: + return + + alpha = self.imgGrad.labelsAlphaSlider.value() + if alpha == 0: + return + + posData = self.data[self.pos_i] + maxID = max(posData.IDs, default=0) + + if maxID >= len(self.lut): + self.extendLabelsLUT(maxID+10) + + currentLab2D = self.currentLab2D + if isOverlaySegmLeftActive: + self.labelsLayerImg1.setImage(currentLab2D, autoLevels=False) + + if isOverlaySegmRightActive: + self.labelsLayerRightImg.setImage(currentLab2D, autoLevels=False) + + def setOverlayLabelsItemsVisible(self, checked): + for _segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): + items = self.overlayLabelsItems[_segmEndname] + gradItem = items[-1] + gradItem.hide() + + if checked: + segmEndname = self.sender().text() + gradItem = self.overlayLabelsItems[segmEndname][-1] + gradItem.show() + + def setRetainSizePolicyLutItems(self): + if not self.retainSizeLutItems: + return + for channel, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB = items[:3] + myutils.setRetainSizePolicy(lutItem, retain=True) + QTimer.singleShot(300, self.autoRange) + + def setOverlayChannelsToolbuttonsChecked(self): + for channel, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB, toolbutton = items[:4] + toolbutton.setChecked( + self.view_model.overlay_toolbutton_checked( + channel, + checked_channels=self.checkedOverlayChannels, + is_single_channel=self.overlayToolbar.isSingleChannel(), + ) + ) + + def setOverlayItemsVisible(self): + visibility_plan = self.view_model.overlay_visibility_plan( + all_channels=self.overlayLayersItems.keys(), + checked_channels=self.checkedOverlayChannels, + overlay_enabled=self.overlayButton.isChecked(), + ) + for channel, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB, toolbutton = items[:4] + if visibility_plan.channel_visible[channel]: + lutItem.show() + alphaSB.show() + alphaSB.label.show() + toolbutton.setVisible(True) + else: + lutItem.hide() + alphaSB.hide() + alphaSB.label.hide() + toolbutton.setVisible(False) + + def overlayChannelToolbuttonClicked(self, checked=False, toolbutton=None): + if toolbutton is None: + toolbutton = self.sender() + + channelName = toolbutton.channelName() + checked_channels = { + channel + for channel, button in self.allOverlayToolbuttons.items() + if button.isChecked() + } + planned_checked_channels = ( + self.view_model.overlay_toolbutton_click_checked_channels( + clicked_channel=channelName, + all_channels=self.allOverlayToolbuttons.keys(), + checked_channels=checked_channels, + toolbar_single_channel=self.overlayToolbar.isSingleChannel(), + ) + ) + for channel, otherToolbutton in self.allOverlayToolbuttons.items(): + otherToolbutton.setChecked(channel in planned_checked_channels) + + if self.overlayToolbar.isTransparent(): + self.setOverlayImages() + return + + self.setOverlayItemsOpacities() + + def setOverlayItemsOpacities(self): + checked_channels = { + channel + for channel, button in self.allOverlayToolbuttons.items() + if button.isChecked() + } + active_channel_alpha_values = {} + for items in self.overlayLayersItems.values(): + imageItem, lutItem, alphaSB = items[:3] + toolbutton = alphaSB.toolbutton + if not toolbutton.isChecked() or not toolbutton.isVisible(): + continue + active_channel_alpha_values[imageItem.channelName] = ( + alphaSB.value()/alphaSB.maximum() + ) + opacity_plan = self.view_model.overlay_item_opacity_plan( + all_channels=self.allOverlayToolbuttons.keys(), + base_channel=self.user_ch_name, + checked_channels=checked_channels, + toolbar_single_channel=self.overlayToolbar.isSingleChannel(), + active_channel_alpha_values=active_channel_alpha_values, + ) + + # Set opacity of every layer accordingly + for channel, otherToolbutton in self.allOverlayToolbuttons.items(): + if channel == self.user_ch_name: + otherImageItem = self.img1 + alphaScrollbar = None + # alpha_value = channel_opacity_mapper[channel] + else: + otherItems = self.overlayLayersItems[channel] + otherImageItem = otherItems[0] + alphaScrollbar = otherItems[2] + # alpha_value = alphaScrollbar.value()/alphaScrollbar.maximum() + + op_val = opacity_plan.opacities[channel] + otherImageItem.setOpacity(op_val, applyToLinked=False) + + if alphaScrollbar is None: + continue + + alphaScrollbar.setDisabled( + opacity_plan.alpha_scrollbar_disabled[channel] + ) + + def initColormapOverlayLayerItem(self, foregrColor, lutItem): + if self.invertBwAction.isChecked(): + bkgrColor = (255,255,255,255) + else: + bkgrColor = (0,0,0,255) + gradient = colors.get_pg_gradient((bkgrColor, foregrColor)) + lutItem.setGradient(gradient) + + def setOpacityOverlayLayersItems(self, value, imageItem=None, scrollbar=None): + if scrollbar is None: + scrollbar = imageItem.alphaScrollBar + + channel = scrollbar.channelName + toolbutton = self.allOverlayToolbuttons[channel] + if not toolbutton.isChecked() or not toolbutton.isVisible(): + return + + if value is None: + value = scrollbar.value() + + if imageItem is None: + imageItem = scrollbar.imageItem + alpha = value/scrollbar.maximum() + elif value > 1: + alpha = value/scrollbar.maximum() + else: + alpha = value + + alpha_values = [] + activeOverlayImageItems = [] + for items in self.overlayLayersItems.values(): + imgItem, lutItem, alphaSB = items[:3] + _toolbutton = alphaSB.toolbutton + if alphaSB.channelName == channel: + alpha_values.append(alpha) + elif not _toolbutton.isChecked() or not _toolbutton.isVisible(): + continue + else: + alpha_values.append(alphaSB.value()/alphaSB.maximum()) + + activeOverlayImageItems.append(imgItem) + + opacities = colors.hierarchical_weights(alpha_values)[::-1] + + for i, imgItem in enumerate(activeOverlayImageItems): + imgItem.setOpacity(opacities[i+1]) + + self.img1.setOpacity(opacities[0], applyToLinked=False) + + def gui_getLostObjScatterItem(self): + self.objLostAnnotRgb = (245, 184, 0) + brush = pg.mkBrush((*self.objLostAnnotRgb, 150)) + pen = pg.mkPen(self.objLostAnnotRgb, width=1) + lostObjScatterItem = pg.ScatterPlotItem( + size=self.contLineWeight+1, pen=pen, + brush=brush, pxMode=False, symbol='s' + ) + return lostObjScatterItem + + def gui_getTrackedLostObjScatterItem(self): + self.objLostTrackedAnnotRgb = (0, 255, 0) + brush = pg.mkBrush((*self.objLostTrackedAnnotRgb, 150)) + pen = pg.mkPen(self.objLostTrackedAnnotRgb, width=1) + lostObjScatterItem = pg.ScatterPlotItem( + size=self.contLineWeight+1, pen=pen, + brush=brush, pxMode=False, symbol='s' + ) + return lostObjScatterItem + + def _gui_createGraphicsItems(self): + for _posData in self.data: + _posData.allData_li = [None]*_posData.SizeT + + posData = self.data[self.pos_i] + + allIDs, posData = self.view_model.label_edits.count_objects( + posData, self.logger.info + ) + + self.highLowResAction.setChecked(True) + numItems = len(allIDs) + if numItems > 1500: + cancel, switchToLowRes = _warnings.warnTooManyItems( + self.host, numItems, self.progressWin + ) + if cancel: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.loadingDataAborted() + return + if switchToLowRes: + self.highLowResAction.setChecked(False) + else: + # Many items requires pxMode active to be fast enough + self.pxModeAction.setChecked(True) + + self.logger.info(f'Creating graphical items...') + + self.ax1_contoursImageItem = pg.ImageItem() + + self.ax1_lostObjImageItem = pg.ImageItem() + self.ax2_lostObjImageItem = pg.ImageItem() + + self.ax1_lostTrackedObjImageItem = pg.ImageItem() + self.ax2_lostTrackedObjImageItem = pg.ImageItem() + + self.ax1_oldMothBudLinesItem = pg.ScatterPlotItem( + symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, + size=self.mothBudLineWeight, pen=None + ) + self.ax1_newMothBudLinesItem = pg.ScatterPlotItem( + symbol='s', pxMode=False, brush=self.newMothBudLineBrush, + size=self.mothBudLineWeight, pen=None + ) + self.ax1_lostObjScatterItem = self.gui_getLostObjScatterItem() + self.yellowContourScatterItem = self.gui_getLostObjScatterItem() + + self.ax1_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() + self.greenContourScatterItem = self.gui_getTrackedLostObjScatterItem() + + brush = pg.mkBrush((0,255,0,200)) + pen = pg.mkPen('g', width=1) + self.ccaFailedScatterItem = pg.ScatterPlotItem( + size=self.contLineWeight+1, pen=pen, + brush=brush, pxMode=False, symbol='s' + ) + + self.ax2_contoursImageItem = pg.ImageItem() + self.ax2_oldMothBudLinesItem = pg.ScatterPlotItem( + symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, + size=self.mothBudLineWeight, pen=None + ) + self.ax2_newMothBudLinesItem = pg.ScatterPlotItem( + symbol='s', pxMode=False, brush=self.newMothBudLineBrush, + size=self.mothBudLineWeight, pen=None + ) + self.ax2_lostObjScatterItem = self.gui_getLostObjScatterItem() + self.ax2_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() + + self.gui_createTextAnnotItems(allIDs) # here + self.gui_setTextAnnotColors()# here + + self.setDisabledAnnotOptions(False) + + self.progressWin.mainPbar.setMaximum(0) + self.gui_addOverlayLayerItems() + self.gui_addTopLayerItems() + + self.gui_addCreatedAxesItems() + self.gui_add_ax_cursors() + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.loadingDataCompleted() + + def gui_createTextAnnotItems(self, allIDs): + self.textAnnot = {} + isHighResolution = self.highLowResAction.isChecked() + pxMode = self.pxModeAction.isChecked() + for ax in range(2): + ax_textAnnot = annotate.TextAnnotations() + ax_textAnnot.initFonts(self.fontSize) + ax_textAnnot.createItems( + isHighResolution, allIDs, pxMode=pxMode + ) + self.textAnnot[ax] = ax_textAnnot + + def gui_addOverlayLayerItems(self): + for items in self.overlayLabelsItems.values(): + imageItem, contoursItem, gradItem = items + self.ax1.addItem(imageItem) + self.ax1.addItem(contoursItem) + + def gui_addTopLayerItems(self): + for item in self.topLayerItems: + self.ax1.addItem(item) + + for item in self.topLayerItemsRight: + self.ax2.addItem(item) + + # self.ax2.addItem(self.currentFrameLabelItem) + + def updateContoursImage(self, ax, delROIsIDs=None, compute=True): + imageItem = self.getContoursImageItem(ax) + if imageItem is None: + return + + if not hasattr(self, 'contoursImage'): + self.initContoursImage() + else: + self.contoursImage[:] = 0 + + contours = [] + for obj in skimage.measure.regionprops(self.currentLab2D): + obj_contours = self.getObjContours( + obj, + all_external=True, + force_calc=compute, + include_internal=self.showAllContoursToggle.isChecked() + ) + contours.extend(obj_contours) + + thickness = self.contLineWeight + color = self.contLineColor + self.setContoursImage(imageItem, contours, thickness, color) + + def setContoursImage(self, imageItem, contours, thickness, color): + cv2.drawContours(self.contoursImage, contours, -1, color, thickness) + imageItem.setImage(self.contoursImage) + + def getObjFromID(self, ID): + posData = self.data[self.pos_i] + try: + idx = posData.IDs_idxs[ID] + except KeyError as e: + # Object already cleared + return + + obj = posData.rp[idx] + return obj + + def setLostObjectContour(self, obj): + allContours = self.getObjContours(obj, all_external=True) + for objContours in allContours: + xx = objContours[:,0] + 0.5 + yy = objContours[:,1] + 0.5 + data = [obj.label]*len(xx) + self.ax1_lostObjScatterItem.addPoints(xx, yy, data=data) + self.ax2_lostObjScatterItem.addPoints(xx, yy) + + def setTrackedLostObjectContour(self, obj): + if self.isExportingVideo: + return + + allContours = self.getObjContours(obj, all_external=True) + for objContours in allContours: + xx = objContours[:,0] + 0.5 + yy = objContours[:,1] + 0.5 + data = [obj.label]*len(xx) + self.ax1_lostTrackedScatterItem.addPoints(xx, yy, data=data) + self.ax2_lostTrackedScatterItem.addPoints(xx, yy) + + def updateLostContoursImage(self, ax, draw=True, delROIsIDs=None): + if draw: + imageItem = self.getLostObjImageItem(ax) + if imageItem is None: + return + + if not hasattr(self, 'lostObjContoursImage'): + self.initLostObjContoursImage() + else: + self.lostObjContoursImage[:] = 0 + + if delROIsIDs is None: + delROIsIDs = set() + + posData = self.data[self.pos_i] + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] + if posData.whitelist is not None and posData.whitelist.whitelistIDs is not None: + whitelist = posData.whitelist.whitelistIDs[posData.frame_i-1] + else: + whitelist = None + + contours = [] + for lostID in posData.lost_IDs: + if lostID in delROIsIDs or (whitelist is not None and lostID not in whitelist): + continue + + obj = prev_rp[prev_IDs_idxs[lostID]] + if not self.isObjVisible(obj.bbox): + continue + + obj_contours = self.getObjContours(obj, all_external=True) + + if ax == 0: + self.addLostObjsToLostObjImage(obj, lostID) + + contours.extend(obj_contours) + + if not draw: + return + + self.drawLostObjContoursImage(imageItem, contours) + + def drawLostObjContoursImage( + self, imageItem, contours, + thickness=1, + color=(255, 165, 0, 255) # orange + ): + img = self.lostObjContoursImage + cv2.drawContours(img, contours, -1, color, thickness) + imageItem.setImage(img) + + def updateLostTrackedContoursImage( + self, ax, delROIsIDs=None, tracked_lost_IDs=None + ): + imageItem = self.getLostTrackedObjImageItem(ax) + if imageItem is None: + return + + if not hasattr(self, 'lostTrackedObjContoursImage'): + self.initLostTrackedObjContoursImage() + else: + self.lostTrackedObjContoursImage[:] = 0 + + if delROIsIDs is None: + delROIsIDs = set() + + posData = self.data[self.pos_i] + if tracked_lost_IDs is None: + tracked_lost_IDs = self.getTrackedLostIDs() + + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] + contours = [] + for tracked_lost_ID in tracked_lost_IDs: + if tracked_lost_ID in delROIsIDs: + continue + + obj = prev_rp[prev_IDs_idxs[tracked_lost_ID]] + if not self.isObjVisible(obj.bbox): + continue + + obj_contours = self.getObjContours(obj, all_external=True) + contours.extend(obj_contours) + + self.drawLostTrackedObjContoursImage(imageItem, contours) + + def drawLostTrackedObjContoursImage(self, imageItem, contours): + thickness = 1 + color = (0, 255, 0, 255) # green + img = self.lostTrackedObjContoursImage + cv2.drawContours(img, contours, -1, color, thickness) + imageItem.setImage(img) + + def getNearestLostObjID(self, y, x): + if not self.annotLostObjsToggle.isChecked(): + return + + posData = self.data[self.pos_i] + if not posData.lost_IDs: + return + + prev_lab = posData.allData_li[posData.frame_i-1]['labels'] + if prev_lab is None: + return + + # if not hasattr(self, 'lostObjContoursImage'): + # self.store_data() + # posData.frame_i -= 1 + # self.get_data() + # self.store_data() + # posData.frame_i += 1 + # self.get_data() + # self.updateLostNewCurrentIDs() + # self.updateLostContoursImage(ax=0) + # self.updateLostContoursImage(ax=1) + # self.updateLostNewCurrentIDs() + + yy, xx, _ = np.nonzero(self.lostObjContoursImage) + lostObjsContourMask = np.zeros(self.currentLab2D.shape, dtype=bool) + lostObjsContourMask[yy.astype(int), xx.astype(int)] = True + + # Add accepted lost IDs + try: + yy, xx, _ = np.nonzero(self.lostTrackedObjContoursImage) + lostObjsContourMask[yy.astype(int), xx.astype(int)] = True + except Exception as err: + pass + + _, y_nearest, x_nearest = self.view_model.label_edits.nearest_nonzero_2d( + lostObjsContourMask, y, x, return_coords=True + ) + nearest_ID = self.get_2Dlab(prev_lab)[y_nearest, x_nearest] + + if nearest_ID == 0: + return + + return nearest_ID + + def addObjContourToContoursImage( + self, ID=0, obj=None, ax=0, thickness=None, color=None, + force=False + ): + imageItem = self.getContoursImageItem(ax, force=force) + if imageItem is None: + return + + if obj is None: + obj = self.getObjFromID(ID) + if obj is None: + return + + contours = self.getObjContours(obj, all_external=True) + if thickness is None: + thickness = self.contLineWeight + if color is None: + color = self.contLineColor + + self.setContoursImage(imageItem, contours, thickness, color) + + def clearObjContour( + self, ID=0, obj=None, ax=0, debug=False, updateImage=True + ): + imageItem = self.getContoursImageItem(ax) + if imageItem is None: + return + + if ID > 0: + self.contoursImage[self.currentLab2D==ID] = [0,0,0,0] + else: + obj_slice = self.getObjSlice(obj.slice) + obj_image = self.getObjImage(obj.image, obj.bbox) + self.contoursImage[obj_slice][obj_image] = [0,0,0,0] + + if not updateImage: + return + + imageItem.setImage(self.contoursImage) + + def setAllContoursImages(self, delROIsIDs=None, compute=True): + if compute: + self.computeAllContours() + self.updateContoursImage(ax=0, delROIsIDs=delROIsIDs, compute=compute) + self.updateContoursImage(ax=1, delROIsIDs=delROIsIDs, compute=compute) + + def setAllLostObjContoursImage(self, delROIsIDs=None): + self.updateLostContoursImage(ax=0, delROIsIDs=None) + self.updateLostContoursImage(ax=1, delROIsIDs=None) + + def setAllLostTrackedObjContoursImage(self, delROIsIDs=None): + self.updateLostTrackedContoursImage(ax=0, delROIsIDs=None) + self.updateLostTrackedContoursImage(ax=1, delROIsIDs=None) + + def getObjContours( + self, obj, all_external=False, local=False, force_calc=True, + include_internal=False + ): + posData = self.data[self.pos_i] + dataDict = posData.allData_li[posData.frame_i] + allContours = dataDict.get('contours') + if allContours is not None and not force_calc: + z = self.z_lab() + key = (obj.label, str(z), all_external, local) + contours = allContours.get(key) + if contours is not None: + return contours + + obj_image = self.getObjImage(obj.image, obj.bbox).astype(np.uint8) + obj_bbox = self.getObjBbox(obj.bbox) + try: + contours = self.view_model.geometry.object_contours( + obj_image=obj_image, + obj_bbox=obj_bbox, + local=local, + all_external=all_external + ) + except Exception as e: + if all_external: + contours = [] + else: + contours = None + self.logger.warning( + f'Object ID {obj.label} contours drawing failed. ' + f'(bounding box = {obj.bbox})' + ) + return contours + + def clearComputedContours(self): + for posData in self.data: + for frame_i, dataDict in enumerate(posData.allData_li): + dataDict['contours'] = {} + + def _computeAllContours2D( + self, dataDict, obj, z, obj_bbox, include_internal=False + ): + obj_image = self.getObjImage(obj.image, obj.bbox, z_slice=z) + if obj_image is None: + return + + all_external = False + local = False + contours = self.view_model.geometry.object_contours( + obj_image=obj_image, + obj_bbox=obj_bbox, + local=local, + all_external=all_external + ) + key = (obj.label, str(z), all_external, local) + dataDict['contours'][key] = contours + + all_external = True + local = False + contours = self.view_model.geometry.object_contours( + obj_image=obj_image, + obj_bbox=obj_bbox, + local=local, + all_external=all_external, + all=include_internal + ) + key = (obj.label, str(z), all_external, local) + dataDict['contours'][key] = contours + + return dataDict + + def computeAllContours(self): + self.logger.info('Computing all contours...') + posData = self.data[self.pos_i] + zz = [None] + if self.isSegm3D: + zz.extend(range(posData.SizeZ)) + + include_internal = self.showAllContoursToggle.isChecked() + for frame_i, dataDict in enumerate(posData.allData_li): + lab = dataDict['labels'] + if lab is None: + break + + rp = dataDict['regionprops'] + if rp is None: + rp = skimage.measure.regionprops(lab) + + dataDict['contours'] = {} + for obj in rp: + obj_bbox = self.getObjBbox(obj.bbox) + for z in zz: + if not self.isObjVisible(obj.bbox, z_slice=z): + continue + + try: + self._computeAllContours2D( + dataDict, obj, z, obj_bbox, + include_internal=include_internal + ) + except Exception as err: + # Contours computation fails on weird objects + pass + + def computeAllObjToObjCostPairs(self): + desc = ( + 'Computing all object-to-object cost matrices...' + ) + self.logger.info(desc) + posData = self.data[self.pos_i] + + self.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self.host, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.show(self.app) + + self.computeAllObjCostPairsThread = QThread() + self.computeAllObjCostPairsWorker = workers.SimpleWorker( + posData, self._computeAllObjToObjCostPairs + ) + + self.computeAllObjCostPairsWorker.moveToThread( + self.computeAllObjCostPairsThread + ) + + self.computeAllObjCostPairsWorker.signals.finished.connect( + self.computeAllObjCostPairsThread.quit + ) + self.computeAllObjCostPairsWorker.signals.finished.connect( + self.computeAllObjCostPairsWorker.deleteLater + ) + self.computeAllObjCostPairsThread.finished.connect( + self.computeAllObjCostPairsThread.deleteLater + ) + + self.computeAllObjCostPairsWorker.signals.critical.connect( + self.computeAllObjCostPairsWorkerCritical + ) + self.computeAllObjCostPairsWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.computeAllObjCostPairsWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.computeAllObjCostPairsWorker.signals.progress.connect( + self.workerProgress + ) + self.computeAllObjCostPairsWorker.signals.finished.connect( + self.computeAllObjCostPairsWorkerFinished + ) + + self.computeAllObjCostPairsThread.started.connect( + self.computeAllObjCostPairsWorker.run + ) + self.computeAllObjCostPairsThread.start() + + self.computeAllObjCostPairsWorkerLoop = QEventLoop() + self.computeAllObjCostPairsWorkerLoop.exec_() + + def _computeAllObjToObjCostPairs(self, posData): + self.computeAllObjCostPairsWorker.signals.initProgressBar.emit( + len(posData.allData_li) + ) + for frame_i, dataDict in enumerate(posData.allData_li): + if frame_i == 0: + continue + + rp = dataDict['regionprops'] + if rp is None: + break + + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + dist_matrix = self.view_model.geometry.object_to_object_contour_distance_matrix( + dataDict['contours'], rp, + previous_regionprops=prev_rp, + restrict_search=True, + ) + dataDict['obj_to_obj_dist_cost_matrix_df'] = dist_matrix + self.computeAllObjCostPairsWorker.signals.progressBar.emit(1) + self.computeAllObjCostPairsWorker.signals.initProgressBar.emit(0) + + def computeAllObjCostPairsWorkerCritical(self, error): + self.computeAllObjCostPairsWorkerLoop.exit() + self.workerCritical(error) + + def computeAllObjCostPairsWorkerFinished(self, output): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.computeAllObjCostPairsWorkerLoop.exit() + + def gui_createMothBudLinePens(self): + if 'mothBudLineSize' in self.df_settings.index: + val = self.df_settings.at['mothBudLineSize', 'value'] + self.mothBudLineWeight = int(val) + else: + self.mothBudLineWeight = 2 + + self.newMothBudlineColor = (255, 0, 0) + if 'mothBudLineColor' in self.df_settings.index: + val = self.df_settings.at['mothBudLineColor', 'value'] + rgba = colors.rgba_str_to_values(val) + self.mothBudLineColor = rgba[0:3] + else: + self.mothBudLineColor = (255,165,0) + + try: + self.imgGrad.mothBudLineColorButton.sigColorChanging.disconnect() + self.imgGrad.mothBudLineColorButton.sigColorChanged.disconnect() + except Exception as e: + pass + try: + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): + act.toggled.disconnect() + except Exception as e: + pass + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): + if act.lineWeight == self.mothBudLineWeight: + act.setChecked(True) + else: + act.setChecked(False) + self.imgGrad.mothBudLineColorButton.setColor(self.mothBudLineColor[:3]) + + self.imgGrad.mothBudLineColorButton.sigColorChanging.connect( + self.updateMothBudLineColour + ) + self.imgGrad.mothBudLineColorButton.sigColorChanged.connect( + self.saveMothBudLineColour + ) + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): + act.toggled.connect(self.mothBudLineWeightToggled) + + # MOther-bud lines brushes + self.NewBudMoth_Pen = pg.mkPen( + color=self.newMothBudlineColor, width=self.mothBudLineWeight+1, + style=Qt.DashLine + ) + self.OldBudMoth_Pen = pg.mkPen( + color=self.mothBudLineColor, width=self.mothBudLineWeight, + style=Qt.DashLine + ) + + self.redDashLinePen = pg.mkPen( + color='r', width=2, style=Qt.DashLine + ) + + self.oldMothBudLineBrush = pg.mkBrush(self.mothBudLineColor) + self.newMothBudLineBrush = pg.mkBrush(self.newMothBudlineColor) + + def imgGradLUTfinished_cb(self): + posData = self.data[self.pos_i] + ticks = self.imgGrad.gradient.listTicks() + + self.img1ChannelGradients[self.user_ch_name] = { + 'ticks': [(x, t.color.getRgb()) for t, x in ticks], + 'mode': 'rgb' + } + + self.df_settings = self.imgGrad.saveState(self.df_settings) + self.df_settings.to_csv(self.settings_csv_path) + + def restoreDefaultSettings(self): + df = self.df_settings + df.at['contLineWeight', 'value'] = 1 + df.at['mothBudLineSize', 'value'] = 1 + df.at['mothBudLineColor', 'value'] = (255, 165, 0, 255) + df.at['contLineColor', 'value'] = (205, 0, 0, 220) + + self._updateContColour((205, 0, 0, 220)) + self._updateMothBudLineColour((255, 165, 0, 255)) + self._updateMothBudLineSize(1) + self._updateContLineThickness() + + df.at['overlaySegmMasksAlpha', 'value'] = 0.3 + df.at['img_cmap', 'value'] = 'grey' + self.imgCmap = self.imgGrad.cmaps['grey'] + self.imgCmapName = 'grey' + self.labelsGrad.item.loadPreset('viridis') + df.at['labels_bkgrColor', 'value'] = (25, 25, 25) + + if df.at['is_bw_inverted', 'value'] == 'Yes': + self.invertBw(update=False) + + df = df[~df.index.str.contains('lab_cmap')] + df.to_csv(self.settings_csv_path) + self.imgGrad.restoreState(df) + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.restoreState(df) + + self.labelsGrad.saveState(df) + self.labelsGrad.restoreState(df, loadCmap=False) + + self.df_settings.to_csv(self.settings_csv_path) + self.updateAllImages() + + def updateMothBudLineColour(self, colorButton): + color = colorButton.color().getRgb() + self.df_settings.at['mothBudLineColor', 'value'] = str(color) + self._updateMothBudLineColour(color) + self.updateAllImages() + + def _updateMothBudLineColour(self, color): + self.gui_createMothBudLinePens() + self.ax1_newMothBudLinesItem.setBrush(self.newMothBudLineBrush) + self.ax1_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush) + self.ax2_newMothBudLinesItem.setBrush(self.newMothBudLineBrush) + self.ax2_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush) + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.mothBudLineColorButton.setColor(color) + + def saveMothBudLineColour(self, colorButton): + self.df_settings.to_csv(self.settings_csv_path) + + def mothBudLineWeightToggled(self, checked=True): + if not checked: + return + self.imgGrad.uncheckContLineWeightActions() + w = self.sender().lineWeight + self.df_settings.at['mothBudLineSize', 'value'] = w + self.df_settings.to_csv(self.settings_csv_path) + self._updateMothBudLineSize(w) + self.updateAllImages() + + def _updateMothBudLineSize(self, size): + self.gui_createMothBudLinePens() + + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): + if act == self.sender(): + act.setChecked(True) + act.toggled.connect(self.mothBudLineWeightToggled) + + self.ax1_oldMothBudLinesItem.setSize(size) + self.ax1_newMothBudLinesItem.setSize(size) + self.ax2_oldMothBudLinesItem.setSize(size) + self.ax2_newMothBudLinesItem.setSize(size) + + def gui_createContourPens(self): + if 'contLineWeight' in self.df_settings.index: + val = self.df_settings.at['contLineWeight', 'value'] + self.contLineWeight = int(val) + else: + self.contLineWeight = 1 + if 'contLineColor' in self.df_settings.index: + val = self.df_settings.at['contLineColor', 'value'] + rgba = colors.rgba_str_to_values(val) + self.contLineColor = rgba + self.newIDlineColor = [min(255, v+50) for v in self.contLineColor] + else: + self.contLineColor = (255, 0, 0, 200) + self.newIDlineColor = (255, 0, 0, 255) + + try: + self.imgGrad.contoursColorButton.sigColorChanging.disconnect() + self.imgGrad.contoursColorButton.sigColorChanged.disconnect() + except Exception as e: + pass + try: + for act in self.imgGrad.contLineWightActionGroup.actions(): + act.toggled.disconnect() + except Exception as e: + pass + for act in self.imgGrad.contLineWightActionGroup.actions(): + if act.lineWeight == self.contLineWeight: + act.setChecked(True) + self.imgGrad.contoursColorButton.setColor(self.contLineColor[:3]) + + self.imgGrad.contoursColorButton.sigColorChanging.connect( + self.updateContColour + ) + self.imgGrad.contoursColorButton.sigColorChanged.connect( + self.saveContColour + ) + for act in self.imgGrad.contLineWightActionGroup.actions(): + act.toggled.connect(self.contLineWeightToggled) + + # Contours pens + self.oldIDs_cpen = pg.mkPen( + color=self.contLineColor, width=self.contLineWeight + ) + self.newIDs_cpen = pg.mkPen( + color=self.newIDlineColor, width=self.contLineWeight+1 + ) + self.tempNewIDs_cpen = pg.mkPen( + color='g', width=self.contLineWeight+1 + ) + + def updateContColour(self, colorButton): + color = colorButton.color().getRgb() + self.df_settings.at['contLineColor', 'value'] = str(color) + self._updateContColour(color) + self.updateAllImages() + + def _updateContColour(self, color): + self.gui_createContourPens() + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.contoursColorButton.setColor(color) + + def saveContColour(self, colorButton): + self.df_settings.to_csv(self.settings_csv_path) + + def contLineWeightToggled(self, checked=True): + if not checked: + return + self.imgGrad.uncheckContLineWeightActions() + w = self.sender().lineWeight + self.df_settings.at['contLineWeight', 'value'] = w + self.df_settings.to_csv(self.settings_csv_path) + self._updateContLineThickness() + self.updateAllImages() + + def _updateContLineThickness(self): + self.gui_createContourPens() + for act in self.imgGrad.contLineWightActionGroup.actions(): + if act == self.sender(): + act.setChecked(True) + act.toggled.connect(self.contLineWeightToggled) + + def gui_createGraphicsItems(self): + # Create enough PlotDataItems and LabelItems to draw contours and IDs. + self.progressWin = apps.QDialogWorkerProgress( + title='Creating axes items', parent=self.host, + pbarDesc='Creating axes items (see progress in the terminal)...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + QTimer.singleShot(50, self._gui_createGraphicsItems) + + def gui_connectGraphicsEvents(self): + self.img1.hoverEvent = self.gui_hoverEventImg1 + self.img2.hoverEvent = self.gui_hoverEventImg2 + self.img1.mousePressEvent = self.gui_mousePressEventImg1 + self.img1.mouseMoveEvent = self.gui_mouseDragEventImg1 + self.img1.mouseReleaseEvent = self.gui_mouseReleaseEventImg1 + self.img2.mousePressEvent = self.gui_mousePressEventImg2 + self.img2.mouseMoveEvent = self.gui_mouseDragEventImg2 + self.img2.mouseReleaseEvent = self.gui_mouseReleaseEventImg2 + self.rightImageItem.mousePressEvent = self.canvas_right_image_view.mouse_press + self.rightImageItem.mouseMoveEvent = self.canvas_right_image_view.mouse_drag + self.rightImageItem.mouseReleaseEvent = self.canvas_right_image_view.mouse_release + self.rightImageItem.hoverEvent = self.gui_hoverEventRightImage + # self.imgGrad.gradient.showMenu = self.gui_gradientContextMenuEvent + self.imgGradRight.gradient.showMenu = ( + self.canvas_context_menu_view.show_right_image_context_menu + ) + # self.imgGrad.vb.contextMenuEvent = self.gui_gradientContextMenuEvent + self.ax1.sigRangeChanged.connect( + self.display_decorations_view.view_range_changed + ) + + def gui_initImg1BottomWidgets(self): + self.zSliceScrollBar.hide() + self.zProjComboBox.hide() + self.zProjLockViewButton.hide() + self.zSliceOverlay_SB.hide() + self.zProjOverlay_CB.hide() + self.overlay_z_label.hide() + self.zSliceCheckbox.hide() + self.zSliceSpinbox.hide() + self.SizeZlabel.hide() diff --git a/cellacdc/views/image_controls_view.py b/cellacdc/views/image_controls_view.py new file mode 100644 index 000000000..1323d5826 --- /dev/null +++ b/cellacdc/views/image_controls_view.py @@ -0,0 +1,413 @@ +"""Qt view adapter for image controls and bottom layout.""" + +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtGui import QFont +from qtpy.QtWidgets import ( + QAction, + QActionGroup, + QCheckBox, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QMenu, + QVBoxLayout, + QWidget, +) + +from cellacdc import widgets +from cellacdc.viewmodels.image_controls_viewmodel import ImageControlsViewModel + +_font = QFont() +_font.setPixelSize(11) + + +class ImageControlsView: + """Qt-facing adapter around image-control defaults and widgets.""" + + def __init__(self, host, view_model: ImageControlsViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def gui_createImg1Widgets(self): + # Toggle contours/ID combobox + self.drawIDsContComboBoxSegmItems = list( + self.view_model.draw_ids_cont_combo_items() + ) + self.drawIDsContComboBox = widgets.ComboBox() + self.drawIDsContComboBox.setFont(_font) + self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) + self.drawIDsContComboBox.setVisible(False) + + self.annotIDsCheckbox = widgets.CheckBox( + 'IDs', keyPressCallback=self.resetFocus) + self.annotCcaInfoCheckbox = widgets.CheckBox( + 'Cell cycle info', keyPressCallback=self.resetFocus) + self.annotNumZslicesCheckbox = widgets.CheckBox( + 'No. z-slices/object', keyPressCallback=self.resetFocus) + + self.annotContourCheckbox = widgets.CheckBox( + 'Contours', keyPressCallback=self.resetFocus) + self.annotSegmMasksCheckbox = widgets.CheckBox( + 'Segm. masks', keyPressCallback=self.resetFocus) + + self.drawMothBudLinesCheckbox = widgets.CheckBox( + 'Only mother-daughter line', keyPressCallback=self.resetFocus + ) + + self.drawNothingCheckbox = widgets.CheckBox( + 'Do not annotate', keyPressCallback=self.resetFocus + ) + + self.annotOptionsWidget = QWidget() + annotOptionsLayout = QHBoxLayout() + + # Show tree info checkbox + self.showTreeInfoCheckbox = widgets.CheckBox( + 'Show tree info', keyPressCallback=self.resetFocus + ) + self.showTreeInfoCheckbox.setFont(_font) + sp = self.showTreeInfoCheckbox.sizePolicy() + sp.setRetainSizeWhenHidden(True) + self.showTreeInfoCheckbox.setSizePolicy(sp) + self.showTreeInfoCheckbox.hide() + + annotOptionsLayout.addWidget(self.showTreeInfoCheckbox) + annotOptionsLayout.addWidget(QLabel(' | ')) + annotOptionsLayout.addWidget(self.annotIDsCheckbox) + annotOptionsLayout.addWidget(self.annotCcaInfoCheckbox) + annotOptionsLayout.addWidget(self.drawMothBudLinesCheckbox) + annotOptionsLayout.addWidget(self.annotNumZslicesCheckbox) + annotOptionsLayout.addWidget(QLabel(' | ')) + annotOptionsLayout.addWidget(self.annotContourCheckbox) + annotOptionsLayout.addWidget(self.annotSegmMasksCheckbox) + annotOptionsLayout.addWidget(QLabel(' | ')) + annotOptionsLayout.addWidget(self.drawNothingCheckbox) + annotOptionsLayout.addWidget(self.drawIDsContComboBox) + self.annotOptionsLayout = annotOptionsLayout + + # Toggle highlight z+-1 objects combobox + self.highlightZneighObjCheckbox = widgets.CheckBox( + 'Highlight objects in neighbouring z-slices', + keyPressCallback=self.resetFocus + ) + self.highlightZneighObjCheckbox.setFont(_font) + self.highlightZneighObjCheckbox.hide() + + annotOptionsLayout.addWidget(self.highlightZneighObjCheckbox) + self.annotOptionsWidget.setLayout(annotOptionsLayout) + + # Annotations options right image + self.annotIDsCheckboxRight = widgets.CheckBox( + 'IDs', keyPressCallback=self.resetFocus) + self.annotCcaInfoCheckboxRight = widgets.CheckBox( + 'Cell cycle info', keyPressCallback=self.resetFocus) + self.annotNumZslicesCheckboxRight = widgets.CheckBox( + 'No. z-slices/object', keyPressCallback=self.resetFocus + ) + + self.annotContourCheckboxRight = widgets.CheckBox( + 'Contours', keyPressCallback=self.resetFocus) + self.annotSegmMasksCheckboxRight = widgets.CheckBox( + 'Segm. masks', keyPressCallback=self.resetFocus) + + self.drawMothBudLinesCheckboxRight = widgets.CheckBox( + 'Only mother-daughter line', keyPressCallback=self.resetFocus + ) + + self.drawNothingCheckboxRight = widgets.CheckBox( + 'Do not annotate', keyPressCallback=self.resetFocus) + + self.annotOptionsWidgetRight = QWidget() + annotOptionsLayoutRight = QHBoxLayout() + + annotOptionsLayoutRight.addWidget(QLabel(' ')) + annotOptionsLayoutRight.addWidget(QLabel(' | ')) + annotOptionsLayoutRight.addWidget(self.annotIDsCheckboxRight) + annotOptionsLayoutRight.addWidget(self.annotCcaInfoCheckboxRight) + annotOptionsLayoutRight.addWidget(self.drawMothBudLinesCheckboxRight) + annotOptionsLayoutRight.addWidget(self.annotNumZslicesCheckboxRight) + annotOptionsLayoutRight.addWidget(QLabel(' | ')) + annotOptionsLayoutRight.addWidget(self.annotContourCheckboxRight) + annotOptionsLayoutRight.addWidget(self.annotSegmMasksCheckboxRight) + annotOptionsLayoutRight.addWidget(QLabel(' | ')) + annotOptionsLayoutRight.addWidget(self.drawNothingCheckboxRight) + self.annotOptionsLayoutRight = annotOptionsLayoutRight + + self.annotOptionsWidgetRight.setLayout(annotOptionsLayoutRight) + + # Frames scrollbar + self.navigateScrollBar = widgets.navigateQScrollBar(Qt.Horizontal) + self.navigateScrollBar.setDisabled(True) + self.navigateScrollBar.setMinimum(1) + self.navigateScrollBar.setMaximum(1) + self.navigateScrollBar.setToolTip( + 'NOTE: The maximum frame number that can be visualized with this ' + 'scrollbar\n' + 'is the last visited frame with the selected mode\n' + '(see "Mode" selector on the top-right).\n\n' + 'If the scrollbar does not move it means that you never visited\n' + 'any frame with current mode.\n\n' + 'Note that the "Viewer" mode allows you to scroll ALL frames.' + ) + t_label = QLabel('frame n. ') + t_label.setFont(_font) + self.t_label = t_label + + # z-slice scrollbars + self.zSliceScrollBar = widgets.linkedQScrollbar(Qt.Horizontal) + + self.zProjComboBox = widgets.ComboBox() + self.zProjComboBox.setFont(_font) + self.zProjComboBox.addItems(self.view_model.z_projection_options()) + self.zProjLockViewButton = widgets.LockPushButton() + self.zProjLockViewButton.setCheckable(True) + self.zProjLockViewButton.setToolTip( + 'If active, the selected z-slice view is applied to all frames' + ) + self.zProjLockViewButton.hide() + + self.switchPlaneCombobox = widgets.SwitchPlaneCombobox() + self.switchPlaneCombobox.setToolTip( + 'Switch viewed plane' + ) + + self.zSliceOverlay_SB = widgets.ScrollBar(Qt.Horizontal) + _z_label = QLabel('Overlay z-slice ') + _z_label.setFont(_font) + _z_label.setDisabled(True) + self.overlay_z_label = _z_label + + self.zProjOverlay_CB = widgets.ComboBox() + self.zProjOverlay_CB.setFont(_font) + self.zProjOverlay_CB.addItems( + self.view_model.overlay_z_projection_options() + ) + self.zProjOverlay_CB.setCurrentIndex(4) + self.zSliceOverlay_SB.setDisabled(True) + + self.img1BottomGroupbox = self.gui_getImg1BottomWidgets() + + def gui_getImg1BottomWidgets(self): + bottomLeftLayout = QGridLayout() + self.bottomLeftLayout = bottomLeftLayout + container = QGroupBox('Navigate and annotate left image') + + row = 0 + bottomLeftLayout.addWidget(self.annotOptionsWidget, row, 0, 1, 4) + # bottomLeftLayout.addWidget( + # self.drawIDsContComboBox, row, 1, 1, 2, + # alignment=Qt.AlignCenter + # ) + + # bottomLeftLayout.addWidget( + # self.showTreeInfoCheckbox, row, 0, 1, 1, + # alignment=Qt.AlignCenter + # ) + + row += 1 + navWidgetsLayout = QHBoxLayout() + self.navSpinBox = widgets.SpinBox(disableKeyPress=True) + self.navSpinBox.setMinimum(1) + self.navSpinBox.setMaximum(100) + self.navSizeLabel = QLabel('/ND') + navWidgetsLayout.addWidget(self.t_label) + navWidgetsLayout.addWidget(self.navSpinBox) + navWidgetsLayout.addWidget(self.navSizeLabel) + bottomLeftLayout.addLayout( + navWidgetsLayout, row, 0, alignment=Qt.AlignRight + ) + bottomLeftLayout.addWidget(self.navigateScrollBar, row, 1, 1, 2) + sp = self.navigateScrollBar.sizePolicy() + sp.setRetainSizeWhenHidden(True) + self.navigateScrollBar.setSizePolicy(sp) + self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) + self.navSpinBox.editingFinished.connect( + self.navigateSpinboxEditingFinished + ) + self.navSpinBox.sigUpClicked.connect( + self.navigateSpinboxEditingFinished + ) + self.navSpinBox.sigDownClicked.connect( + self.navigateSpinboxEditingFinished + ) + + self.lastTrackedFrameLabel = QLabel() + self.lastTrackedFrameLabel.setFont(_font) + bottomLeftLayout.addWidget(self.lastTrackedFrameLabel, row, 3) + + row += 1 + zSliceCheckboxLayout = QHBoxLayout() + self.zSliceCheckbox = QCheckBox('z-slice') + self.zSliceSpinbox = widgets.SpinBox(disableKeyPress=True) + self.zSliceSpinbox.setMinimum(1) + self.SizeZlabel = QLabel('/ND') + self.zSliceCheckbox.setToolTip( + 'Activate/deactivate control of the z-slices with keyboard arrows.\n\n' + 'SHORTCUT to toggle ON/OFF: "Z" key' + ) + zSliceCheckboxLayout.addWidget(self.zSliceCheckbox) + zSliceCheckboxLayout.addWidget(self.zSliceSpinbox) + zSliceCheckboxLayout.addWidget(self.SizeZlabel) + bottomLeftLayout.addLayout( + zSliceCheckboxLayout, row, 0, alignment=Qt.AlignRight + ) + bottomLeftLayout.addWidget(self.zSliceScrollBar, row, 1, 1, 2) + bottomLeftLayout.addWidget(self.zProjComboBox, row, 3) + bottomLeftLayout.addWidget(self.zProjLockViewButton, row, 4) + bottomLeftLayout.addWidget(self.switchPlaneCombobox, row, 5) + self.zSliceSpinbox.connectValueChanged(self.onZsliceSpinboxValueChange) + self.zSliceSpinbox.editingFinished.connect(self.zSliceScrollBarReleased) + + row += 1 + bottomLeftLayout.addWidget( + self.overlay_z_label, row, 0, alignment=Qt.AlignRight + ) + bottomLeftLayout.addWidget(self.zSliceOverlay_SB, row, 1, 1, 2) + + bottomLeftLayout.addWidget(self.zProjOverlay_CB, row, 3) + + row += 1 + self.alphaScrollbarRow = row + + bottomLeftLayout.setColumnStretch(0,0) + bottomLeftLayout.setColumnStretch(1,3) + bottomLeftLayout.setColumnStretch(2,0) + + container.setLayout(bottomLeftLayout) + return container + + def gui_createLabWidgets(self): + bottomRightLayout = QVBoxLayout() + self.rightBottomGroupbox = widgets.GroupBox( + 'Annotate right image independent of left image', + keyPressCallback=self.resetFocus + ) + self.rightBottomGroupbox.setCheckable(True) + self.rightBottomGroupbox.setChecked(False) + self.rightBottomGroupbox.hide() + + self.annotateRightHowCombobox = widgets.ComboBox() + self.annotateRightHowCombobox.setFont(_font) + self.annotateRightHowCombobox.addItems(self.drawIDsContComboBoxSegmItems) + self.annotateRightHowCombobox.setCurrentIndex( + self.drawIDsContComboBox.currentIndex() + ) + self.annotateRightHowCombobox.setVisible(False) + + self.annotOptionsLayoutRight.addWidget(self.annotateRightHowCombobox) + + self.rightImageFramesScrollbar = widgets.ScrollBarWithNumericControl( + labelText='Frame n. ' + ) + self.rightImageFramesScrollbar.setVisible(False) + + bottomRightLayout.addWidget(self.annotOptionsWidgetRight) + bottomRightLayout.addWidget(self.rightImageFramesScrollbar) + bottomRightLayout.addStretch(1) + + self.rightBottomGroupbox.setLayout(bottomRightLayout) + + self.rightBottomGroupbox.toggled.connect(self.rightImageControlsToggled) + + def rightImageControlsToggled(self, checked): + if self.isDataLoading: + return + if checked: + self.annotateRightHowCombobox.setCurrentText( + self.drawIDsContComboBox.currentText() + ) + self.updateAllImages() + + def setFocusGraphics(self): + self.graphLayout.setFocus() + + def setFocusMain(self): + # on macOS with Qt6 setFocus causes crashes. Disabled for now. + return + + def resetFocus(self): + self.setFocusGraphics() + self.setFocusMain() + + def gui_createBottomWidgetsToBottomLayout(self): + # self.bottomDockWidget = QDockWidget(self) + bottomScrollArea = widgets.ScrollArea(resizeVerticalOnShow=True) + bottomScrollArea.sigLeaveEvent.connect(self.setFocusMain) + bottomWidget = QWidget() + bottomScrollAreaLayout = QVBoxLayout() + self.bottomLayout = QHBoxLayout() + self.bottomLayout.addLayout(self.quickSettingsLayout) + self.bottomLayout.addStretch(1) + self.bottomLayout.addWidget(self.img1BottomGroupbox) + self.bottomLayout.addStretch(1) + self.bottomLayout.addWidget(self.rightBottomGroupbox) + self.bottomLayout.addStretch(1) + + bottomScrollAreaLayout.addLayout(self.bottomLayout) + bottomScrollAreaLayout.addStretch(1) + + bottomWidget.setLayout(bottomScrollAreaLayout) + bottomScrollArea.setWidgetResizable(True) + bottomScrollArea.setWidget(bottomWidget) + self.bottomScrollArea = bottomScrollArea + + zoom_perc = self.view_model.bottom_layout_zoom_percent( + self.df_settings + ) + self.bottomLayoutContextMenu = QMenu('Bottom layout', self) + zoomMenu = self.bottomLayoutContextMenu.addMenu('Zoom') + actions = [] + self.bottomLayoutContextMenu.zoomActionGroup = QActionGroup(zoomMenu) + for perc in self.view_model.bottom_layout_zoom_values(): + action = QAction(f'{perc}%', zoomMenu) + action.setCheckable(True) + if perc == zoom_perc: + action.setChecked(True) + action.toggled.connect(self.zoomBottomLayoutActionTriggered) + actions.append(action) + self.bottomLayoutContextMenu.zoomActionGroup.addAction(action) + zoomMenu.addActions(actions) + resetAction = self.bottomLayoutContextMenu.addAction( + 'Reset default height' + ) + resetAction.triggered.connect(self.resizeGui) + retainSpaceAction = self.bottomLayoutContextMenu.addAction( + 'Retain space of hidden sliders' + ) + retainSpaceAction.setCheckable(True) + retainSpaceChecked = self.view_model.retain_space_hidden_sliders( + self.df_settings + ) + retainSpaceAction.setChecked(retainSpaceChecked) + retainSpaceAction.toggled.connect(self.retainSpaceSlidersToggled) + self.retainSpaceSlidersAction = retainSpaceAction + self.setBottomLayoutStretch() + + def gui_resetBottomLayoutHeight(self): + self.h = self.defaultWidgetHeightBottomLayout + self.checkBoxesHeight = 14 + self.fontPixelSize = 11 + self.resizeSlidersArea() + + def gui_createGraphicsPlots(self): + from cellacdc.canvas.qt.dual_pane import create_dual_pane + + parts = create_dual_pane(invert_bw=self.invertBwAction.isChecked()) + self.graphLayout = parts['graphLayout'] + self.ax1 = parts['ax1'] + self.ax2 = parts['ax2'] + self.lutItemsLayout = parts['lutItemsLayout'] + self.titleColor = parts['titleColor'] + self.plotsCol = 1 diff --git a/cellacdc/views/image_display_view.py b/cellacdc/views/image_display_view.py new file mode 100644 index 000000000..44618fe5a --- /dev/null +++ b/cellacdc/views/image_display_view.py @@ -0,0 +1,1441 @@ +"""Qt view adapter for image display, LUT, and cursor workflows.""" + +from __future__ import annotations + +from functools import partial + +import numpy as np +import pyqtgraph as pg +import skimage.exposure +import skimage.measure +from qtpy.QtCore import QTimer +from qtpy.QtWidgets import QAction, QActionGroup + +from cellacdc import ( + apps, + darkBkgrColor, + disableWindow, + exception_handler, + graphLayoutBkgrColor, + myutils, + settings_csv_path, +) +from cellacdc.viewmodels.image_display_viewmodel import ImageDisplayViewModel + + +class ImageDisplayView: + """Qt-facing adapter for image display, LUT, and cursor workflows.""" + + LEGACY_METHODS = ( + 'getDisplayedImg1', + 'getDisplayedZstack', + 'getObjBbox', + 'z_lab', + 'get_2Dlab', + 'get_2Drp', + 'set_2Dlab', + 'setTextAnnotZsliceScrolling', + 'setGraphicalAnnotZsliceScrolling', + 'initContoursImage', + 'initLostObjContoursImage', + 'initLostTrackedObjContoursImage', + 'initManualBackgroundImage', + 'initImgCmap', + 'initTextAnnot', + 'zoomOut', + 'zoomToObjsActionCallback', + 'zoomToCells', + 'equalizeHist', + 'getDistantGray', + 'RGBtoGray', + 'ruler_cb', + 'editImgProperties', + 'setTwoImagesLayout', + 'showNextFrameImageItem', + 'showRightImageItem', + 'showLabelImageItem', + 'setAnnotOptionsRightImageLabelsDisabled', + 'setBottomLayoutStretch', + 'setHoverToolSymbolData', + 'getCheckNormAction', + 'normalizeIntensities', + 'invertBw', + 'setCheckedInvertBW', + 'updateImageValueFormatter', + 'getImageDataFromFilename', + 'z_slice_index', + 'get_2Dimg_from_3D', + 'updateZsliceScrollbar', + 'getRawImage', + 'getRawImageLayer0', + 'getImage', + 'updateLabelsAlpha', + '_getImageupdateAllImages', + 'setImageImg1', + 'setImageImg2', + 'getObject2DimageFromZ', + 'getObject2DsliceFromZ', + 'isObjVisible', + 'getObjImage', + 'getObjSlice', + 'getContoursImageItem', + 'getLostObjImageItem', + 'getLostTrackedObjImageItem', + 'normaliseIntensitiesActionTriggered', + 'setLastUserNormAction', + 'saveLabelsColormap', + 'addFontSizeActions', + 'changeFontSize', + 'enableZstackWidgets', + 'launchSlideshow', + 'setMirroredCursorFromSecondWindow', + 'zProjLockViewToggled', + 'rescaleIntensExportToVideoDialog', + 'customLevelsLutChanged', + 'getPreComputedMinMaxZstack', + 'rescaleIntensitiesLut', + 'showMirroredCursorToggled', + 'clearCursors', + 'activeEraserCircleCursors', + 'activeEraserXCursors', + 'activeBrushCircleCursors', + 'initImgGradRescaleIntensitiesHowPreference', + 'updateAllImages', + 'removeAxLimits', + 'resizeGui', + 'autoRange', + 'resetRange', + ) + + def __init__(self, host, view_model: ImageDisplayViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + # @exec_time + def getDisplayedImg1(self): + return self.img1.image + + def getDisplayedZstack(self): + posData = self.data[self.pos_i] + return posData.img_data[posData.frame_i] + + def getObjBbox(self, obj_bbox): + if self.isSegm3D and len(obj_bbox)==6: + obj_bbox = (obj_bbox[1], obj_bbox[2], obj_bbox[4], obj_bbox[5]) + return obj_bbox + else: + return obj_bbox + + def z_lab(self, checkIfProj=False): + if checkIfProj and self.zProjComboBox.currentText() != 'single z-slice': + return + + if not self.isSegm3D: + return + + posData = self.data[self.pos_i] + + idx = self.zSliceScrollBar.sliderPosition() + + # ensure idx doesnt exceed the number of z-slices of the position + idx_z = min(idx, posData.SizeZ-1) + + if not self.switchPlaneCombobox.isEnabled(): + return idx_z + + depthAxes = self.switchPlaneCombobox.depthAxes() + if depthAxes == 'z': + return idx_z + elif depthAxes == 'y': + idx_y = min(idx, posData.SizeY-1) + return (slice(None), idx_y) + else: + idx_x = min(idx, posData.SizeX-1) + return (slice(None), slice(None), idx_x) + + def get_2Dlab(self, lab, force_z=True): + if self.isSegm3D: + if force_z: + return lab[self.z_lab()] + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if isZslice: + return lab[self.z_lab()] + else: + return lab.max(axis=0) + else: + return lab + + def get_2Drp(self, lab=None): + if self.isSegm3D: + if lab is None: + # self.currentLab2D is defined at self.setImageImg2() + lab = self.currentLab2D + lab = self.get_2Dlab(lab) + rp = skimage.measure.regionprops(lab) + return rp + else: + return self.data[self.pos_i].rp + + def set_2Dlab(self, lab2D, lab3D=None): + posData = self.data[self.pos_i] + + if lab3D is None: + lab3D = posData.lab + + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if isZslice: + lab3D[self.z_lab()] = lab2D + else: + lab3D[:] = lab2D + else: + if lab3D.shape == lab2D.shape: + lab3D[...] = lab2D + else: + posData.lab = lab2D + + def setTextAnnotZsliceScrolling(self): + pass + + def setGraphicalAnnotZsliceScrolling(self): + posData = self.data[self.pos_i] + if self.isSegm3D: + self.currentLab2D = posData.lab[self.z_lab()] + self.setOverlaySegmMasks() + self.custom_annotations_view.doCustomAnnotation(0) + self.update_rp_metadata() + else: + self.currentLab2D = posData.lab + self.setOverlaySegmMasks() + self.updateContoursImage(0) + self.updateContoursImage(1) + + def initContoursImage(self): + posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] + + self.contoursImage = np.zeros((Y, X, 4), dtype=np.uint8) + + def initLostObjContoursImage(self): + posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] + + self.lostObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) + + def initLostTrackedObjContoursImage(self): + posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] + + self.lostTrackedObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) + + def initManualBackgroundImage(self): + posData = self.data[self.pos_i] + if hasattr(posData, 'lab'): + Y, X = posData.lab.shape[-2:] + else: + Y, X = posData.img_data.shape[-2:] + if not hasattr(self, 'manualBackgroundTextItems'): + self.manualBackgroundTextItems = {} + posData.manualBackgroundImage = np.zeros((Y, X, 4), dtype=np.uint8) + if posData.manualBackgroundLab is None: + posData.manualBackgroundLab = np.zeros((Y, X), dtype=np.uint32) + + def initImgCmap(self): + if not 'img_cmap' in self.df_settings.index: + self.df_settings.at['img_cmap', 'value'] = 'grey' + self.imgCmapName = self.df_settings.at['img_cmap', 'value'] + self.imgCmap = self.imgGrad.cmaps[self.imgCmapName] + if self.imgCmapName != 'grey': + # To ensure mapping to colors we need to normalize image + self.normalizeByMaxAction.setChecked(True) + + def initTextAnnot(self, force=False): + posData = self.data[self.pos_i] + if hasattr(posData, 'lab'): + Y, X = posData.lab.shape[-2:] + else: + Y, X = posData.img_data.shape[-2:] + self.textAnnot[0].initItem((Y, X)) + self.textAnnot[1].initItem((Y, X)) + + def zoomOut(self): + self.ax1.autoRange() + + def zoomToObjsActionCallback(self): + self.zoomToCells(enforce=True) + + def zoomToCells(self, enforce=False): + if not self.enableAutoZoomToCellsAction.isChecked() and not enforce: + return + + posData = self.data[self.pos_i] + lab_mask = (self.currentLab2D>0).astype(np.uint8) + rp = skimage.measure.regionprops(lab_mask) + if not rp: + Y, X = lab_mask.shape + xRange = -0.5, X+0.5 + yRange = -0.5, Y+0.5 + else: + obj = rp[0] + min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) + xRange = min_col-10, max_col+10 + yRange = max_row+10, min_row-10 + + self.ax1.setRange(xRange=xRange, yRange=yRange) + + @disableWindow + def equalizeHist(self, checked=True): + self.img1.useEqualized = checked + + if not checked: + self.updateAllImages() + return + + self.logger.info('Equalizing image histogram...') + for pos_i, _posData in enumerate(self.data): + n_dim_img = _posData.img_data.ndim + _posData.equalized_img_data = ( + self.view_model.preprocessing.create_preprocessed_data() + ) + for frame_i, img_frame in enumerate(_posData.img_data): + if n_dim_img == 4: + for z, img_z in enumerate(img_frame): + eq_img = skimage.exposure.equalize_adapthist(img_z) + _posData.equalized_img_data[frame_i][z] = eq_img + self.img1.updateMinMaxValuesEqualizedData( + self.data, pos_i, frame_i, z + ) + self.img1.updateMinMaxValuesEqualizedDataProjections( + self.data, pos_i, frame_i + ) + else: + eq_img = skimage.exposure.equalize_adapthist(img_frame) + _posData.equalized_img_data[frame_i] = eq_img + self.img1.updateMinMaxValuesEqualizedData( + self.data, pos_i, frame_i, None + ) + + self.updateAllImages() + + def getDistantGray(self, desiredGray, bkgrGray): + return self.view_model.formatting.distant_gray(desiredGray, bkgrGray) + + def RGBtoGray(self, R, G, B): + return self.view_model.formatting.rgb_to_gray(R, G, B) + + def ruler_cb(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + self.connectLeftClickButtons() + else: + self.tempSegmentON = False + self.ax1_rulerPlotItem.setData([], []) + self.ax1_rulerAnchorsItem.setData([], []) + + def editImgProperties(self, checked=True): + posData = self.data[self.pos_i] + posData.askInputMetadata( + len(self.data), + ask_SizeT=True, + ask_TimeIncrement=True, + ask_PhysicalSizes=True, + save=True, singlePos=True, + askSegm3D=False + ) + if hasattr(self, 'timestamp'): + self.timestamp.setSecondsPerFrame(posData.TimeIncrement) + self.display_decorations_view.update_timestamp_frame() + + if hasattr(self, 'scaleBar'): + self.scaleBar.updatePhysicalLength(posData.PhysicalSizeX) + + def setTwoImagesLayout(self, isTwoImages): + self.isTwoImageLayout = isTwoImages + if isTwoImages: + self.graphLayout.removeItem(self.titleLabel) + self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) + # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignLeft) + self.ax2.show() + self.ax2.vb.setYLink(self.ax1.vb) + self.ax2.vb.setXLink(self.ax1.vb) + else: + self.graphLayout.removeItem(self.titleLabel) + self.graphLayout.addItem(self.titleLabel, row=0, col=1) + # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignCenter) + self.ax2.hide() + oldLink = self.ax2.vb.linkedView(self.ax1.vb.YAxis) + try: + oldLink.sigYRangeChanged.disconnect() + oldLink.sigXRangeChanged.disconnect() + except TypeError: + pass + + def showNextFrameImageItem(self, checked): + plan = self.view_model.right_pane_visibility_plan( + 'next_frame', checked + ) + self.rightImageFramesScrollbar.setVisible(checked) + self.rightImageFramesScrollbar.setDisabled(not checked) + self.setTwoImagesLayout(checked) + if checked: + self.graphLayout.addItem( + self.imgGradRight, row=1, col=self.plotsCol+2 + ) + self.rightBottomGroupbox.show() + self.rightBottomGroupbox.setChecked(True) + self.drawNothingCheckboxRight.click() + if not self.isDataLoading: + self.updateAllImages() + else: + self.clearAx2Items() + self.rightBottomGroupbox.hide() + try: + self.graphLayout.removeItem(self.imgGradRight) + except Exception: + return + self.rightImageItem.clear() + + for setting, value in plan.settings_updates.items(): + self.df_settings.at[setting, 'value'] = value + self.df_settings.to_csv(self.settings_csv_path) + + QTimer.singleShot(300, self.resizeGui) + + self.setBottomLayoutStretch() + + def showRightImageItem(self, checked): + plan = self.view_model.right_pane_visibility_plan( + 'right_image', checked + ) + self.rightImageFramesScrollbar.setVisible(not checked) + self.rightImageFramesScrollbar.setDisabled(checked) + self.setTwoImagesLayout(checked) + if checked: + self.graphLayout.addItem( + self.imgGradRight, row=1, col=self.plotsCol+2 + ) + self.rightBottomGroupbox.show() + if not self.isDataLoading: + self.updateAllImages() + else: + self.clearAx2Items() + self.rightBottomGroupbox.hide() + try: + self.graphLayout.removeItem(self.imgGradRight) + except Exception: + return + self.rightImageItem.clear() + + for setting, value in plan.settings_updates.items(): + self.df_settings.at[setting, 'value'] = value + self.df_settings.to_csv(self.settings_csv_path) + + QTimer.singleShot(300, self.resizeGui) + + self.setBottomLayoutStretch() + + def showLabelImageItem(self, checked): + plan = self.view_model.right_pane_visibility_plan('labels', checked) + self.rightImageFramesScrollbar.setVisible(not checked) + self.rightImageFramesScrollbar.setDisabled(checked) + self.setTwoImagesLayout(checked) + self.setAnnotOptionsRightImageLabelsDisabled(checked) + if checked: + self.rightBottomGroupbox.show() + self.rightBottomGroupbox.setChecked(True) + if not self.isDataLoading: + self.updateAllImages() + else: + self.clearAx2Items() + self.img2.clear() + self.rightBottomGroupbox.hide() + self.moveDelRoisToLeft() + + for setting, value in plan.settings_updates.items(): + self.df_settings.at[setting, 'value'] = value + self.df_settings.to_csv(self.settings_csv_path) + QTimer.singleShot(200, self.resizeGui) + + self.setBottomLayoutStretch() + + def setAnnotOptionsRightImageLabelsDisabled(self, disabled): + self.annotContourCheckboxRight.setDisabled(disabled) + self.annotSegmMasksCheckboxRight.setDisabled(disabled) + if disabled: + self.annotSegmMasksCheckboxRight.setChecked(False) + self.annotSegmMasksCheckboxRight.setChecked(False) + self.annotIDsCheckboxRight.setChecked(True) + + def setBottomLayoutStretch(self): + if ( + self.labelsGrad.showRightImgAction.isChecked() + or self.labelsGrad.showNextFrameAction.isChecked() + ): + # Equally share space between the two control groupboxes + self.bottomLayout.setStretch(1, 1) + self.bottomLayout.setStretch(2, 5) + self.bottomLayout.setStretch(3, 1) + self.bottomLayout.setStretch(4, 5) + self.bottomLayout.setStretch(5, 1) + elif self.labelsGrad.showLabelsImgAction.isChecked(): + # Left control takes only left space + self.bottomLayout.setStretch(1, 1) + self.bottomLayout.setStretch(2, 5) + self.bottomLayout.setStretch(3, 5) + self.bottomLayout.setStretch(4, 1) + self.bottomLayout.setStretch(5, 1) + else: + # Left control takes all the space + self.bottomLayout.setStretch(1, 3) + self.bottomLayout.setStretch(2, 10) + self.bottomLayout.setStretch(3, 1) + self.bottomLayout.setStretch(4, 1) + self.bottomLayout.setStretch(5, 1) + + def setHoverToolSymbolData(self, xx, yy, ScatterItems, size=None): + if not xx: + self.ax1_lostObjScatterItem.setVisible(True) + self.ax2_lostObjScatterItem.setVisible(True) + + self.ax1_lostTrackedScatterItem.setVisible(True) + self.ax2_lostTrackedScatterItem.setVisible(True) + + for item in ScatterItems: + if size is None: + item.setData(xx, yy) + else: + item.setData(xx, yy, size=size) + + def getCheckNormAction(self): + normalize = False + how = '' + for action in self.normalizeQActionGroup.actions(): + if action.isChecked(): + how = action.text() + normalize = True + break + return action, normalize, how + + def normalizeIntensities(self, img): + action, normalize, how = self.getCheckNormAction() + if not normalize: + return img + + return self.view_model.preprocessing.normalize_display_image(img, how) + + def invertBw(self, checked, update=True): + self.invertBwAlreadyCalledOnce = True + + try: + self.labelsGrad.invertBwAction.toggled.disconnect() + except Exception as err: + pass + + self.labelsGrad.invertBwAction.setChecked(checked) + self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + + try: + self.imgGrad.invertBwAction.toggled.disconnect() + except Exception as err: + pass + self.imgGrad.invertBwAction.setChecked(checked) + self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + + self.imgGrad.setInvertedColorMaps(checked) + self.imgGrad.invertCurrentColormap(checked) + + self.imgGradRight.setInvertedColorMaps(checked) + self.imgGradRight.invertCurrentColormap(checked) + + if hasattr(self, 'overlayLayersItems'): + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.invertBwAction.toggled.disconnect() + lutItem.invertBwAction.setChecked(checked) + lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW) + lutItem.setInvertedColorMaps(checked) + + if self.slideshowWin is not None: + self.slideshowWin.is_bw_inverted = checked + self.slideshowWin.update_img() + self.df_settings.at['is_bw_inverted', 'value'] = ( + self.view_model.invert_bw_setting_value(checked) + ) + self.df_settings.to_csv(self.settings_csv_path) + if checked: + # Light mode + self.equalizeHistPushButton.setStyleSheet('') + self.graphLayout.setBackground(graphLayoutBkgrColor) + self.ax2_BrushCirclePen = pg.mkPen((150,150,150), width=2) + self.ax2_BrushCircleBrush = pg.mkBrush((200,200,200,150)) + self.titleColor = 'black' + else: + # Dark mode + self.equalizeHistPushButton.setStyleSheet( + 'QPushButton {background-color: #282828; color: #F0F0F0;}' + ) + self.graphLayout.setBackground(darkBkgrColor) + self.ax2_BrushCirclePen = pg.mkPen(width=2) + self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50)) + self.titleColor = 'white' + + if not hasattr(self, 'textAnnot'): + return + + self.textAnnot[0].invertBlackAndWhite() + self.textAnnot[1].invertBlackAndWhite() + + self.objLabelAnnotRgb = tuple( + self.textAnnot[0].item.colors()['label'][:3] + ) + self.textIDsColorButton.setColor(self.objLabelAnnotRgb) + self.imgGrad.textColorButton.setColor(self.objLabelAnnotRgb) + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.textColorButton.setColor(self.objLabelAnnotRgb) + + if update: + self.updateAllImages() + + def setCheckedInvertBW(self, checked): + self.invertBwAction.setChecked(checked) + + def updateImageValueFormatter(self): + if self.img1.image is not None: + dtype = self.img1.image.dtype + n_digits = len(str(int(self.img1.image.max()))) + self.imgValueFormatter = ( + self.view_model.formatting.number_fstring_formatter( + dtype, precision=abs(n_digits-5) + ) + ) + + rawImgData = self.data[self.pos_i].img_data + dtype = rawImgData.dtype + n_digits = len(str(int(rawImgData.max()))) + self.rawValueFormatter = ( + self.view_model.formatting.number_fstring_formatter( + dtype, precision=abs(n_digits-5) + ) + ) + + def getImageDataFromFilename(self, filename): + posData = self.data[self.pos_i] + if filename == posData.filename: + return posData.img_data[posData.frame_i] + else: + return posData.ol_data_dict.get(filename) + + def z_slice_index(self): + posData = self.data[self.pos_i] + if posData.SizeZ == 1: + return None + zProjHow = self.zProjComboBox.currentText() + if zProjHow != 'single z-slice': + return zProjHow + + axis_slice = self.zSliceScrollBar.sliderPosition() + if self.switchPlaneCombobox.depthAxes() == 'x': + z_slice = ( + slice(None, None, None), slice(None, None, None), axis_slice + ) + elif self.switchPlaneCombobox.depthAxes() == 'y': + z_slice = ( + slice(None, None, None), axis_slice + ) + else: + z_slice = axis_slice + + return z_slice + + def get_2Dimg_from_3D(self, imgData, isLayer0=True, frame_i=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + if frame_i < 0: + frame_i = 0 + frame_i = posData.frame_i = 0 + + axis_slice = self.zSliceScrollBar.sliderPosition() + if self.switchPlaneCombobox.depthAxes() == 'x': + return imgData[:, :, axis_slice].copy() + elif self.switchPlaneCombobox.depthAxes() == 'y': + return imgData[:, axis_slice].copy() + + idx = (posData.filename, frame_i) + zProjHow_L0 = self.zProjComboBox.currentText() + if isLayer0: + try: + z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + except ValueError as e: + z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] + zProjHow = zProjHow_L0 + else: + z = self.zSliceOverlay_SB.sliderPosition() + zProjHow_L1 = self.zProjOverlay_CB.currentText() + if zProjHow_L1 == 'same as above': + zProjHow = zProjHow_L0 + else: + zProjHow = zProjHow_L1 + + if zProjHow == 'single z-slice': + img = imgData[z] #.copy() + elif zProjHow == 'max z-projection': + img = imgData.max(axis=0) + elif zProjHow == 'mean z-projection': + img = imgData.mean(axis=0) + elif zProjHow == 'median z-proj.': + img = np.median(imgData, axis=0) + return img + + def updateZsliceScrollbar(self, frame_i): + posData = self.data[self.pos_i] + if self.switchPlaneCombobox.depthAxes() != 'z': + return + + idx = (posData.filename, frame_i) + try: + z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + except ValueError as e: + z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] + try: + zProjHow = posData.segmInfo_df.at[idx, 'which_z_proj_gui'] + except ValueError as e: + zProjHow = posData.segmInfo_df.loc[idx, 'which_z_proj_gui'].iloc[0] + + self.zProjComboBox.setCurrentText(zProjHow) + + reconnect = False + try: + self.zSliceScrollBar.actionTriggered.disconnect() + self.zSliceScrollBar.sliderReleased.disconnect() + reconnect = True + except TypeError: + pass + self.zSliceScrollBar.setSliderPosition(z) + if reconnect: + self.zSliceScrollBar.actionTriggered.connect( + self.zSliceScrollBarActionTriggered + ) + self.zSliceScrollBar.sliderReleased.connect( + self.zSliceScrollBarReleased + ) + self.zSliceSpinbox.setValueNoEmit(z+1) + + def getRawImage(self, frame_i=None, filename=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + if filename is None: + rawImgData = posData.img_data[frame_i] + isLayer0 = True + else: + rawImgData = posData.ol_data[filename][frame_i] + isLayer0 = False + if posData.SizeZ > 1: + rawImg = self.get_2Dimg_from_3D(rawImgData, isLayer0=isLayer0) + else: + rawImg = rawImgData + return rawImg + + def getRawImageLayer0(self, frame_i): + posData = self.data[self.pos_i] + + if posData.SizeZ > 1: + img = posData.img_data[frame_i] + self.updateZsliceScrollbar(frame_i) + img = self.get_2Dimg_from_3D(img) + else: + img = posData.img_data[frame_i].copy() + + if img.ndim == 2: + return img + if img.ndim == 3 and img.shape[-1] in (3, 4): + return img + + raise ValueError( + 'Raw image for display must be 2D (Y, X) or RGB/A (Y, X, 3 or 4); ' + f'got shape={getattr(img, "shape", None)}, ndim={getattr(img, "ndim", None)} ' + f'for frame_i={frame_i} (metadata SizeT={posData.SizeT}, SizeZ={posData.SizeZ}). ' + 'Check that metadata SizeT/SizeZ matches the loaded array (e.g. squeezed TIFF vs CSV).' + ) + + def getImage(self, frame_i=None, raw=False): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + if raw: + return self.getRawImageLayer0(frame_i) + + if self.viewPreprocDataToggle.isChecked(): + try: + img = posData.preproc_img_data[frame_i] + if posData.SizeZ == 1: + return np.array(img) + + self.updateZsliceScrollbar(frame_i) + z_slice = self.z_slice_index() + img = img[z_slice] + return img + except Exception as err: + # self.logger.warning( + # 'Pre-processed image not existing --> returning raw image' + # ) + return self.getRawImageLayer0(frame_i) + + viewCombinedImageData = ( + self.viewCombineChannelDataToggle.isChecked() + and self.combineDialog is not None + and not self.combineDialog.saveAsSegm() + ) + + if viewCombinedImageData: + try: + img = posData.combine_img_data[frame_i] + if posData.SizeZ == 1: + return np.array(img) + + self.updateZsliceScrollbar(frame_i) + z_slice = self.z_slice_index() + img = img[z_slice] + return img + except Exception as err: + # self.logger.warning( + # 'combined image not existing --> returning raw image' + # ) + return self.getRawImageLayer0(frame_i) + + if self.equalizeHistPushButton.isChecked(): + img = posData.equalized_img_data[frame_i] + if posData.SizeZ == 1: + return np.array(img) + + self.updateZsliceScrollbar(frame_i) + z_slice = self.z_slice_index() + img = img[z_slice] + return img + + return self.getRawImageLayer0(frame_i) + + def updateLabelsAlpha(self, value): + plan = self.view_model.labels_alpha_plan( + value, + keep_ids_checked=self.keepIDsButton.isChecked(), + ) + self.df_settings.at['overlaySegmMasksAlpha', 'value'] = ( + plan.setting_value + ) + self.df_settings.to_csv(self.settings_csv_path) + self.labelsLayerImg1.setOpacity(plan.opacity) + self.labelsLayerRightImg.setOpacity(plan.opacity) + + def _getImageupdateAllImages(self, image=None): + if image is not None: + return image + + img = self.getImage() + return img + + def setImageImg1(self, image=None): + img = self._getImageupdateAllImages(image=image) + posData = self.data[self.pos_i] + self.img1.setCurrentPosIndex(self.pos_i) + self.img1.setCurrentFrameIndex(posData.frame_i) + if posData.SizeZ > 1: + zProjHow = self.zProjComboBox.currentText() + if zProjHow == 'single z-slice': + z = self.zSliceScrollBar.sliderPosition() + else: + z = zProjHow + + self.img1.setCurrentZsliceIndex(z) + + self.img1.setImage( + img, next_frame_image=self.nextFrameImage(), + scrollbar_value=posData.frame_i+2 + ) + + def setImageImg2(self, updateLookuptable=True, set_image=True): + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + if mode == 'Segmentation and Tracking' or self.isSnapshot: + # self.addExistingDelROIs() + allDelIDs, lab2D = self.getDelROIlab() + else: + lab2D = self.get_2Dlab(posData.lab, force_z=False) + allDelIDs = set() + + self.currentLab2D = lab2D + if self.labelsGrad.permanentGreedyCmapAction.isChecked() and updateLookuptable: + self.greedyShuffleCmap(updateImages=False) + + if self.labelsGrad.showLabelsImgAction.isChecked() and set_image: + self.img2.setImage(lab2D, z=self.z_lab(), autoLevels=False) + + if updateLookuptable: + self.updateLookuptable(delIDs=allDelIDs) + + def getObject2DimageFromZ(self, z, obj): + posData = self.data[self.pos_i] + z_min = obj.bbox[0] + local_z = z - z_min + if local_z >= posData.SizeZ or local_z < 0: + return + return obj.image[local_z] + + def getObject2DsliceFromZ(self, z, obj): + posData = self.data[self.pos_i] + z_min = obj.bbox[0] + local_z = z - z_min + if local_z >= posData.SizeZ or local_z < 0: + return + return obj.image[local_z] + + def isObjVisible(self, obj_bbox, debug=False, z_slice=None): + if z_slice is None: + z_slice = self.z_lab() + + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if not isZslice: + # required a projection --> all obj are visible + return True + + depthAxes = self.switchPlaneCombobox.depthAxes() + + min_z, min_y, min_x, max_z, max_y, max_x = obj_bbox + if depthAxes == 'z': + min_val, max_val = min_z, max_z + val = z_slice + elif depthAxes == 'y': + min_val, max_val = min_y, max_y + val = z_slice[-1] + else: + min_val, max_val = min_x, max_x + val = z_slice[-1] + + if val >= min_val and val < max_val: + return True + else: + return False + else: + return True + + def getObjImage(self, obj_image, obj_bbox, z_slice=None): + if self.isSegm3D and len(obj_bbox)==6: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if not isZslice: + # required a projection + return obj_image.max(axis=0) + + min_z = obj_bbox[0] + if z_slice is None: + z_slice = self.z_lab() + if isinstance(z_slice, tuple): + z_slice = z_slice[-1] + + local_z = z_slice - min_z + try: + obi_image_2d = obj_image[local_z] + except Exception as err: + obi_image_2d = None + return obi_image_2d + else: + return obj_image + + def getObjSlice(self, obj_slice): + if self.isSegm3D: + return obj_slice[1:3] + else: + return obj_slice + + def getContoursImageItem(self, ax, force=False): + if not self.areContoursRequested(ax) and not force: + return + + if ax == 0: + return self.ax1_contoursImageItem + else: + return self.ax2_contoursImageItem + + def getLostObjImageItem(self, ax): + if ax == 0: + return self.ax1_lostObjImageItem + else: + return self.ax1_lostTrackedObjImageItem + + def getLostTrackedObjImageItem(self, ax): + if ax == 0: + return self.ax1_lostTrackedObjImageItem + else: + return self.ax2_lostTrackedObjImageItem + + def normaliseIntensitiesActionTriggered(self, action): + how = action.text() + self.df_settings.at['how_normIntensities', 'value'] = ( + self.view_model.intensity_normalization_setting_value(how) + ) + self.df_settings.to_csv(self.settings_csv_path) + self.updateAllImages() + self.updateImageValueFormatter() + + def setLastUserNormAction(self): + how = self.df_settings.at['how_normIntensities', 'value'] + for action in self.normalizeQActionGroup.actions(): + if action.text() == how: + action.setChecked(True) + break + + def saveLabelsColormap(self): + self.labelsGrad.saveColormap() + + def addFontSizeActions(self, menu, slot): + fontActionGroup = QActionGroup(self.host) + fontActionGroup.setExclusive(True) + for fontSize in range(4,27): + action = QAction(self.host) + action.setText(str(fontSize)) + action.setCheckable(True) + if fontSize == self.fontSize: + action.setChecked(True) + fontActionGroup.addAction(action) + menu.addAction(action) + action.triggered.connect(slot) + return fontActionGroup + + @exception_handler + def changeFontSize(self): + fontSize = self.fontSizeSpinBox.value() + if fontSize == self.fontSize: + return + + self.fontSize = fontSize + + self.df_settings.at['fontSize', 'value'] = self.fontSize + self.df_settings.to_csv(self.settings_csv_path) + + self.setAllIDs() + posData = self.data[self.pos_i] + for ax in range(2): + self.textAnnot[ax].changeFontSize(self.fontSize) + if self.highLowResAction.isChecked(): + self.setAllTextAnnotations() + else: + self.updateAllImages() + + def enableZstackWidgets(self, enabled): + if enabled: + myutils.setRetainSizePolicy(self.zSliceScrollBar) + myutils.setRetainSizePolicy(self.zProjComboBox) + myutils.setRetainSizePolicy(self.zSliceOverlay_SB) + myutils.setRetainSizePolicy(self.zProjOverlay_CB) + myutils.setRetainSizePolicy(self.overlay_z_label) + self.zSliceScrollBar.setDisabled(False) + self.zProjComboBox.show() + if self.data[self.pos_i].SizeT > 1: + self.zProjLockViewButton.show() + self.zSliceScrollBar.show() + self.zSliceCheckbox.show() + self.zSliceSpinbox.show() + self.switchPlaneCombobox.show() + self.switchPlaneCombobox.setDisabled(False) + self.SizeZlabel.show() + else: + myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=False) + myutils.setRetainSizePolicy(self.zProjComboBox, retain=False) + myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=False) + myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=False) + myutils.setRetainSizePolicy(self.overlay_z_label, retain=False) + self.zSliceScrollBar.setDisabled(True) + self.zProjComboBox.hide() + self.zProjComboBox.hide() + self.zSliceScrollBar.hide() + self.zSliceCheckbox.hide() + self.zSliceSpinbox.hide() + self.SizeZlabel.hide() + self.switchPlaneCombobox.hide() + self.switchPlaneCombobox.setDisabled(True) + + self.imgGrad.rescaleAcrossZstackAction.setDisabled(not enabled) + for ch, overlayItems in self.overlayLayersItems.items(): + lutItem = overlayItems[1] + lutItem.rescaleAcrossZstackAction.setDisabled(not enabled) + + def launchSlideshow(self): + posData = self.data[self.pos_i] + self.determineSlideshowWinPos() + if self.slideshowButton.isChecked(): + self.slideshowWin = apps.imageViewer( + parent=self.host, + button_toUncheck=self.slideshowButton, + linkWindow=posData.SizeT > 1, + enableOverlay=True, + enableMirroredCursor=True + ) + self.slideshowWin.img.minMaxValuesMapper = ( + self.img1.minMaxValuesMapper + ) + self.slideshowWin.img.setCurrentPosIndex(self.pos_i) + h = self.drawIDsContComboBox.size().height() + self.slideshowWin.framesScrollBar.setFixedHeight(h) + self.slideshowWin.overlayButton.setChecked( + self.overlayButton.isChecked() + ) + self.slideshowWin.sigHoveringImage.connect( + self.setMirroredCursorFromSecondWindow + ) + if posData.SizeZ > 1: + z_slice = self.zSliceScrollBar.sliderPosition() + self.slideshowWin.img.setCurrentZsliceIndex(z_slice) + self.slideshowWin.zSliceScrollBar.setSliderPosition(z_slice) + self.slideshowWin.z_label.setText( + f'z-slice {z_slice+1:02}/{posData.SizeZ}' + ) + self.slideshowWin.update_img() + self.slideshowWin.show( + left=self.slideshowWinLeft, top=self.slideshowWinTop + ) + else: + self.slideshowWin.close() + self.slideshowWin = None + + def setMirroredCursorFromSecondWindow(self, x, y): + if x is None: + xx, yy = [], [] + else: + xx, yy = [x], [y] + self.ax1_cursor.setData(xx, yy) + if not self.isTwoImageLayout: + return + self.ax2_cursor.setData(xx, yy) + + def zProjLockViewToggled(self, checked): + self.updateZproj(self.zProjComboBox.currentText()) + + def rescaleIntensExportToVideoDialog(self, how, channel, setImage=True): + if channel == self.user_ch_name: + lutItem = self.imgGrad + else: + lutItem = self.overlayLayersItems[channel][1] + + for action in lutItem.rescaleActionGroup.actions(): + if action.text() == how: + action.trigger() + # self.rescaleIntensitiesLut(setImage=setImage) + break + + def customLevelsLutChanged(self, levels, imageItem=None): + imageItem.setLevels(levels) + + def getPreComputedMinMaxZstack(self, channel: str): + if channel != self.user_ch_name: + return None + + posData = self.data[self.pos_i] + zstack_min, zstack_max = np.inf, 0 + for z in range(posData.SizeZ): + key = (self.pos_i, posData.frame_i, z) + levels = self.img1.minMaxValuesMapper.get(key) + if levels is None: + return + + img_min, img_max = levels + if img_min < zstack_min: + zstack_min = img_min + + if img_max > zstack_max: + zstack_max = img_max + + return (zstack_min, zstack_max) + + # @exec_time + def rescaleIntensitiesLut( + self, + action: QAction=None, + setImage: bool=True, + imageItem=None + ): + if not self.isDataLoaded: + self.logger.info( + 'WARNING: Data is not loaded. ' + 'Intensities will be rescaled later.' + ) + return + + posData = self.data[self.pos_i] + if imageItem is None: + imageItem = self.img1 + channel = self.user_ch_name + image_data = posData.img_data + else: + channel = imageItem.channelName + _, filename = self.getPathFromChName(channel, posData) + image_data = posData.fluo_data_dict[filename] + + triggeredByUser = True + if action is None: + triggeredByUser = False + action = imageItem.lutItem.rescaleActionGroup.checkedAction() + + how = action.text() + + setting, setting_value = ( + self.view_model.rescale_intensity_setting_update(channel, how) + ) + self.df_settings.at[setting, 'value'] = setting_value + self.df_settings.to_csv(self.settings_csv_path) + + if how == 'Rescale each 2D image': + if how == self.rescaleIntensChannelHowMapper[channel]: + # No need to update since we have autoscale + return + + imageItem.setEnableAutoLevels(True) + if setImage: + imageItem.setImage(imageItem.image) + return + + lutLevelsCh = posData.lutLevels[channel] + + if how == 'Rescale across z-stack': + imageItem.setEnableAutoLevels(False) + levels_key = (how, posData.frame_i) + levels = lutLevelsCh.get(levels_key) + if levels is None: + levels = self.getPreComputedMinMaxZstack(channel) + + if levels is None: + image_zstack = image_data[posData.frame_i] + levels = (image_zstack.min(), image_zstack.max()) + lutLevelsCh[levels_key] = levels + imageItem.setLevels(levels) + elif how == 'Rescale across time frames': + imageItem.setEnableAutoLevels(False) + levels_key = (how, None) + levels = lutLevelsCh.get(levels_key) + if levels is None: + levels = (image_data.min(), image_data.max()) + + lutLevelsCh[levels_key] = levels + imageItem.setLevels(levels) + elif how == 'Choose custom levels...': + autoLevelsEnabledBefore = imageItem.autoLevelsEnabled + imageItem.setEnableAutoLevels(False) + if triggeredByUser: + current_min, current_max = imageItem.getLevels() + dtype_max = np.iinfo(image_data.dtype).max + max_value = image_data.max() + min_value = image_data.min() + win = apps.SetCustomLevelsLut( + init_min_value=current_min, + init_max_value=current_max, + maximum_max_value=max_value, + minimum_min_value=min_value, + parent=self.host + ) + win.sigLevelsChanged.connect( + partial(self.customLevelsLutChanged, imageItem=imageItem) + ) + win.exec_() + if win.cancel: + imageItem.setEnableAutoLevels(autoLevelsEnabledBefore) + self.logger.info('Custom LUT levels setting cancelled.') + self.updateAllImages() + return + selectedLevels = win.selectedLevels + else: + selectedLevels = imageItem.getLevels() + imageItem.setLevels(selectedLevels) + elif how == 'Do no rescale, display raw image': + imageItem.setEnableAutoLevels(False) + levels_key = (how, None) + levels = lutLevelsCh.get(levels_key) + if levels is None: + dtype_max = np.iinfo(image_data.dtype).max + levels = (0, dtype_max) + lutLevelsCh[levels_key] = levels + imageItem.setLevels(levels) + + self.rescaleIntensChannelHowMapper[channel] = how + + if setImage: + imageItem.setImage(imageItem.image) + + def showMirroredCursorToggled(self, checked): + value = 'Yes' if checked else 'No' + self.df_settings.at['showMirroredCursor', 'value'] = value + self.df_settings.to_csv(settings_csv_path) + + if not checked: + self.clearCursors() + + def clearCursors(self): + self.ax1_cursor.setData([], []) + self.ax2_cursor.setData([], []) + self.setHoverToolSymbolData( + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + eraserCursors = ( + self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX + ) + self.setHoverToolSymbolData([], [], eraserCursors) + + def activeEraserCircleCursors(self, isHoverImg1): + if self.showMirroredCursorAction.isChecked(): + return self.ax1_EraserCircle, self.ax2_EraserCircle + + if isHoverImg1: + return self.ax1_EraserCircle, + else: + return self.ax2_EraserCircle, + + def activeEraserXCursors(self, isHoverImg1): + if self.showMirroredCursorAction.isChecked(): + return self.ax1_EraserX, self.ax2_EraserX + + if isHoverImg1: + return self.ax1_EraserX, + else: + return self.ax2_EraserX, + + def activeBrushCircleCursors(self, isHoverImg1): + if self.showMirroredCursorAction.isChecked(): + return self.ax1_BrushCircle, self.ax2_BrushCircle + + if isHoverImg1: + return self.ax1_BrushCircle, + else: + return self.ax2_BrushCircle, + + def initImgGradRescaleIntensitiesHowPreference(self): + posData = self.data[self.pos_i] + channelName = posData.user_ch_name + if f'how_rescale_intensities_{channelName}' not in self.df_settings.index: + return + + how = self.df_settings.at[ + f'how_rescale_intensities_{channelName}', 'value' + ] + self.imgGrad.setRescaleIntensitiesHow(how) + + # @exec_time + @exception_handler + def updateAllImages( + self, image=None, computePointsLayers=True, computeContours=True, + updateLookuptable=True + ): + self.clearAllItems() + + posData = self.data[self.pos_i] + + self.last_pos_i = self.pos_i + self.last_frame_i = posData.frame_i + + self.rescaleIntensitiesLut(setImage=False) + + self.setImageImg1(image=image) + self.setImageImg2(updateLookuptable=updateLookuptable) + + self.setOverlayImages() + + self.setOverlayLabelsItems() + self.setOverlaySegmMasks() + + if self.slideshowWin is not None: + self.slideshowWin.frame_i = posData.frame_i + self.slideshowWin.update_img() + + # self.update_rp() + + # Annotate ID and draw contours + delROIsIDs = self.setAllTextAnnotations() + self.setAllContoursImages( + delROIsIDs=delROIsIDs, compute=False + ) + + mode = self.modeComboBox.currentText() + self.drawAllMothBudLines() + if mode == 'Normal division: Lineage tree': + self.drawAllLineageTreeLines() + + self.highlightLostNew() + + if self.ccaTableWin is not None: # need to add for lin tree, later + zoomIDs = self.exporting_view.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + self.custom_annotations_view.doCustomAnnotation(0) + + self.annotate_rip_and_bin_IDs() + self.updateTempLayerKeepIDs() + self.whitelistUpdateTempLayer() + self.drawPointsLayers(computePointsLayers=computePointsLayers) + self.setManualBackgroundImage() + self.annotateAssignedObjsAcdcTrackerSecondStep() + + self.highlightSearchedID(self.highlightedID, force=True) + self.display_decorations_view.update_timestamp_frame() + + posData.visited = True + + def removeAxLimits(self): + self.ax1.vb.state['limits']['xLimits'] = [-1E307, +1E307] + self.ax1.vb.state['limits']['yLimits'] = [-1E307, +1E307] + + def resizeGui(self): + self.ax1.vb.state['limits']['xRange'] = [None, None] + self.ax1.vb.state['limits']['yRange'] = [None, None] + self.autoRange() + if self.ax1.getViewBox().state['limits']['xRange'][0] is not None: + self.bottomScrollArea._resizeVertical() + return + (xmin, xmax), (ymin, ymax) = self.ax1.viewRange() + maxYRange = int((ymax-ymin)*1.5) + maxXRange = int((xmax-xmin)*1.5) + self.ax1.setLimits( + maxYRange=maxYRange, + maxXRange=maxXRange + ) + self.bottomScrollArea._resizeVertical() + QTimer.singleShot(200, self.autoRange) + + def autoRange(self): + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.ax2.autoRange() + self.ax1.autoRange() + + def resetRange(self): + if self.ax1_viewRange is None: + return + xRange, yRange = self.ax1_viewRange + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.ax2.vb.setRange(xRange=xRange, yRange=yRange) + self.ax1.vb.setRange(xRange=xRange, yRange=yRange) + self.ax1_viewRange = None + self.isRangeReset = True diff --git a/cellacdc/views/label_editing_view.py b/cellacdc/views/label_editing_view.py new file mode 100644 index 000000000..4200de32c --- /dev/null +++ b/cellacdc/views/label_editing_view.py @@ -0,0 +1,806 @@ +"""Qt view adapter for label-editing workflows.""" + +from __future__ import annotations + +import math + +import numpy as np +import skimage.measure +from qtpy.QtCore import Qt +from qtpy.QtGui import QGuiApplication +from qtpy.QtWidgets import QAction + +from cellacdc import apps, disableWindow, exception_handler +from cellacdc.viewmodels.label_editing_viewmodel import ( + LabelEditingViewModel, +) + + +class LabelEditingView: + """Qt-facing adapter around manual label editing.""" + + LEGACY_METHODS = ( + 'mergeObjs_cb', + 'assignNewIDfromClickedID', + 'addYXcentroidToDf', + '_get_editID_info', + 'apply_manual_edits_to_lab_if_needed', + 'store_zslices_rp', + 'removeObjectFromRp', + 'get_zslices_rp', + '_update_zslices_rp', + 'update_rp', + 'delBorderObj', + 'delNewObj', + 'getClickedID', + 'deleteIDFromLab', + 'removeStoredContours', + 'deleteIDmiddleClick', + 'applyEditID', + 'changeIDfutureFrames', + 'getLastHoveredID', + 'getHoverID', + 'setHoverToolSymbolColor', + 'isPowerBrush', + 'isPowerEraser', + 'isPowerButton', + ) + + def __init__(self, host, view_model: LabelEditingViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def mergeObjs_cb(self, checked): + if not checked: + self.mergeObjsTempLine.setData([], []) + + def assignNewIDfromClickedID(self, clickedID: int, event): + posData = self.data[self.pos_i] + x, y = event.pos().x(), event.pos().y() + newID = self.setBrushID(return_val=True) + mapper = [(clickedID, newID)] + self.applyEditID(clickedID, posData.IDs.copy(), mapper, x, y) + + def addYXcentroidToDf(self, df): + posData = self.data[self.pos_i] + depth_axis = ( + self.switchPlaneCombobox.depthAxes() if self.isSegm3D else 'z' + ) + return self.view_model.edit_id.add_yx_centroids_to_df( + df, + posData.rp, + is_3d=self.isSegm3D, + depth_axis=depth_axis, + ) + + def _get_editID_info(self, df): + posData = self.data[self.pos_i] + depth_axis = ( + self.switchPlaneCombobox.depthAxes() if self.isSegm3D else 'z' + ) + return self.view_model.edit_id.edit_id_info_from_df( + df, + posData.rp, + is_3d=self.isSegm3D, + depth_axis=depth_axis, + ) + + def apply_manual_edits_to_lab_if_needed(self, lab): + posData = self.data[self.pos_i] + data_frame_i = posData.allData_li[posData.frame_i] + edited_lab_dict = data_frame_i['manually_edited_lab']['lab'] + if not self.view_model.should_apply_manual_edits(edited_lab_dict): + return lab + + # zoom_slice = data_frame_i['manually_edited_lab']['zoom_slice'] + for z, lab_edited in edited_lab_dict.items(): + if not self.isSegm3D: + # lab[zoom_slice] = lab_edited + lab = lab_edited + break + + lab[z] = lab_edited + + # lab[z, zoom_slice[0], zoom_slice[1]] = zoom_lab + + return lab + + def store_zslices_rp(self, force_update=False): + if not self.view_model.should_store_zslice_regionprops( + is_segm_3d=self.isSegm3D + ): + return + + posData = self.data[self.pos_i] + are_zslices_rp_stored = ( + posData.allData_li[posData.frame_i].get('z_slices_rp') is not None + ) + if self.view_model.should_update_zslice_regionprops( + force_update=force_update, + already_stored=are_zslices_rp_stored, + ): + self._update_zslices_rp() + + posData.allData_li[posData.frame_i]['z_slices_rp'] = posData.zSlicesRp + + def removeObjectFromRp(self, delID): + posData = self.data[self.pos_i] + rp = [] + IDs = [] + IDs_idxs = {} + idx = 0 + for obj in posData.rp: + if obj.label == delID: + continue + rp.append(obj) + IDs.append(obj.label) + IDs_idxs[obj.label] = idx + idx += 1 + + posData.rp = rp + posData.IDs = IDs + posData.IDs_idxs = IDs_idxs + + if not self.isSegm3D: + return + + zSlicesRp = {} + for z, zSliceRp in posData.zSlicesRp.items(): + if delID in zSliceRp: + continue + + zSlicesRp[z] = zSlicesRp + + posData.zSlicesRp = zSlicesRp + self.store_zslices_rp(force_update=True) + + def get_zslices_rp(self): + if not self.isSegm3D: + return + + posData = self.data[self.pos_i] + self.store_zslices_rp() + posData.zSlicesRp = posData.allData_li[posData.frame_i]['z_slices_rp'] + + # @exec_time + def _update_zslices_rp(self): + if not self.isSegm3D: + return + + posData = self.data[self.pos_i] + posData.zSlicesRp = {} + for z, lab2d in enumerate(posData.lab): + lab2d_rp = skimage.measure.regionprops(lab2d) + posData.zSlicesRp[z] = {obj.label:obj for obj in lab2d_rp} + + @exception_handler + def update_rp( + self, draw=True, debug=False, update_IDs=True, + wl_update=True, wl_track_og_curr=False,wl_update_lab=False + ): + + posData = self.data[self.pos_i] + # Update rp for current posData.lab (e.g. after any change) + + if wl_update: + if self.whitelistOriginalIDs is None: + old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() # for whitelist stuff + else: + old_IDs = self.whitelistOriginalIDs.copy() + self.whitelistOriginalIDs = None + elif self.whitelistOriginalIDs is None: + self.whitelist_old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() + + posData.rp = skimage.measure.regionprops(posData.lab) + if update_IDs: + IDs = [] + IDs_idxs = {} + for idx, obj in enumerate(posData.rp): + IDs.append(obj.label) + IDs_idxs[obj.label] = idx + posData.IDs = IDs + posData.IDs_idxs = IDs_idxs + self.update_rp_metadata(draw=draw) + self.store_zslices_rp(force_update=True) + + if not wl_update: + return + + # Update tracking whitelist + accepted_lost_centroids = self.getTrackedLostIDs() + new_IDs = posData.IDs + added_IDs = set(new_IDs) - set(old_IDs) + removed_IDs = ( + set(old_IDs) + - set(new_IDs) + - set(accepted_lost_centroids) + ) + + self.whitelistPropagateIDs( + IDs_to_add=added_IDs, IDs_to_remove=removed_IDs, + curr_frame_only=True, IDs_curr=new_IDs, + track_og_curr=wl_track_og_curr, + curr_lab=posData.lab, curr_rp=posData.rp, + update_lab=wl_update_lab + ) + + def delBorderObj(self, checked): + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + posData = self.data[self.pos_i] + clear_result = self.view_model.label_edits.clear_border_labels( + posData.lab, buffer_size=1 + ) + posData.lab = clear_result.labels + self.update_rp() + if posData.cca_df is not None: + deletion_result = self.view_model.cca_edits.delete_ids( + posData.cca_df, + clear_result.removed_ids, + ) + posData.cca_df = deletion_result.cca_df + self.store_data() + self.updateAllImages() + + def delNewObj(self, checked): + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + posData = self.data[self.pos_i] + frame_i = posData.frame_i + + if frame_i == 0: + return + + prev_IDs = posData.allData_li[frame_i-1]['IDs'] + curr_IDs = posData.IDs + removal_result = self.view_model.label_edits.remove_new_labels( + posData.lab, + prev_IDs, + curr_IDs, + ) + new_IDs = removal_result.removed_ids + posData.lab = removal_result.labels + + self.update_rp() + + if posData.cca_df is not None: + deletion_result = self.view_model.cca_edits.delete_ids( + posData.cca_df, + new_IDs, + ) + posData.cca_df = deletion_result.cca_df + self.store_data() + self.updateAllImages() + + def getClickedID(self, xdata, ydata, text=''): + posData = self.data[self.pos_i] + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if self.view_model.should_prompt_for_background_id(ID): + msg = ( + 'You clicked on the background.\n' + f'Enter here the ID {text}' + ) + nearest_ID = self.view_model.label_edits.nearest_nonzero_2d( + self.get_2Dlab(posData.lab), xdata, ydata + ) + clickedBkgrID = apps.QLineEditDialog( + title='Clicked on background', + msg=msg, parent=self.host, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + clickedBkgrID.exec_() + if clickedBkgrID.cancel: + return + else: + ID = clickedBkgrID.EntryID + return ID + + def deleteIDFromLab( + self, lab, delID, frame_i=None, delMask=None, shift=False + ): + posData = self.data[self.pos_i] + frame_i = posData.frame_i if frame_i is None else frame_i + + if shift and self.isSegm3D: + lab3D = lab + delMask3D = delMask + lab = self.get_2Dlab(lab) + if delMask is not None: + delMask = self.get_2Dlab(delMask) + rp = skimage.measure.regionprops(lab) + IDs_idxs = {obj.label: idx for idx, obj in enumerate(rp)} + else: + if frame_i==posData.frame_i: + rp = posData.rp + IDs_idxs = posData.IDs_idxs + else: + rp = posData.allData_li[frame_i]['regionprops'] + IDs_idxs = posData.allData_li[frame_i]['IDs_idxs'] + + if isinstance(delID, int): + delID = [delID] + + is_any_id_present = False + for _delID in delID: + if _delID in IDs_idxs: + is_any_id_present = True + break + + if not is_any_id_present: + return lab, delMask + + if delMask is None: + delMask = np.zeros(lab.shape, dtype=bool) + else: + delMask[:] = False + + for _delID in delID: + idx = IDs_idxs.get(_delID, None) + if idx is None: + continue + obj = rp[idx] + delMask[obj.slice][obj.image] = True + lab[delMask] = 0 + + if shift and self.isSegm3D: + self.set_2Dlab(lab, lab3D=lab3D) + lab = lab3D + if delMask3D is not None: + self.set_2Dlab(delMask, lab3D=delMask3D) + delMask = delMask3D + + return lab, delMask + + def removeStoredContours(self, delID, frame_i=None, z_slice=None): + posData = self.data[self.pos_i] + + if frame_i is None: + frame_i = posData.frame_i + + dataDict = posData.allData_li[posData.frame_i] + try: + newContours = {} + for key, contours in dataDict['contours'].items(): + ID = key[0] + if ID == delID: + continue + + if z_slice is not None: + z_slice_i = key[1] + if z_slice_i != z_slice: + continue + + newContours[key] = contours + + dataDict['contours'] = newContours + except KeyError as err: + pass + + @disableWindow + def deleteIDmiddleClick( + self, delIDs: Iterable, applyFutFrames, includeUnvisited, + shift=False + ): + self.clearHighlightedID() + + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + + # Apply Delete ID to future frames if requested + if applyFutFrames: + delMask = np.zeros(posData.lab.shape, dtype=bool) + # Store current data before going to future frames + self.store_data() + segmSizeT = len(posData.segm_data) + for i in range(posData.frame_i+1, segmSizeT): + lab = posData.allData_li[i]['labels'] + if lab is None and not includeUnvisited: + self.enqAutosave() + break + + if lab is not None: + # Visited frame + lab, _ = self.deleteIDFromLab( + lab, delIDs, frame_i=i, delMask=delMask, shift=shift + ) + + # Store change + posData.allData_li[i]['labels'] = lab + # Get the rest of the stored metadata based on the new lab + posData.frame_i = i + self.get_data() + self.store_data(autosave=False) + elif includeUnvisited: + # Unvisited frame (includeUnvisited = True) + lab = posData.segm_data[i] + lab, _ = self.deleteIDFromLab( + lab, delIDs, frame_i=i, delMask=delMask, shift=shift + ) + + # Back to current frame + if applyFutFrames: + posData.frame_i = current_frame_i + self.get_data() + + z_slice = None + if shift and self.isSegm3D: + z_slice = self.z_lab() + + posData.lab, delID_mask = self.deleteIDFromLab( + posData.lab, delIDs, shift=shift + ) + for _delID in delIDs: + self.clearObjContour(ID=_delID, ax=0) + self.clearObjContour(ID=_delID, ax=1) + if z_slice is None: + self.removeObjectFromRp(_delID) + self.removeStoredContours(_delID, z_slice=z_slice) + + if shift and self.isSegm3D: + self.update_rp() + + self.store_data(autosave=False) + self.whitelistPropagateIDs( + IDs_to_remove=delIDs, curr_frame_only=(not applyFutFrames) + ) + return delID_mask + + # @exec_time + def applyEditID( + self, clickedID, currentIDs, oldIDnewIDMapper, clicked_x, clicked_y, shift=False, doPropagateUnvisited=False + ): + posData = self.data[self.pos_i] + + # Ask to propagate change to all future visited frames + key = 'Edit ID' + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + clickedID, key, doNotShow, + posData.UndoFutFrames_EditID, posData.applyFutFrames_EditID, + applyTrackingB=True + ) + + if UndoFutFrames is None: + return + + if shift and self.isSegm3D: + lab = self.get_2Dlab(posData.lab) + else: + lab = posData.lab + + # Store undo state before modifying stuff + self.storeUndoRedoStates(UndoFutFrames) + maxID = max(posData.IDs, default=0) + for old_ID, new_ID in oldIDnewIDMapper: + result = self.view_model.label_edits.apply_id_mapping( + lab, + [(old_ID, new_ID)], + existing_ids=currentIDs, + merge_existing=self.editIDmergeIDs, + start_max_id=maxID, + ) + maxID = result.max_id + if new_ID in currentIDs and not self.editIDmergeIDs: + old_ID_idx = currentIDs.index(old_ID) + new_ID_idx = currentIDs.index(new_ID) + + # Append information for replicating the edit in tracking + # List of tuples (y, x, replacing ID) + objo = posData.rp[old_ID_idx] + yo, xo = self.getObjCentroid(objo.centroid) + objn = posData.rp[new_ID_idx] + yn, xn = self.getObjCentroid(objn.centroid) + if not math.isnan(yo) and not math.isnan(yn): + yn, xn = int(yn), int(xn) + posData.editID_info.append((yn, xn, new_ID)) + yo, xo = int(clicked_y), int(clicked_x) + posData.editID_info.append((yo, xo, old_ID)) + else: + old_ID_idx = posData.IDs.index(old_ID) + + # Append information for replicating the edit in tracking + # List of tuples (y, x, replacing ID) + obj = posData.rp[old_ID_idx] + y, x = self.getObjCentroid(obj.centroid) + if not math.isnan(y) and not math.isnan(y): + y, x = int(y), int(x) + posData.editID_info.append((y, x, new_ID)) + + self.updateAssignedObjsAcdcTrackerSecondStep(new_ID) + + if shift and self.isSegm3D: + self.set_2Dlab(lab) + + # Update rps + self.update_rp() + + # Since we manually changed an ID we don't want to repeat tracking + self.setAllTextAnnotations() + self.highlightLostNew() + # self.checkIDsMultiContour() + + # Update colors for the edited IDs + self.updateLookuptable() + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Edit ID') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Edit ID', update_images=False) + + if not self.editIDbutton.findChild(QAction).isChecked(): + self.editIDbutton.setChecked(False) + + posData.disableAutoActivateViewerWindow = True + + # Perform desired action on future frames + posData.doNotShowAgain_EditID = doNotShowAgain + posData.UndoFutFrames_EditID = UndoFutFrames + posData.applyFutFrames_EditID = applyFutFrames + includeUnvisited = ( + posData.includeUnvisitedInfo['Edit ID'] + or doPropagateUnvisited + ) + + if not applyFutFrames and not doPropagateUnvisited: + return + + self.changeIDfutureFrames( + endFrame_i, oldIDnewIDMapper, includeUnvisited, + shift=shift + ) + + def changeIDfutureFrames( + self, endFrame_i, oldIDnewIDMapper, includeUnvisited, + shift=False + ): + posData = self.data[self.pos_i] + self.current_frame_i = posData.frame_i + + # Store data for current frame + self.store_data() + if endFrame_i is None: + self.app.restoreOverrideCursor() + return + + segmSizeT = len(posData.segm_data) + for i in range(posData.frame_i+1, segmSizeT): + lab = posData.allData_li[i]['labels'] + if lab is None and not includeUnvisited: + self.enqAutosave() + break + + if lab is not None: + # Visited frame + posData.frame_i = i + self.get_data(lin_tree_init=False) + if shift and self.isSegm3D: + lab = self.get_2Dlab(posData.lab) + else: + lab = posData.lab + + if self.onlyTracking: + self.tracking(enforce=True) + elif not posData.IDs: + continue + else: + maxID = max(posData.IDs, default=0) + 1 + for old_ID, new_ID in oldIDnewIDMapper: + result = self.view_model.label_edits.apply_id_mapping( + lab, + [(old_ID, new_ID)], + start_max_id=maxID, + ) + maxID = result.max_id + + if shift and self.isSegm3D: + self.set_2Dlab(lab) + + self.update_rp(draw=False) + self.store_data(autosave=i==endFrame_i) + elif includeUnvisited: + # Unvisited frame (includeUnvisited = True) + lab = posData.segm_data[i] + if shift and self.isSegm3D: + lab = self.get_2Dlab(lab) + else: + lab = lab + + for old_ID, new_ID in oldIDnewIDMapper: + self.view_model.label_edits.apply_id_mapping( + lab, [(old_ID, new_ID)] + ) + + if shift and self.isSegm3D: + posData.segm_data[i][self.z_lab()] = lab + + # Back to current frame + posData.frame_i = self.current_frame_i + self.get_data() + self.app.restoreOverrideCursor() + + def getLastHoveredID(self): + if self.xHoverImg is None: + return 0 + + xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) + ID = self.currentLab2D[ydata, xdata] + return ID + + def getHoverID(self, xdata, ydata, byPassShiftCheck=False): + if not hasattr(self, 'diskMask'): + return 0 + + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + if byPassShiftCheck: + shift = False + else: + shift = modifiers == Qt.ShiftModifier + + if self.isPowerBrush() and not ctrl: + return 0 + + if not self.autoIDcheckbox.isChecked(): + return self.editIDspinbox.value() + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + ID = lab_2D[ydata, xdata] + self.isHoverZneighID = False + if self.isSegm3D: + z = self.z_lab() + SizeZ = posData.lab.shape[0] + doNotLinkThroughZ = self.brushButton.isChecked() and shift + if doNotLinkThroughZ: + if self.brushHoverCenterModeAction.isChecked() or ID>0: + hoverID = ID + else: + masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] + hoverID = np.bincount(masked_lab).argmax() + else: + if z > 0: + ID_z_under = posData.lab[z-1, ydata, xdata] + if self.brushHoverCenterModeAction.isChecked() or ID_z_under>0: + hoverIDa = ID_z_under + else: + lab = posData.lab + masked_lab_a = lab[z-1, ymin:ymax, xmin:xmax][diskMask] + hoverIDa = np.bincount(masked_lab_a).argmax() + else: + hoverIDa = 0 + + if self.brushHoverCenterModeAction.isChecked() or ID>0: + hoverIDb = lab_2D[ydata, xdata] + else: + masked_lab_b = lab_2D[ymin:ymax, xmin:xmax][diskMask] + hoverIDb = np.bincount(masked_lab_b).argmax() + + if z < SizeZ-1: + ID_z_above = posData.lab[z+1, ydata, xdata] + if self.brushHoverCenterModeAction.isChecked() or ID_z_above>0: + hoverIDc = ID_z_above + else: + lab = posData.lab + masked_lab_c = lab[z+1, ymin:ymax, xmin:xmax][diskMask] + hoverIDc = np.bincount(masked_lab_c).argmax() + else: + hoverIDc = 0 + + if hoverIDa > 0: + hoverID = hoverIDa + self.isHoverZneighID = True + elif hoverIDb > 0: + hoverID = hoverIDb + elif hoverIDc > 0: + hoverID = hoverIDc + self.isHoverZneighID = True + else: + hoverID = 0 + else: + if self.view_model.should_force_new_hover_id( + brush_active=self.brushButton.isChecked(), + shift_pressed=shift, + ): + # Force new ID with brush and Shift + hoverID = 0 + elif self.brushHoverCenterModeAction.isChecked() or ID>0: + hoverID = ID + else: + masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] + hoverID = np.bincount(masked_lab).argmax() + + self.editIDspinbox.setValue(hoverID) + + return hoverID + + def setHoverToolSymbolColor( + self, xdata, ydata, pen, ScatterItems, button, + brush=None, hoverRGB=None, ID=None, byPassShiftCheck=False + ): + modifiers = QGuiApplication.keyboardModifiers() + if byPassShiftCheck: + shift = False + else: + shift = modifiers == Qt.ShiftModifier + + posData = self.data[self.pos_i] + Y, X = self.get_2Dlab(posData.lab).shape + if not self.view_model.geometry.is_in_bounds(xdata, ydata, X, Y): + return + + self.isHoverZneighID = False + if ID is None: + hoverID = self.getHoverID( + xdata, ydata, byPassShiftCheck=byPassShiftCheck + ) + else: + hoverID = ID + + if hoverID == 0: + for item in ScatterItems: + item.setPen(pen) + item.setBrush(brush) + else: + try: + rgb = self.lut[hoverID] + rgb = rgb if hoverRGB is None else hoverRGB + rgbPen = np.clip(rgb*1.1, 0, 255) + for item in ScatterItems: + item.setPen(*rgbPen, width=2) + item.setBrush(*rgb, 100) + except IndexError: + pass + + checkChangeID = self.view_model.should_restore_brush_id_from_hover( + is_hover_z_neighbor=self.isHoverZneighID, + shift_pressed=shift, + last_hover_id=self.lastHoverID, + hover_id=hoverID, + ) + if checkChangeID: + # We are hovering an ID in z+1 or z-1 + self.restoreBrushID = hoverID + # self.changeBrushID() + + self.lastHoverID = hoverID + + def isPowerBrush(self): + color = self.brushButton.palette().button().color().name() + return self.view_model.is_power_button_color( + button_color=color, + power_color=self.doublePressKeyButtonColor, + ) + + def isPowerEraser(self): + color = self.eraserButton.palette().button().color().name() + return self.view_model.is_power_button_color( + button_color=color, + power_color=self.doublePressKeyButtonColor, + ) + + def isPowerButton(self, button): + color = button.palette().button().color().name() + return self.view_model.is_power_button_color( + button_color=color, + power_color=self.doublePressKeyButtonColor, + ) diff --git a/cellacdc/views/label_roi_view.py b/cellacdc/views/label_roi_view.py new file mode 100644 index 000000000..61c6e6698 --- /dev/null +++ b/cellacdc/views/label_roi_view.py @@ -0,0 +1,538 @@ +"""Qt view adapter for label-ROI workflows.""" + +from __future__ import annotations + +import numpy as np +from qtpy.QtCore import QMutex, Qt, QThread, QWaitCondition +from qtpy.QtGui import QCursor +from qtpy.QtWidgets import QAction, QMenu + +from cellacdc import ( + apps, + config, + exception_handler, + html_utils, + qutils, + settings_folderpath, + widgets, + workers, +) +from cellacdc.viewmodels.label_roi_viewmodel import LabelRoiViewModel + + +class LabelRoiView: + """Qt-facing adapter around Magic Labeller ROI workflows.""" + + LEGACY_METHODS = ( + 'labelRoiCancelled', + 'labelRoiCheckStartStopFrame', + 'getSecondChannelData', + 'labelRoiToEndFramesTriggered', + 'labelRoiFromCurrentFrameTriggered', + 'showLabelRoiContextMenu', + 'initLabelRoiModel', + 'labelRoiViewCurrentModel', + 'storeLabelRoiParams', + 'loadLabelRoiLastParams', + 'updateLabelRoiCircularSize', + 'updateLabelRoiCircularCursor', + 'getLabelRoiImage', + 'labelRoiTrangeCheckboxToggled', + 'labelRoi_cb', + 'labelRoiWorkerFinished', + 'indexRoiLab', + 'labelRoiDone', + ) + + def __init__(self, host, view_model: LabelRoiViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def labelRoiCancelled(self): + self.labelRoiRunning = False + self.app.restoreOverrideCursor() + self.labelRoiItem.setPos((0,0)) + self.labelRoiItem.setSize((0,0)) + self.freeRoiItem.clear() + self.logger.info('Magic labeller process cancelled.') + + def labelRoiCheckStartStopFrame(self): + enabled = self.labelRoiTrangeCheckbox.isChecked() + start_n = self.labelRoiStartFrameNoSpinbox.value() + stop_n = self.labelRoiStopFrameNoSpinbox.value() + if self.view_model.is_frame_range_valid(enabled, start_n, stop_n): + return True + + self.blinker = qutils.QControlBlink( + self.labelRoiStopFrameNoSpinbox, + qparent=self.host + ) + self.blinker.start() + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + Stop frame number is less than start frame number!

+ What do you want to do? + """) + msg.warning( + self.host, 'Stop frame number lower than start', txt, + buttonsTexts=('Cancel', 'Segment only current frame') + ) + if msg.cancel: + return False + + posData = self.data[self.pos_i] + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) + self.labelRoiStopFrameNoSpinbox.setValue(posData.frame_i+1) + + def getSecondChannelData(self): + if self.secondChannelName is None: + return + + posData = self.data[self.pos_i] + + fluo_ch = self.secondChannelName + fluo_path, filename = self.getPathFromChName(fluo_ch, posData) + if filename in posData.fluo_data_dict: + fluo_data = posData.fluo_data_dict[filename] + else: + fluo_data, bkgrData = self.load_fluo_data(fluo_path) + posData.fluo_data_dict[filename] = fluo_data + posData.fluo_bkgrData_dict[filename] = bkgrData + + start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 + stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() + tRangeLen = self.view_model.frame_range_length( + self.labelRoiTrangeCheckbox.isChecked(), + start_frame_i, + stop_frame_n, + ) + + if tRangeLen > 1: + # fluo_img_data = fluo_data[start_frame_i:stop_frame_n] + if self.isSegm3D or posData.SizeZ == 1: + return fluo_data + else: + T, Z, Y, X = fluo_data.shape + secondChannelData = np.zeros((T, Y, X), dtype=fluo_data.dtype) + for frame_i, fluo_img in enumerate(fluo_data): + secondChannelData[frame_i] = self.get_2Dimg_from_3D( + fluo_data, frame_i=frame_i + ) + return secondChannelData + else: + if posData.SizeT > 1: + fluo_img_data = fluo_data[posData.frame_i] + else: + fluo_img_data = fluo_data + + if self.isSegm3D or posData.SizeZ == 1: + return fluo_img_data + else: + return self.get_2Dimg_from_3D(fluo_img_data) + + def labelRoiToEndFramesTriggered(self): + posData = self.data[self.pos_i] + self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) + + def labelRoiFromCurrentFrameTriggered(self): + posData = self.data[self.pos_i] + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) + + def showLabelRoiContextMenu(self, event): + menu = QMenu(self.labelRoiButton) + action = QAction('Re-initialize magic labeller model...') + action.triggered.connect(self.initLabelRoiModel) + menu.addAction(action) + menu.exec_(QCursor.pos()) + + def initLabelRoiModel(self): + self.app.restoreOverrideCursor() + # Ask which model + self.initLabelRoiModelDialog = apps.QDialogSelectModel( + parent=self.host + ) + self.initLabelRoiModelDialog.exec_() + if self.initLabelRoiModelDialog.cancel: + self.logger.info('Magic labeller aborted.') + self.initLabelRoiModelDialog = None + return True + self.app.setOverrideCursor(Qt.WaitCursor) + model_name = self.initLabelRoiModelDialog.selectedModel + self.labelRoiModel = self.repeatSegm( + model_name=model_name, askSegmParams=True, + is_label_roi=True + ) + if self.labelRoiModel is None: + self.initLabelRoiModelDialog = None + return True + self.labelRoiViewCurrentModelAction.setDisabled(False) + self.initLabelRoiModelDialog = None + return False + + def labelRoiViewCurrentModel(self): + ini_path = self.view_model.model_params_ini_path(settings_folderpath) + configPars = config.ConfigParser() + configPars.read(ini_path) + model_name = self.labelRoiModel.model_name + txt = f'Model: {model_name}' + SECTION = f'{model_name}.init' + txt = f'{txt}

[Initialization parameters]
' + for option in configPars.options(SECTION): + value = configPars[SECTION][option] + param_txt = f'{option} = {value}
' + txt = f'{txt}{param_txt}' + + SECTION = f'{model_name}.segment' + txt = f'{txt}
[Segmentation parameters]
' + for option in configPars.options(SECTION): + value = configPars[SECTION][option] + param_txt = f'{option} = {value}
' + txt = f'{txt}{param_txt}' + + win = apps.ViewTextDialog(txt, parent=self.host) + win.exec_() + + def storeLabelRoiParams(self, value=None, checked=True): + checkedRoiType = self.labelRoiTypesGroup.checkedButton().text() + circRoiRadius = self.labelRoiCircularRadiusSpinbox.value() + roiZdepth = self.labelRoiZdepthSpinbox.value() + autoClearBorder = self.labelRoiAutoClearBorderCheckbox.isChecked() + params = self.view_model.params_settings( + checked_roi_type=checkedRoiType, + circ_roi_radius=circRoiRadius, + roi_zdepth=roiZdepth, + auto_clear_border=autoClearBorder, + replace_existing_objects=( + self.labelRoiReplaceExistingObjectsCheckbox.isChecked() + ), + ) + for setting, setting_value in params.updates.items(): + self.df_settings.at[setting, 'value'] = setting_value + self.df_settings.to_csv(self.settings_csv_path) + + def loadLabelRoiLastParams(self): + idx = 'labelRoi_checkedRoiType' + if idx in self.df_settings.index: + checkedRoiType = self.df_settings.at[idx, 'value'] + for button in self.labelRoiTypesGroup.buttons(): + if button.text() == checkedRoiType: + button.setChecked(True) + break + + idx = 'labelRoi_circRoiRadius' + if idx in self.df_settings.index: + circRoiRadius = self.df_settings.at[idx, 'value'] + self.labelRoiCircularRadiusSpinbox.setValue(int(circRoiRadius)) + + idx = 'labelRoi_roiZdepth' + if idx in self.df_settings.index: + roiZdepth = self.df_settings.at[idx, 'value'] + self.labelRoiZdepthSpinbox.setValue(int(roiZdepth)) + + idx = 'labelRoi_autoClearBorder' + if idx in self.df_settings.index: + clearBorder = self.df_settings.at[idx, 'value'] + checked = self.view_model.checked_from_setting_value(clearBorder) + self.labelRoiAutoClearBorderCheckbox.setChecked(checked) + + idx = 'labelRoi_replaceExistingObjects' + if idx in self.df_settings.index: + val = self.df_settings.at[idx, 'value'] + checked = self.view_model.checked_from_setting_value(val) + self.labelRoiReplaceExistingObjectsCheckbox.setChecked(checked) + + if self.labelRoiIsCircularRadioButton.isChecked(): + self.labelRoiCircularRadiusSpinbox.setDisabled(False) + + def updateLabelRoiCircularSize(self, value): + self.labelRoiCircItemLeft.setSize(value) + self.labelRoiCircItemRight.setSize(value) + + def updateLabelRoiCircularCursor(self, x, y, checked): + size = self.labelRoiCircularRadiusSpinbox.value() + existing_cursor_empty = len(self.labelRoiCircItemLeft.getData()[0]) == 0 + if not self.view_model.should_show_circular_cursor( + label_roi_checked=self.labelRoiButton.isChecked(), + circular_roi_checked=self.labelRoiIsCircularRadioButton.isChecked(), + label_roi_running=self.labelRoiRunning, + cursor_checked=checked, + existing_cursor_empty=existing_cursor_empty, + ): + return + xx, yy = self.view_model.cursor_points(x, y, checked) + + self.labelRoiCircItemLeft.setData(xx, yy, size=size) + self.labelRoiCircItemRight.setData(xx, yy, size=size) + + def getLabelRoiImage(self): + posData = self.data[self.pos_i] + + start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 + stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() + frame_range_enabled = self.labelRoiTrangeCheckbox.isChecked() + tRangeLen = self.view_model.frame_range_length( + frame_range_enabled, + start_frame_i, + stop_frame_n, + ) + tRange = self.view_model.time_range( + frame_range_enabled, + start_frame_i, + stop_frame_n, + ) + + if self.isSegm3D: + if tRangeLen > 1: + imgData = posData.img_data + else: + # Filtered data not existing + imgData = posData.img_data[posData.frame_i] + + roi_zdepth = self.labelRoiZdepthSpinbox.value() + z0, z1 = self.view_model.z_range( + roi_zdepth, + posData.SizeZ, + self.zSliceScrollBar.sliderPosition(), + ) + + if self.labelRoiIsRectRadioButton.isChecked(): + labelRoiSlice = self.labelRoiItem.slice( + zRange=(z0,z1), tRange=tRange + ) + elif self.labelRoiIsFreeHandRadioButton.isChecked(): + labelRoiSlice = self.freeRoiItem.slice( + zRange=(z0,z1), tRange=tRange + ) + elif self.labelRoiIsCircularRadioButton.isChecked(): + labelRoiSlice = self.labelRoiCircItemLeft.slice( + zRange=(z0,z1), tRange=tRange + ) + else: + if self.labelRoiIsRectRadioButton.isChecked(): + labelRoiSlice = self.labelRoiItem.slice(tRange=tRange) + elif self.labelRoiIsFreeHandRadioButton.isChecked(): + labelRoiSlice = self.freeRoiItem.slice(tRange=tRange) + elif self.labelRoiIsCircularRadioButton.isChecked(): + labelRoiSlice = self.labelRoiCircItemLeft.slice(tRange=tRange) + if tRangeLen > 1: + imgData = posData.img_data + else: + imgData = self.img1.image + + roiImg = imgData[labelRoiSlice] + if self.labelRoiIsFreeHandRadioButton.isChecked(): + mask = self.freeRoiItem.mask() + elif self.labelRoiIsCircularRadioButton.isChecked(): + mask = self.labelRoiCircItemLeft.mask() + else: + mask = None + + if mask is not None: + # Copy roiImg otherwise we are replacing minimum inside original image + roiImg = roiImg.copy() + # Fill outside of freehand roi with minimum of the ROI image + if tRangeLen > 1: + for i in range(tRangeLen): + ith_roiImg = roiImg[i] + if self.isSegm3D: + roiImg[i, :, ~mask] = ith_roiImg.min() + else: + roiImg[i, ~mask] = ith_roiImg.min() + else: + if self.isSegm3D: + roiImg[:, ~mask] = roiImg.min() + else: + roiImg[~mask] = roiImg.min() + + return roiImg, labelRoiSlice + + def labelRoiTrangeCheckboxToggled(self, checked): + enabled = self.view_model.should_enable_range_controls(checked) + disabled = not enabled + self.labelRoiStartFrameNoSpinbox.setDisabled(disabled) + self.labelRoiStopFrameNoSpinbox.setDisabled(disabled) + self.labelRoiStartFrameNoSpinbox.label.setDisabled(disabled) + self.labelRoiStopFrameNoSpinbox.label.setDisabled(disabled) + self.labelRoiToEndFramesAction.setDisabled(disabled) + self.labelRoiFromCurrentFrameAction.setDisabled(disabled) + + if not enabled: + return + + posData = self.data[self.pos_i] + + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) + self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) + + def labelRoi_cb(self, checked): + posData = self.data[self.pos_i] + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.labelRoiButton) + self.connectLeftClickButtons() + + self.labelRoiStartFrameNoSpinbox.setMaximum(posData.SizeT) + self.labelRoiStopFrameNoSpinbox.setMaximum(posData.SizeT) + + if self.labelRoiActiveWorkers: + lastActiveWorker = self.labelRoiActiveWorkers[-1] + self.labelRoiGarbageWorkers.append(lastActiveWorker) + lastActiveWorker.finished.emit() + self.logger.info('Collected garbage w5orker (magic labeller).') + + self.labelRoiToolbar.setVisible(True) + if self.isSegm3D: + self.labelRoiZdepthSpinbox.setDisabled(False) + else: + self.labelRoiZdepthSpinbox.setDisabled(True) + + # Start thread and pause it + self.labelRoiThread = QThread() + self.labelRoiMutex = QMutex() + self.labelRoiWaitCond = QWaitCondition() + + labelRoiWorker = workers.LabelRoiWorker(self.host) + + labelRoiWorker.moveToThread(self.labelRoiThread) + labelRoiWorker.finished.connect(self.labelRoiThread.quit) + labelRoiWorker.finished.connect(labelRoiWorker.deleteLater) + self.labelRoiThread.finished.connect( + self.labelRoiThread.deleteLater + ) + + labelRoiWorker.finished.connect(self.labelRoiWorkerFinished) + labelRoiWorker.sigLabellingDone.connect(self.labelRoiDone) + labelRoiWorker.sigProgressBar.connect(self.workerUpdateProgressbar) + + labelRoiWorker.progress.connect(self.workerProgress) + labelRoiWorker.critical.connect(self.workerCritical) + + self.labelRoiActiveWorkers.append(labelRoiWorker) + + self.labelRoiThread.started.connect(labelRoiWorker.run) + self.labelRoiThread.start() + + # Add the rectROI to ax1 + self.ax1.addItem(self.labelRoiItem) + elif self.initLabelRoiModelDialog is not None: + # User is using other tools while the dialog is still open + # --> we allow this because it's useful to be able to use + # the ruler or check things --> do nothing + pass + else: + self.labelRoiToolbar.setVisible(False) + + for worker in self.labelRoiActiveWorkers: + worker._stop() + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + self.labelRoiItem.setPos((0,0)) + self.labelRoiItem.setSize((0,0)) + self.freeRoiItem.clear() + self.ax1.removeItem(self.labelRoiItem) + self.updateLabelRoiCircularCursor(None, None, False) + + def labelRoiWorkerFinished(self): + self.logger.info('Magic labeller closed.') + worker = self.labelRoiActiveWorkers.pop(-1) + + def indexRoiLab(self, roiLab, roiLabSlice, lab, brushID): + result = self.host.view_model.label_edits.index_label_roi( + lab, + roiLab, + roiLabSlice, + brushID, + clear_border=self.labelRoiAutoClearBorderCheckbox.isChecked(), + replace_existing=( + self.labelRoiReplaceExistingObjectsCheckbox.isChecked() + ), + ) + return result.labels + + @exception_handler + def labelRoiDone(self, roiSegmData, isTimeLapse): + self.setDisabled(False) + + posData = self.data[self.pos_i] + self.setBrushID() + + if isTimeLapse: + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.mainPbar.setValue(0) + current_frame_i = posData.frame_i + start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 + for i, roiLab in enumerate(roiSegmData): + frame_i = start_frame_i + i + lab = posData.allData_li[frame_i]['labels'] + store = True + if lab is None: + if frame_i >= len(posData.segm_data): + lab = np.zeros_like(posData.segm_data[0]) + posData.segm_data = np.append( + posData.segm_data, lab[np.newaxis], axis=0 + ) + else: + lab = posData.segm_data[frame_i] + store = False + roiLabSlice = self.labelRoiSlice[1:] + lab = self.indexRoiLab( + roiLab, roiLabSlice, lab, posData.brushID + ) + if store: + posData.frame_i = frame_i + posData.allData_li[frame_i]['labels'] = lab.copy() + self.get_data() + self.store_data(autosave=False) + + # Back to current frame + posData.frame_i = current_frame_i + self.get_data() + else: + roiLab = roiSegmData + posData.lab = self.indexRoiLab( + roiLab, self.labelRoiSlice, posData.lab, posData.brushID + ) + + self.update_rp() + + # Repeat tracking + if self.autoIDcheckbox.isChecked(): + self.tracking(enforce=True, assign_unique_new_IDs=False) + + self.store_data() + self.updateAllImages() + + self.labelRoiItem.setPos((0,0)) + self.labelRoiItem.setSize((0,0)) + self.freeRoiItem.clear() + self.logger.info('Magic labeller done!') + self.app.restoreOverrideCursor() + + self.labelRoiRunning = False + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + uncheckLabelRoiTRange = self.view_model.should_uncheck_time_range( + time_range_checked=self.labelRoiTrangeCheckbox.isChecked(), + persistent_action_checked=( + self.labelRoiTrangeCheckbox.findChild(QAction).isChecked() + ), + ) + if uncheckLabelRoiTRange: + self.labelRoiTrangeCheckbox.setChecked(False) diff --git a/cellacdc/views/label_transform_tools_view.py b/cellacdc/views/label_transform_tools_view.py new file mode 100644 index 000000000..2debf687a --- /dev/null +++ b/cellacdc/views/label_transform_tools_view.py @@ -0,0 +1,218 @@ +"""View adapter for label transform tools.""" + +from __future__ import annotations + +import skimage.measure + +from cellacdc.viewmodels.label_transform_tools_viewmodel import ( + LabelTransformToolsViewModel, +) + + +class LabelTransformToolsView: + """Qt-facing adapter around label transform tool contracts.""" + + def __init__(self, host, view_model: LabelTransformToolsViewModel): + self.host = host + self.view_model = view_model + + def reset_expand_label(self): + self.host.expandingID = self.view_model.reset_expand_label_id() + + def expand_label_callback(self, checked): + if checked: + self.host.disconnectLeftClickButtons() + self.host.uncheckLeftClickButtons(self.host.expandLabelToolButton) + self.host.connectLeftClickButtons() + self.host.expandFootprintSize = 1 + return + + self.host.clearHighlightedID() + alpha = self.host.imgGrad.labelsAlphaSlider.value() + self.host.labelsLayerImg1.setOpacity(alpha) + self.host.labelsLayerRightImg.setOpacity(alpha) + self.host.hoverLabelID = 0 + self.host.expandingID = 0 + self.host.updateAllImages() + + def expand_label(self, dilation=True): + pos_data = self.host.data[self.host.pos_i] + if self.host.hoverLabelID == 0: + self.host.isExpandingLabel = False + return + + reinit_expanding_lab = ( + self.view_model.should_reinitialize_expansion( + expanding_id=self.host.expandingID, + hover_label_id=self.host.hoverLabelID, + dilation=dilation, + is_dilation=self.host.isDilation, + ) + ) + label_id = self.host.hoverLabelID + obj = pos_data.rp[pos_data.IDs.index(label_id)] + + if reinit_expanding_lab: + self.host.storeUndoRedoStates(False) + self.host.isExpandingLabel = True + self.host.expandingID = label_id + self.host.expandingLab = None + self.host.expandFootprintSize = 1 + + lab_2d = self.host.get_2Dlab(pos_data.lab) + resize_result = self.host.view_model.label_edits.resize_label_object( + lab_2d, + self.host.currentLab2D, + obj.coords, + self.host.expandingID, + self.host.expandFootprintSize, + dilation=dilation, + seed_labels=self.host.expandingLab, + ) + self.host.expandingLab = resize_result.seed_labels + self.host.isDilation = dilation + previous_coords = resize_result.previous_coords + expanded_obj_coords = resize_result.resized_coords + + self.host.set_2Dlab(lab_2d) + self.host.currentLab2D = lab_2d + self.host.update_rp() + + if self.host.labelsGrad.showLabelsImgAction.isChecked(): + self.host.img2.setImage(img=self.host.currentLab2D, autoLevels=False) + + self.set_temp_img_expand_label(previous_coords, expanded_obj_coords) + + def start_moving_label(self, x_pos, y_pos): + pos_data = self.host.data[self.host.pos_i] + x_data, y_data = int(x_pos), int(y_pos) + lab_2d = self.host.get_2Dlab(pos_data.lab) + label_id = lab_2d[y_data, x_data] + if not self.view_model.should_start_moving_label(label_id): + self.host.isMovingLabel = False + return + + self.host.isMovingLabel = True + self.host.searchedIDitemRight.setData([], []) + self.host.searchedIDitemLeft.setData([], []) + self.host.movingID = label_id + self.host.prevMovePos = (x_data, y_data) + moving_obj = pos_data.rp[pos_data.IDs.index(label_id)] + self.host.movingObjCoords = moving_obj.coords.copy() + yy, xx = moving_obj.coords[:, -2], moving_obj.coords[:, -1] + self.host.currentLab2D[yy, xx] = 0 + + def move_label(self, x_pos, y_pos): + pos_data = self.host.data[self.host.pos_i] + lab_2d = self.host.get_2Dlab(pos_data.lab) + y_size, x_size = lab_2d.shape + x_data, y_data = int(x_pos), int(y_pos) + if not self.view_model.point_in_shape( + x=x_data, + y=y_data, + shape=(y_size, x_size), + ): + return + + self.host.clearObjContour(ID=self.host.movingID, ax=0) + delta_x, delta_y = self.view_model.move_delta( + previous_pos=self.host.prevMovePos, + current_pos=(x_data, y_data), + ) + move_result = self.host.view_model.label_edits.move_label_object( + pos_data.lab, + self.host.movingObjCoords, + self.host.movingID, + delta_y=delta_y, + delta_x=delta_x, + shape=(y_size, x_size), + ) + self.host.movingObjCoords = move_result.moved_coords + self.host.currentLab2D = self.host.get_2Dlab(pos_data.lab) + if self.host.labelsGrad.showLabelsImgAction.isChecked(): + self.host.img2.setImage(self.host.currentLab2D, autoLevels=False) + + self.set_temp_img1_move_label() + self.host.prevMovePos = (x_data, y_data) + + def move_label_button_toggled(self, checked): + if not self.view_model.should_clear_move_state(checked=checked): + return + self.host.hoverLabelID = 0 + self.host.highlightedID = 0 + self.host.highLightIDLayerImg1.clear() + self.host.highLightIDLayerRightImage.clear() + self.host.setHighlightID(False) + + def _set_temp_img_expand_label_segm_masks(self, previous_coords, ax=0): + labels_image = self.host.getLabelsLayerImage(ax=ax) + labels_image[previous_coords] = 0 + labels_image[previous_coords] = self.host.expandingID + + if ax == 0: + self.host.labelsLayerImg1.setImage( + self.host.labelsLayerImg1.image, autoLevels=False + ) + else: + self.host.labelsLayerRightImg.setImage( + self.host.labelsLayerRightImg.image, autoLevels=False + ) + + def _set_temp_img_expand_label_contours(self, previous_coords, ax=0): + self.host.contoursImage[previous_coords] = [0, 0, 0, 0] + current_lab_2d_rp = skimage.measure.regionprops(self.host.currentLab2D) + for obj in current_lab_2d_rp: + if obj.label == self.host.expandingID: + self.host.addObjContourToContoursImage( + obj=obj, ax=ax, force=True + ) + break + + def set_temp_img_expand_label( + self, + previous_coords, + expanded_obj_coords, + ax=0, + ): + if ax == 0: + how = self.host.drawIDsContComboBox.currentText() + else: + how = self.host.getAnnotateHowRightImage() + + self._set_temp_img_expand_label_contours(previous_coords, ax=ax) + + def set_temp_img1_move_label(self, ax=0): + if ax == 0: + how = self.host.drawIDsContComboBox.currentText() + else: + how = self.host.getAnnotateHowRightImage() + + if how.find('contours') != -1: + current_lab_2d_rp = skimage.measure.regionprops( + self.host.currentLab2D + ) + for obj in current_lab_2d_rp: + if obj.label == self.host.movingID: + self.host.addObjContourToContoursImage(obj=obj, ax=ax) + break + elif how.find('overlay segm. masks') != -1: + if ax == 0: + self.host.labelsLayerImg1.setImage( + self.host.currentLab2D, autoLevels=False + ) + self.host.highLightIDLayerImg1.image[:] = 0 + mask = self.host.currentLab2D == self.host.movingID + self.host.highLightIDLayerImg1.image[mask] = self.host.movingID + highlighted_image = self.host.highLightIDLayerImg1.image + self.host.highLightIDLayerImg1.setImage(highlighted_image) + else: + self.host.labelsLayerRightImg.setImage( + self.host.currentLab2D, autoLevels=False + ) + self.host.highLightIDLayerRightImage.image[:] = 0 + mask = self.host.currentLab2D == self.host.movingID + self.host.highLightIDLayerRightImage.image[mask] = ( + self.host.movingID + ) + highlighted_image = self.host.highLightIDLayerRightImage.image + self.host.highLightIDLayerRightImage.setImage(highlighted_image) diff --git a/cellacdc/views/layout_controls_view.py b/cellacdc/views/layout_controls_view.py new file mode 100644 index 000000000..3a181bfe8 --- /dev/null +++ b/cellacdc/views/layout_controls_view.py @@ -0,0 +1,854 @@ +"""Qt view adapter for layout-control workflows.""" + +from __future__ import annotations + +from functools import partial + +from natsort import natsorted +from qtpy.QtCore import QTimer, Qt +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import ( + QAction, + QActionGroup, + QButtonGroup, + QCheckBox, + QDockWidget, + QGridLayout, + QLabel, + QRadioButton, + QSizePolicy, + QWidget, +) + +from cellacdc import myutils, widgets +from cellacdc.ui.modules.annotation.decorators import resetViewRange +from cellacdc.viewmodels.layout_controls_viewmodel import ( + LayoutControlsViewModel, +) + + +class LayoutControlsView: + """Qt-facing adapter around main layout and control surfaces.""" + + LEGACY_METHODS = ( + 'zoomBottomLayoutActionTriggered', + 'retainSpaceSlidersToggled', + 'gui_createMainLayout', + 'gui_createRegionPropsDockWidget', + 'gui_createControlsToolbar', + 'gui_populateToolSettingsMenu', + 'useCenterBrushCursorHoverIDtoggled', + 'gui_createStatusBar', + 'gui_createTerminalWidget', + 'gui_terminalButtonClicked', + ) + + def __init__(self, host, view_model: LayoutControlsViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def zoomBottomLayoutActionTriggered(self, checked): + if not checked: + return + perc = self.view_model.zoom_percentage_from_text( + self.sender().text() + ) + zoom_factors = self.view_model.zoom_factors(perc) + if zoom_factors is not None: + fontSizeFactor, heightFactor = zoom_factors + self.resizeSlidersArea( + fontSizeFactor=fontSizeFactor, heightFactor=heightFactor + ) + else: + self.image_controls_view.gui_resetBottomLayoutHeight() + self.df_settings.at['bottom_sliders_zoom_perc', 'value'] = perc + self.df_settings.to_csv(self.settings_csv_path) + QTimer.singleShot(150, self.resizeGui) + + def retainSpaceSlidersToggled(self, checked): + self.df_settings.at['retain_space_hidden_sliders', 'value'] = ( + self.view_model.checked_setting_value(checked) + ) + self.df_settings.to_csv(self.settings_csv_path) + retainSpaceZ = self.view_model.should_retain_z_slider_space( + checked=checked, + z_slice_enabled=self.zSliceScrollBar.isEnabled(), + ) + myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=retainSpaceZ) + myutils.setRetainSizePolicy(self.zProjComboBox, retain=retainSpaceZ) + myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=retainSpaceZ) + myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=retainSpaceZ) + myutils.setRetainSizePolicy(self.overlay_z_label, retain=retainSpaceZ) + + QTimer.singleShot(200, self.resizeGui) + + def gui_createMainLayout(self): + mainLayout = QGridLayout() + row, col = 0, 1 # Leave column 1 for the overlay labels gradient editor + mainLayout.addLayout(self.leftSideDocksLayout, row, col, 2, 1) + + row = 0 + col = 2 + mainLayout.addWidget(self.graphLayout, row, col, 1, 2) + mainLayout.setRowStretch(row, 2) + + col = 4 # graphLayout spans two columns + mainLayout.addWidget(self.labelsGrad, row, col) + + col = 5 + mainLayout.addLayout(self.rightSideDocksLayout, row, col, 2, 1) + + col = 2 + row += 1 + self.resizeBottomLayoutLine = widgets.VerticalResizeHline() + mainLayout.addWidget(self.resizeBottomLayoutLine, row, col, 1, 2) + self.resizeBottomLayoutLine.dragged.connect( + self.resizeBottomLayoutLineDragged + ) + self.resizeBottomLayoutLine.clicked.connect( + self.resizeBottomLayoutLineClicked + ) + self.resizeBottomLayoutLine.released.connect( + self.resizeBottomLayoutLineReleased + ) + + # row += 1 + # mainLayout.addItem(QSpacerItem(5,5), row+1, col, 1, 2) + + # row, col = 1, 2 + # mainLayout.addLayout( + # self.bottomLayout, row, col, 1, 2, alignment=Qt.AlignLeft + # ) + + row += 1 + mainLayout.addWidget(self.bottomScrollArea, row, col, 1, 2) + mainLayout.setRowStretch(row, 0) + + # row, col = 2, 1 + # mainLayout.addWidget(self.terminal, row, col, 1, 4) + # self.terminal.hide() + + return mainLayout + + def gui_createRegionPropsDockWidget(self, side=Qt.LeftDockWidgetArea): + self.propsDockWidget = QDockWidget( + 'Cell-ACDC objects', self.host + ) + self.guiTabControl = widgets.guiTabControl(self.propsDockWidget) + + # self.guiTabControl.setFont(_font) + + self.propsDockWidget.setWidget(self.guiTabControl) + self.propsDockWidget.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetFloatable + | QDockWidget.DockWidgetFeature.DockWidgetMovable + ) + self.propsDockWidget.setAllowedAreas( + Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea + ) + + self.addDockWidget(side, self.propsDockWidget) + self.propsDockWidget.hide() + + def gui_createControlsToolbar(self): + self.controlToolBars = [] + self.addToolBarBreak() + + # Edit toolbar + modeToolBar = widgets.ToolBar("Mode", self.host) + self.addToolBar(modeToolBar) + + self.modeComboBox = widgets.ComboBox() + self.modeComboBox.addItems(self.modeItems) + self.modeComboBoxLabel = QLabel(' Mode: ') + self.modeComboBoxLabel.setBuddy(self.modeComboBox) + modeToolBar.addWidget(self.modeComboBoxLabel) + modeToolBar.addWidget(self.modeComboBox) + modeToolBar.setVisible(False) + + self.modeToolBar = modeToolBar + + self.overlayToolbar = widgets.OverlayToolbar(parent=self.host) + self.addToolBar(Qt.TopToolBarArea, self.overlayToolbar) + self.overlayToolbar.setVisible(False) + self.overlayToolbar.sigSetTranspacency.connect( + self.setOverlayTransparency + ) + self.overlayToolbar.sigSetSingleChannel.connect( + self.setOverlaySingleChannel + ) + + self.autoPilotZoomToObjToolbar = widgets.ToolBar( + "Auto-zoom to objects", self + ) + self.autoPilotZoomToObjToolbar.setContextMenuPolicy(Qt.PreventContextMenu) + self.autoPilotZoomToObjToolbar.setMovable(False) + self.addToolBar(Qt.TopToolBarArea, self.autoPilotZoomToObjToolbar) + # self.autoPilotZoomToObjToolbar.setIconSize(QSize(16, 16)) + self.autoPilotZoomToObjToolbar.setVisible(False) + self.autoPilotZoomToObjToolbar.keepVisibleWhenActive = True + self.controlToolBars.append(self.autoPilotZoomToObjToolbar) + + # Highlighted ID or searched ID toolbar + self.highlightIDToolbar = widgets.HighlightedIDToolbar( + parent=self.host + ) + self.addToolBar(Qt.TopToolBarArea, self.highlightIDToolbar) + self.highlightIDToolbar.setVisible(False) + self.highlightIDToolbar.keepVisibleWhenActive = True + self.controlToolBars.append(self.highlightIDToolbar) + + self.highlightIDToolbar.sigIDChanged.connect( + self.setHighlighedIDfromToolbar + ) + + # Widgets toolbar + brushEraserToolBar = widgets.ToolBar("Widgets", self.host) + self.addToolBar(Qt.TopToolBarArea, brushEraserToolBar) + self.controlToolBars.append(brushEraserToolBar) + + self.editIDspinbox = widgets.SpinBox() + # self.editIDspinbox.setMaximum(2**32-1) + editIDLabel = QLabel(' ID: ') + self.editIDLabelAction = brushEraserToolBar.addWidget(editIDLabel) + self.editIDspinboxAction = brushEraserToolBar.addWidget( + self.editIDspinbox + ) + self.editIDLabelAction.setVisible(False) + self.editIDspinboxAction.setVisible(False) + self.editIDspinboxAction.setDisabled(True) + self.editIDLabelAction.setDisabled(True) + + brushEraserToolBar.addWidget(QLabel(' ')) + self.autoIDcheckbox = QCheckBox('Auto-ID') + self.autoIDcheckbox.setChecked(True) + self.autoIDcheckboxAction = brushEraserToolBar.addWidget(self.autoIDcheckbox) + self.autoIDcheckboxAction.setVisible(False) + + self.brushSizeSpinbox = widgets.SpinBox( + disableKeyPress=True, + allowNegative=False + ) + self.brushSizeSpinbox.setValue(4) + brushSizeLabel = QLabel(' Size: ') + brushSizeLabel.setBuddy(self.brushSizeSpinbox) + self.brushSizeLabelAction = brushEraserToolBar.addWidget(brushSizeLabel) + self.brushSizeAction = brushEraserToolBar.addWidget(self.brushSizeSpinbox) + self.brushSizeLabelAction.setVisible(False) + self.brushSizeAction.setVisible(False) + + brushEraserToolBar.addWidget(QLabel(' ')) + self.brushAutoFillCheckbox = QCheckBox('Auto-fill holes') + self.brushAutoFillAction = brushEraserToolBar.addWidget( + self.brushAutoFillCheckbox + ) + self.brushAutoFillAction.setVisible(False) + if 'brushAutoFill' in self.df_settings.index: + checked = self.df_settings.at['brushAutoFill', 'value'] == 'Yes' + self.brushAutoFillCheckbox.setChecked(checked) + + brushEraserToolBar.addWidget(QLabel(' ')) + self.brushAutoHideCheckbox = QCheckBox('Hide objects when hovering') + self.brushAutoHideAction = brushEraserToolBar.addWidget( + self.brushAutoHideCheckbox + ) + self.brushAutoHideCheckbox.setChecked(True) + self.brushAutoHideAction.setVisible(False) + if 'brushAutoHide' in self.df_settings.index: + checked = self.df_settings.at['brushAutoHide', 'value'] == 'Yes' + self.brushAutoHideCheckbox.setChecked(checked) + + brushEraserToolBar.setVisible(False) + self.brushEraserToolBar = brushEraserToolBar + + self.wandControlsToolbar = widgets.WandControlsToolbar( + parent=self.host + ) + + self.addToolBar(Qt.TopToolBarArea , self.wandControlsToolbar) + self.wandControlsToolbar.setVisible(False) + self.controlToolBars.append(self.wandControlsToolbar) + + separatorW = 5 + self.labelRoiToolbar = widgets.ToolBar( + "Magic labeller controls", self.host + ) + self.labelRoiToolbar.addWidget(QLabel('ROI n. of z-slices: ')) + self.labelRoiZdepthSpinbox = widgets.SpinBox(disableKeyPress=True) + self.labelRoiToolbar.addWidget(self.labelRoiZdepthSpinbox) + + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) + self.labelRoiToolbar.addWidget(widgets.QVLine()) + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) + + self.labelRoiReplaceExistingObjectsCheckbox = QCheckBox( + 'Remove objs. touched by new ones' + ) + self.labelRoiToolbar.addWidget(self.labelRoiReplaceExistingObjectsCheckbox) + self.labelRoiAutoClearBorderCheckbox = QCheckBox( + 'Clear ROI borders before adding new objs.' + ) + self.labelRoiAutoClearBorderCheckbox.setChecked(True) + self.labelRoiToolbar.addWidget(self.labelRoiAutoClearBorderCheckbox) + + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) + self.labelRoiToolbar.addWidget(widgets.QVLine()) + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) + + group = QButtonGroup() + group.setExclusive(True) + self.labelRoiIsRectRadioButton = QRadioButton('Rect. ROI') + self.labelRoiIsRectRadioButton.setChecked(True) + self.labelRoiIsFreeHandRadioButton = QRadioButton('Freehand ROI') + self.labelRoiIsCircularRadioButton = QRadioButton('Circular ROI') + group.addButton(self.labelRoiIsRectRadioButton) + group.addButton(self.labelRoiIsFreeHandRadioButton) + group.addButton(self.labelRoiIsCircularRadioButton) + self.labelRoiToolbar.addWidget(self.labelRoiIsRectRadioButton) + self.labelRoiToolbar.addWidget(self.labelRoiIsFreeHandRadioButton) + self.labelRoiToolbar.addWidget(self.labelRoiIsCircularRadioButton) + self.labelRoiToolbar.addWidget(QLabel(' | Radius (pixel): ')) + self.labelRoiCircularRadiusSpinbox = widgets.SpinBox(disableKeyPress=True) + self.labelRoiCircularRadiusSpinbox.setMinimum(1) + self.labelRoiCircularRadiusSpinbox.setValue(11) + self.labelRoiCircularRadiusSpinbox.setDisabled(True) + self.labelRoiToolbar.addWidget(self.labelRoiCircularRadiusSpinbox) + + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) + self.labelRoiToolbar.addWidget(widgets.QVLine()) + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) + + startFrameLabel = QLabel('Start frame n. ') + startFrameLabel.setDisabled(True) + self.labelRoiToolbar.addWidget(startFrameLabel) + self.labelRoiStartFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) + self.labelRoiStartFrameNoSpinbox.label = startFrameLabel + self.labelRoiStartFrameNoSpinbox.setValue(1) + self.labelRoiStartFrameNoSpinbox.setMinimum(1) + self.labelRoiToolbar.addWidget(self.labelRoiStartFrameNoSpinbox) + self.labelRoiStartFrameNoSpinbox.setDisabled(True) + + self.labelRoiFromCurrentFrameAction = QAction(self.host) + self.labelRoiFromCurrentFrameAction.setText('Segment from current frame') + self.labelRoiFromCurrentFrameAction.setIcon(QIcon(":frames_current.svg")) + self.labelRoiToolbar.addAction(self.labelRoiFromCurrentFrameAction) + self.labelRoiFromCurrentFrameAction.setDisabled(True) + + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=3)) + stopFrameLabel = QLabel(' Stop frame n. ') + stopFrameLabel.setDisabled(True) + self.labelRoiToolbar.addWidget(stopFrameLabel) + self.labelRoiStopFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) + self.labelRoiStopFrameNoSpinbox.label = stopFrameLabel + self.labelRoiStopFrameNoSpinbox.setValue(1) + self.labelRoiStopFrameNoSpinbox.setMinimum(1) + self.labelRoiToolbar.addWidget(self.labelRoiStopFrameNoSpinbox) + self.labelRoiStopFrameNoSpinbox.setDisabled(True) + + self.labelRoiToEndFramesAction = QAction(self.host) + self.labelRoiToEndFramesAction.setText('Segment all remaining frames') + self.labelRoiToEndFramesAction.setIcon(QIcon(":frames_end.svg")) + self.labelRoiToolbar.addAction(self.labelRoiToEndFramesAction) + self.labelRoiToEndFramesAction.setDisabled(True) + + self.labelRoiTrangeCheckbox = QCheckBox('Segment range of frames') + self.labelRoiToolbar.addWidget(self.labelRoiTrangeCheckbox) + + self.labelRoiViewCurrentModelAction = QAction(self.host) + self.labelRoiViewCurrentModelAction.setText( + 'View current model\'s parameters' + ) + self.labelRoiViewCurrentModelAction.setIcon(QIcon(":view.svg")) + self.labelRoiToolbar.addAction(self.labelRoiViewCurrentModelAction) + self.labelRoiViewCurrentModelAction.setDisabled(True) + + self.addToolBar(Qt.TopToolBarArea, self.labelRoiToolbar) + self.controlToolBars.append(self.labelRoiToolbar) + self.labelRoiToolbar.setVisible(False) + self.labelRoiTypesGroup = group + + self.loadLabelRoiLastParams() + + self.labelRoiTrangeCheckbox.toggled.connect( + self.labelRoiTrangeCheckboxToggled + ) + self.labelRoiReplaceExistingObjectsCheckbox.toggled.connect( + self.storeLabelRoiParams + ) + self.labelRoiIsCircularRadioButton.toggled.connect( + self.labelRoiIsCircularRadioButtonToggled + ) + self.labelRoiCircularRadiusSpinbox.valueChanged.connect( + self.updateLabelRoiCircularSize + ) + self.labelRoiCircularRadiusSpinbox.valueChanged.connect( + self.storeLabelRoiParams + ) + self.labelRoiZdepthSpinbox.valueChanged.connect( + self.storeLabelRoiParams + ) + self.labelRoiAutoClearBorderCheckbox.toggled.connect( + self.storeLabelRoiParams + ) + group.buttonToggled.connect(self.storeLabelRoiParams) + + self.labelRoiToEndFramesAction.triggered.connect( + self.labelRoiToEndFramesTriggered + ) + self.labelRoiFromCurrentFrameAction.triggered.connect( + self.labelRoiFromCurrentFrameTriggered + ) + self.labelRoiViewCurrentModelAction.triggered.connect( + self.labelRoiViewCurrentModel + ) + + self.keepIDsToolbar = widgets.ToolBar( + "Keep IDs controls", self.host + ) + self.keepIDsConfirmAction = QAction() + self.keepIDsConfirmAction.setIcon(QIcon(":greenTick.svg")) + self.keepIDsConfirmAction.setToolTip('Apply "keep IDs" selection') + self.keepIDsConfirmAction.setDisabled(True) + self.keepIDsToolbar.addAction(self.keepIDsConfirmAction) + self.keepIDsToolbar.addWidget(QLabel(' IDs to keep: ')) + instructionsText = ( + ' (Separate IDs by comma. Use a dash to denote a range of IDs)' + ) + instructionsLabel = QLabel(instructionsText) + self.keptIDsLineEdit = widgets.KeepIDsLineEdit( + instructionsLabel, parent=self.host + ) + self.keepIDsToolbar.addWidget(self.keptIDsLineEdit) + self.keepIDsToolbar.addWidget(instructionsLabel) + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.keepIDsToolbar.addWidget(spacer) + self.addToolBar(Qt.TopToolBarArea, self.keepIDsToolbar) + self.keepIDsToolbar.setVisible(False) + self.controlToolBars.append(self.keepIDsToolbar) + + self.keptIDsLineEdit.sigEnterPressed.connect(self.applyKeepObjects) + self.keptIDsLineEdit.sigIDsChanged.connect(self.updateKeepIDs) + self.keepIDsConfirmAction.triggered.connect(self.applyKeepObjects) + + # closeToolbarAction = QAction( + # QIcon(":cancelButton.svg"), "Close toolbar...", self + # ) + # closeToolbarAction.triggered.connect(self.closeToolbars) + # self.autoPilotZoomToObjToolbar.addAction(closeToolbarAction) + + self.autoPilotZoomToObjToolbar.addWidget(widgets.QVLine()) + self.autoPilotZoomToObjToolbar.addWidget( + widgets.QHWidgetSpacer(width=separatorW) + ) + + spinBox = widgets.SpinBox() + spinBox.setMinimum(1) + spinBox.label = QLabel(' Zoom to ID: ') + spinBox.labelAction = self.autoPilotZoomToObjToolbar.addWidget(spinBox.label) + spinBox.action = self.autoPilotZoomToObjToolbar.addWidget(spinBox) + spinBox.editingFinished.connect(self.zoomToObj) + spinBox.sigUpClicked.connect(self.autoZoomNextObj) + spinBox.sigDownClicked.connect(self.autoZoomPrevObj) + self.autoPilotZoomToObjSpinBox = spinBox + toggle = widgets.Toggle() + self.autoPilotZoomToObjToggle = toggle + toggle.toggled.connect(self.autoPilotZoomToObjToggled) + toggle.label = QLabel(' Auto-pilot: ') + tooltip = ( + 'When auto-pilot is active, you can use Up/Down arrows to ' + 'automatically zoom to the next/previous object.\n\n' + 'Alternatively, you can type the ID of the object you want to ' + 'zoom to.' + ) + toggle.label.setToolTip(tooltip) + toggle.setToolTip(tooltip) + self.autoPilotZoomToObjToolbar.addWidget(toggle.label) + self.autoPilotZoomToObjToolbar.addWidget(toggle) + + self.pointsLayersToolbars = [] + + self.pointsLayersToolbar = widgets.PointsLayersToolbar( + parent=self.host + ) + self.pointsLayersToolbar.setContextMenuPolicy(Qt.PreventContextMenu) + + self.pointsLayersToolbar.sigAddPointsLayer.connect( + self.addPointsLayerTriggered + ) + + self.addToolBar(Qt.TopToolBarArea, self.pointsLayersToolbar) + + self.pointsLayersToolbar.setVisible(False) + self.pointsLayersToolbar.keepVisibleWhenActive = True + self.controlToolBars.append(self.pointsLayersToolbar) + + self.pointsLayersToolbars.append( + self.pointsLayersToolbar + ) + + self.manualTrackingToolbar = widgets.ManualTrackingToolBar( + "Manual tracking controls", self + ) + self.manualTrackingToolbar.sigIDchanged.connect(self.initGhostObject) + self.manualTrackingToolbar.sigDisableGhost.connect(self.clearGhost) + self.manualTrackingToolbar.sigClearGhostContour.connect( + self.clearGhostContour + ) + self.manualTrackingToolbar.sigClearGhostMask.connect( + self.clearGhostMask + ) + self.manualTrackingToolbar.sigGhostOpacityChanged.connect( + self.updateGhostMaskOpacity + ) + + self.addToolBar(Qt.TopToolBarArea, self.manualTrackingToolbar) + self.manualTrackingToolbar.setVisible(False) + self.controlToolBars.append(self.manualTrackingToolbar) + + self.manualBackgroundToolbar = widgets.ManualBackgroundToolBar( + "Manual background controls", self + ) + self.manualBackgroundToolbar.sigIDchanged.connect( + self.initManualBackgroundObject + ) + self.addToolBar(Qt.TopToolBarArea, self.manualBackgroundToolbar) + self.manualBackgroundToolbar.setVisible(False) + self.controlToolBars.append(self.manualBackgroundToolbar) + + # Copy lost object contour toolbar + self.copyLostObjToolbar = widgets.CopyLostObjectToolbar( + "Copy lost object controls", self + ) + for name, action in self.copyLostObjToolbar.widgetsWithShortcut.items(): + self.widgetsWithShortcut[name] = action + + self.copyLostObjToolbar.sigCopyAllObjects.connect( + self.copyAllLostObjects + ) + + self.addToolBar(Qt.TopToolBarArea, self.copyLostObjToolbar) + self.copyLostObjToolbar.setVisible(False) + # self.controlToolBars.append(self.copyLostObjToolbar) + + # Copy lost object contour toolbar + self.drawClearRegionToolbar = widgets.DrawClearRegionToolbar( + "Draw freehand region and clear objects controls", self + ) + + self.addToolBar(Qt.TopToolBarArea, self.drawClearRegionToolbar) + self.drawClearRegionToolbar.setVisible(False) + self.controlToolBars.append(self.drawClearRegionToolbar) + + try: + addNewIDToggleState = self.df_settings.at['addNewIDsWhitelistToggle', 'value'] == 'Yes' + except KeyError: + addNewIDToggleState = True + + self.whitelistIDsToolbar = widgets.WhitelistIDsToolbar( + addNewIDToggleState, self + ) + for name, action in self.whitelistIDsToolbar.widgetsWithShortcut.items(): + self.widgetsWithShortcut[name] = action + + self.addToolBar(Qt.TopToolBarArea, self.whitelistIDsToolbar) + self.whitelistIDsToolbar.setVisible(False) + self.controlToolBars.append(self.whitelistIDsToolbar) + + self.magicPromptsToolbar = widgets.MagicPromptsToolbar(self.host) + for name, action in self.magicPromptsToolbar.widgetsWithShortcut.items(): + self.widgetsWithShortcut[name] = action + + self.magicPromptsToolbar.sigComputeOnZoom.connect( + self.magic_prompts_view.magicPromptsComputeOnZoomTriggered + ) + self.magicPromptsToolbar.sigComputeOnImage.connect( + self.magic_prompts_view.magicPromptsComputeOnImageTriggered + ) + self.magicPromptsToolbar.sigInitSelectedModel.connect( + self.magic_prompts_view.magicPromptsInitModel + ) + self.magicPromptsToolbar.sigViewModelParams.connect( + self.magic_prompts_view.viewSetMagicPromptModelParams + ) + self.magicPromptsToolbar.sigClearPoints.connect( + partial( + self.magic_prompts_view.magicPromptsClearPoints, + only_zoom=False, + ) + ) + self.magicPromptsToolbar.sigClearPointsOnZmom.connect( + partial( + self.magic_prompts_view.magicPromptsClearPoints, + only_zoom=True, + ) + ) + self.magicPromptsToolbar.sigInterpolateZslice.connect( + self.magic_prompts_view.magicPromptsInterpolateZsliceToggled + ) + + self.addToolBar(Qt.TopToolBarArea, self.magicPromptsToolbar) + self.magicPromptsToolbar.setVisible(False) + self.magicPromptsToolbar.keepVisibleWhenActive = True + self.controlToolBars.append(self.magicPromptsToolbar) + + self.promptSegmentPointsLayerToolbar = ( + widgets.PromptableModelPointsLayerToolbar(parent=self.host) + ) + self.promptSegmentPointsLayerToolbar.setContextMenuPolicy( + Qt.PreventContextMenu + ) + + self.addToolBar(Qt.TopToolBarArea, self.promptSegmentPointsLayerToolbar) + self.promptSegmentPointsLayerToolbar.setVisible(False) + + self.pointsLayersToolbars.append( + self.promptSegmentPointsLayerToolbar + ) + + # Second level toolbar + secondLevelToolbar = widgets.ToolBar( + "Second level toolbar", self.host + ) + self.addToolBar(Qt.TopToolBarArea, secondLevelToolbar) + self.delObjToolAction = QAction(self.host) + self.delObjToolAction.setIcon(QIcon(":del_obj_click.svg")) + self.delObjToolAction.setCheckable(True) + self.delObjToolAction.setToolTip( + 'Customisable delete object action\n\n' + 'Go to the `Settings --> Customise keyboard shortcuts...` menu ' + 'on the top menubar\n' + 'to customise the action required to delete ' + 'an object with a click.\n\n' + 'When working with 3D segmentations, to delete only the z-slice mask, hold "Shift" while clicking.' + ) + secondLevelToolbar.addAction(self.delObjToolAction) + secondLevelToolbar.setMovable(False) + self.secondLevelToolbar = secondLevelToolbar + self.secondLevelToolbar.setVisible(False) + + def gui_populateToolSettingsMenu(self): + brushHoverModeActionGroup = QActionGroup(self.host) + brushHoverModeActionGroup.setExclusive(True) + self.brushHoverCenterModeAction = QAction() + self.brushHoverCenterModeAction.setCheckable(True) + self.brushHoverCenterModeAction.setText( + 'Use center of the brush/eraser cursor to determine hover ID' + ) + self.brushHoverCircleModeAction = QAction() + self.brushHoverCircleModeAction.setCheckable(True) + self.brushHoverCircleModeAction.setText( + 'Use the entire circle of the brush/eraser cursor to determine hover ID' + ) + brushHoverModeActionGroup.addAction(self.brushHoverCenterModeAction) + brushHoverModeActionGroup.addAction(self.brushHoverCircleModeAction) + brushHoverModeMenu = self.settingsMenu.addMenu( + 'Brush/eraser cursor hovering mode' + ) + brushHoverModeMenu.addAction(self.brushHoverCenterModeAction) + brushHoverModeMenu.addAction(self.brushHoverCircleModeAction) + + if 'useCenterBrushCursorHoverID' not in self.df_settings.index: + self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' + + useCenterBrushCursorHoverID = self.df_settings.at[ + 'useCenterBrushCursorHoverID', 'value' + ] == 'Yes' + self.brushHoverCenterModeAction.setChecked(useCenterBrushCursorHoverID) + self.brushHoverCircleModeAction.setChecked(not useCenterBrushCursorHoverID) + + self.brushHoverCenterModeAction.toggled.connect( + self.useCenterBrushCursorHoverIDtoggled + ) + + self.settingsMenu.addSeparator() + + keepToolActiveNames = { + 'Segment range of frames': self.labelRoiTrangeCheckbox + } + for button in self.checkableQButtonsGroup.buttons(): + if button.toolTip() == "": + toolName = "MISSING" + continue + else: + toolName = self.view_model.tool_name_from_tooltip( + button.toolTip() + ) + keepToolActiveNames[toolName] = button + + keepToolActiveNames = dict(natsorted(keepToolActiveNames.items())) + + applyToNewFrameNames = { + 'Segmenting for lost IDs': self.segForLostIDsButton, + 'Delete bordering objects': self.delBorderObjAction.button, + 'Delete newly segmented objects': self.delNewObjAction.button, + } + + allToolsList = list(keepToolActiveNames.keys()) + list(applyToNewFrameNames.keys()) + allToolsList = natsorted(allToolsList) + + menus = {} + + for toolName in allToolsList: + menuItemText = f'{toolName} tool'.replace(' ', ' ') + menus[toolName] = self.settingsMenu.addMenu(menuItemText) + + self.keepToolActiveActions = dict() + self.applyToolNewFrameActions = dict() + self.applyToolNewFrameButtons = dict() + all_checked = True + + for toolName, button in keepToolActiveNames.items(): + menu = menus[toolName] + action = QAction(button) + action.setText('Keep tool active after using it') + action.setCheckable(True) + if toolName in self.df_settings.index: + action.setChecked(True) + else: + all_checked = False + action.toggled.connect(self.keepToolActiveActionToggled) + menu.addAction(action) + self.keepToolActiveActions[toolName] = action + + for toolName, button in applyToNewFrameNames.items(): + menu = menus[toolName] + action = QAction(button) + action.setText('Apply when visitng new frame') + action.setCheckable(True) + action.toggled.connect(self.applyToolNewFrameActionToggled) + menu.addAction(action) + self.applyToolNewFrameActions[toolName] = action + self.applyToolNewFrameButtons[toolName] = button + + for toolName in self.applyToolNewFrameActions.keys(): + settingString = toolName.strip() + settingString = toolName.replace(' ', '_') + settingString = f'{settingString}_applyNewFrame' + if settingString in self.df_settings.index: + val = self.df_settings.at[settingString, 'value'] + if val == 'applyNewFrame': + self.applyToolNewFrameActions[toolName].setChecked(True) + + self.settingsMenu.addSeparator() + + self.keepAllToolsActiveToggle = QAction() + self.keepAllToolsActiveToggle.setText( + 'Keep all tools active after using them' + ) + self.keepAllToolsActiveToggle.setCheckable(True) + self.keepAllToolsActiveToggle.setChecked(all_checked) + self.keepAllToolsActiveToggle.toggled.connect( + self.keepAllToolsActiveActionToggled + ) + self.settingsMenu.addAction(self.keepAllToolsActiveToggle) + self.settingsMenu.addSeparator() + + askHowFutureFramesMenu = self.settingsMenu.addMenu( + 'Ask how to propagate changes to future frames' + ) + self.askHowFutureFramesActions = {} + askHowFutureFramesActionsKeys = ( + 'Delete ID', + 'Exclude cell from analysis', + 'Annotate cell as dead', + 'Edit ID', + 'Keep ID' + ) + for key in askHowFutureFramesActionsKeys: + askHowFutureFramesAction = QAction() + askHowFutureFramesAction.setText(f'Ask for "{key}" action') + askHowFutureFramesAction.setCheckable(True) + askHowFutureFramesAction.setChecked(True) + askHowFutureFramesAction.setDisabled(True) + askHowFutureFramesMenu.addAction(askHowFutureFramesAction) + self.askHowFutureFramesActions[key] = askHowFutureFramesAction + + warningsMenu = self.settingsMenu.addMenu('Warnings and pop-ups') + self.warnLostCellsAction = QAction() + self.warnLostCellsAction.setText('Show pop-up warning for lost cells') + self.warnLostCellsAction.setCheckable(True) + self.warnLostCellsAction.setChecked(True) + warningsMenu.addAction(self.warnLostCellsAction) + + warnEditingWithAnnotTexts = { + 'Delete ID': 'Show warning when deleting ID that has annotations', + 'Separate IDs': 'Show warning when separating IDs that have annotations', + 'Edit ID': 'Show warning when editing ID that has annotations', + 'Annotate ID as dead': + 'Show warning when annotating dead ID that has annotations', + 'Delete ID with eraser': + 'Show warning when erasing ID that has annotations', + 'Add new ID with brush tool': + 'Show warning when adding new ID (brush) that has annotations', + 'Merge IDs': + 'Show warning when merging IDs that have annotations', + 'Add new ID with curvature tool': + 'Show warning when adding new ID (curv. tool) that has annotations', + 'Add new ID with magic-wand': + 'Show warning when adding new ID (magic-wand) that has annotations', + 'Delete IDs using ROI': + 'Show warning when using ROIs to delete IDs that have annotations', + } + self.warnEditingWithAnnotActions = {} + for key, desc in warnEditingWithAnnotTexts.items(): + action = QAction() + action.setText(desc) + action.setCheckable(True) + action.setChecked(True) + action.removeAnnot = False + self.warnEditingWithAnnotActions[key] = action + warningsMenu.addAction(action) + + def useCenterBrushCursorHoverIDtoggled(self, checked): + if checked: + self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' + else: + self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'No' + self.df_settings.to_csv(self.settings_csv_path) + + def gui_createStatusBar(self): + self.statusbar = self.statusBar() + # Permanent widget + self.wcLabel = QLabel('') + self.statusbar.addPermanentWidget(self.wcLabel) + + # self.toggleTerminalButton = widgets.ToggleTerminalButton() + # self.statusbar.addWidget(self.toggleTerminalButton) + # self.toggleTerminalButton.sigClicked.connect( + # self.gui_terminalButtonClicked + # ) + + self.statusBarLabel = QLabel('') + self.statusbar.addWidget(self.statusBarLabel) + + def gui_createTerminalWidget(self): + self.terminal = widgets.QLog(logger=self.logger) + self.terminal.connect() + self.terminalDock = QDockWidget('Log', self.host) + + self.terminalDock.setWidget(self.terminal) + self.terminalDock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetFloatable | QDockWidget.DockWidgetFeature.DockWidgetMovable + ) + self.terminalDock.setAllowedAreas(Qt.BottomDockWidgetArea) + self.addDockWidget(Qt.BottomDockWidgetArea, self.terminalDock) + # self.terminalDock.widget().layout().setContentsMargins(10,0,10,0) + self.terminalDock.setVisible(False) + + @resetViewRange + def gui_terminalButtonClicked(self, terminalVisible): + self.terminalDock.setVisible(terminalVisible) diff --git a/cellacdc/views/lineage_interactions_view.py b/cellacdc/views/lineage_interactions_view.py new file mode 100644 index 000000000..9a8170f08 --- /dev/null +++ b/cellacdc/views/lineage_interactions_view.py @@ -0,0 +1,717 @@ +"""Qt view adapter for lineage-tree interaction workflows.""" + +from __future__ import annotations + +from qtpy.QtCore import Qt + +from cellacdc import ( + disableWindow, exception_handler, html_utils, lineage_tree_cols, printl, + widgets, +) +from cellacdc.trackers.CellACDC_normal_division.CellACDC_normal_division_tracker import ( + normal_division_lineage_tree, +) +from cellacdc.viewmodels.lineage_interactions_viewmodel import ( + LineageInteractionsViewModel, +) + + +class LineageInteractionsView: + """Qt-facing adapter around lineage-tree interaction workflows.""" + + LEGACY_METHODS = ( + 'initLinTree', + 'propagateLinTreeAction', + 'resetLin_tree_future', + 'autoLinTree_df', + 'initMissingFramesLinTree', + 'viewLinTreeInfoAction', + 'askLineageTreeChanges', + 'repeat_click_and_backup', + 'getDistanceListMissingIDs', + 'find_mother_action', + 'annotate_unknown_lineage_action', + 'get_difference_table', + ) + + def __init__(self, host, view_model: LineageInteractionsViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + @exception_handler + def initLinTree(self, force=False): + """ + Initializes the lineage tree analysis. + + This method checks if the tracking has been previously checked and saved. If not, it displays a message to the user. + It also prompts the user to go to the last annotated frame and restart the lineage tree analysis if necessary. + Finally, it initializes the necessary data structures and updates the GUI. + + Returns + ------- + proceed : bool + True if the initialization is successful, nothing otherwise. + """ + + mode = str(self.modeComboBox.currentText()) + if not self.view_model.should_initialize( + force=force, + mode=mode, + lineage_tree_exists=self.lineage_tree is not None, + ): + return + + posData = self.data[self.pos_i] + last_tracked_i = self.get_last_tracked_i() + defaultMode = self.view_model.default_mode_after_failed_init() + if last_tracked_i == 0: + # Display message to the user + txt = html_utils.paragraph( + 'On this dataset either you never checked that the segmentation ' + 'and tracking are correct or you did not save yet.

' + 'If you already visited some frames with "Segmentation and Tracking" ' + 'mode save data before switching to "Normal division: Lineage Tree".

' + 'Otherwise you first have to check (and eventually correct) some frames ' + 'in "Segmentation and Tracking" mode before proceeding ' + 'with lineage tree analysis.') + msg = widgets.myMessageBox() + msg.critical( + self.host, 'Tracking was never checked', txt + ) + self.modeComboBox.setCurrentText(defaultMode) + return + + proceed = True + last_lin_tree_frame_i = self.view_model.last_annotated_frame_index( + posData.allData_li + ) + + if last_lin_tree_frame_i == 0: + # Remove undoable actions from segmentation mode + posData.UndoRedoStates[0] = [] + self.undoAction.setEnabled(False) + self.redoAction.setEnabled(False) + + if posData.frame_i > last_lin_tree_frame_i: + # Prompt user to go to last annotated frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + The last annotated frame is frame {last_lin_tree_frame_i+1}.

+ Do you want to restart lineage tree analysis from frame + {last_lin_tree_frame_i+1}?
+ """) + _, yesButton, stayButton = msg.warning( + self.host, 'Go to last annotated frame?', txt, + buttonsTexts=( + 'Cancel', f'Yes, go to frame {last_lin_tree_frame_i+1}', + 'No, stay on current frame') + ) + if yesButton == msg.clickedButton: + msg = 'Looking good!' + self.last_lin_tree_frame_i = last_lin_tree_frame_i + posData.frame_i = last_lin_tree_frame_i + self.titleLabel.setText(msg, color=self.titleColor) + self.get_data(lin_tree_init=False) + self.updateAllImages() # i dont think I need to change this + self.updateScrollbars() # i dont think I need to change this + elif stayButton == msg.clickedButton: + self.initMissingFramesLinTree(posData.frame_i) #!!! + last_lin_tree_frame_i = posData.frame_i + msg = 'Lineage tree analysis initialised!' + self.titleLabel.setText(msg, color='g') + elif msg.cancel: + msg = 'Lineage tree analysis aborted.' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + self.modeComboBox.setCurrentText(defaultMode) + proceed = False + return + + elif posData.frame_i < last_lin_tree_frame_i: + # Prompt user to go to last annotated frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + The last annotated frame is frame {last_lin_tree_frame_i+1}.

+ Do you want to restart lineage tree analysis from frame + {last_lin_tree_frame_i+1}?
+ """) + goTo_last_annotated_frame_i = msg.question( + self.host, 'Go to last annotated frame?', txt, + buttonsTexts=('Yes', 'No', 'Cancel') + )[0] + if goTo_last_annotated_frame_i == msg.clickedButton: + msg = 'Looking good!' + self.titleLabel.setText(msg, color=self.titleColor) + self.last_lin_tree_frame_i = last_lin_tree_frame_i + posData.frame_i = last_lin_tree_frame_i + self.get_data(lin_tree_init=False) + self.updateAllImages() # i dont think I need to change this + self.updateScrollbars() # i dont think I need to change this + elif msg.cancel: + msg = 'Lineage tree analysis aborted.' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + self.modeComboBox.setCurrentText(defaultMode) + proceed = False + return + else: + self.get_data(lin_tree_init=False) + + self.last_lin_tree_frame_i = last_lin_tree_frame_i + + self.navigateScrollBar.setMaximum(last_lin_tree_frame_i+1) + self.navSpinBox.setMaximum(last_lin_tree_frame_i+1) + + if self.lineage_tree is None or force: + self.store_data(autosave=False) + self.get_data(lin_tree_init=False) + self.lineage_tree = normal_division_lineage_tree(gui=self.host) + + msg = 'Lineage tree analysis initialized!' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + + return proceed + + @disableWindow + def propagateLinTreeAction(self, dummy_for_button=None): + """ + Propagates the lineage tree based on the current frame_i. Used in self.propagateLinTreeButton. + """ + posData = self.data[self.pos_i] + self.lineage_tree.propagate(posData.frame_i) + if posData.frame_i == self.original_df_lin_tree_i: + self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() + + self.logger.info('Lineage tree propagated.') + + def resetLin_tree_future(self): + posData = self.data[self.pos_i] + frame_i = posData.frame_i + result = ( + self.host.view_model.lineage.remove_future_lineage_tree_annotations( + posData.allData_li, + lineage_tree_cols, + frame_i, + size_t=posData.SizeT, + ) + ) + + if self.lineage_tree is not None: + self.lineage_tree.frames_for_dfs.discard(frame_i) + for i, acdc_df in result.acdc_dfs_by_frame.items(): + posData.allData_li[i]['acdc_df'] = acdc_df + + def autoLinTree_df(self, enforceAll=False): + """Automatically generates a lineage tree dataframe. + + This method generates a lineage tree dataframe based on the current mode and data. + It checks if the mode is set to 'Normal division: Lineage tree' and if the current frame + is not already processed. If the conditions are met, it retrieves the necessary data + from the current position data and previous position data, and passes it to the + `real_time` method of the `lineage_tree` object. Finally, it converts the lineage tree + to an ACDC dataframe and adds the current frame to the set of frames that have been + processed. + + Parameters + ---------- + enforceAll : bool, optional + If True, enforces processing of all frames, even if they have been processed before. + If False, only processes frames that have not been processed before. Default is False. + + Returns + ------- + bool + True if there are not enough G1 cells for lineage tree generation, False otherwise. + bool + True if the lineage tree generation should proceed, False otherwise. + """ + proceed = True + notEnoughG1Cells = False + mode = str(self.modeComboBox.currentText()) + + # Skip if not the right mode + if not self.view_model.should_process_auto_frame( + mode=mode, + frame_i=self.data[self.pos_i].frame_i, + processed_frames=self.lineage_tree.frames_for_dfs, + ): + return notEnoughG1Cells, proceed + + posData = self.data[self.pos_i] + frame_i = posData.frame_i + + # Make sure that this is a visited frame in segmentation tracking mode + if posData.allData_li[frame_i]['labels'] is None: # may need to change this + proceed = self.warnFrameNeverVisitedSegmMode() + return notEnoughG1Cells, proceed + + self.store_data(autosave=False) + self.get_data() + lab = posData.lab + prev_lab = posData.allData_li[frame_i-1]['labels'] + rp = posData.rp + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + + self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) + self.store_data() + + def initMissingFramesLinTree(self, current_frame_i): # done Need to add partially missing previous frames and loading + """ + When not starting from the first frame, automatically creates lineage tree dfs for all "skipped" frames and initializes the tree if not done so before. + + Parameters + ---------- + current_frame_i : int + The index of the current frame. + + Returns + ------- + None + + Notes + ----- + This method initializes the lineage tree annotations of missing past frames. If the lineage tree has not been initialized before, it creates a new lineage tree based on the labels of the first frame. It then iterates over the missing frames and updates the lineage tree with the labels and region properties of each frame. + """ + + self.logger.info( + 'Initialising lineage tree annotations of missing past frames...' + ) + + self.store_data(autosave=False) + self.get_data() + + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + + if not self.lineage_tree: # init lin tree if not done already + self.lineage_tree = normal_division_lineage_tree( + gui=self.host + ) # here frame_i!=0 + + present_frames = list(self.lineage_tree.frames_for_dfs) if self.lineage_tree else [] + present_frames = [] if not present_frames else present_frames # deal with None + missing_frames = self.view_model.missing_frame_indices( + current_frame_i, + present_frames, + ) + + for frame_i in missing_frames: + lab = posData.allData_li[frame_i]['labels'] + prev_lab = posData.allData_li[frame_i-1]['labels'] + rp = posData.allData_li[frame_i]['regionprops'] + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + # i might need to change this if I need support for only partially missing frames... Although I probably never have to care about that though + self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) + + posData.frame_i = current_frame_i + self.store_data() + + def viewLinTreeInfoAction(self): + mode = str(self.modeComboBox.currentText()) + if not self.view_model.is_lineage_mode(mode): + self.logger.info('This action is only available in the "Normal division: Lineage tree" mode.') + return + + if not self.lineage_tree: + self.logger.info('No lineage tree found.') + return + + posData = self.data[self.pos_i] + + if self.original_df_lin_tree_i != posData.frame_i: + # could be that this is not entirley true and self.curr_original_df_i just didnt get set right though! + txt_changes = '
No changes were made in this frame.

' + + else: + result = self.get_difference_table(return_css_separated=True) + + if result is None: + txt_changes = 'No changes were made in this frame.' + else: + css, txt_changes = result + + txt_changes = 'Changes made in this frame:' + txt_changes + '

' + + cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i) + + if orphan_cells == []: + txt_orphan_cells = 'No orphan Cells!' + else: + txt_orphan_cells = ', '.join([str(cell) for cell in orphan_cells]) + txt_orphan = f'Orphan cells:
{txt_orphan_cells}

' + + lost_cells = list(lost_cells) + if lost_cells == []: + txt_lost_cells = 'No lost Cells!' + else: + txt_lost_cells = ', '.join([str(cell) for cell in lost_cells]) + txt_lost = f'Lost cells:
{txt_lost_cells}

' + + if cells_with_parent == []: + table_cells_with_parent = '
No cells with parents!' + else: + table_cells_with_parent = """ + + + + """ + + for cell, parent in cells_with_parent: + table_cells_with_parent += f''' + + + ''' + table_cells_with_parent += '
Parent IDID
{parent}{cell}
' + + txt_cells_with_parents = f'Cells with parents:{table_cells_with_parent}

' + + css = r''' + + ''' + + txt = css + html_utils.paragraph(txt_changes + txt_orphan + txt_lost + txt_cells_with_parents) + + msg = widgets.myMessageBox() + msg.information(self.host, + 'lineage tree information', + txt + ) + + @disableWindow + def askLineageTreeChanges(self): + """ + Asks the user for changes in the lineage tree. + + This method is called when the user selects the 'Normal division: Lineage tree' mode. + It compared the backed up df (self.original_df from repeat_click_and_backup) with the current df (self.lineage_tree.export_df(posData.frame_i)) and propts the user to keep, propagate or discard the changes. + + """ + mode = str(self.modeComboBox.currentText()) + if not self.view_model.is_lineage_mode(mode): + return + + if not self.lineage_tree: + return + + posData = self.data[self.pos_i] + + if self.original_df_lin_tree_i is not None and self.original_df_lin_tree_i != posData.frame_i: + printl("!This should not happen!") + self.store_data(autosave=False) + og_frame = posData.frame_i + posData.frame_i = self.original_df_lin_tree_i + self.get_data() + self.logger.info('Lineage tree changes were not propagated, going back to original frame.') + self.askLineageTreeChanges() + self.store_data(autosave=False) + posData.frame_i = og_frame + self.get_data() + return + + result = self.get_difference_table(return_css_separated=True, return_differece=True) + if result is None: + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + return + + css, txt, differences = result + changed_IDs = differences['Cell_ID'].unique() + + if posData.frame_i == max(self.lineage_tree.frames_for_dfs): + # here we can just propagate the cahnged. This is super fast, since there is no recursion, no children and fast finding of parents + self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + return + + txt = txt + 'Do you want to keep, propgagte or discard the changes?' + txt = css + html_utils.paragraph('Changes made in this frame
' + txt) + + msg = widgets.myMessageBox() + + propagate_btn, discard_btn, _ = msg.question(self.host, + 'Changes in lineage tree', + txt, + buttonsTexts=('Propagate', 'Discard', 'Cancel'),) + + if msg.clickedButton == propagate_btn: + self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + self.logger.info('Lineage tree propagated.') + + elif msg.clickedButton == discard_btn: + posData.allData_li[posData.frame_i]['acdc_df'] = self.original_df_lin_tree.copy() + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + self.logger.info('Lineage tree changes discarded.') + + + elif msg.cancel: + # Go back to current frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(''' + Changes were kept but not propagated! + Please make sure to come back and propagate them, + otherwise your table might be inconsistent! + There is a button for this next to the edit buttons. + Please also do not visit new frames! + + ''') + msg.warning(self.host, 'Changes kept but not propagated!', txt) + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + self.logger.info('Lineage tree changes discarded.') + + def repeat_click_and_backup(self, posData, event, ydata, xdata): + """ + This function is part of the lin_tree edit functionality. + It handles the back up of the original self.lineage_tree.lineage_list + df and the repeated clicking on the same ID to cycle through pssible mothers. + + Parameters + ---------- + posData : cellacdc.load.loadData + The position data. + event : QtGui.QMouseEvent + The event object. + ydata : int + The y-coordinate data. + xdata : int + The x-coordinate data. + + Returns + ------- + tuple + A tuple containing the point(tuple: (x, y) coords) and ID of clicked cell. + """ + should_log_reset = ( + self.original_df_lin_tree is not None + and self.original_df_lin_tree_i != posData.frame_i + ) + if self.view_model.should_backup_original( + self.original_df_lin_tree_i, + posData.frame_i, + ): + if should_log_reset: + self.logger.info( + '[WARNING]: !!! Original lineage tree df changed, ' + 'resetting original_df_lin_tree !!!' + ) + self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() + self.original_df_lin_tree_i = posData.frame_i + + if not self.right_click_ID: + self.right_click_i = 0 + self.right_click_ID = 0 + + x, y = event.pos().x(), event.pos().y() + point = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + + if ID == 0: + return None, None + + if self.right_click_ID != ID: + self.right_click_i = 0 + self.right_click_ID = ID + self.original_mother_skipped = False + elif event.modifiers() & Qt.ShiftModifier: + self.right_click_i -= 1 + else: + self.right_click_i += 1 + + return point, ID + + def getDistanceListMissingIDs(self, point, ID): + posData = self.data[self.pos_i] + frame_i = posData.frame_i + if self.getDistanceListMissingIDsCachedFrame != frame_i: + self.distanceListMissingIDs = dict() + self.getDistanceListMissingIDsCachedFrame = frame_i + # self.store_data(autosave=False) + # self.get_data() + + if ID not in self.distanceListMissingIDs.keys(): + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + relevant_rp = [ + obj for obj in prev_rp if obj.label not in posData.IDs + ] + len_relevant_rp = len(relevant_rp) + if len_relevant_rp == 0: + self.logger.info('No missing IDs found in previous frame.') + return [] + elif len_relevant_rp == 1: + self.distanceListMissingIDs[ID] = [relevant_rp[0].label] + return [relevant_rp[0].label] + else: + sorted_missing_IDs = self.host.view_model.lineage.sort_ids_by_distance( + relevant_rp, point=point + ) + self.distanceListMissingIDs[ID] = sorted_missing_IDs + return sorted_missing_IDs + else: + return self.distanceListMissingIDs[ID] + + def find_mother_action(self, posData, event, ydata, xdata): + """ + This function is part of the lin_tree edit functionality. + Associated with the right-click action of the 'findNextMotherButton' button. + Handles the right click action, which cycles through possible mothers of the clicked cell. + Changes the parent ID of the clicked cell to the next possible mother in self.lineage_tree.lineage_list. + + Parameters + ---------- + posData : cellacdc.load.loadData + The position data object. + event : QtGui.QMouseEvent + The event object. + ydata : int + The y-coordinate data. + xdata : int + The x-coordinate data. + """ + point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata) + + if point is None: + return + posData = self.data[self.pos_i] + acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] + filtered_IDs = self.getDistanceListMissingIDs(point, ID) + if len(filtered_IDs) == 0: + self.logger.info('No mother candidates found.') + return + + i = self.view_model.next_candidate_index( + self.right_click_i, + len(filtered_IDs), + ) + new_mother = filtered_IDs[i] + + if self.view_model.should_skip_original_mother( + acdc_df_frame.loc[ID]['parent_ID_tree'], + new_mother, + original_mother_skipped=self.original_mother_skipped, + ): # if a mother is already present, skip it + self.right_click_i += 1 + self.original_mother_skipped = True + + i = self.view_model.next_candidate_index( + self.right_click_i, + len(filtered_IDs), + ) + new_mother = filtered_IDs[i] + + acdc_df_frame.at[ID, 'parent_ID_tree'] = new_mother # update mother in the df, no need to propagate or stuff lile this + # dont need to update alldata_li as acdc_df_frame is just a view + self.drawAllLineageTreeLines() + + def annotate_unknown_lineage_action(self, posData, event, ydata, xdata): + """ + This function is part of the lin_tree edit functionality. + Associated with the right-click action of the 'unknownLineageButton' button. + Annotates an unknown lineage by setting its parent ID to -1 in the lineage tree (self.lineage_tree.lineage_list) + + Parameters + ---------- + posData : cellacdc.load.loadData + The position data. + event : QtGui.QMouseEvent + The event that triggered the annotation. + ydata : int + The y-coordinate data. + xdata : int + The x-coordinate data. + """ + point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata) + + if point is None: + return + posData = self.data[self.pos_i] + acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] + acdc_df_frame.at[ID, 'parent_ID_tree'] = -1 + self.drawAllLineageTreeLines() + + @disableWindow + def get_difference_table(self, return_css_separated=False, return_differece=False): + + if self.original_df_lin_tree is None: + return + + posData = self.data[self.pos_i] + + new_df = posData.allData_li[posData.frame_i]['acdc_df'] + original_df = self.original_df_lin_tree.copy() + + if original_df.equals(new_df): + return + + differences = self.view_model.parent_id_differences( + original_df, + new_df, + self.host.view_model.tables.checked_reset_index_cell_id, + ) + if differences is None: + return + + txt = """ + + + + + """ + + for diff in differences.itertuples(): + ID = str(int(diff.Cell_ID)) + old_parent = str(int(diff.self)) + new_parent = str(int(diff.other)) + + txt += f''' + + + + ''' + txt += '
IDold parent -->new parent
{ID}{old_parent}{new_parent}
' + + css = r''' + + ''' + if return_css_separated and not return_differece: + return css, txt + elif return_css_separated and return_differece: + return css, txt, differences + elif not return_css_separated and return_differece: + return txt, differences + else: + txt = css + html_utils.paragraph(txt) + return txt diff --git a/cellacdc/views/magic_prompts_view.py b/cellacdc/views/magic_prompts_view.py new file mode 100644 index 000000000..753c11588 --- /dev/null +++ b/cellacdc/views/magic_prompts_view.py @@ -0,0 +1,412 @@ +"""Qt view adapter for promptable segmentation workflows.""" + +from __future__ import annotations + +from functools import partial + +from qtpy.QtCore import QEventLoop, QThread + +from cellacdc import ( + _warnings, + apps, + exception_handler, + html_utils, + prompts, + qutils, + widgets, + workers, +) +from cellacdc import disableWindow +from cellacdc.viewmodels.magic_prompts_viewmodel import MagicPromptsViewModel + + +class MagicPromptsView: + """Qt-facing adapter around promptable segmentation dialogs and workers.""" + + def __init__(self, host, view_model: MagicPromptsViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def showInstructionsCustomPromptModel(self): + modelFilePath = apps.addCustomPromptModelMessages(QParent=self.host) + if modelFilePath is None: + self.logger.info('Adding custom promptable model process stopped.') + return + + self.view_model.store_custom_promptable_model_path( + modelFilePath + ) + + msg = widgets.myMessageBox(wrapText=False) + info_txt = html_utils.paragraph(f""" + Done!

+ The custom promptable model has been added to the list of models.

+ Use the Magic prompts button (top toolbar) to use it.

+ Have fun! + """) + msg.information(self.host, 'Custom promptable model added', info_txt) + + def segmWithPromptableModelActionTriggered(self): + self.blinker = qutils.QControlBlink( + self.magicPromptsToolButton, qparent=self.host + ) + self.blinker.start() + + @disableWindow + def _importInitMagicPromptModel( + self, model_name, posData, win, acdcPromptSegment, toolbar + ): + self.logger.info(f'Initializing promptable model {model_name}...') + init_kwargs = win.init_kwargs + model = self.view_model.init_prompt_segmentation_model( + acdcPromptSegment, posData, win.init_kwargs + ) + toolbar.model = model + toolbar.model_segment_kwargs = win.model_kwargs + toolbar.model_name = model_name + toolbar.viewModelParamsAction.setDisabled(False) + + self.magicPromptsToolbar.setInitializedModel( + init_kwargs, toolbar.model_segment_kwargs + ) + + self.logger.info( + f'Promptable model {model_name} successfully initialised!' + ) + + @exception_handler + def magicPromptsInitModel( + self, + model_name, + acdcPromptSegment, + init_argspecs, + segment_argspecs, + help_url, + toolbar, + ): + posData = self.data[self.pos_i] + + out = prompts.init_prompt_model_params( + posData, model_name, init_argspecs, segment_argspecs, + help_url=help_url, qparent=self.host, init_last_params=True + ) + win = out.get('win') + if win.cancel: + self.logger.info( + f'Initialization of {model_name} promptable model cancelled.' + ) + return + + self._importInitMagicPromptModel( + model_name, posData, win, acdcPromptSegment, toolbar + ) + + def viewSetMagicPromptModelParams( + self, + model_name, + acdcPromptSegment, + init_argspecs, + segment_argspecs, + help_url, + init_kwargs, + segment_kwargs, + toolbar + ): + posData = self.data[self.pos_i] + + init_argspecs = ( + self.view_model.set_default_arg_specs_from_kwargs( + init_argspecs, init_kwargs + ) + ) + segment_argspecs = ( + self.view_model.set_default_arg_specs_from_kwargs( + segment_argspecs, segment_kwargs + ) + ) + + out = prompts.init_prompt_model_params( + posData, model_name, init_argspecs, segment_argspecs, + help_url=help_url, qparent=self.host, init_last_params=False + ) + win = out.get('win') + if win.cancel: + return + + if win.model_kwargs != segment_kwargs or win.init_kwargs != init_kwargs: + self._importInitMagicPromptModel( + model_name, posData, win, acdcPromptSegment, toolbar + ) + + def getMagicPromptsInputs(self, toolbar): + if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit: + _warnings.warnPromptSegmentPointsLayerNotInit(qparent=self.host) + return + + if not self.magicPromptsToolbar.viewModelParamsAction.isEnabled(): + _warnings.warnPromptSegmentModelNotInit(qparent=self.host) + return + + posData = self.data[self.pos_i] + image = self.getDisplayedZstack() + df_points = self.promptSegmentPointsLayerToolbar.pointsLayerDf( + posData, isSegm3D=self.isSegm3D + ) + + self.logger.info( + f'Starting {toolbar.model_name} promptable segmentation with the ' + f'following prompts:\n\n{df_points}' + ) + + return image, df_points + + @disableWindow + def magicPromptsComputeOnZoomTriggered(self, toolbar): + inputs = self.getMagicPromptsInputs(toolbar) + if inputs is None: + self.logger.info( + '"Computing promptable segmentation on zoom" process cancelled.' + ) + return + + posData = self.data[self.pos_i] + image, df_points = inputs + + zoom = self.view_model.zoom_region(self.ax1.viewRange(), image.shape) + xmin, xmax, ymin, ymax = zoom.bounds + + self.logger.info( + f'Zoom range: xmin={xmin}, xmax={xmax}, ymin={ymin}, ymax={ymax}' + ) + + zoom_slice = zoom.zoom_slice + image = image[..., zoom_slice[0], zoom_slice[1]] + image_origin = zoom.image_origin + df_points = self.view_model.points_in_zoom( + df_points, + zoom, + posData.frame_i, + ) + + self.logger.info( + f'Image origin = {image_origin}\n' + f'Image shape = {image.shape}' + ) + + self.startMagicPromptsWorkerAndWait( + image, df_points, toolbar.model, toolbar.model_segment_kwargs, + image_origin=image_origin, zoom_slice=zoom_slice + ) + + def magicPromptsInterpolateZsliceToggled(self, checked): + # See 'self.promptSegmentPointsLayerToolbar.addPointsZslicesInterpolation' + self.promptSegmentPointsLayerToolbar.doAddPointsZslicesInterpolation = ( + checked + ) + + def magicPromptsClearPoints(self, toolbar, only_zoom=False): + posData = self.data[self.pos_i] + scatterItem = self.promptSegmentPointsLayerToolbar.scatterItem() + action = scatterItem.action + + pointsDataPos = action.pointsData.get(self.pos_i) + if pointsDataPos is None: + return + + framePointsData = action.pointsData[self.pos_i].pop( + posData.frame_i, None + ) + if framePointsData is None: + return + + if not only_zoom: + scatterItem.clear() + return + + zoom = self.view_model.zoom_region( + self.ax1.viewRange(), + posData.img_data.shape, + ) + newFramePointsData = self.view_model.retained_points_outside_zoom( + framePointsData, + zoom, + ) + + action.pointsData[self.pos_i][posData.frame_i] = newFramePointsData + self.drawPointsLayers() + + @disableWindow + def magicPromptsComputeOnImageTriggered(self, toolbar): + inputs = self.getMagicPromptsInputs(toolbar) + if inputs is None: + self.logger.info( + '"Computing promptable segmentation on entire image" ' + 'process cancelled.' + ) + return + + image, df_points = inputs + + self.startMagicPromptsWorkerAndWait( + image, df_points, toolbar.model, toolbar.model_segment_kwargs + ) + + def startMagicPromptsWorkerAndWait( + self, image, df_points, model, model_segment_kwargs, + image_origin=(0, 0, 0), zoom_slice=None + ): + desc = ( + 'Running promptable segmentation model...' + ) + self.logger.info(desc) + posData = self.data[self.pos_i] + + self.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self.host, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.show(self.app) + + self.magicPromptsThread = QThread() + self.magicPromptsWorker = workers.MagicPromptsWorker( + posData, image, df_points, model, model_segment_kwargs, + image_origin=image_origin, + global_image=posData.img_data[posData.frame_i] + ) + + self.magicPromptsWorker.moveToThread( + self.magicPromptsThread + ) + + self.magicPromptsWorker.signals.finished.connect( + self.magicPromptsThread.quit + ) + self.magicPromptsWorker.signals.finished.connect( + self.magicPromptsWorker.deleteLater + ) + self.magicPromptsThread.finished.connect( + self.magicPromptsThread.deleteLater + ) + + self.magicPromptsWorker.signals.critical.connect( + self.magicPromptsWorkerCritical + ) + self.magicPromptsWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.magicPromptsWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.magicPromptsWorker.signals.progress.connect( + self.workerProgress + ) + self.magicPromptsWorker.signals.finished.connect( + partial(self.magicPromptsWorkerFinished, zoom_slice=zoom_slice) + ) + + self.magicPromptsThread.started.connect( + self.magicPromptsWorker.run + ) + self.magicPromptsThread.start() + + self.magicPromptsWorkerLoop = QEventLoop() + self.magicPromptsWorkerLoop.exec_() + + def magicPromptsWorkerCritical(self, error): + self.magicPromptsWorkerLoop.exit() + self.workerCritical(error) + + def magicPromptsWorkerFinished(self, output, zoom_slice=None): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.magicPromptsWorkerLoop.exit() + + lab_new, lab_union, lab_interesection = output + + posData = self.data[self.pos_i] + + is_zoom = True + if zoom_slice is None: + zoom_slice = (slice(None), slice(None)) + is_zoom = False + + img = ( + posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]] + ) + images = [img, img, img, img] + labels_overlays = [ + posData.lab[..., zoom_slice[0], zoom_slice[1]], + lab_new[..., zoom_slice[0], zoom_slice[1]], + lab_union[..., zoom_slice[0], zoom_slice[1]], + lab_interesection[..., zoom_slice[0], zoom_slice[1]], + ] + labels_overlays_lut = self.getLabelsImageLut() + labels_overlays_luts = [ + labels_overlays_lut, + labels_overlays_lut, + labels_overlays_lut, + labels_overlays_lut, + ] + axis_titles = [ + 'Original masks', + 'New masks', + 'Union of original and new masks', + 'Intersection of original and new masks' + ] + + from cellacdc.plot import imshow + promptSegmResultsWindow = imshow( + *images, + labels_overlays=labels_overlays, + labels_overlays_luts=labels_overlays_luts, + axis_titles=axis_titles, + window_title='Promptable segmentation results', + figure_title='Ctrl+Click to select the result to use', + annotate_labels_idxs=[0, 1, 2, 3], + selectable_images=True, + max_ncols=2, + lut='gray', + infer_rgb=False + ) + if promptSegmResultsWindow.selected_idx is None: + self.logger.info( + 'Selection of the promptable model segmentation ' + 'result cancelled.' + ) + return + + if promptSegmResultsWindow.selected_idx == 0: + self.logger.info( + 'No selection of a promptable model segmentation ' + 'result was made' + ) + return + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + results = (None, lab_new, lab_union, lab_interesection) + selected_idx = promptSegmResultsWindow.selected_idx + zoom_out_lab = results[selected_idx][..., zoom_slice[0], zoom_slice[1]] + zoom_out_lab_mask = zoom_out_lab > 0 + + lab = posData.allData_li[posData.frame_i]['labels'] + lab[..., zoom_slice[0], zoom_slice[1]][zoom_out_lab_mask] = ( + zoom_out_lab[zoom_out_lab_mask] + ) + + posData.allData_li[posData.frame_i]['labels'] = lab + self.get_data() + self.store_data(autosave=False) + self.updateAllImages() diff --git a/cellacdc/views/main_menu_view.py b/cellacdc/views/main_menu_view.py new file mode 100644 index 000000000..1a7d8741d --- /dev/null +++ b/cellacdc/views/main_menu_view.py @@ -0,0 +1,222 @@ +"""View adapter for the main menu.""" + +from __future__ import annotations + +from qtpy.QtWidgets import QAction, QActionGroup, QMenu + +from cellacdc.viewmodels.main_menu_viewmodel import MainMenuViewModel + + +class MainMenuView: + """Qt-facing adapter around the main-menu view-model.""" + + def __init__(self, host, view_model: MainMenuViewModel): + self.host = host + self.view_model = view_model + + def create_menu_bar(self): + menu_bar = self.host.menuBar() + menu_bar.setNativeMenuBar(False) + + self._add_file_menu(menu_bar) + self._add_edit_menu(menu_bar) + self._add_view_menu(menu_bar) + self._add_image_menu(menu_bar) + self._add_segment_menu(menu_bar) + self._add_tracking_menu(menu_bar) + self._add_measurements_menu(menu_bar) + self._add_settings_menu(menu_bar) + self._add_mode_menu(menu_bar) + self._add_help_menu(menu_bar) + + def _add_file_menu(self, menu_bar): + file_menu = QMenu("&File", self.host) + self.host.fileMenu = file_menu + menu_bar.addMenu(file_menu) + if self.host.debug: + file_menu.addAction(self.host.createEmptyDataAction) + file_menu.addAction(self.host.newAction) + file_menu.addAction(self.host.newWindowAction) + file_menu.addSeparator() + file_menu.addAction(self.host.openFolderAction) + file_menu.addAction(self.host.openFileAction) + self.host.openRecentMenu = file_menu.addMenu("Open Recent") + file_menu.addSeparator() + file_menu.addAction(self.host.manageVersionsAction) + file_menu.addAction(self.host.saveAction) + file_menu.addAction(self.host.saveAsAction) + file_menu.addAction(self.host.quickSaveAction) + file_menu.addSeparator() + + self.host.exportMenu = file_menu.addMenu('Export') + self.host.exportMenu.addAction(self.host.exportToVideoAction) + self.host.exportMenu.addAction(self.host.exportToImageAction) + file_menu.addSeparator() + file_menu.addAction(self.host.loadFluoAction) + file_menu.addAction(self.host.loadPosAction) + self.host.fileMenu.lastSeparator = file_menu.addSeparator() + file_menu.addAction(self.host.exitAction) + + def _add_edit_menu(self, menu_bar): + edit_menu = menu_bar.addMenu("&Edit") + edit_menu.addSeparator() + edit_menu.addAction(self.host.editShortcutsAction) + edit_menu.addAction(self.host.editTextIDsColorAction) + edit_menu.addAction(self.host.editOverlayColorAction) + edit_menu.addAction(self.host.manuallyEditCcaAction) + edit_menu.addAction(self.host.enableSmartTrackAction) + edit_menu.addAction(self.host.enableAutoZoomToCellsAction) + + def _add_view_menu(self, menu_bar): + self.host.viewMenu = menu_bar.addMenu("&View") + self.host.viewMenu.addSeparator() + self.host.viewMenu.addAction(self.host.viewCcaTableAction) + + def _add_image_menu(self, menu_bar): + image_menu = menu_bar.addMenu("&Image") + image_menu.addSeparator() + image_menu.addAction(self.host.imgPropertiesAction) + self.host.defaultRescaleIntensLutMenu = image_menu.addMenu( + "Default method to rescale intensities (LUT)" + ) + self._add_default_rescale_intensity_menu() + + image_menu.addAction(self.host.addScaleBarAction) + image_menu.addAction(self.host.addTimestampAction) + self.host.rescaleIntensMenu = image_menu.addMenu( + 'Rescale intensities (LUT)' + ) + image_menu.addAction(self.host.preprocessAction) + image_menu.addAction(self.host.combineChannelsAction) + image_menu.addAction(self.host.saveLabColormapAction) + image_menu.addAction(self.host.shuffleCmapAction) + image_menu.addAction(self.host.greedyShuffleCmapAction) + image_menu.addAction(self.host.zoomToObjsAction) + image_menu.addAction(self.host.zoomOutAction) + + def _add_default_rescale_intensity_menu(self): + self.host.defaultRescaleIntensActionGroup = QActionGroup( + self.host.defaultRescaleIntensLutMenu + ) + self.host.defaultRescaleIntensHow = ( + self.view_model.default_rescale_intensity_how( + self.host.df_settings + ) + ) + for how_text in self.view_model.default_rescale_intensity_options(): + action = QAction( + how_text, self.host.defaultRescaleIntensLutMenu + ) + action.setCheckable(True) + if how_text == self.host.defaultRescaleIntensHow: + action.setChecked(True) + + self.host.defaultRescaleIntensActionGroup.addAction(action) + self.host.defaultRescaleIntensLutMenu.addAction(action) + + def _add_segment_menu(self, menu_bar): + segment_menu = menu_bar.addMenu("&Segment") + self.host.segmentMenu = segment_menu + segment_menu.addSeparator() + self.host.segmSingleFrameMenu = segment_menu.addMenu( + 'Segment displayed frame' + ) + for action in self.host.segmActions: + self.host.segmSingleFrameMenu.addAction(action) + + self.host.segmSingleFrameMenu.addSeparator() + self.host.segmSingleFrameMenu.addAction( + self.host.addCustomModelFrameAction + ) + + self.host.segmVideoMenu = segment_menu.addMenu( + 'Segment multiple frames' + ) + for action in self.host.segmActionsVideo: + self.host.segmVideoMenu.addAction(action) + + self.host.segmVideoMenu.addSeparator() + self.host.segmVideoMenu.addAction( + self.host.addCustomModelVideoAction + ) + + self.host.segmWithPromptableModelMenu = segment_menu.addMenu( + 'Segment with promptable model' + ) + self.host.segmWithPromptableModelMenu.addAction( + self.host.segmWithPromptableModelAction + ) + self.host.segmWithPromptableModelMenu.addSeparator() + self.host.segmWithPromptableModelMenu.addAction( + self.host.addCustomPromptModelAction + ) + + segment_menu.addAction(self.host.EditSegForLostIDsSetSettings) + segment_menu.addAction(self.host.postProcessSegmAction) + segment_menu.addAction(self.host.autoSegmAction) + segment_menu.addAction(self.host.relabelSequentialAction) + segment_menu.aboutToShow.connect( + self.host.mode_controls_view.nonViewerEditMenuOpened + ) + + def _add_tracking_menu(self, menu_bar): + tracking_menu = menu_bar.addMenu("&Tracking") + self.host.trackingMenu = tracking_menu + tracking_menu.addSeparator() + select_track_algo_menu = tracking_menu.addMenu( + 'Select real-time tracking algorithm' + ) + for action in self.host.trackingAlgosGroup.actions(): + select_track_algo_menu.addAction(action) + + tracking_menu.addAction(self.host.editRtTrackerParamsAction) + tracking_menu.addAction(self.host.repeatTrackingVideoAction) + tracking_menu.addAction(self.host.repeatTrackingMenuAction) + tracking_menu.aboutToShow.connect( + self.host.mode_controls_view.nonViewerEditMenuOpened + ) + + if self.host.mainWin is not None: + tracking_menu.addAction( + self.host.mainWin.applyTrackingFromTableAction + ) + tracking_menu.addAction( + self.host.mainWin.applyTrackingFromTrackMateXMLAction + ) + + def _add_measurements_menu(self, menu_bar): + measurements_menu = menu_bar.addMenu("&Measurements") + self.host.measurementsMenu = measurements_menu + measurements_menu.addSeparator() + measurements_menu.addAction(self.host.setMeasurementsAction) + measurements_menu.addAction(self.host.addCustomMetricAction) + measurements_menu.addAction(self.host.addCombineMetricAction) + measurements_menu.setDisabled(True) + + def _add_settings_menu(self, menu_bar): + self.host.settingsMenu = QMenu("Settings", self.host) + menu_bar.addMenu(self.host.settingsMenu) + self.host.settingsMenu.addAction(self.host.invertBwAction) + self.host.settingsMenu.addAction(self.host.toggleColorSchemeAction) + self.host.settingsMenu.addSeparator() + self.host.settingsMenu.addAction(self.host.pxModeAction) + self.host.settingsMenu.addAction(self.host.highLowResAction) + self.host.settingsMenu.addAction(self.host.editShortcutsAction) + self.host.settingsMenu.addAction(self.host.showMirroredCursorAction) + self.host.settingsMenu.addSeparator() + self.host.settingsMenu.addAction(self.host.editAutoSaveIntervalAction) + self.host.settingsMenu.addSeparator() + + def _add_mode_menu(self, menu_bar): + self.host.modeMenu = menu_bar.addMenu('Mode') + self.host.modeMenu.menuAction().setVisible(False) + + def _add_help_menu(self, menu_bar): + help_menu = menu_bar.addMenu("&Help") + help_menu.addAction(self.host.openLogFileAction) + help_menu.addAction(self.host.showLogFilesAction) + help_menu.addAction(self.host.tipsAction) + help_menu.addAction(self.host.UserManualAction) + help_menu.addSeparator() + help_menu.addAction(self.host.aboutAction) + self.host.helpMenu = help_menu diff --git a/cellacdc/views/main_toolbar_view.py b/cellacdc/views/main_toolbar_view.py new file mode 100644 index 000000000..3e8d32bee --- /dev/null +++ b/cellacdc/views/main_toolbar_view.py @@ -0,0 +1,596 @@ +"""Qt view adapter for the main GUI toolbars.""" + +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import QAction, QActionGroup, QButtonGroup, QToolButton + +import pyqtgraph as pg + +from cellacdc import widgets +from cellacdc.viewmodels.main_toolbar_viewmodel import MainToolbarViewModel + + +class MainToolbarView: + """Qt-facing adapter around top-level toolbar construction.""" + + def __init__(self, host, view_model: MainToolbarViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def closeToolbars(self): + for toolbar in self.sender().toolbars: + toolbar.setVisible(False) + for action in toolbar.actions(): + try: + action.button.setChecked(False) + except Exception as e: + pass + + def gui_createToolBars(self): + # File toolbar + fileToolBar = self.addToolBar("File") + # fileToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) + fileToolBar.setMovable(False) + + self.segmNdimIndicatorAction = fileToolBar.addWidget( + self.segmNdimIndicator + ) + self.segmNdimIndicatorAction.setVisible(False) + fileToolBar.addAction(self.newAction) + fileToolBar.addAction(self.openFolderAction) + fileToolBar.addAction(self.openFileAction) + fileToolBar.addAction(self.manageVersionsAction) + fileToolBar.addAction(self.saveAction) + fileToolBar.addAction(self.showInExplorerAction) + # fileToolBar.addAction(self.reloadAction) + fileToolBar.addAction(self.undoAction) + fileToolBar.addAction(self.redoAction) + self.fileToolBar = fileToolBar + self.mode_controls_view.setEnabledFileToolbar(False) + + self.undoAction.setEnabled(False) + self.redoAction.setEnabled(False) + + # Navigation toolbar + navigateToolBar = widgets.ToolBar("Navigation", self.host) + navigateToolBar.setContextMenuPolicy(Qt.PreventContextMenu) + # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) + self.addToolBar(navigateToolBar) + navigateToolBar.addAction(self.findIdAction) + + navigateToolBar.addWidget(self.zoomRectButton) + + self.slideshowButton = QToolButton(self.host) + self.slideshowButton.setIcon(QIcon(":eye-plus.svg")) + self.slideshowButton.setCheckable(True) + self.slideshowButton.setShortcut('Ctrl+W') + navigateToolBar.addWidget(self.slideshowButton) + + navigateToolBar.addAction(self.autoPilotButton) + + # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) + navigateToolBar.addAction(self.skipToNewIdAction) + + self.preprocessImageAction = QAction('Preprocess image', self.host) + self.preprocessImageAction.setIcon(QIcon(":filter_image.svg")) + navigateToolBar.addAction(self.preprocessImageAction) + + self.overlayButton = widgets.rightClickToolButton(parent=self.host) + self.overlayButton.setIcon(QIcon(":overlay.svg")) + self.overlayButton.setCheckable(True) + + self.overlayButtonAction = navigateToolBar.addWidget(self.overlayButton) + # self.checkableButtons.append(self.overlayButton) + # self.checkableQButtonsGroup.addButton(self.overlayButton) + + self.countObjsButton = QToolButton(self.host) + self.countObjsButton.setIcon(QIcon(":count_objects.svg")) + self.countObjsButton.setCheckable(True) + self.countObjsButton.setShortcut('Ctrl+Shift+C') + self.countObjsButtonAction = navigateToolBar.addWidget( + self.countObjsButton + ) + + self.togglePointsLayerAction = QAction( + 'Activate points layer', + self.host, + ) + self.togglePointsLayerAction.setCheckable(True) + self.togglePointsLayerAction.setIcon(QIcon(":pointsLayer.svg")) + navigateToolBar.addAction(self.togglePointsLayerAction) + + self.overlayLabelsButton = widgets.rightClickToolButton( + parent=self.host + ) + self.overlayLabelsButton.setIcon(QIcon(":overlay_labels.svg")) + self.overlayLabelsButton.setCheckable(True) + # self.overlayLabelsButton.setVisible(False) + self.overlayLabelsButtonAction = navigateToolBar.addWidget( + self.overlayLabelsButton + ) + self.overlayLabelsButtonAction.setVisible(False) + + self.rulerButton = QToolButton(self.host) + self.rulerButton.setIcon(QIcon(":ruler.svg")) + self.rulerButton.setCheckable(True) + navigateToolBar.addWidget(self.rulerButton) + self.checkableButtons.append(self.rulerButton) + self.LeftClickButtons.append(self.rulerButton) + + # fluorescence image color widget + colorsToolBar = widgets.ToolBar("Colors", self.host) + + self.overlayColorButton = pg.ColorButton( + self.host, color=(230,230,230) + ) + self.overlayColorButton.setDisabled(True) + colorsToolBar.addWidget(self.overlayColorButton) + + self.textIDsColorButton = pg.ColorButton(self.host) + colorsToolBar.addWidget(self.textIDsColorButton) + + self.addToolBar(colorsToolBar) + colorsToolBar.setVisible(False) + + self.navigateToolBar = navigateToolBar + + # cca toolbar + ccaToolBar = widgets.ToolBar("Cell cycle annotations", self.host) + self.addToolBar(ccaToolBar) + + # Assign mother to bud button + self.assignBudMothButton = QToolButton(self.host) + self.assignBudMothButton.setIcon(QIcon(":assign-motherbud.svg")) + self.assignBudMothButton.setCheckable(True) + self.assignBudMothButton.setShortcut('A') + self.assignBudMothButton.setVisible(False) + self.assignBudMothButton.action = ccaToolBar.addWidget( + self.assignBudMothButton + ) + self.checkableButtons.append(self.assignBudMothButton) + self.checkableQButtonsGroup.addButton(self.assignBudMothButton) + self.functionsNotTested3D.append(self.assignBudMothButton) + + + # Set is_history_known button + self.setIsHistoryKnownButton = QToolButton(self.host) + self.setIsHistoryKnownButton.setIcon(QIcon(":history.svg")) + self.setIsHistoryKnownButton.setCheckable(True) + self.setIsHistoryKnownButton.setShortcut('U') + self.setIsHistoryKnownButton.setVisible(False) + self.setIsHistoryKnownButton.action = ccaToolBar.addWidget( + self.setIsHistoryKnownButton + ) + self.checkableButtons.append(self.setIsHistoryKnownButton) + self.checkableQButtonsGroup.addButton(self.setIsHistoryKnownButton) + self.functionsNotTested3D.append(self.setIsHistoryKnownButton) + + ccaToolBar.addAction(self.assignBudMothAutoAction) + ccaToolBar.addAction(self.editCcaToolAction) + ccaToolBar.addAction(self.reInitCcaAction) + ccaToolBar.setVisible(False) + self.ccaToolBar = ccaToolBar + self.functionsNotTested3D.append(self.assignBudMothAutoAction) + self.functionsNotTested3D.append(self.reInitCcaAction) + self.functionsNotTested3D.append(self.editCcaToolAction) + + # Edit toolbar + editToolBar = widgets.ToolBar("Edit", self.host) + editToolBar.setContextMenuPolicy(Qt.PreventContextMenu) + + self.addToolBar(editToolBar) + + self.manulAnnotToolButtons = set() + + self.brushButton = QToolButton(self.host) + self.brushButton.setIcon(QIcon(":brush.svg")) + self.brushButton.setCheckable(True) + editToolBar.addWidget(self.brushButton) + self.checkableButtons.append(self.brushButton) + self.LeftClickButtons.append(self.brushButton) + self.brushButton.keyPressShortcut = Qt.Key_B + self.widgetsWithShortcut['Brush'] = self.brushButton + self.manulAnnotToolButtons.add(self.brushButton) + + self.eraserButton = QToolButton(self.host) + self.eraserButton.setIcon(QIcon(":eraser.svg")) + self.eraserButton.setCheckable(True) + editToolBar.addWidget(self.eraserButton) + self.eraserButton.keyPressShortcut = Qt.Key_X + self.widgetsWithShortcut['Eraser'] = self.eraserButton + self.checkableButtons.append(self.eraserButton) + self.LeftClickButtons.append(self.eraserButton) + self.manulAnnotToolButtons.add(self.eraserButton) + + self.curvToolButton = QToolButton(self.host) + self.curvToolButton.setIcon(QIcon(":curvature-tool.svg")) + self.curvToolButton.setCheckable(True) + self.curvToolButton.setShortcut('C') + self.curvToolButton.action = editToolBar.addWidget(self.curvToolButton) + self.LeftClickButtons.append(self.curvToolButton) + # self.functionsNotTested3D.append(self.curvToolButton) + self.widgetsWithShortcut['Curvature tool'] = self.curvToolButton + # self.checkableButtons.append(self.curvToolButton) + self.manulAnnotToolButtons.add(self.curvToolButton) + + self.wandToolButton = QToolButton(self.host) + self.wandToolButton.setIcon(QIcon(":magic_wand.svg")) + self.wandToolButton.setCheckable(True) + self.wandToolButton.setShortcut('Ctrl+D') + self.wandToolButton.action = editToolBar.addWidget(self.wandToolButton) + self.LeftClickButtons.append(self.wandToolButton) + self.checkableButtons.append(self.eraserButton) + self.widgetsWithShortcut['Magic wand'] = self.wandToolButton + + self.magicPromptsToolButton = QToolButton(self.host) + self.magicPromptsToolButton.setIcon(QIcon(":magic-prompts.svg")) + self.magicPromptsToolButton.setCheckable(True) + self.magicPromptsToolButton.setShortcut('W') + self.magicPromptsToolButton.action = editToolBar.addWidget( + self.magicPromptsToolButton + ) + self.widgetsWithShortcut['Magic prompts'] = self.magicPromptsToolButton + + self.drawClearRegionButton = QToolButton(self.host) + self.drawClearRegionButton.setCheckable(True) + self.drawClearRegionButton.setIcon(QIcon(":clear_freehand_region.svg")) + self.widgetsWithShortcut['Clear freehand region'] = ( + self.drawClearRegionButton + ) + self.toolsActiveInProj3Dsegm.add(self.drawClearRegionButton) + + self.checkableButtons.append(self.drawClearRegionButton) + self.LeftClickButtons.append(self.drawClearRegionButton) + + self.drawClearRegionAction = editToolBar.addWidget( + self.drawClearRegionButton + ) + + self.widgetsWithShortcut['Annotate mother/daughter pairing'] = ( + self.assignBudMothButton + ) + self.widgetsWithShortcut['Annotate unknown history'] = ( + self.setIsHistoryKnownButton + ) + + self.copyLostObjButton = QToolButton(self.host) + self.copyLostObjButton.setIcon(QIcon(":copyContour.svg")) + self.copyLostObjButton.setCheckable(True) + self.copyLostObjButton.setShortcut('V') + self.copyLostObjButton.action = editToolBar.addWidget( + self.copyLostObjButton + ) + self.checkableButtons.append(self.copyLostObjButton) + self.checkableQButtonsGroup.addButton(self.copyLostObjButton) + self.widgetsWithShortcut['Copy lost object contour'] = ( + self.copyLostObjButton + ) + self.functionsNotTested3D.append(self.copyLostObjButton) + + self.labelRoiButton = widgets.rightClickToolButton(parent=self.host) + self.labelRoiButton.setIcon(QIcon(":label_roi.svg")) + self.labelRoiButton.setCheckable(True) + self.labelRoiButton.setShortcut('L') + self.labelRoiButton.action = editToolBar.addWidget(self.labelRoiButton) + self.LeftClickButtons.append(self.labelRoiButton) + self.checkableButtons.append(self.labelRoiButton) + self.checkableQButtonsGroup.addButton(self.labelRoiButton) + self.widgetsWithShortcut['Label ROI'] = self.labelRoiButton + # self.functionsNotTested3D.append(self.labelRoiButton) + + self.manualAnnotPastButton = QToolButton(self.host) + self.manualAnnotPastButton.setIcon(QIcon(":lock_id_annotate_future.svg")) + self.manualAnnotPastButton.setCheckable(True) + self.manualAnnotPastButton.setShortcut('Y') + self.manualAnnotPastButton.action = editToolBar.addWidget( + self.manualAnnotPastButton + ) + self.checkableButtons.append(self.manualAnnotPastButton) + self.widgetsWithShortcut['Lock ID and annotate single object'] = ( + self.manualAnnotPastButton + ) + self.functionsNotTested3D.append(self.manualAnnotPastButton) + self.manulAnnotToolButtons.add(self.manualAnnotPastButton) + + self.segmentToolAction = QAction( + 'Segment with last used model', + self.host, + ) + self.segmentToolAction.setIcon(QIcon(":segment.svg")) + self.segmentToolAction.setShortcut('R') + self.widgetsWithShortcut['Repeat segmentation'] = self.segmentToolAction + editToolBar.addAction(self.segmentToolAction) + + self.segForLostIDsButton = QToolButton(self.host) + self.segForLostIDsButton.setIcon(QIcon(":segForLostIDs.svg")) + self.segForLostIDsAction = editToolBar.addWidget( + self.segForLostIDsButton + ) + self.segForLostIDsButton.clicked.connect( + self.seg_for_lost_ids_view.segForLostIDsButtonClicked + ) + + # self.SegForLostIDsButton.setShortcut('U') + # self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.SegForLostIDsButton + + self.manualBackgroundButton = QToolButton(self.host) + self.manualBackgroundButton.setIcon(QIcon(":manual_background.svg")) + self.manualBackgroundButton.setCheckable(True) + self.manualBackgroundButton.setShortcut('G') + self.LeftClickButtons.append(self.manualBackgroundButton) + self.checkableButtons.append(self.manualBackgroundButton) + self.checkableQButtonsGroup.addButton(self.manualBackgroundButton) + self.widgetsWithShortcut['Manual background'] = self.manualBackgroundButton + + self.manualBackgroundAction = editToolBar.addWidget( + self.manualBackgroundButton + ) + + self.delObjsOutSegmMaskAction = QAction( + QIcon(":del_objs_out_segm.svg"), + 'Select a segmentation file and delete all objects on the background', + self.host + ) + self.delObjsOutSegmMaskAction.setShortcut('I') + self.widgetsWithShortcut['Delete all objects outside segm'] = ( + self.delObjsOutSegmMaskAction + ) + editToolBar.addAction(self.delObjsOutSegmMaskAction) + + self.hullContToolButton = QToolButton(self.host) + self.hullContToolButton.setIcon(QIcon(":hull.svg")) + self.hullContToolButton.setCheckable(True) + self.hullContToolButton.setShortcut('O') + self.hullContToolButton.action = editToolBar.addWidget(self.hullContToolButton) + self.checkableButtons.append(self.hullContToolButton) + self.checkableQButtonsGroup.addButton(self.hullContToolButton) + self.functionsNotTested3D.append(self.hullContToolButton) + self.widgetsWithShortcut['Hull contour'] = self.hullContToolButton + + self.fillHolesToolButton = QToolButton(self.host) + self.fillHolesToolButton.setIcon(QIcon(":fill_holes.svg")) + self.fillHolesToolButton.setCheckable(True) + self.fillHolesToolButton.setShortcut('F') + self.fillHolesToolButton.action = editToolBar.addWidget( + self.fillHolesToolButton + ) + self.checkableButtons.append(self.fillHolesToolButton) + self.checkableQButtonsGroup.addButton(self.fillHolesToolButton) + self.functionsNotTested3D.append(self.fillHolesToolButton) + self.widgetsWithShortcut['Fill holes'] = self.fillHolesToolButton + + self.moveLabelToolButton = QToolButton(self.host) + self.moveLabelToolButton.setIcon(QIcon(":moveLabel.svg")) + self.moveLabelToolButton.setCheckable(True) + self.moveLabelToolButton.setShortcut('P') + self.moveLabelToolButton.action = editToolBar.addWidget(self.moveLabelToolButton) + self.checkableButtons.append(self.moveLabelToolButton) + self.checkableQButtonsGroup.addButton(self.moveLabelToolButton) + self.widgetsWithShortcut['Move label'] = self.moveLabelToolButton + + self.expandLabelToolButton = QToolButton(self.host) + self.expandLabelToolButton.setIcon(QIcon(":expandLabel.svg")) + self.expandLabelToolButton.setCheckable(True) + self.expandLabelToolButton.setShortcut('E') + self.expandLabelToolButton.action = editToolBar.addWidget(self.expandLabelToolButton) + self.expandLabelToolButton.hide() + self.checkableButtons.append(self.expandLabelToolButton) + self.LeftClickButtons.append(self.expandLabelToolButton) + self.checkableQButtonsGroup.addButton(self.expandLabelToolButton) + self.widgetsWithShortcut['Expand/shrink label'] = self.expandLabelToolButton + + self.editIDbutton = QToolButton(self.host) + self.editIDbutton.setIcon(QIcon(":edit-id.svg")) + self.editIDbutton.setCheckable(True) + self.editIDbutton.setShortcut('N') + editToolBar.addWidget(self.editIDbutton) + self.checkableButtons.append(self.editIDbutton) + self.checkableQButtonsGroup.addButton(self.editIDbutton) + self.widgetsWithShortcut['Edit ID'] = self.editIDbutton + + self.separateBudButton = QToolButton(self.host) + self.separateBudButton.setIcon(QIcon(":separate-bud.svg")) + self.separateBudButton.setCheckable(True) + self.separateBudButton.setShortcut('S') + self.separateBudButton.action = editToolBar.addWidget(self.separateBudButton) + self.checkableButtons.append(self.separateBudButton) + self.checkableQButtonsGroup.addButton(self.separateBudButton) + # self.functionsNotTested3D.append(self.separateBudButton) + self.widgetsWithShortcut['Separate objects'] = self.separateBudButton + + self.mergeIDsButton = QToolButton(self.host) + self.mergeIDsButton.setIcon(QIcon(":merge-IDs.svg")) + self.mergeIDsButton.setCheckable(True) + self.mergeIDsButton.setShortcut('M') + self.mergeIDsButton.action = editToolBar.addWidget(self.mergeIDsButton) + self.checkableButtons.append(self.mergeIDsButton) + self.checkableQButtonsGroup.addButton(self.mergeIDsButton) + # self.functionsNotTested3D.append(self.mergeIDsButton) + self.widgetsWithShortcut['Merge objects'] = self.mergeIDsButton + + self.keepIDsButton = QToolButton(self.host) + self.keepIDsButton.setIcon(QIcon(":keep_objects.svg")) + self.keepIDsButton.setCheckable(True) + self.keepIDsButton.action = editToolBar.addWidget(self.keepIDsButton) + self.keepIDsButton.setShortcut('K') + self.checkableButtons.append(self.keepIDsButton) + self.checkableQButtonsGroup.addButton(self.keepIDsButton) + # self.functionsNotTested3D.append(self.keepIDsButton) + self.widgetsWithShortcut['Select objects to keep'] = self.keepIDsButton + + self.whitelistIDsButton = QToolButton(self.host) + self.whitelistIDsButton.setIcon(QIcon(":whitelist.svg")) + self.whitelistIDsButton.setCheckable(True) + self.whitelistIDsButton.action = editToolBar.addWidget( + self.whitelistIDsButton + ) + self.whitelistIDsButton.setShortcut('Ctrl+K') + self.checkableButtons.append(self.whitelistIDsButton) + self.checkableQButtonsGroup.addButton(self.whitelistIDsButton) + self.LeftClickButtons.append(self.whitelistIDsButton) + # self.functionsNotTested3D.append(self.whitelistIDsButton) + self.widgetsWithShortcut['Select objects to add to a tracking whitelist'] = ( + self.whitelistIDsButton + ) + + self.binCellButton = QToolButton(self.host) + self.binCellButton.setIcon(QIcon(":bin.svg")) + self.binCellButton.setCheckable(True) + # self.binCellButton.setShortcut('R') + self.binCellButton.action = editToolBar.addWidget(self.binCellButton) + self.checkableButtons.append(self.binCellButton) + self.checkableQButtonsGroup.addButton(self.binCellButton) + # self.functionsNotTested3D.append(self.binCellButton) + + self.manualTrackingButton = QToolButton(self.host) + self.manualTrackingButton.setIcon(QIcon(":manual_tracking.svg")) + self.manualTrackingButton.setCheckable(True) + self.manualTrackingButton.setShortcut('T') + self.checkableQButtonsGroup.addButton(self.manualTrackingButton) + self.checkableButtons.append(self.manualTrackingButton) + self.widgetsWithShortcut['Manual tracking'] = self.manualTrackingButton + + self.ripCellButton = QToolButton(self.host) + self.ripCellButton.setIcon(QIcon(":rip.svg")) + self.ripCellButton.setCheckable(True) + self.ripCellButton.setShortcut('D') + self.ripCellButton.action = editToolBar.addWidget(self.ripCellButton) + self.checkableButtons.append(self.ripCellButton) + self.checkableQButtonsGroup.addButton(self.ripCellButton) + self.functionsNotTested3D.append(self.ripCellButton) + self.widgetsWithShortcut['Annotate cell as dead'] = self.ripCellButton + + editToolBar.addAction(self.addDelRoiAction) + # editToolBar.addAction(self.addDelPolyLineRoiAction) + + self.addDelPolyLineRoiAction = editToolBar.addWidget( + self.addDelPolyLineRoiButton + ) + self.addDelPolyLineRoiAction.roiType = 'polyline' + + editToolBar.addAction(self.delBorderObjAction) + self.delBorderObjAction.button = editToolBar.widgetForAction( + self.delBorderObjAction + ) + editToolBar.addAction(self.delNewObjAction) + self.delNewObjAction.button = editToolBar.widgetForAction( + self.delNewObjAction + ) + + self.addDelRoiAction.toolbar = editToolBar + self.functionsNotTested3D.append(self.addDelRoiAction) + + self.addDelPolyLineRoiAction.toolbar = editToolBar + self.functionsNotTested3D.append(self.addDelPolyLineRoiAction) + + self.delBorderObjAction.toolbar = editToolBar + self.functionsNotTested3D.append(self.delBorderObjAction) + + self.delNewObjAction.toolbar = editToolBar + # self.functionsNotTested3D.append(self.delNewObjAction) so id this doesnt work in 3d i dont know anymore + + editToolBar.addAction(self.repeatTrackingAction) + + self.manualTrackingAction = editToolBar.addWidget( + self.manualTrackingButton + ) + + self.functionsNotTested3D.append(self.repeatTrackingAction) + self.functionsNotTested3D.append(self.manualTrackingAction) + + self.reinitLastSegmFrameAction = QAction(self.host) + self.reinitLastSegmFrameAction.setIcon(QIcon(":reinitLastSegm.svg")) + self.reinitLastSegmFrameAction.setVisible(False) + editToolBar.addAction(self.reinitLastSegmFrameAction) + editToolBar.setVisible(False) + self.reinitLastSegmFrameAction.toolbar = editToolBar + self.functionsNotTested3D.append(self.reinitLastSegmFrameAction) + + + self.editLin_TreeBar = widgets.ToolBar("Lin Tree Edit", self.host) + self.editLin_TreeBar.setContextMenuPolicy(Qt.PreventContextMenu) + + self.addToolBar(self.editLin_TreeBar) + self.editLin_TreeGroup = QButtonGroup() + self.editLin_TreeGroup.setExclusive(True) + + self.findNextMotherButton = QToolButton(self.host) + self.findNextMotherButton.setIcon(QIcon(":magnGlass.svg")) + self.findNextMotherButton.setCheckable(True) + self.editLin_TreeBar.addWidget(self.findNextMotherButton) + self.editLin_TreeGroup.addButton(self.findNextMotherButton) + self.findNextMotherButton.setShortcut('F') + self.widgetsWithShortcut['Find next potential mother (lineage tree)'] = self.findNextMotherButton + + self.unknownLineageButton = QToolButton(self.host) + self.unknownLineageButton.setIcon(QIcon(":history.svg")) + self.unknownLineageButton.setCheckable(True) + self.editLin_TreeBar.addWidget(self.unknownLineageButton) + self.editLin_TreeGroup.addButton(self.unknownLineageButton) + self.unknownLineageButton.setShortcut('U') + self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.unknownLineageButton + + self.noToolLinTreeButton = QToolButton(self.host) + self.noToolLinTreeButton.setIcon(QIcon(":arrow_cursor.svg")) + self.noToolLinTreeButton.setCheckable(True) + self.editLin_TreeBar.addWidget(self.noToolLinTreeButton) + self.editLin_TreeGroup.addButton(self.noToolLinTreeButton) + self.noToolLinTreeButton.setShortcut('N') + self.widgetsWithShortcut['No tool (lineage tree)'] = self.noToolLinTreeButton + + self.propagateLinTreeButton = QToolButton(self.host) + self.propagateLinTreeButton.setIcon(QIcon(":compute.svg")) + self.editLin_TreeBar.addWidget(self.propagateLinTreeButton) + self.propagateLinTreeButton.setShortcut('P') + self.widgetsWithShortcut['Propagate (lineage tree)'] = self.propagateLinTreeButton + self.propagateLinTreeButton.clicked.connect(self.propagateLinTreeAction) + + self.viewLinTreeInfoButton = QToolButton(self.host) + self.viewLinTreeInfoButton.setIcon(QIcon(":addCustomAnnotation.svg")) + self.editLin_TreeBar.addWidget(self.viewLinTreeInfoButton) + self.viewLinTreeInfoButton.setShortcut('S') + self.widgetsWithShortcut['View Changes (lineage tree)'] = self.viewLinTreeInfoButton + self.viewLinTreeInfoButton.clicked.connect(self.viewLinTreeInfoAction) + + + self.modeItems = list(self.view_model.mode_items()) + + self.modeActionGroup = QActionGroup(self.modeMenu) + for mode in self.modeItems: + action = QAction(mode) + action.setCheckable(True) + self.modeActionGroup.addAction(action) + self.modeMenu.addAction(action) + if mode == 'Viewer': + action.setChecked(True) + + self.editToolBar = editToolBar + self.editToolBar.setVisible(False) + self.navigateToolBar.setVisible(False) + self.editLin_TreeBar.setVisible(False) + + self.gui_createAnnotateToolbar() + + def gui_createAnnotateToolbar(self): + # Edit toolbar + self.annotateToolbar = widgets.ToolBar( + "Custom annotations", + self.host, + ) + self.annotateToolbar.setContextMenuPolicy(Qt.PreventContextMenu) + self.addToolBar(Qt.LeftToolBarArea, self.annotateToolbar) + self.annotateToolbar.addAction(self.loadCustomAnnotationsAction) + self.annotateToolbar.addAction(self.addCustomAnnotationAction) + self.annotateToolbar.addAction(self.viewAllCustomAnnotAction) + self.annotateToolbar.setVisible(False) diff --git a/cellacdc/views/measurements_view.py b/cellacdc/views/measurements_view.py new file mode 100644 index 000000000..06a7b293b --- /dev/null +++ b/cellacdc/views/measurements_view.py @@ -0,0 +1,173 @@ +"""View adapter for measurement setup and dialogs.""" + +from __future__ import annotations + +import pandas as pd + +from cellacdc import apps, cli, favourite_func_metrics_csv_path, widgets +from cellacdc.viewmodels.measurements_viewmodel import MeasurementsViewModel + + +class MeasurementsView: + """Qt-facing adapter around measurement view-model contracts.""" + + def __init__(self, host, view_model: MeasurementsViewModel): + self.host = host + self.view_model = view_model + + def init_metrics_to_save(self, pos_data): + self.host._measurements_kernel._init_metrics_to_save(pos_data) + + def init_metrics(self): + self.host.logger.info('Initializing measurements...') + pos_data = self.host.data[self.host.pos_i] + self.host._measurements_kernel = cli.ComputeMeasurementsKernel( + self.host.logger, self.host.log_path, False + ) + self.host._measurements_kernel.init_args( + pos_data.chNames, pos_data.getSegmEndname() + ) + self.host._measurements_kernel._init_metrics( + pos_data, self.host.isSegm3D + ) + + def show_set_measurements(self, checked=False, qparent=None): + qparent = qparent if qparent is not None else self.host + if self.host.measurementsWin is not None: + self.host.measurementsWin.show() + self.host.measurementsWin.raise_() + self.host.measurementsWin.activateWindow() + return + + favourite_funcs = self._favourite_metric_functions() + pos_data = self.host.data[self.host.pos_i] + all_pos_acdc_df_cols = self.view_model.all_acdc_df_columns( + self.host.data + ) + loaded_ch_names = pos_data.setLoadedChannelNames(returnList=True) + pos_data.fluo_data_dict.pop(self.host.user_ch_name, None) + if self.host.user_ch_name not in loaded_ch_names: + loaded_ch_names.insert(0, self.host.user_ch_name) + not_loaded_ch_names = self.view_model.not_loaded_channels( + self.host.ch_names, + loaded_ch_names, + ) + self.host.notLoadedChNames = not_loaded_ch_names + self.host.measurementsWin = apps.SetMeasurementsDialog( + loaded_ch_names, + not_loaded_ch_names, + pos_data.SizeZ > 1, + self.host.isSegm3D, + favourite_funcs=favourite_funcs, + allPos_acdc_df_cols=list(all_pos_acdc_df_cols), + acdc_df_path=pos_data.images_path, + posData=pos_data, + addCombineMetricCallback=self.add_combine_metric, + allPosData=self.host.data, + parent=qparent, + state=self.host.setMeasWinState, + ) + self.host.measurementsWin.sigCancel.connect( + self.set_measurements_cancelled + ) + self.host.measurementsWin.sigClosed.connect(self.set_measurements) + self.host.measurementsWin.show() + + def set_measurements_cancelled(self): + self.host.measurementsWin = None + + def set_measurements(self): + if self.host.measurementsWin.delExistingCols: + self._remove_existing_unchecked_measurements() + self.host.setMeasWinState = self.host.measurementsWin.state() + self.host.logger.info('Setting measurements...') + self._set_metrics(self.host.measurementsWin) + self.host.logger.info('Metrics successfully set.') + self.host.measurementsWin = None + + def add_custom_metric(self, checked=False): + txt = self.view_model.custom_metrics_instructions() + metrics_path = self.view_model.metrics_examples_path() + msg = widgets.myMessageBox() + msg.addShowInFileManagerButton(metrics_path, 'Show example...') + title = 'Add custom metrics instructions' + msg.information(self.host, title, txt, buttonsTexts=('Ok',)) + + def add_combine_metric(self): + pos_data = self.host.data[self.host.pos_i] + is_zstack = pos_data.SizeZ > 1 + win = apps.combineMetricsEquationDialog( + self.host.ch_names, + is_zstack, + self.host.isSegm3D, + parent=self.host, + ) + win.sigOk.connect(self.save_combine_metrics_to_pos_data) + win.exec_() + win.sigOk.disconnect() + + def save_combine_metrics_to_pos_data(self, window): + for pos_data in self.host.data: + equations_dict, is_mixed_channels = window.getEquationsDict() + for new_col_name, equation in equations_dict.items(): + pos_data.addEquationCombineMetrics( + equation, new_col_name, is_mixed_channels + ) + pos_data.saveCombineMetrics() + + if self.host.measurementsWin is None: + return + + self.host.measurementsWinState = self.host.measurementsWin.state() + self.host.measurementsWin.close() + self.show_set_measurements() + self.host.measurementsWin.restoreState( + self.host.measurementsWinState + ) + + def set_metrics_func(self): + pos_data = self.host.data[self.host.pos_i] + self.host._measurements_kernel._set_metrics_func_from_posData( + pos_data + ) + + def _set_metrics(self, measurements_win): + self.host._measurements_kernel.set_metrics_from_set_measurements_dialog( + measurements_win + ) + for ch_name in self.host._measurements_kernel.chNamesToProcess: + if ch_name not in self.host.notLoadedChNames: + continue + + success = self.host.loadFluo_cb(fluo_channels=[ch_name]) + if not success: + continue + + def _remove_existing_unchecked_measurements(self): + self.host.logger.info('Removing existing unchecked measurements...') + del_cols = self.host.measurementsWin.existingUncheckedColnames + del_rps = self.host.measurementsWin.existingUncheckedRps + self._log_removed_measurements(del_cols, del_rps) + for pos_data in self.host.data: + for data_dict in pos_data.allData_li: + data_dict['acdc_df'] = ( + self.view_model.drop_unchecked_measurements( + data_dict['acdc_df'], + del_cols, + del_rps, + ) + ) + + def _log_removed_measurements(self, del_cols, del_rps): + del_cols_format = [f' * {colname}' for colname in del_cols] + del_rps_format = [f' * {colname}' for colname in del_rps] + del_cols_format.extend(del_rps_format) + del_cols_format = '\n'.join(del_cols_format) + self.host.logger.info(del_cols_format) + + def _favourite_metric_functions(self): + try: + df_favourite_funcs = pd.read_csv(favourite_func_metrics_csv_path) + return df_favourite_funcs['favourite_func_name'].to_list() + except Exception: + return None diff --git a/cellacdc/views/mode_controls_view.py b/cellacdc/views/mode_controls_view.py new file mode 100644 index 000000000..e8df71a91 --- /dev/null +++ b/cellacdc/views/mode_controls_view.py @@ -0,0 +1,466 @@ +"""Qt view adapter for mode and toolbar state controls.""" + +from __future__ import annotations + +from qtpy.QtCore import QTimer + +from cellacdc import disableWindow +from cellacdc.viewmodels.mode_controls_viewmodel import ModeControlsViewModel + + +class ModeControlsView: + """Qt-facing adapter around mode-control decisions.""" + + def __init__(self, host, view_model: ModeControlsViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def nonViewerEditMenuOpened(self): + mode = str(self.modeComboBox.currentText()) + if self.view_model.should_start_blinking( + mode, + ruler_checked=self.rulerButton.isChecked(), + ): + self.startBlinkingModeCB() + + def startBlinkingModeCB(self): + try: + self.timer.stop() + self.stopBlinkTimer.stop() + except Exception as e: + pass + if not self.view_model.should_start_blinking( + str(self.modeComboBox.currentText()), + ruler_checked=self.rulerButton.isChecked(), + ): + return + self.timer = QTimer(self.host) + self.timer.timeout.connect(self.blinkModeComboBox) + self.timer.start(200) + self.stopBlinkTimer = QTimer(self.host) + self.stopBlinkTimer.timeout.connect(self.stopBlinkingCB) + self.stopBlinkTimer.start(2000) + + def blinkModeComboBox(self): + style, next_flag = self.view_model.blink_styles(self.flag) + self.modeComboBox.setStyleSheet(style) + self.flag = next_flag + + def stopBlinkingCB(self): + self.timer.stop() + self.modeComboBox.setStyleSheet('background-color: none') + + def setEnabledCcaToolbar(self, enabled=False): + self.manuallyEditCcaAction.setDisabled(False) + self.viewCcaTableAction.setDisabled(False) + self.ccaToolBar.setVisible(enabled) + for action in self.ccaToolBar.actions(): + button = self.ccaToolBar.widgetForAction(action) + action.setVisible(enabled) + button.setEnabled(enabled) + + # def setEnabledCcaToolbar(self, enabled=False): + # self.manuallyEditCcaAction.setDisabled(False) + # self.viewCcaTableAction.setDisabled(False) + # self.ccaToolBar.setVisible(enabled) + # for action in self.ccaToolBar.actions(): + # button = self.ccaToolBar.widgetForAction(action) + # action.setVisible(enabled) + # button.setEnabled(enabled) + + def setEnabledEditToolbarButton(self, enabled=False): + for action in self.segmActions: + action.setEnabled(enabled) + + for action in self.segmActionsVideo: + action.setEnabled(enabled) + + self.relabelSequentialAction.setEnabled(enabled) + self.repeatTrackingMenuAction.setEnabled(enabled) + self.repeatTrackingVideoAction.setEnabled(enabled) + self.postProcessSegmAction.setEnabled(enabled) + self.autoSegmAction.setEnabled(enabled) + self.editToolBar.setVisible(enabled) + mode = self.modeComboBox.currentText() + ccaON = self.view_model.is_cca_mode(mode) + for action in self.editToolBar.actions(): + button = self.editToolBar.widgetForAction(action) + # Keep binCellButton active in cca mode + if button==self.binCellButton and not enabled and ccaON: + action.setVisible(True) + button.setEnabled(True) + else: + action.setVisible(enabled) + button.setEnabled(enabled) + if not enabled: + self.setUncheckedAllButtons() + + def setEnabledFileToolbar(self, enabled): + for action in self.fileToolBar.actions(): + button = self.fileToolBar.widgetForAction(action) + if action == self.openFolderAction or action == self.newAction: + continue + if action == self.manageVersionsAction: + continue + if action == self.openFileAction: + continue + action.setEnabled(enabled) + button.setEnabled(enabled) + + def reconnectUndoRedo(self): + try: + self.undoAction.triggered.disconnect() + self.redoAction.triggered.disconnect() + except Exception as e: + pass + mode = self.modeComboBox.currentText() + target = self.view_model.undo_redo_target(mode) + if target == 'labels': + self.undoAction.triggered.connect(self.undo_redo_view.undo) + self.redoAction.triggered.connect(self.undo_redo_view.redo) + elif target == 'cca': + self.undoAction.triggered.connect(self.undo_redo_view.UndoCca) + elif target == 'custom_annotations': + self.undoAction.triggered.connect( + self.undo_redo_view.undoCustomAnnotation + ) + else: + self.undoAction.setDisabled(True) + self.redoAction.setDisabled(True) + + def enableSizeSpinbox(self, enabled): + self.brushSizeLabelAction.setVisible(enabled) + self.brushSizeAction.setVisible(enabled) + self.brushAutoFillAction.setVisible(enabled) + self.brushAutoHideAction.setVisible(enabled) + self.brushEraserToolBar.setVisible(enabled) + self.disableNonFunctionalButtons() + + def clearComboBoxFocus(self, mode): + # Remove focus from modeComboBox to avoid the key_up changes its value + self.sender().clearFocus() + try: + self.timer.stop() + self.modeComboBox.setStyleSheet('background-color: none') + except Exception as e: + pass + + def updateModeMenuAction(self): + self.modeActionGroup.triggered.disconnect() + for action in self.modeActionGroup.actions(): + if action.text() != self.modeComboBox.currentText(): + continue + action.setChecked(True) + break + self.modeActionGroup.triggered.connect(self.changeModeFromMenu) + + def changeModeFromMenu(self, action): + self.modeComboBox.setCurrentText(action.text()) + + def restorePrevAnnotOptions(self): + if self.prevAnnotOptions is None: + return + self.restoreAnnotOptions_ax1(options=self.prevAnnotOptions) + self.setDrawAnnotComboboxText() + self.prevAnnotOptions = None + + def uncheckAllButtonsFromButtonGroup(self, buttonGroup): + for button in buttonGroup.buttons(): + if not button.isCheckable(): + continue + + if not button.isChecked(): + continue + + button.setChecked(False) + + @disableWindow + def changeMode(self, text): + self.reconnectUndoRedo() + self.updateModeMenuAction() + self.custom_annotations_view.clearCustomAnnot() + posData = self.data[self.pos_i] + mode = text + prevMode = self.modeComboBox.previousText() + self.annotateToolbar.setVisible(False) + if self.view_model.should_store_on_mode_change(prevMode): + self.store_data(autosave=True) + + self.copyLostObjButton.setChecked(False) + self.stopCcaIntegrityCheckerWorker() + self.setAutoSaveSegmentationEnabled(False) + self.setAutoSaveAnnotationsEnabled(False) + if prevMode == 'Normal division: Lineage tree': + self.askLineageTreeChanges() + self.lineage_tree = None + self.editLin_TreeBar.setVisible(False) + self.uncheckAllButtonsFromButtonGroup(self.editLin_TreeGroup) + + elif prevMode == 'Cell cycle analysis': + self.setEnabledCcaToolbar(enabled=False) + + if mode == 'Segmentation and Tracking': + self.setAutoSaveSegmentationEnabled(True) + self.setSwitchViewedPlaneDisabled(True) + self.trackingMenu.setDisabled(False) + self.modeToolBar.setVisible(True) + self.lastTrackedFrameLabel.setText('') + self.initSegmTrackMode() + self.setEnabledEditToolbarButton(enabled=True) + self.addExistingDelROIs() + self.isFirstTimeOnNextFrame() + self.setEnabledCcaToolbar(enabled=False) + self.clearComputedContours() + self.realTimeTrackingToggle.setDisabled(False) + self.realTimeTrackingToggle.label.setDisabled(False) + if posData.cca_df is not None: + self.store_cca_df() + self.restorePrevAnnotOptions() + self.whitelistViewOGIDs(False) + elif mode == 'Cell cycle analysis': + self.setAutoSaveAnnotationsEnabled(True) + self.setSwitchViewedPlaneDisabled(True) + self.startCcaIntegrityCheckerWorker() + proceed = self.initCca() + if proceed: + self.applyDelROIs() + self.modeToolBar.setVisible(True) + self.realTimeTrackingToggle.setDisabled(True) + self.realTimeTrackingToggle.label.setDisabled(True) + self.computeAllContours() + # RAWR!!!!! + # self.computeAllObjToObjCostPairs() + if proceed: + self.setEnabledEditToolbarButton(enabled=False) + if self.isSnapshot: + self.editToolBar.setVisible(True) + self.setEnabledCcaToolbar(enabled=True) + self.removeAlldelROIsCurrentFrame() + self.setAnnotOptionsCcaMode() + self.clearGhost() + elif mode == 'Viewer': + self.autoSaveTimer.stop() + self.setSwitchViewedPlaneDisabled(False) + self.modeToolBar.setVisible(True) + self.realTimeTrackingToggle.setDisabled(True) + self.realTimeTrackingToggle.label.setDisabled(True) + self.setEnabledEditToolbarButton(enabled=False) + self.setEnabledCcaToolbar(enabled=False) + self.removeAlldelROIsCurrentFrame() + self.status_hover_view.set_status_bar_label() + self.navigateScrollBar.setMaximum(posData.SizeT) + self.navSpinBox.setMaximum(posData.SizeT) + self.clearGhost() + self.computeAllContours() + elif mode == 'Custom annotations': + self.setAutoSaveAnnotationsEnabled(True) + self.setSwitchViewedPlaneDisabled(True) + self.modeToolBar.setVisible(True) + self.realTimeTrackingToggle.setDisabled(True) + self.realTimeTrackingToggle.label.setDisabled(True) + self.setEnabledEditToolbarButton(enabled=False) + self.setEnabledCcaToolbar(enabled=False) + self.removeAlldelROIsCurrentFrame() + self.annotateToolbar.setVisible(True) + self.clearGhost() + self.custom_annotations_view.doCustomAnnotation(0) + self.computeAllContours() + elif mode == 'Snapshot': + self.setAutoSaveAnnotationsEnabled(True) + self.setSwitchViewedPlaneDisabled(False) + self.reconnectUndoRedo() + self.setEnabledSnapshotMode() + self.custom_annotations_view.doCustomAnnotation(0) + self.clearComputedContours() + elif mode == 'Normal division: Lineage tree': # Mode activation for lineage tree + # self.startLinTreeIntegrityCheckerWorker() # need to replace (postponed) + proceed = self.initLinTree() + self.setEnabledCcaToolbar(enabled=False) + self.setNavigateScrollBarMaximum() + if proceed: + self.applyDelROIs() + self.modeToolBar.setVisible(True) + self.realTimeTrackingToggle.setDisabled(True) + self.realTimeTrackingToggle.label.setDisabled(True) + if proceed: + self.setAutoSaveAnnotationsEnabled(True) + self.setEnabledEditToolbarButton(enabled=False) + if self.isSnapshot: + self.editToolBar.setVisible(True) + self.removeAlldelROIsCurrentFrame() + self.setAnnotOptionsLin_treeMode() + self.clearGhost() + self.editLin_TreeBar.setVisible(True) + + self.disableNonFunctionalButtons() + + def disableEditingViewPlaneNotXY(self): + posData = self.data[self.pos_i] + self.manuallyEditCcaAction.setDisabled(True) + for action in self.segmActions: + action.setDisabled(True) + if posData.SizeT == 1: + self.segmVideoMenu.setDisabled(True) + self.postProcessSegmAction.setDisabled(True) + self.autoSegmAction.setDisabled(True) + self.ccaToolBar.setVisible(False) + self.editToolBar.setVisible(False) + for action in self.ccaToolBar.actions(): + button = self.editToolBar.widgetForAction(action) + if button is not None: + button.setDisabled(True) + action.setVisible(False) + for action in self.editToolBar.actions(): + button = self.editToolBar.widgetForAction(action) + action.setVisible(False) + if button is not None: + button.setDisabled(True) + + def setEnabledSnapshotMode(self): + posData = self.data[self.pos_i] + self.manuallyEditCcaAction.setDisabled(False) + self.viewCcaTableAction.setDisabled(False) + for action in self.segmActions: + action.setDisabled(False) + + self.segmVideoMenu.setDisabled(True) + self.trackingMenu.setDisabled(True) + self.modeToolBar.setVisible(False) + + self.relabelSequentialAction.setDisabled(False) + self.postProcessSegmAction.setDisabled(False) + self.autoSegmAction.setDisabled(False) + self.ccaToolBar.setVisible(True) + self.editToolBar.setVisible(True) + self.reinitLastSegmFrameAction.setVisible(False) + for action in self.ccaToolBar.actions(): + button = self.ccaToolBar.widgetForAction(action) + if button == self.assignBudMothButton: + button.setDisabled(False) + action.setVisible(True) + elif action == self.reInitCcaAction: + action.setVisible(True) + elif action == self.assignBudMothAutoAction and posData.SizeT==1: + action.setVisible(True) + for action in self.editToolBar.actions(): + button = self.editToolBar.widgetForAction(action) + action.setVisible(True) + button.setEnabled(True) + self.realTimeTrackingToggle.setDisabled(True) + self.realTimeTrackingToggle.label.setDisabled(True) + self.repeatTrackingAction.setVisible(False) + self.manualTrackingAction.setVisible(False) + button = self.editToolBar.widgetForAction(self.repeatTrackingAction) + button.setDisabled(True) + button = self.editToolBar.widgetForAction(self.manualTrackingAction) + button.setDisabled(True) + self.disableNonFunctionalButtons() + self.reinitLastSegmFrameAction.setVisible(False) + + def setFramesSnapshotMode(self): + self.measurementsMenu.setDisabled(False) + self.setPermanentGreedyCmapPreferences() + if self.isSnapshot: + self.realTimeTrackingToggle.setDisabled(True) + self.realTimeTrackingToggle.label.setDisabled(True) + try: + self.drawIDsContComboBox.currentIndexChanged.disconnect() + except Exception as e: + pass + + self.imgGrad.rescaleAcrossTimeAction.setDisabled(True) + self.repeatTrackingAction.setDisabled(True) + self.manualTrackingAction.setDisabled(True) + self.logger.info('Setting GUI mode to "Snapshots"...') + self.modeComboBox.clear() + self.modeComboBox.addItems(['Snapshot']) + self.modeComboBox.setDisabled(True) + self.modeMenu.menuAction().setVisible(False) + self.drawIDsContComboBox.clear() + self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) + self.drawIDsContComboBox.setCurrentIndex(1) + self.modeToolBar.setVisible(False) + self.skipToNewIdAction.setVisible(False) + self.skipToNewIdAction.setDisabled(True) + self.modeComboBox.setCurrentText('Snapshot') + self.annotateToolbar.setVisible(True) + self.labelsGrad.showNextFrameAction.setDisabled(True) + self.drawIDsContComboBox.currentIndexChanged.connect( + self.drawIDsContComboBox_cb + ) + self.showTreeInfoCheckbox.hide() + self.rightImageFramesScrollbar.setVisible(False) + self.rightImageFramesScrollbar.setDisabled(True) + if not self.isSegm3D: + self.manualBackgroundAction.setVisible(True) + self.manualBackgroundAction.setDisabled(False) + else: + self.manualBackgroundAction.setVisible(False) + self.manualBackgroundAction.setDisabled(True) + self.manualAnnotPastButton.setDisabled(True) + self.manualAnnotPastButton.action.setDisabled(True) + self.manualAnnotPastButton.setVisible(False) + self.manualAnnotPastButton.action.setVisible(False) + self.copyLostObjButton.setDisabled(True) + self.copyLostObjButton.action.setDisabled(True) + self.copyLostObjButton.setVisible(False) + self.copyLostObjButton.action.setVisible(False) + self.segForLostIDsAction.setVisible(False) + self.segForLostIDsAction.setDisabled(True) + self.delNewObjAction.setVisible(False) + self.delNewObjAction.setDisabled(True) + else: + self.imgGrad.rescaleAcrossTimeAction.setDisabled(False) + self.annotateToolbar.setVisible(False) + self.realTimeTrackingToggle.setDisabled(False) + self.repeatTrackingAction.setDisabled(False) + self.manualTrackingAction.setDisabled(False) + self.modeComboBox.setDisabled(False) + self.modeMenu.menuAction().setVisible(True) + self.skipToNewIdAction.setVisible(True) + self.skipToNewIdAction.setDisabled(False) + try: + self.modeComboBox.activated.disconnect() + self.modeComboBox.sigTextChanged.disconnect() + self.drawIDsContComboBox.currentIndexChanged.disconnect() + except Exception as e: + pass + # traceback.print_exc() + self.modeComboBox.clear() + self.modeComboBox.addItems(self.modeItems) + self.drawIDsContComboBox.clear() + self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) + self.modeComboBox.sigTextChanged.connect(self.changeMode) + self.modeComboBox.activated.connect(self.clearComboBoxFocus) + self.drawIDsContComboBox.currentIndexChanged.connect( + self.drawIDsContComboBox_cb) + self.modeComboBox.setCurrentText('Viewer') + self.showTreeInfoCheckbox.show() + self.manualBackgroundAction.setVisible(False) + self.manualBackgroundAction.setDisabled(True) + self.labelsGrad.showNextFrameAction.setDisabled(False) + self.manualAnnotPastButton.setDisabled(False) + self.manualAnnotPastButton.action.setDisabled(False) + self.manualAnnotPastButton.setVisible(True) + self.manualAnnotPastButton.action.setVisible(True) + self.copyLostObjButton.setDisabled(False) + self.copyLostObjButton.action.setDisabled(False) + self.copyLostObjButton.setVisible(True) + self.copyLostObjButton.action.setVisible(True) + self.segForLostIDsAction.setVisible(True) + self.segForLostIDsAction.setDisabled(False) + self.delNewObjAction.setVisible(True) + self.delNewObjAction.setDisabled(False) + + for ch, overlayItems in self.overlayLayersItems.items(): + lutItem = overlayItems[1] + lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot) diff --git a/cellacdc/views/object_cleanup_view.py b/cellacdc/views/object_cleanup_view.py new file mode 100644 index 000000000..6bebd44d8 --- /dev/null +++ b/cellacdc/views/object_cleanup_view.py @@ -0,0 +1,107 @@ +"""View adapter for object cleanup workflows.""" + +from __future__ import annotations + +import numpy as np +from qtpy.QtCore import QThread + +from cellacdc import apps, widgets, workers +from cellacdc.viewmodels.object_cleanup_viewmodel import ( + ObjectCleanupViewModel, +) + + +class ObjectCleanupView: + """Qt-facing adapter around the object-cleanup view-model.""" + + def __init__(self, host, view_model: ObjectCleanupViewModel): + self.host = host + self.view_model = view_model + + def delete_objects_outside_mask_action_triggered(self): + pos_data = self.host.data[self.host.pos_i] + existing_segm_endnames = self.view_model.segmentation_roi_endnames( + basename=pos_data.basename, + images_path=pos_data.images_path, + ) + select_segm_win = widgets.QDialogListbox( + 'Select segmentation file', + 'Select segmentation file to use as ROI:\n', + existing_segm_endnames, + multiSelection=False, + parent=self.host, + ) + select_segm_win.exec_() + if select_segm_win.cancel: + self.host.logger.info('Delete objects process cancelled.') + return + + selected_segm_endname = select_segm_win.selectedItemsText[0] + self.start_delete_objects_outside_mask_worker(selected_segm_endname) + + def start_delete_objects_outside_mask_worker(self, selected_segm_endname): + self.host.store_data(autosave=False) + pos_data = self.host.data[self.host.pos_i] + segm_data = np.squeeze(self.host.getStoredSegmData()) + + self.host.progressWin = apps.QDialogWorkerProgress( + title='Deleting objects outside of ROIs', + parent=self.host, + pbarDesc='Deleting objects outside of ROIs...', + ) + self.host.progressWin.show(self.host.app) + self.host.progressWin.mainPbar.setMaximum(0) + + self.host.thread = QThread() + self.host.worker = workers.DelObjectsOutsideSegmROIWorker( + selected_segm_endname, + segm_data, + pos_data.images_path, + ) + self.host.worker.moveToThread(self.host.thread) + self.host.worker.finished.connect(self.host.thread.quit) + self.host.worker.finished.connect(self.host.worker.deleteLater) + self.host.thread.finished.connect(self.host.thread.deleteLater) + + self.host.worker.progress.connect(self.host.workerProgress) + self.host.worker.critical.connect(self.host.workerCritical) + self.host.worker.finished.connect( + self.delete_objects_outside_mask_worker_finished + ) + self.host.worker.debug.connect(self.host.workerDebug) + + self.host.thread.started.connect(self.host.worker.run) + self.host.thread.start() + + def delete_objects_outside_mask_worker_finished(self, result): + pos_data = self.host.data[self.host.pos_i] + worker, cleared_segm_data, del_ids = result + cleared_segm_data = self.view_model.cleared_segmentation_frames( + cleared_segm_data, + size_t=pos_data.SizeT, + ) + + self.host.update_cca_df_deletedIDs(pos_data, del_ids) + + current_frame_i = pos_data.frame_i + for frame_i, cleared_lab in self.view_model.frame_labels( + cleared_segm_data + ): + pos_data.allData_li[frame_i]['labels'] = cleared_lab + pos_data.frame_i = frame_i + self.host.get_data() + self.host.store_data(autosave=False) + + pos_data.frame_i = current_frame_i + self.host.get_data() + + if self.host.progressWin is not None: + self.host.progressWin.workerFinished = True + self.host.progressWin.close() + self.host.progressWin = None + self.host.logger.info('Deleting objects outside of ROIs finished.') + self.host.titleLabel.setText( + 'Deleting objects outside of ROIs finished.', + color='w', + ) + self.host.updateAllImages() diff --git a/cellacdc/views/object_properties_view.py b/cellacdc/views/object_properties_view.py new file mode 100644 index 000000000..f9abdefb9 --- /dev/null +++ b/cellacdc/views/object_properties_view.py @@ -0,0 +1,915 @@ +"""Qt view adapter for object-property workflows.""" + +from __future__ import annotations + +import numpy as np +import skimage.measure +from tqdm import tqdm + +from cellacdc import apps, exception_handler, html_utils, widgets +from cellacdc.viewmodels.object_properties_viewmodel import ( + ObjectPropertiesViewModel, +) + + +class ObjectPropertiesView: + """Qt-facing adapter around object properties and highlighting.""" + + LEGACY_METHODS = ( + 'initPixelSizePropsDockWidget', + 'showPropsDockWidget', + 'clearHighlightedID', + 'setAllIDs', + 'countObjectsTimelapse', + 'countObjectsSnapshots', + 'countObjects', + 'updateObjectCounts', + 'countObjectsCb', + 'keepIDs_cb', + 'initKeepObjLabelsLayers', + 'updateTempLayerKeepIDs', + 'highlightLabelID', + 'highlightHoverID', + 'grayOutHighlightedLabels', + 'grayOutOverlaySegm', + 'highlightHoverIDsKeptObj', + 'getHighlightedID', + 'clearHighlightedKeepIDs', + 'setHighlighedIDfromToolbar', + 'highlightSearchedID', + '_keepObjects', + 'clearHighlightedText', + 'removeHighlightLabelID', + 'updateKeepIDs', + 'applyKeepObjects', + 'get_curr_lab', + 'highlightIDonHoverCheckBoxToggled', + 'highlightSearchedIDcheckBoxToggled', + 'setHighlightID', + 'propsWidgetIDvalueChanged', + 'updatePropsWidget', + ) + + def __init__(self, host, view_model: ObjectPropertiesViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def initPixelSizePropsDockWidget(self): + posData = self.data[self.pos_i] + PhysicalSizeX = posData.PhysicalSizeX + PhysicalSizeY = posData.PhysicalSizeY + PhysicalSizeZ = posData.PhysicalSizeZ + self.guiTabControl.initPixelSize( + PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ + ) + + def showPropsDockWidget(self, checked=False): + if self.showPropsDockButton.isExpand: + self.propsDockWidget.setVisible(False) + self.setHighlightID(False) + else: + self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() + if self.view_model.should_show_3d_property_controls( + self.isSegm3D + ): + self.guiTabControl.propsQGBox.cellVolVox3D_SB.show() + self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.show() + self.guiTabControl.propsQGBox.cellVolFl3D_DSB.show() + self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.show() + else: + self.guiTabControl.propsQGBox.cellVolVox3D_SB.hide() + self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.hide() + self.guiTabControl.propsQGBox.cellVolFl3D_DSB.hide() + self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.hide() + + self.propsDockWidget.setVisible(True) + self.propsDockWidget.setEnabled(True) + self.updateAllImages() + + def clearHighlightedID(self): + self.highlightIDToolbar.setVisible(False) + + try: + self.updateLostContoursImage(ax=0, delROIsIDs=None) + except Exception as err: + pass + + if self.highlightedID == 0: + return + + self.highlightedID = 0 + self.guiTabControl.highlightCheckbox.setChecked(False) + self.guiTabControl.highlightSearchedCheckbox.setChecked(False) + self.setHighlightID(False) + + def setAllIDs(self, onlyVisited=False): + for posData in self.data: + posData.allIDs = self.view_model.object_counts.collect_all_ids( + posData, + only_visited=onlyVisited, + ) + + def countObjectsTimelapse(self): + if self.countObjsWindow is None: + activeCategories = self.view_model.timelapse_default_categories() + else: + activeCategories = self.countObjsWindow.activeCategories() + + posData = self.data[self.pos_i] + allCategoryCountMapper = posData.countObjectsInSegmTimelapse( + activeCategories + ) + if self.countObjsWindow is None: + return allCategoryCountMapper + + categoryCountMapper = {} + for category in activeCategories: + categoryCountMapper[category] = allCategoryCountMapper[category] + + return categoryCountMapper + + + def countObjectsSnapshots(self): + posData = self.data[self.pos_i] + if self.countObjsWindow is None: + activeCategories = self.view_model.snapshot_default_categories( + is_segm_3d=self.isSegm3D + ) + else: + activeCategories = self.countObjsWindow.activeCategories() + + allCategoryCountMapper = self.view_model.object_counts.snapshot_object_counts( + self.data, + self.pos_i, + current_lab_2d=self.currentLab2D, + include_current_z_slice='In current z-slice' in activeCategories, + ) + + if self.countObjsWindow is None: + return allCategoryCountMapper + + categoryCountMapper = {} + for category in activeCategories: + categoryCountMapper[category] = allCategoryCountMapper[category] + + return categoryCountMapper + + def countObjects(self): + self.logger.info('Counting objects...') + + posData = self.data[self.pos_i] + if posData.SizeT > 1: + return self.countObjectsTimelapse() + + return self.countObjectsSnapshots() + + + def updateObjectCounts(self): + if not self.view_model.should_update_object_counts( + window_exists=self.countObjsWindow is not None, + is_visible=( + self.countObjsWindow.isVisible() + if self.countObjsWindow is not None else False + ), + live_preview_checked=( + self.countObjsWindow.livePreviewCheckbox.isChecked() + if self.countObjsWindow is not None else False + ), + ): + return + + categoryCountMapper = self.countObjects() + self.countObjsWindow.updateCounts(categoryCountMapper) + + def countObjectsCb(self, checked): + if self.countObjsWindow is None: + categoryCountMapper = self.countObjects() + self.countObjsWindow = apps.ObjectCountDialog( + categoryCountMapper=categoryCountMapper, + parent=self.host, + data=self.data + ) + self.countObjsWindow.sigShowEvent.connect(self.updateObjectCounts) + self.countObjsWindow.sigUpdateCounts.connect(self.updateObjectCounts) + + if checked: + self.countObjsWindow.show() + else: + self.countObjsWindow.hide() + + def keepIDs_cb(self, checked): + if checked: + self.highlightedLab = np.zeros_like(self.currentLab2D) + if self.annotCcaInfoCheckbox.isChecked(): + self.annotCcaInfoCheckbox.setChecked(False) + self.annotIDsCheckbox.setChecked(True) + self.setDrawAnnotComboboxText() + self.uncheckLeftClickButtons(None) + self.initKeepObjLabelsLayers() + self.setAllIDs() + else: + # restore items to non-grayed out + self.clearTempBrushImage() + alpha = self.imgGrad.labelsAlphaSlider.value() + self.labelsLayerImg1.setOpacity(alpha) + self.labelsLayerRightImg.setOpacity(alpha) + self.ax1_contoursImageItem.setOpacity(1.0) + self.ax2_contoursImageItem.setOpacity(1.0) + self.ax1_lostObjImageItem.setOpacity(1.0) + self.ax2_lostObjImageItem.setOpacity(1.0) + self.ax1_lostTrackedObjImageItem.setOpacity(1.0) + self.ax2_lostTrackedObjImageItem.setOpacity(1.0) + + self.keepIDsToolbar.setVisible(checked) + self.highlightedIDopts = None + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + self.updateAllImages() + + # QTimer.singleShot(300, self.autoRange) + + def initKeepObjLabelsLayers(self): + lut = np.zeros((len(self.lut), 4), dtype=np.uint8) + lut[:,:-1] = self.lut + lut[:,-1:] = 255 + lut[0] = [0,0,0,0] + self.keepIDsTempLayerLeft.setLevels([0, len(lut)]) + self.keepIDsTempLayerLeft.setLookupTable(lut) + + def updateTempLayerKeepIDs(self): + if not self.keepIDsButton.isChecked(): + return + + keptLab = np.zeros_like(self.currentLab2D) + + posData = self.data[self.pos_i] + for obj in posData.rp: + if obj.label not in self.keptObjectsIDs: + continue + + if not self.isObjVisible(obj.bbox): + continue + + _slice = self.getObjSlice(obj.slice) + _objMask = self.getObjImage(obj.image, obj.bbox) + + keptLab[_slice][_objMask] = obj.label + + self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) + + def highlightLabelID(self, ID, ax=0): + posData = self.data[self.pos_i] + try: + obj = posData.rp[posData.IDs_idxs[ID]] + except KeyError: + return + + self.textAnnot[ax].highlightObject(obj) + + def highlightHoverID(self, x, y, hoverID=None): + if hoverID is None: + try: + hoverID = self.currentLab2D[int(y), int(x)] + except IndexError: + return + + if hoverID == 0: + return + + posData = self.data[self.pos_i] + objIdx = posData.IDs_idxs[hoverID] + obj = posData.rp[objIdx] + self.goToZsliceSearchedID(obj) + self.highlightSearchedID(hoverID) + + def grayOutHighlightedLabels(self, nonGrayedIDs=None, alpha=None): + if nonGrayedIDs is None: + nonGrayedIDs = set() + + posData = self.data[self.pos_i] + if alpha is None: + alpha = self.imgGrad.labelsAlphaSlider.value() + + if not hasattr(self, 'highlightedLab'): + self.highlightedLab = np.zeros_like(self.currentLab2D) + else: + self.highlightedLab[:] = 0 + + lut = np.zeros((2, 4), dtype=np.uint8) + for _obj in posData.rp: + if not self.isObjVisible(_obj.bbox): + continue + if _obj.label not in nonGrayedIDs: + continue + _slice = self.getObjSlice(_obj.slice) + _objMask = self.getObjImage(_obj.image, _obj.bbox) + self.highlightedLab[_slice][_objMask] = _obj.label + rgb = self.lut[_obj.label].copy() + lut[1, :-1] = rgb + # Set alpha to 0.7 + lut[1, -1] = 178 + + return lut + + def grayOutOverlaySegm(self, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + isOverlaySegmActive = how.find('segm. masks') != -1 + if not isOverlaySegmActive: + return + + grayedLut = self.grayOutHighlightedLabels() + + def highlightHoverIDsKeptObj(self, x, y, hoverID=None): + if hoverID is None: + try: + hoverID = self.currentLab2D[int(y), int(x)] + except IndexError: + return + + self.highlightSearchedID(hoverID, greyOthers=False) + + if hoverID == 0 and self.highlightedID == 0: + return + + if hoverID == 0 and self.highlightedID != 0: + self.clearHighlightedKeepIDs() + for ID in self.keptObjectsIDs: + self.highlightLabelID(ID) + return + + posData = self.data[self.pos_i] + try: + objIdx = posData.IDs_idxs[hoverID] + except KeyError as err: + return + + obj = posData.rp[objIdx] + self.goToZsliceSearchedID(obj) + + for ID in self.keptObjectsIDs: + self.highlightLabelID(ID) + + def getHighlightedID(self): + if self.highlightedID > 0: + return self.highlightedID + + doHighlight = self.view_model.should_highlight_props_id( + dock_visible=self.propsDockWidget.isVisible(), + highlight_checked=self.guiTabControl.highlightCheckbox.isChecked(), + searched_highlight_checked=( + self.guiTabControl.highlightSearchedCheckbox.isChecked() + ), + ) + if not doHighlight: + return 0 + + return self.guiTabControl.propsQGBox.idSB.value() + + def clearHighlightedKeepIDs(self): + self.setAllTextAnnotations() + self.highlightedID = 0 + self.searchedIDitemRight.setData([], []) + self.searchedIDitemLeft.setData([], []) + self.highLightIDLayerImg1.clear() + self.highLightIDLayerRightImage.clear() + + def setHighlighedIDfromToolbar(self, ID: int): + self.object_search_view.findID(ID=ID) + + def highlightSearchedID(self, ID, force=False, greyOthers=True): + self.highlightIDToolbar.setIDNoSignals(ID) + + if ID == 0: + self.highlightIDToolbar.setVisible(False) + return + + if ID == self.highlightedID and not force: + return + + doHighlight = self.view_model.should_highlight_props_id( + dock_visible=self.propsDockWidget.isVisible(), + highlight_checked=self.guiTabControl.highlightCheckbox.isChecked(), + searched_highlight_checked=( + self.guiTabControl.highlightSearchedCheckbox.isChecked() + ), + ) + if doHighlight: + self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() + ID = self.highlightedID + + if self.highlightedID > 0: + self.clearHighlightedText() + + self.searchedIDitemRight.setData([], []) + self.searchedIDitemLeft.setData([], []) + + posData = self.data[self.pos_i] + + self.highlightedID = ID + self.highlightIDToolbar.setVisible(True) + + objIdx = posData.IDs_idxs.get(ID) + if objIdx is None: + return + + obj = posData.rp[objIdx] + isObjVisible = self.isObjVisible(obj.bbox) + if not isObjVisible: + return + + if greyOthers: + self.textAnnot[0].grayOutAnnotations() + self.textAnnot[1].grayOutAnnotations() + + how_ax1 = self.drawIDsContComboBox.currentText() + how_ax2 = self.getAnnotateHowRightImage() + isOverlaySegm_ax1 = how_ax1.find('segm. masks') != -1 + isOverlaySegm_ax2 = how_ax2.find('segm. masks') != -1 + alpha = self.imgGrad.labelsAlphaSlider.value() + + if isOverlaySegm_ax1 or isOverlaySegm_ax2: + grayedLut = self.grayOutHighlightedLabels( + nonGrayedIDs={obj.label}, + alpha=alpha + ) + + cont = None + contours = None + if isOverlaySegm_ax1: + self.highLightIDLayerImg1.setLookupTable(grayedLut) + self.highLightIDLayerImg1.setImage(self.highlightedLab) + self.labelsLayerImg1.setOpacity(alpha/3) + else: + contours = self.getObjContours(obj, all_external=True) + for cont in contours: + self.searchedIDitemLeft.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) + + if isOverlaySegm_ax2: + self.highLightIDLayerRightImage.setLookupTable(grayedLut) + self.highLightIDLayerRightImage.setImage(self.highlightedLab) + self.labelsLayerRightImg.setOpacity(alpha/3) + else: + if contours is None: + contours = self.getObjContours(obj, all_external=True) + for cont in contours: + self.searchedIDitemRight.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) + + # Gray out all IDs excpet searched one + lut = self.lut.copy() # [:max(posData.IDs)+1] + lut[:ID] = lut[:ID]*0.2 + lut[ID+1:] = lut[ID+1:]*0.2 + self.img2.setLookupTable(lut) + + # Highlight text + self.highlightLabelID(ID, ax=0) + self.highlightLabelID(ID, ax=1) + + def _keepObjects(self, keepIDs=None, lab=None, rp=None): + posData = self.data[self.pos_i] + if lab is None: + lab = posData.lab + + if rp is None: + rp = posData.rp + + if keepIDs is None: + keepIDs = self.keptObjectsIDs + + for obj in rp: + if obj.label in keepIDs: + continue + + lab[obj.slice][obj.image] = 0 + + return lab + + def clearHighlightedText(self): + pass + + def removeHighlightLabelID(self, IDs=None, ax=0): + posData = self.data[self.pos_i] + if IDs is None: + IDs = posData.IDs + + for ID in IDs: + obj = posData.rp[posData.IDs_idxs[ID]] + self.textAnnot[ax].removeHighlightObject(obj) + + def updateKeepIDs(self, IDs): + posData = self.data[self.pos_i] + + self.clearHighlightedText() + + isAnyIDnotExisting = False + # Check if IDs from line edit are present in current keptObjectIDs list + for ID in IDs: + if ID not in posData.allIDs: + isAnyIDnotExisting = True + continue + if ID not in self.keptObjectsIDs: + self.keptObjectsIDs.append(ID, editText=False) + self.highlightLabelID(ID) + + # Check if IDs in current keptObjectsIDs are present in IDs from line edit + for ID in self.keptObjectsIDs: + if ID not in posData.allIDs: + isAnyIDnotExisting = True + continue + if ID not in IDs: + self.keptObjectsIDs.remove(ID, editText=False) + + self.updateTempLayerKeepIDs() + if isAnyIDnotExisting: + self.keptIDsLineEdit.warnNotExistingID() + else: + self.keptIDsLineEdit.setInstructionsText() + + @exception_handler + def applyKeepObjects(self): + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + self._keepObjects() + self.highlightHoverIDsKeptObj(0, 0, hoverID=0) + + posData = self.data[self.pos_i] + + self.update_rp() + # Repeat tracking + self.tracking(enforce=True, assign_unique_new_IDs=False) + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Deleted non-selected objects') + self.updateAllImages() + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + return + else: + removeAnnot = self.warnEditingWithCca_df( + 'Deleted non-selected objects', get_answer=True + ) + if not removeAnnot: + # We can propagate changes only if the user agrees on + # removing annotations + return + + self.current_frame_i = posData.frame_i + if posData.frame_i > 0: + txt = html_utils.paragraph(""" + Do you want to remove un-kept objects in the past frames too? + """) + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + _, _, applyToPastButton = msg.question( + self, 'Propagate to past frames?', txt, + buttonsTexts=('Cancel', 'No', 'Yes, apply to past frames') + ) + if msg.cancel: + return + if msg.clickedButton == applyToPastButton: + self.store_data() + self.logger.info('Applying keep objects to past frames...') + if not removeAnnot and posData.cca_df is not None: + delIDs = [ + ID for ID in posData.cca_df.index + if ID not in posData.IDs + ] + self.update_cca_df_deletedIDs(posData, delIDs) + + for i in tqdm(range(posData.frame_i), ncols=100): + lab = posData.allData_li[i]['labels'] + rp = posData.allData_li[i]['regionprops'] + keepLab = self._keepObjects(lab=lab, rp=rp) + # Store change + posData.allData_li[i]['labels'] = keepLab.copy() + # Get the rest of the stored metadata based on the new lab + posData.frame_i = i + self.get_data() + self.store_data(autosave=False) + + posData.frame_i = self.current_frame_i + self.get_data() + + # Ask to propagate change to all future visited frames + key = 'Keep ID' + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + self.keptObjectsIDs, key, doNotShow, + posData.UndoFutFrames_keepID, posData.applyFutFrames_keepID, + force=True, applyTrackingB=True + ) + + if UndoFutFrames is None: + # Empty keep object list + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + return + + posData.doNotShowAgain_keepID = doNotShowAgain + posData.UndoFutFrames_keepID = UndoFutFrames + posData.applyFutFrames_keepID = applyFutFrames + includeUnvisited = posData.includeUnvisitedInfo['Keep ID'] + + if applyFutFrames: + self.store_data() + + self.logger.info('Applying to future frames...') + pbar = tqdm(total=posData.SizeT-posData.frame_i-1, ncols=100) + segmSizeT = len(posData.segm_data) + if not removeAnnot and posData.cca_df is not None: + delIDs = [ + ID for ID in posData.cca_df.index + if ID not in posData.IDs + ] + self.update_cca_df_deletedIDs(posData, delIDs) + + for i in range(posData.frame_i+1, segmSizeT): + lab = posData.allData_li[i]['labels'] + if lab is None and not includeUnvisited: + self.enqAutosave() + pbar.update(posData.SizeT-i) + break + + rp = posData.allData_li[i]['regionprops'] + + if lab is not None: + keepLab = self._keepObjects(lab=lab, rp=rp) + # Store change + posData.allData_li[i]['labels'] = keepLab.copy() + # Get the rest of the stored metadata based on the new lab + posData.frame_i = i + self.get_data() + self.store_data(autosave=False) + elif includeUnvisited: + # Unvisited frame (includeUnvisited = True) + lab = posData.segm_data[i] + rp = skimage.measure.regionprops(lab) + keepLab = self._keepObjects(lab=lab, rp=rp) + posData.segm_data[i] = keepLab + + pbar.update() + pbar.close() + + # Back to current frame + if applyFutFrames: + posData.frame_i = self.current_frame_i + self.get_data() + + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + + def get_curr_lab(self, curr_lab: np.ndarray|None = None, frame_i: int|None = None): + """Get the current labels for the position data. Hirarchically checks: + 1. If `curr_lab` is provided, use it. + 2. If `posData.lab` is not None, use it. + 3. If `posData.allData_li[frame_i]['labels']` exists, use it. + 4. If `posData.segm_data[frame_i]` exists, use it. + + If frame_i is None, uses the current frame index from `posData`. + + Parameters + ---------- + curr_lab : np.ndarray, optional + Current labels for the position data if it should be checked + if its not None first, by default None + frame_i : int, optional + Frame index to use for retrieving labels, by default None + + Returns + ------- + np.ndarray + Current labels for the position data + """ + posData = self.data[self.pos_i] + return self.view_model.object_counts.current_labels( + posData, + curr_lab=curr_lab, + frame_i=frame_i, + ) + + def highlightIDonHoverCheckBoxToggled(self, checked): + doHighlight = ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() + ) + if not doHighlight: + self.highlightedID = 0 + self.initLookupTableLab() + else: + self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() + self.highlightSearchedID(self.highlightedID, force=True) + self.updatePropsWidget(self.highlightedID) + self.updateAllImages() + + def highlightSearchedIDcheckBoxToggled(self, checked): + self.highlightIDonHoverCheckBoxToggled(checked) + if checked: + posData = self.data[self.pos_i] + self.highlightedID = self.getHighlightedID() + if self.highlightedID == 0: + return + objIdx = posData.IDs_idxs[self.highlightedID] + obj_idx = posData.IDs_idxs.get(self.highlightedID) + if obj_idx is None: + return + obj = posData.rp[objIdx] + self.goToZsliceSearchedID(obj) + + def setHighlightID(self, doHighlight): + if not doHighlight: + self.highlightedID = 0 + self.initLookupTableLab() + else: + self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() + self.highlightSearchedID(self.highlightedID, force=True) + self.updatePropsWidget(self.highlightedID) + self.updateAllImages() + + def propsWidgetIDvalueChanged(self, ID): + posData = self.data[self.pos_i] + if ID == 0: + self.updatePropsWidget(int(ID)) + return + + propsQGBox = self.guiTabControl.propsQGBox + obj_idx = posData.IDs_idxs.get(ID) + if obj_idx is None: + s = f'Object ID {int(ID):d} does not exist' + propsQGBox.notExistingIDLabel.setText(s) + return + + obj = posData.rp[obj_idx] + self.goToZsliceSearchedID(obj) + self.updatePropsWidget(int(ID)) + + def updatePropsWidget(self, ID, fromHover=False): + if isinstance(ID, str): + # Function called by currentTextChanged of channelCombobox or + # additionalMeasCombobox. We set self.currentPropsID = 0 to force update + ID = self.guiTabControl.propsQGBox.idSB.value() + self.currentPropsID = -1 + + ID = int(ID) + + update = self.view_model.should_update_props_widget( + dock_visible=self.propsDockWidget.isVisible(), + object_id=ID, + current_props_id=self.currentPropsID, + ) + if not update: + return + + posData = self.data[self.pos_i] + if not hasattr(posData, 'rp'): + return + + if posData.rp is None: + self.update_rp() + + if not posData.IDs: + # empty segmentation mask + return + + if fromHover and not self.guiTabControl.highlightCheckbox.isChecked(): + # Do not highlight on hover + return + + propsQGBox = self.guiTabControl.propsQGBox + + obj_idx = posData.IDs_idxs.get(ID) + if obj_idx is None: + s = f'Object ID {int(ID):d} does not exist' + propsQGBox.notExistingIDLabel.setText(s) + return + + propsQGBox.notExistingIDLabel.setText('') + self.currentPropsID = ID + propsQGBox.idSB.setValue(ID) + + doHighlight = self.view_model.should_highlight_props_id( + dock_visible=True, + highlight_checked=self.guiTabControl.highlightCheckbox.isChecked(), + searched_highlight_checked=( + self.guiTabControl.highlightSearchedCheckbox.isChecked() + ), + ) + if doHighlight: + self.highlightSearchedID(ID) + + obj = posData.rp[obj_idx] + + area_pxl = self.view_model.calculate_area_pxl( + is_segm_3d=self.isSegm3D, + z_proj_text=self.zProjComboBox.currentText(), + z_lab=self.z_lab(), + bbox_0=obj.bbox[0], + obj_image=obj.image, + obj_area=obj.area, + ) + + propsQGBox.cellAreaPxlSB.setValue(area_pxl) + + pixelSizeQGBox = self.guiTabControl.pixelSizeQGBox + PhysicalSizeX = pixelSizeQGBox.pixelWidthWidget.value() + PhysicalSizeY = pixelSizeQGBox.pixelHeightWidget.value() + PhysicalSizeZ = pixelSizeQGBox.voxelDepthWidget.value() + + area_um2 = self.view_model.calculate_area_um2( + area_pxl=area_pxl, + physical_size_x=PhysicalSizeX, + physical_size_y=PhysicalSizeY, + ) + + propsQGBox.cellAreaUm2DSB.setValue(area_um2) + + if self.isSegm3D: + vol_vox_3D, vol_fl_3D = self.view_model.calculate_vol_3d( + obj_area=obj.area, + physical_size_x=PhysicalSizeX, + physical_size_y=PhysicalSizeY, + physical_size_z=posData.PhysicalSizeZ, + ) + propsQGBox.cellVolVox3D_SB.setValue(vol_vox_3D) + propsQGBox.cellVolFl3D_DSB.setValue(vol_fl_3D) + + vol_vox, vol_fl = self.view_model.measurements.rotational_volume( + obj, PhysicalSizeY, PhysicalSizeX + ) + propsQGBox.cellVolVoxSB.setValue(int(vol_vox)) + propsQGBox.cellVolFlDSB.setValue(vol_fl) + + elongation = self.view_model.calculate_elongation( + major_axis_length=obj.major_axis_length, + minor_axis_length=obj.minor_axis_length, + ) + propsQGBox.elongationDSB.setValue(elongation) + + solidity = obj.solidity + propsQGBox.solidityDSB.setValue(solidity) + + additionalPropName = propsQGBox.additionalPropsCombobox.currentText() + additionalPropValue = getattr(obj, additionalPropName) + propsQGBox.additionalPropsCombobox.indicator.setValue(additionalPropValue) + + intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox + selectedChannel = intensMeasurQGBox.channelCombobox.currentText() + + try: + _, filename = self.getPathFromChName(selectedChannel, posData) + image = posData.ol_data_dict[filename][posData.frame_i] + except Exception as e: + image = posData.img_data[posData.frame_i] + + objData, img = self.view_model.get_object_and_background_images( + image=image, + is_segm_3d=self.isSegm3D, + pos_data_size_z=posData.SizeZ, + z_slice=self.zSliceScrollBar.sliderPosition(), + obj_slice=obj.slice, + obj_image=obj.image, + img1_image=self.img1.image, + ) + + stats = self.view_model.calculate_intensity_statistics(objData) + intensMeasurQGBox.minimumDSB.setValue(stats['min']) + intensMeasurQGBox.maximumDSB.setValue(stats['max']) + intensMeasurQGBox.meanDSB.setValue(stats['mean']) + intensMeasurQGBox.medianDSB.setValue(stats['median']) + + funcDesc = intensMeasurQGBox.additionalMeasCombobox.currentText() + func = intensMeasurQGBox.additionalMeasCombobox.functions[funcDesc] + + value = self.view_model.calculate_additional_measure( + func_desc=funcDesc, + func=func, + obj_data=objData, + img=img, + lab=posData.lab, + obj_area=obj.area, + vol_vox=vol_vox, + ) + + intensMeasurQGBox.additionalMeasCombobox.indicator.setValue(value) + diff --git a/cellacdc/views/object_search_view.py b/cellacdc/views/object_search_view.py new file mode 100644 index 000000000..5b045e62c --- /dev/null +++ b/cellacdc/views/object_search_view.py @@ -0,0 +1,280 @@ +"""Qt view adapter for object search and navigation.""" + +from __future__ import annotations + +from qtpy.QtCore import QEventLoop, QThread + +from cellacdc import apps, html_utils, widgets, workers +from cellacdc.viewmodels.object_search_viewmodel import ObjectSearchViewModel + + +class ObjectSearchView: + """Qt-facing adapter around object-search commands.""" + + def __init__(self, host, view_model: ObjectSearchViewModel): + self.host = host + self.view_model = view_model + + def findID(self, checked=False, ID=None): + pos_data = self.host.data[self.host.pos_i] + if ID is None: + search_id_dialog = apps.FindIDDialog( + title='Search object by ID', + msg='Enter object ID to find and highlight', + parent=self.host, + isInteger=True, + ) + search_id_dialog.exec_() + if search_id_dialog.cancel: + return + + searched_id = search_id_dialog.EntryID + else: + searched_id = ID + + if searched_id in pos_data.IDs: + self.goToObjectID(searched_id) + return + + if pos_data.SizeT == 1: + self.warnIDnotFound(searched_id) + return + + if searched_id in pos_data.lost_IDs: + self.goToLostObjectID(searched_id) + return + + tracked_lost_ids = self.host.getTrackedLostIDs() + if searched_id in tracked_lost_ids: + self.goToAcceptedLostObjectID(searched_id) + return + + self.host.logger.info(f'Searching ID {searched_id} in other frames...') + + frame_i_found = self.startSearchIDworker(searched_id) + if frame_i_found is None: + self.warnIDnotFound(searched_id) + return + + self.host.logger.info( + f'Object ID {searched_id} found at frame n. {frame_i_found+1}.' + ) + proceed = self.askGoToFrameFoundID(searched_id, frame_i_found) + if not proceed: + return + + pos_data.frame_i = frame_i_found + self.host.get_data() + self.host.updateAllImages() + self.host.updateScrollbars() + + self.goToObjectID(searched_id) + + def startSearchIDworker(self, searchedID): + self.host.setDisabled(True) + try: + return self._startSearchIDworker(searchedID) + finally: + self.host.setDisabled(False) + self.host.activateWindow() + + def _startSearchIDworker(self, searchedID): + pos_data = self.host.data[self.host.pos_i] + + desc = 'Searching ID in all frames...' + + self.host.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self.host.mainWin, pbarDesc=desc + ) + self.host.progressWin.mainPbar.setMaximum(pos_data.SizeT) + self.host.progressWin.show(self.host.app) + + self.host.searchIDthread = QThread() + self.host.searchIDworker = workers.SimpleWorker( + pos_data, + self.searchIDworkerCallback, + func_args=(searchedID,), + ) + self.host.searchIDworker.frame_i_found = None + self.host.searchIDworker.moveToThread(self.host.searchIDthread) + + self.host.searchIDworker.signals.finished.connect( + self.host.searchIDthread.quit + ) + self.host.searchIDworker.signals.finished.connect( + self.host.searchIDworker.deleteLater + ) + self.host.searchIDthread.finished.connect( + self.host.searchIDthread.deleteLater + ) + + self.host.searchIDworker.signals.critical.connect( + self.searchIDworkerCritical + ) + self.host.searchIDworker.signals.initProgressBar.connect( + self.host.workerInitProgressbar + ) + self.host.searchIDworker.signals.progressBar.connect( + self.host.workerUpdateProgressbar + ) + self.host.searchIDworker.signals.progress.connect( + self.host.workerProgress + ) + self.host.searchIDworker.signals.finished.connect( + self.searchIDworkerFinished + ) + + self.host.searchIDthread.started.connect(self.host.searchIDworker.run) + self.host.searchIDthread.start() + + self.host.searchIDworkerLoop = QEventLoop() + self.host.searchIDworkerLoop.exec_() + + return self.host.searchIDworker.frame_i_found + + def searchIDworkerCritical(self, error): + self.host.searchIDworkerLoop.exit() + self.host.workerCritical(error) + + def searchIDworkerFinished(self): + if self.host.progressWin is not None: + self.host.progressWin.workerFinished = True + self.host.progressWin.close() + self.host.progressWin = None + + self.host.searchIDworkerLoop.exit() + + def searchIDworkerCallback(self, posData, searchedID): + self.host.searchIDworker.signals.initProgressBar.emit(0) + self.host.setAllIDs() + self.host.searchIDworker.signals.initProgressBar.emit(posData.SizeT) + frame_i_found = self.view_model.find_frame_with_id( + posData, + searchedID, + progress_callback=self.host.searchIDworker.signals.progressBar.emit, + ) + self.host.searchIDworker.frame_i_found = frame_i_found + + def warnIDnotFound(self, searchedID): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(f""" + Object ID {searchedID} was not found.

+ """) + msg.warning(self.host, f'ID {searchedID} not found', txt) + + def goToObjectID(self, ID): + pos_data = self.host.data[self.host.pos_i] + obj_idx = pos_data.IDs_idxs[ID] + obj = pos_data.rp[obj_idx] + self.host.goToZsliceSearchedID(obj) + + self.host.highlightSearchedID(ID) + props_qgbox = self.host.guiTabControl.propsQGBox + props_qgbox.idSB.setValue(ID) + + def goToLostObjectID(self, lostID, color=(255, 165, 0, 255)): + pos_data = self.host.data[self.host.pos_i] + frame_i = pos_data.frame_i + prev_rp = pos_data.allData_li[frame_i - 1]['regionprops'] + prev_ids_idxs = pos_data.allData_li[frame_i - 1]['IDs_idxs'] + obj = prev_rp[prev_ids_idxs[lostID]] + self.host.goToZsliceSearchedID(obj) + + image_item = self.host.getLostObjImageItem(0) + if not hasattr(self.host, 'lostObjContoursImage'): + self.host.initLostObjContoursImage() + else: + self.host.lostObjContoursImage[:] = 0 + + contours = [] + obj_contours = self.host.getObjContours(obj, all_external=True) + contours.extend(obj_contours) + + self.host.addLostObjsToLostObjImage(obj, lostID) + self.host.drawLostObjContoursImage( + image_item, contours, thickness=2, color=color + ) + + def goToAcceptedLostObjectID(self, acceptedLostID): + pos_data = self.host.data[self.host.pos_i] + frame_i = pos_data.frame_i + prev_rp = pos_data.allData_li[frame_i - 1]['regionprops'] + prev_ids_idxs = pos_data.allData_li[frame_i - 1]['IDs_idxs'] + obj = prev_rp[prev_ids_idxs[acceptedLostID]] + self.host.goToZsliceSearchedID(obj) + + self.host.updateLostTrackedContoursImage( + tracked_lost_IDs=[acceptedLostID] + ) + + def askGoToFrameFoundID(self, searchedID, frame_i_found): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(f""" + Object ID {searchedID} was found at frame n. {frame_i_found+1}.

+ Do you want to go to frame n. {frame_i_found+1}. + """) + noButton, yesButton = msg.information( + self.host, + f'ID {searchedID} found at frame n. {frame_i_found+1}', + txt, + buttonsTexts=( + 'No, stay on current frame', + f'Yes, go to frame n. {frame_i_found+1}', + ), + ) + return msg.clickedButton == yesButton + + def skipForwardToNewID(self): + self.host.progressWin = apps.QDialogWorkerProgress( + title='Searching the next frame with a new object', + parent=self.host, + pbarDesc='Searching the next frame with a new object...', + ) + self.host.progressWin.show(self.host.app) + self.host.progressWin.mainPbar.setMaximum(0) + + self.startFindNextNewIdWorker() + + def startFindNextNewIdWorker(self): + pos_data = self.host.data[self.host.pos_i] + self.host._thread = QThread() + self.host.findNextNewIdWorker = workers.FindNextNewIdWorker( + pos_data, self.host + ) + self.host.findNextNewIdWorker.moveToThread(self.host._thread) + + self.host.findNextNewIdWorker.signals.finished.connect( + self.host._thread.quit + ) + self.host.findNextNewIdWorker.signals.finished.connect( + self.host.findNextNewIdWorker.deleteLater + ) + self.host._thread.finished.connect(self.host._thread.deleteLater) + + self.host.findNextNewIdWorker.signals.finished.connect( + self.findNextNewIdWorkerFinished + ) + self.host.findNextNewIdWorker.signals.progress.connect( + self.host.workerProgress + ) + self.host.findNextNewIdWorker.signals.initProgressBar.connect( + self.host.workerInitProgressbar + ) + self.host.findNextNewIdWorker.signals.progressBar.connect( + self.host.workerUpdateProgressbar + ) + self.host.findNextNewIdWorker.signals.critical.connect( + self.host.workerCritical + ) + + self.host._thread.started.connect(self.host.findNextNewIdWorker.run) + self.host._thread.start() + + def findNextNewIdWorkerFinished(self, next_frame_i): + if self.host.progressWin is not None: + self.host.progressWin.workerFinished = True + self.host.progressWin.close() + self.host.progressWin = None + + self.host.navSpinBox.setValue(next_frame_i + 1) + self.host.framesScrollBarReleased() diff --git a/cellacdc/views/points_layers_view.py b/cellacdc/views/points_layers_view.py new file mode 100644 index 000000000..4b20a5e58 --- /dev/null +++ b/cellacdc/views/points_layers_view.py @@ -0,0 +1,1259 @@ +"""Qt view adapter for points-layer workflows.""" + +from __future__ import annotations + +import os +from collections import defaultdict +from copy import deepcopy +from datetime import datetime +from functools import partial + +import matplotlib +import numpy as np +import pyqtgraph as pg +import skimage.draw +import skimage.measure +from qtpy.QtCore import QTimer +from qtpy.QtWidgets import QLabel + +from cellacdc import _warnings, apps, colors, exception_handler, html_utils, widgets +from cellacdc.viewmodels.points_layers_viewmodel import PointsLayersViewModel + + +class PointsLayersView: + """Qt-facing adapter around points-layer workflows.""" + + LEGACY_METHODS = ( + 'checkAskSavePointsLayers', + 'askSavePointsLayer', + 'reinitPointsLayers', + 'clearPointsLayers', + 'askSaveAddedPoints', + 'pointsLayerToggled', + 'addPointsLayerTriggered', + 'logLoadedTablePointsLayer', + 'buttonAddPointsByClickingActive', + 'setupAddPointsByClicking', + 'storeUndoAddPoint', + 'undoAddPoint', + 'getAddedPointId', + 'addPointsByClickingScatterItemHoverEntered', + 'autoPilotZoomToObjToggled', + 'savePointsAddedByClickingFromEndname', + 'markPointsLayerDirty', + 'flushDirtyPointsLayersAutosave', + 'savePointsAddedByClicking', + 'updatePointsLayerClickEntryTableEndname', + 'pointsLayerDfsToData', + 'pointsLayerLoadedDfsToData', + 'setPointsLayerLoadedDfEndanme', + 'pointsLayerClicksDfsToData', + 'pointsLayerDataToDf', + 'restartZoomAutoPilot', + 'resizeRangeWelcomeText', + 'zoomToObj', + 'addPointsByClickingButtonToggled', + 'autoZoomNextObj', + 'autoZoomPrevObj', + 'pointsLayerAutoPilot', + 'getClickEntryTableFilepaths', + 'getClickEntryNewerRecoveryFilepaths', + 'askLoadNewerRecoveryClickEntryDfs', + 'checkClickEntryTableEndnameExists', + 'checkLoadedTableIds', + 'addPointsLayer', + 'loadClickEntryDfs', + 'removeClickedPoints', + 'restorePrevPointIdRightClick', + 'getClickedPointNewId', + 'setHoverCircleAddPoint', + 'isPointIdAlreadyNew', + 'addClickedPoint', + 'showPointsLayerIdsToggled', + 'removePointsLayer', + 'editPointsLayerAppearance', + 'loadPointsLayerWeighingData', + 'pointLayerToolbuttonToggled', + 'getCentroidsPointsData', + 'drawPointsLayers', + ) + + def __init__(self, host, view_model: PointsLayersViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def checkAskSavePointsLayers(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'layerTypeIdx'): + continue + if action.layerTypeIdx != 4: + continue + + scatterItem = action.scatterItem + xx, yy = scatterItem.getData() + + if xx is None or len(xx) == 0: + toolButton = action.button + tableEndName = toolButton.clickEntryTableEndName + # Check in other loaded pos + are_there_points_to_save = False + for pos_i, _posData in enumerate(self.data): + if pos_i == self.pos_i: + continue + + df = _posData.clickEntryPointsDfs.get(tableEndName) + if df is None: + continue + + are_there_points_to_save = True + break + + if not are_there_points_to_save: + continue + + cancel = self.askSavePointsLayer(action) + if cancel: + return cancel + + return False + + def askSavePointsLayer(self, action): + toolButton = action.button + tableEndName = toolButton.clickEntryTableEndName + saveAction = toolButton.saveAction + + txt = html_utils.paragraph(f""" + Do you want to save the points you added + (table called {tableEndName}.csv)? + """ + ) + msg = widgets.myMessageBox(wrapText=False) + _, _, saveButton = msg.question( + self.host, 'Save points layer?', txt, + buttonsTexts=('Cancel', 'No, do not save', 'Yes, save points') + ) + if msg.clickedButton == saveButton: + self.savePointsAddedByClicking(saveAction.saveToolbutton, None) + + return msg.cancel + + def reinitPointsLayers(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + toolbar.removeAction(action) + toolbar.setVisible(False) + self.autoPilotZoomToObjToolbar.setVisible(False) + + def clearPointsLayers(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + try: + action.scatterItem.clear() + except Exception as e: + continue + + def askSaveAddedPoints(self): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + 'Do you want to save the annotated points?' + ) + _, noButton, yesButton = msg.question( + self.host, 'Save?', txt, + buttonsTexts=('Cancel', 'No', 'Yes') + ) + if msg.clickedButton != yesButton: + return + + for toolbar in self.pointsLayersToolbars: + for action in self.pointsLayersToolbar.actions(): + try: + if 'Save annotated' in action.text(): + action.trigger() + except Exception as err: + pass + + def pointsLayerToggled(self, checked): + if not checked: + for action in self.pointsLayersToolbar.actions(): + try: + if 'Save annotated' in action.text(): + self.askSaveAddedPoints() + break + except Exception as err: + pass + self.pointsLayersToolbar.setVisible(checked) + self.autoPilotZoomToObjToolbar.setVisible(checked) + if self.pointsLayersNeverToggled: + self.pointsLayersToolbar.sigAddPointsLayer.emit() + self.pointsLayersNeverToggled = False + QTimer.singleShot(200, self.autoRange) + + def addPointsLayerTriggered(self, checked=False, toolbar=None): + if toolbar is None: + toolbar = self.pointsLayersToolbar + + if self.addPointsWin is not None: + self.logger.info( + 'Add points layer window is already open. Cannot add now.' + ) + return + + onlyMouseClicks = toolbar == self.promptSegmentPointsLayerToolbar + posData = self.data[self.pos_i] + self.addPointsWin = apps.AddPointsLayerDialog( + channelNames=posData.chNames, + imagesPath=posData.images_path, + hideCentroidsSection=onlyMouseClicks, + hideWeightedCentroidsSection=onlyMouseClicks, + hideFromTableSection=onlyMouseClicks, + hideManualEntrySection=onlyMouseClicks, + hideWithMouseClicksSection=False, + parent=self.host, + ) + cmap = matplotlib.colormaps['gist_rainbow'] + i = np.random.default_rng(seed=123).uniform() + for action in toolbar.actions()[1:]: + if not hasattr(action, 'layerTypeIdx'): + continue + rgb = [round(c*255) for c in cmap(i)][:3] + self.addPointsWin.appearanceGroupbox.colorButton.setColor(rgb) + break + + self.addPointsWin.sigCriticalReadTable.connect(self.logger.info) + self.addPointsWin.sigLoadedTable.connect(self.logLoadedTablePointsLayer) + self.addPointsWin.sigClosed.connect( + partial(self.addPointsLayer, toolbar=toolbar) + ) + self.addPointsWin.sigCheckClickEntryTableEndnameExists.connect( + self.checkClickEntryTableEndnameExists + ) + self.addPointsWin.show() + if self.addPointsWin.clickEntryRadiobutton.isChecked(): + QTimer.singleShot( + 200, + partial( + self.addPointsWin.sigCheckClickEntryTableEndnameExists.emit, + self.addPointsWin.clickEntryTableEndname.text(), + False + ) + ) + + def logLoadedTablePointsLayer(self, df, filename: str): + separator = f'-'*100 + header = f'First 10 rows of loaded table - "{filename}":' + footer = f'Number of points: {len(df)}' + text = ( + f'{separator}\n' + f'{header}\n\n' + f'{df.head(10)}\n\n' + f'{footer}\n' + f'{separator}' + ) + if filename: + text = f'{text}\nFilename: {filename}' + self.logger.info(text) + + def buttonAddPointsByClickingActive(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'layerTypeIdx'): + continue + if action.layerTypeIdx == 4 and action.button.isChecked(): + return action.button + + def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): + self.LeftClickButtons.append(toolButton) + posData = self.data[self.pos_i] + tableEndName = self.addPointsWin.clickEntryTableEndnameText + if isLoadedDf is not None: + posData = self.data[self.pos_i] + tableEndName = tableEndName[len(posData.basename):] + self.loadClickEntryDfs(tableEndName) + + toolButton.toolbar = toolbar + toolButton.clickEntryTableEndName = tableEndName + self.checkableQButtonsGroup.addButton(toolButton) + toolButton.toggled.connect(self.addPointsByClickingButtonToggled) + + self.addPointsByClickingButtonToggled(sender=toolButton) + + toolButton.setToolTip(tableEndName) + + pointIdSpinbox = widgets.SpinBox() + pointIdSpinbox.setMinimum(0) + pointIdSpinbox.setValue(1) + pointIdSpinbox.label = QLabel(' Left-click ID: ') + pointIdSpinbox.labelAction = toolbar.addWidget(pointIdSpinbox.label) + if toolbar == self.promptSegmentPointsLayerToolbar: + newID = self.setBrushID(return_val=True) + pointIdSpinbox.setValue(newID) + pointIdSpinbox.setReadOnly(True) + pointIdSpinbox.setToolTip( + 'The ids added with left-click cannot be manually edited. ' + 'They are always a new, non-existing id.' + ) + + toolButton.actions.append(pointIdSpinbox.labelAction) + pointIdSpinbox.action = toolbar.addWidget(pointIdSpinbox) + toolButton.actions.append(pointIdSpinbox.action) + pointIdSpinbox.toolButton = toolButton + toolButton.pointIdSpinbox = pointIdSpinbox + + rightClickIDSpinbox = widgets.SpinBox() + pointIdSpinbox.setLinkedValueWidget(rightClickIDSpinbox) + rightClickIDSpinbox.setMaximumWidth(pointIdSpinbox.sizeHint().width()) + rightClickIDSpinbox.setValue(pointIdSpinbox.value()) + rightClickIDSpinbox.setMinimum(0) + rightClickIDSpinbox.label = QLabel(' | Right-click ID: ') + rightClickIDSpinbox.labelAction = toolbar.addWidget( + rightClickIDSpinbox.label + ) + toolButton.actions.append(rightClickIDSpinbox.labelAction) + rightClickIDSpinbox.action = toolbar.addWidget(rightClickIDSpinbox) + toolButton.actions.append(rightClickIDSpinbox.action) + rightClickIDSpinbox.toolButton = toolButton + toolButton.rightClickIDSpinbox = rightClickIDSpinbox + + saveToolbutton = widgets.SavePointsLayerButton( + tableEndName, parent=self.host + ) + saveToolbutton.sigRenameTableAction.connect( + self.updatePointsLayerClickEntryTableEndname + ) + saveToolbutton.sigLeftClick.connect(self.savePointsAddedByClicking) + saveAction = toolbar.addWidget(saveToolbutton) + saveToolbutton.action = saveAction + saveAction.saveToolbutton = saveToolbutton + saveAction.toolButton = toolButton + toolButton.saveAction = saveAction + toolButton.saveToolbutton = saveToolbutton + + toolButton.actions.append(saveAction) + + vlineAction = toolbar.addWidget(widgets.QVLine()) + spacerAction = toolbar.addWidget( + widgets.QHWidgetSpacer(width=5) + ) + + toolButton.actions.append(vlineAction) + toolButton.actions.append(spacerAction) + + action = toolButton.action + scatterItem = action.scatterItem + scatterItem.sigHoverEntered.connect( + self.addPointsByClickingScatterItemHoverEntered + ) + + self.pointsLayerClicksDfsToData(posData, toolbar=toolbar) + + def storeUndoAddPoint(self, action): + if not hasattr(self, 'undoAddPointQueueMapper'): + self.undoAddPointQueueMapper = defaultdict(list) + + posData = self.data[self.pos_i] + pointsDataPos = action.pointsData.get(self.pos_i) + if pointsDataPos is None: + return + + state = deepcopy(pointsDataPos) + self.undoAddPointQueueMapper[action].append(state) + self.undoAction.setEnabled(True) + + def undoAddPoint(self, action): + undoAddPointQueue = self.undoAddPointQueueMapper.get(action) + if undoAddPointQueue is None: + return False + + if len(undoAddPointQueue) == 0: + return False + + posData = self.data[self.pos_i] + state = undoAddPointQueue.pop(-1) + action.pointsData[self.pos_i] = state + self.markPointsLayerDirty(action=action) + + self.drawPointsLayers(computePointsLayers=False) + + if len(self.undoAddPointQueueMapper[action]) == 0: + self.undoAction.setEnabled(True) + + return True + + def getAddedPointId( + self, isMagicPrompts, addPointsByClickingButton, + right_click, left_click, middle_click + ): + action = addPointsByClickingButton.action + if right_click: + id = addPointsByClickingButton.rightClickIDSpinbox.value() + elif left_click: + id = addPointsByClickingButton.pointIdSpinbox.value() + id = self.getClickedPointNewId( + action, id, addPointsByClickingButton.pointIdSpinbox, + isMagicPrompts=isMagicPrompts + ) + if isMagicPrompts: + proceed = self.warnAddingPointWithExistingId(id) + if not proceed: + return + + addPointsByClickingButton.pointIdSpinbox.setValue(id) + elif middle_click: + id = 0 + + return id + + def addPointsByClickingScatterItemHoverEntered(self, item, points, event): + point = points[0] + point_id = point.data() + toolButton = item.action.button + toolButton.rightClickIDSpinbox.prevId = ( + toolButton.rightClickIDSpinbox.value() + ) + toolButton.rightClickIDSpinbox.setValue(point_id) + + def autoPilotZoomToObjToggled(self, checked): + if not checked: + self.zoomOut() + return + + posData = self.data[self.pos_i] + if not posData.IDs: + self.logger.info('There are no objects in current segmentation mask') + return + self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) + self.zoomToObj(posData.rp[0]) + + def savePointsAddedByClickingFromEndname(self, tableEndName, recovery=False): + self.pointsLayerDataToDf(self.data[self.pos_i]) + for posData in self.data: + tableFilename = self.view_model.points.click_points_table_filename( + posData.basename, tableEndName + ) + if recovery: + tableFilepath = os.path.join( + posData.recoveryFolderpath(), tableFilename + ) + else: + tableFilepath = os.path.join(posData.images_path, tableFilename) + df = posData.clickEntryPointsDfs.get(tableEndName) + if df is None: + continue + self.view_model.points.save_click_points_table(tableFilepath, df) + + def markPointsLayerDirty(self, tableEndName=None, action=None): + if tableEndName is None and action is not None: + tableEndName = getattr(action, 'clickEntryTableEndName', None) + + if tableEndName is None: + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + if addPointsByClickingButton is None: + return + tableEndName = addPointsByClickingButton.clickEntryTableEndName + + self.dirtyPointsLayerTableEndNames.add(tableEndName) + + def flushDirtyPointsLayersAutosave(self): + if not self.dirtyPointsLayerTableEndNames: + return + + for tableEndName in tuple(self.dirtyPointsLayerTableEndNames): # avoid runtime error + self.savePointsAddedByClickingFromEndname( + tableEndName, recovery=True + ) + + self.dirtyPointsLayerTableEndNames.clear() + + @exception_handler + def savePointsAddedByClicking(self, button, event): + sender = button.action + toolButton = sender.toolButton + tableEndName = toolButton.clickEntryTableEndName + + self.logger.info(f'Saving _{tableEndName}.csv table...') + + self.savePointsAddedByClickingFromEndname(tableEndName) + + self.logger.info(f'{tableEndName}.csv saved!') + self.titleLabel.setText(f'{tableEndName}.csv saved!', color='g') + + def updatePointsLayerClickEntryTableEndname( + self, saveToolbutton, table_endname + ): + saveAction = saveToolbutton.action + toolButton = saveAction.toolButton + toolButton.clickEntryTableEndName = table_endname + + self.logger.info( + f'Done. Click entry table endname updated to "{table_endname}"' + ) + + def pointsLayerDfsToData(self, posData): + self.pointsLayerClicksDfsToData(posData) + + def pointsLayerLoadedDfsToData(self): + posData = self.data[self.pos_i] + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'loadedDfInfo'): + continue + + if action.loadedDfInfo is None: + continue + + endname = action.loadedDfInfo.get('endname') + if endname is None: + continue + + filename = f'{posData.basename}{endname}' + filepath = os.path.join(posData.images_path, filename) + if not os.path.exists(filepath): + action.pointsData[self.pos_i] = {} + + df = self.view_model.points.load_points_table(filepath) + action.pointsData[self.pos_i] = ( + self.view_model.points.loaded_table_to_points_data( + df, action.loadedDfInfo['t'], action.loadedDfInfo['z'], + action.loadedDfInfo['y'], action.loadedDfInfo['x'] + ) + ) + self.logLoadedTablePointsLayer(df, filename=filename) + + def setPointsLayerLoadedDfEndanme(self, action): + if action.loadedDfInfo is None: + return + + posData = self.data[self.pos_i] + images_path = posData.images_path.replace('\\', '/') + + df_folderpath = os.path.dirname( + action.loadedDfInfo['filepath'].replace('\\', '/') + ) + + if images_path != df_folderpath: + return + + df_filename = os.path.basename(action.loadedDfInfo['filepath']) + + if not df_filename.startswith(posData.basename): + return + + endname = df_filename[len(posData.basename):] + action.loadedDfInfo['endname'] = endname + + action.button.setToolTip(endname) + + def pointsLayerClicksDfsToData(self, posData, toolbar=None): + if toolbar is None: + toolbar = self.pointsLayersToolbar + + for action in toolbar.actions()[1:]: + if not hasattr(action, 'button'): + continue + + if not hasattr(action.button, 'clickEntryTableEndName'): + continue + tableEndName = action.button.clickEntryTableEndName + action.pointsData[self.pos_i] = {} + if posData.clickEntryPointsDfs.get(tableEndName) is None: + continue + + df = posData.clickEntryPointsDfs[tableEndName] + + try: + action.pointsData[self.pos_i] = ( + self.view_model.points.click_points_table_to_data( + df, size_z=posData.SizeZ, + ) + ) + except ValueError: + self.warnLoadedPointsTableIsNot3D(tableEndName) + return + + def pointsLayerDataToDf(self, posData, getOnlyActive=False, toolbar=None): + df = None + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'button'): + continue + if not hasattr(action.button, 'clickEntryTableEndName'): + continue + + tableEndName = action.button.clickEntryTableEndName + if getOnlyActive and not action.button.isChecked(): + continue + + df = toolbar.fromActionToDataFrame( + action, posData, isSegm3D=self.isSegm3D + ) + posData.clickEntryPointsDfs[tableEndName] = df + return df + + def restartZoomAutoPilot(self): + if not self.autoPilotZoomToObjToggle.isChecked(): + return + + posData = self.data[self.pos_i] + if not posData.IDs: + return + + self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) + self.zoomToObj(posData.rp[0]) + + def resizeRangeWelcomeText(self): + xRange, yRange = self.ax1.viewRange() + deltaX = xRange[1] - xRange[0] + deltaY = yRange[1] - yRange[0] + self.ax1.setXRange(0, deltaX) + self.ax1.setYRange(0, deltaY) + self.ax1.setLimits( + xMin=0, xMax=deltaX, yMin=0, yMax=deltaY + ) + # self.ax1.setXRange(0, 0) + # self.ax1.setYRange(0, 0) + + def zoomToObj(self, obj=None): + if not hasattr(self, 'data'): + return + posData = self.data[self.pos_i] + if obj is None: + ID = self.sender().value() + try: + ID_idx = posData.IDs_idxs[ID] + obj = obj = posData.rp[ID_idx] + except Exception as e: + self.logger.warning( + f'ID {ID} does not exist (add points by clicking)' + ) + + if obj is None: + return + + self.goToZsliceSearchedID(obj) + min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) + xRange = min_col-5, max_col+5 + yRange = max_row+5, min_row-5 + + self.ax1.setRange(xRange=xRange, yRange=yRange) + + def addPointsByClickingButtonToggled(self, checked=True, sender=None): + if sender is None: + sender = self.sender() + if not sender.isChecked(): + action = sender.action + action.scatterItem.setVisible(False) + return + + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(sender) + self.connectLeftClickButtons() + action = sender.action + action.scatterItem.setVisible(True) + self.ax1_BrushCircle.setBrush(action.brushColor) + self.ax1_BrushCircle.setPen(action.penColor) + + def autoZoomNextObj(self): + self.sender().setValue(self.sender().value() - 1) + self.pointsLayerAutoPilot('next') + self.image_controls_view.setFocusMain() + self.image_controls_view.setFocusGraphics() + + def autoZoomPrevObj(self): + self.sender().setValue(self.sender().value() + 1) + self.pointsLayerAutoPilot('prev') + self.image_controls_view.setFocusMain() + self.image_controls_view.setFocusGraphics() + + def pointsLayerAutoPilot(self, direction): + if not self.autoPilotZoomToObjToggle.isChecked(): + return + ID = self.autoPilotZoomToObjSpinBox.value() + posData = self.data[self.pos_i] + if not posData.IDs: + return + + try: + ID_idx = posData.IDs_idxs[ID] + if direction == 'next': + nextID_idx = ID_idx + 1 + else: + nextID_idx = ID_idx - 1 + obj = posData.rp[nextID_idx] + except Exception as e: + self.logger.info( + f'Auto-pilot restarted from first ID' + ) + obj = posData.rp[0] + + self.autoPilotZoomToObjSpinBox.setValue(obj.label) + self.zoomToObj(obj) + + def getClickEntryTableFilepaths(self, posData, tableEndName): + csv_filename = self.view_model.click_entry_table_filename( + posData.basename, + tableEndName, + ) + filepath = os.path.join(posData.images_path, csv_filename) + recovery_filepath = os.path.join( + posData.images_path, 'recovery', csv_filename + ) + return filepath, recovery_filepath + + def getClickEntryNewerRecoveryFilepaths(self, tableEndName): + newer_recovery_filepaths = [] + for posData in self.data: + filepath, recovery_filepath = self.getClickEntryTableFilepaths( + posData, tableEndName + ) + if not os.path.exists(filepath) or not os.path.exists(recovery_filepath): + continue + + if not self.view_model.should_load_recovery_table( + recovery_exists=True, + main_exists=True, + recovery_mtime=os.path.getmtime(recovery_filepath), + main_mtime=os.path.getmtime(filepath), + ): + continue + + newer_recovery_filepaths.append((filepath, recovery_filepath)) + + return newer_recovery_filepaths + + def askLoadNewerRecoveryClickEntryDfs( + self, tableEndName, newer_recovery_filepaths + ): + if not newer_recovery_filepaths: + return False + + num_tables = len(newer_recovery_filepaths) + filepath, recovery_filepath = newer_recovery_filepaths[0] + main_timestamp = datetime.fromtimestamp( + os.path.getmtime(filepath) + ).strftime('%a %d. %b. %y - %H:%M:%S') + recovery_timestamp = datetime.fromtimestamp( + os.path.getmtime(recovery_filepath) + ).strftime('%a %d. %b. %y - %H:%M:%S') + + if num_tables == 1: + text = html_utils.paragraph( + f'A newer recovery version of {tableEndName}.csv ' + 'was found.

' + f'Main table save date: {main_timestamp}
' + f'Recovery save date: {recovery_timestamp}

' + 'Do you want to load the newer recovery version?' + ) + else: + text = html_utils.paragraph( + f'Newer recovery versions of {tableEndName}.csv ' + f'were found for {num_tables} positions.

' + f'Example main table save date: {main_timestamp}
' + f'Example recovery save date: {recovery_timestamp}

' + 'Do you want to load the newer recovery version where available?' + ) + + msg = widgets.myMessageBox(wrapText=False) + _, yesButton, _ = msg.warning( + self.addPointsWin, 'Newer recovery table found', text, + buttonsTexts=('Cancel', 'Yes, load newer recovery', 'No, load main table') + ) + return msg.clickedButton == yesButton + + def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading): + doesTableExists = False + for posData in self.data: + filepath, _ = self.getClickEntryTableFilepaths(posData, tableEndName) + if os.path.exists(filepath): + doesTableExists = True + break + + if not doesTableExists: + return + + if not forceLoading: + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + f'The table {tableEndName}.csv already exists!

' + 'Do you want to load it?' + ) + _, yesButton, _ = msg.warning( + self.addPointsWin, 'Table exists!', txt, + buttonsTexts=('Cancel', 'Yes, load it', 'No, let me enter a new name') + ) + if msg.clickedButton != yesButton: + return + + newer_recovery_filepaths = self.getClickEntryNewerRecoveryFilepaths( + tableEndName + ) + load_recovery_if_newer = self.askLoadNewerRecoveryClickEntryDfs( + tableEndName, newer_recovery_filepaths + ) + + self.loadClickEntryDfs( + tableEndName, loadRecoveryIfNewer=load_recovery_if_newer + ) + + def checkLoadedTableIds(self, toolbar): + if toolbar != self.promptSegmentPointsLayerToolbar: + return True + + for posData in self.data: + for tableEndName, df in posData.clickEntryPointsDfs.items(): + for point_id in df['id'].values: + if point_id in posData.IDs_idxs: + proceed = self.warnAddingPointWithExistingId( + point_id, table_endname=tableEndName + ) + return proceed + + return True + + @exception_handler + def addPointsLayer(self, toolbar=None): + proceed = self.checkLoadedTableIds(toolbar) + + if self.addPointsWin.cancel or not proceed: + self.addPointsWin = None + self.logger.info('Adding points layer cancelled.') + return + + if toolbar is None: + toolbar = self.pointsLayersToolbar + + symbol = self.addPointsWin.symbol + color = self.addPointsWin.color + pointSize = self.addPointsWin.pointSize + zRadius = int((self.addPointsWin.zHeight-1)/2) + r,g,b,a = color.getRgb() + + scatterItem = widgets.PointsScatterPlotItem( + [], [], ax=self.ax1, symbol=symbol, pxMode=False, size=pointSize, + brush=pg.mkBrush(color=(r,g,b,100)), + pen=pg.mkPen(width=2, color=(r,g,b)), + hoverable=True, hoverBrush=pg.mkBrush((r,g,b,200)), + tip=None, show_data_as_tip=True + ) + self.ax1.addItem(scatterItem) + + toolButton = widgets.PointsLayerToolButton( + symbol, color, parent=self.host + ) + toolButton.actions = [] + toolButton.setCheckable(True) + toolButton.setChecked(True) + if self.addPointsWin.keySequence is not None: + toolButton.setShortcut(self.addPointsWin.keySequence) + toolButton.toggled.connect(self.pointLayerToolbuttonToggled) + toolButton.sigEditAppearance.connect(self.editPointsLayerAppearance) + toolButton.sigShowIdsToggled.connect(self.showPointsLayerIdsToggled) + toolButton.sigRemove.connect( + partial(self.removePointsLayer, toolbar=toolbar) + ) + + action = toolbar.addWidget(toolButton) + action.state = self.addPointsWin.state() + + toolButton.action = action + action.brushColor = (r,g,b,100) + action.brushColorId0 = ( + *colors.hex_to_rgb( + colors.lighten_color( + np.array(action.brushColor)/255, 0.3 + ) + ), 100 + ) + action.penColor = (r,g,b) + action.penColorId0 = colors.lighten_color( + np.array(action.penColor)/255, 0.3 + ) + action.pointSize = pointSize + action.zRadius = zRadius + action.button = toolButton + action.scatterItem = scatterItem + scatterItem.action = action + action.layerType = self.addPointsWin.layerType + action.layerTypeIdx = self.addPointsWin.layerTypeIdx + action.loadedDf = self.addPointsWin.loadedDf + posData = self.data[self.pos_i] + action.pointsData = {} + action.pointsData[self.pos_i] = self.addPointsWin.pointsData + action.snapToMax = False + action.loadedDfInfo = self.addPointsWin.loadedDfInfo + self.setPointsLayerLoadedDfEndanme(action) + + if self.addPointsWin.layerType.startswith('Click to annotate point'): + action.snapToMax = self.addPointsWin.snapToMaxToggle.isChecked() + isLoadedDf = self.addPointsWin.clickEntryIsLoadedDf + self.setupAddPointsByClicking( + toolButton, isLoadedDf, toolbar=toolbar + ) + if self.addPointsWin.autoPilotToggle.isChecked(): + self.autoPilotZoomToObjToggle.setChecked(True) + + weighingChannel = self.addPointsWin.weighingChannel + self.loadPointsLayerWeighingData(action, weighingChannel) + + self.drawPointsLayers() + + if toolbar == self.promptSegmentPointsLayerToolbar: + self.promptSegmentPointsLayerToolbar.isPointsLayerInit = True + self.magicPromptsToolbar.clearPointsAction.setDisabled(False) + self.magicPromptsToolbar.clearPointsActionOnZoom.setDisabled(False) + QTimer.singleShot( + 200, self.magicPromptsToolbar.selectModelAction.trigger + ) + + self.addPointsWin = None + + def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): + for posData in self.data: + filepath, recovery_filepath = self.getClickEntryTableFilepaths( + posData, tableEndName + ) + + if loadRecoveryIfNewer: + recovery_exists = os.path.exists(recovery_filepath) + main_exists = os.path.exists(filepath) + if ( + self.view_model.should_load_recovery_table( + recovery_exists=recovery_exists, + main_exists=main_exists, + recovery_mtime=( + os.path.getmtime(recovery_filepath) + if recovery_exists else None + ), + main_mtime=( + os.path.getmtime(filepath) + if main_exists else None + ), + ) + ): + filepath = recovery_filepath + elif not main_exists: + continue + + if not os.path.exists(filepath): + continue + + self.logger.info(f'Loading points from "{filepath}"...') + df = self.view_model.points.load_click_points_table(filepath) + posData.clickEntryPointsDfs[tableEndName] = df + + try: + self.addPointsWin.loadButton.confirmAction() + except Exception as err: + pass + + def removeClickedPoints(self, action, points): + posData = self.data[self.pos_i] + framePointsData = action.pointsData[self.pos_i][posData.frame_i] + if posData.SizeZ > 1: + zProjHow = self.zProjComboBox.currentText() + if zProjHow != 'single z-slice': + _warnings.warnCannotAddRemovePointsProjection() + return + zSlice = self.zSliceScrollBar.sliderPosition() + else: + zSlice = None + + points_to_remove = [] + for point in points: + pos = point.pos() + points_to_remove.append((pos.x(), pos.y(), point.data())) + removed_ids = self.view_model.points.remove_click_points( + framePointsData, + points_to_remove, + z_slice=zSlice, + z_radius=action.zRadius, + ) + + if removed_ids: + self.markPointsLayerDirty(action=action) + + return removed_ids + + def restorePrevPointIdRightClick(self, addPointsByClickingButton): + # Try to restore the id that was there before hovering + # because the hovering was required only to delete the + # point + try: + prevId = addPointsByClickingButton.rightClickIDSpinbox.prevId + addPointsByClickingButton.rightClickIDSpinbox.setValue(prevId) + except Exception as err: + addPointsByClickingButton.rightClickIDSpinbox.prevId = None + + def getClickedPointNewId( + self, action, current_id, pointIdSpinbox, isMagicPrompts=False + ): + removed_id = getattr(pointIdSpinbox, 'removedId', None) + if removed_id is not None: + pointIdSpinbox.removedId = None + return removed_id + + posData = self.data[self.pos_i] + if isMagicPrompts: + is_already_new = self.isPointIdAlreadyNew(current_id, action) + if is_already_new: + return current_id + + new_ID = self.setBrushID(return_val=True) + new_id = max(current_id, new_ID) + 1 + return new_id + else: + pointsDataPos = action.pointsData.get(self.pos_i) + return self.view_model.points.next_click_point_id( + pointsDataPos, + posData.frame_i, + current_id, + size_z=posData.SizeZ, + ) + + def setHoverCircleAddPoint(self, x, y): + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + if addPointsByClickingButton is None: + return + action = addPointsByClickingButton.action + self.setHoverToolSymbolData( + [x], [y], (self.ax1_BrushCircle,), + size=action.pointSize + ) + + def isPointIdAlreadyNew(self, point_id, action): + posData = self.data[self.pos_i] + pointsDataPos = action.pointsData.get(self.pos_i) + return self.view_model.points.point_id_already_new( + pointsDataPos, + posData.frame_i, + point_id, + posData.IDs_idxs, + ) + + def addClickedPoint(self, action, x, y, id): + x, y = round(x, 2), round(y, 2) + posData = self.data[self.pos_i] + if action.snapToMax: + radius = round(action.pointSize/2) + rr, cc = skimage.draw.disk((round(y), round(x)), radius) + idx_max = (self.img1.image[rr, cc]).argmax() + y, x = rr[idx_max], cc[idx_max] + + pointsDataPos = action.pointsData.setdefault(self.pos_i, {}) + zSlice = None + if posData.SizeZ > 1: + zSlice = self.zSliceScrollBar.sliderPosition() + self.view_model.points.add_click_point( + pointsDataPos, + posData.frame_i, + x, + y, + id, + size_z=posData.SizeZ, + z_slice=zSlice, + ) + + self.markPointsLayerDirty(action=action) + + def showPointsLayerIdsToggled(self, button, checked): + button.action.scatterItem.drawIds = checked + self.drawPointsLayers() + + def removePointsLayer(self, button, toolbar=None): + button.setChecked(False) + button.action.scatterItem.setData([], []) + button.action.loadedDfInfo = None + self.ax1.removeItem(button.action.scatterItem) + toolbar.removeAction(button.action) + for action in button.actions: + toolbar.removeAction(action) + + if toolbar == self.promptSegmentPointsLayerToolbar: + self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False + + def editPointsLayerAppearance(self, button): + win = apps.EditPointsLayerAppearanceDialog(parent=self.host) + win.restoreState(button.action.state) + win.exec_() + if win.cancel: + return + + symbol = win.symbol + color = win.color + pointSize = win.pointSize + zRadius = int((win.zHeight-1)/2) + r,g,b,a = color.getRgb() + + scatterItem = button.action.scatterItem + scatterItem.opts['hoverBrush'] = pg.mkBrush((r,g,b,200)) + scatterItem.setSymbol(symbol, update=False) + scatterItem.setBrush(pg.mkBrush(color=(r,g,b,100)), update=False) + scatterItem.setPen(pg.mkPen(width=2, color=(r,g,b)), update=False) + scatterItem.setSize(pointSize, update=True) + + button.action.brushColor = (r,g,b,100) + button.action.penColor = (r,g,b) + button.action.pointSize = pointSize + button.action.zRadius = zRadius + + button.action.state = win.state() + + def loadPointsLayerWeighingData(self, action, weighingChannel): + if not weighingChannel: + return + + self.logger.info(f'Loading "{weighingChannel}" weighing data...') + action.weighingData = [] + for p, posData in enumerate(self.data): + if weighingChannel == posData.user_ch_name: + wData = posData.img_data + action.weighingData.append(wData) + continue + + path, filename = self.getPathFromChName(weighingChannel, posData) + if path is None: + self.criticalFluoChannelNotFound(weighingChannel, posData) + action.weighingData = [] + return + + if filename in posData.fluo_data_dict: + # Weighing data already loaded as additional fluo channel + wData = posData.fluo_data_dict[filename] + else: + # Weighing data never loaded --> load now + wData, _ = self.load_fluo_data(path) + if posData.SizeT == 1: + wData = wData[np.newaxis] + action.weighingData.append(wData) + + def pointLayerToolbuttonToggled(self, checked): + action = self.sender().action + action.scatterItem.setVisible(checked) + + def getCentroidsPointsData(self, action): + # Centroids (either weighted or not) + # NOTE: if user requested to draw from table we load that in + # apps.AddPointsLayerDialog.ok_cb() + posData = self.data[self.pos_i] + action.pointsData[self.pos_i] = {posData.frame_i: {}} + if hasattr(action, 'weighingData'): + lab = posData.lab + img = action.weighingData[self.pos_i][posData.frame_i] + rp = skimage.measure.regionprops(lab, intensity_image=img) + attr = 'weighted_centroid' + else: + rp = posData.rp + attr = 'centroid' + for i, obj in enumerate(rp): + centroid = getattr(obj, attr) + if len(centroid) == 3: + zc, yc, xc = centroid + z_int = round(zc) + if z_int not in action.pointsData[self.pos_i][posData.frame_i]: + action.pointsData[self.pos_i][posData.frame_i][z_int] = { + 'x': [xc], 'y': [yc], 'id': [obj.label] + } + else: + z_data = action.pointsData[self.pos_i][posData.frame_i][z_int] + z_data['x'].append(xc) + z_data['y'].append(yc) + z_data['id'].append(obj.label) + else: + yc, xc = centroid + if 'y' not in action.pointsData[self.pos_i][posData.frame_i]: + action.pointsData[self.pos_i][posData.frame_i]['y'] = [yc] + action.pointsData[self.pos_i][posData.frame_i]['x'] = [xc] + action.pointsData[self.pos_i][posData.frame_i]['id'] = ( + [obj.label] + ) + else: + action.pointsData[self.pos_i][posData.frame_i]['y'].append(yc) + action.pointsData[self.pos_i][posData.frame_i]['x'].append(xc) + action.pointsData[self.pos_i][posData.frame_i]['id'].append( + obj.label + ) + + def drawPointsLayers(self, computePointsLayers=True): + posData = self.data[self.pos_i] + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'layerTypeIdx'): + continue + + if self.view_model.should_compute_points_layer( + layer_type_index=action.layerTypeIdx, + compute_points_layers=computePointsLayers, + ): + self.getCentroidsPointsData(action) + + if not action.button.isChecked(): + continue + + frames = action.pointsData.get(self.pos_i, set()) + if posData.frame_i not in frames: + if self.view_model.should_log_missing_frame_points( + action.layerTypeIdx + ): + self.logger.info( + f'Frame number {posData.frame_i+1} does not have any ' + f'"{action.layerType}" point to display.' + ) + continue + + framePointsData = action.pointsData[self.pos_i][posData.frame_i] + + zSlice = None + zProjHow = self.zProjComboBox.currentText() + isZslice = self.view_model.should_use_z_slice( + z_projection_mode=zProjHow, + size_z=posData.SizeZ, + frame_points_data=framePointsData, + ) + if isZslice: + zSlice = self.zSliceScrollBar.sliderPosition() + xx, yy, ids, data = self.view_model.points.flatten_frame_points_data( + framePointsData, + z_slice=zSlice, + z_radius=action.zRadius, + ) + + brushColors = [ + action.brushColor if id != 0 else action.brushColorId0 + for id in ids + ] + brushes = [pg.mkBrush(color) for color in brushColors] + + pensColor = [ + action.penColor if id != 0 else action.penColorId0 + for id in ids + ] + pens = [pg.mkPen(color) for color in pensColor] + + if action.layerTypeIdx == 2: + # For loaded table show the rest of the table as a tooltip + data = data + show_data_as_tip = True + else: + data = ids + show_data_as_tip = False + + xx = np.array(xx) # + 0.5 + yy = np.array(yy) # + 0.5 + + action.scatterItem.show_data_as_tip = show_data_as_tip + action.scatterItem.setData( + xx, yy, data=data, brush=brushes, pen=pens + ) diff --git a/cellacdc/views/preprocessing_view.py b/cellacdc/views/preprocessing_view.py new file mode 100644 index 000000000..76232cb65 --- /dev/null +++ b/cellacdc/views/preprocessing_view.py @@ -0,0 +1,493 @@ +"""Qt view adapter for image preprocessing workflows.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Tuple, Union + +import numpy as np +from qtpy.QtCore import QMutex, QThread, QWaitCondition + +from cellacdc import apps, html_utils, widgets, workers +from cellacdc.plot import imshow +from cellacdc.viewmodels.preprocessing_viewmodel import PreprocessingViewModel + + +class PreprocessingView: + """Qt-facing adapter around preprocessing dialogs and workers.""" + + def __init__(self, host, view_model: PreprocessingViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def askGet2Dor3Dimage(self): + txt = html_utils.paragraph(""" + Do you want to test the denoising on the visualized 2D image or + on the entire 3D z-stack? + """) + msg = widgets.myMessageBox(wrapText=False) + _, use3Dbutton, use2Dbutton = msg.question( + self.host, '3D denoising?', txt, + buttonsTexts=('Cancel', 'Denoise 3D z-stack', 'Denoise 2D image') + ) + if msg.cancel: + return + + if msg.clickedButton == use3Dbutton: + posData = self.data[self.pos_i] + zslice = self.zSliceScrollBar.sliderPosition() + return posData.img_data[posData.frame_i, zslice] + else: + return self.getDisplayedImg1() + + def preprocessActionTriggered(self): + self.preprocessDialog.show() + self.preprocessDialog.raise_() + self.preprocessDialog.activateWindow() + self.preprocessDialog.emitSigPreviewToggled() + + def preprocessDialogRecipeChanged(self, recipe): + recipe = self.preprocessDialog.recipe() + if recipe is None: + self.logger.warning('Pre-processing recipe not initialized yet.') + return + + self.updatePreprocessPreview(recipe=recipe) + + def debugShowImg(self, img): + imshow(img) + + def preprocessDialogSavePreprocessedData(self, dialog): + posData = self.data[self.pos_i] + + try: + posData.preprocessedDataArray() + except TypeError as e: + if 'Not all frames have been processed.' in str(e): + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Not all frames have been processed.
' + 'Please process all frames before saving.' + ) + msg.warning( + self.host, 'Process all data before saving', txt + ) + return + + helpText = ( + """ + The preprocessed image file will be saved with a different + file name.

+ Insert a name to append to the end of the new file name. The rest of + the name will be the same as the original file. + """ + ) + + win = apps.filenameDialog( + basename=f'{posData.basename}{self.user_ch_name}', + ext=".tif", + hintText='Insert a name for the preprocessed image file:', + defaultEntry='preprocessed', + helpText=helpText, + allowEmpty=False, + parent=dialog + ) + win.exec_() + if win.cancel: + return + + appendedText = win.entryText + + self.progressWin = apps.QDialogWorkerProgress( + title='Saving pre-processed image(s)', + parent=self.host, + pbarDesc='Saving pre-processed image(s)' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + self.statusBarLabel.setText('Saving pre-processed data...') + + self.savePreprocWorker = workers.SaveProcessedDataWorker( + self.data, appendedText, ext=".tif" + ) + + self.savePreprocThread = QThread() + self.savePreprocWorker.moveToThread(self.savePreprocThread) + self.savePreprocWorker.signals.finished.connect( + self.savePreprocThread.quit + ) + self.savePreprocWorker.signals.finished.connect( + self.savePreprocWorker.deleteLater + ) + self.savePreprocThread.finished.connect( + self.savePreprocThread.deleteLater + ) + + self.savePreprocWorker.signals.critical.connect( + self.workerCritical + ) + self.savePreprocWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.savePreprocWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.savePreprocWorker.signals.progress.connect( + self.workerProgress + ) + self.savePreprocWorker.signals.finished.connect( + self.savePreprocWorkerFinished + ) + + self.savePreprocThread.started.connect( + self.savePreprocWorker.run + ) + self.savePreprocThread.start() + + def preprocessEnqueueCurrentImage(self, recipe): + posData = self.data[self.pos_i] + func = self.view_model.preprocess_image_from_recipe + image_data = self.getImage(raw=True) + if posData.SizeZ > 1: + z_slice = self.z_slice_index() + else: + z_slice = 0 + + recipe = self.view_model.validate_multidimensional_recipe( + recipe + ) + + key = (self.pos_i, posData.frame_i, z_slice) + self.preprocWorker.enqueue( + func, + image_data, + recipe, + key + ) + + def getChData(self, requ_ch=None, pos_i=None): + if not pos_i: + pos_i = self.pos_i + + posData = self.data[pos_i] + + if not requ_ch: + requ_ch = set(self.ch_names) + else: + requ_ch = set(requ_ch) + + posData.setLoadedChannelNames() + + loaded_channels = set(posData.loadedChNames) + missing_channels = requ_ch - loaded_channels + + self.loadFluo_cb(fluo_channels=missing_channels) + + def updatePreprocessPreview(self, *args, **kwargs): + force = kwargs.get('force', False) + + if not self.preprocessDialog.isVisible() and not force: + return + + if not self.preprocessDialog.previewCheckbox.isChecked() and not force: + return + + if kwargs.get('recipe') is None: + recipe = self.preprocessDialog.recipe() + else: + recipe = kwargs.get('recipe') + + if recipe is None: + self.logger.warning('Pre-processing recipe not initialized yet.') + return + + txt = 'Pre-processing current image...' + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + self.preprocessEnqueueCurrentImage(recipe) + + def preprocessPreviewToggled(self, checked): + self.viewPreprocDataToggle.setChecked(checked) + self.updatePreprocessPreview() + + def viewPreprocDataToggled(self, checked): + self.img1.setUsePreprocessed(checked) + self.setImageImg1() + + if self.viewCombineChannelDataToggle.isChecked(): + self.viewCombineChannelDataToggle.toggled.disconnect() + self.viewCombineChannelDataToggle.setChecked(False) + self.viewCombineChannelDataToggle.toggled.connect( + self.viewCombineChannelDataToggled + ) + + def preprocessCurrentImage(self, recipe: List[Dict[str, Any]], *args): + txt = 'Pre-processing current image...' + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + func = self.view_model.preprocess_image_from_recipe + recipe = self.view_model.validate_multidimensional_recipe( + recipe + ) + + image_data = self.getImage(raw=True) + self.preprocWorker.setupJob( + func, + image_data, + recipe, + 'current_image' + ) + + self.preprocWorker.wakeUp() + + def preprocessZStack(self, recipe: List[Dict[str, Any]], *args): + txt = 'Pre-processing z-stack...' + self.statusBarLabel.setText(txt) + self.logger.info(txt) + + posData = self.data[self.pos_i] + func = self.view_model.preprocess_zstack_from_recipe + recipe = self.view_model.validate_multidimensional_recipe( + recipe, apply_to_all_frames=False + ) + image_data = posData.img_data[posData.frame_i] + self.preprocWorker.setupJob( + func, + image_data, + recipe, + 'z_stack' + ) + + self.preprocWorker.wakeUp() + + def preprocessAllFrames(self, recipe: List[Dict[str, Any]]): + txt = 'Pre-processing all frames...' + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + posData = self.data[self.pos_i] + func = self.view_model.preprocess_video_from_recipe + image_data = posData.img_data + self.preprocWorker.setupJob( + func, + image_data, + recipe, + 'all_frames' + ) + self.preprocWorker.wakeUp() + + def preprocessAllPos(self, recipe: List[Dict[str, Any]]): + txt = 'Pre-processing all Positions...' + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + func = self.view_model.preprocess_multi_pos_from_recipe + recipe = self.view_model.validate_multidimensional_recipe( + recipe, apply_to_all_frames=False + ) + image_data = [posData.img_data[0] for posData in self.data] + self.preprocWorker.setupJob( + func, + image_data, + recipe, + 'all_pos' + ) + + self.preprocWorker.wakeUp() + + def setupPreprocessing(self): + posData = self.data[self.pos_i] + if self.preprocessDialog is not None: + self.preprocessDialog.close() + + self.preprocessDialog = apps.PreProcessRecipeDialog( + isTimelapse=posData.SizeT>1, + isZstack=posData.SizeZ>1, + isMultiPos=len(self.data)>1, + df_metadata=posData.metadata_df, + hideOnClosing=True, + addApplyButton=True, + parent=self.host + ) + self.doPreviewPreprocImage = False + self.preprocessDialog.sigApplyImage.connect( + self.preprocessCurrentImage + ) + self.preprocessDialog.sigApplyZstack.connect( + self.preprocessZStack + ) + self.preprocessDialog.sigApplyAllFrames.connect( + self.preprocessAllFrames + ) + self.preprocessDialog.sigApplyAllPos.connect( + self.preprocessAllPos + ) + self.preprocessDialog.sigPreviewToggled.connect( + self.preprocessPreviewToggled + ) + self.preprocessDialog.sigValuesChanged.connect( + self.preprocessDialogRecipeChanged + ) + self.preprocessDialog.sigSavePreprocData.connect( + self.preprocessDialogSavePreprocessedData + ) + + if self.preprocWorker is not None: + return + + self.preprocThread = QThread() + self.preprocMutex = QMutex() + self.preprocWaitCond = QWaitCondition() + + self.preprocWorker = workers.CustomPreprocessWorkerGUI( + self.preprocMutex, self.preprocWaitCond + ) + + self.preprocWorker.moveToThread(self.preprocThread) + self.preprocWorker.signals.finished.connect(self.preprocThread.quit) + self.preprocWorker.signals.finished.connect( + self.preprocWorker.deleteLater + ) + self.preprocThread.finished.connect(self.preprocThread.deleteLater) + + self.preprocWorker.sigDone.connect(self.preprocWorkerDone) + self.preprocWorker.sigIsQueueEmpty.connect( + self.preprocWorkerIsQueueEmpty + ) + self.preprocWorker.sigPreviewDone.connect(self.preprocWorkerPreviewDone) + self.preprocWorker.signals.progress.connect(self.workerProgress) + self.preprocWorker.signals.critical.connect(self.workerCritical) + self.preprocWorker.signals.finished.connect(self.preprocWorkerClosed) + + self.preprocThread.started.connect(self.preprocWorker.run) + self.preprocThread.start() + + self.logger.info('Pre-processing worker started.') + + def preprocWorkerCritical(self, error): + self.preprocessDialog.appliedFinished() + self.workerCritical(error) + + def preprocWorkerIsQueueEmpty(self, isEmpty: bool): + if isEmpty: + self.preprocessDialog.appliedFinished() + else: + self.preprocessDialog.setDisabled(True) + self.preprocessDialog.infoLabel.setText( + 'Computing preview...
' + '(Feel free to use Cell-ACDC while waiting)' + ) + + def preprocWorkerPreviewDone( + self, processed_data: np.ndarray, + key: Tuple[int, int, Union[int, str]] + ): + pos_i, frame_i, z_slice = key + posData = self.data[pos_i] + if not hasattr(posData, 'preproc_img_data'): + posData.preproc_img_data = ( + self.view_model.create_preprocessed_data( + image_data=np.zeros(posData.img_data.shape) + ) + ) + + posData.preproc_img_data[frame_i][z_slice] = processed_data + self.img1.updateMinMaxValuesPreprocessedData( + self.data, pos_i, frame_i, z_slice + ) + + self.setImageImg1() + + def preprocWorkerDone( + self, + processed_data: np.ndarray, + how: str, + ): + self.status_hover_view.set_status_bar_label(log=False) + self.preprocessDialog.appliedFinished() + + posData = self.data[self.pos_i] + if not hasattr(posData, 'preproc_img_data'): + posData.preproc_img_data = ( + self.view_model.create_preprocessed_data() + ) + + if how == 'current_image': + if posData.SizeZ > 1: + z_slice = self.z_slice_index() + posData.preproc_img_data[posData.frame_i][z_slice] = ( + processed_data + ) + else: + posData.preproc_img_data[posData.frame_i] = processed_data + z_slice = 0 + self.img1.updateMinMaxValuesPreprocessedData( + self.data, self.pos_i, posData.frame_i, z_slice + ) + elif how == 'z_stack': + for z_slice, processed_img in enumerate(processed_data): + posData.preproc_img_data[posData.frame_i][z_slice] = ( + processed_img + ) + self.img1.updateMinMaxValuesPreprocessedData( + self.data, self.pos_i, posData.frame_i, z_slice + ) + self.img1.updateMinMaxValuesPreprocessedProjections( + self.data, self.pos_i, posData.frame_i + ) + elif how == 'all_frames': + for frame_i, processed_frame in enumerate(processed_data): + if processed_frame.ndim == 2: + processed_frame = (processed_frame,) + + for z_slice, processed_img in enumerate(processed_frame): + posData.preproc_img_data[frame_i][z_slice] = ( + processed_img + ) + self.img1.updateMinMaxValuesPreprocessedData( + self.data, self.pos_i, frame_i, z_slice + ) + self.img1.updateMinMaxValuesPreprocessedProjections( + self.data, self.pos_i, frame_i + ) + elif how == 'all_pos': + for pos_i, processed_pos_data in enumerate(processed_data): + if processed_pos_data.ndim == 2: + processed_pos_data = (processed_pos_data,) + + posData = self.data[pos_i] + if not hasattr(posData, 'preproc_img_data'): + posData.preproc_img_data = ( + self.view_model.create_preprocessed_data() + ) + for z_slice, processed_img in enumerate(processed_pos_data): + posData.preproc_img_data[0][z_slice] = ( + processed_img + ) + self.img1.updateMinMaxValuesPreprocessedData( + self.data, pos_i, 0, z_slice + ) + + if posData.SizeZ > 1: + self.img1.updateMinMaxValuesPreprocessedProjections( + self.data, pos_i, frame_i + ) + + if not self.viewPreprocDataToggle.isChecked(): + self.viewPreprocDataToggle.setChecked(True) + else: + self.setImageImg1() + + def preprocWorkerClosed(self, worker): + self.logger.info('Pre-processing worker stopped.') diff --git a/cellacdc/views/quick_settings_view.py b/cellacdc/views/quick_settings_view.py new file mode 100644 index 000000000..fdc99ac45 --- /dev/null +++ b/cellacdc/views/quick_settings_view.py @@ -0,0 +1,211 @@ +"""View adapter for quick settings and side-panel widgets.""" + +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QFormLayout, QLabel, QVBoxLayout + +from cellacdc import apps, settings_csv_path, widgets +from cellacdc.viewmodels.quick_settings_viewmodel import ( + QuickSettingsViewModel, +) + + +class QuickSettingsView: + """Qt-facing adapter around quick-settings view-model contracts.""" + + def __init__(self, host, view_model: QuickSettingsViewModel): + self.host = host + self.view_model = view_model + + def create_show_props_button(self, side='left'): + self.host.leftSideDocksLayout = QVBoxLayout() + self.host.leftSideDocksLayout.setSpacing(0) + self.host.leftSideDocksLayout.setContentsMargins(0, 0, 0, 0) + self.host.rightSideDocksLayout = QVBoxLayout() + self.host.rightSideDocksLayout.setSpacing(0) + self.host.rightSideDocksLayout.setContentsMargins(0, 0, 0, 0) + self.host.showPropsDockButton = widgets.expandCollapseButton() + self.host.showPropsDockButton.setDisabled(True) + self.host.showPropsDockButton.setFocusPolicy(Qt.NoFocus) + self.host.showPropsDockButton.setToolTip('Show object properties') + if side == 'left': + self.host.leftSideDocksLayout.addWidget( + self.host.showPropsDockButton + ) + else: + self.host.rightSideDocksLayout.addWidget( + self.host.showPropsDockButton + ) + + def create_widgets(self): + self.host.quickSettingsLayout = QVBoxLayout() + self.host.quickSettingsGroupbox = widgets.GroupBox() + self.host.quickSettingsGroupbox.setTitle('Quick settings') + + layout = QFormLayout() + layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint + ) + layout.setFormAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self._add_view_preprocessed_toggle(layout) + self._add_combined_channels_toggle(layout) + self._add_autosave_toggles(layout) + self._add_autosave_interval_control(layout) + self._add_cca_integrity_checker_toggle(layout) + self._add_lost_objects_toggle(layout) + self._add_realtime_tracking_toggle(layout) + self._add_show_all_contours_toggle(layout) + self._add_font_size_control(layout) + + self.host.quickSettingsGroupbox.setLayout(layout) + self.host.quickSettingsLayout.addWidget( + self.host.quickSettingsGroupbox + ) + self.host.quickSettingsLayout.addStretch(1) + + def show_all_contours_toggled(self): + if not self.view_model.should_update_all_contours( + is_data_loaded=self.host.isDataLoaded + ): + return + + self.host.computeAllContours() + self.host.updateAllImages() + + def _add_view_preprocessed_toggle(self, layout): + self.host.viewPreprocDataToggle = widgets.Toggle() + tooltip = ( + 'View pre-processed data. See menu `Image --> Pre-processing...`\n' + 'on the top menubar.' + ) + self.host.viewPreprocDataToggle.setChecked(False) + self.host.viewPreprocDataToggle.setToolTip(tooltip) + label = QLabel('View pre-processed image') + label.setToolTip(tooltip) + layout.addRow(label, self.host.viewPreprocDataToggle) + + def _add_combined_channels_toggle(self, layout): + self.host.viewCombineChannelDataToggle = widgets.Toggle() + tooltip = ( + 'View combined channel. See menu `Image --> combing channels...`\n' + 'on the top menubar.' + ) + self.host.viewCombineChannelDataToggle.setChecked(False) + self.host.viewCombineChannelDataToggle.setToolTip(tooltip) + label = QLabel('View combined channels') + label.setToolTip(tooltip) + layout.addRow(label, self.host.viewCombineChannelDataToggle) + + def _add_autosave_toggles(self, layout): + self.host.autoSaveToggle = widgets.Toggle() + tooltip = ( + 'Automatically store a copy of the segmentation data ' + 'in the `.recovery` folder after every edit.' + ) + self.host.autoSaveToggle.setChecked(True) + self.host.autoSaveToggle.setToolTip(tooltip) + label = QLabel('Autosave segmentation') + label.setToolTip(tooltip) + layout.addRow(label, self.host.autoSaveToggle) + + self.host.autoSaveAnnotToggle = widgets.Toggle() + tooltip = ( + 'Automatically store a copy of the annotations (acdc_output CSV ' + 'file) in the `.recovery` folder after every edit.' + ) + self.host.autoSaveAnnotToggle.setChecked(True) + self.host.autoSaveAnnotToggle.setToolTip(tooltip) + label = QLabel('Autosave annotations') + label.setToolTip(tooltip) + layout.addRow(label, self.host.autoSaveAnnotToggle) + + def _add_autosave_interval_control(self, layout): + self.host.autoSaveIntervalEditButton = widgets.editPushButton( + flat=True, hoverable=True + ) + self.host.autoSaveIntervalLabel = QLabel('Autosave interval') + self.host.autoSaveIntervalSetTooltip() + layout.addRow( + self.host.autoSaveIntervalLabel, + self.host.autoSaveIntervalEditButton, + ) + + self.host.autoSaveIntervalDialog = apps.AutoSaveIntervalDialog( + parent=self.host + ) + self.host.autoSaveIntervalDialog.setValues( + *self.host.autoSaveIntevalValueUnit + ) + + def _add_cca_integrity_checker_toggle(self, layout): + self.host.ccaIntegrCheckerToggle = widgets.Toggle() + tooltip = ( + 'Toggle background cell cycle annotations integrity checker ON/OFF' + ) + self.host.ccaIntegrCheckerToggle.setChecked(False) + self.host.ccaIntegrCheckerToggle.setToolTip(tooltip) + label = QLabel('Cc annot. checker') + label.setToolTip(tooltip) + layout.addRow(label, self.host.ccaIntegrCheckerToggle) + idx = 'is_cca_integrity_checker_activated' + if idx in self.host.df_settings.index: + val = int(self.host.df_settings.at[idx, 'value']) + self.host.ccaIntegrCheckerToggle.setChecked(not val) + + def _add_lost_objects_toggle(self, layout): + self.host.annotLostObjsToggle = widgets.Toggle() + tooltip = 'Toggle annotation of lost objects mode ON/OFF' + self.host.annotLostObjsToggle.setChecked(True) + self.host.annotLostObjsToggle.setToolTip(tooltip) + label = QLabel('Annot. lost objects') + label.setToolTip(tooltip) + layout.addRow(label, self.host.annotLostObjsToggle) + + def _add_realtime_tracking_toggle(self, layout): + self.host.realTimeTrackingToggle = widgets.Toggle() + self.host.realTimeTrackingToggle.setChecked(True) + self.host.realTimeTrackingToggle.setDisabled(True) + label = QLabel('Real-time tracking') + label.setDisabled(True) + self.host.realTimeTrackingToggle.label = label + layout.addRow(label, self.host.realTimeTrackingToggle) + + def _add_show_all_contours_toggle(self, layout): + self.host.showAllContoursToggle = widgets.Toggle() + tooltip = ( + 'If active, all contours will be displayed, including inner ' + 'contours(e.g. holes and sub-objects)' + ) + self.host.showAllContoursToggle.setToolTip(tooltip) + label = QLabel('Show all contours') + label.setToolTip(tooltip) + layout.addRow(label, self.host.showAllContoursToggle) + self.host.showAllContoursToggle.toggled.connect( + self.show_all_contours_toggled + ) + + def _add_font_size_control(self, layout): + self.host.fontSizeSpinBox = widgets.SpinBox() + self.host.fontSizeSpinBox.setMinimum(1) + self.host.fontSizeSpinBox.setMaximum(99) + layout.addRow('Font size', self.host.fontSizeSpinBox) + font_size_setting = self.view_model.font_size_setting( + self.host.df_settings.at['fontSize', 'value'], + has_px_mode='pxMode' in self.host.df_settings.index, + ) + self.host.fontSize = font_size_setting.value + if font_size_setting.add_px_mode_setting: + self.host.df_settings.at['pxMode', 'value'] = 1 + self.host.df_settings.to_csv(settings_csv_path) + self.host.fontSizeSpinBox.setValue(self.host.fontSize) + self.host.fontSizeSpinBox.editingFinished.connect( + self.host.changeFontSize + ) + self.host.fontSizeSpinBox.sigUpClicked.connect( + self.host.changeFontSize + ) + self.host.fontSizeSpinBox.sigDownClicked.connect( + self.host.changeFontSize + ) diff --git a/cellacdc/views/saving_view.py b/cellacdc/views/saving_view.py new file mode 100644 index 000000000..b507bd315 --- /dev/null +++ b/cellacdc/views/saving_view.py @@ -0,0 +1,1104 @@ +"""Qt view adapter for save and autosave workflows.""" + +from __future__ import annotations + +import os +import uuid +from datetime import datetime +from functools import partial +from typing import Literal + +import pandas as pd +from qtpy.QtCore import QEventLoop, QMutex, QThread, QTimer, QWaitCondition +from qtpy.QtGui import QFont +from qtpy.QtWidgets import QCheckBox, QMessageBox +from tqdm import tqdm + +from cellacdc import _warnings, apps, disableWindow, exception_handler +from cellacdc import cca_df_colnames, html_utils, settings_csv_path, widgets +from cellacdc import load +from cellacdc import workers +from cellacdc.viewmodels.saving_viewmodel import SavingViewModel + + +_font = QFont() +_font.setPixelSize(11) + + +class SavingView: + """Qt-facing adapter for save and autosave workflows.""" + + LEGACY_METHODS = ( + 'manageVersions', + 'turnOffAutoSaveWorker', + 'autoSaveTimerTimedOut', + 'autoSaveTimerCountFrames', + 'enqAutosave', + 'startAutoSaveEveryNframesTimer', + '_enqueueAutoSave', + 'computeVolumeRegionprop', + 'askSaveOriginalSegm', + 'askSaveLastVisitedCcaMode', + 'askSaveLastVisitedSegmMode', + 'askSaveMetrics', + 'askSelectPos', + 'askPosToSave', + 'saveMetricsCritical', + 'saveAsData', + 'saveDataPermissionError', + 'saveDataProgress', + 'saveDataCustomMetricsCritical', + 'saveDataCombinedMetricsMissingColumn', + 'saveDataAddMetricsCritical', + 'saveDataRegionPropsCritical', + 'saveDataUpdateMetricsPbar', + 'saveDataUpdatePbar', + 'quickSave', + 'checkMissingCca', + 'warnDifferentSegmChannel', + 'waitAutoSaveWorker', + 'saveData', + 'autoSaveClose', + 'setAutoSaveSegmentationEnabled', + 'setAutoSaveAnnotationsEnabled', + 'autoSaveToggled', + 'autoSaveAnnotToggled', + 'autoSaveIntervalEdit', + 'autoSaveIntervalValueChanged', + 'autoSaveIntervalSetTooltip', + 'warnErrorsCustomMetrics', + 'warnErrorsAddMetrics', + 'warnErrorsRegionProps', + 'askConcatenate', + 'updateSegmDataAutoSaveWorker', + 'saveDataFinished', + '_waitCloseAutoSaveWorker', + 'cancelSavingInitialisation', + 'askSaveOnClosing', + ) + + def __init__(self, host, view_model: SavingViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def manageVersions(self): + posData = self.data[self.pos_i] + selectVersion = apps.SelectAcdcDfVersionToRestore( + posData, parent=self.host + ) + selectVersion.exec_() + + if selectVersion.cancel: + return + + undoId = uuid.uuid4() + if posData.cca_df is not None: + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + selectedTime = selectVersion.selectedTimestamp + + self.modeComboBox.setCurrentText('Viewer') + self.logger.info(f'Loading file from {selectedTime}...') + + acdc_df = load.read_acdc_df_from_archive( + selectVersion.archiveFilePath, selectVersion.selectedKey + ) + posData.acdc_df = acdc_df + frames = acdc_df.index.get_level_values(0) + last_visited_frame_i = frames.max() + current_frame_i = posData.frame_i + pbar = tqdm(total=last_visited_frame_i+1, ncols=100) + for frame_i in range(last_visited_frame_i+1): + posData.frame_i = frame_i + self.get_data() + if posData.cca_df is not None: + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + if posData.allData_li[frame_i]['labels'] is None: + pbar.update() + continue + + if frame_i not in frames: + acdc_df_i = pd.DataFrame(columns=acdc_df.columns) + acdc_df_i.drop(self.cca_df_colnames, axis=1, errors='ignore') + acdc_df_i.index.name = 'Cell_ID' + else: + acdc_df_i = acdc_df.loc[frame_i].dropna(axis=1, how='all') + + posData.allData_li[frame_i]['acdc_df'] = acdc_df_i + pbar.update() + pbar.close() + + # Back to current frame + posData.frame_i = current_frame_i + self.get_data(debug=False) + self.updateAllImages() + self.logger.info('Annotations correctly recovered.') + + def turnOffAutoSaveWorker(self): + self.autoSaveToggle.setChecked(False) + + def autoSaveTimerTimedOut(self): + if not hasattr(self, 'data'): + # This happes when the self.autoSaveTimer times out after + # the GUI has been closed --> we simply ignore it + self.autoSaveTimer.stop() + return + + self.autoSaveTimer.stop() + self.flushDirtyPointsLayersAutosave() + self._enqueueAutoSave() + + def autoSaveTimerCountFrames(self): + if not hasattr(self, 'data'): + # This happes when the self.autoSaveTimer times out after + # the GUI has been closed --> we simply ignore it + return + + posData = self.data[self.pos_i] + autoSaveIntevalValue, autoSaveIntervalUnit = ( + self.autoSaveIntevalValueUnit + ) + isTimeToAutoSave = ( + abs(posData.frame_i - self.autoSaveTimeStartFrameIdx) + >= autoSaveIntevalValue + ) + if not isTimeToAutoSave: + return + + self.autoSaveTimeStartFrameIdx = posData.frame_i + self.flushDirtyPointsLayersAutosave() + self._enqueueAutoSave() + + def enqAutosave(self): + mode = str(self.modeComboBox.currentText()) + if self.view_model.should_clear_autosave_status(mode=mode): + if self.statusBarLabel.text().endswith('Autosaving...'): + self.statusBarLabel.setText( + self.statusBarLabel.text().replace(' | Autosaving...', '') + ) + return + + if not self.autoSaveActiveWorkers: + self.gui_createAutoSaveWorker() + + if not self.view_model.should_enqueue_autosave( + mode=mode, + has_active_workers=bool(self.autoSaveActiveWorkers), + ): + return + + if self.autoSaveTimer.isActive(): + return + + self._enqueueAutoSave() + autoSaveIntevalValue, autoSaveIntervalUnit = ( + self.autoSaveIntevalValueUnit + ) + schedule = self.view_model.autosave_schedule( + autoSaveIntevalValue, autoSaveIntervalUnit + ) + if schedule is None: + return + + try: + self.autoSaveTimer.timeout.disconnect() + except Exception as err: + pass + + if not schedule.use_frame_timer: + self.autoSaveTimer.timeout.connect(self.autoSaveTimerTimedOut) + self.autoSaveTimer.start(schedule.interval_ms) + else: + self.startAutoSaveEveryNframesTimer() + + def startAutoSaveEveryNframesTimer(self): + posData = self.data[self.pos_i] + self.autoSaveTimeStartFrameIdx = posData.frame_i + self.autoSaveTimer.timeout.connect( + self.autoSaveTimerCountFrames + ) + self.autoSaveTimer.start(500) + + def _enqueueAutoSave(self): + if not self.statusBarLabel.text().endswith('Autosaving...'): + self.statusBarLabel.setText( + f'{self.statusBarLabel.text()} | Autosaving...' + ) + + timestamp = datetime.now().strftime(r'%H:%M:%S.%f')[:-3] + self.logger.info(f'Autosaving... - {timestamp}') + + posData = self.data[self.pos_i] + worker, thread = self.autoSaveActiveWorkers[-1] + worker.enqueue(posData) + + def computeVolumeRegionprop(self): + if 'cell_vol_vox' not in self._measurements_kernel.sizeMetricsToSave: + return + + # We compute the cell volume in the main thread because calling + # skimage.transform.rotate in a separate thread causes crashes + # with segmentation fault on macOS. I don't know why yet. + self.logger.info('Computing cell volume...') + end_i = self.save_until_frame_i + pos_iter = tqdm(self.data, ncols=100) + for p, posData in enumerate(pos_iter): + if self.posToSave is not None: + if posData.pos_foldername not in self.posToSave: + continue + + PhysicalSizeY = posData.PhysicalSizeY + PhysicalSizeX = posData.PhysicalSizeX + frame_iter = tqdm( + posData.allData_li[:end_i+1], ncols=100, position=1, leave=False + ) + for frame_i, data_dict in enumerate(frame_iter): + lab = data_dict['labels'] + if lab is None: + break + rp = data_dict['regionprops'] + obj_iter = tqdm(rp, ncols=100, position=2, leave=False) + for i, obj in enumerate(obj_iter): + vol_vox, vol_fl = ( + self.view_model.measurements.rotational_volume( + obj, PhysicalSizeY, PhysicalSizeX + ) + ) + obj.vol_vox = vol_vox + obj.vol_fl = vol_fl + posData.allData_li[frame_i]['regionprops'] = rp + + def askSaveOriginalSegm(self, isQuickSave=False): + if isQuickSave: + return "", True, True + + posData = self.data[self.pos_i] + if not posData.whitelist: + return "", True, True + + help_txt = html_utils.paragraph(f""" + You have whitelisted IDs in the current position.
+ Do you want to save the not whitelisted segmentation data
+ This will allow you to revisit the original segmentation.
+ """) + + txt = html_utils.paragraph(f""" + You have whitelisted IDs in the current position.
+ Do you want to save the not whitelisted segmentation data?
+ """) + + found_files = self.view_model.workspace.segmentation_files( + posData.images_path + ) + existingEndnames = self.view_model.workspace.endnames( + posData.basename, found_files + ) + + segmFilename = os.path.basename(posData.segm_npz_path) + segmFilename = f"{segmFilename[:-4]}_not_whitelisted" + win = apps.filenameDialog( + basename=posData.basename, + hintText=txt, + defaultEntry=segmFilename, + existingNames=existingEndnames, + helpText=help_txt, + allowEmpty=False, + parent=self.host, + title='Save not whitelisted segmentation data', + addDoNotSaveButton=True + ) + win.exec_() + if win.cancel: + return "", False, True + if win.doNotSave: + return "", True, True + return win.entryText, True, False + + def askSaveLastVisitedCcaMode(self, isQuickSave=False): + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + self.save_until_frame_i = 0 + if self.isSnapshot: + return True + + frame_i = self.view_model.tracking.last_tracked_frame_index( + (data_dict['labels'] for data_dict in posData.allData_li), + first_frame_fallback=-1, + ) + + self.save_until_frame_i = frame_i + self.save_cca_until_frame_i = frame_i + self.last_tracked_i = frame_i + + if isQuickSave: + return True + + last_cca_frame_i = self.navigateScrollBar.maximum()-1 + # Ask to save last visited frame or not + txt = html_utils.paragraph(f""" + You annotated the cell cycle stages up + until frame number {last_cca_frame_i+1}.

+ Enter up to which frame number you want to save the + cell cycle annotations: + """) + lastFrameDialog = apps.QLineEditDialog( + title='Last annoated frame number to save', + defaultTxt=str(last_cca_frame_i+1), + msg=txt, parent=self.host, allowedValues=(1, last_cca_frame_i+1), + warnLastFrame=True, isInteger=True, stretchEntry=False, + lastVisitedFrame=last_cca_frame_i+1, + ) + lastFrameDialog.exec_() + if lastFrameDialog.cancel: + return False + + last_save_cca_frame_i = lastFrameDialog.enteredValue - 1 + + if last_save_cca_frame_i < last_cca_frame_i: + self.resetCcaFuture(last_cca_frame_i) + + self.save_cca_until_frame_i = last_save_cca_frame_i + + return True + + def askSaveLastVisitedSegmMode(self, isQuickSave=False): + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + self.save_until_frame_i = 0 + self.save_cca_until_frame_i = 0 + if self.isSnapshot: + return True + + frame_i = self.view_model.tracking.last_tracked_frame_index( + (data_dict['labels'] for data_dict in posData.allData_li), + first_frame_fallback=-1, + ) + + if isQuickSave: + self.save_until_frame_i = frame_i + self.save_cca_until_frame_i = frame_i + self.last_tracked_i = frame_i + return True + + # Ask to save last visited frame or not + txt = html_utils.paragraph(f""" + You visualised and corrected segmentation and tracking data up + until frame number {frame_i+1}.

+ Enter up to which frame number you want to save data: + """) + lastFrameDialog = apps.QLineEditDialog( + title='Last frame number to save', defaultTxt=str(frame_i+1), + msg=txt, parent=self.host, allowedValues=(1, posData.SizeT), + warnLastFrame=True, isInteger=True, stretchEntry=False, + lastVisitedFrame=frame_i+1, + ) + lastFrameDialog.exec_() + if lastFrameDialog.cancel: + return False + + self.save_until_frame_i = lastFrameDialog.enteredValue - 1 + self.save_cca_until_frame_i = self.save_until_frame_i + if self.save_until_frame_i > frame_i: + self.logger.info( + f'Storing frames {frame_i+1}-{self.save_until_frame_i+1}...' + ) + current_frame_i = posData.frame_i + # User is requesting to save past the last visited frame --> + # store data as if they were visited + for i in range(frame_i+1, self.save_until_frame_i+1): + posData.frame_i = i + self.get_data() + self.store_data(autosave=False) + + # Go back to current frame + posData.frame_i = current_frame_i + self.get_data() + last_tracked_i = self.save_until_frame_i + + self.last_tracked_i = last_tracked_i + return True + + def askSaveMetrics(self): + txt = html_utils.paragraph( + """ + Do you also want to save the measurements + (e.g., cell volume, mean, amount etc.)?

+ + You can find more information by clicking on the + "Set measurements" button below
+ where you will be able to select which measurements + you want to save.

+ If you already set the measurements and you want to save them click "Yes".

+ + NOTE: Saving metrics might be slow, + we recommend doing it only when you need it.
+ """) + msg = widgets.myMessageBox( + parent=self.host, resizeButtons=False, wrapText=False + ) + setMeasurementsButton = widgets.setPushButton('Set measurements...') + _, yesButton, noButton, _ = msg.question( + self.host, 'Save measurements?', txt, + buttonsTexts=('Cancel', 'Yes', 'No', setMeasurementsButton), + showDialog=False + ) + setMeasurementsButton.disconnect() + setMeasurementsButton.clicked.connect( + partial( + self.measurements_view.show_set_measurements, + qparent=msg, + ) + ) + msg.exec_() + save_metrics = msg.clickedButton == yesButton + return save_metrics, msg.cancel + + def askSelectPos(self, action='to save'): + last_pos = 1 + for p, posData in enumerate(self.data): + acdc_df = posData.allData_li[0]['acdc_df'] + if acdc_df is None: + last_pos = p + break + else: + last_pos = len(self.data) + + items = [posData.pos_foldername for posData in self.data] + selectPosWin = widgets.QDialogListbox( + f'Select Positions {action}', f'Select Positions {action}:\n', + items, multiSelection=True, parent=self.host, + preSelectedItems=items[:last_pos] + ) + selectPosWin.exec_() + if selectPosWin.cancel: + return + + return selectPosWin.selectedItemsText + + def askPosToSave(self): + return self.askSelectPos() + + def saveMetricsCritical(self, traceback_format): + print('\n====================================') + self.logger.exception(traceback_format) + print('====================================\n') + self.logger.info('Warning: calculating metrics failed see above...') + print('------------------------------') + + msg = widgets.myMessageBox(wrapText=False) + err_msg = html_utils.paragraph(f""" + Error while saving metrics.

+ More details below or in the terminal/console.

+ Note that the error details from this session are also saved + in the file
+ {self.log_path}

+ Please send the log file when reporting a bug, thanks! + Please restart Cell-ACDC, we apologise for any inconvenience.

+ + """) + msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...') + msg.setDetailedText(traceback_format, visible=True) + msg.critical(self.host, 'Critical error while saving metrics', err_msg) + + self.is_error_state = True + self.waitCond.wakeAll() + + def saveAsData(self, checked=True): + try: + posData = self.data[self.pos_i] + except AttributeError: + return + + existingFilenames = set() + for _posData in self.data: + segm_files = self.view_model.workspace.segmentation_files( + _posData.images_path + ) + _existingEndnames = self.view_model.workspace.endnames( + _posData.basename, segm_files + ) + existingFilenames.update([ + f'{_posData.basename}{endname}.npz' + for endname in _existingEndnames + ]) + posData = self.data[self.pos_i] + basename = self.view_model.save_as_basename(posData.basename) + win = apps.filenameDialog( + basename=basename, + hintText='Insert a filename for the segmentation file:
', + existingNames=existingFilenames + ) + win.exec_() + if win.cancel: + return + + for posData in self.data: + posData.setFilePaths(new_endname=win.entryText) + + self.status_hover_view.set_status_bar_label() + self.saveData() + + def saveDataPermissionError(self, err_msg): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + msg = QMessageBox() + msg.critical(self.host, 'Permission denied', err_msg, msg.Ok) + self.waitCond.wakeAll() + + def saveDataProgress(self, text): + self.logger.info(text) + self.saveWin.progressLabel.setText(text) + + def saveDataCustomMetricsCritical(self, traceback_format, func_name): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + self.logger.info('') + _hl = '====================================' + self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') + self.worker.customMetricsErrors[func_name] = traceback_format + + def saveDataCombinedMetricsMissingColumn(self, error_msg, func_name): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + self.logger.info('') + warning = f'[WARNING]: {error_msg}. Metric {func_name} was skipped.' + _hl = '====================================' + self.logger.info(f'{_hl}\n{warning}\n{_hl}') + self.worker.customMetricsErrors[func_name] = warning + + def saveDataAddMetricsCritical(self, traceback_format, error_message): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + self.logger.info('') + _hl = '====================================' + self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') + self.worker.addMetricsErrors[error_message] = traceback_format + + def saveDataRegionPropsCritical(self, traceback_format, error_message): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + self.logger.info('') + _hl = '====================================' + self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') + self.worker.regionPropsErrors[error_message] = traceback_format + + def saveDataUpdateMetricsPbar(self, max, step): + if max > 0: + self.saveWin.metricsQPbar.setMaximum(max) + self.saveWin.metricsQPbar.setValue(0) + self.saveWin.metricsQPbar.setValue( + self.saveWin.metricsQPbar.value()+step + ) + + def saveDataUpdatePbar(self, step, max=-1, exec_time=0.0): + if max >= 0: + self.saveWin.QPbar.setMaximum(max) + else: + self.saveWin.QPbar.setValue(self.saveWin.QPbar.value()+step) + steps_left = self.saveWin.QPbar.maximum()-self.saveWin.QPbar.value() + seconds = round(exec_time*steps_left) + ETA = self.view_model.formatting.seconds_to_eta(seconds) + self.saveWin.ETA_label.setText(f'ETA: {ETA}') + + def quickSave(self): + self.saveData(isQuickSave=True) + + def checkMissingCca(self): + proceed = True + ignore = False + doNotShowAgain = False + if not self.doNotShowAgainMissingCca: + return proceed, ignore, doNotShowAgain + + missing_cca_items = [ + (item.cca_df, self.data[item.position_i], item.frame_i) + for item in self.view_model.cca_workflows.missing_annotation_items( + (posData.allData_li for posData in self.data), + cca_df_colnames, + is_snapshot=self.isSnapshot, + ) + ] + + if not missing_cca_items: + return proceed, ignore, doNotShowAgain + + proceed = False + ignore, doNotShowAgain =_warnings.warnMissingCca( + missing_cca_items, qparent=self.host + ) + + if doNotShowAgain: + self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'Yes' + self.df_settings.to_csv(self.settings_csv_path) + + return proceed, ignore, doNotShowAgain + + def warnDifferentSegmChannel( + self, loaded_channel, segm_channel_hyperparams, segmEndName + ): + txt = html_utils.paragraph(f""" + You loaded the segmentation file ending with _{segmEndName}.npz + which corresponds to the channel + {segm_channel_hyperparams}.

+ However, in this session you loaded the channel + {loaded_channel}.

+ If you proceed with saving, the segmentation file ending with + _{segmEndName}.npz will be OVERWRITTEN.

+ Are you sure you want to proceed? + """) + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + msg.warning( + self.host, 'WARNING: Potential for data loss', txt, + buttonsTexts=('Cancel', 'Yes') + ) + return msg.cancel + + def waitAutoSaveWorker(self, worker): + if worker.isFinished or worker.isPaused or len(worker.dataQ) == 0: + self.waitAutoSaveWorkerLoop.exit() + self.waitAutoSaveWorkerTimer.stop() + self.status_hover_view.set_status_bar_label(log=False) + + @exception_handler + def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): + self.setDisabled(True, keepDisabled=True) + + self.askLineageTreeChanges() + + self.store_data(autosave=False) + self.applyDelROIs() + self.store_data() + self._isQuickSave = isQuickSave + + # Wait autosave worker to finish + for worker, thread in self.autoSaveActiveWorkers: + self.logger.info('Stopping autosaving process...') + self.statusBarLabel.setText('Stopping autosaving process...') + worker.stop() + self.waitAutoSaveWorkerTimer = QTimer() + self.waitAutoSaveWorkerTimer.timeout.connect( + partial(self.waitAutoSaveWorker, worker) + ) + self.waitAutoSaveWorkerTimer.start(100) + self.waitAutoSaveWorkerLoop = QEventLoop() + self.waitAutoSaveWorkerLoop.exec_() + + self.titleLabel.setText( + 'Saving data... (check progress in the terminal)', + color=self.titleColor + ) + + # Check channel name correspondence to warn + posData = self.data[self.pos_i] + lastSegmChannel, segmEndName = posData.getSegmentedChannelHyperparams() + if lastSegmChannel != self.user_ch_name and lastSegmChannel: + cancel = self.warnDifferentSegmChannel( + self.user_ch_name, lastSegmChannel, segmEndName + ) + if cancel: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return True + posData.updateSegmentedChannelHyperparams(self.user_ch_name) + + # Check missing cca annotations in snaphots + proceed, ignore, self.doNotShowAgainMissingCca = self.checkMissingCca() + if not proceed and not ignore: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return + + self.save_metrics = False + if not isQuickSave: + self.save_metrics, cancel = self.askSaveMetrics() + if cancel: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return True + + self.posToSave = None + if self.view_model.should_ask_positions( + is_snapshot=self.isSnapshot, + is_quick_save=isQuickSave, + position_count=len(self.data), + ): + self.posToSave = self.askPosToSave() + if self.posToSave is None: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return True + + if isQuickSave: + # Quick save only current pos + self.posToSave = self.view_model.quick_save_positions( + self.data[self.pos_i].pos_foldername + ) + + if self.isSnapshot: + self.store_data(mainThread=False) + + mode = self.modeComboBox.currentText() + if mode == 'Cell cycle analysis': + proceed = self.askSaveLastVisitedCcaMode(isQuickSave=isQuickSave) + if not proceed: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return True + else: + proceed = self.askSaveLastVisitedSegmMode(isQuickSave=isQuickSave) + if not proceed: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return True + + append_name_og_whitelist, proceed, do_not_save_og_whitelist = self.askSaveOriginalSegm(isQuickSave=isQuickSave) + if not proceed: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return True + + if self.view_model.should_compute_volume_metrics( + save_metrics=self.save_metrics, + mode=mode, + ): + self.computeVolumeRegionprop() + + infoTxt = html_utils.paragraph( + f'Saving {self.exp_path}...
', font_size='14px' + ) + + self.saveWin = apps.QDialogPbar( + parent=self.host, title='Saving data', infoTxt=infoTxt + ) + self.saveWin.setFont(_font) + # if not self.save_metrics: + self.saveWin.metricsQPbar.hide() + self.saveWin.progressLabel.setText('Preparing data...') + self.saveWin.show() + + # Set up separate thread for saving and show progress bar widget + self.mutex = QMutex() + self.waitCond = QWaitCondition() + self.thread = QThread() + self.worker = workers.saveDataWorker(self.host) + self.worker.mode = mode + self.worker.isQuickSave = isQuickSave + self.worker.append_name_og_whitelist = append_name_og_whitelist + self.worker.do_not_save_og_whitelist = do_not_save_og_whitelist + + self.worker.moveToThread(self.thread) + + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + # Custom signals + self.worker.finished.connect(self.saveDataFinished) + if finishedCallback is not None: + self.worker.finished.connect(finishedCallback) + self.worker.progress.connect(self.saveDataProgress) + self.worker.sigLog.connect(self.workerLog) + self.worker.progressBar.connect(self.saveDataUpdatePbar) + # self.worker.metricsPbarProgress.connect(self.saveDataUpdateMetricsPbar) + self.worker.critical.connect(self.saveDataWorkerCritical) + self.worker.customMetricsCritical.connect( + self.saveDataCustomMetricsCritical + ) + self.worker.sigCombinedMetricsMissingColumn.connect( + self.saveDataCombinedMetricsMissingColumn + ) + self.worker.addMetricsCritical.connect(self.saveDataAddMetricsCritical) + self.worker.regionPropsCritical.connect( + self.saveDataRegionPropsCritical + ) + self.worker.criticalPermissionError.connect(self.saveDataPermissionError) + self.worker.askZsliceAbsent.connect(self.zSliceAbsent) + self.worker.sigDebug.connect(self._workerDebug) + + self.thread.started.connect(self.worker.run) + + self.thread.start() + + return False + + def autoSaveClose(self): + for worker, thread in self.autoSaveActiveWorkers: + worker._stop() + + def setAutoSaveSegmentationEnabled(self, enabled): + if not self.autoSaveActiveWorkers: + return + + worker, thread = self.autoSaveActiveWorkers[-1] + + if enabled: + worker.isAutoSaveON = self.view_model.autosave_segmentation_enabled( + mode=self.modeComboBox.currentText(), + checked=self.autoSaveToggle.isChecked(), + ) + else: + worker.isAutoSaveON = False + + def setAutoSaveAnnotationsEnabled(self, enabled): + if not self.autoSaveActiveWorkers: + return + + worker, thread = self.autoSaveActiveWorkers[-1] + + if enabled: + worker.isAutoSaveAnnotON = ( + self.view_model.autosave_annotations_enabled( + mode=self.modeComboBox.currentText(), + checked=self.autoSaveToggle.isChecked(), + ) + ) + else: + worker.isAutoSaveAnnotON = False + + def autoSaveToggled(self, checked): + if not self.autoSaveActiveWorkers: + self.gui_createAutoSaveWorker() + + if not self.autoSaveActiveWorkers: + return + + worker, thread = self.autoSaveActiveWorkers[-1] + + mode = self.modeComboBox.currentText() + worker.isAutoSaveON = self.view_model.autosave_segmentation_enabled( + mode=mode, + checked=checked, + ) + + def autoSaveAnnotToggled(self, checked): + if not self.autoSaveActiveWorkers: + self.gui_createAutoSaveWorker() + + if not self.autoSaveActiveWorkers: + return + + worker, thread = self.autoSaveActiveWorkers[-1] + + mode = self.modeComboBox.currentText() + worker.isAutoSaveAnnotON = ( + self.view_model.autosave_annotations_enabled( + mode=mode, + checked=checked, + ) + ) + + def autoSaveIntervalEdit(self): + self.autoSaveIntervalDialog.show() + self.autoSaveIntervalDialog.raise_() + self.autoSaveIntervalDialog.activateWindow() + + def autoSaveIntervalValueChanged( + self, value: float, unit: Literal['minutes', 'frames'] + ): + interval_change = self.view_model.autosave_interval_change( + value, + unit, + ) + self.autoSaveIntevalValueUnit = ( + interval_change.value, + interval_change.unit, + ) + self.autoSaveTimer.stop() + + for setting, setting_value in interval_change.settings_updates.items(): + self.df_settings.at[setting, 'value'] = setting_value + self.df_settings.to_csv(settings_csv_path) + + self.logger.info(interval_change.log_message) + self.autoSaveIntervalSetTooltip(interval_change.tooltip) + + if interval_change.start_frame_timer: + self.startAutoSaveEveryNframesTimer() + + def autoSaveIntervalSetTooltip(self, tooltip=None): + if tooltip is None: + value, unit = self.autoSaveIntevalValueUnit + tooltip = self.view_model.autosave_interval_change( + value, + unit, + ).tooltip + self.autoSaveIntervalLabel.setToolTip(tooltip) + self.autoSaveIntervalEditButton.setToolTip(tooltip) + + def warnErrorsCustomMetrics(self): + win = apps.ComputeMetricsErrorsDialog( + self.worker.customMetricsErrors, self.logs_path, + log_type='custom_metrics', parent=self.host + ) + win.exec_() + + def warnErrorsAddMetrics(self): + win = apps.ComputeMetricsErrorsDialog( + self.worker.addMetricsErrors, self.logs_path, + log_type='standard_metrics', parent=self.host + ) + win.exec_() + + def warnErrorsRegionProps(self): + win = apps.ComputeMetricsErrorsDialog( + self.worker.regionPropsErrors, self.logs_path, + log_type='region_props', parent=self.host + ) + win.exec_() + + def askConcatenate(self): + setting_exists = 'showAskConcatenate' in self.df_settings.index + show_setting_value = ( + self.df_settings.at['showAskConcatenate', 'value'] + if setting_exists else None + ) + prompt_plan = self.view_model.concatenate_prompt_plan( + has_main_window=self.mainWin is not None, + is_quick_save=self._isQuickSave, + setting_exists=setting_exists, + show_setting_value=show_setting_value, + ) + if prompt_plan.ensure_setting: + self.df_settings.at['showAskConcatenate', 'value'] = 'Yes' + + if not prompt_plan.should_prompt: + return + + txt = html_utils.paragraph(f""" + Do you want to concatenate the `acdc_output.csv` tables from + multiple Positions into one single CSV file?
+ """) + doNotShowAgainCheckbox = QCheckBox('Do not show again') + msg = widgets.myMessageBox(wrapText=False) + noButton, yesButton = msg.question( + self.host, 'Concatenate tables?', txt, + buttonsTexts=('No', 'Yes'), + widgets=doNotShowAgainCheckbox + ) + show_ask_concatenate = self.view_model.concatenate_prompt_setting( + do_not_show_again=doNotShowAgainCheckbox.isChecked() + ) + self.df_settings.at['showAskConcatenate', 'value'] = ( + show_ask_concatenate + ) + self.df_settings.to_csv(settings_csv_path) + + if not msg.clickedButton == yesButton: + return + + txt = html_utils.paragraph(f""" + To concatenate the `acdc_output.csv` tables from + multiple Positions and multiple experiments
+ launch the concatenation utility from the top menubar of the Cell-ACDC main launcher:

+ Utilities --> Concatenate --> Concatenate acdc output tables from multiple Positions and experiments.... + """) + msg = widgets.myMessageBox(wrapText=False) + msg.information(self.host, 'How to concatenate tables', txt) + + def updateSegmDataAutoSaveWorker(self): + # Update savedSegmData in autosave worker + posData = self.data[self.pos_i] + for worker, thread in self.autoSaveActiveWorkers: + worker.savedSegmData = posData.segm_data.copy() + + def saveDataFinished(self): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + title_text, color = self.view_model.save_finished_title( + aborted=self.saveWin.aborted, + worker_aborted=self.worker.abort, + is_quick_save=self._isQuickSave, + ) + if color is None: + self.titleLabel.setText(title_text) + else: + self.titleLabel.setText(title_text, color=color) + self.saveWin.workerFinished = True + self.saveWin.close() + + if not self.closeGUI: + # Update savedSegmData in autosave worker + self.updateSegmDataAutoSaveWorker() + + if self.worker.addMetricsErrors: + self.warnErrorsAddMetrics() + if self.worker.regionPropsErrors: + self.warnErrorsRegionProps() + if self.worker.customMetricsErrors: + self.warnErrorsCustomMetrics() + + self.checkManageVersions() + + self.askConcatenate() + + if self.closeGUI: + salute_string = self.view_model.formatting.salute_string() + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Data saved!. The GUI will now close.

' + f'{salute_string}' + ) + msg.information(self.host, 'Data saved', txt) + self.close() + + def _waitCloseAutoSaveWorker(self): + didWorkersFinished = [True] + for worker, thread in self.autoSaveActiveWorkers: + if worker.isFinished: + didWorkersFinished.append(True) + else: + didWorkersFinished.append(False) + if all(didWorkersFinished): + self.waitCloseAutoSaveWorkerLoop.stop() + + def cancelSavingInitialisation(self): + self.titleLabel.setText( + 'Saving data process cancelled.', color=self.titleColor + ) + self.closeGUI = False + + @disableWindow + def askSaveOnClosing(self, event): + if not self.saveAction.isEnabled(): + return True + if self.titleLabel.text == 'Saved!': + return True + if not self.isDataLoaded: + return True + + msg = widgets.myMessageBox() + txt = html_utils.paragraph('Do you want to save before closing?') + _, noButton, yesButton = msg.question( + self.host, 'Save?', txt, + buttonsTexts=('Cancel', 'No', 'Yes') + ) + if msg.cancel: + event.ignore() + return False + + if msg.clickedButton == yesButton: + self.closeGUI = True + QTimer.singleShot(100, self.saveAction.trigger) + event.ignore() + return False + return True diff --git a/cellacdc/views/seg_for_lost_ids_view.py b/cellacdc/views/seg_for_lost_ids_view.py new file mode 100644 index 000000000..fd741483a --- /dev/null +++ b/cellacdc/views/seg_for_lost_ids_view.py @@ -0,0 +1,260 @@ +"""Qt view adapter for segmenting lost IDs.""" + +from __future__ import annotations + +from qtpy.QtCore import QMutex, QThread, QWaitCondition + +from cellacdc import apps, workers +from cellacdc.plot import imshow +from cellacdc.viewmodels.seg_for_lost_ids_viewmodel import ( + SegForLostIdsViewModel, +) + + +class SegForLostIdsView: + """Qt-facing adapter around lost-ID segmentation commands.""" + + def __init__(self, host, view_model: SegForLostIdsViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def SegForLostIDsSetSettings(self): + + prev_model = self.view_model.previous_model_name(self.df_settings) + win = apps.QDialogSelectModel(parent=self.host, customFirst=prev_model) + win.exec_() + if win.cancel: + self.logger.info('Seg for lost IDs cancelled.') + return + base_model_name = win.selectedModel + + if self.view_model.should_persist_model_choice(base_model_name): + self.df_settings.at[ + self.view_model.settings_key, 'value' + ] = base_model_name + self.df_settings.to_csv(self.settings_csv_path) + + model_name = self.view_model.worker_model_name + + idx = self.modelNames.index(model_name) + acdcSegment = self.acdcSegment_li[idx] + + try: + if ( + acdcSegment is None + or base_model_name != self.local_seg_base_model_name + ): + self.logger.info(f'Importing {base_model_name}...') + acdcSegment = ( + self.host.view_model.model_registry + .import_segmentation_module(base_model_name) + ) + self.acdcSegment_li[idx] = acdcSegment + self.local_seg_base_model_name = base_model_name + except (IndexError, ImportError, KeyError) as e: + self.logger.error(f'Error importing {base_model_name}: {e}') + return + + extra_ArgSpec = self.view_model.extra_arg_specs() + + init_params, segment_params = ( + self.host.view_model.model_registry.model_arg_specs(acdcSegment) + ) + segment_params = [arg for arg in segment_params if arg[0] != 'diameter'] + + extraParamsTitle = 'Settings for local segmentation' + win = self.initSegmModelParams( + base_model_name, acdcSegment, init_params, segment_params, + extraParams=extra_ArgSpec, extraParamsTitle=extraParamsTitle, + initLastParams=True, ini_filename='segmentation_for_lostIDs.ini', + ) + + if win is None: + self.logger.info('Segmentation for lost IDs cancelled.') + return + + settings = self.view_model.settings_from_dialog(win, base_model_name) + self.SegForLostIDsSettings = { + 'win': settings.win, + 'init_kwargs_new': settings.init_kwargs_new, + 'args_new': settings.args_new, + 'base_model_name': settings.base_model_name, + } + + def segForLostIDsButtonClicked(self): + + why = 'Segmentation for lost IDs' + self.setFrameNavigationDisabled(disable=True, why=why) + posData = self.data[self.pos_i] + if not self.view_model.can_start_from_frame(posData.frame_i): + self.logger.info( + 'Segmentation for lost IDs not available on first frame.' + ) + self.setFrameNavigationDisabled(disable=False, why=why) + return + self.storeUndoRedoStates(False) + self.progressWin = apps.QDialogWorkerProgress( + title='Segmenting for lost IDs', + parent=self.host, + pbarDesc='Segmenting for lost IDs...', + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + self.startSegForLostIDsWorker() + + def onSegForLostInit(self): + self.logger.info('Settings for segmentation for lost IDs not set.') + self.SegForLostIDsSetSettings() + self.SegForLostIDsWaitCond.wakeAll() + + def SegForLostIDsWorkerAskInstallModel(self, model_name): + self.host.view_model.model_registry.check_install_package(model_name) + self.SegForLostIDsWaitCond.wakeAll() + + def startSegForLostIDsWorker(self): + self.SegForLostIDsMutex = QMutex() + self.SegForLostIDsWaitCond = QWaitCondition() + self._thread = QThread() + + # Initialize the worker with mutex and wait condition + self.SegForLostIDsWorker = workers.SegForLostIDsWorker( + self.host, self.SegForLostIDsMutex, self.SegForLostIDsWaitCond + ) + + # Connect the worker's signal to the main thread's slot + self.SegForLostIDsWorker.sigAskInit.connect(self.onSegForLostInit) + self.SegForLostIDsWorker.sigAskInstallModel.connect( + self.SegForLostIDsWorkerAskInstallModel + ) + self.SegForLostIDsWorker.sigshowImageDebug.connect( + self.showImageDebug + ) + + self.SegForLostIDsWorker.sigSegForLostIDsWorkerAskInstallGPU.connect( + self.SegForLostIDsWorkerAskInstallGPU + ) + + self.SegForLostIDsWorker.sigStoreData.connect( + self.onSigStoreDataSegForLostIDsWorker + ) + self.SegForLostIDsWorker.sigUpdateRP.connect( + self.onSigUpdateRPSegForLostIDsWorker + ) + self.SegForLostIDsWorker.sigTrackManuallyAddedObject.connect( + self.onSigTrackManuallyAddedObjectSegForLostIDsWorker + ) + + # Move the worker to the thread + self.SegForLostIDsWorker.moveToThread(self._thread) + + # Manage thread lifecycle + self.SegForLostIDsWorker.signals.finished.connect(self._thread.quit) + self.SegForLostIDsWorker.signals.finished.connect( + self.SegForLostIDsWorker.deleteLater + ) + self._thread.finished.connect(self._thread.deleteLater) + + # Connect other worker signals to the appropriate slots + self.SegForLostIDsWorker.signals.finished.connect( + self.SegForLostIDsWorkerFinished + ) + self.SegForLostIDsWorker.signals.progress.connect(self.workerProgress) + self.SegForLostIDsWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.SegForLostIDsWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.SegForLostIDsWorker.signals.critical.connect(self.workerCritical) + + # Start the thread and worker + self._thread.started.connect(self.SegForLostIDsWorker.run) + self._thread.start() + + def SegForLostIDsWorkerAskInstallGPU(self, model_name, use_gpu): + result = self.host.view_model.model_registry.check_gpu_available( + model_name, use_gpu, qparent=self.host + ) + self.SegForLostIDsWorker.gpu_go = result + dont_force_cpu = self.host.view_model.model_registry.check_gpu_available( + model_name, use_gpu, do_not_warn=True + ) + self.SegForLostIDsWorker.dont_force_cpu = dont_force_cpu + self.SegForLostIDsWaitCond.wakeAll() + + def onSigStoreDataSegForLostIDsWorker(self, autosave): + self.onSigStoreData( + self.SegForLostIDsWaitCond, autosave=autosave + ) + + def onSigUpdateRPSegForLostIDsWorker(self, wl_update, wl_track_og_curr): + self.onSigUpdateRP( + self.SegForLostIDsWaitCond, + wl_update=wl_update, + wl_track_og_curr=wl_track_og_curr, + ) + + def onSigTrackManuallyAddedObjectSegForLostIDsWorker( + self, + added_IDs, + isNewID, + wl_update, + wl_track_og_curr, + ): + self.trackManuallyAddedObject( + added_IDs, + isNewID, + wl_update=wl_update, + wl_track_og_curr=wl_track_og_curr, + ) + self.SegForLostIDsWaitCond.wakeAll() + + def onSigStoreData( + self, waitcond, pos_i=None, enforce=True, debug=False, + mainThread=True, autosave=True, store_cca_df_copy=False + ): + self.store_data( + pos_i=pos_i, + enforce=enforce, + debug=debug, + mainThread=mainThread, + autosave=autosave, + store_cca_df_copy=store_cca_df_copy, + ) + waitcond.wakeAll() + + def onSigUpdateRP(self, waitcond, draw=True, debug=False, update_IDs=True, + wl_update=True, wl_track_og_curr=False): + self.update_rp(draw=draw, debug=debug, update_IDs=update_IDs, + wl_update=wl_update, wl_track_og_curr=wl_track_og_curr) + waitcond.wakeAll() + + def onSigGetData(self, waitcond, debug=False): + self.get_data(debug=debug) + waitcond.wakeAll() + + def SegForLostIDsWorkerFinished(self): + self.updateAllImages() + self.update_rp() + self.store_data(autosave=True) + self.setFrameNavigationDisabled( + disable=False, why='Segmentation for lost IDs' + ) + + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + def showImageDebug(self, img): + imshow(img) diff --git a/cellacdc/views/segmentation_view.py b/cellacdc/views/segmentation_view.py new file mode 100644 index 000000000..412a7d045 --- /dev/null +++ b/cellacdc/views/segmentation_view.py @@ -0,0 +1,770 @@ +"""Qt view adapter for segmentation workflows.""" + +from __future__ import annotations + +import os + +import numpy as np +from qtpy.QtCore import QMutex, QThread, QTimer, QWaitCondition, Qt +from qtpy.QtWidgets import QAction + +from cellacdc import ( + apps, exception_handler, html_utils, prompts, printl, widgets, workers, +) +from cellacdc.plot import imshow +from cellacdc.viewmodels.segmentation_viewmodel import SegmentationViewModel + + +class SegmentationView: + """Qt-facing segmentation workflow adapter.""" + + LEGACY_METHODS = ( + 'computeSegm', + 'autoSegm_cb', + 'postProcessSegm', + 'postProcessSegmApplyToAllFutureFrames', + 'postProcessSegmEditingFinished', + 'postProcessSegmWorkerFinished', + 'postProcessSegmWinClosed', + 'postProcessSegmValueChanged', + 'resetCursor', + 'segmFrameCallback', + 'showInstructionsCustomModel', + 'reinitStoredSegmModels', + 'segmVideoCallback', + 'segmentToolActionTriggered', + 'initSegmModelParams', + 'repeatSegm', + 'debugSegmWorker', + 'selectZtoolZvalueChanged', + 'repeatSegmVideo', + 'segmVideoWorkerFinished', + 'segmWorkerFinished', + 'postProcessing', + 'checkIfAutoSegm', + 'init_segmInfo_df', + ) + + def __init__(self, host, view_model: SegmentationViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def computeSegm(self, force=False): + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + should_compute = self.view_model.should_compute_segmentation( + mode=mode, + has_labels=np.any(posData.lab), + force=force, + auto_enabled=self.autoSegmAction.isChecked(), + ) + if not should_compute: + return + + self.repeatSegm(model_name=self.segmModelName) + + def autoSegm_cb(self, checked): + if checked: + self.askSegmParam = True + # Ask which model + models = self.view_model.segmentation_models() + win = widgets.QDialogListbox( + 'Select model', + 'Select model to use for segmentation: ', + models, + multiSelection=False, + parent=self.host + ) + win.exec_() + if win.cancel: + return + model_name = win.selectedItemsText[0] + self.segmModelName = model_name + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + self.updateAllImages() + self.computeSegm() + self.askSegmParam = False + else: + self.segmModelName = None + + def postProcessSegm(self, checked): + if self.isSegm3D: + SizeZ = max([posData.SizeZ for posData in self.data]) + else: + SizeZ = None + if checked: + posData = self.data[self.pos_i] + self.postProcessSegmWin = apps.PostProcessSegmDialog( + posData, mainWin=self.host + ) + self.postProcessSegmWin.sigClosed.connect( + self.postProcessSegmWinClosed + ) + self.postProcessSegmWin.sigValueChanged.connect( + self.postProcessSegmValueChanged + ) + self.postProcessSegmWin.sigEditingFinished.connect( + self.postProcessSegmEditingFinished + ) + self.postProcessSegmWin.sigApplyToAllFutureFrames.connect( + self.postProcessSegmApplyToAllFutureFrames + ) + self.postProcessSegmWin.show() + self.postProcessSegmWin.valueChanged(None) + else: + self.postProcessSegmWin.close() + self.postProcessSegmWin = None + + def postProcessSegmApplyToAllFutureFrames( + self, postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures + ): + proceed = self.warnEditingWithCca_df( + 'post-processing segmentation', update_images=False + ) + if not proceed: + self.logger.info('Post-processing segmentation cancelled.') + return + + self.progressWin = apps.QDialogWorkerProgress( + title='Post-processing segmentation', parent=self.host, + pbarDesc=f'Post-processing segmentation masks...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + self.startPostProcessSegmWorker( + postProcessKwargs, customPostProcessGroupedFeatures, + customPostProcessFeatures + ) + + def postProcessSegmEditingFinished(self): + self.update_rp() + self.store_data() + self.updateAllImages() + + def postProcessSegmWorkerFinished(self): + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.get_data() + self.updateAllImages() + self.titleLabel.setText('Post-processing segmentation done!', color='w') + self.logger.info('Post-processing segmentation done!') + + def postProcessSegmWinClosed(self): + self.postProcessSegmWin = None + self.postProcessSegmAction.toggled.disconnect() + self.postProcessSegmAction.setChecked(False) + self.postProcessSegmAction.toggled.connect(self.postProcessSegm) + + def postProcessSegmValueChanged(self, lab, delObjs: dict): + for delObj in delObjs.values(): + self.clearObjContour(obj=delObj, ax=0) + self.clearObjContour(obj=delObj, ax=1) + + posData = self.data[self.pos_i] + + labelsToSkip = {} + for ID in posData.IDs: + if ID in delObjs: + labelsToSkip[ID] = True + continue + + restoreObj = self.postProcessSegmWin.origObjs[ID] + self.addObjContourToContoursImage(obj=restoreObj, ax=0) + self.addObjContourToContoursImage(obj=restoreObj, ax=1) + + # self.setAllTextAnnotations(labelsToSkip=labelsToSkip) + + posData.lab = lab + self.setImageImg2() + if self.annotSegmMasksCheckbox.isChecked(): + self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) + if self.annotSegmMasksCheckboxRight.isChecked(): + self.labelsLayerRightImg.setImage(self.currentLab2D, autoLevels=False) + + def resetCursor(self): + if self.app.overrideCursor() is not None: + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + def segmFrameCallback(self, action): + if action == self.addCustomModelFrameAction: + return + + idx = self.segmActions.index(action) + model_name = self.modelNames[idx] + self.repeatSegm(model_name=model_name, askSegmParams=True) + + def showInstructionsCustomModel(self): + modelFilePath = apps.addCustomModelMessages(self.host) + if modelFilePath is None: + self.logger.info('Adding custom model process stopped.') + return + + self.view_model.store_custom_model_path(modelFilePath) + modelName = os.path.basename(os.path.dirname(modelFilePath)) + customModelAction = QAction(modelName) + self.segmSingleFrameMenu.addAction(customModelAction) + self.segmActions.append(customModelAction) + self.segmActionsVideo.append(customModelAction) + self.modelNames.append(modelName) + self.models.append(None) + self.sender().callback(customModelAction) + + def reinitStoredSegmModels(self): + self.models = [None]*len(self.models) + + def segmVideoCallback(self, action): + if action == self.addCustomModelVideoAction: + return + + posData = self.data[self.pos_i] + win = apps.startStopFramesDialog( + posData.SizeT, currentFrameNum=posData.frame_i+1 + ) + win.exec_() + if win.cancel: + self.logger.info('Segmentation on multiple frames aborted.') + return + + idx = self.segmActionsVideo.index(action) + model_name = self.modelNames[idx] + self.repeatSegmVideo(model_name, win.startFrame, win.stopFrame) + + def segmentToolActionTriggered(self): + if self.segmModelName is None: + win = apps.QDialogSelectModel(parent=self.host) + win.exec_() + if win.cancel: + self.logger.info('Repeat segmentation cancelled.') + return + model_name = win.selectedModel + self.repeatSegm( + model_name=model_name, askSegmParams=True + ) + else: + self.repeatSegm(model_name=self.segmModelName) + + def initSegmModelParams( + self, model_name, acdcSegment, init_params, segment_params, + is_label_roi=False, initLastParams=False, + extraParams=None, extraParamsTitle=None,ini_filename=None + + ): + posData = self.data[self.pos_i] + try: + url = acdcSegment.url_help() + except AttributeError: + url = None + + text_if_cancelled = 'Segmentation process cancelled.' + out = prompts.init_segm_model_params( + posData, model_name, init_params, segment_params, + help_url=url, qparent=self.host, init_last_params=initLastParams, + check_sam_embeddings=not is_label_roi, is_gui_caller=True, + extraParams=extraParams,extraParamsTitle=extraParamsTitle, + ini_filename=ini_filename, + ) + if out.get('load_sam_embeddings', False): + self.logger.info('Loading Segment Anything image embeddings...') + for _posData in self.data: + _posData.loadSamEmbeddings(logger_func=None) + text_if_cancelled = 'SAM embeddings loaded.' + + win = out.get('win') + if win is None: + self.logger.info(text_if_cancelled) + self.titleLabel.setText(text_if_cancelled) + return + + if win.cancel: + self.logger.info(text_if_cancelled) + self.titleLabel.setText(text_if_cancelled) + return + + if model_name != 'thresholding': + self.model_kwargs = win.model_kwargs + + return win + + @exception_handler + def repeatSegm( + self, model_name='', askSegmParams=False, is_label_roi=False + ): + model_name = self.view_model.action_model_name(model_name) + + idx = self.modelNames.index(model_name) + # Ask segm parameters if not already set + # and not called by segmSingleFrameMenu (askSegmParams=False) + if not askSegmParams: + askSegmParams = self.model_kwargs is None + + self.downloadWin = apps.downloadModel(model_name, parent=self.host) + self.downloadWin.download() + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + model_name = self.view_model.backend_model_name(model_name) + + posData = self.data[self.pos_i] + # Check if model needs to be imported + acdcSegment = self.acdcSegment_li[idx] + if acdcSegment is None: + self.logger.info(f'Importing {model_name}...') + acdcSegment = ( + self.view_model.import_segmentation_module(model_name) + ) + self.acdcSegment_li[idx] = acdcSegment + + # Ask parameters if the user clicked on the action + # Otherwise this function is called by "computeSegm" function and + # we use loaded parameters + if askSegmParams: + if self.app.overrideCursor() == Qt.WaitCursor: + self.app.restoreOverrideCursor() + self.segmModelName = model_name + # Read all models parameters + init_params, segment_params = ( + self.view_model.model_arg_specs(acdcSegment) + ) + # Prompt user to enter the model parameters + try: + url = acdcSegment.url_help() + except AttributeError: + url = None + + self.preproc_recipe = None + initLastParams = True + if model_name == 'thresholding': + win = apps.QDialogAutomaticThresholding( + parent=self.host, isSegm3D=self.isSegm3D + ) + win.exec_() + if win.cancel: + return + self.model_kwargs = win.segment_kwargs + thresh_method = self.model_kwargs['threshold_method'] + gauss_sigma = self.model_kwargs['gauss_sigma'] + segment_params = ( + self.view_model.insert_model_arg_spec( + segment_params, 'threshold_method', thresh_method + ) + ) + segment_params = ( + self.view_model.insert_model_arg_spec( + segment_params, 'gauss_sigma', gauss_sigma + ) + ) + initLastParams = False + + win = self.initSegmModelParams( + model_name, acdcSegment, init_params, segment_params, + is_label_roi=is_label_roi, + initLastParams=initLastParams + ) + if win is None: + return + + self.standardPostProcessKwargs = win.standardPostProcessKwargs + self.customPostProcessFeatures = win.customPostProcessFeatures + self.customPostProcessGroupedFeatures = ( + win.customPostProcessGroupedFeatures + ) + self.applyPostProcessing = win.applyPostProcessing + self.secondChannelName = win.secondChannelName + self.preproc_recipe = win.preproc_recipe + + self.view_model.log_segmentation_params( + model_name, win.init_kwargs, win.model_kwargs, + logger_func=self.logger.info, + preproc_recipe=win.preproc_recipe, + apply_post_process=self.applyPostProcessing, + standard_postprocess_kwargs=self.standardPostProcessKwargs, + custom_postprocess_features=self.customPostProcessFeatures + ) + + use_gpu = win.init_kwargs.get('gpu', False) + proceed = self.view_model.check_gpu_available( + model_name, use_gpu, qparent=self.host + ) + if not proceed: + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return + + model = self.view_model.init_segmentation_model( + acdcSegment, posData, win.init_kwargs + ) + if model is None: + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return + try: + model.setupLogger(self.logger) + except Exception as e: + pass + self.models[idx] = model + model.model_name = model_name + else: + model = self.models[idx] + + if is_label_roi: + return model + + self.titleLabel.setText( + f'Segmenting with {model_name}... ' + '(check progress in terminal/console)', color=self.titleColor + ) + + post_process_params = self.view_model.post_process_params( + apply_postprocessing=self.applyPostProcessing, + standard_postprocess_kwargs=self.standardPostProcessKwargs, + custom_postprocess_features=self.customPostProcessFeatures, + ) + if askSegmParams: + posData.saveSegmHyperparams( + model_name, win.init_kwargs, win.model_kwargs, + post_process_params=post_process_params, + preproc_recipe=self.preproc_recipe + ) + + if self.askRepeatSegment3D: + self.segment3D = False + if self.isSegm3D and self.askRepeatSegment3D: + msg = widgets.myMessageBox(showCentered=False) + msg.addDoNotShowAgainCheckbox(text='Do not ask again') + txt = html_utils.paragraph( + 'Do you want to segment the entire z-stack or only the ' + 'current z-slice?' + ) + _, segment3DButton, _ = msg.question( + self.host, '3D segmentation?', txt, + buttonsTexts=( + 'Cancel', 'Segment 3D z-stack', 'Segment 2D z-slice' + ) + ) + if msg.cancel: + self.titleLabel.setText('Segmentation process aborted.') + self.logger.info('Segmentation process aborted.') + return + self.segment3D = msg.clickedButton == segment3DButton + if msg.doNotShowAgainCheckbox.isChecked(): + self.askRepeatSegment3D = False + + if self.askZrangeSegm3D: + self.z_range = None + if self.isSegm3D and self.segment3D and self.askZrangeSegm3D: + idx = (posData.filename, posData.frame_i) + try: + orignal_z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + except ValueError as e: + orignal_z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] + selectZtool = apps.QCropZtool( + posData.SizeZ, parent=self.host, cropButtonText='Ok', + addDoNotShowAgain=True, title='Select z-slice range to segment' + ) + selectZtool.sigZvalueChanged.connect(self.selectZtoolZvalueChanged) + selectZtool.sigCrop.connect(selectZtool.close) + selectZtool.exec_() + self.update_z_slice(orignal_z) + if selectZtool.cancel: + self.titleLabel.setText('Segmentation process aborted.') + self.logger.info('Segmentation process aborted.') + return + startZ = selectZtool.lowerZscrollbar.value() + stopZ = selectZtool.upperZscrollbar.value() + self.z_range = (startZ, stopZ) + if selectZtool.doNotShowAgainCheckbox.isChecked(): + self.askZrangeSegm3D = False + + secondChannelData = None + if self.secondChannelName is not None: + secondChannelData = self.getSecondChannelData() + + self.titleLabel.setText( + f'{model_name} is thinking... ' + '(check progress in terminal/console)', color=self.titleColor + ) + + self.model = model + + self.segmWorkerMutex = QMutex() + self.segmWorkerWaitCond = QWaitCondition() + self.thread = QThread() + self.worker = workers.segmWorker( + self.host, + secondChannelData=secondChannelData, + mutex=self.segmWorkerMutex, + waitCond=self.segmWorkerWaitCond + ) + self.worker.z_range = self.z_range + self.worker.moveToThread(self.thread) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + if self.debug: + self.worker.debug.connect(self.debugSegmWorker) + self.thread.finished.connect(self.thread.deleteLater) + + # Custom signals + self.worker.critical.connect(self.workerCritical) + self.worker.finished.connect(self.segmWorkerFinished) + + self.thread.started.connect(self.worker.run) + self.thread.start() + + def debugSegmWorker(self, to_debug): + img, _lab, lab = to_debug + printl(img.shape, _lab.shape, lab.shape) + imshow(img, _lab, lab) + self.segmWorkerWaitCond.wakeAll() + + def selectZtoolZvalueChanged(self, whichZ, z): + self.update_z_slice(z) + + @exception_handler + def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): + model_name = self.view_model.action_model_name(model_name) + + idx = self.modelNames.index(model_name) + + self.downloadWin = apps.downloadModel(model_name, parent=self.host) + self.downloadWin.download() + + model_name = self.view_model.backend_model_name(model_name) + + posData = self.data[self.pos_i] + # Check if model needs to be imported + acdcSegment = self.acdcSegment_li[idx] + if acdcSegment is None: + self.logger.info(f'Importing {model_name}...') + acdcSegment = ( + self.view_model.import_segmentation_module(model_name) + ) + self.acdcSegment_li[idx] = acdcSegment + + # Read all models parameters + init_params, segment_params = ( + self.view_model.model_arg_specs(acdcSegment) + ) + # Prompt user to enter the model parameters + try: + url = acdcSegment.url_help() + except AttributeError: + url = None + + if model_name == 'thresholding': + autoThreshWin = apps.QDialogAutomaticThresholding( + parent=self.host, isSegm3D=self.isSegm3D + ) + autoThreshWin.exec_() + if autoThreshWin.cancel: + return + + win = self.initSegmModelParams( + model_name, acdcSegment, init_params, segment_params + ) + if win is None: + return + + self.standardPostProcessKwargs = win.standardPostProcessKwargs + self.customPostProcessFeatures = win.customPostProcessFeatures + self.customPostProcessGroupedFeatures = ( + win.customPostProcessGroupedFeatures + ) + self.applyPostProcessing = win.applyPostProcessing + self.preproc_recipe = win.preproc_recipe + + self.view_model.log_segmentation_params( + model_name, win.init_kwargs, win.model_kwargs, + logger_func=self.logger.info, + preproc_recipe=win.preproc_recipe, + apply_post_process=self.applyPostProcessing, + standard_postprocess_kwargs=self.standardPostProcessKwargs, + custom_postprocess_features=self.customPostProcessFeatures + ) + + secondChannelData = None + if win.secondChannelName is not None: + secondChannelData = self.getSecondChannelData() + + use_gpu = win.init_kwargs.get('gpu', False) + proceed = self.view_model.check_gpu_available( + model_name, use_gpu, qparent=self.host + ) + if not proceed: + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return + + model = self.view_model.init_segmentation_model( + acdcSegment, posData, win.init_kwargs + ) + if model is None: + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return + try: + model.setupLogger(self.logger) + except Exception as e: + pass + + self.extendSegmDataIfNeeded(stopFrameNum) + self.reInitLastSegmFrame( + from_frame_i=startFrameNum-1, updateImages=False + ) + + self.titleLabel.setText( + f'{model_name} is thinking... ' + '(check progress in terminal/console)', color=self.titleColor + ) + + self.progressWin = apps.QDialogWorkerProgress( + title='Segmenting video', parent=self.host, + pbarDesc=f'Segmenting from frame n. {startFrameNum} to {stopFrameNum}...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(stopFrameNum-startFrameNum) + + self.thread = QThread() + self.worker = workers.segmVideoWorker( + posData, win, model, startFrameNum, stopFrameNum + ) + self.worker.secondChannelData = secondChannelData + self.worker.moveToThread(self.thread) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + # Custom signals + self.worker.critical.connect(self.workerCritical) + self.worker.finished.connect(self.segmVideoWorkerFinished) + self.worker.progressBar.connect(self.workerUpdateProgressbar) + self.worker.progress.connect(self.workerProgress) + + self.thread.started.connect(self.worker.run) + self.thread.start() + + def segmVideoWorkerFinished(self, exec_time): + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.activateAnnotations() + + self.get_data() + self.tracking(enforce=True) + self.updateAllImages() + + txt = f'Done. Segmentation computed in {exec_time:.3f} s' + self.logger.info('-----------------') + self.logger.info(txt) + self.logger.info('=================') + self.titleLabel.setText(txt, color='g') + + def segmWorkerFinished(self, lab, exec_time): + posData = self.data[self.pos_i] + + if posData.segmInfo_df is not None and posData.SizeZ>1: + idx = (posData.filename, posData.frame_i) + posData.segmInfo_df.at[idx, 'resegmented_in_gui'] = True + + if lab.ndim == 2 and self.isSegm3D: + self.set_2Dlab(lab) + else: + posData.lab = lab.copy() + + self.activateAnnotations() + + self.update_rp(wl_update=False) + self.tracking(enforce=True, against_next=posData.frame_i==0) + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Repeat segmentation') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Repeat segmentation') + + txt = f'Done. Segmentation computed in {exec_time:.3f} s' + self.logger.info('-----------------') + self.logger.info(txt) + self.logger.info('=================') + self.titleLabel.setText(txt, color='g') + self.checkIfAutoSegm() + + QTimer.singleShot(200, self.resizeGui) + + @exception_handler + def postProcessing(self): + if self.postProcessSegmWin is None: + return + + self.postProcessSegmWin.setPosData() + posData = self.data[self.pos_i] + lab, delIDs = self.postProcessSegmWin.apply() + if posData.allData_li[posData.frame_i]['labels'] is None: + posData.lab = lab.copy() + self.update_rp() + else: + posData.allData_li[posData.frame_i]['labels'] = lab + self.get_data() + + def checkIfAutoSegm(self): + """ + If there are any frame or position with empty segmentation mask + ask whether automatic segmentation should be turned ON + """ + if self.autoSegmAction.isChecked(): + return + if self.autoSegmDoNotAskAgain: + return + + prompt = self.view_model.empty_segmentation_prompt(self.data) + if not prompt.should_ask: + return + txt = prompt.scope_text + + questionTxt = html_utils.paragraph( + f'Some or all loaded {txt} contain empty segmentation masks.

' + 'Do you want to activate automatic segmentation* ' + f'when visiting these {txt}?

' + '* Automatic segmentation can always be turned ON/OFF from the menu
' + ' Edit --> Segmentation --> Enable automatic segmentation

' + f'NOTE: you can automatically segment all {txt} using the
' + ' segmentation module.' + ) + msg = widgets.myMessageBox(wrapText=False) + noButton, yesButton = msg.question( + self.host, 'Automatic segmentation?', questionTxt, + buttonsTexts=('No', 'Yes') + ) + if msg.clickedButton == yesButton: + self.autoSegmAction.setChecked(True) + else: + self.autoSegmDoNotAskAgain = True + self.autoSegmAction.setChecked(False) + + def init_segmInfo_df(self): + for posData in self.data: + if posData is None: + # posData is None when computing measurements with the utility + # and with timelapse data + continue + posData.init_segmInfo_df() diff --git a/cellacdc/views/session_view.py b/cellacdc/views/session_view.py new file mode 100644 index 000000000..b99e80f21 --- /dev/null +++ b/cellacdc/views/session_view.py @@ -0,0 +1,742 @@ +"""Qt view adapter for session workflows.""" + +from __future__ import annotations + +import os +from functools import partial + +import numpy as np +import skimage.measure +from qtpy.QtWidgets import QAction + +from cellacdc import exception_handler, html_utils, recentPaths_path, settings_csv_path, widgets +from cellacdc.ui.modules.annotation.decorators import get_data_exception_handler +from cellacdc.viewmodels.session_viewmodel import SessionViewModel + + +class SessionView: + """Qt-facing adapter around session setup and frame storage.""" + + LEGACY_METHODS = ( + 'unstore_data', + 'updateLastVisitedFrame', + 'store_data', + '_get_data_unvisited', + '_get_data_visited', + 'get_data', + 'initPosAttr', + 'getStoredSegmData', + 'store_manual_annot_data', + 'get_labels', + 'readRecentPaths', + 'addPathToOpenRecentMenu', + 'loadLastSessionSettings', + 'reInitGui', + 'reinitWidgetsPos', + 'get_session', + '_sync_session', + '_sync_all_sessions', + '_sync_session_frame_i', + 'sync_session_labels', + '_init_tool_dispatcher', + '_make_tool_context', + '_dispatch_tool_event_if_enabled', + ) + + def __init__(self, host, view_model: SessionViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def unstore_data(self): + posData = self.data[self.pos_i] + posData.allData_li[posData.frame_i] = ( + self.view_model.frame_metadata.empty_frame_record() + ) + + def updateLastVisitedFrame(self, last_visited_frame_i=None): + posData = self.data[self.pos_i] + if last_visited_frame_i is None: + last_visited_frame_i = posData.frame_i + + mode = str(self.modeComboBox.currentText()) + update = self.view_model.update_last_visited_frame( + mode, + last_visited_frame_i, + last_tracked_i=posData.last_tracked_i, + last_cca_frame_i=self.last_cca_frame_i, + ) + posData.last_tracked_i = update.last_tracked_i + self.last_cca_frame_i = update.last_cca_frame_i + + @exception_handler + def store_data( + self, pos_i=None, enforce=True, debug=False, mainThread=True, + autosave=True, store_cca_df_copy=False + ): + pos_i = self.pos_i if pos_i is None else pos_i + posData = self.data[pos_i] + mode = str(self.modeComboBox.currentText()) + if not self.view_model.should_store_frame_data( + frame_i=posData.frame_i, + mode=mode, + enforce=enforce, + ): + return + + # if not mainThread: + # self.lin_tree_ask_changes() + + allData_li = posData.allData_li[posData.frame_i] + allData_li['regionprops'] = posData.rp.copy() + allData_li['labels'] = posData.lab.copy() + allData_li['IDs'] = posData.IDs.copy() + allData_li['manualBackgroundLab'] = ( + posData.manualBackgroundLab + ) + allData_li['IDs_idxs'] = ( + posData.IDs_idxs.copy() + ) + if self.manualAnnotPastButton.isChecked(): + self.store_manual_annot_data( + posData=posData, data_frame_i=allData_li + ) + + self.store_zslices_rp() + + depth_axis = ( + self.switchPlaneCombobox.depthAxes() if self.isSegm3D else 'z' + ) + metadata_result = ( + self.view_model.frame_metadata.build_acdc_frame_metadata( + posData.rp, + edit_id_info=posData.editID_info, + existing_df=allData_li['acdc_df'], + is_3d=self.isSegm3D, + depth_axis=depth_axis, + ) + ) + posData.STOREDmaxID = metadata_result.max_id + allData_li['acdc_df'] = metadata_result.dataframe + + if mainThread: + self.pointsLayerDataToDf(posData) + + self.store_cca_df( + pos_i=pos_i, mainThread=mainThread, autosave=autosave, + store_cca_df_copy=store_cca_df_copy + ) + + def _get_data_unvisited(self, posData, debug=False, lin_tree_init=True,): + posData.editID_info = [] + proceed_cca = True + never_visited = True + if str(self.modeComboBox.currentText()) == 'Cell cycle analysis': + # Warn that we are visiting a frame that was never segm-checked + # on cell cycle analysis mode + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Segmentation and Tracking was never checked from ' + f'frame {posData.frame_i+1} onwards.

' + 'To ensure correct cell cell cycle analysis you have to ' + 'first visit the frames after ' + f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' + ) + warn_cca = msg.critical( + self.host, 'Never checked segmentation on requested frame', txt + ) + proceed_cca = False + return proceed_cca, never_visited + + elif str(self.modeComboBox.currentText()) == 'Normal division: Lineage tree': + # Warn that we are visiting a frame that was never segm-checked + # on cell cycle analysis mode + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Segmentation and Tracking was never checked from ' + f'frame {posData.frame_i+1} onwards.

' + 'To ensure correct lineage tree analysis you have to ' + 'first visit the frames after ' + f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' + ) + warn_cca = msg.critical(#??? + self.host, 'Never checked segmentation on requested frame', txt + ) + proceed_cca = False + return proceed_cca, never_visited + + # Requested frame was never visited before. Load from HDD + labels = self.get_labels() + posData.lab = self.apply_manual_edits_to_lab_if_needed( + labels + ) + posData.rp = skimage.measure.regionprops(posData.lab) + self.setManualBackgroundLab() + + if posData.acdc_df is not None: + frames = posData.acdc_df.index.get_level_values(0) + if posData.frame_i in frames: + # Since there was already segmentation metadata from + # previous closed session add it to current metadata + df = posData.acdc_df.loc[posData.frame_i].copy() + binnedIDs_df = df[df['is_cell_excluded']>0] + binnedIDs = set(binnedIDs_df.index).union(posData.binnedIDs) + posData.binnedIDs = binnedIDs + ripIDs_df = df[df['is_cell_dead']>0] + ripIDs = set(ripIDs_df.index).union(posData.ripIDs) + posData.ripIDs = ripIDs + posData.editID_info.extend(self._get_editID_info(df)) + df = self.view_model.cca_edits.normalize_loaded_frame_annotations( + df, + self.cca_df_colnames, + self.cca_df_int_cols, + ) + + i = posData.frame_i + posData.allData_li[i]['acdc_df'] = df.copy() + + if self.lineage_tree is None and lin_tree_init: + self.initLinTree() + + self.get_cca_df() + + return proceed_cca, never_visited + + def _get_data_visited(self, posData, debug=False, lin_tree_init=True,): + # Requested frame was already visited. Load from RAM. + never_visited = False + posData.lab = self.get_labels(from_store=True) + posData.rp = skimage.measure.regionprops(posData.lab) + df = posData.allData_li[posData.frame_i]['acdc_df'] + if df is None: + posData.binnedIDs = set() + posData.ripIDs = set() + posData.editID_info = [] + else: + try: + binnedIDs_df = df[df['is_cell_excluded']>0] + except Exception as err: + df = self.view_model.tables.fix_acdc_df_dtypes(df) + binnedIDs_df = df[df['is_cell_excluded']>0] + posData.binnedIDs = set(binnedIDs_df.index) + ripIDs_df = df[df['is_cell_dead']>0] + posData.ripIDs = set(ripIDs_df.index) + posData.editID_info = self._get_editID_info(df) + self.setManualBackgroundLab(load_from_store=True, debug=debug) + if self.lineage_tree is None and lin_tree_init: + self.initLinTree() + + self.get_cca_df(debug=debug) + + return True, never_visited + + @get_data_exception_handler + def get_data(self, debug=False, lin_tree_init=True): + posData = self.data[self.pos_i] + proceed_cca = True + never_visited = False + if posData.frame_i > 2: + # Remove undo states from 4 frames back to avoid memory issues + posData.UndoRedoStates[posData.frame_i-4] = [] + # Check if current frame contains undo states (not empty list) + if posData.UndoRedoStates[posData.frame_i]: + self.undoAction.setDisabled(False) + elif posData.UndoRedoCcaStates[posData.frame_i]: + self.undoAction.setDisabled(False) + else: + self.undoAction.setDisabled(True) + self.UndoCount = 0 + # If stored labels is None then it is the first time we visit this frame + if posData.allData_li[posData.frame_i]['labels'] is None: + proceed_cca, never_visited = self._get_data_unvisited( + posData, lin_tree_init=lin_tree_init, + ) + if not proceed_cca: + return proceed_cca, never_visited + else: + proceed_cca, never_visited = self._get_data_visited( + posData, lin_tree_init=lin_tree_init, debug=debug + ) + + self.update_rp_metadata(draw=False) + posData.IDs = [obj.label for obj in posData.rp] + posData.IDs_idxs = { + ID:i for ID, i in zip(posData.IDs, range(len(posData.IDs))) + } + self.get_zslices_rp() + self.pointsLayerDfsToData(posData) + return proceed_cca, never_visited + + def initPosAttr(self): + exp_path = self.data[self.pos_i].exp_path + pos_foldernames = self.view_model.workspace.position_folder_names( + exp_path + ) + if self.view_model.should_disable_load_position(len(pos_foldernames)): + self.loadPosAction.setDisabled(True) + else: + self.loadPosAction.setDisabled(False) + + for p, posData in enumerate(self.data): + self.pos_i = p + posData.curvPlotItems = [] + posData.curvAnchorsItems = [] + posData.curvHoverItems = [] + posData.trackedLostIDs = set() + + posData.HDDmaxID = np.max(posData.segm_data) + + # Decision on what to do with changes to future frames attr + posData.doNotShowAgain_EditID = False + posData.UndoFutFrames_EditID = False + posData.applyFutFrames_EditID = False + + posData.doNotShowAgain_RipID = False + posData.UndoFutFrames_RipID = False + posData.applyFutFrames_RipID = False + + posData.doNotShowAgain_DelID = False + posData.UndoFutFrames_DelID = False + posData.applyFutFrames_DelID = False + + posData.doNotShowAgain_keepID = False + posData.UndoFutFrames_keepID = False + posData.applyFutFrames_keepID = False + + posData.doNotShowAgainAssignNewID = False + posData.UndoFutFramesAssignNewID = False + posData.applyFutFramesAssignNewID = False + + posData.includeUnvisitedInfo = { + 'Delete ID': False, 'Edit ID': False, 'Keep ID': False + } + + posData.loadTrackedLostCentroids() + posData.acdcTracker2stepsAnnotInfo = {} + + posData.doNotShowAgain_BinID = False + posData.UndoFutFrames_BinID = False + posData.applyFutFrames_BinID = False + + posData.disableAutoActivateViewerWindow = False + posData.new_IDs = [] + posData.lost_IDs = [] + posData.multiBud_mothIDs = [2] + posData.UndoRedoStates = [[] for _ in range(posData.SizeT)] + posData.UndoRedoCcaStates = [[] for _ in range(posData.SizeT)] + + posData.ol_data_dict = {} + posData.ol_data = None + + posData.ol_labels_data = None + + missing_frames = posData.SizeT - len(posData.allData_li) + if missing_frames > 0: + posData.allData_li.extend([None] * missing_frames) + for i in range(posData.SizeT): + if posData.allData_li[i] is None: + posData.allData_li[i] = ( + self.view_model.frame_metadata.empty_frame_record() + ) + + posData.lutLevels = {channel: {} for channel in self.ch_names} + + posData.ccaStatus_whenEmerged = {} + + posData.frame_i = 0 + posData.brushID = 0 + posData.binnedIDs = set() + posData.ripIDs = set() + posData.cca_df = None + if posData.last_tracked_i is not None: + last_tracked_num = posData.last_tracked_i+1 + # Load previous session data + # Keep track of which ROIs have already been added + # in previous frame + delROIshapes = [[] for _ in range(posData.SizeT)] + for i in range(last_tracked_num): + posData.frame_i = i + self.get_data(debug=True) + self.store_data( + enforce=True, autosave=False, store_cca_df_copy=True + ) + + # Ask whether to resume from last frame + if self.view_model.should_resume_last_session_prompt( + last_tracked_num + ): + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Cell-ACDC detected a previous session ended ' + f'at frame {last_tracked_num}.

' + f'Do you want to resume from frame ' + f'{last_tracked_num}?' + ) + noButton, yesButton = msg.question( + self.host, 'Start from last session?', txt, + buttonsTexts=(' No ', 'Yes') + ) + self.AutoPilotProfile.storeClickMessageBox( + 'Start from last session?', msg.clickedButton.text() + ) + if msg.clickedButton == yesButton: + posData.frame_i = posData.last_tracked_i + self.lastFrameRanOnFirstVisitTools = posData.frame_i + else: + posData.frame_i = 0 + + posData.img_data_min_max = ( + posData.img_data.min(), posData.img_data.max() + ) + + # Back to first position + self.pos_i = 0 + self.get_data(debug=False) + self.store_data(autosave=False) + # self.updateAllImages() + + # Link Y and X axis of both plots to scroll zoom and pan together + self.ax2.vb.setYLink(self.ax1.vb) + self.ax2.vb.setXLink(self.ax1.vb) + + self.setAllIDs() + + def getStoredSegmData(self): + posData = self.data[self.pos_i] + segm_data = [] + for data_frame_i in posData.allData_li: + lab = data_frame_i['labels'] + if lab is None: + break + segm_data.append(lab) + return np.array(segm_data) + + def store_manual_annot_data( + self, posData=None, data_frame_i=None + ): + if posData is None: + posData = self.data[self.pos_i] + + if data_frame_i is None: + data_frame_i = posData.allData_li[posData.frame_i] + + if not self.isSegm3D: + lab = [posData.lab] + else: + lab = posData.lab + + for z, lab_2D in enumerate(lab): + data_frame_i['manually_edited_lab']['lab'][z] = lab_2D + + # data_frame_i['manually_edited_lab']['zoom_slice'] = zoom_slice + + def get_labels( + self, + from_store=False, + frame_i=None, + return_existing=False, + return_copy=True + ): + """Get the labels array. + + Parameters + ---------- + from_store : bool, optional + If True load the labels array from the stored posData.allData_li, + i.e., from RAM. Default is False + frame_i : int, optional + If None, use the current frame index. Default is None + return_existing : bool, optional + If True, the second return element will be a boolean that + is True if the labels array was found stored in `posData.allData_li`. + Default is False + return_copy : bool, optional + If True returns a copy of the labels array + + Returns + ------- + numpy.ndarray or tuple of (numpy.ndarray, bool) + The first element is the labels array requested. If `return_existing` + is True then this method also returns a second boolean element that + is True if the labels array was found in in `posData.allData_li`. + + Note + ---- + + If `from_store` is True then this method will try to get the stored + labels array. If any error occurs then the returned labels are the + saved ones in the segmentation file (i.e., from hard drive). + + """ + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + existing = True + if from_store: + try: + labels = posData.allData_li[frame_i]['labels'] + if labels is None: + from_store = False + except Exception as err: + from_store = False + + if not from_store: + try: + labels = posData.segm_data[frame_i] + except IndexError: + existing = False + # Visting a frame that was not segmented --> empty masks + labels = self.view_model.empty_labels( + is_3d=self.isSegm3D, + size_z=posData.SizeZ, + size_y=posData.SizeY, + size_x=posData.SizeX, + ) + return_copy = False + + if return_copy: + labels = labels.copy() + + if return_existing: + return labels, existing + else: + return labels + + def readRecentPaths(self, recent_paths_path=None): + # Step 0. Remove the old options from the menu + self.openRecentMenu.clear() + + # Step 1. Read recent Paths + if recent_paths_path is None: + recent_paths_path = recentPaths_path + + recentPaths = self.view_model.recent_paths(recent_paths_path) + + # Step 2. Dynamically create the actions + actions = [] + for path in recentPaths: + if not os.path.exists(path): + continue + action = QAction(path, self.host) + action.triggered.connect(partial(self.openRecentFile, path)) + actions.append(action) + + # Step 3. Add the actions to the menu + self.openRecentMenu.addActions(actions) + + def addPathToOpenRecentMenu(self, path): + for action in self.openRecentMenu.actions(): + if path == action.text(): + break + else: + action = QAction(path, self.host) + action.triggered.connect(partial(self.openRecentFile, path)) + + try: + firstAction = self.openRecentMenu.actions()[0] + self.openRecentMenu.insertAction(firstAction, action) + except Exception as e: + pass + + def loadLastSessionSettings(self): + self.settings_csv_path = settings_csv_path + self.df_settings = self.view_model.load_settings( + settings_csv_path + ) + + if 'colorScheme' in self.df_settings.index: + col = 'colorScheme' + self._colorScheme = self.df_settings.at[col, 'value'] + else: + self._colorScheme = 'light' + + self.doNotShowAgainMissingCca = False + if 'doNotShowAgainMissingCca' not in self.df_settings.index: + self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'No' + else: + val = self.df_settings.at['doNotShowAgainMissingCca', 'value'] + self.doNotShowAgainMissingCca = val == 'Yes' + + def reInitGui(self): + cancel = self.checkAskSavePointsLayers() + if cancel: + return False + + if self.overlayToolbar.isTransparent(): + self.overlayToolbar.setTransparent(False) + + self.secondLevelToolbar.setVisible(False) + + self.gui_createLazyLoader() + + try: + self.navSpinBox.valueChanged.disconnect() + except Exception as e: + pass + + try: + self.scaleBar.removeFromAxis(self.ax1) + except Exception as e: + pass + + self.lineage_tree = None + self.getDistanceListMissingIDsCachedFrame = None + self.isZmodifier = False + self.zKeptDown = False + self.askRepeatSegment3D = True + self.askZrangeSegm3D = True + self.isDataLoaded = False + self.retainSizeLutItems = False + self.setMeasWinState = None + self.addPointsWin = None + self.delRoiLab = None + self.showPropsDockButton.setDisabled(True) + self.removeOverlayItems() + self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) + + self.reinitWidgetsPos() + self.removeAllItems() + self.custom_annotations_view.reinitCustomAnnot() + self.reinitPointsLayers() + self.gui_createPlotItems() + self.setUncheckedAllButtons() + self.setUncheckedPointsLayers() + self.restoreDefaultColors() + self.reinitStoredSegmModels() + self.removeAxLimits() + self.curvToolButton.setChecked(False) + + self.wandControlsToolbar.setVisible(False) + self.wandToolButton.setChecked(False) + self.segmNdimIndicatorAction.setVisible(False) + + self.navigateToolBar.hide() + self.ccaToolBar.hide() + self.editToolBar.hide() + self.brushEraserToolBar.hide() + self.modeToolBar.hide() + + self.modeComboBox.setCurrentText('Viewer') + + alpha = self.imgGrad.labelsAlphaSlider.value() + self.labelsLayerImg1.setOpacity(alpha) + self.labelsLayerRightImg.setOpacity(alpha) + self.lastTrackedFrameLabel.setText('') + + self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False + + for action in self.askHowFutureFramesActions.values(): + action.setChecked(True) + action.setDisabled(True) + + return True + + def reinitWidgetsPos(self): + pass + # try: + # # self.highlightZneighObjCheckbox will be connected in + # # self.showHighlightZneighCheckbox() + # self.highlightZneighObjCheckbox.toggled.disconnect() + # except Exception as e: + # pass + # layout = self.bottomLeftLayout + # self.highlightZneighObjCheckbox.hide() + # try: + # layout.removeWidget(self.highlightZneighObjCheckbox) + # except Exception as e: + # pass + # self.highlightZneighObjCheckbox.hide() + # # layout.addWidget( + # # self.drawIDsContComboBox, 0, 1, 1, 2, + # # alignment=Qt.AlignCenter + # # ) + + def get_session(self, pos_i=None): + """Return synced :class:`PositionSession` for a loaded position.""" + if pos_i is None: + pos_i = self.pos_i + if not hasattr(self, 'sessions') or pos_i >= len(self.sessions): + return None + return self.sessions[pos_i] + + def _sync_session(self, pos_i): + """Build or refresh ``PositionSession`` from ``loadData`` at ``pos_i``.""" + if not hasattr(self, 'sessions'): + self.sessions = [] + while len(self.sessions) <= pos_i: + self.sessions.append(None) + pos_data = self.data[pos_i] + session = self.view_model.position_session_from_load_data(pos_data) + self.sessions[pos_i] = session + if hasattr(self, '_tool_dispatcher') and self._tool_dispatcher is not None: + self._tool_dispatcher.set_context(self._make_tool_context(pos_i)) + return session + + def _sync_all_sessions(self): + if not hasattr(self, 'data'): + return + self.sessions = [None] * len(self.data) + for pos_i in range(len(self.data)): + self._sync_session(pos_i) + + def _sync_session_frame_i(self, pos_i=None): + if pos_i is None: + pos_i = self.pos_i + session = self.get_session(pos_i) + if session is None: + return + pos_data = self.data[pos_i] + session.frame_i = pos_data.frame_i + if hasattr(self, '_tool_dispatcher') and self._tool_dispatcher is not None: + self._tool_dispatcher.on_frame_changed(self._make_tool_context(pos_i)) + + def sync_session_labels(self, pos_i=None): + """Mirror ``posData.segm_data`` into the parallel ``PositionSession``.""" + if pos_i is None: + pos_i = self.pos_i + session = self.get_session(pos_i) + pos_data = self.data[pos_i] + if session is None or not hasattr(pos_data, 'segm_data'): + return + if pos_data.segm_data is not None: + session.set_labels(np.asarray(pos_data.segm_data)) + + def _init_tool_dispatcher(self): + from cellacdc.tools.dispatch import ToolDispatcher + + if not hasattr(self, '_tool_dispatcher'): + self._tool_dispatcher = ToolDispatcher() + self._tool_dispatcher.set_context(self._make_tool_context(self.pos_i)) + + def _make_tool_context(self, pos_i=None): + from cellacdc.tools.context import GuiToolContext + + if pos_i is None: + pos_i = self.pos_i + return GuiToolContext( + gui=self, + pos_i=pos_i, + pos_data=self.data[pos_i], + session=self.get_session(pos_i), + ) + + def _dispatch_tool_event_if_enabled(self, event, phase='press', image='img1'): + from cellacdc.tools.adapters.gui_bridge import dispatch_gui_mouse_event + + self._init_tool_dispatcher() + return dispatch_gui_mouse_event( + self._tool_dispatcher, self, event, phase=phase, image=image, + ) diff --git a/cellacdc/views/status_hover_view.py b/cellacdc/views/status_hover_view.py new file mode 100644 index 000000000..9515b8c30 --- /dev/null +++ b/cellacdc/views/status_hover_view.py @@ -0,0 +1,185 @@ +"""View adapter for hover and status-bar formatting.""" + +from __future__ import annotations + +from cellacdc.viewmodels.status_hover_viewmodel import StatusHoverViewModel + + +class StatusHoverView: + """Qt-facing adapter around status/hover view-model contracts.""" + + def __init__(self, host, view_model: StatusHoverViewModel): + self.host = host + self.view_model = view_model + + def channel_hover_values(self, descr, channel, value, ff=None): + if ff is None: + n_digits = len(str(int(value))) + ff = self.host.view_model.formatting.number_fstring_formatter( + type(value), precision=abs(n_digits-5) + ) + return self.view_model.channel_hover_text(descr, channel, value, ff) + + def add_overlay_hover_values_formatted(self, txt, xdata, ydata): + pos_data = self.host.data[self.host.pos_i] + if pos_data.ol_data is None: + return txt + + for filename in pos_data.ol_data: + ch_name = ( + self.host.view_model.formatting.channel_name_from_basename( + filename, pos_data.basename, remove_ext=False + ) + ) + if ch_name not in self.host.checkedOverlayChannels: + continue + + raw_overlay_img = self.host.getRawImage(filename=filename) + raw_overlay_value = raw_overlay_img[ydata, xdata] + raw_txt = self.channel_hover_values( + 'Raw', ch_name, raw_overlay_value + ) + txt = f'{txt} | {raw_txt}' + return txt + + def active_tool_button(self): + for button in self.host.LeftClickButtons: + if button.isChecked(): + return button + + def concat_acdc_df(self): + pos_data = self.host.data[self.host.pos_i] + return self.host.view_model.frame_metadata.concat_visited_acdc_frames( + pos_data.allData_li + ) + + def check_highlight_timestamp(self, x, y, active_tool_button): + if not hasattr(self.host, 'timestamp'): + return + blocked_by_scale_bar = ( + hasattr(self.host, 'scaleBar') + and self.host.scaleBar.isHighlighted() + ) + highlighted = self.view_model.highlight_state( + x=x, + y=y, + bbox=self.host.timestamp.bbox(), + enabled=self.host.addTimestampAction.isChecked(), + active_tool=active_tool_button, + blocked_by_other_highlight=blocked_by_scale_bar, + ) + if highlighted is None: + return + self.host.timestamp.setHighlighted(highlighted) + + def check_highlight_scale_bar(self, x, y, active_tool_button): + if not hasattr(self.host, 'scaleBar'): + return + highlighted = self.view_model.highlight_state( + x=x, + y=y, + bbox=self.host.scaleBar.bbox(), + enabled=self.host.addScaleBarAction.isChecked(), + active_tool=active_tool_button, + ) + if highlighted is None: + return + self.host.scaleBar.setHighlighted(highlighted) + + def mouse_data_coords_right_image(self): + return self.view_model.mouse_data_coords_right_image( + self.host.wcLabel.text() + ) + + def update_values_status_bar(self): + (xl, xr), (yt, yb) = ( + self.host.display_decorations_view.ax1_view_range(integers=True) + ) + width = round(xr - xl) + height = round(yb - yt) + txt = self.view_model.replace_view_range_status( + self.host.wcLabel.text(), + width=width, + height=height, + x_left=xl, + y_top=yt, + x_right=xr, + y_bottom=yb, + ) + self.host.wcLabel.setText(txt) + + def hover_values_formatted(self, xdata, ydata, active_tool_button, is_ax0): + (xl, xr), (yt, yb) = ( + self.host.display_decorations_view.ax1_view_range(integers=True) + ) + width = round(xr - xl) + height = round(yb - yt) + axis_index = 0 if is_ax0 else 1 + txt = self.view_model.base_hover_text( + x=xdata, + y=ydata, + width=width, + height=height, + x_left=xl, + y_top=yt, + x_right=xr, + y_bottom=yb, + axis_index=axis_index, + ) + if active_tool_button == self.host.rulerButton: + return self.add_ruler_measurement_text(txt) + if active_tool_button is not None: + return txt + + pos_data = self.host.data[self.host.pos_i] + raw_img = self.host.getRawImage() + raw_value = raw_img[ydata, xdata] + raw_txt = self.channel_hover_values( + 'Raw', self.host.user_ch_name, raw_value + ) + txt = f'{txt} | {raw_txt}' + txt = self.add_overlay_hover_values_formatted(txt, xdata, ydata) + + label_id = self.host.currentLab2D[ydata, xdata] + label_txt = self.view_model.object_hover_text( + label_id=label_id, + max_id=max(pos_data.IDs, default=0), + object_count=len(pos_data.IDs), + ) + txt = f'{txt} | {label_txt}' + return self.add_ruler_measurement_text(txt) + + def ruler_length_text(self): + return self.view_model.ruler_length_text(self.host.wcLabel.text()) + + def add_ruler_measurement_text(self, txt): + pos_data = self.host.data[self.host.pos_i] + xx, yy = self.host.ax1_rulerPlotItem.getData() + if xx is None: + return txt + + length_pixels = self.view_model.euclidean_length(xx, yy) + depth_axes = self.host.switchPlaneCombobox.depthAxes() + if depth_axes != 'z': + pixel_to_um = pos_data.PhysicalSizeZ + else: + pixel_to_um = pos_data.PhysicalSizeX + + length_txt = self.view_model.ruler_measurement_text( + length_pixels=length_pixels, + pixel_to_um=pixel_to_um, + ) + return f'{txt} | Measurement: {length_txt}' + + def set_status_bar_label(self, log=True): + self.host.statusbar.clearMessage() + pos_data = self.host.data[self.host.pos_i] + txt = self.view_model.status_bar_text( + pos_foldername=pos_data.pos_foldername, + basename=pos_data.basename, + filename=pos_data.filename, + segm_npz_path=pos_data.segm_npz_path, + ) + if log: + self.host.logger.info(txt) + self.host.statusBarLabel.setText(txt) diff --git a/cellacdc/views/tool_activation_view.py b/cellacdc/views/tool_activation_view.py new file mode 100644 index 000000000..2f2770f13 --- /dev/null +++ b/cellacdc/views/tool_activation_view.py @@ -0,0 +1,893 @@ +"""Qt view adapter for active-tool workflows.""" + +from __future__ import annotations + +import numpy as np +from qtpy.QtCore import QEventLoop, QThread, QTimer, Qt + +from cellacdc import apps, qutils, widgets, workers +from cellacdc import disableWindow +from cellacdc.viewmodels.tool_activation_viewmodel import ( + ToolActivationViewModel, +) + + +class ToolActivationView: + """Qt-facing adapter around active-tool workflows.""" + + LEGACY_METHODS = ( + 'uncheckQButton', + 'setUncheckedPointsLayers', + 'setUncheckedAllButtons', + 'setUncheckedAllCustomAnnotButtons', + 'onEscape', + 'clearTempBrushImage', + 'disconnectLeftClickButtons', + 'uncheckLeftClickButtons', + 'connectLeftClickButtonsPointsLayersToolbar', + 'connectLeftClickButtons', + 'wand_cb', + 'magicPrompts_cb', + 'copyLostObjContour_cb', + 'manualAnnotPast_cb', + 'copyLostObjectMask', + 'highlightManualAnnotMode', + 'updateHighlightedAxis', + 'updateLostNewCurrentIDs', + 'highlightLostNew', + 'addLostObjsToLostObjImage', + 'highlightHoverLostObj', + 'annotLostObjsToggled', + 'getPrevFrameIDs', + 'setLostNewOldPrevIDs', + 'setTitleFormatter', + 'setTitleText', + '_copyAllLostObjects_navigateToFrame', + '_copyAllLostObjects_returnToFrame', + '_copyAllLostObjects_refreshRp', + 'copyAllLostObjects', + 'copyAllLostObjectsWorkerCritical', + 'copyAllLostObjectsWorkerFinished', + 'restoreHoverObjBrush', + 'hideItemsHoverBrush', + 'updateBrushCursor', + 'setManualAnnotModeEnabledTools', + 'disableNonFunctionalButtons', + ) + + def __init__(self, host, view_model: ToolActivationViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def uncheckQButton(self, button): + # Manual exclusive where we allow to uncheck all buttons + for b in self.checkableQButtonsGroup.buttons(): + if b != button: + b.setChecked(False) + + def setUncheckedPointsLayers(self): + self.togglePointsLayerAction.setChecked(False) + self.magicPromptsToolButton.setChecked(False) + + def setUncheckedAllButtons(self, buttonsToNotUncheck=None): + self.clickedOnBud = False + if buttonsToNotUncheck is None: + buttonsToNotUncheck = set() + + try: + self.BudMothTempLine.setData([], []) + except Exception as e: + pass + for button in self.checkableButtons: + if button in buttonsToNotUncheck: + continue + button.setChecked(False) + + if self.countObjsButton not in buttonsToNotUncheck: + self.countObjsButton.setChecked(False) + self.splineHoverON = False + self.tempSegmentON = False + self.isRightClickDragImg1 = False + self.curvature_tools_view.clearCurvItems(removeItems=False) + + def setUncheckedAllCustomAnnotButtons(self): + for button in self.customAnnotDict.keys(): + button.setChecked(False) + + def onEscape( + self, + isTypingIDFunctionChecked=False, + buttonsToNotUncheck=None, + doAutoRange=True + ): + if buttonsToNotUncheck is None: + buttonsToNotUncheck = set() + + if self.keepIDsButton.isChecked() and self.keptObjectsIDs: + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + self.highlightHoverIDsKeptObj(0, 0, hoverID=0) + QTimer.singleShot(300, self.autoRange) + return + + if self.brushButton.isChecked() and self.typingEditID: + self.autoIDcheckbox.setChecked(True) + self.typingEditID = False + QTimer.singleShot(300, self.autoRange) + return + + if isTypingIDFunctionChecked and self.typingEditID: + self.typingEditID = False + QTimer.singleShot(300, self.autoRange) + return + + if self.labelRoiButton.isChecked() and self.isMouseDragImg1: + self.isMouseDragImg1 = False + self.labelRoiItem.setPos((0,0)) + self.labelRoiItem.setSize((0,0)) + self.freeRoiItem.clear() + QTimer.singleShot(300, self.autoRange) + return + + if self.zoomRectButton.isChecked(): + self.zoomRectCancelled() + QTimer.singleShot(300, self.autoRange) + return + + self.setUncheckedAllButtons(buttonsToNotUncheck=buttonsToNotUncheck) + self.setUncheckedAllCustomAnnotButtons() + self.setUncheckedPointsLayers() + self.clearTempBrushImage() + self.isMouseDragImg1 = False + self.typingEditID = False + self.clearHighlightedID() + try: + self.polyLineRoi.clearPoints() + except Exception as e: + pass + + if doAutoRange: + QTimer.singleShot(11, self.autoRange) + + def clearTempBrushImage(self, forceClearLinked=True): + if not hasattr(self, 'tempLayerImg1'): + return + + self.tempLayerImg1.setImage( + self.emptyLab, force_set_linked=forceClearLinked + ) + + try: + self.brushContourImage[:] = 0 + except Exception as err: + pass + + try: + self.brushImage[:] = 0 + except Exception as err: + pass + + def disconnectLeftClickButtons(self): + for button in self.LeftClickButtons: + try: + button.toggled.disconnect() + except Exception as e: + # Not all the LeftClickButtons have toggled connected + pass + + def uncheckLeftClickButtons(self, sender): + for button in self.LeftClickButtons: + if button != sender: + button.setChecked(False) + + if button != self.labelRoiButton: + # self.labelRoiButton is disconnected so we manually call uncheck + self.labelRoi_cb(False) + self.secondLevelToolbar.setVisible(True) + for toolbar in self.controlToolBars: + try: + toolbar.keepVisibleWhenActive + if toolbar.isVisible(): + self.secondLevelToolbar.setVisible(False) + continue + except: + pass + toolbar.setVisible(False) + + self.mode_controls_view.enableSizeSpinbox(False) + if sender is not None: + self.keepIDsButton.setChecked(False) + + def connectLeftClickButtonsPointsLayersToolbar(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'layerTypeIdx'): + continue + if action.layerTypeIdx != 4: + continue + action.button.toggled.connect( + self.addPointsByClickingButtonToggled + ) + + def connectLeftClickButtons(self): + self.brushButton.toggled.connect(self.Brush_cb) + self.curvToolButton.toggled.connect( + self.curvature_tools_view.curvTool_cb + ) + self.rulerButton.toggled.connect(self.ruler_cb) + self.eraserButton.toggled.connect(self.Eraser_cb) + self.wandToolButton.toggled.connect(self.wand_cb) + self.labelRoiButton.toggled.connect(self.labelRoi_cb) + self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) + self.drawClearRegionButton.toggled.connect( + self.draw_clear_region_view.toggle + ) + self.expandLabelToolButton.toggled.connect( + self.label_transform_tools_view.expand_label_callback + ) + self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) + self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) + self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) + self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) + self.connectLeftClickButtonsPointsLayersToolbar() + + def wand_cb(self, checked): + posData = self.data[self.pos_i] + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.wandToolButton) + self.connectLeftClickButtons() + self.wandControlsToolbar.setVisible(True) + # self.secondLevelToolbar.setVisible(False) + else: + self.resetCursors() + # self.secondLevelToolbar.setVisible(True) + self.wandControlsToolbar.setVisible(False) + + def magicPrompts_cb(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.magicPromptsToolButton) + self.connectLeftClickButtons() + self.magicPromptsToolbar.setVisible(True) + self.promptSegmentPointsLayerToolbar.setVisible(True) + if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit: + self.addPointsLayerTriggered( + toolbar=self.promptSegmentPointsLayerToolbar + ) + else: + self.resetCursors() + self.promptSegmentPointsLayerToolbar.setVisible(False) + self.magicPromptsToolbar.setVisible(False) + + def copyLostObjContour_cb(self, checked): + self.copyLostObjToolbar.setVisible(checked) + + self.ax1_lostObjScatterItem.hoverLostID = 0 + if not checked: + return + + self.lostObjImage = np.zeros_like(self.currentLab2D) + self.updateLostContoursImage(0) + + def manualAnnotPast_cb(self, checked): + posData = self.data[self.pos_i] + if checked: + for _ in range(3): + self.onEscape( + buttonsToNotUncheck=[self.manualAnnotPastButton], + doAutoRange=False + ) + + self.brushButton.setChecked(True) + self.store_data() + self.manualAnnotState = { + 'editID': self.editIDspinbox.value(), + 'isAutoID': self.autoIDcheckbox.isChecked(), + 'doWarnLostObj': self.warnLostCellsAction.isChecked(), + } + self.autoIDcheckbox.setChecked(False) + self.warnLostCellsAction.setChecked(False) + hoverID = self.getLastHoveredID() + if hoverID == 0: + win = apps.QLineEditDialog( + title='Not hovering any ID', + msg='You are not hovering on any ID.\n' + 'Enter the ID that you want to lock.', + parent=self.host, + isInteger=True, + defaultTxt=self.setBrushID(return_val=True) + ) + win.exec_() + if win.cancel: + self.manualAnnotPastButton.setChecked(False) + return + hoverID = win.EntryID + self.logger.info( + 'Setting manual annotation for ID = ' + f'{hoverID}, at frame n. {posData.frame_i+1}' + ) + self.editIDspinbox.setValue(hoverID) + try: + obj_idx = posData.IDs_idxs[hoverID] + obj = posData.rp[obj_idx] + radius = 0.9 * obj.minor_axis_length / 2 # math.sqrt(obj.area/math.pi)*0.9 + self.brushSizeSpinbox.setValue(round(radius)) + except Exception as err: + pass + + self.manualAnnotState['frame_i_to_restore'] = posData.frame_i + self.manualAnnotState['last_tracked_i'] = ( + self.navigateScrollBar.maximum()-1 + ) + self.ax1.sigRangeChanged.connect(self.highlightManualAnnotMode) + self.ax1.setHighlighted(True, color='green') + else: + self.status_hover_view.set_status_bar_label() + self.autoIDcheckbox.setChecked(self.manualAnnotState['isAutoID']) + self.editIDspinbox.setValue(self.manualAnnotState['editID']) + self.warnLostCellsAction.setChecked( + self.manualAnnotState['doWarnLostObj'] + ) + frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + if frame_to_restore is None: + return + + self.store_data() + self.store_manual_annot_data() + + last_tracked_i_to_restore = self.manualAnnotState['last_tracked_i'] + self.manualAnnotRestoreLastTrackedFrame(last_tracked_i_to_restore) + + self.logger.info( + f'Restoring view to frame n. {posData.frame_i+1}...' + ) + posData.frame_i = frame_to_restore + self.get_data() + self.updateAllImages() + self.updateScrollbars() + self.ax1.sigRangeChanged.disconnect() + self.ax1.setHighlighted(False) + QTimer.singleShot(150, self.autoRange) + + self.setManualAnnotModeEnabledTools(checked) + + def copyLostObjectMask(self, ID: int): + posData = self.data[self.pos_i] + mask = self.lostObjImage == ID + lab2D = self.get_2Dlab(posData.lab) + lab2D[mask] = ID + self.lostObjImage[mask] = 0 + self.set_2Dlab(lab2D) + + def highlightManualAnnotMode(self, viewBox, viewRange): + self.ax1.setHighlighted(True) + + def updateHighlightedAxis(self): + if not self.manualAnnotPastButton.isChecked(): + return + + frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + posData = self.data[self.pos_i] + color = self.view_model.manual_annotation_highlight_color( + current_frame_i=posData.frame_i, + frame_to_restore=frame_to_restore, + ) + + self.ax1.setHighlightingRectItemsColor(color) + + def updateLostNewCurrentIDs(self): + posData = self.data[self.pos_i] + + prev_IDs = self.getPrevFrameIDs() + tracked_lost_IDs = self.getTrackedLostIDs() + curr_IDs = posData.IDs + curr_delRoiIDs = self.getStoredDelRoiIDs() + prev_delRoiIDs = self.getStoredDelRoiIDs(frame_i=posData.frame_i-1) + result = self.view_model.tracking.compute_lost_new_ids( + prev_IDs, + curr_IDs, + current_deleted_roi_ids=curr_delRoiIDs, + previous_deleted_roi_ids=prev_delRoiIDs, + tracked_lost_ids=tracked_lost_IDs, + ) + posData.lost_IDs = result.lost_ids + posData.new_IDs = result.new_ids + posData.old_IDs = prev_IDs + posData.IDs = curr_IDs + + out = ( + result.lost_ids, result.new_ids, result.ids_with_holes, + tracked_lost_IDs, curr_delRoiIDs + ) + return out + + # @exec_time + def highlightLostNew(self): + if self.modeComboBox.currentText() == 'Viewer': + return + + posData = self.data[self.pos_i] + delROIsIDs = self.getDelRoisIDs() + + # self.setAllContoursImages(delROIsIDs=delROIsIDs) + if posData.frame_i == 0: + return + + if not self.annotLostObjsToggle.isChecked(): + return + + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + + if prev_rp is None: + return + + self.setAllLostObjContoursImage(delROIsIDs=delROIsIDs) + self.setAllLostTrackedObjContoursImage(delROIsIDs=delROIsIDs) + + def addLostObjsToLostObjImage(self, lostObj, lostID, force=False): + if not force: + if not self.copyLostObjButton.isChecked(): + return + + obj_slice = self.getObjSlice(lostObj.slice) + obj_image = self.getObjImage(lostObj.image, lostObj.bbox) + self.lostObjImage[obj_slice][obj_image] = lostID + + def highlightHoverLostObj(self, modifiers, event): + if not self.view_model.should_highlight_hover_lost_object( + has_no_modifier=modifiers == Qt.NoModifier, + copy_lost_object_checked=self.copyLostObjButton.isChecked(), + is_exit_event=event.isExit(), + ): + return + + posData = self.data[self.pos_i] + x, y = event.pos() + xdata, ydata = int(x), int(y) + try: + hoverLostID = self.lostObjImage[ydata, xdata] + except IndexError: + return + + self.ax1_lostObjScatterItem.hoverLostID = hoverLostID + if hoverLostID == 0: + self.ax1_lostObjScatterItem.setSize(self.contLineWeight+1) + self.ax1_lostObjScatterItem.setData([], []) + else: + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] + lostObj = prev_rp[prev_IDs_idxs[hoverLostID]] + obj_contours = self.getObjContours(lostObj, all_external=True) + for cont in obj_contours: + xx = cont[:,0] + yy = cont[:,1] + self.ax1_lostObjScatterItem.addPoints(xx, yy) + self.ax1_lostObjScatterItem.setSize(self.contLineWeight+2) + + def annotLostObjsToggled(self, checked): + if not self.isDataLoaded: + return + self.updateAllImages() + + def getPrevFrameIDs(self, current_frame_i=None): + posData = self.data[self.pos_i] + if current_frame_i is None: + current_frame_i = posData.frame_i + + if current_frame_i is None: + return [] + + prev_frame_i = current_frame_i - 1 + prevIDs = posData.allData_li[prev_frame_i]['IDs'] + + if prevIDs: + return prevIDs + + # IDs in previous frame were not stored --> load prev lab from HDD + prev_lab = self.get_labels( + from_store=False, + frame_i=prev_frame_i, + return_copy=False + ) + return self.view_model.label_edits.label_ids_from_labels(prev_lab) + + # @exec_time + def setLostNewOldPrevIDs(self): + posData = self.data[self.pos_i] + if posData.frame_i == 0: + posData.lost_IDs = [] + posData.new_IDs = [] + posData.old_IDs = [] + # posData.multiContIDs = set() + self.titleLabel.setText('Looking good!', color=self.titleColor) + return [] + + # elif self.modeComboBox.currentText() == 'Viewer': + # pass + + out = self.updateLostNewCurrentIDs() + lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs = ( + out + ) + self.setTitleText( + lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs + ) + return curr_delRoiIDs + + def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): + if not IDs: + return htmlTxt_li, htmlTxtFull_li + + if isinstance(IDs, set): + IDs = list(IDs) + + trim_IDs = self.view_model.label_edits.format_trimmed_ids(IDs) + txt = f'{pretxt}: {trim_IDs}' + txt_full = f'{pretxt}:
{IDs}' + + txt = f'{txt}' + txt_full = f'{txt_full}' + + htmlTxt_li.append(txt) + htmlTxtFull_li.append(txt_full) + + return htmlTxt_li, htmlTxtFull_li + + def setTitleText( + self, lost_IDs=None, new_IDs=None, IDs_with_holes=None, + tracked_lost_IDs=None + ): + if self.manualAnnotPastButton.isChecked(): + lockedID = self.editIDspinbox.value() + frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + txt = ( + f'Locked ID {lockedID} ' + f'since frame n. {frame_to_restore+1}' + ) + htmlTxt = f'{txt}' + self.titleLabel.setText(htmlTxt) + return + + mode = self.modeComboBox.currentText() + try: + posData = self.data[self.pos_i] + posData.segm_data[posData.frame_i] + prev_segmented = True + except IndexError: + prev_segmented = False + + if prev_segmented: + htmlTxt_li = [] + htmlTxtFull_li = [] + else: + htmlTxt = f'Never segmented frame. ' + self.titleLabel.setText(htmlTxt) + self.titleLabel.setToolTip(htmlTxt) + return + + if mode != 'Normal division: Lineage tree': + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'IDs lost', 'orange', lost_IDs + ) + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'New IDs', 'red', new_IDs + ) + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'Acc. IDs lost', 'green', + tracked_lost_IDs + ) + + for i, htmlTxtFull in enumerate(htmlTxtFull_li): + htmlTxtFull_li[i] = htmlTxtFull.replace('Acc.', 'Accepted') + + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'IDs with holes', 'red', + IDs_with_holes + ) + else: + try: + cells_with_parent, orphan_cells, lost_cells = ( + self.lineage_tree.export_lin_tree_info(posData.frame_i) + ) + except IndexError or KeyError: + title = 'Processing lineage tree...' + htmlTxt = f'{title}' + self.titleLabel.setText(htmlTxt) + self.titleLabel.setToolTip(htmlTxt) + return + except AttributeError: + title = 'Lineage tree still initializing...' + htmlTxt = f'{title}' + self.titleLabel.setText(htmlTxt) + self.titleLabel.setToolTip(htmlTxt) + return + + parent_cell_txt_raw = [] + if cells_with_parent: + # aggregate same parents + parent_cell_groups = dict() + for cell, parent in cells_with_parent: + if parent not in parent_cell_groups: + parent_cell_groups[parent] = [] + parent_cell_groups[parent].append(cell) + for parent, daughters in parent_cell_groups.items(): + cells_str = ','.join( + [str(daughter) for daughter in daughters] + ) + parent_cell_txt_raw.append(f'({parent}>{cells_str})') + + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'New w/out mother', 'red', + orphan_cells + ) + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'Lost', 'yellow', lost_cells + ) + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'Parent > Cell', 'green', + parent_cell_txt_raw + ) + + if not htmlTxt_li: + title = 'Looking good' + htmlTxt = f'{title}' + self.titleLabel.setText(htmlTxt) + self.titleLabel.setToolTip(htmlTxt) + return + + htmlTxt = ', '.join(htmlTxt_li) + htmlTxtFull = '
'.join(htmlTxtFull_li) + + self.titleLabel.setText(htmlTxt) + self.titleLabel.setToolTip(htmlTxtFull) + + def _copyAllLostObjects_navigateToFrame(self, frame_i): + posData = self.data[self.pos_i] + self.store_data(mainThread=False, autosave=False) + + posData.frame_i = frame_i + self.get_data() + self.tracking(wl_update=False) + self.currentLab2D = self.get_2Dlab(posData.lab) + self.update_rp() + self.updateLostNewCurrentIDs() + self.store_data(mainThread=False, autosave=False) + + self.lostObjContoursImage[:] = 0 + self.lostObjImage[:] = 0 + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] # need to change this when merging with opt. + for lostID in posData.lost_IDs: + obj = prev_rp[prev_IDs_idxs[lostID]] + self.addLostObjsToLostObjImage(obj, lostID, force=True) + + def _copyAllLostObjects_returnToFrame(self, frame_i): + posData = self.data[self.pos_i] + self.store_data(autosave=False, mainThread=False) + posData.frame_i = frame_i + self.get_data() + + def _copyAllLostObjects_refreshRp(self): + self.update_rp(draw=False, wl_update=False) # need to change this when merging with opt. + + @disableWindow + def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): + if not self.copyLostObjButton.isChecked(): + return + + posData = self.data[self.pos_i] + + desc = 'Copying all lost objects...' + + self.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self.mainWin, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(for_future_frame_n+1) + self.progressWin.show(self.app) + + self.copyAllLostObjectsThread = QThread() + + self.copyAllLostObjectsWorker = workers.CopyAllLostObjectsWorker( + self.host, posData, for_future_frame_n, max_overlap_perc + ) + self.copyAllLostObjectsWorker.moveToThread(self.copyAllLostObjectsThread) + + self.copyAllLostObjectsWorker.navigateToFrame.connect( + self._copyAllLostObjects_navigateToFrame, + Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.returnToFrame.connect( + self._copyAllLostObjects_returnToFrame, + Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.copyLostObjectMask.connect( + self.copyLostObjectMask, + Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.refreshRp.connect( + self._copyAllLostObjects_refreshRp, + Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.progressBar.connect( + self.workerUpdateProgressbar + ) + self.copyAllLostObjectsWorker.critical.connect( + self.copyAllLostObjectsWorkerCritical + ) + self.copyAllLostObjectsWorker.finished.connect( + self.copyAllLostObjectsThread.quit + ) + self.copyAllLostObjectsWorker.finished.connect( + self.copyAllLostObjectsWorker.deleteLater + ) + self.copyAllLostObjectsThread.finished.connect( + self.copyAllLostObjectsThread.deleteLater + ) + self.copyAllLostObjectsWorker.finished.connect( + self.copyAllLostObjectsWorkerFinished + ) + + self.copyAllLostObjectsThread.started.connect( + self.copyAllLostObjectsWorker.run + ) + self.copyAllLostObjectsThread.start() + + self.copyAllLostObjectsWorkerLoop = QEventLoop() + self.copyAllLostObjectsWorkerLoop.exec_() + + def copyAllLostObjectsWorkerCritical(self, error): + self.copyAllLostObjectsWorkerLoop.exit() + self.workerCritical(error) + + def copyAllLostObjectsWorkerFinished(self, output): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + if output.get('doReinitLastSegmFrame', False): + self.reInitLastSegmFrame( + from_frame_i=output.get('last_visited_frame_i'), + updateImages=False, + force=True + ) + + if output.get('overlap_warning', False): + self.blinker = qutils.QControlBlink( + self.copyLostObjToolbar.maxOverlapNumberControl, + qparent=self.mainWin + ) + self.blinker.start() + + self.copyAllLostObjectsWorkerLoop.exit() + self.update_rp() + self.updateAllImages() + self.store_data() + + def restoreHoverObjBrush(self): + posData = self.data[self.pos_i] + if self.ax1BrushHoverID in posData.IDs: + obj_idx = posData.IDs_idxs[self.ax1BrushHoverID] + obj = posData.rp[obj_idx] + if not self.isObjVisible(obj.bbox): + return + + self.addObjContourToContoursImage(obj=obj, ax=0) + self.addObjContourToContoursImage(obj=obj, ax=1) + + def hideItemsHoverBrush(self, xy=None, ID=None, force=False): + if xy is not None: + x, y = xy + if x is None: + return + + xdata, ydata = int(x), int(y) + Y, X = self.currentLab2D.shape + + if not self.view_model.point_in_shape( + xdata, ydata, self.currentLab2D.shape + ): + return + + if not self.view_model.should_hide_hover_objects( + brush_auto_hide_checked=self.brushAutoHideCheckbox.isChecked(), + force=force, + ): + return + + posData = self.data[self.pos_i] + + if xy is not None: + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + + if self.ax1_lostObjScatterItem.isVisible(): + self.ax1_lostObjScatterItem.setVisible(False) + + if self.ax1_lostTrackedScatterItem.isVisible(): + self.ax1_lostTrackedScatterItem.setVisible(False) + + if self.ax2_lostObjScatterItem.isVisible(): + self.ax2_lostObjScatterItem.setVisible(False) + + if self.ax2_lostTrackedScatterItem.isVisible(): + self.ax2_lostTrackedScatterItem.setVisible(False) + + # Restore ID previously hovered + if ID != self.ax1BrushHoverID and not self.isMouseDragImg1: + try: + self.restoreHoverObjBrush() + except Exception as e: + self.ax1BrushHoverID = 0 + return + + # Hide items hover ID + if ID != 0: + self.clearObjContour(ID=ID, ax=0) + self.clearObjContour(ID=ID, ax=1) + self.ax1BrushHoverID = ID + else: + self.ax1BrushHoverID = 0 + + def updateBrushCursor(self, x, y, isHoverImg1=True): + if x is None: + return + + xdata, ydata = int(x), int(y) + _img = self.currentLab2D + if not self.view_model.point_in_shape(xdata, ydata, _img.shape): + return + + size = self.brushSizeSpinbox.value()*2 + self.setHoverToolSymbolData( + [x], [y], self.activeBrushCircleCursors(isHoverImg1), + size=size + ) + self.setHoverToolSymbolColor( + xdata, ydata, self.ax2_BrushCirclePen, + self.activeBrushCircleCursors(isHoverImg1), + self.brushButton, brush=self.ax2_BrushCircleBrush + ) + + def setManualAnnotModeEnabledTools(self, enabled): + for action in self.editToolBar.actions(): + toolButton = self.editToolBar.widgetForAction(action) + if toolButton in self.manulAnnotToolButtons: + continue + + toolButton.setDisabled(enabled) + action.setDisabled(enabled) + + def disableNonFunctionalButtons(self): + if not self.view_model.should_disable_non_functional_buttons( + self.isSegm3D + ): + return + + for item in self.functionsNotTested3D: + if hasattr(item, 'action'): + toolButton = item + action = toolButton.action + toolButton.setDisabled(True) + elif hasattr(item, 'toolbar'): + toolbar = item.toolbar + action = item + toolButton = toolbar.widgetForAction(action) + toolButton.setDisabled(True) + else: + action = item + action.setDisabled(True) diff --git a/cellacdc/views/tracking_view.py b/cellacdc/views/tracking_view.py new file mode 100644 index 000000000..e4f9e85f9 --- /dev/null +++ b/cellacdc/views/tracking_view.py @@ -0,0 +1,1382 @@ +"""Qt view adapter for tracking and manual tracking workflows.""" + +from __future__ import annotations + +import cv2 +from functools import partial +from typing import Iterable, List, Set + +import numpy as np +import pyqtgraph as pg +import skimage.measure +from tqdm import tqdm +from qtpy.QtCore import QTimer +from qtpy.QtGui import QFont + +from cellacdc import apps, exception_handler, html_utils, widgets +from cellacdc.trackers.CellACDC import CellACDC_tracker +from cellacdc.viewmodels.tracking_viewmodel import TrackingViewModel + + +font_13px = QFont() +font_13px.setPixelSize(13) + + +class TrackingView: + """Qt-facing adapter for tracking and manual tracking workflows.""" + + LEGACY_METHODS = ( + 'getLastTrackedFrame', + 'get_last_tracked_i', + 'initSegmTrackMode', + 'updateLastCheckedFrameWidgets', + 'resetManualBackgroundItems', + 'enableSmartTrack', + 'trackNewIDtoNewIDsFutureFrame', + 'initRealTimeTracker', + 'realTimeTrackingClicked', + 'repeatTrackingVideo', + 'warnRepeatTrackingVideoOnVisitedFrames', + 'warnRepeatTrackingVideoWithAnnotations', + 'warnTrackerInputNotValid', + 'repeatTracking', + 'separateByLabelling', + 'isFirstTimeOnNextFrame', + 'trackManuallyAddedObject', + 'trackFrameCustomTracker', + 'trackFrame', + 'clearAssignedObjsSecondStep', + 'trackSubsetIDs', + 'doSkipTracking', + 'tracking', + 'handleAdditionalInfoRealTimeTracker', + 'keepOnlyNewIDAssignedObjsSecondStep', + 'updateAssignedObjsAcdcTrackerSecondStep', + 'annotateAssignedObjsAcdcTrackerSecondStep', + 'setTrackedLostCentroids', + 'getTrackedLostIDs', + 'manuallyEditTracking', + 'updateGhostMaskOpacity', + 'addManualTrackingItems', + 'removeManualTrackingItems', + 'addManualBackgroundItems', + 'removeManualBackgroundItems', + 'resetManualBackgroundSpinboxID', + 'initManualBackgroundObject', + 'initGhostObject', + 'clearGhost', + 'clearManualBackgroundAnnotations', + 'clearGhostContour', + 'clearGhostMask', + '_drawGhostContour', + '_drawManualBackgroundObjContour', + '_drawGhostMask', + 'drawManualBackgroundObj', + 'drawManualTrackingGhost', + 'setManualBackgroundImage', + 'setManualBackgrounNextID', + 'clearManualBackgroundObject', + 'addManualBackgroundObject', + 'setManualBackgroundLab', + 'manualTracking_cb', + 'manualBackground_cb', + ) + + def __init__(self, host, view_model: TrackingViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def getLastTrackedFrame(self, posData): + return self.view_model.last_tracked_frame_index( + (data_dict['labels'] for data_dict in posData.allData_li), + ) + + def get_last_tracked_i(self): + posData = self.data[self.pos_i] + return self.view_model.last_tracked_frame_index( + (data_dict['labels'] for data_dict in posData.allData_li), + total_frames=posData.segmSizeT, + ) + + def initSegmTrackMode(self): + posData = self.data[self.pos_i] + last_tracked_i = self.get_last_tracked_i() + + if posData.frame_i > last_tracked_i: + # Prompt user to go to last tracked frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + f'The last visited frame in "Segmentation and Tracking mode" ' + f'is frame {last_tracked_i+1}.\n\n' + f'We recommend to resume from that frame.

' + 'How do you want to proceed?' + ) + goToButton, stayButton = msg.warning( + self.host, 'Go to last visited frame?', txt, + buttonsTexts=( + f'Resume from frame {last_tracked_i+1} (RECOMMENDED)', + f'Stay on current frame {posData.frame_i+1}' + ) + ) + if msg.clickedButton == goToButton: + posData.frame_i = last_tracked_i + self.lastFrameRanOnFirstVisitTools = posData.frame_i + self.get_data() + self.updateAllImages() + self.updateScrollbars() + else: + last_tracked_i = posData.frame_i + current_frame_i = posData.frame_i + self.lastFrameRanOnFirstVisitTools = posData.frame_i + self.logger.info( + f'Storing data up until frame n. {current_frame_i+1}...' + ) + pbar = tqdm(total=current_frame_i+1, ncols=100) + for i in range(current_frame_i): + posData.frame_i = i + self.get_data() + self.store_data(autosave=i==current_frame_i-1) + pbar.update() + pbar.close() + + posData.frame_i = current_frame_i + self.get_data() + + self.highlightLostNew() + self.updateLastCheckedFrameWidgets(last_tracked_i) + + self.isFirstTimeOnNextFrame() + self.initRealTimeTracker() + + def updateLastCheckedFrameWidgets(self, last_tracked_i): + self.navigateScrollBar.setMaximum(last_tracked_i+1) + self.navSpinBox.setMaximum(last_tracked_i+1) + self.lastTrackedFrameLabel.setText( + f'Last checked frame n. = {last_tracked_i+1}' + ) + + def resetManualBackgroundItems(self): + self.initManualBackgroundImage() + self.resetManualBackgroundSpinboxID() + self.drawManualTrackingGhost(self.xHoverImg, self.yHoverImg) + self.drawManualBackgroundObj(self.xHoverImg, self.yHoverImg) + + def enableSmartTrack(self, checked): + posData = self.data[self.pos_i] + # Disable tracking for already visited frames + + if posData.allData_li[posData.frame_i]['labels'] is not None: + trackingEnabled = True + else: + trackingEnabled = False + + if checked: + self.UserEnforced_DisabledTracking = False + self.UserEnforced_Tracking = False + else: + if trackingEnabled: + self.UserEnforced_DisabledTracking = True + self.UserEnforced_Tracking = False + else: + self.UserEnforced_DisabledTracking = False + self.UserEnforced_Tracking = True + + def trackNewIDtoNewIDsFutureFrame(self, newID, newIDmask): + posData = self.data[self.pos_i] + try: + nextLab = posData.allData_li[posData.frame_i+1]['labels'] + except IndexError: + # This is last frame --> there are no future frames + return + + if nextLab is None: + return + + newID_lab = np.zeros_like(posData.lab) + newID_lab[newIDmask] = newID + newLab_rp = [posData.rp[posData.IDs_idxs[newID]]] + newLab_IDs = [newID] + nextRp = posData.allData_li[posData.frame_i+1]['regionprops'] + + tracked_lab = self.trackFrame( + nextLab, nextRp, newID_lab, newLab_rp, newLab_IDs, + assign_unique_new_IDs=False + ) + trackedID = tracked_lab[newID_lab>0][0] + if trackedID == newID: + # Object does not exist in future frame --> do not track + return + + if posData.IDs_idxs.get(trackedID) is not None: + # Tracked ID already exists --> do not track to avoid merging + return + + return trackedID + + def initRealTimeTracker(self, force=False): + for rtTrackerAction in self.trackingAlgosGroup.actions(): + if rtTrackerAction.isChecked(): + break + + aliases = self.view_model.model_registry.real_time_tracker_aliases( + reverse=True + ) + + rtTracker = rtTrackerAction.text() + rtTracker_txt = rtTracker + + if rtTracker in aliases: + rtTracker = aliases[rtTracker] + + if rtTracker == 'Cell-ACDC': + return + if rtTracker == 'YeaZ': + return + + if self.isRealTimeTrackerInitialized and not force: + return + + self.logger.info(f'Initializing {rtTracker_txt} tracker...') + self._rtTrackerName = rtTracker + posData = self.data[self.pos_i] + realTimeTracker, track_frame_params = ( + self.view_model.model_registry.init_tracker( + posData, rtTracker, qparent=self.host, realTime=True + ) + ) + if realTimeTracker is None: + self.logger.info(f'{rtTracker} tracker initialization cancelled.') + return + + self.realTimeTracker = realTimeTracker + self.track_frame_params = track_frame_params + self.logger.info(f'{rtTracker} tracker successfully initialized.') + if 'image_channel_name' in self.track_frame_params: + # Remove the channel name since it was already loaded in init_tracker + del self.track_frame_params['image_channel_name'] + + def realTimeTrackingClicked(self, checked): + # Event called ONLY if the user click on Disable tracking + # NOT called if setChecked is called. This allows to keep track + # of the user choice. This way user con enforce tracking + # NOTE: I know two booleans doing the same thing is overkill + # but the code is more readable when we actually need them + + posData = self.data[self.pos_i] + isRealTimeTrackingDisabled = not checked + + # Turn off smart tracking + self.enableSmartTrackAction.toggled.disconnect() + self.enableSmartTrackAction.setChecked(False) + if isRealTimeTrackingDisabled: + self.UserEnforced_DisabledTracking = True + self.UserEnforced_Tracking = False + else: + txt = html_utils.paragraph(""" + + Do you want to keep tracking always active including on already + visited frames?

+ Note: To re-activate automatic handling of tracking go to
+ Edit --> Smart handling of enabling/disabling tracking. + + """) + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + yesButton, noButton = msg.question( + self.host, 'Keep tracking always active?', txt, + buttonsTexts=('Yes', 'No') + ) + if msg.clickedButton == yesButton: + self.repeatTracking() + self.UserEnforced_DisabledTracking = False + self.UserEnforced_Tracking = True + else: + self.enableSmartTrackAction.setChecked(True) + + @exception_handler + def repeatTrackingVideo(self, checked=False): + posData = self.data[self.pos_i] + win = widgets.selectTrackerGUI( + posData.SizeT, currentFrameNo=posData.frame_i+1 + ) + win.exec_() + if win.cancel: + self.logger.info('Tracking aborted.') + return + + trackerName = win.selectedItemsText[0] + start_n = win.startFrame + stop_n = win.stopFrame + video_to_track = posData.segm_data + for frame_i in range(start_n-1, stop_n): + data_dict = posData.allData_li[frame_i] + lab = data_dict['labels'] + if lab is None: + break + + video_to_track[frame_i] = lab + video_to_track = video_to_track[start_n-1:stop_n] + + self.logger.info(f'Importing {trackerName} tracker...') + self.tracker, self.track_params, init_params = ( + self.view_model.model_registry.init_tracker( + posData, trackerName, qparent=self.host, return_init_params=True + ) + ) + if self.track_params is None: + self.logger.info('Tracking aborted.') + return + + warningText = self.view_model.model_registry.validate_tracker_input( + self.tracker, video_to_track + ) + if warningText is not None: + self.logger.info(warningText) + self.warnTrackerInputNotValid(trackerName, warningText) + return + + if 'image_channel_name' in self.track_params: + # Remove the channel name since it was already loaded in init_tracker + del self.track_params['image_channel_name'] + + track_params_log = { + key: value for key, value in self.track_params.items() + if key != 'image' + } + self.logger.info( + 'Tracking parameters:\n\n' + f'Initialization parameters: {init_params}\n' + f'Track parameters: {track_params_log}' + ) + + last_cca_i = self.get_last_cca_frame_i() + if start_n-2 <= last_cca_i and start_n>1: + proceed = self.warnRepeatTrackingVideoWithAnnotations( + last_cca_i, start_n + ) + if not proceed: + self.logger.info('Tracking aborted.') + return + + self.logger.info(f'Removing annotations from frame n. {start_n}.') + self.resetCcaFuture(start_n-1) + + self.start_n = start_n + self.stop_n = stop_n + + info_txt = f'Tracking from frame n. {start_n} to {stop_n}...' + self.logger.info(info_txt) + + self.progressWin = apps.QDialogWorkerProgress( + title='Tracking', parent=self.host, pbarDesc=info_txt + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(stop_n-start_n) + self.startTrackingWorker(posData, video_to_track) + + def warnRepeatTrackingVideoOnVisitedFrames(self, last_tracked_i, start_n): + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'You are repeating tracking on frames that have already ' + 'been visited/tracked before.

' + 'This will very likely make the annotations wrong.

' + 'If you really want to repeat tracking on the frames before ' + f'{last_tracked_i+1} the annotations from frame ' + f'{start_n} to frame {last_tracked_i+1} ' + 'will be removed.

' + 'Do you want to continue?' + ) + noButton, yesButton = msg.warning( + self.host, 'Repating tracking with annotations!', txt, + buttonsTexts=( + ' No, stop tracking and keep annotations.', + ' Yes, repeat tracking and DELETE annotations.' + ) + ) + if msg.cancel: + return False + + if msg.clickedButton == noButton: + return False + else: + return True + + def warnRepeatTrackingVideoWithAnnotations(self, last_tracked_i, start_n): + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'You are repeating tracking on frames that have cell cycle ' + 'annotations.

' + 'This will very likely make the annotations wrong.

' + 'If you really want to repeat tracking on the frames before ' + f'{last_tracked_i+1} the annotations from frame ' + f'{start_n} to frame {last_tracked_i+1} ' + 'will be removed.

' + 'Do you want to continue?' + ) + noButton, yesButton = msg.warning( + self.host, 'Repating tracking with annotations!', txt, + buttonsTexts=( + ' No, stop tracking and keep annotations.', + ' Yes, repeat tracking and DELETE annotations.' + ) + ) + if msg.cancel: + return False + + if msg.clickedButton == noButton: + return False + else: + return True + + def warnTrackerInputNotValid(self, trackerName, warningText): + msg = widgets.myMessageBox(wrapText=False) + txt = warningText.replace('\n', '
') + txt = html_utils.paragraph( + f'{txt}

' + 'Tracking process will be cancelled. Thank you for your patience!' + ) + msg.warning(self.host, 'Invalid input for tracker', txt) + + def repeatTracking(self): + posData = self.data[self.pos_i] + prev_lab = self.get_2Dlab(posData.lab).copy() + self.tracking(enforce=True, DoManualEdit=False) + if posData.editID_info: + editedIDsInfo = self.view_model.edit_id.manual_edit_conflicts( + posData.lab, posData.editID_info + ) + editedIDsInfoItems = [ + f'ID {oldID} --> {newID}' + for oldID, newID in editedIDsInfo.items() + ] + editIDul = html_utils.to_list(editedIDsInfoItems) + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + You requested to repeat tracking but there are manually + edited IDs (see edited IDs in the details section below) +

+ Do you want to keep these edits or ignore them? + """) + keepManualEditButton = widgets.okPushButton( + 'Keep manually edited IDs' + ) + ignoreButton = widgets.noPushButton( + 'Ignore manually edited IDs' + ) + msg.question( + self.host, 'Repeat tracking mode', txt, + buttonsTexts=(keepManualEditButton, ignoreButton), + detailsText=editIDul + ) + if msg.cancel: + return + if msg.clickedButton == keepManualEditButton: + allIDs = [obj.label for obj in posData.rp] + lab2D = self.get_2Dlab(posData.lab) + self.manuallyEditTracking(lab2D, allIDs) + self.update_rp() + self.setAllTextAnnotations() + self.highlightLostNew() + # self.checkIDsMultiContour() + else: + posData.editID_info = [] + if np.any(posData.lab != prev_lab): + if self.isSnapshot: + self.fixCcaDfAfterEdit('Repeat tracking') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Repeat tracking') + else: + self.updateAllImages() + + def separateByLabelling(self, lab, rp, maxID=None): + """ + Label each single object in posData.lab and if the result is more than + one object then we insert the separated object into posData.lab + """ + setRp = False + posData = self.data[self.pos_i] + if maxID is None: + maxID = max(posData.IDs, default=1) + for obj in rp: + lab_obj = skimage.measure.label(obj.image) + rp_lab_obj = skimage.measure.regionprops(lab_obj) + if len(rp_lab_obj)<=1: + continue + lab_obj += maxID + _slice = obj.slice # self.getObjSlice(obj.slice) + _objMask = obj.image # self.getObjImage(obj.image) + lab[_slice][_objMask] = lab_obj[_objMask] + setRp = True + maxID += 1 + return setRp + + def isFirstTimeOnNextFrame(self): + posData = self.data[self.pos_i] + posData.last_tracked_i = self.navigateScrollBar.maximum()-1 + return posData.frame_i > posData.last_tracked_i + + def trackManuallyAddedObject( + self, added_IDs: List[int] | int | Set[int], isNewID: bool, + wl_update:bool=True, wl_track_og_curr:bool=False + ): + """Track object added manually on frame that was already visited. + + Parameters + ---------- + added_IDs : int | list of int | set + ID or IDs of the object added manually + isNewID : bool + If True, the added object is new + + Notes + ----- + This method tracks the new added object against the previous frame + labels. If the ID determined by tracking is different from `added_ID` + (meaning that tracking thinks the new ID should be changed to the + tracked ID) and the tracked ID is not already existing (which would + otherwise causing merging) we assign the tracked ID to the object with + `added_ID`. + + If instead the tracked ID is the same as `added_ID` we are dealing + with a truly new object. In this case we want to try tracking it against + the next frame (since the next frame was already validated). + As before, we assign the tracked ID (against the next frame) only if + not already existing in current frame (to avoid merging). + """ + if self.isSnapshot: + return + + if not isNewID: + return + + if isinstance(added_IDs, int): + added_IDs = [added_IDs] + + posData = self.data[self.pos_i] + tracked_lab = self.tracking( + enforce=True, assign_unique_new_IDs=False, return_lab=True, + IDs=added_IDs + ) + self.clearAssignedObjsSecondStep() + if tracked_lab is None: + return + + # Track only new object + prevIDs = posData.allData_li[posData.frame_i-1]['IDs'] + + # mask = np.zeros(posData.lab.shape, dtype=bool) + update_rp = False + + for added_ID in added_IDs: + # try: + # obj = posData.rp[added_ID] # ID not present + # mask[obj.slice][obj.image] = True + + # except IndexError as err: + mask = posData.lab == added_ID + try: + trackedID = tracked_lab[mask][0] + except IndexError as err: + # added_ID is not present + continue + + isTrackedIDalreadyPresentAndNotNew = ( + posData.IDs_idxs.get(trackedID) is not None + and added_ID != trackedID + ) + if isTrackedIDalreadyPresentAndNotNew: + continue + + isTrackedIDinPrevIDs = trackedID in prevIDs + if isTrackedIDinPrevIDs: + posData.lab[mask] = trackedID + else: + # New object where we can try to track against next frame + trackedID = self.trackNewIDtoNewIDsFutureFrame(added_ID, mask) + if trackedID is None: + self.clearAssignedObjsSecondStep() + continue + posData.lab[mask] = trackedID + + self.keepOnlyNewIDAssignedObjsSecondStep(trackedID) + update_rp = True + + if update_rp: + self.update_rp(wl_update=wl_update) + + def trackFrameCustomTracker( + self, prev_lab, currentLab, IDs=None, unique_ID=None + ): + if unique_ID is None: + unique_ID = self.setBrushID() + try: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, + unique_ID=unique_ID, + IDs=IDs, + **self.track_frame_params, + ) + except TypeError as err: + if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: + try: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, IDs=IDs, + **self.track_frame_params + ) + except TypeError as err: + if str(err).find('an unexpected keyword argument \'IDs\'') != -1: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, + **self.track_frame_params) + else: + raise err + elif str(err).find('an unexpected keyword argument \'IDs\'') != -1: + try: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, + unique_ID=unique_ID, + **self.track_frame_params + ) + except TypeError as err: + if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, + **self.track_frame_params + ) + else: + raise err + else: + raise err + return tracked_result + + def trackFrame( + self, prev_lab, prev_rp, curr_lab, curr_rp, curr_IDs, + assign_unique_new_IDs=True, IDs=None, unique_ID=None + ): + if self.trackWithAcdcAction.isChecked(): + tracked_result = CellACDC_tracker.track_frame( + prev_lab, prev_rp, curr_lab, curr_rp, + IDs_curr_untracked=curr_IDs, + setBrushID_func=self.setBrushID, + posData=self.data[self.pos_i], + assign_unique_new_IDs=assign_unique_new_IDs, + IDs=IDs, + unique_ID=unique_ID + ) + elif self.trackWithYeazAction.isChecked(): + tracked_result = self.tracking_yeaz.correspondence( + prev_lab, curr_lab, use_modified_yeaz=True, + use_scipy=True + ) + else: + tracked_result = self.trackFrameCustomTracker( + prev_lab, curr_lab, IDs=IDs, unique_ID=unique_ID + ) + + # Check if tracker also returns additional info + if isinstance(tracked_result, tuple): + tracked_lab, tracked_lost_IDs = tracked_result + self.handleAdditionalInfoRealTimeTracker(prev_rp, tracked_lost_IDs) + else: + tracked_lab = tracked_result + + return tracked_lab + + def clearAssignedObjsSecondStep(self): + posData = self.data[self.pos_i] + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + + def trackSubsetIDs(self, subsetIDs: Iterable[int]): + posData = self.data[self.pos_i] + if posData.frame_i == 0: + return + + subsetLab = np.zeros_like(posData.lab) + for subsetID in subsetIDs: + subsetLab[posData.lab == subsetID] = subsetID + + prev_lab = posData.allData_li[posData.frame_i-1]['labels'] + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + tracked_lab = self.trackFrame( + prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, + assign_unique_new_IDs=True + ) + doUpdateRp = False + for subsetID in subsetIDs: + subsetIDmask = posData.lab == subsetID + trackedID = tracked_lab[subsetIDmask][0] + if trackedID == subsetID: + continue + + is_manually_edited = False + for y, x, new_ID in posData.editID_info: + if new_ID == subsetID: + # Do not track because it was manually edited + break + + posData.lab[subsetIDmask] = tracked_lab[subsetIDmask] + doUpdateRp = True + + if not doUpdateRp: + return + + self.update_rp() + + def doSkipTracking(self, against_next: bool, enforce: bool): + if self.isSnapshot: + return True + + mode = str(self.modeComboBox.currentText()) + if mode != 'Segmentation and Tracking': + return True + + if self.UserEnforced_DisabledTracking: + return True + + if not self.realTimeTrackingToggle.isChecked(): + return True + + posData = self.data[self.pos_i] + if against_next: + reference_lab = posData.allData_li[posData.frame_i+1]['labels'] + if reference_lab is None: + # Next frame never visited --> cannot track against next + return True + + if posData.frame_i == posData.SizeT - 1: + # Last frame --> cannot track against next + return True + + else: + # check that we are not on the last frame + if posData.frame_i == 0: + return True + + if enforce or self.UserEnforced_Tracking: + # Enforce even if not last visited frame + return False + + is_first_time_on_next_frame = self.isFirstTimeOnNextFrame() + skip_tracking = not is_first_time_on_next_frame + + return skip_tracking + + # @exec_time + @exception_handler + def tracking( + self, enforce=False, DoManualEdit=True, + storeUndo=False, prev_lab=None, prev_rp=None, + return_lab=False, assign_unique_new_IDs=True, + separateByLabel=True, wl_update=True, + IDs=None, against_next=False, + ): + posData = self.data[self.pos_i] + + if self.doSkipTracking(against_next, enforce): + self.setLostNewOldPrevIDs() + return + + """Tracking starts here""" + staturBarLabelText = self.statusBarLabel.text() + self.statusBarLabel.setText('Tracking...') + + if storeUndo: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + # First separate by labelling + if separateByLabel: + maxID = max(posData.IDs, default=1) + setRp = self.view_model.label_edits.split_connected_components( + posData.lab, + regionprops=posData.rp, + max_id=maxID, + ) + if setRp: + self.update_rp(wl_update=wl_update, ) + + if prev_lab is None: + if not against_next: + prev_lab = posData.allData_li[posData.frame_i-1]['labels'] + else: + prev_lab = posData.allData_li[posData.frame_i+1]['labels'] + if prev_rp is None: + if not against_next: + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + else: + prev_rp = posData.allData_li[posData.frame_i+1]['regionprops'] + + unique_ID = None + if posData.frame_i < self.get_last_tracked_i(): + unique_ID = self.setBrushID(return_val=True) + + tracked_lab = self.trackFrame( + prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, + assign_unique_new_IDs=assign_unique_new_IDs, IDs=IDs, + unique_ID=unique_ID + ) + + if DoManualEdit: + # Correct tracking with manually changed IDs + rp = skimage.measure.regionprops(tracked_lab) + IDs = [obj.label for obj in rp] + self.manuallyEditTracking(tracked_lab, IDs) + + if return_lab: + QTimer.singleShot(50, partial( + self.statusBarLabel.setText, staturBarLabelText + )) + return tracked_lab + + # Update labels, regionprops and determine new and lost IDs + posData.lab = tracked_lab + self.update_rp(wl_update=wl_update, ) + self.setAllTextAnnotations() + QTimer.singleShot(50, partial( + self.statusBarLabel.setText, staturBarLabelText + )) + + def handleAdditionalInfoRealTimeTracker(self, prev_rp, *args): + if self._rtTrackerName == 'CellACDC_normal_division': + tracked_lost_IDs = args[0] + self.setTrackedLostCentroids(prev_rp, tracked_lost_IDs) + elif self._rtTrackerName == 'CellACDC_2steps': + if args[0] is None: + return + posData = self.data[self.pos_i] + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = args[0] + + def keepOnlyNewIDAssignedObjsSecondStep(self, trackedID): + posData = self.data[self.pos_i] + annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) + + if annotInfo is None: + return + + new_objs_1st_step, lost_objs_1st_step = annotInfo + correct_new_objs, correct_lost_objs = [], [] + for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): + newObj_ID = posData.lab[newObj.slice][newObj.image][0] + if newObj_ID != trackedID: + continue + + correct_new_objs.append(newObj) + correct_lost_objs.append(lostObj) + + if not correct_new_objs: + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + else: + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( + correct_new_objs, correct_lost_objs + ) + # self.annotateAssignedObjsAcdcTrackerSecondStep() + + def updateAssignedObjsAcdcTrackerSecondStep(self, newID): + posData = self.data[self.pos_i] + annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) + if annotInfo is None: + return + + new_objs_1st_step, lost_objs_1st_step = annotInfo + correct_new_objs, correct_lost_objs = [], [] + for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): + newObj_ID = posData.lab[newObj.slice][newObj.image][0] + if newObj_ID == newID: + # The ID of the new object tracked with 2nd step was + # manually edit --> do not annotate its linking to lost obj anymore + continue + correct_new_objs.append(newObj) + correct_lost_objs.append(lostObj) + + if not correct_new_objs: + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + else: + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( + correct_new_objs, correct_lost_objs + ) + self.annotateAssignedObjsAcdcTrackerSecondStep() + + def annotateAssignedObjsAcdcTrackerSecondStep(self): + posData = self.data[self.pos_i] + annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) + if annotInfo is None: + return + + new_objs_1st_step, lost_objs_1st_step = annotInfo + for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): + allContours = self.getObjContours(lostObj, all_external=True) + for objContours in allContours: + isObjVisible = self.isObjVisible(newObj.bbox) + if not isObjVisible: + continue + xx = objContours[:,0] + 0.5 + yy = objContours[:,1] + 0.5 + self.yellowContourScatterItem.addPoints(xx, yy) + + y1, x1 = self.getObjCentroid(lostObj.centroid) + y2, x2 = self.getObjCentroid(newObj.centroid) + xx, yy = self.view_model.geometry.line_coords( + y1, x1, y2, x2, dashed=False + ) + self.ax1_oldMothBudLinesItem.addPoints(xx, yy) + + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + + def setTrackedLostCentroids(self, prev_rp, tracked_lost_IDs): + """Store centroids of those IDs the tracker decided is fine to lose + (e.g., upon standard cell division the ID of the mother is fine) + + Parameters + ---------- + prev_rp : skimage.measure.RegionProperties + List of region properties of the object in previous frame + tracked_lost_IDs : iterable + List-like container of the IDs that is fine to lose from previous + frame to current frame + + Note + ---- + This function stores the centroids because the user could change IDs + in multiple ways. Storing centroids is more robust. + """ + posData = self.data[self.pos_i] + frame_i = posData.frame_i + centroids = ( + self.view_model.tracked_lost_centroids_from_regionprops( + prev_rp, + tracked_lost_IDs, + ) + ) + if not centroids: + return + + try: + posData.tracked_lost_centroids[frame_i].update(centroids) + except KeyError: + posData.tracked_lost_centroids[frame_i] = centroids + + def getTrackedLostIDs(self, prev_lab=None, IDs_in_frames=None, frame_i=None): + trackedLostIDs = set() + posData = self.data[self.pos_i] + if self.isExportingVideo: + posData.trackedLostIDs = trackedLostIDs + return trackedLostIDs + + if frame_i is None: + frame_i = posData.frame_i + + if prev_lab is None: + prev_lab = self.get_labels( + from_store=True, + frame_i=posData.frame_i-1, + return_existing=False, + return_copy=False + ) + + if IDs_in_frames is None: + IDs_in_frames = posData.IDs + + try: + tracked_lost_centroids = posData.tracked_lost_centroids[frame_i] + except KeyError: + tracked_lost_centroids = set() + + result = self.view_model.tracked_lost_ids_from_centroids( + prev_lab, + tracked_lost_centroids, + IDs_in_frames, + ) + posData.tracked_lost_centroids[frame_i] = result.remaining_centroids + posData.trackedLostIDs = result.lost_ids + + return result.lost_ids + + def manuallyEditTracking(self, tracked_lab, allIDs): + posData = self.data[self.pos_i] + result = self.view_model.edit_id.apply_manual_edit_tracking( + tracked_lab, + posData.editID_info, + allIDs, + ) + posData.editID_info = result.remaining_edit_info + + def updateGhostMaskOpacity(self, alpha_percentage=None): + if alpha_percentage is None: + alpha_percentage = ( + self.manualTrackingToolbar.ghostMaskOpacitySpinbox.value() + ) + alpha = alpha_percentage/100 + self.ghostMaskItemLeft.setOpacity(alpha) + self.ghostMaskItemRight.setOpacity(alpha) + + def addManualTrackingItems(self): + self.ghostContourItemLeft.addToPlotItem() + self.ghostContourItemRight.addToPlotItem() + + self.ghostMaskItemLeft.addToPlotItem() + self.ghostMaskItemRight.addToPlotItem() + + Y, X = self.img1.image.shape[:2] + self.ghostMaskItemLeft.initImage((Y, X)) + self.ghostMaskItemRight.initImage((Y, X)) + + self.updateGhostMaskOpacity() + + def removeManualTrackingItems(self): + self.ghostContourItemLeft.removeFromPlotItem() + self.ghostContourItemRight.removeFromPlotItem() + + self.ghostMaskItemLeft.removeFromPlotItem() + self.ghostMaskItemRight.removeFromPlotItem() + + def addManualBackgroundItems(self): + self.manualBackgroundObjItem.addToPlotItem() + self.ax1.addItem(self.manualBackgroundImageItem) + + def removeManualBackgroundItems(self): + self.manualBackgroundObjItem.removeFromPlotItem() + self.ax1.removeItem(self.manualBackgroundImageItem) + + def resetManualBackgroundSpinboxID(self): + if not self.manualBackgroundButton.isChecked(): + self.manualBackgroundObj = None + return + + posData = self.data[self.pos_i] + minID = min(posData.IDs, default=0) + self.manualBackgroundToolbar.spinboxID.setValue(minID) + + def initManualBackgroundObject(self, ID=None): + if not self.manualBackgroundButton.isChecked(): + self.manualBackgroundObj = None + return + + if ID is None: + ID = self.manualBackgroundToolbar.spinboxID.value() + + posData = self.data[self.pos_i] + if ID not in posData.IDs: + self.manualBackgroundObj = None + self.manualBackgroundToolbar.showWarning( + f'The ID {ID} does not exist' + ) + self.manualBackgroundObjItem.clear() + return + + ID_idx = posData.IDs_idxs[ID] + self.manualBackgroundObj = posData.rp[ID_idx] + + self.manualBackgroundToolbar.clearInfoText() + self.manualBackgroundObj.contour = self.getObjContours( + self.manualBackgroundObj, local=True + ) + xx_contour = self.manualBackgroundObj.contour[:,0] + yy_contour = self.manualBackgroundObj.contour[:,1] + self.manualBackgroundObj.xx_contour = xx_contour + self.manualBackgroundObj.yy_contour = yy_contour + + def initGhostObject(self, ID=None): + mode = self.modeComboBox.currentText() + if mode != 'Segmentation and Tracking': + self.ghostObject = None + return + + if not self.manualTrackingButton.isChecked(): + self.ghostObject = None + return + + if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): + self.ghostObject = None + return + + if ID is None: + ID = self.manualTrackingToolbar.spinboxID.value() + + posData = self.data[self.pos_i] + if posData.frame_i == 0: + self.ghostObject = None + return + + prevFrameRp = posData.allData_li[posData.frame_i-1]['regionprops'] + if prevFrameRp is None: + self.ghostObject = None + return + + for obj in prevFrameRp: + if obj.label != ID: + continue + self.ghostObject = obj + break + else: + self.ghostObject = None + self.manualTrackingToolbar.showWarning( + f'The ID {ID} does not exist in previous frame ' + '--> starting a new track.' + ) + return + + self.manualTrackingToolbar.clearInfoText() + + self.ghostObject.contour = self.getObjContours( + self.ghostObject, local=True + ) + self.ghostObject.xx_contour = self.ghostObject.contour[:,0] + self.ghostObject.yy_contour = self.ghostObject.contour[:,1] + + self.ghostMaskItemLeft.initLookupTable(self.lut[ID]) + self.ghostMaskItemRight.initLookupTable(self.lut[ID]) + + def clearGhost(self): + self.clearGhostContour() + self.clearGhostMask() + + def clearManualBackgroundAnnotations(self): + try: + for textItem in self.manualBackgroundTextItems.values(): + textItem.setText('') + except Exception as error: + pass + + def clearGhostContour(self): + self.ghostContourItemLeft.clear() + self.ghostContourItemRight.clear() + self.manualBackgroundObjItem.clear() + + def clearGhostMask(self): + self.ghostMaskItemLeft.clear() + self.ghostMaskItemRight.clear() + + def _drawGhostContour(self, x, y): + if self.ghostObject is None: + return + + ID = self.ghostObject.label + yc, xc = self.ghostObject.local_centroid + Dx = x-xc + Dy = y-yc + xx = self.ghostObject.xx_contour + Dx + yy = self.ghostObject.yy_contour + Dy + self.ghostContourItemLeft.setData( + xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) + self.ghostContourItemRight.setData( + xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) + + def _drawManualBackgroundObjContour(self, x, y): + if self.manualBackgroundObj is None: + return + + ID = self.manualBackgroundObj.label + yc, xc = self.manualBackgroundObj.local_centroid + Dx = x-xc + Dy = y-yc + xx = self.manualBackgroundObj.xx_contour + Dx + yy = self.manualBackgroundObj.yy_contour + Dy + self.manualBackgroundObjItem.setData( + xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) + + def _drawGhostMask(self, x, y): + if self.ghostObject is None: + return + + self.clearGhostMask() + ID = self.ghostObject.label + h, w = self.ghostObject.image.shape[-2:] + yc, xc = self.ghostObject.local_centroid + Dx = int(x-xc) + Dy = int(y-yc) + bbox = ((Dy, Dy+h), (Dx, Dx+w)) + + Y, X = self.currentLab2D.shape + slices = self.view_model.geometry.local_to_global_slices(bbox, (Y, X)) + slice_global_to_local, slice_crop_local = slices + + obj_image = self.ghostObject.image[slice_crop_local] + + self.ghostMaskItemLeft.image[slice_global_to_local][obj_image] = ID + self.ghostMaskItemLeft.updateGhostImage( + fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) + + self.ghostMaskItemRight.image[slice_global_to_local][obj_image] = ID + self.ghostMaskItemRight.updateGhostImage( + fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) + + def drawManualBackgroundObj(self, x, y): + if x is None or y is None: + self.clearGhost() + return + + self._drawManualBackgroundObjContour(x, y) + + def drawManualTrackingGhost(self, x, y): + if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): + return + + if x is None or y is None: + self.clearGhost() + return + + if self.manualTrackingToolbar.ghostContourRadiobutton.isChecked(): + self._drawGhostContour(x, y) + else: + self._drawGhostMask(x, y) + + def setManualBackgroundImage(self): + if not self.manualBackgroundButton.isChecked(): + return + + posData = self.data[self.pos_i] + if not hasattr(posData, 'manualBackgroundImage'): + self.initManualBackgroundImage() + + contours = [] + for obj in skimage.measure.regionprops(posData.manualBackgroundLab): + obj_contours = self.getObjContours(obj, all_external=True) + contours.extend(obj_contours) + textItem = self.manualBackgroundTextItems[obj.label] + textItem.setText(f'{obj.label}') + self.ax1.addItem(textItem) + yc, xc = obj.centroid + textItem.setPos(xc, yc) + + cv2.drawContours( + posData.manualBackgroundImage, contours, -1, (255, 0, 0, 200), 1 + ) + self.manualBackgroundImageItem.setImage(posData.manualBackgroundImage) + + def setManualBackgrounNextID(self): + posData = self.data[self.pos_i] + currentID = self.manualBackgroundObj.label + idx = posData.IDs_idxs[currentID] + next_idx = idx + 1 + if next_idx >= len(posData.IDs): + return + next_ID = posData.IDs[next_idx] + self.manualBackgroundToolbar.spinboxID.setValue(next_ID) + + def clearManualBackgroundObject(self, ID): + posData = self.data[self.pos_i] + mask = posData.manualBackgroundLab==ID + posData.manualBackgroundImage[mask, :] = 0 + posData.manualBackgroundLab[mask] = 0 + + def addManualBackgroundObject(self, x, y): + posData = self.data[self.pos_i] + + if not hasattr(self, 'manualBackgroundObj'): + self.initManualBackgroundObject() + + Y, X = self.currentLab2D.shape + ymin, xmin, ymax, xmax = self.manualBackgroundObj.bbox + width, height = xmax-xmin, ymax-ymin + yc, xc = self.manualBackgroundObj.local_centroid + xstart, ystart = round(x-xc), round(y-yc) + xstart = xstart if xstart >= 0 else 0 + ystart = ystart if ystart >= 0 else 0 + + xend = xstart+width + yend = ystart+height + xend = xend if xend <= X else X + yend = yend if yend <= Y else Y + + width = xend-xstart + height = yend-ystart + + obj_image = self.manualBackgroundObj.image[:height, :width] + obj_slice = (slice(ystart, yend), slice(xstart, xend)) + ID = self.manualBackgroundObj.label + self.clearManualBackgroundObject(ID) + posData.manualBackgroundLab[obj_slice][obj_image] = ID + + if ID in self.manualBackgroundTextItems: + self.manualBackgroundTextItems[ID].setPos(x, y) + return + + textItem = pg.TextItem( + text=str(ID), color='r', anchor=(0.5, 0.5) + ) + textItem.setFont(font_13px) + textItem.setPos(x, y) + self.manualBackgroundTextItems[ID] = textItem + + self.ax1.addItem(textItem) + + def setManualBackgroundLab(self, load_from_store=False, debug=True): + posData = self.data[self.pos_i] + if posData.manualBackgroundLab is None: + self.initManualBackgroundImage() + + for obj in skimage.measure.regionprops(posData.manualBackgroundLab): + textItem = pg.TextItem(text='', color='r', anchor=(0.5, 0.5)) + if obj.label in self.manualBackgroundTextItems: + continue + self.manualBackgroundTextItems[obj.label] = textItem + + def manualTracking_cb(self, checked): + self.manualTrackingToolbar.setVisible(checked) + if checked: + self.realTimeTrackingToggle.previousStatus = ( + self.realTimeTrackingToggle.isChecked() + ) + self.realTimeTrackingToggle.setChecked(False) + self.UserEnforced_DisabledTracking_previousStatus = ( + self.UserEnforced_DisabledTracking + ) + self.UserEnforced_Tracking_previousStatus = ( + self.UserEnforced_Tracking + ) + + self.UserEnforced_DisabledTracking = True + self.UserEnforced_Tracking = False + self.initGhostObject() + self.addManualTrackingItems() + else: + self.realTimeTrackingToggle.setChecked( + self.realTimeTrackingToggle.previousStatus + ) + self.UserEnforced_DisabledTracking = ( + self.UserEnforced_DisabledTracking_previousStatus + ) + self.UserEnforced_Tracking = ( + self.UserEnforced_Tracking_previousStatus + ) + self.removeManualTrackingItems() + self.clearGhost() + + def manualBackground_cb(self, checked): + if checked: + posData = self.data[self.pos_i] + minID = min(posData.IDs, default=0) + if minID == self.manualBackgroundToolbar.spinboxID.value(): + self.initManualBackgroundObject() + else: + self.manualBackgroundToolbar.spinboxID.setValue(minID) + # self.initManualBackgroundObject() + # self.initManualBackgroundImage() + self.addManualBackgroundItems() + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.manualBackgroundButton) + self.connectLeftClickButtons() + self.updateAllImages() + else: + self.removeManualTrackingItems() + self.clearGhost() + self.clearManualBackgroundAnnotations() + self.manualBackgroundToolbar.setVisible(checked) diff --git a/cellacdc/views/undo_redo_view.py b/cellacdc/views/undo_redo_view.py new file mode 100644 index 000000000..268a3e65e --- /dev/null +++ b/cellacdc/views/undo_redo_view.py @@ -0,0 +1,432 @@ +"""Qt view adapter for undo, redo, and future-frame propagation.""" + +from __future__ import annotations + +import uuid + +from cellacdc import apps, html_utils, widgets +from cellacdc.viewmodels.undo_redo_viewmodel import UndoRedoViewModel + + +class UndoRedoView: + """Qt-facing adapter around undo/redo actions and state restoration.""" + + LEGACY_METHODS = ( + 'clearUndoQueue', + 'askPropagateChangePast', + 'propagateMergeObjsPast', + 'propagateChange', + 'addCcaState', + 'addCurrentState', + 'getCurrentState', + 'storeUndoRedoStates', + 'storeUndoRedoCca', + 'undoCustomAnnotation', + 'UndoCca', + 'undo', + 'redo', + ) + + def __init__(self, host, view_model: UndoRedoViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def clearUndoQueue(self): + posData = self.data[self.pos_i] + self.UndoCount = 0 + self.redoAction.setEnabled(False) + self.undoAction.setEnabled(False) + posData.UndoRedoStates = self.view_model.empty_frame_stacks( + posData.SizeT + ) + posData.UndoRedoCcaStates = self.view_model.empty_frame_stacks( + posData.SizeT + ) + if hasattr(self, 'undoAddPointQueueMapper'): + self.undoAddPointQueueMapper = ( + self.view_model.empty_add_point_queue() + ) + + def askPropagateChangePast(self, change_txt): + txt = html_utils.paragraph(f""" + Do you want to propagate the change "{change_txt}" to the past frames? + """) + msg = widgets.myMessageBox(wrapText=False) + yesButton, _ = msg.question( + self.host, 'Propagate change to past frames', txt, + buttonsTexts=('Yes', 'No') + ) + return msg.clickedButton == yesButton + + def propagateMergeObjsPast(self, IDs_to_merge): + self.store_data(autosave=False) + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + for past_frame_i in range(posData.frame_i-1, -1, -1): + posData.frame_i = past_frame_i + self.get_data() + + IDs = posData.allData_li[past_frame_i]['IDs'] + stop_loop = False + for ID in IDs_to_merge: + if ID not in IDs: + stop_loop = True + break + + if ID == 0: + continue + posData.lab[posData.lab==ID] = self.firstID + self.update_rp() + + self.store_data(autosave=False) + + if stop_loop: + break + + posData.frame_i = current_frame_i + self.get_data() + + def propagateChange( + self, modID, modTxt, doNotShow, UndoFutFrames, + applyFutFrames, applyTrackingB=False, force=False + ): + """ + This function determines whether there are already visited future frames + that contains "modID". If so, it triggers a pop-up asking the user + what to do (propagate change to future frames o not) + """ + posData = self.data[self.pos_i] + # Do not check the future for the last frame + if posData.frame_i+1 == posData.SizeT: + # No future frames to propagate the change to + return False, False, None, doNotShow + + includeUnvisited = posData.includeUnvisitedInfo.get(modTxt, False) + future_scan = self.host.view_model.tracking.scan_future_id_propagation( + modID, + current_frame_i=posData.frame_i, + frame_labels=( + data_dict['labels'] for data_dict in posData.allData_li + ), + fallback_frame_labels=posData.segm_data, + include_unvisited=includeUnvisited, + total_frames=posData.SizeT, + ) + last_tracked_i = future_scan.last_tracked_i + + if last_tracked_i == posData.frame_i and not includeUnvisited: + # No future frames to propagate the change to + return False, False, None, doNotShow + + if not future_scan.has_affected_future_ids and not force: + # There are future frames but they are not affected by the change + return UndoFutFrames, False, None, doNotShow + + # Ask what to do unless the user has previously checked doNotShowAgain + if doNotShow: + endFrame_i = last_tracked_i + if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': + self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) + return UndoFutFrames, applyFutFrames, endFrame_i, doNotShow + else: + addApplyAllButton = ( + modTxt == 'Delete ID' or modTxt == 'Edit ID' + or modTxt == 'Assign new ID' + ) + ffa = apps.FutureFramesAction_QDialog( + posData.frame_i+1, last_tracked_i, modTxt, + applyTrackingB=applyTrackingB, parent=self.host, + addApplyAllButton=addApplyAllButton + ) + ffa.exec_() + decision = ffa.decision + + if decision is None: + return None, None, None, doNotShow + + endFrame_i = ffa.endFrame_i + doNotShowAgain = ffa.doNotShowCheckbox.isChecked() + askAction = self.askHowFutureFramesActions[modTxt] + askAction.setChecked( not doNotShowAgain) + askAction.setDisabled(False) + + self.onlyTracking = False + if decision == 'apply_and_reinit': + UndoFutFrames = True + applyFutFrames = False + elif decision == 'apply_and_NOTreinit': + UndoFutFrames = False + applyFutFrames = False + elif decision == 'apply_to_all_visited': + UndoFutFrames = False + applyFutFrames = True + elif decision == 'only_tracking': + UndoFutFrames = False + applyFutFrames = True + self.onlyTracking = True + elif decision == 'apply_to_all': + UndoFutFrames = False + applyFutFrames = True + posData.includeUnvisitedInfo[modTxt] = True + + if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': + self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) + return UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain + + def addCcaState(self, frame_i, cca_df, undoId): + posData = self.data[self.pos_i] + posData.UndoRedoCcaStates[frame_i].insert( + 0, {'id': undoId, 'cca_df': cca_df.copy()} + ) + + def addCurrentState(self, storeImage=False, storeOnlyZoom=False): + posData = self.data[self.pos_i] + if posData.cca_df is not None: + cca_df = posData.cca_df.copy() + else: + cca_df = None + + if storeImage: + image = self.img1.image.copy() + else: + image = None + + if storeOnlyZoom: + labels, crop_slice = self.host.view_model.geometry.crop_2d( + self.currentLab2D, self.ax1.viewRange(), tolerance=10, + return_copy=False + ) + if self.isSegm3D: + z = self.z_lab(checkIfProj=True) + if z is None: + z_slice = slice(0, len(posData.lab)) + crop_slice = (z_slice, *crop_slice) + labels = posData.lab[crop_slice].copy() + else: + z_slice = z + crop_slice = (z_slice, *crop_slice) + labels = labels.copy() + else: + labels = labels.copy() + else: + labels = posData.lab.copy() + crop_slice = None + + state = { + 'image': image, + 'labels': labels, + 'editID_info': posData.editID_info.copy(), + 'binnedIDs': posData.binnedIDs.copy(), + 'keptObejctsIDs': self.keptObjectsIDs.copy(), + 'ripIDs': posData.ripIDs.copy(), + 'cca_df': cca_df, + 'crop_slice': crop_slice + } + posData.UndoRedoStates[posData.frame_i].insert(0, state) + + # posData.storedLab = np.array(posData.lab, order='K', copy=True) + # self.storeStateWorker.callbackOnDone = callbackOnDone + # self.storeStateWorker.enqueue(posData, self.img1.image) + + def getCurrentState(self): + posData = self.data[self.pos_i] + i = posData.frame_i + c = self.UndoCount + state = posData.UndoRedoStates[i][c] + if state['image'] is None: + image_left = None + else: + image_left = state['image'].copy() + + crop_slice = state['crop_slice'] + if crop_slice is None: + posData.lab = state['labels'].copy() + elif self.isSegm3D: + z_slice, slice_y, slice_x = crop_slice + posData.lab[..., z_slice, slice_y, slice_x] = state['labels'].copy() + else: + slice_y, slice_x = crop_slice + posData.lab[..., slice_y, slice_x] = state['labels'].copy() + + posData.editID_info = state['editID_info'].copy() + posData.binnedIDs = state['binnedIDs'].copy() + posData.ripIDs = state['ripIDs'].copy() + self.keptObjectsIDs = state['keptObejctsIDs'].copy() + cca_df = state['cca_df'] + if cca_df is not None: + posData.cca_df = state['cca_df'].copy() + else: + posData.cca_df = None + return image_left + + # @exec_time + def storeUndoRedoStates( + self, UndoFutFrames, storeImage=False, storeOnlyZoom=False + ): + posData = self.data[self.pos_i] + if UndoFutFrames: + # Since we modified current frame all future frames that were already + # visited are not valid anymore. Undo changes there + self.reInitLastSegmFrame(updateImages=False) + + # Keep only 5 Undo/Redo states + self.view_model.trim_label_states( + posData.UndoRedoStates[posData.frame_i] + ) + + # Restart count from the most recent state (index 0) + # NOTE: index 0 is most recent state before doing last change + self.UndoCount = 0 + self.undoAction.setEnabled(True) + self.addCurrentState( + storeImage=storeImage, storeOnlyZoom=storeOnlyZoom + ) + + def storeUndoRedoCca(self, frame_i, cca_df, undoId): + if self.isSnapshot: + # For snapshot mode we don't store anything because we have only + # segmentation undo action active + return + """ + Store current cca_df along with a unique id to know which cca_df needs + to be restored + """ + + posData = self.data[self.pos_i] + + # Restart count from the most recent state (index 0) + # NOTE: index 0 is most recent state before doing last change + self.UndoCcaCount = 0 + self.undoAction.setEnabled(True) + + self.addCcaState(frame_i, cca_df, undoId) + + # Keep only 10 Undo/Redo states + self.view_model.trim_cca_states(posData.UndoRedoCcaStates[frame_i]) + + def undoCustomAnnotation(self): + pass + + def UndoCca(self): + posData = self.data[self.pos_i] + # Undo current ccaState + storeState = False + if self.UndoCount == 0: + undoId = uuid.uuid4() + self.addCcaState(posData.frame_i, posData.cca_df, undoId) + storeState = True + + + # Get previously stored state + self.UndoCount += 1 + currentCcaStates = posData.UndoRedoCcaStates[posData.frame_i] + prevCcaState = currentCcaStates[self.UndoCount] + posData.cca_df = prevCcaState['cca_df'] + self.store_cca_df() + self.updateAllImages() + + # Check if we have undone all states + if self.view_model.should_disable_undo_after_cca( + self.UndoCount, currentCcaStates + ): + # There are no states left to undo for current frame_i + self.undoAction.setEnabled(False) + + # Undo all past and future frames that has a last status inserted + # when modyfing current frame + prevStateId = prevCcaState['id'] + for frame_i in range(0, posData.SizeT): + if storeState: + cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) + if cca_df_i is None: + break + # Store current state to enable redoing it + self.addCcaState(frame_i, cca_df_i, undoId) + + CcaStates_i = posData.UndoRedoCcaStates[frame_i] + if len(CcaStates_i) <= self.UndoCount: + # There are no states to undo for frame_i + continue + + CcaState_i = CcaStates_i[self.UndoCount] + id_i = CcaState_i['id'] + if id_i != prevStateId: + # The id of the state in frame_i is different from current frame + continue + + cca_df_i = CcaState_i['cca_df'] + self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) + + self.resetWillDivideInfo() + self.enqAutosave() + + def undo(self): + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + if addPointsByClickingButton is not None: + done = self.undoAddPoint(addPointsByClickingButton.action) + if done: + return + + if self.UndoCount == 0: + # Store current state to enable redoing it + self.addCurrentState() + + posData = self.data[self.pos_i] + # Get previously stored state + if self.view_model.can_undo_labels( + self.UndoCount, + posData.UndoRedoStates[posData.frame_i], + ): + self.UndoCount += 1 + # Since we have undone then it is possible to redo + self.redoAction.setEnabled(True) + + # Restore state + image_left = self.getCurrentState() + self.update_rp() + self.updateAllImages(image=image_left) + self.store_data() + + if not self.view_model.can_undo_labels( + self.UndoCount, + posData.UndoRedoStates[posData.frame_i], + ): + # We have undone all available states + self.undoAction.setEnabled(False) + + if self.whitelistIDsButton.isChecked(): + self.whitelistHighlightIDs() + + def redo(self): + posData = self.data[self.pos_i] + # Get previously stored state + if self.view_model.can_redo_labels(self.UndoCount): + self.UndoCount -= 1 + # Since we have redone then it is possible to undo + self.undoAction.setEnabled(True) + + # Restore state + image_left = self.getCurrentState() + self.update_rp() + self.updateAllImages(image=image_left) + self.store_data() + + if not self.view_model.can_redo_labels(self.UndoCount): + # We have redone all available states + self.redoAction.setEnabled(False) + + if self.whitelistIDsButton.isChecked(): + self.whitelistHighlightIDs() diff --git a/cellacdc/views/whitelist_view.py b/cellacdc/views/whitelist_view.py new file mode 100644 index 000000000..e7713fa53 --- /dev/null +++ b/cellacdc/views/whitelist_view.py @@ -0,0 +1,849 @@ +"""Qt view adapter for the Whitelist feature.""" + +from __future__ import annotations + +import os +import json +import numpy as np +import skimage.measure +from typing import Set, List, Tuple, Any +import time + +from cellacdc import ( + printl, myutils, html_utils, apps, widgets, exception_handler, disableWindow, gui_utils, exec_time +) +from cellacdc.trackers.CellACDC import CellACDC_tracker +from cellacdc.whitelist import Whitelist +from cellacdc.viewmodels.whitelist_viewmodel import WhitelistViewModel + + +class WhitelistView: + """Qt-facing adapter for the Whitelist feature.""" + + LEGACY_METHODS = ( + 'whitelistCheckOriginalLabels', + 'whitelistTrackOGagainstPreviousFrame_cb', + 'whitelistLoadOGLabs_cb', + 'whitelistLoadOGLabs', + 'whitelistViewOGIDs', + 'whitelistSetViewOGIDsToggle', + 'whitelistAddNewIDsToggled', + 'whitelistAddNewIDs', + 'whitelistIDsAccepted', + 'whitelistUpdateLab', + 'whitelistIDsUpdateText', + 'whitelistTrackOGCurr', + 'whitelistTrackCurrOG', + 'whitelistSyncIDsOG', + 'whitelistInitNewFrames', + 'whitelistPropagateIDs', + 'whitelistIDs_cb', + 'whitelistHighlightIDs', + 'whitelistIDsChanged', + 'whitelistUpdateTempLayer', + ) + + def __init__(self, host, view_model: WhitelistViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def whitelistCheckOriginalLabels(self, warning:bool=True, + frame_i:int=None): + posData = self.data[self.pos_i] + if posData.whitelist is None: + return False + + if frame_i is None: + frame_i = posData.frame_i + + if not self.view_model.check_original_labels(posData.whitelist, frame_i): + txt = """ + No original labels are present for the current frame, + this action cannot be performed.""" + self.logger.warning(txt) + if not warning: + return False + msg = widgets.myMessageBox.warning( + self, 'No original labels', txt, + ) + + return False + else: + return True + + @disableWindow + def whitelistTrackOGagainstPreviousFrame_cb(self, signal_slot=None): + posData = self.data[self.pos_i] + frame_i = posData.frame_i + if not self.whitelistCheckOriginalLabels(): + return + old_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] + prev_cell_IDs = posData.allData_li[frame_i-1]['IDs'] + self.whitelistTrackOGCurr(against_prev=True) + new_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] + + new_IDs = self.view_model.get_diff_ids( + old_cell_IDs, set(prev_cell_IDs), new_cell_IDs + ) + + self.whitelistUpdateLab( + track_og_curr=False, IDs_to_add=new_IDs, + ) + + def whitelistLoadOGLabs_cb(self): + posData = self.data[self.pos_i] + curr_seg_path = posData.segm_npz_path + + segmFilename = os.path.basename(curr_seg_path) + custom_first = f"{segmFilename[:-4]}_not_whitelisted.npz" + images_path = posData.images_path + existingEndnames = [ + files for files in os.listdir(images_path) if files.endswith('.npz') + ] + if custom_first not in existingEndnames: + custom_first = None + + infoText = html_utils.paragraph( + 'Select the segmentation file containing the original labels ' + 'of the objects. Pleae note that the current saved "original" ' + 'labels will be replaced with the new ones, but the filtered ' + 'labels will be kept.' + ) + + win = apps.SelectSegmFileDialog( + existingEndnames, images_path, parent=self, + basename=posData.basename, infoText=infoText, + custom_first=custom_first + ) + win.exec_() + if win.cancel: + self.logger.info('Loading original labels canceled.') + return + selected = win.selectedItemText + self.logger.info(f'Loading original labels from {selected}...') + self.whitelistLoadOGLabs(selected) + + @disableWindow + def whitelistLoadOGLabs(self, selected:str): + posData = self.data[self.pos_i] + images_path = posData.images_path + + selected_path = os.path.join(images_path, selected) + posData.whitelist.loadOGLabs(selected_path) + + self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) + + @exception_handler + @disableWindow + def whitelistViewOGIDs(self, checked:bool): + switch_to_og = checked and not self.viewOriginalLabels + switch_to_seg = not checked and self.viewOriginalLabels + + if not switch_to_og and not switch_to_seg: + return + + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + if posData.whitelist._debug: + printl('whitelistViewOGIDs', checked) + + frame_i = posData.frame_i + frames_range = self.view_model.get_frames_range(frame_i) + + self.store_data(autosave=False) + + if not self.whitelistCheckOriginalLabels(): + return + if switch_to_og: + self.setFrameNavigationDisabled(True, why='Viewing original labels') + self.viewOriginalLabels = True + + for i in frames_range: + posData.frame_i = i + self.get_data() + self.whitelistTrackOGCurr(frame_i=i) + + posData.lab = self.view_model.construct_og_frame( + pos_lab=posData.lab, + og_frame_base=posData.whitelist.originalLabs[i], + whitelist_ids=posData.whitelist.whitelistIDs[i], + og_ids=posData.whitelist.originalLabsIDs[i] + ) + self.update_rp(wl_update=False) + self.store_data(autosave=False) + + if frame_i > 0: + missing_IDs = self.view_model.get_missing_ids(posData.IDs, posData.allData_li[frame_i-1]['IDs']) + self.trackManuallyAddedObject(missing_IDs, isNewID=True, wl_update=False) + + self.setAllTextAnnotations() + self.updateAllImages() + + elif switch_to_seg: + self.viewOriginalLabels = False + self.setFrameNavigationDisabled(False, why='Viewing original labels') + + for i in frames_range: + posData.frame_i = i + self.get_data() + try: + posData.whitelist.originalLabs[i] = posData.lab.copy() + posData.whitelist.originalLabsIDs[i] = set(posData.IDs) + except AttributeError: + lab = posData.segm_data[i].copy() + IDs = [obj.label for obj in skimage.measure.regionprops(lab)] + posData.whitelist.originalLabs[i] = lab + posData.whitelist.originalLabsIDs[i] = set(IDs) + + self.update_rp(wl_update=False) + self.store_data(autosave=False) + self.whitelistUpdateLab(frame_i=i) + self.setAllTextAnnotations() + self.updateAllImages() + + def whitelistSetViewOGIDsToggle(self, checked: bool): + self.viewOriginalLabels = checked + self.whitelistIDsToolbar.viewOGToggle.blockSignals(True) + self.whitelistIDsToolbar.viewOGToggle.setChecked(checked) + self.whitelistIDsToolbar.viewOGToggle.blockSignals(False) + + def whitelistAddNewIDsToggled(self, checked: bool): + self.addNewIDsWhitelistToggle = checked + if checked: + self.df_settings.at['addNewIDsWhitelistToggle', 'value'] = 'Yes' + else: + self.df_settings.at['addNewIDsWhitelistToggle', 'value'] = 'No' + self.df_settings.to_csv(self.settings_csv_path) + if checked: + self.whitelistAddNewIDs(ignore_not_first_time=True) + self.whitelistPropagateIDs() + self.updateAllImages() + self.whitelistIDsUpdateText() + + def whitelistAddNewIDs(self, ignore_not_first_time:bool=False): + mode = self.modeComboBox.currentText() + if mode != 'Segmentation and Tracking': + return + + if not self.addNewIDsWhitelistToggle: + return + + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + debug = posData.whitelist._debug + + if debug: + printl('whitelistAddNewIDs') + + posData = self.data[self.pos_i] + frame_i = posData.frame_i + + if self.get_last_tracked_i() > frame_i and not ignore_not_first_time: + return + + if frame_i == 0: + return + + if self.whitelistAddNewIDsFrame is not None and frame_i == self.whitelistAddNewIDsFrame: + return + + self.whitelistAddNewIDsFrame = frame_i + + curr_lab = self.get_curr_lab() + + posData.whitelist.addNewIDs(frame_i=frame_i, + allData_li=posData.allData_li, + IDs_curr=posData.IDs, + curr_lab=curr_lab) + + def whitelistIDsAccepted(self, + whitelistIDs: Set[int] | List[int]): + self.storeUndoRedoStates(False) + + self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) + self.whitelistSetViewOGIDsToggle(False) + self.setFrameNavigationDisabled(False, why='Viewing original labels') + + self.store_data(autosave=False) + + posData = self.data[self.pos_i] + + if not posData.whitelist: + posData.whitelist = Whitelist( + total_frames=posData.SizeT, + ) + + if posData.whitelist._debug: + printl('whitelistIDsAccepted', whitelistIDs) + + whitelistIDs = set(whitelistIDs) + IDs_curr = set(posData.IDs) + + posData.whitelist.IDsAccepted( + whitelistIDs, + segm_data=posData.segm_data, + frame_i=posData.frame_i, + allData_li=posData.allData_li, + IDs_curr=IDs_curr, + curr_lab=posData.lab, + ) + + self.whitelistUpdateLab(track_og_curr=True) + self.whitelistIDsUpdateText() + self.keepIDsTempLayerLeft.clear() + + def whitelistUpdateLab(self, frame_i: int=None, + track_og_curr=False, new_frame:bool=False, + IDs_to_add:List[int] | Set[int]=None, + IDs_to_remove:List[int]|Set[int]=None, + ): + got_data = False + benchmark = False + if benchmark: + ts = [time.perf_counter()] + titles = [ + '', + 'store_data', + 'whitelistSetViewOGIDsToggle', + 'get_data', + 'get what to add/remove', + 'track_og_curr', + 'get current lab', + 'add/remove IDs', + 'store data', + 'update images', + ] + + mode = self.modeComboBox.currentText() + if mode != 'Segmentation and Tracking': + return + + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + if frame_i is None: + frame_i = posData.frame_i + og_frame_i = frame_i + else: + og_frame_i = posData.frame_i + posData.frame_i = frame_i + + debug = posData.whitelist._debug + if debug: + printl('whitelistUpdateLab', frame_i, og_frame_i) + from cellacdc import debugutils + debugutils.print_call_stack() + + if benchmark: + ts.append(time.perf_counter()) + + self.whitelistSetViewOGIDsToggle(False) + + if benchmark: + ts.append(time.perf_counter()) + + if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): + og_lab = posData.whitelist.originalLabs[frame_i] + else: + og_lab = None + if benchmark: + ts.append(time.perf_counter()) + + whitelist = posData.whitelist.get(frame_i=frame_i) + IDs_to_add_remove_provided = IDs_to_add is not None or IDs_to_remove is not None + if not IDs_to_add_remove_provided: + self.get_data() + got_data = True + missing_IDs, to_be_removed_IDs = self.view_model.get_whitelist_missing_and_removed_ids( + whitelist, set(posData.IDs) + ) + else: + missing_IDs = list(IDs_to_add) if IDs_to_add is not None else [] + to_be_removed_IDs = list(IDs_to_remove) if IDs_to_remove is not None else [] + + if benchmark: + ts.append(time.perf_counter()) + + if not missing_IDs and not to_be_removed_IDs: + if og_frame_i != frame_i: + posData.frame_i = og_frame_i + if got_data and og_frame_i != frame_i: + self.get_data() + if benchmark: + print('No IDs to add/remove') + ts.append(time.perf_counter()) + indx = titles.index('track_og_curr') + titles[indx + 1] = 'store_data' + time_taken = time.perf_counter() - ts[0] + print(f'\nTotal time for whitelistUpdateLab: {time_taken:.2f}s') + for i in range(1, len(ts)): + time_taken = ts[i] - ts[i-1] + print(f'Time taken for {titles[i]}: {time_taken:.2f}s') + print('') + return + + if not got_data and og_frame_i != frame_i: + self.get_data() + got_data = True + + if benchmark: + ts.append(time.perf_counter()) + + if missing_IDs and track_og_curr and not new_frame: + self.whitelistTrackOGCurr(frame_i=frame_i, + lab = posData.lab, + rp = posData.rp) + + if debug: + printl(missing_IDs, to_be_removed_IDs) + + curr_lab = posData.lab + if curr_lab is None: + try: + curr_lab = posData.allData_li[frame_i]['labels'].copy() + except: + pass + if curr_lab is None: + try: + curr_lab = posData.segm_data[frame_i].copy() + except: + pass + if curr_lab is None: + printl('No current lab?') + curr_lab = np.zeros_like(posData.segm_data[0]) + + if benchmark: + ts.append(time.perf_counter()) + + curr_lab = self.view_model.apply_id_mask( + curr_lab, og_lab, missing_IDs, to_be_removed_IDs + ) + + if benchmark: + ts.append(time.perf_counter()) + + posData.lab = curr_lab + + self.update_rp(wl_update=False) + self.store_data() + + if benchmark: + ts.append(time.perf_counter()) + if og_frame_i != frame_i: + posData.frame_i = og_frame_i + self.get_data() + + self.updateAllImages() + self.setAllTextAnnotations() + + if benchmark: + ts.append(time.perf_counter()) + time_taken = time.perf_counter() - ts[0] + print(f'\nTotal time for whitelistUpdateLab: {time_taken:.2f}s') + for i in range(1, len(ts)): + time_taken = ts[i] - ts[i-1] + print(f'Time taken for {titles[i]}: {time_taken:.2f}s') + print('') + + def whitelistIDsUpdateText(self): + mode = self.modeComboBox.currentText() + if mode != 'Segmentation and Tracking': + return + + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + if posData.whitelist._debug: + printl('whitelistIDsUpdateText') + + frame_i = posData.frame_i + whitelist = posData.whitelist.get(frame_i=frame_i) + + self.whitelistIDsToolbar.whitelistLineEdit.setText(whitelist) + + def whitelistTrackOGCurr(self, frame_i:int=None, + against_prev:bool=False, + lab:np.ndarray=None, + rp:list=None, + IDs: Set[int] | List[int] =None): + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + debug = posData.whitelist._debug + + if debug: + from cellacdc import debugutils + debugutils.print_call_stack(depth=2) + printl('whitelistTrackOGCurr', against_prev) + + if against_prev and (rp is not None or lab is not None): + raise ValueError('Cannot provide both rp and lab when tracking' + ' against previous frame.' + 'Instead only provide rp and lab, and dont set against_prev.') + + if frame_i is None: + frame_i = posData.frame_i + + if against_prev and frame_i == 0: + return + + if not self.whitelistCheckOriginalLabels(warning=False, + frame_i=frame_i): + if debug: + printl('No original labels, cannot track.') + return + + og_frame_i = posData.frame_i + + if lab is not None and not rp: + rp = skimage.measure.regionprops(lab) + + changed_frame = False + if lab is None: + if debug: + printl('No lab and no rp provided.') + if against_prev: + rp = posData.allData_li[frame_i-1]['regionprops'] + lab = posData.allData_li[frame_i-1]['labels'] + else: + if frame_i != og_frame_i: + self.store_data(autosave=False) + posData.frame_i = frame_i + self.get_data() + changed_frame = True + rp = posData.rp + lab = posData.lab + og_lab = posData.whitelist.originalLabs[frame_i] + og_rp = skimage.measure.regionprops(og_lab) + + denom_overlap_matrix = 'union' if not against_prev else 'area_prev' + + og_lab = CellACDC_tracker.track_frame( + lab, rp, og_lab, og_rp, + denom_overlap_matrix=denom_overlap_matrix, + posData = posData, + setBrushID_func=self.setBrushID, + IDs=IDs, + ) + + posData.whitelist.originalLabs[frame_i] = og_lab + posData.whitelist.originalLabsIDs[frame_i] = {obj.label for obj in skimage.measure.regionprops(og_lab)} + + if changed_frame: + posData.frame_i = og_frame_i + self.get_data() + + def whitelistTrackCurrOG(self, frame_i:int=None, against_prev:bool=False): + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + if posData.whitelist._debug: + printl('whitelistTrackCurrOG', frame_i, against_prev) + + if frame_i is None: + frame_i = posData.frame_i + + if against_prev and frame_i == 0: + return + + og_frame = posData.frame_i + if frame_i != og_frame: + self.store_data(autosave=False) + posData.frame_i = frame_i + self.get_data() + + lab = posData.lab + rp = posData.rp + + if not self.whitelistCheckOriginalLabels(warning=False, + frame_i=frame_i if not against_prev else frame_i-1): + if posData.whitelist._debug: + printl('No original labels, cannot track.') + return + + if against_prev: + og_lab = posData.whitelist.originalLabs[frame_i-1] + else: + og_lab = posData.whitelist.originalLabs[frame_i] + + og_rp = skimage.measure.regionprops(og_lab) + + denom_overlap_matrix = 'union' if not against_prev else 'area_prev' + + lab = CellACDC_tracker.track_frame( + og_lab, og_rp, lab, rp, + denom_overlap_matrix=denom_overlap_matrix, + posData = posData, + setBrushID_func=self.setBrushID + ) + + posData.lab = lab + + self.update_rp(wl_update=False) + self.store_data(autosave=False) + + if frame_i != og_frame: + posData.frame_i = og_frame + self.get_data() + + def whitelistSyncIDsOG(self, + frame_is: List[int]=None, + against_prev: bool=False,): + posData = self.data[self.pos_i] + if frame_is is None: + frame_is = range(posData.SizeT) + + for frame_i in frame_is: + self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=against_prev) + + def whitelistInitNewFrames(self, frame_i:int=None, force:bool=False): + posData = self.data[self.pos_i] + if posData.whitelist is None: + return False, [] + + if frame_i is None: + frame_i = posData.frame_i + + if posData.whitelist._debug: + printl('whitelistInitNewFrames', frame_i, force) + + if frame_i not in posData.whitelist.initialized_i: + self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=True) + + new_frame, update_frames = posData.whitelist.initNewFrames( + frame_i=frame_i, force=force) + + self.whitelistAddNewIDs() + return new_frame, update_frames + + def whitelistPropagateIDs(self, + new_whitelist: Set[int] | List[int] = None, + IDs_to_add: Set[int] = None, + IDs_to_remove: Set[int] = None, + frame_i: int = None, + try_create_new_whitelists: bool = False, + curr_frame_only: bool = False, + force_not_dynamic_update: bool = False, + only_future_frames: bool = True, + allow_only_current_IDs: bool = False, + track_og_curr: bool = True, + IDs_curr: Set[int] | List[int] = None, + index_lab_combo: Tuple[int, np.ndarray] = None, + curr_rp: list = None, + curr_lab: np.ndarray = None, + store_data: bool = True, + update_lab: bool = False, + ): + try: + IDs_curr = IDs_curr.copy() + except AttributeError: + pass + + IDs_curr = set(IDs_curr) if IDs_curr is not None else None + + posData = self.data[self.pos_i] + debug = posData.whitelist._debug if posData.whitelist is not None else False + + if debug: + printl('Propagating IDs...') + from cellacdc import debugutils + debugutils.print_call_stack() + printl(new_whitelist, IDs_to_add, IDs_to_remove) + + if posData.whitelist is None: + return + + if frame_i is None: + frame_i = posData.frame_i + + new_frame, update_frames_init = self.whitelistInitNewFrames(frame_i=frame_i) + + if new_frame: + self.update_rp(wl_update=False) + + update_frames = posData.whitelist.propagateIDs( + frame_i, + posData.allData_li, + new_whitelist=new_whitelist, + IDs_to_add=IDs_to_add, + IDs_to_remove=IDs_to_remove, + try_create_new_whitelists=try_create_new_whitelists, + curr_frame_only=curr_frame_only, + force_not_dynamic_update=force_not_dynamic_update, + only_future_frames=only_future_frames, + allow_only_current_IDs=allow_only_current_IDs, + IDs_curr=IDs_curr, + index_lab_combo=index_lab_combo, + curr_rp=curr_rp, + curr_lab=curr_lab, + ) + if update_lab: + update_frames = update_frames_init + update_frames + else: + update_frames = update_frames_init + + self.whitelistIDsUpdateText() + if store_data: + self.store_data(autosave=False) + + for frame_i, IDs_to_add, IDs_to_remove, new_frame in update_frames: + self.whitelistUpdateLab(frame_i=frame_i, track_og_curr=track_og_curr, + new_frame=new_frame, IDs_to_add=IDs_to_add, + IDs_to_remove=IDs_to_remove, ) + + def whitelistIDs_cb(self, checked:bool): + if checked: + self.initKeepObjLabelsLayers() + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.whitelistIDsButton) + self.connectLeftClickButtons() + + self.whitelistIDsToolbar.setVisible(checked) + self.whitelistHighlightIDs(checked) + self.whitelistIDsUpdateText() + self.whitelistUpdateTempLayer() + + if not checked: + self.setLostNewOldPrevIDs() + self.updateAllImages() + + def whitelistHighlightIDs(self, checked:bool=True): + if not checked: + self.removeHighlightLabelID() + return + + posData = self.data[self.pos_i] + + if posData.whitelist is None: + if not hasattr(self, 'tempWhitelistIDs'): + self.tempWhitelistIDs = set() + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = posData.whitelist.get( + frame_i=posData.frame_i) + + for ID in current_whitelist: + self.highlightLabelID(ID) + + def whitelistIDsChanged(self, + whitelistIDs: Set[int] | List[int], + debug: bool=False): + if not self.whitelistIDsButton.isChecked(): + return + + posData = self.data[self.pos_i] + + if posData.whitelist: + debug = posData.whitelist._debug + if debug: + printl('whitelistIDsChanged', whitelistIDs) + + if posData.whitelist is None: + wl_init = False + if not hasattr(self, 'tempWhitelistIDs'): + self.tempWhitelistIDs = set() + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = self.tempWhitelistIDs + else: + wl_init = True + current_whitelist = posData.whitelist.get( + frame_i=posData.frame_i) + + current_whitelist_copy = current_whitelist.copy() + if not hasattr(posData, 'originalLabsIDs') or posData.whitelist.originalLabsIDs is None: + possible_IDs = posData.IDs.copy() + else: + if not self.whitelistCheckOriginalLabels(warning=False): + possible_IDs = set(posData.IDs) + else: + possible_IDs = posData.whitelist.originalLabsIDs[posData.frame_i] + possible_IDs.update(posData.IDs) + + # Delegate validation of existing IDs to viewmodel/model + filtered_whitelist, isAnyIDnotExisting = self.view_model.filter_existing_ids( + whitelistIDs, possible_IDs + ) + + # Apply changes based on filtered_whitelist + for ID in filtered_whitelist: + if ID not in current_whitelist_copy: + current_whitelist.add(ID) + self.highlightLabelID(ID) + + for ID in current_whitelist_copy: + if ID not in possible_IDs: + continue + if ID not in whitelistIDs: + current_whitelist.remove(ID) + self.removeHighlightLabelID(IDs=[ID]) + + if wl_init: + posData.whitelist.whitelistIDs[posData.frame_i] = current_whitelist + else: + self.tempWhitelistIDs = current_whitelist + + self.whitelistUpdateTempLayer() + if isAnyIDnotExisting: + self.whitelistIDsToolbar.whitelistLineEdit.warnNotExistingID() + else: + self.whitelistIDsToolbar.whitelistLineEdit.setInstructionsText() + + def whitelistUpdateTempLayer(self): + if not self.whitelistIDsButton.isChecked(): + self.keepIDsTempLayerLeft.clear() + return + + if not hasattr(self, 'keptLab'): + self.keptLab = np.zeros_like(self.currentLab2D) + keptLab = self.keptLab + else: + keptLab = self.keptLab + keptLab[:] = 0 + + posData = self.data[self.pos_i] + if posData.whitelist is None: + if not hasattr(self, 'tempWhitelistIDs'): + self.tempWhitelistIDs = set() + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = posData.whitelist.get(posData.frame_i) + + for obj in posData.rp: + if obj.label not in current_whitelist: + continue + + if not self.isObjVisible(obj.bbox): + continue + + _slice = self.getObjSlice(obj.slice) + _objMask = self.getObjImage(obj.image, obj.bbox) + + keptLab[_slice][_objMask] = obj.label + + self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) diff --git a/cellacdc/views/window_events_view.py b/cellacdc/views/window_events_view.py new file mode 100644 index 000000000..97a17c8f2 --- /dev/null +++ b/cellacdc/views/window_events_view.py @@ -0,0 +1,1098 @@ +"""Qt view adapter for main-window and pointer events.""" + +from __future__ import annotations + +import gc +import os +import traceback +import time + +from qtpy.QtCore import Qt, QSettings, QTimer +from qtpy.QtGui import QCursor, QFont, QKeyEvent, QKeySequence, QPixmap +from qtpy.QtWidgets import QAbstractSlider, QCheckBox, QMainWindow + +from cellacdc import apps, exception_handler, html_utils, is_mac, printl, qutils, widgets +from cellacdc.plot import imshow +from cellacdc.viewmodels.window_events_viewmodel import WindowEventsViewModel + + +_font = QFont() +_font.setPixelSize(11) + + +class WindowEventsView: + """Qt-facing adapter for main-window and pointer event handling.""" + + LEGACY_METHODS = ( + 'onKeyHome', + 'onKeyEnd', + 'onKeyPageUp', + 'onKeyPageDown', + 'keyUpCallback', + 'keyDownCallback', + 'dragEnterEvent', + 'dropEvent', + 'changeEvent', + 'leaveEvent', + 'enterEvent', + 'isPanImageClick', + 'middleClickText', + 'isDefaultMiddleClick', + 'isMiddleClick', + 'resizeBottomLayoutLineClicked', + 'resizeBottomLayoutLineDragged', + 'resizeBottomLayoutLineReleased', + 'mousePressEvent', + 'resizeLeaveSpaceTerminalBelow', + '_resizeLeaveSpaceTerminalBelow', + 'checkSetDelObjActionActive', + 'changeRightClickToLeftOnMac', + 'checkTriggerKeyPressShortcuts', + '_temp_debug', + 'checkOverlayToolbuttonClicked', + 'keyPressCheckSetSpinboxValue', + 'editingSpinboxValueTimerCallback', + 'keyPressEvent', + 'doubleRightClickTimerCallBack', + 'doubleKeyTimerCallBack', + 'doubleKeySpacebarTimerCallback', + 'updateBrushCursorOnShiftRelease', + 'onShiftReleased', + 'keyReleaseEvent', + 'clearMemory', + 'askCloseAllWindows', + 'stopPreprocWorker', + 'closeEvent', + 'readSettings', + 'saveWindowGeometry', + 'storeDefaultAndCustomColors', + 'showEvent', + 'super_show', + 'show', + 'resizeSlidersArea', + '_resizeSlidersArea', + 'resizeEvent', + 'gui_createCursors', + ) + + def __init__(self, host, view_model: WindowEventsViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def onKeyHome(self): + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepAdd + ) + + def onKeyEnd(self): + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepSub + ) + + def onKeyPageUp(self): + isAutoPilotActive = ( + self.autoPilotZoomToObjToggle.isChecked() + and self.autoPilotZoomToObjToolbar.isVisible() + ) + if isAutoPilotActive: + self.pointsLayerAutoPilot('next') + elif self.zSliceScrollBar.isVisible(): + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepAdd + ) + + def onKeyPageDown(self): + isAutoPilotActive = ( + self.autoPilotZoomToObjToggle.isChecked() + and self.autoPilotZoomToObjToolbar.isVisible() + ) + if isAutoPilotActive: + self.pointsLayerAutoPilot('prev') + elif self.zSliceScrollBar.isVisible(): + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepAdd + ) + + def keyUpCallback( + self, isBrushActive, isWandActive, isExpandLabelActive, + isLabelRoiCircActive + ): + isAutoPilotActive = ( + self.autoPilotZoomToObjToggle.isChecked() + and self.autoPilotZoomToObjToolbar.isVisible() + ) + if isBrushActive: + brushSize = self.brushSizeSpinbox.value() + self.brushSizeSpinbox.setValue(brushSize+1) + elif isWandActive: + wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() + self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance+1) + elif isExpandLabelActive: + self.label_transform_tools_view.expand_label(dilation=True) + self.expandFootprintSize += 1 + elif isLabelRoiCircActive: + val = self.labelRoiCircularRadiusSpinbox.value() + self.labelRoiCircularRadiusSpinbox.setValue(val+1) + elif isAutoPilotActive: + self.pointsLayerAutoPilot('next') + else: + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepAdd + ) + + def keyDownCallback( + self, isBrushActive, isWandActive, isExpandLabelActive, + isLabelRoiCircActive + ): + isAutoPilotActive = ( + self.autoPilotZoomToObjToggle.isChecked() + and self.autoPilotZoomToObjToolbar.isVisible() + ) + if isBrushActive: + brushSize = self.brushSizeSpinbox.value() + self.brushSizeSpinbox.setValue(brushSize-1) + elif isWandActive: + wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() + self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance-1) + elif isExpandLabelActive: + self.label_transform_tools_view.expand_label(dilation=False) + self.expandFootprintSize += 1 + elif isLabelRoiCircActive: + val = self.labelRoiCircularRadiusSpinbox.value() + self.labelRoiCircularRadiusSpinbox.setValue(val-1) + elif isAutoPilotActive: + self.pointsLayerAutoPilot('prev') + elif self.isNavigateActionOnNextFrame(): + posData = self.data[self.pos_i] + self.rightImageFramesScrollbar.setValue(posData.frame_i+2) + else: + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepSub + ) + + def dragEnterEvent(self, event): + file_path = event.mimeData().urls()[0].toLocalFile() + if os.path.isdir(file_path): + basename = os.path.basename(file_path) + if basename.find('Position_') != -1 or basename == 'Images': + event.acceptProposedAction() + else: + event.ignore() + else: + event.acceptProposedAction() + + def dropEvent(self, event): + event.setDropAction(Qt.CopyAction) + file_path = event.mimeData().urls()[0].toLocalFile() + self.logger.info(f'Dragged and dropped path "{file_path}"') + if os.path.isdir(file_path): + self.openFolder(exp_path=file_path) + else: + self.openFile(file_path=file_path) + + def changeEvent(self, event): + try: + self.delObjToolAction.setChecked(False) + except Exception as err: + return + + def leaveEvent(self, event): + if self.slideshowWin is not None: + posData = self.data[self.pos_i] + mainWinGeometry = self.geometry() + slideshowWinGeometry = self.slideshowWin.geometry() + + overlap = self.view_model.windows_overlap_from_bounds( + main_left=mainWinGeometry.left(), + main_top=mainWinGeometry.top(), + main_width=mainWinGeometry.width(), + main_height=mainWinGeometry.height(), + other_left=slideshowWinGeometry.left(), + other_top=slideshowWinGeometry.top(), + ) + autoActivate = self.view_model.should_auto_activate_viewer( + is_data_loaded=self.isDataLoaded, + windows_overlap=overlap, + disable_auto_activate=posData.disableAutoActivateViewerWindow, + ) + + if autoActivate: + self.slideshowWin.setFocus() + self.slideshowWin.activateWindow() + + def enterEvent(self, event): + event.accept() + if self.slideshowWin is not None: + posData = self.data[self.pos_i] + mainWinGeometry = self.geometry() + slideshowWinGeometry = self.slideshowWin.geometry() + + overlap = self.view_model.windows_overlap_from_bounds( + main_left=mainWinGeometry.left(), + main_top=mainWinGeometry.top(), + main_width=mainWinGeometry.width(), + main_height=mainWinGeometry.height(), + other_left=slideshowWinGeometry.left(), + other_top=slideshowWinGeometry.top(), + ) + autoActivate = self.view_model.should_auto_activate_viewer( + is_data_loaded=self.isDataLoaded, + windows_overlap=overlap, + disable_auto_activate=posData.disableAutoActivateViewerWindow, + ) + + if autoActivate: + # self.setFocus() + self.activateWindow() + + def isPanImageClick(self, mouseEvent, modifiers): + return self.view_model.is_pan_image_click( + mouse_button=mouseEvent.button(), + left_button=Qt.MouseButton.LeftButton, + modifiers=modifiers, + alt_modifier=Qt.AltModifier, + ) + + def middleClickText(self): + if self.delObjAction is None and is_mac: + return self.view_model.middle_click_text( + has_del_object_action=False, + is_mac=is_mac, + ) + + if self.delObjAction is None: + return self.view_model.middle_click_text( + has_del_object_action=False, + is_mac=is_mac, + ) + + delObjKeySequence, delObjQtButton = self.delObjAction + + if delObjQtButton == Qt.MouseButton.LeftButton: + buttonName = 'Left click' + elif delObjQtButton == Qt.MouseButton.RightButton: + buttonName = 'Right click' + else: + buttonName = 'Middle click' + + if delObjKeySequence is None: + keySequenceText = None + else: + keySequenceText = delObjKeySequence.toString() + + return self.view_model.middle_click_text( + has_del_object_action=True, + is_mac=is_mac, + button_name=buttonName, + key_sequence_text=keySequenceText, + ) + + def isDefaultMiddleClick(self, mouseEvent, modifiers): + return self.view_model.is_default_middle_click( + mouse_button=mouseEvent.button(), + modifiers=modifiers, + is_mac=is_mac, + brush_is_checked=self.brushButton.isChecked(), + left_button=Qt.MouseButton.LeftButton, + middle_button=Qt.MouseButton.MiddleButton, + control_modifier=Qt.ControlModifier, + ) + + def isMiddleClick(self, mouseEvent, modifiers): + if self.delObjAction is None: + return self.isDefaultMiddleClick(mouseEvent, modifiers) + + delObjKeySequence, delObjQtButton = self.delObjAction + mouseEventButton = self.changeRightClickToLeftOnMac(mouseEvent) + return self.view_model.is_configured_middle_click( + mouse_button=mouseEventButton, + configured_button=delObjQtButton, + key_sequence_is_none=delObjKeySequence is None, + tool_is_checked=self.delObjToolAction.isChecked(), + ) + + def resizeBottomLayoutLineClicked(self, event): + pass + + def resizeBottomLayoutLineDragged(self, event): + if not self.img1BottomGroupbox.isVisible(): + return + newBottomLayoutHeight = self.bottomScrollArea.minimumHeight() - event.y() + self.bottomScrollArea.setFixedHeight(newBottomLayoutHeight) + + def resizeBottomLayoutLineReleased(self): + QTimer.singleShot(100, self.autoRange) + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.MouseButton.RightButton: + pos = self.resizeBottomLayoutLine.mapFromGlobal(event.globalPos()) + if pos.y()>=0: + self.gui_raiseBottomLayoutContextMenu(event) + return QMainWindow.mousePressEvent(self.host, event) + + def resizeLeaveSpaceTerminalBelow(self): + self.setWindowState(Qt.WindowMaximized) + QTimer.singleShot(200, self._resizeLeaveSpaceTerminalBelow) + + def _resizeLeaveSpaceTerminalBelow(self): + geometry = self.geometry() + left = geometry.left() + top = geometry.top() + width = geometry.width() + height = geometry.height() + self.setGeometry(left, top+10, width, height-200) + + def checkSetDelObjActionActive(self, event): + if self.delObjAction is None and self.is_win: + return + + if self.delObjAction is None: + # On mac we check for Key_Control + if event.key() == Qt.Key_Control: + self.delObjToolAction.setChecked(True) + return + + delObjKeySequence, delObjQtButton = self.delObjAction + keySequenceText = widgets.QKeyEventToString(event).rstrip('+') + + if delObjKeySequence is None: + # self.delObjToolAction.setChecked(True) + return + + delObjKeySequenceText = widgets.macShortcutToWindows( + delObjKeySequence.toString() + ) + keySequenceText = widgets.macShortcutToWindows(keySequenceText) + + # printl( + # delObjKeySequence.toString(), + # keySequenceText, + # delObjKeySequenceText + # ) + + if keySequenceText == delObjKeySequenceText: + self.delObjToolAction.setChecked(True) + + def changeRightClickToLeftOnMac(self, mouseEvent): + button = mouseEvent.button() + if not is_mac: + return button + + delObjKeySequence, delObjQtButton = self.delObjAction + if delObjKeySequence is None: + return button + + if not delObjKeySequence.toString() == 'Control': + return button + + if button != Qt.MouseButton.RightButton: + return button + + if delObjQtButton == Qt.MouseButton.LeftButton: + # On mac, pressing "Control" and clicking with left button changes + # it to a right click button --> here, left click is required for + # delete object --> force return of left click + return Qt.MouseButton.LeftButton + + return button + + + def checkTriggerKeyPressShortcuts(self, event: QKeyEvent): + isBrushKey = event.key() == self.brushButton.keyPressShortcut + isEraserKey = event.key() == self.eraserButton.keyPressShortcut + if isBrushKey or isEraserKey: + return isBrushKey, isEraserKey + + modifierText = widgets.modifierKeyToText(event.modifiers()) + for widget in self.widgetsWithShortcut.values(): + if not hasattr(widget, 'keyPressShortcut'): + continue + + if event.key() == widget.keyPressShortcut: + if widget.isCheckable(): + widget.setChecked(True) + else: + widget.trigger() + continue + + shortcutText = widget.keyPressShortcut.toString() + try: + mod, key = shortcutText.split('+') + if modifierText == mod and event.key() == QKeySequence(key): + widget.trigger() + + except Exception as e: + pass + + return isBrushKey, isEraserKey + + def _temp_debug(self, id=None): + posData = self.data[self.pos_i] + imshow(posData.lab, annotate_labels_idxs=[0]) + + def checkOverlayToolbuttonClicked(self, event): + success = False + try: + n = int(event.text()) + toolbutton = self.allOverlayToolbuttonsByIdx.get(n, None) + toolbutton.click() + success = True + except Exception as e: + # printl(traceback.format_exc()) + success = False + return success + + def keyPressCheckSetSpinboxValue(self, event, spinbox): + """Check if the key pressed is a digit and set the spinbox value + accordingly.""" + try: + n = int(event.text()) + if self.typingEditID: + value = int(f'{spinbox.value()}{n}') + else: + value = n + self.typingEditID = True + spinbox.setValue(value) + + try: + spinbox.timer.stop() + except Exception as err: + pass + + spinbox.timer = QTimer(spinbox) + spinbox.timer.timeout.connect( + self.editingSpinboxValueTimerCallback + ) + spinbox.timer.start(2000) + spinbox.timer.setSingleShot(True) + success = True + except Exception as e: + # printl(traceback.format_exc()) + success = False + return success + + def editingSpinboxValueTimerCallback(self): + self.typingEditID = False + + @exception_handler + def keyPressEvent(self, ev): + ctrl = ev.modifiers() == Qt.ControlModifier + if ctrl and ev.key() == Qt.Key_D: + self.resizeLeaveSpaceTerminalBelow() + return + + if ev.key() == Qt.Key_Q and self.debug: + try: + from cellacdc import _q_debug + _q_debug.q_debug(self) + except Exception as err: + printl(traceback.format_exc()) + printl('[ERROR]: Error with "_qdebug" module. See Traceback above.') + pass + + if not self.isDataLoaded: + self.logger.warning( + 'Data not loaded yet. Key pressing events are not connected.' + ) + return + + if ev.key() == Qt.Key_Control: + if not ctrl: + self.wasCtrlPressedFirstTime = True + self.onCtrlPressedFirstTime() + + if ev.key() == Qt.Key_PageDown: + self.onKeyPageDown() + + if ev.key() == Qt.Key_PageUp: + self.onKeyPageUp() + + if ev.key() == Qt.Key_Home: + self.onKeyHome() + + if ev.key() == Qt.Key_End: + self.onKeyEnd() + + modifiers = ev.modifiers() + isAltModifier = modifiers == Qt.AltModifier + isCtrlModifier = modifiers == Qt.ControlModifier + isShiftModifier = modifiers == Qt.ShiftModifier + + self.checkSetDelObjActionActive(ev) + + self.isZmodifier = ( + ev.key()== Qt.Key_Z and not isAltModifier + and not isCtrlModifier and not isShiftModifier + ) + if isShiftModifier: + if self.brushButton.isChecked(): + # Force default brush symbol with shift down + self.setHoverToolSymbolColor( + 1, 1, self.ax2_BrushCirclePen, + (self.ax2_BrushCircle, self.ax1_BrushCircle), + self.brushButton, brush=self.ax2_BrushCircleBrush, + ID=0 + ) + if self.isSegm3D: + self.changeBrushID() + + isAnyModifier = isAltModifier or isCtrlModifier or isShiftModifier + if not isAnyModifier and self.overlayButton.isChecked(): + isButtonClicked = self.checkOverlayToolbuttonClicked(ev) + if isButtonClicked: + return + + isBrushActive = ( + self.brushButton.isChecked() or self.eraserButton.isChecked() + ) + isManualTrackingActive = self.manualTrackingButton.isChecked() + isManualBackgroundActive = self.manualBackgroundButton.isChecked() + isTypingIDFunctionChecked = False + if self.brushButton.isChecked() and not self.autoIDcheckbox.isChecked(): + success = self.keyPressCheckSetSpinboxValue(ev, self.editIDspinbox) + isTypingIDFunctionChecked = True + + if isManualTrackingActive: + isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( + ev, self.manualTrackingToolbar.spinboxID + ) + + elif isManualBackgroundActive: + isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( + ev, self.manualBackgroundToolbar.spinboxID + ) + + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + if ( + addPointsByClickingButton is not None + and addPointsByClickingButton.toolbar.isVisible() + ): + isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( + ev, addPointsByClickingButton.rightClickIDSpinbox + ) + + isBrushKey, isEraserKey = self.checkTriggerKeyPressShortcuts(ev) + isExpandLabelActive = self.expandLabelToolButton.isChecked() + isWandActive = self.wandToolButton.isChecked() + isLabelRoiCircActive = ( + self.labelRoiButton.isChecked() + and self.labelRoiIsCircularRadioButton.isChecked() + ) + how = self.drawIDsContComboBox.currentText() + isOverlaySegm = how.find('overlay segm. masks') != -1 + if ev.key()==Qt.Key_Up and not isCtrlModifier: + self.keyUpCallback( + isBrushActive, isWandActive, isExpandLabelActive, + isLabelRoiCircActive + ) + elif ev.key()==Qt.Key_Down and not isCtrlModifier: + self.keyDownCallback( + isBrushActive, isWandActive, isExpandLabelActive, + isLabelRoiCircActive + ) + elif ev.key() == Qt.Key_Enter or ev.key() == Qt.Key_Return: + if isTypingIDFunctionChecked: + self.typingEditID = False + elif self.keepIDsButton.isChecked(): + self.keepIDsConfirmAction.trigger() + elif ev.key() == Qt.Key_Escape: + self.onEscape(isTypingIDFunctionChecked=isTypingIDFunctionChecked) + elif isAltModifier: + isCursorSizeAll = self.app.overrideCursor() == Qt.SizeAllCursor + # Alt is pressed while cursor is on images --> set SizeAllCursor + if self.xHoverImg is not None and not isCursorSizeAll: + self.app.setOverrideCursor(Qt.SizeAllCursor) + elif isCtrlModifier and isOverlaySegm: + if ev.key() == Qt.Key_Up: + val = self.imgGrad.labelsAlphaSlider.value() + delta = 5/self.imgGrad.labelsAlphaSlider.maximum() + val = val+delta + self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) + elif ev.key() == Qt.Key_Down: + val = self.imgGrad.labelsAlphaSlider.value() + delta = 5/self.imgGrad.labelsAlphaSlider.maximum() + val = val-delta + self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) + elif ev.key() == self.zoomOutKeyValue: + self.zoomToCells(enforce=True) + if self.countKeyPress == 0: + self.isKeyDoublePress = False + self.countKeyPress = 1 + self.doubleKeyTimeElapsed = False + self.Button = None + QTimer.singleShot(400, self.doubleKeyTimerCallBack) + elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed: + self.ax1.autoRange() + self.isKeyDoublePress = True + self.countKeyPress = 0 + elif ev.key() == Qt.Key_Space: + if self.countKeyPress == 0: + # Single press --> wait that it's not double press + self.isKeyDoublePress = False + self.countKeyPress = 1 + self.doubleKeyTimeElapsed = False + QTimer.singleShot(300, self.doubleKeySpacebarTimerCallback) + elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed: + self.isKeyDoublePress = True + # Double press --> toggle draw nothing + self.onDoubleSpaceBar() + self.countKeyPress = 0 + elif isBrushKey or isEraserKey: + if isBrushKey: + self.Button = self.brushButton + else: + self.Button = self.eraserButton + + if not self.Button.isVisible(): + return + + if self.countKeyPress == 0: + # If first time clicking B activate brush and start timer + # to catch double press of B + if not self.Button.isChecked(): + self.uncheck = False + self.Button.setChecked(True) + else: + self.uncheck = True + self.countKeyPress = 1 + self.isKeyDoublePress = False + self.doubleKeyTimeElapsed = False + + QTimer.singleShot(400, self.doubleKeyTimerCallBack) + elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed: + self.isKeyDoublePress = True + color = self.Button.palette().button().color().name() + if color == self.doublePressKeyButtonColor: + c = self.defaultToolBarButtonColor + else: + c = self.doublePressKeyButtonColor + self.Button.setStyleSheet(f'background-color: {c}') + self.countKeyPress = 0 + if self.xHoverImg is not None: + xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) + if isBrushKey: + self.setHoverToolSymbolColor( + xdata, ydata, self.ax2_BrushCirclePen, + (self.ax2_BrushCircle, self.ax1_BrushCircle), + self.brushButton, brush=self.ax2_BrushCircleBrush + ) + elif isEraserKey: + self.setHoverToolSymbolColor( + xdata, ydata, self.eraserCirclePen, + (self.ax2_EraserCircle, self.ax1_EraserCircle), + self.eraserButton + ) + + def doubleRightClickTimerCallBack(self): + if self.isDoubleRightClick: + self.doubleRightClickTimeElapsed = False + return + self.doubleRightClickTimeElapsed = True + self.countRightClicks = 0 + + # Time to double right click on img1 expired --> single right-click + self.canvas_context_menu_view.show_img_gradient_context_menu( + *self._img1_click_xy + ) + + def doubleKeyTimerCallBack(self): + if self.isKeyDoublePress: + self.doubleKeyTimeElapsed = False + return + self.doubleKeyTimeElapsed = True + self.countKeyPress = 0 + if self.Button is None: + return + + isBrushChecked = self.Button.isChecked() + if isBrushChecked and self.uncheck: + self.Button.setChecked(False) + c = self.defaultToolBarButtonColor + self.Button.setStyleSheet(f'background-color: {c}') + + def doubleKeySpacebarTimerCallback(self): + if self.isKeyDoublePress: + self.doubleKeyTimeElapsed = False + return + self.doubleKeyTimeElapsed = True + self.countKeyPress = 0 + + # # Spacebar single press --> toggle next visualization + # currentIndex = self.drawIDsContComboBox.currentIndex() + # nItems = self.drawIDsContComboBox.count() + # nextIndex = currentIndex+1 + # if nextIndex < nItems: + # self.drawIDsContComboBox.setCurrentIndex(nextIndex) + # else: + # self.drawIDsContComboBox.setCurrentIndex(0) + + def updateBrushCursorOnShiftRelease(self): + xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) + self.setHoverToolSymbolColor( + xdata, ydata, self.ax2_BrushCirclePen, + (self.ax2_BrushCircle, self.ax1_BrushCircle), + self.brushButton, brush=self.ax2_BrushCircleBrush, + byPassShiftCheck=True + ) + if self.isSegm3D: + self.changeBrushID() + + def onShiftReleased(self): + if self.brushButton.isChecked() and self.xHoverImg is not None: + self.updateBrushCursorOnShiftRelease() + + def keyReleaseEvent(self, ev): + if self.app.overrideCursor() == Qt.SizeAllCursor: + self.app.restoreOverrideCursor() + if ev.key() == Qt.Key_Control: + self.onCtrlReleased() + elif ev.key() == Qt.Key_Shift: + self.onShiftReleased() + + canRepeat = ( + ev.key() == Qt.Key_Left + or ev.key() == Qt.Key_Right + or ev.key() == Qt.Key_Up + or ev.key() == Qt.Key_Down + or ev.key() == Qt.Key_Control + or ev.key() == Qt.Key_Backspace + or self.delObjToolAction.isChecked() + ) + + if canRepeat and ev.isAutoRepeat(): + return + + self.delObjToolAction.setChecked(False) + + if ev.isAutoRepeat() and not ev.key() == Qt.Key_Z: + if self.warnKeyPressedMsg is not None: + return + self.warnKeyPressedMsg = widgets.myMessageBox( + showCentered=False, wrapText=False + ) + txt = html_utils.paragraph(f""" + Please, do not keep the key "{ev.text().upper()}" + pressed.

+ It confuses me :)

+ Thanks! + """) + self.warnKeyPressedMsg.warning( + self.host, 'Release the key, please', txt + ) + self.warnKeyPressedMsg = None + elif ev.isAutoRepeat() and ev.key() == Qt.Key_Z and self.isZmodifier: + self.zKeptDown = True + elif ev.key() == Qt.Key_Z and self.isZmodifier: + posData = self.data[self.pos_i] + self.isZmodifier = False + if not self.zKeptDown and posData.SizeZ > 1: + self.zSliceCheckbox.setChecked(not self.zSliceCheckbox.isChecked()) + self.zKeptDown = False + + def clearMemory(self): + if not hasattr(self, 'data'): + return + self.logger.info('Clearing memory...') + for posData in self.data: + try: + del posData.img_data + except Exception as e: + pass + try: + del posData.segm_data + except Exception as e: + pass + try: + del posData.ol_data_dict + except Exception as e: + pass + try: + del posData.fluo_data_dict + except Exception as e: + pass + try: + del posData.ol_data + except Exception as e: + pass + del self.data + + def askCloseAllWindows(self): + txt = html_utils.paragraph(""" + There are other open windows that were created from this window. +

+ If you proceed, the other windows will be closed too.
+ """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning( + self.host, 'Open windows', txt, + buttonsTexts=('Cancel', 'Ok, close now') + ) + return msg.cancel + + def stopPreprocWorker(self): + self.logger.info('Closing pre-processing worker...') + try: + self.preprocWorker.stop() + except Exception as err: + pass + + def closeEvent(self, event): + self.setDisabled(False) + cancel = self.checkAskSavePointsLayers() + if cancel: + event.ignore() + return + + self.onEscape() + self.saveWindowGeometry() + + if self.newWindows: + cancel = self.askCloseAllWindows() + if cancel: + event.ignore() + return + + for window in self.newWindows: + window.close() + + if self.slideshowWin is not None: + self.slideshowWin.close() + if self.ccaTableWin is not None: + self.ccaTableWin.close() + + proceed = self.askSaveOnClosing(event) + if not proceed: + event.ignore() + return + + self.autoSaveClose() + + if self.autoSaveActiveWorkers: + progressWin = apps.QDialogWorkerProgress( + title='Closing autosaving worker', parent=self.host, + pbarDesc='Closing autosaving worker...' + ) + progressWin.show(self.app) + progressWin.mainPbar.setMaximum(0) + self.waitCloseAutoSaveWorkerLoop = qutils.QWhileLoop( + self._waitCloseAutoSaveWorker, period=250 + ) + self.waitCloseAutoSaveWorkerLoop.exec_() + progressWin.workerFinished = True + progressWin.close() + + self.stopPreprocWorker() + self.stopCombineWorker() + self.stopCcaIntegrityCheckerWorker() + + # Close the inifinte loop of the thread + if self.lazyLoader is not None: + self.lazyLoader.exit = True + self.lazyLoaderWaitCond.wakeAll() + self.waitReadH5cond.wakeAll() + + if self.storeStateWorker is not None: + # Close storeStateWorker + self.storeStateWorker._stop() + while self.storeStateWorker.isFinished: + time.sleep(0.05) + + # Block main thread while separate threads closes + time.sleep(0.1) + + self.clearMemory() + + self.logger.info('Closing GUI logger...') + self.logger.close() + + if self.lazyLoader is None: + self.sigClosed.emit(self) + + gc.collect() + + def readSettings(self): + settings = QSettings('schmollerlab', 'acdc_gui') + if settings.value('geometry') is not None: + self.restoreGeometry(settings.value("geometry")) + # self.restoreState(settings.value("windowState")) + + def saveWindowGeometry(self): + settings = QSettings('schmollerlab', 'acdc_gui') + settings.setValue("geometry", self.saveGeometry()) + # settings.setValue("windowState", self.saveState()) + + def storeDefaultAndCustomColors(self): + c = self.overlayButton.palette().button().color().name() + self.defaultToolBarButtonColor = c + self.doublePressKeyButtonColor = '#fa693b' + + def showEvent(self, event): + if self.mainWin is not None: + if not self.mainWin.isMinimized(): + return + self.mainWin.showAllWindows() + # self.setFocus() + self.activateWindow() + + def super_show(self): + QMainWindow.show(self.host) + + def show(self): + self.setFont(_font) + QMainWindow.show(self.host) + + self.setWindowState(Qt.WindowNoState) + self.setWindowState(Qt.WindowActive) + self.raise_() + + self.readSettings() + self.storeDefaultAndCustomColors() + + self.h = self.navSpinBox.size().height() + fontSizeFactor = None + heightFactor = None + if 'bottom_sliders_zoom_perc' in self.df_settings.index: + val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value']) + if val != 100: + fontSizeFactor = val/100 + heightFactor = val/100 + + self.defaultWidgetHeightBottomLayout = self.h + self.checkBoxesHeight = 14 + self.fontPixelSize = 11 + self.defaultBottomLayoutHeight = self.img1BottomGroupbox.height() + + self.bottomLayout.setStretch(0, 0) + self.bottomLayout.addSpacing(self.quickSettingsGroupbox.width()) + self.resizeSlidersArea( + fontSizeFactor=fontSizeFactor, heightFactor=heightFactor + ) + self.bottomScrollArea.hide() + + self.gui_initImg1BottomWidgets() + self.img1BottomGroupbox.hide() + + w = self.showPropsDockButton.width() + h = self.showPropsDockButton.height() + + self.showPropsDockButton.setMaximumWidth(15) + self.showPropsDockButton.setMaximumHeight(120) + + for toolbar in self.controlToolBars: + toolbar.setMinimumHeight( + self.secondLevelToolbar.sizeHint().height() + ) + + self.graphLayout.setFocus() + + def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): + global _font + if heightFactor is None: + self.newCheckBoxesHeight = self.checkBoxesHeight + self.newHeight = self.h + else: + self.newHeight = round(self.h*heightFactor) + self.newCheckBoxesHeight = round(self.checkBoxesHeight*heightFactor) + + if fontSizeFactor is None: + newFontSize = self.fontPixelSize + else: + newFontSize = round(self.fontPixelSize*fontSizeFactor) + newFont = QFont() + newFont.setPixelSize(newFontSize) + _font = newFont + self.zProjComboBox.setFont(newFont) + self.t_label.setFont(newFont) + self.zProjOverlay_CB.setFont(newFont) + self.annotateRightHowCombobox.setFont(newFont) + self.drawIDsContComboBox.setFont(newFont) + self.showTreeInfoCheckbox.setFont(newFont) + self.highlightZneighObjCheckbox.setFont(newFont) + self.navSpinBox.setFont(newFont) + self.zSliceSpinbox.setFont(newFont) + self.SizeZlabel.setFont(newFont) + self.navSizeLabel.setFont(newFont) + self.overlay_z_label.setFont(newFont) + self.img1BottomGroupbox.setFont(newFont) + self.rightBottomGroupbox.setFont(newFont) + try: + self.img1.alphaScrollbar.label.setFont(newFont) + except Exception as e: + pass + for i in range(self.annotOptionsLayout.count()): + widget = self.annotOptionsLayout.itemAt(i).widget() + widget.setFont(newFont) + for i in range(self.annotOptionsLayoutRight.count()): + widget = self.annotOptionsLayoutRight.itemAt(i).widget() + widget.setFont(newFont) + try: + for channel, items in self.overlayLayersItems.items(): + alphaScrollbar = items[2] + alphaScrollbar.label.setFont(newFont) + except: + pass + QTimer.singleShot(100, self._resizeSlidersArea) + + def _resizeSlidersArea(self): + self.navigateScrollBar.setFixedHeight(self.newHeight) + self.zSliceScrollBar.setFixedHeight(self.newHeight) + self.zSliceOverlay_SB.setFixedHeight(self.newHeight) + self.zProjComboBox.setFixedHeight(self.newHeight) + self.zProjOverlay_CB.setFixedHeight(self.newHeight) + self.navSpinBox.setFixedHeight(self.newHeight) + self.zSliceSpinbox.setFixedHeight(self.newHeight) + try: + self.img1.alphaScrollbar.setFixedHeight(self.newHeight) + except Exception as e: + pass + try: + for channel, items in self.overlayLayersItems.items(): + alphaScrollbar = items[2] + alphaScrollbar.setFixedHeight(self.newHeight) + except: + pass + checkBoxStyleSheet = ( + 'QCheckBox::indicator {' + f'width: {self.newCheckBoxesHeight}px;' + f'height: {self.newCheckBoxesHeight}px' + '}' + ) + for i in range(self.annotOptionsLayout.count()): + widget = self.annotOptionsLayout.itemAt(i).widget() + if isinstance(widget, QCheckBox): + widget.setStyleSheet(checkBoxStyleSheet) + for i in range(self.annotOptionsLayoutRight.count()): + widget = self.annotOptionsLayoutRight.itemAt(i).widget() + if isinstance(widget, QCheckBox): + widget.setStyleSheet(checkBoxStyleSheet) + self.zSliceCheckbox.setStyleSheet(checkBoxStyleSheet) + + def resizeEvent(self, event): + if hasattr(self, 'ax1'): + self.ax1.autoRange() + + def gui_createCursors(self): + pixmap = QPixmap(":wand_cursor.svg") + self.wandCursor = QCursor(pixmap, 16, 16) + + pixmap = QPixmap(":curv_cursor.svg") + self.curvCursor = QCursor(pixmap, 16, 16) + + pixmap = QPixmap(":addDelPolyLineRoi_cursor.svg") + self.polyLineRoiCursor = QCursor(pixmap, 16, 16) + + pixmap = QPixmap(":cross_cursor.svg") + self.addPointsCursor = QCursor(pixmap, 16, 16) diff --git a/cellacdc/views/worker_view.py b/cellacdc/views/worker_view.py new file mode 100644 index 000000000..49632ae0b --- /dev/null +++ b/cellacdc/views/worker_view.py @@ -0,0 +1,475 @@ +"""Qt view adapter for GUI worker lifecycle handling.""" + +from __future__ import annotations + +import logging +import traceback +from functools import partial +from typing import Tuple + +import numpy as np +from qtpy.QtCore import QObject, QMutex, QThread, QTimer, QWaitCondition + +from cellacdc import apps, exception_handler, html_utils, issues_url, widgets, workers +from cellacdc.viewmodels.worker_viewmodel import WorkerViewModel + + +class WorkerView: + """Qt-facing adapter around background worker setup and callbacks.""" + + LEGACY_METHODS = ( + 'gui_createLazyLoader', + 'gui_createStoreStateWorker', + 'storeStateWorkerDone', + 'storeStateWorkerClosed', + 'gui_createAutoSaveWorker', + 'autoSaveWorkerStartTimer', + 'autoSaveWorkerTimerCallback', + 'autoSaveWorkerDone', + 'autoSaveWorkerClosed', + 'workerProgress', + 'workerFinished', + 'savePreprocWorkerFinished', + 'loadingNewChunk', + 'lazyLoaderFinished', + 'lazyLoaderCritical', + 'lazyLoaderWorkerClosed', + 'ccaIntegrityWorkerCritical', + 'workerCritical', + 'workerLog', + 'saveDataWorkerCritical', + 'trackingWorkerFinished', + 'workerInitProgressbar', + 'workerUpdateProgressbar', + 'workerInitInnerPbar', + 'workerUpdateInnerPbar', + 'startTrackingWorker', + 'startRelabellingWorker', + 'startPostProcessSegmWorker', + 'relabelWorkerFinished', + 'workerDebug', + ) + + def __init__(self, host, view_model: WorkerViewModel): + object.__setattr__(self, 'host', host) + object.__setattr__(self, 'view_model', view_model) + + def __getattr__(self, name): + return getattr(self.host, name) + + def __setattr__(self, name, value): + if name in {'host', 'view_model'}: + object.__setattr__(self, name, value) + else: + setattr(self.host, name, value) + + def bind_legacy_methods(self): + for name in self.LEGACY_METHODS: + setattr(self.host, name, getattr(self, name)) + + def gui_createLazyLoader(self): + if not self.lazyLoader is None: + return + + self.lazyLoaderThread = QThread() + self.lazyLoaderMutex = QMutex() + self.lazyLoaderWaitCond = QWaitCondition() + self.waitReadH5cond = QWaitCondition() + self.readH5mutex = QMutex() + self.lazyLoader = workers.LazyLoader( + self.lazyLoaderMutex, self.lazyLoaderWaitCond, + self.waitReadH5cond, self.readH5mutex + ) + self.lazyLoader.moveToThread(self.lazyLoaderThread) + self.lazyLoader.wait = True + + self.lazyLoader.signals.finished.connect(self.lazyLoaderThread.quit) + self.lazyLoader.signals.finished.connect(self.lazyLoader.deleteLater) + self.lazyLoaderThread.finished.connect(self.lazyLoaderThread.deleteLater) + + self.lazyLoader.signals.progress.connect(self.workerProgress) + self.lazyLoader.signals.sigLoadingNewChunk.connect(self.loadingNewChunk) + self.lazyLoader.sigLoadingFinished.connect(self.lazyLoaderFinished) + self.lazyLoader.signals.critical.connect(self.lazyLoaderCritical) + self.lazyLoader.signals.finished.connect(self.lazyLoaderWorkerClosed) + + self.lazyLoaderThread.started.connect(self.lazyLoader.run) + self.lazyLoaderThread.start() + + def gui_createStoreStateWorker(self): + self.storeStateWorker = None + return + self.storeStateThread = QThread() + self.autoSaveMutex = QMutex() + self.autoSaveWaitCond = QWaitCondition() + + self.storeStateWorker = workers.StoreGuiStateWorker( + self.autoSaveMutex, self.autoSaveWaitCond + ) + + self.storeStateWorker.moveToThread(self.storeStateThread) + self.storeStateWorker.finished.connect(self.storeStateThread.quit) + self.storeStateWorker.finished.connect(self.storeStateWorker.deleteLater) + self.storeStateThread.finished.connect(self.storeStateThread.deleteLater) + + self.storeStateWorker.sigDone.connect(self.storeStateWorkerDone) + self.storeStateWorker.progress.connect(self.workerProgress) + self.storeStateWorker.finished.connect(self.storeStateWorkerClosed) + + self.storeStateThread.started.connect(self.storeStateWorker.run) + self.storeStateThread.start() + + self.logger.info('Store state worker started.') + + def storeStateWorkerDone(self): + if self.storeStateWorker.callbackOnDone is not None: + self.storeStateWorker.callbackOnDone() + self.storeStateWorker.callbackOnDone = None + + def storeStateWorkerClosed(self): + self.logger.info('Store state worker started.') + + def gui_createAutoSaveWorker(self): + if not hasattr(self, 'data'): + return + + if not self.isDataLoaded: + return + + if self.autoSaveActiveWorkers: + garbage = self.autoSaveActiveWorkers[-1] + self.autoSaveGarbageWorkers.append(garbage) + worker = garbage[0] + worker._stop() + + posData = self.data[self.pos_i] + autoSaveThread = QThread() + self.autoSaveMutex = QMutex() + self.autoSaveWaitCond = QWaitCondition() + + savedSegmData = posData.segm_data.copy() + autoSaveWorker = workers.AutoSaveWorker( + self.autoSaveMutex, self.autoSaveWaitCond, savedSegmData + ) + autoSaveWorker.isAutoSaveON = self.autoSaveToggle.isChecked() + + autoSaveWorker.moveToThread(autoSaveThread) + autoSaveWorker.finished.connect(autoSaveThread.quit) + autoSaveWorker.finished.connect(autoSaveWorker.deleteLater) + autoSaveThread.finished.connect(autoSaveThread.deleteLater) + + autoSaveWorker.sigDone.connect(self.autoSaveWorkerDone) + autoSaveWorker.progress.connect(self.workerProgress) + autoSaveWorker.finished.connect(self.autoSaveWorkerClosed) + autoSaveWorker.sigAutoSaveCannotProceed.connect( + self.turnOffAutoSaveWorker + ) + + autoSaveThread.started.connect(autoSaveWorker.run) + autoSaveThread.start() + + self.autoSaveActiveWorkers.append((autoSaveWorker, autoSaveThread)) + + self.logger.info('Autosaving worker started.') + + def autoSaveWorkerStartTimer(self, worker, posData): + self.autoSaveWorkerTimer = QTimer() + self.autoSaveWorkerTimer.timeout.connect( + partial(self.autoSaveWorkerTimerCallback, worker, posData) + ) + self.autoSaveWorkerTimer.start(150) + + def autoSaveWorkerTimerCallback(self, worker, posData): + if self.view_model.should_enqueue_autosave(self.isSaving): + self.autoSaveWorkerTimer.stop() + worker._enqueue(posData) + + def autoSaveWorkerDone(self): + self.status_hover_view.set_status_bar_label(log=False) + + def autoSaveWorkerClosed(self, worker): + if self.autoSaveActiveWorkers: + self.logger.info('Autosaving worker closed.') + try: + self.autoSaveActiveWorkers.remove(worker) + except Exception as e: + pass + + def workerProgress(self, text, loggerLevel='INFO'): # used in cca and lin tree + if self.progressWin is not None: + self.progressWin.logConsole.append(text) + loggerLevel = self.view_model.progress_log_level(loggerLevel) + self.logger.log(getattr(logging, loggerLevel), text) + + def workerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.logger.info('Worker process ended.') + self.updateAllImages() + self.titleLabel.setText('Done', color='w') + + def savePreprocWorkerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.status_hover_view.set_status_bar_label() + self.logger.info('Pre-processed data saved!') + self.titleLabel.setText('Pre-processed data saved!', color='w') + + def loadingNewChunk(self, chunk_range): + desc = self.view_model.lazy_loader_progress_description(chunk_range) + self.progressWin = apps.QDialogWorkerProgress( + title='Loading data...', parent=self.host, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.show(self.app) + + def lazyLoaderFinished(self): + self.logger.info('Load chunk data worker done.') + if self.lazyLoader.updateImgOnFinished: + self.updateAllImages() + + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + @exception_handler + def lazyLoaderCritical(self, error): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.lazyLoader.pause() + raise error + + def lazyLoaderWorkerClosed(self): + if self.lazyLoader.salute: + self.logger.info('Cell-ACDC GUI closed.') + self.sigClosed.emit(self.host) + + self.lazyLoader = None + + def ccaIntegrityWorkerCritical(self, error): + try: + raise error + except Exception as err: + self.logger.exception(traceback.format_exc()) + + href = f'GitHub page' + txt = html_utils.paragraph(f""" + Unfortunately the experimental feature + check cell cycle annotations integrity raised a + critical error.

+ Cell-ACDC will now disable this feature to allow you to keep + using the software.

+ However, we kindly ask you to report the issue on our + {href}, thank you very much!

+ Please, include the log file when reporting the issue.

+ Log file location: + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning( + self.host, 'Experimental feature error', txt, + commands=(self.log_path,), + path_to_browse=self.logs_path + ) + self.disableCcaIntegrityChecker() + + @exception_handler + def workerCritical(self, out: Tuple[QObject, Exception]): + self.setDisabled(False) + try: + worker, error = out + except TypeError as err: + error = out + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.logger.info(error) + try: + worker.thread().quit() + worker.deleteLater() + worker.thread().deleteLater() + except Exception as err: + # Worker already closed + pass + raise error + + def workerLog(self, text): + self.logger.info(text) + + def saveDataWorkerCritical(self, error): + self.logger.warning( + 'Saving process stopped because of critical error.' + ) + self.saveWin.aborted = True + self.worker.finished.emit() + self.workerCritical(error) + + @exception_handler + def trackingWorkerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.logger.info('Worker process ended.') + askDisableRealTimeTracking = ( + self.view_model.should_disable_realtime_tracking( + self.trackingWorker.trackingOnNeverVisitedFrames, + self.realTimeTrackingToggle.isChecked(), + ) + ) + if askDisableRealTimeTracking: + msg = widgets.myMessageBox() + title = 'Disable real-time tracking?' + txt = ( + 'You perfomed tracking on frames that you have ' + 'never visited.

' + 'Cell-ACDC default behaviour is to track them again when you ' + 'will visit them.

' + 'However, you can overwrite this behaviour and explicitly ' + 'disable tracking for all of the frames you already tracked.

' + 'NOTE: you can reactivate real-time tracking by clicking on the ' + '"Reset last segmented frame" button on the top toolbar.

' + 'What do you want me to do?' + ) + _, disableTrackingButton = msg.information( + self.host, title, html_utils.paragraph(txt), + buttonsTexts=( + 'Keep real-time tracking active (recommended)', + 'Disable real-time tracking' + ) + ) + if msg.clickedButton == disableTrackingButton: + self.logger.info('Disabling real time tracking...') + self.realTimeTrackingToggle.setChecked(False) + # posData = self.data[self.pos_i] + # current_frame_i = posData.frame_i + # for frame_i in range(self.start_n-1, self.stop_n): + # posData.frame_i = frame_i + # self.get_data() + # self.store_data(autosave=frame_i==self.stop_n-1) + # posData.last_tracked_i = frame_i + # self.setNavigateScrollBarMaximum() + + # # Back to current frame + # posData.frame_i = current_frame_i + # self.get_data() + posData = self.data[self.pos_i] + self.updateAllImages() + self.titleLabel.setText('Done', color='w') + + def workerInitProgressbar(self, totalIter): + self.progressWin.mainPbar.setValue(0) + maximum = self.view_model.progressbar_maximum(totalIter) + self.progressWin.mainPbar.setMaximum(maximum) + + def workerUpdateProgressbar(self, step): + self.progressWin.mainPbar.update(step) + + def workerInitInnerPbar(self, totalIter): + self.progressWin.innerPbar.setValue(0) + maximum = self.view_model.progressbar_maximum(totalIter) + self.progressWin.innerPbar.setMaximum(maximum) + + def workerUpdateInnerPbar(self, step): + self.progressWin.innerPbar.update(step) + + def startTrackingWorker(self, posData, video_to_track): + self.thread = QThread() + self.trackingWorker = workers.trackingWorker( + posData, self.host, video_to_track + ) + self.trackingWorker.moveToThread(self.thread) + self.trackingWorker.finished.connect(self.thread.quit) + self.trackingWorker.finished.connect(self.trackingWorker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + # Custom signals + self.trackingWorker.signals.progress = self.trackingWorker.progress + self.trackingWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.trackingWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.trackingWorker.signals.sigInitInnerPbar.connect( + self.workerInitInnerPbar + ) + self.trackingWorker.progress.connect(self.workerProgress) + self.trackingWorker.critical.connect(self.workerCritical) + self.trackingWorker.finished.connect(self.trackingWorkerFinished) + + self.trackingWorker.debug.connect(self.workerDebug) + + self.thread.started.connect(self.trackingWorker.run) + self.thread.start() + + def startRelabellingWorker(self, posFoldernames): + self.thread = QThread() + self.worker = workers.relabelSequentialWorker( + self.host, posFoldernames + ) + self.worker.moveToThread(self.thread) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + self.worker.progress.connect(self.workerProgress) + self.worker.critical.connect(self.workerCritical) + self.worker.finished.connect(self.workerFinished) + self.worker.finished.connect(self.relabelWorkerFinished) + + self.worker.debug.connect(self.workerDebug) + + self.thread.started.connect(self.worker.run) + self.thread.start() + + def startPostProcessSegmWorker( + self, postProcessKwargs, customPostProcessGroupedFeatures, + customPostProcessFeatures + ): + self.thread = QThread() + self.postProcessWorker = workers.PostProcessSegmWorker( + postProcessKwargs, customPostProcessGroupedFeatures, + customPostProcessFeatures, self.host + ) + + self.postProcessWorker.moveToThread(self.thread) + self.postProcessWorker.signals.finished.connect(self.thread.quit) + self.postProcessWorker.signals.finished.connect( + self.postProcessWorker.deleteLater + ) + self.thread.finished.connect(self.thread.deleteLater) + + self.postProcessWorker.signals.finished.connect( + self.postProcessSegmWorkerFinished + ) + self.postProcessWorker.signals.progress.connect(self.workerProgress) + self.postProcessWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.postProcessWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.postProcessWorker.signals.critical.connect( + self.workerCritical + ) + + self.thread.started.connect(self.postProcessWorker.run) + self.thread.start() + + def relabelWorkerFinished(self): + self.updateAllImages() + + def workerDebug(self, item): + tracked_video, worker = item + from cellacdc.plot import imshow + imshow(tracked_video) + worker.waitCond.wakeAll() diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index b22e1cec4..5b03fa52c 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -11841,7 +11841,7 @@ def __init__( self.initItems() def initItems(self): - from cellacdc.models.YeaZ_v2 import load_models_filepath + from cellacdc.segmenters.YeaZ_v2 import load_models_filepath models_name, models_name_filepath_mapper = load_models_filepath() self.addItems(models_name) @@ -11877,7 +11877,7 @@ def onTextChanged(self, text): model_name = modelNameWindow.enteredValue - from cellacdc.models.YeaZ_v2 import add_model_filepath + from cellacdc.segmenters.YeaZ_v2 import add_model_filepath add_model_filepath(model_name, model_filepath) self.addItem(model_name) diff --git a/cellacdc/workers.py b/cellacdc/workers.py index 25feb6f72..30f47dc6e 100755 --- a/cellacdc/workers.py +++ b/cellacdc/workers.py @@ -6533,7 +6533,7 @@ def __init__( @worker_exception_handler def run(self): - from cellacdc.promptable_models import utils + from cellacdc.segmenters_promptable import utils for row in self.df_points.itertuples(): prompt_id = row.id From b6744f756396d34d94258f273c02249270c77cc4 Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 09:22:26 +0200 Subject: [PATCH 02/21] 123 --- cellacdc/viewmodels/__init__.py | 28 +++++++++---------- cellacdc/viewmodels/actions_viewmodel.py | 2 +- .../annotation_display_viewmodel.py | 10 +++---- .../viewmodels/canvas_drawing_viewmodel.py | 4 +-- .../viewmodels/canvas_events_viewmodel.py | 4 +-- .../viewmodels/canvas_selection_viewmodel.py | 4 +-- cellacdc/viewmodels/canvas_tools.py | 7 ----- .../{cca_edits.py => cca_edits_viewmodel.py} | 0 ...orkflows.py => cca_workflows_viewmodel.py} | 0 cellacdc/viewmodels/cell_cycle_viewmodel.py | 8 +++--- cellacdc/viewmodels/data_loading_viewmodel.py | 4 +-- .../{edit_id.py => edit_id_viewmodel.py} | 0 ...{formatting.py => formatting_viewmodel.py} | 0 ...etadata.py => frame_metadata_viewmodel.py} | 0 .../viewmodels/frame_navigation_viewmodel.py | 4 +-- .../{geometry.py => geometry_viewmodel.py} | 0 cellacdc/viewmodels/graphics_viewmodel.py | 8 +++--- .../viewmodels/image_display_viewmodel.py | 2 +- .../viewmodels/label_editing_viewmodel.py | 8 +++--- ...abel_edits.py => label_edits_viewmodel.py} | 0 .../{lineage.py => lineage_viewmodel.py} | 0 .../viewmodels/magic_prompts_viewmodel.py | 2 +- .../viewmodels/{main.py => main_viewmodel.py} | 26 ++++++++--------- cellacdc/viewmodels/measurements.py | 5 ---- ...egistry.py => model_registry_viewmodel.py} | 0 .../viewmodels/object_cleanup_viewmodel.py | 2 +- ...t_counts.py => object_counts_viewmodel.py} | 0 .../viewmodels/object_properties_viewmodel.py | 2 +- .../viewmodels/points_layers_viewmodel.py | 2 +- .../{points.py => points_viewmodel.py} | 0 cellacdc/viewmodels/saving_viewmodel.py | 6 ++-- cellacdc/viewmodels/segmentation_viewmodel.py | 2 +- cellacdc/viewmodels/session.py | 7 ----- cellacdc/viewmodels/session_viewmodel.py | 8 +++--- .../{tables.py => tables_viewmodel.py} | 0 .../viewmodels/tool_activation_viewmodel.py | 2 +- cellacdc/viewmodels/tracking.py | 7 ----- cellacdc/viewmodels/tracking_viewmodel.py | 8 +++--- .../viewmodels/window_events_viewmodel.py | 2 +- .../{workspace.py => workspace_viewmodel.py} | 0 40 files changed, 74 insertions(+), 100 deletions(-) delete mode 100644 cellacdc/viewmodels/canvas_tools.py rename cellacdc/viewmodels/{cca_edits.py => cca_edits_viewmodel.py} (100%) rename cellacdc/viewmodels/{cca_workflows.py => cca_workflows_viewmodel.py} (100%) rename cellacdc/viewmodels/{edit_id.py => edit_id_viewmodel.py} (100%) rename cellacdc/viewmodels/{formatting.py => formatting_viewmodel.py} (100%) rename cellacdc/viewmodels/{frame_metadata.py => frame_metadata_viewmodel.py} (100%) rename cellacdc/viewmodels/{geometry.py => geometry_viewmodel.py} (100%) rename cellacdc/viewmodels/{label_edits.py => label_edits_viewmodel.py} (100%) rename cellacdc/viewmodels/{lineage.py => lineage_viewmodel.py} (100%) rename cellacdc/viewmodels/{main.py => main_viewmodel.py} (93%) delete mode 100644 cellacdc/viewmodels/measurements.py rename cellacdc/viewmodels/{model_registry.py => model_registry_viewmodel.py} (100%) rename cellacdc/viewmodels/{object_counts.py => object_counts_viewmodel.py} (100%) rename cellacdc/viewmodels/{points.py => points_viewmodel.py} (100%) delete mode 100644 cellacdc/viewmodels/session.py rename cellacdc/viewmodels/{tables.py => tables_viewmodel.py} (100%) delete mode 100644 cellacdc/viewmodels/tracking.py rename cellacdc/viewmodels/{workspace.py => workspace_viewmodel.py} (100%) diff --git a/cellacdc/viewmodels/__init__.py b/cellacdc/viewmodels/__init__.py index 94ccc4f91..0c1074c92 100644 --- a/cellacdc/viewmodels/__init__.py +++ b/cellacdc/viewmodels/__init__.py @@ -42,42 +42,42 @@ from .canvas_selection_viewmodel import CanvasSelectionViewModel from .canvas_tool_viewmodel import CanvasToolViewModel from .cell_cycle_viewmodel import CellCycleViewModel -from .cca_edits import CcaEditViewModel, CcaFrameEditResult -from .cca_workflows import CcaWorkflowViewModel +from .cca_edits_viewmodel import CcaEditViewModel, CcaFrameEditResult +from .cca_workflows_viewmodel import CcaWorkflowViewModel from .curvature_viewmodel import CurvatureViewModel from .custom_annotations_viewmodel import CustomAnnotationsViewModel from .data_loading_viewmodel import DataLoadingViewModel from .deleted_rois_viewmodel import DeletedRoisViewModel from .display_decorations_viewmodel import DisplayDecorationsViewModel from .draw_clear_region_viewmodel import DrawClearRegionViewModel -from .edit_id import EditIdViewModel +from .edit_id_viewmodel import EditIdViewModel from .exporting_viewmodel import ExportingViewModel -from .frame_metadata import FrameMetadataViewModel +from .frame_metadata_viewmodel import FrameMetadataViewModel from .frame_navigation_viewmodel import FrameNavigationViewModel -from .formatting import FormattingViewModel -from .geometry import GeometryViewModel +from .formatting_viewmodel import FormattingViewModel +from .geometry_viewmodel import GeometryViewModel from .graphics_viewmodel import GraphicsViewModel from .image_controls_viewmodel import ImageControlsViewModel from .image_display_viewmodel import ImageDisplayViewModel from .label_editing_viewmodel import LabelEditingViewModel -from .label_edits import LabelEditViewModel +from .label_edits_viewmodel import LabelEditViewModel from .label_roi_viewmodel import LabelRoiViewModel from .label_transform_tools_viewmodel import LabelTransformToolsViewModel from .layout_controls_viewmodel import LayoutControlsViewModel -from .lineage import LineageViewModel +from .lineage_viewmodel import LineageViewModel from .lineage_interactions_viewmodel import LineageInteractionsViewModel from .magic_prompts_viewmodel import MagicPromptsViewModel from .main_menu_viewmodel import MainMenuViewModel from .main_toolbar_viewmodel import MainToolbarViewModel -from .main import MainGuiViewModel +from .main_viewmodel import MainGuiViewModel from .measurements_viewmodel import MeasurementsViewModel from .mode_controls_viewmodel import ModeControlsViewModel -from .model_registry import ModelRegistryViewModel -from .object_counts import ObjectCountViewModel +from .model_registry_viewmodel import ModelRegistryViewModel +from .object_counts_viewmodel import ObjectCountViewModel from .object_cleanup_viewmodel import ObjectCleanupViewModel from .object_properties_viewmodel import ObjectPropertiesViewModel from .object_search_viewmodel import ObjectSearchViewModel -from .points import PointsViewModel +from .points_viewmodel import PointsViewModel from .points_layers_viewmodel import PointsLayersViewModel from .preprocessing_viewmodel import PreprocessingViewModel from .quick_settings_viewmodel import QuickSettingsViewModel @@ -86,13 +86,13 @@ from .segmentation_viewmodel import SegmentationViewModel from .session_viewmodel import SessionViewModel from .status_hover_viewmodel import StatusHoverViewModel -from .tables import TableViewModel +from .tables_viewmodel import TableViewModel from .tool_activation_viewmodel import ToolActivationViewModel from .tracking_viewmodel import TrackingViewModel from .undo_redo_viewmodel import UndoRedoViewModel from .worker_viewmodel import WorkerViewModel from .window_events_viewmodel import WindowEventsViewModel -from .workspace import WorkspaceViewModel +from .workspace_viewmodel import WorkspaceViewModel __all__ = [ 'AcdcFrameMetadataResult', diff --git a/cellacdc/viewmodels/actions_viewmodel.py b/cellacdc/viewmodels/actions_viewmodel.py index d02f3eb40..bdee91845 100644 --- a/cellacdc/viewmodels/actions_viewmodel.py +++ b/cellacdc/viewmodels/actions_viewmodel.py @@ -7,7 +7,7 @@ from cellacdc.models.actions_model import ActionsModel from .app_shell_viewmodel import AppShellViewModel -from .model_registry import ModelRegistryViewModel +from .model_registry_viewmodel import ModelRegistryViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/annotation_display_viewmodel.py b/cellacdc/viewmodels/annotation_display_viewmodel.py index 26f3a4897..17ba0ebbe 100644 --- a/cellacdc/viewmodels/annotation_display_viewmodel.py +++ b/cellacdc/viewmodels/annotation_display_viewmodel.py @@ -55,11 +55,11 @@ def __get__(self, instance, owner): ) from .custom_annotations_viewmodel import CustomAnnotationsViewModel -from .edit_id import EditIdViewModel -from .geometry import GeometryViewModel -from .label_edits import LabelEditViewModel -from .lineage import LineageViewModel -from .model_registry import ModelRegistryViewModel +from .edit_id_viewmodel import EditIdViewModel +from .geometry_viewmodel import GeometryViewModel +from .label_edits_viewmodel import LabelEditViewModel +from .lineage_viewmodel import LineageViewModel +from .model_registry_viewmodel import ModelRegistryViewModel class AnnotationDisplayViewModel(QObject): diff --git a/cellacdc/viewmodels/canvas_drawing_viewmodel.py b/cellacdc/viewmodels/canvas_drawing_viewmodel.py index ed55661c6..8dcdcc72d 100644 --- a/cellacdc/viewmodels/canvas_drawing_viewmodel.py +++ b/cellacdc/viewmodels/canvas_drawing_viewmodel.py @@ -7,8 +7,8 @@ from cellacdc.models.canvas_drawing_model import CanvasDrawingModel -from .geometry import GeometryViewModel -from .label_edits import LabelEditViewModel +from .geometry_viewmodel import GeometryViewModel +from .label_edits_viewmodel import LabelEditViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/canvas_events_viewmodel.py b/cellacdc/viewmodels/canvas_events_viewmodel.py index f2b60496a..72fc04d2a 100644 --- a/cellacdc/viewmodels/canvas_events_viewmodel.py +++ b/cellacdc/viewmodels/canvas_events_viewmodel.py @@ -6,8 +6,8 @@ from cellacdc.models.canvas_events_model import CanvasEventsModel -from .geometry import GeometryViewModel -from .label_edits import LabelEditViewModel +from .geometry_viewmodel import GeometryViewModel +from .label_edits_viewmodel import LabelEditViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/canvas_selection_viewmodel.py b/cellacdc/viewmodels/canvas_selection_viewmodel.py index f41a1ea71..b8006dadd 100644 --- a/cellacdc/viewmodels/canvas_selection_viewmodel.py +++ b/cellacdc/viewmodels/canvas_selection_viewmodel.py @@ -6,8 +6,8 @@ from cellacdc.models.canvas_selection_model import CanvasSelectionModel -from .geometry import GeometryViewModel -from .label_edits import LabelEditViewModel +from .geometry_viewmodel import GeometryViewModel +from .label_edits_viewmodel import LabelEditViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/canvas_tools.py b/cellacdc/viewmodels/canvas_tools.py deleted file mode 100644 index 4b501c4d6..000000000 --- a/cellacdc/viewmodels/canvas_tools.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Compatibility import for the canonical canvas tool view-model module.""" - -from __future__ import annotations - -from .canvas_tool_viewmodel import CanvasToolViewModel - -__all__ = ['CanvasToolViewModel'] diff --git a/cellacdc/viewmodels/cca_edits.py b/cellacdc/viewmodels/cca_edits_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/cca_edits.py rename to cellacdc/viewmodels/cca_edits_viewmodel.py diff --git a/cellacdc/viewmodels/cca_workflows.py b/cellacdc/viewmodels/cca_workflows_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/cca_workflows.py rename to cellacdc/viewmodels/cca_workflows_viewmodel.py diff --git a/cellacdc/viewmodels/cell_cycle_viewmodel.py b/cellacdc/viewmodels/cell_cycle_viewmodel.py index 899fe869f..379718e05 100644 --- a/cellacdc/viewmodels/cell_cycle_viewmodel.py +++ b/cellacdc/viewmodels/cell_cycle_viewmodel.py @@ -10,10 +10,10 @@ CellCycleModel, ) -from .cca_edits import CcaEditViewModel -from .cca_workflows import CcaWorkflowViewModel -from .lineage import LineageViewModel -from .model_registry import ModelRegistryViewModel +from .cca_edits_viewmodel import CcaEditViewModel +from .cca_workflows_viewmodel import CcaWorkflowViewModel +from .lineage_viewmodel import LineageViewModel +from .model_registry_viewmodel import ModelRegistryViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/data_loading_viewmodel.py b/cellacdc/viewmodels/data_loading_viewmodel.py index f2ee6d946..e1a506b76 100644 --- a/cellacdc/viewmodels/data_loading_viewmodel.py +++ b/cellacdc/viewmodels/data_loading_viewmodel.py @@ -13,8 +13,8 @@ OpenImageFileTarget, ) -from .formatting import FormattingViewModel -from .workspace import WorkspaceViewModel +from .formatting_viewmodel import FormattingViewModel +from .workspace_viewmodel import WorkspaceViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/edit_id.py b/cellacdc/viewmodels/edit_id_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/edit_id.py rename to cellacdc/viewmodels/edit_id_viewmodel.py diff --git a/cellacdc/viewmodels/formatting.py b/cellacdc/viewmodels/formatting_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/formatting.py rename to cellacdc/viewmodels/formatting_viewmodel.py diff --git a/cellacdc/viewmodels/frame_metadata.py b/cellacdc/viewmodels/frame_metadata_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/frame_metadata.py rename to cellacdc/viewmodels/frame_metadata_viewmodel.py diff --git a/cellacdc/viewmodels/frame_navigation_viewmodel.py b/cellacdc/viewmodels/frame_navigation_viewmodel.py index 961bbc378..c0d358bbe 100644 --- a/cellacdc/viewmodels/frame_navigation_viewmodel.py +++ b/cellacdc/viewmodels/frame_navigation_viewmodel.py @@ -6,8 +6,8 @@ from cellacdc.models.frame_navigation_model import FrameNavigationModel -from .frame_metadata import FrameMetadataViewModel -from .label_edits import LabelEditViewModel +from .frame_metadata_viewmodel import FrameMetadataViewModel +from .label_edits_viewmodel import LabelEditViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/geometry.py b/cellacdc/viewmodels/geometry_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/geometry.py rename to cellacdc/viewmodels/geometry_viewmodel.py diff --git a/cellacdc/viewmodels/graphics_viewmodel.py b/cellacdc/viewmodels/graphics_viewmodel.py index 0d62df4a1..06cf323c9 100644 --- a/cellacdc/viewmodels/graphics_viewmodel.py +++ b/cellacdc/viewmodels/graphics_viewmodel.py @@ -12,10 +12,10 @@ OverlayVisibilityPlan, ) -from .formatting import FormattingViewModel -from .geometry import GeometryViewModel -from .label_edits import LabelEditViewModel -from .workspace import WorkspaceViewModel +from .formatting_viewmodel import FormattingViewModel +from .geometry_viewmodel import GeometryViewModel +from .label_edits_viewmodel import LabelEditViewModel +from .workspace_viewmodel import WorkspaceViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/image_display_viewmodel.py b/cellacdc/viewmodels/image_display_viewmodel.py index c9ec8de41..61dd39741 100644 --- a/cellacdc/viewmodels/image_display_viewmodel.py +++ b/cellacdc/viewmodels/image_display_viewmodel.py @@ -11,7 +11,7 @@ RightPaneVisibilityPlan, ) -from .formatting import FormattingViewModel +from .formatting_viewmodel import FormattingViewModel from .preprocessing_viewmodel import PreprocessingViewModel diff --git a/cellacdc/viewmodels/label_editing_viewmodel.py b/cellacdc/viewmodels/label_editing_viewmodel.py index b6a18921d..ac11ff0d5 100644 --- a/cellacdc/viewmodels/label_editing_viewmodel.py +++ b/cellacdc/viewmodels/label_editing_viewmodel.py @@ -6,10 +6,10 @@ from cellacdc.models.label_editing_model import LabelEditingModel -from .cca_edits import CcaEditViewModel -from .edit_id import EditIdViewModel -from .geometry import GeometryViewModel -from .label_edits import LabelEditViewModel +from .cca_edits_viewmodel import CcaEditViewModel +from .edit_id_viewmodel import EditIdViewModel +from .geometry_viewmodel import GeometryViewModel +from .label_edits_viewmodel import LabelEditViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/label_edits.py b/cellacdc/viewmodels/label_edits_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/label_edits.py rename to cellacdc/viewmodels/label_edits_viewmodel.py diff --git a/cellacdc/viewmodels/lineage.py b/cellacdc/viewmodels/lineage_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/lineage.py rename to cellacdc/viewmodels/lineage_viewmodel.py diff --git a/cellacdc/viewmodels/magic_prompts_viewmodel.py b/cellacdc/viewmodels/magic_prompts_viewmodel.py index f494dd57f..27b11290f 100644 --- a/cellacdc/viewmodels/magic_prompts_viewmodel.py +++ b/cellacdc/viewmodels/magic_prompts_viewmodel.py @@ -8,7 +8,7 @@ MagicPromptsModel, MagicPromptZoom, ) -from cellacdc.viewmodels.model_registry import ModelRegistryViewModel +from cellacdc.viewmodels.model_registry_viewmodel import ModelRegistryViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/main.py b/cellacdc/viewmodels/main_viewmodel.py similarity index 93% rename from cellacdc/viewmodels/main.py rename to cellacdc/viewmodels/main_viewmodel.py index 881d74a4b..9028b115c 100644 --- a/cellacdc/viewmodels/main.py +++ b/cellacdc/viewmodels/main_viewmodel.py @@ -17,41 +17,41 @@ from .canvas_tool_viewmodel import CanvasToolViewModel from .combine_viewmodel import CombineViewModel from .cell_cycle_viewmodel import CellCycleViewModel -from .cca_edits import CcaEditViewModel -from .cca_workflows import CcaWorkflowViewModel +from .cca_edits_viewmodel import CcaEditViewModel +from .cca_workflows_viewmodel import CcaWorkflowViewModel from .curvature_viewmodel import CurvatureViewModel from .custom_annotations_viewmodel import CustomAnnotationsViewModel from .data_loading_viewmodel import DataLoadingViewModel from .deleted_rois_viewmodel import DeletedRoisViewModel from .display_decorations_viewmodel import DisplayDecorationsViewModel from .draw_clear_region_viewmodel import DrawClearRegionViewModel -from .edit_id import EditIdViewModel +from .edit_id_viewmodel import EditIdViewModel from .exporting_viewmodel import ExportingViewModel -from .frame_metadata import FrameMetadataViewModel +from .frame_metadata_viewmodel import FrameMetadataViewModel from .frame_navigation_viewmodel import FrameNavigationViewModel -from .formatting import FormattingViewModel -from .geometry import GeometryViewModel +from .formatting_viewmodel import FormattingViewModel +from .geometry_viewmodel import GeometryViewModel from .graphics_viewmodel import GraphicsViewModel from .image_controls_viewmodel import ImageControlsViewModel from .image_display_viewmodel import ImageDisplayViewModel from .label_editing_viewmodel import LabelEditingViewModel -from .label_edits import LabelEditViewModel +from .label_edits_viewmodel import LabelEditViewModel from .label_roi_viewmodel import LabelRoiViewModel from .label_transform_tools_viewmodel import LabelTransformToolsViewModel from .layout_controls_viewmodel import LayoutControlsViewModel -from .lineage import LineageViewModel +from .lineage_viewmodel import LineageViewModel from .lineage_interactions_viewmodel import LineageInteractionsViewModel from .magic_prompts_viewmodel import MagicPromptsViewModel from .main_menu_viewmodel import MainMenuViewModel from .main_toolbar_viewmodel import MainToolbarViewModel from .measurements_viewmodel import MeasurementsViewModel from .mode_controls_viewmodel import ModeControlsViewModel -from .model_registry import ModelRegistryViewModel -from .object_counts import ObjectCountViewModel +from .model_registry_viewmodel import ModelRegistryViewModel +from .object_counts_viewmodel import ObjectCountViewModel from .object_cleanup_viewmodel import ObjectCleanupViewModel from .object_properties_viewmodel import ObjectPropertiesViewModel from .object_search_viewmodel import ObjectSearchViewModel -from .points import PointsViewModel +from .points_viewmodel import PointsViewModel from .points_layers_viewmodel import PointsLayersViewModel from .preprocessing_viewmodel import PreprocessingViewModel from .quick_settings_viewmodel import QuickSettingsViewModel @@ -60,14 +60,14 @@ from .segmentation_viewmodel import SegmentationViewModel from .session_viewmodel import SessionViewModel from .status_hover_viewmodel import StatusHoverViewModel -from .tables import TableViewModel +from .tables_viewmodel import TableViewModel from .tool_activation_viewmodel import ToolActivationViewModel from .tracking_viewmodel import TrackingViewModel from .undo_redo_viewmodel import UndoRedoViewModel from .whitelist_viewmodel import WhitelistViewModel from .worker_viewmodel import WorkerViewModel from .window_events_viewmodel import WindowEventsViewModel -from .workspace import WorkspaceViewModel +from .workspace_viewmodel import WorkspaceViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/measurements.py b/cellacdc/viewmodels/measurements.py deleted file mode 100644 index 3d0e5b1a9..000000000 --- a/cellacdc/viewmodels/measurements.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility imports for measurement view-models.""" - -from cellacdc.viewmodels.measurements_viewmodel import MeasurementsViewModel - -__all__ = ['MeasurementsViewModel'] diff --git a/cellacdc/viewmodels/model_registry.py b/cellacdc/viewmodels/model_registry_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/model_registry.py rename to cellacdc/viewmodels/model_registry_viewmodel.py diff --git a/cellacdc/viewmodels/object_cleanup_viewmodel.py b/cellacdc/viewmodels/object_cleanup_viewmodel.py index ed6aad71a..53d6de2ee 100644 --- a/cellacdc/viewmodels/object_cleanup_viewmodel.py +++ b/cellacdc/viewmodels/object_cleanup_viewmodel.py @@ -6,7 +6,7 @@ from cellacdc.models.object_cleanup_model import ObjectCleanupModel -from .workspace import WorkspaceViewModel +from .workspace_viewmodel import WorkspaceViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/object_counts.py b/cellacdc/viewmodels/object_counts_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/object_counts.py rename to cellacdc/viewmodels/object_counts_viewmodel.py diff --git a/cellacdc/viewmodels/object_properties_viewmodel.py b/cellacdc/viewmodels/object_properties_viewmodel.py index 6daacf4e7..d6edcb748 100644 --- a/cellacdc/viewmodels/object_properties_viewmodel.py +++ b/cellacdc/viewmodels/object_properties_viewmodel.py @@ -8,7 +8,7 @@ from cellacdc.models.object_properties_model import ObjectPropertiesModel from .measurements_viewmodel import MeasurementsViewModel -from .object_counts import ObjectCountViewModel +from .object_counts_viewmodel import ObjectCountViewModel diff --git a/cellacdc/viewmodels/points_layers_viewmodel.py b/cellacdc/viewmodels/points_layers_viewmodel.py index 9ba391b88..3e74cab77 100644 --- a/cellacdc/viewmodels/points_layers_viewmodel.py +++ b/cellacdc/viewmodels/points_layers_viewmodel.py @@ -7,7 +7,7 @@ from cellacdc.models.points_layers_model import PointsLayersModel -from .points import PointsViewModel +from .points_viewmodel import PointsViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/points.py b/cellacdc/viewmodels/points_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/points.py rename to cellacdc/viewmodels/points_viewmodel.py diff --git a/cellacdc/viewmodels/saving_viewmodel.py b/cellacdc/viewmodels/saving_viewmodel.py index fdae14fb1..8ffccc4c9 100644 --- a/cellacdc/viewmodels/saving_viewmodel.py +++ b/cellacdc/viewmodels/saving_viewmodel.py @@ -11,11 +11,11 @@ SavingModel, ) -from .cca_workflows import CcaWorkflowViewModel -from .formatting import FormattingViewModel +from .cca_workflows_viewmodel import CcaWorkflowViewModel +from .formatting_viewmodel import FormattingViewModel from .measurements_viewmodel import MeasurementsViewModel from .tracking_viewmodel import TrackingViewModel -from .workspace import WorkspaceViewModel +from .workspace_viewmodel import WorkspaceViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/segmentation_viewmodel.py b/cellacdc/viewmodels/segmentation_viewmodel.py index f2f5cf27b..a2fb87922 100644 --- a/cellacdc/viewmodels/segmentation_viewmodel.py +++ b/cellacdc/viewmodels/segmentation_viewmodel.py @@ -6,7 +6,7 @@ from cellacdc.models.segmentation_model import SegmentationModel -from .model_registry import ModelRegistryViewModel +from .model_registry_viewmodel import ModelRegistryViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/session.py b/cellacdc/viewmodels/session.py deleted file mode 100644 index f46db6999..000000000 --- a/cellacdc/viewmodels/session.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Compatibility re-export for session view-models.""" - -from __future__ import annotations - -from .session_viewmodel import DEFAULT_SESSION_SETTINGS, SessionViewModel - -__all__ = ['DEFAULT_SESSION_SETTINGS', 'SessionViewModel'] diff --git a/cellacdc/viewmodels/session_viewmodel.py b/cellacdc/viewmodels/session_viewmodel.py index 972c0a2b4..877b92e68 100644 --- a/cellacdc/viewmodels/session_viewmodel.py +++ b/cellacdc/viewmodels/session_viewmodel.py @@ -13,10 +13,10 @@ update_last_visited_frame_state, ) -from .cca_edits import CcaEditViewModel -from .frame_metadata import FrameMetadataViewModel -from .tables import TableViewModel -from .workspace import WorkspaceViewModel +from .cca_edits_viewmodel import CcaEditViewModel +from .frame_metadata_viewmodel import FrameMetadataViewModel +from .tables_viewmodel import TableViewModel +from .workspace_viewmodel import WorkspaceViewModel DEFAULT_SESSION_SETTINGS = { diff --git a/cellacdc/viewmodels/tables.py b/cellacdc/viewmodels/tables_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/tables.py rename to cellacdc/viewmodels/tables_viewmodel.py diff --git a/cellacdc/viewmodels/tool_activation_viewmodel.py b/cellacdc/viewmodels/tool_activation_viewmodel.py index 38219ef55..5ac44d751 100644 --- a/cellacdc/viewmodels/tool_activation_viewmodel.py +++ b/cellacdc/viewmodels/tool_activation_viewmodel.py @@ -6,7 +6,7 @@ from cellacdc.models.tool_activation_model import ToolActivationModel -from .label_edits import LabelEditViewModel +from .label_edits_viewmodel import LabelEditViewModel from .tracking_viewmodel import TrackingViewModel diff --git a/cellacdc/viewmodels/tracking.py b/cellacdc/viewmodels/tracking.py deleted file mode 100644 index 933a3927c..000000000 --- a/cellacdc/viewmodels/tracking.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Compatibility exports for tracking view-models.""" - -from __future__ import annotations - -from .tracking_viewmodel import TrackingViewModel - -__all__ = ['TrackingViewModel'] diff --git a/cellacdc/viewmodels/tracking_viewmodel.py b/cellacdc/viewmodels/tracking_viewmodel.py index 44a294846..98cd828cc 100644 --- a/cellacdc/viewmodels/tracking_viewmodel.py +++ b/cellacdc/viewmodels/tracking_viewmodel.py @@ -11,10 +11,10 @@ ) from cellacdc.models.tracking_model import TrackingModel -from .edit_id import EditIdViewModel -from .geometry import GeometryViewModel -from .label_edits import LabelEditViewModel -from .model_registry import ModelRegistryViewModel +from .edit_id_viewmodel import EditIdViewModel +from .geometry_viewmodel import GeometryViewModel +from .label_edits_viewmodel import LabelEditViewModel +from .model_registry_viewmodel import ModelRegistryViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/window_events_viewmodel.py b/cellacdc/viewmodels/window_events_viewmodel.py index e6c18fb04..b94e4f2a2 100644 --- a/cellacdc/viewmodels/window_events_viewmodel.py +++ b/cellacdc/viewmodels/window_events_viewmodel.py @@ -6,7 +6,7 @@ from cellacdc.models.window_events_model import WindowEventsModel -from .geometry import GeometryViewModel +from .geometry_viewmodel import GeometryViewModel @dataclass(frozen=True) diff --git a/cellacdc/viewmodels/workspace.py b/cellacdc/viewmodels/workspace_viewmodel.py similarity index 100% rename from cellacdc/viewmodels/workspace.py rename to cellacdc/viewmodels/workspace_viewmodel.py From 31bf27ea16391f2fd83847d7b8c4b58a1007c7ef Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 09:42:43 +0200 Subject: [PATCH 03/21] refactor: simplify modular architecture by consolidating all thin MVVM components directly into views --- cellacdc/domain/__init__.py | 434 +++++ cellacdc/domain/cell_cycle.py | 1543 +++++++++++++++++ cellacdc/domain/cell_cycle_auto.py | 283 +++ cellacdc/domain/cell_cycle_deletions.py | 337 ++++ cellacdc/domain/cell_cycle_divisions.py | 828 +++++++++ cellacdc/domain/cell_cycle_frames.py | 164 ++ cellacdc/domain/cell_cycle_history.py | 129 ++ cellacdc/domain/curvature.py | 198 +++ cellacdc/domain/custom_annotations.py | 142 ++ cellacdc/domain/display_images.py | 40 + cellacdc/domain/edit_id.py | 136 ++ cellacdc/domain/events.py | 26 + cellacdc/domain/frame_metadata.py | 103 ++ cellacdc/domain/labels.py | 741 ++++++++ cellacdc/domain/lineage.py | 99 ++ cellacdc/domain/metrics_basic.py | 35 + cellacdc/domain/object_counts.py | 107 ++ cellacdc/domain/object_search.py | 36 + cellacdc/domain/points.py | 370 ++++ cellacdc/domain/session.py | 142 ++ cellacdc/domain/state.py | 41 + cellacdc/domain/tracking.py | 221 +++ cellacdc/domain/types.py | 8 + cellacdc/domain/visited_frames.py | 48 + cellacdc/gui.py | 247 +-- cellacdc/models/actions_model.py | 31 - cellacdc/models/annotation_display_model.py | 542 ------ cellacdc/models/app_shell_model.py | 38 - cellacdc/models/brush_tools_model.py | 123 -- cellacdc/models/canvas_context_menu_model.py | 52 - cellacdc/models/canvas_drawing_model.py | 42 - cellacdc/models/canvas_events_model.py | 41 - cellacdc/models/canvas_hover_model.py | 121 -- cellacdc/models/canvas_right_image_model.py | 15 - cellacdc/models/canvas_selection_model.py | 69 - cellacdc/models/canvas_tool_model.py | 51 - cellacdc/models/cell_cycle_model.py | 98 -- cellacdc/models/combine_model.py | 58 - cellacdc/models/curvature_model.py | 97 -- cellacdc/models/custom_annotations_model.py | 97 -- cellacdc/models/data_loading_model.py | 265 --- cellacdc/models/deleted_rois_model.py | 40 - cellacdc/models/display_decorations_model.py | 47 - cellacdc/models/draw_clear_region_model.py | 61 - cellacdc/models/exporting_model.py | 110 -- cellacdc/models/frame_navigation_model.py | 165 -- cellacdc/models/graphics_model.py | 182 -- cellacdc/models/image_controls_model.py | 44 - cellacdc/models/image_display_model.py | 76 - cellacdc/models/label_editing_model.py | 54 - cellacdc/models/label_roi_model.py | 135 -- .../models/label_transform_tools_model.py | 35 - cellacdc/models/layout_controls_model.py | 38 - cellacdc/models/lineage_interactions_model.py | 131 -- cellacdc/models/magic_prompts_model.py | 77 - cellacdc/models/main_menu_model.py | 20 - cellacdc/models/main_toolbar_model.py | 18 - cellacdc/models/measurements_model.py | 53 - cellacdc/models/mode_controls_model.py | 41 - cellacdc/models/object_cleanup_model.py | 17 - cellacdc/models/object_properties_model.py | 170 -- cellacdc/models/object_search_model.py | 25 - cellacdc/models/points_layers_model.py | 65 - cellacdc/models/preprocessing_model.py | 74 - cellacdc/models/quick_settings_model.py | 37 - cellacdc/models/saving_model.py | 155 -- cellacdc/models/seg_for_lost_ids_model.py | 125 -- cellacdc/models/segmentation_model.py | 68 - cellacdc/models/session_model.py | 56 - cellacdc/models/status_hover_model.py | 122 -- cellacdc/models/tool_activation_model.py | 47 - cellacdc/models/tracking_model.py | 89 - cellacdc/models/undo_redo_model.py | 32 - cellacdc/models/whitelist_model.py | 100 -- cellacdc/models/window_events_model.py | 7 - cellacdc/models/worker_model.py | 34 - cellacdc/viewmodels/__init__.py | 98 -- cellacdc/viewmodels/actions_viewmodel.py | 56 - .../annotation_display_viewmodel.py | 448 ----- cellacdc/viewmodels/app_shell_viewmodel.py | 29 - cellacdc/viewmodels/brush_tools_viewmodel.py | 87 - .../canvas_context_menu_viewmodel.py | 51 - .../viewmodels/canvas_drawing_viewmodel.py | 60 - .../viewmodels/canvas_events_viewmodel.py | 55 - cellacdc/viewmodels/canvas_hover_viewmodel.py | 59 - .../canvas_right_image_viewmodel.py | 25 - .../viewmodels/canvas_selection_viewmodel.py | 49 - cellacdc/viewmodels/canvas_tool_viewmodel.py | 66 - cellacdc/viewmodels/cell_cycle_viewmodel.py | 67 - cellacdc/viewmodels/combine_viewmodel.py | 29 - cellacdc/viewmodels/curvature_viewmodel.py | 98 -- .../custom_annotations_viewmodel.py | 93 - cellacdc/viewmodels/data_loading_viewmodel.py | 73 - cellacdc/viewmodels/deleted_rois_viewmodel.py | 48 - .../display_decorations_viewmodel.py | 57 - .../viewmodels/draw_clear_region_viewmodel.py | 55 - cellacdc/viewmodels/exporting_viewmodel.py | 58 - .../viewmodels/frame_navigation_viewmodel.py | 66 - cellacdc/viewmodels/graphics_viewmodel.py | 117 -- .../viewmodels/image_controls_viewmodel.py | 32 - .../viewmodels/image_display_viewmodel.py | 57 - .../viewmodels/label_editing_viewmodel.py | 82 - cellacdc/viewmodels/label_roi_viewmodel.py | 121 -- .../label_transform_tools_viewmodel.py | 51 - .../viewmodels/layout_controls_viewmodel.py | 40 - .../lineage_interactions_viewmodel.py | 108 -- .../viewmodels/magic_prompts_viewmodel.py | 57 - cellacdc/viewmodels/main_menu_viewmodel.py | 20 - cellacdc/viewmodels/main_toolbar_viewmodel.py | 17 - cellacdc/viewmodels/main_viewmodel.py | 199 +-- cellacdc/viewmodels/measurements_viewmodel.py | 49 - .../viewmodels/mode_controls_viewmodel.py | 36 - .../viewmodels/object_cleanup_viewmodel.py | 30 - .../viewmodels/object_properties_viewmodel.py | 180 -- .../viewmodels/object_search_viewmodel.py | 28 - .../viewmodels/points_layers_viewmodel.py | 67 - .../viewmodels/preprocessing_viewmodel.py | 65 - .../viewmodels/quick_settings_viewmodel.py | 33 - cellacdc/viewmodels/saving_viewmodel.py | 137 -- .../viewmodels/seg_for_lost_ids_viewmodel.py | 53 - cellacdc/viewmodels/segmentation_viewmodel.py | 82 - cellacdc/viewmodels/session_viewmodel.py | 144 -- cellacdc/viewmodels/status_hover_viewmodel.py | 53 - .../viewmodels/tool_activation_viewmodel.py | 60 - cellacdc/viewmodels/tracking_viewmodel.py | 101 -- cellacdc/viewmodels/undo_redo_viewmodel.py | 39 - cellacdc/viewmodels/whitelist_viewmodel.py | 46 - .../viewmodels/window_events_viewmodel.py | 35 - cellacdc/viewmodels/worker_viewmodel.py | 36 - cellacdc/views/actions_view.py | 72 +- cellacdc/views/annotation_display_view.py | 531 +++++- cellacdc/views/app_shell_view.py | 37 +- cellacdc/views/brush_tools_view.py | 136 +- cellacdc/views/canvas_context_menu_view.py | 55 +- cellacdc/views/canvas_drawing_view.py | 78 +- cellacdc/views/canvas_events_view.py | 83 +- cellacdc/views/canvas_hover_view.py | 143 +- cellacdc/views/canvas_right_image_view.py | 22 +- cellacdc/views/canvas_selection_view.py | 118 +- cellacdc/views/canvas_tool_view.py | 38 +- cellacdc/views/cell_cycle_view.py | 231 ++- cellacdc/views/combine_view.py | 69 +- cellacdc/views/curvature_tools_view.py | 102 +- cellacdc/views/custom_annotations_view.py | 108 +- cellacdc/views/data_loading_view.py | 244 ++- cellacdc/views/deleted_rois_view.py | 66 +- cellacdc/views/display_decorations_view.py | 64 +- cellacdc/views/draw_clear_region_view.py | 64 +- cellacdc/views/exporting_view.py | 118 +- cellacdc/views/frame_navigation_view.py | 196 ++- cellacdc/views/graphics_view.py | 203 ++- cellacdc/views/image_controls_view.py | 61 +- cellacdc/views/image_display_view.py | 86 +- cellacdc/views/label_editing_view.py | 101 +- cellacdc/views/label_roi_view.py | 158 +- cellacdc/views/label_transform_tools_view.py | 52 +- cellacdc/views/layout_controls_view.py | 54 +- cellacdc/views/lineage_interactions_view.py | 160 +- cellacdc/views/magic_prompts_view.py | 88 +- cellacdc/views/main_menu_view.py | 27 +- cellacdc/views/main_toolbar_view.py | 25 +- cellacdc/views/measurements_view.py | 63 +- cellacdc/views/mode_controls_view.py | 58 +- cellacdc/views/object_cleanup_view.py | 27 +- cellacdc/views/object_properties_view.py | 213 ++- cellacdc/views/object_search_view.py | 27 +- cellacdc/views/points_layers_view.py | 103 +- cellacdc/views/preprocessing_view.py | 91 +- cellacdc/views/quick_settings_view.py | 37 +- cellacdc/views/saving_view.py | 183 +- cellacdc/views/seg_for_lost_ids_view.py | 133 +- cellacdc/views/segmentation_view.py | 105 +- cellacdc/views/session_view.py | 88 +- cellacdc/views/status_hover_view.py | 146 +- cellacdc/views/tool_activation_view.py | 72 +- cellacdc/views/tracking_view.py | 109 +- cellacdc/views/undo_redo_view.py | 56 +- cellacdc/views/whitelist_view.py | 119 +- cellacdc/views/window_events_view.py | 32 +- cellacdc/views/worker_view.py | 51 +- 180 files changed, 10611 insertions(+), 9342 deletions(-) create mode 100644 cellacdc/domain/__init__.py create mode 100644 cellacdc/domain/cell_cycle.py create mode 100644 cellacdc/domain/cell_cycle_auto.py create mode 100644 cellacdc/domain/cell_cycle_deletions.py create mode 100644 cellacdc/domain/cell_cycle_divisions.py create mode 100644 cellacdc/domain/cell_cycle_frames.py create mode 100644 cellacdc/domain/cell_cycle_history.py create mode 100644 cellacdc/domain/curvature.py create mode 100644 cellacdc/domain/custom_annotations.py create mode 100644 cellacdc/domain/display_images.py create mode 100644 cellacdc/domain/edit_id.py create mode 100644 cellacdc/domain/events.py create mode 100644 cellacdc/domain/frame_metadata.py create mode 100644 cellacdc/domain/labels.py create mode 100644 cellacdc/domain/lineage.py create mode 100644 cellacdc/domain/metrics_basic.py create mode 100644 cellacdc/domain/object_counts.py create mode 100644 cellacdc/domain/object_search.py create mode 100644 cellacdc/domain/points.py create mode 100644 cellacdc/domain/session.py create mode 100644 cellacdc/domain/state.py create mode 100644 cellacdc/domain/tracking.py create mode 100644 cellacdc/domain/types.py create mode 100644 cellacdc/domain/visited_frames.py delete mode 100644 cellacdc/models/actions_model.py delete mode 100644 cellacdc/models/annotation_display_model.py delete mode 100644 cellacdc/models/app_shell_model.py delete mode 100644 cellacdc/models/brush_tools_model.py delete mode 100644 cellacdc/models/canvas_context_menu_model.py delete mode 100644 cellacdc/models/canvas_drawing_model.py delete mode 100644 cellacdc/models/canvas_events_model.py delete mode 100644 cellacdc/models/canvas_hover_model.py delete mode 100644 cellacdc/models/canvas_right_image_model.py delete mode 100644 cellacdc/models/canvas_selection_model.py delete mode 100644 cellacdc/models/canvas_tool_model.py delete mode 100644 cellacdc/models/cell_cycle_model.py delete mode 100644 cellacdc/models/combine_model.py delete mode 100644 cellacdc/models/curvature_model.py delete mode 100644 cellacdc/models/custom_annotations_model.py delete mode 100644 cellacdc/models/data_loading_model.py delete mode 100644 cellacdc/models/deleted_rois_model.py delete mode 100644 cellacdc/models/display_decorations_model.py delete mode 100644 cellacdc/models/draw_clear_region_model.py delete mode 100644 cellacdc/models/exporting_model.py delete mode 100644 cellacdc/models/frame_navigation_model.py delete mode 100644 cellacdc/models/graphics_model.py delete mode 100644 cellacdc/models/image_controls_model.py delete mode 100644 cellacdc/models/image_display_model.py delete mode 100644 cellacdc/models/label_editing_model.py delete mode 100644 cellacdc/models/label_roi_model.py delete mode 100644 cellacdc/models/label_transform_tools_model.py delete mode 100644 cellacdc/models/layout_controls_model.py delete mode 100644 cellacdc/models/lineage_interactions_model.py delete mode 100644 cellacdc/models/magic_prompts_model.py delete mode 100644 cellacdc/models/main_menu_model.py delete mode 100644 cellacdc/models/main_toolbar_model.py delete mode 100644 cellacdc/models/measurements_model.py delete mode 100644 cellacdc/models/mode_controls_model.py delete mode 100644 cellacdc/models/object_cleanup_model.py delete mode 100644 cellacdc/models/object_properties_model.py delete mode 100644 cellacdc/models/object_search_model.py delete mode 100644 cellacdc/models/points_layers_model.py delete mode 100644 cellacdc/models/preprocessing_model.py delete mode 100644 cellacdc/models/quick_settings_model.py delete mode 100644 cellacdc/models/saving_model.py delete mode 100644 cellacdc/models/seg_for_lost_ids_model.py delete mode 100644 cellacdc/models/segmentation_model.py delete mode 100644 cellacdc/models/session_model.py delete mode 100644 cellacdc/models/status_hover_model.py delete mode 100644 cellacdc/models/tool_activation_model.py delete mode 100644 cellacdc/models/tracking_model.py delete mode 100644 cellacdc/models/undo_redo_model.py delete mode 100644 cellacdc/models/whitelist_model.py delete mode 100644 cellacdc/models/window_events_model.py delete mode 100644 cellacdc/models/worker_model.py delete mode 100644 cellacdc/viewmodels/actions_viewmodel.py delete mode 100644 cellacdc/viewmodels/annotation_display_viewmodel.py delete mode 100644 cellacdc/viewmodels/app_shell_viewmodel.py delete mode 100644 cellacdc/viewmodels/brush_tools_viewmodel.py delete mode 100644 cellacdc/viewmodels/canvas_context_menu_viewmodel.py delete mode 100644 cellacdc/viewmodels/canvas_drawing_viewmodel.py delete mode 100644 cellacdc/viewmodels/canvas_events_viewmodel.py delete mode 100644 cellacdc/viewmodels/canvas_hover_viewmodel.py delete mode 100644 cellacdc/viewmodels/canvas_right_image_viewmodel.py delete mode 100644 cellacdc/viewmodels/canvas_selection_viewmodel.py delete mode 100644 cellacdc/viewmodels/canvas_tool_viewmodel.py delete mode 100644 cellacdc/viewmodels/cell_cycle_viewmodel.py delete mode 100644 cellacdc/viewmodels/combine_viewmodel.py delete mode 100644 cellacdc/viewmodels/curvature_viewmodel.py delete mode 100644 cellacdc/viewmodels/custom_annotations_viewmodel.py delete mode 100644 cellacdc/viewmodels/data_loading_viewmodel.py delete mode 100644 cellacdc/viewmodels/deleted_rois_viewmodel.py delete mode 100644 cellacdc/viewmodels/display_decorations_viewmodel.py delete mode 100644 cellacdc/viewmodels/draw_clear_region_viewmodel.py delete mode 100644 cellacdc/viewmodels/exporting_viewmodel.py delete mode 100644 cellacdc/viewmodels/frame_navigation_viewmodel.py delete mode 100644 cellacdc/viewmodels/graphics_viewmodel.py delete mode 100644 cellacdc/viewmodels/image_controls_viewmodel.py delete mode 100644 cellacdc/viewmodels/image_display_viewmodel.py delete mode 100644 cellacdc/viewmodels/label_editing_viewmodel.py delete mode 100644 cellacdc/viewmodels/label_roi_viewmodel.py delete mode 100644 cellacdc/viewmodels/label_transform_tools_viewmodel.py delete mode 100644 cellacdc/viewmodels/layout_controls_viewmodel.py delete mode 100644 cellacdc/viewmodels/lineage_interactions_viewmodel.py delete mode 100644 cellacdc/viewmodels/magic_prompts_viewmodel.py delete mode 100644 cellacdc/viewmodels/main_menu_viewmodel.py delete mode 100644 cellacdc/viewmodels/main_toolbar_viewmodel.py delete mode 100644 cellacdc/viewmodels/measurements_viewmodel.py delete mode 100644 cellacdc/viewmodels/mode_controls_viewmodel.py delete mode 100644 cellacdc/viewmodels/object_cleanup_viewmodel.py delete mode 100644 cellacdc/viewmodels/object_properties_viewmodel.py delete mode 100644 cellacdc/viewmodels/object_search_viewmodel.py delete mode 100644 cellacdc/viewmodels/points_layers_viewmodel.py delete mode 100644 cellacdc/viewmodels/preprocessing_viewmodel.py delete mode 100644 cellacdc/viewmodels/quick_settings_viewmodel.py delete mode 100644 cellacdc/viewmodels/saving_viewmodel.py delete mode 100644 cellacdc/viewmodels/seg_for_lost_ids_viewmodel.py delete mode 100644 cellacdc/viewmodels/segmentation_viewmodel.py delete mode 100644 cellacdc/viewmodels/session_viewmodel.py delete mode 100644 cellacdc/viewmodels/status_hover_viewmodel.py delete mode 100644 cellacdc/viewmodels/tool_activation_viewmodel.py delete mode 100644 cellacdc/viewmodels/tracking_viewmodel.py delete mode 100644 cellacdc/viewmodels/undo_redo_viewmodel.py delete mode 100644 cellacdc/viewmodels/whitelist_viewmodel.py delete mode 100644 cellacdc/viewmodels/window_events_viewmodel.py delete mode 100644 cellacdc/viewmodels/worker_viewmodel.py diff --git a/cellacdc/domain/__init__.py b/cellacdc/domain/__init__.py new file mode 100644 index 000000000..0aa866ad1 --- /dev/null +++ b/cellacdc/domain/__init__.py @@ -0,0 +1,434 @@ +"""Headless domain model for Cell-ACDC (plan ``core/`` layer).""" + +from .cell_cycle import ( + CcaDisappearedBeforeDivisionFrameResult, + CcaFrameRemovalResult, + CcaFutureRemovalResult, + CcaMotherStatusRestoreResult, + CcaSPhaseDisappearancePropagationResult, + CcaSnapshotIdChanges, + CcaSnapshotIdEditResult, + CcaWillDivideFrameResult, + DEFAULT_CELL_CYCLE_ANNOTATION_VALUES, + DEFAULT_LINEAGE_TREE_ANNOTATION_VALUES, + ExistingNewIdCcaRowsResult, + FutureBudDivisionResult, + MotherEligibilityFrameResult, + MotherEligibilityIssue, + MotherBudPair, + MissingCcaAnnotationItem, + add_base_cell_cycle_annotation, + apply_snapshot_cca_id_edits, + apply_mother_bud_pairing, + annotate_division, + apply_manual_cca_changes, + assign_bud_to_mother, + base_cell_cycle_annotation_status, + build_base_cell_cycle_annotations, + bud_known_history_status, + cca_snapshot_id_changes, + collect_existing_new_id_cca_rows, + collect_existing_new_id_cca_rows_from_frames, + concat_cell_cycle_annotations, + dead_or_excluded_mother_pairs, + division_undo_blocking_frame, + evaluate_mother_future_eligibility_frame, + evaluate_mother_past_eligibility_frame, + ensure_cca_columns, + extract_cell_cycle_annotations, + fix_will_divide_without_next_generation, + future_bud_division, + has_cell_cycle_annotations, + last_annotated_cca_by_cell, + last_annotated_cell_cycle_frame_index, + mark_current_relative_after_disappearance, + mark_disappeared_before_division_frame, + mark_will_divide_frame, + merge_missing_cca_ids, + missing_cell_cycle_annotation_items, + mother_not_g1_before_bud_emergence_frame, + mother_status_before_wrong_bud, + overlay_last_annotated_cca, + propagate_s_phase_disappearance_divisions, + relabel_cca_ids, + remove_cell_cycle_annotations, + remove_future_cell_cycle_annotations, + reset_cca_future_flags, + reset_will_divide_for_generations, + relative_status_before_bud_emergence, + restore_mother_status_for_wrong_bud_frame, + restore_mother_status_until_g1, + s_phase_relative_ids_gone, + split_concat_cell_cycle_annotations, + store_cell_cycle_annotations, + toggle_history_knowledge, + undo_bud_mother_assignment, + undo_division_annotation, + wrong_bud_id_for_mother, + will_divide_without_next_generation_ids, +) +from .cell_cycle_auto import ( + AutoCcaAssignmentResult, + AutoCcaFrameInitResult, + AutoCcaRepeatFrameResult, + apply_auto_cca_assignments, + apply_auto_bud_assignment, + auto_cca_assignments_from_cost, + auto_cca_candidate_mother_ids, + auto_cca_cost_matrix_from_contours, + auto_cca_cost_matrix_from_distances, + auto_cca_repeat_frame_state, + merge_current_with_found_cca_rows, + nearest_point_2d_yx, + prepare_auto_cca_current_frame, + uncorrected_new_ids_for_auto_cca, +) +from .cell_cycle_deletions import ( + CcaDeletedIdsPropagationResult, + CcaDeletedIdsResult, + CcaDeletedRelativeStatusesResult, + CcaRelativeRestoreResult, + apply_cca_deleted_ids_to_frame, + apply_deleted_cell_cycle_ids_to_frame, + delete_cca_ids, + deleted_relative_cca_status, + propagate_deleted_cell_cycle_ids, + restore_cca_relative_statuses, + restore_deleted_relative_cell_cycle_statuses, +) +from .cell_cycle_divisions import ( + CcaBudMotherAssignmentPropagationResult, + CcaBudMotherChangeEligibilityResult, + CcaManualDivisionPropagationResult, + CcaMotherAssignmentEligibilityResult, + CcaMotherBudPairingsResult, + CcaSwapMothersEligibilityResult, + CcaSwapMothersFutureDivisionResult, + CcaSwapMothersPairingPlan, + CcaSwapMothersPastRestoreResult, + CcaSwapMothersPropagationResult, + CcaWillDividePropagationResult, + apply_mother_bud_pairings, + previous_relative_status_before_bud_emergence, + bud_mother_change_eligibility, + mother_assignment_eligibility, + propagate_bud_mother_assignment, + propagate_manual_division_annotation, + propagate_swap_mothers_assignment, + propagate_swap_mothers_future_division, + propagate_will_divide, + restore_swap_mothers_past_status, + swap_mothers_eligibility, + swap_mothers_pairing_plan, +) +from .cell_cycle_frames import ( + CcaFrameResolutionResult, + CcaFrameStoreResult, + CcaMissingFramesInitResult, + normalize_loaded_cell_cycle_frame_annotations, + prepare_cell_cycle_checker_annotations, + prepare_missing_cell_cycle_frame_annotations, + resolve_cell_cycle_annotations, + store_cell_cycle_frame_annotations, +) +from .cell_cycle_history import ( + CcaHistoryKnowledgePropagationResult, + apply_history_knowledge_to_frame, + known_history_status_for_bud, + propagate_history_knowledge, +) +from .custom_annotations import ( + CustomAnnotationColumnResult, + CustomAnnotationFrameUpdate, + custom_annotation_column_exists, + drop_custom_annotation_column, + ensure_custom_annotation_column, + remap_custom_annotation_ids, + rename_custom_annotation_column, + update_custom_annotation_frame, +) +from .edit_id import ( + ManualEditTrackingResult, + add_yx_centroids_to_df, + apply_manual_edit_tracking, + edit_id_info_from_df, + manual_edit_conflicts, + project_centroid, +) +from .frame_metadata import ( + AcdcFrameMetadataResult, + build_acdc_frame_metadata, + concat_visited_acdc_frames, +) +from . import labels +from .labels import ( + DeletedRoiApplyResult, + DeletedRoiRestoreResult, + LabelBorderClearResult, + LabelHoleFillResult, + LabelIdMappingResult, + LabelIdsRemovalResult, + LabelRegionSelectionResult, + LabelRoiIndexResult, + LabelMoveResult, + LabelResizeResult, + apply_deleted_roi_masks, + apply_label_id_mapping, + clear_border_labels, + collect_deleted_roi_ids, + fill_label_holes, + index_label_roi, + label_ids_from_labels, + label_ids_in_masks, + line_roi_mask, + move_label_object, + next_available_label_id, + polygon_roi_mask, + rectangle_roi_mask, + remap_id_set, + remove_new_label_ids, + select_labels_in_region, + restore_deleted_roi_labels, + resize_label_object, +) +from .lineage import ( + LineageAnnotationsRemovalResult, + LineageFutureRemovalResult, + has_lineage_tree_annotations, + remove_future_lineage_tree_annotations, + remove_lineage_tree_annotations, +) +from .points import ( + add_click_point, + click_points_table_to_data, + flatten_frame_points_data, + infer_points_column_mapping, + interpolate_points_zslices, + next_click_point_id, + point_id_already_new, + points_data_to_table, + points_table_to_data, + remove_click_points, +) +from .session import ExperimentSession, PositionSession +from .tracking import ( + FutureIdPropagationScan, + LostNewIdsResult, + TrackedLostIdsResult, + compute_lost_new_ids, + last_tracked_frame_index, + scan_future_id_propagation, + track_labels, + tracked_lost_centroids_from_regionprops, + tracked_lost_ids_from_centroids, +) +from .types import CellID, ChannelName, FrameIndex, PixelSize +from .visited_frames import ( + LastVisitedFrameUpdate, + update_last_visited_frame_state, +) + +__all__ = [ + 'CellID', + 'ChannelName', + 'CustomAnnotationColumnResult', + 'CustomAnnotationFrameUpdate', + 'ExperimentSession', + 'FrameIndex', + 'FutureIdPropagationScan', + 'DeletedRoiApplyResult', + 'DeletedRoiRestoreResult', + 'LabelBorderClearResult', + 'LabelHoleFillResult', + 'LabelIdMappingResult', + 'LabelIdsRemovalResult', + 'LabelRegionSelectionResult', + 'LabelRoiIndexResult', + 'LabelMoveResult', + 'LabelResizeResult', + 'LastVisitedFrameUpdate', + 'LineageAnnotationsRemovalResult', + 'LineageFutureRemovalResult', + 'LostNewIdsResult', + 'ManualEditTrackingResult', + 'PixelSize', + 'PositionSession', + 'TrackedLostIdsResult', + 'add_click_point', + 'add_yx_centroids_to_df', + 'annotate_division', + 'apply_auto_bud_assignment', + 'apply_auto_cca_assignments', + 'apply_cca_deleted_ids_to_frame', + 'apply_deleted_cell_cycle_ids_to_frame', + 'apply_deleted_roi_masks', + 'apply_history_knowledge_to_frame', + 'apply_label_id_mapping', + 'apply_manual_edit_tracking', + 'apply_manual_cca_changes', + 'apply_mother_bud_pairing', + 'apply_mother_bud_pairings', + 'apply_snapshot_cca_id_edits', + 'AcdcFrameMetadataResult', + 'assign_bud_to_mother', + 'CcaDeletedIdsPropagationResult', + 'CcaDeletedIdsResult', + 'CcaDeletedRelativeStatusesResult', + 'CcaDisappearedBeforeDivisionFrameResult', + 'CcaFrameRemovalResult', + 'CcaFrameResolutionResult', + 'CcaFrameStoreResult', + 'CcaFutureRemovalResult', + 'CcaBudMotherAssignmentPropagationResult', + 'CcaBudMotherChangeEligibilityResult', + 'CcaHistoryKnowledgePropagationResult', + 'CcaManualDivisionPropagationResult', + 'CcaMotherAssignmentEligibilityResult', + 'CcaMotherBudPairingsResult', + 'CcaMissingFramesInitResult', + 'CcaMotherStatusRestoreResult', + 'CcaRelativeRestoreResult', + 'CcaSPhaseDisappearancePropagationResult', + 'CcaSnapshotIdChanges', + 'CcaSnapshotIdEditResult', + 'CcaSwapMothersEligibilityResult', + 'CcaSwapMothersFutureDivisionResult', + 'CcaSwapMothersPairingPlan', + 'CcaSwapMothersPastRestoreResult', + 'CcaSwapMothersPropagationResult', + 'CcaWillDivideFrameResult', + 'CcaWillDividePropagationResult', + 'DEFAULT_CELL_CYCLE_ANNOTATION_VALUES', + 'DEFAULT_LINEAGE_TREE_ANNOTATION_VALUES', + 'AutoCcaRepeatFrameResult', + 'AutoCcaAssignmentResult', + 'AutoCcaFrameInitResult', + 'ExistingNewIdCcaRowsResult', + 'FutureBudDivisionResult', + 'MotherEligibilityFrameResult', + 'MotherEligibilityIssue', + 'MotherBudPair', + 'MissingCcaAnnotationItem', + 'add_base_cell_cycle_annotation', + 'base_cell_cycle_annotation_status', + 'build_acdc_frame_metadata', + 'build_base_cell_cycle_annotations', + 'auto_cca_assignments_from_cost', + 'auto_cca_candidate_mother_ids', + 'auto_cca_cost_matrix_from_contours', + 'auto_cca_cost_matrix_from_distances', + 'auto_cca_repeat_frame_state', + 'previous_relative_status_before_bud_emergence', + 'bud_mother_change_eligibility', + 'bud_known_history_status', + 'cca_snapshot_id_changes', + 'click_points_table_to_data', + 'collect_deleted_roi_ids', + 'collect_existing_new_id_cca_rows', + 'collect_existing_new_id_cca_rows_from_frames', + 'concat_cell_cycle_annotations', + 'concat_visited_acdc_frames', + 'clear_border_labels', + 'compute_lost_new_ids', + 'dead_or_excluded_mother_pairs', + 'custom_annotation_column_exists', + 'delete_cca_ids', + 'deleted_relative_cca_status', + 'division_undo_blocking_frame', + 'edit_id_info_from_df', + 'evaluate_mother_future_eligibility_frame', + 'evaluate_mother_past_eligibility_frame', + 'ensure_cca_columns', + 'ensure_custom_annotation_column', + 'extract_cell_cycle_annotations', + 'fill_label_holes', + 'index_label_roi', + 'fix_will_divide_without_next_generation', + 'future_bud_division', + 'has_cell_cycle_annotations', + 'has_lineage_tree_annotations', + 'last_annotated_cca_by_cell', + 'last_annotated_cell_cycle_frame_index', + 'known_history_status_for_bud', + 'last_tracked_frame_index', + 'flatten_frame_points_data', + 'infer_points_column_mapping', + 'interpolate_points_zslices', + 'label_ids_from_labels', + 'label_ids_in_masks', + 'line_roi_mask', + 'manual_edit_conflicts', + 'mark_current_relative_after_disappearance', + 'mark_disappeared_before_division_frame', + 'mark_will_divide_frame', + 'merge_current_with_found_cca_rows', + 'merge_missing_cca_ids', + 'missing_cell_cycle_annotation_items', + 'mother_not_g1_before_bud_emergence_frame', + 'mother_status_before_wrong_bud', + 'nearest_point_2d_yx', + 'normalize_loaded_cell_cycle_frame_annotations', + 'overlay_last_annotated_cca', + 'prepare_missing_cell_cycle_frame_annotations', + 'move_label_object', + 'next_available_label_id', + 'next_click_point_id', + 'point_id_already_new', + 'points_data_to_table', + 'points_table_to_data', + 'polygon_roi_mask', + 'project_centroid', + 'rectangle_roi_mask', + 'remove_new_label_ids', + 'select_labels_in_region', + 'prepare_cell_cycle_checker_annotations', + 'prepare_auto_cca_current_frame', + 'propagate_deleted_cell_cycle_ids', + 'propagate_history_knowledge', + 'propagate_manual_division_annotation', + 'propagate_s_phase_disappearance_divisions', + 'propagate_swap_mothers_future_division', + 'propagate_will_divide', + 'relabel_cca_ids', + 'drop_custom_annotation_column', + 'remove_cell_cycle_annotations', + 'remove_click_points', + 'remove_future_cell_cycle_annotations', + 'remove_future_lineage_tree_annotations', + 'remove_lineage_tree_annotations', + 'remap_custom_annotation_ids', + 'remap_id_set', + 'reset_cca_future_flags', + 'reset_will_divide_for_generations', + 'resolve_cell_cycle_annotations', + 'relative_status_before_bud_emergence', + 'restore_mother_status_for_wrong_bud_frame', + 'restore_mother_status_until_g1', + 'restore_swap_mothers_past_status', + 'restore_cca_relative_statuses', + 'restore_deleted_relative_cell_cycle_statuses', + 'restore_deleted_roi_labels', + 'resize_label_object', + 'rename_custom_annotation_column', + 's_phase_relative_ids_gone', + 'scan_future_id_propagation', + 'split_concat_cell_cycle_annotations', + 'store_cell_cycle_annotations', + 'store_cell_cycle_frame_annotations', + 'mother_assignment_eligibility', + 'propagate_bud_mother_assignment', + 'propagate_swap_mothers_assignment', + 'swap_mothers_eligibility', + 'swap_mothers_pairing_plan', + 'toggle_history_knowledge', + 'track_labels', + 'tracked_lost_centroids_from_regionprops', + 'tracked_lost_ids_from_centroids', + 'uncorrected_new_ids_for_auto_cca', + 'undo_bud_mother_assignment', + 'undo_division_annotation', + 'update_custom_annotation_frame', + 'update_last_visited_frame_state', + 'wrong_bud_id_for_mother', + 'will_divide_without_next_generation_ids', +] diff --git a/cellacdc/domain/cell_cycle.py b/cellacdc/domain/cell_cycle.py new file mode 100644 index 000000000..4a416039d --- /dev/null +++ b/cellacdc/domain/cell_cycle.py @@ -0,0 +1,1543 @@ +"""Pure cell-cycle annotation table operations.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + + +DEFAULT_CELL_CYCLE_ANNOTATION_VALUES = { + 'cell_cycle_stage': 'G1', + 'generation_num': 2, + 'relative_ID': -1, + 'relationship': 'mother', + 'emerg_frame_i': -1, + 'division_frame_i': -1, + 'is_history_known': False, + 'corrected_on_frame_i': -1, + 'will_divide': 0, + 'daughter_disappears_before_division': 0, + 'disappears_before_division': 0, +} + +DEFAULT_LINEAGE_TREE_ANNOTATION_VALUES = { + 'Cell_ID_tree': -1, + 'generation_num_tree': 1, + 'parent_ID_tree': -1, + 'root_ID_tree': -1, + 'sister_ID_tree': -1, +} + + +@dataclass(frozen=True) +class CcaSnapshotIdChanges: + """ID additions/deletions needed after a segmentation edit.""" + + new_ids: list[int] + deleted_ids: list[int] + reset_base: bool = False + + +@dataclass(frozen=True) +class CcaSnapshotIdEditResult: + """CCA table after applying snapshot label-ID edits.""" + + cca_df: pd.DataFrame + changes: CcaSnapshotIdChanges + restored_relative_ids: list[int] + + +@dataclass(frozen=True) +class CcaMotherStatusRestoreResult: + """CCA table after restoring a mother status for one frame.""" + + cca_df: pd.DataFrame + restored: bool + + +@dataclass(frozen=True) +class CcaWillDivideFrameResult: + """CCA table after applying one will-divide propagation step.""" + + cca_df: pd.DataFrame + generation_num: int | None + should_store: bool + stop: bool + + +@dataclass(frozen=True) +class CcaFrameRemovalResult: + """ACDC frame after removing CCA columns.""" + + acdc_df: pd.DataFrame | None + removed: bool + missing_frame: bool = False + + +@dataclass(frozen=True) +class CcaFutureRemovalResult: + """Future CCA removals for frame records and concatenated ACDC data.""" + + acdc_dfs_by_frame: dict[int, pd.DataFrame] + cache_frame_indices: list[int] + removed_frame_indices: list[int] + concatenated_acdc_df: pd.DataFrame | None + stopped_at_frame_i: int | None = None + + +@dataclass(frozen=True) +class ExistingNewIdCcaRowsResult: + """Past CCA rows found for new IDs plus IDs that remain new.""" + + found_cca_dfs: list[pd.DataFrame] + remaining_new_ids: list[int] + + +@dataclass(frozen=True) +class CcaDisappearedBeforeDivisionFrameResult: + """CCA frame update for disappeared-before-division propagation.""" + + cca_df: pd.DataFrame | None + should_store: bool + stop: bool + + +@dataclass(frozen=True) +class CcaSPhaseDisappearancePropagationResult: + """CCA updates after S-phase cells disappear before division.""" + + previous_cca_df: pd.DataFrame + current_cca_df: pd.DataFrame | None + updated_cca_dfs_by_frame: dict[int, pd.DataFrame] + disappeared_ids: list[int] + automatically_divided_ids: list[int] + + +@dataclass(frozen=True) +class FutureBudDivisionResult: + """Future frame where a bud is already annotated as divided.""" + + frame_i: int + mother_id: int + + +@dataclass(frozen=True) +class MotherBudPair: + """Mother-bud ID pair.""" + + bud_id: int + mother_id: int + + +@dataclass(frozen=True) +class MotherEligibilityIssue: + """Reason a mother candidate is not eligible on one frame.""" + + mother_id: int + bud_id: int + frame_i: int + reason: str + blocks_assignment: bool = False + + +@dataclass(frozen=True) +class MotherEligibilityFrameResult: + """Result of checking one mother-eligibility frame.""" + + issue: MotherEligibilityIssue | None + stop: bool + g1_duration: int = 0 + + +@dataclass(frozen=True) +class MissingCcaAnnotationItem: + """One frame with missing values in CCA annotation columns.""" + + position_i: int + frame_i: int | None + cca_df: pd.DataFrame + + +_ADD_NEW_ID_EDITS = { + 'Add new ID with brush tool', + 'Add new ID with curvature tool', +} +_DELETE_ID_EDITS = { + 'Delete ID', + 'Deleted non-selected objects', + 'Delete ID with eraser', + 'Delete IDs using ROI', + 'Merge IDs', +} +_SYNC_ID_EDITS = { + 'Separate IDs', + 'Edit ID', +} + + +def cca_snapshot_id_changes( + edit_text: str, + cca_ids, + current_ids, +) -> CcaSnapshotIdChanges: + """Classify CCA row additions/deletions after snapshot label edits.""" + cca_ids_set = {int(label_id) for label_id in cca_ids} + current_ids_set = {int(label_id) for label_id in current_ids} + new_ids = [ + int(label_id) for label_id in current_ids + if int(label_id) not in cca_ids_set + ] + deleted_ids = [ + int(label_id) for label_id in cca_ids + if int(label_id) not in current_ids_set + ] + + if edit_text in _ADD_NEW_ID_EDITS: + return CcaSnapshotIdChanges(new_ids=new_ids, deleted_ids=[]) + if edit_text in _DELETE_ID_EDITS: + return CcaSnapshotIdChanges(new_ids=[], deleted_ids=deleted_ids) + if edit_text in _SYNC_ID_EDITS: + return CcaSnapshotIdChanges(new_ids=new_ids, deleted_ids=deleted_ids) + if edit_text == 'Repeat segmentation': + return CcaSnapshotIdChanges( + new_ids=[], + deleted_ids=[], + reset_base=True, + ) + return CcaSnapshotIdChanges(new_ids=[], deleted_ids=[]) + + +def relabel_cca_ids( + cca_df: pd.DataFrame, + old_ids, + new_ids, +) -> pd.DataFrame: + """Return ``cca_df`` with IDs relabelled in index and references.""" + id_mapper = dict(zip(old_ids, new_ids)) + relabelled_cca_df = cca_df.copy() + relabelled_cca_df['relative_ID'] = ( + relabelled_cca_df['relative_ID'].replace(old_ids, new_ids) + ) + return relabelled_cca_df.rename(index=id_mapper) + + +def merge_missing_cca_ids( + cca_df: pd.DataFrame | None, + base_cca_df: pd.DataFrame, +) -> pd.DataFrame: + """Return ``cca_df`` with rows filled from ``base_cca_df`` where missing.""" + if cca_df is None: + return base_cca_df.copy() + return cca_df.combine_first(base_cca_df) + + +def apply_snapshot_cca_id_edits( + cca_df: pd.DataFrame, + edit_text: str, + current_ids, + base_cca_df: pd.DataFrame, + *, + base_values: dict | None = None, +) -> CcaSnapshotIdEditResult: + """Return CCA table updated after snapshot label-ID edits.""" + changes = cca_snapshot_id_changes(edit_text, cca_df.index, current_ids) + if changes.reset_base: + return CcaSnapshotIdEditResult( + cca_df=base_cca_df.copy(), + changes=changes, + restored_relative_ids=[], + ) + + updated_cca_df = cca_df.copy() + for new_id in changes.new_ids: + if new_id <= 0: + continue + updated_cca_df = add_base_cell_cycle_annotation( + updated_cca_df, + new_id, + base_values=base_values, + ) + + restored_relative_ids = [] + if changes.deleted_ids: + relative_ids = updated_cca_df.reindex( + changes.deleted_ids, + fill_value=-1, + )['relative_ID'] + updated_cca_df = updated_cca_df.drop( + changes.deleted_ids, + errors='ignore', + ) + for relative_id in relative_ids: + if relative_id <= 0: + continue + updated_cca_df = add_base_cell_cycle_annotation( + updated_cca_df, + relative_id, + base_values=base_values, + ) + restored_relative_ids.append(int(relative_id)) + + return CcaSnapshotIdEditResult( + cca_df=updated_cca_df, + changes=changes, + restored_relative_ids=restored_relative_ids, + ) + + +def collect_existing_new_id_cca_rows( + new_ids, + past_cca_dfs, +) -> ExistingNewIdCcaRowsResult: + """Collect past CCA rows for IDs that were classified as new.""" + if not new_ids: + return ExistingNewIdCcaRowsResult( + found_cca_dfs=[], + remaining_new_ids=[], + ) + + remaining_new_ids = list(new_ids) + found_cca_dfs = [] + for cca_df in past_cca_dfs: + if not remaining_new_ids: + break + + intersect_idx = cca_df.index.intersection(remaining_new_ids) + found_cca_df = cca_df.loc[intersect_idx] + if found_cca_df.empty: + continue + + found_cca_dfs.append(found_cca_df) + found_ids = set(found_cca_df.index) + remaining_new_ids = [ + cell_id for cell_id in remaining_new_ids + if cell_id not in found_ids + ] + + return ExistingNewIdCcaRowsResult( + found_cca_dfs=found_cca_dfs, + remaining_new_ids=remaining_new_ids, + ) + + +def collect_existing_new_id_cca_rows_from_frames( + new_ids, + past_acdc_frames, + cca_colnames, +) -> ExistingNewIdCcaRowsResult: + """Collect past CCA rows for new IDs from frame ACDC tables.""" + past_cca_dfs = ( + acdc_df[list(cca_colnames)] + for _, acdc_df in past_acdc_frames + if acdc_df is not None + ) + return collect_existing_new_id_cca_rows(new_ids, past_cca_dfs) + + +def ensure_cca_columns( + acdc_df: pd.DataFrame, + cca_colnames, + fill_value='', +) -> pd.DataFrame: + """Return ``acdc_df`` with missing CCA columns initialized.""" + updated_acdc_df = acdc_df.copy() + for column in cca_colnames: + if column not in updated_acdc_df.columns: + updated_acdc_df[column] = fill_value + return updated_acdc_df + + +def build_base_cell_cycle_annotations( + cell_ids, + *, + with_tree_cols: bool = False, + base_values: dict | None = None, + tree_values: dict | None = None, +) -> pd.DataFrame: + """Return a base CCA table for ``cell_ids``.""" + base_values = ( + DEFAULT_CELL_CYCLE_ANNOTATION_VALUES + if base_values is None else base_values + ) + tree_values = ( + DEFAULT_LINEAGE_TREE_ANNOTATION_VALUES + if tree_values is None else tree_values + ) + + cell_ids = list(cell_ids) + row_data = dict(base_values) + if with_tree_cols: + row_data = {**row_data, **tree_values} + + cca_df = pd.DataFrame([row_data.copy() for _ in cell_ids], index=cell_ids) + if with_tree_cols: + cca_df['Cell_ID_tree'] = cell_ids + cca_df.index.name = 'Cell_ID' + return cca_df + + +def base_cell_cycle_annotation_status( + base_values: dict | None = None, +) -> pd.Series: + """Return one base CCA status row.""" + base_values = ( + DEFAULT_CELL_CYCLE_ANNOTATION_VALUES + if base_values is None else base_values + ) + return pd.Series(dict(base_values)) + + +def add_base_cell_cycle_annotation( + cca_df: pd.DataFrame, + cell_id: int, + *, + base_values: dict | None = None, +) -> pd.DataFrame: + """Return ``cca_df`` with one base CCA row added or reset.""" + if int(cell_id) <= 0: + return cca_df + + base_values = ( + DEFAULT_CELL_CYCLE_ANNOTATION_VALUES + if base_values is None else base_values + ) + if cca_df.empty: + return build_base_cell_cycle_annotations( + [cell_id], + base_values=base_values, + ) + + updated_cca_df = cca_df.copy() + for column, value in base_values.items(): + updated_cca_df.at[cell_id, column] = value + return updated_cca_df + + +from .cell_cycle_deletions import ( # noqa: E402 + CcaDeletedIdsPropagationResult, + CcaDeletedIdsResult, + CcaDeletedRelativeStatusesResult, + CcaRelativeRestoreResult, + apply_cca_deleted_ids_to_frame, + apply_deleted_cell_cycle_ids_to_frame, + delete_cca_ids, + deleted_relative_cca_status, + propagate_deleted_cell_cycle_ids, + restore_cca_relative_statuses, + restore_deleted_relative_cell_cycle_statuses, +) + +def last_annotated_cca_by_cell( + annotated_cca_dfs, +) -> pd.DataFrame: + """Return last annotated CCA status for each cell across frame tables.""" + annotated_cca_dfs = list(annotated_cca_dfs) + if not annotated_cca_dfs: + return pd.DataFrame() + + keys = range(len(annotated_cca_dfs)) + names = ['frame_i', 'Cell_ID'] + annotated_cca_df = ( + pd.concat(annotated_cca_dfs, keys=keys, names=names) + .reset_index() + .set_index(['Cell_ID', 'frame_i']) + .sort_index() + ) + return annotated_cca_df.groupby(level=0).last() + + +def overlay_last_annotated_cca( + base_cca_df: pd.DataFrame, + last_annotated_cca_df: pd.DataFrame, + cca_colnames, +) -> pd.DataFrame: + """Overlay last known CCA rows onto a base CCA frame.""" + updated_cca_df = base_cca_df.copy() + if last_annotated_cca_df.empty: + return updated_cca_df + + idx = last_annotated_cca_df.index.intersection(updated_cca_df.index) + updated_cca_df.loc[idx, cca_colnames] = last_annotated_cca_df.loc[idx] + return updated_cca_df + + +def has_cell_cycle_annotations(acdc_df: pd.DataFrame | None) -> bool: + """Return whether an ACDC frame table contains CCA annotations.""" + return acdc_df is not None and 'cell_cycle_stage' in acdc_df.columns + + +from .cell_cycle_auto import ( # noqa: E402 + AutoCcaAssignmentResult, + AutoCcaFrameInitResult, + AutoCcaRepeatFrameResult, + apply_auto_cca_assignments, + apply_auto_bud_assignment, + auto_cca_assignments_from_cost, + auto_cca_candidate_mother_ids, + auto_cca_cost_matrix_from_contours, + auto_cca_cost_matrix_from_distances, + auto_cca_repeat_frame_state, + merge_current_with_found_cca_rows, + nearest_point_2d_yx, + prepare_auto_cca_current_frame, + uncorrected_new_ids_for_auto_cca, +) + + +def last_annotated_cell_cycle_frame_index(acdc_dfs) -> int: + """Return the last frame index with CCA annotations. + + This preserves the GUI's legacy first-frame behavior: if the first frame is + missing or unannotated, frame index 0 is returned. + """ + acdc_dfs = list(acdc_dfs) + if not acdc_dfs: + return 0 + + last_seen_i = 0 + for frame_i, acdc_df in enumerate(acdc_dfs): + last_seen_i = frame_i + if not has_cell_cycle_annotations(acdc_df): + break + else: + return last_seen_i + + if last_seen_i == 0 or last_seen_i + 1 == len(acdc_dfs): + return last_seen_i + return last_seen_i - 1 + + +def extract_cell_cycle_annotations( + acdc_df: pd.DataFrame | None, + cca_colnames, + *, + dropna: bool = True, +) -> pd.DataFrame | None: + """Return the CCA columns from an ACDC frame table, if present.""" + if not has_cell_cycle_annotations(acdc_df): + return None + + cca_df = acdc_df[list(cca_colnames)].copy() + if dropna: + cca_df = cca_df.dropna() + return cca_df + + +def concat_cell_cycle_annotations( + frame_records, + cca_colnames, + *, + acdc_key: str = 'acdc_df', + size_t: int | None = None, +) -> pd.DataFrame | None: + """Return consecutive per-frame CCA tables as one MultiIndex table.""" + cca_dfs = [] + keys = [] + + if size_t is None: + records = enumerate(frame_records) + else: + records = ((frame_i, frame_records[frame_i]) for frame_i in range(size_t)) + + for frame_i, frame_record in records: + acdc_df = frame_record[acdc_key] + cca_df = extract_cell_cycle_annotations( + acdc_df, + cca_colnames, + ) + if cca_df is None: + break + + cca_dfs.append(cca_df) + keys.append(frame_i) + + if not cca_dfs: + return None + + return pd.concat(cca_dfs, keys=keys, names=['frame_i']) + + +def split_concat_cell_cycle_annotations( + global_cca_df: pd.DataFrame | None, + *, + size_t: int | None = None, + frame_level: str = 'frame_i', +) -> list[tuple[int, pd.DataFrame]]: + """Return per-frame CCA tables from a concatenated CCA table.""" + if global_cca_df is None: + return [] + + if size_t is None: + frame_indices = global_cca_df.index.get_level_values(frame_level).unique() + else: + frame_indices = range(size_t) + + frame_tables = [] + for frame_i in frame_indices: + try: + cca_df = global_cca_df.xs( + frame_i, + level=frame_level, + drop_level=True, + ) + except KeyError: + break + + frame_tables.append((int(frame_i), cca_df.copy())) + + return frame_tables + + +def remove_cell_cycle_annotations( + acdc_df: pd.DataFrame | None, + cca_colnames, +) -> CcaFrameRemovalResult: + """Return an ACDC frame table without CCA columns.""" + if acdc_df is None: + return CcaFrameRemovalResult( + acdc_df=None, + removed=False, + missing_frame=True, + ) + if not has_cell_cycle_annotations(acdc_df): + return CcaFrameRemovalResult(acdc_df=acdc_df, removed=False) + + return CcaFrameRemovalResult( + acdc_df=acdc_df.drop(columns=cca_colnames, errors='ignore'), + removed=True, + ) + + +def remove_future_cell_cycle_annotations( + frame_records, + cca_colnames, + from_frame_i: int, + *, + size_t: int | None = None, + concatenated_acdc_df: pd.DataFrame | None = None, + acdc_key: str = 'acdc_df', +) -> CcaFutureRemovalResult: + """Return future frame-table CCA removals from ``from_frame_i`` onward.""" + stop_frame_i = None + acdc_dfs_by_frame = {} + cache_frame_indices = [] + removed_frame_indices = [] + stop_at = len(frame_records) if size_t is None else int(size_t) + + for frame_i in range(int(from_frame_i), stop_at): + cache_frame_indices.append(frame_i) + acdc_df = frame_records[frame_i][acdc_key] + result = remove_cell_cycle_annotations(acdc_df, cca_colnames) + if result.missing_frame: + stop_frame_i = frame_i + break + if not result.removed: + continue + + acdc_dfs_by_frame[frame_i] = result.acdc_df + removed_frame_indices.append(frame_i) + + truncated_acdc_df = concatenated_acdc_df + if concatenated_acdc_df is not None: + frames = concatenated_acdc_df.index.get_level_values(0) + if from_frame_i in frames: + truncated_acdc_df = concatenated_acdc_df.loc[:from_frame_i] + + return CcaFutureRemovalResult( + acdc_dfs_by_frame=acdc_dfs_by_frame, + cache_frame_indices=cache_frame_indices, + removed_frame_indices=removed_frame_indices, + concatenated_acdc_df=truncated_acdc_df, + stopped_at_frame_i=stop_frame_i, + ) + + +def store_cell_cycle_annotations( + acdc_df: pd.DataFrame | None, + cca_df: pd.DataFrame | None, + cca_colnames, +) -> pd.DataFrame | None: + """Return ``acdc_df`` with ``cca_df`` annotations merged in.""" + if acdc_df is None or cca_df is None: + return acdc_df + + if has_cell_cycle_annotations(acdc_df): + updated_acdc_df = acdc_df.copy() + updated_acdc_df[list(cca_colnames)] = cca_df[list(cca_colnames)] + return updated_acdc_df + + metadata_df = acdc_df.drop(cca_df.columns, axis=1, errors='ignore') + return metadata_df.join(cca_df, how='left') + + +from .cell_cycle_frames import ( # noqa: E402 + CcaFrameResolutionResult, + CcaFrameStoreResult, + CcaMissingFramesInitResult, + normalize_loaded_cell_cycle_frame_annotations, + prepare_cell_cycle_checker_annotations, + prepare_missing_cell_cycle_frame_annotations, + resolve_cell_cycle_annotations, + store_cell_cycle_frame_annotations, +) + + +def apply_manual_cca_changes( + cca_df: pd.DataFrame, + changes, +) -> pd.DataFrame: + """Return ``cca_df`` with manual CCA table changes applied.""" + updated_cca_df = cca_df.copy() + for cell_id, changes_for_id in changes.items(): + if cell_id not in updated_cca_df.index: + continue + for column, (_old_value, new_value) in changes_for_id.items(): + updated_cca_df.at[cell_id, column] = new_value + return updated_cca_df + + +def missing_cell_cycle_annotation_items( + positions_frame_records, + cca_colnames, + *, + is_snapshot: bool = False, +) -> list[MissingCcaAnnotationItem]: + """Return frames whose CCA annotation columns contain missing values.""" + missing_items = [] + for position_i, frame_records in enumerate(positions_frame_records): + for frame_i, frame_record in enumerate(frame_records): + acdc_df = frame_record['acdc_df'] + if not has_cell_cycle_annotations(acdc_df): + continue + + cca_df = acdc_df[list(cca_colnames)] + if not cca_df.isnull().values.any(): + continue + + missing_items.append( + MissingCcaAnnotationItem( + position_i=position_i, + frame_i=None if is_snapshot else frame_i, + cca_df=cca_df, + ) + ) + + return missing_items + + +def s_phase_relative_ids_gone( + previous_cca_df: pd.DataFrame, + current_ids, +) -> list[int]: + """Return S-phase relative IDs that disappeared while their pair remains.""" + current_ids = set(current_ids) + disappeared_ids = [] + for cc_series in previous_cca_df.itertuples(): + if cc_series.cell_cycle_stage != 'S': + continue + + cell_id = cc_series.Index + relative_id = cc_series.relative_ID + if relative_id == -1: + continue + if relative_id not in current_ids and cell_id in current_ids: + disappeared_ids.append(relative_id) + + return disappeared_ids + + +def mark_current_relative_after_disappearance( + cca_df: pd.DataFrame, + cell_id: int, + division_frame_i: int, +) -> pd.DataFrame: + """Return current CCA with surviving relative marked as divided.""" + updated_cca_df = cca_df.copy() + updated_cca_df.at[cell_id, 'generation_num'] += 1 + updated_cca_df.at[cell_id, 'division_frame_i'] = division_frame_i + updated_cca_df.at[cell_id, 'relationship'] = 'mother' + return updated_cca_df + + +def mark_disappeared_before_division_frame( + cca_df: pd.DataFrame | None, + gone_id: int, + relative_id: int, + generation_num: int, +) -> CcaDisappearedBeforeDivisionFrameResult: + """Mark one past CCA frame while generation continuity holds.""" + if cca_df is None: + return CcaDisappearedBeforeDivisionFrameResult( + cca_df=None, + should_store=False, + stop=True, + ) + + try: + if cca_df.at[relative_id, 'generation_num'] != generation_num: + return CcaDisappearedBeforeDivisionFrameResult( + cca_df=cca_df, + should_store=False, + stop=True, + ) + except Exception: + return CcaDisappearedBeforeDivisionFrameResult( + cca_df=cca_df, + should_store=False, + stop=True, + ) + + updated_cca_df = cca_df.copy() + updated_cca_df.at[gone_id, 'disappears_before_division'] = 1 + updated_cca_df.at[relative_id, 'daughter_disappears_before_division'] = 1 + return CcaDisappearedBeforeDivisionFrameResult( + cca_df=updated_cca_df, + should_store=True, + stop=False, + ) + + +def propagate_s_phase_disappearance_divisions( + previous_cca_df: pd.DataFrame, + current_cca_df: pd.DataFrame | None, + current_frame_i: int, + current_ids, + *, + past_cca_frames=(), + disappeared_ids=None, +) -> CcaSPhaseDisappearancePropagationResult: + """Return CCA updates for S-phase cells whose relatives disappeared.""" + current_frame_i = int(current_frame_i) + previous_frame_i = current_frame_i - 1 + if disappeared_ids is None: + disappeared_ids = s_phase_relative_ids_gone( + previous_cca_df, + current_ids, + ) + else: + disappeared_ids = list(disappeared_ids) + + previous_update = previous_cca_df.copy() + current_update = None if current_cca_df is None else current_cca_df.copy() + past_cca_frames = list(past_cca_frames) + updated_cca_dfs_by_frame = {} + automatically_divided_ids = [] + + for gone_id in disappeared_ids: + relative_id = previous_update.at[gone_id, 'relative_ID'] + generation_num = previous_update.at[relative_id, 'generation_num'] + + previous_result = mark_disappeared_before_division_frame( + previous_update, + gone_id, + relative_id, + generation_num, + ) + if previous_result.should_store: + previous_update = previous_result.cca_df + + annotate_division( + previous_update, + gone_id, + relative_id, + frame_i=previous_frame_i, + ) + updated_cca_dfs_by_frame[previous_frame_i] = previous_update + + if current_update is not None: + current_update = mark_current_relative_after_disappearance( + current_update, + relative_id, + previous_frame_i, + ) + + automatically_divided_ids.append(relative_id) + + for past_frame_i, past_cca_df in past_cca_frames: + past_update = updated_cca_dfs_by_frame.get( + past_frame_i, + past_cca_df, + ) + result = mark_disappeared_before_division_frame( + past_update, + gone_id, + relative_id, + generation_num, + ) + if result.stop: + break + if result.should_store: + updated_cca_dfs_by_frame[past_frame_i] = result.cca_df + + return CcaSPhaseDisappearancePropagationResult( + previous_cca_df=previous_update, + current_cca_df=current_update, + updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, + disappeared_ids=disappeared_ids, + automatically_divided_ids=automatically_divided_ids, + ) + + +def reset_cca_future_flags(cca_df: pd.DataFrame) -> pd.DataFrame: + """Clear future-cycle flags that no longer apply to the current CCA frame.""" + updated_cca_df = cca_df.copy() + s_phase_mask = updated_cca_df.cell_cycle_stage == 'S' + updated_cca_df.loc[s_phase_mask, 'will_divide'] = 0 + + mothers_mask = ( + (updated_cca_df.relationship == 'mother') + & s_phase_mask + ) + bud_mask = updated_cca_df.relationship == 'bud' + + updated_cca_df.loc[ + mothers_mask, 'daughter_disappears_before_division' + ] = 0 + updated_cca_df.loc[bud_mask, 'disappears_before_division'] = 0 + return updated_cca_df + + +def reset_will_divide_for_generations( + global_cca_df: pd.DataFrame, + cell_generation_ids, +) -> pd.DataFrame: + """Return concatenated CCA table with selected ``will_divide`` values reset.""" + updated_cca_df = global_cca_df.copy() + generation_index_df = ( + updated_cca_df.reset_index() + .set_index(['Cell_ID', 'generation_num']) + ) + generation_index_df.loc[cell_generation_ids, 'will_divide'] = 0 + return ( + generation_index_df.reset_index() + .set_index(['frame_i', 'Cell_ID']) + ) + + +def will_divide_without_next_generation_ids( + global_cca_df: pd.DataFrame, +) -> list[tuple[int, int]]: + """Return ``(Cell_ID, generation_num)`` pairs with stale ``will_divide``.""" + global_cca_will_divide = global_cca_df[global_cca_df['will_divide'] > 0] + global_cca_will_divide = global_cca_will_divide.reset_index() + + cell_generation_index = ( + global_cca_df.reset_index() + .set_index(['Cell_ID', 'generation_num']) + .index + ) + + next_gen_will_divide_df = ( + global_cca_will_divide[['Cell_ID', 'generation_num']].copy() + ) + next_gen_will_divide_df['generation_num'] += 1 + next_gen_will_divide_index = ( + next_gen_will_divide_df.reset_index() + .set_index(['Cell_ID', 'generation_num']) + .index + ) + + wrong_next_generation_ids = ( + next_gen_will_divide_index.difference(cell_generation_index) + .to_frame() + .to_numpy() + ) + if wrong_next_generation_ids.size == 0: + return [] + + wrong_next_generation_ids[:, -1] -= 1 + return [ + (cell_id, generation_num) + for cell_id, generation_num in wrong_next_generation_ids + ] + + +def fix_will_divide_without_next_generation( + acdc_df: pd.DataFrame, +) -> pd.DataFrame: + """Return ``acdc_df`` with stale ``will_divide`` values reset to 0.""" + if 'cell_cycle_stage' not in acdc_df.columns: + return acdc_df + + required_cols = ['frame_i', 'Cell_ID', 'generation_num', 'will_divide'] + + cca_df_mask = ~acdc_df['cell_cycle_stage'].isna() + cca_df = acdc_df[cca_df_mask].reset_index()[required_cols] + + cell_generation_ids = will_divide_without_next_generation_ids(cca_df) + if not cell_generation_ids: + return acdc_df + + cca_df = reset_will_divide_for_generations(cca_df, cell_generation_ids) + updated_acdc_df = acdc_df.reset_index().set_index(['frame_i', 'Cell_ID']) + + updated_acdc_df.loc[cca_df.index, 'will_divide'] = cca_df['will_divide'] + return updated_acdc_df + + +def bud_known_history_status( + cell_id: int, + past_cca_frames, + base_status: pd.Series, +) -> pd.Series | None: + """Return restored known-history status for a bud absent in past frames.""" + for frame_i, cca_df in past_cca_frames: + if cca_df is None: + continue + if cell_id in cca_df.index: + continue + + bud_status = base_status.copy() + bud_status['cell_cycle_stage'] = 'S' + bud_status['generation_num'] = 0 + bud_status['relationship'] = 'bud' + bud_status['emerg_frame_i'] = frame_i + 1 + bud_status['is_history_known'] = True + return bud_status + + return None + + +def relative_status_before_bud_emergence( + bud_id: int, + current_mother_id: int, + past_cca_frames, + base_mother_status: pd.Series, + base_bud_status: pd.Series, +) -> pd.Series: + """Return a mother's status from before ``bud_id`` emerged.""" + for frame_i, cca_df in past_cca_frames: + if cca_df is None: + continue + if bud_id in cca_df.index: + continue + + if current_mother_id in cca_df.index: + return cca_df.loc[current_mother_id].copy() + + bud_status = base_bud_status.copy() + bud_status['cell_cycle_stage'] = 'S' + bud_status['generation_num'] = 0 + bud_status['relationship'] = 'bud' + bud_status['emerg_frame_i'] = frame_i + 1 + bud_status['is_history_known'] = True + return bud_status + + return base_mother_status.copy() + + +def assign_bud_to_mother( + cca_df: pd.DataFrame, + bud_id: int, + mother_id: int, + *, + corrected_frame_i: int | None = None, + update_mother: bool = True, + update_mother_only_if_g1: bool = False, + mother_generation_num: int | None = None, + mother_relationship: str | None = 'mother', + previous_mother_id: int | None = None, + previous_mother_status: pd.Series | None = None, + reset_previous_mother: bool = False, +) -> pd.DataFrame: + """Return ``cca_df`` with ``bud_id`` assigned to ``mother_id``.""" + updated_cca_df = cca_df.copy() + + if reset_previous_mother and previous_mother_id in updated_cca_df.index: + updated_cca_df.at[previous_mother_id, 'relative_ID'] = -1 + updated_cca_df.at[previous_mother_id, 'generation_num'] = 2 + updated_cca_df.at[previous_mother_id, 'cell_cycle_stage'] = 'G1' + + updated_cca_df.at[bud_id, 'relative_ID'] = mother_id + updated_cca_df.at[bud_id, 'generation_num'] = 0 + updated_cca_df.at[bud_id, 'relationship'] = 'bud' + updated_cca_df.at[bud_id, 'cell_cycle_stage'] = 'S' + if corrected_frame_i is not None: + updated_cca_df.at[bud_id, 'corrected_on_frame_i'] = corrected_frame_i + + should_update_mother = update_mother and mother_id in updated_cca_df.index + if should_update_mother and update_mother_only_if_g1: + should_update_mother = ( + updated_cca_df.at[mother_id, 'cell_cycle_stage'] == 'G1' + ) + if should_update_mother: + updated_cca_df.at[mother_id, 'relative_ID'] = bud_id + updated_cca_df.at[mother_id, 'cell_cycle_stage'] = 'S' + if mother_generation_num is not None: + updated_cca_df.at[mother_id, 'generation_num'] = mother_generation_num + if mother_relationship is not None: + updated_cca_df.at[mother_id, 'relationship'] = mother_relationship + + if ( + previous_mother_status is not None + and previous_mother_id in updated_cca_df.index + ): + updated_cca_df.loc[previous_mother_id] = previous_mother_status + + return updated_cca_df + + +def future_bud_division( + bud_id: int, + future_cca_frames, +) -> FutureBudDivisionResult | None: + """Return first future frame where ``bud_id`` is already in G1.""" + for frame_i, cca_df in future_cca_frames: + if frame_i == 0: + continue + if cca_df is None: + return None + if bud_id not in cca_df.index: + return None + + cell_cycle_stage = cca_df.at[bud_id, 'cell_cycle_stage'] + if cell_cycle_stage == 'G1': + return FutureBudDivisionResult( + frame_i=frame_i, + mother_id=cca_df.at[bud_id, 'relative_ID'], + ) + + return None + + +def mother_not_g1_before_bud_emergence_frame( + mother_id: int, + bud_id: int, + wrong_bud_id: int, + past_cca_frames, +) -> int | None: + """Return first frame without required mother G1 before bud emergence.""" + for frame_i, cca_df in past_cca_frames: + if bud_id in cca_df.index: + continue + + if cca_df.at[mother_id, 'cell_cycle_stage'] == 'G1': + return None + + bud_id_previous_cycle = cca_df.at[mother_id, 'relative_ID'] + if bud_id_previous_cycle != wrong_bud_id: + return frame_i + 1 + + break + + return None + + +def dead_or_excluded_mother_pairs( + cca_df: pd.DataFrame, + acdc_df: pd.DataFrame, + frame_i: int, +) -> list[MotherBudPair]: + """Return new bud pairings where the mother is dead or excluded.""" + buds_df = cca_df[ + (cca_df.relationship == 'bud') + & (cca_df.emerg_frame_i == frame_i) + ] + if buds_df.empty: + return [] + + mother_ids = buds_df.relative_ID.to_list() + mothers_df = acdc_df.loc[mother_ids] + excluded_df = mothers_df[ + (mothers_df.is_cell_dead > 0) + | (mothers_df.is_cell_excluded > 0) + ] + + return [ + MotherBudPair(bud_id=bud_id, mother_id=mother_id) + for mother_id, bud_id in zip( + excluded_df.index.to_list(), + excluded_df.relative_ID.to_list(), + ) + ] + + +def evaluate_mother_future_eligibility_frame( + cca_df: pd.DataFrame | None, + bud_id: int, + mother_id: int, + frame_i: int, + g1_duration: int, + last_cca_frame_i: int, +) -> MotherEligibilityFrameResult: + """Check one future frame for a proposed mother-bud assignment.""" + if cca_df is None: + return MotherEligibilityFrameResult( + issue=None, + stop=True, + g1_duration=g1_duration, + ) + + if bud_id not in cca_df.index: + return MotherEligibilityFrameResult( + issue=None, + stop=True, + g1_duration=g1_duration, + ) + + is_still_bud = cca_df.at[bud_id, 'relationship'] == 'bud' + if not is_still_bud: + return MotherEligibilityFrameResult( + issue=None, + stop=True, + g1_duration=g1_duration, + ) + + next_g1_duration = g1_duration + 1 + cell_cycle_stage = cca_df.at[mother_id, 'cell_cycle_stage'] + if cell_cycle_stage == 'G1': + return MotherEligibilityFrameResult( + issue=None, + stop=False, + g1_duration=next_g1_duration, + ) + + issue = MotherEligibilityIssue( + mother_id=mother_id, + bud_id=bud_id, + frame_i=frame_i, + reason='not_G1_in_the_future', + blocks_assignment=( + g1_duration == 1 + and frame_i != last_cca_frame_i + ), + ) + return MotherEligibilityFrameResult( + issue=issue, + stop=False, + g1_duration=next_g1_duration, + ) + + +def evaluate_mother_past_eligibility_frame( + cca_df: pd.DataFrame, + bud_id: int, + mother_id: int, + frame_i: int, +) -> MotherEligibilityFrameResult: + """Check one past frame for a proposed mother-bud assignment.""" + is_bud_existing = bud_id in cca_df.index + is_mother_existing = mother_id in cca_df.index + + if not is_mother_existing: + return MotherEligibilityFrameResult(issue=None, stop=True) + + cell_cycle_stage = cca_df.at[mother_id, 'cell_cycle_stage'] + if cell_cycle_stage != 'G1' and is_bud_existing: + issue = MotherEligibilityIssue( + mother_id=mother_id, + bud_id=bud_id, + frame_i=frame_i, + reason='not_G1_in_the_past', + blocks_assignment=True, + ) + return MotherEligibilityFrameResult(issue=issue, stop=True) + + if not is_bud_existing: + issue = None + if cell_cycle_stage != 'G1': + issue = MotherEligibilityIssue( + mother_id=mother_id, + bud_id=bud_id, + frame_i=frame_i, + reason='single_frame_G1_duration', + blocks_assignment=True, + ) + return MotherEligibilityFrameResult(issue=issue, stop=True) + + return MotherEligibilityFrameResult(issue=None, stop=False) + + +def apply_mother_bud_pairing( + cca_df: pd.DataFrame, + bud_id: int, + mother_id: int, + corrected_frame_i: int, + *, + set_mother_s_if_g1: bool = True, +) -> pd.DataFrame: + """Return ``cca_df`` with reciprocal mother-bud IDs corrected.""" + updated_cca_df = cca_df.copy() + updated_cca_df.at[bud_id, 'relative_ID'] = mother_id + updated_cca_df.at[mother_id, 'relative_ID'] = bud_id + updated_cca_df.at[bud_id, 'corrected_on_frame_i'] = corrected_frame_i + updated_cca_df.at[mother_id, 'corrected_on_frame_i'] = corrected_frame_i + + if ( + set_mother_s_if_g1 + and updated_cca_df.at[mother_id, 'cell_cycle_stage'] == 'G1' + ): + updated_cca_df.at[mother_id, 'cell_cycle_stage'] = 'S' + + return updated_cca_df + + +def wrong_bud_id_for_mother(cca_df: pd.DataFrame, mother_id: int) -> int | None: + """Return mother's current bud ID if it is a bud row in ``cca_df``.""" + try: + relative_id = cca_df.at[mother_id, 'relative_ID'] + except Exception: + return None + if relative_id not in cca_df.index: + return None + if cca_df.at[relative_id, 'relationship'] != 'bud': + return None + return int(relative_id) + + +def mother_status_before_wrong_bud( + mother_id: int, + wrong_bud_id: int, + past_cca_frames, + base_status: pd.Series, +) -> pd.Series: + """Return mother's status from before ``wrong_bud_id`` emerged.""" + for cca_df in past_cca_frames: + if cca_df is None: + continue + if wrong_bud_id not in cca_df.index: + return cca_df.loc[mother_id].copy() + return base_status.copy() + + +def restore_mother_status_for_wrong_bud_frame( + cca_df: pd.DataFrame, + mother_id: int, + wrong_bud_id: int, + mother_status: pd.Series, + corrected_frame_i: int, +) -> CcaMotherStatusRestoreResult: + """Restore mother status if ``wrong_bud_id`` is present in ``cca_df``.""" + if wrong_bud_id not in cca_df.index: + return CcaMotherStatusRestoreResult(cca_df=cca_df, restored=False) + + updated_cca_df = cca_df.copy() + updated_cca_df.loc[mother_id] = mother_status + updated_cca_df.at[mother_id, 'corrected_on_frame_i'] = corrected_frame_i + return CcaMotherStatusRestoreResult(cca_df=updated_cca_df, restored=True) + + +def restore_mother_status_until_g1( + cca_df: pd.DataFrame, + mother_id: int, + mother_status: pd.Series, + corrected_frame_i: int, +) -> CcaMotherStatusRestoreResult: + """Restore mother status unless the mother is already back in G1.""" + if cca_df.at[mother_id, 'cell_cycle_stage'] == 'G1': + return CcaMotherStatusRestoreResult(cca_df=cca_df, restored=False) + + updated_cca_df = cca_df.copy() + updated_cca_df.loc[mother_id] = mother_status + updated_cca_df.at[mother_id, 'corrected_on_frame_i'] = corrected_frame_i + return CcaMotherStatusRestoreResult(cca_df=updated_cca_df, restored=True) + + +def mark_will_divide_frame( + cca_df: pd.DataFrame, + cell_id: int, + relative_id: int, + generation_num: int | None = None, +) -> CcaWillDivideFrameResult: + """Mark ``cell_id`` and ``relative_id`` as will-divide for one frame.""" + if cell_id not in cca_df.index: + return CcaWillDivideFrameResult( + cca_df=cca_df, + generation_num=generation_num, + should_store=False, + stop=True, + ) + + if generation_num is None: + generation_num = cca_df.at[cell_id, 'generation_num'] + if cca_df.at[cell_id, 'generation_num'] != generation_num: + return CcaWillDivideFrameResult( + cca_df=cca_df, + generation_num=generation_num, + should_store=False, + stop=True, + ) + + updated_cca_df = cca_df.copy() + updated_cca_df.at[cell_id, 'will_divide'] = 1 + updated_cca_df.at[relative_id, 'will_divide'] = 1 + return CcaWillDivideFrameResult( + cca_df=updated_cca_df, + generation_num=generation_num, + should_store=True, + stop=False, + ) + + +def division_undo_blocking_frame( + cell_id: int, + relative_id: int, + current_frame_i: int, + current_cca_df: pd.DataFrame, + future_cca_frames=(), + past_cca_frames=(), +) -> int | None: + """Return frame index blocking division undo, or ``None`` if allowed.""" + if current_cca_df.at[relative_id, 'cell_cycle_stage'] == 'S': + return current_frame_i + + for frame_i, cca_df in future_cca_frames: + if cca_df is None: + break + if relative_id not in cca_df.index: + continue + if cca_df.at[relative_id, 'cell_cycle_stage'] == 'S': + return frame_i + + for frame_i, cca_df in past_cca_frames: + if cca_df is None: + break + if cell_id not in cca_df.index or relative_id not in cca_df.index: + break + if cca_df.at[cell_id, 'cell_cycle_stage'] == 'S': + break + if cca_df.at[relative_id, 'cell_cycle_stage'] == 'S': + return frame_i + + return None + + +def annotate_division( + cca_df: pd.DataFrame, + cell_id: int, + relative_id: int, + frame_i: int, +) -> bool: + """Annotate a division between two related cells in ``cca_df``.""" + cca_df.at[cell_id, 'cell_cycle_stage'] = 'G1' + cca_df.at[relative_id, 'cell_cycle_stage'] = 'G1' + + if frame_i > 0: + cell_generation = cca_df.at[cell_id, 'generation_num'] + cca_df.at[cell_id, 'generation_num'] += 1 + cca_df.at[cell_id, 'division_frame_i'] = frame_i + relative_generation = cca_df.at[relative_id, 'generation_num'] + cca_df.at[relative_id, 'generation_num'] = relative_generation + 1 + cca_df.at[relative_id, 'division_frame_i'] = frame_i + if cell_generation < relative_generation: + cca_df.at[cell_id, 'relationship'] = 'mother' + else: + cca_df.at[relative_id, 'relationship'] = 'mother' + else: + cca_df.at[cell_id, 'generation_num'] = 2 + cca_df.at[relative_id, 'generation_num'] = 2 + cca_df.at[cell_id, 'division_frame_i'] = -1 + cca_df.at[relative_id, 'division_frame_i'] = -1 + cca_df.at[cell_id, 'relationship'] = 'mother' + cca_df.at[relative_id, 'relationship'] = 'mother' + + return True + + +def undo_division_annotation( + cca_df: pd.DataFrame, + cell_id: int, + relative_id: int, +) -> bool: + """Undo a division annotation between two related cells in ``cca_df``.""" + cca_df.at[cell_id, 'cell_cycle_stage'] = 'S' + cell_generation = cca_df.at[cell_id, 'generation_num'] + cca_df.at[cell_id, 'generation_num'] -= 1 + cca_df.at[cell_id, 'division_frame_i'] = -1 + + cca_df.at[relative_id, 'cell_cycle_stage'] = 'S' + relative_generation = cca_df.at[relative_id, 'generation_num'] + cca_df.at[relative_id, 'generation_num'] -= 1 + cca_df.at[relative_id, 'division_frame_i'] = -1 + + if cell_generation < relative_generation: + cca_df.at[cell_id, 'relationship'] = 'bud' + else: + cca_df.at[relative_id, 'relationship'] = 'bud' + + cca_df.at[cell_id, 'will_divide'] = 0 + cca_df.at[relative_id, 'will_divide'] = 0 + return True + + +def undo_bud_mother_assignment( + cca_df: pd.DataFrame, + cell_id: int, +) -> bool: + """Undo a bud/mother assignment for ``cell_id`` and its relative if present.""" + relative_id = cca_df.at[cell_id, 'relative_ID'] + cell_cycle_stage = cca_df.at[cell_id, 'cell_cycle_stage'] + if cell_cycle_stage == 'G1': + return False + + cca_df.at[cell_id, 'relative_ID'] = -1 + cca_df.at[cell_id, 'generation_num'] = 2 + cca_df.at[cell_id, 'cell_cycle_stage'] = 'G1' + cca_df.at[cell_id, 'relationship'] = 'mother' + + if relative_id in cca_df.index: + cca_df.at[relative_id, 'relative_ID'] = -1 + cca_df.at[relative_id, 'generation_num'] = 2 + cca_df.at[relative_id, 'cell_cycle_stage'] = 'G1' + cca_df.at[relative_id, 'relationship'] = 'mother' + + return True + + +def toggle_history_knowledge( + cca_df: pd.DataFrame, + cell_id: int, + *, + status_when_emerged: pd.Series | None = None, +) -> bool: + """Toggle whether ``cell_id`` has known history in ``cca_df``.""" + is_history_known = cca_df.at[cell_id, 'is_history_known'] + if is_history_known: + cca_df.at[cell_id, 'is_history_known'] = False + cca_df.at[cell_id, 'cell_cycle_stage'] = 'G1' + cca_df.at[cell_id, 'generation_num'] += 2 + cca_df.at[cell_id, 'emerg_frame_i'] = -1 + cca_df.at[cell_id, 'relative_ID'] = -1 + cca_df.at[cell_id, 'relationship'] = 'mother' + else: + if status_when_emerged is None: + raise ValueError( + 'status_when_emerged is required to restore known history' + ) + cca_df.loc[cell_id] = status_when_emerged + + return True + + +from .cell_cycle_history import ( # noqa: E402 + CcaHistoryKnowledgePropagationResult, + apply_history_knowledge_to_frame, + known_history_status_for_bud, + propagate_history_knowledge, +) +from .cell_cycle_divisions import ( # noqa: E402 + CcaBudMotherAssignmentPropagationResult, + CcaBudMotherChangeEligibilityResult, + CcaManualDivisionPropagationResult, + CcaMotherAssignmentEligibilityResult, + CcaMotherBudPairingsResult, + CcaSwapMothersEligibilityResult, + CcaSwapMothersFutureDivisionResult, + CcaSwapMothersPairingPlan, + CcaSwapMothersPastRestoreResult, + CcaSwapMothersPropagationResult, + CcaWillDividePropagationResult, + apply_mother_bud_pairings, + previous_relative_status_before_bud_emergence, + bud_mother_change_eligibility, + mother_assignment_eligibility, + propagate_bud_mother_assignment, + propagate_manual_division_annotation, + propagate_swap_mothers_assignment, + propagate_swap_mothers_future_division, + propagate_will_divide, + restore_swap_mothers_past_status, + swap_mothers_eligibility, + swap_mothers_pairing_plan, +) diff --git a/cellacdc/domain/cell_cycle_auto.py b/cellacdc/domain/cell_cycle_auto.py new file mode 100644 index 000000000..4287e277f --- /dev/null +++ b/cellacdc/domain/cell_cycle_auto.py @@ -0,0 +1,283 @@ +"""Automatic cell-cycle annotation assignment operations.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import pandas as pd +from scipy.optimize import linear_sum_assignment + +from .cell_cycle import MotherBudPair, has_cell_cycle_annotations + + +@dataclass(frozen=True) +class AutoCcaFrameInitResult: + """Current-frame CCA table prepared for automatic assignment.""" + + cca_df: pd.DataFrame + + +@dataclass(frozen=True) +class AutoCcaRepeatFrameResult: + """Repeat-auto-CCA state plus the new IDs still eligible.""" + + is_last_visited_again: bool + new_ids: list[int] + + +@dataclass(frozen=True) +class AutoCcaAssignmentResult: + """Mother-bud assignments selected from an auto-CCA cost matrix.""" + + pairs: list[MotherBudPair] + assigned_mother_ids: set[int] + + +def auto_cca_repeat_frame_state( + current_acdc_df: pd.DataFrame | None, + next_acdc_df: pd.DataFrame | None, + new_ids, + *, + enforce_all: bool = False, +) -> AutoCcaRepeatFrameResult: + """Return repeat-auto-CCA state and IDs eligible for reassignment.""" + original_new_ids = list(new_ids) + if not has_cell_cycle_annotations(current_acdc_df): + return AutoCcaRepeatFrameResult(False, original_new_ids) + if enforce_all: + return AutoCcaRepeatFrameResult(False, original_new_ids) + + filtered_new_ids = [ + cell_id for cell_id in original_new_ids + if current_acdc_df.at[cell_id, 'is_history_known'] + and current_acdc_df.at[cell_id, 'cell_cycle_stage'] == 'S' + ] + is_last_visited_again = ( + not has_cell_cycle_annotations(next_acdc_df) + ) + return AutoCcaRepeatFrameResult( + is_last_visited_again=is_last_visited_again, + new_ids=filtered_new_ids, + ) + + +def merge_current_with_found_cca_rows( + current_cca_df: pd.DataFrame, + found_cca_dfs, +) -> pd.DataFrame: + """Merge current and found CCA rows, keeping current rows first.""" + found_cca_dfs = list(found_cca_dfs) + if not found_cca_dfs: + return current_cca_df + + cca_df = pd.concat([current_cca_df, *found_cca_dfs]) + unique_idx = ~cca_df.index.duplicated(keep='first') + return cca_df[unique_idx] + + +def prepare_auto_cca_current_frame( + previous_cca_df: pd.DataFrame, + current_acdc_df: pd.DataFrame, + cca_colnames, + *, + current_cca_df: pd.DataFrame | None = None, + found_cca_dfs=(), +) -> AutoCcaFrameInitResult: + """Return the current CCA table to use before auto-assignment.""" + if current_cca_df is None: + cca_df = previous_cca_df.copy() + else: + cca_df = current_acdc_df[list(cca_colnames)].copy() + + cca_df = merge_current_with_found_cca_rows(cca_df, found_cca_dfs) + return AutoCcaFrameInitResult(cca_df=cca_df) + + +def uncorrected_new_ids_for_auto_cca( + new_ids, + current_cca_df: pd.DataFrame, +) -> list[int]: + """Filter out new IDs that were manually corrected already.""" + try: + corrected_ids = set( + current_cca_df[current_cca_df['corrected_on_frame_i'] > 0].index + ) + except Exception: + corrected_ids = set() + + return [cell_id for cell_id in new_ids if cell_id not in corrected_ids] + + +def auto_cca_candidate_mother_ids( + previous_cca_df: pd.DataFrame, + previous_acdc_df: pd.DataFrame, + current_ids, + *, + current_cca_df: pd.DataFrame | None = None, + include_current_g1: bool = False, + current_frame_i: int | None = None, +): + """Return candidate G1 mother IDs for automatic CCA assignment.""" + try: + previous_g1_df = previous_cca_df[ + previous_cca_df['cell_cycle_stage'] == 'G1' + ] + previous_g1_df = previous_g1_df[ + ~previous_acdc_df.loc[previous_g1_df.index]['is_cell_dead'] + ] + candidate_ids = set(previous_g1_df.index) + except Exception: + candidate_ids = set() + + if include_current_g1 and current_cca_df is not None: + current_g1_df = current_cca_df[ + current_cca_df['cell_cycle_stage'] == 'G1' + ] + new_cell_g1 = [ + cell_id for cell_id in current_g1_df.index + if cell_id not in previous_cca_df.index + ] + candidate_ids.update(new_cell_g1) + + if ( + current_frame_i is not None + and 'corrected_on_frame_i' in current_cca_df.columns + ): + cells_s_current = current_cca_df[ + (current_cca_df['cell_cycle_stage'] == 'S') + & (current_cca_df['corrected_on_frame_i'] == current_frame_i) + ].index + candidate_ids = candidate_ids - set(cells_s_current) + + current_ids = set(current_ids) + return [cell_id for cell_id in candidate_ids if cell_id in current_ids] + + +def auto_cca_assignments_from_cost( + cost, + mother_ids, + bud_ids, +) -> AutoCcaAssignmentResult: + """Return minimum-cost mother-bud assignments from a cost matrix.""" + mother_ids = list(mother_ids) + bud_ids = list(bud_ids) + row_idx, col_idx = linear_sum_assignment(cost) + pairs = [ + MotherBudPair( + bud_id=bud_ids[bud_idx], + mother_id=mother_ids[mother_idx], + ) + for mother_idx, bud_idx in zip(row_idx, col_idx) + ] + return AutoCcaAssignmentResult( + pairs=pairs, + assigned_mother_ids={pair.mother_id for pair in pairs}, + ) + + +def apply_auto_cca_assignments( + cca_df: pd.DataFrame, + assignments: AutoCcaAssignmentResult, + frame_i: int, + base_bud_status: pd.Series, + *, + previous_cca_df: pd.DataFrame | None = None, + current_ids=None, +) -> pd.DataFrame: + """Apply selected auto-CCA mother-bud assignments to one frame.""" + updated_cca_df = cca_df + for pair in assignments.pairs: + updated_cca_df = apply_auto_bud_assignment( + updated_cca_df, + pair.bud_id, + pair.mother_id, + frame_i, + base_bud_status, + previous_cca_df=previous_cca_df, + new_mother_ids=assignments.assigned_mother_ids, + ) + + if current_ids is not None: + updated_cca_df = updated_cca_df.loc[list(current_ids)] + + return updated_cca_df + + +def nearest_point_2d_yx(points, all_others): + """Return minimum distance and nearest point between two YX point sets.""" + points = np.asarray(points) + all_others = np.asarray(all_others) + diff = points[:, np.newaxis] - all_others + dist = np.linalg.norm(diff, axis=2) + point_idx, other_idx = np.unravel_index(dist.argmin(), dist.shape) + return float(dist[point_idx, other_idx]), all_others[other_idx] + + +def auto_cca_cost_matrix_from_contours( + mother_ids, + bud_ids, + mother_contours, + bud_contours, +) -> np.ndarray: + """Build an auto-CCA cost matrix from mother and bud contours.""" + mother_ids = list(mother_ids) + bud_ids = list(bud_ids) + cost = np.full((len(mother_ids), len(bud_ids)), np.inf) + for mother_idx, mother_id in enumerate(mother_ids): + mother_contour = mother_contours.get(mother_id) + if mother_contour is None: + continue + for bud_idx, bud_id in enumerate(bud_ids): + bud_contour = bud_contours.get(bud_id) + if bud_contour is None: + continue + min_dist, _ = nearest_point_2d_yx(mother_contour, bud_contour) + cost[mother_idx, bud_idx] = min_dist + return cost + + +def auto_cca_cost_matrix_from_distances( + distance_matrix_df: pd.DataFrame, + mother_ids, + bud_ids, +) -> np.ndarray: + """Select an auto-CCA cost matrix from a precomputed distance table.""" + return distance_matrix_df.loc[list(mother_ids), list(bud_ids)].values + + +def apply_auto_bud_assignment( + cca_df: pd.DataFrame, + bud_id: int, + mother_id: int, + frame_i: int, + base_bud_status: pd.Series, + *, + previous_cca_df: pd.DataFrame | None = None, + new_mother_ids=(), +) -> pd.DataFrame: + """Return ``cca_df`` after one automatic bud-to-mother assignment.""" + updated_cca_df = cca_df.copy() + new_mother_ids = set(new_mother_ids) + + if bud_id in updated_cca_df.index and previous_cca_df is not None: + relative_id = updated_cca_df.at[bud_id, 'relative_ID'] + if relative_id in previous_cca_df.index and relative_id not in new_mother_ids: + updated_cca_df.loc[relative_id] = previous_cca_df.loc[relative_id] + + updated_cca_df.at[mother_id, 'relative_ID'] = bud_id + updated_cca_df.at[mother_id, 'cell_cycle_stage'] = 'S' + + bud_status = base_bud_status.copy() + bud_status['cell_cycle_stage'] = 'S' + bud_status['generation_num'] = 0 + bud_status['relative_ID'] = mother_id + bud_status['relationship'] = 'bud' + bud_status['emerg_frame_i'] = frame_i + bud_status['is_history_known'] = True + bud_status['corrected_on_frame_i'] = -1 + for column in bud_status.index: + if column not in updated_cca_df.columns: + updated_cca_df[column] = pd.NA + updated_cca_df.loc[bud_id] = bud_status + return updated_cca_df diff --git a/cellacdc/domain/cell_cycle_deletions.py b/cellacdc/domain/cell_cycle_deletions.py new file mode 100644 index 000000000..4da0c8dd5 --- /dev/null +++ b/cellacdc/domain/cell_cycle_deletions.py @@ -0,0 +1,337 @@ +"""Deleted-ID cell-cycle annotation table operations.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from .cell_cycle import ( + base_cell_cycle_annotation_status, + build_base_cell_cycle_annotations, +) + + +@dataclass(frozen=True) +class CcaDeletedIdsResult: + """CCA table after deleting IDs plus their relative-ID references.""" + + cca_df: pd.DataFrame + relative_ids: pd.Series + + +@dataclass(frozen=True) +class CcaRelativeRestoreResult: + """CCA table after restoring relative statuses.""" + + cca_df: pd.DataFrame + any_restored: bool + + +@dataclass(frozen=True) +class CcaDeletedRelativeStatusesResult: + """Current CCA table plus statuses restored for deleted relatives.""" + + cca_df: pd.DataFrame + relative_statuses: dict + + +@dataclass(frozen=True) +class CcaDeletedIdsPropagationResult: + """Deleted-ID updates across a current frame plus visited neighbors.""" + + current_cca_df: pd.DataFrame + updated_cca_dfs_by_frame: dict[int, pd.DataFrame] + undo_frame_indices: list[int] + relative_statuses: dict + + +def _frame_value(frame_values, frame_i: int): + if frame_values is None: + return None + if hasattr(frame_values, 'get'): + return frame_values.get(frame_i) + try: + return frame_values[frame_i] + except (IndexError, KeyError, TypeError): + return None + + +def _frame_count(frame_values, size_t: int | None = None) -> int: + if size_t is not None: + return int(size_t) + if hasattr(frame_values, 'keys'): + keys = list(frame_values.keys()) + return max(keys) + 1 if keys else 0 + return len(frame_values) + + +def delete_cca_ids( + cca_df: pd.DataFrame, + deleted_ids, +) -> CcaDeletedIdsResult: + """Return ``cca_df`` without ``deleted_ids`` and their relative IDs.""" + relative_ids = cca_df.reindex(deleted_ids, fill_value=-1)['relative_ID'] + updated_cca_df = cca_df.drop(deleted_ids, errors='ignore') + return CcaDeletedIdsResult( + cca_df=updated_cca_df, + relative_ids=relative_ids, + ) + + +def apply_cca_deleted_ids_to_frame( + cca_df: pd.DataFrame, + deleted_ids, + *, + drop_deleted: bool = True, + existing_ids=None, + base_cca_df: pd.DataFrame | None = None, +) -> pd.DataFrame: + """Drop deleted IDs or restore their base rows when labels still exist.""" + if drop_deleted: + return cca_df.drop(deleted_ids, errors='ignore') + + existing_ids = set(deleted_ids if existing_ids is None else existing_ids) + restore_ids = [label_id for label_id in deleted_ids if label_id in existing_ids] + if not restore_ids: + return cca_df + if base_cca_df is None: + raise ValueError('base_cca_df is required when restoring deleted IDs') + + updated_cca_df = cca_df.copy() + for label_id in restore_ids: + if label_id not in base_cca_df.index: + continue + updated_cca_df.loc[label_id] = base_cca_df.loc[label_id] + return updated_cca_df + + +def apply_deleted_cell_cycle_ids_to_frame( + cca_df: pd.DataFrame, + deleted_ids, + relative_statuses: dict, + *, + relative_ids=None, + drop_deleted: bool = True, + existing_ids=None, + base_values: dict | None = None, +) -> CcaRelativeRestoreResult: + """Apply deleted-ID updates and restore relative statuses for one frame.""" + base_cca_df = None + if not drop_deleted: + restore_ids = [label_id for label_id in deleted_ids if ( + existing_ids is None or label_id in existing_ids + )] + if restore_ids: + base_cca_df = build_base_cell_cycle_annotations( + restore_ids, + base_values=base_values, + ) + + updated_cca_df = apply_cca_deleted_ids_to_frame( + cca_df, + deleted_ids, + drop_deleted=drop_deleted, + existing_ids=existing_ids, + base_cca_df=base_cca_df, + ) + return restore_cca_relative_statuses( + updated_cca_df, + relative_statuses, + relative_ids=relative_ids, + ) + + +def restore_cca_relative_statuses( + cca_df: pd.DataFrame, + relative_statuses: dict, + relative_ids=None, +) -> CcaRelativeRestoreResult: + """Restore stored CCA statuses for relative IDs present in ``cca_df``.""" + if relative_ids is None: + relative_ids = relative_statuses.keys() + + updated_cca_df = cca_df.copy() + any_restored = False + required_cols = {'cell_cycle_stage', 'relationship'} + if not required_cols.issubset(updated_cca_df.columns): + return CcaRelativeRestoreResult(updated_cca_df, any_restored) + + for relative_id in relative_ids: + if relative_id not in relative_statuses: + continue + if relative_id not in updated_cca_df.index: + continue + updated_cca_df.loc[relative_id] = relative_statuses[relative_id] + any_restored = True + + return CcaRelativeRestoreResult(updated_cca_df, any_restored) + + +def deleted_relative_cca_status( + cca_df: pd.DataFrame, + relative_id: int, + base_status: pd.Series, + past_cca_dfs=(), +) -> pd.Series | None: + """Return the CCA status to restore for a deleted ID's relative.""" + try: + cell_cycle_stage = cca_df.at[relative_id, 'cell_cycle_stage'] + relationship = cca_df.at[relative_id, 'relationship'] + except Exception: + return None + + cca_status = base_status.copy() + if relationship == 'mother' and cell_cycle_stage == 'S': + for past_cca_df in past_cca_dfs: + if past_cca_df is None or relative_id not in past_cca_df.index: + continue + cell_cycle_stage_past = past_cca_df.at[ + relative_id, 'cell_cycle_stage' + ] + if cell_cycle_stage_past == 'G1': + cca_status = past_cca_df.loc[relative_id].copy() + break + + return cca_status + + +def restore_deleted_relative_cell_cycle_statuses( + cca_df: pd.DataFrame, + relative_ids, + *, + past_cca_dfs=(), + base_values: dict | None = None, +) -> CcaDeletedRelativeStatusesResult: + """Restore statuses for relatives of deleted IDs on the current frame.""" + past_cca_dfs = list(past_cca_dfs) + updated_cca_df = cca_df.copy() + relative_statuses = {} + for relative_id in relative_ids: + base_status = base_cell_cycle_annotation_status(base_values) + base_status.name = relative_id + cca_status = deleted_relative_cca_status( + updated_cca_df, + relative_id, + base_status, + past_cca_dfs=past_cca_dfs, + ) + if cca_status is None: + continue + + updated_cca_df.loc[relative_id] = cca_status + relative_statuses[relative_id] = cca_status + + return CcaDeletedRelativeStatusesResult( + cca_df=updated_cca_df, + relative_statuses=relative_statuses, + ) + + +def propagate_deleted_cell_cycle_ids( + cca_dfs_by_frame, + current_frame_i: int, + deleted_ids, + relative_ids, + *, + current_cca_df: pd.DataFrame | None = None, + future_cca_frames=None, + past_cca_frames=None, + drop_in_past: bool = True, + drop_in_future: bool = True, + existing_ids_by_frame=None, + base_values: dict | None = None, + size_t: int | None = None, +) -> CcaDeletedIdsPropagationResult: + """Return CCA frame updates after IDs were deleted on one frame. + + ``cca_dfs_by_frame`` can be a list-like object or mapping keyed by frame + index. ``None`` frame values represent unvisited frames and stop traversal. + """ + current_frame_i = int(current_frame_i) + if current_cca_df is None: + current_cca_df = _frame_value(cca_dfs_by_frame, current_frame_i) + if current_cca_df is None: + raise ValueError('current frame has no CCA table') + + if past_cca_frames is None: + past_cca_frames = [ + (past_frame_i, _frame_value(cca_dfs_by_frame, past_frame_i)) + for past_frame_i in range(current_frame_i - 1, -1, -1) + ] + else: + past_cca_frames = list(past_cca_frames) + + current_restore_result = restore_deleted_relative_cell_cycle_statuses( + current_cca_df, + relative_ids, + past_cca_dfs=(cca_df_i for _, cca_df_i in past_cca_frames), + base_values=base_values, + ) + current_cca_df = current_restore_result.cca_df + relative_statuses = current_restore_result.relative_statuses + + updated_cca_dfs_by_frame = {} + if relative_statuses: + updated_cca_dfs_by_frame[current_frame_i] = current_cca_df + + undo_frame_indices = [] + if future_cca_frames is None: + stop_frame_i = _frame_count(cca_dfs_by_frame, size_t=size_t) + future_cca_frames = ( + (future_frame_i, _frame_value(cca_dfs_by_frame, future_frame_i)) + for future_frame_i in range(current_frame_i + 1, stop_frame_i) + ) + + for future_frame_i, cca_df_i in future_cca_frames: + if cca_df_i is None: + break + + undo_frame_indices.append(future_frame_i) + existing_ids = None + if not drop_in_future: + existing_ids = _frame_value(existing_ids_by_frame, future_frame_i) + + restore_result = apply_deleted_cell_cycle_ids_to_frame( + cca_df_i, + deleted_ids, + relative_statuses, + relative_ids=relative_ids, + drop_deleted=drop_in_future, + existing_ids=existing_ids, + base_values=base_values, + ) + if not restore_result.any_restored: + break + + updated_cca_dfs_by_frame[future_frame_i] = restore_result.cca_df + + for past_frame_i, cca_df_i in past_cca_frames: + if cca_df_i is None: + break + + undo_frame_indices.append(past_frame_i) + existing_ids = None + if not drop_in_past: + existing_ids = _frame_value(existing_ids_by_frame, past_frame_i) + + restore_result = apply_deleted_cell_cycle_ids_to_frame( + cca_df_i, + deleted_ids, + relative_statuses, + relative_ids=relative_ids, + drop_deleted=drop_in_past, + existing_ids=existing_ids, + base_values=base_values, + ) + if not restore_result.any_restored: + break + + updated_cca_dfs_by_frame[past_frame_i] = restore_result.cca_df + + return CcaDeletedIdsPropagationResult( + current_cca_df=current_cca_df, + updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, + undo_frame_indices=undo_frame_indices, + relative_statuses=relative_statuses, + ) diff --git a/cellacdc/domain/cell_cycle_divisions.py b/cellacdc/domain/cell_cycle_divisions.py new file mode 100644 index 000000000..20d0cb4a6 --- /dev/null +++ b/cellacdc/domain/cell_cycle_divisions.py @@ -0,0 +1,828 @@ +"""Cell-cycle division propagation operations.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from .cell_cycle import ( + annotate_division, + apply_mother_bud_pairing, + assign_bud_to_mother, + evaluate_mother_future_eligibility_frame, + evaluate_mother_past_eligibility_frame, + FutureBudDivisionResult, + future_bud_division, + mark_will_divide_frame, + MotherEligibilityIssue, + mother_status_before_wrong_bud, + mother_not_g1_before_bud_emergence_frame, + relative_status_before_bud_emergence, + restore_mother_status_for_wrong_bud_frame, + restore_mother_status_until_g1, + undo_division_annotation, + wrong_bud_id_for_mother, +) + + +@dataclass(frozen=True) +class CcaWillDividePropagationResult: + """CCA frame updates after marking past frames as will-divide.""" + + updated_cca_dfs_by_frame: dict[int, pd.DataFrame] + generation_num: int | None = None + stopped_frame_i: int | None = None + + +@dataclass(frozen=True) +class CcaManualDivisionPropagationResult: + """CCA frame updates after annotating or undoing one division.""" + + current_cca_df: pd.DataFrame + updated_cca_dfs_by_frame: dict[int, pd.DataFrame] + undo_frame_indices: list[int] + clicked_stage: str + relative_id: int + + +@dataclass(frozen=True) +class CcaSwapMothersFutureDivisionResult: + """CCA frame updates for future division during mother-bud swaps.""" + + updated_cca_dfs_by_frame: dict[int, pd.DataFrame] + wrong_bud_id: int | None + + +@dataclass(frozen=True) +class CcaSwapMothersPastRestoreResult: + """CCA frame updates restoring a mother before a wrong-bud assignment.""" + + updated_cca_dfs_by_frame: dict[int, pd.DataFrame] + wrong_bud_id: int | None + mother_status: pd.Series | None = None + + +@dataclass(frozen=True) +class CcaMotherBudPairingsResult: + """CCA table after applying one or more mother-bud pairings.""" + + cca_df: pd.DataFrame + pairings: dict[int, int] + + +@dataclass(frozen=True) +class CcaBudMotherAssignmentPropagationResult: + """CCA updates after assigning a bud to a different mother.""" + + current_cca_df: pd.DataFrame + updated_cca_dfs_by_frame: dict[int, pd.DataFrame] + undo_frame_indices: list[int] + previous_mother_id: int + + +@dataclass(frozen=True) +class CcaMotherAssignmentEligibilityResult: + """Mother-assignment eligibility issues across future and past frames.""" + + future_issue: MotherEligibilityIssue | None = None + past_issue: MotherEligibilityIssue | None = None + g1_duration_future: int = 0 + + @property + def can_assign_without_user_action(self) -> bool: + return self.future_issue is None and self.past_issue is None + + +@dataclass(frozen=True) +class CcaBudMotherChangeEligibilityResult: + """Result of checking if a bud can change mother assignment.""" + + future_division: FutureBudDivisionResult | None = None + + @property + def can_change(self) -> bool: + return self.future_division is None + + +@dataclass(frozen=True) +class CcaSwapMothersPairingPlan: + """Bud/mother pairing maps for swapping two mother assignments.""" + + correct_pairings: dict[int, int] + wrong_pairings: dict[int, int] + + +@dataclass(frozen=True) +class CcaSwapMothersEligibilityResult: + """Result of validating whether two mother assignments can be swapped.""" + + can_swap: bool + plan: CcaSwapMothersPairingPlan + future_division_bud_id: int | None = None + future_division_mother_id: int | None = None + future_division_frame_i: int | None = None + mother_not_g1_bud_id: int | None = None + mother_not_g1_mother_id: int | None = None + mother_not_g1_frame_i: int | None = None + + +@dataclass(frozen=True) +class CcaSwapMothersPropagationResult: + """CCA updates after swapping two mother-bud assignments.""" + + current_cca_df: pd.DataFrame + updated_cca_dfs_by_frame: dict[int, pd.DataFrame] + plan: CcaSwapMothersPairingPlan + + +def _frame_value(frame_values, frame_i: int): + if hasattr(frame_values, 'get'): + return frame_values.get(frame_i) + try: + return frame_values[frame_i] + except (IndexError, KeyError, TypeError): + return None + + +def _frame_count(frame_values, size_t: int | None = None) -> int: + if size_t is not None: + return int(size_t) + if hasattr(frame_values, 'keys'): + keys = list(frame_values.keys()) + return max(keys) + 1 if keys else 0 + return len(frame_values) + + +def swap_mothers_pairing_plan( + bud_id: int, + other_bud_id: int, + other_mother_id: int, + mother_id: int, +) -> CcaSwapMothersPairingPlan: + """Return correct and wrong pairings for a mother swap.""" + return CcaSwapMothersPairingPlan( + correct_pairings={ + other_bud_id: mother_id, + bud_id: other_mother_id, + }, + wrong_pairings={ + mother_id: bud_id, + other_mother_id: other_bud_id, + }, + ) + + +def swap_mothers_eligibility( + bud_id: int, + other_bud_id: int, + other_mother_id: int, + mother_id: int, + future_cca_frames, + past_cca_frames, +) -> CcaSwapMothersEligibilityResult: + """Validate whether two mother assignments can be swapped.""" + plan = swap_mothers_pairing_plan( + bud_id, + other_bud_id, + other_mother_id, + mother_id, + ) + + future_cca_frames = list(future_cca_frames) + for candidate_bud_id in (bud_id, other_bud_id): + future_division = future_bud_division( + candidate_bud_id, + future_cca_frames, + ) + if future_division is not None: + return CcaSwapMothersEligibilityResult( + can_swap=False, + plan=plan, + future_division_bud_id=candidate_bud_id, + future_division_mother_id=future_division.mother_id, + future_division_frame_i=future_division.frame_i, + ) + + past_cca_frames = list(past_cca_frames) + for correct_bud_id, correct_mother_id in plan.correct_pairings.items(): + wrong_bud_id = plan.wrong_pairings[correct_mother_id] + frame_not_g1 = mother_not_g1_before_bud_emergence_frame( + correct_mother_id, + correct_bud_id, + wrong_bud_id, + past_cca_frames, + ) + if frame_not_g1 is not None: + return CcaSwapMothersEligibilityResult( + can_swap=False, + plan=plan, + mother_not_g1_bud_id=correct_bud_id, + mother_not_g1_mother_id=correct_mother_id, + mother_not_g1_frame_i=frame_not_g1, + ) + + return CcaSwapMothersEligibilityResult(can_swap=True, plan=plan) + + +def apply_mother_bud_pairings( + cca_df: pd.DataFrame, + pairings: dict[int, int], + corrected_frame_i: int, + *, + set_mother_s_if_g1: bool = True, +) -> CcaMotherBudPairingsResult: + """Return ``cca_df`` after applying multiple bud-to-mother pairings.""" + updated_cca_df = cca_df.copy() + for bud_id, mother_id in pairings.items(): + updated_cca_df = apply_mother_bud_pairing( + updated_cca_df, + bud_id, + mother_id, + corrected_frame_i, + set_mother_s_if_g1=set_mother_s_if_g1, + ) + return CcaMotherBudPairingsResult( + cca_df=updated_cca_df, + pairings=dict(pairings), + ) + + +def mother_assignment_eligibility( + bud_id: int, + mother_id: int, + future_cca_frames, + past_cca_frames, + last_cca_frame_i: int, +) -> CcaMotherAssignmentEligibilityResult: + """Return the first eligibility issues for a proposed mother assignment.""" + g1_duration_future = 0 + future_issue = None + for future_i, cca_df_i in future_cca_frames: + result = evaluate_mother_future_eligibility_frame( + cca_df_i, + bud_id, + mother_id, + future_i, + g1_duration_future, + last_cca_frame_i, + ) + g1_duration_future = result.g1_duration + if result.issue is not None: + future_issue = result.issue + break + if result.stop: + break + + past_issue = None + for past_i, cca_df_i in past_cca_frames: + result = evaluate_mother_past_eligibility_frame( + cca_df_i, + bud_id, + mother_id, + past_i, + ) + if result.issue is not None: + past_issue = result.issue + break + if result.stop: + break + + return CcaMotherAssignmentEligibilityResult( + future_issue=future_issue, + past_issue=past_issue, + g1_duration_future=g1_duration_future, + ) + + +def bud_mother_change_eligibility( + bud_id: int, + future_cca_frames, +) -> CcaBudMotherChangeEligibilityResult: + """Validate that ``bud_id`` has no future division annotation.""" + return CcaBudMotherChangeEligibilityResult( + future_division=future_bud_division(bud_id, future_cca_frames), + ) + + +def previous_relative_status_before_bud_emergence( + bud_id: int, + current_mother_id: int, + past_cca_frames, + base_status: pd.Series, +) -> pd.Series: + """Return relative status from before ``bud_id`` emerged.""" + base_mother_status = base_status.copy() + base_mother_status.name = current_mother_id + return relative_status_before_bud_emergence( + bud_id, + current_mother_id, + past_cca_frames, + base_mother_status, + base_status, + ) + + +def propagate_bud_mother_assignment( + current_cca_df: pd.DataFrame, + current_frame_i: int, + bud_id: int, + mother_id: int, + *, + future_cca_frames=(), + past_cca_frames=(), + previous_mother_status: pd.Series | None = None, +) -> CcaBudMotherAssignmentPropagationResult: + """Return CCA updates after assigning ``bud_id`` to ``mother_id``.""" + current_frame_i = int(current_frame_i) + if current_cca_df is None: + raise ValueError('current frame has no CCA table') + + previous_mother_id = current_cca_df.at[bud_id, 'relative_ID'] + if current_frame_i == 0: + current_update = assign_bud_to_mother( + current_cca_df, + bud_id, + mother_id, + previous_mother_id=previous_mother_id, + reset_previous_mother=True, + mother_generation_num=2, + mother_relationship=None, + ) + return CcaBudMotherAssignmentPropagationResult( + current_cca_df=current_update, + updated_cca_dfs_by_frame={current_frame_i: current_update}, + undo_frame_indices=[], + previous_mother_id=previous_mother_id, + ) + + current_update = assign_bud_to_mother( + current_cca_df, + bud_id, + mother_id, + corrected_frame_i=current_frame_i, + previous_mother_id=previous_mother_id, + previous_mother_status=previous_mother_status, + ) + + updated_cca_dfs_by_frame = {current_frame_i: current_update} + undo_frame_indices = [] + + for future_i, cca_df_i in future_cca_frames: + if cca_df_i is None: + break + + if bud_id not in cca_df_i.index or mother_id not in cca_df_i.index: + continue + + undo_frame_indices.append(future_i) + bud_relationship = cca_df_i.at[bud_id, 'relationship'] + bud_stage = cca_df_i.at[bud_id, 'cell_cycle_stage'] + if bud_relationship == 'mother' and bud_stage == 'S': + break + + updated_cca_dfs_by_frame[future_i] = assign_bud_to_mother( + cca_df_i, + bud_id, + mother_id, + update_mother_only_if_g1=True, + previous_mother_id=previous_mother_id, + previous_mother_status=previous_mother_status, + ) + + for past_i, cca_df_i in past_cca_frames: + if cca_df_i is None: + break + + if bud_id not in cca_df_i.index: + break + + undo_frame_indices.append(past_i) + updated_cca_dfs_by_frame[past_i] = assign_bud_to_mother( + cca_df_i, + bud_id, + mother_id, + previous_mother_id=previous_mother_id, + previous_mother_status=previous_mother_status, + ) + + return CcaBudMotherAssignmentPropagationResult( + current_cca_df=current_update, + updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, + undo_frame_indices=undo_frame_indices, + previous_mother_id=previous_mother_id, + ) + + +def propagate_will_divide( + cca_dfs_by_frame, + current_frame_i: int, + cell_id: int, + relative_id: int, + *, + past_cca_frames=None, +) -> CcaWillDividePropagationResult: + """Return past-frame CCA updates for a pending cell division.""" + generation_num = None + stopped_frame_i = None + updated_cca_dfs_by_frame = {} + if past_cca_frames is None: + past_cca_frames = ( + (past_frame_i, _frame_value(cca_dfs_by_frame, past_frame_i)) + for past_frame_i in range(int(current_frame_i) - 1, -1, -1) + ) + + for past_frame_i, cca_df_i in past_cca_frames: + if cca_df_i is None: + stopped_frame_i = past_frame_i + break + + result = mark_will_divide_frame( + cca_df_i, + cell_id, + relative_id, + generation_num=generation_num, + ) + generation_num = result.generation_num + if result.stop: + stopped_frame_i = past_frame_i + break + if result.should_store: + updated_cca_dfs_by_frame[past_frame_i] = result.cca_df + + return CcaWillDividePropagationResult( + updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, + generation_num=generation_num, + stopped_frame_i=stopped_frame_i, + ) + + +def propagate_manual_division_annotation( + cca_dfs_by_frame, + current_frame_i: int, + cell_id: int, + *, + current_cca_df: pd.DataFrame | None = None, + future_cca_frames=None, + past_cca_frames=None, + size_t: int | None = None, +) -> CcaManualDivisionPropagationResult: + """Return CCA updates for manual division annotation or undo.""" + current_frame_i = int(current_frame_i) + if current_cca_df is None: + current_cca_df = _frame_value(cca_dfs_by_frame, current_frame_i) + if current_cca_df is None: + raise ValueError('current frame has no CCA table') + + if past_cca_frames is not None: + past_cca_frames = list(past_cca_frames) + + clicked_stage = current_cca_df.at[cell_id, 'cell_cycle_stage'] + relative_id = current_cca_df.at[cell_id, 'relative_ID'] + current_update = current_cca_df.copy() + updated_cca_dfs_by_frame = {} + + if clicked_stage == 'S': + will_divide_result = propagate_will_divide( + cca_dfs_by_frame if past_cca_frames is None else None, + current_frame_i, + cell_id, + relative_id, + past_cca_frames=past_cca_frames, + ) + updated_cca_dfs_by_frame.update( + will_divide_result.updated_cca_dfs_by_frame + ) + annotate_division(current_update, cell_id, relative_id, current_frame_i) + else: + undo_division_annotation(current_update, cell_id, relative_id) + + updated_cca_dfs_by_frame[current_frame_i] = current_update + undo_frame_indices = [] + + if future_cca_frames is None: + stop_frame_i = _frame_count(cca_dfs_by_frame, size_t=size_t) + future_cca_frames = ( + (future_frame_i, _frame_value(cca_dfs_by_frame, future_frame_i)) + for future_frame_i in range(current_frame_i + 1, stop_frame_i) + ) + + for future_frame_i, cca_df_i in future_cca_frames: + if cca_df_i is None: + break + + undo_frame_indices.append(future_frame_i) + if cell_id not in cca_df_i.index: + continue + + future_update = cca_df_i.copy() + frame_stage = future_update.at[cell_id, 'cell_cycle_stage'] + frame_relative_id = future_update.at[cell_id, 'relative_ID'] + if clicked_stage == 'S': + if frame_stage == 'G1': + break + annotate_division( + future_update, + cell_id, + frame_relative_id, + current_frame_i, + ) + updated_cca_dfs_by_frame[future_frame_i] = future_update + elif frame_stage == 'S': + annotate_division( + future_update, + cell_id, + frame_relative_id, + current_frame_i, + ) + updated_cca_dfs_by_frame[future_frame_i] = future_update + break + else: + undo_division_annotation(future_update, cell_id, frame_relative_id) + updated_cca_dfs_by_frame[future_frame_i] = future_update + + if past_cca_frames is None: + past_cca_frames = ( + (past_frame_i, _frame_value(cca_dfs_by_frame, past_frame_i)) + for past_frame_i in range(current_frame_i - 1, -1, -1) + ) + + for past_frame_i, cca_df_i in past_cca_frames: + if cca_df_i is None: + break + if cell_id not in cca_df_i.index or relative_id not in cca_df_i.index: + break + + undo_frame_indices.append(past_frame_i) + frame_stage = cca_df_i.at[cell_id, 'cell_cycle_stage'] + frame_relative_id = cca_df_i.at[cell_id, 'relative_ID'] + if frame_stage == 'S': + break + + past_update = cca_df_i.copy() + undo_division_annotation(past_update, cell_id, frame_relative_id) + updated_cca_dfs_by_frame[past_frame_i] = past_update + + return CcaManualDivisionPropagationResult( + current_cca_df=current_update, + updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, + undo_frame_indices=undo_frame_indices, + clicked_stage=clicked_stage, + relative_id=relative_id, + ) + + +def propagate_swap_mothers_future_division( + cca_dfs_by_frame, + frame_i: int, + mother_id: int, + *, + size_t: int | None = None, +) -> CcaSwapMothersFutureDivisionResult: + """Return future-frame CCA updates after a swap-mothers division.""" + frame_i = int(frame_i) + cca_df_at_division = _frame_value(cca_dfs_by_frame, frame_i) + if cca_df_at_division is None: + raise ValueError('division frame has no CCA table') + + wrong_bud_id = wrong_bud_id_for_mother(cca_df_at_division, mother_id) + if wrong_bud_id is None: + return CcaSwapMothersFutureDivisionResult( + updated_cca_dfs_by_frame={}, + wrong_bud_id=None, + ) + + updated_cca_dfs_by_frame = {} + division_cca_df = cca_df_at_division.copy() + annotate_division(division_cca_df, mother_id, wrong_bud_id, frame_i) + division_cca_df.at[mother_id, 'corrected_on_frame_i'] = frame_i + updated_cca_dfs_by_frame[frame_i] = division_cca_df + + mother_status_to_restore = division_cca_df.loc[mother_id] + stop_frame_i = _frame_count(cca_dfs_by_frame, size_t=size_t) + for future_i in range(frame_i + 1, stop_frame_i): + cca_df_i = _frame_value(cca_dfs_by_frame, future_i) + if cca_df_i is None: + break + + restore_result = restore_mother_status_until_g1( + cca_df_i, + mother_id, + mother_status_to_restore, + frame_i, + ) + if not restore_result.restored: + break + + updated_cca_dfs_by_frame[future_i] = restore_result.cca_df + + return CcaSwapMothersFutureDivisionResult( + updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, + wrong_bud_id=wrong_bud_id, + ) + + +def restore_swap_mothers_past_status( + cca_dfs_by_frame, + frame_i: int, + mother_id: int, + base_status: pd.Series, +) -> CcaSwapMothersPastRestoreResult: + """Return past-frame CCA updates restoring a mother before a wrong bud.""" + frame_i = int(frame_i) + cca_df_at_disappearance = _frame_value(cca_dfs_by_frame, frame_i) + if cca_df_at_disappearance is None: + raise ValueError('disappearance frame has no CCA table') + + wrong_bud_id = wrong_bud_id_for_mother( + cca_df_at_disappearance, + mother_id, + ) + if wrong_bud_id is None: + return CcaSwapMothersPastRestoreResult( + updated_cca_dfs_by_frame={}, + wrong_bud_id=None, + ) + + past_cca_frames = ( + _frame_value(cca_dfs_by_frame, past_i) + for past_i in range(frame_i, -1, -1) + ) + mother_status = mother_status_before_wrong_bud( + mother_id, + wrong_bud_id, + past_cca_frames, + base_status, + ) + + updated_cca_dfs_by_frame = {} + for past_i in range(frame_i, -1, -1): + cca_df_i = _frame_value(cca_dfs_by_frame, past_i) + if cca_df_i is None: + break + + restore_result = restore_mother_status_for_wrong_bud_frame( + cca_df_i, + mother_id, + wrong_bud_id, + mother_status, + frame_i, + ) + if not restore_result.restored: + break + + updated_cca_dfs_by_frame[past_i] = restore_result.cca_df + + return CcaSwapMothersPastRestoreResult( + updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, + wrong_bud_id=wrong_bud_id, + mother_status=mother_status, + ) + + +def propagate_swap_mothers_assignment( + current_cca_df: pd.DataFrame, + current_frame_i: int, + bud_id: int, + other_bud_id: int, + other_mother_id: int, + mother_id: int, + *, + base_status: pd.Series, + past_cca_frames=(), + future_cca_frames=(), +) -> CcaSwapMothersPropagationResult: + """Return CCA updates after swapping two incorrect mother assignments.""" + current_frame_i = int(current_frame_i) + plan = swap_mothers_pairing_plan( + bud_id, + other_bud_id, + other_mother_id, + mother_id, + ) + + current_pairings_result = apply_mother_bud_pairings( + current_cca_df, + plan.correct_pairings, + current_frame_i, + set_mother_s_if_g1=False, + ) + current_update = current_pairings_result.cca_df + updated_cca_dfs_by_frame = {current_frame_i: current_update} + + past_dfs_by_frame = { + int(frame_i): cca_df for frame_i, cca_df in past_cca_frames + } + corrected_bud_ids_past = set() + for past_i in sorted(past_dfs_by_frame, reverse=True): + if len(corrected_bud_ids_past) == len(plan.correct_pairings): + break + + for correct_bud_id, correct_mother_id in plan.correct_pairings.items(): + if correct_bud_id in corrected_bud_ids_past: + continue + + cca_df_i = past_dfs_by_frame[past_i] + if cca_df_i is None: + continue + + if correct_bud_id not in cca_df_i.index: + corrected_bud_ids_past.add(correct_bud_id) + if len(corrected_bud_ids_past) < len(plan.correct_pairings): + restore_frames = { + frame_i: past_dfs_by_frame[frame_i] + for frame_i in past_dfs_by_frame + if frame_i <= past_i + } + restore_result = restore_swap_mothers_past_status( + restore_frames, + past_i, + correct_mother_id, + base_status, + ) + for frame_i, restored_df in ( + restore_result.updated_cca_dfs_by_frame.items() + ): + past_dfs_by_frame[frame_i] = restored_df + updated_cca_dfs_by_frame[frame_i] = restored_df + continue + + pairings_result = apply_mother_bud_pairings( + cca_df_i, + {correct_bud_id: correct_mother_id}, + current_frame_i, + ) + past_dfs_by_frame[past_i] = pairings_result.cca_df + updated_cca_dfs_by_frame[past_i] = pairings_result.cca_df + + future_dfs_by_frame = { + int(frame_i): cca_df for frame_i, cca_df in future_cca_frames + } + corrected_bud_ids_future = set() + for future_i in sorted(future_dfs_by_frame): + if len(corrected_bud_ids_future) == len(plan.correct_pairings): + break + + cca_df_i = updated_cca_dfs_by_frame.get( + future_i, + future_dfs_by_frame[future_i], + ) + if cca_df_i is None: + break + + for correct_bud_id, correct_mother_id in plan.correct_pairings.items(): + if correct_bud_id in corrected_bud_ids_future: + continue + + if correct_bud_id not in cca_df_i.index: + corrected_bud_ids_future.add(correct_bud_id) + continue + + bud_stage = cca_df_i.at[correct_bud_id, 'cell_cycle_stage'] + if bud_stage == 'G1': + corrected_bud_ids_future.add(correct_bud_id) + if len(corrected_bud_ids_future) < len(plan.correct_pairings): + future_frames_for_division = { + frame_i: updated_cca_dfs_by_frame.get(frame_i, df) + for frame_i, df in future_dfs_by_frame.items() + if frame_i >= future_i + } + future_frames_for_division[future_i] = cca_df_i + division_result = propagate_swap_mothers_future_division( + future_frames_for_division, + future_i, + correct_mother_id, + ) + for frame_i, division_df in ( + division_result.updated_cca_dfs_by_frame.items() + ): + updated_cca_dfs_by_frame[frame_i] = division_df + if division_result.wrong_bud_id is not None: + will_divide_result = propagate_will_divide( + past_dfs_by_frame, + current_frame_i, + correct_mother_id, + division_result.wrong_bud_id, + ) + for frame_i, will_divide_df in ( + will_divide_result + .updated_cca_dfs_by_frame.items() + ): + past_dfs_by_frame[frame_i] = will_divide_df + updated_cca_dfs_by_frame[frame_i] = will_divide_df + cca_df_i = updated_cca_dfs_by_frame.get(future_i, cca_df_i) + continue + + pairings_result = apply_mother_bud_pairings( + cca_df_i, + {correct_bud_id: correct_mother_id}, + current_frame_i, + ) + cca_df_i = pairings_result.cca_df + updated_cca_dfs_by_frame[future_i] = cca_df_i + + return CcaSwapMothersPropagationResult( + current_cca_df=current_update, + updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, + plan=plan, + ) diff --git a/cellacdc/domain/cell_cycle_frames.py b/cellacdc/domain/cell_cycle_frames.py new file mode 100644 index 000000000..c318e3841 --- /dev/null +++ b/cellacdc/domain/cell_cycle_frames.py @@ -0,0 +1,164 @@ +"""Frame-level cell-cycle annotation table operations.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from .cell_cycle import ( + build_base_cell_cycle_annotations, + ensure_cca_columns, + extract_cell_cycle_annotations, + has_cell_cycle_annotations, + last_annotated_cca_by_cell, + store_cell_cycle_annotations, +) + + +@dataclass(frozen=True) +class CcaFrameResolutionResult: + """Resolved CCA table for one frame.""" + + cca_df: pd.DataFrame | None + used_snapshot_fallback: bool = False + + +@dataclass(frozen=True) +class CcaFrameStoreResult: + """Stored ACDC frame plus optional CCA cache tables.""" + + acdc_df: pd.DataFrame | None + checker_cca_df: pd.DataFrame | None = None + cached_cca_df: pd.DataFrame | None = None + + +@dataclass(frozen=True) +class CcaMissingFramesInitResult: + """Prepared missing CCA frame tables and their last known statuses.""" + + acdc_dfs_by_frame: dict[int, pd.DataFrame] + last_annotated_cca_df: pd.DataFrame + + +def prepare_missing_cell_cycle_frame_annotations( + frame_records, + cca_colnames, + last_cca_frame_i: int, +) -> CcaMissingFramesInitResult: + """Prepare missing CCA columns before initializing skipped frames.""" + acdc_dfs_by_frame = {} + annotated_cca_dfs = [] + for frame_i in range(last_cca_frame_i + 1): + acdc_df = frame_records[frame_i]['acdc_df'] + if not has_cell_cycle_annotations(acdc_df): + acdc_df = ensure_cca_columns(acdc_df, cca_colnames) + acdc_dfs_by_frame[frame_i] = acdc_df + annotated_cca_dfs.append(acdc_df[list(cca_colnames)]) + + return CcaMissingFramesInitResult( + acdc_dfs_by_frame=acdc_dfs_by_frame, + last_annotated_cca_df=last_annotated_cca_by_cell(annotated_cca_dfs), + ) + + +def normalize_loaded_cell_cycle_frame_annotations( + acdc_df: pd.DataFrame | None, + cca_colnames, + int_colnames=(), +) -> pd.DataFrame | None: + """Normalize CCA columns loaded from concatenated frame metadata.""" + if acdc_df is None or not has_cell_cycle_annotations(acdc_df): + return acdc_df + + cca_cols = acdc_df.columns.intersection(cca_colnames) + cca_df = acdc_df[cca_cols].dropna() + if cca_df.empty: + return acdc_df.drop(columns=cca_colnames, errors='ignore') + + normalized_acdc_df = acdc_df.loc[cca_df.index].copy() + existing_int_cols = [ + col for col in int_colnames if col in normalized_acdc_df.columns + ] + if existing_int_cols: + normalized_acdc_df[existing_int_cols] = ( + normalized_acdc_df[existing_int_cols].astype('Int64') + ) + return normalized_acdc_df + + +def resolve_cell_cycle_annotations( + acdc_df: pd.DataFrame | None, + cca_colnames, + *, + is_snapshot: bool = False, + snapshot_cell_ids=(), + dropna: bool = True, + base_values: dict | None = None, + tree_values: dict | None = None, + with_tree_cols: bool = False, +) -> CcaFrameResolutionResult: + """Resolve a frame CCA table, optionally falling back to snapshot defaults.""" + cca_df = extract_cell_cycle_annotations( + acdc_df, + cca_colnames, + dropna=False, + ) + used_snapshot_fallback = False + if cca_df is None and is_snapshot: + cca_df = build_base_cell_cycle_annotations( + snapshot_cell_ids, + with_tree_cols=with_tree_cols, + base_values=base_values, + tree_values=tree_values, + ) + used_snapshot_fallback = True + + if cca_df is not None and dropna: + cca_df = cca_df.dropna() + + return CcaFrameResolutionResult( + cca_df=cca_df, + used_snapshot_fallback=used_snapshot_fallback, + ) + + +def prepare_cell_cycle_checker_annotations( + cca_df: pd.DataFrame | None, + *, + checker_running: bool = True, +) -> pd.DataFrame | None: + """Return a checker-safe CCA copy when integrity checks are active.""" + if not checker_running or cca_df is None: + return None + return cca_df.copy() + + +def store_cell_cycle_frame_annotations( + acdc_df: pd.DataFrame | None, + cca_df: pd.DataFrame | None, + cca_colnames, + *, + store_checker_copy: bool = False, + store_cca_df_copy: bool = False, +) -> CcaFrameStoreResult: + """Return stored ACDC frame and optional CCA cache copies.""" + stored_acdc_df = store_cell_cycle_annotations( + acdc_df, + cca_df, + cca_colnames, + ) + checker_cca_df = prepare_cell_cycle_checker_annotations( + cca_df, + checker_running=store_checker_copy, + ) + + cached_cca_df = None + if store_cca_df_copy and cca_df is not None: + cached_cca_df = cca_df.copy() + + return CcaFrameStoreResult( + acdc_df=stored_acdc_df, + checker_cca_df=checker_cca_df, + cached_cca_df=cached_cca_df, + ) diff --git a/cellacdc/domain/cell_cycle_history.py b/cellacdc/domain/cell_cycle_history.py new file mode 100644 index 000000000..548149a9e --- /dev/null +++ b/cellacdc/domain/cell_cycle_history.py @@ -0,0 +1,129 @@ +"""Cell-cycle history-known annotation propagation operations.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from .cell_cycle import bud_known_history_status, toggle_history_knowledge + + +@dataclass(frozen=True) +class CcaHistoryKnowledgePropagationResult: + """CCA table updates after toggling one cell's history knowledge.""" + + current_cca_df: pd.DataFrame + updated_cca_dfs_by_frame: dict[int, pd.DataFrame] + undo_frame_indices: list[int] + relative_id: int + relative_status: pd.Series | None = None + + +def apply_history_knowledge_to_frame( + cca_df: pd.DataFrame, + cell_id: int, + *, + status_when_emerged: pd.Series | None = None, + relative_id: int | None = None, + relative_status: pd.Series | None = None, +) -> pd.DataFrame: + """Return one CCA table after toggling a cell's history knowledge.""" + updated_cca_df = cca_df.copy() + toggle_history_knowledge( + updated_cca_df, + cell_id, + status_when_emerged=status_when_emerged, + ) + if ( + relative_id is not None + and relative_status is not None + and relative_id in updated_cca_df.index + ): + updated_cca_df.loc[relative_id] = relative_status + + return updated_cca_df + + +def known_history_status_for_bud( + bud_id: int, + past_cca_frames, + base_status: pd.Series, +) -> pd.Series | None: + """Return status to restore when marking ``bud_id`` history as known.""" + return bud_known_history_status( + bud_id, + past_cca_frames, + base_status, + ) + + +def propagate_history_knowledge( + current_cca_df: pd.DataFrame, + current_frame_i: int, + cell_id: int, + *, + future_cca_frames=(), + past_cca_frames=(), + status_when_emerged: pd.Series | None = None, + relative_id: int | None = None, + relative_status: pd.Series | None = None, +) -> CcaHistoryKnowledgePropagationResult: + """Return CCA frame updates after toggling history knowledge on a cell.""" + current_frame_i = int(current_frame_i) + if current_cca_df is None: + raise ValueError('current frame has no CCA table') + + if relative_id is None: + relative_id = current_cca_df.at[cell_id, 'relative_ID'] + + updated_current_cca_df = apply_history_knowledge_to_frame( + current_cca_df, + cell_id, + status_when_emerged=status_when_emerged, + relative_id=relative_id, + relative_status=relative_status, + ) + updated_cca_dfs_by_frame = {current_frame_i: updated_current_cca_df} + + undo_frame_indices = [] + for frame_i, cca_df_i in future_cca_frames: + if cca_df_i is None: + break + + undo_frame_indices.append(frame_i) + if cell_id not in cca_df_i.index: + continue + + updated_cca_dfs_by_frame[frame_i] = apply_history_knowledge_to_frame( + cca_df_i, + cell_id, + status_when_emerged=status_when_emerged, + relative_id=relative_id, + relative_status=relative_status, + ) + + for frame_i, cca_df_i in past_cca_frames: + if cca_df_i is None: + break + + undo_frame_indices.append(frame_i) + if cell_id not in cca_df_i.index: + break + + frame_relative_id = cca_df_i.at[cell_id, 'relative_ID'] + updated_cca_dfs_by_frame[frame_i] = apply_history_knowledge_to_frame( + cca_df_i, + cell_id, + status_when_emerged=status_when_emerged, + relative_id=frame_relative_id, + relative_status=relative_status, + ) + + return CcaHistoryKnowledgePropagationResult( + current_cca_df=updated_current_cca_df, + updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, + undo_frame_indices=undo_frame_indices, + relative_id=relative_id, + relative_status=relative_status, + ) diff --git a/cellacdc/domain/curvature.py b/cellacdc/domain/curvature.py new file mode 100644 index 000000000..ea6093d22 --- /dev/null +++ b/cellacdc/domain/curvature.py @@ -0,0 +1,198 @@ +"""Pure curvature and spline editing operations (no Qt).""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import scipy.interpolate +import skimage.draw + + +@dataclass(frozen=True) +class CurvatureLabelPaintResult: + """Result of painting a closed spline into a label image.""" + + labels_2d: np.ndarray + mask: np.ndarray + painted_pixels: int + + +def tangent_brush_polygon( + yx_start, + yx_end, + radius: int | float, + shape: tuple[int, int], +) -> tuple[np.ndarray, np.ndarray]: + """Return polygon coords joining two circular brush centers.""" + y1, x1 = yx_start + y2, x2 = yx_end + radius = float(radius) + + arcsin_den = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + arctan_den = x2 - x1 + if arcsin_den == 0 or arctan_den == 0: + return np.array([], dtype=int), np.array([], dtype=int) + + beta = np.arcsin((radius - radius) / arcsin_den) + gamma = -np.arctan((y2 - y1) / arctan_den) + alpha = gamma - beta + x3 = x1 + radius * np.sin(alpha) + y3 = y1 + radius * np.cos(alpha) + x4 = x2 + radius * np.sin(alpha) + y4 = y2 + radius * np.cos(alpha) + + alpha = gamma + beta + x5 = x1 - radius * np.sin(alpha) + y5 = y1 - radius * np.cos(alpha) + x6 = x2 - radius * np.sin(alpha) + y6 = y2 - radius * np.cos(alpha) + + return skimage.draw.polygon( + [y3, y4, y6, y5], + [x3, x4, x6, x5], + shape=shape, + ) + + +def directional_coords( + alfa_dir: int, + y: int, + x: int, + shape: tuple[int, int], + *, + connectivity: int = 1, +) -> tuple[list[int], list[int]]: + height, width = shape + y_above = y + 1 if y + 1 < height else y + y_below = y - 1 if y > 0 else y + x_right = x + 1 if x + 1 < width else x + x_left = x - 1 if x > 0 else x + + if alfa_dir == 0: + yy = [y_below, y_below, y, y_above, y_above] + xx = [x, x_right, x_right, x_right, x] + elif alfa_dir == 45: + yy = [y_below, y_below, y_below, y, y_above] + xx = [x_left, x, x_right, x_right, x_right] + elif alfa_dir == 90: + yy = [y, y_below, y_below, y_below, y] + xx = [x_left, x_left, x, x_right, x_right] + elif alfa_dir == 135: + yy = [y_above, y, y_below, y_below, y_below] + xx = [x_left, x_left, x_left, x, x_right] + elif alfa_dir == -180 or alfa_dir == 180: + yy = [y_above, y_above, y, y_below, y_below] + xx = [x, x_left, x_left, x_left, x] + elif alfa_dir == -135: + yy = [y_below, y, y_above, y_above, y_above] + xx = [x_left, x_left, x_left, x, x_right] + elif alfa_dir == -90: + yy = [y, y_above, y_above, y_above, y] + xx = [x_left, x_left, x, x_right, x_right] + else: + yy = [y_above, y_above, y_above, y, y_below] + xx = [x_left, x, x_right, x_right, x_right] + + if connectivity == 1: + return yy[1:4], xx[1:4] + return yy, xx + + +def spline_coords( + xx, + yy, + *, + resolution_space=None, + per: bool = False, + append_first: bool = False, +): + xx = np.asarray(xx) + yy = np.asarray(yy) + if len(xx) == 0 or len(yy) == 0: + return [], [] + + valid = np.where(np.abs(np.diff(xx)) + np.abs(np.diff(yy)) > 0) + xx = np.r_[xx[valid], xx[-1]] + yy = np.r_[yy[valid], yy[-1]] + if append_first: + xx = np.r_[xx, xx[0]] + yy = np.r_[yy, yy[0]] + per = True + + if resolution_space is None: + resolution_space = np.linspace(0, 1, 1000) + k = 2 if len(xx) == 3 else 3 + + try: + tck, _u = scipy.interpolate.splprep([xx, yy], s=0, k=k, per=per) + return scipy.interpolate.splev(resolution_space, tck) + except (ValueError, TypeError): + return [], [] + + +def closed_spline_coords( + xx_spline, + yy_spline, + *, + anchor_xx=None, + anchor_yy=None, + predictor=None, + max_exec_time: int = 150, +): + xx_spline = np.asarray(xx_spline) + yy_spline = np.asarray(yy_spline) + bbox_area = ( + (xx_spline.max() - xx_spline.min()) + * (yy_spline.max() - yy_spline.min()) + ) + if bbox_area < 26_000: + return xx_spline, yy_spline + + if predictor is None or anchor_xx is None or anchor_yy is None: + return xx_spline, yy_spline + + optimal_space_size = predictor.predict( + bbox_area, + max_exec_time=max_exec_time, + ) + if optimal_space_size >= 1000: + return xx_spline, yy_spline + + if optimal_space_size < 100: + optimal_space_size = 100 + + resolution_space = np.linspace(0, 1, int(optimal_space_size)) + return spline_coords( + anchor_xx, + anchor_yy, + resolution_space=resolution_space, + per=True, + ) + + +def paint_spline_to_labels( + labels_2d: np.ndarray, + xx_spline, + yy_spline, + label_id: int, + *, + empty_only: bool = True, +) -> CurvatureLabelPaintResult: + updated_labels = labels_2d.copy() + mask = np.zeros(updated_labels.shape, bool) + rr, cc = skimage.draw.polygon( + yy_spline, + xx_spline, + shape=updated_labels.shape, + ) + mask[rr, cc] = True + if empty_only: + mask[updated_labels != 0] = False + + updated_labels[mask] = int(label_id) + return CurvatureLabelPaintResult( + labels_2d=updated_labels, + mask=mask, + painted_pixels=int(np.count_nonzero(mask)), + ) diff --git a/cellacdc/domain/custom_annotations.py b/cellacdc/domain/custom_annotations.py new file mode 100644 index 000000000..49f92c622 --- /dev/null +++ b/cellacdc/domain/custom_annotations.py @@ -0,0 +1,142 @@ +"""Pure custom annotation table operations.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + + +@dataclass(frozen=True) +class CustomAnnotationColumnResult: + """Frame table with a custom annotation column plus annotated IDs.""" + + dataframe: pd.DataFrame + annotated_ids: list[int] + + +@dataclass(frozen=True) +class CustomAnnotationFrameUpdate: + """Frame custom annotation update plus IDs present in current labels.""" + + dataframe: pd.DataFrame + annotated_ids: list[int] + present_annotated_ids: list[int] + + +def _cell_ids_from_index_or_column(df: pd.DataFrame) -> list[int]: + if 'Cell_ID' in df.columns: + return [int(cell_id) for cell_id in df['Cell_ID'].to_list()] + if isinstance(df.index, pd.MultiIndex) and 'Cell_ID' in df.index.names: + cell_ids = df.index.get_level_values('Cell_ID') + return [int(cell_id) for cell_id in cell_ids.to_list()] + return [int(cell_id) for cell_id in df.index.to_list()] + + +def ensure_custom_annotation_column( + acdc_df: pd.DataFrame, + annotation_name: str, +) -> CustomAnnotationColumnResult: + """Return ``acdc_df`` with a 0/1 custom annotation column.""" + updated_df = acdc_df.copy() + if annotation_name not in updated_df.columns: + updated_df[annotation_name] = 0 + return CustomAnnotationColumnResult(updated_df, []) + + updated_df[annotation_name] = updated_df[annotation_name].astype(int) + annotated_df = updated_df[updated_df[annotation_name] == 1] + return CustomAnnotationColumnResult( + dataframe=updated_df, + annotated_ids=_cell_ids_from_index_or_column(annotated_df), + ) + + +def custom_annotation_column_exists( + frame_records, + annotation_name: str, + *, + summary_acdc_df: pd.DataFrame | None = None, +) -> bool: + """Return whether a custom annotation column exists in any metadata table.""" + for frame_record in frame_records: + acdc_df = frame_record['acdc_df'] + if acdc_df is None: + continue + if annotation_name in acdc_df.columns: + return True + + return ( + summary_acdc_df is not None + and annotation_name in summary_acdc_df.columns + ) + + +def drop_custom_annotation_column( + acdc_df: pd.DataFrame | None, + annotation_name: str, +) -> pd.DataFrame | None: + """Return ``acdc_df`` without one custom annotation column.""" + if acdc_df is None: + return None + return acdc_df.drop(columns=annotation_name, errors='ignore') + + +def rename_custom_annotation_column( + acdc_df: pd.DataFrame | None, + old_name: str, + new_name: str, +) -> pd.DataFrame | None: + """Return ``acdc_df`` with one custom annotation column renamed.""" + if acdc_df is None: + return None + return acdc_df.rename(columns={old_name: new_name}) + + +def remap_custom_annotation_ids( + annotated_ids_by_frame, + old_ids, + new_ids, +) -> dict: + """Return custom annotation ID lists remapped after label-ID changes.""" + id_mapper = dict(zip(old_ids, new_ids)) + return { + frame_i: [id_mapper[cell_id] for cell_id in annotated_ids] + for frame_i, annotated_ids in annotated_ids_by_frame.items() + } + + +def update_custom_annotation_frame( + acdc_df: pd.DataFrame, + annotation_name: str, + annotated_ids, + *, + clicked_id: int = 0, + click_is_active: bool = False, + existing_ids=None, +) -> CustomAnnotationFrameUpdate: + """Return frame table and ID list after one custom annotation action.""" + updated_df = acdc_df.copy() + updated_ids = list(annotated_ids) + clicked_id = int(clicked_id) + + if click_is_active and clicked_id > 0: + if clicked_id in updated_ids: + updated_ids.remove(clicked_id) + if clicked_id in updated_df.index: + updated_df.at[clicked_id, annotation_name] = 0 + else: + updated_ids.append(clicked_id) + + existing_ids = set(updated_ids if existing_ids is None else existing_ids) + present_annotated_ids = [ + annot_id for annot_id in updated_ids + if annot_id in existing_ids + ] + for annot_id in present_annotated_ids: + updated_df.at[annot_id, annotation_name] = 1 + + return CustomAnnotationFrameUpdate( + dataframe=updated_df, + annotated_ids=updated_ids, + present_annotated_ids=present_annotated_ids, + ) diff --git a/cellacdc/domain/display_images.py b/cellacdc/domain/display_images.py new file mode 100644 index 000000000..1b47518a7 --- /dev/null +++ b/cellacdc/domain/display_images.py @@ -0,0 +1,40 @@ +"""UI-neutral image display transforms.""" + +from __future__ import annotations + +import numpy as np +import skimage +import skimage.exposure + + +def distant_gray( + desired_gray: float, + background_gray: float, + *, + threshold: float = 0.3, +) -> float: + """Return a gray value with enough contrast from a background value.""" + if abs(desired_gray - background_gray) < threshold: + return 1 - desired_gray + return desired_gray + + +def rgb_to_gray(red: float, green: float, blue: float) -> float: + """Convert RGB values in [0, 255] to gamma-corrected grayscale.""" + c_linear = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255 + if c_linear <= 0.0031309: + return 12.92 * c_linear + return 1.055 * c_linear ** (1 / 2.4) - 0.055 + + +def normalize_display_image(image: np.ndarray, how: str, *, image_to_float): + """Apply Cell-ACDC display normalization semantics to an image.""" + if how == 'Do not normalize. Display raw image': + return image + if how == 'Convert to floating point format with values [0, 1]': + return image_to_float(image) + if how == 'Rescale to [0, 1]': + return skimage.exposure.rescale_intensity(skimage.img_as_float(image)) + if how == 'Normalize by max value': + return image / np.max(image) + return image diff --git a/cellacdc/domain/edit_id.py b/cellacdc/domain/edit_id.py new file mode 100644 index 000000000..66fdc5e21 --- /dev/null +++ b/cellacdc/domain/edit_id.py @@ -0,0 +1,136 @@ +"""Edit-ID metadata transforms.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import pandas as pd + +from .labels import apply_label_id_mapping + + +@dataclass(frozen=True) +class ManualEditTrackingResult: + """Result of replaying manual edit-ID corrections after tracking.""" + + labels: np.ndarray + remaining_edit_info: list[tuple[int, int, int]] + removed_edit_info: list[tuple[int, int, int]] + + +def project_centroid( + centroid, + *, + is_3d: bool = False, + depth_axis: str = 'z', +) -> tuple[float, float]: + """Project a regionprops centroid to the visible y/x plane.""" + if not is_3d: + y, x = centroid + return y, x + + zc, yc, xc = centroid + if depth_axis == 'z': + return yc, xc + if depth_axis == 'y': + return zc, xc + return zc, yc + + +def add_yx_centroids_to_df( + df: pd.DataFrame, + regionprops, + *, + is_3d: bool = False, + depth_axis: str = 'z', +) -> pd.DataFrame: + """Add visible-plane centroid columns indexed by object label.""" + for obj in regionprops: + y_centroid, x_centroid = project_centroid( + obj.centroid, + is_3d=is_3d, + depth_axis=depth_axis, + ) + df.at[obj.label, 'y_centroid'] = int(y_centroid) + df.at[obj.label, 'x_centroid'] = int(x_centroid) + return df + + +def edit_id_info_from_df( + df: pd.DataFrame, + regionprops=None, + *, + is_3d: bool = False, + depth_axis: str = 'z', +) -> list[tuple[int, int, int]]: + """Build replay tuples for manually edited IDs from an ACDC dataframe.""" + if 'was_manually_edited' not in df.columns: + return [] + + has_centroids = {'y_centroid', 'x_centroid'}.issubset(df.columns) + if not has_centroids: + if regionprops is None: + raise ValueError( + 'regionprops are required when centroid columns are missing' + ) + df = add_yx_centroids_to_df( + df, + regionprops, + is_3d=is_3d, + depth_axis=depth_axis, + ) + + manually_edited_df = df[df['was_manually_edited'] > 0] + return [ + (row.y_centroid, row.x_centroid, row.Index) + for row in manually_edited_df.itertuples() + ] + + +def manual_edit_conflicts( + labels: np.ndarray, + edit_id_info, +) -> dict[int, int]: + """Return tracked IDs that differ from requested manual edit IDs.""" + return { + int(labels[y, x]): int(new_id) + for y, x, new_id in edit_id_info + if int(labels[y, x]) != int(new_id) + } + + +def apply_manual_edit_tracking( + tracked_labels: np.ndarray, + edit_id_info, + all_ids, +) -> ManualEditTrackingResult: + """Replay manual ID edits onto a newly tracked label image in place.""" + all_ids_set = {int(label_id) for label_id in all_ids} + max_id = max(all_ids_set, default=1) + remaining_info = [] + removed_info = [] + + for info in edit_id_info: + y, x, new_id = info + new_id = int(new_id) + old_id = int(tracked_labels[y, x]) + normalized_info = (int(y), int(x), new_id) + if old_id == 0 or old_id == new_id: + removed_info.append(normalized_info) + continue + + result = apply_label_id_mapping( + tracked_labels, + [(old_id, new_id)], + existing_ids=all_ids_set, + start_max_id=max_id, + ) + max_id = result.max_id + remaining_info.append(normalized_info) + + return ManualEditTrackingResult( + labels=tracked_labels, + remaining_edit_info=remaining_info, + removed_edit_info=removed_info, + ) diff --git a/cellacdc/domain/events.py b/cellacdc/domain/events.py new file mode 100644 index 000000000..50932cf49 --- /dev/null +++ b/cellacdc/domain/events.py @@ -0,0 +1,26 @@ +"""Lightweight pub/sub for session and canvas state (no Qt).""" + +from __future__ import annotations + +from typing import Any, Callable + + +class EventEmitter: + """Minimal event bus keyed by event name.""" + + def __init__(self) -> None: + self._listeners: dict[str, list[Callable[..., None]]] = {} + + def on(self, event: str, callback: Callable[..., None]) -> None: + self._listeners.setdefault(event, []).append(callback) + + def off(self, event: str, callback: Callable[..., None]) -> None: + if event not in self._listeners: + return + self._listeners[event] = [ + cb for cb in self._listeners[event] if cb is not callback + ] + + def emit(self, event: str, *args: Any, **kwargs: Any) -> None: + for callback in list(self._listeners.get(event, [])): + callback(*args, **kwargs) diff --git a/cellacdc/domain/frame_metadata.py b/cellacdc/domain/frame_metadata.py new file mode 100644 index 000000000..e7354ee0b --- /dev/null +++ b/cellacdc/domain/frame_metadata.py @@ -0,0 +1,103 @@ +"""Per-frame ACDC metadata table transforms.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from .edit_id import project_centroid + + +@dataclass(frozen=True) +class AcdcFrameMetadataResult: + """Result of building one frame's ACDC metadata dataframe.""" + + dataframe: pd.DataFrame + max_id: int + + +def concat_visited_acdc_frames( + frame_records, + *, + labels_key: str = 'labels', + acdc_key: str = 'acdc_df', +) -> pd.DataFrame | None: + """Concatenate ACDC frame tables until labels or metadata are missing.""" + acdc_dfs = [] + keys = [] + for frame_i, frame_record in enumerate(frame_records): + if frame_record[labels_key] is None: + break + + acdc_df = frame_record[acdc_key] + if acdc_df is None: + break + + acdc_dfs.append(acdc_df) + keys.append(frame_i) + + if not acdc_dfs: + return None + + return pd.concat(acdc_dfs, keys=keys, names=['frame_i']) + + +def build_acdc_frame_metadata( + regionprops, + *, + edit_id_info=(), + existing_df: pd.DataFrame | None = None, + is_3d: bool = False, + depth_axis: str = 'z', +) -> AcdcFrameMetadataResult: + """Build or update dynamic per-object metadata for one frame.""" + ids = [] + is_cell_dead = [] + is_cell_excluded = [] + x_centroid = [] + y_centroid = [] + z_centroid = [] + was_manually_edited = [] + edited_new_ids = {int(vals[2]) for vals in edit_id_info} + + for obj in regionprops: + label_id = int(obj.label) + ids.append(label_id) + is_cell_dead.append(getattr(obj, 'dead', False)) + is_cell_excluded.append(getattr(obj, 'excluded', False)) + y, x = project_centroid( + obj.centroid, + is_3d=is_3d, + depth_axis=depth_axis, + ) + y_centroid.append(int(y)) + x_centroid.append(int(x)) + if is_3d: + z_centroid.append(int(obj.centroid[0])) + was_manually_edited.append(int(label_id in edited_new_ids)) + + if existing_df is None: + df = pd.DataFrame( + { + 'Cell_ID': ids, + 'is_cell_dead': is_cell_dead, + 'is_cell_excluded': is_cell_excluded, + 'x_centroid': x_centroid, + 'y_centroid': y_centroid, + 'was_manually_edited': was_manually_edited, + } + ).set_index('Cell_ID') + else: + df = existing_df.drop(columns=['time_seconds'], errors='ignore') + df = df.reindex(ids, fill_value=0) + df['is_cell_dead'] = is_cell_dead + df['is_cell_excluded'] = is_cell_excluded + df['x_centroid'] = x_centroid + df['y_centroid'] = y_centroid + df['was_manually_edited'] = was_manually_edited + + if is_3d: + df['z_centroid'] = z_centroid + + return AcdcFrameMetadataResult(dataframe=df, max_id=max(ids, default=0)) diff --git a/cellacdc/domain/labels.py b/cellacdc/domain/labels.py new file mode 100644 index 000000000..c0a3205a6 --- /dev/null +++ b/cellacdc/domain/labels.py @@ -0,0 +1,741 @@ +"""Pure label-array operations (no Qt).""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import scipy.ndimage +import skimage.measure +import skimage.morphology +import skimage.segmentation + + +@dataclass(frozen=True) +class LabelResizeResult: + """Result of resizing one object in a 2D label plane.""" + + labels_2d: np.ndarray + active_labels_2d: np.ndarray + seed_labels: np.ndarray + previous_coords: tuple[np.ndarray, np.ndarray] + resized_coords: tuple[np.ndarray, np.ndarray] + + +@dataclass(frozen=True) +class LabelMoveResult: + """Result of moving one object in a label image.""" + + labels: np.ndarray + previous_coords: np.ndarray + moved_coords: np.ndarray + + +@dataclass(frozen=True) +class LabelIdMappingResult: + """Result of applying one or more label-ID remappings.""" + + labels: np.ndarray + max_id: int + swapped_pairs: tuple[tuple[int, int], ...] + + +@dataclass(frozen=True) +class LabelBorderClearResult: + """Result of clearing labels that touch the image border.""" + + labels: np.ndarray + removed_ids: list[int] + + +@dataclass(frozen=True) +class LabelIdsRemovalResult: + """Result of removing labels by identity.""" + + labels: np.ndarray + removed_ids: list[int] + + +@dataclass(frozen=True) +class LabelHoleFillResult: + """Result of filling holes for one label ID.""" + + labels: np.ndarray + filled_pixels: int + + +@dataclass(frozen=True) +class LabelRegionSelectionResult: + """Result of selecting labels through a drawn region.""" + + labels: np.ndarray + selected_ids: list[int] + + +@dataclass(frozen=True) +class LabelRoiIndexResult: + """Result of indexing a label ROI into a label image.""" + + labels: np.ndarray + roi_labels: np.ndarray + inserted_ids: list[int] + replaced_ids: list[int] + + +@dataclass(frozen=True) +class DeletedRoiRestoreResult: + """Result of restoring labels from a deleted-ROI mask.""" + + labels_2d: np.ndarray + display_labels_2d: np.ndarray + deleted_mask: np.ndarray + remaining_deleted_ids: set[int] + restored_ids: set[int] + restored_masks: list[tuple[int, np.ndarray]] + + +@dataclass(frozen=True) +class DeletedRoiApplyResult: + """Result of applying deletion ROI masks to a label image.""" + + labels_2d: np.ndarray + deleted_masks: list[np.ndarray] + deleted_ids_by_roi: list[set[int]] + deleted_ids: set[int] + + +def frame_slice(labels: np.ndarray, frame_i: int) -> np.ndarray: + if labels.ndim == 3: + return labels[frame_i] + return labels + + +def clicked_label_at( + labels: np.ndarray, + x: int, + y: int, + frame_i: int = 0, +) -> int: + sl = frame_slice(labels, frame_i) + if y < 0 or x < 0 or y >= sl.shape[0] or x >= sl.shape[1]: + return 0 + return int(sl[y, x]) + + +def label_ids_from_labels(labels: np.ndarray) -> list[int]: + """Return non-background label IDs in ascending order.""" + ids = np.unique(labels) + return [int(label_id) for label_id in ids if int(label_id) != 0] + + +def clear_border_labels( + labels: np.ndarray, + *, + buffer_size: int = 0, + bgval: int = 0, + mask: np.ndarray | None = None, +) -> LabelBorderClearResult: + """Return labels with border-touching objects removed.""" + original_ids = set(label_ids_from_labels(labels)) + cleared_labels = skimage.segmentation.clear_border( + labels, + buffer_size=buffer_size, + bgval=bgval, + mask=mask, + ) + remaining_ids = set(label_ids_from_labels(cleared_labels)) + return LabelBorderClearResult( + labels=cleared_labels, + removed_ids=sorted(original_ids - remaining_ids), + ) + + +def remove_new_label_ids( + labels: np.ndarray, + previous_ids, + current_ids, +) -> LabelIdsRemovalResult: + """Remove labels present in ``current_ids`` but absent from ``previous_ids``.""" + previous_ids = {int(label_id) for label_id in previous_ids} + removed_ids = sorted( + int(label_id) + for label_id in set(current_ids) - previous_ids + if int(label_id) > 0 + ) + + updated_labels = labels.copy() + if removed_ids: + updated_labels[np.isin(updated_labels, removed_ids)] = 0 + + return LabelIdsRemovalResult( + labels=updated_labels, + removed_ids=removed_ids, + ) + + +def fill_label_holes(labels_2d: np.ndarray, label_id: int) -> LabelHoleFillResult: + """Fill holes inside one 2D label object.""" + label_id = int(label_id) + updated_labels = labels_2d.copy() + mask = labels_2d == label_id + filled_mask = scipy.ndimage.binary_fill_holes(mask) + filled_pixels = int(np.count_nonzero(filled_mask & ~mask)) + updated_labels[filled_mask] = label_id + return LabelHoleFillResult( + labels=updated_labels, + filled_pixels=filled_pixels, + ) + + +def _clear_labels_not_fully_in_mask( + labels_2d: np.ndarray, + mask: np.ndarray, +) -> np.ndarray: + selected_labels = labels_2d.copy() + for obj in skimage.measure.regionprops(labels_2d): + if np.all(mask[obj.slice][obj.image]): + continue + selected_labels[obj.slice][obj.image] = 0 + return selected_labels + + +def select_labels_in_region( + labels: np.ndarray, + mask: np.ndarray, + *, + enclosed_only: bool = False, +) -> LabelRegionSelectionResult: + """Return labels selected by a 2D region mask. + + If ``enclosed_only`` is true, only objects fully enclosed by the region are + selected. Otherwise, every object touching the region is selected. + """ + selected_labels = labels.copy() + if enclosed_only: + if selected_labels.ndim == 2: + selected_labels = _clear_labels_not_fully_in_mask( + selected_labels, + mask, + ) + else: + for z, labels_2d in enumerate(selected_labels): + selected_labels[z] = _clear_labels_not_fully_in_mask( + labels_2d, + mask, + ) + else: + selected_labels[..., ~mask] = 0 + + return LabelRegionSelectionResult( + labels=selected_labels, + selected_ids=label_ids_from_labels(selected_labels), + ) + + +def _xy_border_mask(shape: tuple[int, ...]) -> np.ndarray: + mask = np.zeros(shape, dtype=bool) + mask[..., 1:-1, 1:-1] = True + return mask + + +def index_label_roi( + labels: np.ndarray, + roi_labels: np.ndarray, + roi_slice, + brush_id: int, + *, + clear_border: bool = False, + replace_existing: bool = False, +) -> LabelRoiIndexResult: + """Insert ROI labels into ``labels`` using Cell-ACDC label ROI semantics.""" + indexed_roi_labels = roi_labels.copy() + if clear_border: + indexed_roi_labels = skimage.segmentation.clear_border( + indexed_roi_labels, + mask=_xy_border_mask(indexed_roi_labels.shape), + ) + + roi_mask = indexed_roi_labels > 0 + inserted_ids = sorted( + int(label_id) + int(brush_id) - 1 + for label_id in np.unique(indexed_roi_labels[roi_mask]) + ) + indexed_roi_labels[roi_mask] += int(brush_id) - 1 + + updated_labels = labels.copy() + target = updated_labels[roi_slice] + replaced_ids = [] + if replace_existing and np.any(roi_mask): + replaced_ids = [ + int(label_id) for label_id in np.unique(target[roi_mask]) + if int(label_id) != 0 + ] + for label_id in replaced_ids: + updated_labels[updated_labels == label_id] = 0 + target = updated_labels[roi_slice] + + target[roi_mask] = indexed_roi_labels[roi_mask] + return LabelRoiIndexResult( + labels=updated_labels, + roi_labels=indexed_roi_labels, + inserted_ids=inserted_ids, + replaced_ids=replaced_ids, + ) + + +def merge_label_ids( + labels: np.ndarray, + source_id: int, + target_id: int, + frame_i: int | None = None, +) -> np.ndarray: + """Replace ``source_id`` with ``target_id`` in ``labels``.""" + if source_id == target_id or source_id == 0: + return labels + if frame_i is not None and labels.ndim == 3: + sl = labels[frame_i] + sl[sl == source_id] = target_id + else: + labels[labels == source_id] = target_id + return labels + + +def merge_multiple_ids( + labels: np.ndarray, + ids_to_merge: np.ndarray | list[int], + target_id: int, + frame_i: int | None = None, +) -> np.ndarray: + """Merge each ID in ``ids_to_merge`` into ``target_id``.""" + for label_id in np.asarray(ids_to_merge).ravel(): + label_id = int(label_id) + if label_id == 0 or label_id == target_id: + continue + merge_label_ids(labels, label_id, target_id, frame_i=frame_i) + return labels + + +def apply_label_id_mapping( + labels: np.ndarray, + old_new_pairs, + *, + existing_ids=None, + merge_existing: bool = False, + start_max_id: int | None = None, +) -> LabelIdMappingResult: + """Apply Cell-ACDC edit-ID semantics to a label image in place. + + If the target ID already exists and ``merge_existing`` is false, IDs are + swapped through a temporary label. Otherwise the old ID is replaced by the + new ID, which allows explicit merge workflows. + """ + max_id = ( + int(np.max(labels)) if start_max_id is None and labels.size else + int(start_max_id or 0) + ) + existing_ids_set = ( + None if existing_ids is None else {int(label_id) for label_id in existing_ids} + ) + swapped_pairs = [] + + for old_id, new_id in old_new_pairs: + old_id = int(old_id) + new_id = int(new_id) + has_target = ( + bool(np.any(labels == new_id)) + if existing_ids_set is None else new_id in existing_ids_set + ) + + if has_target and not merge_existing: + temp_id = max_id + 1 + labels[labels == old_id] = temp_id + labels[labels == new_id] = old_id + labels[labels == temp_id] = new_id + max_id = temp_id + swapped_pairs.append((old_id, new_id)) + else: + labels[labels == old_id] = new_id + max_id = max(max_id, new_id) + + return LabelIdMappingResult( + labels=labels, + max_id=max_id, + swapped_pairs=tuple(swapped_pairs), + ) + + +def next_available_label_id( + id_groups=(), + *, + manual_edit_info=(), + base_id: int = 0, +) -> int: + """Return the next label ID after all known and manually edited IDs.""" + max_id = int(base_id) + for ids in id_groups: + for label_id in ids: + max_id = max(max_id, int(label_id)) + + for info in manual_edit_info: + try: + label_id = info[2] + except (TypeError, IndexError): + label_id = info + max_id = max(max_id, int(label_id)) + + return max_id + 1 + + +def remap_id_set(ids, old_ids, new_ids) -> set[int]: + """Return an ID set remapped through parallel old/new ID sequences.""" + id_mapper = dict(zip(old_ids, new_ids)) + return {int(id_mapper[label_id]) for label_id in ids} + + +def restore_deleted_roi_labels( + labels_2d: np.ndarray, + display_labels_2d: np.ndarray, + deleted_mask: np.ndarray, + roi_mask: np.ndarray, + deleted_ids, + *, + enforce: bool = True, +) -> DeletedRoiRestoreResult: + """Restore labels that were previously removed by a deletion ROI. + + ``deleted_mask`` stores the deleted object IDs. If ``enforce`` is false, + IDs still overlapping the current ROI mask are kept deleted. + """ + deleted_ids = {int(label_id) for label_id in deleted_ids} + overlap_roi_deleted_ids = { + int(label_id) for label_id in np.unique(deleted_mask[roi_mask]) + } + restored_ids = set() + restored_masks = [] + + for label_id in deleted_ids: + if label_id in overlap_roi_deleted_ids and not enforce: + continue + + restore_mask = deleted_mask == label_id + restored_ids.add(label_id) + restored_masks.append((label_id, restore_mask.copy())) + display_labels_2d[restore_mask] = label_id + labels_2d[restore_mask] = label_id + deleted_mask[restore_mask] = 0 + + return DeletedRoiRestoreResult( + labels_2d=labels_2d, + display_labels_2d=display_labels_2d, + deleted_mask=deleted_mask, + remaining_deleted_ids=deleted_ids - restored_ids, + restored_ids=restored_ids, + restored_masks=restored_masks, + ) + + +def label_ids_in_masks( + labels: np.ndarray, + masks, + *, + additional_labels: np.ndarray | None = None, +) -> set[int]: + """Return all label IDs under one or more boolean masks.""" + label_ids = set() + for mask in masks: + label_ids.update(int(label_id) for label_id in labels[mask]) + if additional_labels is not None: + label_ids.update(int(label_id) for label_id in additional_labels[mask]) + return label_ids + + +def collect_deleted_roi_ids(deleted_ids_by_roi) -> set[int]: + """Flatten stored deleted-ID collections for multiple deletion ROIs.""" + label_ids = set() + for deleted_ids in deleted_ids_by_roi: + label_ids.update(int(label_id) for label_id in deleted_ids) + return label_ids + + +def apply_deleted_roi_masks( + labels_2d: np.ndarray, + roi_masks, + deleted_masks, + deleted_ids_by_roi, +) -> DeletedRoiApplyResult: + """Delete labelled objects intersecting ROI masks and record them.""" + deleted_masks = list(deleted_masks) + deleted_ids_by_roi = [ + {int(label_id) for label_id in deleted_ids} + for deleted_ids in deleted_ids_by_roi + ] + all_deleted_ids = set() + + for idx, roi_mask in enumerate(roi_masks): + deleted_mask = deleted_masks[idx] + deleted_ids = deleted_ids_by_roi[idx] + for obj in skimage.measure.regionprops(labels_2d): + object_mask = obj.image + object_slice = obj.slice + is_deleted_object = np.any(roi_mask[object_slice][object_mask]) + if not is_deleted_object: + continue + + label_id = int(obj.label) + deleted_mask[object_slice][object_mask] = label_id + labels_2d[object_slice][object_mask] = 0 + deleted_ids.add(label_id) + all_deleted_ids.add(label_id) + + deleted_masks[idx] = deleted_mask + deleted_ids_by_roi[idx] = deleted_ids + + return DeletedRoiApplyResult( + labels_2d=labels_2d, + deleted_masks=deleted_masks, + deleted_ids_by_roi=deleted_ids_by_roi, + deleted_ids=all_deleted_ids, + ) + + +def _empty_roi_mask(shape: tuple[int, ...]) -> np.ndarray: + return np.zeros(shape, dtype=bool) + + +def _paint_roi_coords( + roi_mask: np.ndarray, + rr: np.ndarray, + cc: np.ndarray, + *, + z_slice=None, +) -> np.ndarray: + if roi_mask.ndim == 3: + roi_mask[z_slice, rr, cc] = True + else: + roi_mask[rr, cc] = True + return roi_mask + + +def polygon_roi_mask( + shape: tuple[int, ...], + points, + *, + z_slice=None, +) -> np.ndarray: + """Rasterize a polyline or polygon ROI from ``(x, y)`` points.""" + roi_mask = _empty_roi_mask(shape) + if not points: + return roi_mask + + rr_points = [int(y) for x, y in points] + cc_points = [int(x) for x, y in points] + if not rr_points or not cc_points: + return roi_mask + + plane_shape = shape[-2:] + if len(rr_points) == 2: + rr, cc, _ = skimage.draw.line_aa( + rr_points[0], cc_points[0], rr_points[1], cc_points[1], + ) + else: + rr, cc = skimage.draw.polygon(rr_points, cc_points, shape=plane_shape) + + height, width = plane_shape + keep = (rr >= 0) & (rr < height) & (cc >= 0) & (cc < width) + return _paint_roi_coords(roi_mask, rr[keep], cc[keep], z_slice=z_slice) + + +def line_roi_mask( + shape: tuple[int, ...], + point1, + point2, + *, + z_slice=None, +) -> np.ndarray: + """Rasterize a line ROI from two ``(x, y)`` points.""" + roi_mask = _empty_roi_mask(shape) + x1, y1 = [int(coord) for coord in point1] + x2, y2 = [int(coord) for coord in point2] + rr, cc, _ = skimage.draw.line_aa(y1, x1, y2, x2) + return _paint_roi_coords(roi_mask, rr, cc, z_slice=z_slice) + + +def rectangle_roi_mask( + shape: tuple[int, ...], + origin, + size, + *, + z_slice=None, +) -> np.ndarray: + """Rasterize an axis-aligned rectangular ROI.""" + roi_mask = _empty_roi_mask(shape) + x0, y0 = [int(coord) for coord in origin] + width, height = [int(coord) for coord in size] + if roi_mask.ndim == 3: + roi_mask[z_slice, y0:y0+height, x0:x0+width] = True + else: + roi_mask[y0:y0+height, x0:x0+width] = True + return roi_mask + + +def build_disk_mask( + shape: tuple[int, int], + x: int, + y: int, + radius: int, +) -> np.ndarray: + """Build a circular boolean mask centered at ``(x, y)``.""" + height, width = shape + mask = np.zeros((height, width), dtype=bool) + y0, y1 = max(0, y - radius), min(height, y + radius + 1) + x0, x1 = max(0, x - radius), min(width, x + radius + 1) + yy, xx = np.ogrid[y0:y1, x0:x1] + disk = (yy - y) ** 2 + (xx - x) ** 2 <= radius ** 2 + mask[y0:y1, x0:x1] = disk + return mask + + +def _label_target( + labels: np.ndarray, + frame_i: int, +) -> np.ndarray: + if labels.ndim == 3: + return labels[frame_i] + return labels + + +def apply_label_mask( + labels: np.ndarray, + mask: np.ndarray, + label_id: int, + frame_i: int = 0, +) -> np.ndarray: + """Paint ``label_id`` wherever ``mask`` is True.""" + target = _label_target(labels, frame_i) + target[mask] = label_id + return labels + + +def apply_eraser_mask( + labels: np.ndarray, + mask: np.ndarray, + frame_i: int = 0, + only_id: int | None = None, +) -> np.ndarray: + """Zero labels under ``mask``; optionally restrict to ``only_id``.""" + target = _label_target(labels, frame_i) + if only_id is not None: + erase_mask = np.logical_and(mask, target == only_id) + else: + erase_mask = mask + target[erase_mask] = 0 + return labels + + +def resize_label_object( + labels_2d: np.ndarray, + active_labels_2d: np.ndarray, + object_coords: np.ndarray, + label_id: int, + footprint_size: int, + *, + dilation: bool = True, + seed_labels: np.ndarray | None = None, +) -> LabelResizeResult: + """Dilate or erode one label object without overwriting neighbouring IDs. + + ``labels_2d`` is the persisted label plane to edit, while + ``active_labels_2d`` is the collision mask used by interactive tools. Both + arrays are updated in place and returned for scriptable callers. + """ + coords = np.asarray(object_coords) + yy = coords[:, -2].astype(int, copy=True) + xx = coords[:, -1].astype(int, copy=True) + previous_coords = (yy.copy(), xx.copy()) + + if seed_labels is None: + seed_labels = np.zeros_like(active_labels_2d) + seed_labels[yy, xx] = label_id + else: + seed_labels = np.asarray(seed_labels) + + active_labels_2d[yy, xx] = 0 + labels_2d[yy, xx] = 0 + + footprint = skimage.morphology.disk(int(footprint_size)) + if dilation: + resized_labels = skimage.morphology.dilation(seed_labels, footprint) + else: + resized_labels = skimage.morphology.erosion(seed_labels, footprint) + + # Keep the edited object from growing into still-occupied pixels. + resized_labels = np.asarray(resized_labels) + resized_labels[active_labels_2d > 0] = 0 + + resized_regions = skimage.measure.regionprops(resized_labels.astype(np.int32)) + if not resized_regions: + raise ValueError(f'Label {label_id} vanished during resize') + + resized_obj_coords = resized_regions[0].coords + resized_yy = resized_obj_coords[:, -2].astype(int, copy=False) + resized_xx = resized_obj_coords[:, -1].astype(int, copy=False) + resized_coords = (resized_yy.copy(), resized_xx.copy()) + + active_labels_2d[resized_yy, resized_xx] = label_id + labels_2d[resized_yy, resized_xx] = label_id + + return LabelResizeResult( + labels_2d=labels_2d, + active_labels_2d=active_labels_2d, + seed_labels=seed_labels, + previous_coords=previous_coords, + resized_coords=resized_coords, + ) + + +def move_label_object( + labels: np.ndarray, + object_coords: np.ndarray, + label_id: int, + *, + delta_y: int, + delta_x: int, + shape: tuple[int, int] | None = None, +) -> LabelMoveResult: + """Move one 2D or z-stacked label object, clipping at image boundaries.""" + moved_coords = np.asarray(object_coords).copy() + previous_coords = moved_coords.copy() + + if shape is None: + shape = labels.shape[-2:] + height, width = shape + + yy = previous_coords[:, -2].astype(int, copy=False) + xx = previous_coords[:, -1].astype(int, copy=False) + + if labels.ndim >= 3 and previous_coords.shape[1] >= 3: + zz = previous_coords[:, 0].astype(int, copy=False) + labels[zz, yy, xx] = 0 + else: + labels[yy, xx] = 0 + + moved_coords[:, -2] = np.clip( + moved_coords[:, -2] + int(delta_y), 0, height - 1, + ) + moved_coords[:, -1] = np.clip( + moved_coords[:, -1] + int(delta_x), 0, width - 1, + ) + + moved_yy = moved_coords[:, -2].astype(int, copy=False) + moved_xx = moved_coords[:, -1].astype(int, copy=False) + if labels.ndim >= 3 and moved_coords.shape[1] >= 3: + moved_zz = moved_coords[:, 0].astype(int, copy=False) + labels[moved_zz, moved_yy, moved_xx] = label_id + else: + labels[moved_yy, moved_xx] = label_id + + return LabelMoveResult( + labels=labels, + previous_coords=previous_coords, + moved_coords=moved_coords, + ) diff --git a/cellacdc/domain/lineage.py b/cellacdc/domain/lineage.py new file mode 100644 index 000000000..fa87c268c --- /dev/null +++ b/cellacdc/domain/lineage.py @@ -0,0 +1,99 @@ +"""Pure lineage annotation table operations.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + + +@dataclass(frozen=True) +class LineageAnnotationsRemovalResult: + """ACDC frame after removing lineage annotation columns.""" + + acdc_df: pd.DataFrame | None + removed: bool + missing_frame: bool = False + + +@dataclass(frozen=True) +class LineageFutureRemovalResult: + """Future lineage removals for frame records.""" + + acdc_dfs_by_frame: dict[int, pd.DataFrame] + scanned_frame_indices: list[int] + removed_frame_indices: list[int] + missing_frame_indices: list[int] + + +def has_lineage_tree_annotations( + acdc_df: pd.DataFrame | None, + lineage_tree=None, + *, + parent_column: str = 'parent_ID_tree', +) -> bool: + """Return whether lineage tree annotations are active or stored.""" + if lineage_tree is not None: + return True + return acdc_df is not None and parent_column in acdc_df.columns + + +def remove_lineage_tree_annotations( + acdc_df: pd.DataFrame | None, + lineage_tree_colnames, +) -> LineageAnnotationsRemovalResult: + """Return an ACDC frame table without lineage tree columns.""" + if acdc_df is None: + return LineageAnnotationsRemovalResult( + acdc_df=None, + removed=False, + missing_frame=True, + ) + + existing_columns = acdc_df.columns.intersection(lineage_tree_colnames) + if existing_columns.empty: + return LineageAnnotationsRemovalResult(acdc_df=acdc_df, removed=False) + + return LineageAnnotationsRemovalResult( + acdc_df=acdc_df.drop(columns=lineage_tree_colnames, errors='ignore'), + removed=True, + ) + + +def remove_future_lineage_tree_annotations( + frame_records, + lineage_tree_colnames, + from_frame_i: int, + *, + size_t: int | None = None, + acdc_key: str = 'acdc_df', +) -> LineageFutureRemovalResult: + """Return future frame-table lineage removals from ``from_frame_i`` onward.""" + acdc_dfs_by_frame = {} + scanned_frame_indices = [] + removed_frame_indices = [] + missing_frame_indices = [] + stop_at = len(frame_records) if size_t is None else int(size_t) + + for frame_i in range(int(from_frame_i), stop_at): + scanned_frame_indices.append(frame_i) + acdc_df = frame_records[frame_i][acdc_key] + result = remove_lineage_tree_annotations( + acdc_df, + lineage_tree_colnames, + ) + if result.missing_frame: + missing_frame_indices.append(frame_i) + continue + if not result.removed: + continue + + acdc_dfs_by_frame[frame_i] = result.acdc_df + removed_frame_indices.append(frame_i) + + return LineageFutureRemovalResult( + acdc_dfs_by_frame=acdc_dfs_by_frame, + scanned_frame_indices=scanned_frame_indices, + removed_frame_indices=removed_frame_indices, + missing_frame_indices=missing_frame_indices, + ) diff --git a/cellacdc/domain/metrics_basic.py b/cellacdc/domain/metrics_basic.py new file mode 100644 index 000000000..70daa093d --- /dev/null +++ b/cellacdc/domain/metrics_basic.py @@ -0,0 +1,35 @@ +"""Basic measurements from ``PositionSession`` (no legacy ``loadData`` required).""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import skimage.measure + + +def compute_basic_metrics(session) -> pd.DataFrame: + """Regionprops table per frame — fallback when legacy kernel is unavailable.""" + labels = session.labels + if labels is None: + raise ValueError('MeasureRunnable requires labels in session') + + rows: list[dict] = [] + num_frames = session.num_frames + for frame_i in range(num_frames): + lab = session.frame_labels(frame_i) + if lab is None or not np.any(lab): + continue + for rp in skimage.measure.regionprops(lab.astype(np.int32)): + if rp.label == 0: + continue + rows.append({ + 'frame_i': frame_i, + 'Cell_ID': rp.label, + 'area': rp.area, + 'centroid_y': rp.centroid[0], + 'centroid_x': rp.centroid[1], + }) + + if not rows: + return pd.DataFrame(columns=['frame_i', 'Cell_ID', 'area', 'centroid_y', 'centroid_x']) + return pd.DataFrame(rows).set_index(['frame_i', 'Cell_ID']) diff --git a/cellacdc/domain/object_counts.py b/cellacdc/domain/object_counts.py new file mode 100644 index 000000000..d4c0842cc --- /dev/null +++ b/cellacdc/domain/object_counts.py @@ -0,0 +1,107 @@ +"""Scriptable object counting and label-frame helpers.""" + +from __future__ import annotations + +from collections.abc import Callable + +import numpy as np +import skimage.measure + + +def _record_get(record, key, default=None): + if hasattr(record, 'get'): + return record.get(key, default) + return getattr(record, key, default) + + +def current_labels( + pos_data, + *, + curr_lab: np.ndarray | None = None, + frame_i: int | None = None, +) -> np.ndarray | None: + """Resolve the current labels from live, cached, or persisted frame data.""" + if frame_i is None: + frame_i = pos_data.frame_i + + if curr_lab is None and frame_i == pos_data.frame_i: + curr_lab = pos_data.lab + + if curr_lab is None: + try: + labels = _record_get(pos_data.allData_li[frame_i], 'labels') + curr_lab = labels.copy() + except (AttributeError, IndexError, TypeError): + pass + + if curr_lab is None: + try: + curr_lab = pos_data.segm_data[frame_i].copy() + except (AttributeError, IndexError, TypeError): + pass + + return curr_lab + + +def collect_all_ids(pos_data, *, only_visited: bool = False) -> set[int]: + """Collect all object IDs across visited or available segmentation frames.""" + all_ids = set() + for frame_i in range(len(pos_data.segm_data)): + if frame_i >= len(pos_data.allData_li): + break + + frame_record = pos_data.allData_li[frame_i] + lab = _record_get(frame_record, 'labels') + if lab is None and only_visited: + break + + if lab is None: + regionprops = skimage.measure.regionprops(pos_data.segm_data[frame_i]) + else: + regionprops = _record_get(frame_record, 'regionprops') + if regionprops is None: + regionprops = skimage.measure.regionprops(lab) + + all_ids.update(int(obj.label) for obj in regionprops) + + return all_ids + + +def snapshot_object_counts( + positions, + current_pos_i: int, + *, + current_lab_2d=None, + include_current_z_slice: bool = False, + path_exists: Callable[[str], bool], +) -> dict[str, int]: + """Count objects across loaded snapshot positions.""" + pos_data = positions[current_pos_i] + counts = { + 'In current position': len(pos_data.IDs), + 'In all visited positions (current session)': 0, + 'In all visited positions (previous sessions)': 0, + 'In all loaded positions': 0, + } + if include_current_z_slice and current_lab_2d is not None: + counts['In current z-slice'] = len( + skimage.measure.regionprops(current_lab_2d) + ) + + for position in positions: + ids = _record_get(position.allData_li[0], 'IDs', []) + if path_exists(position.acdc_output_csv_path): + counts['In all visited positions (previous sessions)'] += len(ids) + + if ids: + num_objects = len(ids) + else: + regionprops = skimage.measure.regionprops(position.segm_data[0]) + num_objects = len(regionprops) + + counts['In all loaded positions'] += num_objects + + if position.visited: + counts['In all visited positions (current session)'] += num_objects + + return counts diff --git a/cellacdc/domain/object_search.py b/cellacdc/domain/object_search.py new file mode 100644 index 000000000..9e4aa4447 --- /dev/null +++ b/cellacdc/domain/object_search.py @@ -0,0 +1,36 @@ +"""Scriptable object search operations.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence + +import skimage.measure + + +def find_frame_with_id( + segmentation_frames: Sequence, + frame_records: Sequence[dict], + searched_id: int, + *, + progress_callback: Callable[[int], None] | None = None, +) -> int | None: + """Return the first frame index containing ``searched_id``.""" + for frame_i, segmentation in enumerate(segmentation_frames): + if frame_i >= len(frame_records): + break + + frame_record = frame_records[frame_i] + labels = frame_record['labels'] + if labels is None: + regionprops = skimage.measure.regionprops(segmentation) + frame_ids = {obj.label for obj in regionprops} + else: + frame_ids = set(frame_record['IDs']) + + if searched_id in frame_ids: + return frame_i + + if progress_callback is not None: + progress_callback(1) + + return None diff --git a/cellacdc/domain/points.py b/cellacdc/domain/points.py new file mode 100644 index 000000000..163895fce --- /dev/null +++ b/cellacdc/domain/points.py @@ -0,0 +1,370 @@ +"""Pure point-layer table transformations.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +import numpy as np +import pandas as pd + + +def infer_points_column_mapping(columns: Iterable[str]) -> dict[str, str]: + """Infer standard point-layer columns from table columns.""" + column_set = set(columns) + return { + 'x': 'x' if 'x' in column_set else 'None', + 'y': 'y' if 'y' in column_set else 'None', + 'z': 'z' if 'z' in column_set else 'None', + 't': 'frame_i' if 'frame_i' in column_set else 'None', + } + + +def points_table_to_data( + df: pd.DataFrame, + t_col: str, + z_col: str, + y_col: str, + x_col: str, + *, + include_row_data: bool = True, +) -> dict[Any, dict[str, list[Any]] | dict[int, dict[str, list[Any]]]]: + """Convert a points table to the nested point-layer data structure.""" + points_data = {} + points_df = df.copy() + if 'id' not in points_df.columns: + points_df['id'] = '' + + if t_col != 'None': + grouped = points_df.groupby(t_col) + else: + grouped = [(0, points_df)] + + for frame_i, df_frame in grouped: + if z_col != 'None': + df_frame = df_frame.copy() + df_frame[z_col] = df_frame[z_col].round().astype(int) + points_data[frame_i] = {} + for z in df_frame[z_col].unique(): + z_int = round(z) + df_z = df_frame[df_frame[z_col] == z_int] + z_data = { + 'x': df_z[x_col].to_list(), + 'y': df_z[y_col].to_list(), + 'id': df_z['id'].to_list(), + } + if include_row_data: + z_data['data'] = [ + row.to_string() for _, row in df_z.iterrows() + ] + points_data[frame_i][z_int] = z_data + else: + frame_data = { + 'x': df_frame[x_col].to_list(), + 'y': df_frame[y_col].to_list(), + 'id': df_frame['id'].to_list(), + } + if include_row_data: + frame_data['data'] = [ + row.to_string() for _, row in df_frame.iterrows() + ] + points_data[frame_i] = frame_data + return points_data + + +def click_points_table_to_data( + df: pd.DataFrame, + *, + size_z: int = 1, +) -> dict[Any, dict[str, list[Any]] | dict[int, dict[str, list[Any]]]]: + """Convert click-entry point tables to GUI point-layer data.""" + if size_z > 1 and df['z'].isna().any(): + raise ValueError('3D point tables require z values for every row') + + z_col = 'z' if size_z > 1 else 'None' + return points_table_to_data( + df, + 'frame_i', + z_col, + 'y', + 'x', + include_row_data=False, + ) + + +def point_id_already_new( + points_data_pos: dict[Any, Any] | None, + frame_i: int, + point_id: int, + known_ids: Iterable[int], +) -> bool: + """Return whether ``point_id`` is new and not already present in frame data.""" + if point_id in known_ids: + return False + + if points_data_pos is None: + return True + + frame_points_data = points_data_pos.get(frame_i) + if frame_points_data is None: + return True + + if 'x' not in frame_points_data: + for z_data in frame_points_data.values(): + if point_id in z_data['id']: + return False + return True + + return point_id not in frame_points_data['id'] + + +def next_click_point_id( + points_data_pos: dict[Any, Any] | None, + frame_i: int, + current_id: int, + *, + size_z: int = 1, +) -> int: + """Return the next point id for a click-entry layer.""" + if points_data_pos is None: + return 1 + + frame_points_data = points_data_pos.get(frame_i) + if frame_points_data is None: + return 1 + + if size_z > 1: + new_id = 1 + for z_data in frame_points_data.values(): + max_id = max(z_data.get('id', []), default=0) + 1 + if max_id > new_id: + new_id = max_id + else: + new_id = max(frame_points_data.get('id', []), default=0) + 1 + + if current_id >= new_id: + return current_id + return new_id + + +def add_click_point( + points_data_pos: dict[Any, Any], + frame_i: int, + x: float, + y: float, + point_id: int, + *, + size_z: int = 1, + z_slice: int | None = None, +) -> dict[Any, Any]: + """Add one click-entry point to nested point-layer data.""" + frame_points_data = points_data_pos.get(frame_i) + if frame_points_data is None: + if size_z > 1: + if z_slice is None: + raise ValueError('z_slice is required for 3D point data') + points_data_pos[frame_i] = { + z_slice: {'x': [x], 'y': [y], 'id': [point_id]}, + } + else: + points_data_pos[frame_i] = { + 'x': [x], 'y': [y], 'id': [point_id], + } + return points_data_pos + + if size_z > 1: + if z_slice is None: + raise ValueError('z_slice is required for 3D point data') + z_data = frame_points_data.get(z_slice) + if z_data is None: + frame_points_data[z_slice] = { + 'x': [x], 'y': [y], 'id': [point_id], + } + else: + z_data['x'].append(x) + z_data['y'].append(y) + z_data['id'].append(point_id) + else: + frame_points_data['x'].append(x) + frame_points_data['y'].append(y) + frame_points_data['id'].append(point_id) + + points_data_pos[frame_i] = frame_points_data + return points_data_pos + + +def remove_click_points( + frame_points_data: dict[Any, Any], + points: Iterable[tuple[float, float, int]], + *, + z_slice: int | None = None, + z_radius: int = 0, +) -> list[int]: + """Remove clicked points from one frame's nested point-layer data.""" + removed_ids = [] + for x, y, point_id in points: + if z_slice is not None: + z_range = range(z_slice - z_radius, z_slice + z_radius + 1) + data_slices = [ + frame_points_data[z] + for z in z_range + if z in frame_points_data + ] + else: + data_slices = [frame_points_data] + + for points_slice in data_slices: + if point_id not in points_slice['id']: + continue + points_slice['x'].remove(x) + points_slice['y'].remove(y) + points_slice['id'].remove(point_id) + removed_ids.append(point_id) + + return removed_ids + + +def flatten_frame_points_data( + frame_points_data: dict[Any, Any], + *, + z_slice: int | None = None, + z_radius: int = 0, +) -> tuple[list[Any], list[Any], list[Any], list[Any]]: + """Flatten one frame's point-layer data for display or scripting.""" + if 'x' in frame_points_data: + return ( + list(frame_points_data['x']), + list(frame_points_data['y']), + list(frame_points_data['id']), + list(frame_points_data.get('data', [])), + ) + + xx, yy, ids, data = [], [], [], [] + if z_slice is None: + z_items = frame_points_data.items() + else: + z_range = range(z_slice - z_radius, z_slice + z_radius + 1) + z_items = ( + (z, frame_points_data[z]) + for z in z_range + if z in frame_points_data + ) + + for _z, z_data in z_items: + xx.extend(z_data['x']) + yy.extend(z_data['y']) + ids.extend(z_data['id']) + data.extend(z_data.get('data', [])) + return xx, yy, ids, data + + +def _label_at(labels, y: float, x: float, z: float | None, is_segm_3d: bool): + if is_segm_3d and z is not None: + return labels[int(z), int(y), int(x)] + return labels[int(y), int(x)] + + +def _linear_fit_3d(xx, yy, zz): + points = np.column_stack((xx, yy, zz)) + centroid = points.mean(axis=0) + _, _, vh = np.linalg.svd(points - centroid) + return centroid, vh[0] + + +def interpolate_points_zslices( + df: pd.DataFrame, + labels, + *, + is_segm_3d: bool, +) -> pd.DataFrame: + """Interpolate missing z-slice points for each frame/id point track.""" + if not is_segm_3d or 'z' not in df.columns: + return df + + df_new_rows = [] + for (_frame_i, _point_id), df_id in df.groupby(['frame_i', 'id']): + xx = df_id['x'].values + yy = df_id['y'].values + zz = df_id['z'].values + point, direction = _linear_fit_3d(xx, yy, zz) + + new_row_df = df_id.iloc[[0]].copy() + z0, z1 = int(np.min(zz)), int(np.max(zz)) + for z in range(z0, z1 + 1): + if z in zz: + continue + + t_int = (z - point[2]) / direction[2] + x_new, y_new, z_new = point + t_int * direction + new_row_df['z'] = round(z_new) + new_row_df['y'] = round(y_new) + new_row_df['x'] = round(x_new) + new_row_df['Cell_ID'] = labels[ + int(round(z_new)), + int(round(y_new)), + int(round(x_new)), + ] + df_new_rows.append(new_row_df.copy()) + + if not df_new_rows: + return df + + df_new = pd.concat(df_new_rows, ignore_index=True) + df = pd.concat([df, df_new], ignore_index=True) + return df.sort_values(by=['frame_i', 'id', 'z']).reset_index(drop=True) + + +def points_data_to_table( + points_data: dict[Any, Any], + labels, + *, + is_segm_3d: bool = False, + size_z: int = 1, + interpolate_z: bool = False, +) -> pd.DataFrame: + """Convert nested point-layer data to a table.""" + df = pd.DataFrame(columns=['frame_i', 'Cell_ID', 'z', 'y', 'x', 'id']) + frames_vals = [] + cell_ids = [] + zz = [] + yy = [] + xx = [] + ids = [] + + for frame_i, frame_points_data in points_data.items(): + if size_z > 1: + for z, z_slice_points_data in frame_points_data.items(): + for y, x, point_id in zip( + z_slice_points_data['y'], + z_slice_points_data['x'], + z_slice_points_data['id'], + ): + frames_vals.append(frame_i) + cell_ids.append(_label_at(labels, y, x, z, is_segm_3d)) + zz.append(z) + yy.append(y) + xx.append(x) + ids.append(point_id) + else: + for y, x, point_id in zip( + frame_points_data['y'], + frame_points_data['x'], + frame_points_data['id'], + ): + frames_vals.append(frame_i) + cell_ids.append(_label_at(labels, y, x, None, is_segm_3d)) + yy.append(y) + xx.append(x) + ids.append(point_id) + + df['frame_i'] = frames_vals + df['Cell_ID'] = cell_ids + df['y'] = yy + df['x'] = xx + df['id'] = ids + if zz: + df['z'] = zz + + if interpolate_z: + df = interpolate_points_zslices(df, labels, is_segm_3d=is_segm_3d) + return df diff --git a/cellacdc/domain/session.py b/cellacdc/domain/session.py new file mode 100644 index 000000000..0f94a7e4f --- /dev/null +++ b/cellacdc/domain/session.py @@ -0,0 +1,142 @@ +"""In-memory session objects for scripting and GUI binding.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +import pandas as pd + +from .events import EventEmitter +from .state import PositionState + + +@dataclass +class PositionSession: + """Headless per-position data container (successor to ``loadData``).""" + + _state: PositionState + events: EventEmitter = field(default_factory=EventEmitter) + _legacy_pos_data: Any = field(default=None, repr=False) + + @classmethod + def from_arrays( + cls, + intensity: np.ndarray, + labels: np.ndarray | None = None, + acdc_df: pd.DataFrame | None = None, + *, + pixel_size_um: float | None = None, + channel_name: str = '', + basename: str = '', + **metadata: Any, + ) -> PositionSession: + md = dict(metadata) + if pixel_size_um is not None: + md['pixel_size_um'] = pixel_size_um + if channel_name: + md['channel_name'] = channel_name + if basename: + md['basename'] = basename + state = PositionState( + intensity=np.asarray(intensity), + labels=None if labels is None else np.asarray(labels), + acdc_df=acdc_df, + metadata=md, + ) + return cls(_state=state) + + @classmethod + def from_path( + cls, + img_path: str, + user_ch_name: str = '', + **metadata: Any, + ) -> PositionSession: + from cellacdc.io.adapters.disk_to_session import load_position_from_disk + + return load_position_from_disk(img_path, user_ch_name, **metadata) + + @classmethod + def from_loadData(cls, pos_data: Any) -> PositionSession: + from cellacdc.io.adapters.legacy_load_data import session_from_load_data + + return session_from_load_data(pos_data) + + @property + def intensity(self) -> np.ndarray: + return self._state.intensity + + @property + def labels(self) -> np.ndarray | None: + return self._state.labels + + @property + def acdc_df(self) -> pd.DataFrame | None: + return self._state.acdc_df + + @property + def metadata(self) -> dict[str, Any]: + return self._state.metadata + + @property + def frame_i(self) -> int: + return self._state.frame_i + + @frame_i.setter + def frame_i(self, value: int) -> None: + self._state.frame_i = int(value) + self.events.emit('frame_changed', self._state.frame_i) + + @property + def num_frames(self) -> int: + return self._state.num_frames + + @property + def legacy_pos_data(self) -> Any: + return self._legacy_pos_data + + def set_labels(self, labels: np.ndarray) -> None: + self._state.labels = np.asarray(labels) + self.events.emit('labels_changed', self._state.labels) + + def set_acdc_df(self, acdc_df: pd.DataFrame) -> None: + self._state.acdc_df = acdc_df + self.events.emit('acdc_df_changed', acdc_df) + + def frame_intensity(self, frame_i: int | None = None) -> np.ndarray: + return self._state.frame_intensity(frame_i) + + def frame_labels(self, frame_i: int | None = None) -> np.ndarray | None: + return self._state.frame_labels(frame_i) + + def save(self, path: str | None = None) -> None: + from cellacdc.io.adapters.session_to_disk import save_position_session + + save_position_session(self, path) + + +@dataclass +class ExperimentSession: + """Collection of positions in an experiment folder.""" + + positions: list[PositionSession] = field(default_factory=list) + exp_path: str = '' + events: EventEmitter = field(default_factory=EventEmitter) + + @classmethod + def from_experiment_path( + cls, + exp_path: str, + user_ch_name: str = '', + ) -> ExperimentSession: + from cellacdc.io.adapters.disk_to_session import load_experiment_from_disk + + return load_experiment_from_disk(exp_path, user_ch_name) + + def __len__(self) -> int: + return len(self.positions) + + def __getitem__(self, index: int) -> PositionSession: + return self.positions[index] diff --git a/cellacdc/domain/state.py b/cellacdc/domain/state.py new file mode 100644 index 000000000..c4f2c639c --- /dev/null +++ b/cellacdc/domain/state.py @@ -0,0 +1,41 @@ +"""Mutable in-memory state container for a position.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +import pandas as pd + + +@dataclass +class PositionState: + """Raw mutable store backing :class:`PositionSession`.""" + + intensity: np.ndarray + labels: np.ndarray | None = None + acdc_df: pd.DataFrame | None = None + metadata: dict[str, Any] = field(default_factory=dict) + frame_i: int = 0 + fluo_data: dict[str, np.ndarray] = field(default_factory=dict) + + @property + def num_frames(self) -> int: + if self.intensity.ndim == 2: + return 1 + return int(self.intensity.shape[0]) + + def frame_intensity(self, frame_i: int | None = None) -> np.ndarray: + idx = self.frame_i if frame_i is None else frame_i + if self.intensity.ndim == 2: + return self.intensity + return self.intensity[idx] + + def frame_labels(self, frame_i: int | None = None) -> np.ndarray | None: + if self.labels is None: + return None + idx = self.frame_i if frame_i is None else frame_i + if self.labels.ndim == 2: + return self.labels + return self.labels[idx] diff --git a/cellacdc/domain/tracking.py b/cellacdc/domain/tracking.py new file mode 100644 index 000000000..205632360 --- /dev/null +++ b/cellacdc/domain/tracking.py @@ -0,0 +1,221 @@ +"""Tracking-related label metadata transforms.""" + +from __future__ import annotations + +from dataclasses import dataclass +from itertools import product + +import numpy as np + + +@dataclass(frozen=True) +class TrackedLostIdsResult: + """Result of resolving stored lost centroids against a previous label image.""" + + lost_ids: set[int] + remaining_centroids: set[tuple[int, ...]] + + +@dataclass(frozen=True) +class LostNewIdsResult: + """Result of comparing previous and current frame IDs.""" + + lost_ids: list[int] + new_ids: list[int] + ids_with_holes: list[int] + + +@dataclass(frozen=True) +class FutureIdPropagationScan: + """Future-frame scan result for an ID-changing segmentation edit.""" + + last_tracked_i: int + has_affected_future_ids: bool + + +def last_tracked_frame_index( + frame_labels, + *, + first_frame_fallback: int = 0, + total_frames: int | None = None, +) -> int: + """Return the last contiguous frame index with stored labels. + + ``first_frame_fallback`` preserves legacy GUI paths that disagree on + whether an unvisited first frame means frame ``0`` or no frame ``-1``. + """ + last_seen_i = 0 + saw_frame = False + for frame_i, labels in enumerate(frame_labels): + saw_frame = True + if labels is None: + return first_frame_fallback if frame_i == 0 else frame_i - 1 + last_seen_i = frame_i + + if total_frames is not None: + return max(int(total_frames) - 1, 0) + if not saw_frame: + return 0 + return last_seen_i + + +def scan_future_id_propagation( + target_id: int, + *, + current_frame_i: int, + frame_labels, + fallback_frame_labels, + include_unvisited: bool = False, + total_frames: int | None = None, +) -> FutureIdPropagationScan: + """Scan future labels for ``target_id`` and report propagation state.""" + frame_labels = list(frame_labels) + fallback_frame_labels = list(fallback_frame_labels) + if total_frames is None: + total_frames = len(fallback_frame_labels) + + last_tracked_i = int(total_frames) - 1 + last_tracked_i_found = False + has_affected_future_ids = False + for frame_i in range(current_frame_i + 1, len(fallback_frame_labels)): + labels = frame_labels[frame_i] + if labels is None: + if not last_tracked_i_found: + last_tracked_i = frame_i - 1 + last_tracked_i_found = True + if not include_unvisited: + break + labels = fallback_frame_labels[frame_i] + + if target_id in labels: + has_affected_future_ids = True + + return FutureIdPropagationScan( + last_tracked_i=last_tracked_i, + has_affected_future_ids=has_affected_future_ids, + ) + + +def track_labels( + labels: np.ndarray, + tracker_name: str = 'CellACDC', + *, + init_kwargs: dict | None = None, + track_params: dict | None = None, + intensity_img=None, + logger_func=print, +) -> np.ndarray: + """Track a label video with a Cell-ACDC tracker plugin.""" + from cellacdc.plugins.registry import import_tracker_module + + init_kwargs = {} if init_kwargs is None else init_kwargs + track_params = {} if track_params is None else track_params + tracker_module = import_tracker_module(tracker_name) + tracker = tracker_module.tracker(**init_kwargs) + args_to_try = (tuple(), (intensity_img,)) if intensity_img is not None else (tuple(),) + + for args, kwarg_to_remove in product(args_to_try, ('', 'signals')): + kwargs = track_params.copy() + kwargs.pop(kwarg_to_remove, None) + try: + return tracker.track(labels, *args, **kwargs) + except Exception as err: + is_unexpected_kwarg = ( + "got an unexpected keyword argument 'signals'" in str(err) + ) + is_missing_arg = 'missing 1 required positional argument:' in str(err) + if is_unexpected_kwarg or is_missing_arg: + continue + raise + + raise RuntimeError(f'Unable to run {tracker_name} tracker') + + +def compute_lost_new_ids( + previous_ids, + current_ids, + *, + current_deleted_roi_ids=(), + previous_deleted_roi_ids=(), + tracked_lost_ids=(), +) -> LostNewIdsResult: + """Compute ordered lost/new ID lists between adjacent frames.""" + current_id_set = {int(label_id) for label_id in current_ids} + previous_id_set = {int(label_id) for label_id in previous_ids} + current_deleted_roi_ids = { + int(label_id) for label_id in current_deleted_roi_ids + } + previous_deleted_roi_ids = { + int(label_id) for label_id in previous_deleted_roi_ids + } + tracked_lost_ids = {int(label_id) for label_id in tracked_lost_ids} + + lost_ids = [ + int(label_id) for label_id in previous_ids + if ( + int(label_id) not in current_id_set + and int(label_id) not in previous_deleted_roi_ids + and int(label_id) not in tracked_lost_ids + ) + ] + new_ids = [ + int(label_id) for label_id in current_ids + if ( + int(label_id) not in previous_id_set + and int(label_id) not in current_deleted_roi_ids + ) + ] + + return LostNewIdsResult( + lost_ids=lost_ids, + new_ids=new_ids, + ids_with_holes=[], + ) + + +def tracked_lost_centroids_from_regionprops( + regionprops, + tracked_lost_ids, +) -> set[tuple[int, ...]]: + """Collect integer centroids for tracker-accepted lost IDs.""" + tracked_lost_ids = {int(label_id) for label_id in tracked_lost_ids} + return { + tuple(int(val) for val in obj.centroid) + for obj in regionprops + if int(obj.label) in tracked_lost_ids + } + + +def tracked_lost_ids_from_centroids( + prev_labels: np.ndarray, + tracked_lost_centroids, + ids_in_frame, +) -> TrackedLostIdsResult: + """Resolve tracked-lost centroids to IDs and prune re-tracked centroids.""" + tracked_lost_centroids = { + tuple(int(coord) for coord in centroid) + for centroid in tracked_lost_centroids + } + ids_in_frame = {int(label_id) for label_id in ids_in_frame} + retracked_centroids = set() + lost_ids = set() + + for centroid in tracked_lost_centroids: + if len(centroid) < 3 and prev_labels.ndim == 3: + # Ignore wrongly stored centroids, preserving the original record. + continue + + label_id = int(prev_labels[centroid]) + if label_id == 0: + continue + + if label_id in ids_in_frame: + retracked_centroids.add(centroid) + continue + + lost_ids.add(label_id) + + return TrackedLostIdsResult( + lost_ids=lost_ids, + remaining_centroids=tracked_lost_centroids - retracked_centroids, + ) diff --git a/cellacdc/domain/types.py b/cellacdc/domain/types.py new file mode 100644 index 000000000..6e34ad25f --- /dev/null +++ b/cellacdc/domain/types.py @@ -0,0 +1,8 @@ +"""Shared type aliases for the domain model.""" + +from typing import NewType + +FrameIndex = NewType('FrameIndex', int) +CellID = NewType('CellID', int) +ChannelName = NewType('ChannelName', str) +PixelSize = NewType('PixelSize', float) diff --git a/cellacdc/domain/visited_frames.py b/cellacdc/domain/visited_frames.py new file mode 100644 index 000000000..b622ffa66 --- /dev/null +++ b/cellacdc/domain/visited_frames.py @@ -0,0 +1,48 @@ +"""Visited-frame state transitions shared by GUI and scripts.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LastVisitedFrameUpdate: + """Updated last-visited counters for workflow modes.""" + + last_tracked_i: int + last_cca_frame_i: int + changed: bool = False + + +def update_last_visited_frame_state( + mode: str, + last_visited_frame_i: int, + *, + last_tracked_i: int, + last_cca_frame_i: int, +) -> LastVisitedFrameUpdate: + """Return updated last-visited counters for a workflow mode.""" + mode = str(mode) + last_visited_frame_i = int(last_visited_frame_i) + last_tracked_i = int(last_tracked_i) + last_cca_frame_i = int(last_cca_frame_i) + + if mode == 'Segmentation and Tracking': + if last_tracked_i >= last_visited_frame_i: + return LastVisitedFrameUpdate(last_tracked_i, last_cca_frame_i) + return LastVisitedFrameUpdate( + last_visited_frame_i, + last_cca_frame_i, + changed=True, + ) + + if mode == 'Cell cycle analysis': + if last_cca_frame_i >= last_visited_frame_i: + return LastVisitedFrameUpdate(last_tracked_i, last_cca_frame_i) + return LastVisitedFrameUpdate( + last_tracked_i, + last_visited_frame_i, + changed=True, + ) + + return LastVisitedFrameUpdate(last_tracked_i, last_cca_frame_i) diff --git a/cellacdc/gui.py b/cellacdc/gui.py index e04835c12..79039258f 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -139,237 +139,90 @@ def __init__( self.app = app self.closeGUI = False self.view_model = MainGuiViewModel() - self.window_events_view = WindowEventsView( - self, - self.view_model.window_events, - ) + self.window_events_view = WindowEventsView(self) self.window_events_view.bind_legacy_methods() - self.tracking_view = TrackingView( - self, - self.view_model.tracking, - ) + self.tracking_view = TrackingView(self) self.tracking_view.bind_legacy_methods() - self.image_display_view = ImageDisplayView( - self, - self.view_model.image_display, - ) + self.image_display_view = ImageDisplayView(self) self.image_display_view.bind_legacy_methods() - self.data_loading_view = DataLoadingView( - self, - self.view_model.data_loading, - ) + self.data_loading_view = DataLoadingView(self) self.data_loading_view.bind_legacy_methods() - self.cell_cycle_view = CellCycleView( - self, - self.view_model.cell_cycle, - ) + self.cell_cycle_view = CellCycleView(self) self.cell_cycle_view.bind_legacy_methods() - self.graphics_view = GraphicsView( - self, - self.view_model.graphics, - ) + self.graphics_view = GraphicsView(self) self.graphics_view.bind_legacy_methods() - self.actions_view = ActionsView( - self, - self.view_model.actions, - ) + self.actions_view = ActionsView(self) self.actions_view.bind_legacy_methods() - self.app_shell_view = AppShellView( - self, - self.view_model.app_shell, - ) + self.app_shell_view = AppShellView(self) self.app_shell_view.bind_legacy_methods() - self.annotation_display_view = AnnotationDisplayView( - self, - self.view_model.annotation_display, - ) + self.annotation_display_view = AnnotationDisplayView(self) self.annotation_display_view.bind_legacy_methods() - self.session_view = SessionView( - self, - self.view_model.session, - ) + self.session_view = SessionView(self) self.session_view.bind_legacy_methods() - self.frame_navigation_view = FrameNavigationView( - self, - self.view_model.frame_navigation, - ) + self.frame_navigation_view = FrameNavigationView(self) self.frame_navigation_view.bind_legacy_methods() - self.canvas_drawing_view = CanvasDrawingView( - self, - self.view_model.canvas_drawing, - ) + self.canvas_drawing_view = CanvasDrawingView(self) self.canvas_drawing_view.bind_legacy_methods() - self.canvas_events_view = CanvasEventsView( - self, - self.view_model.canvas_events, - ) + self.canvas_events_view = CanvasEventsView(self) self.canvas_events_view.bind_legacy_methods() - self.canvas_selection_view = CanvasSelectionView( - self, - self.view_model.canvas_selection, - ) + self.canvas_selection_view = CanvasSelectionView(self) self.canvas_selection_view.bind_legacy_methods() - self.canvas_context_menu_view = CanvasContextMenuView( - self, - self.view_model.canvas_context_menu, - ) - self.canvas_right_image_view = CanvasRightImageView( - self, - self.view_model.canvas_right_image, - ) - self.canvas_hover_view = CanvasHoverView( - self, - self.view_model.canvas_hover, - ) + self.canvas_context_menu_view = CanvasContextMenuView(self) + self.canvas_right_image_view = CanvasRightImageView(self) + self.canvas_hover_view = CanvasHoverView(self) self.canvas_hover_view.bind_legacy_methods() - self.label_roi_view = LabelRoiView( - self, - self.view_model.label_roi, - ) + self.label_roi_view = LabelRoiView(self) self.label_roi_view.bind_legacy_methods() - self.label_editing_view = LabelEditingView( - self, - self.view_model.label_editing, - ) + self.label_editing_view = LabelEditingView(self) self.label_editing_view.bind_legacy_methods() - self.lineage_interactions_view = LineageInteractionsView( - self, - self.view_model.lineage_interactions, - ) + self.lineage_interactions_view = LineageInteractionsView(self) self.lineage_interactions_view.bind_legacy_methods() - self.custom_annotations_view = CustomAnnotationsView( - self, - self.view_model.custom_annotations, - ) - self.undo_redo_view = UndoRedoView( - self, - self.view_model.undo_redo, - ) + self.custom_annotations_view = CustomAnnotationsView(self) + self.undo_redo_view = UndoRedoView(self) self.undo_redo_view.bind_legacy_methods() - self.worker_view = WorkerView( - self, - self.view_model.worker, - ) + self.worker_view = WorkerView(self) self.worker_view.bind_legacy_methods() - self.brush_tools_view = BrushToolsView( - self, - self.view_model.brush_tools, - ) + self.brush_tools_view = BrushToolsView(self) self.brush_tools_view.bind_legacy_methods() - self.deleted_rois_view = DeletedRoisView( - self, - self.view_model.deleted_rois, - ) + self.deleted_rois_view = DeletedRoisView(self) self.deleted_rois_view.bind_legacy_methods() - self.draw_clear_region_view = DrawClearRegionView( - self, - self.view_model.draw_clear_region, - ) - self.display_decorations_view = DisplayDecorationsView( - self, - self.view_model.display_decorations, - ) - self.object_cleanup_view = ObjectCleanupView( - self, - self.view_model.object_cleanup, - ) - self.object_properties_view = ObjectPropertiesView( - self, - self.view_model.object_properties, - ) + self.draw_clear_region_view = DrawClearRegionView(self) + self.display_decorations_view = DisplayDecorationsView(self) + self.object_cleanup_view = ObjectCleanupView(self) + self.object_properties_view = ObjectPropertiesView(self) self.object_properties_view.bind_legacy_methods() - self.object_search_view = ObjectSearchView( - self, - self.view_model.object_search, - ) + self.object_search_view = ObjectSearchView(self) self.curvature_tools_view = CurvatureToolsView( self, self.view_model.curvature, ) - self.seg_for_lost_ids_view = SegForLostIdsView( - self, - self.view_model.seg_for_lost_ids, - ) - self.segmentation_view = SegmentationView( - self, - self.view_model.segmentation, - ) + self.seg_for_lost_ids_view = SegForLostIdsView(self) + self.segmentation_view = SegmentationView(self) self.segmentation_view.bind_legacy_methods() - self.saving_view = SavingView( - self, - self.view_model.saving, - ) + self.saving_view = SavingView(self) self.saving_view.bind_legacy_methods() - self.mode_controls_view = ModeControlsView( - self, - self.view_model.mode_controls, - ) - self.image_controls_view = ImageControlsView( - self, - self.view_model.image_controls, - ) - self.preprocessing_view = PreprocessingView( - self, - self.view_model.preprocessing, - ) - self.magic_prompts_view = MagicPromptsView( - self, - self.view_model.magic_prompts, - ) - self.exporting_view = ExportingView( - self, - self.view_model.exporting, - ) - self.main_toolbar_view = MainToolbarView( - self, - self.view_model.main_toolbar, - ) - self.main_menu_view = MainMenuView( - self, - self.view_model.main_menu, - ) - self.label_transform_tools_view = LabelTransformToolsView( - self, - self.view_model.label_transform_tools, - ) - self.measurements_view = MeasurementsView( - self, - self.view_model.measurements, - ) - self.quick_settings_view = QuickSettingsView( - self, - self.view_model.quick_settings, - ) - self.status_hover_view = StatusHoverView( - self, - self.view_model.status_hover, - ) - self.points_layers_view = PointsLayersView( - self, - self.view_model.points_layers, - ) + self.mode_controls_view = ModeControlsView(self) + self.image_controls_view = ImageControlsView(self) + self.preprocessing_view = PreprocessingView(self) + self.magic_prompts_view = MagicPromptsView(self) + self.exporting_view = ExportingView(self) + self.main_toolbar_view = MainToolbarView(self) + self.main_menu_view = MainMenuView(self) + self.label_transform_tools_view = LabelTransformToolsView(self) + self.measurements_view = MeasurementsView(self) + self.quick_settings_view = QuickSettingsView(self) + self.status_hover_view = StatusHoverView(self) + self.points_layers_view = PointsLayersView(self) self.points_layers_view.bind_legacy_methods() - self.tool_activation_view = ToolActivationView( - self, - self.view_model.tool_activation, - ) + self.tool_activation_view = ToolActivationView(self) self.tool_activation_view.bind_legacy_methods() - self.layout_controls_view = LayoutControlsView( - self, - self.view_model.layout_controls, - ) + self.layout_controls_view = LayoutControlsView(self) self.layout_controls_view.bind_legacy_methods() - self.combine_view = CombineView( - self, - self.view_model.combine, - ) + self.combine_view = CombineView(self) self.combine_view.bind_legacy_methods() - self.whitelist_view = WhitelistView( - self, - self.view_model.whitelist, - ) + self.whitelist_view = WhitelistView(self) self.whitelist_view.bind_legacy_methods() - self.canvas_tool_view = CanvasToolView(self.view_model.canvas_tools) + self.canvas_tool_view = CanvasToolView(self) self._acdc_version = self.view_model.app_shell.read_version() self.setAcceptDrops(True) diff --git a/cellacdc/models/actions_model.py b/cellacdc/models/actions_model.py deleted file mode 100644 index 2610c08a5..000000000 --- a/cellacdc/models/actions_model.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Scriptable model rules for GUI actions and shortcuts.""" - -from __future__ import annotations - - -class ActionsModel: - """Headless decisions for action and shortcut workflows.""" - - keyboard_shortcuts_section = 'keyboard.shortcuts' - delete_object_section = 'delete_object.action' - delete_key_option = 'Key sequence' - delete_button_option = 'Mouse button' - - def default_delete_object_texts(self, *, is_mac: bool) -> tuple[str, str]: - if is_mac: - return 'Ctrl', 'Left click' - return '', 'Middle click' - - def sanitize_key_sequence_text(self, text) -> str: - if text is None: - return '' - return str(text).encode('ascii', 'ignore').decode('utf-8') - - def delete_object_button_text(self, *, is_left_click: bool) -> str: - return 'Left click' if is_left_click else 'Middle click' - - def delete_object_button_is_left_click(self, text: str) -> bool: - return text == 'Left click' - - def should_restore_default_delete_action(self, *, had_error: bool) -> bool: - return had_error diff --git a/cellacdc/models/annotation_display_model.py b/cellacdc/models/annotation_display_model.py deleted file mode 100644 index ecfd878c1..000000000 --- a/cellacdc/models/annotation_display_model.py +++ /dev/null @@ -1,542 +0,0 @@ -"""Qt-free model rules for annotation display workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal, Mapping - - -AnnotationSide = Literal['left', 'right'] -AnnotationOption = Literal[ - 'ids', - 'cca', - 'mother_bud_lines', - 'contours', - 'segm_masks', - 'nothing', - 'num_zslices', -] - - -@dataclass(frozen=True) -class AnnotationOptionState: - """Checkbox state for one annotation side.""" - - ids: bool = False - cca: bool = False - contours: bool = False - segm_masks: bool = False - mother_bud_lines: bool = False - num_zslices: bool = False - nothing: bool = False - - -@dataclass(frozen=True) -class AnnotationModeChangePlan: - """Pure outcome of changing the annotation mode for one image side.""" - - side: AnnotationSide - setting_update: tuple[str, str] | None - text_annotation_index: int - is_cca_annotation: bool - is_id_annotation: bool - should_refresh_images: bool - should_reset_eraser_temp: bool = False - - -@dataclass(frozen=True) -class AnnotationOptionChangePlan: - """Pure outcome of changing annotation option checkboxes.""" - - side: AnnotationSide - state: AnnotationOptionState - mode_text: str - save_settings: bool - - -@dataclass(frozen=True) -class AnnotationOptionsFromModeTextPlan: - """Pure outcome of syncing option checkboxes from combobox text.""" - - state_updates: tuple[tuple[AnnotationSide, AnnotationOptionState], ...] - - -@dataclass(frozen=True) -class AnnotationDisplaySettingsRestorePlan: - """Pure outcome of restoring annotation display settings.""" - - left_mode: str - right_mode: str - add_new_ids_whitelist_toggle: bool - - -@dataclass(frozen=True) -class PixelModeChangePlan: - """Pure outcome of toggling annotation pixel mode.""" - - setting_update: tuple[str, int] - should_update_text_pixel_mode: bool - should_refresh_images: bool - - -@dataclass(frozen=True) -class TextResolutionChangePlan: - """Pure outcome of toggling annotation text resolution.""" - - mode: str - log_message: str - pixel_mode_disabled: bool - should_update_annotations: bool - should_refresh_images: bool - - -@dataclass(frozen=True) -class TreeAnnotationInfoModePlan: - """Pure outcome of toggling tree annotation info mode.""" - - enabled: bool - action_text_contains: str - action_checked: bool - label_tree_annotations_enabled: bool - gen_num_tree_annotations_enabled: bool - should_refresh_annotations: bool - - -@dataclass(frozen=True) -class ZDepthAnnotationOptionsPlan: - """Pure outcome of enabling left annotation options for z-depth axes.""" - - should_apply: bool - disabled_updates: tuple[tuple[AnnotationOption, bool], ...] = () - state: AnnotationOptionState | None = None - clicked_option: AnnotationOption | None = None - save_settings: bool = False - - -@dataclass(frozen=True) -class Visible3DSegmentationWidgetsPlan: - """Pure outcome of updating 3D-only annotation option widgets.""" - - visible_updates: tuple[tuple[AnnotationSide, AnnotationOption, bool], ...] - checked_updates: tuple[tuple[AnnotationSide, AnnotationOption, bool], ...] - - -@dataclass(frozen=True) -class ZNeighborHighlightCheckboxPlan: - """Pure outcome of updating z-neighbor highlight checkbox state.""" - - should_apply: bool - visible: bool = False - checked: bool = False - should_connect_toggle: bool = False - - -class AnnotationDisplayModel: - """Headless annotation display decisions.""" - - def right_annotation_mode( - self, - *, - show_right_image: bool, - use_right_specific_mode: bool, - right_mode: str, - left_mode: str, - ) -> str: - if not show_right_image: - return 'nothing' - return right_mode if use_right_specific_mode else left_mode - - def text_annotation_flags( - self, - *, - annot_cca_checked: bool, - annot_ids_checked: bool, - mode: str, - ) -> tuple[bool, bool]: - is_lineage_mode = mode == 'Normal division: Lineage tree' - is_cca = annot_cca_checked and not is_lineage_mode - is_id = annot_ids_checked or (annot_cca_checked and is_lineage_mode) - return is_cca, is_id - - def annotation_mode_text( - self, - *, - ids: bool = False, - cca: bool = False, - contours: bool = False, - segm_masks: bool = False, - mother_bud_lines: bool = False, - nothing: bool = False, - ) -> str: - if ids: - if contours: - return 'Draw IDs and contours' - if segm_masks: - return 'Draw IDs and overlay segm. masks' - return 'Draw only IDs' - if cca: - if contours: - return 'Draw cell cycle info and contours' - if segm_masks: - return 'Draw cell cycle info and overlay segm. masks' - return 'Draw only cell cycle info' - if segm_masks: - return 'Draw only overlay segm. masks' - if contours: - return 'Draw only contours' - if mother_bud_lines: - return 'Draw only mother-bud lines' - if nothing: - return 'Draw nothing' - return 'Draw nothing' - - def annotation_flags_from_mode_text(self, text: str) -> dict[str, bool]: - return { - 'ids': 'IDs' in text, - 'cca': 'cell cycle info' in text, - 'contours': 'contours' in text, - 'segm_masks': 'segm. masks' in text, - 'mother_bud_lines': 'mother-bud lines' in text, - 'nothing': 'nothing' in text, - } - - def annotation_option_state_from_mode_text( - self, - text: str, - *, - num_zslices: bool = False, - ) -> AnnotationOptionState: - flags = self.annotation_flags_from_mode_text(text) - return AnnotationOptionState( - ids=flags['ids'], - cca=flags['cca'], - contours=flags['contours'], - segm_masks=flags['segm_masks'], - mother_bud_lines=flags['mother_bud_lines'], - num_zslices=num_zslices, - nothing=flags['nothing'], - ) - - def annotation_options_from_mode_text_plan( - self, - *, - left_text: str, - right_text: str, - left_num_zslices: bool = False, - right_num_zslices: bool = False, - ) -> AnnotationOptionsFromModeTextPlan: - return AnnotationOptionsFromModeTextPlan( - state_updates=( - ( - 'left', - self.annotation_option_state_from_mode_text( - left_text, - num_zslices=left_num_zslices, - ), - ), - ( - 'right', - self.annotation_option_state_from_mode_text( - right_text, - num_zslices=right_num_zslices, - ), - ), - ) - ) - - def restore_saved_settings_plan( - self, - settings_values: Mapping[str, object], - ) -> AnnotationDisplaySettingsRestorePlan: - return AnnotationDisplaySettingsRestorePlan( - left_mode=str( - settings_values.get( - 'how_draw_annotations', - 'Draw IDs and contours', - ) - ), - right_mode=str( - settings_values.get( - 'how_draw_right_annotations', - 'Draw IDs and overlay segm. masks', - ) - ), - add_new_ids_whitelist_toggle=( - settings_values.get('addNewIDsWhitelistToggle', 'Yes') == 'Yes' - ), - ) - - def contours_requested( - self, - *, - ax: int, - left_contours: bool, - right_image_visible: bool, - right_specific_mode: bool, - right_contours: bool, - ) -> bool: - if ax == 0: - return left_contours - if not right_image_visible: - return False - if right_specific_mode: - return right_contours - return left_contours - - def moth_bud_lines_requested( - self, - *, - ax: int, - left_cca: bool, - left_mother_bud_lines: bool, - right_image_visible: bool, - right_specific_mode: bool, - right_cca: bool, - right_mother_bud_lines: bool, - ) -> bool: - if ax == 0: - return left_cca or left_mother_bud_lines - if not right_image_visible: - return False - if right_specific_mode: - return right_cca or right_mother_bud_lines - return left_cca or left_mother_bud_lines - - def should_draw_moth_bud_line( - self, - *, - cca_df_available: bool, - mode: str, - object_visible: bool, - cell_cycle_stage: str, - relationship: str, - ) -> bool: - return ( - cca_df_available - and mode != 'Normal division: Lineage Tree' - and object_visible - and cell_cycle_stage != 'G1' - and relationship == 'bud' - ) - - def should_draw_lineage_tree_lines( - self, - *, - lineage_tree_available: bool, - frames_count: int, - ) -> bool: - return lineage_tree_available and frames_count >= 2 - - def annotation_mode_setting_update( - self, - side: AnnotationSide, - how: str, - ) -> tuple[str, str]: - setting = ( - 'how_draw_right_annotations' - if side == 'right' - else 'how_draw_annotations' - ) - return setting, how - - def annotation_mode_change_plan( - self, - *, - side: AnnotationSide, - how: str, - save_settings: bool, - annot_cca_checked: bool, - annot_ids_checked: bool, - mode: str, - is_data_loading: bool, - eraser_checked: bool = False, - ) -> AnnotationModeChangePlan: - setting_update = None - if save_settings: - setting_update = self.annotation_mode_setting_update(side, how) - - is_cca, is_id = self.text_annotation_flags( - annot_cca_checked=annot_cca_checked, - annot_ids_checked=annot_ids_checked, - mode=mode, - ) - return AnnotationModeChangePlan( - side=side, - setting_update=setting_update, - text_annotation_index=1 if side == 'right' else 0, - is_cca_annotation=is_cca, - is_id_annotation=is_id, - should_refresh_images=not is_data_loading, - should_reset_eraser_temp=side == 'left' and eraser_checked, - ) - - def annotation_option_change_plan( - self, - *, - side: AnnotationSide, - state: AnnotationOptionState, - clicked_option: AnnotationOption | None, - save_settings: bool, - ) -> AnnotationOptionChangePlan: - values = { - 'ids': state.ids, - 'cca': state.cca, - 'contours': state.contours, - 'segm_masks': state.segm_masks, - 'mother_bud_lines': state.mother_bud_lines, - 'num_zslices': state.num_zslices, - 'nothing': state.nothing, - } - - if values['ids'] and clicked_option == 'ids': - values['cca'] = False - values['mother_bud_lines'] = False - - if values['cca'] and clicked_option == 'cca': - values['ids'] = False - values['mother_bud_lines'] = False - - if ( - values['mother_bud_lines'] - and clicked_option == 'mother_bud_lines' - ): - values['ids'] = False - values['cca'] = False - - if values['contours'] and clicked_option == 'contours': - values['segm_masks'] = False - - if values['segm_masks'] and clicked_option == 'segm_masks': - values['contours'] = False - - if clicked_option == 'nothing': - values['ids'] = False - values['cca'] = False - values['contours'] = False - values['segm_masks'] = False - values['mother_bud_lines'] = False - values['num_zslices'] = False - else: - values['nothing'] = False - - if clicked_option == 'num_zslices': - values['ids'] = True - values['nothing'] = False - - new_state = AnnotationOptionState(**values) - return AnnotationOptionChangePlan( - side=side, - state=new_state, - mode_text=self.annotation_mode_text( - ids=new_state.ids, - cca=new_state.cca, - contours=new_state.contours, - segm_masks=new_state.segm_masks, - mother_bud_lines=new_state.mother_bud_lines, - nothing=new_state.nothing, - ), - save_settings=save_settings, - ) - - def pixel_mode_setting_value(self, checked: bool) -> int: - return int(checked) - - def pixel_mode_change_plan( - self, - *, - checked: bool, - is_data_loaded: bool, - high_resolution: bool, - ) -> PixelModeChangePlan: - return PixelModeChangePlan( - setting_update=('pxMode', self.pixel_mode_setting_value(checked)), - should_update_text_pixel_mode=is_data_loaded and high_resolution, - should_refresh_images=is_data_loaded, - ) - - def text_resolution_change_plan( - self, - *, - high_resolution: bool, - is_data_loaded: bool, - ) -> TextResolutionChangePlan: - mode = 'high' if high_resolution else 'low' - return TextResolutionChangePlan( - mode=mode, - log_message=f'Switching to {mode} for the text annnotations...', - pixel_mode_disabled=not high_resolution, - should_update_annotations=is_data_loaded, - should_refresh_images=is_data_loaded, - ) - - def tree_annotation_info_mode_plan( - self, - checked: bool, - ) -> TreeAnnotationInfoModePlan: - return TreeAnnotationInfoModePlan( - enabled=checked, - action_text_contains='tree', - action_checked=checked, - label_tree_annotations_enabled=checked, - gen_num_tree_annotations_enabled=checked, - should_refresh_annotations=True, - ) - - def z_depth_annotation_options_plan( - self, - *, - is_3d: bool, - state: AnnotationOptionState, - ) -> ZDepthAnnotationOptionsPlan: - if not is_3d: - return ZDepthAnnotationOptionsPlan(should_apply=False) - - return ZDepthAnnotationOptionsPlan( - should_apply=True, - disabled_updates=(('ids', False), ('contours', False)), - state=AnnotationOptionState( - ids=True, - cca=state.cca, - contours=True, - segm_masks=state.segm_masks, - mother_bud_lines=state.mother_bud_lines, - num_zslices=state.num_zslices, - nothing=state.nothing, - ), - clicked_option='ids', - save_settings=False, - ) - - def visible_3d_segmentation_widgets_plan( - self, - *, - is_3d: bool, - ) -> Visible3DSegmentationWidgetsPlan: - visible_updates = ( - ('left', 'num_zslices', is_3d), - ('right', 'num_zslices', is_3d), - ) - checked_updates = () - if not is_3d: - checked_updates = ( - ('left', 'num_zslices', False), - ('right', 'num_zslices', False), - ) - return Visible3DSegmentationWidgetsPlan( - visible_updates=visible_updates, - checked_updates=checked_updates, - ) - - def z_neighbor_highlight_checkbox_plan( - self, - *, - is_3d: bool, - ) -> ZNeighborHighlightCheckboxPlan: - if not is_3d: - return ZNeighborHighlightCheckboxPlan(should_apply=False) - return ZNeighborHighlightCheckboxPlan( - should_apply=True, - visible=True, - checked=True, - should_connect_toggle=True, - ) diff --git a/cellacdc/models/app_shell_model.py b/cellacdc/models/app_shell_model.py deleted file mode 100644 index c842b3cf4..000000000 --- a/cellacdc/models/app_shell_model.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Scriptable model services for the application shell.""" - -from __future__ import annotations - -from cellacdc import myutils - - -def get_tooltips_from_docs(): - from cellacdc.load.selection_omexml import get_tooltips_from_docs as func - - return func() - - -def rename_qrc_resources_file(color_scheme: str): - from cellacdc.load.selection_omexml import ( - rename_qrc_resources_file as func, - ) - - return func(color_scheme) - - -class AppShellModel: - """Headless application shell service wrappers.""" - - def read_version(self) -> str: - return myutils.read_version() - - def tooltips_from_docs(self) -> dict: - return get_tooltips_from_docs() - - def browse_docs(self): - return myutils.browse_docs() - - def show_in_file_manager(self, path: str): - return myutils.showInExplorer(path) - - def rename_qrc_resources_file(self, color_scheme: str): - return rename_qrc_resources_file(color_scheme) diff --git a/cellacdc/models/brush_tools_model.py b/cellacdc/models/brush_tools_model.py deleted file mode 100644 index b9f1ccf95..000000000 --- a/cellacdc/models/brush_tools_model.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Scriptable model rules for brush and eraser tools.""" - -from __future__ import annotations - -from typing import Any - -import skimage.morphology - - -class BrushToolsModel: - """Headless decisions and geometry for brush/eraser tools.""" - - yes_value = 'Yes' - no_value = 'No' - - def checked_setting_value(self, checked: bool) -> str: - return self.yes_value if checked else self.no_value - - def default_delete_object_info_value(self) -> str: - return self.yes_value - - def should_show_delete_object_info(self, setting_value: Any) -> bool: - return setting_value == self.yes_value - - def delete_object_info_value( - self, - do_not_show_again_checked: bool, - ) -> str: - return ( - self.no_value - if do_not_show_again_checked - else self.yes_value - ) - - def should_fill_holes( - self, - sender: str, - *, - auto_fill_checked: bool, - ) -> bool: - return sender == 'brush' and auto_fill_checked - - def brush_toolbar_visible( - self, - edit_id_visible: bool, - *, - brush_size_visible: bool, - auto_fill_visible: bool, - auto_hide_visible: bool, - ) -> bool: - return any( - ( - edit_id_visible, - brush_size_visible, - auto_fill_visible, - auto_hide_visible, - ) - ) - - def disk_mask(self, brush_size: int): - return skimage.morphology.disk(brush_size, dtype=bool) - - def disk_mask_bounds( - self, - image_shape: tuple[int, int], - brush_size: int, - xdata: int, - ydata: int, - disk_mask, - ): - y_size, x_size = image_shape - y_bottom, x_left = ydata - brush_size, xdata - brush_size - y_top, x_right = ydata + brush_size + 1, xdata + brush_size + 1 - - if x_left < 0: - if y_bottom < 0: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[-y_bottom:, -x_left:] - y_bottom = 0 - elif y_top > y_size: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[0:y_size - y_bottom, -x_left:] - y_top = y_size - else: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[:, -x_left:] - x_left = 0 - - elif x_right > x_size: - if y_bottom < 0: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[-y_bottom:, 0:x_size - x_left] - y_bottom = 0 - elif y_top > y_size: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[0:y_size - y_bottom, 0:x_size - x_left] - y_top = y_size - else: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[:, 0:x_size - x_left] - x_right = x_size - - elif y_bottom < 0: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[-y_bottom:] - y_bottom = 0 - - elif y_top > y_size: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[0:y_size - y_bottom] - y_top = y_size - - return y_bottom, x_left, y_top, x_right, disk_mask - - def magic_wand_flood_tolerance( - self, - tolerance_percent: float, - image_min: float, - image_max: float, - ): - if tolerance_percent == 0: - return None - return (image_max - image_min) * (tolerance_percent / 100) diff --git a/cellacdc/models/canvas_context_menu_model.py b/cellacdc/models/canvas_context_menu_model.py deleted file mode 100644 index 3998945e1..000000000 --- a/cellacdc/models/canvas_context_menu_model.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Scriptable model rules for canvas context menus.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class DeletedRoiClickDecision: - """Decision for clicks on deleted-ROI overlays.""" - - handled: bool - show_context_menu: bool = False - drag_roi: bool = False - - -class CanvasContextMenuModel: - """Headless canvas context-menu decision rules.""" - - scale_bar_target = 'scale_bar' - timestamp_target = 'timestamp' - gradient_target = 'gradient' - - def image_gradient_menu_target( - self, - *, - scale_bar_highlighted: bool, - timestamp_highlighted: bool, - ) -> str: - if scale_bar_highlighted: - return self.scale_bar_target - if timestamp_highlighted: - return self.timestamp_target - return self.gradient_target - - def deleted_roi_click_decision( - self, - *, - clicked_on_roi: bool, - left_click: bool, - right_click: bool, - ) -> DeletedRoiClickDecision: - if not clicked_on_roi: - return DeletedRoiClickDecision(handled=False) - if right_click: - return DeletedRoiClickDecision( - handled=True, - show_context_menu=True, - ) - if left_click: - return DeletedRoiClickDecision(handled=True, drag_roi=True) - return DeletedRoiClickDecision(handled=False) diff --git a/cellacdc/models/canvas_drawing_model.py b/cellacdc/models/canvas_drawing_model.py deleted file mode 100644 index 772dd604e..000000000 --- a/cellacdc/models/canvas_drawing_model.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Scriptable model rules for canvas drawing interactions.""" - -from __future__ import annotations - -import numpy as np - - -class CanvasDrawingModel: - """Headless decisions for canvas drawing workflows.""" - - viewer_mode = 'Viewer' - - def should_process_canvas_event( - self, - *, - mode: str, - in_bounds: bool, - ) -> bool: - return mode != self.viewer_mode and in_bounds - - def should_clear_after_out_of_bounds(self, *, image: str) -> bool: - return image == 'img1' - - def calculate_brush_mask( - self, - image_shape: tuple[int, int], - ymin: int, - xmin: int, - ymax: int, - xmax: int, - disk_mask: np.ndarray, - rr_poly: np.ndarray | None = None, - cc_poly: np.ndarray | None = None, - ) -> np.ndarray: - """Computes a 2D boolean mask for brush/eraser updates.""" - mask = np.zeros(image_shape, dtype=bool) - disk_slice = (slice(ymin, ymax), slice(xmin, xmax)) - mask[disk_slice][disk_mask] = True - if rr_poly is not None and cc_poly is not None: - mask[rr_poly, cc_poly] = True - return mask - diff --git a/cellacdc/models/canvas_events_model.py b/cellacdc/models/canvas_events_model.py deleted file mode 100644 index aa93d0546..000000000 --- a/cellacdc/models/canvas_events_model.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Qt-free model rules for canvas event routing.""" - -from __future__ import annotations - -import numpy as np - - -class CanvasEventsModel: - """Headless canvas event routing rules and brush mask computations.""" - - def calculate_brush_mask( - self, - image_shape: tuple[int, int], - ymin: int, - xmin: int, - ymax: int, - xmax: int, - disk_mask: np.ndarray, - rr_poly: np.ndarray | None = None, - cc_poly: np.ndarray | None = None, - ) -> np.ndarray: - """Computes a 2D boolean mask for brush/eraser updates.""" - mask = np.zeros(image_shape, dtype=bool) - disk_slice = (slice(ymin, ymax), slice(xmin, xmax)) - mask[disk_slice][disk_mask] = True - if rr_poly is not None and cc_poly is not None: - mask[rr_poly, cc_poly] = True - return mask - - def map_mouse_coordinates_to_label_id( - self, - mouse_pos: tuple[float, float], - label_matrix: np.ndarray, - ) -> int: - """Resolves float pixel coordinate lookup to integer label ID.""" - x, y = mouse_pos - xdata, ydata = int(x), int(y) - height, width = label_matrix.shape - if 0 <= xdata < width and 0 <= ydata < height: - return int(label_matrix[ydata, xdata]) - return 0 diff --git a/cellacdc/models/canvas_hover_model.py b/cellacdc/models/canvas_hover_model.py deleted file mode 100644 index 01558ddbb..000000000 --- a/cellacdc/models/canvas_hover_model.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Scriptable model rules for canvas hover interactions.""" - -from __future__ import annotations - -from typing import Any - - -class CanvasHoverModel: - """Headless decisions for hover and cursor state.""" - - def point_in_bounds( - self, - image_shape: tuple[int, int], - xdata: int, - ydata: int, - ) -> bool: - y_size, x_size = image_shape - return 0 <= xdata < x_size and 0 <= ydata < y_size - - def hover_position(self, is_exit: bool, position) -> tuple[Any, Any]: - if is_exit: - return None, None - return position - - def should_set_mirrored_cursor( - self, - *, - override_cursor_is_none: bool, - is_exit: bool, - mirrored_cursor_enabled: bool, - is_hover_img1: bool = True, - ) -> bool: - return ( - override_cursor_is_none - and not is_exit - and is_hover_img1 - and mirrored_cursor_enabled - ) - - def should_draw_ruler_line( - self, - *, - ruler_checked: bool, - add_deleted_polyline_checked: bool, - temp_segment_on: bool, - is_exit: bool, - ) -> bool: - return ( - (ruler_checked or add_deleted_polyline_checked) - and temp_segment_on - and not is_exit - ) - - def cursor_flags( - self, - *, - is_exit: bool, - no_modifier: bool, - shift: bool, - ctrl: bool, - alt: bool, - brush_checked: bool, - eraser_checked: bool, - add_deleted_polyline_checked: bool, - label_roi_checked: bool, - label_roi_circular_checked: bool, - wand_checked: bool, - move_label_checked: bool, - expand_label_checked: bool, - curvature_checked: bool, - keep_ids_checked: bool, - custom_annotation_available: bool, - manual_tracking_checked: bool, - manual_background_checked: bool, - zoom_rect_checked: bool, - edit_id_checked: bool, - magic_prompts_checked: bool, - points_layer_checked: bool, - add_points_by_clicking_active: bool, - ) -> dict[str, bool]: - return { - 'setBrushCursor': ( - brush_checked and not is_exit and (no_modifier or shift or ctrl) - ), - 'setEraserCursor': eraser_checked and not is_exit and no_modifier, - 'setAddDelPolyLineCursor': ( - add_deleted_polyline_checked and not is_exit and no_modifier - ), - 'setLabelRoiCircCursor': ( - label_roi_checked - and not is_exit - and (no_modifier or shift or ctrl) - and label_roi_circular_checked - ), - 'setWandCursor': wand_checked and not is_exit and no_modifier, - 'setLabelRoiCursor': label_roi_checked and not is_exit and no_modifier, - 'setMoveLabelCursor': move_label_checked and not is_exit and no_modifier, - 'setExpandLabelCursor': ( - expand_label_checked and not is_exit and no_modifier - ), - 'setCurvCursor': curvature_checked and not is_exit and no_modifier, - 'setKeepObjCursor': keep_ids_checked and not is_exit and no_modifier, - 'setCustomAnnotCursor': ( - custom_annotation_available and not is_exit and no_modifier - ), - 'setManualTrackingCursor': ( - manual_tracking_checked and not is_exit and no_modifier - ), - 'setManualBackgroundCursor': ( - manual_background_checked and not is_exit and no_modifier - ), - 'setAddPointCursor': ( - (points_layer_checked or magic_prompts_checked) - and add_points_by_clicking_active - and not is_exit - and no_modifier - ), - 'setZoomRectCursor': zoom_rect_checked and not is_exit and no_modifier, - 'setEditIDCursor': edit_id_checked and not is_exit, - 'setPanImageCursor': alt and not is_exit, - } diff --git a/cellacdc/models/canvas_right_image_model.py b/cellacdc/models/canvas_right_image_model.py deleted file mode 100644 index aa9fc9bbc..000000000 --- a/cellacdc/models/canvas_right_image_model.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Scriptable model rules for duplicated right-image interactions.""" - -from __future__ import annotations - - -class CanvasRightImageModel: - """Headless duplicated right-image event rules.""" - - def should_show_context_menu( - self, - *, - right_click: bool, - is_right_click_action_on: bool, - ) -> bool: - return right_click and not is_right_click_action_on diff --git a/cellacdc/models/canvas_selection_model.py b/cellacdc/models/canvas_selection_model.py deleted file mode 100644 index 6750d2300..000000000 --- a/cellacdc/models/canvas_selection_model.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Qt-free model rules for canvas selection interactions.""" - -from __future__ import annotations - - -class CanvasSelectionModel: - """Headless decisions for canvas selection workflows.""" - - viewer_mode = 'Viewer' - segmentation_mode = 'Segmentation and Tracking' - - def should_drag_image( - self, - *, - left_click: bool, - eraser_on: bool, - brush_on: bool, - middle_click: bool, - pan_click: bool, - ) -> bool: - return pan_click or ( - left_click and not eraser_on and not brush_on and not middle_click - ) - - def should_blink_viewer_mode( - self, - *, - mode: str, - middle_click: bool, - right_action_on: bool = False, - custom_action_on: bool = False, - right_click: bool = False, - ) -> bool: - if mode != self.viewer_mode: - return False - if middle_click: - return True - return (right_action_on or custom_action_on) and ( - right_click or middle_click - ) - - def should_show_labels_menu( - self, - *, - right_click: bool, - right_action_on: bool, - middle_click: bool, - event_from_img1: bool, - ) -> bool: - return ( - right_click - and not right_action_on - and not middle_click - and not event_from_img1 - ) - - def can_delete(self, *, mode: str, is_snapshot: bool) -> bool: - return mode == self.segmentation_mode or is_snapshot - - def is_viewer_mode(self, mode: str) -> bool: - return mode == self.viewer_mode - - def should_process_release( - self, - *, - mode: str, - in_bounds: bool, - ) -> bool: - return mode != self.viewer_mode and in_bounds diff --git a/cellacdc/models/canvas_tool_model.py b/cellacdc/models/canvas_tool_model.py deleted file mode 100644 index 51723da4c..000000000 --- a/cellacdc/models/canvas_tool_model.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Scriptable model rules for canvas tool interaction decisions.""" - -from __future__ import annotations - - -class CanvasToolModel: - """Headless canvas tool decision rules.""" - - manual_separate_draw_mode_key = 'manual_separate_draw_mode' - - def viewer_mode_allows_press( - self, - mode: str, - *, - can_add_point: bool = False, - can_ruler: bool = False, - ) -> bool: - return mode != 'Viewer' or can_add_point or can_ruler - - def should_forward_img1_press_to_img2( - self, - *, - right_click: bool, - middle_click: bool, - can_add_point: bool, - mode: str, - is_snapshot: bool, - is_annotate_division: bool, - manual_background_on: bool, - ) -> bool: - return ( - (right_click or (middle_click and not can_add_point)) - and (mode == 'Segmentation and Tracking' or is_snapshot) - and not is_annotate_division - and not manual_background_on - ) - - def should_forward_img1_release_to_img2( - self, - *, - right_click: bool, - mode: str, - is_snapshot: bool, - ) -> bool: - return ( - (mode == 'Segmentation and Tracking' or is_snapshot) - and right_click - ) - - def manual_separate_draw_mode_update(self, mode) -> tuple[str, object]: - return self.manual_separate_draw_mode_key, mode diff --git a/cellacdc/models/cell_cycle_model.py b/cellacdc/models/cell_cycle_model.py deleted file mode 100644 index e5e9095cb..000000000 --- a/cellacdc/models/cell_cycle_model.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Qt-free model rules for cell-cycle GUI workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass -import pandas as pd - - - -@dataclass(frozen=True) -class AnnotatedEditWarningPlan: - """Decision for editing a frame with existing annotations.""" - - proceed_without_warning: bool - update_images: bool = False - should_prompt: bool = False - warn_type: str | None = None - - -class CellCycleModel: - """Headless cell-cycle workflow rules.""" - - def annotated_edit_warning_plan( - self, - *, - is_snapshot: bool, - acdc_df_missing: bool, - lineage_tree_missing: bool, - cell_cycle_stage_present: bool, - lineage_tree_present: bool, - remembered_skip_warning: bool, - ) -> AnnotatedEditWarningPlan: - if is_snapshot: - return AnnotatedEditWarningPlan(proceed_without_warning=True) - - no_annotation_source = acdc_df_missing and lineage_tree_missing - no_annotations = not cell_cycle_stage_present and not lineage_tree_present - if no_annotation_source or no_annotations or remembered_skip_warning: - return AnnotatedEditWarningPlan( - proceed_without_warning=True, - update_images=True, - ) - - warn_type = ( - 'cell cycle annotations' - if cell_cycle_stage_present - else 'lineage tree annotations' - ) - return AnnotatedEditWarningPlan( - proceed_without_warning=False, - should_prompt=True, - warn_type=warn_type, - ) - - def check_mothers_exclusion_or_dead( - self, - acdc_df: pd.DataFrame, - mother_ids: list[int], - ) -> list[int]: - """Checks tracking rules for cell exclusions or deaths.""" - if acdc_df is None or not mother_ids: - return [] - - valid_ids = [m_id for m_id in mother_ids if m_id in acdc_df.index] - if not valid_ids: - return [] - - mothers_df = acdc_df.loc[valid_ids] - excluded_mask = ( - (mothers_df.get('is_cell_dead', 0) > 0) - | (mothers_df.get('is_cell_excluded', 0) > 0) - ) - return mothers_df[excluded_mask].index.tolist() - - def evaluate_sister_relations( - self, - prev_cca_df: pd.DataFrame, - current_ids: set[int], - ) -> list[int]: - """Determines S-phase mother-bud dependencies and sister relation tracking rules.""" - if prev_cca_df is None or not current_ids: - return [] - - current_ids_set = set(current_ids) - disappeared_ids = [] - for cc_series in prev_cca_df.itertuples(): - if getattr(cc_series, 'cell_cycle_stage', None) != 'S': - continue - - cell_id = cc_series.Index - relative_id = getattr(cc_series, 'relative_ID', -1) - if relative_id == -1: - continue - if relative_id not in current_ids_set and cell_id in current_ids_set: - disappeared_ids.append(relative_id) - - return disappeared_ids - diff --git a/cellacdc/models/combine_model.py b/cellacdc/models/combine_model.py deleted file mode 100644 index 2bde98927..000000000 --- a/cellacdc/models/combine_model.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Headless rules and helpers for the Combine Channels feature.""" - -from __future__ import annotations - -from dataclasses import dataclass -import numpy as np - - -@dataclass(frozen=True) -class CombineModel: - """Headless state and helpers for combining channel and image arrays.""" - - def initialize_combine_image_data(self, pos_data) -> np.ndarray: - """Initializes pos_data.combine_img_data if not already present.""" - if not hasattr(pos_data, 'combine_img_data'): - from cellacdc import preprocess - pos_data.combine_img_data = preprocess.PreprocessedData( - image_data=np.zeros(pos_data.img_data.shape) - ) - return pos_data.combine_img_data - - def validate_dimensions(self, ndim: int) -> bool: - """Asserts that image data dimensions are valid for combining (3D or 4D).""" - if ndim not in (3, 4): - raise ValueError('Invalid number of dimensions in img_data.') - return True - - def group_processed_data_by_pos( - self, - processed_data: list[np.ndarray], - keys: list[tuple[int, int, int]] - ) -> dict[int, list[tuple[tuple[int, int, int], np.ndarray]]]: - """Groups raw processed preview output arrays by position index.""" - unique_pos = {key[0] for key in keys} - per_pos_data = {pos_i: [] for pos_i in unique_pos} - for key, img in zip(keys, processed_data): - pos_i, frame_i, z_slice = key - per_pos_data[pos_i].append((key, img)) - return per_pos_data - - def update_combine_image_data( - self, - pos_data, - pos_i_data: list[tuple[tuple[int, int, int], np.ndarray]] - ): - """Updates preprocessed combined image data container frames and z-slices.""" - n_dim_img = pos_data.img_data.ndim - self.initialize_combine_image_data(pos_data) - self.validate_dimensions(n_dim_img) - - if n_dim_img == 4: - for key, img in pos_i_data: - _, frame_i, z_slice = key - pos_data.combine_img_data[frame_i][z_slice] = img - elif n_dim_img == 3: - for key, img in pos_i_data: - _, frame_i, _ = key - pos_data.combine_img_data[frame_i] = img diff --git a/cellacdc/models/curvature_model.py b/cellacdc/models/curvature_model.py deleted file mode 100644 index 6597ebd4d..000000000 --- a/cellacdc/models/curvature_model.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Scriptable model rules for curvature and spline editing tools.""" - -from __future__ import annotations - -import numpy as np - -from cellacdc.domain.curvature import ( - CurvatureLabelPaintResult, - closed_spline_coords, - directional_coords, - paint_spline_to_labels, - spline_coords, - tangent_brush_polygon, -) - - -class CurvatureModel: - """Headless spline drawing and label-painting operations.""" - - def tangent_brush_polygon( - self, - yx_start, - yx_end, - radius: int | float, - shape: tuple[int, int], - ) -> tuple[np.ndarray, np.ndarray]: - return tangent_brush_polygon(yx_start, yx_end, radius, shape) - - def directional_coords( - self, - alfa_dir: int, - y: int, - x: int, - shape: tuple[int, int], - *, - connectivity: int = 1, - ) -> tuple[list[int], list[int]]: - return directional_coords( - alfa_dir, - y, - x, - shape, - connectivity=connectivity, - ) - - def spline_coords( - self, - xx, - yy, - *, - resolution_space=None, - per: bool = False, - append_first: bool = False, - ): - return spline_coords( - xx, - yy, - resolution_space=resolution_space, - per=per, - append_first=append_first, - ) - - def closed_spline_coords( - self, - xx_spline, - yy_spline, - *, - anchor_xx=None, - anchor_yy=None, - predictor=None, - max_exec_time: int = 150, - ): - return closed_spline_coords( - xx_spline, - yy_spline, - anchor_xx=anchor_xx, - anchor_yy=anchor_yy, - predictor=predictor, - max_exec_time=max_exec_time, - ) - - def paint_spline_to_labels( - self, - labels_2d: np.ndarray, - xx_spline, - yy_spline, - label_id: int, - *, - empty_only: bool = True, - ) -> CurvatureLabelPaintResult: - return paint_spline_to_labels( - labels_2d, - xx_spline, - yy_spline, - label_id, - empty_only=empty_only, - ) diff --git a/cellacdc/models/custom_annotations_model.py b/cellacdc/models/custom_annotations_model.py deleted file mode 100644 index 4baf157b5..000000000 --- a/cellacdc/models/custom_annotations_model.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Scriptable model rules for custom annotations.""" - -from __future__ import annotations - -import os - -import pandas as pd - -from cellacdc import load, myutils -from cellacdc.domain.custom_annotations import ( - CustomAnnotationColumnResult, - CustomAnnotationFrameUpdate, - custom_annotation_column_exists, - drop_custom_annotation_column, - ensure_custom_annotation_column, - remap_custom_annotation_ids, - rename_custom_annotation_column, - update_custom_annotation_frame, -) - - -class CustomAnnotationsModel: - """Headless custom annotation table updates.""" - - def read_saved_annotations( - self, - annotations_path: str, - *, - logger_func=None, - ) -> dict: - if not os.path.exists(annotations_path): - return {} - return load.read_json(annotations_path, logger_func=logger_func) - - def tooltip(self, annotation_state: dict) -> str: - return myutils.getCustomAnnotTooltip(annotation_state) - - def ensure_column( - self, - acdc_df: pd.DataFrame, - annotation_name: str, - ) -> CustomAnnotationColumnResult: - return ensure_custom_annotation_column(acdc_df, annotation_name) - - def column_exists( - self, - frame_records, - annotation_name: str, - *, - summary_acdc_df: pd.DataFrame | None = None, - ) -> bool: - return custom_annotation_column_exists( - frame_records, - annotation_name, - summary_acdc_df=summary_acdc_df, - ) - - def drop_column( - self, - acdc_df: pd.DataFrame | None, - annotation_name: str, - ) -> pd.DataFrame | None: - return drop_custom_annotation_column(acdc_df, annotation_name) - - def rename_column( - self, - acdc_df: pd.DataFrame | None, - old_name: str, - new_name: str, - ) -> pd.DataFrame | None: - return rename_custom_annotation_column(acdc_df, old_name, new_name) - - def remap_ids(self, annotated_ids_by_frame, old_ids, new_ids) -> dict: - return remap_custom_annotation_ids( - annotated_ids_by_frame, - old_ids, - new_ids, - ) - - def update_frame( - self, - acdc_df: pd.DataFrame, - annotation_name: str, - annotated_ids, - *, - clicked_id: int = 0, - click_is_active: bool = False, - existing_ids=None, - ) -> CustomAnnotationFrameUpdate: - return update_custom_annotation_frame( - acdc_df, - annotation_name, - annotated_ids, - clicked_id=clicked_id, - click_is_active=click_is_active, - existing_ids=existing_ids, - ) diff --git a/cellacdc/models/data_loading_model.py b/cellacdc/models/data_loading_model.py deleted file mode 100644 index ca503ef68..000000000 --- a/cellacdc/models/data_loading_model.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Qt-free model rules for data loading workflows.""" - -from __future__ import annotations - -import os -from dataclasses import dataclass -from datetime import datetime - -import cv2 -import numpy as np -import pandas as pd -import skimage -import skimage.color - - -@dataclass(frozen=True) -class ChannelNameSuggestion: - """Default basename/channel split for a user-selected image filename.""" - - basename: str - channel_name: str - - -@dataclass(frozen=True) -class OpenImageFileContext: - """Path context for opening a single image/video file.""" - - file_path: str - filename_no_ext: str - extension: str - source_dirpath: str - source_dirname: str - exp_path: str - acdc_folder: str | None - requires_images_folder: bool - - -@dataclass(frozen=True) -class OpenImageFileTarget: - """Destination paths and metadata names for an opened image/video file.""" - - context: OpenImageFileContext - filename_no_ext: str - channel_name: str | None - basename: str | None - new_filename: str - new_filepath: str - metadata_csv_filename: str | None - metadata_csv_filepath: str | None - tif_filename: str - tif_path: str - direct_copy_supported: bool - - @property - def has_metadata(self) -> bool: - return self.basename is not None - - -@dataclass(frozen=True) -class EmptyDataPlan: - """Path and filename plan for creating an empty dataset.""" - - exp_path: str - pos_path: str - images_path: str - basename: str - tif_filename: str - tif_filepath: str - metadata_filename: str - metadata_filepath: str - - -@dataclass(frozen=True) -class ImageDataPreparation: - """Prepared image data and conversion facts for TIFF writing.""" - - image: np.ndarray - converted_rgb_to_gray: bool - converted_dtype: bool - - -class DataLoadingModel: - """Headless data-loading rules and path plans.""" - - def open_image_file_context( - self, file_path: str, timestamp: str | None = None - ) -> OpenImageFileContext: - filename_no_ext, ext = os.path.splitext(os.path.basename(file_path)) - filename_no_ext = filename_no_ext.rstrip('_') - ext = ext.lower() - dirpath = os.path.dirname(file_path) - dirname = os.path.basename(dirpath) - requires_images_folder = dirname != 'Images' - acdc_folder = None - - if requires_images_folder: - timestamp = timestamp or datetime.now().strftime('%Y%m%d_%H%M%S') - acdc_folder = f'{timestamp}_acdc' - exp_path = os.path.join(dirpath, acdc_folder, 'Images') - else: - exp_path = dirpath - - return OpenImageFileContext( - file_path=file_path, - filename_no_ext=filename_no_ext, - extension=ext, - source_dirpath=dirpath, - source_dirname=dirname, - exp_path=exp_path, - acdc_folder=acdc_folder, - requires_images_folder=requires_images_folder, - ) - - def channel_name_suggestion( - self, filename_no_ext: str - ) -> ChannelNameSuggestion: - underscore_splits = filename_no_ext.split('_') - if len(underscore_splits) > 1: - return ChannelNameSuggestion( - basename='_'.join(underscore_splits[:-1]), - channel_name=underscore_splits[-1], - ) - - return ChannelNameSuggestion( - basename=filename_no_ext, - channel_name='channel_1', - ) - - def open_image_file_target( - self, - context: OpenImageFileContext, - channel_name: str | None = None, - ) -> OpenImageFileTarget: - filename_no_ext = context.filename_no_ext - basename = None - metadata_csv_filename = None - metadata_csv_filepath = None - - if channel_name is not None: - underscore_splits = filename_no_ext.split('_') - if len(underscore_splits) > 1: - default_ch_name = underscore_splits[-1] - if channel_name == default_ch_name: - filename_no_ext = '_'.join(underscore_splits[:-1]) - - basename = f'{filename_no_ext}_' - metadata_csv_filename = f'{basename}metadata.csv' - metadata_csv_filepath = os.path.join( - context.exp_path, metadata_csv_filename - ) - new_filename = ( - f'{filename_no_ext}_{channel_name}{context.extension}' - ) - else: - new_filename = f'{filename_no_ext}{context.extension}' - - new_filepath = os.path.join(context.exp_path, new_filename) - tif_filename_no_ext = os.path.splitext(new_filename)[0] - tif_filename = f'{tif_filename_no_ext}.tif' - tif_path = os.path.join(context.exp_path, tif_filename) - - return OpenImageFileTarget( - context=context, - filename_no_ext=filename_no_ext, - channel_name=channel_name, - basename=basename, - new_filename=new_filename, - new_filepath=new_filepath, - metadata_csv_filename=metadata_csv_filename, - metadata_csv_filepath=metadata_csv_filepath, - tif_filename=tif_filename, - tif_path=tif_path, - direct_copy_supported=context.extension in ('.tif', '.npz'), - ) - - def empty_data_plan(self, exp_path: str) -> EmptyDataPlan: - pos_path = os.path.join(exp_path, 'Position_1') - images_path = os.path.join(pos_path, 'Images') - basename = 'test_empty_' - tif_filename = f'{basename}channel_1.tif' - metadata_filename = f'{basename}metadata.csv' - - return EmptyDataPlan( - exp_path=exp_path, - pos_path=pos_path, - images_path=images_path, - basename=basename, - tif_filename=tif_filename, - tif_filepath=os.path.join(images_path, tif_filename), - metadata_filename=metadata_filename, - metadata_filepath=os.path.join(images_path, metadata_filename), - ) - - def copy_action_text(self, do_copy: bool) -> str: - return 'Copying' if do_copy else 'Moving' - - def is_imagej_dtype(self, dtype: np.dtype) -> bool: - return dtype in (np.uint8, np.uint32, np.float32) - - def prepare_tiff_image_data(self, image: np.ndarray) -> ImageDataPreparation: - converted_rgb_to_gray = False - converted_dtype = False - prepared_image = image - - if ( - prepared_image.ndim == 3 - and (prepared_image.shape[-1] == 3 - or prepared_image.shape[-1] == 4) - ): - converted_rgb_to_gray = True - if prepared_image.shape[-1] == 3: - prepared_image = skimage.color.rgb2gray(prepared_image) - else: - prepared_image = cv2.cvtColor( - prepared_image, cv2.COLOR_RGBA2GRAY - ) - prepared_image = skimage.img_as_ubyte(prepared_image) - - if not self.is_imagej_dtype(prepared_image.dtype): - converted_dtype = True - prepared_image = skimage.img_as_ubyte(prepared_image) - - return ImageDataPreparation( - image=prepared_image, - converted_rgb_to_gray=converted_rgb_to_gray, - converted_dtype=converted_dtype, - ) - - def merge_default_segm_info( - self, - existing_df: pd.DataFrame, - default_df: pd.DataFrame, - ) -> pd.DataFrame: - merged_df = pd.concat([default_df, existing_df]) - unique_idx = ~merged_df.index.duplicated() - return merged_df[unique_idx] - - def copy_single_zslice_segm_info( - self, - existing_df: pd.DataFrame, - default_dst_df: pd.DataFrame, - *, - src_filename: str, - dst_filename: str, - ) -> pd.DataFrame: - dst_df = default_dst_df.copy() - src_df = existing_df.loc[src_filename].copy() - - for z_info in src_df.itertuples(): - frame_i = z_info.Index - if z_info.which_z_proj != 'single z-slice': - continue - - src_idx = (src_filename, frame_i) - if existing_df.at[src_idx, 'resegmented_in_gui']: - col = 'z_slice_used_gui' - else: - col = 'z_slice_used_dataPrep' - - z_slice = existing_df.at[src_idx, col] - dst_idx = (dst_filename, frame_i) - dst_df.at[dst_idx, 'z_slice_used_dataPrep'] = z_slice - dst_df.at[dst_idx, 'z_slice_used_gui'] = z_slice - - return self.merge_default_segm_info(existing_df, dst_df) diff --git a/cellacdc/models/deleted_rois_model.py b/cellacdc/models/deleted_rois_model.py deleted file mode 100644 index 6efa147d7..000000000 --- a/cellacdc/models/deleted_rois_model.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Scriptable model rules for deleted ROI workflows.""" - -from __future__ import annotations - -from collections.abc import Iterable - - -class DeletedRoisModel: - """Headless decisions for deleted-ROI display and propagation.""" - - def roi_axis( - self, - *, - is_polyline: bool, - labels_image_visible: bool, - ) -> str: - if is_polyline or not labels_image_visible: - return 'left' - return 'right' - - def should_render_deleted_roi(self, annotation_mode: str) -> bool: - return 'nothing' not in annotation_mode - - def should_render_deleted_roi_contours(self, annotation_mode: str) -> bool: - return 'contours' in annotation_mode - - def should_render_deleted_roi_overlay(self, annotation_mode: str) -> bool: - return 'overlay segm. masks' in annotation_mode - - def should_initialize_overlay_masks( - self, - init: bool, - annotation_mode: str, - ) -> bool: - return init and not self.should_render_deleted_roi_contours( - annotation_mode - ) - - def labels_to_skip(self, deleted_ids: Iterable[int]) -> dict[int, bool]: - return {deleted_id: True for deleted_id in deleted_ids} diff --git a/cellacdc/models/display_decorations_model.py b/cellacdc/models/display_decorations_model.py deleted file mode 100644 index 8ad40e195..000000000 --- a/cellacdc/models/display_decorations_model.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Scriptable model rules for display decorations.""" - -from __future__ import annotations - - -class DisplayDecorationsModel: - """Headless display-decoration decision rules.""" - - def clamped_view_range(self, image_shape, view_range): - y_size, x_size = image_shape[:2] - x_range, y_range = view_range - x_min = 0 if x_range[0] < 0 else x_range[0] - y_min = 0 if y_range[0] < 0 else y_range[0] - x_max = x_size if x_range[1] >= x_size else x_range[1] - y_max = y_size if y_range[1] >= y_size else y_range[1] - return int(y_min), int(y_max), int(x_min), int(x_max) - - def integer_view_range(self, view_range): - x_range, y_range = view_range - return ( - [round(x_range[0]), round(x_range[1])], - [round(y_range[0]), round(y_range[1])], - ) - - def should_move_decoration( - self, - *, - dialog_open: bool, - move_with_zoom: bool, - ) -> bool: - return dialog_open or move_with_zoom - - def should_store_view_range( - self, - *, - has_range_reset_state: bool, - is_range_reset: bool = False, - ) -> bool: - return has_range_reset_state and is_range_reset - - def should_update_timestamp_frame( - self, - *, - has_timestamp: bool, - timestamp_enabled: bool, - ) -> bool: - return has_timestamp and timestamp_enabled diff --git a/cellacdc/models/draw_clear_region_model.py b/cellacdc/models/draw_clear_region_model.py deleted file mode 100644 index c36b1f7b7..000000000 --- a/cellacdc/models/draw_clear_region_model.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Scriptable model rules for draw-clear-region workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class DrawClearRegionToolbarState: - """Desired z-slice toolbar state for the draw-clear tool.""" - - update_z_control: bool - z_control_enabled: bool = False - size_z: int | None = None - - -class DrawClearRegionModel: - """Headless draw-clear-region decision rules.""" - - single_z_slice_projection = 'single z-slice' - - def toolbar_state( - self, - *, - checked: bool, - is_segm_3d: bool, - size_z: int, - ) -> DrawClearRegionToolbarState: - if not is_segm_3d: - return DrawClearRegionToolbarState(update_z_control=True) - if not checked: - return DrawClearRegionToolbarState(update_z_control=False) - return DrawClearRegionToolbarState( - update_z_control=True, - z_control_enabled=True, - size_z=size_z, - ) - - def z_range_for_projection( - self, - *, - is_segm_3d: bool, - z_projection: str, - size_z: int, - single_z_range, - ): - if not is_segm_3d: - return None - if z_projection == self.single_z_slice_projection: - return single_z_range - return (0, size_z) - - def is_single_z_projection(self, z_projection: str) -> bool: - return z_projection == self.single_z_slice_projection - - def empty_selection_warning(self, *, enclosed_only: bool) -> str: - if enclosed_only: - return ( - 'None of the objects in the freehand region are fully enclosed' - ) - return 'None of the objects are touching the freehand region' diff --git a/cellacdc/models/exporting_model.py b/cellacdc/models/exporting_model.py deleted file mode 100644 index 93cfcebe9..000000000 --- a/cellacdc/models/exporting_model.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Scriptable model rules for image and video export workflows.""" - -from __future__ import annotations - -import os -from dataclasses import dataclass -from datetime import datetime - -import numpy as np -import skimage.measure -import skimage.segmentation - - -@dataclass(frozen=True) -class ExportFramePlan: - """Destination naming for one exported video frame.""" - - frame_index_text: str - png_filename: str - png_filepath: str - - -class ExportingModel: - """Headless export naming, mask, and zoom selection rules.""" - - def timestamped_export_filename(self, kind: str, *, timestamp=None): - if timestamp is None: - timestamp = datetime.now() - return f"{timestamp.strftime('%Y%m%d_%H%M%S')}_acdc_exported_{kind}" - - def export_frame_plan( - self, - *, - current_index: int, - num_digits: int, - filename: str, - pngs_folderpath: str, - ) -> ExportFramePlan: - frame_index_text = str(current_index).zfill(num_digits) - png_filename = f'{frame_index_text}_{filename}.png' - return ExportFramePlan( - frame_index_text=frame_index_text, - png_filename=png_filename, - png_filepath=os.path.join(pngs_folderpath, png_filename), - ) - - def export_mask_image_shape(self, image_shape) -> tuple[int, int, int]: - height, width = image_shape[-2:] - return height, width, 4 - - def build_export_mask_image( - self, - image_shape, - view_range, - *, - invert_bw=False, - ): - mask_image = np.zeros( - self.export_mask_image_shape(image_shape), - dtype=np.uint8, - ) - x_range, y_range = view_range - x0, x1 = map(round, x_range) - y0, y1 = map(round, y_range) - - if invert_bw: - mask_image[:, :, :3] = 255 - - if x0 > 0: - mask_image[:, :x0, 3] = 255 - if x1 < mask_image.shape[1]: - mask_image[:, x1:, 3] = 255 - if y0 > 0: - mask_image[:y0, :, 3] = 255 - if y1 < mask_image.shape[0]: - mask_image[y1:, :, 3] = 255 - - return mask_image - - def zoom_ids(self, labels_2d, view_range): - height, width = labels_2d.shape - ((xmin, xmax), (ymin, ymax)) = view_range - if xmin <= 0 and ymin <= 0 and xmax >= width and ymax >= height: - return None - - xmin = max(xmin, 0) - ymin = max(ymin, 0) - xmax = min(xmax, width) - ymax = min(ymax, height) - - zoom_slice = ( - slice(round(ymin), round(ymax)), - slice(round(xmin), round(xmax)), - ) - zoom_labels = skimage.segmentation.clear_border(labels_2d[zoom_slice]) - zoom_regionprops = skimage.measure.regionprops(zoom_labels) - return [obj.label for obj in zoom_regionprops] - - def shifted_view_range(self, previous_range, current_range, window_range): - prev_x_range, prev_y_range = previous_range - curr_x_range, curr_y_range = current_range - win_x_range, win_y_range = window_range - - delta_x = curr_x_range[0] - prev_x_range[0] - delta_y = curr_y_range[0] - prev_y_range[0] - - return ( - (win_x_range[0] + delta_x, win_x_range[1] + delta_x), - (win_y_range[0] + delta_y, win_y_range[1] + delta_y), - ) diff --git a/cellacdc/models/frame_navigation_model.py b/cellacdc/models/frame_navigation_model.py deleted file mode 100644 index 434bacaff..000000000 --- a/cellacdc/models/frame_navigation_model.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Scriptable model rules for frame and position navigation.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class NavigationLimit: - """Maximum navigation frame and optional status label.""" - - maximum: int - last_checked_frame_i: int | None = None - status_text: str | None = None - - -class FrameNavigationModel: - """Headless decisions for frame/position navigation workflows.""" - - viewer_mode = 'Viewer' - segmentation_mode = 'Segmentation and Tracking' - cell_cycle_mode = 'Cell cycle analysis' - lineage_mode = 'Normal division: Lineage tree' - - def should_show_next_frame_image( - self, - *, - size_t: int, - has_right_image_coords: bool, - action_enabled: bool, - action_checked: bool, - ) -> bool: - return ( - size_t > 1 - and has_right_image_coords - and action_enabled - and action_checked - ) - - def next_frame_index(self, *, current_frame_i: int, frames_count: int) -> int: - next_frame_i = current_frame_i + 1 - if next_frame_i >= frames_count: - return frames_count - 1 - return next_frame_i - - def navigation_position( - self, - *, - is_snapshot: bool, - position_i: int, - frame_i: int, - ) -> int: - return position_i + 1 if is_snapshot else frame_i + 1 - - def navigation_limit( - self, - *, - mode: str, - frame_i: int, - last_tracked_i: int | None, - last_cca_frame_i: int, - lineage_tree_frames, - ) -> NavigationLimit | None: - if mode == self.segmentation_mode: - if last_tracked_i is None or frame_i > last_tracked_i: - maximum = frame_i + 1 - else: - maximum = last_tracked_i + 1 - return NavigationLimit( - maximum=maximum, - last_checked_frame_i=maximum - 1, - ) - if mode == self.cell_cycle_mode: - maximum = max(frame_i, last_cca_frame_i) + 1 - return NavigationLimit( - maximum=maximum, - status_text=f'Last cc annot. frame n. = {maximum}', - ) - if mode == self.lineage_mode: - if lineage_tree_frames: - maximum = max(lineage_tree_frames) + 1 - else: - maximum = frame_i + 1 - return NavigationLimit(maximum=maximum) - return None - - def should_store_when_slider_moves(self, *, mode: str) -> bool: - return mode != self.viewer_mode - - def should_warn_lost_objects( - self, - *, - requested: bool, - action_checked: bool, - mode: str, - lost_ids, - already_accepted: bool, - ) -> bool: - if not requested: - return False - if not action_checked: - return False - if mode != self.segmentation_mode: - return False - if not lost_ids: - return False - return not already_accepted - - def blocks_future_manual_annotation( - self, - *, - manual_annotation_enabled: bool, - current_frame_i: int, - frame_to_restore, - ) -> bool: - if not manual_annotation_enabled: - return False - if frame_to_restore is None: - return False - return current_frame_i > frame_to_restore - - def should_apply_new_frame_tools( - self, - *, - mode: str, - last_tracked_i: int, - frame_i: int, - last_frame_ran: int, - ) -> bool: - return ( - mode == self.segmentation_mode - and last_tracked_i is not None - and last_tracked_i <= frame_i - and frame_i != last_frame_ran - ) - - def is_single_z_slice_projection(self, how: str) -> bool: - return how == 'single z-slice' - - def should_disable_overlay_z_slice(self, how: str) -> bool: - return how.find('max') != -1 or how == 'same as above' - - def projection_frame_indices( - self, - *, - filename, - frame_i: int, - size_t: int, - locked: bool, - ) -> list[tuple[object, int]]: - if locked: - return [(filename, i) for i in range(size_t)] - return [(filename, frame_i)] - - def z_slice_frame_indices( - self, - *, - filename, - frame_i: int, - size_t: int, - locked: bool, - ) -> list[tuple[object, int]]: - if locked: - return [(filename, i) for i in range(size_t)] - return [(filename, i) for i in range(frame_i, size_t)] diff --git a/cellacdc/models/graphics_model.py b/cellacdc/models/graphics_model.py deleted file mode 100644 index 160203697..000000000 --- a/cellacdc/models/graphics_model.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Qt-free model rules for graphics workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass -from collections.abc import Iterable, Mapping -import numpy as np - - -@dataclass(frozen=True) -class OverlayOpacityPlan: - """Opacity and scrollbar state for overlay image items.""" - - opacities: dict[str, float] - alpha_scrollbar_disabled: dict[str, bool] - - -@dataclass(frozen=True) -class OverlayVisibilityPlan: - """Visibility state for overlay controls by channel.""" - - channel_visible: dict[str, bool] - - -class GraphicsModel: - """Headless graphics workflow rules.""" - - def overlay_toolbutton_checked( - self, - channel: str, - *, - checked_channels: Iterable[str], - is_single_channel: bool, - ) -> bool: - return not is_single_channel and channel in set(checked_channels) - - def overlay_toolbutton_click_checked_channels( - self, - *, - clicked_channel: str, - all_channels: Iterable[str], - checked_channels: Iterable[str], - toolbar_single_channel: bool, - ) -> set[str]: - all_channels = set(all_channels) - checked_channels = set(checked_channels) - if not checked_channels or toolbar_single_channel: - checked_channels.add(clicked_channel) - - if toolbar_single_channel: - return {clicked_channel} - - return checked_channels & all_channels - - def overlay_visibility_plan( - self, - *, - all_channels: Iterable[str], - checked_channels: Iterable[str], - overlay_enabled: bool, - ) -> OverlayVisibilityPlan: - checked_channels = set(checked_channels) - return OverlayVisibilityPlan( - channel_visible={ - channel: overlay_enabled and channel in checked_channels - for channel in all_channels - } - ) - - def overlay_channel_opacity_map( - self, - base_channel: str, - active_channel_alpha_values: Mapping[str, float], - ) -> dict[str, float]: - channels = list(active_channel_alpha_values) - alpha_values = list(active_channel_alpha_values.values()) - opacities = self._base_first_hierarchical_opacities(alpha_values) - channel_opacity_mapper = { - channel: opacities[i + 1] - for i, channel in enumerate(channels) - } - channel_opacity_mapper[base_channel] = opacities[0] - return channel_opacity_mapper - - def overlay_item_opacity_plan( - self, - *, - all_channels: Iterable[str], - base_channel: str, - checked_channels: Iterable[str], - toolbar_single_channel: bool, - active_channel_alpha_values: Mapping[str, float], - ) -> OverlayOpacityPlan: - checked_channels = set(checked_channels) - channel_opacity_mapper = self.overlay_channel_opacity_map( - base_channel, - active_channel_alpha_values, - ) - is_single_channel = toolbar_single_channel or len(checked_channels) == 1 - - opacities = {} - alpha_scrollbar_disabled = {} - for channel in all_channels: - if channel in checked_channels and is_single_channel: - op_val = 1.0 - elif channel in checked_channels: - op_val = channel_opacity_mapper[channel] - else: - op_val = 0.0 - - if op_val == 0: - op_val = 0.01 - - opacities[channel] = min(op_val, 0.999) - if channel != base_channel: - alpha_scrollbar_disabled[channel] = op_val == 0 - - return OverlayOpacityPlan( - opacities=opacities, - alpha_scrollbar_disabled=alpha_scrollbar_disabled, - ) - - def _base_first_hierarchical_opacities( - self, - alpha_values: Iterable[float], - ) -> list[float]: - alphas = [1.0, *alpha_values] - if not alphas: - return alphas - - weights = [] - for i, alpha_ref in enumerate(alphas): - weight = alpha_ref - for alpha in alphas[i + 1:]: - weight *= 1 - alpha - weights.append(weight) - - return weights - - def generate_labels_image_lut(self, base_lut: np.ndarray) -> np.ndarray: - """Converts a 3-channel base LUT to a 4-channel RGBA LUT with background as transparent.""" - import numpy as np - lut = np.zeros((len(base_lut), 4), dtype=np.uint8) - lut[:, -1] = 255 - lut[:, :-1] = base_lut - lut[0] = [0, 0, 0, 0] - return lut - - def extend_labels_lut(self, base_lut: np.ndarray, len_new_lut: int) -> np.ndarray: - """Extends base_lut to include IDs greater than original length of base_lut.""" - import numpy as np - if len_new_lut <= len(base_lut): - return base_lut - - num_new_colors = len_new_lut - len(base_lut) - _lut = np.zeros((len_new_lut, 3), np.uint8) - _lut[:len(base_lut)] = base_lut - - random_idx = np.random.randint(0, len(base_lut), size=num_new_colors) - for i, idx in enumerate(random_idx): - rgb = base_lut[idx] - _lut[len(base_lut) + i] = rgb - return _lut - - def apply_lut_dimming_for_kept_objects( - self, - lut: np.ndarray, - kept_object_ids: list[int], - keep_ids_enabled: bool, - ) -> np.ndarray: - """Applies dimming to non-kept objects in the LUT if keep_ids is enabled.""" - import numpy as np - if not keep_ids_enabled: - return lut - - dimmed_lut = np.round(lut * 0.3).astype(np.uint8) - valid_ids = [idx for idx in kept_object_ids if idx < len(lut)] - if valid_ids: - kept_lut = np.round(dimmed_lut[valid_ids] / 0.3).astype(np.uint8) - dimmed_lut[valid_ids] = kept_lut - return dimmed_lut - diff --git a/cellacdc/models/image_controls_model.py b/cellacdc/models/image_controls_model.py deleted file mode 100644 index 3cf1a5ca1..000000000 --- a/cellacdc/models/image_controls_model.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Scriptable model rules for image control widgets.""" - -from __future__ import annotations - - -class ImageControlsModel: - """Headless defaults for image-control UI construction.""" - - draw_ids_cont_combo_items = ( - 'Draw IDs and contours', - 'Draw IDs and overlay segm. masks', - 'Draw only cell cycle info', - 'Draw cell cycle info and contours', - 'Draw cell cycle info and overlay segm. masks', - 'Draw only mother-bud lines', - 'Draw only IDs', - 'Draw only contours', - 'Draw only overlay segm. masks', - 'Draw nothing', - ) - z_projection_options = ( - 'single z-slice', - 'max z-projection', - 'mean z-projection', - 'median z-proj.', - ) - overlay_z_projection_options = ( - 'single z-slice', - 'max z-projection', - 'mean z-projection', - 'median z-proj.', - 'same as above', - ) - bottom_layout_zoom_values = tuple(range(50, 151, 10)) - - def bottom_layout_zoom_percent(self, df_settings) -> int: - if 'bottom_sliders_zoom_perc' not in df_settings.index: - return 100 - return int(df_settings.at['bottom_sliders_zoom_perc', 'value']) - - def retain_space_hidden_sliders(self, df_settings) -> bool: - if 'retain_space_hidden_sliders' not in df_settings.index: - return True - return df_settings.at['retain_space_hidden_sliders', 'value'] == 'Yes' diff --git a/cellacdc/models/image_display_model.py b/cellacdc/models/image_display_model.py deleted file mode 100644 index d0ab31129..000000000 --- a/cellacdc/models/image_display_model.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Qt-free model rules for image display workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal - - -RightPaneMode = Literal['next_frame', 'right_image', 'labels'] - - -@dataclass(frozen=True) -class RightPaneVisibilityPlan: - """Settings update for a right-pane visibility toggle.""" - - mode: RightPaneMode - checked: bool - settings_updates: dict[str, str] - - -@dataclass(frozen=True) -class LabelsAlphaPlan: - """Settings and effective opacity for labels overlay alpha.""" - - setting_value: float - opacity: float - - -class ImageDisplayModel: - """Headless display settings and image-display rules.""" - - def right_pane_visibility_plan( - self, - mode: RightPaneMode, - checked: bool, - ) -> RightPaneVisibilityPlan: - settings_updates = { - 'isNextFrameVisible': 'No', - 'isRightImageVisible': 'No', - 'isLabelsVisible': 'No', - } - if checked: - setting_key = { - 'next_frame': 'isNextFrameVisible', - 'right_image': 'isRightImageVisible', - 'labels': 'isLabelsVisible', - }[mode] - settings_updates[setting_key] = 'Yes' - - return RightPaneVisibilityPlan( - mode=mode, - checked=checked, - settings_updates=settings_updates, - ) - - def invert_bw_setting_value(self, checked: bool) -> str: - return 'Yes' if checked else 'No' - - def labels_alpha_plan( - self, - value: float, - *, - keep_ids_checked: bool, - ) -> LabelsAlphaPlan: - opacity = value / 3 if keep_ids_checked else value - return LabelsAlphaPlan(setting_value=value, opacity=opacity) - - def intensity_normalization_setting_value(self, how: str) -> str: - return how - - def rescale_intensity_setting_update( - self, - channel: str, - how: str, - ) -> tuple[str, str]: - return f'how_rescale_intensities_{channel}', how diff --git a/cellacdc/models/label_editing_model.py b/cellacdc/models/label_editing_model.py deleted file mode 100644 index 7f14e84c5..000000000 --- a/cellacdc/models/label_editing_model.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Scriptable model rules for label-editing workflows.""" - -from __future__ import annotations - - -class LabelEditingModel: - """Headless decisions for manual label editing.""" - - def should_apply_manual_edits(self, edited_labels_by_z) -> bool: - return bool(edited_labels_by_z) - - def should_store_zslice_regionprops(self, *, is_segm_3d: bool) -> bool: - return is_segm_3d - - def should_update_zslice_regionprops( - self, - *, - force_update: bool, - already_stored: bool, - ) -> bool: - return force_update or not already_stored - - def should_prompt_for_background_id(self, clicked_id: int) -> bool: - return clicked_id == 0 - - def is_power_button_color( - self, - *, - button_color: str, - power_color: str, - ) -> bool: - return button_color == power_color - - def should_force_new_hover_id( - self, - *, - brush_active: bool, - shift_pressed: bool, - ) -> bool: - return brush_active and shift_pressed - - def should_restore_brush_id_from_hover( - self, - *, - is_hover_z_neighbor: bool, - shift_pressed: bool, - last_hover_id: int, - hover_id: int, - ) -> bool: - return ( - is_hover_z_neighbor - and not shift_pressed - and last_hover_id != hover_id - ) diff --git a/cellacdc/models/label_roi_model.py b/cellacdc/models/label_roi_model.py deleted file mode 100644 index 116028efa..000000000 --- a/cellacdc/models/label_roi_model.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Scriptable model rules for label-ROI workflows.""" - -from __future__ import annotations - -import os -from dataclasses import dataclass - - -@dataclass(frozen=True) -class LabelRoiParamsSettings: - """Settings updates for Magic Labeller ROI options.""" - - updates: dict[str, object] - - -class LabelRoiModel: - """Headless decisions for Magic Labeller ROI workflows.""" - - yes_value = 'Yes' - no_value = 'No' - - def checked_setting_value(self, checked: bool) -> str: - return self.yes_value if checked else self.no_value - - def checked_from_setting_value(self, value) -> bool: - return value == self.yes_value - - def model_params_ini_path(self, settings_folderpath: str) -> str: - return os.path.join(settings_folderpath, 'last_params_segm_models.ini') - - def params_settings( - self, - *, - checked_roi_type: str, - circ_roi_radius: int, - roi_zdepth: int, - auto_clear_border: bool, - replace_existing_objects: bool, - ) -> LabelRoiParamsSettings: - return LabelRoiParamsSettings( - updates={ - 'labelRoi_checkedRoiType': checked_roi_type, - 'labelRoi_circRoiRadius': circ_roi_radius, - 'labelRoi_roiZdepth': roi_zdepth, - 'labelRoi_autoClearBorder': self.checked_setting_value( - auto_clear_border - ), - 'labelRoi_replaceExistingObjects': ( - self.checked_setting_value(replace_existing_objects) - ), - } - ) - - def is_frame_range_valid( - self, - enabled: bool, - start_frame_number: int, - stop_frame_number: int, - ) -> bool: - return not enabled or start_frame_number <= stop_frame_number - - def frame_range_length( - self, - enabled: bool, - start_frame_index: int, - stop_frame_number: int, - ) -> int: - if not enabled: - return 1 - return stop_frame_number - start_frame_index - - def time_range( - self, - enabled: bool, - start_frame_index: int, - stop_frame_number: int, - ): - if self.frame_range_length( - enabled, - start_frame_index, - stop_frame_number, - ) > 1: - return start_frame_index, stop_frame_number - return None - - def should_enable_range_controls(self, checked: bool) -> bool: - return checked - - def should_show_circular_cursor( - self, - *, - label_roi_checked: bool, - circular_roi_checked: bool, - label_roi_running: bool, - cursor_checked: bool, - existing_cursor_empty: bool, - ) -> bool: - return ( - label_roi_checked - and circular_roi_checked - and not label_roi_running - and (cursor_checked or not existing_cursor_empty) - ) - - def cursor_points(self, x, y, checked: bool): - if not checked: - return [], [] - return [x], [y] - - def should_uncheck_time_range( - self, - *, - time_range_checked: bool, - persistent_action_checked: bool, - ) -> bool: - return time_range_checked and not persistent_action_checked - - def z_range( - self, - roi_zdepth: int, - size_z: int, - current_z_index: int, - ) -> tuple[int, int]: - if roi_zdepth == size_z: - return 0, size_z - if roi_zdepth == 1: - return current_z_index, current_z_index + 1 - - if roi_zdepth % 2 != 0: - roi_zdepth += 1 - half_zdepth = int(roi_zdepth / 2) - zc = current_z_index + 1 - z0 = max(zc - half_zdepth, 0) - z1 = min(zc + half_zdepth, size_z) - return z0, z1 diff --git a/cellacdc/models/label_transform_tools_model.py b/cellacdc/models/label_transform_tools_model.py deleted file mode 100644 index aa9ccc1a4..000000000 --- a/cellacdc/models/label_transform_tools_model.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Scriptable model rules for label transform tools.""" - -from __future__ import annotations - - -class LabelTransformToolsModel: - """Headless decision rules for label transform tools.""" - - def reset_expand_label_id(self) -> int: - return -1 - - def should_reinitialize_expansion( - self, - *, - expanding_id: int, - hover_label_id: int, - dilation: bool, - is_dilation: bool, - ) -> bool: - return expanding_id != hover_label_id or dilation != is_dilation - - def should_start_moving_label(self, label_id: int) -> bool: - return label_id != 0 - - def point_in_shape(self, *, x: int, y: int, shape) -> bool: - y_size, x_size = shape - return x >= 0 and y >= 0 and x < x_size and y < y_size - - def move_delta(self, *, previous_pos, current_pos) -> tuple[int, int]: - x_start, y_start = previous_pos - x_current, y_current = current_pos - return x_current - x_start, y_current - y_start - - def should_clear_move_state(self, *, checked: bool) -> bool: - return not checked diff --git a/cellacdc/models/layout_controls_model.py b/cellacdc/models/layout_controls_model.py deleted file mode 100644 index 493f5d336..000000000 --- a/cellacdc/models/layout_controls_model.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Scriptable model rules for layout-control workflows.""" - -from __future__ import annotations - -import re - - -class LayoutControlsModel: - """Headless decisions for GUI layout controls.""" - - yes_value = 'Yes' - no_value = 'No' - - def zoom_percentage_from_text(self, text: str) -> int: - return int(re.findall(r'(\d+)%', text)[0]) - - def zoom_factors(self, percentage: int) -> tuple[float, float] | None: - if percentage == 100: - return None - factor = percentage / 100 - return factor, factor - - def checked_setting_value(self, checked: bool) -> str: - return self.yes_value if checked else self.no_value - - def checked_from_setting_value(self, value) -> bool: - return value == self.yes_value - - def should_retain_z_slider_space( - self, - *, - checked: bool, - z_slice_enabled: bool, - ) -> bool: - return checked and z_slice_enabled - - def tool_name_from_tooltip(self, tooltip: str) -> str: - return re.findall(r'Name: (.*)', tooltip)[0] diff --git a/cellacdc/models/lineage_interactions_model.py b/cellacdc/models/lineage_interactions_model.py deleted file mode 100644 index a5f3636ec..000000000 --- a/cellacdc/models/lineage_interactions_model.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Scriptable model rules for lineage-tree interaction workflows.""" - -from __future__ import annotations - -from collections.abc import Callable, Iterable, Sequence - -import numpy as np -import pandas as pd - - -class LineageInteractionsModel: - """Headless decisions for lineage-tree interaction workflows.""" - - lineage_mode = 'Normal division: Lineage tree' - viewer_mode = 'Viewer' - - def is_lineage_mode(self, mode: str) -> bool: - return mode == self.lineage_mode - - def should_initialize( - self, - *, - force: bool, - mode: str, - lineage_tree_exists: bool, - ) -> bool: - if not force and lineage_tree_exists: - return False - return force or self.is_lineage_mode(mode) - - def default_mode_after_failed_init(self) -> str: - return self.viewer_mode - - def last_annotated_frame_index( - self, - frame_records: Iterable[dict], - *, - acdc_key: str = 'acdc_df', - generation_column: str = 'generation_num_tree', - ) -> int: - last_frame_i = 0 - for frame_i, record in enumerate(frame_records): - acdc_df = record[acdc_key] - if ( - acdc_df is None - or generation_column not in acdc_df.columns - or acdc_df[generation_column].isin([np.nan, 0]).all() - ): - break - last_frame_i = frame_i - return last_frame_i - - def missing_frame_indices( - self, - current_frame_i: int, - present_frames: Iterable[int] | None, - ) -> list[int]: - present = set(present_frames or []) - missing = [ - frame_i for frame_i in range(current_frame_i + 1) - if frame_i not in present - ] - missing.sort() - return missing - - def should_process_auto_frame( - self, - *, - mode: str, - frame_i: int, - processed_frames: Iterable[int], - ) -> bool: - if not self.is_lineage_mode(mode): - return False - return frame_i not in processed_frames - - def should_backup_original( - self, - original_frame_i: int | None, - current_frame_i: int, - ) -> bool: - return original_frame_i is None or original_frame_i != current_frame_i - - def next_candidate_index( - self, - click_index: int, - candidates_count: int, - ) -> int: - if candidates_count <= 0: - return 0 - return abs(click_index % candidates_count) - - def should_skip_original_mother( - self, - current_parent_id, - candidate_parent_id, - *, - original_mother_skipped: bool, - ) -> bool: - return ( - current_parent_id == candidate_parent_id - and not original_mother_skipped - ) - - def parent_id_differences( - self, - original_df: pd.DataFrame, - new_df: pd.DataFrame, - reset_index_cell_id: Callable[[pd.DataFrame], pd.DataFrame], - *, - compare_columns: Sequence[str] = ('parent_ID_tree',), - ) -> pd.DataFrame | None: - if original_df.equals(new_df): - return None - - new_df = new_df[original_df.columns] - new_df = reset_index_cell_id(new_df) - new_df = new_df[list(compare_columns)] - new_df = new_df.sort_index() - - original_df = reset_index_cell_id(original_df) - original_df = original_df[list(compare_columns)] - original_df = original_df.sort_index() - - differences = original_df.compare(new_df) - if differences.empty: - return None - - differences = reset_index_cell_id(differences) - differences = differences[compare_columns[0]] - return differences.reset_index() diff --git a/cellacdc/models/magic_prompts_model.py b/cellacdc/models/magic_prompts_model.py deleted file mode 100644 index 37c9af182..000000000 --- a/cellacdc/models/magic_prompts_model.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Scriptable model rules for promptable segmentation workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Mapping - - -@dataclass(frozen=True) -class MagicPromptZoom: - """Computed zoom region for promptable segmentation.""" - - bounds: tuple[int, int, int, int] - image_origin: tuple[int, int, int] - zoom_slice: tuple[slice, slice] - - -class MagicPromptsModel: - """Headless promptable-segmentation geometry and point rules.""" - - def zoom_region(self, view_range, image_shape) -> MagicPromptZoom: - (xmin, xmax), (ymin, ymax) = view_range - height, width = image_shape[-2:] - - xmin = int(max(0, xmin)) - xmax = int(min(width, xmax)) - ymin = int(max(0, ymin)) - ymax = int(min(height, ymax)) - - return MagicPromptZoom( - bounds=(xmin, xmax, ymin, ymax), - image_origin=(0, ymin, xmin), - zoom_slice=(slice(ymin, ymax), slice(xmin, xmax)), - ) - - def points_in_zoom(self, df_points, zoom: MagicPromptZoom, frame_i): - xmin, xmax, ymin, ymax = zoom.bounds - filtered = df_points[ - (df_points['y'] >= ymin) - & (df_points['x'] >= xmin) - & (df_points['y'] < ymax) - & (df_points['x'] < xmax) - & (df_points['frame_i'] == frame_i) - ].copy() - filtered['y'] -= ymin - filtered['x'] -= xmin - return filtered - - def retained_points_outside_zoom( - self, - frame_points_data: Mapping, - zoom: MagicPromptZoom, - ): - if 'x' in frame_points_data: - return self._retained_points_outside_zoom_2d( - frame_points_data, - zoom, - ) - - return { - z: self._retained_points_outside_zoom_2d(z_points, zoom) - for z, z_points in frame_points_data.items() - } - - def _retained_points_outside_zoom_2d(self, points_data, zoom): - xmin, xmax, ymin, ymax = zoom.bounds - retained = {'x': [], 'y': [], 'id': []} - for x, y, point_id in zip( - points_data['x'], - points_data['y'], - points_data['id'], - ): - if x < xmin or x >= xmax or y < ymin or y >= ymax: - retained['x'].append(x) - retained['y'].append(y) - retained['id'].append(point_id) - return retained diff --git a/cellacdc/models/main_menu_model.py b/cellacdc/models/main_menu_model.py deleted file mode 100644 index f500b6d7c..000000000 --- a/cellacdc/models/main_menu_model.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Scriptable model rules for the main menu.""" - -from __future__ import annotations - - -class MainMenuModel: - """Headless main-menu decision rules.""" - - default_rescale_intensity_options = ( - 'Rescale each 2D image', - 'Rescale across z-stack', - 'Rescale across time frames', - 'Do no rescale, display raw image', - ) - - def default_rescale_intensity_how(self, settings): - try: - return settings.at['default_rescale_intens_how', 'value'] - except Exception: - return self.default_rescale_intensity_options[0] diff --git a/cellacdc/models/main_toolbar_model.py b/cellacdc/models/main_toolbar_model.py deleted file mode 100644 index 178a3cd27..000000000 --- a/cellacdc/models/main_toolbar_model.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Scriptable model rules for the main GUI toolbars.""" - -from __future__ import annotations - - -class MainToolbarModel: - """Headless toolbar metadata used by the main toolbar view.""" - - mode_items = ( - 'Segmentation and Tracking', - 'Cell cycle analysis', - 'Viewer', - 'Custom annotations', - 'Normal division: Lineage tree', - ) - - def default_mode_items(self) -> tuple[str, ...]: - return self.mode_items diff --git a/cellacdc/models/measurements_model.py b/cellacdc/models/measurements_model.py deleted file mode 100644 index 038745200..000000000 --- a/cellacdc/models/measurements_model.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Scriptable model rules for measurement workflows.""" - -from __future__ import annotations - -from cellacdc.cca_functions import _calc_rot_vol -from cellacdc import measurements - - -class MeasurementsModel: - """Headless measurement calculation and setup rules.""" - - def rotational_volume( - self, - obj, - physical_size_y=1, - physical_size_x=1, - logger=None, - ): - return _calc_rot_vol( - obj, - physical_size_y, - physical_size_x, - logger=logger, - ) - - def custom_metrics_instructions(self): - return measurements.add_metrics_instructions() - - def metrics_examples_path(self): - return measurements.metrics_path - - def all_acdc_df_columns(self, all_pos_data): - columns = set() - for pos_data in all_pos_data: - for data_dict in pos_data.allData_li: - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - columns.update(acdc_df.columns) - return columns - - def not_loaded_channels(self, all_channel_names, loaded_channel_names): - return [c for c in all_channel_names if c not in loaded_channel_names] - - def drop_unchecked_measurements(self, acdc_df, columns, regionprops): - if acdc_df is None: - return None - acdc_df = acdc_df.drop(columns=columns, errors='ignore') - for col_rp in regionprops: - drop_df_rp = acdc_df.filter(regex=fr'{col_rp}.*', axis=1) - drop_cols_rp = drop_df_rp.columns - acdc_df = acdc_df.drop(columns=drop_cols_rp, errors='ignore') - return acdc_df diff --git a/cellacdc/models/mode_controls_model.py b/cellacdc/models/mode_controls_model.py deleted file mode 100644 index ac2e164a5..000000000 --- a/cellacdc/models/mode_controls_model.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Scriptable model rules for GUI mode controls.""" - -from __future__ import annotations - - -class ModeControlsModel: - """Headless decisions for mode toolbar and action state.""" - - viewer_mode = 'Viewer' - segmentation_mode = 'Segmentation and Tracking' - snapshot_mode = 'Snapshot' - cca_mode = 'Cell cycle analysis' - custom_annotations_mode = 'Custom annotations' - - def should_start_blinking( - self, - mode: str, - *, - ruler_checked: bool = False, - ) -> bool: - return mode == self.viewer_mode and not ruler_checked - - def blink_styles(self, flag: bool) -> tuple[str, bool]: - if flag: - return 'background-color: orange', False - return 'background-color: none', True - - def should_store_on_mode_change(self, previous_mode: str) -> bool: - return previous_mode != self.viewer_mode - - def is_cca_mode(self, mode: str) -> bool: - return mode == self.cca_mode - - def undo_redo_target(self, mode: str) -> str: - if mode in {self.segmentation_mode, self.snapshot_mode}: - return 'labels' - if mode == self.cca_mode: - return 'cca' - if mode == self.custom_annotations_mode: - return 'custom_annotations' - return 'disabled' diff --git a/cellacdc/models/object_cleanup_model.py b/cellacdc/models/object_cleanup_model.py deleted file mode 100644 index b29157e3f..000000000 --- a/cellacdc/models/object_cleanup_model.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Scriptable model rules for object cleanup workflows.""" - -from __future__ import annotations - -import numpy as np - - -class ObjectCleanupModel: - """Headless object-cleanup result shaping.""" - - def cleared_segmentation_frames(self, cleared_segm_data, *, size_t: int): - if size_t == 1: - return cleared_segm_data[np.newaxis] - return cleared_segm_data - - def frame_labels(self, cleared_segm_data): - return list(enumerate(cleared_segm_data)) diff --git a/cellacdc/models/object_properties_model.py b/cellacdc/models/object_properties_model.py deleted file mode 100644 index 42d149a97..000000000 --- a/cellacdc/models/object_properties_model.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Scriptable model rules for object-property workflows.""" - -from __future__ import annotations - -import numpy as np - - -class ObjectPropertiesModel: - """Headless decisions for object-property and highlight workflows.""" - - def timelapse_default_categories(self) -> set[str]: - return { - 'In current frame', - 'In all visited frames', - 'In entire video', - 'Unique objects in all visited frames', - 'Unique objects in entire video', - } - - def snapshot_default_categories(self, *, is_segm_3d: bool) -> set[str]: - categories = { - 'In current position', - 'In all visited positions (current session)', - 'In all visited positions (previous sessions)', - 'In all loaded positions', - } - if is_segm_3d: - categories.add('In current z-slice') - return categories - - def should_update_object_counts( - self, - *, - window_exists: bool, - is_visible: bool, - live_preview_checked: bool, - ) -> bool: - return window_exists and is_visible and live_preview_checked - - def should_show_3d_property_controls(self, is_segm_3d: bool) -> bool: - return is_segm_3d - - def should_highlight_props_id( - self, - *, - dock_visible: bool, - highlight_checked: bool, - searched_highlight_checked: bool, - ) -> bool: - return ( - dock_visible - and (highlight_checked or searched_highlight_checked) - ) - - def should_update_props_widget( - self, - *, - dock_visible: bool, - object_id: int, - current_props_id: int, - ) -> bool: - return dock_visible and object_id != 0 and object_id != current_props_id - - def calculate_area_pxl( - self, - *, - is_segm_3d: bool, - z_proj_text: str, - z_lab: int, - bbox_0: int, - obj_image: np.ndarray, - obj_area: int, - ) -> int: - if is_segm_3d: - if z_proj_text == 'single z-slice': - local_z = z_lab - bbox_0 - return int(np.count_nonzero(obj_image[local_z])) - else: - return int(np.count_nonzero(obj_image.max(axis=0))) - else: - return obj_area - - def calculate_area_um2( - self, - *, - area_pxl: int, - physical_size_x: float, - physical_size_y: float, - ) -> float: - return area_pxl * physical_size_y * physical_size_x - - def calculate_vol_3d( - self, - *, - obj_area: int, - physical_size_x: float, - physical_size_y: float, - physical_size_z: float, - ) -> tuple[float, float]: - vol_vox_3D = obj_area - vol_fl_3D = vol_vox_3D * physical_size_z * physical_size_y * physical_size_x - return float(vol_vox_3D), float(vol_fl_3D) - - def calculate_elongation( - self, - *, - major_axis_length: float, - minor_axis_length: float, - ) -> float: - minor_axis = max(1.0, minor_axis_length) - return major_axis_length / minor_axis - - def get_object_and_background_images( - self, - *, - image: np.ndarray, - is_segm_3d: bool, - pos_data_size_z: int, - z_slice: int, - obj_slice: tuple, - obj_image: np.ndarray, - img1_image: np.ndarray | None = None, - ) -> tuple[np.ndarray, np.ndarray]: - if pos_data_size_z > 1 and not is_segm_3d: - obj_data = image[z_slice][obj_slice][obj_image] - img = img1_image if img1_image is not None else image[z_slice] - else: - obj_data = image[obj_slice][obj_image] - img = image - return obj_data, img - - def calculate_intensity_statistics( - self, - obj_data: np.ndarray, - ) -> dict[str, float]: - if obj_data.size == 0: - return {'min': 0.0, 'max': 0.0, 'mean': 0.0, 'median': 0.0} - return { - 'min': float(np.min(obj_data)), - 'max': float(np.max(obj_data)), - 'mean': float(np.mean(obj_data)), - 'median': float(np.median(obj_data)), - } - - def calculate_additional_measure( - self, - *, - func_desc: str, - func: callable, - obj_data: np.ndarray, - img: np.ndarray, - lab: np.ndarray, - obj_area: int, - vol_vox: float, - ) -> float: - if func_desc in ('Concentration', 'Amount'): - background_pixels = img[lab == 0] - bkgr_val = ( - float(np.median(background_pixels)) - if background_pixels.size > 0 - else 0.0 - ) - amount = func(obj_data, bkgr_val, obj_area) - if func_desc == 'Concentration': - return amount / vol_vox - else: - return amount - else: - return float(func(obj_data)) - diff --git a/cellacdc/models/object_search_model.py b/cellacdc/models/object_search_model.py deleted file mode 100644 index 4828109a8..000000000 --- a/cellacdc/models/object_search_model.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Scriptable model rules for object search.""" - -from __future__ import annotations - -from collections.abc import Callable - -from cellacdc.domain.object_search import find_frame_with_id - - -class ObjectSearchModel: - """Headless object-search operations.""" - - def find_frame_with_id( - self, - pos_data, - searched_id: int, - *, - progress_callback: Callable[[int], None] | None = None, - ) -> int | None: - return find_frame_with_id( - pos_data.segm_data, - pos_data.allData_li, - searched_id, - progress_callback=progress_callback, - ) diff --git a/cellacdc/models/points_layers_model.py b/cellacdc/models/points_layers_model.py deleted file mode 100644 index 73541bc5b..000000000 --- a/cellacdc/models/points_layers_model.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Scriptable model rules for points-layer workflows.""" - -from __future__ import annotations - -from collections.abc import Mapping - - -class PointsLayersModel: - """Headless decisions for points-layer GUI workflows.""" - - recovery_tolerance_seconds = 15 - - def click_entry_table_filename( - self, - basename: str, - table_endname: str, - ) -> str: - table_basename = basename if basename.endswith('_') else f'{basename}_' - filename = f'{table_basename}{table_endname}' - if not filename.endswith('.csv'): - filename = f'{filename}.csv' - return filename - - def should_load_recovery_table( - self, - *, - recovery_exists: bool, - main_exists: bool, - recovery_mtime: float | None, - main_mtime: float | None, - ) -> bool: - if not recovery_exists: - return False - if not main_exists: - return True - if recovery_mtime is None or main_mtime is None: - return False - return ( - recovery_mtime - > main_mtime + self.recovery_tolerance_seconds - ) - - def should_compute_points_layer( - self, - *, - layer_type_index: int, - compute_points_layers: bool, - ) -> bool: - return layer_type_index < 2 and compute_points_layers - - def should_log_missing_frame_points(self, layer_type_index: int) -> bool: - return layer_type_index != 4 - - def should_use_z_slice( - self, - *, - z_projection_mode: str, - size_z: int, - frame_points_data: Mapping, - ) -> bool: - return ( - z_projection_mode == 'single z-slice' - and size_z > 1 - and 'x' not in frame_points_data - ) diff --git a/cellacdc/models/preprocessing_model.py b/cellacdc/models/preprocessing_model.py deleted file mode 100644 index 9fe182ec6..000000000 --- a/cellacdc/models/preprocessing_model.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Scriptable model commands for image preprocessing recipes.""" - -from __future__ import annotations - -from typing import Any - -from cellacdc.core import ( - preprocess_image_from_recipe as core_preprocess_image_from_recipe, - preprocess_multi_pos_from_recipe as core_preprocess_multi_pos_from_recipe, - preprocess_video_from_recipe as core_preprocess_video_from_recipe, - preprocess_zstack_from_recipe as core_preprocess_zstack_from_recipe, - validate_multidimensional_recipe as core_validate_multidimensional_recipe, -) -from cellacdc.domain.display_images import normalize_display_image -from cellacdc.myutils import img_to_float -from cellacdc.preprocess import PreprocessedData - - -class PreprocessingModel: - """Headless preprocessing operations used by GUI and scripts.""" - - def validate_multidimensional_recipe( - self, - recipe: list[dict[str, Any]], - *, - apply_to_all_zslices: bool = False, - apply_to_all_frames: bool = False, - ): - return core_validate_multidimensional_recipe( - recipe, - apply_to_all_zslices=apply_to_all_zslices, - apply_to_all_frames=apply_to_all_frames, - ) - - def preprocess_image_from_recipe(self, image, recipe: list[dict[str, Any]]): - return core_preprocess_image_from_recipe(image, recipe) - - def preprocess_zstack_from_recipe(self, image, recipe: list[dict[str, Any]]): - return core_preprocess_zstack_from_recipe(image, recipe) - - def preprocess_video_from_recipe(self, image, recipe: list[dict[str, Any]]): - return core_preprocess_video_from_recipe(image, recipe) - - def preprocess_multi_pos_from_recipe( - self, - images, - recipe: list[dict[str, Any]], - ): - return core_preprocess_multi_pos_from_recipe(images, recipe) - - def image_to_float( - self, - image, - *, - force_dtype=None, - force_missing_dtype=None, - warn=True, - ): - return img_to_float( - image, - force_dtype=force_dtype, - force_missing_dtype=force_missing_dtype, - warn=warn, - ) - - def normalize_display_image(self, image, how: str): - return normalize_display_image( - image, - how, - image_to_float=self.image_to_float, - ) - - def create_preprocessed_data(self, image_data=None): - return PreprocessedData(image_data=image_data) diff --git a/cellacdc/models/quick_settings_model.py b/cellacdc/models/quick_settings_model.py deleted file mode 100644 index 7195534ac..000000000 --- a/cellacdc/models/quick_settings_model.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Scriptable model rules for quick settings.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class FontSizeSetting: - """Parsed font-size setting and migration requirement.""" - - value: int - add_px_mode_setting: bool = False - - -class QuickSettingsModel: - """Headless quick-settings decision rules.""" - - def font_size_setting( - self, - saved_font_size, - *, - has_px_mode: bool, - ) -> FontSizeSetting: - saved_font_size = str(saved_font_size) - if saved_font_size.find('pt') != -1: - saved_font_size = saved_font_size[:-2] - font_size = int(saved_font_size) - if has_px_mode: - return FontSizeSetting(value=font_size) - return FontSizeSetting( - value=2*font_size, - add_px_mode_setting=True, - ) - - def should_update_all_contours(self, *, is_data_loaded: bool) -> bool: - return is_data_loaded diff --git a/cellacdc/models/saving_model.py b/cellacdc/models/saving_model.py deleted file mode 100644 index 5278ba44f..000000000 --- a/cellacdc/models/saving_model.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Scriptable model rules for save and autosave workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal - - -@dataclass(frozen=True) -class AutosaveSchedule: - """Autosave timer decision.""" - - use_frame_timer: bool - interval_ms: int | None = None - - -@dataclass(frozen=True) -class AutosaveIntervalChange: - """Settings and UI text for an autosave interval change.""" - - value: float - unit: Literal['minutes', 'frames'] - settings_updates: dict[str, str] - log_message: str - tooltip: str - start_frame_timer: bool - - -@dataclass(frozen=True) -class ConcatenatePromptPlan: - """Decision for showing the concatenate-output prompt.""" - - should_prompt: bool - ensure_setting: bool - - -class SavingModel: - """Headless decisions for save and autosave workflows.""" - - viewer_mode = 'Viewer' - segmentation_mode = 'Segmentation and Tracking' - cell_cycle_mode = 'Cell cycle analysis' - - def should_clear_autosave_status(self, *, mode: str) -> bool: - return mode == self.viewer_mode - - def should_enqueue_autosave(self, *, mode: str, has_active_workers: bool): - return mode != self.viewer_mode and has_active_workers - - def autosave_schedule( - self, - value: float, - unit: Literal['minutes', 'frames'], - ) -> AutosaveSchedule | None: - if value == 0: - return None - if unit == 'frames': - return AutosaveSchedule(use_frame_timer=True) - return AutosaveSchedule( - use_frame_timer=False, - interval_ms=round(value * 60 * 1000), - ) - - def autosave_interval_change( - self, - value: float, - unit: Literal['minutes', 'frames'], - ) -> AutosaveIntervalChange: - return AutosaveIntervalChange( - value=value, - unit=unit, - settings_updates={ - 'autoSaveIntevalValue': str(value), - 'autoSaveIntervalUnit': unit, - }, - log_message=f'Autosave interval changed to: {value} {unit}', - tooltip=( - 'Change autosave interval to every N frames or minutes\n\n' - f'Current autosave interval: {value} {unit}' - ), - start_frame_timer=unit == 'frames', - ) - - def concatenate_prompt_plan( - self, - *, - has_main_window: bool, - is_quick_save: bool, - setting_exists: bool, - show_setting_value: str | None, - ) -> ConcatenatePromptPlan: - if not has_main_window or is_quick_save: - return ConcatenatePromptPlan( - should_prompt=False, - ensure_setting=False, - ) - - should_prompt = show_setting_value != 'No' - return ConcatenatePromptPlan( - should_prompt=should_prompt, - ensure_setting=not setting_exists, - ) - - def concatenate_prompt_setting(self, *, do_not_show_again: bool) -> str: - if do_not_show_again: - return 'No' - return 'Yes' - - def autosave_segmentation_enabled(self, *, mode: str, checked: bool) -> bool: - if mode != self.segmentation_mode: - return False - return checked - - def autosave_annotations_enabled(self, *, mode: str, checked: bool) -> bool: - if mode != self.viewer_mode: - return False - return checked - - def save_as_basename(self, basename: str) -> str: - if basename.endswith('_'): - return f'{basename}segm' - return f'{basename}_segm' - - def quick_save_positions(self, position_foldername: str) -> set[str]: - return {position_foldername} - - def should_ask_positions( - self, - *, - is_snapshot: bool, - is_quick_save: bool, - position_count: int, - ) -> bool: - return is_snapshot and not is_quick_save and position_count > 1 - - def should_compute_volume_metrics( - self, - *, - save_metrics: bool, - mode: str, - ) -> bool: - return save_metrics or mode == self.cell_cycle_mode - - def save_finished_title( - self, - *, - aborted: bool, - worker_aborted: bool, - is_quick_save: bool, - ) -> tuple[str, str | None]: - if aborted or worker_aborted: - return 'Saving process cancelled.', 'r' - if is_quick_save: - return 'Saved segmentation file and annotations', None - return 'Saved!', None diff --git a/cellacdc/models/seg_for_lost_ids_model.py b/cellacdc/models/seg_for_lost_ids_model.py deleted file mode 100644 index d2f0407bf..000000000 --- a/cellacdc/models/seg_for_lost_ids_model.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Scriptable model rules for segmenting lost IDs.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -from cellacdc.myutils import ArgSpec - - -@dataclass(frozen=True) -class SegForLostIdsSettings: - """Settings selected for the segment-lost-IDs worker.""" - - win: Any - init_kwargs_new: dict[str, Any] - args_new: dict[str, Any] - base_model_name: str - - -class SegForLostIdsModel: - """Headless settings and launch rules for lost-ID segmentation.""" - - settings_key = 'SegForLostIDsModel' - worker_model_name = 'local_seg' - - def previous_model_name(self, df_settings) -> str | None: - try: - return str(df_settings.at[self.settings_key, 'value']) - except KeyError: - return None - - def should_persist_model_choice(self, base_model_name: str | None) -> bool: - return bool(base_model_name) - - def extra_arg_specs(self) -> list[ArgSpec]: - extra_params = ( - 'overlap_threshold', - 'padding', - 'size_perc_diff', - 'distance_filler_growth', - 'max_iterations', - 'allow_only_tracked_cells', - ) - extra_types = (float, float, float, float, int, bool) - extra_defaults = (0.5, 0.8, 0.3, 1.0, 2, False) - extra_desc = ( - ( - 'Overlap threshold with other already segemented cells over ' - 'which newly segmented cells are discarded' - ), - ( - 'Padding of the box used for new segmentation around the ' - 'segmentation from the previous frame' - ), - ( - 'Relative size difference acceptable compared to previous ' - 'frames' - ), - ( - 'Cells which are already segmented are filled with random ' - 'noise sampled from background to ensure that they do not get ' - 'segmented again. This parameter controls the additional ' - 'padding around the already segmented cells.' - ), - ( - 'The algorithm will try and segment the maximum amount of ' - 'cells in the image by running the model several times and ' - 'filling new found cells with background noise. How many of ' - 'these iterations should be run?' - ), - ( - 'If no new cell IDs should be permitted ' - '(based on real time tracking)' - ), - ) - - return [ - ArgSpec( - name=name, - default=default, - type=arg_type, - desc=desc, - docstring='', - ) - for name, default, arg_type, desc in zip( - extra_params, extra_defaults, extra_types, extra_desc - ) - ] - - def split_model_kwargs( - self, - init_kwargs: dict[str, Any], - extra_kwargs: dict[str, Any], - ) -> tuple[dict[str, Any], dict[str, Any]]: - extra_param_names = {arg.name for arg in self.extra_arg_specs()} - init_kwargs_new = {} - args_new = {} - - for key, val in init_kwargs.items(): - if key in extra_param_names: - args_new[key] = val - else: - init_kwargs_new[key] = val - - for key, val in extra_kwargs.items(): - if key in extra_param_names: - args_new[key] = val - - return init_kwargs_new, args_new - - def settings_from_dialog(self, win, base_model_name: str): - init_kwargs_new, args_new = self.split_model_kwargs( - win.init_kwargs, - win.extra_kwargs, - ) - return SegForLostIdsSettings( - win=win, - init_kwargs_new=init_kwargs_new, - args_new=args_new, - base_model_name=base_model_name, - ) - - def can_start_from_frame(self, frame_i: int) -> bool: - return frame_i > 0 diff --git a/cellacdc/models/segmentation_model.py b/cellacdc/models/segmentation_model.py deleted file mode 100644 index 68887e365..000000000 --- a/cellacdc/models/segmentation_model.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Scriptable model rules for segmentation workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np - - -@dataclass(frozen=True) -class EmptySegmentationPrompt: - """Prompt decision for enabling automatic segmentation.""" - - should_ask: bool - scope_text: str = '' - - -class SegmentationModel: - """Headless decisions for segmentation orchestration.""" - - thresholding_backend_name = 'thresholding' - thresholding_action_name = 'Automatic thresholding' - - def action_model_name(self, model_name: str) -> str: - if model_name == self.thresholding_backend_name: - return self.thresholding_action_name - return model_name - - def backend_model_name(self, model_name: str) -> str: - if model_name == self.thresholding_action_name: - return self.thresholding_backend_name - return model_name - - def should_compute_segmentation( - self, - *, - mode: str, - has_labels: bool, - force: bool, - auto_enabled: bool, - ) -> bool: - if mode in {'Viewer', 'Cell cycle analysis'}: - return False - if has_labels and not force: - return False - return auto_enabled - - def post_process_params( - self, - *, - apply_postprocessing, - standard_postprocess_kwargs=None, - custom_postprocess_features=None, - ) -> dict: - params = {'applied_postprocessing': apply_postprocessing} - params.update(standard_postprocess_kwargs or {}) - params.update(custom_postprocess_features or {}) - return params - - def empty_segmentation_prompt(self, position_data) -> EmptySegmentationPrompt: - for pos_data in position_data: - if pos_data.SizeT > 1: - for lab in pos_data.segm_data: - if not np.any(lab): - return EmptySegmentationPrompt(True, 'frames') - elif not np.any(pos_data.segm_data): - return EmptySegmentationPrompt(True, 'positions') - return EmptySegmentationPrompt(False) diff --git a/cellacdc/models/session_model.py b/cellacdc/models/session_model.py deleted file mode 100644 index aa6f6020e..000000000 --- a/cellacdc/models/session_model.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Scriptable model rules for session workflows.""" - -from __future__ import annotations - -import numpy as np - - -class SessionModel: - """Headless decisions for session and frame storage workflows.""" - - def should_store_frame_data( - self, - *, - frame_i: int, - mode: str, - enforce: bool, - ) -> bool: - if frame_i < 0: - return False - if mode == 'Viewer' and not enforce: - return False - return True - - def should_disable_load_position(self, position_count: int) -> bool: - return position_count <= 1 - - def labels_shape( - self, - *, - is_3d: bool, - size_z: int, - size_y: int, - size_x: int, - ) -> tuple[int, ...]: - if is_3d: - return (size_z, size_y, size_x) - return (size_y, size_x) - - def empty_labels( - self, - *, - is_3d: bool, - size_z: int, - size_y: int, - size_x: int, - ) -> np.ndarray: - shape = self.labels_shape( - is_3d=is_3d, - size_z=size_z, - size_y=size_y, - size_x=size_x, - ) - return np.zeros(shape, dtype=np.uint32) - - def should_resume_last_session_prompt(self, last_tracked_num: int) -> bool: - return last_tracked_num > 1 diff --git a/cellacdc/models/status_hover_model.py b/cellacdc/models/status_hover_model.py deleted file mode 100644 index 31b4d1baf..000000000 --- a/cellacdc/models/status_hover_model.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Scriptable model rules for hover and status-bar text.""" - -from __future__ import annotations - -import math -import os -import re - - -class StatusHoverModel: - """Headless status-bar and hover formatting rules.""" - - def channel_hover_text(self, description, channel, value, format_spec): - return f'{description} {channel}: value={value:{format_spec}}' - - def object_hover_text(self, *, label_id, max_id, object_count): - return ( - f'Objects: ID={label_id}, max ID={max_id}, ' - f'num. of objects={object_count}' - ) - - def base_hover_text( - self, - *, - x, - y, - width, - height, - x_left, - y_top, - x_right, - y_bottom, - axis_index, - ): - return ( - f'x={x:d}, y={y:d} | ' - f'W={width:d}, H={height:d} | ' - f'x_left={x_left:d}, y_top={y_top:d} | ' - f'x_right={x_right:d}, y_bottom={y_bottom:d} | ' - f'(ax{axis_index})' - ) - - def replace_view_range_status( - self, - text, - *, - width, - height, - x_left, - y_top, - x_right, - y_bottom, - ): - pattern = ( - r'W=.*?, H=.*? \| ' - r'x_left=.*?, y_top=.*? \| ' - r'x_right=.*?, y_bottom=.*? \| ' - ) - replacing = ( - f'W={width:d}, H={height:d} | ' - f'x_left={x_left:d}, y_top={y_top:d} | ' - f'x_right={x_right:d}, y_bottom={y_bottom:d} | ' - ) - return re.sub(pattern, replacing, text) - - def highlight_state( - self, - *, - x, - y, - bbox, - enabled, - active_tool, - blocked_by_other_highlight=False, - ): - if not enabled or active_tool is not None or blocked_by_other_highlight: - return None - y_min, x_min, y_max, x_max = bbox - return x_min <= x <= x_max and y_min <= y <= y_max - - def mouse_data_coords_right_image(self, text): - if not text: - return None - ax_idx = int(re.findall(r'\(ax(\d)\)', text)[0]) - if ax_idx == 0: - return None - coords = re.findall(r'x=(\d+), y=(\d+) \|', text)[0] - return tuple([int(val) for val in coords]) - - def ruler_length_text(self, text): - length_text = re.findall(r'length = (.*)\)', text)[0] - length_text = length_text.replace('pxl', 'pixels') - return f'{length_text})' - - def ruler_measurement_text(self, *, length_pixels, pixel_to_um): - return ( - f'length = {int(length_pixels)} pxl ' - f'({length_pixels*pixel_to_um:.2f} μm)' - ) - - def euclidean_length(self, x_values, y_values): - return math.sqrt( - (x_values[0]-x_values[1])**2 + (y_values[0]-y_values[1])**2 - ) - - def status_bar_text( - self, - *, - pos_foldername, - basename, - filename, - segm_npz_path, - ): - segmented_channel_name = filename[len(basename):] - segm_filename = os.path.basename(segm_npz_path) - segm_end_name = segm_filename[len(basename):] - return ( - f'{pos_foldername} || ' - f'Basename: {basename} || ' - f'Segmented channel: {segmented_channel_name} || ' - f'Segmentation file name: {segm_end_name}' - ) diff --git a/cellacdc/models/tool_activation_model.py b/cellacdc/models/tool_activation_model.py deleted file mode 100644 index 696babbb3..000000000 --- a/cellacdc/models/tool_activation_model.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Scriptable model rules for active-tool workflows.""" - -from __future__ import annotations - - -class ToolActivationModel: - """Headless decisions for active-tool and hover workflows.""" - - def manual_annotation_highlight_color( - self, - *, - current_frame_i: int, - frame_to_restore: int | None, - ) -> str: - if current_frame_i == frame_to_restore: - return 'green' - if frame_to_restore is not None and current_frame_i < frame_to_restore: - return 'gold' - return 'red' - - def should_highlight_hover_lost_object( - self, - *, - has_no_modifier: bool, - copy_lost_object_checked: bool, - is_exit_event: bool, - ) -> bool: - return ( - has_no_modifier - and copy_lost_object_checked - and not is_exit_event - ) - - def point_in_shape(self, x: int, y: int, shape: tuple[int, int]) -> bool: - height, width = shape - return x >= 0 and x < width and y >= 0 and y < height - - def should_hide_hover_objects( - self, - *, - brush_auto_hide_checked: bool, - force: bool, - ) -> bool: - return brush_auto_hide_checked or force - - def should_disable_non_functional_buttons(self, is_segm_3d: bool) -> bool: - return is_segm_3d diff --git a/cellacdc/models/tracking_model.py b/cellacdc/models/tracking_model.py deleted file mode 100644 index a757d89d9..000000000 --- a/cellacdc/models/tracking_model.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Qt-free model rules for tracking metadata.""" - -from __future__ import annotations - -from cellacdc.domain.tracking import ( - FutureIdPropagationScan, - LostNewIdsResult, - TrackedLostIdsResult, - compute_lost_new_ids, - last_tracked_frame_index, - scan_future_id_propagation, - tracked_lost_centroids_from_regionprops, - tracked_lost_ids_from_centroids, -) - - -class TrackingModel: - """Headless tracking state calculations.""" - - def compute_lost_new_ids( - self, - previous_ids, - current_ids, - *, - current_deleted_roi_ids=(), - previous_deleted_roi_ids=(), - tracked_lost_ids=(), - ) -> LostNewIdsResult: - return compute_lost_new_ids( - previous_ids, - current_ids, - current_deleted_roi_ids=current_deleted_roi_ids, - previous_deleted_roi_ids=previous_deleted_roi_ids, - tracked_lost_ids=tracked_lost_ids, - ) - - def tracked_lost_centroids_from_regionprops( - self, - regionprops, - tracked_lost_ids, - ) -> set[tuple[int, ...]]: - return tracked_lost_centroids_from_regionprops( - regionprops, - tracked_lost_ids, - ) - - def tracked_lost_ids_from_centroids( - self, - previous_labels, - tracked_lost_centroids, - ids_in_frame, - ) -> TrackedLostIdsResult: - return tracked_lost_ids_from_centroids( - previous_labels, - tracked_lost_centroids, - ids_in_frame, - ) - - def last_tracked_frame_index( - self, - frame_labels, - *, - first_frame_fallback: int = 0, - total_frames: int | None = None, - ) -> int: - return last_tracked_frame_index( - frame_labels, - first_frame_fallback=first_frame_fallback, - total_frames=total_frames, - ) - - def scan_future_id_propagation( - self, - target_id: int, - *, - current_frame_i: int, - frame_labels, - fallback_frame_labels, - include_unvisited: bool = False, - total_frames: int | None = None, - ) -> FutureIdPropagationScan: - return scan_future_id_propagation( - target_id, - current_frame_i=current_frame_i, - frame_labels=frame_labels, - fallback_frame_labels=fallback_frame_labels, - include_unvisited=include_unvisited, - total_frames=total_frames, - ) diff --git a/cellacdc/models/undo_redo_model.py b/cellacdc/models/undo_redo_model.py deleted file mode 100644 index e9d5ba63a..000000000 --- a/cellacdc/models/undo_redo_model.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Scriptable model rules for undo and redo stacks.""" - -from __future__ import annotations - -from collections import defaultdict - - -class UndoRedoModel: - """Headless undo/redo stack operations.""" - - def empty_frame_stacks(self, size_t: int) -> list[list]: - return [[] for _ in range(size_t)] - - def empty_add_point_queue(self): - return defaultdict(list) - - def trim_stack(self, states: list, *, max_size: int) -> None: - if len(states) > max_size: - states.pop(-1) - - def can_undo_labels(self, undo_count: int, states: list) -> bool: - return undo_count < len(states) - 1 - - def can_redo_labels(self, undo_count: int) -> bool: - return undo_count > 0 - - def should_disable_undo_after_cca( - self, - undo_count: int, - states: list, - ) -> bool: - return len(states) > undo_count diff --git a/cellacdc/models/whitelist_model.py b/cellacdc/models/whitelist_model.py deleted file mode 100644 index 200630f52..000000000 --- a/cellacdc/models/whitelist_model.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Scriptable model rules for the Whitelist feature.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Set, List -import numpy as np - - -@dataclass(frozen=True) -class WhitelistModel: - """Headless decisions and calculations for Whitelist management.""" - - def filter_existing_ids(self, current_whitelist: Set[int], possible_ids: Set[int]) -> tuple[Set[int], bool]: - """Filters out non-existing IDs from the current whitelist. - - Returns a tuple: (filtered_whitelist, is_any_id_non_existing) - """ - is_any_id_non_existing = False - filtered_whitelist = set(current_whitelist) - for ID in current_whitelist: - if ID not in possible_ids: - is_any_id_non_existing = True - filtered_whitelist.discard(ID) - return filtered_whitelist, is_any_id_non_existing - - def get_missing_ids(self, current_ids: Set[int], previous_ids: Set[int]) -> Set[int]: - """Returns the set of IDs present in current frame but missing from previous frame.""" - return set(current_ids) - set(previous_ids) - - def check_original_labels(self, whitelist_obj, frame_i: int) -> bool: - """Checks if original label data is allocated and valid for the frame.""" - if whitelist_obj is None: - return False - if whitelist_obj.originalLabsIDs is None: - return False - if frame_i >= len(whitelist_obj.originalLabsIDs) or whitelist_obj.originalLabsIDs[frame_i] is None: - return False - return True - - def get_frames_range(self, frame_i: int) -> list[int]: - """Calculates navigation frame ranges for label loading.""" - if frame_i > 0: - return [frame_i - 1, frame_i] - return [frame_i] - - def get_diff_ids(self, old_ids: Set[int], prev_ids: Set[int], new_ids: Set[int]) -> Set[int]: - """Computes tracking difference intersection (new_ids - old_ids) & prev_ids.""" - return (new_ids - old_ids) & prev_ids - - def get_whitelist_missing_and_removed_ids(self, whitelist: Set[int], current_ids: Set[int]) -> tuple[list[int], list[int]]: - """Finds IDs that are missing from current_ids and IDs to be removed from current_ids.""" - missing_ids = list(whitelist - current_ids) - to_be_removed_ids = list(current_ids - whitelist) - return missing_ids, to_be_removed_ids - - def apply_id_mask( - self, - curr_lab: np.ndarray, - og_lab: np.ndarray | None, - missing_ids: list[int] | np.ndarray, - to_be_removed_ids: list[int] | np.ndarray - ) -> np.ndarray: - """Applies missing and removed ID masks to the label array.""" - updated_lab = curr_lab.copy().astype(np.int32) - missing_ids = np.array(missing_ids, dtype=np.int32) - to_be_removed_ids = np.array(to_be_removed_ids, dtype=np.int32) - - if missing_ids.size > 0 and og_lab is not None: - mask = np.isin(og_lab, missing_ids) - updated_lab[mask] = og_lab[mask] - - if to_be_removed_ids.size > 0: - updated_lab[np.isin(updated_lab, to_be_removed_ids)] = 0 - - return updated_lab - - def construct_og_frame( - self, - pos_lab: np.ndarray, - og_frame_base: np.ndarray, - whitelist_ids: Set[int], - og_ids: Set[int] - ) -> np.ndarray: - """Constructs original labels overlay using np.isin masking.""" - og_frame = og_frame_base.copy() - - ids_to_update = whitelist_ids & og_ids - if ids_to_update: - mask = np.isin(og_frame, list(ids_to_update)) - og_frame[mask] = 0 - mask = np.isin(pos_lab, list(ids_to_update)) - og_frame[mask] = pos_lab[mask] - - ids_to_add = whitelist_ids - og_ids - if ids_to_add: - mask = np.isin(pos_lab, list(ids_to_add)) - og_frame[mask] = pos_lab[mask] - - return og_frame diff --git a/cellacdc/models/window_events_model.py b/cellacdc/models/window_events_model.py deleted file mode 100644 index 8e7e0be64..000000000 --- a/cellacdc/models/window_events_model.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Qt-free model rules for main-window event handling.""" - -from __future__ import annotations - - -class WindowEventsModel: - """Headless placeholder for main-window event rules.""" diff --git a/cellacdc/models/worker_model.py b/cellacdc/models/worker_model.py deleted file mode 100644 index 7afe4e395..000000000 --- a/cellacdc/models/worker_model.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Scriptable model rules for GUI worker lifecycle handling.""" - -from __future__ import annotations - - -class WorkerModel: - """Headless worker progress and lifecycle decisions.""" - - def progress_log_level(self, logger_level: str = 'INFO') -> str: - return logger_level or 'INFO' - - def progressbar_maximum(self, total_iterations: int) -> int: - if total_iterations == 1: - return 0 - return total_iterations - - def lazy_loader_progress_description(self, chunk_range) -> str: - coord0_chunk, coord1_chunk = chunk_range - return ( - f'Loading new window, range = ({coord0_chunk}, {coord1_chunk})...' - ) - - def should_enqueue_autosave(self, is_saving: bool) -> bool: - return not is_saving - - def should_disable_realtime_tracking( - self, - tracking_on_never_visited_frames: bool, - realtime_tracking_enabled: bool, - ) -> bool: - return ( - tracking_on_never_visited_frames - and realtime_tracking_enabled - ) diff --git a/cellacdc/viewmodels/__init__.py b/cellacdc/viewmodels/__init__.py index 0c1074c92..30c017aaf 100644 --- a/cellacdc/viewmodels/__init__.py +++ b/cellacdc/viewmodels/__init__.py @@ -30,110 +30,37 @@ ) from cellacdc.domain.visited_frames import LastVisitedFrameUpdate -from .app_shell_viewmodel import AppShellViewModel -from .actions_viewmodel import ActionsViewModel -from .annotation_display_viewmodel import AnnotationDisplayViewModel -from .brush_tools_viewmodel import BrushToolsViewModel -from .canvas_context_menu_viewmodel import CanvasContextMenuViewModel -from .canvas_drawing_viewmodel import CanvasDrawingViewModel -from .canvas_events_viewmodel import CanvasEventsViewModel -from .canvas_hover_viewmodel import CanvasHoverViewModel -from .canvas_right_image_viewmodel import CanvasRightImageViewModel -from .canvas_selection_viewmodel import CanvasSelectionViewModel -from .canvas_tool_viewmodel import CanvasToolViewModel -from .cell_cycle_viewmodel import CellCycleViewModel from .cca_edits_viewmodel import CcaEditViewModel, CcaFrameEditResult from .cca_workflows_viewmodel import CcaWorkflowViewModel -from .curvature_viewmodel import CurvatureViewModel -from .custom_annotations_viewmodel import CustomAnnotationsViewModel -from .data_loading_viewmodel import DataLoadingViewModel -from .deleted_rois_viewmodel import DeletedRoisViewModel -from .display_decorations_viewmodel import DisplayDecorationsViewModel -from .draw_clear_region_viewmodel import DrawClearRegionViewModel from .edit_id_viewmodel import EditIdViewModel -from .exporting_viewmodel import ExportingViewModel from .frame_metadata_viewmodel import FrameMetadataViewModel -from .frame_navigation_viewmodel import FrameNavigationViewModel from .formatting_viewmodel import FormattingViewModel from .geometry_viewmodel import GeometryViewModel -from .graphics_viewmodel import GraphicsViewModel -from .image_controls_viewmodel import ImageControlsViewModel -from .image_display_viewmodel import ImageDisplayViewModel -from .label_editing_viewmodel import LabelEditingViewModel from .label_edits_viewmodel import LabelEditViewModel -from .label_roi_viewmodel import LabelRoiViewModel -from .label_transform_tools_viewmodel import LabelTransformToolsViewModel -from .layout_controls_viewmodel import LayoutControlsViewModel from .lineage_viewmodel import LineageViewModel -from .lineage_interactions_viewmodel import LineageInteractionsViewModel -from .magic_prompts_viewmodel import MagicPromptsViewModel -from .main_menu_viewmodel import MainMenuViewModel -from .main_toolbar_viewmodel import MainToolbarViewModel from .main_viewmodel import MainGuiViewModel -from .measurements_viewmodel import MeasurementsViewModel -from .mode_controls_viewmodel import ModeControlsViewModel from .model_registry_viewmodel import ModelRegistryViewModel from .object_counts_viewmodel import ObjectCountViewModel -from .object_cleanup_viewmodel import ObjectCleanupViewModel -from .object_properties_viewmodel import ObjectPropertiesViewModel -from .object_search_viewmodel import ObjectSearchViewModel from .points_viewmodel import PointsViewModel -from .points_layers_viewmodel import PointsLayersViewModel -from .preprocessing_viewmodel import PreprocessingViewModel -from .quick_settings_viewmodel import QuickSettingsViewModel -from .saving_viewmodel import SavingViewModel -from .seg_for_lost_ids_viewmodel import SegForLostIdsViewModel -from .segmentation_viewmodel import SegmentationViewModel -from .session_viewmodel import SessionViewModel -from .status_hover_viewmodel import StatusHoverViewModel from .tables_viewmodel import TableViewModel -from .tool_activation_viewmodel import ToolActivationViewModel -from .tracking_viewmodel import TrackingViewModel -from .undo_redo_viewmodel import UndoRedoViewModel -from .worker_viewmodel import WorkerViewModel -from .window_events_viewmodel import WindowEventsViewModel from .workspace_viewmodel import WorkspaceViewModel __all__ = [ 'AcdcFrameMetadataResult', - 'ActionsViewModel', - 'AnnotationDisplayViewModel', - 'AppShellViewModel', - 'BrushToolsViewModel', - 'CanvasContextMenuViewModel', - 'CanvasDrawingViewModel', - 'CanvasEventsViewModel', - 'CanvasHoverViewModel', - 'CanvasRightImageViewModel', - 'CanvasSelectionViewModel', - 'CanvasToolViewModel', - 'CellCycleViewModel', 'CcaEditViewModel', 'CcaFrameEditResult', 'CcaWorkflowViewModel', 'CurvatureLabelPaintResult', - 'CurvatureViewModel', 'CustomAnnotationColumnResult', 'CustomAnnotationFrameUpdate', - 'CustomAnnotationsViewModel', - 'DataLoadingViewModel', - 'DeletedRoisViewModel', 'DeletedRoiApplyResult', 'DeletedRoiRestoreResult', - 'DisplayDecorationsViewModel', - 'DrawClearRegionViewModel', 'EditIdViewModel', - 'ExportingViewModel', 'FrameMetadataViewModel', - 'FrameNavigationViewModel', 'FormattingViewModel', 'GeometryViewModel', - 'GraphicsViewModel', - 'ImageControlsViewModel', - 'ImageDisplayViewModel', 'FutureIdPropagationScan', 'LabelBorderClearResult', - 'LabelEditingViewModel', 'LabelHoleFillResult', 'LabelEditViewModel', 'LabelIdMappingResult', @@ -142,42 +69,17 @@ 'LabelRegionSelectionResult', 'LabelResizeResult', 'LabelRoiIndexResult', - 'LabelRoiViewModel', - 'LabelTransformToolsViewModel', - 'LayoutControlsViewModel', 'LastVisitedFrameUpdate', 'LineageAnnotationsRemovalResult', 'LineageFutureRemovalResult', - 'LineageInteractionsViewModel', 'LineageViewModel', - 'MagicPromptsViewModel', - 'MainMenuViewModel', - 'MainToolbarViewModel', 'LostNewIdsResult', 'MainGuiViewModel', 'ManualEditTrackingResult', - 'MeasurementsViewModel', - 'ModeControlsViewModel', 'ModelRegistryViewModel', 'ObjectCountViewModel', - 'ObjectCleanupViewModel', - 'ObjectPropertiesViewModel', - 'ObjectSearchViewModel', 'PointsViewModel', - 'PointsLayersViewModel', - 'PreprocessingViewModel', - 'QuickSettingsViewModel', - 'SavingViewModel', - 'SegForLostIdsViewModel', - 'SegmentationViewModel', - 'SessionViewModel', - 'StatusHoverViewModel', 'TableViewModel', - 'ToolActivationViewModel', 'TrackedLostIdsResult', - 'TrackingViewModel', - 'UndoRedoViewModel', - 'WorkerViewModel', - 'WindowEventsViewModel', 'WorkspaceViewModel', ] diff --git a/cellacdc/viewmodels/actions_viewmodel.py b/cellacdc/viewmodels/actions_viewmodel.py deleted file mode 100644 index bdee91845..000000000 --- a/cellacdc/viewmodels/actions_viewmodel.py +++ /dev/null @@ -1,56 +0,0 @@ -"""View-model contracts for GUI actions and shortcuts.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.actions_model import ActionsModel - -from .app_shell_viewmodel import AppShellViewModel -from .model_registry_viewmodel import ModelRegistryViewModel - - -@dataclass(frozen=True) -class ActionsViewModel: - """Application-facing actions and shortcut decisions.""" - - model: ActionsModel = field(default_factory=ActionsModel) - app_shell: AppShellViewModel = field(default_factory=AppShellViewModel) - model_registry: ModelRegistryViewModel = field( - default_factory=ModelRegistryViewModel - ) - - @property - def keyboard_shortcuts_section(self) -> str: - return self.model.keyboard_shortcuts_section - - @property - def delete_object_section(self) -> str: - return self.model.delete_object_section - - @property - def delete_key_option(self) -> str: - return self.model.delete_key_option - - @property - def delete_button_option(self) -> str: - return self.model.delete_button_option - - def default_delete_object_texts(self, *, is_mac: bool) -> tuple[str, str]: - return self.model.default_delete_object_texts(is_mac=is_mac) - - def sanitize_key_sequence_text(self, text) -> str: - return self.model.sanitize_key_sequence_text(text) - - def delete_object_button_text(self, *, is_left_click: bool) -> str: - return self.model.delete_object_button_text( - is_left_click=is_left_click - ) - - def delete_object_button_is_left_click(self, text: str) -> bool: - return self.model.delete_object_button_is_left_click(text) - - def should_restore_default_delete_action(self, *, had_error: bool) -> bool: - return self.model.should_restore_default_delete_action( - had_error=had_error - ) diff --git a/cellacdc/viewmodels/annotation_display_viewmodel.py b/cellacdc/viewmodels/annotation_display_viewmodel.py deleted file mode 100644 index 17ba0ebbe..000000000 --- a/cellacdc/viewmodels/annotation_display_viewmodel.py +++ /dev/null @@ -1,448 +0,0 @@ -"""View-model contracts for annotation display workflows.""" - -from __future__ import annotations - -from typing import Mapping - -try: - from qtpy.QtCore import QObject, Signal -except ModuleNotFoundError: # pragma: no cover - exercised without GUI extras - class QObject: - def __init__(self, *args, **kwargs) -> None: - super().__init__() - - class _FallbackBoundSignal: - def __init__(self) -> None: - self._slots = [] - - def connect(self, slot) -> None: - self._slots.append(slot) - - def emit(self, *args) -> None: - for slot in tuple(self._slots): - slot(*args) - - class Signal: - def __init__(self, *args) -> None: - self._name = '' - - def __set_name__(self, owner, name) -> None: - self._name = f'__signal_{name}' - - def __get__(self, instance, owner): - if instance is None: - return self - return instance.__dict__.setdefault( - self._name, - _FallbackBoundSignal(), - ) - -from cellacdc.models.annotation_display_model import ( - AnnotationModeChangePlan, - AnnotationDisplaySettingsRestorePlan, - AnnotationOption, - AnnotationOptionChangePlan, - AnnotationOptionsFromModeTextPlan, - AnnotationOptionState, - AnnotationDisplayModel, - AnnotationSide, - PixelModeChangePlan, - TextResolutionChangePlan, - TreeAnnotationInfoModePlan, - Visible3DSegmentationWidgetsPlan, - ZDepthAnnotationOptionsPlan, - ZNeighborHighlightCheckboxPlan, -) - -from .custom_annotations_viewmodel import CustomAnnotationsViewModel -from .edit_id_viewmodel import EditIdViewModel -from .geometry_viewmodel import GeometryViewModel -from .label_edits_viewmodel import LabelEditViewModel -from .lineage_viewmodel import LineageViewModel -from .model_registry_viewmodel import ModelRegistryViewModel - - -class AnnotationDisplayViewModel(QObject): - """Application-facing annotation display decisions and commands.""" - - settingUpdateRequested = Signal(str, object) - textAnnotationFlagsChanged = Signal(int, bool, bool) - imageRefreshRequested = Signal() - eraserTempResetRequested = Signal() - annotationOptionStatesChanged = Signal(str, object) - annotationModeTextUpdateRequested = Signal(str, str, bool) - textAnnotationPixelModeChanged = Signal(bool) - logInfoRequested = Signal(str) - pixelModeActionDisabledChanged = Signal(bool) - textResolutionChangeRequested = Signal(str) - treeAnnotationMenuActionRequested = Signal(str, str, bool, bool) - labelTreeAnnotationsEnabledChanged = Signal(bool) - genNumTreeAnnotationsEnabledChanged = Signal(bool) - allTextAnnotationsRefreshRequested = Signal() - annotationOptionDisabledChanged = Signal(str, str, bool) - annotationOptionVisibleChanged = Signal(str, str, bool) - annotationOptionCheckedChanged = Signal(str, str, bool) - zNeighborHighlightVisibleChanged = Signal(bool) - zNeighborHighlightCheckedChanged = Signal(bool) - zNeighborHighlightToggleConnectionRequested = Signal() - annotationModeComboboxRestoreRequested = Signal(str, str) - addNewIdsWhitelistToggleChanged = Signal(bool) - annotationModeRestoreCallbackRequested = Signal(str) - - def __init__( - self, - model: AnnotationDisplayModel | None = None, - custom_annotations: CustomAnnotationsViewModel | None = None, - edit_id: EditIdViewModel | None = None, - geometry: GeometryViewModel | None = None, - label_edits: LabelEditViewModel | None = None, - lineage: LineageViewModel | None = None, - model_registry: ModelRegistryViewModel | None = None, - ) -> None: - super().__init__() - self.model = model or AnnotationDisplayModel() - self.custom_annotations = ( - custom_annotations or CustomAnnotationsViewModel() - ) - self.edit_id = edit_id or EditIdViewModel() - self.geometry = geometry or GeometryViewModel() - self.label_edits = label_edits or LabelEditViewModel() - self.lineage = lineage or LineageViewModel() - self.model_registry = model_registry or ModelRegistryViewModel() - - def right_annotation_mode(self, **kwargs) -> str: - return self.model.right_annotation_mode(**kwargs) - - def text_annotation_flags(self, **kwargs) -> tuple[bool, bool]: - return self.model.text_annotation_flags(**kwargs) - - def annotation_mode_text(self, **kwargs) -> str: - return self.model.annotation_mode_text(**kwargs) - - def annotation_flags_from_mode_text(self, text: str) -> dict[str, bool]: - return self.model.annotation_flags_from_mode_text(text) - - def annotation_option_state_from_mode_text( - self, - text: str, - *, - num_zslices: bool = False, - ) -> AnnotationOptionState: - return self.model.annotation_option_state_from_mode_text( - text, - num_zslices=num_zslices, - ) - - def contours_requested(self, **kwargs) -> bool: - return self.model.contours_requested(**kwargs) - - def moth_bud_lines_requested(self, **kwargs) -> bool: - return self.model.moth_bud_lines_requested(**kwargs) - - def should_draw_moth_bud_line(self, **kwargs) -> bool: - return self.model.should_draw_moth_bud_line(**kwargs) - - def should_draw_lineage_tree_lines(self, **kwargs) -> bool: - return self.model.should_draw_lineage_tree_lines(**kwargs) - - def annotation_mode_setting_update( - self, - side: AnnotationSide, - how: str, - ) -> tuple[str, str]: - return self.model.annotation_mode_setting_update(side, how) - - def change_annotation_mode( - self, - *, - side: AnnotationSide, - how: str, - save_settings: bool, - annot_cca_checked: bool, - annot_ids_checked: bool, - mode: str, - is_data_loading: bool, - eraser_checked: bool = False, - ) -> AnnotationModeChangePlan: - plan = self.model.annotation_mode_change_plan( - side=side, - how=how, - save_settings=save_settings, - annot_cca_checked=annot_cca_checked, - annot_ids_checked=annot_ids_checked, - mode=mode, - is_data_loading=is_data_loading, - eraser_checked=eraser_checked, - ) - if plan.setting_update is not None: - setting, value = plan.setting_update - self.settingUpdateRequested.emit(setting, value) - self.textAnnotationFlagsChanged.emit( - plan.text_annotation_index, - plan.is_cca_annotation, - plan.is_id_annotation, - ) - if plan.should_refresh_images: - self.imageRefreshRequested.emit() - if plan.should_reset_eraser_temp: - self.eraserTempResetRequested.emit() - return plan - - def change_annotation_options( - self, - *, - side: AnnotationSide, - clicked_option: AnnotationOption | None, - save_settings: bool, - ids: bool, - cca: bool, - contours: bool, - segm_masks: bool, - mother_bud_lines: bool, - num_zslices: bool, - nothing: bool, - ) -> AnnotationOptionChangePlan: - plan = self.model.annotation_option_change_plan( - side=side, - clicked_option=clicked_option, - save_settings=save_settings, - state=AnnotationOptionState( - ids=ids, - cca=cca, - contours=contours, - segm_masks=segm_masks, - mother_bud_lines=mother_bud_lines, - num_zslices=num_zslices, - nothing=nothing, - ), - ) - self.annotationOptionStatesChanged.emit(side, plan.state) - self.annotationModeTextUpdateRequested.emit( - side, - plan.mode_text, - plan.save_settings, - ) - return plan - - def refresh_annotation_mode_text( - self, - *, - side: AnnotationSide, - save_settings: bool, - ids: bool, - cca: bool, - contours: bool, - segm_masks: bool, - mother_bud_lines: bool, - nothing: bool, - ) -> str: - mode_text = self.model.annotation_mode_text( - ids=ids, - cca=cca, - contours=contours, - segm_masks=segm_masks, - mother_bud_lines=mother_bud_lines, - nothing=nothing, - ) - self.annotationModeTextUpdateRequested.emit( - side, - mode_text, - save_settings, - ) - return mode_text - - def sync_annotation_options_from_mode_text( - self, - *, - left_text: str, - right_text: str, - left_num_zslices: bool = False, - right_num_zslices: bool = False, - ) -> AnnotationOptionsFromModeTextPlan: - plan = self.model.annotation_options_from_mode_text_plan( - left_text=left_text, - right_text=right_text, - left_num_zslices=left_num_zslices, - right_num_zslices=right_num_zslices, - ) - for side, state in plan.state_updates: - self.annotationOptionStatesChanged.emit(side, state) - return plan - - def restore_saved_settings( - self, - *, - settings_values: Mapping[str, object], - left_num_zslices: bool = False, - right_num_zslices: bool = False, - ) -> AnnotationDisplaySettingsRestorePlan: - plan = self.model.restore_saved_settings_plan(settings_values) - self.annotationModeComboboxRestoreRequested.emit( - 'left', - plan.left_mode, - ) - self.annotationModeComboboxRestoreRequested.emit( - 'right', - plan.right_mode, - ) - self.addNewIdsWhitelistToggleChanged.emit( - plan.add_new_ids_whitelist_toggle - ) - self.sync_annotation_options_from_mode_text( - left_text=plan.left_mode, - right_text=plan.right_mode, - left_num_zslices=left_num_zslices, - right_num_zslices=right_num_zslices, - ) - self.annotationModeRestoreCallbackRequested.emit('left') - self.annotationModeRestoreCallbackRequested.emit('right') - return plan - - def pixel_mode_setting_value(self, checked: bool) -> int: - return self.model.pixel_mode_setting_value(checked) - - def change_pixel_mode( - self, - *, - checked: bool, - is_data_loaded: bool, - high_resolution: bool, - ) -> PixelModeChangePlan: - plan = self.model.pixel_mode_change_plan( - checked=checked, - is_data_loaded=is_data_loaded, - high_resolution=high_resolution, - ) - setting, value = plan.setting_update - self.settingUpdateRequested.emit(setting, value) - if plan.should_update_text_pixel_mode: - self.textAnnotationPixelModeChanged.emit(checked) - if plan.should_refresh_images: - self.imageRefreshRequested.emit() - return plan - - def change_text_resolution( - self, - *, - high_resolution: bool, - is_data_loaded: bool, - ) -> TextResolutionChangePlan: - plan = self.model.text_resolution_change_plan( - high_resolution=high_resolution, - is_data_loaded=is_data_loaded, - ) - self.logInfoRequested.emit(plan.log_message) - self.pixelModeActionDisabledChanged.emit(plan.pixel_mode_disabled) - if plan.should_update_annotations: - self.textResolutionChangeRequested.emit(plan.mode) - if plan.should_refresh_images: - self.imageRefreshRequested.emit() - return plan - - def change_label_tree_annotations(self, checked: bool) -> None: - self.labelTreeAnnotationsEnabledChanged.emit(checked) - - def change_gen_num_tree_annotations(self, checked: bool) -> None: - self.genNumTreeAnnotationsEnabledChanged.emit(checked) - - def change_tree_annotation_info_mode( - self, - checked: bool, - ) -> TreeAnnotationInfoModePlan: - plan = self.model.tree_annotation_info_mode_plan(checked) - self.treeAnnotationMenuActionRequested.emit( - 'id', - plan.action_text_contains, - plan.enabled, - plan.action_checked, - ) - self.treeAnnotationMenuActionRequested.emit( - 'gen_num', - plan.action_text_contains, - plan.enabled, - plan.action_checked, - ) - self.labelTreeAnnotationsEnabledChanged.emit( - plan.label_tree_annotations_enabled - ) - self.genNumTreeAnnotationsEnabledChanged.emit( - plan.gen_num_tree_annotations_enabled - ) - if plan.should_refresh_annotations: - self.allTextAnnotationsRefreshRequested.emit() - return plan - - def enable_z_depth_annotation_options( - self, - *, - is_3d: bool, - ids: bool, - cca: bool, - contours: bool, - segm_masks: bool, - mother_bud_lines: bool, - num_zslices: bool, - nothing: bool, - ) -> ZDepthAnnotationOptionsPlan: - plan = self.model.z_depth_annotation_options_plan( - is_3d=is_3d, - state=AnnotationOptionState( - ids=ids, - cca=cca, - contours=contours, - segm_masks=segm_masks, - mother_bud_lines=mother_bud_lines, - num_zslices=num_zslices, - nothing=nothing, - ), - ) - if not plan.should_apply: - return plan - - for option, disabled in plan.disabled_updates: - self.annotationOptionDisabledChanged.emit( - 'left', - option, - disabled, - ) - self.annotationOptionStatesChanged.emit('left', plan.state) - option_plan = self.model.annotation_option_change_plan( - side='left', - state=plan.state, - clicked_option=plan.clicked_option, - save_settings=plan.save_settings, - ) - self.annotationOptionStatesChanged.emit('left', option_plan.state) - self.annotationModeTextUpdateRequested.emit( - 'left', - option_plan.mode_text, - option_plan.save_settings, - ) - return plan - - def update_visible_3d_segmentation_widgets( - self, - *, - is_3d: bool, - ) -> Visible3DSegmentationWidgetsPlan: - plan = self.model.visible_3d_segmentation_widgets_plan(is_3d=is_3d) - for side, option, visible in plan.visible_updates: - self.annotationOptionVisibleChanged.emit(side, option, visible) - for side, option, checked in plan.checked_updates: - self.annotationOptionCheckedChanged.emit(side, option, checked) - return plan - - def update_z_neighbor_highlight_checkbox( - self, - *, - is_3d: bool, - ) -> ZNeighborHighlightCheckboxPlan: - plan = self.model.z_neighbor_highlight_checkbox_plan(is_3d=is_3d) - if not plan.should_apply: - return plan - - self.zNeighborHighlightVisibleChanged.emit(plan.visible) - self.zNeighborHighlightCheckedChanged.emit(plan.checked) - if plan.should_connect_toggle: - self.zNeighborHighlightToggleConnectionRequested.emit() - return plan diff --git a/cellacdc/viewmodels/app_shell_viewmodel.py b/cellacdc/viewmodels/app_shell_viewmodel.py deleted file mode 100644 index 958b74ffb..000000000 --- a/cellacdc/viewmodels/app_shell_viewmodel.py +++ /dev/null @@ -1,29 +0,0 @@ -"""View-model contracts for application shell services.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.app_shell_model import AppShellModel - - -@dataclass(frozen=True) -class AppShellViewModel: - """Application-facing commands for app metadata and help services.""" - - model: AppShellModel = field(default_factory=AppShellModel) - - def read_version(self) -> str: - return self.model.read_version() - - def tooltips_from_docs(self) -> dict: - return self.model.tooltips_from_docs() - - def browse_docs(self): - return self.model.browse_docs() - - def show_in_file_manager(self, path: str): - return self.model.show_in_file_manager(path) - - def rename_qrc_resources_file(self, color_scheme: str): - return self.model.rename_qrc_resources_file(color_scheme) diff --git a/cellacdc/viewmodels/brush_tools_viewmodel.py b/cellacdc/viewmodels/brush_tools_viewmodel.py deleted file mode 100644 index 52ff986c6..000000000 --- a/cellacdc/viewmodels/brush_tools_viewmodel.py +++ /dev/null @@ -1,87 +0,0 @@ -"""View-model contracts for brush and eraser tools.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -from cellacdc.models.brush_tools_model import BrushToolsModel - - -@dataclass(frozen=True) -class BrushToolsViewModel: - """Application-facing brush/eraser decisions.""" - - model: BrushToolsModel = field(default_factory=BrushToolsModel) - - def checked_setting_value(self, checked: bool) -> str: - return self.model.checked_setting_value(checked) - - def default_delete_object_info_value(self) -> str: - return self.model.default_delete_object_info_value() - - def should_show_delete_object_info(self, setting_value: Any) -> bool: - return self.model.should_show_delete_object_info(setting_value) - - def delete_object_info_value( - self, - do_not_show_again_checked: bool, - ) -> str: - return self.model.delete_object_info_value(do_not_show_again_checked) - - def should_fill_holes( - self, - sender: str, - *, - auto_fill_checked: bool, - ) -> bool: - return self.model.should_fill_holes( - sender, - auto_fill_checked=auto_fill_checked, - ) - - def brush_toolbar_visible( - self, - edit_id_visible: bool, - *, - brush_size_visible: bool, - auto_fill_visible: bool, - auto_hide_visible: bool, - ) -> bool: - return self.model.brush_toolbar_visible( - edit_id_visible, - brush_size_visible=brush_size_visible, - auto_fill_visible=auto_fill_visible, - auto_hide_visible=auto_hide_visible, - ) - - def disk_mask(self, brush_size: int): - return self.model.disk_mask(brush_size) - - def disk_mask_bounds( - self, - image_shape: tuple[int, int], - brush_size: int, - xdata: int, - ydata: int, - disk_mask, - ): - return self.model.disk_mask_bounds( - image_shape, - brush_size, - xdata, - ydata, - disk_mask, - ) - - def magic_wand_flood_tolerance( - self, - tolerance_percent: float, - image_min: float, - image_max: float, - ): - return self.model.magic_wand_flood_tolerance( - tolerance_percent, - image_min, - image_max, - ) diff --git a/cellacdc/viewmodels/canvas_context_menu_viewmodel.py b/cellacdc/viewmodels/canvas_context_menu_viewmodel.py deleted file mode 100644 index 68ce07ad0..000000000 --- a/cellacdc/viewmodels/canvas_context_menu_viewmodel.py +++ /dev/null @@ -1,51 +0,0 @@ -"""View-model contracts for canvas context menus.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.canvas_context_menu_model import ( - CanvasContextMenuModel, - DeletedRoiClickDecision, -) - - -@dataclass(frozen=True) -class CanvasContextMenuViewModel: - """Application-facing canvas context-menu commands.""" - - model: CanvasContextMenuModel = field( - default_factory=CanvasContextMenuModel - ) - - @property - def scale_bar_target(self) -> str: - return self.model.scale_bar_target - - @property - def timestamp_target(self) -> str: - return self.model.timestamp_target - - def image_gradient_menu_target( - self, - *, - scale_bar_highlighted: bool, - timestamp_highlighted: bool, - ) -> str: - return self.model.image_gradient_menu_target( - scale_bar_highlighted=scale_bar_highlighted, - timestamp_highlighted=timestamp_highlighted, - ) - - def deleted_roi_click_decision( - self, - *, - clicked_on_roi: bool, - left_click: bool, - right_click: bool, - ) -> DeletedRoiClickDecision: - return self.model.deleted_roi_click_decision( - clicked_on_roi=clicked_on_roi, - left_click=left_click, - right_click=right_click, - ) diff --git a/cellacdc/viewmodels/canvas_drawing_viewmodel.py b/cellacdc/viewmodels/canvas_drawing_viewmodel.py deleted file mode 100644 index 8dcdcc72d..000000000 --- a/cellacdc/viewmodels/canvas_drawing_viewmodel.py +++ /dev/null @@ -1,60 +0,0 @@ -"""View-model contracts for canvas drawing interactions.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -import numpy as np - -from cellacdc.models.canvas_drawing_model import CanvasDrawingModel - -from .geometry_viewmodel import GeometryViewModel -from .label_edits_viewmodel import LabelEditViewModel - - -@dataclass(frozen=True) -class CanvasDrawingViewModel: - """Application-facing canvas drawing decisions and transforms.""" - - model: CanvasDrawingModel = field(default_factory=CanvasDrawingModel) - geometry: GeometryViewModel = field(default_factory=GeometryViewModel) - label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) - - def is_viewer_mode(self, mode: str) -> bool: - return mode == self.model.viewer_mode - - def is_in_bounds(self, x: int, y: int, width: int, height: int) -> bool: - return self.geometry.is_in_bounds(x, y, width, height) - - def should_process_canvas_event(self, *, mode: str, in_bounds: bool) -> bool: - return self.model.should_process_canvas_event( - mode=mode, - in_bounds=in_bounds, - ) - - def should_clear_after_out_of_bounds(self, *, image: str) -> bool: - return self.model.should_clear_after_out_of_bounds(image=image) - - def binary_fill_holes(self, mask): - return self.label_edits.binary_fill_holes(mask) - - def convex_hull_mask(self, mask): - return self.label_edits.convex_hull_mask(mask) - - def nearest_nonzero_2d(self, labels, y, x): - return self.label_edits.nearest_nonzero_2d(labels, y, x) - - def calculate_brush_mask( - self, - image_shape: tuple[int, int], - ymin: int, - xmin: int, - ymax: int, - xmax: int, - disk_mask: np.ndarray, - rr_poly: np.ndarray | None = None, - cc_poly: np.ndarray | None = None, - ) -> np.ndarray: - return self.model.calculate_brush_mask( - image_shape, ymin, xmin, ymax, xmax, disk_mask, rr_poly, cc_poly - ) - diff --git a/cellacdc/viewmodels/canvas_events_viewmodel.py b/cellacdc/viewmodels/canvas_events_viewmodel.py deleted file mode 100644 index 72fc04d2a..000000000 --- a/cellacdc/viewmodels/canvas_events_viewmodel.py +++ /dev/null @@ -1,55 +0,0 @@ -"""View-model behavior for canvas event routing.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.canvas_events_model import CanvasEventsModel - -from .geometry_viewmodel import GeometryViewModel -from .label_edits_viewmodel import LabelEditViewModel - - -@dataclass(frozen=True) -class CanvasEventsViewModel: - """GUI-facing helpers for canvas event routing.""" - - model: CanvasEventsModel = field(default_factory=CanvasEventsModel) - geometry: GeometryViewModel = field(default_factory=GeometryViewModel) - label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) - - def snap_xy_to_closest_angle(self, x0, y0, x1, y1): - return self.geometry.snap_xy_to_closest_angle(x0, y0, x1, y1) - - def nearest_nonzero_2d(self, labels, y, x): - return self.label_edits.nearest_nonzero_2d(labels, y, x) - - def binary_fill_holes(self, labels): - return self.label_edits.binary_fill_holes(labels) - - def convex_hull_mask(self, labels): - return self.label_edits.convex_hull_mask(labels) - - def calculate_brush_mask( - self, - image_shape: tuple[int, int], - ymin: int, - xmin: int, - ymax: int, - xmax: int, - disk_mask, - rr_poly=None, - cc_poly=None, - ): - return self.model.calculate_brush_mask( - image_shape, ymin, xmin, ymax, xmax, disk_mask, rr_poly, cc_poly - ) - - def map_mouse_coordinates_to_label_id( - self, - mouse_pos: tuple[float, float], - label_matrix, - ) -> int: - return self.model.map_mouse_coordinates_to_label_id( - mouse_pos, label_matrix - ) diff --git a/cellacdc/viewmodels/canvas_hover_viewmodel.py b/cellacdc/viewmodels/canvas_hover_viewmodel.py deleted file mode 100644 index 563d6d3ad..000000000 --- a/cellacdc/viewmodels/canvas_hover_viewmodel.py +++ /dev/null @@ -1,59 +0,0 @@ -"""View-model contracts for canvas hover interactions.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -from cellacdc.models.canvas_hover_model import CanvasHoverModel - - -@dataclass(frozen=True) -class CanvasHoverViewModel: - """Application-facing canvas hover decisions.""" - - model: CanvasHoverModel = field(default_factory=CanvasHoverModel) - - def point_in_bounds( - self, - image_shape: tuple[int, int], - xdata: int, - ydata: int, - ) -> bool: - return self.model.point_in_bounds(image_shape, xdata, ydata) - - def hover_position(self, is_exit: bool, position) -> tuple[Any, Any]: - return self.model.hover_position(is_exit, position) - - def should_set_mirrored_cursor( - self, - *, - override_cursor_is_none: bool, - is_exit: bool, - mirrored_cursor_enabled: bool, - is_hover_img1: bool = True, - ) -> bool: - return self.model.should_set_mirrored_cursor( - override_cursor_is_none=override_cursor_is_none, - is_exit=is_exit, - mirrored_cursor_enabled=mirrored_cursor_enabled, - is_hover_img1=is_hover_img1, - ) - - def should_draw_ruler_line( - self, - *, - ruler_checked: bool, - add_deleted_polyline_checked: bool, - temp_segment_on: bool, - is_exit: bool, - ) -> bool: - return self.model.should_draw_ruler_line( - ruler_checked=ruler_checked, - add_deleted_polyline_checked=add_deleted_polyline_checked, - temp_segment_on=temp_segment_on, - is_exit=is_exit, - ) - - def cursor_flags(self, **kwargs) -> dict[str, bool]: - return self.model.cursor_flags(**kwargs) diff --git a/cellacdc/viewmodels/canvas_right_image_viewmodel.py b/cellacdc/viewmodels/canvas_right_image_viewmodel.py deleted file mode 100644 index cc3afb796..000000000 --- a/cellacdc/viewmodels/canvas_right_image_viewmodel.py +++ /dev/null @@ -1,25 +0,0 @@ -"""View-model contracts for duplicated right-image interactions.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.canvas_right_image_model import CanvasRightImageModel - - -@dataclass(frozen=True) -class CanvasRightImageViewModel: - """Application-facing duplicated right-image commands.""" - - model: CanvasRightImageModel = field(default_factory=CanvasRightImageModel) - - def should_show_context_menu( - self, - *, - right_click: bool, - is_right_click_action_on: bool, - ) -> bool: - return self.model.should_show_context_menu( - right_click=right_click, - is_right_click_action_on=is_right_click_action_on, - ) diff --git a/cellacdc/viewmodels/canvas_selection_viewmodel.py b/cellacdc/viewmodels/canvas_selection_viewmodel.py deleted file mode 100644 index b8006dadd..000000000 --- a/cellacdc/viewmodels/canvas_selection_viewmodel.py +++ /dev/null @@ -1,49 +0,0 @@ -"""View-model behavior for canvas selection interactions.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.canvas_selection_model import CanvasSelectionModel - -from .geometry_viewmodel import GeometryViewModel -from .label_edits_viewmodel import LabelEditViewModel - - -@dataclass(frozen=True) -class CanvasSelectionViewModel: - """GUI-facing canvas selection decisions and transforms.""" - - model: CanvasSelectionModel = field(default_factory=CanvasSelectionModel) - geometry: GeometryViewModel = field(default_factory=GeometryViewModel) - label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) - - def is_in_bounds(self, x: int, y: int, width: int, height: int) -> bool: - return self.geometry.is_in_bounds(x, y, width, height) - - def should_drag_image(self, **kwargs) -> bool: - return self.model.should_drag_image(**kwargs) - - def should_blink_viewer_mode(self, **kwargs) -> bool: - return self.model.should_blink_viewer_mode(**kwargs) - - def should_show_labels_menu(self, **kwargs) -> bool: - return self.model.should_show_labels_menu(**kwargs) - - def can_delete(self, **kwargs) -> bool: - return self.model.can_delete(**kwargs) - - def is_viewer_mode(self, mode: str) -> bool: - return self.model.is_viewer_mode(mode) - - def should_process_release(self, **kwargs) -> bool: - return self.model.should_process_release(**kwargs) - - def nearest_nonzero_2d(self, labels, y, x): - return self.label_edits.nearest_nonzero_2d(labels, y, x) - - def separate_with_label(self, *args, **kwargs): - return self.label_edits.separate_with_label(*args, **kwargs) - - def split_along_convexity_defects(self, *args, **kwargs): - return self.label_edits.split_along_convexity_defects(*args, **kwargs) diff --git a/cellacdc/viewmodels/canvas_tool_viewmodel.py b/cellacdc/viewmodels/canvas_tool_viewmodel.py deleted file mode 100644 index 52222ebef..000000000 --- a/cellacdc/viewmodels/canvas_tool_viewmodel.py +++ /dev/null @@ -1,66 +0,0 @@ -"""View-model contracts for canvas tool interaction decisions.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.canvas_tool_model import CanvasToolModel - - -@dataclass(frozen=True) -class CanvasToolViewModel: - """Application-facing canvas tool commands.""" - - model: CanvasToolModel = field(default_factory=CanvasToolModel) - - def viewer_mode_allows_press( - self, - mode: str, - *, - can_add_point: bool = False, - can_ruler: bool = False, - ) -> bool: - return self.model.viewer_mode_allows_press( - mode, - can_add_point=can_add_point, - can_ruler=can_ruler, - ) - - def should_forward_img1_press_to_img2( - self, - *, - right_click: bool, - middle_click: bool, - can_add_point: bool, - mode: str, - is_snapshot: bool, - is_annotate_division: bool, - manual_background_on: bool, - ) -> bool: - return self.model.should_forward_img1_press_to_img2( - right_click=right_click, - middle_click=middle_click, - can_add_point=can_add_point, - mode=mode, - is_snapshot=is_snapshot, - is_annotate_division=is_annotate_division, - manual_background_on=manual_background_on, - ) - - def should_forward_img1_release_to_img2( - self, - *, - right_click: bool, - mode: str, - is_snapshot: bool, - ) -> bool: - return self.model.should_forward_img1_release_to_img2( - right_click=right_click, - mode=mode, - is_snapshot=is_snapshot, - ) - - def apply_manual_separate_draw_mode(self, settings, mode): - key, value = self.model.manual_separate_draw_mode_update(mode) - settings.at[key, 'value'] = value - return settings diff --git a/cellacdc/viewmodels/cell_cycle_viewmodel.py b/cellacdc/viewmodels/cell_cycle_viewmodel.py deleted file mode 100644 index 379718e05..000000000 --- a/cellacdc/viewmodels/cell_cycle_viewmodel.py +++ /dev/null @@ -1,67 +0,0 @@ -"""View-model composition for cell-cycle annotation workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -import pandas as pd - -from cellacdc.models.cell_cycle_model import ( - AnnotatedEditWarningPlan, - CellCycleModel, -) - -from .cca_edits_viewmodel import CcaEditViewModel -from .cca_workflows_viewmodel import CcaWorkflowViewModel -from .lineage_viewmodel import LineageViewModel -from .model_registry_viewmodel import ModelRegistryViewModel - - -@dataclass(frozen=True) -class CellCycleViewModel: - """GUI-facing commands for cell-cycle annotation workflows.""" - - model: CellCycleModel = field(default_factory=CellCycleModel) - cca_edits: CcaEditViewModel = field(default_factory=CcaEditViewModel) - cca_workflows: CcaWorkflowViewModel = field( - default_factory=CcaWorkflowViewModel - ) - lineage: LineageViewModel = field(default_factory=LineageViewModel) - model_registry: ModelRegistryViewModel = field( - default_factory=ModelRegistryViewModel - ) - - def annotated_edit_warning_plan( - self, - *, - is_snapshot: bool, - acdc_df_missing: bool, - lineage_tree_missing: bool, - cell_cycle_stage_present: bool, - lineage_tree_present: bool, - remembered_skip_warning: bool, - ) -> AnnotatedEditWarningPlan: - return self.model.annotated_edit_warning_plan( - is_snapshot=is_snapshot, - acdc_df_missing=acdc_df_missing, - lineage_tree_missing=lineage_tree_missing, - cell_cycle_stage_present=cell_cycle_stage_present, - lineage_tree_present=lineage_tree_present, - remembered_skip_warning=remembered_skip_warning, - ) - - def check_mothers_exclusion_or_dead( - self, - acdc_df: pd.DataFrame, - mother_ids: list[int], - ) -> list[int]: - """Wrap check_mothers_exclusion_or_dead model call.""" - return self.model.check_mothers_exclusion_or_dead(acdc_df, mother_ids) - - def evaluate_sister_relations( - self, - prev_cca_df: pd.DataFrame, - current_ids: set[int], - ) -> list[int]: - """Wrap evaluate_sister_relations model call.""" - return self.model.evaluate_sister_relations(prev_cca_df, current_ids) - diff --git a/cellacdc/viewmodels/combine_viewmodel.py b/cellacdc/viewmodels/combine_viewmodel.py deleted file mode 100644 index 0047ebab0..000000000 --- a/cellacdc/viewmodels/combine_viewmodel.py +++ /dev/null @@ -1,29 +0,0 @@ -"""View-model contract for the Combine Channels feature.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from cellacdc.models.combine_model import CombineModel - - -@dataclass(frozen=True) -class CombineViewModel: - """Presentation logic and commands for the Combine Channels feature.""" - - model: CombineModel = field(default_factory=CombineModel) - - def initialize_combine_image_data(self, pos_data): - """Delegate initialization to model.""" - return self.model.initialize_combine_image_data(pos_data) - - def validate_dimensions(self, ndim: int) -> bool: - """Delegate validation to model.""" - return self.model.validate_dimensions(ndim) - - def group_processed_data_by_pos(self, processed_data, keys): - """Delegate grouping to model.""" - return self.model.group_processed_data_by_pos(processed_data, keys) - - def update_combine_image_data(self, pos_data, pos_i_data): - """Delegate combined image data update to model.""" - return self.model.update_combine_image_data(pos_data, pos_i_data) diff --git a/cellacdc/viewmodels/curvature_viewmodel.py b/cellacdc/viewmodels/curvature_viewmodel.py deleted file mode 100644 index 4ccd5baff..000000000 --- a/cellacdc/viewmodels/curvature_viewmodel.py +++ /dev/null @@ -1,98 +0,0 @@ -"""View-model contracts for curvature and spline editing tools.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -import numpy as np - -from cellacdc.domain.curvature import CurvatureLabelPaintResult -from cellacdc.models.curvature_model import CurvatureModel - - -@dataclass(frozen=True) -class CurvatureViewModel: - """Application-facing commands for spline drawing and label painting.""" - - model: CurvatureModel = field(default_factory=CurvatureModel) - - def tangent_brush_polygon( - self, - yx_start, - yx_end, - radius: int | float, - shape: tuple[int, int], - ) -> tuple[np.ndarray, np.ndarray]: - return self.model.tangent_brush_polygon( - yx_start, yx_end, radius, shape - ) - - def directional_coords( - self, - alfa_dir: int, - y: int, - x: int, - shape: tuple[int, int], - *, - connectivity: int = 1, - ) -> tuple[list[int], list[int]]: - return self.model.directional_coords( - alfa_dir, - y, - x, - shape, - connectivity=connectivity, - ) - - def spline_coords( - self, - xx, - yy, - *, - resolution_space=None, - per: bool = False, - append_first: bool = False, - ): - return self.model.spline_coords( - xx, - yy, - resolution_space=resolution_space, - per=per, - append_first=append_first, - ) - - def closed_spline_coords( - self, - xx_spline, - yy_spline, - *, - anchor_xx=None, - anchor_yy=None, - predictor=None, - max_exec_time: int = 150, - ): - return self.model.closed_spline_coords( - xx_spline, - yy_spline, - anchor_xx=anchor_xx, - anchor_yy=anchor_yy, - predictor=predictor, - max_exec_time=max_exec_time, - ) - - def paint_spline_to_labels( - self, - labels_2d: np.ndarray, - xx_spline, - yy_spline, - label_id: int, - *, - empty_only: bool = True, - ) -> CurvatureLabelPaintResult: - return self.model.paint_spline_to_labels( - labels_2d, - xx_spline, - yy_spline, - label_id, - empty_only=empty_only, - ) diff --git a/cellacdc/viewmodels/custom_annotations_viewmodel.py b/cellacdc/viewmodels/custom_annotations_viewmodel.py deleted file mode 100644 index 0ba01ec27..000000000 --- a/cellacdc/viewmodels/custom_annotations_viewmodel.py +++ /dev/null @@ -1,93 +0,0 @@ -"""View-model contracts for custom annotations.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -import pandas as pd - -from cellacdc.domain.custom_annotations import ( - CustomAnnotationColumnResult, - CustomAnnotationFrameUpdate, -) -from cellacdc.models.custom_annotations_model import CustomAnnotationsModel - - -@dataclass(frozen=True) -class CustomAnnotationsViewModel: - """Application-facing custom annotation table commands.""" - - model: CustomAnnotationsModel = field( - default_factory=CustomAnnotationsModel - ) - - def read_saved_annotations( - self, - annotations_path: str, - *, - logger_func=None, - ) -> dict: - return self.model.read_saved_annotations( - annotations_path, - logger_func=logger_func, - ) - - def tooltip(self, annotation_state: dict) -> str: - return self.model.tooltip(annotation_state) - - def ensure_column( - self, - acdc_df: pd.DataFrame, - annotation_name: str, - ) -> CustomAnnotationColumnResult: - return self.model.ensure_column(acdc_df, annotation_name) - - def column_exists( - self, - frame_records, - annotation_name: str, - *, - summary_acdc_df: pd.DataFrame | None = None, - ) -> bool: - return self.model.column_exists( - frame_records, - annotation_name, - summary_acdc_df=summary_acdc_df, - ) - - def drop_column( - self, - acdc_df: pd.DataFrame | None, - annotation_name: str, - ) -> pd.DataFrame | None: - return self.model.drop_column(acdc_df, annotation_name) - - def rename_column( - self, - acdc_df: pd.DataFrame | None, - old_name: str, - new_name: str, - ) -> pd.DataFrame | None: - return self.model.rename_column(acdc_df, old_name, new_name) - - def remap_ids(self, annotated_ids_by_frame, old_ids, new_ids) -> dict: - return self.model.remap_ids(annotated_ids_by_frame, old_ids, new_ids) - - def update_frame( - self, - acdc_df: pd.DataFrame, - annotation_name: str, - annotated_ids, - *, - clicked_id: int = 0, - click_is_active: bool = False, - existing_ids=None, - ) -> CustomAnnotationFrameUpdate: - return self.model.update_frame( - acdc_df, - annotation_name, - annotated_ids, - clicked_id=clicked_id, - click_is_active=click_is_active, - existing_ids=existing_ids, - ) diff --git a/cellacdc/viewmodels/data_loading_viewmodel.py b/cellacdc/viewmodels/data_loading_viewmodel.py deleted file mode 100644 index e1a506b76..000000000 --- a/cellacdc/viewmodels/data_loading_viewmodel.py +++ /dev/null @@ -1,73 +0,0 @@ -"""View-model behavior for data loading workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.data_loading_model import ( - ChannelNameSuggestion, - DataLoadingModel, - EmptyDataPlan, - ImageDataPreparation, - OpenImageFileContext, - OpenImageFileTarget, -) - -from .formatting_viewmodel import FormattingViewModel -from .workspace_viewmodel import WorkspaceViewModel - - -@dataclass(frozen=True) -class DataLoadingViewModel: - """GUI-facing helpers for data loading workflows.""" - - model: DataLoadingModel = field(default_factory=DataLoadingModel) - formatting: FormattingViewModel = field(default_factory=FormattingViewModel) - workspace: WorkspaceViewModel = field(default_factory=WorkspaceViewModel) - - def open_image_file_context( - self, file_path: str, timestamp: str | None = None - ) -> OpenImageFileContext: - return self.model.open_image_file_context(file_path, timestamp) - - def channel_name_suggestion( - self, filename_no_ext: str - ) -> ChannelNameSuggestion: - return self.model.channel_name_suggestion(filename_no_ext) - - def open_image_file_target( - self, - context: OpenImageFileContext, - channel_name: str | None = None, - ) -> OpenImageFileTarget: - return self.model.open_image_file_target(context, channel_name) - - def empty_data_plan(self, exp_path: str) -> EmptyDataPlan: - return self.model.empty_data_plan(exp_path) - - def copy_action_text(self, do_copy: bool) -> str: - return self.model.copy_action_text(do_copy) - - def is_imagej_dtype(self, dtype) -> bool: - return self.model.is_imagej_dtype(dtype) - - def prepare_tiff_image_data(self, image) -> ImageDataPreparation: - return self.model.prepare_tiff_image_data(image) - - def merge_default_segm_info(self, existing_df, default_df): - return self.model.merge_default_segm_info(existing_df, default_df) - - def copy_single_zslice_segm_info( - self, - existing_df, - default_dst_df, - *, - src_filename: str, - dst_filename: str, - ): - return self.model.copy_single_zslice_segm_info( - existing_df, - default_dst_df, - src_filename=src_filename, - dst_filename=dst_filename, - ) diff --git a/cellacdc/viewmodels/deleted_rois_viewmodel.py b/cellacdc/viewmodels/deleted_rois_viewmodel.py deleted file mode 100644 index ed5a11b71..000000000 --- a/cellacdc/viewmodels/deleted_rois_viewmodel.py +++ /dev/null @@ -1,48 +0,0 @@ -"""View-model contracts for deleted ROI workflows.""" - -from __future__ import annotations - -from collections.abc import Iterable -from dataclasses import dataclass, field - -from cellacdc.models.deleted_rois_model import DeletedRoisModel - - -@dataclass(frozen=True) -class DeletedRoisViewModel: - """Application-facing deleted-ROI decisions.""" - - model: DeletedRoisModel = field(default_factory=DeletedRoisModel) - - def roi_axis( - self, - *, - is_polyline: bool, - labels_image_visible: bool, - ) -> str: - return self.model.roi_axis( - is_polyline=is_polyline, - labels_image_visible=labels_image_visible, - ) - - def should_render_deleted_roi(self, annotation_mode: str) -> bool: - return self.model.should_render_deleted_roi(annotation_mode) - - def should_render_deleted_roi_contours(self, annotation_mode: str) -> bool: - return self.model.should_render_deleted_roi_contours(annotation_mode) - - def should_render_deleted_roi_overlay(self, annotation_mode: str) -> bool: - return self.model.should_render_deleted_roi_overlay(annotation_mode) - - def should_initialize_overlay_masks( - self, - init: bool, - annotation_mode: str, - ) -> bool: - return self.model.should_initialize_overlay_masks( - init, - annotation_mode, - ) - - def labels_to_skip(self, deleted_ids: Iterable[int]) -> dict[int, bool]: - return self.model.labels_to_skip(deleted_ids) diff --git a/cellacdc/viewmodels/display_decorations_viewmodel.py b/cellacdc/viewmodels/display_decorations_viewmodel.py deleted file mode 100644 index 0a8a1718f..000000000 --- a/cellacdc/viewmodels/display_decorations_viewmodel.py +++ /dev/null @@ -1,57 +0,0 @@ -"""View-model contracts for display decorations.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.display_decorations_model import ( - DisplayDecorationsModel, -) - - -@dataclass(frozen=True) -class DisplayDecorationsViewModel: - """Application-facing display-decoration commands.""" - - model: DisplayDecorationsModel = field( - default_factory=DisplayDecorationsModel - ) - - def clamped_view_range(self, image_shape, view_range): - return self.model.clamped_view_range(image_shape, view_range) - - def integer_view_range(self, view_range): - return self.model.integer_view_range(view_range) - - def should_move_decoration( - self, - *, - dialog_open: bool, - move_with_zoom: bool, - ) -> bool: - return self.model.should_move_decoration( - dialog_open=dialog_open, - move_with_zoom=move_with_zoom, - ) - - def should_store_view_range( - self, - *, - has_range_reset_state: bool, - is_range_reset: bool = False, - ) -> bool: - return self.model.should_store_view_range( - has_range_reset_state=has_range_reset_state, - is_range_reset=is_range_reset, - ) - - def should_update_timestamp_frame( - self, - *, - has_timestamp: bool, - timestamp_enabled: bool, - ) -> bool: - return self.model.should_update_timestamp_frame( - has_timestamp=has_timestamp, - timestamp_enabled=timestamp_enabled, - ) diff --git a/cellacdc/viewmodels/draw_clear_region_viewmodel.py b/cellacdc/viewmodels/draw_clear_region_viewmodel.py deleted file mode 100644 index 1080c267c..000000000 --- a/cellacdc/viewmodels/draw_clear_region_viewmodel.py +++ /dev/null @@ -1,55 +0,0 @@ -"""View-model contracts for draw-clear-region workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.draw_clear_region_model import ( - DrawClearRegionModel, - DrawClearRegionToolbarState, -) - - -@dataclass(frozen=True) -class DrawClearRegionViewModel: - """Application-facing draw-clear-region commands.""" - - model: DrawClearRegionModel = field( - default_factory=DrawClearRegionModel - ) - - def toolbar_state( - self, - *, - checked: bool, - is_segm_3d: bool, - size_z: int, - ) -> DrawClearRegionToolbarState: - return self.model.toolbar_state( - checked=checked, - is_segm_3d=is_segm_3d, - size_z=size_z, - ) - - def z_range_for_projection( - self, - *, - is_segm_3d: bool, - z_projection: str, - size_z: int, - single_z_range, - ): - return self.model.z_range_for_projection( - is_segm_3d=is_segm_3d, - z_projection=z_projection, - size_z=size_z, - single_z_range=single_z_range, - ) - - def is_single_z_projection(self, z_projection: str) -> bool: - return self.model.is_single_z_projection(z_projection) - - def empty_selection_warning(self, *, enclosed_only: bool) -> str: - return self.model.empty_selection_warning( - enclosed_only=enclosed_only - ) diff --git a/cellacdc/viewmodels/exporting_viewmodel.py b/cellacdc/viewmodels/exporting_viewmodel.py deleted file mode 100644 index c7a40b613..000000000 --- a/cellacdc/viewmodels/exporting_viewmodel.py +++ /dev/null @@ -1,58 +0,0 @@ -"""View-model contracts for image and video export workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.exporting_model import ExportingModel - - -@dataclass(frozen=True) -class ExportingViewModel: - """Application-facing export commands.""" - - model: ExportingModel = field(default_factory=ExportingModel) - - def timestamped_export_filename(self, kind: str, *, timestamp=None): - return self.model.timestamped_export_filename( - kind, - timestamp=timestamp, - ) - - def export_frame_plan( - self, - *, - current_index: int, - num_digits: int, - filename: str, - pngs_folderpath: str, - ): - return self.model.export_frame_plan( - current_index=current_index, - num_digits=num_digits, - filename=filename, - pngs_folderpath=pngs_folderpath, - ) - - def build_export_mask_image( - self, - image_shape, - view_range, - *, - invert_bw=False, - ): - return self.model.build_export_mask_image( - image_shape, - view_range, - invert_bw=invert_bw, - ) - - def zoom_ids(self, labels_2d, view_range): - return self.model.zoom_ids(labels_2d, view_range) - - def shifted_view_range(self, previous_range, current_range, window_range): - return self.model.shifted_view_range( - previous_range, - current_range, - window_range, - ) diff --git a/cellacdc/viewmodels/frame_navigation_viewmodel.py b/cellacdc/viewmodels/frame_navigation_viewmodel.py deleted file mode 100644 index c0d358bbe..000000000 --- a/cellacdc/viewmodels/frame_navigation_viewmodel.py +++ /dev/null @@ -1,66 +0,0 @@ -"""View-model contracts for frame and position navigation.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.frame_navigation_model import FrameNavigationModel - -from .frame_metadata_viewmodel import FrameMetadataViewModel -from .label_edits_viewmodel import LabelEditViewModel - - -@dataclass(frozen=True) -class FrameNavigationViewModel: - """Application-facing frame/position navigation decisions.""" - - model: FrameNavigationModel = field(default_factory=FrameNavigationModel) - frame_metadata: FrameMetadataViewModel = field( - default_factory=FrameMetadataViewModel - ) - label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) - - def should_show_next_frame_image(self, **kwargs) -> bool: - return self.model.should_show_next_frame_image(**kwargs) - - def next_frame_index(self, **kwargs) -> int: - return self.model.next_frame_index(**kwargs) - - def navigation_position(self, **kwargs) -> int: - return self.model.navigation_position(**kwargs) - - def navigation_limit(self, **kwargs): - return self.model.navigation_limit(**kwargs) - - def should_store_when_slider_moves(self, *, mode: str) -> bool: - return self.model.should_store_when_slider_moves(mode=mode) - - def should_warn_lost_objects(self, **kwargs) -> bool: - return self.model.should_warn_lost_objects(**kwargs) - - def blocks_future_manual_annotation(self, **kwargs) -> bool: - return self.model.blocks_future_manual_annotation(**kwargs) - - def should_apply_new_frame_tools(self, **kwargs) -> bool: - return self.model.should_apply_new_frame_tools(**kwargs) - - def is_single_z_slice_projection(self, how: str) -> bool: - return self.model.is_single_z_slice_projection(how) - - def should_disable_overlay_z_slice(self, how: str) -> bool: - return self.model.should_disable_overlay_z_slice(how) - - def projection_frame_indices(self, **kwargs): - return self.model.projection_frame_indices(**kwargs) - - def z_slice_frame_indices(self, **kwargs): - return self.model.z_slice_frame_indices(**kwargs) - - def nearest_nonzero_z_from_centroid(self, *args, **kwargs): - return self.label_edits.nearest_nonzero_z_from_centroid(*args, **kwargs) - - def empty_frame_record(self): - return self.frame_metadata.empty_frame_record() - - def empty_frame_records(self, count: int): - return self.frame_metadata.empty_frame_records(count) diff --git a/cellacdc/viewmodels/graphics_viewmodel.py b/cellacdc/viewmodels/graphics_viewmodel.py deleted file mode 100644 index 06cf323c9..000000000 --- a/cellacdc/viewmodels/graphics_viewmodel.py +++ /dev/null @@ -1,117 +0,0 @@ -"""View-model composition for graphics workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from collections.abc import Iterable, Mapping -import numpy as np - -from cellacdc.models.graphics_model import ( - GraphicsModel, - OverlayOpacityPlan, - OverlayVisibilityPlan, -) - -from .formatting_viewmodel import FormattingViewModel -from .geometry_viewmodel import GeometryViewModel -from .label_edits_viewmodel import LabelEditViewModel -from .workspace_viewmodel import WorkspaceViewModel - - -@dataclass(frozen=True) -class GraphicsViewModel: - """GUI-facing commands for graphics item construction workflows.""" - - model: GraphicsModel = field(default_factory=GraphicsModel) - formatting: FormattingViewModel = field(default_factory=FormattingViewModel) - geometry: GeometryViewModel = field(default_factory=GeometryViewModel) - label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) - workspace: WorkspaceViewModel = field(default_factory=WorkspaceViewModel) - - def overlay_toolbutton_checked( - self, - channel: str, - *, - checked_channels: Iterable[str], - is_single_channel: bool, - ) -> bool: - return self.model.overlay_toolbutton_checked( - channel, - checked_channels=checked_channels, - is_single_channel=is_single_channel, - ) - - def overlay_toolbutton_click_checked_channels( - self, - *, - clicked_channel: str, - all_channels: Iterable[str], - checked_channels: Iterable[str], - toolbar_single_channel: bool, - ) -> set[str]: - return self.model.overlay_toolbutton_click_checked_channels( - clicked_channel=clicked_channel, - all_channels=all_channels, - checked_channels=checked_channels, - toolbar_single_channel=toolbar_single_channel, - ) - - def overlay_visibility_plan( - self, - *, - all_channels: Iterable[str], - checked_channels: Iterable[str], - overlay_enabled: bool, - ) -> OverlayVisibilityPlan: - return self.model.overlay_visibility_plan( - all_channels=all_channels, - checked_channels=checked_channels, - overlay_enabled=overlay_enabled, - ) - - def overlay_channel_opacity_map( - self, - base_channel: str, - active_channel_alpha_values: Mapping[str, float], - ) -> dict[str, float]: - return self.model.overlay_channel_opacity_map( - base_channel, - active_channel_alpha_values, - ) - - def overlay_item_opacity_plan( - self, - *, - all_channels: Iterable[str], - base_channel: str, - checked_channels: Iterable[str], - toolbar_single_channel: bool, - active_channel_alpha_values: Mapping[str, float], - ) -> OverlayOpacityPlan: - return self.model.overlay_item_opacity_plan( - all_channels=all_channels, - base_channel=base_channel, - checked_channels=checked_channels, - toolbar_single_channel=toolbar_single_channel, - active_channel_alpha_values=active_channel_alpha_values, - ) - - def generate_labels_image_lut(self, base_lut: np.ndarray) -> np.ndarray: - """Wrap generate_labels_image_lut model call.""" - return self.model.generate_labels_image_lut(base_lut) - - def extend_labels_lut(self, base_lut: np.ndarray, len_new_lut: int) -> np.ndarray: - """Wrap extend_labels_lut model call.""" - return self.model.extend_labels_lut(base_lut, len_new_lut) - - def apply_lut_dimming_for_kept_objects( - self, - lut: np.ndarray, - kept_object_ids: list[int], - keep_ids_enabled: bool, - ) -> np.ndarray: - """Wrap apply_lut_dimming_for_kept_objects model call.""" - return self.model.apply_lut_dimming_for_kept_objects( - lut, kept_object_ids, keep_ids_enabled - ) - diff --git a/cellacdc/viewmodels/image_controls_viewmodel.py b/cellacdc/viewmodels/image_controls_viewmodel.py deleted file mode 100644 index 08140f20f..000000000 --- a/cellacdc/viewmodels/image_controls_viewmodel.py +++ /dev/null @@ -1,32 +0,0 @@ -"""View-model contracts for image control widgets.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.image_controls_model import ImageControlsModel - - -@dataclass(frozen=True) -class ImageControlsViewModel: - """Application-facing image-control defaults.""" - - model: ImageControlsModel = field(default_factory=ImageControlsModel) - - def draw_ids_cont_combo_items(self) -> tuple[str, ...]: - return self.model.draw_ids_cont_combo_items - - def z_projection_options(self) -> tuple[str, ...]: - return self.model.z_projection_options - - def overlay_z_projection_options(self) -> tuple[str, ...]: - return self.model.overlay_z_projection_options - - def bottom_layout_zoom_values(self) -> tuple[int, ...]: - return self.model.bottom_layout_zoom_values - - def bottom_layout_zoom_percent(self, df_settings) -> int: - return self.model.bottom_layout_zoom_percent(df_settings) - - def retain_space_hidden_sliders(self, df_settings) -> bool: - return self.model.retain_space_hidden_sliders(df_settings) diff --git a/cellacdc/viewmodels/image_display_viewmodel.py b/cellacdc/viewmodels/image_display_viewmodel.py deleted file mode 100644 index 61dd39741..000000000 --- a/cellacdc/viewmodels/image_display_viewmodel.py +++ /dev/null @@ -1,57 +0,0 @@ -"""View-model behavior for image display workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.image_display_model import ( - ImageDisplayModel, - LabelsAlphaPlan, - RightPaneMode, - RightPaneVisibilityPlan, -) - -from .formatting_viewmodel import FormattingViewModel -from .preprocessing_viewmodel import PreprocessingViewModel - - -@dataclass(frozen=True) -class ImageDisplayViewModel: - """GUI-facing helpers for image display workflows.""" - - model: ImageDisplayModel = field(default_factory=ImageDisplayModel) - formatting: FormattingViewModel = field(default_factory=FormattingViewModel) - preprocessing: PreprocessingViewModel = field( - default_factory=PreprocessingViewModel - ) - - def right_pane_visibility_plan( - self, - mode: RightPaneMode, - checked: bool, - ) -> RightPaneVisibilityPlan: - return self.model.right_pane_visibility_plan(mode, checked) - - def invert_bw_setting_value(self, checked: bool) -> str: - return self.model.invert_bw_setting_value(checked) - - def labels_alpha_plan( - self, - value: float, - *, - keep_ids_checked: bool, - ) -> LabelsAlphaPlan: - return self.model.labels_alpha_plan( - value, - keep_ids_checked=keep_ids_checked, - ) - - def intensity_normalization_setting_value(self, how: str) -> str: - return self.model.intensity_normalization_setting_value(how) - - def rescale_intensity_setting_update( - self, - channel: str, - how: str, - ) -> tuple[str, str]: - return self.model.rescale_intensity_setting_update(channel, how) diff --git a/cellacdc/viewmodels/label_editing_viewmodel.py b/cellacdc/viewmodels/label_editing_viewmodel.py deleted file mode 100644 index ac11ff0d5..000000000 --- a/cellacdc/viewmodels/label_editing_viewmodel.py +++ /dev/null @@ -1,82 +0,0 @@ -"""View-model contracts for label-editing workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.label_editing_model import LabelEditingModel - -from .cca_edits_viewmodel import CcaEditViewModel -from .edit_id_viewmodel import EditIdViewModel -from .geometry_viewmodel import GeometryViewModel -from .label_edits_viewmodel import LabelEditViewModel - - -@dataclass(frozen=True) -class LabelEditingViewModel: - """Application-facing label-editing decisions and commands.""" - - model: LabelEditingModel = field(default_factory=LabelEditingModel) - cca_edits: CcaEditViewModel = field(default_factory=CcaEditViewModel) - edit_id: EditIdViewModel = field(default_factory=EditIdViewModel) - geometry: GeometryViewModel = field(default_factory=GeometryViewModel) - label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) - - def should_apply_manual_edits(self, edited_labels_by_z) -> bool: - return self.model.should_apply_manual_edits(edited_labels_by_z) - - def should_store_zslice_regionprops(self, *, is_segm_3d: bool) -> bool: - return self.model.should_store_zslice_regionprops( - is_segm_3d=is_segm_3d - ) - - def should_update_zslice_regionprops( - self, - *, - force_update: bool, - already_stored: bool, - ) -> bool: - return self.model.should_update_zslice_regionprops( - force_update=force_update, - already_stored=already_stored, - ) - - def should_prompt_for_background_id(self, clicked_id: int) -> bool: - return self.model.should_prompt_for_background_id(clicked_id) - - def is_power_button_color( - self, - *, - button_color: str, - power_color: str, - ) -> bool: - return self.model.is_power_button_color( - button_color=button_color, - power_color=power_color, - ) - - def should_force_new_hover_id( - self, - *, - brush_active: bool, - shift_pressed: bool, - ) -> bool: - return self.model.should_force_new_hover_id( - brush_active=brush_active, - shift_pressed=shift_pressed, - ) - - def should_restore_brush_id_from_hover( - self, - *, - is_hover_z_neighbor: bool, - shift_pressed: bool, - last_hover_id: int, - hover_id: int, - ) -> bool: - return self.model.should_restore_brush_id_from_hover( - is_hover_z_neighbor=is_hover_z_neighbor, - shift_pressed=shift_pressed, - last_hover_id=last_hover_id, - hover_id=hover_id, - ) diff --git a/cellacdc/viewmodels/label_roi_viewmodel.py b/cellacdc/viewmodels/label_roi_viewmodel.py deleted file mode 100644 index 62868affd..000000000 --- a/cellacdc/viewmodels/label_roi_viewmodel.py +++ /dev/null @@ -1,121 +0,0 @@ -"""View-model contracts for label-ROI workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.label_roi_model import ( - LabelRoiModel, - LabelRoiParamsSettings, -) - - -@dataclass(frozen=True) -class LabelRoiViewModel: - """Application-facing Magic Labeller ROI decisions.""" - - model: LabelRoiModel = field(default_factory=LabelRoiModel) - - def checked_setting_value(self, checked: bool) -> str: - return self.model.checked_setting_value(checked) - - def checked_from_setting_value(self, value) -> bool: - return self.model.checked_from_setting_value(value) - - def model_params_ini_path(self, settings_folderpath: str) -> str: - return self.model.model_params_ini_path(settings_folderpath) - - def params_settings( - self, - *, - checked_roi_type: str, - circ_roi_radius: int, - roi_zdepth: int, - auto_clear_border: bool, - replace_existing_objects: bool, - ) -> LabelRoiParamsSettings: - return self.model.params_settings( - checked_roi_type=checked_roi_type, - circ_roi_radius=circ_roi_radius, - roi_zdepth=roi_zdepth, - auto_clear_border=auto_clear_border, - replace_existing_objects=replace_existing_objects, - ) - - def is_frame_range_valid( - self, - enabled: bool, - start_frame_number: int, - stop_frame_number: int, - ) -> bool: - return self.model.is_frame_range_valid( - enabled, - start_frame_number, - stop_frame_number, - ) - - def frame_range_length( - self, - enabled: bool, - start_frame_index: int, - stop_frame_number: int, - ) -> int: - return self.model.frame_range_length( - enabled, - start_frame_index, - stop_frame_number, - ) - - def time_range( - self, - enabled: bool, - start_frame_index: int, - stop_frame_number: int, - ): - return self.model.time_range( - enabled, - start_frame_index, - stop_frame_number, - ) - - def should_enable_range_controls(self, checked: bool) -> bool: - return self.model.should_enable_range_controls(checked) - - def should_show_circular_cursor( - self, - *, - label_roi_checked: bool, - circular_roi_checked: bool, - label_roi_running: bool, - cursor_checked: bool, - existing_cursor_empty: bool, - ) -> bool: - return self.model.should_show_circular_cursor( - label_roi_checked=label_roi_checked, - circular_roi_checked=circular_roi_checked, - label_roi_running=label_roi_running, - cursor_checked=cursor_checked, - existing_cursor_empty=existing_cursor_empty, - ) - - def cursor_points(self, x, y, checked: bool): - return self.model.cursor_points(x, y, checked) - - def should_uncheck_time_range( - self, - *, - time_range_checked: bool, - persistent_action_checked: bool, - ) -> bool: - return self.model.should_uncheck_time_range( - time_range_checked=time_range_checked, - persistent_action_checked=persistent_action_checked, - ) - - def z_range( - self, - roi_zdepth: int, - size_z: int, - current_z_index: int, - ) -> tuple[int, int]: - return self.model.z_range(roi_zdepth, size_z, current_z_index) diff --git a/cellacdc/viewmodels/label_transform_tools_viewmodel.py b/cellacdc/viewmodels/label_transform_tools_viewmodel.py deleted file mode 100644 index ca0a8eae8..000000000 --- a/cellacdc/viewmodels/label_transform_tools_viewmodel.py +++ /dev/null @@ -1,51 +0,0 @@ -"""View-model contracts for label transform tools.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.label_transform_tools_model import ( - LabelTransformToolsModel, -) - - -@dataclass(frozen=True) -class LabelTransformToolsViewModel: - """Application-facing label transform commands.""" - - model: LabelTransformToolsModel = field( - default_factory=LabelTransformToolsModel - ) - - def reset_expand_label_id(self) -> int: - return self.model.reset_expand_label_id() - - def should_reinitialize_expansion( - self, - *, - expanding_id: int, - hover_label_id: int, - dilation: bool, - is_dilation: bool, - ) -> bool: - return self.model.should_reinitialize_expansion( - expanding_id=expanding_id, - hover_label_id=hover_label_id, - dilation=dilation, - is_dilation=is_dilation, - ) - - def should_start_moving_label(self, label_id: int) -> bool: - return self.model.should_start_moving_label(label_id) - - def point_in_shape(self, *, x: int, y: int, shape) -> bool: - return self.model.point_in_shape(x=x, y=y, shape=shape) - - def move_delta(self, *, previous_pos, current_pos) -> tuple[int, int]: - return self.model.move_delta( - previous_pos=previous_pos, - current_pos=current_pos, - ) - - def should_clear_move_state(self, *, checked: bool) -> bool: - return self.model.should_clear_move_state(checked=checked) diff --git a/cellacdc/viewmodels/layout_controls_viewmodel.py b/cellacdc/viewmodels/layout_controls_viewmodel.py deleted file mode 100644 index ec6151281..000000000 --- a/cellacdc/viewmodels/layout_controls_viewmodel.py +++ /dev/null @@ -1,40 +0,0 @@ -"""View-model contracts for layout-control workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.layout_controls_model import LayoutControlsModel - - -@dataclass(frozen=True) -class LayoutControlsViewModel: - """Application-facing decisions for GUI layout controls.""" - - model: LayoutControlsModel = field(default_factory=LayoutControlsModel) - - def zoom_percentage_from_text(self, text: str) -> int: - return self.model.zoom_percentage_from_text(text) - - def zoom_factors(self, percentage: int) -> tuple[float, float] | None: - return self.model.zoom_factors(percentage) - - def checked_setting_value(self, checked: bool) -> str: - return self.model.checked_setting_value(checked) - - def checked_from_setting_value(self, value) -> bool: - return self.model.checked_from_setting_value(value) - - def should_retain_z_slider_space( - self, - *, - checked: bool, - z_slice_enabled: bool, - ) -> bool: - return self.model.should_retain_z_slider_space( - checked=checked, - z_slice_enabled=z_slice_enabled, - ) - - def tool_name_from_tooltip(self, tooltip: str) -> str: - return self.model.tool_name_from_tooltip(tooltip) diff --git a/cellacdc/viewmodels/lineage_interactions_viewmodel.py b/cellacdc/viewmodels/lineage_interactions_viewmodel.py deleted file mode 100644 index f8da43e2c..000000000 --- a/cellacdc/viewmodels/lineage_interactions_viewmodel.py +++ /dev/null @@ -1,108 +0,0 @@ -"""View-model contracts for lineage-tree interaction workflows.""" - -from __future__ import annotations - -from collections.abc import Callable, Iterable -from dataclasses import dataclass, field - -import pandas as pd - -from cellacdc.models.lineage_interactions_model import ( - LineageInteractionsModel, -) - - -@dataclass(frozen=True) -class LineageInteractionsViewModel: - """Application-facing lineage-tree interaction decisions.""" - - model: LineageInteractionsModel = field( - default_factory=LineageInteractionsModel - ) - - def is_lineage_mode(self, mode: str) -> bool: - return self.model.is_lineage_mode(mode) - - def should_initialize( - self, - *, - force: bool, - mode: str, - lineage_tree_exists: bool, - ) -> bool: - return self.model.should_initialize( - force=force, - mode=mode, - lineage_tree_exists=lineage_tree_exists, - ) - - def default_mode_after_failed_init(self) -> str: - return self.model.default_mode_after_failed_init() - - def last_annotated_frame_index(self, frame_records: Iterable[dict]) -> int: - return self.model.last_annotated_frame_index(frame_records) - - def missing_frame_indices( - self, - current_frame_i: int, - present_frames: Iterable[int] | None, - ) -> list[int]: - return self.model.missing_frame_indices( - current_frame_i, - present_frames, - ) - - def should_process_auto_frame( - self, - *, - mode: str, - frame_i: int, - processed_frames: Iterable[int], - ) -> bool: - return self.model.should_process_auto_frame( - mode=mode, - frame_i=frame_i, - processed_frames=processed_frames, - ) - - def should_backup_original( - self, - original_frame_i: int | None, - current_frame_i: int, - ) -> bool: - return self.model.should_backup_original( - original_frame_i, - current_frame_i, - ) - - def next_candidate_index( - self, - click_index: int, - candidates_count: int, - ) -> int: - return self.model.next_candidate_index(click_index, candidates_count) - - def should_skip_original_mother( - self, - current_parent_id, - candidate_parent_id, - *, - original_mother_skipped: bool, - ) -> bool: - return self.model.should_skip_original_mother( - current_parent_id, - candidate_parent_id, - original_mother_skipped=original_mother_skipped, - ) - - def parent_id_differences( - self, - original_df: pd.DataFrame, - new_df: pd.DataFrame, - reset_index_cell_id: Callable[[pd.DataFrame], pd.DataFrame], - ) -> pd.DataFrame | None: - return self.model.parent_id_differences( - original_df, - new_df, - reset_index_cell_id, - ) diff --git a/cellacdc/viewmodels/magic_prompts_viewmodel.py b/cellacdc/viewmodels/magic_prompts_viewmodel.py deleted file mode 100644 index 27b11290f..000000000 --- a/cellacdc/viewmodels/magic_prompts_viewmodel.py +++ /dev/null @@ -1,57 +0,0 @@ -"""View-model contracts for promptable segmentation workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.magic_prompts_model import ( - MagicPromptsModel, - MagicPromptZoom, -) -from cellacdc.viewmodels.model_registry_viewmodel import ModelRegistryViewModel - - -@dataclass(frozen=True) -class MagicPromptsViewModel: - """Application-facing promptable-segmentation commands.""" - - model: MagicPromptsModel = field(default_factory=MagicPromptsModel) - registry: ModelRegistryViewModel = field( - default_factory=ModelRegistryViewModel - ) - - def store_custom_promptable_model_path(self, model_file_path): - return self.registry.store_custom_promptable_model_path( - model_file_path - ) - - def init_prompt_segmentation_model( - self, - acdc_prompt_segment, - position_data, - init_kwargs, - ): - return self.registry.init_prompt_segmentation_model( - acdc_prompt_segment, - position_data, - init_kwargs, - ) - - def set_default_arg_specs_from_kwargs(self, params, kwargs): - return self.registry.set_default_arg_specs_from_kwargs(params, kwargs) - - def zoom_region(self, view_range, image_shape) -> MagicPromptZoom: - return self.model.zoom_region(view_range, image_shape) - - def points_in_zoom(self, df_points, zoom: MagicPromptZoom, frame_i): - return self.model.points_in_zoom(df_points, zoom, frame_i) - - def retained_points_outside_zoom( - self, - frame_points_data, - zoom: MagicPromptZoom, - ): - return self.model.retained_points_outside_zoom( - frame_points_data, - zoom, - ) diff --git a/cellacdc/viewmodels/main_menu_viewmodel.py b/cellacdc/viewmodels/main_menu_viewmodel.py deleted file mode 100644 index e4be3273b..000000000 --- a/cellacdc/viewmodels/main_menu_viewmodel.py +++ /dev/null @@ -1,20 +0,0 @@ -"""View-model contracts for the main menu.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.main_menu_model import MainMenuModel - - -@dataclass(frozen=True) -class MainMenuViewModel: - """Application-facing main-menu commands.""" - - model: MainMenuModel = field(default_factory=MainMenuModel) - - def default_rescale_intensity_options(self): - return self.model.default_rescale_intensity_options - - def default_rescale_intensity_how(self, settings): - return self.model.default_rescale_intensity_how(settings) diff --git a/cellacdc/viewmodels/main_toolbar_viewmodel.py b/cellacdc/viewmodels/main_toolbar_viewmodel.py deleted file mode 100644 index 87df58903..000000000 --- a/cellacdc/viewmodels/main_toolbar_viewmodel.py +++ /dev/null @@ -1,17 +0,0 @@ -"""View-model contracts for the main GUI toolbars.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.main_toolbar_model import MainToolbarModel - - -@dataclass(frozen=True) -class MainToolbarViewModel: - """Application-facing toolbar metadata.""" - - model: MainToolbarModel = field(default_factory=MainToolbarModel) - - def mode_items(self) -> tuple[str, ...]: - return self.model.default_mode_items() diff --git a/cellacdc/viewmodels/main_viewmodel.py b/cellacdc/viewmodels/main_viewmodel.py index 9028b115c..76a85a44f 100644 --- a/cellacdc/viewmodels/main_viewmodel.py +++ b/cellacdc/viewmodels/main_viewmodel.py @@ -4,221 +4,32 @@ from dataclasses import dataclass, field -from .app_shell_viewmodel import AppShellViewModel -from .actions_viewmodel import ActionsViewModel -from .annotation_display_viewmodel import AnnotationDisplayViewModel -from .brush_tools_viewmodel import BrushToolsViewModel -from .canvas_context_menu_viewmodel import CanvasContextMenuViewModel -from .canvas_drawing_viewmodel import CanvasDrawingViewModel -from .canvas_events_viewmodel import CanvasEventsViewModel -from .canvas_hover_viewmodel import CanvasHoverViewModel -from .canvas_right_image_viewmodel import CanvasRightImageViewModel -from .canvas_selection_viewmodel import CanvasSelectionViewModel -from .canvas_tool_viewmodel import CanvasToolViewModel -from .combine_viewmodel import CombineViewModel -from .cell_cycle_viewmodel import CellCycleViewModel from .cca_edits_viewmodel import CcaEditViewModel from .cca_workflows_viewmodel import CcaWorkflowViewModel -from .curvature_viewmodel import CurvatureViewModel -from .custom_annotations_viewmodel import CustomAnnotationsViewModel -from .data_loading_viewmodel import DataLoadingViewModel -from .deleted_rois_viewmodel import DeletedRoisViewModel -from .display_decorations_viewmodel import DisplayDecorationsViewModel -from .draw_clear_region_viewmodel import DrawClearRegionViewModel from .edit_id_viewmodel import EditIdViewModel -from .exporting_viewmodel import ExportingViewModel from .frame_metadata_viewmodel import FrameMetadataViewModel -from .frame_navigation_viewmodel import FrameNavigationViewModel from .formatting_viewmodel import FormattingViewModel from .geometry_viewmodel import GeometryViewModel -from .graphics_viewmodel import GraphicsViewModel -from .image_controls_viewmodel import ImageControlsViewModel -from .image_display_viewmodel import ImageDisplayViewModel -from .label_editing_viewmodel import LabelEditingViewModel from .label_edits_viewmodel import LabelEditViewModel -from .label_roi_viewmodel import LabelRoiViewModel -from .label_transform_tools_viewmodel import LabelTransformToolsViewModel -from .layout_controls_viewmodel import LayoutControlsViewModel from .lineage_viewmodel import LineageViewModel -from .lineage_interactions_viewmodel import LineageInteractionsViewModel -from .magic_prompts_viewmodel import MagicPromptsViewModel -from .main_menu_viewmodel import MainMenuViewModel -from .main_toolbar_viewmodel import MainToolbarViewModel -from .measurements_viewmodel import MeasurementsViewModel -from .mode_controls_viewmodel import ModeControlsViewModel from .model_registry_viewmodel import ModelRegistryViewModel from .object_counts_viewmodel import ObjectCountViewModel -from .object_cleanup_viewmodel import ObjectCleanupViewModel -from .object_properties_viewmodel import ObjectPropertiesViewModel -from .object_search_viewmodel import ObjectSearchViewModel from .points_viewmodel import PointsViewModel -from .points_layers_viewmodel import PointsLayersViewModel -from .preprocessing_viewmodel import PreprocessingViewModel -from .quick_settings_viewmodel import QuickSettingsViewModel -from .saving_viewmodel import SavingViewModel -from .seg_for_lost_ids_viewmodel import SegForLostIdsViewModel -from .segmentation_viewmodel import SegmentationViewModel -from .session_viewmodel import SessionViewModel -from .status_hover_viewmodel import StatusHoverViewModel from .tables_viewmodel import TableViewModel -from .tool_activation_viewmodel import ToolActivationViewModel -from .tracking_viewmodel import TrackingViewModel -from .undo_redo_viewmodel import UndoRedoViewModel -from .whitelist_viewmodel import WhitelistViewModel -from .worker_viewmodel import WorkerViewModel -from .window_events_viewmodel import WindowEventsViewModel from .workspace_viewmodel import WorkspaceViewModel @dataclass(frozen=True) class MainGuiViewModel: - """Application-facing commands available to the Qt GUI.""" - - actions: ActionsViewModel = field(default_factory=ActionsViewModel) - annotation_display: AnnotationDisplayViewModel = field( - default_factory=AnnotationDisplayViewModel - ) - app_shell: AppShellViewModel = field(default_factory=AppShellViewModel) - brush_tools: BrushToolsViewModel = field(default_factory=BrushToolsViewModel) - canvas_context_menu: CanvasContextMenuViewModel = field( - default_factory=CanvasContextMenuViewModel - ) - canvas_drawing: CanvasDrawingViewModel = field( - default_factory=CanvasDrawingViewModel - ) - canvas_events: CanvasEventsViewModel = field( - default_factory=CanvasEventsViewModel - ) - canvas_hover: CanvasHoverViewModel = field( - default_factory=CanvasHoverViewModel - ) - canvas_right_image: CanvasRightImageViewModel = field( - default_factory=CanvasRightImageViewModel - ) - canvas_selection: CanvasSelectionViewModel = field( - default_factory=CanvasSelectionViewModel - ) - canvas_tools: CanvasToolViewModel = field( - default_factory=CanvasToolViewModel - ) - combine: CombineViewModel = field( - default_factory=CombineViewModel - ) - cell_cycle: CellCycleViewModel = field( - default_factory=CellCycleViewModel - ) - cca_edits: CcaEditViewModel = field(default_factory=CcaEditViewModel) + """Application-facing commands available to the Qt GUI.""" cca_edits: CcaEditViewModel = field(default_factory=CcaEditViewModel) cca_workflows: CcaWorkflowViewModel = field( default_factory=CcaWorkflowViewModel - ) - curvature: CurvatureViewModel = field(default_factory=CurvatureViewModel) - custom_annotations: CustomAnnotationsViewModel = field( - default_factory=CustomAnnotationsViewModel - ) - data_loading: DataLoadingViewModel = field( - default_factory=DataLoadingViewModel - ) - deleted_rois: DeletedRoisViewModel = field( - default_factory=DeletedRoisViewModel - ) - display_decorations: DisplayDecorationsViewModel = field( - default_factory=DisplayDecorationsViewModel - ) - draw_clear_region: DrawClearRegionViewModel = field( - default_factory=DrawClearRegionViewModel - ) - edit_id: EditIdViewModel = field(default_factory=EditIdViewModel) - exporting: ExportingViewModel = field(default_factory=ExportingViewModel) - frame_navigation: FrameNavigationViewModel = field( - default_factory=FrameNavigationViewModel - ) - frame_metadata: FrameMetadataViewModel = field( + ) edit_id: EditIdViewModel = field(default_factory=EditIdViewModel) frame_metadata: FrameMetadataViewModel = field( default_factory=FrameMetadataViewModel ) formatting: FormattingViewModel = field(default_factory=FormattingViewModel) - geometry: GeometryViewModel = field(default_factory=GeometryViewModel) - graphics: GraphicsViewModel = field(default_factory=GraphicsViewModel) - image_controls: ImageControlsViewModel = field( - default_factory=ImageControlsViewModel - ) - image_display: ImageDisplayViewModel = field( - default_factory=ImageDisplayViewModel - ) - label_editing: LabelEditingViewModel = field( - default_factory=LabelEditingViewModel - ) - label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) - label_roi: LabelRoiViewModel = field(default_factory=LabelRoiViewModel) - label_transform_tools: LabelTransformToolsViewModel = field( - default_factory=LabelTransformToolsViewModel - ) - layout_controls: LayoutControlsViewModel = field( - default_factory=LayoutControlsViewModel - ) - lineage: LineageViewModel = field(default_factory=LineageViewModel) - lineage_interactions: LineageInteractionsViewModel = field( - default_factory=LineageInteractionsViewModel - ) - magic_prompts: MagicPromptsViewModel = field( - default_factory=MagicPromptsViewModel - ) - main_menu: MainMenuViewModel = field(default_factory=MainMenuViewModel) - main_toolbar: MainToolbarViewModel = field( - default_factory=MainToolbarViewModel - ) - measurements: MeasurementsViewModel = field( - default_factory=MeasurementsViewModel - ) - mode_controls: ModeControlsViewModel = field( - default_factory=ModeControlsViewModel - ) - model_registry: ModelRegistryViewModel = field( + geometry: GeometryViewModel = field(default_factory=GeometryViewModel) label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) lineage: LineageViewModel = field(default_factory=LineageViewModel) model_registry: ModelRegistryViewModel = field( default_factory=ModelRegistryViewModel - ) - object_search: ObjectSearchViewModel = field( - default_factory=ObjectSearchViewModel - ) - object_counts: ObjectCountViewModel = field( + ) object_counts: ObjectCountViewModel = field( default_factory=ObjectCountViewModel - ) - object_cleanup: ObjectCleanupViewModel = field( - default_factory=ObjectCleanupViewModel - ) - object_properties: ObjectPropertiesViewModel = field( - default_factory=ObjectPropertiesViewModel - ) - points: PointsViewModel = field(default_factory=PointsViewModel) - points_layers: PointsLayersViewModel = field( - default_factory=PointsLayersViewModel - ) - preprocessing: PreprocessingViewModel = field( - default_factory=PreprocessingViewModel - ) - quick_settings: QuickSettingsViewModel = field( - default_factory=QuickSettingsViewModel - ) - saving: SavingViewModel = field(default_factory=SavingViewModel) - seg_for_lost_ids: SegForLostIdsViewModel = field( - default_factory=SegForLostIdsViewModel - ) - segmentation: SegmentationViewModel = field( - default_factory=SegmentationViewModel - ) - session: SessionViewModel = field(default_factory=SessionViewModel) - status_hover: StatusHoverViewModel = field( - default_factory=StatusHoverViewModel - ) - tables: TableViewModel = field(default_factory=TableViewModel) - tool_activation: ToolActivationViewModel = field( - default_factory=ToolActivationViewModel - ) - tracking: TrackingViewModel = field(default_factory=TrackingViewModel) - undo_redo: UndoRedoViewModel = field(default_factory=UndoRedoViewModel) - whitelist: WhitelistViewModel = field( - default_factory=WhitelistViewModel - ) - worker: WorkerViewModel = field(default_factory=WorkerViewModel) - window_events: WindowEventsViewModel = field( - default_factory=WindowEventsViewModel - ) - workspace: WorkspaceViewModel = field(default_factory=WorkspaceViewModel) + ) points: PointsViewModel = field(default_factory=PointsViewModel) tables: TableViewModel = field(default_factory=TableViewModel) workspace: WorkspaceViewModel = field(default_factory=WorkspaceViewModel) diff --git a/cellacdc/viewmodels/measurements_viewmodel.py b/cellacdc/viewmodels/measurements_viewmodel.py deleted file mode 100644 index 91e17f8df..000000000 --- a/cellacdc/viewmodels/measurements_viewmodel.py +++ /dev/null @@ -1,49 +0,0 @@ -"""View-model contracts for measurement workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.measurements_model import MeasurementsModel - - -@dataclass(frozen=True) -class MeasurementsViewModel: - """Application-facing commands for measurement calculations.""" - - model: MeasurementsModel = field(default_factory=MeasurementsModel) - - def rotational_volume( - self, - obj, - physical_size_y=1, - physical_size_x=1, - logger=None, - ): - return self.model.rotational_volume( - obj, - physical_size_y, - physical_size_x, - logger=logger, - ) - - def custom_metrics_instructions(self): - return self.model.custom_metrics_instructions() - - def metrics_examples_path(self): - return self.model.metrics_examples_path() - - def all_acdc_df_columns(self, all_pos_data): - return self.model.all_acdc_df_columns(all_pos_data) - - def not_loaded_channels(self, all_channel_names, loaded_channel_names): - return self.model.not_loaded_channels( - all_channel_names, loaded_channel_names - ) - - def drop_unchecked_measurements(self, acdc_df, columns, regionprops): - return self.model.drop_unchecked_measurements( - acdc_df, - columns, - regionprops, - ) diff --git a/cellacdc/viewmodels/mode_controls_viewmodel.py b/cellacdc/viewmodels/mode_controls_viewmodel.py deleted file mode 100644 index 756ba4288..000000000 --- a/cellacdc/viewmodels/mode_controls_viewmodel.py +++ /dev/null @@ -1,36 +0,0 @@ -"""View-model contracts for GUI mode controls.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.mode_controls_model import ModeControlsModel - - -@dataclass(frozen=True) -class ModeControlsViewModel: - """Application-facing mode-control decisions.""" - - model: ModeControlsModel = field(default_factory=ModeControlsModel) - - def should_start_blinking( - self, - mode: str, - *, - ruler_checked: bool = False, - ) -> bool: - return self.model.should_start_blinking( - mode, ruler_checked=ruler_checked - ) - - def blink_styles(self, flag: bool) -> tuple[str, bool]: - return self.model.blink_styles(flag) - - def should_store_on_mode_change(self, previous_mode: str) -> bool: - return self.model.should_store_on_mode_change(previous_mode) - - def is_cca_mode(self, mode: str) -> bool: - return self.model.is_cca_mode(mode) - - def undo_redo_target(self, mode: str) -> str: - return self.model.undo_redo_target(mode) diff --git a/cellacdc/viewmodels/object_cleanup_viewmodel.py b/cellacdc/viewmodels/object_cleanup_viewmodel.py deleted file mode 100644 index 53d6de2ee..000000000 --- a/cellacdc/viewmodels/object_cleanup_viewmodel.py +++ /dev/null @@ -1,30 +0,0 @@ -"""View-model contracts for object cleanup workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.object_cleanup_model import ObjectCleanupModel - -from .workspace_viewmodel import WorkspaceViewModel - - -@dataclass(frozen=True) -class ObjectCleanupViewModel: - """Application-facing object-cleanup commands.""" - - model: ObjectCleanupModel = field(default_factory=ObjectCleanupModel) - workspace: WorkspaceViewModel = field(default_factory=WorkspaceViewModel) - - def segmentation_roi_endnames(self, *, basename, images_path): - segm_files = self.workspace.segmentation_files(images_path) - return self.workspace.endnames(basename, segm_files) - - def cleared_segmentation_frames(self, cleared_segm_data, *, size_t: int): - return self.model.cleared_segmentation_frames( - cleared_segm_data, - size_t=size_t, - ) - - def frame_labels(self, cleared_segm_data): - return self.model.frame_labels(cleared_segm_data) diff --git a/cellacdc/viewmodels/object_properties_viewmodel.py b/cellacdc/viewmodels/object_properties_viewmodel.py deleted file mode 100644 index d6edcb748..000000000 --- a/cellacdc/viewmodels/object_properties_viewmodel.py +++ /dev/null @@ -1,180 +0,0 @@ -"""View-model contracts for object-property workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -import numpy as np - -from cellacdc.models.object_properties_model import ObjectPropertiesModel - -from .measurements_viewmodel import MeasurementsViewModel -from .object_counts_viewmodel import ObjectCountViewModel - - - -@dataclass(frozen=True) -class ObjectPropertiesViewModel: - """Application-facing object-property decisions and commands.""" - - model: ObjectPropertiesModel = field(default_factory=ObjectPropertiesModel) - measurements: MeasurementsViewModel = field( - default_factory=MeasurementsViewModel - ) - object_counts: ObjectCountViewModel = field( - default_factory=ObjectCountViewModel - ) - - def timelapse_default_categories(self) -> set[str]: - return self.model.timelapse_default_categories() - - def snapshot_default_categories(self, *, is_segm_3d: bool) -> set[str]: - return self.model.snapshot_default_categories(is_segm_3d=is_segm_3d) - - def should_update_object_counts( - self, - *, - window_exists: bool, - is_visible: bool, - live_preview_checked: bool, - ) -> bool: - return self.model.should_update_object_counts( - window_exists=window_exists, - is_visible=is_visible, - live_preview_checked=live_preview_checked, - ) - - def should_show_3d_property_controls(self, is_segm_3d: bool) -> bool: - return self.model.should_show_3d_property_controls(is_segm_3d) - - def should_highlight_props_id( - self, - *, - dock_visible: bool, - highlight_checked: bool, - searched_highlight_checked: bool, - ) -> bool: - return self.model.should_highlight_props_id( - dock_visible=dock_visible, - highlight_checked=highlight_checked, - searched_highlight_checked=searched_highlight_checked, - ) - - def should_update_props_widget( - self, - *, - dock_visible: bool, - object_id: int, - current_props_id: int, - ) -> bool: - return self.model.should_update_props_widget( - dock_visible=dock_visible, - object_id=object_id, - current_props_id=current_props_id, - ) - - def calculate_area_pxl( - self, - *, - is_segm_3d: bool, - z_proj_text: str, - z_lab: int, - bbox_0: int, - obj_image: np.ndarray, - obj_area: int, - ) -> int: - return self.model.calculate_area_pxl( - is_segm_3d=is_segm_3d, - z_proj_text=z_proj_text, - z_lab=z_lab, - bbox_0=bbox_0, - obj_image=obj_image, - obj_area=obj_area, - ) - - def calculate_area_um2( - self, - *, - area_pxl: int, - physical_size_x: float, - physical_size_y: float, - ) -> float: - return self.model.calculate_area_um2( - area_pxl=area_pxl, - physical_size_x=physical_size_x, - physical_size_y=physical_size_y, - ) - - def calculate_vol_3d( - self, - *, - obj_area: int, - physical_size_x: float, - physical_size_y: float, - physical_size_z: float, - ) -> tuple[float, float]: - return self.model.calculate_vol_3d( - obj_area=obj_area, - physical_size_x=physical_size_x, - physical_size_y=physical_size_y, - physical_size_z=physical_size_z, - ) - - def calculate_elongation( - self, - *, - major_axis_length: float, - minor_axis_length: float, - ) -> float: - return self.model.calculate_elongation( - major_axis_length=major_axis_length, - minor_axis_length=minor_axis_length, - ) - - def get_object_and_background_images( - self, - *, - image: np.ndarray, - is_segm_3d: bool, - pos_data_size_z: int, - z_slice: int, - obj_slice: tuple, - obj_image: np.ndarray, - img1_image: np.ndarray | None = None, - ) -> tuple[np.ndarray, np.ndarray]: - return self.model.get_object_and_background_images( - image=image, - is_segm_3d=is_segm_3d, - pos_data_size_z=pos_data_size_z, - z_slice=z_slice, - obj_slice=obj_slice, - obj_image=obj_image, - img1_image=img1_image, - ) - - def calculate_intensity_statistics( - self, - obj_data: np.ndarray, - ) -> dict[str, float]: - return self.model.calculate_intensity_statistics(obj_data) - - def calculate_additional_measure( - self, - *, - func_desc: str, - func: callable, - obj_data: np.ndarray, - img: np.ndarray, - lab: np.ndarray, - obj_area: int, - vol_vox: float, - ) -> float: - return self.model.calculate_additional_measure( - func_desc=func_desc, - func=func, - obj_data=obj_data, - img=img, - lab=lab, - obj_area=obj_area, - vol_vox=vol_vox, - ) - diff --git a/cellacdc/viewmodels/object_search_viewmodel.py b/cellacdc/viewmodels/object_search_viewmodel.py deleted file mode 100644 index 4b7c8b56a..000000000 --- a/cellacdc/viewmodels/object_search_viewmodel.py +++ /dev/null @@ -1,28 +0,0 @@ -"""View-model contracts for object search and navigation.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass, field - -from cellacdc.models.object_search_model import ObjectSearchModel - - -@dataclass(frozen=True) -class ObjectSearchViewModel: - """Application-facing commands for finding object IDs across frames.""" - - model: ObjectSearchModel = field(default_factory=ObjectSearchModel) - - def find_frame_with_id( - self, - pos_data, - searched_id: int, - *, - progress_callback: Callable[[int], None] | None = None, - ) -> int | None: - return self.model.find_frame_with_id( - pos_data, - searched_id, - progress_callback=progress_callback, - ) diff --git a/cellacdc/viewmodels/points_layers_viewmodel.py b/cellacdc/viewmodels/points_layers_viewmodel.py deleted file mode 100644 index 3e74cab77..000000000 --- a/cellacdc/viewmodels/points_layers_viewmodel.py +++ /dev/null @@ -1,67 +0,0 @@ -"""View-model contracts for points-layer workflows.""" - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass, field - -from cellacdc.models.points_layers_model import PointsLayersModel - -from .points_viewmodel import PointsViewModel - - -@dataclass(frozen=True) -class PointsLayersViewModel: - """Application-facing points-layer decisions and data transforms.""" - - model: PointsLayersModel = field(default_factory=PointsLayersModel) - points: PointsViewModel = field(default_factory=PointsViewModel) - - def click_entry_table_filename( - self, - basename: str, - table_endname: str, - ) -> str: - return self.model.click_entry_table_filename(basename, table_endname) - - def should_load_recovery_table( - self, - *, - recovery_exists: bool, - main_exists: bool, - recovery_mtime: float | None, - main_mtime: float | None, - ) -> bool: - return self.model.should_load_recovery_table( - recovery_exists=recovery_exists, - main_exists=main_exists, - recovery_mtime=recovery_mtime, - main_mtime=main_mtime, - ) - - def should_compute_points_layer( - self, - *, - layer_type_index: int, - compute_points_layers: bool, - ) -> bool: - return self.model.should_compute_points_layer( - layer_type_index=layer_type_index, - compute_points_layers=compute_points_layers, - ) - - def should_log_missing_frame_points(self, layer_type_index: int) -> bool: - return self.model.should_log_missing_frame_points(layer_type_index) - - def should_use_z_slice( - self, - *, - z_projection_mode: str, - size_z: int, - frame_points_data: Mapping, - ) -> bool: - return self.model.should_use_z_slice( - z_projection_mode=z_projection_mode, - size_z=size_z, - frame_points_data=frame_points_data, - ) diff --git a/cellacdc/viewmodels/preprocessing_viewmodel.py b/cellacdc/viewmodels/preprocessing_viewmodel.py deleted file mode 100644 index f7403f66b..000000000 --- a/cellacdc/viewmodels/preprocessing_viewmodel.py +++ /dev/null @@ -1,65 +0,0 @@ -"""View-model contracts for image preprocessing recipes.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -from cellacdc.models.preprocessing_model import PreprocessingModel - - -@dataclass(frozen=True) -class PreprocessingViewModel: - """Application-facing commands for preprocessing recipe execution.""" - - model: PreprocessingModel = field(default_factory=PreprocessingModel) - - def validate_multidimensional_recipe( - self, - recipe: list[dict[str, Any]], - *, - apply_to_all_zslices: bool = False, - apply_to_all_frames: bool = False, - ): - return self.model.validate_multidimensional_recipe( - recipe, - apply_to_all_zslices=apply_to_all_zslices, - apply_to_all_frames=apply_to_all_frames, - ) - - def preprocess_image_from_recipe(self, image, recipe: list[dict[str, Any]]): - return self.model.preprocess_image_from_recipe(image, recipe) - - def preprocess_zstack_from_recipe(self, image, recipe: list[dict[str, Any]]): - return self.model.preprocess_zstack_from_recipe(image, recipe) - - def preprocess_video_from_recipe(self, image, recipe: list[dict[str, Any]]): - return self.model.preprocess_video_from_recipe(image, recipe) - - def preprocess_multi_pos_from_recipe( - self, - images, - recipe: list[dict[str, Any]], - ): - return self.model.preprocess_multi_pos_from_recipe(images, recipe) - - def image_to_float( - self, - image, - *, - force_dtype=None, - force_missing_dtype=None, - warn=True, - ): - return self.model.image_to_float( - image, - force_dtype=force_dtype, - force_missing_dtype=force_missing_dtype, - warn=warn, - ) - - def normalize_display_image(self, image, how: str): - return self.model.normalize_display_image(image, how) - - def create_preprocessed_data(self, image_data=None): - return self.model.create_preprocessed_data(image_data=image_data) diff --git a/cellacdc/viewmodels/quick_settings_viewmodel.py b/cellacdc/viewmodels/quick_settings_viewmodel.py deleted file mode 100644 index 98f5ae306..000000000 --- a/cellacdc/viewmodels/quick_settings_viewmodel.py +++ /dev/null @@ -1,33 +0,0 @@ -"""View-model contracts for quick settings.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.quick_settings_model import ( - FontSizeSetting, - QuickSettingsModel, -) - - -@dataclass(frozen=True) -class QuickSettingsViewModel: - """Application-facing quick-settings commands.""" - - model: QuickSettingsModel = field(default_factory=QuickSettingsModel) - - def font_size_setting( - self, - saved_font_size, - *, - has_px_mode: bool, - ) -> FontSizeSetting: - return self.model.font_size_setting( - saved_font_size, - has_px_mode=has_px_mode, - ) - - def should_update_all_contours(self, *, is_data_loaded: bool) -> bool: - return self.model.should_update_all_contours( - is_data_loaded=is_data_loaded - ) diff --git a/cellacdc/viewmodels/saving_viewmodel.py b/cellacdc/viewmodels/saving_viewmodel.py deleted file mode 100644 index 8ffccc4c9..000000000 --- a/cellacdc/viewmodels/saving_viewmodel.py +++ /dev/null @@ -1,137 +0,0 @@ -"""View-model contracts for save and autosave workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Literal - -from cellacdc.models.saving_model import ( - AutosaveIntervalChange, - ConcatenatePromptPlan, - SavingModel, -) - -from .cca_workflows_viewmodel import CcaWorkflowViewModel -from .formatting_viewmodel import FormattingViewModel -from .measurements_viewmodel import MeasurementsViewModel -from .tracking_viewmodel import TrackingViewModel -from .workspace_viewmodel import WorkspaceViewModel - - -@dataclass(frozen=True) -class SavingViewModel: - """Application-facing save/autosave commands and decisions.""" - - model: SavingModel = field(default_factory=SavingModel) - cca_workflows: CcaWorkflowViewModel = field( - default_factory=CcaWorkflowViewModel - ) - formatting: FormattingViewModel = field(default_factory=FormattingViewModel) - measurements: MeasurementsViewModel = field( - default_factory=MeasurementsViewModel - ) - tracking: TrackingViewModel = field(default_factory=TrackingViewModel) - workspace: WorkspaceViewModel = field(default_factory=WorkspaceViewModel) - - def should_clear_autosave_status(self, *, mode: str) -> bool: - return self.model.should_clear_autosave_status(mode=mode) - - def should_enqueue_autosave( - self, - *, - mode: str, - has_active_workers: bool, - ): - return self.model.should_enqueue_autosave( - mode=mode, - has_active_workers=has_active_workers, - ) - - def autosave_schedule( - self, - value: float, - unit: Literal['minutes', 'frames'], - ): - return self.model.autosave_schedule(value, unit) - - def autosave_interval_change( - self, - value: float, - unit: Literal['minutes', 'frames'], - ) -> AutosaveIntervalChange: - return self.model.autosave_interval_change(value, unit) - - def concatenate_prompt_plan( - self, - *, - has_main_window: bool, - is_quick_save: bool, - setting_exists: bool, - show_setting_value: str | None, - ) -> ConcatenatePromptPlan: - return self.model.concatenate_prompt_plan( - has_main_window=has_main_window, - is_quick_save=is_quick_save, - setting_exists=setting_exists, - show_setting_value=show_setting_value, - ) - - def concatenate_prompt_setting(self, *, do_not_show_again: bool) -> str: - return self.model.concatenate_prompt_setting( - do_not_show_again=do_not_show_again - ) - - def autosave_segmentation_enabled(self, *, mode: str, checked: bool) -> bool: - return self.model.autosave_segmentation_enabled( - mode=mode, - checked=checked, - ) - - def autosave_annotations_enabled(self, *, mode: str, checked: bool) -> bool: - return self.model.autosave_annotations_enabled( - mode=mode, - checked=checked, - ) - - def save_as_basename(self, basename: str) -> str: - return self.model.save_as_basename(basename) - - def quick_save_positions(self, position_foldername: str) -> set[str]: - return self.model.quick_save_positions(position_foldername) - - def should_ask_positions( - self, - *, - is_snapshot: bool, - is_quick_save: bool, - position_count: int, - ) -> bool: - return self.model.should_ask_positions( - is_snapshot=is_snapshot, - is_quick_save=is_quick_save, - position_count=position_count, - ) - - def should_compute_volume_metrics( - self, - *, - save_metrics: bool, - mode: str, - ) -> bool: - return self.model.should_compute_volume_metrics( - save_metrics=save_metrics, - mode=mode, - ) - - def save_finished_title( - self, - *, - aborted: bool, - worker_aborted: bool, - is_quick_save: bool, - ) -> tuple[str, str | None]: - return self.model.save_finished_title( - aborted=aborted, - worker_aborted=worker_aborted, - is_quick_save=is_quick_save, - ) diff --git a/cellacdc/viewmodels/seg_for_lost_ids_viewmodel.py b/cellacdc/viewmodels/seg_for_lost_ids_viewmodel.py deleted file mode 100644 index f46fb0cdc..000000000 --- a/cellacdc/viewmodels/seg_for_lost_ids_viewmodel.py +++ /dev/null @@ -1,53 +0,0 @@ -"""View-model contracts for segmenting lost IDs.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -from cellacdc.models.seg_for_lost_ids_model import ( - SegForLostIdsModel, - SegForLostIdsSettings, -) -from cellacdc.myutils import ArgSpec - - -@dataclass(frozen=True) -class SegForLostIdsViewModel: - """Application-facing commands for lost-ID segmentation settings.""" - - model: SegForLostIdsModel = field(default_factory=SegForLostIdsModel) - - @property - def settings_key(self) -> str: - return self.model.settings_key - - @property - def worker_model_name(self) -> str: - return self.model.worker_model_name - - def previous_model_name(self, df_settings) -> str | None: - return self.model.previous_model_name(df_settings) - - def should_persist_model_choice(self, base_model_name: str | None) -> bool: - return self.model.should_persist_model_choice(base_model_name) - - def extra_arg_specs(self) -> list[ArgSpec]: - return self.model.extra_arg_specs() - - def split_model_kwargs( - self, - init_kwargs: dict[str, Any], - extra_kwargs: dict[str, Any], - ) -> tuple[dict[str, Any], dict[str, Any]]: - return self.model.split_model_kwargs(init_kwargs, extra_kwargs) - - def settings_from_dialog( - self, - win, - base_model_name: str, - ) -> SegForLostIdsSettings: - return self.model.settings_from_dialog(win, base_model_name) - - def can_start_from_frame(self, frame_i: int) -> bool: - return self.model.can_start_from_frame(frame_i) diff --git a/cellacdc/viewmodels/segmentation_viewmodel.py b/cellacdc/viewmodels/segmentation_viewmodel.py deleted file mode 100644 index a2fb87922..000000000 --- a/cellacdc/viewmodels/segmentation_viewmodel.py +++ /dev/null @@ -1,82 +0,0 @@ -"""View-model contracts for segmentation workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.segmentation_model import SegmentationModel - -from .model_registry_viewmodel import ModelRegistryViewModel - - -@dataclass(frozen=True) -class SegmentationViewModel: - """Application-facing segmentation commands and decisions.""" - - model: SegmentationModel = field(default_factory=SegmentationModel) - model_registry: ModelRegistryViewModel = field( - default_factory=ModelRegistryViewModel - ) - - def action_model_name(self, model_name: str) -> str: - return self.model.action_model_name(model_name) - - def backend_model_name(self, model_name: str) -> str: - return self.model.backend_model_name(model_name) - - def should_compute_segmentation( - self, - *, - mode: str, - has_labels: bool, - force: bool, - auto_enabled: bool, - ) -> bool: - return self.model.should_compute_segmentation( - mode=mode, - has_labels=has_labels, - force=force, - auto_enabled=auto_enabled, - ) - - def post_process_params( - self, - *, - apply_postprocessing, - standard_postprocess_kwargs=None, - custom_postprocess_features=None, - ) -> dict: - return self.model.post_process_params( - apply_postprocessing=apply_postprocessing, - standard_postprocess_kwargs=standard_postprocess_kwargs, - custom_postprocess_features=custom_postprocess_features, - ) - - def empty_segmentation_prompt(self, position_data): - return self.model.empty_segmentation_prompt(position_data) - - def segmentation_models(self, *, include_local_seg: bool = False): - return self.model_registry.segmentation_models( - include_local_seg=include_local_seg - ) - - def store_custom_model_path(self, model_file_path): - return self.model_registry.store_custom_model_path(model_file_path) - - def import_segmentation_module(self, model_name): - return self.model_registry.import_segmentation_module(model_name) - - def model_arg_specs(self, acdc_segment): - return self.model_registry.model_arg_specs(acdc_segment) - - def insert_model_arg_spec(self, *args, **kwargs): - return self.model_registry.insert_model_arg_spec(*args, **kwargs) - - def log_segmentation_params(self, *args, **kwargs): - return self.model_registry.log_segmentation_params(*args, **kwargs) - - def check_gpu_available(self, *args, **kwargs): - return self.model_registry.check_gpu_available(*args, **kwargs) - - def init_segmentation_model(self, *args, **kwargs): - return self.model_registry.init_segmentation_model(*args, **kwargs) diff --git a/cellacdc/viewmodels/session_viewmodel.py b/cellacdc/viewmodels/session_viewmodel.py deleted file mode 100644 index 877b92e68..000000000 --- a/cellacdc/viewmodels/session_viewmodel.py +++ /dev/null @@ -1,144 +0,0 @@ -"""View-model commands for session frame state.""" - -from __future__ import annotations - -import os - -import pandas as pd - -from cellacdc.models.session_model import SessionModel -from cellacdc.domain import PositionSession -from cellacdc.domain.visited_frames import ( - LastVisitedFrameUpdate, - update_last_visited_frame_state, -) - -from .cca_edits_viewmodel import CcaEditViewModel -from .frame_metadata_viewmodel import FrameMetadataViewModel -from .tables_viewmodel import TableViewModel -from .workspace_viewmodel import WorkspaceViewModel - - -DEFAULT_SESSION_SETTINGS = { - 'is_bw_inverted': 'No', - 'fontSize': 12, - 'overlayColor': '255-255-0', - 'how_normIntensities': 'raw', - 'isLabelsVisible': 'No', - 'isNextFrameVisible': 'No', - 'isRightImageVisible': 'Yes', - 'manual_separate_draw_mode': 'threepoints_arc', -} - - -class SessionViewModel: - """Application-facing commands for session progress state.""" - - def __init__( - self, - model: SessionModel | None = None, - *, - cca_edits: CcaEditViewModel | None = None, - frame_metadata: FrameMetadataViewModel | None = None, - tables: TableViewModel | None = None, - workspace: WorkspaceViewModel | None = None, - ): - self.model = model or SessionModel() - self.cca_edits = cca_edits or CcaEditViewModel() - self.frame_metadata = frame_metadata or FrameMetadataViewModel() - self.tables = tables or TableViewModel() - self.workspace = workspace or WorkspaceViewModel() - - def recent_paths(self, recent_paths_path) -> list[str]: - if not os.path.exists(recent_paths_path): - return [] - - recent_paths_df = pd.read_csv(recent_paths_path, index_col='index') - recent_paths_df['path'] = recent_paths_df['path'].str.replace('\\', '/') - recent_paths_df = recent_paths_df.drop_duplicates(subset=['path']) - recent_paths_df.to_csv(recent_paths_path) - if 'opened_last_on' in recent_paths_df.columns: - recent_paths_df = recent_paths_df.sort_values( - 'opened_last_on', - ascending=False, - ) - return recent_paths_df['path'].to_list() - - def load_settings(self, settings_csv_path) -> pd.DataFrame: - if os.path.exists(settings_csv_path): - settings_df = pd.read_csv(settings_csv_path, index_col='setting') - settings_df['value'] = settings_df['value'].astype(object) - if 'is_bw_inverted' in settings_df.index: - settings_df.loc['is_bw_inverted'] = ( - settings_df.loc['is_bw_inverted'].astype(str) - ) - else: - settings_df.at['is_bw_inverted', 'value'] = 'No' - if 'how_normIntensities' not in settings_df.index: - raw = 'Do not normalize. Display raw image' - settings_df.at['how_normIntensities', 'value'] = raw - else: - settings_df = pd.DataFrame( - { - 'setting': list(DEFAULT_SESSION_SETTINGS.keys())[:4], - 'value': list(DEFAULT_SESSION_SETTINGS.values())[:4], - } - ).set_index('setting') - - for key, value in DEFAULT_SESSION_SETTINGS.items(): - if key not in settings_df.index: - settings_df.at[key, 'value'] = value - - return settings_df - - def position_session_from_load_data(self, pos_data) -> PositionSession: - return PositionSession.from_loadData(pos_data) - - def update_last_visited_frame( - self, - mode: str, - last_visited_frame_i: int, - *, - last_tracked_i: int, - last_cca_frame_i: int, - ) -> LastVisitedFrameUpdate: - return update_last_visited_frame_state( - mode, - last_visited_frame_i, - last_tracked_i=last_tracked_i, - last_cca_frame_i=last_cca_frame_i, - ) - - def should_store_frame_data( - self, - *, - frame_i: int, - mode: str, - enforce: bool, - ) -> bool: - return self.model.should_store_frame_data( - frame_i=frame_i, - mode=mode, - enforce=enforce, - ) - - def should_disable_load_position(self, position_count: int) -> bool: - return self.model.should_disable_load_position(position_count) - - def empty_labels( - self, - *, - is_3d: bool, - size_z: int, - size_y: int, - size_x: int, - ): - return self.model.empty_labels( - is_3d=is_3d, - size_z=size_z, - size_y=size_y, - size_x=size_x, - ) - - def should_resume_last_session_prompt(self, last_tracked_num: int) -> bool: - return self.model.should_resume_last_session_prompt(last_tracked_num) diff --git a/cellacdc/viewmodels/status_hover_viewmodel.py b/cellacdc/viewmodels/status_hover_viewmodel.py deleted file mode 100644 index e42655cd4..000000000 --- a/cellacdc/viewmodels/status_hover_viewmodel.py +++ /dev/null @@ -1,53 +0,0 @@ -"""View-model contracts for hover and status-bar text.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.status_hover_model import StatusHoverModel - - -@dataclass(frozen=True) -class StatusHoverViewModel: - """Application-facing status/hover formatting commands.""" - - model: StatusHoverModel = field(default_factory=StatusHoverModel) - - def channel_hover_text(self, description, channel, value, format_spec): - return self.model.channel_hover_text( - description, channel, value, format_spec - ) - - def object_hover_text(self, *, label_id, max_id, object_count): - return self.model.object_hover_text( - label_id=label_id, - max_id=max_id, - object_count=object_count, - ) - - def base_hover_text(self, **kwargs): - return self.model.base_hover_text(**kwargs) - - def replace_view_range_status(self, text, **kwargs): - return self.model.replace_view_range_status(text, **kwargs) - - def highlight_state(self, **kwargs): - return self.model.highlight_state(**kwargs) - - def mouse_data_coords_right_image(self, text): - return self.model.mouse_data_coords_right_image(text) - - def ruler_length_text(self, text): - return self.model.ruler_length_text(text) - - def ruler_measurement_text(self, *, length_pixels, pixel_to_um): - return self.model.ruler_measurement_text( - length_pixels=length_pixels, - pixel_to_um=pixel_to_um, - ) - - def euclidean_length(self, x_values, y_values): - return self.model.euclidean_length(x_values, y_values) - - def status_bar_text(self, **kwargs): - return self.model.status_bar_text(**kwargs) diff --git a/cellacdc/viewmodels/tool_activation_viewmodel.py b/cellacdc/viewmodels/tool_activation_viewmodel.py deleted file mode 100644 index 5ac44d751..000000000 --- a/cellacdc/viewmodels/tool_activation_viewmodel.py +++ /dev/null @@ -1,60 +0,0 @@ -"""View-model contracts for active-tool workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.tool_activation_model import ToolActivationModel - -from .label_edits_viewmodel import LabelEditViewModel -from .tracking_viewmodel import TrackingViewModel - - -@dataclass(frozen=True) -class ToolActivationViewModel: - """Application-facing decisions for active tools.""" - - model: ToolActivationModel = field(default_factory=ToolActivationModel) - label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) - tracking: TrackingViewModel = field(default_factory=TrackingViewModel) - - def manual_annotation_highlight_color( - self, - *, - current_frame_i: int, - frame_to_restore: int | None, - ) -> str: - return self.model.manual_annotation_highlight_color( - current_frame_i=current_frame_i, - frame_to_restore=frame_to_restore, - ) - - def should_highlight_hover_lost_object( - self, - *, - has_no_modifier: bool, - copy_lost_object_checked: bool, - is_exit_event: bool, - ) -> bool: - return self.model.should_highlight_hover_lost_object( - has_no_modifier=has_no_modifier, - copy_lost_object_checked=copy_lost_object_checked, - is_exit_event=is_exit_event, - ) - - def point_in_shape(self, x: int, y: int, shape: tuple[int, int]) -> bool: - return self.model.point_in_shape(x, y, shape) - - def should_hide_hover_objects( - self, - *, - brush_auto_hide_checked: bool, - force: bool, - ) -> bool: - return self.model.should_hide_hover_objects( - brush_auto_hide_checked=brush_auto_hide_checked, - force=force, - ) - - def should_disable_non_functional_buttons(self, is_segm_3d: bool) -> bool: - return self.model.should_disable_non_functional_buttons(is_segm_3d) diff --git a/cellacdc/viewmodels/tracking_viewmodel.py b/cellacdc/viewmodels/tracking_viewmodel.py deleted file mode 100644 index 98cd828cc..000000000 --- a/cellacdc/viewmodels/tracking_viewmodel.py +++ /dev/null @@ -1,101 +0,0 @@ -"""View-model commands for tracking workflows.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.domain.tracking import ( - FutureIdPropagationScan, - LostNewIdsResult, - TrackedLostIdsResult, -) -from cellacdc.models.tracking_model import TrackingModel - -from .edit_id_viewmodel import EditIdViewModel -from .geometry_viewmodel import GeometryViewModel -from .label_edits_viewmodel import LabelEditViewModel -from .model_registry_viewmodel import ModelRegistryViewModel - - -@dataclass(frozen=True) -class TrackingViewModel: - """Application-facing commands for tracking state calculations.""" - - model: TrackingModel = field(default_factory=TrackingModel) - edit_id: EditIdViewModel = field(default_factory=EditIdViewModel) - geometry: GeometryViewModel = field(default_factory=GeometryViewModel) - label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) - model_registry: ModelRegistryViewModel = field( - default_factory=ModelRegistryViewModel - ) - - def compute_lost_new_ids( - self, - previous_ids, - current_ids, - *, - current_deleted_roi_ids=(), - previous_deleted_roi_ids=(), - tracked_lost_ids=(), - ) -> LostNewIdsResult: - return self.model.compute_lost_new_ids( - previous_ids, - current_ids, - current_deleted_roi_ids=current_deleted_roi_ids, - previous_deleted_roi_ids=previous_deleted_roi_ids, - tracked_lost_ids=tracked_lost_ids, - ) - - def tracked_lost_centroids_from_regionprops( - self, - regionprops, - tracked_lost_ids, - ) -> set[tuple[int, ...]]: - return self.model.tracked_lost_centroids_from_regionprops( - regionprops, - tracked_lost_ids, - ) - - def tracked_lost_ids_from_centroids( - self, - previous_labels, - tracked_lost_centroids, - ids_in_frame, - ) -> TrackedLostIdsResult: - return self.model.tracked_lost_ids_from_centroids( - previous_labels, - tracked_lost_centroids, - ids_in_frame, - ) - - def last_tracked_frame_index( - self, - frame_labels, - *, - first_frame_fallback: int = 0, - total_frames: int | None = None, - ) -> int: - return self.model.last_tracked_frame_index( - frame_labels, - first_frame_fallback=first_frame_fallback, - total_frames=total_frames, - ) - - def scan_future_id_propagation( - self, - target_id: int, - *, - current_frame_i: int, - frame_labels, - fallback_frame_labels, - include_unvisited: bool = False, - total_frames: int | None = None, - ) -> FutureIdPropagationScan: - return self.model.scan_future_id_propagation( - target_id, - current_frame_i=current_frame_i, - frame_labels=frame_labels, - fallback_frame_labels=fallback_frame_labels, - include_unvisited=include_unvisited, - total_frames=total_frames, - ) diff --git a/cellacdc/viewmodels/undo_redo_viewmodel.py b/cellacdc/viewmodels/undo_redo_viewmodel.py deleted file mode 100644 index 1f814fe21..000000000 --- a/cellacdc/viewmodels/undo_redo_viewmodel.py +++ /dev/null @@ -1,39 +0,0 @@ -"""View-model contracts for undo and redo stack handling.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.undo_redo_model import UndoRedoModel - - -@dataclass(frozen=True) -class UndoRedoViewModel: - """Application-facing commands for undo/redo stack decisions.""" - - model: UndoRedoModel = field(default_factory=UndoRedoModel) - - def empty_frame_stacks(self, size_t: int) -> list[list]: - return self.model.empty_frame_stacks(size_t) - - def empty_add_point_queue(self): - return self.model.empty_add_point_queue() - - def trim_label_states(self, states: list) -> None: - self.model.trim_stack(states, max_size=5) - - def trim_cca_states(self, states: list) -> None: - self.model.trim_stack(states, max_size=10) - - def can_undo_labels(self, undo_count: int, states: list) -> bool: - return self.model.can_undo_labels(undo_count, states) - - def can_redo_labels(self, undo_count: int) -> bool: - return self.model.can_redo_labels(undo_count) - - def should_disable_undo_after_cca( - self, - undo_count: int, - states: list, - ) -> bool: - return self.model.should_disable_undo_after_cca(undo_count, states) diff --git a/cellacdc/viewmodels/whitelist_viewmodel.py b/cellacdc/viewmodels/whitelist_viewmodel.py deleted file mode 100644 index 96ada888d..000000000 --- a/cellacdc/viewmodels/whitelist_viewmodel.py +++ /dev/null @@ -1,46 +0,0 @@ -"""View-model contract for the Whitelist feature.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Set -from cellacdc.models.whitelist_model import WhitelistModel - - -@dataclass(frozen=True) -class WhitelistViewModel: - """Presentation logic and commands for Whitelist management.""" - - model: WhitelistModel = field(default_factory=WhitelistModel) - - def filter_existing_ids(self, current_whitelist: Set[int], possible_ids: Set[int]) -> tuple[Set[int], bool]: - """Filters out non-existing IDs from the current whitelist.""" - return self.model.filter_existing_ids(current_whitelist, possible_ids) - - def get_missing_ids(self, current_ids: Set[int], previous_ids: Set[int]) -> Set[int]: - """Returns the set of IDs present in current frame but missing from previous frame.""" - return self.model.get_missing_ids(current_ids, previous_ids) - - def check_original_labels(self, whitelist_obj, frame_i: int) -> bool: - """Delegate label check to model.""" - return self.model.check_original_labels(whitelist_obj, frame_i) - - def get_frames_range(self, frame_i: int) -> list[int]: - """Delegate range calculation to model.""" - return self.model.get_frames_range(frame_i) - - def get_diff_ids(self, old_ids: Set[int], prev_ids: Set[int], new_ids: Set[int]) -> Set[int]: - """Delegate difference to model.""" - return self.model.get_diff_ids(old_ids, prev_ids, new_ids) - - def get_whitelist_missing_and_removed_ids(self, whitelist: Set[int], current_ids: Set[int]) -> tuple[list[int], list[int]]: - """Delegate ID evaluation to model.""" - return self.model.get_whitelist_missing_and_removed_ids(whitelist, current_ids) - - def apply_id_mask(self, curr_lab, og_lab, missing_ids, to_be_removed_ids): - """Delegate mask updates to model.""" - return self.model.apply_id_mask(curr_lab, og_lab, missing_ids, to_be_removed_ids) - - def construct_og_frame(self, pos_lab, og_frame_base, whitelist_ids, og_ids): - """Delegate overlay construction to model.""" - return self.model.construct_og_frame(pos_lab, og_frame_base, whitelist_ids, og_ids) diff --git a/cellacdc/viewmodels/window_events_viewmodel.py b/cellacdc/viewmodels/window_events_viewmodel.py deleted file mode 100644 index b94e4f2a2..000000000 --- a/cellacdc/viewmodels/window_events_viewmodel.py +++ /dev/null @@ -1,35 +0,0 @@ -"""View-model behavior for main-window event handling.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.window_events_model import WindowEventsModel - -from .geometry_viewmodel import GeometryViewModel - - -@dataclass(frozen=True) -class WindowEventsViewModel: - """GUI-facing helpers for main-window event handling.""" - - model: WindowEventsModel = field(default_factory=WindowEventsModel) - geometry: GeometryViewModel = field(default_factory=GeometryViewModel) - - def windows_overlap_from_bounds(self, **kwargs): - return self.geometry.windows_overlap_from_bounds(**kwargs) - - def should_auto_activate_viewer(self, **kwargs): - return self.geometry.should_auto_activate_viewer(**kwargs) - - def is_pan_image_click(self, **kwargs): - return self.geometry.is_pan_image_click(**kwargs) - - def is_default_middle_click(self, **kwargs): - return self.geometry.is_default_middle_click(**kwargs) - - def is_configured_middle_click(self, **kwargs): - return self.geometry.is_configured_middle_click(**kwargs) - - def middle_click_text(self, **kwargs): - return self.geometry.middle_click_text(**kwargs) diff --git a/cellacdc/viewmodels/worker_viewmodel.py b/cellacdc/viewmodels/worker_viewmodel.py deleted file mode 100644 index 9a56d8630..000000000 --- a/cellacdc/viewmodels/worker_viewmodel.py +++ /dev/null @@ -1,36 +0,0 @@ -"""View-model contracts for GUI worker lifecycle handling.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from cellacdc.models.worker_model import WorkerModel - - -@dataclass(frozen=True) -class WorkerViewModel: - """Application-facing commands for worker progress decisions.""" - - model: WorkerModel = field(default_factory=WorkerModel) - - def progress_log_level(self, logger_level: str = 'INFO') -> str: - return self.model.progress_log_level(logger_level) - - def progressbar_maximum(self, total_iterations: int) -> int: - return self.model.progressbar_maximum(total_iterations) - - def lazy_loader_progress_description(self, chunk_range) -> str: - return self.model.lazy_loader_progress_description(chunk_range) - - def should_enqueue_autosave(self, is_saving: bool) -> bool: - return self.model.should_enqueue_autosave(is_saving) - - def should_disable_realtime_tracking( - self, - tracking_on_never_visited_frames: bool, - realtime_tracking_enabled: bool, - ) -> bool: - return self.model.should_disable_realtime_tracking( - tracking_on_never_visited_frames, - realtime_tracking_enabled, - ) diff --git a/cellacdc/views/actions_view.py b/cellacdc/views/actions_view.py index c25952944..2a12691db 100644 --- a/cellacdc/views/actions_view.py +++ b/cellacdc/views/actions_view.py @@ -10,7 +10,6 @@ from qtpy.QtWidgets import QAction, QActionGroup, QToolButton from cellacdc import apps, is_mac, settings_folderpath, widgets -from cellacdc.viewmodels.actions_viewmodel import ActionsViewModel shortcut_filepath = os.path.join(settings_folderpath, 'shortcuts.ini') @@ -18,6 +17,33 @@ class ActionsView: """Qt-facing adapter around action construction and shortcut editing.""" + """Headless decisions for action and shortcut workflows.""" + + keyboard_shortcuts_section = 'keyboard.shortcuts' + delete_object_section = 'delete_object.action' + delete_key_option = 'Key sequence' + delete_button_option = 'Mouse button' + + def default_delete_object_texts(self, *, is_mac: bool) -> tuple[str, str]: + if is_mac: + return 'Ctrl', 'Left click' + return '', 'Middle click' + + def sanitize_key_sequence_text(self, text) -> str: + if text is None: + return '' + return str(text).encode('ascii', 'ignore').decode('utf-8') + + def delete_object_button_text(self, *, is_left_click: bool) -> str: + return 'Left click' if is_left_click else 'Middle click' + + def delete_object_button_is_left_click(self, text: str) -> bool: + return text == 'Left click' + + def should_restore_default_delete_action(self, *, had_error: bool) -> bool: + return had_error + + LEGACY_METHODS = ( 'gui_createActions', 'gui_updateSwitchColorSchemeActionText', @@ -28,15 +54,13 @@ class ActionsView: 'gui_connectEditActions', ) - def __init__(self, host, view_model: ActionsViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -139,7 +163,7 @@ def gui_createActions(self): self.skipToNewIdAction.setDisabled(True) # Edit actions - models = self.view_model.model_registry.segmentation_models( + models = self.model_registry.segmentation_models( include_local_seg=True ) self.segmActions = [] @@ -215,14 +239,14 @@ def gui_createActions(self): self.trackWithYeazAction.setCheckable(True) self.trackingAlgosGroup.addAction(self.trackWithYeazAction) - rt_trackers = self.view_model.model_registry.real_time_trackers() + rt_trackers = self.model_registry.real_time_trackers() for rt_tracker in rt_trackers: rtTrackerAction = QAction(rt_tracker, self.host) rtTrackerAction.setCheckable(True) self.trackingAlgosGroup.addAction(rtTrackerAction) self.trackWithAcdcAction.setChecked(True) - aliases = self.view_model.model_registry.real_time_tracker_aliases() + aliases = self.model_registry.real_time_tracker_aliases() if 'tracking_algorithm' in self.df_settings.index: trackingAlgo = self.df_settings.at['tracking_algorithm', 'value'] @@ -529,7 +553,7 @@ def gui_connectActions(self): # Connect Help actions self.tipsAction.triggered.connect(self.showTipsAndTricks) self.UserManualAction.triggered.connect( - self.view_model.app_shell.browse_docs + self.app_shell.browse_docs ) self.openLogFileAction.triggered.connect(self.openLogFile) self.showLogFilesAction.triggered.connect(self.showLogFiles) @@ -571,8 +595,8 @@ def initShortcuts(self): if os.path.exists(shortcut_filepath): cp.read(shortcut_filepath) - shortcuts_section = self.view_model.keyboard_shortcuts_section - delete_section = self.view_model.delete_object_section + shortcuts_section = self.keyboard_shortcuts_section + delete_section = self.delete_object_section if shortcuts_section not in cp: cp[shortcuts_section] = {} @@ -590,14 +614,14 @@ def initShortcuts(self): self.delObjAction = None else: delObjKeySequenceText = ( - cp[delete_section][self.view_model.delete_key_option] + cp[delete_section][self.delete_key_option] ) delObjButtonText = ( - cp[delete_section][self.view_model.delete_button_option] + cp[delete_section][self.delete_button_option] ) delObjQtButton = ( Qt.MouseButton.LeftButton - if self.view_model.delete_object_button_is_left_click( + if self.delete_object_button_is_left_click( delObjButtonText ) else Qt.MouseButton.MiddleButton @@ -651,8 +675,8 @@ def setShortcuts(self, shortcuts: dict, save=True): if os.path.exists(shortcut_filepath): cp.read(shortcut_filepath) - shortcuts_section = self.view_model.keyboard_shortcuts_section - delete_section = self.view_model.delete_object_section + shortcuts_section = self.keyboard_shortcuts_section + delete_section = self.delete_object_section if shortcuts_section not in cp: cp[shortcuts_section] = {} @@ -674,18 +698,18 @@ def setShortcuts(self, shortcuts: dict, save=True): delObjKeySequenceText = delObjKeySequence.toString() delObjKeySequenceText = ( - self.view_model.sanitize_key_sequence_text( + self.sanitize_key_sequence_text( delObjKeySequenceText ) ) - delObjButtonText = self.view_model.delete_object_button_text( + delObjButtonText = self.delete_object_button_text( is_left_click=( delObjQtButton == Qt.MouseButton.LeftButton ) ) cp[delete_section] = { - self.view_model.delete_key_option: delObjKeySequenceText, - self.view_model.delete_button_option: delObjButtonText + self.delete_key_option: delObjKeySequenceText, + self.delete_button_option: delObjButtonText } except Exception as err: self.logger.warning( @@ -700,7 +724,7 @@ def setShortcuts(self, shortcuts: dict, save=True): def editShortcuts_cb(self): delObjKeySequenceText, delObjButtonText = ( - self.view_model.default_delete_object_texts(is_mac=is_mac) + self.default_delete_object_texts(is_mac=is_mac) ) if self.delObjAction is not None: @@ -710,11 +734,11 @@ def editShortcuts_cb(self): else: delObjKeySequenceText = delObjKeySequence.toString() delObjKeySequenceText = ( - self.view_model.sanitize_key_sequence_text( + self.sanitize_key_sequence_text( delObjKeySequenceText ) ) - delObjButtonText = self.view_model.delete_object_button_text( + delObjButtonText = self.delete_object_button_text( is_left_click=( delObjQtButton == Qt.MouseButton.LeftButton ) @@ -1040,4 +1064,4 @@ def gui_connectEditActions(self): propsQGBox = self.guiTabControl.propsQGBox propsQGBox.additionalPropsCombobox.currentTextChanged.connect( self.updatePropsWidget - ) + ) \ No newline at end of file diff --git a/cellacdc/views/annotation_display_view.py b/cellacdc/views/annotation_display_view.py index 9cb48f0bd..f6d70751b 100644 --- a/cellacdc/views/annotation_display_view.py +++ b/cellacdc/views/annotation_display_view.py @@ -5,10 +5,9 @@ import re from cellacdc import _palettes, apps, html_utils, widgets -from cellacdc.viewmodels.annotation_display_viewmodel import ( - AnnotationDisplayViewModel, -) +from dataclasses import dataclass +from typing import Literal, Mapping GREEN_HEX = _palettes.green() @@ -77,16 +76,14 @@ class AnnotationDisplayView: 'drawAnnotCombobox_to_options', ) - def __init__(self, host, view_model: AnnotationDisplayViewModel): - object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - self._connect_view_model_signals() + def __init__(self, host): + object.__setattr__(self, 'host', host) self._connect_view_model_signals() def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -96,71 +93,71 @@ def bind_legacy_methods(self): setattr(self.host, name, getattr(self, name)) def _connect_view_model_signals(self): - self.view_model.settingUpdateRequested.connect( + self.settingUpdateRequested.connect( self._apply_view_model_setting_update ) - self.view_model.textAnnotationFlagsChanged.connect( + self.textAnnotationFlagsChanged.connect( self._apply_text_annotation_flags ) - self.view_model.imageRefreshRequested.connect( + self.imageRefreshRequested.connect( self._refresh_images_from_view_model ) - self.view_model.eraserTempResetRequested.connect( + self.eraserTempResetRequested.connect( self._reset_eraser_temp_from_view_model ) - self.view_model.annotationOptionStatesChanged.connect( + self.annotationOptionStatesChanged.connect( self._apply_annotation_option_states ) - self.view_model.annotationModeTextUpdateRequested.connect( + self.annotationModeTextUpdateRequested.connect( self._apply_annotation_mode_text_update ) - self.view_model.textAnnotationPixelModeChanged.connect( + self.textAnnotationPixelModeChanged.connect( self._apply_text_annotation_pixel_mode ) - self.view_model.logInfoRequested.connect(self.logger.info) - self.view_model.pixelModeActionDisabledChanged.connect( + self.logInfoRequested.connect(self.logger.info) + self.pixelModeActionDisabledChanged.connect( self.pxModeAction.setDisabled ) - self.view_model.textResolutionChangeRequested.connect( + self.textResolutionChangeRequested.connect( self._apply_text_resolution_change ) - self.view_model.treeAnnotationMenuActionRequested.connect( + self.treeAnnotationMenuActionRequested.connect( self._apply_tree_annotation_menu_action ) - self.view_model.labelTreeAnnotationsEnabledChanged.connect( + self.labelTreeAnnotationsEnabledChanged.connect( self._apply_label_tree_annotations_enabled ) - self.view_model.genNumTreeAnnotationsEnabledChanged.connect( + self.genNumTreeAnnotationsEnabledChanged.connect( self._apply_gen_num_tree_annotations_enabled ) - self.view_model.allTextAnnotationsRefreshRequested.connect( + self.allTextAnnotationsRefreshRequested.connect( self.setAllTextAnnotations ) - self.view_model.annotationOptionDisabledChanged.connect( + self.annotationOptionDisabledChanged.connect( self._apply_annotation_option_disabled ) - self.view_model.annotationOptionVisibleChanged.connect( + self.annotationOptionVisibleChanged.connect( self._apply_annotation_option_visible ) - self.view_model.annotationOptionCheckedChanged.connect( + self.annotationOptionCheckedChanged.connect( self._apply_annotation_option_checked ) - self.view_model.zNeighborHighlightVisibleChanged.connect( + self.zNeighborHighlightVisibleChanged.connect( self._apply_z_neighbor_highlight_visible ) - self.view_model.zNeighborHighlightCheckedChanged.connect( + self.zNeighborHighlightCheckedChanged.connect( self._apply_z_neighbor_highlight_checked ) - self.view_model.zNeighborHighlightToggleConnectionRequested.connect( + self.zNeighborHighlightToggleConnectionRequested.connect( self._connect_z_neighbor_highlight_toggle ) - self.view_model.annotationModeComboboxRestoreRequested.connect( + self.annotationModeComboboxRestoreRequested.connect( self._apply_annotation_mode_combobox_restore ) - self.view_model.addNewIdsWhitelistToggleChanged.connect( + self.addNewIdsWhitelistToggleChanged.connect( self._apply_add_new_ids_whitelist_toggle ) - self.view_model.annotationModeRestoreCallbackRequested.connect( + self.annotationModeRestoreCallbackRequested.connect( self._apply_annotation_mode_restore_callback ) @@ -321,7 +318,7 @@ def _apply_annotation_mode_text_update( combo.setCurrentText(text) def getAnnotateHowRightImage(self): - return self.view_model.right_annotation_mode( + return self.right_annotation_mode( show_right_image=self.labelsGrad.showRightImgAction.isChecked(), use_right_specific_mode=self.rightBottomGroupbox.isChecked(), right_mode=self.annotateRightHowCombobox.currentText(), @@ -350,7 +347,7 @@ def annotateRightHowCombobox_cb(self, idx): if hasattr(self.annotateRightHowCombobox, 'saveSettings'): saveSettings = self.annotateRightHowCombobox.saveSettings - self.view_model.change_annotation_mode( + self.change_annotation_mode( side='right', how=how, save_settings=saveSettings, @@ -366,7 +363,7 @@ def drawIDsContComboBox_cb(self, idx): if hasattr(self.drawIDsContComboBox, 'saveSettings'): saveSettings = self.drawIDsContComboBox.saveSettings - self.view_model.change_annotation_mode( + self.change_annotation_mode( side='left', how=how, save_settings=saveSettings, @@ -378,7 +375,7 @@ def drawIDsContComboBox_cb(self, idx): ) def areContoursRequested(self, ax): - return self.view_model.contours_requested( + return self.contours_requested( ax=ax, left_contours=self.annotContourCheckbox.isChecked(), right_image_visible=self.labelsGrad.showRightImgAction.isChecked(), @@ -387,7 +384,7 @@ def areContoursRequested(self, ax): ) def areMothBudLinesRequested(self, ax): - return self.view_model.moth_bud_lines_requested( + return self.moth_bud_lines_requested( ax=ax, left_cca=self.annotCcaInfoCheckbox.isChecked(), left_mother_bud_lines=self.drawMothBudLinesCheckbox.isChecked(), @@ -435,7 +432,7 @@ def drawObjMothBudLines(self, obj, posData, ax=0): ccs_ID = cca_df_ID['cell_cycle_stage'] relationship = cca_df_ID['relationship'] - if not self.view_model.should_draw_moth_bud_line( + if not self.should_draw_moth_bud_line( cca_df_available=posData.cca_df is not None, mode=mode, object_visible=self.isObjVisible(obj.bbox), @@ -457,7 +454,7 @@ def drawObjMothBudLines(self, obj, posData, ax=0): relative_ID_obj = posData.rp[relative_rp_idx] y1, x1 = self.getObjCentroid(obj.centroid) y2, x2 = self.getObjCentroid(relative_ID_obj.centroid) - xx, yy = self.view_model.geometry.line_coords( + xx, yy = self.geometry.line_coords( y1, x1, y2, x2, dashed=True ) scatterItem.addPoints(xx, yy) @@ -470,12 +467,422 @@ def clearAllCellToCellLines(self): def drawAllLineageTreeLines(self): """ + + """Headless annotation display decisions.""" + + def right_annotation_mode( + self, + *, + show_right_image: bool, + use_right_specific_mode: bool, + right_mode: str, + left_mode: str, + ) -> str: + if not show_right_image: + return 'nothing' + return right_mode if use_right_specific_mode else left_mode + + def text_annotation_flags( + self, + *, + annot_cca_checked: bool, + annot_ids_checked: bool, + mode: str, + ) -> tuple[bool, bool]: + is_lineage_mode = mode == 'Normal division: Lineage tree' + is_cca = annot_cca_checked and not is_lineage_mode + is_id = annot_ids_checked or (annot_cca_checked and is_lineage_mode) + return is_cca, is_id + + def annotation_mode_text( + self, + *, + ids: bool = False, + cca: bool = False, + contours: bool = False, + segm_masks: bool = False, + mother_bud_lines: bool = False, + nothing: bool = False, + ) -> str: + if ids: + if contours: + return 'Draw IDs and contours' + if segm_masks: + return 'Draw IDs and overlay segm. masks' + return 'Draw only IDs' + if cca: + if contours: + return 'Draw cell cycle info and contours' + if segm_masks: + return 'Draw cell cycle info and overlay segm. masks' + return 'Draw only cell cycle info' + if segm_masks: + return 'Draw only overlay segm. masks' + if contours: + return 'Draw only contours' + if mother_bud_lines: + return 'Draw only mother-bud lines' + if nothing: + return 'Draw nothing' + return 'Draw nothing' + + def annotation_flags_from_mode_text(self, text: str) -> dict[str, bool]: + return { + 'ids': 'IDs' in text, + 'cca': 'cell cycle info' in text, + 'contours': 'contours' in text, + 'segm_masks': 'segm. masks' in text, + 'mother_bud_lines': 'mother-bud lines' in text, + 'nothing': 'nothing' in text, + } + + def annotation_option_state_from_mode_text( + self, + text: str, + *, + num_zslices: bool = False, + ) -> AnnotationOptionState: + flags = self.annotation_flags_from_mode_text(text) + return AnnotationOptionState( + ids=flags['ids'], + cca=flags['cca'], + contours=flags['contours'], + segm_masks=flags['segm_masks'], + mother_bud_lines=flags['mother_bud_lines'], + num_zslices=num_zslices, + nothing=flags['nothing'], + ) + + def annotation_options_from_mode_text_plan( + self, + *, + left_text: str, + right_text: str, + left_num_zslices: bool = False, + right_num_zslices: bool = False, + ) -> AnnotationOptionsFromModeTextPlan: + return AnnotationOptionsFromModeTextPlan( + state_updates=( + ( + 'left', + self.annotation_option_state_from_mode_text( + left_text, + num_zslices=left_num_zslices, + ), + ), + ( + 'right', + self.annotation_option_state_from_mode_text( + right_text, + num_zslices=right_num_zslices, + ), + ), + ) + ) + + def restore_saved_settings_plan( + self, + settings_values: Mapping[str, object], + ) -> AnnotationDisplaySettingsRestorePlan: + return AnnotationDisplaySettingsRestorePlan( + left_mode=str( + settings_values.get( + 'how_draw_annotations', + 'Draw IDs and contours', + ) + ), + right_mode=str( + settings_values.get( + 'how_draw_right_annotations', + 'Draw IDs and overlay segm. masks', + ) + ), + add_new_ids_whitelist_toggle=( + settings_values.get('addNewIDsWhitelistToggle', 'Yes') == 'Yes' + ), + ) + + def contours_requested( + self, + *, + ax: int, + left_contours: bool, + right_image_visible: bool, + right_specific_mode: bool, + right_contours: bool, + ) -> bool: + if ax == 0: + return left_contours + if not right_image_visible: + return False + if right_specific_mode: + return right_contours + return left_contours + + def moth_bud_lines_requested( + self, + *, + ax: int, + left_cca: bool, + left_mother_bud_lines: bool, + right_image_visible: bool, + right_specific_mode: bool, + right_cca: bool, + right_mother_bud_lines: bool, + ) -> bool: + if ax == 0: + return left_cca or left_mother_bud_lines + if not right_image_visible: + return False + if right_specific_mode: + return right_cca or right_mother_bud_lines + return left_cca or left_mother_bud_lines + + def should_draw_moth_bud_line( + self, + *, + cca_df_available: bool, + mode: str, + object_visible: bool, + cell_cycle_stage: str, + relationship: str, + ) -> bool: + return ( + cca_df_available + and mode != 'Normal division: Lineage Tree' + and object_visible + and cell_cycle_stage != 'G1' + and relationship == 'bud' + ) + + def should_draw_lineage_tree_lines( + self, + *, + lineage_tree_available: bool, + frames_count: int, + ) -> bool: + return lineage_tree_available and frames_count >= 2 + + def annotation_mode_setting_update( + self, + side: AnnotationSide, + how: str, + ) -> tuple[str, str]: + setting = ( + 'how_draw_right_annotations' + if side == 'right' + else 'how_draw_annotations' + ) + return setting, how + + def annotation_mode_change_plan( + self, + *, + side: AnnotationSide, + how: str, + save_settings: bool, + annot_cca_checked: bool, + annot_ids_checked: bool, + mode: str, + is_data_loading: bool, + eraser_checked: bool = False, + ) -> AnnotationModeChangePlan: + setting_update = None + if save_settings: + setting_update = self.annotation_mode_setting_update(side, how) + + is_cca, is_id = self.text_annotation_flags( + annot_cca_checked=annot_cca_checked, + annot_ids_checked=annot_ids_checked, + mode=mode, + ) + return AnnotationModeChangePlan( + side=side, + setting_update=setting_update, + text_annotation_index=1 if side == 'right' else 0, + is_cca_annotation=is_cca, + is_id_annotation=is_id, + should_refresh_images=not is_data_loading, + should_reset_eraser_temp=side == 'left' and eraser_checked, + ) + + def annotation_option_change_plan( + self, + *, + side: AnnotationSide, + state: AnnotationOptionState, + clicked_option: AnnotationOption | None, + save_settings: bool, + ) -> AnnotationOptionChangePlan: + values = { + 'ids': state.ids, + 'cca': state.cca, + 'contours': state.contours, + 'segm_masks': state.segm_masks, + 'mother_bud_lines': state.mother_bud_lines, + 'num_zslices': state.num_zslices, + 'nothing': state.nothing, + } + + if values['ids'] and clicked_option == 'ids': + values['cca'] = False + values['mother_bud_lines'] = False + + if values['cca'] and clicked_option == 'cca': + values['ids'] = False + values['mother_bud_lines'] = False + + if ( + values['mother_bud_lines'] + and clicked_option == 'mother_bud_lines' + ): + values['ids'] = False + values['cca'] = False + + if values['contours'] and clicked_option == 'contours': + values['segm_masks'] = False + + if values['segm_masks'] and clicked_option == 'segm_masks': + values['contours'] = False + + if clicked_option == 'nothing': + values['ids'] = False + values['cca'] = False + values['contours'] = False + values['segm_masks'] = False + values['mother_bud_lines'] = False + values['num_zslices'] = False + else: + values['nothing'] = False + + if clicked_option == 'num_zslices': + values['ids'] = True + values['nothing'] = False + + new_state = AnnotationOptionState(**values) + return AnnotationOptionChangePlan( + side=side, + state=new_state, + mode_text=self.annotation_mode_text( + ids=new_state.ids, + cca=new_state.cca, + contours=new_state.contours, + segm_masks=new_state.segm_masks, + mother_bud_lines=new_state.mother_bud_lines, + nothing=new_state.nothing, + ), + save_settings=save_settings, + ) + + def pixel_mode_setting_value(self, checked: bool) -> int: + return int(checked) + + def pixel_mode_change_plan( + self, + *, + checked: bool, + is_data_loaded: bool, + high_resolution: bool, + ) -> PixelModeChangePlan: + return PixelModeChangePlan( + setting_update=('pxMode', self.pixel_mode_setting_value(checked)), + should_update_text_pixel_mode=is_data_loaded and high_resolution, + should_refresh_images=is_data_loaded, + ) + + def text_resolution_change_plan( + self, + *, + high_resolution: bool, + is_data_loaded: bool, + ) -> TextResolutionChangePlan: + mode = 'high' if high_resolution else 'low' + return TextResolutionChangePlan( + mode=mode, + log_message=f'Switching to {mode} for the text annnotations...', + pixel_mode_disabled=not high_resolution, + should_update_annotations=is_data_loaded, + should_refresh_images=is_data_loaded, + ) + + def tree_annotation_info_mode_plan( + self, + checked: bool, + ) -> TreeAnnotationInfoModePlan: + return TreeAnnotationInfoModePlan( + enabled=checked, + action_text_contains='tree', + action_checked=checked, + label_tree_annotations_enabled=checked, + gen_num_tree_annotations_enabled=checked, + should_refresh_annotations=True, + ) + + def z_depth_annotation_options_plan( + self, + *, + is_3d: bool, + state: AnnotationOptionState, + ) -> ZDepthAnnotationOptionsPlan: + if not is_3d: + return ZDepthAnnotationOptionsPlan(should_apply=False) + + return ZDepthAnnotationOptionsPlan( + should_apply=True, + disabled_updates=(('ids', False), ('contours', False)), + state=AnnotationOptionState( + ids=True, + cca=state.cca, + contours=True, + segm_masks=state.segm_masks, + mother_bud_lines=state.mother_bud_lines, + num_zslices=state.num_zslices, + nothing=state.nothing, + ), + clicked_option='ids', + save_settings=False, + ) + + def visible_3d_segmentation_widgets_plan( + self, + *, + is_3d: bool, + ) -> Visible3DSegmentationWidgetsPlan: + visible_updates = ( + ('left', 'num_zslices', is_3d), + ('right', 'num_zslices', is_3d), + ) + checked_updates = () + if not is_3d: + checked_updates = ( + ('left', 'num_zslices', False), + ('right', 'num_zslices', False), + ) + return Visible3DSegmentationWidgetsPlan( + visible_updates=visible_updates, + checked_updates=checked_updates, + ) + + def z_neighbor_highlight_checkbox_plan( + self, + *, + is_3d: bool, + ) -> ZNeighborHighlightCheckboxPlan: + if not is_3d: + return ZNeighborHighlightCheckboxPlan(should_apply=False) + return ZNeighborHighlightCheckboxPlan( + should_apply=True, + visible=True, + checked=True, + should_connect_toggle=True, + ) + Draw all lineage tree lines on the GUI. This method retrieves the lineage tree data and draws the lineage tree lines connecting cells and their respective mothers when the mother has split. """ - if not self.view_model.should_draw_lineage_tree_lines( + if not self.should_draw_lineage_tree_lines( lineage_tree_available=self.lineage_tree is not None, frames_count=( 0 if self.lineage_tree is None @@ -503,14 +910,14 @@ def drawAllLineageTreeLines(self): continue for ID in new_cells: - curr_obj = self.view_model.lineage.object_by_label(rp, ID) + curr_obj = self.lineage.object_by_label(rp, ID) lin_tree_df_ID = lin_tree_df.loc[ID] # lin_tree_df_mother_ID = lin_tree_df_prev.loc[lin_tree_df_ID["parent_ID_tree"]] if lin_tree_df_ID["parent_ID_tree"] == -1: # make sure that new obj where the parents are not known get skipped continue - mother_obj = self.view_model.lineage.object_by_label( + mother_obj = self.lineage.object_by_label( prev_rp, lin_tree_df_ID["parent_ID_tree"] ) @@ -551,7 +958,7 @@ def drawObjLin_TreeMothBudLines(self, ax, obj, mother_obj, isNew, ID=None): y1, x1 = self.getObjCentroid(obj.centroid) y2, x2 = self.getObjCentroid(mother_obj.centroid) - xx, yy = self.view_model.geometry.line_coords( + xx, yy = self.geometry.line_coords( y1, x1, y2, x2, dashed=True ) scatterItem.addPoints(xx, yy) @@ -560,7 +967,7 @@ def getObjCentroid(self, obj_centroid): depth_axis = ( self.switchPlaneCombobox.depthAxes() if self.isSegm3D else 'z' ) - return self.view_model.edit_id.project_centroid( + return self.edit_id.project_centroid( obj_centroid, is_3d=self.isSegm3D, depth_axis=depth_axis, @@ -657,14 +1064,14 @@ def labelRoiIsCircularRadioButtonToggled(self, checked): self.labelRoiCircularRadiusSpinbox.setDisabled(True) def pxModeActionToggled(self, checked): - self.view_model.change_pixel_mode( + self.change_pixel_mode( checked=checked, is_data_loaded=self.isDataLoaded, high_resolution=self.highLowResAction.isChecked(), ) def changeTextResolution(self): - self.view_model.change_text_resolution( + self.change_text_resolution( high_resolution=self.highLowResAction.isChecked(), is_data_loaded=self.isDataLoaded, ) @@ -673,18 +1080,18 @@ def highLowResToggled(self, clicked=True): self.changeTextResolution() def annotGenNumTreeToggled(self, checked): - self.view_model.change_gen_num_tree_annotations(checked) + self.change_gen_num_tree_annotations(checked) def annotLabelIDtreeToggled(self, checked): - self.view_model.change_label_tree_annotations(checked) + self.change_label_tree_annotations(checked) def setAnnotInfoMode(self, checked): - self.view_model.change_tree_annotation_info_mode(checked) + self.change_tree_annotation_info_mode(checked) def annotOptionClicked(self, clicked=True, sender=None, saveSettings=True): if sender is None: sender = self.sender() - self.view_model.change_annotation_options( + self.change_annotation_options( side='left', clicked_option=self._annotation_clicked_option('left', sender), save_settings=saveSettings, @@ -701,7 +1108,7 @@ def setDisabledAnnotCheckBoxesLeft(self, disabled): self.drawNothingCheckbox.setDisabled(disabled) def setEnabledAnnotCheckBoxesLeftZdepthAxes(self): - self.view_model.enable_z_depth_annotation_options( + self.enable_z_depth_annotation_options( is_3d=self.isSegm3D, **self._annotation_option_state('left'), ) @@ -720,7 +1127,7 @@ def annotOptionClickedRight( ): if sender is None: sender = self.sender() - self.view_model.change_annotation_options( + self.change_annotation_options( side='right', clicked_option=self._annotation_clicked_option('right', sender), save_settings=saveSettings, @@ -749,7 +1156,7 @@ def setAnnotOptionsLin_treeMode(self): def setDrawAnnotComboboxText(self, saveSettings=True): state = self._annotation_option_state('left') state.pop('num_zslices') - self.view_model.refresh_annotation_mode_text( + self.refresh_annotation_mode_text( side='left', save_settings=saveSettings, **state, @@ -758,7 +1165,7 @@ def setDrawAnnotComboboxText(self, saveSettings=True): def setDrawAnnotComboboxTextRight(self, saveSettings=True): state = self._annotation_option_state('right') state.pop('num_zslices') - self.view_model.refresh_annotation_mode_text( + self.refresh_annotation_mode_text( side='right', save_settings=saveSettings, **state, @@ -797,10 +1204,10 @@ def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print): logger('Updating annotated IDs...') posData = self.data[self.pos_i] - posData.ripIDs = self.view_model.label_edits.remap_id_set( + posData.ripIDs = self.label_edits.remap_id_set( posData.ripIDs, oldIDs, newIDs ) - posData.binnedIDs = self.view_model.label_edits.remap_id_set( + posData.binnedIDs = self.label_edits.remap_id_set( posData.binnedIDs, oldIDs, newIDs ) self.keptObjectsIDs = widgets.KeptObjectIDsList( @@ -811,7 +1218,7 @@ def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print): for button in customAnnotButtons: customAnnotValues = self.customAnnotDict[button] annotatedIDs = customAnnotValues['annotatedIDs'][self.pos_i] - mappedAnnotIDs = self.view_model.custom_annotations.remap_ids( + mappedAnnotIDs = self.custom_annotations.remap_ids( annotatedIDs, oldIDs, newIDs, @@ -822,7 +1229,7 @@ def rtTrackerActionToggled(self, checked): if not checked: return - aliases = self.view_model.model_registry.real_time_tracker_aliases( + aliases = self.model_registry.real_time_tracker_aliases( reverse=True ) if self.sender().text() in aliases: @@ -1040,12 +1447,12 @@ def keepAllToolsActiveActionToggled(self, checked): ) def setVisible3DsegmWidgets(self): - self.view_model.update_visible_3d_segmentation_widgets( + self.update_visible_3d_segmentation_widgets( is_3d=self.isSegm3D, ) def showHighlightZneighCheckbox(self): - self.view_model.update_z_neighbor_highlight_checkbox( + self.update_z_neighbor_highlight_checkbox( is_3d=self.isSegm3D, ) @@ -1056,7 +1463,7 @@ def highlightZneighLabels_cb(self, checked): pass def restoreSavedSettings(self): - self.view_model.restore_saved_settings( + self.restore_saved_settings( settings_values=self.df_settings['value'].to_dict(), left_num_zslices=self.annotNumZslicesCheckbox.isChecked(), right_num_zslices=self.annotNumZslicesCheckboxRight.isChecked(), @@ -1099,9 +1506,9 @@ def setDisabledAnnotOptions(self, disabled): # self.drawNothingCheckboxRight.setDisabled(disabled) def drawAnnotCombobox_to_options(self): - self.view_model.sync_annotation_options_from_mode_text( + self.sync_annotation_options_from_mode_text( left_text=self.drawIDsContComboBox.currentText(), right_text=self.annotateRightHowCombobox.currentText(), left_num_zslices=self.annotNumZslicesCheckbox.isChecked(), right_num_zslices=self.annotNumZslicesCheckboxRight.isChecked(), - ) + ) \ No newline at end of file diff --git a/cellacdc/views/app_shell_view.py b/cellacdc/views/app_shell_view.py index 5961b82c7..251d796b6 100644 --- a/cellacdc/views/app_shell_view.py +++ b/cellacdc/views/app_shell_view.py @@ -14,12 +14,29 @@ settings_csv_path, widgets, ) from cellacdc.help import about, welcome -from cellacdc.viewmodels.app_shell_viewmodel import AppShellViewModel class AppShellView: """Qt-facing adapter around application shell lifecycle actions.""" + """Headless application shell service wrappers.""" + + def read_version(self) -> str: + return myutils.read_version() + + def tooltips_from_docs(self) -> dict: + return get_tooltips_from_docs() + + def browse_docs(self): + return myutils.browse_docs() + + def show_in_file_manager(self, path: str): + return myutils.showInExplorer(path) + + def rename_qrc_resources_file(self, color_scheme: str): + return rename_qrc_resources_file(color_scheme) + + LEGACY_METHODS = ( 'initGlobalAttr', 'initProfileModels', @@ -42,15 +59,13 @@ class AppShellView: 'about', ) - def __init__(self, host, view_model: AppShellViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -242,7 +257,7 @@ def determineSlideshowWinPos(self): self.slideshowWinTop = winScreenCenterY - int(800/2) def setTooltips(self): - tooltips = self.view_model.tooltips_from_docs() + tooltips = self.tooltips_from_docs() for key, tooltip in tooltips.items(): setShortcut = getattr(self, key).shortcut().toString() @@ -283,7 +298,7 @@ def onToggleColorScheme(self): _warnings.warnRestartCellACDCcolorModeToggled( self._colorScheme, app_name=self._appName, parent=self.host ) - self.view_model.rename_qrc_resources_file(self._colorScheme) + self.rename_qrc_resources_file(self._colorScheme) self.statusBarLabel.setText(html_utils.paragraph( f'Restart {self._appName} for the change to take effect', font_color='red' @@ -297,17 +312,17 @@ def showAbout(self): def openLogFile(self): self.logger.info(f'Opening log file "{self.log_path}"...') - self.view_model.show_in_file_manager(self.log_path) + self.show_in_file_manager(self.log_path) def showLogFiles(self): log_files_path = os.path.dirname(self.log_path) self.logger.info(f'Opening log files folder "{log_files_path}"...') - self.view_model.show_in_file_manager(log_files_path) + self.show_in_file_manager(log_files_path) def showInExplorer_cb(self): posData = self.data[self.pos_i] path = posData.images_path - self.view_model.show_in_file_manager(path) + self.show_in_file_manager(path) def showTipsAndTricks(self): self.welcomeWin = welcome.welcomeWin() @@ -350,4 +365,4 @@ def cutContent(self): pass def about(self): - pass + pass \ No newline at end of file diff --git a/cellacdc/views/brush_tools_view.py b/cellacdc/views/brush_tools_view.py index 781cab0d1..7623a67a3 100644 --- a/cellacdc/views/brush_tools_view.py +++ b/cellacdc/views/brush_tools_view.py @@ -8,7 +8,6 @@ from qtpy.QtWidgets import QCheckBox from cellacdc import html_utils, settings_csv_path, widgets -from cellacdc.viewmodels.brush_tools_viewmodel import BrushToolsViewModel class BrushToolsView: @@ -45,19 +44,128 @@ class BrushToolsView: 'Eraser_cb', ) - def __init__(self, host, view_model: BrushToolsViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) + def checked_setting_value(self, checked: bool) -> str: + return 'Yes' if checked else 'No' + + def default_delete_object_info_value(self) -> str: + return 'Yes' + + def should_show_delete_object_info(self, setting_value: Any) -> bool: + return setting_value == 'Yes' + + def delete_object_info_value( + self, + do_not_show_again_checked: bool, + ) -> str: + return ( + 'No' + if do_not_show_again_checked + else 'Yes' + ) + + def should_fill_holes( + self, + sender: str, + *, + auto_fill_checked: bool, + ) -> bool: + return sender == 'brush' and auto_fill_checked + + def brush_toolbar_visible( + self, + edit_id_visible: bool, + *, + brush_size_visible: bool, + auto_fill_visible: bool, + auto_hide_visible: bool, + ) -> bool: + return any( + ( + edit_id_visible, + brush_size_visible, + auto_fill_visible, + auto_hide_visible, + ) + ) + + def disk_mask(self, brush_size: int): + import skimage.morphology + return skimage.morphology.disk(brush_size, dtype=bool) + + def disk_mask_bounds( + self, + image_shape: tuple[int, int], + brush_size: int, + xdata: int, + ydata: int, + disk_mask, + ): + y_size, x_size = image_shape + y_bottom, x_left = ydata - brush_size, xdata - brush_size + y_top, x_right = ydata + brush_size + 1, xdata + brush_size + 1 + + if x_left < 0: + if y_bottom < 0: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[-y_bottom:, -x_left:] + y_bottom = 0 + elif y_top > y_size: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[0:y_size - y_bottom, -x_left:] + y_top = y_size + else: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[:, -x_left:] + x_left = 0 + + elif x_right > x_size: + if y_bottom < 0: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[-y_bottom:, 0:x_size - x_left] + y_bottom = 0 + elif y_top > y_size: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[0:y_size - y_bottom, 0:x_size - x_left] + y_top = y_size + else: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[:, 0:x_size - x_left] + x_right = x_size + + elif y_bottom < 0: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[-y_bottom:] + y_bottom = 0 + + elif y_top > y_size: + disk_mask = disk_mask.copy() + disk_mask = disk_mask[0:y_size - y_bottom] + y_top = y_size + + return y_bottom, x_left, y_top, x_right, disk_mask + + def magic_wand_flood_tolerance( + self, + tolerance_percent: float, + image_min: float, + image_max: float, + ): + if tolerance_percent == 0: + return None + return (image_max - image_min) * (tolerance_percent / 100) + def bind_legacy_methods(self): for name in self.LEGACY_METHODS: setattr(self.host, name, getattr(self, name)) @@ -65,10 +173,10 @@ def bind_legacy_methods(self): def instructHowDeleteID(self): if 'showInfoDeleteObject' not in self.df_settings.index: self.df_settings.at['showInfoDeleteObject', 'value'] = ( - self.view_model.default_delete_object_info_value() + self.default_delete_object_info_value() ) - showInfoDeleteObject = self.view_model.should_show_delete_object_info( + showInfoDeleteObject = self.should_show_delete_object_info( self.df_settings.at['showInfoDeleteObject', 'value'] ) if not showInfoDeleteObject: @@ -90,7 +198,7 @@ def instructHowDeleteID(self): widgets=doNotShowAgainCheckbox ) - showInfoDeleteObjectValue = self.view_model.delete_object_info_value( + showInfoDeleteObjectValue = self.delete_object_info_value( doNotShowAgainCheckbox.isChecked() ) self.df_settings.at['showInfoDeleteObject', 'value'] = ( @@ -120,12 +228,12 @@ def checkWarnDeletedIDwithEraser(self): return False def brushAutoFillToggled(self, checked): - val = self.view_model.checked_setting_value(checked) + val = self.checked_setting_value(checked) self.df_settings.at['brushAutoFill', 'value'] = val self.df_settings.to_csv(self.settings_csv_path) def brushAutoHideToggled(self, checked): - val = self.view_model.checked_setting_value(checked) + val = self.checked_setting_value(checked) self.df_settings.at['brushAutoHide', 'value'] = val self.df_settings.to_csv(self.settings_csv_path) @@ -133,7 +241,7 @@ def brushAutoHideToggled(self, checked): def fillHolesID(self, ID, sender='brush'): posData = self.data[self.pos_i] if sender == 'brush': - if not self.view_model.should_fill_holes( + if not self.should_fill_holes( sender, auto_fill_checked=self.brushAutoFillCheckbox.isChecked(), ): @@ -226,7 +334,7 @@ def showEditIDwidgets(self, visible): self.editIDspinboxAction.setVisible(visible) self.autoIDcheckboxAction.setVisible(visible) showToolbar = ( - self.view_model.brush_toolbar_visible( + self.brush_toolbar_visible( visible, brush_size_visible=self.brushSizeAction.isVisible(), auto_fill_visible=self.brushAutoFillAction.isVisible(), @@ -279,11 +387,11 @@ def updateEraserCursor(self, x, y, xyLocked=None, isHoverImg1=True): def setDiskMask(self): brushSize = self.brushSizeSpinbox.value() - self.diskMask = self.view_model.disk_mask(brushSize) + self.diskMask = self.disk_mask(brushSize) def getDiskMask(self, xdata, ydata): brushSize = self.brushSizeSpinbox.value() - return self.view_model.disk_mask_bounds( + return self.disk_mask_bounds( self.currentLab2D.shape[-2:], brushSize, xdata, @@ -430,7 +538,7 @@ def getMagicWandFloodTolerance(self): tol_perc = self.wandControlsToolbar.toleranceSpinbox.value() posData = self.data[self.pos_i] _min, _max = posData.img_data_min_max - return self.view_model.magic_wand_flood_tolerance(tol_perc, _min, _max) + return self.magic_wand_flood_tolerance(tol_perc, _min, _max) def initTempLayerBrush(self, ID, ax=0): if ax == 0: diff --git a/cellacdc/views/canvas_context_menu_view.py b/cellacdc/views/canvas_context_menu_view.py index 4a864b92f..2db575c1e 100644 --- a/cellacdc/views/canvas_context_menu_view.py +++ b/cellacdc/views/canvas_context_menu_view.py @@ -3,30 +3,63 @@ from __future__ import annotations import pyqtgraph as pg +from dataclasses import dataclass from qtpy.QtCore import QPoint from qtpy.QtWidgets import QAction, QMenu -from cellacdc.viewmodels.canvas_context_menu_viewmodel import ( - CanvasContextMenuViewModel, -) class CanvasContextMenuView: """Qt-facing adapter around canvas context-menu contracts.""" - def __init__(self, host, view_model: CanvasContextMenuViewModel): - self.host = host - self.view_model = view_model + """Headless canvas context-menu decision rules.""" + + scale_bar_target = 'scale_bar' + timestamp_target = 'timestamp' + gradient_target = 'gradient' + + def image_gradient_menu_target( + self, + *, + scale_bar_highlighted: bool, + timestamp_highlighted: bool, + ) -> str: + if scale_bar_highlighted: + return self.scale_bar_target + if timestamp_highlighted: + return self.timestamp_target + return self.gradient_target + + def deleted_roi_click_decision( + self, + *, + clicked_on_roi: bool, + left_click: bool, + right_click: bool, + ) -> DeletedRoiClickDecision: + if not clicked_on_roi: + return DeletedRoiClickDecision(handled=False) + if right_click: + return DeletedRoiClickDecision( + handled=True, + show_context_menu=True, + ) + if left_click: + return DeletedRoiClickDecision(handled=True, drag_roi=True) + return DeletedRoiClickDecision(handled=False) + + def __init__(self, host): + self.host = host def show_img_gradient_context_menu(self, x, y): - target = self.view_model.image_gradient_menu_target( + target = self.image_gradient_menu_target( scale_bar_highlighted=self._scale_bar_highlighted(), timestamp_highlighted=self._timestamp_highlighted(), ) - if target == self.view_model.scale_bar_target: + if target == self.scale_bar_target: self.host.scaleBar.showContextMenu(x, y) return - if target == self.view_model.timestamp_target: + if target == self.timestamp_target: self.host.timestamp.showContextMenu(x, y) return self.host.imgGrad.gradient.menu.popup(QPoint(int(x), int(y))) @@ -54,7 +87,7 @@ def clicked_deleted_roi(self, event, left_click, right_click): ] else: clicked_on_roi = roi_mask[int(y), int(x)] - decision = self.view_model.deleted_roi_click_decision( + decision = self.deleted_roi_click_decision( clicked_on_roi=clicked_on_roi, left_click=left_click, right_click=right_click, @@ -122,4 +155,4 @@ def _timestamp_highlighted(self): return ( hasattr(self.host, 'timestamp') and self.host.timestamp.isHighlighted() - ) + ) \ No newline at end of file diff --git a/cellacdc/views/canvas_drawing_view.py b/cellacdc/views/canvas_drawing_view.py index 7223ec7ce..9222d1db5 100644 --- a/cellacdc/views/canvas_drawing_view.py +++ b/cellacdc/views/canvas_drawing_view.py @@ -2,6 +2,7 @@ from __future__ import annotations +import numpy as np import numpy as np import skimage.segmentation @@ -10,14 +11,47 @@ from qtpy.QtWidgets import QAction, QMessageBox from cellacdc import apps, exception_handler, html_utils, widgets -from cellacdc.viewmodels.canvas_drawing_viewmodel import ( - CanvasDrawingViewModel, -) class CanvasDrawingView: """Qt-facing adapter for canvas drawing workflows.""" + """Headless decisions for canvas drawing workflows.""" + + viewer_mode = 'Viewer' + + def should_process_canvas_event( + self, + *, + mode: str, + in_bounds: bool, + ) -> bool: + return mode != self.viewer_mode and in_bounds + + def should_clear_after_out_of_bounds(self, *, image: str) -> bool: + return image == 'img1' + + def calculate_brush_mask( + self, + image_shape: tuple[int, int], + ymin: int, + xmin: int, + ymax: int, + xmax: int, + disk_mask: np.ndarray, + rr_poly: np.ndarray | None = None, + cc_poly: np.ndarray | None = None, + ) -> np.ndarray: + """Computes a 2D boolean mask for brush/eraser updates.""" + mask = np.zeros(image_shape, dtype=bool) + disk_slice = (slice(ymin, ymax), slice(xmin, xmax)) + mask[disk_slice][disk_mask] = True + if rr_poly is not None and cc_poly is not None: + mask[rr_poly, cc_poly] = True + return mask + + + LEGACY_METHODS = ( 'gui_addCreatedAxesItems', 'gui_mouseDragEventImg1', @@ -25,15 +59,13 @@ class CanvasDrawingView: 'gui_mouseReleaseEventImg1', ) - def __init__(self, host, view_model: CanvasDrawingViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -90,8 +122,8 @@ def gui_mouseDragEventImg1(self, event): posData = self.data[self.pos_i] Y, X = self.get_2Dlab(posData.lab).shape xdata, ydata = int(x), int(y) - in_bounds = self.view_model.is_in_bounds(xdata, ydata, X, Y) - if not self.view_model.should_process_canvas_event( + in_bounds = self.is_in_bounds(xdata, ydata, X, Y) + if not self.should_process_canvas_event( mode=mode, in_bounds=in_bounds, ): @@ -119,7 +151,7 @@ def gui_mouseDragEventImg1(self, event): diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) # Build brush mask - mask = self.view_model.calculate_brush_mask( + mask = self.calculate_brush_mask( lab_2D.shape, ymin, xmin, ymax, xmax, diskMask, rrPoly, ccPoly ) @@ -179,7 +211,7 @@ def gui_mouseDragEventImg1(self, event): diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) # Build eraser mask - mask = self.view_model.calculate_brush_mask( + mask = self.calculate_brush_mask( lab_2D.shape, ymin, xmin, ymax, xmax, diskMask, rrPoly, ccPoly ) @@ -232,12 +264,12 @@ def gui_mouseDragEventImg1(self, event): self.flood_mask[flood_mask] = True if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): - self.flood_mask = self.view_model.binary_fill_holes( + self.flood_mask = self.binary_fill_holes( self.flood_mask ) if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): - self.flood_mask = self.view_model.convex_hull_mask( + self.flood_mask = self.convex_hull_mask( self.flood_mask ) @@ -270,8 +302,8 @@ def gui_mouseDragEventImg2(self, event): Y, X = self.get_2Dlab(posData.lab).shape x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - in_bounds = self.view_model.is_in_bounds(xdata, ydata, X, Y) - if not self.view_model.should_process_canvas_event( + in_bounds = self.is_in_bounds(xdata, ydata, X, Y) + if not self.should_process_canvas_event( mode=mode, in_bounds=in_bounds, ): @@ -295,7 +327,7 @@ def gui_mouseDragEventImg2(self, event): ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) # Build eraser mask - mask = self.view_model.calculate_brush_mask( + mask = self.calculate_brush_mask( lab_2D.shape, ymin, xmin, ymax, xmax, diskMask, rrPoly, ccPoly ) @@ -327,7 +359,7 @@ def gui_mouseDragEventImg2(self, event): ) # Build brush mask - mask = self.view_model.calculate_brush_mask( + mask = self.calculate_brush_mask( lab_2D.shape, ymin, xmin, ymax, xmax, diskMask, rrPoly, ccPoly ) @@ -360,7 +392,7 @@ def gui_mouseReleaseEventImg1(self, event): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if self.view_model.is_viewer_mode(mode): + if self.is_viewer_mode(mode): return if self._dispatch_tool_event_if_enabled(event, phase='release', image='img1'): @@ -369,12 +401,12 @@ def gui_mouseReleaseEventImg1(self, event): Y, X = self.get_2Dlab(posData.lab).shape x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - in_bounds = self.view_model.is_in_bounds(xdata, ydata, X, Y) - if not self.view_model.should_process_canvas_event( + in_bounds = self.is_in_bounds(xdata, ydata, X, Y) + if not self.should_process_canvas_event( mode=mode, in_bounds=in_bounds, ): - if self.view_model.should_clear_after_out_of_bounds(image='img1'): + if self.should_clear_after_out_of_bounds(image='img1'): self.isMouseDragImg2 = False self.updateAllImages() return @@ -554,7 +586,7 @@ def gui_mouseReleaseEventImg1(self, event): return if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) mothID_prompt = apps.QLineEditDialog( @@ -680,4 +712,4 @@ def gui_mouseReleaseEventImg1(self, event): # Zoom rect mouse release elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): self.isMouseDragImg1 = False - self.zoomRectDone() + self.zoomRectDone() \ No newline at end of file diff --git a/cellacdc/views/canvas_events_view.py b/cellacdc/views/canvas_events_view.py index 4813fa9f9..f851fab3a 100644 --- a/cellacdc/views/canvas_events_view.py +++ b/cellacdc/views/canvas_events_view.py @@ -4,6 +4,7 @@ import numpy as np import pyqtgraph as pg +import numpy as np import skimage.segmentation from qtpy.QtCore import Qt, QTimer @@ -11,25 +12,57 @@ from qtpy.QtWidgets import QAction, QMessageBox from cellacdc import apps, exception_handler -from cellacdc.viewmodels.canvas_events_viewmodel import CanvasEventsViewModel class CanvasEventsView: """Qt-facing adapter for canvas mouse event routing.""" + """Headless canvas event routing rules and brush mask computations.""" + + def calculate_brush_mask( + self, + image_shape: tuple[int, int], + ymin: int, + xmin: int, + ymax: int, + xmax: int, + disk_mask: np.ndarray, + rr_poly: np.ndarray | None = None, + cc_poly: np.ndarray | None = None, + ) -> np.ndarray: + """Computes a 2D boolean mask for brush/eraser updates.""" + mask = np.zeros(image_shape, dtype=bool) + disk_slice = (slice(ymin, ymax), slice(xmin, xmax)) + mask[disk_slice][disk_mask] = True + if rr_poly is not None and cc_poly is not None: + mask[rr_poly, cc_poly] = True + return mask + + def map_mouse_coordinates_to_label_id( + self, + mouse_pos: tuple[float, float], + label_matrix: np.ndarray, + ) -> int: + """Resolves float pixel coordinate lookup to integer label ID.""" + x, y = mouse_pos + xdata, ydata = int(x), int(y) + height, width = label_matrix.shape + if 0 <= xdata < width and 0 <= ydata < height: + return int(label_matrix[ydata, xdata]) + return 0 + + LEGACY_METHODS = ( 'gui_mousePressEventImg1', ) - def __init__(self, host, view_model: CanvasEventsViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -322,8 +355,8 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) Y, X = self.get_2Dlab(posData.lab).shape - if self.view_model.geometry.is_in_bounds(xdata, ydata, X, Y): - ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + if self.geometry.is_in_bounds(xdata, ydata, X, Y): + ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) else: return @@ -488,7 +521,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() x0, y0 = xxRA[0], yyRA[0] if ctrl: - x1, y1 = self.view_model.snap_xy_to_closest_angle( + x1, y1 = self.snap_xy_to_closest_angle( x0, y0, xdata, ydata ) else: @@ -529,9 +562,9 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): elif left_click and canKeep: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) keepID_win = apps.QLineEditDialog( @@ -560,10 +593,10 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): elif left_click and canWhitelistIDs: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) keepID_win = apps.QLineEditDialog( @@ -683,7 +716,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.storeUndoRedoStates(False) self.isNewID = False - posData.brushID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + posData.brushID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) if posData.brushID == 0: self.setBrushID() self.updateLookuptable( @@ -712,14 +745,14 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): self.flood_mask = ( - self.view_model.binary_fill_holes( + self.binary_fill_holes( self.flood_mask ) ) if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): self.flood_mask = ( - self.view_model.convex_hull_mask( + self.convex_hull_mask( self.flood_mask ) ) @@ -770,7 +803,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - delID = self.view_model.map_mouse_coordinates_to_label_id((x, y), posData.manualBackgroundLab) + delID = self.map_mouse_coordinates_to_label_id((x, y), posData.manualBackgroundLab) if delID == 0: return @@ -809,9 +842,9 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) divID_prompt = apps.QLineEditDialog( @@ -852,9 +885,9 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) budID_prompt = apps.QLineEditDialog( @@ -900,9 +933,9 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) unknownID_prompt = apps.QLineEditDialog( @@ -930,9 +963,9 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): elif isCustomAnnot: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - ID = self.view_model.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) clickedBkgrDialog = apps.QLineEditDialog( @@ -989,4 +1022,4 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): padding=0 ) except Exception as err: - QTimer.singleShot(100, self.autoRange) + QTimer.singleShot(100, self.autoRange) \ No newline at end of file diff --git a/cellacdc/views/canvas_hover_view.py b/cellacdc/views/canvas_hover_view.py index 03bbc707f..6ee419a6d 100644 --- a/cellacdc/views/canvas_hover_view.py +++ b/cellacdc/views/canvas_hover_view.py @@ -3,11 +3,11 @@ from __future__ import annotations import pyqtgraph as pg +from typing import Any from qtpy.QtCore import Qt from qtpy.QtGui import QGuiApplication from cellacdc import html_utils, widgets -from cellacdc.viewmodels.canvas_hover_viewmodel import CanvasHoverViewModel class CanvasHoverView: @@ -28,15 +28,13 @@ class CanvasHoverView: 'drawTempRulerLine', ) - def __init__(self, host, view_model: CanvasHoverViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -51,7 +49,7 @@ def updateHoverLabelCursor(self, x, y): return xdata, ydata = int(x), int(y) - if not self.view_model.point_in_bounds( + if not self.point_in_bounds( self.currentLab2D.shape, xdata, ydata, @@ -83,7 +81,7 @@ def gui_hoverEventRightImage(self, event): self.resetCursors() self.gui_hoverEventImg1(event, isHoverImg1=False) - setMirroredCursor = self.view_model.should_set_mirrored_cursor( + setMirroredCursor = self.should_set_mirrored_cursor( override_cursor_is_none=self.app.overrideCursor() is None, is_exit=event.isExit(), mirrored_cursor_enabled=self.showMirroredCursorAction.isChecked(), @@ -100,7 +98,7 @@ def onCtrlPressedFirstTime(self): return xdata, ydata = int(x), int(y) - if not self.view_model.point_in_bounds( + if not self.point_in_bounds( self.currentLab2D.shape, xdata, ydata, @@ -124,7 +122,7 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): except AttributeError: return - self.xHoverImg, self.yHoverImg = self.view_model.hover_position( + self.xHoverImg, self.yHoverImg = self.hover_position( event.isExit(), event.pos(), ) @@ -140,7 +138,7 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): cursorsInfo = self.gui_setCursor(modifiers, event) self.highlightHoverLostObj(modifiers, event) - drawRulerLine = self.view_model.should_draw_ruler_line( + drawRulerLine = self.should_draw_ruler_line( ruler_checked=self.rulerButton.isChecked(), add_deleted_polyline_checked=( self.addDelPolyLineRoiButton.isChecked() @@ -154,7 +152,7 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): if not event.isExit(): x, y = event.pos() xdata, ydata = int(x), int(y) - if self.view_model.point_in_bounds( + if self.point_in_bounds( self.img1.image.shape[:2], xdata, ydata, @@ -265,7 +263,7 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): if drawSpline: self.curvature_tools_view.hoverEventDrawSpline(event) - setMirroredCursor = self.view_model.should_set_mirrored_cursor( + setMirroredCursor = self.should_set_mirrored_cursor( override_cursor_is_none=self.app.overrideCursor() is None, is_exit=event.isExit(), mirrored_cursor_enabled=self.showMirroredCursorAction.isChecked(), @@ -332,7 +330,7 @@ def gui_add_ax_cursors(self): self.ax1.addItem(self.ax1_cursor) def _cursor_flags(self, modifiers, event): - return self.view_model.cursor_flags( + return self.cursor_flags( is_exit=event.isExit(), no_modifier=modifiers == Qt.NoModifier, shift=modifiers == Qt.ShiftModifier, @@ -431,6 +429,121 @@ def warnAddingPointWithExistingId(self, point_id, table_endname=''): msg = widgets.myMessageBox(wrapText=False) txt = (f""" + + """Headless decisions for hover and cursor state.""" + + def point_in_bounds( + self, + image_shape: tuple[int, int], + xdata: int, + ydata: int, + ) -> bool: + y_size, x_size = image_shape + return 0 <= xdata < x_size and 0 <= ydata < y_size + + def hover_position(self, is_exit: bool, position) -> tuple[Any, Any]: + if is_exit: + return None, None + return position + + def should_set_mirrored_cursor( + self, + *, + override_cursor_is_none: bool, + is_exit: bool, + mirrored_cursor_enabled: bool, + is_hover_img1: bool = True, + ) -> bool: + return ( + override_cursor_is_none + and not is_exit + and is_hover_img1 + and mirrored_cursor_enabled + ) + + def should_draw_ruler_line( + self, + *, + ruler_checked: bool, + add_deleted_polyline_checked: bool, + temp_segment_on: bool, + is_exit: bool, + ) -> bool: + return ( + (ruler_checked or add_deleted_polyline_checked) + and temp_segment_on + and not is_exit + ) + + def cursor_flags( + self, + *, + is_exit: bool, + no_modifier: bool, + shift: bool, + ctrl: bool, + alt: bool, + brush_checked: bool, + eraser_checked: bool, + add_deleted_polyline_checked: bool, + label_roi_checked: bool, + label_roi_circular_checked: bool, + wand_checked: bool, + move_label_checked: bool, + expand_label_checked: bool, + curvature_checked: bool, + keep_ids_checked: bool, + custom_annotation_available: bool, + manual_tracking_checked: bool, + manual_background_checked: bool, + zoom_rect_checked: bool, + edit_id_checked: bool, + magic_prompts_checked: bool, + points_layer_checked: bool, + add_points_by_clicking_active: bool, + ) -> dict[str, bool]: + return { + 'setBrushCursor': ( + brush_checked and not is_exit and (no_modifier or shift or ctrl) + ), + 'setEraserCursor': eraser_checked and not is_exit and no_modifier, + 'setAddDelPolyLineCursor': ( + add_deleted_polyline_checked and not is_exit and no_modifier + ), + 'setLabelRoiCircCursor': ( + label_roi_checked + and not is_exit + and (no_modifier or shift or ctrl) + and label_roi_circular_checked + ), + 'setWandCursor': wand_checked and not is_exit and no_modifier, + 'setLabelRoiCursor': label_roi_checked and not is_exit and no_modifier, + 'setMoveLabelCursor': move_label_checked and not is_exit and no_modifier, + 'setExpandLabelCursor': ( + expand_label_checked and not is_exit and no_modifier + ), + 'setCurvCursor': curvature_checked and not is_exit and no_modifier, + 'setKeepObjCursor': keep_ids_checked and not is_exit and no_modifier, + 'setCustomAnnotCursor': ( + custom_annotation_available and not is_exit and no_modifier + ), + 'setManualTrackingCursor': ( + manual_tracking_checked and not is_exit and no_modifier + ), + 'setManualBackgroundCursor': ( + manual_background_checked and not is_exit and no_modifier + ), + 'setAddPointCursor': ( + (points_layer_checked or magic_prompts_checked) + and add_points_by_clicking_active + and not is_exit + and no_modifier + ), + 'setZoomRectCursor': zoom_rect_checked and not is_exit and no_modifier, + 'setEditIDCursor': edit_id_checked and not is_exit, + 'setPanImageCursor': alt and not is_exit, + } + Cell ID {point_id} already exists!

Are you sure you want to add this point? """) @@ -455,7 +568,7 @@ def gui_hoverEventImg2(self, event): except AttributeError: return - self.xHoverImg, self.yHoverImg = self.view_model.hover_position( + self.xHoverImg, self.yHoverImg = self.hover_position( event.isExit(), event.pos(), ) @@ -548,4 +661,4 @@ def drawTempRulerLine(self, event): x1, y1 = self.host.view_model.geometry.snap_xy_to_closest_angle( x0, y0, x1, y1 ) - self.ax1_rulerPlotItem.setData([x0, x1], [y0, y1]) + self.ax1_rulerPlotItem.setData([x0, x1], [y0, y1]) \ No newline at end of file diff --git a/cellacdc/views/canvas_right_image_view.py b/cellacdc/views/canvas_right_image_view.py index 35ce33fa4..7db59488c 100644 --- a/cellacdc/views/canvas_right_image_view.py +++ b/cellacdc/views/canvas_right_image_view.py @@ -6,18 +6,24 @@ from qtpy.QtGui import QGuiApplication from cellacdc import exception_handler -from cellacdc.viewmodels.canvas_right_image_viewmodel import ( - CanvasRightImageViewModel, -) class CanvasRightImageView: """Qt-facing adapter for duplicated right-image mouse events.""" - def __init__(self, host, view_model: CanvasRightImageViewModel): - self.host = host - self.view_model = view_model + """Headless duplicated right-image event rules.""" + + def should_show_context_menu( + self, + *, + right_click: bool, + is_right_click_action_on: bool, + ) -> bool: + return right_click and not is_right_click_action_on + + def __init__(self, host): + self.host = host @exception_handler def mouse_press(self, event): modifiers = QGuiApplication.keyboardModifiers() @@ -27,7 +33,7 @@ def mouse_press(self, event): b.isChecked() for b in self.host.checkableQButtonsGroup.buttons() ]) self.host.typingEditID = False - show_menu = self.view_model.should_show_context_menu( + show_menu = self.should_show_context_menu( right_click=right_click, is_right_click_action_on=is_right_click_action_on, ) @@ -45,4 +51,4 @@ def mouse_drag(self, event): @exception_handler def mouse_release(self, event): - self.host.gui_mouseReleaseEventImg1(event) + self.host.gui_mouseReleaseEventImg1(event) \ No newline at end of file diff --git a/cellacdc/views/canvas_selection_view.py b/cellacdc/views/canvas_selection_view.py index 846e277eb..8529c39ec 100644 --- a/cellacdc/views/canvas_selection_view.py +++ b/cellacdc/views/canvas_selection_view.py @@ -13,28 +13,88 @@ from qtpy.QtWidgets import QAction, QGraphicsSceneMouseEvent from cellacdc import apps, exception_handler -from cellacdc.viewmodels.canvas_selection_viewmodel import ( - CanvasSelectionViewModel, -) class CanvasSelectionView: """Qt-facing adapter for canvas selection workflows.""" + """Headless decisions for canvas selection workflows.""" + + viewer_mode = 'Viewer' + segmentation_mode = 'Segmentation and Tracking' + + def should_drag_image( + self, + *, + left_click: bool, + eraser_on: bool, + brush_on: bool, + middle_click: bool, + pan_click: bool, + ) -> bool: + return pan_click or ( + left_click and not eraser_on and not brush_on and not middle_click + ) + + def should_blink_viewer_mode( + self, + *, + mode: str, + middle_click: bool, + right_action_on: bool = False, + custom_action_on: bool = False, + right_click: bool = False, + ) -> bool: + if mode != self.viewer_mode: + return False + if middle_click: + return True + return (right_action_on or custom_action_on) and ( + right_click or middle_click + ) + + def should_show_labels_menu( + self, + *, + right_click: bool, + right_action_on: bool, + middle_click: bool, + event_from_img1: bool, + ) -> bool: + return ( + right_click + and not right_action_on + and not middle_click + and not event_from_img1 + ) + + def can_delete(self, *, mode: str, is_snapshot: bool) -> bool: + return mode == self.segmentation_mode or is_snapshot + + def is_viewer_mode(self, mode: str) -> bool: + return mode == self.viewer_mode + + def should_process_release( + self, + *, + mode: str, + in_bounds: bool, + ) -> bool: + return mode != self.viewer_mode and in_bounds + + LEGACY_METHODS = ( 'gui_mousePressEventImg2', 'gui_mouseReleaseEventImg2', ) - def __init__(self, host, view_model: CanvasSelectionViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -64,7 +124,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.typingEditID = False # Drag image if neither brush or eraser are On pressed - dragImg = self.view_model.should_drag_image( + dragImg = self.should_drag_image( left_click=left_click, eraser_on=eraserON, brush_on=brushON, @@ -78,7 +138,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): event.ignore() return - if self.view_model.should_blink_viewer_mode( + if self.should_blink_viewer_mode( mode=mode, middle_click=middle_click, ): @@ -119,7 +179,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): right_click and not is_right_click_action_ON and not middle_click ) - showLabelsGradMenu = self.view_model.should_show_labels_menu( + showLabelsGradMenu = self.should_show_labels_menu( right_click=right_click, right_action_on=is_right_click_action_ON, middle_click=middle_click, @@ -131,7 +191,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): event.ignore() return - editInViewerMode = self.view_model.should_blink_viewer_mode( + editInViewerMode = self.should_blink_viewer_mode( mode=mode, middle_click=middle_click, right_action_on=is_right_click_action_ON, @@ -149,7 +209,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): # Brush and eraser are mutually exclusive but we want to keep the eraser # or brush ON and disable them temporarily to allow left-click with # separate ON - canDelete = self.view_model.can_delete( + canDelete = self.can_delete( mode=mode, is_snapshot=self.isSnapshot, ) @@ -161,7 +221,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) delID = self.get_2Dlab(posData.lab)[ydata, xdata] if delID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) delID_prompt = apps.QLineEditDialog( @@ -235,7 +295,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x) sepID_prompt = apps.QLineEditDialog( title='Clicked on background', @@ -260,7 +320,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if self.isSegm3D and not shift: z = self.zSliceScrollBar.sliderPosition() posData.lab, splittedIDs = ( - self.view_model.separate_with_label( + self.separate_with_label( posData.lab, posData.rp, [ID], max_ID, click_coords_list=[(z, ydata, xdata)] ) @@ -268,7 +328,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): success = True # self.set_2Dlab(lab2D) elif not shift: - result = self.view_model.split_along_convexity_defects( + result = self.split_along_convexity_defects( ID, self.get_2Dlab(posData.lab), max_ID ) lab2D, success, splittedIDs = result @@ -333,7 +393,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) clickedBkgrID = apps.QLineEditDialog( @@ -372,7 +432,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) mergeID_prompt = apps.QLineEditDialog( @@ -419,7 +479,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) clickedBkgrID = apps.QLineEditDialog( @@ -443,7 +503,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) mergeID_prompt = apps.QLineEditDialog( @@ -478,7 +538,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) editID_prompt = apps.QLineEditDialog( @@ -543,7 +603,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) keepID_win = apps.QLineEditDialog( @@ -575,7 +635,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) binID_prompt = apps.QLineEditDialog( @@ -656,7 +716,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( self.get_2Dlab(posData.lab), y, x ) ripID_prompt = apps.QLineEditDialog( @@ -750,11 +810,11 @@ def gui_mouseReleaseEventImg2(self, event): return xdata, ydata = int(x), int(y) - in_bounds = self.view_model.is_in_bounds(xdata, ydata, X, Y) - if self.view_model.is_viewer_mode(mode): + in_bounds = self.is_in_bounds(xdata, ydata, X, Y) + if self.is_viewer_mode(mode): return - should_process = self.view_model.should_process_release( + should_process = self.should_process_release( mode=mode, in_bounds=in_bounds, ) @@ -785,7 +845,7 @@ def gui_mouseReleaseEventImg2(self, event): lab2D = self.get_2Dlab(posData.lab) ID = lab2D[ydata, xdata] if ID == 0: - nearest_ID = self.view_model.nearest_nonzero_2d( + nearest_ID = self.nearest_nonzero_2d( lab2D, y, x ) mergeID_prompt = apps.QLineEditDialog( @@ -851,4 +911,4 @@ def gui_mouseReleaseEventImg2(self, event): if not self.mergeIDsButton.findChild(QAction).isChecked(): self.mergeIDsButton.setChecked(False) - self.store_data() + self.store_data() \ No newline at end of file diff --git a/cellacdc/views/canvas_tool_view.py b/cellacdc/views/canvas_tool_view.py index 1e5bd310b..51d22b492 100644 --- a/cellacdc/views/canvas_tool_view.py +++ b/cellacdc/views/canvas_tool_view.py @@ -2,14 +2,14 @@ from __future__ import annotations -from cellacdc.viewmodels.canvas_tool_viewmodel import CanvasToolViewModel - class CanvasToolView: - """Qt-facing adapter around the scriptable canvas tool view-model.""" + """Qt-facing adapter around the scriptable canvas tool decision rules.""" + + manual_separate_draw_mode_key = 'manual_separate_draw_mode' - def __init__(self, view_model: CanvasToolViewModel): - self.view_model = view_model + def __init__(self, host=None): + self.host = host def viewer_mode_allows_press( self, @@ -18,11 +18,7 @@ def viewer_mode_allows_press( can_add_point: bool = False, can_ruler: bool = False, ) -> bool: - return self.view_model.viewer_mode_allows_press( - mode, - can_add_point=can_add_point, - can_ruler=can_ruler, - ) + return mode != 'Viewer' or can_add_point or can_ruler def should_forward_img1_press_to_img2( self, @@ -35,14 +31,11 @@ def should_forward_img1_press_to_img2( is_annotate_division: bool, manual_background_on: bool, ) -> bool: - return self.view_model.should_forward_img1_press_to_img2( - right_click=right_click, - middle_click=middle_click, - can_add_point=can_add_point, - mode=mode, - is_snapshot=is_snapshot, - is_annotate_division=is_annotate_division, - manual_background_on=manual_background_on, + return ( + (right_click or (middle_click and not can_add_point)) + and (mode == 'Segmentation and Tracking' or is_snapshot) + and not is_annotate_division + and not manual_background_on ) def should_forward_img1_release_to_img2( @@ -52,12 +45,11 @@ def should_forward_img1_release_to_img2( mode: str, is_snapshot: bool, ) -> bool: - return self.view_model.should_forward_img1_release_to_img2( - right_click=right_click, - mode=mode, - is_snapshot=is_snapshot, + return ( + (mode == 'Segmentation and Tracking' or is_snapshot) + and right_click ) def store_manual_separate_draw_mode(self, settings, settings_csv_path, mode): - self.view_model.apply_manual_separate_draw_mode(settings, mode) + settings.at[self.manual_separate_draw_mode_key, 'value'] = mode settings.to_csv(settings_csv_path) diff --git a/cellacdc/views/cell_cycle_view.py b/cellacdc/views/cell_cycle_view.py index 29fef422e..20aa054d8 100644 --- a/cellacdc/views/cell_cycle_view.py +++ b/cellacdc/views/cell_cycle_view.py @@ -6,6 +6,8 @@ import uuid from tqdm import tqdm +from dataclasses import dataclass +import pandas as pd from qtpy.QtCore import QMutex, QThread, QTimer, QWaitCondition from qtpy.QtWidgets import QCheckBox, QMessageBox, QPushButton @@ -14,7 +16,6 @@ exception_handler, html_utils, ) from cellacdc import widgets, workers -from cellacdc.viewmodels.cell_cycle_viewmodel import CellCycleViewModel class CellCycleView: @@ -105,15 +106,13 @@ class CellCycleView: 'swapMothers', ) - def __init__(self, host, view_model: CellCycleViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -124,16 +123,96 @@ def bind_legacy_methods(self): def nearest_point_2Dyx(self, points, all_others): """ + + """Headless cell-cycle workflow rules.""" + + def annotated_edit_warning_plan( + self, + *, + is_snapshot: bool, + acdc_df_missing: bool, + lineage_tree_missing: bool, + cell_cycle_stage_present: bool, + lineage_tree_present: bool, + remembered_skip_warning: bool, + ) -> AnnotatedEditWarningPlan: + if is_snapshot: + return AnnotatedEditWarningPlan(proceed_without_warning=True) + + no_annotation_source = acdc_df_missing and lineage_tree_missing + no_annotations = not cell_cycle_stage_present and not lineage_tree_present + if no_annotation_source or no_annotations or remembered_skip_warning: + return AnnotatedEditWarningPlan( + proceed_without_warning=True, + update_images=True, + ) + + warn_type = ( + 'cell cycle annotations' + if cell_cycle_stage_present + else 'lineage tree annotations' + ) + return AnnotatedEditWarningPlan( + proceed_without_warning=False, + should_prompt=True, + warn_type=warn_type, + ) + + def check_mothers_exclusion_or_dead( + self, + acdc_df: pd.DataFrame, + mother_ids: list[int], + ) -> list[int]: + """Checks tracking rules for cell exclusions or deaths.""" + if acdc_df is None or not mother_ids: + return [] + + valid_ids = [m_id for m_id in mother_ids if m_id in acdc_df.index] + if not valid_ids: + return [] + + mothers_df = acdc_df.loc[valid_ids] + excluded_mask = ( + (mothers_df.get('is_cell_dead', 0) > 0) + | (mothers_df.get('is_cell_excluded', 0) > 0) + ) + return mothers_df[excluded_mask].index.tolist() + + def evaluate_sister_relations( + self, + prev_cca_df: pd.DataFrame, + current_ids: set[int], + ) -> list[int]: + """Determines S-phase mother-bud dependencies and sister relation tracking rules.""" + if prev_cca_df is None or not current_ids: + return [] + + current_ids_set = set(current_ids) + disappeared_ids = [] + for cc_series in prev_cca_df.itertuples(): + if getattr(cc_series, 'cell_cycle_stage', None) != 'S': + continue + + cell_id = cc_series.Index + relative_id = getattr(cc_series, 'relative_ID', -1) + if relative_id == -1: + continue + if relative_id not in current_ids_set and cell_id in current_ids_set: + disappeared_ids.append(relative_id) + + return disappeared_ids + + Given 2D array of [y, x] coordinates points and all_others return the [y, x] coordinates of the two points (one from points and one from all_others) that have the absolute minimum distance """ - return self.view_model.cca_workflows.nearest_point_2d_yx(points, all_others) + return self.cca_workflows.nearest_point_2d_yx(points, all_others) def isCurrentFrameCcaVisited(self): posData = self.data[self.pos_i] curr_df = posData.allData_li[posData.frame_i]['acdc_df'] - return self.view_model.cca_edits.has_annotations(curr_df) + return self.cca_edits.has_annotations(curr_df) def warnScellsGone(self, ScellsIDsGone, frame_i): msg = widgets.myMessageBox() @@ -190,7 +269,7 @@ def checkScellsGone(self): prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] prev_cca_df = prev_acdc_df[self.cca_df_colnames].copy() - ScellsIDsGone = self.view_model.evaluate_sister_relations(prev_cca_df, posData.IDs) + ScellsIDsGone = self.evaluate_sister_relations(prev_cca_df, posData.IDs) if not ScellsIDsGone: @@ -208,7 +287,7 @@ def checkScellsGone(self): (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) for frame_i in range(posData.frame_i-2, -1, -1) ) - propagation_result = self.view_model.cca_workflows.propagate_s_phase_disappearance_divisions( + propagation_result = self.cca_workflows.propagate_s_phase_disappearance_divisions( prev_cca_df, posData.cca_df, posData.frame_i, @@ -292,7 +371,7 @@ def checkCcaPastFramesNewIDs(self): (frame_i, posData.allData_li[frame_i]['acdc_df']) for frame_i in range(posData.frame_i-2, -1, -1) ) - result = self.view_model.cca_workflows.collect_existing_new_id_rows( + result = self.cca_workflows.collect_existing_new_id_rows( posData.new_IDs, past_acdc_frames, self.cca_df_colnames, @@ -306,7 +385,7 @@ def addIDBaseCca_df(self, posData, ID): # but they could be -1 for cells in G1 return - posData.cca_df = self.view_model.cca_edits.add_base_annotation( + posData.cca_df = self.cca_edits.add_base_annotation( posData.cca_df, ID, base_values=base_cca_dict, @@ -316,7 +395,7 @@ def addIDBaseCca_df(self, posData, ID): def getBaseCca_df(self, with_tree_cols=False): posData = self.data[self.pos_i] IDs = [obj.label for obj in posData.rp] - return self.view_model.cca_edits.build_base_annotations( + return self.cca_edits.build_base_annotations( IDs, with_tree_cols=with_tree_cols, base_values=base_cca_dict, @@ -325,7 +404,7 @@ def getBaseCca_df(self, with_tree_cols=False): def get_last_cca_frame_i(self): posData = self.data[self.pos_i] - return self.view_model.cca_edits.last_annotated_frame_index( + return self.cca_edits.last_annotated_frame_index( dict_frame_i['acdc_df'] for dict_frame_i in posData.allData_li ) @@ -473,14 +552,14 @@ def _getCcaCostMatrix( mother_contours[ID] = self.getObjContours(obj) bud_contours = dict(zip(posData.new_IDs, newIDs_contours)) - return self.view_model.cca_workflows.auto_cost_matrix_from_contours( + return self.cca_workflows.auto_cost_matrix_from_contours( IDsCellsG1, posData.new_IDs, mother_contours, bud_contours, ) - return self.view_model.cca_workflows.auto_cost_matrix_from_distances( + return self.cca_workflows.auto_cost_matrix_from_distances( dist_matrix_df, IDsCellsG1, posData.new_IDs, @@ -533,7 +612,7 @@ def autoCca_df(self, enforceAll=False): # For the last visited frame we perform assignment again only on # IDs where we didn't manually correct assignment if isLastVisitedAgain and not enforceAll: - posData.new_IDs = self.view_model.cca_workflows.uncorrected_new_ids_for_auto( + posData.new_IDs = self.cca_workflows.uncorrected_new_ids_for_auto( posData.new_IDs, curr_df, ) @@ -552,7 +631,7 @@ def autoCca_df(self, enforceAll=False): acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] prev_cca_df = acdc_df[self.cca_df_colnames].copy() - init_result = self.view_model.cca_workflows.prepare_auto_current_frame( + init_result = self.cca_workflows.prepare_auto_current_frame( prev_cca_df, curr_df, self.cca_df_colnames, @@ -568,7 +647,7 @@ def autoCca_df(self, enforceAll=False): return notEnoughG1Cells, proceed # Get cells in G1 (exclude dead) and check if there are enough cells in G1 - IDsCellsG1 = self.view_model.cca_workflows.auto_candidate_mother_ids( + IDsCellsG1 = self.cca_workflows.auto_candidate_mother_ids( prev_cca_df, acdc_df, posData.IDs, @@ -599,16 +678,16 @@ def autoCca_df(self, enforceAll=False): ) # Assign buds to mothers - assignments = self.view_model.cca_workflows.auto_assignments_from_cost( + assignments = self.cca_workflows.auto_assignments_from_cost( cost, IDsCellsG1, posData.new_IDs, ) - posData.cca_df = self.view_model.cca_workflows.apply_auto_assignments( + posData.cca_df = self.cca_workflows.apply_auto_assignments( posData.cca_df, assignments, posData.frame_i, - self.view_model.cca_workflows.base_status(base_cca_dict), + self.cca_workflows.base_status(base_cca_dict), previous_cca_df=prev_cca_df, current_ids=posData.IDs, ) @@ -624,7 +703,7 @@ def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): posData = self.data[self.pos_i] current_frame_i = posData.frame_i - prep_result = self.view_model.cca_workflows.prepare_missing_frame_annotations( + prep_result = self.cca_workflows.prepare_missing_frame_annotations( posData.allData_li, self.cca_df_colnames, last_cca_frame_i, @@ -639,7 +718,7 @@ def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): posData.frame_i = frame_i self.get_data() cca_df = self.getBaseCca_df() - cca_df = self.view_model.cca_workflows.overlay_last_annotated( + cca_df = self.cca_workflows.overlay_last_annotated( cca_df, last_annotated_cca_df, cca_df_colnames, @@ -654,14 +733,14 @@ def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): def addMissingIDs_cca_df(self, posData): base_cca_df = self.getBaseCca_df() - result = self.view_model.cca_edits.add_missing_ids( + result = self.cca_edits.add_missing_ids( posData.cca_df, base_cca_df, ) posData.cca_df = result.cca_df def update_cca_df_relabelling(self, posData, oldIDs, newIDs): - result = self.view_model.cca_edits.relabel_ids( + result = self.cca_edits.relabel_ids( posData.cca_df, oldIDs, newIDs, @@ -679,7 +758,7 @@ def update_cca_df_deletedIDs( self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) try: - deletion_result = self.view_model.cca_edits.delete_ids( + deletion_result = self.cca_edits.delete_ids( posData.cca_df, deletedIDs, ) @@ -721,7 +800,7 @@ def updateCcaDfDeletedIDsTimelapse( else: existing_ids_by_frame[frame_i] = set(existingIDs) - propagation_result = self.view_model.cca_workflows.propagate_deleted_ids( + propagation_result = self.cca_workflows.propagate_deleted_ids( None, posData.frame_i, deletedIDs, @@ -755,7 +834,7 @@ def update_cca_df_newIDs(self, posData, new_IDs): self.addIDBaseCca_df(posData, newID) def update_cca_df_snapshots(self, editTxt, posData): - result = self.view_model.cca_edits.apply_snapshot_id_edits( + result = self.cca_edits.apply_snapshot_id_edits( posData.cca_df, editTxt, posData.IDs, @@ -795,7 +874,7 @@ def isLastVisitedAgainCca(self, curr_df, enforceAll=False): next_df = None if posData.frame_i+1 < posData.SizeT: next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] - result = self.view_model.cca_workflows.auto_repeat_frame_state( + result = self.cca_workflows.auto_repeat_frame_state( curr_df, next_df, posData.new_IDs, @@ -838,18 +917,18 @@ def handleNoCellsInG1(self, numCellsG1, numNewCells): proceed = True # Annotate the new IDs with unknown history for ID in posData.new_IDs: - posData.cca_df = self.view_model.cca_edits.add_base_annotation( + posData.cca_df = self.cca_edits.add_base_annotation( posData.cca_df, ID, base_values=base_cca_dict, ) - cca_df_ID = self.view_model.cca_workflows.known_history_status_for_bud( + cca_df_ID = self.cca_workflows.known_history_status_for_bud( ID, ( (i, self.get_cca_df(frame_i=i, return_df=True)) for i in range(posData.frame_i-1, -1, -1) ), - self.view_model.cca_workflows.base_status(base_cca_dict), + self.cca_workflows.base_status(base_cca_dict), ) posData.ccaStatus_whenEmerged[ID] = cca_df_ID else: @@ -863,7 +942,7 @@ def handleNoCellsInG1(self, numCellsG1, numNewCells): def isFrameCcaAnnotated(self): posData = self.data[self.pos_i] acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - return self.view_model.cca_edits.has_annotations(acdc_df) + return self.cca_edits.has_annotations(acdc_df) def warnEditingWithCca_df( self, editTxt, return_answer=False, get_answer=False, @@ -878,18 +957,18 @@ def warnEditingWithCca_df( posData = self.data[self.pos_i] acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - cell_cycle_stage_present = self.view_model.cca_edits.has_annotations( + cell_cycle_stage_present = self.cca_edits.has_annotations( acdc_df ) lineage_tree_present = ( - self.view_model.lineage.has_lineage_tree_annotations( + self.lineage.has_lineage_tree_annotations( acdc_df, self.lineage_tree, ) ) action = self.warnEditingWithAnnotActions.get(editTxt, None) - warning_plan = self.view_model.annotated_edit_warning_plan( + warning_plan = self.annotated_edit_warning_plan( is_snapshot=self.isSnapshot, acdc_df_missing=acdc_df is None, lineage_tree_missing=self.lineage_tree is None, @@ -1062,7 +1141,7 @@ def isCcaCheckerChecking(self): def getConcatCcaDf(self): posData = self.data[self.pos_i] - return self.view_model.cca_edits.concat_annotations( + return self.cca_edits.concat_annotations( posData.allData_li, self.cca_df_colnames, size_t=posData.SizeT, @@ -1070,7 +1149,7 @@ def getConcatCcaDf(self): def storeFromConcatCcaDf(self, global_cca_df): posData = self.data[self.pos_i] - for frame_i, cca_df in self.view_model.cca_edits.split_concat_annotations( + for frame_i, cca_df in self.cca_edits.split_concat_annotations( global_cca_df, size_t=posData.SizeT, ): @@ -1083,7 +1162,7 @@ def resetWillDivideInfo(self): if global_cca_df is None: return - global_cca_df = self.view_model.cca_workflows.fix_will_divide_without_next_generation(global_cca_df) + global_cca_df = self.cca_workflows.fix_will_divide_without_next_generation(global_cca_df) self.storeFromConcatCcaDf(global_cca_df) def ccaCheckerStopChecking(self): @@ -1107,7 +1186,7 @@ def resetCcaFuture(self, from_frame_i): self.ccaCheckerStopChecking() self.setNavigateScrollBarMaximum() - removal_result = self.view_model.cca_edits.remove_future_annotations( + removal_result = self.cca_edits.remove_future_annotations( posData.allData_li, self.cca_df_colnames, from_frame_i, @@ -1131,7 +1210,7 @@ def removeCcaAnnotationsCurrentFrame(self): posData.allData_li[posData.frame_i].pop('cca_df_checker', None) df = posData.allData_li[posData.frame_i]['acdc_df'] - result = self.view_model.cca_edits.remove_annotations( + result = self.cca_edits.remove_annotations( df, self.cca_df_colnames ) if result.missing_frame or not result.removed: @@ -1142,7 +1221,7 @@ def removeCcaAnnotationsCurrentFrame(self): def resetFutureCcaColCurrentFrame(self): posData = self.data[self.pos_i] - posData.cca_df = self.view_model.cca_edits.reset_future_flags( + posData.cca_df = self.cca_edits.reset_future_flags( posData.cca_df ) self.store_data() @@ -1154,7 +1233,7 @@ def get_cca_df(self, frame_i=None, return_df=False, debug=False): posData = self.data[self.pos_i] i = posData.frame_i if frame_i is None else frame_i df = posData.allData_li[i]['acdc_df'] - result = self.view_model.cca_edits.resolve_annotations( + result = self.cca_edits.resolve_annotations( df, self.cca_df_colnames, is_snapshot=self.isSnapshot, @@ -1172,14 +1251,14 @@ def get_cca_df(self, frame_i=None, return_df=False, debug=False): def unstore_cca_df(self): posData = self.data[self.pos_i] acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - result = self.view_model.cca_edits.remove_annotations( + result = self.cca_edits.remove_annotations( acdc_df, self.cca_df_colnames ) if result.acdc_df is not None: posData.allData_li[posData.frame_i]['acdc_df'] = result.acdc_df def store_cca_df_checker(self, posData, frame_i, cca_df): - checker_cca_df = self.view_model.cca_edits.prepare_checker_annotations( + checker_cca_df = self.cca_edits.prepare_checker_annotations( cca_df, checker_running=self.ccaCheckerRunning, ) @@ -1215,7 +1294,7 @@ def store_cca_df( posData.frame_i = current_frame_i self.get_data(debug=False) - store_result = self.view_model.cca_edits.store_frame_annotations( + store_result = self.cca_edits.store_frame_annotations( acdc_df, cca_df, self.cca_df_colnames, @@ -1259,7 +1338,7 @@ def viewCcaTable(self): text = f'{header}{df_compare}' self.logger.info(text) - if self.view_model.cca_edits.has_annotations(df): + if self.cca_edits.has_annotations(df): cca_df = df[self.cca_df_colnames] cca_df = cca_df.merge( current_cca_df, how='outer', left_index=True, right_index=True, @@ -1334,7 +1413,7 @@ def autoAssignBud_YeastMate(self): acdcSegment = self.acdcSegment_li[idx] if acdcSegment is None: acdcSegment = ( - self.view_model.model_registry.import_segmentation_module( + self.model_registry.import_segmentation_module( model_name ) ) @@ -1342,7 +1421,7 @@ def autoAssignBud_YeastMate(self): # Read all models parameters init_params, segment_params = ( - self.view_model.model_registry.model_arg_specs(acdcSegment) + self.model_registry.model_arg_specs(acdcSegment) ) # Prompt user to enter the model parameters try: @@ -1367,7 +1446,7 @@ def autoAssignBud_YeastMate(self): return use_gpu = win.init_kwargs.get('gpu', False) - proceed = self.view_model.model_registry.check_gpu_available( + proceed = self.model_registry.check_gpu_available( model_name, use_gpu, qparent=self.host ) if not proceed: @@ -1376,7 +1455,7 @@ def autoAssignBud_YeastMate(self): return self.model_kwargs = win.model_kwargs - model = self.view_model.model_registry.init_segmentation_model( + model = self.model_registry.init_segmentation_model( acdcSegment, posData, win.init_kwargs ) if model is None: @@ -1440,7 +1519,7 @@ def repeatAutoCca(self): # frames that already contain anotations posData = self.data[self.pos_i] next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] - if self.view_model.cca_edits.has_annotations(next_df): + if self.cca_edits.has_annotations(next_df): msg = QMessageBox() warn_cca = msg.critical( self.host, 'Future visited frames detected!', @@ -1528,7 +1607,7 @@ def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i): self.storeUndoRedoCca(i, cca_df_i, undoId) - cca_df_i = self.view_model.cca_edits.apply_manual_changes( + cca_df_i = self.cca_edits.apply_manual_changes( cca_df_i, changes ) self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) @@ -1576,7 +1655,7 @@ def fixWillDivide(self, warning_txt, IDs_will_divide_wrong): self.logger.info('Fixing `will_divide` information...') global_cca_df = self.getConcatCcaDf() - global_cca_df = self.view_model.cca_workflows.reset_will_divide_for_generations( + global_cca_df = self.cca_workflows.reset_will_divide_for_generations( global_cca_df, IDs_will_divide_wrong, ) @@ -1621,25 +1700,25 @@ def annotateIsHistoryKnown(self, ID): relID = posData.cca_df.at[ID, 'relative_ID'] relID_cca = None if relID in posData.cca_df.index: - relID_cca = self.view_model.cca_workflows.previous_relative_status_before_bud_emergence( + relID_cca = self.cca_workflows.previous_relative_status_before_bud_emergence( ID, relID, ( (i, self.get_cca_df(frame_i=i, return_df=True)) for i in range(posData.frame_i-1, -1, -1) ), - self.view_model.cca_workflows.base_status(base_cca_dict), + self.cca_workflows.base_status(base_cca_dict), ) if is_history_known: # Save status of ID when emerged to allow undoing - statusID_whenEmerged = self.view_model.cca_workflows.known_history_status_for_bud( + statusID_whenEmerged = self.cca_workflows.known_history_status_for_bud( ID, ( (i, self.get_cca_df(frame_i=i, return_df=True)) for i in range(posData.frame_i-1, -1, -1) ), - self.view_model.cca_workflows.base_status(base_cca_dict), + self.cca_workflows.base_status(base_cca_dict), ) if statusID_whenEmerged is None: return @@ -1661,7 +1740,7 @@ def annotateIsHistoryKnown(self, ID): (i, self.get_cca_df(frame_i=i, return_df=True)) for i in range(posData.frame_i-1, -1, -1) ) - propagation_result = self.view_model.cca_workflows.propagate_history_knowledge( + propagation_result = self.cca_workflows.propagate_history_knowledge( posData.cca_df, posData.frame_i, ID, @@ -1710,7 +1789,7 @@ def annotateWillDivide(self, ID, relID, frame_i=None): (i, self.get_cca_df(frame_i=i, return_df=True)) for i in range(frame_i-1, -1, -1) ) - propagation_result = self.view_model.cca_workflows.propagate_will_divide( + propagation_result = self.cca_workflows.propagate_will_divide( None, frame_i, ID, @@ -1735,16 +1814,16 @@ def annotateDivision(self, cca_df, ID, relID, frame_i=None): frame_i = posData.frame_i self.annotateWillDivide(ID, relID) - return self.view_model.cca_workflows.annotate_division(cca_df, ID, relID, frame_i) + return self.cca_workflows.annotate_division(cca_df, ID, relID, frame_i) def undoDivisionAnnotation(self, cca_df, ID, relID): # Correct as follows: # If G1 then correct to S and -1 on generation number - return self.view_model.cca_workflows.undo_division_annotation(cca_df, ID, relID) + return self.cca_workflows.undo_division_annotation(cca_df, ID, relID) def undoBudMothAssignment(self, ID): posData = self.data[self.pos_i] - changed = self.view_model.cca_workflows.undo_bud_mother_assignment(posData.cca_df, ID) + changed = self.cca_workflows.undo_bud_mother_assignment(posData.cca_df, ID) if not changed: return @@ -1810,7 +1889,7 @@ def manualCellCycleAnnotation(self, ID): (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) for frame_i in range(posData.frame_i - 1, -1, -1) ) - propagation_result = self.view_model.cca_workflows.propagate_manual_division_annotation( + propagation_result = self.cca_workflows.propagate_manual_division_annotation( None, posData.frame_i, ID, @@ -1943,7 +2022,7 @@ def checkMothEligibility(self, budID, new_mothID): (past_i, self.get_cca_df(frame_i=past_i, return_df=True)) for past_i in range(posData.frame_i-1, -1, -1) ) - result = self.view_model.cca_workflows.mother_assignment_eligibility( + result = self.cca_workflows.mother_assignment_eligibility( budID, new_mothID, future_cca_frames, @@ -1979,7 +2058,7 @@ def checkMothersExcludedOrDead(self): & (posData.cca_df.emerg_frame_i == posData.frame_i) ] mother_ids = buds_df.relative_ID.to_list() if not buds_df.empty else [] - excluded_mother_ids = self.view_model.check_mothers_exclusion_or_dead( + excluded_mother_ids = self.check_mothers_exclusion_or_dead( acdc_df_i, mother_ids ) @@ -2029,7 +2108,7 @@ def checkDivisionCanBeUndone(self, ID, relID): (past_i, self.get_cca_df(frame_i=past_i, return_df=True)) for past_i in range(posData.frame_i-1, -1, -1) ) - return self.view_model.cca_workflows.division_undo_blocking_frame( + return self.cca_workflows.division_undo_blocking_frame( ID, relID, posData.frame_i, @@ -2154,7 +2233,7 @@ def annotateBudToDifferentMother(self): # Store cca_df for undo action undoId = uuid.uuid4() self.storeUndoRedoCca(0, posData.cca_df, undoId) - propagation_result = self.view_model.cca_workflows.propagate_bud_mother_assignment( + propagation_result = self.cca_workflows.propagate_bud_mother_assignment( posData.cca_df, posData.frame_i, budID, @@ -2168,14 +2247,14 @@ def annotateBudToDifferentMother(self): curr_moth_cca = None curr_mothID = posData.cca_df.at[budID, 'relative_ID'] if curr_mothID in posData.cca_df.index: - curr_moth_cca = self.view_model.cca_workflows.previous_relative_status_before_bud_emergence( + curr_moth_cca = self.cca_workflows.previous_relative_status_before_bud_emergence( budID, curr_mothID, ( (i, self.get_cca_df(frame_i=i, return_df=True)) for i in range(posData.frame_i-1, -1, -1) ), - self.view_model.cca_workflows.base_status(base_cca_dict), + self.cca_workflows.base_status(base_cca_dict), ) # Store cca_df for undo action @@ -2190,7 +2269,7 @@ def annotateBudToDifferentMother(self): (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) for frame_i in range(posData.frame_i - 1, -1, -1) ) - propagation_result = self.view_model.cca_workflows.propagate_bud_mother_assignment( + propagation_result = self.cca_workflows.propagate_bud_mother_assignment( posData.cca_df, posData.frame_i, budID, @@ -2292,7 +2371,7 @@ def checkChangeMotherBudEligible(self, budID, frame_i): (future_i, self.get_cca_df(frame_i=future_i, return_df=True)) for future_i in range(frame_i, posData.SizeT) ) - result = self.view_model.cca_workflows.bud_mother_change_eligibility(budID, future_cca_frames) + result = self.cca_workflows.bud_mother_change_eligibility(budID, future_cca_frames) if result.can_change: return True @@ -2322,7 +2401,7 @@ def checkSwapMothersEligibility(self): (past_i, self.get_cca_df(frame_i=past_i, return_df=True)) for past_i in range(posData.frame_i, -1, -1) ] - result = self.view_model.cca_workflows.swap_mothers_eligibility( + result = self.cca_workflows.swap_mothers_eligibility( budID, otherBudID, otherMothID, @@ -2370,7 +2449,7 @@ def swapMothers(self, budID, otherBudID, otherMothID, mothID): (future_i, self.get_cca_df(frame_i=future_i, return_df=True)) for future_i in range(posData.frame_i+1, posData.SizeT) ] - propagation_result = self.view_model.cca_workflows.propagate_swap_mothers_assignment( + propagation_result = self.cca_workflows.propagate_swap_mothers_assignment( posData.cca_df, posData.frame_i, budID, @@ -2379,7 +2458,7 @@ def swapMothers(self, budID, otherBudID, otherMothID, mothID): mothID, past_cca_frames=past_cca_frames, future_cca_frames=future_cca_frames, - base_status=self.view_model.cca_workflows.base_status(base_cca_dict), + base_status=self.cca_workflows.base_status(base_cca_dict), ) posData.cca_df = propagation_result.current_cca_df self.store_cca_df() @@ -2389,4 +2468,4 @@ def swapMothers(self, budID, otherBudID, otherMothID, mothID): continue self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) - self.updateAllImages() + self.updateAllImages() \ No newline at end of file diff --git a/cellacdc/views/combine_view.py b/cellacdc/views/combine_view.py index f4fe42e11..b0fb1ee16 100644 --- a/cellacdc/views/combine_view.py +++ b/cellacdc/views/combine_view.py @@ -3,12 +3,13 @@ from __future__ import annotations from typing import List, Dict, Any, Tuple +from dataclasses import dataclass +import numpy as np from qtpy.QtCore import QThread, QTimer, QMutex, QWaitCondition from natsort import natsorted import numpy as np from cellacdc import core, workers, widgets, html_utils, apps, preprocess, myutils, printl -from cellacdc.viewmodels.combine_viewmodel import CombineViewModel class CombineView: @@ -43,15 +44,13 @@ class CombineView: 'saveCombineWorkerFinished', ) - def __init__(self, host, view_model: CombineViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -82,6 +81,56 @@ def combineDialogSaveCombinedData(self, dialog): helpText = ( """ + + """Headless state and helpers for combining channel and image arrays.""" + + def initialize_combine_image_data(self, pos_data) -> np.ndarray: + """Initializes pos_data.combine_img_data if not already present.""" + if not hasattr(pos_data, 'combine_img_data'): + from cellacdc import preprocess + pos_data.combine_img_data = preprocess.PreprocessedData( + image_data=np.zeros(pos_data.img_data.shape) + ) + return pos_data.combine_img_data + + def validate_dimensions(self, ndim: int) -> bool: + """Asserts that image data dimensions are valid for combining (3D or 4D).""" + if ndim not in (3, 4): + raise ValueError('Invalid number of dimensions in img_data.') + return True + + def group_processed_data_by_pos( + self, + processed_data: list[np.ndarray], + keys: list[tuple[int, int, int]] + ) -> dict[int, list[tuple[tuple[int, int, int], np.ndarray]]]: + """Groups raw processed preview output arrays by position index.""" + unique_pos = {key[0] for key in keys} + per_pos_data = {pos_i: [] for pos_i in unique_pos} + for key, img in zip(keys, processed_data): + pos_i, frame_i, z_slice = key + per_pos_data[pos_i].append((key, img)) + return per_pos_data + + def update_combine_image_data( + self, + pos_data, + pos_i_data: list[tuple[tuple[int, int, int], np.ndarray]] + ): + """Updates preprocessed combined image data container frames and z-slices.""" + n_dim_img = pos_data.img_data.ndim + self.initialize_combine_image_data(pos_data) + self.validate_dimensions(n_dim_img) + + if n_dim_img == 4: + for key, img in pos_i_data: + _, frame_i, z_slice = key + pos_data.combine_img_data[frame_i][z_slice] = img + elif n_dim_img == 3: + for key, img in pos_i_data: + _, frame_i, _ = key + pos_data.combine_img_data[frame_i] = img + The segm/img file will be saved with a different file name.

Insert a name to append to the end of the new file name. The rest of @@ -548,11 +597,11 @@ def combineWorkerPreviewDone( processed_data: List[np.ndarray], keys: List[Tuple[int, int, int]] ): - per_pos_data = self.view_model.group_processed_data_by_pos(processed_data, keys) + per_pos_data = self.group_processed_data_by_pos(processed_data, keys) for pos_i, pos_i_data in per_pos_data.items(): posData = self.data[pos_i] - self.view_model.update_combine_image_data(posData, pos_i_data) + self.update_combine_image_data(posData, pos_i_data) if not self.combineDialog.saveAsSegm(): for key, _ in pos_i_data: @@ -600,11 +649,11 @@ def combineWorkerDone( self.status_hover_view.set_status_bar_label(log=False) self.combineDialog.appliedFinished() - per_pos_data = self.view_model.group_processed_data_by_pos(processed_data, keys) + per_pos_data = self.group_processed_data_by_pos(processed_data, keys) for pos_i, pos_i_data in per_pos_data.items(): posData = self.data[pos_i] - self.view_model.update_combine_image_data(posData, pos_i_data) + self.update_combine_image_data(posData, pos_i_data) if not self.combineDialog.saveAsSegm(): for key, _ in pos_i_data: @@ -643,4 +692,4 @@ def saveCombineWorkerFinished(self): self.status_hover_view.set_status_bar_label() self.logger.info('Combined channels saved!') - self.titleLabel.setText('Combined channels saved!', color='w') + self.titleLabel.setText('Combined channels saved!', color='w') \ No newline at end of file diff --git a/cellacdc/views/curvature_tools_view.py b/cellacdc/views/curvature_tools_view.py index 4f662a8aa..d47d02f03 100644 --- a/cellacdc/views/curvature_tools_view.py +++ b/cellacdc/views/curvature_tools_view.py @@ -4,24 +4,104 @@ import numpy as np import pyqtgraph as pg +import numpy as np import skimage.draw import skimage.measure -from cellacdc.viewmodels.curvature_viewmodel import CurvatureViewModel class CurvatureToolsView: """Qt-facing adapter around curvature tool contracts.""" - def __init__(self, host, view_model: CurvatureViewModel): - object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) + """Headless spline drawing and label-painting operations.""" + + def tangent_brush_polygon( + self, + yx_start, + yx_end, + radius: int | float, + shape: tuple[int, int], + ) -> tuple[np.ndarray, np.ndarray]: + return tangent_brush_polygon(yx_start, yx_end, radius, shape) + + def directional_coords( + self, + alfa_dir: int, + y: int, + x: int, + shape: tuple[int, int], + *, + connectivity: int = 1, + ) -> tuple[list[int], list[int]]: + return directional_coords( + alfa_dir, + y, + x, + shape, + connectivity=connectivity, + ) + def spline_coords( + self, + xx, + yy, + *, + resolution_space=None, + per: bool = False, + append_first: bool = False, + ): + return spline_coords( + xx, + yy, + resolution_space=resolution_space, + per=per, + append_first=append_first, + ) + + def closed_spline_coords( + self, + xx_spline, + yy_spline, + *, + anchor_xx=None, + anchor_yy=None, + predictor=None, + max_exec_time: int = 150, + ): + return closed_spline_coords( + xx_spline, + yy_spline, + anchor_xx=anchor_xx, + anchor_yy=anchor_yy, + predictor=predictor, + max_exec_time=max_exec_time, + ) + + def paint_spline_to_labels( + self, + labels_2d: np.ndarray, + xx_spline, + yy_spline, + label_id: int, + *, + empty_only: bool = True, + ) -> CurvatureLabelPaintResult: + return paint_spline_to_labels( + labels_2d, + xx_spline, + yy_spline, + label_id, + empty_only=empty_only, + ) + + + def __init__(self, host): + object.__setattr__(self, 'host', host) def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -31,7 +111,7 @@ def getPolygonBrush(self, yxc2, Y, X): # see https://en.wikipedia.org/wiki/Tangent_lines_to_circles y1, x1 = self.yPressAx2, self.xPressAx2 y2, x2 = yxc2 - rr_poly, cc_poly = self.view_model.tangent_brush_polygon( + rr_poly, cc_poly = self.tangent_brush_polygon( (y1, x1), (y2, x2), self.brushSizeSpinbox.value(), @@ -42,7 +122,7 @@ def getPolygonBrush(self, yxc2, Y, X): return rr_poly, cc_poly def get_dir_coords(self, alfa_dir, yd, xd, shape, connectivity=1): - return self.view_model.directional_coords( + return self.directional_coords( alfa_dir, yd, xd, @@ -115,7 +195,7 @@ def smoothAutoContWithSpline(self, n=3): def getClosedSplineCoords(self): xxS, yyS = self.curvPlotItem.getData() xx, yy = self.curvAnchors.getData() - return self.view_model.closed_spline_coords( + return self.closed_spline_coords( xxS, yyS, anchor_xx=xx, @@ -127,7 +207,7 @@ def getClosedSplineCoords(self): def getSpline(self, xx, yy, resolutionSpace=None, per=False, appendFirst=False): if resolutionSpace is None: resolutionSpace = self.hoverLinSpace - return self.view_model.spline_coords( + return self.spline_coords( xx, yy, resolution_space=resolutionSpace, @@ -217,7 +297,7 @@ def curvToolSplineToObj(self, xxA=None, yyA=None, isRightClick=False): curvToolID = posData.brushID lab2D = self.get_2Dlab(posData.lab).copy() - result = self.view_model.paint_spline_to_labels( + result = self.paint_spline_to_labels( lab2D, xxS, yyS, @@ -253,4 +333,4 @@ def hoverEventDrawSpline(self, event): xx = np.r_[xx, x] yy = np.r_[yy, y] xi, yi = self.getSpline(xx, yy, per=per) - self.curvHoverPlotItem.setData(xi, yi) + self.curvHoverPlotItem.setData(xi, yi) \ No newline at end of file diff --git a/cellacdc/views/custom_annotations_view.py b/cellacdc/views/custom_annotations_view.py index a4d0d0ed6..50100d4b6 100644 --- a/cellacdc/views/custom_annotations_view.py +++ b/cellacdc/views/custom_annotations_view.py @@ -9,12 +9,11 @@ from collections import defaultdict import pyqtgraph as pg +import os +import pandas as pd from qtpy.QtGui import QColor from cellacdc import apps, html_utils, settings_folderpath, widgets -from cellacdc.viewmodels.custom_annotations_viewmodel import ( - CustomAnnotationsViewModel, -) custom_annot_path = os.path.join(settings_folderpath, 'custom_annotations.json') @@ -23,15 +22,13 @@ class CustomAnnotationsView: """Qt-facing adapter around custom annotation buttons and dialogs.""" - def __init__(self, host, view_model: CustomAnnotationsViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -39,7 +36,7 @@ def __setattr__(self, name, value): def readSavedCustomAnnot(self): if os.path.exists(custom_annot_path): self.logger.info('Loading saved custom annotations...') - tempAnnot = self.view_model.read_saved_annotations( + tempAnnot = self.read_saved_annotations( custom_annot_path, logger_func=self.logger.info ) @@ -85,7 +82,7 @@ def addCustomAnnotationSavedPos(self, pos_i=None): keySequence = widgets.KeySequenceFromText(keySequence) else: keySequence = None - toolTip = self.view_model.tooltip(annotState) + toolTip = self.tooltip(annotState) keepActive = annotState.get('keepActive', True) isHideChecked = annotState.get('isHideChecked', True) @@ -179,7 +176,7 @@ def addCustomAnnotationItems( acdc_df = data_dict['acdc_df'] if acdc_df is None: continue - result = self.view_model.ensure_column( + result = self.ensure_column( acdc_df, name ) data_dict['acdc_df'] = result.dataframe @@ -188,7 +185,7 @@ def addCustomAnnotationItems( ) if posData.acdc_df is not None: - result = self.view_model.ensure_column( + result = self.ensure_column( posData.acdc_df, name, ) @@ -220,6 +217,83 @@ def loadCustomAnnotations(self): if len(items) == 0: msg = widgets.myMessageBox() txt = html_utils.paragraph(""" + + """Headless custom annotation table updates.""" + + def read_saved_annotations( + self, + annotations_path: str, + *, + logger_func=None, + ) -> dict: + if not os.path.exists(annotations_path): + return {} + return load.read_json(annotations_path, logger_func=logger_func) + + def tooltip(self, annotation_state: dict) -> str: + return myutils.getCustomAnnotTooltip(annotation_state) + + def ensure_column( + self, + acdc_df: pd.DataFrame, + annotation_name: str, + ) -> CustomAnnotationColumnResult: + return ensure_custom_annotation_column(acdc_df, annotation_name) + + def column_exists( + self, + frame_records, + annotation_name: str, + *, + summary_acdc_df: pd.DataFrame | None = None, + ) -> bool: + return custom_annotation_column_exists( + frame_records, + annotation_name, + summary_acdc_df=summary_acdc_df, + ) + + def drop_column( + self, + acdc_df: pd.DataFrame | None, + annotation_name: str, + ) -> pd.DataFrame | None: + return drop_custom_annotation_column(acdc_df, annotation_name) + + def rename_column( + self, + acdc_df: pd.DataFrame | None, + old_name: str, + new_name: str, + ) -> pd.DataFrame | None: + return rename_custom_annotation_column(acdc_df, old_name, new_name) + + def remap_ids(self, annotated_ids_by_frame, old_ids, new_ids) -> dict: + return remap_custom_annotation_ids( + annotated_ids_by_frame, + old_ids, + new_ids, + ) + + def update_frame( + self, + acdc_df: pd.DataFrame, + annotation_name: str, + annotated_ids, + *, + clicked_id: int = 0, + click_is_active: bool = False, + existing_ids=None, + ) -> CustomAnnotationFrameUpdate: + return update_custom_annotation_frame( + acdc_df, + annotation_name, + annotated_ids, + clicked_id=clicked_id, + click_is_active=click_is_active, + existing_ids=existing_ids, + ) + There are no custom annotations saved.

Click on "Add custom annotation" button to start adding new annotations. @@ -337,7 +411,7 @@ def askCustomAnnotationNameExists(self, name): def checkNameExists(self, name): posData = self.data[self.pos_i] - if self.view_model.column_exists( + if self.column_exists( posData.allData_li, name, summary_acdc_df=posData.acdc_df, @@ -413,7 +487,7 @@ def customAnnotModify(self, button): old_name = self.customAnnotDict[button]['state']['name'] new_name = self.addAnnotWin.state['name'] posData.allData_li[posData.frame_i]['acdc_df'] = ( - self.view_model.rename_column( + self.rename_column( acdc_df, old_name, new_name ) ) @@ -479,7 +553,7 @@ def doCustomAnnotation(self, ID): self.store_data(autosave=False) acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - result = self.view_model.update_frame( + result = self.update_frame( acdc_df, state['name'], annotIDs_frame_i, @@ -552,7 +626,7 @@ def removeCustomAnnotButton(self, button, askHow=True, save=True): if removeOnlyButton: continue - posData.acdc_df = self.view_model.drop_column( + posData.acdc_df = self.drop_column( posData.acdc_df, name, ) @@ -561,7 +635,7 @@ def removeCustomAnnotButton(self, button, askHow=True, save=True): if acdc_df is None: continue posData.allData_li[frame_i]['acdc_df'] = ( - self.view_model.drop_column( + self.drop_column( acdc_df, name ) ) @@ -616,4 +690,4 @@ def customAnnotButtonToggled(self, checked): if clearAnnotation: self.clearScatterPlotCustomAnnotButton(button) self.setHighlightID(False) - self.resetCursor() + self.resetCursor() \ No newline at end of file diff --git a/cellacdc/views/data_loading_view.py b/cellacdc/views/data_loading_view.py index 01da177b2..276ba555d 100644 --- a/cellacdc/views/data_loading_view.py +++ b/cellacdc/views/data_loading_view.py @@ -11,6 +11,14 @@ import pandas as pd import psutil import skimage +import os +from dataclasses import dataclass +from datetime import datetime +import cv2 +import numpy as np +import pandas as pd +import skimage +import skimage.color import skimage.io from natsort import natsorted from qtpy.QtCore import QEventLoop, QMutex, Qt, QThread, QTimer, QWaitCondition @@ -32,7 +40,6 @@ widgets, workers, ) -from cellacdc.viewmodels.data_loading_viewmodel import DataLoadingViewModel GREEN_HEX = _palettes.green() @@ -86,15 +93,13 @@ class DataLoadingView: 'openRecentFile', ) - def __init__(self, host, view_model: DataLoadingViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -121,7 +126,7 @@ def reload_cb(self): def getFileExtensions(self, images_path): alignedFound = any( f.find('_aligned.np') != -1 - for f in self.view_model.workspace.listdir(images_path) + for f in self.workspace.listdir(images_path) ) if alignedFound: extensions = ( @@ -158,7 +163,7 @@ def zSliceAbsent(self, filename, posData): _, filename = self.getPathFromChName(user_ch_name, _posData) df = myutils.getDefault_SegmInfo_df(_posData, filename) _posData.segmInfo_df = ( - self.view_model.merge_default_segm_info( + self.merge_default_segm_info( _posData.segmInfo_df, df ) ) @@ -178,7 +183,7 @@ def zSliceAbsent(self, filename, posData): return dst_df = myutils.getDefault_SegmInfo_df(_posData, dstFilename) _posData.segmInfo_df = ( - self.view_model.copy_single_zslice_segm_info( + self.copy_single_zslice_segm_info( _posData.segmInfo_df, dst_df, src_filename=srcFilename, @@ -206,6 +211,191 @@ def zSliceAbsent(self, filename, posData): dataPrepWin.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) dataPrepWin.titleText = ( """ + + """Headless data-loading rules and path plans.""" + + def open_image_file_context( + self, file_path: str, timestamp: str | None = None + ) -> OpenImageFileContext: + filename_no_ext, ext = os.path.splitext(os.path.basename(file_path)) + filename_no_ext = filename_no_ext.rstrip('_') + ext = ext.lower() + dirpath = os.path.dirname(file_path) + dirname = os.path.basename(dirpath) + requires_images_folder = dirname != 'Images' + acdc_folder = None + + if requires_images_folder: + timestamp = timestamp or datetime.now().strftime('%Y%m%d_%H%M%S') + acdc_folder = f'{timestamp}_acdc' + exp_path = os.path.join(dirpath, acdc_folder, 'Images') + else: + exp_path = dirpath + + return OpenImageFileContext( + file_path=file_path, + filename_no_ext=filename_no_ext, + extension=ext, + source_dirpath=dirpath, + source_dirname=dirname, + exp_path=exp_path, + acdc_folder=acdc_folder, + requires_images_folder=requires_images_folder, + ) + + def channel_name_suggestion( + self, filename_no_ext: str + ) -> ChannelNameSuggestion: + underscore_splits = filename_no_ext.split('_') + if len(underscore_splits) > 1: + return ChannelNameSuggestion( + basename='_'.join(underscore_splits[:-1]), + channel_name=underscore_splits[-1], + ) + + return ChannelNameSuggestion( + basename=filename_no_ext, + channel_name='channel_1', + ) + + def open_image_file_target( + self, + context: OpenImageFileContext, + channel_name: str | None = None, + ) -> OpenImageFileTarget: + filename_no_ext = context.filename_no_ext + basename = None + metadata_csv_filename = None + metadata_csv_filepath = None + + if channel_name is not None: + underscore_splits = filename_no_ext.split('_') + if len(underscore_splits) > 1: + default_ch_name = underscore_splits[-1] + if channel_name == default_ch_name: + filename_no_ext = '_'.join(underscore_splits[:-1]) + + basename = f'{filename_no_ext}_' + metadata_csv_filename = f'{basename}metadata.csv' + metadata_csv_filepath = os.path.join( + context.exp_path, metadata_csv_filename + ) + new_filename = ( + f'{filename_no_ext}_{channel_name}{context.extension}' + ) + else: + new_filename = f'{filename_no_ext}{context.extension}' + + new_filepath = os.path.join(context.exp_path, new_filename) + tif_filename_no_ext = os.path.splitext(new_filename)[0] + tif_filename = f'{tif_filename_no_ext}.tif' + tif_path = os.path.join(context.exp_path, tif_filename) + + return OpenImageFileTarget( + context=context, + filename_no_ext=filename_no_ext, + channel_name=channel_name, + basename=basename, + new_filename=new_filename, + new_filepath=new_filepath, + metadata_csv_filename=metadata_csv_filename, + metadata_csv_filepath=metadata_csv_filepath, + tif_filename=tif_filename, + tif_path=tif_path, + direct_copy_supported=context.extension in ('.tif', '.npz'), + ) + + def empty_data_plan(self, exp_path: str) -> EmptyDataPlan: + pos_path = os.path.join(exp_path, 'Position_1') + images_path = os.path.join(pos_path, 'Images') + basename = 'test_empty_' + tif_filename = f'{basename}channel_1.tif' + metadata_filename = f'{basename}metadata.csv' + + return EmptyDataPlan( + exp_path=exp_path, + pos_path=pos_path, + images_path=images_path, + basename=basename, + tif_filename=tif_filename, + tif_filepath=os.path.join(images_path, tif_filename), + metadata_filename=metadata_filename, + metadata_filepath=os.path.join(images_path, metadata_filename), + ) + + def copy_action_text(self, do_copy: bool) -> str: + return 'Copying' if do_copy else 'Moving' + + def is_imagej_dtype(self, dtype: np.dtype) -> bool: + return dtype in (np.uint8, np.uint32, np.float32) + + def prepare_tiff_image_data(self, image: np.ndarray) -> ImageDataPreparation: + converted_rgb_to_gray = False + converted_dtype = False + prepared_image = image + + if ( + prepared_image.ndim == 3 + and (prepared_image.shape[-1] == 3 + or prepared_image.shape[-1] == 4) + ): + converted_rgb_to_gray = True + if prepared_image.shape[-1] == 3: + prepared_image = skimage.color.rgb2gray(prepared_image) + else: + prepared_image = cv2.cvtColor( + prepared_image, cv2.COLOR_RGBA2GRAY + ) + prepared_image = skimage.img_as_ubyte(prepared_image) + + if not self.is_imagej_dtype(prepared_image.dtype): + converted_dtype = True + prepared_image = skimage.img_as_ubyte(prepared_image) + + return ImageDataPreparation( + image=prepared_image, + converted_rgb_to_gray=converted_rgb_to_gray, + converted_dtype=converted_dtype, + ) + + def merge_default_segm_info( + self, + existing_df: pd.DataFrame, + default_df: pd.DataFrame, + ) -> pd.DataFrame: + merged_df = pd.concat([default_df, existing_df]) + unique_idx = ~merged_df.index.duplicated() + return merged_df[unique_idx] + + def copy_single_zslice_segm_info( + self, + existing_df: pd.DataFrame, + default_dst_df: pd.DataFrame, + *, + src_filename: str, + dst_filename: str, + ) -> pd.DataFrame: + dst_df = default_dst_df.copy() + src_df = existing_df.loc[src_filename].copy() + + for z_info in src_df.itertuples(): + frame_i = z_info.Index + if z_info.which_z_proj != 'single z-slice': + continue + + src_idx = (src_filename, frame_i) + if existing_df.at[src_idx, 'resegmented_in_gui']: + col = 'z_slice_used_gui' + else: + col = 'z_slice_used_dataPrep' + + z_slice = existing_df.at[src_idx, col] + dst_idx = (dst_filename, frame_i) + dst_df.at[dst_idx, 'z_slice_used_dataPrep'] = z_slice + dst_df.at[dst_idx, 'z_slice_used_gui'] = z_slice + + return self.merge_default_segm_info(existing_df, dst_df) + Select z-slice (or projection) for each frame/position.
Once happy, close the window. """) @@ -236,9 +426,9 @@ def _workerDebug(self, stuff_to_debug): # self.worker.waitCond.wakeAll() def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): - total_ram = self.view_model.formatting.bytes_to_gb(total_ram) - available_ram = self.view_model.formatting.bytes_to_gb(available_ram) - required_ram = self.view_model.formatting.bytes_to_gb(required_ram) + total_ram = self.formatting.bytes_to_gb(total_ram) + available_ram = self.formatting.bytes_to_gb(available_ram) + required_ram = self.formatting.bytes_to_gb(required_ram) required_perc = round(100*required_ram/available_ram) msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" @@ -402,7 +592,7 @@ def initFluoData(self): ) def getPathFromChName(self, chName, posData): - ls = self.view_model.workspace.listdir(posData.images_path) + ls = self.workspace.listdir(posData.images_path) endnames = {f[len(posData.basename):]:f for f in ls} validEnds = ['_aligned.npz', '_aligned.h5', '.h5', '.tif', '.npz'] for end in validEnds: @@ -478,7 +668,7 @@ def loadFluo_cb(self, checked=True, fluo_channels=None): def criticalFluoChannelNotFound(self, fluo_ch, posData): msg = widgets.myMessageBox(showCentered=False) - ls = "\n".join(self.view_model.workspace.listdir(posData.images_path)) + ls = "\n".join(self.workspace.listdir(posData.images_path)) msg.setDetailedText( f'Files present in the {posData.relPath} folder:\n' f'{ls}' @@ -517,10 +707,10 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): for filePath in user_ch_file_paths: _posData = load.loadData(filePath, user_ch_name, log_func=self.logger.info) _posData.getBasenameAndChNames(qparent=self.host) - segm_files = self.view_model.workspace.segmentation_files( + segm_files = self.workspace.segmentation_files( _posData.images_path ) - _existingEndnames = self.view_model.workspace.endnames( + _existingEndnames = self.workspace.endnames( _posData.basename, segm_files ) existingSegmEndNames.update(_existingEndnames) @@ -1159,10 +1349,10 @@ def openFolder( ) def addToRecentPaths(self, path, logger=None): - self.view_model.workspace.add_recent_path(path, logger=self.logger) + self.workspace.add_recent_path(path, logger=self.logger) def getMostRecentPath(self): - return self.view_model.workspace.most_recent_path() + return self.workspace.most_recent_path() @exception_handler def _openFolder( @@ -1217,7 +1407,7 @@ def _openFolder( self.addToRecentPaths(exp_path, logger=self.logger) self.addPathToOpenRecentMenu(exp_path) - folder_type = self.view_model.workspace.determine_folder_type(exp_path) + folder_type = self.workspace.determine_folder_type(exp_path) is_pos_folder, is_images_folder, exp_path = folder_type self.titleLabel.setText('Loading data...', color=self.titleColor) @@ -1245,7 +1435,7 @@ def _openFolder( elif imageFilePath: # images_path = exp_path because called by openFile func - filenames = self.view_model.workspace.listdir(exp_path) + filenames = self.workspace.listdir(exp_path) ch_names, basenameNotFound = ( ch_name_selector.get_available_channels(filenames, exp_path) ) @@ -1262,7 +1452,7 @@ def _openFolder( # Get info from first position selected images_path = self.images_paths[0] - filenames = self.view_model.workspace.listdir(images_path) + filenames = self.workspace.listdir(images_path) if ch_name_selector.is_first_call and user_ch_name is None: ch_names, _ = ch_name_selector.get_available_channels( filenames, images_path @@ -1390,7 +1580,7 @@ def askUserChannelName(self, filename_no_ext, ext): The basename will be common to all created files, while the additional text is used to identify the image files. """) - suggestion = self.view_model.channel_name_suggestion(filename_no_ext) + suggestion = self.channel_name_suggestion(filename_no_ext) basename = suggestion.basename channel_name = suggestion.channel_name @@ -1481,7 +1671,7 @@ def _openFile(self, file_path=None): if not file_path: return - context = self.view_model.open_image_file_context(file_path) + context = self.open_image_file_context(file_path) channel_name = None do_copy = True if context.requires_images_folder: @@ -1501,7 +1691,7 @@ def _openFile(self, file_path=None): os.makedirs(context.exp_path, exist_ok=True) - target = self.view_model.open_image_file_target( + target = self.open_image_file_target( context, channel_name=channel_name ) if target.has_metadata: @@ -1511,7 +1701,7 @@ def _openFile(self, file_path=None): }) df_metadata.to_csv(target.metadata_csv_filepath, index=False) - action_text = self.view_model.copy_action_text(do_copy) + action_text = self.copy_action_text(do_copy) if target.direct_copy_supported: if not os.path.exists(target.new_filepath): @@ -1529,7 +1719,7 @@ def _openFile(self, file_path=None): context.file_path, '', log_func=self.logger.info ) data.loadImgData() - preparation = self.view_model.prepare_tiff_image_data( + preparation = self.prepare_tiff_image_data( data.img_data ) if preparation.converted_rgb_to_gray: @@ -1566,7 +1756,7 @@ def _createEmptyData(self): if not exp_path: return - plan = self.view_model.empty_data_plan(exp_path) + plan = self.empty_data_plan(exp_path) if os.path.exists(plan.images_path): raise FileExistsError( f'The following path already exists "{plan.images_path}"' @@ -1656,4 +1846,4 @@ def criticalImgPathNotFound(self, images_path): def openRecentFile(self, path): self.logger.info(f'Opening recent folder: {path}') self.addToRecentPaths(path, logger=self.logger) - self.openFolder(exp_path=path) + self.openFolder(exp_path=path) \ No newline at end of file diff --git a/cellacdc/views/deleted_rois_view.py b/cellacdc/views/deleted_rois_view.py index 08dac618d..4d92efb10 100644 --- a/cellacdc/views/deleted_rois_view.py +++ b/cellacdc/views/deleted_rois_view.py @@ -7,16 +7,50 @@ import numpy as np import pyqtgraph as pg +from collections.abc import Iterable import skimage.measure from qtpy.QtCore import QRect, QRectF, QTimer from cellacdc import widgets -from cellacdc.viewmodels.deleted_rois_viewmodel import DeletedRoisViewModel class DeletedRoisView: """Qt-facing adapter around deleted-ROI workflows.""" + """Headless decisions for deleted-ROI display and propagation.""" + + def roi_axis( + self, + *, + is_polyline: bool, + labels_image_visible: bool, + ) -> str: + if is_polyline or not labels_image_visible: + return 'left' + return 'right' + + def should_render_deleted_roi(self, annotation_mode: str) -> bool: + return 'nothing' not in annotation_mode + + def should_render_deleted_roi_contours(self, annotation_mode: str) -> bool: + return 'contours' in annotation_mode + + def should_render_deleted_roi_overlay(self, annotation_mode: str) -> bool: + return 'overlay segm. masks' in annotation_mode + + def should_initialize_overlay_masks( + self, + init: bool, + annotation_mode: str, + ) -> bool: + return init and not self.should_render_deleted_roi_contours( + annotation_mode + ) + + def labels_to_skip(self, deleted_ids: Iterable[int]) -> dict[int, bool]: + return {deleted_id: True for deleted_id in deleted_ids} + + LEGACY_METHODS = ( 'removeAlldelROIsCurrentFrame', 'removeDelROI', @@ -47,15 +81,13 @@ class DeletedRoisView: 'addExistingDelROIs', ) - def __init__(self, host, view_model: DeletedRoisViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -126,7 +158,7 @@ def removeDelROIFromFutureFrames(self, roi_to_del): delROIs_info['delIDsROI'].pop(idx) delROIs_info['state'].pop(idx) - target_axis = self.view_model.roi_axis( + target_axis = self.roi_axis( is_polyline=isinstance(self.roi_to_del, pg.PolyLineROI), labels_image_visible=self.labelsGrad.showLabelsImgAction.isChecked(), ) @@ -187,7 +219,7 @@ def updateDelROIinFutureFrames(self, roi: pg.ROI): def addDelROI(self, event): roi, key = self.createDelROI() self.addRoiToDelRoiInfo(roi) - target_axis = self.view_model.roi_axis( + target_axis = self.roi_axis( is_polyline=False, labels_image_visible=self.labelsGrad.showLabelsImgAction.isChecked(), ) @@ -376,15 +408,15 @@ def restoreDelROIimg1(self, delMaskID, delID, ax=0): else: how = self.getAnnotateHowRightImage() - if not self.view_model.should_render_deleted_roi(how): + if not self.should_render_deleted_roi(how): return - if self.view_model.should_render_deleted_roi_contours(how): + if self.should_render_deleted_roi_contours(how): rp_delmask = skimage.measure.regionprops(delMaskID.astype(np.uint8)) if len(rp_delmask) > 0: obj = rp_delmask[0] self.addObjContourToContoursImage(obj=obj, ax=ax) - elif self.view_model.should_render_deleted_roi_overlay(how): + elif self.should_render_deleted_roi_overlay(how): if ax == 0: self.labelsLayerImg1.setImage( self.currentLab2D, autoLevels=False @@ -538,7 +570,7 @@ def applyDelROIimg1(self, roi, init=False, ax=0): if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): return - if self.view_model.should_initialize_overlay_masks(init, how): + if self.should_initialize_overlay_masks(init, how): self.setOverlaySegmMasks(force=True) return @@ -554,15 +586,15 @@ def applyDelROIimg1(self, roi, init=False, ax=0): return delIDs = delROIs_info['delIDsROI'][idx] delMask = delROIs_info['delMasks'][idx] - if not self.view_model.should_render_deleted_roi(how): + if not self.should_render_deleted_roi(how): return - elif self.view_model.should_render_deleted_roi_contours(how): + elif self.should_render_deleted_roi_contours(how): self.updateContoursImage(ax=ax) if not delIDs: return - if self.view_model.should_render_deleted_roi_overlay(how): + if self.should_render_deleted_roi_overlay(how): lab = self.currentLab2D.copy() lab[delMask > 0] = 0 if ax == 0: @@ -571,7 +603,7 @@ def applyDelROIimg1(self, roi, init=False, ax=0): self.labelsLayerRightImg.setImage(lab, autoLevels=False) self.setAllTextAnnotations( - labelsToSkip=self.view_model.labels_to_skip(delIDs) + labelsToSkip=self.labels_to_skip(delIDs) ) def applyDelROIs(self): @@ -611,7 +643,7 @@ def addExistingDelROIs(self): posData = self.data[self.pos_i] delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] for r, roi in enumerate(delROIs_info['rois']): - target_axis = self.view_model.roi_axis( + target_axis = self.roi_axis( is_polyline=isinstance(roi, pg.PolyLineROI), labels_image_visible=( self.labelsGrad.showLabelsImgAction.isChecked() @@ -622,4 +654,4 @@ def addExistingDelROIs(self): else: self.ax2.addDelRoiItem(roi, roi.key) - self.setDelRoiState(roi, delROIs_info['state'][r]) + self.setDelRoiState(roi, delROIs_info['state'][r]) \ No newline at end of file diff --git a/cellacdc/views/display_decorations_view.py b/cellacdc/views/display_decorations_view.py index 27ff009ac..d7ed997aa 100644 --- a/cellacdc/views/display_decorations_view.py +++ b/cellacdc/views/display_decorations_view.py @@ -5,20 +5,58 @@ import numpy as np from cellacdc import apps, widgets -from cellacdc.viewmodels.display_decorations_viewmodel import ( - DisplayDecorationsViewModel, -) class DisplayDecorationsView: """Qt-facing adapter around display-decoration contracts.""" - def __init__(self, host, view_model: DisplayDecorationsViewModel): - self.host = host - self.view_model = view_model + """Headless display-decoration decision rules.""" + + def clamped_view_range(self, image_shape, view_range): + y_size, x_size = image_shape[:2] + x_range, y_range = view_range + x_min = 0 if x_range[0] < 0 else x_range[0] + y_min = 0 if y_range[0] < 0 else y_range[0] + x_max = x_size if x_range[1] >= x_size else x_range[1] + y_max = y_size if y_range[1] >= y_size else y_range[1] + return int(y_min), int(y_max), int(x_min), int(x_max) + + def integer_view_range(self, view_range): + x_range, y_range = view_range + return ( + [round(x_range[0]), round(x_range[1])], + [round(y_range[0]), round(y_range[1])], + ) + + def should_move_decoration( + self, + *, + dialog_open: bool, + move_with_zoom: bool, + ) -> bool: + return dialog_open or move_with_zoom + + def should_store_view_range( + self, + *, + has_range_reset_state: bool, + is_range_reset: bool = False, + ) -> bool: + return has_range_reset_state and is_range_reset + + def should_update_timestamp_frame( + self, + *, + has_timestamp: bool, + timestamp_enabled: bool, + ) -> bool: + return has_timestamp and timestamp_enabled + + def __init__(self, host): + self.host = host def get_view_range(self): - return self.view_model.clamped_view_range( + return self.clamped_view_range( self.host.img1.image.shape, self.host.ax1.viewRange(), ) @@ -27,7 +65,7 @@ def ax1_view_range(self, integers=False): view_range = self._ax1_raw_view_range() if not integers: return view_range - return self.view_model.integer_view_range(view_range) + return self.integer_view_range(view_range) def view_range_changed( self, @@ -43,7 +81,7 @@ def view_range_changed( ) else: scale_bar_move_with_zoom = False - if self.view_model.should_move_decoration( + if self.should_move_decoration( dialog_open=self.host.scaleBarDialog is not None, move_with_zoom=scale_bar_move_with_zoom, ): @@ -55,7 +93,7 @@ def view_range_changed( ) else: timestamp_move_with_zoom = False - if self.view_model.should_move_decoration( + if self.should_move_decoration( dialog_open=self.host.timestampDialog is not None, move_with_zoom=timestamp_move_with_zoom, ): @@ -64,7 +102,7 @@ def view_range_changed( self.host._viewRange = view_range def store_view_range(self): - if not self.view_model.should_store_view_range( + if not self.should_store_view_range( has_range_reset_state=hasattr(self.host, 'isRangeReset'), is_range_reset=getattr(self.host, 'isRangeReset', False), ): @@ -180,7 +218,7 @@ def edit_timestamp_properties(self, properties): self.host.timestampDialog.show() def update_timestamp_frame(self): - if not self.view_model.should_update_timestamp_frame( + if not self.should_update_timestamp_frame( has_timestamp=hasattr(self.host, 'timestamp'), timestamp_enabled=self.host.addTimestampAction.isChecked(), ): @@ -197,4 +235,4 @@ def _ax1_raw_view_range(self): ) if np.all(export_mask): return self.host.ax1.viewRange() - return self.host.ax1.viewRange(export_mask) + return self.host.ax1.viewRange(export_mask) \ No newline at end of file diff --git a/cellacdc/views/draw_clear_region_view.py b/cellacdc/views/draw_clear_region_view.py index 5739a1900..993a1c8b8 100644 --- a/cellacdc/views/draw_clear_region_view.py +++ b/cellacdc/views/draw_clear_region_view.py @@ -2,18 +2,60 @@ from __future__ import annotations -from cellacdc.viewmodels.draw_clear_region_viewmodel import ( - DrawClearRegionViewModel, -) +from dataclasses import dataclass class DrawClearRegionView: """Qt-facing adapter around the scriptable draw-clear view-model.""" - def __init__(self, host, view_model: DrawClearRegionViewModel): - self.host = host - self.view_model = view_model + """Headless draw-clear-region decision rules.""" + + single_z_slice_projection = 'single z-slice' + + def toolbar_state( + self, + *, + checked: bool, + is_segm_3d: bool, + size_z: int, + ) -> DrawClearRegionToolbarState: + if not is_segm_3d: + return DrawClearRegionToolbarState(update_z_control=True) + if not checked: + return DrawClearRegionToolbarState(update_z_control=False) + return DrawClearRegionToolbarState( + update_z_control=True, + z_control_enabled=True, + size_z=size_z, + ) + + def z_range_for_projection( + self, + *, + is_segm_3d: bool, + z_projection: str, + size_z: int, + single_z_range, + ): + if not is_segm_3d: + return None + if z_projection == self.single_z_slice_projection: + return single_z_range + return (0, size_z) + def is_single_z_projection(self, z_projection: str) -> bool: + return z_projection == self.single_z_slice_projection + + def empty_selection_warning(self, *, enclosed_only: bool) -> str: + if enclosed_only: + return ( + 'None of the objects in the freehand region are fully enclosed' + ) + return 'None of the objects are touching the freehand region' + + + def __init__(self, host): + self.host = host def toggle(self, checked): pos_data = self.host.data[self.host.pos_i] if checked: @@ -22,7 +64,7 @@ def toggle(self, checked): self.host.connectLeftClickButtons() self.host.drawClearRegionToolbar.setVisible(checked) - state = self.view_model.toolbar_state( + state = self.toolbar_state( checked=checked, is_segm_3d=self.host.isSegm3D, size_z=pos_data.SizeZ, @@ -63,7 +105,7 @@ def clear_objects_in_freehand_region(self): if not clear_ids: self.host.logger.warning( - self.view_model.empty_selection_warning( + self.empty_selection_warning( enclosed_only=enclosed_only ) ) @@ -79,13 +121,13 @@ def _z_range(self, size_z): single_z_range = None if self.host.isSegm3D: z_projection = self.host.zProjComboBox.currentText() - if self.view_model.is_single_z_projection(z_projection): + if self.is_single_z_projection(z_projection): single_z_range = self.host.drawClearRegionToolbar.zRange( self.host.z_lab(), size_z ) - return self.view_model.z_range_for_projection( + return self.z_range_for_projection( is_segm_3d=self.host.isSegm3D, z_projection=z_projection, size_z=size_z, single_z_range=single_z_range, - ) + ) \ No newline at end of file diff --git a/cellacdc/views/exporting_view.py b/cellacdc/views/exporting_view.py index 6d9dfcc96..f41f445d7 100644 --- a/cellacdc/views/exporting_view.py +++ b/cellacdc/views/exporting_view.py @@ -7,25 +7,28 @@ import traceback from functools import partial +import os +from dataclasses import dataclass +from datetime import datetime +import numpy as np +import skimage.measure +import skimage.segmentation from qtpy.QtCore import QTimer from cellacdc import _warnings, apps, disableWindow, exception_handler from cellacdc import exporters, html_utils, prompts, widgets -from cellacdc.viewmodels.exporting_viewmodel import ExportingViewModel class ExportingView: """Qt-facing adapter around export dialogs, exporters, and progress UI.""" - def __init__(self, host, view_model: ExportingViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -110,7 +113,7 @@ def updateAndExportFrame(self): @exception_handler def exportFrame(self): - plan = self.view_model.export_frame_plan( + plan = self.export_frame_plan( current_index=self.exportToVideoCurrentNavVarIdx, num_digits=self.exportToVideoPreferences['num_digits'], filename=self.exportToVideoPreferences['filename'], @@ -200,6 +203,95 @@ def exportToVideoAddTimestamp(self, checked): def askTimelapseOrZslicesVideo(self): txt = html_utils.paragraph(""" + + """Headless export naming, mask, and zoom selection rules.""" + + def timestamped_export_filename(self, kind: str, *, timestamp=None): + if timestamp is None: + timestamp = datetime.now() + return f"{timestamp.strftime('%Y%m%d_%H%M%S')}_acdc_exported_{kind}" + + def export_frame_plan( + self, + *, + current_index: int, + num_digits: int, + filename: str, + pngs_folderpath: str, + ) -> ExportFramePlan: + frame_index_text = str(current_index).zfill(num_digits) + png_filename = f'{frame_index_text}_{filename}.png' + return ExportFramePlan( + frame_index_text=frame_index_text, + png_filename=png_filename, + png_filepath=os.path.join(pngs_folderpath, png_filename), + ) + + def export_mask_image_shape(self, image_shape) -> tuple[int, int, int]: + height, width = image_shape[-2:] + return height, width, 4 + + def build_export_mask_image( + self, + image_shape, + view_range, + *, + invert_bw=False, + ): + mask_image = np.zeros( + self.export_mask_image_shape(image_shape), + dtype=np.uint8, + ) + x_range, y_range = view_range + x0, x1 = map(round, x_range) + y0, y1 = map(round, y_range) + + if invert_bw: + mask_image[:, :, :3] = 255 + + if x0 > 0: + mask_image[:, :x0, 3] = 255 + if x1 < mask_image.shape[1]: + mask_image[:, x1:, 3] = 255 + if y0 > 0: + mask_image[:y0, :, 3] = 255 + if y1 < mask_image.shape[0]: + mask_image[y1:, :, 3] = 255 + + return mask_image + + def zoom_ids(self, labels_2d, view_range): + height, width = labels_2d.shape + ((xmin, xmax), (ymin, ymax)) = view_range + if xmin <= 0 and ymin <= 0 and xmax >= width and ymax >= height: + return None + + xmin = max(xmin, 0) + ymin = max(ymin, 0) + xmax = min(xmax, width) + ymax = min(ymax, height) + + zoom_slice = ( + slice(round(ymin), round(ymax)), + slice(round(xmin), round(xmax)), + ) + zoom_labels = skimage.segmentation.clear_border(labels_2d[zoom_slice]) + zoom_regionprops = skimage.measure.regionprops(zoom_labels) + return [obj.label for obj in zoom_regionprops] + + def shifted_view_range(self, previous_range, current_range, window_range): + prev_x_range, prev_y_range = previous_range + curr_x_range, curr_y_range = current_range + win_x_range, win_y_range = window_range + + delta_x = curr_x_range[0] - prev_x_range[0] + delta_y = curr_y_range[0] - prev_y_range[0] + + return ( + (win_x_range[0] + delta_x, win_x_range[1] + delta_x), + (win_y_range[0] + delta_y, win_y_range[1] + delta_y), + ) + Do you want to record a video of scrolling through the z-slices or a Timelapse video? """) @@ -226,7 +318,7 @@ def exportToVideoTriggered(self): channels = [self.user_ch_name, *self.checkedOverlayChannels] mode = 'timelapse' if doTimelapseVideo else 'z_slices' - filename = self.view_model.timestamped_export_filename( + filename = self.timestamped_export_filename( f'{mode}_video' ) win = apps.ExportToVideoParametersDialog( @@ -261,7 +353,7 @@ def initExportMaskImage(self): posData = self.data[self.pos_i] z_slice = self.z_lab() img = posData.img_data[posData.frame_i] - self.exportMaskImage = self.view_model.build_export_mask_image( + self.exportMaskImage = self.build_export_mask_image( img[z_slice].shape, self.ax1.viewRange(), invert_bw=False, @@ -271,7 +363,7 @@ def setExportMaskImage(self, viewRange): if not hasattr(self, 'exportMaskImage'): self.initExportMaskImage() - self.exportMaskImage[:] = self.view_model.build_export_mask_image( + self.exportMaskImage[:] = self.build_export_mask_image( self.exportMaskImage.shape[:2], viewRange, invert_bw=self.invertBwAction.isChecked(), @@ -300,7 +392,7 @@ def updateViewRangeExportToImage(self, viewRange): # prevViewRange = self.exportToImageWindow.viewRange() prevViewRange = self._viewRange winViewRange = self.exportToImageWindow.viewRange() - x_range, y_range = self.view_model.shifted_view_range( + x_range, y_range = self.shifted_view_range( prevViewRange, viewRange, winViewRange, @@ -314,7 +406,7 @@ def getZoomIDs(self, viewRange=None): if viewRange is None: viewRange = self.ax1.viewRange() - return self.view_model.zoom_ids(self.currentLab2D, viewRange) + return self.zoom_ids(self.currentLab2D, viewRange) def onSigUpdateCcaTableWindow(self, *args): if not self.isDataLoaded: @@ -348,7 +440,7 @@ def exportToImage(self, preferences): def exportToImageTriggered(self): posData = self.data[self.pos_i] - filename = self.view_model.timestamped_export_filename('image') + filename = self.timestamped_export_filename('image') win = apps.ExportToImageParametersDialog( parent=self.host, startFolderpath=posData.pos_path, @@ -384,4 +476,4 @@ def exportToImageTriggered(self): self.exportToImageWindow = None if not isTransparent: - self.overlayToolbar.setTransparent(False) + self.overlayToolbar.setTransparent(False) \ No newline at end of file diff --git a/cellacdc/views/frame_navigation_view.py b/cellacdc/views/frame_navigation_view.py index af0354f32..4ee17ea1c 100644 --- a/cellacdc/views/frame_navigation_view.py +++ b/cellacdc/views/frame_navigation_view.py @@ -6,13 +6,11 @@ from functools import partial import numpy as np +from dataclasses import dataclass from qtpy.QtCore import QTimer from qtpy.QtWidgets import QAbstractSlider, QCheckBox from cellacdc import QtScoped, apps, exception_handler, html_utils, printl, widgets -from cellacdc.viewmodels.frame_navigation_viewmodel import ( - FrameNavigationViewModel, -) SliderSingleStepAdd = QtScoped.SliderSingleStepAdd() @@ -80,15 +78,13 @@ class FrameNavigationView: 'setZprojDisabled', ) - def __init__(self, host, view_model: FrameNavigationViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -103,7 +99,7 @@ def goToZsliceSearchedID(self, obj): current_z = self.z_lab() nearest_nonzero_z = ( - self.view_model.nearest_nonzero_z_from_centroid( + self.nearest_nonzero_z_from_centroid( obj, current_z=current_z ) ) @@ -117,7 +113,7 @@ def goToZsliceSearchedID(self, obj): def isNavigateActionOnNextFrame(self): posData = self.data[self.pos_i] ax1_coords = self.status_hover_view.mouse_data_coords_right_image() - return self.view_model.should_show_next_frame_image( + return self.should_show_next_frame_image( size_t=posData.SizeT, has_right_image_coords=ax1_coords is not None, action_enabled=self.labelsGrad.showNextFrameAction.isEnabled(), @@ -135,7 +131,7 @@ def nextFrameImage(self, current_frame_i=None): if current_frame_i is None: current_frame_i = posData.frame_i - next_frame_i = self.view_model.next_frame_index( + next_frame_i = self.next_frame_index( current_frame_i=current_frame_i, frames_count=len(posData.img_data), ) @@ -225,7 +221,7 @@ def updateScrollbars(self): self.updateItemsMousePos() self.updateFramePosLabel() posData = self.data[self.pos_i] - navPos = self.view_model.navigation_position( + navPos = self.navigation_position( is_snapshot=self.isSnapshot, position_i=self.pos_i, frame_i=posData.frame_i, @@ -259,7 +255,7 @@ def setNavigateScrollBarMaximum(self): lineage_frames = None if self.lineage_tree is not None: lineage_frames = self.lineage_tree.frames_for_dfs - limit = self.view_model.navigation_limit( + limit = self.navigation_limit( mode=mode, frame_i=posData.frame_i, last_tracked_i=posData.last_tracked_i, @@ -278,6 +274,156 @@ def setNavigateScrollBarMaximum(self): def setFrameNavigationDisabled(self, disable: bool, why: str): """Disables the frame navigation buttons and scrollbar. + + """Headless decisions for frame/position navigation workflows.""" + + viewer_mode = 'Viewer' + segmentation_mode = 'Segmentation and Tracking' + cell_cycle_mode = 'Cell cycle analysis' + lineage_mode = 'Normal division: Lineage tree' + + def should_show_next_frame_image( + self, + *, + size_t: int, + has_right_image_coords: bool, + action_enabled: bool, + action_checked: bool, + ) -> bool: + return ( + size_t > 1 + and has_right_image_coords + and action_enabled + and action_checked + ) + + def next_frame_index(self, *, current_frame_i: int, frames_count: int) -> int: + next_frame_i = current_frame_i + 1 + if next_frame_i >= frames_count: + return frames_count - 1 + return next_frame_i + + def navigation_position( + self, + *, + is_snapshot: bool, + position_i: int, + frame_i: int, + ) -> int: + return position_i + 1 if is_snapshot else frame_i + 1 + + def navigation_limit( + self, + *, + mode: str, + frame_i: int, + last_tracked_i: int | None, + last_cca_frame_i: int, + lineage_tree_frames, + ) -> NavigationLimit | None: + if mode == self.segmentation_mode: + if last_tracked_i is None or frame_i > last_tracked_i: + maximum = frame_i + 1 + else: + maximum = last_tracked_i + 1 + return NavigationLimit( + maximum=maximum, + last_checked_frame_i=maximum - 1, + ) + if mode == self.cell_cycle_mode: + maximum = max(frame_i, last_cca_frame_i) + 1 + return NavigationLimit( + maximum=maximum, + status_text=f'Last cc annot. frame n. = {maximum}', + ) + if mode == self.lineage_mode: + if lineage_tree_frames: + maximum = max(lineage_tree_frames) + 1 + else: + maximum = frame_i + 1 + return NavigationLimit(maximum=maximum) + return None + + def should_store_when_slider_moves(self, *, mode: str) -> bool: + return mode != self.viewer_mode + + def should_warn_lost_objects( + self, + *, + requested: bool, + action_checked: bool, + mode: str, + lost_ids, + already_accepted: bool, + ) -> bool: + if not requested: + return False + if not action_checked: + return False + if mode != self.segmentation_mode: + return False + if not lost_ids: + return False + return not already_accepted + + def blocks_future_manual_annotation( + self, + *, + manual_annotation_enabled: bool, + current_frame_i: int, + frame_to_restore, + ) -> bool: + if not manual_annotation_enabled: + return False + if frame_to_restore is None: + return False + return current_frame_i > frame_to_restore + + def should_apply_new_frame_tools( + self, + *, + mode: str, + last_tracked_i: int, + frame_i: int, + last_frame_ran: int, + ) -> bool: + return ( + mode == self.segmentation_mode + and last_tracked_i is not None + and last_tracked_i <= frame_i + and frame_i != last_frame_ran + ) + + def is_single_z_slice_projection(self, how: str) -> bool: + return how == 'single z-slice' + + def should_disable_overlay_z_slice(self, how: str) -> bool: + return how.find('max') != -1 or how == 'same as above' + + def projection_frame_indices( + self, + *, + filename, + frame_i: int, + size_t: int, + locked: bool, + ) -> list[tuple[object, int]]: + if locked: + return [(filename, i) for i in range(size_t)] + return [(filename, frame_i)] + + def z_slice_frame_indices( + self, + *, + filename, + frame_i: int, + size_t: int, + locked: bool, + ) -> list[tuple[object, int]]: + if locked: + return [(filename, i) for i in range(size_t)] + return [(filename, i) for i in range(frame_i, size_t)] + This is used when the user is not allowed to navigate through frames Call again to unlock it again. Also sets tooltips to inform the user @@ -393,7 +539,7 @@ def framesScrollBarActionTriggered(self, action): def framesScrollBarMoved(self, frame_n): if self.navigateScrollBarStartedMoving: mode = str(self.modeComboBox.currentText()) - if self.view_model.should_store_when_slider_moves(mode=mode): + if self.should_store_when_slider_moves(mode=mode): self.store_data(debug=False) posData = self.data[self.pos_i] @@ -431,7 +577,7 @@ def framesScrollBarReleased(self, do_store_data=False): mode = str(self.modeComboBox.currentText()) if ( - self.view_model.should_store_when_slider_moves(mode=mode) + self.should_store_when_slider_moves(mode=mode) and do_store_data ): self.store_data(debug=False) @@ -518,7 +664,7 @@ def warnLostObjects(self, do_warn=True): except AttributeError as err: already_accepted_lost = False - should_warn = self.view_model.should_warn_lost_objects( + should_warn = self.should_warn_lost_objects( requested=do_warn, action_checked=self.warnLostCellsAction.isChecked(), mode=mode, @@ -599,7 +745,7 @@ def extendSegmDataIfNeeded(self, stopFrameNum): return numFramesToAdd = stopFrameNum - segmSizeT posData.allData_li.extend( - self.view_model.empty_frame_records(numFramesToAdd) + self.empty_frame_records(numFramesToAdd) ) lab_shape = posData.segm_data[0].shape shapeToAdd = (numFramesToAdd, *lab_shape) @@ -638,7 +784,7 @@ def reInitLastSegmFrame( posData.segm_data[i] = posData.allData_li[i]['labels'] posData.allData_li[i] = ( - self.view_model.empty_frame_record() + self.empty_frame_record() ) posData.tracked_lost_centroids[i] = set() @@ -720,7 +866,7 @@ def askInitLinTreeFirstFrame(self): def checkIfFutureFrameManualAnnotPastFrames(self): posData = self.data[self.pos_i] frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - blocked = self.view_model.blocks_future_manual_annotation( + blocked = self.blocks_future_manual_annotation( manual_annotation_enabled=self.manualAnnotPastButton.isChecked(), current_frame_i=posData.frame_i, frame_to_restore=frame_to_restore, @@ -822,7 +968,7 @@ def next_frame(self, warn=True): def apply_tools_on_new_frame(self): mode = str(self.modeComboBox.currentText()) posData = self.data[self.pos_i] - should_apply = self.view_model.should_apply_new_frame_tools( + should_apply = self.should_apply_new_frame_tools( mode=mode, last_tracked_i=posData.last_tracked_i, frame_i=posData.frame_i, @@ -855,7 +1001,7 @@ def manualAnnotRestoreLastTrackedFrame(self, last_tracked_i_to_restore): posData = self.data[self.pos_i] for frame_i in range(last_tracked_i_to_restore+1, posData.SizeT): - data_frame_i = self.view_model.empty_frame_record() + data_frame_i = self.empty_frame_record() data_frame_i['manually_edited_lab'] = ( posData.allData_li[frame_i]['manually_edited_lab'] @@ -1129,7 +1275,7 @@ def onZsliceSpinboxValueChange(self, value): def update_z_slice(self, z): posData = self.data[self.pos_i] if self.switchPlaneCombobox.depthAxes() == 'z': - idx = self.view_model.z_slice_frame_indices( + idx = self.z_slice_frame_indices( filename=posData.filename, frame_i=posData.frame_i, size_t=posData.SizeT, @@ -1153,7 +1299,7 @@ def updateOverlayZslice(self, z): self.setOverlayImages() def updateOverlayZproj(self, how): - if self.view_model.should_disable_overlay_z_slice(how): + if self.should_disable_overlay_z_slice(how): self.overlay_z_label.setDisabled(True) self.zSliceOverlay_SB.setDisabled(True) else: @@ -1163,7 +1309,7 @@ def updateOverlayZproj(self, how): def updateZproj(self, how): for p, posData in enumerate(self.data[self.pos_i:]): - idx = self.view_model.projection_frame_indices( + idx = self.projection_frame_indices( filename=posData.filename, frame_i=posData.frame_i, size_t=posData.SizeT, @@ -1173,7 +1319,7 @@ def updateZproj(self, how): posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) posData = self.data[self.pos_i] - if self.view_model.is_single_z_slice_projection(how): + if self.is_single_z_slice_projection(how): self.zSliceScrollBar.setDisabled(False) self.zSliceSpinbox.setDisabled(False) self.zSliceCheckbox.setDisabled(False) @@ -1211,4 +1357,4 @@ def setZprojDisabled(self, disabled, storePrevState=False): try: button.setChecked(False) except Exception as err: - pass + pass \ No newline at end of file diff --git a/cellacdc/views/graphics_view.py b/cellacdc/views/graphics_view.py index 935cacd50..315dd75a2 100644 --- a/cellacdc/views/graphics_view.py +++ b/cellacdc/views/graphics_view.py @@ -9,6 +9,9 @@ import matplotlib import numpy as np import pyqtgraph as pg +from dataclasses import dataclass +from collections.abc import Iterable, Mapping +import numpy as np import skimage.exposure import skimage.measure from natsort import natsorted @@ -27,7 +30,6 @@ widgets, workers, ) -from cellacdc.viewmodels.graphics_viewmodel import GraphicsViewModel _font = QFont() _font.setPixelSize(11) @@ -170,15 +172,13 @@ class GraphicsView: 'gui_initImg1BottomWidgets', ) - def __init__(self, host, view_model: GraphicsViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -942,6 +942,165 @@ def segmNdimIndicatorClicked(self): toggleText = 'de-activate' msg = widgets.myMessageBox(wrapText=False) important_txt = (""" + + """Headless graphics workflow rules.""" + + def overlay_toolbutton_checked( + self, + channel: str, + *, + checked_channels: Iterable[str], + is_single_channel: bool, + ) -> bool: + return not is_single_channel and channel in set(checked_channels) + + def overlay_toolbutton_click_checked_channels( + self, + *, + clicked_channel: str, + all_channels: Iterable[str], + checked_channels: Iterable[str], + toolbar_single_channel: bool, + ) -> set[str]: + all_channels = set(all_channels) + checked_channels = set(checked_channels) + if not checked_channels or toolbar_single_channel: + checked_channels.add(clicked_channel) + + if toolbar_single_channel: + return {clicked_channel} + + return checked_channels & all_channels + + def overlay_visibility_plan( + self, + *, + all_channels: Iterable[str], + checked_channels: Iterable[str], + overlay_enabled: bool, + ) -> OverlayVisibilityPlan: + checked_channels = set(checked_channels) + return OverlayVisibilityPlan( + channel_visible={ + channel: overlay_enabled and channel in checked_channels + for channel in all_channels + } + ) + + def overlay_channel_opacity_map( + self, + base_channel: str, + active_channel_alpha_values: Mapping[str, float], + ) -> dict[str, float]: + channels = list(active_channel_alpha_values) + alpha_values = list(active_channel_alpha_values.values()) + opacities = self._base_first_hierarchical_opacities(alpha_values) + channel_opacity_mapper = { + channel: opacities[i + 1] + for i, channel in enumerate(channels) + } + channel_opacity_mapper[base_channel] = opacities[0] + return channel_opacity_mapper + + def overlay_item_opacity_plan( + self, + *, + all_channels: Iterable[str], + base_channel: str, + checked_channels: Iterable[str], + toolbar_single_channel: bool, + active_channel_alpha_values: Mapping[str, float], + ) -> OverlayOpacityPlan: + checked_channels = set(checked_channels) + channel_opacity_mapper = self.overlay_channel_opacity_map( + base_channel, + active_channel_alpha_values, + ) + is_single_channel = toolbar_single_channel or len(checked_channels) == 1 + + opacities = {} + alpha_scrollbar_disabled = {} + for channel in all_channels: + if channel in checked_channels and is_single_channel: + op_val = 1.0 + elif channel in checked_channels: + op_val = channel_opacity_mapper[channel] + else: + op_val = 0.0 + + if op_val == 0: + op_val = 0.01 + + opacities[channel] = min(op_val, 0.999) + if channel != base_channel: + alpha_scrollbar_disabled[channel] = op_val == 0 + + return OverlayOpacityPlan( + opacities=opacities, + alpha_scrollbar_disabled=alpha_scrollbar_disabled, + ) + + def _base_first_hierarchical_opacities( + self, + alpha_values: Iterable[float], + ) -> list[float]: + alphas = [1.0, *alpha_values] + if not alphas: + return alphas + + weights = [] + for i, alpha_ref in enumerate(alphas): + weight = alpha_ref + for alpha in alphas[i + 1:]: + weight *= 1 - alpha + weights.append(weight) + + return weights + + def generate_labels_image_lut(self, base_lut: np.ndarray) -> np.ndarray: + """Converts a 3-channel base LUT to a 4-channel RGBA LUT with background as transparent.""" + import numpy as np + lut = np.zeros((len(base_lut), 4), dtype=np.uint8) + lut[:, -1] = 255 + lut[:, :-1] = base_lut + lut[0] = [0, 0, 0, 0] + return lut + + def extend_labels_lut(self, base_lut: np.ndarray, len_new_lut: int) -> np.ndarray: + """Extends base_lut to include IDs greater than original length of base_lut.""" + import numpy as np + if len_new_lut <= len(base_lut): + return base_lut + + num_new_colors = len_new_lut - len(base_lut) + _lut = np.zeros((len_new_lut, 3), np.uint8) + _lut[:len(base_lut)] = base_lut + + random_idx = np.random.randint(0, len(base_lut), size=num_new_colors) + for i, idx in enumerate(random_idx): + rgb = base_lut[idx] + _lut[len(base_lut) + i] = rgb + return _lut + + def apply_lut_dimming_for_kept_objects( + self, + lut: np.ndarray, + kept_object_ids: list[int], + keep_ids_enabled: bool, + ) -> np.ndarray: + """Applies dimming to non-kept objects in the LUT if keep_ids is enabled.""" + import numpy as np + if not keep_ids_enabled: + return lut + + dimmed_lut = np.round(lut * 0.3).astype(np.uint8) + valid_ids = [idx for idx in kept_object_ids if idx < len(lut)] + if valid_ids: + kept_lut = np.round(dimmed_lut[valid_ids] / 0.3).astype(np.uint8) + dimmed_lut[valid_ids] = kept_lut + return dimmed_lut + + The toggle to activate 3D segmentation is visible only when the Number of z-slices is greater than 1. """) @@ -1335,7 +1494,7 @@ def loadOverlayLabelsData(self, segmEndname, pos_i=None): if segmEndname == 'combined segm.': posData.ol_labels_data['combined segm.'] = posData.combine_img_data return - filePath, filename = self.view_model.workspace.path_from_endname( + filePath, filename = self.workspace.path_from_endname( segmEndname, posData.images_path ) self.logger.info(f'Loading "{segmEndname}.npz"...') @@ -1566,7 +1725,7 @@ def setOverlayImages(self, frame_i=None): rgba_imgs_info = {} for filename in posData.ol_data: - chName = self.view_model.formatting.channel_name_from_basename( + chName = self.formatting.channel_name_from_basename( filename, posData.basename, remove_ext=False ) if chName not in self.checkedOverlayChannels: @@ -1636,7 +1795,7 @@ def getOpacitiesFromAlphaScrollbarValues(self): alphaSB.value()/alphaSB.maximum() ) - return self.view_model.overlay_channel_opacity_map( + return self.overlay_channel_opacity_map( self.user_ch_name, active_channel_alpha_values, ) @@ -1679,7 +1838,7 @@ def updateLabelsCmap(self, gradient): def extendLabelsLUT(self, lenNewLut): if lenNewLut > len(self.lut): - self.lut = self.view_model.extend_labels_lut(self.lut, lenNewLut) + self.lut = self.extend_labels_lut(self.lut, lenNewLut) self.initLabelsImageItems() return True return False @@ -1690,7 +1849,7 @@ def initLookupTableLab(self): self.initLabelsImageItems() def getLabelsImageLut(self): - return self.view_model.generate_labels_image_lut(self.lut) + return self.generate_labels_image_lut(self.lut) def initLabelsImageItems(self): lut = self.getLabelsImageLut() @@ -1735,7 +1894,7 @@ def updateLookuptable(self, lenNewLut=None, delIDs=None): if updateLevels: self.img2.setLevels([0, len(lut)]) - lut = self.view_model.apply_lut_dimming_for_kept_objects( + lut = self.apply_lut_dimming_for_kept_objects( lut, getattr(self, 'keptObjectsIDs', []), self.keepIDsButton.isChecked(), @@ -1906,7 +2065,7 @@ def setOverlayChannelsToolbuttonsChecked(self): for channel, items in self.overlayLayersItems.items(): _, lutItem, alphaSB, toolbutton = items[:4] toolbutton.setChecked( - self.view_model.overlay_toolbutton_checked( + self.overlay_toolbutton_checked( channel, checked_channels=self.checkedOverlayChannels, is_single_channel=self.overlayToolbar.isSingleChannel(), @@ -1914,7 +2073,7 @@ def setOverlayChannelsToolbuttonsChecked(self): ) def setOverlayItemsVisible(self): - visibility_plan = self.view_model.overlay_visibility_plan( + visibility_plan = self.overlay_visibility_plan( all_channels=self.overlayLayersItems.keys(), checked_channels=self.checkedOverlayChannels, overlay_enabled=self.overlayButton.isChecked(), @@ -1943,7 +2102,7 @@ def overlayChannelToolbuttonClicked(self, checked=False, toolbutton=None): if button.isChecked() } planned_checked_channels = ( - self.view_model.overlay_toolbutton_click_checked_channels( + self.overlay_toolbutton_click_checked_channels( clicked_channel=channelName, all_channels=self.allOverlayToolbuttons.keys(), checked_channels=checked_channels, @@ -1974,7 +2133,7 @@ def setOverlayItemsOpacities(self): active_channel_alpha_values[imageItem.channelName] = ( alphaSB.value()/alphaSB.maximum() ) - opacity_plan = self.view_model.overlay_item_opacity_plan( + opacity_plan = self.overlay_item_opacity_plan( all_channels=self.allOverlayToolbuttons.keys(), base_channel=self.user_ch_name, checked_channels=checked_channels, @@ -2079,7 +2238,7 @@ def _gui_createGraphicsItems(self): posData = self.data[self.pos_i] - allIDs, posData = self.view_model.label_edits.count_objects( + allIDs, posData = self.label_edits.count_objects( posData, self.logger.info ) @@ -2377,7 +2536,7 @@ def getNearestLostObjID(self, y, x): except Exception as err: pass - _, y_nearest, x_nearest = self.view_model.label_edits.nearest_nonzero_2d( + _, y_nearest, x_nearest = self.label_edits.nearest_nonzero_2d( lostObjsContourMask, y, x, return_coords=True ) nearest_ID = self.get_2Dlab(prev_lab)[y_nearest, x_nearest] @@ -2458,7 +2617,7 @@ def getObjContours( obj_image = self.getObjImage(obj.image, obj.bbox).astype(np.uint8) obj_bbox = self.getObjBbox(obj.bbox) try: - contours = self.view_model.geometry.object_contours( + contours = self.geometry.object_contours( obj_image=obj_image, obj_bbox=obj_bbox, local=local, @@ -2489,7 +2648,7 @@ def _computeAllContours2D( all_external = False local = False - contours = self.view_model.geometry.object_contours( + contours = self.geometry.object_contours( obj_image=obj_image, obj_bbox=obj_bbox, local=local, @@ -2500,7 +2659,7 @@ def _computeAllContours2D( all_external = True local = False - contours = self.view_model.geometry.object_contours( + contours = self.geometry.object_contours( obj_image=obj_image, obj_bbox=obj_bbox, local=local, @@ -2614,7 +2773,7 @@ def _computeAllObjToObjCostPairs(self, posData): break prev_rp = posData.allData_li[frame_i-1]['regionprops'] - dist_matrix = self.view_model.geometry.object_to_object_contour_distance_matrix( + dist_matrix = self.geometry.object_to_object_contour_distance_matrix( dataDict['contours'], rp, previous_regionprops=prev_rp, restrict_search=True, @@ -2905,4 +3064,4 @@ def gui_initImg1BottomWidgets(self): self.overlay_z_label.hide() self.zSliceCheckbox.hide() self.zSliceSpinbox.hide() - self.SizeZlabel.hide() + self.SizeZlabel.hide() \ No newline at end of file diff --git a/cellacdc/views/image_controls_view.py b/cellacdc/views/image_controls_view.py index 1323d5826..17e6a8fcc 100644 --- a/cellacdc/views/image_controls_view.py +++ b/cellacdc/views/image_controls_view.py @@ -18,7 +18,6 @@ ) from cellacdc import widgets -from cellacdc.viewmodels.image_controls_viewmodel import ImageControlsViewModel _font = QFont() _font.setPixelSize(11) @@ -27,15 +26,53 @@ class ImageControlsView: """Qt-facing adapter around image-control defaults and widgets.""" - def __init__(self, host, view_model: ImageControlsViewModel): + """Headless defaults for image-control UI construction.""" + + draw_ids_cont_combo_items = ( + 'Draw IDs and contours', + 'Draw IDs and overlay segm. masks', + 'Draw only cell cycle info', + 'Draw cell cycle info and contours', + 'Draw cell cycle info and overlay segm. masks', + 'Draw only mother-bud lines', + 'Draw only IDs', + 'Draw only contours', + 'Draw only overlay segm. masks', + 'Draw nothing', + ) + z_projection_options = ( + 'single z-slice', + 'max z-projection', + 'mean z-projection', + 'median z-proj.', + ) + overlay_z_projection_options = ( + 'single z-slice', + 'max z-projection', + 'mean z-projection', + 'median z-proj.', + 'same as above', + ) + bottom_layout_zoom_values = tuple(range(50, 151, 10)) + + def bottom_layout_zoom_percent(self, df_settings) -> int: + if 'bottom_sliders_zoom_perc' not in df_settings.index: + return 100 + return int(df_settings.at['bottom_sliders_zoom_perc', 'value']) + + def retain_space_hidden_sliders(self, df_settings) -> bool: + if 'retain_space_hidden_sliders' not in df_settings.index: + return True + return df_settings.at['retain_space_hidden_sliders', 'value'] == 'Yes' + + + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -43,7 +80,7 @@ def __setattr__(self, name, value): def gui_createImg1Widgets(self): # Toggle contours/ID combobox self.drawIDsContComboBoxSegmItems = list( - self.view_model.draw_ids_cont_combo_items() + self.draw_ids_cont_combo_items() ) self.drawIDsContComboBox = widgets.ComboBox() self.drawIDsContComboBox.setFont(_font) @@ -170,7 +207,7 @@ def gui_createImg1Widgets(self): self.zProjComboBox = widgets.ComboBox() self.zProjComboBox.setFont(_font) - self.zProjComboBox.addItems(self.view_model.z_projection_options()) + self.zProjComboBox.addItems(self.z_projection_options()) self.zProjLockViewButton = widgets.LockPushButton() self.zProjLockViewButton.setCheckable(True) self.zProjLockViewButton.setToolTip( @@ -192,7 +229,7 @@ def gui_createImg1Widgets(self): self.zProjOverlay_CB = widgets.ComboBox() self.zProjOverlay_CB.setFont(_font) self.zProjOverlay_CB.addItems( - self.view_model.overlay_z_projection_options() + self.overlay_z_projection_options() ) self.zProjOverlay_CB.setCurrentIndex(4) self.zSliceOverlay_SB.setDisabled(True) @@ -363,14 +400,14 @@ def gui_createBottomWidgetsToBottomLayout(self): bottomScrollArea.setWidget(bottomWidget) self.bottomScrollArea = bottomScrollArea - zoom_perc = self.view_model.bottom_layout_zoom_percent( + zoom_perc = self.bottom_layout_zoom_percent( self.df_settings ) self.bottomLayoutContextMenu = QMenu('Bottom layout', self) zoomMenu = self.bottomLayoutContextMenu.addMenu('Zoom') actions = [] self.bottomLayoutContextMenu.zoomActionGroup = QActionGroup(zoomMenu) - for perc in self.view_model.bottom_layout_zoom_values(): + for perc in self.bottom_layout_zoom_values(): action = QAction(f'{perc}%', zoomMenu) action.setCheckable(True) if perc == zoom_perc: @@ -387,7 +424,7 @@ def gui_createBottomWidgetsToBottomLayout(self): 'Retain space of hidden sliders' ) retainSpaceAction.setCheckable(True) - retainSpaceChecked = self.view_model.retain_space_hidden_sliders( + retainSpaceChecked = self.retain_space_hidden_sliders( self.df_settings ) retainSpaceAction.setChecked(retainSpaceChecked) @@ -410,4 +447,4 @@ def gui_createGraphicsPlots(self): self.ax2 = parts['ax2'] self.lutItemsLayout = parts['lutItemsLayout'] self.titleColor = parts['titleColor'] - self.plotsCol = 1 + self.plotsCol = 1 \ No newline at end of file diff --git a/cellacdc/views/image_display_view.py b/cellacdc/views/image_display_view.py index 44618fe5a..af6cb9262 100644 --- a/cellacdc/views/image_display_view.py +++ b/cellacdc/views/image_display_view.py @@ -6,6 +6,8 @@ import numpy as np import pyqtgraph as pg +from dataclasses import dataclass +from typing import Literal import skimage.exposure import skimage.measure from qtpy.QtCore import QTimer @@ -20,12 +22,60 @@ myutils, settings_csv_path, ) -from cellacdc.viewmodels.image_display_viewmodel import ImageDisplayViewModel class ImageDisplayView: """Qt-facing adapter for image display, LUT, and cursor workflows.""" + """Headless display settings and image-display rules.""" + + def right_pane_visibility_plan( + self, + mode: RightPaneMode, + checked: bool, + ) -> RightPaneVisibilityPlan: + settings_updates = { + 'isNextFrameVisible': 'No', + 'isRightImageVisible': 'No', + 'isLabelsVisible': 'No', + } + if checked: + setting_key = { + 'next_frame': 'isNextFrameVisible', + 'right_image': 'isRightImageVisible', + 'labels': 'isLabelsVisible', + }[mode] + settings_updates[setting_key] = 'Yes' + + return RightPaneVisibilityPlan( + mode=mode, + checked=checked, + settings_updates=settings_updates, + ) + + def invert_bw_setting_value(self, checked: bool) -> str: + return 'Yes' if checked else 'No' + + def labels_alpha_plan( + self, + value: float, + *, + keep_ids_checked: bool, + ) -> LabelsAlphaPlan: + opacity = value / 3 if keep_ids_checked else value + return LabelsAlphaPlan(setting_value=value, opacity=opacity) + + def intensity_normalization_setting_value(self, how: str) -> str: + return how + + def rescale_intensity_setting_update( + self, + channel: str, + how: str, + ) -> tuple[str, str]: + return f'how_rescale_intensities_{channel}', how + + LEGACY_METHODS = ( 'getDisplayedImg1', 'getDisplayedZstack', @@ -107,15 +157,13 @@ class ImageDisplayView: 'resetRange', ) - def __init__(self, host, view_model: ImageDisplayViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -316,7 +364,7 @@ def equalizeHist(self, checked=True): for pos_i, _posData in enumerate(self.data): n_dim_img = _posData.img_data.ndim _posData.equalized_img_data = ( - self.view_model.preprocessing.create_preprocessed_data() + self.preprocessing.create_preprocessed_data() ) for frame_i, img_frame in enumerate(_posData.img_data): if n_dim_img == 4: @@ -339,10 +387,10 @@ def equalizeHist(self, checked=True): self.updateAllImages() def getDistantGray(self, desiredGray, bkgrGray): - return self.view_model.formatting.distant_gray(desiredGray, bkgrGray) + return self.formatting.distant_gray(desiredGray, bkgrGray) def RGBtoGray(self, R, G, B): - return self.view_model.formatting.rgb_to_gray(R, G, B) + return self.formatting.rgb_to_gray(R, G, B) def ruler_cb(self, checked): if checked: @@ -393,7 +441,7 @@ def setTwoImagesLayout(self, isTwoImages): pass def showNextFrameImageItem(self, checked): - plan = self.view_model.right_pane_visibility_plan( + plan = self.right_pane_visibility_plan( 'next_frame', checked ) self.rightImageFramesScrollbar.setVisible(checked) @@ -426,7 +474,7 @@ def showNextFrameImageItem(self, checked): self.setBottomLayoutStretch() def showRightImageItem(self, checked): - plan = self.view_model.right_pane_visibility_plan( + plan = self.right_pane_visibility_plan( 'right_image', checked ) self.rightImageFramesScrollbar.setVisible(not checked) @@ -457,7 +505,7 @@ def showRightImageItem(self, checked): self.setBottomLayoutStretch() def showLabelImageItem(self, checked): - plan = self.view_model.right_pane_visibility_plan('labels', checked) + plan = self.right_pane_visibility_plan('labels', checked) self.rightImageFramesScrollbar.setVisible(not checked) self.rightImageFramesScrollbar.setDisabled(checked) self.setTwoImagesLayout(checked) @@ -543,7 +591,7 @@ def normalizeIntensities(self, img): if not normalize: return img - return self.view_model.preprocessing.normalize_display_image(img, how) + return self.preprocessing.normalize_display_image(img, how) def invertBw(self, checked, update=True): self.invertBwAlreadyCalledOnce = True @@ -581,7 +629,7 @@ def invertBw(self, checked, update=True): self.slideshowWin.is_bw_inverted = checked self.slideshowWin.update_img() self.df_settings.at['is_bw_inverted', 'value'] = ( - self.view_model.invert_bw_setting_value(checked) + self.invert_bw_setting_value(checked) ) self.df_settings.to_csv(self.settings_csv_path) if checked: @@ -627,7 +675,7 @@ def updateImageValueFormatter(self): dtype = self.img1.image.dtype n_digits = len(str(int(self.img1.image.max()))) self.imgValueFormatter = ( - self.view_model.formatting.number_fstring_formatter( + self.formatting.number_fstring_formatter( dtype, precision=abs(n_digits-5) ) ) @@ -636,7 +684,7 @@ def updateImageValueFormatter(self): dtype = rawImgData.dtype n_digits = len(str(int(rawImgData.max()))) self.rawValueFormatter = ( - self.view_model.formatting.number_fstring_formatter( + self.formatting.number_fstring_formatter( dtype, precision=abs(n_digits-5) ) ) @@ -841,7 +889,7 @@ def getImage(self, frame_i=None, raw=False): return self.getRawImageLayer0(frame_i) def updateLabelsAlpha(self, value): - plan = self.view_model.labels_alpha_plan( + plan = self.labels_alpha_plan( value, keep_ids_checked=self.keepIDsButton.isChecked(), ) @@ -998,7 +1046,7 @@ def getLostTrackedObjImageItem(self, ax): def normaliseIntensitiesActionTriggered(self, action): how = action.text() self.df_settings.at['how_normIntensities', 'value'] = ( - self.view_model.intensity_normalization_setting_value(how) + self.intensity_normalization_setting_value(how) ) self.df_settings.to_csv(self.settings_csv_path) self.updateAllImages() @@ -1205,7 +1253,7 @@ def rescaleIntensitiesLut( how = action.text() setting, setting_value = ( - self.view_model.rescale_intensity_setting_update(channel, how) + self.rescale_intensity_setting_update(channel, how) ) self.df_settings.at[setting, 'value'] = setting_value self.df_settings.to_csv(self.settings_csv_path) @@ -1438,4 +1486,4 @@ def resetRange(self): self.ax2.vb.setRange(xRange=xRange, yRange=yRange) self.ax1.vb.setRange(xRange=xRange, yRange=yRange) self.ax1_viewRange = None - self.isRangeReset = True + self.isRangeReset = True \ No newline at end of file diff --git a/cellacdc/views/label_editing_view.py b/cellacdc/views/label_editing_view.py index 4200de32c..8de24459c 100644 --- a/cellacdc/views/label_editing_view.py +++ b/cellacdc/views/label_editing_view.py @@ -11,14 +11,61 @@ from qtpy.QtWidgets import QAction from cellacdc import apps, disableWindow, exception_handler -from cellacdc.viewmodels.label_editing_viewmodel import ( - LabelEditingViewModel, -) class LabelEditingView: """Qt-facing adapter around manual label editing.""" + """Headless decisions for manual label editing.""" + + def should_apply_manual_edits(self, edited_labels_by_z) -> bool: + return bool(edited_labels_by_z) + + def should_store_zslice_regionprops(self, *, is_segm_3d: bool) -> bool: + return is_segm_3d + + def should_update_zslice_regionprops( + self, + *, + force_update: bool, + already_stored: bool, + ) -> bool: + return force_update or not already_stored + + def should_prompt_for_background_id(self, clicked_id: int) -> bool: + return clicked_id == 0 + + def is_power_button_color( + self, + *, + button_color: str, + power_color: str, + ) -> bool: + return button_color == power_color + + def should_force_new_hover_id( + self, + *, + brush_active: bool, + shift_pressed: bool, + ) -> bool: + return brush_active and shift_pressed + + def should_restore_brush_id_from_hover( + self, + *, + is_hover_z_neighbor: bool, + shift_pressed: bool, + last_hover_id: int, + hover_id: int, + ) -> bool: + return ( + is_hover_z_neighbor + and not shift_pressed + and last_hover_id != hover_id + ) + + LEGACY_METHODS = ( 'mergeObjs_cb', 'assignNewIDfromClickedID', @@ -46,15 +93,13 @@ class LabelEditingView: 'isPowerButton', ) - def __init__(self, host, view_model: LabelEditingViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -79,7 +124,7 @@ def addYXcentroidToDf(self, df): depth_axis = ( self.switchPlaneCombobox.depthAxes() if self.isSegm3D else 'z' ) - return self.view_model.edit_id.add_yx_centroids_to_df( + return self.edit_id.add_yx_centroids_to_df( df, posData.rp, is_3d=self.isSegm3D, @@ -91,7 +136,7 @@ def _get_editID_info(self, df): depth_axis = ( self.switchPlaneCombobox.depthAxes() if self.isSegm3D else 'z' ) - return self.view_model.edit_id.edit_id_info_from_df( + return self.edit_id.edit_id_info_from_df( df, posData.rp, is_3d=self.isSegm3D, @@ -102,7 +147,7 @@ def apply_manual_edits_to_lab_if_needed(self, lab): posData = self.data[self.pos_i] data_frame_i = posData.allData_li[posData.frame_i] edited_lab_dict = data_frame_i['manually_edited_lab']['lab'] - if not self.view_model.should_apply_manual_edits(edited_lab_dict): + if not self.should_apply_manual_edits(edited_lab_dict): return lab # zoom_slice = data_frame_i['manually_edited_lab']['zoom_slice'] @@ -119,7 +164,7 @@ def apply_manual_edits_to_lab_if_needed(self, lab): return lab def store_zslices_rp(self, force_update=False): - if not self.view_model.should_store_zslice_regionprops( + if not self.should_store_zslice_regionprops( is_segm_3d=self.isSegm3D ): return @@ -128,7 +173,7 @@ def store_zslices_rp(self, force_update=False): are_zslices_rp_stored = ( posData.allData_li[posData.frame_i].get('z_slices_rp') is not None ) - if self.view_model.should_update_zslice_regionprops( + if self.should_update_zslice_regionprops( force_update=force_update, already_stored=are_zslices_rp_stored, ): @@ -242,13 +287,13 @@ def delBorderObj(self, checked): self.storeUndoRedoStates(False) posData = self.data[self.pos_i] - clear_result = self.view_model.label_edits.clear_border_labels( + clear_result = self.label_edits.clear_border_labels( posData.lab, buffer_size=1 ) posData.lab = clear_result.labels self.update_rp() if posData.cca_df is not None: - deletion_result = self.view_model.cca_edits.delete_ids( + deletion_result = self.cca_edits.delete_ids( posData.cca_df, clear_result.removed_ids, ) @@ -268,7 +313,7 @@ def delNewObj(self, checked): prev_IDs = posData.allData_li[frame_i-1]['IDs'] curr_IDs = posData.IDs - removal_result = self.view_model.label_edits.remove_new_labels( + removal_result = self.label_edits.remove_new_labels( posData.lab, prev_IDs, curr_IDs, @@ -279,7 +324,7 @@ def delNewObj(self, checked): self.update_rp() if posData.cca_df is not None: - deletion_result = self.view_model.cca_edits.delete_ids( + deletion_result = self.cca_edits.delete_ids( posData.cca_df, new_IDs, ) @@ -290,12 +335,12 @@ def delNewObj(self, checked): def getClickedID(self, xdata, ydata, text=''): posData = self.data[self.pos_i] ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if self.view_model.should_prompt_for_background_id(ID): + if self.should_prompt_for_background_id(ID): msg = ( 'You clicked on the background.\n' f'Enter here the ID {text}' ) - nearest_ID = self.view_model.label_edits.nearest_nonzero_2d( + nearest_ID = self.label_edits.nearest_nonzero_2d( self.get_2Dlab(posData.lab), xdata, ydata ) clickedBkgrID = apps.QLineEditDialog( @@ -490,7 +535,7 @@ def applyEditID( self.storeUndoRedoStates(UndoFutFrames) maxID = max(posData.IDs, default=0) for old_ID, new_ID in oldIDnewIDMapper: - result = self.view_model.label_edits.apply_id_mapping( + result = self.label_edits.apply_id_mapping( lab, [(old_ID, new_ID)], existing_ids=currentIDs, @@ -604,7 +649,7 @@ def changeIDfutureFrames( else: maxID = max(posData.IDs, default=0) + 1 for old_ID, new_ID in oldIDnewIDMapper: - result = self.view_model.label_edits.apply_id_mapping( + result = self.label_edits.apply_id_mapping( lab, [(old_ID, new_ID)], start_max_id=maxID, @@ -625,7 +670,7 @@ def changeIDfutureFrames( lab = lab for old_ID, new_ID in oldIDnewIDMapper: - self.view_model.label_edits.apply_id_mapping( + self.label_edits.apply_id_mapping( lab, [(old_ID, new_ID)] ) @@ -717,7 +762,7 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): else: hoverID = 0 else: - if self.view_model.should_force_new_hover_id( + if self.should_force_new_hover_id( brush_active=self.brushButton.isChecked(), shift_pressed=shift, ): @@ -745,7 +790,7 @@ def setHoverToolSymbolColor( posData = self.data[self.pos_i] Y, X = self.get_2Dlab(posData.lab).shape - if not self.view_model.geometry.is_in_bounds(xdata, ydata, X, Y): + if not self.geometry.is_in_bounds(xdata, ydata, X, Y): return self.isHoverZneighID = False @@ -771,7 +816,7 @@ def setHoverToolSymbolColor( except IndexError: pass - checkChangeID = self.view_model.should_restore_brush_id_from_hover( + checkChangeID = self.should_restore_brush_id_from_hover( is_hover_z_neighbor=self.isHoverZneighID, shift_pressed=shift, last_hover_id=self.lastHoverID, @@ -786,21 +831,21 @@ def setHoverToolSymbolColor( def isPowerBrush(self): color = self.brushButton.palette().button().color().name() - return self.view_model.is_power_button_color( + return self.is_power_button_color( button_color=color, power_color=self.doublePressKeyButtonColor, ) def isPowerEraser(self): color = self.eraserButton.palette().button().color().name() - return self.view_model.is_power_button_color( + return self.is_power_button_color( button_color=color, power_color=self.doublePressKeyButtonColor, ) def isPowerButton(self, button): color = button.palette().button().color().name() - return self.view_model.is_power_button_color( + return self.is_power_button_color( button_color=color, power_color=self.doublePressKeyButtonColor, - ) + ) \ No newline at end of file diff --git a/cellacdc/views/label_roi_view.py b/cellacdc/views/label_roi_view.py index 61c6e6698..73afa2d72 100644 --- a/cellacdc/views/label_roi_view.py +++ b/cellacdc/views/label_roi_view.py @@ -3,6 +3,8 @@ from __future__ import annotations import numpy as np +import os +from dataclasses import dataclass from qtpy.QtCore import QMutex, Qt, QThread, QWaitCondition from qtpy.QtGui import QCursor from qtpy.QtWidgets import QAction, QMenu @@ -17,7 +19,6 @@ widgets, workers, ) -from cellacdc.viewmodels.label_roi_viewmodel import LabelRoiViewModel class LabelRoiView: @@ -44,15 +45,13 @@ class LabelRoiView: 'labelRoiDone', ) - def __init__(self, host, view_model: LabelRoiViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -73,7 +72,7 @@ def labelRoiCheckStartStopFrame(self): enabled = self.labelRoiTrangeCheckbox.isChecked() start_n = self.labelRoiStartFrameNoSpinbox.value() stop_n = self.labelRoiStopFrameNoSpinbox.value() - if self.view_model.is_frame_range_valid(enabled, start_n, stop_n): + if self.is_frame_range_valid(enabled, start_n, stop_n): return True self.blinker = qutils.QControlBlink( @@ -83,6 +82,127 @@ def labelRoiCheckStartStopFrame(self): self.blinker.start() msg = widgets.myMessageBox() txt = html_utils.paragraph(""" + + """Headless decisions for Magic Labeller ROI workflows.""" + + yes_value = 'Yes' + no_value = 'No' + + def checked_setting_value(self, checked: bool) -> str: + return self.yes_value if checked else self.no_value + + def checked_from_setting_value(self, value) -> bool: + return value == self.yes_value + + def model_params_ini_path(self, settings_folderpath: str) -> str: + return os.path.join(settings_folderpath, 'last_params_segm_models.ini') + + def params_settings( + self, + *, + checked_roi_type: str, + circ_roi_radius: int, + roi_zdepth: int, + auto_clear_border: bool, + replace_existing_objects: bool, + ) -> LabelRoiParamsSettings: + return LabelRoiParamsSettings( + updates={ + 'labelRoi_checkedRoiType': checked_roi_type, + 'labelRoi_circRoiRadius': circ_roi_radius, + 'labelRoi_roiZdepth': roi_zdepth, + 'labelRoi_autoClearBorder': self.checked_setting_value( + auto_clear_border + ), + 'labelRoi_replaceExistingObjects': ( + self.checked_setting_value(replace_existing_objects) + ), + } + ) + + def is_frame_range_valid( + self, + enabled: bool, + start_frame_number: int, + stop_frame_number: int, + ) -> bool: + return not enabled or start_frame_number <= stop_frame_number + + def frame_range_length( + self, + enabled: bool, + start_frame_index: int, + stop_frame_number: int, + ) -> int: + if not enabled: + return 1 + return stop_frame_number - start_frame_index + + def time_range( + self, + enabled: bool, + start_frame_index: int, + stop_frame_number: int, + ): + if self.frame_range_length( + enabled, + start_frame_index, + stop_frame_number, + ) > 1: + return start_frame_index, stop_frame_number + return None + + def should_enable_range_controls(self, checked: bool) -> bool: + return checked + + def should_show_circular_cursor( + self, + *, + label_roi_checked: bool, + circular_roi_checked: bool, + label_roi_running: bool, + cursor_checked: bool, + existing_cursor_empty: bool, + ) -> bool: + return ( + label_roi_checked + and circular_roi_checked + and not label_roi_running + and (cursor_checked or not existing_cursor_empty) + ) + + def cursor_points(self, x, y, checked: bool): + if not checked: + return [], [] + return [x], [y] + + def should_uncheck_time_range( + self, + *, + time_range_checked: bool, + persistent_action_checked: bool, + ) -> bool: + return time_range_checked and not persistent_action_checked + + def z_range( + self, + roi_zdepth: int, + size_z: int, + current_z_index: int, + ) -> tuple[int, int]: + if roi_zdepth == size_z: + return 0, size_z + if roi_zdepth == 1: + return current_z_index, current_z_index + 1 + + if roi_zdepth % 2 != 0: + roi_zdepth += 1 + half_zdepth = int(roi_zdepth / 2) + zc = current_z_index + 1 + z0 = max(zc - half_zdepth, 0) + z1 = min(zc + half_zdepth, size_z) + return z0, z1 + Stop frame number is less than start frame number!

What do you want to do? """) @@ -114,7 +234,7 @@ def getSecondChannelData(self): start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() - tRangeLen = self.view_model.frame_range_length( + tRangeLen = self.frame_range_length( self.labelRoiTrangeCheckbox.isChecked(), start_frame_i, stop_frame_n, @@ -183,7 +303,7 @@ def initLabelRoiModel(self): return False def labelRoiViewCurrentModel(self): - ini_path = self.view_model.model_params_ini_path(settings_folderpath) + ini_path = self.model_params_ini_path(settings_folderpath) configPars = config.ConfigParser() configPars.read(ini_path) model_name = self.labelRoiModel.model_name @@ -210,7 +330,7 @@ def storeLabelRoiParams(self, value=None, checked=True): circRoiRadius = self.labelRoiCircularRadiusSpinbox.value() roiZdepth = self.labelRoiZdepthSpinbox.value() autoClearBorder = self.labelRoiAutoClearBorderCheckbox.isChecked() - params = self.view_model.params_settings( + params = self.params_settings( checked_roi_type=checkedRoiType, circ_roi_radius=circRoiRadius, roi_zdepth=roiZdepth, @@ -245,13 +365,13 @@ def loadLabelRoiLastParams(self): idx = 'labelRoi_autoClearBorder' if idx in self.df_settings.index: clearBorder = self.df_settings.at[idx, 'value'] - checked = self.view_model.checked_from_setting_value(clearBorder) + checked = self.checked_from_setting_value(clearBorder) self.labelRoiAutoClearBorderCheckbox.setChecked(checked) idx = 'labelRoi_replaceExistingObjects' if idx in self.df_settings.index: val = self.df_settings.at[idx, 'value'] - checked = self.view_model.checked_from_setting_value(val) + checked = self.checked_from_setting_value(val) self.labelRoiReplaceExistingObjectsCheckbox.setChecked(checked) if self.labelRoiIsCircularRadioButton.isChecked(): @@ -264,7 +384,7 @@ def updateLabelRoiCircularSize(self, value): def updateLabelRoiCircularCursor(self, x, y, checked): size = self.labelRoiCircularRadiusSpinbox.value() existing_cursor_empty = len(self.labelRoiCircItemLeft.getData()[0]) == 0 - if not self.view_model.should_show_circular_cursor( + if not self.should_show_circular_cursor( label_roi_checked=self.labelRoiButton.isChecked(), circular_roi_checked=self.labelRoiIsCircularRadioButton.isChecked(), label_roi_running=self.labelRoiRunning, @@ -272,7 +392,7 @@ def updateLabelRoiCircularCursor(self, x, y, checked): existing_cursor_empty=existing_cursor_empty, ): return - xx, yy = self.view_model.cursor_points(x, y, checked) + xx, yy = self.cursor_points(x, y, checked) self.labelRoiCircItemLeft.setData(xx, yy, size=size) self.labelRoiCircItemRight.setData(xx, yy, size=size) @@ -283,12 +403,12 @@ def getLabelRoiImage(self): start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() frame_range_enabled = self.labelRoiTrangeCheckbox.isChecked() - tRangeLen = self.view_model.frame_range_length( + tRangeLen = self.frame_range_length( frame_range_enabled, start_frame_i, stop_frame_n, ) - tRange = self.view_model.time_range( + tRange = self.time_range( frame_range_enabled, start_frame_i, stop_frame_n, @@ -302,7 +422,7 @@ def getLabelRoiImage(self): imgData = posData.img_data[posData.frame_i] roi_zdepth = self.labelRoiZdepthSpinbox.value() - z0, z1 = self.view_model.z_range( + z0, z1 = self.z_range( roi_zdepth, posData.SizeZ, self.zSliceScrollBar.sliderPosition(), @@ -360,7 +480,7 @@ def getLabelRoiImage(self): return roiImg, labelRoiSlice def labelRoiTrangeCheckboxToggled(self, checked): - enabled = self.view_model.should_enable_range_controls(checked) + enabled = self.should_enable_range_controls(checked) disabled = not enabled self.labelRoiStartFrameNoSpinbox.setDisabled(disabled) self.labelRoiStopFrameNoSpinbox.setDisabled(disabled) @@ -528,11 +648,11 @@ def labelRoiDone(self, roiSegmData, isTimeLapse): self.progressWin.close() self.progressWin = None - uncheckLabelRoiTRange = self.view_model.should_uncheck_time_range( + uncheckLabelRoiTRange = self.should_uncheck_time_range( time_range_checked=self.labelRoiTrangeCheckbox.isChecked(), persistent_action_checked=( self.labelRoiTrangeCheckbox.findChild(QAction).isChecked() ), ) if uncheckLabelRoiTRange: - self.labelRoiTrangeCheckbox.setChecked(False) + self.labelRoiTrangeCheckbox.setChecked(False) \ No newline at end of file diff --git a/cellacdc/views/label_transform_tools_view.py b/cellacdc/views/label_transform_tools_view.py index 2debf687a..9ee344473 100644 --- a/cellacdc/views/label_transform_tools_view.py +++ b/cellacdc/views/label_transform_tools_view.py @@ -4,20 +4,46 @@ import skimage.measure -from cellacdc.viewmodels.label_transform_tools_viewmodel import ( - LabelTransformToolsViewModel, -) class LabelTransformToolsView: """Qt-facing adapter around label transform tool contracts.""" - def __init__(self, host, view_model: LabelTransformToolsViewModel): - self.host = host - self.view_model = view_model + """Headless decision rules for label transform tools.""" + + def reset_expand_label_id(self) -> int: + return -1 + + def should_reinitialize_expansion( + self, + *, + expanding_id: int, + hover_label_id: int, + dilation: bool, + is_dilation: bool, + ) -> bool: + return expanding_id != hover_label_id or dilation != is_dilation + + def should_start_moving_label(self, label_id: int) -> bool: + return label_id != 0 + def point_in_shape(self, *, x: int, y: int, shape) -> bool: + y_size, x_size = shape + return x >= 0 and y >= 0 and x < x_size and y < y_size + + def move_delta(self, *, previous_pos, current_pos) -> tuple[int, int]: + x_start, y_start = previous_pos + x_current, y_current = current_pos + return x_current - x_start, y_current - y_start + + def should_clear_move_state(self, *, checked: bool) -> bool: + return not checked + + + def __init__(self, host): + self.host = host def reset_expand_label(self): - self.host.expandingID = self.view_model.reset_expand_label_id() + self.host.expandingID = self.reset_expand_label_id() def expand_label_callback(self, checked): if checked: @@ -42,7 +68,7 @@ def expand_label(self, dilation=True): return reinit_expanding_lab = ( - self.view_model.should_reinitialize_expansion( + self.should_reinitialize_expansion( expanding_id=self.host.expandingID, hover_label_id=self.host.hoverLabelID, dilation=dilation, @@ -88,7 +114,7 @@ def start_moving_label(self, x_pos, y_pos): x_data, y_data = int(x_pos), int(y_pos) lab_2d = self.host.get_2Dlab(pos_data.lab) label_id = lab_2d[y_data, x_data] - if not self.view_model.should_start_moving_label(label_id): + if not self.should_start_moving_label(label_id): self.host.isMovingLabel = False return @@ -107,7 +133,7 @@ def move_label(self, x_pos, y_pos): lab_2d = self.host.get_2Dlab(pos_data.lab) y_size, x_size = lab_2d.shape x_data, y_data = int(x_pos), int(y_pos) - if not self.view_model.point_in_shape( + if not self.point_in_shape( x=x_data, y=y_data, shape=(y_size, x_size), @@ -115,7 +141,7 @@ def move_label(self, x_pos, y_pos): return self.host.clearObjContour(ID=self.host.movingID, ax=0) - delta_x, delta_y = self.view_model.move_delta( + delta_x, delta_y = self.move_delta( previous_pos=self.host.prevMovePos, current_pos=(x_data, y_data), ) @@ -136,7 +162,7 @@ def move_label(self, x_pos, y_pos): self.host.prevMovePos = (x_data, y_data) def move_label_button_toggled(self, checked): - if not self.view_model.should_clear_move_state(checked=checked): + if not self.should_clear_move_state(checked=checked): return self.host.hoverLabelID = 0 self.host.highlightedID = 0 @@ -215,4 +241,4 @@ def set_temp_img1_move_label(self, ax=0): self.host.movingID ) highlighted_image = self.host.highLightIDLayerRightImage.image - self.host.highLightIDLayerRightImage.setImage(highlighted_image) + self.host.highLightIDLayerRightImage.setImage(highlighted_image) \ No newline at end of file diff --git a/cellacdc/views/layout_controls_view.py b/cellacdc/views/layout_controls_view.py index 3a181bfe8..132bb69c6 100644 --- a/cellacdc/views/layout_controls_view.py +++ b/cellacdc/views/layout_controls_view.py @@ -5,6 +5,7 @@ from functools import partial from natsort import natsorted +import re from qtpy.QtCore import QTimer, Qt from qtpy.QtGui import QIcon from qtpy.QtWidgets import ( @@ -22,14 +23,43 @@ from cellacdc import myutils, widgets from cellacdc.ui.modules.annotation.decorators import resetViewRange -from cellacdc.viewmodels.layout_controls_viewmodel import ( - LayoutControlsViewModel, -) class LayoutControlsView: """Qt-facing adapter around main layout and control surfaces.""" + """Headless decisions for GUI layout controls.""" + + yes_value = 'Yes' + no_value = 'No' + + def zoom_percentage_from_text(self, text: str) -> int: + return int(re.findall(r'(\d+)%', text)[0]) + + def zoom_factors(self, percentage: int) -> tuple[float, float] | None: + if percentage == 100: + return None + factor = percentage / 100 + return factor, factor + + def checked_setting_value(self, checked: bool) -> str: + return self.yes_value if checked else self.no_value + + def checked_from_setting_value(self, value) -> bool: + return value == self.yes_value + + def should_retain_z_slider_space( + self, + *, + checked: bool, + z_slice_enabled: bool, + ) -> bool: + return checked and z_slice_enabled + + def tool_name_from_tooltip(self, tooltip: str) -> str: + return re.findall(r'Name: (.*)', tooltip)[0] + + LEGACY_METHODS = ( 'zoomBottomLayoutActionTriggered', 'retainSpaceSlidersToggled', @@ -43,15 +73,13 @@ class LayoutControlsView: 'gui_terminalButtonClicked', ) - def __init__(self, host, view_model: LayoutControlsViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -63,10 +91,10 @@ def bind_legacy_methods(self): def zoomBottomLayoutActionTriggered(self, checked): if not checked: return - perc = self.view_model.zoom_percentage_from_text( + perc = self.zoom_percentage_from_text( self.sender().text() ) - zoom_factors = self.view_model.zoom_factors(perc) + zoom_factors = self.zoom_factors(perc) if zoom_factors is not None: fontSizeFactor, heightFactor = zoom_factors self.resizeSlidersArea( @@ -80,10 +108,10 @@ def zoomBottomLayoutActionTriggered(self, checked): def retainSpaceSlidersToggled(self, checked): self.df_settings.at['retain_space_hidden_sliders', 'value'] = ( - self.view_model.checked_setting_value(checked) + self.checked_setting_value(checked) ) self.df_settings.to_csv(self.settings_csv_path) - retainSpaceZ = self.view_model.should_retain_z_slider_space( + retainSpaceZ = self.should_retain_z_slider_space( checked=checked, z_slice_enabled=self.zSliceScrollBar.isEnabled(), ) @@ -684,7 +712,7 @@ def gui_populateToolSettingsMenu(self): toolName = "MISSING" continue else: - toolName = self.view_model.tool_name_from_tooltip( + toolName = self.tool_name_from_tooltip( button.toolTip() ) keepToolActiveNames[toolName] = button @@ -851,4 +879,4 @@ def gui_createTerminalWidget(self): @resetViewRange def gui_terminalButtonClicked(self, terminalVisible): - self.terminalDock.setVisible(terminalVisible) + self.terminalDock.setVisible(terminalVisible) \ No newline at end of file diff --git a/cellacdc/views/lineage_interactions_view.py b/cellacdc/views/lineage_interactions_view.py index 9a8170f08..6825f3464 100644 --- a/cellacdc/views/lineage_interactions_view.py +++ b/cellacdc/views/lineage_interactions_view.py @@ -2,6 +2,9 @@ from __future__ import annotations +from collections.abc import Callable, Iterable, Sequence +import numpy as np +import pandas as pd from qtpy.QtCore import Qt from cellacdc import ( @@ -11,9 +14,6 @@ from cellacdc.trackers.CellACDC_normal_division.CellACDC_normal_division_tracker import ( normal_division_lineage_tree, ) -from cellacdc.viewmodels.lineage_interactions_viewmodel import ( - LineageInteractionsViewModel, -) class LineageInteractionsView: @@ -34,15 +34,13 @@ class LineageInteractionsView: 'get_difference_table', ) - def __init__(self, host, view_model: LineageInteractionsViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -54,6 +52,128 @@ def bind_legacy_methods(self): @exception_handler def initLinTree(self, force=False): """ + + """Headless decisions for lineage-tree interaction workflows.""" + + lineage_mode = 'Normal division: Lineage tree' + viewer_mode = 'Viewer' + + def is_lineage_mode(self, mode: str) -> bool: + return mode == self.lineage_mode + + def should_initialize( + self, + *, + force: bool, + mode: str, + lineage_tree_exists: bool, + ) -> bool: + if not force and lineage_tree_exists: + return False + return force or self.is_lineage_mode(mode) + + def default_mode_after_failed_init(self) -> str: + return self.viewer_mode + + def last_annotated_frame_index( + self, + frame_records: Iterable[dict], + *, + acdc_key: str = 'acdc_df', + generation_column: str = 'generation_num_tree', + ) -> int: + last_frame_i = 0 + for frame_i, record in enumerate(frame_records): + acdc_df = record[acdc_key] + if ( + acdc_df is None + or generation_column not in acdc_df.columns + or acdc_df[generation_column].isin([np.nan, 0]).all() + ): + break + last_frame_i = frame_i + return last_frame_i + + def missing_frame_indices( + self, + current_frame_i: int, + present_frames: Iterable[int] | None, + ) -> list[int]: + present = set(present_frames or []) + missing = [ + frame_i for frame_i in range(current_frame_i + 1) + if frame_i not in present + ] + missing.sort() + return missing + + def should_process_auto_frame( + self, + *, + mode: str, + frame_i: int, + processed_frames: Iterable[int], + ) -> bool: + if not self.is_lineage_mode(mode): + return False + return frame_i not in processed_frames + + def should_backup_original( + self, + original_frame_i: int | None, + current_frame_i: int, + ) -> bool: + return original_frame_i is None or original_frame_i != current_frame_i + + def next_candidate_index( + self, + click_index: int, + candidates_count: int, + ) -> int: + if candidates_count <= 0: + return 0 + return abs(click_index % candidates_count) + + def should_skip_original_mother( + self, + current_parent_id, + candidate_parent_id, + *, + original_mother_skipped: bool, + ) -> bool: + return ( + current_parent_id == candidate_parent_id + and not original_mother_skipped + ) + + def parent_id_differences( + self, + original_df: pd.DataFrame, + new_df: pd.DataFrame, + reset_index_cell_id: Callable[[pd.DataFrame], pd.DataFrame], + *, + compare_columns: Sequence[str] = ('parent_ID_tree',), + ) -> pd.DataFrame | None: + if original_df.equals(new_df): + return None + + new_df = new_df[original_df.columns] + new_df = reset_index_cell_id(new_df) + new_df = new_df[list(compare_columns)] + new_df = new_df.sort_index() + + original_df = reset_index_cell_id(original_df) + original_df = original_df[list(compare_columns)] + original_df = original_df.sort_index() + + differences = original_df.compare(new_df) + if differences.empty: + return None + + differences = reset_index_cell_id(differences) + differences = differences[compare_columns[0]] + return differences.reset_index() + Initializes the lineage tree analysis. This method checks if the tracking has been previously checked and saved. If not, it displays a message to the user. @@ -67,7 +187,7 @@ def initLinTree(self, force=False): """ mode = str(self.modeComboBox.currentText()) - if not self.view_model.should_initialize( + if not self.should_initialize( force=force, mode=mode, lineage_tree_exists=self.lineage_tree is not None, @@ -76,7 +196,7 @@ def initLinTree(self, force=False): posData = self.data[self.pos_i] last_tracked_i = self.get_last_tracked_i() - defaultMode = self.view_model.default_mode_after_failed_init() + defaultMode = self.default_mode_after_failed_init() if last_tracked_i == 0: # Display message to the user txt = html_utils.paragraph( @@ -95,7 +215,7 @@ def initLinTree(self, force=False): return proceed = True - last_lin_tree_frame_i = self.view_model.last_annotated_frame_index( + last_lin_tree_frame_i = self.last_annotated_frame_index( posData.allData_li ) @@ -244,7 +364,7 @@ def autoLinTree_df(self, enforceAll=False): mode = str(self.modeComboBox.currentText()) # Skip if not the right mode - if not self.view_model.should_process_auto_frame( + if not self.should_process_auto_frame( mode=mode, frame_i=self.data[self.pos_i].frame_i, processed_frames=self.lineage_tree.frames_for_dfs, @@ -304,7 +424,7 @@ def initMissingFramesLinTree(self, current_frame_i): # done Need to add partiall present_frames = list(self.lineage_tree.frames_for_dfs) if self.lineage_tree else [] present_frames = [] if not present_frames else present_frames # deal with None - missing_frames = self.view_model.missing_frame_indices( + missing_frames = self.missing_frame_indices( current_frame_i, present_frames, ) @@ -322,7 +442,7 @@ def initMissingFramesLinTree(self, current_frame_i): # done Need to add partiall def viewLinTreeInfoAction(self): mode = str(self.modeComboBox.currentText()) - if not self.view_model.is_lineage_mode(mode): + if not self.is_lineage_mode(mode): self.logger.info('This action is only available in the "Normal division: Lineage tree" mode.') return @@ -409,7 +529,7 @@ def askLineageTreeChanges(self): """ mode = str(self.modeComboBox.currentText()) - if not self.view_model.is_lineage_mode(mode): + if not self.is_lineage_mode(mode): return if not self.lineage_tree: @@ -511,7 +631,7 @@ def repeat_click_and_backup(self, posData, event, ydata, xdata): self.original_df_lin_tree is not None and self.original_df_lin_tree_i != posData.frame_i ) - if self.view_model.should_backup_original( + if self.should_backup_original( self.original_df_lin_tree_i, posData.frame_i, ): @@ -604,13 +724,13 @@ def find_mother_action(self, posData, event, ydata, xdata): self.logger.info('No mother candidates found.') return - i = self.view_model.next_candidate_index( + i = self.next_candidate_index( self.right_click_i, len(filtered_IDs), ) new_mother = filtered_IDs[i] - if self.view_model.should_skip_original_mother( + if self.should_skip_original_mother( acdc_df_frame.loc[ID]['parent_ID_tree'], new_mother, original_mother_skipped=self.original_mother_skipped, @@ -618,7 +738,7 @@ def find_mother_action(self, posData, event, ydata, xdata): self.right_click_i += 1 self.original_mother_skipped = True - i = self.view_model.next_candidate_index( + i = self.next_candidate_index( self.right_click_i, len(filtered_IDs), ) @@ -668,7 +788,7 @@ def get_difference_table(self, return_css_separated=False, return_differece=Fals if original_df.equals(new_df): return - differences = self.view_model.parent_id_differences( + differences = self.parent_id_differences( original_df, new_df, self.host.view_model.tables.checked_reset_index_cell_id, @@ -714,4 +834,4 @@ def get_difference_table(self, return_css_separated=False, return_differece=Fals return txt, differences else: txt = css + html_utils.paragraph(txt) - return txt + return txt \ No newline at end of file diff --git a/cellacdc/views/magic_prompts_view.py b/cellacdc/views/magic_prompts_view.py index 753c11588..070fe8493 100644 --- a/cellacdc/views/magic_prompts_view.py +++ b/cellacdc/views/magic_prompts_view.py @@ -4,6 +4,8 @@ from functools import partial +from dataclasses import dataclass +from typing import Mapping from qtpy.QtCore import QEventLoop, QThread from cellacdc import ( @@ -17,21 +19,18 @@ workers, ) from cellacdc import disableWindow -from cellacdc.viewmodels.magic_prompts_viewmodel import MagicPromptsViewModel class MagicPromptsView: """Qt-facing adapter around promptable segmentation dialogs and workers.""" - def __init__(self, host, view_model: MagicPromptsViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -42,12 +41,73 @@ def showInstructionsCustomPromptModel(self): self.logger.info('Adding custom promptable model process stopped.') return - self.view_model.store_custom_promptable_model_path( + self.store_custom_promptable_model_path( modelFilePath ) msg = widgets.myMessageBox(wrapText=False) info_txt = html_utils.paragraph(f""" + + """Headless promptable-segmentation geometry and point rules.""" + + def zoom_region(self, view_range, image_shape) -> MagicPromptZoom: + (xmin, xmax), (ymin, ymax) = view_range + height, width = image_shape[-2:] + + xmin = int(max(0, xmin)) + xmax = int(min(width, xmax)) + ymin = int(max(0, ymin)) + ymax = int(min(height, ymax)) + + return MagicPromptZoom( + bounds=(xmin, xmax, ymin, ymax), + image_origin=(0, ymin, xmin), + zoom_slice=(slice(ymin, ymax), slice(xmin, xmax)), + ) + + def points_in_zoom(self, df_points, zoom: MagicPromptZoom, frame_i): + xmin, xmax, ymin, ymax = zoom.bounds + filtered = df_points[ + (df_points['y'] >= ymin) + & (df_points['x'] >= xmin) + & (df_points['y'] < ymax) + & (df_points['x'] < xmax) + & (df_points['frame_i'] == frame_i) + ].copy() + filtered['y'] -= ymin + filtered['x'] -= xmin + return filtered + + def retained_points_outside_zoom( + self, + frame_points_data: Mapping, + zoom: MagicPromptZoom, + ): + if 'x' in frame_points_data: + return self._retained_points_outside_zoom_2d( + frame_points_data, + zoom, + ) + + return { + z: self._retained_points_outside_zoom_2d(z_points, zoom) + for z, z_points in frame_points_data.items() + } + + def _retained_points_outside_zoom_2d(self, points_data, zoom): + xmin, xmax, ymin, ymax = zoom.bounds + retained = {'x': [], 'y': [], 'id': []} + for x, y, point_id in zip( + points_data['x'], + points_data['y'], + points_data['id'], + ): + if x < xmin or x >= xmax or y < ymin or y >= ymax: + retained['x'].append(x) + retained['y'].append(y) + retained['id'].append(point_id) + return retained + Done!

The custom promptable model has been added to the list of models.

Use the Magic prompts button (top toolbar) to use it.

@@ -67,7 +127,7 @@ def _importInitMagicPromptModel( ): self.logger.info(f'Initializing promptable model {model_name}...') init_kwargs = win.init_kwargs - model = self.view_model.init_prompt_segmentation_model( + model = self.init_prompt_segmentation_model( acdcPromptSegment, posData, win.init_kwargs ) toolbar.model = model @@ -124,12 +184,12 @@ def viewSetMagicPromptModelParams( posData = self.data[self.pos_i] init_argspecs = ( - self.view_model.set_default_arg_specs_from_kwargs( + self.set_default_arg_specs_from_kwargs( init_argspecs, init_kwargs ) ) segment_argspecs = ( - self.view_model.set_default_arg_specs_from_kwargs( + self.set_default_arg_specs_from_kwargs( segment_argspecs, segment_kwargs ) ) @@ -181,7 +241,7 @@ def magicPromptsComputeOnZoomTriggered(self, toolbar): posData = self.data[self.pos_i] image, df_points = inputs - zoom = self.view_model.zoom_region(self.ax1.viewRange(), image.shape) + zoom = self.zoom_region(self.ax1.viewRange(), image.shape) xmin, xmax, ymin, ymax = zoom.bounds self.logger.info( @@ -191,7 +251,7 @@ def magicPromptsComputeOnZoomTriggered(self, toolbar): zoom_slice = zoom.zoom_slice image = image[..., zoom_slice[0], zoom_slice[1]] image_origin = zoom.image_origin - df_points = self.view_model.points_in_zoom( + df_points = self.points_in_zoom( df_points, zoom, posData.frame_i, @@ -232,11 +292,11 @@ def magicPromptsClearPoints(self, toolbar, only_zoom=False): scatterItem.clear() return - zoom = self.view_model.zoom_region( + zoom = self.zoom_region( self.ax1.viewRange(), posData.img_data.shape, ) - newFramePointsData = self.view_model.retained_points_outside_zoom( + newFramePointsData = self.retained_points_outside_zoom( framePointsData, zoom, ) @@ -409,4 +469,4 @@ def magicPromptsWorkerFinished(self, output, zoom_slice=None): posData.allData_li[posData.frame_i]['labels'] = lab self.get_data() self.store_data(autosave=False) - self.updateAllImages() + self.updateAllImages() \ No newline at end of file diff --git a/cellacdc/views/main_menu_view.py b/cellacdc/views/main_menu_view.py index 1a7d8741d..bc8cac535 100644 --- a/cellacdc/views/main_menu_view.py +++ b/cellacdc/views/main_menu_view.py @@ -4,16 +4,29 @@ from qtpy.QtWidgets import QAction, QActionGroup, QMenu -from cellacdc.viewmodels.main_menu_viewmodel import MainMenuViewModel class MainMenuView: """Qt-facing adapter around the main-menu view-model.""" - def __init__(self, host, view_model: MainMenuViewModel): - self.host = host - self.view_model = view_model + """Headless main-menu decision rules.""" + + default_rescale_intensity_options = ( + 'Rescale each 2D image', + 'Rescale across z-stack', + 'Rescale across time frames', + 'Do no rescale, display raw image', + ) + + def default_rescale_intensity_how(self, settings): + try: + return settings.at['default_rescale_intens_how', 'value'] + except Exception: + return self.default_rescale_intensity_options[0] + + def __init__(self, host): + self.host = host def create_menu_bar(self): menu_bar = self.host.menuBar() menu_bar.setNativeMenuBar(False) @@ -99,11 +112,11 @@ def _add_default_rescale_intensity_menu(self): self.host.defaultRescaleIntensLutMenu ) self.host.defaultRescaleIntensHow = ( - self.view_model.default_rescale_intensity_how( + self.default_rescale_intensity_how( self.host.df_settings ) ) - for how_text in self.view_model.default_rescale_intensity_options(): + for how_text in self.default_rescale_intensity_options(): action = QAction( how_text, self.host.defaultRescaleIntensLutMenu ) @@ -219,4 +232,4 @@ def _add_help_menu(self, menu_bar): help_menu.addAction(self.host.UserManualAction) help_menu.addSeparator() help_menu.addAction(self.host.aboutAction) - self.host.helpMenu = help_menu + self.host.helpMenu = help_menu \ No newline at end of file diff --git a/cellacdc/views/main_toolbar_view.py b/cellacdc/views/main_toolbar_view.py index 3e8d32bee..b4ec38dae 100644 --- a/cellacdc/views/main_toolbar_view.py +++ b/cellacdc/views/main_toolbar_view.py @@ -9,21 +9,32 @@ import pyqtgraph as pg from cellacdc import widgets -from cellacdc.viewmodels.main_toolbar_viewmodel import MainToolbarViewModel class MainToolbarView: """Qt-facing adapter around top-level toolbar construction.""" - def __init__(self, host, view_model: MainToolbarViewModel): - object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) + """Headless toolbar metadata used by the main toolbar view.""" + + mode_items = ( + 'Segmentation and Tracking', + 'Cell cycle analysis', + 'Viewer', + 'Custom annotations', + 'Normal division: Lineage tree', + ) + + def default_mode_items(self) -> tuple[str, ...]: + return self.mode_items + + def __init__(self, host): + object.__setattr__(self, 'host', host) def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -564,7 +575,7 @@ def gui_createToolBars(self): self.viewLinTreeInfoButton.clicked.connect(self.viewLinTreeInfoAction) - self.modeItems = list(self.view_model.mode_items()) + self.modeItems = list(self.mode_items()) self.modeActionGroup = QActionGroup(self.modeMenu) for mode in self.modeItems: @@ -593,4 +604,4 @@ def gui_createAnnotateToolbar(self): self.annotateToolbar.addAction(self.loadCustomAnnotationsAction) self.annotateToolbar.addAction(self.addCustomAnnotationAction) self.annotateToolbar.addAction(self.viewAllCustomAnnotAction) - self.annotateToolbar.setVisible(False) + self.annotateToolbar.setVisible(False) \ No newline at end of file diff --git a/cellacdc/views/measurements_view.py b/cellacdc/views/measurements_view.py index 06a7b293b..46a7f9465 100644 --- a/cellacdc/views/measurements_view.py +++ b/cellacdc/views/measurements_view.py @@ -5,16 +5,59 @@ import pandas as pd from cellacdc import apps, cli, favourite_func_metrics_csv_path, widgets -from cellacdc.viewmodels.measurements_viewmodel import MeasurementsViewModel class MeasurementsView: """Qt-facing adapter around measurement view-model contracts.""" - def __init__(self, host, view_model: MeasurementsViewModel): - self.host = host - self.view_model = view_model + """Headless measurement calculation and setup rules.""" + + def rotational_volume( + self, + obj, + physical_size_y=1, + physical_size_x=1, + logger=None, + ): + return _calc_rot_vol( + obj, + physical_size_y, + physical_size_x, + logger=logger, + ) + + def custom_metrics_instructions(self): + return measurements.add_metrics_instructions() + + def metrics_examples_path(self): + return measurements.metrics_path + + def all_acdc_df_columns(self, all_pos_data): + columns = set() + for pos_data in all_pos_data: + for data_dict in pos_data.allData_li: + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + continue + columns.update(acdc_df.columns) + return columns + def not_loaded_channels(self, all_channel_names, loaded_channel_names): + return [c for c in all_channel_names if c not in loaded_channel_names] + + def drop_unchecked_measurements(self, acdc_df, columns, regionprops): + if acdc_df is None: + return None + acdc_df = acdc_df.drop(columns=columns, errors='ignore') + for col_rp in regionprops: + drop_df_rp = acdc_df.filter(regex=fr'{col_rp}.*', axis=1) + drop_cols_rp = drop_df_rp.columns + acdc_df = acdc_df.drop(columns=drop_cols_rp, errors='ignore') + return acdc_df + + + def __init__(self, host): + self.host = host def init_metrics_to_save(self, pos_data): self.host._measurements_kernel._init_metrics_to_save(pos_data) @@ -41,14 +84,14 @@ def show_set_measurements(self, checked=False, qparent=None): favourite_funcs = self._favourite_metric_functions() pos_data = self.host.data[self.host.pos_i] - all_pos_acdc_df_cols = self.view_model.all_acdc_df_columns( + all_pos_acdc_df_cols = self.all_acdc_df_columns( self.host.data ) loaded_ch_names = pos_data.setLoadedChannelNames(returnList=True) pos_data.fluo_data_dict.pop(self.host.user_ch_name, None) if self.host.user_ch_name not in loaded_ch_names: loaded_ch_names.insert(0, self.host.user_ch_name) - not_loaded_ch_names = self.view_model.not_loaded_channels( + not_loaded_ch_names = self.not_loaded_channels( self.host.ch_names, loaded_ch_names, ) @@ -86,8 +129,8 @@ def set_measurements(self): self.host.measurementsWin = None def add_custom_metric(self, checked=False): - txt = self.view_model.custom_metrics_instructions() - metrics_path = self.view_model.metrics_examples_path() + txt = self.custom_metrics_instructions() + metrics_path = self.metrics_examples_path() msg = widgets.myMessageBox() msg.addShowInFileManagerButton(metrics_path, 'Show example...') title = 'Add custom metrics instructions' @@ -151,7 +194,7 @@ def _remove_existing_unchecked_measurements(self): for pos_data in self.host.data: for data_dict in pos_data.allData_li: data_dict['acdc_df'] = ( - self.view_model.drop_unchecked_measurements( + self.drop_unchecked_measurements( data_dict['acdc_df'], del_cols, del_rps, @@ -170,4 +213,4 @@ def _favourite_metric_functions(self): df_favourite_funcs = pd.read_csv(favourite_func_metrics_csv_path) return df_favourite_funcs['favourite_func_name'].to_list() except Exception: - return None + return None \ No newline at end of file diff --git a/cellacdc/views/mode_controls_view.py b/cellacdc/views/mode_controls_view.py index e8df71a91..d432f174e 100644 --- a/cellacdc/views/mode_controls_view.py +++ b/cellacdc/views/mode_controls_view.py @@ -5,28 +5,62 @@ from qtpy.QtCore import QTimer from cellacdc import disableWindow -from cellacdc.viewmodels.mode_controls_viewmodel import ModeControlsViewModel class ModeControlsView: """Qt-facing adapter around mode-control decisions.""" - def __init__(self, host, view_model: ModeControlsViewModel): - object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) + """Headless decisions for mode toolbar and action state.""" + + viewer_mode = 'Viewer' + segmentation_mode = 'Segmentation and Tracking' + snapshot_mode = 'Snapshot' + cca_mode = 'Cell cycle analysis' + custom_annotations_mode = 'Custom annotations' + + def should_start_blinking( + self, + mode: str, + *, + ruler_checked: bool = False, + ) -> bool: + return mode == self.viewer_mode and not ruler_checked + + def blink_styles(self, flag: bool) -> tuple[str, bool]: + if flag: + return 'background-color: orange', False + return 'background-color: none', True + + def should_store_on_mode_change(self, previous_mode: str) -> bool: + return previous_mode != self.viewer_mode + def is_cca_mode(self, mode: str) -> bool: + return mode == self.cca_mode + + def undo_redo_target(self, mode: str) -> str: + if mode in {self.segmentation_mode, self.snapshot_mode}: + return 'labels' + if mode == self.cca_mode: + return 'cca' + if mode == self.custom_annotations_mode: + return 'custom_annotations' + return 'disabled' + + + def __init__(self, host): + object.__setattr__(self, 'host', host) def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) def nonViewerEditMenuOpened(self): mode = str(self.modeComboBox.currentText()) - if self.view_model.should_start_blinking( + if self.should_start_blinking( mode, ruler_checked=self.rulerButton.isChecked(), ): @@ -38,7 +72,7 @@ def startBlinkingModeCB(self): self.stopBlinkTimer.stop() except Exception as e: pass - if not self.view_model.should_start_blinking( + if not self.should_start_blinking( str(self.modeComboBox.currentText()), ruler_checked=self.rulerButton.isChecked(), ): @@ -51,7 +85,7 @@ def startBlinkingModeCB(self): self.stopBlinkTimer.start(2000) def blinkModeComboBox(self): - style, next_flag = self.view_model.blink_styles(self.flag) + style, next_flag = self.blink_styles(self.flag) self.modeComboBox.setStyleSheet(style) self.flag = next_flag @@ -91,7 +125,7 @@ def setEnabledEditToolbarButton(self, enabled=False): self.autoSegmAction.setEnabled(enabled) self.editToolBar.setVisible(enabled) mode = self.modeComboBox.currentText() - ccaON = self.view_model.is_cca_mode(mode) + ccaON = self.is_cca_mode(mode) for action in self.editToolBar.actions(): button = self.editToolBar.widgetForAction(action) # Keep binCellButton active in cca mode @@ -123,7 +157,7 @@ def reconnectUndoRedo(self): except Exception as e: pass mode = self.modeComboBox.currentText() - target = self.view_model.undo_redo_target(mode) + target = self.undo_redo_target(mode) if target == 'labels': self.undoAction.triggered.connect(self.undo_redo_view.undo) self.redoAction.triggered.connect(self.undo_redo_view.redo) @@ -192,7 +226,7 @@ def changeMode(self, text): mode = text prevMode = self.modeComboBox.previousText() self.annotateToolbar.setVisible(False) - if self.view_model.should_store_on_mode_change(prevMode): + if self.should_store_on_mode_change(prevMode): self.store_data(autosave=True) self.copyLostObjButton.setChecked(False) @@ -463,4 +497,4 @@ def setFramesSnapshotMode(self): for ch, overlayItems in self.overlayLayersItems.items(): lutItem = overlayItems[1] - lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot) + lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot) \ No newline at end of file diff --git a/cellacdc/views/object_cleanup_view.py b/cellacdc/views/object_cleanup_view.py index 6bebd44d8..5a5e2285d 100644 --- a/cellacdc/views/object_cleanup_view.py +++ b/cellacdc/views/object_cleanup_view.py @@ -2,25 +2,32 @@ from __future__ import annotations +import numpy as np import numpy as np from qtpy.QtCore import QThread from cellacdc import apps, widgets, workers -from cellacdc.viewmodels.object_cleanup_viewmodel import ( - ObjectCleanupViewModel, -) class ObjectCleanupView: """Qt-facing adapter around the object-cleanup view-model.""" - def __init__(self, host, view_model: ObjectCleanupViewModel): - self.host = host - self.view_model = view_model + """Headless object-cleanup result shaping.""" + + def cleared_segmentation_frames(self, cleared_segm_data, *, size_t: int): + if size_t == 1: + return cleared_segm_data[np.newaxis] + return cleared_segm_data + def frame_labels(self, cleared_segm_data): + return list(enumerate(cleared_segm_data)) + + + def __init__(self, host): + self.host = host def delete_objects_outside_mask_action_triggered(self): pos_data = self.host.data[self.host.pos_i] - existing_segm_endnames = self.view_model.segmentation_roi_endnames( + existing_segm_endnames = self.segmentation_roi_endnames( basename=pos_data.basename, images_path=pos_data.images_path, ) @@ -76,7 +83,7 @@ def start_delete_objects_outside_mask_worker(self, selected_segm_endname): def delete_objects_outside_mask_worker_finished(self, result): pos_data = self.host.data[self.host.pos_i] worker, cleared_segm_data, del_ids = result - cleared_segm_data = self.view_model.cleared_segmentation_frames( + cleared_segm_data = self.cleared_segmentation_frames( cleared_segm_data, size_t=pos_data.SizeT, ) @@ -84,7 +91,7 @@ def delete_objects_outside_mask_worker_finished(self, result): self.host.update_cca_df_deletedIDs(pos_data, del_ids) current_frame_i = pos_data.frame_i - for frame_i, cleared_lab in self.view_model.frame_labels( + for frame_i, cleared_lab in self.frame_labels( cleared_segm_data ): pos_data.allData_li[frame_i]['labels'] = cleared_lab @@ -104,4 +111,4 @@ def delete_objects_outside_mask_worker_finished(self, result): 'Deleting objects outside of ROIs finished.', color='w', ) - self.host.updateAllImages() + self.host.updateAllImages() \ No newline at end of file diff --git a/cellacdc/views/object_properties_view.py b/cellacdc/views/object_properties_view.py index f9abdefb9..8850fb2f6 100644 --- a/cellacdc/views/object_properties_view.py +++ b/cellacdc/views/object_properties_view.py @@ -2,14 +2,12 @@ from __future__ import annotations +import numpy as np import numpy as np import skimage.measure from tqdm import tqdm from cellacdc import apps, exception_handler, html_utils, widgets -from cellacdc.viewmodels.object_properties_viewmodel import ( - ObjectPropertiesViewModel, -) class ObjectPropertiesView: @@ -50,15 +48,13 @@ class ObjectPropertiesView: 'updatePropsWidget', ) - def __init__(self, host, view_model: ObjectPropertiesViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -82,7 +78,7 @@ def showPropsDockWidget(self, checked=False): self.setHighlightID(False) else: self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - if self.view_model.should_show_3d_property_controls( + if self.should_show_3d_property_controls( self.isSegm3D ): self.guiTabControl.propsQGBox.cellVolVox3D_SB.show() @@ -117,14 +113,14 @@ def clearHighlightedID(self): def setAllIDs(self, onlyVisited=False): for posData in self.data: - posData.allIDs = self.view_model.object_counts.collect_all_ids( + posData.allIDs = self.object_counts.collect_all_ids( posData, only_visited=onlyVisited, ) def countObjectsTimelapse(self): if self.countObjsWindow is None: - activeCategories = self.view_model.timelapse_default_categories() + activeCategories = self.timelapse_default_categories() else: activeCategories = self.countObjsWindow.activeCategories() @@ -145,13 +141,13 @@ def countObjectsTimelapse(self): def countObjectsSnapshots(self): posData = self.data[self.pos_i] if self.countObjsWindow is None: - activeCategories = self.view_model.snapshot_default_categories( + activeCategories = self.snapshot_default_categories( is_segm_3d=self.isSegm3D ) else: activeCategories = self.countObjsWindow.activeCategories() - allCategoryCountMapper = self.view_model.object_counts.snapshot_object_counts( + allCategoryCountMapper = self.object_counts.snapshot_object_counts( self.data, self.pos_i, current_lab_2d=self.currentLab2D, @@ -178,7 +174,7 @@ def countObjects(self): def updateObjectCounts(self): - if not self.view_model.should_update_object_counts( + if not self.should_update_object_counts( window_exists=self.countObjsWindow is not None, is_visible=( self.countObjsWindow.isVisible() @@ -371,7 +367,7 @@ def getHighlightedID(self): if self.highlightedID > 0: return self.highlightedID - doHighlight = self.view_model.should_highlight_props_id( + doHighlight = self.should_highlight_props_id( dock_visible=self.propsDockWidget.isVisible(), highlight_checked=self.guiTabControl.highlightCheckbox.isChecked(), searched_highlight_checked=( @@ -404,7 +400,7 @@ def highlightSearchedID(self, ID, force=False, greyOthers=True): if ID == self.highlightedID and not force: return - doHighlight = self.view_model.should_highlight_props_id( + doHighlight = self.should_highlight_props_id( dock_visible=self.propsDockWidget.isVisible(), highlight_checked=self.guiTabControl.highlightCheckbox.isChecked(), searched_highlight_checked=( @@ -575,6 +571,170 @@ def applyKeepObjects(self): self.current_frame_i = posData.frame_i if posData.frame_i > 0: txt = html_utils.paragraph(""" + + """Headless decisions for object-property and highlight workflows.""" + + def timelapse_default_categories(self) -> set[str]: + return { + 'In current frame', + 'In all visited frames', + 'In entire video', + 'Unique objects in all visited frames', + 'Unique objects in entire video', + } + + def snapshot_default_categories(self, *, is_segm_3d: bool) -> set[str]: + categories = { + 'In current position', + 'In all visited positions (current session)', + 'In all visited positions (previous sessions)', + 'In all loaded positions', + } + if is_segm_3d: + categories.add('In current z-slice') + return categories + + def should_update_object_counts( + self, + *, + window_exists: bool, + is_visible: bool, + live_preview_checked: bool, + ) -> bool: + return window_exists and is_visible and live_preview_checked + + def should_show_3d_property_controls(self, is_segm_3d: bool) -> bool: + return is_segm_3d + + def should_highlight_props_id( + self, + *, + dock_visible: bool, + highlight_checked: bool, + searched_highlight_checked: bool, + ) -> bool: + return ( + dock_visible + and (highlight_checked or searched_highlight_checked) + ) + + def should_update_props_widget( + self, + *, + dock_visible: bool, + object_id: int, + current_props_id: int, + ) -> bool: + return dock_visible and object_id != 0 and object_id != current_props_id + + def calculate_area_pxl( + self, + *, + is_segm_3d: bool, + z_proj_text: str, + z_lab: int, + bbox_0: int, + obj_image: np.ndarray, + obj_area: int, + ) -> int: + if is_segm_3d: + if z_proj_text == 'single z-slice': + local_z = z_lab - bbox_0 + return int(np.count_nonzero(obj_image[local_z])) + else: + return int(np.count_nonzero(obj_image.max(axis=0))) + else: + return obj_area + + def calculate_area_um2( + self, + *, + area_pxl: int, + physical_size_x: float, + physical_size_y: float, + ) -> float: + return area_pxl * physical_size_y * physical_size_x + + def calculate_vol_3d( + self, + *, + obj_area: int, + physical_size_x: float, + physical_size_y: float, + physical_size_z: float, + ) -> tuple[float, float]: + vol_vox_3D = obj_area + vol_fl_3D = vol_vox_3D * physical_size_z * physical_size_y * physical_size_x + return float(vol_vox_3D), float(vol_fl_3D) + + def calculate_elongation( + self, + *, + major_axis_length: float, + minor_axis_length: float, + ) -> float: + minor_axis = max(1.0, minor_axis_length) + return major_axis_length / minor_axis + + def get_object_and_background_images( + self, + *, + image: np.ndarray, + is_segm_3d: bool, + pos_data_size_z: int, + z_slice: int, + obj_slice: tuple, + obj_image: np.ndarray, + img1_image: np.ndarray | None = None, + ) -> tuple[np.ndarray, np.ndarray]: + if pos_data_size_z > 1 and not is_segm_3d: + obj_data = image[z_slice][obj_slice][obj_image] + img = img1_image if img1_image is not None else image[z_slice] + else: + obj_data = image[obj_slice][obj_image] + img = image + return obj_data, img + + def calculate_intensity_statistics( + self, + obj_data: np.ndarray, + ) -> dict[str, float]: + if obj_data.size == 0: + return {'min': 0.0, 'max': 0.0, 'mean': 0.0, 'median': 0.0} + return { + 'min': float(np.min(obj_data)), + 'max': float(np.max(obj_data)), + 'mean': float(np.mean(obj_data)), + 'median': float(np.median(obj_data)), + } + + def calculate_additional_measure( + self, + *, + func_desc: str, + func: callable, + obj_data: np.ndarray, + img: np.ndarray, + lab: np.ndarray, + obj_area: int, + vol_vox: float, + ) -> float: + if func_desc in ('Concentration', 'Amount'): + background_pixels = img[lab == 0] + bkgr_val = ( + float(np.median(background_pixels)) + if background_pixels.size > 0 + else 0.0 + ) + amount = func(obj_data, bkgr_val, obj_area) + if func_desc == 'Concentration': + return amount / vol_vox + else: + return amount + else: + return float(func(obj_data)) + + Do you want to remove un-kept objects in the past frames too? """) msg = widgets.myMessageBox(wrapText=False, showCentered=False) @@ -703,7 +863,7 @@ def get_curr_lab(self, curr_lab: np.ndarray|None = None, frame_i: int|None = Non Current labels for the position data """ posData = self.data[self.pos_i] - return self.view_model.object_counts.current_labels( + return self.object_counts.current_labels( posData, curr_lab=curr_lab, frame_i=frame_i, @@ -773,7 +933,7 @@ def updatePropsWidget(self, ID, fromHover=False): ID = int(ID) - update = self.view_model.should_update_props_widget( + update = self.should_update_props_widget( dock_visible=self.propsDockWidget.isVisible(), object_id=ID, current_props_id=self.currentPropsID, @@ -808,7 +968,7 @@ def updatePropsWidget(self, ID, fromHover=False): self.currentPropsID = ID propsQGBox.idSB.setValue(ID) - doHighlight = self.view_model.should_highlight_props_id( + doHighlight = self.should_highlight_props_id( dock_visible=True, highlight_checked=self.guiTabControl.highlightCheckbox.isChecked(), searched_highlight_checked=( @@ -820,7 +980,7 @@ def updatePropsWidget(self, ID, fromHover=False): obj = posData.rp[obj_idx] - area_pxl = self.view_model.calculate_area_pxl( + area_pxl = self.calculate_area_pxl( is_segm_3d=self.isSegm3D, z_proj_text=self.zProjComboBox.currentText(), z_lab=self.z_lab(), @@ -836,7 +996,7 @@ def updatePropsWidget(self, ID, fromHover=False): PhysicalSizeY = pixelSizeQGBox.pixelHeightWidget.value() PhysicalSizeZ = pixelSizeQGBox.voxelDepthWidget.value() - area_um2 = self.view_model.calculate_area_um2( + area_um2 = self.calculate_area_um2( area_pxl=area_pxl, physical_size_x=PhysicalSizeX, physical_size_y=PhysicalSizeY, @@ -845,7 +1005,7 @@ def updatePropsWidget(self, ID, fromHover=False): propsQGBox.cellAreaUm2DSB.setValue(area_um2) if self.isSegm3D: - vol_vox_3D, vol_fl_3D = self.view_model.calculate_vol_3d( + vol_vox_3D, vol_fl_3D = self.calculate_vol_3d( obj_area=obj.area, physical_size_x=PhysicalSizeX, physical_size_y=PhysicalSizeY, @@ -854,13 +1014,13 @@ def updatePropsWidget(self, ID, fromHover=False): propsQGBox.cellVolVox3D_SB.setValue(vol_vox_3D) propsQGBox.cellVolFl3D_DSB.setValue(vol_fl_3D) - vol_vox, vol_fl = self.view_model.measurements.rotational_volume( + vol_vox, vol_fl = self.measurements.rotational_volume( obj, PhysicalSizeY, PhysicalSizeX ) propsQGBox.cellVolVoxSB.setValue(int(vol_vox)) propsQGBox.cellVolFlDSB.setValue(vol_fl) - elongation = self.view_model.calculate_elongation( + elongation = self.calculate_elongation( major_axis_length=obj.major_axis_length, minor_axis_length=obj.minor_axis_length, ) @@ -882,7 +1042,7 @@ def updatePropsWidget(self, ID, fromHover=False): except Exception as e: image = posData.img_data[posData.frame_i] - objData, img = self.view_model.get_object_and_background_images( + objData, img = self.get_object_and_background_images( image=image, is_segm_3d=self.isSegm3D, pos_data_size_z=posData.SizeZ, @@ -892,7 +1052,7 @@ def updatePropsWidget(self, ID, fromHover=False): img1_image=self.img1.image, ) - stats = self.view_model.calculate_intensity_statistics(objData) + stats = self.calculate_intensity_statistics(objData) intensMeasurQGBox.minimumDSB.setValue(stats['min']) intensMeasurQGBox.maximumDSB.setValue(stats['max']) intensMeasurQGBox.meanDSB.setValue(stats['mean']) @@ -901,7 +1061,7 @@ def updatePropsWidget(self, ID, fromHover=False): funcDesc = intensMeasurQGBox.additionalMeasCombobox.currentText() func = intensMeasurQGBox.additionalMeasCombobox.functions[funcDesc] - value = self.view_model.calculate_additional_measure( + value = self.calculate_additional_measure( func_desc=funcDesc, func=func, obj_data=objData, @@ -912,4 +1072,3 @@ def updatePropsWidget(self, ID, fromHover=False): ) intensMeasurQGBox.additionalMeasCombobox.indicator.setValue(value) - diff --git a/cellacdc/views/object_search_view.py b/cellacdc/views/object_search_view.py index 5b045e62c..9abebe4e8 100644 --- a/cellacdc/views/object_search_view.py +++ b/cellacdc/views/object_search_view.py @@ -2,19 +2,17 @@ from __future__ import annotations +from collections.abc import Callable from qtpy.QtCore import QEventLoop, QThread from cellacdc import apps, html_utils, widgets, workers -from cellacdc.viewmodels.object_search_viewmodel import ObjectSearchViewModel class ObjectSearchView: """Qt-facing adapter around object-search commands.""" - def __init__(self, host, view_model: ObjectSearchViewModel): + def __init__(self, host): self.host = host - self.view_model = view_model - def findID(self, checked=False, ID=None): pos_data = self.host.data[self.host.pos_i] if ID is None: @@ -148,7 +146,7 @@ def searchIDworkerCallback(self, posData, searchedID): self.host.searchIDworker.signals.initProgressBar.emit(0) self.host.setAllIDs() self.host.searchIDworker.signals.initProgressBar.emit(posData.SizeT) - frame_i_found = self.view_model.find_frame_with_id( + frame_i_found = self.find_frame_with_id( posData, searchedID, progress_callback=self.host.searchIDworker.signals.progressBar.emit, @@ -158,6 +156,23 @@ def searchIDworkerCallback(self, posData, searchedID): def warnIDnotFound(self, searchedID): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(f""" + + """Headless object-search operations.""" + + def find_frame_with_id( + self, + pos_data, + searched_id: int, + *, + progress_callback: Callable[[int], None] | None = None, + ) -> int | None: + return find_frame_with_id( + pos_data.segm_data, + pos_data.allData_li, + searched_id, + progress_callback=progress_callback, + ) + Object ID {searchedID} was not found.

""") msg.warning(self.host, f'ID {searchedID} not found', txt) @@ -277,4 +292,4 @@ def findNextNewIdWorkerFinished(self, next_frame_i): self.host.progressWin = None self.host.navSpinBox.setValue(next_frame_i + 1) - self.host.framesScrollBarReleased() + self.host.framesScrollBarReleased() \ No newline at end of file diff --git a/cellacdc/views/points_layers_view.py b/cellacdc/views/points_layers_view.py index 4b20a5e58..99fb59a68 100644 --- a/cellacdc/views/points_layers_view.py +++ b/cellacdc/views/points_layers_view.py @@ -11,13 +11,13 @@ import matplotlib import numpy as np import pyqtgraph as pg +from collections.abc import Mapping import skimage.draw import skimage.measure from qtpy.QtCore import QTimer from qtpy.QtWidgets import QLabel from cellacdc import _warnings, apps, colors, exception_handler, html_utils, widgets -from cellacdc.viewmodels.points_layers_viewmodel import PointsLayersViewModel class PointsLayersView: @@ -78,15 +78,13 @@ class PointsLayersView: 'drawPointsLayers', ) - def __init__(self, host, view_model: PointsLayersViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -137,6 +135,65 @@ def askSavePointsLayer(self, action): saveAction = toolButton.saveAction txt = html_utils.paragraph(f""" + + """Headless decisions for points-layer GUI workflows.""" + + recovery_tolerance_seconds = 15 + + def click_entry_table_filename( + self, + basename: str, + table_endname: str, + ) -> str: + table_basename = basename if basename.endswith('_') else f'{basename}_' + filename = f'{table_basename}{table_endname}' + if not filename.endswith('.csv'): + filename = f'{filename}.csv' + return filename + + def should_load_recovery_table( + self, + *, + recovery_exists: bool, + main_exists: bool, + recovery_mtime: float | None, + main_mtime: float | None, + ) -> bool: + if not recovery_exists: + return False + if not main_exists: + return True + if recovery_mtime is None or main_mtime is None: + return False + return ( + recovery_mtime + > main_mtime + self.recovery_tolerance_seconds + ) + + def should_compute_points_layer( + self, + *, + layer_type_index: int, + compute_points_layers: bool, + ) -> bool: + return layer_type_index < 2 and compute_points_layers + + def should_log_missing_frame_points(self, layer_type_index: int) -> bool: + return layer_type_index != 4 + + def should_use_z_slice( + self, + *, + z_projection_mode: str, + size_z: int, + frame_points_data: Mapping, + ) -> bool: + return ( + z_projection_mode == 'single z-slice' + and size_z > 1 + and 'x' not in frame_points_data + ) + Do you want to save the points you added (table called {tableEndName}.csv)? """ @@ -441,7 +498,7 @@ def autoPilotZoomToObjToggled(self, checked): def savePointsAddedByClickingFromEndname(self, tableEndName, recovery=False): self.pointsLayerDataToDf(self.data[self.pos_i]) for posData in self.data: - tableFilename = self.view_model.points.click_points_table_filename( + tableFilename = self.points.click_points_table_filename( posData.basename, tableEndName ) if recovery: @@ -453,7 +510,7 @@ def savePointsAddedByClickingFromEndname(self, tableEndName, recovery=False): df = posData.clickEntryPointsDfs.get(tableEndName) if df is None: continue - self.view_model.points.save_click_points_table(tableFilepath, df) + self.points.save_click_points_table(tableFilepath, df) def markPointsLayerDirty(self, tableEndName=None, action=None): if tableEndName is None and action is not None: @@ -524,9 +581,9 @@ def pointsLayerLoadedDfsToData(self): if not os.path.exists(filepath): action.pointsData[self.pos_i] = {} - df = self.view_model.points.load_points_table(filepath) + df = self.points.load_points_table(filepath) action.pointsData[self.pos_i] = ( - self.view_model.points.loaded_table_to_points_data( + self.points.loaded_table_to_points_data( df, action.loadedDfInfo['t'], action.loadedDfInfo['z'], action.loadedDfInfo['y'], action.loadedDfInfo['x'] ) @@ -576,7 +633,7 @@ def pointsLayerClicksDfsToData(self, posData, toolbar=None): try: action.pointsData[self.pos_i] = ( - self.view_model.points.click_points_table_to_data( + self.points.click_points_table_to_data( df, size_z=posData.SizeZ, ) ) @@ -703,7 +760,7 @@ def pointsLayerAutoPilot(self, direction): self.zoomToObj(obj) def getClickEntryTableFilepaths(self, posData, tableEndName): - csv_filename = self.view_model.click_entry_table_filename( + csv_filename = self.click_entry_table_filename( posData.basename, tableEndName, ) @@ -722,7 +779,7 @@ def getClickEntryNewerRecoveryFilepaths(self, tableEndName): if not os.path.exists(filepath) or not os.path.exists(recovery_filepath): continue - if not self.view_model.should_load_recovery_table( + if not self.should_load_recovery_table( recovery_exists=True, main_exists=True, recovery_mtime=os.path.getmtime(recovery_filepath), @@ -930,7 +987,7 @@ def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): recovery_exists = os.path.exists(recovery_filepath) main_exists = os.path.exists(filepath) if ( - self.view_model.should_load_recovery_table( + self.should_load_recovery_table( recovery_exists=recovery_exists, main_exists=main_exists, recovery_mtime=( @@ -951,7 +1008,7 @@ def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): continue self.logger.info(f'Loading points from "{filepath}"...') - df = self.view_model.points.load_click_points_table(filepath) + df = self.points.load_click_points_table(filepath) posData.clickEntryPointsDfs[tableEndName] = df try: @@ -975,7 +1032,7 @@ def removeClickedPoints(self, action, points): for point in points: pos = point.pos() points_to_remove.append((pos.x(), pos.y(), point.data())) - removed_ids = self.view_model.points.remove_click_points( + removed_ids = self.points.remove_click_points( framePointsData, points_to_remove, z_slice=zSlice, @@ -1016,7 +1073,7 @@ def getClickedPointNewId( return new_id else: pointsDataPos = action.pointsData.get(self.pos_i) - return self.view_model.points.next_click_point_id( + return self.points.next_click_point_id( pointsDataPos, posData.frame_i, current_id, @@ -1036,7 +1093,7 @@ def setHoverCircleAddPoint(self, x, y): def isPointIdAlreadyNew(self, point_id, action): posData = self.data[self.pos_i] pointsDataPos = action.pointsData.get(self.pos_i) - return self.view_model.points.point_id_already_new( + return self.points.point_id_already_new( pointsDataPos, posData.frame_i, point_id, @@ -1056,7 +1113,7 @@ def addClickedPoint(self, action, x, y, id): zSlice = None if posData.SizeZ > 1: zSlice = self.zSliceScrollBar.sliderPosition() - self.view_model.points.add_click_point( + self.points.add_click_point( pointsDataPos, posData.frame_i, x, @@ -1193,7 +1250,7 @@ def drawPointsLayers(self, computePointsLayers=True): if not hasattr(action, 'layerTypeIdx'): continue - if self.view_model.should_compute_points_layer( + if self.should_compute_points_layer( layer_type_index=action.layerTypeIdx, compute_points_layers=computePointsLayers, ): @@ -1204,7 +1261,7 @@ def drawPointsLayers(self, computePointsLayers=True): frames = action.pointsData.get(self.pos_i, set()) if posData.frame_i not in frames: - if self.view_model.should_log_missing_frame_points( + if self.should_log_missing_frame_points( action.layerTypeIdx ): self.logger.info( @@ -1217,14 +1274,14 @@ def drawPointsLayers(self, computePointsLayers=True): zSlice = None zProjHow = self.zProjComboBox.currentText() - isZslice = self.view_model.should_use_z_slice( + isZslice = self.should_use_z_slice( z_projection_mode=zProjHow, size_z=posData.SizeZ, frame_points_data=framePointsData, ) if isZslice: zSlice = self.zSliceScrollBar.sliderPosition() - xx, yy, ids, data = self.view_model.points.flatten_frame_points_data( + xx, yy, ids, data = self.points.flatten_frame_points_data( framePointsData, z_slice=zSlice, z_radius=action.zRadius, @@ -1256,4 +1313,4 @@ def drawPointsLayers(self, computePointsLayers=True): action.scatterItem.show_data_as_tip = show_data_as_tip action.scatterItem.setData( xx, yy, data=data, brush=brushes, pen=pens - ) + ) \ No newline at end of file diff --git a/cellacdc/views/preprocessing_view.py b/cellacdc/views/preprocessing_view.py index 76232cb65..ad8ce52ea 100644 --- a/cellacdc/views/preprocessing_view.py +++ b/cellacdc/views/preprocessing_view.py @@ -5,31 +5,86 @@ from typing import Any, Dict, List, Tuple, Union import numpy as np +from typing import Any from qtpy.QtCore import QMutex, QThread, QWaitCondition from cellacdc import apps, html_utils, widgets, workers from cellacdc.plot import imshow -from cellacdc.viewmodels.preprocessing_viewmodel import PreprocessingViewModel class PreprocessingView: """Qt-facing adapter around preprocessing dialogs and workers.""" - def __init__(self, host, view_model: PreprocessingViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) def askGet2Dor3Dimage(self): txt = html_utils.paragraph(""" + + """Headless preprocessing operations used by GUI and scripts.""" + + def validate_multidimensional_recipe( + self, + recipe: list[dict[str, Any]], + *, + apply_to_all_zslices: bool = False, + apply_to_all_frames: bool = False, + ): + return core_validate_multidimensional_recipe( + recipe, + apply_to_all_zslices=apply_to_all_zslices, + apply_to_all_frames=apply_to_all_frames, + ) + + def preprocess_image_from_recipe(self, image, recipe: list[dict[str, Any]]): + return core_preprocess_image_from_recipe(image, recipe) + + def preprocess_zstack_from_recipe(self, image, recipe: list[dict[str, Any]]): + return core_preprocess_zstack_from_recipe(image, recipe) + + def preprocess_video_from_recipe(self, image, recipe: list[dict[str, Any]]): + return core_preprocess_video_from_recipe(image, recipe) + + def preprocess_multi_pos_from_recipe( + self, + images, + recipe: list[dict[str, Any]], + ): + return core_preprocess_multi_pos_from_recipe(images, recipe) + + def image_to_float( + self, + image, + *, + force_dtype=None, + force_missing_dtype=None, + warn=True, + ): + return img_to_float( + image, + force_dtype=force_dtype, + force_missing_dtype=force_missing_dtype, + warn=warn, + ) + + def normalize_display_image(self, image, how: str): + return normalize_display_image( + image, + how, + image_to_float=self.image_to_float, + ) + + def create_preprocessed_data(self, image_data=None): + return PreprocessedData(image_data=image_data) + Do you want to test the denoising on the visualized 2D image or on the entire 3D z-stack? """) @@ -155,14 +210,14 @@ def preprocessDialogSavePreprocessedData(self, dialog): def preprocessEnqueueCurrentImage(self, recipe): posData = self.data[self.pos_i] - func = self.view_model.preprocess_image_from_recipe + func = self.preprocess_image_from_recipe image_data = self.getImage(raw=True) if posData.SizeZ > 1: z_slice = self.z_slice_index() else: z_slice = 0 - recipe = self.view_model.validate_multidimensional_recipe( + recipe = self.validate_multidimensional_recipe( recipe ) @@ -236,8 +291,8 @@ def preprocessCurrentImage(self, recipe: List[Dict[str, Any]], *args): self.logger.info(txt) self.statusBarLabel.setText(txt) - func = self.view_model.preprocess_image_from_recipe - recipe = self.view_model.validate_multidimensional_recipe( + func = self.preprocess_image_from_recipe + recipe = self.validate_multidimensional_recipe( recipe ) @@ -257,8 +312,8 @@ def preprocessZStack(self, recipe: List[Dict[str, Any]], *args): self.logger.info(txt) posData = self.data[self.pos_i] - func = self.view_model.preprocess_zstack_from_recipe - recipe = self.view_model.validate_multidimensional_recipe( + func = self.preprocess_zstack_from_recipe + recipe = self.validate_multidimensional_recipe( recipe, apply_to_all_frames=False ) image_data = posData.img_data[posData.frame_i] @@ -277,7 +332,7 @@ def preprocessAllFrames(self, recipe: List[Dict[str, Any]]): self.statusBarLabel.setText(txt) posData = self.data[self.pos_i] - func = self.view_model.preprocess_video_from_recipe + func = self.preprocess_video_from_recipe image_data = posData.img_data self.preprocWorker.setupJob( func, @@ -292,8 +347,8 @@ def preprocessAllPos(self, recipe: List[Dict[str, Any]]): self.logger.info(txt) self.statusBarLabel.setText(txt) - func = self.view_model.preprocess_multi_pos_from_recipe - recipe = self.view_model.validate_multidimensional_recipe( + func = self.preprocess_multi_pos_from_recipe + recipe = self.validate_multidimensional_recipe( recipe, apply_to_all_frames=False ) image_data = [posData.img_data[0] for posData in self.data] @@ -397,7 +452,7 @@ def preprocWorkerPreviewDone( posData = self.data[pos_i] if not hasattr(posData, 'preproc_img_data'): posData.preproc_img_data = ( - self.view_model.create_preprocessed_data( + self.create_preprocessed_data( image_data=np.zeros(posData.img_data.shape) ) ) @@ -420,7 +475,7 @@ def preprocWorkerDone( posData = self.data[self.pos_i] if not hasattr(posData, 'preproc_img_data'): posData.preproc_img_data = ( - self.view_model.create_preprocessed_data() + self.create_preprocessed_data() ) if how == 'current_image': @@ -469,7 +524,7 @@ def preprocWorkerDone( posData = self.data[pos_i] if not hasattr(posData, 'preproc_img_data'): posData.preproc_img_data = ( - self.view_model.create_preprocessed_data() + self.create_preprocessed_data() ) for z_slice, processed_img in enumerate(processed_pos_data): posData.preproc_img_data[0][z_slice] = ( @@ -490,4 +545,4 @@ def preprocWorkerDone( self.setImageImg1() def preprocWorkerClosed(self, worker): - self.logger.info('Pre-processing worker stopped.') + self.logger.info('Pre-processing worker stopped.') \ No newline at end of file diff --git a/cellacdc/views/quick_settings_view.py b/cellacdc/views/quick_settings_view.py index fdc99ac45..cbcec37d6 100644 --- a/cellacdc/views/quick_settings_view.py +++ b/cellacdc/views/quick_settings_view.py @@ -2,22 +2,41 @@ from __future__ import annotations +from dataclasses import dataclass from qtpy.QtCore import Qt from qtpy.QtWidgets import QFormLayout, QLabel, QVBoxLayout from cellacdc import apps, settings_csv_path, widgets -from cellacdc.viewmodels.quick_settings_viewmodel import ( - QuickSettingsViewModel, -) class QuickSettingsView: """Qt-facing adapter around quick-settings view-model contracts.""" - def __init__(self, host, view_model: QuickSettingsViewModel): - self.host = host - self.view_model = view_model + """Headless quick-settings decision rules.""" + + def font_size_setting( + self, + saved_font_size, + *, + has_px_mode: bool, + ) -> FontSizeSetting: + saved_font_size = str(saved_font_size) + if saved_font_size.find('pt') != -1: + saved_font_size = saved_font_size[:-2] + font_size = int(saved_font_size) + if has_px_mode: + return FontSizeSetting(value=font_size) + return FontSizeSetting( + value=2*font_size, + add_px_mode_setting=True, + ) + + def should_update_all_contours(self, *, is_data_loaded: bool) -> bool: + return is_data_loaded + + def __init__(self, host): + self.host = host def create_show_props_button(self, side='left'): self.host.leftSideDocksLayout = QVBoxLayout() self.host.leftSideDocksLayout.setSpacing(0) @@ -66,7 +85,7 @@ def create_widgets(self): self.host.quickSettingsLayout.addStretch(1) def show_all_contours_toggled(self): - if not self.view_model.should_update_all_contours( + if not self.should_update_all_contours( is_data_loaded=self.host.isDataLoaded ): return @@ -191,7 +210,7 @@ def _add_font_size_control(self, layout): self.host.fontSizeSpinBox.setMinimum(1) self.host.fontSizeSpinBox.setMaximum(99) layout.addRow('Font size', self.host.fontSizeSpinBox) - font_size_setting = self.view_model.font_size_setting( + font_size_setting = self.font_size_setting( self.host.df_settings.at['fontSize', 'value'], has_px_mode='pxMode' in self.host.df_settings.index, ) @@ -208,4 +227,4 @@ def _add_font_size_control(self, layout): ) self.host.fontSizeSpinBox.sigDownClicked.connect( self.host.changeFontSize - ) + ) \ No newline at end of file diff --git a/cellacdc/views/saving_view.py b/cellacdc/views/saving_view.py index b507bd315..e66748998 100644 --- a/cellacdc/views/saving_view.py +++ b/cellacdc/views/saving_view.py @@ -9,6 +9,8 @@ from typing import Literal import pandas as pd +from dataclasses import dataclass +from typing import Literal from qtpy.QtCore import QEventLoop, QMutex, QThread, QTimer, QWaitCondition from qtpy.QtGui import QFont from qtpy.QtWidgets import QCheckBox, QMessageBox @@ -18,7 +20,6 @@ from cellacdc import cca_df_colnames, html_utils, settings_csv_path, widgets from cellacdc import load from cellacdc import workers -from cellacdc.viewmodels.saving_viewmodel import SavingViewModel _font = QFont() @@ -77,15 +78,13 @@ class SavingView: 'askSaveOnClosing', ) - def __init__(self, host, view_model: SavingViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -184,7 +183,7 @@ def autoSaveTimerCountFrames(self): def enqAutosave(self): mode = str(self.modeComboBox.currentText()) - if self.view_model.should_clear_autosave_status(mode=mode): + if self.should_clear_autosave_status(mode=mode): if self.statusBarLabel.text().endswith('Autosaving...'): self.statusBarLabel.setText( self.statusBarLabel.text().replace(' | Autosaving...', '') @@ -194,7 +193,7 @@ def enqAutosave(self): if not self.autoSaveActiveWorkers: self.gui_createAutoSaveWorker() - if not self.view_model.should_enqueue_autosave( + if not self.should_enqueue_autosave( mode=mode, has_active_workers=bool(self.autoSaveActiveWorkers), ): @@ -207,7 +206,7 @@ def enqAutosave(self): autoSaveIntevalValue, autoSaveIntervalUnit = ( self.autoSaveIntevalValueUnit ) - schedule = self.view_model.autosave_schedule( + schedule = self.autosave_schedule( autoSaveIntevalValue, autoSaveIntervalUnit ) if schedule is None: @@ -273,7 +272,7 @@ def computeVolumeRegionprop(self): obj_iter = tqdm(rp, ncols=100, position=2, leave=False) for i, obj in enumerate(obj_iter): vol_vox, vol_fl = ( - self.view_model.measurements.rotational_volume( + self.measurements.rotational_volume( obj, PhysicalSizeY, PhysicalSizeX ) ) @@ -290,6 +289,126 @@ def askSaveOriginalSegm(self, isQuickSave=False): return "", True, True help_txt = html_utils.paragraph(f""" + + """Headless decisions for save and autosave workflows.""" + + viewer_mode = 'Viewer' + segmentation_mode = 'Segmentation and Tracking' + cell_cycle_mode = 'Cell cycle analysis' + + def should_clear_autosave_status(self, *, mode: str) -> bool: + return mode == self.viewer_mode + + def should_enqueue_autosave(self, *, mode: str, has_active_workers: bool): + return mode != self.viewer_mode and has_active_workers + + def autosave_schedule( + self, + value: float, + unit: Literal['minutes', 'frames'], + ) -> AutosaveSchedule | None: + if value == 0: + return None + if unit == 'frames': + return AutosaveSchedule(use_frame_timer=True) + return AutosaveSchedule( + use_frame_timer=False, + interval_ms=round(value * 60 * 1000), + ) + + def autosave_interval_change( + self, + value: float, + unit: Literal['minutes', 'frames'], + ) -> AutosaveIntervalChange: + return AutosaveIntervalChange( + value=value, + unit=unit, + settings_updates={ + 'autoSaveIntevalValue': str(value), + 'autoSaveIntervalUnit': unit, + }, + log_message=f'Autosave interval changed to: {value} {unit}', + tooltip=( + 'Change autosave interval to every N frames or minutes\n\n' + f'Current autosave interval: {value} {unit}' + ), + start_frame_timer=unit == 'frames', + ) + + def concatenate_prompt_plan( + self, + *, + has_main_window: bool, + is_quick_save: bool, + setting_exists: bool, + show_setting_value: str | None, + ) -> ConcatenatePromptPlan: + if not has_main_window or is_quick_save: + return ConcatenatePromptPlan( + should_prompt=False, + ensure_setting=False, + ) + + should_prompt = show_setting_value != 'No' + return ConcatenatePromptPlan( + should_prompt=should_prompt, + ensure_setting=not setting_exists, + ) + + def concatenate_prompt_setting(self, *, do_not_show_again: bool) -> str: + if do_not_show_again: + return 'No' + return 'Yes' + + def autosave_segmentation_enabled(self, *, mode: str, checked: bool) -> bool: + if mode != self.segmentation_mode: + return False + return checked + + def autosave_annotations_enabled(self, *, mode: str, checked: bool) -> bool: + if mode != self.viewer_mode: + return False + return checked + + def save_as_basename(self, basename: str) -> str: + if basename.endswith('_'): + return f'{basename}segm' + return f'{basename}_segm' + + def quick_save_positions(self, position_foldername: str) -> set[str]: + return {position_foldername} + + def should_ask_positions( + self, + *, + is_snapshot: bool, + is_quick_save: bool, + position_count: int, + ) -> bool: + return is_snapshot and not is_quick_save and position_count > 1 + + def should_compute_volume_metrics( + self, + *, + save_metrics: bool, + mode: str, + ) -> bool: + return save_metrics or mode == self.cell_cycle_mode + + def save_finished_title( + self, + *, + aborted: bool, + worker_aborted: bool, + is_quick_save: bool, + ) -> tuple[str, str | None]: + if aborted or worker_aborted: + return 'Saving process cancelled.', 'r' + if is_quick_save: + return 'Saved segmentation file and annotations', None + return 'Saved!', None + You have whitelisted IDs in the current position.
Do you want to save the not whitelisted segmentation data
This will allow you to revisit the original segmentation.
@@ -300,10 +419,10 @@ def askSaveOriginalSegm(self, isQuickSave=False): Do you want to save the not whitelisted segmentation data?
""") - found_files = self.view_model.workspace.segmentation_files( + found_files = self.workspace.segmentation_files( posData.images_path ) - existingEndnames = self.view_model.workspace.endnames( + existingEndnames = self.workspace.endnames( posData.basename, found_files ) @@ -334,7 +453,7 @@ def askSaveLastVisitedCcaMode(self, isQuickSave=False): if self.isSnapshot: return True - frame_i = self.view_model.tracking.last_tracked_frame_index( + frame_i = self.tracking.last_tracked_frame_index( (data_dict['labels'] for data_dict in posData.allData_li), first_frame_fallback=-1, ) @@ -382,7 +501,7 @@ def askSaveLastVisitedSegmMode(self, isQuickSave=False): if self.isSnapshot: return True - frame_i = self.view_model.tracking.last_tracked_frame_index( + frame_i = self.tracking.last_tracked_frame_index( (data_dict['labels'] for data_dict in posData.allData_li), first_frame_fallback=-1, ) @@ -524,10 +643,10 @@ def saveAsData(self, checked=True): existingFilenames = set() for _posData in self.data: - segm_files = self.view_model.workspace.segmentation_files( + segm_files = self.workspace.segmentation_files( _posData.images_path ) - _existingEndnames = self.view_model.workspace.endnames( + _existingEndnames = self.workspace.endnames( _posData.basename, segm_files ) existingFilenames.update([ @@ -535,7 +654,7 @@ def saveAsData(self, checked=True): for endname in _existingEndnames ]) posData = self.data[self.pos_i] - basename = self.view_model.save_as_basename(posData.basename) + basename = self.save_as_basename(posData.basename) win = apps.filenameDialog( basename=basename, hintText='Insert a filename for the segmentation file:
', @@ -610,7 +729,7 @@ def saveDataUpdatePbar(self, step, max=-1, exec_time=0.0): self.saveWin.QPbar.setValue(self.saveWin.QPbar.value()+step) steps_left = self.saveWin.QPbar.maximum()-self.saveWin.QPbar.value() seconds = round(exec_time*steps_left) - ETA = self.view_model.formatting.seconds_to_eta(seconds) + ETA = self.formatting.seconds_to_eta(seconds) self.saveWin.ETA_label.setText(f'ETA: {ETA}') def quickSave(self): @@ -625,7 +744,7 @@ def checkMissingCca(self): missing_cca_items = [ (item.cca_df, self.data[item.position_i], item.frame_i) - for item in self.view_model.cca_workflows.missing_annotation_items( + for item in self.cca_workflows.missing_annotation_items( (posData.allData_li for posData in self.data), cca_df_colnames, is_snapshot=self.isSnapshot, @@ -733,7 +852,7 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): return True self.posToSave = None - if self.view_model.should_ask_positions( + if self.should_ask_positions( is_snapshot=self.isSnapshot, is_quick_save=isQuickSave, position_count=len(self.data), @@ -747,7 +866,7 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): if isQuickSave: # Quick save only current pos - self.posToSave = self.view_model.quick_save_positions( + self.posToSave = self.quick_save_positions( self.data[self.pos_i].pos_foldername ) @@ -777,7 +896,7 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.activateWindow() return True - if self.view_model.should_compute_volume_metrics( + if self.should_compute_volume_metrics( save_metrics=self.save_metrics, mode=mode, ): @@ -852,7 +971,7 @@ def setAutoSaveSegmentationEnabled(self, enabled): worker, thread = self.autoSaveActiveWorkers[-1] if enabled: - worker.isAutoSaveON = self.view_model.autosave_segmentation_enabled( + worker.isAutoSaveON = self.autosave_segmentation_enabled( mode=self.modeComboBox.currentText(), checked=self.autoSaveToggle.isChecked(), ) @@ -867,7 +986,7 @@ def setAutoSaveAnnotationsEnabled(self, enabled): if enabled: worker.isAutoSaveAnnotON = ( - self.view_model.autosave_annotations_enabled( + self.autosave_annotations_enabled( mode=self.modeComboBox.currentText(), checked=self.autoSaveToggle.isChecked(), ) @@ -885,7 +1004,7 @@ def autoSaveToggled(self, checked): worker, thread = self.autoSaveActiveWorkers[-1] mode = self.modeComboBox.currentText() - worker.isAutoSaveON = self.view_model.autosave_segmentation_enabled( + worker.isAutoSaveON = self.autosave_segmentation_enabled( mode=mode, checked=checked, ) @@ -901,7 +1020,7 @@ def autoSaveAnnotToggled(self, checked): mode = self.modeComboBox.currentText() worker.isAutoSaveAnnotON = ( - self.view_model.autosave_annotations_enabled( + self.autosave_annotations_enabled( mode=mode, checked=checked, ) @@ -915,7 +1034,7 @@ def autoSaveIntervalEdit(self): def autoSaveIntervalValueChanged( self, value: float, unit: Literal['minutes', 'frames'] ): - interval_change = self.view_model.autosave_interval_change( + interval_change = self.autosave_interval_change( value, unit, ) @@ -938,7 +1057,7 @@ def autoSaveIntervalValueChanged( def autoSaveIntervalSetTooltip(self, tooltip=None): if tooltip is None: value, unit = self.autoSaveIntevalValueUnit - tooltip = self.view_model.autosave_interval_change( + tooltip = self.autosave_interval_change( value, unit, ).tooltip @@ -972,7 +1091,7 @@ def askConcatenate(self): self.df_settings.at['showAskConcatenate', 'value'] if setting_exists else None ) - prompt_plan = self.view_model.concatenate_prompt_plan( + prompt_plan = self.concatenate_prompt_plan( has_main_window=self.mainWin is not None, is_quick_save=self._isQuickSave, setting_exists=setting_exists, @@ -995,7 +1114,7 @@ def askConcatenate(self): buttonsTexts=('No', 'Yes'), widgets=doNotShowAgainCheckbox ) - show_ask_concatenate = self.view_model.concatenate_prompt_setting( + show_ask_concatenate = self.concatenate_prompt_setting( do_not_show_again=doNotShowAgainCheckbox.isChecked() ) self.df_settings.at['showAskConcatenate', 'value'] = ( @@ -1024,7 +1143,7 @@ def updateSegmDataAutoSaveWorker(self): def saveDataFinished(self): self.setDisabled(False, keepDisabled=False) self.activateWindow() - title_text, color = self.view_model.save_finished_title( + title_text, color = self.save_finished_title( aborted=self.saveWin.aborted, worker_aborted=self.worker.abort, is_quick_save=self._isQuickSave, @@ -1052,7 +1171,7 @@ def saveDataFinished(self): self.askConcatenate() if self.closeGUI: - salute_string = self.view_model.formatting.salute_string() + salute_string = self.formatting.salute_string() msg = widgets.myMessageBox() txt = html_utils.paragraph( 'Data saved!. The GUI will now close.

' @@ -1101,4 +1220,4 @@ def askSaveOnClosing(self, event): QTimer.singleShot(100, self.saveAction.trigger) event.ignore() return False - return True + return True \ No newline at end of file diff --git a/cellacdc/views/seg_for_lost_ids_view.py b/cellacdc/views/seg_for_lost_ids_view.py index fd741483a..83de17f37 100644 --- a/cellacdc/views/seg_for_lost_ids_view.py +++ b/cellacdc/views/seg_for_lost_ids_view.py @@ -2,34 +2,137 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Any from qtpy.QtCore import QMutex, QThread, QWaitCondition from cellacdc import apps, workers from cellacdc.plot import imshow -from cellacdc.viewmodels.seg_for_lost_ids_viewmodel import ( - SegForLostIdsViewModel, -) class SegForLostIdsView: """Qt-facing adapter around lost-ID segmentation commands.""" - def __init__(self, host, view_model: SegForLostIdsViewModel): - object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) + """Headless settings and launch rules for lost-ID segmentation.""" + + settings_key = 'SegForLostIDsModel' + worker_model_name = 'local_seg' + + def previous_model_name(self, df_settings) -> str | None: + try: + return str(df_settings.at[self.settings_key, 'value']) + except KeyError: + return None + + def should_persist_model_choice(self, base_model_name: str | None) -> bool: + return bool(base_model_name) + + def extra_arg_specs(self) -> list[ArgSpec]: + extra_params = ( + 'overlap_threshold', + 'padding', + 'size_perc_diff', + 'distance_filler_growth', + 'max_iterations', + 'allow_only_tracked_cells', + ) + extra_types = (float, float, float, float, int, bool) + extra_defaults = (0.5, 0.8, 0.3, 1.0, 2, False) + extra_desc = ( + ( + 'Overlap threshold with other already segemented cells over ' + 'which newly segmented cells are discarded' + ), + ( + 'Padding of the box used for new segmentation around the ' + 'segmentation from the previous frame' + ), + ( + 'Relative size difference acceptable compared to previous ' + 'frames' + ), + ( + 'Cells which are already segmented are filled with random ' + 'noise sampled from background to ensure that they do not get ' + 'segmented again. This parameter controls the additional ' + 'padding around the already segmented cells.' + ), + ( + 'The algorithm will try and segment the maximum amount of ' + 'cells in the image by running the model several times and ' + 'filling new found cells with background noise. How many of ' + 'these iterations should be run?' + ), + ( + 'If no new cell IDs should be permitted ' + '(based on real time tracking)' + ), + ) + + return [ + ArgSpec( + name=name, + default=default, + type=arg_type, + desc=desc, + docstring='', + ) + for name, default, arg_type, desc in zip( + extra_params, extra_defaults, extra_types, extra_desc + ) + ] + + def split_model_kwargs( + self, + init_kwargs: dict[str, Any], + extra_kwargs: dict[str, Any], + ) -> tuple[dict[str, Any], dict[str, Any]]: + extra_param_names = {arg.name for arg in self.extra_arg_specs()} + init_kwargs_new = {} + args_new = {} + + for key, val in init_kwargs.items(): + if key in extra_param_names: + args_new[key] = val + else: + init_kwargs_new[key] = val + + for key, val in extra_kwargs.items(): + if key in extra_param_names: + args_new[key] = val + + return init_kwargs_new, args_new + + def settings_from_dialog(self, win, base_model_name: str): + init_kwargs_new, args_new = self.split_model_kwargs( + win.init_kwargs, + win.extra_kwargs, + ) + return SegForLostIdsSettings( + win=win, + init_kwargs_new=init_kwargs_new, + args_new=args_new, + base_model_name=base_model_name, + ) + + def can_start_from_frame(self, frame_i: int) -> bool: + return frame_i > 0 + + def __init__(self, host): + object.__setattr__(self, 'host', host) def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) def SegForLostIDsSetSettings(self): - prev_model = self.view_model.previous_model_name(self.df_settings) + prev_model = self.previous_model_name(self.df_settings) win = apps.QDialogSelectModel(parent=self.host, customFirst=prev_model) win.exec_() if win.cancel: @@ -37,13 +140,13 @@ def SegForLostIDsSetSettings(self): return base_model_name = win.selectedModel - if self.view_model.should_persist_model_choice(base_model_name): + if self.should_persist_model_choice(base_model_name): self.df_settings.at[ - self.view_model.settings_key, 'value' + self.settings_key, 'value' ] = base_model_name self.df_settings.to_csv(self.settings_csv_path) - model_name = self.view_model.worker_model_name + model_name = self.worker_model_name idx = self.modelNames.index(model_name) acdcSegment = self.acdcSegment_li[idx] @@ -64,7 +167,7 @@ def SegForLostIDsSetSettings(self): self.logger.error(f'Error importing {base_model_name}: {e}') return - extra_ArgSpec = self.view_model.extra_arg_specs() + extra_ArgSpec = self.extra_arg_specs() init_params, segment_params = ( self.host.view_model.model_registry.model_arg_specs(acdcSegment) @@ -82,7 +185,7 @@ def SegForLostIDsSetSettings(self): self.logger.info('Segmentation for lost IDs cancelled.') return - settings = self.view_model.settings_from_dialog(win, base_model_name) + settings = self.settings_from_dialog(win, base_model_name) self.SegForLostIDsSettings = { 'win': settings.win, 'init_kwargs_new': settings.init_kwargs_new, @@ -95,7 +198,7 @@ def segForLostIDsButtonClicked(self): why = 'Segmentation for lost IDs' self.setFrameNavigationDisabled(disable=True, why=why) posData = self.data[self.pos_i] - if not self.view_model.can_start_from_frame(posData.frame_i): + if not self.can_start_from_frame(posData.frame_i): self.logger.info( 'Segmentation for lost IDs not available on first frame.' ) @@ -257,4 +360,4 @@ def SegForLostIDsWorkerFinished(self): self.progressWin = None def showImageDebug(self, img): - imshow(img) + imshow(img) \ No newline at end of file diff --git a/cellacdc/views/segmentation_view.py b/cellacdc/views/segmentation_view.py index 412a7d045..0ed0c2971 100644 --- a/cellacdc/views/segmentation_view.py +++ b/cellacdc/views/segmentation_view.py @@ -4,6 +4,8 @@ import os +import numpy as np +from dataclasses import dataclass import numpy as np from qtpy.QtCore import QMutex, QThread, QTimer, QWaitCondition, Qt from qtpy.QtWidgets import QAction @@ -12,7 +14,6 @@ apps, exception_handler, html_utils, prompts, printl, widgets, workers, ) from cellacdc.plot import imshow -from cellacdc.viewmodels.segmentation_viewmodel import SegmentationViewModel class SegmentationView: @@ -45,15 +46,13 @@ class SegmentationView: 'init_segmInfo_df', ) - def __init__(self, host, view_model: SegmentationViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -65,7 +64,7 @@ def bind_legacy_methods(self): def computeSegm(self, force=False): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - should_compute = self.view_model.should_compute_segmentation( + should_compute = self.should_compute_segmentation( mode=mode, has_labels=np.any(posData.lab), force=force, @@ -80,7 +79,7 @@ def autoSegm_cb(self, checked): if checked: self.askSegmParam = True # Ask which model - models = self.view_model.segmentation_models() + models = self.segmentation_models() win = widgets.QDialogListbox( 'Select model', 'Select model to use for segmentation: ', @@ -218,7 +217,7 @@ def showInstructionsCustomModel(self): self.logger.info('Adding custom model process stopped.') return - self.view_model.store_custom_model_path(modelFilePath) + self.store_custom_model_path(modelFilePath) modelName = os.path.basename(os.path.dirname(modelFilePath)) customModelAction = QAction(modelName) self.segmSingleFrameMenu.addAction(customModelAction) @@ -308,7 +307,7 @@ def initSegmModelParams( def repeatSegm( self, model_name='', askSegmParams=False, is_label_roi=False ): - model_name = self.view_model.action_model_name(model_name) + model_name = self.action_model_name(model_name) idx = self.modelNames.index(model_name) # Ask segm parameters if not already set @@ -322,7 +321,7 @@ def repeatSegm( # Store undo state before modifying stuff self.storeUndoRedoStates(False) - model_name = self.view_model.backend_model_name(model_name) + model_name = self.backend_model_name(model_name) posData = self.data[self.pos_i] # Check if model needs to be imported @@ -330,7 +329,7 @@ def repeatSegm( if acdcSegment is None: self.logger.info(f'Importing {model_name}...') acdcSegment = ( - self.view_model.import_segmentation_module(model_name) + self.import_segmentation_module(model_name) ) self.acdcSegment_li[idx] = acdcSegment @@ -343,7 +342,7 @@ def repeatSegm( self.segmModelName = model_name # Read all models parameters init_params, segment_params = ( - self.view_model.model_arg_specs(acdcSegment) + self.model_arg_specs(acdcSegment) ) # Prompt user to enter the model parameters try: @@ -364,12 +363,12 @@ def repeatSegm( thresh_method = self.model_kwargs['threshold_method'] gauss_sigma = self.model_kwargs['gauss_sigma'] segment_params = ( - self.view_model.insert_model_arg_spec( + self.insert_model_arg_spec( segment_params, 'threshold_method', thresh_method ) ) segment_params = ( - self.view_model.insert_model_arg_spec( + self.insert_model_arg_spec( segment_params, 'gauss_sigma', gauss_sigma ) ) @@ -392,7 +391,7 @@ def repeatSegm( self.secondChannelName = win.secondChannelName self.preproc_recipe = win.preproc_recipe - self.view_model.log_segmentation_params( + self.log_segmentation_params( model_name, win.init_kwargs, win.model_kwargs, logger_func=self.logger.info, preproc_recipe=win.preproc_recipe, @@ -402,7 +401,7 @@ def repeatSegm( ) use_gpu = win.init_kwargs.get('gpu', False) - proceed = self.view_model.check_gpu_available( + proceed = self.check_gpu_available( model_name, use_gpu, qparent=self.host ) if not proceed: @@ -410,7 +409,7 @@ def repeatSegm( self.titleLabel.setText('Segmentation process cancelled.') return - model = self.view_model.init_segmentation_model( + model = self.init_segmentation_model( acdcSegment, posData, win.init_kwargs ) if model is None: @@ -434,7 +433,7 @@ def repeatSegm( '(check progress in terminal/console)', color=self.titleColor ) - post_process_params = self.view_model.post_process_params( + post_process_params = self.post_process_params( apply_postprocessing=self.applyPostProcessing, standard_postprocess_kwargs=self.standardPostProcessKwargs, custom_postprocess_features=self.customPostProcessFeatures, @@ -541,14 +540,14 @@ def selectZtoolZvalueChanged(self, whichZ, z): @exception_handler def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): - model_name = self.view_model.action_model_name(model_name) + model_name = self.action_model_name(model_name) idx = self.modelNames.index(model_name) self.downloadWin = apps.downloadModel(model_name, parent=self.host) self.downloadWin.download() - model_name = self.view_model.backend_model_name(model_name) + model_name = self.backend_model_name(model_name) posData = self.data[self.pos_i] # Check if model needs to be imported @@ -556,13 +555,13 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): if acdcSegment is None: self.logger.info(f'Importing {model_name}...') acdcSegment = ( - self.view_model.import_segmentation_module(model_name) + self.import_segmentation_module(model_name) ) self.acdcSegment_li[idx] = acdcSegment # Read all models parameters init_params, segment_params = ( - self.view_model.model_arg_specs(acdcSegment) + self.model_arg_specs(acdcSegment) ) # Prompt user to enter the model parameters try: @@ -592,7 +591,7 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): self.applyPostProcessing = win.applyPostProcessing self.preproc_recipe = win.preproc_recipe - self.view_model.log_segmentation_params( + self.log_segmentation_params( model_name, win.init_kwargs, win.model_kwargs, logger_func=self.logger.info, preproc_recipe=win.preproc_recipe, @@ -606,7 +605,7 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): secondChannelData = self.getSecondChannelData() use_gpu = win.init_kwargs.get('gpu', False) - proceed = self.view_model.check_gpu_available( + proceed = self.check_gpu_available( model_name, use_gpu, qparent=self.host ) if not proceed: @@ -614,7 +613,7 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): self.titleLabel.setText('Segmentation process cancelled.') return - model = self.view_model.init_segmentation_model( + model = self.init_segmentation_model( acdcSegment, posData, win.init_kwargs ) if model is None: @@ -728,6 +727,58 @@ def postProcessing(self): def checkIfAutoSegm(self): """ + + """Headless decisions for segmentation orchestration.""" + + thresholding_backend_name = 'thresholding' + thresholding_action_name = 'Automatic thresholding' + + def action_model_name(self, model_name: str) -> str: + if model_name == self.thresholding_backend_name: + return self.thresholding_action_name + return model_name + + def backend_model_name(self, model_name: str) -> str: + if model_name == self.thresholding_action_name: + return self.thresholding_backend_name + return model_name + + def should_compute_segmentation( + self, + *, + mode: str, + has_labels: bool, + force: bool, + auto_enabled: bool, + ) -> bool: + if mode in {'Viewer', 'Cell cycle analysis'}: + return False + if has_labels and not force: + return False + return auto_enabled + + def post_process_params( + self, + *, + apply_postprocessing, + standard_postprocess_kwargs=None, + custom_postprocess_features=None, + ) -> dict: + params = {'applied_postprocessing': apply_postprocessing} + params.update(standard_postprocess_kwargs or {}) + params.update(custom_postprocess_features or {}) + return params + + def empty_segmentation_prompt(self, position_data) -> EmptySegmentationPrompt: + for pos_data in position_data: + if pos_data.SizeT > 1: + for lab in pos_data.segm_data: + if not np.any(lab): + return EmptySegmentationPrompt(True, 'frames') + elif not np.any(pos_data.segm_data): + return EmptySegmentationPrompt(True, 'positions') + return EmptySegmentationPrompt(False) + If there are any frame or position with empty segmentation mask ask whether automatic segmentation should be turned ON """ @@ -736,7 +787,7 @@ def checkIfAutoSegm(self): if self.autoSegmDoNotAskAgain: return - prompt = self.view_model.empty_segmentation_prompt(self.data) + prompt = self.empty_segmentation_prompt(self.data) if not prompt.should_ask: return txt = prompt.scope_text @@ -767,4 +818,4 @@ def init_segmInfo_df(self): # posData is None when computing measurements with the utility # and with timelapse data continue - posData.init_segmInfo_df() + posData.init_segmInfo_df() \ No newline at end of file diff --git a/cellacdc/views/session_view.py b/cellacdc/views/session_view.py index b99e80f21..ab8376602 100644 --- a/cellacdc/views/session_view.py +++ b/cellacdc/views/session_view.py @@ -5,13 +5,13 @@ import os from functools import partial +import numpy as np import numpy as np import skimage.measure from qtpy.QtWidgets import QAction from cellacdc import exception_handler, html_utils, recentPaths_path, settings_csv_path, widgets from cellacdc.ui.modules.annotation.decorators import get_data_exception_handler -from cellacdc.viewmodels.session_viewmodel import SessionViewModel class SessionView: @@ -43,15 +43,13 @@ class SessionView: '_dispatch_tool_event_if_enabled', ) - def __init__(self, host, view_model: SessionViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -63,7 +61,7 @@ def bind_legacy_methods(self): def unstore_data(self): posData = self.data[self.pos_i] posData.allData_li[posData.frame_i] = ( - self.view_model.frame_metadata.empty_frame_record() + self.frame_metadata.empty_frame_record() ) def updateLastVisitedFrame(self, last_visited_frame_i=None): @@ -72,7 +70,7 @@ def updateLastVisitedFrame(self, last_visited_frame_i=None): last_visited_frame_i = posData.frame_i mode = str(self.modeComboBox.currentText()) - update = self.view_model.update_last_visited_frame( + update = self.update_last_visited_frame( mode, last_visited_frame_i, last_tracked_i=posData.last_tracked_i, @@ -89,7 +87,7 @@ def store_data( pos_i = self.pos_i if pos_i is None else pos_i posData = self.data[pos_i] mode = str(self.modeComboBox.currentText()) - if not self.view_model.should_store_frame_data( + if not self.should_store_frame_data( frame_i=posData.frame_i, mode=mode, enforce=enforce, @@ -120,7 +118,7 @@ def store_data( self.switchPlaneCombobox.depthAxes() if self.isSegm3D else 'z' ) metadata_result = ( - self.view_model.frame_metadata.build_acdc_frame_metadata( + self.frame_metadata.build_acdc_frame_metadata( posData.rp, edit_id_info=posData.editID_info, existing_df=allData_li['acdc_df'], @@ -198,7 +196,7 @@ def _get_data_unvisited(self, posData, debug=False, lin_tree_init=True,): ripIDs = set(ripIDs_df.index).union(posData.ripIDs) posData.ripIDs = ripIDs posData.editID_info.extend(self._get_editID_info(df)) - df = self.view_model.cca_edits.normalize_loaded_frame_annotations( + df = self.cca_edits.normalize_loaded_frame_annotations( df, self.cca_df_colnames, self.cca_df_int_cols, @@ -228,7 +226,7 @@ def _get_data_visited(self, posData, debug=False, lin_tree_init=True,): try: binnedIDs_df = df[df['is_cell_excluded']>0] except Exception as err: - df = self.view_model.tables.fix_acdc_df_dtypes(df) + df = self.tables.fix_acdc_df_dtypes(df) binnedIDs_df = df[df['is_cell_excluded']>0] posData.binnedIDs = set(binnedIDs_df.index) ripIDs_df = df[df['is_cell_dead']>0] @@ -281,10 +279,10 @@ def get_data(self, debug=False, lin_tree_init=True): def initPosAttr(self): exp_path = self.data[self.pos_i].exp_path - pos_foldernames = self.view_model.workspace.position_folder_names( + pos_foldernames = self.workspace.position_folder_names( exp_path ) - if self.view_model.should_disable_load_position(len(pos_foldernames)): + if self.should_disable_load_position(len(pos_foldernames)): self.loadPosAction.setDisabled(True) else: self.loadPosAction.setDisabled(False) @@ -348,7 +346,7 @@ def initPosAttr(self): for i in range(posData.SizeT): if posData.allData_li[i] is None: posData.allData_li[i] = ( - self.view_model.frame_metadata.empty_frame_record() + self.frame_metadata.empty_frame_record() ) posData.lutLevels = {channel: {} for channel in self.ch_names} @@ -374,7 +372,7 @@ def initPosAttr(self): ) # Ask whether to resume from last frame - if self.view_model.should_resume_last_session_prompt( + if self.should_resume_last_session_prompt( last_tracked_num ): msg = widgets.myMessageBox() @@ -451,6 +449,56 @@ def get_labels( ): """Get the labels array. + """Headless decisions for session and frame storage workflows.""" + + def should_store_frame_data( + self, + *, + frame_i: int, + mode: str, + enforce: bool, + ) -> bool: + if frame_i < 0: + return False + if mode == 'Viewer' and not enforce: + return False + return True + + def should_disable_load_position(self, position_count: int) -> bool: + return position_count <= 1 + + def labels_shape( + self, + *, + is_3d: bool, + size_z: int, + size_y: int, + size_x: int, + ) -> tuple[int, ...]: + if is_3d: + return (size_z, size_y, size_x) + return (size_y, size_x) + + def empty_labels( + self, + *, + is_3d: bool, + size_z: int, + size_y: int, + size_x: int, + ) -> np.ndarray: + shape = self.labels_shape( + is_3d=is_3d, + size_z=size_z, + size_y=size_y, + size_x=size_x, + ) + return np.zeros(shape, dtype=np.uint32) + + def should_resume_last_session_prompt(self, last_tracked_num: int) -> bool: + return last_tracked_num > 1 + + Parameters ---------- from_store : bool, optional @@ -499,7 +547,7 @@ def get_labels( except IndexError: existing = False # Visting a frame that was not segmented --> empty masks - labels = self.view_model.empty_labels( + labels = self.empty_labels( is_3d=self.isSegm3D, size_z=posData.SizeZ, size_y=posData.SizeY, @@ -523,7 +571,7 @@ def readRecentPaths(self, recent_paths_path=None): if recent_paths_path is None: recent_paths_path = recentPaths_path - recentPaths = self.view_model.recent_paths(recent_paths_path) + recentPaths = self.recent_paths(recent_paths_path) # Step 2. Dynamically create the actions actions = [] @@ -553,7 +601,7 @@ def addPathToOpenRecentMenu(self, path): def loadLastSessionSettings(self): self.settings_csv_path = settings_csv_path - self.df_settings = self.view_model.load_settings( + self.df_settings = self.load_settings( settings_csv_path ) @@ -679,7 +727,7 @@ def _sync_session(self, pos_i): while len(self.sessions) <= pos_i: self.sessions.append(None) pos_data = self.data[pos_i] - session = self.view_model.position_session_from_load_data(pos_data) + session = self.position_session_from_load_data(pos_data) self.sessions[pos_i] = session if hasattr(self, '_tool_dispatcher') and self._tool_dispatcher is not None: self._tool_dispatcher.set_context(self._make_tool_context(pos_i)) @@ -739,4 +787,4 @@ def _dispatch_tool_event_if_enabled(self, event, phase='press', image='img1'): self._init_tool_dispatcher() return dispatch_gui_mouse_event( self._tool_dispatcher, self, event, phase=phase, image=image, - ) + ) \ No newline at end of file diff --git a/cellacdc/views/status_hover_view.py b/cellacdc/views/status_hover_view.py index 9515b8c30..7a90c5412 100644 --- a/cellacdc/views/status_hover_view.py +++ b/cellacdc/views/status_hover_view.py @@ -2,23 +2,137 @@ from __future__ import annotations -from cellacdc.viewmodels.status_hover_viewmodel import StatusHoverViewModel +import math +import os +import re class StatusHoverView: """Qt-facing adapter around status/hover view-model contracts.""" - def __init__(self, host, view_model: StatusHoverViewModel): - self.host = host - self.view_model = view_model + """Headless status-bar and hover formatting rules.""" + + def channel_hover_text(self, description, channel, value, format_spec): + return f'{description} {channel}: value={value:{format_spec}}' + + def object_hover_text(self, *, label_id, max_id, object_count): + return ( + f'Objects: ID={label_id}, max ID={max_id}, ' + f'num. of objects={object_count}' + ) + + def base_hover_text( + self, + *, + x, + y, + width, + height, + x_left, + y_top, + x_right, + y_bottom, + axis_index, + ): + return ( + f'x={x:d}, y={y:d} | ' + f'W={width:d}, H={height:d} | ' + f'x_left={x_left:d}, y_top={y_top:d} | ' + f'x_right={x_right:d}, y_bottom={y_bottom:d} | ' + f'(ax{axis_index})' + ) + + def replace_view_range_status( + self, + text, + *, + width, + height, + x_left, + y_top, + x_right, + y_bottom, + ): + pattern = ( + r'W=.*?, H=.*? \| ' + r'x_left=.*?, y_top=.*? \| ' + r'x_right=.*?, y_bottom=.*? \| ' + ) + replacing = ( + f'W={width:d}, H={height:d} | ' + f'x_left={x_left:d}, y_top={y_top:d} | ' + f'x_right={x_right:d}, y_bottom={y_bottom:d} | ' + ) + return re.sub(pattern, replacing, text) + + def highlight_state( + self, + *, + x, + y, + bbox, + enabled, + active_tool, + blocked_by_other_highlight=False, + ): + if not enabled or active_tool is not None or blocked_by_other_highlight: + return None + y_min, x_min, y_max, x_max = bbox + return x_min <= x <= x_max and y_min <= y <= y_max + def mouse_data_coords_right_image(self, text): + if not text: + return None + ax_idx = int(re.findall(r'\(ax(\d)\)', text)[0]) + if ax_idx == 0: + return None + coords = re.findall(r'x=(\d+), y=(\d+) \|', text)[0] + return tuple([int(val) for val in coords]) + + def ruler_length_text(self, text): + length_text = re.findall(r'length = (.*)\)', text)[0] + length_text = length_text.replace('pxl', 'pixels') + return f'{length_text})' + + def ruler_measurement_text(self, *, length_pixels, pixel_to_um): + return ( + f'length = {int(length_pixels)} pxl ' + f'({length_pixels*pixel_to_um:.2f} μm)' + ) + + def euclidean_length(self, x_values, y_values): + return math.sqrt( + (x_values[0]-x_values[1])**2 + (y_values[0]-y_values[1])**2 + ) + + def status_bar_text( + self, + *, + pos_foldername, + basename, + filename, + segm_npz_path, + ): + segmented_channel_name = filename[len(basename):] + segm_filename = os.path.basename(segm_npz_path) + segm_end_name = segm_filename[len(basename):] + return ( + f'{pos_foldername} || ' + f'Basename: {basename} || ' + f'Segmented channel: {segmented_channel_name} || ' + f'Segmentation file name: {segm_end_name}' + ) + + + def __init__(self, host): + self.host = host def channel_hover_values(self, descr, channel, value, ff=None): if ff is None: n_digits = len(str(int(value))) ff = self.host.view_model.formatting.number_fstring_formatter( type(value), precision=abs(n_digits-5) ) - return self.view_model.channel_hover_text(descr, channel, value, ff) + return self.channel_hover_text(descr, channel, value, ff) def add_overlay_hover_values_formatted(self, txt, xdata, ydata): pos_data = self.host.data[self.host.pos_i] @@ -60,7 +174,7 @@ def check_highlight_timestamp(self, x, y, active_tool_button): hasattr(self.host, 'scaleBar') and self.host.scaleBar.isHighlighted() ) - highlighted = self.view_model.highlight_state( + highlighted = self.highlight_state( x=x, y=y, bbox=self.host.timestamp.bbox(), @@ -75,7 +189,7 @@ def check_highlight_timestamp(self, x, y, active_tool_button): def check_highlight_scale_bar(self, x, y, active_tool_button): if not hasattr(self.host, 'scaleBar'): return - highlighted = self.view_model.highlight_state( + highlighted = self.highlight_state( x=x, y=y, bbox=self.host.scaleBar.bbox(), @@ -87,7 +201,7 @@ def check_highlight_scale_bar(self, x, y, active_tool_button): self.host.scaleBar.setHighlighted(highlighted) def mouse_data_coords_right_image(self): - return self.view_model.mouse_data_coords_right_image( + return self.mouse_data_coords_right_image( self.host.wcLabel.text() ) @@ -97,7 +211,7 @@ def update_values_status_bar(self): ) width = round(xr - xl) height = round(yb - yt) - txt = self.view_model.replace_view_range_status( + txt = self.replace_view_range_status( self.host.wcLabel.text(), width=width, height=height, @@ -115,7 +229,7 @@ def hover_values_formatted(self, xdata, ydata, active_tool_button, is_ax0): width = round(xr - xl) height = round(yb - yt) axis_index = 0 if is_ax0 else 1 - txt = self.view_model.base_hover_text( + txt = self.base_hover_text( x=xdata, y=ydata, width=width, @@ -141,7 +255,7 @@ def hover_values_formatted(self, xdata, ydata, active_tool_button, is_ax0): txt = self.add_overlay_hover_values_formatted(txt, xdata, ydata) label_id = self.host.currentLab2D[ydata, xdata] - label_txt = self.view_model.object_hover_text( + label_txt = self.object_hover_text( label_id=label_id, max_id=max(pos_data.IDs, default=0), object_count=len(pos_data.IDs), @@ -150,7 +264,7 @@ def hover_values_formatted(self, xdata, ydata, active_tool_button, is_ax0): return self.add_ruler_measurement_text(txt) def ruler_length_text(self): - return self.view_model.ruler_length_text(self.host.wcLabel.text()) + return self.ruler_length_text(self.host.wcLabel.text()) def add_ruler_measurement_text(self, txt): pos_data = self.host.data[self.host.pos_i] @@ -158,14 +272,14 @@ def add_ruler_measurement_text(self, txt): if xx is None: return txt - length_pixels = self.view_model.euclidean_length(xx, yy) + length_pixels = self.euclidean_length(xx, yy) depth_axes = self.host.switchPlaneCombobox.depthAxes() if depth_axes != 'z': pixel_to_um = pos_data.PhysicalSizeZ else: pixel_to_um = pos_data.PhysicalSizeX - length_txt = self.view_model.ruler_measurement_text( + length_txt = self.ruler_measurement_text( length_pixels=length_pixels, pixel_to_um=pixel_to_um, ) @@ -174,7 +288,7 @@ def add_ruler_measurement_text(self, txt): def set_status_bar_label(self, log=True): self.host.statusbar.clearMessage() pos_data = self.host.data[self.host.pos_i] - txt = self.view_model.status_bar_text( + txt = self.status_bar_text( pos_foldername=pos_data.pos_foldername, basename=pos_data.basename, filename=pos_data.filename, @@ -182,4 +296,4 @@ def set_status_bar_label(self, log=True): ) if log: self.host.logger.info(txt) - self.host.statusBarLabel.setText(txt) + self.host.statusBarLabel.setText(txt) \ No newline at end of file diff --git a/cellacdc/views/tool_activation_view.py b/cellacdc/views/tool_activation_view.py index 2f2770f13..07fd51adb 100644 --- a/cellacdc/views/tool_activation_view.py +++ b/cellacdc/views/tool_activation_view.py @@ -7,14 +7,54 @@ from cellacdc import apps, qutils, widgets, workers from cellacdc import disableWindow -from cellacdc.viewmodels.tool_activation_viewmodel import ( - ToolActivationViewModel, -) class ToolActivationView: """Qt-facing adapter around active-tool workflows.""" + """Headless decisions for active-tool and hover workflows.""" + + def manual_annotation_highlight_color( + self, + *, + current_frame_i: int, + frame_to_restore: int | None, + ) -> str: + if current_frame_i == frame_to_restore: + return 'green' + if frame_to_restore is not None and current_frame_i < frame_to_restore: + return 'gold' + return 'red' + + def should_highlight_hover_lost_object( + self, + *, + has_no_modifier: bool, + copy_lost_object_checked: bool, + is_exit_event: bool, + ) -> bool: + return ( + has_no_modifier + and copy_lost_object_checked + and not is_exit_event + ) + + def point_in_shape(self, x: int, y: int, shape: tuple[int, int]) -> bool: + height, width = shape + return x >= 0 and x < width and y >= 0 and y < height + + def should_hide_hover_objects( + self, + *, + brush_auto_hide_checked: bool, + force: bool, + ) -> bool: + return brush_auto_hide_checked or force + + def should_disable_non_functional_buttons(self, is_segm_3d: bool) -> bool: + return is_segm_3d + + LEGACY_METHODS = ( 'uncheckQButton', 'setUncheckedPointsLayers', @@ -55,15 +95,13 @@ class ToolActivationView: 'disableNonFunctionalButtons', ) - def __init__(self, host, view_model: ToolActivationViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -383,7 +421,7 @@ def updateHighlightedAxis(self): frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') posData = self.data[self.pos_i] - color = self.view_model.manual_annotation_highlight_color( + color = self.manual_annotation_highlight_color( current_frame_i=posData.frame_i, frame_to_restore=frame_to_restore, ) @@ -398,7 +436,7 @@ def updateLostNewCurrentIDs(self): curr_IDs = posData.IDs curr_delRoiIDs = self.getStoredDelRoiIDs() prev_delRoiIDs = self.getStoredDelRoiIDs(frame_i=posData.frame_i-1) - result = self.view_model.tracking.compute_lost_new_ids( + result = self.tracking.compute_lost_new_ids( prev_IDs, curr_IDs, current_deleted_roi_ids=curr_delRoiIDs, @@ -449,7 +487,7 @@ def addLostObjsToLostObjImage(self, lostObj, lostID, force=False): self.lostObjImage[obj_slice][obj_image] = lostID def highlightHoverLostObj(self, modifiers, event): - if not self.view_model.should_highlight_hover_lost_object( + if not self.should_highlight_hover_lost_object( has_no_modifier=modifiers == Qt.NoModifier, copy_lost_object_checked=self.copyLostObjButton.isChecked(), is_exit_event=event.isExit(), @@ -504,7 +542,7 @@ def getPrevFrameIDs(self, current_frame_i=None): frame_i=prev_frame_i, return_copy=False ) - return self.view_model.label_edits.label_ids_from_labels(prev_lab) + return self.label_edits.label_ids_from_labels(prev_lab) # @exec_time def setLostNewOldPrevIDs(self): @@ -536,7 +574,7 @@ def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): if isinstance(IDs, set): IDs = list(IDs) - trim_IDs = self.view_model.label_edits.format_trimmed_ids(IDs) + trim_IDs = self.label_edits.format_trimmed_ids(IDs) txt = f'{pretxt}: {trim_IDs}' txt_full = f'{pretxt}:
{IDs}' @@ -799,12 +837,12 @@ def hideItemsHoverBrush(self, xy=None, ID=None, force=False): xdata, ydata = int(x), int(y) Y, X = self.currentLab2D.shape - if not self.view_model.point_in_shape( + if not self.point_in_shape( xdata, ydata, self.currentLab2D.shape ): return - if not self.view_model.should_hide_hover_objects( + if not self.should_hide_hover_objects( brush_auto_hide_checked=self.brushAutoHideCheckbox.isChecked(), force=force, ): @@ -849,7 +887,7 @@ def updateBrushCursor(self, x, y, isHoverImg1=True): xdata, ydata = int(x), int(y) _img = self.currentLab2D - if not self.view_model.point_in_shape(xdata, ydata, _img.shape): + if not self.point_in_shape(xdata, ydata, _img.shape): return size = self.brushSizeSpinbox.value()*2 @@ -873,7 +911,7 @@ def setManualAnnotModeEnabledTools(self, enabled): action.setDisabled(enabled) def disableNonFunctionalButtons(self): - if not self.view_model.should_disable_non_functional_buttons( + if not self.should_disable_non_functional_buttons( self.isSegm3D ): return @@ -890,4 +928,4 @@ def disableNonFunctionalButtons(self): toolButton.setDisabled(True) else: action = item - action.setDisabled(True) + action.setDisabled(True) \ No newline at end of file diff --git a/cellacdc/views/tracking_view.py b/cellacdc/views/tracking_view.py index e4f9e85f9..4d8a2b106 100644 --- a/cellacdc/views/tracking_view.py +++ b/cellacdc/views/tracking_view.py @@ -15,7 +15,6 @@ from cellacdc import apps, exception_handler, html_utils, widgets from cellacdc.trackers.CellACDC import CellACDC_tracker -from cellacdc.viewmodels.tracking_viewmodel import TrackingViewModel font_13px = QFont() @@ -82,15 +81,13 @@ class TrackingView: 'manualBackground_cb', ) - def __init__(self, host, view_model: TrackingViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -100,13 +97,13 @@ def bind_legacy_methods(self): setattr(self.host, name, getattr(self, name)) def getLastTrackedFrame(self, posData): - return self.view_model.last_tracked_frame_index( + return self.last_tracked_frame_index( (data_dict['labels'] for data_dict in posData.allData_li), ) def get_last_tracked_i(self): posData = self.data[self.pos_i] - return self.view_model.last_tracked_frame_index( + return self.last_tracked_frame_index( (data_dict['labels'] for data_dict in posData.allData_li), total_frames=posData.segmSizeT, ) @@ -231,7 +228,7 @@ def initRealTimeTracker(self, force=False): if rtTrackerAction.isChecked(): break - aliases = self.view_model.model_registry.real_time_tracker_aliases( + aliases = self.model_registry.real_time_tracker_aliases( reverse=True ) @@ -253,7 +250,7 @@ def initRealTimeTracker(self, force=False): self._rtTrackerName = rtTracker posData = self.data[self.pos_i] realTimeTracker, track_frame_params = ( - self.view_model.model_registry.init_tracker( + self.model_registry.init_tracker( posData, rtTracker, qparent=self.host, realTime=True ) ) @@ -287,6 +284,80 @@ def realTimeTrackingClicked(self, checked): else: txt = html_utils.paragraph(""" + """Headless tracking state calculations.""" + + def compute_lost_new_ids( + self, + previous_ids, + current_ids, + *, + current_deleted_roi_ids=(), + previous_deleted_roi_ids=(), + tracked_lost_ids=(), + ) -> LostNewIdsResult: + return compute_lost_new_ids( + previous_ids, + current_ids, + current_deleted_roi_ids=current_deleted_roi_ids, + previous_deleted_roi_ids=previous_deleted_roi_ids, + tracked_lost_ids=tracked_lost_ids, + ) + + def tracked_lost_centroids_from_regionprops( + self, + regionprops, + tracked_lost_ids, + ) -> set[tuple[int, ...]]: + return tracked_lost_centroids_from_regionprops( + regionprops, + tracked_lost_ids, + ) + + def tracked_lost_ids_from_centroids( + self, + previous_labels, + tracked_lost_centroids, + ids_in_frame, + ) -> TrackedLostIdsResult: + return tracked_lost_ids_from_centroids( + previous_labels, + tracked_lost_centroids, + ids_in_frame, + ) + + def last_tracked_frame_index( + self, + frame_labels, + *, + first_frame_fallback: int = 0, + total_frames: int | None = None, + ) -> int: + return last_tracked_frame_index( + frame_labels, + first_frame_fallback=first_frame_fallback, + total_frames=total_frames, + ) + + def scan_future_id_propagation( + self, + target_id: int, + *, + current_frame_i: int, + frame_labels, + fallback_frame_labels, + include_unvisited: bool = False, + total_frames: int | None = None, + ) -> FutureIdPropagationScan: + return scan_future_id_propagation( + target_id, + current_frame_i=current_frame_i, + frame_labels=frame_labels, + fallback_frame_labels=fallback_frame_labels, + include_unvisited=include_unvisited, + total_frames=total_frames, + ) + + Do you want to keep tracking always active including on already visited frames?

Note: To re-activate automatic handling of tracking go to
@@ -331,7 +402,7 @@ def repeatTrackingVideo(self, checked=False): self.logger.info(f'Importing {trackerName} tracker...') self.tracker, self.track_params, init_params = ( - self.view_model.model_registry.init_tracker( + self.model_registry.init_tracker( posData, trackerName, qparent=self.host, return_init_params=True ) ) @@ -339,7 +410,7 @@ def repeatTrackingVideo(self, checked=False): self.logger.info('Tracking aborted.') return - warningText = self.view_model.model_registry.validate_tracker_input( + warningText = self.model_registry.validate_tracker_input( self.tracker, video_to_track ) if warningText is not None: @@ -454,7 +525,7 @@ def repeatTracking(self): prev_lab = self.get_2Dlab(posData.lab).copy() self.tracking(enforce=True, DoManualEdit=False) if posData.editID_info: - editedIDsInfo = self.view_model.edit_id.manual_edit_conflicts( + editedIDsInfo = self.edit_id.manual_edit_conflicts( posData.lab, posData.editID_info ) editedIDsInfoItems = [ @@ -800,7 +871,7 @@ def tracking( # First separate by labelling if separateByLabel: maxID = max(posData.IDs, default=1) - setRp = self.view_model.label_edits.split_connected_components( + setRp = self.label_edits.split_connected_components( posData.lab, regionprops=posData.rp, max_id=maxID, @@ -928,7 +999,7 @@ def annotateAssignedObjsAcdcTrackerSecondStep(self): y1, x1 = self.getObjCentroid(lostObj.centroid) y2, x2 = self.getObjCentroid(newObj.centroid) - xx, yy = self.view_model.geometry.line_coords( + xx, yy = self.geometry.line_coords( y1, x1, y2, x2, dashed=False ) self.ax1_oldMothBudLinesItem.addPoints(xx, yy) @@ -955,7 +1026,7 @@ def setTrackedLostCentroids(self, prev_rp, tracked_lost_IDs): posData = self.data[self.pos_i] frame_i = posData.frame_i centroids = ( - self.view_model.tracked_lost_centroids_from_regionprops( + self.tracked_lost_centroids_from_regionprops( prev_rp, tracked_lost_IDs, ) @@ -994,7 +1065,7 @@ def getTrackedLostIDs(self, prev_lab=None, IDs_in_frames=None, frame_i=None): except KeyError: tracked_lost_centroids = set() - result = self.view_model.tracked_lost_ids_from_centroids( + result = self.tracked_lost_ids_from_centroids( prev_lab, tracked_lost_centroids, IDs_in_frames, @@ -1006,7 +1077,7 @@ def getTrackedLostIDs(self, prev_lab=None, IDs_in_frames=None, frame_i=None): def manuallyEditTracking(self, tracked_lab, allIDs): posData = self.data[self.pos_i] - result = self.view_model.edit_id.apply_manual_edit_tracking( + result = self.edit_id.apply_manual_edit_tracking( tracked_lab, posData.editID_info, allIDs, @@ -1203,7 +1274,7 @@ def _drawGhostMask(self, x, y): bbox = ((Dy, Dy+h), (Dx, Dx+w)) Y, X = self.currentLab2D.shape - slices = self.view_model.geometry.local_to_global_slices(bbox, (Y, X)) + slices = self.geometry.local_to_global_slices(bbox, (Y, X)) slice_global_to_local, slice_crop_local = slices obj_image = self.ghostObject.image[slice_crop_local] @@ -1379,4 +1450,4 @@ def manualBackground_cb(self, checked): self.removeManualTrackingItems() self.clearGhost() self.clearManualBackgroundAnnotations() - self.manualBackgroundToolbar.setVisible(checked) + self.manualBackgroundToolbar.setVisible(checked) \ No newline at end of file diff --git a/cellacdc/views/undo_redo_view.py b/cellacdc/views/undo_redo_view.py index 268a3e65e..7d42637ee 100644 --- a/cellacdc/views/undo_redo_view.py +++ b/cellacdc/views/undo_redo_view.py @@ -5,9 +5,9 @@ import uuid from cellacdc import apps, html_utils, widgets -from cellacdc.viewmodels.undo_redo_viewmodel import UndoRedoViewModel +from collections import defaultdict class UndoRedoView: """Qt-facing adapter around undo/redo actions and state restoration.""" @@ -27,15 +27,13 @@ class UndoRedoView: 'redo', ) - def __init__(self, host, view_model: UndoRedoViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -49,19 +47,45 @@ def clearUndoQueue(self): self.UndoCount = 0 self.redoAction.setEnabled(False) self.undoAction.setEnabled(False) - posData.UndoRedoStates = self.view_model.empty_frame_stacks( + posData.UndoRedoStates = self.empty_frame_stacks( posData.SizeT ) - posData.UndoRedoCcaStates = self.view_model.empty_frame_stacks( + posData.UndoRedoCcaStates = self.empty_frame_stacks( posData.SizeT ) if hasattr(self, 'undoAddPointQueueMapper'): self.undoAddPointQueueMapper = ( - self.view_model.empty_add_point_queue() + self.empty_add_point_queue() ) def askPropagateChangePast(self, change_txt): txt = html_utils.paragraph(f""" + + """Headless undo/redo stack operations.""" + + def empty_frame_stacks(self, size_t: int) -> list[list]: + return [[] for _ in range(size_t)] + + def empty_add_point_queue(self): + return defaultdict(list) + + def trim_stack(self, states: list, *, max_size: int) -> None: + if len(states) > max_size: + states.pop(-1) + + def can_undo_labels(self, undo_count: int, states: list) -> bool: + return undo_count < len(states) - 1 + + def can_redo_labels(self, undo_count: int) -> bool: + return undo_count > 0 + + def should_disable_undo_after_cca( + self, + undo_count: int, + states: list, + ) -> bool: + return len(states) > undo_count + Do you want to propagate the change "{change_txt}" to the past frames? """) msg = widgets.myMessageBox(wrapText=False) @@ -283,7 +307,7 @@ def storeUndoRedoStates( self.reInitLastSegmFrame(updateImages=False) # Keep only 5 Undo/Redo states - self.view_model.trim_label_states( + self.trim_label_states( posData.UndoRedoStates[posData.frame_i] ) @@ -315,7 +339,7 @@ def storeUndoRedoCca(self, frame_i, cca_df, undoId): self.addCcaState(frame_i, cca_df, undoId) # Keep only 10 Undo/Redo states - self.view_model.trim_cca_states(posData.UndoRedoCcaStates[frame_i]) + self.trim_cca_states(posData.UndoRedoCcaStates[frame_i]) def undoCustomAnnotation(self): pass @@ -339,7 +363,7 @@ def UndoCca(self): self.updateAllImages() # Check if we have undone all states - if self.view_model.should_disable_undo_after_cca( + if self.should_disable_undo_after_cca( self.UndoCount, currentCcaStates ): # There are no states left to undo for current frame_i @@ -386,7 +410,7 @@ def undo(self): posData = self.data[self.pos_i] # Get previously stored state - if self.view_model.can_undo_labels( + if self.can_undo_labels( self.UndoCount, posData.UndoRedoStates[posData.frame_i], ): @@ -400,7 +424,7 @@ def undo(self): self.updateAllImages(image=image_left) self.store_data() - if not self.view_model.can_undo_labels( + if not self.can_undo_labels( self.UndoCount, posData.UndoRedoStates[posData.frame_i], ): @@ -413,7 +437,7 @@ def undo(self): def redo(self): posData = self.data[self.pos_i] # Get previously stored state - if self.view_model.can_redo_labels(self.UndoCount): + if self.can_redo_labels(self.UndoCount): self.UndoCount -= 1 # Since we have redone then it is possible to undo self.undoAction.setEnabled(True) @@ -424,9 +448,9 @@ def redo(self): self.updateAllImages(image=image_left) self.store_data() - if not self.view_model.can_redo_labels(self.UndoCount): + if not self.can_redo_labels(self.UndoCount): # We have redone all available states self.redoAction.setEnabled(False) if self.whitelistIDsButton.isChecked(): - self.whitelistHighlightIDs() + self.whitelistHighlightIDs() \ No newline at end of file diff --git a/cellacdc/views/whitelist_view.py b/cellacdc/views/whitelist_view.py index e7713fa53..c27da2909 100644 --- a/cellacdc/views/whitelist_view.py +++ b/cellacdc/views/whitelist_view.py @@ -5,6 +5,9 @@ import os import json import numpy as np +from dataclasses import dataclass +from typing import Set, List +import numpy as np import skimage.measure from typing import Set, List, Tuple, Any import time @@ -14,7 +17,6 @@ ) from cellacdc.trackers.CellACDC import CellACDC_tracker from cellacdc.whitelist import Whitelist -from cellacdc.viewmodels.whitelist_viewmodel import WhitelistViewModel class WhitelistView: @@ -43,15 +45,13 @@ class WhitelistView: 'whitelistUpdateTempLayer', ) - def __init__(self, host, view_model: WhitelistViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -69,8 +69,99 @@ def whitelistCheckOriginalLabels(self, warning:bool=True, if frame_i is None: frame_i = posData.frame_i - if not self.view_model.check_original_labels(posData.whitelist, frame_i): + if not self.check_original_labels(posData.whitelist, frame_i): txt = """ + + """Headless decisions and calculations for Whitelist management.""" + + def filter_existing_ids(self, current_whitelist: Set[int], possible_ids: Set[int]) -> tuple[Set[int], bool]: + """Filters out non-existing IDs from the current whitelist. + + Returns a tuple: (filtered_whitelist, is_any_id_non_existing) + """ + is_any_id_non_existing = False + filtered_whitelist = set(current_whitelist) + for ID in current_whitelist: + if ID not in possible_ids: + is_any_id_non_existing = True + filtered_whitelist.discard(ID) + return filtered_whitelist, is_any_id_non_existing + + def get_missing_ids(self, current_ids: Set[int], previous_ids: Set[int]) -> Set[int]: + """Returns the set of IDs present in current frame but missing from previous frame.""" + return set(current_ids) - set(previous_ids) + + def check_original_labels(self, whitelist_obj, frame_i: int) -> bool: + """Checks if original label data is allocated and valid for the frame.""" + if whitelist_obj is None: + return False + if whitelist_obj.originalLabsIDs is None: + return False + if frame_i >= len(whitelist_obj.originalLabsIDs) or whitelist_obj.originalLabsIDs[frame_i] is None: + return False + return True + + def get_frames_range(self, frame_i: int) -> list[int]: + """Calculates navigation frame ranges for label loading.""" + if frame_i > 0: + return [frame_i - 1, frame_i] + return [frame_i] + + def get_diff_ids(self, old_ids: Set[int], prev_ids: Set[int], new_ids: Set[int]) -> Set[int]: + """Computes tracking difference intersection (new_ids - old_ids) & prev_ids.""" + return (new_ids - old_ids) & prev_ids + + def get_whitelist_missing_and_removed_ids(self, whitelist: Set[int], current_ids: Set[int]) -> tuple[list[int], list[int]]: + """Finds IDs that are missing from current_ids and IDs to be removed from current_ids.""" + missing_ids = list(whitelist - current_ids) + to_be_removed_ids = list(current_ids - whitelist) + return missing_ids, to_be_removed_ids + + def apply_id_mask( + self, + curr_lab: np.ndarray, + og_lab: np.ndarray | None, + missing_ids: list[int] | np.ndarray, + to_be_removed_ids: list[int] | np.ndarray + ) -> np.ndarray: + """Applies missing and removed ID masks to the label array.""" + updated_lab = curr_lab.copy().astype(np.int32) + missing_ids = np.array(missing_ids, dtype=np.int32) + to_be_removed_ids = np.array(to_be_removed_ids, dtype=np.int32) + + if missing_ids.size > 0 and og_lab is not None: + mask = np.isin(og_lab, missing_ids) + updated_lab[mask] = og_lab[mask] + + if to_be_removed_ids.size > 0: + updated_lab[np.isin(updated_lab, to_be_removed_ids)] = 0 + + return updated_lab + + def construct_og_frame( + self, + pos_lab: np.ndarray, + og_frame_base: np.ndarray, + whitelist_ids: Set[int], + og_ids: Set[int] + ) -> np.ndarray: + """Constructs original labels overlay using np.isin masking.""" + og_frame = og_frame_base.copy() + + ids_to_update = whitelist_ids & og_ids + if ids_to_update: + mask = np.isin(og_frame, list(ids_to_update)) + og_frame[mask] = 0 + mask = np.isin(pos_lab, list(ids_to_update)) + og_frame[mask] = pos_lab[mask] + + ids_to_add = whitelist_ids - og_ids + if ids_to_add: + mask = np.isin(pos_lab, list(ids_to_add)) + og_frame[mask] = pos_lab[mask] + + return og_frame + No original labels are present for the current frame, this action cannot be performed.""" self.logger.warning(txt) @@ -95,7 +186,7 @@ def whitelistTrackOGagainstPreviousFrame_cb(self, signal_slot=None): self.whitelistTrackOGCurr(against_prev=True) new_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] - new_IDs = self.view_model.get_diff_ids( + new_IDs = self.get_diff_ids( old_cell_IDs, set(prev_cell_IDs), new_cell_IDs ) @@ -163,7 +254,7 @@ def whitelistViewOGIDs(self, checked:bool): printl('whitelistViewOGIDs', checked) frame_i = posData.frame_i - frames_range = self.view_model.get_frames_range(frame_i) + frames_range = self.get_frames_range(frame_i) self.store_data(autosave=False) @@ -178,7 +269,7 @@ def whitelistViewOGIDs(self, checked:bool): self.get_data() self.whitelistTrackOGCurr(frame_i=i) - posData.lab = self.view_model.construct_og_frame( + posData.lab = self.construct_og_frame( pos_lab=posData.lab, og_frame_base=posData.whitelist.originalLabs[i], whitelist_ids=posData.whitelist.whitelistIDs[i], @@ -188,7 +279,7 @@ def whitelistViewOGIDs(self, checked:bool): self.store_data(autosave=False) if frame_i > 0: - missing_IDs = self.view_model.get_missing_ids(posData.IDs, posData.allData_li[frame_i-1]['IDs']) + missing_IDs = self.get_missing_ids(posData.IDs, posData.allData_li[frame_i-1]['IDs']) self.trackManuallyAddedObject(missing_IDs, isNewID=True, wl_update=False) self.setAllTextAnnotations() @@ -372,7 +463,7 @@ def whitelistUpdateLab(self, frame_i: int=None, if not IDs_to_add_remove_provided: self.get_data() got_data = True - missing_IDs, to_be_removed_IDs = self.view_model.get_whitelist_missing_and_removed_ids( + missing_IDs, to_be_removed_IDs = self.get_whitelist_missing_and_removed_ids( whitelist, set(posData.IDs) ) else: @@ -433,7 +524,7 @@ def whitelistUpdateLab(self, frame_i: int=None, if benchmark: ts.append(time.perf_counter()) - curr_lab = self.view_model.apply_id_mask( + curr_lab = self.apply_id_mask( curr_lab, og_lab, missing_IDs, to_be_removed_IDs ) @@ -784,7 +875,7 @@ def whitelistIDsChanged(self, possible_IDs.update(posData.IDs) # Delegate validation of existing IDs to viewmodel/model - filtered_whitelist, isAnyIDnotExisting = self.view_model.filter_existing_ids( + filtered_whitelist, isAnyIDnotExisting = self.filter_existing_ids( whitelistIDs, possible_IDs ) @@ -846,4 +937,4 @@ def whitelistUpdateTempLayer(self): keptLab[_slice][_objMask] = obj.label - self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) + self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) \ No newline at end of file diff --git a/cellacdc/views/window_events_view.py b/cellacdc/views/window_events_view.py index 97a17c8f2..0cac5bdaf 100644 --- a/cellacdc/views/window_events_view.py +++ b/cellacdc/views/window_events_view.py @@ -13,7 +13,6 @@ from cellacdc import apps, exception_handler, html_utils, is_mac, printl, qutils, widgets from cellacdc.plot import imshow -from cellacdc.viewmodels.window_events_viewmodel import WindowEventsViewModel _font = QFont() @@ -75,15 +74,13 @@ class WindowEventsView: 'gui_createCursors', ) - def __init__(self, host, view_model: WindowEventsViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -215,7 +212,7 @@ def leaveEvent(self, event): mainWinGeometry = self.geometry() slideshowWinGeometry = self.slideshowWin.geometry() - overlap = self.view_model.windows_overlap_from_bounds( + overlap = self.windows_overlap_from_bounds( main_left=mainWinGeometry.left(), main_top=mainWinGeometry.top(), main_width=mainWinGeometry.width(), @@ -223,7 +220,7 @@ def leaveEvent(self, event): other_left=slideshowWinGeometry.left(), other_top=slideshowWinGeometry.top(), ) - autoActivate = self.view_model.should_auto_activate_viewer( + autoActivate = self.should_auto_activate_viewer( is_data_loaded=self.isDataLoaded, windows_overlap=overlap, disable_auto_activate=posData.disableAutoActivateViewerWindow, @@ -240,7 +237,7 @@ def enterEvent(self, event): mainWinGeometry = self.geometry() slideshowWinGeometry = self.slideshowWin.geometry() - overlap = self.view_model.windows_overlap_from_bounds( + overlap = self.windows_overlap_from_bounds( main_left=mainWinGeometry.left(), main_top=mainWinGeometry.top(), main_width=mainWinGeometry.width(), @@ -248,7 +245,7 @@ def enterEvent(self, event): other_left=slideshowWinGeometry.left(), other_top=slideshowWinGeometry.top(), ) - autoActivate = self.view_model.should_auto_activate_viewer( + autoActivate = self.should_auto_activate_viewer( is_data_loaded=self.isDataLoaded, windows_overlap=overlap, disable_auto_activate=posData.disableAutoActivateViewerWindow, @@ -259,7 +256,7 @@ def enterEvent(self, event): self.activateWindow() def isPanImageClick(self, mouseEvent, modifiers): - return self.view_model.is_pan_image_click( + return self.is_pan_image_click( mouse_button=mouseEvent.button(), left_button=Qt.MouseButton.LeftButton, modifiers=modifiers, @@ -268,13 +265,13 @@ def isPanImageClick(self, mouseEvent, modifiers): def middleClickText(self): if self.delObjAction is None and is_mac: - return self.view_model.middle_click_text( + return self.middle_click_text( has_del_object_action=False, is_mac=is_mac, ) if self.delObjAction is None: - return self.view_model.middle_click_text( + return self.middle_click_text( has_del_object_action=False, is_mac=is_mac, ) @@ -293,7 +290,7 @@ def middleClickText(self): else: keySequenceText = delObjKeySequence.toString() - return self.view_model.middle_click_text( + return self.middle_click_text( has_del_object_action=True, is_mac=is_mac, button_name=buttonName, @@ -301,7 +298,7 @@ def middleClickText(self): ) def isDefaultMiddleClick(self, mouseEvent, modifiers): - return self.view_model.is_default_middle_click( + return self.is_default_middle_click( mouse_button=mouseEvent.button(), modifiers=modifiers, is_mac=is_mac, @@ -317,7 +314,7 @@ def isMiddleClick(self, mouseEvent, modifiers): delObjKeySequence, delObjQtButton = self.delObjAction mouseEventButton = self.changeRightClickToLeftOnMac(mouseEvent) - return self.view_model.is_configured_middle_click( + return self.is_configured_middle_click( mouse_button=mouseEventButton, configured_button=delObjQtButton, key_sequence_is_none=delObjKeySequence is None, @@ -457,6 +454,9 @@ def checkOverlayToolbuttonClicked(self, event): def keyPressCheckSetSpinboxValue(self, event, spinbox): """Check if the key pressed is a digit and set the spinbox value + + """Headless placeholder for main-window event rules.""" + accordingly.""" try: n = int(event.text()) @@ -1095,4 +1095,4 @@ def gui_createCursors(self): self.polyLineRoiCursor = QCursor(pixmap, 16, 16) pixmap = QPixmap(":cross_cursor.svg") - self.addPointsCursor = QCursor(pixmap, 16, 16) + self.addPointsCursor = QCursor(pixmap, 16, 16) \ No newline at end of file diff --git a/cellacdc/views/worker_view.py b/cellacdc/views/worker_view.py index 49632ae0b..3377e6207 100644 --- a/cellacdc/views/worker_view.py +++ b/cellacdc/views/worker_view.py @@ -11,7 +11,6 @@ from qtpy.QtCore import QObject, QMutex, QThread, QTimer, QWaitCondition from cellacdc import apps, exception_handler, html_utils, issues_url, widgets, workers -from cellacdc.viewmodels.worker_viewmodel import WorkerViewModel class WorkerView: @@ -50,15 +49,13 @@ class WorkerView: 'workerDebug', ) - def __init__(self, host, view_model: WorkerViewModel): + def __init__(self, host): object.__setattr__(self, 'host', host) - object.__setattr__(self, 'view_model', view_model) - def __getattr__(self, name): return getattr(self.host, name) def __setattr__(self, name, value): - if name in {'host', 'view_model'}: + if name in {'host'}: object.__setattr__(self, name, value) else: setattr(self.host, name, value) @@ -180,7 +177,7 @@ def autoSaveWorkerStartTimer(self, worker, posData): self.autoSaveWorkerTimer.start(150) def autoSaveWorkerTimerCallback(self, worker, posData): - if self.view_model.should_enqueue_autosave(self.isSaving): + if self.should_enqueue_autosave(self.isSaving): self.autoSaveWorkerTimer.stop() worker._enqueue(posData) @@ -198,7 +195,7 @@ def autoSaveWorkerClosed(self, worker): def workerProgress(self, text, loggerLevel='INFO'): # used in cca and lin tree if self.progressWin is not None: self.progressWin.logConsole.append(text) - loggerLevel = self.view_model.progress_log_level(loggerLevel) + loggerLevel = self.progress_log_level(loggerLevel) self.logger.log(getattr(logging, loggerLevel), text) def workerFinished(self): @@ -221,7 +218,7 @@ def savePreprocWorkerFinished(self): self.titleLabel.setText('Pre-processed data saved!', color='w') def loadingNewChunk(self, chunk_range): - desc = self.view_model.lazy_loader_progress_description(chunk_range) + desc = self.lazy_loader_progress_description(chunk_range) self.progressWin = apps.QDialogWorkerProgress( title='Loading data...', parent=self.host, pbarDesc=desc ) @@ -262,6 +259,36 @@ def ccaIntegrityWorkerCritical(self, error): href = f'GitHub page' txt = html_utils.paragraph(f""" + + """Headless worker progress and lifecycle decisions.""" + + def progress_log_level(self, logger_level: str = 'INFO') -> str: + return logger_level or 'INFO' + + def progressbar_maximum(self, total_iterations: int) -> int: + if total_iterations == 1: + return 0 + return total_iterations + + def lazy_loader_progress_description(self, chunk_range) -> str: + coord0_chunk, coord1_chunk = chunk_range + return ( + f'Loading new window, range = ({coord0_chunk}, {coord1_chunk})...' + ) + + def should_enqueue_autosave(self, is_saving: bool) -> bool: + return not is_saving + + def should_disable_realtime_tracking( + self, + tracking_on_never_visited_frames: bool, + realtime_tracking_enabled: bool, + ) -> bool: + return ( + tracking_on_never_visited_frames + and realtime_tracking_enabled + ) + Unfortunately the experimental feature check cell cycle annotations integrity raised a critical error.

@@ -320,7 +347,7 @@ def trackingWorkerFinished(self): self.progressWin = None self.logger.info('Worker process ended.') askDisableRealTimeTracking = ( - self.view_model.should_disable_realtime_tracking( + self.should_disable_realtime_tracking( self.trackingWorker.trackingOnNeverVisitedFrames, self.realTimeTrackingToggle.isChecked(), ) @@ -367,7 +394,7 @@ def trackingWorkerFinished(self): def workerInitProgressbar(self, totalIter): self.progressWin.mainPbar.setValue(0) - maximum = self.view_model.progressbar_maximum(totalIter) + maximum = self.progressbar_maximum(totalIter) self.progressWin.mainPbar.setMaximum(maximum) def workerUpdateProgressbar(self, step): @@ -375,7 +402,7 @@ def workerUpdateProgressbar(self, step): def workerInitInnerPbar(self, totalIter): self.progressWin.innerPbar.setValue(0) - maximum = self.view_model.progressbar_maximum(totalIter) + maximum = self.progressbar_maximum(totalIter) self.progressWin.innerPbar.setMaximum(maximum) def workerUpdateInnerPbar(self, step): @@ -472,4 +499,4 @@ def workerDebug(self, item): tracked_video, worker = item from cellacdc.plot import imshow imshow(tracked_video) - worker.waitCond.wakeAll() + worker.waitCond.wakeAll() \ No newline at end of file From 57c65d9b36af065ac4074ef54141d7d9d656817d Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 09:53:19 +0200 Subject: [PATCH 04/21] chore: restore cellacdc/gui.py to main branch --- cellacdc/gui.py | 33079 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 32922 insertions(+), 157 deletions(-) diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 79039258f..7247f462e 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -10,11 +10,14 @@ import inspect import logging import uuid +import json from collections import defaultdict, Counter +import psutil import zipfile from functools import partial +from tqdm import tqdm from natsort import natsorted -from typing import Literal, Iterable, Dict, Any, List, Union, Set +from typing import Literal, Iterable, Dict, Any, List, Union, Tuple, Set import time import cv2 @@ -22,10 +25,12 @@ import numpy as np import pandas as pd import matplotlib +import scipy.optimize import scipy.interpolate import scipy.ndimage import skimage import skimage.io +import skimage.measure import skimage.morphology import skimage.draw import skimage.exposure @@ -37,12 +42,12 @@ from qtpy.QtCore import ( Qt, QPoint, QTextStream, QSize, QRect, QRectF, - QEventLoop, QTimer, QEvent, Signal, + QEventLoop, QTimer, QEvent, QObject, Signal, QThread, QMutex, QWaitCondition, QSettings, PYQT6 ) from qtpy.QtGui import ( - QIcon, QCursor, QGuiApplication, QColor, - QFont, QMouseEvent + QIcon, QKeySequence, QCursor, QGuiApplication, QPixmap, QColor, + QFont, QKeyEvent, QMouseEvent ) from qtpy.QtWidgets import ( QAction, QLabel, QPushButton, QHBoxLayout, QSizePolicy, @@ -59,31 +64,42 @@ simplefilter(action="ignore", category=pd.errors.PerformanceWarning) # Custom modules -from . import ( - base_cca_dict, -) +from . import exception_handler, disableWindow +from . import base_cca_dict, lineage_tree_cols, lineage_tree_cols_std_val from . import graphLayoutBkgrColor, darkBkgrColor from . import cca_df_colnames from . import load, prompts, apps, workers, html_utils from . import core, myutils, dataPrep, widgets -from . import _warnings +from . import _warnings, issues_url from . import measurements, printl from . import colors, annotate from . import user_manual_url -from . import settings_folderpath, settings_csv_path +from . import recentPaths_path, settings_folderpath, settings_csv_path from . import favourite_func_metrics_csv_path from . import qutils, autopilot, QtScoped +from . import _palettes +from . import transformation +from . import measure +from . import cca_functions from . import data_structure_docs_url from . import exporters +from . import preprocess from . import io from . import whitelist from . import cli +from . import is_mac from .trackers.CellACDC import CellACDC_tracker +from .cca_functions import _calc_rot_vol from .myutils import exec_time, setupLogger, ArgSpec +from .help import welcome, about +from .trackers.CellACDC_normal_division.CellACDC_normal_division_tracker import ( + normal_division_lineage_tree)#, reorg_sister_cells_for_export) from . import debugutils + from .plot import imshow from . import gui_utils +from . import gui_combine np.seterr(invalid='ignore') @@ -96,6 +112,11 @@ except Exception as e: pass +GREEN_HEX = _palettes.green() + +custom_annot_path = os.path.join(settings_folderpath, 'custom_annotations.json') +shortcut_filepath = os.path.join(settings_folderpath, 'shortcuts.ini') + _font = QFont() _font.setPixelSize(11) @@ -108,10 +129,80 @@ SliderPageStepSub = QtScoped.SliderPageStepSub() SliderMove = QtScoped.SliderMove() -from .viewmodels import MainGuiViewModel +def qt_debug_trace(): + from qtpy.QtCore import pyqtRemoveInputHook + pyqtRemoveInputHook() + import pdb; pdb.set_trace() +def get_data_exception_handler(func): + @wraps(func) + def inner_function(self, *args, **kwargs): + try: + if func.__code__.co_argcount==1 and func.__defaults__ is None: + result = func(self) + elif func.__code__.co_argcount>1 and func.__defaults__ is None: + result = func(self, *args) + else: + result = func(self, *args, **kwargs) + except Exception as e: + try: + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + except AttributeError: + pass + result = None + posData = self.data[self.pos_i] + acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) + segm_filename = os.path.basename(posData.segm_npz_path) + traceback_str = traceback.format_exc() + self.logger.exception(traceback_str) + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...') + msg.setDetailedText(traceback_str) + err_msg = html_utils.paragraph(f""" + Error in function {func.__name__}.

+ One possbile explanation is that either the + {acdc_df_filename} file
+ or the segmentation file {segm_filename}
+ are being synchronized by a cloud service (e.g., Google Drive + or OneDrive) or they are corrupted/damaged.

+ Try moving these files (one by one) outside of the + {os.path.dirname(posData.relPath)} folder +
and reloading the data.

+ More details below or in the terminal/console.

+ Note that the error details from this session are + also saved in the following file:

+ {self.log_path}

+ Please send the log file when reporting a bug, thanks! + Please restart Cell-ACDC, we apologise for any inconvenience.

-class guiWin(QMainWindow): + """) + + msg.critical(self, 'Critical error', err_msg) + self.is_error_state = True + raise e + return result + return inner_function + +def resetViewRange(func): + @wraps(func) + def inner_function(self, *args, **kwargs): + self.storeViewRange() + if func.__code__.co_argcount==1 and func.__defaults__ is None: + result = func(self) + elif func.__code__.co_argcount>1 and func.__defaults__ is None: + result = func(self, *args) + else: + result = func(self, *args, **kwargs) + QTimer.singleShot(200, self.resetRange) + return result + return inner_function + +class guiWin(QMainWindow, whitelist.WhitelistGUIElements, + gui_combine.CombineGuiElements, + gui_combine.CombineGUIWorker): """Main Window.""" sigClosed = Signal(object) @@ -138,92 +229,7 @@ def __init__( self.mainWin = mainWin self.app = app self.closeGUI = False - self.view_model = MainGuiViewModel() - self.window_events_view = WindowEventsView(self) - self.window_events_view.bind_legacy_methods() - self.tracking_view = TrackingView(self) - self.tracking_view.bind_legacy_methods() - self.image_display_view = ImageDisplayView(self) - self.image_display_view.bind_legacy_methods() - self.data_loading_view = DataLoadingView(self) - self.data_loading_view.bind_legacy_methods() - self.cell_cycle_view = CellCycleView(self) - self.cell_cycle_view.bind_legacy_methods() - self.graphics_view = GraphicsView(self) - self.graphics_view.bind_legacy_methods() - self.actions_view = ActionsView(self) - self.actions_view.bind_legacy_methods() - self.app_shell_view = AppShellView(self) - self.app_shell_view.bind_legacy_methods() - self.annotation_display_view = AnnotationDisplayView(self) - self.annotation_display_view.bind_legacy_methods() - self.session_view = SessionView(self) - self.session_view.bind_legacy_methods() - self.frame_navigation_view = FrameNavigationView(self) - self.frame_navigation_view.bind_legacy_methods() - self.canvas_drawing_view = CanvasDrawingView(self) - self.canvas_drawing_view.bind_legacy_methods() - self.canvas_events_view = CanvasEventsView(self) - self.canvas_events_view.bind_legacy_methods() - self.canvas_selection_view = CanvasSelectionView(self) - self.canvas_selection_view.bind_legacy_methods() - self.canvas_context_menu_view = CanvasContextMenuView(self) - self.canvas_right_image_view = CanvasRightImageView(self) - self.canvas_hover_view = CanvasHoverView(self) - self.canvas_hover_view.bind_legacy_methods() - self.label_roi_view = LabelRoiView(self) - self.label_roi_view.bind_legacy_methods() - self.label_editing_view = LabelEditingView(self) - self.label_editing_view.bind_legacy_methods() - self.lineage_interactions_view = LineageInteractionsView(self) - self.lineage_interactions_view.bind_legacy_methods() - self.custom_annotations_view = CustomAnnotationsView(self) - self.undo_redo_view = UndoRedoView(self) - self.undo_redo_view.bind_legacy_methods() - self.worker_view = WorkerView(self) - self.worker_view.bind_legacy_methods() - self.brush_tools_view = BrushToolsView(self) - self.brush_tools_view.bind_legacy_methods() - self.deleted_rois_view = DeletedRoisView(self) - self.deleted_rois_view.bind_legacy_methods() - self.draw_clear_region_view = DrawClearRegionView(self) - self.display_decorations_view = DisplayDecorationsView(self) - self.object_cleanup_view = ObjectCleanupView(self) - self.object_properties_view = ObjectPropertiesView(self) - self.object_properties_view.bind_legacy_methods() - self.object_search_view = ObjectSearchView(self) - self.curvature_tools_view = CurvatureToolsView( - self, - self.view_model.curvature, - ) - self.seg_for_lost_ids_view = SegForLostIdsView(self) - self.segmentation_view = SegmentationView(self) - self.segmentation_view.bind_legacy_methods() - self.saving_view = SavingView(self) - self.saving_view.bind_legacy_methods() - self.mode_controls_view = ModeControlsView(self) - self.image_controls_view = ImageControlsView(self) - self.preprocessing_view = PreprocessingView(self) - self.magic_prompts_view = MagicPromptsView(self) - self.exporting_view = ExportingView(self) - self.main_toolbar_view = MainToolbarView(self) - self.main_menu_view = MainMenuView(self) - self.label_transform_tools_view = LabelTransformToolsView(self) - self.measurements_view = MeasurementsView(self) - self.quick_settings_view = QuickSettingsView(self) - self.status_hover_view = StatusHoverView(self) - self.points_layers_view = PointsLayersView(self) - self.points_layers_view.bind_legacy_methods() - self.tool_activation_view = ToolActivationView(self) - self.tool_activation_view.bind_legacy_methods() - self.layout_controls_view = LayoutControlsView(self) - self.layout_controls_view.bind_legacy_methods() - self.combine_view = CombineView(self) - self.combine_view.bind_legacy_methods() - self.whitelist_view = WhitelistView(self) - self.whitelist_view.bind_legacy_methods() - self.canvas_tool_view = CanvasToolView(self) - self._acdc_version = self.view_model.app_shell.read_version() + self._acdc_version = myutils.read_version() self.setAcceptDrops(True) self._appName = 'Cell-ACDC' @@ -234,6 +240,29 @@ def __init__( self.original_df_lin_tree = None self.original_df_lin_tree_i = None + def setTooltips(self): + tooltips = load.get_tooltips_from_docs() + + for key, tooltip in tooltips.items(): + setShortcut = getattr(self, key).shortcut().toString() + if 'Shortcut: ' in tooltip: + tooltip = tooltip.replace('Shortcut: ', '\nShortcut: ') + elif setShortcut != "": + tooltip = re.sub( + r'Shortcut: \"(.*)\"', + f"Shortcut: \"{setShortcut}\"", + tooltip + ) + else: + tooltip = re.sub( + r'Shortcut: \"(.*)\"', + f"Shortcut: \"No shortcut\"", + tooltip + ) + + getattr(self, key).setToolTip(tooltip) + getattr(self, key)._tooltip = tooltip + def run(self, module='acdc_gui', logs_path=None): self.setWindowIcon() self.setWindowTitle() @@ -345,13 +374,13 @@ def run(self, module='acdc_gui', logs_path=None): self.gui_createCursors() self.gui_createActions() - self.main_menu_view.create_menu_bar() + self.gui_createMenuBar() - self.main_toolbar_view.gui_createToolBars() + self.gui_createToolBars() self.gui_createControlsToolbar() - self.quick_settings_view.create_show_props_button() + self.gui_createShowPropsButton() self.gui_createRegionPropsDockWidget() - self.quick_settings_view.create_widgets() + self.gui_createQuickSettingsWidgets() self.setTooltips() self.gui_populateToolSettingsMenu() @@ -362,12 +391,12 @@ def run(self, module='acdc_gui', logs_path=None): self.gui_createStatusBar() # self.gui_createTerminalWidget() - self.image_controls_view.gui_createGraphicsPlots() + self.gui_createGraphicsPlots() self.gui_addGraphicsItems() - self.image_controls_view.gui_createImg1Widgets() - self.image_controls_view.gui_createLabWidgets() - self.image_controls_view.gui_createBottomWidgetsToBottomLayout() + self.gui_createImg1Widgets() + self.gui_createLabWidgets() + self.gui_createBottomWidgetsToBottomLayout() mainContainer = QWidget() self.setCentralWidget(mainContainer) @@ -385,58 +414,32794 @@ def run(self, module='acdc_gui', logs_path=None): self.show() QTimer.singleShot(100, self.resizeRangeWelcomeText) # self.installEventFilter(self) - + self.logger.info('GUI ready.') + + def initGlobalAttr(self): + self.setOverlayColors() + + self.initImgCmap() + + # Colormap + self.setLut() + + self.fluoDataChNameActions = [] + + self.splineHoverON = False + self.tempSegmentON = False + self.xyOnCtrlPressedFirstTime = None + self.typingEditID = False + self.prevAnnotOptions = None + self.ghostObject = None + self.autoContourHoverON = False + self.navigateScrollBarStartedMoving = True + self.zSliceScrollBarStartedMoving = True + self.labelRoiRunning = False + self.isRangeReset = True + self.lastManualSeparateState = None + self.editIDmergeIDs = True + self.doNotAskAgainExistingID = False + self.doubleRightClickTimeElapsed = False + self.isRealTimeTrackerInitialized = False + self.isWarningCcaIntegrity = False + self.isDoubleRightClick = False + self.isExportingVideo = False + self.pointsLayersNeverToggled = True + self.highlightedIDopts = None + self.timestampStartTimedelta = timedelta(seconds=0) + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + self._ZprojWidgersEnabledState = None + self.imgValueFormatter = 'd' + self.rawValueFormatter = 'd' + self.lastHoverID = -1 + self.annotOptionsToRestore = None + self.annotOptionsToRestoreRight = None + self.rescaleIntensChannelHowMapper = { + self.user_ch_name: 'Rescale each 2D image' + } + self.timestampDialog = None + self.scaleBarDialog = None + self.countObjsWindow = None + self.initLabelRoiModelDialog = None + + # Second channel used by cellpose + self.secondChannelName = None + + self.ax1_viewRange = None + self.measurementsWin = None + + self.model_kwargs = None + self.segmModelName = None + self.labelRoiModel = None + self.autoSegmDoNotAskAgain = False + self.labelRoiGarbageWorkers = [] + self.labelRoiActiveWorkers = [] + + self.clickedOnBud = False + self.postProcessSegmWin = None + + self.UserEnforced_DisabledTracking = False + self.UserEnforced_Tracking = False + + self.ax1BrushHoverID = 0 + + self.disabled_cca_warnings = set() + + self.last_pos_i = -1 + self.last_frame_i = -1 + + # Plots items + self.isMouseDragImg2 = False + self.isMouseDragImg1 = False + self.isMovingLabel = False + self.isRightClickDragImg1 = False + self.clickObjYc, self.clickObjXc = None, None + + self.cca_df_colnames = cca_df_colnames + self.cca_df_dtypes = [ + str, int, int, str, int, int, bool, bool, int + ] + self.cca_df_default_values = list(base_cca_dict.values()) + self.cca_df_int_cols = [ + col for col in cca_df_colnames if type(base_cca_dict[col]) == int + ] + self.lin_tree_df_bool_col = [ + col for col in cca_df_colnames + if isinstance(base_cca_dict[col], bool) + ] + + self.lin_tree_col_checks = [ + 'generation_num', + ] + + # self.lin_tree_df_colnames = set(base_cca_df.keys()) | set(lineage_tree_cols) + # # self.lin_tree_df_dtypes = [ #dk if i need this, for now ignored + # # str, int, int, str, int, int, bool, bool, int + # # ] + # self.lin_tree_df_default_values = list(base_cca_df.values()) + lineage_tree_cols_std_val + self.lin_tree_df_int_cols = [ + 'generation_num', + 'relative_ID', + 'emerg_frame_i', + 'division_frame_i', + 'corrected_on_frame_i' + ] + self.lin_tree_df_bool_col = [ + 'is_history_known', + ] + + self.lin_tree_col_checks = [ + 'generation_num', + ] + + self.lin_tree_df_colnames = self.lin_tree_df_int_cols + self.lin_tree_df_bool_col + self.lin_tree_col_checks + self.SegForLostIDsSettings = {} + + def setWindowIcon(self, icon=None): + if icon is None: + icon = QIcon(":icon.ico") + super().setWindowIcon(icon) + + def setWindowTitle(self, title=None): + if title is None: + title = f'Cell-ACDC v{self._acdc_version} - GUI' + super().setWindowTitle(title) + + def initProfileModels(self): + self.logger.info('Initiliazing profilers...') + + from ._profile.spline_to_obj import model + + self.splineToObjModel = model.Model() + + self.splineToObjModel.fit() + + def setDisabled(self, disabled:bool, keepDisabled:bool=None, force:bool=False): + if force: + if disabled: + super().setDisabled(disabled) + return + else: + self.keepDisabled = False + super().setDisabled(disabled) + return + + if keepDisabled is not None: + self.keepDisabled = keepDisabled + + if self.keepDisabled: + if disabled: + super().setDisabled(disabled) + return + else: + return + else: + super().setDisabled(disabled) + + def readRecentPaths(self, recent_paths_path=None): + # Step 0. Remove the old options from the menu + self.openRecentMenu.clear() + + # Step 1. Read recent Paths + if recent_paths_path is None: + recent_paths_path = recentPaths_path + + if os.path.exists(recent_paths_path): + df = pd.read_csv(recent_paths_path, index_col='index') + df['path'] = df['path'].str.replace('\\', '/') + df = df.drop_duplicates(subset=['path']) + df.to_csv(recent_paths_path) + if 'opened_last_on' in df.columns: + df = df.sort_values('opened_last_on', ascending=False) + recentPaths = df['path'].to_list() + else: + recentPaths = [] + + # Step 2. Dynamically create the actions + actions = [] + for path in recentPaths: + if not os.path.exists(path): + continue + action = QAction(path, self) + action.triggered.connect(partial(self.openRecentFile, path)) + actions.append(action) + + # Step 3. Add the actions to the menu + self.openRecentMenu.addActions(actions) + + def addPathToOpenRecentMenu(self, path): + for action in self.openRecentMenu.actions(): + if path == action.text(): + break + else: + action = QAction(path, self) + action.triggered.connect(partial(self.openRecentFile, path)) + + try: + firstAction = self.openRecentMenu.actions()[0] + self.openRecentMenu.insertAction(firstAction, action) + except Exception as e: + pass + + def loadLastSessionSettings(self): + self.settings_csv_path = settings_csv_path + if os.path.exists(settings_csv_path): + self.df_settings = pd.read_csv( + settings_csv_path, index_col='setting' + ) + if 'is_bw_inverted' not in self.df_settings.index: + self.df_settings.at['is_bw_inverted', 'value'] = 'No' + else: + self.df_settings.loc['is_bw_inverted'] = ( + self.df_settings.loc['is_bw_inverted'].astype(str) + ) + if 'fontSize' not in self.df_settings.index: + self.df_settings.at['fontSize', 'value'] = 12 + if 'overlayColor' not in self.df_settings.index: + self.df_settings.at['overlayColor', 'value'] = '255-255-0' + if 'how_normIntensities' not in self.df_settings.index: + raw = 'Do not normalize. Display raw image' + self.df_settings.at['how_normIntensities', 'value'] = raw + else: + idx = ['is_bw_inverted', 'fontSize', 'overlayColor', 'how_normIntensities'] + values = ['No', 12, '255-255-0', 'raw'] + self.df_settings = pd.DataFrame({ + 'setting': idx,'value': values} + ).set_index('setting') + + if 'isLabelsVisible' not in self.df_settings.index: + self.df_settings.at['isLabelsVisible', 'value'] = 'No' + + if 'isNextFrameVisible' not in self.df_settings.index: + self.df_settings.at['isNextFrameVisible', 'value'] = 'No' + + if 'isRightImageVisible' not in self.df_settings.index: + self.df_settings.at['isRightImageVisible', 'value'] = 'Yes' + + if 'manual_separate_draw_mode' not in self.df_settings.index: + col = 'manual_separate_draw_mode' + self.df_settings.at[col, 'value'] = 'threepoints_arc' + + if 'colorScheme' in self.df_settings.index: + col = 'colorScheme' + self._colorScheme = self.df_settings.at[col, 'value'] + else: + self._colorScheme = 'light' + + self.doNotShowAgainMissingCca = False + if 'doNotShowAgainMissingCca' not in self.df_settings.index: + self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'No' + else: + val = self.df_settings.at['doNotShowAgainMissingCca', 'value'] + self.doNotShowAgainMissingCca = val=='Yes' + + def dragEnterEvent(self, event): + file_path = event.mimeData().urls()[0].toLocalFile() + if os.path.isdir(file_path): + exp_path = file_path + basename = os.path.basename(file_path) + if basename.find('Position_')!=-1 or basename=='Images': + event.acceptProposedAction() + else: + event.ignore() + else: + event.acceptProposedAction() + + def dropEvent(self, event): + event.setDropAction(Qt.CopyAction) + file_path = event.mimeData().urls()[0].toLocalFile() + self.logger.info(f'Dragged and dropped path "{file_path}"') + basename = os.path.basename(file_path) + if os.path.isdir(file_path): + exp_path = file_path + self.openFolder(exp_path=exp_path) + else: + self.openFile(file_path=file_path) + + def changeEvent(self, event): + try: + self.delObjToolAction.setChecked(False) + except Exception as err: + return + + def leaveEvent(self, event): + if self.slideshowWin is not None: + posData = self.data[self.pos_i] + mainWinGeometry = self.geometry() + mainWinLeft = mainWinGeometry.left() + mainWinTop = mainWinGeometry.top() + mainWinWidth = mainWinGeometry.width() + mainWinHeight = mainWinGeometry.height() + mainWinRight = mainWinLeft+mainWinWidth + mainWinBottom = mainWinTop+mainWinHeight + + slideshowWinGeometry = self.slideshowWin.geometry() + slideshowWinLeft = slideshowWinGeometry.left() + slideshowWinTop = slideshowWinGeometry.top() + slideshowWinWidth = slideshowWinGeometry.width() + slideshowWinHeight = slideshowWinGeometry.height() + + # Determine if overlap + overlap = ( + (slideshowWinTop < mainWinBottom) and + (slideshowWinLeft < mainWinRight) + ) + + autoActivate = ( + self.isDataLoaded and not + overlap and not + posData.disableAutoActivateViewerWindow + ) + + if autoActivate: + self.slideshowWin.setFocus() + self.slideshowWin.activateWindow() + + def enterEvent(self, event): + event.accept() + if self.slideshowWin is not None: + posData = self.data[self.pos_i] + mainWinGeometry = self.geometry() + mainWinLeft = mainWinGeometry.left() + mainWinTop = mainWinGeometry.top() + mainWinWidth = mainWinGeometry.width() + mainWinHeight = mainWinGeometry.height() + mainWinRight = mainWinLeft+mainWinWidth + mainWinBottom = mainWinTop+mainWinHeight + + slideshowWinGeometry = self.slideshowWin.geometry() + slideshowWinLeft = slideshowWinGeometry.left() + slideshowWinTop = slideshowWinGeometry.top() + slideshowWinWidth = slideshowWinGeometry.width() + slideshowWinHeight = slideshowWinGeometry.height() + + # Determine if overlap + overlap = ( + (slideshowWinTop < mainWinBottom) and + (slideshowWinLeft < mainWinRight) + ) + + autoActivate = ( + self.isDataLoaded and not + overlap and not + posData.disableAutoActivateViewerWindow + ) + + if autoActivate: + # self.setFocus() + self.activateWindow() + + def isPanImageClick(self, mouseEvent, modifiers): + left_click = mouseEvent.button() == Qt.MouseButton.LeftButton + return modifiers == Qt.AltModifier and left_click + + def middleClickText(self): + if self.delObjAction is None and is_mac: + return 'Command + Left Click' + + if self.delObjAction is None: + return 'Middle Click' + + delObjKeySequence, delObjQtButton = self.delObjAction + + if delObjQtButton == Qt.MouseButton.LeftButton: + buttonName = 'Left click' + elif delObjQtButton == Qt.MouseButton.RightButton: + buttonName = 'Right click' + else: + buttonName = 'Middle click' + + if delObjKeySequence is None: + return buttonName + + return f'{delObjKeySequence.toString()} + {buttonName}' + + def isDefaultMiddleClick(self, mouseEvent, modifiers): + if is_mac: + middle_click = ( + mouseEvent.button() == Qt.MouseButton.LeftButton + and modifiers == Qt.ControlModifier + and not self.brushButton.isChecked() + ) + else: + middle_click = mouseEvent.button() == Qt.MouseButton.MiddleButton + return middle_click + + def isMiddleClick(self, mouseEvent, modifiers): + if self.delObjAction is None: + return self.isDefaultMiddleClick(mouseEvent, modifiers) + + delObjKeySequence, delObjQtButton = self.delObjAction + if delObjKeySequence is None: + # Setting only middle click on mac is allowed, however the + # delObjKeySequence is None and the tool button is never checked + isDelObjectActive = True + else: + isDelObjectActive = self.delObjToolAction.isChecked() + + mouseEventButton = self.changeRightClickToLeftOnMac(mouseEvent) + + middle_click = ( + mouseEventButton == delObjQtButton and isDelObjectActive + ) + + return middle_click + + def gui_createCursors(self): + pixmap = QPixmap(":wand_cursor.svg") + self.wandCursor = QCursor(pixmap, 16, 16) + + pixmap = QPixmap(":curv_cursor.svg") + self.curvCursor = QCursor(pixmap, 16, 16) + + pixmap = QPixmap(":addDelPolyLineRoi_cursor.svg") + self.polyLineRoiCursor = QCursor(pixmap, 16, 16) + + pixmap = QPixmap(":cross_cursor.svg") + self.addPointsCursor = QCursor(pixmap, 16, 16) + + def gui_createMenuBar(self): + menuBar = self.menuBar() + menuBar.setNativeMenuBar(False) + + # File menu + fileMenu = QMenu("&File", self) + self.fileMenu = fileMenu + menuBar.addMenu(fileMenu) + if self.debug: + fileMenu.addAction(self.createEmptyDataAction) + fileMenu.addAction(self.newAction) + fileMenu.addAction(self.newWindowAction) + fileMenu.addSeparator() + fileMenu.addAction(self.openFolderAction) + fileMenu.addAction(self.openFileAction) + # Open Recent submenu + self.openRecentMenu = fileMenu.addMenu("Open Recent") + fileMenu.addSeparator() + fileMenu.addAction(self.manageVersionsAction) + fileMenu.addAction(self.saveAction) + fileMenu.addAction(self.saveAsAction) + fileMenu.addAction(self.quickSaveAction) + fileMenu.addSeparator() + + self.exportMenu = fileMenu.addMenu('Export') + self.exportMenu.addAction(self.exportToVideoAction) + self.exportMenu.addAction(self.exportToImageAction) + fileMenu.addSeparator() + fileMenu.addAction(self.loadFluoAction) + fileMenu.addAction(self.loadPosAction) + # Separator + self.fileMenu.lastSeparator = fileMenu.addSeparator() + fileMenu.addAction(self.exitAction) + + # Edit menu + editMenu = menuBar.addMenu("&Edit") + editMenu.addSeparator() + + editMenu.addAction(self.editShortcutsAction) + editMenu.addAction(self.editTextIDsColorAction) + editMenu.addAction(self.editOverlayColorAction) + editMenu.addAction(self.manuallyEditCcaAction) + editMenu.addAction(self.enableSmartTrackAction) + editMenu.addAction(self.enableAutoZoomToCellsAction) + + # View menu + self.viewMenu = menuBar.addMenu("&View") + self.viewMenu.addSeparator() + self.viewMenu.addAction(self.viewCcaTableAction) + + # Image menu + ImageMenu = menuBar.addMenu("&Image") + ImageMenu.addSeparator() + ImageMenu.addAction(self.imgPropertiesAction) + self.defaultRescaleIntensLutMenu = ImageMenu.addMenu( + "Default method to rescale intensities (LUT)" + ) + + self.defaultRescaleIntensActionGroup = QActionGroup( + self.defaultRescaleIntensLutMenu + ) + howTexts = ( + 'Rescale each 2D image', + 'Rescale across z-stack', + 'Rescale across time frames', + 'Do no rescale, display raw image' + ) + try: + self.defaultRescaleIntensHow = ( + self.df_settings.at['default_rescale_intens_how', 'value'] + ) + except Exception as err: + self.defaultRescaleIntensHow = howTexts[0] + + for howText in howTexts: + action = QAction(howText, self.defaultRescaleIntensLutMenu) + action.setCheckable(True) + if howText == self.defaultRescaleIntensHow: + action.setChecked(True) + + self.defaultRescaleIntensActionGroup.addAction(action) + self.defaultRescaleIntensLutMenu.addAction(action) + + ImageMenu.addAction(self.addScaleBarAction) + ImageMenu.addAction(self.addTimestampAction) + + self.rescaleIntensMenu = ImageMenu.addMenu('Rescale intensities (LUT)') + + ImageMenu.addAction(self.preprocessAction) + ImageMenu.addAction(self.combineChannelsAction) + ImageMenu.addAction(self.saveLabColormapAction) + ImageMenu.addAction(self.shuffleCmapAction) + ImageMenu.addAction(self.greedyShuffleCmapAction) + ImageMenu.addAction(self.zoomToObjsAction) + ImageMenu.addAction(self.zoomOutAction) + + # Segment menu + SegmMenu = menuBar.addMenu("&Segment") + self.segmentMenu = SegmMenu + SegmMenu.addSeparator() + self.segmSingleFrameMenu = SegmMenu.addMenu('Segment displayed frame') + for action in self.segmActions: + self.segmSingleFrameMenu.addAction(action) + + self.segmSingleFrameMenu.addSeparator() + self.segmSingleFrameMenu.addAction(self.addCustomModelFrameAction) + + self.segmVideoMenu = SegmMenu.addMenu('Segment multiple frames') + for action in self.segmActionsVideo: + self.segmVideoMenu.addAction(action) + + self.segmVideoMenu.addSeparator() + self.segmVideoMenu.addAction(self.addCustomModelVideoAction) + + self.segmWithPromptableModelMenu = SegmMenu.addMenu( + 'Segment with promptable model' + ) + + self.segmWithPromptableModelMenu.addAction( + self.segmWithPromptableModelAction + ) + + self.segmWithPromptableModelMenu.addSeparator() + self.segmWithPromptableModelMenu.addAction( + self.addCustomPromptModelAction + ) + + SegmMenu.addAction(self.EditSegForLostIDsSetSettings) + SegmMenu.addAction(self.postProcessSegmAction) + SegmMenu.addAction(self.autoSegmAction) + SegmMenu.addAction(self.relabelSequentialAction) + SegmMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) + + # Tracking menu + trackingMenu = menuBar.addMenu("&Tracking") + self.trackingMenu = trackingMenu + trackingMenu.addSeparator() + selectTrackAlgoMenu = trackingMenu.addMenu( + 'Select real-time tracking algorithm' + ) + for rtTrackerAction in self.trackingAlgosGroup.actions(): + selectTrackAlgoMenu.addAction(rtTrackerAction) + + trackingMenu.addAction(self.editRtTrackerParamsAction) + trackingMenu.addAction(self.repeatTrackingVideoAction) + + trackingMenu.addAction(self.repeatTrackingMenuAction) + trackingMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) + + if self.mainWin is not None: + trackingMenu.addAction( + self.mainWin.applyTrackingFromTableAction + ) + trackingMenu.addAction( + self.mainWin.applyTrackingFromTrackMateXMLAction + ) + + # Measurements menu + measurementsMenu = menuBar.addMenu("&Measurements") + self.measurementsMenu = measurementsMenu + measurementsMenu.addSeparator() + measurementsMenu.addAction(self.setMeasurementsAction) + measurementsMenu.addAction(self.addCustomMetricAction) + measurementsMenu.addAction(self.addCombineMetricAction) + measurementsMenu.setDisabled(True) + # Settings menu + self.settingsMenu = QMenu("Settings", self) + menuBar.addMenu(self.settingsMenu) + self.settingsMenu.addAction(self.invertBwAction) + self.settingsMenu.addAction(self.toggleColorSchemeAction) + self.settingsMenu.addSeparator() + self.settingsMenu.addAction(self.pxModeAction) + self.settingsMenu.addAction(self.highLowResAction) + self.settingsMenu.addAction(self.editShortcutsAction) + self.settingsMenu.addAction(self.showMirroredCursorAction) + self.settingsMenu.addSeparator() + self.settingsMenu.addAction(self.editAutoSaveIntervalAction) + self.settingsMenu.addSeparator() -from .views.combine_view import CombineView -from .views.whitelist_view import WhitelistView -from .views.app_shell_view import AppShellView -from .views.actions_view import ActionsView -from .views.brush_tools_view import BrushToolsView -from .views.annotation_display_view import AnnotationDisplayView -from .views.canvas_context_menu_view import CanvasContextMenuView -from .views.canvas_drawing_view import CanvasDrawingView -from .views.canvas_events_view import CanvasEventsView -from .views.canvas_selection_view import CanvasSelectionView -from .views.canvas_right_image_view import CanvasRightImageView -from .views.canvas_tool_view import CanvasToolView -from .views.canvas_hover_view import CanvasHoverView -from .views.cell_cycle_view import CellCycleView -from .views.custom_annotations_view import CustomAnnotationsView -from .views.curvature_tools_view import CurvatureToolsView -from .views.display_decorations_view import DisplayDecorationsView -from .views.status_hover_view import StatusHoverView -from .views.deleted_rois_view import DeletedRoisView -from .views.data_loading_view import DataLoadingView -from .views.draw_clear_region_view import DrawClearRegionView -from .views.exporting_view import ExportingView -from .views.graphics_view import GraphicsView -from .views.frame_navigation_view import FrameNavigationView -from .views.image_controls_view import ImageControlsView -from .views.image_display_view import ImageDisplayView -from .views.label_editing_view import LabelEditingView -from .views.label_roi_view import LabelRoiView -from .views.label_transform_tools_view import LabelTransformToolsView -from .views.lineage_interactions_view import LineageInteractionsView -from .views.magic_prompts_view import MagicPromptsView -from .views.measurements_view import MeasurementsView -from .views.layout_controls_view import LayoutControlsView -from .views.main_menu_view import MainMenuView -from .views.mode_controls_view import ModeControlsView -from .views.object_search_view import ObjectSearchView -from .views.object_properties_view import ObjectPropertiesView -from .views.object_cleanup_view import ObjectCleanupView -from .views.points_layers_view import PointsLayersView -from .views.preprocessing_view import PreprocessingView -from .views.quick_settings_view import QuickSettingsView -from .views.saving_view import SavingView -from .views.seg_for_lost_ids_view import SegForLostIdsView -from .views.segmentation_view import SegmentationView -from .views.session_view import SessionView -from .views.tool_activation_view import ToolActivationView -from .views.main_toolbar_view import MainToolbarView -from .views.tracking_view import TrackingView -from .views.undo_redo_view import UndoRedoView -from .views.window_events_view import WindowEventsView -from .views.worker_view import WorkerView + # Mode menu (actions added when self.modeComboBox is created) + self.modeMenu = menuBar.addMenu('Mode') + self.modeMenu.menuAction().setVisible(False) + + # Help menu + helpMenu = menuBar.addMenu("&Help") + helpMenu.addAction(self.openLogFileAction) + helpMenu.addAction(self.showLogFilesAction) + helpMenu.addAction(self.tipsAction) + helpMenu.addAction(self.UserManualAction) + helpMenu.addSeparator() + helpMenu.addAction(self.aboutAction) + self.helpMenu = helpMenu + + def gui_createToolBars(self): + # File toolbar + fileToolBar = self.addToolBar("File") + # fileToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) + fileToolBar.setMovable(False) + + self.segmNdimIndicatorAction = fileToolBar.addWidget( + self.segmNdimIndicator + ) + self.segmNdimIndicatorAction.setVisible(False) + fileToolBar.addAction(self.newAction) + fileToolBar.addAction(self.openFolderAction) + fileToolBar.addAction(self.openFileAction) + fileToolBar.addAction(self.manageVersionsAction) + fileToolBar.addAction(self.saveAction) + fileToolBar.addAction(self.showInExplorerAction) + # fileToolBar.addAction(self.reloadAction) + fileToolBar.addAction(self.undoAction) + fileToolBar.addAction(self.redoAction) + self.fileToolBar = fileToolBar + self.setEnabledFileToolbar(False) + + self.undoAction.setEnabled(False) + self.redoAction.setEnabled(False) + + # Navigation toolbar + navigateToolBar = widgets.ToolBar("Navigation", self) + navigateToolBar.setContextMenuPolicy(Qt.PreventContextMenu) + # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) + self.addToolBar(navigateToolBar) + navigateToolBar.addAction(self.findIdAction) + + navigateToolBar.addWidget(self.zoomRectButton) + + self.slideshowButton = QToolButton(self) + self.slideshowButton.setIcon(QIcon(":eye-plus.svg")) + self.slideshowButton.setCheckable(True) + self.slideshowButton.setShortcut('Ctrl+W') + navigateToolBar.addWidget(self.slideshowButton) + + navigateToolBar.addAction(self.autoPilotButton) + + # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) + navigateToolBar.addAction(self.skipToNewIdAction) + + self.preprocessImageAction = QAction('Preprocess image', self) + self.preprocessImageAction.setIcon(QIcon(":filter_image.svg")) + navigateToolBar.addAction(self.preprocessImageAction) + + self.overlayButton = widgets.rightClickToolButton(parent=self) + self.overlayButton.setIcon(QIcon(":overlay.svg")) + self.overlayButton.setCheckable(True) + + self.overlayButtonAction = navigateToolBar.addWidget(self.overlayButton) + # self.checkableButtons.append(self.overlayButton) + # self.checkableQButtonsGroup.addButton(self.overlayButton) + + self.countObjsButton = QToolButton(self) + self.countObjsButton.setIcon(QIcon(":count_objects.svg")) + self.countObjsButton.setCheckable(True) + self.countObjsButton.setShortcut('Ctrl+Shift+C') + self.countObjsButtonAction = navigateToolBar.addWidget( + self.countObjsButton + ) + + self.togglePointsLayerAction = QAction('Activate points layer', self) + self.togglePointsLayerAction.setCheckable(True) + self.togglePointsLayerAction.setIcon(QIcon(":pointsLayer.svg")) + navigateToolBar.addAction(self.togglePointsLayerAction) + + self.overlayLabelsButton = widgets.rightClickToolButton(parent=self) + self.overlayLabelsButton.setIcon(QIcon(":overlay_labels.svg")) + self.overlayLabelsButton.setCheckable(True) + # self.overlayLabelsButton.setVisible(False) + self.overlayLabelsButtonAction = navigateToolBar.addWidget( + self.overlayLabelsButton + ) + self.overlayLabelsButtonAction.setVisible(False) + + self.rulerButton = QToolButton(self) + self.rulerButton.setIcon(QIcon(":ruler.svg")) + self.rulerButton.setCheckable(True) + navigateToolBar.addWidget(self.rulerButton) + self.checkableButtons.append(self.rulerButton) + self.LeftClickButtons.append(self.rulerButton) + + # fluorescence image color widget + colorsToolBar = widgets.ToolBar("Colors", self) + + self.overlayColorButton = pg.ColorButton(self, color=(230,230,230)) + self.overlayColorButton.setDisabled(True) + colorsToolBar.addWidget(self.overlayColorButton) + + self.textIDsColorButton = pg.ColorButton(self) + colorsToolBar.addWidget(self.textIDsColorButton) + + self.addToolBar(colorsToolBar) + colorsToolBar.setVisible(False) + + self.navigateToolBar = navigateToolBar + + # cca toolbar + ccaToolBar = widgets.ToolBar("Cell cycle annotations", self) + self.addToolBar(ccaToolBar) + + # Assign mother to bud button + self.assignBudMothButton = QToolButton(self) + self.assignBudMothButton.setIcon(QIcon(":assign-motherbud.svg")) + self.assignBudMothButton.setCheckable(True) + self.assignBudMothButton.setShortcut('A') + self.assignBudMothButton.setVisible(False) + self.assignBudMothButton.action = ccaToolBar.addWidget( + self.assignBudMothButton + ) + self.checkableButtons.append(self.assignBudMothButton) + self.checkableQButtonsGroup.addButton(self.assignBudMothButton) + self.functionsNotTested3D.append(self.assignBudMothButton) + + + # Set is_history_known button + self.setIsHistoryKnownButton = QToolButton(self) + self.setIsHistoryKnownButton.setIcon(QIcon(":history.svg")) + self.setIsHistoryKnownButton.setCheckable(True) + self.setIsHistoryKnownButton.setShortcut('U') + self.setIsHistoryKnownButton.setVisible(False) + self.setIsHistoryKnownButton.action = ccaToolBar.addWidget( + self.setIsHistoryKnownButton + ) + self.checkableButtons.append(self.setIsHistoryKnownButton) + self.checkableQButtonsGroup.addButton(self.setIsHistoryKnownButton) + self.functionsNotTested3D.append(self.setIsHistoryKnownButton) + + ccaToolBar.addAction(self.assignBudMothAutoAction) + ccaToolBar.addAction(self.editCcaToolAction) + ccaToolBar.addAction(self.reInitCcaAction) + ccaToolBar.setVisible(False) + self.ccaToolBar = ccaToolBar + self.functionsNotTested3D.append(self.assignBudMothAutoAction) + self.functionsNotTested3D.append(self.reInitCcaAction) + self.functionsNotTested3D.append(self.editCcaToolAction) + + # Edit toolbar + editToolBar = widgets.ToolBar("Edit", self) + editToolBar.setContextMenuPolicy(Qt.PreventContextMenu) + + self.addToolBar(editToolBar) + + self.manulAnnotToolButtons = set() + + self.brushButton = QToolButton(self) + self.brushButton.setIcon(QIcon(":brush.svg")) + self.brushButton.setCheckable(True) + editToolBar.addWidget(self.brushButton) + self.checkableButtons.append(self.brushButton) + self.LeftClickButtons.append(self.brushButton) + self.brushButton.keyPressShortcut = Qt.Key_B + self.widgetsWithShortcut['Brush'] = self.brushButton + self.manulAnnotToolButtons.add(self.brushButton) + + self.eraserButton = QToolButton(self) + self.eraserButton.setIcon(QIcon(":eraser.svg")) + self.eraserButton.setCheckable(True) + editToolBar.addWidget(self.eraserButton) + self.eraserButton.keyPressShortcut = Qt.Key_X + self.widgetsWithShortcut['Eraser'] = self.eraserButton + self.checkableButtons.append(self.eraserButton) + self.LeftClickButtons.append(self.eraserButton) + self.manulAnnotToolButtons.add(self.eraserButton) + + self.curvToolButton = QToolButton(self) + self.curvToolButton.setIcon(QIcon(":curvature-tool.svg")) + self.curvToolButton.setCheckable(True) + self.curvToolButton.setShortcut('C') + self.curvToolButton.action = editToolBar.addWidget(self.curvToolButton) + self.LeftClickButtons.append(self.curvToolButton) + # self.functionsNotTested3D.append(self.curvToolButton) + self.widgetsWithShortcut['Curvature tool'] = self.curvToolButton + # self.checkableButtons.append(self.curvToolButton) + self.manulAnnotToolButtons.add(self.curvToolButton) + + self.wandToolButton = QToolButton(self) + self.wandToolButton.setIcon(QIcon(":magic_wand.svg")) + self.wandToolButton.setCheckable(True) + self.wandToolButton.setShortcut('Ctrl+D') + self.wandToolButton.action = editToolBar.addWidget(self.wandToolButton) + self.LeftClickButtons.append(self.wandToolButton) + self.checkableButtons.append(self.eraserButton) + self.widgetsWithShortcut['Magic wand'] = self.wandToolButton + + self.magicPromptsToolButton = QToolButton(self) + self.magicPromptsToolButton.setIcon(QIcon(":magic-prompts.svg")) + self.magicPromptsToolButton.setCheckable(True) + self.magicPromptsToolButton.setShortcut('W') + self.magicPromptsToolButton.action = editToolBar.addWidget( + self.magicPromptsToolButton + ) + self.widgetsWithShortcut['Magic prompts'] = self.magicPromptsToolButton + + self.drawClearRegionButton = QToolButton(self) + self.drawClearRegionButton.setCheckable(True) + self.drawClearRegionButton.setIcon(QIcon(":clear_freehand_region.svg")) + self.widgetsWithShortcut['Clear freehand region'] = ( + self.drawClearRegionButton + ) + self.toolsActiveInProj3Dsegm.add(self.drawClearRegionButton) + + self.checkableButtons.append(self.drawClearRegionButton) + self.LeftClickButtons.append(self.drawClearRegionButton) + + self.drawClearRegionAction = editToolBar.addWidget( + self.drawClearRegionButton + ) + + self.widgetsWithShortcut['Annotate mother/daughter pairing'] = ( + self.assignBudMothButton + ) + self.widgetsWithShortcut['Annotate unknown history'] = ( + self.setIsHistoryKnownButton + ) + + self.copyLostObjButton = QToolButton(self) + self.copyLostObjButton.setIcon(QIcon(":copyContour.svg")) + self.copyLostObjButton.setCheckable(True) + self.copyLostObjButton.setShortcut('V') + self.copyLostObjButton.action = editToolBar.addWidget( + self.copyLostObjButton + ) + self.checkableButtons.append(self.copyLostObjButton) + self.checkableQButtonsGroup.addButton(self.copyLostObjButton) + self.widgetsWithShortcut['Copy lost object contour'] = ( + self.copyLostObjButton + ) + self.functionsNotTested3D.append(self.copyLostObjButton) + + self.labelRoiButton = widgets.rightClickToolButton(parent=self) + self.labelRoiButton.setIcon(QIcon(":label_roi.svg")) + self.labelRoiButton.setCheckable(True) + self.labelRoiButton.setShortcut('L') + self.labelRoiButton.action = editToolBar.addWidget(self.labelRoiButton) + self.LeftClickButtons.append(self.labelRoiButton) + self.checkableButtons.append(self.labelRoiButton) + self.checkableQButtonsGroup.addButton(self.labelRoiButton) + self.widgetsWithShortcut['Label ROI'] = self.labelRoiButton + # self.functionsNotTested3D.append(self.labelRoiButton) + + self.manualAnnotPastButton = QToolButton(self) + self.manualAnnotPastButton.setIcon(QIcon(":lock_id_annotate_future.svg")) + self.manualAnnotPastButton.setCheckable(True) + self.manualAnnotPastButton.setShortcut('Y') + self.manualAnnotPastButton.action = editToolBar.addWidget( + self.manualAnnotPastButton + ) + self.checkableButtons.append(self.manualAnnotPastButton) + self.widgetsWithShortcut['Lock ID and annotate single object'] = ( + self.manualAnnotPastButton + ) + self.functionsNotTested3D.append(self.manualAnnotPastButton) + self.manulAnnotToolButtons.add(self.manualAnnotPastButton) + + self.segmentToolAction = QAction('Segment with last used model', self) + self.segmentToolAction.setIcon(QIcon(":segment.svg")) + self.segmentToolAction.setShortcut('R') + self.widgetsWithShortcut['Repeat segmentation'] = self.segmentToolAction + editToolBar.addAction(self.segmentToolAction) + + self.segForLostIDsButton = QToolButton(self) + self.segForLostIDsButton.setIcon(QIcon(":segForLostIDs.svg")) + self.segForLostIDsAction = editToolBar.addWidget( + self.segForLostIDsButton + ) + self.segForLostIDsButton.clicked.connect( + self.segForLostIDsButtonClicked + ) + + # self.SegForLostIDsButton.setShortcut('U') + # self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.SegForLostIDsButton + + self.manualBackgroundButton = QToolButton(self) + self.manualBackgroundButton.setIcon(QIcon(":manual_background.svg")) + self.manualBackgroundButton.setCheckable(True) + self.manualBackgroundButton.setShortcut('G') + self.LeftClickButtons.append(self.manualBackgroundButton) + self.checkableButtons.append(self.manualBackgroundButton) + self.checkableQButtonsGroup.addButton(self.manualBackgroundButton) + self.widgetsWithShortcut['Manual background'] = self.manualBackgroundButton + + self.manualBackgroundAction = editToolBar.addWidget( + self.manualBackgroundButton + ) + + self.delObjsOutSegmMaskAction = QAction( + QIcon(":del_objs_out_segm.svg"), + 'Select a segmentation file and delete all objects on the background', + self + ) + self.delObjsOutSegmMaskAction.setShortcut('I') + self.widgetsWithShortcut['Delete all objects outside segm'] = ( + self.delObjsOutSegmMaskAction + ) + editToolBar.addAction(self.delObjsOutSegmMaskAction) + + self.hullContToolButton = QToolButton(self) + self.hullContToolButton.setIcon(QIcon(":hull.svg")) + self.hullContToolButton.setCheckable(True) + self.hullContToolButton.setShortcut('O') + self.hullContToolButton.action = editToolBar.addWidget(self.hullContToolButton) + self.checkableButtons.append(self.hullContToolButton) + self.checkableQButtonsGroup.addButton(self.hullContToolButton) + self.functionsNotTested3D.append(self.hullContToolButton) + self.widgetsWithShortcut['Hull contour'] = self.hullContToolButton + + self.fillHolesToolButton = QToolButton(self) + self.fillHolesToolButton.setIcon(QIcon(":fill_holes.svg")) + self.fillHolesToolButton.setCheckable(True) + self.fillHolesToolButton.setShortcut('F') + self.fillHolesToolButton.action = editToolBar.addWidget( + self.fillHolesToolButton + ) + self.checkableButtons.append(self.fillHolesToolButton) + self.checkableQButtonsGroup.addButton(self.fillHolesToolButton) + self.functionsNotTested3D.append(self.fillHolesToolButton) + self.widgetsWithShortcut['Fill holes'] = self.fillHolesToolButton + + self.moveLabelToolButton = QToolButton(self) + self.moveLabelToolButton.setIcon(QIcon(":moveLabel.svg")) + self.moveLabelToolButton.setCheckable(True) + self.moveLabelToolButton.setShortcut('P') + self.moveLabelToolButton.action = editToolBar.addWidget(self.moveLabelToolButton) + self.checkableButtons.append(self.moveLabelToolButton) + self.checkableQButtonsGroup.addButton(self.moveLabelToolButton) + self.widgetsWithShortcut['Move label'] = self.moveLabelToolButton + + self.expandLabelToolButton = QToolButton(self) + self.expandLabelToolButton.setIcon(QIcon(":expandLabel.svg")) + self.expandLabelToolButton.setCheckable(True) + self.expandLabelToolButton.setShortcut('E') + self.expandLabelToolButton.action = editToolBar.addWidget(self.expandLabelToolButton) + self.expandLabelToolButton.hide() + self.checkableButtons.append(self.expandLabelToolButton) + self.LeftClickButtons.append(self.expandLabelToolButton) + self.checkableQButtonsGroup.addButton(self.expandLabelToolButton) + self.widgetsWithShortcut['Expand/shrink label'] = self.expandLabelToolButton + + self.editIDbutton = QToolButton(self) + self.editIDbutton.setIcon(QIcon(":edit-id.svg")) + self.editIDbutton.setCheckable(True) + self.editIDbutton.setShortcut('N') + editToolBar.addWidget(self.editIDbutton) + self.checkableButtons.append(self.editIDbutton) + self.checkableQButtonsGroup.addButton(self.editIDbutton) + self.widgetsWithShortcut['Edit ID'] = self.editIDbutton + + self.separateBudButton = QToolButton(self) + self.separateBudButton.setIcon(QIcon(":separate-bud.svg")) + self.separateBudButton.setCheckable(True) + self.separateBudButton.setShortcut('S') + self.separateBudButton.action = editToolBar.addWidget(self.separateBudButton) + self.checkableButtons.append(self.separateBudButton) + self.checkableQButtonsGroup.addButton(self.separateBudButton) + # self.functionsNotTested3D.append(self.separateBudButton) + self.widgetsWithShortcut['Separate objects'] = self.separateBudButton + + self.mergeIDsButton = QToolButton(self) + self.mergeIDsButton.setIcon(QIcon(":merge-IDs.svg")) + self.mergeIDsButton.setCheckable(True) + self.mergeIDsButton.setShortcut('M') + self.mergeIDsButton.action = editToolBar.addWidget(self.mergeIDsButton) + self.checkableButtons.append(self.mergeIDsButton) + self.checkableQButtonsGroup.addButton(self.mergeIDsButton) + # self.functionsNotTested3D.append(self.mergeIDsButton) + self.widgetsWithShortcut['Merge objects'] = self.mergeIDsButton + + self.keepIDsButton = QToolButton(self) + self.keepIDsButton.setIcon(QIcon(":keep_objects.svg")) + self.keepIDsButton.setCheckable(True) + self.keepIDsButton.action = editToolBar.addWidget(self.keepIDsButton) + self.keepIDsButton.setShortcut('K') + self.checkableButtons.append(self.keepIDsButton) + self.checkableQButtonsGroup.addButton(self.keepIDsButton) + # self.functionsNotTested3D.append(self.keepIDsButton) + self.widgetsWithShortcut['Select objects to keep'] = self.keepIDsButton + + self.whitelistIDsButton = QToolButton(self) + self.whitelistIDsButton.setIcon(QIcon(":whitelist.svg")) + self.whitelistIDsButton.setCheckable(True) + self.whitelistIDsButton.action = editToolBar.addWidget( + self.whitelistIDsButton + ) + self.whitelistIDsButton.setShortcut('Ctrl+K') + self.checkableButtons.append(self.whitelistIDsButton) + self.checkableQButtonsGroup.addButton(self.whitelistIDsButton) + self.LeftClickButtons.append(self.whitelistIDsButton) + # self.functionsNotTested3D.append(self.whitelistIDsButton) + self.widgetsWithShortcut['Select objects to add to a tracking whitelist'] = ( + self.whitelistIDsButton + ) + + self.binCellButton = QToolButton(self) + self.binCellButton.setIcon(QIcon(":bin.svg")) + self.binCellButton.setCheckable(True) + # self.binCellButton.setShortcut('R') + self.binCellButton.action = editToolBar.addWidget(self.binCellButton) + self.checkableButtons.append(self.binCellButton) + self.checkableQButtonsGroup.addButton(self.binCellButton) + # self.functionsNotTested3D.append(self.binCellButton) + + self.manualTrackingButton = QToolButton(self) + self.manualTrackingButton.setIcon(QIcon(":manual_tracking.svg")) + self.manualTrackingButton.setCheckable(True) + self.manualTrackingButton.setShortcut('T') + self.checkableQButtonsGroup.addButton(self.manualTrackingButton) + self.checkableButtons.append(self.manualTrackingButton) + self.widgetsWithShortcut['Manual tracking'] = self.manualTrackingButton + + self.ripCellButton = QToolButton(self) + self.ripCellButton.setIcon(QIcon(":rip.svg")) + self.ripCellButton.setCheckable(True) + self.ripCellButton.setShortcut('D') + self.ripCellButton.action = editToolBar.addWidget(self.ripCellButton) + self.checkableButtons.append(self.ripCellButton) + self.checkableQButtonsGroup.addButton(self.ripCellButton) + self.functionsNotTested3D.append(self.ripCellButton) + self.widgetsWithShortcut['Annotate cell as dead'] = self.ripCellButton + + editToolBar.addAction(self.addDelRoiAction) + # editToolBar.addAction(self.addDelPolyLineRoiAction) + + self.addDelPolyLineRoiAction = editToolBar.addWidget( + self.addDelPolyLineRoiButton + ) + self.addDelPolyLineRoiAction.roiType = 'polyline' + + editToolBar.addAction(self.delBorderObjAction) + self.delBorderObjAction.button = editToolBar.widgetForAction( + self.delBorderObjAction + ) + editToolBar.addAction(self.delNewObjAction) + self.delNewObjAction.button = editToolBar.widgetForAction( + self.delNewObjAction + ) + + self.addDelRoiAction.toolbar = editToolBar + self.functionsNotTested3D.append(self.addDelRoiAction) + + self.addDelPolyLineRoiAction.toolbar = editToolBar + self.functionsNotTested3D.append(self.addDelPolyLineRoiAction) + + self.delBorderObjAction.toolbar = editToolBar + self.functionsNotTested3D.append(self.delBorderObjAction) + + self.delNewObjAction.toolbar = editToolBar + # self.functionsNotTested3D.append(self.delNewObjAction) so id this doesnt work in 3d i dont know anymore + + editToolBar.addAction(self.repeatTrackingAction) + + self.manualTrackingAction = editToolBar.addWidget( + self.manualTrackingButton + ) + + self.functionsNotTested3D.append(self.repeatTrackingAction) + self.functionsNotTested3D.append(self.manualTrackingAction) + + self.reinitLastSegmFrameAction = QAction(self) + self.reinitLastSegmFrameAction.setIcon(QIcon(":reinitLastSegm.svg")) + self.reinitLastSegmFrameAction.setVisible(False) + editToolBar.addAction(self.reinitLastSegmFrameAction) + editToolBar.setVisible(False) + self.reinitLastSegmFrameAction.toolbar = editToolBar + self.functionsNotTested3D.append(self.reinitLastSegmFrameAction) + + + self.editLin_TreeBar = widgets.ToolBar("Lin Tree Edit", self) + self.editLin_TreeBar.setContextMenuPolicy(Qt.PreventContextMenu) + + self.addToolBar(self.editLin_TreeBar) + self.editLin_TreeGroup = QButtonGroup() + self.editLin_TreeGroup.setExclusive(True) + + self.findNextMotherButton = QToolButton(self) + self.findNextMotherButton.setIcon(QIcon(":magnGlass.svg")) + self.findNextMotherButton.setCheckable(True) + self.editLin_TreeBar.addWidget(self.findNextMotherButton) + self.editLin_TreeGroup.addButton(self.findNextMotherButton) + self.findNextMotherButton.setShortcut('F') + self.widgetsWithShortcut['Find next potential mother (lineage tree)'] = self.findNextMotherButton + + self.unknownLineageButton = QToolButton(self) + self.unknownLineageButton.setIcon(QIcon(":history.svg")) + self.unknownLineageButton.setCheckable(True) + self.editLin_TreeBar.addWidget(self.unknownLineageButton) + self.editLin_TreeGroup.addButton(self.unknownLineageButton) + self.unknownLineageButton.setShortcut('U') + self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.unknownLineageButton + + self.noToolLinTreeButton = QToolButton(self) + self.noToolLinTreeButton.setIcon(QIcon(":arrow_cursor.svg")) + self.noToolLinTreeButton.setCheckable(True) + self.editLin_TreeBar.addWidget(self.noToolLinTreeButton) + self.editLin_TreeGroup.addButton(self.noToolLinTreeButton) + self.noToolLinTreeButton.setShortcut('N') + self.widgetsWithShortcut['No tool (lineage tree)'] = self.noToolLinTreeButton + + self.propagateLinTreeButton = QToolButton(self) + self.propagateLinTreeButton.setIcon(QIcon(":compute.svg")) + self.editLin_TreeBar.addWidget(self.propagateLinTreeButton) + self.propagateLinTreeButton.setShortcut('P') + self.widgetsWithShortcut['Propagate (lineage tree)'] = self.propagateLinTreeButton + self.propagateLinTreeButton.clicked.connect(self.propagateLinTreeAction) + + self.viewLinTreeInfoButton = QToolButton(self) + self.viewLinTreeInfoButton.setIcon(QIcon(":addCustomAnnotation.svg")) + self.editLin_TreeBar.addWidget(self.viewLinTreeInfoButton) + self.viewLinTreeInfoButton.setShortcut('S') + self.widgetsWithShortcut['View Changes (lineage tree)'] = self.viewLinTreeInfoButton + self.viewLinTreeInfoButton.clicked.connect(self.viewLinTreeInfoAction) + + + modes_available = [ + 'Segmentation and Tracking', + 'Cell cycle analysis', + 'Viewer', + 'Custom annotations', + 'Normal division: Lineage tree' + ] + self.modeItems = modes_available + + self.modeActionGroup = QActionGroup(self.modeMenu) + for mode in self.modeItems: + action = QAction(mode) + action.setCheckable(True) + self.modeActionGroup.addAction(action) + self.modeMenu.addAction(action) + if mode == 'Viewer': + action.setChecked(True) + + self.editToolBar = editToolBar + self.editToolBar.setVisible(False) + self.navigateToolBar.setVisible(False) + self.editLin_TreeBar.setVisible(False) + + self.gui_createAnnotateToolbar() + + def gui_createAnnotateToolbar(self): + # Edit toolbar + self.annotateToolbar = widgets.ToolBar("Custom annotations", self) + self.annotateToolbar.setContextMenuPolicy(Qt.PreventContextMenu) + self.addToolBar(Qt.LeftToolBarArea, self.annotateToolbar) + self.annotateToolbar.addAction(self.loadCustomAnnotationsAction) + self.annotateToolbar.addAction(self.addCustomAnnotationAction) + self.annotateToolbar.addAction(self.viewAllCustomAnnotAction) + self.annotateToolbar.setVisible(False) + + def gui_createLazyLoader(self): + if not self.lazyLoader is None: + return + + self.lazyLoaderThread = QThread() + self.lazyLoaderMutex = QMutex() + self.lazyLoaderWaitCond = QWaitCondition() + self.waitReadH5cond = QWaitCondition() + self.readH5mutex = QMutex() + self.lazyLoader = workers.LazyLoader( + self.lazyLoaderMutex, self.lazyLoaderWaitCond, + self.waitReadH5cond, self.readH5mutex + ) + self.lazyLoader.moveToThread(self.lazyLoaderThread) + self.lazyLoader.wait = True + + self.lazyLoader.signals.finished.connect(self.lazyLoaderThread.quit) + self.lazyLoader.signals.finished.connect(self.lazyLoader.deleteLater) + self.lazyLoaderThread.finished.connect(self.lazyLoaderThread.deleteLater) + + self.lazyLoader.signals.progress.connect(self.workerProgress) + self.lazyLoader.signals.sigLoadingNewChunk.connect(self.loadingNewChunk) + self.lazyLoader.sigLoadingFinished.connect(self.lazyLoaderFinished) + self.lazyLoader.signals.critical.connect(self.lazyLoaderCritical) + self.lazyLoader.signals.finished.connect(self.lazyLoaderWorkerClosed) + + self.lazyLoaderThread.started.connect(self.lazyLoader.run) + self.lazyLoaderThread.start() + + def gui_createStoreStateWorker(self): + self.storeStateWorker = None + return + self.storeStateThread = QThread() + self.autoSaveMutex = QMutex() + self.autoSaveWaitCond = QWaitCondition() + + self.storeStateWorker = workers.StoreGuiStateWorker( + self.autoSaveMutex, self.autoSaveWaitCond + ) + + self.storeStateWorker.moveToThread(self.storeStateThread) + self.storeStateWorker.finished.connect(self.storeStateThread.quit) + self.storeStateWorker.finished.connect(self.storeStateWorker.deleteLater) + self.storeStateThread.finished.connect(self.storeStateThread.deleteLater) + + self.storeStateWorker.sigDone.connect(self.storeStateWorkerDone) + self.storeStateWorker.progress.connect(self.workerProgress) + self.storeStateWorker.finished.connect(self.storeStateWorkerClosed) + + self.storeStateThread.started.connect(self.storeStateWorker.run) + self.storeStateThread.start() + + self.logger.info('Store state worker started.') + + def storeStateWorkerDone(self): + if self.storeStateWorker.callbackOnDone is not None: + self.storeStateWorker.callbackOnDone() + self.storeStateWorker.callbackOnDone = None + + def storeStateWorkerClosed(self): + self.logger.info('Store state worker started.') + + def gui_createAutoSaveWorker(self): + if not hasattr(self, 'data'): + return + + if not self.isDataLoaded: + return + + if self.autoSaveActiveWorkers: + garbage = self.autoSaveActiveWorkers[-1] + self.autoSaveGarbageWorkers.append(garbage) + worker = garbage[0] + worker._stop() + + posData = self.data[self.pos_i] + autoSaveThread = QThread() + self.autoSaveMutex = QMutex() + self.autoSaveWaitCond = QWaitCondition() + + savedSegmData = posData.segm_data.copy() + autoSaveWorker = workers.AutoSaveWorker( + self.autoSaveMutex, self.autoSaveWaitCond, savedSegmData + ) + autoSaveWorker.isAutoSaveON = self.autoSaveToggle.isChecked() + + autoSaveWorker.moveToThread(autoSaveThread) + autoSaveWorker.finished.connect(autoSaveThread.quit) + autoSaveWorker.finished.connect(autoSaveWorker.deleteLater) + autoSaveThread.finished.connect(autoSaveThread.deleteLater) + + autoSaveWorker.sigDone.connect(self.autoSaveWorkerDone) + autoSaveWorker.progress.connect(self.workerProgress) + autoSaveWorker.finished.connect(self.autoSaveWorkerClosed) + autoSaveWorker.sigAutoSaveCannotProceed.connect( + self.turnOffAutoSaveWorker + ) + + autoSaveThread.started.connect(autoSaveWorker.run) + autoSaveThread.start() + + self.autoSaveActiveWorkers.append((autoSaveWorker, autoSaveThread)) + + self.logger.info('Autosaving worker started.') + + def autoSaveWorkerStartTimer(self, worker, posData): + self.autoSaveWorkerTimer = QTimer() + self.autoSaveWorkerTimer.timeout.connect( + partial(self.autoSaveWorkerTimerCallback, worker, posData) + ) + self.autoSaveWorkerTimer.start(150) + + def autoSaveWorkerTimerCallback(self, worker, posData): + if not self.isSaving: + self.autoSaveWorkerTimer.stop() + worker._enqueue(posData) + + def autoSaveWorkerDone(self): + self.setStatusBarLabel(log=False) + + def ccaCheckerWorkerDone(self): + self.setStatusBarLabel(log=False) + + def preprocWorkerIsQueueEmpty(self, isEmpty: bool): + if isEmpty: + self.preprocessDialog.appliedFinished() + else: + self.preprocessDialog.setDisabled(True) + self.preprocessDialog.infoLabel.setText( + 'Computing preview...
' + '(Feel free to use Cell-ACDC while waiting)' + ) + + def preprocWorkerPreviewDone( + self, processed_data: np.ndarray, + key: Tuple[int, int, Union[int, str]] + ): + pos_i, frame_i, z_slice = key + posData = self.data[pos_i] + if not hasattr(posData, 'preproc_img_data'): + posData.preproc_img_data = preprocess.PreprocessedData( + image_data=np.zeros(posData.img_data.shape) + ) + + posData.preproc_img_data[frame_i][z_slice] = processed_data + self.img1.updateMinMaxValuesPreprocessedData( + self.data, pos_i, frame_i, z_slice + ) + + self.setImageImg1() + + def preprocWorkerDone( + self, + processed_data: np.ndarray, + how: str, + ): + self.setStatusBarLabel(log=False) + self.preprocessDialog.appliedFinished() + + posData = self.data[self.pos_i] + if not hasattr(posData, 'preproc_img_data'): + posData.preproc_img_data = preprocess.PreprocessedData() + + if how == 'current_image': + if posData.SizeZ > 1: + z_slice = self.z_slice_index() + posData.preproc_img_data[posData.frame_i][z_slice] = ( + processed_data + ) + else: + posData.preproc_img_data[posData.frame_i] = processed_data + z_slice = 0 + self.img1.updateMinMaxValuesPreprocessedData( + self.data, self.pos_i, posData.frame_i, z_slice + ) + elif how == 'z_stack': + for z_slice, processed_img in enumerate(processed_data): + posData.preproc_img_data[posData.frame_i][z_slice] = ( + processed_img + ) + self.img1.updateMinMaxValuesPreprocessedData( + self.data, self.pos_i, posData.frame_i, z_slice + ) + self.img1.updateMinMaxValuesPreprocessedProjections( + self.data, self.pos_i, posData.frame_i + ) + elif how == 'all_frames': + for frame_i, processed_frame in enumerate(processed_data): + if processed_frame.ndim == 2: + processed_frame = (processed_frame,) + + for z_slice, processed_img in enumerate(processed_frame): + posData.preproc_img_data[frame_i][z_slice] = ( + processed_img + ) + self.img1.updateMinMaxValuesPreprocessedData( + self.data, self.pos_i, frame_i, z_slice + ) + self.img1.updateMinMaxValuesPreprocessedProjections( + self.data, self.pos_i, frame_i + ) + elif how == 'all_pos': + for pos_i, processed_pos_data in enumerate(processed_data): + if processed_pos_data.ndim == 2: + processed_pos_data = (processed_pos_data,) + + posData = self.data[pos_i] + if not hasattr(posData, 'preproc_img_data'): + posData.preproc_img_data = preprocess.PreprocessedData() + for z_slice, processed_img in enumerate(processed_pos_data): + posData.preproc_img_data[0][z_slice] = ( + processed_img + ) + self.img1.updateMinMaxValuesPreprocessedData( + self.data, pos_i, 0, z_slice + ) + + if posData.SizeZ > 1: + self.img1.updateMinMaxValuesPreprocessedProjections( + self.data, pos_i, frame_i + ) + + if not self.viewPreprocDataToggle.isChecked(): + self.viewPreprocDataToggle.setChecked(True) + else: + self.setImageImg1() + + def goToFrameNumber(self, frame_n): + posData = self.data[self.pos_i] + posData.frame_i = frame_n - 1 + self.get_data() + self.updateAllImages() + self.updateScrollbars() + + def warnCcaIntegrity(self, txt, category): + self.logger.warning(f'{html_utils.to_plain_text(txt)}') + + if 'disable_all' in self.disabled_cca_warnings: + return + + if category in self.disabled_cca_warnings: + return + + if txt in self.disabled_cca_warnings: + return + + if self.isWarningCcaIntegrity: + # Some other warning is still open --> avoid opening another one + return + + self.isWarningCcaIntegrity = True + disabled_warning = _warnings.warn_cca_integrity( + txt, category, self, + go_to_frame_callback=self.goToFrameNumber + ) + if disabled_warning: + self.disabled_cca_warnings.add(disabled_warning) + + self.isWarningCcaIntegrity = False + + def fixWillDivide(self, warning_txt, IDs_will_divide_wrong): + self.logger.info(warning_txt) + self.logger.info('Fixing `will_divide` information...') + + global_cca_df = self.getConcatCcaDf() + global_cca_df = ( + global_cca_df.reset_index() + .set_index(['Cell_ID', 'generation_num']) + ) + global_cca_df.loc[IDs_will_divide_wrong, 'will_divide'] = 0 + global_cca_df = ( + global_cca_df.reset_index() + .set_index(['frame_i', 'Cell_ID']) + ) + self.storeFromConcatCcaDf(global_cca_df) + + def autoSaveWorkerClosed(self, worker): + if self.autoSaveActiveWorkers: + self.logger.info('Autosaving worker closed.') + try: + self.autoSaveActiveWorkers.remove(worker) + except Exception as e: + pass + + def ccaCheckerWorkerClosed(self, worker): + self.logger.info('Cell cycle annotations integrity checker stopped.') + self.ccaCheckerRunning = False + + def preprocWorkerClosed(self, worker): + self.logger.info('Pre-processing worker stopped.') + + def gui_createMainLayout(self): + mainLayout = QGridLayout() + row, col = 0, 1 # Leave column 1 for the overlay labels gradient editor + mainLayout.addLayout(self.leftSideDocksLayout, row, col, 2, 1) + + row = 0 + col = 2 + mainLayout.addWidget(self.graphLayout, row, col, 1, 2) + mainLayout.setRowStretch(row, 2) + + col = 4 # graphLayout spans two columns + mainLayout.addWidget(self.labelsGrad, row, col) + + col = 5 + mainLayout.addLayout(self.rightSideDocksLayout, row, col, 2, 1) + + col = 2 + row += 1 + self.resizeBottomLayoutLine = widgets.VerticalResizeHline() + mainLayout.addWidget(self.resizeBottomLayoutLine, row, col, 1, 2) + self.resizeBottomLayoutLine.dragged.connect( + self.resizeBottomLayoutLineDragged + ) + self.resizeBottomLayoutLine.clicked.connect( + self.resizeBottomLayoutLineClicked + ) + self.resizeBottomLayoutLine.released.connect( + self.resizeBottomLayoutLineReleased + ) + + # row += 1 + # mainLayout.addItem(QSpacerItem(5,5), row+1, col, 1, 2) + + # row, col = 1, 2 + # mainLayout.addLayout( + # self.bottomLayout, row, col, 1, 2, alignment=Qt.AlignLeft + # ) + + row += 1 + mainLayout.addWidget(self.bottomScrollArea, row, col, 1, 2) + mainLayout.setRowStretch(row, 0) + + # row, col = 2, 1 + # mainLayout.addWidget(self.terminal, row, col, 1, 4) + # self.terminal.hide() + + return mainLayout + + def gui_createRegionPropsDockWidget(self, side=Qt.LeftDockWidgetArea): + self.propsDockWidget = QDockWidget('Cell-ACDC objects', self) + self.guiTabControl = widgets.guiTabControl(self.propsDockWidget) + + # self.guiTabControl.setFont(_font) + + self.propsDockWidget.setWidget(self.guiTabControl) + self.propsDockWidget.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetFloatable + | QDockWidget.DockWidgetFeature.DockWidgetMovable + ) + self.propsDockWidget.setAllowedAreas( + Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea + ) + + self.addDockWidget(side, self.propsDockWidget) + self.propsDockWidget.hide() + + def gui_createControlsToolbar(self): + self.controlToolBars = [] + self.addToolBarBreak() + + # Edit toolbar + modeToolBar = widgets.ToolBar("Mode", self) + self.addToolBar(modeToolBar) + + self.modeComboBox = widgets.ComboBox() + self.modeComboBox.addItems(self.modeItems) + self.modeComboBoxLabel = QLabel(' Mode: ') + self.modeComboBoxLabel.setBuddy(self.modeComboBox) + modeToolBar.addWidget(self.modeComboBoxLabel) + modeToolBar.addWidget(self.modeComboBox) + modeToolBar.setVisible(False) + + self.modeToolBar = modeToolBar + + self.overlayToolbar = widgets.OverlayToolbar(parent=self) + self.addToolBar(Qt.TopToolBarArea, self.overlayToolbar) + self.overlayToolbar.setVisible(False) + self.overlayToolbar.sigSetTranspacency.connect( + self.setOverlayTransparency + ) + self.overlayToolbar.sigSetSingleChannel.connect( + self.setOverlaySingleChannel + ) + + self.autoPilotZoomToObjToolbar = widgets.ToolBar( + "Auto-zoom to objects", self + ) + self.autoPilotZoomToObjToolbar.setContextMenuPolicy(Qt.PreventContextMenu) + self.autoPilotZoomToObjToolbar.setMovable(False) + self.addToolBar(Qt.TopToolBarArea, self.autoPilotZoomToObjToolbar) + # self.autoPilotZoomToObjToolbar.setIconSize(QSize(16, 16)) + self.autoPilotZoomToObjToolbar.setVisible(False) + self.autoPilotZoomToObjToolbar.keepVisibleWhenActive = True + self.controlToolBars.append(self.autoPilotZoomToObjToolbar) + + # Highlighted ID or searched ID toolbar + self.highlightIDToolbar = widgets.HighlightedIDToolbar( + parent=self + ) + self.addToolBar(Qt.TopToolBarArea, self.highlightIDToolbar) + self.highlightIDToolbar.setVisible(False) + self.highlightIDToolbar.keepVisibleWhenActive = True + self.controlToolBars.append(self.highlightIDToolbar) + + self.highlightIDToolbar.sigIDChanged.connect( + self.setHighlighedIDfromToolbar + ) + + # Widgets toolbar + brushEraserToolBar = widgets.ToolBar("Widgets", self) + self.addToolBar(Qt.TopToolBarArea, brushEraserToolBar) + self.controlToolBars.append(brushEraserToolBar) + + self.editIDspinbox = widgets.SpinBox() + # self.editIDspinbox.setMaximum(2**32-1) + editIDLabel = QLabel(' ID: ') + self.editIDLabelAction = brushEraserToolBar.addWidget(editIDLabel) + self.editIDspinboxAction = brushEraserToolBar.addWidget( + self.editIDspinbox + ) + self.editIDLabelAction.setVisible(False) + self.editIDspinboxAction.setVisible(False) + self.editIDspinboxAction.setDisabled(True) + self.editIDLabelAction.setDisabled(True) + + brushEraserToolBar.addWidget(QLabel(' ')) + self.autoIDcheckbox = QCheckBox('Auto-ID') + self.autoIDcheckbox.setChecked(True) + self.autoIDcheckboxAction = brushEraserToolBar.addWidget(self.autoIDcheckbox) + self.autoIDcheckboxAction.setVisible(False) + + self.brushSizeSpinbox = widgets.SpinBox( + disableKeyPress=True, + allowNegative=False + ) + self.brushSizeSpinbox.setValue(4) + brushSizeLabel = QLabel(' Size: ') + brushSizeLabel.setBuddy(self.brushSizeSpinbox) + self.brushSizeLabelAction = brushEraserToolBar.addWidget(brushSizeLabel) + self.brushSizeAction = brushEraserToolBar.addWidget(self.brushSizeSpinbox) + self.brushSizeLabelAction.setVisible(False) + self.brushSizeAction.setVisible(False) + + brushEraserToolBar.addWidget(QLabel(' ')) + self.brushAutoFillCheckbox = QCheckBox('Auto-fill holes') + self.brushAutoFillAction = brushEraserToolBar.addWidget( + self.brushAutoFillCheckbox + ) + self.brushAutoFillAction.setVisible(False) + if 'brushAutoFill' in self.df_settings.index: + checked = self.df_settings.at['brushAutoFill', 'value'] == 'Yes' + self.brushAutoFillCheckbox.setChecked(checked) + + brushEraserToolBar.addWidget(QLabel(' ')) + self.brushAutoHideCheckbox = QCheckBox('Hide objects when hovering') + self.brushAutoHideAction = brushEraserToolBar.addWidget( + self.brushAutoHideCheckbox + ) + self.brushAutoHideCheckbox.setChecked(True) + self.brushAutoHideAction.setVisible(False) + if 'brushAutoHide' in self.df_settings.index: + checked = self.df_settings.at['brushAutoHide', 'value'] == 'Yes' + self.brushAutoHideCheckbox.setChecked(checked) + + brushEraserToolBar.setVisible(False) + self.brushEraserToolBar = brushEraserToolBar + + self.wandControlsToolbar = widgets.WandControlsToolbar( + parent=self + ) + + self.addToolBar(Qt.TopToolBarArea , self.wandControlsToolbar) + self.wandControlsToolbar.setVisible(False) + self.controlToolBars.append(self.wandControlsToolbar) + + separatorW = 5 + self.labelRoiToolbar = widgets.ToolBar("Magic labeller controls", self) + self.labelRoiToolbar.addWidget(QLabel('ROI n. of z-slices: ')) + self.labelRoiZdepthSpinbox = widgets.SpinBox(disableKeyPress=True) + self.labelRoiToolbar.addWidget(self.labelRoiZdepthSpinbox) + + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) + self.labelRoiToolbar.addWidget(widgets.QVLine()) + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) + + self.labelRoiReplaceExistingObjectsCheckbox = QCheckBox( + 'Remove objs. touched by new ones' + ) + self.labelRoiToolbar.addWidget(self.labelRoiReplaceExistingObjectsCheckbox) + self.labelRoiAutoClearBorderCheckbox = QCheckBox( + 'Clear ROI borders before adding new objs.' + ) + self.labelRoiAutoClearBorderCheckbox.setChecked(True) + self.labelRoiToolbar.addWidget(self.labelRoiAutoClearBorderCheckbox) + + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) + self.labelRoiToolbar.addWidget(widgets.QVLine()) + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) + + group = QButtonGroup() + group.setExclusive(True) + self.labelRoiIsRectRadioButton = QRadioButton('Rect. ROI') + self.labelRoiIsRectRadioButton.setChecked(True) + self.labelRoiIsFreeHandRadioButton = QRadioButton('Freehand ROI') + self.labelRoiIsCircularRadioButton = QRadioButton('Circular ROI') + group.addButton(self.labelRoiIsRectRadioButton) + group.addButton(self.labelRoiIsFreeHandRadioButton) + group.addButton(self.labelRoiIsCircularRadioButton) + self.labelRoiToolbar.addWidget(self.labelRoiIsRectRadioButton) + self.labelRoiToolbar.addWidget(self.labelRoiIsFreeHandRadioButton) + self.labelRoiToolbar.addWidget(self.labelRoiIsCircularRadioButton) + self.labelRoiToolbar.addWidget(QLabel(' | Radius (pixel): ')) + self.labelRoiCircularRadiusSpinbox = widgets.SpinBox(disableKeyPress=True) + self.labelRoiCircularRadiusSpinbox.setMinimum(1) + self.labelRoiCircularRadiusSpinbox.setValue(11) + self.labelRoiCircularRadiusSpinbox.setDisabled(True) + self.labelRoiToolbar.addWidget(self.labelRoiCircularRadiusSpinbox) + + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) + self.labelRoiToolbar.addWidget(widgets.QVLine()) + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) + + startFrameLabel = QLabel('Start frame n. ') + startFrameLabel.setDisabled(True) + self.labelRoiToolbar.addWidget(startFrameLabel) + self.labelRoiStartFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) + self.labelRoiStartFrameNoSpinbox.label = startFrameLabel + self.labelRoiStartFrameNoSpinbox.setValue(1) + self.labelRoiStartFrameNoSpinbox.setMinimum(1) + self.labelRoiToolbar.addWidget(self.labelRoiStartFrameNoSpinbox) + self.labelRoiStartFrameNoSpinbox.setDisabled(True) + + self.labelRoiFromCurrentFrameAction = QAction(self) + self.labelRoiFromCurrentFrameAction.setText('Segment from current frame') + self.labelRoiFromCurrentFrameAction.setIcon(QIcon(":frames_current.svg")) + self.labelRoiToolbar.addAction(self.labelRoiFromCurrentFrameAction) + self.labelRoiFromCurrentFrameAction.setDisabled(True) + + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=3)) + stopFrameLabel = QLabel(' Stop frame n. ') + stopFrameLabel.setDisabled(True) + self.labelRoiToolbar.addWidget(stopFrameLabel) + self.labelRoiStopFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) + self.labelRoiStopFrameNoSpinbox.label = stopFrameLabel + self.labelRoiStopFrameNoSpinbox.setValue(1) + self.labelRoiStopFrameNoSpinbox.setMinimum(1) + self.labelRoiToolbar.addWidget(self.labelRoiStopFrameNoSpinbox) + self.labelRoiStopFrameNoSpinbox.setDisabled(True) + + self.labelRoiToEndFramesAction = QAction(self) + self.labelRoiToEndFramesAction.setText('Segment all remaining frames') + self.labelRoiToEndFramesAction.setIcon(QIcon(":frames_end.svg")) + self.labelRoiToolbar.addAction(self.labelRoiToEndFramesAction) + self.labelRoiToEndFramesAction.setDisabled(True) + + self.labelRoiTrangeCheckbox = QCheckBox('Segment range of frames') + self.labelRoiToolbar.addWidget(self.labelRoiTrangeCheckbox) + + self.labelRoiViewCurrentModelAction = QAction(self) + self.labelRoiViewCurrentModelAction.setText( + 'View current model\'s parameters' + ) + self.labelRoiViewCurrentModelAction.setIcon(QIcon(":view.svg")) + self.labelRoiToolbar.addAction(self.labelRoiViewCurrentModelAction) + self.labelRoiViewCurrentModelAction.setDisabled(True) + + self.addToolBar(Qt.TopToolBarArea, self.labelRoiToolbar) + self.controlToolBars.append(self.labelRoiToolbar) + self.labelRoiToolbar.setVisible(False) + self.labelRoiTypesGroup = group + + self.loadLabelRoiLastParams() + + self.labelRoiTrangeCheckbox.toggled.connect( + self.labelRoiTrangeCheckboxToggled + ) + self.labelRoiReplaceExistingObjectsCheckbox.toggled.connect( + self.storeLabelRoiParams + ) + self.labelRoiIsCircularRadioButton.toggled.connect( + self.labelRoiIsCircularRadioButtonToggled + ) + self.labelRoiCircularRadiusSpinbox.valueChanged.connect( + self.updateLabelRoiCircularSize + ) + self.labelRoiCircularRadiusSpinbox.valueChanged.connect( + self.storeLabelRoiParams + ) + self.labelRoiZdepthSpinbox.valueChanged.connect( + self.storeLabelRoiParams + ) + self.labelRoiAutoClearBorderCheckbox.toggled.connect( + self.storeLabelRoiParams + ) + group.buttonToggled.connect(self.storeLabelRoiParams) + + self.labelRoiToEndFramesAction.triggered.connect( + self.labelRoiToEndFramesTriggered + ) + self.labelRoiFromCurrentFrameAction.triggered.connect( + self.labelRoiFromCurrentFrameTriggered + ) + self.labelRoiViewCurrentModelAction.triggered.connect( + self.labelRoiViewCurrentModel + ) + + self.keepIDsToolbar = widgets.ToolBar("Keep IDs controls", self) + self.keepIDsConfirmAction = QAction() + self.keepIDsConfirmAction.setIcon(QIcon(":greenTick.svg")) + self.keepIDsConfirmAction.setToolTip('Apply "keep IDs" selection') + self.keepIDsConfirmAction.setDisabled(True) + self.keepIDsToolbar.addAction(self.keepIDsConfirmAction) + self.keepIDsToolbar.addWidget(QLabel(' IDs to keep: ')) + instructionsText = ( + ' (Separate IDs by comma. Use a dash to denote a range of IDs)' + ) + instructionsLabel = QLabel(instructionsText) + self.keptIDsLineEdit = widgets.KeepIDsLineEdit( + instructionsLabel, parent=self + ) + self.keepIDsToolbar.addWidget(self.keptIDsLineEdit) + self.keepIDsToolbar.addWidget(instructionsLabel) + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.keepIDsToolbar.addWidget(spacer) + self.addToolBar(Qt.TopToolBarArea, self.keepIDsToolbar) + self.keepIDsToolbar.setVisible(False) + self.controlToolBars.append(self.keepIDsToolbar) + + self.keptIDsLineEdit.sigEnterPressed.connect(self.applyKeepObjects) + self.keptIDsLineEdit.sigIDsChanged.connect(self.updateKeepIDs) + self.keepIDsConfirmAction.triggered.connect(self.applyKeepObjects) + + # closeToolbarAction = QAction( + # QIcon(":cancelButton.svg"), "Close toolbar...", self + # ) + # closeToolbarAction.triggered.connect(self.closeToolbars) + # self.autoPilotZoomToObjToolbar.addAction(closeToolbarAction) + + self.autoPilotZoomToObjToolbar.addWidget(widgets.QVLine()) + self.autoPilotZoomToObjToolbar.addWidget( + widgets.QHWidgetSpacer(width=separatorW) + ) + + spinBox = widgets.SpinBox() + spinBox.setMinimum(1) + spinBox.label = QLabel(' Zoom to ID: ') + spinBox.labelAction = self.autoPilotZoomToObjToolbar.addWidget(spinBox.label) + spinBox.action = self.autoPilotZoomToObjToolbar.addWidget(spinBox) + spinBox.editingFinished.connect(self.zoomToObj) + spinBox.sigUpClicked.connect(self.autoZoomNextObj) + spinBox.sigDownClicked.connect(self.autoZoomPrevObj) + self.autoPilotZoomToObjSpinBox = spinBox + toggle = widgets.Toggle() + self.autoPilotZoomToObjToggle = toggle + toggle.toggled.connect(self.autoPilotZoomToObjToggled) + toggle.label = QLabel(' Auto-pilot: ') + tooltip = ( + 'When auto-pilot is active, you can use Up/Down arrows to ' + 'automatically zoom to the next/previous object.\n\n' + 'Alternatively, you can type the ID of the object you want to ' + 'zoom to.' + ) + toggle.label.setToolTip(tooltip) + toggle.setToolTip(tooltip) + self.autoPilotZoomToObjToolbar.addWidget(toggle.label) + self.autoPilotZoomToObjToolbar.addWidget(toggle) + + self.pointsLayersToolbars = [] + + self.pointsLayersToolbar = widgets.PointsLayersToolbar(parent=self) + self.pointsLayersToolbar.setContextMenuPolicy(Qt.PreventContextMenu) + + self.pointsLayersToolbar.sigAddPointsLayer.connect( + self.addPointsLayerTriggered + ) + + self.addToolBar(Qt.TopToolBarArea, self.pointsLayersToolbar) + + self.pointsLayersToolbar.setVisible(False) + self.pointsLayersToolbar.keepVisibleWhenActive = True + self.controlToolBars.append(self.pointsLayersToolbar) + + self.pointsLayersToolbars.append( + self.pointsLayersToolbar + ) + + self.manualTrackingToolbar = widgets.ManualTrackingToolBar( + "Manual tracking controls", self + ) + self.manualTrackingToolbar.sigIDchanged.connect(self.initGhostObject) + self.manualTrackingToolbar.sigDisableGhost.connect(self.clearGhost) + self.manualTrackingToolbar.sigClearGhostContour.connect( + self.clearGhostContour + ) + self.manualTrackingToolbar.sigClearGhostMask.connect( + self.clearGhostMask + ) + self.manualTrackingToolbar.sigGhostOpacityChanged.connect( + self.updateGhostMaskOpacity + ) + + self.addToolBar(Qt.TopToolBarArea, self.manualTrackingToolbar) + self.manualTrackingToolbar.setVisible(False) + self.controlToolBars.append(self.manualTrackingToolbar) + + self.manualBackgroundToolbar = widgets.ManualBackgroundToolBar( + "Manual background controls", self + ) + self.manualBackgroundToolbar.sigIDchanged.connect( + self.initManualBackgroundObject + ) + self.addToolBar(Qt.TopToolBarArea, self.manualBackgroundToolbar) + self.manualBackgroundToolbar.setVisible(False) + self.controlToolBars.append(self.manualBackgroundToolbar) + + # Copy lost object contour toolbar + self.copyLostObjToolbar = widgets.CopyLostObjectToolbar( + "Copy lost object controls", self + ) + for name, action in self.copyLostObjToolbar.widgetsWithShortcut.items(): + self.widgetsWithShortcut[name] = action + + self.copyLostObjToolbar.sigCopyAllObjects.connect( + self.copyAllLostObjects + ) + + self.addToolBar(Qt.TopToolBarArea, self.copyLostObjToolbar) + self.copyLostObjToolbar.setVisible(False) + # self.controlToolBars.append(self.copyLostObjToolbar) + + # Copy lost object contour toolbar + self.drawClearRegionToolbar = widgets.DrawClearRegionToolbar( + "Draw freehand region and clear objects controls", self + ) + + self.addToolBar(Qt.TopToolBarArea, self.drawClearRegionToolbar) + self.drawClearRegionToolbar.setVisible(False) + self.controlToolBars.append(self.drawClearRegionToolbar) + + try: + addNewIDToggleState = self.df_settings.at['addNewIDsWhitelistToggle', 'value'] == 'Yes' + except KeyError: + addNewIDToggleState = True + + self.whitelistIDsToolbar = widgets.WhitelistIDsToolbar( + addNewIDToggleState, self + ) + for name, action in self.whitelistIDsToolbar.widgetsWithShortcut.items(): + self.widgetsWithShortcut[name] = action + + self.addToolBar(Qt.TopToolBarArea, self.whitelistIDsToolbar) + self.whitelistIDsToolbar.setVisible(False) + self.controlToolBars.append(self.whitelistIDsToolbar) + + self.magicPromptsToolbar = widgets.MagicPromptsToolbar(self) + for name, action in self.magicPromptsToolbar.widgetsWithShortcut.items(): + self.widgetsWithShortcut[name] = action + + self.magicPromptsToolbar.sigComputeOnZoom.connect( + self.magicPromptsComputeOnZoomTriggered + ) + self.magicPromptsToolbar.sigComputeOnImage.connect( + self.magicPromptsComputeOnImageTriggered + ) + self.magicPromptsToolbar.sigInitSelectedModel.connect( + self.magicPromptsInitModel + ) + self.magicPromptsToolbar.sigViewModelParams.connect( + self.viewSetMagicPromptModelParams + ) + self.magicPromptsToolbar.sigClearPoints.connect( + partial(self.magicPromptsClearPoints, only_zoom=False) + ) + self.magicPromptsToolbar.sigClearPointsOnZmom.connect( + partial(self.magicPromptsClearPoints, only_zoom=True) + ) + self.magicPromptsToolbar.sigInterpolateZslice.connect( + self.magicPromptsInterpolateZsliceToggled + ) + + self.addToolBar(Qt.TopToolBarArea, self.magicPromptsToolbar) + self.magicPromptsToolbar.setVisible(False) + self.magicPromptsToolbar.keepVisibleWhenActive = True + self.controlToolBars.append(self.magicPromptsToolbar) + + self.promptSegmentPointsLayerToolbar = ( + widgets.PromptableModelPointsLayerToolbar(parent=self) + ) + self.promptSegmentPointsLayerToolbar.setContextMenuPolicy( + Qt.PreventContextMenu + ) + + self.addToolBar(Qt.TopToolBarArea, self.promptSegmentPointsLayerToolbar) + self.promptSegmentPointsLayerToolbar.setVisible(False) + + self.pointsLayersToolbars.append( + self.promptSegmentPointsLayerToolbar + ) + + # Second level toolbar + secondLevelToolbar = widgets.ToolBar("Second level toolbar", self) + self.addToolBar(Qt.TopToolBarArea, secondLevelToolbar) + self.delObjToolAction = QAction(self) + self.delObjToolAction.setIcon(QIcon(":del_obj_click.svg")) + self.delObjToolAction.setCheckable(True) + self.delObjToolAction.setToolTip( + 'Customisable delete object action\n\n' + 'Go to the `Settings --> Customise keyboard shortcuts...` menu ' + 'on the top menubar\n' + 'to customise the action required to delete ' + 'an object with a click.\n\n' + 'When working with 3D segmentations, to delete only the z-slice mask, hold "Shift" while clicking.' + ) + secondLevelToolbar.addAction(self.delObjToolAction) + secondLevelToolbar.setMovable(False) + self.secondLevelToolbar = secondLevelToolbar + self.secondLevelToolbar.setVisible(False) + + def gui_populateToolSettingsMenu(self): + brushHoverModeActionGroup = QActionGroup(self) + brushHoverModeActionGroup.setExclusive(True) + self.brushHoverCenterModeAction = QAction() + self.brushHoverCenterModeAction.setCheckable(True) + self.brushHoverCenterModeAction.setText( + 'Use center of the brush/eraser cursor to determine hover ID' + ) + self.brushHoverCircleModeAction = QAction() + self.brushHoverCircleModeAction.setCheckable(True) + self.brushHoverCircleModeAction.setText( + 'Use the entire circle of the brush/eraser cursor to determine hover ID' + ) + brushHoverModeActionGroup.addAction(self.brushHoverCenterModeAction) + brushHoverModeActionGroup.addAction(self.brushHoverCircleModeAction) + brushHoverModeMenu = self.settingsMenu.addMenu( + 'Brush/eraser cursor hovering mode' + ) + brushHoverModeMenu.addAction(self.brushHoverCenterModeAction) + brushHoverModeMenu.addAction(self.brushHoverCircleModeAction) + + if 'useCenterBrushCursorHoverID' not in self.df_settings.index: + self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' + + useCenterBrushCursorHoverID = self.df_settings.at[ + 'useCenterBrushCursorHoverID', 'value' + ] == 'Yes' + self.brushHoverCenterModeAction.setChecked(useCenterBrushCursorHoverID) + self.brushHoverCircleModeAction.setChecked(not useCenterBrushCursorHoverID) + + self.brushHoverCenterModeAction.toggled.connect( + self.useCenterBrushCursorHoverIDtoggled + ) + + self.settingsMenu.addSeparator() + + keepToolActiveNames = { + 'Segment range of frames': self.labelRoiTrangeCheckbox + } + for button in self.checkableQButtonsGroup.buttons(): + if button.toolTip() == "": + toolName = "MISSING" + continue + else: + toolName = re.findall(r'Name: (.*)', button.toolTip())[0] + keepToolActiveNames[toolName] = button + + keepToolActiveNames = dict(natsorted(keepToolActiveNames.items())) + + applyToNewFrameNames = { + 'Segmenting for lost IDs': self.segForLostIDsButton, + 'Delete bordering objects': self.delBorderObjAction.button, + 'Delete newly segmented objects': self.delNewObjAction.button, + } + + allToolsList = list(keepToolActiveNames.keys()) + list(applyToNewFrameNames.keys()) + allToolsList = natsorted(allToolsList) + + menus = {} + + for toolName in allToolsList: + menuItemText = f'{toolName} tool'.replace(' ', ' ') + menus[toolName] = self.settingsMenu.addMenu(menuItemText) + + self.keepToolActiveActions = dict() + self.applyToolNewFrameActions = dict() + self.applyToolNewFrameButtons = dict() + all_checked = True + + for toolName, button in keepToolActiveNames.items(): + menu = menus[toolName] + action = QAction(button) + action.setText('Keep tool active after using it') + action.setCheckable(True) + if toolName in self.df_settings.index: + action.setChecked(True) + else: + all_checked = False + action.toggled.connect(self.keepToolActiveActionToggled) + menu.addAction(action) + self.keepToolActiveActions[toolName] = action + + for toolName, button in applyToNewFrameNames.items(): + menu = menus[toolName] + action = QAction(button) + action.setText('Apply when visitng new frame') + action.setCheckable(True) + action.toggled.connect(self.applyToolNewFrameActionToggled) + menu.addAction(action) + self.applyToolNewFrameActions[toolName] = action + self.applyToolNewFrameButtons[toolName] = button + + for toolName in self.applyToolNewFrameActions.keys(): + settingString = toolName.strip() + settingString = toolName.replace(' ', '_') + settingString = f'{settingString}_applyNewFrame' + if settingString in self.df_settings.index: + val = self.df_settings.at[settingString, 'value'] + if val == 'applyNewFrame': + self.applyToolNewFrameActions[toolName].setChecked(True) + + self.settingsMenu.addSeparator() + + self.keepAllToolsActiveToggle = QAction() + self.keepAllToolsActiveToggle.setText( + 'Keep all tools active after using them' + ) + self.keepAllToolsActiveToggle.setCheckable(True) + self.keepAllToolsActiveToggle.setChecked(all_checked) + self.keepAllToolsActiveToggle.toggled.connect( + self.keepAllToolsActiveActionToggled + ) + self.settingsMenu.addAction(self.keepAllToolsActiveToggle) + self.settingsMenu.addSeparator() + + askHowFutureFramesMenu = self.settingsMenu.addMenu( + 'Ask how to propagate changes to future frames' + ) + self.askHowFutureFramesActions = {} + askHowFutureFramesActionsKeys = ( + 'Delete ID', + 'Exclude cell from analysis', + 'Annotate cell as dead', + 'Edit ID', + 'Keep ID' + ) + for key in askHowFutureFramesActionsKeys: + askHowFutureFramesAction = QAction() + askHowFutureFramesAction.setText(f'Ask for "{key}" action') + askHowFutureFramesAction.setCheckable(True) + askHowFutureFramesAction.setChecked(True) + askHowFutureFramesAction.setDisabled(True) + askHowFutureFramesMenu.addAction(askHowFutureFramesAction) + self.askHowFutureFramesActions[key] = askHowFutureFramesAction + + warningsMenu = self.settingsMenu.addMenu('Warnings and pop-ups') + self.warnLostCellsAction = QAction() + self.warnLostCellsAction.setText('Show pop-up warning for lost cells') + self.warnLostCellsAction.setCheckable(True) + self.warnLostCellsAction.setChecked(True) + warningsMenu.addAction(self.warnLostCellsAction) + + warnEditingWithAnnotTexts = { + 'Delete ID': 'Show warning when deleting ID that has annotations', + 'Separate IDs': 'Show warning when separating IDs that have annotations', + 'Edit ID': 'Show warning when editing ID that has annotations', + 'Annotate ID as dead': + 'Show warning when annotating dead ID that has annotations', + 'Delete ID with eraser': + 'Show warning when erasing ID that has annotations', + 'Add new ID with brush tool': + 'Show warning when adding new ID (brush) that has annotations', + 'Merge IDs': + 'Show warning when merging IDs that have annotations', + 'Add new ID with curvature tool': + 'Show warning when adding new ID (curv. tool) that has annotations', + 'Add new ID with magic-wand': + 'Show warning when adding new ID (magic-wand) that has annotations', + 'Delete IDs using ROI': + 'Show warning when using ROIs to delete IDs that have annotations', + } + self.warnEditingWithAnnotActions = {} + for key, desc in warnEditingWithAnnotTexts.items(): + action = QAction() + action.setText(desc) + action.setCheckable(True) + action.setChecked(True) + action.removeAnnot = False + self.warnEditingWithAnnotActions[key] = action + warningsMenu.addAction(action) + + + def gui_createStatusBar(self): + self.statusbar = self.statusBar() + # Permanent widget + self.wcLabel = QLabel('') + self.statusbar.addPermanentWidget(self.wcLabel) + + # self.toggleTerminalButton = widgets.ToggleTerminalButton() + # self.statusbar.addWidget(self.toggleTerminalButton) + # self.toggleTerminalButton.sigClicked.connect( + # self.gui_terminalButtonClicked + # ) + + self.statusBarLabel = QLabel('') + self.statusbar.addWidget(self.statusBarLabel) + + def gui_createTerminalWidget(self): + self.terminal = widgets.QLog(logger=self.logger) + self.terminal.connect() + self.terminalDock = QDockWidget('Log', self) + + self.terminalDock.setWidget(self.terminal) + self.terminalDock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetFloatable | QDockWidget.DockWidgetFeature.DockWidgetMovable + ) + self.terminalDock.setAllowedAreas(Qt.BottomDockWidgetArea) + self.addDockWidget(Qt.BottomDockWidgetArea, self.terminalDock) + # self.terminalDock.widget().layout().setContentsMargins(10,0,10,0) + self.terminalDock.setVisible(False) + + @resetViewRange + def gui_terminalButtonClicked(self, terminalVisible): + self.terminalDock.setVisible(terminalVisible) + + def gui_createActions(self): + # File actions + self.segmNdimIndicator = widgets.ToolButtonTextIcon(text='') + self.segmNdimIndicator.setCheckable(True) + self.segmNdimIndicator.setChecked(True) + # self.segmNdimIndicator.setDisabled(True) + + if self.debug: + self.createEmptyDataAction = QAction(self) + self.createEmptyDataAction.setText("DEBUG: Create empty data") + + self.newWindowAction = QAction("New Window", self) + + self.newAction = QAction(self) + self.newAction.setText("&New Segmentation File...") + self.newAction.setIcon(QIcon(":file-new.svg")) + self.openFolderAction = QAction( + QIcon(":folder-open.svg"), "&Load Folder...", self + ) + self.openFileAction = QAction( + QIcon(":image.svg"),"&Open Image/Video File...", self + ) + self.manageVersionsAction = QAction( + QIcon(":manage_versions.svg"), "Load Older Versions...", self + ) + self.manageVersionsAction.setDisabled(True) + self.saveAction = QAction(QIcon(":file-save.svg"), "Save", self) + self.saveAsAction = QAction("Save as...", self) + self.exportToVideoAction = QAction("&Video...", self) + self.exportToImageAction = QAction("&Image...", self) + self.quickSaveAction = QAction("Save Only Segmentation Masks", self) + self.loadFluoAction = QAction("Load Fluorescence Images...", self) + self.loadPosAction = QAction("Load Different Position...", self) + # self.reloadAction = QAction( + # QIcon(":reload.svg"), "Reload segmentation file", self + # ) + self.nextAction = QAction('Next', self) + self.prevAction = QAction('Previous', self) + self.showInExplorerAction = QAction( + QIcon(":drawer.svg"), f"&{self.openFolderText}", self + ) + self.exitAction = QAction("&Exit", self) + self.undoAction = QAction(QIcon(":undo.svg"), "Undo", self) + self.redoAction = QAction(QIcon(":redo.svg"), "Redo", self) + # String-based key sequences + self.newWindowAction.setShortcut('Ctrl+Shift+N') + self.newAction.setShortcut('Ctrl+N') + self.openFolderAction.setShortcut('Ctrl+O') + self.loadPosAction.setShortcut('Shift+P') + self.saveAsAction.setShortcut('Ctrl+Shift+S') + self.exportToVideoAction.setShortcut('Ctrl+Shift+V') + self.exportToImageAction.setShortcut('Ctrl+Shift+I') + self.saveAction.setShortcut('Ctrl+Alt+S') + self.quickSaveAction.setShortcut('Ctrl+S') + self.undoAction.setShortcut('Ctrl+Z') + self.redoAction.setShortcut('Ctrl+Y') + self.nextAction.setShortcut(Qt.Key_Right) + self.prevAction.setShortcut(Qt.Key_Left) + self.addAction(self.nextAction) + self.addAction(self.prevAction) + # Help tips + newTip = "Create a new segmentation file" + self.newAction.setStatusTip(newTip) + self.newAction.setWhatsThis("Create a new empty segmentation file") + + self.autoPilotButton = QAction(self) + self.autoPilotButton.setIcon(QIcon(":auto-pilot.svg")) + self.autoPilotButton.setCheckable(True) + self.autoPilotButton.setShortcut('Ctrl+Shift+A') + + self.findIdAction = QAction(self) + self.findIdAction.setIcon(QIcon(":find.svg")) + self.findIdAction.setShortcut('Ctrl+F') + + self.zoomRectButton = QToolButton(self) + self.zoomRectButton.setIcon(QIcon(":zoom_rect.svg")) + self.zoomRectButton.setCheckable(True) + self.zoomRectButton.setShortcut('Shift+Z') + self.LeftClickButtons.append(self.zoomRectButton) + self.checkableButtons.append(self.zoomRectButton) + self.checkableQButtonsGroup.addButton(self.zoomRectButton) + self.widgetsWithShortcut['Zoom to rectangular area'] = ( + self.zoomRectButton + ) + + self.skipToNewIdAction = QAction(self) + self.skipToNewIdAction.setIcon(QIcon(":skip_forward_new_ID.svg")) + self.skipToNewIdAction.setShortcut( + widgets.KeySequenceFromText(Qt.Key_PageUp) + ) + + self.skipToNewIdAction.setDisabled(True) + + # Edit actions + models = myutils.get_list_of_models() + models = [*models, 'local_seg'] # Add local_seg for SegForLostIDsAction + self.segmActions = [] + self.modelNames = [] + self.acdcSegment_li = [] + self.models = [] + for model_name in models: + action = QAction(f"{model_name}...") + self.segmActions.append(action) + self.modelNames.append(model_name) + self.models.append(None) + self.acdcSegment_li.append(None) + action.setDisabled(True) + + self.addCustomModelFrameAction = QAction('Add custom model...', self) + self.addCustomModelVideoAction = QAction('Add custom model...', self) + + self.segmWithPromptableModelAction = QAction( + 'Select promptable model...', self + ) + self.addCustomPromptModelAction = QAction( + 'Add custom promptable model...', self + ) + + self.segmActionsVideo = [] + for model_name in models: + action = QAction(f"{model_name}...") + self.segmActionsVideo.append(action) + action.setDisabled(True) + + self.postProcessSegmAction = QAction( + "Segmentation post-processing...", self + ) + self.postProcessSegmAction.setDisabled(True) + self.postProcessSegmAction.setCheckable(True) + + self.EditSegForLostIDsSetSettings = QAction( + "Edit settings for Segmenting lost IDs...", self + ) + self.EditSegForLostIDsSetSettings.triggered.connect( + self.SegForLostIDsSetSettings + ) + + self.repeatTrackingAction = QAction( + QIcon(":repeat-tracking.svg"), "Repeat tracking", self + ) + self.repeatTrackingAction.setShortcut('Shift+T') + self.widgetsWithShortcut['Repeat Tracking'] = self.repeatTrackingAction + + + self.editRtTrackerParamsAction = QAction( + 'Edit real-time tracker parameters...', self + ) + + self.repeatTrackingMenuAction = QAction( + 'Track current frame with real-time tracker...', self + ) + self.repeatTrackingMenuAction.setDisabled(True) + self.repeatTrackingMenuAction.setShortcut('Shift+T') + + self.repeatTrackingVideoAction = QAction( + 'Select a tracker and track multiple frames...', self + ) + self.repeatTrackingVideoAction.setDisabled(True) + self.repeatTrackingVideoAction.setShortcut('Alt+Shift+T') + + self.trackingAlgosGroup = QActionGroup(self) + self.trackWithAcdcAction = QAction('Cell-ACDC', self) + self.trackWithAcdcAction.setCheckable(True) + self.trackingAlgosGroup.addAction(self.trackWithAcdcAction) + + self.trackWithYeazAction = QAction('YeaZ', self) + self.trackWithYeazAction.setCheckable(True) + self.trackingAlgosGroup.addAction(self.trackWithYeazAction) + + rt_trackers = myutils.get_list_of_real_time_trackers() + for rt_tracker in rt_trackers: + rtTrackerAction = QAction(rt_tracker, self) + rtTrackerAction.setCheckable(True) + self.trackingAlgosGroup.addAction(rtTrackerAction) + + self.trackWithAcdcAction.setChecked(True) + aliases = myutils.aliases_real_time_trackers() + + if 'tracking_algorithm' in self.df_settings.index: + trackingAlgo = self.df_settings.at['tracking_algorithm', 'value'] + if trackingAlgo in aliases: + trackingAlgo = aliases[trackingAlgo] + if trackingAlgo == 'Cell-ACDC': + self.trackWithAcdcAction.setChecked(True) + elif trackingAlgo == 'YeaZ': + self.trackWithYeazAction.setChecked(True) + else: + for rtTrackerAction in self.trackingAlgosGroup.actions(): + if rtTrackerAction.text() == trackingAlgo: + rtTrackerAction.setChecked(True) + break + + self.setMeasurementsAction = QAction('Set measurements...') + self.addCustomMetricAction = QAction('Add custom measurement...') + self.addCombineMetricAction = QAction('Add combined measurement...') + + # Standard key sequence + # self.copyAction.setShortcut(QKeySequence.StandardKey.Copy) + # self.pasteAction.setShortcut(QKeySequence.StandardKey.Paste) + # self.cutAction.setShortcut(QKeySequence.StandardKey.Cut) + # Help actions + self.tipsAction = QAction("Tips and tricks...", self) + self.UserManualAction = QAction("User Documentation...", self) + self.openLogFileAction = QAction("Open log file...", self) + self.showLogFilesAction = QAction("Show log files...", self) + self.aboutAction = QAction("About Cell-ACDC", self) + # self.aboutAction = QAction("&About...", self) + + # Assign mother to bud button + self.assignBudMothAutoAction = QAction(self) + self.assignBudMothAutoAction.setIcon(QIcon(":autoAssign.svg")) + self.assignBudMothAutoAction.setVisible(False) + + self.editCcaToolAction = QAction(self) + self.editCcaToolAction.setIcon(QIcon(":edit_cca.svg")) + # self.editCcaToolAction.setDisabled(True) + self.editCcaToolAction.setVisible(False) + + self.reInitCcaAction = QAction(self) + self.reInitCcaAction.setIcon(QIcon(":reinitCca.svg")) + self.reInitCcaAction.setVisible(False) + + self.toggleColorSchemeAction = QAction( + 'Switch to light theme' + ) + self.gui_updateSwitchColorSchemeActionText() + + self.pxModeAction = widgets.CheckableAction( + 'Fixed size text annotations' + ) + self.pxModeAction.setChecked(True) + pxModeTooltip = ( + 'When the text annotations are with fixed size they scale relative ' + 'to the object when zooming in/out (fixed size in pixels).\n' + 'This is typically faster to render, but it makes annotations ' + 'smaller/larger when zooming in/out, respectively.\n\n' + 'Try activating it to speed up the annotation of many objects ' + 'in high resolution mode.\n\n' + 'After activating it, you might need to increase the font size ' + 'from the menu on the top menubar `Edit --> Font size`.' + ) + self.pxModeAction.setToolTip(pxModeTooltip) + + self.highLowResAction = widgets.CheckableAction( + 'High resolution text annotations' + ) + highLowResTooltip = ( + 'Resolution of the text annotations. High resolution results ' + 'in slower update of the annotations.\n' + 'Not recommended with a number of segmented objects > 500.\n\n' + ) + self.highLowResAction.setToolTip(highLowResTooltip) + + self.editAutoSaveIntervalAction = QAction( + 'Change autosave interval (minutes or frames)...', self + ) + + self.editShortcutsAction = QAction( + 'Customize keyboard shortcuts...', self + ) + self.editShortcutsAction.setShortcut('Ctrl+K') + + self.showMirroredCursorAction = QAction( + 'Show mirrored cursor on images', self + ) + self.showMirroredCursorAction.setCheckable(True) + if 'showMirroredCursor' in self.df_settings.index: + checked = self.df_settings.at['showMirroredCursor', 'value'] == 'Yes' + self.showMirroredCursorAction.setChecked(checked) + else: + self.showMirroredCursorAction.setChecked(True) + self.showMirroredCursorAction.setShortcut('Ctrl+M') + + self.editTextIDsColorAction = QAction('Text annotation color...', self) + self.editTextIDsColorAction.setDisabled(True) + + self.editOverlayColorAction = QAction('Overlay color...', self) + self.editOverlayColorAction.setDisabled(True) + + self.manuallyEditCcaAction = QAction( + 'Edit cell cycle annotations...', self + ) + self.manuallyEditCcaAction.setShortcut('Ctrl+Shift+P') + self.manuallyEditCcaAction.setDisabled(True) + + self.viewCcaTableAction = QAction( + 'View cell cycle annotations...', self + ) + self.viewCcaTableAction.setDisabled(True) + self.viewCcaTableAction.setShortcut('Ctrl+P') + + + self.addScaleBarAction = QAction('Add scale bar', self) + self.addScaleBarAction.setCheckable(True) + + self.addTimestampAction = QAction('Add timestamp', self) + self.addTimestampAction.setCheckable(True) + + self.invertBwAction = QAction('Invert black/white', self) + self.invertBwAction.setCheckable(True) + checked = self.df_settings.at['is_bw_inverted', 'value'] == 'Yes' + self.invertBwAction.setChecked(checked) + + self.shuffleCmapAction = QAction('Randomly shuffle colormap', self) + self.shuffleCmapAction.setShortcut('Shift+S') + + self.greedyShuffleCmapAction = QAction( + 'Greedily shuffle colormap', self + ) + self.greedyShuffleCmapAction.setShortcut('Alt+Shift+S') + + self.saveLabColormapAction = QAction( + 'Save labels colormap...', self + ) + + self.normalizeRawAction = QAction( + 'Do not normalize. Display raw image', self) + self.normalizeToFloatAction = QAction( + 'Convert to floating point format with values [0, 1]', self) + # self.normalizeToUbyteAction = QAction( + # 'Rescale to 8-bit unsigned integer format with values [0, 255]', self) + self.normalizeRescale0to1Action = QAction( + 'Rescale to [0, 1]', self) + self.normalizeByMaxAction = QAction( + 'Normalize by max value', self) + self.normalizeRawAction.setCheckable(True) + self.normalizeToFloatAction.setCheckable(True) + # self.normalizeToUbyteAction.setCheckable(True) + self.normalizeRescale0to1Action.setCheckable(True) + self.normalizeByMaxAction.setCheckable(True) + self.normalizeQActionGroup = QActionGroup(self) + self.normalizeQActionGroup.addAction(self.normalizeRawAction) + self.normalizeQActionGroup.addAction(self.normalizeToFloatAction) + # self.normalizeQActionGroup.addAction(self.normalizeToUbyteAction) + self.normalizeQActionGroup.addAction(self.normalizeRescale0to1Action) + self.normalizeQActionGroup.addAction(self.normalizeByMaxAction) + + self.preprocessAction = QAction( + 'Pre-processing...', self + ) + self.preprocessAction.setShortcut('Alt+Shift+P') + + self.combineChannelsAction = QAction( + 'Combine and manipulate channels and/or segmentation files...', self + ) + self.combineChannelsAction.setShortcut('Alt+Shift+C') + + self.zoomToObjsAction = QAction( + 'Zoom to objects (Shortcut: H key)', self + ) + self.zoomOutAction = QAction( + 'Zoom out (Shortcut: double press H key)', self + ) + + self.relabelSequentialAction = QAction( + 'Relabel IDs sequentially...', self + ) + self.relabelSequentialAction.setShortcut('Ctrl+L') + self.relabelSequentialAction.setDisabled(True) + + self.setLastUserNormAction() + + self.autoSegmAction = QAction( + 'Enable automatic segmentation', self) + self.autoSegmAction.setCheckable(True) + self.autoSegmAction.setDisabled(True) + + self.enableSmartTrackAction = QAction( + 'Smart handling of enabling/disabling tracking', self) + self.enableSmartTrackAction.setCheckable(True) + self.enableSmartTrackAction.setChecked(True) + + self.enableAutoZoomToCellsAction = QAction( + 'Automatic zoom to all cells when pressing "Next/Previous"', self) + self.enableAutoZoomToCellsAction.setCheckable(True) + + self.imgPropertiesAction = QAction('Properties...', self) + self.imgPropertiesAction.setDisabled(True) + + self.addDelRoiAction = QAction(self) + self.addDelRoiAction.roiType = 'rect' + self.addDelRoiAction.setIcon(QIcon(":addDelRoi.svg")) + + self.addDelPolyLineRoiButton = QToolButton(self) + self.addDelPolyLineRoiButton.setCheckable(True) + self.addDelPolyLineRoiButton.setIcon(QIcon(":addDelPolyLineRoi.svg")) + + self.checkableButtons.append(self.addDelPolyLineRoiButton) + self.LeftClickButtons.append(self.addDelPolyLineRoiButton) + + self.delBorderObjAction = QAction(self) + self.delBorderObjAction.setIcon(QIcon(":delBorderObj.svg")) + + self.delNewObjAction = QAction(self) + self.delNewObjAction.setIcon(QIcon(":delNewObj.svg")) + + self.loadCustomAnnotationsAction = QAction(self) + self.loadCustomAnnotationsAction.setIcon(QIcon(":load_annotation.svg")) + self.loadCustomAnnotationsAction.setToolTip( + 'Load previously used custom annotations' + ) + + self.addCustomAnnotationAction = QAction(self) + self.addCustomAnnotationAction.setIcon(QIcon(":addCustomAnnotation.svg")) + self.addCustomAnnotationAction.setToolTip('Add custom annotation') + # self.functionsNotTested3D.append(self.addCustomAnnotationAction) + + self.viewAllCustomAnnotAction = QAction(self) + self.viewAllCustomAnnotAction.setCheckable(True) + self.viewAllCustomAnnotAction.setIcon(QIcon(":eye.svg")) + self.viewAllCustomAnnotAction.setToolTip('Show all custom annotations') + # self.functionsNotTested3D.append(self.viewAllCustomAnnotAction) + + # self.imgGradLabelsAlphaUpAction = QAction(self) + # self.imgGradLabelsAlphaUpAction.setVisible(False) + # self.imgGradLabelsAlphaUpAction.setShortcut('Ctrl+Up') + + def gui_updateSwitchColorSchemeActionText(self): + if self._colorScheme == 'dark': + txt = 'Switch to light theme' + else: + txt = 'Switch to dark theme' + self.toggleColorSchemeAction.setText(txt) + + def gui_connectActions(self): + # Connect File actions + if self.debug: + self.createEmptyDataAction.triggered.connect(self._createEmptyData) + self.segmNdimIndicator.clicked.connect(self.segmNdimIndicatorClicked) + self.newWindowAction.triggered.connect(self.openNewWindow) + self.newAction.triggered.connect(self.newFile) + self.openFolderAction.triggered.connect(self.openFolder) + self.openFileAction.triggered.connect(self.openFile) + self.manageVersionsAction.triggered.connect(self.manageVersions) + self.saveAction.triggered.connect(self.saveData) + self.saveAsAction.triggered.connect(self.saveAsData) + self.exportToVideoAction.triggered.connect(self.exportToVideoTriggered) + self.exportToImageAction.triggered.connect(self.exportToImageTriggered) + self.quickSaveAction.triggered.connect(self.quickSave) + self.viewPreprocDataToggle.toggled.connect( + self.viewPreprocDataToggled + ) + self.viewCombineChannelDataToggle.toggled.connect( + self.viewCombineChannelDataToggled + ) + self.autoSaveToggle.toggled.connect(self.autoSaveToggled) + self.autoSaveAnnotToggle.toggled.connect(self.autoSaveAnnotToggled) + self.autoSaveIntervalDialog.sigValueChanged.connect( + self.autoSaveIntervalValueChanged + ) + self.autoSaveIntervalEditButton.clicked.connect( + self.autoSaveIntervalEdit + ) + self.ccaIntegrCheckerToggle.toggled.connect( + self.ccaIntegrCheckerToggled + ) + self.annotLostObjsToggle.toggled.connect(self.annotLostObjsToggled) + self.highLowResAction.clicked.connect(self.highLowResToggled) + self.showInExplorerAction.triggered.connect(self.showInExplorer_cb) + self.exitAction.triggered.connect(self.close) + self.undoAction.triggered.connect(self.undo) + self.redoAction.triggered.connect(self.redo) + self.nextAction.triggered.connect(self.nextActionTriggered) + self.prevAction.triggered.connect(self.prevActionTriggered) + + self.invertBwAction.toggled.connect(self.invertBw) + self.toggleColorSchemeAction.triggered.connect(self.onToggleColorScheme) + self.pxModeAction.clicked.connect(self.pxModeActionToggled) + self.editShortcutsAction.triggered.connect(self.editShortcuts_cb) + self.editAutoSaveIntervalAction.triggered.connect( + self.autoSaveIntervalEditButton.click + ) + self.showMirroredCursorAction.toggled.connect( + self.showMirroredCursorToggled + ) + + # Connect Help actions + self.tipsAction.triggered.connect(self.showTipsAndTricks) + self.UserManualAction.triggered.connect(myutils.browse_docs) + self.openLogFileAction.triggered.connect(self.openLogFile) + self.showLogFilesAction.triggered.connect(self.showLogFiles) + self.aboutAction.triggered.connect(self.showAbout) + # Connect Open Recent to dynamically populate it + # self.openRecentMenu.aboutToShow.connect(self.populateOpenRecent) + self.checkableQButtonsGroup.buttonClicked.connect(self.uncheckQButton) + + self.showPropsDockButton.sigClicked.connect(self.showPropsDockWidget) + + self.loadCustomAnnotationsAction.triggered.connect( + self.loadCustomAnnotations + ) + self.addCustomAnnotationAction.triggered.connect( + self.addCustomAnnotation + ) + self.viewAllCustomAnnotAction.toggled.connect( + self.viewAllCustomAnnot + ) + self.addCustomModelVideoAction.triggered.connect( + self.showInstructionsCustomModel + ) + self.addCustomModelFrameAction.triggered.connect( + self.showInstructionsCustomModel + ) + self.addCustomModelFrameAction.callback = self.segmFrameCallback + self.addCustomModelVideoAction.callback = self.segmVideoCallback + + self.addCustomPromptModelAction.triggered.connect( + self.showInstructionsCustomPromptModel + ) + self.segmWithPromptableModelAction.triggered.connect( + self.segmWithPromptableModelActionTriggered + ) + + def zProjLockViewToggled(self, checked): + self.updateZproj(self.zProjComboBox.currentText()) + + def rescaleIntensExportToVideoDialog(self, how, channel, setImage=True): + if channel == self.user_ch_name: + lutItem = self.imgGrad + else: + lutItem = self.overlayLayersItems[channel][1] + + for action in lutItem.rescaleActionGroup.actions(): + if action.text() == how: + action.trigger() + # self.rescaleIntensitiesLut(setImage=setImage) + break + + def customLevelsLutChanged(self, levels, imageItem=None): + imageItem.setLevels(levels) + + def getPreComputedMinMaxZstack(self, channel: str): + if channel != self.user_ch_name: + return None + + posData = self.data[self.pos_i] + zstack_min, zstack_max = np.inf, 0 + for z in range(posData.SizeZ): + key = (self.pos_i, posData.frame_i, z) + levels = self.img1.minMaxValuesMapper.get(key) + if levels is None: + return + + img_min, img_max = levels + if img_min < zstack_min: + zstack_min = img_min + + if img_max > zstack_max: + zstack_max = img_max + + return (zstack_min, zstack_max) + + # @exec_time + def rescaleIntensitiesLut( + self, + action: QAction=None, + setImage: bool=True, + imageItem=None + ): + if not self.isDataLoaded: + self.logger.info( + 'WARNING: Data is not loaded. ' + 'Intensities will be rescaled later.' + ) + return + + posData = self.data[self.pos_i] + if imageItem is None: + imageItem = self.img1 + channel = self.user_ch_name + image_data = posData.img_data + else: + channel = imageItem.channelName + _, filename = self.getPathFromChName(channel, posData) + image_data = posData.fluo_data_dict[filename] + + triggeredByUser = True + if action is None: + triggeredByUser = False + action = imageItem.lutItem.rescaleActionGroup.checkedAction() + + how = action.text() + + self.df_settings.at[f'how_rescale_intensities_{channel}', 'value'] = how + self.df_settings.to_csv(self.settings_csv_path) + + if how == 'Rescale each 2D image': + if how == self.rescaleIntensChannelHowMapper[channel]: + # No need to update since we have autoscale + return + + imageItem.setEnableAutoLevels(True) + if setImage: + imageItem.setImage(imageItem.image) + return + + lutLevelsCh = posData.lutLevels[channel] + + if how == 'Rescale across z-stack': + imageItem.setEnableAutoLevels(False) + levels_key = (how, posData.frame_i) + levels = lutLevelsCh.get(levels_key) + if levels is None: + levels = self.getPreComputedMinMaxZstack(channel) + + if levels is None: + image_zstack = image_data[posData.frame_i] + levels = (image_zstack.min(), image_zstack.max()) + lutLevelsCh[levels_key] = levels + imageItem.setLevels(levels) + elif how == 'Rescale across time frames': + imageItem.setEnableAutoLevels(False) + levels_key = (how, None) + levels = lutLevelsCh.get(levels_key) + if levels is None: + levels = (image_data.min(), image_data.max()) + + lutLevelsCh[levels_key] = levels + imageItem.setLevels(levels) + elif how == 'Choose custom levels...': + autoLevelsEnabledBefore = imageItem.autoLevelsEnabled + imageItem.setEnableAutoLevels(False) + if triggeredByUser: + current_min, current_max = imageItem.getLevels() + dtype_max = np.iinfo(image_data.dtype).max + max_value = image_data.max() + min_value = image_data.min() + win = apps.SetCustomLevelsLut( + init_min_value=current_min, + init_max_value=current_max, + maximum_max_value=max_value, + minimum_min_value=min_value, + parent=self + ) + win.sigLevelsChanged.connect( + partial(self.customLevelsLutChanged, imageItem=imageItem) + ) + win.exec_() + if win.cancel: + imageItem.setEnableAutoLevels(autoLevelsEnabledBefore) + self.logger.info('Custom LUT levels setting cancelled.') + self.updateAllImages() + return + selectedLevels = win.selectedLevels + else: + selectedLevels = imageItem.getLevels() + imageItem.setLevels(selectedLevels) + elif how == 'Do no rescale, display raw image': + imageItem.setEnableAutoLevels(False) + levels_key = (how, None) + levels = lutLevelsCh.get(levels_key) + if levels is None: + dtype_max = np.iinfo(image_data.dtype).max + levels = (0, dtype_max) + lutLevelsCh[levels_key] = levels + imageItem.setLevels(levels) + + self.rescaleIntensChannelHowMapper[channel] = how + + if setImage: + imageItem.setImage(imageItem.image) + + def onToggleColorScheme(self): + if self.toggleColorSchemeAction.text().find('light') != -1: + self._colorScheme = 'light' + setDarkModeToggleChecked = False + else: + self._colorScheme = 'dark' + setDarkModeToggleChecked = True + self.gui_updateSwitchColorSchemeActionText() + _warnings.warnRestartCellACDCcolorModeToggled( + self._colorScheme, app_name=self._appName, parent=self + ) + load.rename_qrc_resources_file(self._colorScheme) + self.statusBarLabel.setText(html_utils.paragraph( + f'Restart {self._appName} for the change to take effect', + font_color='red' + )) + self.df_settings.at['colorScheme', 'value'] = self._colorScheme + self.df_settings.to_csv(settings_csv_path) + + def showMirroredCursorToggled(self, checked): + value = 'Yes' if checked else 'No' + self.df_settings.at['showMirroredCursor', 'value'] = value + self.df_settings.to_csv(settings_csv_path) + + if not checked: + self.clearCursors() + + def clearCursors(self): + self.ax1_cursor.setData([], []) + self.ax2_cursor.setData([], []) + self.setHoverToolSymbolData( + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + eraserCursors = ( + self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX + ) + self.setHoverToolSymbolData([], [], eraserCursors) + + def activeEraserCircleCursors(self, isHoverImg1): + if self.showMirroredCursorAction.isChecked(): + return self.ax1_EraserCircle, self.ax2_EraserCircle + + if isHoverImg1: + return self.ax1_EraserCircle, + else: + return self.ax2_EraserCircle, + + def activeEraserXCursors(self, isHoverImg1): + if self.showMirroredCursorAction.isChecked(): + return self.ax1_EraserX, self.ax2_EraserX + + if isHoverImg1: + return self.ax1_EraserX, + else: + return self.ax2_EraserX, + + def activeBrushCircleCursors(self, isHoverImg1): + if self.showMirroredCursorAction.isChecked(): + return self.ax1_BrushCircle, self.ax2_BrushCircle + + if isHoverImg1: + return self.ax1_BrushCircle, + else: + return self.ax2_BrushCircle, + + def gui_connectEditActions(self): + self.showInExplorerAction.setEnabled(True) + self.setEnabledFileToolbar(True) + self.loadFluoAction.setEnabled(True) + self.isEditActionsConnected = True + + self.preprocessImageAction.triggered.connect( + self.preprocessAction.trigger + ) + self.combineChannelsAction.triggered.connect( + self.combineChannelsActionTriggered + ) + + self.overlayButton.toggled.connect(self.overlay_cb) + self.countObjsButton.toggled.connect(self.countObjectsCb) + self.togglePointsLayerAction.toggled.connect(self.pointsLayerToggled) + self.overlayLabelsButton.toggled.connect(self.overlayLabels_cb) + self.overlayButton.sigRightClick.connect(self.showOverlayContextMenu) + self.labelRoiButton.sigRightClick.connect(self.showLabelRoiContextMenu) + self.overlayLabelsButton.sigRightClick.connect( + self.showOverlayLabelsContextMenu + ) + self.rulerButton.toggled.connect(self.ruler_cb) + self.loadFluoAction.triggered.connect(self.loadFluo_cb) + self.loadPosAction.triggered.connect(self.loadPosTriggered) + # self.reloadAction.triggered.connect(self.reload_cb) + self.findIdAction.triggered.connect(self.findID) + self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) + self.autoPilotButton.toggled.connect(self.autoPilotToggled) + self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID) + self.slideshowButton.toggled.connect(self.launchSlideshow) + + self.copyLostObjButton.toggled.connect(self.copyLostObjContour_cb) + self.manualAnnotPastButton.toggled.connect( + self.manualAnnotPast_cb + ) + + self.segmSingleFrameMenu.triggered.connect(self.segmFrameCallback) + self.segmVideoMenu.triggered.connect(self.segmVideoCallback) + + self.postProcessSegmAction.toggled.connect(self.postProcessSegm) + self.autoSegmAction.toggled.connect(self.autoSegm_cb) + self.realTimeTrackingToggle.clicked.connect(self.realTimeTrackingClicked) + self.repeatTrackingAction.triggered.connect(self.repeatTracking) + self.manualTrackingButton.toggled.connect(self.manualTracking_cb) + self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) + self.repeatTrackingMenuAction.triggered.connect(self.repeatTracking) + self.repeatTrackingVideoAction.triggered.connect( + self.repeatTrackingVideo + ) + for rtTrackerAction in self.trackingAlgosGroup.actions(): + rtTrackerAction.toggled.connect(self.rtTrackerActionToggled) + self.editRtTrackerParamsAction.triggered.connect( + self.initRealTimeTracker + ) + self.delObjsOutSegmMaskAction.triggered.connect( + self.delObjsOutSegmMaskActionTriggered + ) + self.mergeIDsButton.toggled.connect(self.mergeObjs_cb) + self.brushButton.toggled.connect(self.Brush_cb) + self.eraserButton.toggled.connect(self.Eraser_cb) + self.curvToolButton.toggled.connect(self.curvTool_cb) + self.wandToolButton.toggled.connect(self.wand_cb) + self.labelRoiButton.toggled.connect(self.labelRoi_cb) + self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) + self.drawClearRegionButton.toggled.connect(self.drawClearRegion_cb) + self.reInitCcaAction.triggered.connect(self.reInitCca) + self.moveLabelToolButton.toggled.connect(self.moveLabelButtonToggled) + self.editCcaToolAction.triggered.connect( + self.manualEditCcaToolbarActionTriggered + ) + self.assignBudMothAutoAction.triggered.connect( + self.autoAssignBud_YeastMate + ) + self.keepIDsButton.toggled.connect(self.keepIDs_cb) + + self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) + + self.whitelistIDsToolbar.sigWhitelistChanged.connect( + self.whitelistIDsChanged + ) + + self.whitelistIDsToolbar.sigWhitelistAccepted.connect( + self.whitelistIDsAccepted + ) + + self.whitelistIDsToolbar.sigViewOGIDs.connect(self.whitelistViewOGIDs) + + self.whitelistIDsToolbar.sigAddNewIDs.connect(self.whitelistAddNewIDsToggled) + + self.whitelistIDsToolbar.sigLoadOGLabs.connect(self.whitelistLoadOGLabs_cb) + + self.whitelistIDsToolbar.sigTrackOGagainstPreviousFrame.connect( + self.whitelistTrackOGagainstPreviousFrame_cb + ) + + self.expandLabelToolButton.toggled.connect(self.expandLabelCallback) + + self.reinitLastSegmFrameAction.triggered.connect( + self.reInitLastSegmFrame + ) + + + self.defaultRescaleIntensActionGroup.triggered.connect( + self.defaultRescaleIntensLutActionToggled + ) + + # self.repeatAutoCcaAction.triggered.connect(self.repeatAutoCca) + self.manuallyEditCcaAction.triggered.connect(self.manualEditCca) + self.addScaleBarAction.toggled.connect(self.addScaleBar) + self.addTimestampAction.toggled.connect(self.addTimestamp) + self.saveLabColormapAction.triggered.connect(self.saveLabelsColormap) + + self.enableSmartTrackAction.toggled.connect(self.enableSmartTrack) + # Brush/Eraser size action + self.brushSizeSpinbox.valueChanged.connect(self.brushSize_cb) + self.autoIDcheckbox.toggled.connect(self.autoIDtoggled) + # Mode + self.modeActionGroup.triggered.connect(self.changeModeFromMenu) + self.modeComboBox.sigTextChanged.connect(self.changeMode) + self.modeComboBox.activated.connect(self.clearComboBoxFocus) + self.equalizeHistPushButton.toggled.connect(self.equalizeHist) + + self.editOverlayColorAction.triggered.connect(self.toggleOverlayColorButton) + self.editTextIDsColorAction.triggered.connect(self.toggleTextIDsColorButton) + self.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) + self.overlayColorButton.sigColorChanged.connect(self.saveOverlayColor) + self.textIDsColorButton.sigColorChanging.connect(self.updateTextAnnotColor) + self.textIDsColorButton.sigColorChanged.connect(self.saveTextIDsColors) + + self.setMeasurementsAction.triggered.connect(self.showSetMeasurements) + self.addCustomMetricAction.triggered.connect(self.addCustomMetric) + self.addCombineMetricAction.triggered.connect(self.addCombineMetric) + + self.labelsGrad.colorButton.sigColorChanging.connect(self.updateBkgrColor) + self.labelsGrad.colorButton.sigColorChanged.connect(self.saveBkgrColor) + self.labelsGrad.sigGradientChangeFinished.connect(self.updateLabelsCmap) + self.labelsGrad.sigGradientChanged.connect(self.ticksCmapMoved) + self.labelsGrad.textColorButton.sigColorChanging.connect( + self.updateTextLabelsColor + ) + self.labelsGrad.textColorButton.sigColorChanged.connect( + self.saveTextLabelsColor + ) + # self.addFontSizeActions( + # self.labelsGrad.fontSizeMenu, self.setFontSizeActionChecked + # ) + + self.labelsGrad.shuffleCmapAction.triggered.connect(self.shuffle_cmap) + self.labelsGrad.greedyShuffleCmapAction.triggered.connect( + self.greedyShuffleCmap + ) + self.labelsGrad.permanentGreedyCmapAction.toggled.connect( + self.permanentGreedyCmapToggled + ) + self.shuffleCmapAction.triggered.connect(self.shuffle_cmap) + self.greedyShuffleCmapAction.triggered.connect(self.greedyShuffleCmap) + self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + self.labelsGrad.sigShowLabelsImgToggled.connect(self.showLabelImageItem) + self.labelsGrad.sigShowRightImgToggled.connect(self.showRightImageItem) + self.labelsGrad.sigShowNextFrameToggled.connect(self.showNextFrameImageItem) + + self.labelsGrad.defaultSettingsAction.triggered.connect( + self.restoreDefaultSettings + ) + + # self.addFontSizeActions( + # self.imgGrad.fontSizeMenu, self.setFontSizeActionChecked + # ) + self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + self.imgGrad.textColorButton.disconnect() + self.imgGrad.textColorButton.clicked.connect( + self.editTextIDsColorAction.trigger + ) + self.imgGrad.labelsAlphaSlider.valueChanged.connect( + self.updateLabelsAlpha + ) + self.imgGrad.defaultSettingsAction.triggered.connect( + self.restoreDefaultSettings + ) + + # Drawing mode + self.drawIDsContComboBox.currentIndexChanged.connect( + self.drawIDsContComboBox_cb + ) + self.drawIDsContComboBox.activated.connect(self.clearComboBoxFocus) + + self.annotateRightHowCombobox.currentIndexChanged.connect( + self.annotateRightHowCombobox_cb + ) + self.annotateRightHowCombobox.activated.connect(self.clearComboBoxFocus) + + self.showTreeInfoCheckbox.toggled.connect(self.setAnnotInfoMode) + + # Left + self.annotIDsCheckbox.clicked.connect(self.annotOptionClicked) + self.annotCcaInfoCheckbox.clicked.connect(self.annotOptionClicked) + self.annotContourCheckbox.clicked.connect(self.annotOptionClicked) + self.annotSegmMasksCheckbox.clicked.connect(self.annotOptionClicked) + self.drawMothBudLinesCheckbox.clicked.connect(self.annotOptionClicked) + self.drawNothingCheckbox.clicked.connect(self.annotOptionClicked) + self.annotNumZslicesCheckbox.clicked.connect(self.annotOptionClicked) + + # Right + self.annotIDsCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.annotCcaInfoCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.annotContourCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.annotSegmMasksCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.drawMothBudLinesCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.drawNothingCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.annotNumZslicesCheckboxRight.clicked.connect( + self.annotOptionClickedRight + ) + + self.segmentToolAction.triggered.connect(self.segmentToolActionTriggered) + + self.addDelRoiAction.triggered.connect(self.addDelROI) + self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) + self.delBorderObjAction.triggered.connect(self.delBorderObj) + self.delNewObjAction.triggered.connect(self.delNewObj) + + self.brushAutoFillCheckbox.toggled.connect(self.brushAutoFillToggled) + self.brushAutoHideCheckbox.toggled.connect(self.brushAutoHideToggled) + + self.imgGrad.sigAddScaleBar.connect(self.addScaleBarAction.setChecked) + self.imgGrad.sigAddTimestamp.connect(self.addTimestampAction.setChecked) + self.imgGrad.gradient.sigGradientChangeFinished.connect( + self.imgGradLUTfinished_cb + ) + + # self.normalizeQActionGroup.triggered.connect( + # self.normaliseIntensitiesActionTriggered + # ) + self.imgPropertiesAction.triggered.connect(self.editImgProperties) + + self.relabelSequentialAction.triggered.connect( + self.relabelSequentialCallback + ) + + self.zoomToObjsAction.triggered.connect(self.zoomToObjsActionCallback) + self.zoomOutAction.triggered.connect(self.zoomOut) + self.preprocessAction.triggered.connect(self.preprocessActionTriggered) + self.combineChannelsAction.triggered.connect(self.combineChannelsActionTriggered) + + self.viewCcaTableAction.triggered.connect(self.viewCcaTable) + + self.guiTabControl.propsQGBox.idSB.valueChanged.connect( + self.propsWidgetIDvalueChanged + ) + self.guiTabControl.highlightCheckbox.toggled.connect( + self.highlightIDonHoverCheckBoxToggled + ) + self.guiTabControl.highlightSearchedCheckbox.toggled.connect( + self.highlightSearchedIDcheckBoxToggled + ) + intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox + intensMeasurQGBox.additionalMeasCombobox.currentTextChanged.connect( + self.updatePropsWidget + ) + intensMeasurQGBox.channelCombobox.currentTextChanged.connect( + self.updatePropsWidget + ) + + propsQGBox = self.guiTabControl.propsQGBox + propsQGBox.additionalPropsCombobox.currentTextChanged.connect( + self.updatePropsWidget + ) + + def gui_createShowPropsButton(self, side='left'): + self.leftSideDocksLayout = QVBoxLayout() + self.leftSideDocksLayout.setSpacing(0) + self.leftSideDocksLayout.setContentsMargins(0,0,0,0) + self.rightSideDocksLayout = QVBoxLayout() + self.rightSideDocksLayout.setSpacing(0) + self.rightSideDocksLayout.setContentsMargins(0,0,0,0) + self.showPropsDockButton = widgets.expandCollapseButton() + self.showPropsDockButton.setDisabled(True) + self.showPropsDockButton.setFocusPolicy(Qt.NoFocus) + self.showPropsDockButton.setToolTip('Show object properties') + if side == 'left': + self.leftSideDocksLayout.addWidget(self.showPropsDockButton) + else: + self.rightSideDocksLayout.addWidget(self.showPropsDockButton) + + def gui_createQuickSettingsWidgets(self): + self.quickSettingsLayout = QVBoxLayout() + self.quickSettingsGroupbox = widgets.GroupBox() + self.quickSettingsGroupbox.setTitle('Quick settings') + + layout = QFormLayout() + layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint + ) + layout.setFormAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self.viewPreprocDataToggle = widgets.Toggle() + viewPreprocDataToggleTooltip = ( + 'View pre-processed data. See menu `Image --> Pre-processing...`\n' + 'on the top menubar.' + ) + self.viewPreprocDataToggle.setChecked(False) + self.viewPreprocDataToggle.setToolTip(viewPreprocDataToggleTooltip) + viewPreprocDataToggleLabel = QLabel('View pre-processed image') + viewPreprocDataToggleLabel.setToolTip(viewPreprocDataToggleTooltip) + layout.addRow(viewPreprocDataToggleLabel, self.viewPreprocDataToggle) + + self.viewCombineChannelDataToggle = widgets.Toggle() + viewCombineChannelDataToggleTooltip = ( + 'View combined channel. See menu `Image --> combing channels...`\n' + 'on the top menubar.' + ) + self.viewCombineChannelDataToggle.setChecked(False) + self.viewCombineChannelDataToggle.setToolTip( + viewCombineChannelDataToggleTooltip + ) + viewCombineChannelDataToggleLabel = QLabel('View combined channels') + viewCombineChannelDataToggleLabel.setToolTip( + viewCombineChannelDataToggleTooltip + ) + layout.addRow( + viewCombineChannelDataToggleLabel, + self.viewCombineChannelDataToggle + ) + + self.autoSaveToggle = widgets.Toggle() + autoSaveTooltip = ( + 'Automatically store a copy of the segmentation data ' + 'in the `.recovery` folder after every edit.' + ) + self.autoSaveToggle.setChecked(True) + self.autoSaveToggle.setToolTip(autoSaveTooltip) + autoSaveLabel = QLabel('Autosave segmentation') + autoSaveLabel.setToolTip(autoSaveTooltip) + layout.addRow(autoSaveLabel, self.autoSaveToggle) + + self.autoSaveAnnotToggle = widgets.Toggle() + autoSaveAnnotTooltip = ( + 'Automatically store a copy of the annotations (acdc_output CSV file) ' + 'in the `.recovery` folder after every edit.' + ) + self.autoSaveAnnotToggle.setChecked(True) + self.autoSaveAnnotToggle.setToolTip(autoSaveAnnotTooltip) + autoSaveAnnotLabel = QLabel('Autosave annotations') + autoSaveAnnotLabel.setToolTip(autoSaveAnnotTooltip) + layout.addRow(autoSaveAnnotLabel, self.autoSaveAnnotToggle) + + self.autoSaveIntervalEditButton = widgets.editPushButton( + flat=True, hoverable=True + ) + self.autoSaveIntervalLabel = QLabel('Autosave interval') + self.autoSaveIntervalSetTooltip() + layout.addRow( + self.autoSaveIntervalLabel, self.autoSaveIntervalEditButton + ) + + self.autoSaveIntervalDialog = apps.AutoSaveIntervalDialog(parent=self) + self.autoSaveIntervalDialog.setValues(*self.autoSaveIntevalValueUnit) + + self.ccaIntegrCheckerToggle = widgets.Toggle() + ccaIntegrCheckerToggleTooltip = ( + 'Toggle background cell cycle annotations integrity checker ON/OFF' + ) + self.ccaIntegrCheckerToggle.setChecked(False) + self.ccaIntegrCheckerToggle.setToolTip(ccaIntegrCheckerToggleTooltip) + label = QLabel('Cc annot. checker') + label.setToolTip(ccaIntegrCheckerToggleTooltip) + layout.addRow(label, self.ccaIntegrCheckerToggle) + if 'is_cca_integrity_checker_activated' in self.df_settings.index: + idx = 'is_cca_integrity_checker_activated' + val = int(self.df_settings.at[idx, 'value']) + self.ccaIntegrCheckerToggle.setChecked(not val) + + self.annotLostObjsToggle = widgets.Toggle() + annotLostObjsToggleTooltip = ( + 'Toggle annotation of lost objects mode ON/OFF' + ) + self.annotLostObjsToggle.setChecked(True) + self.annotLostObjsToggle.setToolTip(annotLostObjsToggleTooltip) + label = QLabel('Annot. lost objects') + label.setToolTip(annotLostObjsToggleTooltip) + layout.addRow(label, self.annotLostObjsToggle) + + self.realTimeTrackingToggle = widgets.Toggle() + self.realTimeTrackingToggle.setChecked(True) + self.realTimeTrackingToggle.setDisabled(True) + label = QLabel('Real-time tracking') + label.setDisabled(True) + self.realTimeTrackingToggle.label = label + layout.addRow(label, self.realTimeTrackingToggle) + + self.showAllContoursToggle = widgets.Toggle() + showAllContoursTooltip = ( + 'If active, all contours will be displayed, including inner contours' + '(e.g. holes and sub-objects)' + ) + self.showAllContoursToggle.setToolTip(showAllContoursTooltip) + showAllContourLabel = QLabel('Show all contours') + showAllContourLabel.setToolTip(showAllContoursTooltip) + layout.addRow(showAllContourLabel, self.showAllContoursToggle) + self.showAllContoursToggle.toggled.connect( + self.showAllContoursToggled + ) + + # Font size + self.fontSizeSpinBox = widgets.SpinBox() + self.fontSizeSpinBox.setMinimum(1) + self.fontSizeSpinBox.setMaximum(99) + layout.addRow('Font size', self.fontSizeSpinBox) + savedFontSize = str(self.df_settings.at['fontSize', 'value']) + if savedFontSize.find('pt') != -1: + savedFontSize = savedFontSize[:-2] + self.fontSize = int(savedFontSize) + if 'pxMode' not in self.df_settings.index: + # Users before introduction of pxMode had pxMode=False, but now + # the new default is True. This requires larger font size. + self.fontSize = 2*self.fontSize + self.df_settings.at['pxMode', 'value'] = 1 + self.df_settings.to_csv(settings_csv_path) + self.fontSizeSpinBox.setValue(self.fontSize) + self.fontSizeSpinBox.editingFinished.connect(self.changeFontSize) + self.fontSizeSpinBox.sigUpClicked.connect(self.changeFontSize) + self.fontSizeSpinBox.sigDownClicked.connect(self.changeFontSize) + + self.quickSettingsGroupbox.setLayout(layout) + self.quickSettingsLayout.addWidget(self.quickSettingsGroupbox) + self.quickSettingsLayout.addStretch(1) + + def showAllContoursToggled(self): + if not self.isDataLoaded: + return + + self.computeAllContours() + self.updateAllImages() + + def gui_createImg1Widgets(self): + # Toggle contours/ID combobox + self.drawIDsContComboBoxSegmItems = [ + 'Draw IDs and contours', + 'Draw IDs and overlay segm. masks', + 'Draw only cell cycle info', + 'Draw cell cycle info and contours', + 'Draw cell cycle info and overlay segm. masks', + 'Draw only mother-bud lines', + 'Draw only IDs', + 'Draw only contours', + 'Draw only overlay segm. masks', + 'Draw nothing' + ] + self.drawIDsContComboBox = widgets.ComboBox() + self.drawIDsContComboBox.setFont(_font) + self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) + self.drawIDsContComboBox.setVisible(False) + + self.annotIDsCheckbox = widgets.CheckBox( + 'IDs', keyPressCallback=self.resetFocus) + self.annotCcaInfoCheckbox = widgets.CheckBox( + 'Cell cycle info', keyPressCallback=self.resetFocus) + self.annotNumZslicesCheckbox = widgets.CheckBox( + 'No. z-slices/object', keyPressCallback=self.resetFocus) + + self.annotContourCheckbox = widgets.CheckBox( + 'Contours', keyPressCallback=self.resetFocus) + self.annotSegmMasksCheckbox = widgets.CheckBox( + 'Segm. masks', keyPressCallback=self.resetFocus) + + self.drawMothBudLinesCheckbox = widgets.CheckBox( + 'Only mother-daughter line', keyPressCallback=self.resetFocus + ) + + self.drawNothingCheckbox = widgets.CheckBox( + 'Do not annotate', keyPressCallback=self.resetFocus + ) + + self.annotOptionsWidget = QWidget() + annotOptionsLayout = QHBoxLayout() + + # Show tree info checkbox + self.showTreeInfoCheckbox = widgets.CheckBox( + 'Show tree info', keyPressCallback=self.resetFocus + ) + self.showTreeInfoCheckbox.setFont(_font) + sp = self.showTreeInfoCheckbox.sizePolicy() + sp.setRetainSizeWhenHidden(True) + self.showTreeInfoCheckbox.setSizePolicy(sp) + self.showTreeInfoCheckbox.hide() + + annotOptionsLayout.addWidget(self.showTreeInfoCheckbox) + annotOptionsLayout.addWidget(QLabel(' | ')) + annotOptionsLayout.addWidget(self.annotIDsCheckbox) + annotOptionsLayout.addWidget(self.annotCcaInfoCheckbox) + annotOptionsLayout.addWidget(self.drawMothBudLinesCheckbox) + annotOptionsLayout.addWidget(self.annotNumZslicesCheckbox) + annotOptionsLayout.addWidget(QLabel(' | ')) + annotOptionsLayout.addWidget(self.annotContourCheckbox) + annotOptionsLayout.addWidget(self.annotSegmMasksCheckbox) + annotOptionsLayout.addWidget(QLabel(' | ')) + annotOptionsLayout.addWidget(self.drawNothingCheckbox) + annotOptionsLayout.addWidget(self.drawIDsContComboBox) + self.annotOptionsLayout = annotOptionsLayout + + # Toggle highlight z+-1 objects combobox + self.highlightZneighObjCheckbox = widgets.CheckBox( + 'Highlight objects in neighbouring z-slices', + keyPressCallback=self.resetFocus + ) + self.highlightZneighObjCheckbox.setFont(_font) + self.highlightZneighObjCheckbox.hide() + + annotOptionsLayout.addWidget(self.highlightZneighObjCheckbox) + self.annotOptionsWidget.setLayout(annotOptionsLayout) + + # Annotations options right image + self.annotIDsCheckboxRight = widgets.CheckBox( + 'IDs', keyPressCallback=self.resetFocus) + self.annotCcaInfoCheckboxRight = widgets.CheckBox( + 'Cell cycle info', keyPressCallback=self.resetFocus) + self.annotNumZslicesCheckboxRight = widgets.CheckBox( + 'No. z-slices/object', keyPressCallback=self.resetFocus + ) + + self.annotContourCheckboxRight = widgets.CheckBox( + 'Contours', keyPressCallback=self.resetFocus) + self.annotSegmMasksCheckboxRight = widgets.CheckBox( + 'Segm. masks', keyPressCallback=self.resetFocus) + + self.drawMothBudLinesCheckboxRight = widgets.CheckBox( + 'Only mother-daughter line', keyPressCallback=self.resetFocus + ) + + self.drawNothingCheckboxRight = widgets.CheckBox( + 'Do not annotate', keyPressCallback=self.resetFocus) + + self.annotOptionsWidgetRight = QWidget() + annotOptionsLayoutRight = QHBoxLayout() + + annotOptionsLayoutRight.addWidget(QLabel(' ')) + annotOptionsLayoutRight.addWidget(QLabel(' | ')) + annotOptionsLayoutRight.addWidget(self.annotIDsCheckboxRight) + annotOptionsLayoutRight.addWidget(self.annotCcaInfoCheckboxRight) + annotOptionsLayoutRight.addWidget(self.drawMothBudLinesCheckboxRight) + annotOptionsLayoutRight.addWidget(self.annotNumZslicesCheckboxRight) + annotOptionsLayoutRight.addWidget(QLabel(' | ')) + annotOptionsLayoutRight.addWidget(self.annotContourCheckboxRight) + annotOptionsLayoutRight.addWidget(self.annotSegmMasksCheckboxRight) + annotOptionsLayoutRight.addWidget(QLabel(' | ')) + annotOptionsLayoutRight.addWidget(self.drawNothingCheckboxRight) + self.annotOptionsLayoutRight = annotOptionsLayoutRight + + self.annotOptionsWidgetRight.setLayout(annotOptionsLayoutRight) + + # Frames scrollbar + self.navigateScrollBar = widgets.navigateQScrollBar(Qt.Horizontal) + self.navigateScrollBar.setDisabled(True) + self.navigateScrollBar.setMinimum(1) + self.navigateScrollBar.setMaximum(1) + self.navigateScrollBar.setToolTip( + 'NOTE: The maximum frame number that can be visualized with this ' + 'scrollbar\n' + 'is the last visited frame with the selected mode\n' + '(see "Mode" selector on the top-right).\n\n' + 'If the scrollbar does not move it means that you never visited\n' + 'any frame with current mode.\n\n' + 'Note that the "Viewer" mode allows you to scroll ALL frames.' + ) + t_label = QLabel('frame n. ') + t_label.setFont(_font) + self.t_label = t_label + + # z-slice scrollbars + self.zSliceScrollBar = widgets.linkedQScrollbar(Qt.Horizontal) + + self.zProjComboBox = widgets.ComboBox() + self.zProjComboBox.setFont(_font) + self.zProjComboBox.addItems([ + 'single z-slice', + 'max z-projection', + 'mean z-projection', + 'median z-proj.' + ]) + self.zProjLockViewButton = widgets.LockPushButton() + self.zProjLockViewButton.setCheckable(True) + self.zProjLockViewButton.setToolTip( + 'If active, the selected z-slice view is applied to all frames' + ) + self.zProjLockViewButton.hide() + + self.switchPlaneCombobox = widgets.SwitchPlaneCombobox() + self.switchPlaneCombobox.setToolTip( + 'Switch viewed plane' + ) + + self.zSliceOverlay_SB = widgets.ScrollBar(Qt.Horizontal) + _z_label = QLabel('Overlay z-slice ') + _z_label.setFont(_font) + _z_label.setDisabled(True) + self.overlay_z_label = _z_label + + self.zProjOverlay_CB = widgets.ComboBox() + self.zProjOverlay_CB.setFont(_font) + self.zProjOverlay_CB.addItems([ + 'single z-slice', 'max z-projection', 'mean z-projection', + 'median z-proj.', 'same as above' + ]) + self.zProjOverlay_CB.setCurrentIndex(4) + self.zSliceOverlay_SB.setDisabled(True) + + self.img1BottomGroupbox = self.gui_getImg1BottomWidgets() + + def gui_getImg1BottomWidgets(self): + bottomLeftLayout = QGridLayout() + self.bottomLeftLayout = bottomLeftLayout + container = QGroupBox('Navigate and annotate left image') + + row = 0 + bottomLeftLayout.addWidget(self.annotOptionsWidget, row, 0, 1, 4) + # bottomLeftLayout.addWidget( + # self.drawIDsContComboBox, row, 1, 1, 2, + # alignment=Qt.AlignCenter + # ) + + # bottomLeftLayout.addWidget( + # self.showTreeInfoCheckbox, row, 0, 1, 1, + # alignment=Qt.AlignCenter + # ) + + row += 1 + navWidgetsLayout = QHBoxLayout() + self.navSpinBox = widgets.SpinBox(disableKeyPress=True) + self.navSpinBox.setMinimum(1) + self.navSpinBox.setMaximum(100) + self.navSizeLabel = QLabel('/ND') + navWidgetsLayout.addWidget(self.t_label) + navWidgetsLayout.addWidget(self.navSpinBox) + navWidgetsLayout.addWidget(self.navSizeLabel) + bottomLeftLayout.addLayout( + navWidgetsLayout, row, 0, alignment=Qt.AlignRight + ) + bottomLeftLayout.addWidget(self.navigateScrollBar, row, 1, 1, 2) + sp = self.navigateScrollBar.sizePolicy() + sp.setRetainSizeWhenHidden(True) + self.navigateScrollBar.setSizePolicy(sp) + self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) + self.navSpinBox.editingFinished.connect( + self.navigateSpinboxEditingFinished + ) + self.navSpinBox.sigUpClicked.connect( + self.navigateSpinboxEditingFinished + ) + self.navSpinBox.sigDownClicked.connect( + self.navigateSpinboxEditingFinished + ) + + self.lastTrackedFrameLabel = QLabel() + self.lastTrackedFrameLabel.setFont(_font) + bottomLeftLayout.addWidget(self.lastTrackedFrameLabel, row, 3) + + row += 1 + zSliceCheckboxLayout = QHBoxLayout() + self.zSliceCheckbox = QCheckBox('z-slice') + self.zSliceSpinbox = widgets.SpinBox(disableKeyPress=True) + self.zSliceSpinbox.setMinimum(1) + self.SizeZlabel = QLabel('/ND') + self.zSliceCheckbox.setToolTip( + 'Activate/deactivate control of the z-slices with keyboard arrows.\n\n' + 'SHORTCUT to toggle ON/OFF: "Z" key' + ) + zSliceCheckboxLayout.addWidget(self.zSliceCheckbox) + zSliceCheckboxLayout.addWidget(self.zSliceSpinbox) + zSliceCheckboxLayout.addWidget(self.SizeZlabel) + bottomLeftLayout.addLayout( + zSliceCheckboxLayout, row, 0, alignment=Qt.AlignRight + ) + bottomLeftLayout.addWidget(self.zSliceScrollBar, row, 1, 1, 2) + bottomLeftLayout.addWidget(self.zProjComboBox, row, 3) + bottomLeftLayout.addWidget(self.zProjLockViewButton, row, 4) + bottomLeftLayout.addWidget(self.switchPlaneCombobox, row, 5) + self.zSliceSpinbox.connectValueChanged(self.onZsliceSpinboxValueChange) + self.zSliceSpinbox.editingFinished.connect(self.zSliceScrollBarReleased) + + row += 1 + bottomLeftLayout.addWidget( + self.overlay_z_label, row, 0, alignment=Qt.AlignRight + ) + bottomLeftLayout.addWidget(self.zSliceOverlay_SB, row, 1, 1, 2) + + bottomLeftLayout.addWidget(self.zProjOverlay_CB, row, 3) + + row += 1 + self.alphaScrollbarRow = row + + bottomLeftLayout.setColumnStretch(0,0) + bottomLeftLayout.setColumnStretch(1,3) + bottomLeftLayout.setColumnStretch(2,0) + + container.setLayout(bottomLeftLayout) + return container + + def gui_createLabWidgets(self): + bottomRightLayout = QVBoxLayout() + self.rightBottomGroupbox = widgets.GroupBox( + 'Annotate right image independent of left image', + keyPressCallback=self.resetFocus + ) + self.rightBottomGroupbox.setCheckable(True) + self.rightBottomGroupbox.setChecked(False) + self.rightBottomGroupbox.hide() + + self.annotateRightHowCombobox = widgets.ComboBox() + self.annotateRightHowCombobox.setFont(_font) + self.annotateRightHowCombobox.addItems(self.drawIDsContComboBoxSegmItems) + self.annotateRightHowCombobox.setCurrentIndex( + self.drawIDsContComboBox.currentIndex() + ) + self.annotateRightHowCombobox.setVisible(False) + + self.annotOptionsLayoutRight.addWidget(self.annotateRightHowCombobox) + + self.rightImageFramesScrollbar = widgets.ScrollBarWithNumericControl( + labelText='Frame n. ' + ) + self.rightImageFramesScrollbar.setVisible(False) + + bottomRightLayout.addWidget(self.annotOptionsWidgetRight) + bottomRightLayout.addWidget(self.rightImageFramesScrollbar) + bottomRightLayout.addStretch(1) + + self.rightBottomGroupbox.setLayout(bottomRightLayout) + + self.rightBottomGroupbox.toggled.connect(self.rightImageControlsToggled) + + def rightImageControlsToggled(self, checked): + if self.isDataLoading: + return + if checked: + self.annotateRightHowCombobox.setCurrentText( + self.drawIDsContComboBox.currentText() + ) + self.updateAllImages() + + def setFocusGraphics(self): + self.graphLayout.setFocus() + + def setFocusMain(self): + # on macOS with Qt6 setFocus causes crashes. Disabled for now. + return + + def resetFocus(self): + self.setFocusGraphics() + self.setFocusMain() + + def gui_createBottomWidgetsToBottomLayout(self): + # self.bottomDockWidget = QDockWidget(self) + bottomScrollArea = widgets.ScrollArea(resizeVerticalOnShow=True) + bottomScrollArea.sigLeaveEvent.connect(self.setFocusMain) + bottomWidget = QWidget() + bottomScrollAreaLayout = QVBoxLayout() + self.bottomLayout = QHBoxLayout() + self.bottomLayout.addLayout(self.quickSettingsLayout) + self.bottomLayout.addStretch(1) + self.bottomLayout.addWidget(self.img1BottomGroupbox) + self.bottomLayout.addStretch(1) + self.bottomLayout.addWidget(self.rightBottomGroupbox) + self.bottomLayout.addStretch(1) + + bottomScrollAreaLayout.addLayout(self.bottomLayout) + bottomScrollAreaLayout.addStretch(1) + + bottomWidget.setLayout(bottomScrollAreaLayout) + bottomScrollArea.setWidgetResizable(True) + bottomScrollArea.setWidget(bottomWidget) + self.bottomScrollArea = bottomScrollArea + + if 'bottom_sliders_zoom_perc' in self.df_settings.index: + val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value']) + zoom_perc = val + else: + zoom_perc = 100 + self.bottomLayoutContextMenu = QMenu('Bottom layout', self) + zoomMenu = self.bottomLayoutContextMenu.addMenu('Zoom') + actions = [] + self.bottomLayoutContextMenu.zoomActionGroup = QActionGroup(zoomMenu) + for perc in np.arange(50, 151, 10): + action = QAction(f'{perc}%', zoomMenu) + action.setCheckable(True) + if perc == zoom_perc: + action.setChecked(True) + action.toggled.connect(self.zoomBottomLayoutActionTriggered) + actions.append(action) + self.bottomLayoutContextMenu.zoomActionGroup.addAction(action) + zoomMenu.addActions(actions) + resetAction = self.bottomLayoutContextMenu.addAction( + 'Reset default height' + ) + resetAction.triggered.connect(self.resizeGui) + retainSpaceAction = self.bottomLayoutContextMenu.addAction( + 'Retain space of hidden sliders' + ) + retainSpaceAction.setCheckable(True) + if 'retain_space_hidden_sliders' in self.df_settings.index: + retainSpaceChecked = ( + self.df_settings.at['retain_space_hidden_sliders', 'value'] + == 'Yes' + ) + else: + retainSpaceChecked = True + retainSpaceAction.setChecked(retainSpaceChecked) + retainSpaceAction.toggled.connect(self.retainSpaceSlidersToggled) + self.retainSpaceSlidersAction = retainSpaceAction + self.setBottomLayoutStretch() + + def gui_resetBottomLayoutHeight(self): + self.h = self.defaultWidgetHeightBottomLayout + self.checkBoxesHeight = 14 + self.fontPixelSize = 11 + self.resizeSlidersArea() + + def gui_createGraphicsPlots(self): + self.graphLayout = pg.GraphicsLayoutWidget() + if self.invertBwAction.isChecked(): + self.graphLayout.setBackground(graphLayoutBkgrColor) + self.titleColor = 'black' + else: + self.graphLayout.setBackground(darkBkgrColor) + self.titleColor = 'white' + + self.lutItemsLayout = self.graphLayout.addLayout(row=1, col=0) + # self.lutItemsLayout.setBorder('w') + + # Left plot + self.ax1 = widgets.MainPlotItem(showWelcomeText=True) + self.ax1.invertY(True) + self.ax1.setAspectLocked(True) + self.ax1.hideAxis('bottom') + self.ax1.hideAxis('left') + self.plotsCol = 1 + self.graphLayout.addItem(self.ax1, row=1, col=1) + + # Right plot + self.ax2 = widgets.MainPlotItem() + self.ax2.setAspectLocked(True) + self.ax2.invertY(True) + self.ax2.hideAxis('bottom') + self.ax2.hideAxis('left') + # self.currentFrameLabelItem = pg.LabelItem( + # color=self.titleColor, size='13px' + # ) + self.graphLayout.addItem(self.ax2, row=1, col=2) + + def gui_addGraphicsItems(self): + # Auto image adjustment button + proxy = QGraphicsProxyWidget() + equalizeHistPushButton = QPushButton("Enhance contrast") + widthHint = equalizeHistPushButton.sizeHint().width() + equalizeHistPushButton.setMaximumWidth(widthHint) + equalizeHistPushButton.setCheckable(True) + if not self.invertBwAction.isChecked(): + equalizeHistPushButton.setStyleSheet( + 'QPushButton {background-color: #282828; color: #F0F0F0;}' + ) + self.equalizeHistPushButton = equalizeHistPushButton + proxy.setWidget(equalizeHistPushButton) + self.graphLayout.addItem(proxy, row=0, col=0) + self.equalizeHistPushButton = equalizeHistPushButton + + # Left image histogram + self.imgGrad = widgets.myHistogramLUTitem(parent=self, name='image') + self.imgGrad.restoreState(self.df_settings) + self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) + for action in self.imgGrad.rescaleActionGroup.actions(): + if action.text() == self.defaultRescaleIntensHow: + action.setChecked(True) + self.rescaleIntensMenu.addAction(action) + + # Colormap gradient widget + self.labelsGrad = widgets.labelsGradientWidget(parent=self) + try: + stateFound = self.labelsGrad.restoreState(self.df_settings) + except Exception as e: + self.logger.exception(traceback.format_exc()) + print('======================================') + self.logger.info( + 'Failed to restore previously used colormap. ' + 'Using default colormap "viridis"' + ) + self.labelsGrad.item.loadPreset('viridis') + + # Add actions to imgGrad gradient item + self.imgGrad.gradient.menu.addAction( + self.labelsGrad.showLabelsImgAction + ) + self.imgGrad.gradient.menu.addAction( + self.labelsGrad.showRightImgAction + ) + self.imgGrad.gradient.menu.addAction( + self.labelsGrad.showNextFrameAction + ) + + self.imgGrad.gradient.menu.addSeparator() + + self.imgGrad.gradient.menu.addMenu(self.exportMenu) + + # Add actions to view menu + self.viewMenu.addAction(self.labelsGrad.showLabelsImgAction) + self.viewMenu.addAction(self.labelsGrad.showRightImgAction) + + # Right image histogram + self.imgGradRight = widgets.baseHistogramLUTitem( + name='image', parent=self, gradientPosition='left' + ) + self.imgGradRight.gradient.menu.addAction( + self.labelsGrad.showLabelsImgAction + ) + self.imgGradRight.gradient.menu.addAction( + self.labelsGrad.showRightImgAction + ) + self.imgGradRight.gradient.menu.addAction( + self.labelsGrad.showNextFrameAction + ) + + self.imgGrad.setChildLutItem(self.imgGradRight) + + # Title + self.titleLabel = pg.LabelItem( + justify='center', color=self.titleColor, size='14pt' + ) + self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) + + def gui_createTextAnnotColors(self, r, g, b, custom=False): + if custom: + self.objLabelAnnotRgb = (int(r), int(g), int(b)) + self.SphaseAnnotRgb = (int(r*0.9), int(r*0.9), int(b*0.9)) + self.G1phaseAnnotRgba = (int(r*0.8), int(g*0.8), int(b*0.8), 220) + else: + self.objLabelAnnotRgb = (255, 255, 255) # white + self.SphaseAnnotRgb = (229, 229, 229) + self.G1phaseAnnotRgba = (204, 204, 204, 220) + self.dividedAnnotRgb = (245, 188, 1) # orange + + self.emptyBrush = pg.mkBrush((0,0,0,0)) + self.emptyPen = pg.mkPen((0,0,0,0)) + + def gui_setTextAnnotColors(self): + self.textAnnot[0].setColors( + self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, + self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb + ) + + self.textAnnot[1].setColors( + self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, + self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb + ) + + + def gui_createPlotItems(self): + if 'textIDsColor' in self.df_settings.index: + rgbString = self.df_settings.at['textIDsColor', 'value'] + r, g, b = colors.rgb_str_to_values(rgbString) + self.gui_createTextAnnotColors(r, g, b, custom=True) + self.textIDsColorButton.setColor((r, g, b)) + else: + self.gui_createTextAnnotColors(0,0,0, custom=False) + + if 'labels_text_color' in self.df_settings.index: + rgbString = self.df_settings.at['labels_text_color', 'value'] + r, g, b = colors.rgb_str_to_values(rgbString) + self.ax2_textColor = (r, g, b) + else: + self.ax2_textColor = (255, 0, 0) + + self.emptyLab = np.zeros((2,2), dtype=np.uint8) + + # Right image item linked to left + self.rightImageItem = widgets.ChildImageItem( + linkedScrollbar=self.rightImageFramesScrollbar + ) + self.imgGradRight.setImageItem(self.rightImageItem) + self.ax2.addItem(self.rightImageItem) + + # Left image + self.img1 = widgets.ParentImageItem( + linkedImageItem=self.rightImageItem, + activatingActions=( + self.labelsGrad.showRightImgAction, + self.labelsGrad.showNextFrameAction + ) + ) + self.imgGrad.setImageItem(self.img1) + self.img1.lutItem = self.imgGrad + self.imgGrad.sigRescaleIntes.connect(self.rescaleIntensitiesLut) + self.ax1.addBaseImageItem(self.img1) + + # RGBA image for true transparency mode + self.rgbaImg1 = pg.ImageItem() + + # self.rgbaImg1.setImage(self.emptyLab) + + # Right image + self.img2 = widgets.labImageItem() + self.ax2.addItem(self.img2) + + self.topLayerItems = [] + self.topLayerItemsRight = [] + + self.gui_createContourPens() + self.gui_createMothBudLinePens() + + self.eraserCirclePen = pg.mkPen(width=1.5, color='r') + + # Temporary line item connecting bud to new mother + self.BudMothTempLine = pg.PlotDataItem(pen=self.NewBudMoth_Pen) + self.topLayerItems.append(self.BudMothTempLine) + + # Temporary line item connecting objects to merge + self.mergeObjsTempLine = widgets.PlotCurveItem(pen=self.redDashLinePen) + self.topLayerItems.append(self.mergeObjsTempLine) + + # Overlay segm. masks item + self.labelsLayerImg1 = widgets.BaseLabelsImageItem() + self.ax1.addItem(self.labelsLayerImg1) + + self.labelsLayerRightImg = widgets.BaseLabelsImageItem() + self.ax2.addItem(self.labelsLayerRightImg) + + # Red/green border rect item + self.GreenLinePen = pg.mkPen(color='g', width=2) + self.RedLinePen = pg.mkPen(color='r', width=2) + self.ax1BorderLine = pg.PlotDataItem() + self.topLayerItems.append(self.ax1BorderLine) + self.ax2BorderLine = pg.PlotDataItem(pen=pg.mkPen(color='r', width=2)) + self.topLayerItems.append(self.ax2BorderLine) + + # Brush/Eraser/Wand.. layer item + self.tempLayerRightImage = pg.ImageItem() + self.tempLayerImg1 = widgets.ParentImageItem( + linkedImageItem=self.tempLayerRightImage, + activatingAction=(self.labelsGrad.showRightImgAction, ) + ) + self.topLayerItems.append(self.tempLayerImg1) + self.topLayerItemsRight.append(self.tempLayerRightImage) + + # Highlighted ID layer items + self.highLightIDLayerImg1 = pg.ImageItem() + self.topLayerItems.append(self.highLightIDLayerImg1) + + # Highlighted ID layer items + self.highLightIDLayerRightImage = pg.ImageItem() + self.topLayerItemsRight.append(self.highLightIDLayerRightImage) + + # Keep IDs temp layers + self.keepIDsTempLayerRight = pg.ImageItem() + self.keepIDsTempLayerLeft = widgets.ParentImageItem( + linkedImageItem=self.keepIDsTempLayerRight, + activatingAction=self.labelsGrad.showRightImgAction + ) + self.topLayerItems.append(self.keepIDsTempLayerLeft) + self.topLayerItemsRight.append(self.keepIDsTempLayerRight) + + # Searched ID contour + self.searchedIDitemRight = pg.ScatterPlotItem() + self.searchedIDitemRight.setData( + [], [], symbol='s', pxMode=False, size=1, + brush=pg.mkBrush(color=(255,0,0,150)), + pen=pg.mkPen(width=2, color='r'), tip=None + ) + self.searchedIDitemLeft = pg.ScatterPlotItem() + self.searchedIDitemLeft.setData( + [], [], symbol='s', pxMode=False, size=1, + brush=pg.mkBrush(color=(255,0,0,150)), + pen=pg.mkPen(width=2, color='r'), tip=None + ) + self.topLayerItems.append(self.searchedIDitemLeft) + self.topLayerItemsRight.append(self.searchedIDitemRight) + + + # Brush circle img1 + self.ax1_BrushCircle = pg.ScatterPlotItem() + self.ax1_BrushCircle.setData( + [], [], symbol='o', pxMode=False, + brush=pg.mkBrush((255,255,255,50)), + pen=pg.mkPen(width=2), tip=None + ) + self.topLayerItems.append(self.ax1_BrushCircle) + + # Eraser circle img1 + self.ax1_EraserCircle = pg.ScatterPlotItem() + self.ax1_EraserCircle.setData( + [], [], symbol='o', pxMode=False, + brush=None, pen=self.eraserCirclePen, tip=None + ) + self.topLayerItems.append(self.ax1_EraserCircle) + + self.ax1_EraserX = pg.ScatterPlotItem() + self.ax1_EraserX.setData( + [], [], symbol='x', pxMode=False, size=3, + brush=pg.mkBrush(color=(255,0,0,50)), + pen=pg.mkPen(width=1, color='r'), tip=None + ) + self.topLayerItems.append(self.ax1_EraserX) + + # Brush circle img1 + self.labelRoiCircItemLeft = widgets.LabelRoiCircularItem() + self.labelRoiCircItemLeft.cleared = False + self.labelRoiCircItemLeft.setData( + [], [], symbol='o', pxMode=False, + brush=pg.mkBrush(color=(255,0,0,0)), + pen=pg.mkPen(color='r', width=2), tip=None + ) + self.labelRoiCircItemRight = widgets.LabelRoiCircularItem() + self.labelRoiCircItemRight.cleared = False + self.labelRoiCircItemRight.setData( + [], [], symbol='o', pxMode=False, + brush=pg.mkBrush(color=(255,0,0,0)), + pen=pg.mkPen(color='r', width=2), tip=None + ) + self.topLayerItems.append(self.labelRoiCircItemLeft) + self.topLayerItemsRight.append(self.labelRoiCircItemRight) + + self.ax1_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() + self.ax1_binnedIDs_ScatterPlot.setData( + [], [], symbol='t', pxMode=False, + brush=pg.mkBrush((255,0,0,50)), size=15, + pen=pg.mkPen(width=3, color='r'), tip=None + ) + self.topLayerItems.append(self.ax1_binnedIDs_ScatterPlot) + + self.ax1_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() + self.ax1_ripIDs_ScatterPlot.setData( + [], [], symbol='x', pxMode=False, + brush=pg.mkBrush((255,0,0,50)), size=15, + pen=pg.mkPen(width=2, color='r'), tip=None + ) + self.topLayerItems.append(self.ax1_ripIDs_ScatterPlot) + + # Ruler plotItem and scatterItem + rulerPen = pg.mkPen(color='r', style=Qt.DashLine, width=2) + self.ax1_rulerPlotItem = widgets.RulerPlotItem(pen=rulerPen) + self.ax1_rulerAnchorsItem = pg.ScatterPlotItem( + symbol='o', size=9, + brush=pg.mkBrush((255,0,0,50)), + pen=pg.mkPen((255,0,0), width=2), tip=None + ) + self.topLayerItems.append(self.ax1_rulerPlotItem) + self.topLayerItems.append(self.ax1_rulerPlotItem.labelItem) + self.topLayerItems.append(self.ax1_rulerAnchorsItem) + + # Start point of polyline roi + self.ax1_point_ScatterPlot = pg.ScatterPlotItem() + self.ax1_point_ScatterPlot.setData( + [], [], symbol='o', pxMode=False, size=3, + pen=pg.mkPen(width=2, color='r'), + brush=pg.mkBrush((255,0,0,50)), tip=None + ) + self.topLayerItems.append(self.ax1_point_ScatterPlot) + + # Experimental: scatter plot to add a point marker + self.startPointPolyLineItem = pg.ScatterPlotItem() + self.startPointPolyLineItem.setData( + [], [], symbol='o', size=9, + pen=pg.mkPen(width=2, color='r'), + brush=pg.mkBrush((255,0,0,50)), + hoverable=True, hoverBrush=pg.mkBrush((255,0,0,255)), tip=None + ) + self.topLayerItems.append(self.startPointPolyLineItem) + + # Eraser circle img2 + self.ax2_EraserCircle = pg.ScatterPlotItem() + self.ax2_EraserCircle.setData( + [], [], symbol='o', pxMode=False, brush=None, + pen=self.eraserCirclePen, tip=None + ) + self.ax2.addItem(self.ax2_EraserCircle) + self.ax2_EraserX = pg.ScatterPlotItem() + self.ax2_EraserX.setData( + [], [], symbol='x', pxMode=False, size=3, + brush=pg.mkBrush(color=(255,0,0,50)), + pen=pg.mkPen(width=1.5, color='r') + ) + self.ax2.addItem(self.ax2_EraserX) + + # Brush circle img2 + self.ax2_BrushCirclePen = pg.mkPen(width=2) + self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50)) + self.ax2_BrushCircle = pg.ScatterPlotItem() + self.ax2_BrushCircle.setData( + [], [], symbol='o', pxMode=False, + brush=self.ax2_BrushCircleBrush, + pen=self.ax2_BrushCirclePen, tip=None + ) + self.ax2.addItem(self.ax2_BrushCircle) + + # Annotated metadata markers (ScatterPlotItem) + self.ax2_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() + self.ax2_binnedIDs_ScatterPlot.setData( + [], [], symbol='t', pxMode=False, + brush=pg.mkBrush((255,0,0,50)), size=15, + pen=pg.mkPen(width=3, color='r'), tip=None + ) + self.ax2.addItem(self.ax2_binnedIDs_ScatterPlot) + + self.ax2_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() + self.ax2_ripIDs_ScatterPlot.setData( + [], [], symbol='x', pxMode=False, + brush=pg.mkBrush((255,0,0,50)), size=15, + pen=pg.mkPen(width=2, color='r'), tip=None + ) + self.ax2.addItem(self.ax2_ripIDs_ScatterPlot) + + self.freeRoiItem = widgets.PlotCurveItem( + pen=pg.mkPen(color='r', width=2) + ) + self.topLayerItems.append(self.freeRoiItem) + + self.warnPairingItem = widgets.PlotCurveItem( + pen=pg.mkPen(color='r', width=5, style=Qt.DashLine), + pxMode=False + ) + self.topLayerItems.append(self.warnPairingItem) + + self.exportMaskImageItem = pg.ImageItem() + + self.ghostContourItemLeft = widgets.GhostContourItem(self.ax1) + self.ghostContourItemRight = widgets.GhostContourItem(self.ax2) + + self.ghostMaskItemLeft = widgets.GhostMaskItem(self.ax1) + self.ghostMaskItemRight = widgets.GhostMaskItem(self.ax2) + + self.manualBackgroundObjItem = widgets.GhostContourItem( + self.ax1, penColor='r', textColor='r' + ) + self.manualBackgroundImageItem = pg.ImageItem() + + def gui_createZoomRectItem(self): + Y, X = self.currentLab2D.shape + # Label ROI rectangle + pen = pg.mkPen('r', width=3, style=Qt.DashLine) + self.zoomRectItem = widgets.ZoomROI( + (0,0), (0,0), + maxBounds=QRectF(QRect(0,0,X,Y)), + scaleSnap=True, + translateSnap=True, + pen=pen, hoverPen=pen + ) + + def gui_createLabelRoiItem(self): + Y, X = self.currentLab2D.shape + # Label ROI rectangle + pen = pg.mkPen('r', width=3) + self.labelRoiItem = widgets.ROI( + (0,0), (0,0), + maxBounds=QRectF(QRect(0,0,X,Y)), + scaleSnap=True, + translateSnap=True, + pen=pen, hoverPen=pen + ) + + posData = self.data[self.pos_i] + if self.labelRoiZdepthSpinbox.value() == 0: + self.labelRoiZdepthSpinbox.setValue(posData.SizeZ) + self.labelRoiZdepthSpinbox.setMaximum(posData.SizeZ+1) + + def gui_createOverlayColors(self): + fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] + self.logger.info( + f'Number of TIFF files detected: {len(fluoChannels)}' + ) + self.overlayColors = {} + for c, ch in enumerate(fluoChannels): + if f'{ch}_rgb' in self.df_settings.index: + rgb_text = self.df_settings.at[f'{ch}_rgb', 'value'] + rgb = tuple([int(val) for val in rgb_text.split('_')]) + self.overlayColors[ch] = rgb + else: + if c >= len(self.overlayRGBs) -1: + i = c/len(fluoChannels) + additional_color_num = c - len(self.overlayRGBs) + 1 + rgbs = [ + tuple([round(c*255) for c in self.overlayCmap(i)][:3]) + for _ in range(additional_color_num) + ] + self.overlayRGBs.extend(rgbs) + rgb = colors.FLUO_CHANNELS_COLORS.get(ch, self.overlayRGBs[c]) + self.overlayColors[ch] = rgb + + def gui_createOverlayItems(self): + self.imgGrad.setAxisLabel(self.user_ch_name) + self.baseLayerToolbutton = widgets.OverlayChannelToolButton( + self.user_ch_name, self.imgGrad + ) + self.baseLayerToolbutton.setChecked(True) + self.baseLayerToolbutton.clicked.connect( + self.overlayChannelToolbuttonClicked + ) + self.allOverlayToolbuttons = { + self.user_ch_name: self.baseLayerToolbutton + } + self.allOverlayToolbuttonsByIdx = { + 0: self.baseLayerToolbutton + } + self.baseLayerToolbutton.action = ( + self.overlayToolbar.addWidget(self.baseLayerToolbutton) + ) + self.overlayLayersItems = {} + self.overlayToolbarAreChannelsChecked = {} + fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] + for c, ch in enumerate(fluoChannels): + overlayItems = self.getOverlayItems(ch, c+1) + self.overlayLayersItems[ch] = overlayItems + imageItem, lutItem = overlayItems[:2] + self.ax1.addItem(imageItem) + self.lutItemsLayout.addItem(lutItem, row=0, col=c+1) + toolbutton = overlayItems[3] + self.allOverlayToolbuttons[ch] = toolbutton + self.allOverlayToolbuttonsByIdx[c+1] = toolbutton + + self.overlayToolbuttonsSep = self.overlayToolbar.addSeparator() + self.plotsCol = len(self.ch_names) + + self.ax1.addImageItem(self.rgbaImg1) + + def gui_getLostObjScatterItem(self): + self.objLostAnnotRgb = (245, 184, 0) + brush = pg.mkBrush((*self.objLostAnnotRgb, 150)) + pen = pg.mkPen(self.objLostAnnotRgb, width=1) + lostObjScatterItem = pg.ScatterPlotItem( + size=self.contLineWeight+1, pen=pen, + brush=brush, pxMode=False, symbol='s' + ) + return lostObjScatterItem + + def gui_getTrackedLostObjScatterItem(self): + self.objLostTrackedAnnotRgb = (0, 255, 0) + brush = pg.mkBrush((*self.objLostTrackedAnnotRgb, 150)) + pen = pg.mkPen(self.objLostTrackedAnnotRgb, width=1) + lostObjScatterItem = pg.ScatterPlotItem( + size=self.contLineWeight+1, pen=pen, + brush=brush, pxMode=False, symbol='s' + ) + return lostObjScatterItem + + def _gui_createGraphicsItems(self): + for _posData in self.data: + _posData.allData_li = [None]*_posData.SizeT + + posData = self.data[self.pos_i] + + allIDs, posData = core.count_objects(posData, self.logger.info) + + self.highLowResAction.setChecked(True) + numItems = len(allIDs) + if numItems > 1500: + cancel, switchToLowRes = _warnings.warnTooManyItems( + self, numItems, self.progressWin + ) + if cancel: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.loadingDataAborted() + return + if switchToLowRes: + self.highLowResAction.setChecked(False) + else: + # Many items requires pxMode active to be fast enough + self.pxModeAction.setChecked(True) + + self.logger.info(f'Creating graphical items...') + + self.ax1_contoursImageItem = pg.ImageItem() + + self.ax1_lostObjImageItem = pg.ImageItem() + self.ax2_lostObjImageItem = pg.ImageItem() + + self.ax1_lostTrackedObjImageItem = pg.ImageItem() + self.ax2_lostTrackedObjImageItem = pg.ImageItem() + + self.ax1_oldMothBudLinesItem = pg.ScatterPlotItem( + symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, + size=self.mothBudLineWeight, pen=None + ) + self.ax1_newMothBudLinesItem = pg.ScatterPlotItem( + symbol='s', pxMode=False, brush=self.newMothBudLineBrush, + size=self.mothBudLineWeight, pen=None + ) + self.ax1_lostObjScatterItem = self.gui_getLostObjScatterItem() + self.yellowContourScatterItem = self.gui_getLostObjScatterItem() + + self.ax1_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() + self.greenContourScatterItem = self.gui_getTrackedLostObjScatterItem() + + brush = pg.mkBrush((0,255,0,200)) + pen = pg.mkPen('g', width=1) + self.ccaFailedScatterItem = pg.ScatterPlotItem( + size=self.contLineWeight+1, pen=pen, + brush=brush, pxMode=False, symbol='s' + ) + + self.ax2_contoursImageItem = pg.ImageItem() + self.ax2_oldMothBudLinesItem = pg.ScatterPlotItem( + symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, + size=self.mothBudLineWeight, pen=None + ) + self.ax2_newMothBudLinesItem = pg.ScatterPlotItem( + symbol='s', pxMode=False, brush=self.newMothBudLineBrush, + size=self.mothBudLineWeight, pen=None + ) + self.ax2_lostObjScatterItem = self.gui_getLostObjScatterItem() + self.ax2_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() + + self.gui_createTextAnnotItems(allIDs) # here + self.gui_setTextAnnotColors()# here + + self.setDisabledAnnotOptions(False) + + self.progressWin.mainPbar.setMaximum(0) + self.gui_addOverlayLayerItems() + self.gui_addTopLayerItems() + + self.gui_addCreatedAxesItems() + self.gui_add_ax_cursors() + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.loadingDataCompleted() + + def gui_createTextAnnotItems(self, allIDs): + self.textAnnot = {} + isHighResolution = self.highLowResAction.isChecked() + pxMode = self.pxModeAction.isChecked() + for ax in range(2): + ax_textAnnot = annotate.TextAnnotations() + ax_textAnnot.initFonts(self.fontSize) + ax_textAnnot.createItems( + isHighResolution, allIDs, pxMode=pxMode + ) + self.textAnnot[ax] = ax_textAnnot + + def gui_addOverlayLayerItems(self): + for items in self.overlayLabelsItems.values(): + imageItem, contoursItem, gradItem = items + self.ax1.addItem(imageItem) + self.ax1.addItem(contoursItem) + + def gui_addTopLayerItems(self): + for item in self.topLayerItems: + self.ax1.addItem(item) + + for item in self.topLayerItemsRight: + self.ax2.addItem(item) + + # self.ax2.addItem(self.currentFrameLabelItem) + + def gui_createMothBudLinePens(self): + if 'mothBudLineSize' in self.df_settings.index: + val = self.df_settings.at['mothBudLineSize', 'value'] + self.mothBudLineWeight = int(val) + else: + self.mothBudLineWeight = 2 + + self.newMothBudlineColor = (255, 0, 0) + if 'mothBudLineColor' in self.df_settings.index: + val = self.df_settings.at['mothBudLineColor', 'value'] + rgba = colors.rgba_str_to_values(val) + self.mothBudLineColor = rgba[0:3] + else: + self.mothBudLineColor = (255,165,0) + + try: + self.imgGrad.mothBudLineColorButton.sigColorChanging.disconnect() + self.imgGrad.mothBudLineColorButton.sigColorChanged.disconnect() + except Exception as e: + pass + try: + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): + act.toggled.disconnect() + except Exception as e: + pass + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): + if act.lineWeight == self.mothBudLineWeight: + act.setChecked(True) + else: + act.setChecked(False) + self.imgGrad.mothBudLineColorButton.setColor(self.mothBudLineColor[:3]) + + self.imgGrad.mothBudLineColorButton.sigColorChanging.connect( + self.updateMothBudLineColour + ) + self.imgGrad.mothBudLineColorButton.sigColorChanged.connect( + self.saveMothBudLineColour + ) + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): + act.toggled.connect(self.mothBudLineWeightToggled) + + # MOther-bud lines brushes + self.NewBudMoth_Pen = pg.mkPen( + color=self.newMothBudlineColor, width=self.mothBudLineWeight+1, + style=Qt.DashLine + ) + self.OldBudMoth_Pen = pg.mkPen( + color=self.mothBudLineColor, width=self.mothBudLineWeight, + style=Qt.DashLine + ) + + self.redDashLinePen = pg.mkPen( + color='r', width=2, style=Qt.DashLine + ) + + self.oldMothBudLineBrush = pg.mkBrush(self.mothBudLineColor) + self.newMothBudLineBrush = pg.mkBrush(self.newMothBudlineColor) + + def gui_createContourPens(self): + if 'contLineWeight' in self.df_settings.index: + val = self.df_settings.at['contLineWeight', 'value'] + self.contLineWeight = int(val) + else: + self.contLineWeight = 1 + if 'contLineColor' in self.df_settings.index: + val = self.df_settings.at['contLineColor', 'value'] + rgba = colors.rgba_str_to_values(val) + self.contLineColor = rgba + self.newIDlineColor = [min(255, v+50) for v in self.contLineColor] + else: + self.contLineColor = (255, 0, 0, 200) + self.newIDlineColor = (255, 0, 0, 255) + + try: + self.imgGrad.contoursColorButton.sigColorChanging.disconnect() + self.imgGrad.contoursColorButton.sigColorChanged.disconnect() + except Exception as e: + pass + try: + for act in self.imgGrad.contLineWightActionGroup.actions(): + act.toggled.disconnect() + except Exception as e: + pass + for act in self.imgGrad.contLineWightActionGroup.actions(): + if act.lineWeight == self.contLineWeight: + act.setChecked(True) + self.imgGrad.contoursColorButton.setColor(self.contLineColor[:3]) + + self.imgGrad.contoursColorButton.sigColorChanging.connect( + self.updateContColour + ) + self.imgGrad.contoursColorButton.sigColorChanged.connect( + self.saveContColour + ) + for act in self.imgGrad.contLineWightActionGroup.actions(): + act.toggled.connect(self.contLineWeightToggled) + + # Contours pens + self.oldIDs_cpen = pg.mkPen( + color=self.contLineColor, width=self.contLineWeight + ) + self.newIDs_cpen = pg.mkPen( + color=self.newIDlineColor, width=self.contLineWeight+1 + ) + self.tempNewIDs_cpen = pg.mkPen( + color='g', width=self.contLineWeight+1 + ) + + def gui_createGraphicsItems(self): + # Create enough PlotDataItems and LabelItems to draw contours and IDs. + self.progressWin = apps.QDialogWorkerProgress( + title='Creating axes items', parent=self, + pbarDesc='Creating axes items (see progress in the terminal)...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + QTimer.singleShot(50, self._gui_createGraphicsItems) + + def gui_connectGraphicsEvents(self): + self.img1.hoverEvent = self.gui_hoverEventImg1 + self.img2.hoverEvent = self.gui_hoverEventImg2 + self.img1.mousePressEvent = self.gui_mousePressEventImg1 + self.img1.mouseMoveEvent = self.gui_mouseDragEventImg1 + self.img1.mouseReleaseEvent = self.gui_mouseReleaseEventImg1 + self.img2.mousePressEvent = self.gui_mousePressEventImg2 + self.img2.mouseMoveEvent = self.gui_mouseDragEventImg2 + self.img2.mouseReleaseEvent = self.gui_mouseReleaseEventImg2 + self.rightImageItem.mousePressEvent = self.gui_mousePressRightImage + self.rightImageItem.mouseMoveEvent = self.gui_mouseDragRightImage + self.rightImageItem.mouseReleaseEvent = self.gui_mouseReleaseRightImage + self.rightImageItem.hoverEvent = self.gui_hoverEventRightImage + # self.imgGrad.gradient.showMenu = self.gui_gradientContextMenuEvent + self.imgGradRight.gradient.showMenu = self.gui_rightImageShowContextMenu + # self.imgGrad.vb.contextMenuEvent = self.gui_gradientContextMenuEvent + self.ax1.sigRangeChanged.connect(self.viewRangeChanged) + + def gui_initImg1BottomWidgets(self): + self.zSliceScrollBar.hide() + self.zProjComboBox.hide() + self.zProjLockViewButton.hide() + self.zSliceOverlay_SB.hide() + self.zProjOverlay_CB.hide() + self.overlay_z_label.hide() + self.zSliceCheckbox.hide() + self.zSliceSpinbox.hide() + self.SizeZlabel.hide() + + @exception_handler + def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): + modifiers = QGuiApplication.keyboardModifiers() + alt = modifiers == Qt.AltModifier + shift = modifiers == Qt.ShiftModifier + shift_regardless = bool(modifiers & Qt.ShiftModifier) + isMod = alt + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + left_click = event.button() == Qt.MouseButton.LeftButton and not alt + middle_click = self.isMiddleClick(event, modifiers) + right_click = event.button() == Qt.MouseButton.RightButton and not alt + isPanImageClick = self.isPanImageClick(event, modifiers) + eraserON = self.eraserButton.isChecked() + brushON = self.brushButton.isChecked() + separateON = self.separateBudButton.isChecked() + self.typingEditID = False + + # Drag image if neither brush or eraser are On pressed + dragImg = ( + left_click and not eraserON and not + brushON and not middle_click + ) + if isPanImageClick: + dragImg = True + + # Enable dragging of the image window like pyqtgraph original code + if dragImg: + pg.ImageItem.mousePressEvent(self.img2, event) + event.ignore() + return + + if mode == 'Viewer' and middle_click: + self.startBlinkingModeCB() + event.ignore() + return + + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + Y, X = self.get_2Dlab(posData.lab).shape + if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + else: + return + + # Check if right click on ROI + isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click) + if isClickOnDelRoi: + return + + # show gradient widget menu if none of the right-click actions are ON + # and event is not coming from image 1 + is_right_click_action_ON = any([ + b.isChecked() for b in self.checkableQButtonsGroup.buttons() + ]) + is_right_click_custom_ON = any([ + b.isChecked() for b in self.customAnnotDict.keys() + ]) + is_event_from_img1 = False + if hasattr(event, 'isImg1Sender'): + is_event_from_img1 = event.isImg1Sender + + is_only_right_click = ( + right_click and not is_right_click_action_ON and not middle_click + ) + + showLabelsGradMenu = ( + is_only_right_click and not is_event_from_img1 + ) + + if showLabelsGradMenu: + self.labelsGrad.showMenu(event) + event.ignore() + return + + editInViewerMode = ( + (is_right_click_action_ON or is_right_click_custom_ON) + and (right_click or middle_click) and mode=='Viewer' + ) + + if editInViewerMode: + self.startBlinkingModeCB() + event.ignore() + return + + # Left-click is used for brush, eraser, separate bud, curvature tool + # and magic labeller + # Brush and eraser are mutually exclusive but we want to keep the eraser + # or brush ON and disable them temporarily to allow left-click with + # separate ON + canDelete = mode == 'Segmentation and Tracking' or self.isSnapshot + + # Delete ID (set to 0) + if middle_click and canDelete: + t0 = time.perf_counter() + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + delID = self.get_2Dlab(posData.lab)[ydata, xdata] + if delID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + delID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.
' + 'Enter here ID(s) that you want to delete

' + 'You can enter multiple IDs separated by comma', + parent=self, + allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + allowList=True, + isInteger=True + ) + delID_prompt.exec_() + if delID_prompt.cancel: + return + delIDs = delID_prompt.EntryID + else: + delIDs = [delID] + + # Ask to propagate change to all future visited frames + key = 'Delete ID' + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + delIDs, key, doNotShow, + posData.UndoFutFrames_DelID, posData.applyFutFrames_DelID + ) + + if UndoFutFrames is None: + return + + # Store undo state before modifying stuff + self.storeUndoRedoStates(UndoFutFrames) + posData.doNotShowAgain_DelID = doNotShowAgain + posData.UndoFutFrames_DelID = UndoFutFrames + posData.applyFutFrames_DelID = applyFutFrames + includeUnvisited = posData.includeUnvisitedInfo['Delete ID'] + + delID_mask = self.deleteIDmiddleClick( + delIDs, applyFutFrames, includeUnvisited, shift=shift_regardless + ) + if delID_mask.ndim == 3: + delID_mask = delID_mask[self.z_lab()] + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Delete ID') + else: + self.warnEditingWithCca_df('Delete ID', update_images=False) + + self.setImageImg2() + delROIsIDs = self.setAllTextAnnotations() + self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False) + + how = self.drawIDsContComboBox.currentText() + if how.find('overlay segm. masks') != -1: + self.labelsLayerImg1.image[delID_mask] = 0 + self.labelsLayerImg1.setImage(self.labelsLayerImg1.image) + + how_ax2 = self.getAnnotateHowRightImage() + if how_ax2.find('overlay segm. masks') != -1: + self.labelsLayerRightImg.image[delID_mask] = 0 + self.labelsLayerRightImg.setImage(self.labelsLayerRightImg.image) + + self.highlightLostNew() + + # Separate bud or objects with same ID + elif right_click and separateON: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x) + sepID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here ID that you want to split', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + sepID_prompt.exec_() + if sepID_prompt.cancel: + return + else: + ID = sepID_prompt.EntryID + y, x = posData.rp[posData.IDs_idxs[ID]].centroid[-2:] + xdata, ydata = int(x), int(y) + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + max_ID = max(posData.IDs, default=1) + + if self.isSegm3D and not shift: + z = self.zSliceScrollBar.sliderPosition() + posData.lab, splittedIDs = measure.separate_with_label( + posData.lab, posData.rp, [ID], max_ID, + click_coords_list=[(z, ydata, xdata)] + ) + success = True + # self.set_2Dlab(lab2D) + elif not shift: + result = core.split_along_convexity_defects( + ID, self.get_2Dlab(posData.lab), max_ID + ) + lab2D, success, splittedIDs = result + self.set_2Dlab(lab2D) + else: + success = False + + # If automatic bud separation was not successfull call manual one + if not success: + posData.disableAutoActivateViewerWindow = True + img = self.getDisplayedImg1() + col = 'manual_separate_draw_mode' + drawMode = self.df_settings.at[col, 'value'] + manualSep = apps.manualSeparateGui( + self.get_2Dlab(posData.lab), ID, img, + fontSize=self.fontSize, + IDcolor=self.lut[ID], + parent=self, + drawMode=drawMode + ) + manualSep.setState(self.lastManualSeparateState) + manualSep.show() + manualSep.centerWindow() + manualSep.show(block=True) + if manualSep.cancel: + posData.disableAutoActivateViewerWindow = False + if not self.separateBudButton.findChild(QAction).isChecked(): + self.separateBudButton.setChecked(False) + return + self.lastManualSeparateState = manualSep.state() + lab2D = self.get_2Dlab(posData.lab) + lab2D[manualSep.lab!=0] = manualSep.lab[manualSep.lab!=0] + self.set_2Dlab(lab2D) + splittedIDs = [obj.label for obj in manualSep.rp] + posData.disableAutoActivateViewerWindow = False + self.storeManualSeparateDrawMode(manualSep.drawMode) + + # Update data (rp, etc) + self.update_rp() + + # Repeat tracking + self.trackSubsetIDs(splittedIDs) + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Separate IDs') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Separate IDs') + + self.store_data() + + if not self.separateBudButton.findChild(QAction).isChecked(): + self.separateBudButton.setChecked(False) + + # Fill holes + elif right_click and self.fillHolesToolButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + clickedBkgrID = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here the ID that you want to ' + 'fill the holes of', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + clickedBkgrID.exec_() + if clickedBkgrID.cancel: + return + else: + ID = clickedBkgrID.EntryID + + if ID in posData.lab: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + obj_idx = posData.IDs.index(ID) + obj = posData.rp[obj_idx] + objMask = self.getObjImage(obj.image, obj.bbox) + localFill = scipy.ndimage.binary_fill_holes(objMask) + posData.lab[self.getObjSlice(obj.slice)][localFill] = ID + + self.update_rp() + self.updateAllImages() + + if not self.fillHolesToolButton.findChild(QAction).isChecked(): + self.fillHolesToolButton.setChecked(False) + + # Hull contour + elif right_click and self.hullContToolButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + mergeID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here the ID that you want to ' + 'replace with Hull contour', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + mergeID_prompt.exec_() + if mergeID_prompt.cancel: + return + else: + ID = mergeID_prompt.EntryID + + if ID in posData.lab: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + obj_idx = posData.IDs.index(ID) + obj = posData.rp[obj_idx] + objMask = self.getObjImage(obj.image, obj.bbox) + localHull = skimage.morphology.convex_hull_image(objMask) + posData.lab[self.getObjSlice(obj.slice)][localHull] = ID + + self.update_rp() + self.updateAllImages() + + if not self.hullContToolButton.findChild(QAction).isChecked(): + self.hullContToolButton.setChecked(False) + + # Move label + elif right_click and self.moveLabelToolButton.isChecked(): + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + x, y = event.pos().x(), event.pos().y() + self.startMovingLabel(x, y) + + # Fill holes + elif right_click and self.fillHolesToolButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + clickedBkgrID = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here the ID that you want to ' + 'fill the holes of', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + clickedBkgrID.exec_() + if clickedBkgrID.cancel: + return + else: + ID = clickedBkgrID.EntryID + + # Merge IDs + elif right_click and self.mergeIDsButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + mergeID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here first ID that you want to merge', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + mergeID_prompt.exec_() + if mergeID_prompt.cancel: + self.mergeObjsTempLine.setData([], []) + return + else: + ID = mergeID_prompt.EntryID + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + self.firstID = ID + + obj_idx = posData.IDs_idxs[ID] + obj = posData.rp[obj_idx] + yc, xc = self.getObjCentroid(obj.centroid) + self.clickObjYc, self.clickObjXc = int(yc), int(xc) + + # Edit ID + elif right_click and self.editIDbutton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + editID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here ID that you want to replace with a new one', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + editID_prompt.show(block=True) + + if editID_prompt.cancel: + return + else: + ID = editID_prompt.EntryID + + obj_idx = posData.IDs_idxs[ID] + y, x = posData.rp[obj_idx].centroid[-2:] + xdata, ydata = int(x), int(y) + + posData.disableAutoActivateViewerWindow = True + currentIDs = posData.IDs.copy() + self.setAllIDs(onlyVisited=True) + addPropagateCheckbox = ( + not self.isSnapshot + and posData.frame_i == self.navigateScrollBar.maximum() - 1 + and posData.frame_i < posData.SizeT - 1 + ) + editID = apps.EditIDDialog( + ID, posData.IDs, + doNotShowAgain=self.doNotAskAgainExistingID, + parent=self, + entryID=self.getNearestLostObjID(y, x), + nextUniqueID=self.setBrushID(return_val=True), + allIDs=posData.allIDs, + addPropagateCheckbox=addPropagateCheckbox + ) + editID.show(block=True) + if editID.cancel: + posData.disableAutoActivateViewerWindow = False + if not self.editIDbutton.findChild(QAction).isChecked(): + self.editIDbutton.setChecked(False) + return + + if editID.assignNewID: + self.assignNewIDfromClickedID(ID, event) + return + + if not self.doNotAskAgainExistingID: + self.editIDmergeIDs = editID.mergeWithExistingID + self.doNotAskAgainExistingID = editID.doNotAskAgainExistingID + + self.applyEditID( + ID, currentIDs, editID.how, x, y, + shift=shift, + doPropagateUnvisited=editID.doPropagateFutureFrames + ) + + elif (right_click or left_click) and self.keepIDsButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + keepID_win = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to keep', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + keepID_win.exec_() + if keepID_win.cancel: + return + else: + ID = keepID_win.EntryID + + if ID in self.keptObjectsIDs: + self.keptObjectsIDs.remove(ID) + self.clearHighlightedText() + else: + self.keptObjectsIDs.append(ID) + self.highlightLabelID(ID) + + self.updateTempLayerKeepIDs() + + # Annotate cell as removed from the analysis + elif right_click and self.binCellButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + binID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to remove from the analysis', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + binID_prompt.exec_() + if binID_prompt.cancel: + return + else: + ID = binID_prompt.EntryID + + # Ask to propagate change to all future visited frames + key = 'Exclude cell from analysis' + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + ID, key, doNotShow, + posData.UndoFutFrames_BinID, + posData.applyFutFrames_BinID + ) + + if UndoFutFrames is None: + # User cancelled the process + return + + posData.doNotShowAgain_BinID = doNotShowAgain + posData.UndoFutFrames_BinID = UndoFutFrames + posData.applyFutFrames_BinID = applyFutFrames + + self.current_frame_i = posData.frame_i + + # Apply Exclude cell from analysis to future frames if requested + if applyFutFrames: + # Store current data before going to future frames + self.store_data() + for i in range(posData.frame_i+1, endFrame_i+1): + posData.frame_i = i + self.get_data() + if ID in posData.binnedIDs: + posData.binnedIDs.remove(ID) + else: + posData.binnedIDs.add(ID) + self.update_rp_metadata(draw=False) + self.store_data(autosave=i==endFrame_i) + + self.app.restoreOverrideCursor() + + # Back to current frame + if applyFutFrames: + posData.frame_i = self.current_frame_i + self.get_data() + + # Store undo state before modifying stuff + self.storeUndoRedoStates(UndoFutFrames) + + if ID in posData.binnedIDs: + posData.binnedIDs.remove(ID) + else: + posData.binnedIDs.add(ID) + + self.annotate_rip_and_bin_IDs(updateLabel=True) + + # Gray out ore restore binned ID + self.updateLookuptable() + + if not self.binCellButton.findChild(QAction).isChecked(): + self.binCellButton.setChecked(False) + + # Annotate cell as dead + elif right_click and self.ripCellButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + ripID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as dead', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + ripID_prompt.exec_() + if ripID_prompt.cancel: + return + else: + ID = ripID_prompt.EntryID + + # Ask to propagate change to all future visited frames + key = 'Annotate cell as dead' + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + ID, key, doNotShow, + posData.UndoFutFrames_RipID, + posData.applyFutFrames_RipID + ) + + if UndoFutFrames is None: + return + + posData.doNotShowAgain_RipID = doNotShowAgain + posData.UndoFutFrames_RipID = UndoFutFrames + posData.applyFutFrames_RipID = applyFutFrames + + self.current_frame_i = posData.frame_i + + # Apply Edit ID to future frames if requested + if applyFutFrames: + # Store current data before going to future frames + self.store_data() + for i in range(posData.frame_i+1, endFrame_i+1): + posData.frame_i = i + self.get_data() + if ID in posData.ripIDs: + posData.ripIDs.remove(ID) + else: + posData.ripIDs.add(ID) + self.update_rp_metadata(draw=False) + self.store_data(autosave=i==endFrame_i) + self.app.restoreOverrideCursor() + + # Back to current frame + if applyFutFrames: + posData.frame_i = self.current_frame_i + self.get_data() + + # Store undo state before modifying stuff + self.storeUndoRedoStates(UndoFutFrames) + + if ID in posData.ripIDs: + posData.ripIDs.remove(ID) + else: + posData.ripIDs.add(ID) + + self.annotate_rip_and_bin_IDs(updateLabel=True) + + # Gray out dead ID + self.updateLookuptable() + self.store_data() + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Annotate ID as dead') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Annotate ID as dead') + + if not self.ripCellButton.findChild(QAction).isChecked(): + self.ripCellButton.setChecked(False) + + def resetExpandLabel(self): + self.expandingID = -1 + + def expandLabelCallback(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + self.connectLeftClickButtons() + self.expandFootprintSize = 1 + else: + self.clearHighlightedID() + alpha = self.imgGrad.labelsAlphaSlider.value() + self.labelsLayerImg1.setOpacity(alpha) + self.labelsLayerRightImg.setOpacity(alpha) + self.hoverLabelID = 0 + self.expandingID = 0 + self.updateAllImages() + + def expandLabel(self, dilation=True): + posData = self.data[self.pos_i] + if self.hoverLabelID == 0: + self.isExpandingLabel = False + return + + # Re-initialize label to expand when we hover on a different ID + # or we change direction + reinitExpandingLab = ( + self.expandingID != self.hoverLabelID + or dilation != self.isDilation + ) + + ID = self.hoverLabelID + + obj = posData.rp[posData.IDs.index(ID)] + + if reinitExpandingLab: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + # hoverLabelID different from previously expanded ID --> reinit + self.isExpandingLabel = True + self.expandingID = ID + self.expandingLab = np.zeros_like(self.currentLab2D) + self.expandingLab[obj.coords[:,-2], obj.coords[:,-1]] = ID + self.expandFootprintSize = 1 + + prevCoords = (obj.coords[:,-2], obj.coords[:,-1]) + self.currentLab2D[obj.coords[:,-2], obj.coords[:,-1]] = 0 + lab_2D = self.get_2Dlab(posData.lab) + lab_2D[obj.coords[:,-2], obj.coords[:,-1]] = 0 + + footprint = skimage.morphology.disk(self.expandFootprintSize) + if dilation: + expandedLab = skimage.morphology.dilation( + self.expandingLab, footprint + ) + self.isDilation = True + else: + expandedLab = skimage.morphology.erosion( + self.expandingLab, footprint + ) + self.isDilation = False + + # Prevent expanding into neighbouring labels + expandedLab[self.currentLab2D>0] = 0 + + # Get coords of the dilated/eroded object + expandedObj = skimage.measure.regionprops(expandedLab)[0] + expandedObjCoords = (expandedObj.coords[:,-2], expandedObj.coords[:,-1]) + + # Add the dilated/erored object + self.currentLab2D[expandedObjCoords] = self.expandingID + lab_2D[expandedObjCoords] = self.expandingID + + self.set_2Dlab(lab_2D) + self.currentLab2D = lab_2D + + self.update_rp() + + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.img2.setImage(img=self.currentLab2D, autoLevels=False) + + self.setTempImgExpandLabel(prevCoords, expandedObjCoords) + + def startMovingLabel(self, xPos, yPos): + posData = self.data[self.pos_i] + xdata, ydata = int(xPos), int(yPos) + lab_2D = self.get_2Dlab(posData.lab) + ID = lab_2D[ydata, xdata] + if ID == 0: + self.isMovingLabel = False + return + + posData = self.data[self.pos_i] + self.isMovingLabel = True + + self.searchedIDitemRight.setData([], []) + self.searchedIDitemLeft.setData([], []) + self.movingID = ID + self.prevMovePos = (xdata, ydata) + movingObj = posData.rp[posData.IDs.index(ID)] + self.movingObjCoords = movingObj.coords.copy() + yy, xx = movingObj.coords[:,-2], movingObj.coords[:,-1] + self.currentLab2D[yy, xx] = 0 + + def moveLabel(self, xPos, yPos): + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + Y, X = lab_2D.shape + xdata, ydata = int(xPos), int(yPos) + if xdata<0 or ydata<0 or xdata>=X or ydata>=Y: + return + + self.clearObjContour(ID=self.movingID, ax=0) + + xStart, yStart = self.prevMovePos + deltaX = xdata-xStart + deltaY = ydata-yStart + + yy, xx = self.movingObjCoords[:,-2], self.movingObjCoords[:,-1] + + if self.isSegm3D: + zz = self.movingObjCoords[:,0] + posData.lab[zz, yy, xx] = 0 + else: + posData.lab[yy, xx] = 0 + + self.movingObjCoords[:,-2] = self.movingObjCoords[:,-2]+deltaY + self.movingObjCoords[:,-1] = self.movingObjCoords[:,-1]+deltaX + + yy, xx = self.movingObjCoords[:,-2], self.movingObjCoords[:,-1] + + yy[yy<0] = 0 + xx[xx<0] = 0 + yy[yy>=Y] = Y-1 + xx[xx>=X] = X-1 + + if self.isSegm3D: + zz = self.movingObjCoords[:,0] + posData.lab[zz, yy, xx] = self.movingID + else: + posData.lab[yy, xx] = self.movingID + + self.currentLab2D = self.get_2Dlab(posData.lab) + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.img2.setImage(self.currentLab2D, autoLevels=False) + + self.setTempImg1MoveLabel() + + self.prevMovePos = (xdata, ydata) + + @exception_handler + def gui_mouseDragEventImg1(self, event): + x, y = event.pos().x(), event.pos().y() + + if hasattr(self, 'scaleBar'): + if self.scaleBarDialog is not None: + self.scaleBarDialog.locCombobox.setCurrentText('Custom') + if self.scaleBar.isHighlighted() and self.scaleBar.clicked: + self.scaleBar.setLocationProperty('custom') + self.scaleBar.move(x, y) + return + + if hasattr(self, 'timestamp'): + if self.timestampDialog is not None: + self.timestampDialog.locCombobox.setCurrentText('Custom') + if self.timestamp.isHighlighted() and self.timestamp.clicked: + self.timestamp.setLocationProperty('custom') + self.timestamp.move(x, y) + return + + mode = str(self.modeComboBox.currentText()) + if mode == 'Viewer': + return + + posData = self.data[self.pos_i] + Y, X = self.get_2Dlab(posData.lab).shape + xdata, ydata = int(x), int(y) + if not myutils.is_in_bounds(xdata, ydata, X, Y): + return + + if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): + self.drawAutoContour(y, x) + + # Brush dragging mouse --> keep brushing + elif self.isMouseDragImg1 and self.brushButton.isChecked(): + lab_2D = self.get_2Dlab(posData.lab) + + # t1 = time.perf_counter() + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) + + # t2 = time.perf_counter() + + diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) + + # Build brush mask + mask = np.zeros(lab_2D.shape, bool) + mask[diskSlice][diskMask] = True + mask[rrPoly, ccPoly] = True + + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + + # t3 = time.perf_counter() + if not self.isPowerBrush() and not ctrl: + mask[lab_2D!=0] = False + self.setHoverToolSymbolColor( + xdata, ydata, self.ax2_BrushCirclePen, + (self.ax2_BrushCircle, self.ax1_BrushCircle), + self.brushButton, brush=self.ax2_BrushCircleBrush + ) + + # t4 = time.perf_counter() + + # Apply brush mask + self.applyBrushMask(mask, posData.brushID) + + self.setImageImg2(updateLookuptable=False) + + # t5 = time.perf_counter() + + lab2D = self.get_2Dlab(posData.lab) + brushMask = np.logical_and( + lab2D[diskSlice] == posData.brushID, diskMask + ) + self.setTempImg1Brush( + False, brushMask, posData.brushID, + toLocalSlice=diskSlice + ) + + # t6 = time.perf_counter() + + # printl( + # 'Brush exec times =\n' + # f' * {(t1-t0)*1000 = :.4f} ms\n' + # f' * {(t2-t1)*1000 = :.4f} ms\n' + # f' * {(t3-t2)*1000 = :.4f} ms\n' + # f' * {(t4-t3)*1000 = :.4f} ms\n' + # f' * {(t5-t4)*1000 = :.4f} ms\n' + # f' * {(t6-t5)*1000 = :.4f} ms\n' + # f' * {(t6-t0)*1000 = :.4f} ms' + # ) + + # Eraser dragging mouse --> keep erasing + elif self.isMouseDragImg1 and self.eraserButton.isChecked(): + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + + diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) + + # Build eraser mask + mask = np.zeros(lab_2D.shape, bool) + mask[ymin:ymax, xmin:xmax][diskMask] = True + mask[rrPoly, ccPoly] = True + + if self.eraseOnlyOneID: + mask[lab_2D!=self.erasedID] = False + self.setHoverToolSymbolColor( + xdata, ydata, self.eraserCirclePen, + (self.ax2_EraserCircle, self.ax1_EraserCircle), + self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], + ID=self.erasedID + ) + + self.erasedIDs.update(lab_2D[mask]) + self.applyEraserMask(mask) + + self.setImageImg2() + + for erasedID in self.erasedIDs: + if erasedID == 0: + continue + self.erasedLab[lab_2D==erasedID] = erasedID + self.erasedLab[mask] = 0 + + eraserMask = mask[diskSlice] + self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice) + self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice, ax=1) + + # Move label dragging mouse --> keep moving + elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + self.moveLabel(x, y) + + # Wand dragging mouse --> keep doing the magic + elif self.isMouseDragImg1 and self.wandToolButton.isChecked(): + tol = self.getMagicWandFloodTolerance() + if self.isSegm3D: + z_slice = self.zSliceScrollBar.sliderPosition() + seed = (z_slice, ydata, xdata) + else: + seed = (ydata, xdata) + + flood_mask = skimage.segmentation.flood( + self.flood_img, seed, tolerance=tol + ) + drawUnderMask = np.logical_or( + posData.lab==0, posData.lab==posData.brushID + ) + flood_mask = np.logical_and(flood_mask, drawUnderMask) + + self.flood_mask[flood_mask] = True + + if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): + self.flood_mask = core.binary_fill_holes(self.flood_mask) + + if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): + self.flood_mask = core.convex_hull_mask(self.flood_mask) + + self.setTempBrushMaskFromWand(self.flood_mask) + + # Label ROI dragging mouse --> draw ROI + elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): + if self.labelRoiIsRectRadioButton.isChecked(): + x0, y0 = self.labelRoiItem.pos() + w, h = (xdata-x0), (ydata-y0) + self.labelRoiItem.setSize((w, h)) + elif self.labelRoiIsFreeHandRadioButton.isChecked(): + self.freeRoiItem.addPoint(xdata, ydata) + + # Draw freehand clear region --> draw region + elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): + self.freeRoiItem.addPoint(xdata, ydata) + + # Label ROI dragging mouse --> draw ROI + elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): + x0, y0 = self.zoomRectItem.pos() + w, h = (xdata-x0), (ydata-y0) + self.zoomRectItem.setSize((w, h)) + + # @exec_time + def fillHolesID(self, ID, sender='brush'): + posData = self.data[self.pos_i] + if sender == 'brush': + if not self.brushAutoFillCheckbox.isChecked(): + return False + + lab2D = self.get_2Dlab(posData.lab) + mask = lab2D == ID + filledMask = scipy.ndimage.binary_fill_holes(mask) + lab2D[filledMask] = ID + + self.set_2Dlab(lab2D) + return True + return False + + def highlightIDonHoverCheckBoxToggled(self, checked): + doHighlight = ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() + ) + if not doHighlight: + self.highlightedID = 0 + self.initLookupTableLab() + else: + self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() + self.highlightSearchedID(self.highlightedID, force=True) + self.updatePropsWidget(self.highlightedID) + self.updateAllImages() + + def highlightSearchedIDcheckBoxToggled(self, checked): + self.highlightIDonHoverCheckBoxToggled(checked) + if checked: + posData = self.data[self.pos_i] + self.highlightedID = self.getHighlightedID() + if self.highlightedID == 0: + return + objIdx = posData.IDs_idxs[self.highlightedID] + obj_idx = posData.IDs_idxs.get(self.highlightedID) + if obj_idx is None: + return + obj = posData.rp[objIdx] + self.goToZsliceSearchedID(obj) + + def setHighlightID(self, doHighlight): + if not doHighlight: + self.highlightedID = 0 + self.initLookupTableLab() + else: + self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() + self.highlightSearchedID(self.highlightedID, force=True) + self.updatePropsWidget(self.highlightedID) + self.updateAllImages() + + def propsWidgetIDvalueChanged(self, ID): + posData = self.data[self.pos_i] + if ID == 0: + self.updatePropsWidget(int(ID)) + return + + propsQGBox = self.guiTabControl.propsQGBox + obj_idx = posData.IDs_idxs.get(ID) + if obj_idx is None: + s = f'Object ID {int(ID):d} does not exist' + propsQGBox.notExistingIDLabel.setText(s) + return + + obj = posData.rp[obj_idx] + self.goToZsliceSearchedID(obj) + self.updatePropsWidget(int(ID)) + + def updatePropsWidget(self, ID, fromHover=False): + if isinstance(ID, str): + # Function called by currentTextChanged of channelCombobox or + # additionalMeasCombobox. We set self.currentPropsID = 0 to force update + ID = self.guiTabControl.propsQGBox.idSB.value() + self.currentPropsID = -1 + + ID = int(ID) + + update = ( + self.propsDockWidget.isVisible() + and ID != 0 and ID!=self.currentPropsID + ) + if not update: + return + + posData = self.data[self.pos_i] + if not hasattr(posData, 'rp'): + return + + if posData.rp is None: + self.update_rp() + + if not posData.IDs: + # empty segmentation mask + return + + if fromHover and not self.guiTabControl.highlightCheckbox.isChecked(): + # Do not highlight on hover + return + + propsQGBox = self.guiTabControl.propsQGBox + + obj_idx = posData.IDs_idxs.get(ID) + if obj_idx is None: + s = f'Object ID {int(ID):d} does not exist' + propsQGBox.notExistingIDLabel.setText(s) + return + + propsQGBox.notExistingIDLabel.setText('') + self.currentPropsID = ID + propsQGBox.idSB.setValue(ID) + + doHighlight = ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() + ) + if doHighlight: + self.highlightSearchedID(ID) + + obj = posData.rp[obj_idx] + + if self.isSegm3D: + if self.zProjComboBox.currentText() == 'single z-slice': + local_z = self.z_lab() - obj.bbox[0] + area_pxl = np.count_nonzero(obj.image[local_z]) + else: + area_pxl = np.count_nonzero(obj.image.max(axis=0)) + else: + area_pxl = obj.area + + propsQGBox.cellAreaPxlSB.setValue(area_pxl) + + pixelSizeQGBox = self.guiTabControl.pixelSizeQGBox + PhysicalSizeX = pixelSizeQGBox.pixelWidthWidget.value() + PhysicalSizeY = pixelSizeQGBox.pixelHeightWidget.value() + PhysicalSizeZ = pixelSizeQGBox.voxelDepthWidget.value() + + yx_pxl_to_um2 = PhysicalSizeY*PhysicalSizeX + + area_um2 = area_pxl*yx_pxl_to_um2 + + propsQGBox.cellAreaUm2DSB.setValue(area_um2) + + if self.isSegm3D: + PhysicalSizeZ = posData.PhysicalSizeZ + vol_vox_3D = obj.area + vol_fl_3D = vol_vox_3D*PhysicalSizeZ*PhysicalSizeY*PhysicalSizeX + propsQGBox.cellVolVox3D_SB.setValue(vol_vox_3D) + propsQGBox.cellVolFl3D_DSB.setValue(vol_fl_3D) + + vol_vox, vol_fl = _calc_rot_vol( + obj, PhysicalSizeY, PhysicalSizeX + ) + propsQGBox.cellVolVoxSB.setValue(int(vol_vox)) + propsQGBox.cellVolFlDSB.setValue(vol_fl) + + + minor_axis_length = max(1, obj.minor_axis_length) + elongation = obj.major_axis_length/minor_axis_length + propsQGBox.elongationDSB.setValue(elongation) + + solidity = obj.solidity + propsQGBox.solidityDSB.setValue(solidity) + + additionalPropName = propsQGBox.additionalPropsCombobox.currentText() + additionalPropValue = getattr(obj, additionalPropName) + propsQGBox.additionalPropsCombobox.indicator.setValue(additionalPropValue) + + intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox + selectedChannel = intensMeasurQGBox.channelCombobox.currentText() + + try: + _, filename = self.getPathFromChName(selectedChannel, posData) + image = posData.ol_data_dict[filename][posData.frame_i] + except Exception as e: + image = posData.img_data[posData.frame_i] + + if posData.SizeZ > 1 and not self.isSegm3D: + z = self.zSliceScrollBar.sliderPosition() + objData = image[z][obj.slice][obj.image] + img = self.img1.image + else: + objData = image[obj.slice][obj.image] + img = image + + intensMeasurQGBox.minimumDSB.setValue(np.min(objData)) + intensMeasurQGBox.maximumDSB.setValue(np.max(objData)) + intensMeasurQGBox.meanDSB.setValue(np.mean(objData)) + intensMeasurQGBox.medianDSB.setValue(np.median(objData)) + + funcDesc = intensMeasurQGBox.additionalMeasCombobox.currentText() + func = intensMeasurQGBox.additionalMeasCombobox.functions[funcDesc] + if funcDesc == 'Concentration': + bkgrVal = np.median(img[posData.lab == 0]) + amount = func(objData, bkgrVal, obj.area) + value = amount/vol_vox + elif funcDesc == 'Amount': + bkgrVal = np.median(img[posData.lab == 0]) + amount = func(objData, bkgrVal, obj.area) + value = amount + else: + value = func(objData) + + intensMeasurQGBox.additionalMeasCombobox.indicator.setValue(value) + + def gui_hoverEventRightImage(self, event): + try: + posData = self.data[self.pos_i] + except AttributeError: + return + + if event.isExit(): + self.resetCursors() + + self.gui_hoverEventImg1(event, isHoverImg1=False) + setMirroredCursor = ( + self.app.overrideCursor() is None and not event.isExit() + and self.showMirroredCursorAction.isChecked() + ) + if setMirroredCursor: + x, y = event.pos() + self.ax1_cursor.setData([x], [y]) + + def onCtrlPressedFirstTime(self): + x, y = self.xHoverImg, self.yHoverImg + if x is None: + self.xyOnCtrlPressedFirstTime = None + return + + xdata, ydata = int(x), int(y) + Y, X = self.currentLab2D.shape + + if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): + self.xyOnCtrlPressedFirstTime = None + return + + ID = self.currentLab2D[ydata, xdata] + if ID == 0: + self.xyOnCtrlPressedFirstTime = None + return + + self.xyOnCtrlPressedFirstTime = (xdata, ydata) + + def onCtrlReleased(self): + self.xyOnCtrlPressedFirstTime = None + + def gui_hoverEventImg1(self, event, isHoverImg1=True): + try: + posData = self.data[self.pos_i] + except AttributeError: + return + + # Update x, y, value label bottom right + if not event.isExit(): + self.xHoverImg, self.yHoverImg = event.pos() + else: + self.xHoverImg, self.yHoverImg = None, None + + if event.isExit(): + self.resetCursor() + + if not event.isExit() and self.slideshowWin is not None: + self.slideshowWin.setMirroredCursorPos(*event.pos()) + + # Alt key was released --> restore cursor + modifiers = QGuiApplication.keyboardModifiers() + cursorsInfo = self.gui_setCursor(modifiers, event) + self.highlightHoverLostObj(modifiers, event) + + drawRulerLine = ( + (self.rulerButton.isChecked() + or self.addDelPolyLineRoiButton.isChecked()) + and self.tempSegmentON and not event.isExit() + ) + if drawRulerLine: + self.drawTempRulerLine(event) + + if not event.isExit(): + x, y = event.pos() + xdata, ydata = int(x), int(y) + _img = self.img1.image + Y, X = _img.shape[:2] + if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: + ID = self.currentLab2D[ydata, xdata] + self.updatePropsWidget(ID, fromHover=True) + activeToolButton = self.getActiveToolButton() + hoverText = self.hoverValuesFormatted( + xdata, ydata, activeToolButton, isHoverImg1 + ) + self.checkHighlightScaleBar(x, y, activeToolButton) + self.checkHighlightTimestamp(x, y, activeToolButton) + self.wcLabel.setText(hoverText) + else: + self.clickedOnBud = False + self.BudMothTempLine.setData([], []) + self.wcLabel.setText('') + + if cursorsInfo['setKeepObjCursor']: + x, y = event.pos() + self.highlightHoverIDsKeptObj(x, y) + + if cursorsInfo['setManualTrackingCursor']: + x, y = event.pos() + # self.highlightHoverID(x, y) + self.drawManualTrackingGhost(x, y) + + if cursorsInfo['setManualBackgroundCursor']: + x, y = event.pos() + # self.highlightHoverID(x, y) + self.drawManualBackgroundObj(x, y) + + if ( + not cursorsInfo['setManualTrackingCursor'] + and not cursorsInfo['setManualBackgroundCursor'] + ): + self.clearGhost() + + setMoveLabelCursor = cursorsInfo['setMoveLabelCursor'] + setExpandLabelCursor = cursorsInfo['setExpandLabelCursor'] + if setMoveLabelCursor or setExpandLabelCursor: + x, y = event.pos() + self.updateHoverLabelCursor(x, y) + + # Draw eraser circle + if cursorsInfo['setEraserCursor']: + x, y = event.pos() + self.updateEraserCursor(x, y, isHoverImg1=isHoverImg1) + self.hideItemsHoverBrush(xy=(x, y)) + elif self.eraserButton.isChecked() and not event.isExit(): + if self.xyOnCtrlPressedFirstTime is not None: + self.updateEraserCursor( + x, y, xyLocked=self.xyOnCtrlPressedFirstTime, + isHoverImg1=isHoverImg1 + ) + self.hideItemsHoverBrush(xy=(x, y)) + else: + eraserCursors = ( + self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX + ) + self.setHoverToolSymbolData([], [], eraserCursors) + + # Draw Brush circle + if cursorsInfo['setBrushCursor']: + x, y = event.pos() + self.updateBrushCursor(x, y, isHoverImg1=isHoverImg1) + self.hideItemsHoverBrush(xy=(x, y)) + elif cursorsInfo['setAddPointCursor']: + x, y = event.pos() + self.setHoverCircleAddPoint(x, y) + else: + self.setHoverToolSymbolData( + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + + # Draw label ROi circular cursor + setLabelRoiCircCursor = cursorsInfo['setLabelRoiCircCursor'] + if setLabelRoiCircCursor: + x, y = event.pos() + else: + x, y = None, None + self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) + + drawMothBudLine = ( + self.assignBudMothButton.isChecked() and self.clickedOnBud + and not event.isExit() + ) + if drawMothBudLine: + self.drawTempMothBudLine(event, posData) + + drawMergeObjsLine = ( + self.mergeIDsButton.isChecked() and not event.isExit() + ) + if drawMergeObjsLine: + self.drawTempMergeObjsLine(event, posData, modifiers) + + # Temporarily draw spline curve + # see https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy + drawSpline = ( + self.curvToolButton.isChecked() and self.splineHoverON + and not event.isExit() + ) + if drawSpline: + self.hoverEventDrawSpline(event) + + setMirroredCursor = ( + self.app.overrideCursor() is None and not event.isExit() + and isHoverImg1 and self.showMirroredCursorAction.isChecked() + ) + if setMirroredCursor: + x, y = event.pos() + self.ax2_cursor.setData([x], [y]) + else: + self.ax2_cursor.setData([], []) + + return cursorsInfo + + def drawTempMothBudLine(self, event, posData): + x, y = event.pos() + y2, x2 = y, x + xdata, ydata = int(x), int(y) + y1, x1 = self.yClickBud, self.xClickBud + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + self.BudMothTempLine.setData([x1, x2], [y1, y2]) + else: + obj_idx = posData.IDs_idxs[ID] + obj = posData.rp[obj_idx] + y2, x2 = self.getObjCentroid(obj.centroid) + self.BudMothTempLine.setData([x1, x2], [y1, y2]) + + def drawTempMergeObjsLine(self, event, posData, modifiers): + if self.clickObjYc is None: + return + modifier = modifiers == Qt.ShiftModifier + x, y = event.pos() + y2, x2 = y, x + xdata, ydata = int(x), int(y) + y1, x1 = self.clickObjYc, self.clickObjXc + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID != 0: + obj_idx = posData.IDs_idxs[ID] + obj = posData.rp[obj_idx] + y2, x2 = self.getObjCentroid(obj.centroid) + + if modifier and ID > 0: + self.mergeObjsTempLine.addPoint(x2, y2) + elif not modifier: + self.mergeObjsTempLine.setData([x1, x2], [y1, y2]) + + def gui_add_ax_cursors(self): + try: + self.ax1.removeItem(self.ax1_cursor) + self.ax2.removeItem(self.ax2_cursor) + except Exception as e: + pass + + self.ax2_cursor = pg.ScatterPlotItem( + symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), + brush=pg.mkBrush('w'), size=16, tip=None + ) + self.ax2.addItem(self.ax2_cursor) + + self.ax1_cursor = pg.ScatterPlotItem( + symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), + brush=pg.mkBrush('w'), size=16, tip=None + ) + self.ax1.addItem(self.ax1_cursor) + + def gui_setCursor(self, modifiers, event): + noModifier = modifiers == Qt.NoModifier + shift = modifiers == Qt.ShiftModifier + ctrl = modifiers == Qt.ControlModifier + alt = modifiers == Qt.AltModifier + + # Alt key was released --> restore cursor + if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: + self.app.restoreOverrideCursor() + + setBrushCursor = ( + self.brushButton.isChecked() and not event.isExit() + and (noModifier or shift or ctrl) + ) + setEraserCursor = ( + self.eraserButton.isChecked() and not event.isExit() + and noModifier + ) + setAddDelPolyLineCursor = ( + self.addDelPolyLineRoiButton.isChecked() and not event.isExit() + and noModifier + ) + setLabelRoiCircCursor = ( + self.labelRoiButton.isChecked() and not event.isExit() + and (noModifier or shift or ctrl) + and self.labelRoiIsCircularRadioButton.isChecked() + ) + setWandCursor = ( + self.wandToolButton.isChecked() and not event.isExit() + and noModifier + ) + setLabelRoiCursor = ( + self.labelRoiButton.isChecked() and not event.isExit() + and noModifier + ) + setMoveLabelCursor = ( + self.moveLabelToolButton.isChecked() and not event.isExit() + and noModifier + ) + setExpandLabelCursor = ( + self.expandLabelToolButton.isChecked() and not event.isExit() + and noModifier + ) + setCurvCursor = ( + self.curvToolButton.isChecked() and not event.isExit() + and noModifier + ) + setKeepObjCursor = ( + self.keepIDsButton.isChecked() and not event.isExit() + and noModifier + ) + setCustomAnnotCursor = ( + self.customAnnotButton is not None and not event.isExit() + and noModifier + ) + setManualTrackingCursor = ( + self.manualTrackingButton.isChecked() + and not event.isExit() + and noModifier + ) + setManualBackgroundCursor = ( + self.manualBackgroundButton.isChecked() + and not event.isExit() + and noModifier + ) + setZoomRectCursor = ( + self.zoomRectButton.isChecked() and not event.isExit() + and noModifier + ) + setEditIDCursor = ( + self.editIDbutton.isChecked() and not event.isExit() + ) + magicPromptsON = self.magicPromptsToolButton.isChecked() + pointsLayerON = self.togglePointsLayerAction.isChecked() + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + setAddPointCursor = ( + (pointsLayerON or magicPromptsON) + and addPointsByClickingButton is not None + and not event.isExit() + and noModifier + ) + overrideCursor = self.app.overrideCursor() + setPanImageCursor = alt and not event.isExit() + if setPanImageCursor and overrideCursor is None: + self.app.setOverrideCursor(Qt.SizeAllCursor) + elif setBrushCursor or setEraserCursor or setLabelRoiCircCursor: + self.app.setOverrideCursor(Qt.CrossCursor) + elif setWandCursor and overrideCursor is None: + self.app.setOverrideCursor(self.wandCursor) + elif setLabelRoiCursor and overrideCursor is None: + self.app.setOverrideCursor(Qt.CrossCursor) + elif setCurvCursor and overrideCursor is None: + self.app.setOverrideCursor(self.curvCursor) + elif setCustomAnnotCursor and overrideCursor is None: + self.app.setOverrideCursor(Qt.PointingHandCursor) + elif setAddDelPolyLineCursor: + self.app.setOverrideCursor(self.polyLineRoiCursor) + elif setCustomAnnotCursor: + x, y = event.pos() + self.highlightHoverID(x, y) + elif setKeepObjCursor and overrideCursor is None: + self.app.setOverrideCursor(Qt.PointingHandCursor) + elif setManualTrackingCursor and overrideCursor is None: + self.app.setOverrideCursor(Qt.PointingHandCursor) + elif setManualBackgroundCursor and overrideCursor is None: + self.app.setOverrideCursor(Qt.PointingHandCursor) + elif setAddPointCursor: + self.app.setOverrideCursor(self.addPointsCursor) + elif setZoomRectCursor: + self.app.setOverrideCursor(Qt.CrossCursor) + elif setEditIDCursor and overrideCursor is None: + if shift: + self.app.setOverrideCursor(Qt.CrossCursor) + else: + self.app.restoreOverrideCursor() + + return { + 'setBrushCursor': setBrushCursor, + 'setEraserCursor': setEraserCursor, + 'setAddDelPolyLineCursor': setAddDelPolyLineCursor, + 'setLabelRoiCircCursor': setLabelRoiCircCursor, + 'setWandCursor': setWandCursor, + 'setLabelRoiCursor': setLabelRoiCursor, + 'setMoveLabelCursor': setMoveLabelCursor, + 'setExpandLabelCursor': setExpandLabelCursor, + 'setCurvCursor': setCurvCursor, + 'setKeepObjCursor': setKeepObjCursor, + 'setCustomAnnotCursor': setCustomAnnotCursor, + 'setManualTrackingCursor': setManualTrackingCursor, + 'setManualBackgroundCursor': setManualBackgroundCursor, + 'setAddPointCursor': setAddPointCursor, + 'setZoomRectCursor': setZoomRectCursor, + 'setEditIDCursor': setEditIDCursor + } + + def warnAddingPointWithExistingId(self, point_id, table_endname=''): + posData = self.data[self.pos_i] + if not point_id in posData.IDs_idxs: + return True + + msg = widgets.myMessageBox(wrapText=False) + txt = (f""" + Cell ID {point_id} already exists!

+ Are you sure you want to add this point? + """) + if table_endname: + txt = (f""" + The loaded table {table_endname} has point id + {point_id}. +

However, {txt} + """) + txt = html_utils.paragraph(txt) + _, _, yesButton = msg.warning( + self, f'Cell ID {point_id} already exist', txt, + buttonsTexts=( + 'Cancel', 'No, do not add', f'Yes, add point id {point_id}' + ) + ) + return msg.clickedButton == yesButton + + def gui_hoverEventImg2(self, event): + try: + posData = self.data[self.pos_i] + except AttributeError: + return + + if not event.isExit(): + self.xHoverImg, self.yHoverImg = event.pos() + else: + self.xHoverImg, self.yHoverImg = None, None + + # Cursor left image --> restore cursor + if event.isExit() and self.app.overrideCursor() is not None: + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + # Alt key was released --> restore cursor + modifiers = QGuiApplication.keyboardModifiers() + noModifier = modifiers == Qt.NoModifier + shift = modifiers == Qt.ShiftModifier + ctrl = modifiers == Qt.ControlModifier + if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: + self.app.restoreOverrideCursor() + + setBrushCursor = ( + self.brushButton.isChecked() and not event.isExit() + and (noModifier or shift or ctrl) + ) + setEraserCursor = ( + self.eraserButton.isChecked() and not event.isExit() + and noModifier + ) + setLabelRoiCircCursor = ( + self.labelRoiButton.isChecked() and not event.isExit() + and (noModifier or shift or ctrl) + and self.labelRoiIsCircularRadioButton.isChecked() + ) + if setBrushCursor or setEraserCursor or setLabelRoiCircCursor: + self.app.setOverrideCursor(Qt.CrossCursor) + + setMoveLabelCursor = ( + self.moveLabelToolButton.isChecked() and not event.isExit() + and noModifier + ) + + setExpandLabelCursor = ( + self.expandLabelToolButton.isChecked() and not event.isExit() + and noModifier + ) + + # Cursor is moving on image while Alt key is pressed --> pan cursor + alt = QGuiApplication.keyboardModifiers() == Qt.AltModifier + setPanImageCursor = alt and not event.isExit() + if setPanImageCursor and self.app.overrideCursor() is None: + self.app.setOverrideCursor(Qt.SizeAllCursor) + + setKeepObjCursor = ( + self.keepIDsButton.isChecked() and not event.isExit() + and noModifier + ) + if setKeepObjCursor and self.app.overrideCursor() is None: + self.app.setOverrideCursor(Qt.PointingHandCursor) + + # Update x, y, value label bottom right + if not event.isExit(): + x, y = event.pos() + xdata, ydata = int(x), int(y) + _img = self.currentLab2D + Y, X = _img.shape + # hoverText = self.hoverValuesFormatted(xdata, ydata) + # self.wcLabel.setText(hoverText) + else: + if self.eraserButton.isChecked() or self.brushButton.isChecked(): + self.gui_mouseReleaseEventImg2(event) + self.wcLabel.setText(f'') + + if setMoveLabelCursor or setExpandLabelCursor: + x, y = event.pos() + self.updateHoverLabelCursor(x, y) + + if setKeepObjCursor: + x, y = event.pos() + self.highlightHoverIDsKeptObj(x, y) + + # Draw eraser circle + if setEraserCursor: + x, y = event.pos() + self.updateEraserCursor(x, y, isHoverImg1=False) + else: + self.setHoverToolSymbolData( + [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX) + ) + + # Draw Brush circle + if setBrushCursor: + x, y = event.pos() + self.updateBrushCursor(x, y, isHoverImg1=False) + else: + self.setHoverToolSymbolData( + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + + # Draw label ROi circular cursor + if setLabelRoiCircCursor: + x, y = event.pos() + else: + x, y = None, None + self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) + + def gui_imgGradShowContextMenu(self, x, y): + if hasattr(self, 'scaleBar'): + if self.scaleBar.isHighlighted(): + self.scaleBar.showContextMenu(x, y) + return + + if hasattr(self, 'timestamp'): + if self.timestamp.isHighlighted(): + self.timestamp.showContextMenu(x, y) + return + + self.imgGrad.gradient.menu.popup(QPoint(int(x), int(y))) + + def gui_rightImageShowContextMenu(self, event): + try: + # Convert QPointF to QPoint + self.imgGradRight.gradient.menu.popup(event.screenPos().toPoint()) + except AttributeError: + self.imgGradRight.gradient.menu.popup(event.screenPos()) + + @exception_handler + def gui_mouseDragEventImg2(self, event): + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + if mode == 'Viewer': + return + + Y, X = self.get_2Dlab(posData.lab).shape + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + if not myutils.is_in_bounds(xdata, ydata, X, Y): + return + + # Eraser dragging mouse --> keep erasing + if self.isMouseDragImg2 and self.eraserButton.isChecked(): + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + Y, X = lab_2D.shape + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + brushSize = self.brushSizeSpinbox.value() + rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + + # Build eraser mask + mask = np.zeros(lab_2D.shape, bool) + mask[ymin:ymax, xmin:xmax][diskMask] = True + mask[rrPoly, ccPoly] = True + + if self.eraseOnlyOneID: + mask[lab_2D!=self.erasedID] = False + self.setHoverToolSymbolColor( + xdata, ydata, self.eraserCirclePen, + (self.ax2_EraserCircle, self.ax1_EraserCircle), + self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], + ID=self.erasedID + ) + + self.erasedIDs.update(lab_2D[mask]) + + self.applyEraserMask(mask) + self.setImageImg2(updateLookuptable=False) + + # Brush paint dragging mouse --> keep painting + if self.isMouseDragImg2 and self.brushButton.isChecked(): + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + Y, X = lab_2D.shape + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) + + # Build brush mask + mask = np.zeros(lab_2D.shape, bool) + mask[ymin:ymax, xmin:xmax][diskMask] = True + mask[rrPoly, ccPoly] = True + + # If user double-pressed 'b' then draw over the labels + color = self.brushButton.palette().button().color().name() + if color != self.doublePressKeyButtonColor: + mask[lab_2D!=0] = False + self.setHoverToolSymbolColor( + xdata, ydata, self.ax2_BrushCirclePen, + (self.ax2_BrushCircle, self.ax1_BrushCircle), + self.eraserButton, brush=self.ax2_BrushCircleBrush + ) + + # Apply brush mask + self.applyBrushMask(mask, self.ax2BrushID) + + self.setImageImg2() + + # Move label dragging mouse --> keep moving + elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + self.moveLabel(x, y) + + @exception_handler + def gui_mouseReleaseEventImg2(self, event): + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + if mode == 'Viewer': + return + + Y, X = self.get_2Dlab(posData.lab).shape + try: + x, y = event.pos().x(), event.pos().y() + except Exception as e: + return + + xdata, ydata = int(x), int(y) + if not myutils.is_in_bounds(xdata, ydata, X, Y): + self.isMouseDragImg2 = False + self.updateAllImages() + return + + # Move label mouse released, update move + if self.isMovingLabel and self.moveLabelToolButton.isChecked(): + self.isMovingLabel = False + + # Update data (rp, etc) + self.update_rp() + + # Repeat tracking + self.tracking(enforce=True, assign_unique_new_IDs=False) + + self.updateAllImages() + + if not self.moveLabelToolButton.findChild(QAction).isChecked(): + self.moveLabelToolButton.setChecked(False) + + # Merge IDs + elif self.mergeIDsButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + lab2D = self.get_2Dlab(posData.lab) + ID = lab2D[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + lab2D, y, x + ) + mergeID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to merge with ID ' + f'{self.firstID}', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + mergeID_prompt.exec_() + if mergeID_prompt.cancel: + return + else: + ID = mergeID_prompt.EntryID + obj_idx = posData.IDs_idxs[ID] + obj = posData.rp[obj_idx] + y2, x2 = self.getObjCentroid(obj.centroid) + self.mergeObjsTempLine.addPoint(x2, y2) + + xx, yy = self.mergeObjsTempLine.getData() + IDs_to_merge = lab2D[yy.astype(int), xx.astype(int)] + for ID in IDs_to_merge: + if ID == 0: + continue + posData.lab[posData.lab==ID] = self.firstID + + self.mergeObjsTempLine.setData([], []) + self.clickObjYc, self.clickObjXc = None, None + + # Update data (rp, etc) + self.update_rp() + + ask_back_prop = True + + if posData.frame_i == 0: + ask_back_prop = False + prev_IDs = [] + else: + prev_IDs = posData.allData_li[posData.frame_i-1]['IDs'] + + if all(ID not in prev_IDs for ID in IDs_to_merge): + ask_back_prop = False + + if not self.isFrameCcaAnnotated() and ask_back_prop: + proceed = self.askPropagateChangePast(f'Merge IDs {IDs_to_merge}') + if proceed: + self.propagateMergeObjsPast(IDs_to_merge) + self.whitelistPropagateIDs(only_future_frames=False, update_lab=True) # in the update_rp() call, this should also be done + + # Repeat tracking + self.tracking( + enforce=True, assign_unique_new_IDs=False, + separateByLabel=False + ) + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Merge IDs') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Merge IDs') + + if not self.mergeIDsButton.findChild(QAction).isChecked(): + self.mergeIDsButton.setChecked(False) + self.store_data() + + @exception_handler + def gui_mouseReleaseEventImg1(self, event): + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + alt = modifiers == Qt.AltModifier + right_click = event.button() == Qt.MouseButton.RightButton and not alt + + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + if mode == 'Viewer': + return + + Y, X = self.get_2Dlab(posData.lab).shape + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + if not myutils.is_in_bounds(xdata, ydata, X, Y): + self.isMouseDragImg2 = False + self.updateAllImages() + return + + if hasattr(self, 'scaleBar'): + if self.scaleBar.isHighlighted() and self.scaleBar.clicked: + self.scaleBar.clicked = False + return + + if hasattr(self, 'timestamp'): + if self.timestamp.isHighlighted() and self.timestamp.clicked: + self.timestamp.clicked = False + return + + sendRightClickImg2 = ( + (mode=='Segmentation and Tracking' or self.isSnapshot) + and right_click + ) + if sendRightClickImg2: + # Allow right-click actions on both images + self.gui_mouseReleaseEventImg2(event) + + # Right-click curvature tool mouse release + if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): + self.isRightClickDragImg1 = False + try: + self.curvToolSplineToObj(isRightClick=True) + self.update_rp() + if self.autoIDcheckbox.isChecked(): + self.trackManuallyAddedObject(posData.brushID, True) + if self.isSnapshot: + self.fixCcaDfAfterEdit('Add new ID with curvature tool') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Add new ID with curvature tool') + self.clearCurvItems() + self.curvTool_cb(True) + except ValueError: + self.clearCurvItems() + self.curvTool_cb(True) + pass + + # Eraser mouse release --> update IDs and contours + elif self.isMouseDragImg1 and self.eraserButton.isChecked(): + self.isMouseDragImg1 = False + + self.clearTempBrushImage() + + # Update data (rp, etc) + self.update_rp() + + doUpdateImages = self.checkWarnDeletedIDwithEraser() + + if doUpdateImages: + self.updateAllImages() + + # Brush button mouse release + elif self.isMouseDragImg1 and self.brushButton.isChecked(): + self.isMouseDragImg1 = False + + self.clearTempBrushImage() + + self.brushReleased() + + # Wand tool release, add new object + elif self.isMouseDragImg1 and self.wandToolButton.isChecked(): + self.isMouseDragImg1 = False + + self.clearTempBrushImage() + + posData = self.data[self.pos_i] + posData.lab[self.flood_mask] = posData.brushID + + # Update data (rp, etc) + self.update_rp() + + # Repeat tracking + self.trackManuallyAddedObject(posData.brushID, self.isNewID) + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Add new ID with magic-wand') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Add new ID with magic-wand') + + # Label ROI mouse release --> label the ROI with labelRoiWorker + elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): + self.labelRoiRunning = True + self.app.setOverrideCursor(Qt.WaitCursor) + self.isMouseDragImg1 = False + + if self.labelRoiIsFreeHandRadioButton.isChecked(): + self.freeRoiItem.closeCurve() + + proceed = self.labelRoiCheckStartStopFrame() + if not proceed: + self.labelRoiCancelled() + return + + roiImg, self.labelRoiSlice = self.getLabelRoiImage() + + if roiImg.size == 0: + self.labelRoiCancelled() + return + + if self.labelRoiModel is None: + cancel = self.initLabelRoiModel() + if cancel: + self.labelRoiCancelled() + return + + # Restore state of button because it was maybe unchecked by + # using other tools that are allowed --> see "elif" case in + # labelRoi_cb + self.labelRoiButton.blockSignals(True) + self.labelRoiButton.setChecked(True) + self.labelRoiToolbar.setVisible(True) + self.labelRoiButton.blockSignals(False) + + roiSecondChannel = None + if self.secondChannelName is not None: + secondChannelData = self.getSecondChannelData() + roiSecondChannel = secondChannelData[self.labelRoiSlice] + + isTimelapse = self.labelRoiTrangeCheckbox.isChecked() + if isTimelapse: + start_n = self.labelRoiStartFrameNoSpinbox.value() + stop_n = self.labelRoiStopFrameNoSpinbox.value() + self.progressWin = apps.QDialogWorkerProgress( + title='ROI segmentation', parent=self, + pbarDesc=f'Segmenting frames n. {start_n} to {stop_n}...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(stop_n-start_n) + + + self.app.restoreOverrideCursor() + labelRoiWorker = self.labelRoiActiveWorkers[-1] + labelRoiWorker.start( + roiImg, posData, + roiSecondChannel=roiSecondChannel, + isTimelapse=isTimelapse + ) + self.app.setOverrideCursor(Qt.WaitCursor) + self.logger.info( + f'Magic labeller started on image ROI = {self.labelRoiSlice}...' + ) + self.titleLabel.setText('Magic labeller is doing its magic...') + self.setDisabled(True) + + # Move label mouse released, update move + elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): + self.isMovingLabel = False + + # Update data (rp, etc) + self.update_rp() + + # Repeat tracking + self.tracking(enforce=True, assign_unique_new_IDs=False) + + if not self.moveLabelToolButton.findChild(QAction).isChecked(): + self.moveLabelToolButton.setChecked(False) + else: + self.updateAllImages() + + # Assign mother to bud + elif self.assignBudMothButton.isChecked() and self.clickedOnBud: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == self.get_2Dlab(posData.lab)[self.yClickBud, self.xClickBud]: + return + + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + mothID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as mother cell', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + mothID_prompt.exec_() + if mothID_prompt.cancel: + return + else: + ID = mothID_prompt.EntryID + obj_idx = posData.IDs.index(ID) + y, x = posData.rp[obj_idx].centroid + xdata, ydata = int(x), int(y) + + if self.isSnapshot: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + relationship = posData.cca_df.at[ID, 'relationship'] + ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] + is_history_known = posData.cca_df.at[ID, 'is_history_known'] + # We allow assiging a cell in G1 as mother only on first frame + # OR if the history is unknown + if relationship == 'bud' and posData.frame_i > 0 and is_history_known: + self.assignBudMothButton.setChecked(False) + txt = html_utils.paragraph( + f'You clicked on ID {ID} which is a BUD.

' + 'To assign a bud start by clicking on the bud ' + 'and release on a cell in G1' + ) + msg = widgets.myMessageBox() + msg.critical( + self, 'Released on a bud', txt + ) + self.assignBudMothButton.setChecked(True) + return + + elif posData.frame_i == 0: + # Check that clicked bud actually is smaller that mother + # otherwise warn the user that he might have clicked first + # on a mother + budID = self.get_2Dlab(posData.lab)[self.yClickBud, self.xClickBud] + new_mothID = self.get_2Dlab(posData.lab)[ydata, xdata] + bud_obj_idx = posData.IDs.index(budID) + new_moth_obj_idx = posData.IDs.index(new_mothID) + rp_budID = posData.rp[bud_obj_idx] + rp_new_mothID = posData.rp[new_moth_obj_idx] + if rp_budID.area >= rp_new_mothID.area: + self.assignBudMothButton.setChecked(False) + msg = widgets.myMessageBox() + txt = ( + f'You clicked FIRST on ID {budID} and then on {new_mothID}.
' + f'For me this means that you want ID {budID} to be the ' + f'BUD of ID {new_mothID}.
' + f'However ID {budID} is bigger than {new_mothID} ' + f'so maybe you should have clicked FIRST on {new_mothID}?

' + 'What do you want me to do?' + ) + txt = html_utils.paragraph(txt) + swapButton, keepButton = msg.warning( + self, 'Which one is bud?', txt, + buttonsTexts=( + f'Assign ID {new_mothID} as the bud of ID {budID}', + f'Keep ID {budID} as the bud of ID {new_mothID}' + ) + ) + if msg.clickedButton == swapButton: + (xdata, ydata, + self.xClickBud, self.yClickBud) = ( + self.xClickBud, self.yClickBud, + xdata, ydata + ) + self.assignBudMothButton.setChecked(True) + + elif is_history_known and not self.clickedOnHistoryKnown: + self.assignBudMothButton.setChecked(False) + budID = self.get_2Dlab(posData.lab)[ydata, xdata] + # Allow assigning an unknown cell ONLY to another unknown cell + txt = ( + f'You started by clicking on ID {budID} which has ' + 'UNKNOWN history, but you then clicked/released on ' + f'ID {ID} which has KNOWN history.\n\n' + 'Only two cells with UNKNOWN history can be assigned as ' + 'relative of each other.' + ) + msg = QMessageBox() + msg.critical( + self, 'Released on a cell with KNOWN history', txt, msg.Ok + ) + self.assignBudMothButton.setChecked(True) + return + + self.clickedOnHistoryKnown = is_history_known + self.xClickMoth, self.yClickMoth = xdata, ydata + + if ccs != 'G1' and posData.frame_i > 0: + self.assignBudMothButton.setChecked(False) + self.onMotherNotInG1(ID) + self.assignBudMothButton.setChecked(True) + else: + self.annotateBudToDifferentMother() + + if not self.assignBudMothButton.findChild(QAction).isChecked(): + self.assignBudMothButton.setChecked(False) + + self.clickedOnBud = False + self.BudMothTempLine.setData([], []) + + # Draw clear region mouse release + elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): + self.isMouseDragImg1 = False + self.freeRoiItem.closeCurve() + self.clearObjsFreehandRegion() + + # Zoom rect mouse release + elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): + self.isMouseDragImg1 = False + self.zoomRectDone() + + def gui_clickedDelRoi(self, event, left_click, right_click): + posData = self.data[self.pos_i] + x, y = event.pos().x(), event.pos().y() + + # Check if right click on ROI + delROIs = ( + posData.allData_li[posData.frame_i]['delROIs_info']['rois'].copy() + ) + for r, roi in enumerate(delROIs): + ROImask = self.getDelRoiMask(roi) + if self.isSegm3D: + clickedOnROI = ROImask[self.z_lab(), int(y), int(x)] + else: + clickedOnROI = ROImask[int(y), int(x)] + raiseContextMenuRoi = right_click and clickedOnROI + dragRoi = left_click and clickedOnROI + if raiseContextMenuRoi: + self.roi_to_del = roi + self.roiContextMenu = QMenu(self) + separator = QAction(self) + separator.setSeparator(True) + self.roiContextMenu.addAction(separator) + action = QAction('Remove ROI') + action.triggered.connect(self.removeDelROI) + self.roiContextMenu.addAction(action) + try: + # Convert QPointF to QPoint + self.roiContextMenu.exec_(event.screenPos().toPoint()) + except AttributeError: + self.roiContextMenu.exec_(event.screenPos()) + return True + elif dragRoi: + event.ignore() + return True + return False + + def gui_getHoveredSegmentsPolyLineRoi(self): + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + segments = [] + for roi in delROIs_info['rois']: + if not isinstance(roi, pg.PolyLineROI): + continue + for seg in roi.segments: + if seg.currentPen == seg.hoverPen: + seg.roi = roi + segments.append(seg) + return segments + + def gui_getHoveredHandlesPolyLineRoi(self): + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + handles = [] + for roi in delROIs_info['rois']: + if not isinstance(roi, pg.PolyLineROI): + continue + for handle in roi.getHandles(): + if handle.currentPen == handle.hoverPen: + handle.roi = roi + handles.append(handle) + return handles + + @exception_handler + def gui_mousePressRightImage(self, event): + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + alt = modifiers == Qt.AltModifier + isMod = alt + right_click = event.button() == Qt.MouseButton.RightButton and not isMod + is_right_click_action_ON = any([ + b.isChecked() for b in self.checkableQButtonsGroup.buttons() + ]) + self.typingEditID = False + showLabelsGradMenu = right_click and not is_right_click_action_ON + if showLabelsGradMenu: + self.gui_rightImageShowContextMenu(event) + event.ignore() + else: + self.gui_mousePressEventImg1(event) + + @exception_handler + def gui_mouseDragRightImage(self, event): + self.gui_mouseDragEventImg1(event) + + @exception_handler + def gui_mouseReleaseRightImage(self, event): + self.gui_mouseReleaseEventImg1(event) + + def drawTempRulerLine(self, event): + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + x, y = event.pos() + x1, y1 = int(x), int(y) + xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() + x0, y0 = xxRA[0], yyRA[0] + if ctrl: + x1, y1 = transformation.snap_xy_to_closest_angle( + x0, y0, x1, y1 + ) + self.ax1_rulerPlotItem.setData([x0, x1], [y0, y1]) + + @exception_handler + def gui_mousePressEventImg1(self, event: QMouseEvent): + self.typingEditID = False + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + alt = modifiers == Qt.AltModifier + isMod = alt + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + isCcaMode = mode == 'Cell cycle analysis' + isCustomAnnotMode = mode == 'Custom annotations' + left_click = event.button() == Qt.MouseButton.LeftButton and not isMod + middle_click = self.isMiddleClick(event, modifiers) + right_click = event.button() == Qt.MouseButton.RightButton + isPanImageClick = self.isPanImageClick(event, modifiers) + brushON = self.brushButton.isChecked() + curvToolON = self.curvToolButton.isChecked() + histON = self.setIsHistoryKnownButton.isChecked() + eraserON = self.eraserButton.isChecked() + rulerON = self.rulerButton.isChecked() + wandON = self.wandToolButton.isChecked() and not isPanImageClick + polyLineRoiON = self.addDelPolyLineRoiButton.isChecked() + labelRoiON = self.labelRoiButton.isChecked() + keepObjON = self.keepIDsButton.isChecked() + whitelistIDsON = self.whitelistIDsButton.isChecked() + separateON = self.separateBudButton.isChecked() + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + manualBackgroundON = self.manualBackgroundButton.isChecked() + magicPromptsON = self.magicPromptsToolButton.isChecked() + pointsLayerON = self.togglePointsLayerAction.isChecked() + copyContourON = ( + self.copyLostObjButton.isChecked() + and self.ax1_lostObjScatterItem.hoverLostID>0 + ) + findNextMotherButtonON = self.findNextMotherButton.isChecked() + unknownLineageButtonON = self.unknownLineageButton.isChecked() + drawClearRegionON = self.drawClearRegionButton.isChecked() + zoomRectON = self.zoomRectButton.isChecked() + + # Check if right-click on segment of polyline roi to add segment + segments = self.gui_getHoveredSegmentsPolyLineRoi() + if len(segments) == 1 and right_click: + seg = segments[0] + seg.roi.segmentClicked(seg, event) + return + + # Check if right-click on handle of polyline roi to remove it + handles = self.gui_getHoveredHandlesPolyLineRoi() + if len(handles) == 1 and right_click: + handle = handles[0] + handle.roi.removeHandle(handle) + return + + # Check if click on ROI + isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click) + if isClickOnDelRoi: + return + + dragImgLeft = ( + left_click and not brushON and not histON + and not curvToolON and not eraserON and not rulerON + and not wandON and not polyLineRoiON and not labelRoiON + and not middle_click and not keepObjON and not separateON + and not manualBackgroundON and not drawClearRegionON + and addPointsByClickingButton is None and not whitelistIDsON + and not zoomRectON + ) + if isPanImageClick: + dragImgLeft = True + + is_right_click_custom_ON = any([ + b.isChecked() for b in self.customAnnotDict.keys() + ]) + + canAnnotateDivision = ( + not self.assignBudMothButton.isChecked() + and not self.setIsHistoryKnownButton.isChecked() + and not self.curvToolButton.isChecked() + and not is_right_click_custom_ON + and not labelRoiON + and not separateON + ) + + # In timelapse mode division can be annotated if isCcaMode and right-click + # while in snapshot mode with Ctrl+right-click + isAnnotateDivision = ( + (right_click and isCcaMode and canAnnotateDivision) + or (right_click and ctrl and self.isSnapshot) + ) + + isCustomAnnot = ( + (right_click or dragImgLeft) + and (isCustomAnnotMode or self.isSnapshot) + and self.customAnnotButton is not None + ) + + is_right_click_action_ON = any([ + b.isChecked() for b in self.checkableQButtonsGroup.buttons() + ]) + + isOnlyRightClick = ( + right_click and canAnnotateDivision and not isAnnotateDivision + and not isMod and not is_right_click_action_ON + and not is_right_click_custom_ON and not copyContourON + and not findNextMotherButtonON and not unknownLineageButtonON + and not middle_click + ) + + if isOnlyRightClick: + # Start timer or check if it is a double-right-click + if self.countRightClicks == 0: + self.isDoubleRightClick = False + self.countRightClicks = 1 + self.doubleRightClickTimeElapsed = False + screenPos = event.screenPos() + self._img1_click_xy = (screenPos.x(), screenPos.y()) + QTimer.singleShot(400, self.doubleRightClickTimerCallBack) + return + elif ( + self.countRightClicks == 1 + and not self.doubleRightClickTimeElapsed + ): + self.isDoubleRightClick = True + self.countRightClicks = 0 + self.editIDbutton.setChecked(True) + + # Left click actions + canCurv = ( + curvToolON and not self.assignBudMothButton.isChecked() + and not brushON and not dragImgLeft and not eraserON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not magicPromptsON and not zoomRectON + ) + canBrush = ( + brushON and not curvToolON and not rulerON + and not dragImgLeft and not eraserON and not wandON + and not labelRoiON and not manualBackgroundON + and addPointsByClickingButton is None and not drawClearRegionON + and not magicPromptsON and not zoomRectON + ) + canErase = ( + eraserON and not curvToolON and not rulerON + and not dragImgLeft and not brushON and not wandON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not magicPromptsON and not zoomRectON + ) + canRuler = ( + rulerON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not wandON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not magicPromptsON and not zoomRectON + ) + canWand = ( + wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not magicPromptsON and not zoomRectON + ) + canPolyLine = ( + polyLineRoiON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not labelRoiON and not manualBackgroundON + and addPointsByClickingButton is None + and not drawClearRegionON and not magicPromptsON + and not zoomRectON + ) + canLabelRoi = ( + labelRoiON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not keepObjON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not whitelistIDsON and not magicPromptsON + and not zoomRectON + ) + canKeep = ( + keepObjON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not whitelistIDsON and not magicPromptsON + and not zoomRectON + ) + canWhitelistIDs = ( + whitelistIDsON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not keepObjON and not magicPromptsON + and not zoomRectON + ) + canAddPoint = ( + (pointsLayerON or magicPromptsON) + and addPointsByClickingButton is not None and not wandON + and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON and not keepObjON + and not manualBackgroundON and not drawClearRegionON + and not zoomRectON + ) + canAddManualBackgroundObj = ( + manualBackgroundON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not keepObjON and not drawClearRegionON + and not magicPromptsON and not whitelistIDsON + and not zoomRectON + ) + canDrawClearRegion = ( + drawClearRegionON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not labelRoiON and not manualBackgroundON + and addPointsByClickingButton is None + and not polyLineRoiON and not magicPromptsON + and not whitelistIDsON and not zoomRectON + ) + canZoomRect = ( + zoomRectON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON + and addPointsByClickingButton is None + and not manualBackgroundON and not drawClearRegionON + and not wandON and not whitelistIDsON and not magicPromptsON + ) + + # Enable dragging of the image window or the scalebar + if dragImgLeft and not isCustomAnnot: + x, y = event.pos().x(), event.pos().y() + if hasattr(self, 'scaleBar'): + if self.scaleBar.isHighlighted(): + self.scaleBar.mousePressed(x, y) + return + if hasattr(self, 'timestamp'): + if self.timestamp.isHighlighted(): + self.timestamp.mousePressed(x, y) + return + pg.ImageItem.mousePressEvent(self.img1, event) + event.ignore() + return + + isAllowedActionViewer = (canAddPoint or canRuler) + + if mode == 'Viewer' and not isAllowedActionViewer: + self.startBlinkingModeCB() + event.ignore() + return + + # Allow right-click or middle-click actions on both images + eventOnImg2 = ( + ( + right_click or (middle_click and not canAddPoint) + # or (left_click and separateON) + ) + and (mode=='Segmentation and Tracking' or self.isSnapshot) + and not isAnnotateDivision and not manualBackgroundON + ) + if eventOnImg2: + event.isImg1Sender = True + self.gui_mousePressEventImg2(event) + + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + Y, X = self.get_2Dlab(posData.lab).shape + if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + else: + return + + # Paint new IDs with brush and left click on the left image + if left_click and canBrush: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + lab_2D = self.get_2Dlab(posData.lab) + Y, X = lab_2D.shape + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False, storeOnlyZoom=True) + + ID = self.getHoverID(xdata, ydata) + + if ID > 0: + posData.brushID = ID + self.isNewID = False + else: + # Update brush ID. Take care of disappearing cells to remember + # to not use their IDs anymore in the future + self.isNewID = True + self.setBrushID() + self.updateLookuptable(lenNewLut=posData.brushID+1) + + self.brushColor = self.lut[posData.brushID]/255 + + self.yPressAx2, self.xPressAx2 = y, x + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) + + self.isMouseDragImg1 = True + + # Draw new objects + localLab = lab_2D[diskSlice] + mask = diskMask.copy() + if not self.isPowerBrush() and not ctrl: + mask[localLab!=0] = False + + self.applyBrushMask(mask, posData.brushID, toLocalSlice=diskSlice) + + self.setImageImg2(updateLookuptable=False) + + how = self.drawIDsContComboBox.currentText() + lab2D = self.get_2Dlab(posData.lab) + self.globalBrushMask = np.zeros(lab2D.shape, dtype=bool) + brushMask = localLab == posData.brushID + brushMask = np.logical_and(brushMask, diskMask) + self.setTempImg1Brush( + True, brushMask, posData.brushID, toLocalSlice=diskSlice + ) + + self.lastHoverID = -1 + + elif left_click and canErase: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + lab_2D = self.get_2Dlab(posData.lab) + Y, X = lab_2D.shape + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False, storeOnlyZoom=True) + + self.yPressAx2, self.xPressAx2 = y, x + # Keep a list of erased IDs got erased + self.erasedIDs = set() + + if self.xyOnCtrlPressedFirstTime is not None: + self.erasedID = self.getHoverID(*self.xyOnCtrlPressedFirstTime) + else: + self.erasedID = self.getHoverID(xdata, ydata) + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + + # Build eraser mask + mask = np.zeros(lab_2D.shape, bool) + mask[ymin:ymax, xmin:xmax][diskMask] = True + + + # If user double-pressed 'b' then erase over ALL labels + color = self.eraserButton.palette().button().color().name() + eraseOnlyOneID = ( + color != self.doublePressKeyButtonColor + and self.erasedID != 0 + ) + + self.eraseOnlyOneID = eraseOnlyOneID + + if eraseOnlyOneID: + mask[lab_2D!=self.erasedID] = False + + self.setTempImg1Eraser(mask, init=True) + self.applyEraserMask(mask) + + self.erasedIDs.update(lab_2D[mask]) + + for erasedID in self.erasedIDs: + if erasedID == 0: + continue + self.erasedLab[lab_2D==erasedID] = erasedID + + self.isMouseDragImg1 = True + + elif canAddPoint: + action = addPointsByClickingButton.action + self.storeUndoAddPoint(action) + x, y = event.pos().x(), event.pos().y() + hoveredPoints = action.scatterItem.pointsAt(event.pos()) + if len(hoveredPoints) > 0: + removed_ids = self.removeClickedPoints(action, hoveredPoints) + if not magicPromptsON: + removed_id = min(removed_ids) + addPointsByClickingButton.pointIdSpinbox.setValue(removed_id) + addPointsByClickingButton.pointIdSpinbox.removedId = ( + removed_id + ) + else: + self.restorePrevPointIdRightClick(addPointsByClickingButton) + self.drawPointsLayers(computePointsLayers=False) + else: + point_id = self.getAddedPointId( + magicPromptsON, addPointsByClickingButton, + right_click, left_click, middle_click + ) + if point_id is None: + return + + self.addClickedPoint(action, x, y, point_id) + self.drawPointsLayers(computePointsLayers=False) + + point_id = self.getClickedPointNewId( + action, point_id, + addPointsByClickingButton.pointIdSpinbox, + isMagicPrompts=magicPromptsON + ) + addPointsByClickingButton.pointIdSpinbox.setValue( + point_id, setLinkedWidget=False + ) + + elif left_click and canDrawClearRegion: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + self.freeRoiItem.addPoint(xdata, ydata) + + self.isMouseDragImg1 = True + + elif left_click and canRuler or canPolyLine: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + closePolyLine = ( + len(self.startPointPolyLineItem.pointsAt(event.pos())) > 0 + ) + if not self.tempSegmentON or canPolyLine: + # Keep adding anchor points for polyline + self.ax1_rulerAnchorsItem.setData([xdata], [ydata]) + self.tempSegmentON = True + else: + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + self.tempSegmentON = False + xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() + x0, y0 = xxRA[0], yyRA[0] + if ctrl: + x1, y1 = transformation.snap_xy_to_closest_angle( + x0, y0, xdata, ydata + ) + else: + x1, y1 = xdata, ydata + lengthText = self.getRulerLengthText() + self.ax1_rulerPlotItem.setData( + [x0, x1], [y0, y1], lengthText=lengthText + ) + self.ax1_rulerAnchorsItem.setData([x0, x1], [y0, y1]) + + xxPolyLine = self.startPointPolyLineItem.getData()[0] + if canPolyLine and len(xxPolyLine) == 0: + # Create and add roi item + self.createDelPolyLineRoi() + # Add start point of polyline roi + self.startPointPolyLineItem.setData([xdata], [ydata]) + self.polyLineRoi.points.append((xdata, ydata)) + elif canPolyLine: + # Add points to polyline roi and eventually close it + if not closePolyLine: + self.polyLineRoi.points.append((xdata, ydata)) + self.addPointsPolyLineRoi(closed=closePolyLine) + if closePolyLine: + # Close polyline ROI + if len(self.polyLineRoi.getLocalHandlePositions()) == 2: + self.polyLineRoi = self.replacePolyLineRoiWithLineRoi( + self.polyLineRoi + ) + self.tempSegmentON = False + self.ax1_rulerAnchorsItem.setData([], []) + self.ax1_rulerPlotItem.setData([], []) + self.startPointPolyLineItem.setData([], []) + self.addRoiToDelRoiInfo(self.polyLineRoi) + # Call roi moving on closing ROI + self.delROImoving(self.polyLineRoi) + self.delROImovingFinished(self.polyLineRoi) + + elif left_click and canKeep: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + keepID_win = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to keep', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + keepID_win.exec_() + if keepID_win.cancel: + return + else: + ID = keepID_win.EntryID + + if ID in self.keptObjectsIDs: + self.keptObjectsIDs.remove(ID) + self.clearHighlightedText() + else: + self.keptObjectsIDs.append(ID) + self.highlightLabelID(ID) + + self.updateTempLayerKeepIDs() + + elif left_click and canWhitelistIDs: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + keepID_win = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to select', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + keepID_win.exec_() + if keepID_win.cancel: + return + else: + ID = keepID_win.EntryID + + posData = self.data[self.pos_i] + + if not posData.whitelist: + wl_init = False + if not hasattr(self, 'tempWhitelistIDs'): + self.tempWhitelistIDs = set() # not updated, only use in this context + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = self.tempWhitelistIDs + else: + wl_init = True + current_whitelist = posData.whitelist.get(posData.frame_i) + + if ID in current_whitelist: + current_whitelist.remove(ID) + self.removeHighlightLabelID(IDs=[ID]) + else: + current_whitelist.add(ID) + self.highlightLabelID(ID) + + self.whitelistIDsToolbar.whitelistLineEdit.setText( + current_whitelist + ) + + if wl_init: + posData.whitelist[posData.frame_i] = current_whitelist + else: + self.tempWhitelistIDs = current_whitelist + + self.whitelistUpdateTempLayer() + + elif right_click and copyContourON: + hoverLostID = self.ax1_lostObjScatterItem.hoverLostID + self.copyLostObjectMask(hoverLostID) + self.update_rp() + self.updateAllImages() + self.store_data() + + elif right_click and canCurv: + # Draw manually assisted auto contour + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + Y, X = self.get_2Dlab(posData.lab).shape + + self.autoCont_x0 = xdata + self.autoCont_y0 = ydata + self.xxA_autoCont, self.yyA_autoCont = [], [] + self.curvAnchors.addPoints([x], [y]) + img = self.getDisplayedImg1() + self.autoContObjMask = np.zeros(img.shape, np.uint8) + self.isRightClickDragImg1 = True + + elif left_click and canCurv: + # Draw manual spline + x, y = event.pos().x(), event.pos().y() + Y, X = self.get_2Dlab(posData.lab).shape + + # Check if user clicked on starting anchor again --> close spline + closeSpline = False + clickedAnchors = self.curvAnchors.pointsAt(event.pos()) + xxA, yyA = self.curvAnchors.getData() + if len(xxA)>0: + if len(xxA) == 1: + self.splineHoverON = True + x0, y0 = xxA[0], yyA[0] + if len(clickedAnchors)>0: + xA_clicked, yA_clicked = clickedAnchors[0].pos() + if x0==xA_clicked and y0==yA_clicked: + x = x0 + y = y0 + closeSpline = True + + # Add anchors + self.curvAnchors.addPoints([x], [y]) + try: + xx, yy = self.curvHoverPlotItem.getData() + self.curvPlotItem.setData(xx, yy) + except Exception as e: + # traceback.print_exc() + pass + + if closeSpline: + self.splineHoverON = False + self.curvToolSplineToObj() + self.update_rp() + if self.autoIDcheckbox.isChecked(): + self.trackManuallyAddedObject(posData.brushID, True) + if self.isSnapshot: + self.fixCcaDfAfterEdit('Add new ID with curvature tool') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Add new ID with curvature tool') + self.clearCurvItems() + self.curvTool_cb(True) + + elif left_click and canWand: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + Y, X = self.get_2Dlab(posData.lab).shape + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + self.isNewID = False + posData.brushID = self.get_2Dlab(posData.lab)[ydata, xdata] + if posData.brushID == 0: + self.setBrushID() + self.updateLookuptable( + lenNewLut=posData.brushID+1 + ) + self.isNewID = True + self.brushColor = self.img2.lut[posData.brushID]/255 + + # NOTE: flood is on mousedrag or release + tol = self.getMagicWandFloodTolerance() + self.initFloodMaskImage() + if self.isSegm3D: + z_slice = self.zSliceScrollBar.sliderPosition() + seed = (z_slice, ydata, xdata) + else: + seed = (ydata, xdata) + + flood_mask = skimage.segmentation.flood( + self.flood_img, seed, tolerance=tol + ) + + drawUnderMask = np.logical_or( + posData.lab==0, posData.lab==posData.brushID + ) + self.flood_mask = np.logical_and(flood_mask, drawUnderMask) + + if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): + self.flood_mask = core.binary_fill_holes(self.flood_mask) + + if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): + self.flood_mask = core.convex_hull_mask(self.flood_mask) + + self.setTempBrushMaskFromWand(self.flood_mask, init=True) + self.isMouseDragImg1 = True + + elif right_click and self.manualTrackingButton.isChecked(): + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + manualTrackID = self.manualTrackingToolbar.spinboxID.value() + clickedID = self.getClickedID( + xdata, ydata, text=f'that you want to assign to {manualTrackID}' + ) + if clickedID is None: + return + + if clickedID == manualTrackID: + self.manualTrackingToolbar.showWarning( + f'The clicked object already has ID = {manualTrackID}' + ) + return + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + posData = self.data[self.pos_i] + currentIDs = posData.IDs.copy() + if manualTrackID in currentIDs: + tempID = max(currentIDs) + 1 + posData.lab[posData.lab == clickedID] = tempID + posData.lab[posData.lab == manualTrackID] = clickedID + posData.lab[posData.lab == tempID] = manualTrackID + self.manualTrackingToolbar.showWarning( + f'The ID {manualTrackID} already exists --> ' + f'ID {manualTrackID} has been swapped with {clickedID}' + ) + else: + posData.lab[posData.lab == clickedID] = manualTrackID + self.manualTrackingToolbar.showInfo( + f'ID {clickedID} changed to {manualTrackID}.' + ) + + self.update_rp() + self.updateAllImages() + + elif right_click and manualBackgroundON: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + + delID = posData.manualBackgroundLab[ydata, xdata] + if delID == 0: + return + + self.clearManualBackgroundObject(delID) + textItem = self.manualBackgroundTextItems.pop(delID) + self.ax1.removeItem(textItem) + self.setManualBackgroundImage() + + elif left_click and canAddManualBackgroundObj: + x, y = event.pos().x(), event.pos().y() + + self.addManualBackgroundObject(x, y) + self.setManualBackgroundImage() + self.setManualBackgrounNextID() + + # Label ROI mouse press + elif (left_click or right_click) and canLabelRoi: + if right_click: + # Force model initialization on mouse release + self.labelRoiModel = None + + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + + if self.labelRoiIsRectRadioButton.isChecked(): + self.labelRoiItem.setPos((xdata, ydata)) + elif self.labelRoiIsFreeHandRadioButton.isChecked(): + self.freeRoiItem.addPoint(xdata, ydata) + + self.isMouseDragImg1 = True + + # Annotate cell cycle division + elif isAnnotateDivision: + if posData.cca_df is None: + return + + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + divID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as divided', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + divID_prompt.exec_() + if divID_prompt.cancel: + return + else: + ID = divID_prompt.EntryID + obj_idx = posData.IDs.index(ID) + y, x = posData.rp[obj_idx].centroid + xdata, ydata = int(x), int(y) + + if not self.isSnapshot: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + # Annotate or undo division + self.manualCellCycleAnnotation(ID) + else: + self.undoBudMothAssignment(ID) + + # Assign bud to mother (mouse down on bud) + elif right_click and self.assignBudMothButton.isChecked(): + if self.clickedOnBud: + # NOTE: self.clickedOnBud is set to False when assigning a mother + # is successfull in mouse release event + # We still have to click on a mother + return + + if posData.cca_df is None: + return + + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + budID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID of a bud you want to correct mother assignment', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + budID_prompt.exec_() + if budID_prompt.cancel: + return + else: + ID = budID_prompt.EntryID + + obj_idx = posData.IDs.index(ID) + y, x = posData.rp[obj_idx].centroid + xdata, ydata = int(x), int(y) + + relationship = posData.cca_df.at[ID, 'relationship'] + is_history_known = posData.cca_df.at[ID, 'is_history_known'] + self.clickedOnHistoryKnown = is_history_known + # We allow assiging a cell in G1 as bud only on first frame + # OR if the history is unknown + if relationship != 'bud' and posData.frame_i > 0 and is_history_known: + txt = (f'You clicked on ID {ID} which is NOT a bud.\n' + 'To assign a bud to a cell start by clicking on a bud ' + 'and release on a cell in G1') + msg = QMessageBox() + msg.critical( + self, 'Not a bud', txt, msg.Ok + ) + return + + self.clickedOnBud = True + self.xClickBud, self.yClickBud = xdata, ydata + + # Annotate (or undo) that cell has unknown history + elif right_click and self.setIsHistoryKnownButton.isChecked(): + if posData.cca_df is None: + return + + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + unknownID_prompt = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as ' + '"history UNKNOWN/KNOWN"', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + unknownID_prompt.exec_() + if unknownID_prompt.cancel: + return + else: + ID = unknownID_prompt.EntryID + obj_idx = posData.IDs.index(ID) + y, x = posData.rp[obj_idx].centroid + xdata, ydata = int(x), int(y) + + self.annotateIsHistoryKnown(ID) + if not self.setIsHistoryKnownButton.findChild(QAction).isChecked(): + self.setIsHistoryKnownButton.setChecked(False) + + elif isCustomAnnot: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) + clickedBkgrDialog = apps.QLineEditDialog( + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as divided', + parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + clickedBkgrDialog.exec_() + if clickedBkgrDialog.cancel: + return + else: + ID = clickedBkgrDialog.EntryID + obj_idx = posData.IDs.index(ID) + y, x = posData.rp[obj_idx].centroid + xdata, ydata = int(x), int(y) + + button = self.doCustomAnnotation(ID) + if button is None: + return + + keepActive = self.customAnnotDict[button]['state']['keepActive'] + if not keepActive: + button.setChecked(False) + + elif right_click and findNextMotherButtonON: + if posData.frame_i == 0: + return + + self.find_mother_action(posData, event, ydata, xdata) + + elif right_click and unknownLineageButtonON: + if posData.frame_i == 0: + return + + self.annotate_unknown_lineage_action(posData, event, ydata, xdata) + + elif (left_click or right_click) and canZoomRect: + if left_click: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + + self.zoomRectItem.setPos((xdata, ydata)) + + self.isMouseDragImg1 = True + else: + try: + xRange, yRange = self.zoomRectItem.getLastRange() + self.ax1.setRange( + xRange=xRange, + yRange=yRange, + padding=0 + ) + except Exception as err: + QTimer.singleShot(100, self.autoRange) + + def repeat_click_and_backup(self, posData, event, ydata, xdata): + """ + This function is part of the lin_tree edit functionality. + It handles the back up of the original self.lineage_tree.lineage_list + df and the repeated clicking on the same ID to cycle through pssible mothers. + + Parameters + ---------- + posData : cellacdc.load.loadData + The position data. + event : QtGui.QMouseEvent + The event object. + ydata : int + The y-coordinate data. + xdata : int + The x-coordinate data. + + Returns + ------- + tuple + A tuple containing the point(tuple: (x, y) coords) and ID of clicked cell. + """ + if self.original_df_lin_tree is None: + self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() + self.original_df_lin_tree_i = posData.frame_i + elif self.original_df_lin_tree_i != posData.frame_i: + self.logger.info( + '[WARNING]: !!! Original lineage tree df changed, resetting original_df_lin_tree !!!' + ) + self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() + self.original_df_lin_tree_i = posData.frame_i + + if not self.right_click_ID: + self.right_click_i = 0 + self.right_click_ID = 0 + + x, y = event.pos().x(), event.pos().y() + point = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + + if ID == 0: + return None, None + + if self.right_click_ID != ID: + self.right_click_i = 0 + self.right_click_ID = ID + self.original_mother_skipped = False + elif event.modifiers() & Qt.ShiftModifier: + self.right_click_i -= 1 + else: + self.right_click_i += 1 + + return point, ID + + def getDistanceListMissingIDs(self, point, ID): + posData = self.data[self.pos_i] + frame_i = posData.frame_i + if self.getDistanceListMissingIDsCachedFrame != frame_i: + self.distanceListMissingIDs = dict() + self.getDistanceListMissingIDsCachedFrame = frame_i + # self.store_data(autosave=False) + # self.get_data() + + if ID not in self.distanceListMissingIDs.keys(): + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + relevant_rp = [ + obj for obj in prev_rp if obj.label not in posData.IDs + ] + len_relevant_rp = len(relevant_rp) + if len_relevant_rp == 0: + self.logger.info('No missing IDs found in previous frame.') + return [] + elif len_relevant_rp == 1: + self.distanceListMissingIDs[ID] = [relevant_rp[0].label] + return [relevant_rp[0].label] + else: + sorted_missing_IDs = myutils.sort_IDs_dist(relevant_rp, point=point) + self.distanceListMissingIDs[ID] = sorted_missing_IDs + return sorted_missing_IDs + else: + return self.distanceListMissingIDs[ID] + + def find_mother_action(self, posData, event, ydata, xdata): + """ + This function is part of the lin_tree edit functionality. + Associated with the right-click action of the 'findNextMotherButton' button. + Handles the right click action, which cycles through possible mothers of the clicked cell. + Changes the parent ID of the clicked cell to the next possible mother in self.lineage_tree.lineage_list. + + Parameters + ---------- + posData : cellacdc.load.loadData + The position data object. + event : QtGui.QMouseEvent + The event object. + ydata : int + The y-coordinate data. + xdata : int + The x-coordinate data. + """ + point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata) + + if point is None: + return + posData = self.data[self.pos_i] + acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] + filtered_IDs = self.getDistanceListMissingIDs(point, ID) + if len(filtered_IDs) == 0: + self.logger.info('No mother candidates found.') + return + + i = self.right_click_i % len(filtered_IDs) + i = abs(i) # Ensure i is non-negative + new_mother = filtered_IDs[i] + + if acdc_df_frame.loc[ID]['parent_ID_tree'] == new_mother and self.original_mother_skipped == False: # if a mother is already present, skip it + self.right_click_i += 1 + self.original_mother_skipped = True + + i = self.right_click_i % len(filtered_IDs) + i = abs(i) # Ensure i is non-negative + new_mother = filtered_IDs[i] + + acdc_df_frame.at[ID, 'parent_ID_tree'] = new_mother # update mother in the df, no need to propagate or stuff lile this + # dont need to update alldata_li as acdc_df_frame is just a view + self.drawAllLineageTreeLines() + + def annotate_unknown_lineage_action(self, posData, event, ydata, xdata): + """ + This function is part of the lin_tree edit functionality. + Associated with the right-click action of the 'unknownLineageButton' button. + Annotates an unknown lineage by setting its parent ID to -1 in the lineage tree (self.lineage_tree.lineage_list) + + Parameters + ---------- + posData : cellacdc.load.loadData + The position data. + event : QtGui.QMouseEvent + The event that triggered the annotation. + ydata : int + The y-coordinate data. + xdata : int + The x-coordinate data. + """ + point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata) + + if point is None: + return + posData = self.data[self.pos_i] + acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] + acdc_df_frame.at[ID, 'parent_ID_tree'] = -1 + self.drawAllLineageTreeLines() + + def gui_addCreatedAxesItems(self): + self.ax1.addItem(self.ax1_contoursImageItem) + self.ax1.addItem(self.ax1_lostObjImageItem) + self.ax1.addItem(self.ax1_lostTrackedObjImageItem) + self.ax1.addItem(self.ax1_oldMothBudLinesItem) + self.ax1.addItem(self.ax1_newMothBudLinesItem) + self.ax1.addItem(self.ax1_lostObjScatterItem) + self.ax1.addItem(self.ax1_lostTrackedScatterItem) + self.ax1.addItem(self.ccaFailedScatterItem) + self.ax1.addItem(self.yellowContourScatterItem) + + self.ax2.addItem(self.ax2_contoursImageItem) + self.ax2.addItem(self.ax2_lostObjImageItem) + self.ax2.addItem(self.ax2_lostTrackedObjImageItem) + self.ax2.addItem(self.ax2_oldMothBudLinesItem) + self.ax2.addItem(self.ax2_newMothBudLinesItem) + self.ax2.addItem(self.ax2_lostObjScatterItem) + + self.textAnnot[0].addToPlotItem(self.ax1) + self.textAnnot[1].addToPlotItem(self.ax2) + + self.ax1.addItem(self.exportMaskImageItem) + self.ax1.exportMaskImageItem = self.exportMaskImageItem + + def SegForLostIDsSetSettings(self): + + try: + prev_model = str(self.df_settings.at['SegForLostIDsModel', 'value']) + except KeyError: + prev_model = None + win = apps.QDialogSelectModel(parent=self, customFirst=prev_model) + win.exec_() + if win.cancel: + self.logger.info('Seg for lost IDs cancelled.') + return + base_model_name = win.selectedModel + + if base_model_name: + self.df_settings.at['SegForLostIDsModel', 'value'] = base_model_name + self.df_settings.to_csv(self.settings_csv_path) + + model_name = 'local_seg' + + idx = self.modelNames.index(model_name) + acdcSegment = self.acdcSegment_li[idx] + + try: + if acdcSegment is None or base_model_name != self.local_seg_base_model_name: + self.logger.info(f'Importing {base_model_name}...') + acdcSegment = myutils.import_segment_module(base_model_name) + self.acdcSegment_li[idx] = acdcSegment + self.local_seg_base_model_name = base_model_name + except (IndexError, ImportError, KeyError) as e: + self.logger.error(f'Error importing {base_model_name}: {e}') + return + + extra_params = ['overlap_threshold', + 'padding', + 'size_perc_diff', + 'distance_filler_growth', + 'max_iterations', + 'allow_only_tracked_cells'] + + extra_types = [float, float, float, float, int, bool] + + extra_defaults = [0.5, 0.8, 0.3, 1., 2, False] + + extra_desc = ['Overlap threshold with other already segemented cells over which newly segmented cells are discarded', + 'Padding of the box used for new segmentation around the segmentation from the previous frame', + 'Relative size difference acceptable compared to previous frames', + """Cells which are already segmented are filled with random noise sampled from background + to ensure that they don't get segmented again. + This parameter controls the additional padding around the already segmented cells.""", + """The algorithm will try and segment the maximum amount + of cells in the image by running the model several + times and filling new found cells with background noise. + How many of these iterations should be run?""", + "If no new cell IDs should be permitted (based on real time tracking)"] + + extra_ArgSpec = [] + for i, param in enumerate(extra_params): + param = ArgSpec(name=param, + default=extra_defaults[i], + type=extra_types[i], + desc=extra_desc[i], + docstring='') + + extra_ArgSpec.append(param) + + init_params, segment_params = myutils.getModelArgSpec(acdcSegment) + segment_params = [arg for arg in segment_params if arg[0] != 'diameter'] + + extraParamsTitle = 'Settings for local segmentation' + win = self.initSegmModelParams( + base_model_name, acdcSegment, init_params, segment_params, + extraParams=extra_ArgSpec, extraParamsTitle=extraParamsTitle, + initLastParams=True, ini_filename='segmentation_for_lostIDs.ini', + ) + + if win is None: + self.logger.info('Segmentation for lost IDs cancelled.') + return + + init_kwargs_new = {} + args_new = {} + for key, val in win.init_kwargs.items(): + if key in extra_params: + args_new[key] = val + else: + init_kwargs_new[key] = val + + for key, val in win.extra_kwargs.items(): + if key in extra_params: + args_new[key] = val + + self.SegForLostIDsSettings = { + 'win': win, + 'init_kwargs_new': init_kwargs_new, + 'args_new': args_new, + 'base_model_name': base_model_name, + } + + def segForLostIDsButtonClicked(self): + + self.setFrameNavigationDisabled(disable=True, why='Segmentation for lost IDs') + posData = self.data[self.pos_i] + if posData.frame_i == 0: + self.logger.info('Segmentation for lost IDs not available on first frame.') + self.setFrameNavigationDisabled(disable=False, why='Segmentation for lost IDs') + return + self.storeUndoRedoStates(False) + self.progressWin = apps.QDialogWorkerProgress( + title='Segmenting for lost IDs', parent=self, + pbarDesc=f'Segmenting for lost IDs...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + self.startSegForLostIDsWorker() + + def onSegForLostInit(self): + self.logger.info('Settings for segmentation for lost IDs not set.') + self.SegForLostIDsSetSettings() + self.SegForLostIDsWaitCond.wakeAll() + + def SegForLostIDsWorkerAskInstallModel(self, model_name): + myutils.check_install_package(model_name) + self.SegForLostIDsWaitCond.wakeAll() + + def startSegForLostIDsWorker(self): + self.SegForLostIDsMutex = QMutex() + self.SegForLostIDsWaitCond = QWaitCondition() + self._thread = QThread() + + # Initialize the worker with mutex and wait condition + self.SegForLostIDsWorker = workers.SegForLostIDsWorker( + self, self.SegForLostIDsMutex, self.SegForLostIDsWaitCond + ) + + # Connect the worker's signal to the main thread's slot + self.SegForLostIDsWorker.sigAskInit.connect(self.onSegForLostInit) + self.SegForLostIDsWorker.sigAskInstallModel.connect( + self.SegForLostIDsWorkerAskInstallModel + ) + self.SegForLostIDsWorker.sigshowImageDebug.connect( + self.showImageDebug + ) + + self.SegForLostIDsWorker.sigSegForLostIDsWorkerAskInstallGPU.connect( + self.SegForLostIDsWorkerAskInstallGPU + ) + + self.SegForLostIDsWorker.sigStoreData.connect(self.onSigStoreDataSegForLostIDsWorker) + self.SegForLostIDsWorker.sigUpdateRP.connect(self.onSigUpdateRPSegForLostIDsWorker) + # self.SegForLostIDsWorker.sigGetData.connect(self.onSigGetDataSegForLostIDsWorker) + # self.SegForLostIDsWorker.sigGet2Dlab.connect(self.onSigGet2DlabSegForLostIDsWorker) + # self.SegForLostIDsWorker.sigGetTrackedLostIDs.connect(self.onSigGetTrackedSegForLostIDsWorker) + # self.SegForLostIDsWorker.sigGetBrushID.connect(self.onSigGetBrushIDSegForLostIDsWorker) + self.SegForLostIDsWorker.sigTrackManuallyAddedObject.connect(self.onSigTrackManuallyAddedObjectSegForLostIDsWorker) + + # Move the worker to the thread + self.SegForLostIDsWorker.moveToThread(self._thread) + + # Manage thread lifecycle + self.SegForLostIDsWorker.signals.finished.connect(self._thread.quit) + self.SegForLostIDsWorker.signals.finished.connect(self.SegForLostIDsWorker.deleteLater) + self._thread.finished.connect(self._thread.deleteLater) + + # Connect other worker signals to the appropriate slots + self.SegForLostIDsWorker.signals.finished.connect(self.SegForLostIDsWorkerFinished) + self.SegForLostIDsWorker.signals.progress.connect(self.workerProgress) + self.SegForLostIDsWorker.signals.initProgressBar.connect(self.workerInitProgressbar) + self.SegForLostIDsWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.SegForLostIDsWorker.signals.critical.connect(self.workerCritical) + + # Start the thread and worker + self._thread.started.connect(self.SegForLostIDsWorker.run) + self._thread.start() + + def SegForLostIDsWorkerAskInstallGPU(self, model_name, use_gpu): + result = myutils.check_gpu_available(model_name, use_gpu, qparent=self) + self.SegForLostIDsWorker.gpu_go = result + dont_force_cpu = myutils.check_gpu_available( + model_name, use_gpu, do_not_warn=True) + self.SegForLostIDsWorker.dont_force_cpu = dont_force_cpu + self.SegForLostIDsWaitCond.wakeAll() + + def onSigStoreDataSegForLostIDsWorker(self, autosave): + self.onSigStoreData( + self.SegForLostIDsWaitCond, autosave=autosave) + + def onSigUpdateRPSegForLostIDsWorker(self, wl_update, wl_track_og_curr): + self.onSigUpdateRP(self.SegForLostIDsWaitCond, + wl_update=wl_update, + wl_track_og_curr=wl_track_og_curr) + + # def onSigGetDataSegForLostIDsWorker(self): + # self.onSigGetData( + # self.SegForLostIDsWaitCond) + + # def onSigGet2DlabSegForLostIDsWorker(self): + # posData = self.data[self.pos_i] + # lab = self.get_2Dlab(posData.lab) + # self.SegForLostIDsWorker.lab = lab + # self.SegForLostIDsWaitCond.wakeAll() + + # def onSigGetTrackedSegForLostIDsWorker(self): + # self.SegForLostIDsWorker.trackedLostIDs = self.getTrackedLostIDs() + # self.SegForLostIDsWaitCond.wakeAll() + + # def onSigGetBrushIDSegForLostIDsWorker(self): + # self.SegForLostIDsWorker.brushID = self.setBrushID(useCurrentLab=True, return_val=True) + # self.SegForLostIDsWaitCond.wakeAll() + + def onSigTrackManuallyAddedObjectSegForLostIDsWorker(self, added_IDs, isNewID, wl_update, wl_track_og_curr): + self.trackManuallyAddedObject(added_IDs, isNewID, wl_update=wl_update, wl_track_og_curr=wl_track_og_curr) + self.SegForLostIDsWaitCond.wakeAll() + + + def onSigStoreData( + self, waitcond, pos_i=None, enforce=True, debug=False, + mainThread=True, autosave=True, store_cca_df_copy=False + ): + self.store_data(pos_i=pos_i, enforce=enforce, debug=debug, mainThread=mainThread, + autosave=autosave, store_cca_df_copy=store_cca_df_copy) + waitcond.wakeAll() + + def onSigUpdateRP(self, waitcond, draw=True, debug=False, update_IDs=True, + wl_update=True, wl_track_og_curr=False): + self.update_rp(draw=draw, debug=debug, update_IDs=update_IDs, + wl_update=wl_update, wl_track_og_curr=wl_track_og_curr) + waitcond.wakeAll() + + def onSigGetData(self, waitcond, debug=False): + self.get_data(debug=debug) + waitcond.wakeAll() + + def SegForLostIDsWorkerFinished(self): + self.updateAllImages() + self.update_rp() + self.store_data(autosave=True) + self.setFrameNavigationDisabled(disable=False, why='Segmentation for lost IDs') + + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + def showImageDebug(self, img): + imshow(img) + + def gui_raiseBottomLayoutContextMenu(self, event): + try: + # Convert QPointF to QPoint + self.bottomLayoutContextMenu.popup(event.globalPos().toPoint()) + except AttributeError: + self.bottomLayoutContextMenu.popup(event.globalPos()) + + def areContoursRequested(self, ax): + if ax == 0 and self.annotContourCheckbox.isChecked(): + return True + + if ax == 1: + if not self.labelsGrad.showRightImgAction.isChecked(): + return False + + isRightDifferentAnnot = self.rightBottomGroupbox.isChecked() + areContRequestedRight = self.annotContourCheckboxRight.isChecked() + + if isRightDifferentAnnot and areContRequestedRight: + return True + + areContRequestedLeft = self.annotContourCheckbox.isChecked() + if not isRightDifferentAnnot and areContRequestedLeft: + return True + return False + + def areMothBudLinesRequested(self, ax): + if ax == 0: + if self.annotCcaInfoCheckbox.isChecked(): + return True + if self.drawMothBudLinesCheckbox.isChecked(): + return True + else: + if not self.labelsGrad.showRightImgAction.isChecked(): + return False + + isRightDifferentAnnot = self.rightBottomGroupbox.isChecked() + areLinesRequestedRight = ( + self.annotCcaInfoCheckboxRight.isChecked() + or self.drawMothBudLinesCheckboxRight.isChecked() + ) + + if isRightDifferentAnnot and areLinesRequestedRight: + return True + + areLinesRequestedLeft = ( + self.drawMothBudLinesCheckbox.isChecked() + or self.annotCcaInfoCheckbox.isChecked() + ) + if not isRightDifferentAnnot and areLinesRequestedLeft: + return True + return False + + def getMothBudLineScatterItem(self, ax, new): + if ax == 0: + if new: + return self.ax1_newMothBudLinesItem + else: + return self.ax1_oldMothBudLinesItem + else: + if new: + return self.ax2_newMothBudLinesItem + else: + return self.ax2_oldMothBudLinesItem + + def labelRoiIsCircularRadioButtonToggled(self, checked): + if checked: + self.labelRoiCircularRadiusSpinbox.setDisabled(False) + else: + self.labelRoiCircularRadiusSpinbox.setDisabled(True) + + def pxModeActionToggled(self, checked): + self.df_settings.at['pxMode', 'value'] = int(checked) + self.df_settings.to_csv(self.settings_csv_path) + + if not self.isDataLoaded: + return + + if self.highLowResAction.isChecked(): + for ax in range(2): + self.textAnnot[ax].setPxMode(checked) + + self.updateAllImages() + + def relabelSequentialCallback(self): + mode = str(self.modeComboBox.currentText()) + if mode == 'Viewer' or mode == 'Cell cycle analysis': + self.startBlinkingModeCB() + return + + posData = self.data[self.pos_i] + selectedPos = (posData.pos_foldername, ) + if len(self.data) > 1: + selectedPos = self.askSelectPos(action='to process') + if selectedPos is None: + self.logger.info('Re-labelling process stopped.') + return + + self.store_data() + # acdc_df_concat = self.getConcatAcdcDf() + # load.store_unsaved_acdc_df( + # posData, acdc_df_concat, + # log_func=self.logger.info + # ) + # if posData.SizeT > 1: + self.progressWin = apps.QDialogWorkerProgress( + title='Re-labelling sequential', parent=self, + pbarDesc='Relabelling sequential...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + self.startRelabellingWorker(selectedPos) + + # elif posData: + # self.storeUndoRedoStates(False) + # posData.lab, oldIDs, newIDs = core.relabel_sequential(posData.lab) + # # Update annotations based on relabelling + # self.update_cca_df_relabelling(posData, oldIDs, newIDs) + # self.updateAnnotatedIDs(oldIDs, newIDs, logger=self.logger.info) + # self.store_data() + # self.update_rp() + # li = list(zip(oldIDs, newIDs)) + # s = '\n'.join([str(pair).replace(',', ' -->') for pair in li]) + # s = f'IDs relabelled as follows:\n{s}' + # self.logger.info(s) + # self.updateAllImages() + + def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print): + logger('Updating annotated IDs...') + posData = self.data[self.pos_i] + + mapper = dict(zip(oldIDs, newIDs)) + posData.ripIDs = set([mapper[ripID] for ripID in posData.ripIDs]) + posData.binnedIDs = set([mapper[binID] for binID in posData.binnedIDs]) + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + + customAnnotButtons = list(self.customAnnotDict.keys()) + for button in customAnnotButtons: + customAnnotValues = self.customAnnotDict[button] + annotatedIDs = customAnnotValues['annotatedIDs'][self.pos_i] + mappedAnnotIDs = {} + for frame_i, annotIDs_i in annotatedIDs.items(): + mappedIDs = [mapper[ID] for ID in annotIDs_i] + mappedAnnotIDs[frame_i] = mappedIDs + customAnnotValues['annotatedIDs'][self.pos_i] = mappedAnnotIDs + + def rtTrackerActionToggled(self, checked): + if not checked: + return + + aliases = myutils.aliases_real_time_trackers(reverse=True) + if self.sender().text() in aliases: + trackingAlgo = aliases[self.sender().text()] + else: + trackingAlgo = self.sender().text() + self.df_settings.at['tracking_algorithm', 'value'] = trackingAlgo + self.df_settings.to_csv(self.settings_csv_path) + + if self.sender().text() == 'YeaZ': + msg = widgets.myMessageBox(wrapText=False) + info_txt = html_utils.paragraph(f""" + Note that YeaZ tracking algorithm tends to be sliglhtly more accurate + overall, but it is less capable of detecting segmentation + errors.

+ If you need to correct as many segmentation errors as possible + we recommend using Cell-ACDC tracking algorithm. + """) + msg.information(self, 'Info about YeaZ', info_txt) + + self.isRealTimeTrackerInitialized = False + self.initRealTimeTracker() + + def autoPilotToggled(self, checked): + self.autoPilotZoomToObjToolbar.setVisible(checked) + if checked: + self.autoPilotZoomToObjToggle.setChecked(False) + self.autoPilotZoomToObjToggle.toggle() + + def zoomRectActionToggled(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + self.connectLeftClickButtons() + self.ax1.addItem(self.zoomRectItem) + else: + self.zoomRectItem.setPos((0,0)) + self.zoomRectItem.setSize((0,0)) + self.ax1.removeItem(self.zoomRectItem) + + def zoomRectDone(self): + xRange, yRange = self.ax1.viewRange() + self.zoomRectItem.storeLastRange(xRange, yRange) + + ymin, xmin, ymax, xmax = self.zoomRectItem.bbox() + + self.zoomRectItem.setPos((0,0)) + self.zoomRectItem.setSize((0,0)) + + self.ax1.setRange( + xRange=(xmin, xmax), + yRange=(ymin, ymax), + padding=0 + ) + + def zoomRectCancelled(self): + self.isMouseDragImg1 = False + self.zoomRectItem.setPos((0,0)) + self.zoomRectItem.setSize((0,0)) + + def findID(self, checked=False, ID=None): + posData = self.data[self.pos_i] + if ID is None: + searchIDdialog = apps.FindIDDialog( + title='Search object by ID', + msg='Enter object ID to find and highlight', + parent=self, + isInteger=True + ) + searchIDdialog.exec_() + if searchIDdialog.cancel: + return + + searchedID = searchIDdialog.EntryID + else: + searchedID = ID + + if searchedID in posData.IDs: + self.goToObjectID(searchedID) + return + + if posData.SizeT == 1: + self.warnIDnotFound(searchedID) + return + + if searchedID in posData.lost_IDs: + self.goToLostObjectID(searchedID) + return + + tracked_lost_IDs = self.getTrackedLostIDs() + if searchedID in tracked_lost_IDs: + self.goToAcceptedLostObjectID(searchedID) + return + + self.logger.info(f'Searching ID {searchedID} in other frames...') + + frame_i_found = self.startSearchIDworker(searchedID) + if frame_i_found is None: + self.warnIDnotFound(searchedID) + return + + self.logger.info( + f'Object ID {searchedID} found at frame n. {frame_i_found+1}.' + ) + proceed = self.askGoToFrameFoundID(searchedID, frame_i_found) + if not proceed: + return + + posData.frame_i = frame_i_found + self.get_data() + self.updateAllImages() + self.updateScrollbars() + + self.goToObjectID(searchedID) + + @disableWindow + def startSearchIDworker(self, searchedID): + posData = self.data[self.pos_i] + + desc = 'Searching ID in all frames...' + + self.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self.mainWin, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(posData.SizeT) + self.progressWin.show(self.app) + + self.searchIDthread = QThread() + self.searchIDworker = workers.SimpleWorker( + posData, self.searchIDworkerCallback, + func_args=(searchedID, ) + ) + self.searchIDworker.frame_i_found = None + self.searchIDworker.moveToThread(self.searchIDthread) + + self.searchIDworker.signals.finished.connect( + self.searchIDthread.quit + ) + self.searchIDworker.signals.finished.connect( + self.searchIDworker.deleteLater + ) + self.searchIDthread.finished.connect(self.searchIDthread.deleteLater) + + self.searchIDworker.signals.critical.connect( + self.searchIDworkerCritical + ) + self.searchIDworker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.searchIDworker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.searchIDworker.signals.progress.connect( + self.workerProgress + ) + self.searchIDworker.signals.finished.connect( + self.searchIDworkerFinished + ) + + self.searchIDthread.started.connect(self.searchIDworker.run) + self.searchIDthread.start() + + self.searchIDworkerLoop = QEventLoop() + self.searchIDworkerLoop.exec_() + + return self.searchIDworker.frame_i_found + + def searchIDworkerCritical(self, error): + self.searchIDworkerLoop.exit() + self.workerCritical(error) + + def searchIDworkerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.searchIDworkerLoop.exit() + + def searchIDworkerCallback(self, posData, searchedID): + self.searchIDworker.signals.initProgressBar.emit(0) + self.setAllIDs() + self.searchIDworker.signals.initProgressBar.emit(posData.SizeT) + frame_i_found = None + for frame_i in range(len(posData.segm_data)): + if frame_i >= len(posData.allData_li): + break + lab = posData.allData_li[frame_i]['labels'] + if lab is None: + rp = skimage.measure.regionprops(posData.segm_data[frame_i]) + IDs = set([obj.label for obj in rp]) + else: + IDs = posData.allData_li[frame_i]['IDs'] + + if searchedID in IDs: + frame_i_found = frame_i + break + + self.searchIDworker.signals.progressBar.emit(1) + + self.searchIDworker.frame_i_found = frame_i_found + + def warnIDnotFound(self, searchedID): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(f""" + Object ID {searchedID} was not found.

+ """) + msg.warning(self, f'ID {searchedID} not found', txt) + + def goToObjectID(self, ID): + posData = self.data[self.pos_i] + objIdx = posData.IDs_idxs[ID] + obj = posData.rp[objIdx] + self.goToZsliceSearchedID(obj) + + self.highlightSearchedID(ID) + propsQGBox = self.guiTabControl.propsQGBox + propsQGBox.idSB.setValue(ID) + + def goToLostObjectID(self, lostID, color=(255, 165, 0, 255)): + posData = self.data[self.pos_i] + frame_i = posData.frame_i + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] + obj = prev_rp[prev_IDs_idxs[lostID]] + self.goToZsliceSearchedID(obj) + + imageItem = self.getLostObjImageItem(0) + thickness = 1 + if not hasattr(self, 'lostObjContoursImage'): + self.initLostObjContoursImage() + else: + self.lostObjContoursImage[:] = 0 + + contours = [] + obj_contours = self.getObjContours(obj, all_external=True) + contours.extend(obj_contours) + + self.addLostObjsToLostObjImage(obj, lostID) + self.drawLostObjContoursImage( + imageItem, contours, thickness=2, color=color + ) + + def goToAcceptedLostObjectID(self, acceptedLostID): + posData = self.data[self.pos_i] + frame_i = posData.frame_i + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] + obj = prev_rp[prev_IDs_idxs[acceptedLostID]] + self.goToZsliceSearchedID(obj) + + self.updateLostTrackedContoursImage(tracked_lost_IDs=[acceptedLostID]) + + def askGoToFrameFoundID(self, searchedID, frame_i_found): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(f""" + Object ID {searchedID} was found at frame n. {frame_i_found+1}.

+ Do you want to go to frame n. {frame_i_found+1}. + """) + noButton, yesButton = msg.information( + self, f'ID {searchedID} found at frame n. {frame_i_found+1}', txt, + buttonsTexts=( + 'No, stay on current frame', + f'Yes, go to frame n. {frame_i_found+1}' + ) + ) + return msg.clickedButton == yesButton + + def skipForwardToNewID(self): + self.progressWin = apps.QDialogWorkerProgress( + title='Searching the next frame with a new object', parent=self, + pbarDesc=f'Searching the next frame with a new object...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + self.startFindNextNewIdWorker() + + def startFindNextNewIdWorker(self): + posData = self.data[self.pos_i] + self._thread = QThread() + self.findNextNewIdWorker = workers.FindNextNewIdWorker(posData, self) + self.findNextNewIdWorker.moveToThread(self._thread) + + self.findNextNewIdWorker.signals.finished.connect(self._thread.quit) + self.findNextNewIdWorker.signals.finished.connect( + self.findNextNewIdWorker.deleteLater + ) + self._thread.finished.connect(self._thread.deleteLater) + + self.findNextNewIdWorker.signals.finished.connect( + self.findNextNewIdWorkerFinished + ) + self.findNextNewIdWorker.signals.progress.connect(self.workerProgress) + self.findNextNewIdWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.findNextNewIdWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.findNextNewIdWorker.signals.critical.connect( + self.workerCritical + ) + + self._thread.started.connect(self.findNextNewIdWorker.run) + self._thread.start() + + def findNextNewIdWorkerFinished(self, next_frame_i): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.navSpinBox.setValue(next_frame_i+1) + self.framesScrollBarReleased() + + def workerProgress(self, text, loggerLevel='INFO'): # used in cca and lin tree + if self.progressWin is not None: + self.progressWin.logConsole.append(text) + self.logger.log(getattr(logging, loggerLevel), text) + + def workerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.logger.info('Worker process ended.') + self.updateAllImages() + self.titleLabel.setText('Done', color='w') + + def savePreprocWorkerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.setStatusBarLabel() + self.logger.info('Pre-processed data saved!') + self.titleLabel.setText('Pre-processed data saved!', color='w') + + def delObjsOutSegmMaskWorkerFinished(self, result): + posData = self.data[self.pos_i] + worker, cleared_segm_data, delIDs = result + if posData.SizeT == 1: + cleared_segm_data = cleared_segm_data[np.newaxis] + + self.update_cca_df_deletedIDs(posData, delIDs) + + current_frame_i = posData.frame_i + for frame_i, cleared_lab in enumerate(cleared_segm_data): + # Store change + posData.allData_li[frame_i]['labels'] = cleared_lab + # Get the rest of the stored metadata based on the new lab + posData.frame_i = frame_i + self.get_data() + self.store_data(autosave=False) + + # Back to current frame + posData.frame_i = current_frame_i + self.get_data() + + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.logger.info('Deleting objects outside of ROIs finished.') + self.titleLabel.setText( + 'Deleting objects outside of ROIs finished.', color='w' + ) + self.updateAllImages() + + def loadingNewChunk(self, chunk_range): + coord0_chunk, coord1_chunk = chunk_range + desc = ( + f'Loading new window, range = ({coord0_chunk}, {coord1_chunk})...' + ) + self.progressWin = apps.QDialogWorkerProgress( + title='Loading data...', parent=self, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.show(self.app) + + def lazyLoaderFinished(self): + self.logger.info('Load chunk data worker done.') + if self.lazyLoader.updateImgOnFinished: + self.updateAllImages() + + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + @exception_handler + def trackingWorkerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.logger.info('Worker process ended.') + askDisableRealTimeTracking = ( + self.trackingWorker.trackingOnNeverVisitedFrames + and self.realTimeTrackingToggle.isChecked() + ) + if askDisableRealTimeTracking: + msg = widgets.myMessageBox() + title = 'Disable real-time tracking?' + txt = ( + 'You perfomed tracking on frames that you have ' + 'never visited.

' + 'Cell-ACDC default behaviour is to track them again when you ' + 'will visit them.

' + 'However, you can overwrite this behaviour and explicitly ' + 'disable tracking for all of the frames you already tracked.

' + 'NOTE: you can reactivate real-time tracking by clicking on the ' + '"Reset last segmented frame" button on the top toolbar.

' + 'What do you want me to do?' + ) + _, disableTrackingButton = msg.information( + self, title, html_utils.paragraph(txt), + buttonsTexts=( + 'Keep real-time tracking active (recommended)', + 'Disable real-time tracking' + ) + ) + if msg.clickedButton == disableTrackingButton: + self.logger.info('Disabling real time tracking...') + self.realTimeTrackingToggle.setChecked(False) + # posData = self.data[self.pos_i] + # current_frame_i = posData.frame_i + # for frame_i in range(self.start_n-1, self.stop_n): + # posData.frame_i = frame_i + # self.get_data() + # self.store_data(autosave=frame_i==self.stop_n-1) + # posData.last_tracked_i = frame_i + # self.setNavigateScrollBarMaximum() + + # # Back to current frame + # posData.frame_i = current_frame_i + # self.get_data() + posData = self.data[self.pos_i] + self.updateAllImages() + self.titleLabel.setText('Done', color='w') + + def workerInitProgressbar(self, totalIter): + self.progressWin.mainPbar.setValue(0) + if totalIter == 1: + totalIter = 0 + self.progressWin.mainPbar.setMaximum(totalIter) + + def workerUpdateProgressbar(self, step): + self.progressWin.mainPbar.update(step) + + def workerInitInnerPbar(self, totalIter): + self.progressWin.innerPbar.setValue(0) + if totalIter == 1: + totalIter = 0 + self.progressWin.innerPbar.setMaximum(totalIter) + + def workerUpdateInnerPbar(self, step): + self.progressWin.innerPbar.update(step) + + def startTrackingWorker(self, posData, video_to_track): + self.thread = QThread() + self.trackingWorker = workers.trackingWorker( + posData, self, video_to_track + ) + self.trackingWorker.moveToThread(self.thread) + self.trackingWorker.finished.connect(self.thread.quit) + self.trackingWorker.finished.connect(self.trackingWorker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + # Custom signals + self.trackingWorker.signals.progress = self.trackingWorker.progress + self.trackingWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.trackingWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.trackingWorker.signals.sigInitInnerPbar.connect( + self.workerInitInnerPbar + ) + self.trackingWorker.progress.connect(self.workerProgress) + self.trackingWorker.critical.connect(self.workerCritical) + self.trackingWorker.finished.connect(self.trackingWorkerFinished) + + self.trackingWorker.debug.connect(self.workerDebug) + + self.thread.started.connect(self.trackingWorker.run) + self.thread.start() + + def startRelabellingWorker(self, posFoldernames): + self.thread = QThread() + self.worker = workers.relabelSequentialWorker(self, posFoldernames) + self.worker.moveToThread(self.thread) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + self.worker.progress.connect(self.workerProgress) + self.worker.critical.connect(self.workerCritical) + self.worker.finished.connect(self.workerFinished) + self.worker.finished.connect(self.relabelWorkerFinished) + + self.worker.debug.connect(self.workerDebug) + + self.thread.started.connect(self.worker.run) + self.thread.start() + + def startPostProcessSegmWorker( + self, postProcessKwargs, customPostProcessGroupedFeatures, + customPostProcessFeatures + ): + self.thread = QThread() + self.postProcessWorker = workers.PostProcessSegmWorker( + postProcessKwargs, customPostProcessGroupedFeatures, + customPostProcessFeatures, self + ) + + self.postProcessWorker.moveToThread(self.thread) + self.postProcessWorker.signals.finished.connect(self.thread.quit) + self.postProcessWorker.signals.finished.connect( + self.postProcessWorker.deleteLater + ) + self.thread.finished.connect(self.thread.deleteLater) + + self.postProcessWorker.signals.finished.connect( + self.postProcessSegmWorkerFinished + ) + self.postProcessWorker.signals.progress.connect(self.workerProgress) + self.postProcessWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.postProcessWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.postProcessWorker.signals.critical.connect( + self.workerCritical + ) + + self.thread.started.connect(self.postProcessWorker.run) + self.thread.start() + + def relabelWorkerFinished(self): + self.updateAllImages() + + def workerDebug(self, item): + tracked_video, worker = item + from cellacdc.plot import imshow + imshow(tracked_video) + worker.waitCond.wakeAll() + + def keepToolActiveActionToggled(self, checked, toolName=None): + if toolName is None: + parentToolButton = self.sender().parent() + toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] + + if checked: + self.df_settings.at[toolName, 'value'] = 'keepActive' + else: + self.df_settings = self.df_settings.drop( + index=toolName, errors='ignore' + ) + self.df_settings.to_csv(self.settings_csv_path) + + def applyToolNewFrameActionToggled(self, checked, toolName=None): + if toolName is None: + parentToolButton = self.sender().parent() + toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] + toolName = toolName.strip() + button = self.applyToolNewFrameButtons[toolName] + toolName = toolName.replace(' ', '_') + settingName = f'{toolName}_applyNewFrame' + if checked: + self.df_settings.at[settingName, 'value'] = 'applyNewFrame' + button.setStyleSheet(f'background-color: {GREEN_HEX}') + else: + self.df_settings = self.df_settings.drop( + index=settingName, errors='ignore' + ) + button.setStyleSheet('background-color: none') + self.df_settings.to_csv(self.settings_csv_path) + + def keepAllToolsActiveActionToggled(self, checked): + for action in self.keepToolActiveActions.values(): + action.setChecked(checked) + + data_loaded = True + if not hasattr(self, 'data'): + data_loaded = False + try: + self.labelRoiTrangeCheckbox.disconnect() + except TypeError: + pass + self.labelRoiTrangeCheckbox.setChecked(checked) # why this is not wrapped in a QAction? + + if data_loaded: + self.labelRoiTrangeCheckbox.toggled.connect( + self.labelRoiTrangeCheckboxToggled + ) + + def determineSlideshowWinPos(self): + screens = self.app.screens() + self.numScreens = len(screens) + winScreen = self.screen() + + # Center main window and determine location of slideshow window + # depending on number of screens available + if self.numScreens > 1: + for screen in screens: + if screen != winScreen: + winScreen = screen + break + + winScreenGeom = winScreen.geometry() + winScreenCenter = winScreenGeom.center() + winScreenCenterX = winScreenCenter.x() + winScreenCenterY = winScreenCenter.y() + winScreenLeft = winScreenGeom.left() + winScreenTop = winScreenGeom.top() + self.slideshowWinLeft = winScreenCenterX - int(850/2) + self.slideshowWinTop = winScreenCenterY - int(800/2) + + def nonViewerEditMenuOpened(self): + mode = str(self.modeComboBox.currentText()) + if mode == 'Viewer': + self.startBlinkingModeCB() + + def getDistantGray(self, desiredGray, bkgrGray): + isDesiredSimilarToBkgr = ( + abs(desiredGray-bkgrGray) < 0.3 + ) + if isDesiredSimilarToBkgr: + return 1-desiredGray + else: + return desiredGray + + def RGBtoGray(self, R, G, B): + # see https://stackoverflow.com/questions/17615963/standard-rgb-to-grayscale-conversion + C_linear = (0.2126*R + 0.7152*G + 0.0722*B)/255 + if C_linear <= 0.0031309: + gray = 12.92*C_linear + else: + gray = 1.055*(C_linear)**(1/2.4) - 0.055 + return gray + + def ruler_cb(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + self.connectLeftClickButtons() + else: + self.tempSegmentON = False + self.ax1_rulerPlotItem.setData([], []) + self.ax1_rulerAnchorsItem.setData([], []) + + def editImgProperties(self, checked=True): + posData = self.data[self.pos_i] + posData.askInputMetadata( + len(self.data), + ask_SizeT=True, + ask_TimeIncrement=True, + ask_PhysicalSizes=True, + save=True, singlePos=True, + askSegm3D=False + ) + if hasattr(self, 'timestamp'): + self.timestamp.setSecondsPerFrame(posData.TimeIncrement) + self.updateTimestampFrame() + + if hasattr(self, 'scaleBar'): + self.scaleBar.updatePhysicalLength(posData.PhysicalSizeX) + + def setHoverToolSymbolData(self, xx, yy, ScatterItems, size=None): + if not xx: + self.ax1_lostObjScatterItem.setVisible(True) + self.ax2_lostObjScatterItem.setVisible(True) + + self.ax1_lostTrackedScatterItem.setVisible(True) + self.ax2_lostTrackedScatterItem.setVisible(True) + + for item in ScatterItems: + if size is None: + item.setData(xx, yy) + else: + item.setData(xx, yy, size=size) + + def updateLabelRoiCircularSize(self, value): + self.labelRoiCircItemLeft.setSize(value) + self.labelRoiCircItemRight.setSize(value) + + def updateLabelRoiCircularCursor(self, x, y, checked): + if not self.labelRoiButton.isChecked(): + return + if not self.labelRoiIsCircularRadioButton.isChecked(): + return + if self.labelRoiRunning: + return + + size = self.labelRoiCircularRadiusSpinbox.value() + if not checked: + xx, yy = [], [] + else: + xx, yy = [x], [y] + + if not xx and len(self.labelRoiCircItemLeft.getData()[0]) == 0: + return + + self.labelRoiCircItemLeft.setData(xx, yy, size=size) + self.labelRoiCircItemRight.setData(xx, yy, size=size) + + def getLabelRoiImage(self): + posData = self.data[self.pos_i] + + if self.labelRoiTrangeCheckbox.isChecked(): + start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 + stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() + tRangeLen = stop_frame_n-start_frame_i + else: + tRangeLen = 1 + + if tRangeLen > 1: + tRange = (start_frame_i, stop_frame_n) + else: + tRange = None + + if self.isSegm3D: + if tRangeLen > 1: + imgData = posData.img_data + else: + # Filtered data not existing + imgData = posData.img_data[posData.frame_i] + + roi_zdepth = self.labelRoiZdepthSpinbox.value() + if roi_zdepth == posData.SizeZ: + z0 = 0 + z1 = posData.SizeZ + elif roi_zdepth == 1: + z0 = self.zSliceScrollBar.sliderPosition() + z1 = z0 + 1 + else: + if roi_zdepth%2 != 0: + roi_zdepth +=1 + half_zdepth = int(roi_zdepth/2) + zc = self.zSliceScrollBar.sliderPosition() + 1 + z0 = zc-half_zdepth + z0 = z0 if z0>=0 else 0 + z1 = zc+half_zdepth + z1 = z1 if z1 1: + imgData = posData.img_data + else: + imgData = self.img1.image + + roiImg = imgData[labelRoiSlice] + if self.labelRoiIsFreeHandRadioButton.isChecked(): + mask = self.freeRoiItem.mask() + elif self.labelRoiIsCircularRadioButton.isChecked(): + mask = self.labelRoiCircItemLeft.mask() + else: + mask = None + + if mask is not None: + # Copy roiImg otherwise we are replacing minimum inside original image + roiImg = roiImg.copy() + # Fill outside of freehand roi with minimum of the ROI image + if tRangeLen > 1: + for i in range(tRangeLen): + ith_roiImg = roiImg[i] + if self.isSegm3D: + roiImg[i, :, ~mask] = ith_roiImg.min() + else: + roiImg[i, ~mask] = ith_roiImg.min() + else: + if self.isSegm3D: + roiImg[:, ~mask] = roiImg.min() + else: + roiImg[~mask] = roiImg.min() + + return roiImg, labelRoiSlice + + def getClickedID(self, xdata, ydata, text=''): + posData = self.data[self.pos_i] + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + msg = ( + 'You clicked on the background.\n' + f'Enter here the ID {text}' + ) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), xdata, ydata + ) + clickedBkgrID = apps.QLineEditDialog( + title='Clicked on background', + msg=msg, parent=self, allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True + ) + clickedBkgrID.exec_() + if clickedBkgrID.cancel: + return + else: + ID = clickedBkgrID.EntryID + return ID + + # @exec_time + def applyEditID( + self, clickedID, currentIDs, oldIDnewIDMapper, clicked_x, clicked_y, shift=False, doPropagateUnvisited=False + ): + posData = self.data[self.pos_i] + + # Ask to propagate change to all future visited frames + key = 'Edit ID' + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + clickedID, key, doNotShow, + posData.UndoFutFrames_EditID, posData.applyFutFrames_EditID, + applyTrackingB=True + ) + + if UndoFutFrames is None: + return + + if shift and self.isSegm3D: + lab = self.get_2Dlab(posData.lab) + else: + lab = posData.lab + + # Store undo state before modifying stuff + self.storeUndoRedoStates(UndoFutFrames) + maxID = max(posData.IDs, default=0) + for old_ID, new_ID in oldIDnewIDMapper: + if new_ID in currentIDs and not self.editIDmergeIDs: + tempID = maxID + 1 + lab[lab == old_ID] = maxID + 1 + lab[lab == new_ID] = old_ID + lab[lab == tempID] = new_ID + maxID += 1 + + old_ID_idx = currentIDs.index(old_ID) + new_ID_idx = currentIDs.index(new_ID) + + # Append information for replicating the edit in tracking + # List of tuples (y, x, replacing ID) + objo = posData.rp[old_ID_idx] + yo, xo = self.getObjCentroid(objo.centroid) + objn = posData.rp[new_ID_idx] + yn, xn = self.getObjCentroid(objn.centroid) + if not math.isnan(yo) and not math.isnan(yn): + yn, xn = int(yn), int(xn) + posData.editID_info.append((yn, xn, new_ID)) + yo, xo = int(clicked_y), int(clicked_x) + posData.editID_info.append((yo, xo, old_ID)) + else: + lab[lab == old_ID] = new_ID + if new_ID > maxID: + maxID = new_ID + old_ID_idx = posData.IDs.index(old_ID) + + # Append information for replicating the edit in tracking + # List of tuples (y, x, replacing ID) + obj = posData.rp[old_ID_idx] + y, x = self.getObjCentroid(obj.centroid) + if not math.isnan(y) and not math.isnan(y): + y, x = int(y), int(x) + posData.editID_info.append((y, x, new_ID)) + + self.updateAssignedObjsAcdcTrackerSecondStep(new_ID) + + if shift and self.isSegm3D: + self.set_2Dlab(lab) + + # Update rps + self.update_rp() + + # Since we manually changed an ID we don't want to repeat tracking + self.setAllTextAnnotations() + self.highlightLostNew() + # self.checkIDsMultiContour() + + # Update colors for the edited IDs + self.updateLookuptable() + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Edit ID') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Edit ID', update_images=False) + + if not self.editIDbutton.findChild(QAction).isChecked(): + self.editIDbutton.setChecked(False) + + posData.disableAutoActivateViewerWindow = True + + # Perform desired action on future frames + posData.doNotShowAgain_EditID = doNotShowAgain + posData.UndoFutFrames_EditID = UndoFutFrames + posData.applyFutFrames_EditID = applyFutFrames + includeUnvisited = ( + posData.includeUnvisitedInfo['Edit ID'] + or doPropagateUnvisited + ) + + if not applyFutFrames and not doPropagateUnvisited: + return + + self.changeIDfutureFrames( + endFrame_i, oldIDnewIDMapper, includeUnvisited, + shift=shift + ) + + def getLastHoveredID(self): + if self.xHoverImg is None: + return 0 + + xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) + ID = self.currentLab2D[ydata, xdata] + return ID + + def getHoverID(self, xdata, ydata, byPassShiftCheck=False): + if not hasattr(self, 'diskMask'): + return 0 + + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + if byPassShiftCheck: + shift = False + else: + shift = modifiers == Qt.ShiftModifier + + if self.isPowerBrush() and not ctrl: + return 0 + + if not self.autoIDcheckbox.isChecked(): + return self.editIDspinbox.value() + + ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + ID = lab_2D[ydata, xdata] + self.isHoverZneighID = False + if self.isSegm3D: + z = self.z_lab() + SizeZ = posData.lab.shape[0] + doNotLinkThroughZ = self.brushButton.isChecked() and shift + if doNotLinkThroughZ: + if self.brushHoverCenterModeAction.isChecked() or ID>0: + hoverID = ID + else: + masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] + hoverID = np.bincount(masked_lab).argmax() + else: + if z > 0: + ID_z_under = posData.lab[z-1, ydata, xdata] + if self.brushHoverCenterModeAction.isChecked() or ID_z_under>0: + hoverIDa = ID_z_under + else: + lab = posData.lab + masked_lab_a = lab[z-1, ymin:ymax, xmin:xmax][diskMask] + hoverIDa = np.bincount(masked_lab_a).argmax() + else: + hoverIDa = 0 + + if self.brushHoverCenterModeAction.isChecked() or ID>0: + hoverIDb = lab_2D[ydata, xdata] + else: + masked_lab_b = lab_2D[ymin:ymax, xmin:xmax][diskMask] + hoverIDb = np.bincount(masked_lab_b).argmax() + + if z < SizeZ-1: + ID_z_above = posData.lab[z+1, ydata, xdata] + if self.brushHoverCenterModeAction.isChecked() or ID_z_above>0: + hoverIDc = ID_z_above + else: + lab = posData.lab + masked_lab_c = lab[z+1, ymin:ymax, xmin:xmax][diskMask] + hoverIDc = np.bincount(masked_lab_c).argmax() + else: + hoverIDc = 0 + + if hoverIDa > 0: + hoverID = hoverIDa + self.isHoverZneighID = True + elif hoverIDb > 0: + hoverID = hoverIDb + elif hoverIDc > 0: + hoverID = hoverIDc + self.isHoverZneighID = True + else: + hoverID = 0 + else: + if self.brushButton.isChecked() and shift: + # Force new ID with brush and Shift + hoverID = 0 + elif self.brushHoverCenterModeAction.isChecked() or ID>0: + hoverID = ID + else: + masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] + hoverID = np.bincount(masked_lab).argmax() + + self.editIDspinbox.setValue(hoverID) + + return hoverID + + def setHoverToolSymbolColor( + self, xdata, ydata, pen, ScatterItems, button, + brush=None, hoverRGB=None, ID=None, byPassShiftCheck=False + ): + modifiers = QGuiApplication.keyboardModifiers() + if byPassShiftCheck: + shift = False + else: + shift = modifiers == Qt.ShiftModifier + + posData = self.data[self.pos_i] + Y, X = self.get_2Dlab(posData.lab).shape + if not myutils.is_in_bounds(xdata, ydata, X, Y): + return + + self.isHoverZneighID = False + if ID is None: + hoverID = self.getHoverID( + xdata, ydata, byPassShiftCheck=byPassShiftCheck + ) + else: + hoverID = ID + + if hoverID == 0: + for item in ScatterItems: + item.setPen(pen) + item.setBrush(brush) + else: + try: + rgb = self.lut[hoverID] + rgb = rgb if hoverRGB is None else hoverRGB + rgbPen = np.clip(rgb*1.1, 0, 255) + for item in ScatterItems: + item.setPen(*rgbPen, width=2) + item.setBrush(*rgb, 100) + except IndexError: + pass + + checkChangeID = ( + self.isHoverZneighID and not shift + and self.lastHoverID != hoverID + ) + if checkChangeID: + # We are hovering an ID in z+1 or z-1 + self.restoreBrushID = hoverID + # self.changeBrushID() + + self.lastHoverID = hoverID + + def isPowerBrush(self): + color = self.brushButton.palette().button().color().name() + return color == self.doublePressKeyButtonColor + + def isPowerEraser(self): + color = self.eraserButton.palette().button().color().name() + return color == self.doublePressKeyButtonColor + + def isPowerButton(self, button): + color = button.palette().button().color().name() + return color == self.doublePressKeyButtonColor + + def getCheckNormAction(self): + normalize = False + how = '' + for action in self.normalizeQActionGroup.actions(): + if action.isChecked(): + how = action.text() + normalize = True + break + return action, normalize, how + + def normalizeIntensities(self, img): + action, normalize, how = self.getCheckNormAction() + if not normalize: + return img + + if how == 'Do not normalize. Display raw image': + img = img + elif how == 'Convert to floating point format with values [0, 1]': + img = myutils.img_to_float(img) + # elif how == 'Rescale to 8-bit unsigned integer format with values [0, 255]': + # img = skimage.img_as_float(img) + # img = (img*255).astype(np.uint8) + # return img + elif how == 'Rescale to [0, 1]': + img = skimage.img_as_float(img) + img = skimage.exposure.rescale_intensity(img) + elif how == 'Normalize by max value': + img = img/np.max(img) + return img + + def removeAlldelROIsCurrentFrame(self): + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + rois = delROIs_info['rois'].copy() + for roi in rois: + self.ax2.removeDelRoiItem(roi) + + for item in self.ax2.items: + if isinstance(item, pg.ROI): + self.ax2.removeDelRoiItem(item) + + for item in self.ax1.items: + if isinstance(item, pg.ROI) and item != self.labelRoiItem: + self.ax1.removeDelRoiItem(item) + + def removeDelROI(self, event): + posData = self.data[self.pos_i] + + for ax in (self.ax1, self.ax2): + try: + self.ax1.removeDelRoiItem(self.roi_to_del) + except Exception as err: + pass + + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + idx = delROIs_info['rois'].index(self.roi_to_del) + delROIs_info['rois'].pop(idx) + delROIs_info['delMasks'].pop(idx) + delROIs_info['delIDsROI'].pop(idx) + delROIs_info['state'].pop(idx) + + self.removeDelROIFromFutureFrames(self.roi_to_del) + self.updateAllImages() + + def removeDelROIFromFutureFrames(self, roi_to_del): + posData = self.data[self.pos_i] + + # Restore deleted IDs from already visited future frames + current_frame_i = posData.frame_i + for i in range(posData.frame_i+1, posData.SizeT): + if posData.allData_li[i]['labels'] is None: + break + + delROIs_info = posData.allData_li[i]['delROIs_info'] + try: + idx = delROIs_info['rois'].index(roi_to_del) + except IndexError: + continue + + posData.frame_i = i + idx = delROIs_info['rois'].index(roi_to_del) + if delROIs_info['delIDsROI'][idx]: + posData.lab = posData.allData_li[i]['labels'] + self.restoreAnnotDelROI(roi_to_del, enforce=True, draw=False) + posData.allData_li[i]['labels'] = posData.lab + self.get_data() + self.store_data(autosave=False) + delROIs_info['rois'].pop(idx) + delROIs_info['delMasks'].pop(idx) + delROIs_info['delIDsROI'].pop(idx) + delROIs_info['state'].pop(idx) + + if isinstance(self.roi_to_del, pg.PolyLineROI): + # PolyLine ROIs are only on ax1 + self.ax1.removeItem(self.roi_to_del) + elif not self.labelsGrad.showLabelsImgAction.isChecked(): + # Rect ROI is on ax1 because ax2 is hidden + self.ax1.removeItem(self.roi_to_del) + else: + # Rect ROI is on ax2 because ax2 is visible + self.ax2.removeItem(self.roi_to_del) + + # Back to current frame + posData.frame_i = current_frame_i + posData.lab = posData.allData_li[posData.frame_i]['labels'] + self.get_data() + self.store_data() + + def updateDelROIinFutureFrames(self, roi: pg.ROI): + posData = self.data[self.pos_i] + restore_current_frame = False + + roiState = roi.getState() + # Restore deleted IDs from already visited future frames + current_frame_i = posData.frame_i + delROIs_info = posData.allData_li[current_frame_i]['delROIs_info'] + try: + idx = delROIs_info['rois'].index(roi) + delROIs_info['state'][idx] = roiState + except Exception as err: + pass + + self.store_data() + + for i in range(posData.frame_i+1, posData.SizeT): + delROIs_info = posData.allData_li[i]['delROIs_info'] + try: + idx = delROIs_info['rois'].index(roi) + except Exception as err: + continue + delROIs_info['state'][idx] = roiState + if posData.allData_li[i]['labels'] is None: + continue + + posData.frame_i = i + posData.lab = posData.allData_li[i]['labels'] + self.restoreAnnotDelROI(roi, enforce=False, draw=False) + posData.allData_li[i]['labels'] = posData.lab + self.get_data() + self.store_data(autosave=False) + restore_current_frame = True + + if not restore_current_frame: + return + + # Back to current frame + posData.frame_i = current_frame_i + posData.lab = posData.allData_li[posData.frame_i]['labels'] + self.get_data() + self.store_data() + + # @exec_time + def getPolygonBrush(self, yxc2, Y, X): + # see https://en.wikipedia.org/wiki/Tangent_lines_to_circles + y1, x1 = self.yPressAx2, self.xPressAx2 + y2, x2 = yxc2 + R = self.brushSizeSpinbox.value() + r = R + + arcsin_den = np.sqrt((x2-x1)**2+(y2-y1)**2) + arctan_den = (x2-x1) + if arcsin_den!=0 and arctan_den!=0: + beta = np.arcsin((R-r)/arcsin_den) + gamma = -np.arctan((y2-y1)/arctan_den) + alpha = gamma-beta + x3 = x1 + r*np.sin(alpha) + y3 = y1 + r*np.cos(alpha) + x4 = x2 + R*np.sin(alpha) + y4 = y2 + R*np.cos(alpha) + + alpha = gamma+beta + x5 = x1 - r*np.sin(alpha) + y5 = y1 - r*np.cos(alpha) + x6 = x2 - R*np.sin(alpha) + y6 = y2 - R*np.cos(alpha) + + rr_poly, cc_poly = skimage.draw.polygon([y3, y4, y6, y5], + [x3, x4, x6, x5], + shape=(Y, X)) + else: + rr_poly, cc_poly = [], [] + + self.yPressAx2, self.xPressAx2 = y2, x2 + return rr_poly, cc_poly + + def get_dir_coords(self, alfa_dir, yd, xd, shape, connectivity=1): + h, w = shape + y_above = yd+1 if yd+1 < h else yd + y_below = yd-1 if yd > 0 else yd + x_right = xd+1 if xd+1 < w else xd + x_left = xd-1 if xd > 0 else xd + if alfa_dir == 0: + yy = [y_below, y_below, yd, y_above, y_above] + xx = [xd, x_right, x_right, x_right, xd] + elif alfa_dir == 45: + yy = [y_below, y_below, y_below, yd, y_above] + xx = [x_left, xd, x_right, x_right, x_right] + elif alfa_dir == 90: + yy = [yd, y_below, y_below, y_below, yd] + xx = [x_left, x_left, xd, x_right, x_right] + elif alfa_dir == 135: + yy = [y_above, yd, y_below, y_below, y_below] + xx = [x_left, x_left, x_left, xd, x_right] + elif alfa_dir == -180 or alfa_dir == 180: + yy = [y_above, y_above, yd, y_below, y_below] + xx = [xd, x_left, x_left, x_left, xd] + elif alfa_dir == -135: + yy = [y_below, yd, y_above, y_above, y_above] + xx = [x_left, x_left, x_left, xd, x_right] + elif alfa_dir == -90: + yy = [yd, y_above, y_above, y_above, yd] + xx = [x_left, x_left, xd, x_right, x_right] + else: + yy = [y_above, y_above, y_above, yd, y_below] + xx = [x_left, xd, x_right, x_right, x_right] + if connectivity == 1: + return yy[1:4], xx[1:4] + else: + return yy, xx + + def drawAutoContour(self, y2, x2): + y1, x1 = self.autoCont_y0, self.autoCont_x0 + Dy = abs(y2-y1) + Dx = abs(x2-x1) + edge = self.getDisplayedImg1() + if Dy != 0 or Dx != 0: + # NOTE: numIter takes care of any lag in mouseMoveEvent + numIter = int(round(max((Dy, Dx)))) + alfa = np.arctan2(y1-y2, x2-x1) + base = np.pi/4 + alfa_dir = round((base * round(alfa/base))*180/np.pi) + for _ in range(numIter): + y1, x1 = self.autoCont_y0, self.autoCont_x0 + yy, xx = self.get_dir_coords(alfa_dir, y1, x1, edge.shape) + a_dir = edge[yy, xx] + min_int = np.max(a_dir) + min_i = list(a_dir).index(min_int) + y, x = yy[min_i], xx[min_i] + try: + xx, yy = self.curvHoverPlotItem.getData() + except TypeError: + xx, yy = [], [] + + if xx is None or yy is None or len(xx) == 0 or len(yy) == 0: + xx, yy = [], [] + elif x == xx[-1] and y == yy[-1]: + # Do not append point equal to last point + return + + xx = np.r_[xx, x] + yy = np.r_[yy, y] + try: + self.curvHoverPlotItem.setData(xx, yy) + self.curvPlotItem.setData(xx, yy) + except TypeError: + pass + self.autoCont_y0, self.autoCont_x0 = y, x + # self.smoothAutoContWithSpline() + + def smoothAutoContWithSpline(self, n=3): + try: + xx, yy = self.curvHoverPlotItem.getData() + if xx is None or yy is None: + return + # Downsample by taking every nth coord + xxA, yyA = xx[::n], yy[::n] + rr, cc = skimage.draw.polygon(yyA, xxA) + self.autoContObjMask[rr, cc] = 1 + rp = skimage.measure.regionprops(self.autoContObjMask) + if not rp: + return + obj = rp[0] + cont = self.getObjContours(obj) + xxC, yyC = cont[:,0], cont[:,1] + xxA, yyA = xxC[::n], yyC[::n] + self.xxA_autoCont, self.yyA_autoCont = xxA, yyA + xxS, yyS = self.getSpline(xxA, yyA, per=True, appendFirst=True) + if len(xxS)>0: + self.curvPlotItem.setData(xxS, yyS) + except (TypeError, ValueError): + pass + + def updateIsHistoryKnown(): + """ + This function is called every time the user saves and it is used + for updating the status of cells where we don't know the history + + There are three possibilities: + + 1. The cell with unknown history is a BUD + --> we don't know when that bud emerged --> 'emerg_frame_i' = -1 + 2. The cell with unknown history is a MOTHER cell + --> we don't know emerging frame --> 'emerg_frame_i' = -1 + AND generation number --> we start from 'generation_num' = 2 + 3. The cell with unknown history is a CELL in G1 + --> we don't know emerging frame --> 'emerg_frame_i' = -1 + AND generation number --> we start from 'generation_num' = 2 + AND relative's ID in the previous cell cycle --> 'relative_ID' = -1 + """ + pass + + def getStatusKnownHistoryBud(self, ID): + posData = self.data[self.pos_i] + cca_df_ID = None + for i in range(posData.frame_i-1, -1, -1): + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + is_cell_existing = is_bud_existing = ID in cca_df_i.index + if not is_cell_existing: + bud_cca_dict = base_cca_dict.copy() + bud_cca_dict['cell_cycle_stage'] = 'S' + bud_cca_dict['generation_num'] = 0 + bud_cca_dict['relationship'] = 'bud' + bud_cca_dict['emerg_frame_i'] = i+1 + bud_cca_dict['is_history_known'] = True + cca_df_ID = pd.Series(bud_cca_dict) + return cca_df_ID + + def setHistoryKnowledge(self, ID, cca_df): + posData = self.data[self.pos_i] + is_history_known = cca_df.at[ID, 'is_history_known'] + if is_history_known: + cca_df.at[ID, 'is_history_known'] = False + cca_df.at[ID, 'cell_cycle_stage'] = 'G1' + cca_df.at[ID, 'generation_num'] += 2 + cca_df.at[ID, 'emerg_frame_i'] = -1 + cca_df.at[ID, 'relative_ID'] = -1 + cca_df.at[ID, 'relationship'] = 'mother' + else: + cca_df.loc[ID] = posData.ccaStatus_whenEmerged[ID] + + def annotateIsHistoryKnown(self, ID): + """ + This function is used for annotating that a cell has unknown or known + history. Cells with unknown history are for example the cells already + present in the first frame or cells that appear in the frame from + outside of the field of view. + + With this function we simply set 'is_history_known' to False. + When the users saves instead we update the entire staus of the cell + with unknown history with the function "updateIsHistoryKnown()" + """ + posData = self.data[self.pos_i] + is_history_known = posData.cca_df.at[ID, 'is_history_known'] + relID = posData.cca_df.at[ID, 'relative_ID'] + if relID in posData.cca_df.index: + relID_cca = self.getStatus_RelID_BeforeEmergence(ID, relID) + + if is_history_known: + # Save status of ID when emerged to allow undoing + statusID_whenEmerged = self.getStatusKnownHistoryBud(ID) + if statusID_whenEmerged is None: + return + posData.ccaStatus_whenEmerged[ID] = statusID_whenEmerged + + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + if ID not in posData.ccaStatus_whenEmerged: + self.warnSettingHistoryKnownCellsFirstFrame(ID) + return + + self.setHistoryKnowledge(ID, posData.cca_df) + + if relID in posData.cca_df.index: + # If the cell with unknown history has a relative ID assigned to it + # we set the cca of it to the status it had BEFORE the assignment + posData.cca_df.loc[relID] = relID_cca + + # Update cell cycle info LabelItems + obj_idx = posData.IDs.index(ID) + rp_ID = posData.rp[obj_idx] + + if relID in posData.IDs: + relObj_idx = posData.IDs.index(relID) + rp_relID = posData.rp[relObj_idx] + + self.setAllTextAnnotations() + self.drawAllMothBudLines() + + self.store_cca_df() + + if self.ccaTableWin is not None: + zoomIDs = self.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + # Correct future frames + for i in range(posData.frame_i+1, posData.SizeT): + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + self.storeUndoRedoCca(i, cca_df_i, undoId) + IDs = cca_df_i.index + if ID not in IDs: + # For some reason ID disappeared from this frame + continue + else: + self.setHistoryKnowledge(ID, cca_df_i) + if relID in IDs: + cca_df_i.loc[relID] = relID_cca + self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) + + + # Correct past frames + for i in range(posData.frame_i-1, -1, -1): + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + self.storeUndoRedoCca(i, cca_df_i, undoId) + IDs = cca_df_i.index + if ID not in IDs: + # we reached frame where ID was not existing yet + break + else: + relID = cca_df_i.at[ID, 'relative_ID'] + self.setHistoryKnowledge(ID, cca_df_i) + if relID in IDs: + cca_df_i.loc[relID] = relID_cca + self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) + + self.enqAutosave() + + def annotateWillDivide(self, ID, relID, frame_i=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + # Store in the past frames that division has been annotated + for past_frame_i in range(frame_i-1, -1, -1): + past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True) + if past_cca_df is None: + return + + if ID not in past_cca_df.index: + # ID is a bud and is not emerged yet here + return + + if frame_i-1 == past_frame_i: + # Get generation number at first iteration + gen_num = past_cca_df.at[ID, 'generation_num'] + + if past_cca_df.at[ID, 'generation_num'] != gen_num: + # ID is a mother and the cell cycle is finished here + return + + past_cca_df.at[ID, 'will_divide'] = 1 + past_cca_df.at[relID, 'will_divide'] = 1 + + self.store_cca_df( + cca_df=past_cca_df, frame_i=past_frame_i, autosave=False + ) + + def annotateDivisionFutureFramesSwapMothers( + self, cca_df_at_future_division, mothIDofDisappearedBud, frame_i + ): + """This method is called as part of `guiWin.swapMothers`. + + It annotates cell division and propagates that to future frames to the + mother cell that stops having the correct bud because division between + wrong bud and other wrong mother was annotated in the future. + + Parameters + ---------- + cca_df_at_future_division : pd.DataFrame + _description_ + mothIDofDisappearedBud : int + Mother ID of the disappeared bud + frame_i : int + Frame since when the mother ID stops having the correct bud because + the correct bud was assigned as divided from the wrong mother + """ + posData = self.data[self.pos_i] + + relativeIDofMothID = cca_df_at_future_division.at[ + mothIDofDisappearedBud, 'relative_ID' + ] + if relativeIDofMothID not in cca_df_at_future_division.index: + # Also wrong bud ID disappeared + return + + relativeIDofMothIDrelationship = cca_df_at_future_division.at[ + relativeIDofMothID, 'relationship' + ] + if relativeIDofMothIDrelationship != 'bud': + # The wrong bud ID is a cell in G1 from future cycle --> + # the actual wrong bud ID disappeared too. + return + + wrongBudID = relativeIDofMothID + + self.annotateDivision( + cca_df_at_future_division, mothIDofDisappearedBud, wrongBudID, + frame_i=frame_i + ) + cca_df_at_future_division.at[ + mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i + self.store_cca_df( + frame_i=frame_i, cca_df=cca_df_at_future_division, autosave=False + ) + + ccaStatusToRestore = cca_df_at_future_division.loc[mothIDofDisappearedBud] + for future_i in range(frame_i+1, posData.SizeT): + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + ccs = cca_df_i.at[mothIDofDisappearedBud, 'cell_cycle_stage'] + if ccs == 'G1': + # Mother cell in G1 again, stop correcting + break + + cca_df_i.loc[mothIDofDisappearedBud] = ccaStatusToRestore + cca_df_i.at[mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i + + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) + + def annotateDivision(self, cca_df, ID, relID, frame_i=None): + # Correct as follows: + # For frame_i > 0 --> assign to G1 and +1 on generation number + # For frame == 0 --> reinitialize to unknown cells + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + self.annotateWillDivide(ID, relID) + + store = False + cca_df.at[ID, 'cell_cycle_stage'] = 'G1' + cca_df.at[relID, 'cell_cycle_stage'] = 'G1' + + if frame_i > 0: + gen_num_clickedID = cca_df.at[ID, 'generation_num'] + cca_df.at[ID, 'generation_num'] += 1 + cca_df.at[ID, 'division_frame_i'] = frame_i + gen_num_relID = cca_df.at[relID, 'generation_num'] + cca_df.at[relID, 'generation_num'] = gen_num_relID+1 + cca_df.at[relID, 'division_frame_i'] = frame_i + if gen_num_clickedID < gen_num_relID: + cca_df.at[ID, 'relationship'] = 'mother' + else: + cca_df.at[relID, 'relationship'] = 'mother' + else: + cca_df.at[ID, 'generation_num'] = 2 + cca_df.at[relID, 'generation_num'] = 2 + + cca_df.at[ID, 'division_frame_i'] = -1 + cca_df.at[relID, 'division_frame_i'] = -1 + + cca_df.at[ID, 'relationship'] = 'mother' + cca_df.at[relID, 'relationship'] = 'mother' + + store = True + return store + + def undoDivisionAnnotation(self, cca_df, ID, relID): + # Correct as follows: + # If G1 then correct to S and -1 on generation number + store = False + cca_df.at[ID, 'cell_cycle_stage'] = 'S' + gen_num_clickedID = cca_df.at[ID, 'generation_num'] + cca_df.at[ID, 'generation_num'] -= 1 + cca_df.at[ID, 'division_frame_i'] = -1 + cca_df.at[relID, 'cell_cycle_stage'] = 'S' + gen_num_relID = cca_df.at[relID, 'generation_num'] + cca_df.at[relID, 'generation_num'] -= 1 + cca_df.at[relID, 'division_frame_i'] = -1 + if gen_num_clickedID < gen_num_relID: + cca_df.at[ID, 'relationship'] = 'bud' + else: + cca_df.at[relID, 'relationship'] = 'bud' + cca_df.at[ID, 'will_divide'] = 0 + cca_df.at[relID, 'will_divide'] = 0 + store = True + return store + + def undoBudMothAssignment(self, ID): + posData = self.data[self.pos_i] + relID = posData.cca_df.at[ID, 'relative_ID'] + ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] + if ccs == 'G1': + return + posData.cca_df.at[ID, 'relative_ID'] = -1 + posData.cca_df.at[ID, 'generation_num'] = 2 + posData.cca_df.at[ID, 'cell_cycle_stage'] = 'G1' + posData.cca_df.at[ID, 'relationship'] = 'mother' + if relID in posData.cca_df.index: + posData.cca_df.at[relID, 'relative_ID'] = -1 + posData.cca_df.at[relID, 'generation_num'] = 2 + posData.cca_df.at[relID, 'cell_cycle_stage'] = 'G1' + posData.cca_df.at[relID, 'relationship'] = 'mother' + + obj_idx = posData.IDs.index(ID) + relObj_idx = posData.IDs.index(relID) + rp_ID = posData.rp[obj_idx] + rp_relID = posData.rp[relObj_idx] + + self.store_cca_df() + + # Update cell cycle info LabelItems + self.setAllTextAnnotations() + + if self.ccaTableWin is not None: + zoomIDs = self.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + @exception_handler + def manualCellCycleAnnotation(self, ID): + """ + This function is used for both annotating division or undoing the + annotation. It can be called on any frame. + + If we annotate division (right click on a cell in S) then it will + check if there are future frames to correct. + Frames to correct are those frames where both the mother and the bud + are annotated as S phase cells. + In this case we assign all those frames to G1, relationship to mother, + and +1 generation number + + If we undo the annotation (right click on a cell in G1) then it will + correct both past and future annotated frames (if present). + Frames to correct are those frames where both the mother and the bud + are annotated as G1 phase cells. + In this case we assign all those frames to G1, relationship back to + bud, and -1 generation number + """ + posData = self.data[self.pos_i] + + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + # Correct current frame + clicked_ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] + relID = posData.cca_df.at[ID, 'relative_ID'] + + if relID not in posData.IDs: + return + + if clicked_ccs == 'G1' and posData.frame_i == 0: + # We do not allow undoing division annotation on first frame + return + + if clicked_ccs == 'G1': + issue_frame_i = self.checkDivisionCanBeUndone(ID, relID) + if issue_frame_i is not None: + _warnings.warnDivisionAnnotationCannotBeUndone( + ID, relID, issue_frame_i, qparent=self + ) + return + + if clicked_ccs == 'S': + self.annotateDivision(posData.cca_df, ID, relID) + self.store_cca_df() + else: + self.undoDivisionAnnotation(posData.cca_df, ID, relID) + self.store_cca_df() + + # Update cell cycle info LabelItems + self.ax1_newMothBudLinesItem.setData([], []) + self.ax1_oldMothBudLinesItem.setData([], []) + self.ax2_newMothBudLinesItem.setData([], []) + self.ax2_oldMothBudLinesItem.setData([], []) + self.drawAllMothBudLines() + self.setAllTextAnnotations() + + if self.ccaTableWin is not None: + zoomIDs = self.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + # Correct future frames + for future_i in range(posData.frame_i+1, posData.SizeT): + cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + self.storeUndoRedoCca(future_i, cca_df_i, undoId) + IDs = cca_df_i.index + if ID not in IDs: + # For some reason ID disappeared from this frame + continue + + ccs = cca_df_i.at[ID, 'cell_cycle_stage'] + relID = cca_df_i.at[ID, 'relative_ID'] + if clicked_ccs == 'S': + if ccs == 'G1': + # Cell is in G1 in the future again so stop annotating + break + self.annotateDivision(cca_df_i, ID, relID) + self.store_cca_df( + frame_i=future_i, cca_df=cca_df_i, autosave=False + ) + elif ccs == 'S': + # Cell is in S in the future again so stop undoing (break) + # also leave a 1 frame duration G1 to avoid a continuous + # S phase + self.annotateDivision(cca_df_i, ID, relID) + self.store_cca_df( + frame_i=future_i, cca_df=cca_df_i, autosave=False + ) + break + else: + self.undoDivisionAnnotation(cca_df_i, ID, relID) + self.store_cca_df( + frame_i=future_i, cca_df=cca_df_i, autosave=False + ) + + # Correct past frames + for past_i in range(posData.frame_i-1, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + if ID not in cca_df_i.index or relID not in cca_df_i.index: + # Bud did not exist at frame_i = i + break + + self.storeUndoRedoCca(past_i, cca_df_i, undoId) + ccs = cca_df_i.at[ID, 'cell_cycle_stage'] + relID = cca_df_i.at[ID, 'relative_ID'] + if ccs == 'S': + # We correct only those frames in which the ID was in 'G1' + break + else: + store = self.undoDivisionAnnotation(cca_df_i, ID, relID) + self.store_cca_df( + frame_i=past_i, cca_df=cca_df_i, autosave=False + ) + + self.enqAutosave() + + def warnMotherNotEligible(self, new_mothID, budID, i, why): + if why == 'not_G1_in_the_future': + err_msg = html_utils.paragraph(f""" + The requested cell in G1 (ID={new_mothID}) + at future frame {i+1} has a bud assigned to it, + therefore it cannot be assigned as the mother + of bud ID {budID}.

+ You can assign a cell as the mother of bud ID {budID} + only if this cell is in G1 for the + entire life of the bud.

+ One possible solution is to click on "cancel", go to + frame {i+1} and assign the bud of cell {new_mothID} + to another cell.\n' + A second solution is to assign bud ID {budID} to cell + {new_mothID} anyway by clicking "Apply".

+ However to ensure correctness of + future assignments Cell-ACDC will delete any cell cycle + information from frame {i+1} to the end. Therefore, you + will have to visit those frames again.

+ The deletion of cell cycle information + CANNOT BE UNDONE! + Saved data is not changed of course.

+ Apply assignment or cancel process? + """) + applyButton = widgets.okPushButton(isDefault=False) + applyButton.setText('Apply and remove future annotations') + msg = widgets.myMessageBox() + _, applyButton = msg.warning( + self, 'Cell not eligible', err_msg, + buttonsTexts=('Cancel', applyButton) + ) + cancel = msg.cancel + apply = msg.clickedButton == applyButton + elif why == 'not_G1_in_the_past': + err_msg = html_utils.paragraph(f""" + The requested cell in G1 + (ID={new_mothID}) at past frame {i+1} + has a bud assigned to it, therefore it cannot be + assigned as mother of bud ID {budID}.
+ You can assign a cell as the mother of bud ID {budID} + only if this cell is in G1 for the entire life of the bud.
+ One possible solution is to first go to frame {i+1} and + assign the bud of cell {new_mothID} to another cell. + """) + msg = widgets.myMessageBox() + msg.warning( + self, 'Cell not eligible', err_msg + ) + cancel = msg.cancel + apply = False + elif why == 'single_frame_G1_duration': + err_msg = html_utils.paragraph(f""" + Assigning bud ID {budID} to cell ID {new_mothID} would result + in no G1 phase at all between previous cell cycle and + current cell cycle (see frame n. {i+1}).

+ + The solution is to annotate division on cell ID {new_mothID} + on any frame before the frame number {i+1}, and then + proceed to correcting the bud assignment.

+ + This will gurantee a G1 duration for the cell {new_mothID} + of at least 1 frame.

+ Thank you for your patience! + """) + msg = widgets.myMessageBox() + msg.warning( + self, 'Cell not eligible', err_msg + ) + cancel = msg.cancel + apply = False + return cancel, apply + + def warnSettingHistoryKnownCellsFirstFrame(self, ID): + txt = html_utils.paragraph(f""" + Cell ID {ID} is a cell that is present since the first + frame.

+ These cells already have history UNKNOWN assigned and the + history status cannot be changed. + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning( + self, 'First frame cells', txt + ) + + def checkMothEligibility(self, budID, new_mothID): + """ + Check that the new mother is in G1 for the entire life of the bud + and that the G1 duration is > than 1 frame + """ + last_cca_frame_i = self.navigateScrollBar.maximum()-1 + posData = self.data[self.pos_i] + eligible = True + + # Check future frames + G1_duration_future = 0 + for future_i in range(posData.frame_i, posData.SizeT): + cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) + + if cca_df_i is None: + # ith frame was not visited yet + break + + if budID not in cca_df_i.index: + # Bud disappeared + break + + is_still_bud = cca_df_i.at[budID, 'relationship'] == 'bud' + if not is_still_bud: + break + + ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] + if ccs != 'G1': + cancel, apply = self.warnMotherNotEligible( + new_mothID, budID, future_i, 'not_G1_in_the_future' + ) + if apply: + self.resetCcaFuture(future_i) + break + isG1singleFrame = G1_duration_future == 1 + isFutureFrameNotLastAnnot = future_i != last_cca_frame_i + if cancel or (isG1singleFrame and isFutureFrameNotLastAnnot): + eligible = False + return eligible + + G1_duration_future += 1 + + # Check past frames + for past_i in range(posData.frame_i-1, -1, -1): + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + + is_bud_existing = budID in cca_df_i.index + is_moth_existing = new_mothID in cca_df_i.index + + if not is_moth_existing: + # Mother not existing because it appeared from outside FOV + break + + ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] + if ccs != 'G1' and is_bud_existing: + # Requested mother not in G1 in the past + # during the life of the bud (is_bud_existing = True) + self.warnMotherNotEligible( + new_mothID, budID, past_i, 'not_G1_in_the_past' + ) + eligible = False + return eligible + + if not is_bud_existing: + # Bud stop existing --> check that mother is still in G1 + if ccs != 'G1': + eligible = False + self.warnMotherNotEligible( + new_mothID, budID, past_i, 'single_frame_G1_duration' + ) + break + + return eligible + + def checkMothersExcludedOrDead(self): + try: + posData = self.data[self.pos_i] + buds_df = posData.cca_df[ + (posData.cca_df.relationship == 'bud') + & (posData.cca_df.emerg_frame_i == posData.frame_i) + ] + acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] + moth_df = acdc_df_i.loc[buds_df.relative_ID.to_list()] + excluded_df = moth_df[ + (moth_df.is_cell_dead > 0) | (moth_df.is_cell_excluded > 0) + ] + excludedMothIDs = excluded_df.index.to_list() + if not excludedMothIDs: + self.stopBlinkingPairItem() + return True + budIDsOfExcludedMoth = excluded_df.relative_ID.to_list() + proceed = self.warnDeadOrExcludedMothers( + budIDsOfExcludedMoth, excludedMothIDs + ) + return proceed + except Exception as e: + self.logger.info(traceback.format_exc()) + print('-'*100) + self.logger.warning( + 'Checking if mother cell is excluded or dead failed.' + ) + print('^'*100) + return False + + def checkDivisionCanBeUndone(self, ID, relID): + """Check that division annotation can be undone (see Notes section) + + Parameters + ---------- + ID : int + Cell ID of the clicked cell in G1 + relID : _type_ + Relative ID of the cell that was clicked + + Notes + ----- + Division annotation can be undone only if `relID` is also in G1 for the + entire duration of the correction + """ + posData = self.data[self.pos_i] + + ccs_relID = posData.cca_df.at[relID, 'cell_cycle_stage'] + if ccs_relID == 'S': + return posData.frame_i + + # Check future frames + for future_i in range(posData.frame_i+1, posData.SizeT): + cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + ccs_relID = cca_df_i.at[relID, 'cell_cycle_stage'] + if ccs_relID == 'S': + return future_i + + # Check past frames + for past_i in range(posData.frame_i-1, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + if ID not in cca_df_i.index or relID not in cca_df_i.index: + # Bud did not exist at frame_i = i + break + + ccs = cca_df_i.at[ID, 'cell_cycle_stage'] + if ccs == 'S': + break + + ccs_relID = cca_df_i.at[relID, 'cell_cycle_stage'] + if ccs_relID == 'S': + return future_i + + + def stopBlinkingPairItem(self): + self.ax1_newMothBudLinesItem.setOpacity(1.0) + self.ax1_oldMothBudLinesItem.setOpacity(1.0) + + self.warnPairingItem.setData([], []) + try: + self.blinkPairingItemTimer.stop() + except Exception as e: + pass + + def warnDeadOrExcludedMothers(self, budIDs, mothIDs): + self.startBlinkingPairingItem(budIDs, mothIDs) + msg = widgets.myMessageBox(wrapText=False) + pairings = [ + f'Mother ID {mID} --> bud ID {bID}' + for mID, bID in zip(mothIDs, budIDs) + ] + txt = html_utils.paragraph(f""" + The mother cell in the following mother-bud pairings + (blinking line on the image) is
+ excluded from the analysis or dead: + {html_utils.to_list(pairings)} + """) + msg.warning( + self, 'Mother cell is excluded or dead', txt, + buttonsTexts=('Cancel', 'Ok') + ) + return not msg.cancel + + def startBlinkingPairingItem(self, budIDs, mothIDs): + self.ax1_newMothBudLinesItem.setOpacity(0.2) + self.ax1_oldMothBudLinesItem.setOpacity(0.2) + + posData = self.data[self.pos_i] + acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] + + # Blink one pairing at the time (the first found) + xc_b = acdc_df_i.loc[budIDs[0], 'x_centroid'] + yc_b = acdc_df_i.loc[budIDs[0], 'y_centroid'] + + xc_m = acdc_df_i.loc[mothIDs[0], 'x_centroid'] + yc_m = acdc_df_i.loc[mothIDs[0], 'y_centroid'] + + self.warnPairingItem.setData([xc_b, xc_m], [yc_b, yc_m]) + + self.blinkPairingItemTimer = QTimer() + self.blinkPairingItemTimer.flag = True + self.blinkPairingItemTimer.timeout.connect(self.blinkPairingItem) + self.blinkPairingItemTimer.start(300) + + def blinkPairingItem(self): + if self.blinkPairingItemTimer.flag: + opacity = 0.3 + self.blinkPairingItemTimer.flag = False + else: + opacity = 1.0 + self.blinkPairingItemTimer.flag = True + self.warnPairingItem.setOpacity(opacity) + + def getStatus_RelID_BeforeEmergence(self, budID, curr_mothID): + posData = self.data[self.pos_i] + # Get status of the current mother before it had budID assigned to it + cca_status_before_bud_emerg = None + for i in range(posData.frame_i-1, -1, -1): + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + + is_bud_existing = budID in cca_df_i.index + if not is_bud_existing: + # Bud was not emerged yet + if curr_mothID in cca_df_i.index: + cca_status_before_bud_emerg = cca_df_i.loc[curr_mothID] + return cca_status_before_bud_emerg + else: + # The bud emerged together with the mother because + # they appeared together from outside of the fov + # and they were trated as new IDs bud in S0 + bud_cca_dict = base_cca_dict.copy() + bud_cca_dict['cell_cycle_stage'] = 'S' + bud_cca_dict['generation_num'] = 0 + bud_cca_dict['relationship'] = 'bud' + bud_cca_dict['emerg_frame_i'] = i+1 + bud_cca_dict['is_history_known'] = True + cca_status_before_bud_emerg = pd.Series(bud_cca_dict) + return cca_status_before_bud_emerg + + # Mother did not have a status before bud emergence because it was + # already paired with bud at first frame --> reinit to default + cca_status_before_bud_emerg = ( + core.getBaseCca_df([curr_mothID]).loc[curr_mothID] + ) + return cca_status_before_bud_emerg + + + def annotateBudToDifferentMother(self): + """ + This function is used for correcting automatic mother-bud assignment. + + It can be called at any frame of the bud life. + + There are three cells involved: bud, current mother, new mother. + + Eligibility: + - User clicked first on a bud (checked at click time) + - User released mouse button on a cell in G1 (checked at release time) + - The new mother MUST be in G1 for all the frames of the bud life + --> if not warn + - The new mother MUST have appeared in current frame OR be already + in G1 in previous frame, otherwise there would be no G1 cycle + + Result: + - The bud only changes relative ID to the new mother + - The new mother changes relative ID and stage to 'S' + - The old mother changes its entire status to the status it had + before being assigned to the clicked bud + """ + posData = self.data[self.pos_i] + lab2D = self.get_2Dlab(posData.lab) + budID = lab2D[self.yClickBud, self.xClickBud] + new_mothID = lab2D[self.yClickMoth, self.xClickMoth] + + if budID == new_mothID: + return + + if not self.isSnapshot: + eligible = self.checkMothEligibility(budID, new_mothID) + if not eligible: + return + + budEligible = self.checkChangeMotherBudEligible( + budID, posData.frame_i + ) + if not budEligible: + return + + # Allow partial initialization of cca_df with mouse + if posData.frame_i == 0: + newMothCcs = posData.cca_df.at[new_mothID, 'cell_cycle_stage'] + if not newMothCcs == 'G1': + err_msg = ( + 'You are assigning the bud to a cell that is not in G1!' + ) + msg = QMessageBox() + msg.critical( + self, 'New mother not in G1!', err_msg, msg.Ok + ) + return + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(0, posData.cca_df, undoId) + currentRelID = posData.cca_df.at[budID, 'relative_ID'] + if currentRelID in posData.cca_df.index: + posData.cca_df.at[currentRelID, 'relative_ID'] = -1 + posData.cca_df.at[currentRelID, 'generation_num'] = 2 + posData.cca_df.at[currentRelID, 'cell_cycle_stage'] = 'G1' + posData.cca_df.at[budID, 'relationship'] = 'bud' + posData.cca_df.at[budID, 'generation_num'] = 0 + posData.cca_df.at[budID, 'relative_ID'] = new_mothID + posData.cca_df.at[budID, 'cell_cycle_stage'] = 'S' + posData.cca_df.at[new_mothID, 'relative_ID'] = budID + posData.cca_df.at[new_mothID, 'generation_num'] = 2 + posData.cca_df.at[new_mothID, 'cell_cycle_stage'] = 'S' + self.updateAllImages() + self.store_cca_df() + return + + curr_mothID = posData.cca_df.at[budID, 'relative_ID'] + if curr_mothID in posData.cca_df.index: + curr_moth_cca = self.getStatus_RelID_BeforeEmergence( + budID, curr_mothID + ) + + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + # Correct current frames and update LabelItems + posData.cca_df.at[budID, 'relative_ID'] = new_mothID + posData.cca_df.at[budID, 'generation_num'] = 0 + posData.cca_df.at[budID, 'relative_ID'] = new_mothID + posData.cca_df.at[budID, 'relationship'] = 'bud' + posData.cca_df.at[budID, 'corrected_on_frame_i'] = posData.frame_i + posData.cca_df.at[budID, 'cell_cycle_stage'] = 'S' + + posData.cca_df.at[new_mothID, 'relative_ID'] = budID + posData.cca_df.at[new_mothID, 'cell_cycle_stage'] = 'S' + posData.cca_df.at[new_mothID, 'relationship'] = 'mother' + + + if curr_mothID in posData.cca_df.index: + # Cells with UNKNOWN history has relative's ID = -1 + # which is not an existing cell + posData.cca_df.loc[curr_mothID] = curr_moth_cca + + self.updateAllImages() + + # self.checkMultiBudMoth(draw=True) + self.store_cca_df() + proceed = self.checkMothersExcludedOrDead() + if not proceed: + # User clicked on cancel in the message box + self.UndoCca() + return + + if self.ccaTableWin is not None: + zoomIDs = self.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + # Correct future frames + for i in range(posData.frame_i+1, posData.SizeT): + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + IDs = cca_df_i.index + if budID not in IDs or new_mothID not in IDs: + # For some reason ID disappeared from this frame + continue + + self.storeUndoRedoCca(i, cca_df_i, undoId) + bud_relationship = cca_df_i.at[budID, 'relationship'] + bud_ccs = cca_df_i.at[budID, 'cell_cycle_stage'] + + if bud_relationship == 'mother' and bud_ccs == 'S': + # The bud at the ith frame budded itself --> stop + break + + cca_df_i.at[budID, 'relative_ID'] = new_mothID + cca_df_i.at[budID, 'generation_num'] = 0 + cca_df_i.at[budID, 'relative_ID'] = new_mothID + cca_df_i.at[budID, 'relationship'] = 'bud' + cca_df_i.at[budID, 'cell_cycle_stage'] = 'S' + + newMoth_bud_ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] + if newMoth_bud_ccs == 'G1': + # Assign bud to new mother only if the new mother is in G1 + # This can happen if the bud already has a G1 annotated + cca_df_i.at[new_mothID, 'relative_ID'] = budID + cca_df_i.at[new_mothID, 'cell_cycle_stage'] = 'S' + cca_df_i.at[new_mothID, 'relationship'] = 'mother' + + if curr_mothID in cca_df_i.index: + # Cells with UNKNOWN history has relative's ID = -1 + # which is not an existing cell + cca_df_i.loc[curr_mothID] = curr_moth_cca + + self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) + + # Correct past frames + for i in range(posData.frame_i-1, -1, -1): + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + + is_bud_existing = budID in cca_df_i.index + if not is_bud_existing: + # Bud was not emerged yet + break + + self.storeUndoRedoCca(i, cca_df_i, undoId) + cca_df_i.at[budID, 'relative_ID'] = new_mothID + cca_df_i.at[budID, 'generation_num'] = 0 + cca_df_i.at[budID, 'relative_ID'] = new_mothID + cca_df_i.at[budID, 'relationship'] = 'bud' + cca_df_i.at[budID, 'cell_cycle_stage'] = 'S' + + cca_df_i.at[new_mothID, 'relative_ID'] = budID + cca_df_i.at[new_mothID, 'cell_cycle_stage'] = 'S' + cca_df_i.at[new_mothID, 'relationship'] = 'mother' + + if curr_mothID in cca_df_i.index: + # Cells with UNKNOWN history has relative's ID = -1 + # which is not an existing cell + cca_df_i.loc[curr_mothID] = curr_moth_cca + + self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) + + self.enqAutosave() + + def onMotherNotInG1(self, mothID): + txt = html_utils.paragraph( + f'You clicked on ID={mothID} which is NOT in G1

' + 'Do you want to proceed with swapping the mother cells?

' + 'NOTE: To assign a bud start by clicking on the bud ' + 'and release on a cell in G1' + ) + msg = widgets.myMessageBox() + swapMothersButton = widgets.reloadPushButton('Swap mother cells') + _, swapMothersButton = msg.warning( + self, 'Released on a cell NOT in G1', txt, + buttonsTexts=('Cancel', swapMothersButton) + ) + if msg.cancel: + return + + pairings = self.checkSwapMothersEligibility() + if pairings is None: + self.logger.info('Swapping mothers is not possible.') + return + + self.swapMothers(*pairings) + + def _checkBudFutureNoDivision(self, budID, start_frame_i): + posData = self.data[self.pos_i] + + future_i = start_frame_i + for future_i in range(start_frame_i, posData.SizeT): + if future_i == 0: + continue + + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + return + + if budID not in cca_df_i.index: + # Bud disappears in the future --> fine + return + + ccs = cca_df_i.at[budID, 'cell_cycle_stage'] + if ccs == 'G1': + return future_i, cca_df_i.at[budID, 'relative_ID'] + + def warnBudAnnotatedDividedInFuture( + self, budID, motherID, future_division_frame_i, + action='swap mother cells' + ): + posData = self.data[self.pos_i] + + txt = html_utils.paragraph(f""" + Bud ID {budID} is annotated as divided from mother ID {motherID} + at frame n. {future_division_frame_i+1},
+ therefore it is not possible to {action}.

+ We recommend reinitializing cell cycle annotations on any + frame
between frames number {posData.frame_i+1} and + {future_division_frame_i} before attempting to {action}.

+ Thank you for your patience! + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, f'{action} not possible'.title(), txt) + return + + def _checkMothInG1beforeBudEmergence( + self, motherID, budID, wrongBudID, start_frame_i + ): + """Check that mother is in G1 on the frame before bud emergence + + Parameters + ---------- + motherID : int + ID of mother cell + budID : int + ID of bud + start_frame_i : int + Frame index from which to start checking in the past + """ + for past_i in range(start_frame_i, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + if budID not in cca_df_i.index: + if cca_df_i.at[motherID, 'cell_cycle_stage'] == 'G1': + return + + budID_prev_cycle = cca_df_i.at[motherID, 'relative_ID'] + if budID_prev_cycle != wrongBudID: + return past_i + 1 + + break + + def warnMotherNotAtLeastOneFrameG1(self, budID, motherID, frame_no_G1): + posData = self.data[self.pos_i] + + txt = html_utils.paragraph(f""" + Assigning bud ID {budID} to cell ID {motherID} cannot be + done because cell ID {motherID} is not in G1 at frame n. + {frame_no_G1}.

+ This would result in no G1 phase between previous cell cycle of + cell ID {motherID} and current one. + This is unfortunately not allowed.

+ One possible solution is to annotate division on cell ID + {motherID} on any frame before frame n. {frame_no_G1}.

+ Thank you for your patience! + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, 'Swap mothers not possible', txt) + return + + def checkChangeMotherBudEligible(self, budID, frame_i): + result = self._checkBudFutureNoDivision(budID, frame_i) + if result is None: + return True + + self.warnBudAnnotatedDividedInFuture( + budID, *result, action='change mother cell' + ) + return False + + def checkSwapMothersEligibility(self): + posData = self.data[self.pos_i] + + lab2D = self.get_2Dlab(posData.lab) + budID = lab2D[self.yClickBud, self.xClickBud] + otherMothID = lab2D[self.yClickMoth, self.xClickMoth] + mothID = posData.cca_df.at[budID, 'relative_ID'] + otherBudID = posData.cca_df.at[otherMothID, 'relative_ID'] + + for _budID in (budID, otherBudID): + result = self._checkBudFutureNoDivision( + _budID, posData.frame_i + ) + if result is None: + continue + + self.warnBudAnnotatedDividedInFuture(_budID, *result) + return + + correct_pairings = { + otherBudID: mothID, budID: otherMothID + } + wrong_pairings = { + mothID: budID, otherMothID: otherBudID + } + for correctBudID, correctMothID in correct_pairings.items(): + wrongBudID = wrong_pairings[correctMothID] + frame_no_G1 = self._checkMothInG1beforeBudEmergence( + correctMothID, correctBudID, wrongBudID, posData.frame_i + ) + if frame_no_G1 is None: + continue + + self.warnMotherNotAtLeastOneFrameG1( + correctBudID, correctMothID, frame_no_G1 + ) + return + + return budID, otherBudID, otherMothID, mothID + + @exception_handler + def swapMothers(self, budID, otherBudID, otherMothID, mothID): + posData = self.data[self.pos_i] + + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + self.logger.info( + f'Swapping assignments (requested at frame n. {posData.frame_i+1}):\n' + f' * Bud ID {budID} --> mother ID {otherMothID}\n' + f' * Bud ID {otherBudID} --> mother ID {mothID}' + ) + + correct_pairings = { + otherBudID: mothID, + budID: otherMothID + } + + for correct_budID, correct_mothID in correct_pairings.items(): + posData.cca_df.at[correct_budID, 'relative_ID'] = correct_mothID + posData.cca_df.at[correct_mothID, 'relative_ID'] = correct_budID + posData.cca_df.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i + posData.cca_df.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i + self.store_cca_df() + + # Correct past frames + corrected_budIDs_past = set() + for past_i in range(posData.frame_i-1, -1, -1): + if len(corrected_budIDs_past) == 2: + break + + for correct_budID, correct_mothID in correct_pairings.items(): + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + + if correct_budID in corrected_budIDs_past: + continue + + if correct_budID not in cca_df_i.index: + # Bud does not exist anymore in the past + corrected_budIDs_past.add(correct_budID) + + if len(corrected_budIDs_past) < 2: + self.restoreMotherToBeforeWrongBudWasAssignedToIt( + correct_mothID, cca_df_i, past_i + ) + continue + + cca_df_i.at[correct_budID, 'relative_ID'] = correct_mothID + cca_df_i.at[correct_mothID, 'relative_ID'] = correct_budID + cca_df_i.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i + cca_df_i.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i + + # Set mother cell cycle stage to S in case it is not + if cca_df_i.at[correct_mothID, 'cell_cycle_stage'] == 'G1': + cca_df_i.at[correct_mothID, 'cell_cycle_stage'] = 'S' + # cca_df_i.at[correct_mothID, 'generation_num'] -= 1 + + self.store_cca_df( + frame_i=past_i, cca_df=cca_df_i, autosave=False + ) + + # Correct future frames + corrected_budIDs_future = set() + for future_i in range(posData.frame_i+1, posData.SizeT): + if len(corrected_budIDs_future) == 2: + break + + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + for correct_budID, correct_mothID in correct_pairings.items(): + if correct_budID in corrected_budIDs_future: + # Bud already corrected in the future + continue + + if correct_budID not in cca_df_i.index: + # Bud disappeared in the future + corrected_budIDs_future.add(correct_budID) + continue + + ccs_bud = cca_df_i.at[correct_budID, 'cell_cycle_stage'] + if ccs_bud == 'G1': + # Bud divided in the future, annotate division between + # correct mother and wrong bud and then stop correcting + if correct_budID not in corrected_budIDs_future: + corrected_budIDs_future.add(correct_budID) + + if len(corrected_budIDs_future) < 2: + self.annotateDivisionFutureFramesSwapMothers( + cca_df_i, correct_mothID, future_i + ) + continue + + cca_df_i.at[correct_budID, 'relative_ID'] = correct_mothID + cca_df_i.at[correct_mothID, 'relative_ID'] = correct_budID + cca_df_i.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i + cca_df_i.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i + + # Set mother cell cycle stage to S in case it is not + if cca_df_i.at[correct_mothID, 'cell_cycle_stage'] == 'G1': + cca_df_i.at[correct_mothID, 'cell_cycle_stage'] = 'S' + # cca_df_i.at[correct_mothID, 'generation_num'] -= 1 + + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) + + self.updateAllImages() + + def restoreMotherToBeforeWrongBudWasAssignedToIt( + self, mothIDofDisappearedBud, + cca_df_at_correct_bud_ID_disappearance, + frame_i + ): + """This method is called as part of `guiWin.swapMothers`. + + Parameters + ---------- + mothIDofDisappearedBud : int + Mother ID of the disappeared bud + cca_df_at_correct_bud_ID_disappearance : pd.DataFrame + Cell cycle annotations DataFrame when the correct bud ID stopped + existing (before emergence) + frame_i : int + Frame index when the correct bud ID stopped existing + (before emergence) + + Note + ---- + It restores the mother cell cycle annotations to the status it had + before the wrong bud was assigned to it. + + We need to do it only if the swapMothers past frames loop is still + iterating to correct the other bud. + + We also need to do this only if the wrong bud ID is actually a bud. + + When we swap mothers in the past frames it can be that the correct bud + ID stops existing (before emergence). In this case the correct mother + still has the wrong bud assigned to ID so we need to restore the status + it had before the wrong bud was assigned to it. + + To determine the status we go back until the wrong bud disappear. That + is the frame before the wrong bud was assigned to the mother we want to + correct. This is the status we want to restore. + + When we go back in time it could be that the wrong bud never disappears + becuase it is already emerged at frame 0. In this case the status we + want to restore at is the default G1 status at frame 0. + """ + relativeIDofMothID = cca_df_at_correct_bud_ID_disappearance.at[ + mothIDofDisappearedBud, 'relative_ID' + ] + if relativeIDofMothID not in cca_df_at_correct_bud_ID_disappearance.index: + # Also wrong bud ID disappeared + return + + relativeIDofMothIDrelationship = cca_df_at_correct_bud_ID_disappearance.at[ + relativeIDofMothID, 'relationship' + ] + if relativeIDofMothIDrelationship != 'bud': + # The wrong bud ID is a cell in G1 from previous cycle --> + # the actual wrong bud ID disappeared too. + return + + wrongBudID = relativeIDofMothID + + mothCcaBeforeWrongBudID = base_cca_dict + # Search in the past for status of mother before wrong bud emerged + for past_i in range(frame_i, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + if wrongBudID not in cca_df_i.index: + mothCcaBeforeWrongBudID = cca_df_i.loc[mothIDofDisappearedBud] + break + + # Restore in past frames the correct mother status + for past_i in range(frame_i, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + if wrongBudID in cca_df_i.index: + cca_df_i.loc[mothIDofDisappearedBud] = mothCcaBeforeWrongBudID + cca_df_i.at[mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i + self.store_cca_df( + frame_i=past_i, cca_df=cca_df_i, autosave=False + ) + else: + break + + def getClosedSplineCoords(self): + xxS, yyS = self.curvPlotItem.getData() + bbox_area = (xxS.max()-xxS.min())*(yyS.max()-yyS.min()) + if bbox_area < 26_000: + # Using 1000 is fast enough according to profiling + return xxS, yyS + + optimalSpaceSize = self.splineToObjModel.predict( + bbox_area, max_exec_time=150 + ) + if optimalSpaceSize >= 1000: + # Using 1000 is fast enough according to model + return xxS, yyS + + if optimalSpaceSize < 100: + # Do not allow a rough spline + optimalSpaceSize = 100 + + # Get spline with optimal space size so that exec time + # or skimage.draw.polygon is less than 150 ms + xx, yy = self.curvAnchors.getData() + resolutionSpace = np.linspace(0, 1, int(optimalSpaceSize)) + xxS, yyS = self.getSpline( + xx, yy, resolutionSpace=resolutionSpace, per=True + ) + return xxS, yyS + + + def getSpline(self, xx, yy, resolutionSpace=None, per=False, appendFirst=False): + # Remove duplicates + valid = np.where(np.abs(np.diff(xx)) + np.abs(np.diff(yy)) > 0) + xx = np.r_[xx[valid], xx[-1]] + yy = np.r_[yy[valid], yy[-1]] + if appendFirst: + xx = np.r_[xx, xx[0]] + yy = np.r_[yy, yy[0]] + per = True + + # Interpolate splice + if resolutionSpace is None: + resolutionSpace = self.hoverLinSpace + k = 2 if len(xx) == 3 else 3 + + try: + tck, u = scipy.interpolate.splprep( + [xx, yy], s=0, k=k, per=per + ) + xi, yi = scipy.interpolate.splev(resolutionSpace, tck) + return xi, yi + except (ValueError, TypeError): + # Catch errors where we know why splprep fails + return [], [] + + def uncheckQButton(self, button): + # Manual exclusive where we allow to uncheck all buttons + for b in self.checkableQButtonsGroup.buttons(): + if b != button: + b.setChecked(False) + + def delBorderObj(self, checked): + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + posData = self.data[self.pos_i] + posData.lab = skimage.segmentation.clear_border( + posData.lab, buffer_size=1 + ) + oldIDs = posData.IDs.copy() + self.update_rp() + removedIDs = [ID for ID in oldIDs if ID not in posData.IDs] + if posData.cca_df is not None: + posData.cca_df = posData.cca_df.drop(index=removedIDs) + self.store_data() + self.updateAllImages() + + def delNewObj(self, checked): + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + posData = self.data[self.pos_i] + frame_i = posData.frame_i + + if frame_i == 0: + return + + prev_IDs = posData.allData_li[frame_i-1]['IDs'] + curr_IDs = posData.IDs + new_IDs = list(set(curr_IDs) - set(prev_IDs)) + + lab = posData.lab + del_mask = np.isin(lab, new_IDs) + lab[del_mask] = 0 + posData.lab = lab + + self.update_rp() + + if posData.cca_df is not None: + posData.cca_df = posData.cca_df.drop(index=new_IDs) + self.store_data() + self.updateAllImages() + + def brushAutoFillToggled(self, checked): + val = 'Yes' if checked else 'No' + self.df_settings.at['brushAutoFill', 'value'] = val + self.df_settings.to_csv(self.settings_csv_path) + + def brushAutoHideToggled(self, checked): + val = 'Yes' if checked else 'No' + self.df_settings.at['brushAutoHide', 'value'] = val + self.df_settings.to_csv(self.settings_csv_path) + + def brushReleased(self): + posData = self.data[self.pos_i] + self.fillHolesID(posData.brushID, sender='brush') + + # Update data (rp, etc) + self.update_rp(update_IDs=self.isNewID,) + + # Repeat tracking + if self.autoIDcheckbox.isChecked(): + self.trackManuallyAddedObject(posData.brushID, self.isNewID) + else: + self.update_rp(update_IDs=posData.brushID not in posData.IDs_idxs) + + # Update images + if self.isNewID: + editTxt = 'Add new ID with brush tool' + if self.isSnapshot: + self.fixCcaDfAfterEdit(editTxt) + self.updateAllImages() + else: + self.warnEditingWithCca_df(editTxt) + else: + self.updateAllImages() + + self.isNewID = False + + def addDelROI(self, event): + roi, key = self.createDelROI() + self.addRoiToDelRoiInfo(roi) + if not self.labelsGrad.showLabelsImgAction.isChecked(): + self.ax1.addDelRoiItem(roi, key) + else: + self.ax2.addDelRoiItem(roi, key) + self.applyDelROIimg1(roi, init=True) + self.applyDelROIimg1(roi, init=True, ax=1) + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Delete IDs using ROI') + self.updateAllImages() + else: + self.warnEditingWithCca_df( + 'Delete IDs using ROI', get_cancelled=True + ) + + def replacePolyLineRoiWithLineRoi(self, roi): + x0, y0 = roi.pos().x(), roi.pos().y() + (_, point1), (_, point2) = roi.getLocalHandlePositions() + xr1, yr1 = point1.x(), point1.y() + xr2, yr2 = point2.x(), point2.y() + x1, y1 = xr1+x0, yr1+y0 + x2, y2 = xr2+x0, yr2+x0 + lineRoi = pg.LineROI((x1, y1), (x2, y2), width=0.5) + lineRoi.handleSize = 7 + self.ax1.removeItem(self.polyLineRoi) + self.ax1.addItem(lineRoi) + lineRoi.removeHandle(2) + # Connect closed ROI + lineRoi.sigRegionChanged.connect(self.delROImoving) + lineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished) + return lineRoi + + def addRoiToDelRoiInfo(self, roi: pg.ROI): + posData = self.data[self.pos_i] + for i in range(posData.frame_i, posData.SizeT): + delROIs_info = posData.allData_li[i]['delROIs_info'] + delROIs_info['rois'].append(roi) + delROIs_info['state'].append(roi.getState()) + delROIs_info['delMasks'].append(np.zeros_like(self.currentLab2D)) + delROIs_info['delIDsROI'].append(set()) + + def addDelPolyLineRoi_cb(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.addDelPolyLineRoiButton) + self.connectLeftClickButtons() + if self.isSnapshot: + self.fixCcaDfAfterEdit('Delete IDs using ROI') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Delete IDs using ROI') + else: + self.tempSegmentON = False + self.ax1_rulerPlotItem.setData([], []) + self.ax1_rulerAnchorsItem.setData([], []) + self.startPointPolyLineItem.setData([], []) + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + def createDelPolyLineRoi(self): + Y, X = self.currentLab2D.shape + self.polyLineRoi = pg.PolyLineROI( + [], rotatable=False, + removable=True, + pen=pg.mkPen(color='r') + ) + self.polyLineRoi.handleSize = 7 + self.polyLineRoi.points = [] + key = uuid.uuid4() + self.ax1.addDelRoiItem(self.polyLineRoi, key) + + def addPointsPolyLineRoi(self, closed=False): + self.polyLineRoi.setPoints(self.polyLineRoi.points, closed=closed) + if not closed: + return + + # Connect closed ROI + self.polyLineRoi.sigRegionChanged.connect(self.delROImoving) + self.polyLineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished) + + def getViewRange(self): + Y, X = self.img1.image.shape[:2] + xRange, yRange = self.ax1.viewRange() + xmin = 0 if xRange[0] < 0 else xRange[0] + ymin = 0 if yRange[0] < 0 else yRange[0] + + xmax = X if xRange[1] >= X else xRange[1] + ymax = Y if yRange[1] >= Y else yRange[1] + return int(ymin), int(ymax), int(xmin), int(xmax) + + def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None): + posData = self.data[self.pos_i] + if xl is None: + xRange, yRange = self.ax1.viewRange() + xl = 0 if xRange[0] < 0 else xRange[0] + yb = 0 if yRange[0] < 0 else yRange[0] + Y, X = self.currentLab2D.shape + if anchors is None: + roi = widgets.DelROI( + [xl, yb], [w, h], + rotatable=False, + removable=True, + pen=pg.mkPen(color='r'), + maxBounds=QRectF(QRect(0,0,X,Y)) + ) + ## handles scaling horizontally around center + roi.addScaleHandle([1, 0.5], [0, 0.5]) + roi.addScaleHandle([0, 0.5], [1, 0.5]) + + ## handles scaling vertically from opposite edge + roi.addScaleHandle([0.5, 0], [0.5, 1]) + roi.addScaleHandle([0.5, 1], [0.5, 0]) + + ## handles scaling both vertically and horizontally + roi.addScaleHandle([1, 1], [0, 0]) + roi.addScaleHandle([0, 0], [1, 1]) + roi.addScaleHandle([0, 1], [1, 0]) + roi.addScaleHandle([1, 0], [0, 1]) + + roi.handleSize = 7 + roi.sigRegionChanged.connect(self.delROImoving) + roi.sigRegionChanged.connect(self.delROIstartedMoving) + roi.sigRegionChangeFinished.connect(self.delROImovingFinished) + + key = uuid.uuid4() + + return roi, key + + def delROIstartedMoving(self, roi): + self.clearLostObjContoursItems() + + def clearLostObjContoursItems(self): + self.ax1_lostObjScatterItem.setData([], []) + self.ax2_lostObjScatterItem.setData([], []) + + self.ax1_lostTrackedScatterItem.setData([], []) + self.ax2_lostTrackedScatterItem.setData([], []) + + self.ax2_lostObjImageItem.clear() + self.ax2_lostTrackedObjImageItem.clear() + + self.ax1_lostObjImageItem.clear() + self.ax1_lostTrackedObjImageItem.clear() + + def delROImoving(self, roi): + roi.setPen(color=(255,255,0)) + # First bring back IDs if the ROI moved away + self.restoreAnnotDelROI(roi) + self.setImageImg2() + self.applyDelROIimg1(roi) + self.applyDelROIimg1(roi, ax=1) + + def delROImovingFinished(self, roi: pg.ROI): + roi.setPen(color='r') + self.update_rp() + self.updateAllImages() + QTimer.singleShot( + 300, partial(self.updateDelROIinFutureFrames, roi) + ) + + def restoreAnnotDelROI(self, roi, enforce=True, draw=True): + posData = self.data[self.pos_i] + ROImask = self.getDelRoiMask(roi) + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + try: + idx = delROIs_info['rois'].index(roi) + except Exception as err: + return + + delMask = delROIs_info['delMasks'][idx] + delIDs = delROIs_info['delIDsROI'][idx] + overlapROIdelIDs = np.unique(delMask[ROImask]) + lab2D = self.get_2Dlab(posData.lab) + restoredIDs = set() + for ID in delIDs: + if ID in overlapROIdelIDs and not enforce: + continue + + restoredIDs.add(ID) + + delMaskID = delMask==ID + self.currentLab2D[delMaskID] = ID + lab2D[delMaskID] = ID + + if draw: + self.restoreDelROIimg1(delMaskID, ID, ax=0) + self.restoreDelROIimg1(delMaskID, ID, ax=1) + + delMask[delMaskID] = 0 + + delROIs_info['delIDsROI'][idx] = delIDs - restoredIDs + self.set_2Dlab(lab2D) + self.update_rp() + + def restoreDelROIimg1(self, delMaskID, delID, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + if how.find('nothing') != -1: + return + + if how.find('contours') != -1: + rp_delmask = skimage.measure.regionprops(delMaskID.astype(np.uint8)) + if len(rp_delmask) > 0: + obj = rp_delmask[0] + self.addObjContourToContoursImage(obj=obj, ax=ax) + elif how.find('overlay segm. masks') != -1: + if ax == 0: + self.labelsLayerImg1.setImage( + self.currentLab2D, autoLevels=False + ) + else: + self.labelsLayerRightImg.setImage( + self.currentLab2D, autoLevels=False + ) + + def getDelRoisIDs(self): + posData = self.data[self.pos_i] + if posData.frame_i > 0: + prev_lab = posData.allData_li[posData.frame_i-1]['labels'] + allDelIDs = set() + for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: + if ( + not self.ax1.isDelRoiItemPresent(roi) + and not self.ax2.isDelRoiItemPresent(roi) + ): + continue + + ROImask = self.getDelRoiMask(roi) + delIDs = posData.lab[ROImask] + allDelIDs.update(delIDs) + if posData.frame_i > 0: + delIDsPrevFrame = prev_lab[ROImask] + allDelIDs.update(delIDsPrevFrame) + return allDelIDs + + def getStoredDelRoiIDs(self, frame_i=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + allDelIDs = set() + delROIs_info = posData.allData_li[frame_i]['delROIs_info'] + delIDs_rois = delROIs_info['delIDsROI'] + for delIDs in delIDs_rois: + allDelIDs.update(delIDs) + return allDelIDs + + # @exec_time + def getDelROIlab(self, input_lab_2D=None): + posData = self.data[self.pos_i] + if self.delRoiLab is None: + self.initDelRoiLab() + + out_lab = self.delRoiLab + if input_lab_2D is None: + out_lab[:] = self.get_2Dlab(posData.lab, force_z=False) + else: + out_lab[:] = input_lab_2D + + allDelIDs = set() + # Iterate rois and delete IDs + for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: + if ( + not self.ax1.isDelRoiItemPresent(roi) + and not self.ax2.isDelRoiItemPresent(roi) + ): + continue + ROImask = self.getDelRoiMask(roi) + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + idx = delROIs_info['rois'].index(roi) + delObjROImask = delROIs_info['delMasks'][idx] + delIDsROI = delROIs_info['delIDsROI'][idx] + delROIlabRp = skimage.measure.regionprops(out_lab) + for delObj in delROIlabRp: + isDelObj = np.any(ROImask[delObj.slice][delObj.image]) + if not isDelObj: + continue + + delObjROImask[delObj.slice][delObj.image] = delObj.label + out_lab[delObj.slice][delObj.image] = 0 + + delIDsROI.add(delObj.label) + allDelIDs.add(delObj.label) + + # Keep a mask of deleted IDs to bring them back when roi moves + delROIs_info['delMasks'][idx] = delObjROImask + delROIs_info['delIDsROI'][idx] = delIDsROI + + # printl( + # f't1-t0: {(t1-t0)*1000:.3f} ms,', + # f't2-t1: {(t2-t1)*1000:.3f} ms,', + # f't3-t2: {(t3-t2)*1000:.3f} ms,', + # # f't4-t3: {(t4-t3)*1000:.3f} ms,', + # # f't5-t4: {(t5-t4)*1000:.3f} ms,', + # # f't6-t5: {(t6-t5)*1000:.3f} ms', + # sep='\n' + # ) + + return allDelIDs, out_lab + + def getDelRoiMask(self, roi, posData=None, z_slice=None): + if posData is None: + posData = self.data[self.pos_i] + if z_slice is None: + z_slice = self.z_lab() + ROImask = np.zeros(posData.lab.shape, bool) + if isinstance(roi, pg.PolyLineROI): + r, c = [], [] + x0, y0 = roi.pos().x(), roi.pos().y() + for _, point in roi.getLocalHandlePositions(): + xr, yr = point.x(), point.y() + r.append(int(yr+y0)) + c.append(int(xr+x0)) + if not r or not c: + return ROImask + + if len(r) == 2: + rr, cc, val = skimage.draw.line_aa(r[0], c[0], r[1], c[1]) + else: + rr, cc = skimage.draw.polygon(r, c, shape=self.currentLab2D.shape) + + Y, X = self.currentLab2D.shape + rr = rr[(rr>=0) & (rr=0) & (cc{descr} {channel}
: value={value:{ff}}' + ) + return txt + + def _addOverlayHoverValuesFormatted(self, txt, xdata, ydata): + posData = self.data[self.pos_i] + if posData.ol_data is None: + return txt + + for filename in posData.ol_data: + chName = myutils.get_chname_from_basename( + filename, posData.basename, remove_ext=False + ) + if chName not in self.checkedOverlayChannels: + continue + + raw_overlay_img = self.getRawImage(filename=filename) + raw_overlay_value = raw_overlay_img[ydata, xdata] + # raw_overlay_max_value = raw_overlay_img.max() + + raw_txt = self._channelHoverValues('Raw', chName, raw_overlay_value) + + txt = f'{txt} | {raw_txt}' + return txt + + def getActiveToolButton(self): + for button in self.LeftClickButtons: + if button.isChecked(): + return button + + def getConcatAcdcDf(self): + acdc_dfs = [] + keys = [] + posData = self.data[self.pos_i] + for frame_i, data_dict in enumerate(posData.allData_li): + lab = data_dict['labels'] + if lab is None: + break + + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + break + + acdc_dfs.append(acdc_df) + keys.append(frame_i) + + if not acdc_dfs: + return + + return pd.concat(acdc_dfs, keys=keys, names=['frame_i']) + + + def checkHighlightTimestamp(self, x, y, activeToolButton): + if not hasattr(self, 'timestamp'): + return + + if not self.addTimestampAction.isChecked(): + return + + if activeToolButton is not None: + return + + if hasattr(self, 'scaleBar'): + if self.scaleBar.isHighlighted(): + return + + ymin, xmin, ymax, xmax = self.timestamp.bbox() + if x < xmin: + self.timestamp.setHighlighted(False) + return + + if x > xmax: + self.timestamp.setHighlighted(False) + return + + if y < ymin: + self.timestamp.setHighlighted(False) + return + + if y > ymax: + self.timestamp.setHighlighted(False) + return + + self.timestamp.setHighlighted(True) + + def checkHighlightScaleBar(self, x, y, activeToolButton): + if not hasattr(self, 'scaleBar'): + return + + if not self.addScaleBarAction.isChecked(): + return + + if activeToolButton is not None: + return + + ymin, xmin, ymax, xmax = self.scaleBar.bbox() + if x < xmin: + self.scaleBar.setHighlighted(False) + return + + if x > xmax: + self.scaleBar.setHighlighted(False) + return + + if y < ymin: + self.scaleBar.setHighlighted(False) + return + + if y > ymax: + self.scaleBar.setHighlighted(False) + return + + self.scaleBar.setHighlighted(True) + + def getMouseDataCoordsRightImage(self): + text = self.wcLabel.text() + if not text: + return + + ax_idx = int(re.findall(r'\(ax(\d)\)', text)[0]) + if ax_idx == 0: + return + + coords = re.findall(r'x=(\d+), y=(\d+) \|', text)[0] + + return tuple([int(val) for val in coords]) + + def updateValuesStatusBar(self): + (xl, xr), (yt, yb) = self.ax1ViewRange(integers=True) + W = round(xr - xl) + H = round(yb - yt) + txt = self.wcLabel.text() + pattern = ( + r'W=.*?, H=.*? \| ' + r'x_left=.*?, y_top=.*? \| ' + r'x_right=.*?, y_bottom=.*? \| ' + ) + replacing = ( + f'W={W:d}, H={H:d} | ' + f'x_left={xl:d}, y_top={yt:d} | ' + f'x_right={xr:d}, y_bottom={yb:d} | ' + ) + txt = re.sub(pattern, replacing, txt) + self.wcLabel.setText(txt) + + def hoverValuesFormatted(self, xdata, ydata, activeToolButton, is_ax0): + (xl, xr), (yt, yb) = self.ax1ViewRange(integers=True) + W = round(xr - xl) + H = round(yb - yt) + ax_idx = 0 if is_ax0 else 1 + txt = ( + f'x={xdata:d}, y={ydata:d} | ' + f'W={W:d}, H={H:d} | ' + f'x_left={xl:d}, y_top={yt:d} | ' + f'x_right={xr:d}, y_bottom={yb:d} | ' + f'(ax{ax_idx})' + ) + if activeToolButton == self.rulerButton: + txt = self._addRulerMeasurementText(txt) + return txt + elif activeToolButton is not None: + return txt + + posData = self.data[self.pos_i] + + raw_img = self.getRawImage() + raw_value = raw_img[ydata, xdata] + # raw_max_value = raw_img.max() + + ch = self.user_ch_name + raw_txt = self._channelHoverValues('Raw', ch, raw_value) + + txt = f'{txt} | {raw_txt}' + + txt = self._addOverlayHoverValuesFormatted(txt, xdata, ydata) + + ID = self.currentLab2D[ydata, xdata] + maxID = max(posData.IDs, default=0) + + num_obj = len(posData.IDs) + lab_txt = ( + f'Objects: ID={ID}, max ID={maxID}, ' + f'num. of objects={num_obj}' + ) + txt = f'{txt} | {lab_txt}' + + txt = self._addRulerMeasurementText(txt) + return txt + + def getRulerLengthText(self): + text = self.wcLabel.text() + lengthText = re.findall(r'length = (.*)\)', text)[0] + lengthText = lengthText.replace('pxl', 'pixels') + return f'{lengthText})' + + def _addRulerMeasurementText(self, txt): + posData = self.data[self.pos_i] + xx, yy = self.ax1_rulerPlotItem.getData() + if xx is None: + return txt + + lenPxl = math.sqrt((xx[0]-xx[1])**2 + (yy[0]-yy[1])**2) + depthAxes = self.switchPlaneCombobox.depthAxes() + if depthAxes != 'z': + pxlToUm = posData.PhysicalSizeZ + else: + pxlToUm = posData.PhysicalSizeX + + length_txt = ( + f'length = {int(lenPxl)} pxl ({lenPxl*pxlToUm:.2f} μm)' + ) + txt = f'{txt} | Measurement: {length_txt}' + return txt + + def updateImageValueFormatter(self): + if self.img1.image is not None: + dtype = self.img1.image.dtype + n_digits = len(str(int(self.img1.image.max()))) + self.imgValueFormatter = myutils.get_number_fstring_formatter( + dtype, precision=abs(n_digits-5) + ) + + rawImgData = self.data[self.pos_i].img_data + dtype = rawImgData.dtype + n_digits = len(str(int(rawImgData.max()))) + self.rawValueFormatter = myutils.get_number_fstring_formatter( + dtype, precision=abs(n_digits-5) + ) + + def normaliseIntensitiesActionTriggered(self, action): + how = action.text() + self.df_settings.at['how_normIntensities', 'value'] = how + self.df_settings.to_csv(self.settings_csv_path) + self.updateAllImages() + self.updateImageValueFormatter() + + def setLastUserNormAction(self): + how = self.df_settings.at['how_normIntensities', 'value'] + for action in self.normalizeQActionGroup.actions(): + if action.text() == how: + action.setChecked(True) + break + + def saveLabelsColormap(self): + self.labelsGrad.saveColormap() + + def addFontSizeActions(self, menu, slot): + fontActionGroup = QActionGroup(self) + fontActionGroup.setExclusive(True) + for fontSize in range(4,27): + action = QAction(self) + action.setText(str(fontSize)) + action.setCheckable(True) + if fontSize == self.fontSize: + action.setChecked(True) + fontActionGroup.addAction(action) + menu.addAction(action) + action.triggered.connect(slot) + return fontActionGroup + + @exception_handler + def changeFontSize(self): + fontSize = self.fontSizeSpinBox.value() + if fontSize == self.fontSize: + return + + self.fontSize = fontSize + + self.df_settings.at['fontSize', 'value'] = self.fontSize + self.df_settings.to_csv(self.settings_csv_path) + + self.setAllIDs() + posData = self.data[self.pos_i] + for ax in range(2): + self.textAnnot[ax].changeFontSize(self.fontSize) + if self.highLowResAction.isChecked(): + self.setAllTextAnnotations() + else: + self.updateAllImages() + + def enableZstackWidgets(self, enabled): + if enabled: + myutils.setRetainSizePolicy(self.zSliceScrollBar) + myutils.setRetainSizePolicy(self.zProjComboBox) + myutils.setRetainSizePolicy(self.zSliceOverlay_SB) + myutils.setRetainSizePolicy(self.zProjOverlay_CB) + myutils.setRetainSizePolicy(self.overlay_z_label) + self.zSliceScrollBar.setDisabled(False) + self.zProjComboBox.show() + if self.data[self.pos_i].SizeT > 1: + self.zProjLockViewButton.show() + self.zSliceScrollBar.show() + self.zSliceCheckbox.show() + self.zSliceSpinbox.show() + self.switchPlaneCombobox.show() + self.switchPlaneCombobox.setDisabled(False) + self.SizeZlabel.show() + else: + myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=False) + myutils.setRetainSizePolicy(self.zProjComboBox, retain=False) + myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=False) + myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=False) + myutils.setRetainSizePolicy(self.overlay_z_label, retain=False) + self.zSliceScrollBar.setDisabled(True) + self.zProjComboBox.hide() + self.zProjComboBox.hide() + self.zSliceScrollBar.hide() + self.zSliceCheckbox.hide() + self.zSliceSpinbox.hide() + self.SizeZlabel.hide() + self.switchPlaneCombobox.hide() + self.switchPlaneCombobox.setDisabled(True) + + self.imgGrad.rescaleAcrossZstackAction.setDisabled(not enabled) + for ch, overlayItems in self.overlayLayersItems.items(): + lutItem = overlayItems[1] + lutItem.rescaleAcrossZstackAction.setDisabled(not enabled) + + def reInitCca(self): + if not self.isSnapshot: + txt = html_utils.paragraph( + 'If you decide to continue ALL cell cycle annotations from ' + 'this frame to the end will be erased from current session ' + '(saved data is not touched of course).

' + 'To annotate future frames again you will have to revisit them.

' + 'Do you want to continue?' + ) + msg = widgets.myMessageBox() + msg.warning( + self, 'Re-initialize annnotations?', txt, + buttonsTexts=('Cancel', 'Yes') + ) + posData = self.data[self.pos_i] + if msg.cancel: + return + + # Reset all future frames + self.resetCcaFuture(posData.frame_i+1) + if posData.frame_i == 0: + # Reset everything since we are on first frame + posData.cca_df = self.getBaseCca_df() + self.store_data() + self.updateAllImages() + self.navigateScrollBar.setMaximum(posData.frame_i+1) + self.navSpinBox.setMaximum(posData.frame_i+1) + else: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + posData = self.data[self.pos_i] + posData.cca_df = self.getBaseCca_df() + self.store_data() + self.updateAllImages() + + + def repeatAutoCca(self): + # Do not allow automatic bud assignment if there are future + # frames that already contain anotations + posData = self.data[self.pos_i] + next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] + if next_df is not None: + if 'cell_cycle_stage' in next_df.columns: + msg = QMessageBox() + warn_cca = msg.critical( + self, 'Future visited frames detected!', + 'Automatic bud assignment CANNOT be performed becasue ' + 'there are future frames that already contain cell cycle ' + 'annotations. The behaviour in this case cannot be predicted.\n\n' + 'We suggest assigning the bud manually OR use the ' + '"Re-initialize cell cycle annotations" button which properly ' + 're-initialize future frames.', + msg.Ok + ) + return + + correctedAssignIDs = ( + posData.cca_df[posData.cca_df['corrected_on_frame_i']>=0].index + ) + NeverCorrectedAssignIDs = [ + ID for ID in posData.new_IDs if ID not in correctedAssignIDs + ] + + # Store cca_df temporarily if attempt_auto_cca fails + posData.cca_df_beforeRepeat = posData.cca_df.copy() + + if not all(NeverCorrectedAssignIDs): + notEnoughG1Cells, proceed = self.attempt_auto_cca() + if notEnoughG1Cells or not proceed: + posData.cca_df = posData.cca_df_beforeRepeat + else: + self.updateAllImages() + return + + msg = QMessageBox() + msg.setIcon(msg.Question) + msg.setText( + 'Do you want to automatically assign buds to mother cells for ' + 'ALL the new cells in this frame (excluding cells with unknown history) ' + 'OR only the cells where you never clicked on?' + ) + msg.setDetailedText( + f'New cells that you never touched:\n\n{NeverCorrectedAssignIDs}') + enforceAllButton = QPushButton('ALL new cells') + b = QPushButton('Only cells that I never corrected assignment') + msg.addButton(b, msg.YesRole) + msg.addButton(enforceAllButton, msg.NoRole) + msg.exec_() + if msg.clickedButton() == enforceAllButton: + notEnoughG1Cells, proceed = self.attempt_auto_cca(enforceAll=True) + else: + notEnoughG1Cells, proceed = self.attempt_auto_cca() + if notEnoughG1Cells or not proceed: + posData.cca_df = posData.cca_df_beforeRepeat + else: + self.updateAllImages() + + def manualEditCcaToolbarActionTriggered(self): + self.manualEditCca() + + def askGet2Dor3Dimage(self): + txt = html_utils.paragraph(""" + Do you want to test the denoising on the visualized 2D image or + on the entire 3D z-stack? + """) + msg = widgets.myMessageBox(wrapText=False) + _, use3Dbutton, use2Dbutton = msg.question( + self, '3D denoising?', txt, + buttonsTexts=('Cancel', 'Denoise 3D z-stack', 'Denoise 2D image') + ) + if msg.cancel: + return + + if msg.clickedButton == use3Dbutton: + posData = self.data[self.pos_i] + zslice = self.zSliceScrollBar.sliderPosition() + return posData.img_data[posData.frame_i, zslice] + else: + return self.getDisplayedImg1() + + def manualEditCca(self, checked=True): + posData = self.data[self.pos_i] + editCcaWidget = apps.editCcaTableWidget( + posData.cca_df, posData.SizeT, current_frame_i=posData.frame_i, + parent=self + ) + editCcaWidget.sigApplyChangesFutureFrames.connect( + self.applyManualCcaChangesFutureFrames + ) + editCcaWidget.exec_() + if editCcaWidget.cancel: + return + posData.cca_df = editCcaWidget.cca_df + self.store_cca_df() + # self.checkMultiBudMoth() + self.updateAllImages() + + @exception_handler + def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i): + self.store_data(autosave=False) + posData = self.data[self.pos_i] + undoId = uuid.uuid4() + for i in range(posData.frame_i, stop_frame_i): + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + self.storeUndoRedoCca(i, cca_df_i, undoId) + + for ID, changes_ID in changes.items(): + if ID not in cca_df_i.index: + continue + for col, (oldValue, newValue) in changes_ID.items(): + cca_df_i.at[ID, col] = newValue + self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) + self.get_data() + self.updateAllImages() + + def annotateRightHowCombobox_cb(self, idx): + how = self.annotateRightHowCombobox.currentText() + saveSettings = True + if hasattr(self.annotateRightHowCombobox, 'saveSettings'): + saveSettings = self.annotateRightHowCombobox.saveSettings + + if saveSettings: + self.df_settings.at['how_draw_right_annotations', 'value'] = how + self.df_settings.to_csv(self.settings_csv_path) + + mode = self.modeComboBox.currentText() + isCcaAnnot = ( + self.annotCcaInfoCheckboxRight.isChecked() and + mode != 'Normal division: Lineage tree' + ) + isIDAnnot = (self.annotIDsCheckboxRight.isChecked() or ( + self.annotCcaInfoCheckboxRight.isChecked() and + mode == 'Normal division: Lineage tree' + )) + self.textAnnot[1].setCcaAnnot( + isCcaAnnot + ) + + self.textAnnot[1].setLabelAnnot( + isIDAnnot + ) + if not self.isDataLoading: + self.updateAllImages() + + def drawIDsContComboBox_cb(self, idx): + how = self.drawIDsContComboBox.currentText() + saveSettings = True + if hasattr(self.drawIDsContComboBox, 'saveSettings'): + saveSettings = self.drawIDsContComboBox.saveSettings + + if saveSettings: + self.df_settings.at['how_draw_annotations', 'value'] = how + self.df_settings.to_csv(self.settings_csv_path) + + mode = self.modeComboBox.currentText() + isCcaAnnot = ( + self.annotCcaInfoCheckbox.isChecked() and + mode != 'Normal division: Lineage tree' + ) + isIDAnnot = (self.annotIDsCheckbox.isChecked() or ( + self.annotCcaInfoCheckbox.isChecked() and + mode == 'Normal division: Lineage tree' + )) + self.textAnnot[0].setCcaAnnot( + isCcaAnnot + ) + + self.textAnnot[0].setLabelAnnot( + isIDAnnot + ) + + if not self.isDataLoading: + self.updateAllImages() + + if self.eraserButton.isChecked(): + self.setTempImg1Eraser(None, init=True) + + def mousePressColorButton(self, event): + posData = self.data[self.pos_i] + items = list(self.checkedOverlayChannels) + if len(items)>1: + selectFluo = widgets.QDialogListbox( + 'Select image', + 'Select which fluorescence image you want to update the color of\n', + items, multiSelection=False, parent=self + ) + selectFluo.exec_() + keys = selectFluo.selectedItemsText + if selectFluo.cancel or not keys: + return + else: + self.overlayColorButton.channel = keys[0] + else: + self.overlayColorButton.channel = items[0] + self.overlayColorButton.selectColor() + + def setEnabledCcaToolbar(self, enabled=False): + self.manuallyEditCcaAction.setDisabled(False) + self.viewCcaTableAction.setDisabled(False) + self.ccaToolBar.setVisible(enabled) + for action in self.ccaToolBar.actions(): + button = self.ccaToolBar.widgetForAction(action) + action.setVisible(enabled) + button.setEnabled(enabled) + + # def setEnabledCcaToolbar(self, enabled=False): + # self.manuallyEditCcaAction.setDisabled(False) + # self.viewCcaTableAction.setDisabled(False) + # self.ccaToolBar.setVisible(enabled) + # for action in self.ccaToolBar.actions(): + # button = self.ccaToolBar.widgetForAction(action) + # action.setVisible(enabled) + # button.setEnabled(enabled) + + def setEnabledEditToolbarButton(self, enabled=False): + for action in self.segmActions: + action.setEnabled(enabled) + + for action in self.segmActionsVideo: + action.setEnabled(enabled) + + self.relabelSequentialAction.setEnabled(enabled) + self.repeatTrackingMenuAction.setEnabled(enabled) + self.repeatTrackingVideoAction.setEnabled(enabled) + self.postProcessSegmAction.setEnabled(enabled) + self.autoSegmAction.setEnabled(enabled) + self.editToolBar.setVisible(enabled) + mode = self.modeComboBox.currentText() + ccaON = mode == 'Cell cycle analysis' + for action in self.editToolBar.actions(): + button = self.editToolBar.widgetForAction(action) + # Keep binCellButton active in cca mode + if button==self.binCellButton and not enabled and ccaON: + action.setVisible(True) + button.setEnabled(True) + else: + action.setVisible(enabled) + button.setEnabled(enabled) + if not enabled: + self.setUncheckedAllButtons() + + def setEnabledFileToolbar(self, enabled): + for action in self.fileToolBar.actions(): + button = self.fileToolBar.widgetForAction(action) + if action == self.openFolderAction or action == self.newAction: + continue + if action == self.manageVersionsAction: + continue + if action == self.openFileAction: + continue + action.setEnabled(enabled) + button.setEnabled(enabled) + + def reconnectUndoRedo(self): + try: + self.undoAction.triggered.disconnect() + self.redoAction.triggered.disconnect() + except Exception as e: + pass + mode = self.modeComboBox.currentText() + if mode == 'Segmentation and Tracking' or mode == 'Snapshot': + self.undoAction.triggered.connect(self.undo) + self.redoAction.triggered.connect(self.redo) + elif mode == 'Cell cycle analysis': + self.undoAction.triggered.connect(self.UndoCca) + elif mode == 'Custom annotations': + self.undoAction.triggered.connect(self.undoCustomAnnotation) + else: + self.undoAction.setDisabled(True) + self.redoAction.setDisabled(True) + + def enableSizeSpinbox(self, enabled): + self.brushSizeLabelAction.setVisible(enabled) + self.brushSizeAction.setVisible(enabled) + self.brushAutoFillAction.setVisible(enabled) + self.brushAutoHideAction.setVisible(enabled) + self.brushEraserToolBar.setVisible(enabled) + self.disableNonFunctionalButtons() + + def reload_cb(self): + posData = self.data[self.pos_i] + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + labData = np.load(posData.segm_npz_path) + # Keep compatibility with .npy and .npz files + try: + lab = labData['arr_0'][posData.frame_i] + except Exception as e: + lab = labData[posData.frame_i] + posData.segm_data[posData.frame_i] = lab.copy() + self.get_data() + self.tracking() + self.updateAllImages() + + def clearComboBoxFocus(self, mode): + # Remove focus from modeComboBox to avoid the key_up changes its value + self.sender().clearFocus() + try: + self.timer.stop() + self.modeComboBox.setStyleSheet('background-color: none') + except Exception as e: + pass + + def updateModeMenuAction(self): + self.modeActionGroup.triggered.disconnect() + for action in self.modeActionGroup.actions(): + if action.text() != self.modeComboBox.currentText(): + continue + action.setChecked(True) + break + self.modeActionGroup.triggered.connect(self.changeModeFromMenu) + + def changeModeFromMenu(self, action): + self.modeComboBox.setCurrentText(action.text()) + + def restorePrevAnnotOptions(self): + if self.prevAnnotOptions is None: + return + self.restoreAnnotOptions_ax1(options=self.prevAnnotOptions) + self.setDrawAnnotComboboxText() + self.prevAnnotOptions = None + + def uncheckAllButtonsFromButtonGroup(self, buttonGroup): + for button in buttonGroup.buttons(): + if not button.isCheckable(): + continue + + if not button.isChecked(): + continue + + button.setChecked(False) + + @disableWindow + def changeMode(self, text): + self.reconnectUndoRedo() + self.updateModeMenuAction() + self.clearCustomAnnot() + posData = self.data[self.pos_i] + mode = text + prevMode = self.modeComboBox.previousText() + self.annotateToolbar.setVisible(False) + if prevMode != 'Viewer': + self.store_data(autosave=True) + + self.copyLostObjButton.setChecked(False) + self.stopCcaIntegrityCheckerWorker() + self.setAutoSaveSegmentationEnabled(False) + self.setAutoSaveAnnotationsEnabled(False) + if prevMode == 'Normal division: Lineage tree': + self.askLineageTreeChanges() + self.lineage_tree = None + self.editLin_TreeBar.setVisible(False) + self.uncheckAllButtonsFromButtonGroup(self.editLin_TreeGroup) + + elif prevMode == 'Cell cycle analysis': + self.setEnabledCcaToolbar(enabled=False) + + if mode == 'Segmentation and Tracking': + self.setAutoSaveSegmentationEnabled(True) + self.setSwitchViewedPlaneDisabled(True) + self.trackingMenu.setDisabled(False) + self.modeToolBar.setVisible(True) + self.lastTrackedFrameLabel.setText('') + self.initSegmTrackMode() + self.setEnabledEditToolbarButton(enabled=True) + self.addExistingDelROIs() + self.isFirstTimeOnNextFrame() + self.setEnabledCcaToolbar(enabled=False) + self.clearComputedContours() + self.realTimeTrackingToggle.setDisabled(False) + self.realTimeTrackingToggle.label.setDisabled(False) + if posData.cca_df is not None: + self.store_cca_df() + self.restorePrevAnnotOptions() + self.whitelistViewOGIDs(False) + elif mode == 'Cell cycle analysis': + self.setAutoSaveAnnotationsEnabled(True) + self.setSwitchViewedPlaneDisabled(True) + self.startCcaIntegrityCheckerWorker() + proceed = self.initCca() + if proceed: + self.applyDelROIs() + self.modeToolBar.setVisible(True) + self.realTimeTrackingToggle.setDisabled(True) + self.realTimeTrackingToggle.label.setDisabled(True) + self.computeAllContours() + # RAWR!!!!! + # self.computeAllObjToObjCostPairs() + if proceed: + self.setEnabledEditToolbarButton(enabled=False) + if self.isSnapshot: + self.editToolBar.setVisible(True) + self.setEnabledCcaToolbar(enabled=True) + self.removeAlldelROIsCurrentFrame() + self.setAnnotOptionsCcaMode() + self.clearGhost() + elif mode == 'Viewer': + self.autoSaveTimer.stop() + self.setSwitchViewedPlaneDisabled(False) + self.modeToolBar.setVisible(True) + self.realTimeTrackingToggle.setDisabled(True) + self.realTimeTrackingToggle.label.setDisabled(True) + self.setEnabledEditToolbarButton(enabled=False) + self.setEnabledCcaToolbar(enabled=False) + self.removeAlldelROIsCurrentFrame() + self.setStatusBarLabel() + self.navigateScrollBar.setMaximum(posData.SizeT) + self.navSpinBox.setMaximum(posData.SizeT) + self.clearGhost() + self.computeAllContours() + elif mode == 'Custom annotations': + self.setAutoSaveAnnotationsEnabled(True) + self.setSwitchViewedPlaneDisabled(True) + self.modeToolBar.setVisible(True) + self.realTimeTrackingToggle.setDisabled(True) + self.realTimeTrackingToggle.label.setDisabled(True) + self.setEnabledEditToolbarButton(enabled=False) + self.setEnabledCcaToolbar(enabled=False) + self.removeAlldelROIsCurrentFrame() + self.annotateToolbar.setVisible(True) + self.clearGhost() + self.doCustomAnnotation(0) + self.computeAllContours() + elif mode == 'Snapshot': + self.setAutoSaveAnnotationsEnabled(True) + self.setSwitchViewedPlaneDisabled(False) + self.reconnectUndoRedo() + self.setEnabledSnapshotMode() + self.doCustomAnnotation(0) + self.clearComputedContours() + elif mode == 'Normal division: Lineage tree': # Mode activation for lineage tree + # self.startLinTreeIntegrityCheckerWorker() # need to replace (postponed) + proceed = self.initLinTree() + self.setEnabledCcaToolbar(enabled=False) + self.setNavigateScrollBarMaximum() + if proceed: + self.applyDelROIs() + self.modeToolBar.setVisible(True) + self.realTimeTrackingToggle.setDisabled(True) + self.realTimeTrackingToggle.label.setDisabled(True) + if proceed: + self.setAutoSaveAnnotationsEnabled(True) + self.setEnabledEditToolbarButton(enabled=False) + if self.isSnapshot: + self.editToolBar.setVisible(True) + self.removeAlldelROIsCurrentFrame() + self.setAnnotOptionsLin_treeMode() + self.clearGhost() + self.editLin_TreeBar.setVisible(True) + + self.disableNonFunctionalButtons() + + def disableEditingViewPlaneNotXY(self): + posData = self.data[self.pos_i] + self.manuallyEditCcaAction.setDisabled(True) + for action in self.segmActions: + action.setDisabled(True) + if posData.SizeT == 1: + self.segmVideoMenu.setDisabled(True) + self.postProcessSegmAction.setDisabled(True) + self.autoSegmAction.setDisabled(True) + self.ccaToolBar.setVisible(False) + self.editToolBar.setVisible(False) + for action in self.ccaToolBar.actions(): + button = self.editToolBar.widgetForAction(action) + if button is not None: + button.setDisabled(True) + action.setVisible(False) + for action in self.editToolBar.actions(): + button = self.editToolBar.widgetForAction(action) + action.setVisible(False) + if button is not None: + button.setDisabled(True) + + def setEnabledSnapshotMode(self): + posData = self.data[self.pos_i] + self.manuallyEditCcaAction.setDisabled(False) + self.viewCcaTableAction.setDisabled(False) + for action in self.segmActions: + action.setDisabled(False) + + self.segmVideoMenu.setDisabled(True) + self.trackingMenu.setDisabled(True) + self.modeToolBar.setVisible(False) + + self.relabelSequentialAction.setDisabled(False) + self.postProcessSegmAction.setDisabled(False) + self.autoSegmAction.setDisabled(False) + self.ccaToolBar.setVisible(True) + self.editToolBar.setVisible(True) + self.reinitLastSegmFrameAction.setVisible(False) + for action in self.ccaToolBar.actions(): + button = self.ccaToolBar.widgetForAction(action) + if button == self.assignBudMothButton: + button.setDisabled(False) + action.setVisible(True) + elif action == self.reInitCcaAction: + action.setVisible(True) + elif action == self.assignBudMothAutoAction and posData.SizeT==1: + action.setVisible(True) + for action in self.editToolBar.actions(): + button = self.editToolBar.widgetForAction(action) + action.setVisible(True) + button.setEnabled(True) + self.realTimeTrackingToggle.setDisabled(True) + self.realTimeTrackingToggle.label.setDisabled(True) + self.repeatTrackingAction.setVisible(False) + self.manualTrackingAction.setVisible(False) + button = self.editToolBar.widgetForAction(self.repeatTrackingAction) + button.setDisabled(True) + button = self.editToolBar.widgetForAction(self.manualTrackingAction) + button.setDisabled(True) + self.disableNonFunctionalButtons() + self.reinitLastSegmFrameAction.setVisible(False) + + def launchSlideshow(self): + posData = self.data[self.pos_i] + self.determineSlideshowWinPos() + if self.slideshowButton.isChecked(): + self.slideshowWin = apps.imageViewer( + parent=self, + button_toUncheck=self.slideshowButton, + linkWindow=posData.SizeT > 1, + enableOverlay=True, + enableMirroredCursor=True + ) + self.slideshowWin.img.minMaxValuesMapper = ( + self.img1.minMaxValuesMapper + ) + self.slideshowWin.img.setCurrentPosIndex(self.pos_i) + h = self.drawIDsContComboBox.size().height() + self.slideshowWin.framesScrollBar.setFixedHeight(h) + self.slideshowWin.overlayButton.setChecked( + self.overlayButton.isChecked() + ) + self.slideshowWin.sigHoveringImage.connect( + self.setMirroredCursorFromSecondWindow + ) + if posData.SizeZ > 1: + z_slice = self.zSliceScrollBar.sliderPosition() + self.slideshowWin.img.setCurrentZsliceIndex(z_slice) + self.slideshowWin.zSliceScrollBar.setSliderPosition(z_slice) + self.slideshowWin.z_label.setText( + f'z-slice {z_slice+1:02}/{posData.SizeZ}' + ) + self.slideshowWin.update_img() + self.slideshowWin.show( + left=self.slideshowWinLeft, top=self.slideshowWinTop + ) + else: + self.slideshowWin.close() + self.slideshowWin = None + + def setMirroredCursorFromSecondWindow(self, x, y): + if x is None: + xx, yy = [], [] + else: + xx, yy = [x], [y] + self.ax1_cursor.setData(xx, yy) + if not self.isTwoImageLayout: + return + self.ax2_cursor.setData(xx, yy) + + def goToZsliceSearchedID(self, obj): + if not self.isSegm3D: + return + + current_z = self.z_lab() + nearest_nonzero_z = core.nearest_nonzero_z_idx_from_z_centroid( + obj, current_z=current_z + ) + if nearest_nonzero_z == current_z: + self.drawPointsLayers(computePointsLayers=True) + return + + self.zSliceScrollBar.setSliderPosition(nearest_nonzero_z) + self.update_z_slice(nearest_nonzero_z) + + def disconnectLeftClickButtons(self): + for button in self.LeftClickButtons: + try: + button.toggled.disconnect() + except Exception as e: + # Not all the LeftClickButtons have toggled connected + pass + + def uncheckLeftClickButtons(self, sender): + for button in self.LeftClickButtons: + if button != sender: + button.setChecked(False) + + if button != self.labelRoiButton: + # self.labelRoiButton is disconnected so we manually call uncheck + self.labelRoi_cb(False) + self.secondLevelToolbar.setVisible(True) + for toolbar in self.controlToolBars: + try: + toolbar.keepVisibleWhenActive + if toolbar.isVisible(): + self.secondLevelToolbar.setVisible(False) + continue + except: + pass + toolbar.setVisible(False) + + self.enableSizeSpinbox(False) + if sender is not None: + self.keepIDsButton.setChecked(False) + + def connectLeftClickButtonsPointsLayersToolbar(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'layerTypeIdx'): + continue + if action.layerTypeIdx != 4: + continue + action.button.toggled.connect( + self.addPointsByClickingButtonToggled + ) + + def connectLeftClickButtons(self): + self.brushButton.toggled.connect(self.Brush_cb) + self.curvToolButton.toggled.connect(self.curvTool_cb) + self.rulerButton.toggled.connect(self.ruler_cb) + self.eraserButton.toggled.connect(self.Eraser_cb) + self.wandToolButton.toggled.connect(self.wand_cb) + self.labelRoiButton.toggled.connect(self.labelRoi_cb) + self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) + self.drawClearRegionButton.toggled.connect(self.drawClearRegion_cb) + self.expandLabelToolButton.toggled.connect(self.expandLabelCallback) + self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) + self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) + self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) + self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) + self.connectLeftClickButtonsPointsLayersToolbar() + + def brushSize_cb(self, value): + self.ax2_EraserCircle.setSize(value*2) + self.ax1_BrushCircle.setSize(value*2) + self.ax2_BrushCircle.setSize(value*2) + self.ax1_EraserCircle.setSize(value*2) + self.ax2_EraserX.setSize(value) + self.ax1_EraserX.setSize(value) + self.setDiskMask() + + def autoIDtoggled(self, checked): + self.editIDspinboxAction.setDisabled(checked) + self.editIDLabelAction.setDisabled(checked) + if not checked and self.editIDspinbox.value() == 0: + newID = self.setBrushID(return_val=True) + self.editIDspinbox.setValue(newID) + + def wand_cb(self, checked): + posData = self.data[self.pos_i] + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.wandToolButton) + self.connectLeftClickButtons() + self.wandControlsToolbar.setVisible(True) + # self.secondLevelToolbar.setVisible(False) + else: + self.resetCursors() + # self.secondLevelToolbar.setVisible(True) + self.wandControlsToolbar.setVisible(False) + + def magicPrompts_cb(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.magicPromptsToolButton) + self.connectLeftClickButtons() + self.magicPromptsToolbar.setVisible(True) + self.promptSegmentPointsLayerToolbar.setVisible(True) + if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit: + self.addPointsLayerTriggered( + toolbar=self.promptSegmentPointsLayerToolbar + ) + else: + self.resetCursors() + self.promptSegmentPointsLayerToolbar.setVisible(False) + self.magicPromptsToolbar.setVisible(False) + + def copyLostObjContour_cb(self, checked): + self.copyLostObjToolbar.setVisible(checked) + + self.ax1_lostObjScatterItem.hoverLostID = 0 + if not checked: + return + + self.lostObjImage = np.zeros_like(self.currentLab2D) + self.updateLostContoursImage(0) + + def manualAnnotPast_cb(self, checked): + posData = self.data[self.pos_i] + if checked: + for _ in range(3): + self.onEscape( + buttonsToNotUncheck=[self.manualAnnotPastButton], + doAutoRange=False + ) + + self.brushButton.setChecked(True) + self.store_data() + self.manualAnnotState = { + 'editID': self.editIDspinbox.value(), + 'isAutoID': self.autoIDcheckbox.isChecked(), + 'doWarnLostObj': self.warnLostCellsAction.isChecked(), + } + self.autoIDcheckbox.setChecked(False) + self.warnLostCellsAction.setChecked(False) + hoverID = self.getLastHoveredID() + if hoverID == 0: + win = apps.QLineEditDialog( + title='Not hovering any ID', + msg='You are not hovering on any ID.\n' + 'Enter the ID that you want to lock.', + parent=self, + isInteger=True, + defaultTxt=self.setBrushID(return_val=True) + ) + win.exec_() + if win.cancel: + self.manualAnnotPastButton.setChecked(False) + return + hoverID = win.EntryID + self.logger.info( + 'Setting manual annotation for ID = ' + f'{hoverID}, at frame n. {posData.frame_i+1}' + ) + self.editIDspinbox.setValue(hoverID) + try: + obj_idx = posData.IDs_idxs[hoverID] + obj = posData.rp[obj_idx] + radius = 0.9 * obj.minor_axis_length / 2 # math.sqrt(obj.area/math.pi)*0.9 + self.brushSizeSpinbox.setValue(round(radius)) + except Exception as err: + pass + + self.manualAnnotState['frame_i_to_restore'] = posData.frame_i + self.manualAnnotState['last_tracked_i'] = ( + self.navigateScrollBar.maximum()-1 + ) + self.ax1.sigRangeChanged.connect(self.highlightManualAnnotMode) + self.ax1.setHighlighted(True, color='green') + else: + self.setStatusBarLabel() + self.autoIDcheckbox.setChecked(self.manualAnnotState['isAutoID']) + self.editIDspinbox.setValue(self.manualAnnotState['editID']) + self.warnLostCellsAction.setChecked( + self.manualAnnotState['doWarnLostObj'] + ) + frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + if frame_to_restore is None: + return + + self.store_data() + self.store_manual_annot_data() + + last_tracked_i_to_restore = self.manualAnnotState['last_tracked_i'] + self.manualAnnotRestoreLastTrackedFrame(last_tracked_i_to_restore) + + self.logger.info( + f'Restoring view to frame n. {posData.frame_i+1}...' + ) + posData.frame_i = frame_to_restore + self.get_data() + self.updateAllImages() + self.updateScrollbars() + self.ax1.sigRangeChanged.disconnect() + self.ax1.setHighlighted(False) + QTimer.singleShot(150, self.autoRange) + + self.setManualAnnotModeEnabledTools(checked) + + def copyLostObjectMask(self, ID: int): + posData = self.data[self.pos_i] + mask = self.lostObjImage == ID + lab2D = self.get_2Dlab(posData.lab) + lab2D[mask] = ID + self.lostObjImage[mask] = 0 + self.set_2Dlab(lab2D) + + def highlightManualAnnotMode(self, viewBox, viewRange): + self.ax1.setHighlighted(True) + + def updateHighlightedAxis(self): + if not self.manualAnnotPastButton.isChecked(): + return + + frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + posData = self.data[self.pos_i] + if posData.frame_i == frame_to_restore: + color = 'green' + elif posData.frame_i < frame_to_restore: + color = 'gold' + else: + color = 'red' + + self.ax1.setHighlightingRectItemsColor(color) + + def updateLostNewCurrentIDs(self): + posData = self.data[self.pos_i] + + prev_IDs = self.getPrevFrameIDs() + tracked_lost_IDs = self.getTrackedLostIDs() + curr_IDs = posData.IDs + curr_delRoiIDs = self.getStoredDelRoiIDs() + prev_delRoiIDs = self.getStoredDelRoiIDs(frame_i=posData.frame_i-1) + lost_IDs = [ + ID for ID in prev_IDs if ID not in curr_IDs + and ID not in prev_delRoiIDs and ID not in tracked_lost_IDs + ] + new_IDs = [ + ID for ID in curr_IDs if ID not in prev_IDs + and ID not in curr_delRoiIDs + ] + IDs_with_holes = [] + posData.lost_IDs = lost_IDs + posData.new_IDs = new_IDs + posData.old_IDs = prev_IDs + posData.IDs = curr_IDs + + out = ( + lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs + ) + return out + + def _copyAllLostObjects_navigateToFrame(self, frame_i): + posData = self.data[self.pos_i] + self.store_data(mainThread=False, autosave=False) + + posData.frame_i = frame_i + self.get_data() + self.tracking(wl_update=False) + self.currentLab2D = self.get_2Dlab(posData.lab) + self.update_rp() + self.updateLostNewCurrentIDs() + self.store_data(mainThread=False, autosave=False) + + self.lostObjContoursImage[:] = 0 + self.lostObjImage[:] = 0 + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] # need to change this when merging with opt. + for lostID in posData.lost_IDs: + obj = prev_rp[prev_IDs_idxs[lostID]] + self.addLostObjsToLostObjImage(obj, lostID, force=True) + + def _copyAllLostObjects_returnToFrame(self, frame_i): + posData = self.data[self.pos_i] + self.store_data(autosave=False, mainThread=False) + posData.frame_i = frame_i + self.get_data() + + def _copyAllLostObjects_refreshRp(self): + self.update_rp(draw=False, wl_update=False) # need to change this when merging with opt. + + @disableWindow + def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): + if not self.copyLostObjButton.isChecked(): + return + + posData = self.data[self.pos_i] + + desc = 'Copying all lost objects...' + + self.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self.mainWin, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(for_future_frame_n+1) + self.progressWin.show(self.app) + + self.copyAllLostObjectsThread = QThread() + + self.copyAllLostObjectsWorker = workers.CopyAllLostObjectsWorker( + self, posData, for_future_frame_n, max_overlap_perc + ) + self.copyAllLostObjectsWorker.moveToThread(self.copyAllLostObjectsThread) + + self.copyAllLostObjectsWorker.navigateToFrame.connect( + self._copyAllLostObjects_navigateToFrame, + Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.returnToFrame.connect( + self._copyAllLostObjects_returnToFrame, + Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.copyLostObjectMask.connect( + self.copyLostObjectMask, + Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.refreshRp.connect( + self._copyAllLostObjects_refreshRp, + Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.progressBar.connect( + self.workerUpdateProgressbar + ) + self.copyAllLostObjectsWorker.critical.connect( + self.copyAllLostObjectsWorkerCritical + ) + self.copyAllLostObjectsWorker.finished.connect( + self.copyAllLostObjectsThread.quit + ) + self.copyAllLostObjectsWorker.finished.connect( + self.copyAllLostObjectsWorker.deleteLater + ) + self.copyAllLostObjectsThread.finished.connect( + self.copyAllLostObjectsThread.deleteLater + ) + self.copyAllLostObjectsWorker.finished.connect( + self.copyAllLostObjectsWorkerFinished + ) + + self.copyAllLostObjectsThread.started.connect( + self.copyAllLostObjectsWorker.run + ) + self.copyAllLostObjectsThread.start() + + self.copyAllLostObjectsWorkerLoop = QEventLoop() + self.copyAllLostObjectsWorkerLoop.exec_() + + def copyAllLostObjectsWorkerCritical(self, error): + self.copyAllLostObjectsWorkerLoop.exit() + self.workerCritical(error) + + def copyAllLostObjectsWorkerFinished(self, output): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + if output.get('doReinitLastSegmFrame', False): + self.reInitLastSegmFrame( + from_frame_i=output.get('last_visited_frame_i'), + updateImages=False, + force=True + ) + + if output.get('overlap_warning', False): + self.blinker = qutils.QControlBlink( + self.copyLostObjToolbar.maxOverlapNumberControl, + qparent=self.mainWin + ) + self.blinker.start() + + self.copyAllLostObjectsWorkerLoop.exit() + self.update_rp() + self.updateAllImages() + self.store_data() + + def labelRoiTrangeCheckboxToggled(self, checked): + disabled = not checked + self.labelRoiStartFrameNoSpinbox.setDisabled(disabled) + self.labelRoiStopFrameNoSpinbox.setDisabled(disabled) + self.labelRoiStartFrameNoSpinbox.label.setDisabled(disabled) + self.labelRoiStopFrameNoSpinbox.label.setDisabled(disabled) + self.labelRoiToEndFramesAction.setDisabled(disabled) + self.labelRoiFromCurrentFrameAction.setDisabled(disabled) + + if disabled: + return + + posData = self.data[self.pos_i] + + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) + self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) + + def drawClearRegion_cb(self, checked): + posData = self.data[self.pos_i] + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.drawClearRegionButton) + self.connectLeftClickButtons() + + self.drawClearRegionToolbar.setVisible(checked) + + if not self.isSegm3D: + self.drawClearRegionToolbar.setZslicesControlEnabled(False) + return + + if not checked: + return + + self.drawClearRegionToolbar.setZslicesControlEnabled( + True, SizeZ=posData.SizeZ + ) + + def labelRoi_cb(self, checked): + posData = self.data[self.pos_i] + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.labelRoiButton) + self.connectLeftClickButtons() + + self.labelRoiStartFrameNoSpinbox.setMaximum(posData.SizeT) + self.labelRoiStopFrameNoSpinbox.setMaximum(posData.SizeT) + + if self.labelRoiActiveWorkers: + lastActiveWorker = self.labelRoiActiveWorkers[-1] + self.labelRoiGarbageWorkers.append(lastActiveWorker) + lastActiveWorker.finished.emit() + self.logger.info('Collected garbage w5orker (magic labeller).') + + self.labelRoiToolbar.setVisible(True) + if self.isSegm3D: + self.labelRoiZdepthSpinbox.setDisabled(False) + else: + self.labelRoiZdepthSpinbox.setDisabled(True) + + # Start thread and pause it + self.labelRoiThread = QThread() + self.labelRoiMutex = QMutex() + self.labelRoiWaitCond = QWaitCondition() + + labelRoiWorker = workers.LabelRoiWorker(self) + + labelRoiWorker.moveToThread(self.labelRoiThread) + labelRoiWorker.finished.connect(self.labelRoiThread.quit) + labelRoiWorker.finished.connect(labelRoiWorker.deleteLater) + self.labelRoiThread.finished.connect( + self.labelRoiThread.deleteLater + ) + + labelRoiWorker.finished.connect(self.labelRoiWorkerFinished) + labelRoiWorker.sigLabellingDone.connect(self.labelRoiDone) + labelRoiWorker.sigProgressBar.connect(self.workerUpdateProgressbar) + + labelRoiWorker.progress.connect(self.workerProgress) + labelRoiWorker.critical.connect(self.workerCritical) + + self.labelRoiActiveWorkers.append(labelRoiWorker) + + self.labelRoiThread.started.connect(labelRoiWorker.run) + self.labelRoiThread.start() + + # Add the rectROI to ax1 + self.ax1.addItem(self.labelRoiItem) + elif self.initLabelRoiModelDialog is not None: + # User is using other tools while the dialog is still open + # --> we allow this because it's useful to be able to use + # the ruler or check things --> do nothing + pass + else: + self.labelRoiToolbar.setVisible(False) + + for worker in self.labelRoiActiveWorkers: + worker._stop() + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + self.labelRoiItem.setPos((0,0)) + self.labelRoiItem.setSize((0,0)) + self.freeRoiItem.clear() + self.ax1.removeItem(self.labelRoiItem) + self.updateLabelRoiCircularCursor(None, None, False) + + def clearObjsFreehandRegion(self): + self.logger.info('Clearing objects inside freehand region...') + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False, storeImage=False, storeOnlyZoom=True) + + posData = self.data[self.pos_i] + zRange = None + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if isZslice: + z_slice = self.z_lab() + zRange = self.drawClearRegionToolbar.zRange( + z_slice, posData.SizeZ + ) + else: + zRange = (0, posData.SizeZ) + + regionSlice = self.freeRoiItem.slice(zRange=zRange) + mask = self.freeRoiItem.mask() + + regionLab = posData.lab[(...,) + regionSlice].copy() + + clearBorders = ( + self.drawClearRegionToolbar + .clearOnlyEnclosedObjsRadioButton.isChecked() + ) + if clearBorders: + if regionLab.ndim == 2: + regionLab = transformation.clear_objects_not_in_mask( + regionLab, mask + ) + regionRp = skimage.measure.regionprops(regionLab) + for obj in regionRp: + if np.all(mask[obj.slice][obj.image]): + continue + + regionLab[obj.slice][obj.image] = 0 + else: + for z, regionLab_z in enumerate(regionLab): + regionLab[z] = transformation.clear_objects_not_in_mask( + regionLab_z, mask + ) + else: + regionLab[..., ~mask] = 0 + + regionRp = skimage.measure.regionprops(regionLab) + clearIDs = [obj.label for obj in regionRp] + + if not clearIDs: + if clearBorders: + self.logger.warning( + 'None of the objects in the freehand region are ' + 'fully enclosed' + ) + else: + self.logger.warning( + 'None of the objects are touching the freehand region' + ) + return + + self.deleteIDmiddleClick(clearIDs, False, False) + self.update_cca_df_deletedIDs(posData, clearIDs) + + self.freeRoiItem.clear() + + self.updateAllImages() + + def labelRoiWorkerFinished(self): + self.logger.info('Magic labeller closed.') + worker = self.labelRoiActiveWorkers.pop(-1) + + def indexRoiLab(self, roiLab, roiLabSlice, lab, brushID): + # Delete only objects touching borders in X and Y not in Z + if self.labelRoiAutoClearBorderCheckbox.isChecked(): + mask = np.zeros(roiLab.shape, dtype=bool) + mask[..., 1:-1, 1:-1] = True + roiLab = skimage.segmentation.clear_border(roiLab, mask=mask) + + roiLabMask = roiLab>0 + roiLab[roiLabMask] += (brushID-1) + if self.labelRoiReplaceExistingObjectsCheckbox.isChecked(): + IDs_touched_by_new_objects = np.unique(lab[roiLabSlice][roiLabMask]) + for ID in IDs_touched_by_new_objects: + lab[lab==ID] = 0 + + lab[roiLabSlice][roiLabMask] = roiLab[roiLabMask] + return lab + + @exception_handler + def labelRoiDone(self, roiSegmData, isTimeLapse): + self.setDisabled(False) + + posData = self.data[self.pos_i] + self.setBrushID() + + if isTimeLapse: + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.mainPbar.setValue(0) + current_frame_i = posData.frame_i + start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 + for i, roiLab in enumerate(roiSegmData): + frame_i = start_frame_i + i + lab = posData.allData_li[frame_i]['labels'] + store = True + if lab is None: + if frame_i >= len(posData.segm_data): + lab = np.zeros_like(posData.segm_data[0]) + posData.segm_data = np.append( + posData.segm_data, lab[np.newaxis], axis=0 + ) + else: + lab = posData.segm_data[frame_i] + store = False + roiLabSlice = self.labelRoiSlice[1:] + lab = self.indexRoiLab( + roiLab, roiLabSlice, lab, posData.brushID + ) + if store: + posData.frame_i = frame_i + posData.allData_li[frame_i]['labels'] = lab.copy() + self.get_data() + self.store_data(autosave=False) + + # Back to current frame + posData.frame_i = current_frame_i + self.get_data() + else: + roiLab = roiSegmData + posData.lab = self.indexRoiLab( + roiLab, self.labelRoiSlice, posData.lab, posData.brushID + ) + + self.update_rp() + + # Repeat tracking + if self.autoIDcheckbox.isChecked(): + self.tracking(enforce=True, assign_unique_new_IDs=False) + + self.store_data() + self.updateAllImages() + + self.labelRoiItem.setPos((0,0)) + self.labelRoiItem.setSize((0,0)) + self.freeRoiItem.clear() + self.logger.info('Magic labeller done!') + self.app.restoreOverrideCursor() + + self.labelRoiRunning = False + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + uncheckLabelRoiTRange = ( + self.labelRoiTrangeCheckbox.isChecked() + and not self.labelRoiTrangeCheckbox.findChild(QAction).isChecked() + ) + if uncheckLabelRoiTRange: + self.labelRoiTrangeCheckbox.setChecked(False) + + def restoreHoverObjBrush(self): + posData = self.data[self.pos_i] + if self.ax1BrushHoverID in posData.IDs: + obj_idx = posData.IDs_idxs[self.ax1BrushHoverID] + obj = posData.rp[obj_idx] + if not self.isObjVisible(obj.bbox): + return + + self.addObjContourToContoursImage(obj=obj, ax=0) + self.addObjContourToContoursImage(obj=obj, ax=1) + + def hideItemsHoverBrush(self, xy=None, ID=None, force=False): + if xy is not None: + x, y = xy + if x is None: + return + + xdata, ydata = int(x), int(y) + Y, X = self.currentLab2D.shape + + if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): + return + + if not self.brushAutoHideCheckbox.isChecked() and not force: + return + + posData = self.data[self.pos_i] + size = self.brushSizeSpinbox.value()*2 + + if xy is not None: + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + + if self.ax1_lostObjScatterItem.isVisible(): + self.ax1_lostObjScatterItem.setVisible(False) + + if self.ax1_lostTrackedScatterItem.isVisible(): + self.ax1_lostTrackedScatterItem.setVisible(False) + + if self.ax2_lostObjScatterItem.isVisible(): + self.ax2_lostObjScatterItem.setVisible(False) + + if self.ax2_lostTrackedScatterItem.isVisible(): + self.ax2_lostTrackedScatterItem.setVisible(False) + + # Restore ID previously hovered + if ID != self.ax1BrushHoverID and not self.isMouseDragImg1: + try: + self.restoreHoverObjBrush() + except Exception as e: + self.ax1BrushHoverID = 0 + return + + # Hide items hover ID + if ID != 0: + self.clearObjContour(ID=ID, ax=0) + self.clearObjContour(ID=ID, ax=1) + self.ax1BrushHoverID = ID + else: + self.ax1BrushHoverID = 0 + + def updateBrushCursor(self, x, y, isHoverImg1=True): + if x is None: + return + + xdata, ydata = int(x), int(y) + _img = self.currentLab2D + Y, X = _img.shape + + if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): + return + + size = self.brushSizeSpinbox.value()*2 + self.setHoverToolSymbolData( + [x], [y], self.activeBrushCircleCursors(isHoverImg1), + size=size + ) + self.setHoverToolSymbolColor( + xdata, ydata, self.ax2_BrushCirclePen, + self.activeBrushCircleCursors(isHoverImg1), + self.brushButton, brush=self.ax2_BrushCircleBrush + ) + + def moveLabelButtonToggled(self, checked): + if not checked: + self.hoverLabelID = 0 + self.highlightedID = 0 + self.highLightIDLayerImg1.clear() + self.highLightIDLayerRightImage.clear() + self.setHighlightID(False) + + def setAllIDs(self, onlyVisited=False): + for posData in self.data: + posData.allIDs = set() + for frame_i in range(len(posData.segm_data)): + if frame_i >= len(posData.allData_li): + break + lab = posData.allData_li[frame_i]['labels'] + if lab is None and onlyVisited: + break + + if lab is None: + rp = skimage.measure.regionprops(posData.segm_data[frame_i]) + else: + rp = posData.allData_li[frame_i]['regionprops'] + posData.allIDs.update([obj.label for obj in rp]) + + def countObjectsTimelapse(self): + if self.countObjsWindow is None: + activeCategories = { + 'In current frame', + 'In all visited frames', + 'In entire video', + 'Unique objects in all visited frames', + 'Unique objects in entire video' + } + else: + activeCategories = self.countObjsWindow.activeCategories() + + posData = self.data[self.pos_i] + allCategoryCountMapper = posData.countObjectsInSegmTimelapse( + activeCategories + ) + if self.countObjsWindow is None: + return allCategoryCountMapper + + categoryCountMapper = {} + for category in activeCategories: + categoryCountMapper[category] = allCategoryCountMapper[category] + + return categoryCountMapper + + + def countObjectsSnapshots(self): + posData = self.data[self.pos_i] + if self.countObjsWindow is None: + activeCategories = { + 'In current position', + 'In all visited positions (current session)', + 'In all visited positions (previous sessions)', + 'In all loaded positions', + } + if self.isSegm3D: + activeCategories.add('In current z-slice') + else: + activeCategories = self.countObjsWindow.activeCategories() + + numObjectsCurrentPos = len(posData.IDs) + numObjectsAllPos = 0 + numObjectsVisitedPosPrevious = 0 + numObjectsVisitedPosCurrent = 0 + numObjectsCurrentZslice = None + if 'In current z-slice' in activeCategories: + numObjectsCurrentZslice = len( + skimage.measure.regionprops(self.currentLab2D) + ) + + for pos_i, _posData in enumerate(self.data): + IDs = _posData.allData_li[0]['IDs'] + if os.path.exists(_posData.acdc_output_csv_path): + numObjectsVisitedPosPrevious += len(IDs) + if IDs: + numObjs = len(IDs) + numObjectsAllPos += len(IDs) + else: + lab = _posData.segm_data[0] + rp = skimage.measure.regionprops(lab) + numObjs = len(rp) + numObjectsAllPos += numObjs + + if _posData.visited: + numObjectsVisitedPosCurrent += numObjs + + allCategoryCountMapper = { + 'In current position': numObjectsCurrentPos, + 'In all visited positions (current session)': + numObjectsVisitedPosCurrent, + 'In all visited positions (previous sessions)': + numObjectsVisitedPosPrevious, + 'In all loaded positions': numObjectsAllPos, + } + if numObjectsCurrentZslice is not None: + allCategoryCountMapper['In current z-slice'] = ( + numObjectsCurrentZslice + ) + + if self.countObjsWindow is None: + return allCategoryCountMapper + + categoryCountMapper = {} + for category in activeCategories: + categoryCountMapper[category] = allCategoryCountMapper[category] + + return categoryCountMapper + + def countObjects(self): + self.logger.info('Counting objects...') + + posData = self.data[self.pos_i] + if posData.SizeT > 1: + return self.countObjectsTimelapse() + + return self.countObjectsSnapshots() + + + def updateObjectCounts(self): + if self.countObjsWindow is None: + return + + if not self.countObjsWindow.isVisible(): + return + + if not self.countObjsWindow.livePreviewCheckbox.isChecked(): + return + + categoryCountMapper = self.countObjects() + self.countObjsWindow.updateCounts(categoryCountMapper) + + def keepIDs_cb(self, checked): + if checked: + self.highlightedLab = np.zeros_like(self.currentLab2D) + if self.annotCcaInfoCheckbox.isChecked(): + self.annotCcaInfoCheckbox.setChecked(False) + self.annotIDsCheckbox.setChecked(True) + self.setDrawAnnotComboboxText() + self.uncheckLeftClickButtons(None) + self.initKeepObjLabelsLayers() + self.setAllIDs() + else: + # restore items to non-grayed out + self.clearTempBrushImage() + alpha = self.imgGrad.labelsAlphaSlider.value() + self.labelsLayerImg1.setOpacity(alpha) + self.labelsLayerRightImg.setOpacity(alpha) + self.ax1_contoursImageItem.setOpacity(1.0) + self.ax2_contoursImageItem.setOpacity(1.0) + self.ax1_lostObjImageItem.setOpacity(1.0) + self.ax2_lostObjImageItem.setOpacity(1.0) + self.ax1_lostTrackedObjImageItem.setOpacity(1.0) + self.ax2_lostTrackedObjImageItem.setOpacity(1.0) + + self.keepIDsToolbar.setVisible(checked) + self.highlightedIDopts = None + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + self.updateAllImages() + + # QTimer.singleShot(300, self.autoRange) + + def get_curr_lab(self, curr_lab: np.ndarray|None = None, frame_i: int|None = None): + """Get the current labels for the position data. Hirarchically checks: + 1. If `curr_lab` is provided, use it. + 2. If `posData.lab` is not None, use it. + 3. If `posData.allData_li[frame_i]['labels']` exists, use it. + 4. If `posData.segm_data[frame_i]` exists, use it. + + If frame_i is None, uses the current frame index from `posData`. + + Parameters + ---------- + curr_lab : np.ndarray, optional + Current labels for the position data if it should be checked + if its not None first, by default None + frame_i : int, optional + Frame index to use for retrieving labels, by default None + + Returns + ------- + np.ndarray + Current labels for the position data + """ + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + if curr_lab is None and frame_i == posData.frame_i: + curr_lab = posData.lab + + if curr_lab is None: + try: + curr_lab = posData.allData_li[frame_i]['labels'].copy() + except: + pass + + if curr_lab is None: + try: + curr_lab = posData.segm_data[frame_i].copy() + except: + pass + + return curr_lab + + def setFrameNavigationDisabled(self, disable: bool, why: str): + """Disables the frame navigation buttons and scrollbar. + This is used when the user is not allowed to navigate through frames + Call again to unlock it again. Also sets tooltips to inform the user + + Parameters + ---------- + disable : bool + if the navigation should be disabled + why : str + the reason for disabeling the navigation. + """ + + if disable: + self.whyNavigateDisabled.add(why) + else: + try: + self.whyNavigateDisabled.remove(why) + except KeyError: + pass + + if len(self.whyNavigateDisabled) == 0: + disable = False + else: + disable = True + + # Apply the disable/enable state + self.prevAction.setDisabled(disable) + self.nextAction.setDisabled(disable) + self.navigateScrollBar.setDisabled(disable) + + # Set appropriate tooltip + if not disable: + self.navigateScrollBar.setToolTip( + 'NOTE: The maximum frame number that can be visualized with this ' + 'scrollbar\n' + 'is the last visited frame with the selected mode\n' + '(see "Mode" selector on the top-right).\n\n' + 'If the scrollbar does not move it means that you never visited\n' + 'any frame with current mode.\n\n' + 'Note that the "Viewer" mode allows you to scroll ALL frames.' + ) + return + + txt = f'Frame navigation disabled: {self.whyNavigateDisabled}' + self.logger.info(txt) + self.navigateScrollBar.setToolTip(txt) + + def delObjsOutSegmMaskActionTriggered(self): + posData = self.data[self.pos_i] + segm_files = load.get_segm_files(posData.images_path) + existingSegmEndnames = load.get_endnames( + posData.basename, segm_files + ) + selectSegmWin = widgets.QDialogListbox( + 'Select segmentation file', + 'Select segmentation file to use as ROI:\n', + existingSegmEndnames, multiSelection=False, parent=self + ) + selectSegmWin.exec_() + if selectSegmWin.cancel: + self.logger.info('Delete objects process cancelled.') + return + + selectedSegmEndname = selectSegmWin.selectedItemsText[0] + + self.startDelObjsOutSegmMaskWorker(selectedSegmEndname) + + def startDelObjsOutSegmMaskWorker(self, selectedSegmEndname): + self.store_data(autosave=False) + posData = self.data[self.pos_i] + segm_data = np.squeeze(self.getStoredSegmData()) + + self.progressWin = apps.QDialogWorkerProgress( + title='Deleting objects outside of ROIs', parent=self, + pbarDesc='Deleting objects outside of ROIs...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + self.thread = QThread() + self.worker = workers.DelObjectsOutsideSegmROIWorker( + selectedSegmEndname, segm_data, posData.images_path + ) + self.worker.moveToThread(self.thread) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + self.worker.progress.connect(self.workerProgress) + self.worker.critical.connect(self.workerCritical) + self.worker.finished.connect(self.delObjsOutSegmMaskWorkerFinished) + + self.worker.debug.connect(self.workerDebug) + + self.thread.started.connect(self.worker.run) + self.thread.start() + + def storeViewRange(self): + if not hasattr(self, 'isRangeReset'): + return + + if not self.isRangeReset: + return + self.ax1_viewRange = self.ax1.viewRange() + self.isRangeReset = False + + def mergeObjs_cb(self, checked): + if not checked: + self.mergeObjsTempLine.setData([], []) + + def Brush_cb(self, checked): + if checked: + self.typingEditID = False + self.setDiskMask() + self.setHoverToolSymbolData( + [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX) + ) + self.updateBrushCursor(self.xHoverImg, self.yHoverImg) + self.setBrushID() + + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + c = self.defaultToolBarButtonColor + self.eraserButton.setStyleSheet(f'background-color: {c}') + self.connectLeftClickButtons() + self.setFocusGraphics() + else: + self.ax1_lostObjScatterItem.setVisible(True) + self.ax2_lostObjScatterItem.setVisible(True) + self.ax1_lostTrackedScatterItem.setVisible(True) + self.ax2_lostTrackedScatterItem.setVisible(True) + + self.setHoverToolSymbolData( + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + self.resetCursors() + + self.showEditIDwidgets(checked) + self.enableSizeSpinbox(checked) + + def showEditIDwidgets(self, visible): + self.editIDLabelAction.setVisible(visible) + self.editIDspinboxAction.setVisible(visible) + self.autoIDcheckboxAction.setVisible(visible) + showToolbar = ( + visible + or self.brushSizeAction.isVisible() + or self.brushAutoFillAction.isVisible() + or self.brushAutoHideAction.isVisible() + ) + self.brushEraserToolBar.setVisible(showToolbar) + + def resetCursors(self): + self.ax1_cursor.setData([], []) + self.ax2_cursor.setData([], []) + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + def setDiskMask(self): + brushSize = self.brushSizeSpinbox.value() + # diam = brushSize*2 + # center = (brushSize, brushSize) + # diskShape = (diam+1, diam+1) + # diskMask = np.zeros(diskShape, bool) + # rr, cc = skimage.draw.disk(center, brushSize+1, shape=diskShape) + # diskMask[rr, cc] = True + self.diskMask = skimage.morphology.disk(brushSize, dtype=bool) + + def getDiskMask(self, xdata, ydata): + Y, X = self.currentLab2D.shape[-2:] + + brushSize = self.brushSizeSpinbox.value() + yBottom, xLeft = ydata-brushSize, xdata-brushSize + yTop, xRight = ydata+brushSize+1, xdata+brushSize+1 + + if xLeft<0: + if yBottom<0: + # Disk mask out of bounds top-left + diskMask = self.diskMask.copy() + diskMask = diskMask[-yBottom:, -xLeft:] + yBottom = 0 + elif yTop>Y: + # Disk mask out of bounds bottom-left + diskMask = self.diskMask.copy() + diskMask = diskMask[0:Y-yBottom, -xLeft:] + yTop = Y + else: + # Disk mask out of bounds on the left + diskMask = self.diskMask.copy() + diskMask = diskMask[:, -xLeft:] + xLeft = 0 + + elif xRight>X: + if yBottom<0: + # Disk mask out of bounds top-right + diskMask = self.diskMask.copy() + diskMask = diskMask[-yBottom:, 0:X-xLeft] + yBottom = 0 + elif yTop>Y: + # Disk mask out of bounds bottom-right + diskMask = self.diskMask.copy() + diskMask = diskMask[0:Y-yBottom, 0:X-xLeft] + yTop = Y + else: + # Disk mask out of bounds on the right + diskMask = self.diskMask.copy() + diskMask = diskMask[:, 0:X-xLeft] + xRight = X + + elif yBottom<0: + # Disk mask out of bounds on top + diskMask = self.diskMask.copy() + diskMask = diskMask[-yBottom:] + yBottom = 0 + + elif yTop>Y: + # Disk mask out of bounds on bottom + diskMask = self.diskMask.copy() + diskMask = diskMask[0:Y-yBottom] + yTop = Y + + else: + # Disk mask fully inside the image + diskMask = self.diskMask + + return yBottom, xLeft, yTop, xRight, diskMask + + def setBrushID(self, useCurrentLab=True, return_val=False): + # Make sure that the brushed ID is always a new one based on + # already visited frames + posData = self.data[self.pos_i] + wl_init = posData.whitelist and posData.whitelist.whitelistIDs + if useCurrentLab: + IDs_tot = set(posData.IDs) + if wl_init: + try: + IDs_tot.update(posData.whitelist.originalLabsIDs[posData.frame_i]) + except: + pass + try: + if posData.whitelist.whitelistIDs[posData.frame_i]: + IDs_tot.update(posData.whitelist.whitelistIDs[posData.frame_i]) + except: + pass + newID = max(IDs_tot, default=0) + else: + newID = 0 + for frame_i, storedData in enumerate(posData.allData_li): + if frame_i == posData.frame_i: + continue + lab = storedData['labels'] + if lab is not None: + rp = storedData['regionprops'] + IDs_tot = {obj.label for obj in rp} + if wl_init: + if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): + IDs_tot.update(posData.whitelist.originalLabsIDs[frame_i]) + if posData.whitelist.whitelistIDs[frame_i]: + IDs_tot.update(posData.whitelist.whitelistIDs[frame_i]) + _max = max(IDs_tot, default=0) + if _max > newID: + newID = _max + else: + break + + for y, x, manual_ID in posData.editID_info: + if manual_ID > newID: + newID = manual_ID + posData.brushID = newID+1 + if return_val: + return posData.brushID + + @disableWindow + def equalizeHist(self, checked=True): + self.img1.useEqualized = checked + + if not checked: + self.updateAllImages() + return + + self.logger.info('Equalizing image histogram...') + for pos_i, _posData in enumerate(self.data): + n_dim_img = _posData.img_data.ndim + _posData.equalized_img_data = preprocess.PreprocessedData() + for frame_i, img_frame in enumerate(_posData.img_data): + if n_dim_img == 4: + for z, img_z in enumerate(img_frame): + eq_img = skimage.exposure.equalize_adapthist(img_z) + _posData.equalized_img_data[frame_i][z] = eq_img + self.img1.updateMinMaxValuesEqualizedData( + self.data, pos_i, frame_i, z + ) + self.img1.updateMinMaxValuesEqualizedDataProjections( + self.data, pos_i, frame_i + ) + else: + eq_img = skimage.exposure.equalize_adapthist(img_frame) + _posData.equalized_img_data[frame_i] = eq_img + self.img1.updateMinMaxValuesEqualizedData( + self.data, pos_i, frame_i, None + ) + + self.updateAllImages() + + def curvTool_cb(self, checked): + posData = self.data[self.pos_i] + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.curvToolButton) + self.connectLeftClickButtons() + self.hoverLinSpace = np.linspace(0, 1, 1000) + self.curvPlotItem = pg.PlotDataItem(pen=self.newIDs_cpen) + self.curvHoverPlotItem = pg.PlotDataItem(pen=self.oldIDs_cpen) + self.curvAnchors = pg.ScatterPlotItem( + symbol='o', size=9, + brush=pg.mkBrush((255,0,0,50)), + pen=pg.mkPen((255,0,0), width=2), + hoverable=True, hoverPen=pg.mkPen((255,0,0), width=3), + hoverBrush=pg.mkBrush((255,0,0)), tip=None + ) + self.ax1.addItem(self.curvAnchors) + self.ax1.addItem(self.curvPlotItem) + self.ax1.addItem(self.curvHoverPlotItem) + self.splineHoverON = True + posData.curvPlotItems.append(self.curvPlotItem) + posData.curvAnchorsItems.append(self.curvAnchors) + posData.curvHoverItems.append(self.curvHoverPlotItem) + else: + self.splineHoverON = False + self.isRightClickDragImg1 = False + self.clearCurvItems() + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + self.showEditIDwidgets(checked) + + def updateHoverLabelCursor(self, x, y): + if x is None: + self.hoverLabelID = 0 + return + + xdata, ydata = int(x), int(y) + Y, X = self.currentLab2D.shape + if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): + return + + ID = self.currentLab2D[ydata, xdata] + self.hoverLabelID = ID + + if ID == 0: + if self.highlightedID != 0: + self.updateAllImages() + self.highlightedID = 0 + return + + if self.app.overrideCursor() != Qt.SizeAllCursor: + self.app.setOverrideCursor(Qt.SizeAllCursor) + + if not self.isMovingLabel: + self.highlightSearchedID(ID) + + def updateEraserCursor(self, x, y, xyLocked=None, isHoverImg1=True): + if x is None: + return + + xdata, ydata = int(x), int(y) + _img = self.currentLab2D + Y, X = _img.shape + + if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): + return + + size = self.brushSizeSpinbox.value()*2 + self.setHoverToolSymbolData( + [x], [y], self.activeEraserCircleCursors(isHoverImg1), + size=size + ) + self.setHoverToolSymbolData( + [x], [y], self.activeEraserXCursors(isHoverImg1), + size=int(size/2) + ) + + isMouseDrag = ( + self.isMouseDragImg1 or self.isMouseDragImg2 + ) + if isMouseDrag: + return + + if xyLocked is not None: + xdata, ydata = xyLocked + + self.setHoverToolSymbolColor( + xdata, ydata, self.eraserCirclePen, + self.activeEraserCircleCursors(isHoverImg1), + self.eraserButton, hoverRGB=None + ) + + def Eraser_cb(self, checked): + if checked: + self.setDiskMask() + self.setHoverToolSymbolData( + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + self.updateEraserCursor(self.xHoverImg, self.yHoverImg) + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + c = self.defaultToolBarButtonColor + self.brushButton.setStyleSheet(f'background-color: {c}') + self.connectLeftClickButtons() + else: + self.setHoverToolSymbolData( + [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX) + ) + self.resetCursors() + self.updateAllImages() + + self.showEditIDwidgets(checked) + self.enableSizeSpinbox(checked) + + def storeCurrentAnnotOptions_ax1(self, return_value=False): + if self.annotOptionsToRestore is not None: + return + + checkboxes = [ + 'annotIDsCheckbox', + 'annotCcaInfoCheckbox', + 'annotContourCheckbox', + 'annotSegmMasksCheckbox', + 'drawMothBudLinesCheckbox', + 'annotNumZslicesCheckbox', + 'drawNothingCheckbox', + ] + annotOptions = {} + for checkboxName in checkboxes: + checkbox = getattr(self, checkboxName) + annotOptions[checkboxName] = checkbox.isChecked() + if return_value: + return annotOptions + self.annotOptionsToRestore = annotOptions + + def storeCurrentAnnotOptions_ax2(self): + if self.annotOptionsToRestoreRight is not None: + return + + checkboxes = [ + 'annotIDsCheckboxRight', + 'annotCcaInfoCheckboxRight', + 'annotContourCheckboxRight', + 'annotSegmMasksCheckboxRight', + 'drawMothBudLinesCheckboxRight', + 'annotNumZslicesCheckboxRight', + 'drawNothingCheckboxRight', + ] + self.annotOptionsToRestoreRight = {} + for checkboxName in checkboxes: + checkbox = getattr(self, checkboxName) + self.annotOptionsToRestoreRight[checkboxName] = checkbox.isChecked() + + def restoreAnnotOptions_ax1(self, options=None): + if options is None and not hasattr(self, 'annotOptionsToRestore'): + return + + if options is None: + options = self.annotOptionsToRestore + + if options is None: + return + + for option, state in options.items(): + checkbox = getattr(self, option) + checkbox.setChecked(state) + + self.setDrawAnnotComboboxText() + self.annotOptionsToRestore = None + + def restoreAnnotOptions_ax2(self): + if not hasattr(self, 'annotOptionsToRestoreRight'): + return + + if self.annotOptionsToRestoreRight is None: + return + + for option, state in self.annotOptionsToRestoreRight.items(): + checkbox = getattr(self, option) + checkbox.setChecked(state) + + self.setDrawAnnotComboboxTextRight() + self.annotOptionsToRestoreRight = None + + def setDrawNothingAnnotations(self): + self.storeCurrentAnnotOptions_ax1() + self.storeCurrentAnnotOptions_ax2() + self.drawNothingCheckbox.setChecked(True) + self.annotOptionClicked( + sender=self.drawNothingCheckbox, saveSettings=False) + self.drawNothingCheckboxRight.setChecked(True) + self.annotOptionClickedRight( + sender=self.drawNothingCheckboxRight, saveSettings=False + ) + + def restoreAnnotationsOptions(self): + self.restoreAnnotOptions_ax1() + self.restoreAnnotOptions_ax2() + + def onDoubleSpaceBar(self): + how = self.drawIDsContComboBox.currentText() + if how.find('nothing') == -1: + self.storeCurrentAnnotOptions_ax1() + self.drawNothingCheckbox.setChecked(True) + self.annotOptionClicked( + sender=self.drawNothingCheckbox, saveSettings=False + ) + else: + self.restoreAnnotOptions_ax1() + + how = self.annotateRightHowCombobox.currentText() + if how.find('nothing') == -1: + self.storeCurrentAnnotOptions_ax2() + self.drawNothingCheckboxRight.setChecked(True) + self.annotOptionClickedRight( + sender=self.drawNothingCheckboxRight, saveSettings=False + ) + else: + self.restoreAnnotOptions_ax2() + + + def resizeBottomLayoutLineClicked(self, event): + pass + + def resizeBottomLayoutLineDragged(self, event): + if not self.img1BottomGroupbox.isVisible(): + return + newBottomLayoutHeight = self.bottomScrollArea.minimumHeight() - event.y() + self.bottomScrollArea.setFixedHeight(newBottomLayoutHeight) + + def resizeBottomLayoutLineReleased(self): + QTimer.singleShot(100, self.autoRange) + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.MouseButton.RightButton: + pos = self.resizeBottomLayoutLine.mapFromGlobal(event.globalPos()) + if pos.y()>=0: + self.gui_raiseBottomLayoutContextMenu(event) + return super().mousePressEvent(event) + + def zoomBottomLayoutActionTriggered(self, checked): + if not checked: + return + perc = int(re.findall(r'(\d+)%', self.sender().text())[0]) + if perc != 100: + fontSizeFactor = perc/100 + heightFactor = perc/100 + self.resizeSlidersArea( + fontSizeFactor=fontSizeFactor, heightFactor=heightFactor + ) + else: + self.gui_resetBottomLayoutHeight() + self.df_settings.at['bottom_sliders_zoom_perc', 'value'] = perc + self.df_settings.to_csv(self.settings_csv_path) + QTimer.singleShot(150, self.resizeGui) + + def defaultRescaleIntensLutActionToggled(self, action): + how = action.text() + for rescaleIntensAction in self.imgGrad.rescaleActionGroup.actions(): + if how == rescaleIntensAction.text(): + rescaleIntensAction.setChecked(True) + rescaleIntensAction.trigger() + break + + for channel, items in self.overlayLayersItems.items(): + lutItem = items[1] + for rescaleIntensAction in lutItem.rescaleActionGroup.actions(): + if how == rescaleIntensAction.text(): + rescaleIntensAction.setChecked(True) + rescaleIntensAction.trigger() + break + + self.df_settings.at['default_rescale_intens_how', 'value'] = how + self.df_settings.to_csv(self.settings_csv_path) + + def retainSpaceSlidersToggled(self, checked): + if checked: + self.df_settings.at['retain_space_hidden_sliders', 'value'] = 'Yes' + else: + self.df_settings.at['retain_space_hidden_sliders', 'value'] = 'No' + self.df_settings.to_csv(self.settings_csv_path) + if not self.zSliceScrollBar.isEnabled(): + retainSpaceZ = False + else: + retainSpaceZ = checked + myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=retainSpaceZ) + myutils.setRetainSizePolicy(self.zProjComboBox, retain=retainSpaceZ) + myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=retainSpaceZ) + myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=retainSpaceZ) + myutils.setRetainSizePolicy(self.overlay_z_label, retain=retainSpaceZ) + + QTimer.singleShot(200, self.resizeGui) + + def resizeLeaveSpaceTerminalBelow(self): + self.setWindowState(Qt.WindowMaximized) + QTimer.singleShot(200, self._resizeLeaveSpaceTerminalBelow) + + def _resizeLeaveSpaceTerminalBelow(self): + geometry = self.geometry() + left = geometry.left() + top = geometry.top() + width = geometry.width() + height = geometry.height() + self.setGeometry(left, top+10, width, height-200) + + def checkSetDelObjActionActive(self, event): + if self.delObjAction is None and self.is_win: + return + + if self.delObjAction is None: + # On mac we check for Key_Control + if event.key() == Qt.Key_Control: + self.delObjToolAction.setChecked(True) + return + + delObjKeySequence, delObjQtButton = self.delObjAction + keySequenceText = widgets.QKeyEventToString(event).rstrip('+') + + if delObjKeySequence is None: + # self.delObjToolAction.setChecked(True) + return + + delObjKeySequenceText = widgets.macShortcutToWindows( + delObjKeySequence.toString() + ) + keySequenceText = widgets.macShortcutToWindows(keySequenceText) + + # printl( + # delObjKeySequence.toString(), + # keySequenceText, + # delObjKeySequenceText + # ) + + if keySequenceText == delObjKeySequenceText: + self.delObjToolAction.setChecked(True) + + def changeRightClickToLeftOnMac(self, mouseEvent): + button = mouseEvent.button() + if not is_mac: + return button + + delObjKeySequence, delObjQtButton = self.delObjAction + if delObjKeySequence is None: + return button + + if not delObjKeySequence.toString() == 'Control': + return button + + if button != Qt.MouseButton.RightButton: + return button + + if delObjQtButton == Qt.MouseButton.LeftButton: + # On mac, pressing "Control" and clicking with left button changes + # it to a right click button --> here, left click is required for + # delete object --> force return of left click + return Qt.MouseButton.LeftButton + + return button + + + def checkTriggerKeyPressShortcuts(self, event: QKeyEvent): + isBrushKey = event.key() == self.brushButton.keyPressShortcut + isEraserKey = event.key() == self.eraserButton.keyPressShortcut + if isBrushKey or isEraserKey: + return isBrushKey, isEraserKey + + modifierText = widgets.modifierKeyToText(event.modifiers()) + for widget in self.widgetsWithShortcut.values(): + if not hasattr(widget, 'keyPressShortcut'): + continue + + if event.key() == widget.keyPressShortcut: + if widget.isCheckable(): + widget.setChecked(True) + else: + widget.trigger() + continue + + shortcutText = widget.keyPressShortcut.toString() + try: + mod, key = shortcutText.split('+') + if modifierText == mod and event.key() == QKeySequence(key): + widget.trigger() + + except Exception as e: + pass + + return isBrushKey, isEraserKey + + def _temp_debug(self, id=None): + posData = self.data[self.pos_i] + imshow(posData.lab, annotate_labels_idxs=[0]) + + def checkOverlayToolbuttonClicked(self, event): + success = False + try: + n = int(event.text()) + toolbutton = self.allOverlayToolbuttonsByIdx.get(n, None) + toolbutton.click() + success = True + except Exception as e: + # printl(traceback.format_exc()) + success = False + return success + + def keyPressCheckSetSpinboxValue(self, event, spinbox): + """Check if the key pressed is a digit and set the spinbox value + accordingly.""" + try: + n = int(event.text()) + if self.typingEditID: + value = int(f'{spinbox.value()}{n}') + else: + value = n + self.typingEditID = True + spinbox.setValue(value) + + try: + spinbox.timer.stop() + except Exception as err: + pass + + spinbox.timer = QTimer(spinbox) + spinbox.timer.timeout.connect( + self.editingSpinboxValueTimerCallback + ) + spinbox.timer.start(2000) + spinbox.timer.setSingleShot(True) + success = True + except Exception as e: + # printl(traceback.format_exc()) + success = False + return success + + def editingSpinboxValueTimerCallback(self): + self.typingEditID = False + + @exception_handler + def keyPressEvent(self, ev): + ctrl = ev.modifiers() == Qt.ControlModifier + if ctrl and ev.key() == Qt.Key_D: + self.resizeLeaveSpaceTerminalBelow() + return + + if ev.key() == Qt.Key_Q and self.debug: + try: + from . import _q_debug + _q_debug.q_debug(self) + except Exception as err: + printl(traceback.format_exc()) + printl('[ERROR]: Error with "_qdebug" module. See Traceback above.') + pass + + if not self.isDataLoaded: + self.logger.warning( + 'Data not loaded yet. Key pressing events are not connected.' + ) + return + + if ev.key() == Qt.Key_Control: + if not ctrl: + self.wasCtrlPressedFirstTime = True + self.onCtrlPressedFirstTime() + + if ev.key() == Qt.Key_PageDown: + self.onKeyPageDown() + + if ev.key() == Qt.Key_PageUp: + self.onKeyPageUp() + + if ev.key() == Qt.Key_Home: + self.onKeyHome() + + if ev.key() == Qt.Key_End: + self.onKeyEnd() + + modifiers = ev.modifiers() + isAltModifier = modifiers == Qt.AltModifier + isCtrlModifier = modifiers == Qt.ControlModifier + isShiftModifier = modifiers == Qt.ShiftModifier + + self.checkSetDelObjActionActive(ev) + + self.isZmodifier = ( + ev.key()== Qt.Key_Z and not isAltModifier + and not isCtrlModifier and not isShiftModifier + ) + if isShiftModifier: + if self.brushButton.isChecked(): + # Force default brush symbol with shift down + self.setHoverToolSymbolColor( + 1, 1, self.ax2_BrushCirclePen, + (self.ax2_BrushCircle, self.ax1_BrushCircle), + self.brushButton, brush=self.ax2_BrushCircleBrush, + ID=0 + ) + if self.isSegm3D: + self.changeBrushID() + + isAnyModifier = isAltModifier or isCtrlModifier or isShiftModifier + if not isAnyModifier and self.overlayButton.isChecked(): + isButtonClicked = self.checkOverlayToolbuttonClicked(ev) + if isButtonClicked: + return + + isBrushActive = ( + self.brushButton.isChecked() or self.eraserButton.isChecked() + ) + isManualTrackingActive = self.manualTrackingButton.isChecked() + isManualBackgroundActive = self.manualBackgroundButton.isChecked() + isTypingIDFunctionChecked = False + if self.brushButton.isChecked() and not self.autoIDcheckbox.isChecked(): + success = self.keyPressCheckSetSpinboxValue(ev, self.editIDspinbox) + isTypingIDFunctionChecked = True + + if isManualTrackingActive: + isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( + ev, self.manualTrackingToolbar.spinboxID + ) + + elif isManualBackgroundActive: + isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( + ev, self.manualBackgroundToolbar.spinboxID + ) + + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + if ( + addPointsByClickingButton is not None + and addPointsByClickingButton.toolbar.isVisible() + ): + isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( + ev, addPointsByClickingButton.rightClickIDSpinbox + ) + + isBrushKey, isEraserKey = self.checkTriggerKeyPressShortcuts(ev) + isExpandLabelActive = self.expandLabelToolButton.isChecked() + isWandActive = self.wandToolButton.isChecked() + isLabelRoiCircActive = ( + self.labelRoiButton.isChecked() + and self.labelRoiIsCircularRadioButton.isChecked() + ) + how = self.drawIDsContComboBox.currentText() + isOverlaySegm = how.find('overlay segm. masks') != -1 + if ev.key()==Qt.Key_Up and not isCtrlModifier: + self.keyUpCallback( + isBrushActive, isWandActive, isExpandLabelActive, + isLabelRoiCircActive + ) + elif ev.key()==Qt.Key_Down and not isCtrlModifier: + self.keyDownCallback( + isBrushActive, isWandActive, isExpandLabelActive, + isLabelRoiCircActive + ) + elif ev.key() == Qt.Key_Enter or ev.key() == Qt.Key_Return: + if isTypingIDFunctionChecked: + self.typingEditID = False + elif self.keepIDsButton.isChecked(): + self.keepIDsConfirmAction.trigger() + elif ev.key() == Qt.Key_Escape: + self.onEscape(isTypingIDFunctionChecked=isTypingIDFunctionChecked) + elif isAltModifier: + isCursorSizeAll = self.app.overrideCursor() == Qt.SizeAllCursor + # Alt is pressed while cursor is on images --> set SizeAllCursor + if self.xHoverImg is not None and not isCursorSizeAll: + self.app.setOverrideCursor(Qt.SizeAllCursor) + elif isCtrlModifier and isOverlaySegm: + if ev.key() == Qt.Key_Up: + val = self.imgGrad.labelsAlphaSlider.value() + delta = 5/self.imgGrad.labelsAlphaSlider.maximum() + val = val+delta + self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) + elif ev.key() == Qt.Key_Down: + val = self.imgGrad.labelsAlphaSlider.value() + delta = 5/self.imgGrad.labelsAlphaSlider.maximum() + val = val-delta + self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) + elif ev.key() == self.zoomOutKeyValue: + self.zoomToCells(enforce=True) + if self.countKeyPress == 0: + self.isKeyDoublePress = False + self.countKeyPress = 1 + self.doubleKeyTimeElapsed = False + self.Button = None + QTimer.singleShot(400, self.doubleKeyTimerCallBack) + elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed: + self.ax1.autoRange() + self.isKeyDoublePress = True + self.countKeyPress = 0 + elif ev.key() == Qt.Key_Space: + if self.countKeyPress == 0: + # Single press --> wait that it's not double press + self.isKeyDoublePress = False + self.countKeyPress = 1 + self.doubleKeyTimeElapsed = False + QTimer.singleShot(300, self.doubleKeySpacebarTimerCallback) + elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed: + self.isKeyDoublePress = True + # Double press --> toggle draw nothing + self.onDoubleSpaceBar() + self.countKeyPress = 0 + elif isBrushKey or isEraserKey: + if isBrushKey: + self.Button = self.brushButton + else: + self.Button = self.eraserButton + + if not self.Button.isVisible(): + return + + if self.countKeyPress == 0: + # If first time clicking B activate brush and start timer + # to catch double press of B + if not self.Button.isChecked(): + self.uncheck = False + self.Button.setChecked(True) + else: + self.uncheck = True + self.countKeyPress = 1 + self.isKeyDoublePress = False + self.doubleKeyTimeElapsed = False + + QTimer.singleShot(400, self.doubleKeyTimerCallBack) + elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed: + self.isKeyDoublePress = True + color = self.Button.palette().button().color().name() + if color == self.doublePressKeyButtonColor: + c = self.defaultToolBarButtonColor + else: + c = self.doublePressKeyButtonColor + self.Button.setStyleSheet(f'background-color: {c}') + self.countKeyPress = 0 + if self.xHoverImg is not None: + xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) + if isBrushKey: + self.setHoverToolSymbolColor( + xdata, ydata, self.ax2_BrushCirclePen, + (self.ax2_BrushCircle, self.ax1_BrushCircle), + self.brushButton, brush=self.ax2_BrushCircleBrush + ) + elif isEraserKey: + self.setHoverToolSymbolColor( + xdata, ydata, self.eraserCirclePen, + (self.ax2_EraserCircle, self.ax1_EraserCircle), + self.eraserButton + ) + + def doubleRightClickTimerCallBack(self): + if self.isDoubleRightClick: + self.doubleRightClickTimeElapsed = False + return + self.doubleRightClickTimeElapsed = True + self.countRightClicks = 0 + + # Time to double right click on img1 expired --> single right-click + self.gui_imgGradShowContextMenu(*self._img1_click_xy) + + def doubleKeyTimerCallBack(self): + if self.isKeyDoublePress: + self.doubleKeyTimeElapsed = False + return + self.doubleKeyTimeElapsed = True + self.countKeyPress = 0 + if self.Button is None: + return + + isBrushChecked = self.Button.isChecked() + if isBrushChecked and self.uncheck: + self.Button.setChecked(False) + c = self.defaultToolBarButtonColor + self.Button.setStyleSheet(f'background-color: {c}') + + def doubleKeySpacebarTimerCallback(self): + if self.isKeyDoublePress: + self.doubleKeyTimeElapsed = False + return + self.doubleKeyTimeElapsed = True + self.countKeyPress = 0 + + # # Spacebar single press --> toggle next visualization + # currentIndex = self.drawIDsContComboBox.currentIndex() + # nItems = self.drawIDsContComboBox.count() + # nextIndex = currentIndex+1 + # if nextIndex < nItems: + # self.drawIDsContComboBox.setCurrentIndex(nextIndex) + # else: + # self.drawIDsContComboBox.setCurrentIndex(0) + + def updateBrushCursorOnShiftRelease(self): + xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) + self.setHoverToolSymbolColor( + xdata, ydata, self.ax2_BrushCirclePen, + (self.ax2_BrushCircle, self.ax1_BrushCircle), + self.brushButton, brush=self.ax2_BrushCircleBrush, + byPassShiftCheck=True + ) + if self.isSegm3D: + self.changeBrushID() + + def onShiftReleased(self): + if self.brushButton.isChecked() and self.xHoverImg is not None: + self.updateBrushCursorOnShiftRelease() + + def keyReleaseEvent(self, ev): + if self.app.overrideCursor() == Qt.SizeAllCursor: + self.app.restoreOverrideCursor() + if ev.key() == Qt.Key_Control: + self.onCtrlReleased() + elif ev.key() == Qt.Key_Shift: + self.onShiftReleased() + + canRepeat = ( + ev.key() == Qt.Key_Left + or ev.key() == Qt.Key_Right + or ev.key() == Qt.Key_Up + or ev.key() == Qt.Key_Down + or ev.key() == Qt.Key_Control + or ev.key() == Qt.Key_Backspace + or self.delObjToolAction.isChecked() + ) + + if canRepeat and ev.isAutoRepeat(): + return + + self.delObjToolAction.setChecked(False) + + if ev.isAutoRepeat() and not ev.key() == Qt.Key_Z: + if self.warnKeyPressedMsg is not None: + return + self.warnKeyPressedMsg = widgets.myMessageBox( + showCentered=False, wrapText=False + ) + txt = html_utils.paragraph(f""" + Please, do not keep the key "{ev.text().upper()}" + pressed.

+ It confuses me :)

+ Thanks! + """) + self.warnKeyPressedMsg.warning(self, 'Release the key, please', txt) + self.warnKeyPressedMsg = None + elif ev.isAutoRepeat() and ev.key() == Qt.Key_Z and self.isZmodifier: + self.zKeptDown = True + elif ev.key() == Qt.Key_Z and self.isZmodifier: + posData = self.data[self.pos_i] + self.isZmodifier = False + if not self.zKeptDown and posData.SizeZ > 1: + self.zSliceCheckbox.setChecked(not self.zSliceCheckbox.isChecked()) + self.zKeptDown = False + + def setUncheckedAllButtons(self, buttonsToNotUncheck=None): + self.clickedOnBud = False + if buttonsToNotUncheck is None: + buttonsToNotUncheck = set() + + try: + self.BudMothTempLine.setData([], []) + except Exception as e: + pass + for button in self.checkableButtons: + if button in buttonsToNotUncheck: + continue + button.setChecked(False) + + if self.countObjsButton not in buttonsToNotUncheck: + self.countObjsButton.setChecked(False) + self.splineHoverON = False + self.tempSegmentON = False + self.isRightClickDragImg1 = False + self.clearCurvItems(removeItems=False) + + def setUncheckedAllCustomAnnotButtons(self): + for button in self.customAnnotDict.keys(): + button.setChecked(False) + + def askPropagateChangePast(self, change_txt): + txt = html_utils.paragraph(f""" + Do you want to propagate the change "{change_txt}" to the past frames? + """) + msg = widgets.myMessageBox(wrapText=False) + yesButton, _ = msg.question( + self, 'Propagate change to past frames', txt, + buttonsTexts=('Yes', 'No') + ) + return msg.clickedButton == yesButton + + def propagateMergeObjsPast(self, IDs_to_merge): + self.store_data(autosave=False) + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + for past_frame_i in range(posData.frame_i-1, -1, -1): + posData.frame_i = past_frame_i + self.get_data() + + IDs = posData.allData_li[past_frame_i]['IDs'] + stop_loop = False + for ID in IDs_to_merge: + if ID not in IDs: + stop_loop = True + break + + if ID == 0: + continue + posData.lab[posData.lab==ID] = self.firstID + self.update_rp() + + self.store_data(autosave=False) + + if stop_loop: + break + + posData.frame_i = current_frame_i + self.get_data() + + def propagateChange( + self, modID, modTxt, doNotShow, UndoFutFrames, + applyFutFrames, applyTrackingB=False, force=False + ): + """ + This function determines whether there are already visited future frames + that contains "modID". If so, it triggers a pop-up asking the user + what to do (propagate change to future frames o not) + """ + posData = self.data[self.pos_i] + # Do not check the future for the last frame + if posData.frame_i+1 == posData.SizeT: + # No future frames to propagate the change to + return False, False, None, doNotShow + + includeUnvisited = posData.includeUnvisitedInfo.get(modTxt, False) + areFutureIDs_affected = [] + # Get number of future frames already visited and check if future + # frames has an ID affected by the change + last_tracked_i_found = False + segmSizeT = len(posData.segm_data) + for i in range(posData.frame_i+1, segmSizeT): + if posData.allData_li[i]['labels'] is None: + if not last_tracked_i_found: + # We set last tracked frame at -1 first None found + last_tracked_i = i - 1 + last_tracked_i_found = True + if not includeUnvisited: + # Stop at last visited frame since includeUnvisited = False + break + else: + lab = posData.segm_data[i] + else: + lab = posData.allData_li[i]['labels'] + + if modID in lab: + areFutureIDs_affected.append(True) + + if not last_tracked_i_found: + # All frames have been visited in segm&track mode + last_tracked_i = posData.SizeT - 1 + + if last_tracked_i == posData.frame_i and not includeUnvisited: + # No future frames to propagate the change to + return False, False, None, doNotShow + + if not areFutureIDs_affected and not force: + # There are future frames but they are not affected by the change + return UndoFutFrames, False, None, doNotShow + + # Ask what to do unless the user has previously checked doNotShowAgain + if doNotShow: + endFrame_i = last_tracked_i + if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': + self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) + return UndoFutFrames, applyFutFrames, endFrame_i, doNotShow + else: + addApplyAllButton = ( + modTxt == 'Delete ID' or modTxt == 'Edit ID' + or modTxt == 'Assign new ID' + ) + ffa = apps.FutureFramesAction_QDialog( + posData.frame_i+1, last_tracked_i, modTxt, + applyTrackingB=applyTrackingB, parent=self, + addApplyAllButton=addApplyAllButton + ) + ffa.exec_() + decision = ffa.decision + + if decision is None: + return None, None, None, doNotShow + + endFrame_i = ffa.endFrame_i + doNotShowAgain = ffa.doNotShowCheckbox.isChecked() + askAction = self.askHowFutureFramesActions[modTxt] + askAction.setChecked( not doNotShowAgain) + askAction.setDisabled(False) + + self.onlyTracking = False + if decision == 'apply_and_reinit': + UndoFutFrames = True + applyFutFrames = False + elif decision == 'apply_and_NOTreinit': + UndoFutFrames = False + applyFutFrames = False + elif decision == 'apply_to_all_visited': + UndoFutFrames = False + applyFutFrames = True + elif decision == 'only_tracking': + UndoFutFrames = False + applyFutFrames = True + self.onlyTracking = True + elif decision == 'apply_to_all': + UndoFutFrames = False + applyFutFrames = True + posData.includeUnvisitedInfo[modTxt] = True + + if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': + self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) + return UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain + + def addCcaState(self, frame_i, cca_df, undoId): + posData = self.data[self.pos_i] + posData.UndoRedoCcaStates[frame_i].insert( + 0, {'id': undoId, 'cca_df': cca_df.copy()} + ) + + def addCurrentState(self, storeImage=False, storeOnlyZoom=False): + posData = self.data[self.pos_i] + if posData.cca_df is not None: + cca_df = posData.cca_df.copy() + else: + cca_df = None + + if storeImage: + image = self.img1.image.copy() + else: + image = None + + if storeOnlyZoom: + labels, crop_slice = transformation.crop_2D( + self.currentLab2D, self.ax1.viewRange(), tolerance=10, + return_copy=False + ) + if self.isSegm3D: + z = self.z_lab(checkIfProj=True) + if z is None: + z_slice = slice(0, len(posData.lab)) + crop_slice = (z_slice, *crop_slice) + labels = posData.lab[crop_slice].copy() + else: + z_slice = z + crop_slice = (z_slice, *crop_slice) + labels = labels.copy() + else: + labels = labels.copy() + else: + labels = posData.lab.copy() + crop_slice = None + + state = { + 'image': image, + 'labels': labels, + 'editID_info': posData.editID_info.copy(), + 'binnedIDs': posData.binnedIDs.copy(), + 'keptObejctsIDs': self.keptObjectsIDs.copy(), + 'ripIDs': posData.ripIDs.copy(), + 'cca_df': cca_df, + 'crop_slice': crop_slice + } + posData.UndoRedoStates[posData.frame_i].insert(0, state) + + # posData.storedLab = np.array(posData.lab, order='K', copy=True) + # self.storeStateWorker.callbackOnDone = callbackOnDone + # self.storeStateWorker.enqueue(posData, self.img1.image) + + def getCurrentState(self): + posData = self.data[self.pos_i] + i = posData.frame_i + c = self.UndoCount + state = posData.UndoRedoStates[i][c] + if state['image'] is None: + image_left = None + else: + image_left = state['image'].copy() + + crop_slice = state['crop_slice'] + if crop_slice is None: + posData.lab = state['labels'].copy() + elif self.isSegm3D: + z_slice, slice_y, slice_x = crop_slice + posData.lab[..., z_slice, slice_y, slice_x] = state['labels'].copy() + else: + slice_y, slice_x = crop_slice + posData.lab[..., slice_y, slice_x] = state['labels'].copy() + + posData.editID_info = state['editID_info'].copy() + posData.binnedIDs = state['binnedIDs'].copy() + posData.ripIDs = state['ripIDs'].copy() + self.keptObjectsIDs = state['keptObejctsIDs'].copy() + cca_df = state['cca_df'] + if cca_df is not None: + posData.cca_df = state['cca_df'].copy() + else: + posData.cca_df = None + return image_left + + def storeLabelRoiParams(self, value=None, checked=True): + checkedRoiType = self.labelRoiTypesGroup.checkedButton().text() + circRoiRadius = self.labelRoiCircularRadiusSpinbox.value() + roiZdepth = self.labelRoiZdepthSpinbox.value() + autoClearBorder = self.labelRoiAutoClearBorderCheckbox.isChecked() + clearBorder = 'Yes' if autoClearBorder else 'No' + self.df_settings.at['labelRoi_checkedRoiType', 'value'] = checkedRoiType + self.df_settings.at['labelRoi_circRoiRadius', 'value'] = circRoiRadius + self.df_settings.at['labelRoi_roiZdepth', 'value'] = roiZdepth + self.df_settings.at['labelRoi_autoClearBorder', 'value'] = clearBorder + self.df_settings.at['labelRoi_replaceExistingObjects', 'value'] = ( + 'Yes' if self.labelRoiReplaceExistingObjectsCheckbox.isChecked() + else 'No' + ) + self.df_settings.to_csv(self.settings_csv_path) + + def loadLabelRoiLastParams(self): + idx = 'labelRoi_checkedRoiType' + if idx in self.df_settings.index: + checkedRoiType = self.df_settings.at[idx, 'value'] + for button in self.labelRoiTypesGroup.buttons(): + if button.text() == checkedRoiType: + button.setChecked(True) + break + + idx = 'labelRoi_circRoiRadius' + if idx in self.df_settings.index: + circRoiRadius = self.df_settings.at[idx, 'value'] + self.labelRoiCircularRadiusSpinbox.setValue(int(circRoiRadius)) + + idx = 'labelRoi_roiZdepth' + if idx in self.df_settings.index: + roiZdepth = self.df_settings.at[idx, 'value'] + self.labelRoiZdepthSpinbox.setValue(int(roiZdepth)) + + idx = 'labelRoi_autoClearBorder' + if idx in self.df_settings.index: + clearBorder = self.df_settings.at[idx, 'value'] + checked = clearBorder == 'Yes' + self.labelRoiAutoClearBorderCheckbox.setChecked(checked) + + idx = 'labelRoi_replaceExistingObjects' + if idx in self.df_settings.index: + val = self.df_settings.at[idx, 'value'] + checked = val == 'Yes' + self.labelRoiReplaceExistingObjectsCheckbox.setChecked(checked) + + if self.labelRoiIsCircularRadioButton.isChecked(): + self.labelRoiCircularRadiusSpinbox.setDisabled(False) + + # @exec_time + def storeUndoRedoStates( + self, UndoFutFrames, storeImage=False, storeOnlyZoom=False + ): + posData = self.data[self.pos_i] + if UndoFutFrames: + # Since we modified current frame all future frames that were already + # visited are not valid anymore. Undo changes there + self.reInitLastSegmFrame(updateImages=False) + + # Keep only 5 Undo/Redo states + if len(posData.UndoRedoStates[posData.frame_i]) > 5: + posData.UndoRedoStates[posData.frame_i].pop(-1) + + # Restart count from the most recent state (index 0) + # NOTE: index 0 is most recent state before doing last change + self.UndoCount = 0 + self.undoAction.setEnabled(True) + self.addCurrentState( + storeImage=storeImage, storeOnlyZoom=storeOnlyZoom + ) + + def storeUndoRedoCca(self, frame_i, cca_df, undoId): + if self.isSnapshot: + # For snapshot mode we don't store anything because we have only + # segmentation undo action active + return + """ + Store current cca_df along with a unique id to know which cca_df needs + to be restored + """ + + posData = self.data[self.pos_i] + + # Restart count from the most recent state (index 0) + # NOTE: index 0 is most recent state before doing last change + self.UndoCcaCount = 0 + self.undoAction.setEnabled(True) + + self.addCcaState(frame_i, cca_df, undoId) + + # Keep only 10 Undo/Redo states + if len(posData.UndoRedoCcaStates[frame_i]) > 10: + posData.UndoRedoCcaStates[frame_i].pop(-1) + + def undoCustomAnnotation(self): + pass + + def UndoCca(self): + posData = self.data[self.pos_i] + # Undo current ccaState + storeState = False + if self.UndoCount == 0: + undoId = uuid.uuid4() + self.addCcaState(posData.frame_i, posData.cca_df, undoId) + storeState = True + + + # Get previously stored state + self.UndoCount += 1 + currentCcaStates = posData.UndoRedoCcaStates[posData.frame_i] + prevCcaState = currentCcaStates[self.UndoCount] + posData.cca_df = prevCcaState['cca_df'] + self.store_cca_df() + self.updateAllImages() + + # Check if we have undone all states + if len(currentCcaStates) > self.UndoCount: + # There are no states left to undo for current frame_i + self.undoAction.setEnabled(False) + + # Undo all past and future frames that has a last status inserted + # when modyfing current frame + prevStateId = prevCcaState['id'] + for frame_i in range(0, posData.SizeT): + if storeState: + cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) + if cca_df_i is None: + break + # Store current state to enable redoing it + self.addCcaState(frame_i, cca_df_i, undoId) + + CcaStates_i = posData.UndoRedoCcaStates[frame_i] + if len(CcaStates_i) <= self.UndoCount: + # There are no states to undo for frame_i + continue + + CcaState_i = CcaStates_i[self.UndoCount] + id_i = CcaState_i['id'] + if id_i != prevStateId: + # The id of the state in frame_i is different from current frame + continue + + cca_df_i = CcaState_i['cca_df'] + self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) + + self.resetWillDivideInfo() + self.enqAutosave() + + def undo(self): + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + if addPointsByClickingButton is not None: + done = self.undoAddPoint(addPointsByClickingButton.action) + if done: + return + + if self.UndoCount == 0: + # Store current state to enable redoing it + self.addCurrentState() + + posData = self.data[self.pos_i] + # Get previously stored state + if self.UndoCount < len(posData.UndoRedoStates[posData.frame_i])-1: + self.UndoCount += 1 + # Since we have undone then it is possible to redo + self.redoAction.setEnabled(True) + + # Restore state + image_left = self.getCurrentState() + self.update_rp() + self.updateAllImages(image=image_left) + self.store_data() + + if not self.UndoCount < len(posData.UndoRedoStates[posData.frame_i])-1: + # We have undone all available states + self.undoAction.setEnabled(False) + + if self.whitelistIDsButton.isChecked(): + self.whitelistHighlightIDs() + + def redo(self): + posData = self.data[self.pos_i] + # Get previously stored state + if self.UndoCount > 0: + self.UndoCount -= 1 + # Since we have redone then it is possible to undo + self.undoAction.setEnabled(True) + + # Restore state + image_left = self.getCurrentState() + self.update_rp() + self.updateAllImages(image=image_left) + self.store_data() + + if not self.UndoCount > 0: + # We have redone all available states + self.redoAction.setEnabled(False) + + if self.whitelistIDsButton.isChecked(): + self.whitelistHighlightIDs() + + def realTimeTrackingClicked(self, checked): + # Event called ONLY if the user click on Disable tracking + # NOT called if setChecked is called. This allows to keep track + # of the user choice. This way user con enforce tracking + # NOTE: I know two booleans doing the same thing is overkill + # but the code is more readable when we actually need them + + posData = self.data[self.pos_i] + isRealTimeTrackingDisabled = not checked + + # Turn off smart tracking + self.enableSmartTrackAction.toggled.disconnect() + self.enableSmartTrackAction.setChecked(False) + if isRealTimeTrackingDisabled: + self.UserEnforced_DisabledTracking = True + self.UserEnforced_Tracking = False + else: + txt = html_utils.paragraph(""" + + Do you want to keep tracking always active including on already + visited frames?

+ Note: To re-activate automatic handling of tracking go to
+ Edit --> Smart handling of enabling/disabling tracking. + + """) + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + yesButton, noButton = msg.question( + self, 'Keep tracking always active?', txt, + buttonsTexts=('Yes', 'No') + ) + if msg.clickedButton == yesButton: + self.repeatTracking() + self.UserEnforced_DisabledTracking = False + self.UserEnforced_Tracking = True + else: + self.enableSmartTrackAction.setChecked(True) + + @exception_handler + def repeatTrackingVideo(self, checked=False): + posData = self.data[self.pos_i] + win = widgets.selectTrackerGUI( + posData.SizeT, currentFrameNo=posData.frame_i+1 + ) + win.exec_() + if win.cancel: + self.logger.info('Tracking aborted.') + return + + trackerName = win.selectedItemsText[0] + start_n = win.startFrame + stop_n = win.stopFrame + video_to_track = posData.segm_data + for frame_i in range(start_n-1, stop_n): + data_dict = posData.allData_li[frame_i] + lab = data_dict['labels'] + if lab is None: + break + + video_to_track[frame_i] = lab + video_to_track = video_to_track[start_n-1:stop_n] + + self.logger.info(f'Importing {trackerName} tracker...') + self.tracker, self.track_params, init_params = myutils.init_tracker( + posData, trackerName, qparent=self, return_init_params=True + ) + if self.track_params is None: + self.logger.info('Tracking aborted.') + return + + warningText = myutils.validate_tracker_input( + self.tracker, video_to_track + ) + if warningText is not None: + self.logger.info(warningText) + self.warnTrackerInputNotValid(trackerName, warningText) + return + + if 'image_channel_name' in self.track_params: + # Remove the channel name since it was already loaded in init_tracker + del self.track_params['image_channel_name'] + + track_params_log = { + key: value for key, value in self.track_params.items() + if key != 'image' + } + self.logger.info( + 'Tracking parameters:\n\n' + f'Initialization parameters: {init_params}\n' + f'Track parameters: {track_params_log}' + ) + + last_cca_i = self.get_last_cca_frame_i() + if start_n-2 <= last_cca_i and start_n>1: + proceed = self.warnRepeatTrackingVideoWithAnnotations( + last_cca_i, start_n + ) + if not proceed: + self.logger.info('Tracking aborted.') + return + + self.logger.info(f'Removing annotations from frame n. {start_n}.') + self.resetCcaFuture(start_n-1) + + self.start_n = start_n + self.stop_n = stop_n + + info_txt = f'Tracking from frame n. {start_n} to {stop_n}...' + self.logger.info(info_txt) + + self.progressWin = apps.QDialogWorkerProgress( + title='Tracking', parent=self, pbarDesc=info_txt + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(stop_n-start_n) + self.startTrackingWorker(posData, video_to_track) + + def warnTrackerInputNotValid(self, trackerName, warningText): + msg = widgets.myMessageBox(wrapText=False) + txt = warningText.replace('\n', '
') + txt = html_utils.paragraph( + f'{txt}

' + 'Tracking process will be cancelled. Thank you for your patience!' + ) + msg.warning(self, 'Invalid input for tracker', txt) + + def repeatTracking(self): + posData = self.data[self.pos_i] + prev_lab = self.get_2Dlab(posData.lab).copy() + self.tracking(enforce=True, DoManualEdit=False) + if posData.editID_info: + editedIDsInfo = { + posData.lab[y,x]:newID + for y, x, newID in posData.editID_info + if posData.lab[y,x] != newID + } + editedIDsInfoItems = [ + f'ID {oldID} --> {newID}' + for oldID, newID in editedIDsInfo.items() + ] + editIDul = html_utils.to_list(editedIDsInfoItems) + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + You requested to repeat tracking but there are manually + edited IDs (see edited IDs in the details section below) +

+ Do you want to keep these edits or ignore them? + """) + keepManualEditButton = widgets.okPushButton( + 'Keep manually edited IDs' + ) + ignoreButton = widgets.noPushButton( + 'Ignore manually edited IDs' + ) + msg.question( + self, 'Repeat tracking mode', txt, + buttonsTexts=(keepManualEditButton, ignoreButton), + detailsText=editIDul + ) + if msg.cancel: + return + if msg.clickedButton == keepManualEditButton: + allIDs = [obj.label for obj in posData.rp] + lab2D = self.get_2Dlab(posData.lab) + self.manuallyEditTracking(lab2D, allIDs) + self.update_rp() + self.setAllTextAnnotations() + self.highlightLostNew() + # self.checkIDsMultiContour() + else: + posData.editID_info = [] + if np.any(posData.lab != prev_lab): + if self.isSnapshot: + self.fixCcaDfAfterEdit('Repeat tracking') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Repeat tracking') + else: + self.updateAllImages() + + def updateGhostMaskOpacity(self, alpha_percentage=None): + if alpha_percentage is None: + alpha_percentage = ( + self.manualTrackingToolbar.ghostMaskOpacitySpinbox.value() + ) + alpha = alpha_percentage/100 + self.ghostMaskItemLeft.setOpacity(alpha) + self.ghostMaskItemRight.setOpacity(alpha) + + def addManualTrackingItems(self): + self.ghostContourItemLeft.addToPlotItem() + self.ghostContourItemRight.addToPlotItem() + + self.ghostMaskItemLeft.addToPlotItem() + self.ghostMaskItemRight.addToPlotItem() + + Y, X = self.img1.image.shape[:2] + self.ghostMaskItemLeft.initImage((Y, X)) + self.ghostMaskItemRight.initImage((Y, X)) + + self.updateGhostMaskOpacity() + + def removeManualTrackingItems(self): + self.ghostContourItemLeft.removeFromPlotItem() + self.ghostContourItemRight.removeFromPlotItem() + + self.ghostMaskItemLeft.removeFromPlotItem() + self.ghostMaskItemRight.removeFromPlotItem() + + def addManualBackgroundItems(self): + self.manualBackgroundObjItem.addToPlotItem() + self.ax1.addItem(self.manualBackgroundImageItem) + + def removeManualBackgroundItems(self): + self.manualBackgroundObjItem.removeFromPlotItem() + self.ax1.removeItem(self.manualBackgroundImageItem) + + def resetManualBackgroundSpinboxID(self): + if not self.manualBackgroundButton.isChecked(): + self.manualBackgroundObj = None + return + + posData = self.data[self.pos_i] + minID = min(posData.IDs, default=0) + self.manualBackgroundToolbar.spinboxID.setValue(minID) + + def initManualBackgroundObject(self, ID=None): + if not self.manualBackgroundButton.isChecked(): + self.manualBackgroundObj = None + return + + if ID is None: + ID = self.manualBackgroundToolbar.spinboxID.value() + + posData = self.data[self.pos_i] + if ID not in posData.IDs: + self.manualBackgroundObj = None + self.manualBackgroundToolbar.showWarning( + f'The ID {ID} does not exist' + ) + self.manualBackgroundObjItem.clear() + return + + ID_idx = posData.IDs_idxs[ID] + self.manualBackgroundObj = posData.rp[ID_idx] + + self.manualBackgroundToolbar.clearInfoText() + self.manualBackgroundObj.contour = self.getObjContours( + self.manualBackgroundObj, local=True + ) + xx_contour = self.manualBackgroundObj.contour[:,0] + yy_contour = self.manualBackgroundObj.contour[:,1] + self.manualBackgroundObj.xx_contour = xx_contour + self.manualBackgroundObj.yy_contour = yy_contour + + def initGhostObject(self, ID=None): + mode = self.modeComboBox.currentText() + if mode != 'Segmentation and Tracking': + self.ghostObject = None + return + + if not self.manualTrackingButton.isChecked(): + self.ghostObject = None + return + + if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): + self.ghostObject = None + return + + if ID is None: + ID = self.manualTrackingToolbar.spinboxID.value() + + posData = self.data[self.pos_i] + if posData.frame_i == 0: + self.ghostObject = None + return + + prevFrameRp = posData.allData_li[posData.frame_i-1]['regionprops'] + if prevFrameRp is None: + self.ghostObject = None + return + + for obj in prevFrameRp: + if obj.label != ID: + continue + self.ghostObject = obj + break + else: + self.ghostObject = None + self.manualTrackingToolbar.showWarning( + f'The ID {ID} does not exist in previous frame ' + '--> starting a new track.' + ) + return + + self.manualTrackingToolbar.clearInfoText() + + self.ghostObject.contour = self.getObjContours( + self.ghostObject, local=True + ) + self.ghostObject.xx_contour = self.ghostObject.contour[:,0] + self.ghostObject.yy_contour = self.ghostObject.contour[:,1] + + self.ghostMaskItemLeft.initLookupTable(self.lut[ID]) + self.ghostMaskItemRight.initLookupTable(self.lut[ID]) + + def clearGhost(self): + self.clearGhostContour() + self.clearGhostMask() + + def clearManualBackgroundAnnotations(self): + try: + for textItem in self.manualBackgroundTextItems.values(): + textItem.setText('') + except Exception as error: + pass + + def clearGhostContour(self): + self.ghostContourItemLeft.clear() + self.ghostContourItemRight.clear() + self.manualBackgroundObjItem.clear() + + def clearGhostMask(self): + self.ghostMaskItemLeft.clear() + self.ghostMaskItemRight.clear() + + @disableWindow + def _importInitMagicPromptModel( + self, model_name, posData, win, acdcPromptSegment, toolbar + ): + self.logger.info(f'Initializing promptable model {model_name}...') + init_kwargs = win.init_kwargs + model = myutils.init_prompt_segm_model( + acdcPromptSegment, posData, win.init_kwargs + ) + toolbar.model = model + toolbar.model_segment_kwargs = win.model_kwargs + toolbar.model_name = model_name + toolbar.viewModelParamsAction.setDisabled(False) + + self.magicPromptsToolbar.setInitializedModel( + init_kwargs, toolbar.model_segment_kwargs + ) + + self.logger.info( + f'Promptable model {model_name} successfully initialised!' + ) + + @exception_handler + def magicPromptsInitModel( + self, + model_name, + acdcPromptSegment, + init_argspecs, + segment_argspecs, + help_url, + toolbar, + ): + posData = self.data[self.pos_i] + + out = prompts.init_prompt_model_params( + posData, model_name, init_argspecs, segment_argspecs, + help_url=help_url, qparent=self, init_last_params=True + ) + win = out.get('win') + if win.cancel: + self.logger.info( + f'Initialization of {model_name} promptable model cancelled.' + ) + return + + self._importInitMagicPromptModel( + model_name, posData, win, acdcPromptSegment, toolbar + ) + + def viewSetMagicPromptModelParams( + self, + model_name, + acdcPromptSegment, + init_argspecs, + segment_argspecs, + help_url, + init_kwargs, + segment_kwargs, + toolbar + ): + posData = self.data[self.pos_i] + + init_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( + init_argspecs, init_kwargs + ) + segment_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( + segment_argspecs, segment_kwargs + ) + + out = prompts.init_prompt_model_params( + posData, model_name, init_argspecs, segment_argspecs, + help_url=help_url, qparent=self, init_last_params=False + ) + win = out.get('win') + if win.cancel: + return + + if win.model_kwargs != segment_kwargs or win.init_kwargs != init_kwargs: + self._importInitMagicPromptModel( + model_name, posData, win, acdcPromptSegment, toolbar + ) + + def getMagicPromptsInputs(self, toolbar): + if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit: + _warnings.warnPromptSegmentPointsLayerNotInit(qparent=self) + return + + if not self.magicPromptsToolbar.viewModelParamsAction.isEnabled(): + _warnings.warnPromptSegmentModelNotInit(qparent=self) + return + + posData = self.data[self.pos_i] + image = self.getDisplayedZstack() + df_points = self.promptSegmentPointsLayerToolbar.pointsLayerDf( + posData, isSegm3D=self.isSegm3D + ) + + self.logger.info( + f'Starting {toolbar.model_name} promptable segmentation with the ' + f'following prompts:\n\n{df_points}' + ) + + return image, df_points + + @disableWindow + def magicPromptsComputeOnZoomTriggered(self, toolbar): + inputs = self.getMagicPromptsInputs(toolbar) + if inputs is None: + self.logger.info( + '"Computing promptable segmentation on zoom" process cancelled.' + ) + return + + posData = self.data[self.pos_i] + image, df_points = inputs + + ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange() + Y, X = image.shape[-2:] + + xmin = int(max(0, xmin)) + xmax = int(min(X, xmax)) + ymin = int(max(0, ymin)) + ymax = int(min(Y, ymax)) + + self.logger.info( + f'Zoom range: xmin={xmin}, xmax={xmax}, ymin={ymin}, ymax={ymax}' + ) + + zoom_slice = (slice(ymin, ymax), slice(xmin, xmax)) + + image = image[..., ymin:ymax, xmin:xmax] + image_origin = (0, ymin, xmin) + + df_points = df_points[df_points['y'] >= ymin] + df_points = df_points[df_points['x'] >= xmin] + df_points = df_points[df_points['y'] < ymax] + df_points = df_points[df_points['x'] < xmax] + + df_points['y'] -= ymin + df_points['x'] -= xmin + + df_points = df_points[ df_points['frame_i'] == posData.frame_i] + + self.logger.info( + f'Image origin = {image_origin}\n' + f'Image shape = {image.shape}' + ) + + self.startMagicPromptsWorkerAndWait( + image, df_points, toolbar.model, toolbar.model_segment_kwargs, + image_origin=image_origin, zoom_slice=zoom_slice + ) + + def magicPromptsInterpolateZsliceToggled(self, checked): + # See 'self.promptSegmentPointsLayerToolbar.addPointsZslicesInterpolation' + self.promptSegmentPointsLayerToolbar.doAddPointsZslicesInterpolation = ( + checked + ) + + def magicPromptsClearPoints(self, toolbar, only_zoom=False): + posData = self.data[self.pos_i] + scatterItem = self.promptSegmentPointsLayerToolbar.scatterItem() + action = scatterItem.action + + pointsDataPos = action.pointsData.get(self.pos_i) + if pointsDataPos is None: + return + + framePointsData = action.pointsData[self.pos_i].pop( + posData.frame_i, None + ) + if framePointsData is None: + return + + if not only_zoom: + scatterItem.clear() + return + + ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange() + Y, X = posData.img_data.shape[-2:] + + xmin = int(max(0, xmin)) + xmax = int(min(X, xmax)) + ymin = int(max(0, ymin)) + ymax = int(min(Y, ymax)) + + if 'x' in framePointsData: + newFramePointsData = {'x': [], 'y': [], 'id': []} + xx = framePointsData['x'] + yy = framePointsData['y'] + ids = framePointsData['id'] + for x, y, point_id in zip(xx, yy, ids): + if x < xmin or x >= xmax or y < ymin or y >= ymax: + newFramePointsData['x'].append(x) + newFramePointsData['y'].append(y) + newFramePointsData['id'].append(point_id) + else: + newFramePointsData = {} + for z, zSliceFramePointsData in framePointsData.items(): + newFramePointsData[z] = {'x': [], 'y': [], 'id': []} + xx = zSliceFramePointsData['x'] + yy = zSliceFramePointsData['y'] + ids = zSliceFramePointsData['id'] + for x, y, point_id in zip(xx, yy, ids): + if x < xmin or x >= xmax or y < ymin or y >= ymax: + newFramePointsData[z]['x'].append(x) + newFramePointsData[z]['y'].append(y) + newFramePointsData[z]['id'].append(point_id) + + action.pointsData[self.pos_i][posData.frame_i] = newFramePointsData + self.drawPointsLayers() + + @disableWindow + def magicPromptsComputeOnImageTriggered(self, toolbar): + inputs = self.getMagicPromptsInputs(toolbar) + if inputs is None: + self.logger.info( + '"Computing promptable segmentation on entire image" ' + 'process cancelled.' + ) + return + + image, df_points = inputs + + self.startMagicPromptsWorkerAndWait( + image, df_points, toolbar.model, toolbar.model_segment_kwargs + ) + + def startMagicPromptsWorkerAndWait( + self, image, df_points, model, model_segment_kwargs, + image_origin=(0, 0, 0), zoom_slice=None + ): + desc = ( + 'Running promptable segmentation model...' + ) + self.logger.info(desc) + posData = self.data[self.pos_i] + + self.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.show(self.app) + + self.magicPromptsThread = QThread() + self.magicPromptsWorker = workers.MagicPromptsWorker( + posData, image, df_points, model, model_segment_kwargs, + image_origin=image_origin, + global_image=posData.img_data[posData.frame_i] + ) + + self.magicPromptsWorker.moveToThread( + self.magicPromptsThread + ) + + self.magicPromptsWorker.signals.finished.connect( + self.magicPromptsThread.quit + ) + self.magicPromptsWorker.signals.finished.connect( + self.magicPromptsWorker.deleteLater + ) + self.magicPromptsThread.finished.connect( + self.magicPromptsThread.deleteLater + ) + + self.magicPromptsWorker.signals.critical.connect( + self.magicPromptsWorkerCritical + ) + self.magicPromptsWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.magicPromptsWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.magicPromptsWorker.signals.progress.connect( + self.workerProgress + ) + self.magicPromptsWorker.signals.finished.connect( + partial(self.magicPromptsWorkerFinished, zoom_slice=zoom_slice) + ) + + self.magicPromptsThread.started.connect( + self.magicPromptsWorker.run + ) + self.magicPromptsThread.start() + + self.magicPromptsWorkerLoop = QEventLoop() + self.magicPromptsWorkerLoop.exec_() + + def magicPromptsWorkerCritical(self, error): + self.magicPromptsWorkerLoop.exit() + self.workerCritical(error) + + def magicPromptsWorkerFinished(self, output, zoom_slice=None): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.magicPromptsWorkerLoop.exit() + + lab_new, lab_union, lab_interesection = output + + posData = self.data[self.pos_i] + + is_zoom = True + if zoom_slice is None: + zoom_slice = (slice(None), slice(None)) + is_zoom = False + + img = ( + posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]] + ) + images = [img, img, img, img] + labels_overlays = [ + posData.lab[..., zoom_slice[0], zoom_slice[1]], + lab_new[..., zoom_slice[0], zoom_slice[1]], + lab_union[..., zoom_slice[0], zoom_slice[1]], + lab_interesection[..., zoom_slice[0], zoom_slice[1]], + ] + labels_overlays_lut = self.getLabelsImageLut() + labels_overlays_luts = [ + labels_overlays_lut, + labels_overlays_lut, + labels_overlays_lut, + labels_overlays_lut, + ] + axis_titles = [ + 'Original masks', + 'New masks', + 'Union of original and new masks', + 'Intersection of original and new masks' + ] + + from cellacdc.plot import imshow + promptSegmResultsWindow = imshow( + *images, + labels_overlays=labels_overlays, + labels_overlays_luts=labels_overlays_luts, + axis_titles=axis_titles, + window_title='Promptable segmentation results', + figure_title='Ctrl+Click to select the result to use', + annotate_labels_idxs=[0, 1, 2, 3], + selectable_images=True, + max_ncols=2, + lut='gray', + infer_rgb=False + ) + if promptSegmResultsWindow.selected_idx is None: + self.logger.info( + 'Selection of the promptable model segmentation ' + 'result cancelled.' + ) + return + + if promptSegmResultsWindow.selected_idx == 0: + self.logger.info( + 'No selection of a promptable model segmentation ' + 'result was made' + ) + return + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + results = (None, lab_new, lab_union, lab_interesection) + selected_idx = promptSegmResultsWindow.selected_idx + zoom_out_lab = results[selected_idx][..., zoom_slice[0], zoom_slice[1]] + zoom_out_lab_mask = zoom_out_lab > 0 + + lab = posData.allData_li[posData.frame_i]['labels'] + lab[..., zoom_slice[0], zoom_slice[1]][zoom_out_lab_mask] = ( + zoom_out_lab[zoom_out_lab_mask] + ) + + posData.allData_li[posData.frame_i]['labels'] = lab + self.get_data() + self.store_data(autosave=False) + self.updateAllImages() + + def manualTracking_cb(self, checked): + self.manualTrackingToolbar.setVisible(checked) + if checked: + self.realTimeTrackingToggle.previousStatus = ( + self.realTimeTrackingToggle.isChecked() + ) + self.realTimeTrackingToggle.setChecked(False) + self.UserEnforced_DisabledTracking_previousStatus = ( + self.UserEnforced_DisabledTracking + ) + self.UserEnforced_Tracking_previousStatus = ( + self.UserEnforced_Tracking + ) + + self.UserEnforced_DisabledTracking = True + self.UserEnforced_Tracking = False + self.initGhostObject() + self.addManualTrackingItems() + else: + self.realTimeTrackingToggle.setChecked( + self.realTimeTrackingToggle.previousStatus + ) + self.UserEnforced_DisabledTracking = ( + self.UserEnforced_DisabledTracking_previousStatus + ) + self.UserEnforced_Tracking = ( + self.UserEnforced_Tracking_previousStatus + ) + self.removeManualTrackingItems() + self.clearGhost() + + def manualBackground_cb(self, checked): + if checked: + posData = self.data[self.pos_i] + minID = min(posData.IDs, default=0) + if minID == self.manualBackgroundToolbar.spinboxID.value(): + self.initManualBackgroundObject() + else: + self.manualBackgroundToolbar.spinboxID.setValue(minID) + # self.initManualBackgroundObject() + # self.initManualBackgroundImage() + self.addManualBackgroundItems() + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.manualBackgroundButton) + self.connectLeftClickButtons() + self.updateAllImages() + else: + self.removeManualTrackingItems() + self.clearGhost() + self.clearManualBackgroundAnnotations() + self.manualBackgroundToolbar.setVisible(checked) + + def autoSegm_cb(self, checked): + if checked: + self.askSegmParam = True + # Ask which model + models = myutils.get_list_of_models() + win = widgets.QDialogListbox( + 'Select model', + 'Select model to use for segmentation: ', + models, + multiSelection=False, + parent=self + ) + win.exec_() + if win.cancel: + return + model_name = win.selectedItemsText[0] + self.segmModelName = model_name + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + self.updateAllImages() + self.computeSegm() + self.askSegmParam = False + else: + self.segmModelName = None + + def postProcessSegm(self, checked): + if self.isSegm3D: + SizeZ = max([posData.SizeZ for posData in self.data]) + else: + SizeZ = None + if checked: + posData = self.data[self.pos_i] + self.postProcessSegmWin = apps.PostProcessSegmDialog( + posData, mainWin=self + ) + self.postProcessSegmWin.sigClosed.connect( + self.postProcessSegmWinClosed + ) + self.postProcessSegmWin.sigValueChanged.connect( + self.postProcessSegmValueChanged + ) + self.postProcessSegmWin.sigEditingFinished.connect( + self.postProcessSegmEditingFinished + ) + self.postProcessSegmWin.sigApplyToAllFutureFrames.connect( + self.postProcessSegmApplyToAllFutureFrames + ) + self.postProcessSegmWin.show() + self.postProcessSegmWin.valueChanged(None) + else: + self.postProcessSegmWin.close() + self.postProcessSegmWin = None + + def postProcessSegmApplyToAllFutureFrames( + self, postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures + ): + proceed = self.warnEditingWithCca_df( + 'post-processing segmentation', update_images=False + ) + if not proceed: + self.logger.info('Post-processing segmentation cancelled.') + return + + self.progressWin = apps.QDialogWorkerProgress( + title='Post-processing segmentation', parent=self, + pbarDesc=f'Post-processing segmentation masks...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + self.startPostProcessSegmWorker( + postProcessKwargs, customPostProcessGroupedFeatures, + customPostProcessFeatures + ) + + def postProcessSegmEditingFinished(self): + self.update_rp() + self.store_data() + self.updateAllImages() + + def postProcessSegmWorkerFinished(self): + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.get_data() + self.updateAllImages() + self.titleLabel.setText('Post-processing segmentation done!', color='w') + self.logger.info('Post-processing segmentation done!') + + def postProcessSegmWinClosed(self): + self.postProcessSegmWin = None + self.postProcessSegmAction.toggled.disconnect() + self.postProcessSegmAction.setChecked(False) + self.postProcessSegmAction.toggled.connect(self.postProcessSegm) + + def postProcessSegmValueChanged(self, lab, delObjs: dict): + for delObj in delObjs.values(): + self.clearObjContour(obj=delObj, ax=0) + self.clearObjContour(obj=delObj, ax=1) + + posData = self.data[self.pos_i] + + labelsToSkip = {} + for ID in posData.IDs: + if ID in delObjs: + labelsToSkip[ID] = True + continue + + restoreObj = self.postProcessSegmWin.origObjs[ID] + self.addObjContourToContoursImage(obj=restoreObj, ax=0) + self.addObjContourToContoursImage(obj=restoreObj, ax=1) + + # self.setAllTextAnnotations(labelsToSkip=labelsToSkip) + + posData.lab = lab + self.setImageImg2() + if self.annotSegmMasksCheckbox.isChecked(): + self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) + if self.annotSegmMasksCheckboxRight.isChecked(): + self.labelsLayerRightImg.setImage(self.currentLab2D, autoLevels=False) + + def readSavedCustomAnnot(self): + tempAnnot = {} + if os.path.exists(custom_annot_path): + self.logger.info('Loading saved custom annotations...') + tempAnnot = load.read_json( + custom_annot_path, logger_func=self.logger.info + ) + + posData = self.data[self.pos_i] + self.savedCustomAnnot = tempAnnot + for pos_i, posData in enumerate(self.data): + self.savedCustomAnnot = { + **self.savedCustomAnnot, **posData.customAnnot + } + + def addCustomAnnotButtonAllLoadedPos(self): + allPosCustomAnnot = {} + for pos_i, posData in enumerate(self.data): + self.addCustomAnnotationSavedPos(pos_i=pos_i) + allPosCustomAnnot = {**allPosCustomAnnot, **posData.customAnnot} + for posData in self.data: + posData.customAnnot = allPosCustomAnnot + + def addCustomAnnotationSavedPos(self, pos_i=None): + if pos_i is None: + pos_i = self.pos_i + + posData = self.data[pos_i] + for name, annotState in posData.customAnnot.items(): + # Check if button is already present and update only annotated IDs + buttons = [b for b in self.customAnnotDict.keys() if b.name==name] + if buttons: + toolButton = buttons[0] + allAnnotedIDs = self.customAnnotDict[toolButton]['annotatedIDs'] + allAnnotedIDs[pos_i] = posData.customAnnotIDs.get(name, {}) + continue + + try: + symbol = re.findall(r"\'(.+)\'", annotState['symbol'])[0] + except Exception as e: + self.logger.info(traceback.format_exc()) + symbol = 'o' + + symbolColor = QColor(*annotState['symbolColor']) + shortcut = annotState['shortcut'] + if shortcut is not None: + keySequence = widgets.macShortcutToWindows(shortcut) + keySequence = widgets.KeySequenceFromText(keySequence) + else: + keySequence = None + toolTip = myutils.getCustomAnnotTooltip(annotState) + keepActive = annotState.get('keepActive', True) + isHideChecked = annotState.get('isHideChecked', True) + + toolButton, action = self.addCustomAnnotationButton( + symbol, symbolColor, keySequence, toolTip, name, + keepActive, isHideChecked + ) + allPosAnnotIDs = [ + pos.customAnnotIDs.get(name, defaultdict(list)) + for pos in self.data + ] + self.customAnnotDict[toolButton] = { + 'action': action, + 'state': annotState, + 'annotatedIDs': allPosAnnotIDs + } + + self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) + + def addCustomAnnotationButton( + self, symbol, symbolColor, keySequence, toolTip, annotName, + keepActive, isHideChecked + ): + toolButton = widgets.customAnnotToolButton( + symbol, symbolColor, parent=self, keepToolActive=keepActive, + isHideChecked=isHideChecked + ) + toolButton.setCheckable(True) + self.checkableQButtonsGroup.addButton(toolButton) + if keySequence is not None: + toolButton.setShortcut(keySequence) + toolButton.setToolTip(toolTip) + toolButton.name = annotName + toolButton.toggled.connect(self.customAnnotButtonToggled) + toolButton.sigRemoveAction.connect(self.removeCustomAnnotButton) + toolButton.sigKeepActiveAction.connect(self.customAnnotKeepActive) + toolButton.sigHideAction.connect(self.customAnnotHide) + toolButton.sigModifyAction.connect(self.customAnnotModify) + action = self.annotateToolbar.addWidget(toolButton) + return toolButton, action + + def addCustomAnnnotScatterPlot( + self, symbolColor, symbol, toolButton + ): + # Add scatter plot item + symbolColorBrush = [0, 0, 0, 50] + symbolColorBrush[:3] = symbolColor.getRgb()[:3] + scatterPlotItem = widgets.CustomAnnotationScatterPlotItem() + scatterPlotItem.setData( + [], [], symbol=symbol, pxMode=False, + brush=pg.mkBrush(symbolColorBrush), size=15, + pen=pg.mkPen(width=3, color=symbolColor), + hoverable=True, hoverBrush=pg.mkBrush(symbolColor), + tip=None + ) + scatterPlotItem.sigHovered.connect(self.customAnnotHovered) + scatterPlotItem.button = toolButton + self.customAnnotDict[toolButton]['scatterPlotItem'] = scatterPlotItem + self.ax1.addItem(scatterPlotItem) + + def addCustomAnnotationItems( + self, symbol, symbolColor, keySequence, toolTip, name, + keepActive, isHideChecked, state + ): + toolButton, action = self.addCustomAnnotationButton( + symbol, symbolColor, keySequence, toolTip, name, + keepActive, isHideChecked + ) + + self.customAnnotDict[toolButton] = { + 'action': action, + 'state': state, + 'annotatedIDs': [defaultdict(list) for _ in range(len(self.data))] + } + + # Save custom annotation to cellacdc/temp/custom_annotations.json + state_to_save = state.copy() + state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) + self.savedCustomAnnot[name] = state_to_save + for posData in self.data: + posData.customAnnot[name] = state_to_save + + # Add scatter plot item + self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) + + customAnnotButton = self.customAnnotDict[toolButton] + allPosAnnotatedIDs = customAnnotButton['annotatedIDs'] + # Add 0s column to acdc_df + for pos_i, posData in enumerate(self.data): + for frame_i, data_dict in enumerate(posData.allData_li): + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + continue + if name not in acdc_df.columns: + acdc_df[name] = 0 + else: + acdc_df[name] = acdc_df[name].astype(int) + acdc_df_annot = acdc_df[acdc_df[name] == 1].reset_index() + annot_IDs = acdc_df_annot['Cell_ID'].to_list() + allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs) + + if posData.acdc_df is not None: + if name not in posData.acdc_df.columns: + posData.acdc_df[name] = 0 + else: + posData.acdc_df[name] = posData.acdc_df[name].astype(int) + acdc_df_annot = ( + posData.acdc_df[posData.acdc_df[name] == 1] + .reset_index() + ) + annot_IDs = acdc_df_annot['Cell_ID'].to_list() + allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs) + + def customAnnotHovered(self, scatterPlotItem, points, event): + # Show tool tip when hovering an annotation with annotation name and ID + vb = scatterPlotItem.getViewBox() + if vb is None: + return + if len(points) > 0: + posData = self.data[self.pos_i] + point = points[0] + x, y = point.pos().x(), point.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + vb.setToolTip( + f'Annotation name: {scatterPlotItem.button.name}\n' + f'ID = {ID}' + ) + else: + vb.setToolTip('') + + def loadCustomAnnotations(self): + items = list(self.savedCustomAnnot.keys()) + if len(items) == 0: + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + There are no custom annotations saved.

+ Click on "Add custom annotation" button to start adding new + annotations. + """) + msg.warning(self, 'No annotations saved', txt) + return + + self.selectAnnotWin = widgets.QDialogListbox( + 'Load previously used custom annotation(s)', + 'Select annotations to load:', items, + additionalButtons=('Delete selected annnotations', ), + parent=self, multiSelection=True + ) + for button in self.selectAnnotWin._additionalButtons: + button.disconnect() + button.clicked.connect(self.deleteSavedAnnotation) + self.selectAnnotWin.exec_() + if self.selectAnnotWin.cancel: + return + + for selectedAnnotName in self.selectAnnotWin.selectedItemsText: + selectedAnnot = self.savedCustomAnnot[selectedAnnotName] + + symbol = selectedAnnot['symbol'] + symbol = re.findall(r"\'(.+)\'", symbol)[0] + symbolColor = selectedAnnot['symbolColor'] + symbolColor = pg.mkColor(symbolColor) + keySequence = widgets.KeySequenceFromText(selectedAnnot['shortcut']) + Type = selectedAnnot['type'] + toolTip = ( + f'Name: {selectedAnnotName}\n\n' + f'Type: {Type}\n\n' + f'Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n' + f'Description: {selectedAnnot["description"]}\n\n' + f'Shortcut: "{keySequence}"' + ) + keepActive = selectedAnnot['keepActive'] + isHideChecked = selectedAnnot['isHideChecked'] + state = { + 'type': Type, + 'name': selectedAnnotName, + 'symbol': selectedAnnot['symbol'], + 'shortcut': selectedAnnot['shortcut'], + 'description': selectedAnnot["description"], + 'keepActive': keepActive, + 'isHideChecked': isHideChecked, + 'symbolColor': symbolColor + } + self.addCustomAnnotationItems( + symbol, symbolColor, keySequence, toolTip, selectedAnnotName, + keepActive, isHideChecked, state + ) + for pos_i, posData in enumerate(self.data): + posData.customAnnot[selectedAnnotName] = selectedAnnot + + self.saveCustomAnnot() + + def deleteSavedAnnotation(self): + for item in self.selectAnnotWin.listBox.selectedItems(): + name = item.text() + self.savedCustomAnnot.pop(name) + self.deleteSelectedAnnot(self.selectAnnotWin.listBox.selectedItems()) + items = list(self.savedCustomAnnot.keys()) + self.selectAnnotWin.listBox.clear() + self.selectAnnotWin.listBox.addItems(items) + + def addCustomAnnotation(self): + self.readSavedCustomAnnot() + + self.addAnnotWin = apps.customAnnotationDialog( + self.savedCustomAnnot, parent=self + ) + self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) + self.addAnnotWin.exec_() + if self.addAnnotWin.cancel: + self.logger.info('Custom annotation process cancelled.') + return + + symbol = self.addAnnotWin.symbol + symbolColor = self.addAnnotWin.state['symbolColor'] + keySequence = self.addAnnotWin.shortcutWidget.widget.keySequence + toolTip = self.addAnnotWin.toolTip + name = self.addAnnotWin.state['name'] + keepActive = self.addAnnotWin.state.get('keepActive', True) + isHideChecked = self.addAnnotWin.state.get('isHideChecked', True) + + proceed = self.checkNameExists(name) + if not proceed: + self.logger.info('Custom annotation process cancelled.') + return + + self.addCustomAnnotationItems( + symbol, symbolColor, keySequence, toolTip, name, + keepActive, isHideChecked, self.addAnnotWin.state + ) + self.saveCustomAnnot() + self.doCustomAnnotation(0) + + def askCustomAnnotationNameExists(self, name): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(f""" + The annotationa called {name} already exists in the + acdc_output CSV file.

+ If you continue, this column will be used to initialize + pre-annotated objects.

+ Do you want to continue? + """ + ) + noButton, yesButton = msg.question( + self, 'Custom annotation name already exists', txt, + buttonsTexts=('No, stop process', 'Yes, use existing column') + ) + return msg.clickedButton == yesButton + + + def checkNameExists(self, name): + posData = self.data[self.pos_i] + for frame_i, data_dict in enumerate(posData.allData_li): + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + continue + if name in acdc_df.columns: + return self.askCustomAnnotationNameExists(name) + + if posData.acdc_df is not None and name in posData.acdc_df.columns: + return self.askCustomAnnotationNameExists(name) + + return True + + + def viewAllCustomAnnot(self, checked): + if not checked: + # Clear all annotations before showing only checked + for button in self.customAnnotDict.keys(): + self.clearScatterPlotCustomAnnotButton(button) + self.doCustomAnnotation(0) + + def clearScatterPlotCustomAnnotButton(self, button): + scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + scatterPlotItem.setData([], []) + + def saveCustomAnnot(self, only_temp=False): + if not hasattr(self, 'savedCustomAnnot'): + return + + if not self.savedCustomAnnot: + return + + # Save to cell acdc temp path + with open(custom_annot_path, mode='w') as file: + json.dump(self.savedCustomAnnot, file, indent=2) + + if only_temp: + return + + self.logger.info('Saving custom annotations parameters...') + # Save to pos path + for _posData in self.data: + _posData.saveCustomAnnotationParams() + + def customAnnotKeepActive(self, button): + self.customAnnotDict[button]['state']['keepActive'] = button.keepToolActive + + def customAnnotHide(self, button): + self.customAnnotDict[button]['state']['isHideChecked'] = button.isHideChecked + clearAnnot = ( + not button.isChecked() and button.isHideChecked + and not self.viewAllCustomAnnotAction.isChecked() + ) + if clearAnnot: + # User checked hide annot with the button not active --> clear + self.clearScatterPlotCustomAnnotButton(button) + elif not button.isChecked(): + # User uncheked hide annot with the button not active --> show + self.doCustomAnnotation(0) + + def deleteSelectedAnnot(self, itemsToDelete): + self.saveCustomAnnot(only_temp=True) + + def customAnnotModify(self, button): + state = self.customAnnotDict[button]['state'] + self.addAnnotWin = apps.customAnnotationDialog( + self.savedCustomAnnot, state=state + ) + self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) + self.addAnnotWin.exec_() + if self.addAnnotWin.cancel: + return + + # Rename column if existing + posData = self.data[self.pos_i] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + if acdc_df is not None: + old_name = self.customAnnotDict[button]['state']['name'] + new_name = self.addAnnotWin.state['name'] + acdc_df = acdc_df.rename(columns={old_name: new_name}) + posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df + + self.customAnnotDict[button]['state'] = self.addAnnotWin.state + + name = self.addAnnotWin.state['name'] + state_to_save = self.addAnnotWin.state.copy() + symbolColor = self.addAnnotWin.state['symbolColor'] + state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) + self.savedCustomAnnot[name] = self.addAnnotWin.state + self.saveCustomAnnot() + + symbol = self.addAnnotWin.symbol + symbolColor = self.customAnnotDict[button]['state']['symbolColor'] + button.setColor(symbolColor) + button.update() + symbolColorBrush = [0, 0, 0, 50] + symbolColorBrush[:3] = symbolColor.getRgb()[:3] + scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + xx, yy = scatterPlotItem.getData() + if xx is None: + xx, yy = [], [] + scatterPlotItem.setData( + xx, yy, symbol=symbol, pxMode=False, + brush=pg.mkBrush(symbolColorBrush), size=15, + pen=pg.mkPen(width=3, color=symbolColor) + ) + + def doCustomAnnotation(self, ID): + mode = self.modeComboBox.currentText() + if not self.isSnapshot and mode != 'Custom annotations': + # Do not show annotations if timelapse and mode not annotations + return + + if self.switchPlaneCombobox.depthAxes() != 'z': + return + + # NOTE: pass 0 for ID to not add + posData = self.data[self.pos_i] + if self.viewAllCustomAnnotAction.isChecked(): + # User requested to show all annotations --> iterate all buttons + # Unless it actively clicked to annotate --> avoid annotating object + # with all the annotations present + buttons = list(self.customAnnotDict.keys()) + else: + # Annotate if the button is active or isHideChecked is False + buttons = [ + b for b in self.customAnnotDict.keys() + if (b.isChecked() or not b.isHideChecked) + ] + if not buttons: + return + + for button in buttons: + annotatedIDs = ( + self.customAnnotDict[button]['annotatedIDs'][self.pos_i] + ) + annotIDs_frame_i = annotatedIDs.get(posData.frame_i, []) + state = self.customAnnotDict[button]['state'] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + + if button.isChecked() and ID > 0: + # Annotate only if existing ID and the button is checked + if ID in annotIDs_frame_i: + annotIDs_frame_i.remove(ID) + acdc_df.at[ID, state['name']] = 0 + elif ID != 0: + annotIDs_frame_i.append(ID) + + annotPerButton = self.customAnnotDict[button] + allAnnotedIDs = annotPerButton['annotatedIDs'] + posAnnotedIDs = allAnnotedIDs[self.pos_i] + posAnnotedIDs[posData.frame_i] = annotIDs_frame_i + + if acdc_df is None: + self.store_data(autosave=False) + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + + xx, yy = [], [] + for annotID in annotIDs_frame_i: + if annotID not in posData.IDs_idxs: + continue + + obj_idx = posData.IDs_idxs[annotID] + obj = posData.rp[obj_idx] + acdc_df.at[annotID, state['name']] = 1 + if not self.isObjVisible(obj.bbox): + continue + y, x = self.getObjCentroid(obj.centroid) + xx.append(x) + yy.append(y) + + scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + scatterPlotItem.setData(xx, yy) + + posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df + + # if self.highlightedID != 0: + # self.highlightedID = 0 + # self.setHighlightID(False) + + if buttons: + return buttons[0] + + def removeCustomAnnotButton(self, button, askHow=True, save=True): + if askHow: + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + Do you want to remove also the column with annotations or + only the annotation button?
+ """) + _, removeOnlyButton, removeColButton = msg.question( + self, 'Remove only button?', txt, + buttonsTexts=( + 'Cancel', 'Remove only button', + ' Remove also column with annotations ' + ) + ) + if msg.cancel: + return + removeOnlyButton = msg.clickedButton == removeOnlyButton + else: + removeOnlyButton = True + + name = self.customAnnotDict[button]['state']['name'] + # remove annotation from position + for posData in self.data: + try: + posData.customAnnot.pop(name) + posData.saveCustomAnnotationParams() + except KeyError as e: + # Current pos doesn't have any annotation button. Continue + continue + + if posData.acdc_df is None: + continue + + if removeOnlyButton: + continue + + posData.acdc_df = posData.acdc_df.drop( + columns=name, errors='ignore' + ) + for frame_i, data_dict in enumerate(posData.allData_li): + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + continue + acdc_df = acdc_df.drop(columns=name, errors='ignore') + posData.allData_li[frame_i]['acdc_df'] = acdc_df + + self.clearScatterPlotCustomAnnotButton(button) + + action = self.customAnnotDict[button]['action'] + self.annotateToolbar.removeAction(action) + self.checkableQButtonsGroup.removeButton(button) + self.customAnnotDict.pop(button) + # self.savedCustomAnnot.pop(name) + + self.saveCustomAnnot(only_temp=True) + + def customAnnotButtonToggled(self, checked): + if checked: + self.customAnnotButton = self.sender() + # Uncheck the other buttons + for button in self.customAnnotDict.keys(): + if button == self.sender(): + continue + + button.toggled.disconnect() + self.clearScatterPlotCustomAnnotButton(button) + button.setChecked(False) + button.toggled.connect(self.customAnnotButtonToggled) + self.doCustomAnnotation(0) + else: + self.customAnnotButton = None + button = self.sender() + clearAnnotation = ( + button.isHideChecked + or not self.viewAllCustomAnnotAction.isChecked() + ) + if clearAnnotation: + self.clearScatterPlotCustomAnnotButton(button) + self.setHighlightID(False) + self.resetCursor() + + def resetCursor(self): + if self.app.overrideCursor() is not None: + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + def segmFrameCallback(self, action): + if action == self.addCustomModelFrameAction: + return + + idx = self.segmActions.index(action) + model_name = self.modelNames[idx] + self.repeatSegm(model_name=model_name, askSegmParams=True) + + def segmVideoCallback(self, action): + if action == self.addCustomModelVideoAction: + return + + posData = self.data[self.pos_i] + win = apps.startStopFramesDialog( + posData.SizeT, currentFrameNum=posData.frame_i+1 + ) + win.exec_() + if win.cancel: + self.logger.info('Segmentation on multiple frames aborted.') + return + + idx = self.segmActionsVideo.index(action) + model_name = self.modelNames[idx] + self.repeatSegmVideo(model_name, win.startFrame, win.stopFrame) + + def segmentToolActionTriggered(self): + if self.segmModelName is None: + win = apps.QDialogSelectModel(parent=self) + win.exec_() + if win.cancel: + self.logger.info('Repeat segmentation cancelled.') + return + model_name = win.selectedModel + self.repeatSegm( + model_name=model_name, askSegmParams=True + ) + else: + self.repeatSegm(model_name=self.segmModelName) + + def initSegmModelParams( + self, model_name, acdcSegment, init_params, segment_params, + is_label_roi=False, initLastParams=False, + extraParams=None, extraParamsTitle=None,ini_filename=None + + ): + posData = self.data[self.pos_i] + try: + url = acdcSegment.url_help() + except AttributeError: + url = None + + text_if_cancelled = 'Segmentation process cancelled.' + out = prompts.init_segm_model_params( + posData, model_name, init_params, segment_params, + help_url=url, qparent=self, init_last_params=initLastParams, + check_sam_embeddings=not is_label_roi, is_gui_caller=True, + extraParams=extraParams,extraParamsTitle=extraParamsTitle, + ini_filename=ini_filename, + ) + if out.get('load_sam_embeddings', False): + self.logger.info('Loading Segment Anything image embeddings...') + for _posData in self.data: + _posData.loadSamEmbeddings(logger_func=None) + text_if_cancelled = 'SAM embeddings loaded.' + + win = out.get('win') + if win is None: + self.logger.info(text_if_cancelled) + self.titleLabel.setText(text_if_cancelled) + return + + if win.cancel: + self.logger.info(text_if_cancelled) + self.titleLabel.setText(text_if_cancelled) + return + + if model_name != 'thresholding': + self.model_kwargs = win.model_kwargs + + return win + + @exception_handler + def repeatSegm( + self, model_name='', askSegmParams=False, is_label_roi=False + ): + if model_name == 'thresholding': + # thresholding model is stored as 'Automatic thresholding' + # at line of code `models.append('Automatic thresholding')` + model_name = 'Automatic thresholding' + + idx = self.modelNames.index(model_name) + # Ask segm parameters if not already set + # and not called by segmSingleFrameMenu (askSegmParams=False) + if not askSegmParams: + askSegmParams = self.model_kwargs is None + + self.downloadWin = apps.downloadModel(model_name, parent=self) + self.downloadWin.download() + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + if model_name == 'Automatic thresholding': + # Automatic thresholding is the name of the models as stored + # in self.modelNames, but the actual model is called thresholding + # (see cellacdc/models/thresholding) + model_name = 'thresholding' + + posData = self.data[self.pos_i] + # Check if model needs to be imported + acdcSegment = self.acdcSegment_li[idx] + if acdcSegment is None: + self.logger.info(f'Importing {model_name}...') + acdcSegment = myutils.import_segment_module(model_name) + self.acdcSegment_li[idx] = acdcSegment + + # Ask parameters if the user clicked on the action + # Otherwise this function is called by "computeSegm" function and + # we use loaded parameters + if askSegmParams: + if self.app.overrideCursor() == Qt.WaitCursor: + self.app.restoreOverrideCursor() + self.segmModelName = model_name + # Read all models parameters + init_params, segment_params = myutils.getModelArgSpec(acdcSegment) + # Prompt user to enter the model parameters + try: + url = acdcSegment.url_help() + except AttributeError: + url = None + + self.preproc_recipe = None + initLastParams = True + if model_name == 'thresholding': + win = apps.QDialogAutomaticThresholding( + parent=self, isSegm3D=self.isSegm3D + ) + win.exec_() + if win.cancel: + return + self.model_kwargs = win.segment_kwargs + thresh_method = self.model_kwargs['threshold_method'] + gauss_sigma = self.model_kwargs['gauss_sigma'] + segment_params = myutils.insertModelArgSpec( + segment_params, 'threshold_method', thresh_method + ) + segment_params = myutils.insertModelArgSpec( + segment_params, 'gauss_sigma', gauss_sigma + ) + initLastParams = False + + win = self.initSegmModelParams( + model_name, acdcSegment, init_params, segment_params, + is_label_roi=is_label_roi, + initLastParams=initLastParams + ) + if win is None: + return + + self.standardPostProcessKwargs = win.standardPostProcessKwargs + self.customPostProcessFeatures = win.customPostProcessFeatures + self.customPostProcessGroupedFeatures = ( + win.customPostProcessGroupedFeatures + ) + self.applyPostProcessing = win.applyPostProcessing + self.secondChannelName = win.secondChannelName + self.preproc_recipe = win.preproc_recipe + + myutils.log_segm_params( + model_name, win.init_kwargs, win.model_kwargs, + logger_func=self.logger.info, + preproc_recipe=win.preproc_recipe, + apply_post_process=self.applyPostProcessing, + standard_postprocess_kwargs=self.standardPostProcessKwargs, + custom_postprocess_features=self.customPostProcessFeatures + ) + + use_gpu = win.init_kwargs.get('gpu', False) + proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) + if not proceed: + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return + + model = myutils.init_segm_model( + acdcSegment, posData, win.init_kwargs + ) + if model is None: + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return + try: + model.setupLogger(self.logger) + except Exception as e: + pass + self.models[idx] = model + model.model_name = model_name + else: + model = self.models[idx] + + if is_label_roi: + return model + + self.titleLabel.setText( + f'Segmenting with {model_name}... ' + '(check progress in terminal/console)', color=self.titleColor + ) + + post_process_params = { + 'applied_postprocessing': self.applyPostProcessing + } + post_process_params = { + **post_process_params, + **self.standardPostProcessKwargs, + **self.customPostProcessFeatures + } + if askSegmParams: + posData.saveSegmHyperparams( + model_name, win.init_kwargs, win.model_kwargs, + post_process_params=post_process_params, + preproc_recipe=self.preproc_recipe + ) + + if self.askRepeatSegment3D: + self.segment3D = False + if self.isSegm3D and self.askRepeatSegment3D: + msg = widgets.myMessageBox(showCentered=False) + msg.addDoNotShowAgainCheckbox(text='Do not ask again') + txt = html_utils.paragraph( + 'Do you want to segment the entire z-stack or only the ' + 'current z-slice?' + ) + _, segment3DButton, _ = msg.question( + self, '3D segmentation?', txt, + buttonsTexts=( + 'Cancel', 'Segment 3D z-stack', 'Segment 2D z-slice' + ) + ) + if msg.cancel: + self.titleLabel.setText('Segmentation process aborted.') + self.logger.info('Segmentation process aborted.') + return + self.segment3D = msg.clickedButton == segment3DButton + if msg.doNotShowAgainCheckbox.isChecked(): + self.askRepeatSegment3D = False + + if self.askZrangeSegm3D: + self.z_range = None + if self.isSegm3D and self.segment3D and self.askZrangeSegm3D: + idx = (posData.filename, posData.frame_i) + try: + orignal_z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + except ValueError as e: + orignal_z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] + selectZtool = apps.QCropZtool( + posData.SizeZ, parent=self, cropButtonText='Ok', + addDoNotShowAgain=True, title='Select z-slice range to segment' + ) + selectZtool.sigZvalueChanged.connect(self.selectZtoolZvalueChanged) + selectZtool.sigCrop.connect(selectZtool.close) + selectZtool.exec_() + self.update_z_slice(orignal_z) + if selectZtool.cancel: + self.titleLabel.setText('Segmentation process aborted.') + self.logger.info('Segmentation process aborted.') + return + startZ = selectZtool.lowerZscrollbar.value() + stopZ = selectZtool.upperZscrollbar.value() + self.z_range = (startZ, stopZ) + if selectZtool.doNotShowAgainCheckbox.isChecked(): + self.askZrangeSegm3D = False + + secondChannelData = None + if self.secondChannelName is not None: + secondChannelData = self.getSecondChannelData() + + self.titleLabel.setText( + f'{model_name} is thinking... ' + '(check progress in terminal/console)', color=self.titleColor + ) + + self.model = model + + self.segmWorkerMutex = QMutex() + self.segmWorkerWaitCond = QWaitCondition() + self.thread = QThread() + self.worker = workers.segmWorker( + self, + secondChannelData=secondChannelData, + mutex=self.segmWorkerMutex, + waitCond=self.segmWorkerWaitCond + ) + self.worker.z_range = self.z_range + self.worker.moveToThread(self.thread) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + if self.debug: + self.worker.debug.connect(self.debugSegmWorker) + self.thread.finished.connect(self.thread.deleteLater) + + # Custom signals + self.worker.critical.connect(self.workerCritical) + self.worker.finished.connect(self.segmWorkerFinished) + + self.thread.started.connect(self.worker.run) + self.thread.start() + + def debugSegmWorker(self, to_debug): + img, _lab, lab = to_debug + printl(img.shape, _lab.shape, lab.shape) + imshow(img, _lab, lab) + self.segmWorkerWaitCond.wakeAll() + + def selectZtoolZvalueChanged(self, whichZ, z): + self.update_z_slice(z) + + @exception_handler + def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): + if model_name == 'thresholding': + # thresholding model is stored as 'Automatic thresholding' + # at line of code `models.append('Automatic thresholding')` + model_name = 'Automatic thresholding' + + idx = self.modelNames.index(model_name) + + self.downloadWin = apps.downloadModel(model_name, parent=self) + self.downloadWin.download() + + if model_name == 'Automatic thresholding': + # Automatic thresholding is the name of the models as stored + # in self.modelNames, but the actual model is called thresholding + # (see cellacdc/models/thresholding) + model_name = 'thresholding' + + posData = self.data[self.pos_i] + # Check if model needs to be imported + acdcSegment = self.acdcSegment_li[idx] + if acdcSegment is None: + self.logger.info(f'Importing {model_name}...') + acdcSegment = myutils.import_segment_module(model_name) + self.acdcSegment_li[idx] = acdcSegment + + # Read all models parameters + init_params, segment_params = myutils.getModelArgSpec(acdcSegment) + # Prompt user to enter the model parameters + try: + url = acdcSegment.url_help() + except AttributeError: + url = None + + if model_name == 'thresholding': + autoThreshWin = apps.QDialogAutomaticThresholding( + parent=self, isSegm3D=self.isSegm3D + ) + autoThreshWin.exec_() + if autoThreshWin.cancel: + return + + win = self.initSegmModelParams( + model_name, acdcSegment, init_params, segment_params + ) + if win is None: + return + + self.standardPostProcessKwargs = win.standardPostProcessKwargs + self.customPostProcessFeatures = win.customPostProcessFeatures + self.customPostProcessGroupedFeatures = ( + win.customPostProcessGroupedFeatures + ) + self.applyPostProcessing = win.applyPostProcessing + self.preproc_recipe = win.preproc_recipe + + myutils.log_segm_params( + model_name, win.init_kwargs, win.model_kwargs, + logger_func=self.logger.info, + preproc_recipe=win.preproc_recipe, + apply_post_process=self.applyPostProcessing, + standard_postprocess_kwargs=self.standardPostProcessKwargs, + custom_postprocess_features=self.customPostProcessFeatures + ) + + secondChannelData = None + if win.secondChannelName is not None: + secondChannelData = self.getSecondChannelData() + + use_gpu = win.init_kwargs.get('gpu', False) + proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) + if not proceed: + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return + + model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) + if model is None: + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return + try: + model.setupLogger(self.logger) + except Exception as e: + pass + + self.extendSegmDataIfNeeded(stopFrameNum) + self.reInitLastSegmFrame( + from_frame_i=startFrameNum-1, updateImages=False + ) + + self.titleLabel.setText( + f'{model_name} is thinking... ' + '(check progress in terminal/console)', color=self.titleColor + ) + + self.progressWin = apps.QDialogWorkerProgress( + title='Segmenting video', parent=self, + pbarDesc=f'Segmenting from frame n. {startFrameNum} to {stopFrameNum}...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(stopFrameNum-startFrameNum) + + self.thread = QThread() + self.worker = workers.segmVideoWorker( + posData, win, model, startFrameNum, stopFrameNum + ) + self.worker.secondChannelData = secondChannelData + self.worker.moveToThread(self.thread) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + # Custom signals + self.worker.critical.connect(self.workerCritical) + self.worker.finished.connect(self.segmVideoWorkerFinished) + self.worker.progressBar.connect(self.workerUpdateProgressbar) + self.worker.progress.connect(self.workerProgress) + + self.thread.started.connect(self.worker.run) + self.thread.start() + + def segmVideoWorkerFinished(self, exec_time): + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.activateAnnotations() + + self.get_data() + self.tracking(enforce=True) + self.updateAllImages() + + txt = f'Done. Segmentation computed in {exec_time:.3f} s' + self.logger.info('-----------------') + self.logger.info(txt) + self.logger.info('=================') + self.titleLabel.setText(txt, color='g') + + @exception_handler + def lazyLoaderCritical(self, error): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.lazyLoader.pause() + raise error + + def ccaIntegrityWorkerCritical(self, error): + try: + raise error + except Exception as err: + self.logger.exception(traceback.format_exc()) + + href = f'GitHub page' + txt = html_utils.paragraph(f""" + Unfortunately the experimental feature + check cell cycle annotations integrity raised a + critical error.

+ Cell-ACDC will now disable this feature to allow you to keep + using the software.

+ However, we kindly ask you to report the issue on our + {href}, thank you very much!

+ Please, include the log file when reporting the issue.

+ Log file location: + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning( + self, 'Experimental feature error', txt, + commands=(self.log_path,), + path_to_browse=self.logs_path + ) + self.disableCcaIntegrityChecker() + + @exception_handler + def workerCritical(self, out: Tuple[QObject, Exception]): + self.setDisabled(False) + try: + worker, error = out + except TypeError as err: + error = out + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.logger.info(error) + try: + worker.thread().quit() + worker.deleteLater() + worker.thread().deleteLater() + except Exception as err: + # Worker already closed + pass + raise error + + def workerLog(self, text): + self.logger.info(text) + + def saveDataWorkerCritical(self, error): + self.logger.warning( + 'Saving process stopped because of critical error.' + ) + self.saveWin.aborted = True + self.worker.finished.emit() + self.workerCritical(error) + + def lazyLoaderWorkerClosed(self): + if self.lazyLoader.salute: + self.logger.info('Cell-ACDC GUI closed.') + self.sigClosed.emit(self) + + self.lazyLoader = None + + def segmWorkerFinished(self, lab, exec_time): + posData = self.data[self.pos_i] + + if posData.segmInfo_df is not None and posData.SizeZ>1: + idx = (posData.filename, posData.frame_i) + posData.segmInfo_df.at[idx, 'resegmented_in_gui'] = True + + if lab.ndim == 2 and self.isSegm3D: + self.set_2Dlab(lab) + else: + posData.lab = lab.copy() + + self.activateAnnotations() + + self.update_rp(wl_update=False) + self.tracking(enforce=True, against_next=posData.frame_i==0) + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Repeat segmentation') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Repeat segmentation') + + txt = f'Done. Segmentation computed in {exec_time:.3f} s' + self.logger.info('-----------------') + self.logger.info(txt) + self.logger.info('=================') + self.titleLabel.setText(txt, color='g') + self.checkIfAutoSegm() + + QTimer.singleShot(200, self.resizeGui) + def activateAnnotations(self): + if self.annotContourCheckbox.isChecked(): + return + if self.annotSegmMasksCheckbox.isChecked(): + return + + self.annotSegmMasksCheckbox.setChecked(True) + self.setDrawAnnotComboboxText() + + # @exec_time + def getDisplayedImg1(self): + return self.img1.image + + def getDisplayedZstack(self): + posData = self.data[self.pos_i] + return posData.img_data[posData.frame_i] + + def autoAssignBud_YeastMate(self): + if not self.is_win: + txt = ( + 'YeastMate is available only on Windows OS.' + 'We are working on expading support also on macOS and Linux.\n\n' + 'Thank you for your patience!' + ) + msg = QMessageBox() + msg.critical( + self, 'Supported only on Windows', txt, msg.Ok + ) + return + + + model_name = 'YeastMate' + idx = self.modelNames.index(model_name) + + self.titleLabel.setText( + f'{model_name} is thinking... ' + '(check progress in terminal/console)', color=self.titleColor + ) + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + posData = self.data[self.pos_i] + # Check if model needs to be imported + acdcSegment = self.acdcSegment_li[idx] + if acdcSegment is None: + acdcSegment = myutils.import_segment_module(model_name) + self.acdcSegment_li[idx] = acdcSegment + + # Read all models parameters + init_params, segment_params = myutils.getModelArgSpec(acdcSegment) + # Prompt user to enter the model parameters + try: + url = acdcSegment.url_help() + except AttributeError: + url = None + + _SizeZ = None + if self.isSegm3D: + _SizeZ = posData.SizeZ + win = apps.QDialogModelParams( + init_params, + segment_params, + model_name, + url=url, + posData=posData, + df_metadata=posData.metadata_df + ) + win.exec_() + if win.cancel: + self.titleLabel.setText('Segmentation aborted.') + return + + use_gpu = win.init_kwargs.get('gpu', False) + proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) + if not proceed: + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return + + self.model_kwargs = win.model_kwargs + model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) + if model is None: + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return + try: + model.setupLogger(self.logger) + except Exception as e: + pass + + self.models[idx] = model + + img = self.getDisplayedImg1() + + posData.cca_df = model.predictCcaState(img, posData.lab) + self.store_data() + self.updateAllImages() + + self.titleLabel.setText('Budding event prediction done.', color='g') + + def isNavigateActionOnNextFrame(self): + posData = self.data[self.pos_i] + if posData.SizeT == 1: + return False + + ax1_coords = self.getMouseDataCoordsRightImage() + if ax1_coords is None: + return False + + if not self.labelsGrad.showNextFrameAction.isEnabled(): + return False + + if not self.labelsGrad.showNextFrameAction.isChecked(): + return + + # Mouse is on right image and next frame action is checked + return True + + def rightImageFramesScrollbarValueChanged(self, value): + img = self.nextFrameImage(current_frame_i=value-2) + self.img1.linkedImageItem.frame_i = value + self.img1.linkedImageItem.setImage(img) + + def nextActionTriggered(self): + if self.isNavigateActionOnNextFrame(): + self.rightImageFramesScrollbar.setValue( + self.rightImageFramesScrollbar.value()+1 + ) + return + + stepAddAction = QAbstractSlider.SliderAction.SliderSingleStepAdd + if self.zKeptDown or self.zSliceCheckbox.isChecked(): + self.zSliceScrollBar.triggerAction(stepAddAction) + else: + self.navigateScrollBar.triggerAction(stepAddAction) + + def prevActionTriggered(self): + if self.isNavigateActionOnNextFrame(): + self.rightImageFramesScrollbar.setValue( + self.rightImageFramesScrollbar.value()-1 + ) + return + + stepSubAction = QAbstractSlider.SliderAction.SliderSingleStepSub + if self.zKeptDown or self.zSliceCheckbox.isChecked(): + self.zSliceScrollBar.triggerAction(stepSubAction) + else: + self.navigateScrollBar.triggerAction(stepSubAction) + + def resetNavigateScrollbar(self): + try: + self.navigateScrollBar.blockSignals(True) + self.navigateScrollBar.actionTriggered.disconnect() + self.navigateScrollBar.sliderReleased.disconnect() + self.navigateScrollBar.sliderMoved.disconnect() + # self.navigateScrollBar.valueChanged.disconnect() + self.navigateScrollBar.setSliderPosition(self.navSpinBox.value()) + except Exception as e: + if "disconnect()" not in str(e): + printl(e) + pass + + self.navigateScrollBar.blockSignals(False) + self.navigateScrollBar.actionTriggered.connect( + self.framesScrollBarActionTriggered + ) + self.navigateScrollBar.sliderReleased.connect(self.framesScrollBarReleased) + self.navigateScrollBar.sliderMoved.connect(self.framesScrollBarMoved) + + @exception_handler + def next_cb(self): + if self.isSnapshot: + self.next_pos() + else: + self.next_frame() + if self.curvToolButton.isChecked(): + self.curvTool_cb(True) + + self.updatePropsWidget('') + + @exception_handler + def prev_cb(self): + if self.isSnapshot: + self.prev_pos() + else: + self.prev_frame() + if self.curvToolButton.isChecked(): + self.curvTool_cb(True) + + self.updatePropsWidget('') + + def zoomOut(self): + self.ax1.autoRange() + + def preprocessActionTriggered(self): + self.preprocessDialog.show() + self.preprocessDialog.raise_() + self.preprocessDialog.activateWindow() + self.preprocessDialog.emitSigPreviewToggled() + + def zoomToObjsActionCallback(self): + self.zoomToCells(enforce=True) + + def zoomToCells(self, enforce=False): + if not self.enableAutoZoomToCellsAction.isChecked() and not enforce: + return + + posData = self.data[self.pos_i] + lab_mask = (self.currentLab2D>0).astype(np.uint8) + rp = skimage.measure.regionprops(lab_mask) + if not rp: + Y, X = lab_mask.shape + xRange = -0.5, X+0.5 + yRange = -0.5, Y+0.5 + else: + obj = rp[0] + min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) + xRange = min_col-10, max_col+10 + yRange = max_row+10, min_row-10 + + self.ax1.setRange(xRange=xRange, yRange=yRange) + + def viewCcaTable(self): + posData = self.data[self.pos_i] + zoomIDs = self.getZoomIDs() + + df = posData.allData_li[posData.frame_i]['acdc_df'] + current_cca_df = posData.cca_df + if zoomIDs is not None: + df = df.loc[zoomIDs] + current_cca_df = current_cca_df.loc[zoomIDs] + + for column in current_cca_df.columns: + header = ( + '================================================\n' + f'CURRENT vs STORED `{column}` column' + f'for frame number {posData.frame_i+1}:\n' + ) + df_compare = current_cca_df[[column]].copy() + df_compare[f'STORED_{column}'] = df[column] + text = f'{header}{df_compare}' + self.logger.info(text) + + if 'cell_cycle_stage' in df.columns: + cca_df = df[self.cca_df_colnames] + cca_df = cca_df.merge( + current_cca_df, how='outer', left_index=True, right_index=True, + suffixes=('_STORED', '_CURRENT') + ) + cca_df = cca_df.reindex(sorted(cca_df.columns), axis=1) + num_cols = len(cca_df.columns) + for j in range(0,num_cols,2): + df_j_x = cca_df.iloc[:,j] + df_j_y = cca_df.iloc[:,j+1] + if any(df_j_x!=df_j_y): + self.logger.info('------------------------') + self.logger.info('DIFFERENCES:') + diff_df = cca_df.iloc[:,j:j+2] + diff_mask = diff_df.iloc[:,0]!=diff_df.iloc[:,1] + self.logger.info(diff_df[diff_mask]) + else: + cca_df = None + self.logger.info(cca_df) + self.logger.info('========================') + if current_cca_df is None: + return + if current_cca_df.empty: + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Cell cycle annotations\' table is empty.
' + ) + msg.warning(self, 'Table empty', txt) + return + + df = posData.add_tree_cols_to_cca_df( + current_cca_df, frame_i=posData.frame_i + ) + if self.ccaTableWin is None: + self.ccaTableWin = apps.ViewCcaTableWindow(df, parent=self) + self.ccaTableWin.show() + self.ccaTableWin.setGeometryWindow() + self.ccaTableWin.sigUpdateCcaTable.connect( + self.onSigUpdateCcaTableWindow + ) + else: + self.ccaTableWin.setFocus() + self.ccaTableWin.activateWindow() + self.ccaTableWin.updateTable(current_cca_df) + + def updateScrollbars(self): + self.updateItemsMousePos() + self.updateFramePosLabel() + posData = self.data[self.pos_i] + navPos = self.pos_i+1 if self.isSnapshot else posData.frame_i+1 + self.navigateScrollBar.setSliderPosition(navPos) + if posData.SizeZ > 1: + self.updateZsliceScrollbar(posData.frame_i) + idx = (posData.filename, posData.frame_i) + self.zSliceScrollBar.setMaximum(posData.SizeZ-1) + self.zSliceSpinbox.setMaximum(posData.SizeZ) + self.SizeZlabel.setText(f'/{posData.SizeZ}') + + def updateItemsMousePos(self): + if self.brushButton.isChecked(): + self.updateBrushCursor(self.xHoverImg, self.yHoverImg) + + if self.eraserButton.isChecked(): + self.updateEraserCursor(self.xHoverImg, self.yHoverImg) + + @exception_handler + def postProcessing(self): + if self.postProcessSegmWin is None: + return + + self.postProcessSegmWin.setPosData() + posData = self.data[self.pos_i] + lab, delIDs = self.postProcessSegmWin.apply() + if posData.allData_li[posData.frame_i]['labels'] is None: + posData.lab = lab.copy() + self.update_rp() + else: + posData.allData_li[posData.frame_i]['labels'] = lab + self.get_data() + + def preprocessDialogRecipeChanged(self, recipe):# why does this need the recepie as an arg + recipe = self.preprocessDialog.recipe() + if recipe is None: + self.logger.warning('Pre-processing recipe not initialized yet.') + return + + self.updatePreprocessPreview(recipe=recipe) + + def debugShowImg(self, img): + imshow(img) + + def preprocessDialogSavePreprocessedData(self, dialog): + posData = self.data[self.pos_i] + + try: + posData.preprocessedDataArray() + except TypeError as e: + if 'Not all frames have been processed.' in str(e): + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Not all frames have been processed.
' + 'Please process all frames before saving.' + ) + msg.warning(self, 'Process all data before saving', txt) + return + + + helpText = ( + """ + The preprocessed image file will be saved with a different + file name.

+ Insert a name to append to the end of the new file name. The rest of + the name will be the same as the original file. + """ + ) + + + win = apps.filenameDialog( + basename=f'{posData.basename}{self.user_ch_name}', + ext=".tif", + hintText='Insert a name for the preprocessed image file:', + defaultEntry='preprocessed', + helpText=helpText, + allowEmpty=False, + parent=dialog + ) + win.exec_() + if win.cancel: + return + + appendedText = win.entryText + + self.progressWin = apps.QDialogWorkerProgress( + title='Saving pre-processed image(s)', + parent=self, + pbarDesc='Saving pre-processed image(s)' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + self.statusBarLabel.setText('Saving pre-processed data...') + + self.savePreprocWorker = workers.SaveProcessedDataWorker( + self.data, appendedText, ext=".tif" + ) + + self.savePreprocThread = QThread() + self.savePreprocWorker.moveToThread(self.savePreprocThread) + self.savePreprocWorker.signals.finished.connect( + self.savePreprocThread.quit + ) + self.savePreprocWorker.signals.finished.connect( + self.savePreprocWorker.deleteLater + ) + self.savePreprocThread.finished.connect( + self.savePreprocThread.deleteLater + ) + + self.savePreprocWorker.signals.critical.connect( + self.workerCritical + ) + self.savePreprocWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.savePreprocWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.savePreprocWorker.signals.progress.connect( + self.workerProgress + ) + self.savePreprocWorker.signals.finished.connect( + self.savePreprocWorkerFinished + ) + + self.savePreprocThread.started.connect( + self.savePreprocWorker.run + ) + self.savePreprocThread.start() + + + def preprocessEnqueueCurrentImage(self, recipe): + posData = self.data[self.pos_i] + func = core.preprocess_image_from_recipe + image_data = self.getImage(raw=True) + if posData.SizeZ > 1: + z_slice = self.z_slice_index() + else: + z_slice = 0 + + recipe = core.validate_multidimensional_recipe(recipe) + + key = (self.pos_i, posData.frame_i, z_slice) + self.preprocWorker.enqueue( + func, + image_data, + recipe, + key + ) + + def getChData(self, requ_ch=None, pos_i=None): + if not pos_i: + pos_i = self.pos_i + + posData = self.data[pos_i] + + if not requ_ch: + requ_ch = set(self.ch_names) + else: + requ_ch = set(requ_ch) + + posData.setLoadedChannelNames() + + loaded_channels = set(posData.loadedChNames) + missing_channels = requ_ch - loaded_channels + + self.loadFluo_cb(fluo_channels=missing_channels) + + def updatePreprocessPreview(self, *args, **kwargs): + force = kwargs.get('force', False) + + if not self.preprocessDialog.isVisible() and not force: + return + + if not self.preprocessDialog.previewCheckbox.isChecked() and not force: + return + + if kwargs.get('recipe') is None: + recipe = self.preprocessDialog.recipe() + else: + recipe = kwargs.get('recipe') + + if recipe is None: + self.logger.warning('Pre-processing recipe not initialized yet.') + return + + txt = 'Pre-processing current image...' + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + self.preprocessEnqueueCurrentImage(recipe) + + def next_pos(self): + self.store_data(debug=True, autosave=False) + prev_pos_i = self.pos_i + if self.pos_i < self.num_pos-1: + self.pos_i += 1 + self.updateSegmDataAutoSaveWorker() + else: + self.logger.info('You reached last position.') + self.pos_i = 0 + self.updatePos() + + def resetManualBackgroundItems(self): + self.initManualBackgroundImage() + self.resetManualBackgroundSpinboxID() + self.drawManualTrackingGhost(self.xHoverImg, self.yHoverImg) + self.drawManualBackgroundObj(self.xHoverImg, self.yHoverImg) + + def clearUndoQueue(self): + posData = self.data[self.pos_i] + self.UndoCount = 0 + self.redoAction.setEnabled(False) + self.undoAction.setEnabled(False) + posData.UndoRedoStates = [[] for _ in range(posData.SizeT)] + posData.UndoRedoCcaStates = [[] for _ in range(posData.SizeT)] + if hasattr(self, 'undoAddPointQueueMapper'): + self.undoAddPointQueueMapper = defaultdict(list) + + def updatePos(self): + self.clearUndoQueue() + self.setStatusBarLabel() + self.checkManageVersions() + self.removeAlldelROIsCurrentFrame() + self.resetManualBackgroundItems() + proceed_cca, never_visited = self.get_data(debug=False) + self.pointsLayerLoadedDfsToData() + self.flushDirtyPointsLayersAutosave() + self.initContoursImage() + self.initDelRoiLab() + self.initTextAnnot() + self.postProcessing() + self.updateScrollbars() + self.updatePreprocessPreview() + self.updateCombineChannelsPreview() + self.updateAllImages() + self.computeSegm() + self.zoomOut() + self.restartZoomAutoPilot() + self.initManualBackgroundObject() + self.updateObjectCounts() + self.updateItemsMousePos() + + def prev_pos(self): + self.store_data(debug=False, autosave=False) + prev_pos_i = self.pos_i + if self.pos_i > 0: + self.pos_i -= 1 + self.updateSegmDataAutoSaveWorker() + else: + self.logger.info('You reached first position.') + self.pos_i = self.num_pos-1 + self.updatePos() + + def updateViewerWindow(self): + if self.slideshowWin is None: + return + + if self.slideshowWin.linkWindow is None: + return + + if not self.slideshowWin.linkWindowCheckbox.isChecked(): + return + + posData = self.data[self.pos_i] + self.slideshowWin.frame_i = posData.frame_i + self.slideshowWin.update_img() + + def warnLostObjects(self, do_warn=True): + if not do_warn: + return True + + if not self.warnLostCellsAction.isChecked(): + return True + + mode = str(self.modeComboBox.currentText()) + if not mode == 'Segmentation and Tracking': + return True + + posData = self.data[self.pos_i] + if not posData.lost_IDs: + return True + + frame_i = posData.frame_i + try: + accepted_lost_IDs = posData.accepted_lost_IDs.get(frame_i, []) + already_accepted_lost = ( + Counter(accepted_lost_IDs) == Counter(posData.lost_IDs) + ) + except AttributeError as err: + already_accepted_lost = False + + if already_accepted_lost: + return True + + self.nextAction.setDisabled(True) + self.prevAction.setDisabled(True) + self.navigateScrollBar.setDisabled(True) + + msg = widgets.myMessageBox() + warn_msg = html_utils.paragraph( + 'Current frame (compared to previous frame) ' + 'has lost the following cells:

' + f'{posData.lost_IDs}

' + 'Are you sure you want to continue?
' + ) + checkBox = QCheckBox('Do not show again') + noButton, yesButton = msg.warning( + self, 'Lost cells!', warn_msg, + buttonsTexts=('No', 'Yes'), + widgets=checkBox + ) + doNotWarnLostCells = not checkBox.isChecked() + self.warnLostCellsAction.setChecked(doNotWarnLostCells) + if msg.clickedButton == noButton: + self.nextAction.setDisabled(False) + self.prevAction.setDisabled(False) + self.navigateScrollBar.setDisabled(False) + return False + + self.nextAction.setDisabled(False) + self.prevAction.setDisabled(False) + self.navigateScrollBar.setDisabled(False) + if not hasattr(posData, 'accepted_lost_IDs'): + posData.accepted_lost_IDs = {} + if frame_i not in posData.accepted_lost_IDs: + posData.accepted_lost_IDs[frame_i] = [] + + posData.accepted_lost_IDs[frame_i].extend(posData.lost_IDs) + # This section is adding the lost cells to tracked_lost_centroids... TBH I dont know why this wasnt done in the first place + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] + accepted_lost_centroids = { + tuple(int(val) for val in prev_rp[prev_IDs_idxs[ID]].centroid) + for ID in posData.lost_IDs + } + try: + posData.tracked_lost_centroids[frame_i] = ( + posData.tracked_lost_centroids[frame_i] | (accepted_lost_centroids) + ) + except KeyError: + posData.tracked_lost_centroids[frame_i] = accepted_lost_centroids + return True + + def askInitCcaFirstFrame(self): + mode = str(self.modeComboBox.currentText()) + if mode != 'Cell cycle analysis': + return True + + posData = self.data[self.pos_i] + if posData.frame_i != 0: + return True + + editCcaWidget = apps.editCcaTableWidget( + posData.cca_df, posData.SizeT, parent=self, + title='Initialize cell cycle annotations' + ) + editCcaWidget.sigApplyChangesFutureFrames.connect( + self.applyManualCcaChangesFutureFrames + ) + editCcaWidget.exec_() + if editCcaWidget.cancel: + self.resetNavigateFramesScrollbar() + return False + + if posData.cca_df is not None: + is_cca_same_as_stored = ( + (posData.cca_df == editCcaWidget.cca_df).all(axis=None) + ) + if not is_cca_same_as_stored: + reinit_cca = self.warnEditingWithCca_df( + 'Re-initialize cell cyle annotations first frame', + return_answer=True + ) + if reinit_cca: + self.resetCcaFuture(0) + + posData.cca_df = editCcaWidget.cca_df + self.store_cca_df() + + return True + + def askInitLinTreeFirstFrame(self): + mode = str(self.modeComboBox.currentText()) + if mode != 'Normal division: Lineage tree': + return True + + posData = self.data[self.pos_i] + if posData.frame_i != 0: + return True + + if self.lineage_tree is None: + self.initLinTree() + + return True + + def checkIfFutureFrameManualAnnotPastFrames(self): + if not self.manualAnnotPastButton.isChecked(): + return True + + posData = self.data[self.pos_i] + frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + if posData.frame_i <= frame_to_restore: + return True + + warn_txt = ( + 'WARNING: Cannot navigate to future frames while in ' + 'manual annotation mode.' + ) + self.logger.info(warn_txt) + self.statusBarLabel.setText(f'

{warn_txt}

') + + return False + + # @exec_time + def next_frame(self, warn=True): + proceed = self.checkIfFutureFrameManualAnnotPastFrames() + if not proceed: + return + + proceed = self.askInitCcaFirstFrame() + if not proceed: + return + + proceed = self.askInitLinTreeFirstFrame() + if not proceed: + return + + mode = str(self.modeComboBox.currentText()) + posData = self.data[self.pos_i] + + if posData.frame_i >= posData.SizeT-1: + # Store data for current frame + if mode != 'Viewer': + self.store_data(debug=False) + msg = 'You reached the last segmented frame!' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + return + + proceed = self.warnLostObjects() + if not proceed: + self.resetNavigateScrollbar() + return + + # Store data for current frame + if mode != 'Viewer': + self.store_data(debug=False) + + self.askLineageTreeChanges() + posData.frame_i += 1 + self.removeAlldelROIsCurrentFrame() + proceed_cca, never_visited = self.get_data() + if not proceed_cca: + posData.frame_i -= 1 + self.get_data() + self.logger.info( + 'No data for current frame. ' + ) + return + + if mode == 'Segmentation and Tracking' or self.isSnapshot: + self.addExistingDelROIs() + + self.updatePreprocessPreview() + self.updateCombineChannelsPreview() + self.postProcessing() + self.tracking(storeUndo=True, wl_update=False) + notEnoughG1Cells, proceed = self.attempt_auto_cca() + if notEnoughG1Cells or not proceed: + posData.frame_i -= 1 + self.get_data() + self.setAllTextAnnotations() + self.logger.info( + 'Not enough G1 cells to compute cell cycle annotations.' + ) + return + + self.store_zslices_rp() + self.resetExpandLabel() + self.updateAllImages() + self.updateHighlightedAxis() + self.updateViewerWindow() + self.updateLastVisitedFrame(last_visited_frame_i=posData.frame_i-1) + self.setNavigateScrollBarMaximum() + self.updateScrollbars() + self.computeSegm() + self.initGhostObject() + self.whitelistPropagateIDs() + self.zoomToCells() + self.updateItemsMousePos() + self.updateObjectCounts() + + self.apply_tools_on_new_frame() + + def apply_tools_on_new_frame(self): + mode = str(self.modeComboBox.currentText()) + if mode != 'Segmentation and Tracking': + return + posData = self.data[self.pos_i] + if not (posData.last_tracked_i <= posData.frame_i) or posData.frame_i == self.lastFrameRanOnFirstVisitTools: + return + + self.lastFrameRanOnFirstVisitTools = posData.frame_i + for name, checkbox in self.applyToolNewFrameActions.items(): + if not checkbox.isChecked(): + continue + + tool_button = self.applyToolNewFrameButtons[name] + try: + if hasattr(tool_button, 'click'): + tool_button.click() + elif hasattr(tool_button, 'trigger'): + tool_button.trigger() + else: + printl( + f"Warning: {name} has no click or trigger method" + ) + except Exception as e: + self.logger.info(f"Error applying tool {name}: {e}") + + @disableWindow + def get_difference_table(self, return_css_separated=False, return_differece=False): + + if self.original_df_lin_tree is None: + return + + posData = self.data[self.pos_i] + + new_df = posData.allData_li[posData.frame_i]['acdc_df'] + original_df = self.original_df_lin_tree.copy() + + if original_df.equals(new_df): + return + + compare_columns = ['parent_ID_tree'] + + new_df = new_df[original_df.columns] + new_df = myutils.checked_reset_index_Cell_ID(new_df) + new_df = new_df[compare_columns] + new_df = new_df.sort_index() + original_df = myutils.checked_reset_index_Cell_ID(original_df) + original_df = original_df[compare_columns] + original_df = original_df.sort_index() + + differences = original_df.compare(new_df) + if differences.empty: + return + + differences = myutils.checked_reset_index_Cell_ID(differences) + + differences = differences['parent_ID_tree'] + differences = differences.reset_index() + + txt = """ + + + + + """ + + for diff in differences.itertuples(): + ID = str(int(diff.Cell_ID)) + old_parent = str(int(diff.self)) + new_parent = str(int(diff.other)) + + txt += f''' + + + + ''' + txt += '
IDold parent -->new parent
{ID}{old_parent}{new_parent}
' + + css = r''' + + ''' + if return_css_separated and not return_differece: + return css, txt + elif return_css_separated and return_differece: + return css, txt, differences + elif not return_css_separated and return_differece: + return txt, differences + else: + txt = css + html_utils.paragraph(txt) + return txt + + def viewLinTreeInfoAction(self): + mode = str(self.modeComboBox.currentText()) + if mode != 'Normal division: Lineage tree': + self.logger.info('This action is only available in the "Normal division: Lineage tree" mode.') + return + + if not self.lineage_tree: + self.logger.info('No lineage tree found.') + return + + posData = self.data[self.pos_i] + + if self.original_df_lin_tree_i != posData.frame_i: + # could be that this is not entirley true and self.curr_original_df_i just didnt get set right though! + txt_changes = '
No changes were made in this frame.

' + + else: + result = self.get_difference_table(return_css_separated=True) + + if result is None: + txt_changes = 'No changes were made in this frame.' + else: + css, txt_changes = result + + txt_changes = 'Changes made in this frame:' + txt_changes + '

' + + cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i) + + if orphan_cells == []: + txt_orphan_cells = 'No orphan Cells!' + else: + txt_orphan_cells = ', '.join([str(cell) for cell in orphan_cells]) + txt_orphan = f'Orphan cells:
{txt_orphan_cells}

' + + lost_cells = list(lost_cells) + if lost_cells == []: + txt_lost_cells = 'No lost Cells!' + else: + txt_lost_cells = ', '.join([str(cell) for cell in lost_cells]) + txt_lost = f'Lost cells:
{txt_lost_cells}

' + + if cells_with_parent == []: + table_cells_with_parent = '
No cells with parents!' + else: + table_cells_with_parent = """ + + + + """ + + for cell, parent in cells_with_parent: + table_cells_with_parent += f''' + + + ''' + table_cells_with_parent += '
Parent IDID
{parent}{cell}
' + + txt_cells_with_parents = f'Cells with parents:{table_cells_with_parent}

' + + css = r''' + + ''' + + txt = css + html_utils.paragraph(txt_changes + txt_orphan + txt_lost + txt_cells_with_parents) + + msg = widgets.myMessageBox() + msg.information(self, + 'lineage tree information', + txt + ) + + @disableWindow + def askLineageTreeChanges(self): + """ + Asks the user for changes in the lineage tree. + + This method is called when the user selects the 'Normal division: Lineage tree' mode. + It compared the backed up df (self.original_df from repeat_click_and_backup) with the current df (self.lineage_tree.export_df(posData.frame_i)) and propts the user to keep, propagate or discard the changes. + + """ + mode = str(self.modeComboBox.currentText()) + if mode != 'Normal division: Lineage tree': + return + + if not self.lineage_tree: + return + + posData = self.data[self.pos_i] + + if self.original_df_lin_tree_i is not None and self.original_df_lin_tree_i != posData.frame_i: + printl("!This should not happen!") + self.store_data(autosave=False) + og_frame = posData.frame_i + posData.frame_i = self.original_df_lin_tree_i + self.get_data() + self.logger.info('Lineage tree changes were not propagated, going back to original frame.') + self.askLineageTreeChanges() + self.store_data(autosave=False) + posData.frame_i = og_frame + self.get_data() + return + + result = self.get_difference_table(return_css_separated=True, return_differece=True) + if result is None: + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + return + + css, txt, differences = result + changed_IDs = differences['Cell_ID'].unique() + + if posData.frame_i == max(self.lineage_tree.frames_for_dfs): + # here we can just propagate the cahnged. This is super fast, since there is no recursion, no children and fast finding of parents + self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + return + + txt = txt + 'Do you want to keep, propgagte or discard the changes?' + txt = css + html_utils.paragraph('Changes made in this frame
' + txt) + + msg = widgets.myMessageBox() + + propagate_btn, discard_btn, _ = msg.question(self, + 'Changes in lineage tree', + txt, + buttonsTexts=('Propagate', 'Discard', 'Cancel'),) + + if msg.clickedButton == propagate_btn: + self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + self.logger.info('Lineage tree propagated.') + + elif msg.clickedButton == discard_btn: + posData.allData_li[posData.frame_i]['acdc_df'] = self.original_df_lin_tree.copy() + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + self.logger.info('Lineage tree changes discarded.') + + + elif msg.cancel: + # Go back to current frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(''' + Changes were kept but not propagated! + Please make sure to come back and propagate them, + otherwise your table might be inconsistent! + There is a button for this next to the edit buttons. + Please also do not visit new frames! + + ''') + msg.warning(self, 'Changes kept but not propagated!', txt) + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + self.logger.info('Lineage tree changes discarded.') + + def manualAnnotRestoreLastTrackedFrame(self, last_tracked_i_to_restore): + if self.navigateScrollBar.maximum()-1 <= last_tracked_i_to_restore: + return + + posData = self.data[self.pos_i] + for frame_i in range(last_tracked_i_to_restore+1, posData.SizeT): + data_frame_i = myutils.get_empty_stored_data_dict() + + data_frame_i['manually_edited_lab'] = ( + posData.allData_li[frame_i]['manually_edited_lab'] + ) + + posData.allData_li[frame_i] = data_frame_i + + self.navigateScrollBar.setMaximum(last_tracked_i_to_restore+1) + self.navSpinBox.setMaximum(last_tracked_i_to_restore+1) + + def setNavigateScrollBarMaximum(self): + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + if mode == 'Segmentation and Tracking': + if posData.last_tracked_i is not None: + if posData.frame_i > posData.last_tracked_i: + self.navigateScrollBar.setMaximum(posData.frame_i+1) + self.navSpinBox.setMaximum(posData.frame_i+1) + else: + self.navigateScrollBar.setMaximum(posData.last_tracked_i+1) + self.navSpinBox.setMaximum(posData.last_tracked_i+1) + else: + self.navigateScrollBar.setMaximum(posData.frame_i+1) + self.navSpinBox.setMaximum(posData.frame_i+1) + + self.updateLastCheckedFrameWidgets(self.navSpinBox.maximum()-1) + elif mode == 'Cell cycle analysis': + if posData.frame_i > self.last_cca_frame_i: + self.navigateScrollBar.setMaximum(posData.frame_i+1) + self.navSpinBox.setMaximum(posData.frame_i+1) + else: + self.navigateScrollBar.setMaximum(self.last_cca_frame_i+1) + self.navSpinBox.setMaximum(self.last_cca_frame_i+1) + self.lastTrackedFrameLabel.setText( + f'Last cc annot. frame n. = {self.navSpinBox.maximum()}' + ) + elif mode == 'Normal division: Lineage tree': + if self.lineage_tree is None: + self.navigateScrollBar.setMaximum(posData.frame_i+1) + self.navSpinBox.setMaximum(posData.frame_i+1) + else: + if self.lineage_tree.frames_for_dfs: + i = max(self.lineage_tree.frames_for_dfs) + else: + i = 0 + self.navigateScrollBar.setMaximum(i+1) + self.navSpinBox.setMaximum(i+1) + + # @exec_time + def prev_frame(self): + posData = self.data[self.pos_i] + if posData.frame_i <= 0: + msg = 'You reached the first frame!' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + return + + # Store data for current frame + mode = str(self.modeComboBox.currentText()) + if mode != 'Viewer': + self.store_data(debug=False) + + self.removeAlldelROIsCurrentFrame() + self.askLineageTreeChanges() + posData.frame_i -= 1 + _, never_visited = self.get_data() + + if mode == 'Segmentation and Tracking' or self.isSnapshot: + self.addExistingDelROIs() + + self.resetExpandLabel() + self.updatePreprocessPreview() + self.updateCombineChannelsPreview() + self.postProcessing() + self.tracking() + self.whitelistPropagateIDs(update_lab=True) + self.updateAllImages() + self.updateScrollbars() + self.updateHighlightedAxis() + self.zoomToCells() + self.initGhostObject() + self.updateViewerWindow() + self.updateItemsMousePos() + self.updateObjectCounts() + + def loadSelectedData(self, user_ch_file_paths, user_ch_name): + data = [] + numPos = len(user_ch_file_paths) + self.user_ch_file_paths = user_ch_file_paths + + self.logger.info(f'Reading {user_ch_name} channel metadata...') + # Get information from first loaded position + posData = load.loadData(user_ch_file_paths[0], user_ch_name, log_func=self.logger.info) + posData.getBasenameAndChNames(qparent=self) + posData.buildPaths() + + if posData.ext != '.h5': + self.lazyLoader.salute = False + self.lazyLoader.exit = True + self.lazyLoaderWaitCond.wakeAll() + self.waitReadH5cond.wakeAll() + + # Get end name of every existing segmentation file + existingSegmEndNames = set() + for filePath in user_ch_file_paths: + _posData = load.loadData(filePath, user_ch_name, log_func=self.logger.info) + _posData.getBasenameAndChNames(qparent=self) + segm_files = load.get_segm_files(_posData.images_path) + _existingEndnames = load.get_endnames( + _posData.basename, segm_files + ) + existingSegmEndNames.update(_existingEndnames) + + selectedSegmEndName = '' + self.newSegmEndName = '' + if self.isNewFile or not existingSegmEndNames: + self.isNewFile = True + # Remove the 'segm_' part to allow filenameDialog to check if + # a new file is existing (since we only ask for the part after + # 'segm_') + existingEndNames = [ + n.replace('segm', '', 1).replace('_', '', 1) + for n in existingSegmEndNames + ] + if posData.basename.endswith('_'): + basename = f'{posData.basename}segm' + else: + basename = f'{posData.basename}_segm' + win = apps.filenameDialog( + basename=basename, + hintText='Insert a filename for the segmentation file:', + existingNames=existingEndNames + ) + win.exec_() + if win.cancel: + self.loadingDataAborted() + return + self.newSegmEndName = win.entryText + else: + if len(existingSegmEndNames) > 0: + win = apps.SelectSegmFileDialog( + existingSegmEndNames, self.exp_path, parent=self, + addNewFileButton=True, basename=posData.basename + ) + win.exec_() + if win.cancel: + self.loadingDataAborted() + return + if win.newSegmEndName is None: + selectedSegmEndName = win.selectedItemText + self.AutoPilotProfile.storeSelectedSegmFile( + selectedSegmEndName + ) + else: + self.newSegmEndName = win.newSegmEndName + self.isNewFile = True + elif len(existingSegmEndNames) == 1: + selectedSegmEndName = list(existingSegmEndNames)[0] + + posData.loadImgData() + + required_ram = posData.getBytesImageData() + if required_ram >= 5e8: + # Disable autosave for data > 500MB + self.autoSaveToggle.setChecked(False) + + proceed = self.checkMemoryRequirements(required_ram) + if not proceed: + self.loadingDataAborted() + return + + posData.loadOtherFiles( + load_segm_data=True, + load_metadata=True, + create_new_segm=self.isNewFile, + new_endname=self.newSegmEndName, + end_filename_segm=selectedSegmEndName, + ) + self.selectedSegmEndName = selectedSegmEndName + self.labelBoolSegm = posData.labelBoolSegm + posData.labelSegmData() + + print('') + self.logger.info( + f'Segmentation filename: {posData.segm_npz_path}' + ) + + proceed = posData.askInputMetadata( + self.num_pos, + ask_SizeT=self.num_pos==1, + ask_TimeIncrement=True, + ask_PhysicalSizes=True, + singlePos=False, + save=True, + warnMultiPos=True + ) + if not proceed: + self.loadingDataAborted() + return + + self.AutoPilotProfile.storeOkAskInputMetadata() + + if posData.isSegm3D is None: + self.isSegm3D = False + else: + self.isSegm3D = posData.isSegm3D + self.SizeT = posData.SizeT + self.SizeZ = posData.SizeZ + self.TimeIncrement = posData.TimeIncrement + self.PhysicalSizeZ = posData.PhysicalSizeZ + self.PhysicalSizeY = posData.PhysicalSizeY + self.PhysicalSizeX = posData.PhysicalSizeX + self.loadSizeS = posData.loadSizeS + self.loadSizeT = posData.loadSizeT + self.loadSizeZ = posData.loadSizeZ + + self.overlayLabelsItems = {} + self.drawModeOverlayLabelsChannels = {} + + self.existingSegmEndNames = existingSegmEndNames + self.createOverlayLabelsContextMenu(existingSegmEndNames) + self.overlayLabelsButtonAction.setVisible(True) + self.createOverlayLabelsItems(existingSegmEndNames) + self.disableNonFunctionalButtons() + + self.isH5chunk = ( + posData.ext == '.h5' + and (self.loadSizeT != self.SizeT + or self.loadSizeZ != self.SizeZ) + ) + + required_ram = posData.checkH5memoryFootprint()*self.loadSizeS + if required_ram > 0: + proceed = self.checkMemoryRequirements(required_ram) + if not proceed: + self.loadingDataAborted() + return + + if posData.SizeT == 1: + self.isSnapshot = True + else: + self.isSnapshot = False + + self.progressWin = apps.QDialogWorkerProgress( + title='Loading data...', parent=self, + pbarDesc=f'Loading "{user_ch_file_paths[0]}"...' + ) + self.progressWin.show(self.app) + + func = partial( + self.startLoadDataWorker, user_ch_file_paths, user_ch_name, + posData + ) + + + QTimer.singleShot(150, func) + + def setManualAnnotModeEnabledTools(self, enabled): + for action in self.editToolBar.actions(): + toolButton = self.editToolBar.widgetForAction(action) + if toolButton in self.manulAnnotToolButtons: + continue + + toolButton.setDisabled(enabled) + action.setDisabled(enabled) + + def disableNonFunctionalButtons(self): + if not self.isSegm3D: + return + + for item in self.functionsNotTested3D: + if hasattr(item, 'action'): + toolButton = item + action = toolButton.action + toolButton.setDisabled(True) + elif hasattr(item, 'toolbar'): + toolbar = item.toolbar + action = item + toolButton = toolbar.widgetForAction(action) + toolButton.setDisabled(True) + else: + action = item + action.setDisabled(True) + + @exception_handler + def startLoadDataWorker( + self, user_ch_file_paths, user_ch_name, firstPosData + ): + self.funcDescription = 'loading data' + + self.guiTabControl.propsQGBox.idSB.setValue(0) + + self.thread = QThread() + self.loadDataMutex = QMutex() + self.loadDataWaitCond = QWaitCondition() + + self.loadDataWorker = workers.loadDataWorker( + self, user_ch_file_paths, user_ch_name, firstPosData + ) + + self.loadDataWorker.moveToThread(self.thread) + self.loadDataWorker.signals.finished.connect(self.thread.quit) + self.loadDataWorker.signals.finished.connect( + self.loadDataWorker.deleteLater + ) + self.thread.finished.connect(self.thread.deleteLater) + + self.loadDataWorker.signals.finished.connect( + self.loadDataWorkerFinished + ) + self.loadDataWorker.signals.progress.connect(self.workerProgress) + self.loadDataWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.loadDataWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.loadDataWorker.signals.critical.connect( + self.workerCritical + ) + self.loadDataWorker.signals.dataIntegrityCritical.connect( + self.loadDataWorkerDataIntegrityCritical + ) + self.loadDataWorker.signals.dataIntegrityWarning.connect( + self.loadDataWorkerDataIntegrityWarning + ) + self.loadDataWorker.signals.sigPermissionError.connect( + self.workerPermissionError + ) + self.loadDataWorker.signals.sigWarnMismatchSegmDataShape.connect( + self.askMismatchSegmDataShape + ) + self.loadDataWorker.signals.sigRecovery.connect( + self.askRecoverNotSavedData + ) + + self.thread.started.connect(self.loadDataWorker.run) + self.thread.start() + + def askRecoverNotSavedData(self, posData): + last_modified_time_unsaved = 'NEVER' + if os.path.exists(posData.segm_npz_temp_path): + recovered_file_path = posData.segm_npz_temp_path + if os.path.exists(posData.segm_npz_path): + last_modified_time_unsaved = ( + datetime.fromtimestamp( + os.path.getmtime(posData.segm_npz_path) + ).strftime("%a %d. %b. %y - %H:%M:%S") + ) + else: + posData.setTempPaths() + if os.path.exists(posData.unsaved_acdc_df_autosave_path): + zip_path = posData.unsaved_acdc_df_autosave_path + with zipfile.ZipFile(zip_path, mode='r') as zip: + csv_names = natsorted(set(zip.namelist())) + iso_key = csv_names[-1][:-4] + most_recent_unsaved_acdc_df_datetime = datetime.strptime( + iso_key, load.ISO_TIMESTAMP_FORMAT + ) + last_modified_time_unsaved = ( + most_recent_unsaved_acdc_df_datetime + ).strftime("%a %d. %b. %y - %H:%M:%S") + + if os.path.exists(posData.acdc_output_csv_path): + acdc_df_mtime = os.path.getmtime(posData.acdc_output_csv_path) + timestamp = datetime.fromtimestamp(acdc_df_mtime) + last_modified_time_saved = timestamp.strftime( + "%a %d. %b. %y - %H:%M:%S" + ) + else: + last_modified_time_saved = 'Null' + + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = html_utils.paragraph(""" + Cell-ACDC detected unsaved data.

+ Do you want to load and recover the unsaved data or + load the data that was last saved by the user? + """) + details = (f""" + The unsaved data was created on {last_modified_time_unsaved}\n\n + The user saved the data last time on {last_modified_time_saved} + """) + msg.setDetailedText(details) + loadUnsavedButton = widgets.reloadPushButton('Recover unsaved data') + loadSavedButton = widgets.savePushButton('Load saved data') + infoButton = widgets.infoPushButton('More info...') + loadSafeNpzButton = '' + if posData.isSafeNpzOverwritePresent(): + loadSafeNpzButton = widgets.reloadPushButton( + 'Load .safe.npz file from crash' + ) + buttons = ( + loadSavedButton, loadUnsavedButton, loadSafeNpzButton, + infoButton + ) + else: + buttons = (loadSavedButton, loadUnsavedButton, infoButton) + msg.question( + self.progressWin, 'Recover unsaved data?', txt, + buttonsTexts=('Cancel', *buttons), + showDialog=False + ) + infoButton.disconnect() + infoButton.clicked.connect(partial(self.showInfoAutosave, posData)) + msg.exec_() + if msg.cancel: + self.loadDataWorker.abort = True + elif msg.clickedButton == loadUnsavedButton: + self.loadDataWorker.loadUnsaved = True + elif msg.clickedButton == loadSafeNpzButton: + self.loadDataWorker.loadSafeOverwriteNpz = True + + self.loadDataWorker.waitCond.wakeAll() + # self.AutoPilotProfile.storeLoadSavedData() + + def showInfoAutosave(self, posData): + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = (f""" + Cell-ACDC either detected unsaved data in a previous session and it + stored it because the Autosave
+ function was active, or it crashed during saving.

+ You can toggle Autosave ON and OFF from the menu on the top menubar + File --> Autosave. + """) + txt = (f""" + {txt}

+ If Cell-ACDC crashed during saving, the segmentation file ending + with .new.npz
+ is present and you might be able to recover the data from there. + """) + + txt = (f""" + {txt}

+ You can find additional recovered data in the following folder: + """) + txt = html_utils.paragraph(txt) + msg.information( + self, 'Autosave info', txt, + path_to_browse=posData.recoveryFolderPath, + commands=(posData.recoveryFolderPath,) + ) + + def askMismatchSegmDataShape(self, posData): + msg = widgets.myMessageBox(wrapText=False) + title = 'Segm. data shape mismatch' + f = '3D' if self.isSegm3D else '2D' + f = f'{f} over time' if posData.SizeT > 1 else f + r = '2D' if self.isSegm3D else '3D' + r = f'{r} over time' if posData.SizeT > 1 else r + text = html_utils.paragraph(f""" + The segmentation masks of the first Position that you loaded is + {f},
+ while {posData.pos_foldername} is {r}.

+ The loaded segmentation masks must be either all 3D + or all 2D.

+ Do you want to skip loading this position or cancel the process? + """) + _, skipPosButton = msg.warning( + self, title, text, buttonsTexts=('Cancel', 'Skip this Position') + ) + if skipPosButton == msg.clickedButton: + self.loadDataWorker.skipPos = True + self.loadDataWorker.waitCond.wakeAll() + + def workerPermissionError(self, txt, waitCond): + msg = widgets.myMessageBox(parent=self) + msg.setIcon(iconName='SP_MessageBoxCritical') + msg.setWindowTitle('Permission denied') + msg.addText(txt) + msg.addButton(' Ok ') + msg.exec_() + waitCond.wakeAll() + + def loadDataWorkerDataIntegrityCritical(self): + errTitle = 'All loaded positions contains frames over time!' + self.titleLabel.setText(errTitle, color='r') + + msg = widgets.myMessageBox(parent=self) + + err_msg = html_utils.paragraph(f""" + {errTitle}.

+ To load data that contains frames over time you have to select + only ONE position. + """) + msg.setIcon(iconName='SP_MessageBoxCritical') + msg.setWindowTitle('Loaded multiple positions with frames!') + msg.addText(err_msg) + msg.addButton('Ok') + msg.show(block=True) + + @exception_handler + def loadDataWorkerFinished(self, data): + self.funcDescription = 'loading data worker finished' + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + if data is None or data=='abort': + self.loadingDataAborted() + return + + if data[0].onlyEditMetadata: + self.loadingDataAborted() + return + + self.pos_i = 0 + self.data = data + self.gui_createGraphicsItems() + return True + + def checkManageVersions(self): + posData = self.data[self.pos_i] + posData.setTempPaths(createFolder=False) + loaded_acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) + + if os.path.exists(posData.recoveryFolderpath()): + self.manageVersionsAction.setDisabled(False) + self.manageVersionsAction.setToolTip( + f'Load an older version of the `{loaded_acdc_df_filename}` file ' + '(table with annotations and measurements).' + ) + else: + self.manageVersionsAction.setDisabled(True) + + def preprocessPreviewToggled(self, checked): + self.viewPreprocDataToggle.setChecked(checked) + self.updatePreprocessPreview() + + + + def preprocessCurrentImage(self, recipe: List[Dict[str, Any]], *args): + txt = 'Pre-processing current image...' + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + func = core.preprocess_image_from_recipe + recipe = core.validate_multidimensional_recipe(recipe) + + image_data = self.getImage(raw=True) + self.preprocWorker.setupJob( + func, + image_data, + recipe, + 'current_image' + ) + + self.preprocWorker.wakeUp() + + def preprocessZStack(self, recipe: List[Dict[str, Any]], *args): + txt = 'Pre-processing z-stack...' + self.statusBarLabel.setText(txt) + self.logger.info(txt) + + posData = self.data[self.pos_i] + func = core.preprocess_zstack_from_recipe + recipe = core.validate_multidimensional_recipe( + recipe, apply_to_all_frames=False + ) + image_data = posData.img_data[posData.frame_i] + self.preprocWorker.setupJob( + func, + image_data, + recipe, + 'z_stack' + ) + + self.preprocWorker.wakeUp() + + def preprocessAllFrames(self, recipe: List[Dict[str, Any]]): + txt = 'Pre-processing all frames...' + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + posData = self.data[self.pos_i] + func = core.preprocess_video_from_recipe + image_data = posData.img_data + self.preprocWorker.setupJob( + func, + image_data, + recipe, + 'all_frames' + ) + self.preprocWorker.wakeUp() + + def preprocessAllPos(self, recipe: List[Dict[str, Any]]): + txt = 'Pre-processing all Positions...' + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + func = core.preprocess_multi_pos_from_recipe + recipe = core.validate_multidimensional_recipe( + recipe, apply_to_all_frames=False + ) + image_data = [posData.img_data[0] for posData in self.data] + self.preprocWorker.setupJob( + func, + image_data, + recipe, + 'all_pos' + ) + + self.preprocWorker.wakeUp() + + def setupPreprocessing(self): + posData = self.data[self.pos_i] + if self.preprocessDialog is not None: + self.preprocessDialog.close() + + self.preprocessDialog = apps.PreProcessRecipeDialog( + isTimelapse=posData.SizeT>1, + isZstack=posData.SizeZ>1, + isMultiPos=len(self.data)>1, + df_metadata=posData.metadata_df, + hideOnClosing=True, + addApplyButton=True, + parent=self + ) + self.doPreviewPreprocImage = False + self.preprocessDialog.sigApplyImage.connect( + self.preprocessCurrentImage + ) + self.preprocessDialog.sigApplyZstack.connect( + self.preprocessZStack + ) + self.preprocessDialog.sigApplyAllFrames.connect( + self.preprocessAllFrames + ) + self.preprocessDialog.sigApplyAllPos.connect( + self.preprocessAllPos + ) + self.preprocessDialog.sigPreviewToggled.connect( + self.preprocessPreviewToggled + ) + self.preprocessDialog.sigValuesChanged.connect( + self.preprocessDialogRecipeChanged + ) + self.preprocessDialog.sigSavePreprocData.connect( + self.preprocessDialogSavePreprocessedData + ) + + if self.preprocWorker is not None: + return + + self.preprocThread = QThread() + self.preprocMutex = QMutex() + self.preprocWaitCond = QWaitCondition() + + self.preprocWorker = workers.CustomPreprocessWorkerGUI( + self.preprocMutex, self.preprocWaitCond + ) + + self.preprocWorker.moveToThread(self.preprocThread) + self.preprocWorker.signals.finished.connect(self.preprocThread.quit) + self.preprocWorker.signals.finished.connect( + self.preprocWorker.deleteLater + ) + self.preprocThread.finished.connect(self.preprocThread.deleteLater) + + self.preprocWorker.sigDone.connect(self.preprocWorkerDone) + self.preprocWorker.sigIsQueueEmpty.connect( + self.preprocWorkerIsQueueEmpty + ) + self.preprocWorker.sigPreviewDone.connect(self.preprocWorkerPreviewDone) + self.preprocWorker.signals.progress.connect(self.workerProgress) + self.preprocWorker.signals.critical.connect(self.workerCritical) + self.preprocWorker.signals.finished.connect(self.preprocWorkerClosed) + + self.preprocThread.started.connect(self.preprocWorker.run) + self.preprocThread.start() + + self.logger.info('Pre-processing worker started.') + + def preprocWorkerCritical(self, error): + self.preprocessDialog.appliedFinished() + self.workerCritical(error) + + @exception_handler + def loadingDataCompleted(self): + self.isDataLoading = True + posData = self.data[self.pos_i] + + files_format = '\n'.join([ + f' - {file}' for file in posData.images_folder_files + ]) + sep = '-'*100 + self.logger.info( + f'{sep}\nFiles present in the first Position folder loaded:\n\n' + f'{files_format}\n{sep}' + ) + self.logger.info(f'Basename of the first Position: {posData.basename}') + self.secondLevelToolbar.setVisible(True) + self.updateImageValueFormatter() + self.checkManageVersions() + self.initManualBackgroundImage() + self.initPixelSizePropsDockWidget() + + self.setWindowTitle( + f'Cell-ACDC v{self._acdc_version} - GUI - "{posData.exp_path}"' + ) + + self.setupPreprocessing() + self.setupCombiningChannels() + + if self.isSegm3D: + self.segmNdimIndicator.setText('3D') + else: + self.segmNdimIndicator.setText('2D') + + self.segmNdimIndicatorAction.setVisible(True) + + self.guiTabControl.addChannels([posData.user_ch_name]) + self.showPropsDockButton.setDisabled(False) + + self.bottomScrollArea.show() + self.gui_createStoreStateWorker() + self.init_segmInfo_df() + self.connectScrollbars() + self.initPosAttr() + + self.logger.info('Pre-computing min and max values of the images...') + self.img1.preComputedMinMaxValues(self.data) + self.img2.minMaxValuesMapper = self.img1.minMaxValuesMapper + + self.initMetrics() + self.initFluoData() + self.createChannelNamesActions() + self.addActionsLutItemContextMenu(self.imgGrad) + + # Scrollbar for opacity of img1 (when overlaying) + self.img1.alphaScrollbar = self.addAlphaScrollbar( + self.user_ch_name, self.img1 + ) + + self.navigateScrollBar.setSliderPosition(posData.frame_i+1) + + # Connect events at the end of loading data process + self.gui_connectGraphicsEvents() + if not self.isEditActionsConnected: + self.gui_connectEditActions() + self.normalizeToFloatAction.setChecked(True) + + self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) + + self.setFramesSnapshotMode() + if self.isSnapshot: + self.navSizeLabel.setText(f'/{len(self.data)}') + else: + self.navSizeLabel.setText(f'/{posData.SizeT}') + + self.enableZstackWidgets(posData.SizeZ > 1) + # self.showHighlightZneighCheckbox() + + self.exportToVideoAction.setDisabled( + posData.SizeZ == 1 and posData.SizeT == 1 + ) + + self.img1BottomGroupbox.show() + + isLabVisible = self.df_settings.at['isLabelsVisible', 'value'] == 'Yes' + isRightImgVisible = ( + self.df_settings.at['isRightImageVisible', 'value'] == 'Yes' + ) + isNextFrameVisible = ( + self.df_settings.at['isNextFrameVisible', 'value'] == 'Yes' + ) + isNextFrameActive = ( + isNextFrameVisible and self.labelsGrad.showNextFrameAction.isEnabled() + ) + self.updateScrollbars() + self.openFolderAction.setEnabled(True) + self.editTextIDsColorAction.setDisabled(False) + self.imgPropertiesAction.setEnabled(True) + self.navigateToolBar.setVisible(True) + self.labelsGrad.showLabelsImgAction.setChecked(isLabVisible) + self.labelsGrad.showRightImgAction.setChecked(isRightImgVisible) + self.labelsGrad.showNextFrameAction.setChecked(isNextFrameActive) + if isRightImgVisible or isNextFrameActive: + self.rightBottomGroupbox.setChecked(True) + + isTwoImagesLayout = ( + isRightImgVisible or isLabVisible or isNextFrameActive + ) + self.setTwoImagesLayout(isTwoImagesLayout) + + self.setBottomLayoutStretch() + + if isNextFrameActive: + self.rightBottomGroupbox.show() + self.rightBottomGroupbox.setChecked(True) + self.drawNothingCheckboxRight.click() + + self.readSavedCustomAnnot() + self.addCustomAnnotButtonAllLoadedPos() + self.setStatusBarLabel() + + self.initLookupTableLab() + if self.invertBwAction.isChecked() and not self.invertBwAlreadyCalledOnce: + self.invertBw(True) + self.restoreSavedSettings() + + self.initContoursImage() + self.initTextAnnot() + self.initDelRoiLab() + + self.update_rp() + self.updateAllImages() + if posData.SizeT > 1: + self.rightImageFramesScrollbar.setValueNoSignal(posData.frame_i+2) + self.setMetricsFunc() + + self.gui_createLabelRoiItem() + self.gui_createZoomRectItem() + + self.titleLabel.setText( + 'Data successfully loaded.', + color=self.titleColor + ) + + self.disableNonFunctionalButtons() + self.setVisible3DsegmWidgets() + + if len(self.data) == 1 and posData.SizeZ > 1 and posData.SizeT == 1: + self.zSliceCheckbox.setChecked(True) + else: + self.zSliceCheckbox.setChecked(False) + + self.labelRoiCircItemLeft.setImageShape(self.currentLab2D.shape) + self.labelRoiCircItemRight.setImageShape(self.currentLab2D.shape) + + self.retainSpaceSlidersToggled(self.retainSpaceSlidersAction.isChecked()) + + self.stopAutomaticLoadingPos() + self.viewAllCustomAnnotAction.setChecked(True) + + self.updateImageValueFormatter() + + posData.loadWhitelist() + + self.setFocusGraphics() + self.setFocusMain() + + # Overwrite axes viewbox context menu + self.ax1.vb.menu = self.imgGrad.gradient.menu + self.ax2.vb.menu = self.labelsGrad.menu + + QTimer.singleShot(200, self.resizeGui) + + self.isDataLoaded = True + self.isDataLoading = False + + self.initImgGradRescaleIntensitiesHowPreference() + + self.rescaleIntensitiesLut(setImage=False) + + self.gui_createAutoSaveWorker() + + def initImgGradRescaleIntensitiesHowPreference(self): + posData = self.data[self.pos_i] + channelName = posData.user_ch_name + if f'how_rescale_intensities_{channelName}' not in self.df_settings.index: + return + + how = self.df_settings.at[ + f'how_rescale_intensities_{channelName}', 'value' + ] + self.imgGrad.setRescaleIntensitiesHow(how) + + def removeAxLimits(self): + self.ax1.vb.state['limits']['xLimits'] = [-1E307, +1E307] + self.ax1.vb.state['limits']['yLimits'] = [-1E307, +1E307] + + def resizeGui(self): + self.ax1.vb.state['limits']['xRange'] = [None, None] + self.ax1.vb.state['limits']['yRange'] = [None, None] + self.autoRange() + if self.ax1.getViewBox().state['limits']['xRange'][0] is not None: + self.bottomScrollArea._resizeVertical() + return + (xmin, xmax), (ymin, ymax) = self.ax1.viewRange() + maxYRange = int((ymax-ymin)*1.5) + maxXRange = int((xmax-xmin)*1.5) + self.ax1.setLimits( + maxYRange=maxYRange, + maxXRange=maxXRange + ) + self.bottomScrollArea._resizeVertical() + QTimer.singleShot(200, self.autoRange) + + def setVisible3DsegmWidgets(self): + self.annotNumZslicesCheckbox.setVisible(self.isSegm3D) + self.annotNumZslicesCheckboxRight.setVisible(self.isSegm3D) + if not self.isSegm3D: + self.annotNumZslicesCheckbox.setChecked(False) + self.annotNumZslicesCheckboxRight.setChecked(False) + + def showHighlightZneighCheckbox(self): + if self.isSegm3D: + layout = self.bottomLeftLayout + # layout.addWidget(self.annotOptionsWidget, 0, 1, 1, 2) + # # layout.removeWidget(self.drawIDsContComboBox) + # # layout.addWidget(self.drawIDsContComboBox, 0, 1, 1, 1, + # # alignment=Qt.AlignCenter + # # ) + # layout.addWidget(self.highlightZneighObjCheckbox, 0, 2, 1, 2, + # alignment=Qt.AlignRight + # ) + self.highlightZneighObjCheckbox.show() + self.highlightZneighObjCheckbox.setChecked(True) + self.highlightZneighObjCheckbox.toggled.connect( + self.highlightZneighLabels_cb + ) + + def restoreSavedSettings(self): + if 'how_draw_annotations' in self.df_settings.index: + how = self.df_settings.at['how_draw_annotations', 'value'] + self.drawIDsContComboBox.setCurrentText(how) + else: + self.drawIDsContComboBox.setCurrentText('Draw IDs and contours') + + if 'how_draw_right_annotations' in self.df_settings.index: + how = self.df_settings.at['how_draw_right_annotations', 'value'] + self.annotateRightHowCombobox.setCurrentText(how) + else: + self.annotateRightHowCombobox.setCurrentText( + 'Draw IDs and overlay segm. masks' + ) + + if 'addNewIDsWhitelistToggle' in self.df_settings.index: + self.addNewIDsWhitelistToggle = ( + self.df_settings.at['addNewIDsWhitelistToggle', 'value'] + ) == 'Yes' + else: + self.addNewIDsWhitelistToggle = True + + self.drawAnnotCombobox_to_options() + self.drawIDsContComboBox_cb(0) + self.annotateRightHowCombobox_cb(0) + + def uncheckAnnotOptions(self, left=True, right=True): + # Left + if left: + self.annotIDsCheckbox.setChecked(False) + self.annotCcaInfoCheckbox.setChecked(False) + self.annotContourCheckbox.setChecked(False) + self.annotSegmMasksCheckbox.setChecked(False) + self.drawMothBudLinesCheckbox.setChecked(False) + self.drawNothingCheckbox.setChecked(False) + + # Right + if right: + self.annotIDsCheckboxRight.setChecked(False) + self.annotCcaInfoCheckboxRight.setChecked(False) + self.annotContourCheckboxRight.setChecked(False) + self.annotSegmMasksCheckboxRight.setChecked(False) + self.drawMothBudLinesCheckboxRight.setChecked(False) + self.drawNothingCheckboxRight.setChecked(False) + + def setDisabledAnnotOptions(self, disabled): + # Left + self.annotIDsCheckbox.setDisabled(disabled) + self.annotCcaInfoCheckbox.setDisabled(disabled) + self.annotContourCheckbox.setDisabled(disabled) + # self.annotSegmMasksCheckbox.setDisabled(disabled) + self.drawMothBudLinesCheckbox.setDisabled(disabled) + # self.drawNothingCheckbox.setDisabled(disabled) + + # Right + self.annotIDsCheckboxRight.setDisabled(disabled) + self.annotCcaInfoCheckboxRight.setDisabled(disabled) + self.annotContourCheckboxRight.setDisabled(disabled) + # self.annotSegmMasksCheckboxRight.setDisabled(disabled) + self.drawMothBudLinesCheckboxRight.setDisabled(disabled) + # self.drawNothingCheckboxRight.setDisabled(disabled) + + def drawAnnotCombobox_to_options(self): + self.uncheckAnnotOptions() + + # Left + how = self.drawIDsContComboBox.currentText() + if how.find('IDs') != -1: + self.annotIDsCheckbox.setChecked(True) + if how.find('cell cycle info') != -1: + self.annotCcaInfoCheckbox.setChecked(True) + if how.find('contours') != -1: + self.annotContourCheckbox.setChecked(True) + if how.find('segm. masks') != -1: + self.annotSegmMasksCheckbox.setChecked(True) + if how.find('mother-bud lines') != -1: + self.drawMothBudLinesCheckbox.setChecked(True) + if how.find('nothing') != -1: + self.drawNothingCheckbox.setChecked(True) + + # Right + how = self.annotateRightHowCombobox.currentText() + if how.find('IDs') != -1: + self.annotIDsCheckboxRight.setChecked(True) + if how.find('cell cycle info') != -1: + self.annotCcaInfoCheckboxRight.setChecked(True) + if how.find('contours') != -1: + self.annotContourCheckboxRight.setChecked(True) + if how.find('segm. masks') != -1: + self.annotSegmMasksCheckboxRight.setChecked(True) + if how.find('mother-bud lines') != -1: + self.drawMothBudLinesCheckboxRight.setChecked(True) + if how.find('nothing') != -1: + self.drawNothingCheckboxRight.setChecked(True) + + def setStatusBarLabel(self, log=True): + self.statusbar.clearMessage() + posData = self.data[self.pos_i] + segmentedChannelname = posData.filename[len(posData.basename):] + segmFilename = os.path.basename(posData.segm_npz_path) + segmEndName = segmFilename[len(posData.basename):] + txt = ( + f'{posData.pos_foldername} || ' + f'Basename: {posData.basename} || ' + f'Segmented channel: {segmentedChannelname} || ' + f'Segmentation file name: {segmEndName}' + ) + mode = str(self.modeComboBox.currentText()) + if log: + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + def autoRange(self): + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.ax2.autoRange() + self.ax1.autoRange() + + def resetRange(self): + if self.ax1_viewRange is None: + return + xRange, yRange = self.ax1_viewRange + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.ax2.vb.setRange(xRange=xRange, yRange=yRange) + self.ax1.vb.setRange(xRange=xRange, yRange=yRange) + self.ax1_viewRange = None + self.isRangeReset = True + + def setFramesSnapshotMode(self): + self.measurementsMenu.setDisabled(False) + self.setPermanentGreedyCmapPreferences() + if self.isSnapshot: + self.realTimeTrackingToggle.setDisabled(True) + self.realTimeTrackingToggle.label.setDisabled(True) + try: + self.drawIDsContComboBox.currentIndexChanged.disconnect() + except Exception as e: + pass + + self.imgGrad.rescaleAcrossTimeAction.setDisabled(True) + self.repeatTrackingAction.setDisabled(True) + self.manualTrackingAction.setDisabled(True) + self.logger.info('Setting GUI mode to "Snapshots"...') + self.modeComboBox.clear() + self.modeComboBox.addItems(['Snapshot']) + self.modeComboBox.setDisabled(True) + self.modeMenu.menuAction().setVisible(False) + self.drawIDsContComboBox.clear() + self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) + self.drawIDsContComboBox.setCurrentIndex(1) + self.modeToolBar.setVisible(False) + self.skipToNewIdAction.setVisible(False) + self.skipToNewIdAction.setDisabled(True) + self.modeComboBox.setCurrentText('Snapshot') + self.annotateToolbar.setVisible(True) + self.labelsGrad.showNextFrameAction.setDisabled(True) + self.drawIDsContComboBox.currentIndexChanged.connect( + self.drawIDsContComboBox_cb + ) + self.showTreeInfoCheckbox.hide() + self.rightImageFramesScrollbar.setVisible(False) + self.rightImageFramesScrollbar.setDisabled(True) + if not self.isSegm3D: + self.manualBackgroundAction.setVisible(True) + self.manualBackgroundAction.setDisabled(False) + else: + self.manualBackgroundAction.setVisible(False) + self.manualBackgroundAction.setDisabled(True) + self.manualAnnotPastButton.setDisabled(True) + self.manualAnnotPastButton.action.setDisabled(True) + self.manualAnnotPastButton.setVisible(False) + self.manualAnnotPastButton.action.setVisible(False) + self.copyLostObjButton.setDisabled(True) + self.copyLostObjButton.action.setDisabled(True) + self.copyLostObjButton.setVisible(False) + self.copyLostObjButton.action.setVisible(False) + self.segForLostIDsAction.setVisible(False) + self.segForLostIDsAction.setDisabled(True) + self.delNewObjAction.setVisible(False) + self.delNewObjAction.setDisabled(True) + else: + self.imgGrad.rescaleAcrossTimeAction.setDisabled(False) + self.annotateToolbar.setVisible(False) + self.realTimeTrackingToggle.setDisabled(False) + self.repeatTrackingAction.setDisabled(False) + self.manualTrackingAction.setDisabled(False) + self.modeComboBox.setDisabled(False) + self.modeMenu.menuAction().setVisible(True) + self.skipToNewIdAction.setVisible(True) + self.skipToNewIdAction.setDisabled(False) + try: + self.modeComboBox.activated.disconnect() + self.modeComboBox.sigTextChanged.disconnect() + self.drawIDsContComboBox.currentIndexChanged.disconnect() + except Exception as e: + pass + # traceback.print_exc() + self.modeComboBox.clear() + self.modeComboBox.addItems(self.modeItems) + self.drawIDsContComboBox.clear() + self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) + self.modeComboBox.sigTextChanged.connect(self.changeMode) + self.modeComboBox.activated.connect(self.clearComboBoxFocus) + self.drawIDsContComboBox.currentIndexChanged.connect( + self.drawIDsContComboBox_cb) + self.modeComboBox.setCurrentText('Viewer') + self.showTreeInfoCheckbox.show() + self.manualBackgroundAction.setVisible(False) + self.manualBackgroundAction.setDisabled(True) + self.labelsGrad.showNextFrameAction.setDisabled(False) + self.manualAnnotPastButton.setDisabled(False) + self.manualAnnotPastButton.action.setDisabled(False) + self.manualAnnotPastButton.setVisible(True) + self.manualAnnotPastButton.action.setVisible(True) + self.copyLostObjButton.setDisabled(False) + self.copyLostObjButton.action.setDisabled(False) + self.copyLostObjButton.setVisible(True) + self.copyLostObjButton.action.setVisible(True) + self.segForLostIDsAction.setVisible(True) + self.segForLostIDsAction.setDisabled(False) + self.delNewObjAction.setVisible(True) + self.delNewObjAction.setDisabled(False) + + for ch, overlayItems in self.overlayLayersItems.items(): + lutItem = overlayItems[1] + lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot) + + def checkIfAutoSegm(self): + """ + If there are any frame or position with empty segmentation mask + ask whether automatic segmentation should be turned ON + """ + if self.autoSegmAction.isChecked(): + return + if self.autoSegmDoNotAskAgain: + return + + ask = False + for posData in self.data: + if posData.SizeT > 1: + for lab in posData.segm_data: + if not np.any(lab): + ask = True + txt = 'frames' + break + else: + if not np.any(posData.segm_data): + ask = True + txt = 'positions' + break + + if not ask: + return + + questionTxt = html_utils.paragraph( + f'Some or all loaded {txt} contain empty segmentation masks.

' + 'Do you want to activate automatic segmentation* ' + f'when visiting these {txt}?

' + '* Automatic segmentation can always be turned ON/OFF from the menu
' + ' Edit --> Segmentation --> Enable automatic segmentation

' + f'NOTE: you can automatically segment all {txt} using the
' + ' segmentation module.' + ) + msg = widgets.myMessageBox(wrapText=False) + noButton, yesButton = msg.question( + self, 'Automatic segmentation?', questionTxt, + buttonsTexts=('No', 'Yes') + ) + if msg.clickedButton == yesButton: + self.autoSegmAction.setChecked(True) + else: + self.autoSegmDoNotAskAgain = True + self.autoSegmAction.setChecked(False) + + def init_segmInfo_df(self): + for posData in self.data: + if posData is None: + # posData is None when computing measurements with the utility + # and with timelapse data + continue + posData.init_segmInfo_df() + + def connectScrollbars(self): + self.t_label.show() + self.navigateScrollBar.show() + self.navigateScrollBar.setDisabled(False) + + if self.data[0].SizeZ > 1: + self.enableZstackWidgets(True) + self.zSliceScrollBar.setMaximum(self.data[0].SizeZ-1) + self.zSliceSpinbox.setMaximum(self.data[0].SizeZ) + self.SizeZlabel.setText(f'/{self.data[0].SizeZ}') + try: + self.zSliceScrollBar.actionTriggered.disconnect() + self.zSliceScrollBar.sliderReleased.disconnect() + self.zProjComboBox.currentTextChanged.disconnect() + self.zProjComboBox.activated.disconnect() + self.switchPlaneCombobox.sigPlaneChanged.disconnect() + self.zProjLockViewButton.toggled.disconnect() + except Exception as e: + pass + self.zSliceScrollBar.actionTriggered.connect( + self.zSliceScrollBarActionTriggered + ) + self.zSliceScrollBar.sliderReleased.connect( + self.zSliceScrollBarReleased + ) + self.zProjComboBox.currentTextChanged.connect(self.updateZproj) + self.zProjComboBox.activated.connect(self.clearComboBoxFocus) + self.switchPlaneCombobox.sigPlaneChanged.connect( + self.switchViewedPlane + ) + self.zProjLockViewButton.toggled.connect(self.zProjLockViewToggled) + + posData = self.data[self.pos_i] + if posData.SizeT == 1: + self.t_label.setText('Position n.') + self.navigateScrollBar.setMinimum(1) + self.navigateScrollBar.setMaximum(len(self.data)) + self.navigateScrollBar.setAbsoluteMaximum(len(self.data)) + self.navSpinBox.setMaximum(len(self.data)) + self.navigateScrollBar.connectEvents({ + 'sliderMoved': self.PosScrollBarMoved, + 'sliderReleased': self.PosScrollBarReleased, + 'actionTriggered': self.PosScrollBarAction + }) + else: + self.navigateScrollBar.setMinimum(1) + self.navigateScrollBar.setAbsoluteMaximum(posData.SizeT) + self.rightImageFramesScrollbar.setMinimum(1) + self.rightImageFramesScrollbar.setMaximum(posData.SizeT) + if posData.last_tracked_i is not None: + self.navigateScrollBar.setMaximum(posData.last_tracked_i+1) + self.navSpinBox.setMaximum(posData.last_tracked_i+1) + self.t_label.setText('Frame n.') + self.navigateScrollBar.connectEvents({ + 'sliderMoved': self.framesScrollBarMoved, + 'sliderReleased': self.framesScrollBarReleased, + 'actionTriggered': self.framesScrollBarActionTriggered + }) + self.rightImageFramesScrollbar.connectValueChanged( + self.rightImageFramesScrollbarValueChanged + ) + + def zSliceScrollBarActionTriggered(self, action): + singleMove = ( + action == SliderSingleStepAdd + or action == SliderSingleStepSub + or action == SliderPageStepAdd + or action == SliderPageStepSub + ) + if singleMove: + self.update_z_slice(self.zSliceScrollBar.sliderPosition()) + elif action == SliderMove: + if self.zSliceScrollBarStartedMoving and self.isSegm3D: + self.clearAx1Items(onlyHideText=True) + self.clearAx2Items(onlyHideText=True) + posData = self.data[self.pos_i] + idx = (posData.filename, posData.frame_i) + z = self.zSliceScrollBar.sliderPosition() + if self.switchPlaneCombobox.depthAxes() == 'z': + posData.segmInfo_df.at[idx, 'z_slice_used_gui'] = z + self.zSliceSpinbox.setValueNoEmit(z+1) + img = self._getImageupdateAllImages(None) + self.img1.setCurrentZsliceIndex(z) + self.img1.setImage( + img, next_frame_image=self.nextFrameImage(), + scrollbar_value=posData.frame_i+2 + ) + try: + self.setOverlayImages() + except Exception as err: + pass + + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.img2.setImage(posData.lab, z=z, autoLevels=False) + self.updateViewerWindow() + self.setTextAnnotZsliceScrolling() + self.setGraphicalAnnotZsliceScrolling() + self.setOverlayLabelsItems() + self.drawPointsLayers(computePointsLayers=False) + self.zSliceScrollBarStartedMoving = False + self.highlightSearchedID(self.highlightedID, force=True) + + def zSliceScrollBarReleased(self): + self.clearTempBrushImage() + self.zSliceScrollBarStartedMoving = True + self.update_z_slice(self.zSliceScrollBar.sliderPosition()) + + def setSwitchViewedPlaneDisabled(self, disabled): + posData = self.data[self.pos_i] + if posData.SizeZ == 1: + return + + self.switchPlaneCombobox.setDisabled(disabled) + if disabled: + self.switchPlaneCombobox.setCurrentIndex(0) + + def _setViewRangeSwitchPlane(self, previousPlane): + posData = self.data[self.pos_i] + SizeZ = posData.SizeZ + SizeY, SizeX = self.img1.image.shape[:2] + currentPlane = self.switchPlaneCombobox.plane() + if previousPlane == 'xy': + if currentPlane == 'zy': + self.ax1.setRange(xRange=self.yRangePrev) + unusedRange = np.clip(self.xRangePrev, 0, SizeX) + elif currentPlane == 'zx': + self.ax1.setRange(xRange=self.xRangePrev) + unusedRange = np.clip(self.yRangePrev, 0, SizeY) + elif previousPlane == 'zy': + if currentPlane == 'xy': + self.ax1.setRange(yRange=self.xRangePrev) + unusedRange = np.clip(self.yRangePrev, 0, SizeZ) + elif currentPlane == 'zx': + self.ax1.setRange(yRange=self.yRangePrev) + unusedRange = np.clip(self.xRangePrev, 0, SizeY) + elif previousPlane == 'zx': + if currentPlane == 'xy': + self.ax1.setRange(xRange=self.xRangePrev) + unusedRange = np.clip(self.yRangePrev, 0, SizeZ) + elif currentPlane == 'zy': + self.ax1.setRange(yRange=self.yRangePrev) + unusedRange = np.clip(self.xRangePrev, 0, SizeX) + + sliceValue = round((unusedRange[0] + unusedRange[1])/2) + self.zSliceScrollBar.setSliderPosition(sliceValue) + self.update_z_slice(self.zSliceScrollBar.sliderPosition()) + + def setViewRangeSwitchPlane(self, previousPlane): + self.autoRange() + QTimer.singleShot( + 100, partial(self._setViewRangeSwitchPlane, previousPlane) + ) + + def switchViewedPlane(self, previousPlane, currentPlane): + posData = self.data[self.pos_i] + self.xRangePrev, self.yRangePrev = self.ax1.viewRange() + self.zSlicePrev = self.zSliceScrollBar.sliderPosition() + + self.zProjComboBox.setCurrentText('single z-slice') + depthAxes = self.switchPlaneCombobox.depthAxes() + self.onEscape() + self.initDelRoiLab() + if depthAxes != 'z': + # Disable projections on plane that is not xy + self.zProjComboBox.setCurrentText('single z-slice') + self.zProjComboBox.setDisabled(True) + + # Clear annotations + self.clearAllItems() + self.setHighlightID(False) + + # Disable annotations on a plane that is not yz + self.setDrawNothingAnnotations() + self.setDisabledAnnotCheckBoxesLeft(True) + self.setDisabledAnnotCheckBoxesRight(True) + self.setEnabledAnnotCheckBoxesLeftZdepthAxes() + self.overlayButtonPrevState = self.overlayButton.isChecked() + self.overlayButton.setChecked(False) + self.overlayButton.setDisabled(True) + else: + self.zProjComboBox.setDisabled(False) + self.restoreAnnotationsOptions() + self.setDisabledAnnotCheckBoxesLeft(False) + self.setDisabledAnnotCheckBoxesRight(False) + self.overlayButton.setDisabled(False) + if self.overlayButtonPrevState: + self.overlayButton.setChecked(self.overlayButtonPrevState) + self.updateZsliceScrollbar(posData.frame_i) + + SizeY, SizeX = posData.img_data[posData.frame_i].shape[-2:] + + if depthAxes != 'z' and self.isSnapshot: + # Disable editing when the plane is not xy + self.disableEditingViewPlaneNotXY() + elif self.isSnapshot: + # Re-enable editing in snapshot mode when the plane is xy + self.setEnabledSnapshotMode() + + if depthAxes == 'z': + maxSliceNum = posData.SizeZ + elif depthAxes == 'y': + maxSliceNum = SizeY + else: + maxSliceNum = SizeX + + maxSliceText = f'/{maxSliceNum}' + self.SizeZlabel.setText(maxSliceText) + self.zSliceCheckbox.setText(f'{depthAxes}-slice') + self.zSliceScrollBar.setMaximum(maxSliceNum-1) + self.zSliceSpinbox.setMaximum(maxSliceNum) + + self.initContoursImage() + self.updateAllImages() + QTimer.singleShot( + 200, partial(self.setViewRangeSwitchPlane, previousPlane) + ) + + def onZsliceSpinboxValueChange(self, value): + self.zSliceScrollBar.setSliderPosition(value-1) + + def update_z_slice(self, z): + posData = self.data[self.pos_i] + if self.switchPlaneCombobox.depthAxes() == 'z': + if self.zProjLockViewButton.isChecked(): + idx = [ + (posData.filename, frame_i) + for frame_i in range(posData.SizeT) + ] + else: + idx = [ + (posData.filename, frame_i) + for frame_i in range(posData.frame_i, posData.SizeT) + ] + posData.segmInfo_df.loc[idx, 'z_slice_used_gui'] = z + + self.updatePreprocessPreview() + self.updateCombineChannelsPreview() + self.highlightedID = self.getHighlightedID() + self.updateAllImages( + computePointsLayers=False, + computeContours=False, + updateLookuptable=True + ) + self.updateItemsMousePos() + if self.isSegm3D: + self.updateObjectCounts() + + def updateOverlayZslice(self, z): + self.setOverlayImages() + + def updateOverlayZproj(self, how): + if how.find('max') != -1 or how == 'same as above': + self.overlay_z_label.setDisabled(True) + self.zSliceOverlay_SB.setDisabled(True) + else: + self.overlay_z_label.setDisabled(False) + self.zSliceOverlay_SB.setDisabled(False) + self.setOverlayImages() + + def updateZproj(self, how): + for p, posData in enumerate(self.data[self.pos_i:]): + if self.zProjLockViewButton.isChecked(): + idx = [ + (posData.filename, frame_i) + for frame_i in range(posData.SizeT) + ] + else: + idx = [(posData.filename, posData.frame_i)] + posData.segmInfo_df.loc[idx, 'which_z_proj_gui'] = how + posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) + + posData = self.data[self.pos_i] + if how == 'single z-slice': + self.zSliceScrollBar.setDisabled(False) + self.zSliceSpinbox.setDisabled(False) + self.zSliceCheckbox.setDisabled(False) + self.setZprojDisabled(False) + self.update_z_slice(self.zSliceScrollBar.sliderPosition()) + else: + self.zSliceScrollBar.setDisabled(True) + self.zSliceSpinbox.setDisabled(True) + self.zSliceCheckbox.setDisabled(True) + self.setZprojDisabled(self.isSegm3D) + self.updateAllImages() + + def setZprojDisabled(self, disabled, storePrevState=False): + self.combineChannelsAction.setDisabled(disabled) + for action in self.editToolBar.actions(): + button = self.editToolBar.widgetForAction(action) + if button == self.eraserButton: + continue + + if button in self.toolsActiveInProj3Dsegm: + continue + + try: + tooltip = button.toolTip() + prefix = 'WARNING: Disabled due to projection mode\n\n' + if disabled: + if not tooltip.startswith(prefix): + button.setToolTip(prefix + tooltip) + else: + if tooltip.startswith(prefix): + button.setToolTip(tooltip[len(prefix):]) + except: + pass + action.setDisabled(disabled) + try: + button.setChecked(False) + except Exception as err: + pass + + def clearAx2Items(self, onlyHideText=False): + self.ax2_binnedIDs_ScatterPlot.clear() + self.ax2_ripIDs_ScatterPlot.clear() + self.ax2_contoursImageItem.clear() + self.ax2_lostObjImageItem.clear() + self.ax2_lostTrackedObjImageItem.clear() + self.textAnnot[1].clear() + self.ax2_newMothBudLinesItem.setData([], []) + self.ax2_oldMothBudLinesItem.setData([], []) + self.ax2_lostObjScatterItem.setData([], []) + + def clearAx1Items(self, onlyHideText=False): + self.ax1_binnedIDs_ScatterPlot.clear() + self.ax1_ripIDs_ScatterPlot.clear() + self.labelsLayerImg1.clear() + self.labelsLayerRightImg.clear() + self.keepIDsTempLayerLeft.clear() + self.keepIDsTempLayerRight.clear() + self.highLightIDLayerImg1.clear() + self.highLightIDLayerRightImage.clear() + self.searchedIDitemLeft.clear() + self.searchedIDitemRight.clear() + self.ax1_contoursImageItem.clear() + self.ax1_lostObjImageItem.clear() + self.ax1_lostTrackedObjImageItem.clear() + self.textAnnot[0].clear() + self.ax1_newMothBudLinesItem.setData([], []) + self.ax1_oldMothBudLinesItem.setData([], []) + self.ax1_lostObjScatterItem.setData([], []) + self.ax1_lostTrackedScatterItem.setData([], []) + self.ccaFailedScatterItem.setData([], []) + self.yellowContourScatterItem.setData([], []) + + self.clearPointsLayers() + + self.clearOverlayLabelsItems() + self.clearManualBackgroundAnnotations() + self.clearCustomAnnot() + + def clearPointsLayers(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + try: + action.scatterItem.clear() + except Exception as e: + continue + + def clearOverlayLabelsItems(self): + for segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): + items = self.overlayLabelsItems[segmEndname] + imageItem, contoursItem, gradItem = items + imageItem.clear() + contoursItem.clear() + + def clearAllItems(self): + self.clearAx1Items() + self.clearAx2Items() + + def clearCustomAnnot(self): + for button in self.customAnnotDict.keys(): + scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + scatterPlotItem.setData([], []) + + def clearCurvItems(self, removeItems=True): + try: + posData = self.data[self.pos_i] + curvItems = zip(posData.curvPlotItems, + posData.curvAnchorsItems, + posData.curvHoverItems) + for plotItem, curvAnchors, hoverItem in curvItems: + plotItem.setData([], []) + curvAnchors.setData([], []) + hoverItem.setData([], []) + if removeItems: + self.ax1.removeItem(plotItem) + self.ax1.removeItem(curvAnchors) + self.ax1.removeItem(hoverItem) + + if removeItems: + posData.curvPlotItems = [] + posData.curvAnchorsItems = [] + posData.curvHoverItems = [] + except AttributeError: + # traceback.print_exc() + pass + + # @exec_time + def curvToolSplineToObj(self, xxA=None, yyA=None, isRightClick=False): + posData = self.data[self.pos_i] + # Store undo state before modifying stuff + self.storeUndoRedoStates(False, storeOnlyZoom=True) + + if isRightClick: + xxS, yyS = self.curvPlotItem.getData() + if xxS is None: + self.setUncheckedAllButtons() + return + self.smoothAutoContWithSpline() + + xxS, yyS = self.getClosedSplineCoords() + + if self.autoIDcheckbox.isChecked(): + self.setBrushID() + curvToolID = posData.brushID + else: + curvToolID = self.editIDspinbox.value() + posData.brushID = curvToolID + + if curvToolID <= 0: + self.setBrushID() + curvToolID = posData.brushID + + lab2D = self.get_2Dlab(posData.lab).copy() + newIDMask = np.zeros(lab2D.shape, bool) + rr, cc = skimage.draw.polygon(yyS, xxS, shape=lab2D.shape) + newIDMask[rr, cc] = True + newIDMask[lab2D!=0] = False + lab2D[newIDMask] = curvToolID + self.set_2Dlab(lab2D) + self.currentLab2D = lab2D + + def addFluoChNameContextMenuAction(self, ch_name): + posData = self.data[self.pos_i] + allTexts = [ + action.text() for action in self.chNamesQActionGroup.actions() + ] + if ch_name not in allTexts: + action = QAction(self) + action.setText(ch_name) + action.setCheckable(True) + self.chNamesQActionGroup.addAction(action) + action.setChecked(True) + self.fluoDataChNameActions.append(action) + + def computeSegm(self, force=False): + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + if mode == 'Viewer' or mode == 'Cell cycle analysis': + return + + if np.any(posData.lab) and not force: + # Do not compute segm if there is already a mask + return + + if not self.autoSegmAction.isChecked(): + return + + self.repeatSegm(model_name=self.segmModelName) + + def initImgCmap(self): + if not 'img_cmap' in self.df_settings.index: + self.df_settings.at['img_cmap', 'value'] = 'grey' + self.imgCmapName = self.df_settings.at['img_cmap', 'value'] + self.imgCmap = self.imgGrad.cmaps[self.imgCmapName] + if self.imgCmapName != 'grey': + # To ensure mapping to colors we need to normalize image + self.normalizeByMaxAction.setChecked(True) + + def initMetricsToSave(self, posData): + self._measurements_kernel._init_metrics_to_save(posData) + + def initMetrics(self): + self.logger.info('Initializing measurements...') + posData = self.data[self.pos_i] + self._measurements_kernel = cli.ComputeMeasurementsKernel( + self.logger, self.log_path, False + ) + self._measurements_kernel.init_args( + posData.chNames, posData.getSegmEndname() + ) + self._measurements_kernel._init_metrics(posData, self.isSegm3D) + + def initPosAttr(self): + exp_path = self.data[self.pos_i].exp_path + pos_foldernames = myutils.get_pos_foldernames(exp_path) + if len(pos_foldernames) == 1: + self.loadPosAction.setDisabled(True) + else: + self.loadPosAction.setDisabled(False) + + for p, posData in enumerate(self.data): + self.pos_i = p + posData.curvPlotItems = [] + posData.curvAnchorsItems = [] + posData.curvHoverItems = [] + posData.trackedLostIDs = set() + + posData.HDDmaxID = np.max(posData.segm_data) + + # Decision on what to do with changes to future frames attr + posData.doNotShowAgain_EditID = False + posData.UndoFutFrames_EditID = False + posData.applyFutFrames_EditID = False + + posData.doNotShowAgain_RipID = False + posData.UndoFutFrames_RipID = False + posData.applyFutFrames_RipID = False + + posData.doNotShowAgain_DelID = False + posData.UndoFutFrames_DelID = False + posData.applyFutFrames_DelID = False + + posData.doNotShowAgain_keepID = False + posData.UndoFutFrames_keepID = False + posData.applyFutFrames_keepID = False + + posData.doNotShowAgainAssignNewID = False + posData.UndoFutFramesAssignNewID = False + posData.applyFutFramesAssignNewID = False + + posData.includeUnvisitedInfo = { + 'Delete ID': False, 'Edit ID': False, 'Keep ID': False + } + + posData.loadTrackedLostCentroids() + posData.acdcTracker2stepsAnnotInfo = {} + + posData.doNotShowAgain_BinID = False + posData.UndoFutFrames_BinID = False + posData.applyFutFrames_BinID = False + + posData.disableAutoActivateViewerWindow = False + posData.new_IDs = [] + posData.lost_IDs = [] + posData.multiBud_mothIDs = [2] + posData.UndoRedoStates = [[] for _ in range(posData.SizeT)] + posData.UndoRedoCcaStates = [[] for _ in range(posData.SizeT)] + + posData.ol_data_dict = {} + posData.ol_data = None + + posData.ol_labels_data = None + + missing_frames = posData.SizeT - len(posData.allData_li) + if missing_frames > 0: + posData.allData_li.extend([None] * missing_frames) + for i in range(posData.SizeT): + if posData.allData_li[i] is None: + posData.allData_li[i] = ( + myutils.get_empty_stored_data_dict() + ) + + posData.lutLevels = {channel: {} for channel in self.ch_names} + + posData.ccaStatus_whenEmerged = {} + + posData.frame_i = 0 + posData.brushID = 0 + posData.binnedIDs = set() + posData.ripIDs = set() + posData.cca_df = None + if posData.last_tracked_i is not None: + last_tracked_num = posData.last_tracked_i+1 + # Load previous session data + # Keep track of which ROIs have already been added + # in previous frame + delROIshapes = [[] for _ in range(posData.SizeT)] + for i in range(last_tracked_num): + posData.frame_i = i + self.get_data(debug=True) + self.store_data( + enforce=True, autosave=False, store_cca_df_copy=True + ) + + # Ask whether to resume from last frame + if last_tracked_num>1: + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Cell-ACDC detected a previous session ended ' + f'at frame {last_tracked_num}.

' + f'Do you want to resume from frame ' + f'{last_tracked_num}?' + ) + noButton, yesButton = msg.question( + self, 'Start from last session?', txt, + buttonsTexts=(' No ', 'Yes') + ) + self.AutoPilotProfile.storeClickMessageBox( + 'Start from last session?', msg.clickedButton.text() + ) + if msg.clickedButton == yesButton: + posData.frame_i = posData.last_tracked_i + self.lastFrameRanOnFirstVisitTools = posData.frame_i + else: + posData.frame_i = 0 + + posData.img_data_min_max = ( + posData.img_data.min(), posData.img_data.max() + ) + + # Back to first position + self.pos_i = 0 + self.get_data(debug=False) + self.store_data(autosave=False) + # self.updateAllImages() + + # Link Y and X axis of both plots to scroll zoom and pan together + self.ax2.vb.setYLink(self.ax1.vb) + self.ax2.vb.setXLink(self.ax1.vb) + + self.setAllIDs() + + def navigateSpinboxValueChanged(self, value): + self.navigateScrollBar.setSliderPosition(value) + if self.isSnapshot: + self.PosScrollBarMoved(value) + else: + self.navigateScrollBarStartedMoving = True + self.framesScrollBarMoved(value) + + def navigateSpinboxEditingFinished(self): + if self.isSnapshot: + self.PosScrollBarReleased() + else: + self.framesScrollBarReleased() + + def PosScrollBarAction(self, action): + if action == SliderSingleStepAdd: + self.next_cb() + elif action == SliderSingleStepSub: + self.prev_cb() + elif action == SliderPageStepAdd: + self.PosScrollBarReleased() + elif action == SliderPageStepSub: + self.PosScrollBarReleased() + + def PosScrollBarMoved(self, pos_n): + if self.navigateScrollBarStartedMoving: + self.store_data() + + self.pos_i = pos_n-1 + self.updateFramePosLabel() + proceed_cca, never_visited = self.get_data() + self.updateAllImages() + self.setStatusBarLabel() + self.navigateScrollBarStartedMoving = False + + def PosScrollBarReleased(self): + self.navigateScrollBarStartedMoving = True + if self.pos_i == self.navigateScrollBar.sliderPosition()-1: + # Slider released without changing value --> do nothing + return + + self.pos_i = self.navigateScrollBar.sliderPosition()-1 + self.updateFramePosLabel() + self.updatePos() + + def resetNavigateFramesScrollbar(self, frame_i=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + self.navigateScrollBar.setValueNoSignal(frame_i+1) + + def framesScrollBarActionTriggered(self, action): + if action == SliderSingleStepAdd: + # Clicking on dialogs triggered by next_cb might trigger + # pressEvent of navigateQScrollBar, avoid that + self.navigateScrollBar.disableCustomPressEvent() + self.next_cb() + QTimer.singleShot(100, self.navigateScrollBar.enableCustomPressEvent) + elif action == SliderSingleStepSub: + self.prev_cb() + elif action == SliderPageStepAdd: + self.framesScrollBarReleased(do_store_data=True) + elif action == SliderPageStepSub: + self.framesScrollBarReleased(do_store_data=True) + + def framesScrollBarMoved(self, frame_n): + if self.navigateScrollBarStartedMoving: + mode = str(self.modeComboBox.currentText()) + if mode != 'Viewer': + self.store_data(debug=False) + + posData = self.data[self.pos_i] + posData.frame_i = frame_n-1 + if posData.allData_li[posData.frame_i]['labels'] is None: + if posData.frame_i < len(posData.segm_data): + posData.lab = posData.segm_data[posData.frame_i] + else: + posData.lab = np.zeros_like(posData.segm_data[0]) + else: + posData.lab = posData.allData_li[posData.frame_i]['labels'] + + self.setImageImg1() + if self.overlayButton.isChecked(): + self.setOverlayImages() + + if self.navigateScrollBarStartedMoving: + self.clearAllItems() + + self.navSpinBox.setValueNoEmit(posData.frame_i+1) + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.img2.setImage(posData.lab, z=self.z_lab(), autoLevels=False) + self.updateLookuptable() + self.updateFramePosLabel() + self.updateViewerWindow() + self.updateTimestampFrame() + self.updateHighlightedAxis() + self.navigateScrollBarStartedMoving = False + + def framesScrollBarReleased(self, do_store_data=False): + posData = self.data[self.pos_i] + if posData.frame_i == self.navigateScrollBar.sliderPosition()-1: + # Slider released without changing value --> do nothing + return + + mode = str(self.modeComboBox.currentText()) + if mode != 'Viewer' and do_store_data: + self.store_data(debug=False) + + self.navigateScrollBarStartedMoving = True + posData.frame_i = self.navigateScrollBar.sliderPosition()-1 + self.updateFramePosLabel() + proceed_cca, never_visited = self.get_data() + self.updateAllImages() + + def unstore_data(self): + posData = self.data[self.pos_i] + posData.allData_li[posData.frame_i] = myutils.get_empty_stored_data_dict() + + def getStoredSegmData(self): + posData = self.data[self.pos_i] + segm_data = [] + for data_frame_i in posData.allData_li: + lab = data_frame_i['labels'] + if lab is None: + break + segm_data.append(lab) + return np.array(segm_data) + + def trackNewIDtoNewIDsFutureFrame(self, newID, newIDmask): + posData = self.data[self.pos_i] + try: + nextLab = posData.allData_li[posData.frame_i+1]['labels'] + except IndexError: + # This is last frame --> there are no future frames + return + + if nextLab is None: + return + + newID_lab = np.zeros_like(posData.lab) + newID_lab[newIDmask] = newID + newLab_rp = [posData.rp[posData.IDs_idxs[newID]]] + newLab_IDs = [newID] + nextRp = posData.allData_li[posData.frame_i+1]['regionprops'] + + tracked_lab = self.trackFrame( + nextLab, nextRp, newID_lab, newLab_rp, newLab_IDs, + assign_unique_new_IDs=False + ) + trackedID = tracked_lab[newID_lab>0][0] + if trackedID == newID: + # Object does not exist in future frame --> do not track + return + + if posData.IDs_idxs.get(trackedID) is not None: + # Tracked ID already exists --> do not track to avoid merging + return + + return trackedID + + def store_manual_annot_data( + self, posData=None, data_frame_i=None + ): + if posData is None: + posData = self.data[self.pos_i] + + if data_frame_i is None: + data_frame_i = posData.allData_li[posData.frame_i] + + if not self.isSegm3D: + lab = [posData.lab] + else: + lab = posData.lab + + for z, lab_2D in enumerate(lab): + data_frame_i['manually_edited_lab']['lab'][z] = lab_2D + + # data_frame_i['manually_edited_lab']['zoom_slice'] = zoom_slice + + @exception_handler + def store_data( + self, pos_i=None, enforce=True, debug=False, mainThread=True, + autosave=True, store_cca_df_copy=False + ): + pos_i = self.pos_i if pos_i is None else pos_i + posData = self.data[pos_i] + if posData.frame_i < 0: + # In some cases we set frame_i = -1 and then call next_frame + # to visualize frame 0. In that case we don't store data + # for frame_i = -1 + return + + mode = str(self.modeComboBox.currentText()) + + if mode == 'Viewer' and not enforce: + return + + # if not mainThread: + # self.lin_tree_ask_changes() + + allData_li = posData.allData_li[posData.frame_i] + allData_li['regionprops'] = posData.rp.copy() + allData_li['labels'] = posData.lab.copy() + allData_li['IDs'] = posData.IDs.copy() + allData_li['manualBackgroundLab'] = ( + posData.manualBackgroundLab + ) + allData_li['IDs_idxs'] = ( + posData.IDs_idxs.copy() + ) + if self.manualAnnotPastButton.isChecked(): + self.store_manual_annot_data( + posData=posData, data_frame_i=allData_li + ) + + self.store_zslices_rp() + + # Store dynamic metadata + is_cell_dead_li = [False]*len(posData.rp) + is_cell_excluded_li = [False]*len(posData.rp) + IDs = [0]*len(posData.rp) + xx_centroid = [0]*len(posData.rp) + yy_centroid = [0]*len(posData.rp) + if self.isSegm3D: + zz_centroid = [0]*len(posData.rp) + areManuallyEdited = [0]*len(posData.rp) + editedNewIDs = [vals[2] for vals in posData.editID_info] + for i, obj in enumerate(posData.rp): + is_cell_dead_li[i] = obj.dead + is_cell_excluded_li[i] = obj.excluded + IDs[i] = obj.label + try: + xx_centroid[i] = int(self.getObjCentroid(obj.centroid)[1]) + yy_centroid[i] = int(self.getObjCentroid(obj.centroid)[0]) + except Exception as err: + printl(obj, obj.centroid, obj.label, posData.frame_i) + if self.isSegm3D: + zz_centroid[i] = int(obj.centroid[0]) + if obj.label in editedNewIDs: + areManuallyEdited[i] = 1 + + posData.STOREDmaxID = max(IDs, default=0) + + acdc_df = allData_li['acdc_df'] + if acdc_df is None: + allData_li['acdc_df'] = pd.DataFrame( + { + 'Cell_ID': IDs, + 'is_cell_dead': is_cell_dead_li, + 'is_cell_excluded': is_cell_excluded_li, + 'x_centroid': xx_centroid, + 'y_centroid': yy_centroid, + 'was_manually_edited': areManuallyEdited + } + ).set_index('Cell_ID') + + if self.isSegm3D: + allData_li['acdc_df']['z_centroid'] = ( + zz_centroid + ) + else: + # Filter or add IDs that were not stored yet + acdc_df = acdc_df.drop(columns=['time_seconds'], errors='ignore') + acdc_df = acdc_df.reindex(IDs, fill_value=0) + acdc_df['is_cell_dead'] = is_cell_dead_li + acdc_df['is_cell_excluded'] = is_cell_excluded_li + acdc_df['x_centroid'] = xx_centroid + acdc_df['y_centroid'] = yy_centroid + if self.isSegm3D: + acdc_df['z_centroid'] = zz_centroid + acdc_df['was_manually_edited'] = areManuallyEdited + allData_li['acdc_df'] = acdc_df + + if mainThread: + self.pointsLayerDataToDf(posData) + + self.store_cca_df( + pos_i=pos_i, mainThread=mainThread, autosave=autosave, + store_cca_df_copy=store_cca_df_copy + ) + + def nearest_point_2Dyx(self, points, all_others): + """ + Given 2D array of [y, x] coordinates points and all_others return the + [y, x] coordinates of the two points (one from points and one from all_others) + that have the absolute minimum distance + """ + # Compute 3D array where each ith row of each kth page is the element-wise + # difference between kth row of points and ith row in all_others array. + # (i.e. diff[k,i] = points[k] - all_others[i]) + diff = points[:, np.newaxis] - all_others + # Compute 2D array of distances where + # dist[i, j] = euclidean dist (points[i],all_others[j]) + dist = np.linalg.norm(diff, axis=2) + # Compute i, j indexes of the absolute minimum distance + i, j = np.unravel_index(dist.argmin(), dist.shape) + nearest_point = all_others[j] + point = points[i] + min_dist = np.min(dist) + return min_dist, nearest_point + + def isCurrentFrameCcaVisited(self): + posData = self.data[self.pos_i] + curr_df = posData.allData_li[posData.frame_i]['acdc_df'] + return curr_df is not None and 'cell_cycle_stage' in curr_df.columns + + def warnScellsGone(self, ScellsIDsGone, frame_i): + msg = widgets.myMessageBox() + text = html_utils.paragraph(f""" + In the next frame the followning cells' IDs in S/G2/M + (highlighted with a yellow contour) will disappear:

+ {ScellsIDsGone}

+ If the cell does not exist you might have deleted it at some point. + If that's the case, then try to go to some previous frames and reset + the cell cycle annotations there (button on the top toolbar).

+ These cells are either buds or mother whose related IDs will not + disappear. This is likely due to cell division happening in + previous frame and the divided bud or mother will be + washed away.

+ If you decide to continue these cells will be automatically + annotated as divided at frame number {frame_i}.

+ Do you want to continue? + """) + _, yesButton, noButton = msg.warning( + self, 'Cells in "S/G2/M" disappeared!', text, + buttonsTexts=('Cancel', 'Yes', 'No') + ) + return msg.clickedButton == yesButton + + def checkScellsGone(self): + """Check if there are cells in S phase whose relative disappear in + current frame. Allow user to choose between automatically assign + division to these cells or cancel and not visit the frame. + + Returns + ------- + bool + False if there are no cells disappeared or the user decided + to accept automatic division. + """ + automaticallyDividedIDs = [] + + mode = str(self.modeComboBox.currentText()) + if mode.find('Cell cycle') == -1: + # No cell cycle analysis mode --> do nothing + return False, automaticallyDividedIDs + + posData = self.data[self.pos_i] + + if posData.allData_li[posData.frame_i]['labels'] is None: + # Frame never visited/checked in segm mode --> autoCca_df will raise + # a critical message + return False, automaticallyDividedIDs + + # Check if there are S cells that either only mother or only + # bud disappeared and automatically assign division to it + # or abort visiting this frame + prev_acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_cca_df = prev_acdc_df[self.cca_df_colnames].copy() + + ScellsIDsGone = [] + for ccSeries in prev_cca_df.itertuples(): + ID = ccSeries.Index + ccs = ccSeries.cell_cycle_stage + if ccs != 'S': + continue + + relID = ccSeries.relative_ID + if relID == -1: + continue + + # Check is relID is gone while ID stays + if relID not in posData.IDs and ID in posData.IDs: + ScellsIDsGone.append(relID) + + if not ScellsIDsGone: + # No cells in S that disappears --> do nothing + return False, automaticallyDividedIDs + + self.highlightNewIDs_ccaFailed(ScellsIDsGone, rp=prev_rp) + proceed = self.warnScellsGone(ScellsIDsGone, posData.frame_i) + self.clearLostObjContoursItems() + + if not proceed: + return True, automaticallyDividedIDs + + for IDgone in ScellsIDsGone: + relID = prev_cca_df.at[IDgone, 'relative_ID'] + self.annotateDisappearedBeforeDivision(relID, IDgone, prev_cca_df) + self.annotateDivision( + prev_cca_df, IDgone, relID, frame_i=posData.frame_i-1 + ) + self.annotateDivisionCurrentFrameRelativeIDgone(relID) + automaticallyDividedIDs.append(relID) + + self.store_cca_df(frame_i=posData.frame_i-1, cca_df=prev_cca_df) + + return False, automaticallyDividedIDs + + def annotateDivisionCurrentFrameRelativeIDgone(self, IDwhoseRelativeIsGone): + posData = self.data[self.pos_i] + if posData.cca_df is None: + return + ID = IDwhoseRelativeIsGone + posData.cca_df.at[ID, 'generation_num'] += 1 + posData.cca_df.at[ID, 'division_frame_i'] = posData.frame_i-1 + posData.cca_df.at[ID, 'relationship'] = 'mother' + + def annotateDisappearedBeforeDivision( + self, relID, IDgone, cca_df, frame_i=None + ): + posData = self.data[self.pos_i] + gen_num = cca_df.at[relID, 'generation_num'] + if frame_i is None: + frame_i = posData.frame_i + + for past_frame_i in range(frame_i-1, -1, -1): + past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True) + if past_cca_df is None: + return + + try: + if past_cca_df.at[relID, 'generation_num'] != gen_num: + # ID is a mother and the cell cycle is finished here + return + except Exception as err: + # Bud stops existing --> stop process + return + + past_cca_df.at[IDgone, 'disappears_before_division'] = 1 + past_cca_df.at[relID, 'daughter_disappears_before_division'] = 1 + + self.store_cca_df( + cca_df=past_cca_df, frame_i=past_frame_i, autosave=False + ) + + @exception_handler + def attempt_auto_cca(self, enforceAll=False): + mode = str(self.modeComboBox.currentText()) + posData = self.data[self.pos_i] + + if mode == 'Cell cycle analysis': + notEnoughG1Cells, proceed = self.autoCca_df( + enforceAll=enforceAll + ) + if not proceed: + return notEnoughG1Cells, proceed + + # mode = str(self.modeComboBox.currentText()) + if posData.cca_df is None: # ??? + notEnoughG1Cells = False + proceed = True + return notEnoughG1Cells, proceed + if posData.cca_df.isna().any(axis=None): + raise ValueError('Cell cycle analysis table contains NaNs') + # self.checkMultiBudMoth() + proceed = self.checkMothersExcludedOrDead() + return notEnoughG1Cells, proceed + + elif mode == 'Normal division: Lineage tree': + self.autoLinTree_df() + notEnoughG1Cells = False + proceed = True + return notEnoughG1Cells, proceed + + else: + notEnoughG1Cells = False + proceed = True + return notEnoughG1Cells, proceed + + + + def highlightIDs(self, IDs, pen): + pass + + def warnFrameNeverVisitedSegmMode(self): + msg = widgets.myMessageBox() + warn_cca = msg.critical( + self, 'Next frame NEVER visited', + 'Next frame was never visited in "Segmentation and Tracking"' + 'mode.\n You cannot perform cell cycle analysis on frames' + 'where segmentation and/or tracking errors were not' + 'checked/corrected.\n\n' + 'Switch to "Segmentation and Tracking" mode ' + 'and check/correct next frame,\n' + 'before attempting cell cycle analysis again', + ) + return False + + def checkCcaPastFramesNewIDs(self): + posData = self.data[self.pos_i] + if not posData.new_IDs: + return + + found_cca_df_IDs = [] + for frame_i in range(posData.frame_i-2, -1, -1): + acdc_df = posData.allData_li[frame_i]['acdc_df'] + cca_df_i = acdc_df[self.cca_df_colnames] + intersect_idx = cca_df_i.index.intersection(posData.new_IDs) + cca_df_i = cca_df_i.loc[intersect_idx] + if cca_df_i.empty: + continue + found_cca_df_IDs.append(cca_df_i) + + # Remove IDs found in past frames from new_IDs list + newIDs = np.array(posData.new_IDs, dtype=np.uint32) + mask_index = np.in1d(newIDs, cca_df_i.index) + posData.new_IDs = list(newIDs[~mask_index]) + if not posData.new_IDs: + return found_cca_df_IDs + return found_cca_df_IDs + + def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): + self.logger.info( + 'Initialising cell cycle annotations of missing past frames...' + ) + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + + annotated_cca_dfs = [] + for frame_i in range(last_cca_frame_i+1): + acdc_df = posData.allData_li[frame_i]['acdc_df'] + if 'cell_cycle_stage' in acdc_df.columns: + continue + + acdc_df[self.cca_df_colnames] = '' + + annotated_cca_dfs = [ + posData.allData_li[i]['acdc_df'][self.cca_df_colnames] + for i in range(last_cca_frame_i+1) + ] + keys = range(last_cca_frame_i+1) + names = ['frame_i', 'Cell_ID'] + annotated_cca_df = ( + pd.concat(annotated_cca_dfs, keys=keys, names=names) + .reset_index() + .set_index(['Cell_ID', 'frame_i']) + .sort_index() + ) + + last_annotated_cca_df = annotated_cca_df.groupby(level=0).last() + cca_df_colnames = self.cca_df_colnames + pbar = tqdm(total=current_frame_i-last_cca_frame_i+1, ncols=100) + for frame_i in range(last_cca_frame_i, current_frame_i+1): + posData.frame_i = frame_i + self.get_data() + cca_df = self.getBaseCca_df() + + idx = last_annotated_cca_df.index.intersection(cca_df.index) + cca_df.loc[idx, cca_df_colnames] = last_annotated_cca_df.loc[idx] + + self.store_cca_df(cca_df=cca_df, frame_i=frame_i, autosave=False) + pbar.update() + pbar.close() + + posData.frame_i = current_frame_i + self.get_data() + + def initMissingFramesLinTree(self, current_frame_i): # done Need to add partially missing previous frames and loading + """ + When not starting from the first frame, automatically creates lineage tree dfs for all "skipped" frames and initializes the tree if not done so before. + + Parameters + ---------- + current_frame_i : int + The index of the current frame. + + Returns + ------- + None + + Notes + ----- + This method initializes the lineage tree annotations of missing past frames. If the lineage tree has not been initialized before, it creates a new lineage tree based on the labels of the first frame. It then iterates over the missing frames and updates the lineage tree with the labels and region properties of each frame. + """ + + self.logger.info( + 'Initialising lineage tree annotations of missing past frames...' + ) + + self.store_data(autosave=False) + self.get_data() + + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + + if not self.lineage_tree: # init lin tree if not done already + self.lineage_tree = normal_division_lineage_tree(gui=self) # here frame_i!=0 + + missing_frames = list(range(current_frame_i+1)) + present_frames = list(self.lineage_tree.frames_for_dfs) if self.lineage_tree else [] + present_frames = [] if not present_frames else present_frames # deal with None + missing_frames = [frame_i for frame_i in missing_frames if frame_i not in present_frames] + missing_frames.sort() + + for frame_i in missing_frames: + lab = posData.allData_li[frame_i]['labels'] + prev_lab = posData.allData_li[frame_i-1]['labels'] + rp = posData.allData_li[frame_i]['regionprops'] + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + # i might need to change this if I need support for only partially missing frames... Although I probably never have to care about that though + self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) + + posData.frame_i = current_frame_i + self.store_data() + + def _getCcaCostMatrix( + self, numCellsG1, numNewCells, IDsCellsG1, newIDs_contours + ): + posData = self.data[self.pos_i] + dataDict = posData.allData_li[posData.frame_i] + dist_matrix_df = dataDict.get('obj_to_obj_dist_cost_matrix_df') + if dist_matrix_df is None: + cost = np.full((numCellsG1, numNewCells), np.inf) + for obj in posData.rp: + ID = obj.label + try: + i = IDsCellsG1.index(ID) + except ValueError: + continue + + cont = self.getObjContours(obj) + i = IDsCellsG1.index(ID) + + # Get distance from cell in G1 and all other new cells + for j, newID_cont in enumerate(newIDs_contours): + min_dist, nearest_xy = self.nearest_point_2Dyx( + cont, newID_cont + ) + cost[i, j] = min_dist + + return cost + + cost = dist_matrix_df.loc[IDsCellsG1, posData.new_IDs].values + + return cost + + def autoCca_df(self, enforceAll=False): + """ + Assign each bud to a mother with scipy linear sum assignment + (Hungarian or Munkres algorithm). First we build a cost matrix where + each (i, j) element is the minimum distance between bud i and mother j. + Then we minimize the cost of assigning each bud to a mother, and finally + we write the assignment info into cca_df + """ + proceed = True + notEnoughG1Cells = False + ScellsGone = False + + posData = self.data[self.pos_i] + + # Skip cca if not the right mode + mode = str(self.modeComboBox.currentText()) + if mode.find('Cell cycle') == -1: + return notEnoughG1Cells, proceed + + + # Make sure that this is a visited frame in segmentation tracking mode + if posData.allData_li[posData.frame_i]['labels'] is None: + proceed = self.warnFrameNeverVisitedSegmMode() + return notEnoughG1Cells, proceed + + # Determine if this is the last visited frame for repeating + # bud assignment on non manually correct (corrected_on_frame_i>0) buds. + # The idea is that the user could have assigned division on a cell + # by going previous and we want to check if this cell could be a + # "better" mother for those non manually corrected buds + curr_df = posData.allData_li[posData.frame_i]['acdc_df'] + isLastVisitedAgain = self.isLastVisitedAgainCca( + curr_df, enforceAll=enforceAll + ) + + frameAlreadyAnnotated = ( + posData.cca_df is not None + and not enforceAll + and not isLastVisitedAgain + ) + # Use stored cca_df and do not modify it with automatic stuff + if frameAlreadyAnnotated: + return notEnoughG1Cells, proceed + + # Keep only correctedAssignIDs if requested + # For the last visited frame we perform assignment again only on + # IDs where we didn't manually correct assignment + correctedAssignIDs = set() + if isLastVisitedAgain and not enforceAll: + try: + correctedAssignIDs = curr_df[ + curr_df['corrected_on_frame_i']>0 + ].index + except Exception as e: + correctedAssignIDs = [] + posData.new_IDs = [ + ID for ID in posData.new_IDs + if ID not in correctedAssignIDs + ] + + # Check if new IDs exist some time in the past + found_cca_df_IDs = self.checkCcaPastFramesNewIDs() + + # Check if there are some S cells that disappeared + abort, automaticallyDividedIDs = self.checkScellsGone() + if abort: + notEnoughG1Cells = False + proceed = False + return notEnoughG1Cells, proceed + + # Get previous dataframe + acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] + prev_cca_df = acdc_df[self.cca_df_colnames].copy() + + if posData.cca_df is None: + posData.cca_df = prev_cca_df.copy() + else: + posData.cca_df = curr_df[self.cca_df_colnames].copy() + + # concatenate new IDs found in past frames (before frame_i-1) + if found_cca_df_IDs is not None: + cca_df = pd.concat([posData.cca_df, *found_cca_df_IDs]) + unique_idx = ~cca_df.index.duplicated(keep='first') + posData.cca_df = cca_df[unique_idx] + + # If there are no new IDs we are done + if not posData.new_IDs: + proceed = True + self.store_cca_df() + return notEnoughG1Cells, proceed + + # Get cells in G1 (exclude dead) and check if there are enough cells in G1 + try: + prev_df_G1 = prev_cca_df[prev_cca_df['cell_cycle_stage']=='G1'] + prev_df_G1 = prev_df_G1[~acdc_df.loc[prev_df_G1.index]['is_cell_dead']] + IDsCellsG1 = set(prev_df_G1.index) + except Exception as err: + IDsCellsG1 = set() + + if isLastVisitedAgain or enforceAll: + # If we are repeating auto cca for last visited frame + # then we also add the cells in G1 that appears in current frame + # and we remove the ones that are already in S in current frame + # if they were manually corrected (i.e., they cannot be mother). + # Note that potential mother cells must be either appearing in + # current frame or in G1 also at previous frame. + # If we would consider cells that are in G1 at current frame + # but not in previous frame, assigning a bud to it would + # result in no G1 at all for the mother cell. + df_G1 = posData.cca_df[posData.cca_df['cell_cycle_stage']=='G1'] + current_G1_IDs = df_G1.index + new_cell_G1 = [ + ID for ID in current_G1_IDs if ID not in prev_cca_df.index + ] + IDsCellsG1.update(new_cell_G1) + cells_S_current = posData.cca_df[ + (posData.cca_df['cell_cycle_stage']=='S') + & (posData.cca_df['corrected_on_frame_i']==posData.frame_i) + ].index + IDsCellsG1 = IDsCellsG1 - set(cells_S_current) + + # Remove cells that disappeared + IDsCellsG1 = [ID for ID in IDsCellsG1 if ID in posData.IDs] + + numCellsG1 = len(IDsCellsG1) + numNewCells = len(posData.new_IDs) + if numCellsG1 < numNewCells: + notEnoughG1Cells, proceed = self.handleNoCellsInG1( + numCellsG1, numNewCells + ) + return notEnoughG1Cells, proceed + + # Compute new IDs contours + newIDs_contours = [] + for obj in posData.rp: + ID = obj.label + if ID in posData.new_IDs: + cont = self.getObjContours(obj) + newIDs_contours.append(cont) + + # Compute cost matrix + cost = self._getCcaCostMatrix( + numCellsG1, numNewCells, IDsCellsG1, newIDs_contours + ) + + # Run hungarian (munkres) assignment algorithm + row_idx, col_idx = scipy.optimize.linear_sum_assignment(cost) + + # New mother cells + newMothIDs = {IDsCellsG1[i] for i in row_idx} + + # Assign buds to mothers + for i, j in zip(row_idx, col_idx): + mothID = IDsCellsG1[i] + budID = posData.new_IDs[j] + + relID = None + # If we are repeating assignment for the bud then we also have to + # correct the possibily wrong mother --> it goes back to + # G1 if it's not a mother that we assign now + if budID in posData.cca_df.index: + relID = posData.cca_df.at[budID, 'relative_ID'] + if relID in prev_cca_df.index and relID not in newMothIDs: + posData.cca_df.loc[relID] = prev_cca_df.loc[relID] + + posData.cca_df.at[mothID, 'relative_ID'] = budID + posData.cca_df.at[mothID, 'cell_cycle_stage'] = 'S' + + bud_cca_dict = base_cca_dict.copy() + bud_cca_dict['cell_cycle_stage'] = 'S' + bud_cca_dict['generation_num'] = 0 + bud_cca_dict['relative_ID'] = mothID + bud_cca_dict['relationship'] = 'bud' + bud_cca_dict['emerg_frame_i'] = posData.frame_i + bud_cca_dict['is_history_known'] = True + bud_cca_dict['corrected_on_frame_i'] = -1 + posData.cca_df.loc[budID] = pd.Series(bud_cca_dict) + + # Keep only existing IDs + posData.cca_df = posData.cca_df.loc[posData.IDs] + + self.store_cca_df() + proceed = True + return notEnoughG1Cells, proceed + + def autoLinTree_df(self, enforceAll=False): + """Automatically generates a lineage tree dataframe. + + This method generates a lineage tree dataframe based on the current mode and data. + It checks if the mode is set to 'Normal division: Lineage tree' and if the current frame + is not already processed. If the conditions are met, it retrieves the necessary data + from the current position data and previous position data, and passes it to the + `real_time` method of the `lineage_tree` object. Finally, it converts the lineage tree + to an ACDC dataframe and adds the current frame to the set of frames that have been + processed. + + Parameters + ---------- + enforceAll : bool, optional + If True, enforces processing of all frames, even if they have been processed before. + If False, only processes frames that have not been processed before. Default is False. + + Returns + ------- + bool + True if there are not enough G1 cells for lineage tree generation, False otherwise. + bool + True if the lineage tree generation should proceed, False otherwise. + """ + proceed = True + notEnoughG1Cells = False + mode = str(self.modeComboBox.currentText()) + + # Skip if not the right mode + if mode != 'Normal division: Lineage tree': + return notEnoughG1Cells, proceed + + posData = self.data[self.pos_i] + frame_i = posData.frame_i + + if frame_i in self.lineage_tree.frames_for_dfs: + return notEnoughG1Cells, proceed + + # Make sure that this is a visited frame in segmentation tracking mode + if posData.allData_li[frame_i]['labels'] is None: # may need to change this + proceed = self.warnFrameNeverVisitedSegmMode() + return notEnoughG1Cells, proceed + + self.store_data(autosave=False) + self.get_data() + lab = posData.lab + prev_lab = posData.allData_li[frame_i-1]['labels'] + rp = posData.rp + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + + self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) + self.store_data() + + def getObjBbox(self, obj_bbox): + if self.isSegm3D and len(obj_bbox)==6: + obj_bbox = (obj_bbox[1], obj_bbox[2], obj_bbox[4], obj_bbox[5]) + return obj_bbox + else: + return obj_bbox + + def z_lab(self, checkIfProj=False): + if checkIfProj and self.zProjComboBox.currentText() != 'single z-slice': + return + + if not self.isSegm3D: + return + + posData = self.data[self.pos_i] + + idx = self.zSliceScrollBar.sliderPosition() + + # ensure idx doesnt exceed the number of z-slices of the position + idx_z = min(idx, posData.SizeZ-1) + + if not self.switchPlaneCombobox.isEnabled(): + return idx_z + + depthAxes = self.switchPlaneCombobox.depthAxes() + if depthAxes == 'z': + return idx_z + elif depthAxes == 'y': + idx_y = min(idx, posData.SizeY-1) + return (slice(None), idx_y) + else: + idx_x = min(idx, posData.SizeX-1) + return (slice(None), slice(None), idx_x) + + def get_2Dlab(self, lab, force_z=True): + if self.isSegm3D: + if force_z: + return lab[self.z_lab()] + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if isZslice: + return lab[self.z_lab()] + else: + return lab.max(axis=0) + else: + return lab + + # @exec_time + def applyEraserMask(self, mask): + posData = self.data[self.pos_i] + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if isZslice: + posData.lab[self.z_lab(), mask] = 0 + else: + posData.lab[:, mask] = 0 + else: + posData.lab[mask] = 0 + + def changeBrushID(self): + """Function called when pressing or releasing shift + """ + if not self.isSegm3D: + # Changing brush ID with shift is only for 3D segm + return + + if not self.brushButton.isChecked(): + # Brush if not active + return + + if not self.isMouseDragImg2 and not self.isMouseDragImg1: + # Mouse is not brushing at the moment + return + + posData = self.data[self.pos_i] + forceNewObj = not self.isNewID + + if forceNewObj: + # Shift is down --> force new object with brush + # e.g., 24 --> 28: + # 24 is hovering ID that we store as self.prevBrushID + # 24 object becomes 28 that is the new posData.brushID + self.isNewID = True + self.changedID = posData.brushID + self.restoreBrushID = posData.brushID + # Set a new ID + self.setBrushID() + else: + # Shift released or hovering on ID in z+-1 + # --> restore brush ID from before shift was pressed or from + # when we started brushing from outside an object + # but we hovered on ID in z+-1 while dragging. + # We change the entire 28 object to 24 so before changing the + # brush ID back to 24 we builg the mask with 28 to change it to 24 + self.isNewID = False + self.changedID = posData.brushID + # Restore ID + posData.brushID = self.restoreBrushID + + brushID = posData.brushID + brushIDmask = self.get_2Dlab(posData.lab) == self.changedID + self.applyBrushMask(brushIDmask, brushID) + if self.isMouseDragImg1: + self.brushColor = self.lut[posData.brushID]/255 + self.setTempImg1Brush(True, brushIDmask, posData.brushID) + + def applyBrushMask(self, mask, ID, toLocalSlice=None): + posData = self.data[self.pos_i] + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if isZslice: + if toLocalSlice is not None: + toLocalSlice = (self.z_lab(), *toLocalSlice) + posData.lab[toLocalSlice][mask] = ID + else: + posData.lab[self.z_lab()][mask] = ID + else: + if toLocalSlice is not None: + for z in range(len(posData.lab)): + _slice = (z, *toLocalSlice) + posData.lab[_slice][mask] = ID + else: + posData.lab[:, mask] = ID + else: + if toLocalSlice is not None: + posData.lab[toLocalSlice][mask] = ID + else: + posData.lab[mask] = ID + + def assignNewIDfromClickedID( + self, clickedID: int, event: QGraphicsSceneMouseEvent + ): + posData = self.data[self.pos_i] + x, y = event.pos().x(), event.pos().y() + newID = self.setBrushID(return_val=True) + mapper = [(clickedID, newID)] + self.applyEditID(clickedID, posData.IDs.copy(), mapper, x, y) + + def get_2Drp(self, lab=None): + if self.isSegm3D: + if lab is None: + # self.currentLab2D is defined at self.setImageImg2() + lab = self.currentLab2D + lab = self.get_2Dlab(lab) + rp = skimage.measure.regionprops(lab) + return rp + else: + return self.data[self.pos_i].rp + + def set_2Dlab(self, lab2D, lab3D=None): + posData = self.data[self.pos_i] + + if lab3D is None: + lab3D = posData.lab + + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if isZslice: + lab3D[self.z_lab()] = lab2D + else: + lab3D[:] = lab2D + else: + if lab3D.shape == lab2D.shape: + lab3D[...] = lab2D + else: + posData.lab = lab2D + + def get_labels( + self, + from_store=False, + frame_i=None, + return_existing=False, + return_copy=True + ): + """Get the labels array. + + Parameters + ---------- + from_store : bool, optional + If True load the labels array from the stored posData.allData_li, + i.e., from RAM. Default is False + frame_i : int, optional + If None, use the current frame index. Default is None + return_existing : bool, optional + If True, the second return element will be a boolean that + is True if the labels array was found stored in `posData.allData_li`. + Default is False + return_copy : bool, optional + If True returns a copy of the labels array + + Returns + ------- + numpy.ndarray or tuple of (numpy.ndarray, bool) + The first element is the labels array requested. If `return_existing` + is True then this method also returns a second boolean element that + is True if the labels array was found in in `posData.allData_li`. + + Note + ---- + + If `from_store` is True then this method will try to get the stored + labels array. If any error occurs then the returned labels are the + saved ones in the segmentation file (i.e., from hard drive). + + """ + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + existing = True + if from_store: + try: + labels = posData.allData_li[frame_i]['labels'] + if labels is None: + from_store = False + except Exception as err: + from_store = False + + if not from_store: + try: + labels = posData.segm_data[frame_i] + except IndexError: + existing = False + # Visting a frame that was not segmented --> empty masks + if self.isSegm3D: + shape = (posData.SizeZ, posData.SizeY, posData.SizeX) + else: + shape = (posData.SizeY, posData.SizeX) + labels = np.zeros(shape, dtype=np.uint32) + return_copy = False + + if return_copy: + labels = labels.copy() + + if return_existing: + return labels, existing + else: + return labels + + def addYXcentroidToDf(self, df): + posData = self.data[self.pos_i] + for obj in posData.rp: + y_centroid = int(self.getObjCentroid(obj.centroid)[0]) + x_centroid = int(self.getObjCentroid(obj.centroid)[1]) + df.at[obj.label, 'y_centroid'] = y_centroid + df.at[obj.label, 'x_centroid'] = x_centroid + return df + + def _get_editID_info(self, df): + if 'was_manually_edited' not in df.columns: + return [] + + if 'y_centroid' not in df.columns or 'x_centroid' not in df.columns: + df = self.addYXcentroidToDf(df) + + manually_edited_df = df[df['was_manually_edited'] > 0] + editID_info = [ + (row.y_centroid, row.x_centroid, row.Index) + for row in manually_edited_df.itertuples() + ] + return editID_info + + def apply_manual_edits_to_lab_if_needed(self, lab): + posData = self.data[self.pos_i] + data_frame_i = posData.allData_li[posData.frame_i] + edited_lab_dict = data_frame_i['manually_edited_lab']['lab'] + if not edited_lab_dict: + return lab + + # zoom_slice = data_frame_i['manually_edited_lab']['zoom_slice'] + for z, lab_edited in edited_lab_dict.items(): + if not self.isSegm3D: + # lab[zoom_slice] = lab_edited + lab = lab_edited + break + + lab[z] = lab_edited + + # lab[z, zoom_slice[0], zoom_slice[1]] = zoom_lab + + return lab + + def _get_data_unvisited(self, posData, debug=False, lin_tree_init=True,): + posData.editID_info = [] + proceed_cca = True + never_visited = True + if str(self.modeComboBox.currentText()) == 'Cell cycle analysis': + # Warn that we are visiting a frame that was never segm-checked + # on cell cycle analysis mode + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Segmentation and Tracking was never checked from ' + f'frame {posData.frame_i+1} onwards.

' + 'To ensure correct cell cell cycle analysis you have to ' + 'first visit the frames after ' + f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' + ) + warn_cca = msg.critical( + self, 'Never checked segmentation on requested frame', txt + ) + proceed_cca = False + return proceed_cca, never_visited + + elif str(self.modeComboBox.currentText()) == 'Normal division: Lineage tree': + # Warn that we are visiting a frame that was never segm-checked + # on cell cycle analysis mode + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Segmentation and Tracking was never checked from ' + f'frame {posData.frame_i+1} onwards.

' + 'To ensure correct lineage tree analysis you have to ' + 'first visit the frames after ' + f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' + ) + warn_cca = msg.critical(#??? + self, 'Never checked segmentation on requested frame', txt + ) + proceed_cca = False + return proceed_cca, never_visited + + # Requested frame was never visited before. Load from HDD + labels = self.get_labels() + posData.lab = self.apply_manual_edits_to_lab_if_needed( + labels + ) + posData.rp = skimage.measure.regionprops(posData.lab) + self.setManualBackgroundLab() + + if posData.acdc_df is not None: + frames = posData.acdc_df.index.get_level_values(0) + if posData.frame_i in frames: + # Since there was already segmentation metadata from + # previous closed session add it to current metadata + df = posData.acdc_df.loc[posData.frame_i].copy() + binnedIDs_df = df[df['is_cell_excluded']>0] + binnedIDs = set(binnedIDs_df.index).union(posData.binnedIDs) + posData.binnedIDs = binnedIDs + ripIDs_df = df[df['is_cell_dead']>0] + ripIDs = set(ripIDs_df.index).union(posData.ripIDs) + posData.ripIDs = ripIDs + posData.editID_info.extend(self._get_editID_info(df)) + # Load cca df into current metadata + if 'cell_cycle_stage' in df.columns: + cca_cols = df.columns.intersection(self.cca_df_colnames) + cca_df = df[cca_cols].dropna() + if cca_df.empty: + df = df.drop( + columns=self.cca_df_colnames, errors='ignore' + ) + else: + df = df.loc[cca_df.index] + cols = self.cca_df_int_cols + df[cols] = df[cols].astype('Int64') + + i = posData.frame_i + posData.allData_li[i]['acdc_df'] = df.copy() + + if self.lineage_tree is None and lin_tree_init: + self.initLinTree() + + self.get_cca_df() + + return proceed_cca, never_visited + + def _get_data_visited(self, posData, debug=False, lin_tree_init=True,): + # Requested frame was already visited. Load from RAM. + never_visited = False + posData.lab = self.get_labels(from_store=True) + posData.rp = skimage.measure.regionprops(posData.lab) + df = posData.allData_li[posData.frame_i]['acdc_df'] + if df is None: + posData.binnedIDs = set() + posData.ripIDs = set() + posData.editID_info = [] + else: + try: + binnedIDs_df = df[df['is_cell_excluded']>0] + except Exception as err: + df = myutils.fix_acdc_df_dtypes(df) + binnedIDs_df = df[df['is_cell_excluded']>0] + posData.binnedIDs = set(binnedIDs_df.index) + ripIDs_df = df[df['is_cell_dead']>0] + posData.ripIDs = set(ripIDs_df.index) + posData.editID_info = self._get_editID_info(df) + self.setManualBackgroundLab(load_from_store=True, debug=debug) + if self.lineage_tree is None and lin_tree_init: + self.initLinTree() + + self.get_cca_df(debug=debug) + + return True, never_visited + + @get_data_exception_handler + def get_data(self, debug=False, lin_tree_init=True): + posData = self.data[self.pos_i] + proceed_cca = True + never_visited = False + if posData.frame_i > 2: + # Remove undo states from 4 frames back to avoid memory issues + posData.UndoRedoStates[posData.frame_i-4] = [] + # Check if current frame contains undo states (not empty list) + if posData.UndoRedoStates[posData.frame_i]: + self.undoAction.setDisabled(False) + elif posData.UndoRedoCcaStates[posData.frame_i]: + self.undoAction.setDisabled(False) + else: + self.undoAction.setDisabled(True) + self.UndoCount = 0 + # If stored labels is None then it is the first time we visit this frame + if posData.allData_li[posData.frame_i]['labels'] is None: + proceed_cca, never_visited = self._get_data_unvisited( + posData, lin_tree_init=lin_tree_init, + ) + if not proceed_cca: + return proceed_cca, never_visited + else: + proceed_cca, never_visited = self._get_data_visited( + posData, lin_tree_init=lin_tree_init, debug=debug + ) + + self.update_rp_metadata(draw=False) + posData.IDs = [obj.label for obj in posData.rp] + posData.IDs_idxs = { + ID:i for ID, i in zip(posData.IDs, range(len(posData.IDs))) + } + self.get_zslices_rp() + self.pointsLayerDfsToData(posData) + return proceed_cca, never_visited + + def addIDBaseCca_df(self, posData, ID): + if ID <= 0: + # When calling update_cca_df_deletedIDs we add relative IDs + # but they could be -1 for cells in G1 + return + + _zip = zip( + self.cca_df_colnames, + self.cca_df_default_values, + ) + if posData.cca_df.empty: + posData.cca_df = pd.DataFrame( + {col: val for col, val in _zip}, + index=[ID] + ) + else: + for col, val in _zip: + posData.cca_df.at[ID, col] = val + self.store_cca_df() + + def getBaseCca_df(self, with_tree_cols=False): + posData = self.data[self.pos_i] + IDs = [obj.label for obj in posData.rp] + cca_df = core.getBaseCca_df(IDs, with_tree_cols=with_tree_cols) + return cca_df + + def get_last_tracked_i(self): + posData = self.data[self.pos_i] + last_tracked_i = 0 + for frame_i, data_dict in enumerate(posData.allData_li): + lab = data_dict['labels'] + if lab is None and frame_i == 0: + last_tracked_i = 0 + break + elif lab is None: + last_tracked_i = frame_i-1 + break + else: + last_tracked_i = posData.segmSizeT-1 + return last_tracked_i + + def get_last_cca_frame_i(self): + posData = self.data[self.pos_i] + + i = 0 + # Determine last annotated frame index + for i, dict_frame_i in enumerate(posData.allData_li): + df = dict_frame_i['acdc_df'] + if df is None: + break + elif 'cell_cycle_stage' not in df.columns: + break + + last_cca_frame_i = i if i==0 or i+1==len(posData.allData_li) else i-1 + + return last_cca_frame_i + + def initSegmTrackMode(self): + posData = self.data[self.pos_i] + last_tracked_i = self.get_last_tracked_i() + + if posData.frame_i > last_tracked_i: + # Prompt user to go to last tracked frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + f'The last visited frame in "Segmentation and Tracking mode" ' + f'is frame {last_tracked_i+1}.\n\n' + f'We recommend to resume from that frame.

' + 'How do you want to proceed?' + ) + goToButton, stayButton = msg.warning( + self, 'Go to last visited frame?', txt, + buttonsTexts=( + f'Resume from frame {last_tracked_i+1} (RECOMMENDED)', + f'Stay on current frame {posData.frame_i+1}' + ) + ) + if msg.clickedButton == goToButton: + posData.frame_i = last_tracked_i + self.lastFrameRanOnFirstVisitTools = posData.frame_i + self.get_data() + self.updateAllImages() + self.updateScrollbars() + else: + last_tracked_i = posData.frame_i + current_frame_i = posData.frame_i + self.lastFrameRanOnFirstVisitTools = posData.frame_i + self.logger.info( + f'Storing data up until frame n. {current_frame_i+1}...' + ) + pbar = tqdm(total=current_frame_i+1, ncols=100) + for i in range(current_frame_i): + posData.frame_i = i + self.get_data() + self.store_data(autosave=i==current_frame_i-1) + pbar.update() + pbar.close() + + posData.frame_i = current_frame_i + self.get_data() + + self.highlightLostNew() + self.updateLastCheckedFrameWidgets(last_tracked_i) + + self.isFirstTimeOnNextFrame() + self.initRealTimeTracker() + + def updateLastCheckedFrameWidgets(self, last_tracked_i): + self.navigateScrollBar.setMaximum(last_tracked_i+1) + self.navSpinBox.setMaximum(last_tracked_i+1) + self.lastTrackedFrameLabel.setText( + f'Last checked frame n. = {last_tracked_i+1}' + ) + + @exception_handler + def initCca(self): + posData = self.data[self.pos_i] + last_tracked_i = self.get_last_tracked_i() + defaultMode = 'Viewer' + if last_tracked_i == 0: + txt = html_utils.paragraph( + 'On this dataset either you never checked that the segmentation ' + 'and tracking are correct or you did not save yet.

' + 'If you already visited some frames with "Segmentation and Tracking" ' + 'mode save data before switching to "Cell cycle analysis mode".

' + 'Otherwise you first have to check (and eventually correct) some frames ' + 'in "Segmentation and Tracking" mode before proceeding ' + 'with cell cycle analysis.') + msg = widgets.myMessageBox() + msg.critical( + self, 'Tracking was never checked', txt + ) + self.modeComboBox.setCurrentText(defaultMode) + return + + proceed = True + + last_cca_frame_i = self.get_last_cca_frame_i() + if last_cca_frame_i == 0: + # Remove undoable actions from segmentation mode + posData.UndoRedoStates[0] = [] + self.undoAction.setEnabled(False) + self.redoAction.setEnabled(False) + + if posData.frame_i > last_cca_frame_i: + # Prompt user to go to last annotated frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + The last annotated frame is frame {last_cca_frame_i+1}.

+ Do you want to restart cell cycle analysis from frame + {last_cca_frame_i+1}?
+ """) + _, goToFrameButton, stayButton = msg.warning( + self, 'Go to last annotated frame?', txt, + buttonsTexts=( + 'Cancel', f'Yes, go to frame {last_cca_frame_i+1}', + 'No, stay on current frame') + ) + if goToFrameButton == msg.clickedButton: + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + msg = 'Looking good!' + self.last_cca_frame_i = last_cca_frame_i + posData.frame_i = last_cca_frame_i + self.titleLabel.setText(msg, color=self.titleColor) + self.get_data() + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + self.updateAllImages() + self.updateScrollbars() + elif stayButton == msg.clickedButton: + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + self.initMissingFramesCca(last_cca_frame_i, posData.frame_i) + last_cca_frame_i = posData.frame_i + msg = 'Cell cycle analysis initialised!' + self.titleLabel.setText(msg, color='g') + elif msg.cancel: + msg = 'Cell cycle analysis aborted.' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + self.modeComboBox.setCurrentText(defaultMode) + proceed = False + return + elif posData.frame_i < last_cca_frame_i: + # Prompt user to go to last annotated frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + The last annotated frame is frame {last_cca_frame_i+1}.

+ Do you want to restart cell cycle analysis from frame + {last_cca_frame_i+1}?
+ """) + yesButton, noButton, _ = msg.question( + self, 'Go to last annotated frame?', txt, + buttonsTexts=('Yes', 'No', 'Cancel') + ) + if msg.cancel: + msg = 'Cell cycle analysis aborted.' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + self.modeComboBox.setCurrentText(defaultMode) + proceed = False + return + + self.addMissingIDs_cca_df(posData) + if msg.clickedButton == yesButton: + self.addMissingIDs_cca_df(posData) + msg = 'Looking good!' + self.titleLabel.setText(msg, color=self.titleColor) + self.last_cca_frame_i = last_cca_frame_i + posData.frame_i = last_cca_frame_i + self.get_data() + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + self.updateAllImages() + self.updateScrollbars() + else: + self.get_data() + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + + self.last_cca_frame_i = last_cca_frame_i + + self.navigateScrollBar.setMaximum(last_cca_frame_i+1) + self.navSpinBox.setMaximum(last_cca_frame_i+1) + self.lastTrackedFrameLabel.setText( + f'Last cc annot. frame n. = {last_cca_frame_i+1}' + ) + + if posData.cca_df is None: + posData.cca_df = self.getBaseCca_df() + self.store_cca_df() + msg = 'Cell cycle analysis initialized!' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + else: + self.get_cca_df() + + self.enqCcaIntegrityChecker() + + return proceed + @exception_handler + def initLinTree(self, force=False): + """ + Initializes the lineage tree analysis. + + This method checks if the tracking has been previously checked and saved. If not, it displays a message to the user. + It also prompts the user to go to the last annotated frame and restart the lineage tree analysis if necessary. + Finally, it initializes the necessary data structures and updates the GUI. + + Returns + ------- + proceed : bool + True if the initialization is successful, nothing otherwise. + """ + + if not force and self.lineage_tree is not None: + return + + mode = str(self.modeComboBox.currentText()) + if mode != 'Normal division: Lineage tree' and not force: + return + + posData = self.data[self.pos_i] + last_tracked_i = self.get_last_tracked_i() + defaultMode = 'Viewer' + if last_tracked_i == 0: + # Display message to the user + txt = html_utils.paragraph( + 'On this dataset either you never checked that the segmentation ' + 'and tracking are correct or you did not save yet.

' + 'If you already visited some frames with "Segmentation and Tracking" ' + 'mode save data before switching to "Normal division: Lineage Tree".

' + 'Otherwise you first have to check (and eventually correct) some frames ' + 'in "Segmentation and Tracking" mode before proceeding ' + 'with lineage tree analysis.') + msg = widgets.myMessageBox() + msg.critical( + self, 'Tracking was never checked', txt + ) + self.modeComboBox.setCurrentText(defaultMode) + return + + proceed = True + last_lin_tree_frame_i = 0 + # Determine last annotated frame index + for i, dict_frame_i in enumerate(posData.allData_li): + df = dict_frame_i['acdc_df'] + if (df is None or + 'generation_num_tree' not in df.columns + or df['generation_num_tree'].isin([np.nan, 0]).all() + ): + break + else: + last_lin_tree_frame_i = i + + if last_lin_tree_frame_i == 0: + # Remove undoable actions from segmentation mode + posData.UndoRedoStates[0] = [] + self.undoAction.setEnabled(False) + self.redoAction.setEnabled(False) + + if posData.frame_i > last_lin_tree_frame_i: + # Prompt user to go to last annotated frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + The last annotated frame is frame {last_lin_tree_frame_i+1}.

+ Do you want to restart lineage tree analysis from frame + {last_lin_tree_frame_i+1}?
+ """) + _, yesButton, stayButton = msg.warning( + self, 'Go to last annotated frame?', txt, + buttonsTexts=( + 'Cancel', f'Yes, go to frame {last_lin_tree_frame_i+1}', + 'No, stay on current frame') + ) + if yesButton == msg.clickedButton: + msg = 'Looking good!' + self.last_lin_tree_frame_i = last_lin_tree_frame_i + posData.frame_i = last_lin_tree_frame_i + self.titleLabel.setText(msg, color=self.titleColor) + self.get_data(lin_tree_init=False) + self.updateAllImages() # i dont think I need to change this + self.updateScrollbars() # i dont think I need to change this + elif stayButton == msg.clickedButton: + self.initMissingFramesLinTree(posData.frame_i) #!!! + last_lin_tree_frame_i = posData.frame_i + msg = 'Lineage tree analysis initialised!' + self.titleLabel.setText(msg, color='g') + elif msg.cancel: + msg = 'Lineage tree analysis aborted.' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + self.modeComboBox.setCurrentText(defaultMode) + proceed = False + return + + elif posData.frame_i < last_lin_tree_frame_i: + # Prompt user to go to last annotated frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + The last annotated frame is frame {last_lin_tree_frame_i+1}.

+ Do you want to restart lineage tree analysis from frame + {last_lin_tree_frame_i+1}?
+ """) + goTo_last_annotated_frame_i = msg.question( + self, 'Go to last annotated frame?', txt, + buttonsTexts=('Yes', 'No', 'Cancel') + )[0] + if goTo_last_annotated_frame_i == msg.clickedButton: + msg = 'Looking good!' + self.titleLabel.setText(msg, color=self.titleColor) + self.last_lin_tree_frame_i = last_lin_tree_frame_i + posData.frame_i = last_lin_tree_frame_i + self.get_data(lin_tree_init=False) + self.updateAllImages() # i dont think I need to change this + self.updateScrollbars() # i dont think I need to change this + elif msg.cancel: + msg = 'Lineage tree analysis aborted.' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + self.modeComboBox.setCurrentText(defaultMode) + proceed = False + return + else: + self.get_data(lin_tree_init=False) + + self.last_lin_tree_frame_i = last_lin_tree_frame_i + + self.navigateScrollBar.setMaximum(last_lin_tree_frame_i+1) + self.navSpinBox.setMaximum(last_lin_tree_frame_i+1) + + if self.lineage_tree is None or force: + self.store_data(autosave=False) + self.get_data(lin_tree_init=False) + self.lineage_tree = normal_division_lineage_tree(gui=self) + + msg = 'Lineage tree analysis initialized!' + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + + return proceed + + @disableWindow + def propagateLinTreeAction(self, dummy_for_button=None): + """ + Propagates the lineage tree based on the current frame_i. Used in self.propagateLinTreeButton. + """ + posData = self.data[self.pos_i] + self.lineage_tree.propagate(posData.frame_i) + if posData.frame_i == self.original_df_lin_tree_i: + self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() + + self.logger.info('Lineage tree propagated.') + + def isCcaCheckerChecking(self): + if not self.ccaCheckerRunning: + return False + + return self.ccaIntegrityCheckerWorker.isChecking + + def getConcatCcaDf(self): + posData = self.data[self.pos_i] + cca_dfs = [] + keys = [] + for frame_i in range(0, posData.SizeT): + cca_df = self.get_cca_df(frame_i=frame_i, return_df=True) + if cca_df is None: + break + + cca_dfs.append(cca_df) + keys.append(frame_i) + + if not cca_dfs: + return + + global_cca_df = pd.concat(cca_dfs, keys=keys, names=['frame_i']) + return global_cca_df + + def storeFromConcatCcaDf(self, global_cca_df): + posData = self.data[self.pos_i] + for frame_i in range(0, posData.SizeT): + try: + cca_df = global_cca_df.loc[frame_i] + except KeyError as err: + break + + self.store_cca_df(frame_i=frame_i, cca_df=cca_df, autosave=False) + + self.get_cca_df() + + def resetWillDivideInfo(self): + global_cca_df = self.getConcatCcaDf() + if global_cca_df is None: + return + + global_cca_df = load._fix_will_divide(global_cca_df) + self.storeFromConcatCcaDf(global_cca_df) + + def ccaCheckerStopChecking(self): + if not self.ccaCheckerRunning: + return + + self.ccaIntegrityCheckerWorker.clearQueue() + + if self.ccaIntegrityCheckerWorker.isChecking: + self.ccaIntegrityCheckerWorker.abortChecking = True + + def updateLastVisitedFrame(self, last_visited_frame_i=None): + if last_visited_frame_i is None: + posData = self.data[self.pos_i] + last_visited_frame_i = posData.frame_i + + mode = str(self.modeComboBox.currentText()) + if mode == 'Viewer': + return + elif mode == 'Segmentation and Tracking': + posData = self.data[self.pos_i] + if posData.last_tracked_i >= last_visited_frame_i: + return + posData.last_tracked_i = last_visited_frame_i + elif mode == 'Cell cycle analysis': + if self.last_cca_frame_i >= last_visited_frame_i: + return + self.last_cca_frame_i = last_visited_frame_i + + def resetCcaFuture(self, from_frame_i): + posData = self.data[self.pos_i] + self.last_cca_frame_i = from_frame_i-1 + self.ccaCheckerStopChecking() + + self.setNavigateScrollBarMaximum() + for i in range(from_frame_i, posData.SizeT): + posData.allData_li[i].pop('cca_df', None) + posData.allData_li[i].pop('cca_df_checker', None) + + df = posData.allData_li[i]['acdc_df'] + if df is None: + # No more saved info to delete + break + + if 'cell_cycle_stage' not in df.columns: + # No cell cycle info present + continue + + df = df.drop(columns=self.cca_df_colnames) + posData.allData_li[i]['acdc_df'] = df + + if posData.acdc_df is not None: + frames = posData.acdc_df.index.get_level_values(0) + if from_frame_i in frames: + posData.acdc_df = posData.acdc_df.loc[:from_frame_i] + + self.resetWillDivideInfo() + + def removeCcaAnnotationsCurrentFrame(self): + posData = self.data[self.pos_i] + posData.cca_df = None + + posData.allData_li[posData.frame_i].pop('cca_df', None) + posData.allData_li[posData.frame_i].pop('cca_df_checker', None) + + df = posData.allData_li[posData.frame_i]['acdc_df'] + if df is None: + # No more saved info to delete + return False + + if 'cell_cycle_stage' not in df.columns: + # No cell cycle info present + return False + + df = df.drop(columns=self.cca_df_colnames) + posData.allData_li[posData.frame_i]['acdc_df'] = df + + return True + + def resetFutureCcaColCurrentFrame(self): + posData = self.data[self.pos_i] + + cca_df_S_mask = posData.cca_df.cell_cycle_stage == 'S' + posData.cca_df.loc[cca_df_S_mask, 'will_divide'] = 0 + + mothers_mask = ( + (posData.cca_df.relationship == 'mother') + & cca_df_S_mask + ) + bud_mask = posData.cca_df.relationship == 'bud' + + posData.cca_df.loc[mothers_mask, 'daughter_disappears_before_division'] = 0 + posData.cca_df.loc[bud_mask, 'disappears_before_division'] = 0 + + cca_df = self.get_cca_df(frame_i=posData.frame_i, return_df=True) + if cca_df is not None: + cca_df_S_mask = cca_df.cell_cycle_stage == 'S' + cca_df.loc[cca_df_S_mask, 'will_divide'] = 0 + + mothers_mask = ( + (cca_df.relationship == 'mother') + & cca_df_S_mask + ) + bud_mask = cca_df.relationship == 'bud' + + cca_df.loc[mothers_mask, 'daughter_disappears_before_division'] = 0 + cca_df.loc[bud_mask, 'disappears_before_division'] = 0 + + self.store_data() + + def resetLin_tree_future(self): + posData = self.data[self.pos_i] + frame_i = posData.frame_i + + for i in range(frame_i, posData.SizeT): + if self.lineage_tree is not None: + self.lineage_tree.frames_for_dfs.discard(frame_i) + df = posData.allData_li[i]['acdc_df'] + # reste lineage tree columns + if df is None: + continue + df = df.drop(columns=lineage_tree_cols, errors='ignore') + posData.allData_li[i]['acdc_df'] = df + + def get_cca_df(self, frame_i=None, return_df=False, debug=False): + # cca_df is None unless the metadata contains cell cycle annotations + # NOTE: cell cycle annotations are either from the current session + # or loaded from HDD in "initPosAttr" with a .question to the user + posData = self.data[self.pos_i] + cca_df = None + i = posData.frame_i if frame_i is None else frame_i + df = posData.allData_li[i]['acdc_df'] + if df is not None: + if 'cell_cycle_stage' in df.columns: + cca_df = df[self.cca_df_colnames].copy() + + if cca_df is None and self.isSnapshot: + cca_df = self.getBaseCca_df() + posData.cca_df = cca_df + + if cca_df is not None: + cca_df = cca_df.dropna() + + if return_df: + return cca_df + else: + posData.cca_df = cca_df + + def changeIDfutureFrames( + self, endFrame_i, oldIDnewIDMapper, includeUnvisited, + shift=False + ): + posData = self.data[self.pos_i] + self.current_frame_i = posData.frame_i + + # Store data for current frame + self.store_data() + if endFrame_i is None: + self.app.restoreOverrideCursor() + return + + segmSizeT = len(posData.segm_data) + for i in range(posData.frame_i+1, segmSizeT): + lab = posData.allData_li[i]['labels'] + if lab is None and not includeUnvisited: + self.enqAutosave() + break + + if lab is not None: + # Visited frame + posData.frame_i = i + self.get_data(lin_tree_init=False) + if shift and self.isSegm3D: + lab = self.get_2Dlab(posData.lab) + else: + lab = posData.lab + + if self.onlyTracking: + self.tracking(enforce=True) + elif not posData.IDs: + continue + else: + maxID = max(posData.IDs, default=0) + 1 + for old_ID, new_ID in oldIDnewIDMapper: + if new_ID in lab: + tempID = maxID + 1 # lab.max() + 1 + lab[lab == old_ID] = tempID + lab[lab == new_ID] = old_ID + lab[lab == tempID] = new_ID + maxID += 1 + else: + lab[lab == old_ID] = new_ID + + if shift and self.isSegm3D: + self.set_2Dlab(lab) + + self.update_rp(draw=False) + self.store_data(autosave=i==endFrame_i) + elif includeUnvisited: + # Unvisited frame (includeUnvisited = True) + lab = posData.segm_data[i] + if shift and self.isSegm3D: + lab = self.get_2Dlab(lab) + else: + lab = lab + + for old_ID, new_ID in oldIDnewIDMapper: + if new_ID in lab: + tempID = lab.max() + 1 + lab[lab == old_ID] = tempID + lab[lab == new_ID] = old_ID + lab[lab == tempID] = new_ID + else: + lab[lab == old_ID] = new_ID + + if shift and self.isSegm3D: + posData.segm_data[i][self.z_lab()] = lab + + # Back to current frame + posData.frame_i = self.current_frame_i + self.get_data() + self.app.restoreOverrideCursor() + + def unstore_cca_df(self): + posData = self.data[self.pos_i] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + for col in self.cca_df_colnames: + if col not in acdc_df.columns: + continue + acdc_df.drop(col, axis=1, inplace=True) + + def store_cca_df_checker(self, posData, frame_i, cca_df): + if not self.ccaCheckerRunning: + return + + if cca_df is None: + return + + posData.allData_li[frame_i]['cca_df_checker'] = cca_df.copy() + + def store_cca_df( + self, pos_i=None, frame_i=None, cca_df=None, mainThread=True, + autosave=True, store_cca_df_copy=False + ): + pos_i = self.pos_i if pos_i is None else pos_i + posData = self.data[pos_i] + i = posData.frame_i if frame_i is None else frame_i + if cca_df is None: + cca_df = posData.cca_df + if self.ccaTableWin is not None and mainThread: + zoomIDs = self.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + acdc_df = posData.allData_li[i]['acdc_df'] + if acdc_df is None: + current_frame_i = None + if frame_i is not None and frame_i != posData.frame_i: + current_frame_i = posData.frame_i + posData.frame_i = frame_i + self.get_data() + self.store_data() + acdc_df = posData.allData_li[i]['acdc_df'] + if current_frame_i is not None: + # Back to current frame + posData.frame_i = current_frame_i + self.get_data(debug=False) + + if 'cell_cycle_stage' in acdc_df.columns: + # Cell cycle info already present --> overwrite with new + acdc_df[self.cca_df_colnames] = cca_df[self.cca_df_colnames] + posData.allData_li[i]['acdc_df'] = acdc_df + elif cca_df is not None: + df = acdc_df.drop(cca_df.columns, axis=1, errors='ignore') + df = df.join(cca_df, how='left') + posData.allData_li[i]['acdc_df'] = df + + # Store copy for cca integrity worker + self.store_cca_df_checker(posData, i, cca_df) + + if store_cca_df_copy and cca_df is not None: + posData.allData_li[i]['cca_df'] = cca_df.copy() + + if autosave: + self.enqAutosave() + self.enqCcaIntegrityChecker() + + # def lin_tree_to_acdc_df(self, force_all=False, ignore=set(), force=set(), specific=set()): + # """ + # Syncs the lineage tree DataFrame with the acdc_df DataFrame. By default, it will only try to sync frames which have not been synced before. + # This can be changed using the optional arguments. + + # Parameters + # ---------- + # force_all : bool, optional + # If True, forces synchronization for all frames. Defaults to False. + # ignore : set, optional + # Set of frames to ignore during synchronization. Defaults to set(). + # force : set, optional + # Set of frames to force synchronization. Defaults to set(). + # specific : set, optional + # Set of frames to specifically synchronize. In this case it will ignore all other inputs and sync those no matter what. Defaults to set(). + # """ + + # if self.lineage_tree is None: + # return + + # # df_for_sync = [] + # # lineage_copy = self.lineage_tree.lineage_list.copy() + # lin_tree_set = self.lineage_tree.frames_for_dfs.copy() + + # if not force_all and not specific: + # dont_sync = self.already_synced_lin_tree + # dont_sync = {frame for frame in dont_sync if not frame in force} + # dont_sync.update(ignore) + + # lin_tree_set = lin_tree_set.difference(dont_sync) + + # if specific: + # lin_tree_set = lin_tree_set.intersection(specific) + + + # if lin_tree_set == []: + # return + + # posData = self.data[self.pos_i] + + # lin_tree_colnames = None + # self.store_data(autosave=False) + # for frame_i in lin_tree_set: + # acdc_df = posData.allData_li[frame_i]['acdc_df'] + + # lin_tree_df = self.lineage_tree.export_df(frame_i) + # if lin_tree_colnames is None: + # lin_tree_colnames = lin_tree_df.columns + + # acdc_df.loc[lin_tree_df.index, lin_tree_colnames] = lin_tree_df[lin_tree_colnames] + + # try: + # try: + # if (acdc_df['generation_num'] == 2).all() and not (acdc_df['generation_num_tree'].isna().all()): # check if generation_num is all just the default value and if yes, replace it with the tree values + # acdc_df['generation_num'] = acdc_df['generation_num_tree'] + # except KeyError: + # acdc_df['generation_num'] = acdc_df['generation_num_tree'] + # except Exception as e: + # self.logger.error(f'Error while syncing generation_num from lineage tree: {e} \n please save and restart') + + # posData.allData_li[frame_i]['acdc_df'] = acdc_df + # self.already_synced_lin_tree.add(frame_i) + + def turnOffAutoSaveWorker(self): + self.autoSaveToggle.setChecked(False) + + def autoSaveTimerTimedOut(self): + if not hasattr(self, 'data'): + # This happes when the self.autoSaveTimer times out after + # the GUI has been closed --> we simply ignore it + self.autoSaveTimer.stop() + return + + self.autoSaveTimer.stop() + self.flushDirtyPointsLayersAutosave() + self._enqueueAutoSave() + + def autoSaveTimerCountFrames(self): + if not hasattr(self, 'data'): + # This happes when the self.autoSaveTimer times out after + # the GUI has been closed --> we simply ignore it + return + + posData = self.data[self.pos_i] + autoSaveIntevalValue, autoSaveIntervalUnit = ( + self.autoSaveIntevalValueUnit + ) + isTimeToAutoSave = ( + abs(posData.frame_i - self.autoSaveTimeStartFrameIdx) + >= autoSaveIntevalValue + ) + if not isTimeToAutoSave: + return + + self.autoSaveTimeStartFrameIdx = posData.frame_i + self.flushDirtyPointsLayersAutosave() + self._enqueueAutoSave() + + def enqAutosave(self): + mode = str(self.modeComboBox.currentText()) + if mode == 'Viewer': + if self.statusBarLabel.text().endswith('Autosaving...'): + self.statusBarLabel.setText( + self.statusBarLabel.text().replace(' | Autosaving...', '') + ) + return + + if not self.autoSaveActiveWorkers: + self.gui_createAutoSaveWorker() + + if not self.autoSaveActiveWorkers: + return + + if self.autoSaveTimer.isActive(): + return + + self._enqueueAutoSave() + autoSaveIntevalValue, autoSaveIntervalUnit = ( + self.autoSaveIntevalValueUnit + ) + if autoSaveIntevalValue == 0: + return + + try: + self.autoSaveTimer.timeout.disconnect() + except Exception as err: + pass + + + if autoSaveIntervalUnit == 'minutes': + autosave_interval_ms = round(autoSaveIntevalValue*60*1000) + self.autoSaveTimer.timeout.connect(self.autoSaveTimerTimedOut) + self.autoSaveTimer.start(autosave_interval_ms) + else: + self.startAutoSaveEveryNframesTimer() + + def startAutoSaveEveryNframesTimer(self): + posData = self.data[self.pos_i] + self.autoSaveTimeStartFrameIdx = posData.frame_i + self.autoSaveTimer.timeout.connect( + self.autoSaveTimerCountFrames + ) + self.autoSaveTimer.start(500) + + def _enqueueAutoSave(self): + if not self.statusBarLabel.text().endswith('Autosaving...'): + self.statusBarLabel.setText( + f'{self.statusBarLabel.text()} | Autosaving...' + ) + + timestamp = datetime.now().strftime(r'%H:%M:%S.%f')[:-3] + self.logger.info(f'Autosaving... - {timestamp}') + + posData = self.data[self.pos_i] + worker, thread = self.autoSaveActiveWorkers[-1] + worker.enqueue(posData) + + def enqCcaIntegrityChecker(self): + if not self.ccaCheckerRunning: + return + posData = self.data[self.pos_i] + self.ccaIntegrityCheckerWorker.enqueue(posData) + + def drawAllMothBudLines(self): + posData = self.data[self.pos_i] + for obj in posData.rp: + self.drawObjMothBudLines(obj, posData, ax=0) + self.drawObjMothBudLines(obj, posData, ax=1) + + def drawObjMothBudLines(self, obj, posData, ax=0): + areMothBudLinesRequested = self.areMothBudLinesRequested(ax) + if not areMothBudLinesRequested: + return + + if posData.cca_df is None: + return + + mode = str(self.modeComboBox.currentText()) + if mode == 'Normal division: Lineage Tree': + return + + ID = obj.label + try: + cca_df_ID = posData.cca_df.loc[ID] + except KeyError: + return + + isObjVisible = self.isObjVisible(obj.bbox) + if not isObjVisible: + return + + ccs_ID = cca_df_ID['cell_cycle_stage'] + if ccs_ID == 'G1': + return + + relationship = cca_df_ID['relationship'] + if relationship != 'bud': + return + + emerg_frame_i = cca_df_ID['emerg_frame_i'] + isNew = emerg_frame_i == posData.frame_i + scatterItem = self.getMothBudLineScatterItem(ax, isNew) + relative_ID = cca_df_ID['relative_ID'] + + try: + relative_rp_idx = posData.IDs_idxs[relative_ID] + except KeyError: + return + + relative_ID_obj = posData.rp[relative_rp_idx] + y1, x1 = self.getObjCentroid(obj.centroid) + y2, x2 = self.getObjCentroid(relative_ID_obj.centroid) + xx, yy = core.get_line(y1, x1, y2, x2, dashed=True) + scatterItem.addPoints(xx, yy) + + def clearAllCellToCellLines(self): + self.ax1_newMothBudLinesItem.setData([], []) + self.ax1_oldMothBudLinesItem.setData([], []) + self.ax2_newMothBudLinesItem.setData([], []) + self.ax2_oldMothBudLinesItem.setData([], []) + + def drawAllLineageTreeLines(self): + """ + Draw all lineage tree lines on the GUI. + + This method retrieves the lineage tree data and draws the lineage tree lines + connecting cells and their respective mothers when the mother has split. + """ + if self.lineage_tree is None: + return + + if len(self.lineage_tree.frames_for_dfs) < 2: + return + + self.clearAllCellToCellLines() + posData = self.data[self.pos_i] + frame_i = posData.frame_i + lin_tree_df = posData.allData_li[frame_i]['acdc_df'] + lin_tree_df_prev = posData.allData_li[frame_i-1]['acdc_df'] + rp = posData.rp + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + + self.setTitleText() + + new_cells = lin_tree_df.index.difference(lin_tree_df_prev.index) # I could use this for the if already but this is probably faster for frames where nothing changes + if new_cells.shape[0] == 0: + return + + for ax in (0, 1): + if not self.areMothBudLinesRequested(ax): + continue + + for ID in new_cells: + curr_obj = myutils.get_obj_by_label(rp, ID) + lin_tree_df_ID = lin_tree_df.loc[ID] + + # lin_tree_df_mother_ID = lin_tree_df_prev.loc[lin_tree_df_ID["parent_ID_tree"]] + if lin_tree_df_ID["parent_ID_tree"] == -1: # make sure that new obj where the parents are not known get skipped + continue + + mother_obj = myutils.get_obj_by_label(prev_rp, lin_tree_df_ID["parent_ID_tree"]) + + emerg_frame_i = lin_tree_df_ID["emerg_frame_i"] + isNew = emerg_frame_i == frame_i + + self.drawObjLin_TreeMothBudLines(ax, curr_obj, mother_obj, isNew, ID=ID) + + def drawObjLin_TreeMothBudLines(self, ax, obj, mother_obj, isNew, ID=None): + """ + Draw moth-bud lines between an object and its mother object. + + Parameters + ---------- + ax : cellacdc.widgets.MainPlotItem + The Cell-ACDC GUI axes object to draw on. + obj : Object + The object for which to draw the moth-bud lines. + mother_obj : Object + The mother object to connect with. + isNew : bool + Indicates whether the object is new or not. + ID : int, optional + The ID of the object, by default None. + """ + if not self.areMothBudLinesRequested(ax): + return + + if not ID: + ID = obj.label + + isObjVisible = self.isObjVisible(obj.bbox) + + if not isObjVisible: + return + + scatterItem = self.getMothBudLineScatterItem(ax, isNew) + + y1, x1 = self.getObjCentroid(obj.centroid) + y2, x2 = self.getObjCentroid(mother_obj.centroid) + xx, yy = core.get_line(y1, x1, y2, x2, dashed=True) + scatterItem.addPoints(xx, yy) + + def getObjCentroid(self, obj_centroid): + if self.isSegm3D: + depthAxes = self.switchPlaneCombobox.depthAxes() + zc, yc, xc = obj_centroid + if depthAxes == 'z': + return yc, xc + elif depthAxes == 'y': + return zc, xc + else: + return zc, yc + else: + return obj_centroid + + def getAnnotateHowRightImage(self): + if not self.labelsGrad.showRightImgAction.isChecked(): + return 'nothing' + + if self.rightBottomGroupbox.isChecked(): + how = self.annotateRightHowCombobox.currentText() + else: + how = self.drawIDsContComboBox.currentText() + return how + + def getObjOptsSegmLabels(self, obj): + if not self.labelsGrad.showLabelsImgAction.isChecked(): + return + + objOpts = self.getObjTextAnnotOpts(obj, 'Draw only IDs', ax=1) + return objOpts + + def store_zslices_rp(self, force_update=False): + if not self.isSegm3D: + return + + posData = self.data[self.pos_i] + are_zslices_rp_stored = ( + posData.allData_li[posData.frame_i].get('z_slices_rp') is not None + ) + if force_update or not are_zslices_rp_stored: + self._update_zslices_rp() + + posData.allData_li[posData.frame_i]['z_slices_rp'] = posData.zSlicesRp + + def removeObjectFromRp(self, delID): + posData = self.data[self.pos_i] + rp = [] + IDs = [] + IDs_idxs = {} + idx = 0 + for obj in posData.rp: + if obj.label == delID: + continue + rp.append(obj) + IDs.append(obj.label) + IDs_idxs[obj.label] = idx + idx += 1 + + posData.rp = rp + posData.IDs = IDs + posData.IDs_idxs = IDs_idxs + + if not self.isSegm3D: + return + + zSlicesRp = {} + for z, zSliceRp in posData.zSlicesRp.items(): + if delID in zSliceRp: + continue + + zSlicesRp[z] = zSlicesRp + + posData.zSlicesRp = zSlicesRp + self.store_zslices_rp(force_update=True) + + def get_zslices_rp(self): + if not self.isSegm3D: + return + + posData = self.data[self.pos_i] + self.store_zslices_rp() + posData.zSlicesRp = posData.allData_li[posData.frame_i]['z_slices_rp'] + + # @exec_time + def _update_zslices_rp(self): + if not self.isSegm3D: + return + + posData = self.data[self.pos_i] + posData.zSlicesRp = {} + for z, lab2d in enumerate(posData.lab): + lab2d_rp = skimage.measure.regionprops(lab2d) + posData.zSlicesRp[z] = {obj.label:obj for obj in lab2d_rp} + + def instructHowDeleteID(self): + if 'showInfoDeleteObject' not in self.df_settings.index: + self.df_settings.at['showInfoDeleteObject', 'value'] = 'Yes' + + showInfoDeleteObject = ( + self.df_settings.at['showInfoDeleteObject', 'value'] == 'Yes' + ) + if not showInfoDeleteObject: + return + + actionText = self.middleClickText() + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + 'You have deleted an object using the eraser tool.

' + 'Did you know that you can use the "Delete object" action
' + 'to delete an object with a single click?

' + f'To do so, use the following action: {actionText}

' + 'Note: You can also set a custom shortcut by going to the menu
' + 'Settings --> Customise keyboard shortcuts....' + ) + doNotShowAgainCheckbox = QCheckBox('Do not show again') + msg.information( + self, 'Delete objects with single click', txt, + widgets=doNotShowAgainCheckbox + ) + + showInfoDeleteObjectValue = ( + 'No' if doNotShowAgainCheckbox.isChecked() else 'Yes' + ) + self.df_settings.at['showInfoDeleteObject', 'value'] = ( + showInfoDeleteObjectValue + ) + self.df_settings.to_csv(settings_csv_path) + + + def checkWarnDeletedIDwithEraser(self): + posData = self.data[self.pos_i] + + for ID in self.erasedIDs: + if ID == 0: + continue + if ID in posData.IDs_idxs: + continue + + self.instructHowDeleteID() + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Delete ID with eraser') + self.updateAllImages() + else: + self.warnEditingWithCca_df('Delete ID with eraser') + + return True + + return False + + @exception_handler + def update_rp( + self, draw=True, debug=False, update_IDs=True, + wl_update=True, wl_track_og_curr=False,wl_update_lab=False + ): + + posData = self.data[self.pos_i] + # Update rp for current posData.lab (e.g. after any change) + + if wl_update: + if self.whitelistOriginalIDs is None: + old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() # for whitelist stuff + else: + old_IDs = self.whitelistOriginalIDs.copy() + self.whitelistOriginalIDs = None + elif self.whitelistOriginalIDs is None: + self.whitelist_old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() + + posData.rp = skimage.measure.regionprops(posData.lab) + if update_IDs: + IDs = [] + IDs_idxs = {} + for idx, obj in enumerate(posData.rp): + IDs.append(obj.label) + IDs_idxs[obj.label] = idx + posData.IDs = IDs + posData.IDs_idxs = IDs_idxs + self.update_rp_metadata(draw=draw) + self.store_zslices_rp(force_update=True) + + if not wl_update: + return + + # Update tracking whitelist + accepted_lost_centroids = self.getTrackedLostIDs() + new_IDs = posData.IDs + added_IDs = set(new_IDs) - set(old_IDs) + removed_IDs = ( + set(old_IDs) + - set(new_IDs) + - set(accepted_lost_centroids) + ) + + self.whitelistPropagateIDs( + IDs_to_add=added_IDs, IDs_to_remove=removed_IDs, + curr_frame_only=True, IDs_curr=new_IDs, + track_og_curr=wl_track_og_curr, + curr_lab=posData.lab, curr_rp=posData.rp, + update_lab=wl_update_lab + ) + + def extendLabelsLUT(self, lenNewLut): + posData = self.data[self.pos_i] + # Build a new lut to include IDs > than original len of lut + if lenNewLut > len(self.lut): + numNewColors = lenNewLut-len(self.lut) + # Index original lut + _lut = np.zeros((lenNewLut, 3), np.uint8) + _lut[:len(self.lut)] = self.lut + # Pick random colors and append them at the end to recycle them + randomIdx = np.random.randint(0,len(self.lut),size=numNewColors) + for i, idx in enumerate(randomIdx): + rgb = self.lut[idx] + _lut[len(self.lut)+i] = rgb + self.lut = _lut + self.initLabelsImageItems() + return True + return False + + def initLookupTableLab(self): + self.img2.setLookupTable(self.lut) + self.img2.setLevels([0, len(self.lut)]) + self.initLabelsImageItems() + + def getLabelsImageLut(self): + lut = np.zeros((len(self.lut), 4), dtype=np.uint8) + lut[:,-1] = 255 + lut[:,:-1] = self.lut + lut[0] = [0,0,0,0] + return lut + + def initLabelsImageItems(self): + lut = self.getLabelsImageLut() + self.labelsLayerImg1.setLevels([0, len(lut)]) + self.labelsLayerRightImg.setLevels([0, len(lut)]) + self.labelsLayerImg1.setLookupTable(lut) + self.labelsLayerRightImg.setLookupTable(lut) + alpha = self.imgGrad.labelsAlphaSlider.value() + self.labelsLayerImg1.setOpacity(alpha) + self.labelsLayerRightImg.setOpacity(alpha) + + def initKeepObjLabelsLayers(self): + lut = np.zeros((len(self.lut), 4), dtype=np.uint8) + lut[:,:-1] = self.lut + lut[:,-1:] = 255 + lut[0] = [0,0,0,0] + self.keepIDsTempLayerLeft.setLevels([0, len(lut)]) + self.keepIDsTempLayerLeft.setLookupTable(lut) + + + def updateTempLayerKeepIDs(self): + if not self.keepIDsButton.isChecked(): + return + + keptLab = np.zeros_like(self.currentLab2D) + + posData = self.data[self.pos_i] + for obj in posData.rp: + if obj.label not in self.keptObjectsIDs: + continue + + if not self.isObjVisible(obj.bbox): + continue + + _slice = self.getObjSlice(obj.slice) + _objMask = self.getObjImage(obj.image, obj.bbox) + + keptLab[_slice][_objMask] = obj.label + + self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) + + def highlightLabelID(self, ID, ax=0): + posData = self.data[self.pos_i] + try: + obj = posData.rp[posData.IDs_idxs[ID]] + except KeyError: + return + + self.textAnnot[ax].highlightObject(obj) + + def _keepObjects(self, keepIDs=None, lab=None, rp=None): + posData = self.data[self.pos_i] + if lab is None: + lab = posData.lab + + if rp is None: + rp = posData.rp + + if keepIDs is None: + keepIDs = self.keptObjectsIDs + + for obj in rp: + if obj.label in keepIDs: + continue + + lab[obj.slice][obj.image] = 0 + + return lab + + def clearHighlightedText(self): + pass + + def removeHighlightLabelID(self, IDs=None, ax=0): + posData = self.data[self.pos_i] + if IDs is None: + IDs = posData.IDs + + for ID in IDs: + obj = posData.rp[posData.IDs_idxs[ID]] + self.textAnnot[ax].removeHighlightObject(obj) + + def updateKeepIDs(self, IDs): + posData = self.data[self.pos_i] + + self.clearHighlightedText() + + isAnyIDnotExisting = False + # Check if IDs from line edit are present in current keptObjectIDs list + for ID in IDs: + if ID not in posData.allIDs: + isAnyIDnotExisting = True + continue + if ID not in self.keptObjectsIDs: + self.keptObjectsIDs.append(ID, editText=False) + self.highlightLabelID(ID) + + # Check if IDs in current keptObjectsIDs are present in IDs from line edit + for ID in self.keptObjectsIDs: + if ID not in posData.allIDs: + isAnyIDnotExisting = True + continue + if ID not in IDs: + self.keptObjectsIDs.remove(ID, editText=False) + + self.updateTempLayerKeepIDs() + if isAnyIDnotExisting: + self.keptIDsLineEdit.warnNotExistingID() + else: + self.keptIDsLineEdit.setInstructionsText() + + @exception_handler + def applyKeepObjects(self): + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + self._keepObjects() + self.highlightHoverIDsKeptObj(0, 0, hoverID=0) + + posData = self.data[self.pos_i] + + self.update_rp() + # Repeat tracking + self.tracking(enforce=True, assign_unique_new_IDs=False) + + if self.isSnapshot: + self.fixCcaDfAfterEdit('Deleted non-selected objects') + self.updateAllImages() + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + return + else: + removeAnnot = self.warnEditingWithCca_df( + 'Deleted non-selected objects', get_answer=True + ) + if not removeAnnot: + # We can propagate changes only if the user agrees on + # removing annotations + return + + self.current_frame_i = posData.frame_i + if posData.frame_i > 0: + txt = html_utils.paragraph(""" + Do you want to remove un-kept objects in the past frames too? + """) + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + _, _, applyToPastButton = msg.question( + self, 'Propagate to past frames?', txt, + buttonsTexts=('Cancel', 'No', 'Yes, apply to past frames') + ) + if msg.cancel: + return + if msg.clickedButton == applyToPastButton: + self.store_data() + self.logger.info('Applying keep objects to past frames...') + if not removeAnnot and posData.cca_df is not None: + delIDs = [ + ID for ID in posData.cca_df.index + if ID not in posData.IDs + ] + self.update_cca_df_deletedIDs(posData, delIDs) + + for i in tqdm(range(posData.frame_i), ncols=100): + lab = posData.allData_li[i]['labels'] + rp = posData.allData_li[i]['regionprops'] + keepLab = self._keepObjects(lab=lab, rp=rp) + # Store change + posData.allData_li[i]['labels'] = keepLab.copy() + # Get the rest of the stored metadata based on the new lab + posData.frame_i = i + self.get_data() + self.store_data(autosave=False) + + posData.frame_i = self.current_frame_i + self.get_data() + + # Ask to propagate change to all future visited frames + key = 'Keep ID' + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + self.keptObjectsIDs, key, doNotShow, + posData.UndoFutFrames_keepID, posData.applyFutFrames_keepID, + force=True, applyTrackingB=True + ) + + if UndoFutFrames is None: + # Empty keep object list + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + return + + posData.doNotShowAgain_keepID = doNotShowAgain + posData.UndoFutFrames_keepID = UndoFutFrames + posData.applyFutFrames_keepID = applyFutFrames + includeUnvisited = posData.includeUnvisitedInfo['Keep ID'] + + if applyFutFrames: + self.store_data() + + self.logger.info('Applying to future frames...') + pbar = tqdm(total=posData.SizeT-posData.frame_i-1, ncols=100) + segmSizeT = len(posData.segm_data) + if not removeAnnot and posData.cca_df is not None: + delIDs = [ + ID for ID in posData.cca_df.index + if ID not in posData.IDs + ] + self.update_cca_df_deletedIDs(posData, delIDs) + + for i in range(posData.frame_i+1, segmSizeT): + lab = posData.allData_li[i]['labels'] + if lab is None and not includeUnvisited: + self.enqAutosave() + pbar.update(posData.SizeT-i) + break + + rp = posData.allData_li[i]['regionprops'] + + if lab is not None: + keepLab = self._keepObjects(lab=lab, rp=rp) + # Store change + posData.allData_li[i]['labels'] = keepLab.copy() + # Get the rest of the stored metadata based on the new lab + posData.frame_i = i + self.get_data() + self.store_data(autosave=False) + elif includeUnvisited: + # Unvisited frame (includeUnvisited = True) + lab = posData.segm_data[i] + rp = skimage.measure.regionprops(lab) + keepLab = self._keepObjects(lab=lab, rp=rp) + posData.segm_data[i] = keepLab + + pbar.update() + pbar.close() + + # Back to current frame + if applyFutFrames: + posData.frame_i = self.current_frame_i + self.get_data() + + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + + def updateLookuptable(self, lenNewLut=None, delIDs=None): + posData = self.data[self.pos_i] + if lenNewLut is None: + try: + if delIDs is None: + IDs = posData.IDs + else: + # Remove IDs removed with ROI from LUT + IDs = [ID for ID in posData.IDs if ID not in delIDs] + lenNewLut = max(IDs, default=0) + 1 + except ValueError: + # Empty segmentation mask + lenNewLut = 1 + # Build a new lut to include IDs > than original len of lut + updateLevels = self.extendLabelsLUT(lenNewLut) + lut = self.lut.copy() + + try: + # lut = self.lut[:lenNewLut].copy() + for ID in posData.binnedIDs: + lut[ID] = lut[ID]*0.2 + + for ID in posData.ripIDs: + lut[ID] = lut[ID]*0.2 + except Exception as e: + err_str = traceback.format_exc() + print('='*30) + self.logger.info(err_str) + print('='*30) + + if updateLevels: + self.img2.setLevels([0, len(lut)]) + + if self.keepIDsButton.isChecked(): + lut = np.round(lut*0.3).astype(np.uint8) + keptLut = np.round(lut[self.keptObjectsIDs]/0.3).astype(np.uint8) + lut[self.keptObjectsIDs] = keptLut + + self.img2.setLookupTable(lut) + + # @exec_time + def update_rp_metadata(self, draw=True): + posData = self.data[self.pos_i] + # Add to rp dynamic metadata (e.g. cells annotated as dead) + for i, obj in enumerate(posData.rp): + ID = obj.label + obj.excluded = ID in posData.binnedIDs + obj.dead = ID in posData.ripIDs + + def annotate_rip_and_bin_IDs(self, updateLabel=False): + depthAxes = self.switchPlaneCombobox.depthAxes() + if self.switchPlaneCombobox.isEnabled() and depthAxes != 'z': + return + + posData = self.data[self.pos_i] + binnedIDs_xx = [] + binnedIDs_yy = [] + ripIDs_xx = [] + ripIDs_yy = [] + for obj in posData.rp: + obj.excluded = obj.label in posData.binnedIDs + obj.dead = obj.label in posData.ripIDs + if not self.isObjVisible(obj.bbox): + continue + + if obj.excluded: + y, x = self.getObjCentroid(obj.centroid) + binnedIDs_xx.append(x) + binnedIDs_yy.append(y) + if updateLabel: + self.getObjOptsSegmLabels(obj) + how = self.drawIDsContComboBox.currentText() + + if obj.dead: + y, x = self.getObjCentroid(obj.centroid) + ripIDs_xx.append(x) + ripIDs_yy.append(y) + if updateLabel: + self.getObjOptsSegmLabels(obj) + how = self.drawIDsContComboBox.currentText() + + self.ax2_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) + self.ax2_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) + self.ax1_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) + self.ax1_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) + + def loadNonAlignedFluoChannel(self, fluo_path): + posData = self.data[self.pos_i] + if posData.filename.find('aligned') != -1: + filename, _ = os.path.splitext(os.path.basename(fluo_path)) + path = f'.../{posData.pos_foldername}/Images/{filename}_aligned.npz' + msg = widgets.myMessageBox() + msg.critical( + self, 'Aligned fluo channel not found!', + 'Aligned data for fluorescence channel not found!\n\n' + f'You loaded aligned data for the cells channel, therefore ' + 'loading NON-aligned fluorescence data is not allowed.\n\n' + 'Run the script "dataPrep.py" to create the following file:\n\n' + f'{path}' + ) + return None + fluo_data = np.squeeze(skimage.io.imread(fluo_path)) + return fluo_data + + def load_fluo_data(self, fluo_path, isGuiThread=True): + self.logger.info(f'Loading fluorescence image data from "{fluo_path}"...') + bkgrData = None + posData = self.data[self.pos_i] + # Load overlay frames and align if needed + filename = os.path.basename(fluo_path) + filename_noEXT, ext = os.path.splitext(filename) + if ext == '.npy' or ext == '.npz': + fluo_data = np.load(fluo_path) + try: + fluo_data = np.squeeze(fluo_data['arr_0']) + except Exception as e: + fluo_data = np.squeeze(fluo_data) + + # Load background data + bkgrData_path = os.path.join( + posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' + ) + if os.path.exists(bkgrData_path): + bkgrData = np.load(bkgrData_path) + elif ext == '.tif' or ext == '.tiff': + aligned_filename = f'{filename_noEXT}_aligned.npz' + aligned_path = os.path.join(posData.images_path, aligned_filename) + if os.path.exists(aligned_path): + fluo_data = np.load(aligned_path)['arr_0'] + + # Load background data + bkgrData_path = os.path.join( + posData.images_path, f'{aligned_filename}_bkgrRoiData.npz' + ) + if os.path.exists(bkgrData_path): + bkgrData = np.load(bkgrData_path) + else: + fluo_data = self.loadNonAlignedFluoChannel(fluo_path) + if fluo_data is None: + return None, None + + # Load background data + bkgrData_path = os.path.join( + posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' + ) + if os.path.exists(bkgrData_path): + bkgrData = np.load(bkgrData_path) + elif isGuiThread: + txt = html_utils.paragraph( + f'File format {ext} is not supported!\n' + 'Choose either .tif or .npz files.' + ) + msg = widgets.myMessageBox() + msg.critical(self, 'File not supported', txt) + return None, None + + return fluo_data, bkgrData + + def setOverlayColors(self): + self.overlayRGBs = [ + (255, 255, 0), + (252, 72, 254), + (49, 222, 134), + (22, 108, 27) + ] + self.overlayCmap = matplotlib.colormaps['hsv'] + self.overlayRGBs.extend( + [tuple([round(c*255) for c in self.overlayCmap(i)][:3]) + for i in np.linspace(0,1,8)] + ) + + def getFileExtensions(self, images_path): + alignedFound = any([f.find('_aligned.np')!=-1 + for f in myutils.listdir(images_path)]) + if alignedFound: + extensions = ( + 'Aligned channels (*npz *npy);; Tif channels(*tiff *tif)' + ';;All Files (*)' + ) + else: + extensions = ( + 'Tif channels(*tiff *tif);; All Files (*)' + ) + return extensions + + def loadOverlayData(self, ol_channels, addToExisting=False): + posData = self.data[self.pos_i] + for ol_ch in ol_channels: + if ol_ch not in list(posData.loadedFluoChannels): + # Requested channel was never loaded --> load it at first + # iter i == 0 + success = self.loadFluo_cb(fluo_channels=[ol_ch]) + if not success: + return False + + lastChannelName = ol_channels[-1] + for action in self.fluoDataChNameActions: + if action.text() == lastChannelName: + action.setChecked(True) + + for p, posData in enumerate(self.data): + if addToExisting: + ol_data = posData.ol_data + else: + ol_data = {} + for i, ol_ch in enumerate(ol_channels): + _, filename = self.getPathFromChName(ol_ch, posData) + ol_data[filename] = ( + posData.ol_data_dict[filename].copy() + ) + self.addFluoChNameContextMenuAction(ol_ch) + posData.ol_data = ol_data + + return True + + def askSelectOverlayChannel(self): + ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] + selectFluo = widgets.QDialogListbox( + 'Select channel', + 'Select channel names to overlay:\n', + ch_names, multiSelection=True, parent=self + ) + selectFluo.exec_() + if selectFluo.cancel: + return + + return selectFluo.selectedItemsText + + def overlayLabels_cb(self, checked, selectedLabelsEndnames=None): + if checked: + if not self.drawModeOverlayLabelsChannels: + if selectedLabelsEndnames is None: + selectedLabelsEndnames = self.askLabelsToOverlay() + if selectedLabelsEndnames is None: + self.logger.info('Overlay labels cancelled.') + self.overlayLabelsButton.setChecked(False) + return + for selectedEndname in selectedLabelsEndnames: + self.loadOverlayLabelsData(selectedEndname) + for action in self.overlayLabelsContextMenu.actions(): + if not action.isCheckable(): + continue + if action.text() == selectedEndname: + action.setChecked(True) + lastSelectedName = selectedLabelsEndnames[-1] + for action in self.selectOverlayLabelsActionGroup.actions(): + if action.text() == lastSelectedName: + action.setChecked(True) + self.updateAllImages() + + def askLabelsToOverlay(self): + selectOverlayLabels = widgets.QDialogListbox( + 'Select segmentation to overlay', + 'Select segmentation file to overlay:\n', + natsorted(self.existingSegmEndNames), + multiSelection=True, + parent=self + ) + selectOverlayLabels.exec_() + if selectOverlayLabels.cancel: + return + + return selectOverlayLabels.selectedItemsText + + def closeToolbars(self): + for toolbar in self.sender().toolbars: + toolbar.setVisible(False) + for action in toolbar.actions(): + try: + action.button.setChecked(False) + except Exception as e: + pass + + def askSaveAddedPoints(self): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + 'Do you want to save the annotated points?' + ) + _, noButton, yesButton = msg.question( + self, 'Save?', txt, + buttonsTexts=('Cancel', 'No', 'Yes') + ) + if msg.clickedButton != yesButton: + return + + for toolbar in self.pointsLayersToolbars: + for action in self.pointsLayersToolbar.actions(): + try: + if 'Save annotated' in action.text(): + action.trigger() + except Exception as err: + pass + + def pointsLayerToggled(self, checked): + if not checked: + for action in self.pointsLayersToolbar.actions(): + try: + if 'Save annotated' in action.text(): + self.askSaveAddedPoints() + break + except Exception as err: + pass + self.pointsLayersToolbar.setVisible(checked) + self.autoPilotZoomToObjToolbar.setVisible(checked) + if self.pointsLayersNeverToggled: + self.pointsLayersToolbar.sigAddPointsLayer.emit() + self.pointsLayersNeverToggled = False + QTimer.singleShot(200, self.autoRange) + + def addPointsLayerTriggered(self, checked=False, toolbar=None): + if toolbar is None: + toolbar = self.pointsLayersToolbar + + if self.addPointsWin is not None: + self.logger.info( + 'Add points layer window is already open. Cannot add now.' + ) + return + + onlyMouseClicks = toolbar == self.promptSegmentPointsLayerToolbar + posData = self.data[self.pos_i] + self.addPointsWin = apps.AddPointsLayerDialog( + channelNames=posData.chNames, + imagesPath=posData.images_path, + hideCentroidsSection=onlyMouseClicks, + hideWeightedCentroidsSection=onlyMouseClicks, + hideFromTableSection=onlyMouseClicks, + hideManualEntrySection=onlyMouseClicks, + hideWithMouseClicksSection=False, + parent=self, + ) + cmap = matplotlib.colormaps['gist_rainbow'] + i = np.random.default_rng(seed=123).uniform() + for action in toolbar.actions()[1:]: + if not hasattr(action, 'layerTypeIdx'): + continue + rgb = [round(c*255) for c in cmap(i)][:3] + self.addPointsWin.appearanceGroupbox.colorButton.setColor(rgb) + break + + self.addPointsWin.sigCriticalReadTable.connect(self.logger.info) + self.addPointsWin.sigLoadedTable.connect(self.logLoadedTablePointsLayer) + self.addPointsWin.sigClosed.connect( + partial(self.addPointsLayer, toolbar=toolbar) + ) + self.addPointsWin.sigCheckClickEntryTableEndnameExists.connect( + self.checkClickEntryTableEndnameExists + ) + self.addPointsWin.show() + if self.addPointsWin.clickEntryRadiobutton.isChecked(): + QTimer.singleShot( + 200, + partial( + self.addPointsWin.sigCheckClickEntryTableEndnameExists.emit, + self.addPointsWin.clickEntryTableEndname.text(), + False + ) + ) + + def logLoadedTablePointsLayer(self, df, filename: str): + separator = f'-'*100 + header = f'First 10 rows of loaded table - "{filename}":' + footer = f'Number of points: {len(df)}' + text = ( + f'{separator}\n' + f'{header}\n\n' + f'{df.head(10)}\n\n' + f'{footer}\n' + f'{separator}' + ) + if filename: + text = f'{text}\nFilename: {filename}' + self.logger.info(text) + + def buttonAddPointsByClickingActive(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'layerTypeIdx'): + continue + if action.layerTypeIdx == 4 and action.button.isChecked(): + return action.button + + def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): + self.LeftClickButtons.append(toolButton) + posData = self.data[self.pos_i] + tableEndName = self.addPointsWin.clickEntryTableEndnameText + if isLoadedDf is not None: + posData = self.data[self.pos_i] + tableEndName = tableEndName[len(posData.basename):] + self.loadClickEntryDfs(tableEndName) + + toolButton.toolbar = toolbar + toolButton.clickEntryTableEndName = tableEndName + self.checkableQButtonsGroup.addButton(toolButton) + toolButton.toggled.connect(self.addPointsByClickingButtonToggled) + + self.addPointsByClickingButtonToggled(sender=toolButton) + + toolButton.setToolTip(tableEndName) + + pointIdSpinbox = widgets.SpinBox() + pointIdSpinbox.setMinimum(0) + pointIdSpinbox.setValue(1) + pointIdSpinbox.label = QLabel(' Left-click ID: ') + pointIdSpinbox.labelAction = toolbar.addWidget(pointIdSpinbox.label) + if toolbar == self.promptSegmentPointsLayerToolbar: + newID = self.setBrushID(return_val=True) + pointIdSpinbox.setValue(newID) + pointIdSpinbox.setReadOnly(True) + pointIdSpinbox.setToolTip( + 'The ids added with left-click cannot be manually edited. ' + 'They are always a new, non-existing id.' + ) + + toolButton.actions.append(pointIdSpinbox.labelAction) + pointIdSpinbox.action = toolbar.addWidget(pointIdSpinbox) + toolButton.actions.append(pointIdSpinbox.action) + pointIdSpinbox.toolButton = toolButton + toolButton.pointIdSpinbox = pointIdSpinbox + + rightClickIDSpinbox = widgets.SpinBox() + pointIdSpinbox.setLinkedValueWidget(rightClickIDSpinbox) + rightClickIDSpinbox.setMaximumWidth(pointIdSpinbox.sizeHint().width()) + rightClickIDSpinbox.setValue(pointIdSpinbox.value()) + rightClickIDSpinbox.setMinimum(0) + rightClickIDSpinbox.label = QLabel(' | Right-click ID: ') + rightClickIDSpinbox.labelAction = toolbar.addWidget( + rightClickIDSpinbox.label + ) + toolButton.actions.append(rightClickIDSpinbox.labelAction) + rightClickIDSpinbox.action = toolbar.addWidget(rightClickIDSpinbox) + toolButton.actions.append(rightClickIDSpinbox.action) + rightClickIDSpinbox.toolButton = toolButton + toolButton.rightClickIDSpinbox = rightClickIDSpinbox + + saveToolbutton = widgets.SavePointsLayerButton( + tableEndName, parent=self + ) + saveToolbutton.sigRenameTableAction.connect( + self.updatePointsLayerClickEntryTableEndname + ) + saveToolbutton.sigLeftClick.connect(self.savePointsAddedByClicking) + saveAction = toolbar.addWidget(saveToolbutton) + saveToolbutton.action = saveAction + saveAction.saveToolbutton = saveToolbutton + saveAction.toolButton = toolButton + toolButton.saveAction = saveAction + toolButton.saveToolbutton = saveToolbutton + + toolButton.actions.append(saveAction) + + vlineAction = toolbar.addWidget(widgets.QVLine()) + spacerAction = toolbar.addWidget( + widgets.QHWidgetSpacer(width=5) + ) + + toolButton.actions.append(vlineAction) + toolButton.actions.append(spacerAction) + + action = toolButton.action + scatterItem = action.scatterItem + scatterItem.sigHoverEntered.connect( + self.addPointsByClickingScatterItemHoverEntered + ) + + self.pointsLayerClicksDfsToData(posData, toolbar=toolbar) + + def storeUndoAddPoint(self, action): + if not hasattr(self, 'undoAddPointQueueMapper'): + self.undoAddPointQueueMapper = defaultdict(list) + + posData = self.data[self.pos_i] + pointsDataPos = action.pointsData.get(self.pos_i) + if pointsDataPos is None: + return + + state = deepcopy(pointsDataPos) + self.undoAddPointQueueMapper[action].append(state) + self.undoAction.setEnabled(True) + + def undoAddPoint(self, action): + undoAddPointQueue = self.undoAddPointQueueMapper.get(action) + if undoAddPointQueue is None: + return False + + if len(undoAddPointQueue) == 0: + return False + + posData = self.data[self.pos_i] + state = undoAddPointQueue.pop(-1) + action.pointsData[self.pos_i] = state + self.markPointsLayerDirty(action=action) + + self.drawPointsLayers(computePointsLayers=False) + + if len(self.undoAddPointQueueMapper[action]) == 0: + self.undoAction.setEnabled(True) + + return True + + def getAddedPointId( + self, isMagicPrompts, addPointsByClickingButton, + right_click, left_click, middle_click + ): + action = addPointsByClickingButton.action + if right_click: + id = addPointsByClickingButton.rightClickIDSpinbox.value() + elif left_click: + id = addPointsByClickingButton.pointIdSpinbox.value() + id = self.getClickedPointNewId( + action, id, addPointsByClickingButton.pointIdSpinbox, + isMagicPrompts=isMagicPrompts + ) + if isMagicPrompts: + proceed = self.warnAddingPointWithExistingId(id) + if not proceed: + return + + addPointsByClickingButton.pointIdSpinbox.setValue(id) + elif middle_click: + id = 0 + + return id + + def addPointsByClickingScatterItemHoverEntered(self, item, points, event): + point = points[0] + point_id = point.data() + toolButton = item.action.button + toolButton.rightClickIDSpinbox.prevId = ( + toolButton.rightClickIDSpinbox.value() + ) + toolButton.rightClickIDSpinbox.setValue(point_id) + + def autoPilotZoomToObjToggled(self, checked): + if not checked: + self.zoomOut() + return + + posData = self.data[self.pos_i] + if not posData.IDs: + self.logger.info('There are no objects in current segmentation mask') + return + self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) + self.zoomToObj(posData.rp[0]) + + def savePointsAddedByClickingFromEndname(self, tableEndName, recovery=False): + self.pointsLayerDataToDf(self.data[self.pos_i]) + for posData in self.data: + if not posData.basename.endswith('_'): + basename = f'{posData.basename}_' + else: + basename = posData.basename + tableFilename = f'{basename}{tableEndName}.csv' + if recovery: + tableFilepath = os.path.join( + posData.recoveryFolderpath(), tableFilename + ) + else: + tableFilepath = os.path.join(posData.images_path, tableFilename) + df = posData.clickEntryPointsDfs.get(tableEndName) + if df is None: + continue + df = df.sort_values(['frame_i', 'Cell_ID']) + df.to_csv(tableFilepath, index=False) + + def markPointsLayerDirty(self, tableEndName=None, action=None): + if tableEndName is None and action is not None: + tableEndName = getattr(action, 'clickEntryTableEndName', None) + + if tableEndName is None: + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + if addPointsByClickingButton is None: + return + tableEndName = addPointsByClickingButton.clickEntryTableEndName + + self.dirtyPointsLayerTableEndNames.add(tableEndName) + + def flushDirtyPointsLayersAutosave(self): + if not self.dirtyPointsLayerTableEndNames: + return + + for tableEndName in tuple(self.dirtyPointsLayerTableEndNames): # avoid runtime error + self.savePointsAddedByClickingFromEndname( + tableEndName, recovery=True + ) + + self.dirtyPointsLayerTableEndNames.clear() + + @exception_handler + def savePointsAddedByClicking(self, button, event): + sender = button.action + toolButton = sender.toolButton + tableEndName = toolButton.clickEntryTableEndName + + self.logger.info(f'Saving _{tableEndName}.csv table...') + + self.savePointsAddedByClickingFromEndname(tableEndName) + + self.logger.info(f'{tableEndName}.csv saved!') + self.titleLabel.setText(f'{tableEndName}.csv saved!', color='g') + + def updatePointsLayerClickEntryTableEndname( + self, saveToolbutton, table_endname + ): + saveAction = saveToolbutton.action + toolButton = saveAction.toolButton + toolButton.clickEntryTableEndName = table_endname + + self.logger.info( + f'Done. Click entry table endname updated to "{table_endname}"' + ) + + def pointsLayerDfsToData(self, posData): + self.pointsLayerClicksDfsToData(posData) + + def pointsLayerLoadedDfsToData(self): + posData = self.data[self.pos_i] + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'loadedDfInfo'): + continue + + if action.loadedDfInfo is None: + continue + + endname = action.loadedDfInfo.get('endname') + if endname is None: + continue + + filename = f'{posData.basename}{endname}' + filepath = os.path.join(posData.images_path, filename) + if not os.path.exists(filepath): + action.pointsData[self.pos_i] = {} + + df = load.load_df_points_layer(filepath) + action.pointsData[self.pos_i] = ( + load.loaded_df_to_points_data( + df, action.loadedDfInfo['t'], action.loadedDfInfo['z'], + action.loadedDfInfo['y'], action.loadedDfInfo['x'] + ) + ) + self.logLoadedTablePointsLayer(df, filename=filename) + + def setPointsLayerLoadedDfEndanme(self, action): + if action.loadedDfInfo is None: + return + + posData = self.data[self.pos_i] + images_path = posData.images_path.replace('\\', '/') + + df_folderpath = os.path.dirname( + action.loadedDfInfo['filepath'].replace('\\', '/') + ) + + if images_path != df_folderpath: + return + + df_filename = os.path.basename(action.loadedDfInfo['filepath']) + + if not df_filename.startswith(posData.basename): + return + + endname = df_filename[len(posData.basename):] + action.loadedDfInfo['endname'] = endname + + action.button.setToolTip(endname) + + def pointsLayerClicksDfsToData(self, posData, toolbar=None): + if toolbar is None: + toolbar = self.pointsLayersToolbar + + for action in toolbar.actions()[1:]: + if not hasattr(action, 'button'): + continue + + if not hasattr(action.button, 'clickEntryTableEndName'): + continue + tableEndName = action.button.clickEntryTableEndName + action.pointsData[self.pos_i] = {} + if posData.clickEntryPointsDfs.get(tableEndName) is None: + continue + + df = posData.clickEntryPointsDfs[tableEndName] + + if posData.SizeZ > 1 and df['z'].isna().any(): + self.warnLoadedPointsTableIsNot3D(tableEndName) + return + + for frame_i, df_frame in df.groupby('frame_i'): + action.pointsData[self.pos_i][frame_i] = {} + if posData.SizeZ > 1: + for z, df_zlice in df_frame.groupby('z'): + xx = df_zlice['x'].to_list() + yy = df_zlice['y'].to_list() + ids = df_zlice['id'].to_list() + action.pointsData[self.pos_i][frame_i][z] = { + 'x': xx, 'y': yy, 'id': ids + } + else: + xx = df_frame['x'].to_list() + yy = df_frame['y'].to_list() + ids = df_frame['id'].to_list() + action.pointsData[self.pos_i][frame_i] = { + 'x': xx, 'y': yy, 'id': ids + } + + def pointsLayerDataToDf(self, posData, getOnlyActive=False, toolbar=None): + df = None + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'button'): + continue + if not hasattr(action.button, 'clickEntryTableEndName'): + continue + + tableEndName = action.button.clickEntryTableEndName + if getOnlyActive and not action.button.isChecked(): + continue + + df = toolbar.fromActionToDataFrame( + action, posData, isSegm3D=self.isSegm3D + ) + posData.clickEntryPointsDfs[tableEndName] = df + return df + + def restartZoomAutoPilot(self): + if not self.autoPilotZoomToObjToggle.isChecked(): + return + + posData = self.data[self.pos_i] + if not posData.IDs: + return + + self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) + self.zoomToObj(posData.rp[0]) + + def resizeRangeWelcomeText(self): + xRange, yRange = self.ax1.viewRange() + deltaX = xRange[1] - xRange[0] + deltaY = yRange[1] - yRange[0] + self.ax1.setXRange(0, deltaX) + self.ax1.setYRange(0, deltaY) + self.ax1.setLimits( + xMin=0, xMax=deltaX, yMin=0, yMax=deltaY + ) + # self.ax1.setXRange(0, 0) + # self.ax1.setYRange(0, 0) + + def zoomToObj(self, obj=None): + if not hasattr(self, 'data'): + return + posData = self.data[self.pos_i] + if obj is None: + ID = self.sender().value() + try: + ID_idx = posData.IDs_idxs[ID] + obj = obj = posData.rp[ID_idx] + except Exception as e: + self.logger.warning( + f'ID {ID} does not exist (add points by clicking)' + ) + + if obj is None: + return + + self.goToZsliceSearchedID(obj) + min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) + xRange = min_col-5, max_col+5 + yRange = max_row+5, min_row-5 + + self.ax1.setRange(xRange=xRange, yRange=yRange) + + def addPointsByClickingButtonToggled(self, checked=True, sender=None): + if sender is None: + sender = self.sender() + if not sender.isChecked(): + action = sender.action + action.scatterItem.setVisible(False) + return + + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(sender) + self.connectLeftClickButtons() + action = sender.action + action.scatterItem.setVisible(True) + self.ax1_BrushCircle.setBrush(action.brushColor) + self.ax1_BrushCircle.setPen(action.penColor) + + def autoZoomNextObj(self): + self.sender().setValue(self.sender().value() - 1) + self.pointsLayerAutoPilot('next') + self.setFocusMain() + self.setFocusGraphics() + + def autoZoomPrevObj(self): + self.sender().setValue(self.sender().value() + 1) + self.pointsLayerAutoPilot('prev') + self.setFocusMain() + self.setFocusGraphics() + + def pointsLayerAutoPilot(self, direction): + if not self.autoPilotZoomToObjToggle.isChecked(): + return + ID = self.autoPilotZoomToObjSpinBox.value() + posData = self.data[self.pos_i] + if not posData.IDs: + return + + try: + ID_idx = posData.IDs_idxs[ID] + if direction == 'next': + nextID_idx = ID_idx + 1 + else: + nextID_idx = ID_idx - 1 + obj = posData.rp[nextID_idx] + except Exception as e: + self.logger.info( + f'Auto-pilot restarted from first ID' + ) + obj = posData.rp[0] + + self.autoPilotZoomToObjSpinBox.setValue(obj.label) + self.zoomToObj(obj) + + def getClickEntryTableFilepaths(self, posData, tableEndName): + if posData.basename.endswith('_'): + basename = posData.basename + else: + basename = f'{posData.basename}_' + + csv_filename = f'{basename}{tableEndName}' + if not csv_filename.endswith('.csv'): + csv_filename = f'{csv_filename}.csv' + + filepath = os.path.join(posData.images_path, csv_filename) + recovery_filepath = os.path.join( + posData.images_path, 'recovery', csv_filename + ) + return filepath, recovery_filepath + + def getClickEntryNewerRecoveryFilepaths(self, tableEndName): + newer_recovery_filepaths = [] + for posData in self.data: + filepath, recovery_filepath = self.getClickEntryTableFilepaths( + posData, tableEndName + ) + if not os.path.exists(filepath) or not os.path.exists(recovery_filepath): + continue + + if os.path.getmtime(recovery_filepath) <= os.path.getmtime(filepath) + 15: # add a 15 second tolerance + continue + + newer_recovery_filepaths.append((filepath, recovery_filepath)) + + return newer_recovery_filepaths + + def askLoadNewerRecoveryClickEntryDfs( + self, tableEndName, newer_recovery_filepaths + ): + if not newer_recovery_filepaths: + return False + + num_tables = len(newer_recovery_filepaths) + filepath, recovery_filepath = newer_recovery_filepaths[0] + main_timestamp = datetime.fromtimestamp( + os.path.getmtime(filepath) + ).strftime('%a %d. %b. %y - %H:%M:%S') + recovery_timestamp = datetime.fromtimestamp( + os.path.getmtime(recovery_filepath) + ).strftime('%a %d. %b. %y - %H:%M:%S') + + if num_tables == 1: + text = html_utils.paragraph( + f'A newer recovery version of {tableEndName}.csv ' + 'was found.

' + f'Main table save date: {main_timestamp}
' + f'Recovery save date: {recovery_timestamp}

' + 'Do you want to load the newer recovery version?' + ) + else: + text = html_utils.paragraph( + f'Newer recovery versions of {tableEndName}.csv ' + f'were found for {num_tables} positions.

' + f'Example main table save date: {main_timestamp}
' + f'Example recovery save date: {recovery_timestamp}

' + 'Do you want to load the newer recovery version where available?' + ) + + msg = widgets.myMessageBox(wrapText=False) + _, yesButton, _ = msg.warning( + self.addPointsWin, 'Newer recovery table found', text, + buttonsTexts=('Cancel', 'Yes, load newer recovery', 'No, load main table') + ) + return msg.clickedButton == yesButton + + def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading): + doesTableExists = False + for posData in self.data: + filepath, _ = self.getClickEntryTableFilepaths(posData, tableEndName) + if os.path.exists(filepath): + doesTableExists = True + break + + if not doesTableExists: + return + + if not forceLoading: + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + f'The table {tableEndName}.csv already exists!

' + 'Do you want to load it?' + ) + _, yesButton, _ = msg.warning( + self.addPointsWin, 'Table exists!', txt, + buttonsTexts=('Cancel', 'Yes, load it', 'No, let me enter a new name') + ) + if msg.clickedButton != yesButton: + return + + newer_recovery_filepaths = self.getClickEntryNewerRecoveryFilepaths( + tableEndName + ) + load_recovery_if_newer = self.askLoadNewerRecoveryClickEntryDfs( + tableEndName, newer_recovery_filepaths + ) + + self.loadClickEntryDfs( + tableEndName, loadRecoveryIfNewer=load_recovery_if_newer + ) + + def checkLoadedTableIds(self, toolbar): + if toolbar != self.promptSegmentPointsLayerToolbar: + return True + + for posData in self.data: + for tableEndName, df in posData.clickEntryPointsDfs.items(): + for point_id in df['id'].values: + if point_id in posData.IDs_idxs: + proceed = self.warnAddingPointWithExistingId( + point_id, table_endname=tableEndName + ) + return proceed + + return True + + @exception_handler + def addPointsLayer(self, toolbar=None): + proceed = self.checkLoadedTableIds(toolbar) + + if self.addPointsWin.cancel or not proceed: + self.addPointsWin = None + self.logger.info('Adding points layer cancelled.') + return + + if toolbar is None: + toolbar = self.pointsLayersToolbar + + symbol = self.addPointsWin.symbol + color = self.addPointsWin.color + pointSize = self.addPointsWin.pointSize + zRadius = int((self.addPointsWin.zHeight-1)/2) + r,g,b,a = color.getRgb() + + scatterItem = widgets.PointsScatterPlotItem( + [], [], ax=self.ax1, symbol=symbol, pxMode=False, size=pointSize, + brush=pg.mkBrush(color=(r,g,b,100)), + pen=pg.mkPen(width=2, color=(r,g,b)), + hoverable=True, hoverBrush=pg.mkBrush((r,g,b,200)), + tip=None, show_data_as_tip=True + ) + self.ax1.addItem(scatterItem) + + toolButton = widgets.PointsLayerToolButton(symbol, color, parent=self) + toolButton.actions = [] + toolButton.setCheckable(True) + toolButton.setChecked(True) + if self.addPointsWin.keySequence is not None: + toolButton.setShortcut(self.addPointsWin.keySequence) + toolButton.toggled.connect(self.pointLayerToolbuttonToggled) + toolButton.sigEditAppearance.connect(self.editPointsLayerAppearance) + toolButton.sigShowIdsToggled.connect(self.showPointsLayerIdsToggled) + toolButton.sigRemove.connect( + partial(self.removePointsLayer, toolbar=toolbar) + ) + + action = toolbar.addWidget(toolButton) + action.state = self.addPointsWin.state() + + toolButton.action = action + action.brushColor = (r,g,b,100) + action.brushColorId0 = ( + *colors.hex_to_rgb( + colors.lighten_color( + np.array(action.brushColor)/255, 0.3 + ) + ), 100 + ) + action.penColor = (r,g,b) + action.penColorId0 = colors.lighten_color( + np.array(action.penColor)/255, 0.3 + ) + action.pointSize = pointSize + action.zRadius = zRadius + action.button = toolButton + action.scatterItem = scatterItem + scatterItem.action = action + action.layerType = self.addPointsWin.layerType + action.layerTypeIdx = self.addPointsWin.layerTypeIdx + action.loadedDf = self.addPointsWin.loadedDf + posData = self.data[self.pos_i] + action.pointsData = {} + action.pointsData[self.pos_i] = self.addPointsWin.pointsData + action.snapToMax = False + action.loadedDfInfo = self.addPointsWin.loadedDfInfo + self.setPointsLayerLoadedDfEndanme(action) + + if self.addPointsWin.layerType.startswith('Click to annotate point'): + action.snapToMax = self.addPointsWin.snapToMaxToggle.isChecked() + isLoadedDf = self.addPointsWin.clickEntryIsLoadedDf + self.setupAddPointsByClicking( + toolButton, isLoadedDf, toolbar=toolbar + ) + if self.addPointsWin.autoPilotToggle.isChecked(): + self.autoPilotZoomToObjToggle.setChecked(True) + + weighingChannel = self.addPointsWin.weighingChannel + self.loadPointsLayerWeighingData(action, weighingChannel) + + self.drawPointsLayers() + + if toolbar == self.promptSegmentPointsLayerToolbar: + self.promptSegmentPointsLayerToolbar.isPointsLayerInit = True + self.magicPromptsToolbar.clearPointsAction.setDisabled(False) + self.magicPromptsToolbar.clearPointsActionOnZoom.setDisabled(False) + QTimer.singleShot( + 200, self.magicPromptsToolbar.selectModelAction.trigger + ) + + self.addPointsWin = None + + def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): + for posData in self.data: + filepath, recovery_filepath = self.getClickEntryTableFilepaths( + posData, tableEndName + ) + + if loadRecoveryIfNewer: + recovery_exists = os.path.exists(recovery_filepath) + main_exists = os.path.exists(filepath) + if ( + recovery_exists + and ( + not main_exists + or os.path.getmtime(recovery_filepath) + > os.path.getmtime(filepath) + 15 + ) + ): + filepath = recovery_filepath + elif not main_exists: + continue + + if not os.path.exists(filepath): + continue + + self.logger.info(f'Loading points from "{filepath}"...') + df = pd.read_csv(filepath) + if 'id' not in df.columns: + df['id'] = range(1, len(df)+1) + posData.clickEntryPointsDfs[tableEndName] = df + + try: + self.addPointsWin.loadButton.confirmAction() + except Exception as err: + pass + + def removeClickedPoints(self, action, points): + posData = self.data[self.pos_i] + framePointsData = action.pointsData[self.pos_i][posData.frame_i] + if posData.SizeZ > 1: + zProjHow = self.zProjComboBox.currentText() + if zProjHow != 'single z-slice': + _warnings.warnCannotAddRemovePointsProjection() + return + zSlice = self.zSliceScrollBar.sliderPosition() + else: + zSlice = None + + removed_ids = [] + for point in points: + pos = point.pos() + x, y = pos.x(), pos.y() + if zSlice is not None: + zSliceRad = action.zRadius + sliceFramePointsData = [framePointsData[z] for z in range( + zSlice-zSliceRad, zSlice+zSliceRad+1 + ) if z in framePointsData.keys()] + else: + sliceFramePointsData = [framePointsData] + + + for sliceFramePointsData in sliceFramePointsData: + if point.data() in sliceFramePointsData['id']: + sliceFramePointsData['x'].remove(x) + sliceFramePointsData['y'].remove(y) + sliceFramePointsData['id'].remove(point.data()) + removed_ids.append(point.data()) + + if removed_ids: + self.markPointsLayerDirty(action=action) + + return removed_ids + + def restorePrevPointIdRightClick(self, addPointsByClickingButton): + # Try to restore the id that was there before hovering + # because the hovering was required only to delete the + # point + try: + prevId = addPointsByClickingButton.rightClickIDSpinbox.prevId + addPointsByClickingButton.rightClickIDSpinbox.setValue(prevId) + except Exception as err: + addPointsByClickingButton.rightClickIDSpinbox.prevId = None + + def getClickedPointNewId( + self, action, current_id, pointIdSpinbox, isMagicPrompts=False + ): + removed_id = getattr(pointIdSpinbox, 'removedId', None) + if removed_id is not None: + pointIdSpinbox.removedId = None + return removed_id + + posData = self.data[self.pos_i] + if isMagicPrompts: + is_already_new = self.isPointIdAlreadyNew(current_id, action) + if is_already_new: + return current_id + + new_ID = self.setBrushID(return_val=True) + new_id = max(current_id, new_ID) + 1 + return new_id + else: + pointsDataPos = action.pointsData.get(self.pos_i) + if pointsDataPos is None: + return 1 + + framePointsData = pointsDataPos.get(posData.frame_i) + if framePointsData is None: + return 1 + if posData.SizeZ > 1: + new_id = 1 + for z_data in framePointsData.values(): + max_id = max(z_data.get('id', 0), default=0) + 1 + if max_id > new_id: + new_id = max_id + else: + new_id = max(framePointsData.get('id', 0), default=0) + 1 + if current_id >= new_id: + return current_id + return new_id + + def setHoverCircleAddPoint(self, x, y): + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + if addPointsByClickingButton is None: + return + action = addPointsByClickingButton.action + self.setHoverToolSymbolData( + [x], [y], (self.ax1_BrushCircle,), + size=action.pointSize + ) + + def isPointIdAlreadyNew(self, point_id, action): + posData = self.data[self.pos_i] + if point_id in posData.IDs_idxs: + return False + + is_ID = point_id in posData.IDs_idxs + pointsDataPos = action.pointsData.get(self.pos_i) + if pointsDataPos is None: + return not is_ID + + framePointsData = pointsDataPos.get(posData.frame_i) + if framePointsData is None: + return not is_ID + + if 'x' not in framePointsData: + is_id_already_added = False + for z, z_data in framePointsData.items(): + if point_id in z_data['id']: + is_id_already_added = True + break + else: + is_id_already_added = point_id in framePointsData['id'] + + is_already_new = not is_ID and not is_id_already_added + return is_already_new + + def addClickedPoint(self, action, x, y, id): + x, y = round(x, 2), round(y, 2) + posData = self.data[self.pos_i] + pointsDataPos = action.pointsData.get(self.pos_i) + if pointsDataPos is None: + action.pointsData[self.pos_i] = {} + + framePointsData = action.pointsData[self.pos_i].get(posData.frame_i) + if action.snapToMax: + radius = round(action.pointSize/2) + rr, cc = skimage.draw.disk((round(y), round(x)), radius) + idx_max = (self.img1.image[rr, cc]).argmax() + y, x = rr[idx_max], cc[idx_max] + + if framePointsData is None: + if posData.SizeZ > 1: + zSlice = self.zSliceScrollBar.sliderPosition() + action.pointsData[self.pos_i][posData.frame_i] = { + zSlice: {'x': [x], 'y': [y], 'id': [id]} + } + else: + action.pointsData[self.pos_i][posData.frame_i] = { + 'x': [x], 'y': [y], 'id': [id] + } + else: + if posData.SizeZ > 1: + zSlice = self.zSliceScrollBar.sliderPosition() + z_data = framePointsData.get(zSlice) + if z_data is None: + framePointsData[zSlice] = {'x': [x], 'y': [y], 'id': [id]} + else: + framePointsData[zSlice]['x'].append(x) + framePointsData[zSlice]['y'].append(y) + framePointsData[zSlice]['id'].append(id) + action.pointsData[self.pos_i][posData.frame_i] = ( + framePointsData + ) + else: + pointsDataPos = action.pointsData[self.pos_i] + framePointsData = pointsDataPos[posData.frame_i] + framePointsData['x'].append(x) + framePointsData['y'].append(y) + framePointsData['id'].append(id) + + self.markPointsLayerDirty(action=action) + + def showPointsLayerIdsToggled(self, button, checked): + button.action.scatterItem.drawIds = checked + self.drawPointsLayers() + + def removePointsLayer(self, button, toolbar=None): + button.setChecked(False) + button.action.scatterItem.setData([], []) + button.action.loadedDfInfo = None + self.ax1.removeItem(button.action.scatterItem) + toolbar.removeAction(button.action) + for action in button.actions: + toolbar.removeAction(action) + + if toolbar == self.promptSegmentPointsLayerToolbar: + self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False + + def editPointsLayerAppearance(self, button): + win = apps.EditPointsLayerAppearanceDialog(parent=self) + win.restoreState(button.action.state) + win.exec_() + if win.cancel: + return + + symbol = win.symbol + color = win.color + pointSize = win.pointSize + zRadius = int((win.zHeight-1)/2) + r,g,b,a = color.getRgb() + + scatterItem = button.action.scatterItem + scatterItem.opts['hoverBrush'] = pg.mkBrush((r,g,b,200)) + scatterItem.setSymbol(symbol, update=False) + scatterItem.setBrush(pg.mkBrush(color=(r,g,b,100)), update=False) + scatterItem.setPen(pg.mkPen(width=2, color=(r,g,b)), update=False) + scatterItem.setSize(pointSize, update=True) + + button.action.brushColor = (r,g,b,100) + button.action.penColor = (r,g,b) + button.action.pointSize = pointSize + button.action.zRadius = zRadius + + button.action.state = win.state() + + def loadPointsLayerWeighingData(self, action, weighingChannel): + if not weighingChannel: + return + + self.logger.info(f'Loading "{weighingChannel}" weighing data...') + action.weighingData = [] + for p, posData in enumerate(self.data): + if weighingChannel == posData.user_ch_name: + wData = posData.img_data + action.weighingData.append(wData) + continue + + path, filename = self.getPathFromChName(weighingChannel, posData) + if path is None: + self.criticalFluoChannelNotFound(weighingChannel, posData) + action.weighingData = [] + return + + if filename in posData.fluo_data_dict: + # Weighing data already loaded as additional fluo channel + wData = posData.fluo_data_dict[filename] + else: + # Weighing data never loaded --> load now + wData, _ = self.load_fluo_data(path) + if posData.SizeT == 1: + wData = wData[np.newaxis] + action.weighingData.append(wData) + + def pointLayerToolbuttonToggled(self, checked): + action = self.sender().action + action.scatterItem.setVisible(checked) + + def getCentroidsPointsData(self, action): + # Centroids (either weighted or not) + # NOTE: if user requested to draw from table we load that in + # apps.AddPointsLayerDialog.ok_cb() + posData = self.data[self.pos_i] + action.pointsData[self.pos_i] = {posData.frame_i: {}} + if hasattr(action, 'weighingData'): + lab = posData.lab + img = action.weighingData[self.pos_i][posData.frame_i] + rp = skimage.measure.regionprops(lab, intensity_image=img) + attr = 'weighted_centroid' + else: + rp = posData.rp + attr = 'centroid' + for i, obj in enumerate(rp): + centroid = getattr(obj, attr) + if len(centroid) == 3: + zc, yc, xc = centroid + z_int = round(zc) + if z_int not in action.pointsData[self.pos_i][posData.frame_i]: + action.pointsData[self.pos_i][posData.frame_i][z_int] = { + 'x': [xc], 'y': [yc], 'id': [obj.label] + } + else: + z_data = action.pointsData[self.pos_i][posData.frame_i][z_int] + z_data['x'].append(xc) + z_data['y'].append(yc) + z_data['id'].append(obj.label) + else: + yc, xc = centroid + if 'y' not in action.pointsData[self.pos_i][posData.frame_i]: + action.pointsData[self.pos_i][posData.frame_i]['y'] = [yc] + action.pointsData[self.pos_i][posData.frame_i]['x'] = [xc] + action.pointsData[self.pos_i][posData.frame_i]['id'] = ( + [obj.label] + ) + else: + action.pointsData[self.pos_i][posData.frame_i]['y'].append(yc) + action.pointsData[self.pos_i][posData.frame_i]['x'].append(xc) + action.pointsData[self.pos_i][posData.frame_i]['id'].append( + obj.label + ) + + def drawPointsLayers(self, computePointsLayers=True): + posData = self.data[self.pos_i] + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'layerTypeIdx'): + continue + + if action.layerTypeIdx < 2 and computePointsLayers: + self.getCentroidsPointsData(action) + + if not action.button.isChecked(): + continue + + frames = action.pointsData.get(self.pos_i, set()) + if posData.frame_i not in frames: + if action.layerTypeIdx != 4: + self.logger.info( + f'Frame number {posData.frame_i+1} does not have any ' + f'"{action.layerType}" point to display.' + ) + continue + + framePointsData = action.pointsData[self.pos_i][posData.frame_i] + + if 'x' not in framePointsData: + # 3D points + zProjHow = self.zProjComboBox.currentText() + isZslice = ( + zProjHow == 'single z-slice' and posData.SizeZ > 1 + ) + if isZslice: + xx, yy, ids, data = [], [], [], [] + zSlice = self.zSliceScrollBar.sliderPosition() + zRadius = action.zRadius + zRange = range(zSlice-zRadius, zSlice+zRadius+1) + for z in zRange: + z_data = framePointsData.get(z) + if z_data is None: + continue + xx.extend(z_data['x']) + yy.extend(z_data['y']) + ids.extend(z_data['id']) + try: + data.extend(z_data['data']) + except KeyError as err: + # data is needed only for loaded tables + pass + else: + xx, yy, ids, data = [], [], [], [] + # z-projection --> draw all points + for z, z_data in framePointsData.items(): + xx.extend(z_data['x']) + yy.extend(z_data['y']) + ids.extend(z_data['id']) + try: + data.extend(z_data['data']) + except KeyError as err: + # data is needed only for loaded tables + pass + else: + # 2D segmentation + xx = framePointsData['x'] + yy = framePointsData['y'] + ids = framePointsData['id'] + try: + data = framePointsData['data'] + except KeyError as err: + # data is needed only for loaded tables + pass + + brushColors = [ + action.brushColor if id != 0 else action.brushColorId0 + for id in ids + ] + brushes = [pg.mkBrush(color) for color in brushColors] + + pensColor = [ + action.penColor if id != 0 else action.penColorId0 + for id in ids + ] + pens = [pg.mkPen(color) for color in pensColor] + + if action.layerTypeIdx == 2: + # For loaded table show the rest of the table as a tooltip + data = data + show_data_as_tip = True + else: + data = ids + show_data_as_tip = False + + xx = np.array(xx) # + 0.5 + yy = np.array(yy) # + 0.5 + + action.scatterItem.show_data_as_tip = show_data_as_tip + action.scatterItem.setData( + xx, yy, data=data, brush=brushes, pen=pens + ) + + def setOverlaySingleChannel(self, *args, **kwargs): + if self.overlayToolbar.isSingleChannel(): + self.overlayToolbarAreChannelsChecked = { + channel:toolbutton.isChecked() + for channel, toolbutton in self.allOverlayToolbuttons.items() + } + firstActiveToolbutton = [ + toolbutton for toolbutton in self.allOverlayToolbuttons.values() + if toolbutton.isChecked() + ][0] + firstActiveToolbutton.click() + else: + for ch, checked in self.overlayToolbarAreChannelsChecked.items(): + toolbutton = self.allOverlayToolbuttons[ch] + toolbutton.setChecked(checked) + + self.setOverlayItemsOpacities() + + def updateTransparentOverlayRgba(self, *args, **kwargs): + self.setOverlayImages() + + def setOverlayTransparency(self, transparent: bool): + opacity = float(transparent) + opacity = opacity if opacity < 1.0 else 0.999 + self.rgbaImg1.setOpacity(opacity) + + if transparent: + self.img1.setOpacity(0.001, applyToLinked=False) + self.imgGrad.sigLookupTableChanged.connect( + self.updateTransparentOverlayRgba + ) + self.imgGrad.sigLevelsChanged.connect( + self.updateTransparentOverlayRgba + ) + + for channel, items in self.overlayLayersItems.items(): + imageItem, lutItem, alphaSB = items[:3] + if transparent: + alphaSB.valueChanged.disconnect() + alphaSB.valueChanged.connect( + self.updateTransparentOverlayRgba + ) + lutItem.sigLookupTableChanged.connect( + self.updateTransparentOverlayRgba + ) + lutItem.sigLevelsChanged.connect( + self.updateTransparentOverlayRgba + ) + imageItem.setOpacity(0) + + if not transparent: + self.setOverlayItemsOpacities() + + self.setOverlayImages() + + def overlay_cb(self, checked): + self.overlayToolbar.setVisible(checked) + + self.UserNormAction, _, _ = self.getCheckNormAction() + posData = self.data[self.pos_i] + if checked: + if posData.ol_data is None: + selectedChannels = self.askSelectOverlayChannel() + if selectedChannels is None: + self.overlayButton.toggled.disconnect() + self.overlayButton.setChecked(False) + self.overlayButton.toggled.connect(self.overlay_cb) + return + + success = self.loadOverlayData(selectedChannels) + if not success: + return False + lastChannel = selectedChannels[-1] + self.setCheckedOverlayContextMenusActions(selectedChannels) + imageItem = self.overlayLayersItems[lastChannel][0] + self.setOpacityOverlayLayersItems(None, imageItem=imageItem) + self.setOverlayChannelsToolbuttonsChecked() + + self.setRetainSizePolicyLutItems() + self.normalizeRescale0to1Action.setChecked(True) + + self.updateAllImages() + self.updateImageValueFormatter() + self.enableOverlayWidgets(True) + else: + self.img1.setOpacity(1.0) + self.updateAllImages() + self.updateImageValueFormatter() + self.enableOverlayWidgets(False) + self.clearOverlayImageItems() + + + self.setOverlayItemsVisible() + + def countObjectsCb(self, checked): + if self.countObjsWindow is None: + categoryCountMapper = self.countObjects() + self.countObjsWindow = apps.ObjectCountDialog( + categoryCountMapper=categoryCountMapper, + parent=self, + data=self.data + ) + self.countObjsWindow.sigShowEvent.connect(self.updateObjectCounts) + self.countObjsWindow.sigUpdateCounts.connect(self.updateObjectCounts) + + if checked: + self.countObjsWindow.show() + else: + self.countObjsWindow.hide() + + def showLabelRoiContextMenu(self, event): + menu = QMenu(self.labelRoiButton) + action = QAction('Re-initialize magic labeller model...') + action.triggered.connect(self.initLabelRoiModel) + menu.addAction(action) + menu.exec_(QCursor.pos()) + + def initLabelRoiModel(self): + self.app.restoreOverrideCursor() + # Ask which model + self.initLabelRoiModelDialog = apps.QDialogSelectModel(parent=self) + self.initLabelRoiModelDialog.exec_() + if self.initLabelRoiModelDialog.cancel: + self.logger.info('Magic labeller aborted.') + self.initLabelRoiModelDialog = None + return True + self.app.setOverrideCursor(Qt.WaitCursor) + model_name = self.initLabelRoiModelDialog.selectedModel + self.labelRoiModel = self.repeatSegm( + model_name=model_name, askSegmParams=True, + is_label_roi=True + ) + if self.labelRoiModel is None: + self.initLabelRoiModelDialog = None + return True + self.labelRoiViewCurrentModelAction.setDisabled(False) + self.initLabelRoiModelDialog = None + return False + + def showOverlayContextMenu(self, event): + if not self.overlayButton.isChecked(): + return + + self.overlayContextMenu.exec_(QCursor.pos()) + + def showOverlayLabelsContextMenu(self, event): + if not self.overlayLabelsButton.isChecked(): + return + + self.overlayLabelsContextMenu.exec_(QCursor.pos()) + + def showInstructionsCustomModel(self): + modelFilePath = apps.addCustomModelMessages(self) + if modelFilePath is None: + self.logger.info('Adding custom model process stopped.') + return + + myutils.store_custom_model_path(modelFilePath) + modelName = os.path.basename(os.path.dirname(modelFilePath)) + customModelAction = QAction(modelName) + self.segmSingleFrameMenu.addAction(customModelAction) + self.segmActions.append(customModelAction) + self.segmActionsVideo.append(customModelAction) + self.modelNames.append(modelName) + self.models.append(None) + self.sender().callback(customModelAction) + + def showInstructionsCustomPromptModel(self): + modelFilePath = apps.addCustomPromptModelMessages(QParent=self) + if modelFilePath is None: + self.logger.info('Adding custom promptable model process stopped.') + return + + myutils.store_custom_promptable_model_path(modelFilePath) + + msg = widgets.myMessageBox(wrapText=False) + info_txt = html_utils.paragraph(f""" + Done!

+ The custom promptable model has been added to the list of models.

+ Use the Magic prompts button (top toolbar) to use it.

+ Have fun! + """) + msg.information(self, 'Custom promptable model added', info_txt) + + def segmWithPromptableModelActionTriggered(self): + self.blinker = qutils.QControlBlink( + self.magicPromptsToolButton, qparent=self + ) + self.blinker.start() + + def setCheckedOverlayContextMenusActions(self, channelNames): + for action in self.overlayContextMenu.actions(): + if action.text() in channelNames: + action.setChecked(True) + self.checkedOverlayChannels.add(action.text()) + + def enableOverlayWidgets(self, enabled): + posData = self.data[self.pos_i] + if enabled: + self.overlayColorButton.setDisabled(False) + self.editOverlayColorAction.setDisabled(False) + + if posData.SizeZ == 1: + return + + self.zSliceOverlay_SB.setMaximum(posData.SizeZ-1) + if self.zProjOverlay_CB.currentText().find('max') != -1: + self.overlay_z_label.setDisabled(True) + self.zSliceOverlay_SB.setDisabled(True) + else: + z = self.zSliceOverlay_SB.sliderPosition() + self.overlay_z_label.setText(f'Overlay z-slice {z+1:02}/{posData.SizeZ}') + self.zSliceOverlay_SB.setDisabled(False) + self.overlay_z_label.setDisabled(False) + self.zSliceOverlay_SB.show() + self.overlay_z_label.show() + self.zProjOverlay_CB.show() + self.zSliceOverlay_SB.valueChanged.connect(self.updateOverlayZslice) + self.zProjOverlay_CB.currentTextChanged.connect(self.updateOverlayZproj) + self.zProjOverlay_CB.activated.connect(self.clearComboBoxFocus) + else: + self.zSliceOverlay_SB.setDisabled(True) + self.zSliceOverlay_SB.hide() + self.overlay_z_label.hide() + self.zProjOverlay_CB.hide() + self.overlayColorButton.setDisabled(True) + self.editOverlayColorAction.setDisabled(True) + + if posData.SizeZ == 1: + return + + self.zSliceOverlay_SB.valueChanged.disconnect() + self.zProjOverlay_CB.currentTextChanged.disconnect() + self.zProjOverlay_CB.activated.disconnect() + + + def criticalFluoChannelNotFound(self, fluo_ch, posData): + msg = widgets.myMessageBox(showCentered=False) + ls = "\n".join(myutils.listdir(posData.images_path)) + msg.setDetailedText( + f'Files present in the {posData.relPath} folder:\n' + f'{ls}' + ) + title = 'Requested channel data not found!' + txt = html_utils.paragraph( + f'The folder {posData.pos_path} ' + 'does not contain ' + 'either one of the following files:

' + f'{posData.basename}{fluo_ch}.tif
' + f'{posData.basename}{fluo_ch}_aligned.npz

' + 'Data loading aborted.' + ) + msg.addShowInFileManagerButton(posData.images_path) + okButton = msg.warning( + self, title, txt, buttonsTexts=('Ok') + ) + + def imgGradLUTfinished_cb(self): + posData = self.data[self.pos_i] + ticks = self.imgGrad.gradient.listTicks() + + self.img1ChannelGradients[self.user_ch_name] = { + 'ticks': [(x, t.color.getRgb()) for t,x in ticks], + 'mode': 'rgb' + } + + self.df_settings = self.imgGrad.saveState(self.df_settings) + self.df_settings.to_csv(self.settings_csv_path) + + def updateContColour(self, colorButton): + color = colorButton.color().getRgb() + self.df_settings.at['contLineColor', 'value'] = str(color) + self._updateContColour(color) + self.updateAllImages() + + def _updateContColour(self, color): + self.gui_createContourPens() + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.contoursColorButton.setColor(color) + + def saveContColour(self, colorButton): + self.df_settings.to_csv(self.settings_csv_path) + + def updateMothBudLineColour(self, colorButton): + color = colorButton.color().getRgb() + self.df_settings.at['mothBudLineColor', 'value'] = str(color) + self._updateMothBudLineColour(color) + self.updateAllImages() + + def _updateMothBudLineColour(self, color): + self.gui_createMothBudLinePens() + self.ax1_newMothBudLinesItem.setBrush(self.newMothBudLineBrush) + self.ax1_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush) + self.ax2_newMothBudLinesItem.setBrush(self.newMothBudLineBrush) + self.ax2_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush) + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.mothBudLineColorButton.setColor(color) + + def saveMothBudLineColour(self, colorButton): + self.df_settings.to_csv(self.settings_csv_path) + + def contLineWeightToggled(self, checked=True): + if not checked: + return + self.imgGrad.uncheckContLineWeightActions() + w = self.sender().lineWeight + self.df_settings.at['contLineWeight', 'value'] = w + self.df_settings.to_csv(self.settings_csv_path) + self._updateContLineThickness() + self.updateAllImages() + + def _updateContLineThickness(self): + self.gui_createContourPens() + for act in self.imgGrad.contLineWightActionGroup.actions(): + if act == self.sender(): + act.setChecked(True) + act.toggled.connect(self.contLineWeightToggled) + + def mothBudLineWeightToggled(self, checked=True): + if not checked: + return + self.imgGrad.uncheckContLineWeightActions() + w = self.sender().lineWeight + self.df_settings.at['mothBudLineSize', 'value'] = w + self.df_settings.to_csv(self.settings_csv_path) + self._updateMothBudLineSize(w) + self.updateAllImages() + + def _updateMothBudLineSize(self, size): + self.gui_createMothBudLinePens() + + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): + if act == self.sender(): + act.setChecked(True) + act.toggled.connect(self.mothBudLineWeightToggled) + + self.ax1_oldMothBudLinesItem.setSize(size) + self.ax1_newMothBudLinesItem.setSize(size) + self.ax2_oldMothBudLinesItem.setSize(size) + self.ax2_newMothBudLinesItem.setSize(size) + + def getOlImg(self, key, frame_i=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + img = posData.ol_data[key][frame_i] + if posData.SizeZ > 1: + zProjHow = self.zProjOverlay_CB.currentText() + z = self.zSliceOverlay_SB.sliderPosition() + if zProjHow == 'same as above': + zProjHow = self.zProjComboBox.currentText() + z = self.zSliceScrollBar.sliderPosition() + reconnect = False + try: + self.zSliceOverlay_SB.valueChanged.disconnect() + reconnect = True + except TypeError: + pass + self.zSliceOverlay_SB.setSliderPosition(z) + if reconnect: + self.zSliceOverlay_SB.valueChanged.connect( + self.updateOverlayZslice + ) + if zProjHow == 'single z-slice': + self.overlay_z_label.setText(f'Overlay z-slice {z+1:02}/{posData.SizeZ}') + ol_img = img[z].copy() + elif zProjHow == 'max z-projection': + ol_img = img.max(axis=0) + elif zProjHow == 'mean z-projection': + ol_img = img.mean(axis=0) + elif zProjHow == 'median z-proj.': + ol_img = np.median(img, axis=0) + else: + ol_img = img.copy() + + return ol_img + + def setTextAnnotZsliceScrolling(self): + pass + + def setGraphicalAnnotZsliceScrolling(self): + posData = self.data[self.pos_i] + if self.isSegm3D: + self.currentLab2D = posData.lab[self.z_lab()] + self.setOverlaySegmMasks() + self.doCustomAnnotation(0) + self.update_rp_metadata() + else: + self.currentLab2D = posData.lab + self.setOverlaySegmMasks() + self.updateContoursImage(0) + self.updateContoursImage(1) + + def initContoursImage(self): + posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] + + self.contoursImage = np.zeros((Y, X, 4), dtype=np.uint8) + + def initDelRoiLab(self): + posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] + + self.delRoiLab = np.zeros((Y, X), dtype=np.uint32) + + def initLostObjContoursImage(self): + posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] + + self.lostObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) + + def initExportMaskImage(self): + posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] + + self.exportMaskImage = np.zeros((Y, X, 4), dtype=np.uint8) + + def initLostTrackedObjContoursImage(self): + posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] + + self.lostTrackedObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) + + def initManualBackgroundImage(self): + posData = self.data[self.pos_i] + if hasattr(posData, 'lab'): + Y, X = posData.lab.shape[-2:] + else: + Y, X = posData.img_data.shape[-2:] + if not hasattr(self, 'manualBackgroundTextItems'): + self.manualBackgroundTextItems = {} + posData.manualBackgroundImage = np.zeros((Y, X, 4), dtype=np.uint8) + if posData.manualBackgroundLab is None: + posData.manualBackgroundLab = np.zeros((Y, X), dtype=np.uint32) + + def initTextAnnot(self, force=False): + posData = self.data[self.pos_i] + if hasattr(posData, 'lab'): + Y, X = posData.lab.shape[-2:] + else: + Y, X = posData.img_data.shape[-2:] + self.textAnnot[0].initItem((Y, X)) + self.textAnnot[1].initItem((Y, X)) + + def getObjContours( + self, obj, all_external=False, local=False, force_calc=True, + include_internal=False + ): + posData = self.data[self.pos_i] + dataDict = posData.allData_li[posData.frame_i] + allContours = dataDict.get('contours') + if allContours is not None and not force_calc: + z = self.z_lab() + key = (obj.label, str(z), all_external, local) + contours = allContours.get(key) + if contours is not None: + return contours + + obj_image = self.getObjImage(obj.image, obj.bbox).astype(np.uint8) + obj_bbox = self.getObjBbox(obj.bbox) + try: + contours = core.get_obj_contours( + obj_image=obj_image, + obj_bbox=obj_bbox, + local=local, + all_external=all_external + ) + except Exception as e: + if all_external: + contours = [] + else: + contours = None + self.logger.warning( + f'Object ID {obj.label} contours drawing failed. ' + f'(bounding box = {obj.bbox})' + ) + return contours + + def clearComputedContours(self): + for posData in self.data: + for frame_i, dataDict in enumerate(posData.allData_li): + dataDict['contours'] = {} + + def _computeAllContours2D( + self, dataDict, obj, z, obj_bbox, include_internal=False + ): + obj_image = self.getObjImage(obj.image, obj.bbox, z_slice=z) + if obj_image is None: + return + + all_external = False + local = False + contours = core.get_obj_contours( + obj_image=obj_image, + obj_bbox=obj_bbox, + local=local, + all_external=all_external + ) + key = (obj.label, str(z), all_external, local) + dataDict['contours'][key] = contours + + all_external = True + local = False + contours = core.get_obj_contours( + obj_image=obj_image, + obj_bbox=obj_bbox, + local=local, + all_external=all_external, + all=include_internal + ) + key = (obj.label, str(z), all_external, local) + dataDict['contours'][key] = contours + + return dataDict + + def computeAllContours(self): + self.logger.info('Computing all contours...') + posData = self.data[self.pos_i] + zz = [None] + if self.isSegm3D: + zz.extend(range(posData.SizeZ)) + + include_internal = self.showAllContoursToggle.isChecked() + for frame_i, dataDict in enumerate(posData.allData_li): + lab = dataDict['labels'] + if lab is None: + break + + rp = dataDict['regionprops'] + if rp is None: + rp = skimage.measure.regionprops(lab) + + dataDict['contours'] = {} + for obj in rp: + obj_bbox = self.getObjBbox(obj.bbox) + for z in zz: + if not self.isObjVisible(obj.bbox, z_slice=z): + continue + + try: + self._computeAllContours2D( + dataDict, obj, z, obj_bbox, + include_internal=include_internal + ) + except Exception as err: + # Contours computation fails on weird objects + pass + + def computeAllObjToObjCostPairs(self): + desc = ( + 'Computing all object-to-object cost matrices...' + ) + self.logger.info(desc) + posData = self.data[self.pos_i] + + + self.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.show(self.app) + + self.computeAllObjCostPairsThread = QThread() + self.computeAllObjCostPairsWorker = workers.SimpleWorker( + posData, self._computeAllObjToObjCostPairs + ) + + self.computeAllObjCostPairsWorker.moveToThread( + self.computeAllObjCostPairsThread + ) + + self.computeAllObjCostPairsWorker.signals.finished.connect( + self.computeAllObjCostPairsThread.quit + ) + self.computeAllObjCostPairsWorker.signals.finished.connect( + self.computeAllObjCostPairsWorker.deleteLater + ) + self.computeAllObjCostPairsThread.finished.connect( + self.computeAllObjCostPairsThread.deleteLater + ) + + self.computeAllObjCostPairsWorker.signals.critical.connect( + self.computeAllObjCostPairsWorkerCritical + ) + self.computeAllObjCostPairsWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.computeAllObjCostPairsWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.computeAllObjCostPairsWorker.signals.progress.connect( + self.workerProgress + ) + self.computeAllObjCostPairsWorker.signals.finished.connect( + self.computeAllObjCostPairsWorkerFinished + ) + + self.computeAllObjCostPairsThread.started.connect( + self.computeAllObjCostPairsWorker.run + ) + self.computeAllObjCostPairsThread.start() + + self.computeAllObjCostPairsWorkerLoop = QEventLoop() + self.computeAllObjCostPairsWorkerLoop.exec_() + + def _computeAllObjToObjCostPairs(self, posData): + self.computeAllObjCostPairsWorker.signals.initProgressBar.emit( + len(posData.allData_li) + ) + for frame_i, dataDict in enumerate(posData.allData_li): + if frame_i == 0: + continue + + rp = dataDict['regionprops'] + if rp is None: + break + + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + dist_matrix = core._compute_all_obj_to_obj_contour_dist_pairs( + dataDict['contours'], rp, + prev_rp=prev_rp, + restrict_search=True + ) + dataDict['obj_to_obj_dist_cost_matrix_df'] = dist_matrix + self.computeAllObjCostPairsWorker.signals.progressBar.emit(1) + self.computeAllObjCostPairsWorker.signals.initProgressBar.emit(0) + + def computeAllObjCostPairsWorkerCritical(self, error): + self.computeAllObjCostPairsWorkerLoop.exit() + self.workerCritical(error) + + def computeAllObjCostPairsWorkerFinished(self, output): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.computeAllObjCostPairsWorkerLoop.exit() + + def setOverlaySegmMasks(self, force=False, forceIfNotActive=False): + if not hasattr(self, 'currentLab2D'): + return + + how = self.drawIDsContComboBox.currentText() + isOverlaySegmLeftActive = how.find('overlay segm. masks') != -1 + + how_ax2 = self.getAnnotateHowRightImage() + isOverlaySegmRightActive = ( + how_ax2.find('overlay segm. masks') != -1 + and self.labelsGrad.showRightImgAction.isChecked() + ) + + isOverlaySegmActive = ( + isOverlaySegmLeftActive or isOverlaySegmRightActive + or force + ) + if not isOverlaySegmActive and not forceIfNotActive: + return + + alpha = self.imgGrad.labelsAlphaSlider.value() + if alpha == 0: + return + + posData = self.data[self.pos_i] + maxID = max(posData.IDs, default=0) + + if maxID >= len(self.lut): + self.extendLabelsLUT(maxID+10) + + currentLab2D = self.currentLab2D + if isOverlaySegmLeftActive: + self.labelsLayerImg1.setImage(currentLab2D, autoLevels=False) + + if isOverlaySegmRightActive: + self.labelsLayerRightImg.setImage(currentLab2D, autoLevels=False) + + def getObject2DimageFromZ(self, z, obj): + posData = self.data[self.pos_i] + z_min = obj.bbox[0] + local_z = z - z_min + if local_z >= posData.SizeZ or local_z < 0: + return + return obj.image[local_z] + + def getObject2DsliceFromZ(self, z, obj): + posData = self.data[self.pos_i] + z_min = obj.bbox[0] + local_z = z - z_min + if local_z >= posData.SizeZ or local_z < 0: + return + return obj.image[local_z] + + def isObjVisible(self, obj_bbox, debug=False, z_slice=None): + if z_slice is None: + z_slice = self.z_lab() + + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if not isZslice: + # required a projection --> all obj are visible + return True + + depthAxes = self.switchPlaneCombobox.depthAxes() + + min_z, min_y, min_x, max_z, max_y, max_x = obj_bbox + if depthAxes == 'z': + min_val, max_val = min_z, max_z + val = z_slice + elif depthAxes == 'y': + min_val, max_val = min_y, max_y + val = z_slice[-1] + else: + min_val, max_val = min_x, max_x + val = z_slice[-1] + + if val >= min_val and val < max_val: + return True + else: + return False + else: + return True + + def getObjImage(self, obj_image, obj_bbox, z_slice=None): + if self.isSegm3D and len(obj_bbox)==6: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if not isZslice: + # required a projection + return obj_image.max(axis=0) + + min_z = obj_bbox[0] + if z_slice is None: + z_slice = self.z_lab() + if isinstance(z_slice, tuple): + z_slice = z_slice[-1] + + local_z = z_slice - min_z + try: + obi_image_2d = obj_image[local_z] + except Exception as err: + obi_image_2d = None + return obi_image_2d + else: + return obj_image + + def getObjSlice(self, obj_slice): + if self.isSegm3D: + return obj_slice[1:3] + else: + return obj_slice + + def setOverlayImages(self, frame_i=None): + if not self.overlayButton.isChecked(): + return + + posData = self.data[self.pos_i] + if posData.ol_data is None: + return + + rgba_imgs_info = {} + for filename in posData.ol_data: + chName = myutils.get_chname_from_basename( + filename, posData.basename, remove_ext=False + ) + if chName not in self.checkedOverlayChannels: + continue + + items = self.overlayLayersItems[chName] + imageItem, lutItem, alphaSB = items[:3] + + ol_img = self.getOlImg(filename, frame_i=frame_i) + + if self.overlayToolbar.isTransparent(): + toolbutton = items[3] + if not toolbutton.isChecked(): + continue + alpha_val = alphaSB.value()/alphaSB.maximum() + ol_img = skimage.exposure.rescale_intensity( + ol_img, out_range=(0.0, 1.0) + ) + out_range_min, out_range_max = lutItem.getLevels() + rgba_imgs_info[chName] = (ol_img, alpha_val, lutItem) + else: + self.rescaleIntensitiesLut(setImage=False, imageItem=imageItem) + imageItem.setImage(ol_img) + + if not self.overlayToolbar.isTransparent(): + return + + alpha_values = [] + images = [] + luts = [] + for channel, info in rgba_imgs_info.items(): + ol_img, alpha_val, lutItem = info + alpha_values.append(alpha_val) + images.append(ol_img) + luts.append(lutItem.gradient.getLookupTable(256, alpha=255)/255) + + weights = colors.hierarchical_weights(alpha_values) + + if self.baseLayerToolbutton.isChecked(): + image1 = self._getImageupdateAllImages() + image1 = skimage.exposure.rescale_intensity( + image1, out_range=(0.0, 1.0) + ) + images.append(image1) + baseLut = ( + self.imgGrad.gradient.getLookupTable(256, alpha=255)/255 + ) + luts.append(baseLut) + + images_rgba = [] + for img, lut in zip(images, luts): + rgba = colors.grayscale_apply_lut(img, lut) + images_rgba.append(rgba) + + rgba_merge = colors.hierarchical_blend(images_rgba, weights) + self.rgbaImg1.setImage(rgba_merge) + + def getOpacitiesFromAlphaScrollbarValues(self): + alpha_values = [] + activeOverlayImageItems = [] + for items in self.overlayLayersItems.values(): + imgItem, lutItem, alphaSB = items[:3] + _toolbutton = alphaSB.toolbutton + if not _toolbutton.isChecked() or not _toolbutton.isVisible(): + continue + + alpha_values.append(alphaSB.value()/alphaSB.maximum()) + activeOverlayImageItems.append(imgItem) + + opacities = colors.hierarchical_weights(alpha_values)[::-1] + channel_opacity_mapper = {} + for i, imgItem in enumerate(activeOverlayImageItems): + channel_opacity_mapper[imgItem.channelName] = opacities[i+1] + + channel_opacity_mapper[self.user_ch_name] = opacities[0] + + return channel_opacity_mapper + + def initShortcuts(self): + from . import config + cp = config.ConfigParser() + if os.path.exists(shortcut_filepath): + cp.read(shortcut_filepath) + + if 'keyboard.shortcuts' not in cp: + cp['keyboard.shortcuts'] = {} + + if cp.has_option('keyboard.shortcuts', 'Zoom out'): + zoomOutKeyValueStr = cp['keyboard.shortcuts']['Zoom out'] + try: + self.zoomOutKeyValue = int(zoomOutKeyValueStr) + except Exception as err: + self.logger.warning( + f'{zoomOutKeyValueStr} is not a valid key ' + 'zooming out action. Restoring default key "H".' + ) + + if 'delete_object.action' not in cp: + self.delObjAction = None + else: + delObjKeySequenceText = cp['delete_object.action']['Key sequence'] + delObjButtonText = cp['delete_object.action']['Mouse button'] + delObjQtButton = ( + Qt.MouseButton.LeftButton if delObjButtonText == 'Left click' + else Qt.MouseButton.MiddleButton + ) + if not delObjKeySequenceText: + delObjKeySequence = None + else: + delObjKeySequence = widgets.KeySequenceFromText( + delObjKeySequenceText + ) + self.delObjToolAction.setChecked(True) + self.delObjAction = delObjKeySequence, delObjQtButton + + shortcuts = {} + for name, widget in self.widgetsWithShortcut.items(): + if name not in cp.options('keyboard.shortcuts'): + if hasattr(widget, 'keyPressShortcut'): + key = widget.keyPressShortcut + shortcut = widgets.KeySequenceFromText(key) + else: + shortcut = widget.shortcut() + shortcut_text = shortcut.toString() + cp['keyboard.shortcuts'][name] = shortcut_text + else: + shortcut_text = cp['keyboard.shortcuts'][name] + shortcut = widgets.KeySequenceFromText(shortcut_text) + + shortcuts[name] = (shortcut_text, shortcut) + self.setShortcuts(shortcuts, save=False) + with open(shortcut_filepath, 'w') as ini: + cp.write(ini) + + def setShortcuts(self, shortcuts: dict, save=True): + for name, (text, shortcut) in shortcuts.items(): + widget = self.widgetsWithShortcut[name] + if shortcut is None: + shortcut = QKeySequence() + if hasattr(widget, 'keyPressShortcut'): + widget.keyPressShortcut = shortcut + else: + widget.setShortcut(shortcut) + s = widget.toolTip() + toolTip = re.sub(r'Shortcut: "(.*)"', f'Shortcut: "{text}"', s) + widget.setToolTip(toolTip) + + if not save: + return + + from . import config + cp = config.ConfigParser() + if os.path.exists(shortcut_filepath): + cp.read(shortcut_filepath) + + if 'keyboard.shortcuts' not in cp: + cp['keyboard.shortcuts'] = {} + + for name, (text, shortcut) in shortcuts.items(): + cp['keyboard.shortcuts'][name] = text + + cp['keyboard.shortcuts']['Zoom out'] = str(self.zoomOutKeyValue) + + if self.delObjAction is None: + with open(shortcut_filepath, 'w') as ini: + cp.write(ini) + return + + delObjKeySequence, delObjQtButton = self.delObjAction + try: + if delObjKeySequence is None: + delObjKeySequenceText = '' + else: + delObjKeySequenceText = delObjKeySequence.toString() + + delObjKeySequenceText = ( + delObjKeySequenceText + .encode('ascii', 'ignore') + .decode('utf-8') + ) + delObjButtonText = ( + 'Left click' if delObjQtButton == Qt.MouseButton.LeftButton + else 'Middle click' + ) + cp['delete_object.action'] = { + 'Key sequence': delObjKeySequenceText, + 'Mouse button': delObjButtonText + } + except Exception as err: + self.logger.warning( + f'{delObjKeySequence} is not a valid keys sequence for ' + 'deleting objects. Setting default action' + ) + self.delObjAction = None + cp.remove_section('delete_object.action') + + with open(shortcut_filepath, 'w') as ini: + cp.write(ini) + + def editShortcuts_cb(self): + if is_mac: + delObjKeySequenceText = 'Ctrl' + delObjButtonText = 'Left click' + else: + delObjKeySequenceText = '' + delObjButtonText = 'Middle click' + + if self.delObjAction is not None: + delObjKeySequence, delObjQtButton = self.delObjAction + if delObjKeySequence is None: + delObjKeySequenceText = '' + else: + delObjKeySequenceText = delObjKeySequence.toString() + delObjKeySequenceText = ( + delObjKeySequenceText.encode('ascii', 'ignore').decode('utf-8') + ) + delObjButtonText = ( + 'Left click' if delObjQtButton == Qt.MouseButton.LeftButton + else 'Middle click' + ) + + win = apps.ShortcutEditorDialog( + self.widgetsWithShortcut, + delObjectKey=delObjKeySequenceText, + delObjectButton=delObjButtonText, + zoomOutKeyValue=self.zoomOutKeyValue, + parent=self + ) + win.exec_() + if win.cancel: + return + + self.delObjAction = win.delObjAction + self.zoomOutKeyValue = win.zoomOutKeyValue + self.setShortcuts(win.customShortcuts) + + def toggleOverlayColorButton(self, checked=True): + self.mousePressColorButton(None) + + def toggleTextIDsColorButton(self, checked=True): + self.textIDsColorButton.selectColor() + + def updateTextAnnotColor(self, button): + r, g, b = np.array(self.textIDsColorButton.color().getRgb()[:3]) + self.imgGrad.textColorButton.setColor((r, g, b)) + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.textColorButton.setColor((r, g, b)) + self.gui_createTextAnnotColors(r,g,b, custom=True) + self.gui_setTextAnnotColors() + self.updateAllImages() + + def saveTextIDsColors(self, button): + self.df_settings.at['textIDsColor', 'value'] = self.objLabelAnnotRgb + self.df_settings.to_csv(self.settings_csv_path) + + def setLut(self, shuffle=True): + self.lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) + if shuffle: + np.random.shuffle(self.lut) + + # Insert background color + if 'labels_bkgrColor' in self.df_settings.index: + rgbString = self.df_settings.at['labels_bkgrColor', 'value'] + try: + r, g, b = rgbString + except Exception as e: + r, g, b = colors.rgb_str_to_values(rgbString) + else: + r, g, b = 25, 25, 25 + self.df_settings.at['labels_bkgrColor', 'value'] = (r, g, b) + + self.lut = np.insert(self.lut, 0, [r, g, b], axis=0) + + def useCenterBrushCursorHoverIDtoggled(self, checked): + if checked: + self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' + else: + self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'No' + self.df_settings.to_csv(self.settings_csv_path) + + def shuffle_cmap(self): + np.random.shuffle(self.lut[1:]) + self.initLabelsImageItems() + self.updateAllImages() + + def setPermanentGreedyCmapPreferences(self): + if self.isSnapshot: + option_name = 'permanent_greedy_lut_snapshots' + else: + option_name = 'permanent_greedy_lut_timelapse' + + if option_name not in self.df_settings.index: + return + + checked = self.df_settings.at[option_name, 'value'] == 'yes' + self.labelsGrad.permanentGreedyCmapAction.setChecked(checked) + + def permanentGreedyCmapToggled(self, checked): + if checked: + settings_value = 'yes' + else: + self.setLut() + self.updateLookuptable() + self.initLabelsImageItems() + settings_value = 'no' + + self.updateAllImages() + + if self.isSnapshot: + option_name = 'permanent_greedy_lut_snapshots' + else: + option_name = 'permanent_greedy_lut_timelapse' + + self.df_settings.at[option_name, 'value'] = settings_value + self.df_settings.to_csv(self.settings_csv_path) + + def greedyShuffleCmap(self, updateImages=True): + lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) + greedy_lut = colors.get_greedy_lut(self.currentLab2D, lut) + self.lut = greedy_lut + self.initLabelsImageItems() + if updateImages: + self.updateAllImages() + + def highlightZneighLabels_cb(self, checked): + if checked: + pass + else: + pass + + def setTwoImagesLayout(self, isTwoImages): + self.isTwoImageLayout = isTwoImages + if isTwoImages: + self.graphLayout.removeItem(self.titleLabel) + self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) + # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignLeft) + self.ax2.show() + self.ax2.vb.setYLink(self.ax1.vb) + self.ax2.vb.setXLink(self.ax1.vb) + else: + self.graphLayout.removeItem(self.titleLabel) + self.graphLayout.addItem(self.titleLabel, row=0, col=1) + # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignCenter) + self.ax2.hide() + oldLink = self.ax2.vb.linkedView(self.ax1.vb.YAxis) + try: + oldLink.sigYRangeChanged.disconnect() + oldLink.sigXRangeChanged.disconnect() + except TypeError: + pass + + def showNextFrameImageItem(self, checked): + self.rightImageFramesScrollbar.setVisible(checked) + self.rightImageFramesScrollbar.setDisabled(not checked) + self.setTwoImagesLayout(checked) + if checked: + self.df_settings.at['isNextFrameVisible', 'value'] = 'Yes' + self.df_settings.at['isRightImageVisible', 'value'] = 'No' + self.df_settings.at['isLabelsVisible', 'value'] = 'No' + self.graphLayout.addItem( + self.imgGradRight, row=1, col=self.plotsCol+2 + ) + self.rightBottomGroupbox.show() + self.rightBottomGroupbox.setChecked(True) + self.drawNothingCheckboxRight.click() + if not self.isDataLoading: + self.updateAllImages() + else: + self.clearAx2Items() + self.rightBottomGroupbox.hide() + self.df_settings.at['isNextFrameVisible', 'value'] = 'No' + try: + self.graphLayout.removeItem(self.imgGradRight) + except Exception: + return + self.rightImageItem.clear() + + self.df_settings.to_csv(self.settings_csv_path) + + QTimer.singleShot(300, self.resizeGui) + + self.setBottomLayoutStretch() + + + def showRightImageItem(self, checked): + self.rightImageFramesScrollbar.setVisible(not checked) + self.rightImageFramesScrollbar.setDisabled(checked) + self.setTwoImagesLayout(checked) + if checked: + self.df_settings.at['isRightImageVisible', 'value'] = 'Yes' + self.df_settings.at['isNextFrameVisible', 'value'] = 'No' + self.df_settings.at['isLabelsVisible', 'value'] = 'No' + self.graphLayout.addItem( + self.imgGradRight, row=1, col=self.plotsCol+2 + ) + self.rightBottomGroupbox.show() + if not self.isDataLoading: + self.updateAllImages() + else: + self.clearAx2Items() + self.rightBottomGroupbox.hide() + self.df_settings.at['isRightImageVisible', 'value'] = 'No' + try: + self.graphLayout.removeItem(self.imgGradRight) + except Exception: + return + self.rightImageItem.clear() + + self.df_settings.to_csv(self.settings_csv_path) + + QTimer.singleShot(300, self.resizeGui) + + self.setBottomLayoutStretch() + + def showLabelImageItem(self, checked): + self.rightImageFramesScrollbar.setVisible(not checked) + self.rightImageFramesScrollbar.setDisabled(checked) + self.setTwoImagesLayout(checked) + self.setAnnotOptionsRightImageLabelsDisabled(checked) + if checked: + self.df_settings.at['isLabelsVisible', 'value'] = 'Yes' + self.df_settings.at['isNextFrameVisible', 'value'] = 'No' + self.df_settings.at['isRightImageVisible', 'value'] = 'No' + self.rightBottomGroupbox.show() + self.rightBottomGroupbox.setChecked(True) + if not self.isDataLoading: + self.updateAllImages() + else: + self.clearAx2Items() + self.img2.clear() + self.df_settings.at['isLabelsVisible', 'value'] = 'No' + self.rightBottomGroupbox.hide() + self.moveDelRoisToLeft() + + self.df_settings.to_csv(self.settings_csv_path) + QTimer.singleShot(200, self.resizeGui) + + self.setBottomLayoutStretch() + + def setAnnotOptionsRightImageLabelsDisabled(self, disabled): + self.annotContourCheckboxRight.setDisabled(disabled) + self.annotSegmMasksCheckboxRight.setDisabled(disabled) + if disabled: + self.annotSegmMasksCheckboxRight.setChecked(False) + self.annotSegmMasksCheckboxRight.setChecked(False) + self.annotIDsCheckboxRight.setChecked(True) + + def moveDelRoisToLeft(self): + # Move del ROIs to the left image + for posData in self.data: + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + for roi in delROIs_info['rois']: + if not self.ax2.isDelRoiItemPresent(roi): + continue + + self.ax1.addDelRoiItem(roi, roi.key) + self.ax2.removeDelRoiItem(roi) + + def setBottomLayoutStretch(self): + if ( + self.labelsGrad.showRightImgAction.isChecked() + or self.labelsGrad.showNextFrameAction.isChecked() + ): + # Equally share space between the two control groupboxes + self.bottomLayout.setStretch(1, 1) + self.bottomLayout.setStretch(2, 5) + self.bottomLayout.setStretch(3, 1) + self.bottomLayout.setStretch(4, 5) + self.bottomLayout.setStretch(5, 1) + elif self.labelsGrad.showLabelsImgAction.isChecked(): + # Left control takes only left space + self.bottomLayout.setStretch(1, 1) + self.bottomLayout.setStretch(2, 5) + self.bottomLayout.setStretch(3, 5) + self.bottomLayout.setStretch(4, 1) + self.bottomLayout.setStretch(5, 1) + else: + # Left control takes all the space + self.bottomLayout.setStretch(1, 3) + self.bottomLayout.setStretch(2, 10) + self.bottomLayout.setStretch(3, 1) + self.bottomLayout.setStretch(4, 1) + self.bottomLayout.setStretch(5, 1) + + def setCheckedInvertBW(self, checked): + self.invertBwAction.setChecked(checked) + + def ticksCmapMoved(self, gradient): + pass + # posData = self.data[self.pos_i] + # self.setLut(posData, shuffle=False) + # self.updateLookuptable() + + def updateLabelsCmap(self, gradient): + self.setLut() + self.updateLookuptable() + self.initLabelsImageItems() + + self.df_settings = self.labelsGrad.saveState(self.df_settings) + self.df_settings.to_csv(self.settings_csv_path) + + self.updateAllImages() + + def updateBkgrColor(self, button): + color = button.color().getRgb()[:3] + self.lut[0] = color + self.updateLookuptable() + + def updateTextLabelsColor(self, button): + self.ax2_textColor = button.color().getRgb()[:3] + posData = self.data[self.pos_i] + if posData.rp is None: + return + + for obj in posData.rp: + self.getObjOptsSegmLabels(obj) + + def saveTextLabelsColor(self, button): + color = button.color().getRgb()[:3] + self.df_settings.at['labels_text_color', 'value'] = color + self.df_settings.to_csv(self.settings_csv_path) + + def saveBkgrColor(self, button): + color = button.color().getRgb()[:3] + self.df_settings.at['labels_bkgrColor', 'value'] = color + self.df_settings.to_csv(self.settings_csv_path) + self.updateAllImages() + + def changeOverlayColor(self, button): + rgb = button.color().getRgb()[:3] + lutItem = self.overlayLayersItems[button.channel][1] + self.initColormapOverlayLayerItem(rgb, lutItem) + lutItem.overlayColorButton.setColor(rgb) + + def saveOverlayColor(self, button): + rgb = button.color().getRgb()[:3] + rgb_text = '_'.join([str(val) for val in rgb]) + self.df_settings.at[f'{button.channel}_rgb', 'value'] = rgb_text + self.df_settings.to_csv(self.settings_csv_path) + + def getImageDataFromFilename(self, filename): + posData = self.data[self.pos_i] + if filename == posData.filename: + return posData.img_data[posData.frame_i] + else: + return posData.ol_data_dict.get(filename) + + def z_slice_index(self): + posData = self.data[self.pos_i] + if posData.SizeZ == 1: + return None + zProjHow = self.zProjComboBox.currentText() + if zProjHow != 'single z-slice': + return zProjHow + + axis_slice = self.zSliceScrollBar.sliderPosition() + if self.switchPlaneCombobox.depthAxes() == 'x': + z_slice = ( + slice(None, None, None), slice(None, None, None), axis_slice + ) + elif self.switchPlaneCombobox.depthAxes() == 'y': + z_slice = ( + slice(None, None, None), axis_slice + ) + else: + z_slice = axis_slice + + return z_slice + + def get_2Dimg_from_3D(self, imgData, isLayer0=True, frame_i=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + if frame_i < 0: + frame_i = 0 + frame_i = posData.frame_i = 0 + + axis_slice = self.zSliceScrollBar.sliderPosition() + if self.switchPlaneCombobox.depthAxes() == 'x': + return imgData[:, :, axis_slice].copy() + elif self.switchPlaneCombobox.depthAxes() == 'y': + return imgData[:, axis_slice].copy() + + idx = (posData.filename, frame_i) + zProjHow_L0 = self.zProjComboBox.currentText() + if isLayer0: + try: + z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + except ValueError as e: + z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] + zProjHow = zProjHow_L0 + else: + z = self.zSliceOverlay_SB.sliderPosition() + zProjHow_L1 = self.zProjOverlay_CB.currentText() + if zProjHow_L1 == 'same as above': + zProjHow = zProjHow_L0 + else: + zProjHow = zProjHow_L1 + + if zProjHow == 'single z-slice': + img = imgData[z] #.copy() + elif zProjHow == 'max z-projection': + img = imgData.max(axis=0) + elif zProjHow == 'mean z-projection': + img = imgData.mean(axis=0) + elif zProjHow == 'median z-proj.': + img = np.median(imgData, axis=0) + return img + + def updateZsliceScrollbar(self, frame_i): + posData = self.data[self.pos_i] + if self.switchPlaneCombobox.depthAxes() != 'z': + return + + idx = (posData.filename, frame_i) + try: + z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + except ValueError as e: + z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] + try: + zProjHow = posData.segmInfo_df.at[idx, 'which_z_proj_gui'] + except ValueError as e: + zProjHow = posData.segmInfo_df.loc[idx, 'which_z_proj_gui'].iloc[0] + + self.zProjComboBox.setCurrentText(zProjHow) + + reconnect = False + try: + self.zSliceScrollBar.actionTriggered.disconnect() + self.zSliceScrollBar.sliderReleased.disconnect() + reconnect = True + except TypeError: + pass + self.zSliceScrollBar.setSliderPosition(z) + if reconnect: + self.zSliceScrollBar.actionTriggered.connect( + self.zSliceScrollBarActionTriggered + ) + self.zSliceScrollBar.sliderReleased.connect( + self.zSliceScrollBarReleased + ) + self.zSliceSpinbox.setValueNoEmit(z+1) + + def getRawImage(self, frame_i=None, filename=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + if filename is None: + rawImgData = posData.img_data[frame_i] + isLayer0 = True + else: + rawImgData = posData.ol_data[filename][frame_i] + isLayer0 = False + if posData.SizeZ > 1: + rawImg = self.get_2Dimg_from_3D(rawImgData, isLayer0=isLayer0) + else: + rawImg = rawImgData + return rawImg + + def getRawImageLayer0(self, frame_i): + posData = self.data[self.pos_i] + + if posData.SizeZ > 1: + img = posData.img_data[frame_i] + self.updateZsliceScrollbar(frame_i) + img = self.get_2Dimg_from_3D(img) + else: + img = posData.img_data[frame_i].copy() + + if img.ndim == 2: + return img + if img.ndim == 3 and img.shape[-1] in (3, 4): + return img + + raise ValueError( + 'Raw image for display must be 2D (Y, X) or RGB/A (Y, X, 3 or 4); ' + f'got shape={getattr(img, "shape", None)}, ndim={getattr(img, "ndim", None)} ' + f'for frame_i={frame_i} (metadata SizeT={posData.SizeT}, SizeZ={posData.SizeZ}). ' + 'Check that metadata SizeT/SizeZ matches the loaded array (e.g. squeezed TIFF vs CSV).' + ) + + def initFloodMaskImage(self): + posData = self.data[self.pos_i] + self.flood_img = posData.img_data[posData.frame_i] + if not self.isSegm3D and posData.SizeZ > 1: + self.flood_img = self.get_2Dimg_from_3D(self.flood_img) + return + + def getMagicWandFloodTolerance(self): + tol_perc = self.wandControlsToolbar.toleranceSpinbox.value() + if tol_perc == 0: + return + + posData = self.data[self.pos_i] + _min, _max = posData.img_data_min_max + tol_fraction = tol_perc/100 + tol = (_max - _min) * tol_fraction + + return tol + + def getImage(self, frame_i=None, raw=False): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + if raw: + return self.getRawImageLayer0(frame_i) + + if self.viewPreprocDataToggle.isChecked(): + try: + img = posData.preproc_img_data[frame_i] + if posData.SizeZ == 1: + return np.array(img) + + self.updateZsliceScrollbar(frame_i) + z_slice = self.z_slice_index() + img = img[z_slice] + return img + except Exception as err: + # self.logger.warning( + # 'Pre-processed image not existing --> returning raw image' + # ) + return self.getRawImageLayer0(frame_i) + + viewCombinedImageData = ( + self.viewCombineChannelDataToggle.isChecked() + and self.combineDialog is not None + and not self.combineDialog.saveAsSegm() + ) + + if viewCombinedImageData: + try: + img = posData.combine_img_data[frame_i] + if posData.SizeZ == 1: + return np.array(img) + + self.updateZsliceScrollbar(frame_i) + z_slice = self.z_slice_index() + img = img[z_slice] + return img + except Exception as err: + # self.logger.warning( + # 'combined image not existing --> returning raw image' + # ) + return self.getRawImageLayer0(frame_i) + + if self.equalizeHistPushButton.isChecked(): + img = posData.equalized_img_data[frame_i] + if posData.SizeZ == 1: + return np.array(img) + + self.updateZsliceScrollbar(frame_i) + z_slice = self.z_slice_index() + img = img[z_slice] + return img + + return self.getRawImageLayer0(frame_i) + + def setImageImg2(self, updateLookuptable=True, set_image=True): + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + if mode == 'Segmentation and Tracking' or self.isSnapshot: + # self.addExistingDelROIs() + allDelIDs, lab2D = self.getDelROIlab() + else: + lab2D = self.get_2Dlab(posData.lab, force_z=False) + allDelIDs = set() + + self.currentLab2D = lab2D + if self.labelsGrad.permanentGreedyCmapAction.isChecked() and updateLookuptable: + self.greedyShuffleCmap(updateImages=False) + + if self.labelsGrad.showLabelsImgAction.isChecked() and set_image: + self.img2.setImage(lab2D, z=self.z_lab(), autoLevels=False) + + if updateLookuptable: + self.updateLookuptable(delIDs=allDelIDs) + + def applyDelROIimg1(self, roi, init=False, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): + return + + if init and how.find('contours') == -1: + self.setOverlaySegmMasks(force=True) + return + + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + try: + idx = delROIs_info['rois'].index(roi) + except Exception as err: + try: + ax.removeDelRoiItem(roi) + except Exception as err: + pass + return + delIDs = delROIs_info['delIDsROI'][idx] + delMask = delROIs_info['delMasks'][idx] + if how.find('nothing') != -1: + return + elif how.find('contours') != -1: + self.updateContoursImage(ax=ax) + + if not delIDs: + return + + if how.find('overlay segm. masks') != -1: + lab = self.currentLab2D.copy() + lab[delMask > 0] = 0 + if ax == 0: + self.labelsLayerImg1.setImage(lab, autoLevels=False) + else: + self.labelsLayerRightImg.setImage(lab, autoLevels=False) + + self.setAllTextAnnotations(labelsToSkip={ID:True for ID in delIDs}) + + def applyDelROIs(self): + self.logger.info('Applying deletion ROIs (if present)...') + + for posData in self.data: + self.current_frame_i = posData.frame_i + for frame_i in range(posData.SizeT): + lab = posData.allData_li[frame_i]['labels'] + if lab is None: + break + delROIs_info = posData.allData_li[frame_i]['delROIs_info'] + delIDs_rois = delROIs_info['delIDsROI'] + if not delIDs_rois: + continue + for delIDs in delIDs_rois: + for delID in delIDs: + lab[lab==delID] = 0 + posData.allData_li[frame_i]['labels'] = lab + # Get the rest of the metadata and store data based on the new lab + posData.frame_i = frame_i + self.get_data() + self.store_data(autosave=False) + + # Back to current frame + posData.frame_i = self.current_frame_i + self.get_data() + + def initTempLayerBrush(self, ID, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + self.hideItemsHoverBrush(ID=ID, force=True) + Y, X = self.img1.image.shape[:2] + tempImage = np.zeros((Y, X), dtype=np.uint32) + if how.find('contours') != -1: + tempImage[self.currentLab2D==ID] = ID + self.brushImage = tempImage.copy() + self.brushContourImage = np.zeros((Y, X, 4), dtype=np.uint8) + color = self.imgGrad.contoursColorButton.color() + self.brushContoursRgba = color.getRgb() + opacity = 1.0 + else: + opacity = self.imgGrad.labelsAlphaSlider.value() + color = self.lut[ID] + lut = np.zeros((2, 4), dtype=np.uint8) + lut[1,-1] = 255 + lut[1,:-1] = color + self.tempLayerImg1.setLookupTable(lut) + self.tempLayerImg1.setOpacity(opacity) + self.tempLayerImg1.setImage(tempImage, force_set_linked=True) + + def _setTempImageBrushContour(self): + pass + + def setTempBrushMaskFromWand(self, flood_mask, init=False): + if not np.any(flood_mask): + return + + posData = self.data[self.pos_i] + mask = np.logical_or( + flood_mask, + posData.lab==posData.brushID + ) + if mask.ndim == 3: + z_slice = self.zSliceScrollBar.sliderPosition() + mask = mask[z_slice] + + self.setTempImg1Brush(init, mask, posData.brushID) + + # @exec_time + def setTempImg1Brush(self, init: bool, mask, ID, toLocalSlice=None, ax=0): + if init: + self.initTempLayerBrush(ID, ax=ax) + + if self.annotContourCheckbox.isChecked(): + brushImage = self.brushImage + else: + brushImage = self.tempLayerImg1.image + + if toLocalSlice is None: + brushImage[mask] = ID + else: + brushImage[toLocalSlice][mask] = ID + + if self.annotContourCheckbox.isChecked(): + try: + obj = skimage.measure.regionprops(brushImage)[0] + except IndexError: + return + objContour = [self.getObjContours(obj)] + # objContour = core.get_obj_contours( + # obj_image=(brushImage>0).astype(np.uint8), local=True + # ) + self.brushContourImage[:] = 0 + img = self.brushContourImage + color = self.brushContoursRgba + cv2.drawContours(img, objContour, -1, color, 1) + self.tempLayerImg1.setImage(img, force_set_linked=True) + else: + self.tempLayerImg1.setImage(brushImage, force_set_linked=True) + + def getLabelsLayerImage(self, ax=0): + if ax == 0: + return self.labelsLayerImg1.image + else: + return self.labelsLayerRightImg.image + + def clearObjFromMask(self, image, mask, toLocalSlice=None): + if mask is None: + return image + + if toLocalSlice is None: + image[mask] = 0 + else: + image[toLocalSlice][mask] = 0 + + return image + + # @exec_time + def setTempImg1Eraser(self, mask, init=False, toLocalSlice=None, ax=0): + if init: + self.erasedLab = np.zeros_like(self.currentLab2D) + + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): + return + + if how.find('contours') != -1: + self.clearObjFromMask( + self.contoursImage, mask, toLocalSlice=toLocalSlice + ) + erasedRp = skimage.measure.regionprops(self.erasedLab) + for obj in erasedRp: + self.addObjContourToContoursImage(obj=obj, ax=ax) + elif how.find('overlay segm. masks') != -1: + labelsImage = self.getLabelsLayerImage(ax=ax) + self.clearObjFromMask(labelsImage, mask, toLocalSlice=toLocalSlice) + if ax == 0: + self.labelsLayerImg1.setImage( + self.labelsLayerImg1.image, autoLevels=False + ) + else: + self.labelsLayerRightImg.setImage( + self.labelsLayerRightImg.image, autoLevels=False + ) + + def _setTempImgExpandLabelSegmMasks(self, prevCoords, ax=0): + # Remove previous overlaid mask + labelsImage = self.getLabelsLayerImage(ax=ax) + labelsImage[prevCoords] = 0 + + # Overlay new moved mask + labelsImage[prevCoords] = self.expandingID + + if ax == 0: + self.labelsLayerImg1.setImage( + self.labelsLayerImg1.image, autoLevels=False) + else: + self.labelsLayerRightImg.setImage( + self.labelsLayerRightImg.image, autoLevels=False) + + def _setTempImgExpandLabelContours(self, prevCoords, ax=0): + self.contoursImage[prevCoords] = [0,0,0,0] + currentLab2Drp = skimage.measure.regionprops(self.currentLab2D) + for obj in currentLab2Drp: + if obj.label == self.expandingID: + # self.clearObjContour(obj=obj, ax=ax) + self.addObjContourToContoursImage(obj=obj, ax=ax, force=True) + break + + def setTempImgExpandLabel(self, prevCoords, expandedObjCoords, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + self._setTempImgExpandLabelContours(prevCoords, ax=ax) + + # if how.find('overlay segm. masks') != -1: + # self._setTempImgExpandLabelSegmMasks(ax=ax) + # else: + # self._setTempImgExpandLabelContours(ax=ax) + + def setTempImg1MoveLabel(self, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + if how.find('contours') != -1: + currentLab2Drp = skimage.measure.regionprops(self.currentLab2D) + for obj in currentLab2Drp: + if obj.label == self.movingID: + self.addObjContourToContoursImage(obj=obj, ax=ax) + break + elif how.find('overlay segm. masks') != -1: + if ax == 0: + self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) + self.highLightIDLayerImg1.image[:] = 0 + mask = self.currentLab2D==self.movingID + self.highLightIDLayerImg1.image[mask] = self.movingID + highlightedImage = self.highLightIDLayerImg1.image + self.highLightIDLayerImg1.setImage(highlightedImage) + else: + self.labelsLayerRightImg.setImage( + self.currentLab2D, autoLevels=False + ) + self.highLightIDLayerRightImage.image[:] = 0 + mask = self.currentLab2D==self.movingID + self.highLightIDLayerRightImage.image[mask] = self.movingID + highlightedImage = self.highLightIDLayerRightImage.image + self.highLightIDLayerRightImage.setImage(highlightedImage) + + def addMissingIDs_cca_df(self, posData): + base_cca_df = self.getBaseCca_df() + if posData.cca_df is None: + posData.cca_df = base_cca_df + return + + posData.cca_df = posData.cca_df.combine_first(base_cca_df) + + def update_cca_df_relabelling(self, posData, oldIDs, newIDs): + relIDs = posData.cca_df['relative_ID'] + posData.cca_df['relative_ID'] = relIDs.replace(oldIDs, newIDs) + mapper = dict(zip(oldIDs, newIDs)) + posData.cca_df = posData.cca_df.rename(index=mapper) + + def update_cca_df_deletedIDs( + self, posData, deletedIDs, dropInPast=True, dropInFuture=True + ): + if posData.cca_df is None: + return + + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + try: + relIDs = ( + posData.cca_df.reindex(deletedIDs, fill_value=-1) + ['relative_ID'] + ) + except KeyError as err: + return + + posData.cca_df = posData.cca_df.drop(deletedIDs, errors='ignore') + if self.isSnapshot: + self.update_cca_df_newIDs(posData, relIDs) + else: + self.updateCcaDfDeletedIDsTimelapse( + posData, relIDs, deletedIDs, undoId, dropInPast, dropInFuture + ) + + @disableWindow + def updateCcaDfDeletedIDsTimelapse( + self, posData, relIDsOfDelIDs, deletedIDs, undoId, + dropInPast, dropInFuture + ): + # Get status of the relIDs (of deleted IDs) to restore + relIDsCcaStatus = {} + for relID in relIDsOfDelIDs: + try: + ccs = posData.cca_df.at[relID, 'cell_cycle_stage'] + relationship = posData.cca_df.at[relID, 'relationship'] + except Exception as err: + continue + + ccaStatus = core.getBaseCca_df([relID]).loc[relID] + if relationship == 'mother' and ccs == 'S': + for past_frame_i in range(posData.frame_i-1, -1, -1): + cca_df_i = self.get_cca_df( + frame_i=past_frame_i, return_df=True + ) + ccs_past = cca_df_i.at[relID, 'cell_cycle_stage'] + if ccs_past == 'G1': + ccaStatus = cca_df_i.loc[relID] + break + + posData.cca_df.loc[relID] = ccaStatus + self.store_data(autosave=False) + relIDsCcaStatus[relID] = ccaStatus + + for fut_frame_i in range(posData.frame_i+1, posData.SizeT): + cca_df_i = self.get_cca_df(frame_i=fut_frame_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + self.storeUndoRedoCca(fut_frame_i, cca_df_i, undoId) + + if dropInFuture: + cca_df_i = cca_df_i.drop(deletedIDs, errors='ignore') + else: + for delID in deletedIDs: + dataDict = posData.allData_li[fut_frame_i] + delIDexists = dataDict['IDs_idxs'].get(delID, False) + if not delIDexists: + continue + + cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID] + + areRelIDsPresent = False + for relID in relIDsOfDelIDs: + try: + ccs = cca_df_i.at[relID, 'cell_cycle_stage'] + relationship = cca_df_i.at[relID, 'relationship'] + ccaStatus = relIDsCcaStatus[relID] + cca_df_i.loc[relID] = ccaStatus + areRelIDsPresent = True + except Exception as err: + continue + + if not areRelIDsPresent: + break + + self.store_cca_df( + frame_i=fut_frame_i, cca_df=cca_df_i, autosave=False + ) + + # Correct past frames + for past_frame_i in range(posData.frame_i-1, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_frame_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + self.storeUndoRedoCca(past_frame_i, cca_df_i, undoId) + if dropInPast: + cca_df_i = cca_df_i.drop(deletedIDs, errors='ignore') + else: + for delID in deletedIDs: + dataDict = posData.allData_li[past_frame_i] + delIDexists = dataDict['IDs_idxs'].get(delID, False) + if not delIDexists: + continue + + cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID] + + areRelIDsPresent = False + for relID in relIDsOfDelIDs: + try: + ccs = cca_df_i.at[relID, 'cell_cycle_stage'] + relationship = cca_df_i.at[relID, 'relationship'] + ccaStatus = relIDsCcaStatus[relID] + cca_df_i.loc[relID] = ccaStatus + areRelIDsPresent = True + except Exception as err: + continue + + if not areRelIDsPresent: + break + + self.store_cca_df( + frame_i=past_frame_i, cca_df=cca_df_i, autosave=False + ) + + def update_cca_df_newIDs(self, posData, new_IDs): + for newID in new_IDs: + self.addIDBaseCca_df(posData, newID) + + def update_cca_df_snapshots(self, editTxt, posData): + cca_df = posData.cca_df + cca_df_IDs = cca_df.index + if editTxt == 'Delete ID': + deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, deleted_IDs) + + elif editTxt == 'Separate IDs': + new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] + self.update_cca_df_newIDs(posData, new_IDs) + deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, deleted_IDs) + + elif editTxt == 'Edit ID': + new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] + self.update_cca_df_newIDs(posData, new_IDs) + old_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, old_IDs) + + elif editTxt == 'Annotate ID as dead': + return + + elif editTxt == 'Deleted non-selected objects': + deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, deleted_IDs) + + elif editTxt == 'Delete ID with eraser': + deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, deleted_IDs) + + elif editTxt == 'Add new ID with brush tool': + new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] + self.update_cca_df_newIDs(posData, new_IDs) + + elif editTxt == 'Merge IDs': + deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, deleted_IDs) + + elif editTxt == 'Add new ID with curvature tool': + new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] + self.update_cca_df_newIDs(posData, new_IDs) + + elif editTxt == 'Delete IDs using ROI': + deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, deleted_IDs) + + elif editTxt == 'Repeat segmentation': + posData.cca_df = self.getBaseCca_df() + + def fixCcaDfAfterEdit(self, editTxt): + posData = self.data[self.pos_i] + if posData.cca_df is not None: + # For snapshot mode we fix or reinit cca_df depending on the edit + self.update_cca_df_snapshots(editTxt, posData) + self.store_data() + + def isFrameCcaAnnotated(self): + posData = self.data[self.pos_i] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + if acdc_df is None: + return False + + return 'cell_cycle_stage' in acdc_df.columns + + def warnEditingWithCca_df( + self, editTxt, return_answer=False, get_answer=False, + get_cancelled=False, update_images=True + ): + # Function used to warn that the user is editing in "Segmentation and + # Tracking" mode a frame that contains cca annotations. + # Ask whether to remove annotations from all future frames + if self.isSnapshot: + return True + + posData = self.data[self.pos_i] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + + if acdc_df is None and self.lineage_tree is None: + if update_images: + self.updateAllImages() + return True + + cell_cycle_stage_present = ( + acdc_df is not None and 'cell_cycle_stage' in acdc_df.columns + ) + lineage_tree_present = ( + self.lineage_tree is not None or 'parent_ID_tree' in acdc_df.columns + ) + if not cell_cycle_stage_present and not lineage_tree_present: + if update_images: + self.updateAllImages() + return True + + action = self.warnEditingWithAnnotActions.get(editTxt, None) + if action is not None and not action.isChecked(): + # user has checked that he does not want to be asked again AND he doesnt want to delete + if update_images: + self.updateAllImages() + return True + + msg = widgets.myMessageBox() + warn_type = 'cell cycle annotations' if cell_cycle_stage_present else 'lineage tree annotations' + txt = html_utils.paragraph( + f'You modified a frame that has {warn_type}.

' + f'The change "{editTxt}" most likely makes the ' + 'annotations wrong.

' + 'If you really want to apply this change we reccommend to remove' + f'ALL {warn_type}
' + 'from current frame to the end.

' + 'What do you want to do?' + ) + if action is not None: + checkBox = QCheckBox('Remember my choice and do not ask again') + else: + checkBox = None + + dropDelIDsNoteText = ( + '' if editTxt.find('Delete') == -1 else ' (drop removed IDs)' + ) + _, removeAnnotButton, _ = msg.warning( + self, 'Edited segmentation with annotations!', txt, + buttonsTexts=( + 'Cancel', + 'Remove annotations from future frames (RECOMMENDED)', + f'Do not remove annotations{dropDelIDsNoteText}' + ), widgets=checkBox + ) + if msg.cancel: + if get_cancelled: + return 'cancelled' + removeAnnotations = False + return removeAnnotations + + if action is not None: + action.setChecked(not checkBox.isChecked()) + action.removeAnnot = msg.clickedButton == removeAnnotButton + + if return_answer: + return msg.clickedButton == removeAnnotButton + + if (msg.clickedButton == removeAnnotButton) and cell_cycle_stage_present: + self.resetFutureCcaColCurrentFrame() + self.resetCcaFuture(posData.frame_i+1) + self.updateAllImages() + elif (msg.clickedButton == removeAnnotButton) and lineage_tree_present: + self.resetLin_tree_future() + self.updateAllImages() + else: + if dropDelIDsNoteText and posData.cca_df is not None: + delIDs = [ + ID for ID in posData.cca_df.index if ID not in posData.IDs + ] + self.update_cca_df_deletedIDs( + posData, delIDs, dropInPast=False + ) + self.addMissingIDs_cca_df(posData) + self.updateAllImages() + self.store_data() + # if action is not None: + # if action.removeAnnot: + # self.store_data() + # posData.frame_i -= 1 + # self.get_data() + # if lineage_tree_present: + # self.resetLin_tree_future() + # self.resetCcaFuture(posData.frame_i) + # self.next_frame() + + if get_answer: + return msg.clickedButton == removeAnnotButton + else: + return True + + def warnRepeatTrackingVideoOnVisitedFrames(self, last_tracked_i, start_n): + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'You are repeating tracking on frames that have already ' + 'been visited/tracked before.

' + 'This will very likely make the annotations wrong.

' + 'If you really want to repeat tracking on the frames before ' + f'{last_tracked_i+1} the annotations from frame ' + f'{start_n} to frame {last_tracked_i+1} ' + 'will be removed.

' + 'Do you want to continue?' + ) + noButton, yesButton = msg.warning( + self, 'Repating tracking with annotations!', txt, + buttonsTexts=( + ' No, stop tracking and keep annotations.', + ' Yes, repeat tracking and DELETE annotations.' + ) + ) + if msg.cancel: + return False + + if msg.clickedButton == noButton: + return False + else: + return True + + def warnRepeatTrackingVideoWithAnnotations(self, last_tracked_i, start_n): + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'You are repeating tracking on frames that have cell cycle ' + 'annotations.

' + 'This will very likely make the annotations wrong.

' + 'If you really want to repeat tracking on the frames before ' + f'{last_tracked_i+1} the annotations from frame ' + f'{start_n} to frame {last_tracked_i+1} ' + 'will be removed.

' + 'Do you want to continue?' + ) + noButton, yesButton = msg.warning( + self, 'Repating tracking with annotations!', txt, + buttonsTexts=( + ' No, stop tracking and keep annotations.', + ' Yes, repeat tracking and DELETE annotations.' + ) + ) + if msg.cancel: + return False + + if msg.clickedButton == noButton: + return False + else: + return True + + def setDelRoiState(self, roi: pg.ROI, state): + roi.sigRegionChanged.disconnect() + roi.sigRegionChangeFinished.disconnect() + roi.setState(state) + roi.sigRegionChanged.connect(self.delROImoving) + roi.sigRegionChangeFinished.connect(self.delROImovingFinished) + + def addExistingDelROIs(self): + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + isAx2hidden = not self.labelsGrad.showLabelsImgAction.isChecked() + + for r, roi in enumerate(delROIs_info['rois']): + if isinstance(roi, pg.PolyLineROI) or isAx2hidden: + # PolyLine ROIs are only on ax1 + self.ax1.addDelRoiItem(roi, roi.key) + else: + # Rect ROI is on ax2 because ax2 is visible + self.ax2.addDelRoiItem(roi, roi.key) + + self.setDelRoiState(roi, delROIs_info['state'][r]) + + def updateFramePosLabel(self): + if self.isSnapshot: + posData = self.data[self.pos_i] + self.navSpinBox.setValueNoEmit(self.pos_i+1) + else: + posData = self.data[0] + self.navSpinBox.setValueNoEmit(posData.frame_i+1) + + def highlightHoverID(self, x, y, hoverID=None): + if hoverID is None: + try: + hoverID = self.currentLab2D[int(y), int(x)] + except IndexError: + return + + if hoverID == 0: + return + + posData = self.data[self.pos_i] + objIdx = posData.IDs_idxs[hoverID] + obj = posData.rp[objIdx] + self.goToZsliceSearchedID(obj) + self.highlightSearchedID(hoverID) + + def grayOutHighlightedLabels(self, nonGrayedIDs=None, alpha=None): + if nonGrayedIDs is None: + nonGrayedIDs = set() + + posData = self.data[self.pos_i] + if alpha is None: + alpha = self.imgGrad.labelsAlphaSlider.value() + + if not hasattr(self, 'highlightedLab'): + self.highlightedLab = np.zeros_like(self.currentLab2D) + else: + self.highlightedLab[:] = 0 + + lut = np.zeros((2, 4), dtype=np.uint8) + for _obj in posData.rp: + if not self.isObjVisible(_obj.bbox): + continue + if _obj.label not in nonGrayedIDs: + continue + _slice = self.getObjSlice(_obj.slice) + _objMask = self.getObjImage(_obj.image, _obj.bbox) + self.highlightedLab[_slice][_objMask] = _obj.label + rgb = self.lut[_obj.label].copy() + lut[1, :-1] = rgb + # Set alpha to 0.7 + lut[1, -1] = 178 + + return lut + + def grayOutOverlaySegm(self, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + isOverlaySegmActive = how.find('segm. masks') != -1 + if not isOverlaySegmActive: + return + + grayedLut = self.grayOutHighlightedLabels() + + def highlightHoverIDsKeptObj(self, x, y, hoverID=None): + if hoverID is None: + try: + hoverID = self.currentLab2D[int(y), int(x)] + except IndexError: + return + + self.highlightSearchedID(hoverID, greyOthers=False) + + if hoverID == 0 and self.highlightedID == 0: + return + + if hoverID == 0 and self.highlightedID != 0: + self.clearHighlightedKeepIDs() + for ID in self.keptObjectsIDs: + self.highlightLabelID(ID) + return + + posData = self.data[self.pos_i] + try: + objIdx = posData.IDs_idxs[hoverID] + except KeyError as err: + return + + obj = posData.rp[objIdx] + self.goToZsliceSearchedID(obj) + + for ID in self.keptObjectsIDs: + self.highlightLabelID(ID) + + def getHighlightedID(self): + if self.highlightedID > 0: + return self.highlightedID + + doHighlight = ( + self.propsDockWidget.isVisible() + and ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() + ) + ) + if not doHighlight: + return 0 + + return self.guiTabControl.propsQGBox.idSB.value() + + def clearHighlightedKeepIDs(self): + self.setAllTextAnnotations() + self.highlightedID = 0 + self.searchedIDitemRight.setData([], []) + self.searchedIDitemLeft.setData([], []) + self.highLightIDLayerImg1.clear() + self.highLightIDLayerRightImage.clear() + + def setHighlighedIDfromToolbar(self, ID: int): + self.findID(ID=ID) + + def highlightSearchedID(self, ID, force=False, greyOthers=True): + self.highlightIDToolbar.setIDNoSignals(ID) + + if ID == 0: + self.highlightIDToolbar.setVisible(False) + return + + if ID == self.highlightedID and not force: + return + + doHighlight = ( + self.propsDockWidget.isVisible() + and ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() + ) + ) + if doHighlight: + self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() + ID = self.highlightedID + + if self.highlightedID > 0: + self.clearHighlightedText() + + self.searchedIDitemRight.setData([], []) + self.searchedIDitemLeft.setData([], []) + + posData = self.data[self.pos_i] + + self.highlightedID = ID + self.highlightIDToolbar.setVisible(True) + + objIdx = posData.IDs_idxs.get(ID) + if objIdx is None: + return + + obj = posData.rp[objIdx] + isObjVisible = self.isObjVisible(obj.bbox) + if not isObjVisible: + return + + if greyOthers: + self.textAnnot[0].grayOutAnnotations() + self.textAnnot[1].grayOutAnnotations() + + how_ax1 = self.drawIDsContComboBox.currentText() + how_ax2 = self.getAnnotateHowRightImage() + isOverlaySegm_ax1 = how_ax1.find('segm. masks') != -1 + isOverlaySegm_ax2 = how_ax2.find('segm. masks') != -1 + alpha = self.imgGrad.labelsAlphaSlider.value() + + if isOverlaySegm_ax1 or isOverlaySegm_ax2: + grayedLut = self.grayOutHighlightedLabels( + nonGrayedIDs={obj.label}, + alpha=alpha + ) + + cont = None + contours = None + if isOverlaySegm_ax1: + self.highLightIDLayerImg1.setLookupTable(grayedLut) + self.highLightIDLayerImg1.setImage(self.highlightedLab) + self.labelsLayerImg1.setOpacity(alpha/3) + else: + contours = self.getObjContours(obj, all_external=True) + for cont in contours: + self.searchedIDitemLeft.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) + + if isOverlaySegm_ax2: + self.highLightIDLayerRightImage.setLookupTable(grayedLut) + self.highLightIDLayerRightImage.setImage(self.highlightedLab) + self.labelsLayerRightImg.setOpacity(alpha/3) + else: + if contours is None: + contours = self.getObjContours(obj, all_external=True) + for cont in contours: + self.searchedIDitemRight.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) + + # Gray out all IDs excpet searched one + lut = self.lut.copy() # [:max(posData.IDs)+1] + lut[:ID] = lut[:ID]*0.2 + lut[ID+1:] = lut[ID+1:]*0.2 + self.img2.setLookupTable(lut) + + # Highlight text + self.highlightLabelID(ID, ax=0) + self.highlightLabelID(ID, ax=1) + + def _drawGhostContour(self, x, y): + if self.ghostObject is None: + return + + ID = self.ghostObject.label + yc, xc = self.ghostObject.local_centroid + Dx = x-xc + Dy = y-yc + xx = self.ghostObject.xx_contour + Dx + yy = self.ghostObject.yy_contour + Dy + self.ghostContourItemLeft.setData( + xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) + self.ghostContourItemRight.setData( + xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) + + def _drawManualBackgroundObjContour(self, x, y): + if self.manualBackgroundObj is None: + return + + ID = self.manualBackgroundObj.label + yc, xc = self.manualBackgroundObj.local_centroid + Dx = x-xc + Dy = y-yc + xx = self.manualBackgroundObj.xx_contour + Dx + yy = self.manualBackgroundObj.yy_contour + Dy + self.manualBackgroundObjItem.setData( + xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) + + def _drawGhostMask(self, x, y): + if self.ghostObject is None: + return + + self.clearGhostMask() + ID = self.ghostObject.label + h, w = self.ghostObject.image.shape[-2:] + yc, xc = self.ghostObject.local_centroid + Dx = int(x-xc) + Dy = int(y-yc) + bbox = ((Dy, Dy+h), (Dx, Dx+w)) + + Y, X = self.currentLab2D.shape + slices = myutils.get_slices_local_into_global_arr(bbox, (Y, X)) + slice_global_to_local, slice_crop_local = slices + + obj_image = self.ghostObject.image[slice_crop_local] + + self.ghostMaskItemLeft.image[slice_global_to_local][obj_image] = ID + self.ghostMaskItemLeft.updateGhostImage( + fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) + + self.ghostMaskItemRight.image[slice_global_to_local][obj_image] = ID + self.ghostMaskItemRight.updateGhostImage( + fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) + + def drawManualBackgroundObj(self, x, y): + if x is None or y is None: + self.clearGhost() + return + + self._drawManualBackgroundObjContour(x, y) + + def drawManualTrackingGhost(self, x, y): + if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): + return + + if x is None or y is None: + self.clearGhost() + return + + if self.manualTrackingToolbar.ghostContourRadiobutton.isChecked(): + self._drawGhostContour(x, y) + else: + self._drawGhostMask(x, y) + + def restoreDefaultSettings(self): + df = self.df_settings + df.at['contLineWeight', 'value'] = 1 + df.at['mothBudLineSize', 'value'] = 1 + df.at['mothBudLineColor', 'value'] = (255, 165, 0, 255) + df.at['contLineColor', 'value'] = (205, 0, 0, 220) + + self._updateContColour((205, 0, 0, 220)) + self._updateMothBudLineColour((255, 165, 0, 255)) + self._updateMothBudLineSize(1) + self._updateContLineThickness() + + df.at['overlaySegmMasksAlpha', 'value'] = 0.3 + df.at['img_cmap', 'value'] = 'grey' + self.imgCmap = self.imgGrad.cmaps['grey'] + self.imgCmapName = 'grey' + self.labelsGrad.item.loadPreset('viridis') + df.at['labels_bkgrColor', 'value'] = (25, 25, 25) + + if df.at['is_bw_inverted', 'value'] == 'Yes': + self.invertBw(update=False) + + df = df[~df.index.str.contains('lab_cmap')] + df.to_csv(self.settings_csv_path) + self.imgGrad.restoreState(df) + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.restoreState(df) + + self.labelsGrad.saveState(df) + self.labelsGrad.restoreState(df, loadCmap=False) + + self.df_settings.to_csv(self.settings_csv_path) + self.updateAllImages() + + def updateLabelsAlpha(self, value): + self.df_settings.at['overlaySegmMasksAlpha', 'value'] = value + self.df_settings.to_csv(self.settings_csv_path) + if self.keepIDsButton.isChecked(): + value = value/3 + self.labelsLayerImg1.setOpacity(value) + self.labelsLayerRightImg.setOpacity(value) + + + def _getImageupdateAllImages(self, image=None): + if image is not None: + return image + + img = self.getImage() + return img + + def setImageImg1(self, image=None): + img = self._getImageupdateAllImages(image=image) + posData = self.data[self.pos_i] + self.img1.setCurrentPosIndex(self.pos_i) + self.img1.setCurrentFrameIndex(posData.frame_i) + if posData.SizeZ > 1: + zProjHow = self.zProjComboBox.currentText() + if zProjHow == 'single z-slice': + z = self.zSliceScrollBar.sliderPosition() + else: + z = zProjHow + + self.img1.setCurrentZsliceIndex(z) + + self.img1.setImage( + img, next_frame_image=self.nextFrameImage(), + scrollbar_value=posData.frame_i+2 + ) + + def getContoursImageItem(self, ax, force=False): + if not self.areContoursRequested(ax) and not force: + return + + if ax == 0: + return self.ax1_contoursImageItem + else: + return self.ax2_contoursImageItem + + def getLostObjImageItem(self, ax): + if ax == 0: + return self.ax1_lostObjImageItem + else: + return self.ax1_lostTrackedObjImageItem + + def getLostTrackedObjImageItem(self, ax): + if ax == 0: + return self.ax1_lostTrackedObjImageItem + else: + return self.ax2_lostTrackedObjImageItem + + def setManualBackgroundImage(self): + if not self.manualBackgroundButton.isChecked(): + return + + posData = self.data[self.pos_i] + if not hasattr(posData, 'manualBackgroundImage'): + self.initManualBackgroundImage() + + contours = [] + for obj in skimage.measure.regionprops(posData.manualBackgroundLab): + obj_contours = self.getObjContours(obj, all_external=True) + contours.extend(obj_contours) + textItem = self.manualBackgroundTextItems[obj.label] + textItem.setText(f'{obj.label}') + self.ax1.addItem(textItem) + yc, xc = obj.centroid + textItem.setPos(xc, yc) + + cv2.drawContours( + posData.manualBackgroundImage, contours, -1, (255, 0, 0, 200), 1 + ) + self.manualBackgroundImageItem.setImage(posData.manualBackgroundImage) + + def setManualBackgrounNextID(self): + posData = self.data[self.pos_i] + currentID = self.manualBackgroundObj.label + idx = posData.IDs_idxs[currentID] + next_idx = idx + 1 + if next_idx >= len(posData.IDs): + return + next_ID = posData.IDs[next_idx] + self.manualBackgroundToolbar.spinboxID.setValue(next_ID) + + def clearManualBackgroundObject(self, ID): + posData = self.data[self.pos_i] + mask = posData.manualBackgroundLab==ID + posData.manualBackgroundImage[mask, :] = 0 + posData.manualBackgroundLab[mask] = 0 + + def addManualBackgroundObject(self, x, y): + posData = self.data[self.pos_i] + + if not hasattr(self, 'manualBackgroundObj'): + self.initManualBackgroundObject() + + Y, X = self.currentLab2D.shape + ymin, xmin, ymax, xmax = self.manualBackgroundObj.bbox + width, height = xmax-xmin, ymax-ymin + yc, xc = self.manualBackgroundObj.local_centroid + xstart, ystart = round(x-xc), round(y-yc) + xstart = xstart if xstart >= 0 else 0 + ystart = ystart if ystart >= 0 else 0 + + xend = xstart+width + yend = ystart+height + xend = xend if xend <= X else X + yend = yend if yend <= Y else Y + + width = xend-xstart + height = yend-ystart + + obj_image = self.manualBackgroundObj.image[:height, :width] + obj_slice = (slice(ystart, yend), slice(xstart, xend)) + ID = self.manualBackgroundObj.label + self.clearManualBackgroundObject(ID) + posData.manualBackgroundLab[obj_slice][obj_image] = ID + + if ID in self.manualBackgroundTextItems: + self.manualBackgroundTextItems[ID].setPos(x, y) + return + + textItem = pg.TextItem( + text=str(ID), color='r', anchor=(0.5, 0.5) + ) + textItem.setFont(font_13px) + textItem.setPos(x, y) + self.manualBackgroundTextItems[ID] = textItem + + self.ax1.addItem(textItem) + + def setManualBackgroundLab(self, load_from_store=False, debug=True): + posData = self.data[self.pos_i] + if posData.manualBackgroundLab is None: + self.initManualBackgroundImage() + + for obj in skimage.measure.regionprops(posData.manualBackgroundLab): + textItem = pg.TextItem(text='', color='r', anchor=(0.5, 0.5)) + if obj.label in self.manualBackgroundTextItems: + continue + self.manualBackgroundTextItems[obj.label] = textItem + + def updateContoursImage(self, ax, delROIsIDs=None, compute=True): + imageItem = self.getContoursImageItem(ax) + if imageItem is None: + return + + if not hasattr(self, 'contoursImage'): + self.initContoursImage() + else: + self.contoursImage[:] = 0 + + contours = [] + for obj in skimage.measure.regionprops(self.currentLab2D): + obj_contours = self.getObjContours( + obj, + all_external=True, + force_calc=compute, + include_internal=self.showAllContoursToggle.isChecked() + ) + contours.extend(obj_contours) + + thickness = self.contLineWeight + color = self.contLineColor + self.setContoursImage(imageItem, contours, thickness, color) + + def setContoursImage(self, imageItem, contours, thickness, color): + cv2.drawContours(self.contoursImage, contours, -1, color, thickness) + imageItem.setImage(self.contoursImage) + + def getObjFromID(self, ID): + posData = self.data[self.pos_i] + try: + idx = posData.IDs_idxs[ID] + except KeyError as e: + # Object already cleared + return + + obj = posData.rp[idx] + return obj + + def setLostObjectContour(self, obj): + allContours = self.getObjContours(obj, all_external=True) + for objContours in allContours: + xx = objContours[:,0] + 0.5 + yy = objContours[:,1] + 0.5 + data = [obj.label]*len(xx) + self.ax1_lostObjScatterItem.addPoints(xx, yy, data=data) + self.ax2_lostObjScatterItem.addPoints(xx, yy) + + def setTrackedLostObjectContour(self, obj): + if self.isExportingVideo: + return + + allContours = self.getObjContours(obj, all_external=True) + for objContours in allContours: + xx = objContours[:,0] + 0.5 + yy = objContours[:,1] + 0.5 + data = [obj.label]*len(xx) + self.ax1_lostTrackedScatterItem.addPoints(xx, yy, data=data) + self.ax2_lostTrackedScatterItem.addPoints(xx, yy) + + def updateLostContoursImage(self, ax, draw=True, delROIsIDs=None): + if draw: + imageItem = self.getLostObjImageItem(ax) + if imageItem is None: + return + + if not hasattr(self, 'lostObjContoursImage'): + self.initLostObjContoursImage() + else: + self.lostObjContoursImage[:] = 0 + + if delROIsIDs is None: + delROIsIDs = set() + + posData = self.data[self.pos_i] + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] + if posData.whitelist is not None and posData.whitelist.whitelistIDs is not None: + whitelist = posData.whitelist.whitelistIDs[posData.frame_i-1] + else: + whitelist = None + + contours = [] + for lostID in posData.lost_IDs: + if lostID in delROIsIDs or (whitelist is not None and lostID not in whitelist): + continue + + obj = prev_rp[prev_IDs_idxs[lostID]] + if not self.isObjVisible(obj.bbox): + continue + + obj_contours = self.getObjContours(obj, all_external=True) + + if ax == 0: + self.addLostObjsToLostObjImage(obj, lostID) + + contours.extend(obj_contours) + + if not draw: + return + + self.drawLostObjContoursImage(imageItem, contours) + + def drawLostObjContoursImage( + self, imageItem, contours, + thickness=1, + color=(255, 165, 0, 255) # orange + ): + img = self.lostObjContoursImage + cv2.drawContours(img, contours, -1, color, thickness) + imageItem.setImage(img) + + def updateLostTrackedContoursImage( + self, ax, delROIsIDs=None, tracked_lost_IDs=None + ): + imageItem = self.getLostTrackedObjImageItem(ax) + if imageItem is None: + return + + if not hasattr(self, 'lostTrackedObjContoursImage'): + self.initLostTrackedObjContoursImage() + else: + self.lostTrackedObjContoursImage[:] = 0 + + if delROIsIDs is None: + delROIsIDs = set() + + posData = self.data[self.pos_i] + if tracked_lost_IDs is None: + tracked_lost_IDs = self.getTrackedLostIDs() + + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] + contours = [] + for tracked_lost_ID in tracked_lost_IDs: + if tracked_lost_ID in delROIsIDs: + continue + + obj = prev_rp[prev_IDs_idxs[tracked_lost_ID]] + if not self.isObjVisible(obj.bbox): + continue + + obj_contours = self.getObjContours(obj, all_external=True) + contours.extend(obj_contours) + + self.drawLostTrackedObjContoursImage(imageItem, contours) + + def drawLostTrackedObjContoursImage(self, imageItem, contours): + thickness = 1 + color = (0, 255, 0, 255) # green + img = self.lostTrackedObjContoursImage + cv2.drawContours(img, contours, -1, color, thickness) + imageItem.setImage(img) + + def getNearestLostObjID(self, y, x): + if not self.annotLostObjsToggle.isChecked(): + return + + posData = self.data[self.pos_i] + if not posData.lost_IDs: + return + + prev_lab = posData.allData_li[posData.frame_i-1]['labels'] + if prev_lab is None: + return + + # if not hasattr(self, 'lostObjContoursImage'): + # self.store_data() + # posData.frame_i -= 1 + # self.get_data() + # self.store_data() + # posData.frame_i += 1 + # self.get_data() + # self.updateLostNewCurrentIDs() + # self.updateLostContoursImage(ax=0) + # self.updateLostContoursImage(ax=1) + # self.updateLostNewCurrentIDs() + + yy, xx, _ = np.nonzero(self.lostObjContoursImage) + lostObjsContourMask = np.zeros(self.currentLab2D.shape, dtype=bool) + lostObjsContourMask[yy.astype(int), xx.astype(int)] = True + + # Add accepted lost IDs + try: + yy, xx, _ = np.nonzero(self.lostTrackedObjContoursImage) + lostObjsContourMask[yy.astype(int), xx.astype(int)] = True + except Exception as err: + pass + + _, y_nearest, x_nearest = core.nearest_nonzero_2D( + lostObjsContourMask, y, x, return_coords=True + ) + nearest_ID = self.get_2Dlab(prev_lab)[y_nearest, x_nearest] + + if nearest_ID == 0: + return + + return nearest_ID + + def setCcaIssueContour(self, obj): + objContours = self.getObjContours(obj, all_external=True) + for cont in objContours: + xx = cont[:,0] + 0.5 + yy = cont[:,1] + 0.5 + self.ax1_lostObjScatterItem.addPoints(xx, yy) + self.textAnnot[0].addObjAnnotation( + obj, 'lost_object', f'{obj.label}?', False + ) + + def isLastVisitedAgainCca(self, curr_df, enforceAll=False): + # Determine if this is the last visited frame for repeating + # bud assignment on non manually corrected_on_frame_i buds. + # The idea is that the user could have assigned division on a cell + # by going previous and we want to check if this cell could be a + # "better" mother for those non manually corrected buds + posData = self.data[self.pos_i] + if curr_df is None: + return False + + if 'cell_cycle_stage' not in curr_df.columns: + return False + + if enforceAll: + return False + + lastVisited = False + posData.new_IDs = [ + ID for ID in posData.new_IDs + if curr_df.at[ID, 'is_history_known'] + and curr_df.at[ID, 'cell_cycle_stage'] == 'S' + ] + if posData.frame_i+1 < posData.SizeT: + next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] + if next_df is None: + lastVisited = True + else: + if 'cell_cycle_stage' not in next_df.columns: + lastVisited = True + else: + lastVisited = True + + return lastVisited + + def highlightNewCellNotEnoughG1cells(self, IDsCellsG1): + posData = self.data[self.pos_i] + for obj in posData.rp: + if obj.label not in IDsCellsG1: + continue + objContours = self.getObjContours(obj) + if objContours is not None: + xx = objContours[:,0] + 0.5 + yy = objContours[:,1] + 0.5 + self.ccaFailedScatterItem.addPoints(xx, yy) + self.textAnnot[0].addObjAnnotation( + obj, 'green', f'{obj.label}?', False + ) + + def handleNoCellsInG1(self, numCellsG1, numNewCells): + posData = self.data[self.pos_i] + self.highlightNewCellNotEnoughG1cells(posData.new_IDs) + continueAnyway = _warnings.warnNotEnoughG1Cells( + numCellsG1, posData.frame_i, numNewCells, qparent=self + ) + if continueAnyway: + notEnoughG1Cells = False + proceed = True + # Annotate the new IDs with unknown history + for ID in posData.new_IDs: + posData.cca_df.loc[ID] = pd.Series(base_cca_dict) + cca_df_ID = self.getStatusKnownHistoryBud(ID) + posData.ccaStatus_whenEmerged[ID] = cca_df_ID + else: + notEnoughG1Cells = True + proceed = False + + # Clear new cells annotations + self.ccaFailedScatterItem.setData([], []) + return notEnoughG1Cells, proceed + + def addObjContourToContoursImage( + self, ID=0, obj=None, ax=0, thickness=None, color=None, + force=False + ): + imageItem = self.getContoursImageItem(ax, force=force) + if imageItem is None: + return + + if obj is None: + obj = self.getObjFromID(ID) + if obj is None: + return + + contours = self.getObjContours(obj, all_external=True) + if thickness is None: + thickness = self.contLineWeight + if color is None: + color = self.contLineColor + + self.setContoursImage(imageItem, contours, thickness, color) + + def clearObjContour( + self, ID=0, obj=None, ax=0, debug=False, updateImage=True + ): + imageItem = self.getContoursImageItem(ax) + if imageItem is None: + return + + if ID > 0: + self.contoursImage[self.currentLab2D==ID] = [0,0,0,0] + else: + obj_slice = self.getObjSlice(obj.slice) + obj_image = self.getObjImage(obj.image, obj.bbox) + self.contoursImage[obj_slice][obj_image] = [0,0,0,0] + + if not updateImage: + return + + imageItem.setImage(self.contoursImage) + + def clearAnnotItems(self): + self.textAnnot[0].clear() + self.textAnnot[1].clear() + + # @exec_time + def setAllTextAnnotations(self, labelsToSkip=None): + delROIsIDs = self.setLostNewOldPrevIDs() + posData = self.data[self.pos_i] + self.textAnnot[0].setAnnotations( + posData=posData, + labelsToSkip=labelsToSkip, + isVisibleCheckFunc=self.isObjVisible, + highlightedID=self.highlightedID, + delROIsIDs=delROIsIDs, + annotateLost=self.annotLostObjsToggle.isChecked(), + getCurrentZfunc=self.z_lab, + getObjCentroidFunc=self.getObjCentroid + ) + self.textAnnot[1].setAnnotations( + posData=posData, labelsToSkip=labelsToSkip, + isVisibleCheckFunc=self.isObjVisible, + highlightedID=self.highlightedID, + delROIsIDs=delROIsIDs, + annotateLost=self.annotLostObjsToggle.isChecked(), + getCurrentZfunc=self.z_lab, + getObjCentroidFunc=self.getObjCentroid + ) + self.textAnnot[0].update() + self.textAnnot[1].update() + return delROIsIDs + + def setAllContoursImages(self, delROIsIDs=None, compute=True): + if compute: + self.computeAllContours() + self.updateContoursImage(ax=0, delROIsIDs=delROIsIDs, compute=compute) + self.updateContoursImage(ax=1, delROIsIDs=delROIsIDs, compute=compute) + + def setAllLostObjContoursImage(self, delROIsIDs=None): + self.updateLostContoursImage(ax=0, delROIsIDs=None) + self.updateLostContoursImage(ax=1, delROIsIDs=None) + + def setAllLostTrackedObjContoursImage(self, delROIsIDs=None): + self.updateLostTrackedContoursImage(ax=0, delROIsIDs=None) + self.updateLostTrackedContoursImage(ax=1, delROIsIDs=None) + + def nextFrameImage(self, current_frame_i=None): + if not self.labelsGrad.showNextFrameAction.isEnabled(): + return + + if not self.labelsGrad.showNextFrameAction.isChecked(): + return + + posData = self.data[self.pos_i] + if current_frame_i is None: + current_frame_i = posData.frame_i + + next_frame_i = current_frame_i + 1 + if next_frame_i >= len(posData.img_data): + img = posData.img_data[-1] + else: + img = posData.img_data[next_frame_i] + + if posData.SizeZ > 1: + img = self.get_2Dimg_from_3D(img, isLayer0=True) + + # img = self.normalizeIntensities(img) + + return img + + def onKeyHome(self): + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepAdd + ) + + def onKeyEnd(self): + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepSub + ) + + def onKeyPageUp(self): + isAutoPilotActive = ( + self.autoPilotZoomToObjToggle.isChecked() + and self.autoPilotZoomToObjToolbar.isVisible() + ) + if isAutoPilotActive: + self.pointsLayerAutoPilot('next') + elif self.zSliceScrollBar.isVisible(): + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepAdd + ) + + def onKeyPageDown(self): + isAutoPilotActive = ( + self.autoPilotZoomToObjToggle.isChecked() + and self.autoPilotZoomToObjToolbar.isVisible() + ) + if isAutoPilotActive: + self.pointsLayerAutoPilot('prev') + elif self.zSliceScrollBar.isVisible(): + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepAdd + ) + + def keyUpCallback( + self, isBrushActive, isWandActive, isExpandLabelActive, + isLabelRoiCircActive + ): + isAutoPilotActive = ( + self.autoPilotZoomToObjToggle.isChecked() + and self.autoPilotZoomToObjToolbar.isVisible() + ) + if isBrushActive: + brushSize = self.brushSizeSpinbox.value() + self.brushSizeSpinbox.setValue(brushSize+1) + elif isWandActive: + wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() + self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance+1) + elif isExpandLabelActive: + self.expandLabel(dilation=True) + self.expandFootprintSize += 1 + elif isLabelRoiCircActive: + val = self.labelRoiCircularRadiusSpinbox.value() + self.labelRoiCircularRadiusSpinbox.setValue(val+1) + elif isAutoPilotActive: + self.pointsLayerAutoPilot('next') + else: + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepAdd + ) + + def keyDownCallback( + self, isBrushActive, isWandActive, isExpandLabelActive, + isLabelRoiCircActive + ): + isAutoPilotActive = ( + self.autoPilotZoomToObjToggle.isChecked() + and self.autoPilotZoomToObjToolbar.isVisible() + ) + if isBrushActive: + brushSize = self.brushSizeSpinbox.value() + self.brushSizeSpinbox.setValue(brushSize-1) + elif isWandActive: + wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() + self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance-1) + elif isExpandLabelActive: + self.expandLabel(dilation=False) + self.expandFootprintSize += 1 + elif isLabelRoiCircActive: + val = self.labelRoiCircularRadiusSpinbox.value() + self.labelRoiCircularRadiusSpinbox.setValue(val-1) + elif isAutoPilotActive: + self.pointsLayerAutoPilot('prev') + elif self.isNavigateActionOnNextFrame(): + posData = self.data[self.pos_i] + self.rightImageFramesScrollbar.setValue(posData.frame_i+2) + else: + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepSub + ) + + # @exec_time + @exception_handler + def updateAllImages( + self, image=None, computePointsLayers=True, computeContours=True, + updateLookuptable=True + ): + self.clearAllItems() + + posData = self.data[self.pos_i] + + self.last_pos_i = self.pos_i + self.last_frame_i = posData.frame_i + + self.rescaleIntensitiesLut(setImage=False) + + self.setImageImg1(image=image) + self.setImageImg2(updateLookuptable=updateLookuptable) + + self.setOverlayImages() + + self.setOverlayLabelsItems() + self.setOverlaySegmMasks() + + if self.slideshowWin is not None: + self.slideshowWin.frame_i = posData.frame_i + self.slideshowWin.update_img() + + # self.update_rp() + + # Annotate ID and draw contours + delROIsIDs = self.setAllTextAnnotations() + self.setAllContoursImages( + delROIsIDs=delROIsIDs, compute=False + ) + + mode = self.modeComboBox.currentText() + self.drawAllMothBudLines() + if mode == 'Normal division: Lineage tree': + self.drawAllLineageTreeLines() + + self.highlightLostNew() + + if self.ccaTableWin is not None: # need to add for lin tree, later + zoomIDs = self.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + self.doCustomAnnotation(0) + + self.annotate_rip_and_bin_IDs() + self.updateTempLayerKeepIDs() + self.whitelistUpdateTempLayer() + self.drawPointsLayers(computePointsLayers=computePointsLayers) + self.setManualBackgroundImage() + self.annotateAssignedObjsAcdcTrackerSecondStep() + + self.highlightSearchedID(self.highlightedID, force=True) + self.updateTimestampFrame() + + posData.visited = True + + def updateTimestampFrame(self): + if not hasattr(self, 'timestamp'): + return + + if not self.addTimestampAction.isChecked(): + return + + posData = self.data[self.pos_i] + self.timestamp.setText(posData.frame_i) + + def deleteIDFromLab( + self, lab, delID, frame_i=None, delMask=None, shift=False + ): + posData = self.data[self.pos_i] + frame_i = posData.frame_i if frame_i is None else frame_i + + if shift and self.isSegm3D: + lab3D = lab + delMask3D = delMask + lab = self.get_2Dlab(lab) + if delMask is not None: + delMask = self.get_2Dlab(delMask) + rp = skimage.measure.regionprops(lab) + IDs_idxs = {obj.label: idx for idx, obj in enumerate(rp)} + else: + if frame_i==posData.frame_i: + rp = posData.rp + IDs_idxs = posData.IDs_idxs + else: + rp = posData.allData_li[frame_i]['regionprops'] + IDs_idxs = posData.allData_li[frame_i]['IDs_idxs'] + + if isinstance(delID, int): + delID = [delID] + + is_any_id_present = False + for _delID in delID: + if _delID in IDs_idxs: + is_any_id_present = True + break + + if not is_any_id_present: + return lab, delMask + + if delMask is None: + delMask = np.zeros(lab.shape, dtype=bool) + else: + delMask[:] = False + + for _delID in delID: + idx = IDs_idxs.get(_delID, None) + if idx is None: + continue + obj = rp[idx] + delMask[obj.slice][obj.image] = True + lab[delMask] = 0 + + if shift and self.isSegm3D: + self.set_2Dlab(lab, lab3D=lab3D) + lab = lab3D + if delMask3D is not None: + self.set_2Dlab(delMask, lab3D=delMask3D) + delMask = delMask3D + + return lab, delMask + + def removeStoredContours(self, delID, frame_i=None, z_slice=None): + posData = self.data[self.pos_i] + + if frame_i is None: + frame_i = posData.frame_i + + dataDict = posData.allData_li[posData.frame_i] + try: + newContours = {} + for key, contours in dataDict['contours'].items(): + ID = key[0] + if ID == delID: + continue + + if z_slice is not None: + z_slice_i = key[1] + if z_slice_i != z_slice: + continue + + newContours[key] = contours + + dataDict['contours'] = newContours + except KeyError as err: + pass + + @disableWindow + def deleteIDmiddleClick( + self, delIDs: Iterable, applyFutFrames, includeUnvisited, + shift=False + ): + self.clearHighlightedID() + + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + + # Apply Delete ID to future frames if requested + if applyFutFrames: + delMask = np.zeros(posData.lab.shape, dtype=bool) + # Store current data before going to future frames + self.store_data() + segmSizeT = len(posData.segm_data) + for i in range(posData.frame_i+1, segmSizeT): + lab = posData.allData_li[i]['labels'] + if lab is None and not includeUnvisited: + self.enqAutosave() + break + + if lab is not None: + # Visited frame + lab, _ = self.deleteIDFromLab( + lab, delIDs, frame_i=i, delMask=delMask, shift=shift + ) + + # Store change + posData.allData_li[i]['labels'] = lab + # Get the rest of the stored metadata based on the new lab + posData.frame_i = i + self.get_data() + self.store_data(autosave=False) + elif includeUnvisited: + # Unvisited frame (includeUnvisited = True) + lab = posData.segm_data[i] + lab, _ = self.deleteIDFromLab( + lab, delIDs, frame_i=i, delMask=delMask, shift=shift + ) + + # Back to current frame + if applyFutFrames: + posData.frame_i = current_frame_i + self.get_data() + + z_slice = None + if shift and self.isSegm3D: + z_slice = self.z_lab() + + posData.lab, delID_mask = self.deleteIDFromLab( + posData.lab, delIDs, shift=shift + ) + for _delID in delIDs: + self.clearObjContour(ID=_delID, ax=0) + self.clearObjContour(ID=_delID, ax=1) + if z_slice is None: + self.removeObjectFromRp(_delID) + self.removeStoredContours(_delID, z_slice=z_slice) + + if shift and self.isSegm3D: + self.update_rp() + + self.store_data(autosave=False) + self.whitelistPropagateIDs(IDs_to_remove=delIDs, curr_frame_only=(not applyFutFrames)) + return delID_mask + + def hideOverlayLabelsItems(self, specific=None): + if specific is None: + specific = self.overlayLabelsItems.keys() + for segmEndname in specific: + imageItem, contoursItem, gradItem = self.overlayLabelsItems[segmEndname] + imageItem.setVisible(False) + contoursItem.setVisible(False) + gradItem.setVisible(False) + + def showOverlayLabelsItems(self, specific=None): + if specific is None: + specific = self.overlayLabelsItems.keys() + for segmEndname in specific: + imageItem, contoursItem, gradItem = self.overlayLabelsItems[segmEndname] + drawMode = self.drawModeOverlayLabelsChannels[segmEndname] + if drawMode == 'Draw contours': + contoursItem.setVisible(True) + elif drawMode == 'Overlay labels': + imageItem.setVisible(True) + gradItem.setVisible(True) + + def setOverlayLabelsItems(self, specific=None): + if not self.overlayLabelsButton.isChecked(): + self.hideOverlayLabelsItems(specific=specific) + return + + if specific is None: + specific = self.drawModeOverlayLabelsChannels.keys() + + for segmEndname in specific: + drawMode = self.drawModeOverlayLabelsChannels[segmEndname] + ol_lab = self.getOverlayLabelsData(segmEndname) + items = self.overlayLabelsItems[segmEndname] + imageItem, contoursItem, gradItem = items + contoursItem.clear() + if drawMode == 'Draw contours': + for obj in skimage.measure.regionprops(ol_lab): + contours = self.getObjContours( + obj, all_external=True + ) + for cont in contours: + contoursItem.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) + elif drawMode == 'Overlay labels': + imageItem.setImage(ol_lab, autoLevels=False) + self.showOverlayLabelsItems(specific=specific) + + def getOverlayLabelsData(self, segmEndname): + posData = self.data[self.pos_i] + + if posData.ol_labels_data is None: + self.loadOverlayLabelsData(segmEndname) + elif segmEndname not in posData.ol_labels_data: + self.loadOverlayLabelsData(segmEndname) + + comb_seg = False + if 'combined segm.' == segmEndname: + comb_seg = True + if not self.isSegm3D: + zStackImg = self.data[0].SizeZ > 1 + if zStackImg: + selected_z_stack = self.zSliceScrollBar.sliderPosition() + else: + selected_z_stack = 0 + out = posData.ol_labels_data['combined segm.'][posData.frame_i][selected_z_stack] + return out.astype(np.uint32) + + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if isZslice: + z = self.zSliceScrollBar.sliderPosition() + ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i][z] + if comb_seg: + ol_lab = ol_lab.astype(np.uint32) + return ol_lab + else: + ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i].max(axis=0) + if comb_seg: + ol_lab = ol_lab.astype(np.uint32) + return ol_lab + else: + return posData.ol_labels_data[segmEndname][posData.frame_i] + + def loadOverlayLabelsData(self, segmEndname, pos_i=None): + if pos_i is None: + pos_i = self.pos_i + posData = self.data[pos_i] + + if posData.ol_labels_data is None: + posData.ol_labels_data = {} + if segmEndname == 'combined segm.': + posData.ol_labels_data['combined segm.'] = posData.combine_img_data + return + filePath, filename = load.get_path_from_endname( + segmEndname, posData.images_path + ) + self.logger.info(f'Loading "{segmEndname}.npz"...') + labelsData = np.load(filePath)['arr_0'] + if posData.SizeT == 1: + labelsData = labelsData[np.newaxis] + if self.isSegm3D and labelsData.ndim == 3: + # 2D segm --> stack to 3D + T, Y, X = labelsData.shape + repeat = [labelsData]*posData.SizeZ + labelsData = np.stack(repeat, axis=1) + + + posData.ol_labels_data[segmEndname] = labelsData + + def startBlinkingModeCB(self): + try: + self.timer.stop() + self.stopBlinkTimer.stop() + except Exception as e: + pass + if self.rulerButton.isChecked(): + return + self.timer = QTimer(self) + self.timer.timeout.connect(self.blinkModeComboBox) + self.timer.start(200) + self.stopBlinkTimer = QTimer(self) + self.stopBlinkTimer.timeout.connect(self.stopBlinkingCB) + self.stopBlinkTimer.start(2000) + + def blinkModeComboBox(self): + if self.flag: + self.modeComboBox.setStyleSheet('background-color: orange') + else: + self.modeComboBox.setStyleSheet('background-color: none') + self.flag = not self.flag + + def stopBlinkingCB(self): + self.timer.stop() + self.modeComboBox.setStyleSheet('background-color: none') + + def highlightNewIDs_ccaFailed(self, IDsWithIssue, rp=None): + if rp is None: + posData = self.data[self.pos_i] + rp = posData.rp + for obj in rp: + if obj.label not in IDsWithIssue: + continue + self.setCcaIssueContour(obj) + + # @exec_time + def highlightLostNew(self): + if self.modeComboBox.currentText() == 'Viewer': + return + + posData = self.data[self.pos_i] + delROIsIDs = self.getDelRoisIDs() + + # self.setAllContoursImages(delROIsIDs=delROIsIDs) + if posData.frame_i == 0: + return + + if not self.annotLostObjsToggle.isChecked(): + return + + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + + if prev_rp is None: + return + + self.setAllLostObjContoursImage(delROIsIDs=delROIsIDs) + self.setAllLostTrackedObjContoursImage(delROIsIDs=delROIsIDs) + + def addLostObjsToLostObjImage(self, lostObj, lostID, force=False): + if not force: + if not self.copyLostObjButton.isChecked(): + return + + obj_slice = self.getObjSlice(lostObj.slice) + obj_image = self.getObjImage(lostObj.image, lostObj.bbox) + self.lostObjImage[obj_slice][obj_image] = lostID + + def highlightHoverLostObj(self, modifiers, event): + noModifier = modifiers == Qt.NoModifier + if not noModifier: + return + + if not self.copyLostObjButton.isChecked(): + return + + if event.isExit(): + return + + posData = self.data[self.pos_i] + x, y = event.pos() + xdata, ydata = int(x), int(y) + try: + hoverLostID = self.lostObjImage[ydata, xdata] + except IndexError: + return + + self.ax1_lostObjScatterItem.hoverLostID = hoverLostID + if hoverLostID == 0: + self.ax1_lostObjScatterItem.setSize(self.contLineWeight+1) + self.ax1_lostObjScatterItem.setData([], []) + else: + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] + lostObj = prev_rp[prev_IDs_idxs[hoverLostID]] + obj_contours = self.getObjContours(lostObj, all_external=True) + for cont in obj_contours: + xx = cont[:,0] + yy = cont[:,1] + self.ax1_lostObjScatterItem.addPoints(xx, yy) + self.ax1_lostObjScatterItem.setSize(self.contLineWeight+2) + + def annotLostObjsToggled(self, checked): + if not self.isDataLoaded: + return + self.updateAllImages() + + def getPrevFrameIDs(self, current_frame_i=None): + posData = self.data[self.pos_i] + if current_frame_i is None: + current_frame_i = posData.frame_i + + if current_frame_i is None: + return [] + + prev_frame_i = current_frame_i - 1 + prevIDs = posData.allData_li[prev_frame_i]['IDs'] + + if prevIDs: + return prevIDs + + # IDs in previous frame were not stored --> load prev lab from HDD + prev_lab = self.get_labels( + from_store=False, + frame_i=prev_frame_i, + return_copy=False + ) + rp = skimage.measure.regionprops(prev_lab) + prevIDs = [obj.label for obj in rp] + return prevIDs + + # @exec_time + def setLostNewOldPrevIDs(self): + posData = self.data[self.pos_i] + if posData.frame_i == 0: + posData.lost_IDs = [] + posData.new_IDs = [] + posData.old_IDs = [] + # posData.multiContIDs = set() + self.titleLabel.setText('Looking good!', color=self.titleColor) + return [] + + # elif self.modeComboBox.currentText() == 'Viewer': + # pass + + out = self.updateLostNewCurrentIDs() + lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs = ( + out + ) + self.setTitleText( + lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs + ) + return curr_delRoiIDs + + + def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): + if not IDs: + return htmlTxt_li, htmlTxtFull_li + + if isinstance(IDs, set): + IDs = list(IDs) + + trim_IDs = myutils.get_trimmed_list(IDs) + txt = f'{pretxt}: {trim_IDs}' + txt_full = f'{pretxt}:
{IDs}' + + txt = f'{txt}' + txt_full = f'{txt_full}' + + htmlTxt_li.append(txt) + htmlTxtFull_li.append(txt_full) + + return htmlTxt_li, htmlTxtFull_li + + def setTitleText( + self, lost_IDs=None, new_IDs=None, IDs_with_holes=None, + tracked_lost_IDs=None + ): + if self.manualAnnotPastButton.isChecked(): + lockedID = self.editIDspinbox.value() + frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + txt = ( + f'Locked ID {lockedID} ' + f'since frame n. {frame_to_restore+1}' + ) + htmlTxt = f'{txt}' + self.titleLabel.setText(htmlTxt) + return + + mode = self.modeComboBox.currentText() + try: + posData = self.data[self.pos_i] + posData.segm_data[posData.frame_i] + prev_segmented = True + except IndexError: + prev_segmented = False + + if prev_segmented: + htmlTxt_li = [] + htmlTxtFull_li = [] + else: + htmlTxt = f'Never segmented frame. ' + self.titleLabel.setText(htmlTxt) + self.titleLabel.setToolTip(htmlTxt) + return + + if mode != 'Normal division: Lineage tree': + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'IDs lost', 'orange', lost_IDs + ) + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'New IDs', 'red', new_IDs + ) + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'Acc. IDs lost', 'green', + tracked_lost_IDs + ) + + for i, htmlTxtFull in enumerate(htmlTxtFull_li): + htmlTxtFull_li[i] = htmlTxtFull.replace('Acc.', 'Accepted') + + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'IDs with holes', 'red', + IDs_with_holes + ) + else: + try: + cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i) + except IndexError or KeyError: + title = 'Processing lineage tree...' + htmlTxt = f'{title}' + self.titleLabel.setText(htmlTxt) + self.titleLabel.setToolTip(htmlTxt) + return + except AttributeError: + title = 'Lineage tree still initializing...' + htmlTxt = f'{title}' + self.titleLabel.setText(htmlTxt) + self.titleLabel.setToolTip(htmlTxt) + return + + parent_cell_txt_raw = [] + if cells_with_parent: + # aggregate same parents + parent_cell_groups = dict() + for cell, parent in cells_with_parent: + if parent not in parent_cell_groups: + parent_cell_groups[parent] = [] + parent_cell_groups[parent].append(cell) + for parent, daughters in parent_cell_groups.items(): + cells_str = ','.join([str(daughter) for daughter in daughters]) + parent_cell_txt_raw.append(f'({parent}>{cells_str})') + + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'New w/out mother', 'red', + orphan_cells + ) + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'Lost', 'yellow', lost_cells + ) + htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( + htmlTxt_li, htmlTxtFull_li, 'Parent > Cell', 'green', + parent_cell_txt_raw + ) + + if not htmlTxt_li: + title = 'Looking good' + htmlTxt = f'{title}' + self.titleLabel.setText(htmlTxt) + self.titleLabel.setToolTip(htmlTxt) + return + + htmlTxt = ', '.join(htmlTxt_li) + htmlTxtFull = '
'.join(htmlTxtFull_li) + + self.titleLabel.setText(htmlTxt) + self.titleLabel.setToolTip(htmlTxtFull) + + def separateByLabelling(self, lab, rp, maxID=None): + """ + Label each single object in posData.lab and if the result is more than + one object then we insert the separated object into posData.lab + """ + setRp = False + posData = self.data[self.pos_i] + if maxID is None: + maxID = max(posData.IDs, default=1) + for obj in rp: + lab_obj = skimage.measure.label(obj.image) + rp_lab_obj = skimage.measure.regionprops(lab_obj) + if len(rp_lab_obj)<=1: + continue + lab_obj += maxID + _slice = obj.slice # self.getObjSlice(obj.slice) + _objMask = obj.image # self.getObjImage(obj.image) + lab[_slice][_objMask] = lab_obj[_objMask] + setRp = True + maxID += 1 + return setRp + + def isFirstTimeOnNextFrame(self): + posData = self.data[self.pos_i] + posData.last_tracked_i = self.navigateScrollBar.maximum()-1 + return posData.frame_i > posData.last_tracked_i + + def trackManuallyAddedObject( + self, added_IDs: List[int] | int | Set[int], isNewID: bool, + wl_update:bool=True, wl_track_og_curr:bool=False + ): + """Track object added manually on frame that was already visited. + + Parameters + ---------- + added_IDs : int | list of int | set + ID or IDs of the object added manually + isNewID : bool + If True, the added object is new + + Notes + ----- + This method tracks the new added object against the previous frame + labels. If the ID determined by tracking is different from `added_ID` + (meaning that tracking thinks the new ID should be changed to the + tracked ID) and the tracked ID is not already existing (which would + otherwise causing merging) we assign the tracked ID to the object with + `added_ID`. + + If instead the tracked ID is the same as `added_ID` we are dealing + with a truly new object. In this case we want to try tracking it against + the next frame (since the next frame was already validated). + As before, we assign the tracked ID (against the next frame) only if + not already existing in current frame (to avoid merging). + """ + if self.isSnapshot: + return + + if not isNewID: + return + + if isinstance(added_IDs, int): + added_IDs = [added_IDs] + + posData = self.data[self.pos_i] + tracked_lab = self.tracking( + enforce=True, assign_unique_new_IDs=False, return_lab=True, + IDs=added_IDs + ) + self.clearAssignedObjsSecondStep() + if tracked_lab is None: + return + + # Track only new object + prevIDs = posData.allData_li[posData.frame_i-1]['IDs'] + + # mask = np.zeros(posData.lab.shape, dtype=bool) + update_rp = False + + for added_ID in added_IDs: + # try: + # obj = posData.rp[added_ID] # ID not present + # mask[obj.slice][obj.image] = True + + # except IndexError as err: + mask = posData.lab == added_ID + try: + trackedID = tracked_lab[mask][0] + except IndexError as err: + # added_ID is not present + continue + + isTrackedIDalreadyPresentAndNotNew = ( + posData.IDs_idxs.get(trackedID) is not None + and added_ID != trackedID + ) + if isTrackedIDalreadyPresentAndNotNew: + continue + + isTrackedIDinPrevIDs = trackedID in prevIDs + if isTrackedIDinPrevIDs: + posData.lab[mask] = trackedID + else: + # New object where we can try to track against next frame + trackedID = self.trackNewIDtoNewIDsFutureFrame(added_ID, mask) + if trackedID is None: + self.clearAssignedObjsSecondStep() + continue + posData.lab[mask] = trackedID + + self.keepOnlyNewIDAssignedObjsSecondStep(trackedID) + update_rp = True + + if update_rp: + self.update_rp(wl_update=wl_update) + + def trackFrameCustomTracker( + self, prev_lab, currentLab, IDs=None, unique_ID=None + ): + if unique_ID is None: + unique_ID = self.setBrushID() + try: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, + unique_ID=unique_ID, + IDs=IDs, + **self.track_frame_params, + ) + except TypeError as err: + if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: + try: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, IDs=IDs, + **self.track_frame_params + ) + except TypeError as err: + if str(err).find('an unexpected keyword argument \'IDs\'') != -1: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, + **self.track_frame_params) + else: + raise err + elif str(err).find('an unexpected keyword argument \'IDs\'') != -1: + try: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, + unique_ID=unique_ID, + **self.track_frame_params + ) + except TypeError as err: + if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, + **self.track_frame_params + ) + else: + raise err + else: + raise err + return tracked_result + + def trackFrame( + self, prev_lab, prev_rp, curr_lab, curr_rp, curr_IDs, + assign_unique_new_IDs=True, IDs=None, unique_ID=None + ): + if self.trackWithAcdcAction.isChecked(): + tracked_result = CellACDC_tracker.track_frame( + prev_lab, prev_rp, curr_lab, curr_rp, + IDs_curr_untracked=curr_IDs, + setBrushID_func=self.setBrushID, + posData=self.data[self.pos_i], + assign_unique_new_IDs=assign_unique_new_IDs, + IDs=IDs, + unique_ID=unique_ID + ) + elif self.trackWithYeazAction.isChecked(): + tracked_result = self.tracking_yeaz.correspondence( + prev_lab, curr_lab, use_modified_yeaz=True, + use_scipy=True + ) + else: + tracked_result = self.trackFrameCustomTracker( + prev_lab, curr_lab, IDs=IDs, unique_ID=unique_ID + ) + + # Check if tracker also returns additional info + if isinstance(tracked_result, tuple): + tracked_lab, tracked_lost_IDs = tracked_result + self.handleAdditionalInfoRealTimeTracker(prev_rp, tracked_lost_IDs) + else: + tracked_lab = tracked_result + + return tracked_lab + + def clearAssignedObjsSecondStep(self): + posData = self.data[self.pos_i] + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + + def trackSubsetIDs(self, subsetIDs: Iterable[int]): + posData = self.data[self.pos_i] + if posData.frame_i == 0: + return + + subsetLab = np.zeros_like(posData.lab) + for subsetID in subsetIDs: + subsetLab[posData.lab == subsetID] = subsetID + + prev_lab = posData.allData_li[posData.frame_i-1]['labels'] + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + tracked_lab = self.trackFrame( + prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, + assign_unique_new_IDs=True + ) + doUpdateRp = False + for subsetID in subsetIDs: + subsetIDmask = posData.lab == subsetID + trackedID = tracked_lab[subsetIDmask][0] + if trackedID == subsetID: + continue + + is_manually_edited = False + for y, x, new_ID in posData.editID_info: + if new_ID == subsetID: + # Do not track because it was manually edited + break + + posData.lab[subsetIDmask] = tracked_lab[subsetIDmask] + doUpdateRp = True + + if not doUpdateRp: + return + + self.update_rp() + + def doSkipTracking(self, against_next: bool, enforce: bool): + if self.isSnapshot: + return True + + mode = str(self.modeComboBox.currentText()) + if mode != 'Segmentation and Tracking': + return True + + if self.UserEnforced_DisabledTracking: + return True + + if not self.realTimeTrackingToggle.isChecked(): + return True + + posData = self.data[self.pos_i] + if against_next: + reference_lab = posData.allData_li[posData.frame_i+1]['labels'] + if reference_lab is None: + # Next frame never visited --> cannot track against next + return True + + if posData.frame_i == posData.SizeT - 1: + # Last frame --> cannot track against next + return True + + else: + # check that we are not on the last frame + if posData.frame_i == 0: + return True + + if enforce or self.UserEnforced_Tracking: + # Enforce even if not last visited frame + return False + + is_first_time_on_next_frame = self.isFirstTimeOnNextFrame() + skip_tracking = not is_first_time_on_next_frame + + return skip_tracking + + + # @exec_time + @exception_handler + def tracking( + self, enforce=False, DoManualEdit=True, + storeUndo=False, prev_lab=None, prev_rp=None, + return_lab=False, assign_unique_new_IDs=True, + separateByLabel=True, wl_update=True, + IDs=None, against_next=False, + ): + posData = self.data[self.pos_i] + + if self.doSkipTracking(against_next, enforce): + self.setLostNewOldPrevIDs() + return + + """Tracking starts here""" + staturBarLabelText = self.statusBarLabel.text() + self.statusBarLabel.setText('Tracking...') + + if storeUndo: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + # First separate by labelling + if separateByLabel: + maxID = max(posData.IDs, default=1) + setRp = core.split_connected_components( + posData.lab, rp=posData.rp, max_ID=maxID + ) + if setRp: + self.update_rp(wl_update=wl_update, ) + + if prev_lab is None: + if not against_next: + prev_lab = posData.allData_li[posData.frame_i-1]['labels'] + else: + prev_lab = posData.allData_li[posData.frame_i+1]['labels'] + if prev_rp is None: + if not against_next: + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + else: + prev_rp = posData.allData_li[posData.frame_i+1]['regionprops'] + + unique_ID = None + if posData.frame_i < self.get_last_tracked_i(): + unique_ID = self.setBrushID(return_val=True) + + tracked_lab = self.trackFrame( + prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, + assign_unique_new_IDs=assign_unique_new_IDs, IDs=IDs, + unique_ID=unique_ID + ) + + if DoManualEdit: + # Correct tracking with manually changed IDs + rp = skimage.measure.regionprops(tracked_lab) + IDs = [obj.label for obj in rp] + self.manuallyEditTracking(tracked_lab, IDs) + + if return_lab: + QTimer.singleShot(50, partial( + self.statusBarLabel.setText, staturBarLabelText + )) + return tracked_lab + + # Update labels, regionprops and determine new and lost IDs + posData.lab = tracked_lab + self.update_rp(wl_update=wl_update, ) + self.setAllTextAnnotations() + QTimer.singleShot(50, partial( + self.statusBarLabel.setText, staturBarLabelText + )) + + def handleAdditionalInfoRealTimeTracker(self, prev_rp, *args): + if self._rtTrackerName == 'CellACDC_normal_division': + tracked_lost_IDs = args[0] + self.setTrackedLostCentroids(prev_rp, tracked_lost_IDs) + elif self._rtTrackerName == 'CellACDC_2steps': + if args[0] is None: + return + posData = self.data[self.pos_i] + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = args[0] + + def keepOnlyNewIDAssignedObjsSecondStep(self, trackedID): + posData = self.data[self.pos_i] + annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) + + if annotInfo is None: + return + + new_objs_1st_step, lost_objs_1st_step = annotInfo + correct_new_objs, correct_lost_objs = [], [] + for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): + newObj_ID = posData.lab[newObj.slice][newObj.image][0] + if newObj_ID != trackedID: + continue + + correct_new_objs.append(newObj) + correct_lost_objs.append(lostObj) + + if not correct_new_objs: + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + else: + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( + correct_new_objs, correct_lost_objs + ) + # self.annotateAssignedObjsAcdcTrackerSecondStep() + + def updateAssignedObjsAcdcTrackerSecondStep(self, newID): + posData = self.data[self.pos_i] + annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) + if annotInfo is None: + return + + new_objs_1st_step, lost_objs_1st_step = annotInfo + correct_new_objs, correct_lost_objs = [], [] + for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): + newObj_ID = posData.lab[newObj.slice][newObj.image][0] + if newObj_ID == newID: + # The ID of the new object tracked with 2nd step was + # manually edit --> do not annotate its linking to lost obj anymore + continue + correct_new_objs.append(newObj) + correct_lost_objs.append(lostObj) + + if not correct_new_objs: + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + else: + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( + correct_new_objs, correct_lost_objs + ) + self.annotateAssignedObjsAcdcTrackerSecondStep() + + + def annotateAssignedObjsAcdcTrackerSecondStep(self): + posData = self.data[self.pos_i] + annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) + if annotInfo is None: + return + + new_objs_1st_step, lost_objs_1st_step = annotInfo + for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): + allContours = self.getObjContours(lostObj, all_external=True) + for objContours in allContours: + isObjVisible = self.isObjVisible(newObj.bbox) + if not isObjVisible: + continue + xx = objContours[:,0] + 0.5 + yy = objContours[:,1] + 0.5 + self.yellowContourScatterItem.addPoints(xx, yy) + + y1, x1 = self.getObjCentroid(lostObj.centroid) + y2, x2 = self.getObjCentroid(newObj.centroid) + xx, yy = core.get_line(y1, x1, y2, x2, dashed=False) + self.ax1_oldMothBudLinesItem.addPoints(xx, yy) + + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + + def setTrackedLostCentroids(self, prev_rp, tracked_lost_IDs): + """Store centroids of those IDs the tracker decided is fine to lose + (e.g., upon standard cell division the ID of the mother is fine) + + Parameters + ---------- + prev_rp : skimage.measure.RegionProperties + List of region properties of the object in previous frame + tracked_lost_IDs : iterable + List-like container of the IDs that is fine to lose from previous + frame to current frame + + Note + ---- + This function stores the centroids because the user could change IDs + in multiple ways. Storing centroids is more robust. + """ + posData = self.data[self.pos_i] + frame_i = posData.frame_i + + for obj in prev_rp: + if obj.label not in tracked_lost_IDs: + continue + + int_centroid = tuple([int(val) for val in obj.centroid]) + try: + posData.tracked_lost_centroids[frame_i].add(int_centroid) + except KeyError: + posData.tracked_lost_centroids[frame_i] = {int_centroid} + + def getTrackedLostIDs(self, prev_lab=None, IDs_in_frames=None, frame_i=None): + trackedLostIDs = set() + posData = self.data[self.pos_i] + if self.isExportingVideo: + posData.trackedLostIDs = trackedLostIDs + return trackedLostIDs + + retrackedLostcent = set() + if frame_i is None: + frame_i = posData.frame_i + + if prev_lab is None: + prev_lab = self.get_labels( + from_store=True, + frame_i=posData.frame_i-1, + return_existing=False, + return_copy=False + ) + + if IDs_in_frames is None: + IDs_in_frames = posData.IDs + + try: + tracked_lost_centroids = posData.tracked_lost_centroids[frame_i] + except KeyError: + tracked_lost_centroids = set() + + for centroid in tracked_lost_centroids: + if len(centroid) < 3 and prev_lab.ndim == 3: + # Ignore wrongly stored centroids + continue + + ID = prev_lab[centroid] + if ID == 0: + continue + + if ID in IDs_in_frames: + retrackedLostcent.add(centroid) + continue + + trackedLostIDs.add(ID) + + posData.tracked_lost_centroids[frame_i] = ( + tracked_lost_centroids - retrackedLostcent + ) + posData.trackedLostIDs = trackedLostIDs + + return trackedLostIDs + + def manuallyEditTracking(self, tracked_lab, allIDs): + posData = self.data[self.pos_i] + infoToRemove = [] + # Correct tracking with manually changed IDs + maxID = max(allIDs, default=1) + for y, x, new_ID in posData.editID_info: + old_ID = tracked_lab[y, x] + if old_ID == 0 or old_ID == new_ID: + infoToRemove.append((y, x, new_ID)) + continue + if new_ID in allIDs: + tempID = maxID+1 + tracked_lab[tracked_lab == old_ID] = tempID + tracked_lab[tracked_lab == new_ID] = old_ID + tracked_lab[tracked_lab == tempID] = new_ID + else: + tracked_lab[tracked_lab == old_ID] = new_ID + if new_ID > maxID: + maxID = new_ID + for info in infoToRemove: + posData.editID_info.remove(info) + + def warnReinitLastSegmFrame(self): + current_frame_n = self.navigateScrollBar.value() + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + Are you sure you want to re-initialize the last visited and + validated frame to number {current_frame_n}?

+ WARNING: If you save, all annotations after frame number + {current_frame_n} will be lost! + """) + msg.warning( + self, 'WARNING: Potential loss of data', txt, + buttonsTexts=('Cancel', 'Yes, I am sure') + ) + return msg.cancel + + def extendSegmDataIfNeeded(self, stopFrameNum): + posData = self.data[self.pos_i] + segmSizeT = len(posData.segm_data) + if stopFrameNum <= segmSizeT: + return + numFramesToAdd = stopFrameNum - segmSizeT + posData.allData_li.extend( + [myutils.get_empty_stored_data_dict() for i in range(numFramesToAdd)] + ) + lab_shape = posData.segm_data[0].shape + shapeToAdd = (numFramesToAdd, *lab_shape) + additionalSegmData = np.zeros(shapeToAdd, dtype=posData.segm_data.dtype) + extendedSegmData = np.concatenate((posData.segm_data, additionalSegmData)) + posData.segm_data = extendedSegmData + + def reInitLastSegmFrame( + self, checked=True, from_frame_i=None, updateImages=True, + force=False + ): + if not force: + cancel = self.warnReinitLastSegmFrame() + if cancel: + self.logger.info( + 'Re-initialization of last validated frame cancelled.' + ) + return + + posData = self.data[self.pos_i] + if from_frame_i is None: + from_frame_i = posData.frame_i + + self.lastFrameRanOnFirstVisitTools = posData.frame_i + + self.updateLastCheckedFrameWidgets(from_frame_i) + posData.last_tracked_i = from_frame_i + self.navigateScrollBar.setMaximum(from_frame_i+1) + self.navSpinBox.setMaximum(from_frame_i+1) + # self.navigateScrollBar.setMinimum(1) + + # posData.tracked_lost_centroids[from_frame_i-1] = set() + for i in range(from_frame_i, posData.SizeT): + if posData.allData_li[i]['labels'] is None: + break + + posData.segm_data[i] = posData.allData_li[i]['labels'] + posData.allData_li[i] = myutils.get_empty_stored_data_dict() + + posData.tracked_lost_centroids[i] = set() + posData.acdcTracker2stepsAnnotInfo.pop(i, None) + + if posData.acdc_df is not None: + frames = posData.acdc_df.index.get_level_values(0) + if from_frame_i in frames: + posData.acdc_df = posData.acdc_df.loc[:from_frame_i] + + self.removeAlldelROIsCurrentFrame() + + if not updateImages: + return + + self.updateAllImages() + + def resetAcceptedLostIDs(self, from_frame_i=None): + posData = self.data[self.pos_i] + if from_frame_i is None: + from_frame_i = posData.frame_i + + posData.tracked_lost_centroids[from_frame_i-1] = set() + for i in range(from_frame_i, posData.SizeT): + posData.tracked_lost_centroids[i] = set() + + def removeAllItems(self): + self.ax1.clear() + self.ax2.clear() + try: + self.chNamesQActionGroup.removeAction(self.userChNameAction) + except Exception as e: + pass + try: + posData = self.data[self.pos_i] + for action in self.fluoDataChNameActions: + self.chNamesQActionGroup.removeAction(action) + except Exception as e: + pass + try: + self.overlayButton.setChecked(False) + except Exception as e: + pass + + if hasattr(self, 'contoursImage'): + self.initContoursImage() + + def createUserChannelNameAction(self): + self.userChNameAction = QAction(self) + self.userChNameAction.setCheckable(True) + self.userChNameAction.setText(self.user_ch_name) + + def createChannelNamesActions(self): + # LUT histogram channel name context menu actions + self.chNamesQActionGroup = QActionGroup(self) + self.chNamesQActionGroup.addAction(self.userChNameAction) + posData = self.data[self.pos_i] + for action in self.fluoDataChNameActions: + self.chNamesQActionGroup.addAction(action) + action.setChecked(False) + + self.userChNameAction.setChecked(True) + + for action in self.overlayContextMenu.actions(): + action.setChecked(False) + + def restoreDefaultColors(self): + try: + color = self.defaultToolBarButtonColor + self.overlayButton.setStyleSheet(f'background-color: {color}') + except AttributeError: + # traceback.print_exc() + pass + + @exception_handler + def _createEmptyData(self): + self.MostRecentPath = self.getMostRecentPath() + exp_path = QFileDialog.getExistingDirectory( + self, + 'Select experiment folder where to create empty data', + self.MostRecentPath + ) + if not exp_path: + return + + pos_path = os.path.join(exp_path, 'Position_1') + images_path = os.path.join(pos_path, 'Images') + if os.path.exists(images_path): + raise FileExistsError(f'The following path already exists "{images_path}"') + + os.makedirs(images_path, exist_ok=True) + + basename = 'test_empty_' + tif_filename = f'{basename}channel_1.tif' + tif_filepath = os.path.join(images_path, tif_filename) + empty_img = np.zeros((256,256), dtype=np.uint8) + empty_img[0,0] = 255 + skimage.io.imsave(tif_filepath, empty_img) + + metadata_filename = f'{basename}metadata.csv' + metadata_filepath = os.path.join(images_path, metadata_filename) + df_metadata = pd.DataFrame({ + 'Description': ['basename'], + 'values': [basename] + }) + df_metadata.to_csv(metadata_filepath, index=False) + + self.isNewFile = True + self._openFolder(exp_path=images_path) + + + def segmNdimIndicatorClicked(self): + ndimText = self.segmNdimIndicator.text() + if ndimText == '2D': + alternativeNdimText = '3D' + toggleText = 'activate' + else: + alternativeNdimText = '2D' + toggleText = 'de-activate' + msg = widgets.myMessageBox(wrapText=False) + important_txt = (""" + The toggle to activate 3D segmentation is visible only when + the Number of z-slices is greater than 1. + """) + txt = html_utils.paragraph(f""" + This indicator shows that you are working with {ndimText} + segmentation masks.

+ + If instead, you want to work with {alternativeNdimText} segmentation, + you need to initialize a new segmentation file.

+ + To do so, go the menu on the top menubar File --> + New Segmentation File... and,
+ at the dialog where you insert the metadata (Number of z-slices, + pixel size, etc.),
+ {toggleText} the parameter called Work with 3D + segmentation masks (z-stack)
+ as indicated in the screenshot below
. + {html_utils.to_admonition(important_txt, admonition_type='note')} +
+ """) + msg.information( + self, 'Segmentation nmber of dimensions info', txt, + image_paths=':toggle_3D_screenshot.png' + ) + self.segmNdimIndicator.setChecked(True) + + def newFile(self): + self.newSegmEndName = '' + self.isNewFile = True + msg = widgets.myMessageBox(parent=self, showCentered=False) + msg.setWindowTitle('File or folder?') + msg.addText(html_utils.paragraph(f""" + Do you want to load an image file or Position + folder(s)? + """)) + loadPosButton = QPushButton('Load Position folder', msg) + loadPosButton.setIcon(QIcon(":folder-open.svg")) + loadFileButton = QPushButton('Load image file', msg) + loadFileButton.setIcon(QIcon(":image.svg")) + helpButton = widgets.helpPushButton('Help...') + msg.addButton(helpButton) + helpButton.disconnect() + helpButton.clicked.connect(self.helpNewFile) + msg.addCancelButton(connect=True) + msg.addButton(loadFileButton) + msg.addButton(loadPosButton) + loadPosButton.setDefault(True) + msg.exec_() + if msg.cancel: + return + + if msg.clickedButton == loadPosButton: + self._openFolder() + else: + self._openFile() + + def openNewWindow(self): + self.logger.info('Opening a new window...') + if self.launcherSlot is not None: + self.launcherSlot() + return + + winClass = self.__class__ + win = winClass( + self.app, parent=self, mainWin=self.mainWin, version=self._version + ) + win.run() + self.newWindows.append(win) + + def helpNewFile(self): + msg = widgets.myMessageBox(showCentered=False) + href = f'user manual' + txt = html_utils.paragraph(f""" + Cell-ACDC can open both a single image file or files structured + into Position folders.

+ If you are just testing out you can load a single image file, but + in general we reccommend structuring your data into Position + folders.

+ More info about Position folders in the {href} at the section + called "Create required data structure from microscopy file(s)". + """) + msg.information( + self, 'Help on Position folders', txt + ) + + def openFile(self, checked=False, file_path=None): + self.logger.info(f'Opening FILE "{file_path}"') + + self.isNewFile = False + self._openFile(file_path=file_path) + + def manageVersions(self): + posData = self.data[self.pos_i] + selectVersion = apps.SelectAcdcDfVersionToRestore(posData, parent=self) + selectVersion.exec_() + + if selectVersion.cancel: + return + + undoId = uuid.uuid4() + if posData.cca_df is not None: + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + selectedTime = selectVersion.selectedTimestamp + + self.modeComboBox.setCurrentText('Viewer') + self.logger.info(f'Loading file from {selectedTime}...') + + acdc_df = load.read_acdc_df_from_archive( + selectVersion.archiveFilePath, selectVersion.selectedKey + ) + posData.acdc_df = acdc_df + frames = acdc_df.index.get_level_values(0) + last_visited_frame_i = frames.max() + current_frame_i = posData.frame_i + pbar = tqdm(total=last_visited_frame_i+1, ncols=100) + for frame_i in range(last_visited_frame_i+1): + posData.frame_i = frame_i + self.get_data() + if posData.cca_df is not None: + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + if posData.allData_li[frame_i]['labels'] is None: + pbar.update() + continue + + if frame_i not in frames: + acdc_df_i = pd.DataFrame(columns=acdc_df.columns) + acdc_df_i.drop(self.cca_df_colnames, axis=1, errors='ignore') + acdc_df_i.index.name = 'Cell_ID' + else: + acdc_df_i = acdc_df.loc[frame_i].dropna(axis=1, how='all') + + posData.allData_li[frame_i]['acdc_df'] = acdc_df_i + pbar.update() + pbar.close() + + # Back to current frame + posData.frame_i = current_frame_i + self.get_data(debug=False) + self.updateAllImages() + self.logger.info('Annotations correctly recovered.') + + def askUserChannelName(self, filename_no_ext, ext): + help_txt = html_utils.paragraph(f""" + Cell-ACDC requires that every image file has a basename and some + additional text, typically the channel name.

+ The basename will be common to all created files, while the additional text is used to identify the image files. + """) + + basename = filename_no_ext + underscore_splits = filename_no_ext.split('_') + if len(underscore_splits) > 1: + channel_name = underscore_splits[-1] + basename = '_'.join(underscore_splits[:-1]) + else: + channel_name = 'channel_1' + + txt = html_utils.paragraph(f""" + Provide some text (e.g., the channel name) to append at the end of the image file. + """) + win = apps.filenameDialog( + basename=basename, + ext=ext, + hintText=txt, + defaultEntry=channel_name, + helpText=help_txt, + allowEmpty=False, + parent=self, + title='Provide channel name for image file', + ) + win.exec_() + if win.cancel: + return False, '' + + return True, win.entryText + + def warnUserCreationImagesFolder(self, images_path, ext): + msg = widgets.myMessageBox(wrapText=False) + txt = (f""" + Cell-ACDC requires a specific folder structure to load the data.

+ Specifically, it requires the image(s) to be located in a + folder called Images.

+ The file format of the images must be TIFF or NPZ + (.tif or .npz extension).

+ You can choose to let Cell-ACDC create the required data structure + from your file,
+ or you can stop the + process and manually place the image(s) into a folder called + Images.

+ If you choose to proceed, Cell-ACDC will create the following + folder: + {images_path} +
+ """) + + if ext == '.tif' or ext == '.npz': + txt = f'{txt}How do you want to proceed?' + else: + txt = f'{txt}Do you want to proceed?' + txt = html_utils.paragraph(txt) + + if ext == '.tif' or ext == '.npz': + copyButton = widgets.copyPushButton( + 'Copy the image into the new folder' + ) + moveButton = widgets.movePushButton( + 'Move the image into the new folder' + ) + _, copyButton, moveButton = msg.information( + self, 'Creating Images folder', txt, + buttonsTexts=('Cancel', copyButton, moveButton) + ) + if msg.cancel: + return False, None + + if msg.clickedButton == copyButton: + return True, True + elif msg.clickedButton == moveButton: + return True, False + + else: + msg.information( + self, 'Creating Images folder', txt, + buttonsTexts=('Cancel', 'Yes, proceed') + ) + if msg.cancel: + return False, None + + return True, True + + @exception_handler + def _openFile(self, file_path=None): + """ + Function used for loading an image file directly. + """ + if file_path is None: + self.MostRecentPath = self.getMostRecentPath() + file_path = QFileDialog.getOpenFileName( + self, 'Select image file', self.MostRecentPath, + "Image/Video Files (*.png *.tif *.tiff *.jpg *.jpeg *.mov *.avi *.mp4)" + ";;All Files (*)")[0] + if not file_path: + return + + filename, ext = os.path.splitext(os.path.basename(file_path)) + ext = ext.lower() + dirpath = os.path.dirname(file_path) + dirname = os.path.basename(dirpath) + filename = filename.rstrip('_') + channel_name = None + do_copy = True + if dirname != 'Images': + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + acdc_folder = f'{timestamp}_acdc' + exp_path = os.path.join(dirpath, acdc_folder, 'Images') + proceed, do_copy = self.warnUserCreationImagesFolder(exp_path, ext) + if not proceed: + self.logger.info('Loading image file cancelled.') + return + + proceed, channel_name = self.askUserChannelName( + filename, '.tif' + ) + if not proceed: + self.logger.info('Loading image file cancelled.') + return + + os.makedirs(exp_path, exist_ok=True) + else: + exp_path = dirpath + + if channel_name is not None: + # Check if user wants to use the existing channel name + underscore_splits = filename.split('_') + if len(underscore_splits) > 1: + default_ch_name = underscore_splits[-1] + if channel_name == default_ch_name: + filename = '_'.join(underscore_splits[:-1]) + + basename = f'{filename}_' + new_filename = f'{filename}_{channel_name}{ext}' + df_metadata = pd.DataFrame({ + 'Description': ['basename'], + 'values': [basename] + }) + metadata_csv_filename = f'{basename}metadata.csv' + metadata_csv_filepath = os.path.join( + exp_path, metadata_csv_filename + ) + df_metadata.to_csv(metadata_csv_filepath, index=False) + else: + new_filename = f'{filename}{ext}' + + if do_copy: + action_text = 'Copying' + else: + action_text = 'Moving' + + if ext == '.tif' or ext == '.npz': + new_filepath = os.path.join(exp_path, new_filename) + if not os.path.exists(new_filepath): + self.logger.info(f'{action_text} file to Images folder...') + if do_copy: + shutil.copy2(file_path, new_filepath) + else: + shutil.move(file_path, new_filepath) + self._openFolder(exp_path=exp_path, imageFilePath=new_filepath) + else: + self.logger.info(f'{action_text} file to .tif format...') + data = load.loadData(file_path, '', log_func=self.logger.info) + data.loadImgData() + img = data.img_data + if img.ndim == 3 and (img.shape[-1] == 3 or img.shape[-1] == 4): + self.logger.info('Converting RGB image to grayscale...') + if img.shape[-1] == 3: + data.img_data = skimage.color.rgb2gray(data.img_data) + else: + data.img_data = cv2.cvtColor( + data.img_data, cv2.COLOR_RGBA2GRAY + ) + data.img_data = skimage.img_as_ubyte(data.img_data) + new_filename_no_ext, ext = os.path.splitext(new_filename) + tif_filename = f'{new_filename_no_ext}.tif' + tif_path = os.path.join(exp_path, tif_filename) + if data.img_data.ndim == 3: + SizeT = data.img_data.shape[0] + SizeZ = 1 + elif data.img_data.ndim == 4: + SizeT = data.img_data.shape[0] + SizeZ = data.img_data.shape[1] + else: + SizeT = 1 + SizeZ = 1 + is_imageJ_dtype = ( + data.img_data.dtype == np.uint8 + or data.img_data.dtype == np.uint32 + or data.img_data.dtype == np.uint32 + or data.img_data.dtype == np.float32 + ) + if not is_imageJ_dtype: + data.img_data = skimage.img_as_ubyte(data.img_data) + + myutils.to_tiff(tif_path, data.img_data) + self._openFolder(exp_path=exp_path, imageFilePath=tif_path) + + def criticalNoTifFound(self, images_path): + err_title = 'No .tif files found in folder.' + err_msg = html_utils.paragraph( + 'The following folder

' + f'{images_path}

' + 'does not contain .tif or .h5 files.

' + 'Only .tif or .h5 files can be loaded with "Open Folder" button.

' + 'Try with File --> Open image/video file... ' + 'and directly select the file you want to load.' + ) + msg = widgets.myMessageBox() + msg.addShowInFileManagerButton(images_path) + msg.critical(self, err_title, err_msg) + + def reinitStoredSegmModels(self): + self.models = [None]*len(self.models) + + def checkAskSavePointsLayers(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, 'layerTypeIdx'): + continue + if action.layerTypeIdx != 4: + continue + + scatterItem = action.scatterItem + xx, yy = scatterItem.getData() + + if xx is None or len(xx) == 0: + toolButton = action.button + tableEndName = toolButton.clickEntryTableEndName + # Check in other loaded pos + are_there_points_to_save = False + for pos_i, _posData in enumerate(self.data): + if pos_i == self.pos_i: + continue + + df = _posData.clickEntryPointsDfs.get(tableEndName) + if df is None: + continue + + are_there_points_to_save = True + break + + if not are_there_points_to_save: + continue + + cancel = self.askSavePointsLayer(action) + if cancel: + return cancel + + return False + + def askSavePointsLayer(self, action): + toolButton = action.button + tableEndName = toolButton.clickEntryTableEndName + saveAction = toolButton.saveAction + + txt = html_utils.paragraph(f""" + Do you want to save the points you added + (table called {tableEndName}.csv)? + """ + ) + msg = widgets.myMessageBox(wrapText=False) + _, _, saveButton = msg.question( + self, 'Save points layer?', txt, + buttonsTexts=('Cancel', 'No, do not save', 'Yes, save points') + ) + if msg.clickedButton == saveButton: + self.savePointsAddedByClicking(saveAction.saveToolbutton, None) + + return msg.cancel + + def removeOverlayItems(self): + self.lutItemsLayout.clear() + + try: + for toolbutton in self.allOverlayToolbuttonsByIdx.values(): + self.overlayToolbar.removeAction(toolbutton.action) + + self.overlayToolbuttonsSep.removeFromToolbar() + except Exception as err: + pass + + def clearOverlayImageItems(self): + for items in self.overlayLayersItems.values(): + imageItem = items[0] + imageItem.clear() + + self.rgbaImg1.clear() + + def reInitGui(self): + cancel = self.checkAskSavePointsLayers() + if cancel: + return False + + if self.overlayToolbar.isTransparent(): + self.overlayToolbar.setTransparent(False) + + self.secondLevelToolbar.setVisible(False) + + self.gui_createLazyLoader() + + try: + self.navSpinBox.valueChanged.disconnect() + except Exception as e: + pass + + try: + self.scaleBar.removeFromAxis(self.ax1) + except Exception as e: + pass + + self.lineage_tree = None + self.getDistanceListMissingIDsCachedFrame = None + self.isZmodifier = False + self.zKeptDown = False + self.askRepeatSegment3D = True + self.askZrangeSegm3D = True + self.isDataLoaded = False + self.retainSizeLutItems = False + self.setMeasWinState = None + self.addPointsWin = None + self.delRoiLab = None + self.showPropsDockButton.setDisabled(True) + self.removeOverlayItems() + self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) + + self.reinitWidgetsPos() + self.removeAllItems() + self.reinitCustomAnnot() + self.reinitPointsLayers() + self.gui_createPlotItems() + self.setUncheckedAllButtons() + self.setUncheckedPointsLayers() + self.restoreDefaultColors() + self.reinitStoredSegmModels() + self.removeAxLimits() + self.curvToolButton.setChecked(False) + + self.wandControlsToolbar.setVisible(False) + self.wandToolButton.setChecked(False) + self.segmNdimIndicatorAction.setVisible(False) + + self.navigateToolBar.hide() + self.ccaToolBar.hide() + self.editToolBar.hide() + self.brushEraserToolBar.hide() + self.modeToolBar.hide() + + self.modeComboBox.setCurrentText('Viewer') + + alpha = self.imgGrad.labelsAlphaSlider.value() + self.labelsLayerImg1.setOpacity(alpha) + self.labelsLayerRightImg.setOpacity(alpha) + self.lastTrackedFrameLabel.setText('') + + self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False + + for action in self.askHowFutureFramesActions.values(): + action.setChecked(True) + action.setDisabled(True) + + return True + + def reinitPointsLayers(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + toolbar.removeAction(action) + toolbar.setVisible(False) + self.autoPilotZoomToObjToolbar.setVisible(False) + + def reinitWidgetsPos(self): + pass + # try: + # # self.highlightZneighObjCheckbox will be connected in + # # self.showHighlightZneighCheckbox() + # self.highlightZneighObjCheckbox.toggled.disconnect() + # except Exception as e: + # pass + # layout = self.bottomLeftLayout + # self.highlightZneighObjCheckbox.hide() + # try: + # layout.removeWidget(self.highlightZneighObjCheckbox) + # except Exception as e: + # pass + # self.highlightZneighObjCheckbox.hide() + # # layout.addWidget( + # # self.drawIDsContComboBox, 0, 1, 1, 2, + # # alignment=Qt.AlignCenter + # # ) + + def reinitCustomAnnot(self): + buttons = list(self.customAnnotDict.keys()) + for button in buttons: + self.clearScatterPlotCustomAnnotButton(button) + action = self.customAnnotDict[button]['action'] + self.annotateToolbar.removeAction(action) + self.checkableQButtonsGroup.removeButton(button) + self.customAnnotDict.pop(button) + # self.savedCustomAnnot.pop(name) + + self.saveCustomAnnot(only_temp=True) + + def loadingDataAborted(self): + self.openFolderAction.setEnabled(True) + self.titleLabel.setText('Loading data aborted.') + + def cleanUpOnError(self): + self.onEscape() + caller = 'Cell-ACDC' + if self.module.startswith('spotmax'): + caller = 'spotMAX' + txt = f'WARNING: {caller} is in error state. Please, restart.' + _hl = '*'*100 + self.titleLabel.setText(txt, color='r') + self.logger.info(f'{_hl}\n{txt}\n{_hl}') + + def openFolder( + self, checked=False, exp_path=None, imageFilePath='' + ): + if exp_path is None: + self.logger.info('Asking to select a folder path...') + else: + self.logger.info(f'Opening FOLDER "{exp_path}"...') + + self.isNewFile = False + if hasattr(self, 'data') and self.titleLabel.text != 'Saved!': + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Do you want to save before loading another dataset?' + ) + _, no, yes = msg.question( + self, 'Save?', txt, + buttonsTexts=('Cancel', 'No', 'Yes') + ) + if msg.clickedButton == yes: + func = partial(self._openFolder, exp_path, imageFilePath) + cancel = self.saveData(finishedCallback=func) + return + elif msg.cancel: + self.store_data() + return + else: + self.store_data(autosave=False) + + self._openFolder( + exp_path=exp_path, imageFilePath=imageFilePath + ) + + def addToRecentPaths(self, path, logger=None): + myutils.addToRecentPaths(path, logger=self.logger) + + def getMostRecentPath(self): + return myutils.getMostRecentPath() + + @exception_handler + def _openFolder( + self, checked=False, exp_path=None, imageFilePath='' + ): + """Main function to load data. + + Parameters + ---------- + checked : bool + kwarg needed because openFolder can be called by openFolderAction. + exp_path : string or None + Path selected by the user either directly, through openFile, + or drag and drop image file. + imageFilePath : string + Path of the image file that was either drag and dropped or opened + from File --> Open image/video file (openFileAction). + + Returns + ------- + None + """ + + if exp_path is None: + self.MostRecentPath = self.getMostRecentPath() + exp_path = QFileDialog.getExistingDirectory( + self, + 'Select experiment folder containing Position_n folders ' + 'or specific Position_n folder', + self.MostRecentPath + ) + + if not exp_path: + self.openFolderAction.setEnabled(True) + return + + proceed = self.reInitGui() + if not proceed: + self.openFolderAction.setEnabled(True) + return + + self.openFolderAction.setEnabled(False) + + if self.slideshowWin is not None: + self.slideshowWin.close() + + if self.ccaTableWin is not None: + self.ccaTableWin.close() + + self.exp_path = exp_path + self.logger.info(f'Loading from {self.exp_path}') + self.addToRecentPaths(exp_path, logger=self.logger) + self.addPathToOpenRecentMenu(exp_path) + + folder_type = myutils.determine_folder_type(exp_path) + is_pos_folder, is_images_folder, exp_path = folder_type + + self.titleLabel.setText('Loading data...', color=self.titleColor) + + skip_channels = [] + ch_name_selector = prompts.select_channel_name( + which_channel='segm', allow_abort=False + ) + user_ch_name = None + if not is_pos_folder and not is_images_folder and not imageFilePath: + images_paths = self._loadFromExperimentFolder(exp_path) + if not images_paths: + self.loadingDataAborted() + return + + elif is_pos_folder and not imageFilePath: + pos_foldername = os.path.basename(exp_path) + exp_path = os.path.dirname(exp_path) + images_paths = [os.path.join(exp_path, pos_foldername, 'Images')] + + elif is_images_folder and not imageFilePath: + images_paths = [exp_path] + pos_path = os.path.dirname(exp_path) + exp_path = os.path.dirname(pos_path) + + elif imageFilePath: + # images_path = exp_path because called by openFile func + filenames = myutils.listdir(exp_path) + ch_names, basenameNotFound = ( + ch_name_selector.get_available_channels(filenames, exp_path) + ) + filename = os.path.basename(imageFilePath) + self.ch_names = ch_names + user_ch_name = [ + chName for chName in ch_names if filename.find(chName)!=-1 + ][0] + images_paths = [exp_path] + pos_path = os.path.dirname(exp_path) + exp_path = os.path.dirname(pos_path) + + self.images_paths = images_paths + + # Get info from first position selected + images_path = self.images_paths[0] + filenames = myutils.listdir(images_path) + if ch_name_selector.is_first_call and user_ch_name is None: + ch_names, _ = ch_name_selector.get_available_channels( + filenames, images_path + ) + self.ch_names = ch_names + if not ch_names: + self.openFolderAction.setEnabled(True) + self.criticalNoTifFound(images_path) + return + if len(ch_names) > 1: + CbLabel='Select channel name to load: ' + ch_name_selector.QtPrompt( + self, ch_names, CbLabel=CbLabel + ) + if ch_name_selector.was_aborted: + self.openFolderAction.setEnabled(True) + return + skip_channels.extend([ + ch for ch in ch_names if ch!=ch_name_selector.channel_name + ]) + else: + ch_name_selector.channel_name = ch_names[0] + ch_name_selector.setUserChannelName() + user_ch_name = ch_name_selector.user_ch_name + else: + # File opened directly with self.openFile + ch_name_selector.channel_name = user_ch_name + + user_ch_file_paths = [] + not_allowed_ends = ['btrack_tracks.h5'] + for images_path in self.images_paths: + channel_file_path = load.get_filename_from_channel( + images_path, user_ch_name, skip_channels=skip_channels, + not_allowed_ends=not_allowed_ends, logger=self.logger.info + ) + if not channel_file_path: + self.criticalImgPathNotFound(images_path) + return + user_ch_file_paths.append(channel_file_path) + + ch_name_selector.setUserChannelName() + self.user_ch_name = user_ch_name + self.img1.channelName = user_ch_name + + self.AutoPilotProfile.storeSelectedChannel(self.user_ch_name) + + self.initGlobalAttr() + self.createOverlayContextMenu() + self.createUserChannelNameAction() + self.gui_createOverlayColors() + self.gui_createOverlayItems() + lastRow = self.bottomLeftLayout.rowCount() + self.bottomLeftLayout.setRowStretch(lastRow+1, 1) + + self.num_pos = len(user_ch_file_paths) + proceed = self.loadSelectedData(user_ch_file_paths, user_ch_name) + if not proceed: + self.openFolderAction.setEnabled(True) + return + + def _loadFromExperimentFolder(self, exp_path): + select_folder = load.select_exp_folder() + values = select_folder.get_values_segmGUI(exp_path) + if not values: + self.criticalInvalidPosFolder(exp_path) + self.openFolderAction.setEnabled(True) + return [] + + if len(values) > 1: + select_folder.QtPrompt(self, values, allow_cancel=False) + if select_folder.cancel: + return [] + else: + select_folder.cancel = False + select_folder.selected_pos = select_folder.pos_foldernames + + images_paths = [] + for pos in select_folder.selected_pos: + images_paths.append(os.path.join(exp_path, pos, 'Images')) + return images_paths + + def criticalInvalidPosFolder(self, exp_path): + href = html_utils.href_tag('here', data_structure_docs_url) + txt = html_utils.paragraph(f""" + The selected folder:

+ + {exp_path}

+ + is not a valid folder.

+ + Select a folder that contains the Position_n folders, + or a specific Position.

+ + If you are trying to load a single image file go to + File --> Open image/video file....

+ + To load a folder containing multiple .tif files the folder must + be called either Position_n
+ (with n being an integer) or Images.

+ + For more information about the correct folder structure see {href}. + """) + msg = widgets.myMessageBox(wrapText=False) + helpButton = widgets.helpPushButton('Help...') + msg.addButton(helpButton) + helpButton.clicked.disconnect() + helpButton.clicked.connect( + partial(myutils.browse_url, data_structure_docs_url) + ) + msg.addShowInFileManagerButton(exp_path) + msg.critical( + self, 'Incompatible folder', txt + ) + + def createOverlayContextMenu(self): + ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] + self.overlayContextMenu = QMenu() + self.overlayContextMenu.addSeparator() + self.checkedOverlayChannels = set() + for chName in ch_names: + action = QAction(chName, self.overlayContextMenu) + action.setCheckable(True) + action.toggled.connect(self.overlayChannelToggled) + self.overlayContextMenu.addAction(action) + + def createOverlayLabelsContextMenu(self, segmEndnames): + self.overlayLabelsContextMenu = QMenu() + self.overlayLabelsContextMenu.addSeparator() + self.drawModeOverlayLabelsChannels = {} + segmEndnames_extended = list(segmEndnames.copy()) + segmEndnames_extended = ['combined segm.'] + segmEndnames_extended + for segmEndname in segmEndnames_extended: + action = QAction(segmEndname, self.overlayLabelsContextMenu) + if segmEndname == 'combined segm.': + action.setCheckable(False) + self.combineSegmViewToggle = action + else: + action.setCheckable(True) + action.toggled.connect(self.addOverlayLabelsToggled) + self.overlayLabelsContextMenu.addAction(action) + + self.overlayLabelsContextMenu.addSeparator() + action = QAction('Edit appearance...', self.overlayLabelsContextMenu) + action.triggered.connect(self.editOverlayLabelsAppearance) + self.overlayLabelsContextMenu.addAction(action) + + def editOverlayLabelsAppearance(self, *args): + segmEndname = list(self.overlayLabelsItems.keys())[0] + contoursItem = self.overlayLabelsItems[segmEndname][1] + win = apps.OverlayLabelsAppearanceDialog( + scatterPlotItem=contoursItem, parent=self + ) + win.exec_() + if win.cancel: + return + + brush = win.properties['brush'] + pen = win.properties['pen'] + for items in self.overlayLabelsItems.values(): + imageItem, contoursItem, gradItem = items + contoursItem.setBrush(brush, update=False) + contoursItem.setPen(pen) + + def createOverlayLabelsItems(self, segmEndnames): + selectActionGroup = QActionGroup(self) + segmEndnames_extended = list(segmEndnames.copy()) + segmEndnames_extended = ['combined segm.'] + segmEndnames_extended + for segmEndname in segmEndnames_extended: + action = QAction(segmEndname) + if segmEndname == 'combined segm.': + action.setCheckable(False) + else: + action.setCheckable(True) + action.toggled.connect(self.setOverlayLabelsItemsVisible) + selectActionGroup.addAction(action) + self.selectOverlayLabelsActionGroup = selectActionGroup + + self.overlayLabelsItems = {} + for segmEndname in segmEndnames_extended: + imageItem = pg.ImageItem() + + gradItem = widgets.overlayLabelsGradientWidget( + imageItem, selectActionGroup, segmEndname + ) + gradItem.hide() + gradItem.drawModeActionGroup.triggered.connect( + self.overlayLabelsDrawModeToggled + ) + self.mainLayout.addWidget(gradItem, 0, 0) + + contoursItem = pg.ScatterPlotItem() + color = colors.get_complementary_color(self.contLineColor) + r, g, b, a = colors.rgba_str_to_values(color) + qcolor = QColor(r, g, b, a) + contoursItem.setData( + [], [], symbol='s', pxMode=False, size=self.contLineWeight*2, + brush=pg.mkBrush(color=qcolor), + pen=pg.mkPen(width=3, color=qcolor), tip=None + ) + + items = (imageItem, contoursItem, gradItem) + self.overlayLabelsItems[segmEndname] = items + + def addOverlayLabelsToggled(self, checked, name=None): + if name is None: + name = self.sender().text() + if checked: + gradItem = self.overlayLabelsItems[name][-1] + drawMode = gradItem.drawModeActionGroup.checkedAction().text() + self.drawModeOverlayLabelsChannels[name] = drawMode + else: + self.drawModeOverlayLabelsChannels.pop(name) + self.hideOverlayLabelsItems(specific=[name]) + self.setOverlayLabelsItems() + + def overlayLabelsDrawModeToggled(self, action): + segmEndname = action.segmEndname + drawMode = action.text() + if segmEndname in self.drawModeOverlayLabelsChannels: + self.drawModeOverlayLabelsChannels[segmEndname] = drawMode + self.setOverlayLabelsItems() + + def overlayChannelToggled(self, checked): + # Action toggled from overlayButton context menu + channelName = self.sender().text() + posData = self.data[self.pos_i] + if checked: + if channelName not in posData.loadedFluoChannels: + self.loadOverlayData([channelName], addToExisting=True) + else: + _, filename = self.getPathFromChName(channelName, posData) + posData.ol_data[filename] = ( + posData.ol_data_dict[filename].copy() + ) + + self.checkedOverlayChannels.add(channelName) + else: + self.checkedOverlayChannels.remove(channelName) + imageItem = self.overlayLayersItems[channelName][0] + imageItem.clear() + + self.setOverlayChannelsToolbuttonsChecked() + self.setOverlayItemsVisible() + self.setRetainSizePolicyLutItems() + self.updateAllImages() + + @exception_handler + def loadDataWorkerDataIntegrityWarning(self, pos_foldername): + err_msg = ( + 'WARNING: Segmentation mask file ("..._segm.npz") not found. ' + 'You could run segmentation module first.' + ) + self.workerProgress(err_msg, 'INFO') + self.titleLabel.setText(err_msg, color='r') + abort = False + msg = widgets.myMessageBox(parent=self) + warn_msg = html_utils.paragraph(f""" + The folder {pos_foldername} does not contain a + pre-computed segmentation mask.

+ You can continue with a blank mask or cancel and + pre-compute the mask with the segmentation module.

+ Do you want to continue? + """) + msg.setIcon(iconName='SP_MessageBoxWarning') + msg.setWindowTitle('Segmentation file not found') + msg.addText(warn_msg) + msg.addButton('Ok') + continueWithBlankSegm = msg.addButton(' Cancel ') + msg.show(block=True) + if continueWithBlankSegm == msg.clickedButton: + abort = True + self.loadDataWorker.abort = abort + self.loadDataWaitCond.wakeAll() + + def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): + total_ram = myutils._bytes_to_GB(total_ram) + available_ram = myutils._bytes_to_GB(available_ram) + required_ram = myutils._bytes_to_GB(required_ram) + required_perc = round(100*required_ram/available_ram) + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + The total amount of data that you requested to load is about + {required_ram:.2f} GB ({required_perc}% of the available memory) + but there are only {available_ram:.2f} GB available.

+ For optimal operation, we recommend loading maximum 30% + of the available memory. To do so, try to close open apps to + free up some memory. Another option is to crop the images + using the data prep module.

+ If you choose to continue, the system might freeze + or your OS could simply kill the process.

+ What do you want to do? + """) + cancelButton, continueButton = msg.warning( + self, 'Memory not sufficient', txt, + buttonsTexts=('Cancel', 'Continue anyway') + ) + if msg.clickedButton == continueButton: + # Disable autosaving since it would keep a copy of the data and + # we cannot afford it with low memory + self.autoSaveToggle.setChecked(False) + return True + else: + return False + + def checkMemoryRequirements(self, required_ram): + memory = psutil.virtual_memory() + total_ram = memory.total + available_ram = memory.available + if required_ram/available_ram > 0.3: + proceed = self.warnMemoryNotSufficient( + total_ram, available_ram, required_ram + ) + return proceed + else: + return True + + def criticalImgPathNotFound(self, images_path): + self.logger.info( + 'The following folder does not contain valid image files: ' + f'"{images_path}"\n\n' + 'Check that all the positions loaded contain the same channel name. ' + 'Make sure to double check for spelling mistakes or types in the ' + 'channel names.' + ) + msg = widgets.myMessageBox() + msg.addShowInFileManagerButton(images_path) + err_msg = html_utils.paragraph(f""" + The folder

+ {images_path}

+ does not contain any valid image file!

+ Valid file formats are .h5, .tif, _aligned.h5, _aligned.npz. + """) + okButton = msg.critical( + self, 'No valid files found!', err_msg, buttonsTexts=('Ok',) + ) + + def initRealTimeTracker(self, force=False): + for rtTrackerAction in self.trackingAlgosGroup.actions(): + if rtTrackerAction.isChecked(): + break + + aliases = myutils.aliases_real_time_trackers(reverse=True) + + rtTracker = rtTrackerAction.text() + rtTracker_txt = rtTracker + + if rtTracker in aliases: + rtTracker = aliases[rtTracker] + + if rtTracker == 'Cell-ACDC': + return + if rtTracker == 'YeaZ': + return + + if self.isRealTimeTrackerInitialized and not force: + return + + self.logger.info(f'Initializing {rtTracker_txt} tracker...') + self._rtTrackerName = rtTracker + posData = self.data[self.pos_i] + realTimeTracker, track_frame_params = myutils.init_tracker( + posData, rtTracker, qparent=self, realTime=True + ) + if realTimeTracker is None: + self.logger.info(f'{rtTracker} tracker initialization cancelled.') + return + + self.realTimeTracker = realTimeTracker + self.track_frame_params = track_frame_params + self.logger.info(f'{rtTracker} tracker successfully initialized.') + if 'image_channel_name' in self.track_frame_params: + # Remove the channel name since it was already loaded in init_tracker + del self.track_frame_params['image_channel_name'] + + def initFluoData(self): + if len(self.ch_names) <= 1: + return + + if 'ask_load_fluo_at_init' in self.df_settings.index: + if self.df_settings.at['ask_load_fluo_at_init', 'value'] == 'No': + return + msg = widgets.myMessageBox(allowClose=False) + txt = ( + 'Do you also want to load fluorescence images?
' + 'You can load as many channels as you want.

' + 'If you load fluorescence images then the software will ' + 'calculate metrics for each loaded fluorescence channel ' + 'such as min, max, mean, quantiles, etc. ' + 'of each segmented object.

' + 'NOTE: You can always load them later from the menu ' + 'File --> Load fluorescence images... or when you set ' + 'measurements from the menu ' + 'Measurements --> Set measurements...' + ) + msg.addDoNotShowAgainCheckbox(text="Don't ask again") + no, yes = msg.question( + self, 'Load fluorescence images?', html_utils.paragraph(txt), + buttonsTexts=('No', 'Yes') + ) + if msg.doNotShowAgainCheckbox.isChecked(): + self.df_settings.at['ask_load_fluo_at_init', 'value'] = 'No' + self.df_settings.to_csv(self.settings_csv_path) + if msg.clickedButton == yes: + self.loadFluo_cb(None) + self.AutoPilotProfile.storeClickMessageBox( + 'Load fluorescence images?', msg.clickedButton.text() + ) + + def getPathFromChName(self, chName, posData): + ls = myutils.listdir(posData.images_path) + endnames = {f[len(posData.basename):]:f for f in ls} + validEnds = ['_aligned.npz', '_aligned.h5', '.h5', '.tif', '.npz'] + for end in validEnds: + files = [ + filename for endname, filename in endnames.items() + if endname == f'{chName}{end}' + ] + if files: + filename = files[0] + break + else: + self.criticalFluoChannelNotFound(chName, posData) + self.app.restoreOverrideCursor() + return None, None + + fluo_path = os.path.join(posData.images_path, filename) + filename, _ = os.path.splitext(filename) + return fluo_path, filename + + def loadPosTriggered(self): + if not self.isDataLoaded: + return + + self.startAutomaticLoadingPos() + + def startAutomaticLoadingPos(self): + self.AutoPilot = autopilot.AutoPilot(self) + self.AutoPilot.execLoadPos() + + def stopAutomaticLoadingPos(self): + if self.AutoPilot is None: + return + + if self.AutoPilot.timer.isActive(): + self.AutoPilot.timer.stop() + self.AutoPilot = None + + def startCcaIntegrityCheckerWorker(self): + if not hasattr(self, 'data'): + return + + if not self.isDataLoaded: + return + + if not self.ccaIntegrCheckerToggle.isChecked(): + return + + ccaCheckerThread = QThread() + self.ccaCheckerMutex = QMutex() + self.ccaCheckerWaitCond = QWaitCondition() + + worker = workers.CcaIntegrityCheckerWorker( + self.ccaCheckerMutex, self.ccaCheckerWaitCond + ) + self.ccaIntegrityCheckerWorker = worker + self.ccaCheckerThread = ccaCheckerThread + + worker.moveToThread(ccaCheckerThread) + worker.finished.connect(ccaCheckerThread.quit) + worker.finished.connect(worker.deleteLater) + ccaCheckerThread.finished.connect(ccaCheckerThread.deleteLater) + + worker.sigDone.connect(self.ccaCheckerWorkerDone) + worker.progress.connect(self.workerProgress) + worker.critical.connect(self.ccaIntegrityWorkerCritical) + worker.finished.connect(self.ccaCheckerWorkerClosed) + worker.sigWarning.connect(self.warnCcaIntegrity) + worker.sigFixWillDivide.connect(self.fixWillDivide) + + ccaCheckerThread.started.connect(worker.run) + ccaCheckerThread.start() + + self.ccaCheckerRunning = True + + self.initCcaIntegrityChecker() + + self.logger.info('Cell cycle annotations integrity checker started.') + + def initCcaIntegrityChecker(self): + posData = self.data[self.pos_i] + for frame_i, data_frame_i in enumerate(posData.allData_li): + lab = data_frame_i['labels'] + if lab is None: + break + + cca_df = self.get_cca_df(frame_i, return_df=True) + self.store_cca_df_checker(posData, frame_i, cca_df) + + self.enqCcaIntegrityChecker() + + def initCcaIntegrityChecker(self): + posData = self.data[self.pos_i] + for frame_i, data_frame_i in enumerate(posData.allData_li): + lab = data_frame_i['labels'] + if lab is None: + break + + cca_df = self.get_cca_df(frame_i, return_df=True) + self.store_cca_df_checker(posData, frame_i, cca_df) + + self.enqCcaIntegrityChecker() + + def disableCcaIntegrityChecker(self): + self.stopCcaIntegrityCheckerWorker() + + def stopCcaIntegrityCheckerWorker(self): + try: + self.ccaIntegrityCheckerWorker._stop() + except Exception as err: + pass + + def loadFluo_cb(self, checked=True, fluo_channels=None): + if fluo_channels is None: + posData = self.data[self.pos_i] + ch_names = [ + ch for ch in self.ch_names if ch != self.user_ch_name + and ch not in posData.loadedFluoChannels + ] + if not ch_names: + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'You already loaded ALL channels.

' + 'To change the overlaid channel ' + 'right-click on the overlay button.' + ) + msg.information(self, 'All channels are loaded', txt) + return False + selectFluo = widgets.QDialogListbox( + 'Select channel to load', + 'Select channel names to load:\n', + ch_names, multiSelection=True, parent=self + ) + selectFluo.exec_() + + if selectFluo.cancel: + return False + + fluo_channels = selectFluo.selectedItemsText + self.AutoPilotProfile.storeLoadedFluoChannels(fluo_channels) + + for p, posData in enumerate(self.data): + # posData.ol_data = None + for fluo_ch in fluo_channels: + fluo_path, filename = self.getPathFromChName(fluo_ch, posData) + if fluo_path is None: + self.criticalFluoChannelNotFound(fluo_ch, posData) + return False + fluo_data, bkgrData = self.load_fluo_data(fluo_path) + if fluo_data is None: + return False + posData.loadedFluoChannels.add(fluo_ch) + + if posData.SizeT == 1: + fluo_data = fluo_data[np.newaxis] + + posData.fluo_data_dict[filename] = fluo_data + posData.fluo_bkgrData_dict[filename] = bkgrData + posData.ol_data_dict[filename] = fluo_data.copy() + + self.overlayButton.setStyleSheet(f'background-color: {GREEN_HEX}') + self.guiTabControl.addChannels([ + posData.user_ch_name, *posData.loadedFluoChannels + ]) + return True + + def labelRoiCancelled(self): + self.labelRoiRunning = False + self.app.restoreOverrideCursor() + self.labelRoiItem.setPos((0,0)) + self.labelRoiItem.setSize((0,0)) + self.freeRoiItem.clear() + self.logger.info('Magic labeller process cancelled.') + + def labelRoiCheckStartStopFrame(self): + if not self.labelRoiTrangeCheckbox.isChecked(): + return True + + start_n = self.labelRoiStartFrameNoSpinbox.value() + stop_n = self.labelRoiStopFrameNoSpinbox.value() + if start_n <= stop_n: + return True + + self.blinker = qutils.QControlBlink( + self.labelRoiStopFrameNoSpinbox, + qparent=self + ) + self.blinker.start() + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + Stop frame number is less than start frame number!

+ What do you want to do? + """) + msg.warning( + self, 'Stop frame number lower than start', txt, + buttonsTexts=('Cancel', 'Segment only current frame') + ) + if msg.cancel: + return False + + posData = self.data[self.pos_i] + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) + self.labelRoiStopFrameNoSpinbox.setValue(posData.frame_i+1) + + def getSecondChannelData(self): + if self.secondChannelName is None: + return + + posData = self.data[self.pos_i] + + fluo_ch = self.secondChannelName + fluo_path, filename = self.getPathFromChName(fluo_ch, posData) + if filename in posData.fluo_data_dict: + fluo_data = posData.fluo_data_dict[filename] + else: + fluo_data, bkgrData = self.load_fluo_data(fluo_path) + posData.fluo_data_dict[filename] = fluo_data + posData.fluo_bkgrData_dict[filename] = bkgrData + + if self.labelRoiTrangeCheckbox.isChecked(): + start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 + stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() + tRangeLen = stop_frame_n-start_frame_i + else: + tRangeLen = 1 + + if tRangeLen > 1: + # fluo_img_data = fluo_data[start_frame_i:stop_frame_n] + if self.isSegm3D or posData.SizeZ == 1: + return fluo_data + else: + T, Z, Y, X = fluo_data.shape + secondChannelData = np.zeros((T, Y, X), dtype=fluo_data.dtype) + for frame_i, fluo_img in enumerate(fluo_data): + secondChannelData[frame_i] = self.get_2Dimg_from_3D( + fluo_data, frame_i=frame_i + ) + return secondChannelData + else: + if posData.SizeT > 1: + fluo_img_data = fluo_data[posData.frame_i] + else: + fluo_img_data = fluo_data + + if self.isSegm3D or posData.SizeZ == 1: + return fluo_img_data + else: + return self.get_2Dimg_from_3D(fluo_img_data) + + def addActionsLutItemContextMenu(self, lutItem): + lutItem.gradient.menu.addSection('Visible channels: ') + for action in self.overlayContextMenu.actions(): + if action.isSeparator(): + continue + lutItem.gradient.menu.addAction(action) + lutItem.gradient.menu.addSeparator() + + annotationMenu = lutItem.gradient.menu.addMenu('Annotations settings') + ID_menu = annotationMenu.addMenu('IDs') + self.annotSettingsIDmenu = QActionGroup(annotationMenu) + labID_action = QAction("Show label's ID") + labID_action.setCheckable(True) + labID_action.setChecked(True) + labID_action.toggled.connect(self.annotLabelIDtreeToggled) + treeID_action = QAction("Show tree's ID") + treeID_action.setCheckable(True) + treeID_action.toggled.connect(self.annotLabelIDtreeToggled) + self.annotSettingsIDmenu.addAction(labID_action) + self.annotSettingsIDmenu.addAction(treeID_action) + ID_menu.addAction(labID_action) + ID_menu.addAction(treeID_action) + + ID_menu = annotationMenu.addMenu('Generation number') + self.annotSettingsGenNumMenu = QActionGroup(annotationMenu) + gen_num_action = QAction("Show default generation number") + gen_num_action.setCheckable(True) + gen_num_action.setChecked(True) + gen_num_action.toggled.connect(self.annotGenNumTreeToggled) + tree_gen_num_action = QAction("Show tree generation number") + tree_gen_num_action.setCheckable(True) + tree_gen_num_action.toggled.connect(self.annotGenNumTreeToggled) + self.annotSettingsGenNumMenu.addAction(gen_num_action) + self.annotSettingsGenNumMenu.addAction(tree_gen_num_action) + ID_menu.addAction(gen_num_action) + ID_menu.addAction(tree_gen_num_action) + + def annotGenNumTreeToggled(self, checked): + self.textAnnot[0].setGenNumTreeAnnotationsEnabled(checked) + + def annotLabelIDtreeToggled(self, checked): + self.textAnnot[0].setLabelTreeAnnotationsEnabled(checked) + + def setAnnotInfoMode(self, checked): + if checked: + for action in self.annotSettingsIDmenu.actions(): + if action.text().find('tree') != -1: + self.textAnnot[0].setLabelTreeAnnotationsEnabled(True) + action.setChecked(True) + break + for action in self.annotSettingsGenNumMenu.actions(): + if action.text().find('tree') != -1: + self.textAnnot[0].setGenNumTreeAnnotationsEnabled(True) + action.setChecked(True) + break + else: + for action in self.annotSettingsIDmenu.actions(): + if action.text().find('tree') == -1: + action.setChecked(False) + self.textAnnot[0].setLabelTreeAnnotationsEnabled(False) + break + for action in self.annotSettingsGenNumMenu.actions(): + if action.text().find('tree') == -1: + action.setChecked(False) + self.textAnnot[0].setGenNumTreeAnnotationsEnabled(False) + break + self.setAllTextAnnotations() + + def annotOptionClicked(self, clicked=True, sender=None, saveSettings=True): + if sender is None: + sender = self.sender() + # First manually set exclusive with uncheckable + clickedIDs = sender == self.annotIDsCheckbox + clickedCca = sender == self.annotCcaInfoCheckbox + clickedMBline = sender == self.drawMothBudLinesCheckbox + if self.annotIDsCheckbox.isChecked() and clickedIDs: + if self.annotCcaInfoCheckbox.isChecked(): + self.annotCcaInfoCheckbox.setChecked(False) + if self.drawMothBudLinesCheckbox.isChecked(): + self.drawMothBudLinesCheckbox.setChecked(False) + + if self.annotCcaInfoCheckbox.isChecked() and clickedCca: + if self.annotIDsCheckbox.isChecked(): + self.annotIDsCheckbox.setChecked(False) + if self.drawMothBudLinesCheckbox.isChecked(): + self.drawMothBudLinesCheckbox.setChecked(False) + + if self.drawMothBudLinesCheckbox.isChecked() and clickedMBline: + if self.annotIDsCheckbox.isChecked(): + self.annotIDsCheckbox.setChecked(False) + if self.annotCcaInfoCheckbox.isChecked(): + self.annotCcaInfoCheckbox.setChecked(False) + + clickedCont = sender == self.annotContourCheckbox + clickedSegm = sender == self.annotSegmMasksCheckbox + if self.annotContourCheckbox.isChecked() and clickedCont: + if self.annotSegmMasksCheckbox.isChecked(): + self.annotSegmMasksCheckbox.setChecked(False) + + if self.annotSegmMasksCheckbox.isChecked() and clickedSegm: + if self.annotContourCheckbox.isChecked(): + self.annotContourCheckbox.setChecked(False) + + clickedDoNot = sender == self.drawNothingCheckbox + if clickedDoNot: + self.annotIDsCheckbox.setChecked(False) + self.annotCcaInfoCheckbox.setChecked(False) + self.annotContourCheckbox.setChecked(False) + self.annotSegmMasksCheckbox.setChecked(False) + self.drawMothBudLinesCheckbox.setChecked(False) + self.annotNumZslicesCheckbox.setChecked(False) + else: + self.drawNothingCheckbox.setChecked(False) + + if sender == self.annotNumZslicesCheckbox: + self.annotIDsCheckbox.setChecked(True) + self.drawNothingCheckbox.setChecked(False) + + self.setDrawAnnotComboboxText(saveSettings=saveSettings) + + def setDisabledAnnotCheckBoxesLeft(self, disabled): + self.annotIDsCheckbox.setDisabled(disabled) + self.annotCcaInfoCheckbox.setDisabled(disabled) + self.annotContourCheckbox.setDisabled(disabled) + self.annotSegmMasksCheckbox.setDisabled(disabled) + self.drawMothBudLinesCheckbox.setDisabled(disabled) + self.annotNumZslicesCheckbox.setDisabled(disabled) + self.drawNothingCheckbox.setDisabled(disabled) + + def setEnabledAnnotCheckBoxesLeftZdepthAxes(self): + if not self.isSegm3D: + return + + self.annotIDsCheckbox.setDisabled(False) + self.annotContourCheckbox.setDisabled(False) + self.annotIDsCheckbox.setChecked(True) + self.annotContourCheckbox.setChecked(True) + + self.annotOptionClicked( + sender=self.annotIDsCheckbox, saveSettings=False) + + def setDisabledAnnotCheckBoxesRight(self, disabled): + self.annotIDsCheckboxRight.setDisabled(disabled) + self.annotCcaInfoCheckboxRight.setDisabled(disabled) + self.annotContourCheckboxRight.setDisabled(disabled) + self.annotSegmMasksCheckboxRight.setDisabled(disabled) + self.drawMothBudLinesCheckboxRight.setDisabled(disabled) + self.annotNumZslicesCheckboxRight.setDisabled(disabled) + self.drawNothingCheckboxRight.setDisabled(disabled) + + def annotOptionClickedRight( + self, clicked=True, sender=None, saveSettings=True + ): + if sender is None: + sender = self.sender() + # First manually set exclusive with uncheckable + clickedIDs = sender == self.annotIDsCheckboxRight + clickedCca = sender == self.annotCcaInfoCheckboxRight + clickedMBline = sender == self.drawMothBudLinesCheckboxRight + if self.annotIDsCheckboxRight.isChecked() and clickedIDs: + if self.annotCcaInfoCheckboxRight.isChecked(): + self.annotCcaInfoCheckboxRight.setChecked(False) + if self.drawMothBudLinesCheckboxRight.isChecked(): + self.drawMothBudLinesCheckboxRight.setChecked(False) + + if self.annotCcaInfoCheckboxRight.isChecked() and clickedCca: + if self.annotIDsCheckboxRight.isChecked(): + self.annotIDsCheckboxRight.setChecked(False) + if self.drawMothBudLinesCheckboxRight.isChecked(): + self.drawMothBudLinesCheckboxRight.setChecked(False) + + if self.drawMothBudLinesCheckboxRight.isChecked() and clickedMBline: + if self.annotIDsCheckboxRight.isChecked(): + self.annotIDsCheckboxRight.setChecked(False) + if self.annotCcaInfoCheckboxRight.isChecked(): + self.annotCcaInfoCheckboxRight.setChecked(False) + + clickedCont = sender == self.annotContourCheckboxRight + clickedSegm = sender == self.annotSegmMasksCheckboxRight + if self.annotContourCheckboxRight.isChecked() and clickedCont: + if self.annotSegmMasksCheckboxRight.isChecked(): + self.annotSegmMasksCheckboxRight.setChecked(False) + + if self.annotSegmMasksCheckboxRight.isChecked() and clickedSegm: + if self.annotContourCheckboxRight.isChecked(): + self.annotContourCheckboxRight.setChecked(False) + + clickedDoNot = sender == self.drawNothingCheckboxRight + if clickedDoNot: + self.annotIDsCheckboxRight.setChecked(False) + self.annotCcaInfoCheckboxRight.setChecked(False) + self.annotContourCheckboxRight.setChecked(False) + self.annotSegmMasksCheckboxRight.setChecked(False) + self.drawMothBudLinesCheckboxRight.setChecked(False) + self.annotNumZslicesCheckboxRight.setChecked(False) + else: + self.drawNothingCheckboxRight.setChecked(False) + + if sender == self.annotNumZslicesCheckboxRight: + self.annotIDsCheckboxRight.setChecked(True) + self.drawNothingCheckboxRight.setChecked(False) + + self.setDrawAnnotComboboxTextRight(saveSettings=saveSettings) + + def setAnnotOptionsCcaMode(self): + self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( + return_value=True + ) + self.annotCcaInfoCheckbox.setChecked(True) + self.annotIDsCheckbox.setChecked(False) + self.drawMothBudLinesCheckbox.setChecked(False) + self.setDrawAnnotComboboxText() + + def setAnnotOptionsLin_treeMode(self): + # self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( + # return_value=True + # ) + self.annotCcaInfoCheckbox.setChecked(True) + self.annotIDsCheckbox.setChecked(False) + self.drawMothBudLinesCheckbox.setChecked(False) + self.setDrawAnnotComboboxText() + self.showTreeInfoCheckbox.setChecked(True) + + def setDrawAnnotComboboxText(self, saveSettings=True): + if self.annotIDsCheckbox.isChecked(): + if self.annotContourCheckbox.isChecked(): + t = 'Draw IDs and contours' + elif self.annotSegmMasksCheckbox.isChecked(): + t = 'Draw IDs and overlay segm. masks' + else: + t = 'Draw only IDs' + + elif self.annotCcaInfoCheckbox.isChecked(): + if self.annotContourCheckbox.isChecked(): + t = 'Draw cell cycle info and contours' + elif self.annotSegmMasksCheckbox.isChecked(): + t = 'Draw cell cycle info and overlay segm. masks' + else: + t = 'Draw only cell cycle info' + + elif self.annotSegmMasksCheckbox.isChecked(): + t = 'Draw only overlay segm. masks' + + elif self.annotContourCheckbox.isChecked(): + t = 'Draw only contours' + + elif self.drawMothBudLinesCheckbox.isChecked(): + t = 'Draw only mother-bud lines' + + elif self.drawNothingCheckbox.isChecked(): + t = 'Draw nothing' + else: + t = 'Draw nothing' + + if t == self.drawIDsContComboBox.currentText(): + self.drawIDsContComboBox_cb(0) + + self.drawIDsContComboBox.saveSettings = saveSettings + self.drawIDsContComboBox.setCurrentText(t) + + def setDrawAnnotComboboxTextRight(self, saveSettings=True): + if self.annotIDsCheckboxRight.isChecked(): + if self.annotContourCheckboxRight.isChecked(): + t = 'Draw IDs and contours' + elif self.annotSegmMasksCheckboxRight.isChecked(): + t = 'Draw IDs and overlay segm. masks' + else: + t = 'Draw only IDs' + + elif self.annotCcaInfoCheckboxRight.isChecked(): + if self.annotContourCheckboxRight.isChecked(): + t = 'Draw cell cycle info and contours' + elif self.annotSegmMasksCheckboxRight.isChecked(): + t = 'Draw cell cycle info and overlay segm. masks' + else: + t = 'Draw only cell cycle info' + + elif self.annotSegmMasksCheckboxRight.isChecked(): + t = 'Draw only overlay segm. masks' + + elif self.annotContourCheckboxRight.isChecked(): + t = 'Draw only contours' + + elif self.drawMothBudLinesCheckboxRight.isChecked(): + t = 'Draw only mother-bud lines' + + elif self.drawNothingCheckboxRight.isChecked(): + t = 'Draw nothing' + else: + t = 'Draw nothing' + + if t == self.annotateRightHowCombobox.currentText(): + self.annotateRightHowCombobox_cb(0) + + self.annotateRightHowCombobox.saveSettings = saveSettings + self.annotateRightHowCombobox.setCurrentText(t) + + def getOverlayItems(self, channelName, index): + imageItem = widgets.OverlayImageItem() + imageItem.setOpacity(0.5) + imageItem.channelName = channelName + + lutItem = widgets.myHistogramLUTitem( + parent=self, name='image', axisLabel=channelName + ) + imageItem.lutItem = lutItem + for action in lutItem.rescaleActionGroup.actions(): + if action.text() == self.defaultRescaleIntensHow: + action.setChecked(True) + break + + lutItem.removeAddScaleBarAction() + lutItem.removeAddTimestampAction() + lutItem.restoreState(self.df_settings) + lutItem.setImageItem(imageItem) + lutItem.vb.raiseContextMenu = lambda x: None + initColor = self.overlayColors[channelName] + self.initColormapOverlayLayerItem(initColor, lutItem) + lutItem.addOverlayColorButton(initColor, channelName) + lutItem.initColor = initColor + lutItem.hide() + + lutItem.overlayColorButton.sigColorChanging.connect( + self.changeOverlayColor + ) + lutItem.overlayColorButton.sigColorChanged.connect( + self.saveOverlayColor + ) + + lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW) + + lutItem.contoursColorButton.disconnect() + lutItem.contoursColorButton.clicked.connect( + self.imgGrad.contoursColorButton.click + ) + for act in lutItem.contLineWightActionGroup.actions(): + act.toggled.connect(self.contLineWeightToggled) + + lutItem.mothBudLineColorButton.disconnect() + lutItem.mothBudLineColorButton.clicked.connect( + self.imgGrad.mothBudLineColorButton.click + ) + for act in lutItem.mothBudLineWightActionGroup.actions(): + act.toggled.connect(self.mothBudLineWeightToggled) + + lutItem.textColorButton.disconnect() + lutItem.textColorButton.clicked.connect( + self.editTextIDsColorAction.trigger + ) + + lutItem.defaultSettingsAction.triggered.connect( + self.restoreDefaultSettings + ) + lutItem.labelsAlphaSlider.valueChanged.connect( + self.setValueLabelsAlphaSlider + ) + lutItem.sigRescaleIntes.connect( + partial(self.rescaleIntensitiesLut, imageItem=imageItem) + ) + if f'how_rescale_intensities_{channelName}' in self.df_settings.index: + how = self.df_settings.at[ + f'how_rescale_intensities_{channelName}', 'value' + ] + lutItem.setRescaleIntensitiesHow(how) + + self.rescaleIntensChannelHowMapper[channelName] = ( + 'Rescale each 2D image' + ) + + self.addActionsLutItemContextMenu(lutItem) + + alphaScrollBar = self.addAlphaScrollbar(channelName, imageItem) + + toolbutton = widgets.OverlayChannelToolButton( + channelName, lutItem, shortcut=str(index) + ) + toolbutton.action = self.overlayToolbar.addWidget(toolbutton) + toolbutton.setVisible(False) + + toolbutton.clicked.connect(self.overlayChannelToolbuttonClicked) + + alphaScrollBar.toolbutton = toolbutton + + return imageItem, lutItem, alphaScrollBar, toolbutton + + def addAlphaScrollbar(self, channelName, imageItem): + alphaScrollBar = widgets.ScrollBar(Qt.Horizontal) + imageItem.alphaScrollBar = alphaScrollBar + alphaScrollBar.channelName = channelName + + label = QLabel(f'Alpha {channelName}') + label.setFont(_font) + label.hide() + alphaScrollBar.imageItem = imageItem + alphaScrollBar.label = label + alphaScrollBar.setFixedHeight(self.h) + alphaScrollBar.hide() + alphaScrollBar.setMinimum(0) + alphaScrollBar.setMaximum(40) + alphaScrollBar.setValue(20) + alphaScrollBar.setToolTip( + f'Control the alpha value of the overlaid channel {channelName}.\n' + 'alpha=0 results in NO overlay,\n' + 'alpha=1 results in only fluorescence data visible' + ) + self.bottomLeftLayout.addWidget( + alphaScrollBar.label, self.alphaScrollbarRow, 0, + alignment=Qt.AlignRight + ) + self.bottomLeftLayout.addWidget( + alphaScrollBar, self.alphaScrollbarRow, 1, 1, 2 + ) + + alphaScrollBar.valueChanged.connect( + partial(self.setOpacityOverlayLayersItems, scrollbar=alphaScrollBar) + ) + + self.alphaScrollbarRow += 1 + return alphaScrollBar + + def setValueLabelsAlphaSlider(self, value): + self.imgGrad.labelsAlphaSlider.setValue(value) + self.updateLabelsAlpha(value) + + def setOverlayLabelsItemsVisible(self, checked): + for _segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): + items = self.overlayLabelsItems[_segmEndname] + gradItem = items[-1] + gradItem.hide() + + if checked: + segmEndname = self.sender().text() + gradItem = self.overlayLabelsItems[segmEndname][-1] + gradItem.show() + + def setRetainSizePolicyLutItems(self): + if not self.retainSizeLutItems: + return + for channel, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB = items[:3] + myutils.setRetainSizePolicy(lutItem, retain=True) + QTimer.singleShot(300, self.autoRange) + + def setOverlayChannelsToolbuttonsChecked(self): + for channel, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB, toolbutton = items[:4] + toolbutton.setChecked( + not self.overlayToolbar.isSingleChannel() + and channel in self.checkedOverlayChannels + ) + + def setOverlayItemsVisible(self): + for channel, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB, toolbutton = items[:4] + lutItem.hide() + alphaSB.hide() + alphaSB.label.hide() + toolbutton.setVisible(False) + + if not self.overlayButton.isChecked(): + return + + for channel, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB, toolbutton = items[:4] + if channel in self.checkedOverlayChannels: + lutItem.show() + alphaSB.show() + alphaSB.label.show() + toolbutton.setVisible(True) + + def overlayChannelToolbuttonClicked(self, checked=False, toolbutton=None): + if toolbutton is None: + toolbutton = self.sender() + + n_checked_buttons = ( + sum([b.isChecked() for b in self.allOverlayToolbuttons.values()]) + ) + + channelName = toolbutton.channelName() + + if n_checked_buttons == 0 or self.overlayToolbar.isSingleChannel(): + # At least one button must be checked + toolbutton.setChecked(True) + + if self.overlayToolbar.isSingleChannel(): + # Exclusive buttons + for channel, otherToolbutton in self.allOverlayToolbuttons.items(): + if channel == channelName: + continue + + otherToolbutton.setChecked(False) + + if self.overlayToolbar.isTransparent(): + self.setOverlayImages() + return + + self.setOverlayItemsOpacities() + + def setOverlayItemsOpacities(self): + n_checked_buttons = ( + sum([b.isChecked() for b in self.allOverlayToolbuttons.values()]) + ) + + isSingleChannel = ( + self.overlayToolbar.isSingleChannel() + or n_checked_buttons == 1 + ) + + channel_opacity_mapper = self.getOpacitiesFromAlphaScrollbarValues() + + # Set opacity of every layer accordingly + for channel, otherToolbutton in self.allOverlayToolbuttons.items(): + if channel == self.user_ch_name: + otherImageItem = self.img1 + alphaScrollbar = None + # alpha_value = channel_opacity_mapper[channel] + else: + otherItems = self.overlayLayersItems[channel] + otherImageItem = otherItems[0] + alphaScrollbar = otherItems[2] + # alpha_value = alphaScrollbar.value()/alphaScrollbar.maximum() + + if otherToolbutton.isChecked() and isSingleChannel: + op_val = 1.0 + elif otherToolbutton.isChecked(): + op_val = channel_opacity_mapper[channel] + else: + op_val = 0.0 + + if op_val == 0: + op_val = 0.01 + + op_val = op_val if op_val < 1.0 else 0.999 + + otherImageItem.setOpacity(op_val, applyToLinked=False) + + if alphaScrollbar is None: + continue + + alphaScrollbar.setDisabled(bool(op_val == 0)) + + def initColormapOverlayLayerItem(self, foregrColor, lutItem): + if self.invertBwAction.isChecked(): + bkgrColor = (255,255,255,255) + else: + bkgrColor = (0,0,0,255) + gradient = colors.get_pg_gradient((bkgrColor, foregrColor)) + lutItem.setGradient(gradient) + + def setOpacityOverlayLayersItems(self, value, imageItem=None, scrollbar=None): + if scrollbar is None: + scrollbar = imageItem.alphaScrollBar + + channel = scrollbar.channelName + toolbutton = self.allOverlayToolbuttons[channel] + if not toolbutton.isChecked() or not toolbutton.isVisible(): + return + + if value is None: + value = scrollbar.value() + + if imageItem is None: + imageItem = scrollbar.imageItem + alpha = value/scrollbar.maximum() + elif value > 1: + alpha = value/scrollbar.maximum() + else: + alpha = value + + alpha_values = [] + activeOverlayImageItems = [] + for items in self.overlayLayersItems.values(): + imgItem, lutItem, alphaSB = items[:3] + _toolbutton = alphaSB.toolbutton + if alphaSB.channelName == channel: + alpha_values.append(alpha) + elif not _toolbutton.isChecked() or not _toolbutton.isVisible(): + continue + else: + alpha_values.append(alphaSB.value()/alphaSB.maximum()) + + activeOverlayImageItems.append(imgItem) + + opacities = colors.hierarchical_weights(alpha_values)[::-1] + + for i, imgItem in enumerate(activeOverlayImageItems): + imgItem.setOpacity(opacities[i+1]) + + self.img1.setOpacity(opacities[0], applyToLinked=False) + + def showInExplorer_cb(self): + posData = self.data[self.pos_i] + path = posData.images_path + myutils.showInExplorer(path) + + def zSliceAbsent(self, filename, posData): + self.app.restoreOverrideCursor() + SizeZ = posData.SizeZ + chNames = posData.chNames + filenamesPresent = posData.segmInfo_df.index.get_level_values(0).unique() + chNamesPresent = [ + ch for ch in chNames + for file in filenamesPresent + if file.endswith(ch) or file.endswith(f'{ch}_aligned') + ] + win = apps.QDialogZsliceAbsent(filename, SizeZ, chNamesPresent) + win.exec_() + if win.cancel: + self.worker.abort = True + self.waitCond.wakeAll() + return + if win.useMiddleSlice: + user_ch_name = filename[len(posData.basename):] + for _posData in self.data: + if _posData is None: + continue + _, filename = self.getPathFromChName(user_ch_name, _posData) + df = myutils.getDefault_SegmInfo_df(_posData, filename) + _posData.segmInfo_df = pd.concat([df, _posData.segmInfo_df]) + unique_idx = ~_posData.segmInfo_df.index.duplicated() + _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] + _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) + elif win.useSameAsCh: + user_ch_name = filename[len(posData.basename):] + for _posData in self.data: + if _posData is None: + continue + _, srcFilename = self.getPathFromChName( + win.selectedChannel, _posData + ) + cellacdc_df = _posData.segmInfo_df.loc[srcFilename].copy() + _, dstFilename = self.getPathFromChName(user_ch_name, _posData) + if dstFilename is None: + self.worker.abort = True + self.waitCond.wakeAll() + return + dst_df = myutils.getDefault_SegmInfo_df(_posData, dstFilename) + for z_info in cellacdc_df.itertuples(): + frame_i = z_info.Index + zProjHow = z_info.which_z_proj + if zProjHow == 'single z-slice': + src_idx = (srcFilename, frame_i) + if _posData.segmInfo_df.at[src_idx, 'resegmented_in_gui']: + col = 'z_slice_used_gui' + else: + col = 'z_slice_used_dataPrep' + z_slice = _posData.segmInfo_df.at[src_idx, col] + dst_idx = (dstFilename, frame_i) + dst_df.at[dst_idx, 'z_slice_used_dataPrep'] = z_slice + dst_df.at[dst_idx, 'z_slice_used_gui'] = z_slice + _posData.segmInfo_df = pd.concat([dst_df, _posData.segmInfo_df]) + unique_idx = ~_posData.segmInfo_df.index.duplicated() + _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] + _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) + elif win.runDataPrep: + user_ch_file_paths = [] + user_ch_name = filename[len(self.data[self.pos_i].basename):] + for _posData in self.data: + if _posData is None: + continue + user_ch_path = load.get_filename_from_channel( + _posData.images_path, user_ch_name + ) + if user_ch_path is None: + self.worker.abort = True + self.waitCond.wakeAll() + return + user_ch_file_paths.append(user_ch_path) + exp_path = os.path.dirname(_posData.pos_path) + + dataPrepWin = dataPrep.dataPrepWin() + dataPrepWin.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + dataPrepWin.titleText = ( + """ + Select z-slice (or projection) for each frame/position.
+ Once happy, close the window. + """) + dataPrepWin.show() + dataPrepWin.initLoading() + dataPrepWin.SizeT = self.data[0].SizeT + dataPrepWin.SizeZ = self.data[0].SizeZ + dataPrepWin.metadataAlreadyAsked = True + self.logger.info(f'Loading channel {user_ch_name} data...') + dataPrepWin.loadFiles( + exp_path, user_ch_file_paths, user_ch_name + ) + dataPrepWin.startAction.setDisabled(True) + dataPrepWin.onlySelectingZslice = True + + loop = QEventLoop(self) + dataPrepWin.loop = loop + loop.exec_() + + self.waitCond.wakeAll() + + def showSetMeasurements(self, checked=False, qparent=None): + qparent = qparent if qparent is not None else self + if self.measurementsWin is not None: + self.measurementsWin.show() + self.measurementsWin.raise_() + self.measurementsWin.activateWindow() + return + + try: + df_favourite_funcs = pd.read_csv(favourite_func_metrics_csv_path) + favourite_funcs = df_favourite_funcs['favourite_func_name'].to_list() + except Exception as e: + favourite_funcs = None + + posData = self.data[self.pos_i] + allPos_acdc_df_cols = set() + for _posData in self.data: + for frame_i, data_dict in enumerate(_posData.allData_li): + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + continue + + allPos_acdc_df_cols.update(acdc_df.columns) + loadedChNames = posData.setLoadedChannelNames(returnList=True) + posData.fluo_data_dict.pop(self.user_ch_name, None) + if self.user_ch_name not in loadedChNames: + loadedChNames.insert(0, self.user_ch_name) + notLoadedChNames = [c for c in self.ch_names if c not in loadedChNames] + self.notLoadedChNames = notLoadedChNames + self.measurementsWin = apps.SetMeasurementsDialog( + loadedChNames, notLoadedChNames, posData.SizeZ > 1, self.isSegm3D, + favourite_funcs=favourite_funcs, + allPos_acdc_df_cols=list(allPos_acdc_df_cols), + acdc_df_path=posData.images_path, posData=posData, + addCombineMetricCallback=self.addCombineMetric, + allPosData=self.data, + parent=qparent, + state=self.setMeasWinState + ) + self.measurementsWin.sigCancel.connect(self.setMeasurementsCancelled) + self.measurementsWin.sigClosed.connect(self.setMeasurements) + self.measurementsWin.show() + + def setMeasurementsCancelled(self): + self.measurementsWin = None + + def setMeasurements(self): + posData = self.data[self.pos_i] + if self.measurementsWin.delExistingCols: + self.logger.info('Removing existing unchecked measurements...') + delCols = self.measurementsWin.existingUncheckedColnames + delRps = self.measurementsWin.existingUncheckedRps + delCols_format = [f' * {colname}' for colname in delCols] + delRps_format = [f' * {colname}' for colname in delRps] + delCols_format.extend(delRps_format) + delCols_format = '\n'.join(delCols_format) + self.logger.info(delCols_format) + for _posData in self.data: + for frame_i, data_dict in enumerate(_posData.allData_li): + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + continue + + acdc_df = acdc_df.drop(columns=delCols, errors='ignore') + for col_rp in delRps: + drop_df_rp = acdc_df.filter(regex=fr'{col_rp}.*', axis=1) + drop_cols_rp = drop_df_rp.columns + acdc_df = acdc_df.drop(columns=drop_cols_rp, errors='ignore') + _posData.allData_li[frame_i]['acdc_df'] = acdc_df + self.setMeasWinState = self.measurementsWin.state() + self.logger.info('Setting measurements...') + self._setMetrics(self.measurementsWin) + self.logger.info('Metrics successfully set.') + self.measurementsWin = None + + def _setMetrics(self, measurementsWin): + self._measurements_kernel.set_metrics_from_set_measurements_dialog( + measurementsWin + ) + for ch in self._measurements_kernel.chNamesToProcess: + if ch not in self.notLoadedChNames: + continue + + success = self.loadFluo_cb(fluo_channels=[ch]) + if not success: + continue + + def addCustomMetric(self, checked=False): + txt = measurements.add_metrics_instructions() + metrics_path = measurements.metrics_path + msg = widgets.myMessageBox() + msg.addShowInFileManagerButton(metrics_path, 'Show example...') + title = 'Add custom metrics instructions' + msg.information(self, title, txt, buttonsTexts=('Ok',)) + + def addCombineMetric(self): + posData = self.data[self.pos_i] + isZstack = posData.SizeZ > 1 + win = apps.combineMetricsEquationDialog( + self.ch_names, isZstack, self.isSegm3D, parent=self + ) + win.sigOk.connect(self.saveCombineMetricsToPosData) + win.exec_() + win.sigOk.disconnect() + + def saveCombineMetricsToPosData(self, window): + for posData in self.data: + equationsDict, isMixedChannels = window.getEquationsDict() + for newColName, equation in equationsDict.items(): + posData.addEquationCombineMetrics( + equation, newColName, isMixedChannels + ) + posData.saveCombineMetrics() + + if self.measurementsWin is None: + return + + self.measurementsWinState = self.measurementsWin.state() + self.measurementsWin.close() + self.showSetMeasurements() + self.measurementsWin.restoreState(self.measurementsWinState) + + def labelRoiToEndFramesTriggered(self): + posData = self.data[self.pos_i] + self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) + + def labelRoiFromCurrentFrameTriggered(self): + posData = self.data[self.pos_i] + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) + + def labelRoiViewCurrentModel(self): + from . import config + ini_path = os.path.join( + settings_folderpath, 'last_params_segm_models.ini' + ) + configPars = config.ConfigParser() + configPars.read(ini_path) + model_name = self.labelRoiModel.model_name + txt = f'Model: {model_name}' + SECTION = f'{model_name}.init' + txt = f'{txt}

[Initialization parameters]
' + for option in configPars.options(SECTION): + value = configPars[SECTION][option] + param_txt = f'{option} = {value}
' + txt = f'{txt}{param_txt}' + + SECTION = f'{model_name}.segment' + txt = f'{txt}
[Segmentation parameters]
' + for option in configPars.options(SECTION): + value = configPars[SECTION][option] + param_txt = f'{option} = {value}
' + txt = f'{txt}{param_txt}' + + win = apps.ViewTextDialog(txt, parent=self) + win.exec_() + + def setMetricsFunc(self): + posData = self.data[self.pos_i] + self._measurements_kernel._set_metrics_func_from_posData(posData) + + def getLastTrackedFrame(self, posData): + last_tracked_i = 0 + for frame_i, data_dict in enumerate(posData.allData_li): + lab = data_dict['labels'] + if lab is None: + frame_i -= 1 + break + if frame_i > 0: + return frame_i + else: + return last_tracked_i + + def computeVolumeRegionprop(self): + if 'cell_vol_vox' not in self._measurements_kernel.sizeMetricsToSave: + return + + # We compute the cell volume in the main thread because calling + # skimage.transform.rotate in a separate thread causes crashes + # with segmentation fault on macOS. I don't know why yet. + self.logger.info('Computing cell volume...') + end_i = self.save_until_frame_i + pos_iter = tqdm(self.data, ncols=100) + for p, posData in enumerate(pos_iter): + if self.posToSave is not None: + if posData.pos_foldername not in self.posToSave: + continue + + PhysicalSizeY = posData.PhysicalSizeY + PhysicalSizeX = posData.PhysicalSizeX + frame_iter = tqdm( + posData.allData_li[:end_i+1], ncols=100, position=1, leave=False + ) + for frame_i, data_dict in enumerate(frame_iter): + lab = data_dict['labels'] + if lab is None: + break + rp = data_dict['regionprops'] + obj_iter = tqdm(rp, ncols=100, position=2, leave=False) + for i, obj in enumerate(obj_iter): + vol_vox, vol_fl = _calc_rot_vol( + obj, PhysicalSizeY, PhysicalSizeX + ) + obj.vol_vox = vol_vox + obj.vol_fl = vol_fl + posData.allData_li[frame_i]['regionprops'] = rp + + def askSaveOriginalSegm(self, isQuickSave=False): + if isQuickSave: + return "", True, True + + posData = self.data[self.pos_i] + if not posData.whitelist: + return "", True, True + + help_txt = html_utils.paragraph(f""" + You have whitelisted IDs in the current position.
+ Do you want to save the not whitelisted segmentation data
+ This will allow you to revisit the original segmentation.
+ """) + + txt = html_utils.paragraph(f""" + You have whitelisted IDs in the current position.
+ Do you want to save the not whitelisted segmentation data?
+ """) + + found_files = load.get_segm_files(posData.images_path) + existingEndnames = load.get_endnames( + posData.basename, found_files + ) + + segmFilename = os.path.basename(posData.segm_npz_path) + segmFilename = f"{segmFilename[:-4]}_not_whitelisted" + win = apps.filenameDialog( + basename=posData.basename, + hintText=txt, + defaultEntry=segmFilename, + existingNames=existingEndnames, + helpText=help_txt, + allowEmpty=False, + parent=self, + title='Save not whitelisted segmentation data', + addDoNotSaveButton=True + ) + win.exec_() + if win.cancel: + return "", False, True + if win.doNotSave: + return "", True, True + return win.entryText, True, False + + def askSaveLastVisitedCcaMode(self, isQuickSave=False): + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + frame_i = 0 + last_tracked_i = 0 + self.save_until_frame_i = 0 + if self.isSnapshot: + return True + + for frame_i, data_dict in enumerate(posData.allData_li): + lab = data_dict['labels'] + if lab is None: + frame_i -= 1 + break + + self.save_until_frame_i = frame_i + self.save_cca_until_frame_i = frame_i + self.last_tracked_i = frame_i + + if isQuickSave: + return True + + last_cca_frame_i = self.navigateScrollBar.maximum()-1 + # Ask to save last visited frame or not + txt = html_utils.paragraph(f""" + You annotated the cell cycle stages up + until frame number {last_cca_frame_i+1}.

+ Enter up to which frame number you want to save the + cell cycle annotations: + """) + lastFrameDialog = apps.QLineEditDialog( + title='Last annoated frame number to save', + defaultTxt=str(last_cca_frame_i+1), + msg=txt, parent=self, allowedValues=(1, last_cca_frame_i+1), + warnLastFrame=True, isInteger=True, stretchEntry=False, + lastVisitedFrame=last_cca_frame_i+1, + ) + lastFrameDialog.exec_() + if lastFrameDialog.cancel: + return False + + last_save_cca_frame_i = lastFrameDialog.enteredValue - 1 + + if last_save_cca_frame_i < last_cca_frame_i: + self.resetCcaFuture(last_cca_frame_i) + + self.save_cca_until_frame_i = last_save_cca_frame_i + + return True + + def askSaveLastVisitedSegmMode(self, isQuickSave=False): + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + frame_i = 0 + last_tracked_i = 0 + self.save_until_frame_i = 0 + self.save_cca_until_frame_i = 0 + if self.isSnapshot: + return True + + for frame_i, data_dict in enumerate(posData.allData_li): + lab = data_dict['labels'] + if lab is None: + frame_i -= 1 + break + + if isQuickSave: + self.save_until_frame_i = frame_i + self.save_cca_until_frame_i = frame_i + self.last_tracked_i = frame_i + return True + + # Ask to save last visited frame or not + txt = html_utils.paragraph(f""" + You visualised and corrected segmentation and tracking data up + until frame number {frame_i+1}.

+ Enter up to which frame number you want to save data: + """) + lastFrameDialog = apps.QLineEditDialog( + title='Last frame number to save', defaultTxt=str(frame_i+1), + msg=txt, parent=self, allowedValues=(1, posData.SizeT), + warnLastFrame=True, isInteger=True, stretchEntry=False, + lastVisitedFrame=frame_i+1, + ) + lastFrameDialog.exec_() + if lastFrameDialog.cancel: + return False + + self.save_until_frame_i = lastFrameDialog.enteredValue - 1 + self.save_cca_until_frame_i = self.save_until_frame_i + if self.save_until_frame_i > frame_i: + self.logger.info( + f'Storing frames {frame_i+1}-{self.save_until_frame_i+1}...' + ) + current_frame_i = posData.frame_i + # User is requesting to save past the last visited frame --> + # store data as if they were visited + for i in range(frame_i+1, self.save_until_frame_i+1): + posData.frame_i = i + self.get_data() + self.store_data(autosave=False) + + # Go back to current frame + posData.frame_i = current_frame_i + self.get_data() + last_tracked_i = self.save_until_frame_i + + self.last_tracked_i = last_tracked_i + return True + + def askSaveMetrics(self): + txt = html_utils.paragraph( + """ + Do you also want to save the measurements + (e.g., cell volume, mean, amount etc.)?

+ + You can find more information by clicking on the + "Set measurements" button below
+ where you will be able to select which measurements + you want to save.

+ If you already set the measurements and you want to save them click "Yes".

+ + NOTE: Saving metrics might be slow, + we recommend doing it only when you need it.
+ """) + msg = widgets.myMessageBox( + parent=self, resizeButtons=False, wrapText=False + ) + setMeasurementsButton = widgets.setPushButton('Set measurements...') + _, yesButton, noButton, _ = msg.question( + self, 'Save measurements?', txt, + buttonsTexts=('Cancel', 'Yes', 'No', setMeasurementsButton), + showDialog=False + ) + setMeasurementsButton.disconnect() + setMeasurementsButton.clicked.connect( + partial( + self.showSetMeasurements, + qparent=msg, + ) + ) + msg.exec_() + save_metrics = msg.clickedButton == yesButton + return save_metrics, msg.cancel + + def askSelectPos(self, action='to save'): + last_pos = 1 + for p, posData in enumerate(self.data): + acdc_df = posData.allData_li[0]['acdc_df'] + if acdc_df is None: + last_pos = p + break + else: + last_pos = len(self.data) + + items = [posData.pos_foldername for posData in self.data] + selectPosWin = widgets.QDialogListbox( + f'Select Positions {action}', f'Select Positions {action}:\n', + items, multiSelection=True, parent=self, + preSelectedItems=items[:last_pos] + ) + selectPosWin.exec_() + if selectPosWin.cancel: + return + + return selectPosWin.selectedItemsText + + def askPosToSave(self): + return self.askSelectPos() + + def saveMetricsCritical(self, traceback_format): + print('\n====================================') + self.logger.exception(traceback_format) + print('====================================\n') + self.logger.info('Warning: calculating metrics failed see above...') + print('------------------------------') + + msg = widgets.myMessageBox(wrapText=False) + err_msg = html_utils.paragraph(f""" + Error while saving metrics.

+ More details below or in the terminal/console.

+ Note that the error details from this session are also saved + in the file
+ {self.log_path}

+ Please send the log file when reporting a bug, thanks! + Please restart Cell-ACDC, we apologise for any inconvenience.

+ + """) + msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...') + msg.setDetailedText(traceback_format, visible=True) + msg.critical(self, 'Critical error while saving metrics', err_msg) + + self.is_error_state = True + self.waitCond.wakeAll() + + def saveAsData(self, checked=True): + try: + posData = self.data[self.pos_i] + except AttributeError: + return + + existingFilenames = set() + for _posData in self.data: + segm_files = load.get_segm_files(_posData.images_path) + _existingEndnames = load.get_endnames( + _posData.basename, segm_files + ) + existingFilenames.update([ + f'{_posData.basename}{endname}.npz' + for endname in _existingEndnames + ]) + posData = self.data[self.pos_i] + if posData.basename.endswith('_'): + basename = f'{posData.basename}segm' + else: + basename = f'{posData.basename}_segm' + win = apps.filenameDialog( + basename=basename, + hintText='Insert a filename for the segmentation file:
', + existingNames=existingFilenames + ) + win.exec_() + if win.cancel: + return + + for posData in self.data: + posData.setFilePaths(new_endname=win.entryText) + + self.setStatusBarLabel() + self.saveData() + + def startExportToVideoWorker(self, preferences): + self.isExportingVideo = True + self.isTransparent = self.overlayToolbar.isTransparent() + if not self.isTransparent: + # SVG export works only with RGBA not with setOpacity + # --> only true transparency mode can be used + self.overlayToolbar.setTransparent(True) + + self.setDisabled(True) + + self.progressWin = apps.QDialogWorkerProgress( + title='Exporting to video', parent=self.mainWin, + pbarDesc='Exporting to video...' + ) + self.progressWin.show(self.app) + self.exportToVideoStopNavVarNum = preferences['stop_nav_var_num'] + self.numFramesExported = 0 + self.progressWin.mainPbar.setMaximum( + preferences['stop_nav_var_num'] + - preferences['start_nav_var_num'] + 1 + ) + self.exportToVideoPreferences = preferences + + self.store_data() + posData = self.data[self.pos_i] + if self.exportToVideoPreferences['is_timelapse']: + # Go to requested start frame + posData.frame_i = preferences['start_nav_var_num'] - 1 + self.get_data() + self.updateAllImages() + self.exportToVideoNavVarIdxToRestore = posData.frame_i + else: + self.update_z_slice(preferences['start_nav_var_num'] - 1) + self.exportToVideoNavVarIdxToRestore = ( + self.zSliceScrollBar.sliderPosition() + ) + self.exportToVideoCurrentNavVarIdx = ( + preferences['start_nav_var_num'] - 1 + ) + + self.exportToVideoImageExporter = exporters.ImageExporter( + self.ax1, + save_pngs=preferences['save_pngs'], + dpi=preferences['dpi'] + ) + self.exportToVideoExporter = exporters.VideoExporter( + preferences['avi_filepath'], preferences['fps'] + ) + + QTimer.singleShot(200, self.updateAndExportFrame) + + def updateAndExportFrame(self): + didVideoExporterFinish = ( + self.exportToVideoCurrentNavVarIdx + == self.exportToVideoStopNavVarNum + ) + if didVideoExporterFinish: + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.mainPbar.setValue(0) + QTimer.singleShot(50, self.exportingFramesFinished) + return + + posData = self.data[self.pos_i] + if self.exportToVideoPreferences['is_timelapse']: + self.goToFrameNumber(self.exportToVideoCurrentNavVarIdx+1) + else: + self.update_z_slice(self.exportToVideoCurrentNavVarIdx) + + success = self.exportFrame() + if success is None: + self.exportingVideoCritical() + return + + self.exportToVideoCurrentNavVarIdx += 1 + self.progressWin.mainPbar.update(1) + + QTimer.singleShot(50, self.updateAndExportFrame) + + @exception_handler + def exportFrame(self): + nd = self.exportToVideoPreferences['num_digits'] + idx = str(self.exportToVideoCurrentNavVarIdx).zfill(nd) + filename = self.exportToVideoPreferences['filename'] + png_filename = f'{idx}_{filename}.png' + pngs_folderpath = self.exportToVideoPreferences['pngs_folderpath'] + + png_filepath = os.path.join(pngs_folderpath, png_filename) + img_bgr = self.exportToVideoImageExporter.export(png_filepath) + self.exportToVideoExporter.add_frame(img_bgr) + return True + + def exportingVideoCritical(self): + self.setDisabled(False) + + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.logger.info('Exporting video process failed.') + + def exportingFramesFinished(self): + if not self.exportToVideoPreferences['save_pngs']: + self.logger.info('Removing PNGs...') + try: + shutil.rmtree(self.exportToVideoPreferences['pngs_folderpath']) + except Exception as err: + pass + + self.logger.info('Saving video...') + + self.exportToVideoExporter.release() + + # Run ffmpeg new process + conversion_to_mp4_successful = True + if self.exportToVideoPreferences['filepath'].endswith('.mp4'): + try: + self.exportToVideoExporter.avi_to_mp4() + try: + os.remove(self.exportToVideoPreferences['avi_filepath']) + except Exception as err: + pass + except Exception as err: + self.logger.exception(traceback.format_exc()) + self.logger.info( + 'Conversion to MP4 failed. See traceback above.' + ) + conversion_to_mp4_successful = False + self.exportToVideoPreferences['filepath'] = ( + self.exportToVideoExporter._avi_filepath + ) + + self.exportToVideoFinished(conversion_to_mp4_successful) + + def exportToVideoFinished(self, conversion_to_mp4_successful): + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + # Back to current frame + if self.exportToVideoPreferences['is_timelapse']: + posData = self.data[self.pos_i] + posData.frame_i = self.exportToVideoNavVarIdxToRestore + self.get_data() + self.store_data() + self.updateAllImages() + self.navigateScrollBar.setSliderPosition(posData.frame_i+1) + self.navSpinBox.setValue(posData.frame_i+1) + else: + self.update_z_slice(self.exportToVideoNavVarIdxToRestore) + + self.setDisabled(False) + self.isExportingVideo = False + + if not self.isTransparent: + # True transparency mode was activated programmatically + # --> restore what the user had before starting to export + self.overlayToolbar.setTransparent(False) + + prompts.exportToVideoFinished( + self.exportToVideoPreferences, conversion_to_mp4_successful, + qparent=self + ) + + def exportAddScaleBar(self, checked): + self.addScaleBarAction.setChecked(checked) + + def exportToVideoAddTimestamp(self, checked): + self.addTimestampAction.setChecked(checked) + + def askTimelapseOrZslicesVideo(self): + txt = html_utils.paragraph(""" + Do you want to record a video of scrolling through the z-slices or + a Timelapse video? + """) + msg = widgets.myMessageBox(wrapText=False) + _, timelapseButton = msg.question( + self, 'Z-slices or Timelapse video?', txt, + buttonsTexts=('Z-slices', 'Timelapse') + ) + if msg.cancel: + return + + return msg.clickedButton == timelapseButton + + def exportToVideoTriggered(self): + posData = self.data[self.pos_i] + + doTimelapseVideo = posData.SizeT > 1 + if posData.SizeT > 1 and posData.SizeZ > 1: + doTimelapseVideo = self.askTimelapseOrZslicesVideo() + + if doTimelapseVideo is None: + self.logger.info('Export to video process cancelled') + return + + channels = [self.user_ch_name, *self.checkedOverlayChannels] + mode = 'timelapse' if doTimelapseVideo else 'z_slices' + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f'{timestamp}_acdc_exported_{mode}_video' + win = apps.ExportToVideoParametersDialog( + channels, + parent=self, + startFolderpath=posData.pos_path, + startFilename=filename, + startFrameNum=posData.frame_i+1, + SizeT=posData.SizeT, + SizeZ=posData.SizeZ, + isTimelapseVideo=doTimelapseVideo, + isScaleBarPresent=self.addScaleBarAction.isChecked(), + isTimestampPresent=self.addTimestampAction.isChecked(), + rescaleIntensChannelHowMapper=self.rescaleIntensChannelHowMapper + ) + win.sigAddScaleBar.connect(self.exportAddScaleBar) + win.sigAddTimestamp.connect(self.exportToVideoAddTimestamp) + win.sigRescaleIntensLut.connect(self.rescaleIntensExportToVideoDialog) + win.exec_() + if win.cancel: + self.logger.info('Export to video process cancelled') + return + + cancel = _warnings.warnExportToVideo(qparent=self) + if cancel: + self.logger.info('Export to video process cancelled') + return + + self.startExportToVideoWorker(win.selected_preferences) + + def setExportMaskImage(self, viewRange): + if not hasattr(self, 'exportMaskImage'): + self.initExportMaskImage() + else: + self.exportMaskImage[:] = 0 + + xRange, yRange = viewRange + x0, x1 = map(round, xRange) + y0, y1 = map(round, yRange) + + if self.invertBwAction.isChecked(): + self.exportMaskImage[:, :, :3] = 255 + + if x0 > 0: + self.exportMaskImage[:, :x0, 3] = 255 + if x1 < self.exportMaskImage.shape[1]: + self.exportMaskImage[:, x1:, 3] = 255 + if y0 > 0: + self.exportMaskImage[:y0, :, 3] = 255 + if y1 < self.exportMaskImage.shape[0]: + self.exportMaskImage[y1:, :, 3] = 255 + + self.exportMaskImageItem.setImage(self.exportMaskImage) + + def setViewRangeFromExportToImageDialog(self, viewRange, win=None): + xRange, yRange = viewRange + # self.ax1.sigRangeChanged.disconnect(self.viewRangeChanged) + self.ax1.setRange(xRange=xRange, yRange=yRange) + # self.ax1.sigRangeChanged.connect(self.viewRangeChanged) + # self.viewRangeChanged( + # self.ax1.vb, viewRange, updateExportMaskImage=False + # ) + self.setExportMaskImage(viewRange) + + def getZoomIDs(self, viewRange=None): + if viewRange is None: + viewRange = self.ax1.viewRange() + + lab = self.currentLab2D + Y, X = lab.shape + ((xmin, xmax), (ymin, ymax)) = viewRange + if xmin <= 0 and ymin <= 0 and xmax >= X and ymax >= Y: + posData = self.data[self.pos_i] + return None + + xmin = xmin if xmin >= 0 else 0 + ymin = ymin if ymin >= 0 else 0 + xmax = xmax if xmax < X else X + ymax = ymax if ymax < Y else Y + + zoomSlice = ( + slice(round(ymin), round(ymax)), + slice(round(xmin), round(xmax)), + ) + + zoomLab = skimage.segmentation.clear_border(lab[zoomSlice]) + zoomRp = skimage.measure.regionprops(zoomLab) + zoomIDs = [obj.label for obj in zoomRp] + return zoomIDs + + def onSigUpdateCcaTableWindow(self, *args): + if not self.isDataLoaded: + return + + if self.ccaTableWin is None: + return + + viewRange = self.ax1.viewRange() + posData = self.data[self.pos_i] + zoomIDs = self.getZoomIDs(viewRange=viewRange) + + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + @disableWindow + def exportToImage(self, preferences): + filepath = preferences['filepath'] + self.logger.info(f'Saving image to "{filepath}"...') + + if filepath.endswith('.svg'): + exporter = exporters.SVGExporter(self.ax1) + else: + exporter = exporters.ImageExporter(self.ax1, dpi=preferences['dpi']) + exporter.export(filepath) + self.logger.info(f'Image saved.') + + self.setDisabled(False) + self.exportMaskImage[:] = 0 + self.exportMaskImageItem.setImage(self.exportMaskImage) + prompts.exportToImageFinished(filepath, qparent=self) + + def exportToImageTriggered(self): + posData = self.data[self.pos_i] + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f'{timestamp}_acdc_exported_image' + win = apps.ExportToImageParametersDialog( + parent=self, + startFolderpath=posData.pos_path, + startFilename=filename, + startViewRange=self.ax1.viewRange(), + isScaleBarPresent=self.addScaleBarAction.isChecked(), + ) + win.sigAddScaleBar.connect(self.exportAddScaleBar) + win.sigRangeChanged.connect( + partial(self.setViewRangeFromExportToImageDialog, win=win) + ) + # self.ax1.vb.sigRangeChanged.connect( + # win.updateViewRangeExportToImageDialog + # ) + self.setExportMaskImage(self.ax1.viewRange()) + self.exportToImageWindow = win + win.exec_() + # self.ax1.vb.sigRangeChanged.disconnect() + if win.cancel: + self.exportMaskImage[:] = 0 + self.exportMaskImageItem.setImage(self.exportMaskImage) + self.exportToImageWindow = None + self.logger.info('Export to image process cancelled') + return + + isTransparent = self.overlayToolbar.isTransparent() + if not isTransparent: + # SVG export works only with RGBA not with setOpacity + # --> only true transparency mode can be used + self.overlayToolbar.setTransparent(True) + + self.exportToImage(win.selected_preferences) + self.exportToImageWindow = None + + if not isTransparent: + self.overlayToolbar.setTransparent(False) + + def saveDataPermissionError(self, err_msg): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + msg = QMessageBox() + msg.critical(self, 'Permission denied', err_msg, msg.Ok) + self.waitCond.wakeAll() + + def saveDataProgress(self, text): + self.logger.info(text) + self.saveWin.progressLabel.setText(text) + + def saveDataCustomMetricsCritical(self, traceback_format, func_name): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + self.logger.info('') + _hl = '====================================' + self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') + self.worker.customMetricsErrors[func_name] = traceback_format + + def saveDataCombinedMetricsMissingColumn(self, error_msg, func_name): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + self.logger.info('') + warning = f'[WARNING]: {error_msg}. Metric {func_name} was skipped.' + _hl = '====================================' + self.logger.info(f'{_hl}\n{warning}\n{_hl}') + self.worker.customMetricsErrors[func_name] = warning + + def saveDataAddMetricsCritical(self, traceback_format, error_message): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + self.logger.info('') + _hl = '====================================' + self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') + self.worker.addMetricsErrors[error_message] = traceback_format + + def saveDataRegionPropsCritical(self, traceback_format, error_message): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + self.logger.info('') + _hl = '====================================' + self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') + self.worker.regionPropsErrors[error_message] = traceback_format + + def saveDataUpdateMetricsPbar(self, max, step): + if max > 0: + self.saveWin.metricsQPbar.setMaximum(max) + self.saveWin.metricsQPbar.setValue(0) + self.saveWin.metricsQPbar.setValue( + self.saveWin.metricsQPbar.value()+step + ) + + def saveDataUpdatePbar(self, step, max=-1, exec_time=0.0): + if max >= 0: + self.saveWin.QPbar.setMaximum(max) + else: + self.saveWin.QPbar.setValue(self.saveWin.QPbar.value()+step) + steps_left = self.saveWin.QPbar.maximum()-self.saveWin.QPbar.value() + seconds = round(exec_time*steps_left) + ETA = myutils.seconds_to_ETA(seconds) + self.saveWin.ETA_label.setText(f'ETA: {ETA}') + + def quickSave(self): + self.saveData(isQuickSave=True) + + def checkMissingCca(self): + proceed = True + ignore = False + doNotShowAgain = False + if not self.doNotShowAgainMissingCca: + return proceed, ignore, doNotShowAgain + + missing_cca_items = [] + for posData in self.data: + for frame_i, data_dict in enumerate(posData.allData_li): + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + continue + + if 'cell_cycle_stage' not in acdc_df.columns: + continue + + cca_df = acdc_df[cca_df_colnames] + if cca_df.isnull().values.any(): + i = frame_i if not self.isSnapshot else None + missing_cca_items.append((cca_df, posData, i)) + + if not missing_cca_items: + return proceed, ignore, doNotShowAgain + + proceed = False + ignore, doNotShowAgain =_warnings.warnMissingCca( + missing_cca_items, qparent=self + ) + + if doNotShowAgain: + self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'Yes' + self.df_settings.to_csv(self.settings_csv_path) + + return proceed, ignore, doNotShowAgain + + def warnDifferentSegmChannel( + self, loaded_channel, segm_channel_hyperparams, segmEndName + ): + txt = html_utils.paragraph(f""" + You loaded the segmentation file ending with _{segmEndName}.npz + which corresponds to the channel + {segm_channel_hyperparams}.

+ However, in this session you loaded the channel + {loaded_channel}.

+ If you proceed with saving, the segmentation file ending with + _{segmEndName}.npz will be OVERWRITTEN.

+ Are you sure you want to proceed? + """) + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + msg.warning( + self, 'WARNING: Potential for data loss', txt, + buttonsTexts=('Cancel', 'Yes') + ) + return msg.cancel + + def waitAutoSaveWorker(self, worker): + if worker.isFinished or worker.isPaused or len(worker.dataQ) == 0: + self.waitAutoSaveWorkerLoop.exit() + self.waitAutoSaveWorkerTimer.stop() + self.setStatusBarLabel(log=False) + + @exception_handler + def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): + self.setDisabled(True, keepDisabled=True) + + self.askLineageTreeChanges() + + self.store_data(autosave=False) + self.applyDelROIs() + self.store_data() + self._isQuickSave = isQuickSave + + # Wait autosave worker to finish + for worker, thread in self.autoSaveActiveWorkers: + self.logger.info('Stopping autosaving process...') + self.statusBarLabel.setText('Stopping autosaving process...') + worker.stop() + self.waitAutoSaveWorkerTimer = QTimer() + self.waitAutoSaveWorkerTimer.timeout.connect( + partial(self.waitAutoSaveWorker, worker) + ) + self.waitAutoSaveWorkerTimer.start(100) + self.waitAutoSaveWorkerLoop = QEventLoop() + self.waitAutoSaveWorkerLoop.exec_() + + self.titleLabel.setText( + 'Saving data... (check progress in the terminal)', + color=self.titleColor + ) + + # Check channel name correspondence to warn + posData = self.data[self.pos_i] + lastSegmChannel, segmEndName = posData.getSegmentedChannelHyperparams() + if lastSegmChannel != self.user_ch_name and lastSegmChannel: + cancel = self.warnDifferentSegmChannel( + self.user_ch_name, lastSegmChannel, segmEndName + ) + if cancel: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return True + posData.updateSegmentedChannelHyperparams(self.user_ch_name) + + # Check missing cca annotations in snaphots + proceed, ignore, self.doNotShowAgainMissingCca = self.checkMissingCca() + if not proceed and not ignore: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return + + self.save_metrics = False + if not isQuickSave: + self.save_metrics, cancel = self.askSaveMetrics() + if cancel: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return True + + self.posToSave = None + if self.isSnapshot and not isQuickSave and len(self.data) > 1: + self.posToSave = self.askPosToSave() + if self.posToSave is None: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return True + + if isQuickSave: + # Quick save only current pos + self.posToSave = {self.data[self.pos_i].pos_foldername} + + if self.isSnapshot: + self.store_data(mainThread=False) + + mode = self.modeComboBox.currentText() + if mode == 'Cell cycle analysis': + proceed = self.askSaveLastVisitedCcaMode(isQuickSave=isQuickSave) + if not proceed: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return True + else: + proceed = self.askSaveLastVisitedSegmMode(isQuickSave=isQuickSave) + if not proceed: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return True + + append_name_og_whitelist, proceed, do_not_save_og_whitelist = self.askSaveOriginalSegm(isQuickSave=isQuickSave) + if not proceed: + self.cancelSavingInitialisation() + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + return True + + if self.save_metrics or mode == 'Cell cycle analysis': + self.computeVolumeRegionprop() + + infoTxt = html_utils.paragraph( + f'Saving {self.exp_path}...
', font_size='14px' + ) + + self.saveWin = apps.QDialogPbar( + parent=self, title='Saving data', infoTxt=infoTxt + ) + self.saveWin.setFont(_font) + # if not self.save_metrics: + self.saveWin.metricsQPbar.hide() + self.saveWin.progressLabel.setText('Preparing data...') + self.saveWin.show() + + # Set up separate thread for saving and show progress bar widget + self.mutex = QMutex() + self.waitCond = QWaitCondition() + self.thread = QThread() + self.worker = workers.saveDataWorker(self) + self.worker.mode = mode + self.worker.isQuickSave = isQuickSave + self.worker.append_name_og_whitelist = append_name_og_whitelist + self.worker.do_not_save_og_whitelist = do_not_save_og_whitelist + + self.worker.moveToThread(self.thread) + + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + # Custom signals + self.worker.finished.connect(self.saveDataFinished) + if finishedCallback is not None: + self.worker.finished.connect(finishedCallback) + self.worker.progress.connect(self.saveDataProgress) + self.worker.sigLog.connect(self.workerLog) + self.worker.progressBar.connect(self.saveDataUpdatePbar) + # self.worker.metricsPbarProgress.connect(self.saveDataUpdateMetricsPbar) + self.worker.critical.connect(self.saveDataWorkerCritical) + self.worker.customMetricsCritical.connect( + self.saveDataCustomMetricsCritical + ) + self.worker.sigCombinedMetricsMissingColumn.connect( + self.saveDataCombinedMetricsMissingColumn + ) + self.worker.addMetricsCritical.connect(self.saveDataAddMetricsCritical) + self.worker.regionPropsCritical.connect( + self.saveDataRegionPropsCritical + ) + self.worker.criticalPermissionError.connect(self.saveDataPermissionError) + self.worker.askZsliceAbsent.connect(self.zSliceAbsent) + self.worker.sigDebug.connect(self._workerDebug) + + self.thread.started.connect(self.worker.run) + + self.thread.start() + + return False + + def _workerDebug(self, stuff_to_debug): + pass + # from acdctools.plot import imshow + # lab, frame_i, autoBkgr_masks = stuff_to_debug + # autoBkgr_mask, autoBkgr_mask_proj = autoBkgr_masks + # imshow(lab, autoBkgr_mask) + # self.worker.waitCond.wakeAll() + + def changeTextResolution(self): + mode = 'high' if self.highLowResAction.isChecked() else 'low' + self.logger.info( + f'Switching to {mode} for the text annnotations...' + ) + self.pxModeAction.setDisabled(not self.highLowResAction.isChecked()) + if not self.isDataLoaded: + return + + self.setAllIDs() + posData = self.data[self.pos_i] + allIDs = posData.allIDs + img_shape = self.img1.image.shape[:2] + self.textAnnot[0].changeResolution(mode, allIDs, self.ax1, img_shape) + self.textAnnot[1].changeResolution(mode, allIDs, self.ax2, img_shape) + self.updateAllImages() + + def highLowResToggled(self, clicked=True): + self.changeTextResolution() + + def autoSaveClose(self): + for worker, thread in self.autoSaveActiveWorkers: + worker._stop() + + def viewPreprocDataToggled(self, checked): + self.img1.setUsePreprocessed(checked) + self.setImageImg1() + + if self.viewCombineChannelDataToggle.isChecked(): + self.viewCombineChannelDataToggle.toggled.disconnect() + self.viewCombineChannelDataToggle.setChecked(False) + self.viewCombineChannelDataToggle.toggled.connect( + self.viewCombineChannelDataToggled + ) + + def setAutoSaveSegmentationEnabled(self, enabled): + if not self.autoSaveActiveWorkers: + return + + worker, thread = self.autoSaveActiveWorkers[-1] + + if enabled: + worker.isAutoSaveON = self.autoSaveToggle.isChecked() + else: + worker.isAutoSaveON = False + + def setAutoSaveAnnotationsEnabled(self, enabled): + if not self.autoSaveActiveWorkers: + return + + worker, thread = self.autoSaveActiveWorkers[-1] + + if enabled: + worker.isAutoSaveAnnotON = self.autoSaveToggle.isChecked() + else: + worker.isAutoSaveAnnotON = False + + def autoSaveToggled(self, checked): + if not self.autoSaveActiveWorkers: + self.gui_createAutoSaveWorker() + + if not self.autoSaveActiveWorkers: + return + + worker, thread = self.autoSaveActiveWorkers[-1] + + mode = self.modeComboBox.currentText() + if mode != 'Segmentation and Tracking': + # Autosaving segmentation makes sense only in + # "Segmentation and Tracking" mode + checked = False + + worker.isAutoSaveON = checked + + def autoSaveAnnotToggled(self, checked): + if not self.autoSaveActiveWorkers: + self.gui_createAutoSaveWorker() + + if not self.autoSaveActiveWorkers: + return + + worker, thread = self.autoSaveActiveWorkers[-1] + + mode = self.modeComboBox.currentText() + if mode != 'Viewer': + # No reason to save in viewer mode + checked = False + + worker.isAutoSaveAnnotON = checked + + def autoSaveIntervalEdit(self): + self.autoSaveIntervalDialog.show() + self.autoSaveIntervalDialog.raise_() + self.autoSaveIntervalDialog.activateWindow() + + def autoSaveIntervalValueChanged( + self, value: float, unit: Literal['minutes', 'frames'] + ): + self.autoSaveIntevalValueUnit = (value, unit) + self.autoSaveTimer.stop() + + self.df_settings.at['autoSaveIntevalValue', 'value'] = str(value) + self.df_settings.at['autoSaveIntervalUnit', 'value'] = unit + self.df_settings.to_csv(settings_csv_path) + + self.logger.info( + f'Autosave interval changed to: {value} {unit}' + ) + self.autoSaveIntervalSetTooltip() + + if unit == 'frames': + self.startAutoSaveEveryNframesTimer() + + def autoSaveIntervalSetTooltip(self): + value, unit = self.autoSaveIntevalValueUnit + autoSaveIntervalEditTooltip = ( + 'Change autosave interval to every N frames or minutes\n\n' + f'Current autosave interval: {value} {unit}' + ) + self.autoSaveIntervalLabel.setToolTip(autoSaveIntervalEditTooltip) + self.autoSaveIntervalEditButton.setToolTip(autoSaveIntervalEditTooltip) + + def ccaIntegrCheckerToggled(self, checked): + self.df_settings.at['is_cca_integrity_checker_activated', 'value'] = ( + int(checked) + ) + self.df_settings.to_csv(self.settings_csv_path) + mode = self.modeComboBox.currentText() + if mode != 'Cell cycle analysis': + return + + if checked: + self.startCcaIntegrityCheckerWorker() + else: + self.disableCcaIntegrityChecker() + + def warnErrorsCustomMetrics(self): + win = apps.ComputeMetricsErrorsDialog( + self.worker.customMetricsErrors, self.logs_path, + log_type='custom_metrics', parent=self + ) + win.exec_() + + def warnErrorsAddMetrics(self): + win = apps.ComputeMetricsErrorsDialog( + self.worker.addMetricsErrors, self.logs_path, + log_type='standard_metrics', parent=self + ) + win.exec_() + + def warnErrorsRegionProps(self): + win = apps.ComputeMetricsErrorsDialog( + self.worker.regionPropsErrors, self.logs_path, + log_type='region_props', parent=self + ) + win.exec_() + + def askConcatenate(self): + if self.mainWin is None: + return + + if self._isQuickSave: + return + + if 'showAskConcatenate' not in self.df_settings.index: + self.df_settings.at['showAskConcatenate', 'value'] = 'Yes' + + showAskConcatenate = ( + self.df_settings.at['showAskConcatenate', 'value'] == 'Yes' + ) + if not showAskConcatenate: + return + + txt = html_utils.paragraph(f""" + Do you want to concatenate the `acdc_output.csv` tables from + multiple Positions into one single CSV file?
+ """) + doNotShowAgainCheckbox = QCheckBox('Do not show again') + msg = widgets.myMessageBox(wrapText=False) + noButton, yesButton = msg.question( + self, 'Concatenate tables?', txt, + buttonsTexts=('No', 'Yes'), + widgets=doNotShowAgainCheckbox + ) + showAskConcatenate = ( + 'No' if doNotShowAgainCheckbox.isChecked() else 'Yes' + ) + self.df_settings.at['showAskConcatenate', 'value'] = ( + showAskConcatenate + ) + self.df_settings.to_csv(settings_csv_path) + + if not msg.clickedButton == yesButton: + return + + txt = html_utils.paragraph(f""" + To concatenate the `acdc_output.csv` tables from + multiple Positions and multiple experiments
+ launch the concatenation utility from the top menubar of the Cell-ACDC main launcher:

+ Utilities --> Concatenate --> Concatenate acdc output tables from multiple Positions and experiments.... + """) + msg = widgets.myMessageBox(wrapText=False) + msg.information(self, 'How to concatenate tables', txt) + + def updateSegmDataAutoSaveWorker(self): + # Update savedSegmData in autosave worker + posData = self.data[self.pos_i] + for worker, thread in self.autoSaveActiveWorkers: + worker.savedSegmData = posData.segm_data.copy() + + def saveDataFinished(self): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + if self.saveWin.aborted or self.worker.abort: + self.titleLabel.setText('Saving process cancelled.', color='r') + elif self._isQuickSave: + self.titleLabel.setText('Saved segmentation file and annotations') + else: + self.titleLabel.setText('Saved!') + self.saveWin.workerFinished = True + self.saveWin.close() + + if not self.closeGUI: + # Update savedSegmData in autosave worker + self.updateSegmDataAutoSaveWorker() + + if self.worker.addMetricsErrors: + self.warnErrorsAddMetrics() + if self.worker.regionPropsErrors: + self.warnErrorsRegionProps() + if self.worker.customMetricsErrors: + self.warnErrorsCustomMetrics() + + self.checkManageVersions() + + self.askConcatenate() + + if self.closeGUI: + salute_string = myutils.get_salute_string() + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + 'Data saved!. The GUI will now close.

' + f'{salute_string}' + ) + msg.information(self, 'Data saved', txt) + self.close() + + def copyContent(self): + pass + + def pasteContent(self): + pass + + def cutContent(self): + pass + + def showAbout(self): + self.aboutWin = about.QDialogAbout(parent=self) + self.aboutWin.show() + + def openLogFile(self): + self.logger.info(f'Opening log file "{self.log_path}"...') + myutils.showInExplorer(self.log_path) + + def showLogFiles(self): + log_files_path = os.path.dirname(self.log_path) + self.logger.info(f'Opening log files folder "{log_files_path}"...') + myutils.showInExplorer(log_files_path) + + def showTipsAndTricks(self): + self.welcomeWin = welcome.welcomeWin() + self.welcomeWin.showAndSetSize() + self.welcomeWin.showPage(self.welcomeWin.quickStartItem) + + def about(self): + pass + + def openRecentFile(self, path): + self.logger.info(f'Opening recent folder: {path}') + self.addToRecentPaths(path, logger=self.logger) + self.openFolder(exp_path=path) + + def _waitCloseAutoSaveWorker(self): + didWorkersFinished = [True] + for worker, thread in self.autoSaveActiveWorkers: + if worker.isFinished: + didWorkersFinished.append(True) + else: + didWorkersFinished.append(False) + if all(didWorkersFinished): + self.waitCloseAutoSaveWorkerLoop.stop() + + def cancelSavingInitialisation(self): + self.titleLabel.setText( + 'Saving data process cancelled.', color=self.titleColor + ) + self.closeGUI = False + + @disableWindow + def askSaveOnClosing(self, event): + if not self.saveAction.isEnabled(): + return True + if self.titleLabel.text == 'Saved!': + return True + if not self.isDataLoaded: + return True + + msg = widgets.myMessageBox() + txt = html_utils.paragraph('Do you want to save before closing?') + _, noButton, yesButton = msg.question( + self, 'Save?', txt, + buttonsTexts=('Cancel', 'No', 'Yes') + ) + if msg.cancel: + event.ignore() + return False + + if msg.clickedButton == yesButton: + self.closeGUI = True + QTimer.singleShot(100, self.saveAction.trigger) + event.ignore() + return False + return True + + def clearMemory(self): + if not hasattr(self, 'data'): + return + self.logger.info('Clearing memory...') + for posData in self.data: + try: + del posData.img_data + except Exception as e: + pass + try: + del posData.segm_data + except Exception as e: + pass + try: + del posData.ol_data_dict + except Exception as e: + pass + try: + del posData.fluo_data_dict + except Exception as e: + pass + try: + del posData.ol_data + except Exception as e: + pass + del self.data + + def setUncheckedPointsLayers(self): + self.togglePointsLayerAction.setChecked(False) + self.magicPromptsToolButton.setChecked(False) + + def clearHighlightedID(self): + self.highlightIDToolbar.setVisible(False) + + try: + self.updateLostContoursImage(ax=0, delROIsIDs=None) + except Exception as err: + pass + + if self.highlightedID == 0: + return + + self.highlightedID = 0 + self.guiTabControl.highlightCheckbox.setChecked(False) + self.guiTabControl.highlightSearchedCheckbox.setChecked(False) + self.setHighlightID(False) + + def onEscape( + self, + isTypingIDFunctionChecked=False, + buttonsToNotUncheck=None, + doAutoRange=True + ): + if buttonsToNotUncheck is None: + buttonsToNotUncheck = set() + + if self.keepIDsButton.isChecked() and self.keptObjectsIDs: + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + self.highlightHoverIDsKeptObj(0, 0, hoverID=0) + QTimer.singleShot(300, self.autoRange) + return + + if self.brushButton.isChecked() and self.typingEditID: + self.autoIDcheckbox.setChecked(True) + self.typingEditID = False + QTimer.singleShot(300, self.autoRange) + return + + if isTypingIDFunctionChecked and self.typingEditID: + self.typingEditID = False + QTimer.singleShot(300, self.autoRange) + return + + if self.labelRoiButton.isChecked() and self.isMouseDragImg1: + self.isMouseDragImg1 = False + self.labelRoiItem.setPos((0,0)) + self.labelRoiItem.setSize((0,0)) + self.freeRoiItem.clear() + QTimer.singleShot(300, self.autoRange) + return + + if self.zoomRectButton.isChecked(): + self.zoomRectCancelled() + QTimer.singleShot(300, self.autoRange) + return + + self.setUncheckedAllButtons(buttonsToNotUncheck=buttonsToNotUncheck) + self.setUncheckedAllCustomAnnotButtons() + self.setUncheckedPointsLayers() + self.clearTempBrushImage() + self.isMouseDragImg1 = False + self.typingEditID = False + self.clearHighlightedID() + try: + self.polyLineRoi.clearPoints() + except Exception as e: + pass + + if doAutoRange: + QTimer.singleShot(11, self.autoRange) + + def clearTempBrushImage(self, forceClearLinked=True): + if not hasattr(self, 'tempLayerImg1'): + return + + self.tempLayerImg1.setImage( + self.emptyLab, force_set_linked=forceClearLinked + ) + + try: + self.brushContourImage[:] = 0 + except Exception as err: + pass + + try: + self.brushImage[:] = 0 + except Exception as err: + pass + + def askCloseAllWindows(self): + txt = html_utils.paragraph(""" + There are other open windows that were created from this window. +

+ If you proceed, the other windows will be closed too.
+ """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning( + self, 'Open windows', txt, + buttonsTexts=('Cancel', 'Ok, close now') + ) + return msg.cancel + + def stopPreprocWorker(self): + self.logger.info('Closing pre-processing worker...') + try: + self.preprocWorker.stop() + except Exception as err: + pass + + def closeEvent(self, event): + self.setDisabled(False) + cancel = self.checkAskSavePointsLayers() + if cancel: + event.ignore() + return + + self.onEscape() + self.saveWindowGeometry() + + if self.newWindows: + cancel = self.askCloseAllWindows() + if cancel: + event.ignore() + return + + for window in self.newWindows: + window.close() + + if self.slideshowWin is not None: + self.slideshowWin.close() + if self.ccaTableWin is not None: + self.ccaTableWin.close() + + proceed = self.askSaveOnClosing(event) + if not proceed: + event.ignore() + return + + self.autoSaveClose() + + if self.autoSaveActiveWorkers: + progressWin = apps.QDialogWorkerProgress( + title='Closing autosaving worker', parent=self, + pbarDesc='Closing autosaving worker...' + ) + progressWin.show(self.app) + progressWin.mainPbar.setMaximum(0) + self.waitCloseAutoSaveWorkerLoop = qutils.QWhileLoop( + self._waitCloseAutoSaveWorker, period=250 + ) + self.waitCloseAutoSaveWorkerLoop.exec_() + progressWin.workerFinished = True + progressWin.close() + + self.stopPreprocWorker() + self.stopCombineWorker() + self.stopCcaIntegrityCheckerWorker() + + # Close the inifinte loop of the thread + if self.lazyLoader is not None: + self.lazyLoader.exit = True + self.lazyLoaderWaitCond.wakeAll() + self.waitReadH5cond.wakeAll() + + if self.storeStateWorker is not None: + # Close storeStateWorker + self.storeStateWorker._stop() + while self.storeStateWorker.isFinished: + time.sleep(0.05) + + # Block main thread while separate threads closes + time.sleep(0.1) + + self.clearMemory() + + self.logger.info('Closing GUI logger...') + self.logger.close() + + if self.lazyLoader is None: + self.sigClosed.emit(self) + + gc.collect() + + def storeManualSeparateDrawMode(self, mode): + self.df_settings.at['manual_separate_draw_mode', 'value'] = mode + self.df_settings.to_csv(self.settings_csv_path) + + def readSettings(self): + settings = QSettings('schmollerlab', 'acdc_gui') + if settings.value('geometry') is not None: + self.restoreGeometry(settings.value("geometry")) + # self.restoreState(settings.value("windowState")) + + def saveWindowGeometry(self): + settings = QSettings('schmollerlab', 'acdc_gui') + settings.setValue("geometry", self.saveGeometry()) + # settings.setValue("windowState", self.saveState()) + + def storeDefaultAndCustomColors(self): + c = self.overlayButton.palette().button().color().name() + self.defaultToolBarButtonColor = c + self.doublePressKeyButtonColor = '#fa693b' + + def initPixelSizePropsDockWidget(self): + posData = self.data[self.pos_i] + PhysicalSizeX = posData.PhysicalSizeX + PhysicalSizeY = posData.PhysicalSizeY + PhysicalSizeZ = posData.PhysicalSizeZ + self.guiTabControl.initPixelSize( + PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ + ) + + def showPropsDockWidget(self, checked=False): + if self.showPropsDockButton.isExpand: + self.propsDockWidget.setVisible(False) + self.setHighlightID(False) + else: + self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() + if self.isSegm3D: + self.guiTabControl.propsQGBox.cellVolVox3D_SB.show() + self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.show() + self.guiTabControl.propsQGBox.cellVolFl3D_DSB.show() + self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.show() + else: + self.guiTabControl.propsQGBox.cellVolVox3D_SB.hide() + self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.hide() + self.guiTabControl.propsQGBox.cellVolFl3D_DSB.hide() + self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.hide() + + self.propsDockWidget.setVisible(True) + self.propsDockWidget.setEnabled(True) + self.updateAllImages() + + def showEvent(self, event): + if self.mainWin is not None: + if not self.mainWin.isMinimized(): + return + self.mainWin.showAllWindows() + # self.setFocus() + self.activateWindow() + + def super_show(self): + super().show() + + def show(self): + self.setFont(_font) + QMainWindow.show(self) + + self.setWindowState(Qt.WindowNoState) + self.setWindowState(Qt.WindowActive) + self.raise_() + + self.readSettings() + self.storeDefaultAndCustomColors() + + self.h = self.navSpinBox.size().height() + fontSizeFactor = None + heightFactor = None + if 'bottom_sliders_zoom_perc' in self.df_settings.index: + val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value']) + if val != 100: + fontSizeFactor = val/100 + heightFactor = val/100 + + self.defaultWidgetHeightBottomLayout = self.h + self.checkBoxesHeight = 14 + self.fontPixelSize = 11 + self.defaultBottomLayoutHeight = self.img1BottomGroupbox.height() + + self.bottomLayout.setStretch(0, 0) + self.bottomLayout.addSpacing(self.quickSettingsGroupbox.width()) + self.resizeSlidersArea( + fontSizeFactor=fontSizeFactor, heightFactor=heightFactor + ) + self.bottomScrollArea.hide() + + self.gui_initImg1BottomWidgets() + self.img1BottomGroupbox.hide() + + w = self.showPropsDockButton.width() + h = self.showPropsDockButton.height() + + self.showPropsDockButton.setMaximumWidth(15) + self.showPropsDockButton.setMaximumHeight(120) + + for toolbar in self.controlToolBars: + toolbar.setMinimumHeight( + self.secondLevelToolbar.sizeHint().height() + ) + + self.graphLayout.setFocus() + + def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): + global _font + if heightFactor is None: + self.newCheckBoxesHeight = self.checkBoxesHeight + self.newHeight = self.h + else: + self.newHeight = round(self.h*heightFactor) + self.newCheckBoxesHeight = round(self.checkBoxesHeight*heightFactor) + + if fontSizeFactor is None: + newFontSize = self.fontPixelSize + else: + newFontSize = round(self.fontPixelSize*fontSizeFactor) + newFont = QFont() + newFont.setPixelSize(newFontSize) + _font = newFont + self.zProjComboBox.setFont(newFont) + self.t_label.setFont(newFont) + self.zProjOverlay_CB.setFont(newFont) + self.annotateRightHowCombobox.setFont(newFont) + self.drawIDsContComboBox.setFont(newFont) + self.showTreeInfoCheckbox.setFont(newFont) + self.highlightZneighObjCheckbox.setFont(newFont) + self.navSpinBox.setFont(newFont) + self.zSliceSpinbox.setFont(newFont) + self.SizeZlabel.setFont(newFont) + self.navSizeLabel.setFont(newFont) + self.overlay_z_label.setFont(newFont) + self.img1BottomGroupbox.setFont(newFont) + self.rightBottomGroupbox.setFont(newFont) + try: + self.img1.alphaScrollbar.label.setFont(newFont) + except Exception as e: + pass + for i in range(self.annotOptionsLayout.count()): + widget = self.annotOptionsLayout.itemAt(i).widget() + widget.setFont(newFont) + for i in range(self.annotOptionsLayoutRight.count()): + widget = self.annotOptionsLayoutRight.itemAt(i).widget() + widget.setFont(newFont) + try: + for channel, items in self.overlayLayersItems.items(): + alphaScrollbar = items[2] + alphaScrollbar.label.setFont(newFont) + except: + pass + QTimer.singleShot(100, self._resizeSlidersArea) + + def _resizeSlidersArea(self): + self.navigateScrollBar.setFixedHeight(self.newHeight) + self.zSliceScrollBar.setFixedHeight(self.newHeight) + self.zSliceOverlay_SB.setFixedHeight(self.newHeight) + self.zProjComboBox.setFixedHeight(self.newHeight) + self.zProjOverlay_CB.setFixedHeight(self.newHeight) + self.navSpinBox.setFixedHeight(self.newHeight) + self.zSliceSpinbox.setFixedHeight(self.newHeight) + try: + self.img1.alphaScrollbar.setFixedHeight(self.newHeight) + except Exception as e: + pass + try: + for channel, items in self.overlayLayersItems.items(): + alphaScrollbar = items[2] + alphaScrollbar.setFixedHeight(self.newHeight) + except: + pass + checkBoxStyleSheet = ( + 'QCheckBox::indicator {' + f'width: {self.newCheckBoxesHeight}px;' + f'height: {self.newCheckBoxesHeight}px' + '}' + ) + for i in range(self.annotOptionsLayout.count()): + widget = self.annotOptionsLayout.itemAt(i).widget() + if isinstance(widget, QCheckBox): + widget.setStyleSheet(checkBoxStyleSheet) + for i in range(self.annotOptionsLayoutRight.count()): + widget = self.annotOptionsLayoutRight.itemAt(i).widget() + if isinstance(widget, QCheckBox): + widget.setStyleSheet(checkBoxStyleSheet) + self.zSliceCheckbox.setStyleSheet(checkBoxStyleSheet) + + def resizeEvent(self, event): + if hasattr(self, 'ax1'): + self.ax1.autoRange() + + def hoverEventDrawSpline(self, event): + x, y = event.pos() + xx, yy = self.curvAnchors.getData() + hoverAnchors = self.curvAnchors.pointsAt(event.pos()) + per = False + # If we are hovering the starting point we generate + # a closed spline + if len(xx) < 2: + return + + if len(hoverAnchors)>0: + xA_hover, yA_hover = hoverAnchors[0].pos() + if xx[0]==xA_hover and yy[0]==yA_hover: + per=True + if per: + # Append start coords and close spline + xx = np.r_[xx, xx[0]] + yy = np.r_[yy, yy[0]] + xi, yi = self.getSpline(xx, yy, per=per) + # self.curvPlotItem.setData([], []) + else: + # Append mouse coords + xx = np.r_[xx, x] + yy = np.r_[yy, y] + xi, yi = self.getSpline(xx, yy, per=per) + self.curvHoverPlotItem.setData(xi, yi) + + def updateViewRangeExportToImage(self, viewRange): + if self.exportToImageWindow is None: + return + + # prevViewRange = self.exportToImageWindow.viewRange() + prevViewRange = self._viewRange + prevXRange = prevViewRange[0] + prevYRange = prevViewRange[1] + currXRange = viewRange[0] + currYRange = viewRange[1] + + prevX0, prevX1 = prevXRange + currX0, currX1 = currXRange + prevY0, prevY1 = prevYRange + currY0, currY1 = currYRange + + deltaX = currX0 - prevX0 + deltaY = currY0 - prevY0 + + winViewRange = self.exportToImageWindow.viewRange() + winXRange = winViewRange[0] + winYRange = winViewRange[1] + winX0, winX1 = winXRange + winY0, winY1 = winYRange + + newX0 = winX0 + deltaX + newX1 = winX1 + deltaX + newY0 = winY0 + deltaY + newY1 = winY1 + deltaY + + self.exportToImageWindow.setViewRange( + (newX0, newX1), (newY0, newY1), emitSignal=False + ) + + def viewRangeChanged(self, viewBox, viewRange, updateExportImageMask=True): + # self.updateViewRangeExportToImage(viewRange) + self.updateValuesStatusBar() + + if hasattr(self, 'scaleBar'): + isScaleBarMoveWithZoom = ( + self.scaleBar.properties()['move_with_zoom'] + ) + else: + isScaleBarMoveWithZoom = False + doMoveScaleBar = ( + self.scaleBarDialog is not None or isScaleBarMoveWithZoom + ) + if doMoveScaleBar: + self.scaleBar.updatePosViewRangeChanged(viewRange) + + if hasattr(self, 'timestamp'): + isTimestampMoveWithZoom = ( + self.timestamp.properties()['move_with_zoom'] + ) + else: + isTimestampMoveWithZoom = False + + doMoveTimestamp = ( + self.timestampDialog is not None or isTimestampMoveWithZoom + ) + if doMoveTimestamp: + self.timestamp.updatePosViewRangeChanged(viewRange) + + self._viewRange = viewRange From c540a1bf4a19e76b66bcfe64878e11b57a704a97 Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 09:54:54 +0200 Subject: [PATCH 05/21] refactor: remove cellacdc/domain folder --- cellacdc/domain/__init__.py | 434 ------- cellacdc/domain/cell_cycle.py | 1543 ----------------------- cellacdc/domain/cell_cycle_auto.py | 283 ----- cellacdc/domain/cell_cycle_deletions.py | 337 ----- cellacdc/domain/cell_cycle_divisions.py | 828 ------------ cellacdc/domain/cell_cycle_frames.py | 164 --- cellacdc/domain/cell_cycle_history.py | 129 -- cellacdc/domain/curvature.py | 198 --- cellacdc/domain/custom_annotations.py | 142 --- cellacdc/domain/display_images.py | 40 - cellacdc/domain/edit_id.py | 136 -- cellacdc/domain/events.py | 26 - cellacdc/domain/frame_metadata.py | 103 -- cellacdc/domain/labels.py | 741 ----------- cellacdc/domain/lineage.py | 99 -- cellacdc/domain/metrics_basic.py | 35 - cellacdc/domain/object_counts.py | 107 -- cellacdc/domain/object_search.py | 36 - cellacdc/domain/points.py | 370 ------ cellacdc/domain/session.py | 142 --- cellacdc/domain/state.py | 41 - cellacdc/domain/tracking.py | 221 ---- cellacdc/domain/types.py | 8 - cellacdc/domain/visited_frames.py | 48 - 24 files changed, 6211 deletions(-) delete mode 100644 cellacdc/domain/__init__.py delete mode 100644 cellacdc/domain/cell_cycle.py delete mode 100644 cellacdc/domain/cell_cycle_auto.py delete mode 100644 cellacdc/domain/cell_cycle_deletions.py delete mode 100644 cellacdc/domain/cell_cycle_divisions.py delete mode 100644 cellacdc/domain/cell_cycle_frames.py delete mode 100644 cellacdc/domain/cell_cycle_history.py delete mode 100644 cellacdc/domain/curvature.py delete mode 100644 cellacdc/domain/custom_annotations.py delete mode 100644 cellacdc/domain/display_images.py delete mode 100644 cellacdc/domain/edit_id.py delete mode 100644 cellacdc/domain/events.py delete mode 100644 cellacdc/domain/frame_metadata.py delete mode 100644 cellacdc/domain/labels.py delete mode 100644 cellacdc/domain/lineage.py delete mode 100644 cellacdc/domain/metrics_basic.py delete mode 100644 cellacdc/domain/object_counts.py delete mode 100644 cellacdc/domain/object_search.py delete mode 100644 cellacdc/domain/points.py delete mode 100644 cellacdc/domain/session.py delete mode 100644 cellacdc/domain/state.py delete mode 100644 cellacdc/domain/tracking.py delete mode 100644 cellacdc/domain/types.py delete mode 100644 cellacdc/domain/visited_frames.py diff --git a/cellacdc/domain/__init__.py b/cellacdc/domain/__init__.py deleted file mode 100644 index 0aa866ad1..000000000 --- a/cellacdc/domain/__init__.py +++ /dev/null @@ -1,434 +0,0 @@ -"""Headless domain model for Cell-ACDC (plan ``core/`` layer).""" - -from .cell_cycle import ( - CcaDisappearedBeforeDivisionFrameResult, - CcaFrameRemovalResult, - CcaFutureRemovalResult, - CcaMotherStatusRestoreResult, - CcaSPhaseDisappearancePropagationResult, - CcaSnapshotIdChanges, - CcaSnapshotIdEditResult, - CcaWillDivideFrameResult, - DEFAULT_CELL_CYCLE_ANNOTATION_VALUES, - DEFAULT_LINEAGE_TREE_ANNOTATION_VALUES, - ExistingNewIdCcaRowsResult, - FutureBudDivisionResult, - MotherEligibilityFrameResult, - MotherEligibilityIssue, - MotherBudPair, - MissingCcaAnnotationItem, - add_base_cell_cycle_annotation, - apply_snapshot_cca_id_edits, - apply_mother_bud_pairing, - annotate_division, - apply_manual_cca_changes, - assign_bud_to_mother, - base_cell_cycle_annotation_status, - build_base_cell_cycle_annotations, - bud_known_history_status, - cca_snapshot_id_changes, - collect_existing_new_id_cca_rows, - collect_existing_new_id_cca_rows_from_frames, - concat_cell_cycle_annotations, - dead_or_excluded_mother_pairs, - division_undo_blocking_frame, - evaluate_mother_future_eligibility_frame, - evaluate_mother_past_eligibility_frame, - ensure_cca_columns, - extract_cell_cycle_annotations, - fix_will_divide_without_next_generation, - future_bud_division, - has_cell_cycle_annotations, - last_annotated_cca_by_cell, - last_annotated_cell_cycle_frame_index, - mark_current_relative_after_disappearance, - mark_disappeared_before_division_frame, - mark_will_divide_frame, - merge_missing_cca_ids, - missing_cell_cycle_annotation_items, - mother_not_g1_before_bud_emergence_frame, - mother_status_before_wrong_bud, - overlay_last_annotated_cca, - propagate_s_phase_disappearance_divisions, - relabel_cca_ids, - remove_cell_cycle_annotations, - remove_future_cell_cycle_annotations, - reset_cca_future_flags, - reset_will_divide_for_generations, - relative_status_before_bud_emergence, - restore_mother_status_for_wrong_bud_frame, - restore_mother_status_until_g1, - s_phase_relative_ids_gone, - split_concat_cell_cycle_annotations, - store_cell_cycle_annotations, - toggle_history_knowledge, - undo_bud_mother_assignment, - undo_division_annotation, - wrong_bud_id_for_mother, - will_divide_without_next_generation_ids, -) -from .cell_cycle_auto import ( - AutoCcaAssignmentResult, - AutoCcaFrameInitResult, - AutoCcaRepeatFrameResult, - apply_auto_cca_assignments, - apply_auto_bud_assignment, - auto_cca_assignments_from_cost, - auto_cca_candidate_mother_ids, - auto_cca_cost_matrix_from_contours, - auto_cca_cost_matrix_from_distances, - auto_cca_repeat_frame_state, - merge_current_with_found_cca_rows, - nearest_point_2d_yx, - prepare_auto_cca_current_frame, - uncorrected_new_ids_for_auto_cca, -) -from .cell_cycle_deletions import ( - CcaDeletedIdsPropagationResult, - CcaDeletedIdsResult, - CcaDeletedRelativeStatusesResult, - CcaRelativeRestoreResult, - apply_cca_deleted_ids_to_frame, - apply_deleted_cell_cycle_ids_to_frame, - delete_cca_ids, - deleted_relative_cca_status, - propagate_deleted_cell_cycle_ids, - restore_cca_relative_statuses, - restore_deleted_relative_cell_cycle_statuses, -) -from .cell_cycle_divisions import ( - CcaBudMotherAssignmentPropagationResult, - CcaBudMotherChangeEligibilityResult, - CcaManualDivisionPropagationResult, - CcaMotherAssignmentEligibilityResult, - CcaMotherBudPairingsResult, - CcaSwapMothersEligibilityResult, - CcaSwapMothersFutureDivisionResult, - CcaSwapMothersPairingPlan, - CcaSwapMothersPastRestoreResult, - CcaSwapMothersPropagationResult, - CcaWillDividePropagationResult, - apply_mother_bud_pairings, - previous_relative_status_before_bud_emergence, - bud_mother_change_eligibility, - mother_assignment_eligibility, - propagate_bud_mother_assignment, - propagate_manual_division_annotation, - propagate_swap_mothers_assignment, - propagate_swap_mothers_future_division, - propagate_will_divide, - restore_swap_mothers_past_status, - swap_mothers_eligibility, - swap_mothers_pairing_plan, -) -from .cell_cycle_frames import ( - CcaFrameResolutionResult, - CcaFrameStoreResult, - CcaMissingFramesInitResult, - normalize_loaded_cell_cycle_frame_annotations, - prepare_cell_cycle_checker_annotations, - prepare_missing_cell_cycle_frame_annotations, - resolve_cell_cycle_annotations, - store_cell_cycle_frame_annotations, -) -from .cell_cycle_history import ( - CcaHistoryKnowledgePropagationResult, - apply_history_knowledge_to_frame, - known_history_status_for_bud, - propagate_history_knowledge, -) -from .custom_annotations import ( - CustomAnnotationColumnResult, - CustomAnnotationFrameUpdate, - custom_annotation_column_exists, - drop_custom_annotation_column, - ensure_custom_annotation_column, - remap_custom_annotation_ids, - rename_custom_annotation_column, - update_custom_annotation_frame, -) -from .edit_id import ( - ManualEditTrackingResult, - add_yx_centroids_to_df, - apply_manual_edit_tracking, - edit_id_info_from_df, - manual_edit_conflicts, - project_centroid, -) -from .frame_metadata import ( - AcdcFrameMetadataResult, - build_acdc_frame_metadata, - concat_visited_acdc_frames, -) -from . import labels -from .labels import ( - DeletedRoiApplyResult, - DeletedRoiRestoreResult, - LabelBorderClearResult, - LabelHoleFillResult, - LabelIdMappingResult, - LabelIdsRemovalResult, - LabelRegionSelectionResult, - LabelRoiIndexResult, - LabelMoveResult, - LabelResizeResult, - apply_deleted_roi_masks, - apply_label_id_mapping, - clear_border_labels, - collect_deleted_roi_ids, - fill_label_holes, - index_label_roi, - label_ids_from_labels, - label_ids_in_masks, - line_roi_mask, - move_label_object, - next_available_label_id, - polygon_roi_mask, - rectangle_roi_mask, - remap_id_set, - remove_new_label_ids, - select_labels_in_region, - restore_deleted_roi_labels, - resize_label_object, -) -from .lineage import ( - LineageAnnotationsRemovalResult, - LineageFutureRemovalResult, - has_lineage_tree_annotations, - remove_future_lineage_tree_annotations, - remove_lineage_tree_annotations, -) -from .points import ( - add_click_point, - click_points_table_to_data, - flatten_frame_points_data, - infer_points_column_mapping, - interpolate_points_zslices, - next_click_point_id, - point_id_already_new, - points_data_to_table, - points_table_to_data, - remove_click_points, -) -from .session import ExperimentSession, PositionSession -from .tracking import ( - FutureIdPropagationScan, - LostNewIdsResult, - TrackedLostIdsResult, - compute_lost_new_ids, - last_tracked_frame_index, - scan_future_id_propagation, - track_labels, - tracked_lost_centroids_from_regionprops, - tracked_lost_ids_from_centroids, -) -from .types import CellID, ChannelName, FrameIndex, PixelSize -from .visited_frames import ( - LastVisitedFrameUpdate, - update_last_visited_frame_state, -) - -__all__ = [ - 'CellID', - 'ChannelName', - 'CustomAnnotationColumnResult', - 'CustomAnnotationFrameUpdate', - 'ExperimentSession', - 'FrameIndex', - 'FutureIdPropagationScan', - 'DeletedRoiApplyResult', - 'DeletedRoiRestoreResult', - 'LabelBorderClearResult', - 'LabelHoleFillResult', - 'LabelIdMappingResult', - 'LabelIdsRemovalResult', - 'LabelRegionSelectionResult', - 'LabelRoiIndexResult', - 'LabelMoveResult', - 'LabelResizeResult', - 'LastVisitedFrameUpdate', - 'LineageAnnotationsRemovalResult', - 'LineageFutureRemovalResult', - 'LostNewIdsResult', - 'ManualEditTrackingResult', - 'PixelSize', - 'PositionSession', - 'TrackedLostIdsResult', - 'add_click_point', - 'add_yx_centroids_to_df', - 'annotate_division', - 'apply_auto_bud_assignment', - 'apply_auto_cca_assignments', - 'apply_cca_deleted_ids_to_frame', - 'apply_deleted_cell_cycle_ids_to_frame', - 'apply_deleted_roi_masks', - 'apply_history_knowledge_to_frame', - 'apply_label_id_mapping', - 'apply_manual_edit_tracking', - 'apply_manual_cca_changes', - 'apply_mother_bud_pairing', - 'apply_mother_bud_pairings', - 'apply_snapshot_cca_id_edits', - 'AcdcFrameMetadataResult', - 'assign_bud_to_mother', - 'CcaDeletedIdsPropagationResult', - 'CcaDeletedIdsResult', - 'CcaDeletedRelativeStatusesResult', - 'CcaDisappearedBeforeDivisionFrameResult', - 'CcaFrameRemovalResult', - 'CcaFrameResolutionResult', - 'CcaFrameStoreResult', - 'CcaFutureRemovalResult', - 'CcaBudMotherAssignmentPropagationResult', - 'CcaBudMotherChangeEligibilityResult', - 'CcaHistoryKnowledgePropagationResult', - 'CcaManualDivisionPropagationResult', - 'CcaMotherAssignmentEligibilityResult', - 'CcaMotherBudPairingsResult', - 'CcaMissingFramesInitResult', - 'CcaMotherStatusRestoreResult', - 'CcaRelativeRestoreResult', - 'CcaSPhaseDisappearancePropagationResult', - 'CcaSnapshotIdChanges', - 'CcaSnapshotIdEditResult', - 'CcaSwapMothersEligibilityResult', - 'CcaSwapMothersFutureDivisionResult', - 'CcaSwapMothersPairingPlan', - 'CcaSwapMothersPastRestoreResult', - 'CcaSwapMothersPropagationResult', - 'CcaWillDivideFrameResult', - 'CcaWillDividePropagationResult', - 'DEFAULT_CELL_CYCLE_ANNOTATION_VALUES', - 'DEFAULT_LINEAGE_TREE_ANNOTATION_VALUES', - 'AutoCcaRepeatFrameResult', - 'AutoCcaAssignmentResult', - 'AutoCcaFrameInitResult', - 'ExistingNewIdCcaRowsResult', - 'FutureBudDivisionResult', - 'MotherEligibilityFrameResult', - 'MotherEligibilityIssue', - 'MotherBudPair', - 'MissingCcaAnnotationItem', - 'add_base_cell_cycle_annotation', - 'base_cell_cycle_annotation_status', - 'build_acdc_frame_metadata', - 'build_base_cell_cycle_annotations', - 'auto_cca_assignments_from_cost', - 'auto_cca_candidate_mother_ids', - 'auto_cca_cost_matrix_from_contours', - 'auto_cca_cost_matrix_from_distances', - 'auto_cca_repeat_frame_state', - 'previous_relative_status_before_bud_emergence', - 'bud_mother_change_eligibility', - 'bud_known_history_status', - 'cca_snapshot_id_changes', - 'click_points_table_to_data', - 'collect_deleted_roi_ids', - 'collect_existing_new_id_cca_rows', - 'collect_existing_new_id_cca_rows_from_frames', - 'concat_cell_cycle_annotations', - 'concat_visited_acdc_frames', - 'clear_border_labels', - 'compute_lost_new_ids', - 'dead_or_excluded_mother_pairs', - 'custom_annotation_column_exists', - 'delete_cca_ids', - 'deleted_relative_cca_status', - 'division_undo_blocking_frame', - 'edit_id_info_from_df', - 'evaluate_mother_future_eligibility_frame', - 'evaluate_mother_past_eligibility_frame', - 'ensure_cca_columns', - 'ensure_custom_annotation_column', - 'extract_cell_cycle_annotations', - 'fill_label_holes', - 'index_label_roi', - 'fix_will_divide_without_next_generation', - 'future_bud_division', - 'has_cell_cycle_annotations', - 'has_lineage_tree_annotations', - 'last_annotated_cca_by_cell', - 'last_annotated_cell_cycle_frame_index', - 'known_history_status_for_bud', - 'last_tracked_frame_index', - 'flatten_frame_points_data', - 'infer_points_column_mapping', - 'interpolate_points_zslices', - 'label_ids_from_labels', - 'label_ids_in_masks', - 'line_roi_mask', - 'manual_edit_conflicts', - 'mark_current_relative_after_disappearance', - 'mark_disappeared_before_division_frame', - 'mark_will_divide_frame', - 'merge_current_with_found_cca_rows', - 'merge_missing_cca_ids', - 'missing_cell_cycle_annotation_items', - 'mother_not_g1_before_bud_emergence_frame', - 'mother_status_before_wrong_bud', - 'nearest_point_2d_yx', - 'normalize_loaded_cell_cycle_frame_annotations', - 'overlay_last_annotated_cca', - 'prepare_missing_cell_cycle_frame_annotations', - 'move_label_object', - 'next_available_label_id', - 'next_click_point_id', - 'point_id_already_new', - 'points_data_to_table', - 'points_table_to_data', - 'polygon_roi_mask', - 'project_centroid', - 'rectangle_roi_mask', - 'remove_new_label_ids', - 'select_labels_in_region', - 'prepare_cell_cycle_checker_annotations', - 'prepare_auto_cca_current_frame', - 'propagate_deleted_cell_cycle_ids', - 'propagate_history_knowledge', - 'propagate_manual_division_annotation', - 'propagate_s_phase_disappearance_divisions', - 'propagate_swap_mothers_future_division', - 'propagate_will_divide', - 'relabel_cca_ids', - 'drop_custom_annotation_column', - 'remove_cell_cycle_annotations', - 'remove_click_points', - 'remove_future_cell_cycle_annotations', - 'remove_future_lineage_tree_annotations', - 'remove_lineage_tree_annotations', - 'remap_custom_annotation_ids', - 'remap_id_set', - 'reset_cca_future_flags', - 'reset_will_divide_for_generations', - 'resolve_cell_cycle_annotations', - 'relative_status_before_bud_emergence', - 'restore_mother_status_for_wrong_bud_frame', - 'restore_mother_status_until_g1', - 'restore_swap_mothers_past_status', - 'restore_cca_relative_statuses', - 'restore_deleted_relative_cell_cycle_statuses', - 'restore_deleted_roi_labels', - 'resize_label_object', - 'rename_custom_annotation_column', - 's_phase_relative_ids_gone', - 'scan_future_id_propagation', - 'split_concat_cell_cycle_annotations', - 'store_cell_cycle_annotations', - 'store_cell_cycle_frame_annotations', - 'mother_assignment_eligibility', - 'propagate_bud_mother_assignment', - 'propagate_swap_mothers_assignment', - 'swap_mothers_eligibility', - 'swap_mothers_pairing_plan', - 'toggle_history_knowledge', - 'track_labels', - 'tracked_lost_centroids_from_regionprops', - 'tracked_lost_ids_from_centroids', - 'uncorrected_new_ids_for_auto_cca', - 'undo_bud_mother_assignment', - 'undo_division_annotation', - 'update_custom_annotation_frame', - 'update_last_visited_frame_state', - 'wrong_bud_id_for_mother', - 'will_divide_without_next_generation_ids', -] diff --git a/cellacdc/domain/cell_cycle.py b/cellacdc/domain/cell_cycle.py deleted file mode 100644 index 4a416039d..000000000 --- a/cellacdc/domain/cell_cycle.py +++ /dev/null @@ -1,1543 +0,0 @@ -"""Pure cell-cycle annotation table operations.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pandas as pd - - -DEFAULT_CELL_CYCLE_ANNOTATION_VALUES = { - 'cell_cycle_stage': 'G1', - 'generation_num': 2, - 'relative_ID': -1, - 'relationship': 'mother', - 'emerg_frame_i': -1, - 'division_frame_i': -1, - 'is_history_known': False, - 'corrected_on_frame_i': -1, - 'will_divide': 0, - 'daughter_disappears_before_division': 0, - 'disappears_before_division': 0, -} - -DEFAULT_LINEAGE_TREE_ANNOTATION_VALUES = { - 'Cell_ID_tree': -1, - 'generation_num_tree': 1, - 'parent_ID_tree': -1, - 'root_ID_tree': -1, - 'sister_ID_tree': -1, -} - - -@dataclass(frozen=True) -class CcaSnapshotIdChanges: - """ID additions/deletions needed after a segmentation edit.""" - - new_ids: list[int] - deleted_ids: list[int] - reset_base: bool = False - - -@dataclass(frozen=True) -class CcaSnapshotIdEditResult: - """CCA table after applying snapshot label-ID edits.""" - - cca_df: pd.DataFrame - changes: CcaSnapshotIdChanges - restored_relative_ids: list[int] - - -@dataclass(frozen=True) -class CcaMotherStatusRestoreResult: - """CCA table after restoring a mother status for one frame.""" - - cca_df: pd.DataFrame - restored: bool - - -@dataclass(frozen=True) -class CcaWillDivideFrameResult: - """CCA table after applying one will-divide propagation step.""" - - cca_df: pd.DataFrame - generation_num: int | None - should_store: bool - stop: bool - - -@dataclass(frozen=True) -class CcaFrameRemovalResult: - """ACDC frame after removing CCA columns.""" - - acdc_df: pd.DataFrame | None - removed: bool - missing_frame: bool = False - - -@dataclass(frozen=True) -class CcaFutureRemovalResult: - """Future CCA removals for frame records and concatenated ACDC data.""" - - acdc_dfs_by_frame: dict[int, pd.DataFrame] - cache_frame_indices: list[int] - removed_frame_indices: list[int] - concatenated_acdc_df: pd.DataFrame | None - stopped_at_frame_i: int | None = None - - -@dataclass(frozen=True) -class ExistingNewIdCcaRowsResult: - """Past CCA rows found for new IDs plus IDs that remain new.""" - - found_cca_dfs: list[pd.DataFrame] - remaining_new_ids: list[int] - - -@dataclass(frozen=True) -class CcaDisappearedBeforeDivisionFrameResult: - """CCA frame update for disappeared-before-division propagation.""" - - cca_df: pd.DataFrame | None - should_store: bool - stop: bool - - -@dataclass(frozen=True) -class CcaSPhaseDisappearancePropagationResult: - """CCA updates after S-phase cells disappear before division.""" - - previous_cca_df: pd.DataFrame - current_cca_df: pd.DataFrame | None - updated_cca_dfs_by_frame: dict[int, pd.DataFrame] - disappeared_ids: list[int] - automatically_divided_ids: list[int] - - -@dataclass(frozen=True) -class FutureBudDivisionResult: - """Future frame where a bud is already annotated as divided.""" - - frame_i: int - mother_id: int - - -@dataclass(frozen=True) -class MotherBudPair: - """Mother-bud ID pair.""" - - bud_id: int - mother_id: int - - -@dataclass(frozen=True) -class MotherEligibilityIssue: - """Reason a mother candidate is not eligible on one frame.""" - - mother_id: int - bud_id: int - frame_i: int - reason: str - blocks_assignment: bool = False - - -@dataclass(frozen=True) -class MotherEligibilityFrameResult: - """Result of checking one mother-eligibility frame.""" - - issue: MotherEligibilityIssue | None - stop: bool - g1_duration: int = 0 - - -@dataclass(frozen=True) -class MissingCcaAnnotationItem: - """One frame with missing values in CCA annotation columns.""" - - position_i: int - frame_i: int | None - cca_df: pd.DataFrame - - -_ADD_NEW_ID_EDITS = { - 'Add new ID with brush tool', - 'Add new ID with curvature tool', -} -_DELETE_ID_EDITS = { - 'Delete ID', - 'Deleted non-selected objects', - 'Delete ID with eraser', - 'Delete IDs using ROI', - 'Merge IDs', -} -_SYNC_ID_EDITS = { - 'Separate IDs', - 'Edit ID', -} - - -def cca_snapshot_id_changes( - edit_text: str, - cca_ids, - current_ids, -) -> CcaSnapshotIdChanges: - """Classify CCA row additions/deletions after snapshot label edits.""" - cca_ids_set = {int(label_id) for label_id in cca_ids} - current_ids_set = {int(label_id) for label_id in current_ids} - new_ids = [ - int(label_id) for label_id in current_ids - if int(label_id) not in cca_ids_set - ] - deleted_ids = [ - int(label_id) for label_id in cca_ids - if int(label_id) not in current_ids_set - ] - - if edit_text in _ADD_NEW_ID_EDITS: - return CcaSnapshotIdChanges(new_ids=new_ids, deleted_ids=[]) - if edit_text in _DELETE_ID_EDITS: - return CcaSnapshotIdChanges(new_ids=[], deleted_ids=deleted_ids) - if edit_text in _SYNC_ID_EDITS: - return CcaSnapshotIdChanges(new_ids=new_ids, deleted_ids=deleted_ids) - if edit_text == 'Repeat segmentation': - return CcaSnapshotIdChanges( - new_ids=[], - deleted_ids=[], - reset_base=True, - ) - return CcaSnapshotIdChanges(new_ids=[], deleted_ids=[]) - - -def relabel_cca_ids( - cca_df: pd.DataFrame, - old_ids, - new_ids, -) -> pd.DataFrame: - """Return ``cca_df`` with IDs relabelled in index and references.""" - id_mapper = dict(zip(old_ids, new_ids)) - relabelled_cca_df = cca_df.copy() - relabelled_cca_df['relative_ID'] = ( - relabelled_cca_df['relative_ID'].replace(old_ids, new_ids) - ) - return relabelled_cca_df.rename(index=id_mapper) - - -def merge_missing_cca_ids( - cca_df: pd.DataFrame | None, - base_cca_df: pd.DataFrame, -) -> pd.DataFrame: - """Return ``cca_df`` with rows filled from ``base_cca_df`` where missing.""" - if cca_df is None: - return base_cca_df.copy() - return cca_df.combine_first(base_cca_df) - - -def apply_snapshot_cca_id_edits( - cca_df: pd.DataFrame, - edit_text: str, - current_ids, - base_cca_df: pd.DataFrame, - *, - base_values: dict | None = None, -) -> CcaSnapshotIdEditResult: - """Return CCA table updated after snapshot label-ID edits.""" - changes = cca_snapshot_id_changes(edit_text, cca_df.index, current_ids) - if changes.reset_base: - return CcaSnapshotIdEditResult( - cca_df=base_cca_df.copy(), - changes=changes, - restored_relative_ids=[], - ) - - updated_cca_df = cca_df.copy() - for new_id in changes.new_ids: - if new_id <= 0: - continue - updated_cca_df = add_base_cell_cycle_annotation( - updated_cca_df, - new_id, - base_values=base_values, - ) - - restored_relative_ids = [] - if changes.deleted_ids: - relative_ids = updated_cca_df.reindex( - changes.deleted_ids, - fill_value=-1, - )['relative_ID'] - updated_cca_df = updated_cca_df.drop( - changes.deleted_ids, - errors='ignore', - ) - for relative_id in relative_ids: - if relative_id <= 0: - continue - updated_cca_df = add_base_cell_cycle_annotation( - updated_cca_df, - relative_id, - base_values=base_values, - ) - restored_relative_ids.append(int(relative_id)) - - return CcaSnapshotIdEditResult( - cca_df=updated_cca_df, - changes=changes, - restored_relative_ids=restored_relative_ids, - ) - - -def collect_existing_new_id_cca_rows( - new_ids, - past_cca_dfs, -) -> ExistingNewIdCcaRowsResult: - """Collect past CCA rows for IDs that were classified as new.""" - if not new_ids: - return ExistingNewIdCcaRowsResult( - found_cca_dfs=[], - remaining_new_ids=[], - ) - - remaining_new_ids = list(new_ids) - found_cca_dfs = [] - for cca_df in past_cca_dfs: - if not remaining_new_ids: - break - - intersect_idx = cca_df.index.intersection(remaining_new_ids) - found_cca_df = cca_df.loc[intersect_idx] - if found_cca_df.empty: - continue - - found_cca_dfs.append(found_cca_df) - found_ids = set(found_cca_df.index) - remaining_new_ids = [ - cell_id for cell_id in remaining_new_ids - if cell_id not in found_ids - ] - - return ExistingNewIdCcaRowsResult( - found_cca_dfs=found_cca_dfs, - remaining_new_ids=remaining_new_ids, - ) - - -def collect_existing_new_id_cca_rows_from_frames( - new_ids, - past_acdc_frames, - cca_colnames, -) -> ExistingNewIdCcaRowsResult: - """Collect past CCA rows for new IDs from frame ACDC tables.""" - past_cca_dfs = ( - acdc_df[list(cca_colnames)] - for _, acdc_df in past_acdc_frames - if acdc_df is not None - ) - return collect_existing_new_id_cca_rows(new_ids, past_cca_dfs) - - -def ensure_cca_columns( - acdc_df: pd.DataFrame, - cca_colnames, - fill_value='', -) -> pd.DataFrame: - """Return ``acdc_df`` with missing CCA columns initialized.""" - updated_acdc_df = acdc_df.copy() - for column in cca_colnames: - if column not in updated_acdc_df.columns: - updated_acdc_df[column] = fill_value - return updated_acdc_df - - -def build_base_cell_cycle_annotations( - cell_ids, - *, - with_tree_cols: bool = False, - base_values: dict | None = None, - tree_values: dict | None = None, -) -> pd.DataFrame: - """Return a base CCA table for ``cell_ids``.""" - base_values = ( - DEFAULT_CELL_CYCLE_ANNOTATION_VALUES - if base_values is None else base_values - ) - tree_values = ( - DEFAULT_LINEAGE_TREE_ANNOTATION_VALUES - if tree_values is None else tree_values - ) - - cell_ids = list(cell_ids) - row_data = dict(base_values) - if with_tree_cols: - row_data = {**row_data, **tree_values} - - cca_df = pd.DataFrame([row_data.copy() for _ in cell_ids], index=cell_ids) - if with_tree_cols: - cca_df['Cell_ID_tree'] = cell_ids - cca_df.index.name = 'Cell_ID' - return cca_df - - -def base_cell_cycle_annotation_status( - base_values: dict | None = None, -) -> pd.Series: - """Return one base CCA status row.""" - base_values = ( - DEFAULT_CELL_CYCLE_ANNOTATION_VALUES - if base_values is None else base_values - ) - return pd.Series(dict(base_values)) - - -def add_base_cell_cycle_annotation( - cca_df: pd.DataFrame, - cell_id: int, - *, - base_values: dict | None = None, -) -> pd.DataFrame: - """Return ``cca_df`` with one base CCA row added or reset.""" - if int(cell_id) <= 0: - return cca_df - - base_values = ( - DEFAULT_CELL_CYCLE_ANNOTATION_VALUES - if base_values is None else base_values - ) - if cca_df.empty: - return build_base_cell_cycle_annotations( - [cell_id], - base_values=base_values, - ) - - updated_cca_df = cca_df.copy() - for column, value in base_values.items(): - updated_cca_df.at[cell_id, column] = value - return updated_cca_df - - -from .cell_cycle_deletions import ( # noqa: E402 - CcaDeletedIdsPropagationResult, - CcaDeletedIdsResult, - CcaDeletedRelativeStatusesResult, - CcaRelativeRestoreResult, - apply_cca_deleted_ids_to_frame, - apply_deleted_cell_cycle_ids_to_frame, - delete_cca_ids, - deleted_relative_cca_status, - propagate_deleted_cell_cycle_ids, - restore_cca_relative_statuses, - restore_deleted_relative_cell_cycle_statuses, -) - -def last_annotated_cca_by_cell( - annotated_cca_dfs, -) -> pd.DataFrame: - """Return last annotated CCA status for each cell across frame tables.""" - annotated_cca_dfs = list(annotated_cca_dfs) - if not annotated_cca_dfs: - return pd.DataFrame() - - keys = range(len(annotated_cca_dfs)) - names = ['frame_i', 'Cell_ID'] - annotated_cca_df = ( - pd.concat(annotated_cca_dfs, keys=keys, names=names) - .reset_index() - .set_index(['Cell_ID', 'frame_i']) - .sort_index() - ) - return annotated_cca_df.groupby(level=0).last() - - -def overlay_last_annotated_cca( - base_cca_df: pd.DataFrame, - last_annotated_cca_df: pd.DataFrame, - cca_colnames, -) -> pd.DataFrame: - """Overlay last known CCA rows onto a base CCA frame.""" - updated_cca_df = base_cca_df.copy() - if last_annotated_cca_df.empty: - return updated_cca_df - - idx = last_annotated_cca_df.index.intersection(updated_cca_df.index) - updated_cca_df.loc[idx, cca_colnames] = last_annotated_cca_df.loc[idx] - return updated_cca_df - - -def has_cell_cycle_annotations(acdc_df: pd.DataFrame | None) -> bool: - """Return whether an ACDC frame table contains CCA annotations.""" - return acdc_df is not None and 'cell_cycle_stage' in acdc_df.columns - - -from .cell_cycle_auto import ( # noqa: E402 - AutoCcaAssignmentResult, - AutoCcaFrameInitResult, - AutoCcaRepeatFrameResult, - apply_auto_cca_assignments, - apply_auto_bud_assignment, - auto_cca_assignments_from_cost, - auto_cca_candidate_mother_ids, - auto_cca_cost_matrix_from_contours, - auto_cca_cost_matrix_from_distances, - auto_cca_repeat_frame_state, - merge_current_with_found_cca_rows, - nearest_point_2d_yx, - prepare_auto_cca_current_frame, - uncorrected_new_ids_for_auto_cca, -) - - -def last_annotated_cell_cycle_frame_index(acdc_dfs) -> int: - """Return the last frame index with CCA annotations. - - This preserves the GUI's legacy first-frame behavior: if the first frame is - missing or unannotated, frame index 0 is returned. - """ - acdc_dfs = list(acdc_dfs) - if not acdc_dfs: - return 0 - - last_seen_i = 0 - for frame_i, acdc_df in enumerate(acdc_dfs): - last_seen_i = frame_i - if not has_cell_cycle_annotations(acdc_df): - break - else: - return last_seen_i - - if last_seen_i == 0 or last_seen_i + 1 == len(acdc_dfs): - return last_seen_i - return last_seen_i - 1 - - -def extract_cell_cycle_annotations( - acdc_df: pd.DataFrame | None, - cca_colnames, - *, - dropna: bool = True, -) -> pd.DataFrame | None: - """Return the CCA columns from an ACDC frame table, if present.""" - if not has_cell_cycle_annotations(acdc_df): - return None - - cca_df = acdc_df[list(cca_colnames)].copy() - if dropna: - cca_df = cca_df.dropna() - return cca_df - - -def concat_cell_cycle_annotations( - frame_records, - cca_colnames, - *, - acdc_key: str = 'acdc_df', - size_t: int | None = None, -) -> pd.DataFrame | None: - """Return consecutive per-frame CCA tables as one MultiIndex table.""" - cca_dfs = [] - keys = [] - - if size_t is None: - records = enumerate(frame_records) - else: - records = ((frame_i, frame_records[frame_i]) for frame_i in range(size_t)) - - for frame_i, frame_record in records: - acdc_df = frame_record[acdc_key] - cca_df = extract_cell_cycle_annotations( - acdc_df, - cca_colnames, - ) - if cca_df is None: - break - - cca_dfs.append(cca_df) - keys.append(frame_i) - - if not cca_dfs: - return None - - return pd.concat(cca_dfs, keys=keys, names=['frame_i']) - - -def split_concat_cell_cycle_annotations( - global_cca_df: pd.DataFrame | None, - *, - size_t: int | None = None, - frame_level: str = 'frame_i', -) -> list[tuple[int, pd.DataFrame]]: - """Return per-frame CCA tables from a concatenated CCA table.""" - if global_cca_df is None: - return [] - - if size_t is None: - frame_indices = global_cca_df.index.get_level_values(frame_level).unique() - else: - frame_indices = range(size_t) - - frame_tables = [] - for frame_i in frame_indices: - try: - cca_df = global_cca_df.xs( - frame_i, - level=frame_level, - drop_level=True, - ) - except KeyError: - break - - frame_tables.append((int(frame_i), cca_df.copy())) - - return frame_tables - - -def remove_cell_cycle_annotations( - acdc_df: pd.DataFrame | None, - cca_colnames, -) -> CcaFrameRemovalResult: - """Return an ACDC frame table without CCA columns.""" - if acdc_df is None: - return CcaFrameRemovalResult( - acdc_df=None, - removed=False, - missing_frame=True, - ) - if not has_cell_cycle_annotations(acdc_df): - return CcaFrameRemovalResult(acdc_df=acdc_df, removed=False) - - return CcaFrameRemovalResult( - acdc_df=acdc_df.drop(columns=cca_colnames, errors='ignore'), - removed=True, - ) - - -def remove_future_cell_cycle_annotations( - frame_records, - cca_colnames, - from_frame_i: int, - *, - size_t: int | None = None, - concatenated_acdc_df: pd.DataFrame | None = None, - acdc_key: str = 'acdc_df', -) -> CcaFutureRemovalResult: - """Return future frame-table CCA removals from ``from_frame_i`` onward.""" - stop_frame_i = None - acdc_dfs_by_frame = {} - cache_frame_indices = [] - removed_frame_indices = [] - stop_at = len(frame_records) if size_t is None else int(size_t) - - for frame_i in range(int(from_frame_i), stop_at): - cache_frame_indices.append(frame_i) - acdc_df = frame_records[frame_i][acdc_key] - result = remove_cell_cycle_annotations(acdc_df, cca_colnames) - if result.missing_frame: - stop_frame_i = frame_i - break - if not result.removed: - continue - - acdc_dfs_by_frame[frame_i] = result.acdc_df - removed_frame_indices.append(frame_i) - - truncated_acdc_df = concatenated_acdc_df - if concatenated_acdc_df is not None: - frames = concatenated_acdc_df.index.get_level_values(0) - if from_frame_i in frames: - truncated_acdc_df = concatenated_acdc_df.loc[:from_frame_i] - - return CcaFutureRemovalResult( - acdc_dfs_by_frame=acdc_dfs_by_frame, - cache_frame_indices=cache_frame_indices, - removed_frame_indices=removed_frame_indices, - concatenated_acdc_df=truncated_acdc_df, - stopped_at_frame_i=stop_frame_i, - ) - - -def store_cell_cycle_annotations( - acdc_df: pd.DataFrame | None, - cca_df: pd.DataFrame | None, - cca_colnames, -) -> pd.DataFrame | None: - """Return ``acdc_df`` with ``cca_df`` annotations merged in.""" - if acdc_df is None or cca_df is None: - return acdc_df - - if has_cell_cycle_annotations(acdc_df): - updated_acdc_df = acdc_df.copy() - updated_acdc_df[list(cca_colnames)] = cca_df[list(cca_colnames)] - return updated_acdc_df - - metadata_df = acdc_df.drop(cca_df.columns, axis=1, errors='ignore') - return metadata_df.join(cca_df, how='left') - - -from .cell_cycle_frames import ( # noqa: E402 - CcaFrameResolutionResult, - CcaFrameStoreResult, - CcaMissingFramesInitResult, - normalize_loaded_cell_cycle_frame_annotations, - prepare_cell_cycle_checker_annotations, - prepare_missing_cell_cycle_frame_annotations, - resolve_cell_cycle_annotations, - store_cell_cycle_frame_annotations, -) - - -def apply_manual_cca_changes( - cca_df: pd.DataFrame, - changes, -) -> pd.DataFrame: - """Return ``cca_df`` with manual CCA table changes applied.""" - updated_cca_df = cca_df.copy() - for cell_id, changes_for_id in changes.items(): - if cell_id not in updated_cca_df.index: - continue - for column, (_old_value, new_value) in changes_for_id.items(): - updated_cca_df.at[cell_id, column] = new_value - return updated_cca_df - - -def missing_cell_cycle_annotation_items( - positions_frame_records, - cca_colnames, - *, - is_snapshot: bool = False, -) -> list[MissingCcaAnnotationItem]: - """Return frames whose CCA annotation columns contain missing values.""" - missing_items = [] - for position_i, frame_records in enumerate(positions_frame_records): - for frame_i, frame_record in enumerate(frame_records): - acdc_df = frame_record['acdc_df'] - if not has_cell_cycle_annotations(acdc_df): - continue - - cca_df = acdc_df[list(cca_colnames)] - if not cca_df.isnull().values.any(): - continue - - missing_items.append( - MissingCcaAnnotationItem( - position_i=position_i, - frame_i=None if is_snapshot else frame_i, - cca_df=cca_df, - ) - ) - - return missing_items - - -def s_phase_relative_ids_gone( - previous_cca_df: pd.DataFrame, - current_ids, -) -> list[int]: - """Return S-phase relative IDs that disappeared while their pair remains.""" - current_ids = set(current_ids) - disappeared_ids = [] - for cc_series in previous_cca_df.itertuples(): - if cc_series.cell_cycle_stage != 'S': - continue - - cell_id = cc_series.Index - relative_id = cc_series.relative_ID - if relative_id == -1: - continue - if relative_id not in current_ids and cell_id in current_ids: - disappeared_ids.append(relative_id) - - return disappeared_ids - - -def mark_current_relative_after_disappearance( - cca_df: pd.DataFrame, - cell_id: int, - division_frame_i: int, -) -> pd.DataFrame: - """Return current CCA with surviving relative marked as divided.""" - updated_cca_df = cca_df.copy() - updated_cca_df.at[cell_id, 'generation_num'] += 1 - updated_cca_df.at[cell_id, 'division_frame_i'] = division_frame_i - updated_cca_df.at[cell_id, 'relationship'] = 'mother' - return updated_cca_df - - -def mark_disappeared_before_division_frame( - cca_df: pd.DataFrame | None, - gone_id: int, - relative_id: int, - generation_num: int, -) -> CcaDisappearedBeforeDivisionFrameResult: - """Mark one past CCA frame while generation continuity holds.""" - if cca_df is None: - return CcaDisappearedBeforeDivisionFrameResult( - cca_df=None, - should_store=False, - stop=True, - ) - - try: - if cca_df.at[relative_id, 'generation_num'] != generation_num: - return CcaDisappearedBeforeDivisionFrameResult( - cca_df=cca_df, - should_store=False, - stop=True, - ) - except Exception: - return CcaDisappearedBeforeDivisionFrameResult( - cca_df=cca_df, - should_store=False, - stop=True, - ) - - updated_cca_df = cca_df.copy() - updated_cca_df.at[gone_id, 'disappears_before_division'] = 1 - updated_cca_df.at[relative_id, 'daughter_disappears_before_division'] = 1 - return CcaDisappearedBeforeDivisionFrameResult( - cca_df=updated_cca_df, - should_store=True, - stop=False, - ) - - -def propagate_s_phase_disappearance_divisions( - previous_cca_df: pd.DataFrame, - current_cca_df: pd.DataFrame | None, - current_frame_i: int, - current_ids, - *, - past_cca_frames=(), - disappeared_ids=None, -) -> CcaSPhaseDisappearancePropagationResult: - """Return CCA updates for S-phase cells whose relatives disappeared.""" - current_frame_i = int(current_frame_i) - previous_frame_i = current_frame_i - 1 - if disappeared_ids is None: - disappeared_ids = s_phase_relative_ids_gone( - previous_cca_df, - current_ids, - ) - else: - disappeared_ids = list(disappeared_ids) - - previous_update = previous_cca_df.copy() - current_update = None if current_cca_df is None else current_cca_df.copy() - past_cca_frames = list(past_cca_frames) - updated_cca_dfs_by_frame = {} - automatically_divided_ids = [] - - for gone_id in disappeared_ids: - relative_id = previous_update.at[gone_id, 'relative_ID'] - generation_num = previous_update.at[relative_id, 'generation_num'] - - previous_result = mark_disappeared_before_division_frame( - previous_update, - gone_id, - relative_id, - generation_num, - ) - if previous_result.should_store: - previous_update = previous_result.cca_df - - annotate_division( - previous_update, - gone_id, - relative_id, - frame_i=previous_frame_i, - ) - updated_cca_dfs_by_frame[previous_frame_i] = previous_update - - if current_update is not None: - current_update = mark_current_relative_after_disappearance( - current_update, - relative_id, - previous_frame_i, - ) - - automatically_divided_ids.append(relative_id) - - for past_frame_i, past_cca_df in past_cca_frames: - past_update = updated_cca_dfs_by_frame.get( - past_frame_i, - past_cca_df, - ) - result = mark_disappeared_before_division_frame( - past_update, - gone_id, - relative_id, - generation_num, - ) - if result.stop: - break - if result.should_store: - updated_cca_dfs_by_frame[past_frame_i] = result.cca_df - - return CcaSPhaseDisappearancePropagationResult( - previous_cca_df=previous_update, - current_cca_df=current_update, - updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, - disappeared_ids=disappeared_ids, - automatically_divided_ids=automatically_divided_ids, - ) - - -def reset_cca_future_flags(cca_df: pd.DataFrame) -> pd.DataFrame: - """Clear future-cycle flags that no longer apply to the current CCA frame.""" - updated_cca_df = cca_df.copy() - s_phase_mask = updated_cca_df.cell_cycle_stage == 'S' - updated_cca_df.loc[s_phase_mask, 'will_divide'] = 0 - - mothers_mask = ( - (updated_cca_df.relationship == 'mother') - & s_phase_mask - ) - bud_mask = updated_cca_df.relationship == 'bud' - - updated_cca_df.loc[ - mothers_mask, 'daughter_disappears_before_division' - ] = 0 - updated_cca_df.loc[bud_mask, 'disappears_before_division'] = 0 - return updated_cca_df - - -def reset_will_divide_for_generations( - global_cca_df: pd.DataFrame, - cell_generation_ids, -) -> pd.DataFrame: - """Return concatenated CCA table with selected ``will_divide`` values reset.""" - updated_cca_df = global_cca_df.copy() - generation_index_df = ( - updated_cca_df.reset_index() - .set_index(['Cell_ID', 'generation_num']) - ) - generation_index_df.loc[cell_generation_ids, 'will_divide'] = 0 - return ( - generation_index_df.reset_index() - .set_index(['frame_i', 'Cell_ID']) - ) - - -def will_divide_without_next_generation_ids( - global_cca_df: pd.DataFrame, -) -> list[tuple[int, int]]: - """Return ``(Cell_ID, generation_num)`` pairs with stale ``will_divide``.""" - global_cca_will_divide = global_cca_df[global_cca_df['will_divide'] > 0] - global_cca_will_divide = global_cca_will_divide.reset_index() - - cell_generation_index = ( - global_cca_df.reset_index() - .set_index(['Cell_ID', 'generation_num']) - .index - ) - - next_gen_will_divide_df = ( - global_cca_will_divide[['Cell_ID', 'generation_num']].copy() - ) - next_gen_will_divide_df['generation_num'] += 1 - next_gen_will_divide_index = ( - next_gen_will_divide_df.reset_index() - .set_index(['Cell_ID', 'generation_num']) - .index - ) - - wrong_next_generation_ids = ( - next_gen_will_divide_index.difference(cell_generation_index) - .to_frame() - .to_numpy() - ) - if wrong_next_generation_ids.size == 0: - return [] - - wrong_next_generation_ids[:, -1] -= 1 - return [ - (cell_id, generation_num) - for cell_id, generation_num in wrong_next_generation_ids - ] - - -def fix_will_divide_without_next_generation( - acdc_df: pd.DataFrame, -) -> pd.DataFrame: - """Return ``acdc_df`` with stale ``will_divide`` values reset to 0.""" - if 'cell_cycle_stage' not in acdc_df.columns: - return acdc_df - - required_cols = ['frame_i', 'Cell_ID', 'generation_num', 'will_divide'] - - cca_df_mask = ~acdc_df['cell_cycle_stage'].isna() - cca_df = acdc_df[cca_df_mask].reset_index()[required_cols] - - cell_generation_ids = will_divide_without_next_generation_ids(cca_df) - if not cell_generation_ids: - return acdc_df - - cca_df = reset_will_divide_for_generations(cca_df, cell_generation_ids) - updated_acdc_df = acdc_df.reset_index().set_index(['frame_i', 'Cell_ID']) - - updated_acdc_df.loc[cca_df.index, 'will_divide'] = cca_df['will_divide'] - return updated_acdc_df - - -def bud_known_history_status( - cell_id: int, - past_cca_frames, - base_status: pd.Series, -) -> pd.Series | None: - """Return restored known-history status for a bud absent in past frames.""" - for frame_i, cca_df in past_cca_frames: - if cca_df is None: - continue - if cell_id in cca_df.index: - continue - - bud_status = base_status.copy() - bud_status['cell_cycle_stage'] = 'S' - bud_status['generation_num'] = 0 - bud_status['relationship'] = 'bud' - bud_status['emerg_frame_i'] = frame_i + 1 - bud_status['is_history_known'] = True - return bud_status - - return None - - -def relative_status_before_bud_emergence( - bud_id: int, - current_mother_id: int, - past_cca_frames, - base_mother_status: pd.Series, - base_bud_status: pd.Series, -) -> pd.Series: - """Return a mother's status from before ``bud_id`` emerged.""" - for frame_i, cca_df in past_cca_frames: - if cca_df is None: - continue - if bud_id in cca_df.index: - continue - - if current_mother_id in cca_df.index: - return cca_df.loc[current_mother_id].copy() - - bud_status = base_bud_status.copy() - bud_status['cell_cycle_stage'] = 'S' - bud_status['generation_num'] = 0 - bud_status['relationship'] = 'bud' - bud_status['emerg_frame_i'] = frame_i + 1 - bud_status['is_history_known'] = True - return bud_status - - return base_mother_status.copy() - - -def assign_bud_to_mother( - cca_df: pd.DataFrame, - bud_id: int, - mother_id: int, - *, - corrected_frame_i: int | None = None, - update_mother: bool = True, - update_mother_only_if_g1: bool = False, - mother_generation_num: int | None = None, - mother_relationship: str | None = 'mother', - previous_mother_id: int | None = None, - previous_mother_status: pd.Series | None = None, - reset_previous_mother: bool = False, -) -> pd.DataFrame: - """Return ``cca_df`` with ``bud_id`` assigned to ``mother_id``.""" - updated_cca_df = cca_df.copy() - - if reset_previous_mother and previous_mother_id in updated_cca_df.index: - updated_cca_df.at[previous_mother_id, 'relative_ID'] = -1 - updated_cca_df.at[previous_mother_id, 'generation_num'] = 2 - updated_cca_df.at[previous_mother_id, 'cell_cycle_stage'] = 'G1' - - updated_cca_df.at[bud_id, 'relative_ID'] = mother_id - updated_cca_df.at[bud_id, 'generation_num'] = 0 - updated_cca_df.at[bud_id, 'relationship'] = 'bud' - updated_cca_df.at[bud_id, 'cell_cycle_stage'] = 'S' - if corrected_frame_i is not None: - updated_cca_df.at[bud_id, 'corrected_on_frame_i'] = corrected_frame_i - - should_update_mother = update_mother and mother_id in updated_cca_df.index - if should_update_mother and update_mother_only_if_g1: - should_update_mother = ( - updated_cca_df.at[mother_id, 'cell_cycle_stage'] == 'G1' - ) - if should_update_mother: - updated_cca_df.at[mother_id, 'relative_ID'] = bud_id - updated_cca_df.at[mother_id, 'cell_cycle_stage'] = 'S' - if mother_generation_num is not None: - updated_cca_df.at[mother_id, 'generation_num'] = mother_generation_num - if mother_relationship is not None: - updated_cca_df.at[mother_id, 'relationship'] = mother_relationship - - if ( - previous_mother_status is not None - and previous_mother_id in updated_cca_df.index - ): - updated_cca_df.loc[previous_mother_id] = previous_mother_status - - return updated_cca_df - - -def future_bud_division( - bud_id: int, - future_cca_frames, -) -> FutureBudDivisionResult | None: - """Return first future frame where ``bud_id`` is already in G1.""" - for frame_i, cca_df in future_cca_frames: - if frame_i == 0: - continue - if cca_df is None: - return None - if bud_id not in cca_df.index: - return None - - cell_cycle_stage = cca_df.at[bud_id, 'cell_cycle_stage'] - if cell_cycle_stage == 'G1': - return FutureBudDivisionResult( - frame_i=frame_i, - mother_id=cca_df.at[bud_id, 'relative_ID'], - ) - - return None - - -def mother_not_g1_before_bud_emergence_frame( - mother_id: int, - bud_id: int, - wrong_bud_id: int, - past_cca_frames, -) -> int | None: - """Return first frame without required mother G1 before bud emergence.""" - for frame_i, cca_df in past_cca_frames: - if bud_id in cca_df.index: - continue - - if cca_df.at[mother_id, 'cell_cycle_stage'] == 'G1': - return None - - bud_id_previous_cycle = cca_df.at[mother_id, 'relative_ID'] - if bud_id_previous_cycle != wrong_bud_id: - return frame_i + 1 - - break - - return None - - -def dead_or_excluded_mother_pairs( - cca_df: pd.DataFrame, - acdc_df: pd.DataFrame, - frame_i: int, -) -> list[MotherBudPair]: - """Return new bud pairings where the mother is dead or excluded.""" - buds_df = cca_df[ - (cca_df.relationship == 'bud') - & (cca_df.emerg_frame_i == frame_i) - ] - if buds_df.empty: - return [] - - mother_ids = buds_df.relative_ID.to_list() - mothers_df = acdc_df.loc[mother_ids] - excluded_df = mothers_df[ - (mothers_df.is_cell_dead > 0) - | (mothers_df.is_cell_excluded > 0) - ] - - return [ - MotherBudPair(bud_id=bud_id, mother_id=mother_id) - for mother_id, bud_id in zip( - excluded_df.index.to_list(), - excluded_df.relative_ID.to_list(), - ) - ] - - -def evaluate_mother_future_eligibility_frame( - cca_df: pd.DataFrame | None, - bud_id: int, - mother_id: int, - frame_i: int, - g1_duration: int, - last_cca_frame_i: int, -) -> MotherEligibilityFrameResult: - """Check one future frame for a proposed mother-bud assignment.""" - if cca_df is None: - return MotherEligibilityFrameResult( - issue=None, - stop=True, - g1_duration=g1_duration, - ) - - if bud_id not in cca_df.index: - return MotherEligibilityFrameResult( - issue=None, - stop=True, - g1_duration=g1_duration, - ) - - is_still_bud = cca_df.at[bud_id, 'relationship'] == 'bud' - if not is_still_bud: - return MotherEligibilityFrameResult( - issue=None, - stop=True, - g1_duration=g1_duration, - ) - - next_g1_duration = g1_duration + 1 - cell_cycle_stage = cca_df.at[mother_id, 'cell_cycle_stage'] - if cell_cycle_stage == 'G1': - return MotherEligibilityFrameResult( - issue=None, - stop=False, - g1_duration=next_g1_duration, - ) - - issue = MotherEligibilityIssue( - mother_id=mother_id, - bud_id=bud_id, - frame_i=frame_i, - reason='not_G1_in_the_future', - blocks_assignment=( - g1_duration == 1 - and frame_i != last_cca_frame_i - ), - ) - return MotherEligibilityFrameResult( - issue=issue, - stop=False, - g1_duration=next_g1_duration, - ) - - -def evaluate_mother_past_eligibility_frame( - cca_df: pd.DataFrame, - bud_id: int, - mother_id: int, - frame_i: int, -) -> MotherEligibilityFrameResult: - """Check one past frame for a proposed mother-bud assignment.""" - is_bud_existing = bud_id in cca_df.index - is_mother_existing = mother_id in cca_df.index - - if not is_mother_existing: - return MotherEligibilityFrameResult(issue=None, stop=True) - - cell_cycle_stage = cca_df.at[mother_id, 'cell_cycle_stage'] - if cell_cycle_stage != 'G1' and is_bud_existing: - issue = MotherEligibilityIssue( - mother_id=mother_id, - bud_id=bud_id, - frame_i=frame_i, - reason='not_G1_in_the_past', - blocks_assignment=True, - ) - return MotherEligibilityFrameResult(issue=issue, stop=True) - - if not is_bud_existing: - issue = None - if cell_cycle_stage != 'G1': - issue = MotherEligibilityIssue( - mother_id=mother_id, - bud_id=bud_id, - frame_i=frame_i, - reason='single_frame_G1_duration', - blocks_assignment=True, - ) - return MotherEligibilityFrameResult(issue=issue, stop=True) - - return MotherEligibilityFrameResult(issue=None, stop=False) - - -def apply_mother_bud_pairing( - cca_df: pd.DataFrame, - bud_id: int, - mother_id: int, - corrected_frame_i: int, - *, - set_mother_s_if_g1: bool = True, -) -> pd.DataFrame: - """Return ``cca_df`` with reciprocal mother-bud IDs corrected.""" - updated_cca_df = cca_df.copy() - updated_cca_df.at[bud_id, 'relative_ID'] = mother_id - updated_cca_df.at[mother_id, 'relative_ID'] = bud_id - updated_cca_df.at[bud_id, 'corrected_on_frame_i'] = corrected_frame_i - updated_cca_df.at[mother_id, 'corrected_on_frame_i'] = corrected_frame_i - - if ( - set_mother_s_if_g1 - and updated_cca_df.at[mother_id, 'cell_cycle_stage'] == 'G1' - ): - updated_cca_df.at[mother_id, 'cell_cycle_stage'] = 'S' - - return updated_cca_df - - -def wrong_bud_id_for_mother(cca_df: pd.DataFrame, mother_id: int) -> int | None: - """Return mother's current bud ID if it is a bud row in ``cca_df``.""" - try: - relative_id = cca_df.at[mother_id, 'relative_ID'] - except Exception: - return None - if relative_id not in cca_df.index: - return None - if cca_df.at[relative_id, 'relationship'] != 'bud': - return None - return int(relative_id) - - -def mother_status_before_wrong_bud( - mother_id: int, - wrong_bud_id: int, - past_cca_frames, - base_status: pd.Series, -) -> pd.Series: - """Return mother's status from before ``wrong_bud_id`` emerged.""" - for cca_df in past_cca_frames: - if cca_df is None: - continue - if wrong_bud_id not in cca_df.index: - return cca_df.loc[mother_id].copy() - return base_status.copy() - - -def restore_mother_status_for_wrong_bud_frame( - cca_df: pd.DataFrame, - mother_id: int, - wrong_bud_id: int, - mother_status: pd.Series, - corrected_frame_i: int, -) -> CcaMotherStatusRestoreResult: - """Restore mother status if ``wrong_bud_id`` is present in ``cca_df``.""" - if wrong_bud_id not in cca_df.index: - return CcaMotherStatusRestoreResult(cca_df=cca_df, restored=False) - - updated_cca_df = cca_df.copy() - updated_cca_df.loc[mother_id] = mother_status - updated_cca_df.at[mother_id, 'corrected_on_frame_i'] = corrected_frame_i - return CcaMotherStatusRestoreResult(cca_df=updated_cca_df, restored=True) - - -def restore_mother_status_until_g1( - cca_df: pd.DataFrame, - mother_id: int, - mother_status: pd.Series, - corrected_frame_i: int, -) -> CcaMotherStatusRestoreResult: - """Restore mother status unless the mother is already back in G1.""" - if cca_df.at[mother_id, 'cell_cycle_stage'] == 'G1': - return CcaMotherStatusRestoreResult(cca_df=cca_df, restored=False) - - updated_cca_df = cca_df.copy() - updated_cca_df.loc[mother_id] = mother_status - updated_cca_df.at[mother_id, 'corrected_on_frame_i'] = corrected_frame_i - return CcaMotherStatusRestoreResult(cca_df=updated_cca_df, restored=True) - - -def mark_will_divide_frame( - cca_df: pd.DataFrame, - cell_id: int, - relative_id: int, - generation_num: int | None = None, -) -> CcaWillDivideFrameResult: - """Mark ``cell_id`` and ``relative_id`` as will-divide for one frame.""" - if cell_id not in cca_df.index: - return CcaWillDivideFrameResult( - cca_df=cca_df, - generation_num=generation_num, - should_store=False, - stop=True, - ) - - if generation_num is None: - generation_num = cca_df.at[cell_id, 'generation_num'] - if cca_df.at[cell_id, 'generation_num'] != generation_num: - return CcaWillDivideFrameResult( - cca_df=cca_df, - generation_num=generation_num, - should_store=False, - stop=True, - ) - - updated_cca_df = cca_df.copy() - updated_cca_df.at[cell_id, 'will_divide'] = 1 - updated_cca_df.at[relative_id, 'will_divide'] = 1 - return CcaWillDivideFrameResult( - cca_df=updated_cca_df, - generation_num=generation_num, - should_store=True, - stop=False, - ) - - -def division_undo_blocking_frame( - cell_id: int, - relative_id: int, - current_frame_i: int, - current_cca_df: pd.DataFrame, - future_cca_frames=(), - past_cca_frames=(), -) -> int | None: - """Return frame index blocking division undo, or ``None`` if allowed.""" - if current_cca_df.at[relative_id, 'cell_cycle_stage'] == 'S': - return current_frame_i - - for frame_i, cca_df in future_cca_frames: - if cca_df is None: - break - if relative_id not in cca_df.index: - continue - if cca_df.at[relative_id, 'cell_cycle_stage'] == 'S': - return frame_i - - for frame_i, cca_df in past_cca_frames: - if cca_df is None: - break - if cell_id not in cca_df.index or relative_id not in cca_df.index: - break - if cca_df.at[cell_id, 'cell_cycle_stage'] == 'S': - break - if cca_df.at[relative_id, 'cell_cycle_stage'] == 'S': - return frame_i - - return None - - -def annotate_division( - cca_df: pd.DataFrame, - cell_id: int, - relative_id: int, - frame_i: int, -) -> bool: - """Annotate a division between two related cells in ``cca_df``.""" - cca_df.at[cell_id, 'cell_cycle_stage'] = 'G1' - cca_df.at[relative_id, 'cell_cycle_stage'] = 'G1' - - if frame_i > 0: - cell_generation = cca_df.at[cell_id, 'generation_num'] - cca_df.at[cell_id, 'generation_num'] += 1 - cca_df.at[cell_id, 'division_frame_i'] = frame_i - relative_generation = cca_df.at[relative_id, 'generation_num'] - cca_df.at[relative_id, 'generation_num'] = relative_generation + 1 - cca_df.at[relative_id, 'division_frame_i'] = frame_i - if cell_generation < relative_generation: - cca_df.at[cell_id, 'relationship'] = 'mother' - else: - cca_df.at[relative_id, 'relationship'] = 'mother' - else: - cca_df.at[cell_id, 'generation_num'] = 2 - cca_df.at[relative_id, 'generation_num'] = 2 - cca_df.at[cell_id, 'division_frame_i'] = -1 - cca_df.at[relative_id, 'division_frame_i'] = -1 - cca_df.at[cell_id, 'relationship'] = 'mother' - cca_df.at[relative_id, 'relationship'] = 'mother' - - return True - - -def undo_division_annotation( - cca_df: pd.DataFrame, - cell_id: int, - relative_id: int, -) -> bool: - """Undo a division annotation between two related cells in ``cca_df``.""" - cca_df.at[cell_id, 'cell_cycle_stage'] = 'S' - cell_generation = cca_df.at[cell_id, 'generation_num'] - cca_df.at[cell_id, 'generation_num'] -= 1 - cca_df.at[cell_id, 'division_frame_i'] = -1 - - cca_df.at[relative_id, 'cell_cycle_stage'] = 'S' - relative_generation = cca_df.at[relative_id, 'generation_num'] - cca_df.at[relative_id, 'generation_num'] -= 1 - cca_df.at[relative_id, 'division_frame_i'] = -1 - - if cell_generation < relative_generation: - cca_df.at[cell_id, 'relationship'] = 'bud' - else: - cca_df.at[relative_id, 'relationship'] = 'bud' - - cca_df.at[cell_id, 'will_divide'] = 0 - cca_df.at[relative_id, 'will_divide'] = 0 - return True - - -def undo_bud_mother_assignment( - cca_df: pd.DataFrame, - cell_id: int, -) -> bool: - """Undo a bud/mother assignment for ``cell_id`` and its relative if present.""" - relative_id = cca_df.at[cell_id, 'relative_ID'] - cell_cycle_stage = cca_df.at[cell_id, 'cell_cycle_stage'] - if cell_cycle_stage == 'G1': - return False - - cca_df.at[cell_id, 'relative_ID'] = -1 - cca_df.at[cell_id, 'generation_num'] = 2 - cca_df.at[cell_id, 'cell_cycle_stage'] = 'G1' - cca_df.at[cell_id, 'relationship'] = 'mother' - - if relative_id in cca_df.index: - cca_df.at[relative_id, 'relative_ID'] = -1 - cca_df.at[relative_id, 'generation_num'] = 2 - cca_df.at[relative_id, 'cell_cycle_stage'] = 'G1' - cca_df.at[relative_id, 'relationship'] = 'mother' - - return True - - -def toggle_history_knowledge( - cca_df: pd.DataFrame, - cell_id: int, - *, - status_when_emerged: pd.Series | None = None, -) -> bool: - """Toggle whether ``cell_id`` has known history in ``cca_df``.""" - is_history_known = cca_df.at[cell_id, 'is_history_known'] - if is_history_known: - cca_df.at[cell_id, 'is_history_known'] = False - cca_df.at[cell_id, 'cell_cycle_stage'] = 'G1' - cca_df.at[cell_id, 'generation_num'] += 2 - cca_df.at[cell_id, 'emerg_frame_i'] = -1 - cca_df.at[cell_id, 'relative_ID'] = -1 - cca_df.at[cell_id, 'relationship'] = 'mother' - else: - if status_when_emerged is None: - raise ValueError( - 'status_when_emerged is required to restore known history' - ) - cca_df.loc[cell_id] = status_when_emerged - - return True - - -from .cell_cycle_history import ( # noqa: E402 - CcaHistoryKnowledgePropagationResult, - apply_history_knowledge_to_frame, - known_history_status_for_bud, - propagate_history_knowledge, -) -from .cell_cycle_divisions import ( # noqa: E402 - CcaBudMotherAssignmentPropagationResult, - CcaBudMotherChangeEligibilityResult, - CcaManualDivisionPropagationResult, - CcaMotherAssignmentEligibilityResult, - CcaMotherBudPairingsResult, - CcaSwapMothersEligibilityResult, - CcaSwapMothersFutureDivisionResult, - CcaSwapMothersPairingPlan, - CcaSwapMothersPastRestoreResult, - CcaSwapMothersPropagationResult, - CcaWillDividePropagationResult, - apply_mother_bud_pairings, - previous_relative_status_before_bud_emergence, - bud_mother_change_eligibility, - mother_assignment_eligibility, - propagate_bud_mother_assignment, - propagate_manual_division_annotation, - propagate_swap_mothers_assignment, - propagate_swap_mothers_future_division, - propagate_will_divide, - restore_swap_mothers_past_status, - swap_mothers_eligibility, - swap_mothers_pairing_plan, -) diff --git a/cellacdc/domain/cell_cycle_auto.py b/cellacdc/domain/cell_cycle_auto.py deleted file mode 100644 index 4287e277f..000000000 --- a/cellacdc/domain/cell_cycle_auto.py +++ /dev/null @@ -1,283 +0,0 @@ -"""Automatic cell-cycle annotation assignment operations.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np -import pandas as pd -from scipy.optimize import linear_sum_assignment - -from .cell_cycle import MotherBudPair, has_cell_cycle_annotations - - -@dataclass(frozen=True) -class AutoCcaFrameInitResult: - """Current-frame CCA table prepared for automatic assignment.""" - - cca_df: pd.DataFrame - - -@dataclass(frozen=True) -class AutoCcaRepeatFrameResult: - """Repeat-auto-CCA state plus the new IDs still eligible.""" - - is_last_visited_again: bool - new_ids: list[int] - - -@dataclass(frozen=True) -class AutoCcaAssignmentResult: - """Mother-bud assignments selected from an auto-CCA cost matrix.""" - - pairs: list[MotherBudPair] - assigned_mother_ids: set[int] - - -def auto_cca_repeat_frame_state( - current_acdc_df: pd.DataFrame | None, - next_acdc_df: pd.DataFrame | None, - new_ids, - *, - enforce_all: bool = False, -) -> AutoCcaRepeatFrameResult: - """Return repeat-auto-CCA state and IDs eligible for reassignment.""" - original_new_ids = list(new_ids) - if not has_cell_cycle_annotations(current_acdc_df): - return AutoCcaRepeatFrameResult(False, original_new_ids) - if enforce_all: - return AutoCcaRepeatFrameResult(False, original_new_ids) - - filtered_new_ids = [ - cell_id for cell_id in original_new_ids - if current_acdc_df.at[cell_id, 'is_history_known'] - and current_acdc_df.at[cell_id, 'cell_cycle_stage'] == 'S' - ] - is_last_visited_again = ( - not has_cell_cycle_annotations(next_acdc_df) - ) - return AutoCcaRepeatFrameResult( - is_last_visited_again=is_last_visited_again, - new_ids=filtered_new_ids, - ) - - -def merge_current_with_found_cca_rows( - current_cca_df: pd.DataFrame, - found_cca_dfs, -) -> pd.DataFrame: - """Merge current and found CCA rows, keeping current rows first.""" - found_cca_dfs = list(found_cca_dfs) - if not found_cca_dfs: - return current_cca_df - - cca_df = pd.concat([current_cca_df, *found_cca_dfs]) - unique_idx = ~cca_df.index.duplicated(keep='first') - return cca_df[unique_idx] - - -def prepare_auto_cca_current_frame( - previous_cca_df: pd.DataFrame, - current_acdc_df: pd.DataFrame, - cca_colnames, - *, - current_cca_df: pd.DataFrame | None = None, - found_cca_dfs=(), -) -> AutoCcaFrameInitResult: - """Return the current CCA table to use before auto-assignment.""" - if current_cca_df is None: - cca_df = previous_cca_df.copy() - else: - cca_df = current_acdc_df[list(cca_colnames)].copy() - - cca_df = merge_current_with_found_cca_rows(cca_df, found_cca_dfs) - return AutoCcaFrameInitResult(cca_df=cca_df) - - -def uncorrected_new_ids_for_auto_cca( - new_ids, - current_cca_df: pd.DataFrame, -) -> list[int]: - """Filter out new IDs that were manually corrected already.""" - try: - corrected_ids = set( - current_cca_df[current_cca_df['corrected_on_frame_i'] > 0].index - ) - except Exception: - corrected_ids = set() - - return [cell_id for cell_id in new_ids if cell_id not in corrected_ids] - - -def auto_cca_candidate_mother_ids( - previous_cca_df: pd.DataFrame, - previous_acdc_df: pd.DataFrame, - current_ids, - *, - current_cca_df: pd.DataFrame | None = None, - include_current_g1: bool = False, - current_frame_i: int | None = None, -): - """Return candidate G1 mother IDs for automatic CCA assignment.""" - try: - previous_g1_df = previous_cca_df[ - previous_cca_df['cell_cycle_stage'] == 'G1' - ] - previous_g1_df = previous_g1_df[ - ~previous_acdc_df.loc[previous_g1_df.index]['is_cell_dead'] - ] - candidate_ids = set(previous_g1_df.index) - except Exception: - candidate_ids = set() - - if include_current_g1 and current_cca_df is not None: - current_g1_df = current_cca_df[ - current_cca_df['cell_cycle_stage'] == 'G1' - ] - new_cell_g1 = [ - cell_id for cell_id in current_g1_df.index - if cell_id not in previous_cca_df.index - ] - candidate_ids.update(new_cell_g1) - - if ( - current_frame_i is not None - and 'corrected_on_frame_i' in current_cca_df.columns - ): - cells_s_current = current_cca_df[ - (current_cca_df['cell_cycle_stage'] == 'S') - & (current_cca_df['corrected_on_frame_i'] == current_frame_i) - ].index - candidate_ids = candidate_ids - set(cells_s_current) - - current_ids = set(current_ids) - return [cell_id for cell_id in candidate_ids if cell_id in current_ids] - - -def auto_cca_assignments_from_cost( - cost, - mother_ids, - bud_ids, -) -> AutoCcaAssignmentResult: - """Return minimum-cost mother-bud assignments from a cost matrix.""" - mother_ids = list(mother_ids) - bud_ids = list(bud_ids) - row_idx, col_idx = linear_sum_assignment(cost) - pairs = [ - MotherBudPair( - bud_id=bud_ids[bud_idx], - mother_id=mother_ids[mother_idx], - ) - for mother_idx, bud_idx in zip(row_idx, col_idx) - ] - return AutoCcaAssignmentResult( - pairs=pairs, - assigned_mother_ids={pair.mother_id for pair in pairs}, - ) - - -def apply_auto_cca_assignments( - cca_df: pd.DataFrame, - assignments: AutoCcaAssignmentResult, - frame_i: int, - base_bud_status: pd.Series, - *, - previous_cca_df: pd.DataFrame | None = None, - current_ids=None, -) -> pd.DataFrame: - """Apply selected auto-CCA mother-bud assignments to one frame.""" - updated_cca_df = cca_df - for pair in assignments.pairs: - updated_cca_df = apply_auto_bud_assignment( - updated_cca_df, - pair.bud_id, - pair.mother_id, - frame_i, - base_bud_status, - previous_cca_df=previous_cca_df, - new_mother_ids=assignments.assigned_mother_ids, - ) - - if current_ids is not None: - updated_cca_df = updated_cca_df.loc[list(current_ids)] - - return updated_cca_df - - -def nearest_point_2d_yx(points, all_others): - """Return minimum distance and nearest point between two YX point sets.""" - points = np.asarray(points) - all_others = np.asarray(all_others) - diff = points[:, np.newaxis] - all_others - dist = np.linalg.norm(diff, axis=2) - point_idx, other_idx = np.unravel_index(dist.argmin(), dist.shape) - return float(dist[point_idx, other_idx]), all_others[other_idx] - - -def auto_cca_cost_matrix_from_contours( - mother_ids, - bud_ids, - mother_contours, - bud_contours, -) -> np.ndarray: - """Build an auto-CCA cost matrix from mother and bud contours.""" - mother_ids = list(mother_ids) - bud_ids = list(bud_ids) - cost = np.full((len(mother_ids), len(bud_ids)), np.inf) - for mother_idx, mother_id in enumerate(mother_ids): - mother_contour = mother_contours.get(mother_id) - if mother_contour is None: - continue - for bud_idx, bud_id in enumerate(bud_ids): - bud_contour = bud_contours.get(bud_id) - if bud_contour is None: - continue - min_dist, _ = nearest_point_2d_yx(mother_contour, bud_contour) - cost[mother_idx, bud_idx] = min_dist - return cost - - -def auto_cca_cost_matrix_from_distances( - distance_matrix_df: pd.DataFrame, - mother_ids, - bud_ids, -) -> np.ndarray: - """Select an auto-CCA cost matrix from a precomputed distance table.""" - return distance_matrix_df.loc[list(mother_ids), list(bud_ids)].values - - -def apply_auto_bud_assignment( - cca_df: pd.DataFrame, - bud_id: int, - mother_id: int, - frame_i: int, - base_bud_status: pd.Series, - *, - previous_cca_df: pd.DataFrame | None = None, - new_mother_ids=(), -) -> pd.DataFrame: - """Return ``cca_df`` after one automatic bud-to-mother assignment.""" - updated_cca_df = cca_df.copy() - new_mother_ids = set(new_mother_ids) - - if bud_id in updated_cca_df.index and previous_cca_df is not None: - relative_id = updated_cca_df.at[bud_id, 'relative_ID'] - if relative_id in previous_cca_df.index and relative_id not in new_mother_ids: - updated_cca_df.loc[relative_id] = previous_cca_df.loc[relative_id] - - updated_cca_df.at[mother_id, 'relative_ID'] = bud_id - updated_cca_df.at[mother_id, 'cell_cycle_stage'] = 'S' - - bud_status = base_bud_status.copy() - bud_status['cell_cycle_stage'] = 'S' - bud_status['generation_num'] = 0 - bud_status['relative_ID'] = mother_id - bud_status['relationship'] = 'bud' - bud_status['emerg_frame_i'] = frame_i - bud_status['is_history_known'] = True - bud_status['corrected_on_frame_i'] = -1 - for column in bud_status.index: - if column not in updated_cca_df.columns: - updated_cca_df[column] = pd.NA - updated_cca_df.loc[bud_id] = bud_status - return updated_cca_df diff --git a/cellacdc/domain/cell_cycle_deletions.py b/cellacdc/domain/cell_cycle_deletions.py deleted file mode 100644 index 4da0c8dd5..000000000 --- a/cellacdc/domain/cell_cycle_deletions.py +++ /dev/null @@ -1,337 +0,0 @@ -"""Deleted-ID cell-cycle annotation table operations.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pandas as pd - -from .cell_cycle import ( - base_cell_cycle_annotation_status, - build_base_cell_cycle_annotations, -) - - -@dataclass(frozen=True) -class CcaDeletedIdsResult: - """CCA table after deleting IDs plus their relative-ID references.""" - - cca_df: pd.DataFrame - relative_ids: pd.Series - - -@dataclass(frozen=True) -class CcaRelativeRestoreResult: - """CCA table after restoring relative statuses.""" - - cca_df: pd.DataFrame - any_restored: bool - - -@dataclass(frozen=True) -class CcaDeletedRelativeStatusesResult: - """Current CCA table plus statuses restored for deleted relatives.""" - - cca_df: pd.DataFrame - relative_statuses: dict - - -@dataclass(frozen=True) -class CcaDeletedIdsPropagationResult: - """Deleted-ID updates across a current frame plus visited neighbors.""" - - current_cca_df: pd.DataFrame - updated_cca_dfs_by_frame: dict[int, pd.DataFrame] - undo_frame_indices: list[int] - relative_statuses: dict - - -def _frame_value(frame_values, frame_i: int): - if frame_values is None: - return None - if hasattr(frame_values, 'get'): - return frame_values.get(frame_i) - try: - return frame_values[frame_i] - except (IndexError, KeyError, TypeError): - return None - - -def _frame_count(frame_values, size_t: int | None = None) -> int: - if size_t is not None: - return int(size_t) - if hasattr(frame_values, 'keys'): - keys = list(frame_values.keys()) - return max(keys) + 1 if keys else 0 - return len(frame_values) - - -def delete_cca_ids( - cca_df: pd.DataFrame, - deleted_ids, -) -> CcaDeletedIdsResult: - """Return ``cca_df`` without ``deleted_ids`` and their relative IDs.""" - relative_ids = cca_df.reindex(deleted_ids, fill_value=-1)['relative_ID'] - updated_cca_df = cca_df.drop(deleted_ids, errors='ignore') - return CcaDeletedIdsResult( - cca_df=updated_cca_df, - relative_ids=relative_ids, - ) - - -def apply_cca_deleted_ids_to_frame( - cca_df: pd.DataFrame, - deleted_ids, - *, - drop_deleted: bool = True, - existing_ids=None, - base_cca_df: pd.DataFrame | None = None, -) -> pd.DataFrame: - """Drop deleted IDs or restore their base rows when labels still exist.""" - if drop_deleted: - return cca_df.drop(deleted_ids, errors='ignore') - - existing_ids = set(deleted_ids if existing_ids is None else existing_ids) - restore_ids = [label_id for label_id in deleted_ids if label_id in existing_ids] - if not restore_ids: - return cca_df - if base_cca_df is None: - raise ValueError('base_cca_df is required when restoring deleted IDs') - - updated_cca_df = cca_df.copy() - for label_id in restore_ids: - if label_id not in base_cca_df.index: - continue - updated_cca_df.loc[label_id] = base_cca_df.loc[label_id] - return updated_cca_df - - -def apply_deleted_cell_cycle_ids_to_frame( - cca_df: pd.DataFrame, - deleted_ids, - relative_statuses: dict, - *, - relative_ids=None, - drop_deleted: bool = True, - existing_ids=None, - base_values: dict | None = None, -) -> CcaRelativeRestoreResult: - """Apply deleted-ID updates and restore relative statuses for one frame.""" - base_cca_df = None - if not drop_deleted: - restore_ids = [label_id for label_id in deleted_ids if ( - existing_ids is None or label_id in existing_ids - )] - if restore_ids: - base_cca_df = build_base_cell_cycle_annotations( - restore_ids, - base_values=base_values, - ) - - updated_cca_df = apply_cca_deleted_ids_to_frame( - cca_df, - deleted_ids, - drop_deleted=drop_deleted, - existing_ids=existing_ids, - base_cca_df=base_cca_df, - ) - return restore_cca_relative_statuses( - updated_cca_df, - relative_statuses, - relative_ids=relative_ids, - ) - - -def restore_cca_relative_statuses( - cca_df: pd.DataFrame, - relative_statuses: dict, - relative_ids=None, -) -> CcaRelativeRestoreResult: - """Restore stored CCA statuses for relative IDs present in ``cca_df``.""" - if relative_ids is None: - relative_ids = relative_statuses.keys() - - updated_cca_df = cca_df.copy() - any_restored = False - required_cols = {'cell_cycle_stage', 'relationship'} - if not required_cols.issubset(updated_cca_df.columns): - return CcaRelativeRestoreResult(updated_cca_df, any_restored) - - for relative_id in relative_ids: - if relative_id not in relative_statuses: - continue - if relative_id not in updated_cca_df.index: - continue - updated_cca_df.loc[relative_id] = relative_statuses[relative_id] - any_restored = True - - return CcaRelativeRestoreResult(updated_cca_df, any_restored) - - -def deleted_relative_cca_status( - cca_df: pd.DataFrame, - relative_id: int, - base_status: pd.Series, - past_cca_dfs=(), -) -> pd.Series | None: - """Return the CCA status to restore for a deleted ID's relative.""" - try: - cell_cycle_stage = cca_df.at[relative_id, 'cell_cycle_stage'] - relationship = cca_df.at[relative_id, 'relationship'] - except Exception: - return None - - cca_status = base_status.copy() - if relationship == 'mother' and cell_cycle_stage == 'S': - for past_cca_df in past_cca_dfs: - if past_cca_df is None or relative_id not in past_cca_df.index: - continue - cell_cycle_stage_past = past_cca_df.at[ - relative_id, 'cell_cycle_stage' - ] - if cell_cycle_stage_past == 'G1': - cca_status = past_cca_df.loc[relative_id].copy() - break - - return cca_status - - -def restore_deleted_relative_cell_cycle_statuses( - cca_df: pd.DataFrame, - relative_ids, - *, - past_cca_dfs=(), - base_values: dict | None = None, -) -> CcaDeletedRelativeStatusesResult: - """Restore statuses for relatives of deleted IDs on the current frame.""" - past_cca_dfs = list(past_cca_dfs) - updated_cca_df = cca_df.copy() - relative_statuses = {} - for relative_id in relative_ids: - base_status = base_cell_cycle_annotation_status(base_values) - base_status.name = relative_id - cca_status = deleted_relative_cca_status( - updated_cca_df, - relative_id, - base_status, - past_cca_dfs=past_cca_dfs, - ) - if cca_status is None: - continue - - updated_cca_df.loc[relative_id] = cca_status - relative_statuses[relative_id] = cca_status - - return CcaDeletedRelativeStatusesResult( - cca_df=updated_cca_df, - relative_statuses=relative_statuses, - ) - - -def propagate_deleted_cell_cycle_ids( - cca_dfs_by_frame, - current_frame_i: int, - deleted_ids, - relative_ids, - *, - current_cca_df: pd.DataFrame | None = None, - future_cca_frames=None, - past_cca_frames=None, - drop_in_past: bool = True, - drop_in_future: bool = True, - existing_ids_by_frame=None, - base_values: dict | None = None, - size_t: int | None = None, -) -> CcaDeletedIdsPropagationResult: - """Return CCA frame updates after IDs were deleted on one frame. - - ``cca_dfs_by_frame`` can be a list-like object or mapping keyed by frame - index. ``None`` frame values represent unvisited frames and stop traversal. - """ - current_frame_i = int(current_frame_i) - if current_cca_df is None: - current_cca_df = _frame_value(cca_dfs_by_frame, current_frame_i) - if current_cca_df is None: - raise ValueError('current frame has no CCA table') - - if past_cca_frames is None: - past_cca_frames = [ - (past_frame_i, _frame_value(cca_dfs_by_frame, past_frame_i)) - for past_frame_i in range(current_frame_i - 1, -1, -1) - ] - else: - past_cca_frames = list(past_cca_frames) - - current_restore_result = restore_deleted_relative_cell_cycle_statuses( - current_cca_df, - relative_ids, - past_cca_dfs=(cca_df_i for _, cca_df_i in past_cca_frames), - base_values=base_values, - ) - current_cca_df = current_restore_result.cca_df - relative_statuses = current_restore_result.relative_statuses - - updated_cca_dfs_by_frame = {} - if relative_statuses: - updated_cca_dfs_by_frame[current_frame_i] = current_cca_df - - undo_frame_indices = [] - if future_cca_frames is None: - stop_frame_i = _frame_count(cca_dfs_by_frame, size_t=size_t) - future_cca_frames = ( - (future_frame_i, _frame_value(cca_dfs_by_frame, future_frame_i)) - for future_frame_i in range(current_frame_i + 1, stop_frame_i) - ) - - for future_frame_i, cca_df_i in future_cca_frames: - if cca_df_i is None: - break - - undo_frame_indices.append(future_frame_i) - existing_ids = None - if not drop_in_future: - existing_ids = _frame_value(existing_ids_by_frame, future_frame_i) - - restore_result = apply_deleted_cell_cycle_ids_to_frame( - cca_df_i, - deleted_ids, - relative_statuses, - relative_ids=relative_ids, - drop_deleted=drop_in_future, - existing_ids=existing_ids, - base_values=base_values, - ) - if not restore_result.any_restored: - break - - updated_cca_dfs_by_frame[future_frame_i] = restore_result.cca_df - - for past_frame_i, cca_df_i in past_cca_frames: - if cca_df_i is None: - break - - undo_frame_indices.append(past_frame_i) - existing_ids = None - if not drop_in_past: - existing_ids = _frame_value(existing_ids_by_frame, past_frame_i) - - restore_result = apply_deleted_cell_cycle_ids_to_frame( - cca_df_i, - deleted_ids, - relative_statuses, - relative_ids=relative_ids, - drop_deleted=drop_in_past, - existing_ids=existing_ids, - base_values=base_values, - ) - if not restore_result.any_restored: - break - - updated_cca_dfs_by_frame[past_frame_i] = restore_result.cca_df - - return CcaDeletedIdsPropagationResult( - current_cca_df=current_cca_df, - updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, - undo_frame_indices=undo_frame_indices, - relative_statuses=relative_statuses, - ) diff --git a/cellacdc/domain/cell_cycle_divisions.py b/cellacdc/domain/cell_cycle_divisions.py deleted file mode 100644 index 20d0cb4a6..000000000 --- a/cellacdc/domain/cell_cycle_divisions.py +++ /dev/null @@ -1,828 +0,0 @@ -"""Cell-cycle division propagation operations.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pandas as pd - -from .cell_cycle import ( - annotate_division, - apply_mother_bud_pairing, - assign_bud_to_mother, - evaluate_mother_future_eligibility_frame, - evaluate_mother_past_eligibility_frame, - FutureBudDivisionResult, - future_bud_division, - mark_will_divide_frame, - MotherEligibilityIssue, - mother_status_before_wrong_bud, - mother_not_g1_before_bud_emergence_frame, - relative_status_before_bud_emergence, - restore_mother_status_for_wrong_bud_frame, - restore_mother_status_until_g1, - undo_division_annotation, - wrong_bud_id_for_mother, -) - - -@dataclass(frozen=True) -class CcaWillDividePropagationResult: - """CCA frame updates after marking past frames as will-divide.""" - - updated_cca_dfs_by_frame: dict[int, pd.DataFrame] - generation_num: int | None = None - stopped_frame_i: int | None = None - - -@dataclass(frozen=True) -class CcaManualDivisionPropagationResult: - """CCA frame updates after annotating or undoing one division.""" - - current_cca_df: pd.DataFrame - updated_cca_dfs_by_frame: dict[int, pd.DataFrame] - undo_frame_indices: list[int] - clicked_stage: str - relative_id: int - - -@dataclass(frozen=True) -class CcaSwapMothersFutureDivisionResult: - """CCA frame updates for future division during mother-bud swaps.""" - - updated_cca_dfs_by_frame: dict[int, pd.DataFrame] - wrong_bud_id: int | None - - -@dataclass(frozen=True) -class CcaSwapMothersPastRestoreResult: - """CCA frame updates restoring a mother before a wrong-bud assignment.""" - - updated_cca_dfs_by_frame: dict[int, pd.DataFrame] - wrong_bud_id: int | None - mother_status: pd.Series | None = None - - -@dataclass(frozen=True) -class CcaMotherBudPairingsResult: - """CCA table after applying one or more mother-bud pairings.""" - - cca_df: pd.DataFrame - pairings: dict[int, int] - - -@dataclass(frozen=True) -class CcaBudMotherAssignmentPropagationResult: - """CCA updates after assigning a bud to a different mother.""" - - current_cca_df: pd.DataFrame - updated_cca_dfs_by_frame: dict[int, pd.DataFrame] - undo_frame_indices: list[int] - previous_mother_id: int - - -@dataclass(frozen=True) -class CcaMotherAssignmentEligibilityResult: - """Mother-assignment eligibility issues across future and past frames.""" - - future_issue: MotherEligibilityIssue | None = None - past_issue: MotherEligibilityIssue | None = None - g1_duration_future: int = 0 - - @property - def can_assign_without_user_action(self) -> bool: - return self.future_issue is None and self.past_issue is None - - -@dataclass(frozen=True) -class CcaBudMotherChangeEligibilityResult: - """Result of checking if a bud can change mother assignment.""" - - future_division: FutureBudDivisionResult | None = None - - @property - def can_change(self) -> bool: - return self.future_division is None - - -@dataclass(frozen=True) -class CcaSwapMothersPairingPlan: - """Bud/mother pairing maps for swapping two mother assignments.""" - - correct_pairings: dict[int, int] - wrong_pairings: dict[int, int] - - -@dataclass(frozen=True) -class CcaSwapMothersEligibilityResult: - """Result of validating whether two mother assignments can be swapped.""" - - can_swap: bool - plan: CcaSwapMothersPairingPlan - future_division_bud_id: int | None = None - future_division_mother_id: int | None = None - future_division_frame_i: int | None = None - mother_not_g1_bud_id: int | None = None - mother_not_g1_mother_id: int | None = None - mother_not_g1_frame_i: int | None = None - - -@dataclass(frozen=True) -class CcaSwapMothersPropagationResult: - """CCA updates after swapping two mother-bud assignments.""" - - current_cca_df: pd.DataFrame - updated_cca_dfs_by_frame: dict[int, pd.DataFrame] - plan: CcaSwapMothersPairingPlan - - -def _frame_value(frame_values, frame_i: int): - if hasattr(frame_values, 'get'): - return frame_values.get(frame_i) - try: - return frame_values[frame_i] - except (IndexError, KeyError, TypeError): - return None - - -def _frame_count(frame_values, size_t: int | None = None) -> int: - if size_t is not None: - return int(size_t) - if hasattr(frame_values, 'keys'): - keys = list(frame_values.keys()) - return max(keys) + 1 if keys else 0 - return len(frame_values) - - -def swap_mothers_pairing_plan( - bud_id: int, - other_bud_id: int, - other_mother_id: int, - mother_id: int, -) -> CcaSwapMothersPairingPlan: - """Return correct and wrong pairings for a mother swap.""" - return CcaSwapMothersPairingPlan( - correct_pairings={ - other_bud_id: mother_id, - bud_id: other_mother_id, - }, - wrong_pairings={ - mother_id: bud_id, - other_mother_id: other_bud_id, - }, - ) - - -def swap_mothers_eligibility( - bud_id: int, - other_bud_id: int, - other_mother_id: int, - mother_id: int, - future_cca_frames, - past_cca_frames, -) -> CcaSwapMothersEligibilityResult: - """Validate whether two mother assignments can be swapped.""" - plan = swap_mothers_pairing_plan( - bud_id, - other_bud_id, - other_mother_id, - mother_id, - ) - - future_cca_frames = list(future_cca_frames) - for candidate_bud_id in (bud_id, other_bud_id): - future_division = future_bud_division( - candidate_bud_id, - future_cca_frames, - ) - if future_division is not None: - return CcaSwapMothersEligibilityResult( - can_swap=False, - plan=plan, - future_division_bud_id=candidate_bud_id, - future_division_mother_id=future_division.mother_id, - future_division_frame_i=future_division.frame_i, - ) - - past_cca_frames = list(past_cca_frames) - for correct_bud_id, correct_mother_id in plan.correct_pairings.items(): - wrong_bud_id = plan.wrong_pairings[correct_mother_id] - frame_not_g1 = mother_not_g1_before_bud_emergence_frame( - correct_mother_id, - correct_bud_id, - wrong_bud_id, - past_cca_frames, - ) - if frame_not_g1 is not None: - return CcaSwapMothersEligibilityResult( - can_swap=False, - plan=plan, - mother_not_g1_bud_id=correct_bud_id, - mother_not_g1_mother_id=correct_mother_id, - mother_not_g1_frame_i=frame_not_g1, - ) - - return CcaSwapMothersEligibilityResult(can_swap=True, plan=plan) - - -def apply_mother_bud_pairings( - cca_df: pd.DataFrame, - pairings: dict[int, int], - corrected_frame_i: int, - *, - set_mother_s_if_g1: bool = True, -) -> CcaMotherBudPairingsResult: - """Return ``cca_df`` after applying multiple bud-to-mother pairings.""" - updated_cca_df = cca_df.copy() - for bud_id, mother_id in pairings.items(): - updated_cca_df = apply_mother_bud_pairing( - updated_cca_df, - bud_id, - mother_id, - corrected_frame_i, - set_mother_s_if_g1=set_mother_s_if_g1, - ) - return CcaMotherBudPairingsResult( - cca_df=updated_cca_df, - pairings=dict(pairings), - ) - - -def mother_assignment_eligibility( - bud_id: int, - mother_id: int, - future_cca_frames, - past_cca_frames, - last_cca_frame_i: int, -) -> CcaMotherAssignmentEligibilityResult: - """Return the first eligibility issues for a proposed mother assignment.""" - g1_duration_future = 0 - future_issue = None - for future_i, cca_df_i in future_cca_frames: - result = evaluate_mother_future_eligibility_frame( - cca_df_i, - bud_id, - mother_id, - future_i, - g1_duration_future, - last_cca_frame_i, - ) - g1_duration_future = result.g1_duration - if result.issue is not None: - future_issue = result.issue - break - if result.stop: - break - - past_issue = None - for past_i, cca_df_i in past_cca_frames: - result = evaluate_mother_past_eligibility_frame( - cca_df_i, - bud_id, - mother_id, - past_i, - ) - if result.issue is not None: - past_issue = result.issue - break - if result.stop: - break - - return CcaMotherAssignmentEligibilityResult( - future_issue=future_issue, - past_issue=past_issue, - g1_duration_future=g1_duration_future, - ) - - -def bud_mother_change_eligibility( - bud_id: int, - future_cca_frames, -) -> CcaBudMotherChangeEligibilityResult: - """Validate that ``bud_id`` has no future division annotation.""" - return CcaBudMotherChangeEligibilityResult( - future_division=future_bud_division(bud_id, future_cca_frames), - ) - - -def previous_relative_status_before_bud_emergence( - bud_id: int, - current_mother_id: int, - past_cca_frames, - base_status: pd.Series, -) -> pd.Series: - """Return relative status from before ``bud_id`` emerged.""" - base_mother_status = base_status.copy() - base_mother_status.name = current_mother_id - return relative_status_before_bud_emergence( - bud_id, - current_mother_id, - past_cca_frames, - base_mother_status, - base_status, - ) - - -def propagate_bud_mother_assignment( - current_cca_df: pd.DataFrame, - current_frame_i: int, - bud_id: int, - mother_id: int, - *, - future_cca_frames=(), - past_cca_frames=(), - previous_mother_status: pd.Series | None = None, -) -> CcaBudMotherAssignmentPropagationResult: - """Return CCA updates after assigning ``bud_id`` to ``mother_id``.""" - current_frame_i = int(current_frame_i) - if current_cca_df is None: - raise ValueError('current frame has no CCA table') - - previous_mother_id = current_cca_df.at[bud_id, 'relative_ID'] - if current_frame_i == 0: - current_update = assign_bud_to_mother( - current_cca_df, - bud_id, - mother_id, - previous_mother_id=previous_mother_id, - reset_previous_mother=True, - mother_generation_num=2, - mother_relationship=None, - ) - return CcaBudMotherAssignmentPropagationResult( - current_cca_df=current_update, - updated_cca_dfs_by_frame={current_frame_i: current_update}, - undo_frame_indices=[], - previous_mother_id=previous_mother_id, - ) - - current_update = assign_bud_to_mother( - current_cca_df, - bud_id, - mother_id, - corrected_frame_i=current_frame_i, - previous_mother_id=previous_mother_id, - previous_mother_status=previous_mother_status, - ) - - updated_cca_dfs_by_frame = {current_frame_i: current_update} - undo_frame_indices = [] - - for future_i, cca_df_i in future_cca_frames: - if cca_df_i is None: - break - - if bud_id not in cca_df_i.index or mother_id not in cca_df_i.index: - continue - - undo_frame_indices.append(future_i) - bud_relationship = cca_df_i.at[bud_id, 'relationship'] - bud_stage = cca_df_i.at[bud_id, 'cell_cycle_stage'] - if bud_relationship == 'mother' and bud_stage == 'S': - break - - updated_cca_dfs_by_frame[future_i] = assign_bud_to_mother( - cca_df_i, - bud_id, - mother_id, - update_mother_only_if_g1=True, - previous_mother_id=previous_mother_id, - previous_mother_status=previous_mother_status, - ) - - for past_i, cca_df_i in past_cca_frames: - if cca_df_i is None: - break - - if bud_id not in cca_df_i.index: - break - - undo_frame_indices.append(past_i) - updated_cca_dfs_by_frame[past_i] = assign_bud_to_mother( - cca_df_i, - bud_id, - mother_id, - previous_mother_id=previous_mother_id, - previous_mother_status=previous_mother_status, - ) - - return CcaBudMotherAssignmentPropagationResult( - current_cca_df=current_update, - updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, - undo_frame_indices=undo_frame_indices, - previous_mother_id=previous_mother_id, - ) - - -def propagate_will_divide( - cca_dfs_by_frame, - current_frame_i: int, - cell_id: int, - relative_id: int, - *, - past_cca_frames=None, -) -> CcaWillDividePropagationResult: - """Return past-frame CCA updates for a pending cell division.""" - generation_num = None - stopped_frame_i = None - updated_cca_dfs_by_frame = {} - if past_cca_frames is None: - past_cca_frames = ( - (past_frame_i, _frame_value(cca_dfs_by_frame, past_frame_i)) - for past_frame_i in range(int(current_frame_i) - 1, -1, -1) - ) - - for past_frame_i, cca_df_i in past_cca_frames: - if cca_df_i is None: - stopped_frame_i = past_frame_i - break - - result = mark_will_divide_frame( - cca_df_i, - cell_id, - relative_id, - generation_num=generation_num, - ) - generation_num = result.generation_num - if result.stop: - stopped_frame_i = past_frame_i - break - if result.should_store: - updated_cca_dfs_by_frame[past_frame_i] = result.cca_df - - return CcaWillDividePropagationResult( - updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, - generation_num=generation_num, - stopped_frame_i=stopped_frame_i, - ) - - -def propagate_manual_division_annotation( - cca_dfs_by_frame, - current_frame_i: int, - cell_id: int, - *, - current_cca_df: pd.DataFrame | None = None, - future_cca_frames=None, - past_cca_frames=None, - size_t: int | None = None, -) -> CcaManualDivisionPropagationResult: - """Return CCA updates for manual division annotation or undo.""" - current_frame_i = int(current_frame_i) - if current_cca_df is None: - current_cca_df = _frame_value(cca_dfs_by_frame, current_frame_i) - if current_cca_df is None: - raise ValueError('current frame has no CCA table') - - if past_cca_frames is not None: - past_cca_frames = list(past_cca_frames) - - clicked_stage = current_cca_df.at[cell_id, 'cell_cycle_stage'] - relative_id = current_cca_df.at[cell_id, 'relative_ID'] - current_update = current_cca_df.copy() - updated_cca_dfs_by_frame = {} - - if clicked_stage == 'S': - will_divide_result = propagate_will_divide( - cca_dfs_by_frame if past_cca_frames is None else None, - current_frame_i, - cell_id, - relative_id, - past_cca_frames=past_cca_frames, - ) - updated_cca_dfs_by_frame.update( - will_divide_result.updated_cca_dfs_by_frame - ) - annotate_division(current_update, cell_id, relative_id, current_frame_i) - else: - undo_division_annotation(current_update, cell_id, relative_id) - - updated_cca_dfs_by_frame[current_frame_i] = current_update - undo_frame_indices = [] - - if future_cca_frames is None: - stop_frame_i = _frame_count(cca_dfs_by_frame, size_t=size_t) - future_cca_frames = ( - (future_frame_i, _frame_value(cca_dfs_by_frame, future_frame_i)) - for future_frame_i in range(current_frame_i + 1, stop_frame_i) - ) - - for future_frame_i, cca_df_i in future_cca_frames: - if cca_df_i is None: - break - - undo_frame_indices.append(future_frame_i) - if cell_id not in cca_df_i.index: - continue - - future_update = cca_df_i.copy() - frame_stage = future_update.at[cell_id, 'cell_cycle_stage'] - frame_relative_id = future_update.at[cell_id, 'relative_ID'] - if clicked_stage == 'S': - if frame_stage == 'G1': - break - annotate_division( - future_update, - cell_id, - frame_relative_id, - current_frame_i, - ) - updated_cca_dfs_by_frame[future_frame_i] = future_update - elif frame_stage == 'S': - annotate_division( - future_update, - cell_id, - frame_relative_id, - current_frame_i, - ) - updated_cca_dfs_by_frame[future_frame_i] = future_update - break - else: - undo_division_annotation(future_update, cell_id, frame_relative_id) - updated_cca_dfs_by_frame[future_frame_i] = future_update - - if past_cca_frames is None: - past_cca_frames = ( - (past_frame_i, _frame_value(cca_dfs_by_frame, past_frame_i)) - for past_frame_i in range(current_frame_i - 1, -1, -1) - ) - - for past_frame_i, cca_df_i in past_cca_frames: - if cca_df_i is None: - break - if cell_id not in cca_df_i.index or relative_id not in cca_df_i.index: - break - - undo_frame_indices.append(past_frame_i) - frame_stage = cca_df_i.at[cell_id, 'cell_cycle_stage'] - frame_relative_id = cca_df_i.at[cell_id, 'relative_ID'] - if frame_stage == 'S': - break - - past_update = cca_df_i.copy() - undo_division_annotation(past_update, cell_id, frame_relative_id) - updated_cca_dfs_by_frame[past_frame_i] = past_update - - return CcaManualDivisionPropagationResult( - current_cca_df=current_update, - updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, - undo_frame_indices=undo_frame_indices, - clicked_stage=clicked_stage, - relative_id=relative_id, - ) - - -def propagate_swap_mothers_future_division( - cca_dfs_by_frame, - frame_i: int, - mother_id: int, - *, - size_t: int | None = None, -) -> CcaSwapMothersFutureDivisionResult: - """Return future-frame CCA updates after a swap-mothers division.""" - frame_i = int(frame_i) - cca_df_at_division = _frame_value(cca_dfs_by_frame, frame_i) - if cca_df_at_division is None: - raise ValueError('division frame has no CCA table') - - wrong_bud_id = wrong_bud_id_for_mother(cca_df_at_division, mother_id) - if wrong_bud_id is None: - return CcaSwapMothersFutureDivisionResult( - updated_cca_dfs_by_frame={}, - wrong_bud_id=None, - ) - - updated_cca_dfs_by_frame = {} - division_cca_df = cca_df_at_division.copy() - annotate_division(division_cca_df, mother_id, wrong_bud_id, frame_i) - division_cca_df.at[mother_id, 'corrected_on_frame_i'] = frame_i - updated_cca_dfs_by_frame[frame_i] = division_cca_df - - mother_status_to_restore = division_cca_df.loc[mother_id] - stop_frame_i = _frame_count(cca_dfs_by_frame, size_t=size_t) - for future_i in range(frame_i + 1, stop_frame_i): - cca_df_i = _frame_value(cca_dfs_by_frame, future_i) - if cca_df_i is None: - break - - restore_result = restore_mother_status_until_g1( - cca_df_i, - mother_id, - mother_status_to_restore, - frame_i, - ) - if not restore_result.restored: - break - - updated_cca_dfs_by_frame[future_i] = restore_result.cca_df - - return CcaSwapMothersFutureDivisionResult( - updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, - wrong_bud_id=wrong_bud_id, - ) - - -def restore_swap_mothers_past_status( - cca_dfs_by_frame, - frame_i: int, - mother_id: int, - base_status: pd.Series, -) -> CcaSwapMothersPastRestoreResult: - """Return past-frame CCA updates restoring a mother before a wrong bud.""" - frame_i = int(frame_i) - cca_df_at_disappearance = _frame_value(cca_dfs_by_frame, frame_i) - if cca_df_at_disappearance is None: - raise ValueError('disappearance frame has no CCA table') - - wrong_bud_id = wrong_bud_id_for_mother( - cca_df_at_disappearance, - mother_id, - ) - if wrong_bud_id is None: - return CcaSwapMothersPastRestoreResult( - updated_cca_dfs_by_frame={}, - wrong_bud_id=None, - ) - - past_cca_frames = ( - _frame_value(cca_dfs_by_frame, past_i) - for past_i in range(frame_i, -1, -1) - ) - mother_status = mother_status_before_wrong_bud( - mother_id, - wrong_bud_id, - past_cca_frames, - base_status, - ) - - updated_cca_dfs_by_frame = {} - for past_i in range(frame_i, -1, -1): - cca_df_i = _frame_value(cca_dfs_by_frame, past_i) - if cca_df_i is None: - break - - restore_result = restore_mother_status_for_wrong_bud_frame( - cca_df_i, - mother_id, - wrong_bud_id, - mother_status, - frame_i, - ) - if not restore_result.restored: - break - - updated_cca_dfs_by_frame[past_i] = restore_result.cca_df - - return CcaSwapMothersPastRestoreResult( - updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, - wrong_bud_id=wrong_bud_id, - mother_status=mother_status, - ) - - -def propagate_swap_mothers_assignment( - current_cca_df: pd.DataFrame, - current_frame_i: int, - bud_id: int, - other_bud_id: int, - other_mother_id: int, - mother_id: int, - *, - base_status: pd.Series, - past_cca_frames=(), - future_cca_frames=(), -) -> CcaSwapMothersPropagationResult: - """Return CCA updates after swapping two incorrect mother assignments.""" - current_frame_i = int(current_frame_i) - plan = swap_mothers_pairing_plan( - bud_id, - other_bud_id, - other_mother_id, - mother_id, - ) - - current_pairings_result = apply_mother_bud_pairings( - current_cca_df, - plan.correct_pairings, - current_frame_i, - set_mother_s_if_g1=False, - ) - current_update = current_pairings_result.cca_df - updated_cca_dfs_by_frame = {current_frame_i: current_update} - - past_dfs_by_frame = { - int(frame_i): cca_df for frame_i, cca_df in past_cca_frames - } - corrected_bud_ids_past = set() - for past_i in sorted(past_dfs_by_frame, reverse=True): - if len(corrected_bud_ids_past) == len(plan.correct_pairings): - break - - for correct_bud_id, correct_mother_id in plan.correct_pairings.items(): - if correct_bud_id in corrected_bud_ids_past: - continue - - cca_df_i = past_dfs_by_frame[past_i] - if cca_df_i is None: - continue - - if correct_bud_id not in cca_df_i.index: - corrected_bud_ids_past.add(correct_bud_id) - if len(corrected_bud_ids_past) < len(plan.correct_pairings): - restore_frames = { - frame_i: past_dfs_by_frame[frame_i] - for frame_i in past_dfs_by_frame - if frame_i <= past_i - } - restore_result = restore_swap_mothers_past_status( - restore_frames, - past_i, - correct_mother_id, - base_status, - ) - for frame_i, restored_df in ( - restore_result.updated_cca_dfs_by_frame.items() - ): - past_dfs_by_frame[frame_i] = restored_df - updated_cca_dfs_by_frame[frame_i] = restored_df - continue - - pairings_result = apply_mother_bud_pairings( - cca_df_i, - {correct_bud_id: correct_mother_id}, - current_frame_i, - ) - past_dfs_by_frame[past_i] = pairings_result.cca_df - updated_cca_dfs_by_frame[past_i] = pairings_result.cca_df - - future_dfs_by_frame = { - int(frame_i): cca_df for frame_i, cca_df in future_cca_frames - } - corrected_bud_ids_future = set() - for future_i in sorted(future_dfs_by_frame): - if len(corrected_bud_ids_future) == len(plan.correct_pairings): - break - - cca_df_i = updated_cca_dfs_by_frame.get( - future_i, - future_dfs_by_frame[future_i], - ) - if cca_df_i is None: - break - - for correct_bud_id, correct_mother_id in plan.correct_pairings.items(): - if correct_bud_id in corrected_bud_ids_future: - continue - - if correct_bud_id not in cca_df_i.index: - corrected_bud_ids_future.add(correct_bud_id) - continue - - bud_stage = cca_df_i.at[correct_bud_id, 'cell_cycle_stage'] - if bud_stage == 'G1': - corrected_bud_ids_future.add(correct_bud_id) - if len(corrected_bud_ids_future) < len(plan.correct_pairings): - future_frames_for_division = { - frame_i: updated_cca_dfs_by_frame.get(frame_i, df) - for frame_i, df in future_dfs_by_frame.items() - if frame_i >= future_i - } - future_frames_for_division[future_i] = cca_df_i - division_result = propagate_swap_mothers_future_division( - future_frames_for_division, - future_i, - correct_mother_id, - ) - for frame_i, division_df in ( - division_result.updated_cca_dfs_by_frame.items() - ): - updated_cca_dfs_by_frame[frame_i] = division_df - if division_result.wrong_bud_id is not None: - will_divide_result = propagate_will_divide( - past_dfs_by_frame, - current_frame_i, - correct_mother_id, - division_result.wrong_bud_id, - ) - for frame_i, will_divide_df in ( - will_divide_result - .updated_cca_dfs_by_frame.items() - ): - past_dfs_by_frame[frame_i] = will_divide_df - updated_cca_dfs_by_frame[frame_i] = will_divide_df - cca_df_i = updated_cca_dfs_by_frame.get(future_i, cca_df_i) - continue - - pairings_result = apply_mother_bud_pairings( - cca_df_i, - {correct_bud_id: correct_mother_id}, - current_frame_i, - ) - cca_df_i = pairings_result.cca_df - updated_cca_dfs_by_frame[future_i] = cca_df_i - - return CcaSwapMothersPropagationResult( - current_cca_df=current_update, - updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, - plan=plan, - ) diff --git a/cellacdc/domain/cell_cycle_frames.py b/cellacdc/domain/cell_cycle_frames.py deleted file mode 100644 index c318e3841..000000000 --- a/cellacdc/domain/cell_cycle_frames.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Frame-level cell-cycle annotation table operations.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pandas as pd - -from .cell_cycle import ( - build_base_cell_cycle_annotations, - ensure_cca_columns, - extract_cell_cycle_annotations, - has_cell_cycle_annotations, - last_annotated_cca_by_cell, - store_cell_cycle_annotations, -) - - -@dataclass(frozen=True) -class CcaFrameResolutionResult: - """Resolved CCA table for one frame.""" - - cca_df: pd.DataFrame | None - used_snapshot_fallback: bool = False - - -@dataclass(frozen=True) -class CcaFrameStoreResult: - """Stored ACDC frame plus optional CCA cache tables.""" - - acdc_df: pd.DataFrame | None - checker_cca_df: pd.DataFrame | None = None - cached_cca_df: pd.DataFrame | None = None - - -@dataclass(frozen=True) -class CcaMissingFramesInitResult: - """Prepared missing CCA frame tables and their last known statuses.""" - - acdc_dfs_by_frame: dict[int, pd.DataFrame] - last_annotated_cca_df: pd.DataFrame - - -def prepare_missing_cell_cycle_frame_annotations( - frame_records, - cca_colnames, - last_cca_frame_i: int, -) -> CcaMissingFramesInitResult: - """Prepare missing CCA columns before initializing skipped frames.""" - acdc_dfs_by_frame = {} - annotated_cca_dfs = [] - for frame_i in range(last_cca_frame_i + 1): - acdc_df = frame_records[frame_i]['acdc_df'] - if not has_cell_cycle_annotations(acdc_df): - acdc_df = ensure_cca_columns(acdc_df, cca_colnames) - acdc_dfs_by_frame[frame_i] = acdc_df - annotated_cca_dfs.append(acdc_df[list(cca_colnames)]) - - return CcaMissingFramesInitResult( - acdc_dfs_by_frame=acdc_dfs_by_frame, - last_annotated_cca_df=last_annotated_cca_by_cell(annotated_cca_dfs), - ) - - -def normalize_loaded_cell_cycle_frame_annotations( - acdc_df: pd.DataFrame | None, - cca_colnames, - int_colnames=(), -) -> pd.DataFrame | None: - """Normalize CCA columns loaded from concatenated frame metadata.""" - if acdc_df is None or not has_cell_cycle_annotations(acdc_df): - return acdc_df - - cca_cols = acdc_df.columns.intersection(cca_colnames) - cca_df = acdc_df[cca_cols].dropna() - if cca_df.empty: - return acdc_df.drop(columns=cca_colnames, errors='ignore') - - normalized_acdc_df = acdc_df.loc[cca_df.index].copy() - existing_int_cols = [ - col for col in int_colnames if col in normalized_acdc_df.columns - ] - if existing_int_cols: - normalized_acdc_df[existing_int_cols] = ( - normalized_acdc_df[existing_int_cols].astype('Int64') - ) - return normalized_acdc_df - - -def resolve_cell_cycle_annotations( - acdc_df: pd.DataFrame | None, - cca_colnames, - *, - is_snapshot: bool = False, - snapshot_cell_ids=(), - dropna: bool = True, - base_values: dict | None = None, - tree_values: dict | None = None, - with_tree_cols: bool = False, -) -> CcaFrameResolutionResult: - """Resolve a frame CCA table, optionally falling back to snapshot defaults.""" - cca_df = extract_cell_cycle_annotations( - acdc_df, - cca_colnames, - dropna=False, - ) - used_snapshot_fallback = False - if cca_df is None and is_snapshot: - cca_df = build_base_cell_cycle_annotations( - snapshot_cell_ids, - with_tree_cols=with_tree_cols, - base_values=base_values, - tree_values=tree_values, - ) - used_snapshot_fallback = True - - if cca_df is not None and dropna: - cca_df = cca_df.dropna() - - return CcaFrameResolutionResult( - cca_df=cca_df, - used_snapshot_fallback=used_snapshot_fallback, - ) - - -def prepare_cell_cycle_checker_annotations( - cca_df: pd.DataFrame | None, - *, - checker_running: bool = True, -) -> pd.DataFrame | None: - """Return a checker-safe CCA copy when integrity checks are active.""" - if not checker_running or cca_df is None: - return None - return cca_df.copy() - - -def store_cell_cycle_frame_annotations( - acdc_df: pd.DataFrame | None, - cca_df: pd.DataFrame | None, - cca_colnames, - *, - store_checker_copy: bool = False, - store_cca_df_copy: bool = False, -) -> CcaFrameStoreResult: - """Return stored ACDC frame and optional CCA cache copies.""" - stored_acdc_df = store_cell_cycle_annotations( - acdc_df, - cca_df, - cca_colnames, - ) - checker_cca_df = prepare_cell_cycle_checker_annotations( - cca_df, - checker_running=store_checker_copy, - ) - - cached_cca_df = None - if store_cca_df_copy and cca_df is not None: - cached_cca_df = cca_df.copy() - - return CcaFrameStoreResult( - acdc_df=stored_acdc_df, - checker_cca_df=checker_cca_df, - cached_cca_df=cached_cca_df, - ) diff --git a/cellacdc/domain/cell_cycle_history.py b/cellacdc/domain/cell_cycle_history.py deleted file mode 100644 index 548149a9e..000000000 --- a/cellacdc/domain/cell_cycle_history.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Cell-cycle history-known annotation propagation operations.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pandas as pd - -from .cell_cycle import bud_known_history_status, toggle_history_knowledge - - -@dataclass(frozen=True) -class CcaHistoryKnowledgePropagationResult: - """CCA table updates after toggling one cell's history knowledge.""" - - current_cca_df: pd.DataFrame - updated_cca_dfs_by_frame: dict[int, pd.DataFrame] - undo_frame_indices: list[int] - relative_id: int - relative_status: pd.Series | None = None - - -def apply_history_knowledge_to_frame( - cca_df: pd.DataFrame, - cell_id: int, - *, - status_when_emerged: pd.Series | None = None, - relative_id: int | None = None, - relative_status: pd.Series | None = None, -) -> pd.DataFrame: - """Return one CCA table after toggling a cell's history knowledge.""" - updated_cca_df = cca_df.copy() - toggle_history_knowledge( - updated_cca_df, - cell_id, - status_when_emerged=status_when_emerged, - ) - if ( - relative_id is not None - and relative_status is not None - and relative_id in updated_cca_df.index - ): - updated_cca_df.loc[relative_id] = relative_status - - return updated_cca_df - - -def known_history_status_for_bud( - bud_id: int, - past_cca_frames, - base_status: pd.Series, -) -> pd.Series | None: - """Return status to restore when marking ``bud_id`` history as known.""" - return bud_known_history_status( - bud_id, - past_cca_frames, - base_status, - ) - - -def propagate_history_knowledge( - current_cca_df: pd.DataFrame, - current_frame_i: int, - cell_id: int, - *, - future_cca_frames=(), - past_cca_frames=(), - status_when_emerged: pd.Series | None = None, - relative_id: int | None = None, - relative_status: pd.Series | None = None, -) -> CcaHistoryKnowledgePropagationResult: - """Return CCA frame updates after toggling history knowledge on a cell.""" - current_frame_i = int(current_frame_i) - if current_cca_df is None: - raise ValueError('current frame has no CCA table') - - if relative_id is None: - relative_id = current_cca_df.at[cell_id, 'relative_ID'] - - updated_current_cca_df = apply_history_knowledge_to_frame( - current_cca_df, - cell_id, - status_when_emerged=status_when_emerged, - relative_id=relative_id, - relative_status=relative_status, - ) - updated_cca_dfs_by_frame = {current_frame_i: updated_current_cca_df} - - undo_frame_indices = [] - for frame_i, cca_df_i in future_cca_frames: - if cca_df_i is None: - break - - undo_frame_indices.append(frame_i) - if cell_id not in cca_df_i.index: - continue - - updated_cca_dfs_by_frame[frame_i] = apply_history_knowledge_to_frame( - cca_df_i, - cell_id, - status_when_emerged=status_when_emerged, - relative_id=relative_id, - relative_status=relative_status, - ) - - for frame_i, cca_df_i in past_cca_frames: - if cca_df_i is None: - break - - undo_frame_indices.append(frame_i) - if cell_id not in cca_df_i.index: - break - - frame_relative_id = cca_df_i.at[cell_id, 'relative_ID'] - updated_cca_dfs_by_frame[frame_i] = apply_history_knowledge_to_frame( - cca_df_i, - cell_id, - status_when_emerged=status_when_emerged, - relative_id=frame_relative_id, - relative_status=relative_status, - ) - - return CcaHistoryKnowledgePropagationResult( - current_cca_df=updated_current_cca_df, - updated_cca_dfs_by_frame=updated_cca_dfs_by_frame, - undo_frame_indices=undo_frame_indices, - relative_id=relative_id, - relative_status=relative_status, - ) diff --git a/cellacdc/domain/curvature.py b/cellacdc/domain/curvature.py deleted file mode 100644 index ea6093d22..000000000 --- a/cellacdc/domain/curvature.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Pure curvature and spline editing operations (no Qt).""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np -import scipy.interpolate -import skimage.draw - - -@dataclass(frozen=True) -class CurvatureLabelPaintResult: - """Result of painting a closed spline into a label image.""" - - labels_2d: np.ndarray - mask: np.ndarray - painted_pixels: int - - -def tangent_brush_polygon( - yx_start, - yx_end, - radius: int | float, - shape: tuple[int, int], -) -> tuple[np.ndarray, np.ndarray]: - """Return polygon coords joining two circular brush centers.""" - y1, x1 = yx_start - y2, x2 = yx_end - radius = float(radius) - - arcsin_den = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) - arctan_den = x2 - x1 - if arcsin_den == 0 or arctan_den == 0: - return np.array([], dtype=int), np.array([], dtype=int) - - beta = np.arcsin((radius - radius) / arcsin_den) - gamma = -np.arctan((y2 - y1) / arctan_den) - alpha = gamma - beta - x3 = x1 + radius * np.sin(alpha) - y3 = y1 + radius * np.cos(alpha) - x4 = x2 + radius * np.sin(alpha) - y4 = y2 + radius * np.cos(alpha) - - alpha = gamma + beta - x5 = x1 - radius * np.sin(alpha) - y5 = y1 - radius * np.cos(alpha) - x6 = x2 - radius * np.sin(alpha) - y6 = y2 - radius * np.cos(alpha) - - return skimage.draw.polygon( - [y3, y4, y6, y5], - [x3, x4, x6, x5], - shape=shape, - ) - - -def directional_coords( - alfa_dir: int, - y: int, - x: int, - shape: tuple[int, int], - *, - connectivity: int = 1, -) -> tuple[list[int], list[int]]: - height, width = shape - y_above = y + 1 if y + 1 < height else y - y_below = y - 1 if y > 0 else y - x_right = x + 1 if x + 1 < width else x - x_left = x - 1 if x > 0 else x - - if alfa_dir == 0: - yy = [y_below, y_below, y, y_above, y_above] - xx = [x, x_right, x_right, x_right, x] - elif alfa_dir == 45: - yy = [y_below, y_below, y_below, y, y_above] - xx = [x_left, x, x_right, x_right, x_right] - elif alfa_dir == 90: - yy = [y, y_below, y_below, y_below, y] - xx = [x_left, x_left, x, x_right, x_right] - elif alfa_dir == 135: - yy = [y_above, y, y_below, y_below, y_below] - xx = [x_left, x_left, x_left, x, x_right] - elif alfa_dir == -180 or alfa_dir == 180: - yy = [y_above, y_above, y, y_below, y_below] - xx = [x, x_left, x_left, x_left, x] - elif alfa_dir == -135: - yy = [y_below, y, y_above, y_above, y_above] - xx = [x_left, x_left, x_left, x, x_right] - elif alfa_dir == -90: - yy = [y, y_above, y_above, y_above, y] - xx = [x_left, x_left, x, x_right, x_right] - else: - yy = [y_above, y_above, y_above, y, y_below] - xx = [x_left, x, x_right, x_right, x_right] - - if connectivity == 1: - return yy[1:4], xx[1:4] - return yy, xx - - -def spline_coords( - xx, - yy, - *, - resolution_space=None, - per: bool = False, - append_first: bool = False, -): - xx = np.asarray(xx) - yy = np.asarray(yy) - if len(xx) == 0 or len(yy) == 0: - return [], [] - - valid = np.where(np.abs(np.diff(xx)) + np.abs(np.diff(yy)) > 0) - xx = np.r_[xx[valid], xx[-1]] - yy = np.r_[yy[valid], yy[-1]] - if append_first: - xx = np.r_[xx, xx[0]] - yy = np.r_[yy, yy[0]] - per = True - - if resolution_space is None: - resolution_space = np.linspace(0, 1, 1000) - k = 2 if len(xx) == 3 else 3 - - try: - tck, _u = scipy.interpolate.splprep([xx, yy], s=0, k=k, per=per) - return scipy.interpolate.splev(resolution_space, tck) - except (ValueError, TypeError): - return [], [] - - -def closed_spline_coords( - xx_spline, - yy_spline, - *, - anchor_xx=None, - anchor_yy=None, - predictor=None, - max_exec_time: int = 150, -): - xx_spline = np.asarray(xx_spline) - yy_spline = np.asarray(yy_spline) - bbox_area = ( - (xx_spline.max() - xx_spline.min()) - * (yy_spline.max() - yy_spline.min()) - ) - if bbox_area < 26_000: - return xx_spline, yy_spline - - if predictor is None or anchor_xx is None or anchor_yy is None: - return xx_spline, yy_spline - - optimal_space_size = predictor.predict( - bbox_area, - max_exec_time=max_exec_time, - ) - if optimal_space_size >= 1000: - return xx_spline, yy_spline - - if optimal_space_size < 100: - optimal_space_size = 100 - - resolution_space = np.linspace(0, 1, int(optimal_space_size)) - return spline_coords( - anchor_xx, - anchor_yy, - resolution_space=resolution_space, - per=True, - ) - - -def paint_spline_to_labels( - labels_2d: np.ndarray, - xx_spline, - yy_spline, - label_id: int, - *, - empty_only: bool = True, -) -> CurvatureLabelPaintResult: - updated_labels = labels_2d.copy() - mask = np.zeros(updated_labels.shape, bool) - rr, cc = skimage.draw.polygon( - yy_spline, - xx_spline, - shape=updated_labels.shape, - ) - mask[rr, cc] = True - if empty_only: - mask[updated_labels != 0] = False - - updated_labels[mask] = int(label_id) - return CurvatureLabelPaintResult( - labels_2d=updated_labels, - mask=mask, - painted_pixels=int(np.count_nonzero(mask)), - ) diff --git a/cellacdc/domain/custom_annotations.py b/cellacdc/domain/custom_annotations.py deleted file mode 100644 index 49f92c622..000000000 --- a/cellacdc/domain/custom_annotations.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Pure custom annotation table operations.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pandas as pd - - -@dataclass(frozen=True) -class CustomAnnotationColumnResult: - """Frame table with a custom annotation column plus annotated IDs.""" - - dataframe: pd.DataFrame - annotated_ids: list[int] - - -@dataclass(frozen=True) -class CustomAnnotationFrameUpdate: - """Frame custom annotation update plus IDs present in current labels.""" - - dataframe: pd.DataFrame - annotated_ids: list[int] - present_annotated_ids: list[int] - - -def _cell_ids_from_index_or_column(df: pd.DataFrame) -> list[int]: - if 'Cell_ID' in df.columns: - return [int(cell_id) for cell_id in df['Cell_ID'].to_list()] - if isinstance(df.index, pd.MultiIndex) and 'Cell_ID' in df.index.names: - cell_ids = df.index.get_level_values('Cell_ID') - return [int(cell_id) for cell_id in cell_ids.to_list()] - return [int(cell_id) for cell_id in df.index.to_list()] - - -def ensure_custom_annotation_column( - acdc_df: pd.DataFrame, - annotation_name: str, -) -> CustomAnnotationColumnResult: - """Return ``acdc_df`` with a 0/1 custom annotation column.""" - updated_df = acdc_df.copy() - if annotation_name not in updated_df.columns: - updated_df[annotation_name] = 0 - return CustomAnnotationColumnResult(updated_df, []) - - updated_df[annotation_name] = updated_df[annotation_name].astype(int) - annotated_df = updated_df[updated_df[annotation_name] == 1] - return CustomAnnotationColumnResult( - dataframe=updated_df, - annotated_ids=_cell_ids_from_index_or_column(annotated_df), - ) - - -def custom_annotation_column_exists( - frame_records, - annotation_name: str, - *, - summary_acdc_df: pd.DataFrame | None = None, -) -> bool: - """Return whether a custom annotation column exists in any metadata table.""" - for frame_record in frame_records: - acdc_df = frame_record['acdc_df'] - if acdc_df is None: - continue - if annotation_name in acdc_df.columns: - return True - - return ( - summary_acdc_df is not None - and annotation_name in summary_acdc_df.columns - ) - - -def drop_custom_annotation_column( - acdc_df: pd.DataFrame | None, - annotation_name: str, -) -> pd.DataFrame | None: - """Return ``acdc_df`` without one custom annotation column.""" - if acdc_df is None: - return None - return acdc_df.drop(columns=annotation_name, errors='ignore') - - -def rename_custom_annotation_column( - acdc_df: pd.DataFrame | None, - old_name: str, - new_name: str, -) -> pd.DataFrame | None: - """Return ``acdc_df`` with one custom annotation column renamed.""" - if acdc_df is None: - return None - return acdc_df.rename(columns={old_name: new_name}) - - -def remap_custom_annotation_ids( - annotated_ids_by_frame, - old_ids, - new_ids, -) -> dict: - """Return custom annotation ID lists remapped after label-ID changes.""" - id_mapper = dict(zip(old_ids, new_ids)) - return { - frame_i: [id_mapper[cell_id] for cell_id in annotated_ids] - for frame_i, annotated_ids in annotated_ids_by_frame.items() - } - - -def update_custom_annotation_frame( - acdc_df: pd.DataFrame, - annotation_name: str, - annotated_ids, - *, - clicked_id: int = 0, - click_is_active: bool = False, - existing_ids=None, -) -> CustomAnnotationFrameUpdate: - """Return frame table and ID list after one custom annotation action.""" - updated_df = acdc_df.copy() - updated_ids = list(annotated_ids) - clicked_id = int(clicked_id) - - if click_is_active and clicked_id > 0: - if clicked_id in updated_ids: - updated_ids.remove(clicked_id) - if clicked_id in updated_df.index: - updated_df.at[clicked_id, annotation_name] = 0 - else: - updated_ids.append(clicked_id) - - existing_ids = set(updated_ids if existing_ids is None else existing_ids) - present_annotated_ids = [ - annot_id for annot_id in updated_ids - if annot_id in existing_ids - ] - for annot_id in present_annotated_ids: - updated_df.at[annot_id, annotation_name] = 1 - - return CustomAnnotationFrameUpdate( - dataframe=updated_df, - annotated_ids=updated_ids, - present_annotated_ids=present_annotated_ids, - ) diff --git a/cellacdc/domain/display_images.py b/cellacdc/domain/display_images.py deleted file mode 100644 index 1b47518a7..000000000 --- a/cellacdc/domain/display_images.py +++ /dev/null @@ -1,40 +0,0 @@ -"""UI-neutral image display transforms.""" - -from __future__ import annotations - -import numpy as np -import skimage -import skimage.exposure - - -def distant_gray( - desired_gray: float, - background_gray: float, - *, - threshold: float = 0.3, -) -> float: - """Return a gray value with enough contrast from a background value.""" - if abs(desired_gray - background_gray) < threshold: - return 1 - desired_gray - return desired_gray - - -def rgb_to_gray(red: float, green: float, blue: float) -> float: - """Convert RGB values in [0, 255] to gamma-corrected grayscale.""" - c_linear = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255 - if c_linear <= 0.0031309: - return 12.92 * c_linear - return 1.055 * c_linear ** (1 / 2.4) - 0.055 - - -def normalize_display_image(image: np.ndarray, how: str, *, image_to_float): - """Apply Cell-ACDC display normalization semantics to an image.""" - if how == 'Do not normalize. Display raw image': - return image - if how == 'Convert to floating point format with values [0, 1]': - return image_to_float(image) - if how == 'Rescale to [0, 1]': - return skimage.exposure.rescale_intensity(skimage.img_as_float(image)) - if how == 'Normalize by max value': - return image / np.max(image) - return image diff --git a/cellacdc/domain/edit_id.py b/cellacdc/domain/edit_id.py deleted file mode 100644 index 66fdc5e21..000000000 --- a/cellacdc/domain/edit_id.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Edit-ID metadata transforms.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np -import pandas as pd - -from .labels import apply_label_id_mapping - - -@dataclass(frozen=True) -class ManualEditTrackingResult: - """Result of replaying manual edit-ID corrections after tracking.""" - - labels: np.ndarray - remaining_edit_info: list[tuple[int, int, int]] - removed_edit_info: list[tuple[int, int, int]] - - -def project_centroid( - centroid, - *, - is_3d: bool = False, - depth_axis: str = 'z', -) -> tuple[float, float]: - """Project a regionprops centroid to the visible y/x plane.""" - if not is_3d: - y, x = centroid - return y, x - - zc, yc, xc = centroid - if depth_axis == 'z': - return yc, xc - if depth_axis == 'y': - return zc, xc - return zc, yc - - -def add_yx_centroids_to_df( - df: pd.DataFrame, - regionprops, - *, - is_3d: bool = False, - depth_axis: str = 'z', -) -> pd.DataFrame: - """Add visible-plane centroid columns indexed by object label.""" - for obj in regionprops: - y_centroid, x_centroid = project_centroid( - obj.centroid, - is_3d=is_3d, - depth_axis=depth_axis, - ) - df.at[obj.label, 'y_centroid'] = int(y_centroid) - df.at[obj.label, 'x_centroid'] = int(x_centroid) - return df - - -def edit_id_info_from_df( - df: pd.DataFrame, - regionprops=None, - *, - is_3d: bool = False, - depth_axis: str = 'z', -) -> list[tuple[int, int, int]]: - """Build replay tuples for manually edited IDs from an ACDC dataframe.""" - if 'was_manually_edited' not in df.columns: - return [] - - has_centroids = {'y_centroid', 'x_centroid'}.issubset(df.columns) - if not has_centroids: - if regionprops is None: - raise ValueError( - 'regionprops are required when centroid columns are missing' - ) - df = add_yx_centroids_to_df( - df, - regionprops, - is_3d=is_3d, - depth_axis=depth_axis, - ) - - manually_edited_df = df[df['was_manually_edited'] > 0] - return [ - (row.y_centroid, row.x_centroid, row.Index) - for row in manually_edited_df.itertuples() - ] - - -def manual_edit_conflicts( - labels: np.ndarray, - edit_id_info, -) -> dict[int, int]: - """Return tracked IDs that differ from requested manual edit IDs.""" - return { - int(labels[y, x]): int(new_id) - for y, x, new_id in edit_id_info - if int(labels[y, x]) != int(new_id) - } - - -def apply_manual_edit_tracking( - tracked_labels: np.ndarray, - edit_id_info, - all_ids, -) -> ManualEditTrackingResult: - """Replay manual ID edits onto a newly tracked label image in place.""" - all_ids_set = {int(label_id) for label_id in all_ids} - max_id = max(all_ids_set, default=1) - remaining_info = [] - removed_info = [] - - for info in edit_id_info: - y, x, new_id = info - new_id = int(new_id) - old_id = int(tracked_labels[y, x]) - normalized_info = (int(y), int(x), new_id) - if old_id == 0 or old_id == new_id: - removed_info.append(normalized_info) - continue - - result = apply_label_id_mapping( - tracked_labels, - [(old_id, new_id)], - existing_ids=all_ids_set, - start_max_id=max_id, - ) - max_id = result.max_id - remaining_info.append(normalized_info) - - return ManualEditTrackingResult( - labels=tracked_labels, - remaining_edit_info=remaining_info, - removed_edit_info=removed_info, - ) diff --git a/cellacdc/domain/events.py b/cellacdc/domain/events.py deleted file mode 100644 index 50932cf49..000000000 --- a/cellacdc/domain/events.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Lightweight pub/sub for session and canvas state (no Qt).""" - -from __future__ import annotations - -from typing import Any, Callable - - -class EventEmitter: - """Minimal event bus keyed by event name.""" - - def __init__(self) -> None: - self._listeners: dict[str, list[Callable[..., None]]] = {} - - def on(self, event: str, callback: Callable[..., None]) -> None: - self._listeners.setdefault(event, []).append(callback) - - def off(self, event: str, callback: Callable[..., None]) -> None: - if event not in self._listeners: - return - self._listeners[event] = [ - cb for cb in self._listeners[event] if cb is not callback - ] - - def emit(self, event: str, *args: Any, **kwargs: Any) -> None: - for callback in list(self._listeners.get(event, [])): - callback(*args, **kwargs) diff --git a/cellacdc/domain/frame_metadata.py b/cellacdc/domain/frame_metadata.py deleted file mode 100644 index e7354ee0b..000000000 --- a/cellacdc/domain/frame_metadata.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Per-frame ACDC metadata table transforms.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pandas as pd - -from .edit_id import project_centroid - - -@dataclass(frozen=True) -class AcdcFrameMetadataResult: - """Result of building one frame's ACDC metadata dataframe.""" - - dataframe: pd.DataFrame - max_id: int - - -def concat_visited_acdc_frames( - frame_records, - *, - labels_key: str = 'labels', - acdc_key: str = 'acdc_df', -) -> pd.DataFrame | None: - """Concatenate ACDC frame tables until labels or metadata are missing.""" - acdc_dfs = [] - keys = [] - for frame_i, frame_record in enumerate(frame_records): - if frame_record[labels_key] is None: - break - - acdc_df = frame_record[acdc_key] - if acdc_df is None: - break - - acdc_dfs.append(acdc_df) - keys.append(frame_i) - - if not acdc_dfs: - return None - - return pd.concat(acdc_dfs, keys=keys, names=['frame_i']) - - -def build_acdc_frame_metadata( - regionprops, - *, - edit_id_info=(), - existing_df: pd.DataFrame | None = None, - is_3d: bool = False, - depth_axis: str = 'z', -) -> AcdcFrameMetadataResult: - """Build or update dynamic per-object metadata for one frame.""" - ids = [] - is_cell_dead = [] - is_cell_excluded = [] - x_centroid = [] - y_centroid = [] - z_centroid = [] - was_manually_edited = [] - edited_new_ids = {int(vals[2]) for vals in edit_id_info} - - for obj in regionprops: - label_id = int(obj.label) - ids.append(label_id) - is_cell_dead.append(getattr(obj, 'dead', False)) - is_cell_excluded.append(getattr(obj, 'excluded', False)) - y, x = project_centroid( - obj.centroid, - is_3d=is_3d, - depth_axis=depth_axis, - ) - y_centroid.append(int(y)) - x_centroid.append(int(x)) - if is_3d: - z_centroid.append(int(obj.centroid[0])) - was_manually_edited.append(int(label_id in edited_new_ids)) - - if existing_df is None: - df = pd.DataFrame( - { - 'Cell_ID': ids, - 'is_cell_dead': is_cell_dead, - 'is_cell_excluded': is_cell_excluded, - 'x_centroid': x_centroid, - 'y_centroid': y_centroid, - 'was_manually_edited': was_manually_edited, - } - ).set_index('Cell_ID') - else: - df = existing_df.drop(columns=['time_seconds'], errors='ignore') - df = df.reindex(ids, fill_value=0) - df['is_cell_dead'] = is_cell_dead - df['is_cell_excluded'] = is_cell_excluded - df['x_centroid'] = x_centroid - df['y_centroid'] = y_centroid - df['was_manually_edited'] = was_manually_edited - - if is_3d: - df['z_centroid'] = z_centroid - - return AcdcFrameMetadataResult(dataframe=df, max_id=max(ids, default=0)) diff --git a/cellacdc/domain/labels.py b/cellacdc/domain/labels.py deleted file mode 100644 index c0a3205a6..000000000 --- a/cellacdc/domain/labels.py +++ /dev/null @@ -1,741 +0,0 @@ -"""Pure label-array operations (no Qt).""" - -from __future__ import annotations - -from dataclasses import dataclass - -import numpy as np -import scipy.ndimage -import skimage.measure -import skimage.morphology -import skimage.segmentation - - -@dataclass(frozen=True) -class LabelResizeResult: - """Result of resizing one object in a 2D label plane.""" - - labels_2d: np.ndarray - active_labels_2d: np.ndarray - seed_labels: np.ndarray - previous_coords: tuple[np.ndarray, np.ndarray] - resized_coords: tuple[np.ndarray, np.ndarray] - - -@dataclass(frozen=True) -class LabelMoveResult: - """Result of moving one object in a label image.""" - - labels: np.ndarray - previous_coords: np.ndarray - moved_coords: np.ndarray - - -@dataclass(frozen=True) -class LabelIdMappingResult: - """Result of applying one or more label-ID remappings.""" - - labels: np.ndarray - max_id: int - swapped_pairs: tuple[tuple[int, int], ...] - - -@dataclass(frozen=True) -class LabelBorderClearResult: - """Result of clearing labels that touch the image border.""" - - labels: np.ndarray - removed_ids: list[int] - - -@dataclass(frozen=True) -class LabelIdsRemovalResult: - """Result of removing labels by identity.""" - - labels: np.ndarray - removed_ids: list[int] - - -@dataclass(frozen=True) -class LabelHoleFillResult: - """Result of filling holes for one label ID.""" - - labels: np.ndarray - filled_pixels: int - - -@dataclass(frozen=True) -class LabelRegionSelectionResult: - """Result of selecting labels through a drawn region.""" - - labels: np.ndarray - selected_ids: list[int] - - -@dataclass(frozen=True) -class LabelRoiIndexResult: - """Result of indexing a label ROI into a label image.""" - - labels: np.ndarray - roi_labels: np.ndarray - inserted_ids: list[int] - replaced_ids: list[int] - - -@dataclass(frozen=True) -class DeletedRoiRestoreResult: - """Result of restoring labels from a deleted-ROI mask.""" - - labels_2d: np.ndarray - display_labels_2d: np.ndarray - deleted_mask: np.ndarray - remaining_deleted_ids: set[int] - restored_ids: set[int] - restored_masks: list[tuple[int, np.ndarray]] - - -@dataclass(frozen=True) -class DeletedRoiApplyResult: - """Result of applying deletion ROI masks to a label image.""" - - labels_2d: np.ndarray - deleted_masks: list[np.ndarray] - deleted_ids_by_roi: list[set[int]] - deleted_ids: set[int] - - -def frame_slice(labels: np.ndarray, frame_i: int) -> np.ndarray: - if labels.ndim == 3: - return labels[frame_i] - return labels - - -def clicked_label_at( - labels: np.ndarray, - x: int, - y: int, - frame_i: int = 0, -) -> int: - sl = frame_slice(labels, frame_i) - if y < 0 or x < 0 or y >= sl.shape[0] or x >= sl.shape[1]: - return 0 - return int(sl[y, x]) - - -def label_ids_from_labels(labels: np.ndarray) -> list[int]: - """Return non-background label IDs in ascending order.""" - ids = np.unique(labels) - return [int(label_id) for label_id in ids if int(label_id) != 0] - - -def clear_border_labels( - labels: np.ndarray, - *, - buffer_size: int = 0, - bgval: int = 0, - mask: np.ndarray | None = None, -) -> LabelBorderClearResult: - """Return labels with border-touching objects removed.""" - original_ids = set(label_ids_from_labels(labels)) - cleared_labels = skimage.segmentation.clear_border( - labels, - buffer_size=buffer_size, - bgval=bgval, - mask=mask, - ) - remaining_ids = set(label_ids_from_labels(cleared_labels)) - return LabelBorderClearResult( - labels=cleared_labels, - removed_ids=sorted(original_ids - remaining_ids), - ) - - -def remove_new_label_ids( - labels: np.ndarray, - previous_ids, - current_ids, -) -> LabelIdsRemovalResult: - """Remove labels present in ``current_ids`` but absent from ``previous_ids``.""" - previous_ids = {int(label_id) for label_id in previous_ids} - removed_ids = sorted( - int(label_id) - for label_id in set(current_ids) - previous_ids - if int(label_id) > 0 - ) - - updated_labels = labels.copy() - if removed_ids: - updated_labels[np.isin(updated_labels, removed_ids)] = 0 - - return LabelIdsRemovalResult( - labels=updated_labels, - removed_ids=removed_ids, - ) - - -def fill_label_holes(labels_2d: np.ndarray, label_id: int) -> LabelHoleFillResult: - """Fill holes inside one 2D label object.""" - label_id = int(label_id) - updated_labels = labels_2d.copy() - mask = labels_2d == label_id - filled_mask = scipy.ndimage.binary_fill_holes(mask) - filled_pixels = int(np.count_nonzero(filled_mask & ~mask)) - updated_labels[filled_mask] = label_id - return LabelHoleFillResult( - labels=updated_labels, - filled_pixels=filled_pixels, - ) - - -def _clear_labels_not_fully_in_mask( - labels_2d: np.ndarray, - mask: np.ndarray, -) -> np.ndarray: - selected_labels = labels_2d.copy() - for obj in skimage.measure.regionprops(labels_2d): - if np.all(mask[obj.slice][obj.image]): - continue - selected_labels[obj.slice][obj.image] = 0 - return selected_labels - - -def select_labels_in_region( - labels: np.ndarray, - mask: np.ndarray, - *, - enclosed_only: bool = False, -) -> LabelRegionSelectionResult: - """Return labels selected by a 2D region mask. - - If ``enclosed_only`` is true, only objects fully enclosed by the region are - selected. Otherwise, every object touching the region is selected. - """ - selected_labels = labels.copy() - if enclosed_only: - if selected_labels.ndim == 2: - selected_labels = _clear_labels_not_fully_in_mask( - selected_labels, - mask, - ) - else: - for z, labels_2d in enumerate(selected_labels): - selected_labels[z] = _clear_labels_not_fully_in_mask( - labels_2d, - mask, - ) - else: - selected_labels[..., ~mask] = 0 - - return LabelRegionSelectionResult( - labels=selected_labels, - selected_ids=label_ids_from_labels(selected_labels), - ) - - -def _xy_border_mask(shape: tuple[int, ...]) -> np.ndarray: - mask = np.zeros(shape, dtype=bool) - mask[..., 1:-1, 1:-1] = True - return mask - - -def index_label_roi( - labels: np.ndarray, - roi_labels: np.ndarray, - roi_slice, - brush_id: int, - *, - clear_border: bool = False, - replace_existing: bool = False, -) -> LabelRoiIndexResult: - """Insert ROI labels into ``labels`` using Cell-ACDC label ROI semantics.""" - indexed_roi_labels = roi_labels.copy() - if clear_border: - indexed_roi_labels = skimage.segmentation.clear_border( - indexed_roi_labels, - mask=_xy_border_mask(indexed_roi_labels.shape), - ) - - roi_mask = indexed_roi_labels > 0 - inserted_ids = sorted( - int(label_id) + int(brush_id) - 1 - for label_id in np.unique(indexed_roi_labels[roi_mask]) - ) - indexed_roi_labels[roi_mask] += int(brush_id) - 1 - - updated_labels = labels.copy() - target = updated_labels[roi_slice] - replaced_ids = [] - if replace_existing and np.any(roi_mask): - replaced_ids = [ - int(label_id) for label_id in np.unique(target[roi_mask]) - if int(label_id) != 0 - ] - for label_id in replaced_ids: - updated_labels[updated_labels == label_id] = 0 - target = updated_labels[roi_slice] - - target[roi_mask] = indexed_roi_labels[roi_mask] - return LabelRoiIndexResult( - labels=updated_labels, - roi_labels=indexed_roi_labels, - inserted_ids=inserted_ids, - replaced_ids=replaced_ids, - ) - - -def merge_label_ids( - labels: np.ndarray, - source_id: int, - target_id: int, - frame_i: int | None = None, -) -> np.ndarray: - """Replace ``source_id`` with ``target_id`` in ``labels``.""" - if source_id == target_id or source_id == 0: - return labels - if frame_i is not None and labels.ndim == 3: - sl = labels[frame_i] - sl[sl == source_id] = target_id - else: - labels[labels == source_id] = target_id - return labels - - -def merge_multiple_ids( - labels: np.ndarray, - ids_to_merge: np.ndarray | list[int], - target_id: int, - frame_i: int | None = None, -) -> np.ndarray: - """Merge each ID in ``ids_to_merge`` into ``target_id``.""" - for label_id in np.asarray(ids_to_merge).ravel(): - label_id = int(label_id) - if label_id == 0 or label_id == target_id: - continue - merge_label_ids(labels, label_id, target_id, frame_i=frame_i) - return labels - - -def apply_label_id_mapping( - labels: np.ndarray, - old_new_pairs, - *, - existing_ids=None, - merge_existing: bool = False, - start_max_id: int | None = None, -) -> LabelIdMappingResult: - """Apply Cell-ACDC edit-ID semantics to a label image in place. - - If the target ID already exists and ``merge_existing`` is false, IDs are - swapped through a temporary label. Otherwise the old ID is replaced by the - new ID, which allows explicit merge workflows. - """ - max_id = ( - int(np.max(labels)) if start_max_id is None and labels.size else - int(start_max_id or 0) - ) - existing_ids_set = ( - None if existing_ids is None else {int(label_id) for label_id in existing_ids} - ) - swapped_pairs = [] - - for old_id, new_id in old_new_pairs: - old_id = int(old_id) - new_id = int(new_id) - has_target = ( - bool(np.any(labels == new_id)) - if existing_ids_set is None else new_id in existing_ids_set - ) - - if has_target and not merge_existing: - temp_id = max_id + 1 - labels[labels == old_id] = temp_id - labels[labels == new_id] = old_id - labels[labels == temp_id] = new_id - max_id = temp_id - swapped_pairs.append((old_id, new_id)) - else: - labels[labels == old_id] = new_id - max_id = max(max_id, new_id) - - return LabelIdMappingResult( - labels=labels, - max_id=max_id, - swapped_pairs=tuple(swapped_pairs), - ) - - -def next_available_label_id( - id_groups=(), - *, - manual_edit_info=(), - base_id: int = 0, -) -> int: - """Return the next label ID after all known and manually edited IDs.""" - max_id = int(base_id) - for ids in id_groups: - for label_id in ids: - max_id = max(max_id, int(label_id)) - - for info in manual_edit_info: - try: - label_id = info[2] - except (TypeError, IndexError): - label_id = info - max_id = max(max_id, int(label_id)) - - return max_id + 1 - - -def remap_id_set(ids, old_ids, new_ids) -> set[int]: - """Return an ID set remapped through parallel old/new ID sequences.""" - id_mapper = dict(zip(old_ids, new_ids)) - return {int(id_mapper[label_id]) for label_id in ids} - - -def restore_deleted_roi_labels( - labels_2d: np.ndarray, - display_labels_2d: np.ndarray, - deleted_mask: np.ndarray, - roi_mask: np.ndarray, - deleted_ids, - *, - enforce: bool = True, -) -> DeletedRoiRestoreResult: - """Restore labels that were previously removed by a deletion ROI. - - ``deleted_mask`` stores the deleted object IDs. If ``enforce`` is false, - IDs still overlapping the current ROI mask are kept deleted. - """ - deleted_ids = {int(label_id) for label_id in deleted_ids} - overlap_roi_deleted_ids = { - int(label_id) for label_id in np.unique(deleted_mask[roi_mask]) - } - restored_ids = set() - restored_masks = [] - - for label_id in deleted_ids: - if label_id in overlap_roi_deleted_ids and not enforce: - continue - - restore_mask = deleted_mask == label_id - restored_ids.add(label_id) - restored_masks.append((label_id, restore_mask.copy())) - display_labels_2d[restore_mask] = label_id - labels_2d[restore_mask] = label_id - deleted_mask[restore_mask] = 0 - - return DeletedRoiRestoreResult( - labels_2d=labels_2d, - display_labels_2d=display_labels_2d, - deleted_mask=deleted_mask, - remaining_deleted_ids=deleted_ids - restored_ids, - restored_ids=restored_ids, - restored_masks=restored_masks, - ) - - -def label_ids_in_masks( - labels: np.ndarray, - masks, - *, - additional_labels: np.ndarray | None = None, -) -> set[int]: - """Return all label IDs under one or more boolean masks.""" - label_ids = set() - for mask in masks: - label_ids.update(int(label_id) for label_id in labels[mask]) - if additional_labels is not None: - label_ids.update(int(label_id) for label_id in additional_labels[mask]) - return label_ids - - -def collect_deleted_roi_ids(deleted_ids_by_roi) -> set[int]: - """Flatten stored deleted-ID collections for multiple deletion ROIs.""" - label_ids = set() - for deleted_ids in deleted_ids_by_roi: - label_ids.update(int(label_id) for label_id in deleted_ids) - return label_ids - - -def apply_deleted_roi_masks( - labels_2d: np.ndarray, - roi_masks, - deleted_masks, - deleted_ids_by_roi, -) -> DeletedRoiApplyResult: - """Delete labelled objects intersecting ROI masks and record them.""" - deleted_masks = list(deleted_masks) - deleted_ids_by_roi = [ - {int(label_id) for label_id in deleted_ids} - for deleted_ids in deleted_ids_by_roi - ] - all_deleted_ids = set() - - for idx, roi_mask in enumerate(roi_masks): - deleted_mask = deleted_masks[idx] - deleted_ids = deleted_ids_by_roi[idx] - for obj in skimage.measure.regionprops(labels_2d): - object_mask = obj.image - object_slice = obj.slice - is_deleted_object = np.any(roi_mask[object_slice][object_mask]) - if not is_deleted_object: - continue - - label_id = int(obj.label) - deleted_mask[object_slice][object_mask] = label_id - labels_2d[object_slice][object_mask] = 0 - deleted_ids.add(label_id) - all_deleted_ids.add(label_id) - - deleted_masks[idx] = deleted_mask - deleted_ids_by_roi[idx] = deleted_ids - - return DeletedRoiApplyResult( - labels_2d=labels_2d, - deleted_masks=deleted_masks, - deleted_ids_by_roi=deleted_ids_by_roi, - deleted_ids=all_deleted_ids, - ) - - -def _empty_roi_mask(shape: tuple[int, ...]) -> np.ndarray: - return np.zeros(shape, dtype=bool) - - -def _paint_roi_coords( - roi_mask: np.ndarray, - rr: np.ndarray, - cc: np.ndarray, - *, - z_slice=None, -) -> np.ndarray: - if roi_mask.ndim == 3: - roi_mask[z_slice, rr, cc] = True - else: - roi_mask[rr, cc] = True - return roi_mask - - -def polygon_roi_mask( - shape: tuple[int, ...], - points, - *, - z_slice=None, -) -> np.ndarray: - """Rasterize a polyline or polygon ROI from ``(x, y)`` points.""" - roi_mask = _empty_roi_mask(shape) - if not points: - return roi_mask - - rr_points = [int(y) for x, y in points] - cc_points = [int(x) for x, y in points] - if not rr_points or not cc_points: - return roi_mask - - plane_shape = shape[-2:] - if len(rr_points) == 2: - rr, cc, _ = skimage.draw.line_aa( - rr_points[0], cc_points[0], rr_points[1], cc_points[1], - ) - else: - rr, cc = skimage.draw.polygon(rr_points, cc_points, shape=plane_shape) - - height, width = plane_shape - keep = (rr >= 0) & (rr < height) & (cc >= 0) & (cc < width) - return _paint_roi_coords(roi_mask, rr[keep], cc[keep], z_slice=z_slice) - - -def line_roi_mask( - shape: tuple[int, ...], - point1, - point2, - *, - z_slice=None, -) -> np.ndarray: - """Rasterize a line ROI from two ``(x, y)`` points.""" - roi_mask = _empty_roi_mask(shape) - x1, y1 = [int(coord) for coord in point1] - x2, y2 = [int(coord) for coord in point2] - rr, cc, _ = skimage.draw.line_aa(y1, x1, y2, x2) - return _paint_roi_coords(roi_mask, rr, cc, z_slice=z_slice) - - -def rectangle_roi_mask( - shape: tuple[int, ...], - origin, - size, - *, - z_slice=None, -) -> np.ndarray: - """Rasterize an axis-aligned rectangular ROI.""" - roi_mask = _empty_roi_mask(shape) - x0, y0 = [int(coord) for coord in origin] - width, height = [int(coord) for coord in size] - if roi_mask.ndim == 3: - roi_mask[z_slice, y0:y0+height, x0:x0+width] = True - else: - roi_mask[y0:y0+height, x0:x0+width] = True - return roi_mask - - -def build_disk_mask( - shape: tuple[int, int], - x: int, - y: int, - radius: int, -) -> np.ndarray: - """Build a circular boolean mask centered at ``(x, y)``.""" - height, width = shape - mask = np.zeros((height, width), dtype=bool) - y0, y1 = max(0, y - radius), min(height, y + radius + 1) - x0, x1 = max(0, x - radius), min(width, x + radius + 1) - yy, xx = np.ogrid[y0:y1, x0:x1] - disk = (yy - y) ** 2 + (xx - x) ** 2 <= radius ** 2 - mask[y0:y1, x0:x1] = disk - return mask - - -def _label_target( - labels: np.ndarray, - frame_i: int, -) -> np.ndarray: - if labels.ndim == 3: - return labels[frame_i] - return labels - - -def apply_label_mask( - labels: np.ndarray, - mask: np.ndarray, - label_id: int, - frame_i: int = 0, -) -> np.ndarray: - """Paint ``label_id`` wherever ``mask`` is True.""" - target = _label_target(labels, frame_i) - target[mask] = label_id - return labels - - -def apply_eraser_mask( - labels: np.ndarray, - mask: np.ndarray, - frame_i: int = 0, - only_id: int | None = None, -) -> np.ndarray: - """Zero labels under ``mask``; optionally restrict to ``only_id``.""" - target = _label_target(labels, frame_i) - if only_id is not None: - erase_mask = np.logical_and(mask, target == only_id) - else: - erase_mask = mask - target[erase_mask] = 0 - return labels - - -def resize_label_object( - labels_2d: np.ndarray, - active_labels_2d: np.ndarray, - object_coords: np.ndarray, - label_id: int, - footprint_size: int, - *, - dilation: bool = True, - seed_labels: np.ndarray | None = None, -) -> LabelResizeResult: - """Dilate or erode one label object without overwriting neighbouring IDs. - - ``labels_2d`` is the persisted label plane to edit, while - ``active_labels_2d`` is the collision mask used by interactive tools. Both - arrays are updated in place and returned for scriptable callers. - """ - coords = np.asarray(object_coords) - yy = coords[:, -2].astype(int, copy=True) - xx = coords[:, -1].astype(int, copy=True) - previous_coords = (yy.copy(), xx.copy()) - - if seed_labels is None: - seed_labels = np.zeros_like(active_labels_2d) - seed_labels[yy, xx] = label_id - else: - seed_labels = np.asarray(seed_labels) - - active_labels_2d[yy, xx] = 0 - labels_2d[yy, xx] = 0 - - footprint = skimage.morphology.disk(int(footprint_size)) - if dilation: - resized_labels = skimage.morphology.dilation(seed_labels, footprint) - else: - resized_labels = skimage.morphology.erosion(seed_labels, footprint) - - # Keep the edited object from growing into still-occupied pixels. - resized_labels = np.asarray(resized_labels) - resized_labels[active_labels_2d > 0] = 0 - - resized_regions = skimage.measure.regionprops(resized_labels.astype(np.int32)) - if not resized_regions: - raise ValueError(f'Label {label_id} vanished during resize') - - resized_obj_coords = resized_regions[0].coords - resized_yy = resized_obj_coords[:, -2].astype(int, copy=False) - resized_xx = resized_obj_coords[:, -1].astype(int, copy=False) - resized_coords = (resized_yy.copy(), resized_xx.copy()) - - active_labels_2d[resized_yy, resized_xx] = label_id - labels_2d[resized_yy, resized_xx] = label_id - - return LabelResizeResult( - labels_2d=labels_2d, - active_labels_2d=active_labels_2d, - seed_labels=seed_labels, - previous_coords=previous_coords, - resized_coords=resized_coords, - ) - - -def move_label_object( - labels: np.ndarray, - object_coords: np.ndarray, - label_id: int, - *, - delta_y: int, - delta_x: int, - shape: tuple[int, int] | None = None, -) -> LabelMoveResult: - """Move one 2D or z-stacked label object, clipping at image boundaries.""" - moved_coords = np.asarray(object_coords).copy() - previous_coords = moved_coords.copy() - - if shape is None: - shape = labels.shape[-2:] - height, width = shape - - yy = previous_coords[:, -2].astype(int, copy=False) - xx = previous_coords[:, -1].astype(int, copy=False) - - if labels.ndim >= 3 and previous_coords.shape[1] >= 3: - zz = previous_coords[:, 0].astype(int, copy=False) - labels[zz, yy, xx] = 0 - else: - labels[yy, xx] = 0 - - moved_coords[:, -2] = np.clip( - moved_coords[:, -2] + int(delta_y), 0, height - 1, - ) - moved_coords[:, -1] = np.clip( - moved_coords[:, -1] + int(delta_x), 0, width - 1, - ) - - moved_yy = moved_coords[:, -2].astype(int, copy=False) - moved_xx = moved_coords[:, -1].astype(int, copy=False) - if labels.ndim >= 3 and moved_coords.shape[1] >= 3: - moved_zz = moved_coords[:, 0].astype(int, copy=False) - labels[moved_zz, moved_yy, moved_xx] = label_id - else: - labels[moved_yy, moved_xx] = label_id - - return LabelMoveResult( - labels=labels, - previous_coords=previous_coords, - moved_coords=moved_coords, - ) diff --git a/cellacdc/domain/lineage.py b/cellacdc/domain/lineage.py deleted file mode 100644 index fa87c268c..000000000 --- a/cellacdc/domain/lineage.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Pure lineage annotation table operations.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pandas as pd - - -@dataclass(frozen=True) -class LineageAnnotationsRemovalResult: - """ACDC frame after removing lineage annotation columns.""" - - acdc_df: pd.DataFrame | None - removed: bool - missing_frame: bool = False - - -@dataclass(frozen=True) -class LineageFutureRemovalResult: - """Future lineage removals for frame records.""" - - acdc_dfs_by_frame: dict[int, pd.DataFrame] - scanned_frame_indices: list[int] - removed_frame_indices: list[int] - missing_frame_indices: list[int] - - -def has_lineage_tree_annotations( - acdc_df: pd.DataFrame | None, - lineage_tree=None, - *, - parent_column: str = 'parent_ID_tree', -) -> bool: - """Return whether lineage tree annotations are active or stored.""" - if lineage_tree is not None: - return True - return acdc_df is not None and parent_column in acdc_df.columns - - -def remove_lineage_tree_annotations( - acdc_df: pd.DataFrame | None, - lineage_tree_colnames, -) -> LineageAnnotationsRemovalResult: - """Return an ACDC frame table without lineage tree columns.""" - if acdc_df is None: - return LineageAnnotationsRemovalResult( - acdc_df=None, - removed=False, - missing_frame=True, - ) - - existing_columns = acdc_df.columns.intersection(lineage_tree_colnames) - if existing_columns.empty: - return LineageAnnotationsRemovalResult(acdc_df=acdc_df, removed=False) - - return LineageAnnotationsRemovalResult( - acdc_df=acdc_df.drop(columns=lineage_tree_colnames, errors='ignore'), - removed=True, - ) - - -def remove_future_lineage_tree_annotations( - frame_records, - lineage_tree_colnames, - from_frame_i: int, - *, - size_t: int | None = None, - acdc_key: str = 'acdc_df', -) -> LineageFutureRemovalResult: - """Return future frame-table lineage removals from ``from_frame_i`` onward.""" - acdc_dfs_by_frame = {} - scanned_frame_indices = [] - removed_frame_indices = [] - missing_frame_indices = [] - stop_at = len(frame_records) if size_t is None else int(size_t) - - for frame_i in range(int(from_frame_i), stop_at): - scanned_frame_indices.append(frame_i) - acdc_df = frame_records[frame_i][acdc_key] - result = remove_lineage_tree_annotations( - acdc_df, - lineage_tree_colnames, - ) - if result.missing_frame: - missing_frame_indices.append(frame_i) - continue - if not result.removed: - continue - - acdc_dfs_by_frame[frame_i] = result.acdc_df - removed_frame_indices.append(frame_i) - - return LineageFutureRemovalResult( - acdc_dfs_by_frame=acdc_dfs_by_frame, - scanned_frame_indices=scanned_frame_indices, - removed_frame_indices=removed_frame_indices, - missing_frame_indices=missing_frame_indices, - ) diff --git a/cellacdc/domain/metrics_basic.py b/cellacdc/domain/metrics_basic.py deleted file mode 100644 index 70daa093d..000000000 --- a/cellacdc/domain/metrics_basic.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Basic measurements from ``PositionSession`` (no legacy ``loadData`` required).""" - -from __future__ import annotations - -import numpy as np -import pandas as pd -import skimage.measure - - -def compute_basic_metrics(session) -> pd.DataFrame: - """Regionprops table per frame — fallback when legacy kernel is unavailable.""" - labels = session.labels - if labels is None: - raise ValueError('MeasureRunnable requires labels in session') - - rows: list[dict] = [] - num_frames = session.num_frames - for frame_i in range(num_frames): - lab = session.frame_labels(frame_i) - if lab is None or not np.any(lab): - continue - for rp in skimage.measure.regionprops(lab.astype(np.int32)): - if rp.label == 0: - continue - rows.append({ - 'frame_i': frame_i, - 'Cell_ID': rp.label, - 'area': rp.area, - 'centroid_y': rp.centroid[0], - 'centroid_x': rp.centroid[1], - }) - - if not rows: - return pd.DataFrame(columns=['frame_i', 'Cell_ID', 'area', 'centroid_y', 'centroid_x']) - return pd.DataFrame(rows).set_index(['frame_i', 'Cell_ID']) diff --git a/cellacdc/domain/object_counts.py b/cellacdc/domain/object_counts.py deleted file mode 100644 index d4c0842cc..000000000 --- a/cellacdc/domain/object_counts.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Scriptable object counting and label-frame helpers.""" - -from __future__ import annotations - -from collections.abc import Callable - -import numpy as np -import skimage.measure - - -def _record_get(record, key, default=None): - if hasattr(record, 'get'): - return record.get(key, default) - return getattr(record, key, default) - - -def current_labels( - pos_data, - *, - curr_lab: np.ndarray | None = None, - frame_i: int | None = None, -) -> np.ndarray | None: - """Resolve the current labels from live, cached, or persisted frame data.""" - if frame_i is None: - frame_i = pos_data.frame_i - - if curr_lab is None and frame_i == pos_data.frame_i: - curr_lab = pos_data.lab - - if curr_lab is None: - try: - labels = _record_get(pos_data.allData_li[frame_i], 'labels') - curr_lab = labels.copy() - except (AttributeError, IndexError, TypeError): - pass - - if curr_lab is None: - try: - curr_lab = pos_data.segm_data[frame_i].copy() - except (AttributeError, IndexError, TypeError): - pass - - return curr_lab - - -def collect_all_ids(pos_data, *, only_visited: bool = False) -> set[int]: - """Collect all object IDs across visited or available segmentation frames.""" - all_ids = set() - for frame_i in range(len(pos_data.segm_data)): - if frame_i >= len(pos_data.allData_li): - break - - frame_record = pos_data.allData_li[frame_i] - lab = _record_get(frame_record, 'labels') - if lab is None and only_visited: - break - - if lab is None: - regionprops = skimage.measure.regionprops(pos_data.segm_data[frame_i]) - else: - regionprops = _record_get(frame_record, 'regionprops') - if regionprops is None: - regionprops = skimage.measure.regionprops(lab) - - all_ids.update(int(obj.label) for obj in regionprops) - - return all_ids - - -def snapshot_object_counts( - positions, - current_pos_i: int, - *, - current_lab_2d=None, - include_current_z_slice: bool = False, - path_exists: Callable[[str], bool], -) -> dict[str, int]: - """Count objects across loaded snapshot positions.""" - pos_data = positions[current_pos_i] - counts = { - 'In current position': len(pos_data.IDs), - 'In all visited positions (current session)': 0, - 'In all visited positions (previous sessions)': 0, - 'In all loaded positions': 0, - } - if include_current_z_slice and current_lab_2d is not None: - counts['In current z-slice'] = len( - skimage.measure.regionprops(current_lab_2d) - ) - - for position in positions: - ids = _record_get(position.allData_li[0], 'IDs', []) - if path_exists(position.acdc_output_csv_path): - counts['In all visited positions (previous sessions)'] += len(ids) - - if ids: - num_objects = len(ids) - else: - regionprops = skimage.measure.regionprops(position.segm_data[0]) - num_objects = len(regionprops) - - counts['In all loaded positions'] += num_objects - - if position.visited: - counts['In all visited positions (current session)'] += num_objects - - return counts diff --git a/cellacdc/domain/object_search.py b/cellacdc/domain/object_search.py deleted file mode 100644 index 9e4aa4447..000000000 --- a/cellacdc/domain/object_search.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Scriptable object search operations.""" - -from __future__ import annotations - -from collections.abc import Callable, Sequence - -import skimage.measure - - -def find_frame_with_id( - segmentation_frames: Sequence, - frame_records: Sequence[dict], - searched_id: int, - *, - progress_callback: Callable[[int], None] | None = None, -) -> int | None: - """Return the first frame index containing ``searched_id``.""" - for frame_i, segmentation in enumerate(segmentation_frames): - if frame_i >= len(frame_records): - break - - frame_record = frame_records[frame_i] - labels = frame_record['labels'] - if labels is None: - regionprops = skimage.measure.regionprops(segmentation) - frame_ids = {obj.label for obj in regionprops} - else: - frame_ids = set(frame_record['IDs']) - - if searched_id in frame_ids: - return frame_i - - if progress_callback is not None: - progress_callback(1) - - return None diff --git a/cellacdc/domain/points.py b/cellacdc/domain/points.py deleted file mode 100644 index 163895fce..000000000 --- a/cellacdc/domain/points.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Pure point-layer table transformations.""" - -from __future__ import annotations - -from collections.abc import Iterable -from typing import Any - -import numpy as np -import pandas as pd - - -def infer_points_column_mapping(columns: Iterable[str]) -> dict[str, str]: - """Infer standard point-layer columns from table columns.""" - column_set = set(columns) - return { - 'x': 'x' if 'x' in column_set else 'None', - 'y': 'y' if 'y' in column_set else 'None', - 'z': 'z' if 'z' in column_set else 'None', - 't': 'frame_i' if 'frame_i' in column_set else 'None', - } - - -def points_table_to_data( - df: pd.DataFrame, - t_col: str, - z_col: str, - y_col: str, - x_col: str, - *, - include_row_data: bool = True, -) -> dict[Any, dict[str, list[Any]] | dict[int, dict[str, list[Any]]]]: - """Convert a points table to the nested point-layer data structure.""" - points_data = {} - points_df = df.copy() - if 'id' not in points_df.columns: - points_df['id'] = '' - - if t_col != 'None': - grouped = points_df.groupby(t_col) - else: - grouped = [(0, points_df)] - - for frame_i, df_frame in grouped: - if z_col != 'None': - df_frame = df_frame.copy() - df_frame[z_col] = df_frame[z_col].round().astype(int) - points_data[frame_i] = {} - for z in df_frame[z_col].unique(): - z_int = round(z) - df_z = df_frame[df_frame[z_col] == z_int] - z_data = { - 'x': df_z[x_col].to_list(), - 'y': df_z[y_col].to_list(), - 'id': df_z['id'].to_list(), - } - if include_row_data: - z_data['data'] = [ - row.to_string() for _, row in df_z.iterrows() - ] - points_data[frame_i][z_int] = z_data - else: - frame_data = { - 'x': df_frame[x_col].to_list(), - 'y': df_frame[y_col].to_list(), - 'id': df_frame['id'].to_list(), - } - if include_row_data: - frame_data['data'] = [ - row.to_string() for _, row in df_frame.iterrows() - ] - points_data[frame_i] = frame_data - return points_data - - -def click_points_table_to_data( - df: pd.DataFrame, - *, - size_z: int = 1, -) -> dict[Any, dict[str, list[Any]] | dict[int, dict[str, list[Any]]]]: - """Convert click-entry point tables to GUI point-layer data.""" - if size_z > 1 and df['z'].isna().any(): - raise ValueError('3D point tables require z values for every row') - - z_col = 'z' if size_z > 1 else 'None' - return points_table_to_data( - df, - 'frame_i', - z_col, - 'y', - 'x', - include_row_data=False, - ) - - -def point_id_already_new( - points_data_pos: dict[Any, Any] | None, - frame_i: int, - point_id: int, - known_ids: Iterable[int], -) -> bool: - """Return whether ``point_id`` is new and not already present in frame data.""" - if point_id in known_ids: - return False - - if points_data_pos is None: - return True - - frame_points_data = points_data_pos.get(frame_i) - if frame_points_data is None: - return True - - if 'x' not in frame_points_data: - for z_data in frame_points_data.values(): - if point_id in z_data['id']: - return False - return True - - return point_id not in frame_points_data['id'] - - -def next_click_point_id( - points_data_pos: dict[Any, Any] | None, - frame_i: int, - current_id: int, - *, - size_z: int = 1, -) -> int: - """Return the next point id for a click-entry layer.""" - if points_data_pos is None: - return 1 - - frame_points_data = points_data_pos.get(frame_i) - if frame_points_data is None: - return 1 - - if size_z > 1: - new_id = 1 - for z_data in frame_points_data.values(): - max_id = max(z_data.get('id', []), default=0) + 1 - if max_id > new_id: - new_id = max_id - else: - new_id = max(frame_points_data.get('id', []), default=0) + 1 - - if current_id >= new_id: - return current_id - return new_id - - -def add_click_point( - points_data_pos: dict[Any, Any], - frame_i: int, - x: float, - y: float, - point_id: int, - *, - size_z: int = 1, - z_slice: int | None = None, -) -> dict[Any, Any]: - """Add one click-entry point to nested point-layer data.""" - frame_points_data = points_data_pos.get(frame_i) - if frame_points_data is None: - if size_z > 1: - if z_slice is None: - raise ValueError('z_slice is required for 3D point data') - points_data_pos[frame_i] = { - z_slice: {'x': [x], 'y': [y], 'id': [point_id]}, - } - else: - points_data_pos[frame_i] = { - 'x': [x], 'y': [y], 'id': [point_id], - } - return points_data_pos - - if size_z > 1: - if z_slice is None: - raise ValueError('z_slice is required for 3D point data') - z_data = frame_points_data.get(z_slice) - if z_data is None: - frame_points_data[z_slice] = { - 'x': [x], 'y': [y], 'id': [point_id], - } - else: - z_data['x'].append(x) - z_data['y'].append(y) - z_data['id'].append(point_id) - else: - frame_points_data['x'].append(x) - frame_points_data['y'].append(y) - frame_points_data['id'].append(point_id) - - points_data_pos[frame_i] = frame_points_data - return points_data_pos - - -def remove_click_points( - frame_points_data: dict[Any, Any], - points: Iterable[tuple[float, float, int]], - *, - z_slice: int | None = None, - z_radius: int = 0, -) -> list[int]: - """Remove clicked points from one frame's nested point-layer data.""" - removed_ids = [] - for x, y, point_id in points: - if z_slice is not None: - z_range = range(z_slice - z_radius, z_slice + z_radius + 1) - data_slices = [ - frame_points_data[z] - for z in z_range - if z in frame_points_data - ] - else: - data_slices = [frame_points_data] - - for points_slice in data_slices: - if point_id not in points_slice['id']: - continue - points_slice['x'].remove(x) - points_slice['y'].remove(y) - points_slice['id'].remove(point_id) - removed_ids.append(point_id) - - return removed_ids - - -def flatten_frame_points_data( - frame_points_data: dict[Any, Any], - *, - z_slice: int | None = None, - z_radius: int = 0, -) -> tuple[list[Any], list[Any], list[Any], list[Any]]: - """Flatten one frame's point-layer data for display or scripting.""" - if 'x' in frame_points_data: - return ( - list(frame_points_data['x']), - list(frame_points_data['y']), - list(frame_points_data['id']), - list(frame_points_data.get('data', [])), - ) - - xx, yy, ids, data = [], [], [], [] - if z_slice is None: - z_items = frame_points_data.items() - else: - z_range = range(z_slice - z_radius, z_slice + z_radius + 1) - z_items = ( - (z, frame_points_data[z]) - for z in z_range - if z in frame_points_data - ) - - for _z, z_data in z_items: - xx.extend(z_data['x']) - yy.extend(z_data['y']) - ids.extend(z_data['id']) - data.extend(z_data.get('data', [])) - return xx, yy, ids, data - - -def _label_at(labels, y: float, x: float, z: float | None, is_segm_3d: bool): - if is_segm_3d and z is not None: - return labels[int(z), int(y), int(x)] - return labels[int(y), int(x)] - - -def _linear_fit_3d(xx, yy, zz): - points = np.column_stack((xx, yy, zz)) - centroid = points.mean(axis=0) - _, _, vh = np.linalg.svd(points - centroid) - return centroid, vh[0] - - -def interpolate_points_zslices( - df: pd.DataFrame, - labels, - *, - is_segm_3d: bool, -) -> pd.DataFrame: - """Interpolate missing z-slice points for each frame/id point track.""" - if not is_segm_3d or 'z' not in df.columns: - return df - - df_new_rows = [] - for (_frame_i, _point_id), df_id in df.groupby(['frame_i', 'id']): - xx = df_id['x'].values - yy = df_id['y'].values - zz = df_id['z'].values - point, direction = _linear_fit_3d(xx, yy, zz) - - new_row_df = df_id.iloc[[0]].copy() - z0, z1 = int(np.min(zz)), int(np.max(zz)) - for z in range(z0, z1 + 1): - if z in zz: - continue - - t_int = (z - point[2]) / direction[2] - x_new, y_new, z_new = point + t_int * direction - new_row_df['z'] = round(z_new) - new_row_df['y'] = round(y_new) - new_row_df['x'] = round(x_new) - new_row_df['Cell_ID'] = labels[ - int(round(z_new)), - int(round(y_new)), - int(round(x_new)), - ] - df_new_rows.append(new_row_df.copy()) - - if not df_new_rows: - return df - - df_new = pd.concat(df_new_rows, ignore_index=True) - df = pd.concat([df, df_new], ignore_index=True) - return df.sort_values(by=['frame_i', 'id', 'z']).reset_index(drop=True) - - -def points_data_to_table( - points_data: dict[Any, Any], - labels, - *, - is_segm_3d: bool = False, - size_z: int = 1, - interpolate_z: bool = False, -) -> pd.DataFrame: - """Convert nested point-layer data to a table.""" - df = pd.DataFrame(columns=['frame_i', 'Cell_ID', 'z', 'y', 'x', 'id']) - frames_vals = [] - cell_ids = [] - zz = [] - yy = [] - xx = [] - ids = [] - - for frame_i, frame_points_data in points_data.items(): - if size_z > 1: - for z, z_slice_points_data in frame_points_data.items(): - for y, x, point_id in zip( - z_slice_points_data['y'], - z_slice_points_data['x'], - z_slice_points_data['id'], - ): - frames_vals.append(frame_i) - cell_ids.append(_label_at(labels, y, x, z, is_segm_3d)) - zz.append(z) - yy.append(y) - xx.append(x) - ids.append(point_id) - else: - for y, x, point_id in zip( - frame_points_data['y'], - frame_points_data['x'], - frame_points_data['id'], - ): - frames_vals.append(frame_i) - cell_ids.append(_label_at(labels, y, x, None, is_segm_3d)) - yy.append(y) - xx.append(x) - ids.append(point_id) - - df['frame_i'] = frames_vals - df['Cell_ID'] = cell_ids - df['y'] = yy - df['x'] = xx - df['id'] = ids - if zz: - df['z'] = zz - - if interpolate_z: - df = interpolate_points_zslices(df, labels, is_segm_3d=is_segm_3d) - return df diff --git a/cellacdc/domain/session.py b/cellacdc/domain/session.py deleted file mode 100644 index 0f94a7e4f..000000000 --- a/cellacdc/domain/session.py +++ /dev/null @@ -1,142 +0,0 @@ -"""In-memory session objects for scripting and GUI binding.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -import numpy as np -import pandas as pd - -from .events import EventEmitter -from .state import PositionState - - -@dataclass -class PositionSession: - """Headless per-position data container (successor to ``loadData``).""" - - _state: PositionState - events: EventEmitter = field(default_factory=EventEmitter) - _legacy_pos_data: Any = field(default=None, repr=False) - - @classmethod - def from_arrays( - cls, - intensity: np.ndarray, - labels: np.ndarray | None = None, - acdc_df: pd.DataFrame | None = None, - *, - pixel_size_um: float | None = None, - channel_name: str = '', - basename: str = '', - **metadata: Any, - ) -> PositionSession: - md = dict(metadata) - if pixel_size_um is not None: - md['pixel_size_um'] = pixel_size_um - if channel_name: - md['channel_name'] = channel_name - if basename: - md['basename'] = basename - state = PositionState( - intensity=np.asarray(intensity), - labels=None if labels is None else np.asarray(labels), - acdc_df=acdc_df, - metadata=md, - ) - return cls(_state=state) - - @classmethod - def from_path( - cls, - img_path: str, - user_ch_name: str = '', - **metadata: Any, - ) -> PositionSession: - from cellacdc.io.adapters.disk_to_session import load_position_from_disk - - return load_position_from_disk(img_path, user_ch_name, **metadata) - - @classmethod - def from_loadData(cls, pos_data: Any) -> PositionSession: - from cellacdc.io.adapters.legacy_load_data import session_from_load_data - - return session_from_load_data(pos_data) - - @property - def intensity(self) -> np.ndarray: - return self._state.intensity - - @property - def labels(self) -> np.ndarray | None: - return self._state.labels - - @property - def acdc_df(self) -> pd.DataFrame | None: - return self._state.acdc_df - - @property - def metadata(self) -> dict[str, Any]: - return self._state.metadata - - @property - def frame_i(self) -> int: - return self._state.frame_i - - @frame_i.setter - def frame_i(self, value: int) -> None: - self._state.frame_i = int(value) - self.events.emit('frame_changed', self._state.frame_i) - - @property - def num_frames(self) -> int: - return self._state.num_frames - - @property - def legacy_pos_data(self) -> Any: - return self._legacy_pos_data - - def set_labels(self, labels: np.ndarray) -> None: - self._state.labels = np.asarray(labels) - self.events.emit('labels_changed', self._state.labels) - - def set_acdc_df(self, acdc_df: pd.DataFrame) -> None: - self._state.acdc_df = acdc_df - self.events.emit('acdc_df_changed', acdc_df) - - def frame_intensity(self, frame_i: int | None = None) -> np.ndarray: - return self._state.frame_intensity(frame_i) - - def frame_labels(self, frame_i: int | None = None) -> np.ndarray | None: - return self._state.frame_labels(frame_i) - - def save(self, path: str | None = None) -> None: - from cellacdc.io.adapters.session_to_disk import save_position_session - - save_position_session(self, path) - - -@dataclass -class ExperimentSession: - """Collection of positions in an experiment folder.""" - - positions: list[PositionSession] = field(default_factory=list) - exp_path: str = '' - events: EventEmitter = field(default_factory=EventEmitter) - - @classmethod - def from_experiment_path( - cls, - exp_path: str, - user_ch_name: str = '', - ) -> ExperimentSession: - from cellacdc.io.adapters.disk_to_session import load_experiment_from_disk - - return load_experiment_from_disk(exp_path, user_ch_name) - - def __len__(self) -> int: - return len(self.positions) - - def __getitem__(self, index: int) -> PositionSession: - return self.positions[index] diff --git a/cellacdc/domain/state.py b/cellacdc/domain/state.py deleted file mode 100644 index c4f2c639c..000000000 --- a/cellacdc/domain/state.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Mutable in-memory state container for a position.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -import numpy as np -import pandas as pd - - -@dataclass -class PositionState: - """Raw mutable store backing :class:`PositionSession`.""" - - intensity: np.ndarray - labels: np.ndarray | None = None - acdc_df: pd.DataFrame | None = None - metadata: dict[str, Any] = field(default_factory=dict) - frame_i: int = 0 - fluo_data: dict[str, np.ndarray] = field(default_factory=dict) - - @property - def num_frames(self) -> int: - if self.intensity.ndim == 2: - return 1 - return int(self.intensity.shape[0]) - - def frame_intensity(self, frame_i: int | None = None) -> np.ndarray: - idx = self.frame_i if frame_i is None else frame_i - if self.intensity.ndim == 2: - return self.intensity - return self.intensity[idx] - - def frame_labels(self, frame_i: int | None = None) -> np.ndarray | None: - if self.labels is None: - return None - idx = self.frame_i if frame_i is None else frame_i - if self.labels.ndim == 2: - return self.labels - return self.labels[idx] diff --git a/cellacdc/domain/tracking.py b/cellacdc/domain/tracking.py deleted file mode 100644 index 205632360..000000000 --- a/cellacdc/domain/tracking.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Tracking-related label metadata transforms.""" - -from __future__ import annotations - -from dataclasses import dataclass -from itertools import product - -import numpy as np - - -@dataclass(frozen=True) -class TrackedLostIdsResult: - """Result of resolving stored lost centroids against a previous label image.""" - - lost_ids: set[int] - remaining_centroids: set[tuple[int, ...]] - - -@dataclass(frozen=True) -class LostNewIdsResult: - """Result of comparing previous and current frame IDs.""" - - lost_ids: list[int] - new_ids: list[int] - ids_with_holes: list[int] - - -@dataclass(frozen=True) -class FutureIdPropagationScan: - """Future-frame scan result for an ID-changing segmentation edit.""" - - last_tracked_i: int - has_affected_future_ids: bool - - -def last_tracked_frame_index( - frame_labels, - *, - first_frame_fallback: int = 0, - total_frames: int | None = None, -) -> int: - """Return the last contiguous frame index with stored labels. - - ``first_frame_fallback`` preserves legacy GUI paths that disagree on - whether an unvisited first frame means frame ``0`` or no frame ``-1``. - """ - last_seen_i = 0 - saw_frame = False - for frame_i, labels in enumerate(frame_labels): - saw_frame = True - if labels is None: - return first_frame_fallback if frame_i == 0 else frame_i - 1 - last_seen_i = frame_i - - if total_frames is not None: - return max(int(total_frames) - 1, 0) - if not saw_frame: - return 0 - return last_seen_i - - -def scan_future_id_propagation( - target_id: int, - *, - current_frame_i: int, - frame_labels, - fallback_frame_labels, - include_unvisited: bool = False, - total_frames: int | None = None, -) -> FutureIdPropagationScan: - """Scan future labels for ``target_id`` and report propagation state.""" - frame_labels = list(frame_labels) - fallback_frame_labels = list(fallback_frame_labels) - if total_frames is None: - total_frames = len(fallback_frame_labels) - - last_tracked_i = int(total_frames) - 1 - last_tracked_i_found = False - has_affected_future_ids = False - for frame_i in range(current_frame_i + 1, len(fallback_frame_labels)): - labels = frame_labels[frame_i] - if labels is None: - if not last_tracked_i_found: - last_tracked_i = frame_i - 1 - last_tracked_i_found = True - if not include_unvisited: - break - labels = fallback_frame_labels[frame_i] - - if target_id in labels: - has_affected_future_ids = True - - return FutureIdPropagationScan( - last_tracked_i=last_tracked_i, - has_affected_future_ids=has_affected_future_ids, - ) - - -def track_labels( - labels: np.ndarray, - tracker_name: str = 'CellACDC', - *, - init_kwargs: dict | None = None, - track_params: dict | None = None, - intensity_img=None, - logger_func=print, -) -> np.ndarray: - """Track a label video with a Cell-ACDC tracker plugin.""" - from cellacdc.plugins.registry import import_tracker_module - - init_kwargs = {} if init_kwargs is None else init_kwargs - track_params = {} if track_params is None else track_params - tracker_module = import_tracker_module(tracker_name) - tracker = tracker_module.tracker(**init_kwargs) - args_to_try = (tuple(), (intensity_img,)) if intensity_img is not None else (tuple(),) - - for args, kwarg_to_remove in product(args_to_try, ('', 'signals')): - kwargs = track_params.copy() - kwargs.pop(kwarg_to_remove, None) - try: - return tracker.track(labels, *args, **kwargs) - except Exception as err: - is_unexpected_kwarg = ( - "got an unexpected keyword argument 'signals'" in str(err) - ) - is_missing_arg = 'missing 1 required positional argument:' in str(err) - if is_unexpected_kwarg or is_missing_arg: - continue - raise - - raise RuntimeError(f'Unable to run {tracker_name} tracker') - - -def compute_lost_new_ids( - previous_ids, - current_ids, - *, - current_deleted_roi_ids=(), - previous_deleted_roi_ids=(), - tracked_lost_ids=(), -) -> LostNewIdsResult: - """Compute ordered lost/new ID lists between adjacent frames.""" - current_id_set = {int(label_id) for label_id in current_ids} - previous_id_set = {int(label_id) for label_id in previous_ids} - current_deleted_roi_ids = { - int(label_id) for label_id in current_deleted_roi_ids - } - previous_deleted_roi_ids = { - int(label_id) for label_id in previous_deleted_roi_ids - } - tracked_lost_ids = {int(label_id) for label_id in tracked_lost_ids} - - lost_ids = [ - int(label_id) for label_id in previous_ids - if ( - int(label_id) not in current_id_set - and int(label_id) not in previous_deleted_roi_ids - and int(label_id) not in tracked_lost_ids - ) - ] - new_ids = [ - int(label_id) for label_id in current_ids - if ( - int(label_id) not in previous_id_set - and int(label_id) not in current_deleted_roi_ids - ) - ] - - return LostNewIdsResult( - lost_ids=lost_ids, - new_ids=new_ids, - ids_with_holes=[], - ) - - -def tracked_lost_centroids_from_regionprops( - regionprops, - tracked_lost_ids, -) -> set[tuple[int, ...]]: - """Collect integer centroids for tracker-accepted lost IDs.""" - tracked_lost_ids = {int(label_id) for label_id in tracked_lost_ids} - return { - tuple(int(val) for val in obj.centroid) - for obj in regionprops - if int(obj.label) in tracked_lost_ids - } - - -def tracked_lost_ids_from_centroids( - prev_labels: np.ndarray, - tracked_lost_centroids, - ids_in_frame, -) -> TrackedLostIdsResult: - """Resolve tracked-lost centroids to IDs and prune re-tracked centroids.""" - tracked_lost_centroids = { - tuple(int(coord) for coord in centroid) - for centroid in tracked_lost_centroids - } - ids_in_frame = {int(label_id) for label_id in ids_in_frame} - retracked_centroids = set() - lost_ids = set() - - for centroid in tracked_lost_centroids: - if len(centroid) < 3 and prev_labels.ndim == 3: - # Ignore wrongly stored centroids, preserving the original record. - continue - - label_id = int(prev_labels[centroid]) - if label_id == 0: - continue - - if label_id in ids_in_frame: - retracked_centroids.add(centroid) - continue - - lost_ids.add(label_id) - - return TrackedLostIdsResult( - lost_ids=lost_ids, - remaining_centroids=tracked_lost_centroids - retracked_centroids, - ) diff --git a/cellacdc/domain/types.py b/cellacdc/domain/types.py deleted file mode 100644 index 6e34ad25f..000000000 --- a/cellacdc/domain/types.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Shared type aliases for the domain model.""" - -from typing import NewType - -FrameIndex = NewType('FrameIndex', int) -CellID = NewType('CellID', int) -ChannelName = NewType('ChannelName', str) -PixelSize = NewType('PixelSize', float) diff --git a/cellacdc/domain/visited_frames.py b/cellacdc/domain/visited_frames.py deleted file mode 100644 index b622ffa66..000000000 --- a/cellacdc/domain/visited_frames.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Visited-frame state transitions shared by GUI and scripts.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class LastVisitedFrameUpdate: - """Updated last-visited counters for workflow modes.""" - - last_tracked_i: int - last_cca_frame_i: int - changed: bool = False - - -def update_last_visited_frame_state( - mode: str, - last_visited_frame_i: int, - *, - last_tracked_i: int, - last_cca_frame_i: int, -) -> LastVisitedFrameUpdate: - """Return updated last-visited counters for a workflow mode.""" - mode = str(mode) - last_visited_frame_i = int(last_visited_frame_i) - last_tracked_i = int(last_tracked_i) - last_cca_frame_i = int(last_cca_frame_i) - - if mode == 'Segmentation and Tracking': - if last_tracked_i >= last_visited_frame_i: - return LastVisitedFrameUpdate(last_tracked_i, last_cca_frame_i) - return LastVisitedFrameUpdate( - last_visited_frame_i, - last_cca_frame_i, - changed=True, - ) - - if mode == 'Cell cycle analysis': - if last_cca_frame_i >= last_visited_frame_i: - return LastVisitedFrameUpdate(last_tracked_i, last_cca_frame_i) - return LastVisitedFrameUpdate( - last_tracked_i, - last_visited_frame_i, - changed=True, - ) - - return LastVisitedFrameUpdate(last_tracked_i, last_cca_frame_i) From 552c2e7ead882ef8273e8d150ad551565638b50e Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 10:10:30 +0200 Subject: [PATCH 06/21] refactor: rename mixins to strip _view and _viewmodel suffixes --- cellacdc/mixins/__init__.py | 0 .../actions_view.py => mixins/actions.py} | 0 .../annotation_display.py} | 0 .../app_shell_view.py => mixins/app_shell.py} | 0 .../brush_tools.py} | 0 .../canvas_context_menu.py} | 0 .../canvas_drawing.py} | 0 .../canvas_events.py} | 0 .../canvas_hover.py} | 0 .../canvas_right_image.py} | 0 .../canvas_selection.py} | 0 .../canvas_tool.py} | 0 .../cca_edits.py} | 0 .../cca_workflows.py} | 0 .../cell_cycle.py} | 0 .../combine_view.py => mixins/combine.py} | 0 .../curvature_tools.py} | 0 .../custom_annotations.py} | 0 .../data_loading.py} | 0 .../deleted_rois.py} | 0 .../display_decorations.py} | 0 .../draw_clear_region.py} | 0 .../edit_id.py} | 0 .../exporting_view.py => mixins/exporting.py} | 0 .../formatting.py} | 0 .../frame_metadata.py} | 0 .../frame_navigation.py} | 0 .../geometry.py} | 0 .../graphics_view.py => mixins/graphics.py} | 0 .../image_controls.py} | 0 .../image_display.py} | 0 .../label_editing.py} | 0 .../label_edits.py} | 0 .../label_roi_view.py => mixins/label_roi.py} | 0 .../label_transform_tools.py} | 0 .../layout_controls.py} | 0 .../lineage.py} | 0 .../lineage_interactions.py} | 0 .../magic_prompts.py} | 0 .../main_viewmodel.py => mixins/main.py} | 0 .../main_menu_view.py => mixins/main_menu.py} | 0 .../main_toolbar.py} | 0 .../measurements.py} | 0 .../mode_controls.py} | 0 .../model_registry.py} | 0 .../object_cleanup.py} | 0 .../object_counts.py} | 0 .../object_properties.py} | 0 .../object_search.py} | 0 .../points_viewmodel.py => mixins/points.py} | 0 .../points_layers.py} | 0 .../preprocessing.py} | 0 .../quick_settings.py} | 0 .../saving_view.py => mixins/saving.py} | 0 .../seg_for_lost_ids.py} | 0 .../segmentation.py} | 0 .../session_view.py => mixins/session.py} | 0 .../status_hover.py} | 0 .../tables_viewmodel.py => mixins/tables.py} | 0 .../tool_activation.py} | 0 .../tracking_view.py => mixins/tracking.py} | 0 .../undo_redo_view.py => mixins/undo_redo.py} | 0 .../whitelist_view.py => mixins/whitelist.py} | 0 .../window_events.py} | 0 .../worker_view.py => mixins/worker.py} | 0 .../workspace.py} | 0 cellacdc/viewmodels/__init__.py | 85 ------------------- cellacdc/views/__init__.py | 1 - 68 files changed, 86 deletions(-) create mode 100644 cellacdc/mixins/__init__.py rename cellacdc/{views/actions_view.py => mixins/actions.py} (100%) rename cellacdc/{views/annotation_display_view.py => mixins/annotation_display.py} (100%) rename cellacdc/{views/app_shell_view.py => mixins/app_shell.py} (100%) rename cellacdc/{views/brush_tools_view.py => mixins/brush_tools.py} (100%) rename cellacdc/{views/canvas_context_menu_view.py => mixins/canvas_context_menu.py} (100%) rename cellacdc/{views/canvas_drawing_view.py => mixins/canvas_drawing.py} (100%) rename cellacdc/{views/canvas_events_view.py => mixins/canvas_events.py} (100%) rename cellacdc/{views/canvas_hover_view.py => mixins/canvas_hover.py} (100%) rename cellacdc/{views/canvas_right_image_view.py => mixins/canvas_right_image.py} (100%) rename cellacdc/{views/canvas_selection_view.py => mixins/canvas_selection.py} (100%) rename cellacdc/{views/canvas_tool_view.py => mixins/canvas_tool.py} (100%) rename cellacdc/{viewmodels/cca_edits_viewmodel.py => mixins/cca_edits.py} (100%) rename cellacdc/{viewmodels/cca_workflows_viewmodel.py => mixins/cca_workflows.py} (100%) rename cellacdc/{views/cell_cycle_view.py => mixins/cell_cycle.py} (100%) rename cellacdc/{views/combine_view.py => mixins/combine.py} (100%) rename cellacdc/{views/curvature_tools_view.py => mixins/curvature_tools.py} (100%) rename cellacdc/{views/custom_annotations_view.py => mixins/custom_annotations.py} (100%) rename cellacdc/{views/data_loading_view.py => mixins/data_loading.py} (100%) rename cellacdc/{views/deleted_rois_view.py => mixins/deleted_rois.py} (100%) rename cellacdc/{views/display_decorations_view.py => mixins/display_decorations.py} (100%) rename cellacdc/{views/draw_clear_region_view.py => mixins/draw_clear_region.py} (100%) rename cellacdc/{viewmodels/edit_id_viewmodel.py => mixins/edit_id.py} (100%) rename cellacdc/{views/exporting_view.py => mixins/exporting.py} (100%) rename cellacdc/{viewmodels/formatting_viewmodel.py => mixins/formatting.py} (100%) rename cellacdc/{viewmodels/frame_metadata_viewmodel.py => mixins/frame_metadata.py} (100%) rename cellacdc/{views/frame_navigation_view.py => mixins/frame_navigation.py} (100%) rename cellacdc/{viewmodels/geometry_viewmodel.py => mixins/geometry.py} (100%) rename cellacdc/{views/graphics_view.py => mixins/graphics.py} (100%) rename cellacdc/{views/image_controls_view.py => mixins/image_controls.py} (100%) rename cellacdc/{views/image_display_view.py => mixins/image_display.py} (100%) rename cellacdc/{views/label_editing_view.py => mixins/label_editing.py} (100%) rename cellacdc/{viewmodels/label_edits_viewmodel.py => mixins/label_edits.py} (100%) rename cellacdc/{views/label_roi_view.py => mixins/label_roi.py} (100%) rename cellacdc/{views/label_transform_tools_view.py => mixins/label_transform_tools.py} (100%) rename cellacdc/{views/layout_controls_view.py => mixins/layout_controls.py} (100%) rename cellacdc/{viewmodels/lineage_viewmodel.py => mixins/lineage.py} (100%) rename cellacdc/{views/lineage_interactions_view.py => mixins/lineage_interactions.py} (100%) rename cellacdc/{views/magic_prompts_view.py => mixins/magic_prompts.py} (100%) rename cellacdc/{viewmodels/main_viewmodel.py => mixins/main.py} (100%) rename cellacdc/{views/main_menu_view.py => mixins/main_menu.py} (100%) rename cellacdc/{views/main_toolbar_view.py => mixins/main_toolbar.py} (100%) rename cellacdc/{views/measurements_view.py => mixins/measurements.py} (100%) rename cellacdc/{views/mode_controls_view.py => mixins/mode_controls.py} (100%) rename cellacdc/{viewmodels/model_registry_viewmodel.py => mixins/model_registry.py} (100%) rename cellacdc/{views/object_cleanup_view.py => mixins/object_cleanup.py} (100%) rename cellacdc/{viewmodels/object_counts_viewmodel.py => mixins/object_counts.py} (100%) rename cellacdc/{views/object_properties_view.py => mixins/object_properties.py} (100%) rename cellacdc/{views/object_search_view.py => mixins/object_search.py} (100%) rename cellacdc/{viewmodels/points_viewmodel.py => mixins/points.py} (100%) rename cellacdc/{views/points_layers_view.py => mixins/points_layers.py} (100%) rename cellacdc/{views/preprocessing_view.py => mixins/preprocessing.py} (100%) rename cellacdc/{views/quick_settings_view.py => mixins/quick_settings.py} (100%) rename cellacdc/{views/saving_view.py => mixins/saving.py} (100%) rename cellacdc/{views/seg_for_lost_ids_view.py => mixins/seg_for_lost_ids.py} (100%) rename cellacdc/{views/segmentation_view.py => mixins/segmentation.py} (100%) rename cellacdc/{views/session_view.py => mixins/session.py} (100%) rename cellacdc/{views/status_hover_view.py => mixins/status_hover.py} (100%) rename cellacdc/{viewmodels/tables_viewmodel.py => mixins/tables.py} (100%) rename cellacdc/{views/tool_activation_view.py => mixins/tool_activation.py} (100%) rename cellacdc/{views/tracking_view.py => mixins/tracking.py} (100%) rename cellacdc/{views/undo_redo_view.py => mixins/undo_redo.py} (100%) rename cellacdc/{views/whitelist_view.py => mixins/whitelist.py} (100%) rename cellacdc/{views/window_events_view.py => mixins/window_events.py} (100%) rename cellacdc/{views/worker_view.py => mixins/worker.py} (100%) rename cellacdc/{viewmodels/workspace_viewmodel.py => mixins/workspace.py} (100%) delete mode 100644 cellacdc/viewmodels/__init__.py delete mode 100644 cellacdc/views/__init__.py diff --git a/cellacdc/mixins/__init__.py b/cellacdc/mixins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cellacdc/views/actions_view.py b/cellacdc/mixins/actions.py similarity index 100% rename from cellacdc/views/actions_view.py rename to cellacdc/mixins/actions.py diff --git a/cellacdc/views/annotation_display_view.py b/cellacdc/mixins/annotation_display.py similarity index 100% rename from cellacdc/views/annotation_display_view.py rename to cellacdc/mixins/annotation_display.py diff --git a/cellacdc/views/app_shell_view.py b/cellacdc/mixins/app_shell.py similarity index 100% rename from cellacdc/views/app_shell_view.py rename to cellacdc/mixins/app_shell.py diff --git a/cellacdc/views/brush_tools_view.py b/cellacdc/mixins/brush_tools.py similarity index 100% rename from cellacdc/views/brush_tools_view.py rename to cellacdc/mixins/brush_tools.py diff --git a/cellacdc/views/canvas_context_menu_view.py b/cellacdc/mixins/canvas_context_menu.py similarity index 100% rename from cellacdc/views/canvas_context_menu_view.py rename to cellacdc/mixins/canvas_context_menu.py diff --git a/cellacdc/views/canvas_drawing_view.py b/cellacdc/mixins/canvas_drawing.py similarity index 100% rename from cellacdc/views/canvas_drawing_view.py rename to cellacdc/mixins/canvas_drawing.py diff --git a/cellacdc/views/canvas_events_view.py b/cellacdc/mixins/canvas_events.py similarity index 100% rename from cellacdc/views/canvas_events_view.py rename to cellacdc/mixins/canvas_events.py diff --git a/cellacdc/views/canvas_hover_view.py b/cellacdc/mixins/canvas_hover.py similarity index 100% rename from cellacdc/views/canvas_hover_view.py rename to cellacdc/mixins/canvas_hover.py diff --git a/cellacdc/views/canvas_right_image_view.py b/cellacdc/mixins/canvas_right_image.py similarity index 100% rename from cellacdc/views/canvas_right_image_view.py rename to cellacdc/mixins/canvas_right_image.py diff --git a/cellacdc/views/canvas_selection_view.py b/cellacdc/mixins/canvas_selection.py similarity index 100% rename from cellacdc/views/canvas_selection_view.py rename to cellacdc/mixins/canvas_selection.py diff --git a/cellacdc/views/canvas_tool_view.py b/cellacdc/mixins/canvas_tool.py similarity index 100% rename from cellacdc/views/canvas_tool_view.py rename to cellacdc/mixins/canvas_tool.py diff --git a/cellacdc/viewmodels/cca_edits_viewmodel.py b/cellacdc/mixins/cca_edits.py similarity index 100% rename from cellacdc/viewmodels/cca_edits_viewmodel.py rename to cellacdc/mixins/cca_edits.py diff --git a/cellacdc/viewmodels/cca_workflows_viewmodel.py b/cellacdc/mixins/cca_workflows.py similarity index 100% rename from cellacdc/viewmodels/cca_workflows_viewmodel.py rename to cellacdc/mixins/cca_workflows.py diff --git a/cellacdc/views/cell_cycle_view.py b/cellacdc/mixins/cell_cycle.py similarity index 100% rename from cellacdc/views/cell_cycle_view.py rename to cellacdc/mixins/cell_cycle.py diff --git a/cellacdc/views/combine_view.py b/cellacdc/mixins/combine.py similarity index 100% rename from cellacdc/views/combine_view.py rename to cellacdc/mixins/combine.py diff --git a/cellacdc/views/curvature_tools_view.py b/cellacdc/mixins/curvature_tools.py similarity index 100% rename from cellacdc/views/curvature_tools_view.py rename to cellacdc/mixins/curvature_tools.py diff --git a/cellacdc/views/custom_annotations_view.py b/cellacdc/mixins/custom_annotations.py similarity index 100% rename from cellacdc/views/custom_annotations_view.py rename to cellacdc/mixins/custom_annotations.py diff --git a/cellacdc/views/data_loading_view.py b/cellacdc/mixins/data_loading.py similarity index 100% rename from cellacdc/views/data_loading_view.py rename to cellacdc/mixins/data_loading.py diff --git a/cellacdc/views/deleted_rois_view.py b/cellacdc/mixins/deleted_rois.py similarity index 100% rename from cellacdc/views/deleted_rois_view.py rename to cellacdc/mixins/deleted_rois.py diff --git a/cellacdc/views/display_decorations_view.py b/cellacdc/mixins/display_decorations.py similarity index 100% rename from cellacdc/views/display_decorations_view.py rename to cellacdc/mixins/display_decorations.py diff --git a/cellacdc/views/draw_clear_region_view.py b/cellacdc/mixins/draw_clear_region.py similarity index 100% rename from cellacdc/views/draw_clear_region_view.py rename to cellacdc/mixins/draw_clear_region.py diff --git a/cellacdc/viewmodels/edit_id_viewmodel.py b/cellacdc/mixins/edit_id.py similarity index 100% rename from cellacdc/viewmodels/edit_id_viewmodel.py rename to cellacdc/mixins/edit_id.py diff --git a/cellacdc/views/exporting_view.py b/cellacdc/mixins/exporting.py similarity index 100% rename from cellacdc/views/exporting_view.py rename to cellacdc/mixins/exporting.py diff --git a/cellacdc/viewmodels/formatting_viewmodel.py b/cellacdc/mixins/formatting.py similarity index 100% rename from cellacdc/viewmodels/formatting_viewmodel.py rename to cellacdc/mixins/formatting.py diff --git a/cellacdc/viewmodels/frame_metadata_viewmodel.py b/cellacdc/mixins/frame_metadata.py similarity index 100% rename from cellacdc/viewmodels/frame_metadata_viewmodel.py rename to cellacdc/mixins/frame_metadata.py diff --git a/cellacdc/views/frame_navigation_view.py b/cellacdc/mixins/frame_navigation.py similarity index 100% rename from cellacdc/views/frame_navigation_view.py rename to cellacdc/mixins/frame_navigation.py diff --git a/cellacdc/viewmodels/geometry_viewmodel.py b/cellacdc/mixins/geometry.py similarity index 100% rename from cellacdc/viewmodels/geometry_viewmodel.py rename to cellacdc/mixins/geometry.py diff --git a/cellacdc/views/graphics_view.py b/cellacdc/mixins/graphics.py similarity index 100% rename from cellacdc/views/graphics_view.py rename to cellacdc/mixins/graphics.py diff --git a/cellacdc/views/image_controls_view.py b/cellacdc/mixins/image_controls.py similarity index 100% rename from cellacdc/views/image_controls_view.py rename to cellacdc/mixins/image_controls.py diff --git a/cellacdc/views/image_display_view.py b/cellacdc/mixins/image_display.py similarity index 100% rename from cellacdc/views/image_display_view.py rename to cellacdc/mixins/image_display.py diff --git a/cellacdc/views/label_editing_view.py b/cellacdc/mixins/label_editing.py similarity index 100% rename from cellacdc/views/label_editing_view.py rename to cellacdc/mixins/label_editing.py diff --git a/cellacdc/viewmodels/label_edits_viewmodel.py b/cellacdc/mixins/label_edits.py similarity index 100% rename from cellacdc/viewmodels/label_edits_viewmodel.py rename to cellacdc/mixins/label_edits.py diff --git a/cellacdc/views/label_roi_view.py b/cellacdc/mixins/label_roi.py similarity index 100% rename from cellacdc/views/label_roi_view.py rename to cellacdc/mixins/label_roi.py diff --git a/cellacdc/views/label_transform_tools_view.py b/cellacdc/mixins/label_transform_tools.py similarity index 100% rename from cellacdc/views/label_transform_tools_view.py rename to cellacdc/mixins/label_transform_tools.py diff --git a/cellacdc/views/layout_controls_view.py b/cellacdc/mixins/layout_controls.py similarity index 100% rename from cellacdc/views/layout_controls_view.py rename to cellacdc/mixins/layout_controls.py diff --git a/cellacdc/viewmodels/lineage_viewmodel.py b/cellacdc/mixins/lineage.py similarity index 100% rename from cellacdc/viewmodels/lineage_viewmodel.py rename to cellacdc/mixins/lineage.py diff --git a/cellacdc/views/lineage_interactions_view.py b/cellacdc/mixins/lineage_interactions.py similarity index 100% rename from cellacdc/views/lineage_interactions_view.py rename to cellacdc/mixins/lineage_interactions.py diff --git a/cellacdc/views/magic_prompts_view.py b/cellacdc/mixins/magic_prompts.py similarity index 100% rename from cellacdc/views/magic_prompts_view.py rename to cellacdc/mixins/magic_prompts.py diff --git a/cellacdc/viewmodels/main_viewmodel.py b/cellacdc/mixins/main.py similarity index 100% rename from cellacdc/viewmodels/main_viewmodel.py rename to cellacdc/mixins/main.py diff --git a/cellacdc/views/main_menu_view.py b/cellacdc/mixins/main_menu.py similarity index 100% rename from cellacdc/views/main_menu_view.py rename to cellacdc/mixins/main_menu.py diff --git a/cellacdc/views/main_toolbar_view.py b/cellacdc/mixins/main_toolbar.py similarity index 100% rename from cellacdc/views/main_toolbar_view.py rename to cellacdc/mixins/main_toolbar.py diff --git a/cellacdc/views/measurements_view.py b/cellacdc/mixins/measurements.py similarity index 100% rename from cellacdc/views/measurements_view.py rename to cellacdc/mixins/measurements.py diff --git a/cellacdc/views/mode_controls_view.py b/cellacdc/mixins/mode_controls.py similarity index 100% rename from cellacdc/views/mode_controls_view.py rename to cellacdc/mixins/mode_controls.py diff --git a/cellacdc/viewmodels/model_registry_viewmodel.py b/cellacdc/mixins/model_registry.py similarity index 100% rename from cellacdc/viewmodels/model_registry_viewmodel.py rename to cellacdc/mixins/model_registry.py diff --git a/cellacdc/views/object_cleanup_view.py b/cellacdc/mixins/object_cleanup.py similarity index 100% rename from cellacdc/views/object_cleanup_view.py rename to cellacdc/mixins/object_cleanup.py diff --git a/cellacdc/viewmodels/object_counts_viewmodel.py b/cellacdc/mixins/object_counts.py similarity index 100% rename from cellacdc/viewmodels/object_counts_viewmodel.py rename to cellacdc/mixins/object_counts.py diff --git a/cellacdc/views/object_properties_view.py b/cellacdc/mixins/object_properties.py similarity index 100% rename from cellacdc/views/object_properties_view.py rename to cellacdc/mixins/object_properties.py diff --git a/cellacdc/views/object_search_view.py b/cellacdc/mixins/object_search.py similarity index 100% rename from cellacdc/views/object_search_view.py rename to cellacdc/mixins/object_search.py diff --git a/cellacdc/viewmodels/points_viewmodel.py b/cellacdc/mixins/points.py similarity index 100% rename from cellacdc/viewmodels/points_viewmodel.py rename to cellacdc/mixins/points.py diff --git a/cellacdc/views/points_layers_view.py b/cellacdc/mixins/points_layers.py similarity index 100% rename from cellacdc/views/points_layers_view.py rename to cellacdc/mixins/points_layers.py diff --git a/cellacdc/views/preprocessing_view.py b/cellacdc/mixins/preprocessing.py similarity index 100% rename from cellacdc/views/preprocessing_view.py rename to cellacdc/mixins/preprocessing.py diff --git a/cellacdc/views/quick_settings_view.py b/cellacdc/mixins/quick_settings.py similarity index 100% rename from cellacdc/views/quick_settings_view.py rename to cellacdc/mixins/quick_settings.py diff --git a/cellacdc/views/saving_view.py b/cellacdc/mixins/saving.py similarity index 100% rename from cellacdc/views/saving_view.py rename to cellacdc/mixins/saving.py diff --git a/cellacdc/views/seg_for_lost_ids_view.py b/cellacdc/mixins/seg_for_lost_ids.py similarity index 100% rename from cellacdc/views/seg_for_lost_ids_view.py rename to cellacdc/mixins/seg_for_lost_ids.py diff --git a/cellacdc/views/segmentation_view.py b/cellacdc/mixins/segmentation.py similarity index 100% rename from cellacdc/views/segmentation_view.py rename to cellacdc/mixins/segmentation.py diff --git a/cellacdc/views/session_view.py b/cellacdc/mixins/session.py similarity index 100% rename from cellacdc/views/session_view.py rename to cellacdc/mixins/session.py diff --git a/cellacdc/views/status_hover_view.py b/cellacdc/mixins/status_hover.py similarity index 100% rename from cellacdc/views/status_hover_view.py rename to cellacdc/mixins/status_hover.py diff --git a/cellacdc/viewmodels/tables_viewmodel.py b/cellacdc/mixins/tables.py similarity index 100% rename from cellacdc/viewmodels/tables_viewmodel.py rename to cellacdc/mixins/tables.py diff --git a/cellacdc/views/tool_activation_view.py b/cellacdc/mixins/tool_activation.py similarity index 100% rename from cellacdc/views/tool_activation_view.py rename to cellacdc/mixins/tool_activation.py diff --git a/cellacdc/views/tracking_view.py b/cellacdc/mixins/tracking.py similarity index 100% rename from cellacdc/views/tracking_view.py rename to cellacdc/mixins/tracking.py diff --git a/cellacdc/views/undo_redo_view.py b/cellacdc/mixins/undo_redo.py similarity index 100% rename from cellacdc/views/undo_redo_view.py rename to cellacdc/mixins/undo_redo.py diff --git a/cellacdc/views/whitelist_view.py b/cellacdc/mixins/whitelist.py similarity index 100% rename from cellacdc/views/whitelist_view.py rename to cellacdc/mixins/whitelist.py diff --git a/cellacdc/views/window_events_view.py b/cellacdc/mixins/window_events.py similarity index 100% rename from cellacdc/views/window_events_view.py rename to cellacdc/mixins/window_events.py diff --git a/cellacdc/views/worker_view.py b/cellacdc/mixins/worker.py similarity index 100% rename from cellacdc/views/worker_view.py rename to cellacdc/mixins/worker.py diff --git a/cellacdc/viewmodels/workspace_viewmodel.py b/cellacdc/mixins/workspace.py similarity index 100% rename from cellacdc/viewmodels/workspace_viewmodel.py rename to cellacdc/mixins/workspace.py diff --git a/cellacdc/viewmodels/__init__.py b/cellacdc/viewmodels/__init__.py deleted file mode 100644 index 30c017aaf..000000000 --- a/cellacdc/viewmodels/__init__.py +++ /dev/null @@ -1,85 +0,0 @@ -"""GUI view models.""" - -from cellacdc.domain.custom_annotations import ( - CustomAnnotationColumnResult, - CustomAnnotationFrameUpdate, -) -from cellacdc.domain.curvature import CurvatureLabelPaintResult -from cellacdc.domain.edit_id import ManualEditTrackingResult -from cellacdc.domain.frame_metadata import AcdcFrameMetadataResult -from cellacdc.domain.labels import ( - DeletedRoiApplyResult, - DeletedRoiRestoreResult, - LabelBorderClearResult, - LabelHoleFillResult, - LabelIdMappingResult, - LabelIdsRemovalResult, - LabelMoveResult, - LabelRegionSelectionResult, - LabelResizeResult, - LabelRoiIndexResult, -) -from cellacdc.domain.lineage import ( - LineageAnnotationsRemovalResult, - LineageFutureRemovalResult, -) -from cellacdc.domain.tracking import ( - FutureIdPropagationScan, - LostNewIdsResult, - TrackedLostIdsResult, -) -from cellacdc.domain.visited_frames import LastVisitedFrameUpdate - -from .cca_edits_viewmodel import CcaEditViewModel, CcaFrameEditResult -from .cca_workflows_viewmodel import CcaWorkflowViewModel -from .edit_id_viewmodel import EditIdViewModel -from .frame_metadata_viewmodel import FrameMetadataViewModel -from .formatting_viewmodel import FormattingViewModel -from .geometry_viewmodel import GeometryViewModel -from .label_edits_viewmodel import LabelEditViewModel -from .lineage_viewmodel import LineageViewModel -from .main_viewmodel import MainGuiViewModel -from .model_registry_viewmodel import ModelRegistryViewModel -from .object_counts_viewmodel import ObjectCountViewModel -from .points_viewmodel import PointsViewModel -from .tables_viewmodel import TableViewModel -from .workspace_viewmodel import WorkspaceViewModel - -__all__ = [ - 'AcdcFrameMetadataResult', - 'CcaEditViewModel', - 'CcaFrameEditResult', - 'CcaWorkflowViewModel', - 'CurvatureLabelPaintResult', - 'CustomAnnotationColumnResult', - 'CustomAnnotationFrameUpdate', - 'DeletedRoiApplyResult', - 'DeletedRoiRestoreResult', - 'EditIdViewModel', - 'FrameMetadataViewModel', - 'FormattingViewModel', - 'GeometryViewModel', - 'FutureIdPropagationScan', - 'LabelBorderClearResult', - 'LabelHoleFillResult', - 'LabelEditViewModel', - 'LabelIdMappingResult', - 'LabelIdsRemovalResult', - 'LabelMoveResult', - 'LabelRegionSelectionResult', - 'LabelResizeResult', - 'LabelRoiIndexResult', - 'LastVisitedFrameUpdate', - 'LineageAnnotationsRemovalResult', - 'LineageFutureRemovalResult', - 'LineageViewModel', - 'LostNewIdsResult', - 'MainGuiViewModel', - 'ManualEditTrackingResult', - 'ModelRegistryViewModel', - 'ObjectCountViewModel', - 'PointsViewModel', - 'TableViewModel', - 'TrackedLostIdsResult', - 'WorkspaceViewModel', -] diff --git a/cellacdc/views/__init__.py b/cellacdc/views/__init__.py deleted file mode 100644 index 8b1378917..000000000 --- a/cellacdc/views/__init__.py +++ /dev/null @@ -1 +0,0 @@ - From d60abca44529349750447014a3ce8aa803bbc0ca Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 10:43:13 +0200 Subject: [PATCH 07/21] refactor: modularize mixins as clean class mixins, fix compilation errors, and format with ruff --- cellacdc/mixins/__init__.py | 69 + cellacdc/mixins/actions.py | 1570 ++++--- cellacdc/mixins/annotation_display.py | 2457 ++++++----- cellacdc/mixins/app_shell.py | 320 +- cellacdc/mixins/brush_tools.py | 963 ++--- cellacdc/mixins/canvas_context_menu.py | 186 +- cellacdc/mixins/canvas_drawing.py | 355 +- cellacdc/mixins/canvas_events.py | 627 +-- cellacdc/mixins/canvas_hover.py | 924 +++-- cellacdc/mixins/canvas_right_image.py | 43 +- cellacdc/mixins/canvas_selection.py | 540 ++- cellacdc/mixins/canvas_tool.py | 34 +- cellacdc/mixins/cca_edits.py | 148 +- cellacdc/mixins/cca_workflows.py | 110 +- cellacdc/mixins/cell_cycle.py | 4207 ++++++++++--------- cellacdc/mixins/combine.py | 1036 +++-- cellacdc/mixins/curvature_tools.py | 495 ++- cellacdc/mixins/custom_annotations.py | 936 +++-- cellacdc/mixins/data_loading.py | 2515 ++++++----- cellacdc/mixins/deleted_rois.py | 931 ++--- cellacdc/mixins/display_decorations.py | 297 +- cellacdc/mixins/draw_clear_region.py | 172 +- cellacdc/mixins/edit_id.py | 50 +- cellacdc/mixins/exporting.py | 684 +-- cellacdc/mixins/formatting.py | 24 +- cellacdc/mixins/frame_metadata.py | 8 +- cellacdc/mixins/frame_navigation.py | 1965 +++++---- cellacdc/mixins/geometry.py | 134 +- cellacdc/mixins/graphics.py | 4829 +++++++++++----------- cellacdc/mixins/image_controls.py | 422 +- cellacdc/mixins/image_display.py | 2222 +++++----- cellacdc/mixins/label_editing.py | 1116 +++-- cellacdc/mixins/label_edits.py | 296 +- cellacdc/mixins/label_roi.py | 855 ++-- cellacdc/mixins/label_transform_tools.py | 316 +- cellacdc/mixins/layout_controls.py | 762 ++-- cellacdc/mixins/lineage.py | 22 +- cellacdc/mixins/lineage_interactions.py | 1163 +++--- cellacdc/mixins/magic_prompts.py | 604 +-- cellacdc/mixins/main.py | 92 +- cellacdc/mixins/main_menu.py | 321 +- cellacdc/mixins/main_toolbar.py | 407 +- cellacdc/mixins/measurements.py | 299 +- cellacdc/mixins/mode_controls.py | 414 +- cellacdc/mixins/model_registry.py | 94 +- cellacdc/mixins/object_cleanup.py | 124 +- cellacdc/mixins/object_counts.py | 8 +- cellacdc/mixins/object_properties.py | 1615 ++++---- cellacdc/mixins/object_search.py | 436 +- cellacdc/mixins/points.py | 102 +- cellacdc/mixins/points_layers.py | 2060 +++++---- cellacdc/mixins/preprocessing.py | 621 ++- cellacdc/mixins/quick_settings.py | 338 +- cellacdc/mixins/saving.py | 1759 ++++---- cellacdc/mixins/seg_for_lost_ids.py | 502 +-- cellacdc/mixins/segmentation.py | 820 ++-- cellacdc/mixins/session.py | 929 +++-- cellacdc/mixins/status_hover.py | 364 +- cellacdc/mixins/tables.py | 2 +- cellacdc/mixins/tool_activation.py | 1254 +++--- cellacdc/mixins/tracking.py | 2358 ++++++----- cellacdc/mixins/undo_redo.py | 560 ++- cellacdc/mixins/whitelist.py | 1650 +++++--- cellacdc/mixins/window_events.py | 1324 +++--- cellacdc/mixins/worker.py | 640 ++- cellacdc/mixins/workspace.py | 42 +- 66 files changed, 26619 insertions(+), 26923 deletions(-) diff --git a/cellacdc/mixins/__init__.py b/cellacdc/mixins/__init__.py index e69de29bb..05c736b8f 100644 --- a/cellacdc/mixins/__init__.py +++ b/cellacdc/mixins/__init__.py @@ -0,0 +1,69 @@ +"""Mixins for gui.py.""" + +from __future__ import annotations + +from .actions import ActionsMixin +from .annotation_display import AnnotationDisplayMixin +from .app_shell import AppShellMixin +from .brush_tools import BrushToolsMixin +from .canvas_context_menu import CanvasContextMenuMixin +from .canvas_drawing import CanvasDrawingMixin +from .canvas_events import CanvasEventsMixin +from .canvas_hover import CanvasHoverMixin +from .canvas_right_image import CanvasRightImageMixin +from .canvas_selection import CanvasSelectionMixin +from .canvas_tool import CanvasToolMixin +from .cca_edits import CcaFrameEditResultMixin +from .cca_workflows import CcaWorkflowMixin +from .cell_cycle import CellCycleMixin +from .combine import CombineMixin +from .curvature_tools import CurvatureToolsMixin +from .custom_annotations import CustomAnnotationsMixin +from .data_loading import DataLoadingMixin +from .deleted_rois import DeletedRoisMixin +from .display_decorations import DisplayDecorationsMixin +from .draw_clear_region import DrawClearRegionMixin +from .edit_id import EditIdMixin +from .exporting import ExportingMixin +from .formatting import FormattingMixin +from .frame_metadata import FrameMetadataMixin +from .frame_navigation import FrameNavigationMixin +from .geometry import GeometryMixin +from .graphics import GraphicsMixin +from .image_controls import ImageControlsMixin +from .image_display import ImageDisplayMixin +from .label_edits import LabelEditMixin +from .label_editing import LabelEditingMixin +from .label_roi import LabelRoiMixin +from .label_transform_tools import LabelTransformToolsMixin +from .layout_controls import LayoutControlsMixin +from .lineage_interactions import LineageInteractionsMixin +from .lineage import LineageMixin +from .magic_prompts import MagicPromptsMixin +from .main import MainGuiMixin +from .main_menu import MainMenuMixin +from .main_toolbar import MainToolbarMixin +from .measurements import MeasurementsMixin +from .mode_controls import ModeControlsMixin +from .model_registry import ModelRegistryMixin +from .object_cleanup import ObjectCleanupMixin +from .object_counts import ObjectCountMixin +from .object_properties import ObjectPropertiesMixin +from .object_search import ObjectSearchMixin +from .points_layers import PointsLayersMixin +from .points import PointsMixin +from .preprocessing import PreprocessingMixin +from .quick_settings import QuickSettingsMixin +from .saving import SavingMixin +from .seg_for_lost_ids import SegForLostIdsMixin +from .segmentation import SegmentationMixin +from .session import SessionMixin +from .status_hover import StatusHoverMixin +from .tables import TableMixin +from .tool_activation import ToolActivationMixin +from .tracking import TrackingMixin +from .undo_redo import UndoRedoMixin +from .whitelist import WhitelistMixin +from .window_events import WindowEventsMixin +from .worker import WorkerMixin +from .workspace import WorkspaceMixin diff --git a/cellacdc/mixins/actions.py b/cellacdc/mixins/actions.py index 2a12691db..d3e0be749 100644 --- a/cellacdc/mixins/actions.py +++ b/cellacdc/mixins/actions.py @@ -11,655 +11,841 @@ from cellacdc import apps, is_mac, settings_folderpath, widgets -shortcut_filepath = os.path.join(settings_folderpath, 'shortcuts.ini') +shortcut_filepath = os.path.join(settings_folderpath, "shortcuts.ini") -class ActionsView: +class ActionsMixin: """Qt-facing adapter around action construction and shortcut editing.""" """Headless decisions for action and shortcut workflows.""" - keyboard_shortcuts_section = 'keyboard.shortcuts' - delete_object_section = 'delete_object.action' - delete_key_option = 'Key sequence' - delete_button_option = 'Mouse button' + keyboard_shortcuts_section = "keyboard.shortcuts" + delete_object_section = "delete_object.action" + delete_key_option = "Key sequence" + delete_button_option = "Mouse button" def default_delete_object_texts(self, *, is_mac: bool) -> tuple[str, str]: if is_mac: - return 'Ctrl', 'Left click' - return '', 'Middle click' - - def sanitize_key_sequence_text(self, text) -> str: - if text is None: - return '' - return str(text).encode('ascii', 'ignore').decode('utf-8') - - def delete_object_button_text(self, *, is_left_click: bool) -> str: - return 'Left click' if is_left_click else 'Middle click' + return "Ctrl", "Left click" + return "", "Middle click" def delete_object_button_is_left_click(self, text: str) -> bool: - return text == 'Left click' - - def should_restore_default_delete_action(self, *, had_error: bool) -> bool: - return had_error + return text == "Left click" + def delete_object_button_text(self, *, is_left_click: bool) -> str: + return "Left click" if is_left_click else "Middle click" - LEGACY_METHODS = ( - 'gui_createActions', - 'gui_updateSwitchColorSchemeActionText', - 'gui_connectActions', - 'initShortcuts', - 'setShortcuts', - 'editShortcuts_cb', - 'gui_connectEditActions', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) + def editShortcuts_cb(self): + if is_mac: + delObjKeySequenceText = "Ctrl" + delObjButtonText = "Left click" else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + delObjKeySequenceText = "" + delObjButtonText = "Middle click" - def gui_createActions(self): - # File actions - self.segmNdimIndicator = widgets.ToolButtonTextIcon(text='') - self.segmNdimIndicator.setCheckable(True) - self.segmNdimIndicator.setChecked(True) - # self.segmNdimIndicator.setDisabled(True) + if self.delObjAction is not None: + delObjKeySequence, delObjQtButton = self.delObjAction + if delObjKeySequence is None: + delObjKeySequenceText = "" + else: + delObjKeySequenceText = delObjKeySequence.toString() + delObjKeySequenceText = delObjKeySequenceText.encode( + "ascii", "ignore" + ).decode("utf-8") + delObjButtonText = ( + "Left click" + if delObjQtButton == Qt.MouseButton.LeftButton + else "Middle click" + ) - if self.debug: - self.createEmptyDataAction = QAction(self.host) - self.createEmptyDataAction.setText("DEBUG: Create empty data") + win = apps.ShortcutEditorDialog( + self.widgetsWithShortcut, + delObjectKey=delObjKeySequenceText, + delObjectButton=delObjButtonText, + zoomOutKeyValue=self.zoomOutKeyValue, + parent=self, + ) + win.exec_() + if win.cancel: + return - self.newWindowAction = QAction("New Window", self.host) + self.delObjAction = win.delObjAction + self.zoomOutKeyValue = win.zoomOutKeyValue + self.setShortcuts(win.customShortcuts) - self.newAction = QAction(self.host) - self.newAction.setText("&New Segmentation File...") - self.newAction.setIcon(QIcon(":file-new.svg")) - self.openFolderAction = QAction( - QIcon(":folder-open.svg"), "&Load Folder...", self.host - ) - self.openFileAction = QAction( - QIcon(":image.svg"),"&Open Image/Video File...", self.host - ) - self.manageVersionsAction = QAction( - QIcon(":manage_versions.svg"), "Load Older Versions...", self.host + def gui_connectActions(self): + # Connect File actions + if self.debug: + self.createEmptyDataAction.triggered.connect(self._createEmptyData) + self.segmNdimIndicator.clicked.connect(self.segmNdimIndicatorClicked) + self.newWindowAction.triggered.connect(self.openNewWindow) + self.newAction.triggered.connect(self.newFile) + self.openFolderAction.triggered.connect(self.openFolder) + self.openFileAction.triggered.connect(self.openFile) + self.manageVersionsAction.triggered.connect(self.manageVersions) + self.saveAction.triggered.connect(self.saveData) + self.saveAsAction.triggered.connect(self.saveAsData) + self.exportToVideoAction.triggered.connect(self.exportToVideoTriggered) + self.exportToImageAction.triggered.connect(self.exportToImageTriggered) + self.quickSaveAction.triggered.connect(self.quickSave) + self.viewPreprocDataToggle.toggled.connect(self.viewPreprocDataToggled) + self.viewCombineChannelDataToggle.toggled.connect( + self.viewCombineChannelDataToggled ) - self.manageVersionsAction.setDisabled(True) - self.saveAction = QAction(QIcon(":file-save.svg"), "Save", self.host) - self.saveAsAction = QAction("Save as...", self.host) - self.exportToVideoAction = QAction("&Video...", self.host) - self.exportToImageAction = QAction("&Image...", self.host) - self.quickSaveAction = QAction("Save Only Segmentation Masks", self.host) - self.loadFluoAction = QAction("Load Fluorescence Images...", self.host) - self.loadPosAction = QAction("Load Different Position...", self.host) - # self.reloadAction = QAction( - # QIcon(":reload.svg"), "Reload segmentation file", self - # ) - self.nextAction = QAction('Next', self.host) - self.prevAction = QAction('Previous', self.host) - self.showInExplorerAction = QAction( - QIcon(":drawer.svg"), f"&{self.openFolderText}", self.host + self.autoSaveToggle.toggled.connect(self.autoSaveToggled) + self.autoSaveAnnotToggle.toggled.connect(self.autoSaveAnnotToggled) + self.autoSaveIntervalDialog.sigValueChanged.connect( + self.autoSaveIntervalValueChanged ) - self.exitAction = QAction("&Exit", self.host) - self.undoAction = QAction(QIcon(":undo.svg"), "Undo", self.host) - self.redoAction = QAction(QIcon(":redo.svg"), "Redo", self.host) - # String-based key sequences - self.newWindowAction.setShortcut('Ctrl+Shift+N') - self.newAction.setShortcut('Ctrl+N') - self.openFolderAction.setShortcut('Ctrl+O') - self.loadPosAction.setShortcut('Shift+P') - self.saveAsAction.setShortcut('Ctrl+Shift+S') - self.exportToVideoAction.setShortcut('Ctrl+Shift+V') - self.exportToImageAction.setShortcut('Ctrl+Shift+I') - self.saveAction.setShortcut('Ctrl+Alt+S') - self.quickSaveAction.setShortcut('Ctrl+S') - self.undoAction.setShortcut('Ctrl+Z') - self.redoAction.setShortcut('Ctrl+Y') - self.nextAction.setShortcut(Qt.Key_Right) - self.prevAction.setShortcut(Qt.Key_Left) - self.addAction(self.nextAction) - self.addAction(self.prevAction) - # Help tips - newTip = "Create a new segmentation file" - self.newAction.setStatusTip(newTip) - self.newAction.setWhatsThis("Create a new empty segmentation file") - - self.autoPilotButton = QAction(self.host) - self.autoPilotButton.setIcon(QIcon(":auto-pilot.svg")) - self.autoPilotButton.setCheckable(True) - self.autoPilotButton.setShortcut('Ctrl+Shift+A') - - self.findIdAction = QAction(self.host) - self.findIdAction.setIcon(QIcon(":find.svg")) - self.findIdAction.setShortcut('Ctrl+F') + self.autoSaveIntervalEditButton.clicked.connect(self.autoSaveIntervalEdit) + self.ccaIntegrCheckerToggle.toggled.connect(self.ccaIntegrCheckerToggled) + self.annotLostObjsToggle.toggled.connect(self.annotLostObjsToggled) + self.highLowResAction.clicked.connect(self.highLowResToggled) + self.showInExplorerAction.triggered.connect(self.showInExplorer_cb) + self.exitAction.triggered.connect(self.close) + self.undoAction.triggered.connect(self.undo) + self.redoAction.triggered.connect(self.redo) + self.nextAction.triggered.connect(self.nextActionTriggered) + self.prevAction.triggered.connect(self.prevActionTriggered) - self.zoomRectButton = QToolButton(self.host) - self.zoomRectButton.setIcon(QIcon(":zoom_rect.svg")) - self.zoomRectButton.setCheckable(True) - self.zoomRectButton.setShortcut('Shift+Z') - self.LeftClickButtons.append(self.zoomRectButton) - self.checkableButtons.append(self.zoomRectButton) - self.checkableQButtonsGroup.addButton(self.zoomRectButton) - self.widgetsWithShortcut['Zoom to rectangular area'] = ( - self.zoomRectButton + self.invertBwAction.toggled.connect(self.invertBw) + self.toggleColorSchemeAction.triggered.connect(self.onToggleColorScheme) + self.pxModeAction.clicked.connect(self.pxModeActionToggled) + self.editShortcutsAction.triggered.connect(self.editShortcuts_cb) + self.editAutoSaveIntervalAction.triggered.connect( + self.autoSaveIntervalEditButton.click ) + self.showMirroredCursorAction.toggled.connect(self.showMirroredCursorToggled) - self.skipToNewIdAction = QAction(self.host) - self.skipToNewIdAction.setIcon(QIcon(":skip_forward_new_ID.svg")) - self.skipToNewIdAction.setShortcut( - widgets.KeySequenceFromText(Qt.Key_PageUp) - ) + # Connect Help actions + self.tipsAction.triggered.connect(self.showTipsAndTricks) + self.UserManualAction.triggered.connect(myutils.browse_docs) + self.openLogFileAction.triggered.connect(self.openLogFile) + self.showLogFilesAction.triggered.connect(self.showLogFiles) + self.aboutAction.triggered.connect(self.showAbout) + # Connect Open Recent to dynamically populate it + # self.openRecentMenu.aboutToShow.connect(self.populateOpenRecent) + self.checkableQButtonsGroup.buttonClicked.connect(self.uncheckQButton) - self.skipToNewIdAction.setDisabled(True) + self.showPropsDockButton.sigClicked.connect(self.showPropsDockWidget) - # Edit actions - models = self.model_registry.segmentation_models( - include_local_seg=True + self.loadCustomAnnotationsAction.triggered.connect(self.loadCustomAnnotations) + self.addCustomAnnotationAction.triggered.connect(self.addCustomAnnotation) + self.viewAllCustomAnnotAction.toggled.connect(self.viewAllCustomAnnot) + self.addCustomModelVideoAction.triggered.connect( + self.showInstructionsCustomModel ) - self.segmActions = [] - self.modelNames = [] - self.acdcSegment_li = [] - self.models = [] - for model_name in models: - action = QAction(f"{model_name}...") - self.segmActions.append(action) - self.modelNames.append(model_name) - self.models.append(None) - self.acdcSegment_li.append(None) - action.setDisabled(True) - - self.addCustomModelFrameAction = QAction('Add custom model...', self.host) - self.addCustomModelVideoAction = QAction('Add custom model...', self.host) + self.addCustomModelFrameAction.triggered.connect( + self.showInstructionsCustomModel + ) + self.addCustomModelFrameAction.callback = self.segmFrameCallback + self.addCustomModelVideoAction.callback = self.segmVideoCallback - self.segmWithPromptableModelAction = QAction( - 'Select promptable model...', self.host + self.addCustomPromptModelAction.triggered.connect( + self.showInstructionsCustomPromptModel ) - self.addCustomPromptModelAction = QAction( - 'Add custom promptable model...', self.host + self.segmWithPromptableModelAction.triggered.connect( + self.segmWithPromptableModelActionTriggered ) - self.segmActionsVideo = [] - for model_name in models: - action = QAction(f"{model_name}...") - self.segmActionsVideo.append(action) - action.setDisabled(True) - - self.postProcessSegmAction = QAction( - "Segmentation post-processing...", self.host - ) - self.postProcessSegmAction.setDisabled(True) - self.postProcessSegmAction.setCheckable(True) + def gui_connectEditActions(self): + self.showInExplorerAction.setEnabled(True) + self.setEnabledFileToolbar(True) + self.loadFluoAction.setEnabled(True) + self.isEditActionsConnected = True - self.EditSegForLostIDsSetSettings = QAction( - "Edit settings for Segmenting lost IDs...", self.host - ) - self.EditSegForLostIDsSetSettings.triggered.connect( - self.seg_for_lost_ids_view.SegForLostIDsSetSettings + self.preprocessImageAction.triggered.connect(self.preprocessAction.trigger) + self.combineChannelsAction.triggered.connect( + self.combineChannelsActionTriggered ) - self.repeatTrackingAction = QAction( - QIcon(":repeat-tracking.svg"), "Repeat tracking", self.host + self.overlayButton.toggled.connect(self.overlay_cb) + self.countObjsButton.toggled.connect(self.countObjectsCb) + self.togglePointsLayerAction.toggled.connect(self.pointsLayerToggled) + self.overlayLabelsButton.toggled.connect(self.overlayLabels_cb) + self.overlayButton.sigRightClick.connect(self.showOverlayContextMenu) + self.labelRoiButton.sigRightClick.connect(self.showLabelRoiContextMenu) + self.overlayLabelsButton.sigRightClick.connect( + self.showOverlayLabelsContextMenu ) - self.repeatTrackingAction.setShortcut('Shift+T') - self.widgetsWithShortcut['Repeat Tracking'] = self.repeatTrackingAction + self.rulerButton.toggled.connect(self.ruler_cb) + self.loadFluoAction.triggered.connect(self.loadFluo_cb) + self.loadPosAction.triggered.connect(self.loadPosTriggered) + # self.reloadAction.triggered.connect(self.reload_cb) + self.findIdAction.triggered.connect(self.findID) + self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) + self.autoPilotButton.toggled.connect(self.autoPilotToggled) + self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID) + self.slideshowButton.toggled.connect(self.launchSlideshow) + self.copyLostObjButton.toggled.connect(self.copyLostObjContour_cb) + self.manualAnnotPastButton.toggled.connect(self.manualAnnotPast_cb) - self.editRtTrackerParamsAction = QAction( - 'Edit real-time tracker parameters...', self.host - ) + self.segmSingleFrameMenu.triggered.connect(self.segmFrameCallback) + self.segmVideoMenu.triggered.connect(self.segmVideoCallback) - self.repeatTrackingMenuAction = QAction( - 'Track current frame with real-time tracker...', self.host + self.postProcessSegmAction.toggled.connect(self.postProcessSegm) + self.autoSegmAction.toggled.connect(self.autoSegm_cb) + self.realTimeTrackingToggle.clicked.connect(self.realTimeTrackingClicked) + self.repeatTrackingAction.triggered.connect(self.repeatTracking) + self.manualTrackingButton.toggled.connect(self.manualTracking_cb) + self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) + self.repeatTrackingMenuAction.triggered.connect(self.repeatTracking) + self.repeatTrackingVideoAction.triggered.connect(self.repeatTrackingVideo) + for rtTrackerAction in self.trackingAlgosGroup.actions(): + rtTrackerAction.toggled.connect(self.rtTrackerActionToggled) + self.editRtTrackerParamsAction.triggered.connect(self.initRealTimeTracker) + self.delObjsOutSegmMaskAction.triggered.connect( + self.delObjsOutSegmMaskActionTriggered ) - self.repeatTrackingMenuAction.setDisabled(True) - self.repeatTrackingMenuAction.setShortcut('Shift+T') - - self.repeatTrackingVideoAction = QAction( - 'Select a tracker and track multiple frames...', self.host + self.mergeIDsButton.toggled.connect(self.mergeObjs_cb) + self.brushButton.toggled.connect(self.Brush_cb) + self.eraserButton.toggled.connect(self.Eraser_cb) + self.curvToolButton.toggled.connect(self.curvTool_cb) + self.wandToolButton.toggled.connect(self.wand_cb) + self.labelRoiButton.toggled.connect(self.labelRoi_cb) + self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) + self.drawClearRegionButton.toggled.connect(self.drawClearRegion_cb) + self.reInitCcaAction.triggered.connect(self.reInitCca) + self.moveLabelToolButton.toggled.connect(self.moveLabelButtonToggled) + self.editCcaToolAction.triggered.connect( + self.manualEditCcaToolbarActionTriggered ) - self.repeatTrackingVideoAction.setDisabled(True) - self.repeatTrackingVideoAction.setShortcut('Alt+Shift+T') - - self.trackingAlgosGroup = QActionGroup(self.host) - self.trackWithAcdcAction = QAction('Cell-ACDC', self.host) - self.trackWithAcdcAction.setCheckable(True) - self.trackingAlgosGroup.addAction(self.trackWithAcdcAction) + self.assignBudMothAutoAction.triggered.connect(self.autoAssignBud_YeastMate) + self.keepIDsButton.toggled.connect(self.keepIDs_cb) - self.trackWithYeazAction = QAction('YeaZ', self.host) - self.trackWithYeazAction.setCheckable(True) - self.trackingAlgosGroup.addAction(self.trackWithYeazAction) + self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) - rt_trackers = self.model_registry.real_time_trackers() - for rt_tracker in rt_trackers: - rtTrackerAction = QAction(rt_tracker, self.host) - rtTrackerAction.setCheckable(True) - self.trackingAlgosGroup.addAction(rtTrackerAction) + self.whitelistIDsToolbar.sigWhitelistChanged.connect(self.whitelistIDsChanged) - self.trackWithAcdcAction.setChecked(True) - aliases = self.model_registry.real_time_tracker_aliases() + self.whitelistIDsToolbar.sigWhitelistAccepted.connect(self.whitelistIDsAccepted) - if 'tracking_algorithm' in self.df_settings.index: - trackingAlgo = self.df_settings.at['tracking_algorithm', 'value'] - if trackingAlgo in aliases: - trackingAlgo = aliases[trackingAlgo] - if trackingAlgo == 'Cell-ACDC': - self.trackWithAcdcAction.setChecked(True) - elif trackingAlgo == 'YeaZ': - self.trackWithYeazAction.setChecked(True) - else: - for rtTrackerAction in self.trackingAlgosGroup.actions(): - if rtTrackerAction.text() == trackingAlgo: - rtTrackerAction.setChecked(True) - break + self.whitelistIDsToolbar.sigViewOGIDs.connect(self.whitelistViewOGIDs) - self.setMeasurementsAction = QAction('Set measurements...') - self.addCustomMetricAction = QAction('Add custom measurement...') - self.addCombineMetricAction = QAction('Add combined measurement...') + self.whitelistIDsToolbar.sigAddNewIDs.connect(self.whitelistAddNewIDsToggled) - # Standard key sequence - # self.copyAction.setShortcut(QKeySequence.StandardKey.Copy) - # self.pasteAction.setShortcut(QKeySequence.StandardKey.Paste) - # self.cutAction.setShortcut(QKeySequence.StandardKey.Cut) - # Help actions - self.tipsAction = QAction("Tips and tricks...", self.host) - self.UserManualAction = QAction("User Documentation...", self.host) - self.openLogFileAction = QAction("Open log file...", self.host) - self.showLogFilesAction = QAction("Show log files...", self.host) - self.aboutAction = QAction("About Cell-ACDC", self.host) - # self.aboutAction = QAction("&About...", self.host) + self.whitelistIDsToolbar.sigLoadOGLabs.connect(self.whitelistLoadOGLabs_cb) - # Assign mother to bud button - self.assignBudMothAutoAction = QAction(self.host) - self.assignBudMothAutoAction.setIcon(QIcon(":autoAssign.svg")) - self.assignBudMothAutoAction.setVisible(False) + self.whitelistIDsToolbar.sigTrackOGagainstPreviousFrame.connect( + self.whitelistTrackOGagainstPreviousFrame_cb + ) - self.editCcaToolAction = QAction(self.host) - self.editCcaToolAction.setIcon(QIcon(":edit_cca.svg")) - # self.editCcaToolAction.setDisabled(True) - self.editCcaToolAction.setVisible(False) + self.expandLabelToolButton.toggled.connect(self.expandLabelCallback) - self.reInitCcaAction = QAction(self.host) - self.reInitCcaAction.setIcon(QIcon(":reinitCca.svg")) - self.reInitCcaAction.setVisible(False) + self.reinitLastSegmFrameAction.triggered.connect(self.reInitLastSegmFrame) - self.toggleColorSchemeAction = QAction( - 'Switch to light theme' + self.defaultRescaleIntensActionGroup.triggered.connect( + self.defaultRescaleIntensLutActionToggled ) - self.gui_updateSwitchColorSchemeActionText() - self.pxModeAction = widgets.CheckableAction( - 'Fixed size text annotations' - ) - self.pxModeAction.setChecked(True) - pxModeTooltip = ( - 'When the text annotations are with fixed size they scale relative ' - 'to the object when zooming in/out (fixed size in pixels).\n' - 'This is typically faster to render, but it makes annotations ' - 'smaller/larger when zooming in/out, respectively.\n\n' - 'Try activating it to speed up the annotation of many objects ' - 'in high resolution mode.\n\n' - 'After activating it, you might need to increase the font size ' - 'from the menu on the top menubar `Edit --> Font size`.' - ) - self.pxModeAction.setToolTip(pxModeTooltip) + # self.repeatAutoCcaAction.triggered.connect(self.repeatAutoCca) + self.manuallyEditCcaAction.triggered.connect(self.manualEditCca) + self.addScaleBarAction.toggled.connect(self.addScaleBar) + self.addTimestampAction.toggled.connect(self.addTimestamp) + self.saveLabColormapAction.triggered.connect(self.saveLabelsColormap) - self.highLowResAction = widgets.CheckableAction( - 'High resolution text annotations' - ) - highLowResTooltip = ( - 'Resolution of the text annotations. High resolution results ' - 'in slower update of the annotations.\n' - 'Not recommended with a number of segmented objects > 500.\n\n' - ) - self.highLowResAction.setToolTip(highLowResTooltip) + self.enableSmartTrackAction.toggled.connect(self.enableSmartTrack) + # Brush/Eraser size action + self.brushSizeSpinbox.valueChanged.connect(self.brushSize_cb) + self.autoIDcheckbox.toggled.connect(self.autoIDtoggled) + # Mode + self.modeActionGroup.triggered.connect(self.changeModeFromMenu) + self.modeComboBox.sigTextChanged.connect(self.changeMode) + self.modeComboBox.activated.connect(self.clearComboBoxFocus) + self.equalizeHistPushButton.toggled.connect(self.equalizeHist) - self.editAutoSaveIntervalAction = QAction( - 'Change autosave interval (minutes or frames)...', self.host - ) + self.editOverlayColorAction.triggered.connect(self.toggleOverlayColorButton) + self.editTextIDsColorAction.triggered.connect(self.toggleTextIDsColorButton) + self.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) + self.overlayColorButton.sigColorChanged.connect(self.saveOverlayColor) + self.textIDsColorButton.sigColorChanging.connect(self.updateTextAnnotColor) + self.textIDsColorButton.sigColorChanged.connect(self.saveTextIDsColors) + + self.setMeasurementsAction.triggered.connect(self.showSetMeasurements) + self.addCustomMetricAction.triggered.connect(self.addCustomMetric) + self.addCombineMetricAction.triggered.connect(self.addCombineMetric) - self.editShortcutsAction = QAction( - 'Customize keyboard shortcuts...', self.host + self.labelsGrad.colorButton.sigColorChanging.connect(self.updateBkgrColor) + self.labelsGrad.colorButton.sigColorChanged.connect(self.saveBkgrColor) + self.labelsGrad.sigGradientChangeFinished.connect(self.updateLabelsCmap) + self.labelsGrad.sigGradientChanged.connect(self.ticksCmapMoved) + self.labelsGrad.textColorButton.sigColorChanging.connect( + self.updateTextLabelsColor + ) + self.labelsGrad.textColorButton.sigColorChanged.connect( + self.saveTextLabelsColor ) - self.editShortcutsAction.setShortcut('Ctrl+K') + # self.addFontSizeActions( + # self.labelsGrad.fontSizeMenu, self.setFontSizeActionChecked + # ) - self.showMirroredCursorAction = QAction( - 'Show mirrored cursor on images', self.host + self.labelsGrad.shuffleCmapAction.triggered.connect(self.shuffle_cmap) + self.labelsGrad.greedyShuffleCmapAction.triggered.connect( + self.greedyShuffleCmap ) - self.showMirroredCursorAction.setCheckable(True) - if 'showMirroredCursor' in self.df_settings.index: - checked = self.df_settings.at['showMirroredCursor', 'value'] == 'Yes' - self.showMirroredCursorAction.setChecked(checked) - else: - self.showMirroredCursorAction.setChecked(True) - self.showMirroredCursorAction.setShortcut('Ctrl+M') + self.labelsGrad.permanentGreedyCmapAction.toggled.connect( + self.permanentGreedyCmapToggled + ) + self.shuffleCmapAction.triggered.connect(self.shuffle_cmap) + self.greedyShuffleCmapAction.triggered.connect(self.greedyShuffleCmap) + self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + self.labelsGrad.sigShowLabelsImgToggled.connect(self.showLabelImageItem) + self.labelsGrad.sigShowRightImgToggled.connect(self.showRightImageItem) + self.labelsGrad.sigShowNextFrameToggled.connect(self.showNextFrameImageItem) - self.editTextIDsColorAction = QAction('Text annotation color...', self.host) - self.editTextIDsColorAction.setDisabled(True) + self.labelsGrad.defaultSettingsAction.triggered.connect( + self.restoreDefaultSettings + ) - self.editOverlayColorAction = QAction('Overlay color...', self.host) - self.editOverlayColorAction.setDisabled(True) + # self.addFontSizeActions( + # self.imgGrad.fontSizeMenu, self.setFontSizeActionChecked + # ) + self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + self.imgGrad.textColorButton.disconnect() + self.imgGrad.textColorButton.clicked.connect( + self.editTextIDsColorAction.trigger + ) + self.imgGrad.labelsAlphaSlider.valueChanged.connect(self.updateLabelsAlpha) + self.imgGrad.defaultSettingsAction.triggered.connect( + self.restoreDefaultSettings + ) - self.manuallyEditCcaAction = QAction( - 'Edit cell cycle annotations...', self.host + # Drawing mode + self.drawIDsContComboBox.currentIndexChanged.connect( + self.drawIDsContComboBox_cb ) - self.manuallyEditCcaAction.setShortcut('Ctrl+Shift+P') - self.manuallyEditCcaAction.setDisabled(True) + self.drawIDsContComboBox.activated.connect(self.clearComboBoxFocus) - self.viewCcaTableAction = QAction( - 'View cell cycle annotations...', self.host + self.annotateRightHowCombobox.currentIndexChanged.connect( + self.annotateRightHowCombobox_cb ) - self.viewCcaTableAction.setDisabled(True) - self.viewCcaTableAction.setShortcut('Ctrl+P') + self.annotateRightHowCombobox.activated.connect(self.clearComboBoxFocus) + self.showTreeInfoCheckbox.toggled.connect(self.setAnnotInfoMode) - self.addScaleBarAction = QAction('Add scale bar', self.host) - self.addScaleBarAction.setCheckable(True) + # Left + self.annotIDsCheckbox.clicked.connect(self.annotOptionClicked) + self.annotCcaInfoCheckbox.clicked.connect(self.annotOptionClicked) + self.annotContourCheckbox.clicked.connect(self.annotOptionClicked) + self.annotSegmMasksCheckbox.clicked.connect(self.annotOptionClicked) + self.drawMothBudLinesCheckbox.clicked.connect(self.annotOptionClicked) + self.drawNothingCheckbox.clicked.connect(self.annotOptionClicked) + self.annotNumZslicesCheckbox.clicked.connect(self.annotOptionClicked) - self.addTimestampAction = QAction('Add timestamp', self.host) - self.addTimestampAction.setCheckable(True) + # Right + self.annotIDsCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.annotCcaInfoCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.annotContourCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.annotSegmMasksCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.drawMothBudLinesCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.drawNothingCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.annotNumZslicesCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.invertBwAction = QAction('Invert black/white', self.host) - self.invertBwAction.setCheckable(True) - checked = self.df_settings.at['is_bw_inverted', 'value'] == 'Yes' - self.invertBwAction.setChecked(checked) + self.segmentToolAction.triggered.connect(self.segmentToolActionTriggered) - self.shuffleCmapAction = QAction('Randomly shuffle colormap', self.host) - self.shuffleCmapAction.setShortcut('Shift+S') + self.addDelRoiAction.triggered.connect(self.addDelROI) + self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) + self.delBorderObjAction.triggered.connect(self.delBorderObj) + self.delNewObjAction.triggered.connect(self.delNewObj) - self.greedyShuffleCmapAction = QAction( - 'Greedily shuffle colormap', self.host - ) - self.greedyShuffleCmapAction.setShortcut('Alt+Shift+S') + self.brushAutoFillCheckbox.toggled.connect(self.brushAutoFillToggled) + self.brushAutoHideCheckbox.toggled.connect(self.brushAutoHideToggled) - self.saveLabColormapAction = QAction( - 'Save labels colormap...', self.host + self.imgGrad.sigAddScaleBar.connect(self.addScaleBarAction.setChecked) + self.imgGrad.sigAddTimestamp.connect(self.addTimestampAction.setChecked) + self.imgGrad.gradient.sigGradientChangeFinished.connect( + self.imgGradLUTfinished_cb ) - self.normalizeRawAction = QAction( - 'Do not normalize. Display raw image', self.host) - self.normalizeToFloatAction = QAction( - 'Convert to floating point format with values [0, 1]', self.host) - # self.normalizeToUbyteAction = QAction( - # 'Rescale to 8-bit unsigned integer format with values [0, 255]', self.host) - self.normalizeRescale0to1Action = QAction( - 'Rescale to [0, 1]', self.host) - self.normalizeByMaxAction = QAction( - 'Normalize by max value', self.host) - self.normalizeRawAction.setCheckable(True) - self.normalizeToFloatAction.setCheckable(True) - # self.normalizeToUbyteAction.setCheckable(True) - self.normalizeRescale0to1Action.setCheckable(True) - self.normalizeByMaxAction.setCheckable(True) - self.normalizeQActionGroup = QActionGroup(self.host) - self.normalizeQActionGroup.addAction(self.normalizeRawAction) - self.normalizeQActionGroup.addAction(self.normalizeToFloatAction) - # self.normalizeQActionGroup.addAction(self.normalizeToUbyteAction) - self.normalizeQActionGroup.addAction(self.normalizeRescale0to1Action) - self.normalizeQActionGroup.addAction(self.normalizeByMaxAction) + # self.normalizeQActionGroup.triggered.connect( + # self.normaliseIntensitiesActionTriggered + # ) + self.imgPropertiesAction.triggered.connect(self.editImgProperties) - self.preprocessAction = QAction( - 'Pre-processing...', self.host - ) - self.preprocessAction.setShortcut('Alt+Shift+P') + self.relabelSequentialAction.triggered.connect(self.relabelSequentialCallback) - self.combineChannelsAction = QAction( - 'Combine and manipulate channels and/or segmentation files...', self.host + self.zoomToObjsAction.triggered.connect(self.zoomToObjsActionCallback) + self.zoomOutAction.triggered.connect(self.zoomOut) + self.preprocessAction.triggered.connect(self.preprocessActionTriggered) + self.combineChannelsAction.triggered.connect( + self.combineChannelsActionTriggered ) - self.combineChannelsAction.setShortcut('Alt+Shift+C') - self.zoomToObjsAction = QAction( - 'Zoom to objects (Shortcut: H key)', self.host + self.viewCcaTableAction.triggered.connect(self.viewCcaTable) + + self.guiTabControl.propsQGBox.idSB.valueChanged.connect( + self.propsWidgetIDvalueChanged ) - self.zoomOutAction = QAction( - 'Zoom out (Shortcut: double press H key)', self.host + self.guiTabControl.highlightCheckbox.toggled.connect( + self.highlightIDonHoverCheckBoxToggled ) - - self.relabelSequentialAction = QAction( - 'Relabel IDs sequentially...', self.host + self.guiTabControl.highlightSearchedCheckbox.toggled.connect( + self.highlightSearchedIDcheckBoxToggled + ) + intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox + intensMeasurQGBox.additionalMeasCombobox.currentTextChanged.connect( + self.updatePropsWidget + ) + intensMeasurQGBox.channelCombobox.currentTextChanged.connect( + self.updatePropsWidget ) - self.relabelSequentialAction.setShortcut('Ctrl+L') - self.relabelSequentialAction.setDisabled(True) - self.setLastUserNormAction() + propsQGBox = self.guiTabControl.propsQGBox + propsQGBox.additionalPropsCombobox.currentTextChanged.connect( + self.updatePropsWidget + ) - self.autoSegmAction = QAction( - 'Enable automatic segmentation', self.host) - self.autoSegmAction.setCheckable(True) - self.autoSegmAction.setDisabled(True) + def gui_createActions(self): + # File actions + self.segmNdimIndicator = widgets.ToolButtonTextIcon(text="") + self.segmNdimIndicator.setCheckable(True) + self.segmNdimIndicator.setChecked(True) + # self.segmNdimIndicator.setDisabled(True) - self.enableSmartTrackAction = QAction( - 'Smart handling of enabling/disabling tracking', self.host) - self.enableSmartTrackAction.setCheckable(True) - self.enableSmartTrackAction.setChecked(True) + if self.debug: + self.createEmptyDataAction = QAction(self) + self.createEmptyDataAction.setText("DEBUG: Create empty data") - self.enableAutoZoomToCellsAction = QAction( - 'Automatic zoom to all cells when pressing "Next/Previous"', self.host) - self.enableAutoZoomToCellsAction.setCheckable(True) + self.newWindowAction = QAction("New Window", self) - self.imgPropertiesAction = QAction('Properties...', self.host) - self.imgPropertiesAction.setDisabled(True) + self.newAction = QAction(self) + self.newAction.setText("&New Segmentation File...") + self.newAction.setIcon(QIcon(":file-new.svg")) + self.openFolderAction = QAction( + QIcon(":folder-open.svg"), "&Load Folder...", self + ) + self.openFileAction = QAction( + QIcon(":image.svg"), "&Open Image/Video File...", self + ) + self.manageVersionsAction = QAction( + QIcon(":manage_versions.svg"), "Load Older Versions...", self + ) + self.manageVersionsAction.setDisabled(True) + self.saveAction = QAction(QIcon(":file-save.svg"), "Save", self) + self.saveAsAction = QAction("Save as...", self) + self.exportToVideoAction = QAction("&Video...", self) + self.exportToImageAction = QAction("&Image...", self) + self.quickSaveAction = QAction("Save Only Segmentation Masks", self) + self.loadFluoAction = QAction("Load Fluorescence Images...", self) + self.loadPosAction = QAction("Load Different Position...", self) + # self.reloadAction = QAction( + # QIcon(":reload.svg"), "Reload segmentation file", self + # ) + self.nextAction = QAction("Next", self) + self.prevAction = QAction("Previous", self) + self.showInExplorerAction = QAction( + QIcon(":drawer.svg"), f"&{self.openFolderText}", self + ) + self.exitAction = QAction("&Exit", self) + self.undoAction = QAction(QIcon(":undo.svg"), "Undo", self) + self.redoAction = QAction(QIcon(":redo.svg"), "Redo", self) + # String-based key sequences + self.newWindowAction.setShortcut("Ctrl+Shift+N") + self.newAction.setShortcut("Ctrl+N") + self.openFolderAction.setShortcut("Ctrl+O") + self.loadPosAction.setShortcut("Shift+P") + self.saveAsAction.setShortcut("Ctrl+Shift+S") + self.exportToVideoAction.setShortcut("Ctrl+Shift+V") + self.exportToImageAction.setShortcut("Ctrl+Shift+I") + self.saveAction.setShortcut("Ctrl+Alt+S") + self.quickSaveAction.setShortcut("Ctrl+S") + self.undoAction.setShortcut("Ctrl+Z") + self.redoAction.setShortcut("Ctrl+Y") + self.nextAction.setShortcut(Qt.Key_Right) + self.prevAction.setShortcut(Qt.Key_Left) + self.addAction(self.nextAction) + self.addAction(self.prevAction) + # Help tips + newTip = "Create a new segmentation file" + self.newAction.setStatusTip(newTip) + self.newAction.setWhatsThis("Create a new empty segmentation file") - self.addDelRoiAction = QAction(self.host) - self.addDelRoiAction.roiType = 'rect' - self.addDelRoiAction.setIcon(QIcon(":addDelRoi.svg")) + self.autoPilotButton = QAction(self) + self.autoPilotButton.setIcon(QIcon(":auto-pilot.svg")) + self.autoPilotButton.setCheckable(True) + self.autoPilotButton.setShortcut("Ctrl+Shift+A") - self.addDelPolyLineRoiButton = QToolButton(self.host) - self.addDelPolyLineRoiButton.setCheckable(True) - self.addDelPolyLineRoiButton.setIcon(QIcon(":addDelPolyLineRoi.svg")) + self.findIdAction = QAction(self) + self.findIdAction.setIcon(QIcon(":find.svg")) + self.findIdAction.setShortcut("Ctrl+F") - self.checkableButtons.append(self.addDelPolyLineRoiButton) - self.LeftClickButtons.append(self.addDelPolyLineRoiButton) + self.zoomRectButton = QToolButton(self) + self.zoomRectButton.setIcon(QIcon(":zoom_rect.svg")) + self.zoomRectButton.setCheckable(True) + self.zoomRectButton.setShortcut("Shift+Z") + self.LeftClickButtons.append(self.zoomRectButton) + self.checkableButtons.append(self.zoomRectButton) + self.checkableQButtonsGroup.addButton(self.zoomRectButton) + self.widgetsWithShortcut["Zoom to rectangular area"] = self.zoomRectButton - self.delBorderObjAction = QAction(self.host) - self.delBorderObjAction.setIcon(QIcon(":delBorderObj.svg")) + self.skipToNewIdAction = QAction(self) + self.skipToNewIdAction.setIcon(QIcon(":skip_forward_new_ID.svg")) + self.skipToNewIdAction.setShortcut(widgets.KeySequenceFromText(Qt.Key_PageUp)) - self.delNewObjAction = QAction(self.host) - self.delNewObjAction.setIcon(QIcon(":delNewObj.svg")) + self.skipToNewIdAction.setDisabled(True) - self.loadCustomAnnotationsAction = QAction(self.host) - self.loadCustomAnnotationsAction.setIcon(QIcon(":load_annotation.svg")) - self.loadCustomAnnotationsAction.setToolTip( - 'Load previously used custom annotations' - ) + # Edit actions + models = myutils.get_list_of_models() + models = [*models, "local_seg"] # Add local_seg for SegForLostIDsAction + self.segmActions = [] + self.modelNames = [] + self.acdcSegment_li = [] + self.models = [] + for model_name in models: + action = QAction(f"{model_name}...") + self.segmActions.append(action) + self.modelNames.append(model_name) + self.models.append(None) + self.acdcSegment_li.append(None) + action.setDisabled(True) - self.addCustomAnnotationAction = QAction(self.host) - self.addCustomAnnotationAction.setIcon(QIcon(":addCustomAnnotation.svg")) - self.addCustomAnnotationAction.setToolTip('Add custom annotation') - # self.functionsNotTested3D.append(self.addCustomAnnotationAction) + self.addCustomModelFrameAction = QAction("Add custom model...", self) + self.addCustomModelVideoAction = QAction("Add custom model...", self) - self.viewAllCustomAnnotAction = QAction(self.host) - self.viewAllCustomAnnotAction.setCheckable(True) - self.viewAllCustomAnnotAction.setIcon(QIcon(":eye.svg")) - self.viewAllCustomAnnotAction.setToolTip('Show all custom annotations') - # self.functionsNotTested3D.append(self.viewAllCustomAnnotAction) + self.segmWithPromptableModelAction = QAction("Select promptable model...", self) + self.addCustomPromptModelAction = QAction( + "Add custom promptable model...", self + ) - # self.imgGradLabelsAlphaUpAction = QAction(self.host) - # self.imgGradLabelsAlphaUpAction.setVisible(False) - # self.imgGradLabelsAlphaUpAction.setShortcut('Ctrl+Up') + self.segmActionsVideo = [] + for model_name in models: + action = QAction(f"{model_name}...") + self.segmActionsVideo.append(action) + action.setDisabled(True) - def gui_updateSwitchColorSchemeActionText(self): - if self._colorScheme == 'dark': - txt = 'Switch to light theme' - else: - txt = 'Switch to dark theme' - self.toggleColorSchemeAction.setText(txt) + self.postProcessSegmAction = QAction("Segmentation post-processing...", self) + self.postProcessSegmAction.setDisabled(True) + self.postProcessSegmAction.setCheckable(True) - def gui_connectActions(self): - # Connect File actions - if self.debug: - self.createEmptyDataAction.triggered.connect(self._createEmptyData) - self.segmNdimIndicator.clicked.connect(self.segmNdimIndicatorClicked) - self.newWindowAction.triggered.connect(self.openNewWindow) - self.newAction.triggered.connect(self.newFile) - self.openFolderAction.triggered.connect(self.openFolder) - self.openFileAction.triggered.connect(self.openFile) - self.manageVersionsAction.triggered.connect(self.manageVersions) - self.saveAction.triggered.connect(self.saveData) - self.saveAsAction.triggered.connect(self.saveAsData) - self.exportToVideoAction.triggered.connect( - self.exporting_view.exportToVideoTriggered - ) - self.exportToImageAction.triggered.connect( - self.exporting_view.exportToImageTriggered + self.EditSegForLostIDsSetSettings = QAction( + "Edit settings for Segmenting lost IDs...", self ) - self.quickSaveAction.triggered.connect(self.quickSave) - self.viewPreprocDataToggle.toggled.connect( - self.preprocessing_view.viewPreprocDataToggled + self.EditSegForLostIDsSetSettings.triggered.connect( + self.SegForLostIDsSetSettings ) - self.viewCombineChannelDataToggle.toggled.connect( - self.viewCombineChannelDataToggled + + self.repeatTrackingAction = QAction( + QIcon(":repeat-tracking.svg"), "Repeat tracking", self ) - self.autoSaveToggle.toggled.connect(self.autoSaveToggled) - self.autoSaveAnnotToggle.toggled.connect(self.autoSaveAnnotToggled) - self.autoSaveIntervalDialog.sigValueChanged.connect( - self.autoSaveIntervalValueChanged + self.repeatTrackingAction.setShortcut("Shift+T") + self.widgetsWithShortcut["Repeat Tracking"] = self.repeatTrackingAction + + self.editRtTrackerParamsAction = QAction( + "Edit real-time tracker parameters...", self ) - self.autoSaveIntervalEditButton.clicked.connect( - self.autoSaveIntervalEdit + + self.repeatTrackingMenuAction = QAction( + "Track current frame with real-time tracker...", self ) - self.ccaIntegrCheckerToggle.toggled.connect( - self.ccaIntegrCheckerToggled + self.repeatTrackingMenuAction.setDisabled(True) + self.repeatTrackingMenuAction.setShortcut("Shift+T") + + self.repeatTrackingVideoAction = QAction( + "Select a tracker and track multiple frames...", self ) - self.annotLostObjsToggle.toggled.connect(self.annotLostObjsToggled) - self.highLowResAction.clicked.connect(self.highLowResToggled) - self.showInExplorerAction.triggered.connect(self.showInExplorer_cb) - self.exitAction.triggered.connect(self.close) - self.undoAction.triggered.connect(self.undo_redo_view.undo) - self.redoAction.triggered.connect(self.undo_redo_view.redo) - self.nextAction.triggered.connect(self.nextActionTriggered) - self.prevAction.triggered.connect(self.prevActionTriggered) + self.repeatTrackingVideoAction.setDisabled(True) + self.repeatTrackingVideoAction.setShortcut("Alt+Shift+T") - self.invertBwAction.toggled.connect(self.invertBw) - self.toggleColorSchemeAction.triggered.connect(self.onToggleColorScheme) - self.pxModeAction.clicked.connect(self.pxModeActionToggled) - self.editShortcutsAction.triggered.connect(self.editShortcuts_cb) - self.editAutoSaveIntervalAction.triggered.connect( - self.autoSaveIntervalEditButton.click + self.trackingAlgosGroup = QActionGroup(self) + self.trackWithAcdcAction = QAction("Cell-ACDC", self) + self.trackWithAcdcAction.setCheckable(True) + self.trackingAlgosGroup.addAction(self.trackWithAcdcAction) + + self.trackWithYeazAction = QAction("YeaZ", self) + self.trackWithYeazAction.setCheckable(True) + self.trackingAlgosGroup.addAction(self.trackWithYeazAction) + + rt_trackers = myutils.get_list_of_real_time_trackers() + for rt_tracker in rt_trackers: + rtTrackerAction = QAction(rt_tracker, self) + rtTrackerAction.setCheckable(True) + self.trackingAlgosGroup.addAction(rtTrackerAction) + + self.trackWithAcdcAction.setChecked(True) + aliases = myutils.aliases_real_time_trackers() + + if "tracking_algorithm" in self.df_settings.index: + trackingAlgo = self.df_settings.at["tracking_algorithm", "value"] + if trackingAlgo in aliases: + trackingAlgo = aliases[trackingAlgo] + if trackingAlgo == "Cell-ACDC": + self.trackWithAcdcAction.setChecked(True) + elif trackingAlgo == "YeaZ": + self.trackWithYeazAction.setChecked(True) + else: + for rtTrackerAction in self.trackingAlgosGroup.actions(): + if rtTrackerAction.text() == trackingAlgo: + rtTrackerAction.setChecked(True) + break + + self.setMeasurementsAction = QAction("Set measurements...") + self.addCustomMetricAction = QAction("Add custom measurement...") + self.addCombineMetricAction = QAction("Add combined measurement...") + + # Standard key sequence + # self.copyAction.setShortcut(QKeySequence.StandardKey.Copy) + # self.pasteAction.setShortcut(QKeySequence.StandardKey.Paste) + # self.cutAction.setShortcut(QKeySequence.StandardKey.Cut) + # Help actions + self.tipsAction = QAction("Tips and tricks...", self) + self.UserManualAction = QAction("User Documentation...", self) + self.openLogFileAction = QAction("Open log file...", self) + self.showLogFilesAction = QAction("Show log files...", self) + self.aboutAction = QAction("About Cell-ACDC", self) + # self.aboutAction = QAction("&About...", self) + + # Assign mother to bud button + self.assignBudMothAutoAction = QAction(self) + self.assignBudMothAutoAction.setIcon(QIcon(":autoAssign.svg")) + self.assignBudMothAutoAction.setVisible(False) + + self.editCcaToolAction = QAction(self) + self.editCcaToolAction.setIcon(QIcon(":edit_cca.svg")) + # self.editCcaToolAction.setDisabled(True) + self.editCcaToolAction.setVisible(False) + + self.reInitCcaAction = QAction(self) + self.reInitCcaAction.setIcon(QIcon(":reinitCca.svg")) + self.reInitCcaAction.setVisible(False) + + self.toggleColorSchemeAction = QAction("Switch to light theme") + self.gui_updateSwitchColorSchemeActionText() + + self.pxModeAction = widgets.CheckableAction("Fixed size text annotations") + self.pxModeAction.setChecked(True) + pxModeTooltip = ( + "When the text annotations are with fixed size they scale relative " + "to the object when zooming in/out (fixed size in pixels).\n" + "This is typically faster to render, but it makes annotations " + "smaller/larger when zooming in/out, respectively.\n\n" + "Try activating it to speed up the annotation of many objects " + "in high resolution mode.\n\n" + "After activating it, you might need to increase the font size " + "from the menu on the top menubar `Edit --> Font size`." ) - self.showMirroredCursorAction.toggled.connect( - self.showMirroredCursorToggled + self.pxModeAction.setToolTip(pxModeTooltip) + + self.highLowResAction = widgets.CheckableAction( + "High resolution text annotations" ) + highLowResTooltip = ( + "Resolution of the text annotations. High resolution results " + "in slower update of the annotations.\n" + "Not recommended with a number of segmented objects > 500.\n\n" + ) + self.highLowResAction.setToolTip(highLowResTooltip) - # Connect Help actions - self.tipsAction.triggered.connect(self.showTipsAndTricks) - self.UserManualAction.triggered.connect( - self.app_shell.browse_docs + self.editAutoSaveIntervalAction = QAction( + "Change autosave interval (minutes or frames)...", self ) - self.openLogFileAction.triggered.connect(self.openLogFile) - self.showLogFilesAction.triggered.connect(self.showLogFiles) - self.aboutAction.triggered.connect(self.showAbout) - # Connect Open Recent to dynamically populate it - # self.openRecentMenu.aboutToShow.connect(self.populateOpenRecent) - self.checkableQButtonsGroup.buttonClicked.connect(self.uncheckQButton) - self.showPropsDockButton.sigClicked.connect(self.showPropsDockWidget) + self.editShortcutsAction = QAction("Customize keyboard shortcuts...", self) + self.editShortcutsAction.setShortcut("Ctrl+K") - self.loadCustomAnnotationsAction.triggered.connect( - self.custom_annotations_view.loadCustomAnnotations - ) - self.addCustomAnnotationAction.triggered.connect( - self.custom_annotations_view.addCustomAnnotation - ) - self.viewAllCustomAnnotAction.toggled.connect( - self.custom_annotations_view.viewAllCustomAnnot + self.showMirroredCursorAction = QAction("Show mirrored cursor on images", self) + self.showMirroredCursorAction.setCheckable(True) + if "showMirroredCursor" in self.df_settings.index: + checked = self.df_settings.at["showMirroredCursor", "value"] == "Yes" + self.showMirroredCursorAction.setChecked(checked) + else: + self.showMirroredCursorAction.setChecked(True) + self.showMirroredCursorAction.setShortcut("Ctrl+M") + + self.editTextIDsColorAction = QAction("Text annotation color...", self) + self.editTextIDsColorAction.setDisabled(True) + + self.editOverlayColorAction = QAction("Overlay color...", self) + self.editOverlayColorAction.setDisabled(True) + + self.manuallyEditCcaAction = QAction("Edit cell cycle annotations...", self) + self.manuallyEditCcaAction.setShortcut("Ctrl+Shift+P") + self.manuallyEditCcaAction.setDisabled(True) + + self.viewCcaTableAction = QAction("View cell cycle annotations...", self) + self.viewCcaTableAction.setDisabled(True) + self.viewCcaTableAction.setShortcut("Ctrl+P") + + self.addScaleBarAction = QAction("Add scale bar", self) + self.addScaleBarAction.setCheckable(True) + + self.addTimestampAction = QAction("Add timestamp", self) + self.addTimestampAction.setCheckable(True) + + self.invertBwAction = QAction("Invert black/white", self) + self.invertBwAction.setCheckable(True) + checked = self.df_settings.at["is_bw_inverted", "value"] == "Yes" + self.invertBwAction.setChecked(checked) + + self.shuffleCmapAction = QAction("Randomly shuffle colormap", self) + self.shuffleCmapAction.setShortcut("Shift+S") + + self.greedyShuffleCmapAction = QAction("Greedily shuffle colormap", self) + self.greedyShuffleCmapAction.setShortcut("Alt+Shift+S") + + self.saveLabColormapAction = QAction("Save labels colormap...", self) + + self.normalizeRawAction = QAction("Do not normalize. Display raw image", self) + self.normalizeToFloatAction = QAction( + "Convert to floating point format with values [0, 1]", self ) - self.addCustomModelVideoAction.triggered.connect( - self.showInstructionsCustomModel + # self.normalizeToUbyteAction = QAction( + # 'Rescale to 8-bit unsigned integer format with values [0, 255]', self) + self.normalizeRescale0to1Action = QAction("Rescale to [0, 1]", self) + self.normalizeByMaxAction = QAction("Normalize by max value", self) + self.normalizeRawAction.setCheckable(True) + self.normalizeToFloatAction.setCheckable(True) + # self.normalizeToUbyteAction.setCheckable(True) + self.normalizeRescale0to1Action.setCheckable(True) + self.normalizeByMaxAction.setCheckable(True) + self.normalizeQActionGroup = QActionGroup(self) + self.normalizeQActionGroup.addAction(self.normalizeRawAction) + self.normalizeQActionGroup.addAction(self.normalizeToFloatAction) + # self.normalizeQActionGroup.addAction(self.normalizeToUbyteAction) + self.normalizeQActionGroup.addAction(self.normalizeRescale0to1Action) + self.normalizeQActionGroup.addAction(self.normalizeByMaxAction) + + self.preprocessAction = QAction("Pre-processing...", self) + self.preprocessAction.setShortcut("Alt+Shift+P") + + self.combineChannelsAction = QAction( + "Combine and manipulate channels and/or segmentation files...", self ) - self.addCustomModelFrameAction.triggered.connect( - self.showInstructionsCustomModel + self.combineChannelsAction.setShortcut("Alt+Shift+C") + + self.zoomToObjsAction = QAction("Zoom to objects (Shortcut: H key)", self) + self.zoomOutAction = QAction("Zoom out (Shortcut: double press H key)", self) + + self.relabelSequentialAction = QAction("Relabel IDs sequentially...", self) + self.relabelSequentialAction.setShortcut("Ctrl+L") + self.relabelSequentialAction.setDisabled(True) + + self.setLastUserNormAction() + + self.autoSegmAction = QAction("Enable automatic segmentation", self) + self.autoSegmAction.setCheckable(True) + self.autoSegmAction.setDisabled(True) + + self.enableSmartTrackAction = QAction( + "Smart handling of enabling/disabling tracking", self ) - self.addCustomModelFrameAction.callback = self.segmFrameCallback - self.addCustomModelVideoAction.callback = self.segmVideoCallback + self.enableSmartTrackAction.setCheckable(True) + self.enableSmartTrackAction.setChecked(True) - self.addCustomPromptModelAction.triggered.connect( - self.magic_prompts_view.showInstructionsCustomPromptModel + self.enableAutoZoomToCellsAction = QAction( + 'Automatic zoom to all cells when pressing "Next/Previous"', self ) - self.segmWithPromptableModelAction.triggered.connect( - self.magic_prompts_view.segmWithPromptableModelActionTriggered + self.enableAutoZoomToCellsAction.setCheckable(True) + + self.imgPropertiesAction = QAction("Properties...", self) + self.imgPropertiesAction.setDisabled(True) + + self.addDelRoiAction = QAction(self) + self.addDelRoiAction.roiType = "rect" + self.addDelRoiAction.setIcon(QIcon(":addDelRoi.svg")) + + self.addDelPolyLineRoiButton = QToolButton(self) + self.addDelPolyLineRoiButton.setCheckable(True) + self.addDelPolyLineRoiButton.setIcon(QIcon(":addDelPolyLineRoi.svg")) + + self.checkableButtons.append(self.addDelPolyLineRoiButton) + self.LeftClickButtons.append(self.addDelPolyLineRoiButton) + + self.delBorderObjAction = QAction(self) + self.delBorderObjAction.setIcon(QIcon(":delBorderObj.svg")) + + self.delNewObjAction = QAction(self) + self.delNewObjAction.setIcon(QIcon(":delNewObj.svg")) + + self.loadCustomAnnotationsAction = QAction(self) + self.loadCustomAnnotationsAction.setIcon(QIcon(":load_annotation.svg")) + self.loadCustomAnnotationsAction.setToolTip( + "Load previously used custom annotations" ) + self.addCustomAnnotationAction = QAction(self) + self.addCustomAnnotationAction.setIcon(QIcon(":addCustomAnnotation.svg")) + self.addCustomAnnotationAction.setToolTip("Add custom annotation") + # self.functionsNotTested3D.append(self.addCustomAnnotationAction) + + self.viewAllCustomAnnotAction = QAction(self) + self.viewAllCustomAnnotAction.setCheckable(True) + self.viewAllCustomAnnotAction.setIcon(QIcon(":eye.svg")) + self.viewAllCustomAnnotAction.setToolTip("Show all custom annotations") + + def gui_updateSwitchColorSchemeActionText(self): + if self._colorScheme == "dark": + txt = "Switch to light theme" + else: + txt = "Switch to dark theme" + self.toggleColorSchemeAction.setText(txt) + def initShortcuts(self): - from cellacdc import config + from . import config + cp = config.ConfigParser() if os.path.exists(shortcut_filepath): cp.read(shortcut_filepath) - shortcuts_section = self.keyboard_shortcuts_section - delete_section = self.delete_object_section - if shortcuts_section not in cp: - cp[shortcuts_section] = {} + if "keyboard.shortcuts" not in cp: + cp["keyboard.shortcuts"] = {} - if cp.has_option(shortcuts_section, 'Zoom out'): - zoomOutKeyValueStr = cp[shortcuts_section]['Zoom out'] + if cp.has_option("keyboard.shortcuts", "Zoom out"): + zoomOutKeyValueStr = cp["keyboard.shortcuts"]["Zoom out"] try: self.zoomOutKeyValue = int(zoomOutKeyValueStr) - except Exception as err: + except Exception: self.logger.warning( - f'{zoomOutKeyValueStr} is not a valid key ' + f"{zoomOutKeyValueStr} is not a valid key " 'zooming out action. Restoring default key "H".' ) - if delete_section not in cp: + if "delete_object.action" not in cp: self.delObjAction = None else: - delObjKeySequenceText = ( - cp[delete_section][self.delete_key_option] - ) - delObjButtonText = ( - cp[delete_section][self.delete_button_option] - ) + delObjKeySequenceText = cp["delete_object.action"]["Key sequence"] + delObjButtonText = cp["delete_object.action"]["Mouse button"] delObjQtButton = ( Qt.MouseButton.LeftButton - if self.delete_object_button_is_left_click( - delObjButtonText - ) + if delObjButtonText == "Left click" else Qt.MouseButton.MiddleButton ) if not delObjKeySequenceText: delObjKeySequence = None else: - delObjKeySequence = widgets.KeySequenceFromText( - delObjKeySequenceText - ) + delObjKeySequence = widgets.KeySequenceFromText(delObjKeySequenceText) self.delObjToolAction.setChecked(True) self.delObjAction = delObjKeySequence, delObjQtButton shortcuts = {} for name, widget in self.widgetsWithShortcut.items(): - if name not in cp.options(shortcuts_section): - if hasattr(widget, 'keyPressShortcut'): + if name not in cp.options("keyboard.shortcuts"): + if hasattr(widget, "keyPressShortcut"): key = widget.keyPressShortcut shortcut = widgets.KeySequenceFromText(key) else: shortcut = widget.shortcut() shortcut_text = shortcut.toString() - cp[shortcuts_section][name] = shortcut_text + cp["keyboard.shortcuts"][name] = shortcut_text else: - shortcut_text = cp[shortcuts_section][name] + shortcut_text = cp["keyboard.shortcuts"][name] shortcut = widgets.KeySequenceFromText(shortcut_text) shortcuts[name] = (shortcut_text, shortcut) self.setShortcuts(shortcuts, save=False) - with open(shortcut_filepath, 'w') as ini: + with open(shortcut_filepath, "w") as ini: cp.write(ini) + def sanitize_key_sequence_text(self, text) -> str: + if text is None: + return "" + return str(text).encode("ascii", "ignore").decode("utf-8") + def setShortcuts(self, shortcuts: dict, save=True): for name, (text, shortcut) in shortcuts.items(): widget = self.widgetsWithShortcut[name] if shortcut is None: shortcut = QKeySequence() - if hasattr(widget, 'keyPressShortcut'): + if hasattr(widget, "keyPressShortcut"): widget.keyPressShortcut = shortcut else: widget.setShortcut(shortcut) @@ -670,398 +856,64 @@ def setShortcuts(self, shortcuts: dict, save=True): if not save: return - from cellacdc import config + from . import config + cp = config.ConfigParser() if os.path.exists(shortcut_filepath): cp.read(shortcut_filepath) - shortcuts_section = self.keyboard_shortcuts_section - delete_section = self.delete_object_section - if shortcuts_section not in cp: - cp[shortcuts_section] = {} + if "keyboard.shortcuts" not in cp: + cp["keyboard.shortcuts"] = {} for name, (text, shortcut) in shortcuts.items(): - cp[shortcuts_section][name] = text + cp["keyboard.shortcuts"][name] = text - cp[shortcuts_section]['Zoom out'] = str(self.zoomOutKeyValue) + cp["keyboard.shortcuts"]["Zoom out"] = str(self.zoomOutKeyValue) if self.delObjAction is None: - with open(shortcut_filepath, 'w') as ini: + with open(shortcut_filepath, "w") as ini: cp.write(ini) return delObjKeySequence, delObjQtButton = self.delObjAction try: if delObjKeySequence is None: - delObjKeySequenceText = '' + delObjKeySequenceText = "" else: delObjKeySequenceText = delObjKeySequence.toString() - delObjKeySequenceText = ( - self.sanitize_key_sequence_text( - delObjKeySequenceText - ) - ) - delObjButtonText = self.delete_object_button_text( - is_left_click=( - delObjQtButton == Qt.MouseButton.LeftButton - ) + delObjKeySequenceText = delObjKeySequenceText.encode( + "ascii", "ignore" + ).decode("utf-8") + delObjButtonText = ( + "Left click" + if delObjQtButton == Qt.MouseButton.LeftButton + else "Middle click" ) - cp[delete_section] = { - self.delete_key_option: delObjKeySequenceText, - self.delete_button_option: delObjButtonText + cp["delete_object.action"] = { + "Key sequence": delObjKeySequenceText, + "Mouse button": delObjButtonText, } - except Exception as err: + except Exception: self.logger.warning( - f'{delObjKeySequence} is not a valid keys sequence for ' - 'deleting objects. Setting default action' + f"{delObjKeySequence} is not a valid keys sequence for " + "deleting objects. Setting default action" ) self.delObjAction = None - cp.remove_section(delete_section) + cp.remove_section("delete_object.action") - with open(shortcut_filepath, 'w') as ini: + with open(shortcut_filepath, "w") as ini: cp.write(ini) - def editShortcuts_cb(self): - delObjKeySequenceText, delObjButtonText = ( - self.default_delete_object_texts(is_mac=is_mac) - ) - - if self.delObjAction is not None: - delObjKeySequence, delObjQtButton = self.delObjAction - if delObjKeySequence is None: - delObjKeySequenceText = '' - else: - delObjKeySequenceText = delObjKeySequence.toString() - delObjKeySequenceText = ( - self.sanitize_key_sequence_text( - delObjKeySequenceText - ) - ) - delObjButtonText = self.delete_object_button_text( - is_left_click=( - delObjQtButton == Qt.MouseButton.LeftButton - ) - ) - - win = apps.ShortcutEditorDialog( - self.widgetsWithShortcut, - delObjectKey=delObjKeySequenceText, - delObjectButton=delObjButtonText, - zoomOutKeyValue=self.zoomOutKeyValue, - parent=self.host - ) - win.exec_() - if win.cancel: - return - - self.delObjAction = win.delObjAction - self.zoomOutKeyValue = win.zoomOutKeyValue - self.setShortcuts(win.customShortcuts) - - def gui_connectEditActions(self): - self.showInExplorerAction.setEnabled(True) - self.mode_controls_view.setEnabledFileToolbar(True) - self.loadFluoAction.setEnabled(True) - self.isEditActionsConnected = True - - self.preprocessImageAction.triggered.connect( - self.preprocessAction.trigger - ) - self.combineChannelsAction.triggered.connect( - self.combineChannelsActionTriggered - ) - - self.overlayButton.toggled.connect(self.overlay_cb) - self.countObjsButton.toggled.connect(self.countObjectsCb) - self.togglePointsLayerAction.toggled.connect(self.pointsLayerToggled) - self.overlayLabelsButton.toggled.connect(self.overlayLabels_cb) - self.overlayButton.sigRightClick.connect(self.showOverlayContextMenu) - self.labelRoiButton.sigRightClick.connect(self.showLabelRoiContextMenu) - self.overlayLabelsButton.sigRightClick.connect( - self.showOverlayLabelsContextMenu - ) - self.rulerButton.toggled.connect(self.ruler_cb) - self.loadFluoAction.triggered.connect(self.loadFluo_cb) - self.loadPosAction.triggered.connect(self.loadPosTriggered) - # self.reloadAction.triggered.connect(self.reload_cb) - self.findIdAction.triggered.connect(self.object_search_view.findID) - self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) - self.autoPilotButton.toggled.connect(self.autoPilotToggled) - self.skipToNewIdAction.triggered.connect( - self.object_search_view.skipForwardToNewID - ) - self.slideshowButton.toggled.connect(self.launchSlideshow) - - self.copyLostObjButton.toggled.connect(self.copyLostObjContour_cb) - self.manualAnnotPastButton.toggled.connect( - self.manualAnnotPast_cb - ) - - self.segmSingleFrameMenu.triggered.connect(self.segmFrameCallback) - self.segmVideoMenu.triggered.connect(self.segmVideoCallback) - - self.postProcessSegmAction.toggled.connect(self.postProcessSegm) - self.autoSegmAction.toggled.connect(self.autoSegm_cb) - self.realTimeTrackingToggle.clicked.connect(self.realTimeTrackingClicked) - self.repeatTrackingAction.triggered.connect(self.repeatTracking) - self.manualTrackingButton.toggled.connect(self.manualTracking_cb) - self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) - self.repeatTrackingMenuAction.triggered.connect(self.repeatTracking) - self.repeatTrackingVideoAction.triggered.connect( - self.repeatTrackingVideo - ) - for rtTrackerAction in self.trackingAlgosGroup.actions(): - rtTrackerAction.toggled.connect(self.rtTrackerActionToggled) - self.editRtTrackerParamsAction.triggered.connect( - self.initRealTimeTracker - ) - self.delObjsOutSegmMaskAction.triggered.connect( - self.object_cleanup_view - .delete_objects_outside_mask_action_triggered - ) - self.mergeIDsButton.toggled.connect(self.mergeObjs_cb) - self.brushButton.toggled.connect(self.Brush_cb) - self.eraserButton.toggled.connect(self.Eraser_cb) - self.curvToolButton.toggled.connect( - self.curvature_tools_view.curvTool_cb - ) - self.wandToolButton.toggled.connect(self.wand_cb) - self.labelRoiButton.toggled.connect(self.labelRoi_cb) - self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) - self.drawClearRegionButton.toggled.connect( - self.draw_clear_region_view.toggle - ) - self.reInitCcaAction.triggered.connect(self.reInitCca) - self.moveLabelToolButton.toggled.connect( - self.label_transform_tools_view.move_label_button_toggled - ) - self.editCcaToolAction.triggered.connect( - self.manualEditCcaToolbarActionTriggered - ) - self.assignBudMothAutoAction.triggered.connect( - self.autoAssignBud_YeastMate - ) - self.keepIDsButton.toggled.connect(self.keepIDs_cb) - - self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) - - self.whitelistIDsToolbar.sigWhitelistChanged.connect( - self.whitelistIDsChanged - ) - - self.whitelistIDsToolbar.sigWhitelistAccepted.connect( - self.whitelistIDsAccepted - ) - - self.whitelistIDsToolbar.sigViewOGIDs.connect(self.whitelistViewOGIDs) - - self.whitelistIDsToolbar.sigAddNewIDs.connect(self.whitelistAddNewIDsToggled) - - self.whitelistIDsToolbar.sigLoadOGLabs.connect(self.whitelistLoadOGLabs_cb) - - self.whitelistIDsToolbar.sigTrackOGagainstPreviousFrame.connect( - self.whitelistTrackOGagainstPreviousFrame_cb - ) - - self.expandLabelToolButton.toggled.connect( - self.label_transform_tools_view.expand_label_callback - ) - - self.reinitLastSegmFrameAction.triggered.connect( - self.reInitLastSegmFrame - ) - - - self.defaultRescaleIntensActionGroup.triggered.connect( - self.defaultRescaleIntensLutActionToggled - ) - - # self.repeatAutoCcaAction.triggered.connect(self.repeatAutoCca) - self.manuallyEditCcaAction.triggered.connect(self.manualEditCca) - self.addScaleBarAction.toggled.connect( - self.display_decorations_view.add_scale_bar - ) - self.addTimestampAction.toggled.connect( - self.display_decorations_view.add_timestamp - ) - self.saveLabColormapAction.triggered.connect(self.saveLabelsColormap) - - self.enableSmartTrackAction.toggled.connect(self.enableSmartTrack) - # Brush/Eraser size action - self.brushSizeSpinbox.valueChanged.connect(self.brushSize_cb) - self.autoIDcheckbox.toggled.connect(self.autoIDtoggled) - # Mode - self.modeActionGroup.triggered.connect( - self.mode_controls_view.changeModeFromMenu - ) - self.modeComboBox.sigTextChanged.connect( - self.mode_controls_view.changeMode - ) - self.modeComboBox.activated.connect( - self.mode_controls_view.clearComboBoxFocus - ) - self.equalizeHistPushButton.toggled.connect(self.equalizeHist) - - self.editOverlayColorAction.triggered.connect(self.toggleOverlayColorButton) - self.editTextIDsColorAction.triggered.connect(self.toggleTextIDsColorButton) - self.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) - self.overlayColorButton.sigColorChanged.connect(self.saveOverlayColor) - self.textIDsColorButton.sigColorChanging.connect(self.updateTextAnnotColor) - self.textIDsColorButton.sigColorChanged.connect(self.saveTextIDsColors) - - self.setMeasurementsAction.triggered.connect( - self.measurements_view.show_set_measurements - ) - self.addCustomMetricAction.triggered.connect( - self.measurements_view.add_custom_metric - ) - self.addCombineMetricAction.triggered.connect( - self.measurements_view.add_combine_metric - ) - - self.labelsGrad.colorButton.sigColorChanging.connect(self.updateBkgrColor) - self.labelsGrad.colorButton.sigColorChanged.connect(self.saveBkgrColor) - self.labelsGrad.sigGradientChangeFinished.connect(self.updateLabelsCmap) - self.labelsGrad.sigGradientChanged.connect(self.ticksCmapMoved) - self.labelsGrad.textColorButton.sigColorChanging.connect( - self.updateTextLabelsColor - ) - self.labelsGrad.textColorButton.sigColorChanged.connect( - self.saveTextLabelsColor - ) - # self.addFontSizeActions( - # self.labelsGrad.fontSizeMenu, self.setFontSizeActionChecked - # ) - - self.labelsGrad.shuffleCmapAction.triggered.connect(self.shuffle_cmap) - self.labelsGrad.greedyShuffleCmapAction.triggered.connect( - self.greedyShuffleCmap - ) - self.labelsGrad.permanentGreedyCmapAction.toggled.connect( - self.permanentGreedyCmapToggled - ) - self.shuffleCmapAction.triggered.connect(self.shuffle_cmap) - self.greedyShuffleCmapAction.triggered.connect(self.greedyShuffleCmap) - self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) - self.labelsGrad.sigShowLabelsImgToggled.connect(self.showLabelImageItem) - self.labelsGrad.sigShowRightImgToggled.connect(self.showRightImageItem) - self.labelsGrad.sigShowNextFrameToggled.connect(self.showNextFrameImageItem) - - self.labelsGrad.defaultSettingsAction.triggered.connect( - self.restoreDefaultSettings - ) - - # self.addFontSizeActions( - # self.imgGrad.fontSizeMenu, self.setFontSizeActionChecked - # ) - self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) - self.imgGrad.textColorButton.disconnect() - self.imgGrad.textColorButton.clicked.connect( - self.editTextIDsColorAction.trigger - ) - self.imgGrad.labelsAlphaSlider.valueChanged.connect( - self.updateLabelsAlpha - ) - self.imgGrad.defaultSettingsAction.triggered.connect( - self.restoreDefaultSettings - ) - - # Drawing mode - self.drawIDsContComboBox.currentIndexChanged.connect( - self.drawIDsContComboBox_cb - ) - self.drawIDsContComboBox.activated.connect( - self.mode_controls_view.clearComboBoxFocus - ) - - self.annotateRightHowCombobox.currentIndexChanged.connect( - self.annotateRightHowCombobox_cb - ) - self.annotateRightHowCombobox.activated.connect( - self.mode_controls_view.clearComboBoxFocus - ) - - self.showTreeInfoCheckbox.toggled.connect(self.setAnnotInfoMode) - - # Left - self.annotIDsCheckbox.clicked.connect(self.annotOptionClicked) - self.annotCcaInfoCheckbox.clicked.connect(self.annotOptionClicked) - self.annotContourCheckbox.clicked.connect(self.annotOptionClicked) - self.annotSegmMasksCheckbox.clicked.connect(self.annotOptionClicked) - self.drawMothBudLinesCheckbox.clicked.connect(self.annotOptionClicked) - self.drawNothingCheckbox.clicked.connect(self.annotOptionClicked) - self.annotNumZslicesCheckbox.clicked.connect(self.annotOptionClicked) - - # Right - self.annotIDsCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotCcaInfoCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotContourCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotSegmMasksCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.drawMothBudLinesCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.drawNothingCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotNumZslicesCheckboxRight.clicked.connect( - self.annotOptionClickedRight - ) - - self.segmentToolAction.triggered.connect(self.segmentToolActionTriggered) - - self.addDelRoiAction.triggered.connect(self.addDelROI) - self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) - self.delBorderObjAction.triggered.connect(self.delBorderObj) - self.delNewObjAction.triggered.connect(self.delNewObj) - - self.brushAutoFillCheckbox.toggled.connect(self.brushAutoFillToggled) - self.brushAutoHideCheckbox.toggled.connect(self.brushAutoHideToggled) - - self.imgGrad.sigAddScaleBar.connect(self.addScaleBarAction.setChecked) - self.imgGrad.sigAddTimestamp.connect(self.addTimestampAction.setChecked) - self.imgGrad.gradient.sigGradientChangeFinished.connect( - self.imgGradLUTfinished_cb - ) - - # self.normalizeQActionGroup.triggered.connect( - # self.normaliseIntensitiesActionTriggered - # ) - self.imgPropertiesAction.triggered.connect(self.editImgProperties) - - self.relabelSequentialAction.triggered.connect( - self.relabelSequentialCallback - ) - - self.zoomToObjsAction.triggered.connect(self.zoomToObjsActionCallback) - self.zoomOutAction.triggered.connect(self.zoomOut) - self.preprocessAction.triggered.connect( - self.preprocessing_view.preprocessActionTriggered - ) - self.combineChannelsAction.triggered.connect(self.combineChannelsActionTriggered) - - self.viewCcaTableAction.triggered.connect(self.viewCcaTable) - - self.guiTabControl.propsQGBox.idSB.valueChanged.connect( - self.propsWidgetIDvalueChanged - ) - self.guiTabControl.highlightCheckbox.toggled.connect( - self.highlightIDonHoverCheckBoxToggled - ) - self.guiTabControl.highlightSearchedCheckbox.toggled.connect( - self.highlightSearchedIDcheckBoxToggled - ) - intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox - intensMeasurQGBox.additionalMeasCombobox.currentTextChanged.connect( - self.updatePropsWidget - ) - intensMeasurQGBox.channelCombobox.currentTextChanged.connect( - self.updatePropsWidget - ) + def should_restore_default_delete_action(self, *, had_error: bool) -> bool: + return had_error - propsQGBox = self.guiTabControl.propsQGBox - propsQGBox.additionalPropsCombobox.currentTextChanged.connect( - self.updatePropsWidget - ) \ No newline at end of file + LEGACY_METHODS = ( + "gui_createActions", + "gui_updateSwitchColorSchemeActionText", + "gui_connectActions", + "initShortcuts", + "setShortcuts", + "editShortcuts_cb", + "gui_connectEditActions", + ) diff --git a/cellacdc/mixins/annotation_display.py b/cellacdc/mixins/annotation_display.py index f6d70751b..16cb60a13 100644 --- a/cellacdc/mixins/annotation_display.py +++ b/cellacdc/mixins/annotation_display.py @@ -6,108 +6,166 @@ from cellacdc import _palettes, apps, html_utils, widgets -from dataclasses import dataclass -from typing import Literal, Mapping +from typing import Mapping + GREEN_HEX = _palettes.green() -class AnnotationDisplayView: +class AnnotationDisplayMixin: """Qt-facing adapter around annotation display and tool state.""" - LEGACY_METHODS = ( - 'getAnnotateHowRightImage', - 'activateAnnotations', - 'gui_raiseBottomLayoutContextMenu', - 'annotateRightHowCombobox_cb', - 'drawIDsContComboBox_cb', - 'areContoursRequested', - 'areMothBudLinesRequested', - 'getMothBudLineScatterItem', - 'drawAllMothBudLines', - 'drawObjMothBudLines', - 'clearAllCellToCellLines', - 'drawAllLineageTreeLines', - 'drawObjLin_TreeMothBudLines', - 'getObjCentroid', - 'getObjOptsSegmLabels', - 'update_rp_metadata', - 'annotate_rip_and_bin_IDs', - 'clearAnnotItems', - 'setAllTextAnnotations', - 'labelRoiIsCircularRadioButtonToggled', - 'pxModeActionToggled', - 'changeTextResolution', - 'highLowResToggled', - 'annotGenNumTreeToggled', - 'annotLabelIDtreeToggled', - 'setAnnotInfoMode', - 'annotOptionClicked', - 'setDisabledAnnotCheckBoxesLeft', - 'setEnabledAnnotCheckBoxesLeftZdepthAxes', - 'setDisabledAnnotCheckBoxesRight', - 'annotOptionClickedRight', - 'setAnnotOptionsCcaMode', - 'setAnnotOptionsLin_treeMode', - 'setDrawAnnotComboboxText', - 'setDrawAnnotComboboxTextRight', - 'relabelSequentialCallback', - 'updateAnnotatedIDs', - 'rtTrackerActionToggled', - 'autoPilotToggled', - 'storeCurrentAnnotOptions_ax1', - 'storeCurrentAnnotOptions_ax2', - 'restoreAnnotOptions_ax1', - 'restoreAnnotOptions_ax2', - 'setDrawNothingAnnotations', - 'restoreAnnotationsOptions', - 'onDoubleSpaceBar', - 'zoomRectActionToggled', - 'zoomRectDone', - 'zoomRectCancelled', - 'keepToolActiveActionToggled', - 'applyToolNewFrameActionToggled', - 'keepAllToolsActiveActionToggled', - 'setVisible3DsegmWidgets', - 'showHighlightZneighCheckbox', - 'highlightZneighLabels_cb', - 'restoreSavedSettings', - 'uncheckAnnotOptions', - 'setDisabledAnnotOptions', - 'drawAnnotCombobox_to_options', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) self._connect_view_model_signals() - - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) + # @exec_time + + # @exec_time + + def _annotation_clicked_option(self, side, sender): + for name, widget in self._annotation_option_widgets(side).items(): + if sender == widget: + return name + return None + + def _annotation_option_state(self, side): + widgets = self._annotation_option_widgets(side) + return {name: widget.isChecked() for name, widget in widgets.items()} + + def _annotation_option_widgets(self, side): + if side == "right": + return { + "ids": self.annotIDsCheckboxRight, + "cca": self.annotCcaInfoCheckboxRight, + "contours": self.annotContourCheckboxRight, + "segm_masks": self.annotSegmMasksCheckboxRight, + "mother_bud_lines": self.drawMothBudLinesCheckboxRight, + "num_zslices": self.annotNumZslicesCheckboxRight, + "nothing": self.drawNothingCheckboxRight, + } + return { + "ids": self.annotIDsCheckbox, + "cca": self.annotCcaInfoCheckbox, + "contours": self.annotContourCheckbox, + "segm_masks": self.annotSegmMasksCheckbox, + "mother_bud_lines": self.drawMothBudLinesCheckbox, + "num_zslices": self.annotNumZslicesCheckbox, + "nothing": self.drawNothingCheckbox, + } + + def _apply_add_new_ids_whitelist_toggle(self, checked): + self.addNewIDsWhitelistToggle = checked + + def _apply_annotation_mode_combobox_restore(self, side, text): + if side == "right": + self.annotateRightHowCombobox.setCurrentText(text) + else: + self.drawIDsContComboBox.setCurrentText(text) + + def _apply_annotation_mode_restore_callback(self, side): + if side == "right": + self.annotateRightHowCombobox_cb(0) + else: + self.drawIDsContComboBox_cb(0) + + def _apply_annotation_mode_text_update( + self, + side, + text, + save_settings, + ): + if side == "right": + combo = self.annotateRightHowCombobox + callback = self.annotateRightHowCombobox_cb + else: + combo = self.drawIDsContComboBox + callback = self.drawIDsContComboBox_cb + + if text == combo.currentText(): + callback(0) + + combo.saveSettings = save_settings + combo.setCurrentText(text) + + def _apply_annotation_option_checked(self, side, option, checked): + widgets = self._annotation_option_widgets(side) + widgets[option].setChecked(checked) + + def _apply_annotation_option_disabled(self, side, option, disabled): + widgets = self._annotation_option_widgets(side) + widgets[option].setDisabled(disabled) + + def _apply_annotation_option_states(self, side, state): + widgets = self._annotation_option_widgets(side) + widgets["ids"].setChecked(state.ids) + widgets["cca"].setChecked(state.cca) + widgets["contours"].setChecked(state.contours) + widgets["segm_masks"].setChecked(state.segm_masks) + widgets["mother_bud_lines"].setChecked(state.mother_bud_lines) + widgets["num_zslices"].setChecked(state.num_zslices) + widgets["nothing"].setChecked(state.nothing) + + def _apply_annotation_option_visible(self, side, option, visible): + widgets = self._annotation_option_widgets(side) + widgets[option].setVisible(visible) + + def _apply_gen_num_tree_annotations_enabled(self, checked): + self.textAnnot[0].setGenNumTreeAnnotationsEnabled(checked) + + def _apply_label_tree_annotations_enabled(self, checked): + self.textAnnot[0].setLabelTreeAnnotationsEnabled(checked) + + def _apply_text_annotation_flags( + self, + ax, + is_cca_annotation, + is_id_annotation, + ): + self.textAnnot[ax].setCcaAnnot(is_cca_annotation) + self.textAnnot[ax].setLabelAnnot(is_id_annotation) + + def _apply_text_annotation_pixel_mode(self, checked): + for ax in range(2): + self.textAnnot[ax].setPxMode(checked) + + def _apply_text_resolution_change(self, mode): + self.setAllIDs() + posData = self.data[self.pos_i] + allIDs = posData.allIDs + img_shape = self.img1.image.shape[:2] + self.textAnnot[0].changeResolution(mode, allIDs, self.ax1, img_shape) + self.textAnnot[1].changeResolution(mode, allIDs, self.ax2, img_shape) + + def _apply_tree_annotation_menu_action( + self, + menu_name, + text, + should_contain_text, + checked, + ): + if menu_name == "id": + menu = self.annotSettingsIDmenu else: - setattr(self.host, name, value) + menu = self.annotSettingsGenNumMenu + + for action in menu.actions(): + text_found = action.text().find(text) != -1 + if text_found == should_contain_text: + action.setChecked(checked) + break + + def _apply_view_model_setting_update(self, setting, value): + self.df_settings.at[setting, "value"] = value + self.df_settings.to_csv(self.settings_csv_path) + + def _apply_z_neighbor_highlight_checked(self, checked): + self.highlightZneighObjCheckbox.setChecked(checked) - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + def _apply_z_neighbor_highlight_visible(self, visible): + self.highlightZneighObjCheckbox.setVisible(visible) def _connect_view_model_signals(self): - self.settingUpdateRequested.connect( - self._apply_view_model_setting_update - ) - self.textAnnotationFlagsChanged.connect( - self._apply_text_annotation_flags - ) - self.imageRefreshRequested.connect( - self._refresh_images_from_view_model - ) - self.eraserTempResetRequested.connect( - self._reset_eraser_temp_from_view_model - ) - self.annotationOptionStatesChanged.connect( - self._apply_annotation_option_states - ) + self.settingUpdateRequested.connect(self._apply_view_model_setting_update) + self.textAnnotationFlagsChanged.connect(self._apply_text_annotation_flags) + self.imageRefreshRequested.connect(self._refresh_images_from_view_model) + self.eraserTempResetRequested.connect(self._reset_eraser_temp_from_view_model) + self.annotationOptionStatesChanged.connect(self._apply_annotation_option_states) self.annotationModeTextUpdateRequested.connect( self._apply_annotation_mode_text_update ) @@ -115,12 +173,8 @@ def _connect_view_model_signals(self): self._apply_text_annotation_pixel_mode ) self.logInfoRequested.connect(self.logger.info) - self.pixelModeActionDisabledChanged.connect( - self.pxModeAction.setDisabled - ) - self.textResolutionChangeRequested.connect( - self._apply_text_resolution_change - ) + self.pixelModeActionDisabledChanged.connect(self.pxModeAction.setDisabled) + self.textResolutionChangeRequested.connect(self._apply_text_resolution_change) self.treeAnnotationMenuActionRequested.connect( self._apply_tree_annotation_menu_action ) @@ -130,9 +184,7 @@ def _connect_view_model_signals(self): self.genNumTreeAnnotationsEnabledChanged.connect( self._apply_gen_num_tree_annotations_enabled ) - self.allTextAnnotationsRefreshRequested.connect( - self.setAllTextAnnotations - ) + self.allTextAnnotationsRefreshRequested.connect(self.setAllTextAnnotations) self.annotationOptionDisabledChanged.connect( self._apply_annotation_option_disabled ) @@ -161,18 +213,8 @@ def _connect_view_model_signals(self): self._apply_annotation_mode_restore_callback ) - def _apply_view_model_setting_update(self, setting, value): - self.df_settings.at[setting, 'value'] = value - self.df_settings.to_csv(self.settings_csv_path) - - def _apply_text_annotation_flags( - self, - ax, - is_cca_annotation, - is_id_annotation, - ): - self.textAnnot[ax].setCcaAnnot(is_cca_annotation) - self.textAnnot[ax].setLabelAnnot(is_id_annotation) + def _connect_z_neighbor_highlight_toggle(self): + self.highlightZneighObjCheckbox.toggled.connect(self.highlightZneighLabels_cb) def _refresh_images_from_view_model(self): self.updateAllImages() @@ -180,319 +222,237 @@ def _refresh_images_from_view_model(self): def _reset_eraser_temp_from_view_model(self): self.setTempImg1Eraser(None, init=True) - def _apply_text_annotation_pixel_mode(self, checked): - for ax in range(2): - self.textAnnot[ax].setPxMode(checked) - - def _apply_text_resolution_change(self, mode): - self.setAllIDs() - posData = self.data[self.pos_i] - allIDs = posData.allIDs - img_shape = self.img1.image.shape[:2] - self.textAnnot[0].changeResolution(mode, allIDs, self.ax1, img_shape) - self.textAnnot[1].changeResolution(mode, allIDs, self.ax2, img_shape) + def activateAnnotations(self): + if self.annotContourCheckbox.isChecked(): + return + if self.annotSegmMasksCheckbox.isChecked(): + return - def _apply_label_tree_annotations_enabled(self, checked): - self.textAnnot[0].setLabelTreeAnnotationsEnabled(checked) + self.annotSegmMasksCheckbox.setChecked(True) + self.setDrawAnnotComboboxText() - def _apply_gen_num_tree_annotations_enabled(self, checked): + def annotGenNumTreeToggled(self, checked): self.textAnnot[0].setGenNumTreeAnnotationsEnabled(checked) - def _apply_tree_annotation_menu_action( - self, - menu_name, - text, - should_contain_text, - checked, - ): - if menu_name == 'id': - menu = self.annotSettingsIDmenu - else: - menu = self.annotSettingsGenNumMenu - - for action in menu.actions(): - text_found = action.text().find(text) != -1 - if text_found == should_contain_text: - action.setChecked(checked) - break - - def _annotation_option_widgets(self, side): - if side == 'right': - return { - 'ids': self.annotIDsCheckboxRight, - 'cca': self.annotCcaInfoCheckboxRight, - 'contours': self.annotContourCheckboxRight, - 'segm_masks': self.annotSegmMasksCheckboxRight, - 'mother_bud_lines': self.drawMothBudLinesCheckboxRight, - 'num_zslices': self.annotNumZslicesCheckboxRight, - 'nothing': self.drawNothingCheckboxRight, - } - return { - 'ids': self.annotIDsCheckbox, - 'cca': self.annotCcaInfoCheckbox, - 'contours': self.annotContourCheckbox, - 'segm_masks': self.annotSegmMasksCheckbox, - 'mother_bud_lines': self.drawMothBudLinesCheckbox, - 'num_zslices': self.annotNumZslicesCheckbox, - 'nothing': self.drawNothingCheckbox, - } + def annotLabelIDtreeToggled(self, checked): + self.textAnnot[0].setLabelTreeAnnotationsEnabled(checked) - def _annotation_option_state(self, side): - widgets = self._annotation_option_widgets(side) - return { - name: widget.isChecked() - for name, widget in widgets.items() - } + def annotOptionClicked(self, clicked=True, sender=None, saveSettings=True): + if sender is None: + sender = self.sender() + # First manually set exclusive with uncheckable + clickedIDs = sender == self.annotIDsCheckbox + clickedCca = sender == self.annotCcaInfoCheckbox + clickedMBline = sender == self.drawMothBudLinesCheckbox + if self.annotIDsCheckbox.isChecked() and clickedIDs: + if self.annotCcaInfoCheckbox.isChecked(): + self.annotCcaInfoCheckbox.setChecked(False) + if self.drawMothBudLinesCheckbox.isChecked(): + self.drawMothBudLinesCheckbox.setChecked(False) + + if self.annotCcaInfoCheckbox.isChecked() and clickedCca: + if self.annotIDsCheckbox.isChecked(): + self.annotIDsCheckbox.setChecked(False) + if self.drawMothBudLinesCheckbox.isChecked(): + self.drawMothBudLinesCheckbox.setChecked(False) + + if self.drawMothBudLinesCheckbox.isChecked() and clickedMBline: + if self.annotIDsCheckbox.isChecked(): + self.annotIDsCheckbox.setChecked(False) + if self.annotCcaInfoCheckbox.isChecked(): + self.annotCcaInfoCheckbox.setChecked(False) + + clickedCont = sender == self.annotContourCheckbox + clickedSegm = sender == self.annotSegmMasksCheckbox + if self.annotContourCheckbox.isChecked() and clickedCont: + if self.annotSegmMasksCheckbox.isChecked(): + self.annotSegmMasksCheckbox.setChecked(False) + + if self.annotSegmMasksCheckbox.isChecked() and clickedSegm: + if self.annotContourCheckbox.isChecked(): + self.annotContourCheckbox.setChecked(False) + + clickedDoNot = sender == self.drawNothingCheckbox + if clickedDoNot: + self.annotIDsCheckbox.setChecked(False) + self.annotCcaInfoCheckbox.setChecked(False) + self.annotContourCheckbox.setChecked(False) + self.annotSegmMasksCheckbox.setChecked(False) + self.drawMothBudLinesCheckbox.setChecked(False) + self.annotNumZslicesCheckbox.setChecked(False) + else: + self.drawNothingCheckbox.setChecked(False) - def _annotation_clicked_option(self, side, sender): - for name, widget in self._annotation_option_widgets(side).items(): - if sender == widget: - return name - return None + if sender == self.annotNumZslicesCheckbox: + self.annotIDsCheckbox.setChecked(True) + self.drawNothingCheckbox.setChecked(False) - def _apply_annotation_option_states(self, side, state): - widgets = self._annotation_option_widgets(side) - widgets['ids'].setChecked(state.ids) - widgets['cca'].setChecked(state.cca) - widgets['contours'].setChecked(state.contours) - widgets['segm_masks'].setChecked(state.segm_masks) - widgets['mother_bud_lines'].setChecked(state.mother_bud_lines) - widgets['num_zslices'].setChecked(state.num_zslices) - widgets['nothing'].setChecked(state.nothing) + self.setDrawAnnotComboboxText(saveSettings=saveSettings) - def _apply_annotation_option_disabled(self, side, option, disabled): - widgets = self._annotation_option_widgets(side) - widgets[option].setDisabled(disabled) + def annotOptionClickedRight(self, clicked=True, sender=None, saveSettings=True): + if sender is None: + sender = self.sender() + # First manually set exclusive with uncheckable + clickedIDs = sender == self.annotIDsCheckboxRight + clickedCca = sender == self.annotCcaInfoCheckboxRight + clickedMBline = sender == self.drawMothBudLinesCheckboxRight + if self.annotIDsCheckboxRight.isChecked() and clickedIDs: + if self.annotCcaInfoCheckboxRight.isChecked(): + self.annotCcaInfoCheckboxRight.setChecked(False) + if self.drawMothBudLinesCheckboxRight.isChecked(): + self.drawMothBudLinesCheckboxRight.setChecked(False) + + if self.annotCcaInfoCheckboxRight.isChecked() and clickedCca: + if self.annotIDsCheckboxRight.isChecked(): + self.annotIDsCheckboxRight.setChecked(False) + if self.drawMothBudLinesCheckboxRight.isChecked(): + self.drawMothBudLinesCheckboxRight.setChecked(False) + + if self.drawMothBudLinesCheckboxRight.isChecked() and clickedMBline: + if self.annotIDsCheckboxRight.isChecked(): + self.annotIDsCheckboxRight.setChecked(False) + if self.annotCcaInfoCheckboxRight.isChecked(): + self.annotCcaInfoCheckboxRight.setChecked(False) + + clickedCont = sender == self.annotContourCheckboxRight + clickedSegm = sender == self.annotSegmMasksCheckboxRight + if self.annotContourCheckboxRight.isChecked() and clickedCont: + if self.annotSegmMasksCheckboxRight.isChecked(): + self.annotSegmMasksCheckboxRight.setChecked(False) + + if self.annotSegmMasksCheckboxRight.isChecked() and clickedSegm: + if self.annotContourCheckboxRight.isChecked(): + self.annotContourCheckboxRight.setChecked(False) + + clickedDoNot = sender == self.drawNothingCheckboxRight + if clickedDoNot: + self.annotIDsCheckboxRight.setChecked(False) + self.annotCcaInfoCheckboxRight.setChecked(False) + self.annotContourCheckboxRight.setChecked(False) + self.annotSegmMasksCheckboxRight.setChecked(False) + self.drawMothBudLinesCheckboxRight.setChecked(False) + self.annotNumZslicesCheckboxRight.setChecked(False) + else: + self.drawNothingCheckboxRight.setChecked(False) - def _apply_annotation_option_visible(self, side, option, visible): - widgets = self._annotation_option_widgets(side) - widgets[option].setVisible(visible) + if sender == self.annotNumZslicesCheckboxRight: + self.annotIDsCheckboxRight.setChecked(True) + self.drawNothingCheckboxRight.setChecked(False) - def _apply_annotation_option_checked(self, side, option, checked): - widgets = self._annotation_option_widgets(side) - widgets[option].setChecked(checked) + self.setDrawAnnotComboboxTextRight(saveSettings=saveSettings) - def _apply_z_neighbor_highlight_visible(self, visible): - self.highlightZneighObjCheckbox.setVisible(visible) + def annotateRightHowCombobox_cb(self, idx): + how = self.annotateRightHowCombobox.currentText() + saveSettings = True + if hasattr(self.annotateRightHowCombobox, "saveSettings"): + saveSettings = self.annotateRightHowCombobox.saveSettings - def _apply_z_neighbor_highlight_checked(self, checked): - self.highlightZneighObjCheckbox.setChecked(checked) + if saveSettings: + self.df_settings.at["how_draw_right_annotations", "value"] = how + self.df_settings.to_csv(self.settings_csv_path) - def _connect_z_neighbor_highlight_toggle(self): - self.highlightZneighObjCheckbox.toggled.connect( - self.highlightZneighLabels_cb + mode = self.modeComboBox.currentText() + isCcaAnnot = ( + self.annotCcaInfoCheckboxRight.isChecked() + and mode != "Normal division: Lineage tree" ) + isIDAnnot = self.annotIDsCheckboxRight.isChecked() or ( + self.annotCcaInfoCheckboxRight.isChecked() + and mode == "Normal division: Lineage tree" + ) + self.textAnnot[1].setCcaAnnot(isCcaAnnot) - def _apply_annotation_mode_combobox_restore(self, side, text): - if side == 'right': - self.annotateRightHowCombobox.setCurrentText(text) - else: - self.drawIDsContComboBox.setCurrentText(text) + self.textAnnot[1].setLabelAnnot(isIDAnnot) + if not self.isDataLoading: + self.updateAllImages() - def _apply_add_new_ids_whitelist_toggle(self, checked): - self.addNewIDsWhitelistToggle = checked + def annotate_rip_and_bin_IDs(self, updateLabel=False): + depthAxes = self.switchPlaneCombobox.depthAxes() + if self.switchPlaneCombobox.isEnabled() and depthAxes != "z": + return - def _apply_annotation_mode_restore_callback(self, side): - if side == 'right': - self.annotateRightHowCombobox_cb(0) - else: - self.drawIDsContComboBox_cb(0) + posData = self.data[self.pos_i] + binnedIDs_xx = [] + binnedIDs_yy = [] + ripIDs_xx = [] + ripIDs_yy = [] + for obj in posData.rp: + obj.excluded = obj.label in posData.binnedIDs + obj.dead = obj.label in posData.ripIDs + if not self.isObjVisible(obj.bbox): + continue - def _apply_annotation_mode_text_update( - self, - side, - text, - save_settings, - ): - if side == 'right': - combo = self.annotateRightHowCombobox - callback = self.annotateRightHowCombobox_cb - else: - combo = self.drawIDsContComboBox - callback = self.drawIDsContComboBox_cb - - if text == combo.currentText(): - callback(0) - - combo.saveSettings = save_settings - combo.setCurrentText(text) - - def getAnnotateHowRightImage(self): - return self.right_annotation_mode( - show_right_image=self.labelsGrad.showRightImgAction.isChecked(), - use_right_specific_mode=self.rightBottomGroupbox.isChecked(), - right_mode=self.annotateRightHowCombobox.currentText(), - left_mode=self.drawIDsContComboBox.currentText(), - ) - - def activateAnnotations(self): - if self.annotContourCheckbox.isChecked(): - return - if self.annotSegmMasksCheckbox.isChecked(): - return - - self.annotSegmMasksCheckbox.setChecked(True) - self.setDrawAnnotComboboxText() - - def gui_raiseBottomLayoutContextMenu(self, event): - try: - # Convert QPointF to QPoint - self.bottomLayoutContextMenu.popup(event.globalPos().toPoint()) - except AttributeError: - self.bottomLayoutContextMenu.popup(event.globalPos()) - - def annotateRightHowCombobox_cb(self, idx): - how = self.annotateRightHowCombobox.currentText() - saveSettings = True - if hasattr(self.annotateRightHowCombobox, 'saveSettings'): - saveSettings = self.annotateRightHowCombobox.saveSettings - - self.change_annotation_mode( - side='right', - how=how, - save_settings=saveSettings, - annot_cca_checked=self.annotCcaInfoCheckboxRight.isChecked(), - annot_ids_checked=self.annotIDsCheckboxRight.isChecked(), - mode=self.modeComboBox.currentText(), - is_data_loading=self.isDataLoading, - ) - - def drawIDsContComboBox_cb(self, idx): - how = self.drawIDsContComboBox.currentText() - saveSettings = True - if hasattr(self.drawIDsContComboBox, 'saveSettings'): - saveSettings = self.drawIDsContComboBox.saveSettings - - self.change_annotation_mode( - side='left', - how=how, - save_settings=saveSettings, - annot_cca_checked=self.annotCcaInfoCheckbox.isChecked(), - annot_ids_checked=self.annotIDsCheckbox.isChecked(), - mode=self.modeComboBox.currentText(), - is_data_loading=self.isDataLoading, - eraser_checked=self.eraserButton.isChecked(), - ) - - def areContoursRequested(self, ax): - return self.contours_requested( - ax=ax, - left_contours=self.annotContourCheckbox.isChecked(), - right_image_visible=self.labelsGrad.showRightImgAction.isChecked(), - right_specific_mode=self.rightBottomGroupbox.isChecked(), - right_contours=self.annotContourCheckboxRight.isChecked(), - ) - - def areMothBudLinesRequested(self, ax): - return self.moth_bud_lines_requested( - ax=ax, - left_cca=self.annotCcaInfoCheckbox.isChecked(), - left_mother_bud_lines=self.drawMothBudLinesCheckbox.isChecked(), - right_image_visible=self.labelsGrad.showRightImgAction.isChecked(), - right_specific_mode=self.rightBottomGroupbox.isChecked(), - right_cca=self.annotCcaInfoCheckboxRight.isChecked(), - right_mother_bud_lines=( - self.drawMothBudLinesCheckboxRight.isChecked() - ), - ) - - def getMothBudLineScatterItem(self, ax, new): - if ax == 0: - if new: - return self.ax1_newMothBudLinesItem - else: - return self.ax1_oldMothBudLinesItem - else: - if new: - return self.ax2_newMothBudLinesItem - else: - return self.ax2_oldMothBudLinesItem - - def drawAllMothBudLines(self): - posData = self.data[self.pos_i] - for obj in posData.rp: - self.drawObjMothBudLines(obj, posData, ax=0) - self.drawObjMothBudLines(obj, posData, ax=1) - - def drawObjMothBudLines(self, obj, posData, ax=0): - areMothBudLinesRequested = self.areMothBudLinesRequested(ax) - if not areMothBudLinesRequested: - return - - mode = str(self.modeComboBox.currentText()) - - if posData.cca_df is None: - return - - ID = obj.label - try: - cca_df_ID = posData.cca_df.loc[ID] - except KeyError: - return - - ccs_ID = cca_df_ID['cell_cycle_stage'] - relationship = cca_df_ID['relationship'] - if not self.should_draw_moth_bud_line( - cca_df_available=posData.cca_df is not None, - mode=mode, - object_visible=self.isObjVisible(obj.bbox), - cell_cycle_stage=ccs_ID, - relationship=relationship, - ): - return - - emerg_frame_i = cca_df_ID['emerg_frame_i'] - isNew = emerg_frame_i == posData.frame_i - scatterItem = self.getMothBudLineScatterItem(ax, isNew) - relative_ID = cca_df_ID['relative_ID'] - - try: - relative_rp_idx = posData.IDs_idxs[relative_ID] - except KeyError: - return - - relative_ID_obj = posData.rp[relative_rp_idx] - y1, x1 = self.getObjCentroid(obj.centroid) - y2, x2 = self.getObjCentroid(relative_ID_obj.centroid) - xx, yy = self.geometry.line_coords( - y1, x1, y2, x2, dashed=True - ) - scatterItem.addPoints(xx, yy) - - def clearAllCellToCellLines(self): - self.ax1_newMothBudLinesItem.setData([], []) - self.ax1_oldMothBudLinesItem.setData([], []) - self.ax2_newMothBudLinesItem.setData([], []) - self.ax2_oldMothBudLinesItem.setData([], []) + if obj.excluded: + y, x = self.getObjCentroid(obj.centroid) + binnedIDs_xx.append(x) + binnedIDs_yy.append(y) + if updateLabel: + self.getObjOptsSegmLabels(obj) + self.drawIDsContComboBox.currentText() - def drawAllLineageTreeLines(self): - """ + if obj.dead: + y, x = self.getObjCentroid(obj.centroid) + ripIDs_xx.append(x) + ripIDs_yy.append(y) + if updateLabel: + self.getObjOptsSegmLabels(obj) + self.drawIDsContComboBox.currentText() - """Headless annotation display decisions.""" + self.ax2_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) + self.ax2_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) + self.ax1_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) + self.ax1_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) - def right_annotation_mode( - self, - *, - show_right_image: bool, - use_right_specific_mode: bool, - right_mode: str, - left_mode: str, - ) -> str: - if not show_right_image: - return 'nothing' - return right_mode if use_right_specific_mode else left_mode + def annotation_flags_from_mode_text(self, text: str) -> dict[str, bool]: + return { + "ids": "IDs" in text, + "cca": "cell cycle info" in text, + "contours": "contours" in text, + "segm_masks": "segm. masks" in text, + "mother_bud_lines": "mother-bud lines" in text, + "nothing": "nothing" in text, + } - def text_annotation_flags( + def annotation_mode_change_plan( self, *, + side: AnnotationSide, + how: str, + save_settings: bool, annot_cca_checked: bool, annot_ids_checked: bool, mode: str, - ) -> tuple[bool, bool]: - is_lineage_mode = mode == 'Normal division: Lineage tree' - is_cca = annot_cca_checked and not is_lineage_mode - is_id = annot_ids_checked or (annot_cca_checked and is_lineage_mode) - return is_cca, is_id + is_data_loading: bool, + eraser_checked: bool = False, + ) -> AnnotationModeChangePlan: + setting_update = None + if save_settings: + setting_update = self.annotation_mode_setting_update(side, how) + + is_cca, is_id = self.text_annotation_flags( + annot_cca_checked=annot_cca_checked, + annot_ids_checked=annot_ids_checked, + mode=mode, + ) + return AnnotationModeChangePlan( + side=side, + setting_update=setting_update, + text_annotation_index=1 if side == "right" else 0, + is_cca_annotation=is_cca, + is_id_annotation=is_id, + should_refresh_images=not is_data_loading, + should_reset_eraser_temp=side == "left" and eraser_checked, + ) + + def annotation_mode_setting_update( + self, + side: AnnotationSide, + how: str, + ) -> tuple[str, str]: + setting = ( + "how_draw_right_annotations" if side == "right" else "how_draw_annotations" + ) + return setting, how def annotation_mode_text( self, @@ -506,36 +466,91 @@ def annotation_mode_text( ) -> str: if ids: if contours: - return 'Draw IDs and contours' + return "Draw IDs and contours" if segm_masks: - return 'Draw IDs and overlay segm. masks' - return 'Draw only IDs' + return "Draw IDs and overlay segm. masks" + return "Draw only IDs" if cca: if contours: - return 'Draw cell cycle info and contours' + return "Draw cell cycle info and contours" if segm_masks: - return 'Draw cell cycle info and overlay segm. masks' - return 'Draw only cell cycle info' + return "Draw cell cycle info and overlay segm. masks" + return "Draw only cell cycle info" if segm_masks: - return 'Draw only overlay segm. masks' + return "Draw only overlay segm. masks" if contours: - return 'Draw only contours' + return "Draw only contours" if mother_bud_lines: - return 'Draw only mother-bud lines' + return "Draw only mother-bud lines" if nothing: - return 'Draw nothing' - return 'Draw nothing' + return "Draw nothing" + return "Draw nothing" - def annotation_flags_from_mode_text(self, text: str) -> dict[str, bool]: - return { - 'ids': 'IDs' in text, - 'cca': 'cell cycle info' in text, - 'contours': 'contours' in text, - 'segm_masks': 'segm. masks' in text, - 'mother_bud_lines': 'mother-bud lines' in text, - 'nothing': 'nothing' in text, + def annotation_option_change_plan( + self, + *, + side: AnnotationSide, + state: AnnotationOptionState, + clicked_option: AnnotationOption | None, + save_settings: bool, + ) -> AnnotationOptionChangePlan: + values = { + "ids": state.ids, + "cca": state.cca, + "contours": state.contours, + "segm_masks": state.segm_masks, + "mother_bud_lines": state.mother_bud_lines, + "num_zslices": state.num_zslices, + "nothing": state.nothing, } + if values["ids"] and clicked_option == "ids": + values["cca"] = False + values["mother_bud_lines"] = False + + if values["cca"] and clicked_option == "cca": + values["ids"] = False + values["mother_bud_lines"] = False + + if values["mother_bud_lines"] and clicked_option == "mother_bud_lines": + values["ids"] = False + values["cca"] = False + + if values["contours"] and clicked_option == "contours": + values["segm_masks"] = False + + if values["segm_masks"] and clicked_option == "segm_masks": + values["contours"] = False + + if clicked_option == "nothing": + values["ids"] = False + values["cca"] = False + values["contours"] = False + values["segm_masks"] = False + values["mother_bud_lines"] = False + values["num_zslices"] = False + else: + values["nothing"] = False + + if clicked_option == "num_zslices": + values["ids"] = True + values["nothing"] = False + + new_state = AnnotationOptionState(**values) + return AnnotationOptionChangePlan( + side=side, + state=new_state, + mode_text=self.annotation_mode_text( + ids=new_state.ids, + cca=new_state.cca, + contours=new_state.contours, + segm_masks=new_state.segm_masks, + mother_bud_lines=new_state.mother_bud_lines, + nothing=new_state.nothing, + ), + save_settings=save_settings, + ) + def annotation_option_state_from_mode_text( self, text: str, @@ -544,13 +559,13 @@ def annotation_option_state_from_mode_text( ) -> AnnotationOptionState: flags = self.annotation_flags_from_mode_text(text) return AnnotationOptionState( - ids=flags['ids'], - cca=flags['cca'], - contours=flags['contours'], - segm_masks=flags['segm_masks'], - mother_bud_lines=flags['mother_bud_lines'], + ids=flags["ids"], + cca=flags["cca"], + contours=flags["contours"], + segm_masks=flags["segm_masks"], + mother_bud_lines=flags["mother_bud_lines"], num_zslices=num_zslices, - nothing=flags['nothing'], + nothing=flags["nothing"], ) def annotation_options_from_mode_text_plan( @@ -564,14 +579,14 @@ def annotation_options_from_mode_text_plan( return AnnotationOptionsFromModeTextPlan( state_updates=( ( - 'left', + "left", self.annotation_option_state_from_mode_text( left_text, num_zslices=left_num_zslices, ), ), ( - 'right', + "right", self.annotation_option_state_from_mode_text( right_text, num_zslices=right_num_zslices, @@ -580,328 +595,142 @@ def annotation_options_from_mode_text_plan( ) ) - def restore_saved_settings_plan( - self, - settings_values: Mapping[str, object], - ) -> AnnotationDisplaySettingsRestorePlan: - return AnnotationDisplaySettingsRestorePlan( - left_mode=str( - settings_values.get( - 'how_draw_annotations', - 'Draw IDs and contours', - ) - ), - right_mode=str( - settings_values.get( - 'how_draw_right_annotations', - 'Draw IDs and overlay segm. masks', - ) - ), - add_new_ids_whitelist_toggle=( - settings_values.get('addNewIDsWhitelistToggle', 'Yes') == 'Yes' - ), - ) + def applyToolNewFrameActionToggled(self, checked, toolName=None): + if toolName is None: + parentToolButton = self.sender().parent() + toolName = re.findall(r"Name: (.*)", parentToolButton.toolTip())[0] + toolName = toolName.strip() + button = self.applyToolNewFrameButtons[toolName] + toolName = toolName.replace(" ", "_") + settingName = f"{toolName}_applyNewFrame" + if checked: + self.df_settings.at[settingName, "value"] = "applyNewFrame" + button.setStyleSheet(f"background-color: {GREEN_HEX}") + else: + self.df_settings = self.df_settings.drop(index=settingName, errors="ignore") + button.setStyleSheet("background-color: none") + self.df_settings.to_csv(self.settings_csv_path) - def contours_requested( - self, - *, - ax: int, - left_contours: bool, - right_image_visible: bool, - right_specific_mode: bool, - right_contours: bool, - ) -> bool: + def areContoursRequested(self, ax): + if ax == 0 and self.annotContourCheckbox.isChecked(): + return True + + if ax == 1: + if not self.labelsGrad.showRightImgAction.isChecked(): + return False + + isRightDifferentAnnot = self.rightBottomGroupbox.isChecked() + areContRequestedRight = self.annotContourCheckboxRight.isChecked() + + if isRightDifferentAnnot and areContRequestedRight: + return True + + areContRequestedLeft = self.annotContourCheckbox.isChecked() + if not isRightDifferentAnnot and areContRequestedLeft: + return True + return False + + def areMothBudLinesRequested(self, ax): if ax == 0: - return left_contours - if not right_image_visible: - return False - if right_specific_mode: - return right_contours - return left_contours - - def moth_bud_lines_requested( - self, - *, - ax: int, - left_cca: bool, - left_mother_bud_lines: bool, - right_image_visible: bool, - right_specific_mode: bool, - right_cca: bool, - right_mother_bud_lines: bool, - ) -> bool: - if ax == 0: - return left_cca or left_mother_bud_lines - if not right_image_visible: - return False - if right_specific_mode: - return right_cca or right_mother_bud_lines - return left_cca or left_mother_bud_lines - - def should_draw_moth_bud_line( - self, - *, - cca_df_available: bool, - mode: str, - object_visible: bool, - cell_cycle_stage: str, - relationship: str, - ) -> bool: - return ( - cca_df_available - and mode != 'Normal division: Lineage Tree' - and object_visible - and cell_cycle_stage != 'G1' - and relationship == 'bud' - ) - - def should_draw_lineage_tree_lines( - self, - *, - lineage_tree_available: bool, - frames_count: int, - ) -> bool: - return lineage_tree_available and frames_count >= 2 - - def annotation_mode_setting_update( - self, - side: AnnotationSide, - how: str, - ) -> tuple[str, str]: - setting = ( - 'how_draw_right_annotations' - if side == 'right' - else 'how_draw_annotations' - ) - return setting, how - - def annotation_mode_change_plan( - self, - *, - side: AnnotationSide, - how: str, - save_settings: bool, - annot_cca_checked: bool, - annot_ids_checked: bool, - mode: str, - is_data_loading: bool, - eraser_checked: bool = False, - ) -> AnnotationModeChangePlan: - setting_update = None - if save_settings: - setting_update = self.annotation_mode_setting_update(side, how) - - is_cca, is_id = self.text_annotation_flags( - annot_cca_checked=annot_cca_checked, - annot_ids_checked=annot_ids_checked, - mode=mode, - ) - return AnnotationModeChangePlan( - side=side, - setting_update=setting_update, - text_annotation_index=1 if side == 'right' else 0, - is_cca_annotation=is_cca, - is_id_annotation=is_id, - should_refresh_images=not is_data_loading, - should_reset_eraser_temp=side == 'left' and eraser_checked, - ) - - def annotation_option_change_plan( - self, - *, - side: AnnotationSide, - state: AnnotationOptionState, - clicked_option: AnnotationOption | None, - save_settings: bool, - ) -> AnnotationOptionChangePlan: - values = { - 'ids': state.ids, - 'cca': state.cca, - 'contours': state.contours, - 'segm_masks': state.segm_masks, - 'mother_bud_lines': state.mother_bud_lines, - 'num_zslices': state.num_zslices, - 'nothing': state.nothing, - } - - if values['ids'] and clicked_option == 'ids': - values['cca'] = False - values['mother_bud_lines'] = False - - if values['cca'] and clicked_option == 'cca': - values['ids'] = False - values['mother_bud_lines'] = False - - if ( - values['mother_bud_lines'] - and clicked_option == 'mother_bud_lines' - ): - values['ids'] = False - values['cca'] = False - - if values['contours'] and clicked_option == 'contours': - values['segm_masks'] = False - - if values['segm_masks'] and clicked_option == 'segm_masks': - values['contours'] = False - - if clicked_option == 'nothing': - values['ids'] = False - values['cca'] = False - values['contours'] = False - values['segm_masks'] = False - values['mother_bud_lines'] = False - values['num_zslices'] = False + if self.annotCcaInfoCheckbox.isChecked(): + return True + if self.drawMothBudLinesCheckbox.isChecked(): + return True else: - values['nothing'] = False - - if clicked_option == 'num_zslices': - values['ids'] = True - values['nothing'] = False + if not self.labelsGrad.showRightImgAction.isChecked(): + return False - new_state = AnnotationOptionState(**values) - return AnnotationOptionChangePlan( - side=side, - state=new_state, - mode_text=self.annotation_mode_text( - ids=new_state.ids, - cca=new_state.cca, - contours=new_state.contours, - segm_masks=new_state.segm_masks, - mother_bud_lines=new_state.mother_bud_lines, - nothing=new_state.nothing, - ), - save_settings=save_settings, - ) + isRightDifferentAnnot = self.rightBottomGroupbox.isChecked() + areLinesRequestedRight = ( + self.annotCcaInfoCheckboxRight.isChecked() + or self.drawMothBudLinesCheckboxRight.isChecked() + ) - def pixel_mode_setting_value(self, checked: bool) -> int: - return int(checked) + if isRightDifferentAnnot and areLinesRequestedRight: + return True - def pixel_mode_change_plan( - self, - *, - checked: bool, - is_data_loaded: bool, - high_resolution: bool, - ) -> PixelModeChangePlan: - return PixelModeChangePlan( - setting_update=('pxMode', self.pixel_mode_setting_value(checked)), - should_update_text_pixel_mode=is_data_loaded and high_resolution, - should_refresh_images=is_data_loaded, - ) + areLinesRequestedLeft = ( + self.drawMothBudLinesCheckbox.isChecked() + or self.annotCcaInfoCheckbox.isChecked() + ) + if not isRightDifferentAnnot and areLinesRequestedLeft: + return True + return False - def text_resolution_change_plan( - self, - *, - high_resolution: bool, - is_data_loaded: bool, - ) -> TextResolutionChangePlan: - mode = 'high' if high_resolution else 'low' - return TextResolutionChangePlan( - mode=mode, - log_message=f'Switching to {mode} for the text annnotations...', - pixel_mode_disabled=not high_resolution, - should_update_annotations=is_data_loaded, - should_refresh_images=is_data_loaded, - ) + def autoPilotToggled(self, checked): + self.autoPilotZoomToObjToolbar.setVisible(checked) + if checked: + self.autoPilotZoomToObjToggle.setChecked(False) + self.autoPilotZoomToObjToggle.toggle() - def tree_annotation_info_mode_plan( - self, - checked: bool, - ) -> TreeAnnotationInfoModePlan: - return TreeAnnotationInfoModePlan( - enabled=checked, - action_text_contains='tree', - action_checked=checked, - label_tree_annotations_enabled=checked, - gen_num_tree_annotations_enabled=checked, - should_refresh_annotations=True, - ) + def changeTextResolution(self): + mode = "high" if self.highLowResAction.isChecked() else "low" + self.logger.info(f"Switching to {mode} for the text annnotations...") + self.pxModeAction.setDisabled(not self.highLowResAction.isChecked()) + if not self.isDataLoaded: + return - def z_depth_annotation_options_plan( - self, - *, - is_3d: bool, - state: AnnotationOptionState, - ) -> ZDepthAnnotationOptionsPlan: - if not is_3d: - return ZDepthAnnotationOptionsPlan(should_apply=False) + self.setAllIDs() + posData = self.data[self.pos_i] + allIDs = posData.allIDs + img_shape = self.img1.image.shape[:2] + self.textAnnot[0].changeResolution(mode, allIDs, self.ax1, img_shape) + self.textAnnot[1].changeResolution(mode, allIDs, self.ax2, img_shape) + self.updateAllImages() - return ZDepthAnnotationOptionsPlan( - should_apply=True, - disabled_updates=(('ids', False), ('contours', False)), - state=AnnotationOptionState( - ids=True, - cca=state.cca, - contours=True, - segm_masks=state.segm_masks, - mother_bud_lines=state.mother_bud_lines, - num_zslices=state.num_zslices, - nothing=state.nothing, - ), - clicked_option='ids', - save_settings=False, - ) + def clearAllCellToCellLines(self): + self.ax1_newMothBudLinesItem.setData([], []) + self.ax1_oldMothBudLinesItem.setData([], []) + self.ax2_newMothBudLinesItem.setData([], []) + self.ax2_oldMothBudLinesItem.setData([], []) - def visible_3d_segmentation_widgets_plan( - self, - *, - is_3d: bool, - ) -> Visible3DSegmentationWidgetsPlan: - visible_updates = ( - ('left', 'num_zslices', is_3d), - ('right', 'num_zslices', is_3d), - ) - checked_updates = () - if not is_3d: - checked_updates = ( - ('left', 'num_zslices', False), - ('right', 'num_zslices', False), - ) - return Visible3DSegmentationWidgetsPlan( - visible_updates=visible_updates, - checked_updates=checked_updates, - ) + def clearAnnotItems(self): + self.textAnnot[0].clear() + self.textAnnot[1].clear() - def z_neighbor_highlight_checkbox_plan( + def contours_requested( self, *, - is_3d: bool, - ) -> ZNeighborHighlightCheckboxPlan: - if not is_3d: - return ZNeighborHighlightCheckboxPlan(should_apply=False) - return ZNeighborHighlightCheckboxPlan( - should_apply=True, - visible=True, - checked=True, - should_connect_toggle=True, - ) + ax: int, + left_contours: bool, + right_image_visible: bool, + right_specific_mode: bool, + right_contours: bool, + ) -> bool: + if ax == 0: + return left_contours + if not right_image_visible: + return False + if right_specific_mode: + return right_contours + return left_contours + def drawAllLineageTreeLines(self): + """ Draw all lineage tree lines on the GUI. This method retrieves the lineage tree data and draws the lineage tree lines connecting cells and their respective mothers when the mother has split. """ - if not self.should_draw_lineage_tree_lines( - lineage_tree_available=self.lineage_tree is not None, - frames_count=( - 0 if self.lineage_tree is None - else len(self.lineage_tree.frames_for_dfs) - ), - ): + if self.lineage_tree is None: + return + + if len(self.lineage_tree.frames_for_dfs) < 2: return self.clearAllCellToCellLines() posData = self.data[self.pos_i] frame_i = posData.frame_i - lin_tree_df = posData.allData_li[frame_i]['acdc_df'] - lin_tree_df_prev = posData.allData_li[frame_i-1]['acdc_df'] + lin_tree_df = posData.allData_li[frame_i]["acdc_df"] + lin_tree_df_prev = posData.allData_li[frame_i - 1]["acdc_df"] rp = posData.rp - prev_rp = posData.allData_li[frame_i-1]['regionprops'] + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] self.setTitleText() - new_cells = lin_tree_df.index.difference(lin_tree_df_prev.index) # I could use this for the if already but this is probably faster for frames where nothing changes + new_cells = lin_tree_df.index.difference( + lin_tree_df_prev.index + ) # I could use this for the if already but this is probably faster for frames where nothing changes if new_cells.shape[0] == 0: return @@ -910,14 +739,16 @@ def z_neighbor_highlight_checkbox_plan( continue for ID in new_cells: - curr_obj = self.lineage.object_by_label(rp, ID) + curr_obj = myutils.get_obj_by_label(rp, ID) lin_tree_df_ID = lin_tree_df.loc[ID] # lin_tree_df_mother_ID = lin_tree_df_prev.loc[lin_tree_df_ID["parent_ID_tree"]] - if lin_tree_df_ID["parent_ID_tree"] == -1: # make sure that new obj where the parents are not known get skipped + if ( + lin_tree_df_ID["parent_ID_tree"] == -1 + ): # make sure that new obj where the parents are not known get skipped continue - mother_obj = self.lineage.object_by_label( + mother_obj = myutils.get_obj_by_label( prev_rp, lin_tree_df_ID["parent_ID_tree"] ) @@ -926,6 +757,74 @@ def z_neighbor_highlight_checkbox_plan( self.drawObjLin_TreeMothBudLines(ax, curr_obj, mother_obj, isNew, ID=ID) + def drawAllMothBudLines(self): + posData = self.data[self.pos_i] + for obj in posData.rp: + self.drawObjMothBudLines(obj, posData, ax=0) + self.drawObjMothBudLines(obj, posData, ax=1) + + def drawAnnotCombobox_to_options(self): + self.uncheckAnnotOptions() + + # Left + how = self.drawIDsContComboBox.currentText() + if how.find("IDs") != -1: + self.annotIDsCheckbox.setChecked(True) + if how.find("cell cycle info") != -1: + self.annotCcaInfoCheckbox.setChecked(True) + if how.find("contours") != -1: + self.annotContourCheckbox.setChecked(True) + if how.find("segm. masks") != -1: + self.annotSegmMasksCheckbox.setChecked(True) + if how.find("mother-bud lines") != -1: + self.drawMothBudLinesCheckbox.setChecked(True) + if how.find("nothing") != -1: + self.drawNothingCheckbox.setChecked(True) + + # Right + how = self.annotateRightHowCombobox.currentText() + if how.find("IDs") != -1: + self.annotIDsCheckboxRight.setChecked(True) + if how.find("cell cycle info") != -1: + self.annotCcaInfoCheckboxRight.setChecked(True) + if how.find("contours") != -1: + self.annotContourCheckboxRight.setChecked(True) + if how.find("segm. masks") != -1: + self.annotSegmMasksCheckboxRight.setChecked(True) + if how.find("mother-bud lines") != -1: + self.drawMothBudLinesCheckboxRight.setChecked(True) + if how.find("nothing") != -1: + self.drawNothingCheckboxRight.setChecked(True) + + def drawIDsContComboBox_cb(self, idx): + how = self.drawIDsContComboBox.currentText() + saveSettings = True + if hasattr(self.drawIDsContComboBox, "saveSettings"): + saveSettings = self.drawIDsContComboBox.saveSettings + + if saveSettings: + self.df_settings.at["how_draw_annotations", "value"] = how + self.df_settings.to_csv(self.settings_csv_path) + + mode = self.modeComboBox.currentText() + isCcaAnnot = ( + self.annotCcaInfoCheckbox.isChecked() + and mode != "Normal division: Lineage tree" + ) + isIDAnnot = self.annotIDsCheckbox.isChecked() or ( + self.annotCcaInfoCheckbox.isChecked() + and mode == "Normal division: Lineage tree" + ) + self.textAnnot[0].setCcaAnnot(isCcaAnnot) + + self.textAnnot[0].setLabelAnnot(isIDAnnot) + + if not self.isDataLoading: + self.updateAllImages() + + if self.eraserButton.isChecked(): + self.setTempImg1Eraser(None, init=True) + def drawObjLin_TreeMothBudLines(self, ax, obj, mother_obj, isNew, ID=None): """ Draw moth-bud lines between an object and its mother object. @@ -958,79 +857,368 @@ def drawObjLin_TreeMothBudLines(self, ax, obj, mother_obj, isNew, ID=None): y1, x1 = self.getObjCentroid(obj.centroid) y2, x2 = self.getObjCentroid(mother_obj.centroid) - xx, yy = self.geometry.line_coords( - y1, x1, y2, x2, dashed=True - ) + xx, yy = core.get_line(y1, x1, y2, x2, dashed=True) scatterItem.addPoints(xx, yy) + def drawObjMothBudLines(self, obj, posData, ax=0): + areMothBudLinesRequested = self.areMothBudLinesRequested(ax) + if not areMothBudLinesRequested: + return + + if posData.cca_df is None: + return + + mode = str(self.modeComboBox.currentText()) + if mode == "Normal division: Lineage Tree": + return + + ID = obj.label + try: + cca_df_ID = posData.cca_df.loc[ID] + except KeyError: + return + + isObjVisible = self.isObjVisible(obj.bbox) + if not isObjVisible: + return + + ccs_ID = cca_df_ID["cell_cycle_stage"] + if ccs_ID == "G1": + return + + relationship = cca_df_ID["relationship"] + if relationship != "bud": + return + + emerg_frame_i = cca_df_ID["emerg_frame_i"] + isNew = emerg_frame_i == posData.frame_i + scatterItem = self.getMothBudLineScatterItem(ax, isNew) + relative_ID = cca_df_ID["relative_ID"] + + try: + relative_rp_idx = posData.IDs_idxs[relative_ID] + except KeyError: + return + + relative_ID_obj = posData.rp[relative_rp_idx] + y1, x1 = self.getObjCentroid(obj.centroid) + y2, x2 = self.getObjCentroid(relative_ID_obj.centroid) + xx, yy = core.get_line(y1, x1, y2, x2, dashed=True) + scatterItem.addPoints(xx, yy) + + def getAnnotateHowRightImage(self): + if not self.labelsGrad.showRightImgAction.isChecked(): + return "nothing" + + if self.rightBottomGroupbox.isChecked(): + how = self.annotateRightHowCombobox.currentText() + else: + how = self.drawIDsContComboBox.currentText() + return how + + def getMothBudLineScatterItem(self, ax, new): + if ax == 0: + if new: + return self.ax1_newMothBudLinesItem + else: + return self.ax1_oldMothBudLinesItem + else: + if new: + return self.ax2_newMothBudLinesItem + else: + return self.ax2_oldMothBudLinesItem + def getObjCentroid(self, obj_centroid): - depth_axis = ( - self.switchPlaneCombobox.depthAxes() if self.isSegm3D else 'z' + if self.isSegm3D: + depthAxes = self.switchPlaneCombobox.depthAxes() + zc, yc, xc = obj_centroid + if depthAxes == "z": + return yc, xc + elif depthAxes == "y": + return zc, xc + else: + return zc, yc + else: + return obj_centroid + + def getObjOptsSegmLabels(self, obj): + if not self.labelsGrad.showLabelsImgAction.isChecked(): + return + + objOpts = self.getObjTextAnnotOpts(obj, "Draw only IDs", ax=1) + return objOpts + + def gui_raiseBottomLayoutContextMenu(self, event): + try: + # Convert QPointF to QPoint + self.bottomLayoutContextMenu.popup(event.globalPos().toPoint()) + except AttributeError: + self.bottomLayoutContextMenu.popup(event.globalPos()) + + def highLowResToggled(self, clicked=True): + self.changeTextResolution() + + def highlightZneighLabels_cb(self, checked): + if checked: + pass + else: + pass + + def keepAllToolsActiveActionToggled(self, checked): + for action in self.keepToolActiveActions.values(): + action.setChecked(checked) + + data_loaded = True + if not hasattr(self, "data"): + data_loaded = False + try: + self.labelRoiTrangeCheckbox.disconnect() + except TypeError: + pass + self.labelRoiTrangeCheckbox.setChecked( + checked + ) # why this is not wrapped in a QAction? + + if data_loaded: + self.labelRoiTrangeCheckbox.toggled.connect( + self.labelRoiTrangeCheckboxToggled + ) + + def keepToolActiveActionToggled(self, checked, toolName=None): + if toolName is None: + parentToolButton = self.sender().parent() + toolName = re.findall(r"Name: (.*)", parentToolButton.toolTip())[0] + + if checked: + self.df_settings.at[toolName, "value"] = "keepActive" + else: + self.df_settings = self.df_settings.drop(index=toolName, errors="ignore") + self.df_settings.to_csv(self.settings_csv_path) + + def labelRoiIsCircularRadioButtonToggled(self, checked): + if checked: + self.labelRoiCircularRadiusSpinbox.setDisabled(False) + else: + self.labelRoiCircularRadiusSpinbox.setDisabled(True) + + def moth_bud_lines_requested( + self, + *, + ax: int, + left_cca: bool, + left_mother_bud_lines: bool, + right_image_visible: bool, + right_specific_mode: bool, + right_cca: bool, + right_mother_bud_lines: bool, + ) -> bool: + if ax == 0: + return left_cca or left_mother_bud_lines + if not right_image_visible: + return False + if right_specific_mode: + return right_cca or right_mother_bud_lines + return left_cca or left_mother_bud_lines + + def onDoubleSpaceBar(self): + how = self.drawIDsContComboBox.currentText() + if how.find("nothing") == -1: + self.storeCurrentAnnotOptions_ax1() + self.drawNothingCheckbox.setChecked(True) + self.annotOptionClicked(sender=self.drawNothingCheckbox, saveSettings=False) + else: + self.restoreAnnotOptions_ax1() + + how = self.annotateRightHowCombobox.currentText() + if how.find("nothing") == -1: + self.storeCurrentAnnotOptions_ax2() + self.drawNothingCheckboxRight.setChecked(True) + self.annotOptionClickedRight( + sender=self.drawNothingCheckboxRight, saveSettings=False + ) + else: + self.restoreAnnotOptions_ax2() + + def pixel_mode_change_plan( + self, + *, + checked: bool, + is_data_loaded: bool, + high_resolution: bool, + ) -> PixelModeChangePlan: + return PixelModeChangePlan( + setting_update=("pxMode", self.pixel_mode_setting_value(checked)), + should_update_text_pixel_mode=is_data_loaded and high_resolution, + should_refresh_images=is_data_loaded, + ) + + def pixel_mode_setting_value(self, checked: bool) -> int: + return int(checked) + + def pxModeActionToggled(self, checked): + self.df_settings.at["pxMode", "value"] = int(checked) + self.df_settings.to_csv(self.settings_csv_path) + + if not self.isDataLoaded: + return + + if self.highLowResAction.isChecked(): + for ax in range(2): + self.textAnnot[ax].setPxMode(checked) + + self.updateAllImages() + + def relabelSequentialCallback(self): + mode = str(self.modeComboBox.currentText()) + if mode == "Viewer" or mode == "Cell cycle analysis": + self.startBlinkingModeCB() + return + + posData = self.data[self.pos_i] + selectedPos = (posData.pos_foldername,) + if len(self.data) > 1: + selectedPos = self.askSelectPos(action="to process") + if selectedPos is None: + self.logger.info("Re-labelling process stopped.") + return + + self.store_data() + # acdc_df_concat = self.getConcatAcdcDf() + # load.store_unsaved_acdc_df( + # posData, acdc_df_concat, + # log_func=self.logger.info + # ) + # if posData.SizeT > 1: + self.progressWin = apps.QDialogWorkerProgress( + title="Re-labelling sequential", + parent=self, + pbarDesc="Relabelling sequential...", ) - return self.edit_id.project_centroid( - obj_centroid, - is_3d=self.isSegm3D, - depth_axis=depth_axis, + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + self.startRelabellingWorker(selectedPos) + + def restoreAnnotOptions_ax1(self, options=None): + if options is None and not hasattr(self, "annotOptionsToRestore"): + return + + if options is None: + options = self.annotOptionsToRestore + + if options is None: + return + + for option, state in options.items(): + checkbox = getattr(self, option) + checkbox.setChecked(state) + + self.setDrawAnnotComboboxText() + self.annotOptionsToRestore = None + + def restoreAnnotOptions_ax2(self): + if not hasattr(self, "annotOptionsToRestoreRight"): + return + + if self.annotOptionsToRestoreRight is None: + return + + for option, state in self.annotOptionsToRestoreRight.items(): + checkbox = getattr(self, option) + checkbox.setChecked(state) + + self.setDrawAnnotComboboxTextRight() + self.annotOptionsToRestoreRight = None + + def restoreAnnotationsOptions(self): + self.restoreAnnotOptions_ax1() + self.restoreAnnotOptions_ax2() + + def restoreSavedSettings(self): + if "how_draw_annotations" in self.df_settings.index: + how = self.df_settings.at["how_draw_annotations", "value"] + self.drawIDsContComboBox.setCurrentText(how) + else: + self.drawIDsContComboBox.setCurrentText("Draw IDs and contours") + + if "how_draw_right_annotations" in self.df_settings.index: + how = self.df_settings.at["how_draw_right_annotations", "value"] + self.annotateRightHowCombobox.setCurrentText(how) + else: + self.annotateRightHowCombobox.setCurrentText( + "Draw IDs and overlay segm. masks" + ) + + if "addNewIDsWhitelistToggle" in self.df_settings.index: + self.addNewIDsWhitelistToggle = ( + (self.df_settings.at["addNewIDsWhitelistToggle", "value"]) == "Yes" + ) + else: + self.addNewIDsWhitelistToggle = True + + self.drawAnnotCombobox_to_options() + self.drawIDsContComboBox_cb(0) + self.annotateRightHowCombobox_cb(0) + + def restore_saved_settings_plan( + self, + settings_values: Mapping[str, object], + ) -> AnnotationDisplaySettingsRestorePlan: + return AnnotationDisplaySettingsRestorePlan( + left_mode=str( + settings_values.get( + "how_draw_annotations", + "Draw IDs and contours", + ) + ), + right_mode=str( + settings_values.get( + "how_draw_right_annotations", + "Draw IDs and overlay segm. masks", + ) + ), + add_new_ids_whitelist_toggle=( + settings_values.get("addNewIDsWhitelistToggle", "Yes") == "Yes" + ), ) - def getObjOptsSegmLabels(self, obj): - if not self.labelsGrad.showLabelsImgAction.isChecked(): - return - - objOpts = self.getObjTextAnnotOpts(obj, 'Draw only IDs', ax=1) - return objOpts - - # @exec_time - def update_rp_metadata(self, draw=True): - posData = self.data[self.pos_i] - # Add to rp dynamic metadata (e.g. cells annotated as dead) - for i, obj in enumerate(posData.rp): - ID = obj.label - obj.excluded = ID in posData.binnedIDs - obj.dead = ID in posData.ripIDs + def right_annotation_mode( + self, + *, + show_right_image: bool, + use_right_specific_mode: bool, + right_mode: str, + left_mode: str, + ) -> str: + if not show_right_image: + return "nothing" + return right_mode if use_right_specific_mode else left_mode - def annotate_rip_and_bin_IDs(self, updateLabel=False): - depthAxes = self.switchPlaneCombobox.depthAxes() - if self.switchPlaneCombobox.isEnabled() and depthAxes != 'z': + def rtTrackerActionToggled(self, checked): + if not checked: return - posData = self.data[self.pos_i] - binnedIDs_xx = [] - binnedIDs_yy = [] - ripIDs_xx = [] - ripIDs_yy = [] - for obj in posData.rp: - obj.excluded = obj.label in posData.binnedIDs - obj.dead = obj.label in posData.ripIDs - if not self.isObjVisible(obj.bbox): - continue - - if obj.excluded: - y, x = self.getObjCentroid(obj.centroid) - binnedIDs_xx.append(x) - binnedIDs_yy.append(y) - if updateLabel: - self.getObjOptsSegmLabels(obj) - how = self.drawIDsContComboBox.currentText() - - if obj.dead: - y, x = self.getObjCentroid(obj.centroid) - ripIDs_xx.append(x) - ripIDs_yy.append(y) - if updateLabel: - self.getObjOptsSegmLabels(obj) - how = self.drawIDsContComboBox.currentText() + aliases = myutils.aliases_real_time_trackers(reverse=True) + if self.sender().text() in aliases: + trackingAlgo = aliases[self.sender().text()] + else: + trackingAlgo = self.sender().text() + self.df_settings.at["tracking_algorithm", "value"] = trackingAlgo + self.df_settings.to_csv(self.settings_csv_path) - self.ax2_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) - self.ax2_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) - self.ax1_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) - self.ax1_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) + if self.sender().text() == "YeaZ": + msg = widgets.myMessageBox(wrapText=False) + info_txt = html_utils.paragraph(""" + Note that YeaZ tracking algorithm tends to be sliglhtly more accurate + overall, but it is less capable of detecting segmentation + errors.

+ If you need to correct as many segmentation errors as possible + we recommend using Cell-ACDC tracking algorithm. + """) + msg.information(self, "Info about YeaZ", info_txt) - def clearAnnotItems(self): - self.textAnnot[0].clear() - self.textAnnot[1].clear() + self.isRealTimeTrackerInitialized = False + self.initRealTimeTracker() - # @exec_time def setAllTextAnnotations(self, labelsToSkip=None): delROIsIDs = self.setLostNewOldPrevIDs() posData = self.data[self.pos_i] @@ -1042,61 +1230,63 @@ def setAllTextAnnotations(self, labelsToSkip=None): delROIsIDs=delROIsIDs, annotateLost=self.annotLostObjsToggle.isChecked(), getCurrentZfunc=self.z_lab, - getObjCentroidFunc=self.getObjCentroid + getObjCentroidFunc=self.getObjCentroid, ) self.textAnnot[1].setAnnotations( - posData=posData, labelsToSkip=labelsToSkip, + posData=posData, + labelsToSkip=labelsToSkip, isVisibleCheckFunc=self.isObjVisible, highlightedID=self.highlightedID, delROIsIDs=delROIsIDs, annotateLost=self.annotLostObjsToggle.isChecked(), getCurrentZfunc=self.z_lab, - getObjCentroidFunc=self.getObjCentroid + getObjCentroidFunc=self.getObjCentroid, ) self.textAnnot[0].update() self.textAnnot[1].update() return delROIsIDs - def labelRoiIsCircularRadioButtonToggled(self, checked): + def setAnnotInfoMode(self, checked): if checked: - self.labelRoiCircularRadiusSpinbox.setDisabled(False) + for action in self.annotSettingsIDmenu.actions(): + if action.text().find("tree") != -1: + self.textAnnot[0].setLabelTreeAnnotationsEnabled(True) + action.setChecked(True) + break + for action in self.annotSettingsGenNumMenu.actions(): + if action.text().find("tree") != -1: + self.textAnnot[0].setGenNumTreeAnnotationsEnabled(True) + action.setChecked(True) + break else: - self.labelRoiCircularRadiusSpinbox.setDisabled(True) - - def pxModeActionToggled(self, checked): - self.change_pixel_mode( - checked=checked, - is_data_loaded=self.isDataLoaded, - high_resolution=self.highLowResAction.isChecked(), - ) - - def changeTextResolution(self): - self.change_text_resolution( - high_resolution=self.highLowResAction.isChecked(), - is_data_loaded=self.isDataLoaded, - ) - - def highLowResToggled(self, clicked=True): - self.changeTextResolution() + for action in self.annotSettingsIDmenu.actions(): + if action.text().find("tree") == -1: + action.setChecked(False) + self.textAnnot[0].setLabelTreeAnnotationsEnabled(False) + break + for action in self.annotSettingsGenNumMenu.actions(): + if action.text().find("tree") == -1: + action.setChecked(False) + self.textAnnot[0].setGenNumTreeAnnotationsEnabled(False) + break + self.setAllTextAnnotations() - def annotGenNumTreeToggled(self, checked): - self.change_gen_num_tree_annotations(checked) - - def annotLabelIDtreeToggled(self, checked): - self.change_label_tree_annotations(checked) - - def setAnnotInfoMode(self, checked): - self.change_tree_annotation_info_mode(checked) + def setAnnotOptionsCcaMode(self): + self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1(return_value=True) + self.annotCcaInfoCheckbox.setChecked(True) + self.annotIDsCheckbox.setChecked(False) + self.drawMothBudLinesCheckbox.setChecked(False) + self.setDrawAnnotComboboxText() - def annotOptionClicked(self, clicked=True, sender=None, saveSettings=True): - if sender is None: - sender = self.sender() - self.change_annotation_options( - side='left', - clicked_option=self._annotation_clicked_option('left', sender), - save_settings=saveSettings, - **self._annotation_option_state('left'), - ) + def setAnnotOptionsLin_treeMode(self): + # self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( + # return_value=True + # ) + self.annotCcaInfoCheckbox.setChecked(True) + self.annotIDsCheckbox.setChecked(False) + self.drawMothBudLinesCheckbox.setChecked(False) + self.setDrawAnnotComboboxText() + self.showTreeInfoCheckbox.setChecked(True) def setDisabledAnnotCheckBoxesLeft(self, disabled): self.annotIDsCheckbox.setDisabled(disabled) @@ -1107,12 +1297,6 @@ def setDisabledAnnotCheckBoxesLeft(self, disabled): self.annotNumZslicesCheckbox.setDisabled(disabled) self.drawNothingCheckbox.setDisabled(disabled) - def setEnabledAnnotCheckBoxesLeftZdepthAxes(self): - self.enable_z_depth_annotation_options( - is_3d=self.isSegm3D, - **self._annotation_option_state('left'), - ) - def setDisabledAnnotCheckBoxesRight(self, disabled): self.annotIDsCheckboxRight.setDisabled(disabled) self.annotCcaInfoCheckboxRight.setDisabled(disabled) @@ -1122,155 +1306,177 @@ def setDisabledAnnotCheckBoxesRight(self, disabled): self.annotNumZslicesCheckboxRight.setDisabled(disabled) self.drawNothingCheckboxRight.setDisabled(disabled) - def annotOptionClickedRight( - self, clicked=True, sender=None, saveSettings=True - ): - if sender is None: - sender = self.sender() - self.change_annotation_options( - side='right', - clicked_option=self._annotation_clicked_option('right', sender), - save_settings=saveSettings, - **self._annotation_option_state('right'), - ) - - def setAnnotOptionsCcaMode(self): - self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( - return_value=True - ) - self.annotCcaInfoCheckbox.setChecked(True) - self.annotIDsCheckbox.setChecked(False) - self.drawMothBudLinesCheckbox.setChecked(False) - self.setDrawAnnotComboboxText() + def setDisabledAnnotOptions(self, disabled): + # Left + self.annotIDsCheckbox.setDisabled(disabled) + self.annotCcaInfoCheckbox.setDisabled(disabled) + self.annotContourCheckbox.setDisabled(disabled) + # self.annotSegmMasksCheckbox.setDisabled(disabled) + self.drawMothBudLinesCheckbox.setDisabled(disabled) + # self.drawNothingCheckbox.setDisabled(disabled) - def setAnnotOptionsLin_treeMode(self): - # self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( - # return_value=True - # ) - self.annotCcaInfoCheckbox.setChecked(True) - self.annotIDsCheckbox.setChecked(False) - self.drawMothBudLinesCheckbox.setChecked(False) - self.setDrawAnnotComboboxText() - self.showTreeInfoCheckbox.setChecked(True) + # Right + self.annotIDsCheckboxRight.setDisabled(disabled) + self.annotCcaInfoCheckboxRight.setDisabled(disabled) + self.annotContourCheckboxRight.setDisabled(disabled) + # self.annotSegmMasksCheckboxRight.setDisabled(disabled) + self.drawMothBudLinesCheckboxRight.setDisabled(disabled) def setDrawAnnotComboboxText(self, saveSettings=True): - state = self._annotation_option_state('left') - state.pop('num_zslices') - self.refresh_annotation_mode_text( - side='left', - save_settings=saveSettings, - **state, - ) + if self.annotIDsCheckbox.isChecked(): + if self.annotContourCheckbox.isChecked(): + t = "Draw IDs and contours" + elif self.annotSegmMasksCheckbox.isChecked(): + t = "Draw IDs and overlay segm. masks" + else: + t = "Draw only IDs" + + elif self.annotCcaInfoCheckbox.isChecked(): + if self.annotContourCheckbox.isChecked(): + t = "Draw cell cycle info and contours" + elif self.annotSegmMasksCheckbox.isChecked(): + t = "Draw cell cycle info and overlay segm. masks" + else: + t = "Draw only cell cycle info" + + elif self.annotSegmMasksCheckbox.isChecked(): + t = "Draw only overlay segm. masks" + + elif self.annotContourCheckbox.isChecked(): + t = "Draw only contours" + + elif self.drawMothBudLinesCheckbox.isChecked(): + t = "Draw only mother-bud lines" + + elif self.drawNothingCheckbox.isChecked(): + t = "Draw nothing" + else: + t = "Draw nothing" + + if t == self.drawIDsContComboBox.currentText(): + self.drawIDsContComboBox_cb(0) + + self.drawIDsContComboBox.saveSettings = saveSettings + self.drawIDsContComboBox.setCurrentText(t) def setDrawAnnotComboboxTextRight(self, saveSettings=True): - state = self._annotation_option_state('right') - state.pop('num_zslices') - self.refresh_annotation_mode_text( - side='right', - save_settings=saveSettings, - **state, - ) + if self.annotIDsCheckboxRight.isChecked(): + if self.annotContourCheckboxRight.isChecked(): + t = "Draw IDs and contours" + elif self.annotSegmMasksCheckboxRight.isChecked(): + t = "Draw IDs and overlay segm. masks" + else: + t = "Draw only IDs" - def relabelSequentialCallback(self): - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer' or mode == 'Cell cycle analysis': - self.mode_controls_view.startBlinkingModeCB() - return + elif self.annotCcaInfoCheckboxRight.isChecked(): + if self.annotContourCheckboxRight.isChecked(): + t = "Draw cell cycle info and contours" + elif self.annotSegmMasksCheckboxRight.isChecked(): + t = "Draw cell cycle info and overlay segm. masks" + else: + t = "Draw only cell cycle info" - posData = self.data[self.pos_i] - selectedPos = (posData.pos_foldername, ) - if len(self.data) > 1: - selectedPos = self.askSelectPos(action='to process') - if selectedPos is None: - self.logger.info('Re-labelling process stopped.') - return + elif self.annotSegmMasksCheckboxRight.isChecked(): + t = "Draw only overlay segm. masks" - self.store_data() - # acdc_df_concat = self.status_hover_view.concat_acdc_df() - # load.store_unsaved_acdc_df( - # posData, acdc_df_concat, - # log_func=self.logger.info - # ) - # if posData.SizeT > 1: - self.progressWin = apps.QDialogWorkerProgress( - title='Re-labelling sequential', parent=self.host, - pbarDesc='Relabelling sequential...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - self.startRelabellingWorker(selectedPos) + elif self.annotContourCheckboxRight.isChecked(): + t = "Draw only contours" - def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print): - logger('Updating annotated IDs...') - posData = self.data[self.pos_i] + elif self.drawMothBudLinesCheckboxRight.isChecked(): + t = "Draw only mother-bud lines" - posData.ripIDs = self.label_edits.remap_id_set( - posData.ripIDs, oldIDs, newIDs - ) - posData.binnedIDs = self.label_edits.remap_id_set( - posData.binnedIDs, oldIDs, newIDs - ) - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) + elif self.drawNothingCheckboxRight.isChecked(): + t = "Draw nothing" + else: + t = "Draw nothing" - customAnnotButtons = list(self.customAnnotDict.keys()) - for button in customAnnotButtons: - customAnnotValues = self.customAnnotDict[button] - annotatedIDs = customAnnotValues['annotatedIDs'][self.pos_i] - mappedAnnotIDs = self.custom_annotations.remap_ids( - annotatedIDs, - oldIDs, - newIDs, - ) - customAnnotValues['annotatedIDs'][self.pos_i] = mappedAnnotIDs + if t == self.annotateRightHowCombobox.currentText(): + self.annotateRightHowCombobox_cb(0) - def rtTrackerActionToggled(self, checked): - if not checked: - return + self.annotateRightHowCombobox.saveSettings = saveSettings + self.annotateRightHowCombobox.setCurrentText(t) - aliases = self.model_registry.real_time_tracker_aliases( - reverse=True + def setDrawNothingAnnotations(self): + self.storeCurrentAnnotOptions_ax1() + self.storeCurrentAnnotOptions_ax2() + self.drawNothingCheckbox.setChecked(True) + self.annotOptionClicked(sender=self.drawNothingCheckbox, saveSettings=False) + self.drawNothingCheckboxRight.setChecked(True) + self.annotOptionClickedRight( + sender=self.drawNothingCheckboxRight, saveSettings=False ) - if self.sender().text() in aliases: - trackingAlgo = aliases[self.sender().text()] - else: - trackingAlgo = self.sender().text() - self.df_settings.at['tracking_algorithm', 'value'] = trackingAlgo - self.df_settings.to_csv(self.settings_csv_path) - if self.sender().text() == 'YeaZ': - msg = widgets.myMessageBox(wrapText=False) - info_txt = html_utils.paragraph(f""" - Note that YeaZ tracking algorithm tends to be sliglhtly more accurate - overall, but it is less capable of detecting segmentation - errors.

- If you need to correct as many segmentation errors as possible - we recommend using Cell-ACDC tracking algorithm. - """) - msg.information(self.host, 'Info about YeaZ', info_txt) + def setEnabledAnnotCheckBoxesLeftZdepthAxes(self): + if not self.isSegm3D: + return - self.isRealTimeTrackerInitialized = False - self.initRealTimeTracker() + self.annotIDsCheckbox.setDisabled(False) + self.annotContourCheckbox.setDisabled(False) + self.annotIDsCheckbox.setChecked(True) + self.annotContourCheckbox.setChecked(True) - def autoPilotToggled(self, checked): - self.autoPilotZoomToObjToolbar.setVisible(checked) - if checked: - self.autoPilotZoomToObjToggle.setChecked(False) - self.autoPilotZoomToObjToggle.toggle() + self.annotOptionClicked(sender=self.annotIDsCheckbox, saveSettings=False) + + def setVisible3DsegmWidgets(self): + self.annotNumZslicesCheckbox.setVisible(self.isSegm3D) + self.annotNumZslicesCheckboxRight.setVisible(self.isSegm3D) + if not self.isSegm3D: + self.annotNumZslicesCheckbox.setChecked(False) + self.annotNumZslicesCheckboxRight.setChecked(False) + + def should_draw_lineage_tree_lines( + self, + *, + lineage_tree_available: bool, + frames_count: int, + ) -> bool: + return lineage_tree_available and frames_count >= 2 + + def should_draw_moth_bud_line( + self, + *, + cca_df_available: bool, + mode: str, + object_visible: bool, + cell_cycle_stage: str, + relationship: str, + ) -> bool: + return ( + cca_df_available + and mode != "Normal division: Lineage Tree" + and object_visible + and cell_cycle_stage != "G1" + and relationship == "bud" + ) + + def showHighlightZneighCheckbox(self): + if self.isSegm3D: + # layout.addWidget(self.annotOptionsWidget, 0, 1, 1, 2) + # # layout.removeWidget(self.drawIDsContComboBox) + # # layout.addWidget(self.drawIDsContComboBox, 0, 1, 1, 1, + # # alignment=Qt.AlignCenter + # # ) + # layout.addWidget(self.highlightZneighObjCheckbox, 0, 2, 1, 2, + # alignment=Qt.AlignRight + # ) + self.highlightZneighObjCheckbox.show() + self.highlightZneighObjCheckbox.setChecked(True) + self.highlightZneighObjCheckbox.toggled.connect( + self.highlightZneighLabels_cb + ) def storeCurrentAnnotOptions_ax1(self, return_value=False): if self.annotOptionsToRestore is not None: return checkboxes = [ - 'annotIDsCheckbox', - 'annotCcaInfoCheckbox', - 'annotContourCheckbox', - 'annotSegmMasksCheckbox', - 'drawMothBudLinesCheckbox', - 'annotNumZslicesCheckbox', - 'drawNothingCheckbox', + "annotIDsCheckbox", + "annotCcaInfoCheckbox", + "annotContourCheckbox", + "annotSegmMasksCheckbox", + "drawMothBudLinesCheckbox", + "annotNumZslicesCheckbox", + "drawNothingCheckbox", ] annotOptions = {} for checkboxName in checkboxes: @@ -1285,86 +1491,165 @@ def storeCurrentAnnotOptions_ax2(self): return checkboxes = [ - 'annotIDsCheckboxRight', - 'annotCcaInfoCheckboxRight', - 'annotContourCheckboxRight', - 'annotSegmMasksCheckboxRight', - 'drawMothBudLinesCheckboxRight', - 'annotNumZslicesCheckboxRight', - 'drawNothingCheckboxRight', + "annotIDsCheckboxRight", + "annotCcaInfoCheckboxRight", + "annotContourCheckboxRight", + "annotSegmMasksCheckboxRight", + "drawMothBudLinesCheckboxRight", + "annotNumZslicesCheckboxRight", + "drawNothingCheckboxRight", ] self.annotOptionsToRestoreRight = {} for checkboxName in checkboxes: checkbox = getattr(self, checkboxName) self.annotOptionsToRestoreRight[checkboxName] = checkbox.isChecked() - def restoreAnnotOptions_ax1(self, options=None): - if options is None and not hasattr(self, 'annotOptionsToRestore'): - return + def text_annotation_flags( + self, + *, + annot_cca_checked: bool, + annot_ids_checked: bool, + mode: str, + ) -> tuple[bool, bool]: + is_lineage_mode = mode == "Normal division: Lineage tree" + is_cca = annot_cca_checked and not is_lineage_mode + is_id = annot_ids_checked or (annot_cca_checked and is_lineage_mode) + return is_cca, is_id - if options is None: - options = self.annotOptionsToRestore + def text_resolution_change_plan( + self, + *, + high_resolution: bool, + is_data_loaded: bool, + ) -> TextResolutionChangePlan: + mode = "high" if high_resolution else "low" + return TextResolutionChangePlan( + mode=mode, + log_message=f"Switching to {mode} for the text annnotations...", + pixel_mode_disabled=not high_resolution, + should_update_annotations=is_data_loaded, + should_refresh_images=is_data_loaded, + ) - if options is None: - return + def tree_annotation_info_mode_plan( + self, + checked: bool, + ) -> TreeAnnotationInfoModePlan: + return TreeAnnotationInfoModePlan( + enabled=checked, + action_text_contains="tree", + action_checked=checked, + label_tree_annotations_enabled=checked, + gen_num_tree_annotations_enabled=checked, + should_refresh_annotations=True, + ) - for option, state in options.items(): - checkbox = getattr(self, option) - checkbox.setChecked(state) + def uncheckAnnotOptions(self, left=True, right=True): + # Left + if left: + self.annotIDsCheckbox.setChecked(False) + self.annotCcaInfoCheckbox.setChecked(False) + self.annotContourCheckbox.setChecked(False) + self.annotSegmMasksCheckbox.setChecked(False) + self.drawMothBudLinesCheckbox.setChecked(False) + self.drawNothingCheckbox.setChecked(False) - self.setDrawAnnotComboboxText() - self.annotOptionsToRestore = None + # Right + if right: + self.annotIDsCheckboxRight.setChecked(False) + self.annotCcaInfoCheckboxRight.setChecked(False) + self.annotContourCheckboxRight.setChecked(False) + self.annotSegmMasksCheckboxRight.setChecked(False) + self.drawMothBudLinesCheckboxRight.setChecked(False) + self.drawNothingCheckboxRight.setChecked(False) - def restoreAnnotOptions_ax2(self): - if not hasattr(self, 'annotOptionsToRestoreRight'): - return + def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print): + logger("Updating annotated IDs...") + posData = self.data[self.pos_i] - if self.annotOptionsToRestoreRight is None: - return + mapper = dict(zip(oldIDs, newIDs)) + posData.ripIDs = set([mapper[ripID] for ripID in posData.ripIDs]) + posData.binnedIDs = set([mapper[binID] for binID in posData.binnedIDs]) + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) - for option, state in self.annotOptionsToRestoreRight.items(): - checkbox = getattr(self, option) - checkbox.setChecked(state) + customAnnotButtons = list(self.customAnnotDict.keys()) + for button in customAnnotButtons: + customAnnotValues = self.customAnnotDict[button] + annotatedIDs = customAnnotValues["annotatedIDs"][self.pos_i] + mappedAnnotIDs = {} + for frame_i, annotIDs_i in annotatedIDs.items(): + mappedIDs = [mapper[ID] for ID in annotIDs_i] + mappedAnnotIDs[frame_i] = mappedIDs + customAnnotValues["annotatedIDs"][self.pos_i] = mappedAnnotIDs - self.setDrawAnnotComboboxTextRight() - self.annotOptionsToRestoreRight = None + def update_rp_metadata(self, draw=True): + posData = self.data[self.pos_i] + # Add to rp dynamic metadata (e.g. cells annotated as dead) + for i, obj in enumerate(posData.rp): + ID = obj.label + obj.excluded = ID in posData.binnedIDs + obj.dead = ID in posData.ripIDs - def setDrawNothingAnnotations(self): - self.storeCurrentAnnotOptions_ax1() - self.storeCurrentAnnotOptions_ax2() - self.drawNothingCheckbox.setChecked(True) - self.annotOptionClicked( - sender=self.drawNothingCheckbox, saveSettings=False) - self.drawNothingCheckboxRight.setChecked(True) - self.annotOptionClickedRight( - sender=self.drawNothingCheckboxRight, saveSettings=False + def visible_3d_segmentation_widgets_plan( + self, + *, + is_3d: bool, + ) -> Visible3DSegmentationWidgetsPlan: + visible_updates = ( + ("left", "num_zslices", is_3d), + ("right", "num_zslices", is_3d), ) - - def restoreAnnotationsOptions(self): - self.restoreAnnotOptions_ax1() - self.restoreAnnotOptions_ax2() - - def onDoubleSpaceBar(self): - how = self.drawIDsContComboBox.currentText() - if how.find('nothing') == -1: - self.storeCurrentAnnotOptions_ax1() - self.drawNothingCheckbox.setChecked(True) - self.annotOptionClicked( - sender=self.drawNothingCheckbox, saveSettings=False + checked_updates = () + if not is_3d: + checked_updates = ( + ("left", "num_zslices", False), + ("right", "num_zslices", False), ) - else: - self.restoreAnnotOptions_ax1() + return Visible3DSegmentationWidgetsPlan( + visible_updates=visible_updates, + checked_updates=checked_updates, + ) - how = self.annotateRightHowCombobox.currentText() - if how.find('nothing') == -1: - self.storeCurrentAnnotOptions_ax2() - self.drawNothingCheckboxRight.setChecked(True) - self.annotOptionClickedRight( - sender=self.drawNothingCheckboxRight, saveSettings=False - ) - else: - self.restoreAnnotOptions_ax2() + def z_depth_annotation_options_plan( + self, + *, + is_3d: bool, + state: AnnotationOptionState, + ) -> ZDepthAnnotationOptionsPlan: + if not is_3d: + return ZDepthAnnotationOptionsPlan(should_apply=False) + + return ZDepthAnnotationOptionsPlan( + should_apply=True, + disabled_updates=(("ids", False), ("contours", False)), + state=AnnotationOptionState( + ids=True, + cca=state.cca, + contours=True, + segm_masks=state.segm_masks, + mother_bud_lines=state.mother_bud_lines, + num_zslices=state.num_zslices, + nothing=state.nothing, + ), + clicked_option="ids", + save_settings=False, + ) + def z_neighbor_highlight_checkbox_plan( + self, + *, + is_3d: bool, + ) -> ZNeighborHighlightCheckboxPlan: + if not is_3d: + return ZNeighborHighlightCheckboxPlan(should_apply=False) + return ZNeighborHighlightCheckboxPlan( + should_apply=True, + visible=True, + checked=True, + should_connect_toggle=True, + ) def zoomRectActionToggled(self, checked): if checked: @@ -1373,142 +1658,22 @@ def zoomRectActionToggled(self, checked): self.connectLeftClickButtons() self.ax1.addItem(self.zoomRectItem) else: - self.zoomRectItem.setPos((0,0)) - self.zoomRectItem.setSize((0,0)) + self.zoomRectItem.setPos((0, 0)) + self.zoomRectItem.setSize((0, 0)) self.ax1.removeItem(self.zoomRectItem) + def zoomRectCancelled(self): + self.isMouseDragImg1 = False + self.zoomRectItem.setPos((0, 0)) + self.zoomRectItem.setSize((0, 0)) + def zoomRectDone(self): xRange, yRange = self.ax1.viewRange() self.zoomRectItem.storeLastRange(xRange, yRange) ymin, xmin, ymax, xmax = self.zoomRectItem.bbox() - self.zoomRectItem.setPos((0,0)) - self.zoomRectItem.setSize((0,0)) - - self.ax1.setRange( - xRange=(xmin, xmax), - yRange=(ymin, ymax), - padding=0 - ) - - def zoomRectCancelled(self): - self.isMouseDragImg1 = False - self.zoomRectItem.setPos((0,0)) - self.zoomRectItem.setSize((0,0)) - - def keepToolActiveActionToggled(self, checked, toolName=None): - if toolName is None: - parentToolButton = self.sender().parent() - toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] - - if checked: - self.df_settings.at[toolName, 'value'] = 'keepActive' - else: - self.df_settings = self.df_settings.drop( - index=toolName, errors='ignore' - ) - self.df_settings.to_csv(self.settings_csv_path) - - def applyToolNewFrameActionToggled(self, checked, toolName=None): - if toolName is None: - parentToolButton = self.sender().parent() - toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] - toolName = toolName.strip() - button = self.applyToolNewFrameButtons[toolName] - toolName = toolName.replace(' ', '_') - settingName = f'{toolName}_applyNewFrame' - if checked: - self.df_settings.at[settingName, 'value'] = 'applyNewFrame' - button.setStyleSheet(f'background-color: {GREEN_HEX}') - else: - self.df_settings = self.df_settings.drop( - index=settingName, errors='ignore' - ) - button.setStyleSheet('background-color: none') - self.df_settings.to_csv(self.settings_csv_path) - - def keepAllToolsActiveActionToggled(self, checked): - for action in self.keepToolActiveActions.values(): - action.setChecked(checked) - - data_loaded = True - if not hasattr(self, 'data'): - data_loaded = False - try: - self.labelRoiTrangeCheckbox.disconnect() - except TypeError: - pass - self.labelRoiTrangeCheckbox.setChecked(checked) # why this is not wrapped in a QAction? - - if data_loaded: - self.labelRoiTrangeCheckbox.toggled.connect( - self.labelRoiTrangeCheckboxToggled - ) - - def setVisible3DsegmWidgets(self): - self.update_visible_3d_segmentation_widgets( - is_3d=self.isSegm3D, - ) - - def showHighlightZneighCheckbox(self): - self.update_z_neighbor_highlight_checkbox( - is_3d=self.isSegm3D, - ) - - def highlightZneighLabels_cb(self, checked): - if checked: - pass - else: - pass - - def restoreSavedSettings(self): - self.restore_saved_settings( - settings_values=self.df_settings['value'].to_dict(), - left_num_zslices=self.annotNumZslicesCheckbox.isChecked(), - right_num_zslices=self.annotNumZslicesCheckboxRight.isChecked(), - ) - - def uncheckAnnotOptions(self, left=True, right=True): - # Left - if left: - self.annotIDsCheckbox.setChecked(False) - self.annotCcaInfoCheckbox.setChecked(False) - self.annotContourCheckbox.setChecked(False) - self.annotSegmMasksCheckbox.setChecked(False) - self.drawMothBudLinesCheckbox.setChecked(False) - self.drawNothingCheckbox.setChecked(False) - - # Right - if right: - self.annotIDsCheckboxRight.setChecked(False) - self.annotCcaInfoCheckboxRight.setChecked(False) - self.annotContourCheckboxRight.setChecked(False) - self.annotSegmMasksCheckboxRight.setChecked(False) - self.drawMothBudLinesCheckboxRight.setChecked(False) - self.drawNothingCheckboxRight.setChecked(False) - - def setDisabledAnnotOptions(self, disabled): - # Left - self.annotIDsCheckbox.setDisabled(disabled) - self.annotCcaInfoCheckbox.setDisabled(disabled) - self.annotContourCheckbox.setDisabled(disabled) - # self.annotSegmMasksCheckbox.setDisabled(disabled) - self.drawMothBudLinesCheckbox.setDisabled(disabled) - # self.drawNothingCheckbox.setDisabled(disabled) + self.zoomRectItem.setPos((0, 0)) + self.zoomRectItem.setSize((0, 0)) - # Right - self.annotIDsCheckboxRight.setDisabled(disabled) - self.annotCcaInfoCheckboxRight.setDisabled(disabled) - self.annotContourCheckboxRight.setDisabled(disabled) - # self.annotSegmMasksCheckboxRight.setDisabled(disabled) - self.drawMothBudLinesCheckboxRight.setDisabled(disabled) - # self.drawNothingCheckboxRight.setDisabled(disabled) - - def drawAnnotCombobox_to_options(self): - self.sync_annotation_options_from_mode_text( - left_text=self.drawIDsContComboBox.currentText(), - right_text=self.annotateRightHowCombobox.currentText(), - left_num_zslices=self.annotNumZslicesCheckbox.isChecked(), - right_num_zslices=self.annotNumZslicesCheckboxRight.isChecked(), - ) \ No newline at end of file + self.ax1.setRange(xRange=(xmin, xmax), yRange=(ymin, ymax), padding=0) diff --git a/cellacdc/mixins/app_shell.py b/cellacdc/mixins/app_shell.py index 251d796b6..0446d48a5 100644 --- a/cellacdc/mixins/app_shell.py +++ b/cellacdc/mixins/app_shell.py @@ -10,72 +10,67 @@ from qtpy.QtWidgets import QWidget from cellacdc import ( - _warnings, base_cca_dict, cca_df_colnames, html_utils, - settings_csv_path, widgets, + _warnings, + base_cca_dict, + cca_df_colnames, + html_utils, + settings_csv_path, + widgets, ) from cellacdc.help import about, welcome -class AppShellView: +class AppShellMixin: """Qt-facing adapter around application shell lifecycle actions.""" """Headless application shell service wrappers.""" - def read_version(self) -> str: - return myutils.read_version() + def _set_qwidget_disabled(self, disabled: bool): + QWidget.setDisabled(self, disabled) - def tooltips_from_docs(self) -> dict: - return get_tooltips_from_docs() + def about(self): + pass def browse_docs(self): return myutils.browse_docs() - def show_in_file_manager(self, path: str): - return myutils.showInExplorer(path) - - def rename_qrc_resources_file(self, color_scheme: str): - return rename_qrc_resources_file(color_scheme) - + def cleanUpOnError(self): + self.onEscape() + caller = "Cell-ACDC" + if self.module.startswith("spotmax"): + caller = "spotMAX" + txt = f"WARNING: {caller} is in error state. Please, restart." + _hl = "*" * 100 + self.titleLabel.setText(txt, color="r") + self.logger.info(f"{_hl}\n{txt}\n{_hl}") - LEGACY_METHODS = ( - 'initGlobalAttr', - 'initProfileModels', - 'setDisabled', - 'determineSlideshowWinPos', - 'setTooltips', - 'setWindowIcon', - 'setWindowTitle', - 'onToggleColorScheme', - 'showAbout', - 'openLogFile', - 'showLogFiles', - 'showInExplorer_cb', - 'showTipsAndTricks', - 'openNewWindow', - 'cleanUpOnError', - 'copyContent', - 'pasteContent', - 'cutContent', - 'about', - ) + def copyContent(self): + pass - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) + def cutContent(self): + pass - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) + def determineSlideshowWinPos(self): + screens = self.app.screens() + self.numScreens = len(screens) + winScreen = self.screen() - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + # Center main window and determine location of slideshow window + # depending on number of screens available + if self.numScreens > 1: + for screen in screens: + if screen != winScreen: + winScreen = screen + break - def _set_qwidget_disabled(self, disabled: bool): - QWidget.setDisabled(self.host, disabled) + winScreenGeom = winScreen.geometry() + winScreenCenter = winScreenGeom.center() + winScreenCenterX = winScreenCenter.x() + winScreenCenterY = winScreenCenter.y() + winScreenGeom.left() + winScreenGeom.top() + self.slideshowWinLeft = winScreenCenterX - int(850 / 2) + self.slideshowWinTop = winScreenCenterY - int(800 / 2) def initGlobalAttr(self): self.setOverlayColors() @@ -113,13 +108,13 @@ def initGlobalAttr(self): self.keptIDsLineEdit, self.keepIDsConfirmAction ) self._ZprojWidgersEnabledState = None - self.imgValueFormatter = 'd' - self.rawValueFormatter = 'd' + self.imgValueFormatter = "d" + self.rawValueFormatter = "d" self.lastHoverID = -1 self.annotOptionsToRestore = None self.annotOptionsToRestoreRight = None self.rescaleIntensChannelHowMapper = { - self.user_ch_name: 'Rescale each 2D image' + self.user_ch_name: "Rescale each 2D image" } self.timestampDialog = None self.scaleBarDialog = None @@ -160,20 +155,17 @@ def initGlobalAttr(self): self.clickObjYc, self.clickObjXc = None, None self.cca_df_colnames = cca_df_colnames - self.cca_df_dtypes = [ - str, int, int, str, int, int, bool, bool, int - ] + self.cca_df_dtypes = [str, int, int, str, int, int, bool, bool, int] self.cca_df_default_values = list(base_cca_dict.values()) self.cca_df_int_cols = [ col for col in cca_df_colnames if type(base_cca_dict[col]) == int ] self.lin_tree_df_bool_col = [ - col for col in cca_df_colnames - if isinstance(base_cca_dict[col], bool) + col for col in cca_df_colnames if isinstance(base_cca_dict[col], bool) ] self.lin_tree_col_checks = [ - 'generation_num', + "generation_num", ] # self.lin_tree_df_colnames = set(base_cca_df.keys()) | set(lineage_tree_cols) @@ -182,18 +174,18 @@ def initGlobalAttr(self): # # ] # self.lin_tree_df_default_values = list(base_cca_df.values()) + lineage_tree_cols_std_val self.lin_tree_df_int_cols = [ - 'generation_num', - 'relative_ID', - 'emerg_frame_i', - 'division_frame_i', - 'corrected_on_frame_i' + "generation_num", + "relative_ID", + "emerg_frame_i", + "division_frame_i", + "corrected_on_frame_i", ] self.lin_tree_df_bool_col = [ - 'is_history_known', + "is_history_known", ] self.lin_tree_col_checks = [ - 'generation_num', + "generation_num", ] self.lin_tree_df_colnames = ( @@ -201,25 +193,94 @@ def initGlobalAttr(self): + self.lin_tree_df_bool_col + self.lin_tree_col_checks ) - self.SegForLostIDsSettings = {} + self.SegForLostIDsSettings = {} def initProfileModels(self): - self.logger.info('Initiliazing profilers...') + self.logger.info("Initiliazing profilers...") - from cellacdc._profile.spline_to_obj import model + from ._profile.spline_to_obj import model self.splineToObjModel = model.Model() self.splineToObjModel.fit() - def setDisabled(self, disabled:bool, keepDisabled:bool=None, force:bool=False): + def onToggleColorScheme(self): + if self.toggleColorSchemeAction.text().find("light") != -1: + self._colorScheme = "light" + else: + self._colorScheme = "dark" + self.gui_updateSwitchColorSchemeActionText() + _warnings.warnRestartCellACDCcolorModeToggled( + self._colorScheme, app_name=self._appName, parent=self + ) + load.rename_qrc_resources_file(self._colorScheme) + self.statusBarLabel.setText( + html_utils.paragraph( + f"Restart {self._appName} for the change to take effect", + font_color="red", + ) + ) + self.df_settings.at["colorScheme", "value"] = self._colorScheme + self.df_settings.to_csv(settings_csv_path) + + def openLogFile(self): + self.logger.info(f'Opening log file "{self.log_path}"...') + myutils.showInExplorer(self.log_path) + + def openNewWindow(self): + self.logger.info("Opening a new window...") + if self.launcherSlot is not None: + self.launcherSlot() + return + + winClass = self.__class__ + win = winClass( + self.app, parent=self, mainWin=self.mainWin, version=self._version + ) + win.run() + self.newWindows.append(win) + + def pasteContent(self): + pass + + def read_version(self) -> str: + return myutils.read_version() + + def rename_qrc_resources_file(self, color_scheme: str): + return rename_qrc_resources_file(color_scheme) + + LEGACY_METHODS = ( + "initGlobalAttr", + "initProfileModels", + "setDisabled", + "determineSlideshowWinPos", + "setTooltips", + "setWindowIcon", + "setWindowTitle", + "onToggleColorScheme", + "showAbout", + "openLogFile", + "showLogFiles", + "showInExplorer_cb", + "showTipsAndTricks", + "openNewWindow", + "cleanUpOnError", + "copyContent", + "pasteContent", + "cutContent", + "about", + ) + + def setDisabled( + self, disabled: bool, keepDisabled: bool = None, force: bool = False + ): if force: if disabled: - self._set_qwidget_disabled(disabled) + super().setDisabled(disabled) return else: self.keepDisabled = False - self._set_qwidget_disabled(disabled) + super().setDisabled(disabled) return if keepDisabled is not None: @@ -227,53 +288,27 @@ def setDisabled(self, disabled:bool, keepDisabled:bool=None, force:bool=False): if self.keepDisabled: if disabled: - self._set_qwidget_disabled(disabled) + super().setDisabled(disabled) return else: return else: - self._set_qwidget_disabled(disabled) - - def determineSlideshowWinPos(self): - screens = self.app.screens() - self.numScreens = len(screens) - winScreen = self.screen() - - # Center main window and determine location of slideshow window - # depending on number of screens available - if self.numScreens > 1: - for screen in screens: - if screen != winScreen: - winScreen = screen - break - - winScreenGeom = winScreen.geometry() - winScreenCenter = winScreenGeom.center() - winScreenCenterX = winScreenCenter.x() - winScreenCenterY = winScreenCenter.y() - winScreenLeft = winScreenGeom.left() - winScreenTop = winScreenGeom.top() - self.slideshowWinLeft = winScreenCenterX - int(850/2) - self.slideshowWinTop = winScreenCenterY - int(800/2) + super().setDisabled(disabled) def setTooltips(self): - tooltips = self.tooltips_from_docs() + tooltips = load.get_tooltips_from_docs() for key, tooltip in tooltips.items(): setShortcut = getattr(self, key).shortcut().toString() - if 'Shortcut: ' in tooltip: - tooltip = tooltip.replace('Shortcut: ', '\nShortcut: ') + if "Shortcut: " in tooltip: + tooltip = tooltip.replace("Shortcut: ", "\nShortcut: ") elif setShortcut != "": tooltip = re.sub( - r'Shortcut: \"(.*)\"', - f"Shortcut: \"{setShortcut}\"", - tooltip + r"Shortcut: \"(.*)\"", f'Shortcut: "{setShortcut}"', tooltip ) else: tooltip = re.sub( - r'Shortcut: \"(.*)\"', - f"Shortcut: \"No shortcut\"", - tooltip + r"Shortcut: \"(.*)\"", 'Shortcut: "No shortcut"', tooltip ) getattr(self, key).setToolTip(tooltip) @@ -282,87 +317,34 @@ def setTooltips(self): def setWindowIcon(self, icon=None): if icon is None: icon = QIcon(":icon.ico") - QWidget.setWindowIcon(self.host, icon) + super().setWindowIcon(icon) def setWindowTitle(self, title=None): if title is None: - title = f'Cell-ACDC v{self._acdc_version} - GUI' - QWidget.setWindowTitle(self.host, title) - - def onToggleColorScheme(self): - if self.toggleColorSchemeAction.text().find('light') != -1: - self._colorScheme = 'light' - else: - self._colorScheme = 'dark' - self.gui_updateSwitchColorSchemeActionText() - _warnings.warnRestartCellACDCcolorModeToggled( - self._colorScheme, app_name=self._appName, parent=self.host - ) - self.rename_qrc_resources_file(self._colorScheme) - self.statusBarLabel.setText(html_utils.paragraph( - f'Restart {self._appName} for the change to take effect', - font_color='red' - )) - self.df_settings.at['colorScheme', 'value'] = self._colorScheme - self.df_settings.to_csv(settings_csv_path) + title = f"Cell-ACDC v{self._acdc_version} - GUI" + super().setWindowTitle(title) def showAbout(self): - self.aboutWin = about.QDialogAbout(parent=self.host) + self.aboutWin = about.QDialogAbout(parent=self) self.aboutWin.show() - def openLogFile(self): - self.logger.info(f'Opening log file "{self.log_path}"...') - self.show_in_file_manager(self.log_path) + def showInExplorer_cb(self): + posData = self.data[self.pos_i] + path = posData.images_path + myutils.showInExplorer(path) def showLogFiles(self): log_files_path = os.path.dirname(self.log_path) self.logger.info(f'Opening log files folder "{log_files_path}"...') - self.show_in_file_manager(log_files_path) - - def showInExplorer_cb(self): - posData = self.data[self.pos_i] - path = posData.images_path - self.show_in_file_manager(path) + myutils.showInExplorer(log_files_path) def showTipsAndTricks(self): self.welcomeWin = welcome.welcomeWin() self.welcomeWin.showAndSetSize() self.welcomeWin.showPage(self.welcomeWin.quickStartItem) - def openNewWindow(self): - self.logger.info('Opening a new window...') - if self.launcherSlot is not None: - self.launcherSlot() - return - - winClass = self.__class__ - win = winClass( - self.app, - parent=self.host, - mainWin=self.mainWin, - version=self._version, - ) - win.run() - self.newWindows.append(win) - - def cleanUpOnError(self): - self.onEscape() - caller = 'Cell-ACDC' - if self.module.startswith('spotmax'): - caller = 'spotMAX' - txt = f'WARNING: {caller} is in error state. Please, restart.' - _hl = '*'*100 - self.titleLabel.setText(txt, color='r') - self.logger.info(f'{_hl}\n{txt}\n{_hl}') - - def copyContent(self): - pass - - def pasteContent(self): - pass - - def cutContent(self): - pass + def show_in_file_manager(self, path: str): + return myutils.showInExplorer(path) - def about(self): - pass \ No newline at end of file + def tooltips_from_docs(self) -> dict: + return get_tooltips_from_docs() diff --git a/cellacdc/mixins/brush_tools.py b/cellacdc/mixins/brush_tools.py index 7623a67a3..25c41d997 100644 --- a/cellacdc/mixins/brush_tools.py +++ b/cellacdc/mixins/brush_tools.py @@ -10,78 +10,179 @@ from cellacdc import html_utils, settings_csv_path, widgets -class BrushToolsView: +class BrushToolsMixin: """Qt-facing adapter around brush and eraser tool workflows.""" - LEGACY_METHODS = ( - 'instructHowDeleteID', - 'checkWarnDeletedIDwithEraser', - 'brushAutoFillToggled', - 'brushAutoHideToggled', - 'fillHolesID', - 'brushReleased', - 'brushSize_cb', - 'autoIDtoggled', - 'Brush_cb', - 'showEditIDwidgets', - 'resetCursors', - 'updateEraserCursor', - 'setDiskMask', - 'getDiskMask', - 'applyEraserMask', - 'changeBrushID', - 'applyBrushMask', - 'setBrushID', - 'initFloodMaskImage', - 'getMagicWandFloodTolerance', - 'initTempLayerBrush', - '_setTempImageBrushContour', - 'setTempBrushMaskFromWand', - 'setTempImg1Brush', - 'getLabelsLayerImage', - 'clearObjFromMask', - 'setTempImg1Eraser', - 'Eraser_cb', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) + # @exec_time + + # @exec_time + + # @exec_time + + # @exec_time + + def Brush_cb(self, checked): + if checked: + self.typingEditID = False + self.setDiskMask() + self.setHoverToolSymbolData( + [], + [], + ( + self.ax1_EraserCircle, + self.ax2_EraserCircle, + self.ax1_EraserX, + self.ax2_EraserX, + ), + ) + self.updateBrushCursor(self.xHoverImg, self.yHoverImg) + self.setBrushID() + + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + c = self.defaultToolBarButtonColor + self.eraserButton.setStyleSheet(f"background-color: {c}") + self.connectLeftClickButtons() + self.setFocusGraphics() else: - setattr(self.host, name, value) + self.ax1_lostObjScatterItem.setVisible(True) + self.ax2_lostObjScatterItem.setVisible(True) + self.ax1_lostTrackedScatterItem.setVisible(True) + self.ax2_lostTrackedScatterItem.setVisible(True) - def checked_setting_value(self, checked: bool) -> str: - return 'Yes' if checked else 'No' + self.setHoverToolSymbolData( + [], + [], + (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + self.resetCursors() - def default_delete_object_info_value(self) -> str: - return 'Yes' + self.showEditIDwidgets(checked) + self.enableSizeSpinbox(checked) - def should_show_delete_object_info(self, setting_value: Any) -> bool: - return setting_value == 'Yes' + def Eraser_cb(self, checked): + if checked: + self.setDiskMask() + self.setHoverToolSymbolData( + [], + [], + (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + self.updateEraserCursor(self.xHoverImg, self.yHoverImg) + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + c = self.defaultToolBarButtonColor + self.brushButton.setStyleSheet(f"background-color: {c}") + self.connectLeftClickButtons() + else: + self.setHoverToolSymbolData( + [], + [], + ( + self.ax1_EraserCircle, + self.ax2_EraserCircle, + self.ax1_EraserX, + self.ax2_EraserX, + ), + ) + self.resetCursors() + self.updateAllImages() - def delete_object_info_value( - self, - do_not_show_again_checked: bool, - ) -> str: - return ( - 'No' - if do_not_show_again_checked - else 'Yes' + self.showEditIDwidgets(checked) + self.enableSizeSpinbox(checked) + + def _setTempImageBrushContour(self): + pass + + def applyBrushMask(self, mask, ID, toLocalSlice=None): + posData = self.data[self.pos_i] + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == "single z-slice" + if isZslice: + if toLocalSlice is not None: + toLocalSlice = (self.z_lab(), *toLocalSlice) + posData.lab[toLocalSlice][mask] = ID + else: + posData.lab[self.z_lab()][mask] = ID + else: + if toLocalSlice is not None: + for z in range(len(posData.lab)): + _slice = (z, *toLocalSlice) + posData.lab[_slice][mask] = ID + else: + posData.lab[:, mask] = ID + else: + if toLocalSlice is not None: + posData.lab[toLocalSlice][mask] = ID + else: + posData.lab[mask] = ID + + def applyEraserMask(self, mask): + posData = self.data[self.pos_i] + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == "single z-slice" + if isZslice: + posData.lab[self.z_lab(), mask] = 0 + else: + posData.lab[:, mask] = 0 + else: + posData.lab[mask] = 0 + + def autoIDtoggled(self, checked): + self.editIDspinboxAction.setDisabled(checked) + self.editIDLabelAction.setDisabled(checked) + if not checked and self.editIDspinbox.value() == 0: + newID = self.setBrushID(return_val=True) + self.editIDspinbox.setValue(newID) + + def brushAutoFillToggled(self, checked): + val = "Yes" if checked else "No" + self.df_settings.at["brushAutoFill", "value"] = val + self.df_settings.to_csv(self.settings_csv_path) + + def brushAutoHideToggled(self, checked): + val = "Yes" if checked else "No" + self.df_settings.at["brushAutoHide", "value"] = val + self.df_settings.to_csv(self.settings_csv_path) + + def brushReleased(self): + posData = self.data[self.pos_i] + self.fillHolesID(posData.brushID, sender="brush") + + # Update data (rp, etc) + self.update_rp( + update_IDs=self.isNewID, ) - def should_fill_holes( - self, - sender: str, - *, - auto_fill_checked: bool, - ) -> bool: - return sender == 'brush' and auto_fill_checked + # Repeat tracking + if self.autoIDcheckbox.isChecked(): + self.trackManuallyAddedObject(posData.brushID, self.isNewID) + else: + self.update_rp(update_IDs=posData.brushID not in posData.IDs_idxs) + + # Update images + if self.isNewID: + editTxt = "Add new ID with brush tool" + if self.isSnapshot: + self.fixCcaDfAfterEdit(editTxt) + self.updateAllImages() + else: + self.warnEditingWithCca_df(editTxt) + else: + self.updateAllImages() + + self.isNewID = False + + def brushSize_cb(self, value): + self.ax2_EraserCircle.setSize(value * 2) + self.ax1_BrushCircle.setSize(value * 2) + self.ax2_BrushCircle.setSize(value * 2) + self.ax1_EraserCircle.setSize(value * 2) + self.ax2_EraserX.setSize(value) + self.ax1_EraserX.setSize(value) + self.setDiskMask() def brush_toolbar_visible( self, @@ -100,8 +201,99 @@ def brush_toolbar_visible( ) ) + def changeBrushID(self): + """Function called when pressing or releasing shift""" + if not self.isSegm3D: + # Changing brush ID with shift is only for 3D segm + return + + if not self.brushButton.isChecked(): + # Brush if not active + return + + if not self.isMouseDragImg2 and not self.isMouseDragImg1: + # Mouse is not brushing at the moment + return + + posData = self.data[self.pos_i] + forceNewObj = not self.isNewID + + if forceNewObj: + # Shift is down --> force new object with brush + # e.g., 24 --> 28: + # 24 is hovering ID that we store as self.prevBrushID + # 24 object becomes 28 that is the new posData.brushID + self.isNewID = True + self.changedID = posData.brushID + self.restoreBrushID = posData.brushID + # Set a new ID + self.setBrushID() + else: + # Shift released or hovering on ID in z+-1 + # --> restore brush ID from before shift was pressed or from + # when we started brushing from outside an object + # but we hovered on ID in z+-1 while dragging. + # We change the entire 28 object to 24 so before changing the + # brush ID back to 24 we builg the mask with 28 to change it to 24 + self.isNewID = False + self.changedID = posData.brushID + # Restore ID + posData.brushID = self.restoreBrushID + + brushID = posData.brushID + brushIDmask = self.get_2Dlab(posData.lab) == self.changedID + self.applyBrushMask(brushIDmask, brushID) + if self.isMouseDragImg1: + self.brushColor = self.lut[posData.brushID] / 255 + self.setTempImg1Brush(True, brushIDmask, posData.brushID) + + def checkWarnDeletedIDwithEraser(self): + posData = self.data[self.pos_i] + + for ID in self.erasedIDs: + if ID == 0: + continue + if ID in posData.IDs_idxs: + continue + + self.instructHowDeleteID() + + if self.isSnapshot: + self.fixCcaDfAfterEdit("Delete ID with eraser") + self.updateAllImages() + else: + self.warnEditingWithCca_df("Delete ID with eraser") + + return True + + return False + + def checked_setting_value(self, checked: bool) -> str: + return "Yes" if checked else "No" + + def clearObjFromMask(self, image, mask, toLocalSlice=None): + if mask is None: + return image + + if toLocalSlice is None: + image[mask] = 0 + else: + image[toLocalSlice][mask] = 0 + + return image + + def default_delete_object_info_value(self) -> str: + return "Yes" + + def delete_object_info_value( + self, + do_not_show_again_checked: bool, + ) -> str: + return "No" if do_not_show_again_checked else "Yes" + def disk_mask(self, brush_size: int): import skimage.morphology + return skimage.morphology.disk(brush_size, dtype=bool) def disk_mask_bounds( @@ -123,7 +315,7 @@ def disk_mask_bounds( y_bottom = 0 elif y_top > y_size: disk_mask = disk_mask.copy() - disk_mask = disk_mask[0:y_size - y_bottom, -x_left:] + disk_mask = disk_mask[0 : y_size - y_bottom, -x_left:] y_top = y_size else: disk_mask = disk_mask.copy() @@ -133,15 +325,15 @@ def disk_mask_bounds( elif x_right > x_size: if y_bottom < 0: disk_mask = disk_mask.copy() - disk_mask = disk_mask[-y_bottom:, 0:x_size - x_left] + disk_mask = disk_mask[-y_bottom:, 0 : x_size - x_left] y_bottom = 0 elif y_top > y_size: disk_mask = disk_mask.copy() - disk_mask = disk_mask[0:y_size - y_bottom, 0:x_size - x_left] + disk_mask = disk_mask[0 : y_size - y_bottom, 0 : x_size - x_left] y_top = y_size else: disk_mask = disk_mask.copy() - disk_mask = disk_mask[:, 0:x_size - x_left] + disk_mask = disk_mask[:, 0 : x_size - x_left] x_right = x_size elif y_bottom < 0: @@ -151,344 +343,191 @@ def disk_mask_bounds( elif y_top > y_size: disk_mask = disk_mask.copy() - disk_mask = disk_mask[0:y_size - y_bottom] + disk_mask = disk_mask[0 : y_size - y_bottom] y_top = y_size return y_bottom, x_left, y_top, x_right, disk_mask - def magic_wand_flood_tolerance( - self, - tolerance_percent: float, - image_min: float, - image_max: float, - ): - if tolerance_percent == 0: - return None - return (image_max - image_min) * (tolerance_percent / 100) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - - def instructHowDeleteID(self): - if 'showInfoDeleteObject' not in self.df_settings.index: - self.df_settings.at['showInfoDeleteObject', 'value'] = ( - self.default_delete_object_info_value() - ) - - showInfoDeleteObject = self.should_show_delete_object_info( - self.df_settings.at['showInfoDeleteObject', 'value'] - ) - if not showInfoDeleteObject: - return - - actionText = self.middleClickText() - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - 'You have deleted an object using the eraser tool.

' - 'Did you know that you can use the "Delete object" action
' - 'to delete an object with a single click?

' - f'To do so, use the following action: {actionText}

' - 'Note: You can also set a custom shortcut by going to the menu
' - 'Settings --> Customise keyboard shortcuts....' - ) - doNotShowAgainCheckbox = QCheckBox('Do not show again') - msg.information( - self.host, 'Delete objects with single click', txt, - widgets=doNotShowAgainCheckbox - ) - - showInfoDeleteObjectValue = self.delete_object_info_value( - doNotShowAgainCheckbox.isChecked() - ) - self.df_settings.at['showInfoDeleteObject', 'value'] = ( - showInfoDeleteObjectValue - ) - self.df_settings.to_csv(settings_csv_path) - - def checkWarnDeletedIDwithEraser(self): + def fillHolesID(self, ID, sender="brush"): posData = self.data[self.pos_i] - - for ID in self.erasedIDs: - if ID == 0: - continue - if ID in posData.IDs_idxs: - continue - - self.instructHowDeleteID() - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete ID with eraser') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Delete ID with eraser') - - return True - - return False - - def brushAutoFillToggled(self, checked): - val = self.checked_setting_value(checked) - self.df_settings.at['brushAutoFill', 'value'] = val - self.df_settings.to_csv(self.settings_csv_path) - - def brushAutoHideToggled(self, checked): - val = self.checked_setting_value(checked) - self.df_settings.at['brushAutoHide', 'value'] = val - self.df_settings.to_csv(self.settings_csv_path) - - # @exec_time - def fillHolesID(self, ID, sender='brush'): - posData = self.data[self.pos_i] - if sender == 'brush': - if not self.should_fill_holes( - sender, - auto_fill_checked=self.brushAutoFillCheckbox.isChecked(), - ): + if sender == "brush": + if not self.brushAutoFillCheckbox.isChecked(): return False lab2D = self.get_2Dlab(posData.lab) - result = self.host.view_model.label_edits.fill_label_holes( - lab2D, - ID, - ) - self.set_2Dlab(result.labels) + mask = lab2D == ID + filledMask = scipy.ndimage.binary_fill_holes(mask) + lab2D[filledMask] = ID + + self.set_2Dlab(lab2D) return True return False - def brushReleased(self): - posData = self.data[self.pos_i] - self.fillHolesID(posData.brushID, sender='brush') - - # Update data (rp, etc) - self.update_rp(update_IDs=self.isNewID,) - - # Repeat tracking - if self.autoIDcheckbox.isChecked(): - self.trackManuallyAddedObject(posData.brushID, self.isNewID) - else: - self.update_rp(update_IDs=posData.brushID not in posData.IDs_idxs) + def getDiskMask(self, xdata, ydata): + Y, X = self.currentLab2D.shape[-2:] - # Update images - if self.isNewID: - editTxt = 'Add new ID with brush tool' - if self.isSnapshot: - self.fixCcaDfAfterEdit(editTxt) - self.updateAllImages() + brushSize = self.brushSizeSpinbox.value() + yBottom, xLeft = ydata - brushSize, xdata - brushSize + yTop, xRight = ydata + brushSize + 1, xdata + brushSize + 1 + + if xLeft < 0: + if yBottom < 0: + # Disk mask out of bounds top-left + diskMask = self.diskMask.copy() + diskMask = diskMask[-yBottom:, -xLeft:] + yBottom = 0 + elif yTop > Y: + # Disk mask out of bounds bottom-left + diskMask = self.diskMask.copy() + diskMask = diskMask[0 : Y - yBottom, -xLeft:] + yTop = Y else: - self.warnEditingWithCca_df(editTxt) - else: - self.updateAllImages() - - self.isNewID = False - - def brushSize_cb(self, value): - self.ax2_EraserCircle.setSize(value*2) - self.ax1_BrushCircle.setSize(value*2) - self.ax2_BrushCircle.setSize(value*2) - self.ax1_EraserCircle.setSize(value*2) - self.ax2_EraserX.setSize(value) - self.ax1_EraserX.setSize(value) - self.setDiskMask() - - def autoIDtoggled(self, checked): - self.editIDspinboxAction.setDisabled(checked) - self.editIDLabelAction.setDisabled(checked) - if not checked and self.editIDspinbox.value() == 0: - newID = self.setBrushID(return_val=True) - self.editIDspinbox.setValue(newID) - - def Brush_cb(self, checked): - if checked: - self.typingEditID = False - self.setDiskMask() - self.setHoverToolSymbolData( - [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX) - ) - self.updateBrushCursor(self.xHoverImg, self.yHoverImg) - self.setBrushID() + # Disk mask out of bounds on the left + diskMask = self.diskMask.copy() + diskMask = diskMask[:, -xLeft:] + xLeft = 0 + + elif xRight > X: + if yBottom < 0: + # Disk mask out of bounds top-right + diskMask = self.diskMask.copy() + diskMask = diskMask[-yBottom:, 0 : X - xLeft] + yBottom = 0 + elif yTop > Y: + # Disk mask out of bounds bottom-right + diskMask = self.diskMask.copy() + diskMask = diskMask[0 : Y - yBottom, 0 : X - xLeft] + yTop = Y + else: + # Disk mask out of bounds on the right + diskMask = self.diskMask.copy() + diskMask = diskMask[:, 0 : X - xLeft] + xRight = X + + elif yBottom < 0: + # Disk mask out of bounds on top + diskMask = self.diskMask.copy() + diskMask = diskMask[-yBottom:] + yBottom = 0 + + elif yTop > Y: + # Disk mask out of bounds on bottom + diskMask = self.diskMask.copy() + diskMask = diskMask[0 : Y - yBottom] + yTop = Y - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - c = self.defaultToolBarButtonColor - self.eraserButton.setStyleSheet(f'background-color: {c}') - self.connectLeftClickButtons() - self.image_controls_view.setFocusGraphics() else: - self.ax1_lostObjScatterItem.setVisible(True) - self.ax2_lostObjScatterItem.setVisible(True) - self.ax1_lostTrackedScatterItem.setVisible(True) - self.ax2_lostTrackedScatterItem.setVisible(True) + # Disk mask fully inside the image + diskMask = self.diskMask - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - self.resetCursors() - - self.showEditIDwidgets(checked) - self.mode_controls_view.enableSizeSpinbox(checked) - - def showEditIDwidgets(self, visible): - self.editIDLabelAction.setVisible(visible) - self.editIDspinboxAction.setVisible(visible) - self.autoIDcheckboxAction.setVisible(visible) - showToolbar = ( - self.brush_toolbar_visible( - visible, - brush_size_visible=self.brushSizeAction.isVisible(), - auto_fill_visible=self.brushAutoFillAction.isVisible(), - auto_hide_visible=self.brushAutoHideAction.isVisible(), - ) - ) - self.brushEraserToolBar.setVisible(showToolbar) - - def resetCursors(self): - self.ax1_cursor.setData([], []) - self.ax2_cursor.setData([], []) - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - def updateEraserCursor(self, x, y, xyLocked=None, isHoverImg1=True): - if x is None: - return - - xdata, ydata = int(x), int(y) - _img = self.currentLab2D - Y, X = _img.shape - - if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): - return + return yBottom, xLeft, yTop, xRight, diskMask - size = self.brushSizeSpinbox.value()*2 - self.setHoverToolSymbolData( - [x], [y], self.activeEraserCircleCursors(isHoverImg1), - size=size - ) - self.setHoverToolSymbolData( - [x], [y], self.activeEraserXCursors(isHoverImg1), - size=int(size/2) - ) - - isMouseDrag = ( - self.isMouseDragImg1 or self.isMouseDragImg2 - ) - if isMouseDrag: - return - - if xyLocked is not None: - xdata, ydata = xyLocked + def getLabelsLayerImage(self, ax=0): + if ax == 0: + return self.labelsLayerImg1.image + else: + return self.labelsLayerRightImg.image - self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, - self.activeEraserCircleCursors(isHoverImg1), - self.eraserButton, hoverRGB=None - ) + def getMagicWandFloodTolerance(self): + tol_perc = self.wandControlsToolbar.toleranceSpinbox.value() + if tol_perc == 0: + return - def setDiskMask(self): - brushSize = self.brushSizeSpinbox.value() - self.diskMask = self.disk_mask(brushSize) + posData = self.data[self.pos_i] + _min, _max = posData.img_data_min_max + tol_fraction = tol_perc / 100 + tol = (_max - _min) * tol_fraction - def getDiskMask(self, xdata, ydata): - brushSize = self.brushSizeSpinbox.value() - return self.disk_mask_bounds( - self.currentLab2D.shape[-2:], - brushSize, - xdata, - ydata, - self.diskMask, - ) + return tol - # @exec_time - def applyEraserMask(self, mask): + def initFloodMaskImage(self): posData = self.data[self.pos_i] - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - posData.lab[self.z_lab(), mask] = 0 - else: - posData.lab[:, mask] = 0 + self.flood_img = posData.img_data[posData.frame_i] + if not self.isSegm3D and posData.SizeZ > 1: + self.flood_img = self.get_2Dimg_from_3D(self.flood_img) + return + + def initTempLayerBrush(self, ID, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() else: - posData.lab[mask] = 0 + how = self.getAnnotateHowRightImage() - def changeBrushID(self): - """Function called when pressing or releasing shift - """ - if not self.isSegm3D: - # Changing brush ID with shift is only for 3D segm - return + self.hideItemsHoverBrush(ID=ID, force=True) + Y, X = self.img1.image.shape[:2] + tempImage = np.zeros((Y, X), dtype=np.uint32) + if how.find("contours") != -1: + tempImage[self.currentLab2D == ID] = ID + self.brushImage = tempImage.copy() + self.brushContourImage = np.zeros((Y, X, 4), dtype=np.uint8) + color = self.imgGrad.contoursColorButton.color() + self.brushContoursRgba = color.getRgb() + opacity = 1.0 + else: + opacity = self.imgGrad.labelsAlphaSlider.value() + color = self.lut[ID] + lut = np.zeros((2, 4), dtype=np.uint8) + lut[1, -1] = 255 + lut[1, :-1] = color + self.tempLayerImg1.setLookupTable(lut) + self.tempLayerImg1.setOpacity(opacity) + self.tempLayerImg1.setImage(tempImage, force_set_linked=True) - if not self.brushButton.isChecked(): - # Brush if not active - return + def instructHowDeleteID(self): + if "showInfoDeleteObject" not in self.df_settings.index: + self.df_settings.at["showInfoDeleteObject", "value"] = "Yes" - if not self.isMouseDragImg2 and not self.isMouseDragImg1: - # Mouse is not brushing at the moment + showInfoDeleteObject = ( + self.df_settings.at["showInfoDeleteObject", "value"] == "Yes" + ) + if not showInfoDeleteObject: return - posData = self.data[self.pos_i] - forceNewObj = not self.isNewID + actionText = self.middleClickText() + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + "You have deleted an object using the eraser tool.

" + 'Did you know that you can use the "Delete object" action
' + "to delete an object with a single click?

" + f"To do so, use the following action: {actionText}

" + "Note: You can also set a custom shortcut by going to the menu
" + "Settings --> Customise keyboard shortcuts...." + ) + doNotShowAgainCheckbox = QCheckBox("Do not show again") + msg.information( + self, + "Delete objects with single click", + txt, + widgets=doNotShowAgainCheckbox, + ) - if forceNewObj: - # Shift is down --> force new object with brush - # e.g., 24 --> 28: - # 24 is hovering ID that we store as self.prevBrushID - # 24 object becomes 28 that is the new posData.brushID - self.isNewID = True - self.changedID = posData.brushID - self.restoreBrushID = posData.brushID - # Set a new ID - self.setBrushID() - else: - # Shift released or hovering on ID in z+-1 - # --> restore brush ID from before shift was pressed or from - # when we started brushing from outside an object - # but we hovered on ID in z+-1 while dragging. - # We change the entire 28 object to 24 so before changing the - # brush ID back to 24 we builg the mask with 28 to change it to 24 - self.isNewID = False - self.changedID = posData.brushID - # Restore ID - posData.brushID = self.restoreBrushID + showInfoDeleteObjectValue = ( + "No" if doNotShowAgainCheckbox.isChecked() else "Yes" + ) + self.df_settings.at["showInfoDeleteObject", "value"] = showInfoDeleteObjectValue + self.df_settings.to_csv(settings_csv_path) - brushID = posData.brushID - brushIDmask = self.get_2Dlab(posData.lab) == self.changedID - self.applyBrushMask(brushIDmask, brushID) - if self.isMouseDragImg1: - self.brushColor = self.lut[posData.brushID]/255 - self.setTempImg1Brush(True, brushIDmask, posData.brushID) + def magic_wand_flood_tolerance( + self, + tolerance_percent: float, + image_min: float, + image_max: float, + ): + if tolerance_percent == 0: + return None + return (image_max - image_min) * (tolerance_percent / 100) - def applyBrushMask(self, mask, ID, toLocalSlice=None): - posData = self.data[self.pos_i] - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - if toLocalSlice is not None: - toLocalSlice = (self.z_lab(), *toLocalSlice) - posData.lab[toLocalSlice][mask] = ID - else: - posData.lab[self.z_lab()][mask] = ID - else: - if toLocalSlice is not None: - for z in range(len(posData.lab)): - _slice = (z, *toLocalSlice) - posData.lab[_slice][mask] = ID - else: - posData.lab[:, mask] = ID - else: - if toLocalSlice is not None: - posData.lab[toLocalSlice][mask] = ID - else: - posData.lab[mask] = ID + def resetCursors(self): + self.ax1_cursor.setData([], []) + self.ax2_cursor.setData([], []) + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() def setBrushID(self, useCurrentLab=True, return_val=False): # Make sure that the brushed ID is always a new one based on # already visited frames posData = self.data[self.pos_i] wl_init = posData.whitelist and posData.whitelist.whitelistIDs - id_groups = [] if useCurrentLab: IDs_tot = set(posData.IDs) if wl_init: @@ -501,90 +540,58 @@ def setBrushID(self, useCurrentLab=True, return_val=False): IDs_tot.update(posData.whitelist.whitelistIDs[posData.frame_i]) except: pass - id_groups.append(IDs_tot) + newID = max(IDs_tot, default=0) + else: + newID = 0 for frame_i, storedData in enumerate(posData.allData_li): if frame_i == posData.frame_i: continue - lab = storedData['labels'] + lab = storedData["labels"] if lab is not None: - rp = storedData['regionprops'] + rp = storedData["regionprops"] IDs_tot = {obj.label for obj in rp} if wl_init: - if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): + if self.whitelistCheckOriginalLabels( + warning=False, frame_i=frame_i + ): IDs_tot.update(posData.whitelist.originalLabsIDs[frame_i]) if posData.whitelist.whitelistIDs[frame_i]: IDs_tot.update(posData.whitelist.whitelistIDs[frame_i]) - id_groups.append(IDs_tot) + _max = max(IDs_tot, default=0) + if _max > newID: + newID = _max else: break - posData.brushID = ( - self.host.view_model.label_edits.next_available_label_id( - id_groups, - manual_edit_info=posData.editID_info, - ) - ) + for y, x, manual_ID in posData.editID_info: + if manual_ID > newID: + newID = manual_ID + posData.brushID = newID + 1 if return_val: return posData.brushID - def initFloodMaskImage(self): - posData = self.data[self.pos_i] - self.flood_img = posData.img_data[posData.frame_i] - if not self.isSegm3D and posData.SizeZ > 1: - self.flood_img = self.get_2Dimg_from_3D(self.flood_img) - return - - def getMagicWandFloodTolerance(self): - tol_perc = self.wandControlsToolbar.toleranceSpinbox.value() - posData = self.data[self.pos_i] - _min, _max = posData.img_data_min_max - return self.magic_wand_flood_tolerance(tol_perc, _min, _max) - - def initTempLayerBrush(self, ID, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - self.hideItemsHoverBrush(ID=ID, force=True) - Y, X = self.img1.image.shape[:2] - tempImage = np.zeros((Y, X), dtype=np.uint32) - if how.find('contours') != -1: - tempImage[self.currentLab2D==ID] = ID - self.brushImage = tempImage.copy() - self.brushContourImage = np.zeros((Y, X, 4), dtype=np.uint8) - color = self.imgGrad.contoursColorButton.color() - self.brushContoursRgba = color.getRgb() - opacity = 1.0 - else: - opacity = self.imgGrad.labelsAlphaSlider.value() - color = self.lut[ID] - lut = np.zeros((2, 4), dtype=np.uint8) - lut[1,-1] = 255 - lut[1,:-1] = color - self.tempLayerImg1.setLookupTable(lut) - self.tempLayerImg1.setOpacity(opacity) - self.tempLayerImg1.setImage(tempImage, force_set_linked=True) - - def _setTempImageBrushContour(self): - pass + def setDiskMask(self): + brushSize = self.brushSizeSpinbox.value() + # diam = brushSize*2 + # center = (brushSize, brushSize) + # diskShape = (diam+1, diam+1) + # diskMask = np.zeros(diskShape, bool) + # rr, cc = skimage.draw.disk(center, brushSize+1, shape=diskShape) + # diskMask[rr, cc] = True + self.diskMask = skimage.morphology.disk(brushSize, dtype=bool) def setTempBrushMaskFromWand(self, flood_mask, init=False): if not np.any(flood_mask): return posData = self.data[self.pos_i] - mask = np.logical_or( - flood_mask, - posData.lab==posData.brushID - ) + mask = np.logical_or(flood_mask, posData.lab == posData.brushID) if mask.ndim == 3: z_slice = self.zSliceScrollBar.sliderPosition() mask = mask[z_slice] self.setTempImg1Brush(init, mask, posData.brushID) - # @exec_time def setTempImg1Brush(self, init: bool, mask, ID, toLocalSlice=None, ax=0): if init: self.initTempLayerBrush(ID, ax=ax) @@ -605,6 +612,9 @@ def setTempImg1Brush(self, init: bool, mask, ID, toLocalSlice=None, ax=0): except IndexError: return objContour = [self.getObjContours(obj)] + # objContour = core.get_obj_contours( + # obj_image=(brushImage>0).astype(np.uint8), local=True + # ) self.brushContourImage[:] = 0 img = self.brushContourImage color = self.brushContoursRgba @@ -613,24 +623,6 @@ def setTempImg1Brush(self, init: bool, mask, ID, toLocalSlice=None, ax=0): else: self.tempLayerImg1.setImage(brushImage, force_set_linked=True) - def getLabelsLayerImage(self, ax=0): - if ax == 0: - return self.labelsLayerImg1.image - else: - return self.labelsLayerRightImg.image - - def clearObjFromMask(self, image, mask, toLocalSlice=None): - if mask is None: - return image - - if toLocalSlice is None: - image[mask] = 0 - else: - image[toLocalSlice][mask] = 0 - - return image - - # @exec_time def setTempImg1Eraser(self, mask, init=False, toLocalSlice=None, ax=0): if init: self.erasedLab = np.zeros_like(self.currentLab2D) @@ -643,14 +635,12 @@ def setTempImg1Eraser(self, mask, init=False, toLocalSlice=None, ax=0): if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): return - if how.find('contours') != -1: - self.clearObjFromMask( - self.contoursImage, mask, toLocalSlice=toLocalSlice - ) + if how.find("contours") != -1: + self.clearObjFromMask(self.contoursImage, mask, toLocalSlice=toLocalSlice) erasedRp = skimage.measure.regionprops(self.erasedLab) for obj in erasedRp: self.addObjContourToContoursImage(obj=obj, ax=ax) - elif how.find('overlay segm. masks') != -1: + elif how.find("overlay segm. masks") != -1: labelsImage = self.getLabelsLayerImage(ax=ax) self.clearObjFromMask(labelsImage, mask, toLocalSlice=toLocalSlice) if ax == 0: @@ -662,25 +652,60 @@ def setTempImg1Eraser(self, mask, init=False, toLocalSlice=None, ax=0): self.labelsLayerRightImg.image, autoLevels=False ) - def Eraser_cb(self, checked): - if checked: - self.setDiskMask() - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - self.updateEraserCursor(self.xHoverImg, self.yHoverImg) - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - c = self.defaultToolBarButtonColor - self.brushButton.setStyleSheet(f'background-color: {c}') - self.connectLeftClickButtons() - else: - self.setHoverToolSymbolData( - [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX) - ) - self.resetCursors() - self.updateAllImages() + def should_fill_holes( + self, + sender: str, + *, + auto_fill_checked: bool, + ) -> bool: + return sender == "brush" and auto_fill_checked - self.showEditIDwidgets(checked) - self.mode_controls_view.enableSizeSpinbox(checked) + def should_show_delete_object_info(self, setting_value: Any) -> bool: + return setting_value == "Yes" + + def showEditIDwidgets(self, visible): + self.editIDLabelAction.setVisible(visible) + self.editIDspinboxAction.setVisible(visible) + self.autoIDcheckboxAction.setVisible(visible) + showToolbar = ( + visible + or self.brushSizeAction.isVisible() + or self.brushAutoFillAction.isVisible() + or self.brushAutoHideAction.isVisible() + ) + self.brushEraserToolBar.setVisible(showToolbar) + + def updateEraserCursor(self, x, y, xyLocked=None, isHoverImg1=True): + if x is None: + return + + xdata, ydata = int(x), int(y) + _img = self.currentLab2D + Y, X = _img.shape + + if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): + return + + size = self.brushSizeSpinbox.value() * 2 + self.setHoverToolSymbolData( + [x], [y], self.activeEraserCircleCursors(isHoverImg1), size=size + ) + self.setHoverToolSymbolData( + [x], [y], self.activeEraserXCursors(isHoverImg1), size=int(size / 2) + ) + + isMouseDrag = self.isMouseDragImg1 or self.isMouseDragImg2 + if isMouseDrag: + return + + if xyLocked is not None: + xdata, ydata = xyLocked + + self.setHoverToolSymbolColor( + xdata, + ydata, + self.eraserCirclePen, + self.activeEraserCircleCursors(isHoverImg1), + self.eraserButton, + hoverRGB=None, + ) diff --git a/cellacdc/mixins/canvas_context_menu.py b/cellacdc/mixins/canvas_context_menu.py index 2db575c1e..059280210 100644 --- a/cellacdc/mixins/canvas_context_menu.py +++ b/cellacdc/mixins/canvas_context_menu.py @@ -3,88 +3,48 @@ from __future__ import annotations import pyqtgraph as pg -from dataclasses import dataclass from qtpy.QtCore import QPoint from qtpy.QtWidgets import QAction, QMenu - -class CanvasContextMenuView: +class CanvasContextMenuMixin: """Qt-facing adapter around canvas context-menu contracts.""" """Headless canvas context-menu decision rules.""" - scale_bar_target = 'scale_bar' - timestamp_target = 'timestamp' - gradient_target = 'gradient' + scale_bar_target = "scale_bar" + timestamp_target = "timestamp" + gradient_target = "gradient" - def image_gradient_menu_target( - self, - *, - scale_bar_highlighted: bool, - timestamp_highlighted: bool, - ) -> str: - if scale_bar_highlighted: - return self.scale_bar_target - if timestamp_highlighted: - return self.timestamp_target - return self.gradient_target - - def deleted_roi_click_decision( - self, - *, - clicked_on_roi: bool, - left_click: bool, - right_click: bool, - ) -> DeletedRoiClickDecision: - if not clicked_on_roi: - return DeletedRoiClickDecision(handled=False) - if right_click: - return DeletedRoiClickDecision( - handled=True, - show_context_menu=True, - ) - if left_click: - return DeletedRoiClickDecision(handled=True, drag_roi=True) - return DeletedRoiClickDecision(handled=False) - - - def __init__(self, host): - self.host = host - def show_img_gradient_context_menu(self, x, y): - target = self.image_gradient_menu_target( - scale_bar_highlighted=self._scale_bar_highlighted(), - timestamp_highlighted=self._timestamp_highlighted(), - ) - if target == self.scale_bar_target: - self.host.scaleBar.showContextMenu(x, y) - return - if target == self.timestamp_target: - self.host.timestamp.showContextMenu(x, y) - return - self.host.imgGrad.gradient.menu.popup(QPoint(int(x), int(y))) + def _scale_bar_highlighted(self): + return hasattr(self, "scaleBar") and self.scaleBar.isHighlighted() - def show_right_image_context_menu(self, event): + def _show_deleted_roi_context_menu(self, event): + self.roiContextMenu = QMenu(self) + separator = QAction(self) + separator.setSeparator(True) + self.roiContextMenu.addAction(separator) + action = QAction("Remove ROI") + action.triggered.connect(self.removeDelROI) + self.roiContextMenu.addAction(action) try: screen_pos = event.screenPos().toPoint() except AttributeError: screen_pos = event.screenPos() - self.host.imgGradRight.gradient.menu.popup(screen_pos) + self.roiContextMenu.exec_(screen_pos) + + def _timestamp_highlighted(self): + return hasattr(self, "timestamp") and self.timestamp.isHighlighted() def clicked_deleted_roi(self, event, left_click, right_click): - pos_data = self.host.data[self.host.pos_i] + pos_data = self.data[self.pos_i] x, y = event.pos().x(), event.pos().y() - del_rois = ( - pos_data.allData_li[pos_data.frame_i]['delROIs_info']['rois'] - .copy() - ) + del_rois = pos_data.allData_li[pos_data.frame_i]["delROIs_info"]["rois"].copy() for roi in del_rois: - roi_mask = self.host.getDelRoiMask(roi) - if self.host.isSegm3D: - clicked_on_roi = roi_mask[ - self.host.z_lab(), int(y), int(x) - ] + roi_mask = self.getDelRoiMask(roi) + if self.isSegm3D: + clicked_on_roi = roi_mask[self.z_lab(), int(y), int(x)] else: clicked_on_roi = roi_mask[int(y), int(x)] decision = self.deleted_roi_click_decision( @@ -93,7 +53,7 @@ def clicked_deleted_roi(self, event, left_click, right_click): right_click=right_click, ) if decision.show_context_menu: - self.host.roi_to_del = roi + self.roi_to_del = roi self._show_deleted_roi_context_menu(event) return True if decision.drag_roi: @@ -101,13 +61,42 @@ def clicked_deleted_roi(self, event, left_click, right_click): return True return False + def deleted_roi_click_decision( + self, + *, + clicked_on_roi: bool, + left_click: bool, + right_click: bool, + ) -> DeletedRoiClickDecision: + if not clicked_on_roi: + return DeletedRoiClickDecision(handled=False) + if right_click: + return DeletedRoiClickDecision( + handled=True, + show_context_menu=True, + ) + if left_click: + return DeletedRoiClickDecision(handled=True, drag_roi=True) + return DeletedRoiClickDecision(handled=False) + + def hovered_handles_polyline_roi(self): + pos_data = self.data[self.pos_i] + del_rois_info = pos_data.allData_li[pos_data.frame_i]["delROIs_info"] + handles = [] + for roi in del_rois_info["rois"]: + if not isinstance(roi, pg.PolyLineROI): + continue + for handle in roi.getHandles(): + if handle.currentPen == handle.hoverPen: + handle.roi = roi + handles.append(handle) + return handles + def hovered_segments_polyline_roi(self): - pos_data = self.host.data[self.host.pos_i] - del_rois_info = ( - pos_data.allData_li[pos_data.frame_i]['delROIs_info'] - ) + pos_data = self.data[self.pos_i] + del_rois_info = pos_data.allData_li[pos_data.frame_i]["delROIs_info"] segments = [] - for roi in del_rois_info['rois']: + for roi in del_rois_info["rois"]: if not isinstance(roi, pg.PolyLineROI): continue for segment in roi.segments: @@ -116,43 +105,34 @@ def hovered_segments_polyline_roi(self): segments.append(segment) return segments - def hovered_handles_polyline_roi(self): - pos_data = self.host.data[self.host.pos_i] - del_rois_info = ( - pos_data.allData_li[pos_data.frame_i]['delROIs_info'] + def image_gradient_menu_target( + self, + *, + scale_bar_highlighted: bool, + timestamp_highlighted: bool, + ) -> str: + if scale_bar_highlighted: + return self.scale_bar_target + if timestamp_highlighted: + return self.timestamp_target + return self.gradient_target + + def show_img_gradient_context_menu(self, x, y): + target = self.image_gradient_menu_target( + scale_bar_highlighted=self._scale_bar_highlighted(), + timestamp_highlighted=self._timestamp_highlighted(), ) - handles = [] - for roi in del_rois_info['rois']: - if not isinstance(roi, pg.PolyLineROI): - continue - for handle in roi.getHandles(): - if handle.currentPen == handle.hoverPen: - handle.roi = roi - handles.append(handle) - return handles + if target == self.scale_bar_target: + self.scaleBar.showContextMenu(x, y) + return + if target == self.timestamp_target: + self.timestamp.showContextMenu(x, y) + return + self.imgGrad.gradient.menu.popup(QPoint(int(x), int(y))) - def _show_deleted_roi_context_menu(self, event): - self.host.roiContextMenu = QMenu(self.host) - separator = QAction(self.host) - separator.setSeparator(True) - self.host.roiContextMenu.addAction(separator) - action = QAction('Remove ROI') - action.triggered.connect(self.host.removeDelROI) - self.host.roiContextMenu.addAction(action) + def show_right_image_context_menu(self, event): try: screen_pos = event.screenPos().toPoint() except AttributeError: screen_pos = event.screenPos() - self.host.roiContextMenu.exec_(screen_pos) - - def _scale_bar_highlighted(self): - return ( - hasattr(self.host, 'scaleBar') - and self.host.scaleBar.isHighlighted() - ) - - def _timestamp_highlighted(self): - return ( - hasattr(self.host, 'timestamp') - and self.host.timestamp.isHighlighted() - ) \ No newline at end of file + self.imgGradRight.gradient.menu.popup(screen_pos) diff --git a/cellacdc/mixins/canvas_drawing.py b/cellacdc/mixins/canvas_drawing.py index 9222d1db5..58bbf9127 100644 --- a/cellacdc/mixins/canvas_drawing.py +++ b/cellacdc/mixins/canvas_drawing.py @@ -2,7 +2,6 @@ from __future__ import annotations -import numpy as np import numpy as np import skimage.segmentation @@ -13,23 +12,12 @@ from cellacdc import apps, exception_handler, html_utils, widgets -class CanvasDrawingView: +class CanvasDrawingMixin: """Qt-facing adapter for canvas drawing workflows.""" """Headless decisions for canvas drawing workflows.""" - viewer_mode = 'Viewer' - - def should_process_canvas_event( - self, - *, - mode: str, - in_bounds: bool, - ) -> bool: - return mode != self.viewer_mode and in_bounds - - def should_clear_after_out_of_bounds(self, *, image: str) -> bool: - return image == 'img1' + viewer_mode = "Viewer" def calculate_brush_mask( self, @@ -50,30 +38,13 @@ def calculate_brush_mask( mask[rr_poly, cc_poly] = True return mask - - LEGACY_METHODS = ( - 'gui_addCreatedAxesItems', - 'gui_mouseDragEventImg1', - 'gui_mouseDragEventImg2', - 'gui_mouseReleaseEventImg1', + "gui_addCreatedAxesItems", + "gui_mouseDragEventImg1", + "gui_mouseDragEventImg2", + "gui_mouseReleaseEventImg1", ) - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - def gui_addCreatedAxesItems(self): self.ax1.addItem(self.ax1_contoursImageItem) self.ax1.addItem(self.ax1_lostObjImageItem) @@ -102,38 +73,34 @@ def gui_addCreatedAxesItems(self): def gui_mouseDragEventImg1(self, event): x, y = event.pos().x(), event.pos().y() - if hasattr(self, 'scaleBar'): + if hasattr(self, "scaleBar"): if self.scaleBarDialog is not None: - self.scaleBarDialog.locCombobox.setCurrentText('Custom') + self.scaleBarDialog.locCombobox.setCurrentText("Custom") if self.scaleBar.isHighlighted() and self.scaleBar.clicked: - self.scaleBar.setLocationProperty('custom') + self.scaleBar.setLocationProperty("custom") self.scaleBar.move(x, y) return - if hasattr(self, 'timestamp'): + if hasattr(self, "timestamp"): if self.timestampDialog is not None: - self.timestampDialog.locCombobox.setCurrentText('Custom') + self.timestampDialog.locCombobox.setCurrentText("Custom") if self.timestamp.isHighlighted() and self.timestamp.clicked: - self.timestamp.setLocationProperty('custom') + self.timestamp.setLocationProperty("custom") self.timestamp.move(x, y) return mode = str(self.modeComboBox.currentText()) + if mode == "Viewer": + return + posData = self.data[self.pos_i] Y, X = self.get_2Dlab(posData.lab).shape xdata, ydata = int(x), int(y) - in_bounds = self.is_in_bounds(xdata, ydata, X, Y) - if not self.should_process_canvas_event( - mode=mode, - in_bounds=in_bounds, - ): - return - - if self._dispatch_tool_event_if_enabled(event, phase='drag', image='img1'): + if not myutils.is_in_bounds(xdata, ydata, X, Y): return if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): - self.curvature_tools_view.drawAutoContour(y, x) + self.drawAutoContour(y, x) # Brush dragging mouse --> keep brushing elif self.isMouseDragImg1 and self.brushButton.isChecked(): @@ -142,29 +109,30 @@ def gui_mouseDragEventImg1(self, event): # t1 = time.perf_counter() ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - rrPoly, ccPoly = self.curvature_tools_view.getPolygonBrush( - (y, x), Y, X - ) + rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) # t2 = time.perf_counter() diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) # Build brush mask - mask = self.calculate_brush_mask( - lab_2D.shape, ymin, xmin, ymax, xmax, diskMask, rrPoly, ccPoly - ) + mask = np.zeros(lab_2D.shape, bool) + mask[diskSlice][diskMask] = True + mask[rrPoly, ccPoly] = True modifiers = QGuiApplication.keyboardModifiers() ctrl = modifiers == Qt.ControlModifier # t3 = time.perf_counter() if not self.isPowerBrush() and not ctrl: - mask[lab_2D!=0] = False + mask[lab_2D != 0] = False self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, + xdata, + ydata, + self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush + self.brushButton, + brush=self.ax2_BrushCircleBrush, ) # t4 = time.perf_counter() @@ -177,12 +145,9 @@ def gui_mouseDragEventImg1(self, event): # t5 = time.perf_counter() lab2D = self.get_2Dlab(posData.lab) - brushMask = np.logical_and( - lab2D[diskSlice] == posData.brushID, diskMask - ) + brushMask = np.logical_and(lab2D[diskSlice] == posData.brushID, diskMask) self.setTempImg1Brush( - False, brushMask, posData.brushID, - toLocalSlice=diskSlice + False, brushMask, posData.brushID, toLocalSlice=diskSlice ) # t6 = time.perf_counter() @@ -202,26 +167,27 @@ def gui_mouseDragEventImg1(self, event): elif self.isMouseDragImg1 and self.eraserButton.isChecked(): posData = self.data[self.pos_i] lab_2D = self.get_2Dlab(posData.lab) - rrPoly, ccPoly = self.curvature_tools_view.getPolygonBrush( - (y, x), Y, X - ) + rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) # Build eraser mask - mask = self.calculate_brush_mask( - lab_2D.shape, ymin, xmin, ymax, xmax, diskMask, rrPoly, ccPoly - ) + mask = np.zeros(lab_2D.shape, bool) + mask[ymin:ymax, xmin:xmax][diskMask] = True + mask[rrPoly, ccPoly] = True if self.eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False + mask[lab_2D != self.erasedID] = False self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, + xdata, + ydata, + self.eraserCirclePen, (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], - ID=self.erasedID + self.eraserButton, + hoverRGB=self.img2.lut[self.erasedID], + ID=self.erasedID, ) self.erasedIDs.update(lab_2D[mask]) @@ -232,7 +198,7 @@ def gui_mouseDragEventImg1(self, event): for erasedID in self.erasedIDs: if erasedID == 0: continue - self.erasedLab[lab_2D==erasedID] = erasedID + self.erasedLab[lab_2D == erasedID] = erasedID self.erasedLab[mask] = 0 eraserMask = mask[diskSlice] @@ -242,7 +208,7 @@ def gui_mouseDragEventImg1(self, event): # Move label dragging mouse --> keep moving elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): x, y = event.pos().x(), event.pos().y() - self.label_transform_tools_view.move_label(x, y) + self.moveLabel(x, y) # Wand dragging mouse --> keep doing the magic elif self.isMouseDragImg1 and self.wandToolButton.isChecked(): @@ -253,25 +219,19 @@ def gui_mouseDragEventImg1(self, event): else: seed = (ydata, xdata) - flood_mask = skimage.segmentation.flood( - self.flood_img, seed, tolerance=tol - ) + flood_mask = skimage.segmentation.flood(self.flood_img, seed, tolerance=tol) drawUnderMask = np.logical_or( - posData.lab==0, posData.lab==posData.brushID + posData.lab == 0, posData.lab == posData.brushID ) flood_mask = np.logical_and(flood_mask, drawUnderMask) self.flood_mask[flood_mask] = True if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): - self.flood_mask = self.binary_fill_holes( - self.flood_mask - ) + self.flood_mask = core.binary_fill_holes(self.flood_mask) if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): - self.flood_mask = self.convex_hull_mask( - self.flood_mask - ) + self.flood_mask = core.convex_hull_mask(self.flood_mask) self.setTempBrushMaskFromWand(self.flood_mask) @@ -279,7 +239,7 @@ def gui_mouseDragEventImg1(self, event): elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): if self.labelRoiIsRectRadioButton.isChecked(): x0, y0 = self.labelRoiItem.pos() - w, h = (xdata-x0), (ydata-y0) + w, h = (xdata - x0), (ydata - y0) self.labelRoiItem.setSize((w, h)) elif self.labelRoiIsFreeHandRadioButton.isChecked(): self.freeRoiItem.addPoint(xdata, ydata) @@ -291,25 +251,20 @@ def gui_mouseDragEventImg1(self, event): # Label ROI dragging mouse --> draw ROI elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): x0, y0 = self.zoomRectItem.pos() - w, h = (xdata-x0), (ydata-y0) + w, h = (xdata - x0), (ydata - y0) self.zoomRectItem.setSize((w, h)) @exception_handler def gui_mouseDragEventImg2(self, event): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) + if mode == "Viewer": + return Y, X = self.get_2Dlab(posData.lab).shape x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - in_bounds = self.is_in_bounds(xdata, ydata, X, Y) - if not self.should_process_canvas_event( - mode=mode, - in_bounds=in_bounds, - ): - return - - if self._dispatch_tool_event_if_enabled(event, phase='drag', image='img2'): + if not myutils.is_in_bounds(xdata, ydata, X, Y): return # Eraser dragging mouse --> keep erasing @@ -319,25 +274,26 @@ def gui_mouseDragEventImg2(self, event): Y, X = lab_2D.shape x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - brushSize = self.brushSizeSpinbox.value() - rrPoly, ccPoly = self.curvature_tools_view.getPolygonBrush( - (y, x), Y, X - ) + self.brushSizeSpinbox.value() + rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) # Build eraser mask - mask = self.calculate_brush_mask( - lab_2D.shape, ymin, xmin, ymax, xmax, diskMask, rrPoly, ccPoly - ) + mask = np.zeros(lab_2D.shape, bool) + mask[ymin:ymax, xmin:xmax][diskMask] = True + mask[rrPoly, ccPoly] = True if self.eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False + mask[lab_2D != self.erasedID] = False self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, + xdata, + ydata, + self.eraserCirclePen, (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], - ID=self.erasedID + self.eraserButton, + hoverRGB=self.img2.lut[self.erasedID], + ID=self.erasedID, ) self.erasedIDs.update(lab_2D[mask]) @@ -354,23 +310,24 @@ def gui_mouseDragEventImg2(self, event): xdata, ydata = int(x), int(y) ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - rrPoly, ccPoly = self.curvature_tools_view.getPolygonBrush( - (y, x), Y, X - ) + rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) # Build brush mask - mask = self.calculate_brush_mask( - lab_2D.shape, ymin, xmin, ymax, xmax, diskMask, rrPoly, ccPoly - ) + mask = np.zeros(lab_2D.shape, bool) + mask[ymin:ymax, xmin:xmax][diskMask] = True + mask[rrPoly, ccPoly] = True # If user double-pressed 'b' then draw over the labels color = self.brushButton.palette().button().color().name() if color != self.doublePressKeyButtonColor: - mask[lab_2D!=0] = False + mask[lab_2D != 0] = False self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, + xdata, + ydata, + self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.eraserButton, brush=self.ax2_BrushCircleBrush + self.eraserButton, + brush=self.ax2_BrushCircleBrush, ) # Apply brush mask @@ -381,53 +338,40 @@ def gui_mouseDragEventImg2(self, event): # Move label dragging mouse --> keep moving elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): x, y = event.pos().x(), event.pos().y() - self.label_transform_tools_view.move_label(x, y) + self.moveLabel(x, y) @exception_handler def gui_mouseReleaseEventImg1(self, event): modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier alt = modifiers == Qt.AltModifier right_click = event.button() == Qt.MouseButton.RightButton and not alt posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if self.is_viewer_mode(mode): - return - - if self._dispatch_tool_event_if_enabled(event, phase='release', image='img1'): + if mode == "Viewer": return Y, X = self.get_2Dlab(posData.lab).shape x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - in_bounds = self.is_in_bounds(xdata, ydata, X, Y) - if not self.should_process_canvas_event( - mode=mode, - in_bounds=in_bounds, - ): - if self.should_clear_after_out_of_bounds(image='img1'): - self.isMouseDragImg2 = False - self.updateAllImages() + if not myutils.is_in_bounds(xdata, ydata, X, Y): + self.isMouseDragImg2 = False + self.updateAllImages() return - if hasattr(self, 'scaleBar'): + if hasattr(self, "scaleBar"): if self.scaleBar.isHighlighted() and self.scaleBar.clicked: self.scaleBar.clicked = False return - if hasattr(self, 'timestamp'): + if hasattr(self, "timestamp"): if self.timestamp.isHighlighted() and self.timestamp.clicked: self.timestamp.clicked = False return sendRightClickImg2 = ( - self.canvas_tool_view.should_forward_img1_release_to_img2( - right_click=right_click, - mode=mode, - is_snapshot=self.isSnapshot, - ) - ) + mode == "Segmentation and Tracking" or self.isSnapshot + ) and right_click if sendRightClickImg2: # Allow right-click actions on both images self.gui_mouseReleaseEventImg2(event) @@ -436,22 +380,20 @@ def gui_mouseReleaseEventImg1(self, event): if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): self.isRightClickDragImg1 = False try: - self.curvature_tools_view.curvToolSplineToObj( - isRightClick=True - ) + self.curvToolSplineToObj(isRightClick=True) self.update_rp() if self.autoIDcheckbox.isChecked(): self.trackManuallyAddedObject(posData.brushID, True) if self.isSnapshot: - self.fixCcaDfAfterEdit('Add new ID with curvature tool') + self.fixCcaDfAfterEdit("Add new ID with curvature tool") self.updateAllImages() else: - self.warnEditingWithCca_df('Add new ID with curvature tool') - self.curvature_tools_view.clearCurvItems() - self.curvature_tools_view.curvTool_cb(True) + self.warnEditingWithCca_df("Add new ID with curvature tool") + self.clearCurvItems() + self.curvTool_cb(True) except ValueError: - self.curvature_tools_view.clearCurvItems() - self.curvature_tools_view.curvTool_cb(True) + self.clearCurvItems() + self.curvTool_cb(True) pass # Eraser mouse release --> update IDs and contours @@ -492,10 +434,10 @@ def gui_mouseReleaseEventImg1(self, event): self.trackManuallyAddedObject(posData.brushID, self.isNewID) if self.isSnapshot: - self.fixCcaDfAfterEdit('Add new ID with magic-wand') + self.fixCcaDfAfterEdit("Add new ID with magic-wand") self.updateAllImages() else: - self.warnEditingWithCca_df('Add new ID with magic-wand') + self.warnEditingWithCca_df("Add new ID with magic-wand") # Label ROI mouse release --> label the ROI with labelRoiWorker elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): @@ -541,25 +483,26 @@ def gui_mouseReleaseEventImg1(self, event): start_n = self.labelRoiStartFrameNoSpinbox.value() stop_n = self.labelRoiStopFrameNoSpinbox.value() self.progressWin = apps.QDialogWorkerProgress( - title='ROI segmentation', parent=self.host, - pbarDesc=f'Segmenting frames n. {start_n} to {stop_n}...' + title="ROI segmentation", + parent=self, + pbarDesc=f"Segmenting frames n. {start_n} to {stop_n}...", ) self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stop_n-start_n) - + self.progressWin.mainPbar.setMaximum(stop_n - start_n) self.app.restoreOverrideCursor() labelRoiWorker = self.labelRoiActiveWorkers[-1] labelRoiWorker.start( - roiImg, posData, + roiImg, + posData, roiSecondChannel=roiSecondChannel, - isTimelapse=isTimelapse + isTimelapse=isTimelapse, ) self.app.setOverrideCursor(Qt.WaitCursor) self.logger.info( - f'Magic labeller started on image ROI = {self.labelRoiSlice}...' + f"Magic labeller started on image ROI = {self.labelRoiSlice}..." ) - self.titleLabel.setText('Magic labeller is doing its magic...') + self.titleLabel.setText("Magic labeller is doing its magic...") self.setDisabled(True) # Move label mouse released, update move @@ -586,16 +529,15 @@ def gui_mouseReleaseEventImg1(self, event): return if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) mothID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as mother cell', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to annotate as mother cell", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) mothID_prompt.exec_() if mothID_prompt.cancel: @@ -610,22 +552,20 @@ def gui_mouseReleaseEventImg1(self, event): # Store undo state before modifying stuff self.storeUndoRedoStates(False) - relationship = posData.cca_df.at[ID, 'relationship'] - ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] - is_history_known = posData.cca_df.at[ID, 'is_history_known'] + relationship = posData.cca_df.at[ID, "relationship"] + ccs = posData.cca_df.at[ID, "cell_cycle_stage"] + is_history_known = posData.cca_df.at[ID, "is_history_known"] # We allow assiging a cell in G1 as mother only on first frame # OR if the history is unknown - if relationship == 'bud' and posData.frame_i > 0 and is_history_known: + if relationship == "bud" and posData.frame_i > 0 and is_history_known: self.assignBudMothButton.setChecked(False) txt = html_utils.paragraph( - f'You clicked on ID {ID} which is a BUD.

' - 'To assign a bud start by clicking on the bud ' - 'and release on a cell in G1' + f"You clicked on ID {ID} which is a BUD.

" + "To assign a bud start by clicking on the bud " + "and release on a cell in G1" ) msg = widgets.myMessageBox() - msg.critical( - self.host, 'Released on a bud', txt - ) + msg.critical(self, "Released on a bud", txt) self.assignBudMothButton.setChecked(True) return @@ -643,26 +583,29 @@ def gui_mouseReleaseEventImg1(self, event): self.assignBudMothButton.setChecked(False) msg = widgets.myMessageBox() txt = ( - f'You clicked FIRST on ID {budID} and then on {new_mothID}.
' - f'For me this means that you want ID {budID} to be the ' - f'BUD of ID {new_mothID}.
' - f'However ID {budID} is bigger than {new_mothID} ' - f'so maybe you should have clicked FIRST on {new_mothID}?

' - 'What do you want me to do?' + f"You clicked FIRST on ID {budID} and then on {new_mothID}.
" + f"For me this means that you want ID {budID} to be the " + f"BUD of ID {new_mothID}.
" + f"However ID {budID} is bigger than {new_mothID} " + f"so maybe you should have clicked FIRST on {new_mothID}?

" + "What do you want me to do?" ) txt = html_utils.paragraph(txt) swapButton, keepButton = msg.warning( - self.host, 'Which one is bud?', txt, + self, + "Which one is bud?", + txt, buttonsTexts=( - f'Assign ID {new_mothID} as the bud of ID {budID}', - f'Keep ID {budID} as the bud of ID {new_mothID}' - ) + f"Assign ID {new_mothID} as the bud of ID {budID}", + f"Keep ID {budID} as the bud of ID {new_mothID}", + ), ) if msg.clickedButton == swapButton: - (xdata, ydata, - self.xClickBud, self.yClickBud) = ( - self.xClickBud, self.yClickBud, - xdata, ydata + (xdata, ydata, self.xClickBud, self.yClickBud) = ( + self.xClickBud, + self.yClickBud, + xdata, + ydata, ) self.assignBudMothButton.setChecked(True) @@ -671,26 +614,21 @@ def gui_mouseReleaseEventImg1(self, event): budID = self.get_2Dlab(posData.lab)[ydata, xdata] # Allow assigning an unknown cell ONLY to another unknown cell txt = ( - f'You started by clicking on ID {budID} which has ' - 'UNKNOWN history, but you then clicked/released on ' - f'ID {ID} which has KNOWN history.\n\n' - 'Only two cells with UNKNOWN history can be assigned as ' - 'relative of each other.' + f"You started by clicking on ID {budID} which has " + "UNKNOWN history, but you then clicked/released on " + f"ID {ID} which has KNOWN history.\n\n" + "Only two cells with UNKNOWN history can be assigned as " + "relative of each other." ) msg = QMessageBox() - msg.critical( - self.host, - 'Released on a cell with KNOWN history', - txt, - msg.Ok, - ) + msg.critical(self, "Released on a cell with KNOWN history", txt, msg.Ok) self.assignBudMothButton.setChecked(True) return self.clickedOnHistoryKnown = is_history_known self.xClickMoth, self.yClickMoth = xdata, ydata - if ccs != 'G1' and posData.frame_i > 0: + if ccs != "G1" and posData.frame_i > 0: self.assignBudMothButton.setChecked(False) self.onMotherNotInG1(ID) self.assignBudMothButton.setChecked(True) @@ -707,9 +645,20 @@ def gui_mouseReleaseEventImg1(self, event): elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): self.isMouseDragImg1 = False self.freeRoiItem.closeCurve() - self.draw_clear_region_view.clear_objects_in_freehand_region() + self.clearObjsFreehandRegion() # Zoom rect mouse release elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): self.isMouseDragImg1 = False - self.zoomRectDone() \ No newline at end of file + self.zoomRectDone() + + def should_clear_after_out_of_bounds(self, *, image: str) -> bool: + return image == "img1" + + def should_process_canvas_event( + self, + *, + mode: str, + in_bounds: bool, + ) -> bool: + return mode != self.viewer_mode and in_bounds diff --git a/cellacdc/mixins/canvas_events.py b/cellacdc/mixins/canvas_events.py index f851fab3a..3a94686e5 100644 --- a/cellacdc/mixins/canvas_events.py +++ b/cellacdc/mixins/canvas_events.py @@ -4,7 +4,6 @@ import numpy as np import pyqtgraph as pg -import numpy as np import skimage.segmentation from qtpy.QtCore import Qt, QTimer @@ -14,7 +13,7 @@ from cellacdc import apps, exception_handler -class CanvasEventsView: +class CanvasEventsMixin: """Qt-facing adapter for canvas mouse event routing.""" """Headless canvas event routing rules and brush mask computations.""" @@ -38,43 +37,8 @@ def calculate_brush_mask( mask[rr_poly, cc_poly] = True return mask - def map_mouse_coordinates_to_label_id( - self, - mouse_pos: tuple[float, float], - label_matrix: np.ndarray, - ) -> int: - """Resolves float pixel coordinate lookup to integer label ID.""" - x, y = mouse_pos - xdata, ydata = int(x), int(y) - height, width = label_matrix.shape - if 0 <= xdata < width and 0 <= ydata < height: - return int(label_matrix[ydata, xdata]) - return 0 - - - LEGACY_METHODS = ( - 'gui_mousePressEventImg1', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - @exception_handler def gui_mousePressEventImg1(self, event: QMouseEvent): - if self._dispatch_tool_event_if_enabled(event, phase='press', image='img1'): - return self.typingEditID = False modifiers = QGuiApplication.keyboardModifiers() ctrl = modifiers == Qt.ControlModifier @@ -82,8 +46,8 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): isMod = alt posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - isCcaMode = mode == 'Cell cycle analysis' - isCustomAnnotMode = mode == 'Custom annotations' + isCcaMode = mode == "Cell cycle analysis" + isCustomAnnotMode = mode == "Custom annotations" left_click = event.button() == Qt.MouseButton.LeftButton and not isMod middle_click = self.isMiddleClick(event, modifiers) right_click = event.button() == Qt.MouseButton.RightButton @@ -105,7 +69,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): pointsLayerON = self.togglePointsLayerAction.isChecked() copyContourON = ( self.copyLostObjButton.isChecked() - and self.ax1_lostObjScatterItem.hoverLostID>0 + and self.ax1_lostObjScatterItem.hoverLostID > 0 ) findNextMotherButtonON = self.findNextMotherButton.isChecked() unknownLineageButtonON = self.unknownLineageButton.isChecked() @@ -113,43 +77,49 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): zoomRectON = self.zoomRectButton.isChecked() # Check if right-click on segment of polyline roi to add segment - segments = self.canvas_context_menu_view.hovered_segments_polyline_roi() + segments = self.gui_getHoveredSegmentsPolyLineRoi() if len(segments) == 1 and right_click: seg = segments[0] seg.roi.segmentClicked(seg, event) return # Check if right-click on handle of polyline roi to remove it - handles = self.canvas_context_menu_view.hovered_handles_polyline_roi() + handles = self.gui_getHoveredHandlesPolyLineRoi() if len(handles) == 1 and right_click: handle = handles[0] handle.roi.removeHandle(handle) return # Check if click on ROI - isClickOnDelRoi = self.canvas_context_menu_view.clicked_deleted_roi( - event, - left_click, - right_click, - ) + isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click) if isClickOnDelRoi: return dragImgLeft = ( - left_click and not brushON and not histON - and not curvToolON and not eraserON and not rulerON - and not wandON and not polyLineRoiON and not labelRoiON - and not middle_click and not keepObjON and not separateON - and not manualBackgroundON and not drawClearRegionON - and addPointsByClickingButton is None and not whitelistIDsON + left_click + and not brushON + and not histON + and not curvToolON + and not eraserON + and not rulerON + and not wandON + and not polyLineRoiON + and not labelRoiON + and not middle_click + and not keepObjON + and not separateON + and not manualBackgroundON + and not drawClearRegionON + and addPointsByClickingButton is None + and not whitelistIDsON and not zoomRectON ) if isPanImageClick: dragImgLeft = True - is_right_click_custom_ON = any([ - b.isChecked() for b in self.customAnnotDict.keys() - ]) + is_right_click_custom_ON = any( + [b.isChecked() for b in self.customAnnotDict.keys()] + ) canAnnotateDivision = ( not self.assignBudMothButton.isChecked() @@ -162,9 +132,8 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): # In timelapse mode division can be annotated if isCcaMode and right-click # while in snapshot mode with Ctrl+right-click - isAnnotateDivision = ( - (right_click and isCcaMode and canAnnotateDivision) - or (right_click and ctrl and self.isSnapshot) + isAnnotateDivision = (right_click and isCcaMode and canAnnotateDivision) or ( + right_click and ctrl and self.isSnapshot ) isCustomAnnot = ( @@ -173,15 +142,20 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): and self.customAnnotButton is not None ) - is_right_click_action_ON = any([ - b.isChecked() for b in self.checkableQButtonsGroup.buttons() - ]) + is_right_click_action_ON = any( + [b.isChecked() for b in self.checkableQButtonsGroup.buttons()] + ) isOnlyRightClick = ( - right_click and canAnnotateDivision and not isAnnotateDivision - and not isMod and not is_right_click_action_ON - and not is_right_click_custom_ON and not copyContourON - and not findNextMotherButtonON and not unknownLineageButtonON + right_click + and canAnnotateDivision + and not isAnnotateDivision + and not isMod + and not is_right_click_action_ON + and not is_right_click_custom_ON + and not copyContourON + and not findNextMotherButtonON + and not unknownLineageButtonON and not middle_click ) @@ -195,132 +169,225 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self._img1_click_xy = (screenPos.x(), screenPos.y()) QTimer.singleShot(400, self.doubleRightClickTimerCallBack) return - elif ( - self.countRightClicks == 1 - and not self.doubleRightClickTimeElapsed - ): + elif self.countRightClicks == 1 and not self.doubleRightClickTimeElapsed: self.isDoubleRightClick = True self.countRightClicks = 0 self.editIDbutton.setChecked(True) # Left click actions canCurv = ( - curvToolON and not self.assignBudMothButton.isChecked() - and not brushON and not dragImgLeft and not eraserON - and not polyLineRoiON and not labelRoiON + curvToolON + and not self.assignBudMothButton.isChecked() + and not brushON + and not dragImgLeft + and not eraserON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON + and not manualBackgroundON + and not drawClearRegionON + and not magicPromptsON + and not zoomRectON ) canBrush = ( - brushON and not curvToolON and not rulerON - and not dragImgLeft and not eraserON and not wandON - and not labelRoiON and not manualBackgroundON - and addPointsByClickingButton is None and not drawClearRegionON - and not magicPromptsON and not zoomRectON + brushON + and not curvToolON + and not rulerON + and not dragImgLeft + and not eraserON + and not wandON + and not labelRoiON + and not manualBackgroundON + and addPointsByClickingButton is None + and not drawClearRegionON + and not magicPromptsON + and not zoomRectON ) canErase = ( - eraserON and not curvToolON and not rulerON - and not dragImgLeft and not brushON and not wandON - and not polyLineRoiON and not labelRoiON + eraserON + and not curvToolON + and not rulerON + and not dragImgLeft + and not brushON + and not wandON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON + and not manualBackgroundON + and not drawClearRegionON + and not magicPromptsON + and not zoomRectON ) canRuler = ( - rulerON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not wandON - and not polyLineRoiON and not labelRoiON + rulerON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not wandON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON + and not manualBackgroundON + and not drawClearRegionON + and not magicPromptsON + and not zoomRectON ) canWand = ( - wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON + wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON + and not manualBackgroundON + and not drawClearRegionON + and not magicPromptsON + and not zoomRectON ) canPolyLine = ( - polyLineRoiON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not labelRoiON and not manualBackgroundON + polyLineRoiON + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not labelRoiON + and not manualBackgroundON and addPointsByClickingButton is None - and not drawClearRegionON and not magicPromptsON + and not drawClearRegionON + and not magicPromptsON and not zoomRectON ) canLabelRoi = ( - labelRoiON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not keepObjON + labelRoiON + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not keepObjON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not whitelistIDsON and not magicPromptsON + and not manualBackgroundON + and not drawClearRegionON + and not whitelistIDsON + and not magicPromptsON and not zoomRectON ) canKeep = ( - keepObjON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON + keepObjON + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not whitelistIDsON and not magicPromptsON + and not manualBackgroundON + and not drawClearRegionON + and not whitelistIDsON + and not magicPromptsON and not zoomRectON ) canWhitelistIDs = ( - whitelistIDsON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON + whitelistIDsON + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not keepObjON and not magicPromptsON + and not manualBackgroundON + and not drawClearRegionON + and not keepObjON + and not magicPromptsON and not zoomRectON ) canAddPoint = ( (pointsLayerON or magicPromptsON) - and addPointsByClickingButton is not None and not wandON - and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON and not keepObjON - and not manualBackgroundON and not drawClearRegionON + and addPointsByClickingButton is not None + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not labelRoiON + and not keepObjON + and not manualBackgroundON + and not drawClearRegionON and not zoomRectON ) canAddManualBackgroundObj = ( - manualBackgroundON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON + manualBackgroundON + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not keepObjON and not drawClearRegionON - and not magicPromptsON and not whitelistIDsON + and not keepObjON + and not drawClearRegionON + and not magicPromptsON + and not whitelistIDsON and not zoomRectON ) canDrawClearRegion = ( - drawClearRegionON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not labelRoiON and not manualBackgroundON + drawClearRegionON + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not labelRoiON + and not manualBackgroundON and addPointsByClickingButton is None - and not polyLineRoiON and not magicPromptsON - and not whitelistIDsON and not zoomRectON + and not polyLineRoiON + and not magicPromptsON + and not whitelistIDsON + and not zoomRectON ) canZoomRect = ( - zoomRectON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON + zoomRectON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not wandON and not whitelistIDsON and not magicPromptsON + and not manualBackgroundON + and not drawClearRegionON + and not wandON + and not whitelistIDsON + and not magicPromptsON ) # Enable dragging of the image window or the scalebar if dragImgLeft and not isCustomAnnot: x, y = event.pos().x(), event.pos().y() - if hasattr(self, 'scaleBar'): + if hasattr(self, "scaleBar"): if self.scaleBar.isHighlighted(): self.scaleBar.mousePressed(x, y) return - if hasattr(self, 'timestamp'): + if hasattr(self, "timestamp"): if self.timestamp.isHighlighted(): self.timestamp.mousePressed(x, y) return @@ -328,25 +395,22 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): event.ignore() return - canPressInViewer = self.canvas_tool_view.viewer_mode_allows_press( - mode, - can_add_point=canAddPoint, - can_ruler=canRuler, - ) - if not canPressInViewer: - self.mode_controls_view.startBlinkingModeCB() + isAllowedActionViewer = canAddPoint or canRuler + + if mode == "Viewer" and not isAllowedActionViewer: + self.startBlinkingModeCB() event.ignore() return # Allow right-click or middle-click actions on both images - eventOnImg2 = self.canvas_tool_view.should_forward_img1_press_to_img2( - right_click=right_click, - middle_click=middle_click, - can_add_point=canAddPoint, - mode=mode, - is_snapshot=self.isSnapshot, - is_annotate_division=isAnnotateDivision, - manual_background_on=manualBackgroundON, + eventOnImg2 = ( + ( + right_click or (middle_click and not canAddPoint) + # or (left_click and separateON) + ) + and (mode == "Segmentation and Tracking" or self.isSnapshot) + and not isAnnotateDivision + and not manualBackgroundON ) if eventOnImg2: event.isImg1Sender = True @@ -355,8 +419,8 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) Y, X = self.get_2Dlab(posData.lab).shape - if self.geometry.is_in_bounds(xdata, ydata, X, Y): - ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: + ID = self.get_2Dlab(posData.lab)[ydata, xdata] else: return @@ -380,9 +444,9 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): # to not use their IDs anymore in the future self.isNewID = True self.setBrushID() - self.updateLookuptable(lenNewLut=posData.brushID+1) + self.updateLookuptable(lenNewLut=posData.brushID + 1) - self.brushColor = self.lut[posData.brushID]/255 + self.brushColor = self.lut[posData.brushID] / 255 self.yPressAx2, self.xPressAx2 = y, x @@ -395,13 +459,13 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): localLab = lab_2D[diskSlice] mask = diskMask.copy() if not self.isPowerBrush() and not ctrl: - mask[localLab!=0] = False + mask[localLab != 0] = False self.applyBrushMask(mask, posData.brushID, toLocalSlice=diskSlice) self.setImageImg2(updateLookuptable=False) - how = self.drawIDsContComboBox.currentText() + self.drawIDsContComboBox.currentText() lab2D = self.get_2Dlab(posData.lab) self.globalBrushMask = np.zeros(lab2D.shape, dtype=bool) brushMask = localLab == posData.brushID @@ -436,18 +500,16 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): mask = np.zeros(lab_2D.shape, bool) mask[ymin:ymax, xmin:xmax][diskMask] = True - # If user double-pressed 'b' then erase over ALL labels color = self.eraserButton.palette().button().color().name() eraseOnlyOneID = ( - color != self.doublePressKeyButtonColor - and self.erasedID != 0 + color != self.doublePressKeyButtonColor and self.erasedID != 0 ) self.eraseOnlyOneID = eraseOnlyOneID if eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False + mask[lab_2D != self.erasedID] = False self.setTempImg1Eraser(mask, init=True) self.applyEraserMask(mask) @@ -457,7 +519,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): for erasedID in self.erasedIDs: if erasedID == 0: continue - self.erasedLab[lab_2D==erasedID] = erasedID + self.erasedLab[lab_2D == erasedID] = erasedID self.isMouseDragImg1 = True @@ -471,16 +533,17 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): if not magicPromptsON: removed_id = min(removed_ids) addPointsByClickingButton.pointIdSpinbox.setValue(removed_id) - addPointsByClickingButton.pointIdSpinbox.removedId = ( - removed_id - ) + addPointsByClickingButton.pointIdSpinbox.removedId = removed_id else: self.restorePrevPointIdRightClick(addPointsByClickingButton) self.drawPointsLayers(computePointsLayers=False) else: point_id = self.getAddedPointId( - magicPromptsON, addPointsByClickingButton, - right_click, left_click, middle_click + magicPromptsON, + addPointsByClickingButton, + right_click, + left_click, + middle_click, ) if point_id is None: return @@ -489,9 +552,10 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.drawPointsLayers(computePointsLayers=False) point_id = self.getClickedPointNewId( - action, point_id, + action, + point_id, addPointsByClickingButton.pointIdSpinbox, - isMagicPrompts=magicPromptsON + isMagicPrompts=magicPromptsON, ) addPointsByClickingButton.pointIdSpinbox.setValue( point_id, setLinkedWidget=False @@ -507,9 +571,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): elif left_click and canRuler or canPolyLine: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - closePolyLine = ( - len(self.startPointPolyLineItem.pointsAt(event.pos())) > 0 - ) + closePolyLine = len(self.startPointPolyLineItem.pointsAt(event.pos())) > 0 if not self.tempSegmentON or canPolyLine: # Keep adding anchor points for polyline self.ax1_rulerAnchorsItem.setData([xdata], [ydata]) @@ -521,12 +583,12 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() x0, y0 = xxRA[0], yyRA[0] if ctrl: - x1, y1 = self.snap_xy_to_closest_angle( + x1, y1 = transformation.snap_xy_to_closest_angle( x0, y0, xdata, ydata ) else: x1, y1 = xdata, ydata - lengthText = self.status_hover_view.ruler_length_text() + lengthText = self.getRulerLengthText() self.ax1_rulerPlotItem.setData( [x0, x1], [y0, y1], lengthText=lengthText ) @@ -562,18 +624,17 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): elif left_click and canKeep: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) keepID_win = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to keep', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to keep", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) keepID_win.exec_() if keepID_win.cancel: @@ -593,19 +654,18 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): elif left_click and canWhitelistIDs: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) keepID_win = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to select', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to select", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) keepID_win.exec_() if keepID_win.cancel: @@ -617,8 +677,10 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): if not posData.whitelist: wl_init = False - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() # not updated, only use in this context + if not hasattr(self, "tempWhitelistIDs"): + self.tempWhitelistIDs = ( + set() + ) # not updated, only use in this context current_whitelist = self.tempWhitelistIDs else: current_whitelist = self.tempWhitelistIDs @@ -633,9 +695,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): current_whitelist.add(ID) self.highlightLabelID(ID) - self.whitelistIDsToolbar.whitelistLineEdit.setText( - current_whitelist - ) + self.whitelistIDsToolbar.whitelistLineEdit.setText(current_whitelist) if wl_init: posData.whitelist[posData.frame_i] = current_whitelist @@ -674,13 +734,13 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): closeSpline = False clickedAnchors = self.curvAnchors.pointsAt(event.pos()) xxA, yyA = self.curvAnchors.getData() - if len(xxA)>0: + if len(xxA) > 0: if len(xxA) == 1: self.splineHoverON = True x0, y0 = xxA[0], yyA[0] - if len(clickedAnchors)>0: + if len(clickedAnchors) > 0: xA_clicked, yA_clicked = clickedAnchors[0].pos() - if x0==xA_clicked and y0==yA_clicked: + if x0 == xA_clicked and y0 == yA_clicked: x = x0 y = y0 closeSpline = True @@ -690,23 +750,23 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): try: xx, yy = self.curvHoverPlotItem.getData() self.curvPlotItem.setData(xx, yy) - except Exception as e: + except Exception: # traceback.print_exc() pass if closeSpline: self.splineHoverON = False - self.curvature_tools_view.curvToolSplineToObj() + self.curvToolSplineToObj() self.update_rp() if self.autoIDcheckbox.isChecked(): self.trackManuallyAddedObject(posData.brushID, True) if self.isSnapshot: - self.fixCcaDfAfterEdit('Add new ID with curvature tool') + self.fixCcaDfAfterEdit("Add new ID with curvature tool") self.updateAllImages() else: - self.warnEditingWithCca_df('Add new ID with curvature tool') - self.curvature_tools_view.clearCurvItems() - self.curvature_tools_view.curvTool_cb(True) + self.warnEditingWithCca_df("Add new ID with curvature tool") + self.clearCurvItems() + self.curvTool_cb(True) elif left_click and canWand: x, y = event.pos().x(), event.pos().y() @@ -716,14 +776,12 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.storeUndoRedoStates(False) self.isNewID = False - posData.brushID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + posData.brushID = self.get_2Dlab(posData.lab)[ydata, xdata] if posData.brushID == 0: self.setBrushID() - self.updateLookuptable( - lenNewLut=posData.brushID+1 - ) + self.updateLookuptable(lenNewLut=posData.brushID + 1) self.isNewID = True - self.brushColor = self.img2.lut[posData.brushID]/255 + self.brushColor = self.img2.lut[posData.brushID] / 255 # NOTE: flood is on mousedrag or release tol = self.getMagicWandFloodTolerance() @@ -734,28 +792,18 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): else: seed = (ydata, xdata) - flood_mask = skimage.segmentation.flood( - self.flood_img, seed, tolerance=tol - ) + flood_mask = skimage.segmentation.flood(self.flood_img, seed, tolerance=tol) drawUnderMask = np.logical_or( - posData.lab==0, posData.lab==posData.brushID + posData.lab == 0, posData.lab == posData.brushID ) self.flood_mask = np.logical_and(flood_mask, drawUnderMask) if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): - self.flood_mask = ( - self.binary_fill_holes( - self.flood_mask - ) - ) + self.flood_mask = core.binary_fill_holes(self.flood_mask) if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): - self.flood_mask = ( - self.convex_hull_mask( - self.flood_mask - ) - ) + self.flood_mask = core.convex_hull_mask(self.flood_mask) self.setTempBrushMaskFromWand(self.flood_mask, init=True) self.isMouseDragImg1 = True @@ -765,14 +813,14 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xdata, ydata = int(x), int(y) manualTrackID = self.manualTrackingToolbar.spinboxID.value() clickedID = self.getClickedID( - xdata, ydata, text=f'that you want to assign to {manualTrackID}' + xdata, ydata, text=f"that you want to assign to {manualTrackID}" ) if clickedID is None: return if clickedID == manualTrackID: self.manualTrackingToolbar.showWarning( - f'The clicked object already has ID = {manualTrackID}' + f"The clicked object already has ID = {manualTrackID}" ) return @@ -787,13 +835,13 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): posData.lab[posData.lab == manualTrackID] = clickedID posData.lab[posData.lab == tempID] = manualTrackID self.manualTrackingToolbar.showWarning( - f'The ID {manualTrackID} already exists --> ' - f'ID {manualTrackID} has been swapped with {clickedID}' + f"The ID {manualTrackID} already exists --> " + f"ID {manualTrackID} has been swapped with {clickedID}" ) else: posData.lab[posData.lab == clickedID] = manualTrackID self.manualTrackingToolbar.showInfo( - f'ID {clickedID} changed to {manualTrackID}.' + f"ID {clickedID} changed to {manualTrackID}." ) self.update_rp() @@ -803,7 +851,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - delID = self.map_mouse_coordinates_to_label_id((x, y), posData.manualBackgroundLab) + delID = posData.manualBackgroundLab[ydata, xdata] if delID == 0: return @@ -842,18 +890,17 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) divID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as divided', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to annotate as divided", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) divID_prompt.exec_() if divID_prompt.cancel: @@ -885,18 +932,17 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) budID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID of a bud you want to correct mother assignment', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID of a bud you want to correct mother assignment", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) budID_prompt.exec_() if budID_prompt.cancel: @@ -908,19 +954,19 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): y, x = posData.rp[obj_idx].centroid xdata, ydata = int(x), int(y) - relationship = posData.cca_df.at[ID, 'relationship'] - is_history_known = posData.cca_df.at[ID, 'is_history_known'] + relationship = posData.cca_df.at[ID, "relationship"] + is_history_known = posData.cca_df.at[ID, "is_history_known"] self.clickedOnHistoryKnown = is_history_known # We allow assiging a cell in G1 as bud only on first frame # OR if the history is unknown - if relationship != 'bud' and posData.frame_i > 0 and is_history_known: - txt = (f'You clicked on ID {ID} which is NOT a bud.\n' - 'To assign a bud to a cell start by clicking on a bud ' - 'and release on a cell in G1') - msg = QMessageBox() - msg.critical( - self.host, 'Not a bud', txt, msg.Ok + if relationship != "bud" and posData.frame_i > 0 and is_history_known: + txt = ( + f"You clicked on ID {ID} which is NOT a bud.\n" + "To assign a bud to a cell start by clicking on a bud " + "and release on a cell in G1" ) + msg = QMessageBox() + msg.critical(self, "Not a bud", txt, msg.Ok) return self.clickedOnBud = True @@ -933,19 +979,18 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) unknownID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as ' - '"history UNKNOWN/KNOWN"', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to annotate as " + '"history UNKNOWN/KNOWN"', + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) unknownID_prompt.exec_() if unknownID_prompt.cancel: @@ -963,18 +1008,17 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): elif isCustomAnnot: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - ID = self.map_mouse_coordinates_to_label_id((x, y), self.get_2Dlab(posData.lab)) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) clickedBkgrDialog = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as divided', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to annotate as divided", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) clickedBkgrDialog.exec_() if clickedBkgrDialog.cancel: @@ -985,11 +1029,11 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): y, x = posData.rp[obj_idx].centroid xdata, ydata = int(x), int(y) - button = self.custom_annotations_view.doCustomAnnotation(ID) + button = self.doCustomAnnotation(ID) if button is None: return - keepActive = self.customAnnotDict[button]['state']['keepActive'] + keepActive = self.customAnnotDict[button]["state"]["keepActive"] if not keepActive: button.setChecked(False) @@ -1016,10 +1060,21 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): else: try: xRange, yRange = self.zoomRectItem.getLastRange() - self.ax1.setRange( - xRange=xRange, - yRange=yRange, - padding=0 - ) - except Exception as err: - QTimer.singleShot(100, self.autoRange) \ No newline at end of file + self.ax1.setRange(xRange=xRange, yRange=yRange, padding=0) + except Exception: + QTimer.singleShot(100, self.autoRange) + + def map_mouse_coordinates_to_label_id( + self, + mouse_pos: tuple[float, float], + label_matrix: np.ndarray, + ) -> int: + """Resolves float pixel coordinate lookup to integer label ID.""" + x, y = mouse_pos + xdata, ydata = int(x), int(y) + height, width = label_matrix.shape + if 0 <= xdata < width and 0 <= ydata < height: + return int(label_matrix[ydata, xdata]) + return 0 + + LEGACY_METHODS = ("gui_mousePressEventImg1",) diff --git a/cellacdc/mixins/canvas_hover.py b/cellacdc/mixins/canvas_hover.py index 6ee419a6d..ce72432d9 100644 --- a/cellacdc/mixins/canvas_hover.py +++ b/cellacdc/mixins/canvas_hover.py @@ -10,111 +10,179 @@ from cellacdc import html_utils, widgets -class CanvasHoverView: +class CanvasHoverMixin: """Qt-facing adapter around canvas hover workflows.""" - LEGACY_METHODS = ( - 'updateHoverLabelCursor', - 'gui_hoverEventRightImage', - 'onCtrlPressedFirstTime', - 'onCtrlReleased', - 'gui_hoverEventImg1', - 'drawTempMothBudLine', - 'drawTempMergeObjsLine', - 'gui_add_ax_cursors', - 'gui_setCursor', - 'warnAddingPointWithExistingId', - 'gui_hoverEventImg2', - 'drawTempRulerLine', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) + """Headless decisions for hover and cursor state.""" - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + def _cursor_flags(self, modifiers, event): + return self.cursor_flags( + is_exit=event.isExit(), + no_modifier=modifiers == Qt.NoModifier, + shift=modifiers == Qt.ShiftModifier, + ctrl=modifiers == Qt.ControlModifier, + alt=modifiers == Qt.AltModifier, + brush_checked=self.brushButton.isChecked(), + eraser_checked=self.eraserButton.isChecked(), + add_deleted_polyline_checked=(self.addDelPolyLineRoiButton.isChecked()), + label_roi_checked=self.labelRoiButton.isChecked(), + label_roi_circular_checked=(self.labelRoiIsCircularRadioButton.isChecked()), + wand_checked=self.wandToolButton.isChecked(), + move_label_checked=self.moveLabelToolButton.isChecked(), + expand_label_checked=self.expandLabelToolButton.isChecked(), + curvature_checked=self.curvToolButton.isChecked(), + keep_ids_checked=self.keepIDsButton.isChecked(), + custom_annotation_available=self.customAnnotButton is not None, + manual_tracking_checked=self.manualTrackingButton.isChecked(), + manual_background_checked=self.manualBackgroundButton.isChecked(), + zoom_rect_checked=self.zoomRectButton.isChecked(), + edit_id_checked=self.editIDbutton.isChecked(), + magic_prompts_checked=self.magicPromptsToolButton.isChecked(), + points_layer_checked=self.togglePointsLayerAction.isChecked(), + add_points_by_clicking_active=( + self.buttonAddPointsByClickingActive() is not None + ), + ) - def updateHoverLabelCursor(self, x, y): - if x is None: - self.hoverLabelID = 0 - return + def cursor_flags( + self, + *, + is_exit: bool, + no_modifier: bool, + shift: bool, + ctrl: bool, + alt: bool, + brush_checked: bool, + eraser_checked: bool, + add_deleted_polyline_checked: bool, + label_roi_checked: bool, + label_roi_circular_checked: bool, + wand_checked: bool, + move_label_checked: bool, + expand_label_checked: bool, + curvature_checked: bool, + keep_ids_checked: bool, + custom_annotation_available: bool, + manual_tracking_checked: bool, + manual_background_checked: bool, + zoom_rect_checked: bool, + edit_id_checked: bool, + magic_prompts_checked: bool, + points_layer_checked: bool, + add_points_by_clicking_active: bool, + ) -> dict[str, bool]: + return { + "setBrushCursor": ( + brush_checked and not is_exit and (no_modifier or shift or ctrl) + ), + "setEraserCursor": eraser_checked and not is_exit and no_modifier, + "setAddDelPolyLineCursor": ( + add_deleted_polyline_checked and not is_exit and no_modifier + ), + "setLabelRoiCircCursor": ( + label_roi_checked + and not is_exit + and (no_modifier or shift or ctrl) + and label_roi_circular_checked + ), + "setWandCursor": wand_checked and not is_exit and no_modifier, + "setLabelRoiCursor": label_roi_checked and not is_exit and no_modifier, + "setMoveLabelCursor": move_label_checked and not is_exit and no_modifier, + "setExpandLabelCursor": ( + expand_label_checked and not is_exit and no_modifier + ), + "setCurvCursor": curvature_checked and not is_exit and no_modifier, + "setKeepObjCursor": keep_ids_checked and not is_exit and no_modifier, + "setCustomAnnotCursor": ( + custom_annotation_available and not is_exit and no_modifier + ), + "setManualTrackingCursor": ( + manual_tracking_checked and not is_exit and no_modifier + ), + "setManualBackgroundCursor": ( + manual_background_checked and not is_exit and no_modifier + ), + "setAddPointCursor": ( + (points_layer_checked or magic_prompts_checked) + and add_points_by_clicking_active + and not is_exit + and no_modifier + ), + "setZoomRectCursor": zoom_rect_checked and not is_exit and no_modifier, + "setEditIDCursor": edit_id_checked and not is_exit, + "setPanImageCursor": alt and not is_exit, + } - xdata, ydata = int(x), int(y) - if not self.point_in_bounds( - self.currentLab2D.shape, - xdata, - ydata, - ): + def drawTempMergeObjsLine(self, event, posData, modifiers): + if self.clickObjYc is None: return + modifier = modifiers == Qt.ShiftModifier + x, y = event.pos() + y2, x2 = y, x + xdata, ydata = int(x), int(y) + y1, x1 = self.clickObjYc, self.clickObjXc + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID != 0: + obj_idx = posData.IDs_idxs[ID] + obj = posData.rp[obj_idx] + y2, x2 = self.getObjCentroid(obj.centroid) - ID = self.currentLab2D[ydata, xdata] - self.hoverLabelID = ID + if modifier and ID > 0: + self.mergeObjsTempLine.addPoint(x2, y2) + elif not modifier: + self.mergeObjsTempLine.setData([x1, x2], [y1, y2]) + def drawTempMothBudLine(self, event, posData): + x, y = event.pos() + y2, x2 = y, x + xdata, ydata = int(x), int(y) + y1, x1 = self.yClickBud, self.xClickBud + ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - if self.highlightedID != 0: - self.updateAllImages() - self.highlightedID = 0 - return - - if self.app.overrideCursor() != Qt.SizeAllCursor: - self.app.setOverrideCursor(Qt.SizeAllCursor) + self.BudMothTempLine.setData([x1, x2], [y1, y2]) + else: + obj_idx = posData.IDs_idxs[ID] + obj = posData.rp[obj_idx] + y2, x2 = self.getObjCentroid(obj.centroid) + self.BudMothTempLine.setData([x1, x2], [y1, y2]) - if not self.isMovingLabel: - self.highlightSearchedID(ID) + def drawTempRulerLine(self, event): + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + x, y = event.pos() + x1, y1 = int(x), int(y) + xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() + x0, y0 = xxRA[0], yyRA[0] + if ctrl: + x1, y1 = transformation.snap_xy_to_closest_angle(x0, y0, x1, y1) + self.ax1_rulerPlotItem.setData([x0, x1], [y0, y1]) - def gui_hoverEventRightImage(self, event): + def gui_add_ax_cursors(self): try: - posData = self.data[self.pos_i] - except AttributeError: - return - - if event.isExit(): - self.resetCursors() + self.ax1.removeItem(self.ax1_cursor) + self.ax2.removeItem(self.ax2_cursor) + except Exception: + pass - self.gui_hoverEventImg1(event, isHoverImg1=False) - setMirroredCursor = self.should_set_mirrored_cursor( - override_cursor_is_none=self.app.overrideCursor() is None, - is_exit=event.isExit(), - mirrored_cursor_enabled=self.showMirroredCursorAction.isChecked(), - is_hover_img1=True, + self.ax2_cursor = pg.ScatterPlotItem( + symbol="+", + pxMode=True, + pen=pg.mkPen("k", width=1), + brush=pg.mkBrush("w"), + size=16, + tip=None, ) - if setMirroredCursor: - x, y = event.pos() - self.ax1_cursor.setData([x], [y]) - - def onCtrlPressedFirstTime(self): - x, y = self.xHoverImg, self.yHoverImg - if x is None: - self.xyOnCtrlPressedFirstTime = None - return - - xdata, ydata = int(x), int(y) - if not self.point_in_bounds( - self.currentLab2D.shape, - xdata, - ydata, - ): - self.xyOnCtrlPressedFirstTime = None - return - - ID = self.currentLab2D[ydata, xdata] - if ID == 0: - self.xyOnCtrlPressedFirstTime = None - return - - self.xyOnCtrlPressedFirstTime = (xdata, ydata) + self.ax2.addItem(self.ax2_cursor) - def onCtrlReleased(self): - self.xyOnCtrlPressedFirstTime = None + self.ax1_cursor = pg.ScatterPlotItem( + symbol="+", + pxMode=True, + pen=pg.mkPen("k", width=1), + brush=pg.mkBrush("w"), + size=16, + tip=None, + ) + self.ax1.addItem(self.ax1_cursor) def gui_hoverEventImg1(self, event, isHoverImg1=True): try: @@ -122,10 +190,11 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): except AttributeError: return - self.xHoverImg, self.yHoverImg = self.hover_position( - event.isExit(), - event.pos(), - ) + # Update x, y, value label bottom right + if not event.isExit(): + self.xHoverImg, self.yHoverImg = event.pos() + else: + self.xHoverImg, self.yHoverImg = None, None if event.isExit(): self.resetCursor() @@ -138,13 +207,10 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): cursorsInfo = self.gui_setCursor(modifiers, event) self.highlightHoverLostObj(modifiers, event) - drawRulerLine = self.should_draw_ruler_line( - ruler_checked=self.rulerButton.isChecked(), - add_deleted_polyline_checked=( - self.addDelPolyLineRoiButton.isChecked() - ), - temp_segment_on=self.tempSegmentON, - is_exit=event.isExit(), + drawRulerLine = ( + (self.rulerButton.isChecked() or self.addDelPolyLineRoiButton.isChecked()) + and self.tempSegmentON + and not event.isExit() ) if drawRulerLine: self.drawTempRulerLine(event) @@ -152,89 +218,89 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): if not event.isExit(): x, y = event.pos() xdata, ydata = int(x), int(y) - if self.point_in_bounds( - self.img1.image.shape[:2], - xdata, - ydata, - ): + _img = self.img1.image + Y, X = _img.shape[:2] + if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: ID = self.currentLab2D[ydata, xdata] self.updatePropsWidget(ID, fromHover=True) - activeToolButton = self.status_hover_view.active_tool_button() - hoverText = self.status_hover_view.hover_values_formatted( + activeToolButton = self.getActiveToolButton() + hoverText = self.hoverValuesFormatted( xdata, ydata, activeToolButton, isHoverImg1 ) - self.status_hover_view.check_highlight_scale_bar( - x, y, activeToolButton - ) - self.status_hover_view.check_highlight_timestamp( - x, y, activeToolButton - ) + self.checkHighlightScaleBar(x, y, activeToolButton) + self.checkHighlightTimestamp(x, y, activeToolButton) self.wcLabel.setText(hoverText) else: self.clickedOnBud = False self.BudMothTempLine.setData([], []) - self.wcLabel.setText('') + self.wcLabel.setText("") - if cursorsInfo['setKeepObjCursor']: + if cursorsInfo["setKeepObjCursor"]: x, y = event.pos() self.highlightHoverIDsKeptObj(x, y) - if cursorsInfo['setManualTrackingCursor']: + if cursorsInfo["setManualTrackingCursor"]: x, y = event.pos() # self.highlightHoverID(x, y) self.drawManualTrackingGhost(x, y) - if cursorsInfo['setManualBackgroundCursor']: + if cursorsInfo["setManualBackgroundCursor"]: x, y = event.pos() # self.highlightHoverID(x, y) self.drawManualBackgroundObj(x, y) if ( - not cursorsInfo['setManualTrackingCursor'] - and not cursorsInfo['setManualBackgroundCursor'] - ): + not cursorsInfo["setManualTrackingCursor"] + and not cursorsInfo["setManualBackgroundCursor"] + ): self.clearGhost() - setMoveLabelCursor = cursorsInfo['setMoveLabelCursor'] - setExpandLabelCursor = cursorsInfo['setExpandLabelCursor'] + setMoveLabelCursor = cursorsInfo["setMoveLabelCursor"] + setExpandLabelCursor = cursorsInfo["setExpandLabelCursor"] if setMoveLabelCursor or setExpandLabelCursor: x, y = event.pos() self.updateHoverLabelCursor(x, y) # Draw eraser circle - if cursorsInfo['setEraserCursor']: + if cursorsInfo["setEraserCursor"]: x, y = event.pos() self.updateEraserCursor(x, y, isHoverImg1=isHoverImg1) self.hideItemsHoverBrush(xy=(x, y)) elif self.eraserButton.isChecked() and not event.isExit(): if self.xyOnCtrlPressedFirstTime is not None: self.updateEraserCursor( - x, y, xyLocked=self.xyOnCtrlPressedFirstTime, - isHoverImg1=isHoverImg1 + x, + y, + xyLocked=self.xyOnCtrlPressedFirstTime, + isHoverImg1=isHoverImg1, ) self.hideItemsHoverBrush(xy=(x, y)) else: eraserCursors = ( - self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX + self.ax1_EraserCircle, + self.ax2_EraserCircle, + self.ax1_EraserX, + self.ax2_EraserX, ) self.setHoverToolSymbolData([], [], eraserCursors) # Draw Brush circle - if cursorsInfo['setBrushCursor']: + if cursorsInfo["setBrushCursor"]: x, y = event.pos() self.updateBrushCursor(x, y, isHoverImg1=isHoverImg1) self.hideItemsHoverBrush(xy=(x, y)) - elif cursorsInfo['setAddPointCursor']: + elif cursorsInfo["setAddPointCursor"]: x, y = event.pos() self.setHoverCircleAddPoint(x, y) else: self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + [], + [], + (self.ax2_BrushCircle, self.ax1_BrushCircle), ) # Draw label ROi circular cursor - setLabelRoiCircCursor = cursorsInfo['setLabelRoiCircCursor'] + setLabelRoiCircCursor = cursorsInfo["setLabelRoiCircCursor"] if setLabelRoiCircCursor: x, y = event.pos() else: @@ -242,32 +308,32 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) drawMothBudLine = ( - self.assignBudMothButton.isChecked() and self.clickedOnBud + self.assignBudMothButton.isChecked() + and self.clickedOnBud and not event.isExit() ) if drawMothBudLine: self.drawTempMothBudLine(event, posData) - drawMergeObjsLine = ( - self.mergeIDsButton.isChecked() and not event.isExit() - ) + drawMergeObjsLine = self.mergeIDsButton.isChecked() and not event.isExit() if drawMergeObjsLine: self.drawTempMergeObjsLine(event, posData, modifiers) # Temporarily draw spline curve # see https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy drawSpline = ( - self.curvToolButton.isChecked() and self.splineHoverON + self.curvToolButton.isChecked() + and self.splineHoverON and not event.isExit() ) if drawSpline: - self.curvature_tools_view.hoverEventDrawSpline(event) + self.hoverEventDrawSpline(event) - setMirroredCursor = self.should_set_mirrored_cursor( - override_cursor_is_none=self.app.overrideCursor() is None, - is_exit=event.isExit(), - mirrored_cursor_enabled=self.showMirroredCursorAction.isChecked(), - is_hover_img1=isHoverImg1, + setMirroredCursor = ( + self.app.overrideCursor() is None + and not event.isExit() + and isHoverImg1 + and self.showMirroredCursorAction.isChecked() ) if setMirroredCursor: x, y = event.pos() @@ -277,116 +343,214 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): return cursorsInfo - def drawTempMothBudLine(self, event, posData): - x, y = event.pos() - y2, x2 = y, x - xdata, ydata = int(x), int(y) - y1, x1 = self.yClickBud, self.xClickBud - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - self.BudMothTempLine.setData([x1, x2], [y1, y2]) + def gui_hoverEventImg2(self, event): + try: + self.data[self.pos_i] + except AttributeError: + return + + if not event.isExit(): + self.xHoverImg, self.yHoverImg = event.pos() else: - obj_idx = posData.IDs_idxs[ID] - obj = posData.rp[obj_idx] - y2, x2 = self.getObjCentroid(obj.centroid) - self.BudMothTempLine.setData([x1, x2], [y1, y2]) + self.xHoverImg, self.yHoverImg = None, None - def drawTempMergeObjsLine(self, event, posData, modifiers): - if self.clickObjYc is None: - return - modifier = modifiers == Qt.ShiftModifier - x, y = event.pos() - y2, x2 = y, x - xdata, ydata = int(x), int(y) - y1, x1 = self.clickObjYc, self.clickObjXc - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID != 0: - obj_idx = posData.IDs_idxs[ID] - obj = posData.rp[obj_idx] - y2, x2 = self.getObjCentroid(obj.centroid) + # Cursor left image --> restore cursor + if event.isExit() and self.app.overrideCursor() is not None: + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() - if modifier and ID > 0: - self.mergeObjsTempLine.addPoint(x2, y2) - elif not modifier: - self.mergeObjsTempLine.setData([x1, x2], [y1, y2]) + # Alt key was released --> restore cursor + modifiers = QGuiApplication.keyboardModifiers() + noModifier = modifiers == Qt.NoModifier + shift = modifiers == Qt.ShiftModifier + ctrl = modifiers == Qt.ControlModifier + if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: + self.app.restoreOverrideCursor() - def gui_add_ax_cursors(self): - try: - self.ax1.removeItem(self.ax1_cursor) - self.ax2.removeItem(self.ax2_cursor) - except Exception as e: - pass + setBrushCursor = ( + self.brushButton.isChecked() + and not event.isExit() + and (noModifier or shift or ctrl) + ) + setEraserCursor = ( + self.eraserButton.isChecked() and not event.isExit() and noModifier + ) + setLabelRoiCircCursor = ( + self.labelRoiButton.isChecked() + and not event.isExit() + and (noModifier or shift or ctrl) + and self.labelRoiIsCircularRadioButton.isChecked() + ) + if setBrushCursor or setEraserCursor or setLabelRoiCircCursor: + self.app.setOverrideCursor(Qt.CrossCursor) - self.ax2_cursor = pg.ScatterPlotItem( - symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), - brush=pg.mkBrush('w'), size=16, tip=None + setMoveLabelCursor = ( + self.moveLabelToolButton.isChecked() and not event.isExit() and noModifier ) - self.ax2.addItem(self.ax2_cursor) - self.ax1_cursor = pg.ScatterPlotItem( - symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), - brush=pg.mkBrush('w'), size=16, tip=None + setExpandLabelCursor = ( + self.expandLabelToolButton.isChecked() and not event.isExit() and noModifier ) - self.ax1.addItem(self.ax1_cursor) - def _cursor_flags(self, modifiers, event): - return self.cursor_flags( - is_exit=event.isExit(), - no_modifier=modifiers == Qt.NoModifier, - shift=modifiers == Qt.ShiftModifier, - ctrl=modifiers == Qt.ControlModifier, - alt=modifiers == Qt.AltModifier, - brush_checked=self.brushButton.isChecked(), - eraser_checked=self.eraserButton.isChecked(), - add_deleted_polyline_checked=( - self.addDelPolyLineRoiButton.isChecked() - ), - label_roi_checked=self.labelRoiButton.isChecked(), - label_roi_circular_checked=( - self.labelRoiIsCircularRadioButton.isChecked() - ), - wand_checked=self.wandToolButton.isChecked(), - move_label_checked=self.moveLabelToolButton.isChecked(), - expand_label_checked=self.expandLabelToolButton.isChecked(), - curvature_checked=self.curvToolButton.isChecked(), - keep_ids_checked=self.keepIDsButton.isChecked(), - custom_annotation_available=self.customAnnotButton is not None, - manual_tracking_checked=self.manualTrackingButton.isChecked(), - manual_background_checked=self.manualBackgroundButton.isChecked(), - zoom_rect_checked=self.zoomRectButton.isChecked(), - edit_id_checked=self.editIDbutton.isChecked(), - magic_prompts_checked=self.magicPromptsToolButton.isChecked(), - points_layer_checked=self.togglePointsLayerAction.isChecked(), - add_points_by_clicking_active=( - self.buttonAddPointsByClickingActive() is not None - ), + # Cursor is moving on image while Alt key is pressed --> pan cursor + alt = QGuiApplication.keyboardModifiers() == Qt.AltModifier + setPanImageCursor = alt and not event.isExit() + if setPanImageCursor and self.app.overrideCursor() is None: + self.app.setOverrideCursor(Qt.SizeAllCursor) + + setKeepObjCursor = ( + self.keepIDsButton.isChecked() and not event.isExit() and noModifier ) + if setKeepObjCursor and self.app.overrideCursor() is None: + self.app.setOverrideCursor(Qt.PointingHandCursor) + + # Update x, y, value label bottom right + if not event.isExit(): + x, y = event.pos() + _xdata, _ydata = int(x), int(y) + _img = self.currentLab2D + Y, X = _img.shape + # hoverText = self.hoverValuesFormatted(xdata, ydata) + # self.wcLabel.setText(hoverText) + else: + if self.eraserButton.isChecked() or self.brushButton.isChecked(): + self.gui_mouseReleaseEventImg2(event) + self.wcLabel.setText("") + + if setMoveLabelCursor or setExpandLabelCursor: + x, y = event.pos() + self.updateHoverLabelCursor(x, y) + + if setKeepObjCursor: + x, y = event.pos() + self.highlightHoverIDsKeptObj(x, y) + + # Draw eraser circle + if setEraserCursor: + x, y = event.pos() + self.updateEraserCursor(x, y, isHoverImg1=False) + else: + self.setHoverToolSymbolData( + [], + [], + ( + self.ax1_EraserCircle, + self.ax2_EraserCircle, + self.ax1_EraserX, + self.ax2_EraserX, + ), + ) + + # Draw Brush circle + if setBrushCursor: + x, y = event.pos() + self.updateBrushCursor(x, y, isHoverImg1=False) + else: + self.setHoverToolSymbolData( + [], + [], + (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + + # Draw label ROi circular cursor + if setLabelRoiCircCursor: + x, y = event.pos() + else: + x, y = None, None + self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) + + def gui_hoverEventRightImage(self, event): + try: + self.data[self.pos_i] + except AttributeError: + return + + if event.isExit(): + self.resetCursors() + + self.gui_hoverEventImg1(event, isHoverImg1=False) + setMirroredCursor = ( + self.app.overrideCursor() is None + and not event.isExit() + and self.showMirroredCursorAction.isChecked() + ) + if setMirroredCursor: + x, y = event.pos() + self.ax1_cursor.setData([x], [y]) def gui_setCursor(self, modifiers, event): noModifier = modifiers == Qt.NoModifier shift = modifiers == Qt.ShiftModifier + ctrl = modifiers == Qt.ControlModifier + alt = modifiers == Qt.AltModifier # Alt key was released --> restore cursor if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: self.app.restoreOverrideCursor() - flags = self._cursor_flags(modifiers, event) - setBrushCursor = flags['setBrushCursor'] - setEraserCursor = flags['setEraserCursor'] - setAddDelPolyLineCursor = flags['setAddDelPolyLineCursor'] - setLabelRoiCircCursor = flags['setLabelRoiCircCursor'] - setWandCursor = flags['setWandCursor'] - setLabelRoiCursor = flags['setLabelRoiCursor'] - setCurvCursor = flags['setCurvCursor'] - setKeepObjCursor = flags['setKeepObjCursor'] - setCustomAnnotCursor = flags['setCustomAnnotCursor'] - setManualTrackingCursor = flags['setManualTrackingCursor'] - setManualBackgroundCursor = flags['setManualBackgroundCursor'] - setAddPointCursor = flags['setAddPointCursor'] - setZoomRectCursor = flags['setZoomRectCursor'] - setEditIDCursor = flags['setEditIDCursor'] + setBrushCursor = ( + self.brushButton.isChecked() + and not event.isExit() + and (noModifier or shift or ctrl) + ) + setEraserCursor = ( + self.eraserButton.isChecked() and not event.isExit() and noModifier + ) + setAddDelPolyLineCursor = ( + self.addDelPolyLineRoiButton.isChecked() + and not event.isExit() + and noModifier + ) + setLabelRoiCircCursor = ( + self.labelRoiButton.isChecked() + and not event.isExit() + and (noModifier or shift or ctrl) + and self.labelRoiIsCircularRadioButton.isChecked() + ) + setWandCursor = ( + self.wandToolButton.isChecked() and not event.isExit() and noModifier + ) + setLabelRoiCursor = ( + self.labelRoiButton.isChecked() and not event.isExit() and noModifier + ) + setMoveLabelCursor = ( + self.moveLabelToolButton.isChecked() and not event.isExit() and noModifier + ) + setExpandLabelCursor = ( + self.expandLabelToolButton.isChecked() and not event.isExit() and noModifier + ) + setCurvCursor = ( + self.curvToolButton.isChecked() and not event.isExit() and noModifier + ) + setKeepObjCursor = ( + self.keepIDsButton.isChecked() and not event.isExit() and noModifier + ) + setCustomAnnotCursor = ( + self.customAnnotButton is not None and not event.isExit() and noModifier + ) + setManualTrackingCursor = ( + self.manualTrackingButton.isChecked() and not event.isExit() and noModifier + ) + setManualBackgroundCursor = ( + self.manualBackgroundButton.isChecked() + and not event.isExit() + and noModifier + ) + setZoomRectCursor = ( + self.zoomRectButton.isChecked() and not event.isExit() and noModifier + ) + setEditIDCursor = self.editIDbutton.isChecked() and not event.isExit() + magicPromptsON = self.magicPromptsToolButton.isChecked() + pointsLayerON = self.togglePointsLayerAction.isChecked() + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + setAddPointCursor = ( + (pointsLayerON or magicPromptsON) + and addPointsByClickingButton is not None + and not event.isExit() + and noModifier + ) overrideCursor = self.app.overrideCursor() - setPanImageCursor = flags['setPanImageCursor'] + setPanImageCursor = alt and not event.isExit() if setPanImageCursor and overrideCursor is None: self.app.setOverrideCursor(Qt.SizeAllCursor) elif setBrushCursor or setEraserCursor or setLabelRoiCircCursor: @@ -420,17 +584,52 @@ def gui_setCursor(self, modifiers, event): else: self.app.restoreOverrideCursor() - return flags + return { + "setBrushCursor": setBrushCursor, + "setEraserCursor": setEraserCursor, + "setAddDelPolyLineCursor": setAddDelPolyLineCursor, + "setLabelRoiCircCursor": setLabelRoiCircCursor, + "setWandCursor": setWandCursor, + "setLabelRoiCursor": setLabelRoiCursor, + "setMoveLabelCursor": setMoveLabelCursor, + "setExpandLabelCursor": setExpandLabelCursor, + "setCurvCursor": setCurvCursor, + "setKeepObjCursor": setKeepObjCursor, + "setCustomAnnotCursor": setCustomAnnotCursor, + "setManualTrackingCursor": setManualTrackingCursor, + "setManualBackgroundCursor": setManualBackgroundCursor, + "setAddPointCursor": setAddPointCursor, + "setZoomRectCursor": setZoomRectCursor, + "setEditIDCursor": setEditIDCursor, + } - def warnAddingPointWithExistingId(self, point_id, table_endname=''): - posData = self.data[self.pos_i] - if not point_id in posData.IDs_idxs: - return True + def hover_position(self, is_exit: bool, position) -> tuple[Any, Any]: + if is_exit: + return None, None + return position - msg = widgets.myMessageBox(wrapText=False) - txt = (f""" + def onCtrlPressedFirstTime(self): + x, y = self.xHoverImg, self.yHoverImg + if x is None: + self.xyOnCtrlPressedFirstTime = None + return - """Headless decisions for hover and cursor state.""" + xdata, ydata = int(x), int(y) + Y, X = self.currentLab2D.shape + + if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): + self.xyOnCtrlPressedFirstTime = None + return + + ID = self.currentLab2D[ydata, xdata] + if ID == 0: + self.xyOnCtrlPressedFirstTime = None + return + + self.xyOnCtrlPressedFirstTime = (xdata, ydata) + + def onCtrlReleased(self): + self.xyOnCtrlPressedFirstTime = None def point_in_bounds( self, @@ -441,26 +640,6 @@ def point_in_bounds( y_size, x_size = image_shape return 0 <= xdata < x_size and 0 <= ydata < y_size - def hover_position(self, is_exit: bool, position) -> tuple[Any, Any]: - if is_exit: - return None, None - return position - - def should_set_mirrored_cursor( - self, - *, - override_cursor_is_none: bool, - is_exit: bool, - mirrored_cursor_enabled: bool, - is_hover_img1: bool = True, - ) -> bool: - return ( - override_cursor_is_none - and not is_exit - and is_hover_img1 - and mirrored_cursor_enabled - ) - def should_draw_ruler_line( self, *, @@ -475,190 +654,67 @@ def should_draw_ruler_line( and not is_exit ) - def cursor_flags( + def should_set_mirrored_cursor( self, *, + override_cursor_is_none: bool, is_exit: bool, - no_modifier: bool, - shift: bool, - ctrl: bool, - alt: bool, - brush_checked: bool, - eraser_checked: bool, - add_deleted_polyline_checked: bool, - label_roi_checked: bool, - label_roi_circular_checked: bool, - wand_checked: bool, - move_label_checked: bool, - expand_label_checked: bool, - curvature_checked: bool, - keep_ids_checked: bool, - custom_annotation_available: bool, - manual_tracking_checked: bool, - manual_background_checked: bool, - zoom_rect_checked: bool, - edit_id_checked: bool, - magic_prompts_checked: bool, - points_layer_checked: bool, - add_points_by_clicking_active: bool, - ) -> dict[str, bool]: - return { - 'setBrushCursor': ( - brush_checked and not is_exit and (no_modifier or shift or ctrl) - ), - 'setEraserCursor': eraser_checked and not is_exit and no_modifier, - 'setAddDelPolyLineCursor': ( - add_deleted_polyline_checked and not is_exit and no_modifier - ), - 'setLabelRoiCircCursor': ( - label_roi_checked - and not is_exit - and (no_modifier or shift or ctrl) - and label_roi_circular_checked - ), - 'setWandCursor': wand_checked and not is_exit and no_modifier, - 'setLabelRoiCursor': label_roi_checked and not is_exit and no_modifier, - 'setMoveLabelCursor': move_label_checked and not is_exit and no_modifier, - 'setExpandLabelCursor': ( - expand_label_checked and not is_exit and no_modifier - ), - 'setCurvCursor': curvature_checked and not is_exit and no_modifier, - 'setKeepObjCursor': keep_ids_checked and not is_exit and no_modifier, - 'setCustomAnnotCursor': ( - custom_annotation_available and not is_exit and no_modifier - ), - 'setManualTrackingCursor': ( - manual_tracking_checked and not is_exit and no_modifier - ), - 'setManualBackgroundCursor': ( - manual_background_checked and not is_exit and no_modifier - ), - 'setAddPointCursor': ( - (points_layer_checked or magic_prompts_checked) - and add_points_by_clicking_active - and not is_exit - and no_modifier - ), - 'setZoomRectCursor': zoom_rect_checked and not is_exit and no_modifier, - 'setEditIDCursor': edit_id_checked and not is_exit, - 'setPanImageCursor': alt and not is_exit, - } - - Cell ID {point_id} already exists!

- Are you sure you want to add this point? - """) - if table_endname: - txt = (f""" - The loaded table {table_endname} has point id - {point_id}. -

However, {txt} - """) - txt = html_utils.paragraph(txt) - _, _, yesButton = msg.warning( - self.host, f'Cell ID {point_id} already exist', txt, - buttonsTexts=( - 'Cancel', 'No, do not add', f'Yes, add point id {point_id}' - ) + mirrored_cursor_enabled: bool, + is_hover_img1: bool = True, + ) -> bool: + return ( + override_cursor_is_none + and not is_exit + and is_hover_img1 + and mirrored_cursor_enabled ) - return msg.clickedButton == yesButton - def gui_hoverEventImg2(self, event): - try: - posData = self.data[self.pos_i] - except AttributeError: + def updateHoverLabelCursor(self, x, y): + if x is None: + self.hoverLabelID = 0 return - self.xHoverImg, self.yHoverImg = self.hover_position( - event.isExit(), - event.pos(), - ) - - # Cursor left image --> restore cursor - if event.isExit() and self.app.overrideCursor() is not None: - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() + xdata, ydata = int(x), int(y) + Y, X = self.currentLab2D.shape + if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): + return - # Alt key was released --> restore cursor - modifiers = QGuiApplication.keyboardModifiers() - noModifier = modifiers == Qt.NoModifier - shift = modifiers == Qt.ShiftModifier - if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: - self.app.restoreOverrideCursor() + ID = self.currentLab2D[ydata, xdata] + self.hoverLabelID = ID - flags = self._cursor_flags(modifiers, event) - setBrushCursor = flags['setBrushCursor'] - setEraserCursor = flags['setEraserCursor'] - setLabelRoiCircCursor = flags['setLabelRoiCircCursor'] - if setBrushCursor or setEraserCursor or setLabelRoiCircCursor: - self.app.setOverrideCursor(Qt.CrossCursor) + if ID == 0: + if self.highlightedID != 0: + self.updateAllImages() + self.highlightedID = 0 + return - # Cursor is moving on image while Alt key is pressed --> pan cursor - setPanImageCursor = flags['setPanImageCursor'] - if setPanImageCursor and self.app.overrideCursor() is None: + if self.app.overrideCursor() != Qt.SizeAllCursor: self.app.setOverrideCursor(Qt.SizeAllCursor) - setKeepObjCursor = flags['setKeepObjCursor'] - if setKeepObjCursor and self.app.overrideCursor() is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) - - # Update x, y, value label bottom right - if not event.isExit(): - x, y = event.pos() - xdata, ydata = int(x), int(y) - _img = self.currentLab2D - Y, X = _img.shape - # hoverText = self.status_hover_view.hover_values_formatted( - # xdata, ydata - # ) - # self.wcLabel.setText(hoverText) - else: - if self.eraserButton.isChecked() or self.brushButton.isChecked(): - self.gui_mouseReleaseEventImg2(event) - self.wcLabel.setText(f'') - - if flags['setMoveLabelCursor'] or flags['setExpandLabelCursor']: - x, y = event.pos() - self.updateHoverLabelCursor(x, y) - - if setKeepObjCursor: - x, y = event.pos() - self.highlightHoverIDsKeptObj(x, y) - - # Draw eraser circle - if setEraserCursor: - x, y = event.pos() - self.updateEraserCursor(x, y, isHoverImg1=False) - else: - self.setHoverToolSymbolData( - [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX) - ) - - # Draw Brush circle - if setBrushCursor: - x, y = event.pos() - self.updateBrushCursor(x, y, isHoverImg1=False) - else: - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) + if not self.isMovingLabel: + self.highlightSearchedID(ID) - # Draw label ROi circular cursor - if setLabelRoiCircCursor: - x, y = event.pos() - else: - x, y = None, None - self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) + def warnAddingPointWithExistingId(self, point_id, table_endname=""): + posData = self.data[self.pos_i] + if point_id not in posData.IDs_idxs: + return True - def drawTempRulerLine(self, event): - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - x, y = event.pos() - x1, y1 = int(x), int(y) - xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() - x0, y0 = xxRA[0], yyRA[0] - if ctrl: - x1, y1 = self.host.view_model.geometry.snap_xy_to_closest_angle( - x0, y0, x1, y1 - ) - self.ax1_rulerPlotItem.setData([x0, x1], [y0, y1]) \ No newline at end of file + msg = widgets.myMessageBox(wrapText=False) + txt = f""" + Cell ID {point_id} already exists!

+ Are you sure you want to add this point? + """ + if table_endname: + txt = f""" + The loaded table {table_endname} has point id + {point_id}. +

However, {txt} + """ + txt = html_utils.paragraph(txt) + _, _, yesButton = msg.warning( + self, + f"Cell ID {point_id} already exist", + txt, + buttonsTexts=("Cancel", "No, do not add", f"Yes, add point id {point_id}"), + ) + return msg.clickedButton == yesButton diff --git a/cellacdc/mixins/canvas_right_image.py b/cellacdc/mixins/canvas_right_image.py index 7db59488c..b4d01094f 100644 --- a/cellacdc/mixins/canvas_right_image.py +++ b/cellacdc/mixins/canvas_right_image.py @@ -8,47 +8,42 @@ from cellacdc import exception_handler -class CanvasRightImageView: +class CanvasRightImageMixin: """Qt-facing adapter for duplicated right-image mouse events.""" """Headless duplicated right-image event rules.""" - def should_show_context_menu( - self, - *, - right_click: bool, - is_right_click_action_on: bool, - ) -> bool: - return right_click and not is_right_click_action_on - + @exception_handler + def mouse_drag(self, event): + self.gui_mouseDragEventImg1(event) - def __init__(self, host): - self.host = host @exception_handler def mouse_press(self, event): modifiers = QGuiApplication.keyboardModifiers() alt = modifiers == Qt.AltModifier right_click = event.button() == Qt.MouseButton.RightButton and not alt - is_right_click_action_on = any([ - b.isChecked() for b in self.host.checkableQButtonsGroup.buttons() - ]) - self.host.typingEditID = False + is_right_click_action_on = any( + [b.isChecked() for b in self.checkableQButtonsGroup.buttons()] + ) + self.typingEditID = False show_menu = self.should_show_context_menu( right_click=right_click, is_right_click_action_on=is_right_click_action_on, ) if show_menu: - self.host.canvas_context_menu_view.show_right_image_context_menu( - event - ) + self.canvas_context_menu_view.show_right_image_context_menu(event) event.ignore() else: - self.host.gui_mousePressEventImg1(event) - - @exception_handler - def mouse_drag(self, event): - self.host.gui_mouseDragEventImg1(event) + self.gui_mousePressEventImg1(event) @exception_handler def mouse_release(self, event): - self.host.gui_mouseReleaseEventImg1(event) \ No newline at end of file + self.gui_mouseReleaseEventImg1(event) + + def should_show_context_menu( + self, + *, + right_click: bool, + is_right_click_action_on: bool, + ) -> bool: + return right_click and not is_right_click_action_on diff --git a/cellacdc/mixins/canvas_selection.py b/cellacdc/mixins/canvas_selection.py index 8529c39ec..db229bbaf 100644 --- a/cellacdc/mixins/canvas_selection.py +++ b/cellacdc/mixins/canvas_selection.py @@ -15,103 +15,23 @@ from cellacdc import apps, exception_handler -class CanvasSelectionView: +class CanvasSelectionMixin: """Qt-facing adapter for canvas selection workflows.""" """Headless decisions for canvas selection workflows.""" - viewer_mode = 'Viewer' - segmentation_mode = 'Segmentation and Tracking' - - def should_drag_image( - self, - *, - left_click: bool, - eraser_on: bool, - brush_on: bool, - middle_click: bool, - pan_click: bool, - ) -> bool: - return pan_click or ( - left_click and not eraser_on and not brush_on and not middle_click - ) - - def should_blink_viewer_mode( - self, - *, - mode: str, - middle_click: bool, - right_action_on: bool = False, - custom_action_on: bool = False, - right_click: bool = False, - ) -> bool: - if mode != self.viewer_mode: - return False - if middle_click: - return True - return (right_action_on or custom_action_on) and ( - right_click or middle_click - ) - - def should_show_labels_menu( - self, - *, - right_click: bool, - right_action_on: bool, - middle_click: bool, - event_from_img1: bool, - ) -> bool: - return ( - right_click - and not right_action_on - and not middle_click - and not event_from_img1 - ) + viewer_mode = "Viewer" + segmentation_mode = "Segmentation and Tracking" def can_delete(self, *, mode: str, is_snapshot: bool) -> bool: return mode == self.segmentation_mode or is_snapshot - def is_viewer_mode(self, mode: str) -> bool: - return mode == self.viewer_mode - - def should_process_release( - self, - *, - mode: str, - in_bounds: bool, - ) -> bool: - return mode != self.viewer_mode and in_bounds - - - LEGACY_METHODS = ( - 'gui_mousePressEventImg2', - 'gui_mouseReleaseEventImg2', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - @exception_handler def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): - if self._dispatch_tool_event_if_enabled(event, phase='press', image='img2'): - return modifiers = QGuiApplication.keyboardModifiers() alt = modifiers == Qt.AltModifier shift = modifiers == Qt.ShiftModifier shift_regardless = bool(modifiers & Qt.ShiftModifier) - isMod = alt posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) left_click = event.button() == Qt.MouseButton.LeftButton and not alt @@ -124,13 +44,9 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.typingEditID = False # Drag image if neither brush or eraser are On pressed - dragImg = self.should_drag_image( - left_click=left_click, - eraser_on=eraserON, - brush_on=brushON, - middle_click=middle_click, - pan_click=isPanImageClick, - ) + dragImg = left_click and not eraserON and not brushON and not middle_click + if isPanImageClick: + dragImg = True # Enable dragging of the image window like pyqtgraph original code if dragImg: @@ -138,11 +54,8 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): event.ignore() return - if self.should_blink_viewer_mode( - mode=mode, - middle_click=middle_click, - ): - self.mode_controls_view.startBlinkingModeCB() + if mode == "Viewer" and middle_click: + self.startBlinkingModeCB() event.ignore() return @@ -155,52 +68,41 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): return # Check if right click on ROI - isClickOnDelRoi = self.canvas_context_menu_view.clicked_deleted_roi( - event, - left_click, - right_click, - ) + isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click) if isClickOnDelRoi: return # show gradient widget menu if none of the right-click actions are ON # and event is not coming from image 1 - is_right_click_action_ON = any([ - b.isChecked() for b in self.checkableQButtonsGroup.buttons() - ]) - is_right_click_custom_ON = any([ - b.isChecked() for b in self.customAnnotDict.keys() - ]) + is_right_click_action_ON = any( + [b.isChecked() for b in self.checkableQButtonsGroup.buttons()] + ) + is_right_click_custom_ON = any( + [b.isChecked() for b in self.customAnnotDict.keys()] + ) is_event_from_img1 = False - if hasattr(event, 'isImg1Sender'): + if hasattr(event, "isImg1Sender"): is_event_from_img1 = event.isImg1Sender is_only_right_click = ( right_click and not is_right_click_action_ON and not middle_click ) - showLabelsGradMenu = self.should_show_labels_menu( - right_click=right_click, - right_action_on=is_right_click_action_ON, - middle_click=middle_click, - event_from_img1=is_event_from_img1, - ) + showLabelsGradMenu = is_only_right_click and not is_event_from_img1 if showLabelsGradMenu: self.labelsGrad.showMenu(event) event.ignore() return - editInViewerMode = self.should_blink_viewer_mode( - mode=mode, - middle_click=middle_click, - right_action_on=is_right_click_action_ON, - custom_action_on=is_right_click_custom_ON, - right_click=right_click, + editInViewerMode = ( + (is_right_click_action_ON or is_right_click_custom_ON) + and (right_click or middle_click) + and mode == "Viewer" ) if editInViewerMode: - self.mode_controls_view.startBlinkingModeCB() + self.startBlinkingModeCB() event.ignore() return @@ -209,31 +111,26 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): # Brush and eraser are mutually exclusive but we want to keep the eraser # or brush ON and disable them temporarily to allow left-click with # separate ON - canDelete = self.can_delete( - mode=mode, - is_snapshot=self.isSnapshot, - ) + canDelete = mode == "Segmentation and Tracking" or self.isSnapshot # Delete ID (set to 0) if middle_click and canDelete: - t0 = time.perf_counter() + time.perf_counter() x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) delID = self.get_2Dlab(posData.lab)[ydata, xdata] if delID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) delID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.
' - 'Enter here ID(s) that you want to delete

' - 'You can enter multiple IDs separated by comma', - parent=self.host, + title="Clicked on background", + msg="You clicked on the background.
" + "Enter here ID(s) that you want to delete

" + "You can enter multiple IDs separated by comma", + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), allowList=True, - isInteger=True + isInteger=True, ) delID_prompt.exec_() if delID_prompt.cancel: @@ -243,13 +140,17 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): delIDs = [delID] # Ask to propagate change to all future visited frames - key = 'Delete ID' + key = "Delete ID" askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - delIDs, key, doNotShow, - posData.UndoFutFrames_DelID, posData.applyFutFrames_DelID + (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( + self.propagateChange( + delIDs, + key, + doNotShow, + posData.UndoFutFrames_DelID, + posData.applyFutFrames_DelID, + ) ) if UndoFutFrames is None: @@ -260,7 +161,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): posData.doNotShowAgain_DelID = doNotShowAgain posData.UndoFutFrames_DelID = UndoFutFrames posData.applyFutFrames_DelID = applyFutFrames - includeUnvisited = posData.includeUnvisitedInfo['Delete ID'] + includeUnvisited = posData.includeUnvisitedInfo["Delete ID"] delID_mask = self.deleteIDmiddleClick( delIDs, applyFutFrames, includeUnvisited, shift=shift_regardless @@ -269,21 +170,21 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): delID_mask = delID_mask[self.z_lab()] if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete ID') + self.fixCcaDfAfterEdit("Delete ID") else: - self.warnEditingWithCca_df('Delete ID', update_images=False) + self.warnEditingWithCca_df("Delete ID", update_images=False) self.setImageImg2() delROIsIDs = self.setAllTextAnnotations() self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False) how = self.drawIDsContComboBox.currentText() - if how.find('overlay segm. masks') != -1: + if how.find("overlay segm. masks") != -1: self.labelsLayerImg1.image[delID_mask] = 0 self.labelsLayerImg1.setImage(self.labelsLayerImg1.image) how_ax2 = self.getAnnotateHowRightImage() - if how_ax2.find('overlay segm. masks') != -1: + if how_ax2.find("overlay segm. masks") != -1: self.labelsLayerRightImg.image[delID_mask] = 0 self.labelsLayerRightImg.setImage(self.labelsLayerRightImg.image) @@ -295,15 +196,15 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) sepID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here ID that you want to split', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter here ID that you want to split", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) sepID_prompt.exec_() if sepID_prompt.cancel: @@ -319,16 +220,17 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if self.isSegm3D and not shift: z = self.zSliceScrollBar.sliderPosition() - posData.lab, splittedIDs = ( - self.separate_with_label( - posData.lab, posData.rp, [ID], max_ID, - click_coords_list=[(z, ydata, xdata)] - ) + posData.lab, splittedIDs = measure.separate_with_label( + posData.lab, + posData.rp, + [ID], + max_ID, + click_coords_list=[(z, ydata, xdata)], ) success = True # self.set_2Dlab(lab2D) elif not shift: - result = self.split_along_convexity_defects( + result = core.split_along_convexity_defects( ID, self.get_2Dlab(posData.lab), max_ID ) lab2D, success, splittedIDs = result @@ -340,14 +242,16 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if not success: posData.disableAutoActivateViewerWindow = True img = self.getDisplayedImg1() - col = 'manual_separate_draw_mode' - drawMode = self.df_settings.at[col, 'value'] + col = "manual_separate_draw_mode" + drawMode = self.df_settings.at[col, "value"] manualSep = apps.manualSeparateGui( - self.get_2Dlab(posData.lab), ID, img, + self.get_2Dlab(posData.lab), + ID, + img, fontSize=self.fontSize, IDcolor=self.lut[ID], - parent=self.host, - drawMode=drawMode + parent=self, + drawMode=drawMode, ) manualSep.setState(self.lastManualSeparateState) manualSep.show() @@ -360,15 +264,11 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): return self.lastManualSeparateState = manualSep.state() lab2D = self.get_2Dlab(posData.lab) - lab2D[manualSep.lab!=0] = manualSep.lab[manualSep.lab!=0] + lab2D[manualSep.lab != 0] = manualSep.lab[manualSep.lab != 0] self.set_2Dlab(lab2D) splittedIDs = [obj.label for obj in manualSep.rp] posData.disableAutoActivateViewerWindow = False - self.canvas_tool_view.store_manual_separate_draw_mode( - self.df_settings, - self.settings_csv_path, - manualSep.drawMode, - ) + self.storeManualSeparateDrawMode(manualSep.drawMode) # Update data (rp, etc) self.update_rp() @@ -377,10 +277,10 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.trackSubsetIDs(splittedIDs) if self.isSnapshot: - self.fixCcaDfAfterEdit('Separate IDs') + self.fixCcaDfAfterEdit("Separate IDs") self.updateAllImages() else: - self.warnEditingWithCca_df('Separate IDs') + self.warnEditingWithCca_df("Separate IDs") self.store_data() @@ -393,17 +293,16 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) clickedBkgrID = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here the ID that you want to ' - 'fill the holes of', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter here the ID that you want to " + "fill the holes of", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) clickedBkgrID.exec_() if clickedBkgrID.cancel: @@ -432,17 +331,16 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) mergeID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here the ID that you want to ' - 'replace with Hull contour', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter here the ID that you want to " + "replace with Hull contour", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) mergeID_prompt.exec_() if mergeID_prompt.cancel: @@ -471,7 +369,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.storeUndoRedoStates(False) x, y = event.pos().x(), event.pos().y() - self.label_transform_tools_view.start_moving_label(x, y) + self.startMovingLabel(x, y) # Fill holes elif right_click and self.fillHolesToolButton.isChecked(): @@ -479,17 +377,16 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) clickedBkgrID = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here the ID that you want to ' - 'fill the holes of', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter here the ID that you want to " + "fill the holes of", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) clickedBkgrID.exec_() if clickedBkgrID.cancel: @@ -503,16 +400,15 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) mergeID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here first ID that you want to merge', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter here first ID that you want to merge", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) mergeID_prompt.exec_() if mergeID_prompt.cancel: @@ -532,22 +428,19 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): # Edit ID elif right_click and self.editIDbutton.isChecked(): - if self._dispatch_tool_event_if_enabled(event, phase='press', image='img2'): - return x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) editID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here ID that you want to replace with a new one', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter here ID that you want to replace with a new one", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) editID_prompt.show(block=True) @@ -569,13 +462,14 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): and posData.frame_i < posData.SizeT - 1 ) editID = apps.EditIDDialog( - ID, posData.IDs, + ID, + posData.IDs, doNotShowAgain=self.doNotAskAgainExistingID, - parent=self.host, + parent=self, entryID=self.getNearestLostObjID(y, x), nextUniqueID=self.setBrushID(return_val=True), allIDs=posData.allIDs, - addPropagateCheckbox=addPropagateCheckbox + addPropagateCheckbox=addPropagateCheckbox, ) editID.show(block=True) if editID.cancel: @@ -593,9 +487,13 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.doNotAskAgainExistingID = editID.doNotAskAgainExistingID self.applyEditID( - ID, currentIDs, editID.how, x, y, + ID, + currentIDs, + editID.how, + x, + y, shift=shift, - doPropagateUnvisited=editID.doPropagateFutureFrames + doPropagateUnvisited=editID.doPropagateFutureFrames, ) elif (right_click or left_click) and self.keepIDsButton.isChecked(): @@ -603,16 +501,15 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) keepID_win = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to keep', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to keep", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) keepID_win.exec_() if keepID_win.cancel: @@ -635,16 +532,15 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) binID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to remove from the analysis', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to remove from the analysis", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) binID_prompt.exec_() if binID_prompt.cancel: @@ -653,14 +549,17 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): ID = binID_prompt.EntryID # Ask to propagate change to all future visited frames - key = 'Exclude cell from analysis' + key = "Exclude cell from analysis" askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - ID, key, doNotShow, - posData.UndoFutFrames_BinID, - posData.applyFutFrames_BinID + (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( + self.propagateChange( + ID, + key, + doNotShow, + posData.UndoFutFrames_BinID, + posData.applyFutFrames_BinID, + ) ) if UndoFutFrames is None: @@ -677,7 +576,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if applyFutFrames: # Store current data before going to future frames self.store_data() - for i in range(posData.frame_i+1, endFrame_i+1): + for i in range(posData.frame_i + 1, endFrame_i + 1): posData.frame_i = i self.get_data() if ID in posData.binnedIDs: @@ -685,7 +584,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): else: posData.binnedIDs.add(ID) self.update_rp_metadata(draw=False) - self.store_data(autosave=i==endFrame_i) + self.store_data(autosave=i == endFrame_i) self.app.restoreOverrideCursor() @@ -716,16 +615,15 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) ripID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as dead', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to annotate as dead", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) ripID_prompt.exec_() if ripID_prompt.cancel: @@ -734,14 +632,17 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): ID = ripID_prompt.EntryID # Ask to propagate change to all future visited frames - key = 'Annotate cell as dead' + key = "Annotate cell as dead" askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - ID, key, doNotShow, - posData.UndoFutFrames_RipID, - posData.applyFutFrames_RipID + (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( + self.propagateChange( + ID, + key, + doNotShow, + posData.UndoFutFrames_RipID, + posData.applyFutFrames_RipID, + ) ) if UndoFutFrames is None: @@ -757,7 +658,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if applyFutFrames: # Store current data before going to future frames self.store_data() - for i in range(posData.frame_i+1, endFrame_i+1): + for i in range(posData.frame_i + 1, endFrame_i + 1): posData.frame_i = i self.get_data() if ID in posData.ripIDs: @@ -765,7 +666,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): else: posData.ripIDs.add(ID) self.update_rp_metadata(draw=False) - self.store_data(autosave=i==endFrame_i) + self.store_data(autosave=i == endFrame_i) self.app.restoreOverrideCursor() # Back to current frame @@ -788,37 +689,29 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.store_data() if self.isSnapshot: - self.fixCcaDfAfterEdit('Annotate ID as dead') + self.fixCcaDfAfterEdit("Annotate ID as dead") self.updateAllImages() else: - self.warnEditingWithCca_df('Annotate ID as dead') + self.warnEditingWithCca_df("Annotate ID as dead") if not self.ripCellButton.findChild(QAction).isChecked(): self.ripCellButton.setChecked(False) @exception_handler def gui_mouseReleaseEventImg2(self, event): - if self._dispatch_tool_event_if_enabled(event, phase='release', image='img2'): - return posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) + if mode == "Viewer": + return Y, X = self.get_2Dlab(posData.lab).shape try: x, y = event.pos().x(), event.pos().y() - except Exception as e: + except Exception: return xdata, ydata = int(x), int(y) - in_bounds = self.is_in_bounds(xdata, ydata, X, Y) - if self.is_viewer_mode(mode): - return - - should_process = self.should_process_release( - mode=mode, - in_bounds=in_bounds, - ) - if not should_process: + if not myutils.is_in_bounds(xdata, ydata, X, Y): self.isMouseDragImg2 = False self.updateAllImages() return @@ -845,17 +738,16 @@ def gui_mouseReleaseEventImg2(self, event): lab2D = self.get_2Dlab(posData.lab) ID = lab2D[ydata, xdata] if ID == 0: - nearest_ID = self.nearest_nonzero_2d( - lab2D, y, x - ) + nearest_ID = core.nearest_nonzero_2D(lab2D, y, x) mergeID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to merge with ID ' - f'{self.firstID}', - parent=self.host, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to merge with ID " + f"{self.firstID}", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) mergeID_prompt.exec_() if mergeID_prompt.cancel: @@ -872,7 +764,7 @@ def gui_mouseReleaseEventImg2(self, event): for ID in IDs_to_merge: if ID == 0: continue - posData.lab[posData.lab==ID] = self.firstID + posData.lab[posData.lab == ID] = self.firstID self.mergeObjsTempLine.setData([], []) self.clickObjYc, self.clickObjXc = None, None @@ -886,29 +778,89 @@ def gui_mouseReleaseEventImg2(self, event): ask_back_prop = False prev_IDs = [] else: - prev_IDs = posData.allData_li[posData.frame_i-1]['IDs'] + prev_IDs = posData.allData_li[posData.frame_i - 1]["IDs"] - if all(ID not in prev_IDs for ID in IDs_to_merge): + if all(ID not in prev_IDs for ID in IDs_to_merge): ask_back_prop = False if not self.isFrameCcaAnnotated() and ask_back_prop: - proceed = self.askPropagateChangePast(f'Merge IDs {IDs_to_merge}') + proceed = self.askPropagateChangePast(f"Merge IDs {IDs_to_merge}") if proceed: self.propagateMergeObjsPast(IDs_to_merge) - self.whitelistPropagateIDs(only_future_frames=False, update_lab=True) # in the update_rp() call, this should also be done + self.whitelistPropagateIDs( + only_future_frames=False, update_lab=True + ) # in the update_rp() call, this should also be done # Repeat tracking self.tracking( - enforce=True, assign_unique_new_IDs=False, - separateByLabel=False + enforce=True, assign_unique_new_IDs=False, separateByLabel=False ) if self.isSnapshot: - self.fixCcaDfAfterEdit('Merge IDs') + self.fixCcaDfAfterEdit("Merge IDs") self.updateAllImages() else: - self.warnEditingWithCca_df('Merge IDs') + self.warnEditingWithCca_df("Merge IDs") if not self.mergeIDsButton.findChild(QAction).isChecked(): self.mergeIDsButton.setChecked(False) - self.store_data() \ No newline at end of file + self.store_data() + + def is_viewer_mode(self, mode: str) -> bool: + return mode == self.viewer_mode + + def should_blink_viewer_mode( + self, + *, + mode: str, + middle_click: bool, + right_action_on: bool = False, + custom_action_on: bool = False, + right_click: bool = False, + ) -> bool: + if mode != self.viewer_mode: + return False + if middle_click: + return True + return (right_action_on or custom_action_on) and (right_click or middle_click) + + def should_drag_image( + self, + *, + left_click: bool, + eraser_on: bool, + brush_on: bool, + middle_click: bool, + pan_click: bool, + ) -> bool: + return pan_click or ( + left_click and not eraser_on and not brush_on and not middle_click + ) + + def should_process_release( + self, + *, + mode: str, + in_bounds: bool, + ) -> bool: + return mode != self.viewer_mode and in_bounds + + LEGACY_METHODS = ( + "gui_mousePressEventImg2", + "gui_mouseReleaseEventImg2", + ) + + def should_show_labels_menu( + self, + *, + right_click: bool, + right_action_on: bool, + middle_click: bool, + event_from_img1: bool, + ) -> bool: + return ( + right_click + and not right_action_on + and not middle_click + and not event_from_img1 + ) diff --git a/cellacdc/mixins/canvas_tool.py b/cellacdc/mixins/canvas_tool.py index 51d22b492..abd47f913 100644 --- a/cellacdc/mixins/canvas_tool.py +++ b/cellacdc/mixins/canvas_tool.py @@ -3,22 +3,10 @@ from __future__ import annotations -class CanvasToolView: +class CanvasToolMixin: """Qt-facing adapter around the scriptable canvas tool decision rules.""" - manual_separate_draw_mode_key = 'manual_separate_draw_mode' - - def __init__(self, host=None): - self.host = host - - def viewer_mode_allows_press( - self, - mode: str, - *, - can_add_point: bool = False, - can_ruler: bool = False, - ) -> bool: - return mode != 'Viewer' or can_add_point or can_ruler + manual_separate_draw_mode_key = "manual_separate_draw_mode" def should_forward_img1_press_to_img2( self, @@ -33,7 +21,7 @@ def should_forward_img1_press_to_img2( ) -> bool: return ( (right_click or (middle_click and not can_add_point)) - and (mode == 'Segmentation and Tracking' or is_snapshot) + and (mode == "Segmentation and Tracking" or is_snapshot) and not is_annotate_division and not manual_background_on ) @@ -45,11 +33,17 @@ def should_forward_img1_release_to_img2( mode: str, is_snapshot: bool, ) -> bool: - return ( - (mode == 'Segmentation and Tracking' or is_snapshot) - and right_click - ) + return (mode == "Segmentation and Tracking" or is_snapshot) and right_click def store_manual_separate_draw_mode(self, settings, settings_csv_path, mode): - settings.at[self.manual_separate_draw_mode_key, 'value'] = mode + settings.at[self.manual_separate_draw_mode_key, "value"] = mode settings.to_csv(settings_csv_path) + + def viewer_mode_allows_press( + self, + mode: str, + *, + can_add_point: bool = False, + can_ruler: bool = False, + ) -> bool: + return mode != "Viewer" or can_add_point or can_ruler diff --git a/cellacdc/mixins/cca_edits.py b/cellacdc/mixins/cca_edits.py index 83e223983..7724aa011 100644 --- a/cellacdc/mixins/cca_edits.py +++ b/cellacdc/mixins/cca_edits.py @@ -35,7 +35,7 @@ @dataclass(frozen=True) -class CcaFrameEditResult: +class CcaFrameEditResultMixin: """Result of a current-frame CCA edit command.""" cca_df: pd.DataFrame @@ -48,6 +48,19 @@ class CcaEditViewModel: command shape that binds view events to scriptable domain operations. """ + def add_base_annotation( + self, + cca_df: pd.DataFrame, + cell_ids, + *, + base_values: dict | None = None, + ) -> pd.DataFrame: + return add_base_cell_cycle_annotation( + cca_df, + cell_ids, + base_values=base_values, + ) + def add_missing_ids( self, cca_df: pd.DataFrame | None, @@ -57,22 +70,12 @@ def add_missing_ids( cca_df=merge_missing_cca_ids(cca_df, base_cca_df), ) - def relabel_ids( - self, - cca_df: pd.DataFrame, - old_ids, - new_ids, - ) -> CcaFrameEditResult: - return CcaFrameEditResult( - cca_df=relabel_cca_ids(cca_df, old_ids, new_ids), - ) - - def delete_ids( + def apply_manual_changes( self, cca_df: pd.DataFrame, - deleted_ids, - ) -> CcaDeletedIdsResult: - return delete_cca_ids(cca_df, deleted_ids) + changes, + ) -> pd.DataFrame: + return apply_manual_cca_changes(cca_df, changes) def apply_snapshot_id_edits( self, @@ -91,38 +94,6 @@ def apply_snapshot_id_edits( base_values=base_values, ) - def apply_manual_changes( - self, - cca_df: pd.DataFrame, - changes, - ) -> pd.DataFrame: - return apply_manual_cca_changes(cca_df, changes) - - def normalize_loaded_frame_annotations( - self, - acdc_df: pd.DataFrame | None, - cca_colnames, - int_colnames=(), - ) -> pd.DataFrame | None: - return normalize_loaded_cell_cycle_frame_annotations( - acdc_df, - cca_colnames, - int_colnames, - ) - - def add_base_annotation( - self, - cca_df: pd.DataFrame, - cell_ids, - *, - base_values: dict | None = None, - ) -> pd.DataFrame: - return add_base_cell_cycle_annotation( - cca_df, - cell_ids, - base_values=base_values, - ) - def build_base_annotations( self, cell_ids, @@ -138,15 +109,12 @@ def build_base_annotations( tree_values=tree_values, ) - def last_annotated_frame_index(self, acdc_dfs) -> int: - return last_annotated_cell_cycle_frame_index(acdc_dfs) - def concat_annotations( self, frame_records, cca_colnames, *, - acdc_key: str = 'acdc_df', + acdc_key: str = "acdc_df", size_t: int | None = None, ) -> pd.DataFrame | None: return concat_cell_cycle_annotations( @@ -156,19 +124,55 @@ def concat_annotations( size_t=size_t, ) - def split_concat_annotations( + def delete_ids( self, - global_cca_df: pd.DataFrame | None, + cca_df: pd.DataFrame, + deleted_ids, + ) -> CcaDeletedIdsResult: + return delete_cca_ids(cca_df, deleted_ids) + + def has_annotations(self, acdc_df: pd.DataFrame | None) -> bool: + return has_cell_cycle_annotations(acdc_df) + + def last_annotated_frame_index(self, acdc_dfs) -> int: + return last_annotated_cell_cycle_frame_index(acdc_dfs) + + def normalize_loaded_frame_annotations( + self, + acdc_df: pd.DataFrame | None, + cca_colnames, + int_colnames=(), + ) -> pd.DataFrame | None: + return normalize_loaded_cell_cycle_frame_annotations( + acdc_df, + cca_colnames, + int_colnames, + ) + + def prepare_checker_annotations( + self, + cca_df: pd.DataFrame | None, *, - size_t: int | None = None, - frame_level: str = 'frame_i', - ) -> list[tuple[int, pd.DataFrame]]: - return split_concat_cell_cycle_annotations( - global_cca_df, - size_t=size_t, - frame_level=frame_level, + checker_running: bool = True, + ) -> pd.DataFrame | None: + return prepare_cell_cycle_checker_annotations( + cca_df, + checker_running=checker_running, + ) + + def relabel_ids( + self, + cca_df: pd.DataFrame, + old_ids, + new_ids, + ) -> CcaFrameEditResult: + return CcaFrameEditResult( + cca_df=relabel_cca_ids(cca_df, old_ids, new_ids), ) + def remove_annotations(self, acdc_df: pd.DataFrame | None, cca_colnames): + return remove_cell_cycle_annotations(acdc_df, cca_colnames) + def remove_future_annotations( self, frame_records, @@ -177,7 +181,7 @@ def remove_future_annotations( *, size_t: int | None = None, concatenated_acdc_df: pd.DataFrame | None = None, - acdc_key: str = 'acdc_df', + acdc_key: str = "acdc_df", ): return remove_future_cell_cycle_annotations( frame_records, @@ -188,9 +192,6 @@ def remove_future_annotations( acdc_key=acdc_key, ) - def remove_annotations(self, acdc_df: pd.DataFrame | None, cca_colnames): - return remove_cell_cycle_annotations(acdc_df, cca_colnames) - def reset_future_flags(self, cca_df: pd.DataFrame) -> pd.DataFrame: return reset_cca_future_flags(cca_df) @@ -217,15 +218,17 @@ def resolve_annotations( with_tree_cols=with_tree_cols, ) - def prepare_checker_annotations( + def split_concat_annotations( self, - cca_df: pd.DataFrame | None, + global_cca_df: pd.DataFrame | None, *, - checker_running: bool = True, - ) -> pd.DataFrame | None: - return prepare_cell_cycle_checker_annotations( - cca_df, - checker_running=checker_running, + size_t: int | None = None, + frame_level: str = "frame_i", + ) -> list[tuple[int, pd.DataFrame]]: + return split_concat_cell_cycle_annotations( + global_cca_df, + size_t=size_t, + frame_level=frame_level, ) def store_frame_annotations( @@ -244,6 +247,3 @@ def store_frame_annotations( store_checker_copy=store_checker_copy, store_cca_df_copy=store_cca_df_copy, ) - - def has_annotations(self, acdc_df: pd.DataFrame | None) -> bool: - return has_cell_cycle_annotations(acdc_df) diff --git a/cellacdc/mixins/cca_workflows.py b/cellacdc/mixins/cca_workflows.py index 98f5cb7f1..db8105710 100644 --- a/cellacdc/mixins/cca_workflows.py +++ b/cellacdc/mixins/cca_workflows.py @@ -51,12 +51,36 @@ ) -class CcaWorkflowViewModel: +class CcaWorkflowMixin: """Application-facing commands for CCA workflows and propagation.""" + def annotate_division(self, *args, **kwargs): + return annotate_division(*args, **kwargs) + + def apply_auto_assignments(self, *args, **kwargs): + return apply_auto_cca_assignments(*args, **kwargs) + + def auto_assignments_from_cost(self, *args, **kwargs): + return auto_cca_assignments_from_cost(*args, **kwargs) + + def auto_candidate_mother_ids(self, *args, **kwargs): + return auto_cca_candidate_mother_ids(*args, **kwargs) + + def auto_cost_matrix_from_contours(self, *args, **kwargs): + return auto_cca_cost_matrix_from_contours(*args, **kwargs) + + def auto_cost_matrix_from_distances(self, *args, **kwargs): + return auto_cca_cost_matrix_from_distances(*args, **kwargs) + + def auto_repeat_frame_state(self, *args, **kwargs): + return auto_cca_repeat_frame_state(*args, **kwargs) + def base_status(self, base_values=None): return base_cell_cycle_annotation_status(base_values) + def bud_mother_change_eligibility(self, *args, **kwargs): + return bud_mother_change_eligibility(*args, **kwargs) + def collect_existing_new_id_rows(self, *args, **kwargs): return collect_existing_new_id_cca_rows_from_frames(*args, **kwargs) @@ -72,89 +96,65 @@ def extract_annotations(self, *args, **kwargs): def fix_will_divide_without_next_generation(self, *args, **kwargs): return fix_will_divide_without_next_generation(*args, **kwargs) + def known_history_status_for_bud(self, *args, **kwargs): + return known_history_status_for_bud(*args, **kwargs) + def missing_annotation_items(self, *args, **kwargs): return missing_cell_cycle_annotation_items(*args, **kwargs) - def overlay_last_annotated(self, *args, **kwargs): - return overlay_last_annotated_cca(*args, **kwargs) - - def propagate_s_phase_disappearance_divisions(self, *args, **kwargs): - return propagate_s_phase_disappearance_divisions(*args, **kwargs) - - def reset_will_divide_for_generations(self, *args, **kwargs): - return reset_will_divide_for_generations(*args, **kwargs) - - def s_phase_relative_ids_gone(self, *args, **kwargs): - return s_phase_relative_ids_gone(*args, **kwargs) - - def annotate_division(self, *args, **kwargs): - return annotate_division(*args, **kwargs) - - def undo_division_annotation(self, *args, **kwargs): - return undo_division_annotation(*args, **kwargs) - - def undo_bud_mother_assignment(self, *args, **kwargs): - return undo_bud_mother_assignment(*args, **kwargs) - - def apply_auto_assignments(self, *args, **kwargs): - return apply_auto_cca_assignments(*args, **kwargs) - - def auto_assignments_from_cost(self, *args, **kwargs): - return auto_cca_assignments_from_cost(*args, **kwargs) - - def auto_candidate_mother_ids(self, *args, **kwargs): - return auto_cca_candidate_mother_ids(*args, **kwargs) - - def auto_cost_matrix_from_contours(self, *args, **kwargs): - return auto_cca_cost_matrix_from_contours(*args, **kwargs) - - def auto_cost_matrix_from_distances(self, *args, **kwargs): - return auto_cca_cost_matrix_from_distances(*args, **kwargs) - - def auto_repeat_frame_state(self, *args, **kwargs): - return auto_cca_repeat_frame_state(*args, **kwargs) + def mother_assignment_eligibility(self, *args, **kwargs): + return mother_assignment_eligibility(*args, **kwargs) def nearest_point_2d_yx(self, *args, **kwargs): return nearest_point_2d_yx(*args, **kwargs) + def overlay_last_annotated(self, *args, **kwargs): + return overlay_last_annotated_cca(*args, **kwargs) + def prepare_auto_current_frame(self, *args, **kwargs): return prepare_auto_cca_current_frame(*args, **kwargs) - def uncorrected_new_ids_for_auto(self, *args, **kwargs): - return uncorrected_new_ids_for_auto_cca(*args, **kwargs) - - def propagate_deleted_ids(self, *args, **kwargs): - return propagate_deleted_cell_cycle_ids(*args, **kwargs) - def prepare_missing_frame_annotations(self, *args, **kwargs): return prepare_missing_cell_cycle_frame_annotations(*args, **kwargs) def previous_relative_status_before_bud_emergence(self, *args, **kwargs): return previous_relative_status_before_bud_emergence(*args, **kwargs) - def bud_mother_change_eligibility(self, *args, **kwargs): - return bud_mother_change_eligibility(*args, **kwargs) - - def mother_assignment_eligibility(self, *args, **kwargs): - return mother_assignment_eligibility(*args, **kwargs) - def propagate_bud_mother_assignment(self, *args, **kwargs): return propagate_bud_mother_assignment(*args, **kwargs) + def propagate_deleted_ids(self, *args, **kwargs): + return propagate_deleted_cell_cycle_ids(*args, **kwargs) + + def propagate_history_knowledge(self, *args, **kwargs): + return propagate_history_knowledge(*args, **kwargs) + def propagate_manual_division_annotation(self, *args, **kwargs): return propagate_manual_division_annotation(*args, **kwargs) + def propagate_s_phase_disappearance_divisions(self, *args, **kwargs): + return propagate_s_phase_disappearance_divisions(*args, **kwargs) + def propagate_swap_mothers_assignment(self, *args, **kwargs): return propagate_swap_mothers_assignment(*args, **kwargs) def propagate_will_divide(self, *args, **kwargs): return propagate_will_divide(*args, **kwargs) + def reset_will_divide_for_generations(self, *args, **kwargs): + return reset_will_divide_for_generations(*args, **kwargs) + + def s_phase_relative_ids_gone(self, *args, **kwargs): + return s_phase_relative_ids_gone(*args, **kwargs) + def swap_mothers_eligibility(self, *args, **kwargs): return swap_mothers_eligibility(*args, **kwargs) - def known_history_status_for_bud(self, *args, **kwargs): - return known_history_status_for_bud(*args, **kwargs) + def uncorrected_new_ids_for_auto(self, *args, **kwargs): + return uncorrected_new_ids_for_auto_cca(*args, **kwargs) - def propagate_history_knowledge(self, *args, **kwargs): - return propagate_history_knowledge(*args, **kwargs) + def undo_bud_mother_assignment(self, *args, **kwargs): + return undo_bud_mother_assignment(*args, **kwargs) + + def undo_division_annotation(self, *args, **kwargs): + return undo_division_annotation(*args, **kwargs) diff --git a/cellacdc/mixins/cell_cycle.py b/cellacdc/mixins/cell_cycle.py index 20aa054d8..91f57c8bf 100644 --- a/cellacdc/mixins/cell_cycle.py +++ b/cellacdc/mixins/cell_cycle.py @@ -6,565 +6,581 @@ import uuid from tqdm import tqdm -from dataclasses import dataclass import pandas as pd from qtpy.QtCore import QMutex, QThread, QTimer, QWaitCondition from qtpy.QtWidgets import QCheckBox, QMessageBox, QPushButton from cellacdc import ( - apps, _warnings, base_cca_dict, base_cca_tree_dict, disableWindow, - exception_handler, html_utils, + apps, + _warnings, + base_cca_dict, + disableWindow, + exception_handler, + html_utils, ) from cellacdc import widgets, workers -class CellCycleView: +class CellCycleMixin: """Qt-facing adapter for cell-cycle annotation workflows.""" - LEGACY_METHODS = ( - 'nearest_point_2Dyx', - 'isCurrentFrameCcaVisited', - 'warnScellsGone', - 'checkScellsGone', - 'attempt_auto_cca', - 'highlightIDs', - 'warnFrameNeverVisitedSegmMode', - 'checkCcaPastFramesNewIDs', - 'addIDBaseCca_df', - 'getBaseCca_df', - 'get_last_cca_frame_i', - 'initCca', - '_getCcaCostMatrix', - 'autoCca_df', - 'initMissingFramesCca', - 'addMissingIDs_cca_df', - 'update_cca_df_relabelling', - 'update_cca_df_deletedIDs', - 'updateCcaDfDeletedIDsTimelapse', - 'update_cca_df_newIDs', - 'update_cca_df_snapshots', - 'fixCcaDfAfterEdit', - 'setCcaIssueContour', - 'isLastVisitedAgainCca', - 'highlightNewCellNotEnoughG1cells', - 'highlightNewIDs_ccaFailed', - 'handleNoCellsInG1', - 'isFrameCcaAnnotated', - 'warnEditingWithCca_df', - 'ccaIntegrCheckerToggled', - 'startCcaIntegrityCheckerWorker', - 'initCcaIntegrityChecker', - 'disableCcaIntegrityChecker', - 'stopCcaIntegrityCheckerWorker', - 'isCcaCheckerChecking', - 'getConcatCcaDf', - 'storeFromConcatCcaDf', - 'resetWillDivideInfo', - 'ccaCheckerStopChecking', - 'enqCcaIntegrityChecker', - 'resetCcaFuture', - 'removeCcaAnnotationsCurrentFrame', - 'resetFutureCcaColCurrentFrame', - 'get_cca_df', - 'unstore_cca_df', - 'store_cca_df_checker', - 'store_cca_df', - 'viewCcaTable', - 'autoAssignBud_YeastMate', - 'reInitCca', - 'repeatAutoCca', - 'manualEditCcaToolbarActionTriggered', - 'manualEditCca', - 'applyManualCcaChangesFutureFrames', - 'ccaCheckerWorkerDone', - 'goToFrameNumber', - 'warnCcaIntegrity', - 'fixWillDivide', - 'ccaCheckerWorkerClosed', - 'updateIsHistoryKnown', - 'annotateIsHistoryKnown', - 'annotateWillDivide', - 'annotateDivision', - 'undoDivisionAnnotation', - 'undoBudMothAssignment', - 'manualCellCycleAnnotation', - 'warnMotherNotEligible', - 'warnSettingHistoryKnownCellsFirstFrame', - 'checkMothEligibility', - 'checkMothersExcludedOrDead', - 'checkDivisionCanBeUndone', - 'stopBlinkingPairItem', - 'warnDeadOrExcludedMothers', - 'startBlinkingPairingItem', - 'blinkPairingItem', - 'annotateBudToDifferentMother', - 'onMotherNotInG1', - 'warnBudAnnotatedDividedInFuture', - 'warnMotherNotAtLeastOneFrameG1', - 'checkChangeMotherBudEligible', - 'checkSwapMothersEligibility', - 'swapMothers', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - - def nearest_point_2Dyx(self, points, all_others): - """ + def _getCcaCostMatrix(self, numCellsG1, numNewCells, IDsCellsG1, newIDs_contours): + posData = self.data[self.pos_i] + dataDict = posData.allData_li[posData.frame_i] + dist_matrix_df = dataDict.get("obj_to_obj_dist_cost_matrix_df") + if dist_matrix_df is None: + cost = np.full((numCellsG1, numNewCells), np.inf) + for obj in posData.rp: + ID = obj.label + try: + i = IDsCellsG1.index(ID) + except ValueError: + continue - """Headless cell-cycle workflow rules.""" + cont = self.getObjContours(obj) + i = IDsCellsG1.index(ID) - def annotated_edit_warning_plan( - self, - *, - is_snapshot: bool, - acdc_df_missing: bool, - lineage_tree_missing: bool, - cell_cycle_stage_present: bool, - lineage_tree_present: bool, - remembered_skip_warning: bool, - ) -> AnnotatedEditWarningPlan: - if is_snapshot: - return AnnotatedEditWarningPlan(proceed_without_warning=True) + # Get distance from cell in G1 and all other new cells + for j, newID_cont in enumerate(newIDs_contours): + min_dist, nearest_xy = self.nearest_point_2Dyx(cont, newID_cont) + cost[i, j] = min_dist - no_annotation_source = acdc_df_missing and lineage_tree_missing - no_annotations = not cell_cycle_stage_present and not lineage_tree_present - if no_annotation_source or no_annotations or remembered_skip_warning: - return AnnotatedEditWarningPlan( - proceed_without_warning=True, - update_images=True, - ) + return cost - warn_type = ( - 'cell cycle annotations' - if cell_cycle_stage_present - else 'lineage tree annotations' - ) - return AnnotatedEditWarningPlan( - proceed_without_warning=False, - should_prompt=True, - warn_type=warn_type, - ) + cost = dist_matrix_df.loc[IDsCellsG1, posData.new_IDs].values - def check_mothers_exclusion_or_dead( - self, - acdc_df: pd.DataFrame, - mother_ids: list[int], - ) -> list[int]: - """Checks tracking rules for cell exclusions or deaths.""" - if acdc_df is None or not mother_ids: - return [] + return cost - valid_ids = [m_id for m_id in mother_ids if m_id in acdc_df.index] - if not valid_ids: - return [] + def addIDBaseCca_df(self, posData, ID): + if ID <= 0: + # When calling update_cca_df_deletedIDs we add relative IDs + # but they could be -1 for cells in G1 + return - mothers_df = acdc_df.loc[valid_ids] - excluded_mask = ( - (mothers_df.get('is_cell_dead', 0) > 0) - | (mothers_df.get('is_cell_excluded', 0) > 0) + _zip = zip( + self.cca_df_colnames, + self.cca_df_default_values, ) - return mothers_df[excluded_mask].index.tolist() + if posData.cca_df.empty: + posData.cca_df = pd.DataFrame({col: val for col, val in _zip}, index=[ID]) + else: + for col, val in _zip: + posData.cca_df.at[ID, col] = val + self.store_cca_df() - def evaluate_sister_relations( - self, - prev_cca_df: pd.DataFrame, - current_ids: set[int], - ) -> list[int]: - """Determines S-phase mother-bud dependencies and sister relation tracking rules.""" - if prev_cca_df is None or not current_ids: - return [] + def addMissingIDs_cca_df(self, posData): + base_cca_df = self.getBaseCca_df() + if posData.cca_df is None: + posData.cca_df = base_cca_df + return - current_ids_set = set(current_ids) - disappeared_ids = [] - for cc_series in prev_cca_df.itertuples(): - if getattr(cc_series, 'cell_cycle_stage', None) != 'S': - continue + posData.cca_df = posData.cca_df.combine_first(base_cca_df) - cell_id = cc_series.Index - relative_id = getattr(cc_series, 'relative_ID', -1) - if relative_id == -1: - continue - if relative_id not in current_ids_set and cell_id in current_ids_set: - disappeared_ids.append(relative_id) + def annotateBudToDifferentMother(self): + """ + This function is used for correcting automatic mother-bud assignment. - return disappeared_ids + It can be called at any frame of the bud life. + There are three cells involved: bud, current mother, new mother. - Given 2D array of [y, x] coordinates points and all_others return the - [y, x] coordinates of the two points (one from points and one from all_others) - that have the absolute minimum distance - """ - return self.cca_workflows.nearest_point_2d_yx(points, all_others) + Eligibility: + - User clicked first on a bud (checked at click time) + - User released mouse button on a cell in G1 (checked at release time) + - The new mother MUST be in G1 for all the frames of the bud life + --> if not warn + - The new mother MUST have appeared in current frame OR be already + in G1 in previous frame, otherwise there would be no G1 cycle - def isCurrentFrameCcaVisited(self): + Result: + - The bud only changes relative ID to the new mother + - The new mother changes relative ID and stage to 'S' + - The old mother changes its entire status to the status it had + before being assigned to the clicked bud + """ posData = self.data[self.pos_i] - curr_df = posData.allData_li[posData.frame_i]['acdc_df'] - return self.cca_edits.has_annotations(curr_df) - - def warnScellsGone(self, ScellsIDsGone, frame_i): - msg = widgets.myMessageBox() - text = html_utils.paragraph(f""" - In the next frame the followning cells' IDs in S/G2/M - (highlighted with a yellow contour) will disappear:

- {ScellsIDsGone}

- If the cell does not exist you might have deleted it at some point. - If that's the case, then try to go to some previous frames and reset - the cell cycle annotations there (button on the top toolbar).

- These cells are either buds or mother whose related IDs will not - disappear. This is likely due to cell division happening in - previous frame and the divided bud or mother will be - washed away.

- If you decide to continue these cells will be automatically - annotated as divided at frame number {frame_i}.

- Do you want to continue? - """) - _, yesButton, noButton = msg.warning( - self.host, 'Cells in "S/G2/M" disappeared!', text, - buttonsTexts=('Cancel', 'Yes', 'No') - ) - return msg.clickedButton == yesButton + lab2D = self.get_2Dlab(posData.lab) + budID = lab2D[self.yClickBud, self.xClickBud] + new_mothID = lab2D[self.yClickMoth, self.xClickMoth] - def checkScellsGone(self): - """Check if there are cells in S phase whose relative disappear in - current frame. Allow user to choose between automatically assign - division to these cells or cancel and not visit the frame. + if budID == new_mothID: + return - Returns - ------- - bool - False if there are no cells disappeared or the user decided - to accept automatic division. - """ - automaticallyDividedIDs = [] + if not self.isSnapshot: + eligible = self.checkMothEligibility(budID, new_mothID) + if not eligible: + return - mode = str(self.modeComboBox.currentText()) - if mode.find('Cell cycle') == -1: - # No cell cycle analysis mode --> do nothing - return False, automaticallyDividedIDs + budEligible = self.checkChangeMotherBudEligible(budID, posData.frame_i) + if not budEligible: + return - posData = self.data[self.pos_i] + # Allow partial initialization of cca_df with mouse + if posData.frame_i == 0: + newMothCcs = posData.cca_df.at[new_mothID, "cell_cycle_stage"] + if not newMothCcs == "G1": + err_msg = "You are assigning the bud to a cell that is not in G1!" + msg = QMessageBox() + msg.critical(self, "New mother not in G1!", err_msg, msg.Ok) + return + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(0, posData.cca_df, undoId) + currentRelID = posData.cca_df.at[budID, "relative_ID"] + if currentRelID in posData.cca_df.index: + posData.cca_df.at[currentRelID, "relative_ID"] = -1 + posData.cca_df.at[currentRelID, "generation_num"] = 2 + posData.cca_df.at[currentRelID, "cell_cycle_stage"] = "G1" + posData.cca_df.at[budID, "relationship"] = "bud" + posData.cca_df.at[budID, "generation_num"] = 0 + posData.cca_df.at[budID, "relative_ID"] = new_mothID + posData.cca_df.at[budID, "cell_cycle_stage"] = "S" + posData.cca_df.at[new_mothID, "relative_ID"] = budID + posData.cca_df.at[new_mothID, "generation_num"] = 2 + posData.cca_df.at[new_mothID, "cell_cycle_stage"] = "S" + self.updateAllImages() + self.store_cca_df() + return - if posData.allData_li[posData.frame_i]['labels'] is None: - # Frame never visited/checked in segm mode --> autoCca_df will raise - # a critical message - return False, automaticallyDividedIDs + curr_mothID = posData.cca_df.at[budID, "relative_ID"] + if curr_mothID in posData.cca_df.index: + curr_moth_cca = self.getStatus_RelID_BeforeEmergence(budID, curr_mothID) - # Check if there are S cells that either only mother or only - # bud disappeared and automatically assign division to it - # or abort visiting this frame - prev_acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_cca_df = prev_acdc_df[self.cca_df_colnames].copy() + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - ScellsIDsGone = self.evaluate_sister_relations(prev_cca_df, posData.IDs) + # Correct current frames and update LabelItems + posData.cca_df.at[budID, "relative_ID"] = new_mothID + posData.cca_df.at[budID, "generation_num"] = 0 + posData.cca_df.at[budID, "relative_ID"] = new_mothID + posData.cca_df.at[budID, "relationship"] = "bud" + posData.cca_df.at[budID, "corrected_on_frame_i"] = posData.frame_i + posData.cca_df.at[budID, "cell_cycle_stage"] = "S" + posData.cca_df.at[new_mothID, "relative_ID"] = budID + posData.cca_df.at[new_mothID, "cell_cycle_stage"] = "S" + posData.cca_df.at[new_mothID, "relationship"] = "mother" - if not ScellsIDsGone: - # No cells in S that disappears --> do nothing - return False, automaticallyDividedIDs + if curr_mothID in posData.cca_df.index: + # Cells with UNKNOWN history has relative's ID = -1 + # which is not an existing cell + posData.cca_df.loc[curr_mothID] = curr_moth_cca - self.highlightNewIDs_ccaFailed(ScellsIDsGone, rp=prev_rp) - proceed = self.warnScellsGone(ScellsIDsGone, posData.frame_i) - self.clearLostObjContoursItems() + self.updateAllImages() + # self.checkMultiBudMoth(draw=True) + self.store_cca_df() + proceed = self.checkMothersExcludedOrDead() if not proceed: - return True, automaticallyDividedIDs + # User clicked on cancel in the message box + self.UndoCca() + return - past_cca_frames = ( - (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) - for frame_i in range(posData.frame_i-2, -1, -1) - ) - propagation_result = self.cca_workflows.propagate_s_phase_disappearance_divisions( - prev_cca_df, - posData.cca_df, - posData.frame_i, - posData.IDs, - past_cca_frames=past_cca_frames, - disappeared_ids=ScellsIDsGone, - ) - if propagation_result.current_cca_df is not None: - posData.cca_df = propagation_result.current_cca_df + if self.ccaTableWin is not None: + zoomIDs = self.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - automaticallyDividedIDs.extend( - propagation_result.automatically_divided_ids - ) - updated_cca_dfs = propagation_result.updated_cca_dfs_by_frame - for frame_i, cca_df_i in updated_cca_dfs.items(): - self.store_cca_df( - frame_i=frame_i, - cca_df=cca_df_i, - autosave=False, - ) + # Correct future frames + for i in range(posData.frame_i + 1, posData.SizeT): + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break - return False, automaticallyDividedIDs + IDs = cca_df_i.index + if budID not in IDs or new_mothID not in IDs: + # For some reason ID disappeared from this frame + continue - @exception_handler - def attempt_auto_cca(self, enforceAll=False): - mode = str(self.modeComboBox.currentText()) - posData = self.data[self.pos_i] + self.storeUndoRedoCca(i, cca_df_i, undoId) + bud_relationship = cca_df_i.at[budID, "relationship"] + bud_ccs = cca_df_i.at[budID, "cell_cycle_stage"] - if mode == 'Cell cycle analysis': - notEnoughG1Cells, proceed = self.autoCca_df( - enforceAll=enforceAll - ) - if not proceed: - return notEnoughG1Cells, proceed + if bud_relationship == "mother" and bud_ccs == "S": + # The bud at the ith frame budded itself --> stop + break - # mode = str(self.modeComboBox.currentText()) - if posData.cca_df is None: # ??? - notEnoughG1Cells = False - proceed = True - return notEnoughG1Cells, proceed - if posData.cca_df.isna().any(axis=None): - raise ValueError('Cell cycle analysis table contains NaNs') - # self.checkMultiBudMoth() - proceed = self.checkMothersExcludedOrDead() - return notEnoughG1Cells, proceed + cca_df_i.at[budID, "relative_ID"] = new_mothID + cca_df_i.at[budID, "generation_num"] = 0 + cca_df_i.at[budID, "relative_ID"] = new_mothID + cca_df_i.at[budID, "relationship"] = "bud" + cca_df_i.at[budID, "cell_cycle_stage"] = "S" + + newMoth_bud_ccs = cca_df_i.at[new_mothID, "cell_cycle_stage"] + if newMoth_bud_ccs == "G1": + # Assign bud to new mother only if the new mother is in G1 + # This can happen if the bud already has a G1 annotated + cca_df_i.at[new_mothID, "relative_ID"] = budID + cca_df_i.at[new_mothID, "cell_cycle_stage"] = "S" + cca_df_i.at[new_mothID, "relationship"] = "mother" + + if curr_mothID in cca_df_i.index: + # Cells with UNKNOWN history has relative's ID = -1 + # which is not an existing cell + cca_df_i.loc[curr_mothID] = curr_moth_cca - elif mode == 'Normal division: Lineage tree': - self.autoLinTree_df() - notEnoughG1Cells = False - proceed = True - return notEnoughG1Cells, proceed + self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - else: - notEnoughG1Cells = False - proceed = True - return notEnoughG1Cells, proceed + # Correct past frames + for i in range(posData.frame_i - 1, -1, -1): + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - def highlightIDs(self, IDs, pen): - pass + is_bud_existing = budID in cca_df_i.index + if not is_bud_existing: + # Bud was not emerged yet + break - def warnFrameNeverVisitedSegmMode(self): - msg = widgets.myMessageBox() - warn_cca = msg.critical( - self.host, 'Next frame NEVER visited', - 'Next frame was never visited in "Segmentation and Tracking"' - 'mode.\n You cannot perform cell cycle analysis on frames' - 'where segmentation and/or tracking errors were not' - 'checked/corrected.\n\n' - 'Switch to "Segmentation and Tracking" mode ' - 'and check/correct next frame,\n' - 'before attempting cell cycle analysis again', - ) - return False + self.storeUndoRedoCca(i, cca_df_i, undoId) + cca_df_i.at[budID, "relative_ID"] = new_mothID + cca_df_i.at[budID, "generation_num"] = 0 + cca_df_i.at[budID, "relative_ID"] = new_mothID + cca_df_i.at[budID, "relationship"] = "bud" + cca_df_i.at[budID, "cell_cycle_stage"] = "S" - def checkCcaPastFramesNewIDs(self): - posData = self.data[self.pos_i] - if not posData.new_IDs: - return + cca_df_i.at[new_mothID, "relative_ID"] = budID + cca_df_i.at[new_mothID, "cell_cycle_stage"] = "S" + cca_df_i.at[new_mothID, "relationship"] = "mother" - past_acdc_frames = ( - (frame_i, posData.allData_li[frame_i]['acdc_df']) - for frame_i in range(posData.frame_i-2, -1, -1) - ) - result = self.cca_workflows.collect_existing_new_id_rows( - posData.new_IDs, - past_acdc_frames, - self.cca_df_colnames, - ) - posData.new_IDs = result.remaining_new_ids - return result.found_cca_dfs + if curr_mothID in cca_df_i.index: + # Cells with UNKNOWN history has relative's ID = -1 + # which is not an existing cell + cca_df_i.loc[curr_mothID] = curr_moth_cca - def addIDBaseCca_df(self, posData, ID): - if ID <= 0: - # When calling update_cca_df_deletedIDs we add relative IDs - # but they could be -1 for cells in G1 - return + self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - posData.cca_df = self.cca_edits.add_base_annotation( - posData.cca_df, - ID, - base_values=base_cca_dict, - ) - self.store_cca_df() + self.enqAutosave() - def getBaseCca_df(self, with_tree_cols=False): + def annotateDivision(self, cca_df, ID, relID, frame_i=None): + # Correct as follows: + # For frame_i > 0 --> assign to G1 and +1 on generation number + # For frame == 0 --> reinitialize to unknown cells posData = self.data[self.pos_i] - IDs = [obj.label for obj in posData.rp] - return self.cca_edits.build_base_annotations( - IDs, - with_tree_cols=with_tree_cols, - base_values=base_cca_dict, - tree_values=base_cca_tree_dict, - ) + if frame_i is None: + frame_i = posData.frame_i - def get_last_cca_frame_i(self): - posData = self.data[self.pos_i] - return self.cca_edits.last_annotated_frame_index( - dict_frame_i['acdc_df'] - for dict_frame_i in posData.allData_li - ) + self.annotateWillDivide(ID, relID) - @exception_handler - def initCca(self): + store = False + cca_df.at[ID, "cell_cycle_stage"] = "G1" + cca_df.at[relID, "cell_cycle_stage"] = "G1" + + if frame_i > 0: + gen_num_clickedID = cca_df.at[ID, "generation_num"] + cca_df.at[ID, "generation_num"] += 1 + cca_df.at[ID, "division_frame_i"] = frame_i + gen_num_relID = cca_df.at[relID, "generation_num"] + cca_df.at[relID, "generation_num"] = gen_num_relID + 1 + cca_df.at[relID, "division_frame_i"] = frame_i + if gen_num_clickedID < gen_num_relID: + cca_df.at[ID, "relationship"] = "mother" + else: + cca_df.at[relID, "relationship"] = "mother" + else: + cca_df.at[ID, "generation_num"] = 2 + cca_df.at[relID, "generation_num"] = 2 + + cca_df.at[ID, "division_frame_i"] = -1 + cca_df.at[relID, "division_frame_i"] = -1 + + cca_df.at[ID, "relationship"] = "mother" + cca_df.at[relID, "relationship"] = "mother" + + store = True + return store + + def annotateIsHistoryKnown(self, ID): + """ + This function is used for annotating that a cell has unknown or known + history. Cells with unknown history are for example the cells already + present in the first frame or cells that appear in the frame from + outside of the field of view. + + With this function we simply set 'is_history_known' to False. + When the users saves instead we update the entire staus of the cell + with unknown history with the function "updateIsHistoryKnown()" + """ posData = self.data[self.pos_i] - last_tracked_i = self.get_last_tracked_i() - defaultMode = 'Viewer' - if last_tracked_i == 0: - txt = html_utils.paragraph( - 'On this dataset either you never checked that the segmentation ' - 'and tracking are correct or you did not save yet.

' - 'If you already visited some frames with "Segmentation and Tracking" ' - 'mode save data before switching to "Cell cycle analysis mode".

' - 'Otherwise you first have to check (and eventually correct) some frames ' - 'in "Segmentation and Tracking" mode before proceeding ' - 'with cell cycle analysis.') - msg = widgets.myMessageBox() - msg.critical( - self.host, 'Tracking was never checked', txt - ) - self.modeComboBox.setCurrentText(defaultMode) + is_history_known = posData.cca_df.at[ID, "is_history_known"] + relID = posData.cca_df.at[ID, "relative_ID"] + if relID in posData.cca_df.index: + relID_cca = self.getStatus_RelID_BeforeEmergence(ID, relID) + + if is_history_known: + # Save status of ID when emerged to allow undoing + statusID_whenEmerged = self.getStatusKnownHistoryBud(ID) + if statusID_whenEmerged is None: + return + posData.ccaStatus_whenEmerged[ID] = statusID_whenEmerged + + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + + if ID not in posData.ccaStatus_whenEmerged: + self.warnSettingHistoryKnownCellsFirstFrame(ID) return - proceed = True + self.setHistoryKnowledge(ID, posData.cca_df) - last_cca_frame_i = self.get_last_cca_frame_i() - if last_cca_frame_i == 0: - # Remove undoable actions from segmentation mode - posData.UndoRedoStates[0] = [] - self.undoAction.setEnabled(False) - self.redoAction.setEnabled(False) + if relID in posData.cca_df.index: + # If the cell with unknown history has a relative ID assigned to it + # we set the cca of it to the status it had BEFORE the assignment + posData.cca_df.loc[relID] = relID_cca - if posData.frame_i > last_cca_frame_i: - # Prompt user to go to last annotated frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_cca_frame_i+1}.

- Do you want to restart cell cycle analysis from frame - {last_cca_frame_i+1}?
- """) - _, goToFrameButton, stayButton = msg.warning( - self.host, 'Go to last annotated frame?', txt, - buttonsTexts=( - 'Cancel', f'Yes, go to frame {last_cca_frame_i+1}', - 'No, stay on current frame') - ) - if goToFrameButton == msg.clickedButton: - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - msg = 'Looking good!' - self.last_cca_frame_i = last_cca_frame_i - posData.frame_i = last_cca_frame_i - self.titleLabel.setText(msg, color=self.titleColor) - self.get_data() - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - self.updateAllImages() - self.updateScrollbars() - elif stayButton == msg.clickedButton: - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - self.initMissingFramesCca(last_cca_frame_i, posData.frame_i) - last_cca_frame_i = posData.frame_i - msg = 'Cell cycle analysis initialised!' - self.titleLabel.setText(msg, color='g') - elif msg.cancel: - msg = 'Cell cycle analysis aborted.' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - self.modeComboBox.setCurrentText(defaultMode) - proceed = False + # Update cell cycle info LabelItems + obj_idx = posData.IDs.index(ID) + posData.rp[obj_idx] + + if relID in posData.IDs: + relObj_idx = posData.IDs.index(relID) + posData.rp[relObj_idx] + + self.setAllTextAnnotations() + self.drawAllMothBudLines() + + self.store_cca_df() + + if self.ccaTableWin is not None: + zoomIDs = self.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + + # Correct future frames + for i in range(posData.frame_i + 1, posData.SizeT): + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + self.storeUndoRedoCca(i, cca_df_i, undoId) + IDs = cca_df_i.index + if ID not in IDs: + # For some reason ID disappeared from this frame + continue + else: + self.setHistoryKnowledge(ID, cca_df_i) + if relID in IDs: + cca_df_i.loc[relID] = relID_cca + self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) + + # Correct past frames + for i in range(posData.frame_i - 1, -1, -1): + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + self.storeUndoRedoCca(i, cca_df_i, undoId) + IDs = cca_df_i.index + if ID not in IDs: + # we reached frame where ID was not existing yet + break + else: + relID = cca_df_i.at[ID, "relative_ID"] + self.setHistoryKnowledge(ID, cca_df_i) + if relID in IDs: + cca_df_i.loc[relID] = relID_cca + self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) + + self.enqAutosave() + + def annotateWillDivide(self, ID, relID, frame_i=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + # Store in the past frames that division has been annotated + for past_frame_i in range(frame_i - 1, -1, -1): + past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True) + if past_cca_df is None: return - elif posData.frame_i < last_cca_frame_i: - # Prompt user to go to last annotated frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_cca_frame_i+1}.

- Do you want to restart cell cycle analysis from frame - {last_cca_frame_i+1}?
- """) - yesButton, noButton, _ = msg.question( - self.host, 'Go to last annotated frame?', txt, - buttonsTexts=('Yes', 'No', 'Cancel') - ) - if msg.cancel: - msg = 'Cell cycle analysis aborted.' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - self.modeComboBox.setCurrentText(defaultMode) - proceed = False + + if ID not in past_cca_df.index: + # ID is a bud and is not emerged yet here return - self.addMissingIDs_cca_df(posData) - if msg.clickedButton == yesButton: - self.addMissingIDs_cca_df(posData) - msg = 'Looking good!' - self.titleLabel.setText(msg, color=self.titleColor) - self.last_cca_frame_i = last_cca_frame_i - posData.frame_i = last_cca_frame_i - self.get_data() - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - self.updateAllImages() - self.updateScrollbars() - else: - self.get_data() - self.addMissingIDs_cca_df(posData) - self.store_cca_df() + if frame_i - 1 == past_frame_i: + # Get generation number at first iteration + gen_num = past_cca_df.at[ID, "generation_num"] - self.last_cca_frame_i = last_cca_frame_i + if past_cca_df.at[ID, "generation_num"] != gen_num: + # ID is a mother and the cell cycle is finished here + return - self.navigateScrollBar.setMaximum(last_cca_frame_i+1) - self.navSpinBox.setMaximum(last_cca_frame_i+1) - self.lastTrackedFrameLabel.setText( - f'Last cc annot. frame n. = {last_cca_frame_i+1}' - ) + past_cca_df.at[ID, "will_divide"] = 1 + past_cca_df.at[relID, "will_divide"] = 1 - if posData.cca_df is None: - posData.cca_df = self.getBaseCca_df() - self.store_cca_df() - msg = 'Cell cycle analysis initialized!' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - else: - self.get_cca_df() + self.store_cca_df(cca_df=past_cca_df, frame_i=past_frame_i, autosave=False) - self.enqCcaIntegrityChecker() + def annotated_edit_warning_plan( + self, + *, + is_snapshot: bool, + acdc_df_missing: bool, + lineage_tree_missing: bool, + cell_cycle_stage_present: bool, + lineage_tree_present: bool, + remembered_skip_warning: bool, + ) -> AnnotatedEditWarningPlan: + if is_snapshot: + return AnnotatedEditWarningPlan(proceed_without_warning=True) - return proceed + no_annotation_source = acdc_df_missing and lineage_tree_missing + no_annotations = not cell_cycle_stage_present and not lineage_tree_present + if no_annotation_source or no_annotations or remembered_skip_warning: + return AnnotatedEditWarningPlan( + proceed_without_warning=True, + update_images=True, + ) - def _getCcaCostMatrix( - self, numCellsG1, numNewCells, IDsCellsG1, newIDs_contours - ): + warn_type = ( + "cell cycle annotations" + if cell_cycle_stage_present + else "lineage tree annotations" + ) + return AnnotatedEditWarningPlan( + proceed_without_warning=False, + should_prompt=True, + warn_type=warn_type, + ) + + @exception_handler + def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i): + self.store_data(autosave=False) posData = self.data[self.pos_i] - dataDict = posData.allData_li[posData.frame_i] - dist_matrix_df = dataDict.get('obj_to_obj_dist_cost_matrix_df') - if dist_matrix_df is None: - mother_contours = {} - for obj in posData.rp: - ID = obj.label - if ID not in IDsCellsG1: + undoId = uuid.uuid4() + for i in range(posData.frame_i, stop_frame_i): + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + self.storeUndoRedoCca(i, cca_df_i, undoId) + + for ID, changes_ID in changes.items(): + if ID not in cca_df_i.index: continue - mother_contours[ID] = self.getObjContours(obj) - - bud_contours = dict(zip(posData.new_IDs, newIDs_contours)) - return self.cca_workflows.auto_cost_matrix_from_contours( - IDsCellsG1, - posData.new_IDs, - mother_contours, - bud_contours, + for col, (oldValue, newValue) in changes_ID.items(): + cca_df_i.at[ID, col] = newValue + self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) + self.get_data() + self.updateAllImages() + + @exception_handler + def attempt_auto_cca(self, enforceAll=False): + mode = str(self.modeComboBox.currentText()) + posData = self.data[self.pos_i] + + if mode == "Cell cycle analysis": + notEnoughG1Cells, proceed = self.autoCca_df(enforceAll=enforceAll) + if not proceed: + return notEnoughG1Cells, proceed + + # mode = str(self.modeComboBox.currentText()) + if posData.cca_df is None: # ??? + notEnoughG1Cells = False + proceed = True + return notEnoughG1Cells, proceed + if posData.cca_df.isna().any(axis=None): + raise ValueError("Cell cycle analysis table contains NaNs") + # self.checkMultiBudMoth() + proceed = self.checkMothersExcludedOrDead() + return notEnoughG1Cells, proceed + + elif mode == "Normal division: Lineage tree": + self.autoLinTree_df() + notEnoughG1Cells = False + proceed = True + return notEnoughG1Cells, proceed + + else: + notEnoughG1Cells = False + proceed = True + return notEnoughG1Cells, proceed + + def autoAssignBud_YeastMate(self): + if not self.is_win: + txt = ( + "YeastMate is available only on Windows OS." + "We are working on expading support also on macOS and Linux.\n\n" + "Thank you for your patience!" ) + msg = QMessageBox() + msg.critical(self, "Supported only on Windows", txt, msg.Ok) + return + + model_name = "YeastMate" + idx = self.modelNames.index(model_name) - return self.cca_workflows.auto_cost_matrix_from_distances( - dist_matrix_df, - IDsCellsG1, - posData.new_IDs, + self.titleLabel.setText( + f"{model_name} is thinking... (check progress in terminal/console)", + color=self.titleColor, ) + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + posData = self.data[self.pos_i] + # Check if model needs to be imported + acdcSegment = self.acdcSegment_li[idx] + if acdcSegment is None: + acdcSegment = myutils.import_segment_module(model_name) + self.acdcSegment_li[idx] = acdcSegment + + # Read all models parameters + init_params, segment_params = myutils.getModelArgSpec(acdcSegment) + # Prompt user to enter the model parameters + try: + url = acdcSegment.url_help() + except AttributeError: + url = None + + _SizeZ = None + if self.isSegm3D: + _SizeZ = posData.SizeZ + win = apps.QDialogModelParams( + init_params, + segment_params, + model_name, + url=url, + posData=posData, + df_metadata=posData.metadata_df, + ) + win.exec_() + if win.cancel: + self.titleLabel.setText("Segmentation aborted.") + return + + use_gpu = win.init_kwargs.get("gpu", False) + proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) + if not proceed: + self.logger.info("Segmentation process cancelled.") + self.titleLabel.setText("Segmentation process cancelled.") + return + + self.model_kwargs = win.model_kwargs + model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) + if model is None: + self.logger.info("Segmentation process cancelled.") + self.titleLabel.setText("Segmentation process cancelled.") + return + try: + model.setupLogger(self.logger) + except Exception: + pass + + self.models[idx] = model + + img = self.getDisplayedImg1() + + posData.cca_df = model.predictCcaState(img, posData.lab) + self.store_data() + self.updateAllImages() + + self.titleLabel.setText("Budding event prediction done.", color="g") + def autoCca_df(self, enforceAll=False): """ Assign each bud to a mother with scipy linear sum assignment @@ -575,17 +591,16 @@ def autoCca_df(self, enforceAll=False): """ proceed = True notEnoughG1Cells = False - ScellsGone = False posData = self.data[self.pos_i] # Skip cca if not the right mode mode = str(self.modeComboBox.currentText()) - if mode.find('Cell cycle') == -1: + if mode.find("Cell cycle") == -1: return notEnoughG1Cells, proceed # Make sure that this is a visited frame in segmentation tracking mode - if posData.allData_li[posData.frame_i]['labels'] is None: + if posData.allData_li[posData.frame_i]["labels"] is None: proceed = self.warnFrameNeverVisitedSegmMode() return notEnoughG1Cells, proceed @@ -594,15 +609,11 @@ def autoCca_df(self, enforceAll=False): # The idea is that the user could have assigned division on a cell # by going previous and we want to check if this cell could be a # "better" mother for those non manually corrected buds - curr_df = posData.allData_li[posData.frame_i]['acdc_df'] - isLastVisitedAgain = self.isLastVisitedAgainCca( - curr_df, enforceAll=enforceAll - ) + curr_df = posData.allData_li[posData.frame_i]["acdc_df"] + isLastVisitedAgain = self.isLastVisitedAgainCca(curr_df, enforceAll=enforceAll) frameAlreadyAnnotated = ( - posData.cca_df is not None - and not enforceAll - and not isLastVisitedAgain + posData.cca_df is not None and not enforceAll and not isLastVisitedAgain ) # Use stored cca_df and do not modify it with automatic stuff if frameAlreadyAnnotated: @@ -611,11 +622,15 @@ def autoCca_df(self, enforceAll=False): # Keep only correctedAssignIDs if requested # For the last visited frame we perform assignment again only on # IDs where we didn't manually correct assignment + correctedAssignIDs = set() if isLastVisitedAgain and not enforceAll: - posData.new_IDs = self.cca_workflows.uncorrected_new_ids_for_auto( - posData.new_IDs, - curr_df, - ) + try: + correctedAssignIDs = curr_df[curr_df["corrected_on_frame_i"] > 0].index + except Exception: + correctedAssignIDs = [] + posData.new_IDs = [ + ID for ID in posData.new_IDs if ID not in correctedAssignIDs + ] # Check if new IDs exist some time in the past found_cca_df_IDs = self.checkCcaPastFramesNewIDs() @@ -628,17 +643,19 @@ def autoCca_df(self, enforceAll=False): return notEnoughG1Cells, proceed # Get previous dataframe - acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] + acdc_df = posData.allData_li[posData.frame_i - 1]["acdc_df"] prev_cca_df = acdc_df[self.cca_df_colnames].copy() - init_result = self.cca_workflows.prepare_auto_current_frame( - prev_cca_df, - curr_df, - self.cca_df_colnames, - current_cca_df=posData.cca_df, - found_cca_dfs=found_cca_df_IDs or (), - ) - posData.cca_df = init_result.cca_df + if posData.cca_df is None: + posData.cca_df = prev_cca_df.copy() + else: + posData.cca_df = curr_df[self.cca_df_colnames].copy() + + # concatenate new IDs found in past frames (before frame_i-1) + if found_cca_df_IDs is not None: + cca_df = pd.concat([posData.cca_df, *found_cca_df_IDs]) + unique_idx = ~cca_df.index.duplicated(keep="first") + posData.cca_df = cca_df[unique_idx] # If there are no new IDs we are done if not posData.new_IDs: @@ -647,21 +664,40 @@ def autoCca_df(self, enforceAll=False): return notEnoughG1Cells, proceed # Get cells in G1 (exclude dead) and check if there are enough cells in G1 - IDsCellsG1 = self.cca_workflows.auto_candidate_mother_ids( - prev_cca_df, - acdc_df, - posData.IDs, - current_cca_df=posData.cca_df, - include_current_g1=isLastVisitedAgain or enforceAll, - current_frame_i=posData.frame_i, - ) + try: + prev_df_G1 = prev_cca_df[prev_cca_df["cell_cycle_stage"] == "G1"] + prev_df_G1 = prev_df_G1[~acdc_df.loc[prev_df_G1.index]["is_cell_dead"]] + IDsCellsG1 = set(prev_df_G1.index) + except Exception: + IDsCellsG1 = set() + + if isLastVisitedAgain or enforceAll: + # If we are repeating auto cca for last visited frame + # then we also add the cells in G1 that appears in current frame + # and we remove the ones that are already in S in current frame + # if they were manually corrected (i.e., they cannot be mother). + # Note that potential mother cells must be either appearing in + # current frame or in G1 also at previous frame. + # If we would consider cells that are in G1 at current frame + # but not in previous frame, assigning a bud to it would + # result in no G1 at all for the mother cell. + df_G1 = posData.cca_df[posData.cca_df["cell_cycle_stage"] == "G1"] + current_G1_IDs = df_G1.index + new_cell_G1 = [ID for ID in current_G1_IDs if ID not in prev_cca_df.index] + IDsCellsG1.update(new_cell_G1) + cells_S_current = posData.cca_df[ + (posData.cca_df["cell_cycle_stage"] == "S") + & (posData.cca_df["corrected_on_frame_i"] == posData.frame_i) + ].index + IDsCellsG1 = IDsCellsG1 - set(cells_S_current) + + # Remove cells that disappeared + IDsCellsG1 = [ID for ID in IDsCellsG1 if ID in posData.IDs] numCellsG1 = len(IDsCellsG1) numNewCells = len(posData.new_IDs) if numCellsG1 < numNewCells: - notEnoughG1Cells, proceed = self.handleNoCellsInG1( - numCellsG1, numNewCells - ) + notEnoughG1Cells, proceed = self.handleNoCellsInG1(numCellsG1, numNewCells) return notEnoughG1Cells, proceed # Compute new IDs contours @@ -677,175 +713,424 @@ def autoCca_df(self, enforceAll=False): numCellsG1, numNewCells, IDsCellsG1, newIDs_contours ) + # Run hungarian (munkres) assignment algorithm + row_idx, col_idx = scipy.optimize.linear_sum_assignment(cost) + + # New mother cells + newMothIDs = {IDsCellsG1[i] for i in row_idx} + # Assign buds to mothers - assignments = self.cca_workflows.auto_assignments_from_cost( - cost, - IDsCellsG1, - posData.new_IDs, - ) - posData.cca_df = self.cca_workflows.apply_auto_assignments( - posData.cca_df, - assignments, - posData.frame_i, - self.cca_workflows.base_status(base_cca_dict), - previous_cca_df=prev_cca_df, - current_ids=posData.IDs, - ) + for i, j in zip(row_idx, col_idx): + mothID = IDsCellsG1[i] + budID = posData.new_IDs[j] + + relID = None + # If we are repeating assignment for the bud then we also have to + # correct the possibily wrong mother --> it goes back to + # G1 if it's not a mother that we assign now + if budID in posData.cca_df.index: + relID = posData.cca_df.at[budID, "relative_ID"] + if relID in prev_cca_df.index and relID not in newMothIDs: + posData.cca_df.loc[relID] = prev_cca_df.loc[relID] + + posData.cca_df.at[mothID, "relative_ID"] = budID + posData.cca_df.at[mothID, "cell_cycle_stage"] = "S" + + bud_cca_dict = base_cca_dict.copy() + bud_cca_dict["cell_cycle_stage"] = "S" + bud_cca_dict["generation_num"] = 0 + bud_cca_dict["relative_ID"] = mothID + bud_cca_dict["relationship"] = "bud" + bud_cca_dict["emerg_frame_i"] = posData.frame_i + bud_cca_dict["is_history_known"] = True + bud_cca_dict["corrected_on_frame_i"] = -1 + posData.cca_df.loc[budID] = pd.Series(bud_cca_dict) + + # Keep only existing IDs + posData.cca_df = posData.cca_df.loc[posData.IDs] self.store_cca_df() proceed = True return notEnoughG1Cells, proceed - def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): - self.logger.info( - 'Initialising cell cycle annotations of missing past frames...' + def blinkPairingItem(self): + if self.blinkPairingItemTimer.flag: + opacity = 0.3 + self.blinkPairingItemTimer.flag = False + else: + opacity = 1.0 + self.blinkPairingItemTimer.flag = True + self.warnPairingItem.setOpacity(opacity) + + def ccaCheckerStopChecking(self): + if not self.ccaCheckerRunning: + return + + self.ccaIntegrityCheckerWorker.clearQueue() + + if self.ccaIntegrityCheckerWorker.isChecking: + self.ccaIntegrityCheckerWorker.abortChecking = True + + def ccaCheckerWorkerClosed(self, worker): + self.logger.info("Cell cycle annotations integrity checker stopped.") + self.ccaCheckerRunning = False + + def ccaCheckerWorkerDone(self): + self.setStatusBarLabel(log=False) + + def ccaIntegrCheckerToggled(self, checked): + self.df_settings.at["is_cca_integrity_checker_activated", "value"] = int( + checked ) + self.df_settings.to_csv(self.settings_csv_path) + mode = self.modeComboBox.currentText() + if mode != "Cell cycle analysis": + return + + if checked: + self.startCcaIntegrityCheckerWorker() + else: + self.disableCcaIntegrityChecker() + + def checkCcaPastFramesNewIDs(self): posData = self.data[self.pos_i] - current_frame_i = posData.frame_i + if not posData.new_IDs: + return - prep_result = self.cca_workflows.prepare_missing_frame_annotations( - posData.allData_li, - self.cca_df_colnames, - last_cca_frame_i, + found_cca_df_IDs = [] + for frame_i in range(posData.frame_i - 2, -1, -1): + acdc_df = posData.allData_li[frame_i]["acdc_df"] + cca_df_i = acdc_df[self.cca_df_colnames] + intersect_idx = cca_df_i.index.intersection(posData.new_IDs) + cca_df_i = cca_df_i.loc[intersect_idx] + if cca_df_i.empty: + continue + found_cca_df_IDs.append(cca_df_i) + + # Remove IDs found in past frames from new_IDs list + newIDs = np.array(posData.new_IDs, dtype=np.uint32) + mask_index = np.in1d(newIDs, cca_df_i.index) + posData.new_IDs = list(newIDs[~mask_index]) + if not posData.new_IDs: + return found_cca_df_IDs + return found_cca_df_IDs + + def checkChangeMotherBudEligible(self, budID, frame_i): + result = self._checkBudFutureNoDivision(budID, frame_i) + if result is None: + return True + + self.warnBudAnnotatedDividedInFuture( + budID, *result, action="change mother cell" ) - for frame_i, acdc_df in prep_result.acdc_dfs_by_frame.items(): - posData.allData_li[frame_i]['acdc_df'] = acdc_df + return False - last_annotated_cca_df = prep_result.last_annotated_cca_df - cca_df_colnames = self.cca_df_colnames - pbar = tqdm(total=current_frame_i-last_cca_frame_i+1, ncols=100) - for frame_i in range(last_cca_frame_i, current_frame_i+1): - posData.frame_i = frame_i - self.get_data() - cca_df = self.getBaseCca_df() - cca_df = self.cca_workflows.overlay_last_annotated( - cca_df, - last_annotated_cca_df, - cca_df_colnames, + def checkDivisionCanBeUndone(self, ID, relID): + """Check that division annotation can be undone (see Notes section) + + Parameters + ---------- + ID : int + Cell ID of the clicked cell in G1 + relID : _type_ + Relative ID of the cell that was clicked + + Notes + ----- + Division annotation can be undone only if `relID` is also in G1 for the + entire duration of the correction + """ + posData = self.data[self.pos_i] + + ccs_relID = posData.cca_df.at[relID, "cell_cycle_stage"] + if ccs_relID == "S": + return posData.frame_i + + # Check future frames + for future_i in range(posData.frame_i + 1, posData.SizeT): + cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + ccs_relID = cca_df_i.at[relID, "cell_cycle_stage"] + if ccs_relID == "S": + return future_i + + # Check past frames + for past_i in range(posData.frame_i - 1, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + if ID not in cca_df_i.index or relID not in cca_df_i.index: + # Bud did not exist at frame_i = i + break + + ccs = cca_df_i.at[ID, "cell_cycle_stage"] + if ccs == "S": + break + + ccs_relID = cca_df_i.at[relID, "cell_cycle_stage"] + if ccs_relID == "S": + return future_i + + def checkMothEligibility(self, budID, new_mothID): + """ + Check that the new mother is in G1 for the entire life of the bud + and that the G1 duration is > than 1 frame + """ + last_cca_frame_i = self.navigateScrollBar.maximum() - 1 + posData = self.data[self.pos_i] + eligible = True + + # Check future frames + G1_duration_future = 0 + for future_i in range(posData.frame_i, posData.SizeT): + cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) + + if cca_df_i is None: + # ith frame was not visited yet + break + + if budID not in cca_df_i.index: + # Bud disappeared + break + + is_still_bud = cca_df_i.at[budID, "relationship"] == "bud" + if not is_still_bud: + break + + ccs = cca_df_i.at[new_mothID, "cell_cycle_stage"] + if ccs != "G1": + cancel, apply = self.warnMotherNotEligible( + new_mothID, budID, future_i, "not_G1_in_the_future" + ) + if apply: + self.resetCcaFuture(future_i) + break + isG1singleFrame = G1_duration_future == 1 + isFutureFrameNotLastAnnot = future_i != last_cca_frame_i + if cancel or (isG1singleFrame and isFutureFrameNotLastAnnot): + eligible = False + return eligible + + G1_duration_future += 1 + + # Check past frames + for past_i in range(posData.frame_i - 1, -1, -1): + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + + is_bud_existing = budID in cca_df_i.index + is_moth_existing = new_mothID in cca_df_i.index + + if not is_moth_existing: + # Mother not existing because it appeared from outside FOV + break + + ccs = cca_df_i.at[new_mothID, "cell_cycle_stage"] + if ccs != "G1" and is_bud_existing: + # Requested mother not in G1 in the past + # during the life of the bud (is_bud_existing = True) + self.warnMotherNotEligible( + new_mothID, budID, past_i, "not_G1_in_the_past" + ) + eligible = False + return eligible + + if not is_bud_existing: + # Bud stop existing --> check that mother is still in G1 + if ccs != "G1": + eligible = False + self.warnMotherNotEligible( + new_mothID, budID, past_i, "single_frame_G1_duration" + ) + break + + return eligible + + def checkMothersExcludedOrDead(self): + try: + posData = self.data[self.pos_i] + buds_df = posData.cca_df[ + (posData.cca_df.relationship == "bud") + & (posData.cca_df.emerg_frame_i == posData.frame_i) + ] + acdc_df_i = posData.allData_li[posData.frame_i]["acdc_df"] + moth_df = acdc_df_i.loc[buds_df.relative_ID.to_list()] + excluded_df = moth_df[ + (moth_df.is_cell_dead > 0) | (moth_df.is_cell_excluded > 0) + ] + excludedMothIDs = excluded_df.index.to_list() + if not excludedMothIDs: + self.stopBlinkingPairItem() + return True + budIDsOfExcludedMoth = excluded_df.relative_ID.to_list() + proceed = self.warnDeadOrExcludedMothers( + budIDsOfExcludedMoth, excludedMothIDs ) + return proceed + except Exception: + self.logger.info(traceback.format_exc()) + print("-" * 100) + self.logger.warning("Checking if mother cell is excluded or dead failed.") + print("^" * 100) + return False - self.store_cca_df(cca_df=cca_df, frame_i=frame_i, autosave=False) - pbar.update() - pbar.close() + def checkScellsGone(self): + """Check if there are cells in S phase whose relative disappear in + current frame. Allow user to choose between automatically assign + division to these cells or cancel and not visit the frame. - posData.frame_i = current_frame_i - self.get_data() + Returns + ------- + bool + False if there are no cells disappeared or the user decided + to accept automatic division. + """ + automaticallyDividedIDs = [] + + mode = str(self.modeComboBox.currentText()) + if mode.find("Cell cycle") == -1: + # No cell cycle analysis mode --> do nothing + return False, automaticallyDividedIDs + + posData = self.data[self.pos_i] + + if posData.allData_li[posData.frame_i]["labels"] is None: + # Frame never visited/checked in segm mode --> autoCca_df will raise + # a critical message + return False, automaticallyDividedIDs + + # Check if there are S cells that either only mother or only + # bud disappeared and automatically assign division to it + # or abort visiting this frame + prev_acdc_df = posData.allData_li[posData.frame_i - 1]["acdc_df"] + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + prev_cca_df = prev_acdc_df[self.cca_df_colnames].copy() + + ScellsIDsGone = [] + for ccSeries in prev_cca_df.itertuples(): + ID = ccSeries.Index + ccs = ccSeries.cell_cycle_stage + if ccs != "S": + continue + + relID = ccSeries.relative_ID + if relID == -1: + continue + + # Check is relID is gone while ID stays + if relID not in posData.IDs and ID in posData.IDs: + ScellsIDsGone.append(relID) + + if not ScellsIDsGone: + # No cells in S that disappears --> do nothing + return False, automaticallyDividedIDs + + self.highlightNewIDs_ccaFailed(ScellsIDsGone, rp=prev_rp) + proceed = self.warnScellsGone(ScellsIDsGone, posData.frame_i) + self.clearLostObjContoursItems() + + if not proceed: + return True, automaticallyDividedIDs + + for IDgone in ScellsIDsGone: + relID = prev_cca_df.at[IDgone, "relative_ID"] + self.annotateDisappearedBeforeDivision(relID, IDgone, prev_cca_df) + self.annotateDivision( + prev_cca_df, IDgone, relID, frame_i=posData.frame_i - 1 + ) + self.annotateDivisionCurrentFrameRelativeIDgone(relID) + automaticallyDividedIDs.append(relID) + + self.store_cca_df(frame_i=posData.frame_i - 1, cca_df=prev_cca_df) + + return False, automaticallyDividedIDs - def addMissingIDs_cca_df(self, posData): - base_cca_df = self.getBaseCca_df() - result = self.cca_edits.add_missing_ids( - posData.cca_df, - base_cca_df, - ) - posData.cca_df = result.cca_df + def checkSwapMothersEligibility(self): + posData = self.data[self.pos_i] - def update_cca_df_relabelling(self, posData, oldIDs, newIDs): - result = self.cca_edits.relabel_ids( - posData.cca_df, - oldIDs, - newIDs, - ) - posData.cca_df = result.cca_df + lab2D = self.get_2Dlab(posData.lab) + budID = lab2D[self.yClickBud, self.xClickBud] + otherMothID = lab2D[self.yClickMoth, self.xClickMoth] + mothID = posData.cca_df.at[budID, "relative_ID"] + otherBudID = posData.cca_df.at[otherMothID, "relative_ID"] - def update_cca_df_deletedIDs( - self, posData, deletedIDs, dropInPast=True, dropInFuture=True - ): - if posData.cca_df is None: + for _budID in (budID, otherBudID): + result = self._checkBudFutureNoDivision(_budID, posData.frame_i) + if result is None: + continue + + self.warnBudAnnotatedDividedInFuture(_budID, *result) return - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + correct_pairings = {otherBudID: mothID, budID: otherMothID} + wrong_pairings = {mothID: budID, otherMothID: otherBudID} + for correctBudID, correctMothID in correct_pairings.items(): + wrongBudID = wrong_pairings[correctMothID] + frame_no_G1 = self._checkMothInG1beforeBudEmergence( + correctMothID, correctBudID, wrongBudID, posData.frame_i + ) + if frame_no_G1 is None: + continue - try: - deletion_result = self.cca_edits.delete_ids( - posData.cca_df, - deletedIDs, + self.warnMotherNotAtLeastOneFrameG1( + correctBudID, correctMothID, frame_no_G1 ) - except KeyError: return - posData.cca_df = deletion_result.cca_df - relIDs = deletion_result.relative_ids - if self.isSnapshot: - self.update_cca_df_newIDs(posData, relIDs) - else: - self.updateCcaDfDeletedIDsTimelapse( - posData, relIDs, deletedIDs, undoId, dropInPast, dropInFuture - ) + return budID, otherBudID, otherMothID, mothID - @disableWindow - def updateCcaDfDeletedIDsTimelapse( - self, posData, relIDsOfDelIDs, deletedIDs, undoId, - dropInPast, dropInFuture - ): - future_cca_frames = ( - (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) - for frame_i in range(posData.frame_i + 1, posData.SizeT) - ) - past_cca_frames = ( - (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) - for frame_i in range(posData.frame_i - 1, -1, -1) - ) - existing_ids_by_frame = None - if not dropInPast or not dropInFuture: - existing_ids_by_frame = {} - for frame_i in range(posData.SizeT): - dataDict = posData.allData_li[frame_i] - existingIDs = dataDict.get('IDs_idxs', {}) - if hasattr(existingIDs, 'items'): - existing_ids_by_frame[frame_i] = { - ID for ID, exists in existingIDs.items() if exists - } - else: - existing_ids_by_frame[frame_i] = set(existingIDs) - - propagation_result = self.cca_workflows.propagate_deleted_ids( - None, - posData.frame_i, - deletedIDs, - relIDsOfDelIDs, - current_cca_df=posData.cca_df, - future_cca_frames=future_cca_frames, - past_cca_frames=past_cca_frames, - drop_in_past=dropInPast, - drop_in_future=dropInFuture, - existing_ids_by_frame=existing_ids_by_frame, - base_values=base_cca_dict, + def check_mothers_exclusion_or_dead( + self, + acdc_df: pd.DataFrame, + mother_ids: list[int], + ) -> list[int]: + """Checks tracking rules for cell exclusions or deaths.""" + if acdc_df is None or not mother_ids: + return [] + + valid_ids = [m_id for m_id in mother_ids if m_id in acdc_df.index] + if not valid_ids: + return [] + + mothers_df = acdc_df.loc[valid_ids] + excluded_mask = (mothers_df.get("is_cell_dead", 0) > 0) | ( + mothers_df.get("is_cell_excluded", 0) > 0 ) - for frame_i in propagation_result.undo_frame_indices: - cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) - self.storeUndoRedoCca(frame_i, cca_df_i, undoId) + return mothers_df[excluded_mask].index.tolist() - updated_cca_dfs = propagation_result.updated_cca_dfs_by_frame - if posData.frame_i in updated_cca_dfs: - posData.cca_df = propagation_result.current_cca_df - self.store_data(autosave=False) + def disableCcaIntegrityChecker(self): + self.stopCcaIntegrityCheckerWorker() - for frame_i, cca_df_i in updated_cca_dfs.items(): - if frame_i == posData.frame_i: - continue - self.store_cca_df( - frame_i=frame_i, cca_df=cca_df_i, autosave=False - ) + def enqCcaIntegrityChecker(self): + if not self.ccaCheckerRunning: + return + posData = self.data[self.pos_i] + self.ccaIntegrityCheckerWorker.enqueue(posData) - def update_cca_df_newIDs(self, posData, new_IDs): - for newID in new_IDs: - self.addIDBaseCca_df(posData, newID) + def evaluate_sister_relations( + self, + prev_cca_df: pd.DataFrame, + current_ids: set[int], + ) -> list[int]: + """Determines S-phase mother-bud dependencies and sister relation tracking rules.""" + if prev_cca_df is None or not current_ids: + return [] - def update_cca_df_snapshots(self, editTxt, posData): - result = self.cca_edits.apply_snapshot_id_edits( - posData.cca_df, - editTxt, - posData.IDs, - self.getBaseCca_df(), - base_values=base_cca_dict, - ) + current_ids_set = set(current_ids) + disappeared_ids = [] + for cc_series in prev_cca_df.itertuples(): + if getattr(cc_series, "cell_cycle_stage", None) != "S": + continue - if result.changes.deleted_ids: - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - posData.cca_df = result.cca_df + cell_id = cc_series.Index + relative_id = getattr(cc_series, "relative_ID", -1) + if relative_id == -1: + continue + if relative_id not in current_ids_set and cell_id in current_ids_set: + disappeared_ids.append(relative_id) + + return disappeared_ids def fixCcaDfAfterEdit(self, editTxt): posData = self.data[self.pos_i] @@ -854,82 +1139,102 @@ def fixCcaDfAfterEdit(self, editTxt): self.update_cca_df_snapshots(editTxt, posData) self.store_data() - def setCcaIssueContour(self, obj): - objContours = self.getObjContours(obj, all_external=True) - for cont in objContours: - xx = cont[:,0] + 0.5 - yy = cont[:,1] + 0.5 - self.ax1_lostObjScatterItem.addPoints(xx, yy) - self.textAnnot[0].addObjAnnotation( - obj, 'lost_object', f'{obj.label}?', False + def fixWillDivide(self, warning_txt, IDs_will_divide_wrong): + self.logger.info(warning_txt) + self.logger.info("Fixing `will_divide` information...") + + global_cca_df = self.getConcatCcaDf() + global_cca_df = global_cca_df.reset_index().set_index( + ["Cell_ID", "generation_num"] ) + global_cca_df.loc[IDs_will_divide_wrong, "will_divide"] = 0 + global_cca_df = global_cca_df.reset_index().set_index(["frame_i", "Cell_ID"]) + self.storeFromConcatCcaDf(global_cca_df) - def isLastVisitedAgainCca(self, curr_df, enforceAll=False): - # Determine if this is the last visited frame for repeating - # bud assignment on non manually corrected_on_frame_i buds. - # The idea is that the user could have assigned division on a cell - # by going previous and we want to check if this cell could be a - # "better" mother for those non manually corrected buds + def getBaseCca_df(self, with_tree_cols=False): posData = self.data[self.pos_i] - next_df = None - if posData.frame_i+1 < posData.SizeT: - next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] - result = self.cca_workflows.auto_repeat_frame_state( - curr_df, - next_df, - posData.new_IDs, - enforce_all=enforceAll, - ) - posData.new_IDs = result.new_ids - return result.is_last_visited_again + IDs = [obj.label for obj in posData.rp] + cca_df = core.getBaseCca_df(IDs, with_tree_cols=with_tree_cols) + return cca_df - def highlightNewCellNotEnoughG1cells(self, IDsCellsG1): + def getConcatCcaDf(self): posData = self.data[self.pos_i] - for obj in posData.rp: - if obj.label not in IDsCellsG1: - continue - objContours = self.getObjContours(obj) - if objContours is not None: - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - self.ccaFailedScatterItem.addPoints(xx, yy) - self.textAnnot[0].addObjAnnotation( - obj, 'green', f'{obj.label}?', False - ) + cca_dfs = [] + keys = [] + for frame_i in range(0, posData.SizeT): + cca_df = self.get_cca_df(frame_i=frame_i, return_df=True) + if cca_df is None: + break - def highlightNewIDs_ccaFailed(self, IDsWithIssue, rp=None): - if rp is None: - posData = self.data[self.pos_i] - rp = posData.rp - for obj in rp: - if obj.label not in IDsWithIssue: - continue - self.setCcaIssueContour(obj) + cca_dfs.append(cca_df) + keys.append(frame_i) + + if not cca_dfs: + return + + global_cca_df = pd.concat(cca_dfs, keys=keys, names=["frame_i"]) + return global_cca_df + + def get_cca_df(self, frame_i=None, return_df=False, debug=False): + # cca_df is None unless the metadata contains cell cycle annotations + # NOTE: cell cycle annotations are either from the current session + # or loaded from HDD in "initPosAttr" with a .question to the user + posData = self.data[self.pos_i] + cca_df = None + i = posData.frame_i if frame_i is None else frame_i + df = posData.allData_li[i]["acdc_df"] + if df is not None: + if "cell_cycle_stage" in df.columns: + cca_df = df[self.cca_df_colnames].copy() + + if cca_df is None and self.isSnapshot: + cca_df = self.getBaseCca_df() + posData.cca_df = cca_df + + if cca_df is not None: + cca_df = cca_df.dropna() + + if return_df: + return cca_df + else: + posData.cca_df = cca_df + + def get_last_cca_frame_i(self): + posData = self.data[self.pos_i] + + i = 0 + # Determine last annotated frame index + for i, dict_frame_i in enumerate(posData.allData_li): + df = dict_frame_i["acdc_df"] + if df is None: + break + elif "cell_cycle_stage" not in df.columns: + break + + last_cca_frame_i = i if i == 0 or i + 1 == len(posData.allData_li) else i - 1 + + return last_cca_frame_i + + def goToFrameNumber(self, frame_n): + posData = self.data[self.pos_i] + posData.frame_i = frame_n - 1 + self.get_data() + self.updateAllImages() + self.updateScrollbars() def handleNoCellsInG1(self, numCellsG1, numNewCells): posData = self.data[self.pos_i] self.highlightNewCellNotEnoughG1cells(posData.new_IDs) continueAnyway = _warnings.warnNotEnoughG1Cells( - numCellsG1, posData.frame_i, numNewCells, qparent=self.host + numCellsG1, posData.frame_i, numNewCells, qparent=self ) if continueAnyway: notEnoughG1Cells = False proceed = True # Annotate the new IDs with unknown history for ID in posData.new_IDs: - posData.cca_df = self.cca_edits.add_base_annotation( - posData.cca_df, - ID, - base_values=base_cca_dict, - ) - cca_df_ID = self.cca_workflows.known_history_status_for_bud( - ID, - ( - (i, self.get_cca_df(frame_i=i, return_df=True)) - for i in range(posData.frame_i-1, -1, -1) - ), - self.cca_workflows.base_status(base_cca_dict), - ) + posData.cca_df.loc[ID] = pd.Series(base_cca_dict) + cca_df_ID = self.getStatusKnownHistoryBud(ID) posData.ccaStatus_whenEmerged[ID] = cca_df_ID else: notEnoughG1Cells = True @@ -939,183 +1244,167 @@ def handleNoCellsInG1(self, numCellsG1, numNewCells): self.ccaFailedScatterItem.setData([], []) return notEnoughG1Cells, proceed - def isFrameCcaAnnotated(self): - posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - return self.cca_edits.has_annotations(acdc_df) - - def warnEditingWithCca_df( - self, editTxt, return_answer=False, get_answer=False, - get_cancelled=False, update_images=True - ): - # Function used to warn that the user is editing in "Segmentation and - # Tracking" mode a frame that contains cca annotations. - # Ask whether to remove annotations from all future frames - if self.isSnapshot: - return True + def highlightIDs(self, IDs, pen): + pass + def highlightNewCellNotEnoughG1cells(self, IDsCellsG1): posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - - cell_cycle_stage_present = self.cca_edits.has_annotations( - acdc_df - ) - lineage_tree_present = ( - self.lineage.has_lineage_tree_annotations( - acdc_df, - self.lineage_tree, - ) - ) - - action = self.warnEditingWithAnnotActions.get(editTxt, None) - warning_plan = self.annotated_edit_warning_plan( - is_snapshot=self.isSnapshot, - acdc_df_missing=acdc_df is None, - lineage_tree_missing=self.lineage_tree is None, - cell_cycle_stage_present=cell_cycle_stage_present, - lineage_tree_present=lineage_tree_present, - remembered_skip_warning=( - action is not None and not action.isChecked() - ), - ) - if warning_plan.proceed_without_warning: - if update_images: - if warning_plan.update_images: - self.updateAllImages() - return True + for obj in posData.rp: + if obj.label not in IDsCellsG1: + continue + objContours = self.getObjContours(obj) + if objContours is not None: + xx = objContours[:, 0] + 0.5 + yy = objContours[:, 1] + 0.5 + self.ccaFailedScatterItem.addPoints(xx, yy) + self.textAnnot[0].addObjAnnotation(obj, "green", f"{obj.label}?", False) - msg = widgets.myMessageBox() - warn_type = warning_plan.warn_type - txt = html_utils.paragraph( - f'You modified a frame that has {warn_type}.

' - f'The change "{editTxt}" most likely makes the ' - 'annotations wrong.

' - 'If you really want to apply this change we reccommend to remove' - f'ALL {warn_type}
' - 'from current frame to the end.

' - 'What do you want to do?' - ) - if action is not None: - checkBox = QCheckBox('Remember my choice and do not ask again') - else: - checkBox = None + def highlightNewIDs_ccaFailed(self, IDsWithIssue, rp=None): + if rp is None: + posData = self.data[self.pos_i] + rp = posData.rp + for obj in rp: + if obj.label not in IDsWithIssue: + continue + self.setCcaIssueContour(obj) - dropDelIDsNoteText = ( - '' if editTxt.find('Delete') == -1 else ' (drop removed IDs)' - ) - _, removeAnnotButton, _ = msg.warning( - self.host, 'Edited segmentation with annotations!', txt, - buttonsTexts=( - 'Cancel', - 'Remove annotations from future frames (RECOMMENDED)', - f'Do not remove annotations{dropDelIDsNoteText}' - ), widgets=checkBox + @exception_handler + def initCca(self): + posData = self.data[self.pos_i] + last_tracked_i = self.get_last_tracked_i() + defaultMode = "Viewer" + if last_tracked_i == 0: + txt = html_utils.paragraph( + "On this dataset either you never checked that the segmentation " + "and tracking are correct or you did not save yet.

" + 'If you already visited some frames with "Segmentation and Tracking" ' + 'mode save data before switching to "Cell cycle analysis mode".

' + "Otherwise you first have to check (and eventually correct) some frames " + 'in "Segmentation and Tracking" mode before proceeding ' + "with cell cycle analysis." ) - if msg.cancel: - if get_cancelled: - return 'cancelled' - removeAnnotations = False - return removeAnnotations - - if action is not None: - action.setChecked(not checkBox.isChecked()) - action.removeAnnot = msg.clickedButton == removeAnnotButton - - if return_answer: - return msg.clickedButton == removeAnnotButton - - if (msg.clickedButton == removeAnnotButton) and cell_cycle_stage_present: - self.resetFutureCcaColCurrentFrame() - self.resetCcaFuture(posData.frame_i+1) - self.updateAllImages() - elif (msg.clickedButton == removeAnnotButton) and lineage_tree_present: - self.resetLin_tree_future() - self.updateAllImages() - else: - if dropDelIDsNoteText and posData.cca_df is not None: - delIDs = [ - ID for ID in posData.cca_df.index if ID not in posData.IDs - ] - self.update_cca_df_deletedIDs( - posData, delIDs, dropInPast=False - ) - self.addMissingIDs_cca_df(posData) - self.updateAllImages() - self.store_data() - # if action is not None: - # if action.removeAnnot: - # self.store_data() - # posData.frame_i -= 1 - # self.get_data() - # if lineage_tree_present: - # self.resetLin_tree_future() - # self.resetCcaFuture(posData.frame_i) - # self.next_frame() - - if get_answer: - return msg.clickedButton == removeAnnotButton - else: - return True - - def ccaIntegrCheckerToggled(self, checked): - self.df_settings.at['is_cca_integrity_checker_activated', 'value'] = ( - int(checked) - ) - self.df_settings.to_csv(self.settings_csv_path) - mode = self.modeComboBox.currentText() - if mode != 'Cell cycle analysis': + msg = widgets.myMessageBox() + msg.critical(self, "Tracking was never checked", txt) + self.modeComboBox.setCurrentText(defaultMode) return - if checked: - self.startCcaIntegrityCheckerWorker() - else: - self.disableCcaIntegrityChecker() + proceed = True - def startCcaIntegrityCheckerWorker(self): - if not hasattr(self, 'data'): - return + last_cca_frame_i = self.get_last_cca_frame_i() + if last_cca_frame_i == 0: + # Remove undoable actions from segmentation mode + posData.UndoRedoStates[0] = [] + self.undoAction.setEnabled(False) + self.redoAction.setEnabled(False) - if not self.isDataLoaded: - return + if posData.frame_i > last_cca_frame_i: + # Prompt user to go to last annotated frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + The last annotated frame is frame {last_cca_frame_i + 1}.

+ Do you want to restart cell cycle analysis from frame + {last_cca_frame_i + 1}?
+ """) + _, goToFrameButton, stayButton = msg.warning( + self, + "Go to last annotated frame?", + txt, + buttonsTexts=( + "Cancel", + f"Yes, go to frame {last_cca_frame_i + 1}", + "No, stay on current frame", + ), + ) + if goToFrameButton == msg.clickedButton: + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + msg = "Looking good!" + self.last_cca_frame_i = last_cca_frame_i + posData.frame_i = last_cca_frame_i + self.titleLabel.setText(msg, color=self.titleColor) + self.get_data() + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + self.updateAllImages() + self.updateScrollbars() + elif stayButton == msg.clickedButton: + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + self.initMissingFramesCca(last_cca_frame_i, posData.frame_i) + last_cca_frame_i = posData.frame_i + msg = "Cell cycle analysis initialised!" + self.titleLabel.setText(msg, color="g") + elif msg.cancel: + msg = "Cell cycle analysis aborted." + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + self.modeComboBox.setCurrentText(defaultMode) + proceed = False + return + elif posData.frame_i < last_cca_frame_i: + # Prompt user to go to last annotated frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + The last annotated frame is frame {last_cca_frame_i + 1}.

+ Do you want to restart cell cycle analysis from frame + {last_cca_frame_i + 1}?
+ """) + yesButton, noButton, _ = msg.question( + self, + "Go to last annotated frame?", + txt, + buttonsTexts=("Yes", "No", "Cancel"), + ) + if msg.cancel: + msg = "Cell cycle analysis aborted." + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + self.modeComboBox.setCurrentText(defaultMode) + proceed = False + return - if not self.ccaIntegrCheckerToggle.isChecked(): - return + self.addMissingIDs_cca_df(posData) + if msg.clickedButton == yesButton: + self.addMissingIDs_cca_df(posData) + msg = "Looking good!" + self.titleLabel.setText(msg, color=self.titleColor) + self.last_cca_frame_i = last_cca_frame_i + posData.frame_i = last_cca_frame_i + self.get_data() + self.addMissingIDs_cca_df(posData) + self.store_cca_df() + self.updateAllImages() + self.updateScrollbars() + else: + self.get_data() + self.addMissingIDs_cca_df(posData) + self.store_cca_df() - ccaCheckerThread = QThread() - self.ccaCheckerMutex = QMutex() - self.ccaCheckerWaitCond = QWaitCondition() + self.last_cca_frame_i = last_cca_frame_i - worker = workers.CcaIntegrityCheckerWorker( - self.ccaCheckerMutex, self.ccaCheckerWaitCond + self.navigateScrollBar.setMaximum(last_cca_frame_i + 1) + self.navSpinBox.setMaximum(last_cca_frame_i + 1) + self.lastTrackedFrameLabel.setText( + f"Last cc annot. frame n. = {last_cca_frame_i + 1}" ) - self.ccaIntegrityCheckerWorker = worker - self.ccaCheckerThread = ccaCheckerThread - - worker.moveToThread(ccaCheckerThread) - worker.finished.connect(ccaCheckerThread.quit) - worker.finished.connect(worker.deleteLater) - ccaCheckerThread.finished.connect(ccaCheckerThread.deleteLater) - - worker.sigDone.connect(self.ccaCheckerWorkerDone) - worker.progress.connect(self.workerProgress) - worker.critical.connect(self.ccaIntegrityWorkerCritical) - worker.finished.connect(self.ccaCheckerWorkerClosed) - worker.sigWarning.connect(self.warnCcaIntegrity) - worker.sigFixWillDivide.connect(self.fixWillDivide) - - ccaCheckerThread.started.connect(worker.run) - ccaCheckerThread.start() - self.ccaCheckerRunning = True + if posData.cca_df is None: + posData.cca_df = self.getBaseCca_df() + self.store_cca_df() + msg = "Cell cycle analysis initialized!" + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + else: + self.get_cca_df() - self.initCcaIntegrityChecker() + self.enqCcaIntegrityChecker() - self.logger.info('Cell cycle annotations integrity checker started.') + return proceed def initCcaIntegrityChecker(self): posData = self.data[self.pos_i] for frame_i, data_frame_i in enumerate(posData.allData_li): - lab = data_frame_i['labels'] + lab = data_frame_i["labels"] if lab is None: break @@ -1124,386 +1413,311 @@ def initCcaIntegrityChecker(self): self.enqCcaIntegrityChecker() - def disableCcaIntegrityChecker(self): - self.stopCcaIntegrityCheckerWorker() - - def stopCcaIntegrityCheckerWorker(self): - try: - self.ccaIntegrityCheckerWorker._stop() - except Exception as err: - pass + def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): + self.logger.info( + "Initialising cell cycle annotations of missing past frames..." + ) + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i - def isCcaCheckerChecking(self): - if not self.ccaCheckerRunning: - return False + annotated_cca_dfs = [] + for frame_i in range(last_cca_frame_i + 1): + acdc_df = posData.allData_li[frame_i]["acdc_df"] + if "cell_cycle_stage" in acdc_df.columns: + continue - return self.ccaIntegrityCheckerWorker.isChecking + acdc_df[self.cca_df_colnames] = "" - def getConcatCcaDf(self): - posData = self.data[self.pos_i] - return self.cca_edits.concat_annotations( - posData.allData_li, - self.cca_df_colnames, - size_t=posData.SizeT, + annotated_cca_dfs = [ + posData.allData_li[i]["acdc_df"][self.cca_df_colnames] + for i in range(last_cca_frame_i + 1) + ] + keys = range(last_cca_frame_i + 1) + names = ["frame_i", "Cell_ID"] + annotated_cca_df = ( + pd.concat(annotated_cca_dfs, keys=keys, names=names) + .reset_index() + .set_index(["Cell_ID", "frame_i"]) + .sort_index() ) - def storeFromConcatCcaDf(self, global_cca_df): - posData = self.data[self.pos_i] - for frame_i, cca_df in self.cca_edits.split_concat_annotations( - global_cca_df, - size_t=posData.SizeT, - ): - self.store_cca_df(frame_i=frame_i, cca_df=cca_df, autosave=False) + last_annotated_cca_df = annotated_cca_df.groupby(level=0).last() + cca_df_colnames = self.cca_df_colnames + pbar = tqdm(total=current_frame_i - last_cca_frame_i + 1, ncols=100) + for frame_i in range(last_cca_frame_i, current_frame_i + 1): + posData.frame_i = frame_i + self.get_data() + cca_df = self.getBaseCca_df() - self.get_cca_df() + idx = last_annotated_cca_df.index.intersection(cca_df.index) + cca_df.loc[idx, cca_df_colnames] = last_annotated_cca_df.loc[idx] - def resetWillDivideInfo(self): - global_cca_df = self.getConcatCcaDf() - if global_cca_df is None: - return + self.store_cca_df(cca_df=cca_df, frame_i=frame_i, autosave=False) + pbar.update() + pbar.close() - global_cca_df = self.cca_workflows.fix_will_divide_without_next_generation(global_cca_df) - self.storeFromConcatCcaDf(global_cca_df) + posData.frame_i = current_frame_i + self.get_data() - def ccaCheckerStopChecking(self): + def isCcaCheckerChecking(self): if not self.ccaCheckerRunning: - return - - self.ccaIntegrityCheckerWorker.clearQueue() + return False - if self.ccaIntegrityCheckerWorker.isChecking: - self.ccaIntegrityCheckerWorker.abortChecking = True + return self.ccaIntegrityCheckerWorker.isChecking - def enqCcaIntegrityChecker(self): - if not self.ccaCheckerRunning: - return + def isCurrentFrameCcaVisited(self): posData = self.data[self.pos_i] - self.ccaIntegrityCheckerWorker.enqueue(posData) + curr_df = posData.allData_li[posData.frame_i]["acdc_df"] + return curr_df is not None and "cell_cycle_stage" in curr_df.columns - def resetCcaFuture(self, from_frame_i): + def isFrameCcaAnnotated(self): posData = self.data[self.pos_i] - self.last_cca_frame_i = from_frame_i-1 - self.ccaCheckerStopChecking() - - self.setNavigateScrollBarMaximum() - removal_result = self.cca_edits.remove_future_annotations( - posData.allData_li, - self.cca_df_colnames, - from_frame_i, - size_t=posData.SizeT, - concatenated_acdc_df=posData.acdc_df, - ) - for i in removal_result.cache_frame_indices: - posData.allData_li[i].pop('cca_df', None) - posData.allData_li[i].pop('cca_df_checker', None) - for i, acdc_df in removal_result.acdc_dfs_by_frame.items(): - posData.allData_li[i]['acdc_df'] = acdc_df - posData.acdc_df = removal_result.concatenated_acdc_df + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] + if acdc_df is None: + return False - self.resetWillDivideInfo() + return "cell_cycle_stage" in acdc_df.columns - def removeCcaAnnotationsCurrentFrame(self): + def isLastVisitedAgainCca(self, curr_df, enforceAll=False): + # Determine if this is the last visited frame for repeating + # bud assignment on non manually corrected_on_frame_i buds. + # The idea is that the user could have assigned division on a cell + # by going previous and we want to check if this cell could be a + # "better" mother for those non manually corrected buds posData = self.data[self.pos_i] - posData.cca_df = None - - posData.allData_li[posData.frame_i].pop('cca_df', None) - posData.allData_li[posData.frame_i].pop('cca_df_checker', None) - - df = posData.allData_li[posData.frame_i]['acdc_df'] - result = self.cca_edits.remove_annotations( - df, self.cca_df_colnames - ) - if result.missing_frame or not result.removed: + if curr_df is None: return False - posData.allData_li[posData.frame_i]['acdc_df'] = result.acdc_df - return True - - def resetFutureCcaColCurrentFrame(self): - posData = self.data[self.pos_i] - posData.cca_df = self.cca_edits.reset_future_flags( - posData.cca_df - ) - self.store_data() + if "cell_cycle_stage" not in curr_df.columns: + return False - def get_cca_df(self, frame_i=None, return_df=False, debug=False): - # cca_df is None unless the metadata contains cell cycle annotations - # NOTE: cell cycle annotations are either from the current session - # or loaded from HDD in "initPosAttr" with a .question to the user - posData = self.data[self.pos_i] - i = posData.frame_i if frame_i is None else frame_i - df = posData.allData_li[i]['acdc_df'] - result = self.cca_edits.resolve_annotations( - df, - self.cca_df_colnames, - is_snapshot=self.isSnapshot, - snapshot_cell_ids=(obj.label for obj in posData.rp), - base_values=base_cca_dict, - tree_values=base_cca_tree_dict, - ) - cca_df = result.cca_df + if enforceAll: + return False - if return_df: - return cca_df + lastVisited = False + posData.new_IDs = [ + ID + for ID in posData.new_IDs + if curr_df.at[ID, "is_history_known"] + and curr_df.at[ID, "cell_cycle_stage"] == "S" + ] + if posData.frame_i + 1 < posData.SizeT: + next_df = posData.allData_li[posData.frame_i + 1]["acdc_df"] + if next_df is None: + lastVisited = True + else: + if "cell_cycle_stage" not in next_df.columns: + lastVisited = True else: - posData.cca_df = cca_df - - def unstore_cca_df(self): - posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - result = self.cca_edits.remove_annotations( - acdc_df, self.cca_df_colnames - ) - if result.acdc_df is not None: - posData.allData_li[posData.frame_i]['acdc_df'] = result.acdc_df - - def store_cca_df_checker(self, posData, frame_i, cca_df): - checker_cca_df = self.cca_edits.prepare_checker_annotations( - cca_df, - checker_running=self.ccaCheckerRunning, - ) - if checker_cca_df is None: - return - - posData.allData_li[frame_i]['cca_df_checker'] = checker_cca_df - - def store_cca_df( - self, pos_i=None, frame_i=None, cca_df=None, mainThread=True, - autosave=True, store_cca_df_copy=False - ): - pos_i = self.pos_i if pos_i is None else pos_i - posData = self.data[pos_i] - i = posData.frame_i if frame_i is None else frame_i - if cca_df is None: - cca_df = posData.cca_df - if self.ccaTableWin is not None and mainThread: - zoomIDs = self.exporting_view.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - acdc_df = posData.allData_li[i]['acdc_df'] - if acdc_df is None: - current_frame_i = None - if frame_i is not None and frame_i != posData.frame_i: - current_frame_i = posData.frame_i - posData.frame_i = frame_i - self.get_data() - self.store_data() - acdc_df = posData.allData_li[i]['acdc_df'] - if current_frame_i is not None: - # Back to current frame - posData.frame_i = current_frame_i - self.get_data(debug=False) - - store_result = self.cca_edits.store_frame_annotations( - acdc_df, - cca_df, - self.cca_df_colnames, - store_checker_copy=self.ccaCheckerRunning, - store_cca_df_copy=store_cca_df_copy, - ) - if store_result.acdc_df is not None: - posData.allData_li[i]['acdc_df'] = store_result.acdc_df + lastVisited = True - # Store copy for cca integrity worker - if store_result.checker_cca_df is not None: - posData.allData_li[i]['cca_df_checker'] = ( - store_result.checker_cca_df - ) + return lastVisited - if store_result.cached_cca_df is not None: - posData.allData_li[i]['cca_df'] = store_result.cached_cca_df + @exception_handler + def manualCellCycleAnnotation(self, ID): + """ + This function is used for both annotating division or undoing the + annotation. It can be called on any frame. - if autosave: - self.enqAutosave() - self.enqCcaIntegrityChecker() + If we annotate division (right click on a cell in S) then it will + check if there are future frames to correct. + Frames to correct are those frames where both the mother and the bud + are annotated as S phase cells. + In this case we assign all those frames to G1, relationship to mother, + and +1 generation number - def viewCcaTable(self): + If we undo the annotation (right click on a cell in G1) then it will + correct both past and future annotated frames (if present). + Frames to correct are those frames where both the mother and the bud + are annotated as G1 phase cells. + In this case we assign all those frames to G1, relationship back to + bud, and -1 generation number + """ posData = self.data[self.pos_i] - zoomIDs = self.exporting_view.getZoomIDs() - df = posData.allData_li[posData.frame_i]['acdc_df'] - current_cca_df = posData.cca_df - if zoomIDs is not None: - df = df.loc[zoomIDs] - current_cca_df = current_cca_df.loc[zoomIDs] + # Store cca_df for undo action + undoId = uuid.uuid4() + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - for column in current_cca_df.columns: - header = ( - '================================================\n' - f'CURRENT vs STORED `{column}` column' - f'for frame number {posData.frame_i+1}:\n' - ) - df_compare = current_cca_df[[column]].copy() - df_compare[f'STORED_{column}'] = df[column] - text = f'{header}{df_compare}' - self.logger.info(text) + # Correct current frame + clicked_ccs = posData.cca_df.at[ID, "cell_cycle_stage"] + relID = posData.cca_df.at[ID, "relative_ID"] - if self.cca_edits.has_annotations(df): - cca_df = df[self.cca_df_colnames] - cca_df = cca_df.merge( - current_cca_df, how='outer', left_index=True, right_index=True, - suffixes=('_STORED', '_CURRENT') - ) - cca_df = cca_df.reindex(sorted(cca_df.columns), axis=1) - num_cols = len(cca_df.columns) - for j in range(0,num_cols,2): - df_j_x = cca_df.iloc[:,j] - df_j_y = cca_df.iloc[:,j+1] - if any(df_j_x!=df_j_y): - self.logger.info('------------------------') - self.logger.info('DIFFERENCES:') - diff_df = cca_df.iloc[:,j:j+2] - diff_mask = diff_df.iloc[:,0]!=diff_df.iloc[:,1] - self.logger.info(diff_df[diff_mask]) - else: - cca_df = None - self.logger.info(cca_df) - self.logger.info('========================') - if current_cca_df is None: + if relID not in posData.IDs: return - if current_cca_df.empty: - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Cell cycle annotations\' table is empty.
' - ) - msg.warning(self.host, 'Table empty', txt) + + if clicked_ccs == "G1" and posData.frame_i == 0: + # We do not allow undoing division annotation on first frame return - df = posData.add_tree_cols_to_cca_df( - current_cca_df, frame_i=posData.frame_i - ) - if self.ccaTableWin is None: - self.ccaTableWin = apps.ViewCcaTableWindow(df, parent=self.host) - self.ccaTableWin.show() - self.ccaTableWin.setGeometryWindow() - self.ccaTableWin.sigUpdateCcaTable.connect( - self.exporting_view.onSigUpdateCcaTableWindow - ) + if clicked_ccs == "G1": + issue_frame_i = self.checkDivisionCanBeUndone(ID, relID) + if issue_frame_i is not None: + _warnings.warnDivisionAnnotationCannotBeUndone( + ID, relID, issue_frame_i, qparent=self + ) + return + + if clicked_ccs == "S": + self.annotateDivision(posData.cca_df, ID, relID) + self.store_cca_df() else: - self.ccaTableWin.setFocus() - self.ccaTableWin.activateWindow() - self.ccaTableWin.updateTable(current_cca_df) + self.undoDivisionAnnotation(posData.cca_df, ID, relID) + self.store_cca_df() - def autoAssignBud_YeastMate(self): - if not self.is_win: - txt = ( - 'YeastMate is available only on Windows OS.' - 'We are working on expading support also on macOS and Linux.\n\n' - 'Thank you for your patience!' - ) - msg = QMessageBox() - msg.critical( - self.host, 'Supported only on Windows', txt, msg.Ok - ) - return + # Update cell cycle info LabelItems + self.ax1_newMothBudLinesItem.setData([], []) + self.ax1_oldMothBudLinesItem.setData([], []) + self.ax2_newMothBudLinesItem.setData([], []) + self.ax2_oldMothBudLinesItem.setData([], []) + self.drawAllMothBudLines() + self.setAllTextAnnotations() - model_name = 'YeastMate' - idx = self.modelNames.index(model_name) + if self.ccaTableWin is not None: + zoomIDs = self.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - self.titleLabel.setText( - f'{model_name} is thinking... ' - '(check progress in terminal/console)', color=self.titleColor - ) + # Correct future frames + for future_i in range(posData.frame_i + 1, posData.SizeT): + cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) + self.storeUndoRedoCca(future_i, cca_df_i, undoId) + IDs = cca_df_i.index + if ID not in IDs: + # For some reason ID disappeared from this frame + continue - posData = self.data[self.pos_i] - # Check if model needs to be imported - acdcSegment = self.acdcSegment_li[idx] - if acdcSegment is None: - acdcSegment = ( - self.model_registry.import_segmentation_module( - model_name - ) - ) - self.acdcSegment_li[idx] = acdcSegment + ccs = cca_df_i.at[ID, "cell_cycle_stage"] + relID = cca_df_i.at[ID, "relative_ID"] + if clicked_ccs == "S": + if ccs == "G1": + # Cell is in G1 in the future again so stop annotating + break + self.annotateDivision(cca_df_i, ID, relID) + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) + elif ccs == "S": + # Cell is in S in the future again so stop undoing (break) + # also leave a 1 frame duration G1 to avoid a continuous + # S phase + self.annotateDivision(cca_df_i, ID, relID) + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) + break + else: + self.undoDivisionAnnotation(cca_df_i, ID, relID) + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) + + # Correct past frames + for past_i in range(posData.frame_i - 1, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + if ID not in cca_df_i.index or relID not in cca_df_i.index: + # Bud did not exist at frame_i = i + break - # Read all models parameters - init_params, segment_params = ( - self.model_registry.model_arg_specs(acdcSegment) - ) - # Prompt user to enter the model parameters - try: - url = acdcSegment.url_help() - except AttributeError: - url = None + self.storeUndoRedoCca(past_i, cca_df_i, undoId) + ccs = cca_df_i.at[ID, "cell_cycle_stage"] + relID = cca_df_i.at[ID, "relative_ID"] + if ccs == "S": + # We correct only those frames in which the ID was in 'G1' + break + else: + self.undoDivisionAnnotation(cca_df_i, ID, relID) + self.store_cca_df(frame_i=past_i, cca_df=cca_df_i, autosave=False) - _SizeZ = None - if self.isSegm3D: - _SizeZ = posData.SizeZ - win = apps.QDialogModelParams( - init_params, - segment_params, - model_name, - url=url, - posData=posData, - df_metadata=posData.metadata_df - ) - win.exec_() - if win.cancel: - self.titleLabel.setText('Segmentation aborted.') - return + self.enqAutosave() - use_gpu = win.init_kwargs.get('gpu', False) - proceed = self.model_registry.check_gpu_available( - model_name, use_gpu, qparent=self.host + def manualEditCca(self, checked=True): + posData = self.data[self.pos_i] + editCcaWidget = apps.editCcaTableWidget( + posData.cca_df, posData.SizeT, current_frame_i=posData.frame_i, parent=self ) - if not proceed: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - - self.model_kwargs = win.model_kwargs - model = self.model_registry.init_segmentation_model( - acdcSegment, posData, win.init_kwargs + editCcaWidget.sigApplyChangesFutureFrames.connect( + self.applyManualCcaChangesFutureFrames ) - if model is None: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') + editCcaWidget.exec_() + if editCcaWidget.cancel: return - try: - model.setupLogger(self.logger) - except Exception as e: - pass + posData.cca_df = editCcaWidget.cca_df + self.store_cca_df() + # self.checkMultiBudMoth() + self.updateAllImages() - self.models[idx] = model + def manualEditCcaToolbarActionTriggered(self): + self.manualEditCca() - img = self.getDisplayedImg1() + def nearest_point_2Dyx(self, points, all_others): + """ + Given 2D array of [y, x] coordinates points and all_others return the + [y, x] coordinates of the two points (one from points and one from all_others) + that have the absolute minimum distance + """ + # Compute 3D array where each ith row of each kth page is the element-wise + # difference between kth row of points and ith row in all_others array. + # (i.e. diff[k,i] = points[k] - all_others[i]) + diff = points[:, np.newaxis] - all_others + # Compute 2D array of distances where + # dist[i, j] = euclidean dist (points[i],all_others[j]) + dist = np.linalg.norm(diff, axis=2) + # Compute i, j indexes of the absolute minimum distance + i, j = np.unravel_index(dist.argmin(), dist.shape) + nearest_point = all_others[j] + points[i] + min_dist = np.min(dist) + return min_dist, nearest_point - posData.cca_df = model.predictCcaState(img, posData.lab) - self.store_data() - self.updateAllImages() + def onMotherNotInG1(self, mothID): + txt = html_utils.paragraph( + f"You clicked on ID={mothID} which is NOT in G1

" + "Do you want to proceed with swapping the mother cells?

" + "NOTE: To assign a bud start by clicking on the bud " + "and release on a cell in G1" + ) + msg = widgets.myMessageBox() + swapMothersButton = widgets.reloadPushButton("Swap mother cells") + _, swapMothersButton = msg.warning( + self, + "Released on a cell NOT in G1", + txt, + buttonsTexts=("Cancel", swapMothersButton), + ) + if msg.cancel: + return + + pairings = self.checkSwapMothersEligibility() + if pairings is None: + self.logger.info("Swapping mothers is not possible.") + return - self.titleLabel.setText('Budding event prediction done.', color='g') + self.swapMothers(*pairings) def reInitCca(self): if not self.isSnapshot: txt = html_utils.paragraph( - 'If you decide to continue ALL cell cycle annotations from ' - 'this frame to the end will be erased from current session ' - '(saved data is not touched of course).

' - 'To annotate future frames again you will have to revisit them.

' - 'Do you want to continue?' + "If you decide to continue ALL cell cycle annotations from " + "this frame to the end will be erased from current session " + "(saved data is not touched of course).

" + "To annotate future frames again you will have to revisit them.

" + "Do you want to continue?" ) msg = widgets.myMessageBox() msg.warning( - self.host, 'Re-initialize annnotations?', txt, - buttonsTexts=('Cancel', 'Yes') + self, "Re-initialize annnotations?", txt, buttonsTexts=("Cancel", "Yes") ) posData = self.data[self.pos_i] if msg.cancel: return # Reset all future frames - self.resetCcaFuture(posData.frame_i+1) + self.resetCcaFuture(posData.frame_i + 1) if posData.frame_i == 0: # Reset everything since we are on first frame posData.cca_df = self.getBaseCca_df() self.store_data() self.updateAllImages() - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) + self.navigateScrollBar.setMaximum(posData.frame_i + 1) + self.navSpinBox.setMaximum(posData.frame_i + 1) else: # Store undo state before modifying stuff self.storeUndoRedoStates(False) @@ -1513,29 +1727,51 @@ def reInitCca(self): self.store_data() self.updateAllImages() + def removeCcaAnnotationsCurrentFrame(self): + posData = self.data[self.pos_i] + posData.cca_df = None + + posData.allData_li[posData.frame_i].pop("cca_df", None) + posData.allData_li[posData.frame_i].pop("cca_df_checker", None) + + df = posData.allData_li[posData.frame_i]["acdc_df"] + if df is None: + # No more saved info to delete + return False + + if "cell_cycle_stage" not in df.columns: + # No cell cycle info present + return False + + df = df.drop(columns=self.cca_df_colnames) + posData.allData_li[posData.frame_i]["acdc_df"] = df + + return True def repeatAutoCca(self): # Do not allow automatic bud assignment if there are future # frames that already contain anotations posData = self.data[self.pos_i] - next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] - if self.cca_edits.has_annotations(next_df): - msg = QMessageBox() - warn_cca = msg.critical( - self.host, 'Future visited frames detected!', - 'Automatic bud assignment CANNOT be performed becasue ' - 'there are future frames that already contain cell cycle ' - 'annotations. The behaviour in this case cannot be predicted.\n\n' - 'We suggest assigning the bud manually OR use the ' - '"Re-initialize cell cycle annotations" button which properly ' - 're-initialize future frames.', - msg.Ok - ) - return + next_df = posData.allData_li[posData.frame_i + 1]["acdc_df"] + if next_df is not None: + if "cell_cycle_stage" in next_df.columns: + msg = QMessageBox() + msg.critical( + self, + "Future visited frames detected!", + "Automatic bud assignment CANNOT be performed becasue " + "there are future frames that already contain cell cycle " + "annotations. The behaviour in this case cannot be predicted.\n\n" + "We suggest assigning the bud manually OR use the " + '"Re-initialize cell cycle annotations" button which properly ' + "re-initialize future frames.", + msg.Ok, + ) + return - correctedAssignIDs = ( - posData.cca_df[posData.cca_df['corrected_on_frame_i']>=0].index - ) + correctedAssignIDs = posData.cca_df[ + posData.cca_df["corrected_on_frame_i"] >= 0 + ].index NeverCorrectedAssignIDs = [ ID for ID in posData.new_IDs if ID not in correctedAssignIDs ] @@ -1554,14 +1790,15 @@ def repeatAutoCca(self): msg = QMessageBox() msg.setIcon(msg.Question) msg.setText( - 'Do you want to automatically assign buds to mother cells for ' - 'ALL the new cells in this frame (excluding cells with unknown history) ' - 'OR only the cells where you never clicked on?' + "Do you want to automatically assign buds to mother cells for " + "ALL the new cells in this frame (excluding cells with unknown history) " + "OR only the cells where you never clicked on?" ) msg.setDetailedText( - f'New cells that you never touched:\n\n{NeverCorrectedAssignIDs}') - enforceAllButton = QPushButton('ALL new cells') - b = QPushButton('Only cells that I never corrected assignment') + f"New cells that you never touched:\n\n{NeverCorrectedAssignIDs}" + ) + enforceAllButton = QPushButton("ALL new cells") + b = QPushButton("Only cells that I never corrected assignment") msg.addButton(b, msg.YesRole) msg.addButton(enforceAllButton, msg.NoRole) msg.exec_() @@ -1574,898 +1811,962 @@ def repeatAutoCca(self): else: self.updateAllImages() - def manualEditCcaToolbarActionTriggered(self): - self.manualEditCca() - - def manualEditCca(self, checked=True): + def resetCcaFuture(self, from_frame_i): posData = self.data[self.pos_i] - editCcaWidget = apps.editCcaTableWidget( - posData.cca_df, posData.SizeT, current_frame_i=posData.frame_i, - parent=self.host - ) - editCcaWidget.sigApplyChangesFutureFrames.connect( - self.applyManualCcaChangesFutureFrames - ) - editCcaWidget.exec_() - if editCcaWidget.cancel: - return - posData.cca_df = editCcaWidget.cca_df - self.store_cca_df() - # self.checkMultiBudMoth() - self.updateAllImages() + self.last_cca_frame_i = from_frame_i - 1 + self.ccaCheckerStopChecking() - @exception_handler - def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i): - self.store_data(autosave=False) - posData = self.data[self.pos_i] - undoId = uuid.uuid4() - for i in range(posData.frame_i, stop_frame_i): - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet + self.setNavigateScrollBarMaximum() + for i in range(from_frame_i, posData.SizeT): + posData.allData_li[i].pop("cca_df", None) + posData.allData_li[i].pop("cca_df_checker", None) + + df = posData.allData_li[i]["acdc_df"] + if df is None: + # No more saved info to delete break - self.storeUndoRedoCca(i, cca_df_i, undoId) + if "cell_cycle_stage" not in df.columns: + # No cell cycle info present + continue - cca_df_i = self.cca_edits.apply_manual_changes( - cca_df_i, changes - ) - self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - self.get_data() - self.updateAllImages() + df = df.drop(columns=self.cca_df_colnames) + posData.allData_li[i]["acdc_df"] = df - def ccaCheckerWorkerDone(self): - self.status_hover_view.set_status_bar_label(log=False) + if posData.acdc_df is not None: + frames = posData.acdc_df.index.get_level_values(0) + if from_frame_i in frames: + posData.acdc_df = posData.acdc_df.loc[:from_frame_i] - def goToFrameNumber(self, frame_n): - posData = self.data[self.pos_i] - posData.frame_i = frame_n - 1 - self.get_data() - self.updateAllImages() - self.updateScrollbars() + self.resetWillDivideInfo() - def warnCcaIntegrity(self, txt, category): - self.logger.warning(f'{html_utils.to_plain_text(txt)}') + def resetFutureCcaColCurrentFrame(self): + posData = self.data[self.pos_i] - if 'disable_all' in self.disabled_cca_warnings: - return + cca_df_S_mask = posData.cca_df.cell_cycle_stage == "S" + posData.cca_df.loc[cca_df_S_mask, "will_divide"] = 0 - if category in self.disabled_cca_warnings: - return + mothers_mask = (posData.cca_df.relationship == "mother") & cca_df_S_mask + bud_mask = posData.cca_df.relationship == "bud" - if txt in self.disabled_cca_warnings: - return + posData.cca_df.loc[mothers_mask, "daughter_disappears_before_division"] = 0 + posData.cca_df.loc[bud_mask, "disappears_before_division"] = 0 - if self.isWarningCcaIntegrity: - # Some other warning is still open --> avoid opening another one - return + cca_df = self.get_cca_df(frame_i=posData.frame_i, return_df=True) + if cca_df is not None: + cca_df_S_mask = cca_df.cell_cycle_stage == "S" + cca_df.loc[cca_df_S_mask, "will_divide"] = 0 - self.isWarningCcaIntegrity = True - disabled_warning = _warnings.warn_cca_integrity( - txt, category, self.host, - go_to_frame_callback=self.goToFrameNumber - ) - if disabled_warning: - self.disabled_cca_warnings.add(disabled_warning) + mothers_mask = (cca_df.relationship == "mother") & cca_df_S_mask + bud_mask = cca_df.relationship == "bud" - self.isWarningCcaIntegrity = False + cca_df.loc[mothers_mask, "daughter_disappears_before_division"] = 0 + cca_df.loc[bud_mask, "disappears_before_division"] = 0 - def fixWillDivide(self, warning_txt, IDs_will_divide_wrong): - self.logger.info(warning_txt) - self.logger.info('Fixing `will_divide` information...') + self.store_data() + def resetWillDivideInfo(self): global_cca_df = self.getConcatCcaDf() - global_cca_df = self.cca_workflows.reset_will_divide_for_generations( - global_cca_df, - IDs_will_divide_wrong, - ) + if global_cca_df is None: + return + + global_cca_df = load._fix_will_divide(global_cca_df) self.storeFromConcatCcaDf(global_cca_df) - def ccaCheckerWorkerClosed(self, worker): - self.logger.info('Cell cycle annotations integrity checker stopped.') - self.ccaCheckerRunning = False + def setCcaIssueContour(self, obj): + objContours = self.getObjContours(obj, all_external=True) + for cont in objContours: + xx = cont[:, 0] + 0.5 + yy = cont[:, 1] + 0.5 + self.ax1_lostObjScatterItem.addPoints(xx, yy) + self.textAnnot[0].addObjAnnotation(obj, "lost_object", f"{obj.label}?", False) + + def startBlinkingPairingItem(self, budIDs, mothIDs): + self.ax1_newMothBudLinesItem.setOpacity(0.2) + self.ax1_oldMothBudLinesItem.setOpacity(0.2) - def updateIsHistoryKnown(): - """ - This function is called every time the user saves and it is used - for updating the status of cells where we don't know the history + posData = self.data[self.pos_i] + acdc_df_i = posData.allData_li[posData.frame_i]["acdc_df"] - There are three possibilities: + # Blink one pairing at the time (the first found) + xc_b = acdc_df_i.loc[budIDs[0], "x_centroid"] + yc_b = acdc_df_i.loc[budIDs[0], "y_centroid"] - 1. The cell with unknown history is a BUD - --> we don't know when that bud emerged --> 'emerg_frame_i' = -1 - 2. The cell with unknown history is a MOTHER cell - --> we don't know emerging frame --> 'emerg_frame_i' = -1 - AND generation number --> we start from 'generation_num' = 2 - 3. The cell with unknown history is a CELL in G1 - --> we don't know emerging frame --> 'emerg_frame_i' = -1 - AND generation number --> we start from 'generation_num' = 2 - AND relative's ID in the previous cell cycle --> 'relative_ID' = -1 - """ - pass + xc_m = acdc_df_i.loc[mothIDs[0], "x_centroid"] + yc_m = acdc_df_i.loc[mothIDs[0], "y_centroid"] - def annotateIsHistoryKnown(self, ID): - """ - This function is used for annotating that a cell has unknown or known - history. Cells with unknown history are for example the cells already - present in the first frame or cells that appear in the frame from - outside of the field of view. + self.warnPairingItem.setData([xc_b, xc_m], [yc_b, yc_m]) - With this function we simply set 'is_history_known' to False. - When the users saves instead we update the entire staus of the cell - with unknown history with the function "updateIsHistoryKnown()" - """ - posData = self.data[self.pos_i] - is_history_known = posData.cca_df.at[ID, 'is_history_known'] - relID = posData.cca_df.at[ID, 'relative_ID'] - relID_cca = None - if relID in posData.cca_df.index: - relID_cca = self.cca_workflows.previous_relative_status_before_bud_emergence( - ID, - relID, - ( - (i, self.get_cca_df(frame_i=i, return_df=True)) - for i in range(posData.frame_i-1, -1, -1) - ), - self.cca_workflows.base_status(base_cca_dict), - ) + self.blinkPairingItemTimer = QTimer() + self.blinkPairingItemTimer.flag = True + self.blinkPairingItemTimer.timeout.connect(self.blinkPairingItem) + self.blinkPairingItemTimer.start(300) - if is_history_known: - # Save status of ID when emerged to allow undoing - statusID_whenEmerged = self.cca_workflows.known_history_status_for_bud( - ID, - ( - (i, self.get_cca_df(frame_i=i, return_df=True)) - for i in range(posData.frame_i-1, -1, -1) - ), - self.cca_workflows.base_status(base_cca_dict), - ) - if statusID_whenEmerged is None: - return - posData.ccaStatus_whenEmerged[ID] = statusID_whenEmerged + def startCcaIntegrityCheckerWorker(self): + if not hasattr(self, "data"): + return - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + if not self.isDataLoaded: + return - if ID not in posData.ccaStatus_whenEmerged: - self.warnSettingHistoryKnownCellsFirstFrame(ID) + if not self.ccaIntegrCheckerToggle.isChecked(): return - future_cca_frames = ( - (i, self.get_cca_df(frame_i=i, return_df=True)) - for i in range(posData.frame_i+1, posData.SizeT) - ) - past_cca_frames = ( - (i, self.get_cca_df(frame_i=i, return_df=True)) - for i in range(posData.frame_i-1, -1, -1) - ) - propagation_result = self.cca_workflows.propagate_history_knowledge( - posData.cca_df, - posData.frame_i, - ID, - future_cca_frames=future_cca_frames, - past_cca_frames=past_cca_frames, - status_when_emerged=posData.ccaStatus_whenEmerged.get(ID), - relative_id=relID, - relative_status=relID_cca, + ccaCheckerThread = QThread() + self.ccaCheckerMutex = QMutex() + self.ccaCheckerWaitCond = QWaitCondition() + + worker = workers.CcaIntegrityCheckerWorker( + self.ccaCheckerMutex, self.ccaCheckerWaitCond ) - posData.cca_df = propagation_result.current_cca_df + self.ccaIntegrityCheckerWorker = worker + self.ccaCheckerThread = ccaCheckerThread - # Update cell cycle info LabelItems - obj_idx = posData.IDs.index(ID) - rp_ID = posData.rp[obj_idx] + worker.moveToThread(ccaCheckerThread) + worker.finished.connect(ccaCheckerThread.quit) + worker.finished.connect(worker.deleteLater) + ccaCheckerThread.finished.connect(ccaCheckerThread.deleteLater) - if relID in posData.IDs: - relObj_idx = posData.IDs.index(relID) - rp_relID = posData.rp[relObj_idx] + worker.sigDone.connect(self.ccaCheckerWorkerDone) + worker.progress.connect(self.workerProgress) + worker.critical.connect(self.ccaIntegrityWorkerCritical) + worker.finished.connect(self.ccaCheckerWorkerClosed) + worker.sigWarning.connect(self.warnCcaIntegrity) + worker.sigFixWillDivide.connect(self.fixWillDivide) - self.setAllTextAnnotations() - self.drawAllMothBudLines() + ccaCheckerThread.started.connect(worker.run) + ccaCheckerThread.start() - self.store_cca_df() + self.ccaCheckerRunning = True - if self.ccaTableWin is not None: - zoomIDs = self.exporting_view.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + self.initCcaIntegrityChecker() - for frame_i in propagation_result.undo_frame_indices: - cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) - self.storeUndoRedoCca(frame_i, cca_df_i, undoId) + self.logger.info("Cell cycle annotations integrity checker started.") - for frame_i, cca_df_i in propagation_result.updated_cca_dfs_by_frame.items(): - if frame_i == posData.frame_i: - continue - self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) + def stopBlinkingPairItem(self): + self.ax1_newMothBudLinesItem.setOpacity(1.0) + self.ax1_oldMothBudLinesItem.setOpacity(1.0) - self.enqAutosave() + self.warnPairingItem.setData([], []) + try: + self.blinkPairingItemTimer.stop() + except Exception: + pass - def annotateWillDivide(self, ID, relID, frame_i=None): + def stopCcaIntegrityCheckerWorker(self): + try: + self.ccaIntegrityCheckerWorker._stop() + except Exception: + pass + + def storeFromConcatCcaDf(self, global_cca_df): posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i + for frame_i in range(0, posData.SizeT): + try: + cca_df = global_cca_df.loc[frame_i] + except KeyError: + break - past_cca_frames = ( - (i, self.get_cca_df(frame_i=i, return_df=True)) - for i in range(frame_i-1, -1, -1) - ) - propagation_result = self.cca_workflows.propagate_will_divide( - None, - frame_i, - ID, - relID, - past_cca_frames=past_cca_frames, - ) - for past_frame_i, cca_df_i in ( - propagation_result.updated_cca_dfs_by_frame.items() - ): - self.store_cca_df( - cca_df=cca_df_i, - frame_i=past_frame_i, - autosave=False, - ) + self.store_cca_df(frame_i=frame_i, cca_df=cca_df, autosave=False) - def annotateDivision(self, cca_df, ID, relID, frame_i=None): - # Correct as follows: - # For frame_i > 0 --> assign to G1 and +1 on generation number - # For frame == 0 --> reinitialize to unknown cells - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i + self.get_cca_df() - self.annotateWillDivide(ID, relID) - return self.cca_workflows.annotate_division(cca_df, ID, relID, frame_i) + def store_cca_df( + self, + pos_i=None, + frame_i=None, + cca_df=None, + mainThread=True, + autosave=True, + store_cca_df_copy=False, + ): + pos_i = self.pos_i if pos_i is None else pos_i + posData = self.data[pos_i] + i = posData.frame_i if frame_i is None else frame_i + if cca_df is None: + cca_df = posData.cca_df + if self.ccaTableWin is not None and mainThread: + zoomIDs = self.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - def undoDivisionAnnotation(self, cca_df, ID, relID): - # Correct as follows: - # If G1 then correct to S and -1 on generation number - return self.cca_workflows.undo_division_annotation(cca_df, ID, relID) + acdc_df = posData.allData_li[i]["acdc_df"] + if acdc_df is None: + current_frame_i = None + if frame_i is not None and frame_i != posData.frame_i: + current_frame_i = posData.frame_i + posData.frame_i = frame_i + self.get_data() + self.store_data() + acdc_df = posData.allData_li[i]["acdc_df"] + if current_frame_i is not None: + # Back to current frame + posData.frame_i = current_frame_i + self.get_data(debug=False) - def undoBudMothAssignment(self, ID): - posData = self.data[self.pos_i] - changed = self.cca_workflows.undo_bud_mother_assignment(posData.cca_df, ID) - if not changed: - return + if "cell_cycle_stage" in acdc_df.columns: + # Cell cycle info already present --> overwrite with new + acdc_df[self.cca_df_colnames] = cca_df[self.cca_df_colnames] + posData.allData_li[i]["acdc_df"] = acdc_df + elif cca_df is not None: + df = acdc_df.drop(cca_df.columns, axis=1, errors="ignore") + df = df.join(cca_df, how="left") + posData.allData_li[i]["acdc_df"] = df - self.store_cca_df() + # Store copy for cca integrity worker + self.store_cca_df_checker(posData, i, cca_df) - # Update cell cycle info LabelItems - self.setAllTextAnnotations() + if store_cca_df_copy and cca_df is not None: + posData.allData_li[i]["cca_df"] = cca_df.copy() - if self.ccaTableWin is not None: - zoomIDs = self.exporting_view.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + if autosave: + self.enqAutosave() + self.enqCcaIntegrityChecker() - @exception_handler - def manualCellCycleAnnotation(self, ID): - """ - This function is used for both annotating division or undoing the - annotation. It can be called on any frame. + def store_cca_df_checker(self, posData, frame_i, cca_df): + if not self.ccaCheckerRunning: + return - If we annotate division (right click on a cell in S) then it will - check if there are future frames to correct. - Frames to correct are those frames where both the mother and the bud - are annotated as S phase cells. - In this case we assign all those frames to G1, relationship to mother, - and +1 generation number + if cca_df is None: + return - If we undo the annotation (right click on a cell in G1) then it will - correct both past and future annotated frames (if present). - Frames to correct are those frames where both the mother and the bud - are annotated as G1 phase cells. - In this case we assign all those frames to G1, relationship back to - bud, and -1 generation number - """ + posData.allData_li[frame_i]["cca_df_checker"] = cca_df.copy() + + @exception_handler + def swapMothers(self, budID, otherBudID, otherMothID, mothID): posData = self.data[self.pos_i] # Store cca_df for undo action undoId = uuid.uuid4() self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - # Correct current frame - clicked_ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] - relID = posData.cca_df.at[ID, 'relative_ID'] + self.logger.info( + f"Swapping assignments (requested at frame n. {posData.frame_i + 1}):\n" + f" * Bud ID {budID} --> mother ID {otherMothID}\n" + f" * Bud ID {otherBudID} --> mother ID {mothID}" + ) - if relID not in posData.IDs: - return + correct_pairings = {otherBudID: mothID, budID: otherMothID} - if clicked_ccs == 'G1' and posData.frame_i == 0: - # We do not allow undoing division annotation on first frame - return + for correct_budID, correct_mothID in correct_pairings.items(): + posData.cca_df.at[correct_budID, "relative_ID"] = correct_mothID + posData.cca_df.at[correct_mothID, "relative_ID"] = correct_budID + posData.cca_df.at[correct_budID, "corrected_on_frame_i"] = posData.frame_i + posData.cca_df.at[correct_mothID, "corrected_on_frame_i"] = posData.frame_i + self.store_cca_df() - if clicked_ccs == 'G1': - issue_frame_i = self.checkDivisionCanBeUndone(ID, relID) - if issue_frame_i is not None: - _warnings.warnDivisionAnnotationCannotBeUndone( - ID, relID, issue_frame_i, qparent=self.host - ) - return + # Correct past frames + corrected_budIDs_past = set() + for past_i in range(posData.frame_i - 1, -1, -1): + if len(corrected_budIDs_past) == 2: + break - future_cca_frames = ( - (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) - for frame_i in range(posData.frame_i + 1, posData.SizeT) - ) - past_cca_frames = ( - (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) - for frame_i in range(posData.frame_i - 1, -1, -1) - ) - propagation_result = self.cca_workflows.propagate_manual_division_annotation( - None, - posData.frame_i, - ID, - current_cca_df=posData.cca_df, - future_cca_frames=future_cca_frames, - past_cca_frames=past_cca_frames, - ) - posData.cca_df = propagation_result.current_cca_df - self.store_cca_df() + for correct_budID, correct_mothID in correct_pairings.items(): + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - # Update cell cycle info LabelItems - self.ax1_newMothBudLinesItem.setData([], []) - self.ax1_oldMothBudLinesItem.setData([], []) - self.ax2_newMothBudLinesItem.setData([], []) - self.ax2_oldMothBudLinesItem.setData([], []) - self.drawAllMothBudLines() - self.setAllTextAnnotations() + if correct_budID in corrected_budIDs_past: + continue - if self.ccaTableWin is not None: - zoomIDs = self.exporting_view.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + if correct_budID not in cca_df_i.index: + # Bud does not exist anymore in the past + corrected_budIDs_past.add(correct_budID) - for frame_i in propagation_result.undo_frame_indices: - cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) - self.storeUndoRedoCca(frame_i, cca_df_i, undoId) + if len(corrected_budIDs_past) < 2: + self.restoreMotherToBeforeWrongBudWasAssignedToIt( + correct_mothID, cca_df_i, past_i + ) + continue - for frame_i, cca_df_i in propagation_result.updated_cca_dfs_by_frame.items(): - if frame_i == posData.frame_i: - continue - self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) + cca_df_i.at[correct_budID, "relative_ID"] = correct_mothID + cca_df_i.at[correct_mothID, "relative_ID"] = correct_budID + cca_df_i.at[correct_budID, "corrected_on_frame_i"] = posData.frame_i + cca_df_i.at[correct_mothID, "corrected_on_frame_i"] = posData.frame_i - self.enqAutosave() + # Set mother cell cycle stage to S in case it is not + if cca_df_i.at[correct_mothID, "cell_cycle_stage"] == "G1": + cca_df_i.at[correct_mothID, "cell_cycle_stage"] = "S" + # cca_df_i.at[correct_mothID, 'generation_num'] -= 1 - def warnMotherNotEligible(self, new_mothID, budID, i, why): - if why == 'not_G1_in_the_future': - err_msg = html_utils.paragraph(f""" - The requested cell in G1 (ID={new_mothID}) - at future frame {i+1} has a bud assigned to it, - therefore it cannot be assigned as the mother - of bud ID {budID}.

- You can assign a cell as the mother of bud ID {budID} - only if this cell is in G1 for the - entire life of the bud.

- One possible solution is to click on "cancel", go to - frame {i+1} and assign the bud of cell {new_mothID} - to another cell.\n' - A second solution is to assign bud ID {budID} to cell - {new_mothID} anyway by clicking "Apply".

- However to ensure correctness of - future assignments Cell-ACDC will delete any cell cycle - information from frame {i+1} to the end. Therefore, you - will have to visit those frames again.

- The deletion of cell cycle information - CANNOT BE UNDONE! - Saved data is not changed of course.

- Apply assignment or cancel process? - """) - applyButton = widgets.okPushButton(isDefault=False) - applyButton.setText('Apply and remove future annotations') - msg = widgets.myMessageBox() - _, applyButton = msg.warning( - self.host, 'Cell not eligible', err_msg, - buttonsTexts=('Cancel', applyButton) - ) - cancel = msg.cancel - apply = msg.clickedButton == applyButton - elif why == 'not_G1_in_the_past': - err_msg = html_utils.paragraph(f""" - The requested cell in G1 - (ID={new_mothID}) at past frame {i+1} - has a bud assigned to it, therefore it cannot be - assigned as mother of bud ID {budID}.
- You can assign a cell as the mother of bud ID {budID} - only if this cell is in G1 for the entire life of the bud.
- One possible solution is to first go to frame {i+1} and - assign the bud of cell {new_mothID} to another cell. - """) - msg = widgets.myMessageBox() - msg.warning( - self.host, 'Cell not eligible', err_msg - ) - cancel = msg.cancel - apply = False - elif why == 'single_frame_G1_duration': - err_msg = html_utils.paragraph(f""" - Assigning bud ID {budID} to cell ID {new_mothID} would result - in no G1 phase at all between previous cell cycle and - current cell cycle (see frame n. {i+1}).

+ self.store_cca_df(frame_i=past_i, cca_df=cca_df_i, autosave=False) - The solution is to annotate division on cell ID {new_mothID} - on any frame before the frame number {i+1}, and then - proceed to correcting the bud assignment.

+ # Correct future frames + corrected_budIDs_future = set() + for future_i in range(posData.frame_i + 1, posData.SizeT): + if len(corrected_budIDs_future) == 2: + break + + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break - This will gurantee a G1 duration for the cell {new_mothID} - of at least 1 frame.

- Thank you for your patience! - """) - msg = widgets.myMessageBox() - msg.warning( - self.host, 'Cell not eligible', err_msg - ) - cancel = msg.cancel - apply = False - return cancel, apply + for correct_budID, correct_mothID in correct_pairings.items(): + if correct_budID in corrected_budIDs_future: + # Bud already corrected in the future + continue - def warnSettingHistoryKnownCellsFirstFrame(self, ID): - txt = html_utils.paragraph(f""" - Cell ID {ID} is a cell that is present since the first - frame.

- These cells already have history UNKNOWN assigned and the - history status cannot be changed. - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning( - self.host, 'First frame cells', txt - ) + if correct_budID not in cca_df_i.index: + # Bud disappeared in the future + corrected_budIDs_future.add(correct_budID) + continue - def checkMothEligibility(self, budID, new_mothID): - """ - Check that the new mother is in G1 for the entire life of the bud - and that the G1 duration is > than 1 frame - """ - last_cca_frame_i = self.navigateScrollBar.maximum()-1 - posData = self.data[self.pos_i] - future_cca_frames = ( - (future_i, self.get_cca_df(frame_i=future_i, return_df=True)) - for future_i in range(posData.frame_i, posData.SizeT) - ) - past_cca_frames = ( - (past_i, self.get_cca_df(frame_i=past_i, return_df=True)) - for past_i in range(posData.frame_i-1, -1, -1) - ) - result = self.cca_workflows.mother_assignment_eligibility( - budID, - new_mothID, - future_cca_frames, - past_cca_frames, - last_cca_frame_i, - ) - if result.future_issue is not None: - issue = result.future_issue - cancel, apply = self.warnMotherNotEligible( - new_mothID, budID, issue.frame_i, issue.reason - ) - if apply: - self.resetCcaFuture(issue.frame_i) - elif cancel or issue.blocks_assignment: - return False - - if result.past_issue is not None: - issue = result.past_issue - self.warnMotherNotEligible( - new_mothID, budID, issue.frame_i, issue.reason - ) - return False + ccs_bud = cca_df_i.at[correct_budID, "cell_cycle_stage"] + if ccs_bud == "G1": + # Bud divided in the future, annotate division between + # correct mother and wrong bud and then stop correcting + if correct_budID not in corrected_budIDs_future: + corrected_budIDs_future.add(correct_budID) + + if len(corrected_budIDs_future) < 2: + self.annotateDivisionFutureFramesSwapMothers( + cca_df_i, correct_mothID, future_i + ) + continue - return True + cca_df_i.at[correct_budID, "relative_ID"] = correct_mothID + cca_df_i.at[correct_mothID, "relative_ID"] = correct_budID + cca_df_i.at[correct_budID, "corrected_on_frame_i"] = posData.frame_i + cca_df_i.at[correct_mothID, "corrected_on_frame_i"] = posData.frame_i - def checkMothersExcludedOrDead(self): - try: - posData = self.data[self.pos_i] - acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] - - buds_df = posData.cca_df[ - (posData.cca_df.relationship == 'bud') - & (posData.cca_df.emerg_frame_i == posData.frame_i) - ] - mother_ids = buds_df.relative_ID.to_list() if not buds_df.empty else [] - excluded_mother_ids = self.check_mothers_exclusion_or_dead( - acdc_df_i, mother_ids - ) - - if not excluded_mother_ids: - self.stopBlinkingPairItem() - return True - - bud_ids = [] - for m_id in excluded_mother_ids: - b_id = buds_df[buds_df.relative_ID == m_id].index.tolist()[0] - bud_ids.append(b_id) - - proceed = self.warnDeadOrExcludedMothers( - bud_ids, excluded_mother_ids - ) - return proceed - except Exception as e: - self.logger.info(traceback.format_exc()) - print('-'*100) - self.logger.warning( - 'Checking if mother cell is excluded or dead failed.' - ) - print('^'*100) - return False + # Set mother cell cycle stage to S in case it is not + if cca_df_i.at[correct_mothID, "cell_cycle_stage"] == "G1": + cca_df_i.at[correct_mothID, "cell_cycle_stage"] = "S" + # cca_df_i.at[correct_mothID, 'generation_num'] -= 1 - def checkDivisionCanBeUndone(self, ID, relID): - """Check that division annotation can be undone (see Notes section) + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) - Parameters - ---------- - ID : int - Cell ID of the clicked cell in G1 - relID : _type_ - Relative ID of the cell that was clicked + self.updateAllImages() - Notes - ----- - Division annotation can be undone only if `relID` is also in G1 for the - entire duration of the correction - """ + def undoBudMothAssignment(self, ID): posData = self.data[self.pos_i] - future_cca_frames = ( - (future_i, self.get_cca_df(frame_i=future_i, return_df=True)) - for future_i in range(posData.frame_i+1, posData.SizeT) - ) - past_cca_frames = ( - (past_i, self.get_cca_df(frame_i=past_i, return_df=True)) - for past_i in range(posData.frame_i-1, -1, -1) - ) - return self.cca_workflows.division_undo_blocking_frame( - ID, - relID, - posData.frame_i, - posData.cca_df, - future_cca_frames=future_cca_frames, - past_cca_frames=past_cca_frames, - ) + relID = posData.cca_df.at[ID, "relative_ID"] + ccs = posData.cca_df.at[ID, "cell_cycle_stage"] + if ccs == "G1": + return + posData.cca_df.at[ID, "relative_ID"] = -1 + posData.cca_df.at[ID, "generation_num"] = 2 + posData.cca_df.at[ID, "cell_cycle_stage"] = "G1" + posData.cca_df.at[ID, "relationship"] = "mother" + if relID in posData.cca_df.index: + posData.cca_df.at[relID, "relative_ID"] = -1 + posData.cca_df.at[relID, "generation_num"] = 2 + posData.cca_df.at[relID, "cell_cycle_stage"] = "G1" + posData.cca_df.at[relID, "relationship"] = "mother" + obj_idx = posData.IDs.index(ID) + relObj_idx = posData.IDs.index(relID) + posData.rp[obj_idx] + posData.rp[relObj_idx] - def stopBlinkingPairItem(self): - self.ax1_newMothBudLinesItem.setOpacity(1.0) - self.ax1_oldMothBudLinesItem.setOpacity(1.0) + self.store_cca_df() - self.warnPairingItem.setData([], []) - try: - self.blinkPairingItemTimer.stop() - except Exception as e: - pass + # Update cell cycle info LabelItems + self.setAllTextAnnotations() - def warnDeadOrExcludedMothers(self, budIDs, mothIDs): - self.startBlinkingPairingItem(budIDs, mothIDs) - msg = widgets.myMessageBox(wrapText=False) - pairings = [ - f'Mother ID {mID} --> bud ID {bID}' - for mID, bID in zip(mothIDs, budIDs) - ] - txt = html_utils.paragraph(f""" - The mother cell in the following mother-bud pairings - (blinking line on the image) is
- excluded from the analysis or dead: - {html_utils.to_list(pairings)} - """) - msg.warning( - self.host, 'Mother cell is excluded or dead', txt, - buttonsTexts=('Cancel', 'Ok') - ) - return not msg.cancel + if self.ccaTableWin is not None: + zoomIDs = self.getZoomIDs() + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - def startBlinkingPairingItem(self, budIDs, mothIDs): - self.ax1_newMothBudLinesItem.setOpacity(0.2) - self.ax1_oldMothBudLinesItem.setOpacity(0.2) + def undoDivisionAnnotation(self, cca_df, ID, relID): + # Correct as follows: + # If G1 then correct to S and -1 on generation number + store = False + cca_df.at[ID, "cell_cycle_stage"] = "S" + gen_num_clickedID = cca_df.at[ID, "generation_num"] + cca_df.at[ID, "generation_num"] -= 1 + cca_df.at[ID, "division_frame_i"] = -1 + cca_df.at[relID, "cell_cycle_stage"] = "S" + gen_num_relID = cca_df.at[relID, "generation_num"] + cca_df.at[relID, "generation_num"] -= 1 + cca_df.at[relID, "division_frame_i"] = -1 + if gen_num_clickedID < gen_num_relID: + cca_df.at[ID, "relationship"] = "bud" + else: + cca_df.at[relID, "relationship"] = "bud" + cca_df.at[ID, "will_divide"] = 0 + cca_df.at[relID, "will_divide"] = 0 + store = True + return store + def unstore_cca_df(self): posData = self.data[self.pos_i] - acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] + for col in self.cca_df_colnames: + if col not in acdc_df.columns: + continue + acdc_df.drop(col, axis=1, inplace=True) - # Blink one pairing at the time (the first found) - xc_b = acdc_df_i.loc[budIDs[0], 'x_centroid'] - yc_b = acdc_df_i.loc[budIDs[0], 'y_centroid'] + @disableWindow + def updateCcaDfDeletedIDsTimelapse( + self, posData, relIDsOfDelIDs, deletedIDs, undoId, dropInPast, dropInFuture + ): + # Get status of the relIDs (of deleted IDs) to restore + relIDsCcaStatus = {} + for relID in relIDsOfDelIDs: + try: + ccs = posData.cca_df.at[relID, "cell_cycle_stage"] + relationship = posData.cca_df.at[relID, "relationship"] + except Exception: + continue - xc_m = acdc_df_i.loc[mothIDs[0], 'x_centroid'] - yc_m = acdc_df_i.loc[mothIDs[0], 'y_centroid'] + ccaStatus = core.getBaseCca_df([relID]).loc[relID] + if relationship == "mother" and ccs == "S": + for past_frame_i in range(posData.frame_i - 1, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_frame_i, return_df=True) + ccs_past = cca_df_i.at[relID, "cell_cycle_stage"] + if ccs_past == "G1": + ccaStatus = cca_df_i.loc[relID] + break - self.warnPairingItem.setData([xc_b, xc_m], [yc_b, yc_m]) + posData.cca_df.loc[relID] = ccaStatus + self.store_data(autosave=False) + relIDsCcaStatus[relID] = ccaStatus - self.blinkPairingItemTimer = QTimer() - self.blinkPairingItemTimer.flag = True - self.blinkPairingItemTimer.timeout.connect(self.blinkPairingItem) - self.blinkPairingItemTimer.start(300) + for fut_frame_i in range(posData.frame_i + 1, posData.SizeT): + cca_df_i = self.get_cca_df(frame_i=fut_frame_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break - def blinkPairingItem(self): - if self.blinkPairingItemTimer.flag: - opacity = 0.3 - self.blinkPairingItemTimer.flag = False - else: - opacity = 1.0 - self.blinkPairingItemTimer.flag = True - self.warnPairingItem.setOpacity(opacity) + self.storeUndoRedoCca(fut_frame_i, cca_df_i, undoId) - def annotateBudToDifferentMother(self): - """ - This function is used for correcting automatic mother-bud assignment. + if dropInFuture: + cca_df_i = cca_df_i.drop(deletedIDs, errors="ignore") + else: + for delID in deletedIDs: + dataDict = posData.allData_li[fut_frame_i] + delIDexists = dataDict["IDs_idxs"].get(delID, False) + if not delIDexists: + continue + + cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID] + + areRelIDsPresent = False + for relID in relIDsOfDelIDs: + try: + ccs = cca_df_i.at[relID, "cell_cycle_stage"] + relationship = cca_df_i.at[relID, "relationship"] + ccaStatus = relIDsCcaStatus[relID] + cca_df_i.loc[relID] = ccaStatus + areRelIDsPresent = True + except Exception: + continue - It can be called at any frame of the bud life. + if not areRelIDsPresent: + break - There are three cells involved: bud, current mother, new mother. + self.store_cca_df(frame_i=fut_frame_i, cca_df=cca_df_i, autosave=False) - Eligibility: - - User clicked first on a bud (checked at click time) - - User released mouse button on a cell in G1 (checked at release time) - - The new mother MUST be in G1 for all the frames of the bud life - --> if not warn - - The new mother MUST have appeared in current frame OR be already - in G1 in previous frame, otherwise there would be no G1 cycle + # Correct past frames + for past_frame_i in range(posData.frame_i - 1, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_frame_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break - Result: - - The bud only changes relative ID to the new mother - - The new mother changes relative ID and stage to 'S' - - The old mother changes its entire status to the status it had - before being assigned to the clicked bud - """ - posData = self.data[self.pos_i] - lab2D = self.get_2Dlab(posData.lab) - budID = lab2D[self.yClickBud, self.xClickBud] - new_mothID = lab2D[self.yClickMoth, self.xClickMoth] + self.storeUndoRedoCca(past_frame_i, cca_df_i, undoId) + if dropInPast: + cca_df_i = cca_df_i.drop(deletedIDs, errors="ignore") + else: + for delID in deletedIDs: + dataDict = posData.allData_li[past_frame_i] + delIDexists = dataDict["IDs_idxs"].get(delID, False) + if not delIDexists: + continue + + cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID] + + areRelIDsPresent = False + for relID in relIDsOfDelIDs: + try: + ccs = cca_df_i.at[relID, "cell_cycle_stage"] + relationship = cca_df_i.at[relID, "relationship"] + ccaStatus = relIDsCcaStatus[relID] + cca_df_i.loc[relID] = ccaStatus + areRelIDsPresent = True + except Exception: + continue - if budID == new_mothID: - return + if not areRelIDsPresent: + break - if not self.isSnapshot: - eligible = self.checkMothEligibility(budID, new_mothID) - if not eligible: - return + self.store_cca_df(frame_i=past_frame_i, cca_df=cca_df_i, autosave=False) - budEligible = self.checkChangeMotherBudEligible( - budID, posData.frame_i - ) - if not budEligible: - return + def updateIsHistoryKnown(): + """ + This function is called every time the user saves and it is used + for updating the status of cells where we don't know the history - # Allow partial initialization of cca_df with mouse - if posData.frame_i == 0: - newMothCcs = posData.cca_df.at[new_mothID, 'cell_cycle_stage'] - if not newMothCcs == 'G1': - err_msg = ( - 'You are assigning the bud to a cell that is not in G1!' - ) - msg = QMessageBox() - msg.critical( - self.host, 'New mother not in G1!', err_msg, msg.Ok - ) - return - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(0, posData.cca_df, undoId) - propagation_result = self.cca_workflows.propagate_bud_mother_assignment( - posData.cca_df, - posData.frame_i, - budID, - new_mothID, - ) - posData.cca_df = propagation_result.current_cca_df - self.updateAllImages() - self.store_cca_df() - return + There are three possibilities: - curr_moth_cca = None - curr_mothID = posData.cca_df.at[budID, 'relative_ID'] - if curr_mothID in posData.cca_df.index: - curr_moth_cca = self.cca_workflows.previous_relative_status_before_bud_emergence( - budID, - curr_mothID, - ( - (i, self.get_cca_df(frame_i=i, return_df=True)) - for i in range(posData.frame_i-1, -1, -1) - ), - self.cca_workflows.base_status(base_cca_dict), - ) + 1. The cell with unknown history is a BUD + --> we don't know when that bud emerged --> 'emerg_frame_i' = -1 + 2. The cell with unknown history is a MOTHER cell + --> we don't know emerging frame --> 'emerg_frame_i' = -1 + AND generation number --> we start from 'generation_num' = 2 + 3. The cell with unknown history is a CELL in G1 + --> we don't know emerging frame --> 'emerg_frame_i' = -1 + AND generation number --> we start from 'generation_num' = 2 + AND relative's ID in the previous cell cycle --> 'relative_ID' = -1 + """ + pass + + def update_cca_df_deletedIDs( + self, posData, deletedIDs, dropInPast=True, dropInFuture=True + ): + if posData.cca_df is None: + return # Store cca_df for undo action undoId = uuid.uuid4() self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - future_cca_frames = ( - (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) - for frame_i in range(posData.frame_i + 1, posData.SizeT) - ) - past_cca_frames = ( - (frame_i, self.get_cca_df(frame_i=frame_i, return_df=True)) - for frame_i in range(posData.frame_i - 1, -1, -1) - ) - propagation_result = self.cca_workflows.propagate_bud_mother_assignment( - posData.cca_df, - posData.frame_i, - budID, - new_mothID, - future_cca_frames=future_cca_frames, - past_cca_frames=past_cca_frames, - previous_mother_status=curr_moth_cca, - ) - posData.cca_df = propagation_result.current_cca_df + try: + relIDs = posData.cca_df.reindex(deletedIDs, fill_value=-1)["relative_ID"] + except KeyError: + return - self.updateAllImages() + posData.cca_df = posData.cca_df.drop(deletedIDs, errors="ignore") + if self.isSnapshot: + self.update_cca_df_newIDs(posData, relIDs) + else: + self.updateCcaDfDeletedIDsTimelapse( + posData, relIDs, deletedIDs, undoId, dropInPast, dropInFuture + ) - # self.checkMultiBudMoth(draw=True) - self.store_cca_df() - proceed = self.checkMothersExcludedOrDead() - if not proceed: - # User clicked on cancel in the message box - self.UndoCca() + def update_cca_df_newIDs(self, posData, new_IDs): + for newID in new_IDs: + self.addIDBaseCca_df(posData, newID) + + def update_cca_df_relabelling(self, posData, oldIDs, newIDs): + relIDs = posData.cca_df["relative_ID"] + posData.cca_df["relative_ID"] = relIDs.replace(oldIDs, newIDs) + mapper = dict(zip(oldIDs, newIDs)) + posData.cca_df = posData.cca_df.rename(index=mapper) + + def update_cca_df_snapshots(self, editTxt, posData): + cca_df = posData.cca_df + cca_df_IDs = cca_df.index + if editTxt == "Delete ID": + deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, deleted_IDs) + + elif editTxt == "Separate IDs": + new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] + self.update_cca_df_newIDs(posData, new_IDs) + deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, deleted_IDs) + + elif editTxt == "Edit ID": + new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] + self.update_cca_df_newIDs(posData, new_IDs) + old_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, old_IDs) + + elif editTxt == "Annotate ID as dead": return - if self.ccaTableWin is not None: - zoomIDs = self.exporting_view.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + elif editTxt == "Deleted non-selected objects": + deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, deleted_IDs) - for frame_i in propagation_result.undo_frame_indices: - cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) - self.storeUndoRedoCca(frame_i, cca_df_i, undoId) + elif editTxt == "Delete ID with eraser": + deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, deleted_IDs) - for frame_i, cca_df_i in propagation_result.updated_cca_dfs_by_frame.items(): - if frame_i == posData.frame_i: - continue - self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) + elif editTxt == "Add new ID with brush tool": + new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] + self.update_cca_df_newIDs(posData, new_IDs) - self.enqAutosave() + elif editTxt == "Merge IDs": + deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, deleted_IDs) - def onMotherNotInG1(self, mothID): - txt = html_utils.paragraph( - f'You clicked on ID={mothID} which is NOT in G1

' - 'Do you want to proceed with swapping the mother cells?

' - 'NOTE: To assign a bud start by clicking on the bud ' - 'and release on a cell in G1' - ) - msg = widgets.myMessageBox() - swapMothersButton = widgets.reloadPushButton('Swap mother cells') - _, swapMothersButton = msg.warning( - self.host, 'Released on a cell NOT in G1', txt, - buttonsTexts=('Cancel', swapMothersButton) - ) - if msg.cancel: - return + elif editTxt == "Add new ID with curvature tool": + new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] + self.update_cca_df_newIDs(posData, new_IDs) + + elif editTxt == "Delete IDs using ROI": + deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, deleted_IDs) + + elif editTxt == "Repeat segmentation": + posData.cca_df = self.getBaseCca_df() + + def viewCcaTable(self): + posData = self.data[self.pos_i] + zoomIDs = self.getZoomIDs() + + df = posData.allData_li[posData.frame_i]["acdc_df"] + current_cca_df = posData.cca_df + if zoomIDs is not None: + df = df.loc[zoomIDs] + current_cca_df = current_cca_df.loc[zoomIDs] + + for column in current_cca_df.columns: + header = ( + "================================================\n" + f"CURRENT vs STORED `{column}` column" + f"for frame number {posData.frame_i + 1}:\n" + ) + df_compare = current_cca_df[[column]].copy() + df_compare[f"STORED_{column}"] = df[column] + text = f"{header}{df_compare}" + self.logger.info(text) - pairings = self.checkSwapMothersEligibility() - if pairings is None: - self.logger.info('Swapping mothers is not possible.') + if "cell_cycle_stage" in df.columns: + cca_df = df[self.cca_df_colnames] + cca_df = cca_df.merge( + current_cca_df, + how="outer", + left_index=True, + right_index=True, + suffixes=("_STORED", "_CURRENT"), + ) + cca_df = cca_df.reindex(sorted(cca_df.columns), axis=1) + num_cols = len(cca_df.columns) + for j in range(0, num_cols, 2): + df_j_x = cca_df.iloc[:, j] + df_j_y = cca_df.iloc[:, j + 1] + if any(df_j_x != df_j_y): + self.logger.info("------------------------") + self.logger.info("DIFFERENCES:") + diff_df = cca_df.iloc[:, j : j + 2] + diff_mask = diff_df.iloc[:, 0] != diff_df.iloc[:, 1] + self.logger.info(diff_df[diff_mask]) + else: + cca_df = None + self.logger.info(cca_df) + self.logger.info("========================") + if current_cca_df is None: + return + if current_cca_df.empty: + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + "Cell cycle annotations' table is empty.
" + ) + msg.warning(self, "Table empty", txt) return - self.swapMothers(*pairings) + df = posData.add_tree_cols_to_cca_df(current_cca_df, frame_i=posData.frame_i) + if self.ccaTableWin is None: + self.ccaTableWin = apps.ViewCcaTableWindow(df, parent=self) + self.ccaTableWin.show() + self.ccaTableWin.setGeometryWindow() + self.ccaTableWin.sigUpdateCcaTable.connect(self.onSigUpdateCcaTableWindow) + else: + self.ccaTableWin.setFocus() + self.ccaTableWin.activateWindow() + self.ccaTableWin.updateTable(current_cca_df) def warnBudAnnotatedDividedInFuture( - self, budID, motherID, future_division_frame_i, - action='swap mother cells' - ): + self, budID, motherID, future_division_frame_i, action="swap mother cells" + ): posData = self.data[self.pos_i] txt = html_utils.paragraph(f""" - Bud ID {budID} is annotated as divided from mother ID {motherID} - at frame n. {future_division_frame_i+1},
+ Bud ID {budID} is annotated as divided from mother ID {motherID} + at frame n. {future_division_frame_i + 1},
therefore it is not possible to {action}.

- We recommend reinitializing cell cycle annotations on any - frame
between frames number {posData.frame_i+1} and + We recommend reinitializing cell cycle annotations on any + frame
between frames number {posData.frame_i + 1} and {future_division_frame_i} before attempting to {action}.

Thank you for your patience! """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self.host, f'{action} not possible'.title(), txt) + msg.warning(self, f"{action} not possible".title(), txt) return - def warnMotherNotAtLeastOneFrameG1(self, budID, motherID, frame_no_G1): - posData = self.data[self.pos_i] + def warnCcaIntegrity(self, txt, category): + self.logger.warning(f"{html_utils.to_plain_text(txt)}") + if "disable_all" in self.disabled_cca_warnings: + return + + if category in self.disabled_cca_warnings: + return + + if txt in self.disabled_cca_warnings: + return + + if self.isWarningCcaIntegrity: + # Some other warning is still open --> avoid opening another one + return + + self.isWarningCcaIntegrity = True + disabled_warning = _warnings.warn_cca_integrity( + txt, category, self, go_to_frame_callback=self.goToFrameNumber + ) + if disabled_warning: + self.disabled_cca_warnings.add(disabled_warning) + + self.isWarningCcaIntegrity = False + + def warnDeadOrExcludedMothers(self, budIDs, mothIDs): + self.startBlinkingPairingItem(budIDs, mothIDs) + msg = widgets.myMessageBox(wrapText=False) + pairings = [ + f"Mother ID {mID} --> bud ID {bID}" for mID, bID in zip(mothIDs, budIDs) + ] txt = html_utils.paragraph(f""" - Assigning bud ID {budID} to cell ID {motherID} cannot be - done because cell ID {motherID} is not in G1 at frame n. - {frame_no_G1}.

- This would result in no G1 phase between previous cell cycle of - cell ID {motherID} and current one. - This is unfortunately not allowed.

- One possible solution is to annotate division on cell ID - {motherID} on any frame before frame n. {frame_no_G1}.

- Thank you for your patience! + The mother cell in the following mother-bud pairings + (blinking line on the image) is
+ excluded from the analysis or dead: + {html_utils.to_list(pairings)} """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self.host, 'Swap mothers not possible', txt) - return + msg.warning( + self, "Mother cell is excluded or dead", txt, buttonsTexts=("Cancel", "Ok") + ) + return not msg.cancel + + def warnEditingWithCca_df( + self, + editTxt, + return_answer=False, + get_answer=False, + get_cancelled=False, + update_images=True, + ): + # Function used to warn that the user is editing in "Segmentation and + # Tracking" mode a frame that contains cca annotations. + # Ask whether to remove annotations from all future frames + if self.isSnapshot: + return True - def checkChangeMotherBudEligible(self, budID, frame_i): posData = self.data[self.pos_i] - future_cca_frames = ( - (future_i, self.get_cca_df(frame_i=future_i, return_df=True)) - for future_i in range(frame_i, posData.SizeT) - ) - result = self.cca_workflows.bud_mother_change_eligibility(budID, future_cca_frames) - if result.can_change: + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] + + if acdc_df is None and self.lineage_tree is None: + if update_images: + self.updateAllImages() return True - future_division = result.future_division - self.warnBudAnnotatedDividedInFuture( - budID, - future_division.mother_id, - future_division.frame_i, - action='change mother cell', + cell_cycle_stage_present = ( + acdc_df is not None and "cell_cycle_stage" in acdc_df.columns ) - return False + lineage_tree_present = ( + self.lineage_tree is not None or "parent_ID_tree" in acdc_df.columns + ) + if not cell_cycle_stage_present and not lineage_tree_present: + if update_images: + self.updateAllImages() + return True - def checkSwapMothersEligibility(self): - posData = self.data[self.pos_i] + action = self.warnEditingWithAnnotActions.get(editTxt, None) + if action is not None and not action.isChecked(): + # user has checked that he does not want to be asked again AND he doesnt want to delete + if update_images: + self.updateAllImages() + return True - lab2D = self.get_2Dlab(posData.lab) - budID = lab2D[self.yClickBud, self.xClickBud] - otherMothID = lab2D[self.yClickMoth, self.xClickMoth] - mothID = posData.cca_df.at[budID, 'relative_ID'] - otherBudID = posData.cca_df.at[otherMothID, 'relative_ID'] + msg = widgets.myMessageBox() + warn_type = ( + "cell cycle annotations" + if cell_cycle_stage_present + else "lineage tree annotations" + ) + txt = html_utils.paragraph( + f"You modified a frame that has {warn_type}.

" + f'The change "{editTxt}" most likely makes the ' + "annotations wrong.

" + "If you really want to apply this change we reccommend to remove" + f"ALL {warn_type}
" + "from current frame to the end.

" + "What do you want to do?" + ) + if action is not None: + checkBox = QCheckBox("Remember my choice and do not ask again") + else: + checkBox = None - future_cca_frames = [ - (future_i, self.get_cca_df(frame_i=future_i, return_df=True)) - for future_i in range(posData.frame_i, posData.SizeT) - ] - past_cca_frames = [ - (past_i, self.get_cca_df(frame_i=past_i, return_df=True)) - for past_i in range(posData.frame_i, -1, -1) - ] - result = self.cca_workflows.swap_mothers_eligibility( - budID, - otherBudID, - otherMothID, - mothID, - future_cca_frames, - past_cca_frames, + dropDelIDsNoteText = ( + "" if editTxt.find("Delete") == -1 else " (drop removed IDs)" ) - if result.future_division_frame_i is not None: - self.warnBudAnnotatedDividedInFuture( - result.future_division_bud_id, - result.future_division_mother_id, - result.future_division_frame_i, - ) - return + _, removeAnnotButton, _ = msg.warning( + self, + "Edited segmentation with annotations!", + txt, + buttonsTexts=( + "Cancel", + "Remove annotations from future frames (RECOMMENDED)", + f"Do not remove annotations{dropDelIDsNoteText}", + ), + widgets=checkBox, + ) + if msg.cancel: + if get_cancelled: + return "cancelled" + removeAnnotations = False + return removeAnnotations - if result.mother_not_g1_frame_i is not None: - self.warnMotherNotAtLeastOneFrameG1( - result.mother_not_g1_bud_id, - result.mother_not_g1_mother_id, - result.mother_not_g1_frame_i, - ) - return + if action is not None: + action.setChecked(not checkBox.isChecked()) + action.removeAnnot = msg.clickedButton == removeAnnotButton - return budID, otherBudID, otherMothID, mothID + if return_answer: + return msg.clickedButton == removeAnnotButton - @exception_handler - def swapMothers(self, budID, otherBudID, otherMothID, mothID): - posData = self.data[self.pos_i] + if (msg.clickedButton == removeAnnotButton) and cell_cycle_stage_present: + self.resetFutureCcaColCurrentFrame() + self.resetCcaFuture(posData.frame_i + 1) + self.updateAllImages() + elif (msg.clickedButton == removeAnnotButton) and lineage_tree_present: + self.resetLin_tree_future() + self.updateAllImages() + else: + if dropDelIDsNoteText and posData.cca_df is not None: + delIDs = [ID for ID in posData.cca_df.index if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, delIDs, dropInPast=False) + self.addMissingIDs_cca_df(posData) + self.updateAllImages() + self.store_data() + # if action is not None: + # if action.removeAnnot: + # self.store_data() + # posData.frame_i -= 1 + # self.get_data() + # if lineage_tree_present: + # self.resetLin_tree_future() + # self.resetCcaFuture(posData.frame_i) + # self.next_frame() - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + if get_answer: + return msg.clickedButton == removeAnnotButton + else: + return True - self.logger.info( - f'Swapping assignments (requested at frame n. {posData.frame_i+1}):\n' - f' * Bud ID {budID} --> mother ID {otherMothID}\n' - f' * Bud ID {otherBudID} --> mother ID {mothID}' + def warnFrameNeverVisitedSegmMode(self): + msg = widgets.myMessageBox() + msg.critical( + self, + "Next frame NEVER visited", + 'Next frame was never visited in "Segmentation and Tracking"' + "mode.\n You cannot perform cell cycle analysis on frames" + "where segmentation and/or tracking errors were not" + "checked/corrected.\n\n" + 'Switch to "Segmentation and Tracking" mode ' + "and check/correct next frame,\n" + "before attempting cell cycle analysis again", ) + return False - past_cca_frames = [ - (past_i, self.get_cca_df(frame_i=past_i, return_df=True)) - for past_i in range(posData.frame_i-1, -1, -1) - ] - future_cca_frames = [ - (future_i, self.get_cca_df(frame_i=future_i, return_df=True)) - for future_i in range(posData.frame_i+1, posData.SizeT) - ] - propagation_result = self.cca_workflows.propagate_swap_mothers_assignment( - posData.cca_df, - posData.frame_i, - budID, - otherBudID, - otherMothID, - mothID, - past_cca_frames=past_cca_frames, - future_cca_frames=future_cca_frames, - base_status=self.cca_workflows.base_status(base_cca_dict), - ) - posData.cca_df = propagation_result.current_cca_df - self.store_cca_df() + def warnMotherNotAtLeastOneFrameG1(self, budID, motherID, frame_no_G1): + self.data[self.pos_i] - for frame_i, cca_df_i in propagation_result.updated_cca_dfs_by_frame.items(): - if frame_i == posData.frame_i: - continue - self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) + txt = html_utils.paragraph(f""" + Assigning bud ID {budID} to cell ID {motherID} cannot be + done because cell ID {motherID} is not in G1 at frame n. + {frame_no_G1}.

+ This would result in no G1 phase between previous cell cycle of + cell ID {motherID} and current one. + This is unfortunately not allowed.

+ One possible solution is to annotate division on cell ID + {motherID} on any frame before frame n. {frame_no_G1}.

+ Thank you for your patience! + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Swap mothers not possible", txt) + return + + def warnMotherNotEligible(self, new_mothID, budID, i, why): + if why == "not_G1_in_the_future": + err_msg = html_utils.paragraph(f""" + The requested cell in G1 (ID={new_mothID}) + at future frame {i + 1} has a bud assigned to it, + therefore it cannot be assigned as the mother + of bud ID {budID}.

+ You can assign a cell as the mother of bud ID {budID} + only if this cell is in G1 for the + entire life of the bud.

+ One possible solution is to click on "cancel", go to + frame {i + 1} and assign the bud of cell {new_mothID} + to another cell.\n' + A second solution is to assign bud ID {budID} to cell + {new_mothID} anyway by clicking "Apply".

+ However to ensure correctness of + future assignments Cell-ACDC will delete any cell cycle + information from frame {i + 1} to the end. Therefore, you + will have to visit those frames again.

+ The deletion of cell cycle information + CANNOT BE UNDONE! + Saved data is not changed of course.

+ Apply assignment or cancel process? + """) + applyButton = widgets.okPushButton(isDefault=False) + applyButton.setText("Apply and remove future annotations") + msg = widgets.myMessageBox() + _, applyButton = msg.warning( + self, "Cell not eligible", err_msg, buttonsTexts=("Cancel", applyButton) + ) + cancel = msg.cancel + apply = msg.clickedButton == applyButton + elif why == "not_G1_in_the_past": + err_msg = html_utils.paragraph(f""" + The requested cell in G1 + (ID={new_mothID}) at past frame {i + 1} + has a bud assigned to it, therefore it cannot be + assigned as mother of bud ID {budID}.
+ You can assign a cell as the mother of bud ID {budID} + only if this cell is in G1 for the entire life of the bud.
+ One possible solution is to first go to frame {i + 1} and + assign the bud of cell {new_mothID} to another cell. + """) + msg = widgets.myMessageBox() + msg.warning(self, "Cell not eligible", err_msg) + cancel = msg.cancel + apply = False + elif why == "single_frame_G1_duration": + err_msg = html_utils.paragraph(f""" + Assigning bud ID {budID} to cell ID {new_mothID} would result + in no G1 phase at all between previous cell cycle and + current cell cycle (see frame n. {i + 1}).

+ + The solution is to annotate division on cell ID {new_mothID} + on any frame before the frame number {i + 1}, and then + proceed to correcting the bud assignment.

+ + This will gurantee a G1 duration for the cell {new_mothID} + of at least 1 frame.

+ Thank you for your patience! + """) + msg = widgets.myMessageBox() + msg.warning(self, "Cell not eligible", err_msg) + cancel = msg.cancel + apply = False + return cancel, apply + + def warnScellsGone(self, ScellsIDsGone, frame_i): + msg = widgets.myMessageBox() + text = html_utils.paragraph(f""" + In the next frame the followning cells' IDs in S/G2/M + (highlighted with a yellow contour) will disappear:

+ {ScellsIDsGone}

+ If the cell does not exist you might have deleted it at some point. + If that's the case, then try to go to some previous frames and reset + the cell cycle annotations there (button on the top toolbar).

+ These cells are either buds or mother whose related IDs will not + disappear. This is likely due to cell division happening in + previous frame and the divided bud or mother will be + washed away.

+ If you decide to continue these cells will be automatically + annotated as divided at frame number {frame_i}.

+ Do you want to continue? + """) + _, yesButton, noButton = msg.warning( + self, + 'Cells in "S/G2/M" disappeared!', + text, + buttonsTexts=("Cancel", "Yes", "No"), + ) + return msg.clickedButton == yesButton - self.updateAllImages() \ No newline at end of file + def warnSettingHistoryKnownCellsFirstFrame(self, ID): + txt = html_utils.paragraph(f""" + Cell ID {ID} is a cell that is present since the first + frame.

+ These cells already have history UNKNOWN assigned and the + history status cannot be changed. + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "First frame cells", txt) diff --git a/cellacdc/mixins/combine.py b/cellacdc/mixins/combine.py index b0fb1ee16..073a0a692 100644 --- a/cellacdc/mixins/combine.py +++ b/cellacdc/mixins/combine.py @@ -3,158 +3,190 @@ from __future__ import annotations from typing import List, Dict, Any, Tuple -from dataclasses import dataclass import numpy as np from qtpy.QtCore import QThread, QTimer, QMutex, QWaitCondition from natsort import natsorted -import numpy as np -from cellacdc import core, workers, widgets, html_utils, apps, preprocess, myutils, printl +from cellacdc import core, workers, widgets, html_utils, apps, preprocess, myutils -class CombineView: +class CombineMixin: """Qt-facing adapter for the Combine Channels feature.""" - LEGACY_METHODS = ( - '_setup_vars_combine', - 'combineDialogSaveCombinedData', - 'combineDialogStepsChanged', - 'updateCombineChannelsPreview', - 'viewCombineChannelDataToggled', - 'setupCombiningChannels', - 'combineDialogClosed', - '_combineDialogClosed', - 'combineViewAsSegmSetup', - 'combineChannelsActionTriggered', - 'combineEnqueueCurrentImage', - 'combinePreviewToggled', - 'combinePreviewViewAsSegmToggled', - 'combineCurrentImage', - 'combineZStack', - 'combineAllFrames', - 'combineAllPos', - 'stopCombineWorker', - 'combineWorkerCritical', - 'combineWorkerIsQueueEmpty', - 'combineWorkerPreviewDone', - 'combineWorkerAskLoadChannels', - 'combineWorkerDone', - 'combineWorkerClosed', - 'saveCombinedChannelsWorkerFinished', - 'saveCombineWorkerFinished', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) + """Headless state and helpers for combining channel and image arrays.""" - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + def _combineDialogClosed(self): + self.combineDialog = None def _setup_vars_combine(self): self.combineWorker = None self.combineDialog = None self.combineSegmViewToggle = None - - def combineDialogSaveCombinedData(self, dialog): - posData = self.data[self.pos_i] - - try: - posData.combinedChannelsDataArray() - except TypeError as e: - if 'Not all frames have been processed.' in str(e): - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Not all frames have been processed.
' - 'Please process all frames before saving.' - ) - msg.warning(self, 'Process all data before saving', txt) - return - - helpText = ( - """ - """Headless state and helpers for combining channel and image arrays.""" + def combineAllFrames( + self, + steps: List[Dict[str, Any]] = None, + keep_input_data_type: bool = None, + formula: str = None, + ): + if steps and not keep_input_data_type: + raise ValueError("keep_input_data_type must be set if steps is set") - def initialize_combine_image_data(self, pos_data) -> np.ndarray: - """Initializes pos_data.combine_img_data if not already present.""" - if not hasattr(pos_data, 'combine_img_data'): - from cellacdc import preprocess - pos_data.combine_img_data = preprocess.PreprocessedData( - image_data=np.zeros(pos_data.img_data.shape) + if steps is None: + steps, keep_input_data_type, formula = self.combineDialog.steps( + return_keepInputDataType=True ) - return pos_data.combine_img_data + txt = "Combining all frames..." + self.logger.info(txt) + self.statusBarLabel.setText(txt) - def validate_dimensions(self, ndim: int) -> bool: - """Asserts that image data dimensions are valid for combining (3D or 4D).""" - if ndim not in (3, 4): - raise ValueError('Invalid number of dimensions in img_data.') - return True + selected_channel = core.get_selected_channels(steps) + self.getChData(requ_ch=selected_channel) - def group_processed_data_by_pos( + key = (self.pos_i, None, None) + self.combineWorker.setupJob( + self.data, + steps, + keep_input_data_type, + key, + output_as_segm=self.combineDialog.saveAsSegm(), + formula=formula, + ) + + self.combineWorker.wakeUp() + + def combineAllPos( self, - processed_data: list[np.ndarray], - keys: list[tuple[int, int, int]] - ) -> dict[int, list[tuple[tuple[int, int, int], np.ndarray]]]: - """Groups raw processed preview output arrays by position index.""" - unique_pos = {key[0] for key in keys} - per_pos_data = {pos_i: [] for pos_i in unique_pos} - for key, img in zip(keys, processed_data): - pos_i, frame_i, z_slice = key - per_pos_data[pos_i].append((key, img)) - return per_pos_data + steps: List[Dict[str, Any]] = None, + keep_input_data_type: bool = None, + formula: str = None, + ): + if steps and not keep_input_data_type: + raise ValueError("keep_input_data_type must be set if steps is set") - def update_combine_image_data( + if steps is None: + steps, keep_input_data_type, formula = self.combineDialog.steps( + return_keepInputDataType=True + ) + txt = "Combining all Positions..." + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + selected_channel = core.get_selected_channels(steps) + + for pos_i in range(len(self.data)): + self.getChData(requ_ch=selected_channel, pos_i=pos_i) + + key = (None, None, None) + self.combineWorker.setupJob( + self.data, + steps, + keep_input_data_type, + key, + output_as_segm=self.combineDialog.saveAsSegm(), + formula=formula, + ) + + self.combineWorker.wakeUp() + + def combineChannelsActionTriggered(self): + if self.zProjComboBox is not None: + curr_proj = self.zProjComboBox.currentText() + if curr_proj != "single z-slice": + self.zProjComboBox.setCurrentText("single z-slice") + + if self.switchPlaneCombobox is not None: + depthAxes = self.switchPlaneCombobox.depthAxes() + if depthAxes != "z": + self.switchPlaneCombobox.setCurrentText("xy") + + if self.combineDialog is None: + self.setupCombiningChannels() + self.combineDialog.show() + self.combineDialog.raise_() + self.combineDialog.activateWindow() + self.combineDialog.emitSigPreviewToggled() + + def combineCurrentImage( self, - pos_data, - pos_i_data: list[tuple[tuple[int, int, int], np.ndarray]] + steps: List[Dict[str, Any]] = None, + keep_input_data_type: bool = None, + formula: str = None, ): - """Updates preprocessed combined image data container frames and z-slices.""" - n_dim_img = pos_data.img_data.ndim - self.initialize_combine_image_data(pos_data) - self.validate_dimensions(n_dim_img) - if n_dim_img == 4: - for key, img in pos_i_data: - _, frame_i, z_slice = key - pos_data.combine_img_data[frame_i][z_slice] = img - elif n_dim_img == 3: - for key, img in pos_i_data: - _, frame_i, _ = key - pos_data.combine_img_data[frame_i] = img + if steps and keep_input_data_type is None: + raise ValueError("keep_input_data_type must be set if steps is set") + + if steps is None: + steps, keep_input_data_type, formula = self.combineDialog.steps( + return_keepInputDataType=True + ) + txt = "Combining current image..." + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + selected_channel = core.get_selected_channels(steps) + self.getChData(requ_ch=selected_channel) + + z_slice = self.zSliceScrollBar.sliderPosition() + pos_i = self.pos_i + + key = (pos_i, self.data[pos_i].frame_i, z_slice) + + self.combineWorker.setupJob( + self.data, + steps, + keep_input_data_type, + key, + output_as_segm=self.combineDialog.saveAsSegm(), + formula=formula, + ) + + self.combineWorker.wakeUp() + + def combineDialogClosed(self, window): + QTimer.singleShot(200, self._combineDialogClosed) + + def combineDialogSaveCombinedData(self, dialog): + # here check if all data has been processed? + posData = self.data[self.pos_i] + + try: + posData.combinedChannelsDataArray() + except TypeError as e: + if "Not all frames have been processed." in str(e): + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + "Not all frames have been processed.
" + "Please process all frames before saving." + ) + msg.warning(self, "Process all data before saving", txt) + return + helpText = """ The segm/img file will be saved with a different file name.

Insert a name to append to the end of the new file name. The rest of the name will be the same as the original file base. """ - ) - hintText = 'Insert a name for the combined channels file:' + hintText = "Insert a name for the combined channels file:" basename = posData.basename if self.combineDialog.saveAsSegm(): - ext = '.npz' - hintText = hintText.replace('channels', 'segmentation') - helpText = helpText.replace('channels', 'segmentation') - basename = f'{basename}segm' + ext = ".npz" + hintText = hintText.replace("channels", "segmentation") + helpText = helpText.replace("channels", "segmentation") + basename = f"{basename}segm" else: - ext = '.tif' - + ext = ".tif" + win = apps.filenameDialog( basename=basename, ext=ext, hintText=hintText, - defaultEntry='combined', - helpText=helpText, + defaultEntry="combined", + helpText=helpText, allowEmpty=False, - parent=dialog + parent=dialog, ) win.exec_() if win.cancel: @@ -162,24 +194,25 @@ def update_combine_image_data( appendedText = win.entryText if appendedText: - filename = f'{basename}_{appendedText}{ext}' + filename = f"{basename}_{appendedText}{ext}" else: - filename = f'{basename}{ext}' - + filename = f"{basename}{ext}" + self.progressWin = apps.QDialogWorkerProgress( - title='Saving combined channels(s)', + title="Saving combined channels(s)", parent=self, - pbarDesc='Saving combined channels(s)' + pbarDesc="Saving combined channels(s)", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - - self.statusBarLabel.setText('Saving combined channels...') - + + self.statusBarLabel.setText("Saving combined channels...") + self.saveCombinedChannelsWorker = workers.SaveCombinedChannelsWorker( - self.data, filename, + self.data, + filename, ) - + self.saveCombinedChannelsThread = QThread() self.saveCombinedChannelsWorker.moveToThread(self.saveCombinedChannelsThread) self.saveCombinedChannelsWorker.signals.finished.connect( @@ -189,181 +222,66 @@ def update_combine_image_data( self.saveCombinedChannelsWorker.deleteLater ) self.saveCombinedChannelsThread.finished.connect( - self.saveCombinedChannelsWorker.deleteLater - ) - - self.saveCombinedChannelsWorker.signals.critical.connect( - self.workerCritical + self.saveCombinedChannelsThread.deleteLater ) + + self.saveCombinedChannelsWorker.signals.critical.connect(self.workerCritical) self.saveCombinedChannelsWorker.signals.initProgressBar.connect( self.workerInitProgressbar ) self.saveCombinedChannelsWorker.signals.progressBar.connect( self.workerUpdateProgressbar ) - self.saveCombinedChannelsWorker.signals.progress.connect( - self.workerProgress - ) + self.saveCombinedChannelsWorker.signals.progress.connect(self.workerProgress) self.saveCombinedChannelsWorker.signals.finished.connect( self.saveCombinedChannelsWorkerFinished ) - + self.saveCombinedChannelsThread.started.connect( self.saveCombinedChannelsWorker.run ) - self.saveCombinedChannelsWorker.sigDebugShowImg.connect( - self.preprocessing_view.debugShowImg - ) + self.saveCombinedChannelsWorker.sigDebugShowImg.connect(self.debugShowImg) self.saveCombinedChannelsThread.start() - + def combineDialogStepsChanged(self): - steps, keep_input_data_type, formula = self.combineDialog.steps(return_keepInputDataType=True) + steps, keep_input_data_type, formula = self.combineDialog.steps( + return_keepInputDataType=True + ) if steps is None: - self.logger.warning('Combine channels recipe not initialized yet.') - return - - self.updateCombineChannelsPreview(steps=steps, keep_input_data_type=keep_input_data_type, formula=formula) - - def updateCombineChannelsPreview(self, *args, **kwargs): - force = kwargs.get('force', False) - - if self.combineDialog is None: - return - - if not self.combineDialog.isVisible() and not force: + self.logger.warning("Combine channels recipe not initialized yet.") return - - if not self.combineDialog.previewCheckbox.isChecked() and not force: - return - - if kwargs.get('steps') is None: - steps, keep_input_data_type, formula = self.combineDialog.steps(return_keepInputDataType=True) - else: - steps = kwargs.get('steps') - keep_input_data_type = kwargs.get('keep_input_data_type') - formula = kwargs.get('formula') - if steps is None: - self.logger.warning('Combine channels recipe not initialized yet.') - return - - txt = 'Combining...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - self.combineEnqueueCurrentImage(steps, keep_input_data_type, formula) - - def viewCombineChannelDataToggled(self, checked): - self.img1.setUseCombined(checked) - - if checked: - self.combineViewAsSegmSetup() - else: - self.setImageImg1() + self.updateCombineChannelsPreview( + steps=steps, keep_input_data_type=keep_input_data_type, formula=formula + ) - if self.viewPreprocDataToggle.isChecked(): - self.viewPreprocDataToggle.toggled.disconnect() - self.viewPreprocDataToggle.setChecked(False) - self.viewPreprocDataToggle.toggled.connect( - self.preprocessing_view.viewPreprocDataToggled - ) - - def setupCombiningChannels(self): + def combineEnqueueCurrentImage(self, steps, keep_input_data_type, formula): posData = self.data[self.pos_i] - if self.combineDialog is not None: - self.combineDialog.close() - - ordered_channels = [ch for ch in posData.chNames if ch != self.user_ch_name] - ordered_channels = natsorted(ordered_channels) - ordered_channels = [self.user_ch_name] + ordered_channels - - segmentations = [segm for segm in self.existingSegmEndNames] - segmentations = natsorted(segmentations) - segmentations = ['current segm.'] + segmentations - ordered_channels.extend(segmentations) - - self.combineDialog = apps.CombineChannelsSetupDialogGUI( - ordered_channels, - isTimelapse=posData.SizeT>1, - isZstack=posData.SizeZ>1, - isMultiPos=len(self.data)>1, - df_metadata=posData.metadata_df, - hideOnClosing=True, - parent=self - ) - self.doPreviewPreprocImage = False - self.combineDialog.sigApplyImage.connect( - self.combineCurrentImage - ) - self.combineDialog.sigApplyZstack.connect( - self.combineZStack - ) - self.combineDialog.sigApplyAllFrames.connect( - self.combineAllFrames - ) - self.combineDialog.sigApplyAllPos.connect( - self.combineAllPos - ) - self.combineDialog.sigPreviewToggled.connect( - self.combinePreviewToggled - ) - self.combineDialog.sigSaveAsSegmCheckboxToggled.connect( - self.combinePreviewViewAsSegmToggled - ) - self.combineDialog.sigValuesChanged.connect( - self.combineDialogStepsChanged - ) - self.combineDialog.sigSavePreprocData.connect( - self.combineDialogSaveCombinedData - ) - self.combineDialog.sigClose.connect( - self.combineDialogClosed - ) - if self.combineWorker is not None: - return - - self.combineThread = QThread() - self.combineMutex = QMutex() - self.combineWaitCond = QWaitCondition() - - self.combineWorker = workers.CombineChannelsWorkerGUI( - self.combineMutex, self.combineWaitCond, - logger_func=self.logger.info, - ) - - self.combineWorker.moveToThread(self.combineThread) - self.combineWorker.signals.finished.connect(self.combineThread.quit) - self.combineWorker.signals.finished.connect( - self.combineWorker.deleteLater - ) - self.combineThread.finished.connect(self.combineWorker.deleteLater) + if posData.SizeZ > 1: + z_slice = self.z_slice_index() + else: + z_slice = 0 - self.combineWorker.sigDone.connect(self.combineWorkerDone) - self.combineWorker.sigIsQueueEmpty.connect( - self.combineWorkerIsQueueEmpty + key = (self.pos_i, posData.frame_i, z_slice) + self.combineWorker.enqueue( + self.data, + steps, + key, + keep_input_data_type, + output_as_segm=self.combineDialog.saveAsSegm(), + formula=formula, ) - self.combineWorker.sigPreviewDone.connect(self.combineWorkerPreviewDone) - self.combineWorker.signals.progress.connect(self.workerProgress) - self.combineWorker.signals.critical.connect(self.workerCritical) - self.combineWorker.signals.finished.connect(self.combineWorkerClosed) - self.combineWorker.sigAskLoadChannels.connect( - self.combineWorkerAskLoadChannels - ) - - self.combineThread.started.connect(self.combineWorker.run) - self.combineThread.start() - - self.logger.info('Combine channels worker started.') - - def combineDialogClosed(self, window): - QTimer.singleShot(200, self._combineDialogClosed) - - def _combineDialogClosed(self): - self.combineDialog = None + def combinePreviewToggled(self, checked): + self.viewCombineChannelDataToggle.setChecked(checked) + self.updateCombineChannelsPreview() + + def combinePreviewViewAsSegmToggled(self, checked): + self.updateCombineChannelsPreview() + self.combineViewAsSegmSetup() def combineViewAsSegmSetup(self): if self.combineDialog is None: @@ -374,133 +292,192 @@ def combineViewAsSegmSetup(self): if self.combineSegmViewToggle.isChecked(): self.combineSegmViewToggle.setChecked(False) self.combineSegmViewToggle.setCheckable(False) - + if not self.overlayLabelsButton.isChecked() and combineViewAsSegm: self.overlayLabelsButton.blockSignals(True) self.overlayLabelsButton.setChecked(True) - self.overlayLabels_cb(checked=True, selectedLabelsEndnames=['combined segm.']) + self.overlayLabels_cb( + checked=True, selectedLabelsEndnames=["combined segm."] + ) self.overlayLabelsButton.blockSignals(False) - + if combineViewAsSegm: if not self.combineSegmViewToggle.isChecked(): self.combineSegmViewToggle.setCheckable(True) - + + # reset view to update the overlay labels self.combineSegmViewToggle.setChecked(False) self.combineSegmViewToggle.setChecked(True) self.img1.setUseCombined(False) self.setImageImg1() - def combineChannelsActionTriggered(self): - if self.zProjComboBox is not None: - curr_proj = self.zProjComboBox.currentText() - if curr_proj != 'single z-slice': - self.zProjComboBox.setCurrentText('single z-slice') - - if self.switchPlaneCombobox is not None: - depthAxes = self.switchPlaneCombobox.depthAxes() - if depthAxes != 'z': - self.switchPlaneCombobox.setCurrentText('xy') - - if self.combineDialog is None: - self.setupCombiningChannels() - self.combineDialog.show() - self.combineDialog.raise_() - self.combineDialog.activateWindow() - self.combineDialog.emitSigPreviewToggled() + def combineWorkerAskLoadChannels(self, requ_channels, pos_i): + # spit channels and segm to load + segms_to_load, channels_to_load, current_segm = ( + myutils.separate_fluo_segment_channels(requ_channels) + ) + if pos_i is None: + pos_i = list(range(len(self.data))) + elif not isinstance(pos_i, list): + pos_i = [pos_i] - def combineEnqueueCurrentImage(self, steps, keep_input_data_type, formula): - posData = self.data[self.pos_i] + for i in pos_i: + if channels_to_load: + self.getChData(requ_ch=channels_to_load, pos_i=i) + for segm in segms_to_load: + self.loadOverlayLabelsData(segm, pos_i=i) + self.combineWorker.wake_waitCondLoadFluoChannels() - if posData.SizeZ > 1: - z_slice = self.z_slice_index() + def combineWorkerClosed(self, worker): + self.logger.info("Combine worker stopped.") + + def combineWorkerCritical(self, error): + self.combineDialog.appliedFinished() + self.workerCritical(error) + + def combineWorkerDone( + self, processed_data: List[np.ndarray], keys: List[Tuple[int, int, int]] + ): + self.setStatusBarLabel(log=False) + self.combineDialog.appliedFinished() + + unique_pos = {key[0] for key in keys} + per_pos_data = {pos_i: [] for pos_i in unique_pos} + + for key, img in zip(keys, processed_data): + pos_i, frame_i, z_slice = key + per_pos_data[pos_i].append((key, img)) + + for pos_i in unique_pos: + posData = self.data[pos_i] + if not hasattr(posData, "combine_img_data"): + posData.combine_img_data = preprocess.PreprocessedData( + image_data=np.zeros(posData.img_data.shape) + ) + + n_dim_img = posData.img_data.ndim + + if n_dim_img == 4: + for key, processed_data in per_pos_data[pos_i]: + pos_i, frame_i, z_slice = key + posData.combine_img_data[frame_i][z_slice] = processed_data + if not self.combineDialog.saveAsSegm(): + self.img1.updateMinMaxValuesCombinedData( + self.data, pos_i, frame_i, z_slice + ) + if not self.combineDialog.saveAsSegm(): + self.img1.updateMinMaxValuesCombinedDataProjections( + self.data, pos_i, frame_i + ) + else: + for key, processed_data in per_pos_data[pos_i]: + pos_i, frame_i, z_slice = key + posData.combine_img_data[frame_i] = processed_data + if not self.combineDialog.saveAsSegm(): + self.img1.updateMinMaxValuesCombinedData( + self.data, pos_i, frame_i, z_slice + ) + + if not self.viewCombineChannelDataToggle.isChecked(): + self.viewCombineChannelDataToggle.setChecked(True) + else: + self.setImageImg1() + + def combineWorkerIsQueueEmpty(self, isEmpty: bool): + if isEmpty: + self.combineDialog.appliedFinished() else: - z_slice = 0 - - key = (self.pos_i, posData.frame_i, z_slice) - self.combineWorker.enqueue( - self.data, - steps, - key, - keep_input_data_type, - output_as_segm=self.combineDialog.saveAsSegm(), - formula=formula, - ) - - def combinePreviewToggled(self, checked): - self.viewCombineChannelDataToggle.setChecked(checked) - self.updateCombineChannelsPreview() - - def combinePreviewViewAsSegmToggled(self, checked): - self.updateCombineChannelsPreview() - self.combineViewAsSegmSetup() - - def combineCurrentImage( - self, - steps: List[Dict[str, Any]]=None, - keep_input_data_type:bool=None, - formula: str=None, - ): - if steps and keep_input_data_type is None: - raise ValueError('keep_input_data_type must be set if steps is set') - - if steps is None: - steps, keep_input_data_type, formula = self.combineDialog.steps( - return_keepInputDataType=True + self.combineDialog.setDisabled(True) + self.combineDialog.infoLabel.setText( + "Computing preview...
" + "(Feel free to use Cell-ACDC while waiting)" ) - txt = 'Combining current image...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - selected_channel = core.get_selected_channels(steps) - self.preprocessing_view.getChData(requ_ch=selected_channel) - z_slice = self.zSliceScrollBar.sliderPosition() - pos_i = self.pos_i + def combineWorkerPreviewDone( + self, processed_data: List[np.ndarray], keys: List[Tuple[int, int, int]] + ): + unique_pos = {key[0] for key in keys} + per_pos_data = {pos_i: [] for pos_i in unique_pos} - key = (pos_i, self.data[pos_i].frame_i, z_slice) + for key, img in zip(keys, processed_data): + pos_i, frame_i, z_slice = key + per_pos_data[pos_i].append((key, img)) - self.combineWorker.setupJob( - self.data, - steps, - keep_input_data_type, - key, - output_as_segm=self.combineDialog.saveAsSegm(), - formula=formula, + for pos_i in unique_pos: + posData = self.data[pos_i] + if not hasattr(posData, "combine_img_data"): + posData.combine_img_data = preprocess.PreprocessedData( + image_data=np.zeros(posData.img_data.shape) + ) + + n_dim_img = posData.img_data.ndim + + if n_dim_img == 4: + for key, processed_data in per_pos_data[pos_i]: + pos_i, frame_i, z_slice = key + posData.combine_img_data[frame_i][z_slice] = processed_data + if not self.combineDialog.saveAsSegm(): + self.img1.updateMinMaxValuesCombinedData( + self.data, pos_i, frame_i, z_slice + ) + self.img1.updateMinMaxValuesCombinedDataProjections( + self.data, pos_i, frame_i + ) + elif n_dim_img == 3: + for key, processed_data in per_pos_data[pos_i]: + pos_i, frame_i, z_slice = key + posData.combine_img_data[frame_i] = processed_data + if not self.combineDialog.saveAsSegm(): + self.img1.updateMinMaxValuesCombinedData( + self.data, pos_i, frame_i, z_slice + ) + else: + raise ValueError("Invalid number of dimensions in img_data.") + + posData = self.data[self.pos_i] + curr_pos_i, curr_frame_i, curr_z_slice = ( + self.pos_i, + self.data[self.pos_i].frame_i, + self.z_slice_index(), ) - - self.combineWorker.wakeUp() - + if not self.combineDialog.saveAsSegm(): + self.img1.updateMinMaxValuesCombinedData( + self.data, curr_pos_i, curr_frame_i, curr_z_slice + ) + + self.combineViewAsSegmSetup() + def combineZStack( - self, - steps: List[Dict[str, Any]]=None, - keep_input_data_type:bool=None, - formula: str=None, - ): + self, + steps: List[Dict[str, Any]] = None, + keep_input_data_type: bool = None, + formula: str = None, + ): if self.combineDialog is not None: keep_input_data_type = ( self.combineDialog.keepInputDataTypeToggle.isChecked() ) - + if steps and keep_input_data_type is None: - raise ValueError('keep_input_data_type must be set if steps is set') - + raise ValueError("keep_input_data_type must be set if steps is set") + if steps is None: steps, keep_input_data_type, formula = self.combineDialog.steps( return_keepInputDataType=True ) - txt = 'Combining z-stack...' + txt = "Combining z-stack..." self.statusBarLabel.setText(txt) self.logger.info(txt) - + selected_channel = core.get_selected_channels(steps) - self.preprocessing_view.getChData(requ_ch=selected_channel) + self.getChData(requ_ch=selected_channel) posData = self.data[self.pos_i] key = (self.pos_i, posData.frame_i, None) self.combineWorker.setupJob( - self.data, - steps, + self.data, + steps, keep_input_data_type, key, output_as_segm=self.combineDialog.saveAsSegm(), @@ -508,188 +485,191 @@ def combineZStack( ) self.combineWorker.wakeUp() - - def combineAllFrames(self, - steps: List[Dict[str, Any]]=None, - keep_input_data_type:bool=None, - formula: str=None, - ): - if steps and not keep_input_data_type: - raise ValueError('keep_input_data_type must be set if steps is set') - - if steps is None: - steps, keep_input_data_type, formula = self.combineDialog.steps(return_keepInputDataType=True) - txt = 'Combining all frames...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - selected_channel = core.get_selected_channels(steps) - self.preprocessing_view.getChData(requ_ch=selected_channel) - key = (self.pos_i, None, None) - self.combineWorker.setupJob( - self.data, - steps, - keep_input_data_type, - key, - output_as_segm=self.combineDialog.saveAsSegm(), - formula=formula, - ) + def group_processed_data_by_pos( + self, processed_data: list[np.ndarray], keys: list[tuple[int, int, int]] + ) -> dict[int, list[tuple[tuple[int, int, int], np.ndarray]]]: + """Groups raw processed preview output arrays by position index.""" + unique_pos = {key[0] for key in keys} + per_pos_data = {pos_i: [] for pos_i in unique_pos} + for key, img in zip(keys, processed_data): + pos_i, frame_i, z_slice = key + per_pos_data[pos_i].append((key, img)) + return per_pos_data - self.combineWorker.wakeUp() - - def combineAllPos(self, - steps: List[Dict[str, Any]]=None, - keep_input_data_type:bool=None, - formula: str=None, - ): - if steps and not keep_input_data_type: - raise ValueError('keep_input_data_type must be set if steps is set') - - if steps is None: - steps, keep_input_data_type, formula = self.combineDialog.steps(return_keepInputDataType=True) - txt = 'Combining all Positions...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - selected_channel = core.get_selected_channels(steps) - - for pos_i in range(len(self.data)): - self.preprocessing_view.getChData( - requ_ch=selected_channel, pos_i=pos_i + def initialize_combine_image_data(self, pos_data) -> np.ndarray: + """Initializes pos_data.combine_img_data if not already present.""" + if not hasattr(pos_data, "combine_img_data"): + from cellacdc import preprocess + + pos_data.combine_img_data = preprocess.PreprocessedData( + image_data=np.zeros(pos_data.img_data.shape) ) + return pos_data.combine_img_data - key = (None, None, None) - self.combineWorker.setupJob( - self.data, - steps, - keep_input_data_type, - key, - output_as_segm=self.combineDialog.saveAsSegm(), - formula=formula, + def saveCombineWorkerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.setStatusBarLabel() + self.logger.info("Combined channels saved!") + self.titleLabel.setText("Combined channels saved!", color="w") + + def saveCombinedChannelsWorkerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.setStatusBarLabel() + self.logger.info("Combined channels data saved!") + self.titleLabel.setText("Combined channels data saved!", color="w") + + def setupCombiningChannels(self): + posData = self.data[self.pos_i] + if self.combineDialog is not None: + self.combineDialog.close() + + ordered_channels = [ch for ch in posData.chNames if ch != self.user_ch_name] + ordered_channels = natsorted(ordered_channels) + ordered_channels = [self.user_ch_name] + ordered_channels + + segmentations = [segm for segm in self.existingSegmEndNames] + segmentations = natsorted(segmentations) + segmentations = ["current segm."] + segmentations + # also add segm + ordered_channels.extend(segmentations) + + self.combineDialog = apps.CombineChannelsSetupDialogGUI( + ordered_channels, + isTimelapse=posData.SizeT > 1, + isZstack=posData.SizeZ > 1, + isMultiPos=len(self.data) > 1, + df_metadata=posData.metadata_df, + hideOnClosing=True, + # addApplyButton=True, + parent=self, ) + self.doPreviewPreprocImage = False # to do + self.combineDialog.sigApplyImage.connect(self.combineCurrentImage) + self.combineDialog.sigApplyZstack.connect(self.combineZStack) + self.combineDialog.sigApplyAllFrames.connect(self.combineAllFrames) + self.combineDialog.sigApplyAllPos.connect(self.combineAllPos) + self.combineDialog.sigPreviewToggled.connect(self.combinePreviewToggled) + self.combineDialog.sigSaveAsSegmCheckboxToggled.connect( + self.combinePreviewViewAsSegmToggled + ) + self.combineDialog.sigValuesChanged.connect(self.combineDialogStepsChanged) + self.combineDialog.sigSavePreprocData.connect( + self.combineDialogSaveCombinedData + ) + self.combineDialog.sigClose.connect(self.combineDialogClosed) + + if self.combineWorker is not None: + return + + self.combineThread = QThread() + self.combineMutex = QMutex() + self.combineWaitCond = QWaitCondition() + + self.combineWorker = workers.CombineChannelsWorkerGUI( + self.combineMutex, + self.combineWaitCond, + logger_func=self.logger.info, + # signals=self.signals # what are the singals for gui??? + ) + + self.combineWorker.moveToThread(self.combineThread) + self.combineWorker.signals.finished.connect(self.combineThread.quit) + self.combineWorker.signals.finished.connect(self.combineWorker.deleteLater) + self.combineThread.finished.connect(self.combineWorker.deleteLater) + + self.combineWorker.sigDone.connect(self.combineWorkerDone) + self.combineWorker.sigIsQueueEmpty.connect(self.combineWorkerIsQueueEmpty) + self.combineWorker.sigPreviewDone.connect(self.combineWorkerPreviewDone) + self.combineWorker.signals.progress.connect(self.workerProgress) + self.combineWorker.signals.critical.connect(self.workerCritical) + self.combineWorker.signals.finished.connect(self.combineWorkerClosed) + + self.combineWorker.sigAskLoadChannels.connect(self.combineWorkerAskLoadChannels) + + self.combineThread.started.connect(self.combineWorker.run) + self.combineThread.start() + + self.logger.info("Combine channels worker started.") - self.combineWorker.wakeUp() - def stopCombineWorker(self): - self.logger.info('Closing combine worker...') + self.logger.info("Closing combine worker...") try: self.combineWorker.stop() - except Exception as err: + except Exception: pass - - def combineWorkerCritical(self, error): - self.combineDialog.appliedFinished() - self.workerCritical(error) - def combineWorkerIsQueueEmpty(self, isEmpty: bool): - if isEmpty: - self.combineDialog.appliedFinished() - else: - self.combineDialog.setDisabled(True) - self.combineDialog.infoLabel.setText( - 'Computing preview...
' - '(Feel free to use Cell-ACDC while waiting)' - ) + def updateCombineChannelsPreview(self, *args, **kwargs): + force = kwargs.get("force", False) - def combineWorkerPreviewDone( - self, - processed_data: List[np.ndarray], - keys: List[Tuple[int, int, int]] - ): - per_pos_data = self.group_processed_data_by_pos(processed_data, keys) + if self.combineDialog is None: + return - for pos_i, pos_i_data in per_pos_data.items(): - posData = self.data[pos_i] - self.update_combine_image_data(posData, pos_i_data) - - if not self.combineDialog.saveAsSegm(): - for key, _ in pos_i_data: - _, frame_i, z_slice = key - self.img1.updateMinMaxValuesCombinedData( - self.data, pos_i, frame_i, z_slice - ) - if posData.img_data.ndim == 4: - self.img1.updateMinMaxValuesCombinedDataProjections( - self.data, pos_i, frame_i - ) - - posData = self.data[self.pos_i] - curr_pos_i, curr_frame_i, curr_z_slice = ( - self.pos_i, self.data[self.pos_i].frame_i, self.z_slice_index() - ) - if not self.combineDialog.saveAsSegm(): - self.img1.updateMinMaxValuesCombinedData( - self.data, curr_pos_i, curr_frame_i, curr_z_slice + if not self.combineDialog.isVisible() and not force: + return + + if not self.combineDialog.previewCheckbox.isChecked() and not force: + return + + if kwargs.get("steps") is None: + steps, keep_input_data_type, formula = self.combineDialog.steps( + return_keepInputDataType=True ) - - self.combineViewAsSegmSetup() - - def combineWorkerAskLoadChannels(self, requ_channels, pos_i): - segms_to_load, channels_to_load, current_segm = myutils.separate_fluo_segment_channels(requ_channels) - if pos_i is None: - pos_i = list(range(len(self.data))) - elif not isinstance(pos_i, list): - pos_i = [pos_i] + else: + steps = kwargs.get("steps") + keep_input_data_type = kwargs.get("keep_input_data_type") + formula = kwargs.get("formula") - for i in pos_i: - if channels_to_load: - self.preprocessing_view.getChData( - requ_ch=channels_to_load, pos_i=i - ) - for segm in segms_to_load: - self.loadOverlayLabelsData(segm, pos_i=i) - self.combineWorker.wake_waitCondLoadFluoChannels() - - def combineWorkerDone( - self, - processed_data: List[np.ndarray], - keys: List[Tuple[int, int, int]] - ): - self.status_hover_view.set_status_bar_label(log=False) - self.combineDialog.appliedFinished() + if steps is None: + self.logger.warning("Combine channels recipe not initialized yet.") + return - per_pos_data = self.group_processed_data_by_pos(processed_data, keys) + txt = "Combining..." + self.logger.info(txt) + self.statusBarLabel.setText(txt) - for pos_i, pos_i_data in per_pos_data.items(): - posData = self.data[pos_i] - self.update_combine_image_data(posData, pos_i_data) - - if not self.combineDialog.saveAsSegm(): - for key, _ in pos_i_data: - _, frame_i, z_slice = key - self.img1.updateMinMaxValuesCombinedData( - self.data, pos_i, frame_i, z_slice - ) - if posData.img_data.ndim == 4: - self.img1.updateMinMaxValuesCombinedDataProjections( - self.data, pos_i, frame_i - ) - - if not self.viewCombineChannelDataToggle.isChecked(): - self.viewCombineChannelDataToggle.setChecked(True) - else: - self.setImageImg1() + self.combineEnqueueCurrentImage(steps, keep_input_data_type, formula) - def combineWorkerClosed(self, worker): - self.logger.info('Combine worker stopped.') - - def saveCombinedChannelsWorkerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.status_hover_view.set_status_bar_label() - self.logger.info('Combined channels data saved!') - self.titleLabel.setText('Combined channels data saved!', color='w') + def update_combine_image_data( + self, pos_data, pos_i_data: list[tuple[tuple[int, int, int], np.ndarray]] + ): + """Updates preprocessed combined image data container frames and z-slices.""" + n_dim_img = pos_data.img_data.ndim + self.initialize_combine_image_data(pos_data) + self.validate_dimensions(n_dim_img) - def saveCombineWorkerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.status_hover_view.set_status_bar_label() - self.logger.info('Combined channels saved!') - self.titleLabel.setText('Combined channels saved!', color='w') \ No newline at end of file + if n_dim_img == 4: + for key, img in pos_i_data: + _, frame_i, z_slice = key + pos_data.combine_img_data[frame_i][z_slice] = img + elif n_dim_img == 3: + for key, img in pos_i_data: + _, frame_i, _ = key + pos_data.combine_img_data[frame_i] = img + + def validate_dimensions(self, ndim: int) -> bool: + """Asserts that image data dimensions are valid for combining (3D or 4D).""" + if ndim not in (3, 4): + raise ValueError("Invalid number of dimensions in img_data.") + return True + + def viewCombineChannelDataToggled(self, checked): + self.img1.setUseCombined(checked) + + if checked: + self.combineViewAsSegmSetup() + else: # setimage1 is already called in combineViewAsSegmSetup + self.setImageImg1() + + if self.viewPreprocDataToggle.isChecked(): + self.viewPreprocDataToggle.toggled.disconnect() + self.viewPreprocDataToggle.setChecked(False) + self.viewPreprocDataToggle.toggled.connect(self.viewPreprocDataToggled) diff --git a/cellacdc/mixins/curvature_tools.py b/cellacdc/mixins/curvature_tools.py index d47d02f03..ffc2fafcb 100644 --- a/cellacdc/mixins/curvature_tools.py +++ b/cellacdc/mixins/curvature_tools.py @@ -4,59 +4,41 @@ import numpy as np import pyqtgraph as pg -import numpy as np import skimage.draw import skimage.measure - -class CurvatureToolsView: +class CurvatureToolsMixin: """Qt-facing adapter around curvature tool contracts.""" """Headless spline drawing and label-painting operations.""" - def tangent_brush_polygon( - self, - yx_start, - yx_end, - radius: int | float, - shape: tuple[int, int], - ) -> tuple[np.ndarray, np.ndarray]: - return tangent_brush_polygon(yx_start, yx_end, radius, shape) + # @exec_time - def directional_coords( - self, - alfa_dir: int, - y: int, - x: int, - shape: tuple[int, int], - *, - connectivity: int = 1, - ) -> tuple[list[int], list[int]]: - return directional_coords( - alfa_dir, - y, - x, - shape, - connectivity=connectivity, - ) + # @exec_time - def spline_coords( - self, - xx, - yy, - *, - resolution_space=None, - per: bool = False, - append_first: bool = False, - ): - return spline_coords( - xx, - yy, - resolution_space=resolution_space, - per=per, - append_first=append_first, - ) + def clearCurvItems(self, removeItems=True): + try: + posData = self.data[self.pos_i] + curvItems = zip( + posData.curvPlotItems, posData.curvAnchorsItems, posData.curvHoverItems + ) + for plotItem, curvAnchors, hoverItem in curvItems: + plotItem.setData([], []) + curvAnchors.setData([], []) + hoverItem.setData([], []) + if removeItems: + self.ax1.removeItem(plotItem) + self.ax1.removeItem(curvAnchors) + self.ax1.removeItem(hoverItem) + + if removeItems: + posData.curvPlotItems = [] + posData.curvAnchorsItems = [] + posData.curvHoverItems = [] + except AttributeError: + # traceback.print_exc() + pass def closed_spline_coords( self, @@ -77,70 +59,103 @@ def closed_spline_coords( max_exec_time=max_exec_time, ) - def paint_spline_to_labels( - self, - labels_2d: np.ndarray, - xx_spline, - yy_spline, - label_id: int, - *, - empty_only: bool = True, - ) -> CurvatureLabelPaintResult: - return paint_spline_to_labels( - labels_2d, - xx_spline, - yy_spline, - label_id, - empty_only=empty_only, - ) + def curvToolSplineToObj(self, xxA=None, yyA=None, isRightClick=False): + posData = self.data[self.pos_i] + # Store undo state before modifying stuff + self.storeUndoRedoStates(False, storeOnlyZoom=True) + if isRightClick: + xxS, yyS = self.curvPlotItem.getData() + if xxS is None: + self.setUncheckedAllButtons() + return + self.smoothAutoContWithSpline() - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) + xxS, yyS = self.getClosedSplineCoords() - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) + if self.autoIDcheckbox.isChecked(): + self.setBrushID() + curvToolID = posData.brushID else: - setattr(self.host, name, value) + curvToolID = self.editIDspinbox.value() + posData.brushID = curvToolID - # @exec_time - def getPolygonBrush(self, yxc2, Y, X): - # see https://en.wikipedia.org/wiki/Tangent_lines_to_circles - y1, x1 = self.yPressAx2, self.xPressAx2 - y2, x2 = yxc2 - rr_poly, cc_poly = self.tangent_brush_polygon( - (y1, x1), - (y2, x2), - self.brushSizeSpinbox.value(), - (Y, X), - ) + if curvToolID <= 0: + self.setBrushID() + curvToolID = posData.brushID - self.yPressAx2, self.xPressAx2 = y2, x2 - return rr_poly, cc_poly + lab2D = self.get_2Dlab(posData.lab).copy() + newIDMask = np.zeros(lab2D.shape, bool) + rr, cc = skimage.draw.polygon(yyS, xxS, shape=lab2D.shape) + newIDMask[rr, cc] = True + newIDMask[lab2D != 0] = False + lab2D[newIDMask] = curvToolID + self.set_2Dlab(lab2D) + self.currentLab2D = lab2D - def get_dir_coords(self, alfa_dir, yd, xd, shape, connectivity=1): - return self.directional_coords( + def curvTool_cb(self, checked): + posData = self.data[self.pos_i] + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.curvToolButton) + self.connectLeftClickButtons() + self.hoverLinSpace = np.linspace(0, 1, 1000) + self.curvPlotItem = pg.PlotDataItem(pen=self.newIDs_cpen) + self.curvHoverPlotItem = pg.PlotDataItem(pen=self.oldIDs_cpen) + self.curvAnchors = pg.ScatterPlotItem( + symbol="o", + size=9, + brush=pg.mkBrush((255, 0, 0, 50)), + pen=pg.mkPen((255, 0, 0), width=2), + hoverable=True, + hoverPen=pg.mkPen((255, 0, 0), width=3), + hoverBrush=pg.mkBrush((255, 0, 0)), + tip=None, + ) + self.ax1.addItem(self.curvAnchors) + self.ax1.addItem(self.curvPlotItem) + self.ax1.addItem(self.curvHoverPlotItem) + self.splineHoverON = True + posData.curvPlotItems.append(self.curvPlotItem) + posData.curvAnchorsItems.append(self.curvAnchors) + posData.curvHoverItems.append(self.curvHoverPlotItem) + else: + self.splineHoverON = False + self.isRightClickDragImg1 = False + self.clearCurvItems() + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + self.showEditIDwidgets(checked) + + def directional_coords( + self, + alfa_dir: int, + y: int, + x: int, + shape: tuple[int, int], + *, + connectivity: int = 1, + ) -> tuple[list[int], list[int]]: + return directional_coords( alfa_dir, - yd, - xd, + y, + x, shape, connectivity=connectivity, ) def drawAutoContour(self, y2, x2): y1, x1 = self.autoCont_y0, self.autoCont_x0 - Dy = abs(y2-y1) - Dx = abs(x2-x1) + Dy = abs(y2 - y1) + Dx = abs(x2 - x1) edge = self.getDisplayedImg1() if Dy != 0 or Dx != 0: # NOTE: numIter takes care of any lag in mouseMoveEvent numIter = int(round(max((Dy, Dx)))) - alfa = np.arctan2(y1-y2, x2-x1) - base = np.pi/4 - alfa_dir = round((base * round(alfa/base))*180/np.pi) + alfa = np.arctan2(y1 - y2, x2 - x1) + base = np.pi / 4 + alfa_dir = round((base * round(alfa / base)) * 180 / np.pi) for _ in range(numIter): y1, x1 = self.autoCont_y0, self.autoCont_x0 yy, xx = self.get_dir_coords(alfa_dir, y1, x1, edge.shape) @@ -167,146 +182,120 @@ def drawAutoContour(self, y2, x2): except TypeError: pass self.autoCont_y0, self.autoCont_x0 = y, x - # self.smoothAutoContWithSpline() - - def smoothAutoContWithSpline(self, n=3): - try: - xx, yy = self.curvHoverPlotItem.getData() - if xx is None or yy is None: - return - # Downsample by taking every nth coord - xxA, yyA = xx[::n], yy[::n] - rr, cc = skimage.draw.polygon(yyA, xxA) - self.autoContObjMask[rr, cc] = 1 - rp = skimage.measure.regionprops(self.autoContObjMask) - if not rp: - return - obj = rp[0] - cont = self.getObjContours(obj) - xxC, yyC = cont[:,0], cont[:,1] - xxA, yyA = xxC[::n], yyC[::n] - self.xxA_autoCont, self.yyA_autoCont = xxA, yyA - xxS, yyS = self.getSpline(xxA, yyA, per=True, appendFirst=True) - if len(xxS)>0: - self.curvPlotItem.setData(xxS, yyS) - except (TypeError, ValueError): - pass def getClosedSplineCoords(self): xxS, yyS = self.curvPlotItem.getData() + bbox_area = (xxS.max() - xxS.min()) * (yyS.max() - yyS.min()) + if bbox_area < 26_000: + # Using 1000 is fast enough according to profiling + return xxS, yyS + + optimalSpaceSize = self.splineToObjModel.predict(bbox_area, max_exec_time=150) + if optimalSpaceSize >= 1000: + # Using 1000 is fast enough according to model + return xxS, yyS + + if optimalSpaceSize < 100: + # Do not allow a rough spline + optimalSpaceSize = 100 + + # Get spline with optimal space size so that exec time + # or skimage.draw.polygon is less than 150 ms xx, yy = self.curvAnchors.getData() - return self.closed_spline_coords( - xxS, - yyS, - anchor_xx=xx, - anchor_yy=yy, - predictor=self.splineToObjModel, - ) - + resolutionSpace = np.linspace(0, 1, int(optimalSpaceSize)) + xxS, yyS = self.getSpline(xx, yy, resolutionSpace=resolutionSpace, per=True) + return xxS, yyS - def getSpline(self, xx, yy, resolutionSpace=None, per=False, appendFirst=False): - if resolutionSpace is None: - resolutionSpace = self.hoverLinSpace - return self.spline_coords( - xx, - yy, - resolution_space=resolutionSpace, - per=per, - append_first=appendFirst, - ) - - def curvTool_cb(self, checked): - posData = self.data[self.pos_i] - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.curvToolButton) - self.connectLeftClickButtons() - self.hoverLinSpace = np.linspace(0, 1, 1000) - self.curvPlotItem = pg.PlotDataItem(pen=self.newIDs_cpen) - self.curvHoverPlotItem = pg.PlotDataItem(pen=self.oldIDs_cpen) - self.curvAnchors = pg.ScatterPlotItem( - symbol='o', size=9, - brush=pg.mkBrush((255,0,0,50)), - pen=pg.mkPen((255,0,0), width=2), - hoverable=True, hoverPen=pg.mkPen((255,0,0), width=3), - hoverBrush=pg.mkBrush((255,0,0)), tip=None + def getPolygonBrush(self, yxc2, Y, X): + # see https://en.wikipedia.org/wiki/Tangent_lines_to_circles + y1, x1 = self.yPressAx2, self.xPressAx2 + y2, x2 = yxc2 + R = self.brushSizeSpinbox.value() + r = R + + arcsin_den = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + arctan_den = x2 - x1 + if arcsin_den != 0 and arctan_den != 0: + beta = np.arcsin((R - r) / arcsin_den) + gamma = -np.arctan((y2 - y1) / arctan_den) + alpha = gamma - beta + x3 = x1 + r * np.sin(alpha) + y3 = y1 + r * np.cos(alpha) + x4 = x2 + R * np.sin(alpha) + y4 = y2 + R * np.cos(alpha) + + alpha = gamma + beta + x5 = x1 - r * np.sin(alpha) + y5 = y1 - r * np.cos(alpha) + x6 = x2 - R * np.sin(alpha) + y6 = y2 - R * np.cos(alpha) + + rr_poly, cc_poly = skimage.draw.polygon( + [y3, y4, y6, y5], [x3, x4, x6, x5], shape=(Y, X) ) - self.ax1.addItem(self.curvAnchors) - self.ax1.addItem(self.curvPlotItem) - self.ax1.addItem(self.curvHoverPlotItem) - self.splineHoverON = True - posData.curvPlotItems.append(self.curvPlotItem) - posData.curvAnchorsItems.append(self.curvAnchors) - posData.curvHoverItems.append(self.curvHoverPlotItem) else: - self.splineHoverON = False - self.isRightClickDragImg1 = False - self.clearCurvItems() - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - self.showEditIDwidgets(checked) + rr_poly, cc_poly = [], [] - def clearCurvItems(self, removeItems=True): - try: - posData = self.data[self.pos_i] - curvItems = zip(posData.curvPlotItems, - posData.curvAnchorsItems, - posData.curvHoverItems) - for plotItem, curvAnchors, hoverItem in curvItems: - plotItem.setData([], []) - curvAnchors.setData([], []) - hoverItem.setData([], []) - if removeItems: - self.ax1.removeItem(plotItem) - self.ax1.removeItem(curvAnchors) - self.ax1.removeItem(hoverItem) - - if removeItems: - posData.curvPlotItems = [] - posData.curvAnchorsItems = [] - posData.curvHoverItems = [] - except AttributeError: - # traceback.print_exc() - pass + self.yPressAx2, self.xPressAx2 = y2, x2 + return rr_poly, cc_poly - # @exec_time - def curvToolSplineToObj(self, xxA=None, yyA=None, isRightClick=False): - posData = self.data[self.pos_i] - # Store undo state before modifying stuff - self.storeUndoRedoStates(False, storeOnlyZoom=True) + def getSpline(self, xx, yy, resolutionSpace=None, per=False, appendFirst=False): + # Remove duplicates + valid = np.where(np.abs(np.diff(xx)) + np.abs(np.diff(yy)) > 0) + xx = np.r_[xx[valid], xx[-1]] + yy = np.r_[yy[valid], yy[-1]] + if appendFirst: + xx = np.r_[xx, xx[0]] + yy = np.r_[yy, yy[0]] + per = True - if isRightClick: - xxS, yyS = self.curvPlotItem.getData() - if xxS is None: - self.setUncheckedAllButtons() - return - self.smoothAutoContWithSpline() + # Interpolate splice + if resolutionSpace is None: + resolutionSpace = self.hoverLinSpace + k = 2 if len(xx) == 3 else 3 - xxS, yyS = self.getClosedSplineCoords() + try: + tck, u = scipy.interpolate.splprep([xx, yy], s=0, k=k, per=per) + xi, yi = scipy.interpolate.splev(resolutionSpace, tck) + return xi, yi + except (ValueError, TypeError): + # Catch errors where we know why splprep fails + return [], [] - if self.autoIDcheckbox.isChecked(): - self.setBrushID() - curvToolID = posData.brushID + def get_dir_coords(self, alfa_dir, yd, xd, shape, connectivity=1): + h, w = shape + y_above = yd + 1 if yd + 1 < h else yd + y_below = yd - 1 if yd > 0 else yd + x_right = xd + 1 if xd + 1 < w else xd + x_left = xd - 1 if xd > 0 else xd + if alfa_dir == 0: + yy = [y_below, y_below, yd, y_above, y_above] + xx = [xd, x_right, x_right, x_right, xd] + elif alfa_dir == 45: + yy = [y_below, y_below, y_below, yd, y_above] + xx = [x_left, xd, x_right, x_right, x_right] + elif alfa_dir == 90: + yy = [yd, y_below, y_below, y_below, yd] + xx = [x_left, x_left, xd, x_right, x_right] + elif alfa_dir == 135: + yy = [y_above, yd, y_below, y_below, y_below] + xx = [x_left, x_left, x_left, xd, x_right] + elif alfa_dir == -180 or alfa_dir == 180: + yy = [y_above, y_above, yd, y_below, y_below] + xx = [xd, x_left, x_left, x_left, xd] + elif alfa_dir == -135: + yy = [y_below, yd, y_above, y_above, y_above] + xx = [x_left, x_left, x_left, xd, x_right] + elif alfa_dir == -90: + yy = [yd, y_above, y_above, y_above, yd] + xx = [x_left, x_left, xd, x_right, x_right] else: - curvToolID = self.editIDspinbox.value() - posData.brushID = curvToolID - - if curvToolID <= 0: - self.setBrushID() - curvToolID = posData.brushID - - lab2D = self.get_2Dlab(posData.lab).copy() - result = self.paint_spline_to_labels( - lab2D, - xxS, - yyS, - curvToolID, - empty_only=True, - ) - lab2D = result.labels_2d - self.set_2Dlab(lab2D) - self.currentLab2D = lab2D + yy = [y_above, y_above, y_above, yd, y_below] + xx = [x_left, xd, x_right, x_right, x_right] + if connectivity == 1: + return yy[1:4], xx[1:4] + else: + return yy, xx def hoverEventDrawSpline(self, event): x, y = event.pos() @@ -318,10 +307,10 @@ def hoverEventDrawSpline(self, event): if len(xx) < 2: return - if len(hoverAnchors)>0: + if len(hoverAnchors) > 0: xA_hover, yA_hover = hoverAnchors[0].pos() - if xx[0]==xA_hover and yy[0]==yA_hover: - per=True + if xx[0] == xA_hover and yy[0] == yA_hover: + per = True if per: # Append start coords and close spline xx = np.r_[xx, xx[0]] @@ -333,4 +322,70 @@ def hoverEventDrawSpline(self, event): xx = np.r_[xx, x] yy = np.r_[yy, y] xi, yi = self.getSpline(xx, yy, per=per) - self.curvHoverPlotItem.setData(xi, yi) \ No newline at end of file + self.curvHoverPlotItem.setData(xi, yi) + + def paint_spline_to_labels( + self, + labels_2d: np.ndarray, + xx_spline, + yy_spline, + label_id: int, + *, + empty_only: bool = True, + ) -> CurvatureLabelPaintResult: + return paint_spline_to_labels( + labels_2d, + xx_spline, + yy_spline, + label_id, + empty_only=empty_only, + ) + + def smoothAutoContWithSpline(self, n=3): + try: + xx, yy = self.curvHoverPlotItem.getData() + if xx is None or yy is None: + return + # Downsample by taking every nth coord + xxA, yyA = xx[::n], yy[::n] + rr, cc = skimage.draw.polygon(yyA, xxA) + self.autoContObjMask[rr, cc] = 1 + rp = skimage.measure.regionprops(self.autoContObjMask) + if not rp: + return + obj = rp[0] + cont = self.getObjContours(obj) + xxC, yyC = cont[:, 0], cont[:, 1] + xxA, yyA = xxC[::n], yyC[::n] + self.xxA_autoCont, self.yyA_autoCont = xxA, yyA + xxS, yyS = self.getSpline(xxA, yyA, per=True, appendFirst=True) + if len(xxS) > 0: + self.curvPlotItem.setData(xxS, yyS) + except (TypeError, ValueError): + pass + + def spline_coords( + self, + xx, + yy, + *, + resolution_space=None, + per: bool = False, + append_first: bool = False, + ): + return spline_coords( + xx, + yy, + resolution_space=resolution_space, + per=per, + append_first=append_first, + ) + + def tangent_brush_polygon( + self, + yx_start, + yx_end, + radius: int | float, + shape: tuple[int, int], + ) -> tuple[np.ndarray, np.ndarray]: + return tangent_brush_polygon(yx_start, yx_end, radius, shape) diff --git a/cellacdc/mixins/custom_annotations.py b/cellacdc/mixins/custom_annotations.py index 50100d4b6..eebdb4b37 100644 --- a/cellacdc/mixins/custom_annotations.py +++ b/cellacdc/mixins/custom_annotations.py @@ -9,44 +9,42 @@ from collections import defaultdict import pyqtgraph as pg -import os import pandas as pd from qtpy.QtGui import QColor from cellacdc import apps, html_utils, settings_folderpath, widgets -custom_annot_path = os.path.join(settings_folderpath, 'custom_annotations.json') +custom_annot_path = os.path.join(settings_folderpath, "custom_annotations.json") -class CustomAnnotationsView: +class CustomAnnotationsMixin: """Qt-facing adapter around custom annotation buttons and dialogs.""" - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) + """Headless custom annotation table updates.""" - def readSavedCustomAnnot(self): - if os.path.exists(custom_annot_path): - self.logger.info('Loading saved custom annotations...') - tempAnnot = self.read_saved_annotations( - custom_annot_path, logger_func=self.logger.info + def addCustomAnnnotScatterPlot(self, symbolColor, symbol, toolButton): + # Add scatter plot item + symbolColorBrush = [0, 0, 0, 50] + symbolColorBrush[:3] = symbolColor.getRgb()[:3] + scatterPlotItem = widgets.CustomAnnotationScatterPlotItem() + scatterPlotItem.setData( + [], + [], + symbol=symbol, + pxMode=False, + brush=pg.mkBrush(symbolColorBrush), + size=15, + pen=pg.mkPen(width=3, color=symbolColor), + hoverable=True, + hoverBrush=pg.mkBrush(symbolColor), + tip=None, ) + scatterPlotItem.sigHovered.connect(self.customAnnotHovered) + scatterPlotItem.button = toolButton + self.customAnnotDict[toolButton]["scatterPlotItem"] = scatterPlotItem + self.ax1.addItem(scatterPlotItem) - posData = self.data[self.pos_i] - self.savedCustomAnnot = tempAnnot - for pos_i, posData in enumerate(self.data): - self.savedCustomAnnot = { - **self.savedCustomAnnot, **posData.customAnnot - } - def addCustomAnnotButtonAllLoadedPos(self): allPosCustomAnnot = {} for pos_i, posData in enumerate(self.data): @@ -55,60 +53,60 @@ def addCustomAnnotButtonAllLoadedPos(self): for posData in self.data: posData.customAnnot = allPosCustomAnnot - def addCustomAnnotationSavedPos(self, pos_i=None): - if pos_i is None: - pos_i = self.pos_i - - posData = self.data[pos_i] - for name, annotState in posData.customAnnot.items(): - # Check if button is already present and update only annotated IDs - buttons = [b for b in self.customAnnotDict.keys() if b.name==name] - if buttons: - toolButton = buttons[0] - allAnnotedIDs = self.customAnnotDict[toolButton]['annotatedIDs'] - allAnnotedIDs[pos_i] = posData.customAnnotIDs.get(name, {}) - continue + def addCustomAnnotation(self): + self.readSavedCustomAnnot() - try: - symbol = re.findall(r"\'(.+)\'", annotState['symbol'])[0] - except Exception as e: - self.logger.info(traceback.format_exc()) - symbol = 'o' - - symbolColor = QColor(*annotState['symbolColor']) - shortcut = annotState['shortcut'] - if shortcut is not None: - keySequence = widgets.macShortcutToWindows(shortcut) - keySequence = widgets.KeySequenceFromText(keySequence) - else: - keySequence = None - toolTip = self.tooltip(annotState) - keepActive = annotState.get('keepActive', True) - isHideChecked = annotState.get('isHideChecked', True) + self.addAnnotWin = apps.customAnnotationDialog( + self.savedCustomAnnot, parent=self + ) + self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) + self.addAnnotWin.exec_() + if self.addAnnotWin.cancel: + self.logger.info("Custom annotation process cancelled.") + return - toolButton, action = self.addCustomAnnotationButton( - symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked - ) - allPosAnnotIDs = [ - pos.customAnnotIDs.get(name, defaultdict(list)) - for pos in self.data - ] - self.customAnnotDict[toolButton] = { - 'action': action, - 'state': annotState, - 'annotatedIDs': allPosAnnotIDs - } + symbol = self.addAnnotWin.symbol + symbolColor = self.addAnnotWin.state["symbolColor"] + keySequence = self.addAnnotWin.shortcutWidget.widget.keySequence + toolTip = self.addAnnotWin.toolTip + name = self.addAnnotWin.state["name"] + keepActive = self.addAnnotWin.state.get("keepActive", True) + isHideChecked = self.addAnnotWin.state.get("isHideChecked", True) - self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) + proceed = self.checkNameExists(name) + if not proceed: + self.logger.info("Custom annotation process cancelled.") + return + + self.addCustomAnnotationItems( + symbol, + symbolColor, + keySequence, + toolTip, + name, + keepActive, + isHideChecked, + self.addAnnotWin.state, + ) + self.saveCustomAnnot() + self.doCustomAnnotation(0) def addCustomAnnotationButton( - self, symbol, symbolColor, keySequence, toolTip, annotName, - keepActive, isHideChecked - ): + self, + symbol, + symbolColor, + keySequence, + toolTip, + annotName, + keepActive, + isHideChecked, + ): toolButton = widgets.customAnnotToolButton( - symbol, symbolColor, parent=self.host, keepToolActive=keepActive, - isHideChecked=isHideChecked + symbol, + symbolColor, + parent=self, + keepToolActive=keepActive, + isHideChecked=isHideChecked, ) toolButton.setCheckable(True) self.checkableQButtonsGroup.addButton(toolButton) @@ -124,43 +122,30 @@ def addCustomAnnotationButton( action = self.annotateToolbar.addWidget(toolButton) return toolButton, action - def addCustomAnnnotScatterPlot( - self, symbolColor, symbol, toolButton - ): - # Add scatter plot item - symbolColorBrush = [0, 0, 0, 50] - symbolColorBrush[:3] = symbolColor.getRgb()[:3] - scatterPlotItem = widgets.CustomAnnotationScatterPlotItem() - scatterPlotItem.setData( - [], [], symbol=symbol, pxMode=False, - brush=pg.mkBrush(symbolColorBrush), size=15, - pen=pg.mkPen(width=3, color=symbolColor), - hoverable=True, hoverBrush=pg.mkBrush(symbolColor), - tip=None - ) - scatterPlotItem.sigHovered.connect(self.customAnnotHovered) - scatterPlotItem.button = toolButton - self.customAnnotDict[toolButton]['scatterPlotItem'] = scatterPlotItem - self.ax1.addItem(scatterPlotItem) - def addCustomAnnotationItems( - self, symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked, state - ): + self, + symbol, + symbolColor, + keySequence, + toolTip, + name, + keepActive, + isHideChecked, + state, + ): toolButton, action = self.addCustomAnnotationButton( - symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked + symbol, symbolColor, keySequence, toolTip, name, keepActive, isHideChecked ) self.customAnnotDict[toolButton] = { - 'action': action, - 'state': state, - 'annotatedIDs': [defaultdict(list) for _ in range(len(self.data))] + "action": action, + "state": state, + "annotatedIDs": [defaultdict(list) for _ in range(len(self.data))], } # Save custom annotation to cellacdc/temp/custom_annotations.json state_to_save = state.copy() - state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) + state_to_save["symbolColor"] = tuple(symbolColor.getRgb()) self.savedCustomAnnot[name] = state_to_save for posData in self.data: posData.customAnnot[name] = state_to_save @@ -169,228 +154,82 @@ def addCustomAnnotationItems( self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) customAnnotButton = self.customAnnotDict[toolButton] - allPosAnnotatedIDs = customAnnotButton['annotatedIDs'] + allPosAnnotatedIDs = customAnnotButton["annotatedIDs"] # Add 0s column to acdc_df for pos_i, posData in enumerate(self.data): for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] + acdc_df = data_dict["acdc_df"] if acdc_df is None: continue - result = self.ensure_column( - acdc_df, name - ) - data_dict['acdc_df'] = result.dataframe - allPosAnnotatedIDs[pos_i][frame_i].extend( - result.annotated_ids - ) - - if posData.acdc_df is not None: - result = self.ensure_column( - posData.acdc_df, - name, - ) - posData.acdc_df = result.dataframe - allPosAnnotatedIDs[pos_i][frame_i].extend( - result.annotated_ids - ) + if name not in acdc_df.columns: + acdc_df[name] = 0 + else: + acdc_df[name] = acdc_df[name].astype(int) + acdc_df_annot = acdc_df[acdc_df[name] == 1].reset_index() + annot_IDs = acdc_df_annot["Cell_ID"].to_list() + allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs) - def customAnnotHovered(self, scatterPlotItem, points, event): - # Show tool tip when hovering an annotation with annotation name and ID - vb = scatterPlotItem.getViewBox() - if vb is None: - return - if len(points) > 0: - posData = self.data[self.pos_i] - point = points[0] - x, y = point.pos().x(), point.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - vb.setToolTip( - f'Annotation name: {scatterPlotItem.button.name}\n' - f'ID = {ID}' - ) - else: - vb.setToolTip('') - - def loadCustomAnnotations(self): - items = list(self.savedCustomAnnot.keys()) - if len(items) == 0: - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - - """Headless custom annotation table updates.""" - - def read_saved_annotations( - self, - annotations_path: str, - *, - logger_func=None, - ) -> dict: - if not os.path.exists(annotations_path): - return {} - return load.read_json(annotations_path, logger_func=logger_func) - - def tooltip(self, annotation_state: dict) -> str: - return myutils.getCustomAnnotTooltip(annotation_state) - - def ensure_column( - self, - acdc_df: pd.DataFrame, - annotation_name: str, - ) -> CustomAnnotationColumnResult: - return ensure_custom_annotation_column(acdc_df, annotation_name) - - def column_exists( - self, - frame_records, - annotation_name: str, - *, - summary_acdc_df: pd.DataFrame | None = None, - ) -> bool: - return custom_annotation_column_exists( - frame_records, - annotation_name, - summary_acdc_df=summary_acdc_df, - ) - - def drop_column( - self, - acdc_df: pd.DataFrame | None, - annotation_name: str, - ) -> pd.DataFrame | None: - return drop_custom_annotation_column(acdc_df, annotation_name) + if posData.acdc_df is not None: + if name not in posData.acdc_df.columns: + posData.acdc_df[name] = 0 + else: + posData.acdc_df[name] = posData.acdc_df[name].astype(int) + acdc_df_annot = posData.acdc_df[ + posData.acdc_df[name] == 1 + ].reset_index() + annot_IDs = acdc_df_annot["Cell_ID"].to_list() + allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs) - def rename_column( - self, - acdc_df: pd.DataFrame | None, - old_name: str, - new_name: str, - ) -> pd.DataFrame | None: - return rename_custom_annotation_column(acdc_df, old_name, new_name) + def addCustomAnnotationSavedPos(self, pos_i=None): + if pos_i is None: + pos_i = self.pos_i - def remap_ids(self, annotated_ids_by_frame, old_ids, new_ids) -> dict: - return remap_custom_annotation_ids( - annotated_ids_by_frame, - old_ids, - new_ids, - ) + posData = self.data[pos_i] + for name, annotState in posData.customAnnot.items(): + # Check if button is already present and update only annotated IDs + buttons = [b for b in self.customAnnotDict.keys() if b.name == name] + if buttons: + toolButton = buttons[0] + allAnnotedIDs = self.customAnnotDict[toolButton]["annotatedIDs"] + allAnnotedIDs[pos_i] = posData.customAnnotIDs.get(name, {}) + continue - def update_frame( - self, - acdc_df: pd.DataFrame, - annotation_name: str, - annotated_ids, - *, - clicked_id: int = 0, - click_is_active: bool = False, - existing_ids=None, - ) -> CustomAnnotationFrameUpdate: - return update_custom_annotation_frame( - acdc_df, - annotation_name, - annotated_ids, - clicked_id=clicked_id, - click_is_active=click_is_active, - existing_ids=existing_ids, - ) + try: + symbol = re.findall(r"\'(.+)\'", annotState["symbol"])[0] + except Exception: + self.logger.info(traceback.format_exc()) + symbol = "o" - There are no custom annotations saved.

- Click on "Add custom annotation" button to start adding new - annotations. - """) - msg.warning(self.host, 'No annotations saved', txt) - return - - self.selectAnnotWin = widgets.QDialogListbox( - 'Load previously used custom annotation(s)', - 'Select annotations to load:', items, - additionalButtons=('Delete selected annnotations', ), - parent=self.host, multiSelection=True - ) - for button in self.selectAnnotWin._additionalButtons: - button.disconnect() - button.clicked.connect(self.deleteSavedAnnotation) - self.selectAnnotWin.exec_() - if self.selectAnnotWin.cancel: - return - - for selectedAnnotName in self.selectAnnotWin.selectedItemsText: - selectedAnnot = self.savedCustomAnnot[selectedAnnotName] + symbolColor = QColor(*annotState["symbolColor"]) + shortcut = annotState["shortcut"] + if shortcut is not None: + keySequence = widgets.macShortcutToWindows(shortcut) + keySequence = widgets.KeySequenceFromText(keySequence) + else: + keySequence = None + toolTip = myutils.getCustomAnnotTooltip(annotState) + keepActive = annotState.get("keepActive", True) + isHideChecked = annotState.get("isHideChecked", True) - symbol = selectedAnnot['symbol'] - symbol = re.findall(r"\'(.+)\'", symbol)[0] - symbolColor = selectedAnnot['symbolColor'] - symbolColor = pg.mkColor(symbolColor) - keySequence = widgets.KeySequenceFromText(selectedAnnot['shortcut']) - Type = selectedAnnot['type'] - toolTip = ( - f'Name: {selectedAnnotName}\n\n' - f'Type: {Type}\n\n' - f'Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n' - f'Description: {selectedAnnot["description"]}\n\n' - f'Shortcut: "{keySequence}"' + toolButton, action = self.addCustomAnnotationButton( + symbol, + symbolColor, + keySequence, + toolTip, + name, + keepActive, + isHideChecked, ) - keepActive = selectedAnnot['keepActive'] - isHideChecked = selectedAnnot['isHideChecked'] - state = { - 'type': Type, - 'name': selectedAnnotName, - 'symbol': selectedAnnot['symbol'], - 'shortcut': selectedAnnot['shortcut'], - 'description': selectedAnnot["description"], - 'keepActive': keepActive, - 'isHideChecked': isHideChecked, - 'symbolColor': symbolColor + allPosAnnotIDs = [ + pos.customAnnotIDs.get(name, defaultdict(list)) for pos in self.data + ] + self.customAnnotDict[toolButton] = { + "action": action, + "state": annotState, + "annotatedIDs": allPosAnnotIDs, } - self.addCustomAnnotationItems( - symbol, symbolColor, keySequence, toolTip, selectedAnnotName, - keepActive, isHideChecked, state - ) - for pos_i, posData in enumerate(self.data): - posData.customAnnot[selectedAnnotName] = selectedAnnot - - self.saveCustomAnnot() - - def deleteSavedAnnotation(self): - for item in self.selectAnnotWin.listBox.selectedItems(): - name = item.text() - self.savedCustomAnnot.pop(name) - self.deleteSelectedAnnot(self.selectAnnotWin.listBox.selectedItems()) - items = list(self.savedCustomAnnot.keys()) - self.selectAnnotWin.listBox.clear() - self.selectAnnotWin.listBox.addItems(items) - - def addCustomAnnotation(self): - self.readSavedCustomAnnot() - self.addAnnotWin = apps.customAnnotationDialog( - self.savedCustomAnnot, parent=self.host - ) - self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) - self.addAnnotWin.exec_() - if self.addAnnotWin.cancel: - self.logger.info('Custom annotation process cancelled.') - return - - symbol = self.addAnnotWin.symbol - symbolColor = self.addAnnotWin.state['symbolColor'] - keySequence = self.addAnnotWin.shortcutWidget.widget.keySequence - toolTip = self.addAnnotWin.toolTip - name = self.addAnnotWin.state['name'] - keepActive = self.addAnnotWin.state.get('keepActive', True) - isHideChecked = self.addAnnotWin.state.get('isHideChecked', True) - - proceed = self.checkNameExists(name) - if not proceed: - self.logger.info('Custom annotation process cancelled.') - return - - self.addCustomAnnotationItems( - symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked, self.addAnnotWin.state - ) - self.saveCustomAnnot() - self.doCustomAnnotation(0) + self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) def askCustomAnnotationNameExists(self, name): msg = widgets.myMessageBox(wrapText=False) @@ -400,64 +239,80 @@ def askCustomAnnotationNameExists(self, name): If you continue, this column will be used to initialize pre-annotated objects.

Do you want to continue? - """ - ) + """) noButton, yesButton = msg.question( - self.host, 'Custom annotation name already exists', txt, - buttonsTexts=('No, stop process', 'Yes, use existing column') + self, + "Custom annotation name already exists", + txt, + buttonsTexts=("No, stop process", "Yes, use existing column"), ) return msg.clickedButton == yesButton - - + def checkNameExists(self, name): posData = self.data[self.pos_i] - if self.column_exists( - posData.allData_li, - name, - summary_acdc_df=posData.acdc_df, - ): + for frame_i, data_dict in enumerate(posData.allData_li): + acdc_df = data_dict["acdc_df"] + if acdc_df is None: + continue + if name in acdc_df.columns: + return self.askCustomAnnotationNameExists(name) + + if posData.acdc_df is not None and name in posData.acdc_df.columns: return self.askCustomAnnotationNameExists(name) - + return True - - - def viewAllCustomAnnot(self, checked): - if not checked: - # Clear all annotations before showing only checked - for button in self.customAnnotDict.keys(): - self.clearScatterPlotCustomAnnotButton(button) - self.doCustomAnnotation(0) + + def clearCustomAnnot(self): + for button in self.customAnnotDict.keys(): + scatterPlotItem = self.customAnnotDict[button]["scatterPlotItem"] + scatterPlotItem.setData([], []) def clearScatterPlotCustomAnnotButton(self, button): - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + scatterPlotItem = self.customAnnotDict[button]["scatterPlotItem"] scatterPlotItem.setData([], []) - def saveCustomAnnot(self, only_temp=False): - if not hasattr(self, 'savedCustomAnnot'): - return - - if not self.savedCustomAnnot: - return - - # Save to cell acdc temp path - with open(custom_annot_path, mode='w') as file: - json.dump(self.savedCustomAnnot, file, indent=2) + def column_exists( + self, + frame_records, + annotation_name: str, + *, + summary_acdc_df: pd.DataFrame | None = None, + ) -> bool: + return custom_annotation_column_exists( + frame_records, + annotation_name, + summary_acdc_df=summary_acdc_df, + ) - if only_temp: - return - - self.logger.info('Saving custom annotations parameters...') - # Save to pos path - for _posData in self.data: - _posData.saveCustomAnnotationParams() + def customAnnotButtonToggled(self, checked): + if checked: + self.customAnnotButton = self.sender() + # Uncheck the other buttons + for button in self.customAnnotDict.keys(): + if button == self.sender(): + continue - def customAnnotKeepActive(self, button): - self.customAnnotDict[button]['state']['keepActive'] = button.keepToolActive + button.toggled.disconnect() + self.clearScatterPlotCustomAnnotButton(button) + button.setChecked(False) + button.toggled.connect(self.customAnnotButtonToggled) + self.doCustomAnnotation(0) + else: + self.customAnnotButton = None + button = self.sender() + clearAnnotation = ( + button.isHideChecked or not self.viewAllCustomAnnotAction.isChecked() + ) + if clearAnnotation: + self.clearScatterPlotCustomAnnotButton(button) + self.setHighlightID(False) + self.resetCursor() def customAnnotHide(self, button): - self.customAnnotDict[button]['state']['isHideChecked'] = button.isHideChecked + self.customAnnotDict[button]["state"]["isHideChecked"] = button.isHideChecked clearAnnot = ( - not button.isChecked() and button.isHideChecked + not button.isChecked() + and button.isHideChecked and not self.viewAllCustomAnnotAction.isChecked() ) if clearAnnot: @@ -467,13 +322,28 @@ def customAnnotHide(self, button): # User uncheked hide annot with the button not active --> show self.doCustomAnnotation(0) - def deleteSelectedAnnot(self, itemsToDelete): - self.saveCustomAnnot(only_temp=True) + def customAnnotHovered(self, scatterPlotItem, points, event): + # Show tool tip when hovering an annotation with annotation name and ID + vb = scatterPlotItem.getViewBox() + if vb is None: + return + if len(points) > 0: + posData = self.data[self.pos_i] + point = points[0] + x, y = point.pos().x(), point.pos().y() + xdata, ydata = int(x), int(y) + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + vb.setToolTip(f"Annotation name: {scatterPlotItem.button.name}\nID = {ID}") + else: + vb.setToolTip("") + + def customAnnotKeepActive(self, button): + self.customAnnotDict[button]["state"]["keepActive"] = button.keepToolActive def customAnnotModify(self, button): - state = self.customAnnotDict[button]['state'] + state = self.customAnnotDict[button]["state"] self.addAnnotWin = apps.customAnnotationDialog( - self.savedCustomAnnot, parent=self.host, state=state + self.savedCustomAnnot, state=state ) self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) self.addAnnotWin.exec_() @@ -482,50 +352,63 @@ def customAnnotModify(self, button): # Rename column if existing posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] if acdc_df is not None: - old_name = self.customAnnotDict[button]['state']['name'] - new_name = self.addAnnotWin.state['name'] - posData.allData_li[posData.frame_i]['acdc_df'] = ( - self.rename_column( - acdc_df, old_name, new_name - ) - ) + old_name = self.customAnnotDict[button]["state"]["name"] + new_name = self.addAnnotWin.state["name"] + acdc_df = acdc_df.rename(columns={old_name: new_name}) + posData.allData_li[posData.frame_i]["acdc_df"] = acdc_df - self.customAnnotDict[button]['state'] = self.addAnnotWin.state + self.customAnnotDict[button]["state"] = self.addAnnotWin.state - name = self.addAnnotWin.state['name'] + name = self.addAnnotWin.state["name"] state_to_save = self.addAnnotWin.state.copy() - symbolColor = self.addAnnotWin.state['symbolColor'] - state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) + symbolColor = self.addAnnotWin.state["symbolColor"] + state_to_save["symbolColor"] = tuple(symbolColor.getRgb()) self.savedCustomAnnot[name] = self.addAnnotWin.state self.saveCustomAnnot() symbol = self.addAnnotWin.symbol - symbolColor = self.customAnnotDict[button]['state']['symbolColor'] + symbolColor = self.customAnnotDict[button]["state"]["symbolColor"] button.setColor(symbolColor) button.update() symbolColorBrush = [0, 0, 0, 50] symbolColorBrush[:3] = symbolColor.getRgb()[:3] - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + scatterPlotItem = self.customAnnotDict[button]["scatterPlotItem"] xx, yy = scatterPlotItem.getData() if xx is None: xx, yy = [], [] scatterPlotItem.setData( - xx, yy, symbol=symbol, pxMode=False, - brush=pg.mkBrush(symbolColorBrush), size=15, - pen=pg.mkPen(width=3, color=symbolColor) + xx, + yy, + symbol=symbol, + pxMode=False, + brush=pg.mkBrush(symbolColorBrush), + size=15, + pen=pg.mkPen(width=3, color=symbolColor), ) + def deleteSavedAnnotation(self): + for item in self.selectAnnotWin.listBox.selectedItems(): + name = item.text() + self.savedCustomAnnot.pop(name) + self.deleteSelectedAnnot(self.selectAnnotWin.listBox.selectedItems()) + items = list(self.savedCustomAnnot.keys()) + self.selectAnnotWin.listBox.clear() + self.selectAnnotWin.listBox.addItems(items) + + def deleteSelectedAnnot(self, itemsToDelete): + self.saveCustomAnnot(only_temp=True) + def doCustomAnnotation(self, ID): mode = self.modeComboBox.currentText() - if not self.isSnapshot and mode != 'Custom annotations': + if not self.isSnapshot and mode != "Custom annotations": # Do not show annotations if timelapse and mode not annotations return - - if self.switchPlaneCombobox.depthAxes() != 'z': + + if self.switchPlaneCombobox.depthAxes() != "z": return - + # NOTE: pass 0 for ID to not add posData = self.data[self.pos_i] if self.viewAllCustomAnnotAction.isChecked(): @@ -536,53 +419,55 @@ def doCustomAnnotation(self, ID): else: # Annotate if the button is active or isHideChecked is False buttons = [ - b for b in self.customAnnotDict.keys() + b + for b in self.customAnnotDict.keys() if (b.isChecked() or not b.isHideChecked) ] if not buttons: return for button in buttons: - annotatedIDs = ( - self.customAnnotDict[button]['annotatedIDs'][self.pos_i] - ) + annotatedIDs = self.customAnnotDict[button]["annotatedIDs"][self.pos_i] annotIDs_frame_i = annotatedIDs.get(posData.frame_i, []) - state = self.customAnnotDict[button]['state'] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - if acdc_df is None: - self.store_data(autosave=False) - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - - result = self.update_frame( - acdc_df, - state['name'], - annotIDs_frame_i, - clicked_id=ID, - click_is_active=button.isChecked(), - existing_ids=posData.IDs_idxs, - ) + state = self.customAnnotDict[button]["state"] + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] + + if button.isChecked() and ID > 0: + # Annotate only if existing ID and the button is checked + if ID in annotIDs_frame_i: + annotIDs_frame_i.remove(ID) + acdc_df.at[ID, state["name"]] = 0 + elif ID != 0: + annotIDs_frame_i.append(ID) annotPerButton = self.customAnnotDict[button] - allAnnotedIDs = annotPerButton['annotatedIDs'] + allAnnotedIDs = annotPerButton["annotatedIDs"] posAnnotedIDs = allAnnotedIDs[self.pos_i] - posAnnotedIDs[posData.frame_i] = result.annotated_ids - acdc_df = result.dataframe - + posAnnotedIDs[posData.frame_i] = annotIDs_frame_i + + if acdc_df is None: + self.store_data(autosave=False) + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] + xx, yy = [], [] - for annotID in result.present_annotated_ids: + for annotID in annotIDs_frame_i: + if annotID not in posData.IDs_idxs: + continue + obj_idx = posData.IDs_idxs[annotID] obj = posData.rp[obj_idx] + acdc_df.at[annotID, state["name"]] = 1 if not self.isObjVisible(obj.bbox): continue y, x = self.getObjCentroid(obj.centroid) xx.append(x) yy.append(y) - - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + + scatterPlotItem = self.customAnnotDict[button]["scatterPlotItem"] scatterPlotItem.setData(xx, yy) - posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df - + posData.allData_li[posData.frame_i]["acdc_df"] = acdc_df + # if self.highlightedID != 0: # self.highlightedID = 0 # self.setHighlightID(False) @@ -590,6 +475,130 @@ def doCustomAnnotation(self, ID): if buttons: return buttons[0] + def drop_column( + self, + acdc_df: pd.DataFrame | None, + annotation_name: str, + ) -> pd.DataFrame | None: + return drop_custom_annotation_column(acdc_df, annotation_name) + + def ensure_column( + self, + acdc_df: pd.DataFrame, + annotation_name: str, + ) -> CustomAnnotationColumnResult: + return ensure_custom_annotation_column(acdc_df, annotation_name) + + def loadCustomAnnotations(self): + items = list(self.savedCustomAnnot.keys()) + if len(items) == 0: + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + There are no custom annotations saved.

+ Click on "Add custom annotation" button to start adding new + annotations. + """) + msg.warning(self, "No annotations saved", txt) + return + + self.selectAnnotWin = widgets.QDialogListbox( + "Load previously used custom annotation(s)", + "Select annotations to load:", + items, + additionalButtons=("Delete selected annnotations",), + parent=self, + multiSelection=True, + ) + for button in self.selectAnnotWin._additionalButtons: + button.disconnect() + button.clicked.connect(self.deleteSavedAnnotation) + self.selectAnnotWin.exec_() + if self.selectAnnotWin.cancel: + return + + for selectedAnnotName in self.selectAnnotWin.selectedItemsText: + selectedAnnot = self.savedCustomAnnot[selectedAnnotName] + + symbol = selectedAnnot["symbol"] + symbol = re.findall(r"\'(.+)\'", symbol)[0] + symbolColor = selectedAnnot["symbolColor"] + symbolColor = pg.mkColor(symbolColor) + keySequence = widgets.KeySequenceFromText(selectedAnnot["shortcut"]) + Type = selectedAnnot["type"] + toolTip = ( + f"Name: {selectedAnnotName}\n\n" + f"Type: {Type}\n\n" + f"Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n" + f"Description: {selectedAnnot['description']}\n\n" + f'Shortcut: "{keySequence}"' + ) + keepActive = selectedAnnot["keepActive"] + isHideChecked = selectedAnnot["isHideChecked"] + state = { + "type": Type, + "name": selectedAnnotName, + "symbol": selectedAnnot["symbol"], + "shortcut": selectedAnnot["shortcut"], + "description": selectedAnnot["description"], + "keepActive": keepActive, + "isHideChecked": isHideChecked, + "symbolColor": symbolColor, + } + self.addCustomAnnotationItems( + symbol, + symbolColor, + keySequence, + toolTip, + selectedAnnotName, + keepActive, + isHideChecked, + state, + ) + for pos_i, posData in enumerate(self.data): + posData.customAnnot[selectedAnnotName] = selectedAnnot + + self.saveCustomAnnot() + + def readSavedCustomAnnot(self): + tempAnnot = {} + if os.path.exists(custom_annot_path): + self.logger.info("Loading saved custom annotations...") + tempAnnot = load.read_json(custom_annot_path, logger_func=self.logger.info) + + posData = self.data[self.pos_i] + self.savedCustomAnnot = tempAnnot + for pos_i, posData in enumerate(self.data): + self.savedCustomAnnot = {**self.savedCustomAnnot, **posData.customAnnot} + + def read_saved_annotations( + self, + annotations_path: str, + *, + logger_func=None, + ) -> dict: + if not os.path.exists(annotations_path): + return {} + return load.read_json(annotations_path, logger_func=logger_func) + + def reinitCustomAnnot(self): + buttons = list(self.customAnnotDict.keys()) + for button in buttons: + self.clearScatterPlotCustomAnnotButton(button) + action = self.customAnnotDict[button]["action"] + self.annotateToolbar.removeAction(action) + self.checkableQButtonsGroup.removeButton(button) + self.customAnnotDict.pop(button) + # self.savedCustomAnnot.pop(name) + + self.saveCustomAnnot(only_temp=True) + + def remap_ids(self, annotated_ids_by_frame, old_ids, new_ids) -> dict: + return remap_custom_annotation_ids( + annotated_ids_by_frame, + old_ids, + new_ids, + ) + def removeCustomAnnotButton(self, button, askHow=True, save=True): if askHow: msg = widgets.myMessageBox() @@ -598,51 +607,48 @@ def removeCustomAnnotButton(self, button, askHow=True, save=True): only the annotation button?
""") _, removeOnlyButton, removeColButton = msg.question( - self.host, 'Remove only button?', txt, + self, + "Remove only button?", + txt, buttonsTexts=( - 'Cancel', 'Remove only button', - ' Remove also column with annotations ' - ) + "Cancel", + "Remove only button", + " Remove also column with annotations ", + ), ) if msg.cancel: return removeOnlyButton = msg.clickedButton == removeOnlyButton else: removeOnlyButton = True - - name = self.customAnnotDict[button]['state']['name'] + + name = self.customAnnotDict[button]["state"]["name"] # remove annotation from position for posData in self.data: try: posData.customAnnot.pop(name) posData.saveCustomAnnotationParams() - except KeyError as e: + except KeyError: # Current pos doesn't have any annotation button. Continue continue if posData.acdc_df is None: continue - + if removeOnlyButton: continue - posData.acdc_df = self.drop_column( - posData.acdc_df, - name, - ) + posData.acdc_df = posData.acdc_df.drop(columns=name, errors="ignore") for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] + acdc_df = data_dict["acdc_df"] if acdc_df is None: continue - posData.allData_li[frame_i]['acdc_df'] = ( - self.drop_column( - acdc_df, name - ) - ) + acdc_df = acdc_df.drop(columns=name, errors="ignore") + posData.allData_li[frame_i]["acdc_df"] = acdc_df self.clearScatterPlotCustomAnnotButton(button) - action = self.customAnnotDict[button]['action'] + action = self.customAnnotDict[button]["action"] self.annotateToolbar.removeAction(action) self.checkableQButtonsGroup.removeButton(button) self.customAnnotDict.pop(button) @@ -650,44 +656,58 @@ def removeCustomAnnotButton(self, button, askHow=True, save=True): self.saveCustomAnnot(only_temp=True) - def reinitCustomAnnot(self): - buttons = list(self.customAnnotDict.keys()) - for button in buttons: - self.clearScatterPlotCustomAnnotButton(button) - action = self.customAnnotDict[button]['action'] - self.annotateToolbar.removeAction(action) - self.checkableQButtonsGroup.removeButton(button) - self.customAnnotDict.pop(button) - # self.savedCustomAnnot.pop(name) + def rename_column( + self, + acdc_df: pd.DataFrame | None, + old_name: str, + new_name: str, + ) -> pd.DataFrame | None: + return rename_custom_annotation_column(acdc_df, old_name, new_name) - self.saveCustomAnnot(only_temp=True) + def saveCustomAnnot(self, only_temp=False): + if not hasattr(self, "savedCustomAnnot"): + return - def clearCustomAnnot(self): - for button in self.customAnnotDict.keys(): - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] - scatterPlotItem.setData([], []) + if not self.savedCustomAnnot: + return - def customAnnotButtonToggled(self, checked): - if checked: - self.customAnnotButton = self.sender() - # Uncheck the other buttons - for button in self.customAnnotDict.keys(): - if button == self.sender(): - continue + # Save to cell acdc temp path + with open(custom_annot_path, mode="w") as file: + json.dump(self.savedCustomAnnot, file, indent=2) - button.toggled.disconnect() - self.clearScatterPlotCustomAnnotButton(button) - button.setChecked(False) - button.toggled.connect(self.customAnnotButtonToggled) - self.doCustomAnnotation(0) - else: - self.customAnnotButton = None - button = self.sender() - clearAnnotation = ( - button.isHideChecked - or not self.viewAllCustomAnnotAction.isChecked() - ) - if clearAnnotation: + if only_temp: + return + + self.logger.info("Saving custom annotations parameters...") + # Save to pos path + for _posData in self.data: + _posData.saveCustomAnnotationParams() + + def tooltip(self, annotation_state: dict) -> str: + return myutils.getCustomAnnotTooltip(annotation_state) + + def update_frame( + self, + acdc_df: pd.DataFrame, + annotation_name: str, + annotated_ids, + *, + clicked_id: int = 0, + click_is_active: bool = False, + existing_ids=None, + ) -> CustomAnnotationFrameUpdate: + return update_custom_annotation_frame( + acdc_df, + annotation_name, + annotated_ids, + clicked_id=clicked_id, + click_is_active=click_is_active, + existing_ids=existing_ids, + ) + + def viewAllCustomAnnot(self, checked): + if not checked: + # Clear all annotations before showing only checked + for button in self.customAnnotDict.keys(): self.clearScatterPlotCustomAnnotButton(button) - self.setHighlightID(False) - self.resetCursor() \ No newline at end of file + self.doCustomAnnotation(0) diff --git a/cellacdc/mixins/data_loading.py b/cellacdc/mixins/data_loading.py index 276ba555d..bd51c8eac 100644 --- a/cellacdc/mixins/data_loading.py +++ b/cellacdc/mixins/data_loading.py @@ -11,13 +11,8 @@ import pandas as pd import psutil import skimage -import os -from dataclasses import dataclass from datetime import datetime import cv2 -import numpy as np -import pandas as pd -import skimage import skimage.color import skimage.io from natsort import natsorted @@ -44,422 +39,500 @@ GREEN_HEX = _palettes.green() -class DataLoadingView: +class DataLoadingMixin: """Qt-facing adapter for data loading and recovery workflows.""" - LEGACY_METHODS = ( - 'reload_cb', - 'getFileExtensions', - 'zSliceAbsent', - '_workerDebug', - 'warnMemoryNotSufficient', - 'checkMemoryRequirements', - 'loadPosTriggered', - 'startAutomaticLoadingPos', - 'stopAutomaticLoadingPos', - 'loadNonAlignedFluoChannel', - 'load_fluo_data', - 'initFluoData', - 'getPathFromChName', - 'loadFluo_cb', - 'criticalFluoChannelNotFound', - 'loadSelectedData', - 'startLoadDataWorker', - 'askRecoverNotSavedData', - 'showInfoAutosave', - 'askMismatchSegmDataShape', - 'workerPermissionError', - 'loadDataWorkerDataIntegrityCritical', - 'loadDataWorkerFinished', - 'checkManageVersions', - 'loadingDataCompleted', - 'loadingDataAborted', - 'loadDataWorkerDataIntegrityWarning', - 'openFolder', - 'addToRecentPaths', - 'getMostRecentPath', - '_openFolder', - '_loadFromExperimentFolder', - 'criticalInvalidPosFolder', - 'openFile', - 'askUserChannelName', - 'warnUserCreationImagesFolder', - '_openFile', - 'criticalNoTifFound', - '_createEmptyData', - 'newFile', - 'helpNewFile', - 'criticalImgPathNotFound', - 'openRecentFile', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) + """Headless data-loading rules and path plans.""" - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + @exception_handler + def _createEmptyData(self): + self.MostRecentPath = self.getMostRecentPath() + exp_path = QFileDialog.getExistingDirectory( + self, + "Select experiment folder where to create empty data", + self.MostRecentPath, + ) + if not exp_path: + return - def reload_cb(self): - posData = self.data[self.pos_i] - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - labData = np.load(posData.segm_npz_path) - # Keep compatibility with .npy and .npz files - try: - lab = labData['arr_0'][posData.frame_i] - except Exception as e: - lab = labData[posData.frame_i] - posData.segm_data[posData.frame_i] = lab.copy() - self.get_data() - self.tracking() - self.updateAllImages() + pos_path = os.path.join(exp_path, "Position_1") + images_path = os.path.join(pos_path, "Images") + if os.path.exists(images_path): + raise FileExistsError(f'The following path already exists "{images_path}"') - def getFileExtensions(self, images_path): - alignedFound = any( - f.find('_aligned.np') != -1 - for f in self.workspace.listdir(images_path) - ) - if alignedFound: - extensions = ( - 'Aligned channels (*npz *npy);; Tif channels(*tiff *tif)' - ';;All Files (*)' - ) - else: - extensions = ( - 'Tif channels(*tiff *tif);; All Files (*)' - ) - return extensions + os.makedirs(images_path, exist_ok=True) - def zSliceAbsent(self, filename, posData): - self.app.restoreOverrideCursor() - SizeZ = posData.SizeZ - chNames = posData.chNames - filenamesPresent = posData.segmInfo_df.index.get_level_values(0).unique() - chNamesPresent = [ - ch for ch in chNames - for file in filenamesPresent - if file.endswith(ch) or file.endswith(f'{ch}_aligned') - ] - win = apps.QDialogZsliceAbsent(filename, SizeZ, chNamesPresent) - win.exec_() - if win.cancel: - self.worker.abort = True - self.waitCond.wakeAll() - return - if win.useMiddleSlice: - user_ch_name = filename[len(posData.basename):] - for _posData in self.data: - if _posData is None: - continue - _, filename = self.getPathFromChName(user_ch_name, _posData) - df = myutils.getDefault_SegmInfo_df(_posData, filename) - _posData.segmInfo_df = ( - self.merge_default_segm_info( - _posData.segmInfo_df, df - ) - ) - _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) - elif win.useSameAsCh: - user_ch_name = filename[len(posData.basename):] - for _posData in self.data: - if _posData is None: - continue - _, srcFilename = self.getPathFromChName( - win.selectedChannel, _posData - ) - _, dstFilename = self.getPathFromChName(user_ch_name, _posData) - if dstFilename is None: - self.worker.abort = True - self.waitCond.wakeAll() - return - dst_df = myutils.getDefault_SegmInfo_df(_posData, dstFilename) - _posData.segmInfo_df = ( - self.copy_single_zslice_segm_info( - _posData.segmInfo_df, - dst_df, - src_filename=srcFilename, - dst_filename=dstFilename, - ) - ) - _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) - elif win.runDataPrep: - user_ch_file_paths = [] - user_ch_name = filename[len(self.data[self.pos_i].basename):] - for _posData in self.data: - if _posData is None: - continue - user_ch_path = load.get_filename_from_channel( - _posData.images_path, user_ch_name - ) - if user_ch_path is None: - self.worker.abort = True - self.waitCond.wakeAll() - return - user_ch_file_paths.append(user_ch_path) - exp_path = os.path.dirname(_posData.pos_path) + basename = "test_empty_" + tif_filename = f"{basename}channel_1.tif" + tif_filepath = os.path.join(images_path, tif_filename) + empty_img = np.zeros((256, 256), dtype=np.uint8) + empty_img[0, 0] = 255 + skimage.io.imsave(tif_filepath, empty_img) - dataPrepWin = dataPrep.dataPrepWin() - dataPrepWin.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - dataPrepWin.titleText = ( - """ + metadata_filename = f"{basename}metadata.csv" + metadata_filepath = os.path.join(images_path, metadata_filename) + df_metadata = pd.DataFrame({"Description": ["basename"], "values": [basename]}) + df_metadata.to_csv(metadata_filepath, index=False) - """Headless data-loading rules and path plans.""" + self.isNewFile = True + self._openFolder(exp_path=images_path) - def open_image_file_context( - self, file_path: str, timestamp: str | None = None - ) -> OpenImageFileContext: - filename_no_ext, ext = os.path.splitext(os.path.basename(file_path)) - filename_no_ext = filename_no_ext.rstrip('_') - ext = ext.lower() - dirpath = os.path.dirname(file_path) - dirname = os.path.basename(dirpath) - requires_images_folder = dirname != 'Images' - acdc_folder = None + def _loadFromExperimentFolder(self, exp_path): + select_folder = load.select_exp_folder() + values = select_folder.get_values_segmGUI(exp_path) + if not values: + self.criticalInvalidPosFolder(exp_path) + self.openFolderAction.setEnabled(True) + return [] - if requires_images_folder: - timestamp = timestamp or datetime.now().strftime('%Y%m%d_%H%M%S') - acdc_folder = f'{timestamp}_acdc' - exp_path = os.path.join(dirpath, acdc_folder, 'Images') + if len(values) > 1: + select_folder.QtPrompt(self, values, allow_cancel=False) + if select_folder.cancel: + return [] else: - exp_path = dirpath + select_folder.cancel = False + select_folder.selected_pos = select_folder.pos_foldernames - return OpenImageFileContext( - file_path=file_path, - filename_no_ext=filename_no_ext, - extension=ext, - source_dirpath=dirpath, - source_dirname=dirname, - exp_path=exp_path, - acdc_folder=acdc_folder, - requires_images_folder=requires_images_folder, - ) + images_paths = [] + for pos in select_folder.selected_pos: + images_paths.append(os.path.join(exp_path, pos, "Images")) + return images_paths - def channel_name_suggestion( - self, filename_no_ext: str - ) -> ChannelNameSuggestion: - underscore_splits = filename_no_ext.split('_') - if len(underscore_splits) > 1: - return ChannelNameSuggestion( - basename='_'.join(underscore_splits[:-1]), - channel_name=underscore_splits[-1], - ) + @exception_handler + def _openFile(self, file_path=None): + """ + Function used for loading an image file directly. + """ + if file_path is None: + self.MostRecentPath = self.getMostRecentPath() + file_path = QFileDialog.getOpenFileName( + self, + "Select image file", + self.MostRecentPath, + "Image/Video Files (*.png *.tif *.tiff *.jpg *.jpeg *.mov *.avi *.mp4)" + ";;All Files (*)", + )[0] + if not file_path: + return - return ChannelNameSuggestion( - basename=filename_no_ext, - channel_name='channel_1', - ) + filename, ext = os.path.splitext(os.path.basename(file_path)) + ext = ext.lower() + dirpath = os.path.dirname(file_path) + dirname = os.path.basename(dirpath) + filename = filename.rstrip("_") + channel_name = None + do_copy = True + if dirname != "Images": + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + acdc_folder = f"{timestamp}_acdc" + exp_path = os.path.join(dirpath, acdc_folder, "Images") + proceed, do_copy = self.warnUserCreationImagesFolder(exp_path, ext) + if not proceed: + self.logger.info("Loading image file cancelled.") + return - def open_image_file_target( - self, - context: OpenImageFileContext, - channel_name: str | None = None, - ) -> OpenImageFileTarget: - filename_no_ext = context.filename_no_ext - basename = None - metadata_csv_filename = None - metadata_csv_filepath = None + proceed, channel_name = self.askUserChannelName(filename, ".tif") + if not proceed: + self.logger.info("Loading image file cancelled.") + return + + os.makedirs(exp_path, exist_ok=True) + else: + exp_path = dirpath if channel_name is not None: - underscore_splits = filename_no_ext.split('_') + # Check if user wants to use the existing channel name + underscore_splits = filename.split("_") if len(underscore_splits) > 1: default_ch_name = underscore_splits[-1] if channel_name == default_ch_name: - filename_no_ext = '_'.join(underscore_splits[:-1]) + filename = "_".join(underscore_splits[:-1]) - basename = f'{filename_no_ext}_' - metadata_csv_filename = f'{basename}metadata.csv' - metadata_csv_filepath = os.path.join( - context.exp_path, metadata_csv_filename - ) - new_filename = ( - f'{filename_no_ext}_{channel_name}{context.extension}' + basename = f"{filename}_" + new_filename = f"{filename}_{channel_name}{ext}" + df_metadata = pd.DataFrame( + {"Description": ["basename"], "values": [basename]} ) + metadata_csv_filename = f"{basename}metadata.csv" + metadata_csv_filepath = os.path.join(exp_path, metadata_csv_filename) + df_metadata.to_csv(metadata_csv_filepath, index=False) else: - new_filename = f'{filename_no_ext}{context.extension}' + new_filename = f"{filename}{ext}" - new_filepath = os.path.join(context.exp_path, new_filename) - tif_filename_no_ext = os.path.splitext(new_filename)[0] - tif_filename = f'{tif_filename_no_ext}.tif' - tif_path = os.path.join(context.exp_path, tif_filename) + if do_copy: + action_text = "Copying" + else: + action_text = "Moving" - return OpenImageFileTarget( - context=context, - filename_no_ext=filename_no_ext, - channel_name=channel_name, - basename=basename, - new_filename=new_filename, - new_filepath=new_filepath, - metadata_csv_filename=metadata_csv_filename, - metadata_csv_filepath=metadata_csv_filepath, - tif_filename=tif_filename, - tif_path=tif_path, - direct_copy_supported=context.extension in ('.tif', '.npz'), - ) + if ext == ".tif" or ext == ".npz": + new_filepath = os.path.join(exp_path, new_filename) + if not os.path.exists(new_filepath): + self.logger.info(f"{action_text} file to Images folder...") + if do_copy: + shutil.copy2(file_path, new_filepath) + else: + shutil.move(file_path, new_filepath) + self._openFolder(exp_path=exp_path, imageFilePath=new_filepath) + else: + self.logger.info(f"{action_text} file to .tif format...") + data = load.loadData(file_path, "", log_func=self.logger.info) + data.loadImgData() + img = data.img_data + if img.ndim == 3 and (img.shape[-1] == 3 or img.shape[-1] == 4): + self.logger.info("Converting RGB image to grayscale...") + if img.shape[-1] == 3: + data.img_data = skimage.color.rgb2gray(data.img_data) + else: + data.img_data = cv2.cvtColor(data.img_data, cv2.COLOR_RGBA2GRAY) + data.img_data = skimage.img_as_ubyte(data.img_data) + new_filename_no_ext, ext = os.path.splitext(new_filename) + tif_filename = f"{new_filename_no_ext}.tif" + tif_path = os.path.join(exp_path, tif_filename) + if data.img_data.ndim == 3: + data.img_data.shape[0] + elif data.img_data.ndim == 4: + data.img_data.shape[0] + data.img_data.shape[1] + else: + pass + is_imageJ_dtype = ( + data.img_data.dtype == np.uint8 + or data.img_data.dtype == np.uint32 + or data.img_data.dtype == np.uint32 + or data.img_data.dtype == np.float32 + ) + if not is_imageJ_dtype: + data.img_data = skimage.img_as_ubyte(data.img_data) - def empty_data_plan(self, exp_path: str) -> EmptyDataPlan: - pos_path = os.path.join(exp_path, 'Position_1') - images_path = os.path.join(pos_path, 'Images') - basename = 'test_empty_' - tif_filename = f'{basename}channel_1.tif' - metadata_filename = f'{basename}metadata.csv' + myutils.to_tiff(tif_path, data.img_data) + self._openFolder(exp_path=exp_path, imageFilePath=tif_path) - return EmptyDataPlan( - exp_path=exp_path, - pos_path=pos_path, - images_path=images_path, - basename=basename, - tif_filename=tif_filename, - tif_filepath=os.path.join(images_path, tif_filename), - metadata_filename=metadata_filename, - metadata_filepath=os.path.join(images_path, metadata_filename), - ) + @exception_handler + def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): + """Main function to load data. - def copy_action_text(self, do_copy: bool) -> str: - return 'Copying' if do_copy else 'Moving' + Parameters + ---------- + checked : bool + kwarg needed because openFolder can be called by openFolderAction. + exp_path : string or None + Path selected by the user either directly, through openFile, + or drag and drop image file. + imageFilePath : string + Path of the image file that was either drag and dropped or opened + from File --> Open image/video file (openFileAction). - def is_imagej_dtype(self, dtype: np.dtype) -> bool: - return dtype in (np.uint8, np.uint32, np.float32) + Returns + ------- + None + """ - def prepare_tiff_image_data(self, image: np.ndarray) -> ImageDataPreparation: - converted_rgb_to_gray = False - converted_dtype = False - prepared_image = image + if exp_path is None: + self.MostRecentPath = self.getMostRecentPath() + exp_path = QFileDialog.getExistingDirectory( + self, + "Select experiment folder containing Position_n folders " + "or specific Position_n folder", + self.MostRecentPath, + ) - if ( - prepared_image.ndim == 3 - and (prepared_image.shape[-1] == 3 - or prepared_image.shape[-1] == 4) - ): - converted_rgb_to_gray = True - if prepared_image.shape[-1] == 3: - prepared_image = skimage.color.rgb2gray(prepared_image) - else: - prepared_image = cv2.cvtColor( - prepared_image, cv2.COLOR_RGBA2GRAY - ) - prepared_image = skimage.img_as_ubyte(prepared_image) + if not exp_path: + self.openFolderAction.setEnabled(True) + return - if not self.is_imagej_dtype(prepared_image.dtype): - converted_dtype = True - prepared_image = skimage.img_as_ubyte(prepared_image) + proceed = self.reInitGui() + if not proceed: + self.openFolderAction.setEnabled(True) + return - return ImageDataPreparation( - image=prepared_image, - converted_rgb_to_gray=converted_rgb_to_gray, - converted_dtype=converted_dtype, - ) + self.openFolderAction.setEnabled(False) - def merge_default_segm_info( - self, - existing_df: pd.DataFrame, - default_df: pd.DataFrame, - ) -> pd.DataFrame: - merged_df = pd.concat([default_df, existing_df]) - unique_idx = ~merged_df.index.duplicated() - return merged_df[unique_idx] + if self.slideshowWin is not None: + self.slideshowWin.close() - def copy_single_zslice_segm_info( - self, - existing_df: pd.DataFrame, - default_dst_df: pd.DataFrame, - *, - src_filename: str, - dst_filename: str, - ) -> pd.DataFrame: - dst_df = default_dst_df.copy() - src_df = existing_df.loc[src_filename].copy() + if self.ccaTableWin is not None: + self.ccaTableWin.close() - for z_info in src_df.itertuples(): - frame_i = z_info.Index - if z_info.which_z_proj != 'single z-slice': - continue + self.exp_path = exp_path + self.logger.info(f"Loading from {self.exp_path}") + self.addToRecentPaths(exp_path, logger=self.logger) + self.addPathToOpenRecentMenu(exp_path) - src_idx = (src_filename, frame_i) - if existing_df.at[src_idx, 'resegmented_in_gui']: - col = 'z_slice_used_gui' - else: - col = 'z_slice_used_dataPrep' + folder_type = myutils.determine_folder_type(exp_path) + is_pos_folder, is_images_folder, exp_path = folder_type - z_slice = existing_df.at[src_idx, col] - dst_idx = (dst_filename, frame_i) - dst_df.at[dst_idx, 'z_slice_used_dataPrep'] = z_slice - dst_df.at[dst_idx, 'z_slice_used_gui'] = z_slice + self.titleLabel.setText("Loading data...", color=self.titleColor) - return self.merge_default_segm_info(existing_df, dst_df) + skip_channels = [] + ch_name_selector = prompts.select_channel_name( + which_channel="segm", allow_abort=False + ) + user_ch_name = None + if not is_pos_folder and not is_images_folder and not imageFilePath: + images_paths = self._loadFromExperimentFolder(exp_path) + if not images_paths: + self.loadingDataAborted() + return - Select z-slice (or projection) for each frame/position.
- Once happy, close the window. - """) - dataPrepWin.show() - dataPrepWin.initLoading() - dataPrepWin.SizeT = self.data[0].SizeT - dataPrepWin.SizeZ = self.data[0].SizeZ - dataPrepWin.metadataAlreadyAsked = True - self.logger.info(f'Loading channel {user_ch_name} data...') - dataPrepWin.loadFiles( - exp_path, user_ch_file_paths, user_ch_name + elif is_pos_folder and not imageFilePath: + pos_foldername = os.path.basename(exp_path) + exp_path = os.path.dirname(exp_path) + images_paths = [os.path.join(exp_path, pos_foldername, "Images")] + + elif is_images_folder and not imageFilePath: + images_paths = [exp_path] + pos_path = os.path.dirname(exp_path) + exp_path = os.path.dirname(pos_path) + + elif imageFilePath: + # images_path = exp_path because called by openFile func + filenames = myutils.listdir(exp_path) + ch_names, basenameNotFound = ch_name_selector.get_available_channels( + filenames, exp_path ) - dataPrepWin.startAction.setDisabled(True) - dataPrepWin.onlySelectingZslice = True + filename = os.path.basename(imageFilePath) + self.ch_names = ch_names + user_ch_name = [ + chName for chName in ch_names if filename.find(chName) != -1 + ][0] + images_paths = [exp_path] + pos_path = os.path.dirname(exp_path) + exp_path = os.path.dirname(pos_path) - loop = QEventLoop(self.host) - dataPrepWin.loop = loop - loop.exec_() + self.images_paths = images_paths - self.waitCond.wakeAll() + # Get info from first position selected + images_path = self.images_paths[0] + filenames = myutils.listdir(images_path) + if ch_name_selector.is_first_call and user_ch_name is None: + ch_names, _ = ch_name_selector.get_available_channels( + filenames, images_path + ) + self.ch_names = ch_names + if not ch_names: + self.openFolderAction.setEnabled(True) + self.criticalNoTifFound(images_path) + return + if len(ch_names) > 1: + CbLabel = "Select channel name to load: " + ch_name_selector.QtPrompt(self, ch_names, CbLabel=CbLabel) + if ch_name_selector.was_aborted: + self.openFolderAction.setEnabled(True) + return + skip_channels.extend( + [ch for ch in ch_names if ch != ch_name_selector.channel_name] + ) + else: + ch_name_selector.channel_name = ch_names[0] + ch_name_selector.setUserChannelName() + user_ch_name = ch_name_selector.user_ch_name + else: + # File opened directly with self.openFile + ch_name_selector.channel_name = user_ch_name + + user_ch_file_paths = [] + not_allowed_ends = ["btrack_tracks.h5"] + for images_path in self.images_paths: + channel_file_path = load.get_filename_from_channel( + images_path, + user_ch_name, + skip_channels=skip_channels, + not_allowed_ends=not_allowed_ends, + logger=self.logger.info, + ) + if not channel_file_path: + self.criticalImgPathNotFound(images_path) + return + user_ch_file_paths.append(channel_file_path) + + ch_name_selector.setUserChannelName() + self.user_ch_name = user_ch_name + self.img1.channelName = user_ch_name + + self.AutoPilotProfile.storeSelectedChannel(self.user_ch_name) + + self.initGlobalAttr() + self.createOverlayContextMenu() + self.createUserChannelNameAction() + self.gui_createOverlayColors() + self.gui_createOverlayItems() + lastRow = self.bottomLeftLayout.rowCount() + self.bottomLeftLayout.setRowStretch(lastRow + 1, 1) + + self.num_pos = len(user_ch_file_paths) + proceed = self.loadSelectedData(user_ch_file_paths, user_ch_name) + if not proceed: + self.openFolderAction.setEnabled(True) + return def _workerDebug(self, stuff_to_debug): pass - # from acdctools.plot import imshow - # lab, frame_i, autoBkgr_masks = stuff_to_debug - # autoBkgr_mask, autoBkgr_mask_proj = autoBkgr_masks - # imshow(lab, autoBkgr_mask) - # self.worker.waitCond.wakeAll() - def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): - total_ram = self.formatting.bytes_to_gb(total_ram) - available_ram = self.formatting.bytes_to_gb(available_ram) - required_ram = self.formatting.bytes_to_gb(required_ram) - required_perc = round(100*required_ram/available_ram) - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The total amount of data that you requested to load is about - {required_ram:.2f} GB ({required_perc}% of the available memory) - but there are only {available_ram:.2f} GB available.

- For optimal operation, we recommend loading maximum 30% - of the available memory. To do so, try to close open apps to - free up some memory. Another option is to crop the images - using the data prep module.

- If you choose to continue, the system might freeze - or your OS could simply kill the process.

- What do you want to do? + def addToRecentPaths(self, path, logger=None): + myutils.addToRecentPaths(path, logger=self.logger) + + def askMismatchSegmDataShape(self, posData): + msg = widgets.myMessageBox(wrapText=False) + title = "Segm. data shape mismatch" + f = "3D" if self.isSegm3D else "2D" + f = f"{f} over time" if posData.SizeT > 1 else f + r = "2D" if self.isSegm3D else "3D" + r = f"{r} over time" if posData.SizeT > 1 else r + text = html_utils.paragraph(f""" + The segmentation masks of the first Position that you loaded is + {f},
+ while {posData.pos_foldername} is {r}.

+ The loaded segmentation masks must be either all 3D + or all 2D.

+ Do you want to skip loading this position or cancel the process? """) - cancelButton, continueButton = msg.warning( - self.host, 'Memory not sufficient', txt, - buttonsTexts=('Cancel', 'Continue anyway') + _, skipPosButton = msg.warning( + self, title, text, buttonsTexts=("Cancel", "Skip this Position") ) - if msg.clickedButton == continueButton: - # Disable autosaving since it would keep a copy of the data and - # we cannot afford it with low memory - self.autoSaveToggle.setChecked(False) - return True + if skipPosButton == msg.clickedButton: + self.loadDataWorker.skipPos = True + self.loadDataWorker.waitCond.wakeAll() + + def askRecoverNotSavedData(self, posData): + last_modified_time_unsaved = "NEVER" + if os.path.exists(posData.segm_npz_temp_path): + if os.path.exists(posData.segm_npz_path): + last_modified_time_unsaved = datetime.fromtimestamp( + os.path.getmtime(posData.segm_npz_path) + ).strftime("%a %d. %b. %y - %H:%M:%S") else: - return False + posData.setTempPaths() + if os.path.exists(posData.unsaved_acdc_df_autosave_path): + zip_path = posData.unsaved_acdc_df_autosave_path + with zipfile.ZipFile(zip_path, mode="r") as zip: + csv_names = natsorted(set(zip.namelist())) + iso_key = csv_names[-1][:-4] + most_recent_unsaved_acdc_df_datetime = datetime.strptime( + iso_key, load.ISO_TIMESTAMP_FORMAT + ) + last_modified_time_unsaved = ( + most_recent_unsaved_acdc_df_datetime + ).strftime("%a %d. %b. %y - %H:%M:%S") + + if os.path.exists(posData.acdc_output_csv_path): + acdc_df_mtime = os.path.getmtime(posData.acdc_output_csv_path) + timestamp = datetime.fromtimestamp(acdc_df_mtime) + last_modified_time_saved = timestamp.strftime("%a %d. %b. %y - %H:%M:%S") + else: + last_modified_time_saved = "Null" + + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = html_utils.paragraph(""" + Cell-ACDC detected unsaved data.

+ Do you want to load and recover the unsaved data or + load the data that was last saved by the user? + """) + details = f""" + The unsaved data was created on {last_modified_time_unsaved}\n\n + The user saved the data last time on {last_modified_time_saved} + """ + msg.setDetailedText(details) + loadUnsavedButton = widgets.reloadPushButton("Recover unsaved data") + loadSavedButton = widgets.savePushButton("Load saved data") + infoButton = widgets.infoPushButton("More info...") + loadSafeNpzButton = "" + if posData.isSafeNpzOverwritePresent(): + loadSafeNpzButton = widgets.reloadPushButton( + "Load .safe.npz file from crash" + ) + buttons = ( + loadSavedButton, + loadUnsavedButton, + loadSafeNpzButton, + infoButton, + ) + else: + buttons = (loadSavedButton, loadUnsavedButton, infoButton) + msg.question( + self.progressWin, + "Recover unsaved data?", + txt, + buttonsTexts=("Cancel", *buttons), + showDialog=False, + ) + infoButton.disconnect() + infoButton.clicked.connect(partial(self.showInfoAutosave, posData)) + msg.exec_() + if msg.cancel: + self.loadDataWorker.abort = True + elif msg.clickedButton == loadUnsavedButton: + self.loadDataWorker.loadUnsaved = True + elif msg.clickedButton == loadSafeNpzButton: + self.loadDataWorker.loadSafeOverwriteNpz = True + + self.loadDataWorker.waitCond.wakeAll() + + def askUserChannelName(self, filename_no_ext, ext): + help_txt = html_utils.paragraph(""" + Cell-ACDC requires that every image file has a basename and some + additional text, typically the channel name.

+ The basename will be common to all created files, while the additional text is used to identify the image files. + """) + + basename = filename_no_ext + underscore_splits = filename_no_ext.split("_") + if len(underscore_splits) > 1: + channel_name = underscore_splits[-1] + basename = "_".join(underscore_splits[:-1]) + else: + channel_name = "channel_1" + + txt = html_utils.paragraph(""" + Provide some text (e.g., the channel name) to append at the end of the image file. + """) + win = apps.filenameDialog( + basename=basename, + ext=ext, + hintText=txt, + defaultEntry=channel_name, + helpText=help_txt, + allowEmpty=False, + parent=self, + title="Provide channel name for image file", + ) + win.exec_() + if win.cancel: + return False, "" + + return True, win.entryText + + def channel_name_suggestion(self, filename_no_ext: str) -> ChannelNameSuggestion: + underscore_splits = filename_no_ext.split("_") + if len(underscore_splits) > 1: + return ChannelNameSuggestion( + basename="_".join(underscore_splits[:-1]), + channel_name=underscore_splits[-1], + ) + + return ChannelNameSuggestion( + basename=filename_no_ext, + channel_name="channel_1", + ) + + def checkManageVersions(self): + posData = self.data[self.pos_i] + posData.setTempPaths(createFolder=False) + loaded_acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) + + if os.path.exists(posData.recoveryFolderpath()): + self.manageVersionsAction.setDisabled(False) + self.manageVersionsAction.setToolTip( + f"Load an older version of the `{loaded_acdc_df_filename}` file " + "(table with annotations and measurements)." + ) + else: + self.manageVersionsAction.setDisabled(True) def checkMemoryRequirements(self, required_ram): memory = psutil.virtual_memory() total_ram = memory.total available_ram = memory.available - if required_ram/available_ram > 0.3: + if required_ram / available_ram > 0.3: proceed = self.warnMemoryNotSufficient( total_ram, available_ram, required_ram ) @@ -467,171 +540,312 @@ def checkMemoryRequirements(self, required_ram): else: return True - def loadPosTriggered(self): - if not self.isDataLoaded: - return + def copy_action_text(self, do_copy: bool) -> str: + return "Copying" if do_copy else "Moving" - self.startAutomaticLoadingPos() + def copy_single_zslice_segm_info( + self, + existing_df: pd.DataFrame, + default_dst_df: pd.DataFrame, + *, + src_filename: str, + dst_filename: str, + ) -> pd.DataFrame: + dst_df = default_dst_df.copy() + src_df = existing_df.loc[src_filename].copy() - def startAutomaticLoadingPos(self): - self.AutoPilot = autopilot.AutoPilot(self.host) - self.AutoPilot.execLoadPos() + for z_info in src_df.itertuples(): + frame_i = z_info.Index + if z_info.which_z_proj != "single z-slice": + continue - def stopAutomaticLoadingPos(self): - if self.AutoPilot is None: - return + src_idx = (src_filename, frame_i) + if existing_df.at[src_idx, "resegmented_in_gui"]: + col = "z_slice_used_gui" + else: + col = "z_slice_used_dataPrep" - if self.AutoPilot.timer.isActive(): - self.AutoPilot.timer.stop() - self.AutoPilot = None + z_slice = existing_df.at[src_idx, col] + dst_idx = (dst_filename, frame_i) + dst_df.at[dst_idx, "z_slice_used_dataPrep"] = z_slice + dst_df.at[dst_idx, "z_slice_used_gui"] = z_slice - def loadNonAlignedFluoChannel(self, fluo_path): - posData = self.data[self.pos_i] - if posData.filename.find('aligned') != -1: - filename, _ = os.path.splitext(os.path.basename(fluo_path)) - path = f'.../{posData.pos_foldername}/Images/{filename}_aligned.npz' - msg = widgets.myMessageBox() - msg.critical( - self.host, 'Aligned fluo channel not found!', - 'Aligned data for fluorescence channel not found!\n\n' - f'You loaded aligned data for the cells channel, therefore ' - 'loading NON-aligned fluorescence data is not allowed.\n\n' - 'Run the script "dataPrep.py" to create the following file:\n\n' - f'{path}' - ) - return None - fluo_data = np.squeeze(skimage.io.imread(fluo_path)) - return fluo_data + return self.merge_default_segm_info(existing_df, dst_df) - def load_fluo_data(self, fluo_path, isGuiThread=True): - self.logger.info(f'Loading fluorescence image data from "{fluo_path}"...') - bkgrData = None - posData = self.data[self.pos_i] - # Load overlay frames and align if needed - filename = os.path.basename(fluo_path) - filename_noEXT, ext = os.path.splitext(filename) - if ext == '.npy' or ext == '.npz': - fluo_data = np.load(fluo_path) - try: - fluo_data = np.squeeze(fluo_data['arr_0']) - except Exception as e: - fluo_data = np.squeeze(fluo_data) + def criticalFluoChannelNotFound(self, fluo_ch, posData): + msg = widgets.myMessageBox(showCentered=False) + ls = "\n".join(myutils.listdir(posData.images_path)) + msg.setDetailedText(f"Files present in the {posData.relPath} folder:\n{ls}") + title = "Requested channel data not found!" + txt = html_utils.paragraph( + f"The folder {posData.pos_path} " + "does not contain " + "either one of the following files:

" + f"{posData.basename}{fluo_ch}.tif
" + f"{posData.basename}{fluo_ch}_aligned.npz

" + "Data loading aborted." + ) + msg.addShowInFileManagerButton(posData.images_path) + msg.warning(self, title, txt, buttonsTexts=("Ok")) - # Load background data - bkgrData_path = os.path.join( - posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' - ) - if os.path.exists(bkgrData_path): - bkgrData = np.load(bkgrData_path) - elif ext == '.tif' or ext == '.tiff': - aligned_filename = f'{filename_noEXT}_aligned.npz' - aligned_path = os.path.join(posData.images_path, aligned_filename) - if os.path.exists(aligned_path): - fluo_data = np.load(aligned_path)['arr_0'] + def criticalImgPathNotFound(self, images_path): + self.logger.info( + "The following folder does not contain valid image files: " + f'"{images_path}"\n\n' + "Check that all the positions loaded contain the same channel name. " + "Make sure to double check for spelling mistakes or types in the " + "channel names." + ) + msg = widgets.myMessageBox() + msg.addShowInFileManagerButton(images_path) + err_msg = html_utils.paragraph(f""" + The folder

+ {images_path}

+ does not contain any valid image file!

+ Valid file formats are .h5, .tif, _aligned.h5, _aligned.npz. + """) + msg.critical(self, "No valid files found!", err_msg, buttonsTexts=("Ok",)) - # Load background data - bkgrData_path = os.path.join( - posData.images_path, f'{aligned_filename}_bkgrRoiData.npz' - ) - if os.path.exists(bkgrData_path): - bkgrData = np.load(bkgrData_path) - else: - fluo_data = self.loadNonAlignedFluoChannel(fluo_path) - if fluo_data is None: - return None, None + def criticalInvalidPosFolder(self, exp_path): + href = html_utils.href_tag("here", data_structure_docs_url) + txt = html_utils.paragraph(f""" + The selected folder:

+ + {exp_path}

+ + is not a valid folder.

+ + Select a folder that contains the Position_n folders, + or a specific Position.

+ + If you are trying to load a single image file go to + File --> Open image/video file....

+ + To load a folder containing multiple .tif files the folder must + be called either Position_n
+ (with n being an integer) or Images.

+ + For more information about the correct folder structure see {href}. + """) + msg = widgets.myMessageBox(wrapText=False) + helpButton = widgets.helpPushButton("Help...") + msg.addButton(helpButton) + helpButton.clicked.disconnect() + helpButton.clicked.connect(partial(myutils.browse_url, data_structure_docs_url)) + msg.addShowInFileManagerButton(exp_path) + msg.critical(self, "Incompatible folder", txt) - # Load background data - bkgrData_path = os.path.join( - posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' - ) - if os.path.exists(bkgrData_path): - bkgrData = np.load(bkgrData_path) - elif isGuiThread: - txt = html_utils.paragraph( - f'File format {ext} is not supported!\n' - 'Choose either .tif or .npz files.' + def criticalNoTifFound(self, images_path): + err_title = "No .tif files found in folder." + err_msg = html_utils.paragraph( + "The following folder

" + f"{images_path}

" + "does not contain .tif or .h5 files.

" + 'Only .tif or .h5 files can be loaded with "Open Folder" button.

' + "Try with File --> Open image/video file... " + "and directly select the file you want to load." + ) + msg = widgets.myMessageBox() + msg.addShowInFileManagerButton(images_path) + msg.critical(self, err_title, err_msg) + + def empty_data_plan(self, exp_path: str) -> EmptyDataPlan: + pos_path = os.path.join(exp_path, "Position_1") + images_path = os.path.join(pos_path, "Images") + basename = "test_empty_" + tif_filename = f"{basename}channel_1.tif" + metadata_filename = f"{basename}metadata.csv" + + return EmptyDataPlan( + exp_path=exp_path, + pos_path=pos_path, + images_path=images_path, + basename=basename, + tif_filename=tif_filename, + tif_filepath=os.path.join(images_path, tif_filename), + metadata_filename=metadata_filename, + metadata_filepath=os.path.join(images_path, metadata_filename), + ) + + def getFileExtensions(self, images_path): + alignedFound = any( + [f.find("_aligned.np") != -1 for f in myutils.listdir(images_path)] + ) + if alignedFound: + extensions = ( + "Aligned channels (*npz *npy);; Tif channels(*tiff *tif);;All Files (*)" ) - msg = widgets.myMessageBox() - msg.critical(self.host, 'File not supported', txt) + else: + extensions = "Tif channels(*tiff *tif);; All Files (*)" + return extensions + + def getMostRecentPath(self): + return myutils.getMostRecentPath() + + def getPathFromChName(self, chName, posData): + ls = myutils.listdir(posData.images_path) + endnames = {f[len(posData.basename) :]: f for f in ls} + validEnds = ["_aligned.npz", "_aligned.h5", ".h5", ".tif", ".npz"] + for end in validEnds: + files = [ + filename + for endname, filename in endnames.items() + if endname == f"{chName}{end}" + ] + if files: + filename = files[0] + break + else: + self.criticalFluoChannelNotFound(chName, posData) + self.app.restoreOverrideCursor() return None, None - return fluo_data, bkgrData + fluo_path = os.path.join(posData.images_path, filename) + filename, _ = os.path.splitext(filename) + return fluo_path, filename + + def helpNewFile(self): + msg = widgets.myMessageBox(showCentered=False) + href = f'user manual' + txt = html_utils.paragraph(f""" + Cell-ACDC can open both a single image file or files structured + into Position folders.

+ If you are just testing out you can load a single image file, but + in general we reccommend structuring your data into Position + folders.

+ More info about Position folders in the {href} at the section + called "Create required data structure from microscopy file(s)". + """) + msg.information(self, "Help on Position folders", txt) def initFluoData(self): if len(self.ch_names) <= 1: return - if 'ask_load_fluo_at_init' in self.df_settings.index: - if self.df_settings.at['ask_load_fluo_at_init', 'value'] == 'No': + if "ask_load_fluo_at_init" in self.df_settings.index: + if self.df_settings.at["ask_load_fluo_at_init", "value"] == "No": return msg = widgets.myMessageBox(allowClose=False) txt = ( - 'Do you also want to load fluorescence images?
' - 'You can load as many channels as you want.

' - 'If you load fluorescence images then the software will ' - 'calculate metrics for each loaded fluorescence channel ' - 'such as min, max, mean, quantiles, etc. ' - 'of each segmented object.

' - 'NOTE: You can always load them later from the menu ' - 'File --> Load fluorescence images... or when you set ' - 'measurements from the menu ' - 'Measurements --> Set measurements...' + "Do you also want to load fluorescence images?
" + "You can load as many channels as you want.

" + "If you load fluorescence images then the software will " + "calculate metrics for each loaded fluorescence channel " + "such as min, max, mean, quantiles, etc. " + "of each segmented object.

" + "NOTE: You can always load them later from the menu " + "File --> Load fluorescence images... or when you set " + "measurements from the menu " + "Measurements --> Set measurements..." ) msg.addDoNotShowAgainCheckbox(text="Don't ask again") no, yes = msg.question( - self.host, 'Load fluorescence images?', html_utils.paragraph(txt), - buttonsTexts=('No', 'Yes') + self, + "Load fluorescence images?", + html_utils.paragraph(txt), + buttonsTexts=("No", "Yes"), ) if msg.doNotShowAgainCheckbox.isChecked(): - self.df_settings.at['ask_load_fluo_at_init', 'value'] = 'No' + self.df_settings.at["ask_load_fluo_at_init", "value"] = "No" self.df_settings.to_csv(self.settings_csv_path) if msg.clickedButton == yes: self.loadFluo_cb(None) self.AutoPilotProfile.storeClickMessageBox( - 'Load fluorescence images?', msg.clickedButton.text() + "Load fluorescence images?", msg.clickedButton.text() ) - def getPathFromChName(self, chName, posData): - ls = self.workspace.listdir(posData.images_path) - endnames = {f[len(posData.basename):]:f for f in ls} - validEnds = ['_aligned.npz', '_aligned.h5', '.h5', '.tif', '.npz'] - for end in validEnds: - files = [ - filename for endname, filename in endnames.items() - if endname == f'{chName}{end}' - ] - if files: - filename = files[0] - break - else: - self.criticalFluoChannelNotFound(chName, posData) - self.app.restoreOverrideCursor() - return None, None + def is_imagej_dtype(self, dtype: np.dtype) -> bool: + return dtype in (np.uint8, np.uint32, np.float32) - fluo_path = os.path.join(posData.images_path, filename) - filename, _ = os.path.splitext(filename) - return fluo_path, filename + def loadDataWorkerDataIntegrityCritical(self): + errTitle = "All loaded positions contains frames over time!" + self.titleLabel.setText(errTitle, color="r") + + msg = widgets.myMessageBox(parent=self) + + err_msg = html_utils.paragraph(f""" + {errTitle}.

+ To load data that contains frames over time you have to select + only ONE position. + """) + msg.setIcon(iconName="SP_MessageBoxCritical") + msg.setWindowTitle("Loaded multiple positions with frames!") + msg.addText(err_msg) + msg.addButton("Ok") + msg.show(block=True) + + @exception_handler + def loadDataWorkerDataIntegrityWarning(self, pos_foldername): + err_msg = ( + 'WARNING: Segmentation mask file ("..._segm.npz") not found. ' + "You could run segmentation module first." + ) + self.workerProgress(err_msg, "INFO") + self.titleLabel.setText(err_msg, color="r") + abort = False + msg = widgets.myMessageBox(parent=self) + warn_msg = html_utils.paragraph(f""" + The folder {pos_foldername} does not contain a + pre-computed segmentation mask.

+ You can continue with a blank mask or cancel and + pre-compute the mask with the segmentation module.

+ Do you want to continue? + """) + msg.setIcon(iconName="SP_MessageBoxWarning") + msg.setWindowTitle("Segmentation file not found") + msg.addText(warn_msg) + msg.addButton("Ok") + continueWithBlankSegm = msg.addButton(" Cancel ") + msg.show(block=True) + if continueWithBlankSegm == msg.clickedButton: + abort = True + self.loadDataWorker.abort = abort + self.loadDataWaitCond.wakeAll() + + @exception_handler + def loadDataWorkerFinished(self, data): + self.funcDescription = "loading data worker finished" + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + if data is None or data == "abort": + self.loadingDataAborted() + return + + if data[0].onlyEditMetadata: + self.loadingDataAborted() + return + + self.pos_i = 0 + self.data = data + self.gui_createGraphicsItems() + return True def loadFluo_cb(self, checked=True, fluo_channels=None): if fluo_channels is None: posData = self.data[self.pos_i] ch_names = [ - ch for ch in self.ch_names if ch != self.user_ch_name - and ch not in posData.loadedFluoChannels + ch + for ch in self.ch_names + if ch != self.user_ch_name and ch not in posData.loadedFluoChannels ] if not ch_names: msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'You already loaded ALL channels.

' - 'To change the overlaid channel ' - 'right-click on the overlay button.' + "You already loaded ALL channels.

" + "To change the overlaid channel " + "right-click on the overlay button." ) - msg.information(self.host, 'All channels are loaded', txt) + msg.information(self, "All channels are loaded", txt) return False selectFluo = widgets.QDialogListbox( - 'Select channel to load', - 'Select channel names to load:\n', - ch_names, multiSelection=True, parent=self.host + "Select channel to load", + "Select channel names to load:\n", + ch_names, + multiSelection=True, + parent=self, ) selectFluo.exec_() @@ -660,43 +874,50 @@ def loadFluo_cb(self, checked=True, fluo_channels=None): posData.fluo_bkgrData_dict[filename] = bkgrData posData.ol_data_dict[filename] = fluo_data.copy() - self.overlayButton.setStyleSheet(f'background-color: {GREEN_HEX}') - self.guiTabControl.addChannels([ - posData.user_ch_name, *posData.loadedFluoChannels - ]) + self.overlayButton.setStyleSheet(f"background-color: {GREEN_HEX}") + self.guiTabControl.addChannels( + [posData.user_ch_name, *posData.loadedFluoChannels] + ) return True - def criticalFluoChannelNotFound(self, fluo_ch, posData): - msg = widgets.myMessageBox(showCentered=False) - ls = "\n".join(self.workspace.listdir(posData.images_path)) - msg.setDetailedText( - f'Files present in the {posData.relPath} folder:\n' - f'{ls}' - ) - title = 'Requested channel data not found!' - txt = html_utils.paragraph( - f'The folder {posData.pos_path} ' - 'does not contain ' - 'either one of the following files:

' - f'{posData.basename}{fluo_ch}.tif
' - f'{posData.basename}{fluo_ch}_aligned.npz

' - 'Data loading aborted.' - ) - msg.addShowInFileManagerButton(posData.images_path) - okButton = msg.warning( - self.host, title, txt, buttonsTexts=('Ok') - ) + def loadNonAlignedFluoChannel(self, fluo_path): + posData = self.data[self.pos_i] + if posData.filename.find("aligned") != -1: + filename, _ = os.path.splitext(os.path.basename(fluo_path)) + path = f".../{posData.pos_foldername}/Images/{filename}_aligned.npz" + msg = widgets.myMessageBox() + msg.critical( + self, + "Aligned fluo channel not found!", + "Aligned data for fluorescence channel not found!\n\n" + f"You loaded aligned data for the cells channel, therefore " + "loading NON-aligned fluorescence data is not allowed.\n\n" + 'Run the script "dataPrep.py" to create the following file:\n\n' + f"{path}", + ) + return None + fluo_data = np.squeeze(skimage.io.imread(fluo_path)) + return fluo_data + + def loadPosTriggered(self): + if not self.isDataLoaded: + return + + self.startAutomaticLoadingPos() def loadSelectedData(self, user_ch_file_paths, user_ch_name): + len(user_ch_file_paths) self.user_ch_file_paths = user_ch_file_paths - self.logger.info(f'Reading {user_ch_name} channel metadata...') + self.logger.info(f"Reading {user_ch_name} channel metadata...") # Get information from first loaded position - posData = load.loadData(user_ch_file_paths[0], user_ch_name, log_func=self.logger.info) - posData.getBasenameAndChNames(qparent=self.host) + posData = load.loadData( + user_ch_file_paths[0], user_ch_name, log_func=self.logger.info + ) + posData.getBasenameAndChNames(qparent=self) posData.buildPaths() - if posData.ext != '.h5': + if posData.ext != ".h5": self.lazyLoader.salute = False self.lazyLoader.exit = True self.lazyLoaderWaitCond.wakeAll() @@ -706,34 +927,30 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): existingSegmEndNames = set() for filePath in user_ch_file_paths: _posData = load.loadData(filePath, user_ch_name, log_func=self.logger.info) - _posData.getBasenameAndChNames(qparent=self.host) - segm_files = self.workspace.segmentation_files( - _posData.images_path - ) - _existingEndnames = self.workspace.endnames( - _posData.basename, segm_files - ) + _posData.getBasenameAndChNames(qparent=self) + segm_files = load.get_segm_files(_posData.images_path) + _existingEndnames = load.get_endnames(_posData.basename, segm_files) existingSegmEndNames.update(_existingEndnames) - selectedSegmEndName = '' - self.newSegmEndName = '' + selectedSegmEndName = "" + self.newSegmEndName = "" if self.isNewFile or not existingSegmEndNames: self.isNewFile = True # Remove the 'segm_' part to allow filenameDialog to check if # a new file is existing (since we only ask for the part after # 'segm_') existingEndNames = [ - n.replace('segm', '', 1).replace('_', '', 1) + n.replace("segm", "", 1).replace("_", "", 1) for n in existingSegmEndNames ] - if posData.basename.endswith('_'): - basename = f'{posData.basename}segm' + if posData.basename.endswith("_"): + basename = f"{posData.basename}segm" else: - basename = f'{posData.basename}_segm' + basename = f"{posData.basename}_segm" win = apps.filenameDialog( basename=basename, - hintText='Insert a filename for the segmentation file:', - existingNames=existingEndNames + hintText="Insert a filename for the segmentation file:", + existingNames=existingEndNames, ) win.exec_() if win.cancel: @@ -743,8 +960,11 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): else: if len(existingSegmEndNames) > 0: win = apps.SelectSegmFileDialog( - existingSegmEndNames, self.exp_path, parent=self.host, - addNewFileButton=True, basename=posData.basename + existingSegmEndNames, + self.exp_path, + parent=self, + addNewFileButton=True, + basename=posData.basename, ) win.exec_() if win.cancel: @@ -752,9 +972,7 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): return if win.newSegmEndName is None: selectedSegmEndName = win.selectedItemText - self.AutoPilotProfile.storeSelectedSegmFile( - selectedSegmEndName - ) + self.AutoPilotProfile.storeSelectedSegmFile(selectedSegmEndName) else: self.newSegmEndName = win.newSegmEndName self.isNewFile = True @@ -784,19 +1002,17 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): self.labelBoolSegm = posData.labelBoolSegm posData.labelSegmData() - print('') - self.logger.info( - f'Segmentation filename: {posData.segm_npz_path}' - ) + print("") + self.logger.info(f"Segmentation filename: {posData.segm_npz_path}") proceed = posData.askInputMetadata( self.num_pos, - ask_SizeT=self.num_pos==1, + ask_SizeT=self.num_pos == 1, ask_TimeIncrement=True, ask_PhysicalSizes=True, singlePos=False, save=True, - warnMultiPos=True + warnMultiPos=True, ) if not proceed: self.loadingDataAborted() @@ -827,13 +1043,11 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): self.createOverlayLabelsItems(existingSegmEndNames) self.disableNonFunctionalButtons() - self.isH5chunk = ( - posData.ext == '.h5' - and (self.loadSizeT != self.SizeT - or self.loadSizeZ != self.SizeZ) + self.isH5chunk = posData.ext == ".h5" and ( + self.loadSizeT != self.SizeT or self.loadSizeZ != self.SizeZ ) - required_ram = posData.checkH5memoryFootprint()*self.loadSizeS + required_ram = posData.checkH5memoryFootprint() * self.loadSizeS if required_ram > 0: proceed = self.checkMemoryRequirements(required_ram) if not proceed: @@ -846,276 +1060,90 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): self.isSnapshot = False self.progressWin = apps.QDialogWorkerProgress( - title='Loading data...', parent=self.host, - pbarDesc=f'Loading "{user_ch_file_paths[0]}"...' + title="Loading data...", + parent=self, + pbarDesc=f'Loading "{user_ch_file_paths[0]}"...', ) self.progressWin.show(self.app) func = partial( - self.startLoadDataWorker, user_ch_file_paths, user_ch_name, - posData + self.startLoadDataWorker, user_ch_file_paths, user_ch_name, posData ) - QTimer.singleShot(150, func) - @exception_handler - def startLoadDataWorker( - self, user_ch_file_paths, user_ch_name, firstPosData - ): - self.funcDescription = 'loading data' - - self.guiTabControl.propsQGBox.idSB.setValue(0) - - self.thread = QThread() - self.loadDataMutex = QMutex() - self.loadDataWaitCond = QWaitCondition() - - self.loadDataWorker = workers.loadDataWorker( - self.host, user_ch_file_paths, user_ch_name, firstPosData - ) - - self.loadDataWorker.moveToThread(self.thread) - self.loadDataWorker.signals.finished.connect(self.thread.quit) - self.loadDataWorker.signals.finished.connect( - self.loadDataWorker.deleteLater - ) - self.thread.finished.connect(self.thread.deleteLater) - - self.loadDataWorker.signals.finished.connect( - self.loadDataWorkerFinished - ) - self.loadDataWorker.signals.progress.connect(self.workerProgress) - self.loadDataWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.loadDataWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.loadDataWorker.signals.critical.connect( - self.workerCritical - ) - self.loadDataWorker.signals.dataIntegrityCritical.connect( - self.loadDataWorkerDataIntegrityCritical - ) - self.loadDataWorker.signals.dataIntegrityWarning.connect( - self.loadDataWorkerDataIntegrityWarning - ) - self.loadDataWorker.signals.sigPermissionError.connect( - self.workerPermissionError - ) - self.loadDataWorker.signals.sigWarnMismatchSegmDataShape.connect( - self.askMismatchSegmDataShape - ) - self.loadDataWorker.signals.sigRecovery.connect( - self.askRecoverNotSavedData - ) + def load_fluo_data(self, fluo_path, isGuiThread=True): + self.logger.info(f'Loading fluorescence image data from "{fluo_path}"...') + bkgrData = None + posData = self.data[self.pos_i] + # Load overlay frames and align if needed + filename = os.path.basename(fluo_path) + filename_noEXT, ext = os.path.splitext(filename) + if ext == ".npy" or ext == ".npz": + fluo_data = np.load(fluo_path) + try: + fluo_data = np.squeeze(fluo_data["arr_0"]) + except Exception: + fluo_data = np.squeeze(fluo_data) - self.thread.started.connect(self.loadDataWorker.run) - self.thread.start() + # Load background data + bkgrData_path = os.path.join( + posData.images_path, f"{filename_noEXT}_bkgrRoiData.npz" + ) + if os.path.exists(bkgrData_path): + bkgrData = np.load(bkgrData_path) + elif ext == ".tif" or ext == ".tiff": + aligned_filename = f"{filename_noEXT}_aligned.npz" + aligned_path = os.path.join(posData.images_path, aligned_filename) + if os.path.exists(aligned_path): + fluo_data = np.load(aligned_path)["arr_0"] - def askRecoverNotSavedData(self, posData): - last_modified_time_unsaved = 'NEVER' - if os.path.exists(posData.segm_npz_temp_path): - recovered_file_path = posData.segm_npz_temp_path - if os.path.exists(posData.segm_npz_path): - last_modified_time_unsaved = ( - datetime.fromtimestamp( - os.path.getmtime(posData.segm_npz_path) - ).strftime("%a %d. %b. %y - %H:%M:%S") - ) - else: - posData.setTempPaths() - if os.path.exists(posData.unsaved_acdc_df_autosave_path): - zip_path = posData.unsaved_acdc_df_autosave_path - with zipfile.ZipFile(zip_path, mode='r') as zip: - csv_names = natsorted(set(zip.namelist())) - iso_key = csv_names[-1][:-4] - most_recent_unsaved_acdc_df_datetime = datetime.strptime( - iso_key, load.ISO_TIMESTAMP_FORMAT + # Load background data + bkgrData_path = os.path.join( + posData.images_path, f"{aligned_filename}_bkgrRoiData.npz" ) - last_modified_time_unsaved = ( - most_recent_unsaved_acdc_df_datetime - ).strftime("%a %d. %b. %y - %H:%M:%S") - - if os.path.exists(posData.acdc_output_csv_path): - acdc_df_mtime = os.path.getmtime(posData.acdc_output_csv_path) - timestamp = datetime.fromtimestamp(acdc_df_mtime) - last_modified_time_saved = timestamp.strftime( - "%a %d. %b. %y - %H:%M:%S" - ) - else: - last_modified_time_saved = 'Null' + if os.path.exists(bkgrData_path): + bkgrData = np.load(bkgrData_path) + else: + fluo_data = self.loadNonAlignedFluoChannel(fluo_path) + if fluo_data is None: + return None, None - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = html_utils.paragraph(""" - Cell-ACDC detected unsaved data.

- Do you want to load and recover the unsaved data or - load the data that was last saved by the user? - """) - details = (f""" - The unsaved data was created on {last_modified_time_unsaved}\n\n - The user saved the data last time on {last_modified_time_saved} - """) - msg.setDetailedText(details) - loadUnsavedButton = widgets.reloadPushButton('Recover unsaved data') - loadSavedButton = widgets.savePushButton('Load saved data') - infoButton = widgets.infoPushButton('More info...') - loadSafeNpzButton = '' - if posData.isSafeNpzOverwritePresent(): - loadSafeNpzButton = widgets.reloadPushButton( - 'Load .safe.npz file from crash' - ) - buttons = ( - loadSavedButton, loadUnsavedButton, loadSafeNpzButton, - infoButton + # Load background data + bkgrData_path = os.path.join( + posData.images_path, f"{filename_noEXT}_bkgrRoiData.npz" + ) + if os.path.exists(bkgrData_path): + bkgrData = np.load(bkgrData_path) + elif isGuiThread: + txt = html_utils.paragraph( + f"File format {ext} is not supported!\n" + "Choose either .tif or .npz files." ) - else: - buttons = (loadSavedButton, loadUnsavedButton, infoButton) - msg.question( - self.progressWin, 'Recover unsaved data?', txt, - buttonsTexts=('Cancel', *buttons), - showDialog=False - ) - infoButton.disconnect() - infoButton.clicked.connect(partial(self.showInfoAutosave, posData)) - msg.exec_() - if msg.cancel: - self.loadDataWorker.abort = True - elif msg.clickedButton == loadUnsavedButton: - self.loadDataWorker.loadUnsaved = True - elif msg.clickedButton == loadSafeNpzButton: - self.loadDataWorker.loadSafeOverwriteNpz = True - - self.loadDataWorker.waitCond.wakeAll() - # self.AutoPilotProfile.storeLoadSavedData() - - def showInfoAutosave(self, posData): - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = (f""" - Cell-ACDC either detected unsaved data in a previous session and it - stored it because the Autosave
- function was active, or it crashed during saving.

- You can toggle Autosave ON and OFF from the menu on the top menubar - File --> Autosave. - """) - txt = (f""" - {txt}

- If Cell-ACDC crashed during saving, the segmentation file ending - with .new.npz
- is present and you might be able to recover the data from there. - """) - - txt = (f""" - {txt}

- You can find additional recovered data in the following folder: - """) - txt = html_utils.paragraph(txt) - msg.information( - self.host, 'Autosave info', txt, - path_to_browse=posData.recoveryFolderPath, - commands=(posData.recoveryFolderPath,) - ) - - def askMismatchSegmDataShape(self, posData): - msg = widgets.myMessageBox(wrapText=False) - title = 'Segm. data shape mismatch' - f = '3D' if self.isSegm3D else '2D' - f = f'{f} over time' if posData.SizeT > 1 else f - r = '2D' if self.isSegm3D else '3D' - r = f'{r} over time' if posData.SizeT > 1 else r - text = html_utils.paragraph(f""" - The segmentation masks of the first Position that you loaded is - {f},
- while {posData.pos_foldername} is {r}.

- The loaded segmentation masks must be either all 3D - or all 2D.

- Do you want to skip loading this position or cancel the process? - """) - _, skipPosButton = msg.warning( - self.host, title, text, - buttonsTexts=('Cancel', 'Skip this Position') - ) - if skipPosButton == msg.clickedButton: - self.loadDataWorker.skipPos = True - self.loadDataWorker.waitCond.wakeAll() - - def workerPermissionError(self, txt, waitCond): - msg = widgets.myMessageBox(parent=self.host) - msg.setIcon(iconName='SP_MessageBoxCritical') - msg.setWindowTitle('Permission denied') - msg.addText(txt) - msg.addButton(' Ok ') - msg.exec_() - waitCond.wakeAll() - - def loadDataWorkerDataIntegrityCritical(self): - errTitle = 'All loaded positions contains frames over time!' - self.titleLabel.setText(errTitle, color='r') - - msg = widgets.myMessageBox(parent=self.host) - - err_msg = html_utils.paragraph(f""" - {errTitle}.

- To load data that contains frames over time you have to select - only ONE position. - """) - msg.setIcon(iconName='SP_MessageBoxCritical') - msg.setWindowTitle('Loaded multiple positions with frames!') - msg.addText(err_msg) - msg.addButton('Ok') - msg.show(block=True) - - @exception_handler - def loadDataWorkerFinished(self, data): - self.funcDescription = 'loading data worker finished' - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - if data is None or data=='abort': - self.loadingDataAborted() - return - - if data[0].onlyEditMetadata: - self.loadingDataAborted() - return - - self.pos_i = 0 - self.data = data - self.sessions = [None] * len(data) - self.gui_createGraphicsItems() - return True + msg = widgets.myMessageBox() + msg.critical(self, "File not supported", txt) + return None, None - def checkManageVersions(self): - posData = self.data[self.pos_i] - posData.setTempPaths(createFolder=False) - loaded_acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) + return fluo_data, bkgrData - if os.path.exists(posData.recoveryFolderpath()): - self.manageVersionsAction.setDisabled(False) - self.manageVersionsAction.setToolTip( - f'Load an older version of the `{loaded_acdc_df_filename}` file ' - '(table with annotations and measurements).' - ) - else: - self.manageVersionsAction.setDisabled(True) + def loadingDataAborted(self): + self.openFolderAction.setEnabled(True) + self.titleLabel.setText("Loading data aborted.") @exception_handler def loadingDataCompleted(self): self.isDataLoading = True posData = self.data[self.pos_i] - files_format = '\n'.join([ - f' - {file}' for file in posData.images_folder_files - ]) - sep = '-'*100 + files_format = "\n".join( + [f" - {file}" for file in posData.images_folder_files] + ) + sep = "-" * 100 self.logger.info( - f'{sep}\nFiles present in the first Position folder loaded:\n\n' - f'{files_format}\n{sep}' + f"{sep}\nFiles present in the first Position folder loaded:\n\n" + f"{files_format}\n{sep}" ) - self.logger.info(f'Basename of the first Position: {posData.basename}') + self.logger.info(f"Basename of the first Position: {posData.basename}") self.secondLevelToolbar.setVisible(True) self.updateImageValueFormatter() self.checkManageVersions() @@ -1126,13 +1154,13 @@ def loadingDataCompleted(self): f'Cell-ACDC v{self._acdc_version} - GUI - "{posData.exp_path}"' ) - self.preprocessing_view.setupPreprocessing() + self.setupPreprocessing() self.setupCombiningChannels() if self.isSegm3D: - self.segmNdimIndicator.setText('3D') + self.segmNdimIndicator.setText("3D") else: - self.segmNdimIndicator.setText('2D') + self.segmNdimIndicator.setText("2D") self.segmNdimIndicatorAction.setVisible(True) @@ -1145,21 +1173,19 @@ def loadingDataCompleted(self): self.connectScrollbars() self.initPosAttr() - self.logger.info('Pre-computing min and max values of the images...') + self.logger.info("Pre-computing min and max values of the images...") self.img1.preComputedMinMaxValues(self.data) self.img2.minMaxValuesMapper = self.img1.minMaxValuesMapper - self.measurements_view.init_metrics() + self.initMetrics() self.initFluoData() self.createChannelNamesActions() self.addActionsLutItemContextMenu(self.imgGrad) # Scrollbar for opacity of img1 (when overlaying) - self.img1.alphaScrollbar = self.addAlphaScrollbar( - self.user_ch_name, self.img1 - ) + self.img1.alphaScrollbar = self.addAlphaScrollbar(self.user_ch_name, self.img1) - self.navigateScrollBar.setSliderPosition(posData.frame_i+1) + self.navigateScrollBar.setSliderPosition(posData.frame_i + 1) # Connect events at the end of loading data process self.gui_connectGraphicsEvents() @@ -1169,28 +1195,22 @@ def loadingDataCompleted(self): self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) - self.mode_controls_view.setFramesSnapshotMode() + self.setFramesSnapshotMode() if self.isSnapshot: - self.navSizeLabel.setText(f'/{len(self.data)}') + self.navSizeLabel.setText(f"/{len(self.data)}") else: - self.navSizeLabel.setText(f'/{posData.SizeT}') + self.navSizeLabel.setText(f"/{posData.SizeT}") self.enableZstackWidgets(posData.SizeZ > 1) # self.showHighlightZneighCheckbox() - self.exportToVideoAction.setDisabled( - posData.SizeZ == 1 and posData.SizeT == 1 - ) + self.exportToVideoAction.setDisabled(posData.SizeZ == 1 and posData.SizeT == 1) self.img1BottomGroupbox.show() - isLabVisible = self.df_settings.at['isLabelsVisible', 'value'] == 'Yes' - isRightImgVisible = ( - self.df_settings.at['isRightImageVisible', 'value'] == 'Yes' - ) - isNextFrameVisible = ( - self.df_settings.at['isNextFrameVisible', 'value'] == 'Yes' - ) + isLabVisible = self.df_settings.at["isLabelsVisible", "value"] == "Yes" + isRightImgVisible = self.df_settings.at["isRightImageVisible", "value"] == "Yes" + isNextFrameVisible = self.df_settings.at["isNextFrameVisible", "value"] == "Yes" isNextFrameActive = ( isNextFrameVisible and self.labelsGrad.showNextFrameAction.isEnabled() ) @@ -1205,9 +1225,7 @@ def loadingDataCompleted(self): if isRightImgVisible or isNextFrameActive: self.rightBottomGroupbox.setChecked(True) - isTwoImagesLayout = ( - isRightImgVisible or isLabVisible or isNextFrameActive - ) + isTwoImagesLayout = isRightImgVisible or isLabVisible or isNextFrameActive self.setTwoImagesLayout(isTwoImagesLayout) self.setBottomLayoutStretch() @@ -1217,9 +1235,9 @@ def loadingDataCompleted(self): self.rightBottomGroupbox.setChecked(True) self.drawNothingCheckboxRight.click() - self.custom_annotations_view.readSavedCustomAnnot() - self.custom_annotations_view.addCustomAnnotButtonAllLoadedPos() - self.status_hover_view.set_status_bar_label() + self.readSavedCustomAnnot() + self.addCustomAnnotButtonAllLoadedPos() + self.setStatusBarLabel() self.initLookupTableLab() if self.invertBwAction.isChecked() and not self.invertBwAlreadyCalledOnce: @@ -1233,16 +1251,13 @@ def loadingDataCompleted(self): self.update_rp() self.updateAllImages() if posData.SizeT > 1: - self.rightImageFramesScrollbar.setValueNoSignal(posData.frame_i+2) - self.measurements_view.set_metrics_func() + self.rightImageFramesScrollbar.setValueNoSignal(posData.frame_i + 2) + self.setMetricsFunc() self.gui_createLabelRoiItem() self.gui_createZoomRectItem() - self.titleLabel.setText( - 'Data successfully loaded.', - color=self.titleColor - ) + self.titleLabel.setText("Data successfully loaded.", color=self.titleColor) self.disableNonFunctionalButtons() self.setVisible3DsegmWidgets() @@ -1264,8 +1279,8 @@ def loadingDataCompleted(self): posData.loadWhitelist() - self.image_controls_view.setFocusGraphics() - self.image_controls_view.setFocusMain() + self.setFocusGraphics() + self.setFocusMain() # Overwrite axes viewbox context menu self.ax1.vb.menu = self.imgGrad.gradient.menu @@ -1274,8 +1289,6 @@ def loadingDataCompleted(self): QTimer.singleShot(200, self.resizeGui) self.isDataLoaded = True - self._sync_all_sessions() - self._init_tool_dispatcher() self.isDataLoading = False self.initImgGradRescaleIntensitiesHowPreference() @@ -1284,59 +1297,71 @@ def loadingDataCompleted(self): self.gui_createAutoSaveWorker() - def loadingDataAborted(self): - self.openFolderAction.setEnabled(True) - self.titleLabel.setText('Loading data aborted.') + def merge_default_segm_info( + self, + existing_df: pd.DataFrame, + default_df: pd.DataFrame, + ) -> pd.DataFrame: + merged_df = pd.concat([default_df, existing_df]) + unique_idx = ~merged_df.index.duplicated() + return merged_df[unique_idx] + + def newFile(self): + self.newSegmEndName = "" + self.isNewFile = True + msg = widgets.myMessageBox(parent=self, showCentered=False) + msg.setWindowTitle("File or folder?") + msg.addText( + html_utils.paragraph(""" + Do you want to load an image file or Position + folder(s)? + """) + ) + loadPosButton = QPushButton("Load Position folder", msg) + loadPosButton.setIcon(QIcon(":folder-open.svg")) + loadFileButton = QPushButton("Load image file", msg) + loadFileButton.setIcon(QIcon(":image.svg")) + helpButton = widgets.helpPushButton("Help...") + msg.addButton(helpButton) + helpButton.disconnect() + helpButton.clicked.connect(self.helpNewFile) + msg.addCancelButton(connect=True) + msg.addButton(loadFileButton) + msg.addButton(loadPosButton) + loadPosButton.setDefault(True) + msg.exec_() + if msg.cancel: + return + + if msg.clickedButton == loadPosButton: + self._openFolder() + else: + self._openFile() + + def openFile(self, checked=False, file_path=None): + self.logger.info(f'Opening FILE "{file_path}"') - @exception_handler - def loadDataWorkerDataIntegrityWarning(self, pos_foldername): - err_msg = ( - 'WARNING: Segmentation mask file ("..._segm.npz") not found. ' - 'You could run segmentation module first.' - ) - self.workerProgress(err_msg, 'INFO') - self.titleLabel.setText(err_msg, color='r') - abort = False - msg = widgets.myMessageBox(parent=self.host) - warn_msg = html_utils.paragraph(f""" - The folder {pos_foldername} does not contain a - pre-computed segmentation mask.

- You can continue with a blank mask or cancel and - pre-compute the mask with the segmentation module.

- Do you want to continue? - """) - msg.setIcon(iconName='SP_MessageBoxWarning') - msg.setWindowTitle('Segmentation file not found') - msg.addText(warn_msg) - msg.addButton('Ok') - continueWithBlankSegm = msg.addButton(' Cancel ') - msg.show(block=True) - if continueWithBlankSegm == msg.clickedButton: - abort = True - self.loadDataWorker.abort = abort - self.loadDataWaitCond.wakeAll() + self.isNewFile = False + self._openFile(file_path=file_path) - def openFolder( - self, checked=False, exp_path=None, imageFilePath='' - ): + def openFolder(self, checked=False, exp_path=None, imageFilePath=""): if exp_path is None: - self.logger.info('Asking to select a folder path...') + self.logger.info("Asking to select a folder path...") else: self.logger.info(f'Opening FOLDER "{exp_path}"...') self.isNewFile = False - if hasattr(self, 'data') and self.titleLabel.text != 'Saved!': + if hasattr(self, "data") and self.titleLabel.text != "Saved!": msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Do you want to save before loading another dataset?' + "Do you want to save before loading another dataset?" ) _, no, yes = msg.question( - self.host, 'Save?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') + self, "Save?", txt, buttonsTexts=("Cancel", "No", "Yes") ) if msg.clickedButton == yes: func = partial(self._openFolder, exp_path, imageFilePath) - cancel = self.saveData(finishedCallback=func) + self.saveData(finishedCallback=func) return elif msg.cancel: self.store_data() @@ -1344,300 +1369,274 @@ def openFolder( else: self.store_data(autosave=False) - self._openFolder( - exp_path=exp_path, imageFilePath=imageFilePath - ) - - def addToRecentPaths(self, path, logger=None): - self.workspace.add_recent_path(path, logger=self.logger) - - def getMostRecentPath(self): - return self.workspace.most_recent_path() - - @exception_handler - def _openFolder( - self, checked=False, exp_path=None, imageFilePath='' - ): - """Main function to load data. - - Parameters - ---------- - checked : bool - kwarg needed because openFolder can be called by openFolderAction. - exp_path : string or None - Path selected by the user either directly, through openFile, - or drag and drop image file. - imageFilePath : string - Path of the image file that was either drag and dropped or opened - from File --> Open image/video file (openFileAction). - - Returns - ------- - None - """ - - if exp_path is None: - self.MostRecentPath = self.getMostRecentPath() - exp_path = QFileDialog.getExistingDirectory( - self.host, - 'Select experiment folder containing Position_n folders ' - 'or specific Position_n folder', - self.MostRecentPath - ) - - if not exp_path: - self.openFolderAction.setEnabled(True) - return - - proceed = self.reInitGui() - if not proceed: - self.openFolderAction.setEnabled(True) - return - - self.openFolderAction.setEnabled(False) - - if self.slideshowWin is not None: - self.slideshowWin.close() - - if self.ccaTableWin is not None: - self.ccaTableWin.close() + self._openFolder(exp_path=exp_path, imageFilePath=imageFilePath) - self.exp_path = exp_path - self.logger.info(f'Loading from {self.exp_path}') - self.addToRecentPaths(exp_path, logger=self.logger) - self.addPathToOpenRecentMenu(exp_path) + def openRecentFile(self, path): + self.logger.info(f"Opening recent folder: {path}") + self.addToRecentPaths(path, logger=self.logger) + self.openFolder(exp_path=path) - folder_type = self.workspace.determine_folder_type(exp_path) - is_pos_folder, is_images_folder, exp_path = folder_type + def open_image_file_context( + self, file_path: str, timestamp: str | None = None + ) -> OpenImageFileContext: + filename_no_ext, ext = os.path.splitext(os.path.basename(file_path)) + filename_no_ext = filename_no_ext.rstrip("_") + ext = ext.lower() + dirpath = os.path.dirname(file_path) + dirname = os.path.basename(dirpath) + requires_images_folder = dirname != "Images" + acdc_folder = None - self.titleLabel.setText('Loading data...', color=self.titleColor) + if requires_images_folder: + timestamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S") + acdc_folder = f"{timestamp}_acdc" + exp_path = os.path.join(dirpath, acdc_folder, "Images") + else: + exp_path = dirpath - skip_channels = [] - ch_name_selector = prompts.select_channel_name( - which_channel='segm', allow_abort=False + return OpenImageFileContext( + file_path=file_path, + filename_no_ext=filename_no_ext, + extension=ext, + source_dirpath=dirpath, + source_dirname=dirname, + exp_path=exp_path, + acdc_folder=acdc_folder, + requires_images_folder=requires_images_folder, ) - user_ch_name = None - if not is_pos_folder and not is_images_folder and not imageFilePath: - images_paths = self._loadFromExperimentFolder(exp_path) - if not images_paths: - self.loadingDataAborted() - return - - elif is_pos_folder and not imageFilePath: - pos_foldername = os.path.basename(exp_path) - exp_path = os.path.dirname(exp_path) - images_paths = [os.path.join(exp_path, pos_foldername, 'Images')] - - elif is_images_folder and not imageFilePath: - images_paths = [exp_path] - pos_path = os.path.dirname(exp_path) - exp_path = os.path.dirname(pos_path) - elif imageFilePath: - # images_path = exp_path because called by openFile func - filenames = self.workspace.listdir(exp_path) - ch_names, basenameNotFound = ( - ch_name_selector.get_available_channels(filenames, exp_path) - ) - filename = os.path.basename(imageFilePath) - self.ch_names = ch_names - user_ch_name = [ - chName for chName in ch_names if filename.find(chName)!=-1 - ][0] - images_paths = [exp_path] - pos_path = os.path.dirname(exp_path) - exp_path = os.path.dirname(pos_path) + def open_image_file_target( + self, + context: OpenImageFileContext, + channel_name: str | None = None, + ) -> OpenImageFileTarget: + filename_no_ext = context.filename_no_ext + basename = None + metadata_csv_filename = None + metadata_csv_filepath = None - self.images_paths = images_paths + if channel_name is not None: + underscore_splits = filename_no_ext.split("_") + if len(underscore_splits) > 1: + default_ch_name = underscore_splits[-1] + if channel_name == default_ch_name: + filename_no_ext = "_".join(underscore_splits[:-1]) - # Get info from first position selected - images_path = self.images_paths[0] - filenames = self.workspace.listdir(images_path) - if ch_name_selector.is_first_call and user_ch_name is None: - ch_names, _ = ch_name_selector.get_available_channels( - filenames, images_path + basename = f"{filename_no_ext}_" + metadata_csv_filename = f"{basename}metadata.csv" + metadata_csv_filepath = os.path.join( + context.exp_path, metadata_csv_filename ) - self.ch_names = ch_names - if not ch_names: - self.openFolderAction.setEnabled(True) - self.criticalNoTifFound(images_path) - return - if len(ch_names) > 1: - CbLabel='Select channel name to load: ' - ch_name_selector.QtPrompt( - self.host, ch_names, CbLabel=CbLabel - ) - if ch_name_selector.was_aborted: - self.openFolderAction.setEnabled(True) - return - skip_channels.extend([ - ch for ch in ch_names if ch!=ch_name_selector.channel_name - ]) - else: - ch_name_selector.channel_name = ch_names[0] - ch_name_selector.setUserChannelName() - user_ch_name = ch_name_selector.user_ch_name + new_filename = f"{filename_no_ext}_{channel_name}{context.extension}" else: - # File opened directly with self.openFile - ch_name_selector.channel_name = user_ch_name + new_filename = f"{filename_no_ext}{context.extension}" - user_ch_file_paths = [] - not_allowed_ends = ['btrack_tracks.h5'] - for images_path in self.images_paths: - channel_file_path = load.get_filename_from_channel( - images_path, user_ch_name, skip_channels=skip_channels, - not_allowed_ends=not_allowed_ends, logger=self.logger.info - ) - if not channel_file_path: - self.criticalImgPathNotFound(images_path) - return - user_ch_file_paths.append(channel_file_path) + new_filepath = os.path.join(context.exp_path, new_filename) + tif_filename_no_ext = os.path.splitext(new_filename)[0] + tif_filename = f"{tif_filename_no_ext}.tif" + tif_path = os.path.join(context.exp_path, tif_filename) - ch_name_selector.setUserChannelName() - self.user_ch_name = user_ch_name - self.img1.channelName = user_ch_name + return OpenImageFileTarget( + context=context, + filename_no_ext=filename_no_ext, + channel_name=channel_name, + basename=basename, + new_filename=new_filename, + new_filepath=new_filepath, + metadata_csv_filename=metadata_csv_filename, + metadata_csv_filepath=metadata_csv_filepath, + tif_filename=tif_filename, + tif_path=tif_path, + direct_copy_supported=context.extension in (".tif", ".npz"), + ) - self.AutoPilotProfile.storeSelectedChannel(self.user_ch_name) + def prepare_tiff_image_data(self, image: np.ndarray) -> ImageDataPreparation: + converted_rgb_to_gray = False + converted_dtype = False + prepared_image = image - self.initGlobalAttr() - self.createOverlayContextMenu() - self.createUserChannelNameAction() - self.gui_createOverlayColors() - self.gui_createOverlayItems() - lastRow = self.bottomLeftLayout.rowCount() - self.bottomLeftLayout.setRowStretch(lastRow+1, 1) + if prepared_image.ndim == 3 and ( + prepared_image.shape[-1] == 3 or prepared_image.shape[-1] == 4 + ): + converted_rgb_to_gray = True + if prepared_image.shape[-1] == 3: + prepared_image = skimage.color.rgb2gray(prepared_image) + else: + prepared_image = cv2.cvtColor(prepared_image, cv2.COLOR_RGBA2GRAY) + prepared_image = skimage.img_as_ubyte(prepared_image) - self.num_pos = len(user_ch_file_paths) - proceed = self.loadSelectedData(user_ch_file_paths, user_ch_name) - if not proceed: - self.openFolderAction.setEnabled(True) - return + if not self.is_imagej_dtype(prepared_image.dtype): + converted_dtype = True + prepared_image = skimage.img_as_ubyte(prepared_image) - def _loadFromExperimentFolder(self, exp_path): - select_folder = load.select_exp_folder() - values = select_folder.get_values_segmGUI(exp_path) - if not values: - self.criticalInvalidPosFolder(exp_path) - self.openFolderAction.setEnabled(True) - return [] + return ImageDataPreparation( + image=prepared_image, + converted_rgb_to_gray=converted_rgb_to_gray, + converted_dtype=converted_dtype, + ) - if len(values) > 1: - select_folder.QtPrompt(self.host, values, allow_cancel=False) - if select_folder.cancel: - return [] - else: - select_folder.cancel = False - select_folder.selected_pos = select_folder.pos_foldernames + def reload_cb(self): + posData = self.data[self.pos_i] + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + labData = np.load(posData.segm_npz_path) + # Keep compatibility with .npy and .npz files + try: + lab = labData["arr_0"][posData.frame_i] + except Exception: + lab = labData[posData.frame_i] + posData.segm_data[posData.frame_i] = lab.copy() + self.get_data() + self.tracking() + self.updateAllImages() - images_paths = [] - for pos in select_folder.selected_pos: - images_paths.append(os.path.join(exp_path, pos, 'Images')) - return images_paths + def showInfoAutosave(self, posData): + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = """ + Cell-ACDC either detected unsaved data in a previous session and it + stored it because the Autosave
+ function was active, or it crashed during saving.

+ You can toggle Autosave ON and OFF from the menu on the top menubar + File --> Autosave. + """ + txt = f""" + {txt}

+ If Cell-ACDC crashed during saving, the segmentation file ending + with .new.npz
+ is present and you might be able to recover the data from there. + """ + + txt = f""" + {txt}

+ You can find additional recovered data in the following folder: + """ + txt = html_utils.paragraph(txt) + msg.information( + self, + "Autosave info", + txt, + path_to_browse=posData.recoveryFolderPath, + commands=(posData.recoveryFolderPath,), + ) - def criticalInvalidPosFolder(self, exp_path): - href = html_utils.href_tag('here', data_structure_docs_url) - txt = html_utils.paragraph(f""" - The selected folder:

+ def startAutomaticLoadingPos(self): + self.AutoPilot = autopilot.AutoPilot(self) + self.AutoPilot.execLoadPos() - {exp_path}

+ @exception_handler + def startLoadDataWorker(self, user_ch_file_paths, user_ch_name, firstPosData): + self.funcDescription = "loading data" - is not a valid folder.

+ self.guiTabControl.propsQGBox.idSB.setValue(0) - Select a folder that contains the Position_n folders, - or a specific Position.

+ self.thread = QThread() + self.loadDataMutex = QMutex() + self.loadDataWaitCond = QWaitCondition() - If you are trying to load a single image file go to - File --> Open image/video file....

+ self.loadDataWorker = workers.loadDataWorker( + self, user_ch_file_paths, user_ch_name, firstPosData + ) - To load a folder containing multiple .tif files the folder must - be called either Position_n
- (with n being an integer) or Images.

+ self.loadDataWorker.moveToThread(self.thread) + self.loadDataWorker.signals.finished.connect(self.thread.quit) + self.loadDataWorker.signals.finished.connect(self.loadDataWorker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) - For more information about the correct folder structure see {href}. - """) - msg = widgets.myMessageBox(wrapText=False) - helpButton = widgets.helpPushButton('Help...') - msg.addButton(helpButton) - helpButton.clicked.disconnect() - helpButton.clicked.connect( - partial(myutils.browse_url, data_structure_docs_url) + self.loadDataWorker.signals.finished.connect(self.loadDataWorkerFinished) + self.loadDataWorker.signals.progress.connect(self.workerProgress) + self.loadDataWorker.signals.initProgressBar.connect(self.workerInitProgressbar) + self.loadDataWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.loadDataWorker.signals.critical.connect(self.workerCritical) + self.loadDataWorker.signals.dataIntegrityCritical.connect( + self.loadDataWorkerDataIntegrityCritical ) - msg.addShowInFileManagerButton(exp_path) - msg.critical( - self.host, 'Incompatible folder', txt + self.loadDataWorker.signals.dataIntegrityWarning.connect( + self.loadDataWorkerDataIntegrityWarning ) + self.loadDataWorker.signals.sigPermissionError.connect( + self.workerPermissionError + ) + self.loadDataWorker.signals.sigWarnMismatchSegmDataShape.connect( + self.askMismatchSegmDataShape + ) + self.loadDataWorker.signals.sigRecovery.connect(self.askRecoverNotSavedData) - def openFile(self, checked=False, file_path=None): - self.logger.info(f'Opening FILE "{file_path}"') - - self.isNewFile = False - self._openFile(file_path=file_path) + self.thread.started.connect(self.loadDataWorker.run) + self.thread.start() - def askUserChannelName(self, filename_no_ext, ext): - help_txt = html_utils.paragraph(f""" - Cell-ACDC requires that every image file has a basename and some - additional text, typically the channel name.

- The basename will be common to all created files, while the additional text is used to identify the image files. - """) + def stopAutomaticLoadingPos(self): + if self.AutoPilot is None: + return - suggestion = self.channel_name_suggestion(filename_no_ext) - basename = suggestion.basename - channel_name = suggestion.channel_name + if self.AutoPilot.timer.isActive(): + self.AutoPilot.timer.stop() + self.AutoPilot = None + def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): + total_ram = myutils._bytes_to_GB(total_ram) + available_ram = myutils._bytes_to_GB(available_ram) + required_ram = myutils._bytes_to_GB(required_ram) + required_perc = round(100 * required_ram / available_ram) + msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" - Provide some text (e.g., the channel name) to append at the end of the image file. + The total amount of data that you requested to load is about + {required_ram:.2f} GB ({required_perc}% of the available memory) + but there are only {available_ram:.2f} GB available.

+ For optimal operation, we recommend loading maximum 30% + of the available memory. To do so, try to close open apps to + free up some memory. Another option is to crop the images + using the data prep module.

+ If you choose to continue, the system might freeze + or your OS could simply kill the process.

+ What do you want to do? """) - win = apps.filenameDialog( - basename=basename, - ext=ext, - hintText=txt, - defaultEntry=channel_name, - helpText=help_txt, - allowEmpty=False, - parent=self.host, - title='Provide channel name for image file', + cancelButton, continueButton = msg.warning( + self, + "Memory not sufficient", + txt, + buttonsTexts=("Cancel", "Continue anyway"), ) - win.exec_() - if win.cancel: - return False, '' - - return True, win.entryText + if msg.clickedButton == continueButton: + # Disable autosaving since it would keep a copy of the data and + # we cannot afford it with low memory + self.autoSaveToggle.setChecked(False) + return True + else: + return False def warnUserCreationImagesFolder(self, images_path, ext): msg = widgets.myMessageBox(wrapText=False) - txt = (f""" + txt = f""" Cell-ACDC requires a specific folder structure to load the data.

Specifically, it requires the image(s) to be located in a folder called Images.

- The file format of the images must be TIFF or NPZ + The file format of the images must be TIFF or NPZ (.tif or .npz extension).

- You can choose to let Cell-ACDC create the required data structure + You can choose to let Cell-ACDC create the required data structure from your file,
- or you can stop the - process and manually place the image(s) into a folder called + or you can stop the + process and manually place the image(s) into a folder called Images.

- If you choose to proceed, Cell-ACDC will create the following + If you choose to proceed, Cell-ACDC will create the following folder: {images_path}
- """) + """ - if ext == '.tif' or ext == '.npz': - txt = f'{txt}How do you want to proceed?' + if ext == ".tif" or ext == ".npz": + txt = f"{txt}How do you want to proceed?" else: - txt = f'{txt}Do you want to proceed?' + txt = f"{txt}Do you want to proceed?" txt = html_utils.paragraph(txt) - if ext == '.tif' or ext == '.npz': - copyButton = widgets.copyPushButton( - 'Copy the image into the new folder' - ) - moveButton = widgets.movePushButton( - 'Move the image into the new folder' - ) + if ext == ".tif" or ext == ".npz": + copyButton = widgets.copyPushButton("Copy the image into the new folder") + moveButton = widgets.movePushButton("Move the image into the new folder") _, copyButton, moveButton = msg.information( - self.host, 'Creating Images folder', txt, - buttonsTexts=('Cancel', copyButton, moveButton) + self, + "Creating Images folder", + txt, + buttonsTexts=("Cancel", copyButton, moveButton), ) if msg.cancel: return False, None @@ -1649,201 +1648,117 @@ def warnUserCreationImagesFolder(self, images_path, ext): else: msg.information( - self.host, 'Creating Images folder', txt, - buttonsTexts=('Cancel', 'Yes, proceed') + self, + "Creating Images folder", + txt, + buttonsTexts=("Cancel", "Yes, proceed"), ) if msg.cancel: return False, None return True, True - @exception_handler - def _openFile(self, file_path=None): - """ - Function used for loading an image file directly. - """ - if file_path is None: - self.MostRecentPath = self.getMostRecentPath() - file_path = QFileDialog.getOpenFileName( - self.host, 'Select image file', self.MostRecentPath, - "Image/Video Files (*.png *.tif *.tiff *.jpg *.jpeg *.mov *.avi *.mp4)" - ";;All Files (*)")[0] - if not file_path: - return - - context = self.open_image_file_context(file_path) - channel_name = None - do_copy = True - if context.requires_images_folder: - proceed, do_copy = self.warnUserCreationImagesFolder( - context.exp_path, context.extension - ) - if not proceed: - self.logger.info('Loading image file cancelled.') - return - - proceed, channel_name = self.askUserChannelName( - context.filename_no_ext, '.tif' - ) - if not proceed: - self.logger.info('Loading image file cancelled.') - return - - os.makedirs(context.exp_path, exist_ok=True) - - target = self.open_image_file_target( - context, channel_name=channel_name - ) - if target.has_metadata: - df_metadata = pd.DataFrame({ - 'Description': ['basename'], - 'values': [target.basename] - }) - df_metadata.to_csv(target.metadata_csv_filepath, index=False) - - action_text = self.copy_action_text(do_copy) - - if target.direct_copy_supported: - if not os.path.exists(target.new_filepath): - self.logger.info(f'{action_text} file to Images folder...') - if do_copy: - shutil.copy2(file_path, target.new_filepath) - else: - shutil.move(file_path, target.new_filepath) - self._openFolder( - exp_path=context.exp_path, imageFilePath=target.new_filepath - ) - else: - self.logger.info(f'{action_text} file to .tif format...') - data = load.loadData( - context.file_path, '', log_func=self.logger.info - ) - data.loadImgData() - preparation = self.prepare_tiff_image_data( - data.img_data - ) - if preparation.converted_rgb_to_gray: - self.logger.info('Converting RGB image to grayscale...') - data.img_data = preparation.image - - myutils.to_tiff(target.tif_path, data.img_data) - self._openFolder( - exp_path=context.exp_path, imageFilePath=target.tif_path - ) - - def criticalNoTifFound(self, images_path): - err_title = 'No .tif files found in folder.' - err_msg = html_utils.paragraph( - 'The following folder

' - f'{images_path}

' - 'does not contain .tif or .h5 files.

' - 'Only .tif or .h5 files can be loaded with "Open Folder" button.

' - 'Try with File --> Open image/video file... ' - 'and directly select the file you want to load.' - ) - msg = widgets.myMessageBox() - msg.addShowInFileManagerButton(images_path) - msg.critical(self.host, err_title, err_msg) - - @exception_handler - def _createEmptyData(self): - self.MostRecentPath = self.getMostRecentPath() - exp_path = QFileDialog.getExistingDirectory( - self.host, - 'Select experiment folder where to create empty data', - self.MostRecentPath - ) - if not exp_path: - return - - plan = self.empty_data_plan(exp_path) - if os.path.exists(plan.images_path): - raise FileExistsError( - f'The following path already exists "{plan.images_path}"' - ) - - os.makedirs(plan.images_path, exist_ok=True) - - empty_img = np.zeros((256,256), dtype=np.uint8) - empty_img[0,0] = 255 - skimage.io.imsave(plan.tif_filepath, empty_img) - - df_metadata = pd.DataFrame({ - 'Description': ['basename'], - 'values': [plan.basename] - }) - df_metadata.to_csv(plan.metadata_filepath, index=False) - - self.isNewFile = True - self._openFolder(exp_path=plan.images_path) - - def newFile(self): - self.newSegmEndName = '' - self.isNewFile = True - msg = widgets.myMessageBox(parent=self.host, showCentered=False) - msg.setWindowTitle('File or folder?') - msg.addText(html_utils.paragraph(f""" - Do you want to load an image file or Position - folder(s)? - """)) - loadPosButton = QPushButton('Load Position folder', msg) - loadPosButton.setIcon(QIcon(":folder-open.svg")) - loadFileButton = QPushButton('Load image file', msg) - loadFileButton.setIcon(QIcon(":image.svg")) - helpButton = widgets.helpPushButton('Help...') - msg.addButton(helpButton) - helpButton.disconnect() - helpButton.clicked.connect(self.helpNewFile) - msg.addCancelButton(connect=True) - msg.addButton(loadFileButton) - msg.addButton(loadPosButton) - loadPosButton.setDefault(True) + def workerPermissionError(self, txt, waitCond): + msg = widgets.myMessageBox(parent=self) + msg.setIcon(iconName="SP_MessageBoxCritical") + msg.setWindowTitle("Permission denied") + msg.addText(txt) + msg.addButton(" Ok ") msg.exec_() - if msg.cancel: - return + waitCond.wakeAll() - if msg.clickedButton == loadPosButton: - self._openFolder() - else: - self._openFile() + def zSliceAbsent(self, filename, posData): + self.app.restoreOverrideCursor() + SizeZ = posData.SizeZ + chNames = posData.chNames + filenamesPresent = posData.segmInfo_df.index.get_level_values(0).unique() + chNamesPresent = [ + ch + for ch in chNames + for file in filenamesPresent + if file.endswith(ch) or file.endswith(f"{ch}_aligned") + ] + win = apps.QDialogZsliceAbsent(filename, SizeZ, chNamesPresent) + win.exec_() + if win.cancel: + self.worker.abort = True + self.waitCond.wakeAll() + return + if win.useMiddleSlice: + user_ch_name = filename[len(posData.basename) :] + for _posData in self.data: + if _posData is None: + continue + _, filename = self.getPathFromChName(user_ch_name, _posData) + df = myutils.getDefault_SegmInfo_df(_posData, filename) + _posData.segmInfo_df = pd.concat([df, _posData.segmInfo_df]) + unique_idx = ~_posData.segmInfo_df.index.duplicated() + _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] + _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) + elif win.useSameAsCh: + user_ch_name = filename[len(posData.basename) :] + for _posData in self.data: + if _posData is None: + continue + _, srcFilename = self.getPathFromChName(win.selectedChannel, _posData) + cellacdc_df = _posData.segmInfo_df.loc[srcFilename].copy() + _, dstFilename = self.getPathFromChName(user_ch_name, _posData) + if dstFilename is None: + self.worker.abort = True + self.waitCond.wakeAll() + return + dst_df = myutils.getDefault_SegmInfo_df(_posData, dstFilename) + for z_info in cellacdc_df.itertuples(): + frame_i = z_info.Index + zProjHow = z_info.which_z_proj + if zProjHow == "single z-slice": + src_idx = (srcFilename, frame_i) + if _posData.segmInfo_df.at[src_idx, "resegmented_in_gui"]: + col = "z_slice_used_gui" + else: + col = "z_slice_used_dataPrep" + z_slice = _posData.segmInfo_df.at[src_idx, col] + dst_idx = (dstFilename, frame_i) + dst_df.at[dst_idx, "z_slice_used_dataPrep"] = z_slice + dst_df.at[dst_idx, "z_slice_used_gui"] = z_slice + _posData.segmInfo_df = pd.concat([dst_df, _posData.segmInfo_df]) + unique_idx = ~_posData.segmInfo_df.index.duplicated() + _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] + _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) + elif win.runDataPrep: + user_ch_file_paths = [] + user_ch_name = filename[len(self.data[self.pos_i].basename) :] + for _posData in self.data: + if _posData is None: + continue + user_ch_path = load.get_filename_from_channel( + _posData.images_path, user_ch_name + ) + if user_ch_path is None: + self.worker.abort = True + self.waitCond.wakeAll() + return + user_ch_file_paths.append(user_ch_path) + exp_path = os.path.dirname(_posData.pos_path) - def helpNewFile(self): - msg = widgets.myMessageBox(showCentered=False) - href = f'user manual' - txt = html_utils.paragraph(f""" - Cell-ACDC can open both a single image file or files structured - into Position folders.

- If you are just testing out you can load a single image file, but - in general we reccommend structuring your data into Position - folders.

- More info about Position folders in the {href} at the section - called "Create required data structure from microscopy file(s)". - """) - msg.information( - self.host, 'Help on Position folders', txt - ) + dataPrepWin = dataPrep.dataPrepWin() + dataPrepWin.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + dataPrepWin.titleText = """ + Select z-slice (or projection) for each frame/position.
+ Once happy, close the window. + """ + dataPrepWin.show() + dataPrepWin.initLoading() + dataPrepWin.SizeT = self.data[0].SizeT + dataPrepWin.SizeZ = self.data[0].SizeZ + dataPrepWin.metadataAlreadyAsked = True + self.logger.info(f"Loading channel {user_ch_name} data...") + dataPrepWin.loadFiles(exp_path, user_ch_file_paths, user_ch_name) + dataPrepWin.startAction.setDisabled(True) + dataPrepWin.onlySelectingZslice = True - def criticalImgPathNotFound(self, images_path): - self.logger.info( - 'The following folder does not contain valid image files: ' - f'"{images_path}"\n\n' - 'Check that all the positions loaded contain the same channel name. ' - 'Make sure to double check for spelling mistakes or types in the ' - 'channel names.' - ) - msg = widgets.myMessageBox() - msg.addShowInFileManagerButton(images_path) - err_msg = html_utils.paragraph(f""" - The folder

- {images_path}

- does not contain any valid image file!

- Valid file formats are .h5, .tif, _aligned.h5, _aligned.npz. - """) - okButton = msg.critical( - self.host, 'No valid files found!', err_msg, buttonsTexts=('Ok',) - ) + loop = QEventLoop(self) + dataPrepWin.loop = loop + loop.exec_() - def openRecentFile(self, path): - self.logger.info(f'Opening recent folder: {path}') - self.addToRecentPaths(path, logger=self.logger) - self.openFolder(exp_path=path) \ No newline at end of file + self.waitCond.wakeAll() diff --git a/cellacdc/mixins/deleted_rois.py b/cellacdc/mixins/deleted_rois.py index 4d92efb10..5f4904030 100644 --- a/cellacdc/mixins/deleted_rois.py +++ b/cellacdc/mixins/deleted_rois.py @@ -14,297 +14,174 @@ from cellacdc import widgets -class DeletedRoisView: +class DeletedRoisMixin: """Qt-facing adapter around deleted-ROI workflows.""" """Headless decisions for deleted-ROI display and propagation.""" - def roi_axis( - self, - *, - is_polyline: bool, - labels_image_visible: bool, - ) -> str: - if is_polyline or not labels_image_visible: - return 'left' - return 'right' - - def should_render_deleted_roi(self, annotation_mode: str) -> bool: - return 'nothing' not in annotation_mode - - def should_render_deleted_roi_contours(self, annotation_mode: str) -> bool: - return 'contours' in annotation_mode + # @exec_time - def should_render_deleted_roi_overlay(self, annotation_mode: str) -> bool: - return 'overlay segm. masks' in annotation_mode + def addDelPolyLineRoi_cb(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.addDelPolyLineRoiButton) + self.connectLeftClickButtons() + if self.isSnapshot: + self.fixCcaDfAfterEdit("Delete IDs using ROI") + self.updateAllImages() + else: + self.warnEditingWithCca_df("Delete IDs using ROI") + else: + self.tempSegmentON = False + self.ax1_rulerPlotItem.setData([], []) + self.ax1_rulerAnchorsItem.setData([], []) + self.startPointPolyLineItem.setData([], []) + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() - def should_initialize_overlay_masks( - self, - init: bool, - annotation_mode: str, - ) -> bool: - return init and not self.should_render_deleted_roi_contours( - annotation_mode - ) + def addDelROI(self, event): + roi, key = self.createDelROI() + self.addRoiToDelRoiInfo(roi) + if not self.labelsGrad.showLabelsImgAction.isChecked(): + self.ax1.addDelRoiItem(roi, key) + else: + self.ax2.addDelRoiItem(roi, key) + self.applyDelROIimg1(roi, init=True) + self.applyDelROIimg1(roi, init=True, ax=1) - def labels_to_skip(self, deleted_ids: Iterable[int]) -> dict[int, bool]: - return {deleted_id: True for deleted_id in deleted_ids} + if self.isSnapshot: + self.fixCcaDfAfterEdit("Delete IDs using ROI") + self.updateAllImages() + else: + self.warnEditingWithCca_df("Delete IDs using ROI", get_cancelled=True) + def addExistingDelROIs(self): + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + isAx2hidden = not self.labelsGrad.showLabelsImgAction.isChecked() - LEGACY_METHODS = ( - 'removeAlldelROIsCurrentFrame', - 'removeDelROI', - 'removeDelROIFromFutureFrames', - 'updateDelROIinFutureFrames', - 'addDelROI', - 'replacePolyLineRoiWithLineRoi', - 'addRoiToDelRoiInfo', - 'addDelPolyLineRoi_cb', - 'createDelPolyLineRoi', - 'addPointsPolyLineRoi', - 'createDelROI', - 'delROIstartedMoving', - 'clearLostObjContoursItems', - 'delROImoving', - 'delROImovingFinished', - 'restoreAnnotDelROI', - 'restoreDelROIimg1', - 'getDelRoisIDs', - 'getStoredDelRoiIDs', - 'getDelROIlab', - 'getDelRoiMask', - 'initDelRoiLab', - 'moveDelRoisToLeft', - 'applyDelROIimg1', - 'applyDelROIs', - 'setDelRoiState', - 'addExistingDelROIs', - ) + for r, roi in enumerate(delROIs_info["rois"]): + if isinstance(roi, pg.PolyLineROI) or isAx2hidden: + # PolyLine ROIs are only on ax1 + self.ax1.addDelRoiItem(roi, roi.key) + else: + # Rect ROI is on ax2 because ax2 is visible + self.ax2.addDelRoiItem(roi, roi.key) - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) + self.setDelRoiState(roi, delROIs_info["state"][r]) - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) + def addPointsPolyLineRoi(self, closed=False): + self.polyLineRoi.setPoints(self.polyLineRoi.points, closed=closed) + if not closed: + return - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + # Connect closed ROI + self.polyLineRoi.sigRegionChanged.connect(self.delROImoving) + self.polyLineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished) - def removeAlldelROIsCurrentFrame(self): + def addRoiToDelRoiInfo(self, roi: pg.ROI): posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - rois = delROIs_info['rois'].copy() - for roi in rois: - self.ax2.removeDelRoiItem(roi) + for i in range(posData.frame_i, posData.SizeT): + delROIs_info = posData.allData_li[i]["delROIs_info"] + delROIs_info["rois"].append(roi) + delROIs_info["state"].append(roi.getState()) + delROIs_info["delMasks"].append(np.zeros_like(self.currentLab2D)) + delROIs_info["delIDsROI"].append(set()) - for item in self.ax2.items: - if isinstance(item, pg.ROI): - self.ax2.removeDelRoiItem(item) + def applyDelROIimg1(self, roi, init=False, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() - for item in self.ax1.items: - if isinstance(item, pg.ROI) and item != self.labelRoiItem: - self.ax1.removeDelRoiItem(item) + if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): + return - def removeDelROI(self, event): - posData = self.data[self.pos_i] + if init and how.find("contours") == -1: + self.setOverlaySegmMasks(force=True) + return - for ax in (self.ax1, self.ax2): + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + try: + idx = delROIs_info["rois"].index(roi) + except Exception: try: - self.ax1.removeDelRoiItem(self.roi_to_del) - except Exception as err: + ax.removeDelRoiItem(roi) + except Exception: pass + return + delIDs = delROIs_info["delIDsROI"][idx] + delMask = delROIs_info["delMasks"][idx] + if how.find("nothing") != -1: + return + elif how.find("contours") != -1: + self.updateContoursImage(ax=ax) - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - idx = delROIs_info['rois'].index(self.roi_to_del) - delROIs_info['rois'].pop(idx) - delROIs_info['delMasks'].pop(idx) - delROIs_info['delIDsROI'].pop(idx) - delROIs_info['state'].pop(idx) - - self.removeDelROIFromFutureFrames(self.roi_to_del) - self.updateAllImages() + if not delIDs: + return - def removeDelROIFromFutureFrames(self, roi_to_del): - posData = self.data[self.pos_i] + if how.find("overlay segm. masks") != -1: + lab = self.currentLab2D.copy() + lab[delMask > 0] = 0 + if ax == 0: + self.labelsLayerImg1.setImage(lab, autoLevels=False) + else: + self.labelsLayerRightImg.setImage(lab, autoLevels=False) - # Restore deleted IDs from already visited future frames - current_frame_i = posData.frame_i - for i in range(posData.frame_i+1, posData.SizeT): - if posData.allData_li[i]['labels'] is None: - break + self.setAllTextAnnotations(labelsToSkip={ID: True for ID in delIDs}) - delROIs_info = posData.allData_li[i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi_to_del) - except IndexError: - continue + def applyDelROIs(self): + self.logger.info("Applying deletion ROIs (if present)...") - posData.frame_i = i - idx = delROIs_info['rois'].index(roi_to_del) - if delROIs_info['delIDsROI'][idx]: - posData.lab = posData.allData_li[i]['labels'] - self.restoreAnnotDelROI(roi_to_del, enforce=True, draw=False) - posData.allData_li[i]['labels'] = posData.lab + for posData in self.data: + self.current_frame_i = posData.frame_i + for frame_i in range(posData.SizeT): + lab = posData.allData_li[frame_i]["labels"] + if lab is None: + break + delROIs_info = posData.allData_li[frame_i]["delROIs_info"] + delIDs_rois = delROIs_info["delIDsROI"] + if not delIDs_rois: + continue + for delIDs in delIDs_rois: + for delID in delIDs: + lab[lab == delID] = 0 + posData.allData_li[frame_i]["labels"] = lab + # Get the rest of the metadata and store data based on the new lab + posData.frame_i = frame_i self.get_data() self.store_data(autosave=False) - delROIs_info['rois'].pop(idx) - delROIs_info['delMasks'].pop(idx) - delROIs_info['delIDsROI'].pop(idx) - delROIs_info['state'].pop(idx) - - target_axis = self.roi_axis( - is_polyline=isinstance(self.roi_to_del, pg.PolyLineROI), - labels_image_visible=self.labelsGrad.showLabelsImgAction.isChecked(), - ) - if target_axis == 'left': - self.ax1.removeItem(self.roi_to_del) - else: - self.ax2.removeItem(self.roi_to_del) - - # Back to current frame - posData.frame_i = current_frame_i - posData.lab = posData.allData_li[posData.frame_i]['labels'] - self.get_data() - self.store_data() - - def updateDelROIinFutureFrames(self, roi: pg.ROI): - posData = self.data[self.pos_i] - restore_current_frame = False - roiState = roi.getState() - # Restore deleted IDs from already visited future frames - current_frame_i = posData.frame_i - delROIs_info = posData.allData_li[current_frame_i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi) - delROIs_info['state'][idx] = roiState - except Exception as err: - pass - - self.store_data() - - for i in range(posData.frame_i+1, posData.SizeT): - delROIs_info = posData.allData_li[i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi) - except Exception as err: - continue - delROIs_info['state'][idx] = roiState - if posData.allData_li[i]['labels'] is None: - continue - - posData.frame_i = i - posData.lab = posData.allData_li[i]['labels'] - self.restoreAnnotDelROI(roi, enforce=False, draw=False) - posData.allData_li[i]['labels'] = posData.lab + # Back to current frame + posData.frame_i = self.current_frame_i self.get_data() - self.store_data(autosave=False) - restore_current_frame = True - - if not restore_current_frame: - return - - # Back to current frame - posData.frame_i = current_frame_i - posData.lab = posData.allData_li[posData.frame_i]['labels'] - self.get_data() - self.store_data() - def addDelROI(self, event): - roi, key = self.createDelROI() - self.addRoiToDelRoiInfo(roi) - target_axis = self.roi_axis( - is_polyline=False, - labels_image_visible=self.labelsGrad.showLabelsImgAction.isChecked(), - ) - if target_axis == 'left': - self.ax1.addDelRoiItem(roi, key) - else: - self.ax2.addDelRoiItem(roi, key) - self.applyDelROIimg1(roi, init=True) - self.applyDelROIimg1(roi, init=True, ax=1) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete IDs using ROI') - self.updateAllImages() - else: - self.warnEditingWithCca_df( - 'Delete IDs using ROI', get_cancelled=True - ) + def clearLostObjContoursItems(self): + self.ax1_lostObjScatterItem.setData([], []) + self.ax2_lostObjScatterItem.setData([], []) - def replacePolyLineRoiWithLineRoi(self, roi): - x0, y0 = roi.pos().x(), roi.pos().y() - (_, point1), (_, point2) = roi.getLocalHandlePositions() - xr1, yr1 = point1.x(), point1.y() - xr2, yr2 = point2.x(), point2.y() - x1, y1 = xr1+x0, yr1+y0 - x2, y2 = xr2+x0, yr2+x0 - lineRoi = pg.LineROI((x1, y1), (x2, y2), width=0.5) - lineRoi.handleSize = 7 - self.ax1.removeItem(self.polyLineRoi) - self.ax1.addItem(lineRoi) - lineRoi.removeHandle(2) - # Connect closed ROI - lineRoi.sigRegionChanged.connect(self.delROImoving) - lineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished) - return lineRoi + self.ax1_lostTrackedScatterItem.setData([], []) + self.ax2_lostTrackedScatterItem.setData([], []) - def addRoiToDelRoiInfo(self, roi: pg.ROI): - posData = self.data[self.pos_i] - for i in range(posData.frame_i, posData.SizeT): - delROIs_info = posData.allData_li[i]['delROIs_info'] - delROIs_info['rois'].append(roi) - delROIs_info['state'].append(roi.getState()) - delROIs_info['delMasks'].append(np.zeros_like(self.currentLab2D)) - delROIs_info['delIDsROI'].append(set()) + self.ax2_lostObjImageItem.clear() + self.ax2_lostTrackedObjImageItem.clear() - def addDelPolyLineRoi_cb(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.addDelPolyLineRoiButton) - self.connectLeftClickButtons() - if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete IDs using ROI') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Delete IDs using ROI') - else: - self.tempSegmentON = False - self.ax1_rulerPlotItem.setData([], []) - self.ax1_rulerAnchorsItem.setData([], []) - self.startPointPolyLineItem.setData([], []) - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() + self.ax1_lostObjImageItem.clear() + self.ax1_lostTrackedObjImageItem.clear() def createDelPolyLineRoi(self): Y, X = self.currentLab2D.shape self.polyLineRoi = pg.PolyLineROI( - [], rotatable=False, - removable=True, - pen=pg.mkPen(color='r') + [], rotatable=False, removable=True, pen=pg.mkPen(color="r") ) self.polyLineRoi.handleSize = 7 self.polyLineRoi.points = [] key = uuid.uuid4() self.ax1.addDelRoiItem(self.polyLineRoi, key) - def addPointsPolyLineRoi(self, closed=False): - self.polyLineRoi.setPoints(self.polyLineRoi.points, closed=closed) - if not closed: - return - - # Connect closed ROI - self.polyLineRoi.sigRegionChanged.connect(self.delROImoving) - self.polyLineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished) - def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None): - posData = self.data[self.pos_i] + self.data[self.pos_i] if xl is None: xRange, yRange = self.ax1.viewRange() xl = 0 if xRange[0] < 0 else xRange[0] @@ -312,11 +189,12 @@ def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None): Y, X = self.currentLab2D.shape if anchors is None: roi = widgets.DelROI( - [xl, yb], [w, h], + [xl, yb], + [w, h], rotatable=False, removable=True, - pen=pg.mkPen(color='r'), - maxBounds=QRectF(QRect(0,0,X,Y)) + pen=pg.mkPen(color="r"), + maxBounds=QRectF(QRect(0, 0, X, Y)), ) ## handles scaling horizontally around center roi.addScaleHandle([1, 0.5], [0, 0.5]) @@ -341,24 +219,8 @@ def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None): return roi, key - def delROIstartedMoving(self, roi): - self.clearLostObjContoursItems() - - def clearLostObjContoursItems(self): - self.ax1_lostObjScatterItem.setData([], []) - self.ax2_lostObjScatterItem.setData([], []) - - self.ax1_lostTrackedScatterItem.setData([], []) - self.ax2_lostTrackedScatterItem.setData([], []) - - self.ax2_lostObjImageItem.clear() - self.ax2_lostTrackedObjImageItem.clear() - - self.ax1_lostObjImageItem.clear() - self.ax1_lostTrackedObjImageItem.clear() - def delROImoving(self, roi): - roi.setPen(color=(255,255,0)) + roi.setPen(color=(255, 255, 0)) # First bring back IDs if the ROI moved away self.restoreAnnotDelROI(roi) self.setImageImg2() @@ -366,95 +228,14 @@ def delROImoving(self, roi): self.applyDelROIimg1(roi, ax=1) def delROImovingFinished(self, roi: pg.ROI): - roi.setPen(color='r') + roi.setPen(color="r") self.update_rp() self.updateAllImages() - QTimer.singleShot( - 300, partial(self.updateDelROIinFutureFrames, roi) - ) + QTimer.singleShot(300, partial(self.updateDelROIinFutureFrames, roi)) - def restoreAnnotDelROI(self, roi, enforce=True, draw=True): - posData = self.data[self.pos_i] - ROImask = self.getDelRoiMask(roi) - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi) - except Exception as err: - return - - delMask = delROIs_info['delMasks'][idx] - delIDs = delROIs_info['delIDsROI'][idx] - lab2D = self.get_2Dlab(posData.lab) - result = self.host.view_model.label_edits.restore_deleted_roi_labels( - lab2D, - self.currentLab2D, - delMask, - ROImask, - delIDs, - enforce=enforce, - ) - if draw: - for ID, delMaskID in result.restored_masks: - self.restoreDelROIimg1(delMaskID, ID, ax=0) - self.restoreDelROIimg1(delMaskID, ID, ax=1) - - delROIs_info['delIDsROI'][idx] = result.remaining_deleted_ids - self.set_2Dlab(result.labels_2d) - self.update_rp() - - def restoreDelROIimg1(self, delMaskID, delID, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - if not self.should_render_deleted_roi(how): - return - - if self.should_render_deleted_roi_contours(how): - rp_delmask = skimage.measure.regionprops(delMaskID.astype(np.uint8)) - if len(rp_delmask) > 0: - obj = rp_delmask[0] - self.addObjContourToContoursImage(obj=obj, ax=ax) - elif self.should_render_deleted_roi_overlay(how): - if ax == 0: - self.labelsLayerImg1.setImage( - self.currentLab2D, autoLevels=False - ) - else: - self.labelsLayerRightImg.setImage( - self.currentLab2D, autoLevels=False - ) - - def getDelRoisIDs(self): - posData = self.data[self.pos_i] - roi_masks = [] - if posData.frame_i > 0: - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: - if ( - not self.ax1.isDelRoiItemPresent(roi) - and not self.ax2.isDelRoiItemPresent(roi) - ): - continue - roi_masks.append(self.getDelRoiMask(roi)) - - return self.host.view_model.label_edits.label_ids_in_masks( - posData.lab, - roi_masks, - additional_labels=prev_lab if posData.frame_i > 0 else None, - ) - - def getStoredDelRoiIDs(self, frame_i=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - delROIs_info = posData.allData_li[frame_i]['delROIs_info'] - return self.host.view_model.label_edits.collect_deleted_roi_ids( - delROIs_info['delIDsROI'] - ) + def delROIstartedMoving(self, roi): + self.clearLostObjContoursItems() - # @exec_time def getDelROIlab(self, input_lab_2D=None): posData = self.data[self.pos_i] if self.delRoiLab is None: @@ -466,35 +247,33 @@ def getDelROIlab(self, input_lab_2D=None): else: out_lab[:] = input_lab_2D - roi_masks = [] - deleted_masks = [] - deleted_ids_by_roi = [] - roi_indices = [] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: - if ( - not self.ax1.isDelRoiItemPresent(roi) - and not self.ax2.isDelRoiItemPresent(roi) - ): + allDelIDs = set() + # Iterate rois and delete IDs + for roi in posData.allData_li[posData.frame_i]["delROIs_info"]["rois"]: + if not self.ax1.isDelRoiItemPresent( + roi + ) and not self.ax2.isDelRoiItemPresent(roi): continue - idx = delROIs_info['rois'].index(roi) - roi_indices.append(idx) - roi_masks.append(self.getDelRoiMask(roi)) - deleted_masks.append(delROIs_info['delMasks'][idx]) - deleted_ids_by_roi.append(delROIs_info['delIDsROI'][idx]) - - result = self.host.view_model.label_edits.apply_deleted_roi_masks( - out_lab, - roi_masks, - deleted_masks, - deleted_ids_by_roi, - ) - for result_i, roi_i in enumerate(roi_indices): - # Keep a mask of deleted IDs to bring them back when ROI moves. - delROIs_info['delMasks'][roi_i] = result.deleted_masks[result_i] - delROIs_info['delIDsROI'][roi_i] = ( - result.deleted_ids_by_roi[result_i] - ) + ROImask = self.getDelRoiMask(roi) + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + idx = delROIs_info["rois"].index(roi) + delObjROImask = delROIs_info["delMasks"][idx] + delIDsROI = delROIs_info["delIDsROI"][idx] + delROIlabRp = skimage.measure.regionprops(out_lab) + for delObj in delROIlabRp: + isDelObj = np.any(ROImask[delObj.slice][delObj.image]) + if not isDelObj: + continue + + delObjROImask[delObj.slice][delObj.image] = delObj.label + out_lab[delObj.slice][delObj.image] = 0 + + delIDsROI.add(delObj.label) + allDelIDs.add(delObj.label) + + # Keep a mask of deleted IDs to bring them back when roi moves + delROIs_info["delMasks"][idx] = delObjROImask + delROIs_info["delIDsROI"][idx] = delIDsROI # printl( # f't1-t0: {(t1-t0)*1000:.3f} ms,', @@ -506,41 +285,86 @@ def getDelROIlab(self, input_lab_2D=None): # sep='\n' # ) - return result.deleted_ids, result.labels_2d + return allDelIDs, out_lab def getDelRoiMask(self, roi, posData=None, z_slice=None): if posData is None: posData = self.data[self.pos_i] if z_slice is None: z_slice = self.z_lab() + ROImask = np.zeros(posData.lab.shape, bool) if isinstance(roi, pg.PolyLineROI): + r, c = [], [] x0, y0 = roi.pos().x(), roi.pos().y() - points = [] for _, point in roi.getLocalHandlePositions(): xr, yr = point.x(), point.y() - points.append((int(xr+x0), int(yr+y0))) - return self.host.view_model.label_edits.polygon_roi_mask( - posData.lab.shape, - points, - z_slice=z_slice, - ) + r.append(int(yr + y0)) + c.append(int(xr + x0)) + if not r or not c: + return ROImask + + if len(r) == 2: + rr, cc, val = skimage.draw.line_aa(r[0], c[0], r[1], c[1]) + else: + rr, cc = skimage.draw.polygon(r, c, shape=self.currentLab2D.shape) + + Y, X = self.currentLab2D.shape + rr = rr[(rr >= 0) & (rr < Y)] + cc = cc[(cc >= 0) & (cc < X)] + + if self.isSegm3D: + ROImask[z_slice, rr, cc] = True + else: + ROImask[rr, cc] = True elif isinstance(roi, pg.LineROI): (_, point1), (_, point2) = roi.getSceneHandlePositions() point1 = self.ax1.vb.mapSceneToView(point1) point2 = self.ax1.vb.mapSceneToView(point2) - return self.host.view_model.label_edits.line_roi_mask( - posData.lab.shape, - (point1.x(), point1.y()), - (point2.x(), point2.y()), - z_slice=z_slice, - ) + x1, y1 = int(point1.x()), int(point1.y()) + x2, y2 = int(point2.x()), int(point2.y()) + rr, cc, val = skimage.draw.line_aa(y1, x1, y2, x2) + if self.isSegm3D: + ROImask[z_slice, rr, cc] = True + else: + ROImask[rr, cc] = True else: - return self.host.view_model.label_edits.rectangle_roi_mask( - posData.lab.shape, - roi.pos(), - roi.size(), - z_slice=z_slice, - ) + x0, y0 = [int(c) for c in roi.pos()] + w, h = [int(c) for c in roi.size()] + if self.isSegm3D: + ROImask[z_slice, y0 : y0 + h, x0 : x0 + w] = True + else: + ROImask[y0 : y0 + h, x0 : x0 + w] = True + return ROImask + + def getDelRoisIDs(self): + posData = self.data[self.pos_i] + if posData.frame_i > 0: + prev_lab = posData.allData_li[posData.frame_i - 1]["labels"] + allDelIDs = set() + for roi in posData.allData_li[posData.frame_i]["delROIs_info"]["rois"]: + if not self.ax1.isDelRoiItemPresent( + roi + ) and not self.ax2.isDelRoiItemPresent(roi): + continue + + ROImask = self.getDelRoiMask(roi) + delIDs = posData.lab[ROImask] + allDelIDs.update(delIDs) + if posData.frame_i > 0: + delIDsPrevFrame = prev_lab[ROImask] + allDelIDs.update(delIDsPrevFrame) + return allDelIDs + + def getStoredDelRoiIDs(self, frame_i=None): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + allDelIDs = set() + delROIs_info = posData.allData_li[frame_i]["delROIs_info"] + delIDs_rois = delROIs_info["delIDsROI"] + for delIDs in delIDs_rois: + allDelIDs.update(delIDs) + return allDelIDs def initDelRoiLab(self): posData = self.data[self.pos_i] @@ -550,87 +374,208 @@ def initDelRoiLab(self): self.delRoiLab = np.zeros((Y, X), dtype=np.uint32) + def labels_to_skip(self, deleted_ids: Iterable[int]) -> dict[int, bool]: + return {deleted_id: True for deleted_id in deleted_ids} + + LEGACY_METHODS = ( + "removeAlldelROIsCurrentFrame", + "removeDelROI", + "removeDelROIFromFutureFrames", + "updateDelROIinFutureFrames", + "addDelROI", + "replacePolyLineRoiWithLineRoi", + "addRoiToDelRoiInfo", + "addDelPolyLineRoi_cb", + "createDelPolyLineRoi", + "addPointsPolyLineRoi", + "createDelROI", + "delROIstartedMoving", + "clearLostObjContoursItems", + "delROImoving", + "delROImovingFinished", + "restoreAnnotDelROI", + "restoreDelROIimg1", + "getDelRoisIDs", + "getStoredDelRoiIDs", + "getDelROIlab", + "getDelRoiMask", + "initDelRoiLab", + "moveDelRoisToLeft", + "applyDelROIimg1", + "applyDelROIs", + "setDelRoiState", + "addExistingDelROIs", + ) + def moveDelRoisToLeft(self): # Move del ROIs to the left image for posData in self.data: - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - for roi in delROIs_info['rois']: + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + for roi in delROIs_info["rois"]: if not self.ax2.isDelRoiItemPresent(roi): continue self.ax1.addDelRoiItem(roi, roi.key) self.ax2.removeDelRoiItem(roi) - def applyDelROIimg1(self, roi, init=False, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() + def removeAlldelROIsCurrentFrame(self): + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + rois = delROIs_info["rois"].copy() + for roi in rois: + self.ax2.removeDelRoiItem(roi) - if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): - return + for item in self.ax2.items: + if isinstance(item, pg.ROI): + self.ax2.removeDelRoiItem(item) - if self.should_initialize_overlay_masks(init, how): - self.setOverlaySegmMasks(force=True) - return + for item in self.ax1.items: + if isinstance(item, pg.ROI) and item != self.labelRoiItem: + self.ax1.removeDelRoiItem(item) + def removeDelROI(self, event): posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi) - except Exception as err: + + for ax in (self.ax1, self.ax2): try: - ax.removeDelRoiItem(roi) - except Exception as err: + self.ax1.removeDelRoiItem(self.roi_to_del) + except Exception: pass - return - delIDs = delROIs_info['delIDsROI'][idx] - delMask = delROIs_info['delMasks'][idx] - if not self.should_render_deleted_roi(how): - return - elif self.should_render_deleted_roi_contours(how): - self.updateContoursImage(ax=ax) - if not delIDs: - return + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + idx = delROIs_info["rois"].index(self.roi_to_del) + delROIs_info["rois"].pop(idx) + delROIs_info["delMasks"].pop(idx) + delROIs_info["delIDsROI"].pop(idx) + delROIs_info["state"].pop(idx) - if self.should_render_deleted_roi_overlay(how): - lab = self.currentLab2D.copy() - lab[delMask > 0] = 0 - if ax == 0: - self.labelsLayerImg1.setImage(lab, autoLevels=False) - else: - self.labelsLayerRightImg.setImage(lab, autoLevels=False) + self.removeDelROIFromFutureFrames(self.roi_to_del) + self.updateAllImages() - self.setAllTextAnnotations( - labelsToSkip=self.labels_to_skip(delIDs) - ) + def removeDelROIFromFutureFrames(self, roi_to_del): + posData = self.data[self.pos_i] - def applyDelROIs(self): - self.logger.info('Applying deletion ROIs (if present)...') + # Restore deleted IDs from already visited future frames + current_frame_i = posData.frame_i + for i in range(posData.frame_i + 1, posData.SizeT): + if posData.allData_li[i]["labels"] is None: + break - for posData in self.data: - self.current_frame_i = posData.frame_i - for frame_i in range(posData.SizeT): - lab = posData.allData_li[frame_i]['labels'] - if lab is None: - break - delROIs_info = posData.allData_li[frame_i]['delROIs_info'] - delIDs_rois = delROIs_info['delIDsROI'] - if not delIDs_rois: - continue - for delIDs in delIDs_rois: - for delID in delIDs: - lab[lab==delID] = 0 - posData.allData_li[frame_i]['labels'] = lab - # Get the rest of the metadata and store data based on the new lab - posData.frame_i = frame_i + delROIs_info = posData.allData_li[i]["delROIs_info"] + try: + idx = delROIs_info["rois"].index(roi_to_del) + except IndexError: + continue + + posData.frame_i = i + idx = delROIs_info["rois"].index(roi_to_del) + if delROIs_info["delIDsROI"][idx]: + posData.lab = posData.allData_li[i]["labels"] + self.restoreAnnotDelROI(roi_to_del, enforce=True, draw=False) + posData.allData_li[i]["labels"] = posData.lab self.get_data() self.store_data(autosave=False) + delROIs_info["rois"].pop(idx) + delROIs_info["delMasks"].pop(idx) + delROIs_info["delIDsROI"].pop(idx) + delROIs_info["state"].pop(idx) - # Back to current frame - posData.frame_i = self.current_frame_i - self.get_data() + if isinstance(self.roi_to_del, pg.PolyLineROI): + # PolyLine ROIs are only on ax1 + self.ax1.removeItem(self.roi_to_del) + elif not self.labelsGrad.showLabelsImgAction.isChecked(): + # Rect ROI is on ax1 because ax2 is hidden + self.ax1.removeItem(self.roi_to_del) + else: + # Rect ROI is on ax2 because ax2 is visible + self.ax2.removeItem(self.roi_to_del) + + # Back to current frame + posData.frame_i = current_frame_i + posData.lab = posData.allData_li[posData.frame_i]["labels"] + self.get_data() + self.store_data() + + def replacePolyLineRoiWithLineRoi(self, roi): + x0, y0 = roi.pos().x(), roi.pos().y() + (_, point1), (_, point2) = roi.getLocalHandlePositions() + xr1, yr1 = point1.x(), point1.y() + xr2, yr2 = point2.x(), point2.y() + x1, y1 = xr1 + x0, yr1 + y0 + x2, y2 = xr2 + x0, yr2 + x0 + lineRoi = pg.LineROI((x1, y1), (x2, y2), width=0.5) + lineRoi.handleSize = 7 + self.ax1.removeItem(self.polyLineRoi) + self.ax1.addItem(lineRoi) + lineRoi.removeHandle(2) + # Connect closed ROI + lineRoi.sigRegionChanged.connect(self.delROImoving) + lineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished) + return lineRoi + + def restoreAnnotDelROI(self, roi, enforce=True, draw=True): + posData = self.data[self.pos_i] + ROImask = self.getDelRoiMask(roi) + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + try: + idx = delROIs_info["rois"].index(roi) + except Exception: + return + + delMask = delROIs_info["delMasks"][idx] + delIDs = delROIs_info["delIDsROI"][idx] + overlapROIdelIDs = np.unique(delMask[ROImask]) + lab2D = self.get_2Dlab(posData.lab) + restoredIDs = set() + for ID in delIDs: + if ID in overlapROIdelIDs and not enforce: + continue + + restoredIDs.add(ID) + + delMaskID = delMask == ID + self.currentLab2D[delMaskID] = ID + lab2D[delMaskID] = ID + + if draw: + self.restoreDelROIimg1(delMaskID, ID, ax=0) + self.restoreDelROIimg1(delMaskID, ID, ax=1) + + delMask[delMaskID] = 0 + + delROIs_info["delIDsROI"][idx] = delIDs - restoredIDs + self.set_2Dlab(lab2D) + self.update_rp() + + def restoreDelROIimg1(self, delMaskID, delID, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + if how.find("nothing") != -1: + return + + if how.find("contours") != -1: + rp_delmask = skimage.measure.regionprops(delMaskID.astype(np.uint8)) + if len(rp_delmask) > 0: + obj = rp_delmask[0] + self.addObjContourToContoursImage(obj=obj, ax=ax) + elif how.find("overlay segm. masks") != -1: + if ax == 0: + self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) + else: + self.labelsLayerRightImg.setImage(self.currentLab2D, autoLevels=False) + + def roi_axis( + self, + *, + is_polyline: bool, + labels_image_visible: bool, + ) -> str: + if is_polyline or not labels_image_visible: + return "left" + return "right" def setDelRoiState(self, roi: pg.ROI, state): roi.sigRegionChanged.disconnect() @@ -639,19 +584,61 @@ def setDelRoiState(self, roi: pg.ROI, state): roi.sigRegionChanged.connect(self.delROImoving) roi.sigRegionChangeFinished.connect(self.delROImovingFinished) - def addExistingDelROIs(self): + def should_initialize_overlay_masks( + self, + init: bool, + annotation_mode: str, + ) -> bool: + return init and not self.should_render_deleted_roi_contours(annotation_mode) + + def should_render_deleted_roi(self, annotation_mode: str) -> bool: + return "nothing" not in annotation_mode + + def should_render_deleted_roi_contours(self, annotation_mode: str) -> bool: + return "contours" in annotation_mode + + def should_render_deleted_roi_overlay(self, annotation_mode: str) -> bool: + return "overlay segm. masks" in annotation_mode + + def updateDelROIinFutureFrames(self, roi: pg.ROI): posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - for r, roi in enumerate(delROIs_info['rois']): - target_axis = self.roi_axis( - is_polyline=isinstance(roi, pg.PolyLineROI), - labels_image_visible=( - self.labelsGrad.showLabelsImgAction.isChecked() - ), - ) - if target_axis == 'left': - self.ax1.addDelRoiItem(roi, roi.key) - else: - self.ax2.addDelRoiItem(roi, roi.key) + restore_current_frame = False + + roiState = roi.getState() + # Restore deleted IDs from already visited future frames + current_frame_i = posData.frame_i + delROIs_info = posData.allData_li[current_frame_i]["delROIs_info"] + try: + idx = delROIs_info["rois"].index(roi) + delROIs_info["state"][idx] = roiState + except Exception: + pass + + self.store_data() + + for i in range(posData.frame_i + 1, posData.SizeT): + delROIs_info = posData.allData_li[i]["delROIs_info"] + try: + idx = delROIs_info["rois"].index(roi) + except Exception: + continue + delROIs_info["state"][idx] = roiState + if posData.allData_li[i]["labels"] is None: + continue + + posData.frame_i = i + posData.lab = posData.allData_li[i]["labels"] + self.restoreAnnotDelROI(roi, enforce=False, draw=False) + posData.allData_li[i]["labels"] = posData.lab + self.get_data() + self.store_data(autosave=False) + restore_current_frame = True - self.setDelRoiState(roi, delROIs_info['state'][r]) \ No newline at end of file + if not restore_current_frame: + return + + # Back to current frame + posData.frame_i = current_frame_i + posData.lab = posData.allData_li[posData.frame_i]["labels"] + self.get_data() + self.store_data() diff --git a/cellacdc/mixins/display_decorations.py b/cellacdc/mixins/display_decorations.py index d7ed997aa..604b6d977 100644 --- a/cellacdc/mixins/display_decorations.py +++ b/cellacdc/mixins/display_decorations.py @@ -7,11 +7,81 @@ from cellacdc import apps, widgets -class DisplayDecorationsView: +class DisplayDecorationsMixin: """Qt-facing adapter around display-decoration contracts.""" """Headless display-decoration decision rules.""" + def _ax1_raw_view_range(self): + if self.exportToImageWindow is None: + return self.ax1.viewRange() + export_mask = np.all(self.exportMaskImage == [0, 0, 0, 0], axis=-1) + if np.all(export_mask): + return self.ax1.viewRange() + return self.ax1.viewRange(export_mask) + + def add_scale_bar(self, checked): + if checked: + pos_data = self.data[self.pos_i] + y_size, x_size = self.img1.image.shape[:2] + view_range = self.ax1_view_range() + self.scaleBarDialog = apps.ScaleBarPropertiesDialog( + x_size, + y_size, + pos_data.PhysicalSizeX, + parent=self, + ) + self.scaleBarDialog.show() + self.scaleBar = widgets.ScaleBar( + (y_size, x_size), view_range, parent=self.ax1 + ) + self.scaleBar.sigEditProperties.connect(self.edit_scale_bar_properties) + self.scaleBar.sigRemove.connect(self.edit_scale_bar_remove) + self.scaleBar.addToAxis(self.ax1) + self.scaleBar.draw(**self.scaleBarDialog.kwargs()) + self.scaleBarDialog.sigValueChanged.connect(self.update_scale_bar) + self.scaleBarDialog.exec_() + if self.scaleBarDialog.cancel: + self.addScaleBarAction.setChecked(False) + return + else: + self.scaleBar.removeFromAxis(self.ax1) + + self.scaleBarDialog = None + self.imgGrad.addScaleBarAction.setChecked(checked) + + def add_timestamp(self, checked): + if checked: + pos_data = self.data[self.pos_i] + y_size, x_size = self.img1.image.shape[:2] + view_range = self.ax1_view_range() + self.timestampDialog = apps.TimestampPropertiesDialog(parent=self) + self.timestampDialog.show() + self.timestamp = widgets.TimestampItem( + y_size, + x_size, + view_range, + secondsPerFrame=pos_data.TimeIncrement, + start_timedelta=self.timestampStartTimedelta, + ) + self.timestamp.sigEditProperties.connect(self.edit_timestamp_properties) + self.timestamp.sigRemove.connect(self.edit_timestamp_remove) + self.timestamp.addToAxis(self.ax1) + self.timestamp.draw(pos_data.frame_i, **self.timestampDialog.kwargs()) + self.timestampDialog.sigValueChanged.connect(self.update_timestamp) + self.timestampDialog.exec_() + else: + self.timestamp.removeFromAxis(self.ax1) + + self.timestampDialog = None + self.imgGrad.addTimestampAction.setChecked(checked) + + def ax1_view_range(self, integers=False): + view_range = self._ax1_raw_view_range() + if not integers: + return view_range + return self.integer_view_range(view_range) + def clamped_view_range(self, image_shape, view_range): y_size, x_size = image_shape[:2] x_range, y_range = view_range @@ -21,6 +91,36 @@ def clamped_view_range(self, image_shape, view_range): y_max = y_size if y_range[1] >= y_size else y_range[1] return int(y_min), int(y_max), int(x_min), int(x_max) + def edit_scale_bar_properties(self, properties): + y_size, x_size = self.img1.image.shape[:2] + pos_data = self.data[self.pos_i] + self.scaleBarDialog = apps.ScaleBarPropertiesDialog( + x_size, + y_size, + pos_data.PhysicalSizeX, + parent=self, + **properties, + ) + self.scaleBarDialog.sigValueChanged.connect(self.update_scale_bar) + self.scaleBarDialog.exec_() + + def edit_scale_bar_remove(self, timestamp): + self.addScaleBarAction.setChecked(False) + + def edit_timestamp_properties(self, properties): + self.timestampDialog = apps.TimestampPropertiesDialog(parent=self, **properties) + self.timestampDialog.sigValueChanged.connect(self.update_timestamp) + self.timestampDialog.show() + + def edit_timestamp_remove(self, timestamp): + self.addTimestampAction.setChecked(False) + + def get_view_range(self): + return self.clamped_view_range( + self.img1.image.shape, + self.ax1.viewRange(), + ) + def integer_view_range(self, view_range): x_range, y_range = view_range return ( @@ -52,20 +152,31 @@ def should_update_timestamp_frame( ) -> bool: return has_timestamp and timestamp_enabled + def store_view_range(self): + if not self.should_store_view_range( + has_range_reset_state=hasattr(self, "isRangeReset"), + is_range_reset=getattr(self, "isRangeReset", False), + ): + return + self.ax1_viewRange = self.ax1.viewRange() + self.isRangeReset = False + + def update_scale_bar(self, scale_bar_kwargs): + self.scaleBar.draw(**scale_bar_kwargs) - def __init__(self, host): - self.host = host - def get_view_range(self): - return self.clamped_view_range( - self.host.img1.image.shape, - self.host.ax1.viewRange(), - ) + def update_timestamp(self, timestamp_kwargs): + pos_data = self.data[self.pos_i] + self.timestamp.draw(pos_data.frame_i, **timestamp_kwargs) - def ax1_view_range(self, integers=False): - view_range = self._ax1_raw_view_range() - if not integers: - return view_range - return self.integer_view_range(view_range) + def update_timestamp_frame(self): + if not self.should_update_timestamp_frame( + has_timestamp=hasattr(self, "timestamp"), + timestamp_enabled=self.addTimestampAction.isChecked(), + ): + return + + pos_data = self.data[self.pos_i] + self.timestamp.setText(pos_data.frame_i) def view_range_changed( self, @@ -73,166 +184,26 @@ def view_range_changed( view_range, updateExportImageMask=True, ): - self.host.status_hover_view.update_values_status_bar() + self.status_hover_view.update_values_status_bar() - if hasattr(self.host, 'scaleBar'): - scale_bar_move_with_zoom = ( - self.host.scaleBar.properties()['move_with_zoom'] - ) + if hasattr(self, "scaleBar"): + scale_bar_move_with_zoom = self.scaleBar.properties()["move_with_zoom"] else: scale_bar_move_with_zoom = False if self.should_move_decoration( - dialog_open=self.host.scaleBarDialog is not None, + dialog_open=self.scaleBarDialog is not None, move_with_zoom=scale_bar_move_with_zoom, ): - self.host.scaleBar.updatePosViewRangeChanged(view_range) + self.scaleBar.updatePosViewRangeChanged(view_range) - if hasattr(self.host, 'timestamp'): - timestamp_move_with_zoom = ( - self.host.timestamp.properties()['move_with_zoom'] - ) + if hasattr(self, "timestamp"): + timestamp_move_with_zoom = self.timestamp.properties()["move_with_zoom"] else: timestamp_move_with_zoom = False if self.should_move_decoration( - dialog_open=self.host.timestampDialog is not None, + dialog_open=self.timestampDialog is not None, move_with_zoom=timestamp_move_with_zoom, ): - self.host.timestamp.updatePosViewRangeChanged(view_range) - - self.host._viewRange = view_range - - def store_view_range(self): - if not self.should_store_view_range( - has_range_reset_state=hasattr(self.host, 'isRangeReset'), - is_range_reset=getattr(self.host, 'isRangeReset', False), - ): - return - self.host.ax1_viewRange = self.host.ax1.viewRange() - self.host.isRangeReset = False - - def add_timestamp(self, checked): - if checked: - pos_data = self.host.data[self.host.pos_i] - y_size, x_size = self.host.img1.image.shape[:2] - view_range = self.ax1_view_range() - self.host.timestampDialog = apps.TimestampPropertiesDialog( - parent=self.host - ) - self.host.timestampDialog.show() - self.host.timestamp = widgets.TimestampItem( - y_size, - x_size, - view_range, - secondsPerFrame=pos_data.TimeIncrement, - start_timedelta=self.host.timestampStartTimedelta, - ) - self.host.timestamp.sigEditProperties.connect( - self.edit_timestamp_properties - ) - self.host.timestamp.sigRemove.connect( - self.edit_timestamp_remove - ) - self.host.timestamp.addToAxis(self.host.ax1) - self.host.timestamp.draw( - pos_data.frame_i, **self.host.timestampDialog.kwargs() - ) - self.host.timestampDialog.sigValueChanged.connect( - self.update_timestamp - ) - self.host.timestampDialog.exec_() - else: - self.host.timestamp.removeFromAxis(self.host.ax1) - - self.host.timestampDialog = None - self.host.imgGrad.addTimestampAction.setChecked(checked) - - def add_scale_bar(self, checked): - if checked: - pos_data = self.host.data[self.host.pos_i] - y_size, x_size = self.host.img1.image.shape[:2] - view_range = self.ax1_view_range() - self.host.scaleBarDialog = apps.ScaleBarPropertiesDialog( - x_size, - y_size, - pos_data.PhysicalSizeX, - parent=self.host, - ) - self.host.scaleBarDialog.show() - self.host.scaleBar = widgets.ScaleBar( - (y_size, x_size), view_range, parent=self.host.ax1 - ) - self.host.scaleBar.sigEditProperties.connect( - self.edit_scale_bar_properties - ) - self.host.scaleBar.sigRemove.connect(self.edit_scale_bar_remove) - self.host.scaleBar.addToAxis(self.host.ax1) - self.host.scaleBar.draw(**self.host.scaleBarDialog.kwargs()) - self.host.scaleBarDialog.sigValueChanged.connect( - self.update_scale_bar - ) - self.host.scaleBarDialog.exec_() - if self.host.scaleBarDialog.cancel: - self.host.addScaleBarAction.setChecked(False) - return - else: - self.host.scaleBar.removeFromAxis(self.host.ax1) - - self.host.scaleBarDialog = None - self.host.imgGrad.addScaleBarAction.setChecked(checked) - - def update_scale_bar(self, scale_bar_kwargs): - self.host.scaleBar.draw(**scale_bar_kwargs) - - def update_timestamp(self, timestamp_kwargs): - pos_data = self.host.data[self.host.pos_i] - self.host.timestamp.draw(pos_data.frame_i, **timestamp_kwargs) - - def edit_scale_bar_remove(self, timestamp): - self.host.addScaleBarAction.setChecked(False) - - def edit_scale_bar_properties(self, properties): - y_size, x_size = self.host.img1.image.shape[:2] - pos_data = self.host.data[self.host.pos_i] - self.host.scaleBarDialog = apps.ScaleBarPropertiesDialog( - x_size, - y_size, - pos_data.PhysicalSizeX, - parent=self.host, - **properties, - ) - self.host.scaleBarDialog.sigValueChanged.connect( - self.update_scale_bar - ) - self.host.scaleBarDialog.exec_() - - def edit_timestamp_remove(self, timestamp): - self.host.addTimestampAction.setChecked(False) - - def edit_timestamp_properties(self, properties): - self.host.timestampDialog = apps.TimestampPropertiesDialog( - parent=self.host, **properties - ) - self.host.timestampDialog.sigValueChanged.connect( - self.update_timestamp - ) - self.host.timestampDialog.show() - - def update_timestamp_frame(self): - if not self.should_update_timestamp_frame( - has_timestamp=hasattr(self.host, 'timestamp'), - timestamp_enabled=self.host.addTimestampAction.isChecked(), - ): - return + self.timestamp.updatePosViewRangeChanged(view_range) - pos_data = self.host.data[self.host.pos_i] - self.host.timestamp.setText(pos_data.frame_i) - - def _ax1_raw_view_range(self): - if self.host.exportToImageWindow is None: - return self.host.ax1.viewRange() - export_mask = np.all( - self.host.exportMaskImage == [0, 0, 0, 0], axis=-1 - ) - if np.all(export_mask): - return self.host.ax1.viewRange() - return self.host.ax1.viewRange(export_mask) \ No newline at end of file + self._viewRange = view_range diff --git a/cellacdc/mixins/draw_clear_region.py b/cellacdc/mixins/draw_clear_region.py index 993a1c8b8..4d98312a2 100644 --- a/cellacdc/mixins/draw_clear_region.py +++ b/cellacdc/mixins/draw_clear_region.py @@ -3,14 +3,89 @@ from __future__ import annotations - -from dataclasses import dataclass -class DrawClearRegionView: +class DrawClearRegionMixin: """Qt-facing adapter around the scriptable draw-clear view-model.""" """Headless draw-clear-region decision rules.""" - single_z_slice_projection = 'single z-slice' + single_z_slice_projection = "single z-slice" + + def _z_range(self, size_z): + z_projection = None + single_z_range = None + if self.isSegm3D: + z_projection = self.zProjComboBox.currentText() + if self.is_single_z_projection(z_projection): + single_z_range = self.drawClearRegionToolbar.zRange( + self.z_lab(), size_z + ) + return self.z_range_for_projection( + is_segm_3d=self.isSegm3D, + z_projection=z_projection, + size_z=size_z, + single_z_range=single_z_range, + ) + + def clear_objects_in_freehand_region(self): + self.logger.info("Clearing objects inside freehand region...") + self.storeUndoRedoStates(False, storeImage=False, storeOnlyZoom=True) + + pos_data = self.data[self.pos_i] + z_range = self._z_range(pos_data.SizeZ) + region_slice = self.freeRoiItem.slice(zRange=z_range) + mask = self.freeRoiItem.mask() + region_lab = pos_data.lab[(...,) + region_slice].copy() + + enclosed_only = ( + self.drawClearRegionToolbar.clearOnlyEnclosedObjsRadioButton.isChecked() + ) + selection_result = self.view_model.label_edits.select_labels_in_region( + region_lab, + mask, + enclosed_only=enclosed_only, + ) + clear_ids = selection_result.selected_ids + + if not clear_ids: + self.logger.warning( + self.empty_selection_warning(enclosed_only=enclosed_only) + ) + return + + self.deleteIDmiddleClick(clear_ids, False, False) + self.update_cca_df_deletedIDs(pos_data, clear_ids) + self.freeRoiItem.clear() + self.updateAllImages() + + def empty_selection_warning(self, *, enclosed_only: bool) -> str: + if enclosed_only: + return "None of the objects in the freehand region are fully enclosed" + return "None of the objects are touching the freehand region" + + def is_single_z_projection(self, z_projection: str) -> bool: + return z_projection == self.single_z_slice_projection + + def toggle(self, checked): + pos_data = self.data[self.pos_i] + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.drawClearRegionButton) + self.connectLeftClickButtons() + + self.drawClearRegionToolbar.setVisible(checked) + state = self.toolbar_state( + checked=checked, + is_segm_3d=self.isSegm3D, + size_z=pos_data.SizeZ, + ) + if not state.update_z_control: + return + if state.z_control_enabled: + self.drawClearRegionToolbar.setZslicesControlEnabled( + True, SizeZ=state.size_z + ) + return + self.drawClearRegionToolbar.setZslicesControlEnabled(False) def toolbar_state( self, @@ -42,92 +117,3 @@ def z_range_for_projection( if z_projection == self.single_z_slice_projection: return single_z_range return (0, size_z) - - def is_single_z_projection(self, z_projection: str) -> bool: - return z_projection == self.single_z_slice_projection - - def empty_selection_warning(self, *, enclosed_only: bool) -> str: - if enclosed_only: - return ( - 'None of the objects in the freehand region are fully enclosed' - ) - return 'None of the objects are touching the freehand region' - - - def __init__(self, host): - self.host = host - def toggle(self, checked): - pos_data = self.host.data[self.host.pos_i] - if checked: - self.host.disconnectLeftClickButtons() - self.host.uncheckLeftClickButtons(self.host.drawClearRegionButton) - self.host.connectLeftClickButtons() - - self.host.drawClearRegionToolbar.setVisible(checked) - state = self.toolbar_state( - checked=checked, - is_segm_3d=self.host.isSegm3D, - size_z=pos_data.SizeZ, - ) - if not state.update_z_control: - return - if state.z_control_enabled: - self.host.drawClearRegionToolbar.setZslicesControlEnabled( - True, SizeZ=state.size_z - ) - return - self.host.drawClearRegionToolbar.setZslicesControlEnabled(False) - - def clear_objects_in_freehand_region(self): - self.host.logger.info('Clearing objects inside freehand region...') - self.host.storeUndoRedoStates( - False, storeImage=False, storeOnlyZoom=True - ) - - pos_data = self.host.data[self.host.pos_i] - z_range = self._z_range(pos_data.SizeZ) - region_slice = self.host.freeRoiItem.slice(zRange=z_range) - mask = self.host.freeRoiItem.mask() - region_lab = pos_data.lab[(...,) + region_slice].copy() - - enclosed_only = ( - self.host.drawClearRegionToolbar - .clearOnlyEnclosedObjsRadioButton.isChecked() - ) - selection_result = ( - self.host.view_model.label_edits.select_labels_in_region( - region_lab, - mask, - enclosed_only=enclosed_only, - ) - ) - clear_ids = selection_result.selected_ids - - if not clear_ids: - self.host.logger.warning( - self.empty_selection_warning( - enclosed_only=enclosed_only - ) - ) - return - - self.host.deleteIDmiddleClick(clear_ids, False, False) - self.host.update_cca_df_deletedIDs(pos_data, clear_ids) - self.host.freeRoiItem.clear() - self.host.updateAllImages() - - def _z_range(self, size_z): - z_projection = None - single_z_range = None - if self.host.isSegm3D: - z_projection = self.host.zProjComboBox.currentText() - if self.is_single_z_projection(z_projection): - single_z_range = self.host.drawClearRegionToolbar.zRange( - self.host.z_lab(), size_z - ) - return self.z_range_for_projection( - is_segm_3d=self.host.isSegm3D, - z_projection=z_projection, - size_z=size_z, - single_z_range=single_z_range, - ) \ No newline at end of file diff --git a/cellacdc/mixins/edit_id.py b/cellacdc/mixins/edit_id.py index c20d3acfd..47deb7a8d 100644 --- a/cellacdc/mixins/edit_id.py +++ b/cellacdc/mixins/edit_id.py @@ -15,29 +15,16 @@ ) -class EditIdViewModel: +class EditIdMixin: """Application-facing commands for manual ID edit metadata.""" - def project_centroid( - self, - centroid, - *, - is_3d: bool = False, - depth_axis: str = 'z', - ) -> tuple[float, float]: - return project_centroid( - centroid, - is_3d=is_3d, - depth_axis=depth_axis, - ) - def add_yx_centroids_to_df( self, df: pd.DataFrame, regionprops, *, is_3d: bool = False, - depth_axis: str = 'z', + depth_axis: str = "z", ) -> pd.DataFrame: return add_yx_centroids_to_df( df, @@ -46,13 +33,25 @@ def add_yx_centroids_to_df( depth_axis=depth_axis, ) + def apply_manual_edit_tracking( + self, + tracked_labels: np.ndarray, + edit_id_info, + all_ids, + ) -> ManualEditTrackingResult: + return apply_manual_edit_tracking( + tracked_labels, + edit_id_info, + all_ids, + ) + def edit_id_info_from_df( self, df: pd.DataFrame, regionprops=None, *, is_3d: bool = False, - depth_axis: str = 'z', + depth_axis: str = "z", ) -> list[tuple[int, int, int]]: return edit_id_info_from_df( df, @@ -68,14 +67,15 @@ def manual_edit_conflicts( ) -> dict[int, int]: return manual_edit_conflicts(labels, edit_id_info) - def apply_manual_edit_tracking( + def project_centroid( self, - tracked_labels: np.ndarray, - edit_id_info, - all_ids, - ) -> ManualEditTrackingResult: - return apply_manual_edit_tracking( - tracked_labels, - edit_id_info, - all_ids, + centroid, + *, + is_3d: bool = False, + depth_axis: str = "z", + ) -> tuple[float, float]: + return project_centroid( + centroid, + is_3d=is_3d, + depth_axis=depth_axis, ) diff --git a/cellacdc/mixins/exporting.py b/cellacdc/mixins/exporting.py index f41f445d7..ded2839cd 100644 --- a/cellacdc/mixins/exporting.py +++ b/cellacdc/mixins/exporting.py @@ -7,8 +7,6 @@ import traceback from functools import partial -import os -from dataclasses import dataclass from datetime import datetime import numpy as np import skimage.measure @@ -19,151 +17,131 @@ from cellacdc import exporters, html_utils, prompts, widgets -class ExportingView: +class ExportingMixin: """Qt-facing adapter around export dialogs, exporters, and progress UI.""" - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def startExportToVideoWorker(self, preferences): - self.isExportingVideo = True - self.isTransparent = self.overlayToolbar.isTransparent() - if not self.isTransparent: - # SVG export works only with RGBA not with setOpacity - # --> only true transparency mode can be used - self.overlayToolbar.setTransparent(True) - - self.setDisabled(True) - - self.progressWin = apps.QDialogWorkerProgress( - title='Exporting to video', parent=self.host.mainWin, - pbarDesc='Exporting to video...' - ) - self.progressWin.show(self.app) - self.exportToVideoStopNavVarNum = preferences['stop_nav_var_num'] - self.numFramesExported = 0 - self.progressWin.mainPbar.setMaximum( - preferences['stop_nav_var_num'] - - preferences['start_nav_var_num'] + 1 - ) - self.exportToVideoPreferences = preferences - - self.store_data() - posData = self.data[self.pos_i] - if self.exportToVideoPreferences['is_timelapse']: - # Go to requested start frame - posData.frame_i = preferences['start_nav_var_num'] - 1 - self.get_data() - self.updateAllImages() - self.exportToVideoNavVarIdxToRestore = posData.frame_i - else: - self.update_z_slice(preferences['start_nav_var_num'] - 1) - self.exportToVideoNavVarIdxToRestore = ( - self.zSliceScrollBar.sliderPosition() - ) - self.exportToVideoCurrentNavVarIdx = ( - preferences['start_nav_var_num'] - 1 - ) - - self.exportToVideoImageExporter = exporters.ImageExporter( - self.ax1, - save_pngs=preferences['save_pngs'], - dpi=preferences['dpi'] - ) - self.exportToVideoExporter = exporters.VideoExporter( - preferences['avi_filepath'], preferences['fps'] + def askTimelapseOrZslicesVideo(self): + txt = html_utils.paragraph(""" + Do you want to record a video of scrolling through the z-slices or + a Timelapse video? + """) + msg = widgets.myMessageBox(wrapText=False) + _, timelapseButton = msg.question( + self, + "Z-slices or Timelapse video?", + txt, + buttonsTexts=("Z-slices", "Timelapse"), ) + if msg.cancel: + return - QTimer.singleShot(200, self.updateAndExportFrame) + return msg.clickedButton == timelapseButton - def updateAndExportFrame(self): - didVideoExporterFinish = ( - self.exportToVideoCurrentNavVarIdx - == self.exportToVideoStopNavVarNum + def build_export_mask_image( + self, + image_shape, + view_range, + *, + invert_bw=False, + ): + mask_image = np.zeros( + self.export_mask_image_shape(image_shape), + dtype=np.uint8, ) - if didVideoExporterFinish: - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.mainPbar.setValue(0) - QTimer.singleShot(50, self.exportingFramesFinished) - return + x_range, y_range = view_range + x0, x1 = map(round, x_range) + y0, y1 = map(round, y_range) - posData = self.data[self.pos_i] - if self.exportToVideoPreferences['is_timelapse']: - self.goToFrameNumber(self.exportToVideoCurrentNavVarIdx+1) - else: - self.update_z_slice(self.exportToVideoCurrentNavVarIdx) + if invert_bw: + mask_image[:, :, :3] = 255 - success = self.exportFrame() - if success is None: - self.exportingVideoCritical() - return + if x0 > 0: + mask_image[:, :x0, 3] = 255 + if x1 < mask_image.shape[1]: + mask_image[:, x1:, 3] = 255 + if y0 > 0: + mask_image[:y0, :, 3] = 255 + if y1 < mask_image.shape[0]: + mask_image[y1:, :, 3] = 255 - self.exportToVideoCurrentNavVarIdx += 1 - self.progressWin.mainPbar.update(1) + return mask_image - QTimer.singleShot(50, self.updateAndExportFrame) + def exportAddScaleBar(self, checked): + self.addScaleBarAction.setChecked(checked) @exception_handler def exportFrame(self): - plan = self.export_frame_plan( - current_index=self.exportToVideoCurrentNavVarIdx, - num_digits=self.exportToVideoPreferences['num_digits'], - filename=self.exportToVideoPreferences['filename'], - pngs_folderpath=self.exportToVideoPreferences['pngs_folderpath'], - ) - img_bgr = self.exportToVideoImageExporter.export(plan.png_filepath) + nd = self.exportToVideoPreferences["num_digits"] + idx = str(self.exportToVideoCurrentNavVarIdx).zfill(nd) + filename = self.exportToVideoPreferences["filename"] + png_filename = f"{idx}_{filename}.png" + pngs_folderpath = self.exportToVideoPreferences["pngs_folderpath"] + + png_filepath = os.path.join(pngs_folderpath, png_filename) + img_bgr = self.exportToVideoImageExporter.export(png_filepath) self.exportToVideoExporter.add_frame(img_bgr) return True - def exportingVideoCritical(self): - self.setDisabled(False) + @disableWindow + def exportToImage(self, preferences): + filepath = preferences["filepath"] + self.logger.info(f'Saving image to "{filepath}"...') - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None + if filepath.endswith(".svg"): + exporter = exporters.SVGExporter(self.ax1) + else: + exporter = exporters.ImageExporter(self.ax1, dpi=preferences["dpi"]) + exporter.export(filepath) + self.logger.info("Image saved.") - self.logger.info('Exporting video process failed.') + self.setDisabled(False) + self.exportMaskImage[:] = 0 + self.exportMaskImageItem.setImage(self.exportMaskImage) + prompts.exportToImageFinished(filepath, qparent=self) - def exportingFramesFinished(self): - if not self.exportToVideoPreferences['save_pngs']: - self.logger.info('Removing PNGs...') - try: - shutil.rmtree(self.exportToVideoPreferences['pngs_folderpath']) - except Exception as err: - pass + def exportToImageTriggered(self): + posData = self.data[self.pos_i] + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{timestamp}_acdc_exported_image" + win = apps.ExportToImageParametersDialog( + parent=self, + startFolderpath=posData.pos_path, + startFilename=filename, + startViewRange=self.ax1.viewRange(), + isScaleBarPresent=self.addScaleBarAction.isChecked(), + ) + win.sigAddScaleBar.connect(self.exportAddScaleBar) + win.sigRangeChanged.connect( + partial(self.setViewRangeFromExportToImageDialog, win=win) + ) + # self.ax1.vb.sigRangeChanged.connect( + # win.updateViewRangeExportToImageDialog + # ) + self.setExportMaskImage(self.ax1.viewRange()) + self.exportToImageWindow = win + win.exec_() + # self.ax1.vb.sigRangeChanged.disconnect() + if win.cancel: + self.exportMaskImage[:] = 0 + self.exportMaskImageItem.setImage(self.exportMaskImage) + self.exportToImageWindow = None + self.logger.info("Export to image process cancelled") + return - self.logger.info('Saving video...') + isTransparent = self.overlayToolbar.isTransparent() + if not isTransparent: + # SVG export works only with RGBA not with setOpacity + # --> only true transparency mode can be used + self.overlayToolbar.setTransparent(True) - self.exportToVideoExporter.release() + self.exportToImage(win.selected_preferences) + self.exportToImageWindow = None - # Run ffmpeg new process - conversion_to_mp4_successful = True - if self.exportToVideoPreferences['filepath'].endswith('.mp4'): - try: - self.exportToVideoExporter.avi_to_mp4() - try: - os.remove(self.exportToVideoPreferences['avi_filepath']) - except Exception as err: - pass - except Exception as err: - self.logger.exception(traceback.format_exc()) - self.logger.info( - 'Conversion to MP4 failed. See traceback above.' - ) - conversion_to_mp4_successful = False - self.exportToVideoPreferences['filepath'] = ( - self.exportToVideoExporter._avi_filepath - ) + if not isTransparent: + self.overlayToolbar.setTransparent(False) - self.exportToVideoFinished(conversion_to_mp4_successful) + def exportToVideoAddTimestamp(self, checked): + self.addTimestampAction.setChecked(checked) def exportToVideoFinished(self, conversion_to_mp4_successful): self.progressWin.workerFinished = True @@ -171,14 +149,14 @@ def exportToVideoFinished(self, conversion_to_mp4_successful): self.progressWin = None # Back to current frame - if self.exportToVideoPreferences['is_timelapse']: + if self.exportToVideoPreferences["is_timelapse"]: posData = self.data[self.pos_i] posData.frame_i = self.exportToVideoNavVarIdxToRestore self.get_data() self.store_data() self.updateAllImages() - self.navigateScrollBar.setSliderPosition(posData.frame_i+1) - self.navSpinBox.setValue(posData.frame_i+1) + self.navigateScrollBar.setSliderPosition(posData.frame_i + 1) + self.navSpinBox.setValue(posData.frame_i + 1) else: self.update_z_slice(self.exportToVideoNavVarIdxToRestore) @@ -191,25 +169,51 @@ def exportToVideoFinished(self, conversion_to_mp4_successful): self.overlayToolbar.setTransparent(False) prompts.exportToVideoFinished( - self.exportToVideoPreferences, conversion_to_mp4_successful, - qparent=self.host + self.exportToVideoPreferences, conversion_to_mp4_successful, qparent=self ) - def exportAddScaleBar(self, checked): - self.addScaleBarAction.setChecked(checked) + def exportToVideoTriggered(self): + posData = self.data[self.pos_i] - def exportToVideoAddTimestamp(self, checked): - self.addTimestampAction.setChecked(checked) + doTimelapseVideo = posData.SizeT > 1 + if posData.SizeT > 1 and posData.SizeZ > 1: + doTimelapseVideo = self.askTimelapseOrZslicesVideo() - def askTimelapseOrZslicesVideo(self): - txt = html_utils.paragraph(""" + if doTimelapseVideo is None: + self.logger.info("Export to video process cancelled") + return - """Headless export naming, mask, and zoom selection rules.""" + channels = [self.user_ch_name, *self.checkedOverlayChannels] + mode = "timelapse" if doTimelapseVideo else "z_slices" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{timestamp}_acdc_exported_{mode}_video" + win = apps.ExportToVideoParametersDialog( + channels, + parent=self, + startFolderpath=posData.pos_path, + startFilename=filename, + startFrameNum=posData.frame_i + 1, + SizeT=posData.SizeT, + SizeZ=posData.SizeZ, + isTimelapseVideo=doTimelapseVideo, + isScaleBarPresent=self.addScaleBarAction.isChecked(), + isTimestampPresent=self.addTimestampAction.isChecked(), + rescaleIntensChannelHowMapper=self.rescaleIntensChannelHowMapper, + ) + win.sigAddScaleBar.connect(self.exportAddScaleBar) + win.sigAddTimestamp.connect(self.exportToVideoAddTimestamp) + win.sigRescaleIntensLut.connect(self.rescaleIntensExportToVideoDialog) + win.exec_() + if win.cancel: + self.logger.info("Export to video process cancelled") + return - def timestamped_export_filename(self, kind: str, *, timestamp=None): - if timestamp is None: - timestamp = datetime.now() - return f"{timestamp.strftime('%Y%m%d_%H%M%S')}_acdc_exported_{kind}" + cancel = _warnings.warnExportToVideo(qparent=self) + if cancel: + self.logger.info("Export to video process cancelled") + return + + self.startExportToVideoWorker(win.selected_preferences) def export_frame_plan( self, @@ -220,7 +224,7 @@ def export_frame_plan( pngs_folderpath: str, ) -> ExportFramePlan: frame_index_text = str(current_index).zfill(num_digits) - png_filename = f'{frame_index_text}_{filename}.png' + png_filename = f"{frame_index_text}_{filename}.png" return ExportFramePlan( frame_index_text=frame_index_text, png_filename=png_filename, @@ -231,249 +235,265 @@ def export_mask_image_shape(self, image_shape) -> tuple[int, int, int]: height, width = image_shape[-2:] return height, width, 4 - def build_export_mask_image( - self, - image_shape, - view_range, - *, - invert_bw=False, - ): - mask_image = np.zeros( - self.export_mask_image_shape(image_shape), - dtype=np.uint8, - ) - x_range, y_range = view_range - x0, x1 = map(round, x_range) - y0, y1 = map(round, y_range) + def exportingFramesFinished(self): + if not self.exportToVideoPreferences["save_pngs"]: + self.logger.info("Removing PNGs...") + try: + shutil.rmtree(self.exportToVideoPreferences["pngs_folderpath"]) + except Exception: + pass - if invert_bw: - mask_image[:, :, :3] = 255 + self.logger.info("Saving video...") - if x0 > 0: - mask_image[:, :x0, 3] = 255 - if x1 < mask_image.shape[1]: - mask_image[:, x1:, 3] = 255 - if y0 > 0: - mask_image[:y0, :, 3] = 255 - if y1 < mask_image.shape[0]: - mask_image[y1:, :, 3] = 255 + self.exportToVideoExporter.release() - return mask_image + # Run ffmpeg new process + conversion_to_mp4_successful = True + if self.exportToVideoPreferences["filepath"].endswith(".mp4"): + try: + self.exportToVideoExporter.avi_to_mp4() + try: + os.remove(self.exportToVideoPreferences["avi_filepath"]) + except Exception: + pass + except Exception: + self.logger.exception(traceback.format_exc()) + self.logger.info("Conversion to MP4 failed. See traceback above.") + conversion_to_mp4_successful = False + self.exportToVideoPreferences["filepath"] = ( + self.exportToVideoExporter._avi_filepath + ) - def zoom_ids(self, labels_2d, view_range): - height, width = labels_2d.shape - ((xmin, xmax), (ymin, ymax)) = view_range - if xmin <= 0 and ymin <= 0 and xmax >= width and ymax >= height: - return None + self.exportToVideoFinished(conversion_to_mp4_successful) - xmin = max(xmin, 0) - ymin = max(ymin, 0) - xmax = min(xmax, width) - ymax = min(ymax, height) + def exportingVideoCritical(self): + self.setDisabled(False) - zoom_slice = ( - slice(round(ymin), round(ymax)), - slice(round(xmin), round(xmax)), - ) - zoom_labels = skimage.segmentation.clear_border(labels_2d[zoom_slice]) - zoom_regionprops = skimage.measure.regionprops(zoom_labels) - return [obj.label for obj in zoom_regionprops] + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None - def shifted_view_range(self, previous_range, current_range, window_range): - prev_x_range, prev_y_range = previous_range - curr_x_range, curr_y_range = current_range - win_x_range, win_y_range = window_range + self.logger.info("Exporting video process failed.") - delta_x = curr_x_range[0] - prev_x_range[0] - delta_y = curr_y_range[0] - prev_y_range[0] + def getZoomIDs(self, viewRange=None): + if viewRange is None: + viewRange = self.ax1.viewRange() - return ( - (win_x_range[0] + delta_x, win_x_range[1] + delta_x), - (win_y_range[0] + delta_y, win_y_range[1] + delta_y), - ) + lab = self.currentLab2D + Y, X = lab.shape + ((xmin, xmax), (ymin, ymax)) = viewRange + if xmin <= 0 and ymin <= 0 and xmax >= X and ymax >= Y: + self.data[self.pos_i] + return None - Do you want to record a video of scrolling through the z-slices or - a Timelapse video? - """) - msg = widgets.myMessageBox(wrapText=False) - _, timelapseButton = msg.question( - self.host, 'Z-slices or Timelapse video?', txt, - buttonsTexts=('Z-slices', 'Timelapse') + xmin = xmin if xmin >= 0 else 0 + ymin = ymin if ymin >= 0 else 0 + xmax = xmax if xmax < X else X + ymax = ymax if ymax < Y else Y + + zoomSlice = ( + slice(round(ymin), round(ymax)), + slice(round(xmin), round(xmax)), ) - if msg.cancel: - return - return msg.clickedButton == timelapseButton + zoomLab = skimage.segmentation.clear_border(lab[zoomSlice]) + zoomRp = skimage.measure.regionprops(zoomLab) + zoomIDs = [obj.label for obj in zoomRp] + return zoomIDs - def exportToVideoTriggered(self): + def initExportMaskImage(self): posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] - doTimelapseVideo = posData.SizeT > 1 - if posData.SizeT > 1 and posData.SizeZ > 1: - doTimelapseVideo = self.askTimelapseOrZslicesVideo() - - if doTimelapseVideo is None: - self.logger.info('Export to video process cancelled') - return + self.exportMaskImage = np.zeros((Y, X, 4), dtype=np.uint8) - channels = [self.user_ch_name, *self.checkedOverlayChannels] - mode = 'timelapse' if doTimelapseVideo else 'z_slices' - filename = self.timestamped_export_filename( - f'{mode}_video' - ) - win = apps.ExportToVideoParametersDialog( - channels, - parent=self.host, - startFolderpath=posData.pos_path, - startFilename=filename, - startFrameNum=posData.frame_i+1, - SizeT=posData.SizeT, - SizeZ=posData.SizeZ, - isTimelapseVideo=doTimelapseVideo, - isScaleBarPresent=self.addScaleBarAction.isChecked(), - isTimestampPresent=self.addTimestampAction.isChecked(), - rescaleIntensChannelHowMapper=self.rescaleIntensChannelHowMapper - ) - win.sigAddScaleBar.connect(self.exportAddScaleBar) - win.sigAddTimestamp.connect(self.exportToVideoAddTimestamp) - win.sigRescaleIntensLut.connect(self.rescaleIntensExportToVideoDialog) - win.exec_() - if win.cancel: - self.logger.info('Export to video process cancelled') + def onSigUpdateCcaTableWindow(self, *args): + if not self.isDataLoaded: return - cancel = _warnings.warnExportToVideo(qparent=self.host) - if cancel: - self.logger.info('Export to video process cancelled') + if self.ccaTableWin is None: return - self.startExportToVideoWorker(win.selected_preferences) - - def initExportMaskImage(self): + viewRange = self.ax1.viewRange() posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - self.exportMaskImage = self.build_export_mask_image( - img[z_slice].shape, - self.ax1.viewRange(), - invert_bw=False, - ) + zoomIDs = self.getZoomIDs(viewRange=viewRange) + + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) def setExportMaskImage(self, viewRange): - if not hasattr(self, 'exportMaskImage'): + if not hasattr(self, "exportMaskImage"): self.initExportMaskImage() + else: + self.exportMaskImage[:] = 0 - self.exportMaskImage[:] = self.build_export_mask_image( - self.exportMaskImage.shape[:2], - viewRange, - invert_bw=self.invertBwAction.isChecked(), - ) + xRange, yRange = viewRange + x0, x1 = map(round, xRange) + y0, y1 = map(round, yRange) + + if self.invertBwAction.isChecked(): + self.exportMaskImage[:, :, :3] = 255 + + if x0 > 0: + self.exportMaskImage[:, :x0, 3] = 255 + if x1 < self.exportMaskImage.shape[1]: + self.exportMaskImage[:, x1:, 3] = 255 + if y0 > 0: + self.exportMaskImage[:y0, :, 3] = 255 + if y1 < self.exportMaskImage.shape[0]: + self.exportMaskImage[y1:, :, 3] = 255 self.exportMaskImageItem.setImage(self.exportMaskImage) def setViewRangeFromExportToImageDialog(self, viewRange, win=None): xRange, yRange = viewRange - # self.ax1.sigRangeChanged.disconnect( - # self.display_decorations_view.view_range_changed - # ) + # self.ax1.sigRangeChanged.disconnect(self.viewRangeChanged) self.ax1.setRange(xRange=xRange, yRange=yRange) - # self.ax1.sigRangeChanged.connect( - # self.display_decorations_view.view_range_changed - # ) - # self.display_decorations_view.view_range_changed( + # self.ax1.sigRangeChanged.connect(self.viewRangeChanged) + # self.viewRangeChanged( # self.ax1.vb, viewRange, updateExportMaskImage=False # ) self.setExportMaskImage(viewRange) - def updateViewRangeExportToImage(self, viewRange): - if self.exportToImageWindow is None: - return + def shifted_view_range(self, previous_range, current_range, window_range): + prev_x_range, prev_y_range = previous_range + curr_x_range, curr_y_range = current_range + win_x_range, win_y_range = window_range - # prevViewRange = self.exportToImageWindow.viewRange() - prevViewRange = self._viewRange - winViewRange = self.exportToImageWindow.viewRange() - x_range, y_range = self.shifted_view_range( - prevViewRange, - viewRange, - winViewRange, + delta_x = curr_x_range[0] - prev_x_range[0] + delta_y = curr_y_range[0] - prev_y_range[0] + + return ( + (win_x_range[0] + delta_x, win_x_range[1] + delta_x), + (win_y_range[0] + delta_y, win_y_range[1] + delta_y), ) - self.exportToImageWindow.setViewRange( - x_range, y_range, emitSignal=False + def startExportToVideoWorker(self, preferences): + self.isExportingVideo = True + self.isTransparent = self.overlayToolbar.isTransparent() + if not self.isTransparent: + # SVG export works only with RGBA not with setOpacity + # --> only true transparency mode can be used + self.overlayToolbar.setTransparent(True) + + self.setDisabled(True) + + self.progressWin = apps.QDialogWorkerProgress( + title="Exporting to video", + parent=self.mainWin, + pbarDesc="Exporting to video...", + ) + self.progressWin.show(self.app) + self.exportToVideoStopNavVarNum = preferences["stop_nav_var_num"] + self.numFramesExported = 0 + self.progressWin.mainPbar.setMaximum( + preferences["stop_nav_var_num"] - preferences["start_nav_var_num"] + 1 ) + self.exportToVideoPreferences = preferences - def getZoomIDs(self, viewRange=None): - if viewRange is None: - viewRange = self.ax1.viewRange() + self.store_data() + posData = self.data[self.pos_i] + if self.exportToVideoPreferences["is_timelapse"]: + # Go to requested start frame + posData.frame_i = preferences["start_nav_var_num"] - 1 + self.get_data() + self.updateAllImages() + self.exportToVideoNavVarIdxToRestore = posData.frame_i + else: + self.update_z_slice(preferences["start_nav_var_num"] - 1) + self.exportToVideoNavVarIdxToRestore = self.zSliceScrollBar.sliderPosition() + self.exportToVideoCurrentNavVarIdx = preferences["start_nav_var_num"] - 1 - return self.zoom_ids(self.currentLab2D, viewRange) + self.exportToVideoImageExporter = exporters.ImageExporter( + self.ax1, save_pngs=preferences["save_pngs"], dpi=preferences["dpi"] + ) + self.exportToVideoExporter = exporters.VideoExporter( + preferences["avi_filepath"], preferences["fps"] + ) - def onSigUpdateCcaTableWindow(self, *args): - if not self.isDataLoaded: + QTimer.singleShot(200, self.updateAndExportFrame) + + def timestamped_export_filename(self, kind: str, *, timestamp=None): + if timestamp is None: + timestamp = datetime.now() + return f"{timestamp.strftime('%Y%m%d_%H%M%S')}_acdc_exported_{kind}" + + def updateAndExportFrame(self): + didVideoExporterFinish = ( + self.exportToVideoCurrentNavVarIdx == self.exportToVideoStopNavVarNum + ) + if didVideoExporterFinish: + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.mainPbar.setValue(0) + QTimer.singleShot(50, self.exportingFramesFinished) return - if self.ccaTableWin is None: + self.data[self.pos_i] + if self.exportToVideoPreferences["is_timelapse"]: + self.goToFrameNumber(self.exportToVideoCurrentNavVarIdx + 1) + else: + self.update_z_slice(self.exportToVideoCurrentNavVarIdx) + + success = self.exportFrame() + if success is None: + self.exportingVideoCritical() return - viewRange = self.ax1.viewRange() - posData = self.data[self.pos_i] - zoomIDs = self.getZoomIDs(viewRange=viewRange) + self.exportToVideoCurrentNavVarIdx += 1 + self.progressWin.mainPbar.update(1) - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) + QTimer.singleShot(50, self.updateAndExportFrame) - @disableWindow - def exportToImage(self, preferences): - filepath = preferences['filepath'] - self.logger.info(f'Saving image to "{filepath}"...') + def updateViewRangeExportToImage(self, viewRange): + if self.exportToImageWindow is None: + return - if filepath.endswith('.svg'): - exporter = exporters.SVGExporter(self.ax1) - else: - exporter = exporters.ImageExporter(self.ax1, dpi=preferences['dpi']) - exporter.export(filepath) - self.logger.info(f'Image saved.') + # prevViewRange = self.exportToImageWindow.viewRange() + prevViewRange = self._viewRange + prevXRange = prevViewRange[0] + prevYRange = prevViewRange[1] + currXRange = viewRange[0] + currYRange = viewRange[1] - self.setDisabled(False) - self.exportMaskImage[:] = 0 - self.exportMaskImageItem.setImage(self.exportMaskImage) - prompts.exportToImageFinished(filepath, qparent=self.host) + prevX0, prevX1 = prevXRange + currX0, currX1 = currXRange + prevY0, prevY1 = prevYRange + currY0, currY1 = currYRange - def exportToImageTriggered(self): - posData = self.data[self.pos_i] - filename = self.timestamped_export_filename('image') - win = apps.ExportToImageParametersDialog( - parent=self.host, - startFolderpath=posData.pos_path, - startFilename=filename, - startViewRange=self.ax1.viewRange(), - isScaleBarPresent=self.addScaleBarAction.isChecked(), - ) - win.sigAddScaleBar.connect(self.exportAddScaleBar) - win.sigRangeChanged.connect( - partial(self.setViewRangeFromExportToImageDialog, win=win) + deltaX = currX0 - prevX0 + deltaY = currY0 - prevY0 + + winViewRange = self.exportToImageWindow.viewRange() + winXRange = winViewRange[0] + winYRange = winViewRange[1] + winX0, winX1 = winXRange + winY0, winY1 = winYRange + + newX0 = winX0 + deltaX + newX1 = winX1 + deltaX + newY0 = winY0 + deltaY + newY1 = winY1 + deltaY + + self.exportToImageWindow.setViewRange( + (newX0, newX1), (newY0, newY1), emitSignal=False ) - # self.ax1.vb.sigRangeChanged.connect( - # win.updateViewRangeExportToImageDialog - # ) - self.setExportMaskImage(self.ax1.viewRange()) - self.exportToImageWindow = win - win.exec_() - # self.ax1.vb.sigRangeChanged.disconnect() - if win.cancel: - self.exportMaskImage[:] = 0 - self.exportMaskImageItem.setImage(self.exportMaskImage) - self.exportToImageWindow = None - self.logger.info('Export to image process cancelled') - return - isTransparent = self.overlayToolbar.isTransparent() - if not isTransparent: - # SVG export works only with RGBA not with setOpacity - # --> only true transparency mode can be used - self.overlayToolbar.setTransparent(True) + def zoom_ids(self, labels_2d, view_range): + height, width = labels_2d.shape + ((xmin, xmax), (ymin, ymax)) = view_range + if xmin <= 0 and ymin <= 0 and xmax >= width and ymax >= height: + return None - self.exportToImage(win.selected_preferences) - self.exportToImageWindow = None + xmin = max(xmin, 0) + ymin = max(ymin, 0) + xmax = min(xmax, width) + ymax = min(ymax, height) - if not isTransparent: - self.overlayToolbar.setTransparent(False) \ No newline at end of file + zoom_slice = ( + slice(round(ymin), round(ymax)), + slice(round(xmin), round(xmax)), + ) + zoom_labels = skimage.segmentation.clear_border(labels_2d[zoom_slice]) + zoom_regionprops = skimage.measure.regionprops(zoom_labels) + return [obj.label for obj in zoom_regionprops] diff --git a/cellacdc/mixins/formatting.py b/cellacdc/mixins/formatting.py index 54585a13f..e78999538 100644 --- a/cellacdc/mixins/formatting.py +++ b/cellacdc/mixins/formatting.py @@ -12,11 +12,11 @@ ) -class FormattingViewModel: +class FormattingMixin: """Application-facing commands for display string formatting.""" - def number_fstring_formatter(self, dtype, *, precision=4): - return get_number_fstring_formatter(dtype, precision=precision) + def bytes_to_gb(self, size_bytes): + return _bytes_to_GB(size_bytes) def channel_name_from_basename( self, @@ -31,15 +31,6 @@ def channel_name_from_basename( remove_ext=remove_ext, ) - def bytes_to_gb(self, size_bytes): - return _bytes_to_GB(size_bytes) - - def seconds_to_eta(self, seconds): - return seconds_to_ETA(seconds) - - def salute_string(self): - return get_salute_string() - def distant_gray( self, desired_gray, @@ -53,5 +44,14 @@ def distant_gray( threshold=threshold, ) + def number_fstring_formatter(self, dtype, *, precision=4): + return get_number_fstring_formatter(dtype, precision=precision) + def rgb_to_gray(self, red, green, blue): return rgb_to_gray(red, green, blue) + + def salute_string(self): + return get_salute_string() + + def seconds_to_eta(self, seconds): + return seconds_to_ETA(seconds) diff --git a/cellacdc/mixins/frame_metadata.py b/cellacdc/mixins/frame_metadata.py index a8c9813e1..f5794c931 100644 --- a/cellacdc/mixins/frame_metadata.py +++ b/cellacdc/mixins/frame_metadata.py @@ -12,7 +12,7 @@ from cellacdc.myutils import get_empty_stored_data_dict -class FrameMetadataViewModel: +class FrameMetadataMixin: """Application-facing commands for per-frame ACDC metadata tables.""" def build_acdc_frame_metadata( @@ -22,7 +22,7 @@ def build_acdc_frame_metadata( edit_id_info=(), existing_df: pd.DataFrame | None = None, is_3d: bool = False, - depth_axis: str = 'z', + depth_axis: str = "z", ) -> AcdcFrameMetadataResult: return build_acdc_frame_metadata( regionprops, @@ -36,8 +36,8 @@ def concat_visited_acdc_frames( self, frame_records, *, - labels_key: str = 'labels', - acdc_key: str = 'acdc_df', + labels_key: str = "labels", + acdc_key: str = "acdc_df", ) -> pd.DataFrame | None: return concat_visited_acdc_frames( frame_records, diff --git a/cellacdc/mixins/frame_navigation.py b/cellacdc/mixins/frame_navigation.py index 4ee17ea1c..b18d33d54 100644 --- a/cellacdc/mixins/frame_navigation.py +++ b/cellacdc/mixins/frame_navigation.py @@ -6,7 +6,6 @@ from functools import partial import numpy as np -from dataclasses import dataclass from qtpy.QtCore import QTimer from qtpy.QtWidgets import QAbstractSlider, QCheckBox @@ -20,507 +19,262 @@ SliderMove = QtScoped.SliderMove() -class FrameNavigationView: +class FrameNavigationMixin: """Qt-facing adapter for frame and position navigation workflows.""" - LEGACY_METHODS = ( - 'goToZsliceSearchedID', - 'isNavigateActionOnNextFrame', - 'nextFrameImage', - 'rightImageFramesScrollbarValueChanged', - 'nextActionTriggered', - 'prevActionTriggered', - 'resetNavigateScrollbar', - 'next_cb', - 'prev_cb', - 'updateScrollbars', - 'updateFramePosLabel', - 'updateItemsMousePos', - 'setNavigateScrollBarMaximum', - 'setFrameNavigationDisabled', - 'navigateSpinboxValueChanged', - 'navigateSpinboxEditingFinished', - 'PosScrollBarAction', - 'PosScrollBarMoved', - 'PosScrollBarReleased', - 'resetNavigateFramesScrollbar', - 'framesScrollBarActionTriggered', - 'framesScrollBarMoved', - 'framesScrollBarReleased', - 'next_pos', - 'updatePos', - 'prev_pos', - 'updateViewerWindow', - 'warnLostObjects', - 'warnReinitLastSegmFrame', - 'extendSegmDataIfNeeded', - 'reInitLastSegmFrame', - 'resetAcceptedLostIDs', - 'askInitCcaFirstFrame', - 'askInitLinTreeFirstFrame', - 'checkIfFutureFrameManualAnnotPastFrames', - 'next_frame', - 'apply_tools_on_new_frame', - 'manualAnnotRestoreLastTrackedFrame', - 'prev_frame', - 'connectScrollbars', - 'zSliceScrollBarActionTriggered', - 'zSliceScrollBarReleased', - 'setSwitchViewedPlaneDisabled', - '_setViewRangeSwitchPlane', - 'setViewRangeSwitchPlane', - 'switchViewedPlane', - 'onZsliceSpinboxValueChange', - 'update_z_slice', - 'updateOverlayZslice', - 'updateOverlayZproj', - 'updateZproj', - 'setZprojDisabled', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + # @exec_time - def goToZsliceSearchedID(self, obj): - if not self.isSegm3D: - return + # @exec_time - current_z = self.z_lab() - nearest_nonzero_z = ( - self.nearest_nonzero_z_from_centroid( - obj, current_z=current_z - ) - ) - if nearest_nonzero_z == current_z: - self.drawPointsLayers(computePointsLayers=True) - return + def PosScrollBarAction(self, action): + if action == SliderSingleStepAdd: + self.next_cb() + elif action == SliderSingleStepSub: + self.prev_cb() + elif action == SliderPageStepAdd: + self.PosScrollBarReleased() + elif action == SliderPageStepSub: + self.PosScrollBarReleased() - self.zSliceScrollBar.setSliderPosition(nearest_nonzero_z) - self.update_z_slice(nearest_nonzero_z) + def PosScrollBarMoved(self, pos_n): + if self.navigateScrollBarStartedMoving: + self.store_data() - def isNavigateActionOnNextFrame(self): - posData = self.data[self.pos_i] - ax1_coords = self.status_hover_view.mouse_data_coords_right_image() - return self.should_show_next_frame_image( - size_t=posData.SizeT, - has_right_image_coords=ax1_coords is not None, - action_enabled=self.labelsGrad.showNextFrameAction.isEnabled(), - action_checked=self.labelsGrad.showNextFrameAction.isChecked(), - ) + self.pos_i = pos_n - 1 + self.updateFramePosLabel() + proceed_cca, never_visited = self.get_data() + self.updateAllImages() + self.setStatusBarLabel() + self.navigateScrollBarStartedMoving = False - def nextFrameImage(self, current_frame_i=None): - if not self.labelsGrad.showNextFrameAction.isEnabled(): + def PosScrollBarReleased(self): + self.navigateScrollBarStartedMoving = True + if self.pos_i == self.navigateScrollBar.sliderPosition() - 1: + # Slider released without changing value --> do nothing return - if not self.labelsGrad.showNextFrameAction.isChecked(): - return + self.pos_i = self.navigateScrollBar.sliderPosition() - 1 + self.updateFramePosLabel() + self.updatePos() + def _setViewRangeSwitchPlane(self, previousPlane): posData = self.data[self.pos_i] - if current_frame_i is None: - current_frame_i = posData.frame_i - - next_frame_i = self.next_frame_index( - current_frame_i=current_frame_i, - frames_count=len(posData.img_data), - ) - img = posData.img_data[next_frame_i] - - if posData.SizeZ > 1: - img = self.get_2Dimg_from_3D(img, isLayer0=True) - - # img = self.normalizeIntensities(img) - - return img + SizeZ = posData.SizeZ + SizeY, SizeX = self.img1.image.shape[:2] + currentPlane = self.switchPlaneCombobox.plane() + if previousPlane == "xy": + if currentPlane == "zy": + self.ax1.setRange(xRange=self.yRangePrev) + unusedRange = np.clip(self.xRangePrev, 0, SizeX) + elif currentPlane == "zx": + self.ax1.setRange(xRange=self.xRangePrev) + unusedRange = np.clip(self.yRangePrev, 0, SizeY) + elif previousPlane == "zy": + if currentPlane == "xy": + self.ax1.setRange(yRange=self.xRangePrev) + unusedRange = np.clip(self.yRangePrev, 0, SizeZ) + elif currentPlane == "zx": + self.ax1.setRange(yRange=self.yRangePrev) + unusedRange = np.clip(self.xRangePrev, 0, SizeY) + elif previousPlane == "zx": + if currentPlane == "xy": + self.ax1.setRange(xRange=self.xRangePrev) + unusedRange = np.clip(self.yRangePrev, 0, SizeZ) + elif currentPlane == "zy": + self.ax1.setRange(yRange=self.yRangePrev) + unusedRange = np.clip(self.xRangePrev, 0, SizeX) - def rightImageFramesScrollbarValueChanged(self, value): - img = self.nextFrameImage(current_frame_i=value-2) - self.img1.linkedImageItem.frame_i = value - self.img1.linkedImageItem.setImage(img) + sliceValue = round((unusedRange[0] + unusedRange[1]) / 2) + self.zSliceScrollBar.setSliderPosition(sliceValue) + self.update_z_slice(self.zSliceScrollBar.sliderPosition()) - def nextActionTriggered(self): - if self.isNavigateActionOnNextFrame(): - self.rightImageFramesScrollbar.setValue( - self.rightImageFramesScrollbar.value()+1 - ) + def apply_tools_on_new_frame(self): + mode = str(self.modeComboBox.currentText()) + if mode != "Segmentation and Tracking": return - - stepAddAction = QAbstractSlider.SliderAction.SliderSingleStepAdd - if self.zKeptDown or self.zSliceCheckbox.isChecked(): - self.zSliceScrollBar.triggerAction(stepAddAction) - else: - self.navigateScrollBar.triggerAction(stepAddAction) - - def prevActionTriggered(self): - if self.isNavigateActionOnNextFrame(): - self.rightImageFramesScrollbar.setValue( - self.rightImageFramesScrollbar.value()-1 - ) + posData = self.data[self.pos_i] + if ( + not (posData.last_tracked_i <= posData.frame_i) + or posData.frame_i == self.lastFrameRanOnFirstVisitTools + ): return - stepSubAction = QAbstractSlider.SliderAction.SliderSingleStepSub - if self.zKeptDown or self.zSliceCheckbox.isChecked(): - self.zSliceScrollBar.triggerAction(stepSubAction) - else: - self.navigateScrollBar.triggerAction(stepSubAction) - - def resetNavigateScrollbar(self): - try: - self.navigateScrollBar.blockSignals(True) - self.navigateScrollBar.actionTriggered.disconnect() - self.navigateScrollBar.sliderReleased.disconnect() - self.navigateScrollBar.sliderMoved.disconnect() - # self.navigateScrollBar.valueChanged.disconnect() - self.navigateScrollBar.setSliderPosition(self.navSpinBox.value()) - except Exception as e: - if "disconnect()" not in str(e): - printl(e) - pass - - self.navigateScrollBar.blockSignals(False) - self.navigateScrollBar.actionTriggered.connect( - self.framesScrollBarActionTriggered - ) - self.navigateScrollBar.sliderReleased.connect(self.framesScrollBarReleased) - self.navigateScrollBar.sliderMoved.connect(self.framesScrollBarMoved) - - @exception_handler - def next_cb(self): - if self.isSnapshot: - self.next_pos() - else: - self.next_frame() - if self.curvToolButton.isChecked(): - self.curvature_tools_view.curvTool_cb(True) - - self.updatePropsWidget('') + self.lastFrameRanOnFirstVisitTools = posData.frame_i + for name, checkbox in self.applyToolNewFrameActions.items(): + if not checkbox.isChecked(): + continue - @exception_handler - def prev_cb(self): - if self.isSnapshot: - self.prev_pos() - else: - self.prev_frame() - if self.curvToolButton.isChecked(): - self.curvature_tools_view.curvTool_cb(True) + tool_button = self.applyToolNewFrameButtons[name] + try: + if hasattr(tool_button, "click"): + tool_button.click() + elif hasattr(tool_button, "trigger"): + tool_button.trigger() + else: + printl(f"Warning: {name} has no click or trigger method") + except Exception as e: + self.logger.info(f"Error applying tool {name}: {e}") - self.updatePropsWidget('') + def askInitCcaFirstFrame(self): + mode = str(self.modeComboBox.currentText()) + if mode != "Cell cycle analysis": + return True - def updateScrollbars(self): - self.updateItemsMousePos() - self.updateFramePosLabel() posData = self.data[self.pos_i] - navPos = self.navigation_position( - is_snapshot=self.isSnapshot, - position_i=self.pos_i, - frame_i=posData.frame_i, - ) - self.navigateScrollBar.setSliderPosition(navPos) - if posData.SizeZ > 1: - self.updateZsliceScrollbar(posData.frame_i) - idx = (posData.filename, posData.frame_i) - self.zSliceScrollBar.setMaximum(posData.SizeZ-1) - self.zSliceSpinbox.setMaximum(posData.SizeZ) - self.SizeZlabel.setText(f'/{posData.SizeZ}') - - def updateFramePosLabel(self): - if self.isSnapshot: - posData = self.data[self.pos_i] - self.navSpinBox.setValueNoEmit(self.pos_i+1) - else: - posData = self.data[0] - self.navSpinBox.setValueNoEmit(posData.frame_i+1) - - def updateItemsMousePos(self): - if self.brushButton.isChecked(): - self.updateBrushCursor(self.xHoverImg, self.yHoverImg) - - if self.eraserButton.isChecked(): - self.updateEraserCursor(self.xHoverImg, self.yHoverImg) + if posData.frame_i != 0: + return True - def setNavigateScrollBarMaximum(self): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - lineage_frames = None - if self.lineage_tree is not None: - lineage_frames = self.lineage_tree.frames_for_dfs - limit = self.navigation_limit( - mode=mode, - frame_i=posData.frame_i, - last_tracked_i=posData.last_tracked_i, - last_cca_frame_i=self.last_cca_frame_i, - lineage_tree_frames=lineage_frames, + editCcaWidget = apps.editCcaTableWidget( + posData.cca_df, + posData.SizeT, + parent=self, + title="Initialize cell cycle annotations", ) - if limit is None: - return - - self.navigateScrollBar.setMaximum(limit.maximum) - self.navSpinBox.setMaximum(limit.maximum) - if limit.last_checked_frame_i is not None: - self.updateLastCheckedFrameWidgets(limit.last_checked_frame_i) - if limit.status_text is not None: - self.lastTrackedFrameLabel.setText(limit.status_text) - - def setFrameNavigationDisabled(self, disable: bool, why: str): - """Disables the frame navigation buttons and scrollbar. - - """Headless decisions for frame/position navigation workflows.""" - - viewer_mode = 'Viewer' - segmentation_mode = 'Segmentation and Tracking' - cell_cycle_mode = 'Cell cycle analysis' - lineage_mode = 'Normal division: Lineage tree' - - def should_show_next_frame_image( - self, - *, - size_t: int, - has_right_image_coords: bool, - action_enabled: bool, - action_checked: bool, - ) -> bool: - return ( - size_t > 1 - and has_right_image_coords - and action_enabled - and action_checked + editCcaWidget.sigApplyChangesFutureFrames.connect( + self.applyManualCcaChangesFutureFrames ) - - def next_frame_index(self, *, current_frame_i: int, frames_count: int) -> int: - next_frame_i = current_frame_i + 1 - if next_frame_i >= frames_count: - return frames_count - 1 - return next_frame_i - - def navigation_position( - self, - *, - is_snapshot: bool, - position_i: int, - frame_i: int, - ) -> int: - return position_i + 1 if is_snapshot else frame_i + 1 - - def navigation_limit( - self, - *, - mode: str, - frame_i: int, - last_tracked_i: int | None, - last_cca_frame_i: int, - lineage_tree_frames, - ) -> NavigationLimit | None: - if mode == self.segmentation_mode: - if last_tracked_i is None or frame_i > last_tracked_i: - maximum = frame_i + 1 - else: - maximum = last_tracked_i + 1 - return NavigationLimit( - maximum=maximum, - last_checked_frame_i=maximum - 1, - ) - if mode == self.cell_cycle_mode: - maximum = max(frame_i, last_cca_frame_i) + 1 - return NavigationLimit( - maximum=maximum, - status_text=f'Last cc annot. frame n. = {maximum}', - ) - if mode == self.lineage_mode: - if lineage_tree_frames: - maximum = max(lineage_tree_frames) + 1 - else: - maximum = frame_i + 1 - return NavigationLimit(maximum=maximum) - return None - - def should_store_when_slider_moves(self, *, mode: str) -> bool: - return mode != self.viewer_mode - - def should_warn_lost_objects( - self, - *, - requested: bool, - action_checked: bool, - mode: str, - lost_ids, - already_accepted: bool, - ) -> bool: - if not requested: - return False - if not action_checked: - return False - if mode != self.segmentation_mode: - return False - if not lost_ids: - return False - return not already_accepted - - def blocks_future_manual_annotation( - self, - *, - manual_annotation_enabled: bool, - current_frame_i: int, - frame_to_restore, - ) -> bool: - if not manual_annotation_enabled: - return False - if frame_to_restore is None: + editCcaWidget.exec_() + if editCcaWidget.cancel: + self.resetNavigateFramesScrollbar() return False - return current_frame_i > frame_to_restore - def should_apply_new_frame_tools( - self, - *, - mode: str, - last_tracked_i: int, - frame_i: int, - last_frame_ran: int, - ) -> bool: - return ( - mode == self.segmentation_mode - and last_tracked_i is not None - and last_tracked_i <= frame_i - and frame_i != last_frame_ran - ) - - def is_single_z_slice_projection(self, how: str) -> bool: - return how == 'single z-slice' - - def should_disable_overlay_z_slice(self, how: str) -> bool: - return how.find('max') != -1 or how == 'same as above' - - def projection_frame_indices( - self, - *, - filename, - frame_i: int, - size_t: int, - locked: bool, - ) -> list[tuple[object, int]]: - if locked: - return [(filename, i) for i in range(size_t)] - return [(filename, frame_i)] - - def z_slice_frame_indices( - self, - *, - filename, - frame_i: int, - size_t: int, - locked: bool, - ) -> list[tuple[object, int]]: - if locked: - return [(filename, i) for i in range(size_t)] - return [(filename, i) for i in range(frame_i, size_t)] - - This is used when the user is not allowed to navigate through frames - Call again to unlock it again. Also sets tooltips to inform the user - - Parameters - ---------- - disable : bool - if the navigation should be disabled - why : str - the reason for disabeling the navigation. - """ + if posData.cca_df is not None: + is_cca_same_as_stored = (posData.cca_df == editCcaWidget.cca_df).all( + axis=None + ) + if not is_cca_same_as_stored: + reinit_cca = self.warnEditingWithCca_df( + "Re-initialize cell cyle annotations first frame", + return_answer=True, + ) + if reinit_cca: + self.resetCcaFuture(0) - if disable: - self.whyNavigateDisabled.add(why) - else: - try: - self.whyNavigateDisabled.remove(why) - except KeyError: - pass + posData.cca_df = editCcaWidget.cca_df + self.store_cca_df() - if len(self.whyNavigateDisabled) == 0: - disable = False - else: - disable = True + return True - # Apply the disable/enable state - self.prevAction.setDisabled(disable) - self.nextAction.setDisabled(disable) - self.navigateScrollBar.setDisabled(disable) + def askInitLinTreeFirstFrame(self): + mode = str(self.modeComboBox.currentText()) + if mode != "Normal division: Lineage tree": + return True - # Set appropriate tooltip - if not disable: - self.navigateScrollBar.setToolTip( - 'NOTE: The maximum frame number that can be visualized with this ' - 'scrollbar\n' - 'is the last visited frame with the selected mode\n' - '(see "Mode" selector on the top-right).\n\n' - 'If the scrollbar does not move it means that you never visited\n' - 'any frame with current mode.\n\n' - 'Note that the "Viewer" mode allows you to scroll ALL frames.' - ) - return + posData = self.data[self.pos_i] + if posData.frame_i != 0: + return True - txt = f'Frame navigation disabled: {self.whyNavigateDisabled}' - self.logger.info(txt) - self.navigateScrollBar.setToolTip(txt) + if self.lineage_tree is None: + self.initLinTree() - def navigateSpinboxValueChanged(self, value): - self.navigateScrollBar.setSliderPosition(value) - if self.isSnapshot: - self.PosScrollBarMoved(value) - else: - self.navigateScrollBarStartedMoving = True - self.framesScrollBarMoved(value) + return True - def navigateSpinboxEditingFinished(self): - if self.isSnapshot: - self.PosScrollBarReleased() - else: - self.framesScrollBarReleased() + def blocks_future_manual_annotation( + self, + *, + manual_annotation_enabled: bool, + current_frame_i: int, + frame_to_restore, + ) -> bool: + if not manual_annotation_enabled: + return False + if frame_to_restore is None: + return False + return current_frame_i > frame_to_restore - def PosScrollBarAction(self, action): - if action == SliderSingleStepAdd: - self.next_cb() - elif action == SliderSingleStepSub: - self.prev_cb() - elif action == SliderPageStepAdd: - self.PosScrollBarReleased() - elif action == SliderPageStepSub: - self.PosScrollBarReleased() + def checkIfFutureFrameManualAnnotPastFrames(self): + if not self.manualAnnotPastButton.isChecked(): + return True - def PosScrollBarMoved(self, pos_n): - if self.navigateScrollBarStartedMoving: - self.store_data() + posData = self.data[self.pos_i] + frame_to_restore = self.manualAnnotState.get("frame_i_to_restore") + if posData.frame_i <= frame_to_restore: + return True - self.pos_i = pos_n-1 - self.updateFramePosLabel() - proceed_cca, never_visited = self.get_data() - self.updateAllImages() - self.status_hover_view.set_status_bar_label() - self.navigateScrollBarStartedMoving = False + warn_txt = ( + "WARNING: Cannot navigate to future frames while in manual annotation mode." + ) + self.logger.info(warn_txt) + self.statusBarLabel.setText(f'

{warn_txt}

') - def PosScrollBarReleased(self): - self.navigateScrollBarStartedMoving = True - if self.pos_i == self.navigateScrollBar.sliderPosition()-1: - # Slider released without changing value --> do nothing - return + return False - self.pos_i = self.navigateScrollBar.sliderPosition()-1 - self.updateFramePosLabel() - self.updatePos() + def connectScrollbars(self): + self.t_label.show() + self.navigateScrollBar.show() + self.navigateScrollBar.setDisabled(False) + + if self.data[0].SizeZ > 1: + self.enableZstackWidgets(True) + self.zSliceScrollBar.setMaximum(self.data[0].SizeZ - 1) + self.zSliceSpinbox.setMaximum(self.data[0].SizeZ) + self.SizeZlabel.setText(f"/{self.data[0].SizeZ}") + try: + self.zSliceScrollBar.actionTriggered.disconnect() + self.zSliceScrollBar.sliderReleased.disconnect() + self.zProjComboBox.currentTextChanged.disconnect() + self.zProjComboBox.activated.disconnect() + self.switchPlaneCombobox.sigPlaneChanged.disconnect() + self.zProjLockViewButton.toggled.disconnect() + except Exception: + pass + self.zSliceScrollBar.actionTriggered.connect( + self.zSliceScrollBarActionTriggered + ) + self.zSliceScrollBar.sliderReleased.connect(self.zSliceScrollBarReleased) + self.zProjComboBox.currentTextChanged.connect(self.updateZproj) + self.zProjComboBox.activated.connect(self.clearComboBoxFocus) + self.switchPlaneCombobox.sigPlaneChanged.connect(self.switchViewedPlane) + self.zProjLockViewButton.toggled.connect(self.zProjLockViewToggled) - def resetNavigateFramesScrollbar(self, frame_i=None): posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i + if posData.SizeT == 1: + self.t_label.setText("Position n.") + self.navigateScrollBar.setMinimum(1) + self.navigateScrollBar.setMaximum(len(self.data)) + self.navigateScrollBar.setAbsoluteMaximum(len(self.data)) + self.navSpinBox.setMaximum(len(self.data)) + self.navigateScrollBar.connectEvents( + { + "sliderMoved": self.PosScrollBarMoved, + "sliderReleased": self.PosScrollBarReleased, + "actionTriggered": self.PosScrollBarAction, + } + ) + else: + self.navigateScrollBar.setMinimum(1) + self.navigateScrollBar.setAbsoluteMaximum(posData.SizeT) + self.rightImageFramesScrollbar.setMinimum(1) + self.rightImageFramesScrollbar.setMaximum(posData.SizeT) + if posData.last_tracked_i is not None: + self.navigateScrollBar.setMaximum(posData.last_tracked_i + 1) + self.navSpinBox.setMaximum(posData.last_tracked_i + 1) + self.t_label.setText("Frame n.") + self.navigateScrollBar.connectEvents( + { + "sliderMoved": self.framesScrollBarMoved, + "sliderReleased": self.framesScrollBarReleased, + "actionTriggered": self.framesScrollBarActionTriggered, + } + ) + self.rightImageFramesScrollbar.connectValueChanged( + self.rightImageFramesScrollbarValueChanged + ) - self.navigateScrollBar.setValueNoSignal(frame_i+1) + def extendSegmDataIfNeeded(self, stopFrameNum): + posData = self.data[self.pos_i] + segmSizeT = len(posData.segm_data) + if stopFrameNum <= segmSizeT: + return + numFramesToAdd = stopFrameNum - segmSizeT + posData.allData_li.extend( + [myutils.get_empty_stored_data_dict() for i in range(numFramesToAdd)] + ) + lab_shape = posData.segm_data[0].shape + shapeToAdd = (numFramesToAdd, *lab_shape) + additionalSegmData = np.zeros(shapeToAdd, dtype=posData.segm_data.dtype) + extendedSegmData = np.concatenate((posData.segm_data, additionalSegmData)) + posData.segm_data = extendedSegmData def framesScrollBarActionTriggered(self, action): if action == SliderSingleStepAdd: @@ -539,18 +293,18 @@ def framesScrollBarActionTriggered(self, action): def framesScrollBarMoved(self, frame_n): if self.navigateScrollBarStartedMoving: mode = str(self.modeComboBox.currentText()) - if self.should_store_when_slider_moves(mode=mode): + if mode != "Viewer": self.store_data(debug=False) posData = self.data[self.pos_i] - posData.frame_i = frame_n-1 - if posData.allData_li[posData.frame_i]['labels'] is None: + posData.frame_i = frame_n - 1 + if posData.allData_li[posData.frame_i]["labels"] is None: if posData.frame_i < len(posData.segm_data): posData.lab = posData.segm_data[posData.frame_i] else: posData.lab = np.zeros_like(posData.segm_data[0]) else: - posData.lab = posData.allData_li[posData.frame_i]['labels'] + posData.lab = posData.allData_li[posData.frame_i]["labels"] self.setImageImg1() if self.overlayButton.isChecked(): @@ -559,331 +313,188 @@ def framesScrollBarMoved(self, frame_n): if self.navigateScrollBarStartedMoving: self.clearAllItems() - self.navSpinBox.setValueNoEmit(posData.frame_i+1) + self.navSpinBox.setValueNoEmit(posData.frame_i + 1) if self.labelsGrad.showLabelsImgAction.isChecked(): self.img2.setImage(posData.lab, z=self.z_lab(), autoLevels=False) self.updateLookuptable() self.updateFramePosLabel() self.updateViewerWindow() - self.display_decorations_view.update_timestamp_frame() + self.updateTimestampFrame() self.updateHighlightedAxis() self.navigateScrollBarStartedMoving = False def framesScrollBarReleased(self, do_store_data=False): posData = self.data[self.pos_i] - if posData.frame_i == self.navigateScrollBar.sliderPosition()-1: + if posData.frame_i == self.navigateScrollBar.sliderPosition() - 1: # Slider released without changing value --> do nothing return mode = str(self.modeComboBox.currentText()) - if ( - self.should_store_when_slider_moves(mode=mode) - and do_store_data - ): + if mode != "Viewer" and do_store_data: self.store_data(debug=False) self.navigateScrollBarStartedMoving = True - posData.frame_i = self.navigateScrollBar.sliderPosition()-1 + posData.frame_i = self.navigateScrollBar.sliderPosition() - 1 self.updateFramePosLabel() proceed_cca, never_visited = self.get_data() self.updateAllImages() - def next_pos(self): - self.store_data(debug=True, autosave=False) - prev_pos_i = self.pos_i - if self.pos_i < self.num_pos-1: - self.pos_i += 1 - self.updateSegmDataAutoSaveWorker() - else: - self.logger.info('You reached last position.') - self.pos_i = 0 - self.updatePos() - - def updatePos(self): - self.clearUndoQueue() - self.status_hover_view.set_status_bar_label() - self.checkManageVersions() - self.removeAlldelROIsCurrentFrame() - self.resetManualBackgroundItems() - proceed_cca, never_visited = self.get_data(debug=False) - self.pointsLayerLoadedDfsToData() - self.flushDirtyPointsLayersAutosave() - self.initContoursImage() - self.initDelRoiLab() - self.initTextAnnot() - self.postProcessing() - self.updateScrollbars() - self.preprocessing_view.updatePreprocessPreview() - self.updateCombineChannelsPreview() - self.updateAllImages() - self.computeSegm() - self.zoomOut() - self.restartZoomAutoPilot() - self.initManualBackgroundObject() - self.updateObjectCounts() - self.updateItemsMousePos() - - self._sync_session(self.pos_i) - self._sync_session_frame_i(self.pos_i) - - def prev_pos(self): - self.store_data(debug=False, autosave=False) - prev_pos_i = self.pos_i - if self.pos_i > 0: - self.pos_i -= 1 - self.updateSegmDataAutoSaveWorker() - else: - self.logger.info('You reached first position.') - self.pos_i = self.num_pos-1 - self.updatePos() - - def updateViewerWindow(self): - if self.slideshowWin is None: - return - - if self.slideshowWin.linkWindow is None: - return - - if not self.slideshowWin.linkWindowCheckbox.isChecked(): + def goToZsliceSearchedID(self, obj): + if not self.isSegm3D: return - posData = self.data[self.pos_i] - self.slideshowWin.frame_i = posData.frame_i - self.slideshowWin.update_img() - - def warnLostObjects(self, do_warn=True): - mode = str(self.modeComboBox.currentText()) - posData = self.data[self.pos_i] - - frame_i = posData.frame_i - try: - accepted_lost_IDs = posData.accepted_lost_IDs.get(frame_i, []) - already_accepted_lost = ( - Counter(accepted_lost_IDs) == Counter(posData.lost_IDs) - ) - except AttributeError as err: - already_accepted_lost = False - - should_warn = self.should_warn_lost_objects( - requested=do_warn, - action_checked=self.warnLostCellsAction.isChecked(), - mode=mode, - lost_ids=posData.lost_IDs, - already_accepted=already_accepted_lost, - ) - if not should_warn: - return True - - self.nextAction.setDisabled(True) - self.prevAction.setDisabled(True) - self.navigateScrollBar.setDisabled(True) - - msg = widgets.myMessageBox() - warn_msg = html_utils.paragraph( - 'Current frame (compared to previous frame) ' - 'has lost the following cells:

' - f'{posData.lost_IDs}

' - 'Are you sure you want to continue?
' - ) - checkBox = QCheckBox('Do not show again') - noButton, yesButton = msg.warning( - self.host, 'Lost cells!', warn_msg, - buttonsTexts=('No', 'Yes'), - widgets=checkBox - ) - doNotWarnLostCells = not checkBox.isChecked() - self.warnLostCellsAction.setChecked(doNotWarnLostCells) - if msg.clickedButton == noButton: - self.nextAction.setDisabled(False) - self.prevAction.setDisabled(False) - self.navigateScrollBar.setDisabled(False) - return False - - self.nextAction.setDisabled(False) - self.prevAction.setDisabled(False) - self.navigateScrollBar.setDisabled(False) - if not hasattr(posData, 'accepted_lost_IDs'): - posData.accepted_lost_IDs = {} - if frame_i not in posData.accepted_lost_IDs: - posData.accepted_lost_IDs[frame_i] = [] - - posData.accepted_lost_IDs[frame_i].extend(posData.lost_IDs) - # This section is adding the lost cells to tracked_lost_centroids... TBH I dont know why this wasnt done in the first place - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] - accepted_lost_centroids = { - tuple(int(val) for val in prev_rp[prev_IDs_idxs[ID]].centroid) - for ID in posData.lost_IDs - } - try: - posData.tracked_lost_centroids[frame_i] = ( - posData.tracked_lost_centroids[frame_i] | (accepted_lost_centroids) - ) - except KeyError: - posData.tracked_lost_centroids[frame_i] = accepted_lost_centroids - return True - - def warnReinitLastSegmFrame(self): - current_frame_n = self.navigateScrollBar.value() - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - Are you sure you want to re-initialize the last visited and - validated frame to number {current_frame_n}?

- WARNING: If you save, all annotations after frame number - {current_frame_n} will be lost! - """) - msg.warning( - self.host, 'WARNING: Potential loss of data', txt, - buttonsTexts=('Cancel', 'Yes, I am sure') + current_z = self.z_lab() + nearest_nonzero_z = core.nearest_nonzero_z_idx_from_z_centroid( + obj, current_z=current_z ) - return msg.cancel - - def extendSegmDataIfNeeded(self, stopFrameNum): - posData = self.data[self.pos_i] - segmSizeT = len(posData.segm_data) - if stopFrameNum <= segmSizeT: + if nearest_nonzero_z == current_z: + self.drawPointsLayers(computePointsLayers=True) return - numFramesToAdd = stopFrameNum - segmSizeT - posData.allData_li.extend( - self.empty_frame_records(numFramesToAdd) - ) - lab_shape = posData.segm_data[0].shape - shapeToAdd = (numFramesToAdd, *lab_shape) - additionalSegmData = np.zeros(shapeToAdd, dtype=posData.segm_data.dtype) - extendedSegmData = np.concatenate((posData.segm_data, additionalSegmData)) - posData.segm_data = extendedSegmData - def reInitLastSegmFrame( - self, checked=True, from_frame_i=None, updateImages=True, - force=False - ): - if not force: - cancel = self.warnReinitLastSegmFrame() - if cancel: - self.logger.info( - 'Re-initialization of last validated frame cancelled.' - ) - return + self.zSliceScrollBar.setSliderPosition(nearest_nonzero_z) + self.update_z_slice(nearest_nonzero_z) + def isNavigateActionOnNextFrame(self): posData = self.data[self.pos_i] - if from_frame_i is None: - from_frame_i = posData.frame_i - - self.lastFrameRanOnFirstVisitTools = posData.frame_i - - self.updateLastCheckedFrameWidgets(from_frame_i) - posData.last_tracked_i = from_frame_i - self.navigateScrollBar.setMaximum(from_frame_i+1) - self.navSpinBox.setMaximum(from_frame_i+1) - # self.navigateScrollBar.setMinimum(1) + if posData.SizeT == 1: + return False - # posData.tracked_lost_centroids[from_frame_i-1] = set() - for i in range(from_frame_i, posData.SizeT): - if posData.allData_li[i]['labels'] is None: - break + ax1_coords = self.getMouseDataCoordsRightImage() + if ax1_coords is None: + return False - posData.segm_data[i] = posData.allData_li[i]['labels'] - posData.allData_li[i] = ( - self.empty_frame_record() - ) + if not self.labelsGrad.showNextFrameAction.isEnabled(): + return False - posData.tracked_lost_centroids[i] = set() - posData.acdcTracker2stepsAnnotInfo.pop(i, None) + if not self.labelsGrad.showNextFrameAction.isChecked(): + return - if posData.acdc_df is not None: - frames = posData.acdc_df.index.get_level_values(0) - if from_frame_i in frames: - posData.acdc_df = posData.acdc_df.loc[:from_frame_i] + # Mouse is on right image and next frame action is checked + return True - self.removeAlldelROIsCurrentFrame() + def is_single_z_slice_projection(self, how: str) -> bool: + return how == "single z-slice" - if not updateImages: + def manualAnnotRestoreLastTrackedFrame(self, last_tracked_i_to_restore): + if self.navigateScrollBar.maximum() - 1 <= last_tracked_i_to_restore: return - self.updateAllImages() - - def resetAcceptedLostIDs(self, from_frame_i=None): posData = self.data[self.pos_i] - if from_frame_i is None: - from_frame_i = posData.frame_i + for frame_i in range(last_tracked_i_to_restore + 1, posData.SizeT): + data_frame_i = myutils.get_empty_stored_data_dict() - posData.tracked_lost_centroids[from_frame_i-1] = set() - for i in range(from_frame_i, posData.SizeT): - posData.tracked_lost_centroids[i] = set() + data_frame_i["manually_edited_lab"] = posData.allData_li[frame_i][ + "manually_edited_lab" + ] - def askInitCcaFirstFrame(self): - mode = str(self.modeComboBox.currentText()) - if mode != 'Cell cycle analysis': - return True + posData.allData_li[frame_i] = data_frame_i - posData = self.data[self.pos_i] - if posData.frame_i != 0: - return True + self.navigateScrollBar.setMaximum(last_tracked_i_to_restore + 1) + self.navSpinBox.setMaximum(last_tracked_i_to_restore + 1) - editCcaWidget = apps.editCcaTableWidget( - posData.cca_df, posData.SizeT, parent=self.host, - title='Initialize cell cycle annotations' - ) - editCcaWidget.sigApplyChangesFutureFrames.connect( - self.applyManualCcaChangesFutureFrames - ) - editCcaWidget.exec_() - if editCcaWidget.cancel: - self.resetNavigateFramesScrollbar() - return False + def navigateSpinboxEditingFinished(self): + if self.isSnapshot: + self.PosScrollBarReleased() + else: + self.framesScrollBarReleased() - if posData.cca_df is not None: - is_cca_same_as_stored = ( - (posData.cca_df == editCcaWidget.cca_df).all(axis=None) + def navigateSpinboxValueChanged(self, value): + self.navigateScrollBar.setSliderPosition(value) + if self.isSnapshot: + self.PosScrollBarMoved(value) + else: + self.navigateScrollBarStartedMoving = True + self.framesScrollBarMoved(value) + + def navigation_limit( + self, + *, + mode: str, + frame_i: int, + last_tracked_i: int | None, + last_cca_frame_i: int, + lineage_tree_frames, + ) -> NavigationLimit | None: + if mode == self.segmentation_mode: + if last_tracked_i is None or frame_i > last_tracked_i: + maximum = frame_i + 1 + else: + maximum = last_tracked_i + 1 + return NavigationLimit( + maximum=maximum, + last_checked_frame_i=maximum - 1, ) - if not is_cca_same_as_stored: - reinit_cca = self.warnEditingWithCca_df( - 'Re-initialize cell cyle annotations first frame', - return_answer=True - ) - if reinit_cca: - self.resetCcaFuture(0) + if mode == self.cell_cycle_mode: + maximum = max(frame_i, last_cca_frame_i) + 1 + return NavigationLimit( + maximum=maximum, + status_text=f"Last cc annot. frame n. = {maximum}", + ) + if mode == self.lineage_mode: + if lineage_tree_frames: + maximum = max(lineage_tree_frames) + 1 + else: + maximum = frame_i + 1 + return NavigationLimit(maximum=maximum) + return None - posData.cca_df = editCcaWidget.cca_df - self.store_cca_df() + def navigation_position( + self, + *, + is_snapshot: bool, + position_i: int, + frame_i: int, + ) -> int: + return position_i + 1 if is_snapshot else frame_i + 1 - return True + def nextActionTriggered(self): + if self.isNavigateActionOnNextFrame(): + self.rightImageFramesScrollbar.setValue( + self.rightImageFramesScrollbar.value() + 1 + ) + return - def askInitLinTreeFirstFrame(self): - mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree': - return True + stepAddAction = QAbstractSlider.SliderAction.SliderSingleStepAdd + if self.zKeptDown or self.zSliceCheckbox.isChecked(): + self.zSliceScrollBar.triggerAction(stepAddAction) + else: + self.navigateScrollBar.triggerAction(stepAddAction) + + def nextFrameImage(self, current_frame_i=None): + if not self.labelsGrad.showNextFrameAction.isEnabled(): + return + + if not self.labelsGrad.showNextFrameAction.isChecked(): + return posData = self.data[self.pos_i] - if posData.frame_i != 0: - return True + if current_frame_i is None: + current_frame_i = posData.frame_i - if self.lineage_tree is None: - self.initLinTree() + next_frame_i = current_frame_i + 1 + if next_frame_i >= len(posData.img_data): + img = posData.img_data[-1] + else: + img = posData.img_data[next_frame_i] - return True + if posData.SizeZ > 1: + img = self.get_2Dimg_from_3D(img, isLayer0=True) - def checkIfFutureFrameManualAnnotPastFrames(self): - posData = self.data[self.pos_i] - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - blocked = self.blocks_future_manual_annotation( - manual_annotation_enabled=self.manualAnnotPastButton.isChecked(), - current_frame_i=posData.frame_i, - frame_to_restore=frame_to_restore, - ) - if not blocked: - return True + # img = self.normalizeIntensities(img) - warn_txt = ( - 'WARNING: Cannot navigate to future frames while in ' - 'manual annotation mode.' - ) - self.logger.info(warn_txt) - self.statusBarLabel.setText(f'

{warn_txt}

') + return img - return False + @exception_handler + def next_cb(self): + if self.isSnapshot: + self.next_pos() + else: + self.next_frame() + if self.curvToolButton.isChecked(): + self.curvTool_cb(True) + + self.updatePropsWidget("") - # @exec_time def next_frame(self, warn=True): proceed = self.checkIfFutureFrameManualAnnotPastFrames() if not proceed: @@ -900,11 +511,11 @@ def next_frame(self, warn=True): mode = str(self.modeComboBox.currentText()) posData = self.data[self.pos_i] - if posData.frame_i >= posData.SizeT-1: + if posData.frame_i >= posData.SizeT - 1: # Store data for current frame - if mode != 'Viewer': + if mode != "Viewer": self.store_data(debug=False) - msg = 'You reached the last segmented frame!' + msg = "You reached the last segmented frame!" self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) return @@ -915,7 +526,7 @@ def next_frame(self, warn=True): return # Store data for current frame - if mode != 'Viewer': + if mode != "Viewer": self.store_data(debug=False) self.askLineageTreeChanges() @@ -925,15 +536,13 @@ def next_frame(self, warn=True): if not proceed_cca: posData.frame_i -= 1 self.get_data() - self.logger.info( - 'No data for current frame. ' - ) + self.logger.info("No data for current frame. ") return - if mode == 'Segmentation and Tracking' or self.isSnapshot: + if mode == "Segmentation and Tracking" or self.isSnapshot: self.addExistingDelROIs() - self.preprocessing_view.updatePreprocessPreview() + self.updatePreprocessPreview() self.updateCombineChannelsPreview() self.postProcessing() self.tracking(storeUndo=True, wl_update=False) @@ -942,17 +551,15 @@ def next_frame(self, warn=True): posData.frame_i -= 1 self.get_data() self.setAllTextAnnotations() - self.logger.info( - 'Not enough G1 cells to compute cell cycle annotations.' - ) + self.logger.info("Not enough G1 cells to compute cell cycle annotations.") return self.store_zslices_rp() - self.label_transform_tools_view.reset_expand_label() + self.resetExpandLabel() self.updateAllImages() self.updateHighlightedAxis() self.updateViewerWindow() - self.updateLastVisitedFrame(last_visited_frame_i=posData.frame_i-1) + self.updateLastVisitedFrame(last_visited_frame_i=posData.frame_i - 1) self.setNavigateScrollBarMaximum() self.updateScrollbars() self.computeSegm() @@ -962,202 +569,279 @@ def next_frame(self, warn=True): self.updateItemsMousePos() self.updateObjectCounts() - self.apply_tools_on_new_frame() - self._sync_session_frame_i() + self.apply_tools_on_new_frame() + + def next_frame_index(self, *, current_frame_i: int, frames_count: int) -> int: + next_frame_i = current_frame_i + 1 + if next_frame_i >= frames_count: + return frames_count - 1 + return next_frame_i + + def next_pos(self): + self.store_data(debug=True, autosave=False) + if self.pos_i < self.num_pos - 1: + self.pos_i += 1 + self.updateSegmDataAutoSaveWorker() + else: + self.logger.info("You reached last position.") + self.pos_i = 0 + self.updatePos() + + def onZsliceSpinboxValueChange(self, value): + self.zSliceScrollBar.setSliderPosition(value - 1) + + def prevActionTriggered(self): + if self.isNavigateActionOnNextFrame(): + self.rightImageFramesScrollbar.setValue( + self.rightImageFramesScrollbar.value() - 1 + ) + return + + stepSubAction = QAbstractSlider.SliderAction.SliderSingleStepSub + if self.zKeptDown or self.zSliceCheckbox.isChecked(): + self.zSliceScrollBar.triggerAction(stepSubAction) + else: + self.navigateScrollBar.triggerAction(stepSubAction) + + @exception_handler + def prev_cb(self): + if self.isSnapshot: + self.prev_pos() + else: + self.prev_frame() + if self.curvToolButton.isChecked(): + self.curvTool_cb(True) + + self.updatePropsWidget("") + + def prev_frame(self): + posData = self.data[self.pos_i] + if posData.frame_i <= 0: + msg = "You reached the first frame!" + self.logger.info(msg) + self.titleLabel.setText(msg, color=self.titleColor) + return + + # Store data for current frame + mode = str(self.modeComboBox.currentText()) + if mode != "Viewer": + self.store_data(debug=False) + + self.removeAlldelROIsCurrentFrame() + self.askLineageTreeChanges() + posData.frame_i -= 1 + _, never_visited = self.get_data() + + if mode == "Segmentation and Tracking" or self.isSnapshot: + self.addExistingDelROIs() + + self.resetExpandLabel() + self.updatePreprocessPreview() + self.updateCombineChannelsPreview() + self.postProcessing() + self.tracking() + self.whitelistPropagateIDs(update_lab=True) + self.updateAllImages() + self.updateScrollbars() + self.updateHighlightedAxis() + self.zoomToCells() + self.initGhostObject() + self.updateViewerWindow() + self.updateItemsMousePos() + self.updateObjectCounts() + + def prev_pos(self): + self.store_data(debug=False, autosave=False) + if self.pos_i > 0: + self.pos_i -= 1 + self.updateSegmDataAutoSaveWorker() + else: + self.logger.info("You reached first position.") + self.pos_i = self.num_pos - 1 + self.updatePos() + + def projection_frame_indices( + self, + *, + filename, + frame_i: int, + size_t: int, + locked: bool, + ) -> list[tuple[object, int]]: + if locked: + return [(filename, i) for i in range(size_t)] + return [(filename, frame_i)] + + def reInitLastSegmFrame( + self, checked=True, from_frame_i=None, updateImages=True, force=False + ): + if not force: + cancel = self.warnReinitLastSegmFrame() + if cancel: + self.logger.info("Re-initialization of last validated frame cancelled.") + return + + posData = self.data[self.pos_i] + if from_frame_i is None: + from_frame_i = posData.frame_i + + self.lastFrameRanOnFirstVisitTools = posData.frame_i + + self.updateLastCheckedFrameWidgets(from_frame_i) + posData.last_tracked_i = from_frame_i + self.navigateScrollBar.setMaximum(from_frame_i + 1) + self.navSpinBox.setMaximum(from_frame_i + 1) + # self.navigateScrollBar.setMinimum(1) + + # posData.tracked_lost_centroids[from_frame_i-1] = set() + for i in range(from_frame_i, posData.SizeT): + if posData.allData_li[i]["labels"] is None: + break + + posData.segm_data[i] = posData.allData_li[i]["labels"] + posData.allData_li[i] = myutils.get_empty_stored_data_dict() - def apply_tools_on_new_frame(self): - mode = str(self.modeComboBox.currentText()) - posData = self.data[self.pos_i] - should_apply = self.should_apply_new_frame_tools( - mode=mode, - last_tracked_i=posData.last_tracked_i, - frame_i=posData.frame_i, - last_frame_ran=self.lastFrameRanOnFirstVisitTools, - ) - if not should_apply: - return + posData.tracked_lost_centroids[i] = set() + posData.acdcTracker2stepsAnnotInfo.pop(i, None) - self.lastFrameRanOnFirstVisitTools = posData.frame_i - for name, checkbox in self.applyToolNewFrameActions.items(): - if not checkbox.isChecked(): - continue + if posData.acdc_df is not None: + frames = posData.acdc_df.index.get_level_values(0) + if from_frame_i in frames: + posData.acdc_df = posData.acdc_df.loc[:from_frame_i] - tool_button = self.applyToolNewFrameButtons[name] - try: - if hasattr(tool_button, 'click'): - tool_button.click() - elif hasattr(tool_button, 'trigger'): - tool_button.trigger() - else: - printl( - f"Warning: {name} has no click or trigger method" - ) - except Exception as e: - self.logger.info(f"Error applying tool {name}: {e}") + self.removeAlldelROIsCurrentFrame() - def manualAnnotRestoreLastTrackedFrame(self, last_tracked_i_to_restore): - if self.navigateScrollBar.maximum()-1 <= last_tracked_i_to_restore: + if not updateImages: return - posData = self.data[self.pos_i] - for frame_i in range(last_tracked_i_to_restore+1, posData.SizeT): - data_frame_i = self.empty_frame_record() - - data_frame_i['manually_edited_lab'] = ( - posData.allData_li[frame_i]['manually_edited_lab'] - ) + self.updateAllImages() - posData.allData_li[frame_i] = data_frame_i + def resetAcceptedLostIDs(self, from_frame_i=None): + posData = self.data[self.pos_i] + if from_frame_i is None: + from_frame_i = posData.frame_i - self.navigateScrollBar.setMaximum(last_tracked_i_to_restore+1) - self.navSpinBox.setMaximum(last_tracked_i_to_restore+1) + posData.tracked_lost_centroids[from_frame_i - 1] = set() + for i in range(from_frame_i, posData.SizeT): + posData.tracked_lost_centroids[i] = set() - # @exec_time - def prev_frame(self): + def resetNavigateFramesScrollbar(self, frame_i=None): posData = self.data[self.pos_i] - if posData.frame_i <= 0: - msg = 'You reached the first frame!' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - return + if frame_i is None: + frame_i = posData.frame_i - # Store data for current frame - mode = str(self.modeComboBox.currentText()) - if mode != 'Viewer': - self.store_data(debug=False) + self.navigateScrollBar.setValueNoSignal(frame_i + 1) - self.removeAlldelROIsCurrentFrame() - self.askLineageTreeChanges() - posData.frame_i -= 1 - _, never_visited = self.get_data() + def resetNavigateScrollbar(self): + try: + self.navigateScrollBar.blockSignals(True) + self.navigateScrollBar.actionTriggered.disconnect() + self.navigateScrollBar.sliderReleased.disconnect() + self.navigateScrollBar.sliderMoved.disconnect() + # self.navigateScrollBar.valueChanged.disconnect() + self.navigateScrollBar.setSliderPosition(self.navSpinBox.value()) + except Exception as e: + if "disconnect()" not in str(e): + printl(e) + pass - if mode == 'Segmentation and Tracking' or self.isSnapshot: - self.addExistingDelROIs() + self.navigateScrollBar.blockSignals(False) + self.navigateScrollBar.actionTriggered.connect( + self.framesScrollBarActionTriggered + ) + self.navigateScrollBar.sliderReleased.connect(self.framesScrollBarReleased) + self.navigateScrollBar.sliderMoved.connect(self.framesScrollBarMoved) - self.label_transform_tools_view.reset_expand_label() - self.preprocessing_view.updatePreprocessPreview() - self.updateCombineChannelsPreview() - self.postProcessing() - self.tracking() - self.whitelistPropagateIDs(update_lab=True) - self.updateAllImages() - self.updateScrollbars() - self.updateHighlightedAxis() - self.zoomToCells() - self.initGhostObject() - self.updateViewerWindow() - self.updateItemsMousePos() - self.updateObjectCounts() - self._sync_session_frame_i() + def rightImageFramesScrollbarValueChanged(self, value): + img = self.nextFrameImage(current_frame_i=value - 2) + self.img1.linkedImageItem.frame_i = value + self.img1.linkedImageItem.setImage(img) - def connectScrollbars(self): - self.t_label.show() - self.navigateScrollBar.show() - self.navigateScrollBar.setDisabled(False) + def setFrameNavigationDisabled(self, disable: bool, why: str): + """Disables the frame navigation buttons and scrollbar. + This is used when the user is not allowed to navigate through frames + Call again to unlock it again. Also sets tooltips to inform the user - if self.data[0].SizeZ > 1: - self.enableZstackWidgets(True) - self.zSliceScrollBar.setMaximum(self.data[0].SizeZ-1) - self.zSliceSpinbox.setMaximum(self.data[0].SizeZ) - self.SizeZlabel.setText(f'/{self.data[0].SizeZ}') + Parameters + ---------- + disable : bool + if the navigation should be disabled + why : str + the reason for disabeling the navigation. + """ + + if disable: + self.whyNavigateDisabled.add(why) + else: try: - self.zSliceScrollBar.actionTriggered.disconnect() - self.zSliceScrollBar.sliderReleased.disconnect() - self.zProjComboBox.currentTextChanged.disconnect() - self.zProjComboBox.activated.disconnect() - self.switchPlaneCombobox.sigPlaneChanged.disconnect() - self.zProjLockViewButton.toggled.disconnect() - except Exception as e: + self.whyNavigateDisabled.remove(why) + except KeyError: pass - self.zSliceScrollBar.actionTriggered.connect( - self.zSliceScrollBarActionTriggered - ) - self.zSliceScrollBar.sliderReleased.connect( - self.zSliceScrollBarReleased - ) - self.zProjComboBox.currentTextChanged.connect(self.updateZproj) - self.zProjComboBox.activated.connect( - self.mode_controls_view.clearComboBoxFocus - ) - self.switchPlaneCombobox.sigPlaneChanged.connect( - self.switchViewedPlane - ) - self.zProjLockViewButton.toggled.connect(self.zProjLockViewToggled) - posData = self.data[self.pos_i] - if posData.SizeT == 1: - self.t_label.setText('Position n.') - self.navigateScrollBar.setMinimum(1) - self.navigateScrollBar.setMaximum(len(self.data)) - self.navigateScrollBar.setAbsoluteMaximum(len(self.data)) - self.navSpinBox.setMaximum(len(self.data)) - self.navigateScrollBar.connectEvents({ - 'sliderMoved': self.PosScrollBarMoved, - 'sliderReleased': self.PosScrollBarReleased, - 'actionTriggered': self.PosScrollBarAction - }) + if len(self.whyNavigateDisabled) == 0: + disable = False else: - self.navigateScrollBar.setMinimum(1) - self.navigateScrollBar.setAbsoluteMaximum(posData.SizeT) - self.rightImageFramesScrollbar.setMinimum(1) - self.rightImageFramesScrollbar.setMaximum(posData.SizeT) - if posData.last_tracked_i is not None: - self.navigateScrollBar.setMaximum(posData.last_tracked_i+1) - self.navSpinBox.setMaximum(posData.last_tracked_i+1) - self.t_label.setText('Frame n.') - self.navigateScrollBar.connectEvents({ - 'sliderMoved': self.framesScrollBarMoved, - 'sliderReleased': self.framesScrollBarReleased, - 'actionTriggered': self.framesScrollBarActionTriggered - }) - self.rightImageFramesScrollbar.connectValueChanged( - self.rightImageFramesScrollbarValueChanged - ) + disable = True - def zSliceScrollBarActionTriggered(self, action): - singleMove = ( - action == SliderSingleStepAdd - or action == SliderSingleStepSub - or action == SliderPageStepAdd - or action == SliderPageStepSub - ) - if singleMove: - self.update_z_slice(self.zSliceScrollBar.sliderPosition()) - elif action == SliderMove: - if self.zSliceScrollBarStartedMoving and self.isSegm3D: - self.clearAx1Items(onlyHideText=True) - self.clearAx2Items(onlyHideText=True) - posData = self.data[self.pos_i] - idx = (posData.filename, posData.frame_i) - z = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == 'z': - posData.segmInfo_df.at[idx, 'z_slice_used_gui'] = z - self.zSliceSpinbox.setValueNoEmit(z+1) - img = self._getImageupdateAllImages(None) - self.img1.setCurrentZsliceIndex(z) - self.img1.setImage( - img, next_frame_image=self.nextFrameImage(), - scrollbar_value=posData.frame_i+2 + # Apply the disable/enable state + self.prevAction.setDisabled(disable) + self.nextAction.setDisabled(disable) + self.navigateScrollBar.setDisabled(disable) + + # Set appropriate tooltip + if not disable: + self.navigateScrollBar.setToolTip( + "NOTE: The maximum frame number that can be visualized with this " + "scrollbar\n" + "is the last visited frame with the selected mode\n" + '(see "Mode" selector on the top-right).\n\n' + "If the scrollbar does not move it means that you never visited\n" + "any frame with current mode.\n\n" + 'Note that the "Viewer" mode allows you to scroll ALL frames.' ) - try: - self.setOverlayImages() - except Exception as err: - pass + return - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.img2.setImage(posData.lab, z=z, autoLevels=False) - self.updateViewerWindow() - self.setTextAnnotZsliceScrolling() - self.setGraphicalAnnotZsliceScrolling() - self.setOverlayLabelsItems() - self.drawPointsLayers(computePointsLayers=False) - self.zSliceScrollBarStartedMoving = False - self.highlightSearchedID(self.highlightedID, force=True) + txt = f"Frame navigation disabled: {self.whyNavigateDisabled}" + self.logger.info(txt) + self.navigateScrollBar.setToolTip(txt) - def zSliceScrollBarReleased(self): - self.clearTempBrushImage() - self.zSliceScrollBarStartedMoving = True - self.update_z_slice(self.zSliceScrollBar.sliderPosition()) + def setNavigateScrollBarMaximum(self): + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + if mode == "Segmentation and Tracking": + if posData.last_tracked_i is not None: + if posData.frame_i > posData.last_tracked_i: + self.navigateScrollBar.setMaximum(posData.frame_i + 1) + self.navSpinBox.setMaximum(posData.frame_i + 1) + else: + self.navigateScrollBar.setMaximum(posData.last_tracked_i + 1) + self.navSpinBox.setMaximum(posData.last_tracked_i + 1) + else: + self.navigateScrollBar.setMaximum(posData.frame_i + 1) + self.navSpinBox.setMaximum(posData.frame_i + 1) + + self.updateLastCheckedFrameWidgets(self.navSpinBox.maximum() - 1) + elif mode == "Cell cycle analysis": + if posData.frame_i > self.last_cca_frame_i: + self.navigateScrollBar.setMaximum(posData.frame_i + 1) + self.navSpinBox.setMaximum(posData.frame_i + 1) + else: + self.navigateScrollBar.setMaximum(self.last_cca_frame_i + 1) + self.navSpinBox.setMaximum(self.last_cca_frame_i + 1) + self.lastTrackedFrameLabel.setText( + f"Last cc annot. frame n. = {self.navSpinBox.maximum()}" + ) + elif mode == "Normal division: Lineage tree": + if self.lineage_tree is None: + self.navigateScrollBar.setMaximum(posData.frame_i + 1) + self.navSpinBox.setMaximum(posData.frame_i + 1) + else: + if self.lineage_tree.frames_for_dfs: + i = max(self.lineage_tree.frames_for_dfs) + else: + i = 0 + self.navigateScrollBar.setMaximum(i + 1) + self.navSpinBox.setMaximum(i + 1) def setSwitchViewedPlaneDisabled(self, disabled): posData = self.data[self.pos_i] @@ -1168,55 +852,101 @@ def setSwitchViewedPlaneDisabled(self, disabled): if disabled: self.switchPlaneCombobox.setCurrentIndex(0) - def _setViewRangeSwitchPlane(self, previousPlane): - posData = self.data[self.pos_i] - SizeZ = posData.SizeZ - SizeY, SizeX = self.img1.image.shape[:2] - currentPlane = self.switchPlaneCombobox.plane() - if previousPlane == 'xy': - if currentPlane == 'zy': - self.ax1.setRange(xRange=self.yRangePrev) - unusedRange = np.clip(self.xRangePrev, 0, SizeX) - elif currentPlane == 'zx': - self.ax1.setRange(xRange=self.xRangePrev) - unusedRange = np.clip(self.yRangePrev, 0, SizeY) - elif previousPlane == 'zy': - if currentPlane == 'xy': - self.ax1.setRange(yRange=self.xRangePrev) - unusedRange = np.clip(self.yRangePrev, 0, SizeZ) - elif currentPlane == 'zx': - self.ax1.setRange(yRange=self.yRangePrev) - unusedRange = np.clip(self.xRangePrev, 0, SizeY) - elif previousPlane == 'zx': - if currentPlane == 'xy': - self.ax1.setRange(xRange=self.xRangePrev) - unusedRange = np.clip(self.yRangePrev, 0, SizeZ) - elif currentPlane == 'zy': - self.ax1.setRange(yRange=self.yRangePrev) - unusedRange = np.clip(self.xRangePrev, 0, SizeX) + def setViewRangeSwitchPlane(self, previousPlane): + self.autoRange() + QTimer.singleShot(100, partial(self._setViewRangeSwitchPlane, previousPlane)) + + def setZprojDisabled(self, disabled, storePrevState=False): + self.combineChannelsAction.setDisabled(disabled) + for action in self.editToolBar.actions(): + button = self.editToolBar.widgetForAction(action) + if button == self.eraserButton: + continue + + if button in self.toolsActiveInProj3Dsegm: + continue + + try: + tooltip = button.toolTip() + prefix = "WARNING: Disabled due to projection mode\n\n" + if disabled: + if not tooltip.startswith(prefix): + button.setToolTip(prefix + tooltip) + else: + if tooltip.startswith(prefix): + button.setToolTip(tooltip[len(prefix) :]) + except: + pass + action.setDisabled(disabled) + try: + button.setChecked(False) + except Exception: + pass + + def should_apply_new_frame_tools( + self, + *, + mode: str, + last_tracked_i: int, + frame_i: int, + last_frame_ran: int, + ) -> bool: + return ( + mode == self.segmentation_mode + and last_tracked_i is not None + and last_tracked_i <= frame_i + and frame_i != last_frame_ran + ) - sliceValue = round((unusedRange[0] + unusedRange[1])/2) - self.zSliceScrollBar.setSliderPosition(sliceValue) - self.update_z_slice(self.zSliceScrollBar.sliderPosition()) + def should_disable_overlay_z_slice(self, how: str) -> bool: + return how.find("max") != -1 or how == "same as above" - def setViewRangeSwitchPlane(self, previousPlane): - self.autoRange() - QTimer.singleShot( - 100, partial(self._setViewRangeSwitchPlane, previousPlane) + def should_show_next_frame_image( + self, + *, + size_t: int, + has_right_image_coords: bool, + action_enabled: bool, + action_checked: bool, + ) -> bool: + return ( + size_t > 1 and has_right_image_coords and action_enabled and action_checked ) + def should_store_when_slider_moves(self, *, mode: str) -> bool: + return mode != self.viewer_mode + + def should_warn_lost_objects( + self, + *, + requested: bool, + action_checked: bool, + mode: str, + lost_ids, + already_accepted: bool, + ) -> bool: + if not requested: + return False + if not action_checked: + return False + if mode != self.segmentation_mode: + return False + if not lost_ids: + return False + return not already_accepted + def switchViewedPlane(self, previousPlane, currentPlane): posData = self.data[self.pos_i] self.xRangePrev, self.yRangePrev = self.ax1.viewRange() self.zSlicePrev = self.zSliceScrollBar.sliderPosition() - self.zProjComboBox.setCurrentText('single z-slice') + self.zProjComboBox.setCurrentText("single z-slice") depthAxes = self.switchPlaneCombobox.depthAxes() self.onEscape() self.initDelRoiLab() - if depthAxes != 'z': + if depthAxes != "z": # Disable projections on plane that is not xy - self.zProjComboBox.setCurrentText('single z-slice') + self.zProjComboBox.setCurrentText("single z-slice") self.zProjComboBox.setDisabled(True) # Clear annotations @@ -1243,63 +973,47 @@ def switchViewedPlane(self, previousPlane, currentPlane): SizeY, SizeX = posData.img_data[posData.frame_i].shape[-2:] - if depthAxes != 'z' and self.isSnapshot: + if depthAxes != "z" and self.isSnapshot: # Disable editing when the plane is not xy - self.mode_controls_view.disableEditingViewPlaneNotXY() + self.disableEditingViewPlaneNotXY() elif self.isSnapshot: # Re-enable editing in snapshot mode when the plane is xy - self.mode_controls_view.setEnabledSnapshotMode() + self.setEnabledSnapshotMode() - if depthAxes == 'z': + if depthAxes == "z": maxSliceNum = posData.SizeZ - elif depthAxes == 'y': + elif depthAxes == "y": maxSliceNum = SizeY else: maxSliceNum = SizeX - maxSliceText = f'/{maxSliceNum}' + maxSliceText = f"/{maxSliceNum}" self.SizeZlabel.setText(maxSliceText) - self.zSliceCheckbox.setText(f'{depthAxes}-slice') - self.zSliceScrollBar.setMaximum(maxSliceNum-1) + self.zSliceCheckbox.setText(f"{depthAxes}-slice") + self.zSliceScrollBar.setMaximum(maxSliceNum - 1) self.zSliceSpinbox.setMaximum(maxSliceNum) self.initContoursImage() self.updateAllImages() - QTimer.singleShot( - 200, partial(self.setViewRangeSwitchPlane, previousPlane) - ) - - def onZsliceSpinboxValueChange(self, value): - self.zSliceScrollBar.setSliderPosition(value-1) + QTimer.singleShot(200, partial(self.setViewRangeSwitchPlane, previousPlane)) - def update_z_slice(self, z): - posData = self.data[self.pos_i] - if self.switchPlaneCombobox.depthAxes() == 'z': - idx = self.z_slice_frame_indices( - filename=posData.filename, - frame_i=posData.frame_i, - size_t=posData.SizeT, - locked=self.zProjLockViewButton.isChecked(), - ) - posData.segmInfo_df.loc[idx, 'z_slice_used_gui'] = z + def updateFramePosLabel(self): + if self.isSnapshot: + posData = self.data[self.pos_i] + self.navSpinBox.setValueNoEmit(self.pos_i + 1) + else: + posData = self.data[0] + self.navSpinBox.setValueNoEmit(posData.frame_i + 1) - self.preprocessing_view.updatePreprocessPreview() - self.updateCombineChannelsPreview() - self.highlightedID = self.getHighlightedID() - self.updateAllImages( - computePointsLayers=False, - computeContours=False, - updateLookuptable=True - ) - self.updateItemsMousePos() - if self.isSegm3D: - self.updateObjectCounts() + def updateItemsMousePos(self): + if self.brushButton.isChecked(): + self.updateBrushCursor(self.xHoverImg, self.yHoverImg) - def updateOverlayZslice(self, z): - self.setOverlayImages() + if self.eraserButton.isChecked(): + self.updateEraserCursor(self.xHoverImg, self.yHoverImg) def updateOverlayZproj(self, how): - if self.should_disable_overlay_z_slice(how): + if how.find("max") != -1 or how == "same as above": self.overlay_z_label.setDisabled(True) self.zSliceOverlay_SB.setDisabled(True) else: @@ -1307,19 +1021,70 @@ def updateOverlayZproj(self, how): self.zSliceOverlay_SB.setDisabled(False) self.setOverlayImages() + def updateOverlayZslice(self, z): + self.setOverlayImages() + + def updatePos(self): + self.clearUndoQueue() + self.setStatusBarLabel() + self.checkManageVersions() + self.removeAlldelROIsCurrentFrame() + self.resetManualBackgroundItems() + proceed_cca, never_visited = self.get_data(debug=False) + self.pointsLayerLoadedDfsToData() + self.flushDirtyPointsLayersAutosave() + self.initContoursImage() + self.initDelRoiLab() + self.initTextAnnot() + self.postProcessing() + self.updateScrollbars() + self.updatePreprocessPreview() + self.updateCombineChannelsPreview() + self.updateAllImages() + self.computeSegm() + self.zoomOut() + self.restartZoomAutoPilot() + self.initManualBackgroundObject() + self.updateObjectCounts() + self.updateItemsMousePos() + + def updateScrollbars(self): + self.updateItemsMousePos() + self.updateFramePosLabel() + posData = self.data[self.pos_i] + navPos = self.pos_i + 1 if self.isSnapshot else posData.frame_i + 1 + self.navigateScrollBar.setSliderPosition(navPos) + if posData.SizeZ > 1: + self.updateZsliceScrollbar(posData.frame_i) + self.zSliceScrollBar.setMaximum(posData.SizeZ - 1) + self.zSliceSpinbox.setMaximum(posData.SizeZ) + self.SizeZlabel.setText(f"/{posData.SizeZ}") + + def updateViewerWindow(self): + if self.slideshowWin is None: + return + + if self.slideshowWin.linkWindow is None: + return + + if not self.slideshowWin.linkWindowCheckbox.isChecked(): + return + + posData = self.data[self.pos_i] + self.slideshowWin.frame_i = posData.frame_i + self.slideshowWin.update_img() + def updateZproj(self, how): - for p, posData in enumerate(self.data[self.pos_i:]): - idx = self.projection_frame_indices( - filename=posData.filename, - frame_i=posData.frame_i, - size_t=posData.SizeT, - locked=self.zProjLockViewButton.isChecked(), - ) - posData.segmInfo_df.loc[idx, 'which_z_proj_gui'] = how + for p, posData in enumerate(self.data[self.pos_i :]): + if self.zProjLockViewButton.isChecked(): + idx = [(posData.filename, frame_i) for frame_i in range(posData.SizeT)] + else: + idx = [(posData.filename, posData.frame_i)] + posData.segmInfo_df.loc[idx, "which_z_proj_gui"] = how posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) posData = self.data[self.pos_i] - if self.is_single_z_slice_projection(how): + if how == "single z-slice": self.zSliceScrollBar.setDisabled(False) self.zSliceSpinbox.setDisabled(False) self.zSliceCheckbox.setDisabled(False) @@ -1332,29 +1097,173 @@ def updateZproj(self, how): self.setZprojDisabled(self.isSegm3D) self.updateAllImages() - def setZprojDisabled(self, disabled, storePrevState=False): - self.combineChannelsAction.setDisabled(disabled) - for action in self.editToolBar.actions(): - button = self.editToolBar.widgetForAction(action) - if button == self.eraserButton: - continue + def update_z_slice(self, z): + posData = self.data[self.pos_i] + if self.switchPlaneCombobox.depthAxes() == "z": + if self.zProjLockViewButton.isChecked(): + idx = [(posData.filename, frame_i) for frame_i in range(posData.SizeT)] + else: + idx = [ + (posData.filename, frame_i) + for frame_i in range(posData.frame_i, posData.SizeT) + ] + posData.segmInfo_df.loc[idx, "z_slice_used_gui"] = z - if button in self.toolsActiveInProj3Dsegm: - continue + self.updatePreprocessPreview() + self.updateCombineChannelsPreview() + self.highlightedID = self.getHighlightedID() + self.updateAllImages( + computePointsLayers=False, computeContours=False, updateLookuptable=True + ) + self.updateItemsMousePos() + if self.isSegm3D: + self.updateObjectCounts() + + def warnLostObjects(self, do_warn=True): + if not do_warn: + return True + + if not self.warnLostCellsAction.isChecked(): + return True + + mode = str(self.modeComboBox.currentText()) + if not mode == "Segmentation and Tracking": + return True + + posData = self.data[self.pos_i] + if not posData.lost_IDs: + return True + + frame_i = posData.frame_i + try: + accepted_lost_IDs = posData.accepted_lost_IDs.get(frame_i, []) + already_accepted_lost = Counter(accepted_lost_IDs) == Counter( + posData.lost_IDs + ) + except AttributeError: + already_accepted_lost = False + + if already_accepted_lost: + return True + + self.nextAction.setDisabled(True) + self.prevAction.setDisabled(True) + self.navigateScrollBar.setDisabled(True) + + msg = widgets.myMessageBox() + warn_msg = html_utils.paragraph( + "Current frame (compared to previous frame) " + "has lost the following cells:

" + f"{posData.lost_IDs}

" + "Are you sure you want to continue?
" + ) + checkBox = QCheckBox("Do not show again") + noButton, yesButton = msg.warning( + self, "Lost cells!", warn_msg, buttonsTexts=("No", "Yes"), widgets=checkBox + ) + doNotWarnLostCells = not checkBox.isChecked() + self.warnLostCellsAction.setChecked(doNotWarnLostCells) + if msg.clickedButton == noButton: + self.nextAction.setDisabled(False) + self.prevAction.setDisabled(False) + self.navigateScrollBar.setDisabled(False) + return False + + self.nextAction.setDisabled(False) + self.prevAction.setDisabled(False) + self.navigateScrollBar.setDisabled(False) + if not hasattr(posData, "accepted_lost_IDs"): + posData.accepted_lost_IDs = {} + if frame_i not in posData.accepted_lost_IDs: + posData.accepted_lost_IDs[frame_i] = [] + + posData.accepted_lost_IDs[frame_i].extend(posData.lost_IDs) + # This section is adding the lost cells to tracked_lost_centroids... TBH I dont know why this wasnt done in the first place + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[posData.frame_i - 1]["IDs_idxs"] + accepted_lost_centroids = { + tuple(int(val) for val in prev_rp[prev_IDs_idxs[ID]].centroid) + for ID in posData.lost_IDs + } + try: + posData.tracked_lost_centroids[frame_i] = posData.tracked_lost_centroids[ + frame_i + ] | (accepted_lost_centroids) + except KeyError: + posData.tracked_lost_centroids[frame_i] = accepted_lost_centroids + return True + + def warnReinitLastSegmFrame(self): + current_frame_n = self.navigateScrollBar.value() + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + Are you sure you want to re-initialize the last visited and + validated frame to number {current_frame_n}?

+ WARNING: If you save, all annotations after frame number + {current_frame_n} will be lost! + """) + msg.warning( + self, + "WARNING: Potential loss of data", + txt, + buttonsTexts=("Cancel", "Yes, I am sure"), + ) + return msg.cancel + def zSliceScrollBarActionTriggered(self, action): + singleMove = ( + action == SliderSingleStepAdd + or action == SliderSingleStepSub + or action == SliderPageStepAdd + or action == SliderPageStepSub + ) + if singleMove: + self.update_z_slice(self.zSliceScrollBar.sliderPosition()) + elif action == SliderMove: + if self.zSliceScrollBarStartedMoving and self.isSegm3D: + self.clearAx1Items(onlyHideText=True) + self.clearAx2Items(onlyHideText=True) + posData = self.data[self.pos_i] + idx = (posData.filename, posData.frame_i) + z = self.zSliceScrollBar.sliderPosition() + if self.switchPlaneCombobox.depthAxes() == "z": + posData.segmInfo_df.at[idx, "z_slice_used_gui"] = z + self.zSliceSpinbox.setValueNoEmit(z + 1) + img = self._getImageupdateAllImages(None) + self.img1.setCurrentZsliceIndex(z) + self.img1.setImage( + img, + next_frame_image=self.nextFrameImage(), + scrollbar_value=posData.frame_i + 2, + ) try: - tooltip = button.toolTip() - prefix = 'WARNING: Disabled due to projection mode\n\n' - if disabled: - if not tooltip.startswith(prefix): - button.setToolTip(prefix + tooltip) - else: - if tooltip.startswith(prefix): - button.setToolTip(tooltip[len(prefix):]) - except: + self.setOverlayImages() + except Exception: pass - action.setDisabled(disabled) - try: - button.setChecked(False) - except Exception as err: - pass \ No newline at end of file + + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.img2.setImage(posData.lab, z=z, autoLevels=False) + self.updateViewerWindow() + self.setTextAnnotZsliceScrolling() + self.setGraphicalAnnotZsliceScrolling() + self.setOverlayLabelsItems() + self.drawPointsLayers(computePointsLayers=False) + self.zSliceScrollBarStartedMoving = False + self.highlightSearchedID(self.highlightedID, force=True) + + def zSliceScrollBarReleased(self): + self.clearTempBrushImage() + self.zSliceScrollBarStartedMoving = True + self.update_z_slice(self.zSliceScrollBar.sliderPosition()) + + def z_slice_frame_indices( + self, + *, + filename, + frame_i: int, + size_t: int, + locked: bool, + ) -> list[tuple[object, int]]: + if locked: + return [(filename, i) for i in range(size_t)] + return [(filename, i) for i in range(frame_i, size_t)] diff --git a/cellacdc/mixins/geometry.py b/cellacdc/mixins/geometry.py index 65c655c95..c88aec5d8 100644 --- a/cellacdc/mixins/geometry.py +++ b/cellacdc/mixins/geometry.py @@ -8,25 +8,9 @@ from cellacdc.transformation import crop_2D, snap_xy_to_closest_angle -class GeometryViewModel: +class GeometryMixin: """Application-facing commands for geometric interaction transforms.""" - def snap_xy_to_closest_angle( - self, - x0, - y0, - x1, - y1, - angle_factor=15, - ): - return snap_xy_to_closest_angle( - x0, - y0, - x1, - y1, - angle_factor=angle_factor, - ) - def crop_2d( self, image, @@ -42,48 +26,19 @@ def crop_2d( return_copy=return_copy, ) - def line_coords(self, y1, x1, y2, x2, *, dashed=True): - return get_line(y1, x1, y2, x2, dashed=dashed) - - def is_in_bounds(self, x, y, width, height): - return is_in_bounds(x, y, width, height) - - def windows_overlap_from_bounds( - self, - *, - main_left, - main_top, - main_width, - main_height, - other_left, - other_top, - ) -> bool: - main_right = main_left + main_width - main_bottom = main_top + main_height - return (other_top < main_bottom) and (other_left < main_right) - - def should_auto_activate_viewer( - self, - *, - is_data_loaded: bool, - windows_overlap: bool, - disable_auto_activate: bool, - ) -> bool: - return ( - is_data_loaded - and not windows_overlap - and not disable_auto_activate - ) - - def is_pan_image_click( + def is_configured_middle_click( self, *, mouse_button, - left_button, - modifiers, - alt_modifier, + configured_button, + key_sequence_is_none: bool, + tool_is_checked: bool, ) -> bool: - return modifiers == alt_modifier and mouse_button == left_button + if key_sequence_is_none: + is_del_object_active = True + else: + is_del_object_active = tool_is_checked + return mouse_button == configured_button and is_del_object_active def is_default_middle_click( self, @@ -104,19 +59,24 @@ def is_default_middle_click( ) return mouse_button == middle_button - def is_configured_middle_click( + def is_in_bounds(self, x, y, width, height): + return is_in_bounds(x, y, width, height) + + def is_pan_image_click( self, *, mouse_button, - configured_button, - key_sequence_is_none: bool, - tool_is_checked: bool, + left_button, + modifiers, + alt_modifier, ) -> bool: - if key_sequence_is_none: - is_del_object_active = True - else: - is_del_object_active = tool_is_checked - return mouse_button == configured_button and is_del_object_active + return modifiers == alt_modifier and mouse_button == left_button + + def line_coords(self, y1, x1, y2, x2, *, dashed=True): + return get_line(y1, x1, y2, x2, dashed=dashed) + + def local_to_global_slices(self, bbox_coords, global_shape): + return get_slices_local_into_global_arr(bbox_coords, global_shape) def middle_click_text( self, @@ -127,12 +87,12 @@ def middle_click_text( key_sequence_text: str | None = None, ) -> str: if not has_del_object_action and is_mac: - return 'Command + Left Click' + return "Command + Left Click" if not has_del_object_action: - return 'Middle Click' + return "Middle Click" if key_sequence_text is None: return button_name - return f'{key_sequence_text} + {button_name}' + return f"{key_sequence_text} + {button_name}" def object_contours( self, @@ -170,5 +130,41 @@ def object_to_object_contour_distance_matrix( restrict_search=restrict_search, ) - def local_to_global_slices(self, bbox_coords, global_shape): - return get_slices_local_into_global_arr(bbox_coords, global_shape) + def should_auto_activate_viewer( + self, + *, + is_data_loaded: bool, + windows_overlap: bool, + disable_auto_activate: bool, + ) -> bool: + return is_data_loaded and not windows_overlap and not disable_auto_activate + + def snap_xy_to_closest_angle( + self, + x0, + y0, + x1, + y1, + angle_factor=15, + ): + return snap_xy_to_closest_angle( + x0, + y0, + x1, + y1, + angle_factor=angle_factor, + ) + + def windows_overlap_from_bounds( + self, + *, + main_left, + main_top, + main_width, + main_height, + other_left, + other_top, + ) -> bool: + main_right = main_left + main_width + main_bottom = main_top + main_height + return (other_top < main_bottom) and (other_left < main_right) diff --git a/cellacdc/mixins/graphics.py b/cellacdc/mixins/graphics.py index 315dd75a2..3481bb408 100644 --- a/cellacdc/mixins/graphics.py +++ b/cellacdc/mixins/graphics.py @@ -9,9 +9,7 @@ import matplotlib import numpy as np import pyqtgraph as pg -from dataclasses import dataclass from collections.abc import Iterable, Mapping -import numpy as np import skimage.exposure import skimage.measure from natsort import natsorted @@ -35,677 +33,221 @@ _font.setPixelSize(11) -class GraphicsView: +class GraphicsMixin: """Qt-facing adapter for graphics item construction workflows.""" - LEGACY_METHODS = ( - 'defaultRescaleIntensLutActionToggled', - 'mousePressColorButton', - 'gui_addGraphicsItems', - 'gui_createTextAnnotColors', - 'gui_setTextAnnotColors', - 'gui_createPlotItems', - 'gui_createZoomRectItem', - 'gui_createLabelRoiItem', - 'gui_createOverlayColors', - 'gui_createOverlayItems', - 'addActionsLutItemContextMenu', - 'getOverlayItems', - 'removeAllItems', - 'clearAx2Items', - 'clearAx1Items', - 'clearOverlayLabelsItems', - 'clearAllItems', - 'createUserChannelNameAction', - 'createChannelNamesActions', - 'addFluoChNameContextMenuAction', - 'restoreDefaultColors', - 'segmNdimIndicatorClicked', - 'addAlphaScrollbar', - 'createOverlayContextMenu', - 'createOverlayLabelsContextMenu', - 'editOverlayLabelsAppearance', - 'createOverlayLabelsItems', - 'addOverlayLabelsToggled', - 'overlayLabelsDrawModeToggled', - 'overlayChannelToggled', - 'overlayLabels_cb', - 'askLabelsToOverlay', - 'showOverlayContextMenu', - 'showOverlayLabelsContextMenu', - 'setCheckedOverlayContextMenusActions', - 'enableOverlayWidgets', - 'hideOverlayLabelsItems', - 'showOverlayLabelsItems', - 'setOverlayLabelsItems', - 'getOverlayLabelsData', - 'loadOverlayLabelsData', - 'removeOverlayItems', - 'clearOverlayImageItems', - 'setOverlayColors', - 'loadOverlayData', - 'askSelectOverlayChannel', - 'setOverlaySingleChannel', - 'updateTransparentOverlayRgba', - 'setOverlayTransparency', - 'overlay_cb', - 'getOlImg', - 'setOverlayImages', - 'getOpacitiesFromAlphaScrollbarValues', - 'toggleOverlayColorButton', - 'toggleTextIDsColorButton', - 'updateTextAnnotColor', - 'saveTextIDsColors', - 'ticksCmapMoved', - 'updateLabelsCmap', - 'extendLabelsLUT', - 'initLookupTableLab', - 'getLabelsImageLut', - 'initLabelsImageItems', - 'updateLookuptable', - 'setLut', - 'shuffle_cmap', - 'setPermanentGreedyCmapPreferences', - 'permanentGreedyCmapToggled', - 'greedyShuffleCmap', - 'updateBkgrColor', - 'updateTextLabelsColor', - 'saveTextLabelsColor', - 'saveBkgrColor', - 'changeOverlayColor', - 'saveOverlayColor', - 'setValueLabelsAlphaSlider', - 'setOverlaySegmMasks', - 'setOverlayLabelsItemsVisible', - 'setRetainSizePolicyLutItems', - 'setOverlayChannelsToolbuttonsChecked', - 'setOverlayItemsVisible', - 'overlayChannelToolbuttonClicked', - 'setOverlayItemsOpacities', - 'initColormapOverlayLayerItem', - 'setOpacityOverlayLayersItems', - 'gui_getLostObjScatterItem', - 'gui_getTrackedLostObjScatterItem', - '_gui_createGraphicsItems', - 'gui_createTextAnnotItems', - 'gui_addOverlayLayerItems', - 'gui_addTopLayerItems', - 'updateContoursImage', - 'setContoursImage', - 'getObjFromID', - 'setLostObjectContour', - 'setTrackedLostObjectContour', - 'updateLostContoursImage', - 'drawLostObjContoursImage', - 'updateLostTrackedContoursImage', - 'drawLostTrackedObjContoursImage', - 'getNearestLostObjID', - 'addObjContourToContoursImage', - 'clearObjContour', - 'setAllContoursImages', - 'setAllLostObjContoursImage', - 'setAllLostTrackedObjContoursImage', - 'getObjContours', - 'clearComputedContours', - '_computeAllContours2D', - 'computeAllContours', - 'computeAllObjToObjCostPairs', - '_computeAllObjToObjCostPairs', - 'computeAllObjCostPairsWorkerCritical', - 'computeAllObjCostPairsWorkerFinished', - 'gui_createMothBudLinePens', - 'imgGradLUTfinished_cb', - 'restoreDefaultSettings', - 'updateMothBudLineColour', - '_updateMothBudLineColour', - 'saveMothBudLineColour', - 'mothBudLineWeightToggled', - '_updateMothBudLineSize', - 'gui_createContourPens', - 'updateContColour', - '_updateContColour', - 'saveContColour', - 'contLineWeightToggled', - '_updateContLineThickness', - 'gui_createGraphicsItems', - 'gui_connectGraphicsEvents', - 'gui_initImg1BottomWidgets', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - - def defaultRescaleIntensLutActionToggled(self, action): - how = action.text() - for rescaleIntensAction in self.imgGrad.rescaleActionGroup.actions(): - if how == rescaleIntensAction.text(): - rescaleIntensAction.setChecked(True) - rescaleIntensAction.trigger() - break - - for channel, items in self.overlayLayersItems.items(): - lutItem = items[1] - for rescaleIntensAction in lutItem.rescaleActionGroup.actions(): - if how == rescaleIntensAction.text(): - rescaleIntensAction.setChecked(True) - rescaleIntensAction.trigger() - break - - self.df_settings.at['default_rescale_intens_how', 'value'] = how - self.df_settings.to_csv(self.settings_csv_path) + """Headless graphics workflow rules.""" - def mousePressColorButton(self, event): - posData = self.data[self.pos_i] - items = list(self.checkedOverlayChannels) - if len(items)>1: - selectFluo = widgets.QDialogListbox( - 'Select image', - 'Select which fluorescence image you want to update the color of\n', - items, multiSelection=False, parent=self.host - ) - selectFluo.exec_() - keys = selectFluo.selectedItemsText - if selectFluo.cancel or not keys: - return - else: - self.overlayColorButton.channel = keys[0] - else: - self.overlayColorButton.channel = items[0] - self.overlayColorButton.selectColor() + def _base_first_hierarchical_opacities( + self, + alpha_values: Iterable[float], + ) -> list[float]: + alphas = [1.0, *alpha_values] + if not alphas: + return alphas - def gui_addGraphicsItems(self): - # Auto image adjustment button - proxy = QGraphicsProxyWidget() - equalizeHistPushButton = QPushButton("Enhance contrast") - widthHint = equalizeHistPushButton.sizeHint().width() - equalizeHistPushButton.setMaximumWidth(widthHint) - equalizeHistPushButton.setCheckable(True) - if not self.invertBwAction.isChecked(): - equalizeHistPushButton.setStyleSheet( - 'QPushButton {background-color: #282828; color: #F0F0F0;}' - ) - self.equalizeHistPushButton = equalizeHistPushButton - proxy.setWidget(equalizeHistPushButton) - self.graphLayout.addItem(proxy, row=0, col=0) - self.equalizeHistPushButton = equalizeHistPushButton + weights = [] + for i, alpha_ref in enumerate(alphas): + weight = alpha_ref + for alpha in alphas[i + 1 :]: + weight *= 1 - alpha + weights.append(weight) - # Left image histogram - self.imgGrad = widgets.myHistogramLUTitem(parent=self.host, name='image') - self.imgGrad.restoreState(self.df_settings) - self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) - for action in self.imgGrad.rescaleActionGroup.actions(): - if action.text() == self.defaultRescaleIntensHow: - action.setChecked(True) - self.rescaleIntensMenu.addAction(action) + return weights - # Colormap gradient widget - self.labelsGrad = widgets.labelsGradientWidget(parent=self.host) - try: - stateFound = self.labelsGrad.restoreState(self.df_settings) - except Exception as e: - self.logger.exception(traceback.format_exc()) - print('======================================') - self.logger.info( - 'Failed to restore previously used colormap. ' - 'Using default colormap "viridis"' - ) - self.labelsGrad.item.loadPreset('viridis') + def _computeAllContours2D(self, dataDict, obj, z, obj_bbox, include_internal=False): + obj_image = self.getObjImage(obj.image, obj.bbox, z_slice=z) + if obj_image is None: + return - # Add actions to imgGrad gradient item - self.imgGrad.gradient.menu.addAction( - self.labelsGrad.showLabelsImgAction - ) - self.imgGrad.gradient.menu.addAction( - self.labelsGrad.showRightImgAction + all_external = False + local = False + contours = core.get_obj_contours( + obj_image=obj_image, + obj_bbox=obj_bbox, + local=local, + all_external=all_external, ) - self.imgGrad.gradient.menu.addAction( - self.labelsGrad.showNextFrameAction + key = (obj.label, str(z), all_external, local) + dataDict["contours"][key] = contours + + all_external = True + local = False + contours = core.get_obj_contours( + obj_image=obj_image, + obj_bbox=obj_bbox, + local=local, + all_external=all_external, + all=include_internal, ) + key = (obj.label, str(z), all_external, local) + dataDict["contours"][key] = contours - self.imgGrad.gradient.menu.addSeparator() + return dataDict - self.imgGrad.gradient.menu.addMenu(self.exportMenu) + def _computeAllObjToObjCostPairs(self, posData): + self.computeAllObjCostPairsWorker.signals.initProgressBar.emit( + len(posData.allData_li) + ) + for frame_i, dataDict in enumerate(posData.allData_li): + if frame_i == 0: + continue - # Add actions to view menu - self.viewMenu.addAction(self.labelsGrad.showLabelsImgAction) - self.viewMenu.addAction(self.labelsGrad.showRightImgAction) + rp = dataDict["regionprops"] + if rp is None: + break - # Right image histogram - self.imgGradRight = widgets.baseHistogramLUTitem( - name='image', parent=self.host, gradientPosition='left' - ) - self.imgGradRight.gradient.menu.addAction( - self.labelsGrad.showLabelsImgAction - ) - self.imgGradRight.gradient.menu.addAction( - self.labelsGrad.showRightImgAction - ) - self.imgGradRight.gradient.menu.addAction( - self.labelsGrad.showNextFrameAction - ) + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + dist_matrix = core._compute_all_obj_to_obj_contour_dist_pairs( + dataDict["contours"], rp, prev_rp=prev_rp, restrict_search=True + ) + dataDict["obj_to_obj_dist_cost_matrix_df"] = dist_matrix + self.computeAllObjCostPairsWorker.signals.progressBar.emit(1) + self.computeAllObjCostPairsWorker.signals.initProgressBar.emit(0) - self.imgGrad.setChildLutItem(self.imgGradRight) + def _gui_createGraphicsItems(self): + for _posData in self.data: + _posData.allData_li = [None] * _posData.SizeT - # Title - self.titleLabel = pg.LabelItem( - justify='center', color=self.titleColor, size='14pt' - ) - self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) + posData = self.data[self.pos_i] - def gui_createTextAnnotColors(self, r, g, b, custom=False): - if custom: - self.objLabelAnnotRgb = (int(r), int(g), int(b)) - self.SphaseAnnotRgb = (int(r*0.9), int(r*0.9), int(b*0.9)) - self.G1phaseAnnotRgba = (int(r*0.8), int(g*0.8), int(b*0.8), 220) - else: - self.objLabelAnnotRgb = (255, 255, 255) # white - self.SphaseAnnotRgb = (229, 229, 229) - self.G1phaseAnnotRgba = (204, 204, 204, 220) - self.dividedAnnotRgb = (245, 188, 1) # orange + allIDs, posData = core.count_objects(posData, self.logger.info) - self.emptyBrush = pg.mkBrush((0,0,0,0)) - self.emptyPen = pg.mkPen((0,0,0,0)) + self.highLowResAction.setChecked(True) + numItems = len(allIDs) + if numItems > 1500: + cancel, switchToLowRes = _warnings.warnTooManyItems( + self, numItems, self.progressWin + ) + if cancel: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.loadingDataAborted() + return + if switchToLowRes: + self.highLowResAction.setChecked(False) + else: + # Many items requires pxMode active to be fast enough + self.pxModeAction.setChecked(True) - def gui_setTextAnnotColors(self): - self.textAnnot[0].setColors( - self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, - self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb - ) + self.logger.info("Creating graphical items...") - self.textAnnot[1].setColors( - self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, - self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb - ) + self.ax1_contoursImageItem = pg.ImageItem() + self.ax1_lostObjImageItem = pg.ImageItem() + self.ax2_lostObjImageItem = pg.ImageItem() - def gui_createPlotItems(self): - if 'textIDsColor' in self.df_settings.index: - rgbString = self.df_settings.at['textIDsColor', 'value'] - r, g, b = colors.rgb_str_to_values(rgbString) - self.gui_createTextAnnotColors(r, g, b, custom=True) - self.textIDsColorButton.setColor((r, g, b)) - else: - self.gui_createTextAnnotColors(0,0,0, custom=False) + self.ax1_lostTrackedObjImageItem = pg.ImageItem() + self.ax2_lostTrackedObjImageItem = pg.ImageItem() - if 'labels_text_color' in self.df_settings.index: - rgbString = self.df_settings.at['labels_text_color', 'value'] - r, g, b = colors.rgb_str_to_values(rgbString) - self.ax2_textColor = (r, g, b) - else: - self.ax2_textColor = (255, 0, 0) + self.ax1_oldMothBudLinesItem = pg.ScatterPlotItem( + symbol="s", + pxMode=False, + brush=self.oldMothBudLineBrush, + size=self.mothBudLineWeight, + pen=None, + ) + self.ax1_newMothBudLinesItem = pg.ScatterPlotItem( + symbol="s", + pxMode=False, + brush=self.newMothBudLineBrush, + size=self.mothBudLineWeight, + pen=None, + ) + self.ax1_lostObjScatterItem = self.gui_getLostObjScatterItem() + self.yellowContourScatterItem = self.gui_getLostObjScatterItem() - self.emptyLab = np.zeros((2,2), dtype=np.uint8) + self.ax1_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() + self.greenContourScatterItem = self.gui_getTrackedLostObjScatterItem() - # Right image item linked to left - self.rightImageItem = widgets.ChildImageItem( - linkedScrollbar=self.rightImageFramesScrollbar + brush = pg.mkBrush((0, 255, 0, 200)) + pen = pg.mkPen("g", width=1) + self.ccaFailedScatterItem = pg.ScatterPlotItem( + size=self.contLineWeight + 1, pen=pen, brush=brush, pxMode=False, symbol="s" ) - self.imgGradRight.setImageItem(self.rightImageItem) - self.ax2.addItem(self.rightImageItem) - # Left image - self.img1 = widgets.ParentImageItem( - linkedImageItem=self.rightImageItem, - activatingActions=( - self.labelsGrad.showRightImgAction, - self.labelsGrad.showNextFrameAction - ) + self.ax2_contoursImageItem = pg.ImageItem() + self.ax2_oldMothBudLinesItem = pg.ScatterPlotItem( + symbol="s", + pxMode=False, + brush=self.oldMothBudLineBrush, + size=self.mothBudLineWeight, + pen=None, ) - self.imgGrad.setImageItem(self.img1) - self.img1.lutItem = self.imgGrad - self.imgGrad.sigRescaleIntes.connect(self.rescaleIntensitiesLut) - self.ax1.addBaseImageItem(self.img1) + self.ax2_newMothBudLinesItem = pg.ScatterPlotItem( + symbol="s", + pxMode=False, + brush=self.newMothBudLineBrush, + size=self.mothBudLineWeight, + pen=None, + ) + self.ax2_lostObjScatterItem = self.gui_getLostObjScatterItem() + self.ax2_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() - # RGBA image for true transparency mode - self.rgbaImg1 = pg.ImageItem() + self.gui_createTextAnnotItems(allIDs) # here + self.gui_setTextAnnotColors() # here - # self.rgbaImg1.setImage(self.emptyLab) + self.setDisabledAnnotOptions(False) - # Right image - self.img2 = widgets.labImageItem() - self.ax2.addItem(self.img2) + self.progressWin.mainPbar.setMaximum(0) + self.gui_addOverlayLayerItems() + self.gui_addTopLayerItems() - self.topLayerItems = [] - self.topLayerItemsRight = [] + self.gui_addCreatedAxesItems() + self.gui_add_ax_cursors() + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None - self.gui_createContourPens() - self.gui_createMothBudLinePens() + self.loadingDataCompleted() - self.eraserCirclePen = pg.mkPen(width=1.5, color='r') + def _updateContColour(self, color): + self.gui_createContourPens() + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.contoursColorButton.setColor(color) - # Temporary line item connecting bud to new mother - self.BudMothTempLine = pg.PlotDataItem(pen=self.NewBudMoth_Pen) - self.topLayerItems.append(self.BudMothTempLine) + def _updateContLineThickness(self): + self.gui_createContourPens() + for act in self.imgGrad.contLineWightActionGroup.actions(): + if act == self.sender(): + act.setChecked(True) + act.toggled.connect(self.contLineWeightToggled) - # Temporary line item connecting objects to merge - self.mergeObjsTempLine = widgets.PlotCurveItem(pen=self.redDashLinePen) - self.topLayerItems.append(self.mergeObjsTempLine) + def _updateMothBudLineColour(self, color): + self.gui_createMothBudLinePens() + self.ax1_newMothBudLinesItem.setBrush(self.newMothBudLineBrush) + self.ax1_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush) + self.ax2_newMothBudLinesItem.setBrush(self.newMothBudLineBrush) + self.ax2_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush) + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.mothBudLineColorButton.setColor(color) - # Overlay segm. masks item - self.labelsLayerImg1 = widgets.BaseLabelsImageItem() - self.ax1.addItem(self.labelsLayerImg1) + def _updateMothBudLineSize(self, size): + self.gui_createMothBudLinePens() - self.labelsLayerRightImg = widgets.BaseLabelsImageItem() - self.ax2.addItem(self.labelsLayerRightImg) + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): + if act == self.sender(): + act.setChecked(True) + act.toggled.connect(self.mothBudLineWeightToggled) - # Red/green border rect item - self.GreenLinePen = pg.mkPen(color='g', width=2) - self.RedLinePen = pg.mkPen(color='r', width=2) - self.ax1BorderLine = pg.PlotDataItem() - self.topLayerItems.append(self.ax1BorderLine) - self.ax2BorderLine = pg.PlotDataItem(pen=pg.mkPen(color='r', width=2)) - self.topLayerItems.append(self.ax2BorderLine) - - # Brush/Eraser/Wand.. layer item - self.tempLayerRightImage = pg.ImageItem() - self.tempLayerImg1 = widgets.ParentImageItem( - linkedImageItem=self.tempLayerRightImage, - activatingAction=(self.labelsGrad.showRightImgAction, ) - ) - self.topLayerItems.append(self.tempLayerImg1) - self.topLayerItemsRight.append(self.tempLayerRightImage) - - # Highlighted ID layer items - self.highLightIDLayerImg1 = pg.ImageItem() - self.topLayerItems.append(self.highLightIDLayerImg1) - - # Highlighted ID layer items - self.highLightIDLayerRightImage = pg.ImageItem() - self.topLayerItemsRight.append(self.highLightIDLayerRightImage) - - # Keep IDs temp layers - self.keepIDsTempLayerRight = pg.ImageItem() - self.keepIDsTempLayerLeft = widgets.ParentImageItem( - linkedImageItem=self.keepIDsTempLayerRight, - activatingAction=self.labelsGrad.showRightImgAction - ) - self.topLayerItems.append(self.keepIDsTempLayerLeft) - self.topLayerItemsRight.append(self.keepIDsTempLayerRight) - - # Searched ID contour - self.searchedIDitemRight = pg.ScatterPlotItem() - self.searchedIDitemRight.setData( - [], [], symbol='s', pxMode=False, size=1, - brush=pg.mkBrush(color=(255,0,0,150)), - pen=pg.mkPen(width=2, color='r'), tip=None - ) - self.searchedIDitemLeft = pg.ScatterPlotItem() - self.searchedIDitemLeft.setData( - [], [], symbol='s', pxMode=False, size=1, - brush=pg.mkBrush(color=(255,0,0,150)), - pen=pg.mkPen(width=2, color='r'), tip=None - ) - self.topLayerItems.append(self.searchedIDitemLeft) - self.topLayerItemsRight.append(self.searchedIDitemRight) - - - # Brush circle img1 - self.ax1_BrushCircle = pg.ScatterPlotItem() - self.ax1_BrushCircle.setData( - [], [], symbol='o', pxMode=False, - brush=pg.mkBrush((255,255,255,50)), - pen=pg.mkPen(width=2), tip=None - ) - self.topLayerItems.append(self.ax1_BrushCircle) - - # Eraser circle img1 - self.ax1_EraserCircle = pg.ScatterPlotItem() - self.ax1_EraserCircle.setData( - [], [], symbol='o', pxMode=False, - brush=None, pen=self.eraserCirclePen, tip=None - ) - self.topLayerItems.append(self.ax1_EraserCircle) - - self.ax1_EraserX = pg.ScatterPlotItem() - self.ax1_EraserX.setData( - [], [], symbol='x', pxMode=False, size=3, - brush=pg.mkBrush(color=(255,0,0,50)), - pen=pg.mkPen(width=1, color='r'), tip=None - ) - self.topLayerItems.append(self.ax1_EraserX) - - # Brush circle img1 - self.labelRoiCircItemLeft = widgets.LabelRoiCircularItem() - self.labelRoiCircItemLeft.cleared = False - self.labelRoiCircItemLeft.setData( - [], [], symbol='o', pxMode=False, - brush=pg.mkBrush(color=(255,0,0,0)), - pen=pg.mkPen(color='r', width=2), tip=None - ) - self.labelRoiCircItemRight = widgets.LabelRoiCircularItem() - self.labelRoiCircItemRight.cleared = False - self.labelRoiCircItemRight.setData( - [], [], symbol='o', pxMode=False, - brush=pg.mkBrush(color=(255,0,0,0)), - pen=pg.mkPen(color='r', width=2), tip=None - ) - self.topLayerItems.append(self.labelRoiCircItemLeft) - self.topLayerItemsRight.append(self.labelRoiCircItemRight) - - self.ax1_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() - self.ax1_binnedIDs_ScatterPlot.setData( - [], [], symbol='t', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=3, color='r'), tip=None - ) - self.topLayerItems.append(self.ax1_binnedIDs_ScatterPlot) - - self.ax1_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() - self.ax1_ripIDs_ScatterPlot.setData( - [], [], symbol='x', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=2, color='r'), tip=None - ) - self.topLayerItems.append(self.ax1_ripIDs_ScatterPlot) - - # Ruler plotItem and scatterItem - rulerPen = pg.mkPen(color='r', style=Qt.DashLine, width=2) - self.ax1_rulerPlotItem = widgets.RulerPlotItem(pen=rulerPen) - self.ax1_rulerAnchorsItem = pg.ScatterPlotItem( - symbol='o', size=9, - brush=pg.mkBrush((255,0,0,50)), - pen=pg.mkPen((255,0,0), width=2), tip=None - ) - self.topLayerItems.append(self.ax1_rulerPlotItem) - self.topLayerItems.append(self.ax1_rulerPlotItem.labelItem) - self.topLayerItems.append(self.ax1_rulerAnchorsItem) - - # Start point of polyline roi - self.ax1_point_ScatterPlot = pg.ScatterPlotItem() - self.ax1_point_ScatterPlot.setData( - [], [], symbol='o', pxMode=False, size=3, - pen=pg.mkPen(width=2, color='r'), - brush=pg.mkBrush((255,0,0,50)), tip=None - ) - self.topLayerItems.append(self.ax1_point_ScatterPlot) - - # Experimental: scatter plot to add a point marker - self.startPointPolyLineItem = pg.ScatterPlotItem() - self.startPointPolyLineItem.setData( - [], [], symbol='o', size=9, - pen=pg.mkPen(width=2, color='r'), - brush=pg.mkBrush((255,0,0,50)), - hoverable=True, hoverBrush=pg.mkBrush((255,0,0,255)), tip=None - ) - self.topLayerItems.append(self.startPointPolyLineItem) - - # Eraser circle img2 - self.ax2_EraserCircle = pg.ScatterPlotItem() - self.ax2_EraserCircle.setData( - [], [], symbol='o', pxMode=False, brush=None, - pen=self.eraserCirclePen, tip=None - ) - self.ax2.addItem(self.ax2_EraserCircle) - self.ax2_EraserX = pg.ScatterPlotItem() - self.ax2_EraserX.setData( - [], [], symbol='x', pxMode=False, size=3, - brush=pg.mkBrush(color=(255,0,0,50)), - pen=pg.mkPen(width=1.5, color='r') - ) - self.ax2.addItem(self.ax2_EraserX) - - # Brush circle img2 - self.ax2_BrushCirclePen = pg.mkPen(width=2) - self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50)) - self.ax2_BrushCircle = pg.ScatterPlotItem() - self.ax2_BrushCircle.setData( - [], [], symbol='o', pxMode=False, - brush=self.ax2_BrushCircleBrush, - pen=self.ax2_BrushCirclePen, tip=None - ) - self.ax2.addItem(self.ax2_BrushCircle) - - # Annotated metadata markers (ScatterPlotItem) - self.ax2_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() - self.ax2_binnedIDs_ScatterPlot.setData( - [], [], symbol='t', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=3, color='r'), tip=None - ) - self.ax2.addItem(self.ax2_binnedIDs_ScatterPlot) - - self.ax2_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() - self.ax2_ripIDs_ScatterPlot.setData( - [], [], symbol='x', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=2, color='r'), tip=None - ) - self.ax2.addItem(self.ax2_ripIDs_ScatterPlot) - - self.freeRoiItem = widgets.PlotCurveItem( - pen=pg.mkPen(color='r', width=2) - ) - self.topLayerItems.append(self.freeRoiItem) - - self.warnPairingItem = widgets.PlotCurveItem( - pen=pg.mkPen(color='r', width=5, style=Qt.DashLine), - pxMode=False - ) - self.topLayerItems.append(self.warnPairingItem) - - self.exportMaskImageItem = pg.ImageItem() - - self.ghostContourItemLeft = widgets.GhostContourItem(self.ax1) - self.ghostContourItemRight = widgets.GhostContourItem(self.ax2) - - self.ghostMaskItemLeft = widgets.GhostMaskItem(self.ax1) - self.ghostMaskItemRight = widgets.GhostMaskItem(self.ax2) - - self.manualBackgroundObjItem = widgets.GhostContourItem( - self.ax1, penColor='r', textColor='r' - ) - self.manualBackgroundImageItem = pg.ImageItem() - - def gui_createZoomRectItem(self): - Y, X = self.currentLab2D.shape - # Label ROI rectangle - pen = pg.mkPen('r', width=3, style=Qt.DashLine) - self.zoomRectItem = widgets.ZoomROI( - (0,0), (0,0), - maxBounds=QRectF(QRect(0,0,X,Y)), - scaleSnap=True, - translateSnap=True, - pen=pen, hoverPen=pen - ) - - def gui_createLabelRoiItem(self): - Y, X = self.currentLab2D.shape - # Label ROI rectangle - pen = pg.mkPen('r', width=3) - self.labelRoiItem = widgets.ROI( - (0,0), (0,0), - maxBounds=QRectF(QRect(0,0,X,Y)), - scaleSnap=True, - translateSnap=True, - pen=pen, hoverPen=pen - ) - - posData = self.data[self.pos_i] - if self.labelRoiZdepthSpinbox.value() == 0: - self.labelRoiZdepthSpinbox.setValue(posData.SizeZ) - self.labelRoiZdepthSpinbox.setMaximum(posData.SizeZ+1) - - def gui_createOverlayColors(self): - fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] - self.logger.info( - f'Number of TIFF files detected: {len(fluoChannels)}' - ) - self.overlayColors = {} - for c, ch in enumerate(fluoChannels): - if f'{ch}_rgb' in self.df_settings.index: - rgb_text = self.df_settings.at[f'{ch}_rgb', 'value'] - rgb = tuple([int(val) for val in rgb_text.split('_')]) - self.overlayColors[ch] = rgb - else: - if c >= len(self.overlayRGBs) -1: - i = c/len(fluoChannels) - additional_color_num = c - len(self.overlayRGBs) + 1 - rgbs = [ - tuple([round(c*255) for c in self.overlayCmap(i)][:3]) - for _ in range(additional_color_num) - ] - self.overlayRGBs.extend(rgbs) - rgb = colors.FLUO_CHANNELS_COLORS.get(ch, self.overlayRGBs[c]) - self.overlayColors[ch] = rgb - - def gui_createOverlayItems(self): - self.imgGrad.setAxisLabel(self.user_ch_name) - self.baseLayerToolbutton = widgets.OverlayChannelToolButton( - self.user_ch_name, self.imgGrad - ) - self.baseLayerToolbutton.setChecked(True) - self.baseLayerToolbutton.clicked.connect( - self.overlayChannelToolbuttonClicked - ) - self.allOverlayToolbuttons = { - self.user_ch_name: self.baseLayerToolbutton - } - self.allOverlayToolbuttonsByIdx = { - 0: self.baseLayerToolbutton - } - self.baseLayerToolbutton.action = ( - self.overlayToolbar.addWidget(self.baseLayerToolbutton) - ) - self.overlayLayersItems = {} - self.overlayToolbarAreChannelsChecked = {} - fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] - for c, ch in enumerate(fluoChannels): - overlayItems = self.getOverlayItems(ch, c+1) - self.overlayLayersItems[ch] = overlayItems - imageItem, lutItem = overlayItems[:2] - self.ax1.addItem(imageItem) - self.lutItemsLayout.addItem(lutItem, row=0, col=c+1) - toolbutton = overlayItems[3] - self.allOverlayToolbuttons[ch] = toolbutton - self.allOverlayToolbuttonsByIdx[c+1] = toolbutton - - self.overlayToolbuttonsSep = self.overlayToolbar.addSeparator() - self.plotsCol = len(self.ch_names) - - self.ax1.addImageItem(self.rgbaImg1) + self.ax1_oldMothBudLinesItem.setSize(size) + self.ax1_newMothBudLinesItem.setSize(size) + self.ax2_oldMothBudLinesItem.setSize(size) + self.ax2_newMothBudLinesItem.setSize(size) def addActionsLutItemContextMenu(self, lutItem): - lutItem.gradient.menu.addSection('Visible channels: ') + lutItem.gradient.menu.addSection("Visible channels: ") for action in self.overlayContextMenu.actions(): if action.isSeparator(): continue lutItem.gradient.menu.addAction(action) lutItem.gradient.menu.addSeparator() - annotationMenu = lutItem.gradient.menu.addMenu('Annotations settings') - ID_menu = annotationMenu.addMenu('IDs') + annotationMenu = lutItem.gradient.menu.addMenu("Annotations settings") + ID_menu = annotationMenu.addMenu("IDs") self.annotSettingsIDmenu = QActionGroup(annotationMenu) labID_action = QAction("Show label's ID") labID_action.setCheckable(True) @@ -719,7 +261,7 @@ def addActionsLutItemContextMenu(self, lutItem): ID_menu.addAction(labID_action) ID_menu.addAction(treeID_action) - ID_menu = annotationMenu.addMenu('Generation number') + ID_menu = annotationMenu.addMenu("Generation number") self.annotSettingsGenNumMenu = QActionGroup(annotationMenu) gen_num_action = QAction("Show default generation number") gen_num_action.setCheckable(True) @@ -733,125 +275,138 @@ def addActionsLutItemContextMenu(self, lutItem): ID_menu.addAction(gen_num_action) ID_menu.addAction(tree_gen_num_action) - def getOverlayItems(self, channelName, index): - imageItem = widgets.OverlayImageItem() - imageItem.setOpacity(0.5) - imageItem.channelName = channelName + def addAlphaScrollbar(self, channelName, imageItem): + alphaScrollBar = widgets.ScrollBar(Qt.Horizontal) + imageItem.alphaScrollBar = alphaScrollBar + alphaScrollBar.channelName = channelName - lutItem = widgets.myHistogramLUTitem( - parent=self.host, name='image', axisLabel=channelName + label = QLabel(f"Alpha {channelName}") + label.setFont(_font) + label.hide() + alphaScrollBar.imageItem = imageItem + alphaScrollBar.label = label + alphaScrollBar.setFixedHeight(self.h) + alphaScrollBar.hide() + alphaScrollBar.setMinimum(0) + alphaScrollBar.setMaximum(40) + alphaScrollBar.setValue(20) + alphaScrollBar.setToolTip( + f"Control the alpha value of the overlaid channel {channelName}.\n" + "alpha=0 results in NO overlay,\n" + "alpha=1 results in only fluorescence data visible" ) - imageItem.lutItem = lutItem - for action in lutItem.rescaleActionGroup.actions(): - if action.text() == self.defaultRescaleIntensHow: - action.setChecked(True) - break - - lutItem.removeAddScaleBarAction() - lutItem.removeAddTimestampAction() - lutItem.restoreState(self.df_settings) - lutItem.setImageItem(imageItem) - lutItem.vb.raiseContextMenu = lambda x: None - initColor = self.overlayColors[channelName] - self.initColormapOverlayLayerItem(initColor, lutItem) - lutItem.addOverlayColorButton(initColor, channelName) - lutItem.initColor = initColor - lutItem.hide() - - lutItem.overlayColorButton.sigColorChanging.connect( - self.changeOverlayColor + self.bottomLeftLayout.addWidget( + alphaScrollBar.label, self.alphaScrollbarRow, 0, alignment=Qt.AlignRight ) - lutItem.overlayColorButton.sigColorChanged.connect( - self.saveOverlayColor + self.bottomLeftLayout.addWidget(alphaScrollBar, self.alphaScrollbarRow, 1, 1, 2) + + alphaScrollBar.valueChanged.connect( + partial(self.setOpacityOverlayLayersItems, scrollbar=alphaScrollBar) ) - lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW) + self.alphaScrollbarRow += 1 + return alphaScrollBar - lutItem.contoursColorButton.disconnect() - lutItem.contoursColorButton.clicked.connect( - self.imgGrad.contoursColorButton.click - ) - for act in lutItem.contLineWightActionGroup.actions(): - act.toggled.connect(self.contLineWeightToggled) + def addFluoChNameContextMenuAction(self, ch_name): + self.data[self.pos_i] + allTexts = [action.text() for action in self.chNamesQActionGroup.actions()] + if ch_name not in allTexts: + action = QAction(self) + action.setText(ch_name) + action.setCheckable(True) + self.chNamesQActionGroup.addAction(action) + action.setChecked(True) + self.fluoDataChNameActions.append(action) - lutItem.mothBudLineColorButton.disconnect() - lutItem.mothBudLineColorButton.clicked.connect( - self.imgGrad.mothBudLineColorButton.click - ) - for act in lutItem.mothBudLineWightActionGroup.actions(): - act.toggled.connect(self.mothBudLineWeightToggled) + def addObjContourToContoursImage( + self, ID=0, obj=None, ax=0, thickness=None, color=None, force=False + ): + imageItem = self.getContoursImageItem(ax, force=force) + if imageItem is None: + return - lutItem.textColorButton.disconnect() - lutItem.textColorButton.clicked.connect( - self.editTextIDsColorAction.trigger - ) + if obj is None: + obj = self.getObjFromID(ID) + if obj is None: + return - lutItem.defaultSettingsAction.triggered.connect( - self.restoreDefaultSettings - ) - lutItem.labelsAlphaSlider.valueChanged.connect( - self.setValueLabelsAlphaSlider - ) - lutItem.sigRescaleIntes.connect( - partial(self.rescaleIntensitiesLut, imageItem=imageItem) - ) - if f'how_rescale_intensities_{channelName}' in self.df_settings.index: - how = self.df_settings.at[ - f'how_rescale_intensities_{channelName}', 'value' - ] - lutItem.setRescaleIntensitiesHow(how) + contours = self.getObjContours(obj, all_external=True) + if thickness is None: + thickness = self.contLineWeight + if color is None: + color = self.contLineColor - self.rescaleIntensChannelHowMapper[channelName] = ( - 'Rescale each 2D image' - ) + self.setContoursImage(imageItem, contours, thickness, color) - self.addActionsLutItemContextMenu(lutItem) + def addOverlayLabelsToggled(self, checked, name=None): + if name is None: + name = self.sender().text() + if checked: + gradItem = self.overlayLabelsItems[name][-1] + drawMode = gradItem.drawModeActionGroup.checkedAction().text() + self.drawModeOverlayLabelsChannels[name] = drawMode + else: + self.drawModeOverlayLabelsChannels.pop(name) + self.hideOverlayLabelsItems(specific=[name]) + self.setOverlayLabelsItems() - alphaScrollBar = self.addAlphaScrollbar(channelName, imageItem) + def apply_lut_dimming_for_kept_objects( + self, + lut: np.ndarray, + kept_object_ids: list[int], + keep_ids_enabled: bool, + ) -> np.ndarray: + """Applies dimming to non-kept objects in the LUT if keep_ids is enabled.""" + import numpy as np - toolbutton = widgets.OverlayChannelToolButton( - channelName, lutItem, shortcut=str(index) - ) - toolbutton.action = self.overlayToolbar.addWidget(toolbutton) - toolbutton.setVisible(False) + if not keep_ids_enabled: + return lut - toolbutton.clicked.connect(self.overlayChannelToolbuttonClicked) + dimmed_lut = np.round(lut * 0.3).astype(np.uint8) + valid_ids = [idx for idx in kept_object_ids if idx < len(lut)] + if valid_ids: + kept_lut = np.round(dimmed_lut[valid_ids] / 0.3).astype(np.uint8) + dimmed_lut[valid_ids] = kept_lut + return dimmed_lut - alphaScrollBar.toolbutton = toolbutton + def askLabelsToOverlay(self): + selectOverlayLabels = widgets.QDialogListbox( + "Select segmentation to overlay", + "Select segmentation file to overlay:\n", + natsorted(self.existingSegmEndNames), + multiSelection=True, + parent=self, + ) + selectOverlayLabels.exec_() + if selectOverlayLabels.cancel: + return - return imageItem, lutItem, alphaScrollBar, toolbutton + return selectOverlayLabels.selectedItemsText - def removeAllItems(self): - self.ax1.clear() - self.ax2.clear() - try: - self.chNamesQActionGroup.removeAction(self.userChNameAction) - except Exception as e: - pass - try: - posData = self.data[self.pos_i] - for action in self.fluoDataChNameActions: - self.chNamesQActionGroup.removeAction(action) - except Exception as e: - pass - try: - self.overlayButton.setChecked(False) - except Exception as e: - pass + def askSelectOverlayChannel(self): + ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] + selectFluo = widgets.QDialogListbox( + "Select channel", + "Select channel names to overlay:\n", + ch_names, + multiSelection=True, + parent=self, + ) + selectFluo.exec_() + if selectFluo.cancel: + return - if hasattr(self, 'contoursImage'): - self.initContoursImage() + return selectFluo.selectedItemsText - def clearAx2Items(self, onlyHideText=False): - self.ax2_binnedIDs_ScatterPlot.clear() - self.ax2_ripIDs_ScatterPlot.clear() - self.ax2_contoursImageItem.clear() - self.ax2_lostObjImageItem.clear() - self.ax2_lostTrackedObjImageItem.clear() - self.textAnnot[1].clear() - self.ax2_newMothBudLinesItem.setData([], []) - self.ax2_oldMothBudLinesItem.setData([], []) - self.ax2_lostObjScatterItem.setData([], []) + def changeOverlayColor(self, button): + rgb = button.color().getRgb()[:3] + lutItem = self.overlayLayersItems[button.channel][1] + self.initColormapOverlayLayerItem(rgb, lutItem) + lutItem.overlayColorButton.setColor(rgb) + + def clearAllItems(self): + self.clearAx1Items() + self.clearAx2Items() def clearAx1Items(self, onlyHideText=False): self.ax1_binnedIDs_ScatterPlot.clear() @@ -879,7 +434,47 @@ def clearAx1Items(self, onlyHideText=False): self.clearOverlayLabelsItems() self.clearManualBackgroundAnnotations() - self.custom_annotations_view.clearCustomAnnot() + self.clearCustomAnnot() + + def clearAx2Items(self, onlyHideText=False): + self.ax2_binnedIDs_ScatterPlot.clear() + self.ax2_ripIDs_ScatterPlot.clear() + self.ax2_contoursImageItem.clear() + self.ax2_lostObjImageItem.clear() + self.ax2_lostTrackedObjImageItem.clear() + self.textAnnot[1].clear() + self.ax2_newMothBudLinesItem.setData([], []) + self.ax2_oldMothBudLinesItem.setData([], []) + self.ax2_lostObjScatterItem.setData([], []) + + def clearComputedContours(self): + for posData in self.data: + for frame_i, dataDict in enumerate(posData.allData_li): + dataDict["contours"] = {} + + def clearObjContour(self, ID=0, obj=None, ax=0, debug=False, updateImage=True): + imageItem = self.getContoursImageItem(ax) + if imageItem is None: + return + + if ID > 0: + self.contoursImage[self.currentLab2D == ID] = [0, 0, 0, 0] + else: + obj_slice = self.getObjSlice(obj.slice) + obj_image = self.getObjImage(obj.image, obj.bbox) + self.contoursImage[obj_slice][obj_image] = [0, 0, 0, 0] + + if not updateImage: + return + + imageItem.setImage(self.contoursImage) + + def clearOverlayImageItems(self): + for items in self.overlayLayersItems.values(): + imageItem = items[0] + imageItem.clear() + + self.rgbaImg1.clear() def clearOverlayLabelsItems(self): for segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): @@ -888,279 +483,128 @@ def clearOverlayLabelsItems(self): imageItem.clear() contoursItem.clear() - def clearAllItems(self): - self.clearAx1Items() - self.clearAx2Items() + def computeAllContours(self): + self.logger.info("Computing all contours...") + posData = self.data[self.pos_i] + zz = [None] + if self.isSegm3D: + zz.extend(range(posData.SizeZ)) - def createUserChannelNameAction(self): - self.userChNameAction = QAction(self.host) - self.userChNameAction.setCheckable(True) - self.userChNameAction.setText(self.user_ch_name) + include_internal = self.showAllContoursToggle.isChecked() + for frame_i, dataDict in enumerate(posData.allData_li): + lab = dataDict["labels"] + if lab is None: + break - def createChannelNamesActions(self): - # LUT histogram channel name context menu actions - self.chNamesQActionGroup = QActionGroup(self.host) - self.chNamesQActionGroup.addAction(self.userChNameAction) - posData = self.data[self.pos_i] - for action in self.fluoDataChNameActions: - self.chNamesQActionGroup.addAction(action) - action.setChecked(False) + rp = dataDict["regionprops"] + if rp is None: + rp = skimage.measure.regionprops(lab) - self.userChNameAction.setChecked(True) + dataDict["contours"] = {} + for obj in rp: + obj_bbox = self.getObjBbox(obj.bbox) + for z in zz: + if not self.isObjVisible(obj.bbox, z_slice=z): + continue - for action in self.overlayContextMenu.actions(): - action.setChecked(False) + try: + self._computeAllContours2D( + dataDict, + obj, + z, + obj_bbox, + include_internal=include_internal, + ) + except Exception: + # Contours computation fails on weird objects + pass - def addFluoChNameContextMenuAction(self, ch_name): - posData = self.data[self.pos_i] - allTexts = [ - action.text() for action in self.chNamesQActionGroup.actions() - ] - if ch_name not in allTexts: - action = QAction(self.host) - action.setText(ch_name) - action.setCheckable(True) - self.chNamesQActionGroup.addAction(action) - action.setChecked(True) - self.fluoDataChNameActions.append(action) + def computeAllObjCostPairsWorkerCritical(self, error): + self.computeAllObjCostPairsWorkerLoop.exit() + self.workerCritical(error) - def restoreDefaultColors(self): - try: - color = self.defaultToolBarButtonColor - self.overlayButton.setStyleSheet(f'background-color: {color}') - except AttributeError: - # traceback.print_exc() - pass + def computeAllObjCostPairsWorkerFinished(self, output): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.computeAllObjCostPairsWorkerLoop.exit() - def segmNdimIndicatorClicked(self): - ndimText = self.segmNdimIndicator.text() - if ndimText == '2D': - alternativeNdimText = '3D' - toggleText = 'activate' - else: - alternativeNdimText = '2D' - toggleText = 'de-activate' - msg = widgets.myMessageBox(wrapText=False) - important_txt = (""" + def computeAllObjToObjCostPairs(self): + desc = "Computing all object-to-object cost matrices..." + self.logger.info(desc) + posData = self.data[self.pos_i] - """Headless graphics workflow rules.""" + self.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.show(self.app) - def overlay_toolbutton_checked( - self, - channel: str, - *, - checked_channels: Iterable[str], - is_single_channel: bool, - ) -> bool: - return not is_single_channel and channel in set(checked_channels) + self.computeAllObjCostPairsThread = QThread() + self.computeAllObjCostPairsWorker = workers.SimpleWorker( + posData, self._computeAllObjToObjCostPairs + ) - def overlay_toolbutton_click_checked_channels( - self, - *, - clicked_channel: str, - all_channels: Iterable[str], - checked_channels: Iterable[str], - toolbar_single_channel: bool, - ) -> set[str]: - all_channels = set(all_channels) - checked_channels = set(checked_channels) - if not checked_channels or toolbar_single_channel: - checked_channels.add(clicked_channel) - - if toolbar_single_channel: - return {clicked_channel} - - return checked_channels & all_channels - - def overlay_visibility_plan( - self, - *, - all_channels: Iterable[str], - checked_channels: Iterable[str], - overlay_enabled: bool, - ) -> OverlayVisibilityPlan: - checked_channels = set(checked_channels) - return OverlayVisibilityPlan( - channel_visible={ - channel: overlay_enabled and channel in checked_channels - for channel in all_channels - } + self.computeAllObjCostPairsWorker.moveToThread( + self.computeAllObjCostPairsThread ) - def overlay_channel_opacity_map( - self, - base_channel: str, - active_channel_alpha_values: Mapping[str, float], - ) -> dict[str, float]: - channels = list(active_channel_alpha_values) - alpha_values = list(active_channel_alpha_values.values()) - opacities = self._base_first_hierarchical_opacities(alpha_values) - channel_opacity_mapper = { - channel: opacities[i + 1] - for i, channel in enumerate(channels) - } - channel_opacity_mapper[base_channel] = opacities[0] - return channel_opacity_mapper - - def overlay_item_opacity_plan( - self, - *, - all_channels: Iterable[str], - base_channel: str, - checked_channels: Iterable[str], - toolbar_single_channel: bool, - active_channel_alpha_values: Mapping[str, float], - ) -> OverlayOpacityPlan: - checked_channels = set(checked_channels) - channel_opacity_mapper = self.overlay_channel_opacity_map( - base_channel, - active_channel_alpha_values, + self.computeAllObjCostPairsWorker.signals.finished.connect( + self.computeAllObjCostPairsThread.quit ) - is_single_channel = toolbar_single_channel or len(checked_channels) == 1 - - opacities = {} - alpha_scrollbar_disabled = {} - for channel in all_channels: - if channel in checked_channels and is_single_channel: - op_val = 1.0 - elif channel in checked_channels: - op_val = channel_opacity_mapper[channel] - else: - op_val = 0.0 - - if op_val == 0: - op_val = 0.01 - - opacities[channel] = min(op_val, 0.999) - if channel != base_channel: - alpha_scrollbar_disabled[channel] = op_val == 0 - - return OverlayOpacityPlan( - opacities=opacities, - alpha_scrollbar_disabled=alpha_scrollbar_disabled, + self.computeAllObjCostPairsWorker.signals.finished.connect( + self.computeAllObjCostPairsWorker.deleteLater ) - - def _base_first_hierarchical_opacities( - self, - alpha_values: Iterable[float], - ) -> list[float]: - alphas = [1.0, *alpha_values] - if not alphas: - return alphas - - weights = [] - for i, alpha_ref in enumerate(alphas): - weight = alpha_ref - for alpha in alphas[i + 1:]: - weight *= 1 - alpha - weights.append(weight) - - return weights - - def generate_labels_image_lut(self, base_lut: np.ndarray) -> np.ndarray: - """Converts a 3-channel base LUT to a 4-channel RGBA LUT with background as transparent.""" - import numpy as np - lut = np.zeros((len(base_lut), 4), dtype=np.uint8) - lut[:, -1] = 255 - lut[:, :-1] = base_lut - lut[0] = [0, 0, 0, 0] - return lut - - def extend_labels_lut(self, base_lut: np.ndarray, len_new_lut: int) -> np.ndarray: - """Extends base_lut to include IDs greater than original length of base_lut.""" - import numpy as np - if len_new_lut <= len(base_lut): - return base_lut - - num_new_colors = len_new_lut - len(base_lut) - _lut = np.zeros((len_new_lut, 3), np.uint8) - _lut[:len(base_lut)] = base_lut - - random_idx = np.random.randint(0, len(base_lut), size=num_new_colors) - for i, idx in enumerate(random_idx): - rgb = base_lut[idx] - _lut[len(base_lut) + i] = rgb - return _lut - - def apply_lut_dimming_for_kept_objects( - self, - lut: np.ndarray, - kept_object_ids: list[int], - keep_ids_enabled: bool, - ) -> np.ndarray: - """Applies dimming to non-kept objects in the LUT if keep_ids is enabled.""" - import numpy as np - if not keep_ids_enabled: - return lut - - dimmed_lut = np.round(lut * 0.3).astype(np.uint8) - valid_ids = [idx for idx in kept_object_ids if idx < len(lut)] - if valid_ids: - kept_lut = np.round(dimmed_lut[valid_ids] / 0.3).astype(np.uint8) - dimmed_lut[valid_ids] = kept_lut - return dimmed_lut - - - The toggle to activate 3D segmentation is visible only when - the Number of z-slices is greater than 1. - """) - txt = html_utils.paragraph(f""" - This indicator shows that you are working with {ndimText} - segmentation masks.

- - If instead, you want to work with {alternativeNdimText} segmentation, - you need to initialize a new segmentation file.

- - To do so, go the menu on the top menubar File --> - New Segmentation File... and,
- at the dialog where you insert the metadata (Number of z-slices, - pixel size, etc.),
- {toggleText} the parameter called Work with 3D - segmentation masks (z-stack)
- as indicated in the screenshot below
. - {html_utils.to_admonition(important_txt, admonition_type='note')} -
- """) - msg.information( - self.host, 'Segmentation nmber of dimensions info', txt, - image_paths=':toggle_3D_screenshot.png' + self.computeAllObjCostPairsThread.finished.connect( + self.computeAllObjCostPairsThread.deleteLater ) - self.segmNdimIndicator.setChecked(True) - def addAlphaScrollbar(self, channelName, imageItem): - alphaScrollBar = widgets.ScrollBar(Qt.Horizontal) - imageItem.alphaScrollBar = alphaScrollBar - alphaScrollBar.channelName = channelName - - label = QLabel(f'Alpha {channelName}') - label.setFont(_font) - label.hide() - alphaScrollBar.imageItem = imageItem - alphaScrollBar.label = label - alphaScrollBar.setFixedHeight(self.h) - alphaScrollBar.hide() - alphaScrollBar.setMinimum(0) - alphaScrollBar.setMaximum(40) - alphaScrollBar.setValue(20) - alphaScrollBar.setToolTip( - f'Control the alpha value of the overlaid channel {channelName}.\n' - 'alpha=0 results in NO overlay,\n' - 'alpha=1 results in only fluorescence data visible' + self.computeAllObjCostPairsWorker.signals.critical.connect( + self.computeAllObjCostPairsWorkerCritical ) - self.bottomLeftLayout.addWidget( - alphaScrollBar.label, self.alphaScrollbarRow, 0, - alignment=Qt.AlignRight + self.computeAllObjCostPairsWorker.signals.initProgressBar.connect( + self.workerInitProgressbar ) - self.bottomLeftLayout.addWidget( - alphaScrollBar, self.alphaScrollbarRow, 1, 1, 2 + self.computeAllObjCostPairsWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.computeAllObjCostPairsWorker.signals.progress.connect(self.workerProgress) + self.computeAllObjCostPairsWorker.signals.finished.connect( + self.computeAllObjCostPairsWorkerFinished ) - alphaScrollBar.valueChanged.connect( - partial(self.setOpacityOverlayLayersItems, scrollbar=alphaScrollBar) + self.computeAllObjCostPairsThread.started.connect( + self.computeAllObjCostPairsWorker.run ) + self.computeAllObjCostPairsThread.start() - self.alphaScrollbarRow += 1 - return alphaScrollBar + self.computeAllObjCostPairsWorkerLoop = QEventLoop() + self.computeAllObjCostPairsWorkerLoop.exec_() + + def contLineWeightToggled(self, checked=True): + if not checked: + return + self.imgGrad.uncheckContLineWeightActions() + w = self.sender().lineWeight + self.df_settings.at["contLineWeight", "value"] = w + self.df_settings.to_csv(self.settings_csv_path) + self._updateContLineThickness() + self.updateAllImages() + + def createChannelNamesActions(self): + # LUT histogram channel name context menu actions + self.chNamesQActionGroup = QActionGroup(self) + self.chNamesQActionGroup.addAction(self.userChNameAction) + self.data[self.pos_i] + for action in self.fluoDataChNameActions: + self.chNamesQActionGroup.addAction(action) + action.setChecked(False) + + self.userChNameAction.setChecked(True) + + for action in self.overlayContextMenu.actions(): + action.setChecked(False) def createOverlayContextMenu(self): ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] @@ -1178,10 +622,10 @@ def createOverlayLabelsContextMenu(self, segmEndnames): self.overlayLabelsContextMenu.addSeparator() self.drawModeOverlayLabelsChannels = {} segmEndnames_extended = list(segmEndnames.copy()) - segmEndnames_extended = ['combined segm.'] + segmEndnames_extended + segmEndnames_extended = ["combined segm."] + segmEndnames_extended for segmEndname in segmEndnames_extended: action = QAction(segmEndname, self.overlayLabelsContextMenu) - if segmEndname == 'combined segm.': + if segmEndname == "combined segm.": action.setCheckable(False) self.combineSegmViewToggle = action else: @@ -1190,34 +634,17 @@ def createOverlayLabelsContextMenu(self, segmEndnames): self.overlayLabelsContextMenu.addAction(action) self.overlayLabelsContextMenu.addSeparator() - action = QAction('Edit appearance...', self.overlayLabelsContextMenu) + action = QAction("Edit appearance...", self.overlayLabelsContextMenu) action.triggered.connect(self.editOverlayLabelsAppearance) self.overlayLabelsContextMenu.addAction(action) - def editOverlayLabelsAppearance(self, *args): - segmEndname = list(self.overlayLabelsItems.keys())[0] - contoursItem = self.overlayLabelsItems[segmEndname][1] - win = apps.OverlayLabelsAppearanceDialog( - scatterPlotItem=contoursItem, parent=self.host - ) - win.exec_() - if win.cancel: - return - - brush = win.properties['brush'] - pen = win.properties['pen'] - for items in self.overlayLabelsItems.values(): - imageItem, contoursItem, gradItem = items - contoursItem.setBrush(brush, update=False) - contoursItem.setPen(pen) - def createOverlayLabelsItems(self, segmEndnames): - selectActionGroup = QActionGroup(self.host) + selectActionGroup = QActionGroup(self) segmEndnames_extended = list(segmEndnames.copy()) - segmEndnames_extended = ['combined segm.'] + segmEndnames_extended + segmEndnames_extended = ["combined segm."] + segmEndnames_extended for segmEndname in segmEndnames_extended: action = QAction(segmEndname) - if segmEndname == 'combined segm.': + if segmEndname == "combined segm.": action.setCheckable(False) else: action.setCheckable(True) @@ -1243,110 +670,77 @@ def createOverlayLabelsItems(self, segmEndnames): r, g, b, a = colors.rgba_str_to_values(color) qcolor = QColor(r, g, b, a) contoursItem.setData( - [], [], symbol='s', pxMode=False, size=self.contLineWeight*2, + [], + [], + symbol="s", + pxMode=False, + size=self.contLineWeight * 2, brush=pg.mkBrush(color=qcolor), - pen=pg.mkPen(width=3, color=qcolor), tip=None + pen=pg.mkPen(width=3, color=qcolor), + tip=None, ) items = (imageItem, contoursItem, gradItem) self.overlayLabelsItems[segmEndname] = items - def addOverlayLabelsToggled(self, checked, name=None): - if name is None: - name = self.sender().text() - if checked: - gradItem = self.overlayLabelsItems[name][-1] - drawMode = gradItem.drawModeActionGroup.checkedAction().text() - self.drawModeOverlayLabelsChannels[name] = drawMode - else: - self.drawModeOverlayLabelsChannels.pop(name) - self.hideOverlayLabelsItems(specific=[name]) - self.setOverlayLabelsItems() + def createUserChannelNameAction(self): + self.userChNameAction = QAction(self) + self.userChNameAction.setCheckable(True) + self.userChNameAction.setText(self.user_ch_name) - def overlayLabelsDrawModeToggled(self, action): - segmEndname = action.segmEndname - drawMode = action.text() - if segmEndname in self.drawModeOverlayLabelsChannels: - self.drawModeOverlayLabelsChannels[segmEndname] = drawMode - self.setOverlayLabelsItems() + def defaultRescaleIntensLutActionToggled(self, action): + how = action.text() + for rescaleIntensAction in self.imgGrad.rescaleActionGroup.actions(): + if how == rescaleIntensAction.text(): + rescaleIntensAction.setChecked(True) + rescaleIntensAction.trigger() + break - def overlayChannelToggled(self, checked): - # Action toggled from overlayButton context menu - channelName = self.sender().text() - posData = self.data[self.pos_i] - if checked: - if channelName not in posData.loadedFluoChannels: - self.loadOverlayData([channelName], addToExisting=True) - else: - _, filename = self.getPathFromChName(channelName, posData) - posData.ol_data[filename] = ( - posData.ol_data_dict[filename].copy() - ) + for channel, items in self.overlayLayersItems.items(): + lutItem = items[1] + for rescaleIntensAction in lutItem.rescaleActionGroup.actions(): + if how == rescaleIntensAction.text(): + rescaleIntensAction.setChecked(True) + rescaleIntensAction.trigger() + break - self.checkedOverlayChannels.add(channelName) - else: - self.checkedOverlayChannels.remove(channelName) - imageItem = self.overlayLayersItems[channelName][0] - imageItem.clear() + self.df_settings.at["default_rescale_intens_how", "value"] = how + self.df_settings.to_csv(self.settings_csv_path) - self.setOverlayChannelsToolbuttonsChecked() - self.setOverlayItemsVisible() - self.setRetainSizePolicyLutItems() - self.updateAllImages() + def drawLostObjContoursImage( + self, + imageItem, + contours, + thickness=1, + color=(255, 165, 0, 255), # orange + ): + img = self.lostObjContoursImage + cv2.drawContours(img, contours, -1, color, thickness) + imageItem.setImage(img) - def overlayLabels_cb(self, checked, selectedLabelsEndnames=None): - if checked: - if not self.drawModeOverlayLabelsChannels: - if selectedLabelsEndnames is None: - selectedLabelsEndnames = self.askLabelsToOverlay() - if selectedLabelsEndnames is None: - self.logger.info('Overlay labels cancelled.') - self.overlayLabelsButton.setChecked(False) - return - for selectedEndname in selectedLabelsEndnames: - self.loadOverlayLabelsData(selectedEndname) - for action in self.overlayLabelsContextMenu.actions(): - if not action.isCheckable(): - continue - if action.text() == selectedEndname: - action.setChecked(True) - lastSelectedName = selectedLabelsEndnames[-1] - for action in self.selectOverlayLabelsActionGroup.actions(): - if action.text() == lastSelectedName: - action.setChecked(True) - self.updateAllImages() + def drawLostTrackedObjContoursImage(self, imageItem, contours): + thickness = 1 + color = (0, 255, 0, 255) # green + img = self.lostTrackedObjContoursImage + cv2.drawContours(img, contours, -1, color, thickness) + imageItem.setImage(img) - def askLabelsToOverlay(self): - selectOverlayLabels = widgets.QDialogListbox( - 'Select segmentation to overlay', - 'Select segmentation file to overlay:\n', - natsorted(self.existingSegmEndNames), - multiSelection=True, - parent=self.host + def editOverlayLabelsAppearance(self, *args): + segmEndname = list(self.overlayLabelsItems.keys())[0] + contoursItem = self.overlayLabelsItems[segmEndname][1] + win = apps.OverlayLabelsAppearanceDialog( + scatterPlotItem=contoursItem, parent=self ) - selectOverlayLabels.exec_() - if selectOverlayLabels.cancel: - return - - return selectOverlayLabels.selectedItemsText - - def showOverlayContextMenu(self, event): - if not self.overlayButton.isChecked(): - return - - self.overlayContextMenu.exec_(QCursor.pos()) - - def showOverlayLabelsContextMenu(self, event): - if not self.overlayLabelsButton.isChecked(): + win.exec_() + if win.cancel: return - self.overlayLabelsContextMenu.exec_(QCursor.pos()) - - def setCheckedOverlayContextMenusActions(self, channelNames): - for action in self.overlayContextMenu.actions(): - if action.text() in channelNames: - action.setChecked(True) - self.checkedOverlayChannels.add(action.text()) + brush = win.properties["brush"] + pen = win.properties["pen"] + for items in self.overlayLabelsItems.values(): + imageItem, contoursItem, gradItem = items + contoursItem.setBrush(brush, update=False) + contoursItem.setPen(pen) def enableOverlayWidgets(self, enabled): posData = self.data[self.pos_i] @@ -1357,14 +751,14 @@ def enableOverlayWidgets(self, enabled): if posData.SizeZ == 1: return - self.zSliceOverlay_SB.setMaximum(posData.SizeZ-1) - if self.zProjOverlay_CB.currentText().find('max') != -1: + self.zSliceOverlay_SB.setMaximum(posData.SizeZ - 1) + if self.zProjOverlay_CB.currentText().find("max") != -1: self.overlay_z_label.setDisabled(True) self.zSliceOverlay_SB.setDisabled(True) else: z = self.zSliceOverlay_SB.sliderPosition() self.overlay_z_label.setText( - f'Overlay z-slice {z+1:02}/{posData.SizeZ}' + f"Overlay z-slice {z + 1:02}/{posData.SizeZ}" ) self.zSliceOverlay_SB.setDisabled(False) self.overlay_z_label.setDisabled(False) @@ -1372,12 +766,8 @@ def enableOverlayWidgets(self, enabled): self.overlay_z_label.show() self.zProjOverlay_CB.show() self.zSliceOverlay_SB.valueChanged.connect(self.updateOverlayZslice) - self.zProjOverlay_CB.currentTextChanged.connect( - self.updateOverlayZproj - ) - self.zProjOverlay_CB.activated.connect( - self.mode_controls_view.clearComboBoxFocus - ) + self.zProjOverlay_CB.currentTextChanged.connect(self.updateOverlayZproj) + self.zProjOverlay_CB.activated.connect(self.clearComboBoxFocus) else: self.zSliceOverlay_SB.setDisabled(True) self.zSliceOverlay_SB.hide() @@ -1393,288 +783,151 @@ def enableOverlayWidgets(self, enabled): self.zProjOverlay_CB.currentTextChanged.disconnect() self.zProjOverlay_CB.activated.disconnect() - def hideOverlayLabelsItems(self, specific=None): - if specific is None: - specific = self.overlayLabelsItems.keys() - for segmEndname in specific: - imageItem, contoursItem, gradItem = self.overlayLabelsItems[ - segmEndname - ] - imageItem.setVisible(False) - contoursItem.setVisible(False) - gradItem.setVisible(False) + def extendLabelsLUT(self, lenNewLut): + self.data[self.pos_i] + # Build a new lut to include IDs > than original len of lut + if lenNewLut > len(self.lut): + numNewColors = lenNewLut - len(self.lut) + # Index original lut + _lut = np.zeros((lenNewLut, 3), np.uint8) + _lut[: len(self.lut)] = self.lut + # Pick random colors and append them at the end to recycle them + randomIdx = np.random.randint(0, len(self.lut), size=numNewColors) + for i, idx in enumerate(randomIdx): + rgb = self.lut[idx] + _lut[len(self.lut) + i] = rgb + self.lut = _lut + self.initLabelsImageItems() + return True + return False - def showOverlayLabelsItems(self, specific=None): - if specific is None: - specific = self.overlayLabelsItems.keys() - for segmEndname in specific: - imageItem, contoursItem, gradItem = self.overlayLabelsItems[ - segmEndname - ] - drawMode = self.drawModeOverlayLabelsChannels[segmEndname] - if drawMode == 'Draw contours': - contoursItem.setVisible(True) - elif drawMode == 'Overlay labels': - imageItem.setVisible(True) - gradItem.setVisible(True) + def extend_labels_lut(self, base_lut: np.ndarray, len_new_lut: int) -> np.ndarray: + """Extends base_lut to include IDs greater than original length of base_lut.""" + import numpy as np - def setOverlayLabelsItems(self, specific=None): - if not self.overlayLabelsButton.isChecked(): - self.hideOverlayLabelsItems(specific=specific) - return + if len_new_lut <= len(base_lut): + return base_lut - if specific is None: - specific = self.drawModeOverlayLabelsChannels.keys() + num_new_colors = len_new_lut - len(base_lut) + _lut = np.zeros((len_new_lut, 3), np.uint8) + _lut[: len(base_lut)] = base_lut - for segmEndname in specific: - drawMode = self.drawModeOverlayLabelsChannels[segmEndname] - ol_lab = self.getOverlayLabelsData(segmEndname) - items = self.overlayLabelsItems[segmEndname] - imageItem, contoursItem, gradItem = items - contoursItem.clear() - if drawMode == 'Draw contours': - for obj in skimage.measure.regionprops(ol_lab): - contours = self.getObjContours( - obj, all_external=True - ) - for cont in contours: - contoursItem.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) - elif drawMode == 'Overlay labels': - imageItem.setImage(ol_lab, autoLevels=False) - self.showOverlayLabelsItems(specific=specific) + random_idx = np.random.randint(0, len(base_lut), size=num_new_colors) + for i, idx in enumerate(random_idx): + rgb = base_lut[idx] + _lut[len(base_lut) + i] = rgb + return _lut - def getOverlayLabelsData(self, segmEndname): - posData = self.data[self.pos_i] + def generate_labels_image_lut(self, base_lut: np.ndarray) -> np.ndarray: + """Converts a 3-channel base LUT to a 4-channel RGBA LUT with background as transparent.""" + import numpy as np - if posData.ol_labels_data is None: - self.loadOverlayLabelsData(segmEndname) - elif segmEndname not in posData.ol_labels_data: - self.loadOverlayLabelsData(segmEndname) + lut = np.zeros((len(base_lut), 4), dtype=np.uint8) + lut[:, -1] = 255 + lut[:, :-1] = base_lut + lut[0] = [0, 0, 0, 0] + return lut - comb_seg = False - if 'combined segm.' == segmEndname: - comb_seg = True - if not self.isSegm3D: - zStackImg = self.data[0].SizeZ > 1 - if zStackImg: - selected_z_stack = self.zSliceScrollBar.sliderPosition() - else: - selected_z_stack = 0 - out = posData.ol_labels_data['combined segm.'][ - posData.frame_i - ][selected_z_stack] - return out.astype(np.uint32) + def getLabelsImageLut(self): + lut = np.zeros((len(self.lut), 4), dtype=np.uint8) + lut[:, -1] = 255 + lut[:, :-1] = self.lut + lut[0] = [0, 0, 0, 0] + return lut - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - z = self.zSliceScrollBar.sliderPosition() - ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i][z] - if comb_seg: - ol_lab = ol_lab.astype(np.uint32) - return ol_lab - else: - ol_lab = posData.ol_labels_data[segmEndname][ - posData.frame_i - ].max(axis=0) - if comb_seg: - ol_lab = ol_lab.astype(np.uint32) - return ol_lab - else: - return posData.ol_labels_data[segmEndname][posData.frame_i] + def getNearestLostObjID(self, y, x): + if not self.annotLostObjsToggle.isChecked(): + return - def loadOverlayLabelsData(self, segmEndname, pos_i=None): - if pos_i is None: - pos_i = self.pos_i - posData = self.data[pos_i] + posData = self.data[self.pos_i] + if not posData.lost_IDs: + return - if posData.ol_labels_data is None: - posData.ol_labels_data = {} - if segmEndname == 'combined segm.': - posData.ol_labels_data['combined segm.'] = posData.combine_img_data - return - filePath, filename = self.workspace.path_from_endname( - segmEndname, posData.images_path - ) - self.logger.info(f'Loading "{segmEndname}.npz"...') - labelsData = np.load(filePath)['arr_0'] - if posData.SizeT == 1: - labelsData = labelsData[np.newaxis] - if self.isSegm3D and labelsData.ndim == 3: - # 2D segm --> stack to 3D - T, Y, X = labelsData.shape - repeat = [labelsData]*posData.SizeZ - labelsData = np.stack(repeat, axis=1) + prev_lab = posData.allData_li[posData.frame_i - 1]["labels"] + if prev_lab is None: + return - posData.ol_labels_data[segmEndname] = labelsData + # if not hasattr(self, 'lostObjContoursImage'): + # self.store_data() + # posData.frame_i -= 1 + # self.get_data() + # self.store_data() + # posData.frame_i += 1 + # self.get_data() + # self.updateLostNewCurrentIDs() + # self.updateLostContoursImage(ax=0) + # self.updateLostContoursImage(ax=1) + # self.updateLostNewCurrentIDs() - def removeOverlayItems(self): - self.lutItemsLayout.clear() + yy, xx, _ = np.nonzero(self.lostObjContoursImage) + lostObjsContourMask = np.zeros(self.currentLab2D.shape, dtype=bool) + lostObjsContourMask[yy.astype(int), xx.astype(int)] = True + # Add accepted lost IDs try: - for toolbutton in self.allOverlayToolbuttonsByIdx.values(): - self.overlayToolbar.removeAction(toolbutton.action) - - self.overlayToolbuttonsSep.removeFromToolbar() - except Exception as err: + yy, xx, _ = np.nonzero(self.lostTrackedObjContoursImage) + lostObjsContourMask[yy.astype(int), xx.astype(int)] = True + except Exception: pass - def clearOverlayImageItems(self): - for items in self.overlayLayersItems.values(): - imageItem = items[0] - imageItem.clear() + _, y_nearest, x_nearest = core.nearest_nonzero_2D( + lostObjsContourMask, y, x, return_coords=True + ) + nearest_ID = self.get_2Dlab(prev_lab)[y_nearest, x_nearest] - self.rgbaImg1.clear() + if nearest_ID == 0: + return - def setOverlayColors(self): - self.overlayRGBs = [ - (255, 255, 0), - (252, 72, 254), - (49, 222, 134), - (22, 108, 27) - ] - self.overlayCmap = matplotlib.colormaps['hsv'] - self.overlayRGBs.extend( - [tuple([round(c*255) for c in self.overlayCmap(i)][:3]) - for i in np.linspace(0,1,8)] - ) + return nearest_ID - def loadOverlayData(self, ol_channels, addToExisting=False): + def getObjContours( + self, + obj, + all_external=False, + local=False, + force_calc=True, + include_internal=False, + ): posData = self.data[self.pos_i] - for ol_ch in ol_channels: - if ol_ch not in list(posData.loadedFluoChannels): - # Requested channel was never loaded --> load it at first - # iter i == 0 - success = self.loadFluo_cb(fluo_channels=[ol_ch]) - if not success: - return False - - lastChannelName = ol_channels[-1] - for action in self.fluoDataChNameActions: - if action.text() == lastChannelName: - action.setChecked(True) - - for p, posData in enumerate(self.data): - if addToExisting: - ol_data = posData.ol_data - else: - ol_data = {} - for i, ol_ch in enumerate(ol_channels): - _, filename = self.getPathFromChName(ol_ch, posData) - ol_data[filename] = ( - posData.ol_data_dict[filename].copy() - ) - self.addFluoChNameContextMenuAction(ol_ch) - posData.ol_data = ol_data - - return True - - def askSelectOverlayChannel(self): - ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] - selectFluo = widgets.QDialogListbox( - 'Select channel', - 'Select channel names to overlay:\n', - ch_names, multiSelection=True, parent=self.host - ) - selectFluo.exec_() - if selectFluo.cancel: - return - - return selectFluo.selectedItemsText - - def setOverlaySingleChannel(self, *args, **kwargs): - if self.overlayToolbar.isSingleChannel(): - self.overlayToolbarAreChannelsChecked = { - channel:toolbutton.isChecked() - for channel, toolbutton in self.allOverlayToolbuttons.items() - } - firstActiveToolbutton = [ - toolbutton for toolbutton in self.allOverlayToolbuttons.values() - if toolbutton.isChecked() - ][0] - firstActiveToolbutton.click() - else: - for ch, checked in self.overlayToolbarAreChannelsChecked.items(): - toolbutton = self.allOverlayToolbuttons[ch] - toolbutton.setChecked(checked) - - self.setOverlayItemsOpacities() - - def updateTransparentOverlayRgba(self, *args, **kwargs): - self.setOverlayImages() - - def setOverlayTransparency(self, transparent: bool): - opacity = float(transparent) - opacity = opacity if opacity < 1.0 else 0.999 - self.rgbaImg1.setOpacity(opacity) + dataDict = posData.allData_li[posData.frame_i] + allContours = dataDict.get("contours") + if allContours is not None and not force_calc: + z = self.z_lab() + key = (obj.label, str(z), all_external, local) + contours = allContours.get(key) + if contours is not None: + return contours - if transparent: - self.img1.setOpacity(0.001, applyToLinked=False) - self.imgGrad.sigLookupTableChanged.connect( - self.updateTransparentOverlayRgba + obj_image = self.getObjImage(obj.image, obj.bbox).astype(np.uint8) + obj_bbox = self.getObjBbox(obj.bbox) + try: + contours = core.get_obj_contours( + obj_image=obj_image, + obj_bbox=obj_bbox, + local=local, + all_external=all_external, ) - self.imgGrad.sigLevelsChanged.connect( - self.updateTransparentOverlayRgba + except Exception: + if all_external: + contours = [] + else: + contours = None + self.logger.warning( + f"Object ID {obj.label} contours drawing failed. " + f"(bounding box = {obj.bbox})" ) + return contours - for channel, items in self.overlayLayersItems.items(): - imageItem, lutItem, alphaSB = items[:3] - if transparent: - alphaSB.valueChanged.disconnect() - alphaSB.valueChanged.connect( - self.updateTransparentOverlayRgba - ) - lutItem.sigLookupTableChanged.connect( - self.updateTransparentOverlayRgba - ) - lutItem.sigLevelsChanged.connect( - self.updateTransparentOverlayRgba - ) - imageItem.setOpacity(0) - - if not transparent: - self.setOverlayItemsOpacities() - - self.setOverlayImages() - - def overlay_cb(self, checked): - self.overlayToolbar.setVisible(checked) - - self.UserNormAction, _, _ = self.getCheckNormAction() + def getObjFromID(self, ID): posData = self.data[self.pos_i] - if checked: - if posData.ol_data is None: - selectedChannels = self.askSelectOverlayChannel() - if selectedChannels is None: - self.overlayButton.toggled.disconnect() - self.overlayButton.setChecked(False) - self.overlayButton.toggled.connect(self.overlay_cb) - return - - success = self.loadOverlayData(selectedChannels) - if not success: - return False - lastChannel = selectedChannels[-1] - self.setCheckedOverlayContextMenusActions(selectedChannels) - imageItem = self.overlayLayersItems[lastChannel][0] - self.setOpacityOverlayLayersItems(None, imageItem=imageItem) - self.setOverlayChannelsToolbuttonsChecked() - - self.setRetainSizePolicyLutItems() - self.normalizeRescale0to1Action.setChecked(True) - - self.updateAllImages() - self.updateImageValueFormatter() - self.enableOverlayWidgets(True) - else: - self.img1.setOpacity(1.0) - self.updateAllImages() - self.updateImageValueFormatter() - self.enableOverlayWidgets(False) - self.clearOverlayImageItems() + try: + idx = posData.IDs_idxs[ID] + except KeyError: + # Object already cleared + return - self.setOverlayItemsVisible() + obj = posData.rp[idx] + return obj def getOlImg(self, key, frame_i=None): posData = self.data[self.pos_i] @@ -1685,7 +938,7 @@ def getOlImg(self, key, frame_i=None): if posData.SizeZ > 1: zProjHow = self.zProjOverlay_CB.currentText() z = self.zSliceOverlay_SB.sliderPosition() - if zProjHow == 'same as above': + if zProjHow == "same as above": zProjHow = self.zProjComboBox.currentText() z = self.zSliceScrollBar.sliderPosition() reconnect = False @@ -1696,897 +949,1381 @@ def getOlImg(self, key, frame_i=None): pass self.zSliceOverlay_SB.setSliderPosition(z) if reconnect: - self.zSliceOverlay_SB.valueChanged.connect( - self.updateOverlayZslice - ) - if zProjHow == 'single z-slice': + self.zSliceOverlay_SB.valueChanged.connect(self.updateOverlayZslice) + if zProjHow == "single z-slice": self.overlay_z_label.setText( - f'Overlay z-slice {z+1:02}/{posData.SizeZ}' + f"Overlay z-slice {z + 1:02}/{posData.SizeZ}" ) ol_img = img[z].copy() - elif zProjHow == 'max z-projection': + elif zProjHow == "max z-projection": ol_img = img.max(axis=0) - elif zProjHow == 'mean z-projection': + elif zProjHow == "mean z-projection": ol_img = img.mean(axis=0) - elif zProjHow == 'median z-proj.': + elif zProjHow == "median z-proj.": ol_img = np.median(img, axis=0) else: ol_img = img.copy() return ol_img - def setOverlayImages(self, frame_i=None): - if not self.overlayButton.isChecked(): - return - - posData = self.data[self.pos_i] - if posData.ol_data is None: - return - - rgba_imgs_info = {} - for filename in posData.ol_data: - chName = self.formatting.channel_name_from_basename( - filename, posData.basename, remove_ext=False - ) - if chName not in self.checkedOverlayChannels: + def getOpacitiesFromAlphaScrollbarValues(self): + alpha_values = [] + activeOverlayImageItems = [] + for items in self.overlayLayersItems.values(): + imgItem, lutItem, alphaSB = items[:3] + _toolbutton = alphaSB.toolbutton + if not _toolbutton.isChecked() or not _toolbutton.isVisible(): continue - items = self.overlayLayersItems[chName] - imageItem, lutItem, alphaSB = items[:3] - - ol_img = self.getOlImg(filename, frame_i=frame_i) + alpha_values.append(alphaSB.value() / alphaSB.maximum()) + activeOverlayImageItems.append(imgItem) - if self.overlayToolbar.isTransparent(): - toolbutton = items[3] - if not toolbutton.isChecked(): - continue - alpha_val = alphaSB.value()/alphaSB.maximum() - ol_img = skimage.exposure.rescale_intensity( - ol_img, out_range=(0.0, 1.0) - ) - out_range_min, out_range_max = lutItem.getLevels() - rgba_imgs_info[chName] = (ol_img, alpha_val, lutItem) - else: - self.rescaleIntensitiesLut(setImage=False, imageItem=imageItem) - imageItem.setImage(ol_img) + opacities = colors.hierarchical_weights(alpha_values)[::-1] + channel_opacity_mapper = {} + for i, imgItem in enumerate(activeOverlayImageItems): + channel_opacity_mapper[imgItem.channelName] = opacities[i + 1] - if not self.overlayToolbar.isTransparent(): - return + channel_opacity_mapper[self.user_ch_name] = opacities[0] - alpha_values = [] - images = [] - luts = [] - for channel, info in rgba_imgs_info.items(): - ol_img, alpha_val, lutItem = info - alpha_values.append(alpha_val) - images.append(ol_img) - luts.append(lutItem.gradient.getLookupTable(256, alpha=255)/255) + return channel_opacity_mapper - weights = colors.hierarchical_weights(alpha_values) + def getOverlayItems(self, channelName, index): + imageItem = widgets.OverlayImageItem() + imageItem.setOpacity(0.5) + imageItem.channelName = channelName - if self.baseLayerToolbutton.isChecked(): - image1 = self._getImageupdateAllImages() - image1 = skimage.exposure.rescale_intensity( - image1, out_range=(0.0, 1.0) - ) - images.append(image1) - baseLut = ( - self.imgGrad.gradient.getLookupTable(256, alpha=255)/255 - ) - luts.append(baseLut) + lutItem = widgets.myHistogramLUTitem( + parent=self, name="image", axisLabel=channelName + ) + imageItem.lutItem = lutItem + for action in lutItem.rescaleActionGroup.actions(): + if action.text() == self.defaultRescaleIntensHow: + action.setChecked(True) + break - images_rgba = [] - for img, lut in zip(images, luts): - rgba = colors.grayscale_apply_lut(img, lut) - images_rgba.append(rgba) + lutItem.removeAddScaleBarAction() + lutItem.removeAddTimestampAction() + lutItem.restoreState(self.df_settings) + lutItem.setImageItem(imageItem) + lutItem.vb.raiseContextMenu = lambda x: None + initColor = self.overlayColors[channelName] + self.initColormapOverlayLayerItem(initColor, lutItem) + lutItem.addOverlayColorButton(initColor, channelName) + lutItem.initColor = initColor + lutItem.hide() - rgba_merge = colors.hierarchical_blend(images_rgba, weights) - self.rgbaImg1.setImage(rgba_merge) + lutItem.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) + lutItem.overlayColorButton.sigColorChanged.connect(self.saveOverlayColor) - def getOpacitiesFromAlphaScrollbarValues(self): - active_channel_alpha_values = {} - for items in self.overlayLayersItems.values(): - imgItem, lutItem, alphaSB = items[:3] - _toolbutton = alphaSB.toolbutton - if not _toolbutton.isChecked() or not _toolbutton.isVisible(): - continue + lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW) - active_channel_alpha_values[imgItem.channelName] = ( - alphaSB.value()/alphaSB.maximum() - ) + lutItem.contoursColorButton.disconnect() + lutItem.contoursColorButton.clicked.connect( + self.imgGrad.contoursColorButton.click + ) + for act in lutItem.contLineWightActionGroup.actions(): + act.toggled.connect(self.contLineWeightToggled) - return self.overlay_channel_opacity_map( - self.user_ch_name, - active_channel_alpha_values, + lutItem.mothBudLineColorButton.disconnect() + lutItem.mothBudLineColorButton.clicked.connect( + self.imgGrad.mothBudLineColorButton.click ) + for act in lutItem.mothBudLineWightActionGroup.actions(): + act.toggled.connect(self.mothBudLineWeightToggled) - def toggleOverlayColorButton(self, checked=True): - self.mousePressColorButton(None) + lutItem.textColorButton.disconnect() + lutItem.textColorButton.clicked.connect(self.editTextIDsColorAction.trigger) - def toggleTextIDsColorButton(self, checked=True): - self.textIDsColorButton.selectColor() + lutItem.defaultSettingsAction.triggered.connect(self.restoreDefaultSettings) + lutItem.labelsAlphaSlider.valueChanged.connect(self.setValueLabelsAlphaSlider) + lutItem.sigRescaleIntes.connect( + partial(self.rescaleIntensitiesLut, imageItem=imageItem) + ) + if f"how_rescale_intensities_{channelName}" in self.df_settings.index: + how = self.df_settings.at[f"how_rescale_intensities_{channelName}", "value"] + lutItem.setRescaleIntensitiesHow(how) - def updateTextAnnotColor(self, button): - r, g, b = np.array(self.textIDsColorButton.color().getRgb()[:3]) - self.imgGrad.textColorButton.setColor((r, g, b)) - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.textColorButton.setColor((r, g, b)) - self.gui_createTextAnnotColors(r,g,b, custom=True) - self.gui_setTextAnnotColors() - self.updateAllImages() + self.rescaleIntensChannelHowMapper[channelName] = "Rescale each 2D image" - def saveTextIDsColors(self, button): - self.df_settings.at['textIDsColor', 'value'] = self.objLabelAnnotRgb - self.df_settings.to_csv(self.settings_csv_path) + self.addActionsLutItemContextMenu(lutItem) - def ticksCmapMoved(self, gradient): - pass - # posData = self.data[self.pos_i] - # self.setLut(posData, shuffle=False) - # self.updateLookuptable() - - def updateLabelsCmap(self, gradient): - self.setLut() - self.updateLookuptable() - self.initLabelsImageItems() - - self.df_settings = self.labelsGrad.saveState(self.df_settings) - self.df_settings.to_csv(self.settings_csv_path) - - self.updateAllImages() - - def extendLabelsLUT(self, lenNewLut): - if lenNewLut > len(self.lut): - self.lut = self.extend_labels_lut(self.lut, lenNewLut) - self.initLabelsImageItems() - return True - return False - - def initLookupTableLab(self): - self.img2.setLookupTable(self.lut) - self.img2.setLevels([0, len(self.lut)]) - self.initLabelsImageItems() - - def getLabelsImageLut(self): - return self.generate_labels_image_lut(self.lut) - - def initLabelsImageItems(self): - lut = self.getLabelsImageLut() - self.labelsLayerImg1.setLevels([0, len(lut)]) - self.labelsLayerRightImg.setLevels([0, len(lut)]) - self.labelsLayerImg1.setLookupTable(lut) - self.labelsLayerRightImg.setLookupTable(lut) - alpha = self.imgGrad.labelsAlphaSlider.value() - self.labelsLayerImg1.setOpacity(alpha) - self.labelsLayerRightImg.setOpacity(alpha) - - def updateLookuptable(self, lenNewLut=None, delIDs=None): - posData = self.data[self.pos_i] - if lenNewLut is None: - try: - if delIDs is None: - IDs = posData.IDs - else: - # Remove IDs removed with ROI from LUT - IDs = [ID for ID in posData.IDs if ID not in delIDs] - lenNewLut = max(IDs, default=0) + 1 - except ValueError: - # Empty segmentation mask - lenNewLut = 1 - # Build a new lut to include IDs > than original len of lut - updateLevels = self.extendLabelsLUT(lenNewLut) - lut = self.lut.copy() - - try: - # lut = self.lut[:lenNewLut].copy() - for ID in posData.binnedIDs: - lut[ID] = lut[ID]*0.2 - - for ID in posData.ripIDs: - lut[ID] = lut[ID]*0.2 - except Exception as e: - err_str = traceback.format_exc() - print('='*30) - self.logger.info(err_str) - print('='*30) - - if updateLevels: - self.img2.setLevels([0, len(lut)]) + alphaScrollBar = self.addAlphaScrollbar(channelName, imageItem) - lut = self.apply_lut_dimming_for_kept_objects( - lut, - getattr(self, 'keptObjectsIDs', []), - self.keepIDsButton.isChecked(), + toolbutton = widgets.OverlayChannelToolButton( + channelName, lutItem, shortcut=str(index) ) + toolbutton.action = self.overlayToolbar.addWidget(toolbutton) + toolbutton.setVisible(False) - self.img2.setLookupTable(lut) - - def setLut(self, shuffle=True): - self.lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) - if shuffle: - np.random.shuffle(self.lut) - - # Insert background color - if 'labels_bkgrColor' in self.df_settings.index: - rgbString = self.df_settings.at['labels_bkgrColor', 'value'] - try: - r, g, b = rgbString - except Exception as e: - r, g, b = colors.rgb_str_to_values(rgbString) - else: - r, g, b = 25, 25, 25 - self.df_settings.at['labels_bkgrColor', 'value'] = (r, g, b) - - self.lut = np.insert(self.lut, 0, [r, g, b], axis=0) - - def shuffle_cmap(self): - np.random.shuffle(self.lut[1:]) - self.initLabelsImageItems() - self.updateAllImages() + toolbutton.clicked.connect(self.overlayChannelToolbuttonClicked) - def setPermanentGreedyCmapPreferences(self): - if self.isSnapshot: - option_name = 'permanent_greedy_lut_snapshots' - else: - option_name = 'permanent_greedy_lut_timelapse' + alphaScrollBar.toolbutton = toolbutton - if option_name not in self.df_settings.index: - return + return imageItem, lutItem, alphaScrollBar, toolbutton - checked = self.df_settings.at[option_name, 'value'] == 'yes' - self.labelsGrad.permanentGreedyCmapAction.setChecked(checked) + def getOverlayLabelsData(self, segmEndname): + posData = self.data[self.pos_i] - def permanentGreedyCmapToggled(self, checked): - if checked: - settings_value = 'yes' - else: - self.setLut() - self.updateLookuptable() - self.initLabelsImageItems() - settings_value = 'no' + if posData.ol_labels_data is None: + self.loadOverlayLabelsData(segmEndname) + elif segmEndname not in posData.ol_labels_data: + self.loadOverlayLabelsData(segmEndname) - self.updateAllImages() + comb_seg = False + if "combined segm." == segmEndname: + comb_seg = True + if not self.isSegm3D: + zStackImg = self.data[0].SizeZ > 1 + if zStackImg: + selected_z_stack = self.zSliceScrollBar.sliderPosition() + else: + selected_z_stack = 0 + out = posData.ol_labels_data["combined segm."][posData.frame_i][ + selected_z_stack + ] + return out.astype(np.uint32) - if self.isSnapshot: - option_name = 'permanent_greedy_lut_snapshots' + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == "single z-slice" + if isZslice: + z = self.zSliceScrollBar.sliderPosition() + ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i][z] + if comb_seg: + ol_lab = ol_lab.astype(np.uint32) + return ol_lab + else: + ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i].max( + axis=0 + ) + if comb_seg: + ol_lab = ol_lab.astype(np.uint32) + return ol_lab else: - option_name = 'permanent_greedy_lut_timelapse' - - self.df_settings.at[option_name, 'value'] = settings_value - self.df_settings.to_csv(self.settings_csv_path) + return posData.ol_labels_data[segmEndname][posData.frame_i] def greedyShuffleCmap(self, updateImages=True): - lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) + lut = self.labelsGrad.item.colorMap().getLookupTable(0, 1, 255) greedy_lut = colors.get_greedy_lut(self.currentLab2D, lut) self.lut = greedy_lut self.initLabelsImageItems() if updateImages: self.updateAllImages() - def updateBkgrColor(self, button): - color = button.color().getRgb()[:3] - self.lut[0] = color - self.updateLookuptable() - - def updateTextLabelsColor(self, button): - self.ax2_textColor = button.color().getRgb()[:3] - posData = self.data[self.pos_i] - if posData.rp is None: - return - - for obj in posData.rp: - self.getObjOptsSegmLabels(obj) - - def saveTextLabelsColor(self, button): - color = button.color().getRgb()[:3] - self.df_settings.at['labels_text_color', 'value'] = color - self.df_settings.to_csv(self.settings_csv_path) + def gui_addGraphicsItems(self): + # Auto image adjustment button + proxy = QGraphicsProxyWidget() + equalizeHistPushButton = QPushButton("Enhance contrast") + widthHint = equalizeHistPushButton.sizeHint().width() + equalizeHistPushButton.setMaximumWidth(widthHint) + equalizeHistPushButton.setCheckable(True) + if not self.invertBwAction.isChecked(): + equalizeHistPushButton.setStyleSheet( + "QPushButton {background-color: #282828; color: #F0F0F0;}" + ) + self.equalizeHistPushButton = equalizeHistPushButton + proxy.setWidget(equalizeHistPushButton) + self.graphLayout.addItem(proxy, row=0, col=0) + self.equalizeHistPushButton = equalizeHistPushButton - def saveBkgrColor(self, button): - color = button.color().getRgb()[:3] - self.df_settings.at['labels_bkgrColor', 'value'] = color - self.df_settings.to_csv(self.settings_csv_path) - self.updateAllImages() + # Left image histogram + self.imgGrad = widgets.myHistogramLUTitem(parent=self, name="image") + self.imgGrad.restoreState(self.df_settings) + self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) + for action in self.imgGrad.rescaleActionGroup.actions(): + if action.text() == self.defaultRescaleIntensHow: + action.setChecked(True) + self.rescaleIntensMenu.addAction(action) - def changeOverlayColor(self, button): - rgb = button.color().getRgb()[:3] - lutItem = self.overlayLayersItems[button.channel][1] - self.initColormapOverlayLayerItem(rgb, lutItem) - lutItem.overlayColorButton.setColor(rgb) + # Colormap gradient widget + self.labelsGrad = widgets.labelsGradientWidget(parent=self) + try: + self.labelsGrad.restoreState(self.df_settings) + except Exception: + self.logger.exception(traceback.format_exc()) + print("======================================") + self.logger.info( + "Failed to restore previously used colormap. " + 'Using default colormap "viridis"' + ) + self.labelsGrad.item.loadPreset("viridis") - def saveOverlayColor(self, button): - rgb = button.color().getRgb()[:3] - rgb_text = '_'.join([str(val) for val in rgb]) - self.df_settings.at[f'{button.channel}_rgb', 'value'] = rgb_text - self.df_settings.to_csv(self.settings_csv_path) + # Add actions to imgGrad gradient item + self.imgGrad.gradient.menu.addAction(self.labelsGrad.showLabelsImgAction) + self.imgGrad.gradient.menu.addAction(self.labelsGrad.showRightImgAction) + self.imgGrad.gradient.menu.addAction(self.labelsGrad.showNextFrameAction) - def setValueLabelsAlphaSlider(self, value): - self.imgGrad.labelsAlphaSlider.setValue(value) - self.updateLabelsAlpha(value) + self.imgGrad.gradient.menu.addSeparator() - def setOverlaySegmMasks(self, force=False, forceIfNotActive=False): - if not hasattr(self, 'currentLab2D'): - return + self.imgGrad.gradient.menu.addMenu(self.exportMenu) - how = self.drawIDsContComboBox.currentText() - isOverlaySegmLeftActive = how.find('overlay segm. masks') != -1 + # Add actions to view menu + self.viewMenu.addAction(self.labelsGrad.showLabelsImgAction) + self.viewMenu.addAction(self.labelsGrad.showRightImgAction) - how_ax2 = self.getAnnotateHowRightImage() - isOverlaySegmRightActive = ( - how_ax2.find('overlay segm. masks') != -1 - and self.labelsGrad.showRightImgAction.isChecked() + # Right image histogram + self.imgGradRight = widgets.baseHistogramLUTitem( + name="image", parent=self, gradientPosition="left" ) + self.imgGradRight.gradient.menu.addAction(self.labelsGrad.showLabelsImgAction) + self.imgGradRight.gradient.menu.addAction(self.labelsGrad.showRightImgAction) + self.imgGradRight.gradient.menu.addAction(self.labelsGrad.showNextFrameAction) - isOverlaySegmActive = ( - isOverlaySegmLeftActive or isOverlaySegmRightActive - or force + self.imgGrad.setChildLutItem(self.imgGradRight) + + # Title + self.titleLabel = pg.LabelItem( + justify="center", color=self.titleColor, size="14pt" ) - if not isOverlaySegmActive and not forceIfNotActive: - return + self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) - alpha = self.imgGrad.labelsAlphaSlider.value() - if alpha == 0: - return + def gui_addOverlayLayerItems(self): + for items in self.overlayLabelsItems.values(): + imageItem, contoursItem, gradItem = items + self.ax1.addItem(imageItem) + self.ax1.addItem(contoursItem) - posData = self.data[self.pos_i] - maxID = max(posData.IDs, default=0) + def gui_addTopLayerItems(self): + for item in self.topLayerItems: + self.ax1.addItem(item) - if maxID >= len(self.lut): - self.extendLabelsLUT(maxID+10) + for item in self.topLayerItemsRight: + self.ax2.addItem(item) - currentLab2D = self.currentLab2D - if isOverlaySegmLeftActive: - self.labelsLayerImg1.setImage(currentLab2D, autoLevels=False) + def gui_connectGraphicsEvents(self): + self.img1.hoverEvent = self.gui_hoverEventImg1 + self.img2.hoverEvent = self.gui_hoverEventImg2 + self.img1.mousePressEvent = self.gui_mousePressEventImg1 + self.img1.mouseMoveEvent = self.gui_mouseDragEventImg1 + self.img1.mouseReleaseEvent = self.gui_mouseReleaseEventImg1 + self.img2.mousePressEvent = self.gui_mousePressEventImg2 + self.img2.mouseMoveEvent = self.gui_mouseDragEventImg2 + self.img2.mouseReleaseEvent = self.gui_mouseReleaseEventImg2 + self.rightImageItem.mousePressEvent = self.gui_mousePressRightImage + self.rightImageItem.mouseMoveEvent = self.gui_mouseDragRightImage + self.rightImageItem.mouseReleaseEvent = self.gui_mouseReleaseRightImage + self.rightImageItem.hoverEvent = self.gui_hoverEventRightImage + # self.imgGrad.gradient.showMenu = self.gui_gradientContextMenuEvent + self.imgGradRight.gradient.showMenu = self.gui_rightImageShowContextMenu + # self.imgGrad.vb.contextMenuEvent = self.gui_gradientContextMenuEvent + self.ax1.sigRangeChanged.connect(self.viewRangeChanged) - if isOverlaySegmRightActive: - self.labelsLayerRightImg.setImage(currentLab2D, autoLevels=False) + def gui_createContourPens(self): + if "contLineWeight" in self.df_settings.index: + val = self.df_settings.at["contLineWeight", "value"] + self.contLineWeight = int(val) + else: + self.contLineWeight = 1 + if "contLineColor" in self.df_settings.index: + val = self.df_settings.at["contLineColor", "value"] + rgba = colors.rgba_str_to_values(val) + self.contLineColor = rgba + self.newIDlineColor = [min(255, v + 50) for v in self.contLineColor] + else: + self.contLineColor = (255, 0, 0, 200) + self.newIDlineColor = (255, 0, 0, 255) - def setOverlayLabelsItemsVisible(self, checked): - for _segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): - items = self.overlayLabelsItems[_segmEndname] - gradItem = items[-1] - gradItem.hide() - - if checked: - segmEndname = self.sender().text() - gradItem = self.overlayLabelsItems[segmEndname][-1] - gradItem.show() + try: + self.imgGrad.contoursColorButton.sigColorChanging.disconnect() + self.imgGrad.contoursColorButton.sigColorChanged.disconnect() + except Exception: + pass + try: + for act in self.imgGrad.contLineWightActionGroup.actions(): + act.toggled.disconnect() + except Exception: + pass + for act in self.imgGrad.contLineWightActionGroup.actions(): + if act.lineWeight == self.contLineWeight: + act.setChecked(True) + self.imgGrad.contoursColorButton.setColor(self.contLineColor[:3]) - def setRetainSizePolicyLutItems(self): - if not self.retainSizeLutItems: - return - for channel, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB = items[:3] - myutils.setRetainSizePolicy(lutItem, retain=True) - QTimer.singleShot(300, self.autoRange) + self.imgGrad.contoursColorButton.sigColorChanging.connect(self.updateContColour) + self.imgGrad.contoursColorButton.sigColorChanged.connect(self.saveContColour) + for act in self.imgGrad.contLineWightActionGroup.actions(): + act.toggled.connect(self.contLineWeightToggled) - def setOverlayChannelsToolbuttonsChecked(self): - for channel, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB, toolbutton = items[:4] - toolbutton.setChecked( - self.overlay_toolbutton_checked( - channel, - checked_channels=self.checkedOverlayChannels, - is_single_channel=self.overlayToolbar.isSingleChannel(), - ) - ) + # Contours pens + self.oldIDs_cpen = pg.mkPen(color=self.contLineColor, width=self.contLineWeight) + self.newIDs_cpen = pg.mkPen( + color=self.newIDlineColor, width=self.contLineWeight + 1 + ) + self.tempNewIDs_cpen = pg.mkPen(color="g", width=self.contLineWeight + 1) - def setOverlayItemsVisible(self): - visibility_plan = self.overlay_visibility_plan( - all_channels=self.overlayLayersItems.keys(), - checked_channels=self.checkedOverlayChannels, - overlay_enabled=self.overlayButton.isChecked(), + def gui_createGraphicsItems(self): + # Create enough PlotDataItems and LabelItems to draw contours and IDs. + self.progressWin = apps.QDialogWorkerProgress( + title="Creating axes items", + parent=self, + pbarDesc="Creating axes items (see progress in the terminal)...", ) - for channel, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB, toolbutton = items[:4] - if visibility_plan.channel_visible[channel]: - lutItem.show() - alphaSB.show() - alphaSB.label.show() - toolbutton.setVisible(True) - else: - lutItem.hide() - alphaSB.hide() - alphaSB.label.hide() - toolbutton.setVisible(False) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) - def overlayChannelToolbuttonClicked(self, checked=False, toolbutton=None): - if toolbutton is None: - toolbutton = self.sender() + QTimer.singleShot(50, self._gui_createGraphicsItems) - channelName = toolbutton.channelName() - checked_channels = { - channel - for channel, button in self.allOverlayToolbuttons.items() - if button.isChecked() - } - planned_checked_channels = ( - self.overlay_toolbutton_click_checked_channels( - clicked_channel=channelName, - all_channels=self.allOverlayToolbuttons.keys(), - checked_channels=checked_channels, - toolbar_single_channel=self.overlayToolbar.isSingleChannel(), - ) + def gui_createLabelRoiItem(self): + Y, X = self.currentLab2D.shape + # Label ROI rectangle + pen = pg.mkPen("r", width=3) + self.labelRoiItem = widgets.ROI( + (0, 0), + (0, 0), + maxBounds=QRectF(QRect(0, 0, X, Y)), + scaleSnap=True, + translateSnap=True, + pen=pen, + hoverPen=pen, ) - for channel, otherToolbutton in self.allOverlayToolbuttons.items(): - otherToolbutton.setChecked(channel in planned_checked_channels) - if self.overlayToolbar.isTransparent(): - self.setOverlayImages() - return + posData = self.data[self.pos_i] + if self.labelRoiZdepthSpinbox.value() == 0: + self.labelRoiZdepthSpinbox.setValue(posData.SizeZ) + self.labelRoiZdepthSpinbox.setMaximum(posData.SizeZ + 1) - self.setOverlayItemsOpacities() + def gui_createMothBudLinePens(self): + if "mothBudLineSize" in self.df_settings.index: + val = self.df_settings.at["mothBudLineSize", "value"] + self.mothBudLineWeight = int(val) + else: + self.mothBudLineWeight = 2 - def setOverlayItemsOpacities(self): - checked_channels = { - channel - for channel, button in self.allOverlayToolbuttons.items() - if button.isChecked() - } - active_channel_alpha_values = {} - for items in self.overlayLayersItems.values(): - imageItem, lutItem, alphaSB = items[:3] - toolbutton = alphaSB.toolbutton - if not toolbutton.isChecked() or not toolbutton.isVisible(): - continue - active_channel_alpha_values[imageItem.channelName] = ( - alphaSB.value()/alphaSB.maximum() - ) - opacity_plan = self.overlay_item_opacity_plan( - all_channels=self.allOverlayToolbuttons.keys(), - base_channel=self.user_ch_name, - checked_channels=checked_channels, - toolbar_single_channel=self.overlayToolbar.isSingleChannel(), - active_channel_alpha_values=active_channel_alpha_values, - ) + self.newMothBudlineColor = (255, 0, 0) + if "mothBudLineColor" in self.df_settings.index: + val = self.df_settings.at["mothBudLineColor", "value"] + rgba = colors.rgba_str_to_values(val) + self.mothBudLineColor = rgba[0:3] + else: + self.mothBudLineColor = (255, 165, 0) - # Set opacity of every layer accordingly - for channel, otherToolbutton in self.allOverlayToolbuttons.items(): - if channel == self.user_ch_name: - otherImageItem = self.img1 - alphaScrollbar = None - # alpha_value = channel_opacity_mapper[channel] + try: + self.imgGrad.mothBudLineColorButton.sigColorChanging.disconnect() + self.imgGrad.mothBudLineColorButton.sigColorChanged.disconnect() + except Exception: + pass + try: + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): + act.toggled.disconnect() + except Exception: + pass + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): + if act.lineWeight == self.mothBudLineWeight: + act.setChecked(True) else: - otherItems = self.overlayLayersItems[channel] - otherImageItem = otherItems[0] - alphaScrollbar = otherItems[2] - # alpha_value = alphaScrollbar.value()/alphaScrollbar.maximum() + act.setChecked(False) + self.imgGrad.mothBudLineColorButton.setColor(self.mothBudLineColor[:3]) - op_val = opacity_plan.opacities[channel] - otherImageItem.setOpacity(op_val, applyToLinked=False) + self.imgGrad.mothBudLineColorButton.sigColorChanging.connect( + self.updateMothBudLineColour + ) + self.imgGrad.mothBudLineColorButton.sigColorChanged.connect( + self.saveMothBudLineColour + ) + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): + act.toggled.connect(self.mothBudLineWeightToggled) - if alphaScrollbar is None: - continue + # MOther-bud lines brushes + self.NewBudMoth_Pen = pg.mkPen( + color=self.newMothBudlineColor, + width=self.mothBudLineWeight + 1, + style=Qt.DashLine, + ) + self.OldBudMoth_Pen = pg.mkPen( + color=self.mothBudLineColor, width=self.mothBudLineWeight, style=Qt.DashLine + ) - alphaScrollbar.setDisabled( - opacity_plan.alpha_scrollbar_disabled[channel] - ) + self.redDashLinePen = pg.mkPen(color="r", width=2, style=Qt.DashLine) - def initColormapOverlayLayerItem(self, foregrColor, lutItem): - if self.invertBwAction.isChecked(): - bkgrColor = (255,255,255,255) - else: - bkgrColor = (0,0,0,255) - gradient = colors.get_pg_gradient((bkgrColor, foregrColor)) - lutItem.setGradient(gradient) + self.oldMothBudLineBrush = pg.mkBrush(self.mothBudLineColor) + self.newMothBudLineBrush = pg.mkBrush(self.newMothBudlineColor) - def setOpacityOverlayLayersItems(self, value, imageItem=None, scrollbar=None): - if scrollbar is None: - scrollbar = imageItem.alphaScrollBar + def gui_createOverlayColors(self): + fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] + self.logger.info(f"Number of TIFF files detected: {len(fluoChannels)}") + self.overlayColors = {} + for c, ch in enumerate(fluoChannels): + if f"{ch}_rgb" in self.df_settings.index: + rgb_text = self.df_settings.at[f"{ch}_rgb", "value"] + rgb = tuple([int(val) for val in rgb_text.split("_")]) + self.overlayColors[ch] = rgb + else: + if c >= len(self.overlayRGBs) - 1: + i = c / len(fluoChannels) + additional_color_num = c - len(self.overlayRGBs) + 1 + rgbs = [ + tuple([round(c * 255) for c in self.overlayCmap(i)][:3]) + for _ in range(additional_color_num) + ] + self.overlayRGBs.extend(rgbs) + rgb = colors.FLUO_CHANNELS_COLORS.get(ch, self.overlayRGBs[c]) + self.overlayColors[ch] = rgb - channel = scrollbar.channelName - toolbutton = self.allOverlayToolbuttons[channel] - if not toolbutton.isChecked() or not toolbutton.isVisible(): - return + def gui_createOverlayItems(self): + self.imgGrad.setAxisLabel(self.user_ch_name) + self.baseLayerToolbutton = widgets.OverlayChannelToolButton( + self.user_ch_name, self.imgGrad + ) + self.baseLayerToolbutton.setChecked(True) + self.baseLayerToolbutton.clicked.connect(self.overlayChannelToolbuttonClicked) + self.allOverlayToolbuttons = {self.user_ch_name: self.baseLayerToolbutton} + self.allOverlayToolbuttonsByIdx = {0: self.baseLayerToolbutton} + self.baseLayerToolbutton.action = self.overlayToolbar.addWidget( + self.baseLayerToolbutton + ) + self.overlayLayersItems = {} + self.overlayToolbarAreChannelsChecked = {} + fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] + for c, ch in enumerate(fluoChannels): + overlayItems = self.getOverlayItems(ch, c + 1) + self.overlayLayersItems[ch] = overlayItems + imageItem, lutItem = overlayItems[:2] + self.ax1.addItem(imageItem) + self.lutItemsLayout.addItem(lutItem, row=0, col=c + 1) + toolbutton = overlayItems[3] + self.allOverlayToolbuttons[ch] = toolbutton + self.allOverlayToolbuttonsByIdx[c + 1] = toolbutton - if value is None: - value = scrollbar.value() + self.overlayToolbuttonsSep = self.overlayToolbar.addSeparator() + self.plotsCol = len(self.ch_names) - if imageItem is None: - imageItem = scrollbar.imageItem - alpha = value/scrollbar.maximum() - elif value > 1: - alpha = value/scrollbar.maximum() + self.ax1.addImageItem(self.rgbaImg1) + + def gui_createPlotItems(self): + if "textIDsColor" in self.df_settings.index: + rgbString = self.df_settings.at["textIDsColor", "value"] + r, g, b = colors.rgb_str_to_values(rgbString) + self.gui_createTextAnnotColors(r, g, b, custom=True) + self.textIDsColorButton.setColor((r, g, b)) else: - alpha = value + self.gui_createTextAnnotColors(0, 0, 0, custom=False) - alpha_values = [] - activeOverlayImageItems = [] - for items in self.overlayLayersItems.values(): - imgItem, lutItem, alphaSB = items[:3] - _toolbutton = alphaSB.toolbutton - if alphaSB.channelName == channel: - alpha_values.append(alpha) - elif not _toolbutton.isChecked() or not _toolbutton.isVisible(): - continue - else: - alpha_values.append(alphaSB.value()/alphaSB.maximum()) + if "labels_text_color" in self.df_settings.index: + rgbString = self.df_settings.at["labels_text_color", "value"] + r, g, b = colors.rgb_str_to_values(rgbString) + self.ax2_textColor = (r, g, b) + else: + self.ax2_textColor = (255, 0, 0) - activeOverlayImageItems.append(imgItem) + self.emptyLab = np.zeros((2, 2), dtype=np.uint8) - opacities = colors.hierarchical_weights(alpha_values)[::-1] + # Right image item linked to left + self.rightImageItem = widgets.ChildImageItem( + linkedScrollbar=self.rightImageFramesScrollbar + ) + self.imgGradRight.setImageItem(self.rightImageItem) + self.ax2.addItem(self.rightImageItem) - for i, imgItem in enumerate(activeOverlayImageItems): - imgItem.setOpacity(opacities[i+1]) + # Left image + self.img1 = widgets.ParentImageItem( + linkedImageItem=self.rightImageItem, + activatingActions=( + self.labelsGrad.showRightImgAction, + self.labelsGrad.showNextFrameAction, + ), + ) + self.imgGrad.setImageItem(self.img1) + self.img1.lutItem = self.imgGrad + self.imgGrad.sigRescaleIntes.connect(self.rescaleIntensitiesLut) + self.ax1.addBaseImageItem(self.img1) - self.img1.setOpacity(opacities[0], applyToLinked=False) + # RGBA image for true transparency mode + self.rgbaImg1 = pg.ImageItem() - def gui_getLostObjScatterItem(self): - self.objLostAnnotRgb = (245, 184, 0) - brush = pg.mkBrush((*self.objLostAnnotRgb, 150)) - pen = pg.mkPen(self.objLostAnnotRgb, width=1) - lostObjScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight+1, pen=pen, - brush=brush, pxMode=False, symbol='s' - ) - return lostObjScatterItem + # self.rgbaImg1.setImage(self.emptyLab) - def gui_getTrackedLostObjScatterItem(self): - self.objLostTrackedAnnotRgb = (0, 255, 0) - brush = pg.mkBrush((*self.objLostTrackedAnnotRgb, 150)) - pen = pg.mkPen(self.objLostTrackedAnnotRgb, width=1) - lostObjScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight+1, pen=pen, - brush=brush, pxMode=False, symbol='s' - ) - return lostObjScatterItem + # Right image + self.img2 = widgets.labImageItem() + self.ax2.addItem(self.img2) - def _gui_createGraphicsItems(self): - for _posData in self.data: - _posData.allData_li = [None]*_posData.SizeT + self.topLayerItems = [] + self.topLayerItemsRight = [] - posData = self.data[self.pos_i] + self.gui_createContourPens() + self.gui_createMothBudLinePens() - allIDs, posData = self.label_edits.count_objects( - posData, self.logger.info - ) + self.eraserCirclePen = pg.mkPen(width=1.5, color="r") - self.highLowResAction.setChecked(True) - numItems = len(allIDs) - if numItems > 1500: - cancel, switchToLowRes = _warnings.warnTooManyItems( - self.host, numItems, self.progressWin - ) - if cancel: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.loadingDataAborted() - return - if switchToLowRes: - self.highLowResAction.setChecked(False) - else: - # Many items requires pxMode active to be fast enough - self.pxModeAction.setChecked(True) + # Temporary line item connecting bud to new mother + self.BudMothTempLine = pg.PlotDataItem(pen=self.NewBudMoth_Pen) + self.topLayerItems.append(self.BudMothTempLine) - self.logger.info(f'Creating graphical items...') + # Temporary line item connecting objects to merge + self.mergeObjsTempLine = widgets.PlotCurveItem(pen=self.redDashLinePen) + self.topLayerItems.append(self.mergeObjsTempLine) - self.ax1_contoursImageItem = pg.ImageItem() + # Overlay segm. masks item + self.labelsLayerImg1 = widgets.BaseLabelsImageItem() + self.ax1.addItem(self.labelsLayerImg1) - self.ax1_lostObjImageItem = pg.ImageItem() - self.ax2_lostObjImageItem = pg.ImageItem() + self.labelsLayerRightImg = widgets.BaseLabelsImageItem() + self.ax2.addItem(self.labelsLayerRightImg) - self.ax1_lostTrackedObjImageItem = pg.ImageItem() - self.ax2_lostTrackedObjImageItem = pg.ImageItem() + # Red/green border rect item + self.GreenLinePen = pg.mkPen(color="g", width=2) + self.RedLinePen = pg.mkPen(color="r", width=2) + self.ax1BorderLine = pg.PlotDataItem() + self.topLayerItems.append(self.ax1BorderLine) + self.ax2BorderLine = pg.PlotDataItem(pen=pg.mkPen(color="r", width=2)) + self.topLayerItems.append(self.ax2BorderLine) - self.ax1_oldMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, - size=self.mothBudLineWeight, pen=None - ) - self.ax1_newMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.newMothBudLineBrush, - size=self.mothBudLineWeight, pen=None + # Brush/Eraser/Wand.. layer item + self.tempLayerRightImage = pg.ImageItem() + self.tempLayerImg1 = widgets.ParentImageItem( + linkedImageItem=self.tempLayerRightImage, + activatingAction=(self.labelsGrad.showRightImgAction,), ) - self.ax1_lostObjScatterItem = self.gui_getLostObjScatterItem() - self.yellowContourScatterItem = self.gui_getLostObjScatterItem() + self.topLayerItems.append(self.tempLayerImg1) + self.topLayerItemsRight.append(self.tempLayerRightImage) - self.ax1_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() - self.greenContourScatterItem = self.gui_getTrackedLostObjScatterItem() + # Highlighted ID layer items + self.highLightIDLayerImg1 = pg.ImageItem() + self.topLayerItems.append(self.highLightIDLayerImg1) - brush = pg.mkBrush((0,255,0,200)) - pen = pg.mkPen('g', width=1) - self.ccaFailedScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight+1, pen=pen, - brush=brush, pxMode=False, symbol='s' + # Highlighted ID layer items + self.highLightIDLayerRightImage = pg.ImageItem() + self.topLayerItemsRight.append(self.highLightIDLayerRightImage) + + # Keep IDs temp layers + self.keepIDsTempLayerRight = pg.ImageItem() + self.keepIDsTempLayerLeft = widgets.ParentImageItem( + linkedImageItem=self.keepIDsTempLayerRight, + activatingAction=self.labelsGrad.showRightImgAction, ) + self.topLayerItems.append(self.keepIDsTempLayerLeft) + self.topLayerItemsRight.append(self.keepIDsTempLayerRight) - self.ax2_contoursImageItem = pg.ImageItem() - self.ax2_oldMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, - size=self.mothBudLineWeight, pen=None + # Searched ID contour + self.searchedIDitemRight = pg.ScatterPlotItem() + self.searchedIDitemRight.setData( + [], + [], + symbol="s", + pxMode=False, + size=1, + brush=pg.mkBrush(color=(255, 0, 0, 150)), + pen=pg.mkPen(width=2, color="r"), + tip=None, ) - self.ax2_newMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.newMothBudLineBrush, - size=self.mothBudLineWeight, pen=None + self.searchedIDitemLeft = pg.ScatterPlotItem() + self.searchedIDitemLeft.setData( + [], + [], + symbol="s", + pxMode=False, + size=1, + brush=pg.mkBrush(color=(255, 0, 0, 150)), + pen=pg.mkPen(width=2, color="r"), + tip=None, ) - self.ax2_lostObjScatterItem = self.gui_getLostObjScatterItem() - self.ax2_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() - - self.gui_createTextAnnotItems(allIDs) # here - self.gui_setTextAnnotColors()# here - - self.setDisabledAnnotOptions(False) + self.topLayerItems.append(self.searchedIDitemLeft) + self.topLayerItemsRight.append(self.searchedIDitemRight) - self.progressWin.mainPbar.setMaximum(0) - self.gui_addOverlayLayerItems() - self.gui_addTopLayerItems() + # Brush circle img1 + self.ax1_BrushCircle = pg.ScatterPlotItem() + self.ax1_BrushCircle.setData( + [], + [], + symbol="o", + pxMode=False, + brush=pg.mkBrush((255, 255, 255, 50)), + pen=pg.mkPen(width=2), + tip=None, + ) + self.topLayerItems.append(self.ax1_BrushCircle) - self.gui_addCreatedAxesItems() - self.gui_add_ax_cursors() - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None + # Eraser circle img1 + self.ax1_EraserCircle = pg.ScatterPlotItem() + self.ax1_EraserCircle.setData( + [], + [], + symbol="o", + pxMode=False, + brush=None, + pen=self.eraserCirclePen, + tip=None, + ) + self.topLayerItems.append(self.ax1_EraserCircle) - self.loadingDataCompleted() + self.ax1_EraserX = pg.ScatterPlotItem() + self.ax1_EraserX.setData( + [], + [], + symbol="x", + pxMode=False, + size=3, + brush=pg.mkBrush(color=(255, 0, 0, 50)), + pen=pg.mkPen(width=1, color="r"), + tip=None, + ) + self.topLayerItems.append(self.ax1_EraserX) - def gui_createTextAnnotItems(self, allIDs): - self.textAnnot = {} - isHighResolution = self.highLowResAction.isChecked() - pxMode = self.pxModeAction.isChecked() - for ax in range(2): - ax_textAnnot = annotate.TextAnnotations() - ax_textAnnot.initFonts(self.fontSize) - ax_textAnnot.createItems( - isHighResolution, allIDs, pxMode=pxMode - ) - self.textAnnot[ax] = ax_textAnnot + # Brush circle img1 + self.labelRoiCircItemLeft = widgets.LabelRoiCircularItem() + self.labelRoiCircItemLeft.cleared = False + self.labelRoiCircItemLeft.setData( + [], + [], + symbol="o", + pxMode=False, + brush=pg.mkBrush(color=(255, 0, 0, 0)), + pen=pg.mkPen(color="r", width=2), + tip=None, + ) + self.labelRoiCircItemRight = widgets.LabelRoiCircularItem() + self.labelRoiCircItemRight.cleared = False + self.labelRoiCircItemRight.setData( + [], + [], + symbol="o", + pxMode=False, + brush=pg.mkBrush(color=(255, 0, 0, 0)), + pen=pg.mkPen(color="r", width=2), + tip=None, + ) + self.topLayerItems.append(self.labelRoiCircItemLeft) + self.topLayerItemsRight.append(self.labelRoiCircItemRight) - def gui_addOverlayLayerItems(self): - for items in self.overlayLabelsItems.values(): - imageItem, contoursItem, gradItem = items - self.ax1.addItem(imageItem) - self.ax1.addItem(contoursItem) + self.ax1_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() + self.ax1_binnedIDs_ScatterPlot.setData( + [], + [], + symbol="t", + pxMode=False, + brush=pg.mkBrush((255, 0, 0, 50)), + size=15, + pen=pg.mkPen(width=3, color="r"), + tip=None, + ) + self.topLayerItems.append(self.ax1_binnedIDs_ScatterPlot) - def gui_addTopLayerItems(self): - for item in self.topLayerItems: - self.ax1.addItem(item) + self.ax1_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() + self.ax1_ripIDs_ScatterPlot.setData( + [], + [], + symbol="x", + pxMode=False, + brush=pg.mkBrush((255, 0, 0, 50)), + size=15, + pen=pg.mkPen(width=2, color="r"), + tip=None, + ) + self.topLayerItems.append(self.ax1_ripIDs_ScatterPlot) - for item in self.topLayerItemsRight: - self.ax2.addItem(item) + # Ruler plotItem and scatterItem + rulerPen = pg.mkPen(color="r", style=Qt.DashLine, width=2) + self.ax1_rulerPlotItem = widgets.RulerPlotItem(pen=rulerPen) + self.ax1_rulerAnchorsItem = pg.ScatterPlotItem( + symbol="o", + size=9, + brush=pg.mkBrush((255, 0, 0, 50)), + pen=pg.mkPen((255, 0, 0), width=2), + tip=None, + ) + self.topLayerItems.append(self.ax1_rulerPlotItem) + self.topLayerItems.append(self.ax1_rulerPlotItem.labelItem) + self.topLayerItems.append(self.ax1_rulerAnchorsItem) - # self.ax2.addItem(self.currentFrameLabelItem) + # Start point of polyline roi + self.ax1_point_ScatterPlot = pg.ScatterPlotItem() + self.ax1_point_ScatterPlot.setData( + [], + [], + symbol="o", + pxMode=False, + size=3, + pen=pg.mkPen(width=2, color="r"), + brush=pg.mkBrush((255, 0, 0, 50)), + tip=None, + ) + self.topLayerItems.append(self.ax1_point_ScatterPlot) - def updateContoursImage(self, ax, delROIsIDs=None, compute=True): - imageItem = self.getContoursImageItem(ax) - if imageItem is None: - return + # Experimental: scatter plot to add a point marker + self.startPointPolyLineItem = pg.ScatterPlotItem() + self.startPointPolyLineItem.setData( + [], + [], + symbol="o", + size=9, + pen=pg.mkPen(width=2, color="r"), + brush=pg.mkBrush((255, 0, 0, 50)), + hoverable=True, + hoverBrush=pg.mkBrush((255, 0, 0, 255)), + tip=None, + ) + self.topLayerItems.append(self.startPointPolyLineItem) - if not hasattr(self, 'contoursImage'): - self.initContoursImage() - else: - self.contoursImage[:] = 0 + # Eraser circle img2 + self.ax2_EraserCircle = pg.ScatterPlotItem() + self.ax2_EraserCircle.setData( + [], + [], + symbol="o", + pxMode=False, + brush=None, + pen=self.eraserCirclePen, + tip=None, + ) + self.ax2.addItem(self.ax2_EraserCircle) + self.ax2_EraserX = pg.ScatterPlotItem() + self.ax2_EraserX.setData( + [], + [], + symbol="x", + pxMode=False, + size=3, + brush=pg.mkBrush(color=(255, 0, 0, 50)), + pen=pg.mkPen(width=1.5, color="r"), + ) + self.ax2.addItem(self.ax2_EraserX) - contours = [] - for obj in skimage.measure.regionprops(self.currentLab2D): - obj_contours = self.getObjContours( - obj, - all_external=True, - force_calc=compute, - include_internal=self.showAllContoursToggle.isChecked() - ) - contours.extend(obj_contours) + # Brush circle img2 + self.ax2_BrushCirclePen = pg.mkPen(width=2) + self.ax2_BrushCircleBrush = pg.mkBrush((255, 255, 255, 50)) + self.ax2_BrushCircle = pg.ScatterPlotItem() + self.ax2_BrushCircle.setData( + [], + [], + symbol="o", + pxMode=False, + brush=self.ax2_BrushCircleBrush, + pen=self.ax2_BrushCirclePen, + tip=None, + ) + self.ax2.addItem(self.ax2_BrushCircle) - thickness = self.contLineWeight - color = self.contLineColor - self.setContoursImage(imageItem, contours, thickness, color) + # Annotated metadata markers (ScatterPlotItem) + self.ax2_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() + self.ax2_binnedIDs_ScatterPlot.setData( + [], + [], + symbol="t", + pxMode=False, + brush=pg.mkBrush((255, 0, 0, 50)), + size=15, + pen=pg.mkPen(width=3, color="r"), + tip=None, + ) + self.ax2.addItem(self.ax2_binnedIDs_ScatterPlot) - def setContoursImage(self, imageItem, contours, thickness, color): - cv2.drawContours(self.contoursImage, contours, -1, color, thickness) - imageItem.setImage(self.contoursImage) + self.ax2_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() + self.ax2_ripIDs_ScatterPlot.setData( + [], + [], + symbol="x", + pxMode=False, + brush=pg.mkBrush((255, 0, 0, 50)), + size=15, + pen=pg.mkPen(width=2, color="r"), + tip=None, + ) + self.ax2.addItem(self.ax2_ripIDs_ScatterPlot) - def getObjFromID(self, ID): - posData = self.data[self.pos_i] - try: - idx = posData.IDs_idxs[ID] - except KeyError as e: - # Object already cleared - return + self.freeRoiItem = widgets.PlotCurveItem(pen=pg.mkPen(color="r", width=2)) + self.topLayerItems.append(self.freeRoiItem) - obj = posData.rp[idx] - return obj + self.warnPairingItem = widgets.PlotCurveItem( + pen=pg.mkPen(color="r", width=5, style=Qt.DashLine), pxMode=False + ) + self.topLayerItems.append(self.warnPairingItem) - def setLostObjectContour(self, obj): - allContours = self.getObjContours(obj, all_external=True) - for objContours in allContours: - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - data = [obj.label]*len(xx) - self.ax1_lostObjScatterItem.addPoints(xx, yy, data=data) - self.ax2_lostObjScatterItem.addPoints(xx, yy) + self.exportMaskImageItem = pg.ImageItem() - def setTrackedLostObjectContour(self, obj): - if self.isExportingVideo: - return + self.ghostContourItemLeft = widgets.GhostContourItem(self.ax1) + self.ghostContourItemRight = widgets.GhostContourItem(self.ax2) - allContours = self.getObjContours(obj, all_external=True) - for objContours in allContours: - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - data = [obj.label]*len(xx) - self.ax1_lostTrackedScatterItem.addPoints(xx, yy, data=data) - self.ax2_lostTrackedScatterItem.addPoints(xx, yy) + self.ghostMaskItemLeft = widgets.GhostMaskItem(self.ax1) + self.ghostMaskItemRight = widgets.GhostMaskItem(self.ax2) - def updateLostContoursImage(self, ax, draw=True, delROIsIDs=None): - if draw: - imageItem = self.getLostObjImageItem(ax) - if imageItem is None: - return + self.manualBackgroundObjItem = widgets.GhostContourItem( + self.ax1, penColor="r", textColor="r" + ) + self.manualBackgroundImageItem = pg.ImageItem() - if not hasattr(self, 'lostObjContoursImage'): - self.initLostObjContoursImage() + def gui_createTextAnnotColors(self, r, g, b, custom=False): + if custom: + self.objLabelAnnotRgb = (int(r), int(g), int(b)) + self.SphaseAnnotRgb = (int(r * 0.9), int(r * 0.9), int(b * 0.9)) + self.G1phaseAnnotRgba = (int(r * 0.8), int(g * 0.8), int(b * 0.8), 220) else: - self.lostObjContoursImage[:] = 0 - - if delROIsIDs is None: - delROIsIDs = set() + self.objLabelAnnotRgb = (255, 255, 255) # white + self.SphaseAnnotRgb = (229, 229, 229) + self.G1phaseAnnotRgba = (204, 204, 204, 220) + self.dividedAnnotRgb = (245, 188, 1) # orange - posData = self.data[self.pos_i] - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] - if posData.whitelist is not None and posData.whitelist.whitelistIDs is not None: - whitelist = posData.whitelist.whitelistIDs[posData.frame_i-1] - else: - whitelist = None + self.emptyBrush = pg.mkBrush((0, 0, 0, 0)) + self.emptyPen = pg.mkPen((0, 0, 0, 0)) - contours = [] - for lostID in posData.lost_IDs: - if lostID in delROIsIDs or (whitelist is not None and lostID not in whitelist): - continue + def gui_createTextAnnotItems(self, allIDs): + self.textAnnot = {} + isHighResolution = self.highLowResAction.isChecked() + pxMode = self.pxModeAction.isChecked() + for ax in range(2): + ax_textAnnot = annotate.TextAnnotations() + ax_textAnnot.initFonts(self.fontSize) + ax_textAnnot.createItems(isHighResolution, allIDs, pxMode=pxMode) + self.textAnnot[ax] = ax_textAnnot - obj = prev_rp[prev_IDs_idxs[lostID]] - if not self.isObjVisible(obj.bbox): - continue + def gui_createZoomRectItem(self): + Y, X = self.currentLab2D.shape + # Label ROI rectangle + pen = pg.mkPen("r", width=3, style=Qt.DashLine) + self.zoomRectItem = widgets.ZoomROI( + (0, 0), + (0, 0), + maxBounds=QRectF(QRect(0, 0, X, Y)), + scaleSnap=True, + translateSnap=True, + pen=pen, + hoverPen=pen, + ) - obj_contours = self.getObjContours(obj, all_external=True) + def gui_getLostObjScatterItem(self): + self.objLostAnnotRgb = (245, 184, 0) + brush = pg.mkBrush((*self.objLostAnnotRgb, 150)) + pen = pg.mkPen(self.objLostAnnotRgb, width=1) + lostObjScatterItem = pg.ScatterPlotItem( + size=self.contLineWeight + 1, pen=pen, brush=brush, pxMode=False, symbol="s" + ) + return lostObjScatterItem - if ax == 0: - self.addLostObjsToLostObjImage(obj, lostID) + def gui_getTrackedLostObjScatterItem(self): + self.objLostTrackedAnnotRgb = (0, 255, 0) + brush = pg.mkBrush((*self.objLostTrackedAnnotRgb, 150)) + pen = pg.mkPen(self.objLostTrackedAnnotRgb, width=1) + lostObjScatterItem = pg.ScatterPlotItem( + size=self.contLineWeight + 1, pen=pen, brush=brush, pxMode=False, symbol="s" + ) + return lostObjScatterItem - contours.extend(obj_contours) + def gui_initImg1BottomWidgets(self): + self.zSliceScrollBar.hide() + self.zProjComboBox.hide() + self.zProjLockViewButton.hide() + self.zSliceOverlay_SB.hide() + self.zProjOverlay_CB.hide() + self.overlay_z_label.hide() + self.zSliceCheckbox.hide() + self.zSliceSpinbox.hide() + self.SizeZlabel.hide() - if not draw: - return + def gui_setTextAnnotColors(self): + self.textAnnot[0].setColors( + self.objLabelAnnotRgb, + self.dividedAnnotRgb, + self.SphaseAnnotRgb, + self.G1phaseAnnotRgba, + self.objLostAnnotRgb, + self.objLostTrackedAnnotRgb, + ) - self.drawLostObjContoursImage(imageItem, contours) + self.textAnnot[1].setColors( + self.objLabelAnnotRgb, + self.dividedAnnotRgb, + self.SphaseAnnotRgb, + self.G1phaseAnnotRgba, + self.objLostAnnotRgb, + self.objLostTrackedAnnotRgb, + ) - def drawLostObjContoursImage( - self, imageItem, contours, - thickness=1, - color=(255, 165, 0, 255) # orange - ): - img = self.lostObjContoursImage - cv2.drawContours(img, contours, -1, color, thickness) - imageItem.setImage(img) + def hideOverlayLabelsItems(self, specific=None): + if specific is None: + specific = self.overlayLabelsItems.keys() + for segmEndname in specific: + imageItem, contoursItem, gradItem = self.overlayLabelsItems[segmEndname] + imageItem.setVisible(False) + contoursItem.setVisible(False) + gradItem.setVisible(False) - def updateLostTrackedContoursImage( - self, ax, delROIsIDs=None, tracked_lost_IDs=None - ): - imageItem = self.getLostTrackedObjImageItem(ax) - if imageItem is None: - return + def imgGradLUTfinished_cb(self): + self.data[self.pos_i] + ticks = self.imgGrad.gradient.listTicks() - if not hasattr(self, 'lostTrackedObjContoursImage'): - self.initLostTrackedObjContoursImage() + self.img1ChannelGradients[self.user_ch_name] = { + "ticks": [(x, t.color.getRgb()) for t, x in ticks], + "mode": "rgb", + } + + self.df_settings = self.imgGrad.saveState(self.df_settings) + self.df_settings.to_csv(self.settings_csv_path) + + def initColormapOverlayLayerItem(self, foregrColor, lutItem): + if self.invertBwAction.isChecked(): + bkgrColor = (255, 255, 255, 255) else: - self.lostTrackedObjContoursImage[:] = 0 + bkgrColor = (0, 0, 0, 255) + gradient = colors.get_pg_gradient((bkgrColor, foregrColor)) + lutItem.setGradient(gradient) - if delROIsIDs is None: - delROIsIDs = set() + def initLabelsImageItems(self): + lut = self.getLabelsImageLut() + self.labelsLayerImg1.setLevels([0, len(lut)]) + self.labelsLayerRightImg.setLevels([0, len(lut)]) + self.labelsLayerImg1.setLookupTable(lut) + self.labelsLayerRightImg.setLookupTable(lut) + alpha = self.imgGrad.labelsAlphaSlider.value() + self.labelsLayerImg1.setOpacity(alpha) + self.labelsLayerRightImg.setOpacity(alpha) - posData = self.data[self.pos_i] - if tracked_lost_IDs is None: - tracked_lost_IDs = self.getTrackedLostIDs() + def initLookupTableLab(self): + self.img2.setLookupTable(self.lut) + self.img2.setLevels([0, len(self.lut)]) + self.initLabelsImageItems() - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] - contours = [] - for tracked_lost_ID in tracked_lost_IDs: - if tracked_lost_ID in delROIsIDs: - continue + def loadOverlayData(self, ol_channels, addToExisting=False): + posData = self.data[self.pos_i] + for ol_ch in ol_channels: + if ol_ch not in list(posData.loadedFluoChannels): + # Requested channel was never loaded --> load it at first + # iter i == 0 + success = self.loadFluo_cb(fluo_channels=[ol_ch]) + if not success: + return False - obj = prev_rp[prev_IDs_idxs[tracked_lost_ID]] - if not self.isObjVisible(obj.bbox): - continue + lastChannelName = ol_channels[-1] + for action in self.fluoDataChNameActions: + if action.text() == lastChannelName: + action.setChecked(True) - obj_contours = self.getObjContours(obj, all_external=True) - contours.extend(obj_contours) + for p, posData in enumerate(self.data): + if addToExisting: + ol_data = posData.ol_data + else: + ol_data = {} + for i, ol_ch in enumerate(ol_channels): + _, filename = self.getPathFromChName(ol_ch, posData) + ol_data[filename] = posData.ol_data_dict[filename].copy() + self.addFluoChNameContextMenuAction(ol_ch) + posData.ol_data = ol_data - self.drawLostTrackedObjContoursImage(imageItem, contours) + return True - def drawLostTrackedObjContoursImage(self, imageItem, contours): - thickness = 1 - color = (0, 255, 0, 255) # green - img = self.lostTrackedObjContoursImage - cv2.drawContours(img, contours, -1, color, thickness) - imageItem.setImage(img) + def loadOverlayLabelsData(self, segmEndname, pos_i=None): + if pos_i is None: + pos_i = self.pos_i + posData = self.data[pos_i] - def getNearestLostObjID(self, y, x): - if not self.annotLostObjsToggle.isChecked(): + if posData.ol_labels_data is None: + posData.ol_labels_data = {} + if segmEndname == "combined segm.": + posData.ol_labels_data["combined segm."] = posData.combine_img_data return + filePath, filename = load.get_path_from_endname( + segmEndname, posData.images_path + ) + self.logger.info(f'Loading "{segmEndname}.npz"...') + labelsData = np.load(filePath)["arr_0"] + if posData.SizeT == 1: + labelsData = labelsData[np.newaxis] + if self.isSegm3D and labelsData.ndim == 3: + # 2D segm --> stack to 3D + T, Y, X = labelsData.shape + repeat = [labelsData] * posData.SizeZ + labelsData = np.stack(repeat, axis=1) - posData = self.data[self.pos_i] - if not posData.lost_IDs: - return + posData.ol_labels_data[segmEndname] = labelsData - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - if prev_lab is None: + def mothBudLineWeightToggled(self, checked=True): + if not checked: return + self.imgGrad.uncheckContLineWeightActions() + w = self.sender().lineWeight + self.df_settings.at["mothBudLineSize", "value"] = w + self.df_settings.to_csv(self.settings_csv_path) + self._updateMothBudLineSize(w) + self.updateAllImages() - # if not hasattr(self, 'lostObjContoursImage'): - # self.store_data() - # posData.frame_i -= 1 - # self.get_data() - # self.store_data() - # posData.frame_i += 1 - # self.get_data() - # self.updateLostNewCurrentIDs() - # self.updateLostContoursImage(ax=0) - # self.updateLostContoursImage(ax=1) - # self.updateLostNewCurrentIDs() + def mousePressColorButton(self, event): + self.data[self.pos_i] + items = list(self.checkedOverlayChannels) + if len(items) > 1: + selectFluo = widgets.QDialogListbox( + "Select image", + "Select which fluorescence image you want to update the color of\n", + items, + multiSelection=False, + parent=self, + ) + selectFluo.exec_() + keys = selectFluo.selectedItemsText + if selectFluo.cancel or not keys: + return + else: + self.overlayColorButton.channel = keys[0] + else: + self.overlayColorButton.channel = items[0] + self.overlayColorButton.selectColor() - yy, xx, _ = np.nonzero(self.lostObjContoursImage) - lostObjsContourMask = np.zeros(self.currentLab2D.shape, dtype=bool) - lostObjsContourMask[yy.astype(int), xx.astype(int)] = True + def overlayChannelToggled(self, checked): + # Action toggled from overlayButton context menu + channelName = self.sender().text() + posData = self.data[self.pos_i] + if checked: + if channelName not in posData.loadedFluoChannels: + self.loadOverlayData([channelName], addToExisting=True) + else: + _, filename = self.getPathFromChName(channelName, posData) + posData.ol_data[filename] = posData.ol_data_dict[filename].copy() - # Add accepted lost IDs - try: - yy, xx, _ = np.nonzero(self.lostTrackedObjContoursImage) - lostObjsContourMask[yy.astype(int), xx.astype(int)] = True - except Exception as err: - pass + self.checkedOverlayChannels.add(channelName) + else: + self.checkedOverlayChannels.remove(channelName) + imageItem = self.overlayLayersItems[channelName][0] + imageItem.clear() - _, y_nearest, x_nearest = self.label_edits.nearest_nonzero_2d( - lostObjsContourMask, y, x, return_coords=True - ) - nearest_ID = self.get_2Dlab(prev_lab)[y_nearest, x_nearest] + self.setOverlayChannelsToolbuttonsChecked() + self.setOverlayItemsVisible() + self.setRetainSizePolicyLutItems() + self.updateAllImages() - if nearest_ID == 0: - return + def overlayChannelToolbuttonClicked(self, checked=False, toolbutton=None): + if toolbutton is None: + toolbutton = self.sender() - return nearest_ID + n_checked_buttons = sum( + [b.isChecked() for b in self.allOverlayToolbuttons.values()] + ) - def addObjContourToContoursImage( - self, ID=0, obj=None, ax=0, thickness=None, color=None, - force=False - ): - imageItem = self.getContoursImageItem(ax, force=force) - if imageItem is None: - return + channelName = toolbutton.channelName() - if obj is None: - obj = self.getObjFromID(ID) - if obj is None: - return + if n_checked_buttons == 0 or self.overlayToolbar.isSingleChannel(): + # At least one button must be checked + toolbutton.setChecked(True) - contours = self.getObjContours(obj, all_external=True) - if thickness is None: - thickness = self.contLineWeight - if color is None: - color = self.contLineColor + if self.overlayToolbar.isSingleChannel(): + # Exclusive buttons + for channel, otherToolbutton in self.allOverlayToolbuttons.items(): + if channel == channelName: + continue - self.setContoursImage(imageItem, contours, thickness, color) + otherToolbutton.setChecked(False) - def clearObjContour( - self, ID=0, obj=None, ax=0, debug=False, updateImage=True - ): - imageItem = self.getContoursImageItem(ax) - if imageItem is None: + if self.overlayToolbar.isTransparent(): + self.setOverlayImages() return - if ID > 0: - self.contoursImage[self.currentLab2D==ID] = [0,0,0,0] - else: - obj_slice = self.getObjSlice(obj.slice) - obj_image = self.getObjImage(obj.image, obj.bbox) - self.contoursImage[obj_slice][obj_image] = [0,0,0,0] - - if not updateImage: - return + self.setOverlayItemsOpacities() - imageItem.setImage(self.contoursImage) + def overlayLabelsDrawModeToggled(self, action): + segmEndname = action.segmEndname + drawMode = action.text() + if segmEndname in self.drawModeOverlayLabelsChannels: + self.drawModeOverlayLabelsChannels[segmEndname] = drawMode + self.setOverlayLabelsItems() - def setAllContoursImages(self, delROIsIDs=None, compute=True): + def overlayLabels_cb(self, checked, selectedLabelsEndnames=None): + if checked: + if not self.drawModeOverlayLabelsChannels: + if selectedLabelsEndnames is None: + selectedLabelsEndnames = self.askLabelsToOverlay() + if selectedLabelsEndnames is None: + self.logger.info("Overlay labels cancelled.") + self.overlayLabelsButton.setChecked(False) + return + for selectedEndname in selectedLabelsEndnames: + self.loadOverlayLabelsData(selectedEndname) + for action in self.overlayLabelsContextMenu.actions(): + if not action.isCheckable(): + continue + if action.text() == selectedEndname: + action.setChecked(True) + lastSelectedName = selectedLabelsEndnames[-1] + for action in self.selectOverlayLabelsActionGroup.actions(): + if action.text() == lastSelectedName: + action.setChecked(True) + self.updateAllImages() + + def overlay_cb(self, checked): + self.overlayToolbar.setVisible(checked) + + self.UserNormAction, _, _ = self.getCheckNormAction() + posData = self.data[self.pos_i] + if checked: + if posData.ol_data is None: + selectedChannels = self.askSelectOverlayChannel() + if selectedChannels is None: + self.overlayButton.toggled.disconnect() + self.overlayButton.setChecked(False) + self.overlayButton.toggled.connect(self.overlay_cb) + return + + success = self.loadOverlayData(selectedChannels) + if not success: + return False + lastChannel = selectedChannels[-1] + self.setCheckedOverlayContextMenusActions(selectedChannels) + imageItem = self.overlayLayersItems[lastChannel][0] + self.setOpacityOverlayLayersItems(None, imageItem=imageItem) + self.setOverlayChannelsToolbuttonsChecked() + + self.setRetainSizePolicyLutItems() + self.normalizeRescale0to1Action.setChecked(True) + + self.updateAllImages() + self.updateImageValueFormatter() + self.enableOverlayWidgets(True) + else: + self.img1.setOpacity(1.0) + self.updateAllImages() + self.updateImageValueFormatter() + self.enableOverlayWidgets(False) + self.clearOverlayImageItems() + + self.setOverlayItemsVisible() + + def overlay_channel_opacity_map( + self, + base_channel: str, + active_channel_alpha_values: Mapping[str, float], + ) -> dict[str, float]: + channels = list(active_channel_alpha_values) + alpha_values = list(active_channel_alpha_values.values()) + opacities = self._base_first_hierarchical_opacities(alpha_values) + channel_opacity_mapper = { + channel: opacities[i + 1] for i, channel in enumerate(channels) + } + channel_opacity_mapper[base_channel] = opacities[0] + return channel_opacity_mapper + + def overlay_item_opacity_plan( + self, + *, + all_channels: Iterable[str], + base_channel: str, + checked_channels: Iterable[str], + toolbar_single_channel: bool, + active_channel_alpha_values: Mapping[str, float], + ) -> OverlayOpacityPlan: + checked_channels = set(checked_channels) + channel_opacity_mapper = self.overlay_channel_opacity_map( + base_channel, + active_channel_alpha_values, + ) + is_single_channel = toolbar_single_channel or len(checked_channels) == 1 + + opacities = {} + alpha_scrollbar_disabled = {} + for channel in all_channels: + if channel in checked_channels and is_single_channel: + op_val = 1.0 + elif channel in checked_channels: + op_val = channel_opacity_mapper[channel] + else: + op_val = 0.0 + + if op_val == 0: + op_val = 0.01 + + opacities[channel] = min(op_val, 0.999) + if channel != base_channel: + alpha_scrollbar_disabled[channel] = op_val == 0 + + return OverlayOpacityPlan( + opacities=opacities, + alpha_scrollbar_disabled=alpha_scrollbar_disabled, + ) + + def overlay_toolbutton_checked( + self, + channel: str, + *, + checked_channels: Iterable[str], + is_single_channel: bool, + ) -> bool: + return not is_single_channel and channel in set(checked_channels) + + def overlay_toolbutton_click_checked_channels( + self, + *, + clicked_channel: str, + all_channels: Iterable[str], + checked_channels: Iterable[str], + toolbar_single_channel: bool, + ) -> set[str]: + all_channels = set(all_channels) + checked_channels = set(checked_channels) + if not checked_channels or toolbar_single_channel: + checked_channels.add(clicked_channel) + + if toolbar_single_channel: + return {clicked_channel} + + return checked_channels & all_channels + + def overlay_visibility_plan( + self, + *, + all_channels: Iterable[str], + checked_channels: Iterable[str], + overlay_enabled: bool, + ) -> OverlayVisibilityPlan: + checked_channels = set(checked_channels) + return OverlayVisibilityPlan( + channel_visible={ + channel: overlay_enabled and channel in checked_channels + for channel in all_channels + } + ) + + def permanentGreedyCmapToggled(self, checked): + if checked: + settings_value = "yes" + else: + self.setLut() + self.updateLookuptable() + self.initLabelsImageItems() + settings_value = "no" + + self.updateAllImages() + + if self.isSnapshot: + option_name = "permanent_greedy_lut_snapshots" + else: + option_name = "permanent_greedy_lut_timelapse" + + self.df_settings.at[option_name, "value"] = settings_value + self.df_settings.to_csv(self.settings_csv_path) + + def removeAllItems(self): + self.ax1.clear() + self.ax2.clear() + try: + self.chNamesQActionGroup.removeAction(self.userChNameAction) + except Exception: + pass + try: + self.data[self.pos_i] + for action in self.fluoDataChNameActions: + self.chNamesQActionGroup.removeAction(action) + except Exception: + pass + try: + self.overlayButton.setChecked(False) + except Exception: + pass + + if hasattr(self, "contoursImage"): + self.initContoursImage() + + def removeOverlayItems(self): + self.lutItemsLayout.clear() + + try: + for toolbutton in self.allOverlayToolbuttonsByIdx.values(): + self.overlayToolbar.removeAction(toolbutton.action) + + self.overlayToolbuttonsSep.removeFromToolbar() + except Exception: + pass + + def restoreDefaultColors(self): + try: + color = self.defaultToolBarButtonColor + self.overlayButton.setStyleSheet(f"background-color: {color}") + except AttributeError: + # traceback.print_exc() + pass + + def restoreDefaultSettings(self): + df = self.df_settings + df.at["contLineWeight", "value"] = 1 + df.at["mothBudLineSize", "value"] = 1 + df.at["mothBudLineColor", "value"] = (255, 165, 0, 255) + df.at["contLineColor", "value"] = (205, 0, 0, 220) + + self._updateContColour((205, 0, 0, 220)) + self._updateMothBudLineColour((255, 165, 0, 255)) + self._updateMothBudLineSize(1) + self._updateContLineThickness() + + df.at["overlaySegmMasksAlpha", "value"] = 0.3 + df.at["img_cmap", "value"] = "grey" + self.imgCmap = self.imgGrad.cmaps["grey"] + self.imgCmapName = "grey" + self.labelsGrad.item.loadPreset("viridis") + df.at["labels_bkgrColor", "value"] = (25, 25, 25) + + if df.at["is_bw_inverted", "value"] == "Yes": + self.invertBw(update=False) + + df = df[~df.index.str.contains("lab_cmap")] + df.to_csv(self.settings_csv_path) + self.imgGrad.restoreState(df) + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.restoreState(df) + + self.labelsGrad.saveState(df) + self.labelsGrad.restoreState(df, loadCmap=False) + + self.df_settings.to_csv(self.settings_csv_path) + self.updateAllImages() + + def saveBkgrColor(self, button): + color = button.color().getRgb()[:3] + self.df_settings.at["labels_bkgrColor", "value"] = color + self.df_settings.to_csv(self.settings_csv_path) + self.updateAllImages() + + def saveContColour(self, colorButton): + self.df_settings.to_csv(self.settings_csv_path) + + def saveMothBudLineColour(self, colorButton): + self.df_settings.to_csv(self.settings_csv_path) + + def saveOverlayColor(self, button): + rgb = button.color().getRgb()[:3] + rgb_text = "_".join([str(val) for val in rgb]) + self.df_settings.at[f"{button.channel}_rgb", "value"] = rgb_text + self.df_settings.to_csv(self.settings_csv_path) + + def saveTextIDsColors(self, button): + self.df_settings.at["textIDsColor", "value"] = self.objLabelAnnotRgb + self.df_settings.to_csv(self.settings_csv_path) + + def saveTextLabelsColor(self, button): + color = button.color().getRgb()[:3] + self.df_settings.at["labels_text_color", "value"] = color + self.df_settings.to_csv(self.settings_csv_path) + + def segmNdimIndicatorClicked(self): + ndimText = self.segmNdimIndicator.text() + if ndimText == "2D": + alternativeNdimText = "3D" + toggleText = "activate" + else: + alternativeNdimText = "2D" + toggleText = "de-activate" + msg = widgets.myMessageBox(wrapText=False) + important_txt = """ + The toggle to activate 3D segmentation is visible only when + the Number of z-slices is greater than 1. + """ + txt = html_utils.paragraph(f""" + This indicator shows that you are working with {ndimText} + segmentation masks.

+ + If instead, you want to work with {alternativeNdimText} segmentation, + you need to initialize a new segmentation file.

+ + To do so, go the menu on the top menubar File --> + New Segmentation File... and,
+ at the dialog where you insert the metadata (Number of z-slices, + pixel size, etc.),
+ {toggleText} the parameter called Work with 3D + segmentation masks (z-stack)
+ as indicated in the screenshot below
. + {html_utils.to_admonition(important_txt, admonition_type="note")} +
+ """) + msg.information( + self, + "Segmentation nmber of dimensions info", + txt, + image_paths=":toggle_3D_screenshot.png", + ) + self.segmNdimIndicator.setChecked(True) + + def setAllContoursImages(self, delROIsIDs=None, compute=True): if compute: self.computeAllContours() self.updateContoursImage(ax=0, delROIsIDs=delROIsIDs, compute=compute) @@ -2600,468 +2337,610 @@ def setAllLostTrackedObjContoursImage(self, delROIsIDs=None): self.updateLostTrackedContoursImage(ax=0, delROIsIDs=None) self.updateLostTrackedContoursImage(ax=1, delROIsIDs=None) - def getObjContours( - self, obj, all_external=False, local=False, force_calc=True, - include_internal=False - ): + def setCheckedOverlayContextMenusActions(self, channelNames): + for action in self.overlayContextMenu.actions(): + if action.text() in channelNames: + action.setChecked(True) + self.checkedOverlayChannels.add(action.text()) + + def setContoursImage(self, imageItem, contours, thickness, color): + cv2.drawContours(self.contoursImage, contours, -1, color, thickness) + imageItem.setImage(self.contoursImage) + + def setLostObjectContour(self, obj): + allContours = self.getObjContours(obj, all_external=True) + for objContours in allContours: + xx = objContours[:, 0] + 0.5 + yy = objContours[:, 1] + 0.5 + data = [obj.label] * len(xx) + self.ax1_lostObjScatterItem.addPoints(xx, yy, data=data) + self.ax2_lostObjScatterItem.addPoints(xx, yy) + + def setLut(self, shuffle=True): + self.lut = self.labelsGrad.item.colorMap().getLookupTable(0, 1, 255) + if shuffle: + np.random.shuffle(self.lut) + + # Insert background color + if "labels_bkgrColor" in self.df_settings.index: + rgbString = self.df_settings.at["labels_bkgrColor", "value"] + try: + r, g, b = rgbString + except Exception: + r, g, b = colors.rgb_str_to_values(rgbString) + else: + r, g, b = 25, 25, 25 + self.df_settings.at["labels_bkgrColor", "value"] = (r, g, b) + + self.lut = np.insert(self.lut, 0, [r, g, b], axis=0) + + def setOpacityOverlayLayersItems(self, value, imageItem=None, scrollbar=None): + if scrollbar is None: + scrollbar = imageItem.alphaScrollBar + + channel = scrollbar.channelName + toolbutton = self.allOverlayToolbuttons[channel] + if not toolbutton.isChecked() or not toolbutton.isVisible(): + return + + if value is None: + value = scrollbar.value() + + if imageItem is None: + imageItem = scrollbar.imageItem + alpha = value / scrollbar.maximum() + elif value > 1: + alpha = value / scrollbar.maximum() + else: + alpha = value + + alpha_values = [] + activeOverlayImageItems = [] + for items in self.overlayLayersItems.values(): + imgItem, lutItem, alphaSB = items[:3] + _toolbutton = alphaSB.toolbutton + if alphaSB.channelName == channel: + alpha_values.append(alpha) + elif not _toolbutton.isChecked() or not _toolbutton.isVisible(): + continue + else: + alpha_values.append(alphaSB.value() / alphaSB.maximum()) + + activeOverlayImageItems.append(imgItem) + + opacities = colors.hierarchical_weights(alpha_values)[::-1] + + for i, imgItem in enumerate(activeOverlayImageItems): + imgItem.setOpacity(opacities[i + 1]) + + self.img1.setOpacity(opacities[0], applyToLinked=False) + + def setOverlayChannelsToolbuttonsChecked(self): + for channel, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB, toolbutton = items[:4] + toolbutton.setChecked( + not self.overlayToolbar.isSingleChannel() + and channel in self.checkedOverlayChannels + ) + + def setOverlayColors(self): + self.overlayRGBs = [ + (255, 255, 0), + (252, 72, 254), + (49, 222, 134), + (22, 108, 27), + ] + self.overlayCmap = matplotlib.colormaps["hsv"] + self.overlayRGBs.extend( + [ + tuple([round(c * 255) for c in self.overlayCmap(i)][:3]) + for i in np.linspace(0, 1, 8) + ] + ) + + def setOverlayImages(self, frame_i=None): + if not self.overlayButton.isChecked(): + return + posData = self.data[self.pos_i] - dataDict = posData.allData_li[posData.frame_i] - allContours = dataDict.get('contours') - if allContours is not None and not force_calc: - z = self.z_lab() - key = (obj.label, str(z), all_external, local) - contours = allContours.get(key) - if contours is not None: - return contours + if posData.ol_data is None: + return - obj_image = self.getObjImage(obj.image, obj.bbox).astype(np.uint8) - obj_bbox = self.getObjBbox(obj.bbox) - try: - contours = self.geometry.object_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, - local=local, - all_external=all_external + rgba_imgs_info = {} + for filename in posData.ol_data: + chName = myutils.get_chname_from_basename( + filename, posData.basename, remove_ext=False ) - except Exception as e: - if all_external: - contours = [] + if chName not in self.checkedOverlayChannels: + continue + + items = self.overlayLayersItems[chName] + imageItem, lutItem, alphaSB = items[:3] + + ol_img = self.getOlImg(filename, frame_i=frame_i) + + if self.overlayToolbar.isTransparent(): + toolbutton = items[3] + if not toolbutton.isChecked(): + continue + alpha_val = alphaSB.value() / alphaSB.maximum() + ol_img = skimage.exposure.rescale_intensity( + ol_img, out_range=(0.0, 1.0) + ) + out_range_min, out_range_max = lutItem.getLevels() + rgba_imgs_info[chName] = (ol_img, alpha_val, lutItem) else: - contours = None - self.logger.warning( - f'Object ID {obj.label} contours drawing failed. ' - f'(bounding box = {obj.bbox})' - ) - return contours + self.rescaleIntensitiesLut(setImage=False, imageItem=imageItem) + imageItem.setImage(ol_img) - def clearComputedContours(self): - for posData in self.data: - for frame_i, dataDict in enumerate(posData.allData_li): - dataDict['contours'] = {} + if not self.overlayToolbar.isTransparent(): + return + + alpha_values = [] + images = [] + luts = [] + for channel, info in rgba_imgs_info.items(): + ol_img, alpha_val, lutItem = info + alpha_values.append(alpha_val) + images.append(ol_img) + luts.append(lutItem.gradient.getLookupTable(256, alpha=255) / 255) + + weights = colors.hierarchical_weights(alpha_values) + + if self.baseLayerToolbutton.isChecked(): + image1 = self._getImageupdateAllImages() + image1 = skimage.exposure.rescale_intensity(image1, out_range=(0.0, 1.0)) + images.append(image1) + baseLut = self.imgGrad.gradient.getLookupTable(256, alpha=255) / 255 + luts.append(baseLut) + + images_rgba = [] + for img, lut in zip(images, luts): + rgba = colors.grayscale_apply_lut(img, lut) + images_rgba.append(rgba) + + rgba_merge = colors.hierarchical_blend(images_rgba, weights) + self.rgbaImg1.setImage(rgba_merge) + + def setOverlayItemsOpacities(self): + n_checked_buttons = sum( + [b.isChecked() for b in self.allOverlayToolbuttons.values()] + ) + + isSingleChannel = ( + self.overlayToolbar.isSingleChannel() or n_checked_buttons == 1 + ) + + channel_opacity_mapper = self.getOpacitiesFromAlphaScrollbarValues() + + # Set opacity of every layer accordingly + for channel, otherToolbutton in self.allOverlayToolbuttons.items(): + if channel == self.user_ch_name: + otherImageItem = self.img1 + alphaScrollbar = None + # alpha_value = channel_opacity_mapper[channel] + else: + otherItems = self.overlayLayersItems[channel] + otherImageItem = otherItems[0] + alphaScrollbar = otherItems[2] + # alpha_value = alphaScrollbar.value()/alphaScrollbar.maximum() + + if otherToolbutton.isChecked() and isSingleChannel: + op_val = 1.0 + elif otherToolbutton.isChecked(): + op_val = channel_opacity_mapper[channel] + else: + op_val = 0.0 + + if op_val == 0: + op_val = 0.01 + + op_val = op_val if op_val < 1.0 else 0.999 + + otherImageItem.setOpacity(op_val, applyToLinked=False) + + if alphaScrollbar is None: + continue + + alphaScrollbar.setDisabled(bool(op_val == 0)) + + def setOverlayItemsVisible(self): + for channel, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB, toolbutton = items[:4] + lutItem.hide() + alphaSB.hide() + alphaSB.label.hide() + toolbutton.setVisible(False) + + if not self.overlayButton.isChecked(): + return + + for channel, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB, toolbutton = items[:4] + if channel in self.checkedOverlayChannels: + lutItem.show() + alphaSB.show() + alphaSB.label.show() + toolbutton.setVisible(True) + + def setOverlayLabelsItems(self, specific=None): + if not self.overlayLabelsButton.isChecked(): + self.hideOverlayLabelsItems(specific=specific) + return + + if specific is None: + specific = self.drawModeOverlayLabelsChannels.keys() + + for segmEndname in specific: + drawMode = self.drawModeOverlayLabelsChannels[segmEndname] + ol_lab = self.getOverlayLabelsData(segmEndname) + items = self.overlayLabelsItems[segmEndname] + imageItem, contoursItem, gradItem = items + contoursItem.clear() + if drawMode == "Draw contours": + for obj in skimage.measure.regionprops(ol_lab): + contours = self.getObjContours(obj, all_external=True) + for cont in contours: + contoursItem.addPoints(cont[:, 0] + 0.5, cont[:, 1] + 0.5) + elif drawMode == "Overlay labels": + imageItem.setImage(ol_lab, autoLevels=False) + self.showOverlayLabelsItems(specific=specific) + + def setOverlayLabelsItemsVisible(self, checked): + for _segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): + items = self.overlayLabelsItems[_segmEndname] + gradItem = items[-1] + gradItem.hide() + + if checked: + segmEndname = self.sender().text() + gradItem = self.overlayLabelsItems[segmEndname][-1] + gradItem.show() - def _computeAllContours2D( - self, dataDict, obj, z, obj_bbox, include_internal=False - ): - obj_image = self.getObjImage(obj.image, obj.bbox, z_slice=z) - if obj_image is None: + def setOverlaySegmMasks(self, force=False, forceIfNotActive=False): + if not hasattr(self, "currentLab2D"): return - all_external = False - local = False - contours = self.geometry.object_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, - local=local, - all_external=all_external + how = self.drawIDsContComboBox.currentText() + isOverlaySegmLeftActive = how.find("overlay segm. masks") != -1 + + how_ax2 = self.getAnnotateHowRightImage() + isOverlaySegmRightActive = ( + how_ax2.find("overlay segm. masks") != -1 + and self.labelsGrad.showRightImgAction.isChecked() ) - key = (obj.label, str(z), all_external, local) - dataDict['contours'][key] = contours - all_external = True - local = False - contours = self.geometry.object_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, - local=local, - all_external=all_external, - all=include_internal + isOverlaySegmActive = ( + isOverlaySegmLeftActive or isOverlaySegmRightActive or force ) - key = (obj.label, str(z), all_external, local) - dataDict['contours'][key] = contours + if not isOverlaySegmActive and not forceIfNotActive: + return - return dataDict + alpha = self.imgGrad.labelsAlphaSlider.value() + if alpha == 0: + return - def computeAllContours(self): - self.logger.info('Computing all contours...') posData = self.data[self.pos_i] - zz = [None] - if self.isSegm3D: - zz.extend(range(posData.SizeZ)) + maxID = max(posData.IDs, default=0) - include_internal = self.showAllContoursToggle.isChecked() - for frame_i, dataDict in enumerate(posData.allData_li): - lab = dataDict['labels'] - if lab is None: - break + if maxID >= len(self.lut): + self.extendLabelsLUT(maxID + 10) - rp = dataDict['regionprops'] - if rp is None: - rp = skimage.measure.regionprops(lab) + currentLab2D = self.currentLab2D + if isOverlaySegmLeftActive: + self.labelsLayerImg1.setImage(currentLab2D, autoLevels=False) - dataDict['contours'] = {} - for obj in rp: - obj_bbox = self.getObjBbox(obj.bbox) - for z in zz: - if not self.isObjVisible(obj.bbox, z_slice=z): - continue + if isOverlaySegmRightActive: + self.labelsLayerRightImg.setImage(currentLab2D, autoLevels=False) - try: - self._computeAllContours2D( - dataDict, obj, z, obj_bbox, - include_internal=include_internal - ) - except Exception as err: - # Contours computation fails on weird objects - pass + def setOverlaySingleChannel(self, *args, **kwargs): + if self.overlayToolbar.isSingleChannel(): + self.overlayToolbarAreChannelsChecked = { + channel: toolbutton.isChecked() + for channel, toolbutton in self.allOverlayToolbuttons.items() + } + firstActiveToolbutton = [ + toolbutton + for toolbutton in self.allOverlayToolbuttons.values() + if toolbutton.isChecked() + ][0] + firstActiveToolbutton.click() + else: + for ch, checked in self.overlayToolbarAreChannelsChecked.items(): + toolbutton = self.allOverlayToolbuttons[ch] + toolbutton.setChecked(checked) - def computeAllObjToObjCostPairs(self): - desc = ( - 'Computing all object-to-object cost matrices...' - ) - self.logger.info(desc) - posData = self.data[self.pos_i] + self.setOverlayItemsOpacities() - self.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self.host, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.show(self.app) + def setOverlayTransparency(self, transparent: bool): + opacity = float(transparent) + opacity = opacity if opacity < 1.0 else 0.999 + self.rgbaImg1.setOpacity(opacity) - self.computeAllObjCostPairsThread = QThread() - self.computeAllObjCostPairsWorker = workers.SimpleWorker( - posData, self._computeAllObjToObjCostPairs - ) + if transparent: + self.img1.setOpacity(0.001, applyToLinked=False) + self.imgGrad.sigLookupTableChanged.connect( + self.updateTransparentOverlayRgba + ) + self.imgGrad.sigLevelsChanged.connect(self.updateTransparentOverlayRgba) - self.computeAllObjCostPairsWorker.moveToThread( - self.computeAllObjCostPairsThread - ) + for channel, items in self.overlayLayersItems.items(): + imageItem, lutItem, alphaSB = items[:3] + if transparent: + alphaSB.valueChanged.disconnect() + alphaSB.valueChanged.connect(self.updateTransparentOverlayRgba) + lutItem.sigLookupTableChanged.connect(self.updateTransparentOverlayRgba) + lutItem.sigLevelsChanged.connect(self.updateTransparentOverlayRgba) + imageItem.setOpacity(0) - self.computeAllObjCostPairsWorker.signals.finished.connect( - self.computeAllObjCostPairsThread.quit - ) - self.computeAllObjCostPairsWorker.signals.finished.connect( - self.computeAllObjCostPairsWorker.deleteLater - ) - self.computeAllObjCostPairsThread.finished.connect( - self.computeAllObjCostPairsThread.deleteLater - ) + if not transparent: + self.setOverlayItemsOpacities() - self.computeAllObjCostPairsWorker.signals.critical.connect( - self.computeAllObjCostPairsWorkerCritical - ) - self.computeAllObjCostPairsWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.computeAllObjCostPairsWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.computeAllObjCostPairsWorker.signals.progress.connect( - self.workerProgress - ) - self.computeAllObjCostPairsWorker.signals.finished.connect( - self.computeAllObjCostPairsWorkerFinished - ) + self.setOverlayImages() - self.computeAllObjCostPairsThread.started.connect( - self.computeAllObjCostPairsWorker.run - ) - self.computeAllObjCostPairsThread.start() + def setPermanentGreedyCmapPreferences(self): + if self.isSnapshot: + option_name = "permanent_greedy_lut_snapshots" + else: + option_name = "permanent_greedy_lut_timelapse" - self.computeAllObjCostPairsWorkerLoop = QEventLoop() - self.computeAllObjCostPairsWorkerLoop.exec_() + if option_name not in self.df_settings.index: + return - def _computeAllObjToObjCostPairs(self, posData): - self.computeAllObjCostPairsWorker.signals.initProgressBar.emit( - len(posData.allData_li) - ) - for frame_i, dataDict in enumerate(posData.allData_li): - if frame_i == 0: - continue + checked = self.df_settings.at[option_name, "value"] == "yes" + self.labelsGrad.permanentGreedyCmapAction.setChecked(checked) - rp = dataDict['regionprops'] - if rp is None: - break + def setRetainSizePolicyLutItems(self): + if not self.retainSizeLutItems: + return + for channel, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB = items[:3] + myutils.setRetainSizePolicy(lutItem, retain=True) + QTimer.singleShot(300, self.autoRange) - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - dist_matrix = self.geometry.object_to_object_contour_distance_matrix( - dataDict['contours'], rp, - previous_regionprops=prev_rp, - restrict_search=True, - ) - dataDict['obj_to_obj_dist_cost_matrix_df'] = dist_matrix - self.computeAllObjCostPairsWorker.signals.progressBar.emit(1) - self.computeAllObjCostPairsWorker.signals.initProgressBar.emit(0) + def setTrackedLostObjectContour(self, obj): + if self.isExportingVideo: + return - def computeAllObjCostPairsWorkerCritical(self, error): - self.computeAllObjCostPairsWorkerLoop.exit() - self.workerCritical(error) + allContours = self.getObjContours(obj, all_external=True) + for objContours in allContours: + xx = objContours[:, 0] + 0.5 + yy = objContours[:, 1] + 0.5 + data = [obj.label] * len(xx) + self.ax1_lostTrackedScatterItem.addPoints(xx, yy, data=data) + self.ax2_lostTrackedScatterItem.addPoints(xx, yy) - def computeAllObjCostPairsWorkerFinished(self, output): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.computeAllObjCostPairsWorkerLoop.exit() + def setValueLabelsAlphaSlider(self, value): + self.imgGrad.labelsAlphaSlider.setValue(value) + self.updateLabelsAlpha(value) - def gui_createMothBudLinePens(self): - if 'mothBudLineSize' in self.df_settings.index: - val = self.df_settings.at['mothBudLineSize', 'value'] - self.mothBudLineWeight = int(val) - else: - self.mothBudLineWeight = 2 + def showOverlayContextMenu(self, event): + if not self.overlayButton.isChecked(): + return - self.newMothBudlineColor = (255, 0, 0) - if 'mothBudLineColor' in self.df_settings.index: - val = self.df_settings.at['mothBudLineColor', 'value'] - rgba = colors.rgba_str_to_values(val) - self.mothBudLineColor = rgba[0:3] + self.overlayContextMenu.exec_(QCursor.pos()) + + def showOverlayLabelsContextMenu(self, event): + if not self.overlayLabelsButton.isChecked(): + return + + self.overlayLabelsContextMenu.exec_(QCursor.pos()) + + def showOverlayLabelsItems(self, specific=None): + if specific is None: + specific = self.overlayLabelsItems.keys() + for segmEndname in specific: + imageItem, contoursItem, gradItem = self.overlayLabelsItems[segmEndname] + drawMode = self.drawModeOverlayLabelsChannels[segmEndname] + if drawMode == "Draw contours": + contoursItem.setVisible(True) + elif drawMode == "Overlay labels": + imageItem.setVisible(True) + gradItem.setVisible(True) + + def shuffle_cmap(self): + np.random.shuffle(self.lut[1:]) + self.initLabelsImageItems() + self.updateAllImages() + + def ticksCmapMoved(self, gradient): + pass + + def toggleOverlayColorButton(self, checked=True): + self.mousePressColorButton(None) + + def toggleTextIDsColorButton(self, checked=True): + self.textIDsColorButton.selectColor() + + def updateBkgrColor(self, button): + color = button.color().getRgb()[:3] + self.lut[0] = color + self.updateLookuptable() + + def updateContColour(self, colorButton): + color = colorButton.color().getRgb() + self.df_settings.at["contLineColor", "value"] = str(color) + self._updateContColour(color) + self.updateAllImages() + + def updateContoursImage(self, ax, delROIsIDs=None, compute=True): + imageItem = self.getContoursImageItem(ax) + if imageItem is None: + return + + if not hasattr(self, "contoursImage"): + self.initContoursImage() else: - self.mothBudLineColor = (255,165,0) + self.contoursImage[:] = 0 - try: - self.imgGrad.mothBudLineColorButton.sigColorChanging.disconnect() - self.imgGrad.mothBudLineColorButton.sigColorChanged.disconnect() - except Exception as e: - pass - try: - for act in self.imgGrad.mothBudLineWightActionGroup.actions(): - act.toggled.disconnect() - except Exception as e: - pass - for act in self.imgGrad.mothBudLineWightActionGroup.actions(): - if act.lineWeight == self.mothBudLineWeight: - act.setChecked(True) - else: - act.setChecked(False) - self.imgGrad.mothBudLineColorButton.setColor(self.mothBudLineColor[:3]) + contours = [] + for obj in skimage.measure.regionprops(self.currentLab2D): + obj_contours = self.getObjContours( + obj, + all_external=True, + force_calc=compute, + include_internal=self.showAllContoursToggle.isChecked(), + ) + contours.extend(obj_contours) - self.imgGrad.mothBudLineColorButton.sigColorChanging.connect( - self.updateMothBudLineColour - ) - self.imgGrad.mothBudLineColorButton.sigColorChanged.connect( - self.saveMothBudLineColour - ) - for act in self.imgGrad.mothBudLineWightActionGroup.actions(): - act.toggled.connect(self.mothBudLineWeightToggled) + thickness = self.contLineWeight + color = self.contLineColor + self.setContoursImage(imageItem, contours, thickness, color) - # MOther-bud lines brushes - self.NewBudMoth_Pen = pg.mkPen( - color=self.newMothBudlineColor, width=self.mothBudLineWeight+1, - style=Qt.DashLine - ) - self.OldBudMoth_Pen = pg.mkPen( - color=self.mothBudLineColor, width=self.mothBudLineWeight, - style=Qt.DashLine - ) + def updateLabelsCmap(self, gradient): + self.setLut() + self.updateLookuptable() + self.initLabelsImageItems() - self.redDashLinePen = pg.mkPen( - color='r', width=2, style=Qt.DashLine - ) + self.df_settings = self.labelsGrad.saveState(self.df_settings) + self.df_settings.to_csv(self.settings_csv_path) - self.oldMothBudLineBrush = pg.mkBrush(self.mothBudLineColor) - self.newMothBudLineBrush = pg.mkBrush(self.newMothBudlineColor) + self.updateAllImages() - def imgGradLUTfinished_cb(self): + def updateLookuptable(self, lenNewLut=None, delIDs=None): posData = self.data[self.pos_i] - ticks = self.imgGrad.gradient.listTicks() + if lenNewLut is None: + try: + if delIDs is None: + IDs = posData.IDs + else: + # Remove IDs removed with ROI from LUT + IDs = [ID for ID in posData.IDs if ID not in delIDs] + lenNewLut = max(IDs, default=0) + 1 + except ValueError: + # Empty segmentation mask + lenNewLut = 1 + # Build a new lut to include IDs > than original len of lut + updateLevels = self.extendLabelsLUT(lenNewLut) + lut = self.lut.copy() - self.img1ChannelGradients[self.user_ch_name] = { - 'ticks': [(x, t.color.getRgb()) for t, x in ticks], - 'mode': 'rgb' - } + try: + # lut = self.lut[:lenNewLut].copy() + for ID in posData.binnedIDs: + lut[ID] = lut[ID] * 0.2 - self.df_settings = self.imgGrad.saveState(self.df_settings) - self.df_settings.to_csv(self.settings_csv_path) + for ID in posData.ripIDs: + lut[ID] = lut[ID] * 0.2 + except Exception: + err_str = traceback.format_exc() + print("=" * 30) + self.logger.info(err_str) + print("=" * 30) - def restoreDefaultSettings(self): - df = self.df_settings - df.at['contLineWeight', 'value'] = 1 - df.at['mothBudLineSize', 'value'] = 1 - df.at['mothBudLineColor', 'value'] = (255, 165, 0, 255) - df.at['contLineColor', 'value'] = (205, 0, 0, 220) + if updateLevels: + self.img2.setLevels([0, len(lut)]) - self._updateContColour((205, 0, 0, 220)) - self._updateMothBudLineColour((255, 165, 0, 255)) - self._updateMothBudLineSize(1) - self._updateContLineThickness() + if self.keepIDsButton.isChecked(): + lut = np.round(lut * 0.3).astype(np.uint8) + keptLut = np.round(lut[self.keptObjectsIDs] / 0.3).astype(np.uint8) + lut[self.keptObjectsIDs] = keptLut - df.at['overlaySegmMasksAlpha', 'value'] = 0.3 - df.at['img_cmap', 'value'] = 'grey' - self.imgCmap = self.imgGrad.cmaps['grey'] - self.imgCmapName = 'grey' - self.labelsGrad.item.loadPreset('viridis') - df.at['labels_bkgrColor', 'value'] = (25, 25, 25) + self.img2.setLookupTable(lut) - if df.at['is_bw_inverted', 'value'] == 'Yes': - self.invertBw(update=False) + def updateLostContoursImage(self, ax, draw=True, delROIsIDs=None): + if draw: + imageItem = self.getLostObjImageItem(ax) + if imageItem is None: + return - df = df[~df.index.str.contains('lab_cmap')] - df.to_csv(self.settings_csv_path) - self.imgGrad.restoreState(df) - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.restoreState(df) + if not hasattr(self, "lostObjContoursImage"): + self.initLostObjContoursImage() + else: + self.lostObjContoursImage[:] = 0 - self.labelsGrad.saveState(df) - self.labelsGrad.restoreState(df, loadCmap=False) + if delROIsIDs is None: + delROIsIDs = set() - self.df_settings.to_csv(self.settings_csv_path) - self.updateAllImages() + posData = self.data[self.pos_i] + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[posData.frame_i - 1]["IDs_idxs"] + if posData.whitelist is not None and posData.whitelist.whitelistIDs is not None: + whitelist = posData.whitelist.whitelistIDs[posData.frame_i - 1] + else: + whitelist = None - def updateMothBudLineColour(self, colorButton): - color = colorButton.color().getRgb() - self.df_settings.at['mothBudLineColor', 'value'] = str(color) - self._updateMothBudLineColour(color) - self.updateAllImages() + contours = [] + for lostID in posData.lost_IDs: + if lostID in delROIsIDs or ( + whitelist is not None and lostID not in whitelist + ): + continue - def _updateMothBudLineColour(self, color): - self.gui_createMothBudLinePens() - self.ax1_newMothBudLinesItem.setBrush(self.newMothBudLineBrush) - self.ax1_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush) - self.ax2_newMothBudLinesItem.setBrush(self.newMothBudLineBrush) - self.ax2_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush) - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.mothBudLineColorButton.setColor(color) + obj = prev_rp[prev_IDs_idxs[lostID]] + if not self.isObjVisible(obj.bbox): + continue - def saveMothBudLineColour(self, colorButton): - self.df_settings.to_csv(self.settings_csv_path) + obj_contours = self.getObjContours(obj, all_external=True) - def mothBudLineWeightToggled(self, checked=True): - if not checked: - return - self.imgGrad.uncheckContLineWeightActions() - w = self.sender().lineWeight - self.df_settings.at['mothBudLineSize', 'value'] = w - self.df_settings.to_csv(self.settings_csv_path) - self._updateMothBudLineSize(w) - self.updateAllImages() + if ax == 0: + self.addLostObjsToLostObjImage(obj, lostID) - def _updateMothBudLineSize(self, size): - self.gui_createMothBudLinePens() + contours.extend(obj_contours) - for act in self.imgGrad.mothBudLineWightActionGroup.actions(): - if act == self.sender(): - act.setChecked(True) - act.toggled.connect(self.mothBudLineWeightToggled) + if not draw: + return - self.ax1_oldMothBudLinesItem.setSize(size) - self.ax1_newMothBudLinesItem.setSize(size) - self.ax2_oldMothBudLinesItem.setSize(size) - self.ax2_newMothBudLinesItem.setSize(size) + self.drawLostObjContoursImage(imageItem, contours) - def gui_createContourPens(self): - if 'contLineWeight' in self.df_settings.index: - val = self.df_settings.at['contLineWeight', 'value'] - self.contLineWeight = int(val) - else: - self.contLineWeight = 1 - if 'contLineColor' in self.df_settings.index: - val = self.df_settings.at['contLineColor', 'value'] - rgba = colors.rgba_str_to_values(val) - self.contLineColor = rgba - self.newIDlineColor = [min(255, v+50) for v in self.contLineColor] + def updateLostTrackedContoursImage( + self, ax, delROIsIDs=None, tracked_lost_IDs=None + ): + imageItem = self.getLostTrackedObjImageItem(ax) + if imageItem is None: + return + + if not hasattr(self, "lostTrackedObjContoursImage"): + self.initLostTrackedObjContoursImage() else: - self.contLineColor = (255, 0, 0, 200) - self.newIDlineColor = (255, 0, 0, 255) + self.lostTrackedObjContoursImage[:] = 0 - try: - self.imgGrad.contoursColorButton.sigColorChanging.disconnect() - self.imgGrad.contoursColorButton.sigColorChanged.disconnect() - except Exception as e: - pass - try: - for act in self.imgGrad.contLineWightActionGroup.actions(): - act.toggled.disconnect() - except Exception as e: - pass - for act in self.imgGrad.contLineWightActionGroup.actions(): - if act.lineWeight == self.contLineWeight: - act.setChecked(True) - self.imgGrad.contoursColorButton.setColor(self.contLineColor[:3]) + if delROIsIDs is None: + delROIsIDs = set() - self.imgGrad.contoursColorButton.sigColorChanging.connect( - self.updateContColour - ) - self.imgGrad.contoursColorButton.sigColorChanged.connect( - self.saveContColour - ) - for act in self.imgGrad.contLineWightActionGroup.actions(): - act.toggled.connect(self.contLineWeightToggled) + posData = self.data[self.pos_i] + if tracked_lost_IDs is None: + tracked_lost_IDs = self.getTrackedLostIDs() - # Contours pens - self.oldIDs_cpen = pg.mkPen( - color=self.contLineColor, width=self.contLineWeight - ) - self.newIDs_cpen = pg.mkPen( - color=self.newIDlineColor, width=self.contLineWeight+1 - ) - self.tempNewIDs_cpen = pg.mkPen( - color='g', width=self.contLineWeight+1 - ) + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[posData.frame_i - 1]["IDs_idxs"] + contours = [] + for tracked_lost_ID in tracked_lost_IDs: + if tracked_lost_ID in delROIsIDs: + continue - def updateContColour(self, colorButton): + obj = prev_rp[prev_IDs_idxs[tracked_lost_ID]] + if not self.isObjVisible(obj.bbox): + continue + + obj_contours = self.getObjContours(obj, all_external=True) + contours.extend(obj_contours) + + self.drawLostTrackedObjContoursImage(imageItem, contours) + + def updateMothBudLineColour(self, colorButton): color = colorButton.color().getRgb() - self.df_settings.at['contLineColor', 'value'] = str(color) - self._updateContColour(color) + self.df_settings.at["mothBudLineColor", "value"] = str(color) + self._updateMothBudLineColour(color) self.updateAllImages() - def _updateContColour(self, color): - self.gui_createContourPens() + def updateTextAnnotColor(self, button): + r, g, b = np.array(self.textIDsColorButton.color().getRgb()[:3]) + self.imgGrad.textColorButton.setColor((r, g, b)) for items in self.overlayLayersItems.values(): lutItem = items[1] - lutItem.contoursColorButton.setColor(color) - - def saveContColour(self, colorButton): - self.df_settings.to_csv(self.settings_csv_path) - - def contLineWeightToggled(self, checked=True): - if not checked: - return - self.imgGrad.uncheckContLineWeightActions() - w = self.sender().lineWeight - self.df_settings.at['contLineWeight', 'value'] = w - self.df_settings.to_csv(self.settings_csv_path) - self._updateContLineThickness() + lutItem.textColorButton.setColor((r, g, b)) + self.gui_createTextAnnotColors(r, g, b, custom=True) + self.gui_setTextAnnotColors() self.updateAllImages() - def _updateContLineThickness(self): - self.gui_createContourPens() - for act in self.imgGrad.contLineWightActionGroup.actions(): - if act == self.sender(): - act.setChecked(True) - act.toggled.connect(self.contLineWeightToggled) - - def gui_createGraphicsItems(self): - # Create enough PlotDataItems and LabelItems to draw contours and IDs. - self.progressWin = apps.QDialogWorkerProgress( - title='Creating axes items', parent=self.host, - pbarDesc='Creating axes items (see progress in the terminal)...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - QTimer.singleShot(50, self._gui_createGraphicsItems) + def updateTextLabelsColor(self, button): + self.ax2_textColor = button.color().getRgb()[:3] + posData = self.data[self.pos_i] + if posData.rp is None: + return - def gui_connectGraphicsEvents(self): - self.img1.hoverEvent = self.gui_hoverEventImg1 - self.img2.hoverEvent = self.gui_hoverEventImg2 - self.img1.mousePressEvent = self.gui_mousePressEventImg1 - self.img1.mouseMoveEvent = self.gui_mouseDragEventImg1 - self.img1.mouseReleaseEvent = self.gui_mouseReleaseEventImg1 - self.img2.mousePressEvent = self.gui_mousePressEventImg2 - self.img2.mouseMoveEvent = self.gui_mouseDragEventImg2 - self.img2.mouseReleaseEvent = self.gui_mouseReleaseEventImg2 - self.rightImageItem.mousePressEvent = self.canvas_right_image_view.mouse_press - self.rightImageItem.mouseMoveEvent = self.canvas_right_image_view.mouse_drag - self.rightImageItem.mouseReleaseEvent = self.canvas_right_image_view.mouse_release - self.rightImageItem.hoverEvent = self.gui_hoverEventRightImage - # self.imgGrad.gradient.showMenu = self.gui_gradientContextMenuEvent - self.imgGradRight.gradient.showMenu = ( - self.canvas_context_menu_view.show_right_image_context_menu - ) - # self.imgGrad.vb.contextMenuEvent = self.gui_gradientContextMenuEvent - self.ax1.sigRangeChanged.connect( - self.display_decorations_view.view_range_changed - ) + for obj in posData.rp: + self.getObjOptsSegmLabels(obj) - def gui_initImg1BottomWidgets(self): - self.zSliceScrollBar.hide() - self.zProjComboBox.hide() - self.zProjLockViewButton.hide() - self.zSliceOverlay_SB.hide() - self.zProjOverlay_CB.hide() - self.overlay_z_label.hide() - self.zSliceCheckbox.hide() - self.zSliceSpinbox.hide() - self.SizeZlabel.hide() \ No newline at end of file + def updateTransparentOverlayRgba(self, *args, **kwargs): + self.setOverlayImages() diff --git a/cellacdc/mixins/image_controls.py b/cellacdc/mixins/image_controls.py index 17e6a8fcc..f61017dfc 100644 --- a/cellacdc/mixins/image_controls.py +++ b/cellacdc/mixins/image_controls.py @@ -23,88 +23,152 @@ _font.setPixelSize(11) -class ImageControlsView: +class ImageControlsMixin: """Qt-facing adapter around image-control defaults and widgets.""" """Headless defaults for image-control UI construction.""" - draw_ids_cont_combo_items = ( - 'Draw IDs and contours', - 'Draw IDs and overlay segm. masks', - 'Draw only cell cycle info', - 'Draw cell cycle info and contours', - 'Draw cell cycle info and overlay segm. masks', - 'Draw only mother-bud lines', - 'Draw only IDs', - 'Draw only contours', - 'Draw only overlay segm. masks', - 'Draw nothing', - ) - z_projection_options = ( - 'single z-slice', - 'max z-projection', - 'mean z-projection', - 'median z-proj.', - ) - overlay_z_projection_options = ( - 'single z-slice', - 'max z-projection', - 'mean z-projection', - 'median z-proj.', - 'same as above', - ) + draw_ids_cont_combo_items = () + z_projection_options = () + overlay_z_projection_options = () bottom_layout_zoom_values = tuple(range(50, 151, 10)) def bottom_layout_zoom_percent(self, df_settings) -> int: - if 'bottom_sliders_zoom_perc' not in df_settings.index: + if "bottom_sliders_zoom_perc" not in df_settings.index: return 100 - return int(df_settings.at['bottom_sliders_zoom_perc', 'value']) + return int(df_settings.at["bottom_sliders_zoom_perc", "value"]) - def retain_space_hidden_sliders(self, df_settings) -> bool: - if 'retain_space_hidden_sliders' not in df_settings.index: - return True - return df_settings.at['retain_space_hidden_sliders', 'value'] == 'Yes' + def gui_createBottomWidgetsToBottomLayout(self): + # self.bottomDockWidget = QDockWidget(self) + bottomScrollArea = widgets.ScrollArea(resizeVerticalOnShow=True) + bottomScrollArea.sigLeaveEvent.connect(self.setFocusMain) + bottomWidget = QWidget() + bottomScrollAreaLayout = QVBoxLayout() + self.bottomLayout = QHBoxLayout() + self.bottomLayout.addLayout(self.quickSettingsLayout) + self.bottomLayout.addStretch(1) + self.bottomLayout.addWidget(self.img1BottomGroupbox) + self.bottomLayout.addStretch(1) + self.bottomLayout.addWidget(self.rightBottomGroupbox) + self.bottomLayout.addStretch(1) + bottomScrollAreaLayout.addLayout(self.bottomLayout) + bottomScrollAreaLayout.addStretch(1) - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) + bottomWidget.setLayout(bottomScrollAreaLayout) + bottomScrollArea.setWidgetResizable(True) + bottomScrollArea.setWidget(bottomWidget) + self.bottomScrollArea = bottomScrollArea + + if "bottom_sliders_zoom_perc" in self.df_settings.index: + val = int(self.df_settings.at["bottom_sliders_zoom_perc", "value"]) + zoom_perc = val + else: + zoom_perc = 100 + self.bottomLayoutContextMenu = QMenu("Bottom layout", self) + zoomMenu = self.bottomLayoutContextMenu.addMenu("Zoom") + actions = [] + self.bottomLayoutContextMenu.zoomActionGroup = QActionGroup(zoomMenu) + for perc in np.arange(50, 151, 10): + action = QAction(f"{perc}%", zoomMenu) + action.setCheckable(True) + if perc == zoom_perc: + action.setChecked(True) + action.toggled.connect(self.zoomBottomLayoutActionTriggered) + actions.append(action) + self.bottomLayoutContextMenu.zoomActionGroup.addAction(action) + zoomMenu.addActions(actions) + resetAction = self.bottomLayoutContextMenu.addAction("Reset default height") + resetAction.triggered.connect(self.resizeGui) + retainSpaceAction = self.bottomLayoutContextMenu.addAction( + "Retain space of hidden sliders" + ) + retainSpaceAction.setCheckable(True) + if "retain_space_hidden_sliders" in self.df_settings.index: + retainSpaceChecked = ( + self.df_settings.at["retain_space_hidden_sliders", "value"] == "Yes" + ) + else: + retainSpaceChecked = True + retainSpaceAction.setChecked(retainSpaceChecked) + retainSpaceAction.toggled.connect(self.retainSpaceSlidersToggled) + self.retainSpaceSlidersAction = retainSpaceAction + self.setBottomLayoutStretch() - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) + def gui_createGraphicsPlots(self): + self.graphLayout = pg.GraphicsLayoutWidget() + if self.invertBwAction.isChecked(): + self.graphLayout.setBackground(graphLayoutBkgrColor) + self.titleColor = "black" else: - setattr(self.host, name, value) + self.graphLayout.setBackground(darkBkgrColor) + self.titleColor = "white" + + self.lutItemsLayout = self.graphLayout.addLayout(row=1, col=0) + # self.lutItemsLayout.setBorder('w') + + # Left plot + self.ax1 = widgets.MainPlotItem(showWelcomeText=True) + self.ax1.invertY(True) + self.ax1.setAspectLocked(True) + self.ax1.hideAxis("bottom") + self.ax1.hideAxis("left") + self.plotsCol = 1 + self.graphLayout.addItem(self.ax1, row=1, col=1) + + # Right plot + self.ax2 = widgets.MainPlotItem() + self.ax2.setAspectLocked(True) + self.ax2.invertY(True) + self.ax2.hideAxis("bottom") + self.ax2.hideAxis("left") + # self.currentFrameLabelItem = pg.LabelItem( + # color=self.titleColor, size='13px' + # ) + self.graphLayout.addItem(self.ax2, row=1, col=2) def gui_createImg1Widgets(self): # Toggle contours/ID combobox - self.drawIDsContComboBoxSegmItems = list( - self.draw_ids_cont_combo_items() - ) + self.drawIDsContComboBoxSegmItems = [ + "Draw IDs and contours", + "Draw IDs and overlay segm. masks", + "Draw only cell cycle info", + "Draw cell cycle info and contours", + "Draw cell cycle info and overlay segm. masks", + "Draw only mother-bud lines", + "Draw only IDs", + "Draw only contours", + "Draw only overlay segm. masks", + "Draw nothing", + ] self.drawIDsContComboBox = widgets.ComboBox() self.drawIDsContComboBox.setFont(_font) self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) self.drawIDsContComboBox.setVisible(False) self.annotIDsCheckbox = widgets.CheckBox( - 'IDs', keyPressCallback=self.resetFocus) + "IDs", keyPressCallback=self.resetFocus + ) self.annotCcaInfoCheckbox = widgets.CheckBox( - 'Cell cycle info', keyPressCallback=self.resetFocus) + "Cell cycle info", keyPressCallback=self.resetFocus + ) self.annotNumZslicesCheckbox = widgets.CheckBox( - 'No. z-slices/object', keyPressCallback=self.resetFocus) + "No. z-slices/object", keyPressCallback=self.resetFocus + ) self.annotContourCheckbox = widgets.CheckBox( - 'Contours', keyPressCallback=self.resetFocus) + "Contours", keyPressCallback=self.resetFocus + ) self.annotSegmMasksCheckbox = widgets.CheckBox( - 'Segm. masks', keyPressCallback=self.resetFocus) + "Segm. masks", keyPressCallback=self.resetFocus + ) self.drawMothBudLinesCheckbox = widgets.CheckBox( - 'Only mother-daughter line', keyPressCallback=self.resetFocus + "Only mother-daughter line", keyPressCallback=self.resetFocus ) self.drawNothingCheckbox = widgets.CheckBox( - 'Do not annotate', keyPressCallback=self.resetFocus + "Do not annotate", keyPressCallback=self.resetFocus ) self.annotOptionsWidget = QWidget() @@ -112,7 +176,7 @@ def gui_createImg1Widgets(self): # Show tree info checkbox self.showTreeInfoCheckbox = widgets.CheckBox( - 'Show tree info', keyPressCallback=self.resetFocus + "Show tree info", keyPressCallback=self.resetFocus ) self.showTreeInfoCheckbox.setFont(_font) sp = self.showTreeInfoCheckbox.sizePolicy() @@ -121,23 +185,23 @@ def gui_createImg1Widgets(self): self.showTreeInfoCheckbox.hide() annotOptionsLayout.addWidget(self.showTreeInfoCheckbox) - annotOptionsLayout.addWidget(QLabel(' | ')) + annotOptionsLayout.addWidget(QLabel(" | ")) annotOptionsLayout.addWidget(self.annotIDsCheckbox) annotOptionsLayout.addWidget(self.annotCcaInfoCheckbox) annotOptionsLayout.addWidget(self.drawMothBudLinesCheckbox) annotOptionsLayout.addWidget(self.annotNumZslicesCheckbox) - annotOptionsLayout.addWidget(QLabel(' | ')) + annotOptionsLayout.addWidget(QLabel(" | ")) annotOptionsLayout.addWidget(self.annotContourCheckbox) annotOptionsLayout.addWidget(self.annotSegmMasksCheckbox) - annotOptionsLayout.addWidget(QLabel(' | ')) + annotOptionsLayout.addWidget(QLabel(" | ")) annotOptionsLayout.addWidget(self.drawNothingCheckbox) annotOptionsLayout.addWidget(self.drawIDsContComboBox) self.annotOptionsLayout = annotOptionsLayout # Toggle highlight z+-1 objects combobox self.highlightZneighObjCheckbox = widgets.CheckBox( - 'Highlight objects in neighbouring z-slices', - keyPressCallback=self.resetFocus + "Highlight objects in neighbouring z-slices", + keyPressCallback=self.resetFocus, ) self.highlightZneighObjCheckbox.setFont(_font) self.highlightZneighObjCheckbox.hide() @@ -147,38 +211,43 @@ def gui_createImg1Widgets(self): # Annotations options right image self.annotIDsCheckboxRight = widgets.CheckBox( - 'IDs', keyPressCallback=self.resetFocus) + "IDs", keyPressCallback=self.resetFocus + ) self.annotCcaInfoCheckboxRight = widgets.CheckBox( - 'Cell cycle info', keyPressCallback=self.resetFocus) + "Cell cycle info", keyPressCallback=self.resetFocus + ) self.annotNumZslicesCheckboxRight = widgets.CheckBox( - 'No. z-slices/object', keyPressCallback=self.resetFocus + "No. z-slices/object", keyPressCallback=self.resetFocus ) self.annotContourCheckboxRight = widgets.CheckBox( - 'Contours', keyPressCallback=self.resetFocus) + "Contours", keyPressCallback=self.resetFocus + ) self.annotSegmMasksCheckboxRight = widgets.CheckBox( - 'Segm. masks', keyPressCallback=self.resetFocus) + "Segm. masks", keyPressCallback=self.resetFocus + ) self.drawMothBudLinesCheckboxRight = widgets.CheckBox( - 'Only mother-daughter line', keyPressCallback=self.resetFocus + "Only mother-daughter line", keyPressCallback=self.resetFocus ) self.drawNothingCheckboxRight = widgets.CheckBox( - 'Do not annotate', keyPressCallback=self.resetFocus) + "Do not annotate", keyPressCallback=self.resetFocus + ) self.annotOptionsWidgetRight = QWidget() annotOptionsLayoutRight = QHBoxLayout() - annotOptionsLayoutRight.addWidget(QLabel(' ')) - annotOptionsLayoutRight.addWidget(QLabel(' | ')) + annotOptionsLayoutRight.addWidget(QLabel(" ")) + annotOptionsLayoutRight.addWidget(QLabel(" | ")) annotOptionsLayoutRight.addWidget(self.annotIDsCheckboxRight) annotOptionsLayoutRight.addWidget(self.annotCcaInfoCheckboxRight) annotOptionsLayoutRight.addWidget(self.drawMothBudLinesCheckboxRight) annotOptionsLayoutRight.addWidget(self.annotNumZslicesCheckboxRight) - annotOptionsLayoutRight.addWidget(QLabel(' | ')) + annotOptionsLayoutRight.addWidget(QLabel(" | ")) annotOptionsLayoutRight.addWidget(self.annotContourCheckboxRight) annotOptionsLayoutRight.addWidget(self.annotSegmMasksCheckboxRight) - annotOptionsLayoutRight.addWidget(QLabel(' | ')) + annotOptionsLayoutRight.addWidget(QLabel(" | ")) annotOptionsLayoutRight.addWidget(self.drawNothingCheckboxRight) self.annotOptionsLayoutRight = annotOptionsLayoutRight @@ -190,15 +259,15 @@ def gui_createImg1Widgets(self): self.navigateScrollBar.setMinimum(1) self.navigateScrollBar.setMaximum(1) self.navigateScrollBar.setToolTip( - 'NOTE: The maximum frame number that can be visualized with this ' - 'scrollbar\n' - 'is the last visited frame with the selected mode\n' + "NOTE: The maximum frame number that can be visualized with this " + "scrollbar\n" + "is the last visited frame with the selected mode\n" '(see "Mode" selector on the top-right).\n\n' - 'If the scrollbar does not move it means that you never visited\n' - 'any frame with current mode.\n\n' + "If the scrollbar does not move it means that you never visited\n" + "any frame with current mode.\n\n" 'Note that the "Viewer" mode allows you to scroll ALL frames.' ) - t_label = QLabel('frame n. ') + t_label = QLabel("frame n. ") t_label.setFont(_font) self.t_label = t_label @@ -207,21 +276,26 @@ def gui_createImg1Widgets(self): self.zProjComboBox = widgets.ComboBox() self.zProjComboBox.setFont(_font) - self.zProjComboBox.addItems(self.z_projection_options()) + self.zProjComboBox.addItems( + [ + "single z-slice", + "max z-projection", + "mean z-projection", + "median z-proj.", + ] + ) self.zProjLockViewButton = widgets.LockPushButton() self.zProjLockViewButton.setCheckable(True) self.zProjLockViewButton.setToolTip( - 'If active, the selected z-slice view is applied to all frames' + "If active, the selected z-slice view is applied to all frames" ) self.zProjLockViewButton.hide() self.switchPlaneCombobox = widgets.SwitchPlaneCombobox() - self.switchPlaneCombobox.setToolTip( - 'Switch viewed plane' - ) + self.switchPlaneCombobox.setToolTip("Switch viewed plane") self.zSliceOverlay_SB = widgets.ScrollBar(Qt.Horizontal) - _z_label = QLabel('Overlay z-slice ') + _z_label = QLabel("Overlay z-slice ") _z_label.setFont(_font) _z_label.setDisabled(True) self.overlay_z_label = _z_label @@ -229,17 +303,56 @@ def gui_createImg1Widgets(self): self.zProjOverlay_CB = widgets.ComboBox() self.zProjOverlay_CB.setFont(_font) self.zProjOverlay_CB.addItems( - self.overlay_z_projection_options() + [ + "single z-slice", + "max z-projection", + "mean z-projection", + "median z-proj.", + "same as above", + ] ) self.zProjOverlay_CB.setCurrentIndex(4) self.zSliceOverlay_SB.setDisabled(True) self.img1BottomGroupbox = self.gui_getImg1BottomWidgets() + def gui_createLabWidgets(self): + bottomRightLayout = QVBoxLayout() + self.rightBottomGroupbox = widgets.GroupBox( + "Annotate right image independent of left image", + keyPressCallback=self.resetFocus, + ) + self.rightBottomGroupbox.setCheckable(True) + self.rightBottomGroupbox.setChecked(False) + self.rightBottomGroupbox.hide() + + self.annotateRightHowCombobox = widgets.ComboBox() + self.annotateRightHowCombobox.setFont(_font) + self.annotateRightHowCombobox.addItems(self.drawIDsContComboBoxSegmItems) + self.annotateRightHowCombobox.setCurrentIndex( + self.drawIDsContComboBox.currentIndex() + ) + self.annotateRightHowCombobox.setVisible(False) + + self.annotOptionsLayoutRight.addWidget(self.annotateRightHowCombobox) + + self.rightImageFramesScrollbar = widgets.ScrollBarWithNumericControl( + labelText="Frame n. " + ) + self.rightImageFramesScrollbar.setVisible(False) + + bottomRightLayout.addWidget(self.annotOptionsWidgetRight) + bottomRightLayout.addWidget(self.rightImageFramesScrollbar) + bottomRightLayout.addStretch(1) + + self.rightBottomGroupbox.setLayout(bottomRightLayout) + + self.rightBottomGroupbox.toggled.connect(self.rightImageControlsToggled) + def gui_getImg1BottomWidgets(self): bottomLeftLayout = QGridLayout() self.bottomLeftLayout = bottomLeftLayout - container = QGroupBox('Navigate and annotate left image') + container = QGroupBox("Navigate and annotate left image") row = 0 bottomLeftLayout.addWidget(self.annotOptionsWidget, row, 0, 1, 4) @@ -258,27 +371,19 @@ def gui_getImg1BottomWidgets(self): self.navSpinBox = widgets.SpinBox(disableKeyPress=True) self.navSpinBox.setMinimum(1) self.navSpinBox.setMaximum(100) - self.navSizeLabel = QLabel('/ND') + self.navSizeLabel = QLabel("/ND") navWidgetsLayout.addWidget(self.t_label) navWidgetsLayout.addWidget(self.navSpinBox) navWidgetsLayout.addWidget(self.navSizeLabel) - bottomLeftLayout.addLayout( - navWidgetsLayout, row, 0, alignment=Qt.AlignRight - ) + bottomLeftLayout.addLayout(navWidgetsLayout, row, 0, alignment=Qt.AlignRight) bottomLeftLayout.addWidget(self.navigateScrollBar, row, 1, 1, 2) sp = self.navigateScrollBar.sizePolicy() sp.setRetainSizeWhenHidden(True) self.navigateScrollBar.setSizePolicy(sp) self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) - self.navSpinBox.editingFinished.connect( - self.navigateSpinboxEditingFinished - ) - self.navSpinBox.sigUpClicked.connect( - self.navigateSpinboxEditingFinished - ) - self.navSpinBox.sigDownClicked.connect( - self.navigateSpinboxEditingFinished - ) + self.navSpinBox.editingFinished.connect(self.navigateSpinboxEditingFinished) + self.navSpinBox.sigUpClicked.connect(self.navigateSpinboxEditingFinished) + self.navSpinBox.sigDownClicked.connect(self.navigateSpinboxEditingFinished) self.lastTrackedFrameLabel = QLabel() self.lastTrackedFrameLabel.setFont(_font) @@ -286,12 +391,12 @@ def gui_getImg1BottomWidgets(self): row += 1 zSliceCheckboxLayout = QHBoxLayout() - self.zSliceCheckbox = QCheckBox('z-slice') + self.zSliceCheckbox = QCheckBox("z-slice") self.zSliceSpinbox = widgets.SpinBox(disableKeyPress=True) self.zSliceSpinbox.setMinimum(1) - self.SizeZlabel = QLabel('/ND') + self.SizeZlabel = QLabel("/ND") self.zSliceCheckbox.setToolTip( - 'Activate/deactivate control of the z-slices with keyboard arrows.\n\n' + "Activate/deactivate control of the z-slices with keyboard arrows.\n\n" 'SHORTCUT to toggle ON/OFF: "Z" key' ) zSliceCheckboxLayout.addWidget(self.zSliceCheckbox) @@ -318,45 +423,27 @@ def gui_getImg1BottomWidgets(self): row += 1 self.alphaScrollbarRow = row - bottomLeftLayout.setColumnStretch(0,0) - bottomLeftLayout.setColumnStretch(1,3) - bottomLeftLayout.setColumnStretch(2,0) + bottomLeftLayout.setColumnStretch(0, 0) + bottomLeftLayout.setColumnStretch(1, 3) + bottomLeftLayout.setColumnStretch(2, 0) container.setLayout(bottomLeftLayout) return container - def gui_createLabWidgets(self): - bottomRightLayout = QVBoxLayout() - self.rightBottomGroupbox = widgets.GroupBox( - 'Annotate right image independent of left image', - keyPressCallback=self.resetFocus - ) - self.rightBottomGroupbox.setCheckable(True) - self.rightBottomGroupbox.setChecked(False) - self.rightBottomGroupbox.hide() - - self.annotateRightHowCombobox = widgets.ComboBox() - self.annotateRightHowCombobox.setFont(_font) - self.annotateRightHowCombobox.addItems(self.drawIDsContComboBoxSegmItems) - self.annotateRightHowCombobox.setCurrentIndex( - self.drawIDsContComboBox.currentIndex() - ) - self.annotateRightHowCombobox.setVisible(False) - - self.annotOptionsLayoutRight.addWidget(self.annotateRightHowCombobox) - - self.rightImageFramesScrollbar = widgets.ScrollBarWithNumericControl( - labelText='Frame n. ' - ) - self.rightImageFramesScrollbar.setVisible(False) - - bottomRightLayout.addWidget(self.annotOptionsWidgetRight) - bottomRightLayout.addWidget(self.rightImageFramesScrollbar) - bottomRightLayout.addStretch(1) + def gui_resetBottomLayoutHeight(self): + self.h = self.defaultWidgetHeightBottomLayout + self.checkBoxesHeight = 14 + self.fontPixelSize = 11 + self.resizeSlidersArea() - self.rightBottomGroupbox.setLayout(bottomRightLayout) + def resetFocus(self): + self.setFocusGraphics() + self.setFocusMain() - self.rightBottomGroupbox.toggled.connect(self.rightImageControlsToggled) + def retain_space_hidden_sliders(self, df_settings) -> bool: + if "retain_space_hidden_sliders" not in df_settings.index: + return True + return df_settings.at["retain_space_hidden_sliders", "value"] == "Yes" def rightImageControlsToggled(self, checked): if self.isDataLoading: @@ -373,78 +460,3 @@ def setFocusGraphics(self): def setFocusMain(self): # on macOS with Qt6 setFocus causes crashes. Disabled for now. return - - def resetFocus(self): - self.setFocusGraphics() - self.setFocusMain() - - def gui_createBottomWidgetsToBottomLayout(self): - # self.bottomDockWidget = QDockWidget(self) - bottomScrollArea = widgets.ScrollArea(resizeVerticalOnShow=True) - bottomScrollArea.sigLeaveEvent.connect(self.setFocusMain) - bottomWidget = QWidget() - bottomScrollAreaLayout = QVBoxLayout() - self.bottomLayout = QHBoxLayout() - self.bottomLayout.addLayout(self.quickSettingsLayout) - self.bottomLayout.addStretch(1) - self.bottomLayout.addWidget(self.img1BottomGroupbox) - self.bottomLayout.addStretch(1) - self.bottomLayout.addWidget(self.rightBottomGroupbox) - self.bottomLayout.addStretch(1) - - bottomScrollAreaLayout.addLayout(self.bottomLayout) - bottomScrollAreaLayout.addStretch(1) - - bottomWidget.setLayout(bottomScrollAreaLayout) - bottomScrollArea.setWidgetResizable(True) - bottomScrollArea.setWidget(bottomWidget) - self.bottomScrollArea = bottomScrollArea - - zoom_perc = self.bottom_layout_zoom_percent( - self.df_settings - ) - self.bottomLayoutContextMenu = QMenu('Bottom layout', self) - zoomMenu = self.bottomLayoutContextMenu.addMenu('Zoom') - actions = [] - self.bottomLayoutContextMenu.zoomActionGroup = QActionGroup(zoomMenu) - for perc in self.bottom_layout_zoom_values(): - action = QAction(f'{perc}%', zoomMenu) - action.setCheckable(True) - if perc == zoom_perc: - action.setChecked(True) - action.toggled.connect(self.zoomBottomLayoutActionTriggered) - actions.append(action) - self.bottomLayoutContextMenu.zoomActionGroup.addAction(action) - zoomMenu.addActions(actions) - resetAction = self.bottomLayoutContextMenu.addAction( - 'Reset default height' - ) - resetAction.triggered.connect(self.resizeGui) - retainSpaceAction = self.bottomLayoutContextMenu.addAction( - 'Retain space of hidden sliders' - ) - retainSpaceAction.setCheckable(True) - retainSpaceChecked = self.retain_space_hidden_sliders( - self.df_settings - ) - retainSpaceAction.setChecked(retainSpaceChecked) - retainSpaceAction.toggled.connect(self.retainSpaceSlidersToggled) - self.retainSpaceSlidersAction = retainSpaceAction - self.setBottomLayoutStretch() - - def gui_resetBottomLayoutHeight(self): - self.h = self.defaultWidgetHeightBottomLayout - self.checkBoxesHeight = 14 - self.fontPixelSize = 11 - self.resizeSlidersArea() - - def gui_createGraphicsPlots(self): - from cellacdc.canvas.qt.dual_pane import create_dual_pane - - parts = create_dual_pane(invert_bw=self.invertBwAction.isChecked()) - self.graphLayout = parts['graphLayout'] - self.ax1 = parts['ax1'] - self.ax2 = parts['ax2'] - self.lutItemsLayout = parts['lutItemsLayout'] - self.titleColor = parts['titleColor'] - self.plotsCol = 1 \ No newline at end of file diff --git a/cellacdc/mixins/image_display.py b/cellacdc/mixins/image_display.py index af6cb9262..b1fefe6d9 100644 --- a/cellacdc/mixins/image_display.py +++ b/cellacdc/mixins/image_display.py @@ -6,8 +6,6 @@ import numpy as np import pyqtgraph as pg -from dataclasses import dataclass -from typing import Literal import skimage.exposure import skimage.measure from qtpy.QtCore import QTimer @@ -24,383 +22,117 @@ ) -class ImageDisplayView: +class ImageDisplayMixin: """Qt-facing adapter for image display, LUT, and cursor workflows.""" """Headless display settings and image-display rules.""" - def right_pane_visibility_plan( - self, - mode: RightPaneMode, - checked: bool, - ) -> RightPaneVisibilityPlan: - settings_updates = { - 'isNextFrameVisible': 'No', - 'isRightImageVisible': 'No', - 'isLabelsVisible': 'No', - } - if checked: - setting_key = { - 'next_frame': 'isNextFrameVisible', - 'right_image': 'isRightImageVisible', - 'labels': 'isLabelsVisible', - }[mode] - settings_updates[setting_key] = 'Yes' - - return RightPaneVisibilityPlan( - mode=mode, - checked=checked, - settings_updates=settings_updates, - ) - - def invert_bw_setting_value(self, checked: bool) -> str: - return 'Yes' if checked else 'No' - - def labels_alpha_plan( - self, - value: float, - *, - keep_ids_checked: bool, - ) -> LabelsAlphaPlan: - opacity = value / 3 if keep_ids_checked else value - return LabelsAlphaPlan(setting_value=value, opacity=opacity) - - def intensity_normalization_setting_value(self, how: str) -> str: - return how - - def rescale_intensity_setting_update( - self, - channel: str, - how: str, - ) -> tuple[str, str]: - return f'how_rescale_intensities_{channel}', how - - - LEGACY_METHODS = ( - 'getDisplayedImg1', - 'getDisplayedZstack', - 'getObjBbox', - 'z_lab', - 'get_2Dlab', - 'get_2Drp', - 'set_2Dlab', - 'setTextAnnotZsliceScrolling', - 'setGraphicalAnnotZsliceScrolling', - 'initContoursImage', - 'initLostObjContoursImage', - 'initLostTrackedObjContoursImage', - 'initManualBackgroundImage', - 'initImgCmap', - 'initTextAnnot', - 'zoomOut', - 'zoomToObjsActionCallback', - 'zoomToCells', - 'equalizeHist', - 'getDistantGray', - 'RGBtoGray', - 'ruler_cb', - 'editImgProperties', - 'setTwoImagesLayout', - 'showNextFrameImageItem', - 'showRightImageItem', - 'showLabelImageItem', - 'setAnnotOptionsRightImageLabelsDisabled', - 'setBottomLayoutStretch', - 'setHoverToolSymbolData', - 'getCheckNormAction', - 'normalizeIntensities', - 'invertBw', - 'setCheckedInvertBW', - 'updateImageValueFormatter', - 'getImageDataFromFilename', - 'z_slice_index', - 'get_2Dimg_from_3D', - 'updateZsliceScrollbar', - 'getRawImage', - 'getRawImageLayer0', - 'getImage', - 'updateLabelsAlpha', - '_getImageupdateAllImages', - 'setImageImg1', - 'setImageImg2', - 'getObject2DimageFromZ', - 'getObject2DsliceFromZ', - 'isObjVisible', - 'getObjImage', - 'getObjSlice', - 'getContoursImageItem', - 'getLostObjImageItem', - 'getLostTrackedObjImageItem', - 'normaliseIntensitiesActionTriggered', - 'setLastUserNormAction', - 'saveLabelsColormap', - 'addFontSizeActions', - 'changeFontSize', - 'enableZstackWidgets', - 'launchSlideshow', - 'setMirroredCursorFromSecondWindow', - 'zProjLockViewToggled', - 'rescaleIntensExportToVideoDialog', - 'customLevelsLutChanged', - 'getPreComputedMinMaxZstack', - 'rescaleIntensitiesLut', - 'showMirroredCursorToggled', - 'clearCursors', - 'activeEraserCircleCursors', - 'activeEraserXCursors', - 'activeBrushCircleCursors', - 'initImgGradRescaleIntensitiesHowPreference', - 'updateAllImages', - 'removeAxLimits', - 'resizeGui', - 'autoRange', - 'resetRange', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - # @exec_time - def getDisplayedImg1(self): - return self.img1.image - def getDisplayedZstack(self): - posData = self.data[self.pos_i] - return posData.img_data[posData.frame_i] - - def getObjBbox(self, obj_bbox): - if self.isSegm3D and len(obj_bbox)==6: - obj_bbox = (obj_bbox[1], obj_bbox[2], obj_bbox[4], obj_bbox[5]) - return obj_bbox - else: - return obj_bbox - - def z_lab(self, checkIfProj=False): - if checkIfProj and self.zProjComboBox.currentText() != 'single z-slice': - return - - if not self.isSegm3D: - return - - posData = self.data[self.pos_i] - - idx = self.zSliceScrollBar.sliderPosition() - - # ensure idx doesnt exceed the number of z-slices of the position - idx_z = min(idx, posData.SizeZ-1) - - if not self.switchPlaneCombobox.isEnabled(): - return idx_z + # @exec_time - depthAxes = self.switchPlaneCombobox.depthAxes() - if depthAxes == 'z': - return idx_z - elif depthAxes == 'y': - idx_y = min(idx, posData.SizeY-1) - return (slice(None), idx_y) - else: - idx_x = min(idx, posData.SizeX-1) - return (slice(None), slice(None), idx_x) + # @exec_time - def get_2Dlab(self, lab, force_z=True): - if self.isSegm3D: - if force_z: - return lab[self.z_lab()] - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - return lab[self.z_lab()] - else: - return lab.max(axis=0) + def RGBtoGray(self, R, G, B): + # see https://stackoverflow.com/questions/17615963/standard-rgb-to-grayscale-conversion + C_linear = (0.2126 * R + 0.7152 * G + 0.0722 * B) / 255 + if C_linear <= 0.0031309: + gray = 12.92 * C_linear else: - return lab + gray = 1.055 * (C_linear) ** (1 / 2.4) - 0.055 + return gray - def get_2Drp(self, lab=None): - if self.isSegm3D: - if lab is None: - # self.currentLab2D is defined at self.setImageImg2() - lab = self.currentLab2D - lab = self.get_2Dlab(lab) - rp = skimage.measure.regionprops(lab) - return rp - else: - return self.data[self.pos_i].rp + def _getImageupdateAllImages(self, image=None): + if image is not None: + return image - def set_2Dlab(self, lab2D, lab3D=None): - posData = self.data[self.pos_i] + img = self.getImage() + return img - if lab3D is None: - lab3D = posData.lab + def activeBrushCircleCursors(self, isHoverImg1): + if self.showMirroredCursorAction.isChecked(): + return self.ax1_BrushCircle, self.ax2_BrushCircle - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - lab3D[self.z_lab()] = lab2D - else: - lab3D[:] = lab2D + if isHoverImg1: + return (self.ax1_BrushCircle,) else: - if lab3D.shape == lab2D.shape: - lab3D[...] = lab2D - else: - posData.lab = lab2D + return (self.ax2_BrushCircle,) - def setTextAnnotZsliceScrolling(self): - pass + def activeEraserCircleCursors(self, isHoverImg1): + if self.showMirroredCursorAction.isChecked(): + return self.ax1_EraserCircle, self.ax2_EraserCircle - def setGraphicalAnnotZsliceScrolling(self): - posData = self.data[self.pos_i] - if self.isSegm3D: - self.currentLab2D = posData.lab[self.z_lab()] - self.setOverlaySegmMasks() - self.custom_annotations_view.doCustomAnnotation(0) - self.update_rp_metadata() + if isHoverImg1: + return (self.ax1_EraserCircle,) else: - self.currentLab2D = posData.lab - self.setOverlaySegmMasks() - self.updateContoursImage(0) - self.updateContoursImage(1) + return (self.ax2_EraserCircle,) - def initContoursImage(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.contoursImage = np.zeros((Y, X, 4), dtype=np.uint8) - - def initLostObjContoursImage(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.lostObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) - - def initLostTrackedObjContoursImage(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.lostTrackedObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) + def activeEraserXCursors(self, isHoverImg1): + if self.showMirroredCursorAction.isChecked(): + return self.ax1_EraserX, self.ax2_EraserX - def initManualBackgroundImage(self): - posData = self.data[self.pos_i] - if hasattr(posData, 'lab'): - Y, X = posData.lab.shape[-2:] + if isHoverImg1: + return (self.ax1_EraserX,) else: - Y, X = posData.img_data.shape[-2:] - if not hasattr(self, 'manualBackgroundTextItems'): - self.manualBackgroundTextItems = {} - posData.manualBackgroundImage = np.zeros((Y, X, 4), dtype=np.uint8) - if posData.manualBackgroundLab is None: - posData.manualBackgroundLab = np.zeros((Y, X), dtype=np.uint32) + return (self.ax2_EraserX,) - def initImgCmap(self): - if not 'img_cmap' in self.df_settings.index: - self.df_settings.at['img_cmap', 'value'] = 'grey' - self.imgCmapName = self.df_settings.at['img_cmap', 'value'] - self.imgCmap = self.imgGrad.cmaps[self.imgCmapName] - if self.imgCmapName != 'grey': - # To ensure mapping to colors we need to normalize image - self.normalizeByMaxAction.setChecked(True) - - def initTextAnnot(self, force=False): - posData = self.data[self.pos_i] - if hasattr(posData, 'lab'): - Y, X = posData.lab.shape[-2:] - else: - Y, X = posData.img_data.shape[-2:] - self.textAnnot[0].initItem((Y, X)) - self.textAnnot[1].initItem((Y, X)) + def addFontSizeActions(self, menu, slot): + fontActionGroup = QActionGroup(self) + fontActionGroup.setExclusive(True) + for fontSize in range(4, 27): + action = QAction(self) + action.setText(str(fontSize)) + action.setCheckable(True) + if fontSize == self.fontSize: + action.setChecked(True) + fontActionGroup.addAction(action) + menu.addAction(action) + action.triggered.connect(slot) + return fontActionGroup - def zoomOut(self): + def autoRange(self): + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.ax2.autoRange() self.ax1.autoRange() - def zoomToObjsActionCallback(self): - self.zoomToCells(enforce=True) - - def zoomToCells(self, enforce=False): - if not self.enableAutoZoomToCellsAction.isChecked() and not enforce: + @exception_handler + def changeFontSize(self): + fontSize = self.fontSizeSpinBox.value() + if fontSize == self.fontSize: return - posData = self.data[self.pos_i] - lab_mask = (self.currentLab2D>0).astype(np.uint8) - rp = skimage.measure.regionprops(lab_mask) - if not rp: - Y, X = lab_mask.shape - xRange = -0.5, X+0.5 - yRange = -0.5, Y+0.5 - else: - obj = rp[0] - min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) - xRange = min_col-10, max_col+10 - yRange = max_row+10, min_row-10 - - self.ax1.setRange(xRange=xRange, yRange=yRange) + self.fontSize = fontSize - @disableWindow - def equalizeHist(self, checked=True): - self.img1.useEqualized = checked + self.df_settings.at["fontSize", "value"] = self.fontSize + self.df_settings.to_csv(self.settings_csv_path) - if not checked: + self.setAllIDs() + self.data[self.pos_i] + for ax in range(2): + self.textAnnot[ax].changeFontSize(self.fontSize) + if self.highLowResAction.isChecked(): + self.setAllTextAnnotations() + else: self.updateAllImages() - return - - self.logger.info('Equalizing image histogram...') - for pos_i, _posData in enumerate(self.data): - n_dim_img = _posData.img_data.ndim - _posData.equalized_img_data = ( - self.preprocessing.create_preprocessed_data() - ) - for frame_i, img_frame in enumerate(_posData.img_data): - if n_dim_img == 4: - for z, img_z in enumerate(img_frame): - eq_img = skimage.exposure.equalize_adapthist(img_z) - _posData.equalized_img_data[frame_i][z] = eq_img - self.img1.updateMinMaxValuesEqualizedData( - self.data, pos_i, frame_i, z - ) - self.img1.updateMinMaxValuesEqualizedDataProjections( - self.data, pos_i, frame_i - ) - else: - eq_img = skimage.exposure.equalize_adapthist(img_frame) - _posData.equalized_img_data[frame_i] = eq_img - self.img1.updateMinMaxValuesEqualizedData( - self.data, pos_i, frame_i, None - ) - - self.updateAllImages() - - def getDistantGray(self, desiredGray, bkgrGray): - return self.formatting.distant_gray(desiredGray, bkgrGray) - def RGBtoGray(self, R, G, B): - return self.formatting.rgb_to_gray(R, G, B) + def clearCursors(self): + self.ax1_cursor.setData([], []) + self.ax2_cursor.setData([], []) + self.setHoverToolSymbolData( + [], + [], + (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) + eraserCursors = ( + self.ax1_EraserCircle, + self.ax2_EraserCircle, + self.ax1_EraserX, + self.ax2_EraserX, + ) + self.setHoverToolSymbolData([], [], eraserCursors) - def ruler_cb(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - self.connectLeftClickButtons() - else: - self.tempSegmentON = False - self.ax1_rulerPlotItem.setData([], []) - self.ax1_rulerAnchorsItem.setData([], []) + def customLevelsLutChanged(self, levels, imageItem=None): + imageItem.setLevels(levels) def editImgProperties(self, checked=True): posData = self.data[self.pos_i] @@ -409,176 +141,90 @@ def editImgProperties(self, checked=True): ask_SizeT=True, ask_TimeIncrement=True, ask_PhysicalSizes=True, - save=True, singlePos=True, - askSegm3D=False + save=True, + singlePos=True, + askSegm3D=False, ) - if hasattr(self, 'timestamp'): + if hasattr(self, "timestamp"): self.timestamp.setSecondsPerFrame(posData.TimeIncrement) - self.display_decorations_view.update_timestamp_frame() + self.updateTimestampFrame() - if hasattr(self, 'scaleBar'): + if hasattr(self, "scaleBar"): self.scaleBar.updatePhysicalLength(posData.PhysicalSizeX) - def setTwoImagesLayout(self, isTwoImages): - self.isTwoImageLayout = isTwoImages - if isTwoImages: - self.graphLayout.removeItem(self.titleLabel) - self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) - # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignLeft) - self.ax2.show() - self.ax2.vb.setYLink(self.ax1.vb) - self.ax2.vb.setXLink(self.ax1.vb) - else: - self.graphLayout.removeItem(self.titleLabel) - self.graphLayout.addItem(self.titleLabel, row=0, col=1) - # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignCenter) - self.ax2.hide() - oldLink = self.ax2.vb.linkedView(self.ax1.vb.YAxis) - try: - oldLink.sigYRangeChanged.disconnect() - oldLink.sigXRangeChanged.disconnect() - except TypeError: - pass - - def showNextFrameImageItem(self, checked): - plan = self.right_pane_visibility_plan( - 'next_frame', checked - ) - self.rightImageFramesScrollbar.setVisible(checked) - self.rightImageFramesScrollbar.setDisabled(not checked) - self.setTwoImagesLayout(checked) - if checked: - self.graphLayout.addItem( - self.imgGradRight, row=1, col=self.plotsCol+2 - ) - self.rightBottomGroupbox.show() - self.rightBottomGroupbox.setChecked(True) - self.drawNothingCheckboxRight.click() - if not self.isDataLoading: - self.updateAllImages() - else: - self.clearAx2Items() - self.rightBottomGroupbox.hide() - try: - self.graphLayout.removeItem(self.imgGradRight) - except Exception: - return - self.rightImageItem.clear() - - for setting, value in plan.settings_updates.items(): - self.df_settings.at[setting, 'value'] = value - self.df_settings.to_csv(self.settings_csv_path) - - QTimer.singleShot(300, self.resizeGui) - - self.setBottomLayoutStretch() - - def showRightImageItem(self, checked): - plan = self.right_pane_visibility_plan( - 'right_image', checked - ) - self.rightImageFramesScrollbar.setVisible(not checked) - self.rightImageFramesScrollbar.setDisabled(checked) - self.setTwoImagesLayout(checked) - if checked: - self.graphLayout.addItem( - self.imgGradRight, row=1, col=self.plotsCol+2 - ) - self.rightBottomGroupbox.show() - if not self.isDataLoading: - self.updateAllImages() - else: - self.clearAx2Items() - self.rightBottomGroupbox.hide() - try: - self.graphLayout.removeItem(self.imgGradRight) - except Exception: - return - self.rightImageItem.clear() - - for setting, value in plan.settings_updates.items(): - self.df_settings.at[setting, 'value'] = value - self.df_settings.to_csv(self.settings_csv_path) - - QTimer.singleShot(300, self.resizeGui) - - self.setBottomLayoutStretch() - - def showLabelImageItem(self, checked): - plan = self.right_pane_visibility_plan('labels', checked) - self.rightImageFramesScrollbar.setVisible(not checked) - self.rightImageFramesScrollbar.setDisabled(checked) - self.setTwoImagesLayout(checked) - self.setAnnotOptionsRightImageLabelsDisabled(checked) - if checked: - self.rightBottomGroupbox.show() - self.rightBottomGroupbox.setChecked(True) - if not self.isDataLoading: - self.updateAllImages() + def enableZstackWidgets(self, enabled): + if enabled: + myutils.setRetainSizePolicy(self.zSliceScrollBar) + myutils.setRetainSizePolicy(self.zProjComboBox) + myutils.setRetainSizePolicy(self.zSliceOverlay_SB) + myutils.setRetainSizePolicy(self.zProjOverlay_CB) + myutils.setRetainSizePolicy(self.overlay_z_label) + self.zSliceScrollBar.setDisabled(False) + self.zProjComboBox.show() + if self.data[self.pos_i].SizeT > 1: + self.zProjLockViewButton.show() + self.zSliceScrollBar.show() + self.zSliceCheckbox.show() + self.zSliceSpinbox.show() + self.switchPlaneCombobox.show() + self.switchPlaneCombobox.setDisabled(False) + self.SizeZlabel.show() else: - self.clearAx2Items() - self.img2.clear() - self.rightBottomGroupbox.hide() - self.moveDelRoisToLeft() - - for setting, value in plan.settings_updates.items(): - self.df_settings.at[setting, 'value'] = value - self.df_settings.to_csv(self.settings_csv_path) - QTimer.singleShot(200, self.resizeGui) - - self.setBottomLayoutStretch() + myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=False) + myutils.setRetainSizePolicy(self.zProjComboBox, retain=False) + myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=False) + myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=False) + myutils.setRetainSizePolicy(self.overlay_z_label, retain=False) + self.zSliceScrollBar.setDisabled(True) + self.zProjComboBox.hide() + self.zProjComboBox.hide() + self.zSliceScrollBar.hide() + self.zSliceCheckbox.hide() + self.zSliceSpinbox.hide() + self.SizeZlabel.hide() + self.switchPlaneCombobox.hide() + self.switchPlaneCombobox.setDisabled(True) - def setAnnotOptionsRightImageLabelsDisabled(self, disabled): - self.annotContourCheckboxRight.setDisabled(disabled) - self.annotSegmMasksCheckboxRight.setDisabled(disabled) - if disabled: - self.annotSegmMasksCheckboxRight.setChecked(False) - self.annotSegmMasksCheckboxRight.setChecked(False) - self.annotIDsCheckboxRight.setChecked(True) + self.imgGrad.rescaleAcrossZstackAction.setDisabled(not enabled) + for ch, overlayItems in self.overlayLayersItems.items(): + lutItem = overlayItems[1] + lutItem.rescaleAcrossZstackAction.setDisabled(not enabled) - def setBottomLayoutStretch(self): - if ( - self.labelsGrad.showRightImgAction.isChecked() - or self.labelsGrad.showNextFrameAction.isChecked() - ): - # Equally share space between the two control groupboxes - self.bottomLayout.setStretch(1, 1) - self.bottomLayout.setStretch(2, 5) - self.bottomLayout.setStretch(3, 1) - self.bottomLayout.setStretch(4, 5) - self.bottomLayout.setStretch(5, 1) - elif self.labelsGrad.showLabelsImgAction.isChecked(): - # Left control takes only left space - self.bottomLayout.setStretch(1, 1) - self.bottomLayout.setStretch(2, 5) - self.bottomLayout.setStretch(3, 5) - self.bottomLayout.setStretch(4, 1) - self.bottomLayout.setStretch(5, 1) - else: - # Left control takes all the space - self.bottomLayout.setStretch(1, 3) - self.bottomLayout.setStretch(2, 10) - self.bottomLayout.setStretch(3, 1) - self.bottomLayout.setStretch(4, 1) - self.bottomLayout.setStretch(5, 1) + @disableWindow + def equalizeHist(self, checked=True): + self.img1.useEqualized = checked - def setHoverToolSymbolData(self, xx, yy, ScatterItems, size=None): - if not xx: - self.ax1_lostObjScatterItem.setVisible(True) - self.ax2_lostObjScatterItem.setVisible(True) + if not checked: + self.updateAllImages() + return - self.ax1_lostTrackedScatterItem.setVisible(True) - self.ax2_lostTrackedScatterItem.setVisible(True) + self.logger.info("Equalizing image histogram...") + for pos_i, _posData in enumerate(self.data): + n_dim_img = _posData.img_data.ndim + _posData.equalized_img_data = preprocess.PreprocessedData() + for frame_i, img_frame in enumerate(_posData.img_data): + if n_dim_img == 4: + for z, img_z in enumerate(img_frame): + eq_img = skimage.exposure.equalize_adapthist(img_z) + _posData.equalized_img_data[frame_i][z] = eq_img + self.img1.updateMinMaxValuesEqualizedData( + self.data, pos_i, frame_i, z + ) + self.img1.updateMinMaxValuesEqualizedDataProjections( + self.data, pos_i, frame_i + ) + else: + eq_img = skimage.exposure.equalize_adapthist(img_frame) + _posData.equalized_img_data[frame_i] = eq_img + self.img1.updateMinMaxValuesEqualizedData( + self.data, pos_i, frame_i, None + ) - for item in ScatterItems: - if size is None: - item.setData(xx, yy) - else: - item.setData(xx, yy, size=size) + self.updateAllImages() def getCheckNormAction(self): normalize = False - how = '' + how = "" for action in self.normalizeQActionGroup.actions(): if action.isChecked(): how = action.text() @@ -586,108 +232,86 @@ def getCheckNormAction(self): break return action, normalize, how - def normalizeIntensities(self, img): - action, normalize, how = self.getCheckNormAction() - if not normalize: - return img + def getContoursImageItem(self, ax, force=False): + if not self.areContoursRequested(ax) and not force: + return - return self.preprocessing.normalize_display_image(img, how) + if ax == 0: + return self.ax1_contoursImageItem + else: + return self.ax2_contoursImageItem - def invertBw(self, checked, update=True): - self.invertBwAlreadyCalledOnce = True + def getDisplayedImg1(self): + return self.img1.image - try: - self.labelsGrad.invertBwAction.toggled.disconnect() - except Exception as err: - pass + def getDisplayedZstack(self): + posData = self.data[self.pos_i] + return posData.img_data[posData.frame_i] - self.labelsGrad.invertBwAction.setChecked(checked) - self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + def getDistantGray(self, desiredGray, bkgrGray): + isDesiredSimilarToBkgr = abs(desiredGray - bkgrGray) < 0.3 + if isDesiredSimilarToBkgr: + return 1 - desiredGray + else: + return desiredGray - try: - self.imgGrad.invertBwAction.toggled.disconnect() - except Exception as err: - pass - self.imgGrad.invertBwAction.setChecked(checked) - self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + def getImage(self, frame_i=None, raw=False): + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i - self.imgGrad.setInvertedColorMaps(checked) - self.imgGrad.invertCurrentColormap(checked) + if raw: + return self.getRawImageLayer0(frame_i) - self.imgGradRight.setInvertedColorMaps(checked) - self.imgGradRight.invertCurrentColormap(checked) + if self.viewPreprocDataToggle.isChecked(): + try: + img = posData.preproc_img_data[frame_i] + if posData.SizeZ == 1: + return np.array(img) - if hasattr(self, 'overlayLayersItems'): - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.invertBwAction.toggled.disconnect() - lutItem.invertBwAction.setChecked(checked) - lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW) - lutItem.setInvertedColorMaps(checked) + self.updateZsliceScrollbar(frame_i) + z_slice = self.z_slice_index() + img = img[z_slice] + return img + except Exception: + # self.logger.warning( + # 'Pre-processed image not existing --> returning raw image' + # ) + return self.getRawImageLayer0(frame_i) - if self.slideshowWin is not None: - self.slideshowWin.is_bw_inverted = checked - self.slideshowWin.update_img() - self.df_settings.at['is_bw_inverted', 'value'] = ( - self.invert_bw_setting_value(checked) + viewCombinedImageData = ( + self.viewCombineChannelDataToggle.isChecked() + and self.combineDialog is not None + and not self.combineDialog.saveAsSegm() ) - self.df_settings.to_csv(self.settings_csv_path) - if checked: - # Light mode - self.equalizeHistPushButton.setStyleSheet('') - self.graphLayout.setBackground(graphLayoutBkgrColor) - self.ax2_BrushCirclePen = pg.mkPen((150,150,150), width=2) - self.ax2_BrushCircleBrush = pg.mkBrush((200,200,200,150)) - self.titleColor = 'black' - else: - # Dark mode - self.equalizeHistPushButton.setStyleSheet( - 'QPushButton {background-color: #282828; color: #F0F0F0;}' - ) - self.graphLayout.setBackground(darkBkgrColor) - self.ax2_BrushCirclePen = pg.mkPen(width=2) - self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50)) - self.titleColor = 'white' - - if not hasattr(self, 'textAnnot'): - return - self.textAnnot[0].invertBlackAndWhite() - self.textAnnot[1].invertBlackAndWhite() - - self.objLabelAnnotRgb = tuple( - self.textAnnot[0].item.colors()['label'][:3] - ) - self.textIDsColorButton.setColor(self.objLabelAnnotRgb) - self.imgGrad.textColorButton.setColor(self.objLabelAnnotRgb) - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.textColorButton.setColor(self.objLabelAnnotRgb) + if viewCombinedImageData: + try: + img = posData.combine_img_data[frame_i] + if posData.SizeZ == 1: + return np.array(img) - if update: - self.updateAllImages() + self.updateZsliceScrollbar(frame_i) + z_slice = self.z_slice_index() + img = img[z_slice] + return img + except Exception: + # self.logger.warning( + # 'combined image not existing --> returning raw image' + # ) + return self.getRawImageLayer0(frame_i) - def setCheckedInvertBW(self, checked): - self.invertBwAction.setChecked(checked) + if self.equalizeHistPushButton.isChecked(): + img = posData.equalized_img_data[frame_i] + if posData.SizeZ == 1: + return np.array(img) - def updateImageValueFormatter(self): - if self.img1.image is not None: - dtype = self.img1.image.dtype - n_digits = len(str(int(self.img1.image.max()))) - self.imgValueFormatter = ( - self.formatting.number_fstring_formatter( - dtype, precision=abs(n_digits-5) - ) - ) + self.updateZsliceScrollbar(frame_i) + z_slice = self.z_slice_index() + img = img[z_slice] + return img - rawImgData = self.data[self.pos_i].img_data - dtype = rawImgData.dtype - n_digits = len(str(int(rawImgData.max()))) - self.rawValueFormatter = ( - self.formatting.number_fstring_formatter( - dtype, precision=abs(n_digits-5) - ) - ) + return self.getRawImageLayer0(frame_i) def getImageDataFromFilename(self, filename): posData = self.data[self.pos_i] @@ -696,101 +320,90 @@ def getImageDataFromFilename(self, filename): else: return posData.ol_data_dict.get(filename) - def z_slice_index(self): - posData = self.data[self.pos_i] - if posData.SizeZ == 1: - return None - zProjHow = self.zProjComboBox.currentText() - if zProjHow != 'single z-slice': - return zProjHow - - axis_slice = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == 'x': - z_slice = ( - slice(None, None, None), slice(None, None, None), axis_slice - ) - elif self.switchPlaneCombobox.depthAxes() == 'y': - z_slice = ( - slice(None, None, None), axis_slice - ) + def getLostObjImageItem(self, ax): + if ax == 0: + return self.ax1_lostObjImageItem else: - z_slice = axis_slice + return self.ax1_lostTrackedObjImageItem - return z_slice + def getLostTrackedObjImageItem(self, ax): + if ax == 0: + return self.ax1_lostTrackedObjImageItem + else: + return self.ax2_lostTrackedObjImageItem - def get_2Dimg_from_3D(self, imgData, isLayer0=True, frame_i=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - if frame_i < 0: - frame_i = 0 - frame_i = posData.frame_i = 0 + def getObjBbox(self, obj_bbox): + if self.isSegm3D and len(obj_bbox) == 6: + obj_bbox = (obj_bbox[1], obj_bbox[2], obj_bbox[4], obj_bbox[5]) + return obj_bbox + else: + return obj_bbox - axis_slice = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == 'x': - return imgData[:, :, axis_slice].copy() - elif self.switchPlaneCombobox.depthAxes() == 'y': - return imgData[:, axis_slice].copy() + def getObjImage(self, obj_image, obj_bbox, z_slice=None): + if self.isSegm3D and len(obj_bbox) == 6: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == "single z-slice" + if not isZslice: + # required a projection + return obj_image.max(axis=0) - idx = (posData.filename, frame_i) - zProjHow_L0 = self.zProjComboBox.currentText() - if isLayer0: + min_z = obj_bbox[0] + if z_slice is None: + z_slice = self.z_lab() + if isinstance(z_slice, tuple): + z_slice = z_slice[-1] + + local_z = z_slice - min_z try: - z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] - except ValueError as e: - z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] - zProjHow = zProjHow_L0 + obi_image_2d = obj_image[local_z] + except Exception: + obi_image_2d = None + return obi_image_2d else: - z = self.zSliceOverlay_SB.sliderPosition() - zProjHow_L1 = self.zProjOverlay_CB.currentText() - if zProjHow_L1 == 'same as above': - zProjHow = zProjHow_L0 - else: - zProjHow = zProjHow_L1 + return obj_image - if zProjHow == 'single z-slice': - img = imgData[z] #.copy() - elif zProjHow == 'max z-projection': - img = imgData.max(axis=0) - elif zProjHow == 'mean z-projection': - img = imgData.mean(axis=0) - elif zProjHow == 'median z-proj.': - img = np.median(imgData, axis=0) - return img + def getObjSlice(self, obj_slice): + if self.isSegm3D: + return obj_slice[1:3] + else: + return obj_slice - def updateZsliceScrollbar(self, frame_i): + def getObject2DimageFromZ(self, z, obj): posData = self.data[self.pos_i] - if self.switchPlaneCombobox.depthAxes() != 'z': + z_min = obj.bbox[0] + local_z = z - z_min + if local_z >= posData.SizeZ or local_z < 0: return + return obj.image[local_z] - idx = (posData.filename, frame_i) - try: - z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] - except ValueError as e: - z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] - try: - zProjHow = posData.segmInfo_df.at[idx, 'which_z_proj_gui'] - except ValueError as e: - zProjHow = posData.segmInfo_df.loc[idx, 'which_z_proj_gui'].iloc[0] + def getObject2DsliceFromZ(self, z, obj): + posData = self.data[self.pos_i] + z_min = obj.bbox[0] + local_z = z - z_min + if local_z >= posData.SizeZ or local_z < 0: + return + return obj.image[local_z] - self.zProjComboBox.setCurrentText(zProjHow) + def getPreComputedMinMaxZstack(self, channel: str): + if channel != self.user_ch_name: + return None - reconnect = False - try: - self.zSliceScrollBar.actionTriggered.disconnect() - self.zSliceScrollBar.sliderReleased.disconnect() - reconnect = True - except TypeError: - pass - self.zSliceScrollBar.setSliderPosition(z) - if reconnect: - self.zSliceScrollBar.actionTriggered.connect( - self.zSliceScrollBarActionTriggered - ) - self.zSliceScrollBar.sliderReleased.connect( - self.zSliceScrollBarReleased - ) - self.zSliceSpinbox.setValueNoEmit(z+1) + posData = self.data[self.pos_i] + zstack_min, zstack_max = np.inf, 0 + for z in range(posData.SizeZ): + key = (self.pos_i, posData.frame_i, z) + levels = self.img1.minMaxValuesMapper.get(key) + if levels is None: + return + + img_min, img_max = levels + if img_min < zstack_min: + zstack_min = img_min + + if img_max > zstack_max: + zstack_max = img_max + + return (zstack_min, zstack_max) def getRawImage(self, frame_i=None, filename=None): posData = self.data[self.pos_i] @@ -824,143 +437,214 @@ def getRawImageLayer0(self, frame_i): return img raise ValueError( - 'Raw image for display must be 2D (Y, X) or RGB/A (Y, X, 3 or 4); ' - f'got shape={getattr(img, "shape", None)}, ndim={getattr(img, "ndim", None)} ' - f'for frame_i={frame_i} (metadata SizeT={posData.SizeT}, SizeZ={posData.SizeZ}). ' - 'Check that metadata SizeT/SizeZ matches the loaded array (e.g. squeezed TIFF vs CSV).' + "Raw image for display must be 2D (Y, X) or RGB/A (Y, X, 3 or 4); " + f"got shape={getattr(img, 'shape', None)}, ndim={getattr(img, 'ndim', None)} " + f"for frame_i={frame_i} (metadata SizeT={posData.SizeT}, SizeZ={posData.SizeZ}). " + "Check that metadata SizeT/SizeZ matches the loaded array (e.g. squeezed TIFF vs CSV)." ) - def getImage(self, frame_i=None, raw=False): + def get_2Dimg_from_3D(self, imgData, isLayer0=True, frame_i=None): posData = self.data[self.pos_i] if frame_i is None: frame_i = posData.frame_i + if frame_i < 0: + frame_i = 0 + frame_i = posData.frame_i = 0 - if raw: - return self.getRawImageLayer0(frame_i) + axis_slice = self.zSliceScrollBar.sliderPosition() + if self.switchPlaneCombobox.depthAxes() == "x": + return imgData[:, :, axis_slice].copy() + elif self.switchPlaneCombobox.depthAxes() == "y": + return imgData[:, axis_slice].copy() - if self.viewPreprocDataToggle.isChecked(): + idx = (posData.filename, frame_i) + zProjHow_L0 = self.zProjComboBox.currentText() + if isLayer0: try: - img = posData.preproc_img_data[frame_i] - if posData.SizeZ == 1: - return np.array(img) + z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] + except ValueError: + z = posData.segmInfo_df.loc[idx, "z_slice_used_gui"].iloc[0] + zProjHow = zProjHow_L0 + else: + z = self.zSliceOverlay_SB.sliderPosition() + zProjHow_L1 = self.zProjOverlay_CB.currentText() + if zProjHow_L1 == "same as above": + zProjHow = zProjHow_L0 + else: + zProjHow = zProjHow_L1 - self.updateZsliceScrollbar(frame_i) - z_slice = self.z_slice_index() - img = img[z_slice] - return img - except Exception as err: - # self.logger.warning( - # 'Pre-processed image not existing --> returning raw image' - # ) - return self.getRawImageLayer0(frame_i) + if zProjHow == "single z-slice": + img = imgData[z] # .copy() + elif zProjHow == "max z-projection": + img = imgData.max(axis=0) + elif zProjHow == "mean z-projection": + img = imgData.mean(axis=0) + elif zProjHow == "median z-proj.": + img = np.median(imgData, axis=0) + return img - viewCombinedImageData = ( - self.viewCombineChannelDataToggle.isChecked() - and self.combineDialog is not None - and not self.combineDialog.saveAsSegm() - ) + def get_2Dlab(self, lab, force_z=True): + if self.isSegm3D: + if force_z: + return lab[self.z_lab()] + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == "single z-slice" + if isZslice: + return lab[self.z_lab()] + else: + return lab.max(axis=0) + else: + return lab - if viewCombinedImageData: - try: - img = posData.combine_img_data[frame_i] - if posData.SizeZ == 1: - return np.array(img) + def get_2Drp(self, lab=None): + if self.isSegm3D: + if lab is None: + # self.currentLab2D is defined at self.setImageImg2() + lab = self.currentLab2D + lab = self.get_2Dlab(lab) + rp = skimage.measure.regionprops(lab) + return rp + else: + return self.data[self.pos_i].rp - self.updateZsliceScrollbar(frame_i) - z_slice = self.z_slice_index() - img = img[z_slice] - return img - except Exception as err: - # self.logger.warning( - # 'combined image not existing --> returning raw image' - # ) - return self.getRawImageLayer0(frame_i) + def initContoursImage(self): + posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] - if self.equalizeHistPushButton.isChecked(): - img = posData.equalized_img_data[frame_i] - if posData.SizeZ == 1: - return np.array(img) + self.contoursImage = np.zeros((Y, X, 4), dtype=np.uint8) - self.updateZsliceScrollbar(frame_i) - z_slice = self.z_slice_index() - img = img[z_slice] - return img + def initImgCmap(self): + if "img_cmap" not in self.df_settings.index: + self.df_settings.at["img_cmap", "value"] = "grey" + self.imgCmapName = self.df_settings.at["img_cmap", "value"] + self.imgCmap = self.imgGrad.cmaps[self.imgCmapName] + if self.imgCmapName != "grey": + # To ensure mapping to colors we need to normalize image + self.normalizeByMaxAction.setChecked(True) - return self.getRawImageLayer0(frame_i) + def initImgGradRescaleIntensitiesHowPreference(self): + posData = self.data[self.pos_i] + channelName = posData.user_ch_name + if f"how_rescale_intensities_{channelName}" not in self.df_settings.index: + return - def updateLabelsAlpha(self, value): - plan = self.labels_alpha_plan( - value, - keep_ids_checked=self.keepIDsButton.isChecked(), - ) - self.df_settings.at['overlaySegmMasksAlpha', 'value'] = ( - plan.setting_value - ) - self.df_settings.to_csv(self.settings_csv_path) - self.labelsLayerImg1.setOpacity(plan.opacity) - self.labelsLayerRightImg.setOpacity(plan.opacity) + how = self.df_settings.at[f"how_rescale_intensities_{channelName}", "value"] + self.imgGrad.setRescaleIntensitiesHow(how) - def _getImageupdateAllImages(self, image=None): - if image is not None: - return image + def initLostObjContoursImage(self): + posData = self.data[self.pos_i] + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] - img = self.getImage() - return img + self.lostObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) - def setImageImg1(self, image=None): - img = self._getImageupdateAllImages(image=image) + def initLostTrackedObjContoursImage(self): posData = self.data[self.pos_i] - self.img1.setCurrentPosIndex(self.pos_i) - self.img1.setCurrentFrameIndex(posData.frame_i) - if posData.SizeZ > 1: - zProjHow = self.zProjComboBox.currentText() - if zProjHow == 'single z-slice': - z = self.zSliceScrollBar.sliderPosition() - else: - z = zProjHow + z_slice = self.z_lab() + img = posData.img_data[posData.frame_i] + Y, X = img[z_slice].shape[-2:] + + self.lostTrackedObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) + + def initManualBackgroundImage(self): + posData = self.data[self.pos_i] + if hasattr(posData, "lab"): + Y, X = posData.lab.shape[-2:] + else: + Y, X = posData.img_data.shape[-2:] + if not hasattr(self, "manualBackgroundTextItems"): + self.manualBackgroundTextItems = {} + posData.manualBackgroundImage = np.zeros((Y, X, 4), dtype=np.uint8) + if posData.manualBackgroundLab is None: + posData.manualBackgroundLab = np.zeros((Y, X), dtype=np.uint32) + + def initTextAnnot(self, force=False): + posData = self.data[self.pos_i] + if hasattr(posData, "lab"): + Y, X = posData.lab.shape[-2:] + else: + Y, X = posData.img_data.shape[-2:] + self.textAnnot[0].initItem((Y, X)) + self.textAnnot[1].initItem((Y, X)) + + def intensity_normalization_setting_value(self, how: str) -> str: + return how + + def invertBw(self, checked, update=True): + self.invertBwAlreadyCalledOnce = True + + try: + self.labelsGrad.invertBwAction.toggled.disconnect() + except Exception: + pass + + self.labelsGrad.invertBwAction.setChecked(checked) + self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + + try: + self.imgGrad.invertBwAction.toggled.disconnect() + except Exception: + pass + self.imgGrad.invertBwAction.setChecked(checked) + self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + + self.imgGrad.setInvertedColorMaps(checked) + self.imgGrad.invertCurrentColormap(checked) - self.img1.setCurrentZsliceIndex(z) + self.imgGradRight.setInvertedColorMaps(checked) + self.imgGradRight.invertCurrentColormap(checked) - self.img1.setImage( - img, next_frame_image=self.nextFrameImage(), - scrollbar_value=posData.frame_i+2 - ) + if hasattr(self, "overlayLayersItems"): + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.invertBwAction.toggled.disconnect() + lutItem.invertBwAction.setChecked(checked) + lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW) + lutItem.setInvertedColorMaps(checked) - def setImageImg2(self, updateLookuptable=True, set_image=True): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Segmentation and Tracking' or self.isSnapshot: - # self.addExistingDelROIs() - allDelIDs, lab2D = self.getDelROIlab() + if self.slideshowWin is not None: + self.slideshowWin.is_bw_inverted = checked + self.slideshowWin.update_img() + self.df_settings.at["is_bw_inverted", "value"] = "Yes" if checked else "No" + self.df_settings.to_csv(self.settings_csv_path) + if checked: + # Light mode + self.equalizeHistPushButton.setStyleSheet("") + self.graphLayout.setBackground(graphLayoutBkgrColor) + self.ax2_BrushCirclePen = pg.mkPen((150, 150, 150), width=2) + self.ax2_BrushCircleBrush = pg.mkBrush((200, 200, 200, 150)) + self.titleColor = "black" else: - lab2D = self.get_2Dlab(posData.lab, force_z=False) - allDelIDs = set() + # Dark mode + self.equalizeHistPushButton.setStyleSheet( + "QPushButton {background-color: #282828; color: #F0F0F0;}" + ) + self.graphLayout.setBackground(darkBkgrColor) + self.ax2_BrushCirclePen = pg.mkPen(width=2) + self.ax2_BrushCircleBrush = pg.mkBrush((255, 255, 255, 50)) + self.titleColor = "white" - self.currentLab2D = lab2D - if self.labelsGrad.permanentGreedyCmapAction.isChecked() and updateLookuptable: - self.greedyShuffleCmap(updateImages=False) + if not hasattr(self, "textAnnot"): + return - if self.labelsGrad.showLabelsImgAction.isChecked() and set_image: - self.img2.setImage(lab2D, z=self.z_lab(), autoLevels=False) + self.textAnnot[0].invertBlackAndWhite() + self.textAnnot[1].invertBlackAndWhite() - if updateLookuptable: - self.updateLookuptable(delIDs=allDelIDs) + self.objLabelAnnotRgb = tuple(self.textAnnot[0].item.colors()["label"][:3]) + self.textIDsColorButton.setColor(self.objLabelAnnotRgb) + self.imgGrad.textColorButton.setColor(self.objLabelAnnotRgb) + for items in self.overlayLayersItems.values(): + lutItem = items[1] + lutItem.textColorButton.setColor(self.objLabelAnnotRgb) - def getObject2DimageFromZ(self, z, obj): - posData = self.data[self.pos_i] - z_min = obj.bbox[0] - local_z = z - z_min - if local_z >= posData.SizeZ or local_z < 0: - return - return obj.image[local_z] + if update: + self.updateAllImages() - def getObject2DsliceFromZ(self, z, obj): - posData = self.data[self.pos_i] - z_min = obj.bbox[0] - local_z = z - z_min - if local_z >= posData.SizeZ or local_z < 0: - return - return obj.image[local_z] + def invert_bw_setting_value(self, checked: bool) -> str: + return "Yes" if checked else "No" def isObjVisible(self, obj_bbox, debug=False, z_slice=None): if z_slice is None: @@ -968,7 +652,7 @@ def isObjVisible(self, obj_bbox, debug=False, z_slice=None): if self.isSegm3D: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' + isZslice = zProjHow == "single z-slice" if not isZslice: # required a projection --> all obj are visible return True @@ -976,10 +660,10 @@ def isObjVisible(self, obj_bbox, debug=False, z_slice=None): depthAxes = self.switchPlaneCombobox.depthAxes() min_z, min_y, min_x, max_z, max_y, max_x = obj_bbox - if depthAxes == 'z': + if depthAxes == "z": min_val, max_val = min_z, max_z val = z_slice - elif depthAxes == 'y': + elif depthAxes == "y": min_val, max_val = min_y, max_y val = z_slice[-1] else: @@ -993,167 +677,31 @@ def isObjVisible(self, obj_bbox, debug=False, z_slice=None): else: return True - def getObjImage(self, obj_image, obj_bbox, z_slice=None): - if self.isSegm3D and len(obj_bbox)==6: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if not isZslice: - # required a projection - return obj_image.max(axis=0) - - min_z = obj_bbox[0] - if z_slice is None: - z_slice = self.z_lab() - if isinstance(z_slice, tuple): - z_slice = z_slice[-1] - - local_z = z_slice - min_z - try: - obi_image_2d = obj_image[local_z] - except Exception as err: - obi_image_2d = None - return obi_image_2d - else: - return obj_image - - def getObjSlice(self, obj_slice): - if self.isSegm3D: - return obj_slice[1:3] - else: - return obj_slice - - def getContoursImageItem(self, ax, force=False): - if not self.areContoursRequested(ax) and not force: - return - - if ax == 0: - return self.ax1_contoursImageItem - else: - return self.ax2_contoursImageItem - - def getLostObjImageItem(self, ax): - if ax == 0: - return self.ax1_lostObjImageItem - else: - return self.ax1_lostTrackedObjImageItem - - def getLostTrackedObjImageItem(self, ax): - if ax == 0: - return self.ax1_lostTrackedObjImageItem - else: - return self.ax2_lostTrackedObjImageItem - - def normaliseIntensitiesActionTriggered(self, action): - how = action.text() - self.df_settings.at['how_normIntensities', 'value'] = ( - self.intensity_normalization_setting_value(how) - ) - self.df_settings.to_csv(self.settings_csv_path) - self.updateAllImages() - self.updateImageValueFormatter() - - def setLastUserNormAction(self): - how = self.df_settings.at['how_normIntensities', 'value'] - for action in self.normalizeQActionGroup.actions(): - if action.text() == how: - action.setChecked(True) - break - - def saveLabelsColormap(self): - self.labelsGrad.saveColormap() - - def addFontSizeActions(self, menu, slot): - fontActionGroup = QActionGroup(self.host) - fontActionGroup.setExclusive(True) - for fontSize in range(4,27): - action = QAction(self.host) - action.setText(str(fontSize)) - action.setCheckable(True) - if fontSize == self.fontSize: - action.setChecked(True) - fontActionGroup.addAction(action) - menu.addAction(action) - action.triggered.connect(slot) - return fontActionGroup - - @exception_handler - def changeFontSize(self): - fontSize = self.fontSizeSpinBox.value() - if fontSize == self.fontSize: - return - - self.fontSize = fontSize - - self.df_settings.at['fontSize', 'value'] = self.fontSize - self.df_settings.to_csv(self.settings_csv_path) - - self.setAllIDs() - posData = self.data[self.pos_i] - for ax in range(2): - self.textAnnot[ax].changeFontSize(self.fontSize) - if self.highLowResAction.isChecked(): - self.setAllTextAnnotations() - else: - self.updateAllImages() - - def enableZstackWidgets(self, enabled): - if enabled: - myutils.setRetainSizePolicy(self.zSliceScrollBar) - myutils.setRetainSizePolicy(self.zProjComboBox) - myutils.setRetainSizePolicy(self.zSliceOverlay_SB) - myutils.setRetainSizePolicy(self.zProjOverlay_CB) - myutils.setRetainSizePolicy(self.overlay_z_label) - self.zSliceScrollBar.setDisabled(False) - self.zProjComboBox.show() - if self.data[self.pos_i].SizeT > 1: - self.zProjLockViewButton.show() - self.zSliceScrollBar.show() - self.zSliceCheckbox.show() - self.zSliceSpinbox.show() - self.switchPlaneCombobox.show() - self.switchPlaneCombobox.setDisabled(False) - self.SizeZlabel.show() - else: - myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=False) - myutils.setRetainSizePolicy(self.zProjComboBox, retain=False) - myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=False) - myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=False) - myutils.setRetainSizePolicy(self.overlay_z_label, retain=False) - self.zSliceScrollBar.setDisabled(True) - self.zProjComboBox.hide() - self.zProjComboBox.hide() - self.zSliceScrollBar.hide() - self.zSliceCheckbox.hide() - self.zSliceSpinbox.hide() - self.SizeZlabel.hide() - self.switchPlaneCombobox.hide() - self.switchPlaneCombobox.setDisabled(True) - - self.imgGrad.rescaleAcrossZstackAction.setDisabled(not enabled) - for ch, overlayItems in self.overlayLayersItems.items(): - lutItem = overlayItems[1] - lutItem.rescaleAcrossZstackAction.setDisabled(not enabled) + def labels_alpha_plan( + self, + value: float, + *, + keep_ids_checked: bool, + ) -> LabelsAlphaPlan: + opacity = value / 3 if keep_ids_checked else value + return LabelsAlphaPlan(setting_value=value, opacity=opacity) def launchSlideshow(self): posData = self.data[self.pos_i] self.determineSlideshowWinPos() if self.slideshowButton.isChecked(): self.slideshowWin = apps.imageViewer( - parent=self.host, + parent=self, button_toUncheck=self.slideshowButton, linkWindow=posData.SizeT > 1, enableOverlay=True, - enableMirroredCursor=True - ) - self.slideshowWin.img.minMaxValuesMapper = ( - self.img1.minMaxValuesMapper + enableMirroredCursor=True, ) + self.slideshowWin.img.minMaxValuesMapper = self.img1.minMaxValuesMapper self.slideshowWin.img.setCurrentPosIndex(self.pos_i) h = self.drawIDsContComboBox.size().height() self.slideshowWin.framesScrollBar.setFixedHeight(h) - self.slideshowWin.overlayButton.setChecked( - self.overlayButton.isChecked() - ) + self.slideshowWin.overlayButton.setChecked(self.overlayButton.isChecked()) self.slideshowWin.sigHoveringImage.connect( self.setMirroredCursorFromSecondWindow ) @@ -1162,76 +710,63 @@ def launchSlideshow(self): self.slideshowWin.img.setCurrentZsliceIndex(z_slice) self.slideshowWin.zSliceScrollBar.setSliderPosition(z_slice) self.slideshowWin.z_label.setText( - f'z-slice {z_slice+1:02}/{posData.SizeZ}' + f"z-slice {z_slice + 1:02}/{posData.SizeZ}" ) self.slideshowWin.update_img() - self.slideshowWin.show( - left=self.slideshowWinLeft, top=self.slideshowWinTop - ) + self.slideshowWin.show(left=self.slideshowWinLeft, top=self.slideshowWinTop) else: self.slideshowWin.close() self.slideshowWin = None - def setMirroredCursorFromSecondWindow(self, x, y): - if x is None: - xx, yy = [], [] - else: - xx, yy = [x], [y] - self.ax1_cursor.setData(xx, yy) - if not self.isTwoImageLayout: - return - self.ax2_cursor.setData(xx, yy) + def normaliseIntensitiesActionTriggered(self, action): + how = action.text() + self.df_settings.at["how_normIntensities", "value"] = how + self.df_settings.to_csv(self.settings_csv_path) + self.updateAllImages() + self.updateImageValueFormatter() - def zProjLockViewToggled(self, checked): - self.updateZproj(self.zProjComboBox.currentText()) + def normalizeIntensities(self, img): + action, normalize, how = self.getCheckNormAction() + if not normalize: + return img + + if how == "Do not normalize. Display raw image": + img = img + elif how == "Convert to floating point format with values [0, 1]": + img = myutils.img_to_float(img) + # elif how == 'Rescale to 8-bit unsigned integer format with values [0, 255]': + # img = skimage.img_as_float(img) + # img = (img*255).astype(np.uint8) + # return img + elif how == "Rescale to [0, 1]": + img = skimage.img_as_float(img) + img = skimage.exposure.rescale_intensity(img) + elif how == "Normalize by max value": + img = img / np.max(img) + return img + + def removeAxLimits(self): + self.ax1.vb.state["limits"]["xLimits"] = [-1e307, +1e307] + self.ax1.vb.state["limits"]["yLimits"] = [-1e307, +1e307] def rescaleIntensExportToVideoDialog(self, how, channel, setImage=True): if channel == self.user_ch_name: lutItem = self.imgGrad else: - lutItem = self.overlayLayersItems[channel][1] - - for action in lutItem.rescaleActionGroup.actions(): - if action.text() == how: - action.trigger() - # self.rescaleIntensitiesLut(setImage=setImage) - break - - def customLevelsLutChanged(self, levels, imageItem=None): - imageItem.setLevels(levels) - - def getPreComputedMinMaxZstack(self, channel: str): - if channel != self.user_ch_name: - return None - - posData = self.data[self.pos_i] - zstack_min, zstack_max = np.inf, 0 - for z in range(posData.SizeZ): - key = (self.pos_i, posData.frame_i, z) - levels = self.img1.minMaxValuesMapper.get(key) - if levels is None: - return - - img_min, img_max = levels - if img_min < zstack_min: - zstack_min = img_min - - if img_max > zstack_max: - zstack_max = img_max + lutItem = self.overlayLayersItems[channel][1] - return (zstack_min, zstack_max) + for action in lutItem.rescaleActionGroup.actions(): + if action.text() == how: + action.trigger() + # self.rescaleIntensitiesLut(setImage=setImage) + break - # @exec_time def rescaleIntensitiesLut( - self, - action: QAction=None, - setImage: bool=True, - imageItem=None - ): + self, action: QAction = None, setImage: bool = True, imageItem=None + ): if not self.isDataLoaded: self.logger.info( - 'WARNING: Data is not loaded. ' - 'Intensities will be rescaled later.' + "WARNING: Data is not loaded. Intensities will be rescaled later." ) return @@ -1252,13 +787,10 @@ def rescaleIntensitiesLut( how = action.text() - setting, setting_value = ( - self.rescale_intensity_setting_update(channel, how) - ) - self.df_settings.at[setting, 'value'] = setting_value + self.df_settings.at[f"how_rescale_intensities_{channel}", "value"] = how self.df_settings.to_csv(self.settings_csv_path) - if how == 'Rescale each 2D image': + if how == "Rescale each 2D image": if how == self.rescaleIntensChannelHowMapper[channel]: # No need to update since we have autoscale return @@ -1270,134 +802,482 @@ def rescaleIntensitiesLut( lutLevelsCh = posData.lutLevels[channel] - if how == 'Rescale across z-stack': + if how == "Rescale across z-stack": imageItem.setEnableAutoLevels(False) levels_key = (how, posData.frame_i) levels = lutLevelsCh.get(levels_key) if levels is None: levels = self.getPreComputedMinMaxZstack(channel) - if levels is None: - image_zstack = image_data[posData.frame_i] - levels = (image_zstack.min(), image_zstack.max()) - lutLevelsCh[levels_key] = levels - imageItem.setLevels(levels) - elif how == 'Rescale across time frames': - imageItem.setEnableAutoLevels(False) - levels_key = (how, None) - levels = lutLevelsCh.get(levels_key) - if levels is None: - levels = (image_data.min(), image_data.max()) + if levels is None: + image_zstack = image_data[posData.frame_i] + levels = (image_zstack.min(), image_zstack.max()) + lutLevelsCh[levels_key] = levels + imageItem.setLevels(levels) + elif how == "Rescale across time frames": + imageItem.setEnableAutoLevels(False) + levels_key = (how, None) + levels = lutLevelsCh.get(levels_key) + if levels is None: + levels = (image_data.min(), image_data.max()) + + lutLevelsCh[levels_key] = levels + imageItem.setLevels(levels) + elif how == "Choose custom levels...": + autoLevelsEnabledBefore = imageItem.autoLevelsEnabled + imageItem.setEnableAutoLevels(False) + if triggeredByUser: + current_min, current_max = imageItem.getLevels() + dtype_max = np.iinfo(image_data.dtype).max + max_value = image_data.max() + min_value = image_data.min() + win = apps.SetCustomLevelsLut( + init_min_value=current_min, + init_max_value=current_max, + maximum_max_value=max_value, + minimum_min_value=min_value, + parent=self, + ) + win.sigLevelsChanged.connect( + partial(self.customLevelsLutChanged, imageItem=imageItem) + ) + win.exec_() + if win.cancel: + imageItem.setEnableAutoLevels(autoLevelsEnabledBefore) + self.logger.info("Custom LUT levels setting cancelled.") + self.updateAllImages() + return + selectedLevels = win.selectedLevels + else: + selectedLevels = imageItem.getLevels() + imageItem.setLevels(selectedLevels) + elif how == "Do no rescale, display raw image": + imageItem.setEnableAutoLevels(False) + levels_key = (how, None) + levels = lutLevelsCh.get(levels_key) + if levels is None: + dtype_max = np.iinfo(image_data.dtype).max + levels = (0, dtype_max) + lutLevelsCh[levels_key] = levels + imageItem.setLevels(levels) + + self.rescaleIntensChannelHowMapper[channel] = how + + if setImage: + imageItem.setImage(imageItem.image) + + def rescale_intensity_setting_update( + self, + channel: str, + how: str, + ) -> tuple[str, str]: + return f"how_rescale_intensities_{channel}", how + + LEGACY_METHODS = ( + "getDisplayedImg1", + "getDisplayedZstack", + "getObjBbox", + "z_lab", + "get_2Dlab", + "get_2Drp", + "set_2Dlab", + "setTextAnnotZsliceScrolling", + "setGraphicalAnnotZsliceScrolling", + "initContoursImage", + "initLostObjContoursImage", + "initLostTrackedObjContoursImage", + "initManualBackgroundImage", + "initImgCmap", + "initTextAnnot", + "zoomOut", + "zoomToObjsActionCallback", + "zoomToCells", + "equalizeHist", + "getDistantGray", + "RGBtoGray", + "ruler_cb", + "editImgProperties", + "setTwoImagesLayout", + "showNextFrameImageItem", + "showRightImageItem", + "showLabelImageItem", + "setAnnotOptionsRightImageLabelsDisabled", + "setBottomLayoutStretch", + "setHoverToolSymbolData", + "getCheckNormAction", + "normalizeIntensities", + "invertBw", + "setCheckedInvertBW", + "updateImageValueFormatter", + "getImageDataFromFilename", + "z_slice_index", + "get_2Dimg_from_3D", + "updateZsliceScrollbar", + "getRawImage", + "getRawImageLayer0", + "getImage", + "updateLabelsAlpha", + "_getImageupdateAllImages", + "setImageImg1", + "setImageImg2", + "getObject2DimageFromZ", + "getObject2DsliceFromZ", + "isObjVisible", + "getObjImage", + "getObjSlice", + "getContoursImageItem", + "getLostObjImageItem", + "getLostTrackedObjImageItem", + "normaliseIntensitiesActionTriggered", + "setLastUserNormAction", + "saveLabelsColormap", + "addFontSizeActions", + "changeFontSize", + "enableZstackWidgets", + "launchSlideshow", + "setMirroredCursorFromSecondWindow", + "zProjLockViewToggled", + "rescaleIntensExportToVideoDialog", + "customLevelsLutChanged", + "getPreComputedMinMaxZstack", + "rescaleIntensitiesLut", + "showMirroredCursorToggled", + "clearCursors", + "activeEraserCircleCursors", + "activeEraserXCursors", + "activeBrushCircleCursors", + "initImgGradRescaleIntensitiesHowPreference", + "updateAllImages", + "removeAxLimits", + "resizeGui", + "autoRange", + "resetRange", + ) + + def resetRange(self): + if self.ax1_viewRange is None: + return + xRange, yRange = self.ax1_viewRange + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.ax2.vb.setRange(xRange=xRange, yRange=yRange) + self.ax1.vb.setRange(xRange=xRange, yRange=yRange) + self.ax1_viewRange = None + self.isRangeReset = True + + def resizeGui(self): + self.ax1.vb.state["limits"]["xRange"] = [None, None] + self.ax1.vb.state["limits"]["yRange"] = [None, None] + self.autoRange() + if self.ax1.getViewBox().state["limits"]["xRange"][0] is not None: + self.bottomScrollArea._resizeVertical() + return + (xmin, xmax), (ymin, ymax) = self.ax1.viewRange() + maxYRange = int((ymax - ymin) * 1.5) + maxXRange = int((xmax - xmin) * 1.5) + self.ax1.setLimits(maxYRange=maxYRange, maxXRange=maxXRange) + self.bottomScrollArea._resizeVertical() + QTimer.singleShot(200, self.autoRange) + + def right_pane_visibility_plan( + self, + mode: RightPaneMode, + checked: bool, + ) -> RightPaneVisibilityPlan: + settings_updates = { + "isNextFrameVisible": "No", + "isRightImageVisible": "No", + "isLabelsVisible": "No", + } + if checked: + setting_key = { + "next_frame": "isNextFrameVisible", + "right_image": "isRightImageVisible", + "labels": "isLabelsVisible", + }[mode] + settings_updates[setting_key] = "Yes" + + return RightPaneVisibilityPlan( + mode=mode, + checked=checked, + settings_updates=settings_updates, + ) + + def ruler_cb(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + self.connectLeftClickButtons() + else: + self.tempSegmentON = False + self.ax1_rulerPlotItem.setData([], []) + self.ax1_rulerAnchorsItem.setData([], []) + + def saveLabelsColormap(self): + self.labelsGrad.saveColormap() + + def setAnnotOptionsRightImageLabelsDisabled(self, disabled): + self.annotContourCheckboxRight.setDisabled(disabled) + self.annotSegmMasksCheckboxRight.setDisabled(disabled) + if disabled: + self.annotSegmMasksCheckboxRight.setChecked(False) + self.annotSegmMasksCheckboxRight.setChecked(False) + self.annotIDsCheckboxRight.setChecked(True) + + def setBottomLayoutStretch(self): + if ( + self.labelsGrad.showRightImgAction.isChecked() + or self.labelsGrad.showNextFrameAction.isChecked() + ): + # Equally share space between the two control groupboxes + self.bottomLayout.setStretch(1, 1) + self.bottomLayout.setStretch(2, 5) + self.bottomLayout.setStretch(3, 1) + self.bottomLayout.setStretch(4, 5) + self.bottomLayout.setStretch(5, 1) + elif self.labelsGrad.showLabelsImgAction.isChecked(): + # Left control takes only left space + self.bottomLayout.setStretch(1, 1) + self.bottomLayout.setStretch(2, 5) + self.bottomLayout.setStretch(3, 5) + self.bottomLayout.setStretch(4, 1) + self.bottomLayout.setStretch(5, 1) + else: + # Left control takes all the space + self.bottomLayout.setStretch(1, 3) + self.bottomLayout.setStretch(2, 10) + self.bottomLayout.setStretch(3, 1) + self.bottomLayout.setStretch(4, 1) + self.bottomLayout.setStretch(5, 1) + + def setCheckedInvertBW(self, checked): + self.invertBwAction.setChecked(checked) + + def setGraphicalAnnotZsliceScrolling(self): + posData = self.data[self.pos_i] + if self.isSegm3D: + self.currentLab2D = posData.lab[self.z_lab()] + self.setOverlaySegmMasks() + self.doCustomAnnotation(0) + self.update_rp_metadata() + else: + self.currentLab2D = posData.lab + self.setOverlaySegmMasks() + self.updateContoursImage(0) + self.updateContoursImage(1) + + def setHoverToolSymbolData(self, xx, yy, ScatterItems, size=None): + if not xx: + self.ax1_lostObjScatterItem.setVisible(True) + self.ax2_lostObjScatterItem.setVisible(True) + + self.ax1_lostTrackedScatterItem.setVisible(True) + self.ax2_lostTrackedScatterItem.setVisible(True) + + for item in ScatterItems: + if size is None: + item.setData(xx, yy) + else: + item.setData(xx, yy, size=size) + + def setImageImg1(self, image=None): + img = self._getImageupdateAllImages(image=image) + posData = self.data[self.pos_i] + self.img1.setCurrentPosIndex(self.pos_i) + self.img1.setCurrentFrameIndex(posData.frame_i) + if posData.SizeZ > 1: + zProjHow = self.zProjComboBox.currentText() + if zProjHow == "single z-slice": + z = self.zSliceScrollBar.sliderPosition() + else: + z = zProjHow + + self.img1.setCurrentZsliceIndex(z) + + self.img1.setImage( + img, + next_frame_image=self.nextFrameImage(), + scrollbar_value=posData.frame_i + 2, + ) + + def setImageImg2(self, updateLookuptable=True, set_image=True): + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + if mode == "Segmentation and Tracking" or self.isSnapshot: + # self.addExistingDelROIs() + allDelIDs, lab2D = self.getDelROIlab() + else: + lab2D = self.get_2Dlab(posData.lab, force_z=False) + allDelIDs = set() + + self.currentLab2D = lab2D + if self.labelsGrad.permanentGreedyCmapAction.isChecked() and updateLookuptable: + self.greedyShuffleCmap(updateImages=False) + + if self.labelsGrad.showLabelsImgAction.isChecked() and set_image: + self.img2.setImage(lab2D, z=self.z_lab(), autoLevels=False) + + if updateLookuptable: + self.updateLookuptable(delIDs=allDelIDs) + + def setLastUserNormAction(self): + how = self.df_settings.at["how_normIntensities", "value"] + for action in self.normalizeQActionGroup.actions(): + if action.text() == how: + action.setChecked(True) + break + + def setMirroredCursorFromSecondWindow(self, x, y): + if x is None: + xx, yy = [], [] + else: + xx, yy = [x], [y] + self.ax1_cursor.setData(xx, yy) + if not self.isTwoImageLayout: + return + self.ax2_cursor.setData(xx, yy) + + def setTextAnnotZsliceScrolling(self): + pass + + def setTwoImagesLayout(self, isTwoImages): + self.isTwoImageLayout = isTwoImages + if isTwoImages: + self.graphLayout.removeItem(self.titleLabel) + self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) + # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignLeft) + self.ax2.show() + self.ax2.vb.setYLink(self.ax1.vb) + self.ax2.vb.setXLink(self.ax1.vb) + else: + self.graphLayout.removeItem(self.titleLabel) + self.graphLayout.addItem(self.titleLabel, row=0, col=1) + # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignCenter) + self.ax2.hide() + oldLink = self.ax2.vb.linkedView(self.ax1.vb.YAxis) + try: + oldLink.sigYRangeChanged.disconnect() + oldLink.sigXRangeChanged.disconnect() + except TypeError: + pass + + def set_2Dlab(self, lab2D, lab3D=None): + posData = self.data[self.pos_i] - lutLevelsCh[levels_key] = levels - imageItem.setLevels(levels) - elif how == 'Choose custom levels...': - autoLevelsEnabledBefore = imageItem.autoLevelsEnabled - imageItem.setEnableAutoLevels(False) - if triggeredByUser: - current_min, current_max = imageItem.getLevels() - dtype_max = np.iinfo(image_data.dtype).max - max_value = image_data.max() - min_value = image_data.min() - win = apps.SetCustomLevelsLut( - init_min_value=current_min, - init_max_value=current_max, - maximum_max_value=max_value, - minimum_min_value=min_value, - parent=self.host - ) - win.sigLevelsChanged.connect( - partial(self.customLevelsLutChanged, imageItem=imageItem) - ) - win.exec_() - if win.cancel: - imageItem.setEnableAutoLevels(autoLevelsEnabledBefore) - self.logger.info('Custom LUT levels setting cancelled.') - self.updateAllImages() - return - selectedLevels = win.selectedLevels + if lab3D is None: + lab3D = posData.lab + + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == "single z-slice" + if isZslice: + lab3D[self.z_lab()] = lab2D else: - selectedLevels = imageItem.getLevels() - imageItem.setLevels(selectedLevels) - elif how == 'Do no rescale, display raw image': - imageItem.setEnableAutoLevels(False) - levels_key = (how, None) - levels = lutLevelsCh.get(levels_key) - if levels is None: - dtype_max = np.iinfo(image_data.dtype).max - levels = (0, dtype_max) - lutLevelsCh[levels_key] = levels - imageItem.setLevels(levels) + lab3D[:] = lab2D + else: + if lab3D.shape == lab2D.shape: + lab3D[...] = lab2D + else: + posData.lab = lab2D - self.rescaleIntensChannelHowMapper[channel] = how + def showLabelImageItem(self, checked): + self.rightImageFramesScrollbar.setVisible(not checked) + self.rightImageFramesScrollbar.setDisabled(checked) + self.setTwoImagesLayout(checked) + self.setAnnotOptionsRightImageLabelsDisabled(checked) + if checked: + self.df_settings.at["isLabelsVisible", "value"] = "Yes" + self.df_settings.at["isNextFrameVisible", "value"] = "No" + self.df_settings.at["isRightImageVisible", "value"] = "No" + self.rightBottomGroupbox.show() + self.rightBottomGroupbox.setChecked(True) + if not self.isDataLoading: + self.updateAllImages() + else: + self.clearAx2Items() + self.img2.clear() + self.df_settings.at["isLabelsVisible", "value"] = "No" + self.rightBottomGroupbox.hide() + self.moveDelRoisToLeft() - if setImage: - imageItem.setImage(imageItem.image) + self.df_settings.to_csv(self.settings_csv_path) + QTimer.singleShot(200, self.resizeGui) + + self.setBottomLayoutStretch() def showMirroredCursorToggled(self, checked): - value = 'Yes' if checked else 'No' - self.df_settings.at['showMirroredCursor', 'value'] = value + value = "Yes" if checked else "No" + self.df_settings.at["showMirroredCursor", "value"] = value self.df_settings.to_csv(settings_csv_path) if not checked: self.clearCursors() - def clearCursors(self): - self.ax1_cursor.setData([], []) - self.ax2_cursor.setData([], []) - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - eraserCursors = ( - self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX - ) - self.setHoverToolSymbolData([], [], eraserCursors) - - def activeEraserCircleCursors(self, isHoverImg1): - if self.showMirroredCursorAction.isChecked(): - return self.ax1_EraserCircle, self.ax2_EraserCircle - - if isHoverImg1: - return self.ax1_EraserCircle, + def showNextFrameImageItem(self, checked): + self.rightImageFramesScrollbar.setVisible(checked) + self.rightImageFramesScrollbar.setDisabled(not checked) + self.setTwoImagesLayout(checked) + if checked: + self.df_settings.at["isNextFrameVisible", "value"] = "Yes" + self.df_settings.at["isRightImageVisible", "value"] = "No" + self.df_settings.at["isLabelsVisible", "value"] = "No" + self.graphLayout.addItem(self.imgGradRight, row=1, col=self.plotsCol + 2) + self.rightBottomGroupbox.show() + self.rightBottomGroupbox.setChecked(True) + self.drawNothingCheckboxRight.click() + if not self.isDataLoading: + self.updateAllImages() else: - return self.ax2_EraserCircle, + self.clearAx2Items() + self.rightBottomGroupbox.hide() + self.df_settings.at["isNextFrameVisible", "value"] = "No" + try: + self.graphLayout.removeItem(self.imgGradRight) + except Exception: + return + self.rightImageItem.clear() - def activeEraserXCursors(self, isHoverImg1): - if self.showMirroredCursorAction.isChecked(): - return self.ax1_EraserX, self.ax2_EraserX + self.df_settings.to_csv(self.settings_csv_path) - if isHoverImg1: - return self.ax1_EraserX, - else: - return self.ax2_EraserX, + QTimer.singleShot(300, self.resizeGui) - def activeBrushCircleCursors(self, isHoverImg1): - if self.showMirroredCursorAction.isChecked(): - return self.ax1_BrushCircle, self.ax2_BrushCircle + self.setBottomLayoutStretch() - if isHoverImg1: - return self.ax1_BrushCircle, + def showRightImageItem(self, checked): + self.rightImageFramesScrollbar.setVisible(not checked) + self.rightImageFramesScrollbar.setDisabled(checked) + self.setTwoImagesLayout(checked) + if checked: + self.df_settings.at["isRightImageVisible", "value"] = "Yes" + self.df_settings.at["isNextFrameVisible", "value"] = "No" + self.df_settings.at["isLabelsVisible", "value"] = "No" + self.graphLayout.addItem(self.imgGradRight, row=1, col=self.plotsCol + 2) + self.rightBottomGroupbox.show() + if not self.isDataLoading: + self.updateAllImages() else: - return self.ax2_BrushCircle, + self.clearAx2Items() + self.rightBottomGroupbox.hide() + self.df_settings.at["isRightImageVisible", "value"] = "No" + try: + self.graphLayout.removeItem(self.imgGradRight) + except Exception: + return + self.rightImageItem.clear() - def initImgGradRescaleIntensitiesHowPreference(self): - posData = self.data[self.pos_i] - channelName = posData.user_ch_name - if f'how_rescale_intensities_{channelName}' not in self.df_settings.index: - return + self.df_settings.to_csv(self.settings_csv_path) - how = self.df_settings.at[ - f'how_rescale_intensities_{channelName}', 'value' - ] - self.imgGrad.setRescaleIntensitiesHow(how) + QTimer.singleShot(300, self.resizeGui) + + self.setBottomLayoutStretch() - # @exec_time @exception_handler def updateAllImages( - self, image=None, computePointsLayers=True, computeContours=True, - updateLookuptable=True - ): + self, + image=None, + computePointsLayers=True, + computeContours=True, + updateLookuptable=True, + ): self.clearAllItems() posData = self.data[self.pos_i] @@ -1423,22 +1303,20 @@ def updateAllImages( # Annotate ID and draw contours delROIsIDs = self.setAllTextAnnotations() - self.setAllContoursImages( - delROIsIDs=delROIsIDs, compute=False - ) + self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False) mode = self.modeComboBox.currentText() self.drawAllMothBudLines() - if mode == 'Normal division: Lineage tree': + if mode == "Normal division: Lineage tree": self.drawAllLineageTreeLines() self.highlightLostNew() - if self.ccaTableWin is not None: # need to add for lin tree, later - zoomIDs = self.exporting_view.getZoomIDs() + if self.ccaTableWin is not None: # need to add for lin tree, later + zoomIDs = self.getZoomIDs() self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - self.custom_annotations_view.doCustomAnnotation(0) + self.doCustomAnnotation(0) self.annotate_rip_and_bin_IDs() self.updateTempLayerKeepIDs() @@ -1448,42 +1326,134 @@ def updateAllImages( self.annotateAssignedObjsAcdcTrackerSecondStep() self.highlightSearchedID(self.highlightedID, force=True) - self.display_decorations_view.update_timestamp_frame() + self.updateTimestampFrame() posData.visited = True - def removeAxLimits(self): - self.ax1.vb.state['limits']['xLimits'] = [-1E307, +1E307] - self.ax1.vb.state['limits']['yLimits'] = [-1E307, +1E307] + def updateImageValueFormatter(self): + if self.img1.image is not None: + dtype = self.img1.image.dtype + n_digits = len(str(int(self.img1.image.max()))) + self.imgValueFormatter = myutils.get_number_fstring_formatter( + dtype, precision=abs(n_digits - 5) + ) - def resizeGui(self): - self.ax1.vb.state['limits']['xRange'] = [None, None] - self.ax1.vb.state['limits']['yRange'] = [None, None] - self.autoRange() - if self.ax1.getViewBox().state['limits']['xRange'][0] is not None: - self.bottomScrollArea._resizeVertical() - return - (xmin, xmax), (ymin, ymax) = self.ax1.viewRange() - maxYRange = int((ymax-ymin)*1.5) - maxXRange = int((xmax-xmin)*1.5) - self.ax1.setLimits( - maxYRange=maxYRange, - maxXRange=maxXRange + rawImgData = self.data[self.pos_i].img_data + dtype = rawImgData.dtype + n_digits = len(str(int(rawImgData.max()))) + self.rawValueFormatter = myutils.get_number_fstring_formatter( + dtype, precision=abs(n_digits - 5) ) - self.bottomScrollArea._resizeVertical() - QTimer.singleShot(200, self.autoRange) - def autoRange(self): - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.ax2.autoRange() + def updateLabelsAlpha(self, value): + self.df_settings.at["overlaySegmMasksAlpha", "value"] = value + self.df_settings.to_csv(self.settings_csv_path) + if self.keepIDsButton.isChecked(): + value = value / 3 + self.labelsLayerImg1.setOpacity(value) + self.labelsLayerRightImg.setOpacity(value) + + def updateZsliceScrollbar(self, frame_i): + posData = self.data[self.pos_i] + if self.switchPlaneCombobox.depthAxes() != "z": + return + + idx = (posData.filename, frame_i) + try: + z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] + except ValueError: + z = posData.segmInfo_df.loc[idx, "z_slice_used_gui"].iloc[0] + try: + zProjHow = posData.segmInfo_df.at[idx, "which_z_proj_gui"] + except ValueError: + zProjHow = posData.segmInfo_df.loc[idx, "which_z_proj_gui"].iloc[0] + + self.zProjComboBox.setCurrentText(zProjHow) + + reconnect = False + try: + self.zSliceScrollBar.actionTriggered.disconnect() + self.zSliceScrollBar.sliderReleased.disconnect() + reconnect = True + except TypeError: + pass + self.zSliceScrollBar.setSliderPosition(z) + if reconnect: + self.zSliceScrollBar.actionTriggered.connect( + self.zSliceScrollBarActionTriggered + ) + self.zSliceScrollBar.sliderReleased.connect(self.zSliceScrollBarReleased) + self.zSliceSpinbox.setValueNoEmit(z + 1) + + def zProjLockViewToggled(self, checked): + self.updateZproj(self.zProjComboBox.currentText()) + + def z_lab(self, checkIfProj=False): + if checkIfProj and self.zProjComboBox.currentText() != "single z-slice": + return + + if not self.isSegm3D: + return + + posData = self.data[self.pos_i] + + idx = self.zSliceScrollBar.sliderPosition() + + # ensure idx doesnt exceed the number of z-slices of the position + idx_z = min(idx, posData.SizeZ - 1) + + if not self.switchPlaneCombobox.isEnabled(): + return idx_z + + depthAxes = self.switchPlaneCombobox.depthAxes() + if depthAxes == "z": + return idx_z + elif depthAxes == "y": + idx_y = min(idx, posData.SizeY - 1) + return (slice(None), idx_y) + else: + idx_x = min(idx, posData.SizeX - 1) + return (slice(None), slice(None), idx_x) + + def z_slice_index(self): + posData = self.data[self.pos_i] + if posData.SizeZ == 1: + return None + zProjHow = self.zProjComboBox.currentText() + if zProjHow != "single z-slice": + return zProjHow + + axis_slice = self.zSliceScrollBar.sliderPosition() + if self.switchPlaneCombobox.depthAxes() == "x": + z_slice = (slice(None, None, None), slice(None, None, None), axis_slice) + elif self.switchPlaneCombobox.depthAxes() == "y": + z_slice = (slice(None, None, None), axis_slice) + else: + z_slice = axis_slice + + return z_slice + + def zoomOut(self): self.ax1.autoRange() - def resetRange(self): - if self.ax1_viewRange is None: + def zoomToCells(self, enforce=False): + if not self.enableAutoZoomToCellsAction.isChecked() and not enforce: return - xRange, yRange = self.ax1_viewRange - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.ax2.vb.setRange(xRange=xRange, yRange=yRange) - self.ax1.vb.setRange(xRange=xRange, yRange=yRange) - self.ax1_viewRange = None - self.isRangeReset = True \ No newline at end of file + + self.data[self.pos_i] + lab_mask = (self.currentLab2D > 0).astype(np.uint8) + rp = skimage.measure.regionprops(lab_mask) + if not rp: + Y, X = lab_mask.shape + xRange = -0.5, X + 0.5 + yRange = -0.5, Y + 0.5 + else: + obj = rp[0] + min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) + xRange = min_col - 10, max_col + 10 + yRange = max_row + 10, min_row - 10 + + self.ax1.setRange(xRange=xRange, yRange=yRange) + + def zoomToObjsActionCallback(self): + self.zoomToCells(enforce=True) diff --git a/cellacdc/mixins/label_editing.py b/cellacdc/mixins/label_editing.py index 8de24459c..497bb714e 100644 --- a/cellacdc/mixins/label_editing.py +++ b/cellacdc/mixins/label_editing.py @@ -13,141 +13,169 @@ from cellacdc import apps, disableWindow, exception_handler -class LabelEditingView: +class LabelEditingMixin: """Qt-facing adapter around manual label editing.""" """Headless decisions for manual label editing.""" - def should_apply_manual_edits(self, edited_labels_by_z) -> bool: - return bool(edited_labels_by_z) + # @exec_time - def should_store_zslice_regionprops(self, *, is_segm_3d: bool) -> bool: - return is_segm_3d + # @exec_time - def should_update_zslice_regionprops( - self, - *, - force_update: bool, - already_stored: bool, - ) -> bool: - return force_update or not already_stored + def _get_editID_info(self, df): + if "was_manually_edited" not in df.columns: + return [] - def should_prompt_for_background_id(self, clicked_id: int) -> bool: - return clicked_id == 0 + if "y_centroid" not in df.columns or "x_centroid" not in df.columns: + df = self.addYXcentroidToDf(df) - def is_power_button_color( - self, - *, - button_color: str, - power_color: str, - ) -> bool: - return button_color == power_color + manually_edited_df = df[df["was_manually_edited"] > 0] + editID_info = [ + (row.y_centroid, row.x_centroid, row.Index) + for row in manually_edited_df.itertuples() + ] + return editID_info - def should_force_new_hover_id( - self, - *, - brush_active: bool, - shift_pressed: bool, - ) -> bool: - return brush_active and shift_pressed + def _update_zslices_rp(self): + if not self.isSegm3D: + return - def should_restore_brush_id_from_hover( + posData = self.data[self.pos_i] + posData.zSlicesRp = {} + for z, lab2d in enumerate(posData.lab): + lab2d_rp = skimage.measure.regionprops(lab2d) + posData.zSlicesRp[z] = {obj.label: obj for obj in lab2d_rp} + + def addYXcentroidToDf(self, df): + posData = self.data[self.pos_i] + for obj in posData.rp: + y_centroid = int(self.getObjCentroid(obj.centroid)[0]) + x_centroid = int(self.getObjCentroid(obj.centroid)[1]) + df.at[obj.label, "y_centroid"] = y_centroid + df.at[obj.label, "x_centroid"] = x_centroid + return df + + def applyEditID( self, - *, - is_hover_z_neighbor: bool, - shift_pressed: bool, - last_hover_id: int, - hover_id: int, - ) -> bool: - return ( - is_hover_z_neighbor - and not shift_pressed - and last_hover_id != hover_id + clickedID, + currentIDs, + oldIDnewIDMapper, + clicked_x, + clicked_y, + shift=False, + doPropagateUnvisited=False, + ): + posData = self.data[self.pos_i] + + # Ask to propagate change to all future visited frames + key = "Edit ID" + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( + self.propagateChange( + clickedID, + key, + doNotShow, + posData.UndoFutFrames_EditID, + posData.applyFutFrames_EditID, + applyTrackingB=True, + ) ) + if UndoFutFrames is None: + return - LEGACY_METHODS = ( - 'mergeObjs_cb', - 'assignNewIDfromClickedID', - 'addYXcentroidToDf', - '_get_editID_info', - 'apply_manual_edits_to_lab_if_needed', - 'store_zslices_rp', - 'removeObjectFromRp', - 'get_zslices_rp', - '_update_zslices_rp', - 'update_rp', - 'delBorderObj', - 'delNewObj', - 'getClickedID', - 'deleteIDFromLab', - 'removeStoredContours', - 'deleteIDmiddleClick', - 'applyEditID', - 'changeIDfutureFrames', - 'getLastHoveredID', - 'getHoverID', - 'setHoverToolSymbolColor', - 'isPowerBrush', - 'isPowerEraser', - 'isPowerButton', - ) + if shift and self.isSegm3D: + lab = self.get_2Dlab(posData.lab) + else: + lab = posData.lab - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) + # Store undo state before modifying stuff + self.storeUndoRedoStates(UndoFutFrames) + maxID = max(posData.IDs, default=0) + for old_ID, new_ID in oldIDnewIDMapper: + if new_ID in currentIDs and not self.editIDmergeIDs: + tempID = maxID + 1 + lab[lab == old_ID] = maxID + 1 + lab[lab == new_ID] = old_ID + lab[lab == tempID] = new_ID + maxID += 1 - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) + old_ID_idx = currentIDs.index(old_ID) + new_ID_idx = currentIDs.index(new_ID) - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + # Append information for replicating the edit in tracking + # List of tuples (y, x, replacing ID) + objo = posData.rp[old_ID_idx] + yo, xo = self.getObjCentroid(objo.centroid) + objn = posData.rp[new_ID_idx] + yn, xn = self.getObjCentroid(objn.centroid) + if not math.isnan(yo) and not math.isnan(yn): + yn, xn = int(yn), int(xn) + posData.editID_info.append((yn, xn, new_ID)) + yo, xo = int(clicked_y), int(clicked_x) + posData.editID_info.append((yo, xo, old_ID)) + else: + lab[lab == old_ID] = new_ID + if new_ID > maxID: + maxID = new_ID + old_ID_idx = posData.IDs.index(old_ID) - def mergeObjs_cb(self, checked): - if not checked: - self.mergeObjsTempLine.setData([], []) + # Append information for replicating the edit in tracking + # List of tuples (y, x, replacing ID) + obj = posData.rp[old_ID_idx] + y, x = self.getObjCentroid(obj.centroid) + if not math.isnan(y) and not math.isnan(y): + y, x = int(y), int(x) + posData.editID_info.append((y, x, new_ID)) - def assignNewIDfromClickedID(self, clickedID: int, event): - posData = self.data[self.pos_i] - x, y = event.pos().x(), event.pos().y() - newID = self.setBrushID(return_val=True) - mapper = [(clickedID, newID)] - self.applyEditID(clickedID, posData.IDs.copy(), mapper, x, y) + self.updateAssignedObjsAcdcTrackerSecondStep(new_ID) - def addYXcentroidToDf(self, df): - posData = self.data[self.pos_i] - depth_axis = ( - self.switchPlaneCombobox.depthAxes() if self.isSegm3D else 'z' - ) - return self.edit_id.add_yx_centroids_to_df( - df, - posData.rp, - is_3d=self.isSegm3D, - depth_axis=depth_axis, - ) + if shift and self.isSegm3D: + self.set_2Dlab(lab) - def _get_editID_info(self, df): - posData = self.data[self.pos_i] - depth_axis = ( - self.switchPlaneCombobox.depthAxes() if self.isSegm3D else 'z' + # Update rps + self.update_rp() + + # Since we manually changed an ID we don't want to repeat tracking + self.setAllTextAnnotations() + self.highlightLostNew() + # self.checkIDsMultiContour() + + # Update colors for the edited IDs + self.updateLookuptable() + + if self.isSnapshot: + self.fixCcaDfAfterEdit("Edit ID") + self.updateAllImages() + else: + self.warnEditingWithCca_df("Edit ID", update_images=False) + + if not self.editIDbutton.findChild(QAction).isChecked(): + self.editIDbutton.setChecked(False) + + posData.disableAutoActivateViewerWindow = True + + # Perform desired action on future frames + posData.doNotShowAgain_EditID = doNotShowAgain + posData.UndoFutFrames_EditID = UndoFutFrames + posData.applyFutFrames_EditID = applyFutFrames + includeUnvisited = ( + posData.includeUnvisitedInfo["Edit ID"] or doPropagateUnvisited ) - return self.edit_id.edit_id_info_from_df( - df, - posData.rp, - is_3d=self.isSegm3D, - depth_axis=depth_axis, + + if not applyFutFrames and not doPropagateUnvisited: + return + + self.changeIDfutureFrames( + endFrame_i, oldIDnewIDMapper, includeUnvisited, shift=shift ) def apply_manual_edits_to_lab_if_needed(self, lab): posData = self.data[self.pos_i] data_frame_i = posData.allData_li[posData.frame_i] - edited_lab_dict = data_frame_i['manually_edited_lab']['lab'] - if not self.should_apply_manual_edits(edited_lab_dict): + edited_lab_dict = data_frame_i["manually_edited_lab"]["lab"] + if not edited_lab_dict: return lab # zoom_slice = data_frame_i['manually_edited_lab']['zoom_slice'] @@ -163,141 +191,98 @@ def apply_manual_edits_to_lab_if_needed(self, lab): return lab - def store_zslices_rp(self, force_update=False): - if not self.should_store_zslice_regionprops( - is_segm_3d=self.isSegm3D - ): - return - + def assignNewIDfromClickedID(self, clickedID: int, event: QGraphicsSceneMouseEvent): posData = self.data[self.pos_i] - are_zslices_rp_stored = ( - posData.allData_li[posData.frame_i].get('z_slices_rp') is not None - ) - if self.should_update_zslice_regionprops( - force_update=force_update, - already_stored=are_zslices_rp_stored, - ): - self._update_zslices_rp() - - posData.allData_li[posData.frame_i]['z_slices_rp'] = posData.zSlicesRp - - def removeObjectFromRp(self, delID): - posData = self.data[self.pos_i] - rp = [] - IDs = [] - IDs_idxs = {} - idx = 0 - for obj in posData.rp: - if obj.label == delID: - continue - rp.append(obj) - IDs.append(obj.label) - IDs_idxs[obj.label] = idx - idx += 1 - - posData.rp = rp - posData.IDs = IDs - posData.IDs_idxs = IDs_idxs - - if not self.isSegm3D: - return - - zSlicesRp = {} - for z, zSliceRp in posData.zSlicesRp.items(): - if delID in zSliceRp: - continue - - zSlicesRp[z] = zSlicesRp - - posData.zSlicesRp = zSlicesRp - self.store_zslices_rp(force_update=True) - - def get_zslices_rp(self): - if not self.isSegm3D: - return + x, y = event.pos().x(), event.pos().y() + newID = self.setBrushID(return_val=True) + mapper = [(clickedID, newID)] + self.applyEditID(clickedID, posData.IDs.copy(), mapper, x, y) + def changeIDfutureFrames( + self, endFrame_i, oldIDnewIDMapper, includeUnvisited, shift=False + ): posData = self.data[self.pos_i] - self.store_zslices_rp() - posData.zSlicesRp = posData.allData_li[posData.frame_i]['z_slices_rp'] + self.current_frame_i = posData.frame_i - # @exec_time - def _update_zslices_rp(self): - if not self.isSegm3D: + # Store data for current frame + self.store_data() + if endFrame_i is None: + self.app.restoreOverrideCursor() return - posData = self.data[self.pos_i] - posData.zSlicesRp = {} - for z, lab2d in enumerate(posData.lab): - lab2d_rp = skimage.measure.regionprops(lab2d) - posData.zSlicesRp[z] = {obj.label:obj for obj in lab2d_rp} + segmSizeT = len(posData.segm_data) + for i in range(posData.frame_i + 1, segmSizeT): + lab = posData.allData_li[i]["labels"] + if lab is None and not includeUnvisited: + self.enqAutosave() + break - @exception_handler - def update_rp( - self, draw=True, debug=False, update_IDs=True, - wl_update=True, wl_track_og_curr=False,wl_update_lab=False - ): + if lab is not None: + # Visited frame + posData.frame_i = i + self.get_data(lin_tree_init=False) + if shift and self.isSegm3D: + lab = self.get_2Dlab(posData.lab) + else: + lab = posData.lab - posData = self.data[self.pos_i] - # Update rp for current posData.lab (e.g. after any change) + if self.onlyTracking: + self.tracking(enforce=True) + elif not posData.IDs: + continue + else: + maxID = max(posData.IDs, default=0) + 1 + for old_ID, new_ID in oldIDnewIDMapper: + if new_ID in lab: + tempID = maxID + 1 # lab.max() + 1 + lab[lab == old_ID] = tempID + lab[lab == new_ID] = old_ID + lab[lab == tempID] = new_ID + maxID += 1 + else: + lab[lab == old_ID] = new_ID - if wl_update: - if self.whitelistOriginalIDs is None: - old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() # for whitelist stuff - else: - old_IDs = self.whitelistOriginalIDs.copy() - self.whitelistOriginalIDs = None - elif self.whitelistOriginalIDs is None: - self.whitelist_old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() + if shift and self.isSegm3D: + self.set_2Dlab(lab) - posData.rp = skimage.measure.regionprops(posData.lab) - if update_IDs: - IDs = [] - IDs_idxs = {} - for idx, obj in enumerate(posData.rp): - IDs.append(obj.label) - IDs_idxs[obj.label] = idx - posData.IDs = IDs - posData.IDs_idxs = IDs_idxs - self.update_rp_metadata(draw=draw) - self.store_zslices_rp(force_update=True) + self.update_rp(draw=False) + self.store_data(autosave=i == endFrame_i) + elif includeUnvisited: + # Unvisited frame (includeUnvisited = True) + lab = posData.segm_data[i] + if shift and self.isSegm3D: + lab = self.get_2Dlab(lab) + else: + lab = lab - if not wl_update: - return + for old_ID, new_ID in oldIDnewIDMapper: + if new_ID in lab: + tempID = lab.max() + 1 + lab[lab == old_ID] = tempID + lab[lab == new_ID] = old_ID + lab[lab == tempID] = new_ID + else: + lab[lab == old_ID] = new_ID - # Update tracking whitelist - accepted_lost_centroids = self.getTrackedLostIDs() - new_IDs = posData.IDs - added_IDs = set(new_IDs) - set(old_IDs) - removed_IDs = ( - set(old_IDs) - - set(new_IDs) - - set(accepted_lost_centroids) - ) + if shift and self.isSegm3D: + posData.segm_data[i][self.z_lab()] = lab - self.whitelistPropagateIDs( - IDs_to_add=added_IDs, IDs_to_remove=removed_IDs, - curr_frame_only=True, IDs_curr=new_IDs, - track_og_curr=wl_track_og_curr, - curr_lab=posData.lab, curr_rp=posData.rp, - update_lab=wl_update_lab - ) + # Back to current frame + posData.frame_i = self.current_frame_i + self.get_data() + self.app.restoreOverrideCursor() def delBorderObj(self, checked): # Store undo state before modifying stuff self.storeUndoRedoStates(False) posData = self.data[self.pos_i] - clear_result = self.label_edits.clear_border_labels( - posData.lab, buffer_size=1 - ) - posData.lab = clear_result.labels + posData.lab = skimage.segmentation.clear_border(posData.lab, buffer_size=1) + oldIDs = posData.IDs.copy() self.update_rp() + removedIDs = [ID for ID in oldIDs if ID not in posData.IDs] if posData.cca_df is not None: - deletion_result = self.cca_edits.delete_ids( - posData.cca_df, - clear_result.removed_ids, - ) - posData.cca_df = deletion_result.cca_df + posData.cca_df = posData.cca_df.drop(index=removedIDs) self.store_data() self.updateAllImages() @@ -311,54 +296,23 @@ def delNewObj(self, checked): if frame_i == 0: return - prev_IDs = posData.allData_li[frame_i-1]['IDs'] + prev_IDs = posData.allData_li[frame_i - 1]["IDs"] curr_IDs = posData.IDs - removal_result = self.label_edits.remove_new_labels( - posData.lab, - prev_IDs, - curr_IDs, - ) - new_IDs = removal_result.removed_ids - posData.lab = removal_result.labels + new_IDs = list(set(curr_IDs) - set(prev_IDs)) + + lab = posData.lab + del_mask = np.isin(lab, new_IDs) + lab[del_mask] = 0 + posData.lab = lab self.update_rp() if posData.cca_df is not None: - deletion_result = self.cca_edits.delete_ids( - posData.cca_df, - new_IDs, - ) - posData.cca_df = deletion_result.cca_df + posData.cca_df = posData.cca_df.drop(index=new_IDs) self.store_data() self.updateAllImages() - def getClickedID(self, xdata, ydata, text=''): - posData = self.data[self.pos_i] - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if self.should_prompt_for_background_id(ID): - msg = ( - 'You clicked on the background.\n' - f'Enter here the ID {text}' - ) - nearest_ID = self.label_edits.nearest_nonzero_2d( - self.get_2Dlab(posData.lab), xdata, ydata - ) - clickedBkgrID = apps.QLineEditDialog( - title='Clicked on background', - msg=msg, parent=self.host, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - clickedBkgrID.exec_() - if clickedBkgrID.cancel: - return - else: - ID = clickedBkgrID.EntryID - return ID - - def deleteIDFromLab( - self, lab, delID, frame_i=None, delMask=None, shift=False - ): + def deleteIDFromLab(self, lab, delID, frame_i=None, delMask=None, shift=False): posData = self.data[self.pos_i] frame_i = posData.frame_i if frame_i is None else frame_i @@ -371,12 +325,12 @@ def deleteIDFromLab( rp = skimage.measure.regionprops(lab) IDs_idxs = {obj.label: idx for idx, obj in enumerate(rp)} else: - if frame_i==posData.frame_i: + if frame_i == posData.frame_i: rp = posData.rp IDs_idxs = posData.IDs_idxs else: - rp = posData.allData_li[frame_i]['regionprops'] - IDs_idxs = posData.allData_li[frame_i]['IDs_idxs'] + rp = posData.allData_li[frame_i]["regionprops"] + IDs_idxs = posData.allData_li[frame_i]["IDs_idxs"] if isinstance(delID, int): delID = [delID] @@ -412,286 +366,97 @@ def deleteIDFromLab( return lab, delMask - def removeStoredContours(self, delID, frame_i=None, z_slice=None): - posData = self.data[self.pos_i] - - if frame_i is None: - frame_i = posData.frame_i - - dataDict = posData.allData_li[posData.frame_i] - try: - newContours = {} - for key, contours in dataDict['contours'].items(): - ID = key[0] - if ID == delID: - continue - - if z_slice is not None: - z_slice_i = key[1] - if z_slice_i != z_slice: - continue - - newContours[key] = contours - - dataDict['contours'] = newContours - except KeyError as err: - pass - @disableWindow def deleteIDmiddleClick( - self, delIDs: Iterable, applyFutFrames, includeUnvisited, - shift=False - ): + self, delIDs: Iterable, applyFutFrames, includeUnvisited, shift=False + ): self.clearHighlightedID() posData = self.data[self.pos_i] current_frame_i = posData.frame_i - # Apply Delete ID to future frames if requested - if applyFutFrames: - delMask = np.zeros(posData.lab.shape, dtype=bool) - # Store current data before going to future frames - self.store_data() - segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i+1, segmSizeT): - lab = posData.allData_li[i]['labels'] - if lab is None and not includeUnvisited: - self.enqAutosave() - break - - if lab is not None: - # Visited frame - lab, _ = self.deleteIDFromLab( - lab, delIDs, frame_i=i, delMask=delMask, shift=shift - ) - - # Store change - posData.allData_li[i]['labels'] = lab - # Get the rest of the stored metadata based on the new lab - posData.frame_i = i - self.get_data() - self.store_data(autosave=False) - elif includeUnvisited: - # Unvisited frame (includeUnvisited = True) - lab = posData.segm_data[i] - lab, _ = self.deleteIDFromLab( - lab, delIDs, frame_i=i, delMask=delMask, shift=shift - ) - - # Back to current frame - if applyFutFrames: - posData.frame_i = current_frame_i - self.get_data() - - z_slice = None - if shift and self.isSegm3D: - z_slice = self.z_lab() - - posData.lab, delID_mask = self.deleteIDFromLab( - posData.lab, delIDs, shift=shift - ) - for _delID in delIDs: - self.clearObjContour(ID=_delID, ax=0) - self.clearObjContour(ID=_delID, ax=1) - if z_slice is None: - self.removeObjectFromRp(_delID) - self.removeStoredContours(_delID, z_slice=z_slice) - - if shift and self.isSegm3D: - self.update_rp() - - self.store_data(autosave=False) - self.whitelistPropagateIDs( - IDs_to_remove=delIDs, curr_frame_only=(not applyFutFrames) - ) - return delID_mask - - # @exec_time - def applyEditID( - self, clickedID, currentIDs, oldIDnewIDMapper, clicked_x, clicked_y, shift=False, doPropagateUnvisited=False - ): - posData = self.data[self.pos_i] - - # Ask to propagate change to all future visited frames - key = 'Edit ID' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - clickedID, key, doNotShow, - posData.UndoFutFrames_EditID, posData.applyFutFrames_EditID, - applyTrackingB=True - ) - - if UndoFutFrames is None: - return - - if shift and self.isSegm3D: - lab = self.get_2Dlab(posData.lab) - else: - lab = posData.lab - - # Store undo state before modifying stuff - self.storeUndoRedoStates(UndoFutFrames) - maxID = max(posData.IDs, default=0) - for old_ID, new_ID in oldIDnewIDMapper: - result = self.label_edits.apply_id_mapping( - lab, - [(old_ID, new_ID)], - existing_ids=currentIDs, - merge_existing=self.editIDmergeIDs, - start_max_id=maxID, - ) - maxID = result.max_id - if new_ID in currentIDs and not self.editIDmergeIDs: - old_ID_idx = currentIDs.index(old_ID) - new_ID_idx = currentIDs.index(new_ID) - - # Append information for replicating the edit in tracking - # List of tuples (y, x, replacing ID) - objo = posData.rp[old_ID_idx] - yo, xo = self.getObjCentroid(objo.centroid) - objn = posData.rp[new_ID_idx] - yn, xn = self.getObjCentroid(objn.centroid) - if not math.isnan(yo) and not math.isnan(yn): - yn, xn = int(yn), int(xn) - posData.editID_info.append((yn, xn, new_ID)) - yo, xo = int(clicked_y), int(clicked_x) - posData.editID_info.append((yo, xo, old_ID)) - else: - old_ID_idx = posData.IDs.index(old_ID) - - # Append information for replicating the edit in tracking - # List of tuples (y, x, replacing ID) - obj = posData.rp[old_ID_idx] - y, x = self.getObjCentroid(obj.centroid) - if not math.isnan(y) and not math.isnan(y): - y, x = int(y), int(x) - posData.editID_info.append((y, x, new_ID)) - - self.updateAssignedObjsAcdcTrackerSecondStep(new_ID) - - if shift and self.isSegm3D: - self.set_2Dlab(lab) - - # Update rps - self.update_rp() - - # Since we manually changed an ID we don't want to repeat tracking - self.setAllTextAnnotations() - self.highlightLostNew() - # self.checkIDsMultiContour() - - # Update colors for the edited IDs - self.updateLookuptable() - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Edit ID') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Edit ID', update_images=False) - - if not self.editIDbutton.findChild(QAction).isChecked(): - self.editIDbutton.setChecked(False) - - posData.disableAutoActivateViewerWindow = True - - # Perform desired action on future frames - posData.doNotShowAgain_EditID = doNotShowAgain - posData.UndoFutFrames_EditID = UndoFutFrames - posData.applyFutFrames_EditID = applyFutFrames - includeUnvisited = ( - posData.includeUnvisitedInfo['Edit ID'] - or doPropagateUnvisited - ) - - if not applyFutFrames and not doPropagateUnvisited: - return - - self.changeIDfutureFrames( - endFrame_i, oldIDnewIDMapper, includeUnvisited, - shift=shift - ) - - def changeIDfutureFrames( - self, endFrame_i, oldIDnewIDMapper, includeUnvisited, - shift=False - ): - posData = self.data[self.pos_i] - self.current_frame_i = posData.frame_i - - # Store data for current frame - self.store_data() - if endFrame_i is None: - self.app.restoreOverrideCursor() - return - - segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i+1, segmSizeT): - lab = posData.allData_li[i]['labels'] - if lab is None and not includeUnvisited: - self.enqAutosave() - break - - if lab is not None: - # Visited frame - posData.frame_i = i - self.get_data(lin_tree_init=False) - if shift and self.isSegm3D: - lab = self.get_2Dlab(posData.lab) - else: - lab = posData.lab - - if self.onlyTracking: - self.tracking(enforce=True) - elif not posData.IDs: - continue - else: - maxID = max(posData.IDs, default=0) + 1 - for old_ID, new_ID in oldIDnewIDMapper: - result = self.label_edits.apply_id_mapping( - lab, - [(old_ID, new_ID)], - start_max_id=maxID, - ) - maxID = result.max_id - - if shift and self.isSegm3D: - self.set_2Dlab(lab) - - self.update_rp(draw=False) - self.store_data(autosave=i==endFrame_i) - elif includeUnvisited: - # Unvisited frame (includeUnvisited = True) - lab = posData.segm_data[i] - if shift and self.isSegm3D: - lab = self.get_2Dlab(lab) - else: - lab = lab + # Apply Delete ID to future frames if requested + if applyFutFrames: + delMask = np.zeros(posData.lab.shape, dtype=bool) + # Store current data before going to future frames + self.store_data() + segmSizeT = len(posData.segm_data) + for i in range(posData.frame_i + 1, segmSizeT): + lab = posData.allData_li[i]["labels"] + if lab is None and not includeUnvisited: + self.enqAutosave() + break - for old_ID, new_ID in oldIDnewIDMapper: - self.label_edits.apply_id_mapping( - lab, [(old_ID, new_ID)] + if lab is not None: + # Visited frame + lab, _ = self.deleteIDFromLab( + lab, delIDs, frame_i=i, delMask=delMask, shift=shift ) - if shift and self.isSegm3D: - posData.segm_data[i][self.z_lab()] = lab + # Store change + posData.allData_li[i]["labels"] = lab + # Get the rest of the stored metadata based on the new lab + posData.frame_i = i + self.get_data() + self.store_data(autosave=False) + elif includeUnvisited: + # Unvisited frame (includeUnvisited = True) + lab = posData.segm_data[i] + lab, _ = self.deleteIDFromLab( + lab, delIDs, frame_i=i, delMask=delMask, shift=shift + ) # Back to current frame - posData.frame_i = self.current_frame_i - self.get_data() - self.app.restoreOverrideCursor() + if applyFutFrames: + posData.frame_i = current_frame_i + self.get_data() - def getLastHoveredID(self): - if self.xHoverImg is None: - return 0 + z_slice = None + if shift and self.isSegm3D: + z_slice = self.z_lab() - xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) - ID = self.currentLab2D[ydata, xdata] + posData.lab, delID_mask = self.deleteIDFromLab(posData.lab, delIDs, shift=shift) + for _delID in delIDs: + self.clearObjContour(ID=_delID, ax=0) + self.clearObjContour(ID=_delID, ax=1) + if z_slice is None: + self.removeObjectFromRp(_delID) + self.removeStoredContours(_delID, z_slice=z_slice) + + if shift and self.isSegm3D: + self.update_rp() + + self.store_data(autosave=False) + self.whitelistPropagateIDs( + IDs_to_remove=delIDs, curr_frame_only=(not applyFutFrames) + ) + return delID_mask + + def getClickedID(self, xdata, ydata, text=""): + posData = self.data[self.pos_i] + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + if ID == 0: + msg = f"You clicked on the background.\nEnter here the ID {text}" + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), xdata, ydata + ) + clickedBkgrID = apps.QLineEditDialog( + title="Clicked on background", + msg=msg, + parent=self, + allowedValues=posData.IDs, + defaultTxt=str(nearest_ID), + isInteger=True, + ) + clickedBkgrID.exec_() + if clickedBkgrID.cancel: + return + else: + ID = clickedBkgrID.EntryID return ID def getHoverID(self, xdata, ydata, byPassShiftCheck=False): - if not hasattr(self, 'diskMask'): + if not hasattr(self, "diskMask"): return 0 modifiers = QGuiApplication.keyboardModifiers() @@ -717,36 +482,36 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): SizeZ = posData.lab.shape[0] doNotLinkThroughZ = self.brushButton.isChecked() and shift if doNotLinkThroughZ: - if self.brushHoverCenterModeAction.isChecked() or ID>0: + if self.brushHoverCenterModeAction.isChecked() or ID > 0: hoverID = ID else: masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] hoverID = np.bincount(masked_lab).argmax() else: if z > 0: - ID_z_under = posData.lab[z-1, ydata, xdata] - if self.brushHoverCenterModeAction.isChecked() or ID_z_under>0: + ID_z_under = posData.lab[z - 1, ydata, xdata] + if self.brushHoverCenterModeAction.isChecked() or ID_z_under > 0: hoverIDa = ID_z_under else: lab = posData.lab - masked_lab_a = lab[z-1, ymin:ymax, xmin:xmax][diskMask] + masked_lab_a = lab[z - 1, ymin:ymax, xmin:xmax][diskMask] hoverIDa = np.bincount(masked_lab_a).argmax() else: hoverIDa = 0 - if self.brushHoverCenterModeAction.isChecked() or ID>0: + if self.brushHoverCenterModeAction.isChecked() or ID > 0: hoverIDb = lab_2D[ydata, xdata] else: masked_lab_b = lab_2D[ymin:ymax, xmin:xmax][diskMask] hoverIDb = np.bincount(masked_lab_b).argmax() - if z < SizeZ-1: - ID_z_above = posData.lab[z+1, ydata, xdata] - if self.brushHoverCenterModeAction.isChecked() or ID_z_above>0: + if z < SizeZ - 1: + ID_z_above = posData.lab[z + 1, ydata, xdata] + if self.brushHoverCenterModeAction.isChecked() or ID_z_above > 0: hoverIDc = ID_z_above else: lab = posData.lab - masked_lab_c = lab[z+1, ymin:ymax, xmin:xmax][diskMask] + masked_lab_c = lab[z + 1, ymin:ymax, xmin:xmax][diskMask] hoverIDc = np.bincount(masked_lab_c).argmax() else: hoverIDc = 0 @@ -762,13 +527,10 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): else: hoverID = 0 else: - if self.should_force_new_hover_id( - brush_active=self.brushButton.isChecked(), - shift_pressed=shift, - ): + if self.brushButton.isChecked() and shift: # Force new ID with brush and Shift hoverID = 0 - elif self.brushHoverCenterModeAction.isChecked() or ID>0: + elif self.brushHoverCenterModeAction.isChecked() or ID > 0: hoverID = ID else: masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] @@ -778,10 +540,114 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): return hoverID + def getLastHoveredID(self): + if self.xHoverImg is None: + return 0 + + xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) + ID = self.currentLab2D[ydata, xdata] + return ID + + def get_zslices_rp(self): + if not self.isSegm3D: + return + + posData = self.data[self.pos_i] + self.store_zslices_rp() + posData.zSlicesRp = posData.allData_li[posData.frame_i]["z_slices_rp"] + + def isPowerBrush(self): + color = self.brushButton.palette().button().color().name() + return color == self.doublePressKeyButtonColor + + def isPowerButton(self, button): + color = button.palette().button().color().name() + return color == self.doublePressKeyButtonColor + + def isPowerEraser(self): + color = self.eraserButton.palette().button().color().name() + return color == self.doublePressKeyButtonColor + + def is_power_button_color( + self, + *, + button_color: str, + power_color: str, + ) -> bool: + return button_color == power_color + + def mergeObjs_cb(self, checked): + if not checked: + self.mergeObjsTempLine.setData([], []) + + def removeObjectFromRp(self, delID): + posData = self.data[self.pos_i] + rp = [] + IDs = [] + IDs_idxs = {} + idx = 0 + for obj in posData.rp: + if obj.label == delID: + continue + rp.append(obj) + IDs.append(obj.label) + IDs_idxs[obj.label] = idx + idx += 1 + + posData.rp = rp + posData.IDs = IDs + posData.IDs_idxs = IDs_idxs + + if not self.isSegm3D: + return + + zSlicesRp = {} + for z, zSliceRp in posData.zSlicesRp.items(): + if delID in zSliceRp: + continue + + zSlicesRp[z] = zSlicesRp + + posData.zSlicesRp = zSlicesRp + self.store_zslices_rp(force_update=True) + + def removeStoredContours(self, delID, frame_i=None, z_slice=None): + posData = self.data[self.pos_i] + + if frame_i is None: + frame_i = posData.frame_i + + dataDict = posData.allData_li[posData.frame_i] + try: + newContours = {} + for key, contours in dataDict["contours"].items(): + ID = key[0] + if ID == delID: + continue + + if z_slice is not None: + z_slice_i = key[1] + if z_slice_i != z_slice: + continue + + newContours[key] = contours + + dataDict["contours"] = newContours + except KeyError: + pass + def setHoverToolSymbolColor( - self, xdata, ydata, pen, ScatterItems, button, - brush=None, hoverRGB=None, ID=None, byPassShiftCheck=False - ): + self, + xdata, + ydata, + pen, + ScatterItems, + button, + brush=None, + hoverRGB=None, + ID=None, + byPassShiftCheck=False, + ): modifiers = QGuiApplication.keyboardModifiers() if byPassShiftCheck: shift = False @@ -790,14 +656,12 @@ def setHoverToolSymbolColor( posData = self.data[self.pos_i] Y, X = self.get_2Dlab(posData.lab).shape - if not self.geometry.is_in_bounds(xdata, ydata, X, Y): + if not myutils.is_in_bounds(xdata, ydata, X, Y): return self.isHoverZneighID = False if ID is None: - hoverID = self.getHoverID( - xdata, ydata, byPassShiftCheck=byPassShiftCheck - ) + hoverID = self.getHoverID(xdata, ydata, byPassShiftCheck=byPassShiftCheck) else: hoverID = ID @@ -809,18 +673,15 @@ def setHoverToolSymbolColor( try: rgb = self.lut[hoverID] rgb = rgb if hoverRGB is None else hoverRGB - rgbPen = np.clip(rgb*1.1, 0, 255) + rgbPen = np.clip(rgb * 1.1, 0, 255) for item in ScatterItems: item.setPen(*rgbPen, width=2) item.setBrush(*rgb, 100) except IndexError: pass - checkChangeID = self.should_restore_brush_id_from_hover( - is_hover_z_neighbor=self.isHoverZneighID, - shift_pressed=shift, - last_hover_id=self.lastHoverID, - hover_id=hoverID, + checkChangeID = ( + self.isHoverZneighID and not shift and self.lastHoverID != hoverID ) if checkChangeID: # We are hovering an ID in z+1 or z-1 @@ -829,23 +690,134 @@ def setHoverToolSymbolColor( self.lastHoverID = hoverID - def isPowerBrush(self): - color = self.brushButton.palette().button().color().name() - return self.is_power_button_color( - button_color=color, - power_color=self.doublePressKeyButtonColor, - ) + def should_apply_manual_edits(self, edited_labels_by_z) -> bool: + return bool(edited_labels_by_z) - def isPowerEraser(self): - color = self.eraserButton.palette().button().color().name() - return self.is_power_button_color( - button_color=color, - power_color=self.doublePressKeyButtonColor, + def should_force_new_hover_id( + self, + *, + brush_active: bool, + shift_pressed: bool, + ) -> bool: + return brush_active and shift_pressed + + def should_prompt_for_background_id(self, clicked_id: int) -> bool: + return clicked_id == 0 + + def should_restore_brush_id_from_hover( + self, + *, + is_hover_z_neighbor: bool, + shift_pressed: bool, + last_hover_id: int, + hover_id: int, + ) -> bool: + return is_hover_z_neighbor and not shift_pressed and last_hover_id != hover_id + + LEGACY_METHODS = ( + "mergeObjs_cb", + "assignNewIDfromClickedID", + "addYXcentroidToDf", + "_get_editID_info", + "apply_manual_edits_to_lab_if_needed", + "store_zslices_rp", + "removeObjectFromRp", + "get_zslices_rp", + "_update_zslices_rp", + "update_rp", + "delBorderObj", + "delNewObj", + "getClickedID", + "deleteIDFromLab", + "removeStoredContours", + "deleteIDmiddleClick", + "applyEditID", + "changeIDfutureFrames", + "getLastHoveredID", + "getHoverID", + "setHoverToolSymbolColor", + "isPowerBrush", + "isPowerEraser", + "isPowerButton", + ) + + def should_store_zslice_regionprops(self, *, is_segm_3d: bool) -> bool: + return is_segm_3d + + def should_update_zslice_regionprops( + self, + *, + force_update: bool, + already_stored: bool, + ) -> bool: + return force_update or not already_stored + + def store_zslices_rp(self, force_update=False): + if not self.isSegm3D: + return + + posData = self.data[self.pos_i] + are_zslices_rp_stored = ( + posData.allData_li[posData.frame_i].get("z_slices_rp") is not None ) + if force_update or not are_zslices_rp_stored: + self._update_zslices_rp() - def isPowerButton(self, button): - color = button.palette().button().color().name() - return self.is_power_button_color( - button_color=color, - power_color=self.doublePressKeyButtonColor, - ) \ No newline at end of file + posData.allData_li[posData.frame_i]["z_slices_rp"] = posData.zSlicesRp + + @exception_handler + def update_rp( + self, + draw=True, + debug=False, + update_IDs=True, + wl_update=True, + wl_track_og_curr=False, + wl_update_lab=False, + ): + + posData = self.data[self.pos_i] + # Update rp for current posData.lab (e.g. after any change) + + if wl_update: + if self.whitelistOriginalIDs is None: + old_IDs = posData.allData_li[posData.frame_i][ + "IDs" + ].copy() # for whitelist stuff + else: + old_IDs = self.whitelistOriginalIDs.copy() + self.whitelistOriginalIDs = None + elif self.whitelistOriginalIDs is None: + self.whitelist_old_IDs = posData.allData_li[posData.frame_i]["IDs"].copy() + + posData.rp = skimage.measure.regionprops(posData.lab) + if update_IDs: + IDs = [] + IDs_idxs = {} + for idx, obj in enumerate(posData.rp): + IDs.append(obj.label) + IDs_idxs[obj.label] = idx + posData.IDs = IDs + posData.IDs_idxs = IDs_idxs + self.update_rp_metadata(draw=draw) + self.store_zslices_rp(force_update=True) + + if not wl_update: + return + + # Update tracking whitelist + accepted_lost_centroids = self.getTrackedLostIDs() + new_IDs = posData.IDs + added_IDs = set(new_IDs) - set(old_IDs) + removed_IDs = set(old_IDs) - set(new_IDs) - set(accepted_lost_centroids) + + self.whitelistPropagateIDs( + IDs_to_add=added_IDs, + IDs_to_remove=removed_IDs, + curr_frame_only=True, + IDs_curr=new_IDs, + track_og_curr=wl_track_og_curr, + curr_lab=posData.lab, + curr_rp=posData.rp, + update_lab=wl_update_lab, + ) diff --git a/cellacdc/mixins/label_edits.py b/cellacdc/mixins/label_edits.py index 722c24a99..a43aa82d4 100644 --- a/cellacdc/mixins/label_edits.py +++ b/cellacdc/mixins/label_edits.py @@ -47,9 +47,43 @@ from cellacdc.myutils import get_trimmed_list -class LabelEditViewModel: +class LabelEditMixin: """Application-facing commands for editing label arrays.""" + def apply_deleted_roi_masks( + self, + labels_2d: np.ndarray, + roi_masks, + deleted_masks, + deleted_ids_by_roi, + ) -> DeletedRoiApplyResult: + return apply_deleted_roi_masks( + labels_2d, + roi_masks, + deleted_masks, + deleted_ids_by_roi, + ) + + def apply_id_mapping( + self, + labels: np.ndarray, + old_new_pairs, + *, + existing_ids=None, + merge_existing: bool = False, + start_max_id: int | None = None, + ) -> LabelIdMappingResult: + return apply_label_id_mapping( + labels, + old_new_pairs, + existing_ids=existing_ids, + merge_existing=merge_existing, + start_max_id=start_max_id, + ) + + def binary_fill_holes(self, mask: np.ndarray, *, slice_by_slice: bool = True): + return core_binary_fill_holes(mask, slice_by_slice=slice_by_slice) + def clear_border_labels( self, labels: np.ndarray, @@ -58,13 +92,14 @@ def clear_border_labels( ) -> LabelBorderClearResult: return clear_border_labels(labels, buffer_size=buffer_size) - def remove_new_labels( - self, - labels: np.ndarray, - previous_ids, - current_ids, - ) -> LabelIdsRemovalResult: - return remove_new_label_ids(labels, previous_ids, current_ids) + def collect_deleted_roi_ids(self, deleted_ids_by_roi) -> set[int]: + return collect_deleted_roi_ids(deleted_ids_by_roi) + + def convex_hull_mask(self, mask: np.ndarray, *, slice_by_slice: bool = True): + return core_convex_hull_mask(mask, slice_by_slice=slice_by_slice) + + def count_objects(self, position_data, logger_func): + return core_count_objects(position_data, logger_func) def fill_label_holes( self, @@ -73,18 +108,8 @@ def fill_label_holes( ) -> LabelHoleFillResult: return fill_label_holes(labels_2d, label_id) - def select_labels_in_region( - self, - labels: np.ndarray, - mask: np.ndarray, - *, - enclosed_only: bool = False, - ) -> LabelRegionSelectionResult: - return select_labels_in_region( - labels, - mask, - enclosed_only=enclosed_only, - ) + def format_trimmed_ids(self, ids, *, max_num_digits=10): + return get_trimmed_list(list(ids), max_num_digits=max_num_digits) def index_label_roi( self, @@ -105,27 +130,32 @@ def index_label_roi( replace_existing=replace_existing, ) - def resize_label_object( + def label_ids_from_labels(self, labels: np.ndarray) -> list[int]: + return label_ids_from_labels(labels) + + def label_ids_in_masks( self, - labels_2d: np.ndarray, - active_labels_2d: np.ndarray, - object_coords: np.ndarray, - label_id: int, - footprint_size: int, + labels: np.ndarray, + masks, *, - dilation: bool = True, - seed_labels: np.ndarray | None = None, - ) -> LabelResizeResult: - return resize_label_object( - labels_2d, - active_labels_2d, - object_coords, - label_id, - footprint_size, - dilation=dilation, - seed_labels=seed_labels, + additional_labels: np.ndarray | None = None, + ) -> set[int]: + return label_ids_in_masks( + labels, + masks, + additional_labels=additional_labels, ) + def line_roi_mask( + self, + shape: tuple[int, ...], + point1, + point2, + *, + z_slice=None, + ) -> np.ndarray: + return line_roi_mask(shape, point1, point2, z_slice=z_slice) + def move_label_object( self, labels: np.ndarray, @@ -145,70 +175,37 @@ def move_label_object( shape=shape, ) - def apply_id_mapping( - self, - labels: np.ndarray, - old_new_pairs, - *, - existing_ids=None, - merge_existing: bool = False, - start_max_id: int | None = None, - ) -> LabelIdMappingResult: - return apply_label_id_mapping( - labels, - old_new_pairs, - existing_ids=existing_ids, - merge_existing=merge_existing, - start_max_id=start_max_id, - ) - - def restore_deleted_roi_labels( + def nearest_nonzero_2d( self, labels_2d: np.ndarray, - display_labels_2d: np.ndarray, - deleted_mask: np.ndarray, - roi_mask: np.ndarray, - deleted_ids, + y, + x, *, - enforce: bool = True, - ) -> DeletedRoiRestoreResult: - return restore_deleted_roi_labels( + max_dist=None, + return_coords: bool = False, + ): + return nearest_nonzero_2D( labels_2d, - display_labels_2d, - deleted_mask, - roi_mask, - deleted_ids, - enforce=enforce, - ) - - def label_ids_in_masks( - self, - labels: np.ndarray, - masks, - *, - additional_labels: np.ndarray | None = None, - ) -> set[int]: - return label_ids_in_masks( - labels, - masks, - additional_labels=additional_labels, + y, + x, + max_dist=max_dist, + return_coords=return_coords, ) - def collect_deleted_roi_ids(self, deleted_ids_by_roi) -> set[int]: - return collect_deleted_roi_ids(deleted_ids_by_roi) + def nearest_nonzero_z_from_centroid(self, obj, *, current_z: int = -1): + return nearest_nonzero_z_idx_from_z_centroid(obj, current_z=current_z) - def apply_deleted_roi_masks( + def next_available_label_id( self, - labels_2d: np.ndarray, - roi_masks, - deleted_masks, - deleted_ids_by_roi, - ) -> DeletedRoiApplyResult: - return apply_deleted_roi_masks( - labels_2d, - roi_masks, - deleted_masks, - deleted_ids_by_roi, + id_groups=(), + *, + manual_edit_info=(), + base_id: int = 0, + ) -> int: + return next_available_label_id( + id_groups, + manual_edit_info=manual_edit_info, + base_id=base_id, ) def polygon_roi_mask( @@ -220,16 +217,6 @@ def polygon_roi_mask( ) -> np.ndarray: return polygon_roi_mask(shape, points, z_slice=z_slice) - def line_roi_mask( - self, - shape: tuple[int, ...], - point1, - point2, - *, - z_slice=None, - ) -> np.ndarray: - return line_roi_mask(shape, point1, point2, z_slice=z_slice) - def rectangle_roi_mask( self, shape: tuple[int, ...], @@ -240,24 +227,69 @@ def rectangle_roi_mask( ) -> np.ndarray: return rectangle_roi_mask(shape, origin, size, z_slice=z_slice) - def next_available_label_id( + def remap_id_set(self, ids, old_ids, new_ids) -> set[int]: + return remap_id_set(ids, old_ids, new_ids) + + def remove_new_labels( self, - id_groups=(), + labels: np.ndarray, + previous_ids, + current_ids, + ) -> LabelIdsRemovalResult: + return remove_new_label_ids(labels, previous_ids, current_ids) + + def resize_label_object( + self, + labels_2d: np.ndarray, + active_labels_2d: np.ndarray, + object_coords: np.ndarray, + label_id: int, + footprint_size: int, *, - manual_edit_info=(), - base_id: int = 0, - ) -> int: - return next_available_label_id( - id_groups, - manual_edit_info=manual_edit_info, - base_id=base_id, + dilation: bool = True, + seed_labels: np.ndarray | None = None, + ) -> LabelResizeResult: + return resize_label_object( + labels_2d, + active_labels_2d, + object_coords, + label_id, + footprint_size, + dilation=dilation, + seed_labels=seed_labels, ) - def label_ids_from_labels(self, labels: np.ndarray) -> list[int]: - return label_ids_from_labels(labels) + def restore_deleted_roi_labels( + self, + labels_2d: np.ndarray, + display_labels_2d: np.ndarray, + deleted_mask: np.ndarray, + roi_mask: np.ndarray, + deleted_ids, + *, + enforce: bool = True, + ) -> DeletedRoiRestoreResult: + return restore_deleted_roi_labels( + labels_2d, + display_labels_2d, + deleted_mask, + roi_mask, + deleted_ids, + enforce=enforce, + ) - def remap_id_set(self, ids, old_ids, new_ids) -> set[int]: - return remap_id_set(ids, old_ids, new_ids) + def select_labels_in_region( + self, + labels: np.ndarray, + mask: np.ndarray, + *, + enclosed_only: bool = False, + ) -> LabelRegionSelectionResult: + return select_labels_in_region( + labels, + mask, + enclosed_only=enclosed_only, + ) def separate_with_label( self, @@ -276,26 +308,6 @@ def separate_with_label( click_coords_list=click_coords_list, ) - def nearest_nonzero_2d( - self, - labels_2d: np.ndarray, - y, - x, - *, - max_dist=None, - return_coords: bool = False, - ): - return nearest_nonzero_2D( - labels_2d, - y, - x, - max_dist=max_dist, - return_coords=return_coords, - ) - - def nearest_nonzero_z_from_centroid(self, obj, *, current_z: int = -1): - return nearest_nonzero_z_idx_from_z_centroid(obj, current_z=current_z) - def split_along_convexity_defects( self, label_id: int, @@ -325,15 +337,3 @@ def split_connected_components( rp=regionprops, max_ID=max_id, ) - - def binary_fill_holes(self, mask: np.ndarray, *, slice_by_slice: bool = True): - return core_binary_fill_holes(mask, slice_by_slice=slice_by_slice) - - def convex_hull_mask(self, mask: np.ndarray, *, slice_by_slice: bool = True): - return core_convex_hull_mask(mask, slice_by_slice=slice_by_slice) - - def count_objects(self, position_data, logger_func): - return core_count_objects(position_data, logger_func) - - def format_trimmed_ids(self, ids, *, max_num_digits=10): - return get_trimmed_list(list(ids), max_num_digits=max_num_digits) diff --git a/cellacdc/mixins/label_roi.py b/cellacdc/mixins/label_roi.py index 73afa2d72..aed620d4e 100644 --- a/cellacdc/mixins/label_roi.py +++ b/cellacdc/mixins/label_roi.py @@ -4,14 +4,12 @@ import numpy as np import os -from dataclasses import dataclass from qtpy.QtCore import QMutex, Qt, QThread, QWaitCondition from qtpy.QtGui import QCursor from qtpy.QtWidgets import QAction, QMenu from cellacdc import ( apps, - config, exception_handler, html_utils, qutils, @@ -21,112 +19,24 @@ ) -class LabelRoiView: +class LabelRoiMixin: """Qt-facing adapter around Magic Labeller ROI workflows.""" - LEGACY_METHODS = ( - 'labelRoiCancelled', - 'labelRoiCheckStartStopFrame', - 'getSecondChannelData', - 'labelRoiToEndFramesTriggered', - 'labelRoiFromCurrentFrameTriggered', - 'showLabelRoiContextMenu', - 'initLabelRoiModel', - 'labelRoiViewCurrentModel', - 'storeLabelRoiParams', - 'loadLabelRoiLastParams', - 'updateLabelRoiCircularSize', - 'updateLabelRoiCircularCursor', - 'getLabelRoiImage', - 'labelRoiTrangeCheckboxToggled', - 'labelRoi_cb', - 'labelRoiWorkerFinished', - 'indexRoiLab', - 'labelRoiDone', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - - def labelRoiCancelled(self): - self.labelRoiRunning = False - self.app.restoreOverrideCursor() - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) - self.freeRoiItem.clear() - self.logger.info('Magic labeller process cancelled.') - - def labelRoiCheckStartStopFrame(self): - enabled = self.labelRoiTrangeCheckbox.isChecked() - start_n = self.labelRoiStartFrameNoSpinbox.value() - stop_n = self.labelRoiStopFrameNoSpinbox.value() - if self.is_frame_range_valid(enabled, start_n, stop_n): - return True - - self.blinker = qutils.QControlBlink( - self.labelRoiStopFrameNoSpinbox, - qparent=self.host - ) - self.blinker.start() - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - """Headless decisions for Magic Labeller ROI workflows.""" - yes_value = 'Yes' - no_value = 'No' - - def checked_setting_value(self, checked: bool) -> str: - return self.yes_value if checked else self.no_value + yes_value = "Yes" + no_value = "No" def checked_from_setting_value(self, value) -> bool: return value == self.yes_value - def model_params_ini_path(self, settings_folderpath: str) -> str: - return os.path.join(settings_folderpath, 'last_params_segm_models.ini') - - def params_settings( - self, - *, - checked_roi_type: str, - circ_roi_radius: int, - roi_zdepth: int, - auto_clear_border: bool, - replace_existing_objects: bool, - ) -> LabelRoiParamsSettings: - return LabelRoiParamsSettings( - updates={ - 'labelRoi_checkedRoiType': checked_roi_type, - 'labelRoi_circRoiRadius': circ_roi_radius, - 'labelRoi_roiZdepth': roi_zdepth, - 'labelRoi_autoClearBorder': self.checked_setting_value( - auto_clear_border - ), - 'labelRoi_replaceExistingObjects': ( - self.checked_setting_value(replace_existing_objects) - ), - } - ) + def checked_setting_value(self, checked: bool) -> str: + return self.yes_value if checked else self.no_value - def is_frame_range_valid( - self, - enabled: bool, - start_frame_number: int, - stop_frame_number: int, - ) -> bool: - return not enabled or start_frame_number <= stop_frame_number + def cursor_points(self, x, y, checked: bool): + if not checked: + return [], [] + return [x], [y] def frame_range_length( self, @@ -138,84 +48,91 @@ def frame_range_length( return 1 return stop_frame_number - start_frame_index - def time_range( - self, - enabled: bool, - start_frame_index: int, - stop_frame_number: int, - ): - if self.frame_range_length( - enabled, - start_frame_index, - stop_frame_number, - ) > 1: - return start_frame_index, stop_frame_number - return None + def getLabelRoiImage(self): + posData = self.data[self.pos_i] - def should_enable_range_controls(self, checked: bool) -> bool: - return checked + if self.labelRoiTrangeCheckbox.isChecked(): + start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 + stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() + tRangeLen = stop_frame_n - start_frame_i + else: + tRangeLen = 1 - def should_show_circular_cursor( - self, - *, - label_roi_checked: bool, - circular_roi_checked: bool, - label_roi_running: bool, - cursor_checked: bool, - existing_cursor_empty: bool, - ) -> bool: - return ( - label_roi_checked - and circular_roi_checked - and not label_roi_running - and (cursor_checked or not existing_cursor_empty) - ) + if tRangeLen > 1: + tRange = (start_frame_i, stop_frame_n) + else: + tRange = None - def cursor_points(self, x, y, checked: bool): - if not checked: - return [], [] - return [x], [y] + if self.isSegm3D: + if tRangeLen > 1: + imgData = posData.img_data + else: + # Filtered data not existing + imgData = posData.img_data[posData.frame_i] - def should_uncheck_time_range( - self, - *, - time_range_checked: bool, - persistent_action_checked: bool, - ) -> bool: - return time_range_checked and not persistent_action_checked + roi_zdepth = self.labelRoiZdepthSpinbox.value() + if roi_zdepth == posData.SizeZ: + z0 = 0 + z1 = posData.SizeZ + elif roi_zdepth == 1: + z0 = self.zSliceScrollBar.sliderPosition() + z1 = z0 + 1 + else: + if roi_zdepth % 2 != 0: + roi_zdepth += 1 + half_zdepth = int(roi_zdepth / 2) + zc = self.zSliceScrollBar.sliderPosition() + 1 + z0 = zc - half_zdepth + z0 = z0 if z0 >= 0 else 0 + z1 = zc + half_zdepth + z1 = z1 if z1 < posData.SizeZ else posData.SizeZ - def z_range( - self, - roi_zdepth: int, - size_z: int, - current_z_index: int, - ) -> tuple[int, int]: - if roi_zdepth == size_z: - return 0, size_z - if roi_zdepth == 1: - return current_z_index, current_z_index + 1 + if self.labelRoiIsRectRadioButton.isChecked(): + labelRoiSlice = self.labelRoiItem.slice(zRange=(z0, z1), tRange=tRange) + elif self.labelRoiIsFreeHandRadioButton.isChecked(): + labelRoiSlice = self.freeRoiItem.slice(zRange=(z0, z1), tRange=tRange) + elif self.labelRoiIsCircularRadioButton.isChecked(): + labelRoiSlice = self.labelRoiCircItemLeft.slice( + zRange=(z0, z1), tRange=tRange + ) + else: + if self.labelRoiIsRectRadioButton.isChecked(): + labelRoiSlice = self.labelRoiItem.slice(tRange=tRange) + elif self.labelRoiIsFreeHandRadioButton.isChecked(): + labelRoiSlice = self.freeRoiItem.slice(tRange=tRange) + elif self.labelRoiIsCircularRadioButton.isChecked(): + labelRoiSlice = self.labelRoiCircItemLeft.slice(tRange=tRange) + if tRangeLen > 1: + imgData = posData.img_data + else: + imgData = self.img1.image - if roi_zdepth % 2 != 0: - roi_zdepth += 1 - half_zdepth = int(roi_zdepth / 2) - zc = current_z_index + 1 - z0 = max(zc - half_zdepth, 0) - z1 = min(zc + half_zdepth, size_z) - return z0, z1 + roiImg = imgData[labelRoiSlice] + if self.labelRoiIsFreeHandRadioButton.isChecked(): + mask = self.freeRoiItem.mask() + elif self.labelRoiIsCircularRadioButton.isChecked(): + mask = self.labelRoiCircItemLeft.mask() + else: + mask = None - Stop frame number is less than start frame number!

- What do you want to do? - """) - msg.warning( - self.host, 'Stop frame number lower than start', txt, - buttonsTexts=('Cancel', 'Segment only current frame') - ) - if msg.cancel: - return False + if mask is not None: + # Copy roiImg otherwise we are replacing minimum inside original image + roiImg = roiImg.copy() + # Fill outside of freehand roi with minimum of the ROI image + if tRangeLen > 1: + for i in range(tRangeLen): + ith_roiImg = roiImg[i] + if self.isSegm3D: + roiImg[i, :, ~mask] = ith_roiImg.min() + else: + roiImg[i, ~mask] = ith_roiImg.min() + else: + if self.isSegm3D: + roiImg[:, ~mask] = roiImg.min() + else: + roiImg[~mask] = roiImg.min() - posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) - self.labelRoiStopFrameNoSpinbox.setValue(posData.frame_i+1) + return roiImg, labelRoiSlice def getSecondChannelData(self): if self.secondChannelName is None: @@ -232,13 +149,12 @@ def getSecondChannelData(self): posData.fluo_data_dict[filename] = fluo_data posData.fluo_bkgrData_dict[filename] = bkgrData - start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 - stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() - tRangeLen = self.frame_range_length( - self.labelRoiTrangeCheckbox.isChecked(), - start_frame_i, - stop_frame_n, - ) + if self.labelRoiTrangeCheckbox.isChecked(): + start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 + stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() + tRangeLen = stop_frame_n - start_frame_i + else: + tRangeLen = 1 if tRangeLen > 1: # fluo_img_data = fluo_data[start_frame_i:stop_frame_n] @@ -263,37 +179,36 @@ def getSecondChannelData(self): else: return self.get_2Dimg_from_3D(fluo_img_data) - def labelRoiToEndFramesTriggered(self): - posData = self.data[self.pos_i] - self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) - - def labelRoiFromCurrentFrameTriggered(self): - posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) - - def showLabelRoiContextMenu(self, event): - menu = QMenu(self.labelRoiButton) - action = QAction('Re-initialize magic labeller model...') - action.triggered.connect(self.initLabelRoiModel) - menu.addAction(action) - menu.exec_(QCursor.pos()) + def indexRoiLab(self, roiLab, roiLabSlice, lab, brushID): + # Delete only objects touching borders in X and Y not in Z + if self.labelRoiAutoClearBorderCheckbox.isChecked(): + mask = np.zeros(roiLab.shape, dtype=bool) + mask[..., 1:-1, 1:-1] = True + roiLab = skimage.segmentation.clear_border(roiLab, mask=mask) + + roiLabMask = roiLab > 0 + roiLab[roiLabMask] += brushID - 1 + if self.labelRoiReplaceExistingObjectsCheckbox.isChecked(): + IDs_touched_by_new_objects = np.unique(lab[roiLabSlice][roiLabMask]) + for ID in IDs_touched_by_new_objects: + lab[lab == ID] = 0 + + lab[roiLabSlice][roiLabMask] = roiLab[roiLabMask] + return lab def initLabelRoiModel(self): self.app.restoreOverrideCursor() # Ask which model - self.initLabelRoiModelDialog = apps.QDialogSelectModel( - parent=self.host - ) + self.initLabelRoiModelDialog = apps.QDialogSelectModel(parent=self) self.initLabelRoiModelDialog.exec_() if self.initLabelRoiModelDialog.cancel: - self.logger.info('Magic labeller aborted.') + self.logger.info("Magic labeller aborted.") self.initLabelRoiModelDialog = None return True self.app.setOverrideCursor(Qt.WaitCursor) model_name = self.initLabelRoiModelDialog.selectedModel self.labelRoiModel = self.repeatSegm( - model_name=model_name, askSegmParams=True, - is_label_roi=True + model_name=model_name, askSegmParams=True, is_label_roi=True ) if self.labelRoiModel is None: self.initLabelRoiModelDialog = None @@ -302,186 +217,133 @@ def initLabelRoiModel(self): self.initLabelRoiModelDialog = None return False - def labelRoiViewCurrentModel(self): - ini_path = self.model_params_ini_path(settings_folderpath) - configPars = config.ConfigParser() - configPars.read(ini_path) - model_name = self.labelRoiModel.model_name - txt = f'Model: {model_name}' - SECTION = f'{model_name}.init' - txt = f'{txt}

[Initialization parameters]
' - for option in configPars.options(SECTION): - value = configPars[SECTION][option] - param_txt = f'{option} = {value}
' - txt = f'{txt}{param_txt}' - - SECTION = f'{model_name}.segment' - txt = f'{txt}
[Segmentation parameters]
' - for option in configPars.options(SECTION): - value = configPars[SECTION][option] - param_txt = f'{option} = {value}
' - txt = f'{txt}{param_txt}' - - win = apps.ViewTextDialog(txt, parent=self.host) - win.exec_() - - def storeLabelRoiParams(self, value=None, checked=True): - checkedRoiType = self.labelRoiTypesGroup.checkedButton().text() - circRoiRadius = self.labelRoiCircularRadiusSpinbox.value() - roiZdepth = self.labelRoiZdepthSpinbox.value() - autoClearBorder = self.labelRoiAutoClearBorderCheckbox.isChecked() - params = self.params_settings( - checked_roi_type=checkedRoiType, - circ_roi_radius=circRoiRadius, - roi_zdepth=roiZdepth, - auto_clear_border=autoClearBorder, - replace_existing_objects=( - self.labelRoiReplaceExistingObjectsCheckbox.isChecked() - ), - ) - for setting, setting_value in params.updates.items(): - self.df_settings.at[setting, 'value'] = setting_value - self.df_settings.to_csv(self.settings_csv_path) - - def loadLabelRoiLastParams(self): - idx = 'labelRoi_checkedRoiType' - if idx in self.df_settings.index: - checkedRoiType = self.df_settings.at[idx, 'value'] - for button in self.labelRoiTypesGroup.buttons(): - if button.text() == checkedRoiType: - button.setChecked(True) - break + def is_frame_range_valid( + self, + enabled: bool, + start_frame_number: int, + stop_frame_number: int, + ) -> bool: + return not enabled or start_frame_number <= stop_frame_number - idx = 'labelRoi_circRoiRadius' - if idx in self.df_settings.index: - circRoiRadius = self.df_settings.at[idx, 'value'] - self.labelRoiCircularRadiusSpinbox.setValue(int(circRoiRadius)) + def labelRoiCancelled(self): + self.labelRoiRunning = False + self.app.restoreOverrideCursor() + self.labelRoiItem.setPos((0, 0)) + self.labelRoiItem.setSize((0, 0)) + self.freeRoiItem.clear() + self.logger.info("Magic labeller process cancelled.") - idx = 'labelRoi_roiZdepth' - if idx in self.df_settings.index: - roiZdepth = self.df_settings.at[idx, 'value'] - self.labelRoiZdepthSpinbox.setValue(int(roiZdepth)) + def labelRoiCheckStartStopFrame(self): + if not self.labelRoiTrangeCheckbox.isChecked(): + return True - idx = 'labelRoi_autoClearBorder' - if idx in self.df_settings.index: - clearBorder = self.df_settings.at[idx, 'value'] - checked = self.checked_from_setting_value(clearBorder) - self.labelRoiAutoClearBorderCheckbox.setChecked(checked) + start_n = self.labelRoiStartFrameNoSpinbox.value() + stop_n = self.labelRoiStopFrameNoSpinbox.value() + if start_n <= stop_n: + return True - idx = 'labelRoi_replaceExistingObjects' - if idx in self.df_settings.index: - val = self.df_settings.at[idx, 'value'] - checked = self.checked_from_setting_value(val) - self.labelRoiReplaceExistingObjectsCheckbox.setChecked(checked) + self.blinker = qutils.QControlBlink( + self.labelRoiStopFrameNoSpinbox, qparent=self + ) + self.blinker.start() + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + Stop frame number is less than start frame number!

+ What do you want to do? + """) + msg.warning( + self, + "Stop frame number lower than start", + txt, + buttonsTexts=("Cancel", "Segment only current frame"), + ) + if msg.cancel: + return False - if self.labelRoiIsCircularRadioButton.isChecked(): - self.labelRoiCircularRadiusSpinbox.setDisabled(False) + posData = self.data[self.pos_i] + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i + 1) + self.labelRoiStopFrameNoSpinbox.setValue(posData.frame_i + 1) - def updateLabelRoiCircularSize(self, value): - self.labelRoiCircItemLeft.setSize(value) - self.labelRoiCircItemRight.setSize(value) + @exception_handler + def labelRoiDone(self, roiSegmData, isTimeLapse): + self.setDisabled(False) - def updateLabelRoiCircularCursor(self, x, y, checked): - size = self.labelRoiCircularRadiusSpinbox.value() - existing_cursor_empty = len(self.labelRoiCircItemLeft.getData()[0]) == 0 - if not self.should_show_circular_cursor( - label_roi_checked=self.labelRoiButton.isChecked(), - circular_roi_checked=self.labelRoiIsCircularRadioButton.isChecked(), - label_roi_running=self.labelRoiRunning, - cursor_checked=checked, - existing_cursor_empty=existing_cursor_empty, - ): - return - xx, yy = self.cursor_points(x, y, checked) + posData = self.data[self.pos_i] + self.setBrushID() - self.labelRoiCircItemLeft.setData(xx, yy, size=size) - self.labelRoiCircItemRight.setData(xx, yy, size=size) + if isTimeLapse: + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.mainPbar.setValue(0) + current_frame_i = posData.frame_i + start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 + for i, roiLab in enumerate(roiSegmData): + frame_i = start_frame_i + i + lab = posData.allData_li[frame_i]["labels"] + store = True + if lab is None: + if frame_i >= len(posData.segm_data): + lab = np.zeros_like(posData.segm_data[0]) + posData.segm_data = np.append( + posData.segm_data, lab[np.newaxis], axis=0 + ) + else: + lab = posData.segm_data[frame_i] + store = False + roiLabSlice = self.labelRoiSlice[1:] + lab = self.indexRoiLab(roiLab, roiLabSlice, lab, posData.brushID) + if store: + posData.frame_i = frame_i + posData.allData_li[frame_i]["labels"] = lab.copy() + self.get_data() + self.store_data(autosave=False) - def getLabelRoiImage(self): - posData = self.data[self.pos_i] + # Back to current frame + posData.frame_i = current_frame_i + self.get_data() + else: + roiLab = roiSegmData + posData.lab = self.indexRoiLab( + roiLab, self.labelRoiSlice, posData.lab, posData.brushID + ) - start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 - stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() - frame_range_enabled = self.labelRoiTrangeCheckbox.isChecked() - tRangeLen = self.frame_range_length( - frame_range_enabled, - start_frame_i, - stop_frame_n, - ) - tRange = self.time_range( - frame_range_enabled, - start_frame_i, - stop_frame_n, - ) + self.update_rp() - if self.isSegm3D: - if tRangeLen > 1: - imgData = posData.img_data - else: - # Filtered data not existing - imgData = posData.img_data[posData.frame_i] + # Repeat tracking + if self.autoIDcheckbox.isChecked(): + self.tracking(enforce=True, assign_unique_new_IDs=False) - roi_zdepth = self.labelRoiZdepthSpinbox.value() - z0, z1 = self.z_range( - roi_zdepth, - posData.SizeZ, - self.zSliceScrollBar.sliderPosition(), - ) + self.store_data() + self.updateAllImages() - if self.labelRoiIsRectRadioButton.isChecked(): - labelRoiSlice = self.labelRoiItem.slice( - zRange=(z0,z1), tRange=tRange - ) - elif self.labelRoiIsFreeHandRadioButton.isChecked(): - labelRoiSlice = self.freeRoiItem.slice( - zRange=(z0,z1), tRange=tRange - ) - elif self.labelRoiIsCircularRadioButton.isChecked(): - labelRoiSlice = self.labelRoiCircItemLeft.slice( - zRange=(z0,z1), tRange=tRange - ) - else: - if self.labelRoiIsRectRadioButton.isChecked(): - labelRoiSlice = self.labelRoiItem.slice(tRange=tRange) - elif self.labelRoiIsFreeHandRadioButton.isChecked(): - labelRoiSlice = self.freeRoiItem.slice(tRange=tRange) - elif self.labelRoiIsCircularRadioButton.isChecked(): - labelRoiSlice = self.labelRoiCircItemLeft.slice(tRange=tRange) - if tRangeLen > 1: - imgData = posData.img_data - else: - imgData = self.img1.image + self.labelRoiItem.setPos((0, 0)) + self.labelRoiItem.setSize((0, 0)) + self.freeRoiItem.clear() + self.logger.info("Magic labeller done!") + self.app.restoreOverrideCursor() - roiImg = imgData[labelRoiSlice] - if self.labelRoiIsFreeHandRadioButton.isChecked(): - mask = self.freeRoiItem.mask() - elif self.labelRoiIsCircularRadioButton.isChecked(): - mask = self.labelRoiCircItemLeft.mask() - else: - mask = None + self.labelRoiRunning = False + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None - if mask is not None: - # Copy roiImg otherwise we are replacing minimum inside original image - roiImg = roiImg.copy() - # Fill outside of freehand roi with minimum of the ROI image - if tRangeLen > 1: - for i in range(tRangeLen): - ith_roiImg = roiImg[i] - if self.isSegm3D: - roiImg[i, :, ~mask] = ith_roiImg.min() - else: - roiImg[i, ~mask] = ith_roiImg.min() - else: - if self.isSegm3D: - roiImg[:, ~mask] = roiImg.min() - else: - roiImg[~mask] = roiImg.min() + uncheckLabelRoiTRange = ( + self.labelRoiTrangeCheckbox.isChecked() + and not self.labelRoiTrangeCheckbox.findChild(QAction).isChecked() + ) + if uncheckLabelRoiTRange: + self.labelRoiTrangeCheckbox.setChecked(False) - return roiImg, labelRoiSlice + def labelRoiFromCurrentFrameTriggered(self): + posData = self.data[self.pos_i] + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i + 1) + + def labelRoiToEndFramesTriggered(self): + posData = self.data[self.pos_i] + self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) def labelRoiTrangeCheckboxToggled(self, checked): - enabled = self.should_enable_range_controls(checked) - disabled = not enabled + disabled = not checked self.labelRoiStartFrameNoSpinbox.setDisabled(disabled) self.labelRoiStopFrameNoSpinbox.setDisabled(disabled) self.labelRoiStartFrameNoSpinbox.label.setDisabled(disabled) @@ -489,14 +351,43 @@ def labelRoiTrangeCheckboxToggled(self, checked): self.labelRoiToEndFramesAction.setDisabled(disabled) self.labelRoiFromCurrentFrameAction.setDisabled(disabled) - if not enabled: + if disabled: return posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i + 1) self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) + def labelRoiViewCurrentModel(self): + from . import config + + ini_path = os.path.join(settings_folderpath, "last_params_segm_models.ini") + configPars = config.ConfigParser() + configPars.read(ini_path) + model_name = self.labelRoiModel.model_name + txt = f"Model: {model_name}" + SECTION = f"{model_name}.init" + txt = f"{txt}

[Initialization parameters]
" + for option in configPars.options(SECTION): + value = configPars[SECTION][option] + param_txt = f"{option} = {value}
" + txt = f"{txt}{param_txt}" + + SECTION = f"{model_name}.segment" + txt = f"{txt}
[Segmentation parameters]
" + for option in configPars.options(SECTION): + value = configPars[SECTION][option] + param_txt = f"{option} = {value}
" + txt = f"{txt}{param_txt}" + + win = apps.ViewTextDialog(txt, parent=self) + win.exec_() + + def labelRoiWorkerFinished(self): + self.logger.info("Magic labeller closed.") + self.labelRoiActiveWorkers.pop(-1) + def labelRoi_cb(self, checked): posData = self.data[self.pos_i] if checked: @@ -511,7 +402,7 @@ def labelRoi_cb(self, checked): lastActiveWorker = self.labelRoiActiveWorkers[-1] self.labelRoiGarbageWorkers.append(lastActiveWorker) lastActiveWorker.finished.emit() - self.logger.info('Collected garbage w5orker (magic labeller).') + self.logger.info("Collected garbage w5orker (magic labeller).") self.labelRoiToolbar.setVisible(True) if self.isSegm3D: @@ -524,14 +415,12 @@ def labelRoi_cb(self, checked): self.labelRoiMutex = QMutex() self.labelRoiWaitCond = QWaitCondition() - labelRoiWorker = workers.LabelRoiWorker(self.host) + labelRoiWorker = workers.LabelRoiWorker(self) labelRoiWorker.moveToThread(self.labelRoiThread) labelRoiWorker.finished.connect(self.labelRoiThread.quit) labelRoiWorker.finished.connect(labelRoiWorker.deleteLater) - self.labelRoiThread.finished.connect( - self.labelRoiThread.deleteLater - ) + self.labelRoiThread.finished.connect(self.labelRoiThread.deleteLater) labelRoiWorker.finished.connect(self.labelRoiWorkerFinished) labelRoiWorker.sigLabellingDone.connect(self.labelRoiDone) @@ -560,99 +449,177 @@ def labelRoi_cb(self, checked): while self.app.overrideCursor() is not None: self.app.restoreOverrideCursor() - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) + self.labelRoiItem.setPos((0, 0)) + self.labelRoiItem.setSize((0, 0)) self.freeRoiItem.clear() self.ax1.removeItem(self.labelRoiItem) self.updateLabelRoiCircularCursor(None, None, False) - def labelRoiWorkerFinished(self): - self.logger.info('Magic labeller closed.') - worker = self.labelRoiActiveWorkers.pop(-1) + def loadLabelRoiLastParams(self): + idx = "labelRoi_checkedRoiType" + if idx in self.df_settings.index: + checkedRoiType = self.df_settings.at[idx, "value"] + for button in self.labelRoiTypesGroup.buttons(): + if button.text() == checkedRoiType: + button.setChecked(True) + break - def indexRoiLab(self, roiLab, roiLabSlice, lab, brushID): - result = self.host.view_model.label_edits.index_label_roi( - lab, - roiLab, - roiLabSlice, - brushID, - clear_border=self.labelRoiAutoClearBorderCheckbox.isChecked(), - replace_existing=( - self.labelRoiReplaceExistingObjectsCheckbox.isChecked() - ), + idx = "labelRoi_circRoiRadius" + if idx in self.df_settings.index: + circRoiRadius = self.df_settings.at[idx, "value"] + self.labelRoiCircularRadiusSpinbox.setValue(int(circRoiRadius)) + + idx = "labelRoi_roiZdepth" + if idx in self.df_settings.index: + roiZdepth = self.df_settings.at[idx, "value"] + self.labelRoiZdepthSpinbox.setValue(int(roiZdepth)) + + idx = "labelRoi_autoClearBorder" + if idx in self.df_settings.index: + clearBorder = self.df_settings.at[idx, "value"] + checked = clearBorder == "Yes" + self.labelRoiAutoClearBorderCheckbox.setChecked(checked) + + idx = "labelRoi_replaceExistingObjects" + if idx in self.df_settings.index: + val = self.df_settings.at[idx, "value"] + checked = val == "Yes" + self.labelRoiReplaceExistingObjectsCheckbox.setChecked(checked) + + if self.labelRoiIsCircularRadioButton.isChecked(): + self.labelRoiCircularRadiusSpinbox.setDisabled(False) + + def model_params_ini_path(self, settings_folderpath: str) -> str: + return os.path.join(settings_folderpath, "last_params_segm_models.ini") + + def params_settings( + self, + *, + checked_roi_type: str, + circ_roi_radius: int, + roi_zdepth: int, + auto_clear_border: bool, + replace_existing_objects: bool, + ) -> LabelRoiParamsSettings: + return LabelRoiParamsSettings( + updates={ + "labelRoi_checkedRoiType": checked_roi_type, + "labelRoi_circRoiRadius": circ_roi_radius, + "labelRoi_roiZdepth": roi_zdepth, + "labelRoi_autoClearBorder": self.checked_setting_value( + auto_clear_border + ), + "labelRoi_replaceExistingObjects": ( + self.checked_setting_value(replace_existing_objects) + ), + } ) - return result.labels - @exception_handler - def labelRoiDone(self, roiSegmData, isTimeLapse): - self.setDisabled(False) + def should_enable_range_controls(self, checked: bool) -> bool: + return checked - posData = self.data[self.pos_i] - self.setBrushID() + def should_show_circular_cursor( + self, + *, + label_roi_checked: bool, + circular_roi_checked: bool, + label_roi_running: bool, + cursor_checked: bool, + existing_cursor_empty: bool, + ) -> bool: + return ( + label_roi_checked + and circular_roi_checked + and not label_roi_running + and (cursor_checked or not existing_cursor_empty) + ) - if isTimeLapse: - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.mainPbar.setValue(0) - current_frame_i = posData.frame_i - start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 - for i, roiLab in enumerate(roiSegmData): - frame_i = start_frame_i + i - lab = posData.allData_li[frame_i]['labels'] - store = True - if lab is None: - if frame_i >= len(posData.segm_data): - lab = np.zeros_like(posData.segm_data[0]) - posData.segm_data = np.append( - posData.segm_data, lab[np.newaxis], axis=0 - ) - else: - lab = posData.segm_data[frame_i] - store = False - roiLabSlice = self.labelRoiSlice[1:] - lab = self.indexRoiLab( - roiLab, roiLabSlice, lab, posData.brushID - ) - if store: - posData.frame_i = frame_i - posData.allData_li[frame_i]['labels'] = lab.copy() - self.get_data() - self.store_data(autosave=False) + def should_uncheck_time_range( + self, + *, + time_range_checked: bool, + persistent_action_checked: bool, + ) -> bool: + return time_range_checked and not persistent_action_checked - # Back to current frame - posData.frame_i = current_frame_i - self.get_data() - else: - roiLab = roiSegmData - posData.lab = self.indexRoiLab( - roiLab, self.labelRoiSlice, posData.lab, posData.brushID + def showLabelRoiContextMenu(self, event): + menu = QMenu(self.labelRoiButton) + action = QAction("Re-initialize magic labeller model...") + action.triggered.connect(self.initLabelRoiModel) + menu.addAction(action) + menu.exec_(QCursor.pos()) + + def storeLabelRoiParams(self, value=None, checked=True): + checkedRoiType = self.labelRoiTypesGroup.checkedButton().text() + circRoiRadius = self.labelRoiCircularRadiusSpinbox.value() + roiZdepth = self.labelRoiZdepthSpinbox.value() + autoClearBorder = self.labelRoiAutoClearBorderCheckbox.isChecked() + clearBorder = "Yes" if autoClearBorder else "No" + self.df_settings.at["labelRoi_checkedRoiType", "value"] = checkedRoiType + self.df_settings.at["labelRoi_circRoiRadius", "value"] = circRoiRadius + self.df_settings.at["labelRoi_roiZdepth", "value"] = roiZdepth + self.df_settings.at["labelRoi_autoClearBorder", "value"] = clearBorder + self.df_settings.at["labelRoi_replaceExistingObjects", "value"] = ( + "Yes" if self.labelRoiReplaceExistingObjectsCheckbox.isChecked() else "No" + ) + self.df_settings.to_csv(self.settings_csv_path) + + def time_range( + self, + enabled: bool, + start_frame_index: int, + stop_frame_number: int, + ): + if ( + self.frame_range_length( + enabled, + start_frame_index, + stop_frame_number, ) + > 1 + ): + return start_frame_index, stop_frame_number + return None - self.update_rp() + def updateLabelRoiCircularCursor(self, x, y, checked): + if not self.labelRoiButton.isChecked(): + return + if not self.labelRoiIsCircularRadioButton.isChecked(): + return + if self.labelRoiRunning: + return - # Repeat tracking - if self.autoIDcheckbox.isChecked(): - self.tracking(enforce=True, assign_unique_new_IDs=False) + size = self.labelRoiCircularRadiusSpinbox.value() + if not checked: + xx, yy = [], [] + else: + xx, yy = [x], [y] - self.store_data() - self.updateAllImages() + if not xx and len(self.labelRoiCircItemLeft.getData()[0]) == 0: + return - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) - self.freeRoiItem.clear() - self.logger.info('Magic labeller done!') - self.app.restoreOverrideCursor() + self.labelRoiCircItemLeft.setData(xx, yy, size=size) + self.labelRoiCircItemRight.setData(xx, yy, size=size) - self.labelRoiRunning = False - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None + def updateLabelRoiCircularSize(self, value): + self.labelRoiCircItemLeft.setSize(value) + self.labelRoiCircItemRight.setSize(value) - uncheckLabelRoiTRange = self.should_uncheck_time_range( - time_range_checked=self.labelRoiTrangeCheckbox.isChecked(), - persistent_action_checked=( - self.labelRoiTrangeCheckbox.findChild(QAction).isChecked() - ), - ) - if uncheckLabelRoiTRange: - self.labelRoiTrangeCheckbox.setChecked(False) \ No newline at end of file + def z_range( + self, + roi_zdepth: int, + size_z: int, + current_z_index: int, + ) -> tuple[int, int]: + if roi_zdepth == size_z: + return 0, size_z + if roi_zdepth == 1: + return current_z_index, current_z_index + 1 + + if roi_zdepth % 2 != 0: + roi_zdepth += 1 + half_zdepth = int(roi_zdepth / 2) + zc = current_z_index + 1 + z0 = max(zc - half_zdepth, 0) + z1 = min(zc + half_zdepth, size_z) + return z0, z1 diff --git a/cellacdc/mixins/label_transform_tools.py b/cellacdc/mixins/label_transform_tools.py index 9ee344473..0f16e5c10 100644 --- a/cellacdc/mixins/label_transform_tools.py +++ b/cellacdc/mixins/label_transform_tools.py @@ -5,132 +5,101 @@ import skimage.measure - -class LabelTransformToolsView: +class LabelTransformToolsMixin: """Qt-facing adapter around label transform tool contracts.""" """Headless decision rules for label transform tools.""" - def reset_expand_label_id(self) -> int: - return -1 - - def should_reinitialize_expansion( - self, - *, - expanding_id: int, - hover_label_id: int, - dilation: bool, - is_dilation: bool, - ) -> bool: - return expanding_id != hover_label_id or dilation != is_dilation - - def should_start_moving_label(self, label_id: int) -> bool: - return label_id != 0 - - def point_in_shape(self, *, x: int, y: int, shape) -> bool: - y_size, x_size = shape - return x >= 0 and y >= 0 and x < x_size and y < y_size - - def move_delta(self, *, previous_pos, current_pos) -> tuple[int, int]: - x_start, y_start = previous_pos - x_current, y_current = current_pos - return x_current - x_start, y_current - y_start - - def should_clear_move_state(self, *, checked: bool) -> bool: - return not checked - - - def __init__(self, host): - self.host = host - def reset_expand_label(self): - self.host.expandingID = self.reset_expand_label_id() + def _set_temp_img_expand_label_contours(self, previous_coords, ax=0): + self.contoursImage[previous_coords] = [0, 0, 0, 0] + current_lab_2d_rp = skimage.measure.regionprops(self.currentLab2D) + for obj in current_lab_2d_rp: + if obj.label == self.expandingID: + self.addObjContourToContoursImage(obj=obj, ax=ax, force=True) + break - def expand_label_callback(self, checked): - if checked: - self.host.disconnectLeftClickButtons() - self.host.uncheckLeftClickButtons(self.host.expandLabelToolButton) - self.host.connectLeftClickButtons() - self.host.expandFootprintSize = 1 - return + def _set_temp_img_expand_label_segm_masks(self, previous_coords, ax=0): + labels_image = self.getLabelsLayerImage(ax=ax) + labels_image[previous_coords] = 0 + labels_image[previous_coords] = self.expandingID - self.host.clearHighlightedID() - alpha = self.host.imgGrad.labelsAlphaSlider.value() - self.host.labelsLayerImg1.setOpacity(alpha) - self.host.labelsLayerRightImg.setOpacity(alpha) - self.host.hoverLabelID = 0 - self.host.expandingID = 0 - self.host.updateAllImages() + if ax == 0: + self.labelsLayerImg1.setImage(self.labelsLayerImg1.image, autoLevels=False) + else: + self.labelsLayerRightImg.setImage( + self.labelsLayerRightImg.image, autoLevels=False + ) def expand_label(self, dilation=True): - pos_data = self.host.data[self.host.pos_i] - if self.host.hoverLabelID == 0: - self.host.isExpandingLabel = False + pos_data = self.data[self.pos_i] + if self.hoverLabelID == 0: + self.isExpandingLabel = False return - reinit_expanding_lab = ( - self.should_reinitialize_expansion( - expanding_id=self.host.expandingID, - hover_label_id=self.host.hoverLabelID, - dilation=dilation, - is_dilation=self.host.isDilation, - ) + reinit_expanding_lab = self.should_reinitialize_expansion( + expanding_id=self.expandingID, + hover_label_id=self.hoverLabelID, + dilation=dilation, + is_dilation=self.isDilation, ) - label_id = self.host.hoverLabelID + label_id = self.hoverLabelID obj = pos_data.rp[pos_data.IDs.index(label_id)] if reinit_expanding_lab: - self.host.storeUndoRedoStates(False) - self.host.isExpandingLabel = True - self.host.expandingID = label_id - self.host.expandingLab = None - self.host.expandFootprintSize = 1 - - lab_2d = self.host.get_2Dlab(pos_data.lab) - resize_result = self.host.view_model.label_edits.resize_label_object( + self.storeUndoRedoStates(False) + self.isExpandingLabel = True + self.expandingID = label_id + self.expandingLab = None + self.expandFootprintSize = 1 + + lab_2d = self.get_2Dlab(pos_data.lab) + resize_result = self.view_model.label_edits.resize_label_object( lab_2d, - self.host.currentLab2D, + self.currentLab2D, obj.coords, - self.host.expandingID, - self.host.expandFootprintSize, + self.expandingID, + self.expandFootprintSize, dilation=dilation, - seed_labels=self.host.expandingLab, + seed_labels=self.expandingLab, ) - self.host.expandingLab = resize_result.seed_labels - self.host.isDilation = dilation + self.expandingLab = resize_result.seed_labels + self.isDilation = dilation previous_coords = resize_result.previous_coords expanded_obj_coords = resize_result.resized_coords - self.host.set_2Dlab(lab_2d) - self.host.currentLab2D = lab_2d - self.host.update_rp() + self.set_2Dlab(lab_2d) + self.currentLab2D = lab_2d + self.update_rp() - if self.host.labelsGrad.showLabelsImgAction.isChecked(): - self.host.img2.setImage(img=self.host.currentLab2D, autoLevels=False) + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.img2.setImage(img=self.currentLab2D, autoLevels=False) self.set_temp_img_expand_label(previous_coords, expanded_obj_coords) - def start_moving_label(self, x_pos, y_pos): - pos_data = self.host.data[self.host.pos_i] - x_data, y_data = int(x_pos), int(y_pos) - lab_2d = self.host.get_2Dlab(pos_data.lab) - label_id = lab_2d[y_data, x_data] - if not self.should_start_moving_label(label_id): - self.host.isMovingLabel = False + def expand_label_callback(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.expandLabelToolButton) + self.connectLeftClickButtons() + self.expandFootprintSize = 1 return - self.host.isMovingLabel = True - self.host.searchedIDitemRight.setData([], []) - self.host.searchedIDitemLeft.setData([], []) - self.host.movingID = label_id - self.host.prevMovePos = (x_data, y_data) - moving_obj = pos_data.rp[pos_data.IDs.index(label_id)] - self.host.movingObjCoords = moving_obj.coords.copy() - yy, xx = moving_obj.coords[:, -2], moving_obj.coords[:, -1] - self.host.currentLab2D[yy, xx] = 0 + self.clearHighlightedID() + alpha = self.imgGrad.labelsAlphaSlider.value() + self.labelsLayerImg1.setOpacity(alpha) + self.labelsLayerRightImg.setOpacity(alpha) + self.hoverLabelID = 0 + self.expandingID = 0 + self.updateAllImages() + + def move_delta(self, *, previous_pos, current_pos) -> tuple[int, int]: + x_start, y_start = previous_pos + x_current, y_current = current_pos + return x_current - x_start, y_current - y_start def move_label(self, x_pos, y_pos): - pos_data = self.host.data[self.host.pos_i] - lab_2d = self.host.get_2Dlab(pos_data.lab) + pos_data = self.data[self.pos_i] + lab_2d = self.get_2Dlab(pos_data.lab) y_size, x_size = lab_2d.shape x_data, y_data = int(x_pos), int(y_pos) if not self.point_in_shape( @@ -140,59 +109,73 @@ def move_label(self, x_pos, y_pos): ): return - self.host.clearObjContour(ID=self.host.movingID, ax=0) + self.clearObjContour(ID=self.movingID, ax=0) delta_x, delta_y = self.move_delta( - previous_pos=self.host.prevMovePos, + previous_pos=self.prevMovePos, current_pos=(x_data, y_data), ) - move_result = self.host.view_model.label_edits.move_label_object( + move_result = self.view_model.label_edits.move_label_object( pos_data.lab, - self.host.movingObjCoords, - self.host.movingID, + self.movingObjCoords, + self.movingID, delta_y=delta_y, delta_x=delta_x, shape=(y_size, x_size), ) - self.host.movingObjCoords = move_result.moved_coords - self.host.currentLab2D = self.host.get_2Dlab(pos_data.lab) - if self.host.labelsGrad.showLabelsImgAction.isChecked(): - self.host.img2.setImage(self.host.currentLab2D, autoLevels=False) + self.movingObjCoords = move_result.moved_coords + self.currentLab2D = self.get_2Dlab(pos_data.lab) + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.img2.setImage(self.currentLab2D, autoLevels=False) self.set_temp_img1_move_label() - self.host.prevMovePos = (x_data, y_data) + self.prevMovePos = (x_data, y_data) def move_label_button_toggled(self, checked): if not self.should_clear_move_state(checked=checked): return - self.host.hoverLabelID = 0 - self.host.highlightedID = 0 - self.host.highLightIDLayerImg1.clear() - self.host.highLightIDLayerRightImage.clear() - self.host.setHighlightID(False) + self.hoverLabelID = 0 + self.highlightedID = 0 + self.highLightIDLayerImg1.clear() + self.highLightIDLayerRightImage.clear() + self.setHighlightID(False) - def _set_temp_img_expand_label_segm_masks(self, previous_coords, ax=0): - labels_image = self.host.getLabelsLayerImage(ax=ax) - labels_image[previous_coords] = 0 - labels_image[previous_coords] = self.host.expandingID + def point_in_shape(self, *, x: int, y: int, shape) -> bool: + y_size, x_size = shape + return x >= 0 and y >= 0 and x < x_size and y < y_size + + def reset_expand_label(self): + self.expandingID = self.reset_expand_label_id() + def reset_expand_label_id(self) -> int: + return -1 + + def set_temp_img1_move_label(self, ax=0): if ax == 0: - self.host.labelsLayerImg1.setImage( - self.host.labelsLayerImg1.image, autoLevels=False - ) + how = self.drawIDsContComboBox.currentText() else: - self.host.labelsLayerRightImg.setImage( - self.host.labelsLayerRightImg.image, autoLevels=False - ) + how = self.getAnnotateHowRightImage() - def _set_temp_img_expand_label_contours(self, previous_coords, ax=0): - self.host.contoursImage[previous_coords] = [0, 0, 0, 0] - current_lab_2d_rp = skimage.measure.regionprops(self.host.currentLab2D) - for obj in current_lab_2d_rp: - if obj.label == self.host.expandingID: - self.host.addObjContourToContoursImage( - obj=obj, ax=ax, force=True - ) - break + if how.find("contours") != -1: + current_lab_2d_rp = skimage.measure.regionprops(self.currentLab2D) + for obj in current_lab_2d_rp: + if obj.label == self.movingID: + self.addObjContourToContoursImage(obj=obj, ax=ax) + break + elif how.find("overlay segm. masks") != -1: + if ax == 0: + self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) + self.highLightIDLayerImg1.image[:] = 0 + mask = self.currentLab2D == self.movingID + self.highLightIDLayerImg1.image[mask] = self.movingID + highlighted_image = self.highLightIDLayerImg1.image + self.highLightIDLayerImg1.setImage(highlighted_image) + else: + self.labelsLayerRightImg.setImage(self.currentLab2D, autoLevels=False) + self.highLightIDLayerRightImage.image[:] = 0 + mask = self.currentLab2D == self.movingID + self.highLightIDLayerRightImage.image[mask] = self.movingID + highlighted_image = self.highLightIDLayerRightImage.image + self.highLightIDLayerRightImage.setImage(highlighted_image) def set_temp_img_expand_label( self, @@ -201,44 +184,43 @@ def set_temp_img_expand_label( ax=0, ): if ax == 0: - how = self.host.drawIDsContComboBox.currentText() + self.drawIDsContComboBox.currentText() else: - how = self.host.getAnnotateHowRightImage() + self.getAnnotateHowRightImage() self._set_temp_img_expand_label_contours(previous_coords, ax=ax) - def set_temp_img1_move_label(self, ax=0): - if ax == 0: - how = self.host.drawIDsContComboBox.currentText() - else: - how = self.host.getAnnotateHowRightImage() + def should_clear_move_state(self, *, checked: bool) -> bool: + return not checked - if how.find('contours') != -1: - current_lab_2d_rp = skimage.measure.regionprops( - self.host.currentLab2D - ) - for obj in current_lab_2d_rp: - if obj.label == self.host.movingID: - self.host.addObjContourToContoursImage(obj=obj, ax=ax) - break - elif how.find('overlay segm. masks') != -1: - if ax == 0: - self.host.labelsLayerImg1.setImage( - self.host.currentLab2D, autoLevels=False - ) - self.host.highLightIDLayerImg1.image[:] = 0 - mask = self.host.currentLab2D == self.host.movingID - self.host.highLightIDLayerImg1.image[mask] = self.host.movingID - highlighted_image = self.host.highLightIDLayerImg1.image - self.host.highLightIDLayerImg1.setImage(highlighted_image) - else: - self.host.labelsLayerRightImg.setImage( - self.host.currentLab2D, autoLevels=False - ) - self.host.highLightIDLayerRightImage.image[:] = 0 - mask = self.host.currentLab2D == self.host.movingID - self.host.highLightIDLayerRightImage.image[mask] = ( - self.host.movingID - ) - highlighted_image = self.host.highLightIDLayerRightImage.image - self.host.highLightIDLayerRightImage.setImage(highlighted_image) \ No newline at end of file + def should_reinitialize_expansion( + self, + *, + expanding_id: int, + hover_label_id: int, + dilation: bool, + is_dilation: bool, + ) -> bool: + return expanding_id != hover_label_id or dilation != is_dilation + + def should_start_moving_label(self, label_id: int) -> bool: + return label_id != 0 + + def start_moving_label(self, x_pos, y_pos): + pos_data = self.data[self.pos_i] + x_data, y_data = int(x_pos), int(y_pos) + lab_2d = self.get_2Dlab(pos_data.lab) + label_id = lab_2d[y_data, x_data] + if not self.should_start_moving_label(label_id): + self.isMovingLabel = False + return + + self.isMovingLabel = True + self.searchedIDitemRight.setData([], []) + self.searchedIDitemLeft.setData([], []) + self.movingID = label_id + self.prevMovePos = (x_data, y_data) + moving_obj = pos_data.rp[pos_data.IDs.index(label_id)] + self.movingObjCoords = moving_obj.coords.copy() + yy, xx = moving_obj.coords[:, -2], moving_obj.coords[:, -1] + self.currentLab2D[yy, xx] = 0 diff --git a/cellacdc/mixins/layout_controls.py b/cellacdc/mixins/layout_controls.py index 132bb69c6..d8e3ef542 100644 --- a/cellacdc/mixins/layout_controls.py +++ b/cellacdc/mixins/layout_controls.py @@ -25,203 +25,45 @@ from cellacdc.ui.modules.annotation.decorators import resetViewRange -class LayoutControlsView: +class LayoutControlsMixin: """Qt-facing adapter around main layout and control surfaces.""" """Headless decisions for GUI layout controls.""" - yes_value = 'Yes' - no_value = 'No' - - def zoom_percentage_from_text(self, text: str) -> int: - return int(re.findall(r'(\d+)%', text)[0]) - - def zoom_factors(self, percentage: int) -> tuple[float, float] | None: - if percentage == 100: - return None - factor = percentage / 100 - return factor, factor - - def checked_setting_value(self, checked: bool) -> str: - return self.yes_value if checked else self.no_value + yes_value = "Yes" + no_value = "No" def checked_from_setting_value(self, value) -> bool: return value == self.yes_value - def should_retain_z_slider_space( - self, - *, - checked: bool, - z_slice_enabled: bool, - ) -> bool: - return checked and z_slice_enabled - - def tool_name_from_tooltip(self, tooltip: str) -> str: - return re.findall(r'Name: (.*)', tooltip)[0] - - - LEGACY_METHODS = ( - 'zoomBottomLayoutActionTriggered', - 'retainSpaceSlidersToggled', - 'gui_createMainLayout', - 'gui_createRegionPropsDockWidget', - 'gui_createControlsToolbar', - 'gui_populateToolSettingsMenu', - 'useCenterBrushCursorHoverIDtoggled', - 'gui_createStatusBar', - 'gui_createTerminalWidget', - 'gui_terminalButtonClicked', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - - def zoomBottomLayoutActionTriggered(self, checked): - if not checked: - return - perc = self.zoom_percentage_from_text( - self.sender().text() - ) - zoom_factors = self.zoom_factors(perc) - if zoom_factors is not None: - fontSizeFactor, heightFactor = zoom_factors - self.resizeSlidersArea( - fontSizeFactor=fontSizeFactor, heightFactor=heightFactor - ) - else: - self.image_controls_view.gui_resetBottomLayoutHeight() - self.df_settings.at['bottom_sliders_zoom_perc', 'value'] = perc - self.df_settings.to_csv(self.settings_csv_path) - QTimer.singleShot(150, self.resizeGui) - - def retainSpaceSlidersToggled(self, checked): - self.df_settings.at['retain_space_hidden_sliders', 'value'] = ( - self.checked_setting_value(checked) - ) - self.df_settings.to_csv(self.settings_csv_path) - retainSpaceZ = self.should_retain_z_slider_space( - checked=checked, - z_slice_enabled=self.zSliceScrollBar.isEnabled(), - ) - myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.zProjComboBox, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.overlay_z_label, retain=retainSpaceZ) - - QTimer.singleShot(200, self.resizeGui) - - def gui_createMainLayout(self): - mainLayout = QGridLayout() - row, col = 0, 1 # Leave column 1 for the overlay labels gradient editor - mainLayout.addLayout(self.leftSideDocksLayout, row, col, 2, 1) - - row = 0 - col = 2 - mainLayout.addWidget(self.graphLayout, row, col, 1, 2) - mainLayout.setRowStretch(row, 2) - - col = 4 # graphLayout spans two columns - mainLayout.addWidget(self.labelsGrad, row, col) - - col = 5 - mainLayout.addLayout(self.rightSideDocksLayout, row, col, 2, 1) - - col = 2 - row += 1 - self.resizeBottomLayoutLine = widgets.VerticalResizeHline() - mainLayout.addWidget(self.resizeBottomLayoutLine, row, col, 1, 2) - self.resizeBottomLayoutLine.dragged.connect( - self.resizeBottomLayoutLineDragged - ) - self.resizeBottomLayoutLine.clicked.connect( - self.resizeBottomLayoutLineClicked - ) - self.resizeBottomLayoutLine.released.connect( - self.resizeBottomLayoutLineReleased - ) - - # row += 1 - # mainLayout.addItem(QSpacerItem(5,5), row+1, col, 1, 2) - - # row, col = 1, 2 - # mainLayout.addLayout( - # self.bottomLayout, row, col, 1, 2, alignment=Qt.AlignLeft - # ) - - row += 1 - mainLayout.addWidget(self.bottomScrollArea, row, col, 1, 2) - mainLayout.setRowStretch(row, 0) - - # row, col = 2, 1 - # mainLayout.addWidget(self.terminal, row, col, 1, 4) - # self.terminal.hide() - - return mainLayout - - def gui_createRegionPropsDockWidget(self, side=Qt.LeftDockWidgetArea): - self.propsDockWidget = QDockWidget( - 'Cell-ACDC objects', self.host - ) - self.guiTabControl = widgets.guiTabControl(self.propsDockWidget) - - # self.guiTabControl.setFont(_font) - - self.propsDockWidget.setWidget(self.guiTabControl) - self.propsDockWidget.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetFloatable - | QDockWidget.DockWidgetFeature.DockWidgetMovable - ) - self.propsDockWidget.setAllowedAreas( - Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea - ) - - self.addDockWidget(side, self.propsDockWidget) - self.propsDockWidget.hide() + def checked_setting_value(self, checked: bool) -> str: + return self.yes_value if checked else self.no_value def gui_createControlsToolbar(self): self.controlToolBars = [] self.addToolBarBreak() - + # Edit toolbar - modeToolBar = widgets.ToolBar("Mode", self.host) + modeToolBar = widgets.ToolBar("Mode", self) self.addToolBar(modeToolBar) self.modeComboBox = widgets.ComboBox() self.modeComboBox.addItems(self.modeItems) - self.modeComboBoxLabel = QLabel(' Mode: ') + self.modeComboBoxLabel = QLabel(" Mode: ") self.modeComboBoxLabel.setBuddy(self.modeComboBox) modeToolBar.addWidget(self.modeComboBoxLabel) modeToolBar.addWidget(self.modeComboBox) modeToolBar.setVisible(False) - + self.modeToolBar = modeToolBar - - self.overlayToolbar = widgets.OverlayToolbar(parent=self.host) + + self.overlayToolbar = widgets.OverlayToolbar(parent=self) self.addToolBar(Qt.TopToolBarArea, self.overlayToolbar) self.overlayToolbar.setVisible(False) - self.overlayToolbar.sigSetTranspacency.connect( - self.setOverlayTransparency - ) - self.overlayToolbar.sigSetSingleChannel.connect( - self.setOverlaySingleChannel - ) - - self.autoPilotZoomToObjToolbar = widgets.ToolBar( - "Auto-zoom to objects", self - ) + self.overlayToolbar.sigSetTranspacency.connect(self.setOverlayTransparency) + self.overlayToolbar.sigSetSingleChannel.connect(self.setOverlaySingleChannel) + + self.autoPilotZoomToObjToolbar = widgets.ToolBar("Auto-zoom to objects", self) self.autoPilotZoomToObjToolbar.setContextMenuPolicy(Qt.PreventContextMenu) self.autoPilotZoomToObjToolbar.setMovable(False) self.addToolBar(Qt.TopToolBarArea, self.autoPilotZoomToObjToolbar) @@ -229,92 +71,81 @@ def gui_createControlsToolbar(self): self.autoPilotZoomToObjToolbar.setVisible(False) self.autoPilotZoomToObjToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.autoPilotZoomToObjToolbar) - + # Highlighted ID or searched ID toolbar - self.highlightIDToolbar = widgets.HighlightedIDToolbar( - parent=self.host - ) + self.highlightIDToolbar = widgets.HighlightedIDToolbar(parent=self) self.addToolBar(Qt.TopToolBarArea, self.highlightIDToolbar) self.highlightIDToolbar.setVisible(False) self.highlightIDToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.highlightIDToolbar) - - self.highlightIDToolbar.sigIDChanged.connect( - self.setHighlighedIDfromToolbar - ) - + + self.highlightIDToolbar.sigIDChanged.connect(self.setHighlighedIDfromToolbar) + # Widgets toolbar - brushEraserToolBar = widgets.ToolBar("Widgets", self.host) + brushEraserToolBar = widgets.ToolBar("Widgets", self) self.addToolBar(Qt.TopToolBarArea, brushEraserToolBar) self.controlToolBars.append(brushEraserToolBar) self.editIDspinbox = widgets.SpinBox() # self.editIDspinbox.setMaximum(2**32-1) - editIDLabel = QLabel(' ID: ') + editIDLabel = QLabel(" ID: ") self.editIDLabelAction = brushEraserToolBar.addWidget(editIDLabel) - self.editIDspinboxAction = brushEraserToolBar.addWidget( - self.editIDspinbox - ) + self.editIDspinboxAction = brushEraserToolBar.addWidget(self.editIDspinbox) self.editIDLabelAction.setVisible(False) self.editIDspinboxAction.setVisible(False) self.editIDspinboxAction.setDisabled(True) self.editIDLabelAction.setDisabled(True) - brushEraserToolBar.addWidget(QLabel(' ')) - self.autoIDcheckbox = QCheckBox('Auto-ID') + brushEraserToolBar.addWidget(QLabel(" ")) + self.autoIDcheckbox = QCheckBox("Auto-ID") self.autoIDcheckbox.setChecked(True) self.autoIDcheckboxAction = brushEraserToolBar.addWidget(self.autoIDcheckbox) self.autoIDcheckboxAction.setVisible(False) self.brushSizeSpinbox = widgets.SpinBox( - disableKeyPress=True, - allowNegative=False + disableKeyPress=True, allowNegative=False ) self.brushSizeSpinbox.setValue(4) - brushSizeLabel = QLabel(' Size: ') + brushSizeLabel = QLabel(" Size: ") brushSizeLabel.setBuddy(self.brushSizeSpinbox) self.brushSizeLabelAction = brushEraserToolBar.addWidget(brushSizeLabel) self.brushSizeAction = brushEraserToolBar.addWidget(self.brushSizeSpinbox) self.brushSizeLabelAction.setVisible(False) self.brushSizeAction.setVisible(False) - - brushEraserToolBar.addWidget(QLabel(' ')) - self.brushAutoFillCheckbox = QCheckBox('Auto-fill holes') + + brushEraserToolBar.addWidget(QLabel(" ")) + self.brushAutoFillCheckbox = QCheckBox("Auto-fill holes") self.brushAutoFillAction = brushEraserToolBar.addWidget( self.brushAutoFillCheckbox ) self.brushAutoFillAction.setVisible(False) - if 'brushAutoFill' in self.df_settings.index: - checked = self.df_settings.at['brushAutoFill', 'value'] == 'Yes' + if "brushAutoFill" in self.df_settings.index: + checked = self.df_settings.at["brushAutoFill", "value"] == "Yes" self.brushAutoFillCheckbox.setChecked(checked) - - brushEraserToolBar.addWidget(QLabel(' ')) - self.brushAutoHideCheckbox = QCheckBox('Hide objects when hovering') + + brushEraserToolBar.addWidget(QLabel(" ")) + self.brushAutoHideCheckbox = QCheckBox("Hide objects when hovering") self.brushAutoHideAction = brushEraserToolBar.addWidget( self.brushAutoHideCheckbox ) self.brushAutoHideCheckbox.setChecked(True) self.brushAutoHideAction.setVisible(False) - if 'brushAutoHide' in self.df_settings.index: - checked = self.df_settings.at['brushAutoHide', 'value'] == 'Yes' + if "brushAutoHide" in self.df_settings.index: + checked = self.df_settings.at["brushAutoHide", "value"] == "Yes" self.brushAutoHideCheckbox.setChecked(checked) - + brushEraserToolBar.setVisible(False) self.brushEraserToolBar = brushEraserToolBar - self.wandControlsToolbar = widgets.WandControlsToolbar( - parent=self.host - ) + self.wandControlsToolbar = widgets.WandControlsToolbar(parent=self) - self.addToolBar(Qt.TopToolBarArea , self.wandControlsToolbar) + self.addToolBar(Qt.TopToolBarArea, self.wandControlsToolbar) self.wandControlsToolbar.setVisible(False) self.controlToolBars.append(self.wandControlsToolbar) separatorW = 5 - self.labelRoiToolbar = widgets.ToolBar( - "Magic labeller controls", self.host - ) - self.labelRoiToolbar.addWidget(QLabel('ROI n. of z-slices: ')) + self.labelRoiToolbar = widgets.ToolBar("Magic labeller controls", self) + self.labelRoiToolbar.addWidget(QLabel("ROI n. of z-slices: ")) self.labelRoiZdepthSpinbox = widgets.SpinBox(disableKeyPress=True) self.labelRoiToolbar.addWidget(self.labelRoiZdepthSpinbox) @@ -323,43 +154,43 @@ def gui_createControlsToolbar(self): self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) self.labelRoiReplaceExistingObjectsCheckbox = QCheckBox( - 'Remove objs. touched by new ones' + "Remove objs. touched by new ones" ) self.labelRoiToolbar.addWidget(self.labelRoiReplaceExistingObjectsCheckbox) self.labelRoiAutoClearBorderCheckbox = QCheckBox( - 'Clear ROI borders before adding new objs.' + "Clear ROI borders before adding new objs." ) self.labelRoiAutoClearBorderCheckbox.setChecked(True) self.labelRoiToolbar.addWidget(self.labelRoiAutoClearBorderCheckbox) - + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) self.labelRoiToolbar.addWidget(widgets.QVLine()) self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) group = QButtonGroup() group.setExclusive(True) - self.labelRoiIsRectRadioButton = QRadioButton('Rect. ROI') + self.labelRoiIsRectRadioButton = QRadioButton("Rect. ROI") self.labelRoiIsRectRadioButton.setChecked(True) - self.labelRoiIsFreeHandRadioButton = QRadioButton('Freehand ROI') - self.labelRoiIsCircularRadioButton = QRadioButton('Circular ROI') + self.labelRoiIsFreeHandRadioButton = QRadioButton("Freehand ROI") + self.labelRoiIsCircularRadioButton = QRadioButton("Circular ROI") group.addButton(self.labelRoiIsRectRadioButton) group.addButton(self.labelRoiIsFreeHandRadioButton) group.addButton(self.labelRoiIsCircularRadioButton) self.labelRoiToolbar.addWidget(self.labelRoiIsRectRadioButton) self.labelRoiToolbar.addWidget(self.labelRoiIsFreeHandRadioButton) self.labelRoiToolbar.addWidget(self.labelRoiIsCircularRadioButton) - self.labelRoiToolbar.addWidget(QLabel(' | Radius (pixel): ')) + self.labelRoiToolbar.addWidget(QLabel(" | Radius (pixel): ")) self.labelRoiCircularRadiusSpinbox = widgets.SpinBox(disableKeyPress=True) self.labelRoiCircularRadiusSpinbox.setMinimum(1) self.labelRoiCircularRadiusSpinbox.setValue(11) self.labelRoiCircularRadiusSpinbox.setDisabled(True) self.labelRoiToolbar.addWidget(self.labelRoiCircularRadiusSpinbox) - + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) self.labelRoiToolbar.addWidget(widgets.QVLine()) self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - startFrameLabel = QLabel('Start frame n. ') + startFrameLabel = QLabel("Start frame n. ") startFrameLabel.setDisabled(True) self.labelRoiToolbar.addWidget(startFrameLabel) self.labelRoiStartFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) @@ -369,14 +200,14 @@ def gui_createControlsToolbar(self): self.labelRoiToolbar.addWidget(self.labelRoiStartFrameNoSpinbox) self.labelRoiStartFrameNoSpinbox.setDisabled(True) - self.labelRoiFromCurrentFrameAction = QAction(self.host) - self.labelRoiFromCurrentFrameAction.setText('Segment from current frame') + self.labelRoiFromCurrentFrameAction = QAction(self) + self.labelRoiFromCurrentFrameAction.setText("Segment from current frame") self.labelRoiFromCurrentFrameAction.setIcon(QIcon(":frames_current.svg")) self.labelRoiToolbar.addAction(self.labelRoiFromCurrentFrameAction) self.labelRoiFromCurrentFrameAction.setDisabled(True) self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=3)) - stopFrameLabel = QLabel(' Stop frame n. ') + stopFrameLabel = QLabel(" Stop frame n. ") stopFrameLabel.setDisabled(True) self.labelRoiToolbar.addWidget(stopFrameLabel) self.labelRoiStopFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) @@ -386,19 +217,17 @@ def gui_createControlsToolbar(self): self.labelRoiToolbar.addWidget(self.labelRoiStopFrameNoSpinbox) self.labelRoiStopFrameNoSpinbox.setDisabled(True) - self.labelRoiToEndFramesAction = QAction(self.host) - self.labelRoiToEndFramesAction.setText('Segment all remaining frames') + self.labelRoiToEndFramesAction = QAction(self) + self.labelRoiToEndFramesAction.setText("Segment all remaining frames") self.labelRoiToEndFramesAction.setIcon(QIcon(":frames_end.svg")) self.labelRoiToolbar.addAction(self.labelRoiToEndFramesAction) self.labelRoiToEndFramesAction.setDisabled(True) - self.labelRoiTrangeCheckbox = QCheckBox('Segment range of frames') + self.labelRoiTrangeCheckbox = QCheckBox("Segment range of frames") self.labelRoiToolbar.addWidget(self.labelRoiTrangeCheckbox) - self.labelRoiViewCurrentModelAction = QAction(self.host) - self.labelRoiViewCurrentModelAction.setText( - 'View current model\'s parameters' - ) + self.labelRoiViewCurrentModelAction = QAction(self) + self.labelRoiViewCurrentModelAction.setText("View current model's parameters") self.labelRoiViewCurrentModelAction.setIcon(QIcon(":view.svg")) self.labelRoiToolbar.addAction(self.labelRoiViewCurrentModelAction) self.labelRoiViewCurrentModelAction.setDisabled(True) @@ -410,9 +239,7 @@ def gui_createControlsToolbar(self): self.loadLabelRoiLastParams() - self.labelRoiTrangeCheckbox.toggled.connect( - self.labelRoiTrangeCheckboxToggled - ) + self.labelRoiTrangeCheckbox.toggled.connect(self.labelRoiTrangeCheckboxToggled) self.labelRoiReplaceExistingObjectsCheckbox.toggled.connect( self.storeLabelRoiParams ) @@ -425,12 +252,8 @@ def gui_createControlsToolbar(self): self.labelRoiCircularRadiusSpinbox.valueChanged.connect( self.storeLabelRoiParams ) - self.labelRoiZdepthSpinbox.valueChanged.connect( - self.storeLabelRoiParams - ) - self.labelRoiAutoClearBorderCheckbox.toggled.connect( - self.storeLabelRoiParams - ) + self.labelRoiZdepthSpinbox.valueChanged.connect(self.storeLabelRoiParams) + self.labelRoiAutoClearBorderCheckbox.toggled.connect(self.storeLabelRoiParams) group.buttonToggled.connect(self.storeLabelRoiParams) self.labelRoiToEndFramesAction.triggered.connect( @@ -443,22 +266,18 @@ def gui_createControlsToolbar(self): self.labelRoiViewCurrentModel ) - self.keepIDsToolbar = widgets.ToolBar( - "Keep IDs controls", self.host - ) + self.keepIDsToolbar = widgets.ToolBar("Keep IDs controls", self) self.keepIDsConfirmAction = QAction() self.keepIDsConfirmAction.setIcon(QIcon(":greenTick.svg")) self.keepIDsConfirmAction.setToolTip('Apply "keep IDs" selection') self.keepIDsConfirmAction.setDisabled(True) self.keepIDsToolbar.addAction(self.keepIDsConfirmAction) - self.keepIDsToolbar.addWidget(QLabel(' IDs to keep: ')) + self.keepIDsToolbar.addWidget(QLabel(" IDs to keep: ")) instructionsText = ( - ' (Separate IDs by comma. Use a dash to denote a range of IDs)' + " (Separate IDs by comma. Use a dash to denote a range of IDs)" ) instructionsLabel = QLabel(instructionsText) - self.keptIDsLineEdit = widgets.KeepIDsLineEdit( - instructionsLabel, parent=self.host - ) + self.keptIDsLineEdit = widgets.KeepIDsLineEdit(instructionsLabel, parent=self) self.keepIDsToolbar.addWidget(self.keptIDsLineEdit) self.keepIDsToolbar.addWidget(instructionsLabel) spacer = QWidget() @@ -471,21 +290,21 @@ def gui_createControlsToolbar(self): self.keptIDsLineEdit.sigEnterPressed.connect(self.applyKeepObjects) self.keptIDsLineEdit.sigIDsChanged.connect(self.updateKeepIDs) self.keepIDsConfirmAction.triggered.connect(self.applyKeepObjects) - + # closeToolbarAction = QAction( # QIcon(":cancelButton.svg"), "Close toolbar...", self # ) # closeToolbarAction.triggered.connect(self.closeToolbars) # self.autoPilotZoomToObjToolbar.addAction(closeToolbarAction) - + self.autoPilotZoomToObjToolbar.addWidget(widgets.QVLine()) self.autoPilotZoomToObjToolbar.addWidget( widgets.QHWidgetSpacer(width=separatorW) ) - + spinBox = widgets.SpinBox() spinBox.setMinimum(1) - spinBox.label = QLabel(' Zoom to ID: ') + spinBox.label = QLabel(" Zoom to ID: ") spinBox.labelAction = self.autoPilotZoomToObjToolbar.addWidget(spinBox.label) spinBox.action = self.autoPilotZoomToObjToolbar.addWidget(spinBox) spinBox.editingFinished.connect(self.zoomToObj) @@ -495,50 +314,40 @@ def gui_createControlsToolbar(self): toggle = widgets.Toggle() self.autoPilotZoomToObjToggle = toggle toggle.toggled.connect(self.autoPilotZoomToObjToggled) - toggle.label = QLabel(' Auto-pilot: ') + toggle.label = QLabel(" Auto-pilot: ") tooltip = ( - 'When auto-pilot is active, you can use Up/Down arrows to ' - 'automatically zoom to the next/previous object.\n\n' - 'Alternatively, you can type the ID of the object you want to ' - 'zoom to.' + "When auto-pilot is active, you can use Up/Down arrows to " + "automatically zoom to the next/previous object.\n\n" + "Alternatively, you can type the ID of the object you want to " + "zoom to." ) toggle.label.setToolTip(tooltip) toggle.setToolTip(tooltip) self.autoPilotZoomToObjToolbar.addWidget(toggle.label) self.autoPilotZoomToObjToolbar.addWidget(toggle) - + self.pointsLayersToolbars = [] - - self.pointsLayersToolbar = widgets.PointsLayersToolbar( - parent=self.host - ) + + self.pointsLayersToolbar = widgets.PointsLayersToolbar(parent=self) self.pointsLayersToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - - self.pointsLayersToolbar.sigAddPointsLayer.connect( - self.addPointsLayerTriggered - ) - + + self.pointsLayersToolbar.sigAddPointsLayer.connect(self.addPointsLayerTriggered) + self.addToolBar(Qt.TopToolBarArea, self.pointsLayersToolbar) - + self.pointsLayersToolbar.setVisible(False) self.pointsLayersToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.pointsLayersToolbar) - - self.pointsLayersToolbars.append( - self.pointsLayersToolbar - ) + + self.pointsLayersToolbars.append(self.pointsLayersToolbar) self.manualTrackingToolbar = widgets.ManualTrackingToolBar( "Manual tracking controls", self ) self.manualTrackingToolbar.sigIDchanged.connect(self.initGhostObject) self.manualTrackingToolbar.sigDisableGhost.connect(self.clearGhost) - self.manualTrackingToolbar.sigClearGhostContour.connect( - self.clearGhostContour - ) - self.manualTrackingToolbar.sigClearGhostMask.connect( - self.clearGhostMask - ) + self.manualTrackingToolbar.sigClearGhostContour.connect(self.clearGhostContour) + self.manualTrackingToolbar.sigClearGhostMask.connect(self.clearGhostMask) self.manualTrackingToolbar.sigGhostOpacityChanged.connect( self.updateGhostMaskOpacity ) @@ -546,7 +355,7 @@ def gui_createControlsToolbar(self): self.addToolBar(Qt.TopToolBarArea, self.manualTrackingToolbar) self.manualTrackingToolbar.setVisible(False) self.controlToolBars.append(self.manualTrackingToolbar) - + self.manualBackgroundToolbar = widgets.ManualBackgroundToolBar( "Manual background controls", self ) @@ -556,7 +365,7 @@ def gui_createControlsToolbar(self): self.addToolBar(Qt.TopToolBarArea, self.manualBackgroundToolbar) self.manualBackgroundToolbar.setVisible(False) self.controlToolBars.append(self.manualBackgroundToolbar) - + # Copy lost object contour toolbar self.copyLostObjToolbar = widgets.CopyLostObjectToolbar( "Copy lost object controls", self @@ -564,137 +373,217 @@ def gui_createControlsToolbar(self): for name, action in self.copyLostObjToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - self.copyLostObjToolbar.sigCopyAllObjects.connect( - self.copyAllLostObjects - ) - + self.copyLostObjToolbar.sigCopyAllObjects.connect(self.copyAllLostObjects) + self.addToolBar(Qt.TopToolBarArea, self.copyLostObjToolbar) self.copyLostObjToolbar.setVisible(False) # self.controlToolBars.append(self.copyLostObjToolbar) - + # Copy lost object contour toolbar self.drawClearRegionToolbar = widgets.DrawClearRegionToolbar( "Draw freehand region and clear objects controls", self ) - + self.addToolBar(Qt.TopToolBarArea, self.drawClearRegionToolbar) self.drawClearRegionToolbar.setVisible(False) self.controlToolBars.append(self.drawClearRegionToolbar) try: - addNewIDToggleState = self.df_settings.at['addNewIDsWhitelistToggle', 'value'] == 'Yes' + addNewIDToggleState = ( + self.df_settings.at["addNewIDsWhitelistToggle", "value"] == "Yes" + ) except KeyError: addNewIDToggleState = True - + self.whitelistIDsToolbar = widgets.WhitelistIDsToolbar( addNewIDToggleState, self ) for name, action in self.whitelistIDsToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - + self.addToolBar(Qt.TopToolBarArea, self.whitelistIDsToolbar) self.whitelistIDsToolbar.setVisible(False) self.controlToolBars.append(self.whitelistIDsToolbar) - - self.magicPromptsToolbar = widgets.MagicPromptsToolbar(self.host) + + self.magicPromptsToolbar = widgets.MagicPromptsToolbar(self) for name, action in self.magicPromptsToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - + self.magicPromptsToolbar.sigComputeOnZoom.connect( - self.magic_prompts_view.magicPromptsComputeOnZoomTriggered + self.magicPromptsComputeOnZoomTriggered ) self.magicPromptsToolbar.sigComputeOnImage.connect( - self.magic_prompts_view.magicPromptsComputeOnImageTriggered + self.magicPromptsComputeOnImageTriggered ) self.magicPromptsToolbar.sigInitSelectedModel.connect( - self.magic_prompts_view.magicPromptsInitModel + self.magicPromptsInitModel ) self.magicPromptsToolbar.sigViewModelParams.connect( - self.magic_prompts_view.viewSetMagicPromptModelParams + self.viewSetMagicPromptModelParams ) self.magicPromptsToolbar.sigClearPoints.connect( - partial( - self.magic_prompts_view.magicPromptsClearPoints, - only_zoom=False, - ) + partial(self.magicPromptsClearPoints, only_zoom=False) ) self.magicPromptsToolbar.sigClearPointsOnZmom.connect( - partial( - self.magic_prompts_view.magicPromptsClearPoints, - only_zoom=True, - ) + partial(self.magicPromptsClearPoints, only_zoom=True) ) self.magicPromptsToolbar.sigInterpolateZslice.connect( - self.magic_prompts_view.magicPromptsInterpolateZsliceToggled + self.magicPromptsInterpolateZsliceToggled ) self.addToolBar(Qt.TopToolBarArea, self.magicPromptsToolbar) self.magicPromptsToolbar.setVisible(False) self.magicPromptsToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.magicPromptsToolbar) - + self.promptSegmentPointsLayerToolbar = ( - widgets.PromptableModelPointsLayerToolbar(parent=self.host) - ) - self.promptSegmentPointsLayerToolbar.setContextMenuPolicy( - Qt.PreventContextMenu + widgets.PromptableModelPointsLayerToolbar(parent=self) ) - + self.promptSegmentPointsLayerToolbar.setContextMenuPolicy(Qt.PreventContextMenu) + self.addToolBar(Qt.TopToolBarArea, self.promptSegmentPointsLayerToolbar) self.promptSegmentPointsLayerToolbar.setVisible(False) - - self.pointsLayersToolbars.append( - self.promptSegmentPointsLayerToolbar - ) - + + self.pointsLayersToolbars.append(self.promptSegmentPointsLayerToolbar) + # Second level toolbar - secondLevelToolbar = widgets.ToolBar( - "Second level toolbar", self.host - ) + secondLevelToolbar = widgets.ToolBar("Second level toolbar", self) self.addToolBar(Qt.TopToolBarArea, secondLevelToolbar) - self.delObjToolAction = QAction(self.host) + self.delObjToolAction = QAction(self) self.delObjToolAction.setIcon(QIcon(":del_obj_click.svg")) self.delObjToolAction.setCheckable(True) self.delObjToolAction.setToolTip( - 'Customisable delete object action\n\n' - 'Go to the `Settings --> Customise keyboard shortcuts...` menu ' - 'on the top menubar\n' - 'to customise the action required to delete ' - 'an object with a click.\n\n' + "Customisable delete object action\n\n" + "Go to the `Settings --> Customise keyboard shortcuts...` menu " + "on the top menubar\n" + "to customise the action required to delete " + "an object with a click.\n\n" 'When working with 3D segmentations, to delete only the z-slice mask, hold "Shift" while clicking.' ) secondLevelToolbar.addAction(self.delObjToolAction) secondLevelToolbar.setMovable(False) self.secondLevelToolbar = secondLevelToolbar self.secondLevelToolbar.setVisible(False) - + + def gui_createMainLayout(self): + mainLayout = QGridLayout() + row, col = 0, 1 # Leave column 1 for the overlay labels gradient editor + mainLayout.addLayout(self.leftSideDocksLayout, row, col, 2, 1) + + row = 0 + col = 2 + mainLayout.addWidget(self.graphLayout, row, col, 1, 2) + mainLayout.setRowStretch(row, 2) + + col = 4 # graphLayout spans two columns + mainLayout.addWidget(self.labelsGrad, row, col) + + col = 5 + mainLayout.addLayout(self.rightSideDocksLayout, row, col, 2, 1) + + col = 2 + row += 1 + self.resizeBottomLayoutLine = widgets.VerticalResizeHline() + mainLayout.addWidget(self.resizeBottomLayoutLine, row, col, 1, 2) + self.resizeBottomLayoutLine.dragged.connect(self.resizeBottomLayoutLineDragged) + self.resizeBottomLayoutLine.clicked.connect(self.resizeBottomLayoutLineClicked) + self.resizeBottomLayoutLine.released.connect( + self.resizeBottomLayoutLineReleased + ) + + # row += 1 + # mainLayout.addItem(QSpacerItem(5,5), row+1, col, 1, 2) + + # row, col = 1, 2 + # mainLayout.addLayout( + # self.bottomLayout, row, col, 1, 2, alignment=Qt.AlignLeft + # ) + + row += 1 + mainLayout.addWidget(self.bottomScrollArea, row, col, 1, 2) + mainLayout.setRowStretch(row, 0) + + # row, col = 2, 1 + # mainLayout.addWidget(self.terminal, row, col, 1, 4) + # self.terminal.hide() + + return mainLayout + + def gui_createRegionPropsDockWidget(self, side=Qt.LeftDockWidgetArea): + self.propsDockWidget = QDockWidget("Cell-ACDC objects", self) + self.guiTabControl = widgets.guiTabControl(self.propsDockWidget) + + # self.guiTabControl.setFont(_font) + + self.propsDockWidget.setWidget(self.guiTabControl) + self.propsDockWidget.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetFloatable + | QDockWidget.DockWidgetFeature.DockWidgetMovable + ) + self.propsDockWidget.setAllowedAreas( + Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea + ) + + self.addDockWidget(side, self.propsDockWidget) + self.propsDockWidget.hide() + + def gui_createStatusBar(self): + self.statusbar = self.statusBar() + # Permanent widget + self.wcLabel = QLabel("") + self.statusbar.addPermanentWidget(self.wcLabel) + + # self.toggleTerminalButton = widgets.ToggleTerminalButton() + # self.statusbar.addWidget(self.toggleTerminalButton) + # self.toggleTerminalButton.sigClicked.connect( + # self.gui_terminalButtonClicked + # ) + + self.statusBarLabel = QLabel("") + self.statusbar.addWidget(self.statusBarLabel) + + def gui_createTerminalWidget(self): + self.terminal = widgets.QLog(logger=self.logger) + self.terminal.connect() + self.terminalDock = QDockWidget("Log", self) + + self.terminalDock.setWidget(self.terminal) + self.terminalDock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetFloatable + | QDockWidget.DockWidgetFeature.DockWidgetMovable + ) + self.terminalDock.setAllowedAreas(Qt.BottomDockWidgetArea) + self.addDockWidget(Qt.BottomDockWidgetArea, self.terminalDock) + # self.terminalDock.widget().layout().setContentsMargins(10,0,10,0) + self.terminalDock.setVisible(False) + def gui_populateToolSettingsMenu(self): - brushHoverModeActionGroup = QActionGroup(self.host) + brushHoverModeActionGroup = QActionGroup(self) brushHoverModeActionGroup.setExclusive(True) self.brushHoverCenterModeAction = QAction() self.brushHoverCenterModeAction.setCheckable(True) self.brushHoverCenterModeAction.setText( - 'Use center of the brush/eraser cursor to determine hover ID' + "Use center of the brush/eraser cursor to determine hover ID" ) self.brushHoverCircleModeAction = QAction() self.brushHoverCircleModeAction.setCheckable(True) self.brushHoverCircleModeAction.setText( - 'Use the entire circle of the brush/eraser cursor to determine hover ID' + "Use the entire circle of the brush/eraser cursor to determine hover ID" ) brushHoverModeActionGroup.addAction(self.brushHoverCenterModeAction) brushHoverModeActionGroup.addAction(self.brushHoverCircleModeAction) brushHoverModeMenu = self.settingsMenu.addMenu( - 'Brush/eraser cursor hovering mode' + "Brush/eraser cursor hovering mode" ) brushHoverModeMenu.addAction(self.brushHoverCenterModeAction) brushHoverModeMenu.addAction(self.brushHoverCircleModeAction) - if 'useCenterBrushCursorHoverID' not in self.df_settings.index: - self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' + if "useCenterBrushCursorHoverID" not in self.df_settings.index: + self.df_settings.at["useCenterBrushCursorHoverID", "value"] = "Yes" - useCenterBrushCursorHoverID = self.df_settings.at[ - 'useCenterBrushCursorHoverID', 'value' - ] == 'Yes' + useCenterBrushCursorHoverID = ( + self.df_settings.at["useCenterBrushCursorHoverID", "value"] == "Yes" + ) self.brushHoverCenterModeAction.setChecked(useCenterBrushCursorHoverID) self.brushHoverCircleModeAction.setChecked(not useCenterBrushCursorHoverID) @@ -704,45 +593,43 @@ def gui_populateToolSettingsMenu(self): self.settingsMenu.addSeparator() - keepToolActiveNames = { - 'Segment range of frames': self.labelRoiTrangeCheckbox - } + keepToolActiveNames = {"Segment range of frames": self.labelRoiTrangeCheckbox} for button in self.checkableQButtonsGroup.buttons(): if button.toolTip() == "": toolName = "MISSING" continue else: - toolName = self.tool_name_from_tooltip( - button.toolTip() - ) + toolName = re.findall(r"Name: (.*)", button.toolTip())[0] keepToolActiveNames[toolName] = button - + keepToolActiveNames = dict(natsorted(keepToolActiveNames.items())) - + applyToNewFrameNames = { - 'Segmenting for lost IDs': self.segForLostIDsButton, - 'Delete bordering objects': self.delBorderObjAction.button, - 'Delete newly segmented objects': self.delNewObjAction.button, + "Segmenting for lost IDs": self.segForLostIDsButton, + "Delete bordering objects": self.delBorderObjAction.button, + "Delete newly segmented objects": self.delNewObjAction.button, } - - allToolsList = list(keepToolActiveNames.keys()) + list(applyToNewFrameNames.keys()) + + allToolsList = list(keepToolActiveNames.keys()) + list( + applyToNewFrameNames.keys() + ) allToolsList = natsorted(allToolsList) - + menus = {} - + for toolName in allToolsList: - menuItemText = f'{toolName} tool'.replace(' ', ' ') + menuItemText = f"{toolName} tool".replace(" ", " ") menus[toolName] = self.settingsMenu.addMenu(menuItemText) - + self.keepToolActiveActions = dict() self.applyToolNewFrameActions = dict() self.applyToolNewFrameButtons = dict() all_checked = True - + for toolName, button in keepToolActiveNames.items(): menu = menus[toolName] action = QAction(button) - action.setText('Keep tool active after using it') + action.setText("Keep tool active after using it") action.setCheckable(True) if toolName in self.df_settings.index: action.setChecked(True) @@ -751,32 +638,30 @@ def gui_populateToolSettingsMenu(self): action.toggled.connect(self.keepToolActiveActionToggled) menu.addAction(action) self.keepToolActiveActions[toolName] = action - + for toolName, button in applyToNewFrameNames.items(): menu = menus[toolName] action = QAction(button) - action.setText('Apply when visitng new frame') + action.setText("Apply when visitng new frame") action.setCheckable(True) action.toggled.connect(self.applyToolNewFrameActionToggled) menu.addAction(action) self.applyToolNewFrameActions[toolName] = action self.applyToolNewFrameButtons[toolName] = button - + for toolName in self.applyToolNewFrameActions.keys(): settingString = toolName.strip() - settingString = toolName.replace(' ', '_') - settingString = f'{settingString}_applyNewFrame' + settingString = toolName.replace(" ", "_") + settingString = f"{settingString}_applyNewFrame" if settingString in self.df_settings.index: - val = self.df_settings.at[settingString, 'value'] - if val == 'applyNewFrame': + val = self.df_settings.at[settingString, "value"] + if val == "applyNewFrame": self.applyToolNewFrameActions[toolName].setChecked(True) - + self.settingsMenu.addSeparator() self.keepAllToolsActiveToggle = QAction() - self.keepAllToolsActiveToggle.setText( - 'Keep all tools active after using them' - ) + self.keepAllToolsActiveToggle.setText("Keep all tools active after using them") self.keepAllToolsActiveToggle.setCheckable(True) self.keepAllToolsActiveToggle.setChecked(all_checked) self.keepAllToolsActiveToggle.toggled.connect( @@ -784,17 +669,17 @@ def gui_populateToolSettingsMenu(self): ) self.settingsMenu.addAction(self.keepAllToolsActiveToggle) self.settingsMenu.addSeparator() - + askHowFutureFramesMenu = self.settingsMenu.addMenu( - 'Ask how to propagate changes to future frames' + "Ask how to propagate changes to future frames" ) self.askHowFutureFramesActions = {} askHowFutureFramesActionsKeys = ( - 'Delete ID', - 'Exclude cell from analysis', - 'Annotate cell as dead', - 'Edit ID', - 'Keep ID' + "Delete ID", + "Exclude cell from analysis", + "Annotate cell as dead", + "Edit ID", + "Keep ID", ) for key in askHowFutureFramesActionsKeys: askHowFutureFramesAction = QAction() @@ -804,32 +689,25 @@ def gui_populateToolSettingsMenu(self): askHowFutureFramesAction.setDisabled(True) askHowFutureFramesMenu.addAction(askHowFutureFramesAction) self.askHowFutureFramesActions[key] = askHowFutureFramesAction - - warningsMenu = self.settingsMenu.addMenu('Warnings and pop-ups') + + warningsMenu = self.settingsMenu.addMenu("Warnings and pop-ups") self.warnLostCellsAction = QAction() - self.warnLostCellsAction.setText('Show pop-up warning for lost cells') + self.warnLostCellsAction.setText("Show pop-up warning for lost cells") self.warnLostCellsAction.setCheckable(True) self.warnLostCellsAction.setChecked(True) warningsMenu.addAction(self.warnLostCellsAction) warnEditingWithAnnotTexts = { - 'Delete ID': 'Show warning when deleting ID that has annotations', - 'Separate IDs': 'Show warning when separating IDs that have annotations', - 'Edit ID': 'Show warning when editing ID that has annotations', - 'Annotate ID as dead': - 'Show warning when annotating dead ID that has annotations', - 'Delete ID with eraser': - 'Show warning when erasing ID that has annotations', - 'Add new ID with brush tool': - 'Show warning when adding new ID (brush) that has annotations', - 'Merge IDs': - 'Show warning when merging IDs that have annotations', - 'Add new ID with curvature tool': - 'Show warning when adding new ID (curv. tool) that has annotations', - 'Add new ID with magic-wand': - 'Show warning when adding new ID (magic-wand) that has annotations', - 'Delete IDs using ROI': - 'Show warning when using ROIs to delete IDs that have annotations', + "Delete ID": "Show warning when deleting ID that has annotations", + "Separate IDs": "Show warning when separating IDs that have annotations", + "Edit ID": "Show warning when editing ID that has annotations", + "Annotate ID as dead": "Show warning when annotating dead ID that has annotations", + "Delete ID with eraser": "Show warning when erasing ID that has annotations", + "Add new ID with brush tool": "Show warning when adding new ID (brush) that has annotations", + "Merge IDs": "Show warning when merging IDs that have annotations", + "Add new ID with curvature tool": "Show warning when adding new ID (curv. tool) that has annotations", + "Add new ID with magic-wand": "Show warning when adding new ID (magic-wand) that has annotations", + "Delete IDs using ROI": "Show warning when using ROIs to delete IDs that have annotations", } self.warnEditingWithAnnotActions = {} for key, desc in warnEditingWithAnnotTexts.items(): @@ -841,42 +719,80 @@ def gui_populateToolSettingsMenu(self): self.warnEditingWithAnnotActions[key] = action warningsMenu.addAction(action) - def useCenterBrushCursorHoverIDtoggled(self, checked): + @resetViewRange + def gui_terminalButtonClicked(self, terminalVisible): + self.terminalDock.setVisible(terminalVisible) + + def retainSpaceSlidersToggled(self, checked): if checked: - self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' + self.df_settings.at["retain_space_hidden_sliders", "value"] = "Yes" else: - self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'No' + self.df_settings.at["retain_space_hidden_sliders", "value"] = "No" self.df_settings.to_csv(self.settings_csv_path) + if not self.zSliceScrollBar.isEnabled(): + retainSpaceZ = False + else: + retainSpaceZ = checked + myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=retainSpaceZ) + myutils.setRetainSizePolicy(self.zProjComboBox, retain=retainSpaceZ) + myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=retainSpaceZ) + myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=retainSpaceZ) + myutils.setRetainSizePolicy(self.overlay_z_label, retain=retainSpaceZ) - def gui_createStatusBar(self): - self.statusbar = self.statusBar() - # Permanent widget - self.wcLabel = QLabel('') - self.statusbar.addPermanentWidget(self.wcLabel) + QTimer.singleShot(200, self.resizeGui) - # self.toggleTerminalButton = widgets.ToggleTerminalButton() - # self.statusbar.addWidget(self.toggleTerminalButton) - # self.toggleTerminalButton.sigClicked.connect( - # self.gui_terminalButtonClicked - # ) + def should_retain_z_slider_space( + self, + *, + checked: bool, + z_slice_enabled: bool, + ) -> bool: + return checked and z_slice_enabled - self.statusBarLabel = QLabel('') - self.statusbar.addWidget(self.statusBarLabel) - - def gui_createTerminalWidget(self): - self.terminal = widgets.QLog(logger=self.logger) - self.terminal.connect() - self.terminalDock = QDockWidget('Log', self.host) + def tool_name_from_tooltip(self, tooltip: str) -> str: + return re.findall(r"Name: (.*)", tooltip)[0] - self.terminalDock.setWidget(self.terminal) - self.terminalDock.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetFloatable | QDockWidget.DockWidgetFeature.DockWidgetMovable - ) - self.terminalDock.setAllowedAreas(Qt.BottomDockWidgetArea) - self.addDockWidget(Qt.BottomDockWidgetArea, self.terminalDock) - # self.terminalDock.widget().layout().setContentsMargins(10,0,10,0) - self.terminalDock.setVisible(False) - - @resetViewRange - def gui_terminalButtonClicked(self, terminalVisible): - self.terminalDock.setVisible(terminalVisible) \ No newline at end of file + LEGACY_METHODS = ( + "zoomBottomLayoutActionTriggered", + "retainSpaceSlidersToggled", + "gui_createMainLayout", + "gui_createRegionPropsDockWidget", + "gui_createControlsToolbar", + "gui_populateToolSettingsMenu", + "useCenterBrushCursorHoverIDtoggled", + "gui_createStatusBar", + "gui_createTerminalWidget", + "gui_terminalButtonClicked", + ) + + def useCenterBrushCursorHoverIDtoggled(self, checked): + if checked: + self.df_settings.at["useCenterBrushCursorHoverID", "value"] = "Yes" + else: + self.df_settings.at["useCenterBrushCursorHoverID", "value"] = "No" + self.df_settings.to_csv(self.settings_csv_path) + + def zoomBottomLayoutActionTriggered(self, checked): + if not checked: + return + perc = int(re.findall(r"(\d+)%", self.sender().text())[0]) + if perc != 100: + fontSizeFactor = perc / 100 + heightFactor = perc / 100 + self.resizeSlidersArea( + fontSizeFactor=fontSizeFactor, heightFactor=heightFactor + ) + else: + self.gui_resetBottomLayoutHeight() + self.df_settings.at["bottom_sliders_zoom_perc", "value"] = perc + self.df_settings.to_csv(self.settings_csv_path) + QTimer.singleShot(150, self.resizeGui) + + def zoom_factors(self, percentage: int) -> tuple[float, float] | None: + if percentage == 100: + return None + factor = percentage / 100 + return factor, factor + + def zoom_percentage_from_text(self, text: str) -> int: + return int(re.findall(r"(\d+)%", text)[0]) diff --git a/cellacdc/mixins/lineage.py b/cellacdc/mixins/lineage.py index deef9fb47..070a519b1 100644 --- a/cellacdc/mixins/lineage.py +++ b/cellacdc/mixins/lineage.py @@ -14,7 +14,7 @@ from cellacdc.myutils import get_obj_by_label, sort_IDs_dist -class LineageViewModel: +class LineageMixin: """Application-facing commands for lineage annotation tables.""" def has_lineage_tree_annotations( @@ -22,7 +22,7 @@ def has_lineage_tree_annotations( acdc_df: pd.DataFrame | None, lineage_tree=None, *, - parent_column: str = 'parent_ID_tree', + parent_column: str = "parent_ID_tree", ) -> bool: return has_lineage_tree_annotations( acdc_df, @@ -30,12 +30,8 @@ def has_lineage_tree_annotations( parent_column=parent_column, ) - def remove_lineage_tree_annotations( - self, - acdc_df: pd.DataFrame | None, - lineage_tree_colnames, - ) -> LineageAnnotationsRemovalResult: - return remove_lineage_tree_annotations(acdc_df, lineage_tree_colnames) + def object_by_label(self, regionprops, label): + return get_obj_by_label(regionprops, label) def remove_future_lineage_tree_annotations( self, @@ -44,7 +40,7 @@ def remove_future_lineage_tree_annotations( from_frame_i: int, *, size_t: int | None = None, - acdc_key: str = 'acdc_df', + acdc_key: str = "acdc_df", ) -> LineageFutureRemovalResult: return remove_future_lineage_tree_annotations( frame_records, @@ -54,8 +50,12 @@ def remove_future_lineage_tree_annotations( acdc_key=acdc_key, ) - def object_by_label(self, regionprops, label): - return get_obj_by_label(regionprops, label) + def remove_lineage_tree_annotations( + self, + acdc_df: pd.DataFrame | None, + lineage_tree_colnames, + ) -> LineageAnnotationsRemovalResult: + return remove_lineage_tree_annotations(acdc_df, lineage_tree_colnames) def sort_ids_by_distance(self, regionprops, *, point=None, label=None): return sort_IDs_dist(regionprops, point=point, ID=label) diff --git a/cellacdc/mixins/lineage_interactions.py b/cellacdc/mixins/lineage_interactions.py index 6825f3464..5bfa8277c 100644 --- a/cellacdc/mixins/lineage_interactions.py +++ b/cellacdc/mixins/lineage_interactions.py @@ -8,7 +8,11 @@ from qtpy.QtCore import Qt from cellacdc import ( - disableWindow, exception_handler, html_utils, lineage_tree_cols, printl, + disableWindow, + exception_handler, + html_utils, + lineage_tree_cols, + printl, widgets, ) from cellacdc.trackers.CellACDC_normal_division.CellACDC_normal_division_tracker import ( @@ -16,164 +20,339 @@ ) -class LineageInteractionsView: +class LineageInteractionsMixin: """Qt-facing adapter around lineage-tree interaction workflows.""" - LEGACY_METHODS = ( - 'initLinTree', - 'propagateLinTreeAction', - 'resetLin_tree_future', - 'autoLinTree_df', - 'initMissingFramesLinTree', - 'viewLinTreeInfoAction', - 'askLineageTreeChanges', - 'repeat_click_and_backup', - 'getDistanceListMissingIDs', - 'find_mother_action', - 'annotate_unknown_lineage_action', - 'get_difference_table', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) + def annotate_unknown_lineage_action(self, posData, event, ydata, xdata): + """ + This function is part of the lin_tree edit functionality. + Associated with the right-click action of the 'unknownLineageButton' button. + Annotates an unknown lineage by setting its parent ID to -1 in the lineage tree (self.lineage_tree.lineage_list) + + Parameters + ---------- + posData : cellacdc.load.loadData + The position data. + event : QtGui.QMouseEvent + The event that triggered the annotation. + ydata : int + The y-coordinate data. + xdata : int + The x-coordinate data. + """ + point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata) - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + if point is None: + return + posData = self.data[self.pos_i] + acdc_df_frame = posData.allData_li[posData.frame_i]["acdc_df"] + acdc_df_frame.at[ID, "parent_ID_tree"] = -1 + self.drawAllLineageTreeLines() - @exception_handler - def initLinTree(self, force=False): + @disableWindow + def askLineageTreeChanges(self): """ + Asks the user for changes in the lineage tree. - """Headless decisions for lineage-tree interaction workflows.""" + This method is called when the user selects the 'Normal division: Lineage tree' mode. + It compared the backed up df (self.original_df from repeat_click_and_backup) with the current df (self.lineage_tree.export_df(posData.frame_i)) and propts the user to keep, propagate or discard the changes. - lineage_mode = 'Normal division: Lineage tree' - viewer_mode = 'Viewer' + """ + mode = str(self.modeComboBox.currentText()) + if mode != "Normal division: Lineage tree": + return - def is_lineage_mode(self, mode: str) -> bool: - return mode == self.lineage_mode + if not self.lineage_tree: + return - def should_initialize( - self, - *, - force: bool, - mode: str, - lineage_tree_exists: bool, - ) -> bool: - if not force and lineage_tree_exists: - return False - return force or self.is_lineage_mode(mode) + posData = self.data[self.pos_i] + + if ( + self.original_df_lin_tree_i is not None + and self.original_df_lin_tree_i != posData.frame_i + ): + printl("!This should not happen!") + self.store_data(autosave=False) + og_frame = posData.frame_i + posData.frame_i = self.original_df_lin_tree_i + self.get_data() + self.logger.info( + "Lineage tree changes were not propagated, going back to original frame." + ) + self.askLineageTreeChanges() + self.store_data(autosave=False) + posData.frame_i = og_frame + self.get_data() + return + + result = self.get_difference_table( + return_css_separated=True, return_differece=True + ) + if result is None: + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + return + + css, txt, differences = result + changed_IDs = differences["Cell_ID"].unique() + + if posData.frame_i == max(self.lineage_tree.frames_for_dfs): + # here we can just propagate the cahnged. This is super fast, since there is no recursion, no children and fast finding of parents + self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + return + + txt = txt + "Do you want to keep, propgagte or discard the changes?" + txt = css + html_utils.paragraph("Changes made in this frame
" + txt) + + msg = widgets.myMessageBox() + + propagate_btn, discard_btn, _ = msg.question( + self, + "Changes in lineage tree", + txt, + buttonsTexts=("Propagate", "Discard", "Cancel"), + ) + + if msg.clickedButton == propagate_btn: + self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + self.logger.info("Lineage tree propagated.") + + elif msg.clickedButton == discard_btn: + posData.allData_li[posData.frame_i]["acdc_df"] = ( + self.original_df_lin_tree.copy() + ) + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + self.logger.info("Lineage tree changes discarded.") + + elif msg.cancel: + # Go back to current frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + Changes were kept but not propagated! + Please make sure to come back and propagate them, + otherwise your table might be inconsistent! + There is a button for this next to the edit buttons. + Please also do not visit new frames! + + """) + msg.warning(self, "Changes kept but not propagated!", txt) + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + self.logger.info("Lineage tree changes discarded.") + + def autoLinTree_df(self, enforceAll=False): + """Automatically generates a lineage tree dataframe. + + This method generates a lineage tree dataframe based on the current mode and data. + It checks if the mode is set to 'Normal division: Lineage tree' and if the current frame + is not already processed. If the conditions are met, it retrieves the necessary data + from the current position data and previous position data, and passes it to the + `real_time` method of the `lineage_tree` object. Finally, it converts the lineage tree + to an ACDC dataframe and adds the current frame to the set of frames that have been + processed. + + Parameters + ---------- + enforceAll : bool, optional + If True, enforces processing of all frames, even if they have been processed before. + If False, only processes frames that have not been processed before. Default is False. + + Returns + ------- + bool + True if there are not enough G1 cells for lineage tree generation, False otherwise. + bool + True if the lineage tree generation should proceed, False otherwise. + """ + proceed = True + notEnoughG1Cells = False + mode = str(self.modeComboBox.currentText()) + + # Skip if not the right mode + if mode != "Normal division: Lineage tree": + return notEnoughG1Cells, proceed + + posData = self.data[self.pos_i] + frame_i = posData.frame_i + + if frame_i in self.lineage_tree.frames_for_dfs: + return notEnoughG1Cells, proceed + + # Make sure that this is a visited frame in segmentation tracking mode + if posData.allData_li[frame_i]["labels"] is None: # may need to change this + proceed = self.warnFrameNeverVisitedSegmMode() + return notEnoughG1Cells, proceed + + self.store_data(autosave=False) + self.get_data() + lab = posData.lab + prev_lab = posData.allData_li[frame_i - 1]["labels"] + rp = posData.rp + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + + self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) + self.store_data() def default_mode_after_failed_init(self) -> str: return self.viewer_mode - def last_annotated_frame_index( - self, - frame_records: Iterable[dict], - *, - acdc_key: str = 'acdc_df', - generation_column: str = 'generation_num_tree', - ) -> int: - last_frame_i = 0 - for frame_i, record in enumerate(frame_records): - acdc_df = record[acdc_key] - if ( - acdc_df is None - or generation_column not in acdc_df.columns - or acdc_df[generation_column].isin([np.nan, 0]).all() - ): - break - last_frame_i = frame_i - return last_frame_i + def find_mother_action(self, posData, event, ydata, xdata): + """ + This function is part of the lin_tree edit functionality. + Associated with the right-click action of the 'findNextMotherButton' button. + Handles the right click action, which cycles through possible mothers of the clicked cell. + Changes the parent ID of the clicked cell to the next possible mother in self.lineage_tree.lineage_list. - def missing_frame_indices( - self, - current_frame_i: int, - present_frames: Iterable[int] | None, - ) -> list[int]: - present = set(present_frames or []) - missing = [ - frame_i for frame_i in range(current_frame_i + 1) - if frame_i not in present - ] - missing.sort() - return missing + Parameters + ---------- + posData : cellacdc.load.loadData + The position data object. + event : QtGui.QMouseEvent + The event object. + ydata : int + The y-coordinate data. + xdata : int + The x-coordinate data. + """ + point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata) - def should_process_auto_frame( - self, - *, - mode: str, - frame_i: int, - processed_frames: Iterable[int], - ) -> bool: - if not self.is_lineage_mode(mode): - return False - return frame_i not in processed_frames + if point is None: + return + posData = self.data[self.pos_i] + acdc_df_frame = posData.allData_li[posData.frame_i]["acdc_df"] + filtered_IDs = self.getDistanceListMissingIDs(point, ID) + if len(filtered_IDs) == 0: + self.logger.info("No mother candidates found.") + return - def should_backup_original( - self, - original_frame_i: int | None, - current_frame_i: int, - ) -> bool: - return original_frame_i is None or original_frame_i != current_frame_i + i = self.right_click_i % len(filtered_IDs) + i = abs(i) # Ensure i is non-negative + new_mother = filtered_IDs[i] - def next_candidate_index( - self, - click_index: int, - candidates_count: int, - ) -> int: - if candidates_count <= 0: - return 0 - return abs(click_index % candidates_count) + if ( + acdc_df_frame.loc[ID]["parent_ID_tree"] == new_mother + and not self.original_mother_skipped + ): # if a mother is already present, skip it + self.right_click_i += 1 + self.original_mother_skipped = True - def should_skip_original_mother( - self, - current_parent_id, - candidate_parent_id, - *, - original_mother_skipped: bool, - ) -> bool: - return ( - current_parent_id == candidate_parent_id - and not original_mother_skipped + i = self.right_click_i % len(filtered_IDs) + i = abs(i) # Ensure i is non-negative + new_mother = filtered_IDs[i] + + acdc_df_frame.at[ID, "parent_ID_tree"] = ( + new_mother # update mother in the df, no need to propagate or stuff lile this ) + # dont need to update alldata_li as acdc_df_frame is just a view + self.drawAllLineageTreeLines() + + def getDistanceListMissingIDs(self, point, ID): + posData = self.data[self.pos_i] + frame_i = posData.frame_i + if self.getDistanceListMissingIDsCachedFrame != frame_i: + self.distanceListMissingIDs = dict() + self.getDistanceListMissingIDsCachedFrame = frame_i + # self.store_data(autosave=False) + # self.get_data() + + if ID not in self.distanceListMissingIDs.keys(): + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + relevant_rp = [obj for obj in prev_rp if obj.label not in posData.IDs] + len_relevant_rp = len(relevant_rp) + if len_relevant_rp == 0: + self.logger.info("No missing IDs found in previous frame.") + return [] + elif len_relevant_rp == 1: + self.distanceListMissingIDs[ID] = [relevant_rp[0].label] + return [relevant_rp[0].label] + else: + sorted_missing_IDs = myutils.sort_IDs_dist(relevant_rp, point=point) + self.distanceListMissingIDs[ID] = sorted_missing_IDs + return sorted_missing_IDs + else: + return self.distanceListMissingIDs[ID] + + @disableWindow + def get_difference_table(self, return_css_separated=False, return_differece=False): + + if self.original_df_lin_tree is None: + return + + posData = self.data[self.pos_i] + + new_df = posData.allData_li[posData.frame_i]["acdc_df"] + original_df = self.original_df_lin_tree.copy() - def parent_id_differences( - self, - original_df: pd.DataFrame, - new_df: pd.DataFrame, - reset_index_cell_id: Callable[[pd.DataFrame], pd.DataFrame], - *, - compare_columns: Sequence[str] = ('parent_ID_tree',), - ) -> pd.DataFrame | None: if original_df.equals(new_df): - return None + return + + compare_columns = ["parent_ID_tree"] new_df = new_df[original_df.columns] - new_df = reset_index_cell_id(new_df) - new_df = new_df[list(compare_columns)] + new_df = myutils.checked_reset_index_Cell_ID(new_df) + new_df = new_df[compare_columns] new_df = new_df.sort_index() - - original_df = reset_index_cell_id(original_df) - original_df = original_df[list(compare_columns)] + original_df = myutils.checked_reset_index_Cell_ID(original_df) + original_df = original_df[compare_columns] original_df = original_df.sort_index() differences = original_df.compare(new_df) if differences.empty: - return None + return - differences = reset_index_cell_id(differences) - differences = differences[compare_columns[0]] - return differences.reset_index() + differences = myutils.checked_reset_index_Cell_ID(differences) + + differences = differences["parent_ID_tree"] + differences = differences.reset_index() + + txt = """ + + + + + """ + + for diff in differences.itertuples(): + ID = str(int(diff.Cell_ID)) + old_parent = str(int(diff.self)) + new_parent = str(int(diff.other)) + + txt += f""" + + + + """ + txt += "
IDold parent -->new parent
{ID}{old_parent}{new_parent}
" + + css = r""" + + """ + if return_css_separated and not return_differece: + return css, txt + elif return_css_separated and return_differece: + return css, txt, differences + elif not return_css_separated and return_differece: + return txt, differences + else: + txt = css + html_utils.paragraph(txt) + return txt + @exception_handler + def initLinTree(self, force=False): + """ Initializes the lineage tree analysis. This method checks if the tracking has been previously checked and saved. If not, it displays a message to the user. @@ -186,38 +365,45 @@ def parent_id_differences( True if the initialization is successful, nothing otherwise. """ + if not force and self.lineage_tree is not None: + return + mode = str(self.modeComboBox.currentText()) - if not self.should_initialize( - force=force, - mode=mode, - lineage_tree_exists=self.lineage_tree is not None, - ): + if mode != "Normal division: Lineage tree" and not force: return posData = self.data[self.pos_i] last_tracked_i = self.get_last_tracked_i() - defaultMode = self.default_mode_after_failed_init() + defaultMode = "Viewer" if last_tracked_i == 0: # Display message to the user txt = html_utils.paragraph( - 'On this dataset either you never checked that the segmentation ' - 'and tracking are correct or you did not save yet.

' + "On this dataset either you never checked that the segmentation " + "and tracking are correct or you did not save yet.

" 'If you already visited some frames with "Segmentation and Tracking" ' 'mode save data before switching to "Normal division: Lineage Tree".

' - 'Otherwise you first have to check (and eventually correct) some frames ' + "Otherwise you first have to check (and eventually correct) some frames " 'in "Segmentation and Tracking" mode before proceeding ' - 'with lineage tree analysis.') - msg = widgets.myMessageBox() - msg.critical( - self.host, 'Tracking was never checked', txt + "with lineage tree analysis." ) + msg = widgets.myMessageBox() + msg.critical(self, "Tracking was never checked", txt) self.modeComboBox.setCurrentText(defaultMode) return proceed = True - last_lin_tree_frame_i = self.last_annotated_frame_index( - posData.allData_li - ) + last_lin_tree_frame_i = 0 + # Determine last annotated frame index + for i, dict_frame_i in enumerate(posData.allData_li): + df = dict_frame_i["acdc_df"] + if ( + df is None + or "generation_num_tree" not in df.columns + or df["generation_num_tree"].isin([np.nan, 0]).all() + ): + break + else: + last_lin_tree_frame_i = i if last_lin_tree_frame_i == 0: # Remove undoable actions from segmentation mode @@ -229,31 +415,35 @@ def parent_id_differences( # Prompt user to go to last annotated frame msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_lin_tree_frame_i+1}.

- Do you want to restart lineage tree analysis from frame - {last_lin_tree_frame_i+1}?
+ The last annotated frame is frame {last_lin_tree_frame_i + 1}.

+ Do you want to restart lineage tree analysis from frame + {last_lin_tree_frame_i + 1}?
""") _, yesButton, stayButton = msg.warning( - self.host, 'Go to last annotated frame?', txt, + self, + "Go to last annotated frame?", + txt, buttonsTexts=( - 'Cancel', f'Yes, go to frame {last_lin_tree_frame_i+1}', - 'No, stay on current frame') + "Cancel", + f"Yes, go to frame {last_lin_tree_frame_i + 1}", + "No, stay on current frame", + ), ) if yesButton == msg.clickedButton: - msg = 'Looking good!' + msg = "Looking good!" self.last_lin_tree_frame_i = last_lin_tree_frame_i posData.frame_i = last_lin_tree_frame_i self.titleLabel.setText(msg, color=self.titleColor) self.get_data(lin_tree_init=False) - self.updateAllImages() # i dont think I need to change this - self.updateScrollbars() # i dont think I need to change this + self.updateAllImages() # i dont think I need to change this + self.updateScrollbars() # i dont think I need to change this elif stayButton == msg.clickedButton: - self.initMissingFramesLinTree(posData.frame_i) #!!! + self.initMissingFramesLinTree(posData.frame_i) #!!! last_lin_tree_frame_i = posData.frame_i - msg = 'Lineage tree analysis initialised!' - self.titleLabel.setText(msg, color='g') + msg = "Lineage tree analysis initialised!" + self.titleLabel.setText(msg, color="g") elif msg.cancel: - msg = 'Lineage tree analysis aborted.' + msg = "Lineage tree analysis aborted." self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) self.modeComboBox.setCurrentText(defaultMode) @@ -264,24 +454,26 @@ def parent_id_differences( # Prompt user to go to last annotated frame msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_lin_tree_frame_i+1}.

+ The last annotated frame is frame {last_lin_tree_frame_i + 1}.

Do you want to restart lineage tree analysis from frame - {last_lin_tree_frame_i+1}?
+ {last_lin_tree_frame_i + 1}?
""") goTo_last_annotated_frame_i = msg.question( - self.host, 'Go to last annotated frame?', txt, - buttonsTexts=('Yes', 'No', 'Cancel') + self, + "Go to last annotated frame?", + txt, + buttonsTexts=("Yes", "No", "Cancel"), )[0] if goTo_last_annotated_frame_i == msg.clickedButton: - msg = 'Looking good!' + msg = "Looking good!" self.titleLabel.setText(msg, color=self.titleColor) self.last_lin_tree_frame_i = last_lin_tree_frame_i posData.frame_i = last_lin_tree_frame_i self.get_data(lin_tree_init=False) - self.updateAllImages() # i dont think I need to change this - self.updateScrollbars() # i dont think I need to change this + self.updateAllImages() # i dont think I need to change this + self.updateScrollbars() # i dont think I need to change this elif msg.cancel: - msg = 'Lineage tree analysis aborted.' + msg = "Lineage tree analysis aborted." self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) self.modeComboBox.setCurrentText(defaultMode) @@ -292,104 +484,23 @@ def parent_id_differences( self.last_lin_tree_frame_i = last_lin_tree_frame_i - self.navigateScrollBar.setMaximum(last_lin_tree_frame_i+1) - self.navSpinBox.setMaximum(last_lin_tree_frame_i+1) + self.navigateScrollBar.setMaximum(last_lin_tree_frame_i + 1) + self.navSpinBox.setMaximum(last_lin_tree_frame_i + 1) if self.lineage_tree is None or force: self.store_data(autosave=False) self.get_data(lin_tree_init=False) - self.lineage_tree = normal_division_lineage_tree(gui=self.host) + self.lineage_tree = normal_division_lineage_tree(gui=self) - msg = 'Lineage tree analysis initialized!' + msg = "Lineage tree analysis initialized!" self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) return proceed - @disableWindow - def propagateLinTreeAction(self, dummy_for_button=None): - """ - Propagates the lineage tree based on the current frame_i. Used in self.propagateLinTreeButton. - """ - posData = self.data[self.pos_i] - self.lineage_tree.propagate(posData.frame_i) - if posData.frame_i == self.original_df_lin_tree_i: - self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() - - self.logger.info('Lineage tree propagated.') - - def resetLin_tree_future(self): - posData = self.data[self.pos_i] - frame_i = posData.frame_i - result = ( - self.host.view_model.lineage.remove_future_lineage_tree_annotations( - posData.allData_li, - lineage_tree_cols, - frame_i, - size_t=posData.SizeT, - ) - ) - - if self.lineage_tree is not None: - self.lineage_tree.frames_for_dfs.discard(frame_i) - for i, acdc_df in result.acdc_dfs_by_frame.items(): - posData.allData_li[i]['acdc_df'] = acdc_df - - def autoLinTree_df(self, enforceAll=False): - """Automatically generates a lineage tree dataframe. - - This method generates a lineage tree dataframe based on the current mode and data. - It checks if the mode is set to 'Normal division: Lineage tree' and if the current frame - is not already processed. If the conditions are met, it retrieves the necessary data - from the current position data and previous position data, and passes it to the - `real_time` method of the `lineage_tree` object. Finally, it converts the lineage tree - to an ACDC dataframe and adds the current frame to the set of frames that have been - processed. - - Parameters - ---------- - enforceAll : bool, optional - If True, enforces processing of all frames, even if they have been processed before. - If False, only processes frames that have not been processed before. Default is False. - - Returns - ------- - bool - True if there are not enough G1 cells for lineage tree generation, False otherwise. - bool - True if the lineage tree generation should proceed, False otherwise. - """ - proceed = True - notEnoughG1Cells = False - mode = str(self.modeComboBox.currentText()) - - # Skip if not the right mode - if not self.should_process_auto_frame( - mode=mode, - frame_i=self.data[self.pos_i].frame_i, - processed_frames=self.lineage_tree.frames_for_dfs, - ): - return notEnoughG1Cells, proceed - - posData = self.data[self.pos_i] - frame_i = posData.frame_i - - # Make sure that this is a visited frame in segmentation tracking mode - if posData.allData_li[frame_i]['labels'] is None: # may need to change this - proceed = self.warnFrameNeverVisitedSegmMode() - return notEnoughG1Cells, proceed - - self.store_data(autosave=False) - self.get_data() - lab = posData.lab - prev_lab = posData.allData_li[frame_i-1]['labels'] - rp = posData.rp - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - - self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) - self.store_data() - - def initMissingFramesLinTree(self, current_frame_i): # done Need to add partially missing previous frames and loading + def initMissingFramesLinTree( + self, current_frame_i + ): # done Need to add partially missing previous frames and loading """ When not starting from the first frame, automatically creates lineage tree dfs for all "skipped" frames and initializes the tree if not done so before. @@ -408,7 +519,7 @@ def initMissingFramesLinTree(self, current_frame_i): # done Need to add partiall """ self.logger.info( - 'Initialising lineage tree annotations of missing past frames...' + "Initialising lineage tree annotations of missing past frames..." ) self.store_data(autosave=False) @@ -417,193 +528,116 @@ def initMissingFramesLinTree(self, current_frame_i): # done Need to add partiall posData = self.data[self.pos_i] current_frame_i = posData.frame_i - if not self.lineage_tree: # init lin tree if not done already + if not self.lineage_tree: # init lin tree if not done already self.lineage_tree = normal_division_lineage_tree( - gui=self.host - ) # here frame_i!=0 - - present_frames = list(self.lineage_tree.frames_for_dfs) if self.lineage_tree else [] - present_frames = [] if not present_frames else present_frames # deal with None - missing_frames = self.missing_frame_indices( - current_frame_i, - present_frames, + gui=self + ) # here frame_i!=0 + + missing_frames = list(range(current_frame_i + 1)) + present_frames = ( + list(self.lineage_tree.frames_for_dfs) if self.lineage_tree else [] ) + present_frames = [] if not present_frames else present_frames # deal with None + missing_frames = [ + frame_i for frame_i in missing_frames if frame_i not in present_frames + ] + missing_frames.sort() for frame_i in missing_frames: - lab = posData.allData_li[frame_i]['labels'] - prev_lab = posData.allData_li[frame_i-1]['labels'] - rp = posData.allData_li[frame_i]['regionprops'] - prev_rp = posData.allData_li[frame_i-1]['regionprops'] + lab = posData.allData_li[frame_i]["labels"] + prev_lab = posData.allData_li[frame_i - 1]["labels"] + rp = posData.allData_li[frame_i]["regionprops"] + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] # i might need to change this if I need support for only partially missing frames... Although I probably never have to care about that though self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) posData.frame_i = current_frame_i self.store_data() - def viewLinTreeInfoAction(self): - mode = str(self.modeComboBox.currentText()) - if not self.is_lineage_mode(mode): - self.logger.info('This action is only available in the "Normal division: Lineage tree" mode.') - return - - if not self.lineage_tree: - self.logger.info('No lineage tree found.') - return - - posData = self.data[self.pos_i] - - if self.original_df_lin_tree_i != posData.frame_i: - # could be that this is not entirley true and self.curr_original_df_i just didnt get set right though! - txt_changes = '
No changes were made in this frame.

' - - else: - result = self.get_difference_table(return_css_separated=True) - - if result is None: - txt_changes = 'No changes were made in this frame.' - else: - css, txt_changes = result - - txt_changes = 'Changes made in this frame:' + txt_changes + '

' - - cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i) - - if orphan_cells == []: - txt_orphan_cells = 'No orphan Cells!' - else: - txt_orphan_cells = ', '.join([str(cell) for cell in orphan_cells]) - txt_orphan = f'Orphan cells:
{txt_orphan_cells}

' - - lost_cells = list(lost_cells) - if lost_cells == []: - txt_lost_cells = 'No lost Cells!' - else: - txt_lost_cells = ', '.join([str(cell) for cell in lost_cells]) - txt_lost = f'Lost cells:
{txt_lost_cells}

' - - if cells_with_parent == []: - table_cells_with_parent = '
No cells with parents!' - else: - table_cells_with_parent = """ - - - - """ - - for cell, parent in cells_with_parent: - table_cells_with_parent += f''' - - - ''' - table_cells_with_parent += '
Parent IDID
{parent}{cell}
' - - txt_cells_with_parents = f'Cells with parents:{table_cells_with_parent}

' - - css = r''' - - ''' - - txt = css + html_utils.paragraph(txt_changes + txt_orphan + txt_lost + txt_cells_with_parents) - - msg = widgets.myMessageBox() - msg.information(self.host, - 'lineage tree information', - txt - ) - - @disableWindow - def askLineageTreeChanges(self): - """ - Asks the user for changes in the lineage tree. - - This method is called when the user selects the 'Normal division: Lineage tree' mode. - It compared the backed up df (self.original_df from repeat_click_and_backup) with the current df (self.lineage_tree.export_df(posData.frame_i)) and propts the user to keep, propagate or discard the changes. - - """ - mode = str(self.modeComboBox.currentText()) - if not self.is_lineage_mode(mode): - return - - if not self.lineage_tree: - return - - posData = self.data[self.pos_i] - - if self.original_df_lin_tree_i is not None and self.original_df_lin_tree_i != posData.frame_i: - printl("!This should not happen!") - self.store_data(autosave=False) - og_frame = posData.frame_i - posData.frame_i = self.original_df_lin_tree_i - self.get_data() - self.logger.info('Lineage tree changes were not propagated, going back to original frame.') - self.askLineageTreeChanges() - self.store_data(autosave=False) - posData.frame_i = og_frame - self.get_data() - return - - result = self.get_difference_table(return_css_separated=True, return_differece=True) - if result is None: - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - return + def is_lineage_mode(self, mode: str) -> bool: + return mode == self.lineage_mode - css, txt, differences = result - changed_IDs = differences['Cell_ID'].unique() + def last_annotated_frame_index( + self, + frame_records: Iterable[dict], + *, + acdc_key: str = "acdc_df", + generation_column: str = "generation_num_tree", + ) -> int: + last_frame_i = 0 + for frame_i, record in enumerate(frame_records): + acdc_df = record[acdc_key] + if ( + acdc_df is None + or generation_column not in acdc_df.columns + or acdc_df[generation_column].isin([np.nan, 0]).all() + ): + break + last_frame_i = frame_i + return last_frame_i - if posData.frame_i == max(self.lineage_tree.frames_for_dfs): - # here we can just propagate the cahnged. This is super fast, since there is no recursion, no children and fast finding of parents - self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - return + def missing_frame_indices( + self, + current_frame_i: int, + present_frames: Iterable[int] | None, + ) -> list[int]: + present = set(present_frames or []) + missing = [ + frame_i for frame_i in range(current_frame_i + 1) if frame_i not in present + ] + missing.sort() + return missing - txt = txt + 'Do you want to keep, propgagte or discard the changes?' - txt = css + html_utils.paragraph('Changes made in this frame
' + txt) + def next_candidate_index( + self, + click_index: int, + candidates_count: int, + ) -> int: + if candidates_count <= 0: + return 0 + return abs(click_index % candidates_count) - msg = widgets.myMessageBox() + def parent_id_differences( + self, + original_df: pd.DataFrame, + new_df: pd.DataFrame, + reset_index_cell_id: Callable[[pd.DataFrame], pd.DataFrame], + *, + compare_columns: Sequence[str] = ("parent_ID_tree",), + ) -> pd.DataFrame | None: + if original_df.equals(new_df): + return None - propagate_btn, discard_btn, _ = msg.question(self.host, - 'Changes in lineage tree', - txt, - buttonsTexts=('Propagate', 'Discard', 'Cancel'),) + new_df = new_df[original_df.columns] + new_df = reset_index_cell_id(new_df) + new_df = new_df[list(compare_columns)] + new_df = new_df.sort_index() - if msg.clickedButton == propagate_btn: - self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - self.logger.info('Lineage tree propagated.') + original_df = reset_index_cell_id(original_df) + original_df = original_df[list(compare_columns)] + original_df = original_df.sort_index() - elif msg.clickedButton == discard_btn: - posData.allData_li[posData.frame_i]['acdc_df'] = self.original_df_lin_tree.copy() - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - self.logger.info('Lineage tree changes discarded.') + differences = original_df.compare(new_df) + if differences.empty: + return None + differences = reset_index_cell_id(differences) + differences = differences[compare_columns[0]] + return differences.reset_index() - elif msg.cancel: - # Go back to current frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(''' - Changes were kept but not propagated! - Please make sure to come back and propagate them, - otherwise your table might be inconsistent! - There is a button for this next to the edit buttons. - Please also do not visit new frames! + @disableWindow + def propagateLinTreeAction(self, dummy_for_button=None): + """ + Propagates the lineage tree based on the current frame_i. Used in self.propagateLinTreeButton. + """ + posData = self.data[self.pos_i] + self.lineage_tree.propagate(posData.frame_i) + if posData.frame_i == self.original_df_lin_tree_i: + self.original_df_lin_tree = posData.allData_li[posData.frame_i][ + "acdc_df" + ].copy() - ''') - msg.warning(self.host, 'Changes kept but not propagated!', txt) - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - self.logger.info('Lineage tree changes discarded.') + self.logger.info("Lineage tree propagated.") def repeat_click_and_backup(self, posData, event, ydata, xdata): """ @@ -627,20 +661,18 @@ def repeat_click_and_backup(self, posData, event, ydata, xdata): tuple A tuple containing the point(tuple: (x, y) coords) and ID of clicked cell. """ - should_log_reset = ( - self.original_df_lin_tree is not None - and self.original_df_lin_tree_i != posData.frame_i - ) - if self.should_backup_original( - self.original_df_lin_tree_i, - posData.frame_i, - ): - if should_log_reset: - self.logger.info( - '[WARNING]: !!! Original lineage tree df changed, ' - 'resetting original_df_lin_tree !!!' - ) - self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() + if self.original_df_lin_tree is None: + self.original_df_lin_tree = posData.allData_li[posData.frame_i][ + "acdc_df" + ].copy() + self.original_df_lin_tree_i = posData.frame_i + elif self.original_df_lin_tree_i != posData.frame_i: + self.logger.info( + "[WARNING]: !!! Original lineage tree df changed, resetting original_df_lin_tree !!!" + ) + self.original_df_lin_tree = posData.allData_li[posData.frame_i][ + "acdc_df" + ].copy() self.original_df_lin_tree_i = posData.frame_i if not self.right_click_ID: @@ -665,173 +697,138 @@ def repeat_click_and_backup(self, posData, event, ydata, xdata): return point, ID - def getDistanceListMissingIDs(self, point, ID): + def resetLin_tree_future(self): posData = self.data[self.pos_i] frame_i = posData.frame_i - if self.getDistanceListMissingIDsCachedFrame != frame_i: - self.distanceListMissingIDs = dict() - self.getDistanceListMissingIDsCachedFrame = frame_i - # self.store_data(autosave=False) - # self.get_data() - - if ID not in self.distanceListMissingIDs.keys(): - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - relevant_rp = [ - obj for obj in prev_rp if obj.label not in posData.IDs - ] - len_relevant_rp = len(relevant_rp) - if len_relevant_rp == 0: - self.logger.info('No missing IDs found in previous frame.') - return [] - elif len_relevant_rp == 1: - self.distanceListMissingIDs[ID] = [relevant_rp[0].label] - return [relevant_rp[0].label] - else: - sorted_missing_IDs = self.host.view_model.lineage.sort_ids_by_distance( - relevant_rp, point=point - ) - self.distanceListMissingIDs[ID] = sorted_missing_IDs - return sorted_missing_IDs - else: - return self.distanceListMissingIDs[ID] - def find_mother_action(self, posData, event, ydata, xdata): - """ - This function is part of the lin_tree edit functionality. - Associated with the right-click action of the 'findNextMotherButton' button. - Handles the right click action, which cycles through possible mothers of the clicked cell. - Changes the parent ID of the clicked cell to the next possible mother in self.lineage_tree.lineage_list. + for i in range(frame_i, posData.SizeT): + if self.lineage_tree is not None: + self.lineage_tree.frames_for_dfs.discard(frame_i) + df = posData.allData_li[i]["acdc_df"] + # reste lineage tree columns + if df is None: + continue + df = df.drop(columns=lineage_tree_cols, errors="ignore") + posData.allData_li[i]["acdc_df"] = df - Parameters - ---------- - posData : cellacdc.load.loadData - The position data object. - event : QtGui.QMouseEvent - The event object. - ydata : int - The y-coordinate data. - xdata : int - The x-coordinate data. - """ - point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata) + def should_backup_original( + self, + original_frame_i: int | None, + current_frame_i: int, + ) -> bool: + return original_frame_i is None or original_frame_i != current_frame_i - if point is None: - return - posData = self.data[self.pos_i] - acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] - filtered_IDs = self.getDistanceListMissingIDs(point, ID) - if len(filtered_IDs) == 0: - self.logger.info('No mother candidates found.') - return + def should_initialize( + self, + *, + force: bool, + mode: str, + lineage_tree_exists: bool, + ) -> bool: + if not force and lineage_tree_exists: + return False + return force or self.is_lineage_mode(mode) - i = self.next_candidate_index( - self.right_click_i, - len(filtered_IDs), - ) - new_mother = filtered_IDs[i] + def should_process_auto_frame( + self, + *, + mode: str, + frame_i: int, + processed_frames: Iterable[int], + ) -> bool: + if not self.is_lineage_mode(mode): + return False + return frame_i not in processed_frames - if self.should_skip_original_mother( - acdc_df_frame.loc[ID]['parent_ID_tree'], - new_mother, - original_mother_skipped=self.original_mother_skipped, - ): # if a mother is already present, skip it - self.right_click_i += 1 - self.original_mother_skipped = True + def should_skip_original_mother( + self, + current_parent_id, + candidate_parent_id, + *, + original_mother_skipped: bool, + ) -> bool: + return current_parent_id == candidate_parent_id and not original_mother_skipped - i = self.next_candidate_index( - self.right_click_i, - len(filtered_IDs), + def viewLinTreeInfoAction(self): + mode = str(self.modeComboBox.currentText()) + if mode != "Normal division: Lineage tree": + self.logger.info( + 'This action is only available in the "Normal division: Lineage tree" mode.' ) - new_mother = filtered_IDs[i] + return - acdc_df_frame.at[ID, 'parent_ID_tree'] = new_mother # update mother in the df, no need to propagate or stuff lile this - # dont need to update alldata_li as acdc_df_frame is just a view - self.drawAllLineageTreeLines() + if not self.lineage_tree: + self.logger.info("No lineage tree found.") + return - def annotate_unknown_lineage_action(self, posData, event, ydata, xdata): - """ - This function is part of the lin_tree edit functionality. - Associated with the right-click action of the 'unknownLineageButton' button. - Annotates an unknown lineage by setting its parent ID to -1 in the lineage tree (self.lineage_tree.lineage_list) + posData = self.data[self.pos_i] - Parameters - ---------- - posData : cellacdc.load.loadData - The position data. - event : QtGui.QMouseEvent - The event that triggered the annotation. - ydata : int - The y-coordinate data. - xdata : int - The x-coordinate data. - """ - point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata) + if self.original_df_lin_tree_i != posData.frame_i: + # could be that this is not entirley true and self.curr_original_df_i just didnt get set right though! + txt_changes = "
No changes were made in this frame.

" - if point is None: - return - posData = self.data[self.pos_i] - acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] - acdc_df_frame.at[ID, 'parent_ID_tree'] = -1 - self.drawAllLineageTreeLines() + else: + result = self.get_difference_table(return_css_separated=True) - @disableWindow - def get_difference_table(self, return_css_separated=False, return_differece=False): + if result is None: + txt_changes = "No changes were made in this frame." + else: + css, txt_changes = result - if self.original_df_lin_tree is None: - return + txt_changes = "Changes made in this frame:" + txt_changes + "

" - posData = self.data[self.pos_i] + cells_with_parent, orphan_cells, lost_cells = ( + self.lineage_tree.export_lin_tree_info(posData.frame_i) + ) - new_df = posData.allData_li[posData.frame_i]['acdc_df'] - original_df = self.original_df_lin_tree.copy() + if orphan_cells == []: + txt_orphan_cells = "No orphan Cells!" + else: + txt_orphan_cells = ", ".join([str(cell) for cell in orphan_cells]) + txt_orphan = f"Orphan cells:
{txt_orphan_cells}

" - if original_df.equals(new_df): - return + lost_cells = list(lost_cells) + if lost_cells == []: + txt_lost_cells = "No lost Cells!" + else: + txt_lost_cells = ", ".join([str(cell) for cell in lost_cells]) + txt_lost = f"Lost cells:
{txt_lost_cells}

" - differences = self.parent_id_differences( - original_df, - new_df, - self.host.view_model.tables.checked_reset_index_cell_id, - ) - if differences is None: - return + if cells_with_parent == []: + table_cells_with_parent = "
No cells with parents!" + else: + table_cells_with_parent = """ + + + + """ - txt = """
Parent IDID
- - - - - """ + for cell, parent in cells_with_parent: + table_cells_with_parent += f""" + + + """ + table_cells_with_parent += "
IDold parent -->new parent
{parent}{cell}
" - for diff in differences.itertuples(): - ID = str(int(diff.Cell_ID)) - old_parent = str(int(diff.self)) - new_parent = str(int(diff.other)) + txt_cells_with_parents = ( + f"Cells with parents:{table_cells_with_parent}

" + ) - txt += f''' - {ID} - {old_parent} - {new_parent} - ''' - txt += '' + css = r""" + + """ - css = r''' - - ''' - if return_css_separated and not return_differece: - return css, txt - elif return_css_separated and return_differece: - return css, txt, differences - elif not return_css_separated and return_differece: - return txt, differences - else: - txt = css + html_utils.paragraph(txt) - return txt \ No newline at end of file + txt = css + html_utils.paragraph( + txt_changes + txt_orphan + txt_lost + txt_cells_with_parents + ) + + msg = widgets.myMessageBox() + msg.information(self, "lineage tree information", txt) diff --git a/cellacdc/mixins/magic_prompts.py b/cellacdc/mixins/magic_prompts.py index 070fe8493..39a9b44c8 100644 --- a/cellacdc/mixins/magic_prompts.py +++ b/cellacdc/mixins/magic_prompts.py @@ -4,7 +4,6 @@ from functools import partial -from dataclasses import dataclass from typing import Mapping from qtpy.QtCore import QEventLoop, QThread @@ -21,113 +20,18 @@ from cellacdc import disableWindow -class MagicPromptsView: +class MagicPromptsMixin: """Qt-facing adapter around promptable segmentation dialogs and workers.""" - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def showInstructionsCustomPromptModel(self): - modelFilePath = apps.addCustomPromptModelMessages(QParent=self.host) - if modelFilePath is None: - self.logger.info('Adding custom promptable model process stopped.') - return - - self.store_custom_promptable_model_path( - modelFilePath - ) - - msg = widgets.myMessageBox(wrapText=False) - info_txt = html_utils.paragraph(f""" - """Headless promptable-segmentation geometry and point rules.""" - def zoom_region(self, view_range, image_shape) -> MagicPromptZoom: - (xmin, xmax), (ymin, ymax) = view_range - height, width = image_shape[-2:] - - xmin = int(max(0, xmin)) - xmax = int(min(width, xmax)) - ymin = int(max(0, ymin)) - ymax = int(min(height, ymax)) - - return MagicPromptZoom( - bounds=(xmin, xmax, ymin, ymax), - image_origin=(0, ymin, xmin), - zoom_slice=(slice(ymin, ymax), slice(xmin, xmax)), - ) - - def points_in_zoom(self, df_points, zoom: MagicPromptZoom, frame_i): - xmin, xmax, ymin, ymax = zoom.bounds - filtered = df_points[ - (df_points['y'] >= ymin) - & (df_points['x'] >= xmin) - & (df_points['y'] < ymax) - & (df_points['x'] < xmax) - & (df_points['frame_i'] == frame_i) - ].copy() - filtered['y'] -= ymin - filtered['x'] -= xmin - return filtered - - def retained_points_outside_zoom( - self, - frame_points_data: Mapping, - zoom: MagicPromptZoom, - ): - if 'x' in frame_points_data: - return self._retained_points_outside_zoom_2d( - frame_points_data, - zoom, - ) - - return { - z: self._retained_points_outside_zoom_2d(z_points, zoom) - for z, z_points in frame_points_data.items() - } - - def _retained_points_outside_zoom_2d(self, points_data, zoom): - xmin, xmax, ymin, ymax = zoom.bounds - retained = {'x': [], 'y': [], 'id': []} - for x, y, point_id in zip( - points_data['x'], - points_data['y'], - points_data['id'], - ): - if x < xmin or x >= xmax or y < ymin or y >= ymax: - retained['x'].append(x) - retained['y'].append(y) - retained['id'].append(point_id) - return retained - - Done!

- The custom promptable model has been added to the list of models.

- Use the Magic prompts button (top toolbar) to use it.

- Have fun! - """) - msg.information(self.host, 'Custom promptable model added', info_txt) - - def segmWithPromptableModelActionTriggered(self): - self.blinker = qutils.QControlBlink( - self.magicPromptsToolButton, qparent=self.host - ) - self.blinker.start() - @disableWindow def _importInitMagicPromptModel( - self, model_name, posData, win, acdcPromptSegment, toolbar - ): - self.logger.info(f'Initializing promptable model {model_name}...') + self, model_name, posData, win, acdcPromptSegment, toolbar + ): + self.logger.info(f"Initializing promptable model {model_name}...") init_kwargs = win.init_kwargs - model = self.init_prompt_segmentation_model( + model = myutils.init_prompt_segm_model( acdcPromptSegment, posData, win.init_kwargs ) toolbar.model = model @@ -139,81 +43,29 @@ def _importInitMagicPromptModel( init_kwargs, toolbar.model_segment_kwargs ) - self.logger.info( - f'Promptable model {model_name} successfully initialised!' - ) + self.logger.info(f"Promptable model {model_name} successfully initialised!") - @exception_handler - def magicPromptsInitModel( - self, - model_name, - acdcPromptSegment, - init_argspecs, - segment_argspecs, - help_url, - toolbar, - ): - posData = self.data[self.pos_i] - - out = prompts.init_prompt_model_params( - posData, model_name, init_argspecs, segment_argspecs, - help_url=help_url, qparent=self.host, init_last_params=True - ) - win = out.get('win') - if win.cancel: - self.logger.info( - f'Initialization of {model_name} promptable model cancelled.' - ) - return - - self._importInitMagicPromptModel( - model_name, posData, win, acdcPromptSegment, toolbar - ) - - def viewSetMagicPromptModelParams( - self, - model_name, - acdcPromptSegment, - init_argspecs, - segment_argspecs, - help_url, - init_kwargs, - segment_kwargs, - toolbar + def _retained_points_outside_zoom_2d(self, points_data, zoom): + xmin, xmax, ymin, ymax = zoom.bounds + retained = {"x": [], "y": [], "id": []} + for x, y, point_id in zip( + points_data["x"], + points_data["y"], + points_data["id"], ): - posData = self.data[self.pos_i] - - init_argspecs = ( - self.set_default_arg_specs_from_kwargs( - init_argspecs, init_kwargs - ) - ) - segment_argspecs = ( - self.set_default_arg_specs_from_kwargs( - segment_argspecs, segment_kwargs - ) - ) - - out = prompts.init_prompt_model_params( - posData, model_name, init_argspecs, segment_argspecs, - help_url=help_url, qparent=self.host, init_last_params=False - ) - win = out.get('win') - if win.cancel: - return - - if win.model_kwargs != segment_kwargs or win.init_kwargs != init_kwargs: - self._importInitMagicPromptModel( - model_name, posData, win, acdcPromptSegment, toolbar - ) + if x < xmin or x >= xmax or y < ymin or y >= ymax: + retained["x"].append(x) + retained["y"].append(y) + retained["id"].append(point_id) + return retained def getMagicPromptsInputs(self, toolbar): if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit: - _warnings.warnPromptSegmentPointsLayerNotInit(qparent=self.host) + _warnings.warnPromptSegmentPointsLayerNotInit(qparent=self) return if not self.magicPromptsToolbar.viewModelParamsAction.isEnabled(): - _warnings.warnPromptSegmentModelNotInit(qparent=self.host) + _warnings.warnPromptSegmentModelNotInit(qparent=self) return posData = self.data[self.pos_i] @@ -223,56 +75,12 @@ def getMagicPromptsInputs(self, toolbar): ) self.logger.info( - f'Starting {toolbar.model_name} promptable segmentation with the ' - f'following prompts:\n\n{df_points}' + f"Starting {toolbar.model_name} promptable segmentation with the " + f"following prompts:\n\n{df_points}" ) return image, df_points - @disableWindow - def magicPromptsComputeOnZoomTriggered(self, toolbar): - inputs = self.getMagicPromptsInputs(toolbar) - if inputs is None: - self.logger.info( - '"Computing promptable segmentation on zoom" process cancelled.' - ) - return - - posData = self.data[self.pos_i] - image, df_points = inputs - - zoom = self.zoom_region(self.ax1.viewRange(), image.shape) - xmin, xmax, ymin, ymax = zoom.bounds - - self.logger.info( - f'Zoom range: xmin={xmin}, xmax={xmax}, ymin={ymin}, ymax={ymax}' - ) - - zoom_slice = zoom.zoom_slice - image = image[..., zoom_slice[0], zoom_slice[1]] - image_origin = zoom.image_origin - df_points = self.points_in_zoom( - df_points, - zoom, - posData.frame_i, - ) - - self.logger.info( - f'Image origin = {image_origin}\n' - f'Image shape = {image.shape}' - ) - - self.startMagicPromptsWorkerAndWait( - image, df_points, toolbar.model, toolbar.model_segment_kwargs, - image_origin=image_origin, zoom_slice=zoom_slice - ) - - def magicPromptsInterpolateZsliceToggled(self, checked): - # See 'self.promptSegmentPointsLayerToolbar.addPointsZslicesInterpolation' - self.promptSegmentPointsLayerToolbar.doAddPointsZslicesInterpolation = ( - checked - ) - def magicPromptsClearPoints(self, toolbar, only_zoom=False): posData = self.data[self.pos_i] scatterItem = self.promptSegmentPointsLayerToolbar.scatterItem() @@ -282,9 +90,7 @@ def magicPromptsClearPoints(self, toolbar, only_zoom=False): if pointsDataPos is None: return - framePointsData = action.pointsData[self.pos_i].pop( - posData.frame_i, None - ) + framePointsData = action.pointsData[self.pos_i].pop(posData.frame_i, None) if framePointsData is None: return @@ -292,14 +98,36 @@ def magicPromptsClearPoints(self, toolbar, only_zoom=False): scatterItem.clear() return - zoom = self.zoom_region( - self.ax1.viewRange(), - posData.img_data.shape, - ) - newFramePointsData = self.retained_points_outside_zoom( - framePointsData, - zoom, - ) + ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange() + Y, X = posData.img_data.shape[-2:] + + xmin = int(max(0, xmin)) + xmax = int(min(X, xmax)) + ymin = int(max(0, ymin)) + ymax = int(min(Y, ymax)) + + if "x" in framePointsData: + newFramePointsData = {"x": [], "y": [], "id": []} + xx = framePointsData["x"] + yy = framePointsData["y"] + ids = framePointsData["id"] + for x, y, point_id in zip(xx, yy, ids): + if x < xmin or x >= xmax or y < ymin or y >= ymax: + newFramePointsData["x"].append(x) + newFramePointsData["y"].append(y) + newFramePointsData["id"].append(point_id) + else: + newFramePointsData = {} + for z, zSliceFramePointsData in framePointsData.items(): + newFramePointsData[z] = {"x": [], "y": [], "id": []} + xx = zSliceFramePointsData["x"] + yy = zSliceFramePointsData["y"] + ids = zSliceFramePointsData["id"] + for x, y, point_id in zip(xx, yy, ids): + if x < xmin or x >= xmax or y < ymin or y >= ymax: + newFramePointsData[z]["x"].append(x) + newFramePointsData[z]["y"].append(y) + newFramePointsData[z]["id"].append(point_id) action.pointsData[self.pos_i][posData.frame_i] = newFramePointsData self.drawPointsLayers() @@ -309,8 +137,7 @@ def magicPromptsComputeOnImageTriggered(self, toolbar): inputs = self.getMagicPromptsInputs(toolbar) if inputs is None: self.logger.info( - '"Computing promptable segmentation on entire image" ' - 'process cancelled.' + '"Computing promptable segmentation on entire image" process cancelled.' ) return @@ -320,66 +147,91 @@ def magicPromptsComputeOnImageTriggered(self, toolbar): image, df_points, toolbar.model, toolbar.model_segment_kwargs ) - def startMagicPromptsWorkerAndWait( - self, image, df_points, model, model_segment_kwargs, - image_origin=(0, 0, 0), zoom_slice=None - ): - desc = ( - 'Running promptable segmentation model...' - ) - self.logger.info(desc) + @disableWindow + def magicPromptsComputeOnZoomTriggered(self, toolbar): + inputs = self.getMagicPromptsInputs(toolbar) + if inputs is None: + self.logger.info( + '"Computing promptable segmentation on zoom" process cancelled.' + ) + return + posData = self.data[self.pos_i] + image, df_points = inputs - self.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self.host, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.show(self.app) + ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange() + Y, X = image.shape[-2:] - self.magicPromptsThread = QThread() - self.magicPromptsWorker = workers.MagicPromptsWorker( - posData, image, df_points, model, model_segment_kwargs, - image_origin=image_origin, - global_image=posData.img_data[posData.frame_i] - ) + xmin = int(max(0, xmin)) + xmax = int(min(X, xmax)) + ymin = int(max(0, ymin)) + ymax = int(min(Y, ymax)) - self.magicPromptsWorker.moveToThread( - self.magicPromptsThread + self.logger.info( + f"Zoom range: xmin={xmin}, xmax={xmax}, ymin={ymin}, ymax={ymax}" ) - self.magicPromptsWorker.signals.finished.connect( - self.magicPromptsThread.quit - ) - self.magicPromptsWorker.signals.finished.connect( - self.magicPromptsWorker.deleteLater - ) - self.magicPromptsThread.finished.connect( - self.magicPromptsThread.deleteLater - ) + zoom_slice = (slice(ymin, ymax), slice(xmin, xmax)) - self.magicPromptsWorker.signals.critical.connect( - self.magicPromptsWorkerCritical - ) - self.magicPromptsWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.magicPromptsWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.magicPromptsWorker.signals.progress.connect( - self.workerProgress + image = image[..., ymin:ymax, xmin:xmax] + image_origin = (0, ymin, xmin) + + df_points = df_points[df_points["y"] >= ymin] + df_points = df_points[df_points["x"] >= xmin] + df_points = df_points[df_points["y"] < ymax] + df_points = df_points[df_points["x"] < xmax] + + df_points["y"] -= ymin + df_points["x"] -= xmin + + df_points = df_points[df_points["frame_i"] == posData.frame_i] + + self.logger.info(f"Image origin = {image_origin}\nImage shape = {image.shape}") + + self.startMagicPromptsWorkerAndWait( + image, + df_points, + toolbar.model, + toolbar.model_segment_kwargs, + image_origin=image_origin, + zoom_slice=zoom_slice, ) - self.magicPromptsWorker.signals.finished.connect( - partial(self.magicPromptsWorkerFinished, zoom_slice=zoom_slice) + + @exception_handler + def magicPromptsInitModel( + self, + model_name, + acdcPromptSegment, + init_argspecs, + segment_argspecs, + help_url, + toolbar, + ): + posData = self.data[self.pos_i] + + out = prompts.init_prompt_model_params( + posData, + model_name, + init_argspecs, + segment_argspecs, + help_url=help_url, + qparent=self, + init_last_params=True, ) + win = out.get("win") + if win.cancel: + self.logger.info( + f"Initialization of {model_name} promptable model cancelled." + ) + return - self.magicPromptsThread.started.connect( - self.magicPromptsWorker.run + self._importInitMagicPromptModel( + model_name, posData, win, acdcPromptSegment, toolbar ) - self.magicPromptsThread.start() - self.magicPromptsWorkerLoop = QEventLoop() - self.magicPromptsWorkerLoop.exec_() + def magicPromptsInterpolateZsliceToggled(self, checked): + # See 'self.promptSegmentPointsLayerToolbar.addPointsZslicesInterpolation' + self.promptSegmentPointsLayerToolbar.doAddPointsZslicesInterpolation = checked def magicPromptsWorkerCritical(self, error): self.magicPromptsWorkerLoop.exit() @@ -396,14 +248,10 @@ def magicPromptsWorkerFinished(self, output, zoom_slice=None): posData = self.data[self.pos_i] - is_zoom = True if zoom_slice is None: zoom_slice = (slice(None), slice(None)) - is_zoom = False - img = ( - posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]] - ) + img = posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]] images = [img, img, img, img] labels_overlays = [ posData.lab[..., zoom_slice[0], zoom_slice[1]], @@ -419,37 +267,36 @@ def magicPromptsWorkerFinished(self, output, zoom_slice=None): labels_overlays_lut, ] axis_titles = [ - 'Original masks', - 'New masks', - 'Union of original and new masks', - 'Intersection of original and new masks' + "Original masks", + "New masks", + "Union of original and new masks", + "Intersection of original and new masks", ] from cellacdc.plot import imshow + promptSegmResultsWindow = imshow( *images, labels_overlays=labels_overlays, labels_overlays_luts=labels_overlays_luts, axis_titles=axis_titles, - window_title='Promptable segmentation results', - figure_title='Ctrl+Click to select the result to use', + window_title="Promptable segmentation results", + figure_title="Ctrl+Click to select the result to use", annotate_labels_idxs=[0, 1, 2, 3], selectable_images=True, max_ncols=2, - lut='gray', - infer_rgb=False + lut="gray", + infer_rgb=False, ) if promptSegmResultsWindow.selected_idx is None: self.logger.info( - 'Selection of the promptable model segmentation ' - 'result cancelled.' + "Selection of the promptable model segmentation result cancelled." ) return if promptSegmResultsWindow.selected_idx == 0: self.logger.info( - 'No selection of a promptable model segmentation ' - 'result was made' + "No selection of a promptable model segmentation result was made" ) return @@ -461,12 +308,173 @@ def magicPromptsWorkerFinished(self, output, zoom_slice=None): zoom_out_lab = results[selected_idx][..., zoom_slice[0], zoom_slice[1]] zoom_out_lab_mask = zoom_out_lab > 0 - lab = posData.allData_li[posData.frame_i]['labels'] - lab[..., zoom_slice[0], zoom_slice[1]][zoom_out_lab_mask] = ( - zoom_out_lab[zoom_out_lab_mask] - ) + lab = posData.allData_li[posData.frame_i]["labels"] + lab[..., zoom_slice[0], zoom_slice[1]][zoom_out_lab_mask] = zoom_out_lab[ + zoom_out_lab_mask + ] - posData.allData_li[posData.frame_i]['labels'] = lab + posData.allData_li[posData.frame_i]["labels"] = lab self.get_data() self.store_data(autosave=False) - self.updateAllImages() \ No newline at end of file + self.updateAllImages() + + def points_in_zoom(self, df_points, zoom: MagicPromptZoom, frame_i): + xmin, xmax, ymin, ymax = zoom.bounds + filtered = df_points[ + (df_points["y"] >= ymin) + & (df_points["x"] >= xmin) + & (df_points["y"] < ymax) + & (df_points["x"] < xmax) + & (df_points["frame_i"] == frame_i) + ].copy() + filtered["y"] -= ymin + filtered["x"] -= xmin + return filtered + + def retained_points_outside_zoom( + self, + frame_points_data: Mapping, + zoom: MagicPromptZoom, + ): + if "x" in frame_points_data: + return self._retained_points_outside_zoom_2d( + frame_points_data, + zoom, + ) + + return { + z: self._retained_points_outside_zoom_2d(z_points, zoom) + for z, z_points in frame_points_data.items() + } + + def segmWithPromptableModelActionTriggered(self): + self.blinker = qutils.QControlBlink(self.magicPromptsToolButton, qparent=self) + self.blinker.start() + + def showInstructionsCustomPromptModel(self): + modelFilePath = apps.addCustomPromptModelMessages(QParent=self) + if modelFilePath is None: + self.logger.info("Adding custom promptable model process stopped.") + return + + myutils.store_custom_promptable_model_path(modelFilePath) + + msg = widgets.myMessageBox(wrapText=False) + info_txt = html_utils.paragraph(""" + Done!

+ The custom promptable model has been added to the list of models.

+ Use the Magic prompts button (top toolbar) to use it.

+ Have fun! + """) + msg.information(self, "Custom promptable model added", info_txt) + + def startMagicPromptsWorkerAndWait( + self, + image, + df_points, + model, + model_segment_kwargs, + image_origin=(0, 0, 0), + zoom_slice=None, + ): + desc = "Running promptable segmentation model..." + self.logger.info(desc) + posData = self.data[self.pos_i] + + self.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.show(self.app) + + self.magicPromptsThread = QThread() + self.magicPromptsWorker = workers.MagicPromptsWorker( + posData, + image, + df_points, + model, + model_segment_kwargs, + image_origin=image_origin, + global_image=posData.img_data[posData.frame_i], + ) + + self.magicPromptsWorker.moveToThread(self.magicPromptsThread) + + self.magicPromptsWorker.signals.finished.connect(self.magicPromptsThread.quit) + self.magicPromptsWorker.signals.finished.connect( + self.magicPromptsWorker.deleteLater + ) + self.magicPromptsThread.finished.connect(self.magicPromptsThread.deleteLater) + + self.magicPromptsWorker.signals.critical.connect( + self.magicPromptsWorkerCritical + ) + self.magicPromptsWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.magicPromptsWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.magicPromptsWorker.signals.progress.connect(self.workerProgress) + self.magicPromptsWorker.signals.finished.connect( + partial(self.magicPromptsWorkerFinished, zoom_slice=zoom_slice) + ) + + self.magicPromptsThread.started.connect(self.magicPromptsWorker.run) + self.magicPromptsThread.start() + + self.magicPromptsWorkerLoop = QEventLoop() + self.magicPromptsWorkerLoop.exec_() + + def viewSetMagicPromptModelParams( + self, + model_name, + acdcPromptSegment, + init_argspecs, + segment_argspecs, + help_url, + init_kwargs, + segment_kwargs, + toolbar, + ): + posData = self.data[self.pos_i] + + init_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( + init_argspecs, init_kwargs + ) + segment_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( + segment_argspecs, segment_kwargs + ) + + out = prompts.init_prompt_model_params( + posData, + model_name, + init_argspecs, + segment_argspecs, + help_url=help_url, + qparent=self, + init_last_params=False, + ) + win = out.get("win") + if win.cancel: + return + + if win.model_kwargs != segment_kwargs or win.init_kwargs != init_kwargs: + self._importInitMagicPromptModel( + model_name, posData, win, acdcPromptSegment, toolbar + ) + + def zoom_region(self, view_range, image_shape) -> MagicPromptZoom: + (xmin, xmax), (ymin, ymax) = view_range + height, width = image_shape[-2:] + + xmin = int(max(0, xmin)) + xmax = int(min(width, xmax)) + ymin = int(max(0, ymin)) + ymax = int(min(height, ymax)) + + return MagicPromptZoom( + bounds=(xmin, xmax, ymin, ymax), + image_origin=(0, ymin, xmin), + zoom_slice=(slice(ymin, ymax), slice(xmin, xmax)), + ) diff --git a/cellacdc/mixins/main.py b/cellacdc/mixins/main.py index 76a85a44f..33c44bab1 100644 --- a/cellacdc/mixins/main.py +++ b/cellacdc/mixins/main.py @@ -1,35 +1,63 @@ -"""Main GUI view-model composition root.""" +"""Main GUI mixin composition root.""" from __future__ import annotations -from dataclasses import dataclass, field - -from .cca_edits_viewmodel import CcaEditViewModel -from .cca_workflows_viewmodel import CcaWorkflowViewModel -from .edit_id_viewmodel import EditIdViewModel -from .frame_metadata_viewmodel import FrameMetadataViewModel -from .formatting_viewmodel import FormattingViewModel -from .geometry_viewmodel import GeometryViewModel -from .label_edits_viewmodel import LabelEditViewModel -from .lineage_viewmodel import LineageViewModel -from .model_registry_viewmodel import ModelRegistryViewModel -from .object_counts_viewmodel import ObjectCountViewModel -from .points_viewmodel import PointsViewModel -from .tables_viewmodel import TableViewModel -from .workspace_viewmodel import WorkspaceViewModel - - -@dataclass(frozen=True) -class MainGuiViewModel: - """Application-facing commands available to the Qt GUI.""" cca_edits: CcaEditViewModel = field(default_factory=CcaEditViewModel) - cca_workflows: CcaWorkflowViewModel = field( - default_factory=CcaWorkflowViewModel - ) edit_id: EditIdViewModel = field(default_factory=EditIdViewModel) frame_metadata: FrameMetadataViewModel = field( - default_factory=FrameMetadataViewModel - ) - formatting: FormattingViewModel = field(default_factory=FormattingViewModel) - geometry: GeometryViewModel = field(default_factory=GeometryViewModel) label_edits: LabelEditViewModel = field(default_factory=LabelEditViewModel) lineage: LineageViewModel = field(default_factory=LineageViewModel) model_registry: ModelRegistryViewModel = field( - default_factory=ModelRegistryViewModel - ) object_counts: ObjectCountViewModel = field( - default_factory=ObjectCountViewModel - ) points: PointsViewModel = field(default_factory=PointsViewModel) tables: TableViewModel = field(default_factory=TableViewModel) workspace: WorkspaceViewModel = field(default_factory=WorkspaceViewModel) + +class MainGuiMixin: + """Composition adapter properties to support legacy view-model routing.""" + + @property + def view_model(self): + return self + + @property + def cca_edits(self): + return self + + @property + def cca_workflows(self): + return self + + @property + def edit_id(self): + return self + + @property + def frame_metadata(self): + return self + + @property + def formatting(self): + return self + + @property + def geometry(self): + return self + + @property + def label_edits(self): + return self + + @property + def lineage(self): + return self + + @property + def model_registry(self): + return self + + @property + def object_counts(self): + return self + + @property + def points(self): + return self + + @property + def tables(self): + return self + + @property + def workspace(self): + return self diff --git a/cellacdc/mixins/main_menu.py b/cellacdc/mixins/main_menu.py index bc8cac535..379aa9d61 100644 --- a/cellacdc/mixins/main_menu.py +++ b/cellacdc/mixins/main_menu.py @@ -5,231 +5,200 @@ from qtpy.QtWidgets import QAction, QActionGroup, QMenu - -class MainMenuView: +class MainMenuMixin: """Qt-facing adapter around the main-menu view-model.""" """Headless main-menu decision rules.""" - default_rescale_intensity_options = ( - 'Rescale each 2D image', - 'Rescale across z-stack', - 'Rescale across time frames', - 'Do no rescale, display raw image', - ) - - def default_rescale_intensity_how(self, settings): - try: - return settings.at['default_rescale_intens_how', 'value'] - except Exception: - return self.default_rescale_intensity_options[0] + default_rescale_intensity_options = () + def _add_default_rescale_intensity_menu(self): + self.defaultRescaleIntensActionGroup = QActionGroup( + self.defaultRescaleIntensLutMenu + ) + self.defaultRescaleIntensHow = self.default_rescale_intensity_how( + self.df_settings + ) + for how_text in self.default_rescale_intensity_options(): + action = QAction(how_text, self.defaultRescaleIntensLutMenu) + action.setCheckable(True) + if how_text == self.defaultRescaleIntensHow: + action.setChecked(True) - def __init__(self, host): - self.host = host - def create_menu_bar(self): - menu_bar = self.host.menuBar() - menu_bar.setNativeMenuBar(False) + self.defaultRescaleIntensActionGroup.addAction(action) + self.defaultRescaleIntensLutMenu.addAction(action) - self._add_file_menu(menu_bar) - self._add_edit_menu(menu_bar) - self._add_view_menu(menu_bar) - self._add_image_menu(menu_bar) - self._add_segment_menu(menu_bar) - self._add_tracking_menu(menu_bar) - self._add_measurements_menu(menu_bar) - self._add_settings_menu(menu_bar) - self._add_mode_menu(menu_bar) - self._add_help_menu(menu_bar) + def _add_edit_menu(self, menu_bar): + edit_menu = menu_bar.addMenu("&Edit") + edit_menu.addSeparator() + edit_menu.addAction(self.editShortcutsAction) + edit_menu.addAction(self.editTextIDsColorAction) + edit_menu.addAction(self.editOverlayColorAction) + edit_menu.addAction(self.manuallyEditCcaAction) + edit_menu.addAction(self.enableSmartTrackAction) + edit_menu.addAction(self.enableAutoZoomToCellsAction) def _add_file_menu(self, menu_bar): - file_menu = QMenu("&File", self.host) - self.host.fileMenu = file_menu + file_menu = QMenu("&File", self) + self.fileMenu = file_menu menu_bar.addMenu(file_menu) - if self.host.debug: - file_menu.addAction(self.host.createEmptyDataAction) - file_menu.addAction(self.host.newAction) - file_menu.addAction(self.host.newWindowAction) + if self.debug: + file_menu.addAction(self.createEmptyDataAction) + file_menu.addAction(self.newAction) + file_menu.addAction(self.newWindowAction) file_menu.addSeparator() - file_menu.addAction(self.host.openFolderAction) - file_menu.addAction(self.host.openFileAction) - self.host.openRecentMenu = file_menu.addMenu("Open Recent") + file_menu.addAction(self.openFolderAction) + file_menu.addAction(self.openFileAction) + self.openRecentMenu = file_menu.addMenu("Open Recent") file_menu.addSeparator() - file_menu.addAction(self.host.manageVersionsAction) - file_menu.addAction(self.host.saveAction) - file_menu.addAction(self.host.saveAsAction) - file_menu.addAction(self.host.quickSaveAction) + file_menu.addAction(self.manageVersionsAction) + file_menu.addAction(self.saveAction) + file_menu.addAction(self.saveAsAction) + file_menu.addAction(self.quickSaveAction) file_menu.addSeparator() - self.host.exportMenu = file_menu.addMenu('Export') - self.host.exportMenu.addAction(self.host.exportToVideoAction) - self.host.exportMenu.addAction(self.host.exportToImageAction) + self.exportMenu = file_menu.addMenu("Export") + self.exportMenu.addAction(self.exportToVideoAction) + self.exportMenu.addAction(self.exportToImageAction) file_menu.addSeparator() - file_menu.addAction(self.host.loadFluoAction) - file_menu.addAction(self.host.loadPosAction) - self.host.fileMenu.lastSeparator = file_menu.addSeparator() - file_menu.addAction(self.host.exitAction) - - def _add_edit_menu(self, menu_bar): - edit_menu = menu_bar.addMenu("&Edit") - edit_menu.addSeparator() - edit_menu.addAction(self.host.editShortcutsAction) - edit_menu.addAction(self.host.editTextIDsColorAction) - edit_menu.addAction(self.host.editOverlayColorAction) - edit_menu.addAction(self.host.manuallyEditCcaAction) - edit_menu.addAction(self.host.enableSmartTrackAction) - edit_menu.addAction(self.host.enableAutoZoomToCellsAction) + file_menu.addAction(self.loadFluoAction) + file_menu.addAction(self.loadPosAction) + self.fileMenu.lastSeparator = file_menu.addSeparator() + file_menu.addAction(self.exitAction) - def _add_view_menu(self, menu_bar): - self.host.viewMenu = menu_bar.addMenu("&View") - self.host.viewMenu.addSeparator() - self.host.viewMenu.addAction(self.host.viewCcaTableAction) + def _add_help_menu(self, menu_bar): + help_menu = menu_bar.addMenu("&Help") + help_menu.addAction(self.openLogFileAction) + help_menu.addAction(self.showLogFilesAction) + help_menu.addAction(self.tipsAction) + help_menu.addAction(self.UserManualAction) + help_menu.addSeparator() + help_menu.addAction(self.aboutAction) + self.helpMenu = help_menu def _add_image_menu(self, menu_bar): image_menu = menu_bar.addMenu("&Image") image_menu.addSeparator() - image_menu.addAction(self.host.imgPropertiesAction) - self.host.defaultRescaleIntensLutMenu = image_menu.addMenu( + image_menu.addAction(self.imgPropertiesAction) + self.defaultRescaleIntensLutMenu = image_menu.addMenu( "Default method to rescale intensities (LUT)" ) self._add_default_rescale_intensity_menu() - image_menu.addAction(self.host.addScaleBarAction) - image_menu.addAction(self.host.addTimestampAction) - self.host.rescaleIntensMenu = image_menu.addMenu( - 'Rescale intensities (LUT)' - ) - image_menu.addAction(self.host.preprocessAction) - image_menu.addAction(self.host.combineChannelsAction) - image_menu.addAction(self.host.saveLabColormapAction) - image_menu.addAction(self.host.shuffleCmapAction) - image_menu.addAction(self.host.greedyShuffleCmapAction) - image_menu.addAction(self.host.zoomToObjsAction) - image_menu.addAction(self.host.zoomOutAction) + image_menu.addAction(self.addScaleBarAction) + image_menu.addAction(self.addTimestampAction) + self.rescaleIntensMenu = image_menu.addMenu("Rescale intensities (LUT)") + image_menu.addAction(self.preprocessAction) + image_menu.addAction(self.combineChannelsAction) + image_menu.addAction(self.saveLabColormapAction) + image_menu.addAction(self.shuffleCmapAction) + image_menu.addAction(self.greedyShuffleCmapAction) + image_menu.addAction(self.zoomToObjsAction) + image_menu.addAction(self.zoomOutAction) - def _add_default_rescale_intensity_menu(self): - self.host.defaultRescaleIntensActionGroup = QActionGroup( - self.host.defaultRescaleIntensLutMenu - ) - self.host.defaultRescaleIntensHow = ( - self.default_rescale_intensity_how( - self.host.df_settings - ) - ) - for how_text in self.default_rescale_intensity_options(): - action = QAction( - how_text, self.host.defaultRescaleIntensLutMenu - ) - action.setCheckable(True) - if how_text == self.host.defaultRescaleIntensHow: - action.setChecked(True) + def _add_measurements_menu(self, menu_bar): + measurements_menu = menu_bar.addMenu("&Measurements") + self.measurementsMenu = measurements_menu + measurements_menu.addSeparator() + measurements_menu.addAction(self.setMeasurementsAction) + measurements_menu.addAction(self.addCustomMetricAction) + measurements_menu.addAction(self.addCombineMetricAction) + measurements_menu.setDisabled(True) - self.host.defaultRescaleIntensActionGroup.addAction(action) - self.host.defaultRescaleIntensLutMenu.addAction(action) + def _add_mode_menu(self, menu_bar): + self.modeMenu = menu_bar.addMenu("Mode") + self.modeMenu.menuAction().setVisible(False) def _add_segment_menu(self, menu_bar): segment_menu = menu_bar.addMenu("&Segment") - self.host.segmentMenu = segment_menu + self.segmentMenu = segment_menu segment_menu.addSeparator() - self.host.segmSingleFrameMenu = segment_menu.addMenu( - 'Segment displayed frame' - ) - for action in self.host.segmActions: - self.host.segmSingleFrameMenu.addAction(action) + self.segmSingleFrameMenu = segment_menu.addMenu("Segment displayed frame") + for action in self.segmActions: + self.segmSingleFrameMenu.addAction(action) - self.host.segmSingleFrameMenu.addSeparator() - self.host.segmSingleFrameMenu.addAction( - self.host.addCustomModelFrameAction - ) + self.segmSingleFrameMenu.addSeparator() + self.segmSingleFrameMenu.addAction(self.addCustomModelFrameAction) - self.host.segmVideoMenu = segment_menu.addMenu( - 'Segment multiple frames' - ) - for action in self.host.segmActionsVideo: - self.host.segmVideoMenu.addAction(action) + self.segmVideoMenu = segment_menu.addMenu("Segment multiple frames") + for action in self.segmActionsVideo: + self.segmVideoMenu.addAction(action) - self.host.segmVideoMenu.addSeparator() - self.host.segmVideoMenu.addAction( - self.host.addCustomModelVideoAction - ) + self.segmVideoMenu.addSeparator() + self.segmVideoMenu.addAction(self.addCustomModelVideoAction) - self.host.segmWithPromptableModelMenu = segment_menu.addMenu( - 'Segment with promptable model' - ) - self.host.segmWithPromptableModelMenu.addAction( - self.host.segmWithPromptableModelAction + self.segmWithPromptableModelMenu = segment_menu.addMenu( + "Segment with promptable model" ) - self.host.segmWithPromptableModelMenu.addSeparator() - self.host.segmWithPromptableModelMenu.addAction( - self.host.addCustomPromptModelAction - ) - - segment_menu.addAction(self.host.EditSegForLostIDsSetSettings) - segment_menu.addAction(self.host.postProcessSegmAction) - segment_menu.addAction(self.host.autoSegmAction) - segment_menu.addAction(self.host.relabelSequentialAction) + self.segmWithPromptableModelMenu.addAction(self.segmWithPromptableModelAction) + self.segmWithPromptableModelMenu.addSeparator() + self.segmWithPromptableModelMenu.addAction(self.addCustomPromptModelAction) + + segment_menu.addAction(self.EditSegForLostIDsSetSettings) + segment_menu.addAction(self.postProcessSegmAction) + segment_menu.addAction(self.autoSegmAction) + segment_menu.addAction(self.relabelSequentialAction) segment_menu.aboutToShow.connect( - self.host.mode_controls_view.nonViewerEditMenuOpened + self.mode_controls_view.nonViewerEditMenuOpened ) + def _add_settings_menu(self, menu_bar): + self.settingsMenu = QMenu("Settings", self) + menu_bar.addMenu(self.settingsMenu) + self.settingsMenu.addAction(self.invertBwAction) + self.settingsMenu.addAction(self.toggleColorSchemeAction) + self.settingsMenu.addSeparator() + self.settingsMenu.addAction(self.pxModeAction) + self.settingsMenu.addAction(self.highLowResAction) + self.settingsMenu.addAction(self.editShortcutsAction) + self.settingsMenu.addAction(self.showMirroredCursorAction) + self.settingsMenu.addSeparator() + self.settingsMenu.addAction(self.editAutoSaveIntervalAction) + self.settingsMenu.addSeparator() + def _add_tracking_menu(self, menu_bar): tracking_menu = menu_bar.addMenu("&Tracking") - self.host.trackingMenu = tracking_menu + self.trackingMenu = tracking_menu tracking_menu.addSeparator() select_track_algo_menu = tracking_menu.addMenu( - 'Select real-time tracking algorithm' + "Select real-time tracking algorithm" ) - for action in self.host.trackingAlgosGroup.actions(): + for action in self.trackingAlgosGroup.actions(): select_track_algo_menu.addAction(action) - tracking_menu.addAction(self.host.editRtTrackerParamsAction) - tracking_menu.addAction(self.host.repeatTrackingVideoAction) - tracking_menu.addAction(self.host.repeatTrackingMenuAction) + tracking_menu.addAction(self.editRtTrackerParamsAction) + tracking_menu.addAction(self.repeatTrackingVideoAction) + tracking_menu.addAction(self.repeatTrackingMenuAction) tracking_menu.aboutToShow.connect( - self.host.mode_controls_view.nonViewerEditMenuOpened + self.mode_controls_view.nonViewerEditMenuOpened ) - if self.host.mainWin is not None: - tracking_menu.addAction( - self.host.mainWin.applyTrackingFromTableAction - ) - tracking_menu.addAction( - self.host.mainWin.applyTrackingFromTrackMateXMLAction - ) + if self.mainWin is not None: + tracking_menu.addAction(self.mainWin.applyTrackingFromTableAction) + tracking_menu.addAction(self.mainWin.applyTrackingFromTrackMateXMLAction) - def _add_measurements_menu(self, menu_bar): - measurements_menu = menu_bar.addMenu("&Measurements") - self.host.measurementsMenu = measurements_menu - measurements_menu.addSeparator() - measurements_menu.addAction(self.host.setMeasurementsAction) - measurements_menu.addAction(self.host.addCustomMetricAction) - measurements_menu.addAction(self.host.addCombineMetricAction) - measurements_menu.setDisabled(True) + def _add_view_menu(self, menu_bar): + self.viewMenu = menu_bar.addMenu("&View") + self.viewMenu.addSeparator() + self.viewMenu.addAction(self.viewCcaTableAction) - def _add_settings_menu(self, menu_bar): - self.host.settingsMenu = QMenu("Settings", self.host) - menu_bar.addMenu(self.host.settingsMenu) - self.host.settingsMenu.addAction(self.host.invertBwAction) - self.host.settingsMenu.addAction(self.host.toggleColorSchemeAction) - self.host.settingsMenu.addSeparator() - self.host.settingsMenu.addAction(self.host.pxModeAction) - self.host.settingsMenu.addAction(self.host.highLowResAction) - self.host.settingsMenu.addAction(self.host.editShortcutsAction) - self.host.settingsMenu.addAction(self.host.showMirroredCursorAction) - self.host.settingsMenu.addSeparator() - self.host.settingsMenu.addAction(self.host.editAutoSaveIntervalAction) - self.host.settingsMenu.addSeparator() + def create_menu_bar(self): + menu_bar = self.menuBar() + menu_bar.setNativeMenuBar(False) - def _add_mode_menu(self, menu_bar): - self.host.modeMenu = menu_bar.addMenu('Mode') - self.host.modeMenu.menuAction().setVisible(False) + self._add_file_menu(menu_bar) + self._add_edit_menu(menu_bar) + self._add_view_menu(menu_bar) + self._add_image_menu(menu_bar) + self._add_segment_menu(menu_bar) + self._add_tracking_menu(menu_bar) + self._add_measurements_menu(menu_bar) + self._add_settings_menu(menu_bar) + self._add_mode_menu(menu_bar) + self._add_help_menu(menu_bar) - def _add_help_menu(self, menu_bar): - help_menu = menu_bar.addMenu("&Help") - help_menu.addAction(self.host.openLogFileAction) - help_menu.addAction(self.host.showLogFilesAction) - help_menu.addAction(self.host.tipsAction) - help_menu.addAction(self.host.UserManualAction) - help_menu.addSeparator() - help_menu.addAction(self.host.aboutAction) - self.host.helpMenu = help_menu \ No newline at end of file + def default_rescale_intensity_how(self, settings): + try: + return settings.at["default_rescale_intens_how", "value"] + except Exception: + return self.default_rescale_intensity_options[0] diff --git a/cellacdc/mixins/main_toolbar.py b/cellacdc/mixins/main_toolbar.py index b4ec38dae..fa8862b77 100644 --- a/cellacdc/mixins/main_toolbar.py +++ b/cellacdc/mixins/main_toolbar.py @@ -11,33 +11,12 @@ from cellacdc import widgets -class MainToolbarView: +class MainToolbarMixin: """Qt-facing adapter around top-level toolbar construction.""" """Headless toolbar metadata used by the main toolbar view.""" - mode_items = ( - 'Segmentation and Tracking', - 'Cell cycle analysis', - 'Viewer', - 'Custom annotations', - 'Normal division: Lineage tree', - ) - - def default_mode_items(self) -> tuple[str, ...]: - return self.mode_items - - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) + mode_items = () def closeToolbars(self): for toolbar in self.sender().toolbars: @@ -45,18 +24,29 @@ def closeToolbars(self): for action in toolbar.actions(): try: action.button.setChecked(False) - except Exception as e: + except Exception: pass + def default_mode_items(self) -> tuple[str, ...]: + return self.mode_items + + def gui_createAnnotateToolbar(self): + # Edit toolbar + self.annotateToolbar = widgets.ToolBar("Custom annotations", self) + self.annotateToolbar.setContextMenuPolicy(Qt.PreventContextMenu) + self.addToolBar(Qt.LeftToolBarArea, self.annotateToolbar) + self.annotateToolbar.addAction(self.loadCustomAnnotationsAction) + self.annotateToolbar.addAction(self.addCustomAnnotationAction) + self.annotateToolbar.addAction(self.viewAllCustomAnnotAction) + self.annotateToolbar.setVisible(False) + def gui_createToolBars(self): # File toolbar fileToolBar = self.addToolBar("File") # fileToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) fileToolBar.setMovable(False) - self.segmNdimIndicatorAction = fileToolBar.addWidget( - self.segmNdimIndicator - ) + self.segmNdimIndicatorAction = fileToolBar.addWidget(self.segmNdimIndicator) self.segmNdimIndicatorAction.setVisible(False) fileToolBar.addAction(self.newAction) fileToolBar.addAction(self.openFolderAction) @@ -68,62 +58,55 @@ def gui_createToolBars(self): fileToolBar.addAction(self.undoAction) fileToolBar.addAction(self.redoAction) self.fileToolBar = fileToolBar - self.mode_controls_view.setEnabledFileToolbar(False) + self.setEnabledFileToolbar(False) self.undoAction.setEnabled(False) self.redoAction.setEnabled(False) # Navigation toolbar - navigateToolBar = widgets.ToolBar("Navigation", self.host) + navigateToolBar = widgets.ToolBar("Navigation", self) navigateToolBar.setContextMenuPolicy(Qt.PreventContextMenu) # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) self.addToolBar(navigateToolBar) navigateToolBar.addAction(self.findIdAction) - + navigateToolBar.addWidget(self.zoomRectButton) - self.slideshowButton = QToolButton(self.host) + self.slideshowButton = QToolButton(self) self.slideshowButton.setIcon(QIcon(":eye-plus.svg")) self.slideshowButton.setCheckable(True) - self.slideshowButton.setShortcut('Ctrl+W') + self.slideshowButton.setShortcut("Ctrl+W") navigateToolBar.addWidget(self.slideshowButton) - + navigateToolBar.addAction(self.autoPilotButton) - + # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) navigateToolBar.addAction(self.skipToNewIdAction) - - self.preprocessImageAction = QAction('Preprocess image', self.host) + + self.preprocessImageAction = QAction("Preprocess image", self) self.preprocessImageAction.setIcon(QIcon(":filter_image.svg")) navigateToolBar.addAction(self.preprocessImageAction) - self.overlayButton = widgets.rightClickToolButton(parent=self.host) + self.overlayButton = widgets.rightClickToolButton(parent=self) self.overlayButton.setIcon(QIcon(":overlay.svg")) self.overlayButton.setCheckable(True) self.overlayButtonAction = navigateToolBar.addWidget(self.overlayButton) # self.checkableButtons.append(self.overlayButton) # self.checkableQButtonsGroup.addButton(self.overlayButton) - - self.countObjsButton = QToolButton(self.host) + + self.countObjsButton = QToolButton(self) self.countObjsButton.setIcon(QIcon(":count_objects.svg")) self.countObjsButton.setCheckable(True) - self.countObjsButton.setShortcut('Ctrl+Shift+C') - self.countObjsButtonAction = navigateToolBar.addWidget( - self.countObjsButton - ) + self.countObjsButton.setShortcut("Ctrl+Shift+C") + self.countObjsButtonAction = navigateToolBar.addWidget(self.countObjsButton) - self.togglePointsLayerAction = QAction( - 'Activate points layer', - self.host, - ) + self.togglePointsLayerAction = QAction("Activate points layer", self) self.togglePointsLayerAction.setCheckable(True) self.togglePointsLayerAction.setIcon(QIcon(":pointsLayer.svg")) navigateToolBar.addAction(self.togglePointsLayerAction) - self.overlayLabelsButton = widgets.rightClickToolButton( - parent=self.host - ) + self.overlayLabelsButton = widgets.rightClickToolButton(parent=self) self.overlayLabelsButton.setIcon(QIcon(":overlay_labels.svg")) self.overlayLabelsButton.setCheckable(True) # self.overlayLabelsButton.setVisible(False) @@ -132,7 +115,7 @@ def gui_createToolBars(self): ) self.overlayLabelsButtonAction.setVisible(False) - self.rulerButton = QToolButton(self.host) + self.rulerButton = QToolButton(self) self.rulerButton.setIcon(QIcon(":ruler.svg")) self.rulerButton.setCheckable(True) navigateToolBar.addWidget(self.rulerButton) @@ -140,15 +123,13 @@ def gui_createToolBars(self): self.LeftClickButtons.append(self.rulerButton) # fluorescence image color widget - colorsToolBar = widgets.ToolBar("Colors", self.host) + colorsToolBar = widgets.ToolBar("Colors", self) - self.overlayColorButton = pg.ColorButton( - self.host, color=(230,230,230) - ) + self.overlayColorButton = pg.ColorButton(self, color=(230, 230, 230)) self.overlayColorButton.setDisabled(True) colorsToolBar.addWidget(self.overlayColorButton) - self.textIDsColorButton = pg.ColorButton(self.host) + self.textIDsColorButton = pg.ColorButton(self) colorsToolBar.addWidget(self.textIDsColorButton) self.addToolBar(colorsToolBar) @@ -157,28 +138,25 @@ def gui_createToolBars(self): self.navigateToolBar = navigateToolBar # cca toolbar - ccaToolBar = widgets.ToolBar("Cell cycle annotations", self.host) + ccaToolBar = widgets.ToolBar("Cell cycle annotations", self) self.addToolBar(ccaToolBar) # Assign mother to bud button - self.assignBudMothButton = QToolButton(self.host) + self.assignBudMothButton = QToolButton(self) self.assignBudMothButton.setIcon(QIcon(":assign-motherbud.svg")) self.assignBudMothButton.setCheckable(True) - self.assignBudMothButton.setShortcut('A') + self.assignBudMothButton.setShortcut("A") self.assignBudMothButton.setVisible(False) - self.assignBudMothButton.action = ccaToolBar.addWidget( - self.assignBudMothButton - ) + self.assignBudMothButton.action = ccaToolBar.addWidget(self.assignBudMothButton) self.checkableButtons.append(self.assignBudMothButton) self.checkableQButtonsGroup.addButton(self.assignBudMothButton) self.functionsNotTested3D.append(self.assignBudMothButton) - # Set is_history_known button - self.setIsHistoryKnownButton = QToolButton(self.host) + self.setIsHistoryKnownButton = QToolButton(self) self.setIsHistoryKnownButton.setIcon(QIcon(":history.svg")) self.setIsHistoryKnownButton.setCheckable(True) - self.setIsHistoryKnownButton.setShortcut('U') + self.setIsHistoryKnownButton.setShortcut("U") self.setIsHistoryKnownButton.setVisible(False) self.setIsHistoryKnownButton.action = ccaToolBar.addWidget( self.setIsHistoryKnownButton @@ -186,7 +164,7 @@ def gui_createToolBars(self): self.checkableButtons.append(self.setIsHistoryKnownButton) self.checkableQButtonsGroup.addButton(self.setIsHistoryKnownButton) self.functionsNotTested3D.append(self.setIsHistoryKnownButton) - + ccaToolBar.addAction(self.assignBudMothAutoAction) ccaToolBar.addAction(self.editCcaToolAction) ccaToolBar.addAction(self.reInitCcaAction) @@ -197,265 +175,250 @@ def gui_createToolBars(self): self.functionsNotTested3D.append(self.editCcaToolAction) # Edit toolbar - editToolBar = widgets.ToolBar("Edit", self.host) + editToolBar = widgets.ToolBar("Edit", self) editToolBar.setContextMenuPolicy(Qt.PreventContextMenu) - + self.addToolBar(editToolBar) - + self.manulAnnotToolButtons = set() - self.brushButton = QToolButton(self.host) + self.brushButton = QToolButton(self) self.brushButton.setIcon(QIcon(":brush.svg")) self.brushButton.setCheckable(True) editToolBar.addWidget(self.brushButton) self.checkableButtons.append(self.brushButton) self.LeftClickButtons.append(self.brushButton) self.brushButton.keyPressShortcut = Qt.Key_B - self.widgetsWithShortcut['Brush'] = self.brushButton + self.widgetsWithShortcut["Brush"] = self.brushButton self.manulAnnotToolButtons.add(self.brushButton) - self.eraserButton = QToolButton(self.host) + self.eraserButton = QToolButton(self) self.eraserButton.setIcon(QIcon(":eraser.svg")) self.eraserButton.setCheckable(True) editToolBar.addWidget(self.eraserButton) self.eraserButton.keyPressShortcut = Qt.Key_X - self.widgetsWithShortcut['Eraser'] = self.eraserButton + self.widgetsWithShortcut["Eraser"] = self.eraserButton self.checkableButtons.append(self.eraserButton) self.LeftClickButtons.append(self.eraserButton) self.manulAnnotToolButtons.add(self.eraserButton) - self.curvToolButton = QToolButton(self.host) + self.curvToolButton = QToolButton(self) self.curvToolButton.setIcon(QIcon(":curvature-tool.svg")) self.curvToolButton.setCheckable(True) - self.curvToolButton.setShortcut('C') + self.curvToolButton.setShortcut("C") self.curvToolButton.action = editToolBar.addWidget(self.curvToolButton) self.LeftClickButtons.append(self.curvToolButton) # self.functionsNotTested3D.append(self.curvToolButton) - self.widgetsWithShortcut['Curvature tool'] = self.curvToolButton + self.widgetsWithShortcut["Curvature tool"] = self.curvToolButton # self.checkableButtons.append(self.curvToolButton) self.manulAnnotToolButtons.add(self.curvToolButton) - self.wandToolButton = QToolButton(self.host) + self.wandToolButton = QToolButton(self) self.wandToolButton.setIcon(QIcon(":magic_wand.svg")) self.wandToolButton.setCheckable(True) - self.wandToolButton.setShortcut('Ctrl+D') + self.wandToolButton.setShortcut("Ctrl+D") self.wandToolButton.action = editToolBar.addWidget(self.wandToolButton) self.LeftClickButtons.append(self.wandToolButton) self.checkableButtons.append(self.eraserButton) - self.widgetsWithShortcut['Magic wand'] = self.wandToolButton - - self.magicPromptsToolButton = QToolButton(self.host) + self.widgetsWithShortcut["Magic wand"] = self.wandToolButton + + self.magicPromptsToolButton = QToolButton(self) self.magicPromptsToolButton.setIcon(QIcon(":magic-prompts.svg")) self.magicPromptsToolButton.setCheckable(True) - self.magicPromptsToolButton.setShortcut('W') + self.magicPromptsToolButton.setShortcut("W") self.magicPromptsToolButton.action = editToolBar.addWidget( self.magicPromptsToolButton ) - self.widgetsWithShortcut['Magic prompts'] = self.magicPromptsToolButton - - self.drawClearRegionButton = QToolButton(self.host) + self.widgetsWithShortcut["Magic prompts"] = self.magicPromptsToolButton + + self.drawClearRegionButton = QToolButton(self) self.drawClearRegionButton.setCheckable(True) self.drawClearRegionButton.setIcon(QIcon(":clear_freehand_region.svg")) - self.widgetsWithShortcut['Clear freehand region'] = ( - self.drawClearRegionButton - ) + self.widgetsWithShortcut["Clear freehand region"] = self.drawClearRegionButton self.toolsActiveInProj3Dsegm.add(self.drawClearRegionButton) - + self.checkableButtons.append(self.drawClearRegionButton) self.LeftClickButtons.append(self.drawClearRegionButton) - - self.drawClearRegionAction = editToolBar.addWidget( - self.drawClearRegionButton - ) - self.widgetsWithShortcut['Annotate mother/daughter pairing'] = ( + self.drawClearRegionAction = editToolBar.addWidget(self.drawClearRegionButton) + + self.widgetsWithShortcut["Annotate mother/daughter pairing"] = ( self.assignBudMothButton ) - self.widgetsWithShortcut['Annotate unknown history'] = ( + self.widgetsWithShortcut["Annotate unknown history"] = ( self.setIsHistoryKnownButton ) - - self.copyLostObjButton = QToolButton(self.host) + + self.copyLostObjButton = QToolButton(self) self.copyLostObjButton.setIcon(QIcon(":copyContour.svg")) self.copyLostObjButton.setCheckable(True) - self.copyLostObjButton.setShortcut('V') - self.copyLostObjButton.action = editToolBar.addWidget( - self.copyLostObjButton - ) + self.copyLostObjButton.setShortcut("V") + self.copyLostObjButton.action = editToolBar.addWidget(self.copyLostObjButton) self.checkableButtons.append(self.copyLostObjButton) self.checkableQButtonsGroup.addButton(self.copyLostObjButton) - self.widgetsWithShortcut['Copy lost object contour'] = ( - self.copyLostObjButton - ) + self.widgetsWithShortcut["Copy lost object contour"] = self.copyLostObjButton self.functionsNotTested3D.append(self.copyLostObjButton) - - self.labelRoiButton = widgets.rightClickToolButton(parent=self.host) + + self.labelRoiButton = widgets.rightClickToolButton(parent=self) self.labelRoiButton.setIcon(QIcon(":label_roi.svg")) self.labelRoiButton.setCheckable(True) - self.labelRoiButton.setShortcut('L') + self.labelRoiButton.setShortcut("L") self.labelRoiButton.action = editToolBar.addWidget(self.labelRoiButton) self.LeftClickButtons.append(self.labelRoiButton) self.checkableButtons.append(self.labelRoiButton) self.checkableQButtonsGroup.addButton(self.labelRoiButton) - self.widgetsWithShortcut['Label ROI'] = self.labelRoiButton + self.widgetsWithShortcut["Label ROI"] = self.labelRoiButton # self.functionsNotTested3D.append(self.labelRoiButton) - - self.manualAnnotPastButton = QToolButton(self.host) + + self.manualAnnotPastButton = QToolButton(self) self.manualAnnotPastButton.setIcon(QIcon(":lock_id_annotate_future.svg")) self.manualAnnotPastButton.setCheckable(True) - self.manualAnnotPastButton.setShortcut('Y') + self.manualAnnotPastButton.setShortcut("Y") self.manualAnnotPastButton.action = editToolBar.addWidget( self.manualAnnotPastButton ) self.checkableButtons.append(self.manualAnnotPastButton) - self.widgetsWithShortcut['Lock ID and annotate single object'] = ( + self.widgetsWithShortcut["Lock ID and annotate single object"] = ( self.manualAnnotPastButton ) self.functionsNotTested3D.append(self.manualAnnotPastButton) self.manulAnnotToolButtons.add(self.manualAnnotPastButton) - self.segmentToolAction = QAction( - 'Segment with last used model', - self.host, - ) + self.segmentToolAction = QAction("Segment with last used model", self) self.segmentToolAction.setIcon(QIcon(":segment.svg")) - self.segmentToolAction.setShortcut('R') - self.widgetsWithShortcut['Repeat segmentation'] = self.segmentToolAction + self.segmentToolAction.setShortcut("R") + self.widgetsWithShortcut["Repeat segmentation"] = self.segmentToolAction editToolBar.addAction(self.segmentToolAction) - self.segForLostIDsButton = QToolButton(self.host) + self.segForLostIDsButton = QToolButton(self) self.segForLostIDsButton.setIcon(QIcon(":segForLostIDs.svg")) - self.segForLostIDsAction = editToolBar.addWidget( - self.segForLostIDsButton - ) - self.segForLostIDsButton.clicked.connect( - self.seg_for_lost_ids_view.segForLostIDsButtonClicked - ) + self.segForLostIDsAction = editToolBar.addWidget(self.segForLostIDsButton) + self.segForLostIDsButton.clicked.connect(self.segForLostIDsButtonClicked) # self.SegForLostIDsButton.setShortcut('U') # self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.SegForLostIDsButton - - self.manualBackgroundButton = QToolButton(self.host) + + self.manualBackgroundButton = QToolButton(self) self.manualBackgroundButton.setIcon(QIcon(":manual_background.svg")) self.manualBackgroundButton.setCheckable(True) - self.manualBackgroundButton.setShortcut('G') + self.manualBackgroundButton.setShortcut("G") self.LeftClickButtons.append(self.manualBackgroundButton) self.checkableButtons.append(self.manualBackgroundButton) self.checkableQButtonsGroup.addButton(self.manualBackgroundButton) - self.widgetsWithShortcut['Manual background'] = self.manualBackgroundButton - - self.manualBackgroundAction = editToolBar.addWidget( - self.manualBackgroundButton - ) - + self.widgetsWithShortcut["Manual background"] = self.manualBackgroundButton + + self.manualBackgroundAction = editToolBar.addWidget(self.manualBackgroundButton) + self.delObjsOutSegmMaskAction = QAction( - QIcon(":del_objs_out_segm.svg"), - 'Select a segmentation file and delete all objects on the background', - self.host + QIcon(":del_objs_out_segm.svg"), + "Select a segmentation file and delete all objects on the background", + self, ) - self.delObjsOutSegmMaskAction.setShortcut('I') - self.widgetsWithShortcut['Delete all objects outside segm'] = ( + self.delObjsOutSegmMaskAction.setShortcut("I") + self.widgetsWithShortcut["Delete all objects outside segm"] = ( self.delObjsOutSegmMaskAction ) editToolBar.addAction(self.delObjsOutSegmMaskAction) - self.hullContToolButton = QToolButton(self.host) + self.hullContToolButton = QToolButton(self) self.hullContToolButton.setIcon(QIcon(":hull.svg")) self.hullContToolButton.setCheckable(True) - self.hullContToolButton.setShortcut('O') + self.hullContToolButton.setShortcut("O") self.hullContToolButton.action = editToolBar.addWidget(self.hullContToolButton) self.checkableButtons.append(self.hullContToolButton) self.checkableQButtonsGroup.addButton(self.hullContToolButton) self.functionsNotTested3D.append(self.hullContToolButton) - self.widgetsWithShortcut['Hull contour'] = self.hullContToolButton + self.widgetsWithShortcut["Hull contour"] = self.hullContToolButton - self.fillHolesToolButton = QToolButton(self.host) + self.fillHolesToolButton = QToolButton(self) self.fillHolesToolButton.setIcon(QIcon(":fill_holes.svg")) self.fillHolesToolButton.setCheckable(True) - self.fillHolesToolButton.setShortcut('F') + self.fillHolesToolButton.setShortcut("F") self.fillHolesToolButton.action = editToolBar.addWidget( self.fillHolesToolButton ) self.checkableButtons.append(self.fillHolesToolButton) self.checkableQButtonsGroup.addButton(self.fillHolesToolButton) self.functionsNotTested3D.append(self.fillHolesToolButton) - self.widgetsWithShortcut['Fill holes'] = self.fillHolesToolButton + self.widgetsWithShortcut["Fill holes"] = self.fillHolesToolButton - self.moveLabelToolButton = QToolButton(self.host) + self.moveLabelToolButton = QToolButton(self) self.moveLabelToolButton.setIcon(QIcon(":moveLabel.svg")) self.moveLabelToolButton.setCheckable(True) - self.moveLabelToolButton.setShortcut('P') - self.moveLabelToolButton.action = editToolBar.addWidget(self.moveLabelToolButton) + self.moveLabelToolButton.setShortcut("P") + self.moveLabelToolButton.action = editToolBar.addWidget( + self.moveLabelToolButton + ) self.checkableButtons.append(self.moveLabelToolButton) self.checkableQButtonsGroup.addButton(self.moveLabelToolButton) - self.widgetsWithShortcut['Move label'] = self.moveLabelToolButton + self.widgetsWithShortcut["Move label"] = self.moveLabelToolButton - self.expandLabelToolButton = QToolButton(self.host) + self.expandLabelToolButton = QToolButton(self) self.expandLabelToolButton.setIcon(QIcon(":expandLabel.svg")) self.expandLabelToolButton.setCheckable(True) - self.expandLabelToolButton.setShortcut('E') - self.expandLabelToolButton.action = editToolBar.addWidget(self.expandLabelToolButton) + self.expandLabelToolButton.setShortcut("E") + self.expandLabelToolButton.action = editToolBar.addWidget( + self.expandLabelToolButton + ) self.expandLabelToolButton.hide() self.checkableButtons.append(self.expandLabelToolButton) self.LeftClickButtons.append(self.expandLabelToolButton) self.checkableQButtonsGroup.addButton(self.expandLabelToolButton) - self.widgetsWithShortcut['Expand/shrink label'] = self.expandLabelToolButton + self.widgetsWithShortcut["Expand/shrink label"] = self.expandLabelToolButton - self.editIDbutton = QToolButton(self.host) + self.editIDbutton = QToolButton(self) self.editIDbutton.setIcon(QIcon(":edit-id.svg")) self.editIDbutton.setCheckable(True) - self.editIDbutton.setShortcut('N') + self.editIDbutton.setShortcut("N") editToolBar.addWidget(self.editIDbutton) self.checkableButtons.append(self.editIDbutton) self.checkableQButtonsGroup.addButton(self.editIDbutton) - self.widgetsWithShortcut['Edit ID'] = self.editIDbutton + self.widgetsWithShortcut["Edit ID"] = self.editIDbutton - self.separateBudButton = QToolButton(self.host) + self.separateBudButton = QToolButton(self) self.separateBudButton.setIcon(QIcon(":separate-bud.svg")) self.separateBudButton.setCheckable(True) - self.separateBudButton.setShortcut('S') + self.separateBudButton.setShortcut("S") self.separateBudButton.action = editToolBar.addWidget(self.separateBudButton) self.checkableButtons.append(self.separateBudButton) self.checkableQButtonsGroup.addButton(self.separateBudButton) # self.functionsNotTested3D.append(self.separateBudButton) - self.widgetsWithShortcut['Separate objects'] = self.separateBudButton + self.widgetsWithShortcut["Separate objects"] = self.separateBudButton - self.mergeIDsButton = QToolButton(self.host) + self.mergeIDsButton = QToolButton(self) self.mergeIDsButton.setIcon(QIcon(":merge-IDs.svg")) self.mergeIDsButton.setCheckable(True) - self.mergeIDsButton.setShortcut('M') + self.mergeIDsButton.setShortcut("M") self.mergeIDsButton.action = editToolBar.addWidget(self.mergeIDsButton) self.checkableButtons.append(self.mergeIDsButton) self.checkableQButtonsGroup.addButton(self.mergeIDsButton) # self.functionsNotTested3D.append(self.mergeIDsButton) - self.widgetsWithShortcut['Merge objects'] = self.mergeIDsButton + self.widgetsWithShortcut["Merge objects"] = self.mergeIDsButton - self.keepIDsButton = QToolButton(self.host) + self.keepIDsButton = QToolButton(self) self.keepIDsButton.setIcon(QIcon(":keep_objects.svg")) self.keepIDsButton.setCheckable(True) self.keepIDsButton.action = editToolBar.addWidget(self.keepIDsButton) - self.keepIDsButton.setShortcut('K') + self.keepIDsButton.setShortcut("K") self.checkableButtons.append(self.keepIDsButton) self.checkableQButtonsGroup.addButton(self.keepIDsButton) # self.functionsNotTested3D.append(self.keepIDsButton) - self.widgetsWithShortcut['Select objects to keep'] = self.keepIDsButton + self.widgetsWithShortcut["Select objects to keep"] = self.keepIDsButton - self.whitelistIDsButton = QToolButton(self.host) + self.whitelistIDsButton = QToolButton(self) self.whitelistIDsButton.setIcon(QIcon(":whitelist.svg")) self.whitelistIDsButton.setCheckable(True) - self.whitelistIDsButton.action = editToolBar.addWidget( - self.whitelistIDsButton - ) - self.whitelistIDsButton.setShortcut('Ctrl+K') + self.whitelistIDsButton.action = editToolBar.addWidget(self.whitelistIDsButton) + self.whitelistIDsButton.setShortcut("Ctrl+K") self.checkableButtons.append(self.whitelistIDsButton) self.checkableQButtonsGroup.addButton(self.whitelistIDsButton) self.LeftClickButtons.append(self.whitelistIDsButton) # self.functionsNotTested3D.append(self.whitelistIDsButton) - self.widgetsWithShortcut['Select objects to add to a tracking whitelist'] = ( + self.widgetsWithShortcut["Select objects to add to a tracking whitelist"] = ( self.whitelistIDsButton ) - self.binCellButton = QToolButton(self.host) + self.binCellButton = QToolButton(self) self.binCellButton.setIcon(QIcon(":bin.svg")) self.binCellButton.setCheckable(True) # self.binCellButton.setShortcut('R') @@ -464,40 +427,38 @@ def gui_createToolBars(self): self.checkableQButtonsGroup.addButton(self.binCellButton) # self.functionsNotTested3D.append(self.binCellButton) - self.manualTrackingButton = QToolButton(self.host) + self.manualTrackingButton = QToolButton(self) self.manualTrackingButton.setIcon(QIcon(":manual_tracking.svg")) self.manualTrackingButton.setCheckable(True) - self.manualTrackingButton.setShortcut('T') + self.manualTrackingButton.setShortcut("T") self.checkableQButtonsGroup.addButton(self.manualTrackingButton) self.checkableButtons.append(self.manualTrackingButton) - self.widgetsWithShortcut['Manual tracking'] = self.manualTrackingButton + self.widgetsWithShortcut["Manual tracking"] = self.manualTrackingButton - self.ripCellButton = QToolButton(self.host) + self.ripCellButton = QToolButton(self) self.ripCellButton.setIcon(QIcon(":rip.svg")) self.ripCellButton.setCheckable(True) - self.ripCellButton.setShortcut('D') + self.ripCellButton.setShortcut("D") self.ripCellButton.action = editToolBar.addWidget(self.ripCellButton) self.checkableButtons.append(self.ripCellButton) self.checkableQButtonsGroup.addButton(self.ripCellButton) self.functionsNotTested3D.append(self.ripCellButton) - self.widgetsWithShortcut['Annotate cell as dead'] = self.ripCellButton + self.widgetsWithShortcut["Annotate cell as dead"] = self.ripCellButton editToolBar.addAction(self.addDelRoiAction) # editToolBar.addAction(self.addDelPolyLineRoiAction) - + self.addDelPolyLineRoiAction = editToolBar.addWidget( self.addDelPolyLineRoiButton ) - self.addDelPolyLineRoiAction.roiType = 'polyline' - + self.addDelPolyLineRoiAction.roiType = "polyline" + editToolBar.addAction(self.delBorderObjAction) self.delBorderObjAction.button = editToolBar.widgetForAction( self.delBorderObjAction ) editToolBar.addAction(self.delNewObjAction) - self.delNewObjAction.button = editToolBar.widgetForAction( - self.delNewObjAction - ) + self.delNewObjAction.button = editToolBar.widgetForAction(self.delNewObjAction) self.addDelRoiAction.toolbar = editToolBar self.functionsNotTested3D.append(self.addDelRoiAction) @@ -507,20 +468,18 @@ def gui_createToolBars(self): self.delBorderObjAction.toolbar = editToolBar self.functionsNotTested3D.append(self.delBorderObjAction) - + self.delNewObjAction.toolbar = editToolBar # self.functionsNotTested3D.append(self.delNewObjAction) so id this doesnt work in 3d i dont know anymore editToolBar.addAction(self.repeatTrackingAction) - - self.manualTrackingAction = editToolBar.addWidget( - self.manualTrackingButton - ) + + self.manualTrackingAction = editToolBar.addWidget(self.manualTrackingButton) self.functionsNotTested3D.append(self.repeatTrackingAction) self.functionsNotTested3D.append(self.manualTrackingAction) - self.reinitLastSegmFrameAction = QAction(self.host) + self.reinitLastSegmFrameAction = QAction(self) self.reinitLastSegmFrameAction.setIcon(QIcon(":reinitLastSegm.svg")) self.reinitLastSegmFrameAction.setVisible(False) editToolBar.addAction(self.reinitLastSegmFrameAction) @@ -528,54 +487,67 @@ def gui_createToolBars(self): self.reinitLastSegmFrameAction.toolbar = editToolBar self.functionsNotTested3D.append(self.reinitLastSegmFrameAction) - - self.editLin_TreeBar = widgets.ToolBar("Lin Tree Edit", self.host) + self.editLin_TreeBar = widgets.ToolBar("Lin Tree Edit", self) self.editLin_TreeBar.setContextMenuPolicy(Qt.PreventContextMenu) - + self.addToolBar(self.editLin_TreeBar) self.editLin_TreeGroup = QButtonGroup() self.editLin_TreeGroup.setExclusive(True) - self.findNextMotherButton = QToolButton(self.host) + self.findNextMotherButton = QToolButton(self) self.findNextMotherButton.setIcon(QIcon(":magnGlass.svg")) self.findNextMotherButton.setCheckable(True) self.editLin_TreeBar.addWidget(self.findNextMotherButton) self.editLin_TreeGroup.addButton(self.findNextMotherButton) - self.findNextMotherButton.setShortcut('F') - self.widgetsWithShortcut['Find next potential mother (lineage tree)'] = self.findNextMotherButton + self.findNextMotherButton.setShortcut("F") + self.widgetsWithShortcut["Find next potential mother (lineage tree)"] = ( + self.findNextMotherButton + ) - self.unknownLineageButton = QToolButton(self.host) + self.unknownLineageButton = QToolButton(self) self.unknownLineageButton.setIcon(QIcon(":history.svg")) self.unknownLineageButton.setCheckable(True) self.editLin_TreeBar.addWidget(self.unknownLineageButton) self.editLin_TreeGroup.addButton(self.unknownLineageButton) - self.unknownLineageButton.setShortcut('U') - self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.unknownLineageButton + self.unknownLineageButton.setShortcut("U") + self.widgetsWithShortcut["Unknown lineage (lineage tree)"] = ( + self.unknownLineageButton + ) - self.noToolLinTreeButton = QToolButton(self.host) + self.noToolLinTreeButton = QToolButton(self) self.noToolLinTreeButton.setIcon(QIcon(":arrow_cursor.svg")) self.noToolLinTreeButton.setCheckable(True) self.editLin_TreeBar.addWidget(self.noToolLinTreeButton) self.editLin_TreeGroup.addButton(self.noToolLinTreeButton) - self.noToolLinTreeButton.setShortcut('N') - self.widgetsWithShortcut['No tool (lineage tree)'] = self.noToolLinTreeButton + self.noToolLinTreeButton.setShortcut("N") + self.widgetsWithShortcut["No tool (lineage tree)"] = self.noToolLinTreeButton - self.propagateLinTreeButton = QToolButton(self.host) + self.propagateLinTreeButton = QToolButton(self) self.propagateLinTreeButton.setIcon(QIcon(":compute.svg")) self.editLin_TreeBar.addWidget(self.propagateLinTreeButton) - self.propagateLinTreeButton.setShortcut('P') - self.widgetsWithShortcut['Propagate (lineage tree)'] = self.propagateLinTreeButton + self.propagateLinTreeButton.setShortcut("P") + self.widgetsWithShortcut["Propagate (lineage tree)"] = ( + self.propagateLinTreeButton + ) self.propagateLinTreeButton.clicked.connect(self.propagateLinTreeAction) - self.viewLinTreeInfoButton = QToolButton(self.host) + self.viewLinTreeInfoButton = QToolButton(self) self.viewLinTreeInfoButton.setIcon(QIcon(":addCustomAnnotation.svg")) self.editLin_TreeBar.addWidget(self.viewLinTreeInfoButton) - self.viewLinTreeInfoButton.setShortcut('S') - self.widgetsWithShortcut['View Changes (lineage tree)'] = self.viewLinTreeInfoButton + self.viewLinTreeInfoButton.setShortcut("S") + self.widgetsWithShortcut["View Changes (lineage tree)"] = ( + self.viewLinTreeInfoButton + ) self.viewLinTreeInfoButton.clicked.connect(self.viewLinTreeInfoAction) - - self.modeItems = list(self.mode_items()) + modes_available = [ + "Segmentation and Tracking", + "Cell cycle analysis", + "Viewer", + "Custom annotations", + "Normal division: Lineage tree", + ] + self.modeItems = modes_available self.modeActionGroup = QActionGroup(self.modeMenu) for mode in self.modeItems: @@ -583,7 +555,7 @@ def gui_createToolBars(self): action.setCheckable(True) self.modeActionGroup.addAction(action) self.modeMenu.addAction(action) - if mode == 'Viewer': + if mode == "Viewer": action.setChecked(True) self.editToolBar = editToolBar @@ -592,16 +564,3 @@ def gui_createToolBars(self): self.editLin_TreeBar.setVisible(False) self.gui_createAnnotateToolbar() - - def gui_createAnnotateToolbar(self): - # Edit toolbar - self.annotateToolbar = widgets.ToolBar( - "Custom annotations", - self.host, - ) - self.annotateToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - self.addToolBar(Qt.LeftToolBarArea, self.annotateToolbar) - self.annotateToolbar.addAction(self.loadCustomAnnotationsAction) - self.annotateToolbar.addAction(self.addCustomAnnotationAction) - self.annotateToolbar.addAction(self.viewAllCustomAnnotAction) - self.annotateToolbar.setVisible(False) \ No newline at end of file diff --git a/cellacdc/mixins/measurements.py b/cellacdc/mixins/measurements.py index 46a7f9465..f5e317d3f 100644 --- a/cellacdc/mixins/measurements.py +++ b/cellacdc/mixins/measurements.py @@ -7,150 +7,128 @@ from cellacdc import apps, cli, favourite_func_metrics_csv_path, widgets -class MeasurementsView: +class MeasurementsMixin: """Qt-facing adapter around measurement view-model contracts.""" """Headless measurement calculation and setup rules.""" - def rotational_volume( - self, - obj, - physical_size_y=1, - physical_size_x=1, - logger=None, - ): - return _calc_rot_vol( - obj, - physical_size_y, - physical_size_x, - logger=logger, + def _favourite_metric_functions(self): + try: + df_favourite_funcs = pd.read_csv(favourite_func_metrics_csv_path) + return df_favourite_funcs["favourite_func_name"].to_list() + except Exception: + return None + + def _log_removed_measurements(self, del_cols, del_rps): + del_cols_format = [f" * {colname}" for colname in del_cols] + del_rps_format = [f" * {colname}" for colname in del_rps] + del_cols_format.extend(del_rps_format) + del_cols_format = "\n".join(del_cols_format) + self.logger.info(del_cols_format) + + def _remove_existing_unchecked_measurements(self): + self.logger.info("Removing existing unchecked measurements...") + del_cols = self.measurementsWin.existingUncheckedColnames + del_rps = self.measurementsWin.existingUncheckedRps + self._log_removed_measurements(del_cols, del_rps) + for pos_data in self.data: + for data_dict in pos_data.allData_li: + data_dict["acdc_df"] = self.drop_unchecked_measurements( + data_dict["acdc_df"], + del_cols, + del_rps, + ) + + def _set_metrics(self, measurements_win): + self._measurements_kernel.set_metrics_from_set_measurements_dialog( + measurements_win ) + for ch_name in self._measurements_kernel.chNamesToProcess: + if ch_name not in self.notLoadedChNames: + continue - def custom_metrics_instructions(self): - return measurements.add_metrics_instructions() + success = self.loadFluo_cb(fluo_channels=[ch_name]) + if not success: + continue - def metrics_examples_path(self): - return measurements.metrics_path + def add_combine_metric(self): + pos_data = self.data[self.pos_i] + is_zstack = pos_data.SizeZ > 1 + win = apps.combineMetricsEquationDialog( + self.ch_names, + is_zstack, + self.isSegm3D, + parent=self, + ) + win.sigOk.connect(self.save_combine_metrics_to_pos_data) + win.exec_() + win.sigOk.disconnect() + + def add_custom_metric(self, checked=False): + txt = self.custom_metrics_instructions() + metrics_path = self.metrics_examples_path() + msg = widgets.myMessageBox() + msg.addShowInFileManagerButton(metrics_path, "Show example...") + title = "Add custom metrics instructions" + msg.information(self, title, txt, buttonsTexts=("Ok",)) def all_acdc_df_columns(self, all_pos_data): columns = set() for pos_data in all_pos_data: for data_dict in pos_data.allData_li: - acdc_df = data_dict['acdc_df'] + acdc_df = data_dict["acdc_df"] if acdc_df is None: continue columns.update(acdc_df.columns) return columns - def not_loaded_channels(self, all_channel_names, loaded_channel_names): - return [c for c in all_channel_names if c not in loaded_channel_names] + def custom_metrics_instructions(self): + return measurements.add_metrics_instructions() def drop_unchecked_measurements(self, acdc_df, columns, regionprops): if acdc_df is None: return None - acdc_df = acdc_df.drop(columns=columns, errors='ignore') + acdc_df = acdc_df.drop(columns=columns, errors="ignore") for col_rp in regionprops: - drop_df_rp = acdc_df.filter(regex=fr'{col_rp}.*', axis=1) + drop_df_rp = acdc_df.filter(regex=rf"{col_rp}.*", axis=1) drop_cols_rp = drop_df_rp.columns - acdc_df = acdc_df.drop(columns=drop_cols_rp, errors='ignore') + acdc_df = acdc_df.drop(columns=drop_cols_rp, errors="ignore") return acdc_df - - def __init__(self, host): - self.host = host - def init_metrics_to_save(self, pos_data): - self.host._measurements_kernel._init_metrics_to_save(pos_data) - def init_metrics(self): - self.host.logger.info('Initializing measurements...') - pos_data = self.host.data[self.host.pos_i] - self.host._measurements_kernel = cli.ComputeMeasurementsKernel( - self.host.logger, self.host.log_path, False - ) - self.host._measurements_kernel.init_args( - pos_data.chNames, pos_data.getSegmEndname() - ) - self.host._measurements_kernel._init_metrics( - pos_data, self.host.isSegm3D - ) - - def show_set_measurements(self, checked=False, qparent=None): - qparent = qparent if qparent is not None else self.host - if self.host.measurementsWin is not None: - self.host.measurementsWin.show() - self.host.measurementsWin.raise_() - self.host.measurementsWin.activateWindow() - return - - favourite_funcs = self._favourite_metric_functions() - pos_data = self.host.data[self.host.pos_i] - all_pos_acdc_df_cols = self.all_acdc_df_columns( - self.host.data - ) - loaded_ch_names = pos_data.setLoadedChannelNames(returnList=True) - pos_data.fluo_data_dict.pop(self.host.user_ch_name, None) - if self.host.user_ch_name not in loaded_ch_names: - loaded_ch_names.insert(0, self.host.user_ch_name) - not_loaded_ch_names = self.not_loaded_channels( - self.host.ch_names, - loaded_ch_names, - ) - self.host.notLoadedChNames = not_loaded_ch_names - self.host.measurementsWin = apps.SetMeasurementsDialog( - loaded_ch_names, - not_loaded_ch_names, - pos_data.SizeZ > 1, - self.host.isSegm3D, - favourite_funcs=favourite_funcs, - allPos_acdc_df_cols=list(all_pos_acdc_df_cols), - acdc_df_path=pos_data.images_path, - posData=pos_data, - addCombineMetricCallback=self.add_combine_metric, - allPosData=self.host.data, - parent=qparent, - state=self.host.setMeasWinState, - ) - self.host.measurementsWin.sigCancel.connect( - self.set_measurements_cancelled + self.logger.info("Initializing measurements...") + pos_data = self.data[self.pos_i] + self._measurements_kernel = cli.ComputeMeasurementsKernel( + self.logger, self.log_path, False ) - self.host.measurementsWin.sigClosed.connect(self.set_measurements) - self.host.measurementsWin.show() + self._measurements_kernel.init_args(pos_data.chNames, pos_data.getSegmEndname()) + self._measurements_kernel._init_metrics(pos_data, self.isSegm3D) - def set_measurements_cancelled(self): - self.host.measurementsWin = None + def init_metrics_to_save(self, pos_data): + self._measurements_kernel._init_metrics_to_save(pos_data) - def set_measurements(self): - if self.host.measurementsWin.delExistingCols: - self._remove_existing_unchecked_measurements() - self.host.setMeasWinState = self.host.measurementsWin.state() - self.host.logger.info('Setting measurements...') - self._set_metrics(self.host.measurementsWin) - self.host.logger.info('Metrics successfully set.') - self.host.measurementsWin = None + def metrics_examples_path(self): + return measurements.metrics_path - def add_custom_metric(self, checked=False): - txt = self.custom_metrics_instructions() - metrics_path = self.metrics_examples_path() - msg = widgets.myMessageBox() - msg.addShowInFileManagerButton(metrics_path, 'Show example...') - title = 'Add custom metrics instructions' - msg.information(self.host, title, txt, buttonsTexts=('Ok',)) + def not_loaded_channels(self, all_channel_names, loaded_channel_names): + return [c for c in all_channel_names if c not in loaded_channel_names] - def add_combine_metric(self): - pos_data = self.host.data[self.host.pos_i] - is_zstack = pos_data.SizeZ > 1 - win = apps.combineMetricsEquationDialog( - self.host.ch_names, - is_zstack, - self.host.isSegm3D, - parent=self.host, + def rotational_volume( + self, + obj, + physical_size_y=1, + physical_size_x=1, + logger=None, + ): + return _calc_rot_vol( + obj, + physical_size_y, + physical_size_x, + logger=logger, ) - win.sigOk.connect(self.save_combine_metrics_to_pos_data) - win.exec_() - win.sigOk.disconnect() def save_combine_metrics_to_pos_data(self, window): - for pos_data in self.host.data: + for pos_data in self.data: equations_dict, is_mixed_channels = window.getEquationsDict() for new_col_name, equation in equations_dict.items(): pos_data.addEquationCombineMetrics( @@ -158,59 +136,64 @@ def save_combine_metrics_to_pos_data(self, window): ) pos_data.saveCombineMetrics() - if self.host.measurementsWin is None: + if self.measurementsWin is None: return - self.host.measurementsWinState = self.host.measurementsWin.state() - self.host.measurementsWin.close() + self.measurementsWinState = self.measurementsWin.state() + self.measurementsWin.close() self.show_set_measurements() - self.host.measurementsWin.restoreState( - self.host.measurementsWinState - ) - - def set_metrics_func(self): - pos_data = self.host.data[self.host.pos_i] - self.host._measurements_kernel._set_metrics_func_from_posData( - pos_data - ) + self.measurementsWin.restoreState(self.measurementsWinState) - def _set_metrics(self, measurements_win): - self.host._measurements_kernel.set_metrics_from_set_measurements_dialog( - measurements_win - ) - for ch_name in self.host._measurements_kernel.chNamesToProcess: - if ch_name not in self.host.notLoadedChNames: - continue + def set_measurements(self): + if self.measurementsWin.delExistingCols: + self._remove_existing_unchecked_measurements() + self.setMeasWinState = self.measurementsWin.state() + self.logger.info("Setting measurements...") + self._set_metrics(self.measurementsWin) + self.logger.info("Metrics successfully set.") + self.measurementsWin = None - success = self.host.loadFluo_cb(fluo_channels=[ch_name]) - if not success: - continue + def set_measurements_cancelled(self): + self.measurementsWin = None - def _remove_existing_unchecked_measurements(self): - self.host.logger.info('Removing existing unchecked measurements...') - del_cols = self.host.measurementsWin.existingUncheckedColnames - del_rps = self.host.measurementsWin.existingUncheckedRps - self._log_removed_measurements(del_cols, del_rps) - for pos_data in self.host.data: - for data_dict in pos_data.allData_li: - data_dict['acdc_df'] = ( - self.drop_unchecked_measurements( - data_dict['acdc_df'], - del_cols, - del_rps, - ) - ) + def set_metrics_func(self): + pos_data = self.data[self.pos_i] + self._measurements_kernel._set_metrics_func_from_posData(pos_data) - def _log_removed_measurements(self, del_cols, del_rps): - del_cols_format = [f' * {colname}' for colname in del_cols] - del_rps_format = [f' * {colname}' for colname in del_rps] - del_cols_format.extend(del_rps_format) - del_cols_format = '\n'.join(del_cols_format) - self.host.logger.info(del_cols_format) + def show_set_measurements(self, checked=False, qparent=None): + qparent = qparent if qparent is not None else self + if self.measurementsWin is not None: + self.measurementsWin.show() + self.measurementsWin.raise_() + self.measurementsWin.activateWindow() + return - def _favourite_metric_functions(self): - try: - df_favourite_funcs = pd.read_csv(favourite_func_metrics_csv_path) - return df_favourite_funcs['favourite_func_name'].to_list() - except Exception: - return None \ No newline at end of file + favourite_funcs = self._favourite_metric_functions() + pos_data = self.data[self.pos_i] + all_pos_acdc_df_cols = self.all_acdc_df_columns(self.data) + loaded_ch_names = pos_data.setLoadedChannelNames(returnList=True) + pos_data.fluo_data_dict.pop(self.user_ch_name, None) + if self.user_ch_name not in loaded_ch_names: + loaded_ch_names.insert(0, self.user_ch_name) + not_loaded_ch_names = self.not_loaded_channels( + self.ch_names, + loaded_ch_names, + ) + self.notLoadedChNames = not_loaded_ch_names + self.measurementsWin = apps.SetMeasurementsDialog( + loaded_ch_names, + not_loaded_ch_names, + pos_data.SizeZ > 1, + self.isSegm3D, + favourite_funcs=favourite_funcs, + allPos_acdc_df_cols=list(all_pos_acdc_df_cols), + acdc_df_path=pos_data.images_path, + posData=pos_data, + addCombineMetricCallback=self.add_combine_metric, + allPosData=self.data, + parent=qparent, + state=self.setMeasWinState, + ) + self.measurementsWin.sigCancel.connect(self.set_measurements_cancelled) + self.measurementsWin.sigClosed.connect(self.set_measurements) + self.measurementsWin.show() diff --git a/cellacdc/mixins/mode_controls.py b/cellacdc/mixins/mode_controls.py index d432f174e..fdaab1c9c 100644 --- a/cellacdc/mixins/mode_controls.py +++ b/cellacdc/mixins/mode_controls.py @@ -7,100 +7,16 @@ from cellacdc import disableWindow -class ModeControlsView: +class ModeControlsMixin: """Qt-facing adapter around mode-control decisions.""" """Headless decisions for mode toolbar and action state.""" - viewer_mode = 'Viewer' - segmentation_mode = 'Segmentation and Tracking' - snapshot_mode = 'Snapshot' - cca_mode = 'Cell cycle analysis' - custom_annotations_mode = 'Custom annotations' - - def should_start_blinking( - self, - mode: str, - *, - ruler_checked: bool = False, - ) -> bool: - return mode == self.viewer_mode and not ruler_checked - - def blink_styles(self, flag: bool) -> tuple[str, bool]: - if flag: - return 'background-color: orange', False - return 'background-color: none', True - - def should_store_on_mode_change(self, previous_mode: str) -> bool: - return previous_mode != self.viewer_mode - - def is_cca_mode(self, mode: str) -> bool: - return mode == self.cca_mode - - def undo_redo_target(self, mode: str) -> str: - if mode in {self.segmentation_mode, self.snapshot_mode}: - return 'labels' - if mode == self.cca_mode: - return 'cca' - if mode == self.custom_annotations_mode: - return 'custom_annotations' - return 'disabled' - - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def nonViewerEditMenuOpened(self): - mode = str(self.modeComboBox.currentText()) - if self.should_start_blinking( - mode, - ruler_checked=self.rulerButton.isChecked(), - ): - self.startBlinkingModeCB() - - def startBlinkingModeCB(self): - try: - self.timer.stop() - self.stopBlinkTimer.stop() - except Exception as e: - pass - if not self.should_start_blinking( - str(self.modeComboBox.currentText()), - ruler_checked=self.rulerButton.isChecked(), - ): - return - self.timer = QTimer(self.host) - self.timer.timeout.connect(self.blinkModeComboBox) - self.timer.start(200) - self.stopBlinkTimer = QTimer(self.host) - self.stopBlinkTimer.timeout.connect(self.stopBlinkingCB) - self.stopBlinkTimer.start(2000) - - def blinkModeComboBox(self): - style, next_flag = self.blink_styles(self.flag) - self.modeComboBox.setStyleSheet(style) - self.flag = next_flag - - def stopBlinkingCB(self): - self.timer.stop() - self.modeComboBox.setStyleSheet('background-color: none') - - def setEnabledCcaToolbar(self, enabled=False): - self.manuallyEditCcaAction.setDisabled(False) - self.viewCcaTableAction.setDisabled(False) - self.ccaToolBar.setVisible(enabled) - for action in self.ccaToolBar.actions(): - button = self.ccaToolBar.widgetForAction(action) - action.setVisible(enabled) - button.setEnabled(enabled) + viewer_mode = "Viewer" + segmentation_mode = "Segmentation and Tracking" + snapshot_mode = "Snapshot" + cca_mode = "Cell cycle analysis" + custom_annotations_mode = "Custom annotations" # def setEnabledCcaToolbar(self, enabled=False): # self.manuallyEditCcaAction.setDisabled(False) @@ -111,143 +27,49 @@ def setEnabledCcaToolbar(self, enabled=False): # action.setVisible(enabled) # button.setEnabled(enabled) - def setEnabledEditToolbarButton(self, enabled=False): - for action in self.segmActions: - action.setEnabled(enabled) - - for action in self.segmActionsVideo: - action.setEnabled(enabled) - - self.relabelSequentialAction.setEnabled(enabled) - self.repeatTrackingMenuAction.setEnabled(enabled) - self.repeatTrackingVideoAction.setEnabled(enabled) - self.postProcessSegmAction.setEnabled(enabled) - self.autoSegmAction.setEnabled(enabled) - self.editToolBar.setVisible(enabled) - mode = self.modeComboBox.currentText() - ccaON = self.is_cca_mode(mode) - for action in self.editToolBar.actions(): - button = self.editToolBar.widgetForAction(action) - # Keep binCellButton active in cca mode - if button==self.binCellButton and not enabled and ccaON: - action.setVisible(True) - button.setEnabled(True) - else: - action.setVisible(enabled) - button.setEnabled(enabled) - if not enabled: - self.setUncheckedAllButtons() - - def setEnabledFileToolbar(self, enabled): - for action in self.fileToolBar.actions(): - button = self.fileToolBar.widgetForAction(action) - if action == self.openFolderAction or action == self.newAction: - continue - if action == self.manageVersionsAction: - continue - if action == self.openFileAction: - continue - action.setEnabled(enabled) - button.setEnabled(enabled) - - def reconnectUndoRedo(self): - try: - self.undoAction.triggered.disconnect() - self.redoAction.triggered.disconnect() - except Exception as e: - pass - mode = self.modeComboBox.currentText() - target = self.undo_redo_target(mode) - if target == 'labels': - self.undoAction.triggered.connect(self.undo_redo_view.undo) - self.redoAction.triggered.connect(self.undo_redo_view.redo) - elif target == 'cca': - self.undoAction.triggered.connect(self.undo_redo_view.UndoCca) - elif target == 'custom_annotations': - self.undoAction.triggered.connect( - self.undo_redo_view.undoCustomAnnotation - ) + def blinkModeComboBox(self): + if self.flag: + self.modeComboBox.setStyleSheet("background-color: orange") else: - self.undoAction.setDisabled(True) - self.redoAction.setDisabled(True) - - def enableSizeSpinbox(self, enabled): - self.brushSizeLabelAction.setVisible(enabled) - self.brushSizeAction.setVisible(enabled) - self.brushAutoFillAction.setVisible(enabled) - self.brushAutoHideAction.setVisible(enabled) - self.brushEraserToolBar.setVisible(enabled) - self.disableNonFunctionalButtons() - - def clearComboBoxFocus(self, mode): - # Remove focus from modeComboBox to avoid the key_up changes its value - self.sender().clearFocus() - try: - self.timer.stop() - self.modeComboBox.setStyleSheet('background-color: none') - except Exception as e: - pass - - def updateModeMenuAction(self): - self.modeActionGroup.triggered.disconnect() - for action in self.modeActionGroup.actions(): - if action.text() != self.modeComboBox.currentText(): - continue - action.setChecked(True) - break - self.modeActionGroup.triggered.connect(self.changeModeFromMenu) - - def changeModeFromMenu(self, action): - self.modeComboBox.setCurrentText(action.text()) - - def restorePrevAnnotOptions(self): - if self.prevAnnotOptions is None: - return - self.restoreAnnotOptions_ax1(options=self.prevAnnotOptions) - self.setDrawAnnotComboboxText() - self.prevAnnotOptions = None - - def uncheckAllButtonsFromButtonGroup(self, buttonGroup): - for button in buttonGroup.buttons(): - if not button.isCheckable(): - continue - - if not button.isChecked(): - continue + self.modeComboBox.setStyleSheet("background-color: none") + self.flag = not self.flag - button.setChecked(False) + def blink_styles(self, flag: bool) -> tuple[str, bool]: + if flag: + return "background-color: orange", False + return "background-color: none", True @disableWindow def changeMode(self, text): self.reconnectUndoRedo() self.updateModeMenuAction() - self.custom_annotations_view.clearCustomAnnot() + self.clearCustomAnnot() posData = self.data[self.pos_i] mode = text prevMode = self.modeComboBox.previousText() self.annotateToolbar.setVisible(False) - if self.should_store_on_mode_change(prevMode): + if prevMode != "Viewer": self.store_data(autosave=True) self.copyLostObjButton.setChecked(False) self.stopCcaIntegrityCheckerWorker() self.setAutoSaveSegmentationEnabled(False) self.setAutoSaveAnnotationsEnabled(False) - if prevMode == 'Normal division: Lineage tree': + if prevMode == "Normal division: Lineage tree": self.askLineageTreeChanges() self.lineage_tree = None self.editLin_TreeBar.setVisible(False) self.uncheckAllButtonsFromButtonGroup(self.editLin_TreeGroup) - elif prevMode == 'Cell cycle analysis': + elif prevMode == "Cell cycle analysis": self.setEnabledCcaToolbar(enabled=False) - if mode == 'Segmentation and Tracking': + if mode == "Segmentation and Tracking": self.setAutoSaveSegmentationEnabled(True) self.setSwitchViewedPlaneDisabled(True) self.trackingMenu.setDisabled(False) self.modeToolBar.setVisible(True) - self.lastTrackedFrameLabel.setText('') + self.lastTrackedFrameLabel.setText("") self.initSegmTrackMode() self.setEnabledEditToolbarButton(enabled=True) self.addExistingDelROIs() @@ -260,7 +82,7 @@ def changeMode(self, text): self.store_cca_df() self.restorePrevAnnotOptions() self.whitelistViewOGIDs(False) - elif mode == 'Cell cycle analysis': + elif mode == "Cell cycle analysis": self.setAutoSaveAnnotationsEnabled(True) self.setSwitchViewedPlaneDisabled(True) self.startCcaIntegrityCheckerWorker() @@ -281,7 +103,7 @@ def changeMode(self, text): self.removeAlldelROIsCurrentFrame() self.setAnnotOptionsCcaMode() self.clearGhost() - elif mode == 'Viewer': + elif mode == "Viewer": self.autoSaveTimer.stop() self.setSwitchViewedPlaneDisabled(False) self.modeToolBar.setVisible(True) @@ -290,12 +112,12 @@ def changeMode(self, text): self.setEnabledEditToolbarButton(enabled=False) self.setEnabledCcaToolbar(enabled=False) self.removeAlldelROIsCurrentFrame() - self.status_hover_view.set_status_bar_label() + self.setStatusBarLabel() self.navigateScrollBar.setMaximum(posData.SizeT) self.navSpinBox.setMaximum(posData.SizeT) self.clearGhost() self.computeAllContours() - elif mode == 'Custom annotations': + elif mode == "Custom annotations": self.setAutoSaveAnnotationsEnabled(True) self.setSwitchViewedPlaneDisabled(True) self.modeToolBar.setVisible(True) @@ -306,16 +128,18 @@ def changeMode(self, text): self.removeAlldelROIsCurrentFrame() self.annotateToolbar.setVisible(True) self.clearGhost() - self.custom_annotations_view.doCustomAnnotation(0) + self.doCustomAnnotation(0) self.computeAllContours() - elif mode == 'Snapshot': + elif mode == "Snapshot": self.setAutoSaveAnnotationsEnabled(True) self.setSwitchViewedPlaneDisabled(False) self.reconnectUndoRedo() self.setEnabledSnapshotMode() - self.custom_annotations_view.doCustomAnnotation(0) + self.doCustomAnnotation(0) self.clearComputedContours() - elif mode == 'Normal division: Lineage tree': # Mode activation for lineage tree + elif ( + mode == "Normal division: Lineage tree" + ): # Mode activation for lineage tree # self.startLinTreeIntegrityCheckerWorker() # need to replace (postponed) proceed = self.initLinTree() self.setEnabledCcaToolbar(enabled=False) @@ -337,6 +161,18 @@ def changeMode(self, text): self.disableNonFunctionalButtons() + def changeModeFromMenu(self, action): + self.modeComboBox.setCurrentText(action.text()) + + def clearComboBoxFocus(self, mode): + # Remove focus from modeComboBox to avoid the key_up changes its value + self.sender().clearFocus() + try: + self.timer.stop() + self.modeComboBox.setStyleSheet("background-color: none") + except Exception: + pass + def disableEditingViewPlaneNotXY(self): posData = self.data[self.pos_i] self.manuallyEditCcaAction.setDisabled(True) @@ -359,6 +195,95 @@ def disableEditingViewPlaneNotXY(self): if button is not None: button.setDisabled(True) + def enableSizeSpinbox(self, enabled): + self.brushSizeLabelAction.setVisible(enabled) + self.brushSizeAction.setVisible(enabled) + self.brushAutoFillAction.setVisible(enabled) + self.brushAutoHideAction.setVisible(enabled) + self.brushEraserToolBar.setVisible(enabled) + self.disableNonFunctionalButtons() + + def is_cca_mode(self, mode: str) -> bool: + return mode == self.cca_mode + + def nonViewerEditMenuOpened(self): + mode = str(self.modeComboBox.currentText()) + if mode == "Viewer": + self.startBlinkingModeCB() + + def reconnectUndoRedo(self): + try: + self.undoAction.triggered.disconnect() + self.redoAction.triggered.disconnect() + except Exception: + pass + mode = self.modeComboBox.currentText() + if mode == "Segmentation and Tracking" or mode == "Snapshot": + self.undoAction.triggered.connect(self.undo) + self.redoAction.triggered.connect(self.redo) + elif mode == "Cell cycle analysis": + self.undoAction.triggered.connect(self.UndoCca) + elif mode == "Custom annotations": + self.undoAction.triggered.connect(self.undoCustomAnnotation) + else: + self.undoAction.setDisabled(True) + self.redoAction.setDisabled(True) + + def restorePrevAnnotOptions(self): + if self.prevAnnotOptions is None: + return + self.restoreAnnotOptions_ax1(options=self.prevAnnotOptions) + self.setDrawAnnotComboboxText() + self.prevAnnotOptions = None + + def setEnabledCcaToolbar(self, enabled=False): + self.manuallyEditCcaAction.setDisabled(False) + self.viewCcaTableAction.setDisabled(False) + self.ccaToolBar.setVisible(enabled) + for action in self.ccaToolBar.actions(): + button = self.ccaToolBar.widgetForAction(action) + action.setVisible(enabled) + button.setEnabled(enabled) + + def setEnabledEditToolbarButton(self, enabled=False): + for action in self.segmActions: + action.setEnabled(enabled) + + for action in self.segmActionsVideo: + action.setEnabled(enabled) + + self.relabelSequentialAction.setEnabled(enabled) + self.repeatTrackingMenuAction.setEnabled(enabled) + self.repeatTrackingVideoAction.setEnabled(enabled) + self.postProcessSegmAction.setEnabled(enabled) + self.autoSegmAction.setEnabled(enabled) + self.editToolBar.setVisible(enabled) + mode = self.modeComboBox.currentText() + ccaON = mode == "Cell cycle analysis" + for action in self.editToolBar.actions(): + button = self.editToolBar.widgetForAction(action) + # Keep binCellButton active in cca mode + if button == self.binCellButton and not enabled and ccaON: + action.setVisible(True) + button.setEnabled(True) + else: + action.setVisible(enabled) + button.setEnabled(enabled) + if not enabled: + self.setUncheckedAllButtons() + + def setEnabledFileToolbar(self, enabled): + for action in self.fileToolBar.actions(): + button = self.fileToolBar.widgetForAction(action) + if action == self.openFolderAction or action == self.newAction: + continue + if action == self.manageVersionsAction: + continue + if action == self.openFileAction: + continue + action.setEnabled(enabled) + button.setEnabled(enabled) + def setEnabledSnapshotMode(self): posData = self.data[self.pos_i] self.manuallyEditCcaAction.setDisabled(False) @@ -383,7 +308,7 @@ def setEnabledSnapshotMode(self): action.setVisible(True) elif action == self.reInitCcaAction: action.setVisible(True) - elif action == self.assignBudMothAutoAction and posData.SizeT==1: + elif action == self.assignBudMothAutoAction and posData.SizeT == 1: action.setVisible(True) for action in self.editToolBar.actions(): button = self.editToolBar.widgetForAction(action) @@ -408,7 +333,7 @@ def setFramesSnapshotMode(self): self.realTimeTrackingToggle.label.setDisabled(True) try: self.drawIDsContComboBox.currentIndexChanged.disconnect() - except Exception as e: + except Exception: pass self.imgGrad.rescaleAcrossTimeAction.setDisabled(True) @@ -416,7 +341,7 @@ def setFramesSnapshotMode(self): self.manualTrackingAction.setDisabled(True) self.logger.info('Setting GUI mode to "Snapshots"...') self.modeComboBox.clear() - self.modeComboBox.addItems(['Snapshot']) + self.modeComboBox.addItems(["Snapshot"]) self.modeComboBox.setDisabled(True) self.modeMenu.menuAction().setVisible(False) self.drawIDsContComboBox.clear() @@ -425,7 +350,7 @@ def setFramesSnapshotMode(self): self.modeToolBar.setVisible(False) self.skipToNewIdAction.setVisible(False) self.skipToNewIdAction.setDisabled(True) - self.modeComboBox.setCurrentText('Snapshot') + self.modeComboBox.setCurrentText("Snapshot") self.annotateToolbar.setVisible(True) self.labelsGrad.showNextFrameAction.setDisabled(True) self.drawIDsContComboBox.currentIndexChanged.connect( @@ -466,7 +391,7 @@ def setFramesSnapshotMode(self): self.modeComboBox.activated.disconnect() self.modeComboBox.sigTextChanged.disconnect() self.drawIDsContComboBox.currentIndexChanged.disconnect() - except Exception as e: + except Exception: pass # traceback.print_exc() self.modeComboBox.clear() @@ -476,8 +401,9 @@ def setFramesSnapshotMode(self): self.modeComboBox.sigTextChanged.connect(self.changeMode) self.modeComboBox.activated.connect(self.clearComboBoxFocus) self.drawIDsContComboBox.currentIndexChanged.connect( - self.drawIDsContComboBox_cb) - self.modeComboBox.setCurrentText('Viewer') + self.drawIDsContComboBox_cb + ) + self.modeComboBox.setCurrentText("Viewer") self.showTreeInfoCheckbox.show() self.manualBackgroundAction.setVisible(False) self.manualBackgroundAction.setDisabled(True) @@ -497,4 +423,62 @@ def setFramesSnapshotMode(self): for ch, overlayItems in self.overlayLayersItems.items(): lutItem = overlayItems[1] - lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot) \ No newline at end of file + lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot) + + def should_start_blinking( + self, + mode: str, + *, + ruler_checked: bool = False, + ) -> bool: + return mode == self.viewer_mode and not ruler_checked + + def should_store_on_mode_change(self, previous_mode: str) -> bool: + return previous_mode != self.viewer_mode + + def startBlinkingModeCB(self): + try: + self.timer.stop() + self.stopBlinkTimer.stop() + except Exception: + pass + if self.rulerButton.isChecked(): + return + self.timer = QTimer(self) + self.timer.timeout.connect(self.blinkModeComboBox) + self.timer.start(200) + self.stopBlinkTimer = QTimer(self) + self.stopBlinkTimer.timeout.connect(self.stopBlinkingCB) + self.stopBlinkTimer.start(2000) + + def stopBlinkingCB(self): + self.timer.stop() + self.modeComboBox.setStyleSheet("background-color: none") + + def uncheckAllButtonsFromButtonGroup(self, buttonGroup): + for button in buttonGroup.buttons(): + if not button.isCheckable(): + continue + + if not button.isChecked(): + continue + + button.setChecked(False) + + def undo_redo_target(self, mode: str) -> str: + if mode in {self.segmentation_mode, self.snapshot_mode}: + return "labels" + if mode == self.cca_mode: + return "cca" + if mode == self.custom_annotations_mode: + return "custom_annotations" + return "disabled" + + def updateModeMenuAction(self): + self.modeActionGroup.triggered.disconnect() + for action in self.modeActionGroup.actions(): + if action.text() != self.modeComboBox.currentText(): + continue + action.setChecked(True) + break + self.modeActionGroup.triggered.connect(self.changeModeFromMenu) diff --git a/cellacdc/mixins/model_registry.py b/cellacdc/mixins/model_registry.py index 80714139b..6bce519c8 100644 --- a/cellacdc/mixins/model_registry.py +++ b/cellacdc/mixins/model_registry.py @@ -22,30 +22,9 @@ ) -class ModelRegistryViewModel: +class ModelRegistryMixin: """Application-facing commands for available model registries.""" - def segmentation_models(self, *, include_local_seg: bool = False): - models = list(get_list_of_models()) - if include_local_seg and 'local_seg' not in models: - models.append('local_seg') - return models - - def real_time_trackers(self): - return get_list_of_real_time_trackers() - - def real_time_tracker_aliases(self, *, reverse: bool = False): - return aliases_real_time_trackers(reverse=reverse) - - def model_arg_specs(self, acdc_segment): - return getModelArgSpec(acdc_segment) - - def import_segmentation_module(self, model_name): - return import_segment_module(model_name) - - def check_install_package(self, model_name): - return check_install_package(model_name) - def check_gpu_available( self, model_name, @@ -61,8 +40,11 @@ def check_gpu_available( do_not_warn=do_not_warn, ) - def init_segmentation_model(self, acdc_segment, position_data, init_kwargs): - return init_segm_model(acdc_segment, position_data, init_kwargs) + def check_install_package(self, model_name): + return check_install_package(model_name) + + def import_segmentation_module(self, model_name): + return import_segment_module(model_name) def init_prompt_segmentation_model( self, @@ -76,11 +58,30 @@ def init_prompt_segmentation_model( init_kwargs, ) + def init_segmentation_model(self, acdc_segment, position_data, init_kwargs): + return init_segm_model(acdc_segment, position_data, init_kwargs) + def init_tracker(self, position_data, tracker_name, **kwargs): return init_tracker(position_data, tracker_name, **kwargs) - def validate_tracker_input(self, tracker, segmentation_video): - return validate_tracker_input(tracker, segmentation_video) + def insert_model_arg_spec( + self, + params, + param_name, + param_value, + *, + param_type=None, + desc="", + docstring="", + ): + return insertModelArgSpec( + params, + param_name, + param_value, + param_type=param_type, + desc=desc, + docstring=docstring, + ) def log_segmentation_params( self, @@ -105,30 +106,29 @@ def log_segmentation_params( custom_postprocess_features=custom_postprocess_features, ) + def model_arg_specs(self, acdc_segment): + return getModelArgSpec(acdc_segment) + + def real_time_tracker_aliases(self, *, reverse: bool = False): + return aliases_real_time_trackers(reverse=reverse) + + def real_time_trackers(self): + return get_list_of_real_time_trackers() + + def segmentation_models(self, *, include_local_seg: bool = False): + models = list(get_list_of_models()) + if include_local_seg and "local_seg" not in models: + models.append("local_seg") + return models + + def set_default_arg_specs_from_kwargs(self, params, kwargs): + return setDefaultValueArgSpecsFromKwargs(params, kwargs) + def store_custom_model_path(self, model_file_path): return store_custom_model_path(model_file_path) def store_custom_promptable_model_path(self, model_file_path): return store_custom_promptable_model_path(model_file_path) - def set_default_arg_specs_from_kwargs(self, params, kwargs): - return setDefaultValueArgSpecsFromKwargs(params, kwargs) - - def insert_model_arg_spec( - self, - params, - param_name, - param_value, - *, - param_type=None, - desc='', - docstring='', - ): - return insertModelArgSpec( - params, - param_name, - param_value, - param_type=param_type, - desc=desc, - docstring=docstring, - ) + def validate_tracker_input(self, tracker, segmentation_video): + return validate_tracker_input(tracker, segmentation_video) diff --git a/cellacdc/mixins/object_cleanup.py b/cellacdc/mixins/object_cleanup.py index 5a5e2285d..350ddc5b4 100644 --- a/cellacdc/mixins/object_cleanup.py +++ b/cellacdc/mixins/object_cleanup.py @@ -2,14 +2,13 @@ from __future__ import annotations -import numpy as np import numpy as np from qtpy.QtCore import QThread from cellacdc import apps, widgets, workers -class ObjectCleanupView: +class ObjectCleanupMixin: """Qt-facing adapter around the object-cleanup view-model.""" """Headless object-cleanup result shaping.""" @@ -19,96 +18,89 @@ def cleared_segmentation_frames(self, cleared_segm_data, *, size_t: int): return cleared_segm_data[np.newaxis] return cleared_segm_data - def frame_labels(self, cleared_segm_data): - return list(enumerate(cleared_segm_data)) - - - def __init__(self, host): - self.host = host def delete_objects_outside_mask_action_triggered(self): - pos_data = self.host.data[self.host.pos_i] + pos_data = self.data[self.pos_i] existing_segm_endnames = self.segmentation_roi_endnames( basename=pos_data.basename, images_path=pos_data.images_path, ) select_segm_win = widgets.QDialogListbox( - 'Select segmentation file', - 'Select segmentation file to use as ROI:\n', + "Select segmentation file", + "Select segmentation file to use as ROI:\n", existing_segm_endnames, multiSelection=False, - parent=self.host, + parent=self, ) select_segm_win.exec_() if select_segm_win.cancel: - self.host.logger.info('Delete objects process cancelled.') + self.logger.info("Delete objects process cancelled.") return selected_segm_endname = select_segm_win.selectedItemsText[0] self.start_delete_objects_outside_mask_worker(selected_segm_endname) - def start_delete_objects_outside_mask_worker(self, selected_segm_endname): - self.host.store_data(autosave=False) - pos_data = self.host.data[self.host.pos_i] - segm_data = np.squeeze(self.host.getStoredSegmData()) - - self.host.progressWin = apps.QDialogWorkerProgress( - title='Deleting objects outside of ROIs', - parent=self.host, - pbarDesc='Deleting objects outside of ROIs...', - ) - self.host.progressWin.show(self.host.app) - self.host.progressWin.mainPbar.setMaximum(0) - - self.host.thread = QThread() - self.host.worker = workers.DelObjectsOutsideSegmROIWorker( - selected_segm_endname, - segm_data, - pos_data.images_path, - ) - self.host.worker.moveToThread(self.host.thread) - self.host.worker.finished.connect(self.host.thread.quit) - self.host.worker.finished.connect(self.host.worker.deleteLater) - self.host.thread.finished.connect(self.host.thread.deleteLater) - - self.host.worker.progress.connect(self.host.workerProgress) - self.host.worker.critical.connect(self.host.workerCritical) - self.host.worker.finished.connect( - self.delete_objects_outside_mask_worker_finished - ) - self.host.worker.debug.connect(self.host.workerDebug) - - self.host.thread.started.connect(self.host.worker.run) - self.host.thread.start() - def delete_objects_outside_mask_worker_finished(self, result): - pos_data = self.host.data[self.host.pos_i] + pos_data = self.data[self.pos_i] worker, cleared_segm_data, del_ids = result cleared_segm_data = self.cleared_segmentation_frames( cleared_segm_data, size_t=pos_data.SizeT, ) - self.host.update_cca_df_deletedIDs(pos_data, del_ids) + self.update_cca_df_deletedIDs(pos_data, del_ids) current_frame_i = pos_data.frame_i - for frame_i, cleared_lab in self.frame_labels( - cleared_segm_data - ): - pos_data.allData_li[frame_i]['labels'] = cleared_lab + for frame_i, cleared_lab in self.frame_labels(cleared_segm_data): + pos_data.allData_li[frame_i]["labels"] = cleared_lab pos_data.frame_i = frame_i - self.host.get_data() - self.host.store_data(autosave=False) + self.get_data() + self.store_data(autosave=False) pos_data.frame_i = current_frame_i - self.host.get_data() - - if self.host.progressWin is not None: - self.host.progressWin.workerFinished = True - self.host.progressWin.close() - self.host.progressWin = None - self.host.logger.info('Deleting objects outside of ROIs finished.') - self.host.titleLabel.setText( - 'Deleting objects outside of ROIs finished.', - color='w', + self.get_data() + + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.logger.info("Deleting objects outside of ROIs finished.") + self.titleLabel.setText( + "Deleting objects outside of ROIs finished.", + color="w", + ) + self.updateAllImages() + + def frame_labels(self, cleared_segm_data): + return list(enumerate(cleared_segm_data)) + + def start_delete_objects_outside_mask_worker(self, selected_segm_endname): + self.store_data(autosave=False) + pos_data = self.data[self.pos_i] + segm_data = np.squeeze(self.getStoredSegmData()) + + self.progressWin = apps.QDialogWorkerProgress( + title="Deleting objects outside of ROIs", + parent=self, + pbarDesc="Deleting objects outside of ROIs...", + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + self.thread = QThread() + self.worker = workers.DelObjectsOutsideSegmROIWorker( + selected_segm_endname, + segm_data, + pos_data.images_path, ) - self.host.updateAllImages() \ No newline at end of file + self.worker.moveToThread(self.thread) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + self.worker.progress.connect(self.workerProgress) + self.worker.critical.connect(self.workerCritical) + self.worker.finished.connect(self.delete_objects_outside_mask_worker_finished) + self.worker.debug.connect(self.workerDebug) + + self.thread.started.connect(self.worker.run) + self.thread.start() diff --git a/cellacdc/mixins/object_counts.py b/cellacdc/mixins/object_counts.py index bdff7a08b..4967e204a 100644 --- a/cellacdc/mixins/object_counts.py +++ b/cellacdc/mixins/object_counts.py @@ -11,15 +11,15 @@ ) -class ObjectCountViewModel: +class ObjectCountMixin: """Application-facing object count and label-frame commands.""" - def current_labels(self, pos_data, *, curr_lab=None, frame_i=None): - return current_labels(pos_data, curr_lab=curr_lab, frame_i=frame_i) - def collect_all_ids(self, pos_data, *, only_visited: bool = False) -> set[int]: return collect_all_ids(pos_data, only_visited=only_visited) + def current_labels(self, pos_data, *, curr_lab=None, frame_i=None): + return current_labels(pos_data, curr_lab=curr_lab, frame_i=frame_i) + def snapshot_object_counts( self, positions, diff --git a/cellacdc/mixins/object_properties.py b/cellacdc/mixins/object_properties.py index 8850fb2f6..c2bce9eb0 100644 --- a/cellacdc/mixins/object_properties.py +++ b/cellacdc/mixins/object_properties.py @@ -2,7 +2,6 @@ from __future__ import annotations -import numpy as np import numpy as np import skimage.measure from tqdm import tqdm @@ -10,473 +9,10 @@ from cellacdc import apps, exception_handler, html_utils, widgets -class ObjectPropertiesView: +class ObjectPropertiesMixin: """Qt-facing adapter around object properties and highlighting.""" - LEGACY_METHODS = ( - 'initPixelSizePropsDockWidget', - 'showPropsDockWidget', - 'clearHighlightedID', - 'setAllIDs', - 'countObjectsTimelapse', - 'countObjectsSnapshots', - 'countObjects', - 'updateObjectCounts', - 'countObjectsCb', - 'keepIDs_cb', - 'initKeepObjLabelsLayers', - 'updateTempLayerKeepIDs', - 'highlightLabelID', - 'highlightHoverID', - 'grayOutHighlightedLabels', - 'grayOutOverlaySegm', - 'highlightHoverIDsKeptObj', - 'getHighlightedID', - 'clearHighlightedKeepIDs', - 'setHighlighedIDfromToolbar', - 'highlightSearchedID', - '_keepObjects', - 'clearHighlightedText', - 'removeHighlightLabelID', - 'updateKeepIDs', - 'applyKeepObjects', - 'get_curr_lab', - 'highlightIDonHoverCheckBoxToggled', - 'highlightSearchedIDcheckBoxToggled', - 'setHighlightID', - 'propsWidgetIDvalueChanged', - 'updatePropsWidget', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - - def initPixelSizePropsDockWidget(self): - posData = self.data[self.pos_i] - PhysicalSizeX = posData.PhysicalSizeX - PhysicalSizeY = posData.PhysicalSizeY - PhysicalSizeZ = posData.PhysicalSizeZ - self.guiTabControl.initPixelSize( - PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ - ) - - def showPropsDockWidget(self, checked=False): - if self.showPropsDockButton.isExpand: - self.propsDockWidget.setVisible(False) - self.setHighlightID(False) - else: - self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - if self.should_show_3d_property_controls( - self.isSegm3D - ): - self.guiTabControl.propsQGBox.cellVolVox3D_SB.show() - self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.show() - self.guiTabControl.propsQGBox.cellVolFl3D_DSB.show() - self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.show() - else: - self.guiTabControl.propsQGBox.cellVolVox3D_SB.hide() - self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.hide() - self.guiTabControl.propsQGBox.cellVolFl3D_DSB.hide() - self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.hide() - - self.propsDockWidget.setVisible(True) - self.propsDockWidget.setEnabled(True) - self.updateAllImages() - - def clearHighlightedID(self): - self.highlightIDToolbar.setVisible(False) - - try: - self.updateLostContoursImage(ax=0, delROIsIDs=None) - except Exception as err: - pass - - if self.highlightedID == 0: - return - - self.highlightedID = 0 - self.guiTabControl.highlightCheckbox.setChecked(False) - self.guiTabControl.highlightSearchedCheckbox.setChecked(False) - self.setHighlightID(False) - - def setAllIDs(self, onlyVisited=False): - for posData in self.data: - posData.allIDs = self.object_counts.collect_all_ids( - posData, - only_visited=onlyVisited, - ) - - def countObjectsTimelapse(self): - if self.countObjsWindow is None: - activeCategories = self.timelapse_default_categories() - else: - activeCategories = self.countObjsWindow.activeCategories() - - posData = self.data[self.pos_i] - allCategoryCountMapper = posData.countObjectsInSegmTimelapse( - activeCategories - ) - if self.countObjsWindow is None: - return allCategoryCountMapper - - categoryCountMapper = {} - for category in activeCategories: - categoryCountMapper[category] = allCategoryCountMapper[category] - - return categoryCountMapper - - - def countObjectsSnapshots(self): - posData = self.data[self.pos_i] - if self.countObjsWindow is None: - activeCategories = self.snapshot_default_categories( - is_segm_3d=self.isSegm3D - ) - else: - activeCategories = self.countObjsWindow.activeCategories() - - allCategoryCountMapper = self.object_counts.snapshot_object_counts( - self.data, - self.pos_i, - current_lab_2d=self.currentLab2D, - include_current_z_slice='In current z-slice' in activeCategories, - ) - - if self.countObjsWindow is None: - return allCategoryCountMapper - - categoryCountMapper = {} - for category in activeCategories: - categoryCountMapper[category] = allCategoryCountMapper[category] - - return categoryCountMapper - - def countObjects(self): - self.logger.info('Counting objects...') - - posData = self.data[self.pos_i] - if posData.SizeT > 1: - return self.countObjectsTimelapse() - - return self.countObjectsSnapshots() - - - def updateObjectCounts(self): - if not self.should_update_object_counts( - window_exists=self.countObjsWindow is not None, - is_visible=( - self.countObjsWindow.isVisible() - if self.countObjsWindow is not None else False - ), - live_preview_checked=( - self.countObjsWindow.livePreviewCheckbox.isChecked() - if self.countObjsWindow is not None else False - ), - ): - return - - categoryCountMapper = self.countObjects() - self.countObjsWindow.updateCounts(categoryCountMapper) - - def countObjectsCb(self, checked): - if self.countObjsWindow is None: - categoryCountMapper = self.countObjects() - self.countObjsWindow = apps.ObjectCountDialog( - categoryCountMapper=categoryCountMapper, - parent=self.host, - data=self.data - ) - self.countObjsWindow.sigShowEvent.connect(self.updateObjectCounts) - self.countObjsWindow.sigUpdateCounts.connect(self.updateObjectCounts) - - if checked: - self.countObjsWindow.show() - else: - self.countObjsWindow.hide() - - def keepIDs_cb(self, checked): - if checked: - self.highlightedLab = np.zeros_like(self.currentLab2D) - if self.annotCcaInfoCheckbox.isChecked(): - self.annotCcaInfoCheckbox.setChecked(False) - self.annotIDsCheckbox.setChecked(True) - self.setDrawAnnotComboboxText() - self.uncheckLeftClickButtons(None) - self.initKeepObjLabelsLayers() - self.setAllIDs() - else: - # restore items to non-grayed out - self.clearTempBrushImage() - alpha = self.imgGrad.labelsAlphaSlider.value() - self.labelsLayerImg1.setOpacity(alpha) - self.labelsLayerRightImg.setOpacity(alpha) - self.ax1_contoursImageItem.setOpacity(1.0) - self.ax2_contoursImageItem.setOpacity(1.0) - self.ax1_lostObjImageItem.setOpacity(1.0) - self.ax2_lostObjImageItem.setOpacity(1.0) - self.ax1_lostTrackedObjImageItem.setOpacity(1.0) - self.ax2_lostTrackedObjImageItem.setOpacity(1.0) - - self.keepIDsToolbar.setVisible(checked) - self.highlightedIDopts = None - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - self.updateAllImages() - - # QTimer.singleShot(300, self.autoRange) - - def initKeepObjLabelsLayers(self): - lut = np.zeros((len(self.lut), 4), dtype=np.uint8) - lut[:,:-1] = self.lut - lut[:,-1:] = 255 - lut[0] = [0,0,0,0] - self.keepIDsTempLayerLeft.setLevels([0, len(lut)]) - self.keepIDsTempLayerLeft.setLookupTable(lut) - - def updateTempLayerKeepIDs(self): - if not self.keepIDsButton.isChecked(): - return - - keptLab = np.zeros_like(self.currentLab2D) - - posData = self.data[self.pos_i] - for obj in posData.rp: - if obj.label not in self.keptObjectsIDs: - continue - - if not self.isObjVisible(obj.bbox): - continue - - _slice = self.getObjSlice(obj.slice) - _objMask = self.getObjImage(obj.image, obj.bbox) - - keptLab[_slice][_objMask] = obj.label - - self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) - - def highlightLabelID(self, ID, ax=0): - posData = self.data[self.pos_i] - try: - obj = posData.rp[posData.IDs_idxs[ID]] - except KeyError: - return - - self.textAnnot[ax].highlightObject(obj) - - def highlightHoverID(self, x, y, hoverID=None): - if hoverID is None: - try: - hoverID = self.currentLab2D[int(y), int(x)] - except IndexError: - return - - if hoverID == 0: - return - - posData = self.data[self.pos_i] - objIdx = posData.IDs_idxs[hoverID] - obj = posData.rp[objIdx] - self.goToZsliceSearchedID(obj) - self.highlightSearchedID(hoverID) - - def grayOutHighlightedLabels(self, nonGrayedIDs=None, alpha=None): - if nonGrayedIDs is None: - nonGrayedIDs = set() - - posData = self.data[self.pos_i] - if alpha is None: - alpha = self.imgGrad.labelsAlphaSlider.value() - - if not hasattr(self, 'highlightedLab'): - self.highlightedLab = np.zeros_like(self.currentLab2D) - else: - self.highlightedLab[:] = 0 - - lut = np.zeros((2, 4), dtype=np.uint8) - for _obj in posData.rp: - if not self.isObjVisible(_obj.bbox): - continue - if _obj.label not in nonGrayedIDs: - continue - _slice = self.getObjSlice(_obj.slice) - _objMask = self.getObjImage(_obj.image, _obj.bbox) - self.highlightedLab[_slice][_objMask] = _obj.label - rgb = self.lut[_obj.label].copy() - lut[1, :-1] = rgb - # Set alpha to 0.7 - lut[1, -1] = 178 - - return lut - - def grayOutOverlaySegm(self, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - isOverlaySegmActive = how.find('segm. masks') != -1 - if not isOverlaySegmActive: - return - - grayedLut = self.grayOutHighlightedLabels() - - def highlightHoverIDsKeptObj(self, x, y, hoverID=None): - if hoverID is None: - try: - hoverID = self.currentLab2D[int(y), int(x)] - except IndexError: - return - - self.highlightSearchedID(hoverID, greyOthers=False) - - if hoverID == 0 and self.highlightedID == 0: - return - - if hoverID == 0 and self.highlightedID != 0: - self.clearHighlightedKeepIDs() - for ID in self.keptObjectsIDs: - self.highlightLabelID(ID) - return - - posData = self.data[self.pos_i] - try: - objIdx = posData.IDs_idxs[hoverID] - except KeyError as err: - return - - obj = posData.rp[objIdx] - self.goToZsliceSearchedID(obj) - - for ID in self.keptObjectsIDs: - self.highlightLabelID(ID) - - def getHighlightedID(self): - if self.highlightedID > 0: - return self.highlightedID - - doHighlight = self.should_highlight_props_id( - dock_visible=self.propsDockWidget.isVisible(), - highlight_checked=self.guiTabControl.highlightCheckbox.isChecked(), - searched_highlight_checked=( - self.guiTabControl.highlightSearchedCheckbox.isChecked() - ), - ) - if not doHighlight: - return 0 - - return self.guiTabControl.propsQGBox.idSB.value() - - def clearHighlightedKeepIDs(self): - self.setAllTextAnnotations() - self.highlightedID = 0 - self.searchedIDitemRight.setData([], []) - self.searchedIDitemLeft.setData([], []) - self.highLightIDLayerImg1.clear() - self.highLightIDLayerRightImage.clear() - - def setHighlighedIDfromToolbar(self, ID: int): - self.object_search_view.findID(ID=ID) - - def highlightSearchedID(self, ID, force=False, greyOthers=True): - self.highlightIDToolbar.setIDNoSignals(ID) - - if ID == 0: - self.highlightIDToolbar.setVisible(False) - return - - if ID == self.highlightedID and not force: - return - - doHighlight = self.should_highlight_props_id( - dock_visible=self.propsDockWidget.isVisible(), - highlight_checked=self.guiTabControl.highlightCheckbox.isChecked(), - searched_highlight_checked=( - self.guiTabControl.highlightSearchedCheckbox.isChecked() - ), - ) - if doHighlight: - self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - ID = self.highlightedID - - if self.highlightedID > 0: - self.clearHighlightedText() - - self.searchedIDitemRight.setData([], []) - self.searchedIDitemLeft.setData([], []) - - posData = self.data[self.pos_i] - - self.highlightedID = ID - self.highlightIDToolbar.setVisible(True) - - objIdx = posData.IDs_idxs.get(ID) - if objIdx is None: - return - - obj = posData.rp[objIdx] - isObjVisible = self.isObjVisible(obj.bbox) - if not isObjVisible: - return - - if greyOthers: - self.textAnnot[0].grayOutAnnotations() - self.textAnnot[1].grayOutAnnotations() - - how_ax1 = self.drawIDsContComboBox.currentText() - how_ax2 = self.getAnnotateHowRightImage() - isOverlaySegm_ax1 = how_ax1.find('segm. masks') != -1 - isOverlaySegm_ax2 = how_ax2.find('segm. masks') != -1 - alpha = self.imgGrad.labelsAlphaSlider.value() - - if isOverlaySegm_ax1 or isOverlaySegm_ax2: - grayedLut = self.grayOutHighlightedLabels( - nonGrayedIDs={obj.label}, - alpha=alpha - ) - - cont = None - contours = None - if isOverlaySegm_ax1: - self.highLightIDLayerImg1.setLookupTable(grayedLut) - self.highLightIDLayerImg1.setImage(self.highlightedLab) - self.labelsLayerImg1.setOpacity(alpha/3) - else: - contours = self.getObjContours(obj, all_external=True) - for cont in contours: - self.searchedIDitemLeft.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) - - if isOverlaySegm_ax2: - self.highLightIDLayerRightImage.setLookupTable(grayedLut) - self.highLightIDLayerRightImage.setImage(self.highlightedLab) - self.labelsLayerRightImg.setOpacity(alpha/3) - else: - if contours is None: - contours = self.getObjContours(obj, all_external=True) - for cont in contours: - self.searchedIDitemRight.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) - - # Gray out all IDs excpet searched one - lut = self.lut.copy() # [:max(posData.IDs)+1] - lut[:ID] = lut[:ID]*0.2 - lut[ID+1:] = lut[ID+1:]*0.2 - self.img2.setLookupTable(lut) - - # Highlight text - self.highlightLabelID(ID, ax=0) - self.highlightLabelID(ID, ax=1) + """Headless decisions for object-property and highlight workflows.""" def _keepObjects(self, keepIDs=None, lab=None, rp=None): posData = self.data[self.pos_i] @@ -497,135 +33,172 @@ def _keepObjects(self, keepIDs=None, lab=None, rp=None): return lab - def clearHighlightedText(self): - pass - - def removeHighlightLabelID(self, IDs=None, ax=0): - posData = self.data[self.pos_i] - if IDs is None: - IDs = posData.IDs + @exception_handler + def applyKeepObjects(self): + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) - for ID in IDs: - obj = posData.rp[posData.IDs_idxs[ID]] - self.textAnnot[ax].removeHighlightObject(obj) + self._keepObjects() + self.highlightHoverIDsKeptObj(0, 0, hoverID=0) - def updateKeepIDs(self, IDs): posData = self.data[self.pos_i] - self.clearHighlightedText() - - isAnyIDnotExisting = False - # Check if IDs from line edit are present in current keptObjectIDs list - for ID in IDs: - if ID not in posData.allIDs: - isAnyIDnotExisting = True - continue - if ID not in self.keptObjectsIDs: - self.keptObjectsIDs.append(ID, editText=False) - self.highlightLabelID(ID) - - # Check if IDs in current keptObjectsIDs are present in IDs from line edit - for ID in self.keptObjectsIDs: - if ID not in posData.allIDs: - isAnyIDnotExisting = True - continue - if ID not in IDs: - self.keptObjectsIDs.remove(ID, editText=False) + self.update_rp() + # Repeat tracking + self.tracking(enforce=True, assign_unique_new_IDs=False) - self.updateTempLayerKeepIDs() - if isAnyIDnotExisting: - self.keptIDsLineEdit.warnNotExistingID() + if self.isSnapshot: + self.fixCcaDfAfterEdit("Deleted non-selected objects") + self.updateAllImages() + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + return else: - self.keptIDsLineEdit.setInstructionsText() + removeAnnot = self.warnEditingWithCca_df( + "Deleted non-selected objects", get_answer=True + ) + if not removeAnnot: + # We can propagate changes only if the user agrees on + # removing annotations + return - @exception_handler - def applyKeepObjects(self): - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) + self.current_frame_i = posData.frame_i + if posData.frame_i > 0: + txt = html_utils.paragraph(""" + Do you want to remove un-kept objects in the past frames too? + """) + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + _, _, applyToPastButton = msg.question( + self, + "Propagate to past frames?", + txt, + buttonsTexts=("Cancel", "No", "Yes, apply to past frames"), + ) + if msg.cancel: + return + if msg.clickedButton == applyToPastButton: + self.store_data() + self.logger.info("Applying keep objects to past frames...") + if not removeAnnot and posData.cca_df is not None: + delIDs = [ + ID for ID in posData.cca_df.index if ID not in posData.IDs + ] + self.update_cca_df_deletedIDs(posData, delIDs) - self._keepObjects() - self.highlightHoverIDsKeptObj(0, 0, hoverID=0) + for i in tqdm(range(posData.frame_i), ncols=100): + lab = posData.allData_li[i]["labels"] + rp = posData.allData_li[i]["regionprops"] + keepLab = self._keepObjects(lab=lab, rp=rp) + # Store change + posData.allData_li[i]["labels"] = keepLab.copy() + # Get the rest of the stored metadata based on the new lab + posData.frame_i = i + self.get_data() + self.store_data(autosave=False) - posData = self.data[self.pos_i] + posData.frame_i = self.current_frame_i + self.get_data() - self.update_rp() - # Repeat tracking - self.tracking(enforce=True, assign_unique_new_IDs=False) + # Ask to propagate change to all future visited frames + key = "Keep ID" + askAction = self.askHowFutureFramesActions[key] + doNotShow = not askAction.isChecked() + (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( + self.propagateChange( + self.keptObjectsIDs, + key, + doNotShow, + posData.UndoFutFrames_keepID, + posData.applyFutFrames_keepID, + force=True, + applyTrackingB=True, + ) + ) - if self.isSnapshot: - self.fixCcaDfAfterEdit('Deleted non-selected objects') - self.updateAllImages() + if UndoFutFrames is None: + # Empty keep object list self.keptObjectsIDs = widgets.KeptObjectIDsList( self.keptIDsLineEdit, self.keepIDsConfirmAction ) return - else: - removeAnnot = self.warnEditingWithCca_df( - 'Deleted non-selected objects', get_answer=True - ) - if not removeAnnot: - # We can propagate changes only if the user agrees on - # removing annotations - return - self.current_frame_i = posData.frame_i - if posData.frame_i > 0: - txt = html_utils.paragraph(""" + posData.doNotShowAgain_keepID = doNotShowAgain + posData.UndoFutFrames_keepID = UndoFutFrames + posData.applyFutFrames_keepID = applyFutFrames + includeUnvisited = posData.includeUnvisitedInfo["Keep ID"] - """Headless decisions for object-property and highlight workflows.""" + if applyFutFrames: + self.store_data() - def timelapse_default_categories(self) -> set[str]: - return { - 'In current frame', - 'In all visited frames', - 'In entire video', - 'Unique objects in all visited frames', - 'Unique objects in entire video', - } + self.logger.info("Applying to future frames...") + pbar = tqdm(total=posData.SizeT - posData.frame_i - 1, ncols=100) + segmSizeT = len(posData.segm_data) + if not removeAnnot and posData.cca_df is not None: + delIDs = [ID for ID in posData.cca_df.index if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, delIDs) - def snapshot_default_categories(self, *, is_segm_3d: bool) -> set[str]: - categories = { - 'In current position', - 'In all visited positions (current session)', - 'In all visited positions (previous sessions)', - 'In all loaded positions', - } - if is_segm_3d: - categories.add('In current z-slice') - return categories + for i in range(posData.frame_i + 1, segmSizeT): + lab = posData.allData_li[i]["labels"] + if lab is None and not includeUnvisited: + self.enqAutosave() + pbar.update(posData.SizeT - i) + break - def should_update_object_counts( - self, - *, - window_exists: bool, - is_visible: bool, - live_preview_checked: bool, - ) -> bool: - return window_exists and is_visible and live_preview_checked + rp = posData.allData_li[i]["regionprops"] - def should_show_3d_property_controls(self, is_segm_3d: bool) -> bool: - return is_segm_3d + if lab is not None: + keepLab = self._keepObjects(lab=lab, rp=rp) + # Store change + posData.allData_li[i]["labels"] = keepLab.copy() + # Get the rest of the stored metadata based on the new lab + posData.frame_i = i + self.get_data() + self.store_data(autosave=False) + elif includeUnvisited: + # Unvisited frame (includeUnvisited = True) + lab = posData.segm_data[i] + rp = skimage.measure.regionprops(lab) + keepLab = self._keepObjects(lab=lab, rp=rp) + posData.segm_data[i] = keepLab - def should_highlight_props_id( - self, - *, - dock_visible: bool, - highlight_checked: bool, - searched_highlight_checked: bool, - ) -> bool: - return ( - dock_visible - and (highlight_checked or searched_highlight_checked) + pbar.update() + pbar.close() + + # Back to current frame + if applyFutFrames: + posData.frame_i = self.current_frame_i + self.get_data() + + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction ) - def should_update_props_widget( + def calculate_additional_measure( self, *, - dock_visible: bool, - object_id: int, - current_props_id: int, - ) -> bool: - return dock_visible and object_id != 0 and object_id != current_props_id + func_desc: str, + func: callable, + obj_data: np.ndarray, + img: np.ndarray, + lab: np.ndarray, + obj_area: int, + vol_vox: float, + ) -> float: + if func_desc in ("Concentration", "Amount"): + background_pixels = img[lab == 0] + bkgr_val = ( + float(np.median(background_pixels)) + if background_pixels.size > 0 + else 0.0 + ) + amount = func(obj_data, bkgr_val, obj_area) + if func_desc == "Concentration": + return amount / vol_vox + else: + return amount + else: + return float(func(obj_data)) def calculate_area_pxl( self, @@ -638,7 +211,7 @@ def calculate_area_pxl( obj_area: int, ) -> int: if is_segm_3d: - if z_proj_text == 'single z-slice': + if z_proj_text == "single z-slice": local_z = z_lab - bbox_0 return int(np.count_nonzero(obj_image[local_z])) else: @@ -655,26 +228,228 @@ def calculate_area_um2( ) -> float: return area_pxl * physical_size_y * physical_size_x - def calculate_vol_3d( - self, - *, - obj_area: int, - physical_size_x: float, - physical_size_y: float, - physical_size_z: float, - ) -> tuple[float, float]: - vol_vox_3D = obj_area - vol_fl_3D = vol_vox_3D * physical_size_z * physical_size_y * physical_size_x - return float(vol_vox_3D), float(vol_fl_3D) + def calculate_elongation( + self, + *, + major_axis_length: float, + minor_axis_length: float, + ) -> float: + minor_axis = max(1.0, minor_axis_length) + return major_axis_length / minor_axis + + def calculate_intensity_statistics( + self, + obj_data: np.ndarray, + ) -> dict[str, float]: + if obj_data.size == 0: + return {"min": 0.0, "max": 0.0, "mean": 0.0, "median": 0.0} + return { + "min": float(np.min(obj_data)), + "max": float(np.max(obj_data)), + "mean": float(np.mean(obj_data)), + "median": float(np.median(obj_data)), + } + + def calculate_vol_3d( + self, + *, + obj_area: int, + physical_size_x: float, + physical_size_y: float, + physical_size_z: float, + ) -> tuple[float, float]: + vol_vox_3D = obj_area + vol_fl_3D = vol_vox_3D * physical_size_z * physical_size_y * physical_size_x + return float(vol_vox_3D), float(vol_fl_3D) + + def clearHighlightedID(self): + self.highlightIDToolbar.setVisible(False) + + try: + self.updateLostContoursImage(ax=0, delROIsIDs=None) + except Exception: + pass + + if self.highlightedID == 0: + return + + self.highlightedID = 0 + self.guiTabControl.highlightCheckbox.setChecked(False) + self.guiTabControl.highlightSearchedCheckbox.setChecked(False) + self.setHighlightID(False) + + def clearHighlightedKeepIDs(self): + self.setAllTextAnnotations() + self.highlightedID = 0 + self.searchedIDitemRight.setData([], []) + self.searchedIDitemLeft.setData([], []) + self.highLightIDLayerImg1.clear() + self.highLightIDLayerRightImage.clear() + + def clearHighlightedText(self): + pass + + def countObjects(self): + self.logger.info("Counting objects...") + + posData = self.data[self.pos_i] + if posData.SizeT > 1: + return self.countObjectsTimelapse() + + return self.countObjectsSnapshots() + + def countObjectsCb(self, checked): + if self.countObjsWindow is None: + categoryCountMapper = self.countObjects() + self.countObjsWindow = apps.ObjectCountDialog( + categoryCountMapper=categoryCountMapper, parent=self, data=self.data + ) + self.countObjsWindow.sigShowEvent.connect(self.updateObjectCounts) + self.countObjsWindow.sigUpdateCounts.connect(self.updateObjectCounts) + + if checked: + self.countObjsWindow.show() + else: + self.countObjsWindow.hide() + + def countObjectsSnapshots(self): + posData = self.data[self.pos_i] + if self.countObjsWindow is None: + activeCategories = { + "In current position", + "In all visited positions (current session)", + "In all visited positions (previous sessions)", + "In all loaded positions", + } + if self.isSegm3D: + activeCategories.add("In current z-slice") + else: + activeCategories = self.countObjsWindow.activeCategories() + + numObjectsCurrentPos = len(posData.IDs) + numObjectsAllPos = 0 + numObjectsVisitedPosPrevious = 0 + numObjectsVisitedPosCurrent = 0 + numObjectsCurrentZslice = None + if "In current z-slice" in activeCategories: + numObjectsCurrentZslice = len( + skimage.measure.regionprops(self.currentLab2D) + ) + + for pos_i, _posData in enumerate(self.data): + IDs = _posData.allData_li[0]["IDs"] + if os.path.exists(_posData.acdc_output_csv_path): + numObjectsVisitedPosPrevious += len(IDs) + if IDs: + numObjs = len(IDs) + numObjectsAllPos += len(IDs) + else: + lab = _posData.segm_data[0] + rp = skimage.measure.regionprops(lab) + numObjs = len(rp) + numObjectsAllPos += numObjs + + if _posData.visited: + numObjectsVisitedPosCurrent += numObjs + + allCategoryCountMapper = { + "In current position": numObjectsCurrentPos, + "In all visited positions (current session)": numObjectsVisitedPosCurrent, + "In all visited positions (previous sessions)": numObjectsVisitedPosPrevious, + "In all loaded positions": numObjectsAllPos, + } + if numObjectsCurrentZslice is not None: + allCategoryCountMapper["In current z-slice"] = numObjectsCurrentZslice + + if self.countObjsWindow is None: + return allCategoryCountMapper + + categoryCountMapper = {} + for category in activeCategories: + categoryCountMapper[category] = allCategoryCountMapper[category] + + return categoryCountMapper + + def countObjectsTimelapse(self): + if self.countObjsWindow is None: + activeCategories = { + "In current frame", + "In all visited frames", + "In entire video", + "Unique objects in all visited frames", + "Unique objects in entire video", + } + else: + activeCategories = self.countObjsWindow.activeCategories() + + posData = self.data[self.pos_i] + allCategoryCountMapper = posData.countObjectsInSegmTimelapse(activeCategories) + if self.countObjsWindow is None: + return allCategoryCountMapper + + categoryCountMapper = {} + for category in activeCategories: + categoryCountMapper[category] = allCategoryCountMapper[category] + + return categoryCountMapper + + def getHighlightedID(self): + if self.highlightedID > 0: + return self.highlightedID + + doHighlight = self.propsDockWidget.isVisible() and ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() + ) + if not doHighlight: + return 0 + + return self.guiTabControl.propsQGBox.idSB.value() + + def get_curr_lab( + self, curr_lab: np.ndarray | None = None, frame_i: int | None = None + ): + """Get the current labels for the position data. Hirarchically checks: + 1. If `curr_lab` is provided, use it. + 2. If `posData.lab` is not None, use it. + 3. If `posData.allData_li[frame_i]['labels']` exists, use it. + 4. If `posData.segm_data[frame_i]` exists, use it. + + If frame_i is None, uses the current frame index from `posData`. + + Parameters + ---------- + curr_lab : np.ndarray, optional + Current labels for the position data if it should be checked + if its not None first, by default None + frame_i : int, optional + Frame index to use for retrieving labels, by default None + + Returns + ------- + np.ndarray + Current labels for the position data + """ + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + if curr_lab is None and frame_i == posData.frame_i: + curr_lab = posData.lab - def calculate_elongation( - self, - *, - major_axis_length: float, - minor_axis_length: float, - ) -> float: - minor_axis = max(1.0, minor_axis_length) - return major_axis_length / minor_axis + if curr_lab is None: + try: + curr_lab = posData.allData_li[frame_i]["labels"].copy() + except: + pass + + if curr_lab is None: + try: + curr_lab = posData.segm_data[frame_i].copy() + except: + pass + + return curr_lab def get_object_and_background_images( self, @@ -695,193 +470,199 @@ def get_object_and_background_images( img = image return obj_data, img - def calculate_intensity_statistics( - self, - obj_data: np.ndarray, - ) -> dict[str, float]: - if obj_data.size == 0: - return {'min': 0.0, 'max': 0.0, 'mean': 0.0, 'median': 0.0} - return { - 'min': float(np.min(obj_data)), - 'max': float(np.max(obj_data)), - 'mean': float(np.mean(obj_data)), - 'median': float(np.median(obj_data)), - } + def grayOutHighlightedLabels(self, nonGrayedIDs=None, alpha=None): + if nonGrayedIDs is None: + nonGrayedIDs = set() - def calculate_additional_measure( - self, - *, - func_desc: str, - func: callable, - obj_data: np.ndarray, - img: np.ndarray, - lab: np.ndarray, - obj_area: int, - vol_vox: float, - ) -> float: - if func_desc in ('Concentration', 'Amount'): - background_pixels = img[lab == 0] - bkgr_val = ( - float(np.median(background_pixels)) - if background_pixels.size > 0 - else 0.0 - ) - amount = func(obj_data, bkgr_val, obj_area) - if func_desc == 'Concentration': - return amount / vol_vox - else: - return amount + posData = self.data[self.pos_i] + if alpha is None: + alpha = self.imgGrad.labelsAlphaSlider.value() + + if not hasattr(self, "highlightedLab"): + self.highlightedLab = np.zeros_like(self.currentLab2D) else: - return float(func(obj_data)) + self.highlightedLab[:] = 0 + lut = np.zeros((2, 4), dtype=np.uint8) + for _obj in posData.rp: + if not self.isObjVisible(_obj.bbox): + continue + if _obj.label not in nonGrayedIDs: + continue + _slice = self.getObjSlice(_obj.slice) + _objMask = self.getObjImage(_obj.image, _obj.bbox) + self.highlightedLab[_slice][_objMask] = _obj.label + rgb = self.lut[_obj.label].copy() + lut[1, :-1] = rgb + # Set alpha to 0.7 + lut[1, -1] = 178 - Do you want to remove un-kept objects in the past frames too? - """) - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - _, _, applyToPastButton = msg.question( - self, 'Propagate to past frames?', txt, - buttonsTexts=('Cancel', 'No', 'Yes, apply to past frames') - ) - if msg.cancel: + return lut + + def grayOutOverlaySegm(self, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + isOverlaySegmActive = how.find("segm. masks") != -1 + if not isOverlaySegmActive: + return + + self.grayOutHighlightedLabels() + + def highlightHoverID(self, x, y, hoverID=None): + if hoverID is None: + try: + hoverID = self.currentLab2D[int(y), int(x)] + except IndexError: return - if msg.clickedButton == applyToPastButton: - self.store_data() - self.logger.info('Applying keep objects to past frames...') - if not removeAnnot and posData.cca_df is not None: - delIDs = [ - ID for ID in posData.cca_df.index - if ID not in posData.IDs - ] - self.update_cca_df_deletedIDs(posData, delIDs) - for i in tqdm(range(posData.frame_i), ncols=100): - lab = posData.allData_li[i]['labels'] - rp = posData.allData_li[i]['regionprops'] - keepLab = self._keepObjects(lab=lab, rp=rp) - # Store change - posData.allData_li[i]['labels'] = keepLab.copy() - # Get the rest of the stored metadata based on the new lab - posData.frame_i = i - self.get_data() - self.store_data(autosave=False) + if hoverID == 0: + return - posData.frame_i = self.current_frame_i - self.get_data() + posData = self.data[self.pos_i] + objIdx = posData.IDs_idxs[hoverID] + obj = posData.rp[objIdx] + self.goToZsliceSearchedID(obj) + self.highlightSearchedID(hoverID) - # Ask to propagate change to all future visited frames - key = 'Keep ID' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - self.keptObjectsIDs, key, doNotShow, - posData.UndoFutFrames_keepID, posData.applyFutFrames_keepID, - force=True, applyTrackingB=True - ) + def highlightHoverIDsKeptObj(self, x, y, hoverID=None): + if hoverID is None: + try: + hoverID = self.currentLab2D[int(y), int(x)] + except IndexError: + return - if UndoFutFrames is None: - # Empty keep object list - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) + self.highlightSearchedID(hoverID, greyOthers=False) + + if hoverID == 0 and self.highlightedID == 0: return - posData.doNotShowAgain_keepID = doNotShowAgain - posData.UndoFutFrames_keepID = UndoFutFrames - posData.applyFutFrames_keepID = applyFutFrames - includeUnvisited = posData.includeUnvisitedInfo['Keep ID'] + if hoverID == 0 and self.highlightedID != 0: + self.clearHighlightedKeepIDs() + for ID in self.keptObjectsIDs: + self.highlightLabelID(ID) + return - if applyFutFrames: - self.store_data() + posData = self.data[self.pos_i] + try: + objIdx = posData.IDs_idxs[hoverID] + except KeyError: + return - self.logger.info('Applying to future frames...') - pbar = tqdm(total=posData.SizeT-posData.frame_i-1, ncols=100) - segmSizeT = len(posData.segm_data) - if not removeAnnot and posData.cca_df is not None: - delIDs = [ - ID for ID in posData.cca_df.index - if ID not in posData.IDs - ] - self.update_cca_df_deletedIDs(posData, delIDs) + obj = posData.rp[objIdx] + self.goToZsliceSearchedID(obj) - for i in range(posData.frame_i+1, segmSizeT): - lab = posData.allData_li[i]['labels'] - if lab is None and not includeUnvisited: - self.enqAutosave() - pbar.update(posData.SizeT-i) - break + for ID in self.keptObjectsIDs: + self.highlightLabelID(ID) - rp = posData.allData_li[i]['regionprops'] + def highlightIDonHoverCheckBoxToggled(self, checked): + doHighlight = ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() + ) + if not doHighlight: + self.highlightedID = 0 + self.initLookupTableLab() + else: + self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() + self.highlightSearchedID(self.highlightedID, force=True) + self.updatePropsWidget(self.highlightedID) + self.updateAllImages() - if lab is not None: - keepLab = self._keepObjects(lab=lab, rp=rp) - # Store change - posData.allData_li[i]['labels'] = keepLab.copy() - # Get the rest of the stored metadata based on the new lab - posData.frame_i = i - self.get_data() - self.store_data(autosave=False) - elif includeUnvisited: - # Unvisited frame (includeUnvisited = True) - lab = posData.segm_data[i] - rp = skimage.measure.regionprops(lab) - keepLab = self._keepObjects(lab=lab, rp=rp) - posData.segm_data[i] = keepLab + def highlightLabelID(self, ID, ax=0): + posData = self.data[self.pos_i] + try: + obj = posData.rp[posData.IDs_idxs[ID]] + except KeyError: + return - pbar.update() - pbar.close() + self.textAnnot[ax].highlightObject(obj) - # Back to current frame - if applyFutFrames: - posData.frame_i = self.current_frame_i - self.get_data() + def highlightSearchedID(self, ID, force=False, greyOthers=True): + self.highlightIDToolbar.setIDNoSignals(ID) - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction + if ID == 0: + self.highlightIDToolbar.setVisible(False) + return + + if ID == self.highlightedID and not force: + return + + doHighlight = self.propsDockWidget.isVisible() and ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() ) + if doHighlight: + self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() + ID = self.highlightedID - def get_curr_lab(self, curr_lab: np.ndarray|None = None, frame_i: int|None = None): - """Get the current labels for the position data. Hirarchically checks: - 1. If `curr_lab` is provided, use it. - 2. If `posData.lab` is not None, use it. - 3. If `posData.allData_li[frame_i]['labels']` exists, use it. - 4. If `posData.segm_data[frame_i]` exists, use it. + if self.highlightedID > 0: + self.clearHighlightedText() - If frame_i is None, uses the current frame index from `posData`. + self.searchedIDitemRight.setData([], []) + self.searchedIDitemLeft.setData([], []) - Parameters - ---------- - curr_lab : np.ndarray, optional - Current labels for the position data if it should be checked - if its not None first, by default None - frame_i : int, optional - Frame index to use for retrieving labels, by default None + posData = self.data[self.pos_i] + + self.highlightedID = ID + self.highlightIDToolbar.setVisible(True) + + objIdx = posData.IDs_idxs.get(ID) + if objIdx is None: + return + + obj = posData.rp[objIdx] + isObjVisible = self.isObjVisible(obj.bbox) + if not isObjVisible: + return + + if greyOthers: + self.textAnnot[0].grayOutAnnotations() + self.textAnnot[1].grayOutAnnotations() + + how_ax1 = self.drawIDsContComboBox.currentText() + how_ax2 = self.getAnnotateHowRightImage() + isOverlaySegm_ax1 = how_ax1.find("segm. masks") != -1 + isOverlaySegm_ax2 = how_ax2.find("segm. masks") != -1 + alpha = self.imgGrad.labelsAlphaSlider.value() - Returns - ------- - np.ndarray - Current labels for the position data - """ - posData = self.data[self.pos_i] - return self.object_counts.current_labels( - posData, - curr_lab=curr_lab, - frame_i=frame_i, - ) + if isOverlaySegm_ax1 or isOverlaySegm_ax2: + grayedLut = self.grayOutHighlightedLabels( + nonGrayedIDs={obj.label}, alpha=alpha + ) - def highlightIDonHoverCheckBoxToggled(self, checked): - doHighlight = ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() - ) - if not doHighlight: - self.highlightedID = 0 - self.initLookupTableLab() + cont = None + contours = None + if isOverlaySegm_ax1: + self.highLightIDLayerImg1.setLookupTable(grayedLut) + self.highLightIDLayerImg1.setImage(self.highlightedLab) + self.labelsLayerImg1.setOpacity(alpha / 3) else: - self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - self.highlightSearchedID(self.highlightedID, force=True) - self.updatePropsWidget(self.highlightedID) - self.updateAllImages() + contours = self.getObjContours(obj, all_external=True) + for cont in contours: + self.searchedIDitemLeft.addPoints(cont[:, 0] + 0.5, cont[:, 1] + 0.5) + + if isOverlaySegm_ax2: + self.highLightIDLayerRightImage.setLookupTable(grayedLut) + self.highLightIDLayerRightImage.setImage(self.highlightedLab) + self.labelsLayerRightImg.setOpacity(alpha / 3) + else: + if contours is None: + contours = self.getObjContours(obj, all_external=True) + for cont in contours: + self.searchedIDitemRight.addPoints(cont[:, 0] + 0.5, cont[:, 1] + 0.5) + + # Gray out all IDs excpet searched one + lut = self.lut.copy() # [:max(posData.IDs)+1] + lut[:ID] = lut[:ID] * 0.2 + lut[ID + 1 :] = lut[ID + 1 :] * 0.2 + self.img2.setLookupTable(lut) + + # Highlight text + self.highlightLabelID(ID, ax=0) + self.highlightLabelID(ID, ax=1) def highlightSearchedIDcheckBoxToggled(self, checked): self.highlightIDonHoverCheckBoxToggled(checked) @@ -897,14 +678,49 @@ def highlightSearchedIDcheckBoxToggled(self, checked): obj = posData.rp[objIdx] self.goToZsliceSearchedID(obj) - def setHighlightID(self, doHighlight): - if not doHighlight: - self.highlightedID = 0 - self.initLookupTableLab() + def initKeepObjLabelsLayers(self): + lut = np.zeros((len(self.lut), 4), dtype=np.uint8) + lut[:, :-1] = self.lut + lut[:, -1:] = 255 + lut[0] = [0, 0, 0, 0] + self.keepIDsTempLayerLeft.setLevels([0, len(lut)]) + self.keepIDsTempLayerLeft.setLookupTable(lut) + + def initPixelSizePropsDockWidget(self): + posData = self.data[self.pos_i] + PhysicalSizeX = posData.PhysicalSizeX + PhysicalSizeY = posData.PhysicalSizeY + PhysicalSizeZ = posData.PhysicalSizeZ + self.guiTabControl.initPixelSize(PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) + + def keepIDs_cb(self, checked): + if checked: + self.highlightedLab = np.zeros_like(self.currentLab2D) + if self.annotCcaInfoCheckbox.isChecked(): + self.annotCcaInfoCheckbox.setChecked(False) + self.annotIDsCheckbox.setChecked(True) + self.setDrawAnnotComboboxText() + self.uncheckLeftClickButtons(None) + self.initKeepObjLabelsLayers() + self.setAllIDs() else: - self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - self.highlightSearchedID(self.highlightedID, force=True) - self.updatePropsWidget(self.highlightedID) + # restore items to non-grayed out + self.clearTempBrushImage() + alpha = self.imgGrad.labelsAlphaSlider.value() + self.labelsLayerImg1.setOpacity(alpha) + self.labelsLayerRightImg.setOpacity(alpha) + self.ax1_contoursImageItem.setOpacity(1.0) + self.ax2_contoursImageItem.setOpacity(1.0) + self.ax1_lostObjImageItem.setOpacity(1.0) + self.ax2_lostObjImageItem.setOpacity(1.0) + self.ax1_lostTrackedObjImageItem.setOpacity(1.0) + self.ax2_lostTrackedObjImageItem.setOpacity(1.0) + + self.keepIDsToolbar.setVisible(checked) + self.highlightedIDopts = None + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) self.updateAllImages() def propsWidgetIDvalueChanged(self, ID): @@ -916,7 +732,7 @@ def propsWidgetIDvalueChanged(self, ID): propsQGBox = self.guiTabControl.propsQGBox obj_idx = posData.IDs_idxs.get(ID) if obj_idx is None: - s = f'Object ID {int(ID):d} does not exist' + s = f"Object ID {int(ID):d} does not exist" propsQGBox.notExistingIDLabel.setText(s) return @@ -924,6 +740,157 @@ def propsWidgetIDvalueChanged(self, ID): self.goToZsliceSearchedID(obj) self.updatePropsWidget(int(ID)) + def removeHighlightLabelID(self, IDs=None, ax=0): + posData = self.data[self.pos_i] + if IDs is None: + IDs = posData.IDs + + for ID in IDs: + obj = posData.rp[posData.IDs_idxs[ID]] + self.textAnnot[ax].removeHighlightObject(obj) + + def setAllIDs(self, onlyVisited=False): + for posData in self.data: + posData.allIDs = set() + for frame_i in range(len(posData.segm_data)): + if frame_i >= len(posData.allData_li): + break + lab = posData.allData_li[frame_i]["labels"] + if lab is None and onlyVisited: + break + + if lab is None: + rp = skimage.measure.regionprops(posData.segm_data[frame_i]) + else: + rp = posData.allData_li[frame_i]["regionprops"] + posData.allIDs.update([obj.label for obj in rp]) + + def setHighlighedIDfromToolbar(self, ID: int): + self.findID(ID=ID) + + def setHighlightID(self, doHighlight): + if not doHighlight: + self.highlightedID = 0 + self.initLookupTableLab() + else: + self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() + self.highlightSearchedID(self.highlightedID, force=True) + self.updatePropsWidget(self.highlightedID) + self.updateAllImages() + + def should_highlight_props_id( + self, + *, + dock_visible: bool, + highlight_checked: bool, + searched_highlight_checked: bool, + ) -> bool: + return dock_visible and (highlight_checked or searched_highlight_checked) + + def should_show_3d_property_controls(self, is_segm_3d: bool) -> bool: + return is_segm_3d + + def should_update_object_counts( + self, + *, + window_exists: bool, + is_visible: bool, + live_preview_checked: bool, + ) -> bool: + return window_exists and is_visible and live_preview_checked + + def should_update_props_widget( + self, + *, + dock_visible: bool, + object_id: int, + current_props_id: int, + ) -> bool: + return dock_visible and object_id != 0 and object_id != current_props_id + + def showPropsDockWidget(self, checked=False): + if self.showPropsDockButton.isExpand: + self.propsDockWidget.setVisible(False) + self.setHighlightID(False) + else: + self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() + if self.isSegm3D: + self.guiTabControl.propsQGBox.cellVolVox3D_SB.show() + self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.show() + self.guiTabControl.propsQGBox.cellVolFl3D_DSB.show() + self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.show() + else: + self.guiTabControl.propsQGBox.cellVolVox3D_SB.hide() + self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.hide() + self.guiTabControl.propsQGBox.cellVolFl3D_DSB.hide() + self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.hide() + + self.propsDockWidget.setVisible(True) + self.propsDockWidget.setEnabled(True) + self.updateAllImages() + + def snapshot_default_categories(self, *, is_segm_3d: bool) -> set[str]: + categories = { + "In current position", + "In all visited positions (current session)", + "In all visited positions (previous sessions)", + "In all loaded positions", + } + if is_segm_3d: + categories.add("In current z-slice") + return categories + + def timelapse_default_categories(self) -> set[str]: + return { + "In current frame", + "In all visited frames", + "In entire video", + "Unique objects in all visited frames", + "Unique objects in entire video", + } + + def updateKeepIDs(self, IDs): + posData = self.data[self.pos_i] + + self.clearHighlightedText() + + isAnyIDnotExisting = False + # Check if IDs from line edit are present in current keptObjectIDs list + for ID in IDs: + if ID not in posData.allIDs: + isAnyIDnotExisting = True + continue + if ID not in self.keptObjectsIDs: + self.keptObjectsIDs.append(ID, editText=False) + self.highlightLabelID(ID) + + # Check if IDs in current keptObjectsIDs are present in IDs from line edit + for ID in self.keptObjectsIDs: + if ID not in posData.allIDs: + isAnyIDnotExisting = True + continue + if ID not in IDs: + self.keptObjectsIDs.remove(ID, editText=False) + + self.updateTempLayerKeepIDs() + if isAnyIDnotExisting: + self.keptIDsLineEdit.warnNotExistingID() + else: + self.keptIDsLineEdit.setInstructionsText() + + def updateObjectCounts(self): + if self.countObjsWindow is None: + return + + if not self.countObjsWindow.isVisible(): + return + + if not self.countObjsWindow.livePreviewCheckbox.isChecked(): + return + + categoryCountMapper = self.countObjects() + self.countObjsWindow.updateCounts(categoryCountMapper) + def updatePropsWidget(self, ID, fromHover=False): if isinstance(ID, str): # Function called by currentTextChanged of channelCombobox or @@ -933,16 +900,14 @@ def updatePropsWidget(self, ID, fromHover=False): ID = int(ID) - update = self.should_update_props_widget( - dock_visible=self.propsDockWidget.isVisible(), - object_id=ID, - current_props_id=self.currentPropsID, + update = ( + self.propsDockWidget.isVisible() and ID != 0 and ID != self.currentPropsID ) if not update: return posData = self.data[self.pos_i] - if not hasattr(posData, 'rp'): + if not hasattr(posData, "rp"): return if posData.rp is None: @@ -960,34 +925,31 @@ def updatePropsWidget(self, ID, fromHover=False): obj_idx = posData.IDs_idxs.get(ID) if obj_idx is None: - s = f'Object ID {int(ID):d} does not exist' + s = f"Object ID {int(ID):d} does not exist" propsQGBox.notExistingIDLabel.setText(s) return - propsQGBox.notExistingIDLabel.setText('') + propsQGBox.notExistingIDLabel.setText("") self.currentPropsID = ID propsQGBox.idSB.setValue(ID) - doHighlight = self.should_highlight_props_id( - dock_visible=True, - highlight_checked=self.guiTabControl.highlightCheckbox.isChecked(), - searched_highlight_checked=( - self.guiTabControl.highlightSearchedCheckbox.isChecked() - ), + doHighlight = ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() ) if doHighlight: self.highlightSearchedID(ID) obj = posData.rp[obj_idx] - area_pxl = self.calculate_area_pxl( - is_segm_3d=self.isSegm3D, - z_proj_text=self.zProjComboBox.currentText(), - z_lab=self.z_lab(), - bbox_0=obj.bbox[0], - obj_image=obj.image, - obj_area=obj.area, - ) + if self.isSegm3D: + if self.zProjComboBox.currentText() == "single z-slice": + local_z = self.z_lab() - obj.bbox[0] + area_pxl = np.count_nonzero(obj.image[local_z]) + else: + area_pxl = np.count_nonzero(obj.image.max(axis=0)) + else: + area_pxl = obj.area propsQGBox.cellAreaPxlSB.setValue(area_pxl) @@ -996,34 +958,25 @@ def updatePropsWidget(self, ID, fromHover=False): PhysicalSizeY = pixelSizeQGBox.pixelHeightWidget.value() PhysicalSizeZ = pixelSizeQGBox.voxelDepthWidget.value() - area_um2 = self.calculate_area_um2( - area_pxl=area_pxl, - physical_size_x=PhysicalSizeX, - physical_size_y=PhysicalSizeY, - ) + yx_pxl_to_um2 = PhysicalSizeY * PhysicalSizeX + + area_um2 = area_pxl * yx_pxl_to_um2 propsQGBox.cellAreaUm2DSB.setValue(area_um2) if self.isSegm3D: - vol_vox_3D, vol_fl_3D = self.calculate_vol_3d( - obj_area=obj.area, - physical_size_x=PhysicalSizeX, - physical_size_y=PhysicalSizeY, - physical_size_z=posData.PhysicalSizeZ, - ) + PhysicalSizeZ = posData.PhysicalSizeZ + vol_vox_3D = obj.area + vol_fl_3D = vol_vox_3D * PhysicalSizeZ * PhysicalSizeY * PhysicalSizeX propsQGBox.cellVolVox3D_SB.setValue(vol_vox_3D) propsQGBox.cellVolFl3D_DSB.setValue(vol_fl_3D) - vol_vox, vol_fl = self.measurements.rotational_volume( - obj, PhysicalSizeY, PhysicalSizeX - ) + vol_vox, vol_fl = _calc_rot_vol(obj, PhysicalSizeY, PhysicalSizeX) propsQGBox.cellVolVoxSB.setValue(int(vol_vox)) propsQGBox.cellVolFlDSB.setValue(vol_fl) - elongation = self.calculate_elongation( - major_axis_length=obj.major_axis_length, - minor_axis_length=obj.minor_axis_length, - ) + minor_axis_length = max(1, obj.minor_axis_length) + elongation = obj.major_axis_length / minor_axis_length propsQGBox.elongationDSB.setValue(elongation) solidity = obj.solidity @@ -1039,36 +992,54 @@ def updatePropsWidget(self, ID, fromHover=False): try: _, filename = self.getPathFromChName(selectedChannel, posData) image = posData.ol_data_dict[filename][posData.frame_i] - except Exception as e: + except Exception: image = posData.img_data[posData.frame_i] - objData, img = self.get_object_and_background_images( - image=image, - is_segm_3d=self.isSegm3D, - pos_data_size_z=posData.SizeZ, - z_slice=self.zSliceScrollBar.sliderPosition(), - obj_slice=obj.slice, - obj_image=obj.image, - img1_image=self.img1.image, - ) + if posData.SizeZ > 1 and not self.isSegm3D: + z = self.zSliceScrollBar.sliderPosition() + objData = image[z][obj.slice][obj.image] + img = self.img1.image + else: + objData = image[obj.slice][obj.image] + img = image - stats = self.calculate_intensity_statistics(objData) - intensMeasurQGBox.minimumDSB.setValue(stats['min']) - intensMeasurQGBox.maximumDSB.setValue(stats['max']) - intensMeasurQGBox.meanDSB.setValue(stats['mean']) - intensMeasurQGBox.medianDSB.setValue(stats['median']) + intensMeasurQGBox.minimumDSB.setValue(np.min(objData)) + intensMeasurQGBox.maximumDSB.setValue(np.max(objData)) + intensMeasurQGBox.meanDSB.setValue(np.mean(objData)) + intensMeasurQGBox.medianDSB.setValue(np.median(objData)) funcDesc = intensMeasurQGBox.additionalMeasCombobox.currentText() func = intensMeasurQGBox.additionalMeasCombobox.functions[funcDesc] - - value = self.calculate_additional_measure( - func_desc=funcDesc, - func=func, - obj_data=objData, - img=img, - lab=posData.lab, - obj_area=obj.area, - vol_vox=vol_vox, - ) + if funcDesc == "Concentration": + bkgrVal = np.median(img[posData.lab == 0]) + amount = func(objData, bkgrVal, obj.area) + value = amount / vol_vox + elif funcDesc == "Amount": + bkgrVal = np.median(img[posData.lab == 0]) + amount = func(objData, bkgrVal, obj.area) + value = amount + else: + value = func(objData) intensMeasurQGBox.additionalMeasCombobox.indicator.setValue(value) + + def updateTempLayerKeepIDs(self): + if not self.keepIDsButton.isChecked(): + return + + keptLab = np.zeros_like(self.currentLab2D) + + posData = self.data[self.pos_i] + for obj in posData.rp: + if obj.label not in self.keptObjectsIDs: + continue + + if not self.isObjVisible(obj.bbox): + continue + + _slice = self.getObjSlice(obj.slice) + _objMask = self.getObjImage(obj.image, obj.bbox) + + keptLab[_slice][_objMask] = obj.label + + self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) diff --git a/cellacdc/mixins/object_search.py b/cellacdc/mixins/object_search.py index 9abebe4e8..00ddcc6f0 100644 --- a/cellacdc/mixins/object_search.py +++ b/cellacdc/mixins/object_search.py @@ -8,156 +8,129 @@ from cellacdc import apps, html_utils, widgets, workers -class ObjectSearchView: +class ObjectSearchMixin: """Qt-facing adapter around object-search commands.""" - def __init__(self, host): - self.host = host + """Headless object-search operations.""" + + def _startSearchIDworker(self, searchedID): + pos_data = self.data[self.pos_i] + + desc = "Searching ID in all frames..." + + self.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self.mainWin, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(pos_data.SizeT) + self.progressWin.show(self.app) + + self.searchIDthread = QThread() + self.searchIDworker = workers.SimpleWorker( + pos_data, + self.searchIDworkerCallback, + func_args=(searchedID,), + ) + self.searchIDworker.frame_i_found = None + self.searchIDworker.moveToThread(self.searchIDthread) + + self.searchIDworker.signals.finished.connect(self.searchIDthread.quit) + self.searchIDworker.signals.finished.connect(self.searchIDworker.deleteLater) + self.searchIDthread.finished.connect(self.searchIDthread.deleteLater) + + self.searchIDworker.signals.critical.connect(self.searchIDworkerCritical) + self.searchIDworker.signals.initProgressBar.connect(self.workerInitProgressbar) + self.searchIDworker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.searchIDworker.signals.progress.connect(self.workerProgress) + self.searchIDworker.signals.finished.connect(self.searchIDworkerFinished) + + self.searchIDthread.started.connect(self.searchIDworker.run) + self.searchIDthread.start() + + self.searchIDworkerLoop = QEventLoop() + self.searchIDworkerLoop.exec_() + + return self.searchIDworker.frame_i_found + + def askGoToFrameFoundID(self, searchedID, frame_i_found): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(f""" + Object ID {searchedID} was found at frame n. {frame_i_found + 1}.

+ Do you want to go to frame n. {frame_i_found + 1}. + """) + noButton, yesButton = msg.information( + self, + f"ID {searchedID} found at frame n. {frame_i_found + 1}", + txt, + buttonsTexts=( + "No, stay on current frame", + f"Yes, go to frame n. {frame_i_found + 1}", + ), + ) + return msg.clickedButton == yesButton + def findID(self, checked=False, ID=None): - pos_data = self.host.data[self.host.pos_i] + posData = self.data[self.pos_i] if ID is None: - search_id_dialog = apps.FindIDDialog( - title='Search object by ID', - msg='Enter object ID to find and highlight', - parent=self.host, + searchIDdialog = apps.FindIDDialog( + title="Search object by ID", + msg="Enter object ID to find and highlight", + parent=self, isInteger=True, ) - search_id_dialog.exec_() - if search_id_dialog.cancel: + searchIDdialog.exec_() + if searchIDdialog.cancel: return - searched_id = search_id_dialog.EntryID + searchedID = searchIDdialog.EntryID else: - searched_id = ID + searchedID = ID - if searched_id in pos_data.IDs: - self.goToObjectID(searched_id) + if searchedID in posData.IDs: + self.goToObjectID(searchedID) return - if pos_data.SizeT == 1: - self.warnIDnotFound(searched_id) + if posData.SizeT == 1: + self.warnIDnotFound(searchedID) return - if searched_id in pos_data.lost_IDs: - self.goToLostObjectID(searched_id) + if searchedID in posData.lost_IDs: + self.goToLostObjectID(searchedID) return - tracked_lost_ids = self.host.getTrackedLostIDs() - if searched_id in tracked_lost_ids: - self.goToAcceptedLostObjectID(searched_id) + tracked_lost_IDs = self.getTrackedLostIDs() + if searchedID in tracked_lost_IDs: + self.goToAcceptedLostObjectID(searchedID) return - self.host.logger.info(f'Searching ID {searched_id} in other frames...') + self.logger.info(f"Searching ID {searchedID} in other frames...") - frame_i_found = self.startSearchIDworker(searched_id) + frame_i_found = self.startSearchIDworker(searchedID) if frame_i_found is None: - self.warnIDnotFound(searched_id) + self.warnIDnotFound(searchedID) return - self.host.logger.info( - f'Object ID {searched_id} found at frame n. {frame_i_found+1}.' + self.logger.info( + f"Object ID {searchedID} found at frame n. {frame_i_found + 1}." ) - proceed = self.askGoToFrameFoundID(searched_id, frame_i_found) + proceed = self.askGoToFrameFoundID(searchedID, frame_i_found) if not proceed: return - pos_data.frame_i = frame_i_found - self.host.get_data() - self.host.updateAllImages() - self.host.updateScrollbars() - - self.goToObjectID(searched_id) - - def startSearchIDworker(self, searchedID): - self.host.setDisabled(True) - try: - return self._startSearchIDworker(searchedID) - finally: - self.host.setDisabled(False) - self.host.activateWindow() - - def _startSearchIDworker(self, searchedID): - pos_data = self.host.data[self.host.pos_i] - - desc = 'Searching ID in all frames...' - - self.host.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self.host.mainWin, pbarDesc=desc - ) - self.host.progressWin.mainPbar.setMaximum(pos_data.SizeT) - self.host.progressWin.show(self.host.app) - - self.host.searchIDthread = QThread() - self.host.searchIDworker = workers.SimpleWorker( - pos_data, - self.searchIDworkerCallback, - func_args=(searchedID,), - ) - self.host.searchIDworker.frame_i_found = None - self.host.searchIDworker.moveToThread(self.host.searchIDthread) - - self.host.searchIDworker.signals.finished.connect( - self.host.searchIDthread.quit - ) - self.host.searchIDworker.signals.finished.connect( - self.host.searchIDworker.deleteLater - ) - self.host.searchIDthread.finished.connect( - self.host.searchIDthread.deleteLater - ) - - self.host.searchIDworker.signals.critical.connect( - self.searchIDworkerCritical - ) - self.host.searchIDworker.signals.initProgressBar.connect( - self.host.workerInitProgressbar - ) - self.host.searchIDworker.signals.progressBar.connect( - self.host.workerUpdateProgressbar - ) - self.host.searchIDworker.signals.progress.connect( - self.host.workerProgress - ) - self.host.searchIDworker.signals.finished.connect( - self.searchIDworkerFinished - ) - - self.host.searchIDthread.started.connect(self.host.searchIDworker.run) - self.host.searchIDthread.start() - - self.host.searchIDworkerLoop = QEventLoop() - self.host.searchIDworkerLoop.exec_() - - return self.host.searchIDworker.frame_i_found - - def searchIDworkerCritical(self, error): - self.host.searchIDworkerLoop.exit() - self.host.workerCritical(error) - - def searchIDworkerFinished(self): - if self.host.progressWin is not None: - self.host.progressWin.workerFinished = True - self.host.progressWin.close() - self.host.progressWin = None + posData.frame_i = frame_i_found + self.get_data() + self.updateAllImages() + self.updateScrollbars() - self.host.searchIDworkerLoop.exit() + self.goToObjectID(searchedID) - def searchIDworkerCallback(self, posData, searchedID): - self.host.searchIDworker.signals.initProgressBar.emit(0) - self.host.setAllIDs() - self.host.searchIDworker.signals.initProgressBar.emit(posData.SizeT) - frame_i_found = self.find_frame_with_id( - posData, - searchedID, - progress_callback=self.host.searchIDworker.signals.progressBar.emit, - ) - self.host.searchIDworker.frame_i_found = frame_i_found - - def warnIDnotFound(self, searchedID): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(f""" + def findNextNewIdWorkerFinished(self, next_frame_i): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None - """Headless object-search operations.""" + self.navSpinBox.setValue(next_frame_i + 1) + self.framesScrollBarReleased() def find_frame_with_id( self, @@ -173,123 +146,160 @@ def find_frame_with_id( progress_callback=progress_callback, ) - Object ID {searchedID} was not found.

- """) - msg.warning(self.host, f'ID {searchedID} not found', txt) - - def goToObjectID(self, ID): - pos_data = self.host.data[self.host.pos_i] - obj_idx = pos_data.IDs_idxs[ID] - obj = pos_data.rp[obj_idx] - self.host.goToZsliceSearchedID(obj) + def goToAcceptedLostObjectID(self, acceptedLostID): + posData = self.data[self.pos_i] + frame_i = posData.frame_i + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[frame_i - 1]["IDs_idxs"] + obj = prev_rp[prev_IDs_idxs[acceptedLostID]] + self.goToZsliceSearchedID(obj) - self.host.highlightSearchedID(ID) - props_qgbox = self.host.guiTabControl.propsQGBox - props_qgbox.idSB.setValue(ID) + self.updateLostTrackedContoursImage(tracked_lost_IDs=[acceptedLostID]) def goToLostObjectID(self, lostID, color=(255, 165, 0, 255)): - pos_data = self.host.data[self.host.pos_i] - frame_i = pos_data.frame_i - prev_rp = pos_data.allData_li[frame_i - 1]['regionprops'] - prev_ids_idxs = pos_data.allData_li[frame_i - 1]['IDs_idxs'] - obj = prev_rp[prev_ids_idxs[lostID]] - self.host.goToZsliceSearchedID(obj) - - image_item = self.host.getLostObjImageItem(0) - if not hasattr(self.host, 'lostObjContoursImage'): - self.host.initLostObjContoursImage() + posData = self.data[self.pos_i] + frame_i = posData.frame_i + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[frame_i - 1]["IDs_idxs"] + obj = prev_rp[prev_IDs_idxs[lostID]] + self.goToZsliceSearchedID(obj) + + imageItem = self.getLostObjImageItem(0) + if not hasattr(self, "lostObjContoursImage"): + self.initLostObjContoursImage() else: - self.host.lostObjContoursImage[:] = 0 + self.lostObjContoursImage[:] = 0 contours = [] - obj_contours = self.host.getObjContours(obj, all_external=True) + obj_contours = self.getObjContours(obj, all_external=True) contours.extend(obj_contours) - self.host.addLostObjsToLostObjImage(obj, lostID) - self.host.drawLostObjContoursImage( - image_item, contours, thickness=2, color=color - ) + self.addLostObjsToLostObjImage(obj, lostID) + self.drawLostObjContoursImage(imageItem, contours, thickness=2, color=color) - def goToAcceptedLostObjectID(self, acceptedLostID): - pos_data = self.host.data[self.host.pos_i] - frame_i = pos_data.frame_i - prev_rp = pos_data.allData_li[frame_i - 1]['regionprops'] - prev_ids_idxs = pos_data.allData_li[frame_i - 1]['IDs_idxs'] - obj = prev_rp[prev_ids_idxs[acceptedLostID]] - self.host.goToZsliceSearchedID(obj) - - self.host.updateLostTrackedContoursImage( - tracked_lost_IDs=[acceptedLostID] - ) + def goToObjectID(self, ID): + posData = self.data[self.pos_i] + objIdx = posData.IDs_idxs[ID] + obj = posData.rp[objIdx] + self.goToZsliceSearchedID(obj) - def askGoToFrameFoundID(self, searchedID, frame_i_found): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(f""" - Object ID {searchedID} was found at frame n. {frame_i_found+1}.

- Do you want to go to frame n. {frame_i_found+1}. - """) - noButton, yesButton = msg.information( - self.host, - f'ID {searchedID} found at frame n. {frame_i_found+1}', - txt, - buttonsTexts=( - 'No, stay on current frame', - f'Yes, go to frame n. {frame_i_found+1}', - ), - ) - return msg.clickedButton == yesButton + self.highlightSearchedID(ID) + propsQGBox = self.guiTabControl.propsQGBox + propsQGBox.idSB.setValue(ID) + + def searchIDworkerCallback(self, posData, searchedID): + self.searchIDworker.signals.initProgressBar.emit(0) + self.setAllIDs() + self.searchIDworker.signals.initProgressBar.emit(posData.SizeT) + frame_i_found = None + for frame_i in range(len(posData.segm_data)): + if frame_i >= len(posData.allData_li): + break + lab = posData.allData_li[frame_i]["labels"] + if lab is None: + rp = skimage.measure.regionprops(posData.segm_data[frame_i]) + IDs = set([obj.label for obj in rp]) + else: + IDs = posData.allData_li[frame_i]["IDs"] + + if searchedID in IDs: + frame_i_found = frame_i + break + + self.searchIDworker.signals.progressBar.emit(1) + + self.searchIDworker.frame_i_found = frame_i_found + + def searchIDworkerCritical(self, error): + self.searchIDworkerLoop.exit() + self.workerCritical(error) + + def searchIDworkerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.searchIDworkerLoop.exit() def skipForwardToNewID(self): - self.host.progressWin = apps.QDialogWorkerProgress( - title='Searching the next frame with a new object', - parent=self.host, - pbarDesc='Searching the next frame with a new object...', + self.progressWin = apps.QDialogWorkerProgress( + title="Searching the next frame with a new object", + parent=self, + pbarDesc="Searching the next frame with a new object...", ) - self.host.progressWin.show(self.host.app) - self.host.progressWin.mainPbar.setMaximum(0) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) self.startFindNextNewIdWorker() def startFindNextNewIdWorker(self): - pos_data = self.host.data[self.host.pos_i] - self.host._thread = QThread() - self.host.findNextNewIdWorker = workers.FindNextNewIdWorker( - pos_data, self.host + posData = self.data[self.pos_i] + self._thread = QThread() + self.findNextNewIdWorker = workers.FindNextNewIdWorker(posData, self) + self.findNextNewIdWorker.moveToThread(self._thread) + + self.findNextNewIdWorker.signals.finished.connect(self._thread.quit) + self.findNextNewIdWorker.signals.finished.connect( + self.findNextNewIdWorker.deleteLater ) - self.host.findNextNewIdWorker.moveToThread(self.host._thread) + self._thread.finished.connect(self._thread.deleteLater) - self.host.findNextNewIdWorker.signals.finished.connect( - self.host._thread.quit - ) - self.host.findNextNewIdWorker.signals.finished.connect( - self.host.findNextNewIdWorker.deleteLater - ) - self.host._thread.finished.connect(self.host._thread.deleteLater) - - self.host.findNextNewIdWorker.signals.finished.connect( + self.findNextNewIdWorker.signals.finished.connect( self.findNextNewIdWorkerFinished ) - self.host.findNextNewIdWorker.signals.progress.connect( - self.host.workerProgress + self.findNextNewIdWorker.signals.progress.connect(self.workerProgress) + self.findNextNewIdWorker.signals.initProgressBar.connect( + self.workerInitProgressbar ) - self.host.findNextNewIdWorker.signals.initProgressBar.connect( - self.host.workerInitProgressbar + self.findNextNewIdWorker.signals.progressBar.connect( + self.workerUpdateProgressbar ) - self.host.findNextNewIdWorker.signals.progressBar.connect( - self.host.workerUpdateProgressbar + self.findNextNewIdWorker.signals.critical.connect(self.workerCritical) + + self._thread.started.connect(self.findNextNewIdWorker.run) + self._thread.start() + + @disableWindow + def startSearchIDworker(self, searchedID): + posData = self.data[self.pos_i] + + desc = "Searching ID in all frames..." + + self.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self.mainWin, pbarDesc=desc ) - self.host.findNextNewIdWorker.signals.critical.connect( - self.host.workerCritical + self.progressWin.mainPbar.setMaximum(posData.SizeT) + self.progressWin.show(self.app) + + self.searchIDthread = QThread() + self.searchIDworker = workers.SimpleWorker( + posData, self.searchIDworkerCallback, func_args=(searchedID,) ) + self.searchIDworker.frame_i_found = None + self.searchIDworker.moveToThread(self.searchIDthread) - self.host._thread.started.connect(self.host.findNextNewIdWorker.run) - self.host._thread.start() + self.searchIDworker.signals.finished.connect(self.searchIDthread.quit) + self.searchIDworker.signals.finished.connect(self.searchIDworker.deleteLater) + self.searchIDthread.finished.connect(self.searchIDthread.deleteLater) - def findNextNewIdWorkerFinished(self, next_frame_i): - if self.host.progressWin is not None: - self.host.progressWin.workerFinished = True - self.host.progressWin.close() - self.host.progressWin = None + self.searchIDworker.signals.critical.connect(self.searchIDworkerCritical) + self.searchIDworker.signals.initProgressBar.connect(self.workerInitProgressbar) + self.searchIDworker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.searchIDworker.signals.progress.connect(self.workerProgress) + self.searchIDworker.signals.finished.connect(self.searchIDworkerFinished) + + self.searchIDthread.started.connect(self.searchIDworker.run) + self.searchIDthread.start() + + self.searchIDworkerLoop = QEventLoop() + self.searchIDworkerLoop.exec_() - self.host.navSpinBox.setValue(next_frame_i + 1) - self.host.framesScrollBarReleased() \ No newline at end of file + return self.searchIDworker.frame_i_found + + def warnIDnotFound(self, searchedID): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(f""" + Object ID {searchedID} was not found.

+ """) + msg.warning(self, f"ID {searchedID} not found", txt) diff --git a/cellacdc/mixins/points.py b/cellacdc/mixins/points.py index 3165b5dfe..7e7a1d26e 100644 --- a/cellacdc/mixins/points.py +++ b/cellacdc/mixins/points.py @@ -19,9 +19,30 @@ ) -class PointsViewModel: +class PointsMixin: """Application-facing commands for point-layer data transforms.""" + def add_click_point( + self, + points_data_pos, + frame_i: int, + x: float, + y: float, + point_id: int, + *, + size_z: int = 1, + z_slice: int | None = None, + ): + return add_click_point( + points_data_pos, + frame_i, + x, + y, + point_id, + size_z=size_z, + z_slice=z_slice, + ) + def click_points_table_filename( self, basename: str, @@ -29,6 +50,22 @@ def click_points_table_filename( ) -> str: return click_points_table_filename(basename, table_endname) + def click_points_table_to_data(self, df, *, size_z: int = 1): + return click_points_table_to_data(df, size_z=size_z) + + def flatten_frame_points_data( + self, + frame_points_data, + *, + z_slice: int | None = None, + z_radius: int = 0, + ): + return flatten_frame_points_data( + frame_points_data, + z_slice=z_slice, + z_radius=z_radius, + ) + def load_click_points_table(self, filepath): return load_click_points_table(filepath) @@ -45,32 +82,6 @@ def loaded_table_to_points_data( ): return points_table_to_data(df, t_col, z_col, y_col, x_col) - def save_click_points_table( - self, - filepath, - df, - sort_by=('frame_i', 'Cell_ID'), - ): - return save_click_points_table(filepath, df, sort_by=sort_by) - - def click_points_table_to_data(self, df, *, size_z: int = 1): - return click_points_table_to_data(df, size_z=size_z) - - def remove_click_points( - self, - frame_points_data, - points, - *, - z_slice: int | None = None, - z_radius: int = 0, - ) -> list[int]: - return remove_click_points( - frame_points_data, - points, - z_slice=z_slice, - z_radius=z_radius, - ) - def next_click_point_id( self, points_data_pos, @@ -100,36 +111,25 @@ def point_id_already_new( known_ids, ) - def add_click_point( - self, - points_data_pos, - frame_i: int, - x: float, - y: float, - point_id: int, - *, - size_z: int = 1, - z_slice: int | None = None, - ): - return add_click_point( - points_data_pos, - frame_i, - x, - y, - point_id, - size_z=size_z, - z_slice=z_slice, - ) - - def flatten_frame_points_data( + def remove_click_points( self, frame_points_data, + points, *, z_slice: int | None = None, z_radius: int = 0, - ): - return flatten_frame_points_data( + ) -> list[int]: + return remove_click_points( frame_points_data, + points, z_slice=z_slice, z_radius=z_radius, ) + + def save_click_points_table( + self, + filepath, + df, + sort_by=("frame_i", "Cell_ID"), + ): + return save_click_points_table(filepath, df, sort_by=sort_by) diff --git a/cellacdc/mixins/points_layers.py b/cellacdc/mixins/points_layers.py index 99fb59a68..937df7abe 100644 --- a/cellacdc/mixins/points_layers.py +++ b/cellacdc/mixins/points_layers.py @@ -4,6 +4,7 @@ import os from collections import defaultdict +from collections.abc import Mapping from copy import deepcopy from datetime import datetime from functools import partial @@ -11,7 +12,6 @@ import matplotlib import numpy as np import pyqtgraph as pg -from collections.abc import Mapping import skimage.draw import skimage.measure from qtpy.QtCore import QTimer @@ -20,253 +20,181 @@ from cellacdc import _warnings, apps, colors, exception_handler, html_utils, widgets -class PointsLayersView: +class PointsLayersMixin: """Qt-facing adapter around points-layer workflows.""" - LEGACY_METHODS = ( - 'checkAskSavePointsLayers', - 'askSavePointsLayer', - 'reinitPointsLayers', - 'clearPointsLayers', - 'askSaveAddedPoints', - 'pointsLayerToggled', - 'addPointsLayerTriggered', - 'logLoadedTablePointsLayer', - 'buttonAddPointsByClickingActive', - 'setupAddPointsByClicking', - 'storeUndoAddPoint', - 'undoAddPoint', - 'getAddedPointId', - 'addPointsByClickingScatterItemHoverEntered', - 'autoPilotZoomToObjToggled', - 'savePointsAddedByClickingFromEndname', - 'markPointsLayerDirty', - 'flushDirtyPointsLayersAutosave', - 'savePointsAddedByClicking', - 'updatePointsLayerClickEntryTableEndname', - 'pointsLayerDfsToData', - 'pointsLayerLoadedDfsToData', - 'setPointsLayerLoadedDfEndanme', - 'pointsLayerClicksDfsToData', - 'pointsLayerDataToDf', - 'restartZoomAutoPilot', - 'resizeRangeWelcomeText', - 'zoomToObj', - 'addPointsByClickingButtonToggled', - 'autoZoomNextObj', - 'autoZoomPrevObj', - 'pointsLayerAutoPilot', - 'getClickEntryTableFilepaths', - 'getClickEntryNewerRecoveryFilepaths', - 'askLoadNewerRecoveryClickEntryDfs', - 'checkClickEntryTableEndnameExists', - 'checkLoadedTableIds', - 'addPointsLayer', - 'loadClickEntryDfs', - 'removeClickedPoints', - 'restorePrevPointIdRightClick', - 'getClickedPointNewId', - 'setHoverCircleAddPoint', - 'isPointIdAlreadyNew', - 'addClickedPoint', - 'showPointsLayerIdsToggled', - 'removePointsLayer', - 'editPointsLayerAppearance', - 'loadPointsLayerWeighingData', - 'pointLayerToolbuttonToggled', - 'getCentroidsPointsData', - 'drawPointsLayers', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - - def checkAskSavePointsLayers(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - if action.layerTypeIdx != 4: - continue + """Headless decisions for points-layer GUI workflows.""" - scatterItem = action.scatterItem - xx, yy = scatterItem.getData() + recovery_tolerance_seconds = 15 - if xx is None or len(xx) == 0: - toolButton = action.button - tableEndName = toolButton.clickEntryTableEndName - # Check in other loaded pos - are_there_points_to_save = False - for pos_i, _posData in enumerate(self.data): - if pos_i == self.pos_i: - continue + def addClickedPoint(self, action, x, y, id): + x, y = round(x, 2), round(y, 2) + posData = self.data[self.pos_i] + pointsDataPos = action.pointsData.get(self.pos_i) + if pointsDataPos is None: + action.pointsData[self.pos_i] = {} - df = _posData.clickEntryPointsDfs.get(tableEndName) - if df is None: - continue + framePointsData = action.pointsData[self.pos_i].get(posData.frame_i) + if action.snapToMax: + radius = round(action.pointSize / 2) + rr, cc = skimage.draw.disk((round(y), round(x)), radius) + idx_max = (self.img1.image[rr, cc]).argmax() + y, x = rr[idx_max], cc[idx_max] - are_there_points_to_save = True - break + if framePointsData is None: + if posData.SizeZ > 1: + zSlice = self.zSliceScrollBar.sliderPosition() + action.pointsData[self.pos_i][posData.frame_i] = { + zSlice: {"x": [x], "y": [y], "id": [id]} + } + else: + action.pointsData[self.pos_i][posData.frame_i] = { + "x": [x], + "y": [y], + "id": [id], + } + else: + if posData.SizeZ > 1: + zSlice = self.zSliceScrollBar.sliderPosition() + z_data = framePointsData.get(zSlice) + if z_data is None: + framePointsData[zSlice] = {"x": [x], "y": [y], "id": [id]} + else: + framePointsData[zSlice]["x"].append(x) + framePointsData[zSlice]["y"].append(y) + framePointsData[zSlice]["id"].append(id) + action.pointsData[self.pos_i][posData.frame_i] = framePointsData + else: + pointsDataPos = action.pointsData[self.pos_i] + framePointsData = pointsDataPos[posData.frame_i] + framePointsData["x"].append(x) + framePointsData["y"].append(y) + framePointsData["id"].append(id) - if not are_there_points_to_save: - continue + self.markPointsLayerDirty(action=action) - cancel = self.askSavePointsLayer(action) - if cancel: - return cancel + def addPointsByClickingButtonToggled(self, checked=True, sender=None): + if sender is None: + sender = self.sender() + if not sender.isChecked(): + action = sender.action + action.scatterItem.setVisible(False) + return - return False + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(sender) + self.connectLeftClickButtons() + action = sender.action + action.scatterItem.setVisible(True) + self.ax1_BrushCircle.setBrush(action.brushColor) + self.ax1_BrushCircle.setPen(action.penColor) - def askSavePointsLayer(self, action): - toolButton = action.button - tableEndName = toolButton.clickEntryTableEndName - saveAction = toolButton.saveAction + def addPointsByClickingScatterItemHoverEntered(self, item, points, event): + point = points[0] + point_id = point.data() + toolButton = item.action.button + toolButton.rightClickIDSpinbox.prevId = toolButton.rightClickIDSpinbox.value() + toolButton.rightClickIDSpinbox.setValue(point_id) - txt = html_utils.paragraph(f""" + @exception_handler + def addPointsLayer(self, toolbar=None): + proceed = self.checkLoadedTableIds(toolbar) - """Headless decisions for points-layer GUI workflows.""" + if self.addPointsWin.cancel or not proceed: + self.addPointsWin = None + self.logger.info("Adding points layer cancelled.") + return - recovery_tolerance_seconds = 15 + if toolbar is None: + toolbar = self.pointsLayersToolbar - def click_entry_table_filename( - self, - basename: str, - table_endname: str, - ) -> str: - table_basename = basename if basename.endswith('_') else f'{basename}_' - filename = f'{table_basename}{table_endname}' - if not filename.endswith('.csv'): - filename = f'{filename}.csv' - return filename + symbol = self.addPointsWin.symbol + color = self.addPointsWin.color + pointSize = self.addPointsWin.pointSize + zRadius = int((self.addPointsWin.zHeight - 1) / 2) + r, g, b, a = color.getRgb() - def should_load_recovery_table( - self, - *, - recovery_exists: bool, - main_exists: bool, - recovery_mtime: float | None, - main_mtime: float | None, - ) -> bool: - if not recovery_exists: - return False - if not main_exists: - return True - if recovery_mtime is None or main_mtime is None: - return False - return ( - recovery_mtime - > main_mtime + self.recovery_tolerance_seconds + scatterItem = widgets.PointsScatterPlotItem( + [], + [], + ax=self.ax1, + symbol=symbol, + pxMode=False, + size=pointSize, + brush=pg.mkBrush(color=(r, g, b, 100)), + pen=pg.mkPen(width=2, color=(r, g, b)), + hoverable=True, + hoverBrush=pg.mkBrush((r, g, b, 200)), + tip=None, + show_data_as_tip=True, ) + self.ax1.addItem(scatterItem) - def should_compute_points_layer( - self, - *, - layer_type_index: int, - compute_points_layers: bool, - ) -> bool: - return layer_type_index < 2 and compute_points_layers - - def should_log_missing_frame_points(self, layer_type_index: int) -> bool: - return layer_type_index != 4 + toolButton = widgets.PointsLayerToolButton(symbol, color, parent=self) + toolButton.actions = [] + toolButton.setCheckable(True) + toolButton.setChecked(True) + if self.addPointsWin.keySequence is not None: + toolButton.setShortcut(self.addPointsWin.keySequence) + toolButton.toggled.connect(self.pointLayerToolbuttonToggled) + toolButton.sigEditAppearance.connect(self.editPointsLayerAppearance) + toolButton.sigShowIdsToggled.connect(self.showPointsLayerIdsToggled) + toolButton.sigRemove.connect(partial(self.removePointsLayer, toolbar=toolbar)) - def should_use_z_slice( - self, - *, - z_projection_mode: str, - size_z: int, - frame_points_data: Mapping, - ) -> bool: - return ( - z_projection_mode == 'single z-slice' - and size_z > 1 - and 'x' not in frame_points_data - ) + action = toolbar.addWidget(toolButton) + action.state = self.addPointsWin.state() - Do you want to save the points you added - (table called {tableEndName}.csv)? - """ - ) - msg = widgets.myMessageBox(wrapText=False) - _, _, saveButton = msg.question( - self.host, 'Save points layer?', txt, - buttonsTexts=('Cancel', 'No, do not save', 'Yes, save points') + toolButton.action = action + action.brushColor = (r, g, b, 100) + action.brushColorId0 = ( + *colors.hex_to_rgb( + colors.lighten_color(np.array(action.brushColor) / 255, 0.3) + ), + 100, ) - if msg.clickedButton == saveButton: - self.savePointsAddedByClicking(saveAction.saveToolbutton, None) - - return msg.cancel + action.penColor = (r, g, b) + action.penColorId0 = colors.lighten_color(np.array(action.penColor) / 255, 0.3) + action.pointSize = pointSize + action.zRadius = zRadius + action.button = toolButton + action.scatterItem = scatterItem + scatterItem.action = action + action.layerType = self.addPointsWin.layerType + action.layerTypeIdx = self.addPointsWin.layerTypeIdx + action.loadedDf = self.addPointsWin.loadedDf + self.data[self.pos_i] + action.pointsData = {} + action.pointsData[self.pos_i] = self.addPointsWin.pointsData + action.snapToMax = False + action.loadedDfInfo = self.addPointsWin.loadedDfInfo + self.setPointsLayerLoadedDfEndanme(action) - def reinitPointsLayers(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - toolbar.removeAction(action) - toolbar.setVisible(False) - self.autoPilotZoomToObjToolbar.setVisible(False) + if self.addPointsWin.layerType.startswith("Click to annotate point"): + action.snapToMax = self.addPointsWin.snapToMaxToggle.isChecked() + isLoadedDf = self.addPointsWin.clickEntryIsLoadedDf + self.setupAddPointsByClicking(toolButton, isLoadedDf, toolbar=toolbar) + if self.addPointsWin.autoPilotToggle.isChecked(): + self.autoPilotZoomToObjToggle.setChecked(True) - def clearPointsLayers(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - try: - action.scatterItem.clear() - except Exception as e: - continue + weighingChannel = self.addPointsWin.weighingChannel + self.loadPointsLayerWeighingData(action, weighingChannel) - def askSaveAddedPoints(self): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - 'Do you want to save the annotated points?' - ) - _, noButton, yesButton = msg.question( - self.host, 'Save?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') - ) - if msg.clickedButton != yesButton: - return + self.drawPointsLayers() - for toolbar in self.pointsLayersToolbars: - for action in self.pointsLayersToolbar.actions(): - try: - if 'Save annotated' in action.text(): - action.trigger() - except Exception as err: - pass + if toolbar == self.promptSegmentPointsLayerToolbar: + self.promptSegmentPointsLayerToolbar.isPointsLayerInit = True + self.magicPromptsToolbar.clearPointsAction.setDisabled(False) + self.magicPromptsToolbar.clearPointsActionOnZoom.setDisabled(False) + QTimer.singleShot(200, self.magicPromptsToolbar.selectModelAction.trigger) - def pointsLayerToggled(self, checked): - if not checked: - for action in self.pointsLayersToolbar.actions(): - try: - if 'Save annotated' in action.text(): - self.askSaveAddedPoints() - break - except Exception as err: - pass - self.pointsLayersToolbar.setVisible(checked) - self.autoPilotZoomToObjToolbar.setVisible(checked) - if self.pointsLayersNeverToggled: - self.pointsLayersToolbar.sigAddPointsLayer.emit() - self.pointsLayersNeverToggled = False - QTimer.singleShot(200, self.autoRange) + self.addPointsWin = None def addPointsLayerTriggered(self, checked=False, toolbar=None): if toolbar is None: toolbar = self.pointsLayersToolbar if self.addPointsWin is not None: - self.logger.info( - 'Add points layer window is already open. Cannot add now.' - ) + self.logger.info("Add points layer window is already open. Cannot add now.") return onlyMouseClicks = toolbar == self.promptSegmentPointsLayerToolbar @@ -279,14 +207,14 @@ def addPointsLayerTriggered(self, checked=False, toolbar=None): hideFromTableSection=onlyMouseClicks, hideManualEntrySection=onlyMouseClicks, hideWithMouseClicksSection=False, - parent=self.host, + parent=self, ) - cmap = matplotlib.colormaps['gist_rainbow'] + cmap = matplotlib.colormaps["gist_rainbow"] i = np.random.default_rng(seed=123).uniform() for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): + if not hasattr(action, "layerTypeIdx"): continue - rgb = [round(c*255) for c in cmap(i)][:3] + rgb = [round(c * 255) for c in cmap(i)][:3] self.addPointsWin.appearanceGroupbox.colorButton.setColor(rgb) break @@ -305,163 +233,372 @@ def addPointsLayerTriggered(self, checked=False, toolbar=None): partial( self.addPointsWin.sigCheckClickEntryTableEndnameExists.emit, self.addPointsWin.clickEntryTableEndname.text(), - False - ) + False, + ), ) - def logLoadedTablePointsLayer(self, df, filename: str): - separator = f'-'*100 - header = f'First 10 rows of loaded table - "{filename}":' - footer = f'Number of points: {len(df)}' - text = ( - f'{separator}\n' - f'{header}\n\n' - f'{df.head(10)}\n\n' - f'{footer}\n' - f'{separator}' - ) - if filename: - text = f'{text}\nFilename: {filename}' - self.logger.info(text) - - def buttonAddPointsByClickingActive(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - if action.layerTypeIdx == 4 and action.button.isChecked(): - return action.button + def askLoadNewerRecoveryClickEntryDfs(self, tableEndName, newer_recovery_filepaths): + if not newer_recovery_filepaths: + return False - def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): - self.LeftClickButtons.append(toolButton) - posData = self.data[self.pos_i] - tableEndName = self.addPointsWin.clickEntryTableEndnameText - if isLoadedDf is not None: - posData = self.data[self.pos_i] - tableEndName = tableEndName[len(posData.basename):] - self.loadClickEntryDfs(tableEndName) + num_tables = len(newer_recovery_filepaths) + filepath, recovery_filepath = newer_recovery_filepaths[0] + main_timestamp = datetime.fromtimestamp(os.path.getmtime(filepath)).strftime( + "%a %d. %b. %y - %H:%M:%S" + ) + recovery_timestamp = datetime.fromtimestamp( + os.path.getmtime(recovery_filepath) + ).strftime("%a %d. %b. %y - %H:%M:%S") - toolButton.toolbar = toolbar - toolButton.clickEntryTableEndName = tableEndName - self.checkableQButtonsGroup.addButton(toolButton) - toolButton.toggled.connect(self.addPointsByClickingButtonToggled) + if num_tables == 1: + text = html_utils.paragraph( + f"A newer recovery version of {tableEndName}.csv " + "was found.

" + f"Main table save date: {main_timestamp}
" + f"Recovery save date: {recovery_timestamp}

" + "Do you want to load the newer recovery version?" + ) + else: + text = html_utils.paragraph( + f"Newer recovery versions of {tableEndName}.csv " + f"were found for {num_tables} positions.

" + f"Example main table save date: {main_timestamp}
" + f"Example recovery save date: {recovery_timestamp}

" + "Do you want to load the newer recovery version where available?" + ) - self.addPointsByClickingButtonToggled(sender=toolButton) + msg = widgets.myMessageBox(wrapText=False) + _, yesButton, _ = msg.warning( + self.addPointsWin, + "Newer recovery table found", + text, + buttonsTexts=("Cancel", "Yes, load newer recovery", "No, load main table"), + ) + return msg.clickedButton == yesButton - toolButton.setToolTip(tableEndName) + def askSaveAddedPoints(self): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph("Do you want to save the annotated points?") + _, noButton, yesButton = msg.question( + self, "Save?", txt, buttonsTexts=("Cancel", "No", "Yes") + ) + if msg.clickedButton != yesButton: + return - pointIdSpinbox = widgets.SpinBox() - pointIdSpinbox.setMinimum(0) - pointIdSpinbox.setValue(1) - pointIdSpinbox.label = QLabel(' Left-click ID: ') - pointIdSpinbox.labelAction = toolbar.addWidget(pointIdSpinbox.label) - if toolbar == self.promptSegmentPointsLayerToolbar: - newID = self.setBrushID(return_val=True) - pointIdSpinbox.setValue(newID) - pointIdSpinbox.setReadOnly(True) - pointIdSpinbox.setToolTip( - 'The ids added with left-click cannot be manually edited. ' - 'They are always a new, non-existing id.' - ) + for toolbar in self.pointsLayersToolbars: + for action in self.pointsLayersToolbar.actions(): + try: + if "Save annotated" in action.text(): + action.trigger() + except Exception: + pass - toolButton.actions.append(pointIdSpinbox.labelAction) - pointIdSpinbox.action = toolbar.addWidget(pointIdSpinbox) - toolButton.actions.append(pointIdSpinbox.action) - pointIdSpinbox.toolButton = toolButton - toolButton.pointIdSpinbox = pointIdSpinbox + def askSavePointsLayer(self, action): + toolButton = action.button + tableEndName = toolButton.clickEntryTableEndName + saveAction = toolButton.saveAction - rightClickIDSpinbox = widgets.SpinBox() - pointIdSpinbox.setLinkedValueWidget(rightClickIDSpinbox) - rightClickIDSpinbox.setMaximumWidth(pointIdSpinbox.sizeHint().width()) - rightClickIDSpinbox.setValue(pointIdSpinbox.value()) - rightClickIDSpinbox.setMinimum(0) - rightClickIDSpinbox.label = QLabel(' | Right-click ID: ') - rightClickIDSpinbox.labelAction = toolbar.addWidget( - rightClickIDSpinbox.label + txt = html_utils.paragraph(f""" + Do you want to save the points you added + (table called {tableEndName}.csv)? + """) + msg = widgets.myMessageBox(wrapText=False) + _, _, saveButton = msg.question( + self, + "Save points layer?", + txt, + buttonsTexts=("Cancel", "No, do not save", "Yes, save points"), ) - toolButton.actions.append(rightClickIDSpinbox.labelAction) - rightClickIDSpinbox.action = toolbar.addWidget(rightClickIDSpinbox) - toolButton.actions.append(rightClickIDSpinbox.action) - rightClickIDSpinbox.toolButton = toolButton - toolButton.rightClickIDSpinbox = rightClickIDSpinbox + if msg.clickedButton == saveButton: + self.savePointsAddedByClicking(saveAction.saveToolbutton, None) + + return msg.cancel + + def autoPilotZoomToObjToggled(self, checked): + if not checked: + self.zoomOut() + return + + posData = self.data[self.pos_i] + if not posData.IDs: + self.logger.info("There are no objects in current segmentation mask") + return + self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) + self.zoomToObj(posData.rp[0]) + + def autoZoomNextObj(self): + self.sender().setValue(self.sender().value() - 1) + self.pointsLayerAutoPilot("next") + self.setFocusMain() + self.setFocusGraphics() + + def autoZoomPrevObj(self): + self.sender().setValue(self.sender().value() + 1) + self.pointsLayerAutoPilot("prev") + self.setFocusMain() + self.setFocusGraphics() + + def buttonAddPointsByClickingActive(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, "layerTypeIdx"): + continue + if action.layerTypeIdx == 4 and action.button.isChecked(): + return action.button + + def checkAskSavePointsLayers(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, "layerTypeIdx"): + continue + if action.layerTypeIdx != 4: + continue + + scatterItem = action.scatterItem + xx, yy = scatterItem.getData() + + if xx is None or len(xx) == 0: + toolButton = action.button + tableEndName = toolButton.clickEntryTableEndName + # Check in other loaded pos + are_there_points_to_save = False + for pos_i, _posData in enumerate(self.data): + if pos_i == self.pos_i: + continue + + df = _posData.clickEntryPointsDfs.get(tableEndName) + if df is None: + continue + + are_there_points_to_save = True + break + + if not are_there_points_to_save: + continue + + cancel = self.askSavePointsLayer(action) + if cancel: + return cancel + + return False + + def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading): + doesTableExists = False + for posData in self.data: + filepath, _ = self.getClickEntryTableFilepaths(posData, tableEndName) + if os.path.exists(filepath): + doesTableExists = True + break + + if not doesTableExists: + return + + if not forceLoading: + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + f"The table {tableEndName}.csv already exists!

" + "Do you want to load it?" + ) + _, yesButton, _ = msg.warning( + self.addPointsWin, + "Table exists!", + txt, + buttonsTexts=("Cancel", "Yes, load it", "No, let me enter a new name"), + ) + if msg.clickedButton != yesButton: + return - saveToolbutton = widgets.SavePointsLayerButton( - tableEndName, parent=self.host + newer_recovery_filepaths = self.getClickEntryNewerRecoveryFilepaths( + tableEndName ) - saveToolbutton.sigRenameTableAction.connect( - self.updatePointsLayerClickEntryTableEndname + load_recovery_if_newer = self.askLoadNewerRecoveryClickEntryDfs( + tableEndName, newer_recovery_filepaths ) - saveToolbutton.sigLeftClick.connect(self.savePointsAddedByClicking) - saveAction = toolbar.addWidget(saveToolbutton) - saveToolbutton.action = saveAction - saveAction.saveToolbutton = saveToolbutton - saveAction.toolButton = toolButton - toolButton.saveAction = saveAction - toolButton.saveToolbutton = saveToolbutton - toolButton.actions.append(saveAction) + self.loadClickEntryDfs(tableEndName, loadRecoveryIfNewer=load_recovery_if_newer) - vlineAction = toolbar.addWidget(widgets.QVLine()) - spacerAction = toolbar.addWidget( - widgets.QHWidgetSpacer(width=5) - ) + def checkLoadedTableIds(self, toolbar): + if toolbar != self.promptSegmentPointsLayerToolbar: + return True - toolButton.actions.append(vlineAction) - toolButton.actions.append(spacerAction) + for posData in self.data: + for tableEndName, df in posData.clickEntryPointsDfs.items(): + for point_id in df["id"].values: + if point_id in posData.IDs_idxs: + proceed = self.warnAddingPointWithExistingId( + point_id, table_endname=tableEndName + ) + return proceed - action = toolButton.action - scatterItem = action.scatterItem - scatterItem.sigHoverEntered.connect( - self.addPointsByClickingScatterItemHoverEntered - ) + return True - self.pointsLayerClicksDfsToData(posData, toolbar=toolbar) + def clearPointsLayers(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + try: + action.scatterItem.clear() + except Exception: + continue - def storeUndoAddPoint(self, action): - if not hasattr(self, 'undoAddPointQueueMapper'): - self.undoAddPointQueueMapper = defaultdict(list) + def click_entry_table_filename( + self, + basename: str, + table_endname: str, + ) -> str: + table_basename = basename if basename.endswith("_") else f"{basename}_" + filename = f"{table_basename}{table_endname}" + if not filename.endswith(".csv"): + filename = f"{filename}.csv" + return filename + def drawPointsLayers(self, computePointsLayers=True): posData = self.data[self.pos_i] - pointsDataPos = action.pointsData.get(self.pos_i) - if pointsDataPos is None: + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, "layerTypeIdx"): + continue + + if action.layerTypeIdx < 2 and computePointsLayers: + self.getCentroidsPointsData(action) + + if not action.button.isChecked(): + continue + + frames = action.pointsData.get(self.pos_i, set()) + if posData.frame_i not in frames: + if action.layerTypeIdx != 4: + self.logger.info( + f"Frame number {posData.frame_i + 1} does not have any " + f'"{action.layerType}" point to display.' + ) + continue + + framePointsData = action.pointsData[self.pos_i][posData.frame_i] + + if "x" not in framePointsData: + # 3D points + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == "single z-slice" and posData.SizeZ > 1 + if isZslice: + xx, yy, ids, data = [], [], [], [] + zSlice = self.zSliceScrollBar.sliderPosition() + zRadius = action.zRadius + zRange = range(zSlice - zRadius, zSlice + zRadius + 1) + for z in zRange: + z_data = framePointsData.get(z) + if z_data is None: + continue + xx.extend(z_data["x"]) + yy.extend(z_data["y"]) + ids.extend(z_data["id"]) + try: + data.extend(z_data["data"]) + except KeyError: + # data is needed only for loaded tables + pass + else: + xx, yy, ids, data = [], [], [], [] + # z-projection --> draw all points + for z, z_data in framePointsData.items(): + xx.extend(z_data["x"]) + yy.extend(z_data["y"]) + ids.extend(z_data["id"]) + try: + data.extend(z_data["data"]) + except KeyError: + # data is needed only for loaded tables + pass + else: + # 2D segmentation + xx = framePointsData["x"] + yy = framePointsData["y"] + ids = framePointsData["id"] + try: + data = framePointsData["data"] + except KeyError: + # data is needed only for loaded tables + pass + + brushColors = [ + action.brushColor if id != 0 else action.brushColorId0 for id in ids + ] + brushes = [pg.mkBrush(color) for color in brushColors] + + pensColor = [ + action.penColor if id != 0 else action.penColorId0 for id in ids + ] + pens = [pg.mkPen(color) for color in pensColor] + + if action.layerTypeIdx == 2: + # For loaded table show the rest of the table as a tooltip + data = data + show_data_as_tip = True + else: + data = ids + show_data_as_tip = False + + xx = np.array(xx) # + 0.5 + yy = np.array(yy) # + 0.5 + + action.scatterItem.show_data_as_tip = show_data_as_tip + action.scatterItem.setData(xx, yy, data=data, brush=brushes, pen=pens) + + def editPointsLayerAppearance(self, button): + win = apps.EditPointsLayerAppearanceDialog(parent=self) + win.restoreState(button.action.state) + win.exec_() + if win.cancel: return - state = deepcopy(pointsDataPos) - self.undoAddPointQueueMapper[action].append(state) - self.undoAction.setEnabled(True) + symbol = win.symbol + color = win.color + pointSize = win.pointSize + zRadius = int((win.zHeight - 1) / 2) + r, g, b, a = color.getRgb() - def undoAddPoint(self, action): - undoAddPointQueue = self.undoAddPointQueueMapper.get(action) - if undoAddPointQueue is None: - return False + scatterItem = button.action.scatterItem + scatterItem.opts["hoverBrush"] = pg.mkBrush((r, g, b, 200)) + scatterItem.setSymbol(symbol, update=False) + scatterItem.setBrush(pg.mkBrush(color=(r, g, b, 100)), update=False) + scatterItem.setPen(pg.mkPen(width=2, color=(r, g, b)), update=False) + scatterItem.setSize(pointSize, update=True) - if len(undoAddPointQueue) == 0: - return False + button.action.brushColor = (r, g, b, 100) + button.action.penColor = (r, g, b) + button.action.pointSize = pointSize + button.action.zRadius = zRadius - posData = self.data[self.pos_i] - state = undoAddPointQueue.pop(-1) - action.pointsData[self.pos_i] = state - self.markPointsLayerDirty(action=action) + button.action.state = win.state() - self.drawPointsLayers(computePointsLayers=False) + def flushDirtyPointsLayersAutosave(self): + if not self.dirtyPointsLayerTableEndNames: + return - if len(self.undoAddPointQueueMapper[action]) == 0: - self.undoAction.setEnabled(True) + for tableEndName in tuple( + self.dirtyPointsLayerTableEndNames + ): # avoid runtime error + self.savePointsAddedByClickingFromEndname(tableEndName, recovery=True) - return True + self.dirtyPointsLayerTableEndNames.clear() def getAddedPointId( - self, isMagicPrompts, addPointsByClickingButton, - right_click, left_click, middle_click - ): + self, + isMagicPrompts, + addPointsByClickingButton, + right_click, + left_click, + middle_click, + ): action = addPointsByClickingButton.action if right_click: id = addPointsByClickingButton.rightClickIDSpinbox.value() elif left_click: id = addPointsByClickingButton.pointIdSpinbox.value() id = self.getClickedPointNewId( - action, id, addPointsByClickingButton.pointIdSpinbox, - isMagicPrompts=isMagicPrompts + action, + id, + addPointsByClickingButton.pointIdSpinbox, + isMagicPrompts=isMagicPrompts, ) if isMagicPrompts: proceed = self.warnAddingPointWithExistingId(id) @@ -474,155 +611,260 @@ def getAddedPointId( return id - def addPointsByClickingScatterItemHoverEntered(self, item, points, event): - point = points[0] - point_id = point.data() - toolButton = item.action.button - toolButton.rightClickIDSpinbox.prevId = ( - toolButton.rightClickIDSpinbox.value() - ) - toolButton.rightClickIDSpinbox.setValue(point_id) - - def autoPilotZoomToObjToggled(self, checked): - if not checked: - self.zoomOut() - return - + def getCentroidsPointsData(self, action): + # Centroids (either weighted or not) + # NOTE: if user requested to draw from table we load that in + # apps.AddPointsLayerDialog.ok_cb() posData = self.data[self.pos_i] - if not posData.IDs: - self.logger.info('There are no objects in current segmentation mask') - return - self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) - self.zoomToObj(posData.rp[0]) + action.pointsData[self.pos_i] = {posData.frame_i: {}} + if hasattr(action, "weighingData"): + lab = posData.lab + img = action.weighingData[self.pos_i][posData.frame_i] + rp = skimage.measure.regionprops(lab, intensity_image=img) + attr = "weighted_centroid" + else: + rp = posData.rp + attr = "centroid" + for i, obj in enumerate(rp): + centroid = getattr(obj, attr) + if len(centroid) == 3: + zc, yc, xc = centroid + z_int = round(zc) + if z_int not in action.pointsData[self.pos_i][posData.frame_i]: + action.pointsData[self.pos_i][posData.frame_i][z_int] = { + "x": [xc], + "y": [yc], + "id": [obj.label], + } + else: + z_data = action.pointsData[self.pos_i][posData.frame_i][z_int] + z_data["x"].append(xc) + z_data["y"].append(yc) + z_data["id"].append(obj.label) + else: + yc, xc = centroid + if "y" not in action.pointsData[self.pos_i][posData.frame_i]: + action.pointsData[self.pos_i][posData.frame_i]["y"] = [yc] + action.pointsData[self.pos_i][posData.frame_i]["x"] = [xc] + action.pointsData[self.pos_i][posData.frame_i]["id"] = [obj.label] + else: + action.pointsData[self.pos_i][posData.frame_i]["y"].append(yc) + action.pointsData[self.pos_i][posData.frame_i]["x"].append(xc) + action.pointsData[self.pos_i][posData.frame_i]["id"].append( + obj.label + ) - def savePointsAddedByClickingFromEndname(self, tableEndName, recovery=False): - self.pointsLayerDataToDf(self.data[self.pos_i]) + def getClickEntryNewerRecoveryFilepaths(self, tableEndName): + newer_recovery_filepaths = [] for posData in self.data: - tableFilename = self.points.click_points_table_filename( - posData.basename, tableEndName + filepath, recovery_filepath = self.getClickEntryTableFilepaths( + posData, tableEndName ) - if recovery: - tableFilepath = os.path.join( - posData.recoveryFolderpath(), tableFilename - ) - else: - tableFilepath = os.path.join(posData.images_path, tableFilename) - df = posData.clickEntryPointsDfs.get(tableEndName) - if df is None: + if not os.path.exists(filepath) or not os.path.exists(recovery_filepath): continue - self.points.save_click_points_table(tableFilepath, df) - - def markPointsLayerDirty(self, tableEndName=None, action=None): - if tableEndName is None and action is not None: - tableEndName = getattr(action, 'clickEntryTableEndName', None) - if tableEndName is None: - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - if addPointsByClickingButton is None: - return - tableEndName = addPointsByClickingButton.clickEntryTableEndName + if ( + os.path.getmtime(recovery_filepath) <= os.path.getmtime(filepath) + 15 + ): # add a 15 second tolerance + continue - self.dirtyPointsLayerTableEndNames.add(tableEndName) + newer_recovery_filepaths.append((filepath, recovery_filepath)) - def flushDirtyPointsLayersAutosave(self): - if not self.dirtyPointsLayerTableEndNames: - return + return newer_recovery_filepaths - for tableEndName in tuple(self.dirtyPointsLayerTableEndNames): # avoid runtime error - self.savePointsAddedByClickingFromEndname( - tableEndName, recovery=True - ) + def getClickEntryTableFilepaths(self, posData, tableEndName): + if posData.basename.endswith("_"): + basename = posData.basename + else: + basename = f"{posData.basename}_" - self.dirtyPointsLayerTableEndNames.clear() + csv_filename = f"{basename}{tableEndName}" + if not csv_filename.endswith(".csv"): + csv_filename = f"{csv_filename}.csv" - @exception_handler - def savePointsAddedByClicking(self, button, event): - sender = button.action - toolButton = sender.toolButton - tableEndName = toolButton.clickEntryTableEndName + filepath = os.path.join(posData.images_path, csv_filename) + recovery_filepath = os.path.join(posData.images_path, "recovery", csv_filename) + return filepath, recovery_filepath - self.logger.info(f'Saving _{tableEndName}.csv table...') + def getClickedPointNewId( + self, action, current_id, pointIdSpinbox, isMagicPrompts=False + ): + removed_id = getattr(pointIdSpinbox, "removedId", None) + if removed_id is not None: + pointIdSpinbox.removedId = None + return removed_id - self.savePointsAddedByClickingFromEndname(tableEndName) + posData = self.data[self.pos_i] + if isMagicPrompts: + is_already_new = self.isPointIdAlreadyNew(current_id, action) + if is_already_new: + return current_id - self.logger.info(f'{tableEndName}.csv saved!') - self.titleLabel.setText(f'{tableEndName}.csv saved!', color='g') + new_ID = self.setBrushID(return_val=True) + new_id = max(current_id, new_ID) + 1 + return new_id + else: + pointsDataPos = action.pointsData.get(self.pos_i) + if pointsDataPos is None: + return 1 + + framePointsData = pointsDataPos.get(posData.frame_i) + if framePointsData is None: + return 1 + if posData.SizeZ > 1: + new_id = 1 + for z_data in framePointsData.values(): + max_id = max(z_data.get("id", 0), default=0) + 1 + if max_id > new_id: + new_id = max_id + else: + new_id = max(framePointsData.get("id", 0), default=0) + 1 + if current_id >= new_id: + return current_id + return new_id - def updatePointsLayerClickEntryTableEndname( - self, saveToolbutton, table_endname - ): - saveAction = saveToolbutton.action - toolButton = saveAction.toolButton - toolButton.clickEntryTableEndName = table_endname + def isPointIdAlreadyNew(self, point_id, action): + posData = self.data[self.pos_i] + if point_id in posData.IDs_idxs: + return False - self.logger.info( - f'Done. Click entry table endname updated to "{table_endname}"' - ) + is_ID = point_id in posData.IDs_idxs + pointsDataPos = action.pointsData.get(self.pos_i) + if pointsDataPos is None: + return not is_ID + + framePointsData = pointsDataPos.get(posData.frame_i) + if framePointsData is None: + return not is_ID + + if "x" not in framePointsData: + is_id_already_added = False + for z, z_data in framePointsData.items(): + if point_id in z_data["id"]: + is_id_already_added = True + break + else: + is_id_already_added = point_id in framePointsData["id"] - def pointsLayerDfsToData(self, posData): - self.pointsLayerClicksDfsToData(posData) + is_already_new = not is_ID and not is_id_already_added + return is_already_new - def pointsLayerLoadedDfsToData(self): - posData = self.data[self.pos_i] - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'loadedDfInfo'): - continue + def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): + for posData in self.data: + filepath, recovery_filepath = self.getClickEntryTableFilepaths( + posData, tableEndName + ) - if action.loadedDfInfo is None: + if loadRecoveryIfNewer: + recovery_exists = os.path.exists(recovery_filepath) + main_exists = os.path.exists(filepath) + if recovery_exists and ( + not main_exists + or os.path.getmtime(recovery_filepath) + > os.path.getmtime(filepath) + 15 + ): + filepath = recovery_filepath + elif not main_exists: continue - endname = action.loadedDfInfo.get('endname') - if endname is None: - continue + if not os.path.exists(filepath): + continue - filename = f'{posData.basename}{endname}' - filepath = os.path.join(posData.images_path, filename) - if not os.path.exists(filepath): - action.pointsData[self.pos_i] = {} + self.logger.info(f'Loading points from "{filepath}"...') + df = pd.read_csv(filepath) + if "id" not in df.columns: + df["id"] = range(1, len(df) + 1) + posData.clickEntryPointsDfs[tableEndName] = df - df = self.points.load_points_table(filepath) - action.pointsData[self.pos_i] = ( - self.points.loaded_table_to_points_data( - df, action.loadedDfInfo['t'], action.loadedDfInfo['z'], - action.loadedDfInfo['y'], action.loadedDfInfo['x'] - ) - ) - self.logLoadedTablePointsLayer(df, filename=filename) + try: + self.addPointsWin.loadButton.confirmAction() + except Exception: + pass - def setPointsLayerLoadedDfEndanme(self, action): - if action.loadedDfInfo is None: + def loadPointsLayerWeighingData(self, action, weighingChannel): + if not weighingChannel: return - posData = self.data[self.pos_i] - images_path = posData.images_path.replace('\\', '/') + self.logger.info(f'Loading "{weighingChannel}" weighing data...') + action.weighingData = [] + for p, posData in enumerate(self.data): + if weighingChannel == posData.user_ch_name: + wData = posData.img_data + action.weighingData.append(wData) + continue - df_folderpath = os.path.dirname( - action.loadedDfInfo['filepath'].replace('\\', '/') - ) + path, filename = self.getPathFromChName(weighingChannel, posData) + if path is None: + self.criticalFluoChannelNotFound(weighingChannel, posData) + action.weighingData = [] + return - if images_path != df_folderpath: - return + if filename in posData.fluo_data_dict: + # Weighing data already loaded as additional fluo channel + wData = posData.fluo_data_dict[filename] + else: + # Weighing data never loaded --> load now + wData, _ = self.load_fluo_data(path) + if posData.SizeT == 1: + wData = wData[np.newaxis] + action.weighingData.append(wData) + + def logLoadedTablePointsLayer(self, df, filename: str): + separator = "-" * 100 + header = f'First 10 rows of loaded table - "{filename}":' + footer = f"Number of points: {len(df)}" + text = f"{separator}\n{header}\n\n{df.head(10)}\n\n{footer}\n{separator}" + if filename: + text = f"{text}\nFilename: {filename}" + self.logger.info(text) - df_filename = os.path.basename(action.loadedDfInfo['filepath']) + def markPointsLayerDirty(self, tableEndName=None, action=None): + if tableEndName is None and action is not None: + tableEndName = getattr(action, "clickEntryTableEndName", None) - if not df_filename.startswith(posData.basename): + if tableEndName is None: + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + if addPointsByClickingButton is None: + return + tableEndName = addPointsByClickingButton.clickEntryTableEndName + + self.dirtyPointsLayerTableEndNames.add(tableEndName) + + def pointLayerToolbuttonToggled(self, checked): + action = self.sender().action + action.scatterItem.setVisible(checked) + + def pointsLayerAutoPilot(self, direction): + if not self.autoPilotZoomToObjToggle.isChecked(): + return + ID = self.autoPilotZoomToObjSpinBox.value() + posData = self.data[self.pos_i] + if not posData.IDs: return - endname = df_filename[len(posData.basename):] - action.loadedDfInfo['endname'] = endname + try: + ID_idx = posData.IDs_idxs[ID] + if direction == "next": + nextID_idx = ID_idx + 1 + else: + nextID_idx = ID_idx - 1 + obj = posData.rp[nextID_idx] + except Exception: + self.logger.info("Auto-pilot restarted from first ID") + obj = posData.rp[0] - action.button.setToolTip(endname) + self.autoPilotZoomToObjSpinBox.setValue(obj.label) + self.zoomToObj(obj) def pointsLayerClicksDfsToData(self, posData, toolbar=None): if toolbar is None: toolbar = self.pointsLayersToolbar for action in toolbar.actions()[1:]: - if not hasattr(action, 'button'): + if not hasattr(action, "button"): continue - if not hasattr(action.button, 'clickEntryTableEndName'): + if not hasattr(action.button, "clickEntryTableEndName"): continue tableEndName = action.button.clickEntryTableEndName action.pointsData[self.pos_i] = {} @@ -631,23 +873,39 @@ def pointsLayerClicksDfsToData(self, posData, toolbar=None): df = posData.clickEntryPointsDfs[tableEndName] - try: - action.pointsData[self.pos_i] = ( - self.points.click_points_table_to_data( - df, size_z=posData.SizeZ, - ) - ) - except ValueError: + if posData.SizeZ > 1 and df["z"].isna().any(): self.warnLoadedPointsTableIsNot3D(tableEndName) return + for frame_i, df_frame in df.groupby("frame_i"): + action.pointsData[self.pos_i][frame_i] = {} + if posData.SizeZ > 1: + for z, df_zlice in df_frame.groupby("z"): + xx = df_zlice["x"].to_list() + yy = df_zlice["y"].to_list() + ids = df_zlice["id"].to_list() + action.pointsData[self.pos_i][frame_i][z] = { + "x": xx, + "y": yy, + "id": ids, + } + else: + xx = df_frame["x"].to_list() + yy = df_frame["y"].to_list() + ids = df_frame["id"].to_list() + action.pointsData[self.pos_i][frame_i] = { + "x": xx, + "y": yy, + "id": ids, + } + def pointsLayerDataToDf(self, posData, getOnlyActive=False, toolbar=None): df = None for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, 'button'): + if not hasattr(action, "button"): continue - if not hasattr(action.button, 'clickEntryTableEndName'): + if not hasattr(action.button, "clickEntryTableEndName"): continue tableEndName = action.button.clickEntryTableEndName @@ -660,657 +918,389 @@ def pointsLayerDataToDf(self, posData, getOnlyActive=False, toolbar=None): posData.clickEntryPointsDfs[tableEndName] = df return df - def restartZoomAutoPilot(self): - if not self.autoPilotZoomToObjToggle.isChecked(): - return + def pointsLayerDfsToData(self, posData): + self.pointsLayerClicksDfsToData(posData) + def pointsLayerLoadedDfsToData(self): posData = self.data[self.pos_i] - if not posData.IDs: - return + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, "loadedDfInfo"): + continue - self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) - self.zoomToObj(posData.rp[0]) + if action.loadedDfInfo is None: + continue - def resizeRangeWelcomeText(self): - xRange, yRange = self.ax1.viewRange() - deltaX = xRange[1] - xRange[0] - deltaY = yRange[1] - yRange[0] - self.ax1.setXRange(0, deltaX) - self.ax1.setYRange(0, deltaY) - self.ax1.setLimits( - xMin=0, xMax=deltaX, yMin=0, yMax=deltaY - ) - # self.ax1.setXRange(0, 0) - # self.ax1.setYRange(0, 0) + endname = action.loadedDfInfo.get("endname") + if endname is None: + continue - def zoomToObj(self, obj=None): - if not hasattr(self, 'data'): - return - posData = self.data[self.pos_i] - if obj is None: - ID = self.sender().value() - try: - ID_idx = posData.IDs_idxs[ID] - obj = obj = posData.rp[ID_idx] - except Exception as e: - self.logger.warning( - f'ID {ID} does not exist (add points by clicking)' - ) + filename = f"{posData.basename}{endname}" + filepath = os.path.join(posData.images_path, filename) + if not os.path.exists(filepath): + action.pointsData[self.pos_i] = {} - if obj is None: - return + df = load.load_df_points_layer(filepath) + action.pointsData[self.pos_i] = load.loaded_df_to_points_data( + df, + action.loadedDfInfo["t"], + action.loadedDfInfo["z"], + action.loadedDfInfo["y"], + action.loadedDfInfo["x"], + ) + self.logLoadedTablePointsLayer(df, filename=filename) - self.goToZsliceSearchedID(obj) - min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) - xRange = min_col-5, max_col+5 - yRange = max_row+5, min_row-5 + def pointsLayerToggled(self, checked): + if not checked: + for action in self.pointsLayersToolbar.actions(): + try: + if "Save annotated" in action.text(): + self.askSaveAddedPoints() + break + except Exception: + pass + self.pointsLayersToolbar.setVisible(checked) + self.autoPilotZoomToObjToolbar.setVisible(checked) + if self.pointsLayersNeverToggled: + self.pointsLayersToolbar.sigAddPointsLayer.emit() + self.pointsLayersNeverToggled = False + QTimer.singleShot(200, self.autoRange) - self.ax1.setRange(xRange=xRange, yRange=yRange) + def reinitPointsLayers(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + toolbar.removeAction(action) + toolbar.setVisible(False) + self.autoPilotZoomToObjToolbar.setVisible(False) - def addPointsByClickingButtonToggled(self, checked=True, sender=None): - if sender is None: - sender = self.sender() - if not sender.isChecked(): - action = sender.action - action.scatterItem.setVisible(False) - return + def removeClickedPoints(self, action, points): + posData = self.data[self.pos_i] + framePointsData = action.pointsData[self.pos_i][posData.frame_i] + if posData.SizeZ > 1: + zProjHow = self.zProjComboBox.currentText() + if zProjHow != "single z-slice": + _warnings.warnCannotAddRemovePointsProjection() + return + zSlice = self.zSliceScrollBar.sliderPosition() + else: + zSlice = None - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(sender) - self.connectLeftClickButtons() - action = sender.action - action.scatterItem.setVisible(True) - self.ax1_BrushCircle.setBrush(action.brushColor) - self.ax1_BrushCircle.setPen(action.penColor) + removed_ids = [] + for point in points: + pos = point.pos() + x, y = pos.x(), pos.y() + if zSlice is not None: + zSliceRad = action.zRadius + sliceFramePointsData = [ + framePointsData[z] + for z in range(zSlice - zSliceRad, zSlice + zSliceRad + 1) + if z in framePointsData.keys() + ] + else: + sliceFramePointsData = [framePointsData] - def autoZoomNextObj(self): - self.sender().setValue(self.sender().value() - 1) - self.pointsLayerAutoPilot('next') - self.image_controls_view.setFocusMain() - self.image_controls_view.setFocusGraphics() + for sliceFramePointsData in sliceFramePointsData: + if point.data() in sliceFramePointsData["id"]: + sliceFramePointsData["x"].remove(x) + sliceFramePointsData["y"].remove(y) + sliceFramePointsData["id"].remove(point.data()) + removed_ids.append(point.data()) - def autoZoomPrevObj(self): - self.sender().setValue(self.sender().value() + 1) - self.pointsLayerAutoPilot('prev') - self.image_controls_view.setFocusMain() - self.image_controls_view.setFocusGraphics() + if removed_ids: + self.markPointsLayerDirty(action=action) - def pointsLayerAutoPilot(self, direction): + return removed_ids + + def removePointsLayer(self, button, toolbar=None): + button.setChecked(False) + button.action.scatterItem.setData([], []) + button.action.loadedDfInfo = None + self.ax1.removeItem(button.action.scatterItem) + toolbar.removeAction(button.action) + for action in button.actions: + toolbar.removeAction(action) + + if toolbar == self.promptSegmentPointsLayerToolbar: + self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False + + def resizeRangeWelcomeText(self): + xRange, yRange = self.ax1.viewRange() + deltaX = xRange[1] - xRange[0] + deltaY = yRange[1] - yRange[0] + self.ax1.setXRange(0, deltaX) + self.ax1.setYRange(0, deltaY) + self.ax1.setLimits(xMin=0, xMax=deltaX, yMin=0, yMax=deltaY) + + def restartZoomAutoPilot(self): if not self.autoPilotZoomToObjToggle.isChecked(): return - ID = self.autoPilotZoomToObjSpinBox.value() + posData = self.data[self.pos_i] if not posData.IDs: return - try: - ID_idx = posData.IDs_idxs[ID] - if direction == 'next': - nextID_idx = ID_idx + 1 - else: - nextID_idx = ID_idx - 1 - obj = posData.rp[nextID_idx] - except Exception as e: - self.logger.info( - f'Auto-pilot restarted from first ID' - ) - obj = posData.rp[0] + self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) + self.zoomToObj(posData.rp[0]) - self.autoPilotZoomToObjSpinBox.setValue(obj.label) - self.zoomToObj(obj) + def restorePrevPointIdRightClick(self, addPointsByClickingButton): + # Try to restore the id that was there before hovering + # because the hovering was required only to delete the + # point + try: + prevId = addPointsByClickingButton.rightClickIDSpinbox.prevId + addPointsByClickingButton.rightClickIDSpinbox.setValue(prevId) + except Exception: + addPointsByClickingButton.rightClickIDSpinbox.prevId = None - def getClickEntryTableFilepaths(self, posData, tableEndName): - csv_filename = self.click_entry_table_filename( - posData.basename, - tableEndName, - ) - filepath = os.path.join(posData.images_path, csv_filename) - recovery_filepath = os.path.join( - posData.images_path, 'recovery', csv_filename - ) - return filepath, recovery_filepath + @exception_handler + def savePointsAddedByClicking(self, button, event): + sender = button.action + toolButton = sender.toolButton + tableEndName = toolButton.clickEntryTableEndName - def getClickEntryNewerRecoveryFilepaths(self, tableEndName): - newer_recovery_filepaths = [] - for posData in self.data: - filepath, recovery_filepath = self.getClickEntryTableFilepaths( - posData, tableEndName - ) - if not os.path.exists(filepath) or not os.path.exists(recovery_filepath): - continue + self.logger.info(f"Saving _{tableEndName}.csv table...") - if not self.should_load_recovery_table( - recovery_exists=True, - main_exists=True, - recovery_mtime=os.path.getmtime(recovery_filepath), - main_mtime=os.path.getmtime(filepath), - ): - continue + self.savePointsAddedByClickingFromEndname(tableEndName) - newer_recovery_filepaths.append((filepath, recovery_filepath)) + self.logger.info(f"{tableEndName}.csv saved!") + self.titleLabel.setText(f"{tableEndName}.csv saved!", color="g") - return newer_recovery_filepaths + def savePointsAddedByClickingFromEndname(self, tableEndName, recovery=False): + self.pointsLayerDataToDf(self.data[self.pos_i]) + for posData in self.data: + if not posData.basename.endswith("_"): + basename = f"{posData.basename}_" + else: + basename = posData.basename + tableFilename = f"{basename}{tableEndName}.csv" + if recovery: + tableFilepath = os.path.join( + posData.recoveryFolderpath(), tableFilename + ) + else: + tableFilepath = os.path.join(posData.images_path, tableFilename) + df = posData.clickEntryPointsDfs.get(tableEndName) + if df is None: + continue + df = df.sort_values(["frame_i", "Cell_ID"]) + df.to_csv(tableFilepath, index=False) - def askLoadNewerRecoveryClickEntryDfs( - self, tableEndName, newer_recovery_filepaths - ): - if not newer_recovery_filepaths: - return False + def setHoverCircleAddPoint(self, x, y): + addPointsByClickingButton = self.buttonAddPointsByClickingActive() + if addPointsByClickingButton is None: + return + action = addPointsByClickingButton.action + self.setHoverToolSymbolData( + [x], [y], (self.ax1_BrushCircle,), size=action.pointSize + ) - num_tables = len(newer_recovery_filepaths) - filepath, recovery_filepath = newer_recovery_filepaths[0] - main_timestamp = datetime.fromtimestamp( - os.path.getmtime(filepath) - ).strftime('%a %d. %b. %y - %H:%M:%S') - recovery_timestamp = datetime.fromtimestamp( - os.path.getmtime(recovery_filepath) - ).strftime('%a %d. %b. %y - %H:%M:%S') + def setPointsLayerLoadedDfEndanme(self, action): + if action.loadedDfInfo is None: + return - if num_tables == 1: - text = html_utils.paragraph( - f'A newer recovery version of {tableEndName}.csv ' - 'was found.

' - f'Main table save date: {main_timestamp}
' - f'Recovery save date: {recovery_timestamp}

' - 'Do you want to load the newer recovery version?' - ) - else: - text = html_utils.paragraph( - f'Newer recovery versions of {tableEndName}.csv ' - f'were found for {num_tables} positions.

' - f'Example main table save date: {main_timestamp}
' - f'Example recovery save date: {recovery_timestamp}

' - 'Do you want to load the newer recovery version where available?' - ) + posData = self.data[self.pos_i] + images_path = posData.images_path.replace("\\", "/") - msg = widgets.myMessageBox(wrapText=False) - _, yesButton, _ = msg.warning( - self.addPointsWin, 'Newer recovery table found', text, - buttonsTexts=('Cancel', 'Yes, load newer recovery', 'No, load main table') + df_folderpath = os.path.dirname( + action.loadedDfInfo["filepath"].replace("\\", "/") ) - return msg.clickedButton == yesButton - - def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading): - doesTableExists = False - for posData in self.data: - filepath, _ = self.getClickEntryTableFilepaths(posData, tableEndName) - if os.path.exists(filepath): - doesTableExists = True - break - if not doesTableExists: + if images_path != df_folderpath: return - if not forceLoading: - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - f'The table {tableEndName}.csv already exists!

' - 'Do you want to load it?' - ) - _, yesButton, _ = msg.warning( - self.addPointsWin, 'Table exists!', txt, - buttonsTexts=('Cancel', 'Yes, load it', 'No, let me enter a new name') - ) - if msg.clickedButton != yesButton: - return + df_filename = os.path.basename(action.loadedDfInfo["filepath"]) - newer_recovery_filepaths = self.getClickEntryNewerRecoveryFilepaths( - tableEndName - ) - load_recovery_if_newer = self.askLoadNewerRecoveryClickEntryDfs( - tableEndName, newer_recovery_filepaths - ) + if not df_filename.startswith(posData.basename): + return - self.loadClickEntryDfs( - tableEndName, loadRecoveryIfNewer=load_recovery_if_newer - ) + endname = df_filename[len(posData.basename) :] + action.loadedDfInfo["endname"] = endname - def checkLoadedTableIds(self, toolbar): - if toolbar != self.promptSegmentPointsLayerToolbar: - return True + action.button.setToolTip(endname) - for posData in self.data: - for tableEndName, df in posData.clickEntryPointsDfs.items(): - for point_id in df['id'].values: - if point_id in posData.IDs_idxs: - proceed = self.warnAddingPointWithExistingId( - point_id, table_endname=tableEndName - ) - return proceed + def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): + self.LeftClickButtons.append(toolButton) + posData = self.data[self.pos_i] + tableEndName = self.addPointsWin.clickEntryTableEndnameText + if isLoadedDf is not None: + posData = self.data[self.pos_i] + tableEndName = tableEndName[len(posData.basename) :] + self.loadClickEntryDfs(tableEndName) - return True + toolButton.toolbar = toolbar + toolButton.clickEntryTableEndName = tableEndName + self.checkableQButtonsGroup.addButton(toolButton) + toolButton.toggled.connect(self.addPointsByClickingButtonToggled) - @exception_handler - def addPointsLayer(self, toolbar=None): - proceed = self.checkLoadedTableIds(toolbar) + self.addPointsByClickingButtonToggled(sender=toolButton) - if self.addPointsWin.cancel or not proceed: - self.addPointsWin = None - self.logger.info('Adding points layer cancelled.') - return + toolButton.setToolTip(tableEndName) - if toolbar is None: - toolbar = self.pointsLayersToolbar + pointIdSpinbox = widgets.SpinBox() + pointIdSpinbox.setMinimum(0) + pointIdSpinbox.setValue(1) + pointIdSpinbox.label = QLabel(" Left-click ID: ") + pointIdSpinbox.labelAction = toolbar.addWidget(pointIdSpinbox.label) + if toolbar == self.promptSegmentPointsLayerToolbar: + newID = self.setBrushID(return_val=True) + pointIdSpinbox.setValue(newID) + pointIdSpinbox.setReadOnly(True) + pointIdSpinbox.setToolTip( + "The ids added with left-click cannot be manually edited. " + "They are always a new, non-existing id." + ) - symbol = self.addPointsWin.symbol - color = self.addPointsWin.color - pointSize = self.addPointsWin.pointSize - zRadius = int((self.addPointsWin.zHeight-1)/2) - r,g,b,a = color.getRgb() + toolButton.actions.append(pointIdSpinbox.labelAction) + pointIdSpinbox.action = toolbar.addWidget(pointIdSpinbox) + toolButton.actions.append(pointIdSpinbox.action) + pointIdSpinbox.toolButton = toolButton + toolButton.pointIdSpinbox = pointIdSpinbox - scatterItem = widgets.PointsScatterPlotItem( - [], [], ax=self.ax1, symbol=symbol, pxMode=False, size=pointSize, - brush=pg.mkBrush(color=(r,g,b,100)), - pen=pg.mkPen(width=2, color=(r,g,b)), - hoverable=True, hoverBrush=pg.mkBrush((r,g,b,200)), - tip=None, show_data_as_tip=True - ) - self.ax1.addItem(scatterItem) + rightClickIDSpinbox = widgets.SpinBox() + pointIdSpinbox.setLinkedValueWidget(rightClickIDSpinbox) + rightClickIDSpinbox.setMaximumWidth(pointIdSpinbox.sizeHint().width()) + rightClickIDSpinbox.setValue(pointIdSpinbox.value()) + rightClickIDSpinbox.setMinimum(0) + rightClickIDSpinbox.label = QLabel(" | Right-click ID: ") + rightClickIDSpinbox.labelAction = toolbar.addWidget(rightClickIDSpinbox.label) + toolButton.actions.append(rightClickIDSpinbox.labelAction) + rightClickIDSpinbox.action = toolbar.addWidget(rightClickIDSpinbox) + toolButton.actions.append(rightClickIDSpinbox.action) + rightClickIDSpinbox.toolButton = toolButton + toolButton.rightClickIDSpinbox = rightClickIDSpinbox - toolButton = widgets.PointsLayerToolButton( - symbol, color, parent=self.host + saveToolbutton = widgets.SavePointsLayerButton(tableEndName, parent=self) + saveToolbutton.sigRenameTableAction.connect( + self.updatePointsLayerClickEntryTableEndname ) - toolButton.actions = [] - toolButton.setCheckable(True) - toolButton.setChecked(True) - if self.addPointsWin.keySequence is not None: - toolButton.setShortcut(self.addPointsWin.keySequence) - toolButton.toggled.connect(self.pointLayerToolbuttonToggled) - toolButton.sigEditAppearance.connect(self.editPointsLayerAppearance) - toolButton.sigShowIdsToggled.connect(self.showPointsLayerIdsToggled) - toolButton.sigRemove.connect( - partial(self.removePointsLayer, toolbar=toolbar) - ) - - action = toolbar.addWidget(toolButton) - action.state = self.addPointsWin.state() - - toolButton.action = action - action.brushColor = (r,g,b,100) - action.brushColorId0 = ( - *colors.hex_to_rgb( - colors.lighten_color( - np.array(action.brushColor)/255, 0.3 - ) - ), 100 - ) - action.penColor = (r,g,b) - action.penColorId0 = colors.lighten_color( - np.array(action.penColor)/255, 0.3 - ) - action.pointSize = pointSize - action.zRadius = zRadius - action.button = toolButton - action.scatterItem = scatterItem - scatterItem.action = action - action.layerType = self.addPointsWin.layerType - action.layerTypeIdx = self.addPointsWin.layerTypeIdx - action.loadedDf = self.addPointsWin.loadedDf - posData = self.data[self.pos_i] - action.pointsData = {} - action.pointsData[self.pos_i] = self.addPointsWin.pointsData - action.snapToMax = False - action.loadedDfInfo = self.addPointsWin.loadedDfInfo - self.setPointsLayerLoadedDfEndanme(action) - - if self.addPointsWin.layerType.startswith('Click to annotate point'): - action.snapToMax = self.addPointsWin.snapToMaxToggle.isChecked() - isLoadedDf = self.addPointsWin.clickEntryIsLoadedDf - self.setupAddPointsByClicking( - toolButton, isLoadedDf, toolbar=toolbar - ) - if self.addPointsWin.autoPilotToggle.isChecked(): - self.autoPilotZoomToObjToggle.setChecked(True) - - weighingChannel = self.addPointsWin.weighingChannel - self.loadPointsLayerWeighingData(action, weighingChannel) - - self.drawPointsLayers() - - if toolbar == self.promptSegmentPointsLayerToolbar: - self.promptSegmentPointsLayerToolbar.isPointsLayerInit = True - self.magicPromptsToolbar.clearPointsAction.setDisabled(False) - self.magicPromptsToolbar.clearPointsActionOnZoom.setDisabled(False) - QTimer.singleShot( - 200, self.magicPromptsToolbar.selectModelAction.trigger - ) - - self.addPointsWin = None - - def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): - for posData in self.data: - filepath, recovery_filepath = self.getClickEntryTableFilepaths( - posData, tableEndName - ) - - if loadRecoveryIfNewer: - recovery_exists = os.path.exists(recovery_filepath) - main_exists = os.path.exists(filepath) - if ( - self.should_load_recovery_table( - recovery_exists=recovery_exists, - main_exists=main_exists, - recovery_mtime=( - os.path.getmtime(recovery_filepath) - if recovery_exists else None - ), - main_mtime=( - os.path.getmtime(filepath) - if main_exists else None - ), - ) - ): - filepath = recovery_filepath - elif not main_exists: - continue - - if not os.path.exists(filepath): - continue + saveToolbutton.sigLeftClick.connect(self.savePointsAddedByClicking) + saveAction = toolbar.addWidget(saveToolbutton) + saveToolbutton.action = saveAction + saveAction.saveToolbutton = saveToolbutton + saveAction.toolButton = toolButton + toolButton.saveAction = saveAction + toolButton.saveToolbutton = saveToolbutton - self.logger.info(f'Loading points from "{filepath}"...') - df = self.points.load_click_points_table(filepath) - posData.clickEntryPointsDfs[tableEndName] = df + toolButton.actions.append(saveAction) - try: - self.addPointsWin.loadButton.confirmAction() - except Exception as err: - pass + vlineAction = toolbar.addWidget(widgets.QVLine()) + spacerAction = toolbar.addWidget(widgets.QHWidgetSpacer(width=5)) - def removeClickedPoints(self, action, points): - posData = self.data[self.pos_i] - framePointsData = action.pointsData[self.pos_i][posData.frame_i] - if posData.SizeZ > 1: - zProjHow = self.zProjComboBox.currentText() - if zProjHow != 'single z-slice': - _warnings.warnCannotAddRemovePointsProjection() - return - zSlice = self.zSliceScrollBar.sliderPosition() - else: - zSlice = None + toolButton.actions.append(vlineAction) + toolButton.actions.append(spacerAction) - points_to_remove = [] - for point in points: - pos = point.pos() - points_to_remove.append((pos.x(), pos.y(), point.data())) - removed_ids = self.points.remove_click_points( - framePointsData, - points_to_remove, - z_slice=zSlice, - z_radius=action.zRadius, + action = toolButton.action + scatterItem = action.scatterItem + scatterItem.sigHoverEntered.connect( + self.addPointsByClickingScatterItemHoverEntered ) - if removed_ids: - self.markPointsLayerDirty(action=action) - - return removed_ids - - def restorePrevPointIdRightClick(self, addPointsByClickingButton): - # Try to restore the id that was there before hovering - # because the hovering was required only to delete the - # point - try: - prevId = addPointsByClickingButton.rightClickIDSpinbox.prevId - addPointsByClickingButton.rightClickIDSpinbox.setValue(prevId) - except Exception as err: - addPointsByClickingButton.rightClickIDSpinbox.prevId = None - - def getClickedPointNewId( - self, action, current_id, pointIdSpinbox, isMagicPrompts=False - ): - removed_id = getattr(pointIdSpinbox, 'removedId', None) - if removed_id is not None: - pointIdSpinbox.removedId = None - return removed_id - - posData = self.data[self.pos_i] - if isMagicPrompts: - is_already_new = self.isPointIdAlreadyNew(current_id, action) - if is_already_new: - return current_id - - new_ID = self.setBrushID(return_val=True) - new_id = max(current_id, new_ID) + 1 - return new_id - else: - pointsDataPos = action.pointsData.get(self.pos_i) - return self.points.next_click_point_id( - pointsDataPos, - posData.frame_i, - current_id, - size_z=posData.SizeZ, - ) + self.pointsLayerClicksDfsToData(posData, toolbar=toolbar) - def setHoverCircleAddPoint(self, x, y): - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - if addPointsByClickingButton is None: - return - action = addPointsByClickingButton.action - self.setHoverToolSymbolData( - [x], [y], (self.ax1_BrushCircle,), - size=action.pointSize - ) + def should_compute_points_layer( + self, + *, + layer_type_index: int, + compute_points_layers: bool, + ) -> bool: + return layer_type_index < 2 and compute_points_layers - def isPointIdAlreadyNew(self, point_id, action): - posData = self.data[self.pos_i] - pointsDataPos = action.pointsData.get(self.pos_i) - return self.points.point_id_already_new( - pointsDataPos, - posData.frame_i, - point_id, - posData.IDs_idxs, - ) + def should_load_recovery_table( + self, + *, + recovery_exists: bool, + main_exists: bool, + recovery_mtime: float | None, + main_mtime: float | None, + ) -> bool: + if not recovery_exists: + return False + if not main_exists: + return True + if recovery_mtime is None or main_mtime is None: + return False + return recovery_mtime > main_mtime + self.recovery_tolerance_seconds - def addClickedPoint(self, action, x, y, id): - x, y = round(x, 2), round(y, 2) - posData = self.data[self.pos_i] - if action.snapToMax: - radius = round(action.pointSize/2) - rr, cc = skimage.draw.disk((round(y), round(x)), radius) - idx_max = (self.img1.image[rr, cc]).argmax() - y, x = rr[idx_max], cc[idx_max] + def should_log_missing_frame_points(self, layer_type_index: int) -> bool: + return layer_type_index != 4 - pointsDataPos = action.pointsData.setdefault(self.pos_i, {}) - zSlice = None - if posData.SizeZ > 1: - zSlice = self.zSliceScrollBar.sliderPosition() - self.points.add_click_point( - pointsDataPos, - posData.frame_i, - x, - y, - id, - size_z=posData.SizeZ, - z_slice=zSlice, + def should_use_z_slice( + self, + *, + z_projection_mode: str, + size_z: int, + frame_points_data: Mapping, + ) -> bool: + return ( + z_projection_mode == "single z-slice" + and size_z > 1 + and "x" not in frame_points_data ) - self.markPointsLayerDirty(action=action) - def showPointsLayerIdsToggled(self, button, checked): button.action.scatterItem.drawIds = checked self.drawPointsLayers() - def removePointsLayer(self, button, toolbar=None): - button.setChecked(False) - button.action.scatterItem.setData([], []) - button.action.loadedDfInfo = None - self.ax1.removeItem(button.action.scatterItem) - toolbar.removeAction(button.action) - for action in button.actions: - toolbar.removeAction(action) - - if toolbar == self.promptSegmentPointsLayerToolbar: - self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False + def storeUndoAddPoint(self, action): + if not hasattr(self, "undoAddPointQueueMapper"): + self.undoAddPointQueueMapper = defaultdict(list) - def editPointsLayerAppearance(self, button): - win = apps.EditPointsLayerAppearanceDialog(parent=self.host) - win.restoreState(button.action.state) - win.exec_() - if win.cancel: + self.data[self.pos_i] + pointsDataPos = action.pointsData.get(self.pos_i) + if pointsDataPos is None: return - symbol = win.symbol - color = win.color - pointSize = win.pointSize - zRadius = int((win.zHeight-1)/2) - r,g,b,a = color.getRgb() - - scatterItem = button.action.scatterItem - scatterItem.opts['hoverBrush'] = pg.mkBrush((r,g,b,200)) - scatterItem.setSymbol(symbol, update=False) - scatterItem.setBrush(pg.mkBrush(color=(r,g,b,100)), update=False) - scatterItem.setPen(pg.mkPen(width=2, color=(r,g,b)), update=False) - scatterItem.setSize(pointSize, update=True) + state = deepcopy(pointsDataPos) + self.undoAddPointQueueMapper[action].append(state) + self.undoAction.setEnabled(True) - button.action.brushColor = (r,g,b,100) - button.action.penColor = (r,g,b) - button.action.pointSize = pointSize - button.action.zRadius = zRadius + def undoAddPoint(self, action): + undoAddPointQueue = self.undoAddPointQueueMapper.get(action) + if undoAddPointQueue is None: + return False - button.action.state = win.state() + if len(undoAddPointQueue) == 0: + return False - def loadPointsLayerWeighingData(self, action, weighingChannel): - if not weighingChannel: - return + self.data[self.pos_i] + state = undoAddPointQueue.pop(-1) + action.pointsData[self.pos_i] = state + self.markPointsLayerDirty(action=action) - self.logger.info(f'Loading "{weighingChannel}" weighing data...') - action.weighingData = [] - for p, posData in enumerate(self.data): - if weighingChannel == posData.user_ch_name: - wData = posData.img_data - action.weighingData.append(wData) - continue + self.drawPointsLayers(computePointsLayers=False) - path, filename = self.getPathFromChName(weighingChannel, posData) - if path is None: - self.criticalFluoChannelNotFound(weighingChannel, posData) - action.weighingData = [] - return + if len(self.undoAddPointQueueMapper[action]) == 0: + self.undoAction.setEnabled(True) - if filename in posData.fluo_data_dict: - # Weighing data already loaded as additional fluo channel - wData = posData.fluo_data_dict[filename] - else: - # Weighing data never loaded --> load now - wData, _ = self.load_fluo_data(path) - if posData.SizeT == 1: - wData = wData[np.newaxis] - action.weighingData.append(wData) + return True - def pointLayerToolbuttonToggled(self, checked): - action = self.sender().action - action.scatterItem.setVisible(checked) + def updatePointsLayerClickEntryTableEndname(self, saveToolbutton, table_endname): + saveAction = saveToolbutton.action + toolButton = saveAction.toolButton + toolButton.clickEntryTableEndName = table_endname - def getCentroidsPointsData(self, action): - # Centroids (either weighted or not) - # NOTE: if user requested to draw from table we load that in - # apps.AddPointsLayerDialog.ok_cb() - posData = self.data[self.pos_i] - action.pointsData[self.pos_i] = {posData.frame_i: {}} - if hasattr(action, 'weighingData'): - lab = posData.lab - img = action.weighingData[self.pos_i][posData.frame_i] - rp = skimage.measure.regionprops(lab, intensity_image=img) - attr = 'weighted_centroid' - else: - rp = posData.rp - attr = 'centroid' - for i, obj in enumerate(rp): - centroid = getattr(obj, attr) - if len(centroid) == 3: - zc, yc, xc = centroid - z_int = round(zc) - if z_int not in action.pointsData[self.pos_i][posData.frame_i]: - action.pointsData[self.pos_i][posData.frame_i][z_int] = { - 'x': [xc], 'y': [yc], 'id': [obj.label] - } - else: - z_data = action.pointsData[self.pos_i][posData.frame_i][z_int] - z_data['x'].append(xc) - z_data['y'].append(yc) - z_data['id'].append(obj.label) - else: - yc, xc = centroid - if 'y' not in action.pointsData[self.pos_i][posData.frame_i]: - action.pointsData[self.pos_i][posData.frame_i]['y'] = [yc] - action.pointsData[self.pos_i][posData.frame_i]['x'] = [xc] - action.pointsData[self.pos_i][posData.frame_i]['id'] = ( - [obj.label] - ) - else: - action.pointsData[self.pos_i][posData.frame_i]['y'].append(yc) - action.pointsData[self.pos_i][posData.frame_i]['x'].append(xc) - action.pointsData[self.pos_i][posData.frame_i]['id'].append( - obj.label - ) + self.logger.info( + f'Done. Click entry table endname updated to "{table_endname}"' + ) - def drawPointsLayers(self, computePointsLayers=True): + def zoomToObj(self, obj=None): + if not hasattr(self, "data"): + return posData = self.data[self.pos_i] - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - - if self.should_compute_points_layer( - layer_type_index=action.layerTypeIdx, - compute_points_layers=computePointsLayers, - ): - self.getCentroidsPointsData(action) - - if not action.button.isChecked(): - continue - - frames = action.pointsData.get(self.pos_i, set()) - if posData.frame_i not in frames: - if self.should_log_missing_frame_points( - action.layerTypeIdx - ): - self.logger.info( - f'Frame number {posData.frame_i+1} does not have any ' - f'"{action.layerType}" point to display.' - ) - continue - - framePointsData = action.pointsData[self.pos_i][posData.frame_i] - - zSlice = None - zProjHow = self.zProjComboBox.currentText() - isZslice = self.should_use_z_slice( - z_projection_mode=zProjHow, - size_z=posData.SizeZ, - frame_points_data=framePointsData, - ) - if isZslice: - zSlice = self.zSliceScrollBar.sliderPosition() - xx, yy, ids, data = self.points.flatten_frame_points_data( - framePointsData, - z_slice=zSlice, - z_radius=action.zRadius, - ) - - brushColors = [ - action.brushColor if id != 0 else action.brushColorId0 - for id in ids - ] - brushes = [pg.mkBrush(color) for color in brushColors] - - pensColor = [ - action.penColor if id != 0 else action.penColorId0 - for id in ids - ] - pens = [pg.mkPen(color) for color in pensColor] + if obj is None: + ID = self.sender().value() + try: + ID_idx = posData.IDs_idxs[ID] + obj = obj = posData.rp[ID_idx] + except Exception: + self.logger.warning(f"ID {ID} does not exist (add points by clicking)") - if action.layerTypeIdx == 2: - # For loaded table show the rest of the table as a tooltip - data = data - show_data_as_tip = True - else: - data = ids - show_data_as_tip = False + if obj is None: + return - xx = np.array(xx) # + 0.5 - yy = np.array(yy) # + 0.5 + self.goToZsliceSearchedID(obj) + min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) + xRange = min_col - 5, max_col + 5 + yRange = max_row + 5, min_row - 5 - action.scatterItem.show_data_as_tip = show_data_as_tip - action.scatterItem.setData( - xx, yy, data=data, brush=brushes, pen=pens - ) \ No newline at end of file + self.ax1.setRange(xRange=xRange, yRange=yRange) diff --git a/cellacdc/mixins/preprocessing.py b/cellacdc/mixins/preprocessing.py index ad8ce52ea..c6f6b56fd 100644 --- a/cellacdc/mixins/preprocessing.py +++ b/cellacdc/mixins/preprocessing.py @@ -5,60 +5,60 @@ from typing import Any, Dict, List, Tuple, Union import numpy as np -from typing import Any from qtpy.QtCore import QMutex, QThread, QWaitCondition from cellacdc import apps, html_utils, widgets, workers from cellacdc.plot import imshow -class PreprocessingView: +class PreprocessingMixin: """Qt-facing adapter around preprocessing dialogs and workers.""" - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) + def askGet2Dor3Dimage(self): + txt = html_utils.paragraph(""" + Do you want to test the denoising on the visualized 2D image or + on the entire 3D z-stack? + """) + msg = widgets.myMessageBox(wrapText=False) + _, use3Dbutton, use2Dbutton = msg.question( + self, + "3D denoising?", + txt, + buttonsTexts=("Cancel", "Denoise 3D z-stack", "Denoise 2D image"), + ) + if msg.cancel: + return - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) + if msg.clickedButton == use3Dbutton: + posData = self.data[self.pos_i] + zslice = self.zSliceScrollBar.sliderPosition() + return posData.img_data[posData.frame_i, zslice] else: - setattr(self.host, name, value) + return self.getDisplayedImg1() - def askGet2Dor3Dimage(self): - txt = html_utils.paragraph(""" + def create_preprocessed_data(self, image_data=None): + return PreprocessedData(image_data=image_data) - """Headless preprocessing operations used by GUI and scripts.""" + def debugShowImg(self, img): + imshow(img) - def validate_multidimensional_recipe( - self, - recipe: list[dict[str, Any]], - *, - apply_to_all_zslices: bool = False, - apply_to_all_frames: bool = False, - ): - return core_validate_multidimensional_recipe( - recipe, - apply_to_all_zslices=apply_to_all_zslices, - apply_to_all_frames=apply_to_all_frames, - ) + def getChData(self, requ_ch=None, pos_i=None): + if not pos_i: + pos_i = self.pos_i - def preprocess_image_from_recipe(self, image, recipe: list[dict[str, Any]]): - return core_preprocess_image_from_recipe(image, recipe) + posData = self.data[pos_i] - def preprocess_zstack_from_recipe(self, image, recipe: list[dict[str, Any]]): - return core_preprocess_zstack_from_recipe(image, recipe) + if not requ_ch: + requ_ch = set(self.ch_names) + else: + requ_ch = set(requ_ch) - def preprocess_video_from_recipe(self, image, recipe: list[dict[str, Any]]): - return core_preprocess_video_from_recipe(image, recipe) + posData.setLoadedChannelNames() - def preprocess_multi_pos_from_recipe( - self, - images, - recipe: list[dict[str, Any]], - ): - return core_preprocess_multi_pos_from_recipe(images, recipe) + loaded_channels = set(posData.loadedChNames) + missing_channels = requ_ch - loaded_channels + + self.loadFluo_cb(fluo_channels=missing_channels) def image_to_float( self, @@ -82,26 +82,105 @@ def normalize_display_image(self, image, how: str): image_to_float=self.image_to_float, ) - def create_preprocessed_data(self, image_data=None): - return PreprocessedData(image_data=image_data) + def preprocWorkerClosed(self, worker): + self.logger.info("Pre-processing worker stopped.") - Do you want to test the denoising on the visualized 2D image or - on the entire 3D z-stack? - """) - msg = widgets.myMessageBox(wrapText=False) - _, use3Dbutton, use2Dbutton = msg.question( - self.host, '3D denoising?', txt, - buttonsTexts=('Cancel', 'Denoise 3D z-stack', 'Denoise 2D image') - ) - if msg.cancel: - return + def preprocWorkerCritical(self, error): + self.preprocessDialog.appliedFinished() + self.workerCritical(error) - if msg.clickedButton == use3Dbutton: - posData = self.data[self.pos_i] - zslice = self.zSliceScrollBar.sliderPosition() - return posData.img_data[posData.frame_i, zslice] + def preprocWorkerDone( + self, + processed_data: np.ndarray, + how: str, + ): + self.setStatusBarLabel(log=False) + self.preprocessDialog.appliedFinished() + + posData = self.data[self.pos_i] + if not hasattr(posData, "preproc_img_data"): + posData.preproc_img_data = preprocess.PreprocessedData() + + if how == "current_image": + if posData.SizeZ > 1: + z_slice = self.z_slice_index() + posData.preproc_img_data[posData.frame_i][z_slice] = processed_data + else: + posData.preproc_img_data[posData.frame_i] = processed_data + z_slice = 0 + self.img1.updateMinMaxValuesPreprocessedData( + self.data, self.pos_i, posData.frame_i, z_slice + ) + elif how == "z_stack": + for z_slice, processed_img in enumerate(processed_data): + posData.preproc_img_data[posData.frame_i][z_slice] = processed_img + self.img1.updateMinMaxValuesPreprocessedData( + self.data, self.pos_i, posData.frame_i, z_slice + ) + self.img1.updateMinMaxValuesPreprocessedProjections( + self.data, self.pos_i, posData.frame_i + ) + elif how == "all_frames": + for frame_i, processed_frame in enumerate(processed_data): + if processed_frame.ndim == 2: + processed_frame = (processed_frame,) + + for z_slice, processed_img in enumerate(processed_frame): + posData.preproc_img_data[frame_i][z_slice] = processed_img + self.img1.updateMinMaxValuesPreprocessedData( + self.data, self.pos_i, frame_i, z_slice + ) + self.img1.updateMinMaxValuesPreprocessedProjections( + self.data, self.pos_i, frame_i + ) + elif how == "all_pos": + for pos_i, processed_pos_data in enumerate(processed_data): + if processed_pos_data.ndim == 2: + processed_pos_data = (processed_pos_data,) + + posData = self.data[pos_i] + if not hasattr(posData, "preproc_img_data"): + posData.preproc_img_data = preprocess.PreprocessedData() + for z_slice, processed_img in enumerate(processed_pos_data): + posData.preproc_img_data[0][z_slice] = processed_img + self.img1.updateMinMaxValuesPreprocessedData( + self.data, pos_i, 0, z_slice + ) + + if posData.SizeZ > 1: + self.img1.updateMinMaxValuesPreprocessedProjections( + self.data, pos_i, frame_i + ) + + if not self.viewPreprocDataToggle.isChecked(): + self.viewPreprocDataToggle.setChecked(True) else: - return self.getDisplayedImg1() + self.setImageImg1() + + def preprocWorkerIsQueueEmpty(self, isEmpty: bool): + if isEmpty: + self.preprocessDialog.appliedFinished() + else: + self.preprocessDialog.setDisabled(True) + self.preprocessDialog.infoLabel.setText( + "Computing preview...
" + "(Feel free to use Cell-ACDC while waiting)" + ) + + def preprocWorkerPreviewDone( + self, processed_data: np.ndarray, key: Tuple[int, int, Union[int, str]] + ): + pos_i, frame_i, z_slice = key + posData = self.data[pos_i] + if not hasattr(posData, "preproc_img_data"): + posData.preproc_img_data = preprocess.PreprocessedData( + image_data=np.zeros(posData.img_data.shape) + ) + + posData.preproc_img_data[frame_i][z_slice] = processed_data + self.img1.updateMinMaxValuesPreprocessedData(self.data, pos_i, frame_i, z_slice) + + self.setImageImg1() def preprocessActionTriggered(self): self.preprocessDialog.show() @@ -109,51 +188,84 @@ def preprocessActionTriggered(self): self.preprocessDialog.activateWindow() self.preprocessDialog.emitSigPreviewToggled() - def preprocessDialogRecipeChanged(self, recipe): + def preprocessAllFrames(self, recipe: List[Dict[str, Any]]): + txt = "Pre-processing all frames..." + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + posData = self.data[self.pos_i] + func = core.preprocess_video_from_recipe + image_data = posData.img_data + self.preprocWorker.setupJob(func, image_data, recipe, "all_frames") + self.preprocWorker.wakeUp() + + def preprocessAllPos(self, recipe: List[Dict[str, Any]]): + txt = "Pre-processing all Positions..." + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + func = core.preprocess_multi_pos_from_recipe + recipe = core.validate_multidimensional_recipe( + recipe, apply_to_all_frames=False + ) + image_data = [posData.img_data[0] for posData in self.data] + self.preprocWorker.setupJob(func, image_data, recipe, "all_pos") + + self.preprocWorker.wakeUp() + + def preprocessCurrentImage(self, recipe: List[Dict[str, Any]], *args): + txt = "Pre-processing current image..." + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + func = core.preprocess_image_from_recipe + recipe = core.validate_multidimensional_recipe(recipe) + + image_data = self.getImage(raw=True) + self.preprocWorker.setupJob(func, image_data, recipe, "current_image") + + self.preprocWorker.wakeUp() + + def preprocessDialogRecipeChanged( + self, recipe + ): # why does this need the recepie as an arg recipe = self.preprocessDialog.recipe() if recipe is None: - self.logger.warning('Pre-processing recipe not initialized yet.') + self.logger.warning("Pre-processing recipe not initialized yet.") return self.updatePreprocessPreview(recipe=recipe) - def debugShowImg(self, img): - imshow(img) - def preprocessDialogSavePreprocessedData(self, dialog): posData = self.data[self.pos_i] try: posData.preprocessedDataArray() except TypeError as e: - if 'Not all frames have been processed.' in str(e): + if "Not all frames have been processed." in str(e): msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Not all frames have been processed.
' - 'Please process all frames before saving.' - ) - msg.warning( - self.host, 'Process all data before saving', txt + "Not all frames have been processed.
" + "Please process all frames before saving." ) + msg.warning(self, "Process all data before saving", txt) return - helpText = ( - """ - The preprocessed image file will be saved with a different + helpText = """ + The preprocessed image file will be saved with a different file name.

- Insert a name to append to the end of the new file name. The rest of + Insert a name to append to the end of the new file name. The rest of the name will be the same as the original file. """ - ) win = apps.filenameDialog( - basename=f'{posData.basename}{self.user_ch_name}', + basename=f"{posData.basename}{self.user_ch_name}", ext=".tif", - hintText='Insert a name for the preprocessed image file:', - defaultEntry='preprocessed', + hintText="Insert a name for the preprocessed image file:", + defaultEntry="preprocessed", helpText=helpText, allowEmpty=False, - parent=dialog + parent=dialog, ) win.exec_() if win.cancel: @@ -162,14 +274,14 @@ def preprocessDialogSavePreprocessedData(self, dialog): appendedText = win.entryText self.progressWin = apps.QDialogWorkerProgress( - title='Saving pre-processed image(s)', - parent=self.host, - pbarDesc='Saving pre-processed image(s)' + title="Saving pre-processed image(s)", + parent=self, + pbarDesc="Saving pre-processed image(s)", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - self.statusBarLabel.setText('Saving pre-processed data...') + self.statusBarLabel.setText("Saving pre-processed data...") self.savePreprocWorker = workers.SaveProcessedDataWorker( self.data, appendedText, ext=".tif" @@ -177,189 +289,71 @@ def preprocessDialogSavePreprocessedData(self, dialog): self.savePreprocThread = QThread() self.savePreprocWorker.moveToThread(self.savePreprocThread) - self.savePreprocWorker.signals.finished.connect( - self.savePreprocThread.quit - ) + self.savePreprocWorker.signals.finished.connect(self.savePreprocThread.quit) self.savePreprocWorker.signals.finished.connect( self.savePreprocWorker.deleteLater ) - self.savePreprocThread.finished.connect( - self.savePreprocThread.deleteLater - ) + self.savePreprocThread.finished.connect(self.savePreprocThread.deleteLater) - self.savePreprocWorker.signals.critical.connect( - self.workerCritical - ) + self.savePreprocWorker.signals.critical.connect(self.workerCritical) self.savePreprocWorker.signals.initProgressBar.connect( self.workerInitProgressbar ) - self.savePreprocWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.savePreprocWorker.signals.progress.connect( - self.workerProgress - ) - self.savePreprocWorker.signals.finished.connect( - self.savePreprocWorkerFinished - ) + self.savePreprocWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.savePreprocWorker.signals.progress.connect(self.workerProgress) + self.savePreprocWorker.signals.finished.connect(self.savePreprocWorkerFinished) - self.savePreprocThread.started.connect( - self.savePreprocWorker.run - ) + self.savePreprocThread.started.connect(self.savePreprocWorker.run) self.savePreprocThread.start() def preprocessEnqueueCurrentImage(self, recipe): posData = self.data[self.pos_i] - func = self.preprocess_image_from_recipe + func = core.preprocess_image_from_recipe image_data = self.getImage(raw=True) if posData.SizeZ > 1: z_slice = self.z_slice_index() else: z_slice = 0 - recipe = self.validate_multidimensional_recipe( - recipe - ) + recipe = core.validate_multidimensional_recipe(recipe) key = (self.pos_i, posData.frame_i, z_slice) - self.preprocWorker.enqueue( - func, - image_data, - recipe, - key - ) - - def getChData(self, requ_ch=None, pos_i=None): - if not pos_i: - pos_i = self.pos_i - - posData = self.data[pos_i] - - if not requ_ch: - requ_ch = set(self.ch_names) - else: - requ_ch = set(requ_ch) - - posData.setLoadedChannelNames() - - loaded_channels = set(posData.loadedChNames) - missing_channels = requ_ch - loaded_channels - - self.loadFluo_cb(fluo_channels=missing_channels) - - def updatePreprocessPreview(self, *args, **kwargs): - force = kwargs.get('force', False) - - if not self.preprocessDialog.isVisible() and not force: - return - - if not self.preprocessDialog.previewCheckbox.isChecked() and not force: - return - - if kwargs.get('recipe') is None: - recipe = self.preprocessDialog.recipe() - else: - recipe = kwargs.get('recipe') - - if recipe is None: - self.logger.warning('Pre-processing recipe not initialized yet.') - return - - txt = 'Pre-processing current image...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - self.preprocessEnqueueCurrentImage(recipe) + self.preprocWorker.enqueue(func, image_data, recipe, key) def preprocessPreviewToggled(self, checked): self.viewPreprocDataToggle.setChecked(checked) self.updatePreprocessPreview() - def viewPreprocDataToggled(self, checked): - self.img1.setUsePreprocessed(checked) - self.setImageImg1() - - if self.viewCombineChannelDataToggle.isChecked(): - self.viewCombineChannelDataToggle.toggled.disconnect() - self.viewCombineChannelDataToggle.setChecked(False) - self.viewCombineChannelDataToggle.toggled.connect( - self.viewCombineChannelDataToggled - ) - - def preprocessCurrentImage(self, recipe: List[Dict[str, Any]], *args): - txt = 'Pre-processing current image...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - func = self.preprocess_image_from_recipe - recipe = self.validate_multidimensional_recipe( - recipe - ) - - image_data = self.getImage(raw=True) - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'current_image' - ) - - self.preprocWorker.wakeUp() - def preprocessZStack(self, recipe: List[Dict[str, Any]], *args): - txt = 'Pre-processing z-stack...' + txt = "Pre-processing z-stack..." self.statusBarLabel.setText(txt) self.logger.info(txt) posData = self.data[self.pos_i] - func = self.preprocess_zstack_from_recipe - recipe = self.validate_multidimensional_recipe( + func = core.preprocess_zstack_from_recipe + recipe = core.validate_multidimensional_recipe( recipe, apply_to_all_frames=False ) image_data = posData.img_data[posData.frame_i] - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'z_stack' - ) + self.preprocWorker.setupJob(func, image_data, recipe, "z_stack") self.preprocWorker.wakeUp() - def preprocessAllFrames(self, recipe: List[Dict[str, Any]]): - txt = 'Pre-processing all frames...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - posData = self.data[self.pos_i] - func = self.preprocess_video_from_recipe - image_data = posData.img_data - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'all_frames' - ) - self.preprocWorker.wakeUp() + def preprocess_image_from_recipe(self, image, recipe: list[dict[str, Any]]): + return core_preprocess_image_from_recipe(image, recipe) - def preprocessAllPos(self, recipe: List[Dict[str, Any]]): - txt = 'Pre-processing all Positions...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) + def preprocess_multi_pos_from_recipe( + self, + images, + recipe: list[dict[str, Any]], + ): + return core_preprocess_multi_pos_from_recipe(images, recipe) - func = self.preprocess_multi_pos_from_recipe - recipe = self.validate_multidimensional_recipe( - recipe, apply_to_all_frames=False - ) - image_data = [posData.img_data[0] for posData in self.data] - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'all_pos' - ) + def preprocess_video_from_recipe(self, image, recipe: list[dict[str, Any]]): + return core_preprocess_video_from_recipe(image, recipe) - self.preprocWorker.wakeUp() + def preprocess_zstack_from_recipe(self, image, recipe: list[dict[str, Any]]): + return core_preprocess_zstack_from_recipe(image, recipe) def setupPreprocessing(self): posData = self.data[self.pos_i] @@ -367,30 +361,20 @@ def setupPreprocessing(self): self.preprocessDialog.close() self.preprocessDialog = apps.PreProcessRecipeDialog( - isTimelapse=posData.SizeT>1, - isZstack=posData.SizeZ>1, - isMultiPos=len(self.data)>1, + isTimelapse=posData.SizeT > 1, + isZstack=posData.SizeZ > 1, + isMultiPos=len(self.data) > 1, df_metadata=posData.metadata_df, hideOnClosing=True, addApplyButton=True, - parent=self.host + parent=self, ) self.doPreviewPreprocImage = False - self.preprocessDialog.sigApplyImage.connect( - self.preprocessCurrentImage - ) - self.preprocessDialog.sigApplyZstack.connect( - self.preprocessZStack - ) - self.preprocessDialog.sigApplyAllFrames.connect( - self.preprocessAllFrames - ) - self.preprocessDialog.sigApplyAllPos.connect( - self.preprocessAllPos - ) - self.preprocessDialog.sigPreviewToggled.connect( - self.preprocessPreviewToggled - ) + self.preprocessDialog.sigApplyImage.connect(self.preprocessCurrentImage) + self.preprocessDialog.sigApplyZstack.connect(self.preprocessZStack) + self.preprocessDialog.sigApplyAllFrames.connect(self.preprocessAllFrames) + self.preprocessDialog.sigApplyAllPos.connect(self.preprocessAllPos) + self.preprocessDialog.sigPreviewToggled.connect(self.preprocessPreviewToggled) self.preprocessDialog.sigValuesChanged.connect( self.preprocessDialogRecipeChanged ) @@ -411,15 +395,11 @@ def setupPreprocessing(self): self.preprocWorker.moveToThread(self.preprocThread) self.preprocWorker.signals.finished.connect(self.preprocThread.quit) - self.preprocWorker.signals.finished.connect( - self.preprocWorker.deleteLater - ) + self.preprocWorker.signals.finished.connect(self.preprocWorker.deleteLater) self.preprocThread.finished.connect(self.preprocThread.deleteLater) self.preprocWorker.sigDone.connect(self.preprocWorkerDone) - self.preprocWorker.sigIsQueueEmpty.connect( - self.preprocWorkerIsQueueEmpty - ) + self.preprocWorker.sigIsQueueEmpty.connect(self.preprocWorkerIsQueueEmpty) self.preprocWorker.sigPreviewDone.connect(self.preprocWorkerPreviewDone) self.preprocWorker.signals.progress.connect(self.workerProgress) self.preprocWorker.signals.critical.connect(self.workerCritical) @@ -428,121 +408,52 @@ def setupPreprocessing(self): self.preprocThread.started.connect(self.preprocWorker.run) self.preprocThread.start() - self.logger.info('Pre-processing worker started.') - - def preprocWorkerCritical(self, error): - self.preprocessDialog.appliedFinished() - self.workerCritical(error) - - def preprocWorkerIsQueueEmpty(self, isEmpty: bool): - if isEmpty: - self.preprocessDialog.appliedFinished() - else: - self.preprocessDialog.setDisabled(True) - self.preprocessDialog.infoLabel.setText( - 'Computing preview...
' - '(Feel free to use Cell-ACDC while waiting)' - ) - - def preprocWorkerPreviewDone( - self, processed_data: np.ndarray, - key: Tuple[int, int, Union[int, str]] - ): - pos_i, frame_i, z_slice = key - posData = self.data[pos_i] - if not hasattr(posData, 'preproc_img_data'): - posData.preproc_img_data = ( - self.create_preprocessed_data( - image_data=np.zeros(posData.img_data.shape) - ) - ) + self.logger.info("Pre-processing worker started.") - posData.preproc_img_data[frame_i][z_slice] = processed_data - self.img1.updateMinMaxValuesPreprocessedData( - self.data, pos_i, frame_i, z_slice - ) + def updatePreprocessPreview(self, *args, **kwargs): + force = kwargs.get("force", False) - self.setImageImg1() + if not self.preprocessDialog.isVisible() and not force: + return - def preprocWorkerDone( - self, - processed_data: np.ndarray, - how: str, - ): - self.status_hover_view.set_status_bar_label(log=False) - self.preprocessDialog.appliedFinished() + if not self.preprocessDialog.previewCheckbox.isChecked() and not force: + return - posData = self.data[self.pos_i] - if not hasattr(posData, 'preproc_img_data'): - posData.preproc_img_data = ( - self.create_preprocessed_data() - ) + if kwargs.get("recipe") is None: + recipe = self.preprocessDialog.recipe() + else: + recipe = kwargs.get("recipe") - if how == 'current_image': - if posData.SizeZ > 1: - z_slice = self.z_slice_index() - posData.preproc_img_data[posData.frame_i][z_slice] = ( - processed_data - ) - else: - posData.preproc_img_data[posData.frame_i] = processed_data - z_slice = 0 - self.img1.updateMinMaxValuesPreprocessedData( - self.data, self.pos_i, posData.frame_i, z_slice - ) - elif how == 'z_stack': - for z_slice, processed_img in enumerate(processed_data): - posData.preproc_img_data[posData.frame_i][z_slice] = ( - processed_img - ) - self.img1.updateMinMaxValuesPreprocessedData( - self.data, self.pos_i, posData.frame_i, z_slice - ) - self.img1.updateMinMaxValuesPreprocessedProjections( - self.data, self.pos_i, posData.frame_i - ) - elif how == 'all_frames': - for frame_i, processed_frame in enumerate(processed_data): - if processed_frame.ndim == 2: - processed_frame = (processed_frame,) + if recipe is None: + self.logger.warning("Pre-processing recipe not initialized yet.") + return - for z_slice, processed_img in enumerate(processed_frame): - posData.preproc_img_data[frame_i][z_slice] = ( - processed_img - ) - self.img1.updateMinMaxValuesPreprocessedData( - self.data, self.pos_i, frame_i, z_slice - ) - self.img1.updateMinMaxValuesPreprocessedProjections( - self.data, self.pos_i, frame_i - ) - elif how == 'all_pos': - for pos_i, processed_pos_data in enumerate(processed_data): - if processed_pos_data.ndim == 2: - processed_pos_data = (processed_pos_data,) + txt = "Pre-processing current image..." + self.logger.info(txt) + self.statusBarLabel.setText(txt) - posData = self.data[pos_i] - if not hasattr(posData, 'preproc_img_data'): - posData.preproc_img_data = ( - self.create_preprocessed_data() - ) - for z_slice, processed_img in enumerate(processed_pos_data): - posData.preproc_img_data[0][z_slice] = ( - processed_img - ) - self.img1.updateMinMaxValuesPreprocessedData( - self.data, pos_i, 0, z_slice - ) + self.preprocessEnqueueCurrentImage(recipe) - if posData.SizeZ > 1: - self.img1.updateMinMaxValuesPreprocessedProjections( - self.data, pos_i, frame_i - ) + def validate_multidimensional_recipe( + self, + recipe: list[dict[str, Any]], + *, + apply_to_all_zslices: bool = False, + apply_to_all_frames: bool = False, + ): + return core_validate_multidimensional_recipe( + recipe, + apply_to_all_zslices=apply_to_all_zslices, + apply_to_all_frames=apply_to_all_frames, + ) - if not self.viewPreprocDataToggle.isChecked(): - self.viewPreprocDataToggle.setChecked(True) - else: - self.setImageImg1() + def viewPreprocDataToggled(self, checked): + self.img1.setUsePreprocessed(checked) + self.setImageImg1() - def preprocWorkerClosed(self, worker): - self.logger.info('Pre-processing worker stopped.') \ No newline at end of file + if self.viewCombineChannelDataToggle.isChecked(): + self.viewCombineChannelDataToggle.toggled.disconnect() + self.viewCombineChannelDataToggle.setChecked(False) + self.viewCombineChannelDataToggle.toggled.connect( + self.viewCombineChannelDataToggled + ) diff --git a/cellacdc/mixins/quick_settings.py b/cellacdc/mixins/quick_settings.py index cbcec37d6..e40b63cb4 100644 --- a/cellacdc/mixins/quick_settings.py +++ b/cellacdc/mixins/quick_settings.py @@ -2,229 +2,201 @@ from __future__ import annotations -from dataclasses import dataclass from qtpy.QtCore import Qt from qtpy.QtWidgets import QFormLayout, QLabel, QVBoxLayout from cellacdc import apps, settings_csv_path, widgets -class QuickSettingsView: +class QuickSettingsMixin: """Qt-facing adapter around quick-settings view-model contracts.""" """Headless quick-settings decision rules.""" - def font_size_setting( - self, - saved_font_size, - *, - has_px_mode: bool, - ) -> FontSizeSetting: - saved_font_size = str(saved_font_size) - if saved_font_size.find('pt') != -1: - saved_font_size = saved_font_size[:-2] - font_size = int(saved_font_size) - if has_px_mode: - return FontSizeSetting(value=font_size) - return FontSizeSetting( - value=2*font_size, - add_px_mode_setting=True, - ) - - def should_update_all_contours(self, *, is_data_loaded: bool) -> bool: - return is_data_loaded - - - def __init__(self, host): - self.host = host - def create_show_props_button(self, side='left'): - self.host.leftSideDocksLayout = QVBoxLayout() - self.host.leftSideDocksLayout.setSpacing(0) - self.host.leftSideDocksLayout.setContentsMargins(0, 0, 0, 0) - self.host.rightSideDocksLayout = QVBoxLayout() - self.host.rightSideDocksLayout.setSpacing(0) - self.host.rightSideDocksLayout.setContentsMargins(0, 0, 0, 0) - self.host.showPropsDockButton = widgets.expandCollapseButton() - self.host.showPropsDockButton.setDisabled(True) - self.host.showPropsDockButton.setFocusPolicy(Qt.NoFocus) - self.host.showPropsDockButton.setToolTip('Show object properties') - if side == 'left': - self.host.leftSideDocksLayout.addWidget( - self.host.showPropsDockButton - ) - else: - self.host.rightSideDocksLayout.addWidget( - self.host.showPropsDockButton - ) - - def create_widgets(self): - self.host.quickSettingsLayout = QVBoxLayout() - self.host.quickSettingsGroupbox = widgets.GroupBox() - self.host.quickSettingsGroupbox.setTitle('Quick settings') - - layout = QFormLayout() - layout.setFieldGrowthPolicy( - QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint + def _add_autosave_interval_control(self, layout): + self.autoSaveIntervalEditButton = widgets.editPushButton( + flat=True, hoverable=True ) - layout.setFormAlignment(Qt.AlignRight | Qt.AlignVCenter) - - self._add_view_preprocessed_toggle(layout) - self._add_combined_channels_toggle(layout) - self._add_autosave_toggles(layout) - self._add_autosave_interval_control(layout) - self._add_cca_integrity_checker_toggle(layout) - self._add_lost_objects_toggle(layout) - self._add_realtime_tracking_toggle(layout) - self._add_show_all_contours_toggle(layout) - self._add_font_size_control(layout) - - self.host.quickSettingsGroupbox.setLayout(layout) - self.host.quickSettingsLayout.addWidget( - self.host.quickSettingsGroupbox + self.autoSaveIntervalLabel = QLabel("Autosave interval") + self.autoSaveIntervalSetTooltip() + layout.addRow( + self.autoSaveIntervalLabel, + self.autoSaveIntervalEditButton, ) - self.host.quickSettingsLayout.addStretch(1) - - def show_all_contours_toggled(self): - if not self.should_update_all_contours( - is_data_loaded=self.host.isDataLoaded - ): - return - self.host.computeAllContours() - self.host.updateAllImages() + self.autoSaveIntervalDialog = apps.AutoSaveIntervalDialog(parent=self) + self.autoSaveIntervalDialog.setValues(*self.autoSaveIntevalValueUnit) - def _add_view_preprocessed_toggle(self, layout): - self.host.viewPreprocDataToggle = widgets.Toggle() + def _add_autosave_toggles(self, layout): + self.autoSaveToggle = widgets.Toggle() tooltip = ( - 'View pre-processed data. See menu `Image --> Pre-processing...`\n' - 'on the top menubar.' + "Automatically store a copy of the segmentation data " + "in the `.recovery` folder after every edit." ) - self.host.viewPreprocDataToggle.setChecked(False) - self.host.viewPreprocDataToggle.setToolTip(tooltip) - label = QLabel('View pre-processed image') + self.autoSaveToggle.setChecked(True) + self.autoSaveToggle.setToolTip(tooltip) + label = QLabel("Autosave segmentation") label.setToolTip(tooltip) - layout.addRow(label, self.host.viewPreprocDataToggle) + layout.addRow(label, self.autoSaveToggle) - def _add_combined_channels_toggle(self, layout): - self.host.viewCombineChannelDataToggle = widgets.Toggle() + self.autoSaveAnnotToggle = widgets.Toggle() tooltip = ( - 'View combined channel. See menu `Image --> combing channels...`\n' - 'on the top menubar.' + "Automatically store a copy of the annotations (acdc_output CSV " + "file) in the `.recovery` folder after every edit." ) - self.host.viewCombineChannelDataToggle.setChecked(False) - self.host.viewCombineChannelDataToggle.setToolTip(tooltip) - label = QLabel('View combined channels') + self.autoSaveAnnotToggle.setChecked(True) + self.autoSaveAnnotToggle.setToolTip(tooltip) + label = QLabel("Autosave annotations") label.setToolTip(tooltip) - layout.addRow(label, self.host.viewCombineChannelDataToggle) + layout.addRow(label, self.autoSaveAnnotToggle) - def _add_autosave_toggles(self, layout): - self.host.autoSaveToggle = widgets.Toggle() - tooltip = ( - 'Automatically store a copy of the segmentation data ' - 'in the `.recovery` folder after every edit.' - ) - self.host.autoSaveToggle.setChecked(True) - self.host.autoSaveToggle.setToolTip(tooltip) - label = QLabel('Autosave segmentation') + def _add_cca_integrity_checker_toggle(self, layout): + self.ccaIntegrCheckerToggle = widgets.Toggle() + tooltip = "Toggle background cell cycle annotations integrity checker ON/OFF" + self.ccaIntegrCheckerToggle.setChecked(False) + self.ccaIntegrCheckerToggle.setToolTip(tooltip) + label = QLabel("Cc annot. checker") label.setToolTip(tooltip) - layout.addRow(label, self.host.autoSaveToggle) + layout.addRow(label, self.ccaIntegrCheckerToggle) + idx = "is_cca_integrity_checker_activated" + if idx in self.df_settings.index: + val = int(self.df_settings.at[idx, "value"]) + self.ccaIntegrCheckerToggle.setChecked(not val) - self.host.autoSaveAnnotToggle = widgets.Toggle() + def _add_combined_channels_toggle(self, layout): + self.viewCombineChannelDataToggle = widgets.Toggle() tooltip = ( - 'Automatically store a copy of the annotations (acdc_output CSV ' - 'file) in the `.recovery` folder after every edit.' + "View combined channel. See menu `Image --> combing channels...`\n" + "on the top menubar." ) - self.host.autoSaveAnnotToggle.setChecked(True) - self.host.autoSaveAnnotToggle.setToolTip(tooltip) - label = QLabel('Autosave annotations') + self.viewCombineChannelDataToggle.setChecked(False) + self.viewCombineChannelDataToggle.setToolTip(tooltip) + label = QLabel("View combined channels") label.setToolTip(tooltip) - layout.addRow(label, self.host.autoSaveAnnotToggle) - - def _add_autosave_interval_control(self, layout): - self.host.autoSaveIntervalEditButton = widgets.editPushButton( - flat=True, hoverable=True - ) - self.host.autoSaveIntervalLabel = QLabel('Autosave interval') - self.host.autoSaveIntervalSetTooltip() - layout.addRow( - self.host.autoSaveIntervalLabel, - self.host.autoSaveIntervalEditButton, - ) + layout.addRow(label, self.viewCombineChannelDataToggle) - self.host.autoSaveIntervalDialog = apps.AutoSaveIntervalDialog( - parent=self.host - ) - self.host.autoSaveIntervalDialog.setValues( - *self.host.autoSaveIntevalValueUnit - ) - - def _add_cca_integrity_checker_toggle(self, layout): - self.host.ccaIntegrCheckerToggle = widgets.Toggle() - tooltip = ( - 'Toggle background cell cycle annotations integrity checker ON/OFF' + def _add_font_size_control(self, layout): + self.fontSizeSpinBox = widgets.SpinBox() + self.fontSizeSpinBox.setMinimum(1) + self.fontSizeSpinBox.setMaximum(99) + layout.addRow("Font size", self.fontSizeSpinBox) + font_size_setting = self.font_size_setting( + self.df_settings.at["fontSize", "value"], + has_px_mode="pxMode" in self.df_settings.index, ) - self.host.ccaIntegrCheckerToggle.setChecked(False) - self.host.ccaIntegrCheckerToggle.setToolTip(tooltip) - label = QLabel('Cc annot. checker') - label.setToolTip(tooltip) - layout.addRow(label, self.host.ccaIntegrCheckerToggle) - idx = 'is_cca_integrity_checker_activated' - if idx in self.host.df_settings.index: - val = int(self.host.df_settings.at[idx, 'value']) - self.host.ccaIntegrCheckerToggle.setChecked(not val) + self.fontSize = font_size_setting.value + if font_size_setting.add_px_mode_setting: + self.df_settings.at["pxMode", "value"] = 1 + self.df_settings.to_csv(settings_csv_path) + self.fontSizeSpinBox.setValue(self.fontSize) + self.fontSizeSpinBox.editingFinished.connect(self.changeFontSize) + self.fontSizeSpinBox.sigUpClicked.connect(self.changeFontSize) + self.fontSizeSpinBox.sigDownClicked.connect(self.changeFontSize) def _add_lost_objects_toggle(self, layout): - self.host.annotLostObjsToggle = widgets.Toggle() - tooltip = 'Toggle annotation of lost objects mode ON/OFF' - self.host.annotLostObjsToggle.setChecked(True) - self.host.annotLostObjsToggle.setToolTip(tooltip) - label = QLabel('Annot. lost objects') + self.annotLostObjsToggle = widgets.Toggle() + tooltip = "Toggle annotation of lost objects mode ON/OFF" + self.annotLostObjsToggle.setChecked(True) + self.annotLostObjsToggle.setToolTip(tooltip) + label = QLabel("Annot. lost objects") label.setToolTip(tooltip) - layout.addRow(label, self.host.annotLostObjsToggle) + layout.addRow(label, self.annotLostObjsToggle) def _add_realtime_tracking_toggle(self, layout): - self.host.realTimeTrackingToggle = widgets.Toggle() - self.host.realTimeTrackingToggle.setChecked(True) - self.host.realTimeTrackingToggle.setDisabled(True) - label = QLabel('Real-time tracking') + self.realTimeTrackingToggle = widgets.Toggle() + self.realTimeTrackingToggle.setChecked(True) + self.realTimeTrackingToggle.setDisabled(True) + label = QLabel("Real-time tracking") label.setDisabled(True) - self.host.realTimeTrackingToggle.label = label - layout.addRow(label, self.host.realTimeTrackingToggle) + self.realTimeTrackingToggle.label = label + layout.addRow(label, self.realTimeTrackingToggle) def _add_show_all_contours_toggle(self, layout): - self.host.showAllContoursToggle = widgets.Toggle() + self.showAllContoursToggle = widgets.Toggle() tooltip = ( - 'If active, all contours will be displayed, including inner ' - 'contours(e.g. holes and sub-objects)' + "If active, all contours will be displayed, including inner " + "contours(e.g. holes and sub-objects)" ) - self.host.showAllContoursToggle.setToolTip(tooltip) - label = QLabel('Show all contours') + self.showAllContoursToggle.setToolTip(tooltip) + label = QLabel("Show all contours") label.setToolTip(tooltip) - layout.addRow(label, self.host.showAllContoursToggle) - self.host.showAllContoursToggle.toggled.connect( - self.show_all_contours_toggled - ) + layout.addRow(label, self.showAllContoursToggle) + self.showAllContoursToggle.toggled.connect(self.show_all_contours_toggled) - def _add_font_size_control(self, layout): - self.host.fontSizeSpinBox = widgets.SpinBox() - self.host.fontSizeSpinBox.setMinimum(1) - self.host.fontSizeSpinBox.setMaximum(99) - layout.addRow('Font size', self.host.fontSizeSpinBox) - font_size_setting = self.font_size_setting( - self.host.df_settings.at['fontSize', 'value'], - has_px_mode='pxMode' in self.host.df_settings.index, - ) - self.host.fontSize = font_size_setting.value - if font_size_setting.add_px_mode_setting: - self.host.df_settings.at['pxMode', 'value'] = 1 - self.host.df_settings.to_csv(settings_csv_path) - self.host.fontSizeSpinBox.setValue(self.host.fontSize) - self.host.fontSizeSpinBox.editingFinished.connect( - self.host.changeFontSize + def _add_view_preprocessed_toggle(self, layout): + self.viewPreprocDataToggle = widgets.Toggle() + tooltip = ( + "View pre-processed data. See menu `Image --> Pre-processing...`\n" + "on the top menubar." ) - self.host.fontSizeSpinBox.sigUpClicked.connect( - self.host.changeFontSize + self.viewPreprocDataToggle.setChecked(False) + self.viewPreprocDataToggle.setToolTip(tooltip) + label = QLabel("View pre-processed image") + label.setToolTip(tooltip) + layout.addRow(label, self.viewPreprocDataToggle) + + def create_show_props_button(self, side="left"): + self.leftSideDocksLayout = QVBoxLayout() + self.leftSideDocksLayout.setSpacing(0) + self.leftSideDocksLayout.setContentsMargins(0, 0, 0, 0) + self.rightSideDocksLayout = QVBoxLayout() + self.rightSideDocksLayout.setSpacing(0) + self.rightSideDocksLayout.setContentsMargins(0, 0, 0, 0) + self.showPropsDockButton = widgets.expandCollapseButton() + self.showPropsDockButton.setDisabled(True) + self.showPropsDockButton.setFocusPolicy(Qt.NoFocus) + self.showPropsDockButton.setToolTip("Show object properties") + if side == "left": + self.leftSideDocksLayout.addWidget(self.showPropsDockButton) + else: + self.rightSideDocksLayout.addWidget(self.showPropsDockButton) + + def create_widgets(self): + self.quickSettingsLayout = QVBoxLayout() + self.quickSettingsGroupbox = widgets.GroupBox() + self.quickSettingsGroupbox.setTitle("Quick settings") + + layout = QFormLayout() + layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint) + layout.setFormAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self._add_view_preprocessed_toggle(layout) + self._add_combined_channels_toggle(layout) + self._add_autosave_toggles(layout) + self._add_autosave_interval_control(layout) + self._add_cca_integrity_checker_toggle(layout) + self._add_lost_objects_toggle(layout) + self._add_realtime_tracking_toggle(layout) + self._add_show_all_contours_toggle(layout) + self._add_font_size_control(layout) + + self.quickSettingsGroupbox.setLayout(layout) + self.quickSettingsLayout.addWidget(self.quickSettingsGroupbox) + self.quickSettingsLayout.addStretch(1) + + def font_size_setting( + self, + saved_font_size, + *, + has_px_mode: bool, + ) -> FontSizeSetting: + saved_font_size = str(saved_font_size) + if saved_font_size.find("pt") != -1: + saved_font_size = saved_font_size[:-2] + font_size = int(saved_font_size) + if has_px_mode: + return FontSizeSetting(value=font_size) + return FontSizeSetting( + value=2 * font_size, + add_px_mode_setting=True, ) - self.host.fontSizeSpinBox.sigDownClicked.connect( - self.host.changeFontSize - ) \ No newline at end of file + + def should_update_all_contours(self, *, is_data_loaded: bool) -> bool: + return is_data_loaded + + def show_all_contours_toggled(self): + if not self.should_update_all_contours(is_data_loaded=self.isDataLoaded): + return + + self.computeAllContours() + self.updateAllImages() diff --git a/cellacdc/mixins/saving.py b/cellacdc/mixins/saving.py index e66748998..f73d532d9 100644 --- a/cellacdc/mixins/saving.py +++ b/cellacdc/mixins/saving.py @@ -9,8 +9,6 @@ from typing import Literal import pandas as pd -from dataclasses import dataclass -from typing import Literal from qtpy.QtCore import QEventLoop, QMutex, QThread, QTimer, QWaitCondition from qtpy.QtGui import QFont from qtpy.QtWidgets import QCheckBox, QMessageBox @@ -26,259 +24,259 @@ _font.setPixelSize(11) -class SavingView: +class SavingMixin: """Qt-facing adapter for save and autosave workflows.""" - LEGACY_METHODS = ( - 'manageVersions', - 'turnOffAutoSaveWorker', - 'autoSaveTimerTimedOut', - 'autoSaveTimerCountFrames', - 'enqAutosave', - 'startAutoSaveEveryNframesTimer', - '_enqueueAutoSave', - 'computeVolumeRegionprop', - 'askSaveOriginalSegm', - 'askSaveLastVisitedCcaMode', - 'askSaveLastVisitedSegmMode', - 'askSaveMetrics', - 'askSelectPos', - 'askPosToSave', - 'saveMetricsCritical', - 'saveAsData', - 'saveDataPermissionError', - 'saveDataProgress', - 'saveDataCustomMetricsCritical', - 'saveDataCombinedMetricsMissingColumn', - 'saveDataAddMetricsCritical', - 'saveDataRegionPropsCritical', - 'saveDataUpdateMetricsPbar', - 'saveDataUpdatePbar', - 'quickSave', - 'checkMissingCca', - 'warnDifferentSegmChannel', - 'waitAutoSaveWorker', - 'saveData', - 'autoSaveClose', - 'setAutoSaveSegmentationEnabled', - 'setAutoSaveAnnotationsEnabled', - 'autoSaveToggled', - 'autoSaveAnnotToggled', - 'autoSaveIntervalEdit', - 'autoSaveIntervalValueChanged', - 'autoSaveIntervalSetTooltip', - 'warnErrorsCustomMetrics', - 'warnErrorsAddMetrics', - 'warnErrorsRegionProps', - 'askConcatenate', - 'updateSegmDataAutoSaveWorker', - 'saveDataFinished', - '_waitCloseAutoSaveWorker', - 'cancelSavingInitialisation', - 'askSaveOnClosing', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) + """Headless decisions for save and autosave workflows.""" - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + viewer_mode = "Viewer" + segmentation_mode = "Segmentation and Tracking" + cell_cycle_mode = "Cell cycle analysis" + + def _enqueueAutoSave(self): + if not self.statusBarLabel.text().endswith("Autosaving..."): + self.statusBarLabel.setText(f"{self.statusBarLabel.text()} | Autosaving...") + + timestamp = datetime.now().strftime(r"%H:%M:%S.%f")[:-3] + self.logger.info(f"Autosaving... - {timestamp}") - def manageVersions(self): posData = self.data[self.pos_i] - selectVersion = apps.SelectAcdcDfVersionToRestore( - posData, parent=self.host - ) - selectVersion.exec_() + worker, thread = self.autoSaveActiveWorkers[-1] + worker.enqueue(posData) - if selectVersion.cancel: + def _waitCloseAutoSaveWorker(self): + didWorkersFinished = [True] + for worker, thread in self.autoSaveActiveWorkers: + if worker.isFinished: + didWorkersFinished.append(True) + else: + didWorkersFinished.append(False) + if all(didWorkersFinished): + self.waitCloseAutoSaveWorkerLoop.stop() + + def askConcatenate(self): + if self.mainWin is None: return - undoId = uuid.uuid4() - if posData.cca_df is not None: - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + if self._isQuickSave: + return - selectedTime = selectVersion.selectedTimestamp + if "showAskConcatenate" not in self.df_settings.index: + self.df_settings.at["showAskConcatenate", "value"] = "Yes" - self.modeComboBox.setCurrentText('Viewer') - self.logger.info(f'Loading file from {selectedTime}...') + showAskConcatenate = self.df_settings.at["showAskConcatenate", "value"] == "Yes" + if not showAskConcatenate: + return - acdc_df = load.read_acdc_df_from_archive( - selectVersion.archiveFilePath, selectVersion.selectedKey + txt = html_utils.paragraph(""" + Do you want to concatenate the `acdc_output.csv` tables from + multiple Positions into one single CSV file?
+ """) + doNotShowAgainCheckbox = QCheckBox("Do not show again") + msg = widgets.myMessageBox(wrapText=False) + noButton, yesButton = msg.question( + self, + "Concatenate tables?", + txt, + buttonsTexts=("No", "Yes"), + widgets=doNotShowAgainCheckbox, ) - posData.acdc_df = acdc_df - frames = acdc_df.index.get_level_values(0) - last_visited_frame_i = frames.max() - current_frame_i = posData.frame_i - pbar = tqdm(total=last_visited_frame_i+1, ncols=100) - for frame_i in range(last_visited_frame_i+1): - posData.frame_i = frame_i - self.get_data() - if posData.cca_df is not None: - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - if posData.allData_li[frame_i]['labels'] is None: - pbar.update() - continue + showAskConcatenate = "No" if doNotShowAgainCheckbox.isChecked() else "Yes" + self.df_settings.at["showAskConcatenate", "value"] = showAskConcatenate + self.df_settings.to_csv(settings_csv_path) - if frame_i not in frames: - acdc_df_i = pd.DataFrame(columns=acdc_df.columns) - acdc_df_i.drop(self.cca_df_colnames, axis=1, errors='ignore') - acdc_df_i.index.name = 'Cell_ID' - else: - acdc_df_i = acdc_df.loc[frame_i].dropna(axis=1, how='all') + if not msg.clickedButton == yesButton: + return - posData.allData_li[frame_i]['acdc_df'] = acdc_df_i - pbar.update() - pbar.close() + txt = html_utils.paragraph(""" + To concatenate the `acdc_output.csv` tables from + multiple Positions and multiple experiments
+ launch the concatenation utility from the top menubar of the Cell-ACDC main launcher:

+ Utilities --> Concatenate --> Concatenate acdc output tables from multiple Positions and experiments.... + """) + msg = widgets.myMessageBox(wrapText=False) + msg.information(self, "How to concatenate tables", txt) - # Back to current frame - posData.frame_i = current_frame_i - self.get_data(debug=False) - self.updateAllImages() - self.logger.info('Annotations correctly recovered.') + def askPosToSave(self): + return self.askSelectPos() - def turnOffAutoSaveWorker(self): - self.autoSaveToggle.setChecked(False) + def askSaveLastVisitedCcaMode(self, isQuickSave=False): + posData = self.data[self.pos_i] + frame_i = 0 + self.save_until_frame_i = 0 + if self.isSnapshot: + return True - def autoSaveTimerTimedOut(self): - if not hasattr(self, 'data'): - # This happes when the self.autoSaveTimer times out after - # the GUI has been closed --> we simply ignore it - self.autoSaveTimer.stop() - return + for frame_i, data_dict in enumerate(posData.allData_li): + lab = data_dict["labels"] + if lab is None: + frame_i -= 1 + break - self.autoSaveTimer.stop() - self.flushDirtyPointsLayersAutosave() - self._enqueueAutoSave() + self.save_until_frame_i = frame_i + self.save_cca_until_frame_i = frame_i + self.last_tracked_i = frame_i - def autoSaveTimerCountFrames(self): - if not hasattr(self, 'data'): - # This happes when the self.autoSaveTimer times out after - # the GUI has been closed --> we simply ignore it - return + if isQuickSave: + return True - posData = self.data[self.pos_i] - autoSaveIntevalValue, autoSaveIntervalUnit = ( - self.autoSaveIntevalValueUnit - ) - isTimeToAutoSave = ( - abs(posData.frame_i - self.autoSaveTimeStartFrameIdx) - >= autoSaveIntevalValue + last_cca_frame_i = self.navigateScrollBar.maximum() - 1 + # Ask to save last visited frame or not + txt = html_utils.paragraph(f""" + You annotated the cell cycle stages up + until frame number {last_cca_frame_i + 1}.

+ Enter up to which frame number you want to save the + cell cycle annotations: + """) + lastFrameDialog = apps.QLineEditDialog( + title="Last annoated frame number to save", + defaultTxt=str(last_cca_frame_i + 1), + msg=txt, + parent=self, + allowedValues=(1, last_cca_frame_i + 1), + warnLastFrame=True, + isInteger=True, + stretchEntry=False, + lastVisitedFrame=last_cca_frame_i + 1, ) - if not isTimeToAutoSave: - return - - self.autoSaveTimeStartFrameIdx = posData.frame_i - self.flushDirtyPointsLayersAutosave() - self._enqueueAutoSave() + lastFrameDialog.exec_() + if lastFrameDialog.cancel: + return False - def enqAutosave(self): - mode = str(self.modeComboBox.currentText()) - if self.should_clear_autosave_status(mode=mode): - if self.statusBarLabel.text().endswith('Autosaving...'): - self.statusBarLabel.setText( - self.statusBarLabel.text().replace(' | Autosaving...', '') - ) - return + last_save_cca_frame_i = lastFrameDialog.enteredValue - 1 - if not self.autoSaveActiveWorkers: - self.gui_createAutoSaveWorker() + if last_save_cca_frame_i < last_cca_frame_i: + self.resetCcaFuture(last_cca_frame_i) - if not self.should_enqueue_autosave( - mode=mode, - has_active_workers=bool(self.autoSaveActiveWorkers), - ): - return + self.save_cca_until_frame_i = last_save_cca_frame_i - if self.autoSaveTimer.isActive(): - return + return True - self._enqueueAutoSave() - autoSaveIntevalValue, autoSaveIntervalUnit = ( - self.autoSaveIntevalValueUnit - ) - schedule = self.autosave_schedule( - autoSaveIntevalValue, autoSaveIntervalUnit - ) - if schedule is None: - return + def askSaveLastVisitedSegmMode(self, isQuickSave=False): + posData = self.data[self.pos_i] + current_frame_i = posData.frame_i + frame_i = 0 + last_tracked_i = 0 + self.save_until_frame_i = 0 + self.save_cca_until_frame_i = 0 + if self.isSnapshot: + return True - try: - self.autoSaveTimer.timeout.disconnect() - except Exception as err: - pass + for frame_i, data_dict in enumerate(posData.allData_li): + lab = data_dict["labels"] + if lab is None: + frame_i -= 1 + break - if not schedule.use_frame_timer: - self.autoSaveTimer.timeout.connect(self.autoSaveTimerTimedOut) - self.autoSaveTimer.start(schedule.interval_ms) - else: - self.startAutoSaveEveryNframesTimer() + if isQuickSave: + self.save_until_frame_i = frame_i + self.save_cca_until_frame_i = frame_i + self.last_tracked_i = frame_i + return True - def startAutoSaveEveryNframesTimer(self): - posData = self.data[self.pos_i] - self.autoSaveTimeStartFrameIdx = posData.frame_i - self.autoSaveTimer.timeout.connect( - self.autoSaveTimerCountFrames + # Ask to save last visited frame or not + txt = html_utils.paragraph(f""" + You visualised and corrected segmentation and tracking data up + until frame number {frame_i + 1}.

+ Enter up to which frame number you want to save data: + """) + lastFrameDialog = apps.QLineEditDialog( + title="Last frame number to save", + defaultTxt=str(frame_i + 1), + msg=txt, + parent=self, + allowedValues=(1, posData.SizeT), + warnLastFrame=True, + isInteger=True, + stretchEntry=False, + lastVisitedFrame=frame_i + 1, ) - self.autoSaveTimer.start(500) + lastFrameDialog.exec_() + if lastFrameDialog.cancel: + return False - def _enqueueAutoSave(self): - if not self.statusBarLabel.text().endswith('Autosaving...'): - self.statusBarLabel.setText( - f'{self.statusBarLabel.text()} | Autosaving...' + self.save_until_frame_i = lastFrameDialog.enteredValue - 1 + self.save_cca_until_frame_i = self.save_until_frame_i + if self.save_until_frame_i > frame_i: + self.logger.info( + f"Storing frames {frame_i + 1}-{self.save_until_frame_i + 1}..." ) + current_frame_i = posData.frame_i + # User is requesting to save past the last visited frame --> + # store data as if they were visited + for i in range(frame_i + 1, self.save_until_frame_i + 1): + posData.frame_i = i + self.get_data() + self.store_data(autosave=False) - timestamp = datetime.now().strftime(r'%H:%M:%S.%f')[:-3] - self.logger.info(f'Autosaving... - {timestamp}') + # Go back to current frame + posData.frame_i = current_frame_i + self.get_data() + last_tracked_i = self.save_until_frame_i - posData = self.data[self.pos_i] - worker, thread = self.autoSaveActiveWorkers[-1] - worker.enqueue(posData) + self.last_tracked_i = last_tracked_i + return True - def computeVolumeRegionprop(self): - if 'cell_vol_vox' not in self._measurements_kernel.sizeMetricsToSave: - return + def askSaveMetrics(self): + txt = html_utils.paragraph( + """ + Do you also want to save the measurements + (e.g., cell volume, mean, amount etc.)?

+ + You can find more information by clicking on the + "Set measurements" button below
+ where you will be able to select which measurements + you want to save.

+ If you already set the measurements and you want to save them click "Yes".

+ + NOTE: Saving metrics might be slow, + we recommend doing it only when you need it.
+ """ + ) + msg = widgets.myMessageBox(parent=self, resizeButtons=False, wrapText=False) + setMeasurementsButton = widgets.setPushButton("Set measurements...") + _, yesButton, noButton, _ = msg.question( + self, + "Save measurements?", + txt, + buttonsTexts=("Cancel", "Yes", "No", setMeasurementsButton), + showDialog=False, + ) + setMeasurementsButton.disconnect() + setMeasurementsButton.clicked.connect( + partial( + self.showSetMeasurements, + qparent=msg, + ) + ) + msg.exec_() + save_metrics = msg.clickedButton == yesButton + return save_metrics, msg.cancel - # We compute the cell volume in the main thread because calling - # skimage.transform.rotate in a separate thread causes crashes - # with segmentation fault on macOS. I don't know why yet. - self.logger.info('Computing cell volume...') - end_i = self.save_until_frame_i - pos_iter = tqdm(self.data, ncols=100) - for p, posData in enumerate(pos_iter): - if self.posToSave is not None: - if posData.pos_foldername not in self.posToSave: - continue + @disableWindow + def askSaveOnClosing(self, event): + if not self.saveAction.isEnabled(): + return True + if self.titleLabel.text == "Saved!": + return True + if not self.isDataLoaded: + return True - PhysicalSizeY = posData.PhysicalSizeY - PhysicalSizeX = posData.PhysicalSizeX - frame_iter = tqdm( - posData.allData_li[:end_i+1], ncols=100, position=1, leave=False - ) - for frame_i, data_dict in enumerate(frame_iter): - lab = data_dict['labels'] - if lab is None: - break - rp = data_dict['regionprops'] - obj_iter = tqdm(rp, ncols=100, position=2, leave=False) - for i, obj in enumerate(obj_iter): - vol_vox, vol_fl = ( - self.measurements.rotational_volume( - obj, PhysicalSizeY, PhysicalSizeX - ) - ) - obj.vol_vox = vol_vox - obj.vol_fl = vol_fl - posData.allData_li[frame_i]['regionprops'] = rp + msg = widgets.myMessageBox() + txt = html_utils.paragraph("Do you want to save before closing?") + _, noButton, yesButton = msg.question( + self, "Save?", txt, buttonsTexts=("Cancel", "No", "Yes") + ) + if msg.cancel: + event.ignore() + return False + + if msg.clickedButton == yesButton: + self.closeGUI = True + QTimer.singleShot(100, self.saveAction.trigger) + event.ignore() + return False + return True def askSaveOriginalSegm(self, isQuickSave=False): if isQuickSave: @@ -288,352 +286,394 @@ def askSaveOriginalSegm(self, isQuickSave=False): if not posData.whitelist: return "", True, True - help_txt = html_utils.paragraph(f""" - - """Headless decisions for save and autosave workflows.""" - - viewer_mode = 'Viewer' - segmentation_mode = 'Segmentation and Tracking' - cell_cycle_mode = 'Cell cycle analysis' + help_txt = html_utils.paragraph(""" + You have whitelisted IDs in the current position.
+ Do you want to save the not whitelisted segmentation data
+ This will allow you to revisit the original segmentation.
+ """) - def should_clear_autosave_status(self, *, mode: str) -> bool: - return mode == self.viewer_mode + txt = html_utils.paragraph(""" + You have whitelisted IDs in the current position.
+ Do you want to save the not whitelisted segmentation data?
+ """) - def should_enqueue_autosave(self, *, mode: str, has_active_workers: bool): - return mode != self.viewer_mode and has_active_workers + found_files = load.get_segm_files(posData.images_path) + existingEndnames = load.get_endnames(posData.basename, found_files) - def autosave_schedule( - self, - value: float, - unit: Literal['minutes', 'frames'], - ) -> AutosaveSchedule | None: - if value == 0: - return None - if unit == 'frames': - return AutosaveSchedule(use_frame_timer=True) - return AutosaveSchedule( - use_frame_timer=False, - interval_ms=round(value * 60 * 1000), + segmFilename = os.path.basename(posData.segm_npz_path) + segmFilename = f"{segmFilename[:-4]}_not_whitelisted" + win = apps.filenameDialog( + basename=posData.basename, + hintText=txt, + defaultEntry=segmFilename, + existingNames=existingEndnames, + helpText=help_txt, + allowEmpty=False, + parent=self, + title="Save not whitelisted segmentation data", + addDoNotSaveButton=True, + ) + win.exec_() + if win.cancel: + return "", False, True + if win.doNotSave: + return "", True, True + return win.entryText, True, False + + def askSelectPos(self, action="to save"): + last_pos = 1 + for p, posData in enumerate(self.data): + acdc_df = posData.allData_li[0]["acdc_df"] + if acdc_df is None: + last_pos = p + break + else: + last_pos = len(self.data) + + items = [posData.pos_foldername for posData in self.data] + selectPosWin = widgets.QDialogListbox( + f"Select Positions {action}", + f"Select Positions {action}:\n", + items, + multiSelection=True, + parent=self, + preSelectedItems=items[:last_pos], ) + selectPosWin.exec_() + if selectPosWin.cancel: + return + + return selectPosWin.selectedItemsText + + def autoSaveAnnotToggled(self, checked): + if not self.autoSaveActiveWorkers: + self.gui_createAutoSaveWorker() + + if not self.autoSaveActiveWorkers: + return + + worker, thread = self.autoSaveActiveWorkers[-1] + + mode = self.modeComboBox.currentText() + if mode != "Viewer": + # No reason to save in viewer mode + checked = False + + worker.isAutoSaveAnnotON = checked + + def autoSaveClose(self): + for worker, thread in self.autoSaveActiveWorkers: + worker._stop() + + def autoSaveIntervalEdit(self): + self.autoSaveIntervalDialog.show() + self.autoSaveIntervalDialog.raise_() + self.autoSaveIntervalDialog.activateWindow() + + def autoSaveIntervalSetTooltip(self): + value, unit = self.autoSaveIntevalValueUnit + autoSaveIntervalEditTooltip = ( + "Change autosave interval to every N frames or minutes\n\n" + f"Current autosave interval: {value} {unit}" + ) + self.autoSaveIntervalLabel.setToolTip(autoSaveIntervalEditTooltip) + self.autoSaveIntervalEditButton.setToolTip(autoSaveIntervalEditTooltip) + + def autoSaveIntervalValueChanged( + self, value: float, unit: Literal["minutes", "frames"] + ): + self.autoSaveIntevalValueUnit = (value, unit) + self.autoSaveTimer.stop() + + self.df_settings.at["autoSaveIntevalValue", "value"] = str(value) + self.df_settings.at["autoSaveIntervalUnit", "value"] = unit + self.df_settings.to_csv(settings_csv_path) + + self.logger.info(f"Autosave interval changed to: {value} {unit}") + self.autoSaveIntervalSetTooltip() + + if unit == "frames": + self.startAutoSaveEveryNframesTimer() + + def autoSaveTimerCountFrames(self): + if not hasattr(self, "data"): + # This happes when the self.autoSaveTimer times out after + # the GUI has been closed --> we simply ignore it + return + + posData = self.data[self.pos_i] + autoSaveIntevalValue, autoSaveIntervalUnit = self.autoSaveIntevalValueUnit + isTimeToAutoSave = ( + abs(posData.frame_i - self.autoSaveTimeStartFrameIdx) + >= autoSaveIntevalValue + ) + if not isTimeToAutoSave: + return + + self.autoSaveTimeStartFrameIdx = posData.frame_i + self.flushDirtyPointsLayersAutosave() + self._enqueueAutoSave() + + def autoSaveTimerTimedOut(self): + if not hasattr(self, "data"): + # This happes when the self.autoSaveTimer times out after + # the GUI has been closed --> we simply ignore it + self.autoSaveTimer.stop() + return + + self.autoSaveTimer.stop() + self.flushDirtyPointsLayersAutosave() + self._enqueueAutoSave() + + def autoSaveToggled(self, checked): + if not self.autoSaveActiveWorkers: + self.gui_createAutoSaveWorker() + + if not self.autoSaveActiveWorkers: + return + + worker, thread = self.autoSaveActiveWorkers[-1] + + mode = self.modeComboBox.currentText() + if mode != "Segmentation and Tracking": + # Autosaving segmentation makes sense only in + # "Segmentation and Tracking" mode + checked = False + + worker.isAutoSaveON = checked + + def autosave_annotations_enabled(self, *, mode: str, checked: bool) -> bool: + if mode != self.viewer_mode: + return False + return checked def autosave_interval_change( self, value: float, - unit: Literal['minutes', 'frames'], + unit: Literal["minutes", "frames"], ) -> AutosaveIntervalChange: return AutosaveIntervalChange( value=value, unit=unit, settings_updates={ - 'autoSaveIntevalValue': str(value), - 'autoSaveIntervalUnit': unit, + "autoSaveIntevalValue": str(value), + "autoSaveIntervalUnit": unit, }, - log_message=f'Autosave interval changed to: {value} {unit}', + log_message=f"Autosave interval changed to: {value} {unit}", tooltip=( - 'Change autosave interval to every N frames or minutes\n\n' - f'Current autosave interval: {value} {unit}' + "Change autosave interval to every N frames or minutes\n\n" + f"Current autosave interval: {value} {unit}" ), - start_frame_timer=unit == 'frames', + start_frame_timer=unit == "frames", ) - def concatenate_prompt_plan( + def autosave_schedule( self, - *, - has_main_window: bool, - is_quick_save: bool, - setting_exists: bool, - show_setting_value: str | None, - ) -> ConcatenatePromptPlan: - if not has_main_window or is_quick_save: - return ConcatenatePromptPlan( - should_prompt=False, - ensure_setting=False, - ) - - should_prompt = show_setting_value != 'No' - return ConcatenatePromptPlan( - should_prompt=should_prompt, - ensure_setting=not setting_exists, + value: float, + unit: Literal["minutes", "frames"], + ) -> AutosaveSchedule | None: + if value == 0: + return None + if unit == "frames": + return AutosaveSchedule(use_frame_timer=True) + return AutosaveSchedule( + use_frame_timer=False, + interval_ms=round(value * 60 * 1000), ) - def concatenate_prompt_setting(self, *, do_not_show_again: bool) -> str: - if do_not_show_again: - return 'No' - return 'Yes' - def autosave_segmentation_enabled(self, *, mode: str, checked: bool) -> bool: if mode != self.segmentation_mode: return False return checked - def autosave_annotations_enabled(self, *, mode: str, checked: bool) -> bool: - if mode != self.viewer_mode: - return False - return checked - - def save_as_basename(self, basename: str) -> str: - if basename.endswith('_'): - return f'{basename}segm' - return f'{basename}_segm' - - def quick_save_positions(self, position_foldername: str) -> set[str]: - return {position_foldername} - - def should_ask_positions( - self, - *, - is_snapshot: bool, - is_quick_save: bool, - position_count: int, - ) -> bool: - return is_snapshot and not is_quick_save and position_count > 1 - - def should_compute_volume_metrics( - self, - *, - save_metrics: bool, - mode: str, - ) -> bool: - return save_metrics or mode == self.cell_cycle_mode - - def save_finished_title( - self, - *, - aborted: bool, - worker_aborted: bool, - is_quick_save: bool, - ) -> tuple[str, str | None]: - if aborted or worker_aborted: - return 'Saving process cancelled.', 'r' - if is_quick_save: - return 'Saved segmentation file and annotations', None - return 'Saved!', None + def cancelSavingInitialisation(self): + self.titleLabel.setText("Saving data process cancelled.", color=self.titleColor) + self.closeGUI = False - You have whitelisted IDs in the current position.
- Do you want to save the not whitelisted segmentation data
- This will allow you to revisit the original segmentation.
- """) + def checkMissingCca(self): + proceed = True + ignore = False + doNotShowAgain = False + if not self.doNotShowAgainMissingCca: + return proceed, ignore, doNotShowAgain - txt = html_utils.paragraph(f""" - You have whitelisted IDs in the current position.
- Do you want to save the not whitelisted segmentation data?
- """) + missing_cca_items = [] + for posData in self.data: + for frame_i, data_dict in enumerate(posData.allData_li): + acdc_df = data_dict["acdc_df"] + if acdc_df is None: + continue - found_files = self.workspace.segmentation_files( - posData.images_path - ) - existingEndnames = self.workspace.endnames( - posData.basename, found_files - ) + if "cell_cycle_stage" not in acdc_df.columns: + continue - segmFilename = os.path.basename(posData.segm_npz_path) - segmFilename = f"{segmFilename[:-4]}_not_whitelisted" - win = apps.filenameDialog( - basename=posData.basename, - hintText=txt, - defaultEntry=segmFilename, - existingNames=existingEndnames, - helpText=help_txt, - allowEmpty=False, - parent=self.host, - title='Save not whitelisted segmentation data', - addDoNotSaveButton=True - ) - win.exec_() - if win.cancel: - return "", False, True - if win.doNotSave: - return "", True, True - return win.entryText, True, False + cca_df = acdc_df[cca_df_colnames] + if cca_df.isnull().values.any(): + i = frame_i if not self.isSnapshot else None + missing_cca_items.append((cca_df, posData, i)) - def askSaveLastVisitedCcaMode(self, isQuickSave=False): - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - self.save_until_frame_i = 0 - if self.isSnapshot: - return True + if not missing_cca_items: + return proceed, ignore, doNotShowAgain - frame_i = self.tracking.last_tracked_frame_index( - (data_dict['labels'] for data_dict in posData.allData_li), - first_frame_fallback=-1, + proceed = False + ignore, doNotShowAgain = _warnings.warnMissingCca( + missing_cca_items, qparent=self ) - self.save_until_frame_i = frame_i - self.save_cca_until_frame_i = frame_i - self.last_tracked_i = frame_i - - if isQuickSave: - return True - - last_cca_frame_i = self.navigateScrollBar.maximum()-1 - # Ask to save last visited frame or not - txt = html_utils.paragraph(f""" - You annotated the cell cycle stages up - until frame number {last_cca_frame_i+1}.

- Enter up to which frame number you want to save the - cell cycle annotations: - """) - lastFrameDialog = apps.QLineEditDialog( - title='Last annoated frame number to save', - defaultTxt=str(last_cca_frame_i+1), - msg=txt, parent=self.host, allowedValues=(1, last_cca_frame_i+1), - warnLastFrame=True, isInteger=True, stretchEntry=False, - lastVisitedFrame=last_cca_frame_i+1, - ) - lastFrameDialog.exec_() - if lastFrameDialog.cancel: - return False + if doNotShowAgain: + self.df_settings.at["doNotShowAgainMissingCca", "value"] = "Yes" + self.df_settings.to_csv(self.settings_csv_path) - last_save_cca_frame_i = lastFrameDialog.enteredValue - 1 + return proceed, ignore, doNotShowAgain - if last_save_cca_frame_i < last_cca_frame_i: - self.resetCcaFuture(last_cca_frame_i) + def computeVolumeRegionprop(self): + if "cell_vol_vox" not in self._measurements_kernel.sizeMetricsToSave: + return - self.save_cca_until_frame_i = last_save_cca_frame_i + # We compute the cell volume in the main thread because calling + # skimage.transform.rotate in a separate thread causes crashes + # with segmentation fault on macOS. I don't know why yet. + self.logger.info("Computing cell volume...") + end_i = self.save_until_frame_i + pos_iter = tqdm(self.data, ncols=100) + for p, posData in enumerate(pos_iter): + if self.posToSave is not None: + if posData.pos_foldername not in self.posToSave: + continue - return True + PhysicalSizeY = posData.PhysicalSizeY + PhysicalSizeX = posData.PhysicalSizeX + frame_iter = tqdm( + posData.allData_li[: end_i + 1], ncols=100, position=1, leave=False + ) + for frame_i, data_dict in enumerate(frame_iter): + lab = data_dict["labels"] + if lab is None: + break + rp = data_dict["regionprops"] + obj_iter = tqdm(rp, ncols=100, position=2, leave=False) + for i, obj in enumerate(obj_iter): + vol_vox, vol_fl = _calc_rot_vol(obj, PhysicalSizeY, PhysicalSizeX) + obj.vol_vox = vol_vox + obj.vol_fl = vol_fl + posData.allData_li[frame_i]["regionprops"] = rp - def askSaveLastVisitedSegmMode(self, isQuickSave=False): - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - self.save_until_frame_i = 0 - self.save_cca_until_frame_i = 0 - if self.isSnapshot: - return True + def concatenate_prompt_plan( + self, + *, + has_main_window: bool, + is_quick_save: bool, + setting_exists: bool, + show_setting_value: str | None, + ) -> ConcatenatePromptPlan: + if not has_main_window or is_quick_save: + return ConcatenatePromptPlan( + should_prompt=False, + ensure_setting=False, + ) - frame_i = self.tracking.last_tracked_frame_index( - (data_dict['labels'] for data_dict in posData.allData_li), - first_frame_fallback=-1, + should_prompt = show_setting_value != "No" + return ConcatenatePromptPlan( + should_prompt=should_prompt, + ensure_setting=not setting_exists, ) - if isQuickSave: - self.save_until_frame_i = frame_i - self.save_cca_until_frame_i = frame_i - self.last_tracked_i = frame_i - return True - - # Ask to save last visited frame or not - txt = html_utils.paragraph(f""" - You visualised and corrected segmentation and tracking data up - until frame number {frame_i+1}.

- Enter up to which frame number you want to save data: - """) - lastFrameDialog = apps.QLineEditDialog( - title='Last frame number to save', defaultTxt=str(frame_i+1), - msg=txt, parent=self.host, allowedValues=(1, posData.SizeT), - warnLastFrame=True, isInteger=True, stretchEntry=False, - lastVisitedFrame=frame_i+1, - ) - lastFrameDialog.exec_() - if lastFrameDialog.cancel: - return False + def concatenate_prompt_setting(self, *, do_not_show_again: bool) -> str: + if do_not_show_again: + return "No" + return "Yes" - self.save_until_frame_i = lastFrameDialog.enteredValue - 1 - self.save_cca_until_frame_i = self.save_until_frame_i - if self.save_until_frame_i > frame_i: - self.logger.info( - f'Storing frames {frame_i+1}-{self.save_until_frame_i+1}...' - ) - current_frame_i = posData.frame_i - # User is requesting to save past the last visited frame --> - # store data as if they were visited - for i in range(frame_i+1, self.save_until_frame_i+1): - posData.frame_i = i - self.get_data() - self.store_data(autosave=False) + def enqAutosave(self): + mode = str(self.modeComboBox.currentText()) + if mode == "Viewer": + if self.statusBarLabel.text().endswith("Autosaving..."): + self.statusBarLabel.setText( + self.statusBarLabel.text().replace(" | Autosaving...", "") + ) + return - # Go back to current frame - posData.frame_i = current_frame_i - self.get_data() - last_tracked_i = self.save_until_frame_i + if not self.autoSaveActiveWorkers: + self.gui_createAutoSaveWorker() - self.last_tracked_i = last_tracked_i - return True + if not self.autoSaveActiveWorkers: + return - def askSaveMetrics(self): - txt = html_utils.paragraph( - """ - Do you also want to save the measurements - (e.g., cell volume, mean, amount etc.)?

+ if self.autoSaveTimer.isActive(): + return - You can find more information by clicking on the - "Set measurements" button below
- where you will be able to select which measurements - you want to save.

- If you already set the measurements and you want to save them click "Yes".

+ self._enqueueAutoSave() + autoSaveIntevalValue, autoSaveIntervalUnit = self.autoSaveIntevalValueUnit + if autoSaveIntevalValue == 0: + return - NOTE: Saving metrics might be slow, - we recommend doing it only when you need it.
- """) - msg = widgets.myMessageBox( - parent=self.host, resizeButtons=False, wrapText=False - ) - setMeasurementsButton = widgets.setPushButton('Set measurements...') - _, yesButton, noButton, _ = msg.question( - self.host, 'Save measurements?', txt, - buttonsTexts=('Cancel', 'Yes', 'No', setMeasurementsButton), - showDialog=False - ) - setMeasurementsButton.disconnect() - setMeasurementsButton.clicked.connect( - partial( - self.measurements_view.show_set_measurements, - qparent=msg, - ) - ) - msg.exec_() - save_metrics = msg.clickedButton == yesButton - return save_metrics, msg.cancel + try: + self.autoSaveTimer.timeout.disconnect() + except Exception: + pass - def askSelectPos(self, action='to save'): - last_pos = 1 - for p, posData in enumerate(self.data): - acdc_df = posData.allData_li[0]['acdc_df'] - if acdc_df is None: - last_pos = p - break + if autoSaveIntervalUnit == "minutes": + autosave_interval_ms = round(autoSaveIntevalValue * 60 * 1000) + self.autoSaveTimer.timeout.connect(self.autoSaveTimerTimedOut) + self.autoSaveTimer.start(autosave_interval_ms) else: - last_pos = len(self.data) + self.startAutoSaveEveryNframesTimer() + + def manageVersions(self): + posData = self.data[self.pos_i] + selectVersion = apps.SelectAcdcDfVersionToRestore(posData, parent=self) + selectVersion.exec_() - items = [posData.pos_foldername for posData in self.data] - selectPosWin = widgets.QDialogListbox( - f'Select Positions {action}', f'Select Positions {action}:\n', - items, multiSelection=True, parent=self.host, - preSelectedItems=items[:last_pos] - ) - selectPosWin.exec_() - if selectPosWin.cancel: + if selectVersion.cancel: return - return selectPosWin.selectedItemsText + undoId = uuid.uuid4() + if posData.cca_df is not None: + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - def askPosToSave(self): - return self.askSelectPos() + selectedTime = selectVersion.selectedTimestamp - def saveMetricsCritical(self, traceback_format): - print('\n====================================') - self.logger.exception(traceback_format) - print('====================================\n') - self.logger.info('Warning: calculating metrics failed see above...') - print('------------------------------') + self.modeComboBox.setCurrentText("Viewer") + self.logger.info(f"Loading file from {selectedTime}...") - msg = widgets.myMessageBox(wrapText=False) - err_msg = html_utils.paragraph(f""" - Error while saving metrics.

- More details below or in the terminal/console.

- Note that the error details from this session are also saved - in the file
- {self.log_path}

- Please send the log file when reporting a bug, thanks! - Please restart Cell-ACDC, we apologise for any inconvenience.

+ acdc_df = load.read_acdc_df_from_archive( + selectVersion.archiveFilePath, selectVersion.selectedKey + ) + posData.acdc_df = acdc_df + frames = acdc_df.index.get_level_values(0) + last_visited_frame_i = frames.max() + current_frame_i = posData.frame_i + pbar = tqdm(total=last_visited_frame_i + 1, ncols=100) + for frame_i in range(last_visited_frame_i + 1): + posData.frame_i = frame_i + self.get_data() + if posData.cca_df is not None: + self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) + if posData.allData_li[frame_i]["labels"] is None: + pbar.update() + continue - """) - msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...') - msg.setDetailedText(traceback_format, visible=True) - msg.critical(self.host, 'Critical error while saving metrics', err_msg) + if frame_i not in frames: + acdc_df_i = pd.DataFrame(columns=acdc_df.columns) + acdc_df_i.drop(self.cca_df_colnames, axis=1, errors="ignore") + acdc_df_i.index.name = "Cell_ID" + else: + acdc_df_i = acdc_df.loc[frame_i].dropna(axis=1, how="all") - self.is_error_state = True - self.waitCond.wakeAll() + posData.allData_li[frame_i]["acdc_df"] = acdc_df_i + pbar.update() + pbar.close() + + # Back to current frame + posData.frame_i = current_frame_i + self.get_data(debug=False) + self.updateAllImages() + self.logger.info("Annotations correctly recovered.") + + def quickSave(self): + self.saveData(isQuickSave=True) + + def quick_save_positions(self, position_foldername: str) -> set[str]: + return {position_foldername} def saveAsData(self, checked=True): try: @@ -643,22 +683,20 @@ def saveAsData(self, checked=True): existingFilenames = set() for _posData in self.data: - segm_files = self.workspace.segmentation_files( - _posData.images_path - ) - _existingEndnames = self.workspace.endnames( - _posData.basename, segm_files + segm_files = load.get_segm_files(_posData.images_path) + _existingEndnames = load.get_endnames(_posData.basename, segm_files) + existingFilenames.update( + [f"{_posData.basename}{endname}.npz" for endname in _existingEndnames] ) - existingFilenames.update([ - f'{_posData.basename}{endname}.npz' - for endname in _existingEndnames - ]) posData = self.data[self.pos_i] - basename = self.save_as_basename(posData.basename) + if posData.basename.endswith("_"): + basename = f"{posData.basename}segm" + else: + basename = f"{posData.basename}_segm" win = apps.filenameDialog( basename=basename, - hintText='Insert a filename for the segmentation file:
', - existingNames=existingFilenames + hintText="Insert a filename for the segmentation file:
", + existingNames=existingFilenames, ) win.exec_() if win.cancel: @@ -667,130 +705,9 @@ def saveAsData(self, checked=True): for posData in self.data: posData.setFilePaths(new_endname=win.entryText) - self.status_hover_view.set_status_bar_label() + self.setStatusBarLabel() self.saveData() - def saveDataPermissionError(self, err_msg): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - msg = QMessageBox() - msg.critical(self.host, 'Permission denied', err_msg, msg.Ok) - self.waitCond.wakeAll() - - def saveDataProgress(self, text): - self.logger.info(text) - self.saveWin.progressLabel.setText(text) - - def saveDataCustomMetricsCritical(self, traceback_format, func_name): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - self.logger.info('') - _hl = '====================================' - self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') - self.worker.customMetricsErrors[func_name] = traceback_format - - def saveDataCombinedMetricsMissingColumn(self, error_msg, func_name): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - self.logger.info('') - warning = f'[WARNING]: {error_msg}. Metric {func_name} was skipped.' - _hl = '====================================' - self.logger.info(f'{_hl}\n{warning}\n{_hl}') - self.worker.customMetricsErrors[func_name] = warning - - def saveDataAddMetricsCritical(self, traceback_format, error_message): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - self.logger.info('') - _hl = '====================================' - self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') - self.worker.addMetricsErrors[error_message] = traceback_format - - def saveDataRegionPropsCritical(self, traceback_format, error_message): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - self.logger.info('') - _hl = '====================================' - self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') - self.worker.regionPropsErrors[error_message] = traceback_format - - def saveDataUpdateMetricsPbar(self, max, step): - if max > 0: - self.saveWin.metricsQPbar.setMaximum(max) - self.saveWin.metricsQPbar.setValue(0) - self.saveWin.metricsQPbar.setValue( - self.saveWin.metricsQPbar.value()+step - ) - - def saveDataUpdatePbar(self, step, max=-1, exec_time=0.0): - if max >= 0: - self.saveWin.QPbar.setMaximum(max) - else: - self.saveWin.QPbar.setValue(self.saveWin.QPbar.value()+step) - steps_left = self.saveWin.QPbar.maximum()-self.saveWin.QPbar.value() - seconds = round(exec_time*steps_left) - ETA = self.formatting.seconds_to_eta(seconds) - self.saveWin.ETA_label.setText(f'ETA: {ETA}') - - def quickSave(self): - self.saveData(isQuickSave=True) - - def checkMissingCca(self): - proceed = True - ignore = False - doNotShowAgain = False - if not self.doNotShowAgainMissingCca: - return proceed, ignore, doNotShowAgain - - missing_cca_items = [ - (item.cca_df, self.data[item.position_i], item.frame_i) - for item in self.cca_workflows.missing_annotation_items( - (posData.allData_li for posData in self.data), - cca_df_colnames, - is_snapshot=self.isSnapshot, - ) - ] - - if not missing_cca_items: - return proceed, ignore, doNotShowAgain - - proceed = False - ignore, doNotShowAgain =_warnings.warnMissingCca( - missing_cca_items, qparent=self.host - ) - - if doNotShowAgain: - self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'Yes' - self.df_settings.to_csv(self.settings_csv_path) - - return proceed, ignore, doNotShowAgain - - def warnDifferentSegmChannel( - self, loaded_channel, segm_channel_hyperparams, segmEndName - ): - txt = html_utils.paragraph(f""" - You loaded the segmentation file ending with _{segmEndName}.npz - which corresponds to the channel - {segm_channel_hyperparams}.

- However, in this session you loaded the channel - {loaded_channel}.

- If you proceed with saving, the segmentation file ending with - _{segmEndName}.npz will be OVERWRITTEN.

- Are you sure you want to proceed? - """) - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.warning( - self.host, 'WARNING: Potential for data loss', txt, - buttonsTexts=('Cancel', 'Yes') - ) - return msg.cancel - - def waitAutoSaveWorker(self, worker): - if worker.isFinished or worker.isPaused or len(worker.dataQ) == 0: - self.waitAutoSaveWorkerLoop.exit() - self.waitAutoSaveWorkerTimer.stop() - self.status_hover_view.set_status_bar_label(log=False) - @exception_handler def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.setDisabled(True, keepDisabled=True) @@ -804,8 +721,8 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): # Wait autosave worker to finish for worker, thread in self.autoSaveActiveWorkers: - self.logger.info('Stopping autosaving process...') - self.statusBarLabel.setText('Stopping autosaving process...') + self.logger.info("Stopping autosaving process...") + self.statusBarLabel.setText("Stopping autosaving process...") worker.stop() self.waitAutoSaveWorkerTimer = QTimer() self.waitAutoSaveWorkerTimer.timeout.connect( @@ -816,8 +733,7 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.waitAutoSaveWorkerLoop.exec_() self.titleLabel.setText( - 'Saving data... (check progress in the terminal)', - color=self.titleColor + "Saving data... (check progress in the terminal)", color=self.titleColor ) # Check channel name correspondence to warn @@ -852,11 +768,7 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): return True self.posToSave = None - if self.should_ask_positions( - is_snapshot=self.isSnapshot, - is_quick_save=isQuickSave, - position_count=len(self.data), - ): + if self.isSnapshot and not isQuickSave and len(self.data) > 1: self.posToSave = self.askPosToSave() if self.posToSave is None: self.cancelSavingInitialisation() @@ -866,15 +778,13 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): if isQuickSave: # Quick save only current pos - self.posToSave = self.quick_save_positions( - self.data[self.pos_i].pos_foldername - ) + self.posToSave = {self.data[self.pos_i].pos_foldername} if self.isSnapshot: self.store_data(mainThread=False) mode = self.modeComboBox.currentText() - if mode == 'Cell cycle analysis': + if mode == "Cell cycle analysis": proceed = self.askSaveLastVisitedCcaMode(isQuickSave=isQuickSave) if not proceed: self.cancelSavingInitialisation() @@ -889,269 +799,106 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.activateWindow() return True - append_name_og_whitelist, proceed, do_not_save_og_whitelist = self.askSaveOriginalSegm(isQuickSave=isQuickSave) + append_name_og_whitelist, proceed, do_not_save_og_whitelist = ( + self.askSaveOriginalSegm(isQuickSave=isQuickSave) + ) if not proceed: self.cancelSavingInitialisation() self.setDisabled(False, keepDisabled=False) self.activateWindow() return True - if self.should_compute_volume_metrics( - save_metrics=self.save_metrics, - mode=mode, - ): - self.computeVolumeRegionprop() - - infoTxt = html_utils.paragraph( - f'Saving {self.exp_path}...
', font_size='14px' - ) - - self.saveWin = apps.QDialogPbar( - parent=self.host, title='Saving data', infoTxt=infoTxt - ) - self.saveWin.setFont(_font) - # if not self.save_metrics: - self.saveWin.metricsQPbar.hide() - self.saveWin.progressLabel.setText('Preparing data...') - self.saveWin.show() - - # Set up separate thread for saving and show progress bar widget - self.mutex = QMutex() - self.waitCond = QWaitCondition() - self.thread = QThread() - self.worker = workers.saveDataWorker(self.host) - self.worker.mode = mode - self.worker.isQuickSave = isQuickSave - self.worker.append_name_og_whitelist = append_name_og_whitelist - self.worker.do_not_save_og_whitelist = do_not_save_og_whitelist - - self.worker.moveToThread(self.thread) - - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - # Custom signals - self.worker.finished.connect(self.saveDataFinished) - if finishedCallback is not None: - self.worker.finished.connect(finishedCallback) - self.worker.progress.connect(self.saveDataProgress) - self.worker.sigLog.connect(self.workerLog) - self.worker.progressBar.connect(self.saveDataUpdatePbar) - # self.worker.metricsPbarProgress.connect(self.saveDataUpdateMetricsPbar) - self.worker.critical.connect(self.saveDataWorkerCritical) - self.worker.customMetricsCritical.connect( - self.saveDataCustomMetricsCritical - ) - self.worker.sigCombinedMetricsMissingColumn.connect( - self.saveDataCombinedMetricsMissingColumn - ) - self.worker.addMetricsCritical.connect(self.saveDataAddMetricsCritical) - self.worker.regionPropsCritical.connect( - self.saveDataRegionPropsCritical - ) - self.worker.criticalPermissionError.connect(self.saveDataPermissionError) - self.worker.askZsliceAbsent.connect(self.zSliceAbsent) - self.worker.sigDebug.connect(self._workerDebug) - - self.thread.started.connect(self.worker.run) - - self.thread.start() - - return False - - def autoSaveClose(self): - for worker, thread in self.autoSaveActiveWorkers: - worker._stop() - - def setAutoSaveSegmentationEnabled(self, enabled): - if not self.autoSaveActiveWorkers: - return - - worker, thread = self.autoSaveActiveWorkers[-1] - - if enabled: - worker.isAutoSaveON = self.autosave_segmentation_enabled( - mode=self.modeComboBox.currentText(), - checked=self.autoSaveToggle.isChecked(), - ) - else: - worker.isAutoSaveON = False - - def setAutoSaveAnnotationsEnabled(self, enabled): - if not self.autoSaveActiveWorkers: - return - - worker, thread = self.autoSaveActiveWorkers[-1] - - if enabled: - worker.isAutoSaveAnnotON = ( - self.autosave_annotations_enabled( - mode=self.modeComboBox.currentText(), - checked=self.autoSaveToggle.isChecked(), - ) - ) - else: - worker.isAutoSaveAnnotON = False - - def autoSaveToggled(self, checked): - if not self.autoSaveActiveWorkers: - self.gui_createAutoSaveWorker() - - if not self.autoSaveActiveWorkers: - return - - worker, thread = self.autoSaveActiveWorkers[-1] - - mode = self.modeComboBox.currentText() - worker.isAutoSaveON = self.autosave_segmentation_enabled( - mode=mode, - checked=checked, - ) - - def autoSaveAnnotToggled(self, checked): - if not self.autoSaveActiveWorkers: - self.gui_createAutoSaveWorker() - - if not self.autoSaveActiveWorkers: - return - - worker, thread = self.autoSaveActiveWorkers[-1] - - mode = self.modeComboBox.currentText() - worker.isAutoSaveAnnotON = ( - self.autosave_annotations_enabled( - mode=mode, - checked=checked, - ) - ) - - def autoSaveIntervalEdit(self): - self.autoSaveIntervalDialog.show() - self.autoSaveIntervalDialog.raise_() - self.autoSaveIntervalDialog.activateWindow() - - def autoSaveIntervalValueChanged( - self, value: float, unit: Literal['minutes', 'frames'] - ): - interval_change = self.autosave_interval_change( - value, - unit, - ) - self.autoSaveIntevalValueUnit = ( - interval_change.value, - interval_change.unit, - ) - self.autoSaveTimer.stop() - - for setting, setting_value in interval_change.settings_updates.items(): - self.df_settings.at[setting, 'value'] = setting_value - self.df_settings.to_csv(settings_csv_path) + if self.save_metrics or mode == "Cell cycle analysis": + self.computeVolumeRegionprop() - self.logger.info(interval_change.log_message) - self.autoSaveIntervalSetTooltip(interval_change.tooltip) + infoTxt = html_utils.paragraph( + f"Saving {self.exp_path}...
", font_size="14px" + ) - if interval_change.start_frame_timer: - self.startAutoSaveEveryNframesTimer() + self.saveWin = apps.QDialogPbar( + parent=self, title="Saving data", infoTxt=infoTxt + ) + self.saveWin.setFont(_font) + # if not self.save_metrics: + self.saveWin.metricsQPbar.hide() + self.saveWin.progressLabel.setText("Preparing data...") + self.saveWin.show() - def autoSaveIntervalSetTooltip(self, tooltip=None): - if tooltip is None: - value, unit = self.autoSaveIntevalValueUnit - tooltip = self.autosave_interval_change( - value, - unit, - ).tooltip - self.autoSaveIntervalLabel.setToolTip(tooltip) - self.autoSaveIntervalEditButton.setToolTip(tooltip) + # Set up separate thread for saving and show progress bar widget + self.mutex = QMutex() + self.waitCond = QWaitCondition() + self.thread = QThread() + self.worker = workers.saveDataWorker(self) + self.worker.mode = mode + self.worker.isQuickSave = isQuickSave + self.worker.append_name_og_whitelist = append_name_og_whitelist + self.worker.do_not_save_og_whitelist = do_not_save_og_whitelist - def warnErrorsCustomMetrics(self): - win = apps.ComputeMetricsErrorsDialog( - self.worker.customMetricsErrors, self.logs_path, - log_type='custom_metrics', parent=self.host - ) - win.exec_() + self.worker.moveToThread(self.thread) - def warnErrorsAddMetrics(self): - win = apps.ComputeMetricsErrorsDialog( - self.worker.addMetricsErrors, self.logs_path, - log_type='standard_metrics', parent=self.host - ) - win.exec_() + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) - def warnErrorsRegionProps(self): - win = apps.ComputeMetricsErrorsDialog( - self.worker.regionPropsErrors, self.logs_path, - log_type='region_props', parent=self.host + # Custom signals + self.worker.finished.connect(self.saveDataFinished) + if finishedCallback is not None: + self.worker.finished.connect(finishedCallback) + self.worker.progress.connect(self.saveDataProgress) + self.worker.sigLog.connect(self.workerLog) + self.worker.progressBar.connect(self.saveDataUpdatePbar) + # self.worker.metricsPbarProgress.connect(self.saveDataUpdateMetricsPbar) + self.worker.critical.connect(self.saveDataWorkerCritical) + self.worker.customMetricsCritical.connect(self.saveDataCustomMetricsCritical) + self.worker.sigCombinedMetricsMissingColumn.connect( + self.saveDataCombinedMetricsMissingColumn ) - win.exec_() + self.worker.addMetricsCritical.connect(self.saveDataAddMetricsCritical) + self.worker.regionPropsCritical.connect(self.saveDataRegionPropsCritical) + self.worker.criticalPermissionError.connect(self.saveDataPermissionError) + self.worker.askZsliceAbsent.connect(self.zSliceAbsent) + self.worker.sigDebug.connect(self._workerDebug) - def askConcatenate(self): - setting_exists = 'showAskConcatenate' in self.df_settings.index - show_setting_value = ( - self.df_settings.at['showAskConcatenate', 'value'] - if setting_exists else None - ) - prompt_plan = self.concatenate_prompt_plan( - has_main_window=self.mainWin is not None, - is_quick_save=self._isQuickSave, - setting_exists=setting_exists, - show_setting_value=show_setting_value, - ) - if prompt_plan.ensure_setting: - self.df_settings.at['showAskConcatenate', 'value'] = 'Yes' + self.thread.started.connect(self.worker.run) - if not prompt_plan.should_prompt: - return + self.thread.start() - txt = html_utils.paragraph(f""" - Do you want to concatenate the `acdc_output.csv` tables from - multiple Positions into one single CSV file?
- """) - doNotShowAgainCheckbox = QCheckBox('Do not show again') - msg = widgets.myMessageBox(wrapText=False) - noButton, yesButton = msg.question( - self.host, 'Concatenate tables?', txt, - buttonsTexts=('No', 'Yes'), - widgets=doNotShowAgainCheckbox - ) - show_ask_concatenate = self.concatenate_prompt_setting( - do_not_show_again=doNotShowAgainCheckbox.isChecked() - ) - self.df_settings.at['showAskConcatenate', 'value'] = ( - show_ask_concatenate - ) - self.df_settings.to_csv(settings_csv_path) + return False - if not msg.clickedButton == yesButton: - return + def saveDataAddMetricsCritical(self, traceback_format, error_message): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + self.logger.info("") + _hl = "====================================" + self.logger.info(f"{_hl}\n{traceback_format}\n{_hl}") + self.worker.addMetricsErrors[error_message] = traceback_format - txt = html_utils.paragraph(f""" - To concatenate the `acdc_output.csv` tables from - multiple Positions and multiple experiments
- launch the concatenation utility from the top menubar of the Cell-ACDC main launcher:

- Utilities --> Concatenate --> Concatenate acdc output tables from multiple Positions and experiments.... - """) - msg = widgets.myMessageBox(wrapText=False) - msg.information(self.host, 'How to concatenate tables', txt) + def saveDataCombinedMetricsMissingColumn(self, error_msg, func_name): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + self.logger.info("") + warning = f"[WARNING]: {error_msg}. Metric {func_name} was skipped." + _hl = "====================================" + self.logger.info(f"{_hl}\n{warning}\n{_hl}") + self.worker.customMetricsErrors[func_name] = warning - def updateSegmDataAutoSaveWorker(self): - # Update savedSegmData in autosave worker - posData = self.data[self.pos_i] - for worker, thread in self.autoSaveActiveWorkers: - worker.savedSegmData = posData.segm_data.copy() + def saveDataCustomMetricsCritical(self, traceback_format, func_name): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + self.logger.info("") + _hl = "====================================" + self.logger.info(f"{_hl}\n{traceback_format}\n{_hl}") + self.worker.customMetricsErrors[func_name] = traceback_format def saveDataFinished(self): self.setDisabled(False, keepDisabled=False) self.activateWindow() - title_text, color = self.save_finished_title( - aborted=self.saveWin.aborted, - worker_aborted=self.worker.abort, - is_quick_save=self._isQuickSave, - ) - if color is None: - self.titleLabel.setText(title_text) + if self.saveWin.aborted or self.worker.abort: + self.titleLabel.setText("Saving process cancelled.", color="r") + elif self._isQuickSave: + self.titleLabel.setText("Saved segmentation file and annotations") else: - self.titleLabel.setText(title_text, color=color) + self.titleLabel.setText("Saved!") self.saveWin.workerFinished = True self.saveWin.close() @@ -1160,7 +907,7 @@ def saveDataFinished(self): self.updateSegmDataAutoSaveWorker() if self.worker.addMetricsErrors: - self.warnErrorsAddMetrics() + self.warnErrorsAddMetrics() if self.worker.regionPropsErrors: self.warnErrorsRegionProps() if self.worker.customMetricsErrors: @@ -1171,53 +918,203 @@ def saveDataFinished(self): self.askConcatenate() if self.closeGUI: - salute_string = self.formatting.salute_string() + salute_string = myutils.get_salute_string() msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Data saved!. The GUI will now close.

' - f'{salute_string}' + f"Data saved!. The GUI will now close.

{salute_string}" ) - msg.information(self.host, 'Data saved', txt) + msg.information(self, "Data saved", txt) self.close() - def _waitCloseAutoSaveWorker(self): - didWorkersFinished = [True] + def saveDataPermissionError(self, err_msg): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + msg = QMessageBox() + msg.critical(self, "Permission denied", err_msg, msg.Ok) + self.waitCond.wakeAll() + + def saveDataProgress(self, text): + self.logger.info(text) + self.saveWin.progressLabel.setText(text) + + def saveDataRegionPropsCritical(self, traceback_format, error_message): + self.setDisabled(False, keepDisabled=False) + self.activateWindow() + self.logger.info("") + _hl = "====================================" + self.logger.info(f"{_hl}\n{traceback_format}\n{_hl}") + self.worker.regionPropsErrors[error_message] = traceback_format + + def saveDataUpdateMetricsPbar(self, max, step): + if max > 0: + self.saveWin.metricsQPbar.setMaximum(max) + self.saveWin.metricsQPbar.setValue(0) + self.saveWin.metricsQPbar.setValue(self.saveWin.metricsQPbar.value() + step) + + def saveDataUpdatePbar(self, step, max=-1, exec_time=0.0): + if max >= 0: + self.saveWin.QPbar.setMaximum(max) + else: + self.saveWin.QPbar.setValue(self.saveWin.QPbar.value() + step) + steps_left = self.saveWin.QPbar.maximum() - self.saveWin.QPbar.value() + seconds = round(exec_time * steps_left) + ETA = myutils.seconds_to_ETA(seconds) + self.saveWin.ETA_label.setText(f"ETA: {ETA}") + + def saveMetricsCritical(self, traceback_format): + print("\n====================================") + self.logger.exception(traceback_format) + print("====================================\n") + self.logger.info("Warning: calculating metrics failed see above...") + print("------------------------------") + + msg = widgets.myMessageBox(wrapText=False) + err_msg = html_utils.paragraph(f""" + Error while saving metrics.

+ More details below or in the terminal/console.

+ Note that the error details from this session are also saved + in the file
+ {self.log_path}

+ Please send the log file when reporting a bug, thanks! + Please restart Cell-ACDC, we apologise for any inconvenience.

+ + """) + msg.addShowInFileManagerButton(self.logs_path, txt="Show log file...") + msg.setDetailedText(traceback_format, visible=True) + msg.critical(self, "Critical error while saving metrics", err_msg) + + self.is_error_state = True + self.waitCond.wakeAll() + + def save_as_basename(self, basename: str) -> str: + if basename.endswith("_"): + return f"{basename}segm" + return f"{basename}_segm" + + def save_finished_title( + self, + *, + aborted: bool, + worker_aborted: bool, + is_quick_save: bool, + ) -> tuple[str, str | None]: + if aborted or worker_aborted: + return "Saving process cancelled.", "r" + if is_quick_save: + return "Saved segmentation file and annotations", None + return "Saved!", None + + def setAutoSaveAnnotationsEnabled(self, enabled): + if not self.autoSaveActiveWorkers: + return + + worker, thread = self.autoSaveActiveWorkers[-1] + + if enabled: + worker.isAutoSaveAnnotON = self.autoSaveToggle.isChecked() + else: + worker.isAutoSaveAnnotON = False + + def setAutoSaveSegmentationEnabled(self, enabled): + if not self.autoSaveActiveWorkers: + return + + worker, thread = self.autoSaveActiveWorkers[-1] + + if enabled: + worker.isAutoSaveON = self.autoSaveToggle.isChecked() + else: + worker.isAutoSaveON = False + + def should_ask_positions( + self, + *, + is_snapshot: bool, + is_quick_save: bool, + position_count: int, + ) -> bool: + return is_snapshot and not is_quick_save and position_count > 1 + + def should_clear_autosave_status(self, *, mode: str) -> bool: + return mode == self.viewer_mode + + def should_compute_volume_metrics( + self, + *, + save_metrics: bool, + mode: str, + ) -> bool: + return save_metrics or mode == self.cell_cycle_mode + + def should_enqueue_autosave(self, *, mode: str, has_active_workers: bool): + return mode != self.viewer_mode and has_active_workers + + def startAutoSaveEveryNframesTimer(self): + posData = self.data[self.pos_i] + self.autoSaveTimeStartFrameIdx = posData.frame_i + self.autoSaveTimer.timeout.connect(self.autoSaveTimerCountFrames) + self.autoSaveTimer.start(500) + + def turnOffAutoSaveWorker(self): + self.autoSaveToggle.setChecked(False) + + def updateSegmDataAutoSaveWorker(self): + # Update savedSegmData in autosave worker + posData = self.data[self.pos_i] for worker, thread in self.autoSaveActiveWorkers: - if worker.isFinished: - didWorkersFinished.append(True) - else: - didWorkersFinished.append(False) - if all(didWorkersFinished): - self.waitCloseAutoSaveWorkerLoop.stop() + worker.savedSegmData = posData.segm_data.copy() - def cancelSavingInitialisation(self): - self.titleLabel.setText( - 'Saving data process cancelled.', color=self.titleColor + def waitAutoSaveWorker(self, worker): + if worker.isFinished or worker.isPaused or len(worker.dataQ) == 0: + self.waitAutoSaveWorkerLoop.exit() + self.waitAutoSaveWorkerTimer.stop() + self.setStatusBarLabel(log=False) + + def warnDifferentSegmChannel( + self, loaded_channel, segm_channel_hyperparams, segmEndName + ): + txt = html_utils.paragraph(f""" + You loaded the segmentation file ending with _{segmEndName}.npz + which corresponds to the channel + {segm_channel_hyperparams}.

+ However, in this session you loaded the channel + {loaded_channel}.

+ If you proceed with saving, the segmentation file ending with + _{segmEndName}.npz will be OVERWRITTEN.

+ Are you sure you want to proceed? + """) + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + msg.warning( + self, + "WARNING: Potential for data loss", + txt, + buttonsTexts=("Cancel", "Yes"), ) - self.closeGUI = False + return msg.cancel - @disableWindow - def askSaveOnClosing(self, event): - if not self.saveAction.isEnabled(): - return True - if self.titleLabel.text == 'Saved!': - return True - if not self.isDataLoaded: - return True + def warnErrorsAddMetrics(self): + win = apps.ComputeMetricsErrorsDialog( + self.worker.addMetricsErrors, + self.logs_path, + log_type="standard_metrics", + parent=self, + ) + win.exec_() - msg = widgets.myMessageBox() - txt = html_utils.paragraph('Do you want to save before closing?') - _, noButton, yesButton = msg.question( - self.host, 'Save?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') + def warnErrorsCustomMetrics(self): + win = apps.ComputeMetricsErrorsDialog( + self.worker.customMetricsErrors, + self.logs_path, + log_type="custom_metrics", + parent=self, ) - if msg.cancel: - event.ignore() - return False + win.exec_() - if msg.clickedButton == yesButton: - self.closeGUI = True - QTimer.singleShot(100, self.saveAction.trigger) - event.ignore() - return False - return True \ No newline at end of file + def warnErrorsRegionProps(self): + win = apps.ComputeMetricsErrorsDialog( + self.worker.regionPropsErrors, + self.logs_path, + log_type="region_props", + parent=self, + ) + win.exec_() diff --git a/cellacdc/mixins/seg_for_lost_ids.py b/cellacdc/mixins/seg_for_lost_ids.py index 83de17f37..1614a33ec 100644 --- a/cellacdc/mixins/seg_for_lost_ids.py +++ b/cellacdc/mixins/seg_for_lost_ids.py @@ -2,71 +2,192 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Any + from qtpy.QtCore import QMutex, QThread, QWaitCondition from cellacdc import apps, workers from cellacdc.plot import imshow -class SegForLostIdsView: +class SegForLostIdsMixin: """Qt-facing adapter around lost-ID segmentation commands.""" """Headless settings and launch rules for lost-ID segmentation.""" - settings_key = 'SegForLostIDsModel' - worker_model_name = 'local_seg' + settings_key = "SegForLostIDsModel" + worker_model_name = "local_seg" + + def SegForLostIDsSetSettings(self): - def previous_model_name(self, df_settings) -> str | None: try: - return str(df_settings.at[self.settings_key, 'value']) + prev_model = str(self.df_settings.at["SegForLostIDsModel", "value"]) except KeyError: - return None + prev_model = None + win = apps.QDialogSelectModel(parent=self, customFirst=prev_model) + win.exec_() + if win.cancel: + self.logger.info("Seg for lost IDs cancelled.") + return + base_model_name = win.selectedModel - def should_persist_model_choice(self, base_model_name: str | None) -> bool: - return bool(base_model_name) + if base_model_name: + self.df_settings.at["SegForLostIDsModel", "value"] = base_model_name + self.df_settings.to_csv(self.settings_csv_path) + + model_name = "local_seg" + + idx = self.modelNames.index(model_name) + acdcSegment = self.acdcSegment_li[idx] + + try: + if acdcSegment is None or base_model_name != self.local_seg_base_model_name: + self.logger.info(f"Importing {base_model_name}...") + acdcSegment = myutils.import_segment_module(base_model_name) + self.acdcSegment_li[idx] = acdcSegment + self.local_seg_base_model_name = base_model_name + except (IndexError, ImportError, KeyError) as e: + self.logger.error(f"Error importing {base_model_name}: {e}") + return + + extra_params = [ + "overlap_threshold", + "padding", + "size_perc_diff", + "distance_filler_growth", + "max_iterations", + "allow_only_tracked_cells", + ] + + extra_types = [float, float, float, float, int, bool] + + extra_defaults = [0.5, 0.8, 0.3, 1.0, 2, False] + + extra_desc = [ + "Overlap threshold with other already segemented cells over which newly segmented cells are discarded", + "Padding of the box used for new segmentation around the segmentation from the previous frame", + "Relative size difference acceptable compared to previous frames", + """Cells which are already segmented are filled with random noise sampled from background + to ensure that they don't get segmented again. + This parameter controls the additional padding around the already segmented cells.""", + """The algorithm will try and segment the maximum amount + of cells in the image by running the model several + times and filling new found cells with background noise. + How many of these iterations should be run?""", + "If no new cell IDs should be permitted (based on real time tracking)", + ] + + extra_ArgSpec = [] + for i, param in enumerate(extra_params): + param = ArgSpec( + name=param, + default=extra_defaults[i], + type=extra_types[i], + desc=extra_desc[i], + docstring="", + ) + + extra_ArgSpec.append(param) + + init_params, segment_params = myutils.getModelArgSpec(acdcSegment) + segment_params = [arg for arg in segment_params if arg[0] != "diameter"] + + extraParamsTitle = "Settings for local segmentation" + win = self.initSegmModelParams( + base_model_name, + acdcSegment, + init_params, + segment_params, + extraParams=extra_ArgSpec, + extraParamsTitle=extraParamsTitle, + initLastParams=True, + ini_filename="segmentation_for_lostIDs.ini", + ) + + if win is None: + self.logger.info("Segmentation for lost IDs cancelled.") + return + + init_kwargs_new = {} + args_new = {} + for key, val in win.init_kwargs.items(): + if key in extra_params: + args_new[key] = val + else: + init_kwargs_new[key] = val + + for key, val in win.extra_kwargs.items(): + if key in extra_params: + args_new[key] = val + + self.SegForLostIDsSettings = { + "win": win, + "init_kwargs_new": init_kwargs_new, + "args_new": args_new, + "base_model_name": base_model_name, + } + + def SegForLostIDsWorkerAskInstallGPU(self, model_name, use_gpu): + result = myutils.check_gpu_available(model_name, use_gpu, qparent=self) + self.SegForLostIDsWorker.gpu_go = result + dont_force_cpu = myutils.check_gpu_available( + model_name, use_gpu, do_not_warn=True + ) + self.SegForLostIDsWorker.dont_force_cpu = dont_force_cpu + self.SegForLostIDsWaitCond.wakeAll() + + def SegForLostIDsWorkerAskInstallModel(self, model_name): + myutils.check_install_package(model_name) + self.SegForLostIDsWaitCond.wakeAll() + + def SegForLostIDsWorkerFinished(self): + self.updateAllImages() + self.update_rp() + self.store_data(autosave=True) + self.setFrameNavigationDisabled(disable=False, why="Segmentation for lost IDs") + + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + def can_start_from_frame(self, frame_i: int) -> bool: + return frame_i > 0 def extra_arg_specs(self) -> list[ArgSpec]: extra_params = ( - 'overlap_threshold', - 'padding', - 'size_perc_diff', - 'distance_filler_growth', - 'max_iterations', - 'allow_only_tracked_cells', + "overlap_threshold", + "padding", + "size_perc_diff", + "distance_filler_growth", + "max_iterations", + "allow_only_tracked_cells", ) extra_types = (float, float, float, float, int, bool) extra_defaults = (0.5, 0.8, 0.3, 1.0, 2, False) extra_desc = ( ( - 'Overlap threshold with other already segemented cells over ' - 'which newly segmented cells are discarded' + "Overlap threshold with other already segemented cells over " + "which newly segmented cells are discarded" ), ( - 'Padding of the box used for new segmentation around the ' - 'segmentation from the previous frame' + "Padding of the box used for new segmentation around the " + "segmentation from the previous frame" ), + ("Relative size difference acceptable compared to previous frames"), ( - 'Relative size difference acceptable compared to previous ' - 'frames' + "Cells which are already segmented are filled with random " + "noise sampled from background to ensure that they do not get " + "segmented again. This parameter controls the additional " + "padding around the already segmented cells." ), ( - 'Cells which are already segmented are filled with random ' - 'noise sampled from background to ensure that they do not get ' - 'segmented again. This parameter controls the additional ' - 'padding around the already segmented cells.' - ), - ( - 'The algorithm will try and segment the maximum amount of ' - 'cells in the image by running the model several times and ' - 'filling new found cells with background noise. How many of ' - 'these iterations should be run?' - ), - ( - 'If no new cell IDs should be permitted ' - '(based on real time tracking)' + "The algorithm will try and segment the maximum amount of " + "cells in the image by running the model several times and " + "filling new found cells with background noise. How many of " + "these iterations should be run?" ), + ("If no new cell IDs should be permitted (based on real time tracking)"), ) return [ @@ -75,154 +196,143 @@ def extra_arg_specs(self) -> list[ArgSpec]: default=default, type=arg_type, desc=desc, - docstring='', + docstring="", ) for name, default, arg_type, desc in zip( extra_params, extra_defaults, extra_types, extra_desc ) ] - def split_model_kwargs( - self, - init_kwargs: dict[str, Any], - extra_kwargs: dict[str, Any], - ) -> tuple[dict[str, Any], dict[str, Any]]: - extra_param_names = {arg.name for arg in self.extra_arg_specs()} - init_kwargs_new = {} - args_new = {} - - for key, val in init_kwargs.items(): - if key in extra_param_names: - args_new[key] = val - else: - init_kwargs_new[key] = val - - for key, val in extra_kwargs.items(): - if key in extra_param_names: - args_new[key] = val + def onSegForLostInit(self): + self.logger.info("Settings for segmentation for lost IDs not set.") + self.SegForLostIDsSetSettings() + self.SegForLostIDsWaitCond.wakeAll() - return init_kwargs_new, args_new + def onSigGetData(self, waitcond, debug=False): + self.get_data(debug=debug) + waitcond.wakeAll() - def settings_from_dialog(self, win, base_model_name: str): - init_kwargs_new, args_new = self.split_model_kwargs( - win.init_kwargs, - win.extra_kwargs, - ) - return SegForLostIdsSettings( - win=win, - init_kwargs_new=init_kwargs_new, - args_new=args_new, - base_model_name=base_model_name, + def onSigStoreData( + self, + waitcond, + pos_i=None, + enforce=True, + debug=False, + mainThread=True, + autosave=True, + store_cca_df_copy=False, + ): + self.store_data( + pos_i=pos_i, + enforce=enforce, + debug=debug, + mainThread=mainThread, + autosave=autosave, + store_cca_df_copy=store_cca_df_copy, ) + waitcond.wakeAll() - def can_start_from_frame(self, frame_i: int) -> bool: - return frame_i > 0 - - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def SegForLostIDsSetSettings(self): - - prev_model = self.previous_model_name(self.df_settings) - win = apps.QDialogSelectModel(parent=self.host, customFirst=prev_model) - win.exec_() - if win.cancel: - self.logger.info('Seg for lost IDs cancelled.') - return - base_model_name = win.selectedModel - - if self.should_persist_model_choice(base_model_name): - self.df_settings.at[ - self.settings_key, 'value' - ] = base_model_name - self.df_settings.to_csv(self.settings_csv_path) - - model_name = self.worker_model_name - - idx = self.modelNames.index(model_name) - acdcSegment = self.acdcSegment_li[idx] - - try: - if ( - acdcSegment is None - or base_model_name != self.local_seg_base_model_name - ): - self.logger.info(f'Importing {base_model_name}...') - acdcSegment = ( - self.host.view_model.model_registry - .import_segmentation_module(base_model_name) - ) - self.acdcSegment_li[idx] = acdcSegment - self.local_seg_base_model_name = base_model_name - except (IndexError, ImportError, KeyError) as e: - self.logger.error(f'Error importing {base_model_name}: {e}') - return - - extra_ArgSpec = self.extra_arg_specs() + def onSigStoreDataSegForLostIDsWorker(self, autosave): + self.onSigStoreData(self.SegForLostIDsWaitCond, autosave=autosave) - init_params, segment_params = ( - self.host.view_model.model_registry.model_arg_specs(acdcSegment) + def onSigTrackManuallyAddedObjectSegForLostIDsWorker( + self, added_IDs, isNewID, wl_update, wl_track_og_curr + ): + self.trackManuallyAddedObject( + added_IDs, isNewID, wl_update=wl_update, wl_track_og_curr=wl_track_og_curr ) - segment_params = [arg for arg in segment_params if arg[0] != 'diameter'] + self.SegForLostIDsWaitCond.wakeAll() - extraParamsTitle = 'Settings for local segmentation' - win = self.initSegmModelParams( - base_model_name, acdcSegment, init_params, segment_params, - extraParams=extra_ArgSpec, extraParamsTitle=extraParamsTitle, - initLastParams=True, ini_filename='segmentation_for_lostIDs.ini', + def onSigUpdateRP( + self, + waitcond, + draw=True, + debug=False, + update_IDs=True, + wl_update=True, + wl_track_og_curr=False, + ): + self.update_rp( + draw=draw, + debug=debug, + update_IDs=update_IDs, + wl_update=wl_update, + wl_track_og_curr=wl_track_og_curr, ) + waitcond.wakeAll() - if win is None: - self.logger.info('Segmentation for lost IDs cancelled.') - return + def onSigUpdateRPSegForLostIDsWorker(self, wl_update, wl_track_og_curr): + self.onSigUpdateRP( + self.SegForLostIDsWaitCond, + wl_update=wl_update, + wl_track_og_curr=wl_track_og_curr, + ) - settings = self.settings_from_dialog(win, base_model_name) - self.SegForLostIDsSettings = { - 'win': settings.win, - 'init_kwargs_new': settings.init_kwargs_new, - 'args_new': settings.args_new, - 'base_model_name': settings.base_model_name, - } + def previous_model_name(self, df_settings) -> str | None: + try: + return str(df_settings.at[self.settings_key, "value"]) + except KeyError: + return None def segForLostIDsButtonClicked(self): - why = 'Segmentation for lost IDs' - self.setFrameNavigationDisabled(disable=True, why=why) + self.setFrameNavigationDisabled(disable=True, why="Segmentation for lost IDs") posData = self.data[self.pos_i] - if not self.can_start_from_frame(posData.frame_i): - self.logger.info( - 'Segmentation for lost IDs not available on first frame.' + if posData.frame_i == 0: + self.logger.info("Segmentation for lost IDs not available on first frame.") + self.setFrameNavigationDisabled( + disable=False, why="Segmentation for lost IDs" ) - self.setFrameNavigationDisabled(disable=False, why=why) return self.storeUndoRedoStates(False) self.progressWin = apps.QDialogWorkerProgress( - title='Segmenting for lost IDs', - parent=self.host, - pbarDesc='Segmenting for lost IDs...', + title="Segmenting for lost IDs", + parent=self, + pbarDesc="Segmenting for lost IDs...", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) self.startSegForLostIDsWorker() - def onSegForLostInit(self): - self.logger.info('Settings for segmentation for lost IDs not set.') - self.SegForLostIDsSetSettings() - self.SegForLostIDsWaitCond.wakeAll() + def settings_from_dialog(self, win, base_model_name: str): + init_kwargs_new, args_new = self.split_model_kwargs( + win.init_kwargs, + win.extra_kwargs, + ) + return SegForLostIdsSettings( + win=win, + init_kwargs_new=init_kwargs_new, + args_new=args_new, + base_model_name=base_model_name, + ) - def SegForLostIDsWorkerAskInstallModel(self, model_name): - self.host.view_model.model_registry.check_install_package(model_name) - self.SegForLostIDsWaitCond.wakeAll() + def should_persist_model_choice(self, base_model_name: str | None) -> bool: + return bool(base_model_name) + + def showImageDebug(self, img): + imshow(img) + + def split_model_kwargs( + self, + init_kwargs: dict[str, Any], + extra_kwargs: dict[str, Any], + ) -> tuple[dict[str, Any], dict[str, Any]]: + extra_param_names = {arg.name for arg in self.extra_arg_specs()} + init_kwargs_new = {} + args_new = {} + + for key, val in init_kwargs.items(): + if key in extra_param_names: + args_new[key] = val + else: + init_kwargs_new[key] = val + + for key, val in extra_kwargs.items(): + if key in extra_param_names: + args_new[key] = val + + return init_kwargs_new, args_new def startSegForLostIDsWorker(self): self.SegForLostIDsMutex = QMutex() @@ -231,7 +341,7 @@ def startSegForLostIDsWorker(self): # Initialize the worker with mutex and wait condition self.SegForLostIDsWorker = workers.SegForLostIDsWorker( - self.host, self.SegForLostIDsMutex, self.SegForLostIDsWaitCond + self, self.SegForLostIDsMutex, self.SegForLostIDsWaitCond ) # Connect the worker's signal to the main thread's slot @@ -239,9 +349,7 @@ def startSegForLostIDsWorker(self): self.SegForLostIDsWorker.sigAskInstallModel.connect( self.SegForLostIDsWorkerAskInstallModel ) - self.SegForLostIDsWorker.sigshowImageDebug.connect( - self.showImageDebug - ) + self.SegForLostIDsWorker.sigshowImageDebug.connect(self.showImageDebug) self.SegForLostIDsWorker.sigSegForLostIDsWorkerAskInstallGPU.connect( self.SegForLostIDsWorkerAskInstallGPU @@ -253,6 +361,10 @@ def startSegForLostIDsWorker(self): self.SegForLostIDsWorker.sigUpdateRP.connect( self.onSigUpdateRPSegForLostIDsWorker ) + # self.SegForLostIDsWorker.sigGetData.connect(self.onSigGetDataSegForLostIDsWorker) + # self.SegForLostIDsWorker.sigGet2Dlab.connect(self.onSigGet2DlabSegForLostIDsWorker) + # self.SegForLostIDsWorker.sigGetTrackedLostIDs.connect(self.onSigGetTrackedSegForLostIDsWorker) + # self.SegForLostIDsWorker.sigGetBrushID.connect(self.onSigGetBrushIDSegForLostIDsWorker) self.SegForLostIDsWorker.sigTrackManuallyAddedObject.connect( self.onSigTrackManuallyAddedObjectSegForLostIDsWorker ) @@ -283,81 +395,3 @@ def startSegForLostIDsWorker(self): # Start the thread and worker self._thread.started.connect(self.SegForLostIDsWorker.run) self._thread.start() - - def SegForLostIDsWorkerAskInstallGPU(self, model_name, use_gpu): - result = self.host.view_model.model_registry.check_gpu_available( - model_name, use_gpu, qparent=self.host - ) - self.SegForLostIDsWorker.gpu_go = result - dont_force_cpu = self.host.view_model.model_registry.check_gpu_available( - model_name, use_gpu, do_not_warn=True - ) - self.SegForLostIDsWorker.dont_force_cpu = dont_force_cpu - self.SegForLostIDsWaitCond.wakeAll() - - def onSigStoreDataSegForLostIDsWorker(self, autosave): - self.onSigStoreData( - self.SegForLostIDsWaitCond, autosave=autosave - ) - - def onSigUpdateRPSegForLostIDsWorker(self, wl_update, wl_track_og_curr): - self.onSigUpdateRP( - self.SegForLostIDsWaitCond, - wl_update=wl_update, - wl_track_og_curr=wl_track_og_curr, - ) - - def onSigTrackManuallyAddedObjectSegForLostIDsWorker( - self, - added_IDs, - isNewID, - wl_update, - wl_track_og_curr, - ): - self.trackManuallyAddedObject( - added_IDs, - isNewID, - wl_update=wl_update, - wl_track_og_curr=wl_track_og_curr, - ) - self.SegForLostIDsWaitCond.wakeAll() - - def onSigStoreData( - self, waitcond, pos_i=None, enforce=True, debug=False, - mainThread=True, autosave=True, store_cca_df_copy=False - ): - self.store_data( - pos_i=pos_i, - enforce=enforce, - debug=debug, - mainThread=mainThread, - autosave=autosave, - store_cca_df_copy=store_cca_df_copy, - ) - waitcond.wakeAll() - - def onSigUpdateRP(self, waitcond, draw=True, debug=False, update_IDs=True, - wl_update=True, wl_track_og_curr=False): - self.update_rp(draw=draw, debug=debug, update_IDs=update_IDs, - wl_update=wl_update, wl_track_og_curr=wl_track_og_curr) - waitcond.wakeAll() - - def onSigGetData(self, waitcond, debug=False): - self.get_data(debug=debug) - waitcond.wakeAll() - - def SegForLostIDsWorkerFinished(self): - self.updateAllImages() - self.update_rp() - self.store_data(autosave=True) - self.setFrameNavigationDisabled( - disable=False, why='Segmentation for lost IDs' - ) - - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - def showImageDebug(self, img): - imshow(img) \ No newline at end of file diff --git a/cellacdc/mixins/segmentation.py b/cellacdc/mixins/segmentation.py index 0ed0c2971..a58a519e3 100644 --- a/cellacdc/mixins/segmentation.py +++ b/cellacdc/mixins/segmentation.py @@ -4,88 +4,41 @@ import os -import numpy as np -from dataclasses import dataclass import numpy as np from qtpy.QtCore import QMutex, QThread, QTimer, QWaitCondition, Qt from qtpy.QtWidgets import QAction from cellacdc import ( - apps, exception_handler, html_utils, prompts, printl, widgets, workers, + apps, + exception_handler, + html_utils, + prompts, + printl, + widgets, + workers, ) from cellacdc.plot import imshow -class SegmentationView: +class SegmentationMixin: """Qt-facing segmentation workflow adapter.""" - LEGACY_METHODS = ( - 'computeSegm', - 'autoSegm_cb', - 'postProcessSegm', - 'postProcessSegmApplyToAllFutureFrames', - 'postProcessSegmEditingFinished', - 'postProcessSegmWorkerFinished', - 'postProcessSegmWinClosed', - 'postProcessSegmValueChanged', - 'resetCursor', - 'segmFrameCallback', - 'showInstructionsCustomModel', - 'reinitStoredSegmModels', - 'segmVideoCallback', - 'segmentToolActionTriggered', - 'initSegmModelParams', - 'repeatSegm', - 'debugSegmWorker', - 'selectZtoolZvalueChanged', - 'repeatSegmVideo', - 'segmVideoWorkerFinished', - 'segmWorkerFinished', - 'postProcessing', - 'checkIfAutoSegm', - 'init_segmInfo_df', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - - def computeSegm(self, force=False): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - should_compute = self.should_compute_segmentation( - mode=mode, - has_labels=np.any(posData.lab), - force=force, - auto_enabled=self.autoSegmAction.isChecked(), - ) - if not should_compute: - return - - self.repeatSegm(model_name=self.segmModelName) + def action_model_name(self, model_name: str) -> str: + if model_name == self.thresholding_backend_name: + return self.thresholding_action_name + return model_name def autoSegm_cb(self, checked): if checked: self.askSegmParam = True # Ask which model - models = self.segmentation_models() + models = myutils.get_list_of_models() win = widgets.QDialogListbox( - 'Select model', - 'Select model to use for segmentation: ', + "Select model", + "Select model to use for segmentation: ", models, multiSelection=False, - parent=self.host + parent=self, ) win.exec_() if win.cancel: @@ -100,19 +53,160 @@ def autoSegm_cb(self, checked): else: self.segmModelName = None + def backend_model_name(self, model_name: str) -> str: + if model_name == self.thresholding_action_name: + return self.thresholding_backend_name + return model_name + + def checkIfAutoSegm(self): + """ + If there are any frame or position with empty segmentation mask + ask whether automatic segmentation should be turned ON + """ + if self.autoSegmAction.isChecked(): + return + if self.autoSegmDoNotAskAgain: + return + + ask = False + for posData in self.data: + if posData.SizeT > 1: + for lab in posData.segm_data: + if not np.any(lab): + ask = True + txt = "frames" + break + else: + if not np.any(posData.segm_data): + ask = True + txt = "positions" + break + + if not ask: + return + + questionTxt = html_utils.paragraph( + f"Some or all loaded {txt} contain empty segmentation masks.

" + "Do you want to activate automatic segmentation* " + f"when visiting these {txt}?

" + "* Automatic segmentation can always be turned ON/OFF from the menu
" + " Edit --> Segmentation --> Enable automatic segmentation

" + f"NOTE: you can automatically segment all {txt} using the
" + " segmentation module." + ) + msg = widgets.myMessageBox(wrapText=False) + noButton, yesButton = msg.question( + self, "Automatic segmentation?", questionTxt, buttonsTexts=("No", "Yes") + ) + if msg.clickedButton == yesButton: + self.autoSegmAction.setChecked(True) + else: + self.autoSegmDoNotAskAgain = True + self.autoSegmAction.setChecked(False) + + def computeSegm(self, force=False): + posData = self.data[self.pos_i] + mode = str(self.modeComboBox.currentText()) + if mode == "Viewer" or mode == "Cell cycle analysis": + return + + if np.any(posData.lab) and not force: + # Do not compute segm if there is already a mask + return + + if not self.autoSegmAction.isChecked(): + return + + self.repeatSegm(model_name=self.segmModelName) + + def debugSegmWorker(self, to_debug): + img, _lab, lab = to_debug + printl(img.shape, _lab.shape, lab.shape) + imshow(img, _lab, lab) + self.segmWorkerWaitCond.wakeAll() + + def empty_segmentation_prompt(self, position_data) -> EmptySegmentationPrompt: + for pos_data in position_data: + if pos_data.SizeT > 1: + for lab in pos_data.segm_data: + if not np.any(lab): + return EmptySegmentationPrompt(True, "frames") + elif not np.any(pos_data.segm_data): + return EmptySegmentationPrompt(True, "positions") + return EmptySegmentationPrompt(False) + + def initSegmModelParams( + self, + model_name, + acdcSegment, + init_params, + segment_params, + is_label_roi=False, + initLastParams=False, + extraParams=None, + extraParamsTitle=None, + ini_filename=None, + ): + posData = self.data[self.pos_i] + try: + url = acdcSegment.url_help() + except AttributeError: + url = None + + text_if_cancelled = "Segmentation process cancelled." + out = prompts.init_segm_model_params( + posData, + model_name, + init_params, + segment_params, + help_url=url, + qparent=self, + init_last_params=initLastParams, + check_sam_embeddings=not is_label_roi, + is_gui_caller=True, + extraParams=extraParams, + extraParamsTitle=extraParamsTitle, + ini_filename=ini_filename, + ) + if out.get("load_sam_embeddings", False): + self.logger.info("Loading Segment Anything image embeddings...") + for _posData in self.data: + _posData.loadSamEmbeddings(logger_func=None) + text_if_cancelled = "SAM embeddings loaded." + + win = out.get("win") + if win is None: + self.logger.info(text_if_cancelled) + self.titleLabel.setText(text_if_cancelled) + return + + if win.cancel: + self.logger.info(text_if_cancelled) + self.titleLabel.setText(text_if_cancelled) + return + + if model_name != "thresholding": + self.model_kwargs = win.model_kwargs + + return win + + def init_segmInfo_df(self): + for posData in self.data: + if posData is None: + # posData is None when computing measurements with the utility + # and with timelapse data + continue + posData.init_segmInfo_df() + def postProcessSegm(self, checked): if self.isSegm3D: - SizeZ = max([posData.SizeZ for posData in self.data]) + max([posData.SizeZ for posData in self.data]) else: - SizeZ = None + pass if checked: posData = self.data[self.pos_i] - self.postProcessSegmWin = apps.PostProcessSegmDialog( - posData, mainWin=self.host - ) - self.postProcessSegmWin.sigClosed.connect( - self.postProcessSegmWinClosed - ) + self.postProcessSegmWin = apps.PostProcessSegmDialog(posData, mainWin=self) + self.postProcessSegmWin.sigClosed.connect(self.postProcessSegmWinClosed) self.postProcessSegmWin.sigValueChanged.connect( self.postProcessSegmValueChanged ) @@ -129,27 +223,30 @@ def postProcessSegm(self, checked): self.postProcessSegmWin = None def postProcessSegmApplyToAllFutureFrames( - self, postProcessKwargs, - customPostProcessGroupedFeatures, - customPostProcessFeatures - ): + self, + postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures, + ): proceed = self.warnEditingWithCca_df( - 'post-processing segmentation', update_images=False + "post-processing segmentation", update_images=False ) if not proceed: - self.logger.info('Post-processing segmentation cancelled.') + self.logger.info("Post-processing segmentation cancelled.") return self.progressWin = apps.QDialogWorkerProgress( - title='Post-processing segmentation', parent=self.host, - pbarDesc=f'Post-processing segmentation masks...' + title="Post-processing segmentation", + parent=self, + pbarDesc="Post-processing segmentation masks...", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) self.startPostProcessSegmWorker( - postProcessKwargs, customPostProcessGroupedFeatures, - customPostProcessFeatures + postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures, ) def postProcessSegmEditingFinished(self): @@ -157,21 +254,6 @@ def postProcessSegmEditingFinished(self): self.store_data() self.updateAllImages() - def postProcessSegmWorkerFinished(self): - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.get_data() - self.updateAllImages() - self.titleLabel.setText('Post-processing segmentation done!', color='w') - self.logger.info('Post-processing segmentation done!') - - def postProcessSegmWinClosed(self): - self.postProcessSegmWin = None - self.postProcessSegmAction.toggled.disconnect() - self.postProcessSegmAction.setChecked(False) - self.postProcessSegmAction.toggled.connect(self.postProcessSegm) - def postProcessSegmValueChanged(self, lab, delObjs: dict): for delObj in delObjs.values(): self.clearObjContour(obj=delObj, ax=0) @@ -198,116 +280,57 @@ def postProcessSegmValueChanged(self, lab, delObjs: dict): if self.annotSegmMasksCheckboxRight.isChecked(): self.labelsLayerRightImg.setImage(self.currentLab2D, autoLevels=False) - def resetCursor(self): - if self.app.overrideCursor() is not None: - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - def segmFrameCallback(self, action): - if action == self.addCustomModelFrameAction: - return - - idx = self.segmActions.index(action) - model_name = self.modelNames[idx] - self.repeatSegm(model_name=model_name, askSegmParams=True) - - def showInstructionsCustomModel(self): - modelFilePath = apps.addCustomModelMessages(self.host) - if modelFilePath is None: - self.logger.info('Adding custom model process stopped.') - return - - self.store_custom_model_path(modelFilePath) - modelName = os.path.basename(os.path.dirname(modelFilePath)) - customModelAction = QAction(modelName) - self.segmSingleFrameMenu.addAction(customModelAction) - self.segmActions.append(customModelAction) - self.segmActionsVideo.append(customModelAction) - self.modelNames.append(modelName) - self.models.append(None) - self.sender().callback(customModelAction) + def postProcessSegmWinClosed(self): + self.postProcessSegmWin = None + self.postProcessSegmAction.toggled.disconnect() + self.postProcessSegmAction.setChecked(False) + self.postProcessSegmAction.toggled.connect(self.postProcessSegm) - def reinitStoredSegmModels(self): - self.models = [None]*len(self.models) + def postProcessSegmWorkerFinished(self): + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.get_data() + self.updateAllImages() + self.titleLabel.setText("Post-processing segmentation done!", color="w") + self.logger.info("Post-processing segmentation done!") - def segmVideoCallback(self, action): - if action == self.addCustomModelVideoAction: + @exception_handler + def postProcessing(self): + if self.postProcessSegmWin is None: return + self.postProcessSegmWin.setPosData() posData = self.data[self.pos_i] - win = apps.startStopFramesDialog( - posData.SizeT, currentFrameNum=posData.frame_i+1 - ) - win.exec_() - if win.cancel: - self.logger.info('Segmentation on multiple frames aborted.') - return - - idx = self.segmActionsVideo.index(action) - model_name = self.modelNames[idx] - self.repeatSegmVideo(model_name, win.startFrame, win.stopFrame) - - def segmentToolActionTriggered(self): - if self.segmModelName is None: - win = apps.QDialogSelectModel(parent=self.host) - win.exec_() - if win.cancel: - self.logger.info('Repeat segmentation cancelled.') - return - model_name = win.selectedModel - self.repeatSegm( - model_name=model_name, askSegmParams=True - ) + lab, delIDs = self.postProcessSegmWin.apply() + if posData.allData_li[posData.frame_i]["labels"] is None: + posData.lab = lab.copy() + self.update_rp() else: - self.repeatSegm(model_name=self.segmModelName) - - def initSegmModelParams( - self, model_name, acdcSegment, init_params, segment_params, - is_label_roi=False, initLastParams=False, - extraParams=None, extraParamsTitle=None,ini_filename=None - - ): - posData = self.data[self.pos_i] - try: - url = acdcSegment.url_help() - except AttributeError: - url = None - - text_if_cancelled = 'Segmentation process cancelled.' - out = prompts.init_segm_model_params( - posData, model_name, init_params, segment_params, - help_url=url, qparent=self.host, init_last_params=initLastParams, - check_sam_embeddings=not is_label_roi, is_gui_caller=True, - extraParams=extraParams,extraParamsTitle=extraParamsTitle, - ini_filename=ini_filename, - ) - if out.get('load_sam_embeddings', False): - self.logger.info('Loading Segment Anything image embeddings...') - for _posData in self.data: - _posData.loadSamEmbeddings(logger_func=None) - text_if_cancelled = 'SAM embeddings loaded.' - - win = out.get('win') - if win is None: - self.logger.info(text_if_cancelled) - self.titleLabel.setText(text_if_cancelled) - return - - if win.cancel: - self.logger.info(text_if_cancelled) - self.titleLabel.setText(text_if_cancelled) - return + posData.allData_li[posData.frame_i]["labels"] = lab + self.get_data() - if model_name != 'thresholding': - self.model_kwargs = win.model_kwargs + def post_process_params( + self, + *, + apply_postprocessing, + standard_postprocess_kwargs=None, + custom_postprocess_features=None, + ) -> dict: + params = {"applied_postprocessing": apply_postprocessing} + params.update(standard_postprocess_kwargs or {}) + params.update(custom_postprocess_features or {}) + return params - return win + def reinitStoredSegmModels(self): + self.models = [None] * len(self.models) @exception_handler - def repeatSegm( - self, model_name='', askSegmParams=False, is_label_roi=False - ): - model_name = self.action_model_name(model_name) + def repeatSegm(self, model_name="", askSegmParams=False, is_label_roi=False): + if model_name == "thresholding": + # thresholding model is stored as 'Automatic thresholding' + # at line of code `models.append('Automatic thresholding')` + model_name = "Automatic thresholding" idx = self.modelNames.index(model_name) # Ask segm parameters if not already set @@ -315,22 +338,24 @@ def repeatSegm( if not askSegmParams: askSegmParams = self.model_kwargs is None - self.downloadWin = apps.downloadModel(model_name, parent=self.host) + self.downloadWin = apps.downloadModel(model_name, parent=self) self.downloadWin.download() # Store undo state before modifying stuff self.storeUndoRedoStates(False) - model_name = self.backend_model_name(model_name) + if model_name == "Automatic thresholding": + # Automatic thresholding is the name of the models as stored + # in self.modelNames, but the actual model is called thresholding + # (see cellacdc/models/thresholding) + model_name = "thresholding" posData = self.data[self.pos_i] # Check if model needs to be imported acdcSegment = self.acdcSegment_li[idx] if acdcSegment is None: - self.logger.info(f'Importing {model_name}...') - acdcSegment = ( - self.import_segmentation_module(model_name) - ) + self.logger.info(f"Importing {model_name}...") + acdcSegment = myutils.import_segment_module(model_name) self.acdcSegment_li[idx] = acdcSegment # Ask parameters if the user clicked on the action @@ -341,84 +366,77 @@ def repeatSegm( self.app.restoreOverrideCursor() self.segmModelName = model_name # Read all models parameters - init_params, segment_params = ( - self.model_arg_specs(acdcSegment) - ) + init_params, segment_params = myutils.getModelArgSpec(acdcSegment) # Prompt user to enter the model parameters try: - url = acdcSegment.url_help() + acdcSegment.url_help() except AttributeError: - url = None + pass self.preproc_recipe = None initLastParams = True - if model_name == 'thresholding': + if model_name == "thresholding": win = apps.QDialogAutomaticThresholding( - parent=self.host, isSegm3D=self.isSegm3D + parent=self, isSegm3D=self.isSegm3D ) win.exec_() if win.cancel: return self.model_kwargs = win.segment_kwargs - thresh_method = self.model_kwargs['threshold_method'] - gauss_sigma = self.model_kwargs['gauss_sigma'] - segment_params = ( - self.insert_model_arg_spec( - segment_params, 'threshold_method', thresh_method - ) + thresh_method = self.model_kwargs["threshold_method"] + gauss_sigma = self.model_kwargs["gauss_sigma"] + segment_params = myutils.insertModelArgSpec( + segment_params, "threshold_method", thresh_method ) - segment_params = ( - self.insert_model_arg_spec( - segment_params, 'gauss_sigma', gauss_sigma - ) + segment_params = myutils.insertModelArgSpec( + segment_params, "gauss_sigma", gauss_sigma ) initLastParams = False win = self.initSegmModelParams( - model_name, acdcSegment, init_params, segment_params, + model_name, + acdcSegment, + init_params, + segment_params, is_label_roi=is_label_roi, - initLastParams=initLastParams + initLastParams=initLastParams, ) if win is None: return self.standardPostProcessKwargs = win.standardPostProcessKwargs self.customPostProcessFeatures = win.customPostProcessFeatures - self.customPostProcessGroupedFeatures = ( - win.customPostProcessGroupedFeatures - ) + self.customPostProcessGroupedFeatures = win.customPostProcessGroupedFeatures self.applyPostProcessing = win.applyPostProcessing self.secondChannelName = win.secondChannelName self.preproc_recipe = win.preproc_recipe - self.log_segmentation_params( - model_name, win.init_kwargs, win.model_kwargs, + myutils.log_segm_params( + model_name, + win.init_kwargs, + win.model_kwargs, logger_func=self.logger.info, preproc_recipe=win.preproc_recipe, apply_post_process=self.applyPostProcessing, standard_postprocess_kwargs=self.standardPostProcessKwargs, - custom_postprocess_features=self.customPostProcessFeatures + custom_postprocess_features=self.customPostProcessFeatures, ) - use_gpu = win.init_kwargs.get('gpu', False) - proceed = self.check_gpu_available( - model_name, use_gpu, qparent=self.host - ) + use_gpu = win.init_kwargs.get("gpu", False) + proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) if not proceed: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') + self.logger.info("Segmentation process cancelled.") + self.titleLabel.setText("Segmentation process cancelled.") return - model = self.init_segmentation_model( - acdcSegment, posData, win.init_kwargs - ) + model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) if model is None: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') + self.logger.info("Segmentation process cancelled.") + self.titleLabel.setText("Segmentation process cancelled.") return try: model.setupLogger(self.logger) - except Exception as e: + except Exception: pass self.models[idx] = model model.model_name = model_name @@ -429,40 +447,43 @@ def repeatSegm( return model self.titleLabel.setText( - f'Segmenting with {model_name}... ' - '(check progress in terminal/console)', color=self.titleColor + f"Segmenting with {model_name}... (check progress in terminal/console)", + color=self.titleColor, ) - post_process_params = self.post_process_params( - apply_postprocessing=self.applyPostProcessing, - standard_postprocess_kwargs=self.standardPostProcessKwargs, - custom_postprocess_features=self.customPostProcessFeatures, - ) + post_process_params = {"applied_postprocessing": self.applyPostProcessing} + post_process_params = { + **post_process_params, + **self.standardPostProcessKwargs, + **self.customPostProcessFeatures, + } if askSegmParams: posData.saveSegmHyperparams( - model_name, win.init_kwargs, win.model_kwargs, + model_name, + win.init_kwargs, + win.model_kwargs, post_process_params=post_process_params, - preproc_recipe=self.preproc_recipe + preproc_recipe=self.preproc_recipe, ) if self.askRepeatSegment3D: self.segment3D = False if self.isSegm3D and self.askRepeatSegment3D: msg = widgets.myMessageBox(showCentered=False) - msg.addDoNotShowAgainCheckbox(text='Do not ask again') + msg.addDoNotShowAgainCheckbox(text="Do not ask again") txt = html_utils.paragraph( - 'Do you want to segment the entire z-stack or only the ' - 'current z-slice?' + "Do you want to segment the entire z-stack or only the " + "current z-slice?" ) _, segment3DButton, _ = msg.question( - self.host, '3D segmentation?', txt, - buttonsTexts=( - 'Cancel', 'Segment 3D z-stack', 'Segment 2D z-slice' - ) + self, + "3D segmentation?", + txt, + buttonsTexts=("Cancel", "Segment 3D z-stack", "Segment 2D z-slice"), ) if msg.cancel: - self.titleLabel.setText('Segmentation process aborted.') - self.logger.info('Segmentation process aborted.') + self.titleLabel.setText("Segmentation process aborted.") + self.logger.info("Segmentation process aborted.") return self.segment3D = msg.clickedButton == segment3DButton if msg.doNotShowAgainCheckbox.isChecked(): @@ -473,20 +494,23 @@ def repeatSegm( if self.isSegm3D and self.segment3D and self.askZrangeSegm3D: idx = (posData.filename, posData.frame_i) try: - orignal_z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] - except ValueError as e: - orignal_z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] + orignal_z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] + except ValueError: + orignal_z = posData.segmInfo_df.loc[idx, "z_slice_used_gui"].iloc[0] selectZtool = apps.QCropZtool( - posData.SizeZ, parent=self.host, cropButtonText='Ok', - addDoNotShowAgain=True, title='Select z-slice range to segment' + posData.SizeZ, + parent=self, + cropButtonText="Ok", + addDoNotShowAgain=True, + title="Select z-slice range to segment", ) selectZtool.sigZvalueChanged.connect(self.selectZtoolZvalueChanged) selectZtool.sigCrop.connect(selectZtool.close) selectZtool.exec_() self.update_z_slice(orignal_z) if selectZtool.cancel: - self.titleLabel.setText('Segmentation process aborted.') - self.logger.info('Segmentation process aborted.') + self.titleLabel.setText("Segmentation process aborted.") + self.logger.info("Segmentation process aborted.") return startZ = selectZtool.lowerZscrollbar.value() stopZ = selectZtool.upperZscrollbar.value() @@ -499,8 +523,8 @@ def repeatSegm( secondChannelData = self.getSecondChannelData() self.titleLabel.setText( - f'{model_name} is thinking... ' - '(check progress in terminal/console)', color=self.titleColor + f"{model_name} is thinking... (check progress in terminal/console)", + color=self.titleColor, ) self.model = model @@ -509,10 +533,10 @@ def repeatSegm( self.segmWorkerWaitCond = QWaitCondition() self.thread = QThread() self.worker = workers.segmWorker( - self.host, + self, secondChannelData=secondChannelData, mutex=self.segmWorkerMutex, - waitCond=self.segmWorkerWaitCond + waitCond=self.segmWorkerWaitCond, ) self.worker.z_range = self.z_range self.worker.moveToThread(self.thread) @@ -529,49 +553,43 @@ def repeatSegm( self.thread.started.connect(self.worker.run) self.thread.start() - def debugSegmWorker(self, to_debug): - img, _lab, lab = to_debug - printl(img.shape, _lab.shape, lab.shape) - imshow(img, _lab, lab) - self.segmWorkerWaitCond.wakeAll() - - def selectZtoolZvalueChanged(self, whichZ, z): - self.update_z_slice(z) - @exception_handler def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): - model_name = self.action_model_name(model_name) + if model_name == "thresholding": + # thresholding model is stored as 'Automatic thresholding' + # at line of code `models.append('Automatic thresholding')` + model_name = "Automatic thresholding" idx = self.modelNames.index(model_name) - self.downloadWin = apps.downloadModel(model_name, parent=self.host) + self.downloadWin = apps.downloadModel(model_name, parent=self) self.downloadWin.download() - model_name = self.backend_model_name(model_name) + if model_name == "Automatic thresholding": + # Automatic thresholding is the name of the models as stored + # in self.modelNames, but the actual model is called thresholding + # (see cellacdc/models/thresholding) + model_name = "thresholding" posData = self.data[self.pos_i] # Check if model needs to be imported acdcSegment = self.acdcSegment_li[idx] if acdcSegment is None: - self.logger.info(f'Importing {model_name}...') - acdcSegment = ( - self.import_segmentation_module(model_name) - ) + self.logger.info(f"Importing {model_name}...") + acdcSegment = myutils.import_segment_module(model_name) self.acdcSegment_li[idx] = acdcSegment # Read all models parameters - init_params, segment_params = ( - self.model_arg_specs(acdcSegment) - ) + init_params, segment_params = myutils.getModelArgSpec(acdcSegment) # Prompt user to enter the model parameters try: - url = acdcSegment.url_help() + acdcSegment.url_help() except AttributeError: - url = None + pass - if model_name == 'thresholding': + if model_name == "thresholding": autoThreshWin = apps.QDialogAutomaticThresholding( - parent=self.host, isSegm3D=self.isSegm3D + parent=self, isSegm3D=self.isSegm3D ) autoThreshWin.exec_() if autoThreshWin.cancel: @@ -585,62 +603,57 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): self.standardPostProcessKwargs = win.standardPostProcessKwargs self.customPostProcessFeatures = win.customPostProcessFeatures - self.customPostProcessGroupedFeatures = ( - win.customPostProcessGroupedFeatures - ) + self.customPostProcessGroupedFeatures = win.customPostProcessGroupedFeatures self.applyPostProcessing = win.applyPostProcessing self.preproc_recipe = win.preproc_recipe - self.log_segmentation_params( - model_name, win.init_kwargs, win.model_kwargs, + myutils.log_segm_params( + model_name, + win.init_kwargs, + win.model_kwargs, logger_func=self.logger.info, preproc_recipe=win.preproc_recipe, apply_post_process=self.applyPostProcessing, standard_postprocess_kwargs=self.standardPostProcessKwargs, - custom_postprocess_features=self.customPostProcessFeatures + custom_postprocess_features=self.customPostProcessFeatures, ) secondChannelData = None if win.secondChannelName is not None: secondChannelData = self.getSecondChannelData() - use_gpu = win.init_kwargs.get('gpu', False) - proceed = self.check_gpu_available( - model_name, use_gpu, qparent=self.host - ) + use_gpu = win.init_kwargs.get("gpu", False) + proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) if not proceed: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') + self.logger.info("Segmentation process cancelled.") + self.titleLabel.setText("Segmentation process cancelled.") return - model = self.init_segmentation_model( - acdcSegment, posData, win.init_kwargs - ) + model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) if model is None: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') + self.logger.info("Segmentation process cancelled.") + self.titleLabel.setText("Segmentation process cancelled.") return try: model.setupLogger(self.logger) - except Exception as e: + except Exception: pass self.extendSegmDataIfNeeded(stopFrameNum) - self.reInitLastSegmFrame( - from_frame_i=startFrameNum-1, updateImages=False - ) + self.reInitLastSegmFrame(from_frame_i=startFrameNum - 1, updateImages=False) self.titleLabel.setText( - f'{model_name} is thinking... ' - '(check progress in terminal/console)', color=self.titleColor + f"{model_name} is thinking... (check progress in terminal/console)", + color=self.titleColor, ) self.progressWin = apps.QDialogWorkerProgress( - title='Segmenting video', parent=self.host, - pbarDesc=f'Segmenting from frame n. {startFrameNum} to {stopFrameNum}...' + title="Segmenting video", + parent=self, + pbarDesc=f"Segmenting from frame n. {startFrameNum} to {stopFrameNum}...", ) self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stopFrameNum-startFrameNum) + self.progressWin.mainPbar.setMaximum(stopFrameNum - startFrameNum) self.thread = QThread() self.worker = workers.segmVideoWorker( @@ -661,6 +674,36 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): self.thread.started.connect(self.worker.run) self.thread.start() + def resetCursor(self): + if self.app.overrideCursor() is not None: + while self.app.overrideCursor() is not None: + self.app.restoreOverrideCursor() + + def segmFrameCallback(self, action): + if action == self.addCustomModelFrameAction: + return + + idx = self.segmActions.index(action) + model_name = self.modelNames[idx] + self.repeatSegm(model_name=model_name, askSegmParams=True) + + def segmVideoCallback(self, action): + if action == self.addCustomModelVideoAction: + return + + posData = self.data[self.pos_i] + win = apps.startStopFramesDialog( + posData.SizeT, currentFrameNum=posData.frame_i + 1 + ) + win.exec_() + if win.cancel: + self.logger.info("Segmentation on multiple frames aborted.") + return + + idx = self.segmActionsVideo.index(action) + model_name = self.modelNames[idx] + self.repeatSegmVideo(model_name, win.startFrame, win.stopFrame) + def segmVideoWorkerFinished(self, exec_time): self.progressWin.workerFinished = True self.progressWin.close() @@ -672,18 +715,18 @@ def segmVideoWorkerFinished(self, exec_time): self.tracking(enforce=True) self.updateAllImages() - txt = f'Done. Segmentation computed in {exec_time:.3f} s' - self.logger.info('-----------------') + txt = f"Done. Segmentation computed in {exec_time:.3f} s" + self.logger.info("-----------------") self.logger.info(txt) - self.logger.info('=================') - self.titleLabel.setText(txt, color='g') + self.logger.info("=================") + self.titleLabel.setText(txt, color="g") def segmWorkerFinished(self, lab, exec_time): posData = self.data[self.pos_i] - if posData.segmInfo_df is not None and posData.SizeZ>1: + if posData.segmInfo_df is not None and posData.SizeZ > 1: idx = (posData.filename, posData.frame_i) - posData.segmInfo_df.at[idx, 'resegmented_in_gui'] = True + posData.segmInfo_df.at[idx, "resegmented_in_gui"] = True if lab.ndim == 2 and self.isSegm3D: self.set_2Dlab(lab) @@ -693,55 +736,37 @@ def segmWorkerFinished(self, lab, exec_time): self.activateAnnotations() self.update_rp(wl_update=False) - self.tracking(enforce=True, against_next=posData.frame_i==0) + self.tracking(enforce=True, against_next=posData.frame_i == 0) if self.isSnapshot: - self.fixCcaDfAfterEdit('Repeat segmentation') + self.fixCcaDfAfterEdit("Repeat segmentation") self.updateAllImages() else: - self.warnEditingWithCca_df('Repeat segmentation') + self.warnEditingWithCca_df("Repeat segmentation") - txt = f'Done. Segmentation computed in {exec_time:.3f} s' - self.logger.info('-----------------') + txt = f"Done. Segmentation computed in {exec_time:.3f} s" + self.logger.info("-----------------") self.logger.info(txt) - self.logger.info('=================') - self.titleLabel.setText(txt, color='g') + self.logger.info("=================") + self.titleLabel.setText(txt, color="g") self.checkIfAutoSegm() QTimer.singleShot(200, self.resizeGui) - @exception_handler - def postProcessing(self): - if self.postProcessSegmWin is None: - return - - self.postProcessSegmWin.setPosData() - posData = self.data[self.pos_i] - lab, delIDs = self.postProcessSegmWin.apply() - if posData.allData_li[posData.frame_i]['labels'] is None: - posData.lab = lab.copy() - self.update_rp() + def segmentToolActionTriggered(self): + if self.segmModelName is None: + win = apps.QDialogSelectModel(parent=self) + win.exec_() + if win.cancel: + self.logger.info("Repeat segmentation cancelled.") + return + model_name = win.selectedModel + self.repeatSegm(model_name=model_name, askSegmParams=True) else: - posData.allData_li[posData.frame_i]['labels'] = lab - self.get_data() - - def checkIfAutoSegm(self): - """ - - """Headless decisions for segmentation orchestration.""" - - thresholding_backend_name = 'thresholding' - thresholding_action_name = 'Automatic thresholding' - - def action_model_name(self, model_name: str) -> str: - if model_name == self.thresholding_backend_name: - return self.thresholding_action_name - return model_name + self.repeatSegm(model_name=self.segmModelName) - def backend_model_name(self, model_name: str) -> str: - if model_name == self.thresholding_action_name: - return self.thresholding_backend_name - return model_name + def selectZtoolZvalueChanged(self, whichZ, z): + self.update_z_slice(z) def should_compute_segmentation( self, @@ -751,71 +776,24 @@ def should_compute_segmentation( force: bool, auto_enabled: bool, ) -> bool: - if mode in {'Viewer', 'Cell cycle analysis'}: + if mode in {"Viewer", "Cell cycle analysis"}: return False if has_labels and not force: return False return auto_enabled - def post_process_params( - self, - *, - apply_postprocessing, - standard_postprocess_kwargs=None, - custom_postprocess_features=None, - ) -> dict: - params = {'applied_postprocessing': apply_postprocessing} - params.update(standard_postprocess_kwargs or {}) - params.update(custom_postprocess_features or {}) - return params - - def empty_segmentation_prompt(self, position_data) -> EmptySegmentationPrompt: - for pos_data in position_data: - if pos_data.SizeT > 1: - for lab in pos_data.segm_data: - if not np.any(lab): - return EmptySegmentationPrompt(True, 'frames') - elif not np.any(pos_data.segm_data): - return EmptySegmentationPrompt(True, 'positions') - return EmptySegmentationPrompt(False) - - If there are any frame or position with empty segmentation mask - ask whether automatic segmentation should be turned ON - """ - if self.autoSegmAction.isChecked(): - return - if self.autoSegmDoNotAskAgain: - return - - prompt = self.empty_segmentation_prompt(self.data) - if not prompt.should_ask: + def showInstructionsCustomModel(self): + modelFilePath = apps.addCustomModelMessages(self) + if modelFilePath is None: + self.logger.info("Adding custom model process stopped.") return - txt = prompt.scope_text - - questionTxt = html_utils.paragraph( - f'Some or all loaded {txt} contain empty segmentation masks.

' - 'Do you want to activate automatic segmentation* ' - f'when visiting these {txt}?

' - '* Automatic segmentation can always be turned ON/OFF from the menu
' - ' Edit --> Segmentation --> Enable automatic segmentation

' - f'NOTE: you can automatically segment all {txt} using the
' - ' segmentation module.' - ) - msg = widgets.myMessageBox(wrapText=False) - noButton, yesButton = msg.question( - self.host, 'Automatic segmentation?', questionTxt, - buttonsTexts=('No', 'Yes') - ) - if msg.clickedButton == yesButton: - self.autoSegmAction.setChecked(True) - else: - self.autoSegmDoNotAskAgain = True - self.autoSegmAction.setChecked(False) - def init_segmInfo_df(self): - for posData in self.data: - if posData is None: - # posData is None when computing measurements with the utility - # and with timelapse data - continue - posData.init_segmInfo_df() \ No newline at end of file + myutils.store_custom_model_path(modelFilePath) + modelName = os.path.basename(os.path.dirname(modelFilePath)) + customModelAction = QAction(modelName) + self.segmSingleFrameMenu.addAction(customModelAction) + self.segmActions.append(customModelAction) + self.segmActionsVideo.append(customModelAction) + self.modelNames.append(modelName) + self.models.append(None) + self.sender().callback(customModelAction) diff --git a/cellacdc/mixins/session.py b/cellacdc/mixins/session.py index ab8376602..8af809a1c 100644 --- a/cellacdc/mixins/session.py +++ b/cellacdc/mixins/session.py @@ -5,181 +5,79 @@ import os from functools import partial -import numpy as np import numpy as np import skimage.measure from qtpy.QtWidgets import QAction -from cellacdc import exception_handler, html_utils, recentPaths_path, settings_csv_path, widgets +from cellacdc import ( + exception_handler, + html_utils, + recentPaths_path, + settings_csv_path, + widgets, +) from cellacdc.ui.modules.annotation.decorators import get_data_exception_handler -class SessionView: +class SessionMixin: """Qt-facing adapter around session setup and frame storage.""" - LEGACY_METHODS = ( - 'unstore_data', - 'updateLastVisitedFrame', - 'store_data', - '_get_data_unvisited', - '_get_data_visited', - 'get_data', - 'initPosAttr', - 'getStoredSegmData', - 'store_manual_annot_data', - 'get_labels', - 'readRecentPaths', - 'addPathToOpenRecentMenu', - 'loadLastSessionSettings', - 'reInitGui', - 'reinitWidgetsPos', - 'get_session', - '_sync_session', - '_sync_all_sessions', - '_sync_session_frame_i', - 'sync_session_labels', - '_init_tool_dispatcher', - '_make_tool_context', - '_dispatch_tool_event_if_enabled', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - - def unstore_data(self): - posData = self.data[self.pos_i] - posData.allData_li[posData.frame_i] = ( - self.frame_metadata.empty_frame_record() - ) - - def updateLastVisitedFrame(self, last_visited_frame_i=None): - posData = self.data[self.pos_i] - if last_visited_frame_i is None: - last_visited_frame_i = posData.frame_i - - mode = str(self.modeComboBox.currentText()) - update = self.update_last_visited_frame( - mode, - last_visited_frame_i, - last_tracked_i=posData.last_tracked_i, - last_cca_frame_i=self.last_cca_frame_i, - ) - posData.last_tracked_i = update.last_tracked_i - self.last_cca_frame_i = update.last_cca_frame_i - - @exception_handler - def store_data( - self, pos_i=None, enforce=True, debug=False, mainThread=True, - autosave=True, store_cca_df_copy=False - ): - pos_i = self.pos_i if pos_i is None else pos_i - posData = self.data[pos_i] - mode = str(self.modeComboBox.currentText()) - if not self.should_store_frame_data( - frame_i=posData.frame_i, - mode=mode, - enforce=enforce, - ): - return - - # if not mainThread: - # self.lin_tree_ask_changes() - - allData_li = posData.allData_li[posData.frame_i] - allData_li['regionprops'] = posData.rp.copy() - allData_li['labels'] = posData.lab.copy() - allData_li['IDs'] = posData.IDs.copy() - allData_li['manualBackgroundLab'] = ( - posData.manualBackgroundLab - ) - allData_li['IDs_idxs'] = ( - posData.IDs_idxs.copy() - ) - if self.manualAnnotPastButton.isChecked(): - self.store_manual_annot_data( - posData=posData, data_frame_i=allData_li - ) - - self.store_zslices_rp() - - depth_axis = ( - self.switchPlaneCombobox.depthAxes() if self.isSegm3D else 'z' - ) - metadata_result = ( - self.frame_metadata.build_acdc_frame_metadata( - posData.rp, - edit_id_info=posData.editID_info, - existing_df=allData_li['acdc_df'], - is_3d=self.isSegm3D, - depth_axis=depth_axis, - ) - ) - posData.STOREDmaxID = metadata_result.max_id - allData_li['acdc_df'] = metadata_result.dataframe - - if mainThread: - self.pointsLayerDataToDf(posData) + def _dispatch_tool_event_if_enabled(self, event, phase="press", image="img1"): + from cellacdc.tools.adapters.gui_bridge import dispatch_gui_mouse_event - self.store_cca_df( - pos_i=pos_i, mainThread=mainThread, autosave=autosave, - store_cca_df_copy=store_cca_df_copy + self._init_tool_dispatcher() + return dispatch_gui_mouse_event( + self._tool_dispatcher, + self, + event, + phase=phase, + image=image, ) - def _get_data_unvisited(self, posData, debug=False, lin_tree_init=True,): + def _get_data_unvisited( + self, + posData, + debug=False, + lin_tree_init=True, + ): posData.editID_info = [] proceed_cca = True never_visited = True - if str(self.modeComboBox.currentText()) == 'Cell cycle analysis': + if str(self.modeComboBox.currentText()) == "Cell cycle analysis": # Warn that we are visiting a frame that was never segm-checked # on cell cycle analysis mode msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Segmentation and Tracking was never checked from ' - f'frame {posData.frame_i+1} onwards.

' - 'To ensure correct cell cell cycle analysis you have to ' - 'first visit the frames after ' - f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' - ) - warn_cca = msg.critical( - self.host, 'Never checked segmentation on requested frame', txt + "Segmentation and Tracking was never checked from " + f"frame {posData.frame_i + 1} onwards.

" + "To ensure correct cell cell cycle analysis you have to " + "first visit the frames after " + f'{posData.frame_i + 1} with "Segmentation and Tracking" mode.' ) + msg.critical(self, "Never checked segmentation on requested frame", txt) proceed_cca = False return proceed_cca, never_visited - elif str(self.modeComboBox.currentText()) == 'Normal division: Lineage tree': + elif str(self.modeComboBox.currentText()) == "Normal division: Lineage tree": # Warn that we are visiting a frame that was never segm-checked # on cell cycle analysis mode msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Segmentation and Tracking was never checked from ' - f'frame {posData.frame_i+1} onwards.

' - 'To ensure correct lineage tree analysis you have to ' - 'first visit the frames after ' - f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' + "Segmentation and Tracking was never checked from " + f"frame {posData.frame_i + 1} onwards.

" + "To ensure correct lineage tree analysis you have to " + "first visit the frames after " + f'{posData.frame_i + 1} with "Segmentation and Tracking" mode.' ) - warn_cca = msg.critical(#??? - self.host, 'Never checked segmentation on requested frame', txt + msg.critical( # ??? + self, "Never checked segmentation on requested frame", txt ) proceed_cca = False return proceed_cca, never_visited # Requested frame was never visited before. Load from HDD labels = self.get_labels() - posData.lab = self.apply_manual_edits_to_lab_if_needed( - labels - ) + posData.lab = self.apply_manual_edits_to_lab_if_needed(labels) posData.rp = skimage.measure.regionprops(posData.lab) self.setManualBackgroundLab() @@ -189,21 +87,26 @@ def _get_data_unvisited(self, posData, debug=False, lin_tree_init=True,): # Since there was already segmentation metadata from # previous closed session add it to current metadata df = posData.acdc_df.loc[posData.frame_i].copy() - binnedIDs_df = df[df['is_cell_excluded']>0] + binnedIDs_df = df[df["is_cell_excluded"] > 0] binnedIDs = set(binnedIDs_df.index).union(posData.binnedIDs) posData.binnedIDs = binnedIDs - ripIDs_df = df[df['is_cell_dead']>0] + ripIDs_df = df[df["is_cell_dead"] > 0] ripIDs = set(ripIDs_df.index).union(posData.ripIDs) posData.ripIDs = ripIDs posData.editID_info.extend(self._get_editID_info(df)) - df = self.cca_edits.normalize_loaded_frame_annotations( - df, - self.cca_df_colnames, - self.cca_df_int_cols, - ) + # Load cca df into current metadata + if "cell_cycle_stage" in df.columns: + cca_cols = df.columns.intersection(self.cca_df_colnames) + cca_df = df[cca_cols].dropna() + if cca_df.empty: + df = df.drop(columns=self.cca_df_colnames, errors="ignore") + else: + df = df.loc[cca_df.index] + cols = self.cca_df_int_cols + df[cols] = df[cols].astype("Int64") i = posData.frame_i - posData.allData_li[i]['acdc_df'] = df.copy() + posData.allData_li[i]["acdc_df"] = df.copy() if self.lineage_tree is None and lin_tree_init: self.initLinTree() @@ -212,24 +115,29 @@ def _get_data_unvisited(self, posData, debug=False, lin_tree_init=True,): return proceed_cca, never_visited - def _get_data_visited(self, posData, debug=False, lin_tree_init=True,): + def _get_data_visited( + self, + posData, + debug=False, + lin_tree_init=True, + ): # Requested frame was already visited. Load from RAM. never_visited = False posData.lab = self.get_labels(from_store=True) posData.rp = skimage.measure.regionprops(posData.lab) - df = posData.allData_li[posData.frame_i]['acdc_df'] + df = posData.allData_li[posData.frame_i]["acdc_df"] if df is None: posData.binnedIDs = set() posData.ripIDs = set() posData.editID_info = [] else: try: - binnedIDs_df = df[df['is_cell_excluded']>0] - except Exception as err: - df = self.tables.fix_acdc_df_dtypes(df) - binnedIDs_df = df[df['is_cell_excluded']>0] + binnedIDs_df = df[df["is_cell_excluded"] > 0] + except Exception: + df = myutils.fix_acdc_df_dtypes(df) + binnedIDs_df = df[df["is_cell_excluded"] > 0] posData.binnedIDs = set(binnedIDs_df.index) - ripIDs_df = df[df['is_cell_dead']>0] + ripIDs_df = df[df["is_cell_dead"] > 0] posData.ripIDs = set(ripIDs_df.index) posData.editID_info = self._get_editID_info(df) self.setManualBackgroundLab(load_from_store=True, debug=debug) @@ -240,6 +148,96 @@ def _get_data_visited(self, posData, debug=False, lin_tree_init=True,): return True, never_visited + def _init_tool_dispatcher(self): + from cellacdc.tools.dispatch import ToolDispatcher + + if not hasattr(self, "_tool_dispatcher"): + self._tool_dispatcher = ToolDispatcher() + self._tool_dispatcher.set_context(self._make_tool_context(self.pos_i)) + + def _make_tool_context(self, pos_i=None): + from cellacdc.tools.context import GuiToolContext + + if pos_i is None: + pos_i = self.pos_i + return GuiToolContext( + gui=self, + pos_i=pos_i, + pos_data=self.data[pos_i], + session=self.get_session(pos_i), + ) + + def _sync_all_sessions(self): + if not hasattr(self, "data"): + return + self.sessions = [None] * len(self.data) + for pos_i in range(len(self.data)): + self._sync_session(pos_i) + + def _sync_session(self, pos_i): + """Build or refresh ``PositionSession`` from ``loadData`` at ``pos_i``.""" + if not hasattr(self, "sessions"): + self.sessions = [] + while len(self.sessions) <= pos_i: + self.sessions.append(None) + pos_data = self.data[pos_i] + session = self.position_session_from_load_data(pos_data) + self.sessions[pos_i] = session + if hasattr(self, "_tool_dispatcher") and self._tool_dispatcher is not None: + self._tool_dispatcher.set_context(self._make_tool_context(pos_i)) + return session + + def _sync_session_frame_i(self, pos_i=None): + if pos_i is None: + pos_i = self.pos_i + session = self.get_session(pos_i) + if session is None: + return + pos_data = self.data[pos_i] + session.frame_i = pos_data.frame_i + if hasattr(self, "_tool_dispatcher") and self._tool_dispatcher is not None: + self._tool_dispatcher.on_frame_changed(self._make_tool_context(pos_i)) + + def addPathToOpenRecentMenu(self, path): + for action in self.openRecentMenu.actions(): + if path == action.text(): + break + else: + action = QAction(path, self) + action.triggered.connect(partial(self.openRecentFile, path)) + + try: + firstAction = self.openRecentMenu.actions()[0] + self.openRecentMenu.insertAction(firstAction, action) + except Exception: + pass + + def empty_labels( + self, + *, + is_3d: bool, + size_z: int, + size_y: int, + size_x: int, + ) -> np.ndarray: + shape = self.labels_shape( + is_3d=is_3d, + size_z=size_z, + size_y=size_y, + size_x=size_x, + ) + return np.zeros(shape, dtype=np.uint32) + + def getStoredSegmData(self): + posData = self.data[self.pos_i] + segm_data = [] + for data_frame_i in posData.allData_li: + lab = data_frame_i["labels"] + if lab is None: + break + segm_data.append(lab) + return np.array(segm_data) + @get_data_exception_handler def get_data(self, debug=False, lin_tree_init=True): posData = self.data[self.pos_i] @@ -247,7 +245,7 @@ def get_data(self, debug=False, lin_tree_init=True): never_visited = False if posData.frame_i > 2: # Remove undo states from 4 frames back to avoid memory issues - posData.UndoRedoStates[posData.frame_i-4] = [] + posData.UndoRedoStates[posData.frame_i - 4] = [] # Check if current frame contains undo states (not empty list) if posData.UndoRedoStates[posData.frame_i]: self.undoAction.setDisabled(False) @@ -257,9 +255,10 @@ def get_data(self, debug=False, lin_tree_init=True): self.undoAction.setDisabled(True) self.UndoCount = 0 # If stored labels is None then it is the first time we visit this frame - if posData.allData_li[posData.frame_i]['labels'] is None: - proceed_cca, never_visited = self._get_data_unvisited( - posData, lin_tree_init=lin_tree_init, + if posData.allData_li[posData.frame_i]["labels"] is None: + proceed_cca, never_visited = self._get_data_unvisited( + posData, + lin_tree_init=lin_tree_init, ) if not proceed_cca: return proceed_cca, never_visited @@ -271,18 +270,92 @@ def get_data(self, debug=False, lin_tree_init=True): self.update_rp_metadata(draw=False) posData.IDs = [obj.label for obj in posData.rp] posData.IDs_idxs = { - ID:i for ID, i in zip(posData.IDs, range(len(posData.IDs))) + ID: i for ID, i in zip(posData.IDs, range(len(posData.IDs))) } self.get_zslices_rp() self.pointsLayerDfsToData(posData) return proceed_cca, never_visited + def get_labels( + self, from_store=False, frame_i=None, return_existing=False, return_copy=True + ): + """Get the labels array. + + Parameters + ---------- + from_store : bool, optional + If True load the labels array from the stored posData.allData_li, + i.e., from RAM. Default is False + frame_i : int, optional + If None, use the current frame index. Default is None + return_existing : bool, optional + If True, the second return element will be a boolean that + is True if the labels array was found stored in `posData.allData_li`. + Default is False + return_copy : bool, optional + If True returns a copy of the labels array + + Returns + ------- + numpy.ndarray or tuple of (numpy.ndarray, bool) + The first element is the labels array requested. If `return_existing` + is True then this method also returns a second boolean element that + is True if the labels array was found in in `posData.allData_li`. + + Note + ---- + + If `from_store` is True then this method will try to get the stored + labels array. If any error occurs then the returned labels are the + saved ones in the segmentation file (i.e., from hard drive). + + """ + posData = self.data[self.pos_i] + if frame_i is None: + frame_i = posData.frame_i + + existing = True + if from_store: + try: + labels = posData.allData_li[frame_i]["labels"] + if labels is None: + from_store = False + except Exception: + from_store = False + + if not from_store: + try: + labels = posData.segm_data[frame_i] + except IndexError: + existing = False + # Visting a frame that was not segmented --> empty masks + if self.isSegm3D: + shape = (posData.SizeZ, posData.SizeY, posData.SizeX) + else: + shape = (posData.SizeY, posData.SizeX) + labels = np.zeros(shape, dtype=np.uint32) + return_copy = False + + if return_copy: + labels = labels.copy() + + if return_existing: + return labels, existing + else: + return labels + + def get_session(self, pos_i=None): + """Return synced :class:`PositionSession` for a loaded position.""" + if pos_i is None: + pos_i = self.pos_i + if not hasattr(self, "sessions") or pos_i >= len(self.sessions): + return None + return self.sessions[pos_i] + def initPosAttr(self): exp_path = self.data[self.pos_i].exp_path - pos_foldernames = self.workspace.position_folder_names( - exp_path - ) - if self.should_disable_load_position(len(pos_foldernames)): + pos_foldernames = myutils.get_pos_foldernames(exp_path) + if len(pos_foldernames) == 1: self.loadPosAction.setDisabled(True) else: self.loadPosAction.setDisabled(False) @@ -318,7 +391,9 @@ def initPosAttr(self): posData.applyFutFramesAssignNewID = False posData.includeUnvisitedInfo = { - 'Delete ID': False, 'Edit ID': False, 'Keep ID': False + "Delete ID": False, + "Edit ID": False, + "Keep ID": False, } posData.loadTrackedLostCentroids() @@ -345,9 +420,7 @@ def initPosAttr(self): posData.allData_li.extend([None] * missing_frames) for i in range(posData.SizeT): if posData.allData_li[i] is None: - posData.allData_li[i] = ( - self.frame_metadata.empty_frame_record() - ) + posData.allData_li[i] = myutils.get_empty_stored_data_dict() posData.lutLevels = {channel: {} for channel in self.ch_names} @@ -359,11 +432,11 @@ def initPosAttr(self): posData.ripIDs = set() posData.cca_df = None if posData.last_tracked_i is not None: - last_tracked_num = posData.last_tracked_i+1 + last_tracked_num = posData.last_tracked_i + 1 # Load previous session data # Keep track of which ROIs have already been added # in previous frame - delROIshapes = [[] for _ in range(posData.SizeT)] + [[] for _ in range(posData.SizeT)] for i in range(last_tracked_num): posData.frame_i = i self.get_data(debug=True) @@ -372,22 +445,22 @@ def initPosAttr(self): ) # Ask whether to resume from last frame - if self.should_resume_last_session_prompt( - last_tracked_num - ): + if last_tracked_num > 1: msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Cell-ACDC detected a previous session ended ' - f'at frame {last_tracked_num}.

' - f'Do you want to resume from frame ' - f'{last_tracked_num}?' + "Cell-ACDC detected a previous session ended " + f"at frame {last_tracked_num}.

" + f"Do you want to resume from frame " + f"{last_tracked_num}?" ) noButton, yesButton = msg.question( - self.host, 'Start from last session?', txt, - buttonsTexts=(' No ', 'Yes') + self, + "Start from last session?", + txt, + buttonsTexts=(" No ", "Yes"), ) self.AutoPilotProfile.storeClickMessageBox( - 'Start from last session?', msg.clickedButton.text() + "Start from last session?", msg.clickedButton.text() ) if msg.clickedButton == yesButton: posData.frame_i = posData.last_tracked_i @@ -395,9 +468,7 @@ def initPosAttr(self): else: posData.frame_i = 0 - posData.img_data_min_max = ( - posData.img_data.min(), posData.img_data.max() - ) + posData.img_data_min_max = (posData.img_data.min(), posData.img_data.max()) # Back to first position self.pos_i = 0 @@ -411,62 +482,6 @@ def initPosAttr(self): self.setAllIDs() - def getStoredSegmData(self): - posData = self.data[self.pos_i] - segm_data = [] - for data_frame_i in posData.allData_li: - lab = data_frame_i['labels'] - if lab is None: - break - segm_data.append(lab) - return np.array(segm_data) - - def store_manual_annot_data( - self, posData=None, data_frame_i=None - ): - if posData is None: - posData = self.data[self.pos_i] - - if data_frame_i is None: - data_frame_i = posData.allData_li[posData.frame_i] - - if not self.isSegm3D: - lab = [posData.lab] - else: - lab = posData.lab - - for z, lab_2D in enumerate(lab): - data_frame_i['manually_edited_lab']['lab'][z] = lab_2D - - # data_frame_i['manually_edited_lab']['zoom_slice'] = zoom_slice - - def get_labels( - self, - from_store=False, - frame_i=None, - return_existing=False, - return_copy=True - ): - """Get the labels array. - - """Headless decisions for session and frame storage workflows.""" - - def should_store_frame_data( - self, - *, - frame_i: int, - mode: str, - enforce: bool, - ) -> bool: - if frame_i < 0: - return False - if mode == 'Viewer' and not enforce: - return False - return True - - def should_disable_load_position(self, position_count: int) -> bool: - return position_count <= 1 - def labels_shape( self, *, @@ -479,144 +494,55 @@ def labels_shape( return (size_z, size_y, size_x) return (size_y, size_x) - def empty_labels( - self, - *, - is_3d: bool, - size_z: int, - size_y: int, - size_x: int, - ) -> np.ndarray: - shape = self.labels_shape( - is_3d=is_3d, - size_z=size_z, - size_y=size_y, - size_x=size_x, - ) - return np.zeros(shape, dtype=np.uint32) - - def should_resume_last_session_prompt(self, last_tracked_num: int) -> bool: - return last_tracked_num > 1 - - - Parameters - ---------- - from_store : bool, optional - If True load the labels array from the stored posData.allData_li, - i.e., from RAM. Default is False - frame_i : int, optional - If None, use the current frame index. Default is None - return_existing : bool, optional - If True, the second return element will be a boolean that - is True if the labels array was found stored in `posData.allData_li`. - Default is False - return_copy : bool, optional - If True returns a copy of the labels array - - Returns - ------- - numpy.ndarray or tuple of (numpy.ndarray, bool) - The first element is the labels array requested. If `return_existing` - is True then this method also returns a second boolean element that - is True if the labels array was found in in `posData.allData_li`. - - Note - ---- - - If `from_store` is True then this method will try to get the stored - labels array. If any error occurs then the returned labels are the - saved ones in the segmentation file (i.e., from hard drive). - - """ - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - existing = True - if from_store: - try: - labels = posData.allData_li[frame_i]['labels'] - if labels is None: - from_store = False - except Exception as err: - from_store = False - - if not from_store: - try: - labels = posData.segm_data[frame_i] - except IndexError: - existing = False - # Visting a frame that was not segmented --> empty masks - labels = self.empty_labels( - is_3d=self.isSegm3D, - size_z=posData.SizeZ, - size_y=posData.SizeY, - size_x=posData.SizeX, - ) - return_copy = False - - if return_copy: - labels = labels.copy() - - if return_existing: - return labels, existing + def loadLastSessionSettings(self): + self.settings_csv_path = settings_csv_path + if os.path.exists(settings_csv_path): + self.df_settings = pd.read_csv(settings_csv_path, index_col="setting") + if "is_bw_inverted" not in self.df_settings.index: + self.df_settings.at["is_bw_inverted", "value"] = "No" + else: + self.df_settings.loc["is_bw_inverted"] = self.df_settings.loc[ + "is_bw_inverted" + ].astype(str) + if "fontSize" not in self.df_settings.index: + self.df_settings.at["fontSize", "value"] = 12 + if "overlayColor" not in self.df_settings.index: + self.df_settings.at["overlayColor", "value"] = "255-255-0" + if "how_normIntensities" not in self.df_settings.index: + raw = "Do not normalize. Display raw image" + self.df_settings.at["how_normIntensities", "value"] = raw else: - return labels + idx = ["is_bw_inverted", "fontSize", "overlayColor", "how_normIntensities"] + values = ["No", 12, "255-255-0", "raw"] + self.df_settings = pd.DataFrame( + {"setting": idx, "value": values} + ).set_index("setting") - def readRecentPaths(self, recent_paths_path=None): - # Step 0. Remove the old options from the menu - self.openRecentMenu.clear() + if "isLabelsVisible" not in self.df_settings.index: + self.df_settings.at["isLabelsVisible", "value"] = "No" - # Step 1. Read recent Paths - if recent_paths_path is None: - recent_paths_path = recentPaths_path + if "isNextFrameVisible" not in self.df_settings.index: + self.df_settings.at["isNextFrameVisible", "value"] = "No" - recentPaths = self.recent_paths(recent_paths_path) + if "isRightImageVisible" not in self.df_settings.index: + self.df_settings.at["isRightImageVisible", "value"] = "Yes" - # Step 2. Dynamically create the actions - actions = [] - for path in recentPaths: - if not os.path.exists(path): - continue - action = QAction(path, self.host) - action.triggered.connect(partial(self.openRecentFile, path)) - actions.append(action) + if "manual_separate_draw_mode" not in self.df_settings.index: + col = "manual_separate_draw_mode" + self.df_settings.at[col, "value"] = "threepoints_arc" - # Step 3. Add the actions to the menu - self.openRecentMenu.addActions(actions) - - def addPathToOpenRecentMenu(self, path): - for action in self.openRecentMenu.actions(): - if path == action.text(): - break - else: - action = QAction(path, self.host) - action.triggered.connect(partial(self.openRecentFile, path)) - - try: - firstAction = self.openRecentMenu.actions()[0] - self.openRecentMenu.insertAction(firstAction, action) - except Exception as e: - pass - - def loadLastSessionSettings(self): - self.settings_csv_path = settings_csv_path - self.df_settings = self.load_settings( - settings_csv_path - ) - - if 'colorScheme' in self.df_settings.index: - col = 'colorScheme' - self._colorScheme = self.df_settings.at[col, 'value'] + if "colorScheme" in self.df_settings.index: + col = "colorScheme" + self._colorScheme = self.df_settings.at[col, "value"] else: - self._colorScheme = 'light' + self._colorScheme = "light" self.doNotShowAgainMissingCca = False - if 'doNotShowAgainMissingCca' not in self.df_settings.index: - self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'No' + if "doNotShowAgainMissingCca" not in self.df_settings.index: + self.df_settings.at["doNotShowAgainMissingCca", "value"] = "No" else: - val = self.df_settings.at['doNotShowAgainMissingCca', 'value'] - self.doNotShowAgainMissingCca = val == 'Yes' + val = self.df_settings.at["doNotShowAgainMissingCca", "value"] + self.doNotShowAgainMissingCca = val == "Yes" def reInitGui(self): cancel = self.checkAskSavePointsLayers() @@ -632,12 +558,12 @@ def reInitGui(self): try: self.navSpinBox.valueChanged.disconnect() - except Exception as e: + except Exception: pass try: self.scaleBar.removeFromAxis(self.ax1) - except Exception as e: + except Exception: pass self.lineage_tree = None @@ -657,7 +583,7 @@ def reInitGui(self): self.reinitWidgetsPos() self.removeAllItems() - self.custom_annotations_view.reinitCustomAnnot() + self.reinitCustomAnnot() self.reinitPointsLayers() self.gui_createPlotItems() self.setUncheckedAllButtons() @@ -677,12 +603,12 @@ def reInitGui(self): self.brushEraserToolBar.hide() self.modeToolBar.hide() - self.modeComboBox.setCurrentText('Viewer') + self.modeComboBox.setCurrentText("Viewer") alpha = self.imgGrad.labelsAlphaSlider.value() self.labelsLayerImg1.setOpacity(alpha) self.labelsLayerRightImg.setOpacity(alpha) - self.lastTrackedFrameLabel.setText('') + self.lastTrackedFrameLabel.setText("") self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False @@ -692,64 +618,176 @@ def reInitGui(self): return True + def readRecentPaths(self, recent_paths_path=None): + # Step 0. Remove the old options from the menu + self.openRecentMenu.clear() + + # Step 1. Read recent Paths + if recent_paths_path is None: + recent_paths_path = recentPaths_path + + if os.path.exists(recent_paths_path): + df = pd.read_csv(recent_paths_path, index_col="index") + df["path"] = df["path"].str.replace("\\", "/") + df = df.drop_duplicates(subset=["path"]) + df.to_csv(recent_paths_path) + if "opened_last_on" in df.columns: + df = df.sort_values("opened_last_on", ascending=False) + recentPaths = df["path"].to_list() + else: + recentPaths = [] + + # Step 2. Dynamically create the actions + actions = [] + for path in recentPaths: + if not os.path.exists(path): + continue + action = QAction(path, self) + action.triggered.connect(partial(self.openRecentFile, path)) + actions.append(action) + + # Step 3. Add the actions to the menu + self.openRecentMenu.addActions(actions) + def reinitWidgetsPos(self): pass - # try: - # # self.highlightZneighObjCheckbox will be connected in - # # self.showHighlightZneighCheckbox() - # self.highlightZneighObjCheckbox.toggled.disconnect() - # except Exception as e: - # pass - # layout = self.bottomLeftLayout - # self.highlightZneighObjCheckbox.hide() - # try: - # layout.removeWidget(self.highlightZneighObjCheckbox) - # except Exception as e: - # pass - # self.highlightZneighObjCheckbox.hide() - # # layout.addWidget( - # # self.drawIDsContComboBox, 0, 1, 1, 2, - # # alignment=Qt.AlignCenter - # # ) - def get_session(self, pos_i=None): - """Return synced :class:`PositionSession` for a loaded position.""" - if pos_i is None: - pos_i = self.pos_i - if not hasattr(self, 'sessions') or pos_i >= len(self.sessions): - return None - return self.sessions[pos_i] + def should_disable_load_position(self, position_count: int) -> bool: + return position_count <= 1 - def _sync_session(self, pos_i): - """Build or refresh ``PositionSession`` from ``loadData`` at ``pos_i``.""" - if not hasattr(self, 'sessions'): - self.sessions = [] - while len(self.sessions) <= pos_i: - self.sessions.append(None) - pos_data = self.data[pos_i] - session = self.position_session_from_load_data(pos_data) - self.sessions[pos_i] = session - if hasattr(self, '_tool_dispatcher') and self._tool_dispatcher is not None: - self._tool_dispatcher.set_context(self._make_tool_context(pos_i)) - return session + def should_resume_last_session_prompt(self, last_tracked_num: int) -> bool: + return last_tracked_num > 1 - def _sync_all_sessions(self): - if not hasattr(self, 'data'): + Parameters + + def should_store_frame_data( + self, + *, + frame_i: int, + mode: str, + enforce: bool, + ) -> bool: + if frame_i < 0: + return False + if mode == "Viewer" and not enforce: + return False + return True + + @exception_handler + def store_data( + self, + pos_i=None, + enforce=True, + debug=False, + mainThread=True, + autosave=True, + store_cca_df_copy=False, + ): + pos_i = self.pos_i if pos_i is None else pos_i + posData = self.data[pos_i] + if posData.frame_i < 0: + # In some cases we set frame_i = -1 and then call next_frame + # to visualize frame 0. In that case we don't store data + # for frame_i = -1 return - self.sessions = [None] * len(self.data) - for pos_i in range(len(self.data)): - self._sync_session(pos_i) - def _sync_session_frame_i(self, pos_i=None): - if pos_i is None: - pos_i = self.pos_i - session = self.get_session(pos_i) - if session is None: + mode = str(self.modeComboBox.currentText()) + + if mode == "Viewer" and not enforce: return - pos_data = self.data[pos_i] - session.frame_i = pos_data.frame_i - if hasattr(self, '_tool_dispatcher') and self._tool_dispatcher is not None: - self._tool_dispatcher.on_frame_changed(self._make_tool_context(pos_i)) + + # if not mainThread: + # self.lin_tree_ask_changes() + + allData_li = posData.allData_li[posData.frame_i] + allData_li["regionprops"] = posData.rp.copy() + allData_li["labels"] = posData.lab.copy() + allData_li["IDs"] = posData.IDs.copy() + allData_li["manualBackgroundLab"] = posData.manualBackgroundLab + allData_li["IDs_idxs"] = posData.IDs_idxs.copy() + if self.manualAnnotPastButton.isChecked(): + self.store_manual_annot_data(posData=posData, data_frame_i=allData_li) + + self.store_zslices_rp() + + # Store dynamic metadata + is_cell_dead_li = [False] * len(posData.rp) + is_cell_excluded_li = [False] * len(posData.rp) + IDs = [0] * len(posData.rp) + xx_centroid = [0] * len(posData.rp) + yy_centroid = [0] * len(posData.rp) + if self.isSegm3D: + zz_centroid = [0] * len(posData.rp) + areManuallyEdited = [0] * len(posData.rp) + editedNewIDs = [vals[2] for vals in posData.editID_info] + for i, obj in enumerate(posData.rp): + is_cell_dead_li[i] = obj.dead + is_cell_excluded_li[i] = obj.excluded + IDs[i] = obj.label + try: + xx_centroid[i] = int(self.getObjCentroid(obj.centroid)[1]) + yy_centroid[i] = int(self.getObjCentroid(obj.centroid)[0]) + except Exception: + printl(obj, obj.centroid, obj.label, posData.frame_i) + if self.isSegm3D: + zz_centroid[i] = int(obj.centroid[0]) + if obj.label in editedNewIDs: + areManuallyEdited[i] = 1 + + posData.STOREDmaxID = max(IDs, default=0) + + acdc_df = allData_li["acdc_df"] + if acdc_df is None: + allData_li["acdc_df"] = pd.DataFrame( + { + "Cell_ID": IDs, + "is_cell_dead": is_cell_dead_li, + "is_cell_excluded": is_cell_excluded_li, + "x_centroid": xx_centroid, + "y_centroid": yy_centroid, + "was_manually_edited": areManuallyEdited, + } + ).set_index("Cell_ID") + + if self.isSegm3D: + allData_li["acdc_df"]["z_centroid"] = zz_centroid + else: + # Filter or add IDs that were not stored yet + acdc_df = acdc_df.drop(columns=["time_seconds"], errors="ignore") + acdc_df = acdc_df.reindex(IDs, fill_value=0) + acdc_df["is_cell_dead"] = is_cell_dead_li + acdc_df["is_cell_excluded"] = is_cell_excluded_li + acdc_df["x_centroid"] = xx_centroid + acdc_df["y_centroid"] = yy_centroid + if self.isSegm3D: + acdc_df["z_centroid"] = zz_centroid + acdc_df["was_manually_edited"] = areManuallyEdited + allData_li["acdc_df"] = acdc_df + + if mainThread: + self.pointsLayerDataToDf(posData) + + self.store_cca_df( + pos_i=pos_i, + mainThread=mainThread, + autosave=autosave, + store_cca_df_copy=store_cca_df_copy, + ) + + def store_manual_annot_data(self, posData=None, data_frame_i=None): + if posData is None: + posData = self.data[self.pos_i] + + if data_frame_i is None: + data_frame_i = posData.allData_li[posData.frame_i] + + if not self.isSegm3D: + lab = [posData.lab] + else: + lab = posData.lab + + for z, lab_2D in enumerate(lab): + data_frame_i["manually_edited_lab"]["lab"][z] = lab_2D def sync_session_labels(self, pos_i=None): """Mirror ``posData.segm_data`` into the parallel ``PositionSession``.""" @@ -757,34 +795,29 @@ def sync_session_labels(self, pos_i=None): pos_i = self.pos_i session = self.get_session(pos_i) pos_data = self.data[pos_i] - if session is None or not hasattr(pos_data, 'segm_data'): + if session is None or not hasattr(pos_data, "segm_data"): return if pos_data.segm_data is not None: session.set_labels(np.asarray(pos_data.segm_data)) - def _init_tool_dispatcher(self): - from cellacdc.tools.dispatch import ToolDispatcher - - if not hasattr(self, '_tool_dispatcher'): - self._tool_dispatcher = ToolDispatcher() - self._tool_dispatcher.set_context(self._make_tool_context(self.pos_i)) - - def _make_tool_context(self, pos_i=None): - from cellacdc.tools.context import GuiToolContext - - if pos_i is None: - pos_i = self.pos_i - return GuiToolContext( - gui=self, - pos_i=pos_i, - pos_data=self.data[pos_i], - session=self.get_session(pos_i), - ) + def unstore_data(self): + posData = self.data[self.pos_i] + posData.allData_li[posData.frame_i] = myutils.get_empty_stored_data_dict() - def _dispatch_tool_event_if_enabled(self, event, phase='press', image='img1'): - from cellacdc.tools.adapters.gui_bridge import dispatch_gui_mouse_event + def updateLastVisitedFrame(self, last_visited_frame_i=None): + if last_visited_frame_i is None: + posData = self.data[self.pos_i] + last_visited_frame_i = posData.frame_i - self._init_tool_dispatcher() - return dispatch_gui_mouse_event( - self._tool_dispatcher, self, event, phase=phase, image=image, - ) \ No newline at end of file + mode = str(self.modeComboBox.currentText()) + if mode == "Viewer": + return + elif mode == "Segmentation and Tracking": + posData = self.data[self.pos_i] + if posData.last_tracked_i >= last_visited_frame_i: + return + posData.last_tracked_i = last_visited_frame_i + elif mode == "Cell cycle analysis": + if self.last_cca_frame_i >= last_visited_frame_i: + return + self.last_cca_frame_i = last_visited_frame_i diff --git a/cellacdc/mixins/status_hover.py b/cellacdc/mixins/status_hover.py index 7a90c5412..1760d0fa2 100644 --- a/cellacdc/mixins/status_hover.py +++ b/cellacdc/mixins/status_hover.py @@ -3,23 +3,57 @@ from __future__ import annotations - import math import os import re -class StatusHoverView: + + +class StatusHoverMixin: """Qt-facing adapter around status/hover view-model contracts.""" """Headless status-bar and hover formatting rules.""" - def channel_hover_text(self, description, channel, value, format_spec): - return f'{description} {channel}: value={value:{format_spec}}' + def active_tool_button(self): + for button in self.LeftClickButtons: + if button.isChecked(): + return button - def object_hover_text(self, *, label_id, max_id, object_count): - return ( - f'Objects: ID={label_id}, max ID={max_id}, ' - f'num. of objects={object_count}' + def add_overlay_hover_values_formatted(self, txt, xdata, ydata): + pos_data = self.data[self.pos_i] + if pos_data.ol_data is None: + return txt + + for filename in pos_data.ol_data: + ch_name = self.view_model.formatting.channel_name_from_basename( + filename, pos_data.basename, remove_ext=False + ) + if ch_name not in self.checkedOverlayChannels: + continue + + raw_overlay_img = self.getRawImage(filename=filename) + raw_overlay_value = raw_overlay_img[ydata, xdata] + raw_txt = self.channel_hover_values("Raw", ch_name, raw_overlay_value) + txt = f"{txt} | {raw_txt}" + return txt + + def add_ruler_measurement_text(self, txt): + pos_data = self.data[self.pos_i] + xx, yy = self.ax1_rulerPlotItem.getData() + if xx is None: + return txt + + length_pixels = self.euclidean_length(xx, yy) + depth_axes = self.switchPlaneCombobox.depthAxes() + if depth_axes != "z": + pixel_to_um = pos_data.PhysicalSizeZ + else: + pixel_to_um = pos_data.PhysicalSizeX + + length_txt = self.ruler_measurement_text( + length_pixels=length_pixels, + pixel_to_um=pixel_to_um, ) + return f"{txt} | Measurement: {length_txt}" def base_hover_text( self, @@ -35,197 +69,84 @@ def base_hover_text( axis_index, ): return ( - f'x={x:d}, y={y:d} | ' - f'W={width:d}, H={height:d} | ' - f'x_left={x_left:d}, y_top={y_top:d} | ' - f'x_right={x_right:d}, y_bottom={y_bottom:d} | ' - f'(ax{axis_index})' - ) - - def replace_view_range_status( - self, - text, - *, - width, - height, - x_left, - y_top, - x_right, - y_bottom, - ): - pattern = ( - r'W=.*?, H=.*? \| ' - r'x_left=.*?, y_top=.*? \| ' - r'x_right=.*?, y_bottom=.*? \| ' - ) - replacing = ( - f'W={width:d}, H={height:d} | ' - f'x_left={x_left:d}, y_top={y_top:d} | ' - f'x_right={x_right:d}, y_bottom={y_bottom:d} | ' - ) - return re.sub(pattern, replacing, text) - - def highlight_state( - self, - *, - x, - y, - bbox, - enabled, - active_tool, - blocked_by_other_highlight=False, - ): - if not enabled or active_tool is not None or blocked_by_other_highlight: - return None - y_min, x_min, y_max, x_max = bbox - return x_min <= x <= x_max and y_min <= y <= y_max - - def mouse_data_coords_right_image(self, text): - if not text: - return None - ax_idx = int(re.findall(r'\(ax(\d)\)', text)[0]) - if ax_idx == 0: - return None - coords = re.findall(r'x=(\d+), y=(\d+) \|', text)[0] - return tuple([int(val) for val in coords]) - - def ruler_length_text(self, text): - length_text = re.findall(r'length = (.*)\)', text)[0] - length_text = length_text.replace('pxl', 'pixels') - return f'{length_text})' - - def ruler_measurement_text(self, *, length_pixels, pixel_to_um): - return ( - f'length = {int(length_pixels)} pxl ' - f'({length_pixels*pixel_to_um:.2f} μm)' - ) - - def euclidean_length(self, x_values, y_values): - return math.sqrt( - (x_values[0]-x_values[1])**2 + (y_values[0]-y_values[1])**2 - ) - - def status_bar_text( - self, - *, - pos_foldername, - basename, - filename, - segm_npz_path, - ): - segmented_channel_name = filename[len(basename):] - segm_filename = os.path.basename(segm_npz_path) - segm_end_name = segm_filename[len(basename):] - return ( - f'{pos_foldername} || ' - f'Basename: {basename} || ' - f'Segmented channel: {segmented_channel_name} || ' - f'Segmentation file name: {segm_end_name}' + f"x={x:d}, y={y:d} | " + f"W={width:d}, H={height:d} | " + f"x_left={x_left:d}, y_top={y_top:d} | " + f"x_right={x_right:d}, y_bottom={y_bottom:d} | " + f"(ax{axis_index})" ) + def channel_hover_text(self, description, channel, value, format_spec): + return f"{description} {channel}: value={value:{format_spec}}" - def __init__(self, host): - self.host = host def channel_hover_values(self, descr, channel, value, ff=None): if ff is None: n_digits = len(str(int(value))) - ff = self.host.view_model.formatting.number_fstring_formatter( - type(value), precision=abs(n_digits-5) + ff = self.view_model.formatting.number_fstring_formatter( + type(value), precision=abs(n_digits - 5) ) return self.channel_hover_text(descr, channel, value, ff) - def add_overlay_hover_values_formatted(self, txt, xdata, ydata): - pos_data = self.host.data[self.host.pos_i] - if pos_data.ol_data is None: - return txt - - for filename in pos_data.ol_data: - ch_name = ( - self.host.view_model.formatting.channel_name_from_basename( - filename, pos_data.basename, remove_ext=False - ) - ) - if ch_name not in self.host.checkedOverlayChannels: - continue - - raw_overlay_img = self.host.getRawImage(filename=filename) - raw_overlay_value = raw_overlay_img[ydata, xdata] - raw_txt = self.channel_hover_values( - 'Raw', ch_name, raw_overlay_value - ) - txt = f'{txt} | {raw_txt}' - return txt - - def active_tool_button(self): - for button in self.host.LeftClickButtons: - if button.isChecked(): - return button - - def concat_acdc_df(self): - pos_data = self.host.data[self.host.pos_i] - return self.host.view_model.frame_metadata.concat_visited_acdc_frames( - pos_data.allData_li - ) - - def check_highlight_timestamp(self, x, y, active_tool_button): - if not hasattr(self.host, 'timestamp'): + def check_highlight_scale_bar(self, x, y, active_tool_button): + if not hasattr(self, "scaleBar"): return - blocked_by_scale_bar = ( - hasattr(self.host, 'scaleBar') - and self.host.scaleBar.isHighlighted() - ) highlighted = self.highlight_state( x=x, y=y, - bbox=self.host.timestamp.bbox(), - enabled=self.host.addTimestampAction.isChecked(), + bbox=self.scaleBar.bbox(), + enabled=self.addScaleBarAction.isChecked(), active_tool=active_tool_button, - blocked_by_other_highlight=blocked_by_scale_bar, ) if highlighted is None: return - self.host.timestamp.setHighlighted(highlighted) + self.scaleBar.setHighlighted(highlighted) - def check_highlight_scale_bar(self, x, y, active_tool_button): - if not hasattr(self.host, 'scaleBar'): + def check_highlight_timestamp(self, x, y, active_tool_button): + if not hasattr(self, "timestamp"): return + blocked_by_scale_bar = ( + hasattr(self, "scaleBar") and self.scaleBar.isHighlighted() + ) highlighted = self.highlight_state( x=x, y=y, - bbox=self.host.scaleBar.bbox(), - enabled=self.host.addScaleBarAction.isChecked(), + bbox=self.timestamp.bbox(), + enabled=self.addTimestampAction.isChecked(), active_tool=active_tool_button, + blocked_by_other_highlight=blocked_by_scale_bar, ) if highlighted is None: return - self.host.scaleBar.setHighlighted(highlighted) + self.timestamp.setHighlighted(highlighted) - def mouse_data_coords_right_image(self): - return self.mouse_data_coords_right_image( - self.host.wcLabel.text() + def concat_acdc_df(self): + pos_data = self.data[self.pos_i] + return self.view_model.frame_metadata.concat_visited_acdc_frames( + pos_data.allData_li ) - def update_values_status_bar(self): - (xl, xr), (yt, yb) = ( - self.host.display_decorations_view.ax1_view_range(integers=True) - ) - width = round(xr - xl) - height = round(yb - yt) - txt = self.replace_view_range_status( - self.host.wcLabel.text(), - width=width, - height=height, - x_left=xl, - y_top=yt, - x_right=xr, - y_bottom=yb, + def euclidean_length(self, x_values, y_values): + return math.sqrt( + (x_values[0] - x_values[1]) ** 2 + (y_values[0] - y_values[1]) ** 2 ) - self.host.wcLabel.setText(txt) + + def highlight_state( + self, + *, + x, + y, + bbox, + enabled, + active_tool, + blocked_by_other_highlight=False, + ): + if not enabled or active_tool is not None or blocked_by_other_highlight: + return None + y_min, x_min, y_max, x_max = bbox + return x_min <= x <= x_max and y_min <= y <= y_max def hover_values_formatted(self, xdata, ydata, active_tool_button, is_ax0): - (xl, xr), (yt, yb) = ( - self.host.display_decorations_view.ax1_view_range(integers=True) - ) + (xl, xr), (yt, yb) = self.display_decorations_view.ax1_view_range(integers=True) width = round(xr - xl) height = round(yb - yt) axis_index = 0 if is_ax0 else 1 @@ -240,54 +161,70 @@ def hover_values_formatted(self, xdata, ydata, active_tool_button, is_ax0): y_bottom=yb, axis_index=axis_index, ) - if active_tool_button == self.host.rulerButton: + if active_tool_button == self.rulerButton: return self.add_ruler_measurement_text(txt) if active_tool_button is not None: return txt - pos_data = self.host.data[self.host.pos_i] - raw_img = self.host.getRawImage() + pos_data = self.data[self.pos_i] + raw_img = self.getRawImage() raw_value = raw_img[ydata, xdata] - raw_txt = self.channel_hover_values( - 'Raw', self.host.user_ch_name, raw_value - ) - txt = f'{txt} | {raw_txt}' + raw_txt = self.channel_hover_values("Raw", self.user_ch_name, raw_value) + txt = f"{txt} | {raw_txt}" txt = self.add_overlay_hover_values_formatted(txt, xdata, ydata) - label_id = self.host.currentLab2D[ydata, xdata] + label_id = self.currentLab2D[ydata, xdata] label_txt = self.object_hover_text( label_id=label_id, max_id=max(pos_data.IDs, default=0), object_count=len(pos_data.IDs), ) - txt = f'{txt} | {label_txt}' + txt = f"{txt} | {label_txt}" return self.add_ruler_measurement_text(txt) - def ruler_length_text(self): - return self.ruler_length_text(self.host.wcLabel.text()) + def mouse_data_coords_right_image(self): + return self.mouse_data_coords_right_image(self.wcLabel.text()) - def add_ruler_measurement_text(self, txt): - pos_data = self.host.data[self.host.pos_i] - xx, yy = self.host.ax1_rulerPlotItem.getData() - if xx is None: - return txt + def object_hover_text(self, *, label_id, max_id, object_count): + return ( + f"Objects: ID={label_id}, max ID={max_id}, " + f"num. of objects={object_count}" + ) - length_pixels = self.euclidean_length(xx, yy) - depth_axes = self.host.switchPlaneCombobox.depthAxes() - if depth_axes != 'z': - pixel_to_um = pos_data.PhysicalSizeZ - else: - pixel_to_um = pos_data.PhysicalSizeX + def replace_view_range_status( + self, + text, + *, + width, + height, + x_left, + y_top, + x_right, + y_bottom, + ): + pattern = ( + r"W=.*?, H=.*? \| " + r"x_left=.*?, y_top=.*? \| " + r"x_right=.*?, y_bottom=.*? \| " + ) + replacing = ( + f"W={width:d}, H={height:d} | " + f"x_left={x_left:d}, y_top={y_top:d} | " + f"x_right={x_right:d}, y_bottom={y_bottom:d} | " + ) + return re.sub(pattern, replacing, text) - length_txt = self.ruler_measurement_text( - length_pixels=length_pixels, - pixel_to_um=pixel_to_um, + def ruler_length_text(self): + return self.ruler_length_text(self.wcLabel.text()) + + def ruler_measurement_text(self, *, length_pixels, pixel_to_um): + return ( + f"length = {int(length_pixels)} pxl ({length_pixels * pixel_to_um:.2f} μm)" ) - return f'{txt} | Measurement: {length_txt}' def set_status_bar_label(self, log=True): - self.host.statusbar.clearMessage() - pos_data = self.host.data[self.host.pos_i] + self.statusbar.clearMessage() + pos_data = self.data[self.pos_i] txt = self.status_bar_text( pos_foldername=pos_data.pos_foldername, basename=pos_data.basename, @@ -295,5 +232,38 @@ def set_status_bar_label(self, log=True): segm_npz_path=pos_data.segm_npz_path, ) if log: - self.host.logger.info(txt) - self.host.statusBarLabel.setText(txt) \ No newline at end of file + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + def status_bar_text( + self, + *, + pos_foldername, + basename, + filename, + segm_npz_path, + ): + segmented_channel_name = filename[len(basename) :] + segm_filename = os.path.basename(segm_npz_path) + segm_end_name = segm_filename[len(basename) :] + return ( + f"{pos_foldername} || " + f"Basename: {basename} || " + f"Segmented channel: {segmented_channel_name} || " + f"Segmentation file name: {segm_end_name}" + ) + + def update_values_status_bar(self): + (xl, xr), (yt, yb) = self.display_decorations_view.ax1_view_range(integers=True) + width = round(xr - xl) + height = round(yb - yt) + txt = self.replace_view_range_status( + self.wcLabel.text(), + width=width, + height=height, + x_left=xl, + y_top=yt, + x_right=xr, + y_bottom=yb, + ) + self.wcLabel.setText(txt) diff --git a/cellacdc/mixins/tables.py b/cellacdc/mixins/tables.py index 09458f1b0..ffa505327 100644 --- a/cellacdc/mixins/tables.py +++ b/cellacdc/mixins/tables.py @@ -7,7 +7,7 @@ from cellacdc.myutils import checked_reset_index_Cell_ID, fix_acdc_df_dtypes -class TableViewModel: +class TableMixin: """Application-facing commands for dataframe normalization.""" def checked_reset_index_cell_id(self, dataframe: pd.DataFrame) -> pd.DataFrame: diff --git a/cellacdc/mixins/tool_activation.py b/cellacdc/mixins/tool_activation.py index 07fd51adb..cb79574e8 100644 --- a/cellacdc/mixins/tool_activation.py +++ b/cellacdc/mixins/tool_activation.py @@ -9,292 +9,361 @@ from cellacdc import disableWindow -class ToolActivationView: +class ToolActivationMixin: """Qt-facing adapter around active-tool workflows.""" """Headless decisions for active-tool and hover workflows.""" - def manual_annotation_highlight_color( - self, - *, - current_frame_i: int, - frame_to_restore: int | None, - ) -> str: - if current_frame_i == frame_to_restore: - return 'green' - if frame_to_restore is not None and current_frame_i < frame_to_restore: - return 'gold' - return 'red' + # @exec_time - def should_highlight_hover_lost_object( - self, - *, - has_no_modifier: bool, - copy_lost_object_checked: bool, - is_exit_event: bool, - ) -> bool: - return ( - has_no_modifier - and copy_lost_object_checked - and not is_exit_event - ) + # @exec_time - def point_in_shape(self, x: int, y: int, shape: tuple[int, int]) -> bool: - height, width = shape - return x >= 0 and x < width and y >= 0 and y < height + def _copyAllLostObjects_navigateToFrame(self, frame_i): + posData = self.data[self.pos_i] + self.store_data(mainThread=False, autosave=False) - def should_hide_hover_objects( - self, - *, - brush_auto_hide_checked: bool, - force: bool, - ) -> bool: - return brush_auto_hide_checked or force + posData.frame_i = frame_i + self.get_data() + self.tracking(wl_update=False) + self.currentLab2D = self.get_2Dlab(posData.lab) + self.update_rp() + self.updateLostNewCurrentIDs() + self.store_data(mainThread=False, autosave=False) - def should_disable_non_functional_buttons(self, is_segm_3d: bool) -> bool: - return is_segm_3d + self.lostObjContoursImage[:] = 0 + self.lostObjImage[:] = 0 + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[frame_i - 1][ + "IDs_idxs" + ] # need to change this when merging with opt. + for lostID in posData.lost_IDs: + obj = prev_rp[prev_IDs_idxs[lostID]] + self.addLostObjsToLostObjImage(obj, lostID, force=True) + def _copyAllLostObjects_refreshRp(self): + self.update_rp( + draw=False, wl_update=False + ) # need to change this when merging with opt. - LEGACY_METHODS = ( - 'uncheckQButton', - 'setUncheckedPointsLayers', - 'setUncheckedAllButtons', - 'setUncheckedAllCustomAnnotButtons', - 'onEscape', - 'clearTempBrushImage', - 'disconnectLeftClickButtons', - 'uncheckLeftClickButtons', - 'connectLeftClickButtonsPointsLayersToolbar', - 'connectLeftClickButtons', - 'wand_cb', - 'magicPrompts_cb', - 'copyLostObjContour_cb', - 'manualAnnotPast_cb', - 'copyLostObjectMask', - 'highlightManualAnnotMode', - 'updateHighlightedAxis', - 'updateLostNewCurrentIDs', - 'highlightLostNew', - 'addLostObjsToLostObjImage', - 'highlightHoverLostObj', - 'annotLostObjsToggled', - 'getPrevFrameIDs', - 'setLostNewOldPrevIDs', - 'setTitleFormatter', - 'setTitleText', - '_copyAllLostObjects_navigateToFrame', - '_copyAllLostObjects_returnToFrame', - '_copyAllLostObjects_refreshRp', - 'copyAllLostObjects', - 'copyAllLostObjectsWorkerCritical', - 'copyAllLostObjectsWorkerFinished', - 'restoreHoverObjBrush', - 'hideItemsHoverBrush', - 'updateBrushCursor', - 'setManualAnnotModeEnabledTools', - 'disableNonFunctionalButtons', - ) + def _copyAllLostObjects_returnToFrame(self, frame_i): + posData = self.data[self.pos_i] + self.store_data(autosave=False, mainThread=False) + posData.frame_i = frame_i + self.get_data() - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) + def addLostObjsToLostObjImage(self, lostObj, lostID, force=False): + if not force: + if not self.copyLostObjButton.isChecked(): + return - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) + obj_slice = self.getObjSlice(lostObj.slice) + obj_image = self.getObjImage(lostObj.image, lostObj.bbox) + self.lostObjImage[obj_slice][obj_image] = lostID - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + def annotLostObjsToggled(self, checked): + if not self.isDataLoaded: + return + self.updateAllImages() - def uncheckQButton(self, button): - # Manual exclusive where we allow to uncheck all buttons - for b in self.checkableQButtonsGroup.buttons(): - if b != button: - b.setChecked(False) + def clearTempBrushImage(self, forceClearLinked=True): + if not hasattr(self, "tempLayerImg1"): + return - def setUncheckedPointsLayers(self): - self.togglePointsLayerAction.setChecked(False) - self.magicPromptsToolButton.setChecked(False) + self.tempLayerImg1.setImage(self.emptyLab, force_set_linked=forceClearLinked) - def setUncheckedAllButtons(self, buttonsToNotUncheck=None): - self.clickedOnBud = False - if buttonsToNotUncheck is None: - buttonsToNotUncheck = set() + try: + self.brushContourImage[:] = 0 + except Exception: + pass try: - self.BudMothTempLine.setData([], []) - except Exception as e: + self.brushImage[:] = 0 + except Exception: pass - for button in self.checkableButtons: - if button in buttonsToNotUncheck: - continue - button.setChecked(False) - if self.countObjsButton not in buttonsToNotUncheck: - self.countObjsButton.setChecked(False) - self.splineHoverON = False - self.tempSegmentON = False - self.isRightClickDragImg1 = False - self.curvature_tools_view.clearCurvItems(removeItems=False) + def connectLeftClickButtons(self): + self.brushButton.toggled.connect(self.Brush_cb) + self.curvToolButton.toggled.connect(self.curvTool_cb) + self.rulerButton.toggled.connect(self.ruler_cb) + self.eraserButton.toggled.connect(self.Eraser_cb) + self.wandToolButton.toggled.connect(self.wand_cb) + self.labelRoiButton.toggled.connect(self.labelRoi_cb) + self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) + self.drawClearRegionButton.toggled.connect(self.drawClearRegion_cb) + self.expandLabelToolButton.toggled.connect(self.expandLabelCallback) + self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) + self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) + self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) + self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) + self.connectLeftClickButtonsPointsLayersToolbar() - def setUncheckedAllCustomAnnotButtons(self): - for button in self.customAnnotDict.keys(): - button.setChecked(False) + def connectLeftClickButtonsPointsLayersToolbar(self): + for toolbar in self.pointsLayersToolbars: + for action in toolbar.actions()[1:]: + if not hasattr(action, "layerTypeIdx"): + continue + if action.layerTypeIdx != 4: + continue + action.button.toggled.connect(self.addPointsByClickingButtonToggled) - def onEscape( - self, - isTypingIDFunctionChecked=False, - buttonsToNotUncheck=None, - doAutoRange=True - ): - if buttonsToNotUncheck is None: - buttonsToNotUncheck = set() + @disableWindow + def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): + if not self.copyLostObjButton.isChecked(): + return - if self.keepIDsButton.isChecked() and self.keptObjectsIDs: - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction + posData = self.data[self.pos_i] + + desc = "Copying all lost objects..." + + self.progressWin = apps.QDialogWorkerProgress( + title=desc, parent=self.mainWin, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(for_future_frame_n + 1) + self.progressWin.show(self.app) + + self.copyAllLostObjectsThread = QThread() + + self.copyAllLostObjectsWorker = workers.CopyAllLostObjectsWorker( + self, posData, for_future_frame_n, max_overlap_perc + ) + self.copyAllLostObjectsWorker.moveToThread(self.copyAllLostObjectsThread) + + self.copyAllLostObjectsWorker.navigateToFrame.connect( + self._copyAllLostObjects_navigateToFrame, Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.returnToFrame.connect( + self._copyAllLostObjects_returnToFrame, Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.copyLostObjectMask.connect( + self.copyLostObjectMask, Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.refreshRp.connect( + self._copyAllLostObjects_refreshRp, Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.progressBar.connect(self.workerUpdateProgressbar) + self.copyAllLostObjectsWorker.critical.connect( + self.copyAllLostObjectsWorkerCritical + ) + self.copyAllLostObjectsWorker.finished.connect( + self.copyAllLostObjectsThread.quit + ) + self.copyAllLostObjectsWorker.finished.connect( + self.copyAllLostObjectsWorker.deleteLater + ) + self.copyAllLostObjectsThread.finished.connect( + self.copyAllLostObjectsThread.deleteLater + ) + self.copyAllLostObjectsWorker.finished.connect( + self.copyAllLostObjectsWorkerFinished + ) + + self.copyAllLostObjectsThread.started.connect(self.copyAllLostObjectsWorker.run) + self.copyAllLostObjectsThread.start() + + self.copyAllLostObjectsWorkerLoop = QEventLoop() + self.copyAllLostObjectsWorkerLoop.exec_() + + def copyAllLostObjectsWorkerCritical(self, error): + self.copyAllLostObjectsWorkerLoop.exit() + self.workerCritical(error) + + def copyAllLostObjectsWorkerFinished(self, output): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + if output.get("doReinitLastSegmFrame", False): + self.reInitLastSegmFrame( + from_frame_i=output.get("last_visited_frame_i"), + updateImages=False, + force=True, ) - self.highlightHoverIDsKeptObj(0, 0, hoverID=0) - QTimer.singleShot(300, self.autoRange) - return - if self.brushButton.isChecked() and self.typingEditID: - self.autoIDcheckbox.setChecked(True) - self.typingEditID = False - QTimer.singleShot(300, self.autoRange) + if output.get("overlap_warning", False): + self.blinker = qutils.QControlBlink( + self.copyLostObjToolbar.maxOverlapNumberControl, qparent=self.mainWin + ) + self.blinker.start() + + self.copyAllLostObjectsWorkerLoop.exit() + self.update_rp() + self.updateAllImages() + self.store_data() + + def copyLostObjContour_cb(self, checked): + self.copyLostObjToolbar.setVisible(checked) + + self.ax1_lostObjScatterItem.hoverLostID = 0 + if not checked: return - if isTypingIDFunctionChecked and self.typingEditID: - self.typingEditID = False - QTimer.singleShot(300, self.autoRange) + self.lostObjImage = np.zeros_like(self.currentLab2D) + self.updateLostContoursImage(0) + + def copyLostObjectMask(self, ID: int): + posData = self.data[self.pos_i] + mask = self.lostObjImage == ID + lab2D = self.get_2Dlab(posData.lab) + lab2D[mask] = ID + self.lostObjImage[mask] = 0 + self.set_2Dlab(lab2D) + + def disableNonFunctionalButtons(self): + if not self.isSegm3D: return - if self.labelRoiButton.isChecked() and self.isMouseDragImg1: - self.isMouseDragImg1 = False - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) - self.freeRoiItem.clear() - QTimer.singleShot(300, self.autoRange) + for item in self.functionsNotTested3D: + if hasattr(item, "action"): + toolButton = item + action = toolButton.action + toolButton.setDisabled(True) + elif hasattr(item, "toolbar"): + toolbar = item.toolbar + action = item + toolButton = toolbar.widgetForAction(action) + toolButton.setDisabled(True) + else: + action = item + action.setDisabled(True) + + def disconnectLeftClickButtons(self): + for button in self.LeftClickButtons: + try: + button.toggled.disconnect() + except Exception: + # Not all the LeftClickButtons have toggled connected + pass + + def getPrevFrameIDs(self, current_frame_i=None): + posData = self.data[self.pos_i] + if current_frame_i is None: + current_frame_i = posData.frame_i + + if current_frame_i is None: + return [] + + prev_frame_i = current_frame_i - 1 + prevIDs = posData.allData_li[prev_frame_i]["IDs"] + + if prevIDs: + return prevIDs + + # IDs in previous frame were not stored --> load prev lab from HDD + prev_lab = self.get_labels( + from_store=False, frame_i=prev_frame_i, return_copy=False + ) + rp = skimage.measure.regionprops(prev_lab) + prevIDs = [obj.label for obj in rp] + return prevIDs + + def hideItemsHoverBrush(self, xy=None, ID=None, force=False): + if xy is not None: + x, y = xy + if x is None: + return + + xdata, ydata = int(x), int(y) + Y, X = self.currentLab2D.shape + + if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): + return + + if not self.brushAutoHideCheckbox.isChecked() and not force: return - if self.zoomRectButton.isChecked(): - self.zoomRectCancelled() - QTimer.singleShot(300, self.autoRange) + posData = self.data[self.pos_i] + self.brushSizeSpinbox.value() * 2 + + if xy is not None: + ID = self.get_2Dlab(posData.lab)[ydata, xdata] + + if self.ax1_lostObjScatterItem.isVisible(): + self.ax1_lostObjScatterItem.setVisible(False) + + if self.ax1_lostTrackedScatterItem.isVisible(): + self.ax1_lostTrackedScatterItem.setVisible(False) + + if self.ax2_lostObjScatterItem.isVisible(): + self.ax2_lostObjScatterItem.setVisible(False) + + if self.ax2_lostTrackedScatterItem.isVisible(): + self.ax2_lostTrackedScatterItem.setVisible(False) + + # Restore ID previously hovered + if ID != self.ax1BrushHoverID and not self.isMouseDragImg1: + try: + self.restoreHoverObjBrush() + except Exception: + self.ax1BrushHoverID = 0 + return + + # Hide items hover ID + if ID != 0: + self.clearObjContour(ID=ID, ax=0) + self.clearObjContour(ID=ID, ax=1) + self.ax1BrushHoverID = ID + else: + self.ax1BrushHoverID = 0 + + def highlightHoverLostObj(self, modifiers, event): + noModifier = modifiers == Qt.NoModifier + if not noModifier: return - self.setUncheckedAllButtons(buttonsToNotUncheck=buttonsToNotUncheck) - self.setUncheckedAllCustomAnnotButtons() - self.setUncheckedPointsLayers() - self.clearTempBrushImage() - self.isMouseDragImg1 = False - self.typingEditID = False - self.clearHighlightedID() - try: - self.polyLineRoi.clearPoints() - except Exception as e: - pass + if not self.copyLostObjButton.isChecked(): + return - if doAutoRange: - QTimer.singleShot(11, self.autoRange) + if event.isExit(): + return - def clearTempBrushImage(self, forceClearLinked=True): - if not hasattr(self, 'tempLayerImg1'): + posData = self.data[self.pos_i] + x, y = event.pos() + xdata, ydata = int(x), int(y) + try: + hoverLostID = self.lostObjImage[ydata, xdata] + except IndexError: return - self.tempLayerImg1.setImage( - self.emptyLab, force_set_linked=forceClearLinked - ) - - try: - self.brushContourImage[:] = 0 - except Exception as err: - pass + self.ax1_lostObjScatterItem.hoverLostID = hoverLostID + if hoverLostID == 0: + self.ax1_lostObjScatterItem.setSize(self.contLineWeight + 1) + self.ax1_lostObjScatterItem.setData([], []) + else: + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[posData.frame_i - 1]["IDs_idxs"] + lostObj = prev_rp[prev_IDs_idxs[hoverLostID]] + obj_contours = self.getObjContours(lostObj, all_external=True) + for cont in obj_contours: + xx = cont[:, 0] + yy = cont[:, 1] + self.ax1_lostObjScatterItem.addPoints(xx, yy) + self.ax1_lostObjScatterItem.setSize(self.contLineWeight + 2) - try: - self.brushImage[:] = 0 - except Exception as err: - pass + def highlightLostNew(self): + if self.modeComboBox.currentText() == "Viewer": + return - def disconnectLeftClickButtons(self): - for button in self.LeftClickButtons: - try: - button.toggled.disconnect() - except Exception as e: - # Not all the LeftClickButtons have toggled connected - pass + posData = self.data[self.pos_i] + delROIsIDs = self.getDelRoisIDs() - def uncheckLeftClickButtons(self, sender): - for button in self.LeftClickButtons: - if button != sender: - button.setChecked(False) + # self.setAllContoursImages(delROIsIDs=delROIsIDs) + if posData.frame_i == 0: + return - if button != self.labelRoiButton: - # self.labelRoiButton is disconnected so we manually call uncheck - self.labelRoi_cb(False) - self.secondLevelToolbar.setVisible(True) - for toolbar in self.controlToolBars: - try: - toolbar.keepVisibleWhenActive - if toolbar.isVisible(): - self.secondLevelToolbar.setVisible(False) - continue - except: - pass - toolbar.setVisible(False) + if not self.annotLostObjsToggle.isChecked(): + return - self.mode_controls_view.enableSizeSpinbox(False) - if sender is not None: - self.keepIDsButton.setChecked(False) + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] - def connectLeftClickButtonsPointsLayersToolbar(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - if action.layerTypeIdx != 4: - continue - action.button.toggled.connect( - self.addPointsByClickingButtonToggled - ) + if prev_rp is None: + return - def connectLeftClickButtons(self): - self.brushButton.toggled.connect(self.Brush_cb) - self.curvToolButton.toggled.connect( - self.curvature_tools_view.curvTool_cb - ) - self.rulerButton.toggled.connect(self.ruler_cb) - self.eraserButton.toggled.connect(self.Eraser_cb) - self.wandToolButton.toggled.connect(self.wand_cb) - self.labelRoiButton.toggled.connect(self.labelRoi_cb) - self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) - self.drawClearRegionButton.toggled.connect( - self.draw_clear_region_view.toggle - ) - self.expandLabelToolButton.toggled.connect( - self.label_transform_tools_view.expand_label_callback - ) - self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) - self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) - self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) - self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) - self.connectLeftClickButtonsPointsLayersToolbar() + self.setAllLostObjContoursImage(delROIsIDs=delROIsIDs) + self.setAllLostTrackedObjContoursImage(delROIsIDs=delROIsIDs) - def wand_cb(self, checked): - posData = self.data[self.pos_i] - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.wandToolButton) - self.connectLeftClickButtons() - self.wandControlsToolbar.setVisible(True) - # self.secondLevelToolbar.setVisible(False) - else: - self.resetCursors() - # self.secondLevelToolbar.setVisible(True) - self.wandControlsToolbar.setVisible(False) + def highlightManualAnnotMode(self, viewBox, viewRange): + self.ax1.setHighlighted(True) def magicPrompts_cb(self, checked): if checked: @@ -312,43 +381,32 @@ def magicPrompts_cb(self, checked): self.promptSegmentPointsLayerToolbar.setVisible(False) self.magicPromptsToolbar.setVisible(False) - def copyLostObjContour_cb(self, checked): - self.copyLostObjToolbar.setVisible(checked) - - self.ax1_lostObjScatterItem.hoverLostID = 0 - if not checked: - return - - self.lostObjImage = np.zeros_like(self.currentLab2D) - self.updateLostContoursImage(0) - def manualAnnotPast_cb(self, checked): posData = self.data[self.pos_i] if checked: for _ in range(3): self.onEscape( - buttonsToNotUncheck=[self.manualAnnotPastButton], - doAutoRange=False + buttonsToNotUncheck=[self.manualAnnotPastButton], doAutoRange=False ) self.brushButton.setChecked(True) self.store_data() self.manualAnnotState = { - 'editID': self.editIDspinbox.value(), - 'isAutoID': self.autoIDcheckbox.isChecked(), - 'doWarnLostObj': self.warnLostCellsAction.isChecked(), + "editID": self.editIDspinbox.value(), + "isAutoID": self.autoIDcheckbox.isChecked(), + "doWarnLostObj": self.warnLostCellsAction.isChecked(), } self.autoIDcheckbox.setChecked(False) self.warnLostCellsAction.setChecked(False) hoverID = self.getLastHoveredID() if hoverID == 0: win = apps.QLineEditDialog( - title='Not hovering any ID', - msg='You are not hovering on any ID.\n' - 'Enter the ID that you want to lock.', - parent=self.host, + title="Not hovering any ID", + msg="You are not hovering on any ID.\n" + "Enter the ID that you want to lock.", + parent=self, isInteger=True, - defaultTxt=self.setBrushID(return_val=True) + defaultTxt=self.setBrushID(return_val=True), ) win.exec_() if win.cancel: @@ -356,44 +414,42 @@ def manualAnnotPast_cb(self, checked): return hoverID = win.EntryID self.logger.info( - 'Setting manual annotation for ID = ' - f'{hoverID}, at frame n. {posData.frame_i+1}' + "Setting manual annotation for ID = " + f"{hoverID}, at frame n. {posData.frame_i + 1}" ) self.editIDspinbox.setValue(hoverID) try: obj_idx = posData.IDs_idxs[hoverID] obj = posData.rp[obj_idx] - radius = 0.9 * obj.minor_axis_length / 2 # math.sqrt(obj.area/math.pi)*0.9 + radius = ( + 0.9 * obj.minor_axis_length / 2 + ) # math.sqrt(obj.area/math.pi)*0.9 self.brushSizeSpinbox.setValue(round(radius)) - except Exception as err: + except Exception: pass - self.manualAnnotState['frame_i_to_restore'] = posData.frame_i - self.manualAnnotState['last_tracked_i'] = ( - self.navigateScrollBar.maximum()-1 + self.manualAnnotState["frame_i_to_restore"] = posData.frame_i + self.manualAnnotState["last_tracked_i"] = ( + self.navigateScrollBar.maximum() - 1 ) self.ax1.sigRangeChanged.connect(self.highlightManualAnnotMode) - self.ax1.setHighlighted(True, color='green') + self.ax1.setHighlighted(True, color="green") else: - self.status_hover_view.set_status_bar_label() - self.autoIDcheckbox.setChecked(self.manualAnnotState['isAutoID']) - self.editIDspinbox.setValue(self.manualAnnotState['editID']) - self.warnLostCellsAction.setChecked( - self.manualAnnotState['doWarnLostObj'] - ) - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + self.setStatusBarLabel() + self.autoIDcheckbox.setChecked(self.manualAnnotState["isAutoID"]) + self.editIDspinbox.setValue(self.manualAnnotState["editID"]) + self.warnLostCellsAction.setChecked(self.manualAnnotState["doWarnLostObj"]) + frame_to_restore = self.manualAnnotState.get("frame_i_to_restore") if frame_to_restore is None: return self.store_data() self.store_manual_annot_data() - last_tracked_i_to_restore = self.manualAnnotState['last_tracked_i'] + last_tracked_i_to_restore = self.manualAnnotState["last_tracked_i"] self.manualAnnotRestoreLastTrackedFrame(last_tracked_i_to_restore) - self.logger.info( - f'Restoring view to frame n. {posData.frame_i+1}...' - ) + self.logger.info(f"Restoring view to frame n. {posData.frame_i + 1}...") posData.frame_i = frame_to_restore self.get_data() self.updateAllImages() @@ -404,147 +460,89 @@ def manualAnnotPast_cb(self, checked): self.setManualAnnotModeEnabledTools(checked) - def copyLostObjectMask(self, ID: int): - posData = self.data[self.pos_i] - mask = self.lostObjImage == ID - lab2D = self.get_2Dlab(posData.lab) - lab2D[mask] = ID - self.lostObjImage[mask] = 0 - self.set_2Dlab(lab2D) - - def highlightManualAnnotMode(self, viewBox, viewRange): - self.ax1.setHighlighted(True) - - def updateHighlightedAxis(self): - if not self.manualAnnotPastButton.isChecked(): - return - - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - posData = self.data[self.pos_i] - color = self.manual_annotation_highlight_color( - current_frame_i=posData.frame_i, - frame_to_restore=frame_to_restore, - ) - - self.ax1.setHighlightingRectItemsColor(color) - - def updateLostNewCurrentIDs(self): - posData = self.data[self.pos_i] - - prev_IDs = self.getPrevFrameIDs() - tracked_lost_IDs = self.getTrackedLostIDs() - curr_IDs = posData.IDs - curr_delRoiIDs = self.getStoredDelRoiIDs() - prev_delRoiIDs = self.getStoredDelRoiIDs(frame_i=posData.frame_i-1) - result = self.tracking.compute_lost_new_ids( - prev_IDs, - curr_IDs, - current_deleted_roi_ids=curr_delRoiIDs, - previous_deleted_roi_ids=prev_delRoiIDs, - tracked_lost_ids=tracked_lost_IDs, - ) - posData.lost_IDs = result.lost_ids - posData.new_IDs = result.new_ids - posData.old_IDs = prev_IDs - posData.IDs = curr_IDs + def manual_annotation_highlight_color( + self, + *, + current_frame_i: int, + frame_to_restore: int | None, + ) -> str: + if current_frame_i == frame_to_restore: + return "green" + if frame_to_restore is not None and current_frame_i < frame_to_restore: + return "gold" + return "red" - out = ( - result.lost_ids, result.new_ids, result.ids_with_holes, - tracked_lost_IDs, curr_delRoiIDs - ) - return out + def onEscape( + self, + isTypingIDFunctionChecked=False, + buttonsToNotUncheck=None, + doAutoRange=True, + ): + if buttonsToNotUncheck is None: + buttonsToNotUncheck = set() - # @exec_time - def highlightLostNew(self): - if self.modeComboBox.currentText() == 'Viewer': + if self.keepIDsButton.isChecked() and self.keptObjectsIDs: + self.keptObjectsIDs = widgets.KeptObjectIDsList( + self.keptIDsLineEdit, self.keepIDsConfirmAction + ) + self.highlightHoverIDsKeptObj(0, 0, hoverID=0) + QTimer.singleShot(300, self.autoRange) return - posData = self.data[self.pos_i] - delROIsIDs = self.getDelRoisIDs() - - # self.setAllContoursImages(delROIsIDs=delROIsIDs) - if posData.frame_i == 0: + if self.brushButton.isChecked() and self.typingEditID: + self.autoIDcheckbox.setChecked(True) + self.typingEditID = False + QTimer.singleShot(300, self.autoRange) return - if not self.annotLostObjsToggle.isChecked(): + if isTypingIDFunctionChecked and self.typingEditID: + self.typingEditID = False + QTimer.singleShot(300, self.autoRange) return - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - - if prev_rp is None: + if self.labelRoiButton.isChecked() and self.isMouseDragImg1: + self.isMouseDragImg1 = False + self.labelRoiItem.setPos((0, 0)) + self.labelRoiItem.setSize((0, 0)) + self.freeRoiItem.clear() + QTimer.singleShot(300, self.autoRange) return - self.setAllLostObjContoursImage(delROIsIDs=delROIsIDs) - self.setAllLostTrackedObjContoursImage(delROIsIDs=delROIsIDs) - - def addLostObjsToLostObjImage(self, lostObj, lostID, force=False): - if not force: - if not self.copyLostObjButton.isChecked(): - return - - obj_slice = self.getObjSlice(lostObj.slice) - obj_image = self.getObjImage(lostObj.image, lostObj.bbox) - self.lostObjImage[obj_slice][obj_image] = lostID - - def highlightHoverLostObj(self, modifiers, event): - if not self.should_highlight_hover_lost_object( - has_no_modifier=modifiers == Qt.NoModifier, - copy_lost_object_checked=self.copyLostObjButton.isChecked(), - is_exit_event=event.isExit(), - ): + if self.zoomRectButton.isChecked(): + self.zoomRectCancelled() + QTimer.singleShot(300, self.autoRange) return - posData = self.data[self.pos_i] - x, y = event.pos() - xdata, ydata = int(x), int(y) + self.setUncheckedAllButtons(buttonsToNotUncheck=buttonsToNotUncheck) + self.setUncheckedAllCustomAnnotButtons() + self.setUncheckedPointsLayers() + self.clearTempBrushImage() + self.isMouseDragImg1 = False + self.typingEditID = False + self.clearHighlightedID() try: - hoverLostID = self.lostObjImage[ydata, xdata] - except IndexError: - return + self.polyLineRoi.clearPoints() + except Exception: + pass - self.ax1_lostObjScatterItem.hoverLostID = hoverLostID - if hoverLostID == 0: - self.ax1_lostObjScatterItem.setSize(self.contLineWeight+1) - self.ax1_lostObjScatterItem.setData([], []) - else: - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] - lostObj = prev_rp[prev_IDs_idxs[hoverLostID]] - obj_contours = self.getObjContours(lostObj, all_external=True) - for cont in obj_contours: - xx = cont[:,0] - yy = cont[:,1] - self.ax1_lostObjScatterItem.addPoints(xx, yy) - self.ax1_lostObjScatterItem.setSize(self.contLineWeight+2) + if doAutoRange: + QTimer.singleShot(11, self.autoRange) - def annotLostObjsToggled(self, checked): - if not self.isDataLoaded: - return - self.updateAllImages() + def point_in_shape(self, x: int, y: int, shape: tuple[int, int]) -> bool: + height, width = shape + return x >= 0 and x < width and y >= 0 and y < height - def getPrevFrameIDs(self, current_frame_i=None): + def restoreHoverObjBrush(self): posData = self.data[self.pos_i] - if current_frame_i is None: - current_frame_i = posData.frame_i - - if current_frame_i is None: - return [] - - prev_frame_i = current_frame_i - 1 - prevIDs = posData.allData_li[prev_frame_i]['IDs'] - - if prevIDs: - return prevIDs + if self.ax1BrushHoverID in posData.IDs: + obj_idx = posData.IDs_idxs[self.ax1BrushHoverID] + obj = posData.rp[obj_idx] + if not self.isObjVisible(obj.bbox): + return - # IDs in previous frame were not stored --> load prev lab from HDD - prev_lab = self.get_labels( - from_store=False, - frame_i=prev_frame_i, - return_copy=False - ) - return self.label_edits.label_ids_from_labels(prev_lab) + self.addObjContourToContoursImage(obj=obj, ax=0) + self.addObjContourToContoursImage(obj=obj, ax=1) - # @exec_time def setLostNewOldPrevIDs(self): posData = self.data[self.pos_i] if posData.frame_i == 0: @@ -552,21 +550,26 @@ def setLostNewOldPrevIDs(self): posData.new_IDs = [] posData.old_IDs = [] # posData.multiContIDs = set() - self.titleLabel.setText('Looking good!', color=self.titleColor) + self.titleLabel.setText("Looking good!", color=self.titleColor) return [] # elif self.modeComboBox.currentText() == 'Viewer': # pass out = self.updateLostNewCurrentIDs() - lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs = ( - out - ) - self.setTitleText( - lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs - ) + lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs = out + self.setTitleText(lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs) return curr_delRoiIDs + def setManualAnnotModeEnabledTools(self, enabled): + for action in self.editToolBar.actions(): + toolButton = self.editToolBar.widgetForAction(action) + if toolButton in self.manulAnnotToolButtons: + continue + + toolButton.setDisabled(enabled) + action.setDisabled(enabled) + def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): if not IDs: return htmlTxt_li, htmlTxtFull_li @@ -574,9 +577,9 @@ def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): if isinstance(IDs, set): IDs = list(IDs) - trim_IDs = self.label_edits.format_trimmed_ids(IDs) - txt = f'{pretxt}: {trim_IDs}' - txt_full = f'{pretxt}:
{IDs}' + trim_IDs = myutils.get_trimmed_list(IDs) + txt = f"{pretxt}: {trim_IDs}" + txt_full = f"{pretxt}:
{IDs}" txt = f'{txt}' txt_full = f'{txt_full}' @@ -587,16 +590,12 @@ def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): return htmlTxt_li, htmlTxtFull_li def setTitleText( - self, lost_IDs=None, new_IDs=None, IDs_with_holes=None, - tracked_lost_IDs=None - ): + self, lost_IDs=None, new_IDs=None, IDs_with_holes=None, tracked_lost_IDs=None + ): if self.manualAnnotPastButton.isChecked(): lockedID = self.editIDspinbox.value() - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - txt = ( - f'Locked ID {lockedID} ' - f'since frame n. {frame_to_restore+1}' - ) + frame_to_restore = self.manualAnnotState.get("frame_i_to_restore") + txt = f"Locked ID {lockedID} since frame n. {frame_to_restore + 1}" htmlTxt = f'{txt}' self.titleLabel.setText(htmlTxt) return @@ -613,29 +612,27 @@ def setTitleText( htmlTxt_li = [] htmlTxtFull_li = [] else: - htmlTxt = f'Never segmented frame. ' + htmlTxt = 'Never segmented frame. ' self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxt) return - if mode != 'Normal division: Lineage tree': + if mode != "Normal division: Lineage tree": htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'IDs lost', 'orange', lost_IDs + htmlTxt_li, htmlTxtFull_li, "IDs lost", "orange", lost_IDs ) htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'New IDs', 'red', new_IDs + htmlTxt_li, htmlTxtFull_li, "New IDs", "red", new_IDs ) htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'Acc. IDs lost', 'green', - tracked_lost_IDs + htmlTxt_li, htmlTxtFull_li, "Acc. IDs lost", "green", tracked_lost_IDs ) for i, htmlTxtFull in enumerate(htmlTxtFull_li): - htmlTxtFull_li[i] = htmlTxtFull.replace('Acc.', 'Accepted') + htmlTxtFull_li[i] = htmlTxtFull.replace("Acc.", "Accepted") htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'IDs with holes', 'red', - IDs_with_holes + htmlTxt_li, htmlTxtFull_li, "IDs with holes", "red", IDs_with_holes ) else: try: @@ -643,13 +640,13 @@ def setTitleText( self.lineage_tree.export_lin_tree_info(posData.frame_i) ) except IndexError or KeyError: - title = 'Processing lineage tree...' + title = "Processing lineage tree..." htmlTxt = f'{title}' self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxt) return except AttributeError: - title = 'Lineage tree still initializing...' + title = "Lineage tree still initializing..." htmlTxt = f'{title}' self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxt) @@ -664,222 +661,153 @@ def setTitleText( parent_cell_groups[parent] = [] parent_cell_groups[parent].append(cell) for parent, daughters in parent_cell_groups.items(): - cells_str = ','.join( - [str(daughter) for daughter in daughters] - ) - parent_cell_txt_raw.append(f'({parent}>{cells_str})') + cells_str = ",".join([str(daughter) for daughter in daughters]) + parent_cell_txt_raw.append(f"({parent}>{cells_str})") htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'New w/out mother', 'red', - orphan_cells + htmlTxt_li, htmlTxtFull_li, "New w/out mother", "red", orphan_cells ) htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'Lost', 'yellow', lost_cells + htmlTxt_li, htmlTxtFull_li, "Lost", "yellow", lost_cells ) htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'Parent > Cell', 'green', - parent_cell_txt_raw + htmlTxt_li, + htmlTxtFull_li, + "Parent > Cell", + "green", + parent_cell_txt_raw, ) if not htmlTxt_li: - title = 'Looking good' + title = "Looking good" htmlTxt = f'{title}' self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxt) return - htmlTxt = ', '.join(htmlTxt_li) - htmlTxtFull = '
'.join(htmlTxtFull_li) + htmlTxt = ", ".join(htmlTxt_li) + htmlTxtFull = "
".join(htmlTxtFull_li) self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxtFull) - def _copyAllLostObjects_navigateToFrame(self, frame_i): - posData = self.data[self.pos_i] - self.store_data(mainThread=False, autosave=False) - - posData.frame_i = frame_i - self.get_data() - self.tracking(wl_update=False) - self.currentLab2D = self.get_2Dlab(posData.lab) - self.update_rp() - self.updateLostNewCurrentIDs() - self.store_data(mainThread=False, autosave=False) - - self.lostObjContoursImage[:] = 0 - self.lostObjImage[:] = 0 - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] # need to change this when merging with opt. - for lostID in posData.lost_IDs: - obj = prev_rp[prev_IDs_idxs[lostID]] - self.addLostObjsToLostObjImage(obj, lostID, force=True) - - def _copyAllLostObjects_returnToFrame(self, frame_i): - posData = self.data[self.pos_i] - self.store_data(autosave=False, mainThread=False) - posData.frame_i = frame_i - self.get_data() - - def _copyAllLostObjects_refreshRp(self): - self.update_rp(draw=False, wl_update=False) # need to change this when merging with opt. - - @disableWindow - def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): - if not self.copyLostObjButton.isChecked(): - return - - posData = self.data[self.pos_i] - - desc = 'Copying all lost objects...' - - self.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self.mainWin, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(for_future_frame_n+1) - self.progressWin.show(self.app) - - self.copyAllLostObjectsThread = QThread() - - self.copyAllLostObjectsWorker = workers.CopyAllLostObjectsWorker( - self.host, posData, for_future_frame_n, max_overlap_perc - ) - self.copyAllLostObjectsWorker.moveToThread(self.copyAllLostObjectsThread) - - self.copyAllLostObjectsWorker.navigateToFrame.connect( - self._copyAllLostObjects_navigateToFrame, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.returnToFrame.connect( - self._copyAllLostObjects_returnToFrame, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.copyLostObjectMask.connect( - self.copyLostObjectMask, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.refreshRp.connect( - self._copyAllLostObjects_refreshRp, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.progressBar.connect( - self.workerUpdateProgressbar - ) - self.copyAllLostObjectsWorker.critical.connect( - self.copyAllLostObjectsWorkerCritical - ) - self.copyAllLostObjectsWorker.finished.connect( - self.copyAllLostObjectsThread.quit - ) - self.copyAllLostObjectsWorker.finished.connect( - self.copyAllLostObjectsWorker.deleteLater - ) - self.copyAllLostObjectsThread.finished.connect( - self.copyAllLostObjectsThread.deleteLater - ) - self.copyAllLostObjectsWorker.finished.connect( - self.copyAllLostObjectsWorkerFinished - ) - - self.copyAllLostObjectsThread.started.connect( - self.copyAllLostObjectsWorker.run - ) - self.copyAllLostObjectsThread.start() - - self.copyAllLostObjectsWorkerLoop = QEventLoop() - self.copyAllLostObjectsWorkerLoop.exec_() - - def copyAllLostObjectsWorkerCritical(self, error): - self.copyAllLostObjectsWorkerLoop.exit() - self.workerCritical(error) - - def copyAllLostObjectsWorkerFinished(self, output): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - if output.get('doReinitLastSegmFrame', False): - self.reInitLastSegmFrame( - from_frame_i=output.get('last_visited_frame_i'), - updateImages=False, - force=True - ) - - if output.get('overlap_warning', False): - self.blinker = qutils.QControlBlink( - self.copyLostObjToolbar.maxOverlapNumberControl, - qparent=self.mainWin - ) - self.blinker.start() - - self.copyAllLostObjectsWorkerLoop.exit() - self.update_rp() - self.updateAllImages() - self.store_data() - - def restoreHoverObjBrush(self): - posData = self.data[self.pos_i] - if self.ax1BrushHoverID in posData.IDs: - obj_idx = posData.IDs_idxs[self.ax1BrushHoverID] - obj = posData.rp[obj_idx] - if not self.isObjVisible(obj.bbox): - return - - self.addObjContourToContoursImage(obj=obj, ax=0) - self.addObjContourToContoursImage(obj=obj, ax=1) - - def hideItemsHoverBrush(self, xy=None, ID=None, force=False): - if xy is not None: - x, y = xy - if x is None: - return + def setUncheckedAllButtons(self, buttonsToNotUncheck=None): + self.clickedOnBud = False + if buttonsToNotUncheck is None: + buttonsToNotUncheck = set() - xdata, ydata = int(x), int(y) - Y, X = self.currentLab2D.shape + try: + self.BudMothTempLine.setData([], []) + except Exception: + pass + for button in self.checkableButtons: + if button in buttonsToNotUncheck: + continue + button.setChecked(False) - if not self.point_in_shape( - xdata, ydata, self.currentLab2D.shape - ): - return + if self.countObjsButton not in buttonsToNotUncheck: + self.countObjsButton.setChecked(False) + self.splineHoverON = False + self.tempSegmentON = False + self.isRightClickDragImg1 = False + self.clearCurvItems(removeItems=False) - if not self.should_hide_hover_objects( - brush_auto_hide_checked=self.brushAutoHideCheckbox.isChecked(), - force=force, - ): - return + def setUncheckedAllCustomAnnotButtons(self): + for button in self.customAnnotDict.keys(): + button.setChecked(False) - posData = self.data[self.pos_i] + def setUncheckedPointsLayers(self): + self.togglePointsLayerAction.setChecked(False) + self.magicPromptsToolButton.setChecked(False) - if xy is not None: - ID = self.get_2Dlab(posData.lab)[ydata, xdata] + def should_disable_non_functional_buttons(self, is_segm_3d: bool) -> bool: + return is_segm_3d - if self.ax1_lostObjScatterItem.isVisible(): - self.ax1_lostObjScatterItem.setVisible(False) + LEGACY_METHODS = ( + "uncheckQButton", + "setUncheckedPointsLayers", + "setUncheckedAllButtons", + "setUncheckedAllCustomAnnotButtons", + "onEscape", + "clearTempBrushImage", + "disconnectLeftClickButtons", + "uncheckLeftClickButtons", + "connectLeftClickButtonsPointsLayersToolbar", + "connectLeftClickButtons", + "wand_cb", + "magicPrompts_cb", + "copyLostObjContour_cb", + "manualAnnotPast_cb", + "copyLostObjectMask", + "highlightManualAnnotMode", + "updateHighlightedAxis", + "updateLostNewCurrentIDs", + "highlightLostNew", + "addLostObjsToLostObjImage", + "highlightHoverLostObj", + "annotLostObjsToggled", + "getPrevFrameIDs", + "setLostNewOldPrevIDs", + "setTitleFormatter", + "setTitleText", + "_copyAllLostObjects_navigateToFrame", + "_copyAllLostObjects_returnToFrame", + "_copyAllLostObjects_refreshRp", + "copyAllLostObjects", + "copyAllLostObjectsWorkerCritical", + "copyAllLostObjectsWorkerFinished", + "restoreHoverObjBrush", + "hideItemsHoverBrush", + "updateBrushCursor", + "setManualAnnotModeEnabledTools", + "disableNonFunctionalButtons", + ) - if self.ax1_lostTrackedScatterItem.isVisible(): - self.ax1_lostTrackedScatterItem.setVisible(False) + def should_hide_hover_objects( + self, + *, + brush_auto_hide_checked: bool, + force: bool, + ) -> bool: + return brush_auto_hide_checked or force - if self.ax2_lostObjScatterItem.isVisible(): - self.ax2_lostObjScatterItem.setVisible(False) + def should_highlight_hover_lost_object( + self, + *, + has_no_modifier: bool, + copy_lost_object_checked: bool, + is_exit_event: bool, + ) -> bool: + return has_no_modifier and copy_lost_object_checked and not is_exit_event - if self.ax2_lostTrackedScatterItem.isVisible(): - self.ax2_lostTrackedScatterItem.setVisible(False) + def uncheckLeftClickButtons(self, sender): + for button in self.LeftClickButtons: + if button != sender: + button.setChecked(False) - # Restore ID previously hovered - if ID != self.ax1BrushHoverID and not self.isMouseDragImg1: + if button != self.labelRoiButton: + # self.labelRoiButton is disconnected so we manually call uncheck + self.labelRoi_cb(False) + self.secondLevelToolbar.setVisible(True) + for toolbar in self.controlToolBars: try: - self.restoreHoverObjBrush() - except Exception as e: - self.ax1BrushHoverID = 0 - return + toolbar.keepVisibleWhenActive + if toolbar.isVisible(): + self.secondLevelToolbar.setVisible(False) + continue + except: + pass + toolbar.setVisible(False) - # Hide items hover ID - if ID != 0: - self.clearObjContour(ID=ID, ax=0) - self.clearObjContour(ID=ID, ax=1) - self.ax1BrushHoverID = ID - else: - self.ax1BrushHoverID = 0 + self.enableSizeSpinbox(False) + if sender is not None: + self.keepIDsButton.setChecked(False) + + def uncheckQButton(self, button): + # Manual exclusive where we allow to uncheck all buttons + for b in self.checkableQButtonsGroup.buttons(): + if b != button: + b.setChecked(False) def updateBrushCursor(self, x, y, isHoverImg1=True): if x is None: @@ -887,45 +815,75 @@ def updateBrushCursor(self, x, y, isHoverImg1=True): xdata, ydata = int(x), int(y) _img = self.currentLab2D - if not self.point_in_shape(xdata, ydata, _img.shape): + Y, X = _img.shape + + if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): return - size = self.brushSizeSpinbox.value()*2 + size = self.brushSizeSpinbox.value() * 2 self.setHoverToolSymbolData( - [x], [y], self.activeBrushCircleCursors(isHoverImg1), - size=size + [x], [y], self.activeBrushCircleCursors(isHoverImg1), size=size ) self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, + xdata, + ydata, + self.ax2_BrushCirclePen, self.activeBrushCircleCursors(isHoverImg1), - self.brushButton, brush=self.ax2_BrushCircleBrush + self.brushButton, + brush=self.ax2_BrushCircleBrush, ) - def setManualAnnotModeEnabledTools(self, enabled): - for action in self.editToolBar.actions(): - toolButton = self.editToolBar.widgetForAction(action) - if toolButton in self.manulAnnotToolButtons: - continue + def updateHighlightedAxis(self): + if not self.manualAnnotPastButton.isChecked(): + return - toolButton.setDisabled(enabled) - action.setDisabled(enabled) + frame_to_restore = self.manualAnnotState.get("frame_i_to_restore") + posData = self.data[self.pos_i] + if posData.frame_i == frame_to_restore: + color = "green" + elif posData.frame_i < frame_to_restore: + color = "gold" + else: + color = "red" - def disableNonFunctionalButtons(self): - if not self.should_disable_non_functional_buttons( - self.isSegm3D - ): - return + self.ax1.setHighlightingRectItemsColor(color) - for item in self.functionsNotTested3D: - if hasattr(item, 'action'): - toolButton = item - action = toolButton.action - toolButton.setDisabled(True) - elif hasattr(item, 'toolbar'): - toolbar = item.toolbar - action = item - toolButton = toolbar.widgetForAction(action) - toolButton.setDisabled(True) - else: - action = item - action.setDisabled(True) \ No newline at end of file + def updateLostNewCurrentIDs(self): + posData = self.data[self.pos_i] + + prev_IDs = self.getPrevFrameIDs() + tracked_lost_IDs = self.getTrackedLostIDs() + curr_IDs = posData.IDs + curr_delRoiIDs = self.getStoredDelRoiIDs() + prev_delRoiIDs = self.getStoredDelRoiIDs(frame_i=posData.frame_i - 1) + lost_IDs = [ + ID + for ID in prev_IDs + if ID not in curr_IDs + and ID not in prev_delRoiIDs + and ID not in tracked_lost_IDs + ] + new_IDs = [ + ID for ID in curr_IDs if ID not in prev_IDs and ID not in curr_delRoiIDs + ] + IDs_with_holes = [] + posData.lost_IDs = lost_IDs + posData.new_IDs = new_IDs + posData.old_IDs = prev_IDs + posData.IDs = curr_IDs + + out = (lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs) + return out + + def wand_cb(self, checked): + self.data[self.pos_i] + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.wandToolButton) + self.connectLeftClickButtons() + self.wandControlsToolbar.setVisible(True) + # self.secondLevelToolbar.setVisible(False) + else: + self.resetCursors() + # self.secondLevelToolbar.setVisible(True) + self.wandControlsToolbar.setVisible(False) diff --git a/cellacdc/mixins/tracking.py b/cellacdc/mixins/tracking.py index 4d8a2b106..cf98a961f 100644 --- a/cellacdc/mixins/tracking.py +++ b/cellacdc/mixins/tracking.py @@ -21,270 +21,181 @@ font_13px.setPixelSize(13) -class TrackingView: +class TrackingMixin: """Qt-facing adapter for tracking and manual tracking workflows.""" - LEGACY_METHODS = ( - 'getLastTrackedFrame', - 'get_last_tracked_i', - 'initSegmTrackMode', - 'updateLastCheckedFrameWidgets', - 'resetManualBackgroundItems', - 'enableSmartTrack', - 'trackNewIDtoNewIDsFutureFrame', - 'initRealTimeTracker', - 'realTimeTrackingClicked', - 'repeatTrackingVideo', - 'warnRepeatTrackingVideoOnVisitedFrames', - 'warnRepeatTrackingVideoWithAnnotations', - 'warnTrackerInputNotValid', - 'repeatTracking', - 'separateByLabelling', - 'isFirstTimeOnNextFrame', - 'trackManuallyAddedObject', - 'trackFrameCustomTracker', - 'trackFrame', - 'clearAssignedObjsSecondStep', - 'trackSubsetIDs', - 'doSkipTracking', - 'tracking', - 'handleAdditionalInfoRealTimeTracker', - 'keepOnlyNewIDAssignedObjsSecondStep', - 'updateAssignedObjsAcdcTrackerSecondStep', - 'annotateAssignedObjsAcdcTrackerSecondStep', - 'setTrackedLostCentroids', - 'getTrackedLostIDs', - 'manuallyEditTracking', - 'updateGhostMaskOpacity', - 'addManualTrackingItems', - 'removeManualTrackingItems', - 'addManualBackgroundItems', - 'removeManualBackgroundItems', - 'resetManualBackgroundSpinboxID', - 'initManualBackgroundObject', - 'initGhostObject', - 'clearGhost', - 'clearManualBackgroundAnnotations', - 'clearGhostContour', - 'clearGhostMask', - '_drawGhostContour', - '_drawManualBackgroundObjContour', - '_drawGhostMask', - 'drawManualBackgroundObj', - 'drawManualTrackingGhost', - 'setManualBackgroundImage', - 'setManualBackgrounNextID', - 'clearManualBackgroundObject', - 'addManualBackgroundObject', - 'setManualBackgroundLab', - 'manualTracking_cb', - 'manualBackground_cb', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) + """Headless tracking state calculations.""" - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) + # @exec_time - def getLastTrackedFrame(self, posData): - return self.last_tracked_frame_index( - (data_dict['labels'] for data_dict in posData.allData_li), - ) + def _drawGhostContour(self, x, y): + if self.ghostObject is None: + return - def get_last_tracked_i(self): - posData = self.data[self.pos_i] - return self.last_tracked_frame_index( - (data_dict['labels'] for data_dict in posData.allData_li), - total_frames=posData.segmSizeT, + ID = self.ghostObject.label + yc, xc = self.ghostObject.local_centroid + Dx = x - xc + Dy = y - yc + xx = self.ghostObject.xx_contour + Dx + yy = self.ghostObject.yy_contour + Dy + self.ghostContourItemLeft.setData( + xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) + self.ghostContourItemRight.setData( + xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x ) - def initSegmTrackMode(self): - posData = self.data[self.pos_i] - last_tracked_i = self.get_last_tracked_i() + def _drawGhostMask(self, x, y): + if self.ghostObject is None: + return - if posData.frame_i > last_tracked_i: - # Prompt user to go to last tracked frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - f'The last visited frame in "Segmentation and Tracking mode" ' - f'is frame {last_tracked_i+1}.\n\n' - f'We recommend to resume from that frame.

' - 'How do you want to proceed?' - ) - goToButton, stayButton = msg.warning( - self.host, 'Go to last visited frame?', txt, - buttonsTexts=( - f'Resume from frame {last_tracked_i+1} (RECOMMENDED)', - f'Stay on current frame {posData.frame_i+1}' - ) - ) - if msg.clickedButton == goToButton: - posData.frame_i = last_tracked_i - self.lastFrameRanOnFirstVisitTools = posData.frame_i - self.get_data() - self.updateAllImages() - self.updateScrollbars() - else: - last_tracked_i = posData.frame_i - current_frame_i = posData.frame_i - self.lastFrameRanOnFirstVisitTools = posData.frame_i - self.logger.info( - f'Storing data up until frame n. {current_frame_i+1}...' - ) - pbar = tqdm(total=current_frame_i+1, ncols=100) - for i in range(current_frame_i): - posData.frame_i = i - self.get_data() - self.store_data(autosave=i==current_frame_i-1) - pbar.update() - pbar.close() + self.clearGhostMask() + ID = self.ghostObject.label + h, w = self.ghostObject.image.shape[-2:] + yc, xc = self.ghostObject.local_centroid + Dx = int(x - xc) + Dy = int(y - yc) + bbox = ((Dy, Dy + h), (Dx, Dx + w)) - posData.frame_i = current_frame_i - self.get_data() + Y, X = self.currentLab2D.shape + slices = myutils.get_slices_local_into_global_arr(bbox, (Y, X)) + slice_global_to_local, slice_crop_local = slices - self.highlightLostNew() - self.updateLastCheckedFrameWidgets(last_tracked_i) + obj_image = self.ghostObject.image[slice_crop_local] - self.isFirstTimeOnNextFrame() - self.initRealTimeTracker() + self.ghostMaskItemLeft.image[slice_global_to_local][obj_image] = ID + self.ghostMaskItemLeft.updateGhostImage( + fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) - def updateLastCheckedFrameWidgets(self, last_tracked_i): - self.navigateScrollBar.setMaximum(last_tracked_i+1) - self.navSpinBox.setMaximum(last_tracked_i+1) - self.lastTrackedFrameLabel.setText( - f'Last checked frame n. = {last_tracked_i+1}' + self.ghostMaskItemRight.image[slice_global_to_local][obj_image] = ID + self.ghostMaskItemRight.updateGhostImage( + fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x ) - def resetManualBackgroundItems(self): - self.initManualBackgroundImage() - self.resetManualBackgroundSpinboxID() - self.drawManualTrackingGhost(self.xHoverImg, self.yHoverImg) - self.drawManualBackgroundObj(self.xHoverImg, self.yHoverImg) + def _drawManualBackgroundObjContour(self, x, y): + if self.manualBackgroundObj is None: + return - def enableSmartTrack(self, checked): + ID = self.manualBackgroundObj.label + yc, xc = self.manualBackgroundObj.local_centroid + Dx = x - xc + Dy = y - yc + xx = self.manualBackgroundObj.xx_contour + Dx + yy = self.manualBackgroundObj.yy_contour + Dy + self.manualBackgroundObjItem.setData( + xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + ) + + def addManualBackgroundItems(self): + self.manualBackgroundObjItem.addToPlotItem() + self.ax1.addItem(self.manualBackgroundImageItem) + + def addManualBackgroundObject(self, x, y): posData = self.data[self.pos_i] - # Disable tracking for already visited frames - if posData.allData_li[posData.frame_i]['labels'] is not None: - trackingEnabled = True - else: - trackingEnabled = False + if not hasattr(self, "manualBackgroundObj"): + self.initManualBackgroundObject() - if checked: - self.UserEnforced_DisabledTracking = False - self.UserEnforced_Tracking = False - else: - if trackingEnabled: - self.UserEnforced_DisabledTracking = True - self.UserEnforced_Tracking = False - else: - self.UserEnforced_DisabledTracking = False - self.UserEnforced_Tracking = True + Y, X = self.currentLab2D.shape + ymin, xmin, ymax, xmax = self.manualBackgroundObj.bbox + width, height = xmax - xmin, ymax - ymin + yc, xc = self.manualBackgroundObj.local_centroid + xstart, ystart = round(x - xc), round(y - yc) + xstart = xstart if xstart >= 0 else 0 + ystart = ystart if ystart >= 0 else 0 - def trackNewIDtoNewIDsFutureFrame(self, newID, newIDmask): - posData = self.data[self.pos_i] - try: - nextLab = posData.allData_li[posData.frame_i+1]['labels'] - except IndexError: - # This is last frame --> there are no future frames - return + xend = xstart + width + yend = ystart + height + xend = xend if xend <= X else X + yend = yend if yend <= Y else Y - if nextLab is None: - return + width = xend - xstart + height = yend - ystart - newID_lab = np.zeros_like(posData.lab) - newID_lab[newIDmask] = newID - newLab_rp = [posData.rp[posData.IDs_idxs[newID]]] - newLab_IDs = [newID] - nextRp = posData.allData_li[posData.frame_i+1]['regionprops'] + obj_image = self.manualBackgroundObj.image[:height, :width] + obj_slice = (slice(ystart, yend), slice(xstart, xend)) + ID = self.manualBackgroundObj.label + self.clearManualBackgroundObject(ID) + posData.manualBackgroundLab[obj_slice][obj_image] = ID - tracked_lab = self.trackFrame( - nextLab, nextRp, newID_lab, newLab_rp, newLab_IDs, - assign_unique_new_IDs=False - ) - trackedID = tracked_lab[newID_lab>0][0] - if trackedID == newID: - # Object does not exist in future frame --> do not track + if ID in self.manualBackgroundTextItems: + self.manualBackgroundTextItems[ID].setPos(x, y) return - if posData.IDs_idxs.get(trackedID) is not None: - # Tracked ID already exists --> do not track to avoid merging - return + textItem = pg.TextItem(text=str(ID), color="r", anchor=(0.5, 0.5)) + textItem.setFont(font_13px) + textItem.setPos(x, y) + self.manualBackgroundTextItems[ID] = textItem - return trackedID + self.ax1.addItem(textItem) - def initRealTimeTracker(self, force=False): - for rtTrackerAction in self.trackingAlgosGroup.actions(): - if rtTrackerAction.isChecked(): - break + def addManualTrackingItems(self): + self.ghostContourItemLeft.addToPlotItem() + self.ghostContourItemRight.addToPlotItem() - aliases = self.model_registry.real_time_tracker_aliases( - reverse=True - ) + self.ghostMaskItemLeft.addToPlotItem() + self.ghostMaskItemRight.addToPlotItem() - rtTracker = rtTrackerAction.text() - rtTracker_txt = rtTracker + Y, X = self.img1.image.shape[:2] + self.ghostMaskItemLeft.initImage((Y, X)) + self.ghostMaskItemRight.initImage((Y, X)) - if rtTracker in aliases: - rtTracker = aliases[rtTracker] + self.updateGhostMaskOpacity() - if rtTracker == 'Cell-ACDC': - return - if rtTracker == 'YeaZ': + def annotateAssignedObjsAcdcTrackerSecondStep(self): + posData = self.data[self.pos_i] + annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) + if annotInfo is None: return - if self.isRealTimeTrackerInitialized and not force: - return + new_objs_1st_step, lost_objs_1st_step = annotInfo + for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): + allContours = self.getObjContours(lostObj, all_external=True) + for objContours in allContours: + isObjVisible = self.isObjVisible(newObj.bbox) + if not isObjVisible: + continue + xx = objContours[:, 0] + 0.5 + yy = objContours[:, 1] + 0.5 + self.yellowContourScatterItem.addPoints(xx, yy) - self.logger.info(f'Initializing {rtTracker_txt} tracker...') - self._rtTrackerName = rtTracker + y1, x1 = self.getObjCentroid(lostObj.centroid) + y2, x2 = self.getObjCentroid(newObj.centroid) + xx, yy = core.get_line(y1, x1, y2, x2, dashed=False) + self.ax1_oldMothBudLinesItem.addPoints(xx, yy) + + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + + def clearAssignedObjsSecondStep(self): posData = self.data[self.pos_i] - realTimeTracker, track_frame_params = ( - self.model_registry.init_tracker( - posData, rtTracker, qparent=self.host, realTime=True - ) - ) - if realTimeTracker is None: - self.logger.info(f'{rtTracker} tracker initialization cancelled.') - return + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None - self.realTimeTracker = realTimeTracker - self.track_frame_params = track_frame_params - self.logger.info(f'{rtTracker} tracker successfully initialized.') - if 'image_channel_name' in self.track_frame_params: - # Remove the channel name since it was already loaded in init_tracker - del self.track_frame_params['image_channel_name'] + def clearGhost(self): + self.clearGhostContour() + self.clearGhostMask() - def realTimeTrackingClicked(self, checked): - # Event called ONLY if the user click on Disable tracking - # NOT called if setChecked is called. This allows to keep track - # of the user choice. This way user con enforce tracking - # NOTE: I know two booleans doing the same thing is overkill - # but the code is more readable when we actually need them + def clearGhostContour(self): + self.ghostContourItemLeft.clear() + self.ghostContourItemRight.clear() + self.manualBackgroundObjItem.clear() - posData = self.data[self.pos_i] - isRealTimeTrackingDisabled = not checked + def clearGhostMask(self): + self.ghostMaskItemLeft.clear() + self.ghostMaskItemRight.clear() - # Turn off smart tracking - self.enableSmartTrackAction.toggled.disconnect() - self.enableSmartTrackAction.setChecked(False) - if isRealTimeTrackingDisabled: - self.UserEnforced_DisabledTracking = True - self.UserEnforced_Tracking = False - else: - txt = html_utils.paragraph(""" + def clearManualBackgroundAnnotations(self): + try: + for textItem in self.manualBackgroundTextItems.values(): + textItem.setText("") + except Exception: + pass - """Headless tracking state calculations.""" + def clearManualBackgroundObject(self, ID): + posData = self.data[self.pos_i] + mask = posData.manualBackgroundLab == ID + posData.manualBackgroundImage[mask, :] = 0 + posData.manualBackgroundLab[mask] = 0 def compute_lost_new_ids( self, @@ -303,1151 +214,1218 @@ def compute_lost_new_ids( tracked_lost_ids=tracked_lost_ids, ) - def tracked_lost_centroids_from_regionprops( - self, - regionprops, - tracked_lost_ids, - ) -> set[tuple[int, ...]]: - return tracked_lost_centroids_from_regionprops( - regionprops, - tracked_lost_ids, - ) + def doSkipTracking(self, against_next: bool, enforce: bool): + if self.isSnapshot: + return True - def tracked_lost_ids_from_centroids( - self, - previous_labels, - tracked_lost_centroids, - ids_in_frame, - ) -> TrackedLostIdsResult: - return tracked_lost_ids_from_centroids( - previous_labels, - tracked_lost_centroids, - ids_in_frame, - ) + mode = str(self.modeComboBox.currentText()) + if mode != "Segmentation and Tracking": + return True - def last_tracked_frame_index( - self, - frame_labels, - *, - first_frame_fallback: int = 0, - total_frames: int | None = None, - ) -> int: - return last_tracked_frame_index( - frame_labels, - first_frame_fallback=first_frame_fallback, - total_frames=total_frames, - ) + if self.UserEnforced_DisabledTracking: + return True - def scan_future_id_propagation( - self, - target_id: int, - *, - current_frame_i: int, - frame_labels, - fallback_frame_labels, - include_unvisited: bool = False, - total_frames: int | None = None, - ) -> FutureIdPropagationScan: - return scan_future_id_propagation( - target_id, - current_frame_i=current_frame_i, - frame_labels=frame_labels, - fallback_frame_labels=fallback_frame_labels, - include_unvisited=include_unvisited, - total_frames=total_frames, - ) + if not self.realTimeTrackingToggle.isChecked(): + return True + posData = self.data[self.pos_i] + if against_next: + reference_lab = posData.allData_li[posData.frame_i + 1]["labels"] + if reference_lab is None: + # Next frame never visited --> cannot track against next + return True - Do you want to keep tracking always active including on already - visited frames?

- Note: To re-activate automatic handling of tracking go to
- Edit --> Smart handling of enabling/disabling tracking. + if posData.frame_i == posData.SizeT - 1: + # Last frame --> cannot track against next + return True - """) - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - yesButton, noButton = msg.question( - self.host, 'Keep tracking always active?', txt, - buttonsTexts=('Yes', 'No') - ) - if msg.clickedButton == yesButton: - self.repeatTracking() - self.UserEnforced_DisabledTracking = False - self.UserEnforced_Tracking = True - else: - self.enableSmartTrackAction.setChecked(True) + else: + # check that we are not on the last frame + if posData.frame_i == 0: + return True - @exception_handler - def repeatTrackingVideo(self, checked=False): - posData = self.data[self.pos_i] - win = widgets.selectTrackerGUI( - posData.SizeT, currentFrameNo=posData.frame_i+1 - ) - win.exec_() - if win.cancel: - self.logger.info('Tracking aborted.') + if enforce or self.UserEnforced_Tracking: + # Enforce even if not last visited frame + return False + + is_first_time_on_next_frame = self.isFirstTimeOnNextFrame() + skip_tracking = not is_first_time_on_next_frame + + return skip_tracking + + def drawManualBackgroundObj(self, x, y): + if x is None or y is None: + self.clearGhost() return - trackerName = win.selectedItemsText[0] - start_n = win.startFrame - stop_n = win.stopFrame - video_to_track = posData.segm_data - for frame_i in range(start_n-1, stop_n): - data_dict = posData.allData_li[frame_i] - lab = data_dict['labels'] + self._drawManualBackgroundObjContour(x, y) + + def drawManualTrackingGhost(self, x, y): + if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): + return + + if x is None or y is None: + self.clearGhost() + return + + if self.manualTrackingToolbar.ghostContourRadiobutton.isChecked(): + self._drawGhostContour(x, y) + else: + self._drawGhostMask(x, y) + + def enableSmartTrack(self, checked): + posData = self.data[self.pos_i] + # Disable tracking for already visited frames + + if posData.allData_li[posData.frame_i]["labels"] is not None: + trackingEnabled = True + else: + trackingEnabled = False + + if checked: + self.UserEnforced_DisabledTracking = False + self.UserEnforced_Tracking = False + else: + if trackingEnabled: + self.UserEnforced_DisabledTracking = True + self.UserEnforced_Tracking = False + else: + self.UserEnforced_DisabledTracking = False + self.UserEnforced_Tracking = True + + def getLastTrackedFrame(self, posData): + last_tracked_i = 0 + for frame_i, data_dict in enumerate(posData.allData_li): + lab = data_dict["labels"] if lab is None: + frame_i -= 1 break + if frame_i > 0: + return frame_i + else: + return last_tracked_i - video_to_track[frame_i] = lab - video_to_track = video_to_track[start_n-1:stop_n] + def getTrackedLostIDs(self, prev_lab=None, IDs_in_frames=None, frame_i=None): + trackedLostIDs = set() + posData = self.data[self.pos_i] + if self.isExportingVideo: + posData.trackedLostIDs = trackedLostIDs + return trackedLostIDs + + retrackedLostcent = set() + if frame_i is None: + frame_i = posData.frame_i - self.logger.info(f'Importing {trackerName} tracker...') - self.tracker, self.track_params, init_params = ( - self.model_registry.init_tracker( - posData, trackerName, qparent=self.host, return_init_params=True + if prev_lab is None: + prev_lab = self.get_labels( + from_store=True, + frame_i=posData.frame_i - 1, + return_existing=False, + return_copy=False, ) - ) - if self.track_params is None: - self.logger.info('Tracking aborted.') - return - warningText = self.model_registry.validate_tracker_input( - self.tracker, video_to_track - ) - if warningText is not None: - self.logger.info(warningText) - self.warnTrackerInputNotValid(trackerName, warningText) - return + if IDs_in_frames is None: + IDs_in_frames = posData.IDs - if 'image_channel_name' in self.track_params: - # Remove the channel name since it was already loaded in init_tracker - del self.track_params['image_channel_name'] + try: + tracked_lost_centroids = posData.tracked_lost_centroids[frame_i] + except KeyError: + tracked_lost_centroids = set() - track_params_log = { - key: value for key, value in self.track_params.items() - if key != 'image' - } - self.logger.info( - 'Tracking parameters:\n\n' - f'Initialization parameters: {init_params}\n' - f'Track parameters: {track_params_log}' + for centroid in tracked_lost_centroids: + if len(centroid) < 3 and prev_lab.ndim == 3: + # Ignore wrongly stored centroids + continue + + ID = prev_lab[centroid] + if ID == 0: + continue + + if ID in IDs_in_frames: + retrackedLostcent.add(centroid) + continue + + trackedLostIDs.add(ID) + + posData.tracked_lost_centroids[frame_i] = ( + tracked_lost_centroids - retrackedLostcent ) + posData.trackedLostIDs = trackedLostIDs - last_cca_i = self.get_last_cca_frame_i() - if start_n-2 <= last_cca_i and start_n>1: - proceed = self.warnRepeatTrackingVideoWithAnnotations( - last_cca_i, start_n - ) - if not proceed: - self.logger.info('Tracking aborted.') + return trackedLostIDs + + def get_last_tracked_i(self): + posData = self.data[self.pos_i] + last_tracked_i = 0 + for frame_i, data_dict in enumerate(posData.allData_li): + lab = data_dict["labels"] + if lab is None and frame_i == 0: + last_tracked_i = 0 + break + elif lab is None: + last_tracked_i = frame_i - 1 + break + else: + last_tracked_i = posData.segmSizeT - 1 + return last_tracked_i + + def handleAdditionalInfoRealTimeTracker(self, prev_rp, *args): + if self._rtTrackerName == "CellACDC_normal_division": + tracked_lost_IDs = args[0] + self.setTrackedLostCentroids(prev_rp, tracked_lost_IDs) + elif self._rtTrackerName == "CellACDC_2steps": + if args[0] is None: return + posData = self.data[self.pos_i] + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = args[0] - self.logger.info(f'Removing annotations from frame n. {start_n}.') - self.resetCcaFuture(start_n-1) + def initGhostObject(self, ID=None): + mode = self.modeComboBox.currentText() + if mode != "Segmentation and Tracking": + self.ghostObject = None + return - self.start_n = start_n - self.stop_n = stop_n + if not self.manualTrackingButton.isChecked(): + self.ghostObject = None + return - info_txt = f'Tracking from frame n. {start_n} to {stop_n}...' - self.logger.info(info_txt) + if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): + self.ghostObject = None + return - self.progressWin = apps.QDialogWorkerProgress( - title='Tracking', parent=self.host, pbarDesc=info_txt - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stop_n-start_n) - self.startTrackingWorker(posData, video_to_track) + if ID is None: + ID = self.manualTrackingToolbar.spinboxID.value() - def warnRepeatTrackingVideoOnVisitedFrames(self, last_tracked_i, start_n): - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'You are repeating tracking on frames that have already ' - 'been visited/tracked before.

' - 'This will very likely make the annotations wrong.

' - 'If you really want to repeat tracking on the frames before ' - f'{last_tracked_i+1} the annotations from frame ' - f'{start_n} to frame {last_tracked_i+1} ' - 'will be removed.

' - 'Do you want to continue?' - ) - noButton, yesButton = msg.warning( - self.host, 'Repating tracking with annotations!', txt, - buttonsTexts=( - ' No, stop tracking and keep annotations.', - ' Yes, repeat tracking and DELETE annotations.' - ) - ) - if msg.cancel: - return False + posData = self.data[self.pos_i] + if posData.frame_i == 0: + self.ghostObject = None + return - if msg.clickedButton == noButton: - return False - else: - return True + prevFrameRp = posData.allData_li[posData.frame_i - 1]["regionprops"] + if prevFrameRp is None: + self.ghostObject = None + return - def warnRepeatTrackingVideoWithAnnotations(self, last_tracked_i, start_n): - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'You are repeating tracking on frames that have cell cycle ' - 'annotations.

' - 'This will very likely make the annotations wrong.

' - 'If you really want to repeat tracking on the frames before ' - f'{last_tracked_i+1} the annotations from frame ' - f'{start_n} to frame {last_tracked_i+1} ' - 'will be removed.

' - 'Do you want to continue?' - ) - noButton, yesButton = msg.warning( - self.host, 'Repating tracking with annotations!', txt, - buttonsTexts=( - ' No, stop tracking and keep annotations.', - ' Yes, repeat tracking and DELETE annotations.' + for obj in prevFrameRp: + if obj.label != ID: + continue + self.ghostObject = obj + break + else: + self.ghostObject = None + self.manualTrackingToolbar.showWarning( + f"The ID {ID} does not exist in previous frame " + "--> starting a new track." ) - ) - if msg.cancel: - return False + return - if msg.clickedButton == noButton: - return False - else: - return True + self.manualTrackingToolbar.clearInfoText() - def warnTrackerInputNotValid(self, trackerName, warningText): - msg = widgets.myMessageBox(wrapText=False) - txt = warningText.replace('\n', '
') - txt = html_utils.paragraph( - f'{txt}

' - 'Tracking process will be cancelled. Thank you for your patience!' - ) - msg.warning(self.host, 'Invalid input for tracker', txt) + self.ghostObject.contour = self.getObjContours(self.ghostObject, local=True) + self.ghostObject.xx_contour = self.ghostObject.contour[:, 0] + self.ghostObject.yy_contour = self.ghostObject.contour[:, 1] + + self.ghostMaskItemLeft.initLookupTable(self.lut[ID]) + self.ghostMaskItemRight.initLookupTable(self.lut[ID]) + + def initManualBackgroundObject(self, ID=None): + if not self.manualBackgroundButton.isChecked(): + self.manualBackgroundObj = None + return + + if ID is None: + ID = self.manualBackgroundToolbar.spinboxID.value() - def repeatTracking(self): posData = self.data[self.pos_i] - prev_lab = self.get_2Dlab(posData.lab).copy() - self.tracking(enforce=True, DoManualEdit=False) - if posData.editID_info: - editedIDsInfo = self.edit_id.manual_edit_conflicts( - posData.lab, posData.editID_info - ) - editedIDsInfoItems = [ - f'ID {oldID} --> {newID}' - for oldID, newID in editedIDsInfo.items() - ] - editIDul = html_utils.to_list(editedIDsInfoItems) - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - You requested to repeat tracking but there are manually - edited IDs (see edited IDs in the details section below) -

- Do you want to keep these edits or ignore them? - """) - keepManualEditButton = widgets.okPushButton( - 'Keep manually edited IDs' - ) - ignoreButton = widgets.noPushButton( - 'Ignore manually edited IDs' - ) - msg.question( - self.host, 'Repeat tracking mode', txt, - buttonsTexts=(keepManualEditButton, ignoreButton), - detailsText=editIDul - ) - if msg.cancel: - return - if msg.clickedButton == keepManualEditButton: - allIDs = [obj.label for obj in posData.rp] - lab2D = self.get_2Dlab(posData.lab) - self.manuallyEditTracking(lab2D, allIDs) - self.update_rp() - self.setAllTextAnnotations() - self.highlightLostNew() - # self.checkIDsMultiContour() - else: - posData.editID_info = [] - if np.any(posData.lab != prev_lab): - if self.isSnapshot: - self.fixCcaDfAfterEdit('Repeat tracking') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Repeat tracking') - else: - self.updateAllImages() + if ID not in posData.IDs: + self.manualBackgroundObj = None + self.manualBackgroundToolbar.showWarning(f"The ID {ID} does not exist") + self.manualBackgroundObjItem.clear() + return - def separateByLabelling(self, lab, rp, maxID=None): - """ - Label each single object in posData.lab and if the result is more than - one object then we insert the separated object into posData.lab - """ - setRp = False - posData = self.data[self.pos_i] - if maxID is None: - maxID = max(posData.IDs, default=1) - for obj in rp: - lab_obj = skimage.measure.label(obj.image) - rp_lab_obj = skimage.measure.regionprops(lab_obj) - if len(rp_lab_obj)<=1: - continue - lab_obj += maxID - _slice = obj.slice # self.getObjSlice(obj.slice) - _objMask = obj.image # self.getObjImage(obj.image) - lab[_slice][_objMask] = lab_obj[_objMask] - setRp = True - maxID += 1 - return setRp + ID_idx = posData.IDs_idxs[ID] + self.manualBackgroundObj = posData.rp[ID_idx] - def isFirstTimeOnNextFrame(self): - posData = self.data[self.pos_i] - posData.last_tracked_i = self.navigateScrollBar.maximum()-1 - return posData.frame_i > posData.last_tracked_i + self.manualBackgroundToolbar.clearInfoText() + self.manualBackgroundObj.contour = self.getObjContours( + self.manualBackgroundObj, local=True + ) + xx_contour = self.manualBackgroundObj.contour[:, 0] + yy_contour = self.manualBackgroundObj.contour[:, 1] + self.manualBackgroundObj.xx_contour = xx_contour + self.manualBackgroundObj.yy_contour = yy_contour - def trackManuallyAddedObject( - self, added_IDs: List[int] | int | Set[int], isNewID: bool, - wl_update:bool=True, wl_track_og_curr:bool=False - ): - """Track object added manually on frame that was already visited. + def initRealTimeTracker(self, force=False): + for rtTrackerAction in self.trackingAlgosGroup.actions(): + if rtTrackerAction.isChecked(): + break - Parameters - ---------- - added_IDs : int | list of int | set - ID or IDs of the object added manually - isNewID : bool - If True, the added object is new + aliases = myutils.aliases_real_time_trackers(reverse=True) - Notes - ----- - This method tracks the new added object against the previous frame - labels. If the ID determined by tracking is different from `added_ID` - (meaning that tracking thinks the new ID should be changed to the - tracked ID) and the tracked ID is not already existing (which would - otherwise causing merging) we assign the tracked ID to the object with - `added_ID`. + rtTracker = rtTrackerAction.text() + rtTracker_txt = rtTracker - If instead the tracked ID is the same as `added_ID` we are dealing - with a truly new object. In this case we want to try tracking it against - the next frame (since the next frame was already validated). - As before, we assign the tracked ID (against the next frame) only if - not already existing in current frame (to avoid merging). - """ - if self.isSnapshot: - return + if rtTracker in aliases: + rtTracker = aliases[rtTracker] - if not isNewID: + if rtTracker == "Cell-ACDC": + return + if rtTracker == "YeaZ": return - if isinstance(added_IDs, int): - added_IDs = [added_IDs] + if self.isRealTimeTrackerInitialized and not force: + return + self.logger.info(f"Initializing {rtTracker_txt} tracker...") + self._rtTrackerName = rtTracker posData = self.data[self.pos_i] - tracked_lab = self.tracking( - enforce=True, assign_unique_new_IDs=False, return_lab=True, - IDs=added_IDs + realTimeTracker, track_frame_params = myutils.init_tracker( + posData, rtTracker, qparent=self, realTime=True ) - self.clearAssignedObjsSecondStep() - if tracked_lab is None: + if realTimeTracker is None: + self.logger.info(f"{rtTracker} tracker initialization cancelled.") return - # Track only new object - prevIDs = posData.allData_li[posData.frame_i-1]['IDs'] - - # mask = np.zeros(posData.lab.shape, dtype=bool) - update_rp = False - - for added_ID in added_IDs: - # try: - # obj = posData.rp[added_ID] # ID not present - # mask[obj.slice][obj.image] = True + self.realTimeTracker = realTimeTracker + self.track_frame_params = track_frame_params + self.logger.info(f"{rtTracker} tracker successfully initialized.") + if "image_channel_name" in self.track_frame_params: + # Remove the channel name since it was already loaded in init_tracker + del self.track_frame_params["image_channel_name"] - # except IndexError as err: - mask = posData.lab == added_ID - try: - trackedID = tracked_lab[mask][0] - except IndexError as err: - # added_ID is not present - continue + def initSegmTrackMode(self): + posData = self.data[self.pos_i] + last_tracked_i = self.get_last_tracked_i() - isTrackedIDalreadyPresentAndNotNew = ( - posData.IDs_idxs.get(trackedID) is not None - and added_ID != trackedID + if posData.frame_i > last_tracked_i: + # Prompt user to go to last tracked frame + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + f'The last visited frame in "Segmentation and Tracking mode" ' + f"is frame {last_tracked_i + 1}.\n\n" + f"We recommend to resume from that frame.

" + "How do you want to proceed?" ) - if isTrackedIDalreadyPresentAndNotNew: - continue - - isTrackedIDinPrevIDs = trackedID in prevIDs - if isTrackedIDinPrevIDs: - posData.lab[mask] = trackedID - else: - # New object where we can try to track against next frame - trackedID = self.trackNewIDtoNewIDsFutureFrame(added_ID, mask) - if trackedID is None: - self.clearAssignedObjsSecondStep() - continue - posData.lab[mask] = trackedID - - self.keepOnlyNewIDAssignedObjsSecondStep(trackedID) - update_rp = True - - if update_rp: - self.update_rp(wl_update=wl_update) - - def trackFrameCustomTracker( - self, prev_lab, currentLab, IDs=None, unique_ID=None - ): - if unique_ID is None: - unique_ID = self.setBrushID() - try: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - unique_ID=unique_ID, - IDs=IDs, - **self.track_frame_params, + goToButton, stayButton = msg.warning( + self, + "Go to last visited frame?", + txt, + buttonsTexts=( + f"Resume from frame {last_tracked_i + 1} (RECOMMENDED)", + f"Stay on current frame {posData.frame_i + 1}", + ), ) - except TypeError as err: - if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: - try: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, IDs=IDs, - **self.track_frame_params - ) - except TypeError as err: - if str(err).find('an unexpected keyword argument \'IDs\'') != -1: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - **self.track_frame_params) - else: - raise err - elif str(err).find('an unexpected keyword argument \'IDs\'') != -1: - try: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - unique_ID=unique_ID, - **self.track_frame_params - ) - except TypeError as err: - if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - **self.track_frame_params - ) - else: - raise err + if msg.clickedButton == goToButton: + posData.frame_i = last_tracked_i + self.lastFrameRanOnFirstVisitTools = posData.frame_i + self.get_data() + self.updateAllImages() + self.updateScrollbars() else: - raise err - return tracked_result + last_tracked_i = posData.frame_i + current_frame_i = posData.frame_i + self.lastFrameRanOnFirstVisitTools = posData.frame_i + self.logger.info( + f"Storing data up until frame n. {current_frame_i + 1}..." + ) + pbar = tqdm(total=current_frame_i + 1, ncols=100) + for i in range(current_frame_i): + posData.frame_i = i + self.get_data() + self.store_data(autosave=i == current_frame_i - 1) + pbar.update() + pbar.close() - def trackFrame( - self, prev_lab, prev_rp, curr_lab, curr_rp, curr_IDs, - assign_unique_new_IDs=True, IDs=None, unique_ID=None - ): - if self.trackWithAcdcAction.isChecked(): - tracked_result = CellACDC_tracker.track_frame( - prev_lab, prev_rp, curr_lab, curr_rp, - IDs_curr_untracked=curr_IDs, - setBrushID_func=self.setBrushID, - posData=self.data[self.pos_i], - assign_unique_new_IDs=assign_unique_new_IDs, - IDs=IDs, - unique_ID=unique_ID - ) - elif self.trackWithYeazAction.isChecked(): - tracked_result = self.tracking_yeaz.correspondence( - prev_lab, curr_lab, use_modified_yeaz=True, - use_scipy=True - ) - else: - tracked_result = self.trackFrameCustomTracker( - prev_lab, curr_lab, IDs=IDs, unique_ID=unique_ID - ) + posData.frame_i = current_frame_i + self.get_data() - # Check if tracker also returns additional info - if isinstance(tracked_result, tuple): - tracked_lab, tracked_lost_IDs = tracked_result - self.handleAdditionalInfoRealTimeTracker(prev_rp, tracked_lost_IDs) - else: - tracked_lab = tracked_result + self.highlightLostNew() + self.updateLastCheckedFrameWidgets(last_tracked_i) - return tracked_lab + self.isFirstTimeOnNextFrame() + self.initRealTimeTracker() - def clearAssignedObjsSecondStep(self): + def isFirstTimeOnNextFrame(self): posData = self.data[self.pos_i] - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + posData.last_tracked_i = self.navigateScrollBar.maximum() - 1 + return posData.frame_i > posData.last_tracked_i - def trackSubsetIDs(self, subsetIDs: Iterable[int]): + def keepOnlyNewIDAssignedObjsSecondStep(self, trackedID): posData = self.data[self.pos_i] - if posData.frame_i == 0: - return - - subsetLab = np.zeros_like(posData.lab) - for subsetID in subsetIDs: - subsetLab[posData.lab == subsetID] = subsetID - - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - tracked_lab = self.trackFrame( - prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, - assign_unique_new_IDs=True - ) - doUpdateRp = False - for subsetID in subsetIDs: - subsetIDmask = posData.lab == subsetID - trackedID = tracked_lab[subsetIDmask][0] - if trackedID == subsetID: - continue - - is_manually_edited = False - for y, x, new_ID in posData.editID_info: - if new_ID == subsetID: - # Do not track because it was manually edited - break - - posData.lab[subsetIDmask] = tracked_lab[subsetIDmask] - doUpdateRp = True + annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) - if not doUpdateRp: + if annotInfo is None: return - self.update_rp() - - def doSkipTracking(self, against_next: bool, enforce: bool): - if self.isSnapshot: - return True - - mode = str(self.modeComboBox.currentText()) - if mode != 'Segmentation and Tracking': - return True - - if self.UserEnforced_DisabledTracking: - return True + new_objs_1st_step, lost_objs_1st_step = annotInfo + correct_new_objs, correct_lost_objs = [], [] + for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): + newObj_ID = posData.lab[newObj.slice][newObj.image][0] + if newObj_ID != trackedID: + continue - if not self.realTimeTrackingToggle.isChecked(): - return True + correct_new_objs.append(newObj) + correct_lost_objs.append(lostObj) - posData = self.data[self.pos_i] - if against_next: - reference_lab = posData.allData_li[posData.frame_i+1]['labels'] - if reference_lab is None: - # Next frame never visited --> cannot track against next - return True + if not correct_new_objs: + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + else: + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( + correct_new_objs, + correct_lost_objs, + ) - if posData.frame_i == posData.SizeT - 1: - # Last frame --> cannot track against next - return True + def last_tracked_frame_index( + self, + frame_labels, + *, + first_frame_fallback: int = 0, + total_frames: int | None = None, + ) -> int: + return last_tracked_frame_index( + frame_labels, + first_frame_fallback=first_frame_fallback, + total_frames=total_frames, + ) + def manualBackground_cb(self, checked): + if checked: + posData = self.data[self.pos_i] + minID = min(posData.IDs, default=0) + if minID == self.manualBackgroundToolbar.spinboxID.value(): + self.initManualBackgroundObject() + else: + self.manualBackgroundToolbar.spinboxID.setValue(minID) + # self.initManualBackgroundObject() + # self.initManualBackgroundImage() + self.addManualBackgroundItems() + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.manualBackgroundButton) + self.connectLeftClickButtons() + self.updateAllImages() else: - # check that we are not on the last frame - if posData.frame_i == 0: - return True - - if enforce or self.UserEnforced_Tracking: - # Enforce even if not last visited frame - return False + self.removeManualTrackingItems() + self.clearGhost() + self.clearManualBackgroundAnnotations() + self.manualBackgroundToolbar.setVisible(checked) - is_first_time_on_next_frame = self.isFirstTimeOnNextFrame() - skip_tracking = not is_first_time_on_next_frame + def manualTracking_cb(self, checked): + self.manualTrackingToolbar.setVisible(checked) + if checked: + self.realTimeTrackingToggle.previousStatus = ( + self.realTimeTrackingToggle.isChecked() + ) + self.realTimeTrackingToggle.setChecked(False) + self.UserEnforced_DisabledTracking_previousStatus = ( + self.UserEnforced_DisabledTracking + ) + self.UserEnforced_Tracking_previousStatus = self.UserEnforced_Tracking - return skip_tracking + self.UserEnforced_DisabledTracking = True + self.UserEnforced_Tracking = False + self.initGhostObject() + self.addManualTrackingItems() + else: + self.realTimeTrackingToggle.setChecked( + self.realTimeTrackingToggle.previousStatus + ) + self.UserEnforced_DisabledTracking = ( + self.UserEnforced_DisabledTracking_previousStatus + ) + self.UserEnforced_Tracking = self.UserEnforced_Tracking_previousStatus + self.removeManualTrackingItems() + self.clearGhost() - # @exec_time - @exception_handler - def tracking( - self, enforce=False, DoManualEdit=True, - storeUndo=False, prev_lab=None, prev_rp=None, - return_lab=False, assign_unique_new_IDs=True, - separateByLabel=True, wl_update=True, - IDs=None, against_next=False, - ): + def manuallyEditTracking(self, tracked_lab, allIDs): posData = self.data[self.pos_i] + infoToRemove = [] + # Correct tracking with manually changed IDs + maxID = max(allIDs, default=1) + for y, x, new_ID in posData.editID_info: + old_ID = tracked_lab[y, x] + if old_ID == 0 or old_ID == new_ID: + infoToRemove.append((y, x, new_ID)) + continue + if new_ID in allIDs: + tempID = maxID + 1 + tracked_lab[tracked_lab == old_ID] = tempID + tracked_lab[tracked_lab == new_ID] = old_ID + tracked_lab[tracked_lab == tempID] = new_ID + else: + tracked_lab[tracked_lab == old_ID] = new_ID + if new_ID > maxID: + maxID = new_ID + for info in infoToRemove: + posData.editID_info.remove(info) - if self.doSkipTracking(against_next, enforce): - self.setLostNewOldPrevIDs() - return + def realTimeTrackingClicked(self, checked): + # Event called ONLY if the user click on Disable tracking + # NOT called if setChecked is called. This allows to keep track + # of the user choice. This way user con enforce tracking + # NOTE: I know two booleans doing the same thing is overkill + # but the code is more readable when we actually need them - """Tracking starts here""" - staturBarLabelText = self.statusBarLabel.text() - self.statusBarLabel.setText('Tracking...') + self.data[self.pos_i] + isRealTimeTrackingDisabled = not checked - if storeUndo: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) + # Turn off smart tracking + self.enableSmartTrackAction.toggled.disconnect() + self.enableSmartTrackAction.setChecked(False) + if isRealTimeTrackingDisabled: + self.UserEnforced_DisabledTracking = True + self.UserEnforced_Tracking = False + else: + txt = html_utils.paragraph(""" - # First separate by labelling - if separateByLabel: - maxID = max(posData.IDs, default=1) - setRp = self.label_edits.split_connected_components( - posData.lab, - regionprops=posData.rp, - max_id=maxID, - ) - if setRp: - self.update_rp(wl_update=wl_update, ) + Do you want to keep tracking always active including on already + visited frames?

+ Note: To re-activate automatic handling of tracking go to
+ Edit --> Smart handling of enabling/disabling tracking. - if prev_lab is None: - if not against_next: - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - else: - prev_lab = posData.allData_li[posData.frame_i+1]['labels'] - if prev_rp is None: - if not against_next: - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + """) + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + yesButton, noButton = msg.question( + self, "Keep tracking always active?", txt, buttonsTexts=("Yes", "No") + ) + if msg.clickedButton == yesButton: + self.repeatTracking() + self.UserEnforced_DisabledTracking = False + self.UserEnforced_Tracking = True else: - prev_rp = posData.allData_li[posData.frame_i+1]['regionprops'] - - unique_ID = None - if posData.frame_i < self.get_last_tracked_i(): - unique_ID = self.setBrushID(return_val=True) - - tracked_lab = self.trackFrame( - prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, - assign_unique_new_IDs=assign_unique_new_IDs, IDs=IDs, - unique_ID=unique_ID - ) + self.enableSmartTrackAction.setChecked(True) - if DoManualEdit: - # Correct tracking with manually changed IDs - rp = skimage.measure.regionprops(tracked_lab) - IDs = [obj.label for obj in rp] - self.manuallyEditTracking(tracked_lab, IDs) + def removeManualBackgroundItems(self): + self.manualBackgroundObjItem.removeFromPlotItem() + self.ax1.removeItem(self.manualBackgroundImageItem) - if return_lab: - QTimer.singleShot(50, partial( - self.statusBarLabel.setText, staturBarLabelText - )) - return tracked_lab + def removeManualTrackingItems(self): + self.ghostContourItemLeft.removeFromPlotItem() + self.ghostContourItemRight.removeFromPlotItem() - # Update labels, regionprops and determine new and lost IDs - posData.lab = tracked_lab - self.update_rp(wl_update=wl_update, ) - self.setAllTextAnnotations() - QTimer.singleShot(50, partial( - self.statusBarLabel.setText, staturBarLabelText - )) + self.ghostMaskItemLeft.removeFromPlotItem() + self.ghostMaskItemRight.removeFromPlotItem() - def handleAdditionalInfoRealTimeTracker(self, prev_rp, *args): - if self._rtTrackerName == 'CellACDC_normal_division': - tracked_lost_IDs = args[0] - self.setTrackedLostCentroids(prev_rp, tracked_lost_IDs) - elif self._rtTrackerName == 'CellACDC_2steps': - if args[0] is None: + def repeatTracking(self): + posData = self.data[self.pos_i] + prev_lab = self.get_2Dlab(posData.lab).copy() + self.tracking(enforce=True, DoManualEdit=False) + if posData.editID_info: + editedIDsInfo = { + posData.lab[y, x]: newID + for y, x, newID in posData.editID_info + if posData.lab[y, x] != newID + } + editedIDsInfoItems = [ + f"ID {oldID} --> {newID}" for oldID, newID in editedIDsInfo.items() + ] + editIDul = html_utils.to_list(editedIDsInfoItems) + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + You requested to repeat tracking but there are manually + edited IDs (see edited IDs in the details section below) +

+ Do you want to keep these edits or ignore them? + """) + keepManualEditButton = widgets.okPushButton("Keep manually edited IDs") + ignoreButton = widgets.noPushButton("Ignore manually edited IDs") + msg.question( + self, + "Repeat tracking mode", + txt, + buttonsTexts=(keepManualEditButton, ignoreButton), + detailsText=editIDul, + ) + if msg.cancel: return - posData = self.data[self.pos_i] - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = args[0] + if msg.clickedButton == keepManualEditButton: + allIDs = [obj.label for obj in posData.rp] + lab2D = self.get_2Dlab(posData.lab) + self.manuallyEditTracking(lab2D, allIDs) + self.update_rp() + self.setAllTextAnnotations() + self.highlightLostNew() + # self.checkIDsMultiContour() + else: + posData.editID_info = [] + if np.any(posData.lab != prev_lab): + if self.isSnapshot: + self.fixCcaDfAfterEdit("Repeat tracking") + self.updateAllImages() + else: + self.warnEditingWithCca_df("Repeat tracking") + else: + self.updateAllImages() - def keepOnlyNewIDAssignedObjsSecondStep(self, trackedID): + @exception_handler + def repeatTrackingVideo(self, checked=False): posData = self.data[self.pos_i] - annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) - - if annotInfo is None: + win = widgets.selectTrackerGUI( + posData.SizeT, currentFrameNo=posData.frame_i + 1 + ) + win.exec_() + if win.cancel: + self.logger.info("Tracking aborted.") return - new_objs_1st_step, lost_objs_1st_step = annotInfo - correct_new_objs, correct_lost_objs = [], [] - for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): - newObj_ID = posData.lab[newObj.slice][newObj.image][0] - if newObj_ID != trackedID: - continue + trackerName = win.selectedItemsText[0] + start_n = win.startFrame + stop_n = win.stopFrame + video_to_track = posData.segm_data + for frame_i in range(start_n - 1, stop_n): + data_dict = posData.allData_li[frame_i] + lab = data_dict["labels"] + if lab is None: + break - correct_new_objs.append(newObj) - correct_lost_objs.append(lostObj) + video_to_track[frame_i] = lab + video_to_track = video_to_track[start_n - 1 : stop_n] - if not correct_new_objs: - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None - else: - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( - correct_new_objs, correct_lost_objs - ) - # self.annotateAssignedObjsAcdcTrackerSecondStep() + self.logger.info(f"Importing {trackerName} tracker...") + self.tracker, self.track_params, init_params = myutils.init_tracker( + posData, trackerName, qparent=self, return_init_params=True + ) + if self.track_params is None: + self.logger.info("Tracking aborted.") + return - def updateAssignedObjsAcdcTrackerSecondStep(self, newID): - posData = self.data[self.pos_i] - annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) - if annotInfo is None: + warningText = myutils.validate_tracker_input(self.tracker, video_to_track) + if warningText is not None: + self.logger.info(warningText) + self.warnTrackerInputNotValid(trackerName, warningText) return - new_objs_1st_step, lost_objs_1st_step = annotInfo - correct_new_objs, correct_lost_objs = [], [] - for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): - newObj_ID = posData.lab[newObj.slice][newObj.image][0] - if newObj_ID == newID: - # The ID of the new object tracked with 2nd step was - # manually edit --> do not annotate its linking to lost obj anymore - continue - correct_new_objs.append(newObj) - correct_lost_objs.append(lostObj) + if "image_channel_name" in self.track_params: + # Remove the channel name since it was already loaded in init_tracker + del self.track_params["image_channel_name"] - if not correct_new_objs: - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None - else: - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( - correct_new_objs, correct_lost_objs - ) - self.annotateAssignedObjsAcdcTrackerSecondStep() + track_params_log = { + key: value for key, value in self.track_params.items() if key != "image" + } + self.logger.info( + "Tracking parameters:\n\n" + f"Initialization parameters: {init_params}\n" + f"Track parameters: {track_params_log}" + ) - def annotateAssignedObjsAcdcTrackerSecondStep(self): - posData = self.data[self.pos_i] - annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) - if annotInfo is None: - return + last_cca_i = self.get_last_cca_frame_i() + if start_n - 2 <= last_cca_i and start_n > 1: + proceed = self.warnRepeatTrackingVideoWithAnnotations(last_cca_i, start_n) + if not proceed: + self.logger.info("Tracking aborted.") + return - new_objs_1st_step, lost_objs_1st_step = annotInfo - for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): - allContours = self.getObjContours(lostObj, all_external=True) - for objContours in allContours: - isObjVisible = self.isObjVisible(newObj.bbox) - if not isObjVisible: - continue - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - self.yellowContourScatterItem.addPoints(xx, yy) + self.logger.info(f"Removing annotations from frame n. {start_n}.") + self.resetCcaFuture(start_n - 1) - y1, x1 = self.getObjCentroid(lostObj.centroid) - y2, x2 = self.getObjCentroid(newObj.centroid) - xx, yy = self.geometry.line_coords( - y1, x1, y2, x2, dashed=False - ) - self.ax1_oldMothBudLinesItem.addPoints(xx, yy) + self.start_n = start_n + self.stop_n = stop_n - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + info_txt = f"Tracking from frame n. {start_n} to {stop_n}..." + self.logger.info(info_txt) - def setTrackedLostCentroids(self, prev_rp, tracked_lost_IDs): - """Store centroids of those IDs the tracker decided is fine to lose - (e.g., upon standard cell division the ID of the mother is fine) + self.progressWin = apps.QDialogWorkerProgress( + title="Tracking", parent=self, pbarDesc=info_txt + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(stop_n - start_n) + self.startTrackingWorker(posData, video_to_track) - Parameters - ---------- - prev_rp : skimage.measure.RegionProperties - List of region properties of the object in previous frame - tracked_lost_IDs : iterable - List-like container of the IDs that is fine to lose from previous - frame to current frame + def resetManualBackgroundItems(self): + self.initManualBackgroundImage() + self.resetManualBackgroundSpinboxID() + self.drawManualTrackingGhost(self.xHoverImg, self.yHoverImg) + self.drawManualBackgroundObj(self.xHoverImg, self.yHoverImg) - Note - ---- - This function stores the centroids because the user could change IDs - in multiple ways. Storing centroids is more robust. - """ - posData = self.data[self.pos_i] - frame_i = posData.frame_i - centroids = ( - self.tracked_lost_centroids_from_regionprops( - prev_rp, - tracked_lost_IDs, - ) - ) - if not centroids: + def resetManualBackgroundSpinboxID(self): + if not self.manualBackgroundButton.isChecked(): + self.manualBackgroundObj = None return - try: - posData.tracked_lost_centroids[frame_i].update(centroids) - except KeyError: - posData.tracked_lost_centroids[frame_i] = centroids - - def getTrackedLostIDs(self, prev_lab=None, IDs_in_frames=None, frame_i=None): - trackedLostIDs = set() posData = self.data[self.pos_i] - if self.isExportingVideo: - posData.trackedLostIDs = trackedLostIDs - return trackedLostIDs - - if frame_i is None: - frame_i = posData.frame_i - - if prev_lab is None: - prev_lab = self.get_labels( - from_store=True, - frame_i=posData.frame_i-1, - return_existing=False, - return_copy=False - ) - - if IDs_in_frames is None: - IDs_in_frames = posData.IDs - - try: - tracked_lost_centroids = posData.tracked_lost_centroids[frame_i] - except KeyError: - tracked_lost_centroids = set() + minID = min(posData.IDs, default=0) + self.manualBackgroundToolbar.spinboxID.setValue(minID) - result = self.tracked_lost_ids_from_centroids( - prev_lab, - tracked_lost_centroids, - IDs_in_frames, + def scan_future_id_propagation( + self, + target_id: int, + *, + current_frame_i: int, + frame_labels, + fallback_frame_labels, + include_unvisited: bool = False, + total_frames: int | None = None, + ) -> FutureIdPropagationScan: + return scan_future_id_propagation( + target_id, + current_frame_i=current_frame_i, + frame_labels=frame_labels, + fallback_frame_labels=fallback_frame_labels, + include_unvisited=include_unvisited, + total_frames=total_frames, ) - posData.tracked_lost_centroids[frame_i] = result.remaining_centroids - posData.trackedLostIDs = result.lost_ids - - return result.lost_ids - def manuallyEditTracking(self, tracked_lab, allIDs): + def separateByLabelling(self, lab, rp, maxID=None): + """ + Label each single object in posData.lab and if the result is more than + one object then we insert the separated object into posData.lab + """ + setRp = False posData = self.data[self.pos_i] - result = self.edit_id.apply_manual_edit_tracking( - tracked_lab, - posData.editID_info, - allIDs, - ) - posData.editID_info = result.remaining_edit_info - - def updateGhostMaskOpacity(self, alpha_percentage=None): - if alpha_percentage is None: - alpha_percentage = ( - self.manualTrackingToolbar.ghostMaskOpacitySpinbox.value() - ) - alpha = alpha_percentage/100 - self.ghostMaskItemLeft.setOpacity(alpha) - self.ghostMaskItemRight.setOpacity(alpha) - - def addManualTrackingItems(self): - self.ghostContourItemLeft.addToPlotItem() - self.ghostContourItemRight.addToPlotItem() + if maxID is None: + maxID = max(posData.IDs, default=1) + for obj in rp: + lab_obj = skimage.measure.label(obj.image) + rp_lab_obj = skimage.measure.regionprops(lab_obj) + if len(rp_lab_obj) <= 1: + continue + lab_obj += maxID + _slice = obj.slice # self.getObjSlice(obj.slice) + _objMask = obj.image # self.getObjImage(obj.image) + lab[_slice][_objMask] = lab_obj[_objMask] + setRp = True + maxID += 1 + return setRp - self.ghostMaskItemLeft.addToPlotItem() - self.ghostMaskItemRight.addToPlotItem() + def setManualBackgrounNextID(self): + posData = self.data[self.pos_i] + currentID = self.manualBackgroundObj.label + idx = posData.IDs_idxs[currentID] + next_idx = idx + 1 + if next_idx >= len(posData.IDs): + return + next_ID = posData.IDs[next_idx] + self.manualBackgroundToolbar.spinboxID.setValue(next_ID) - Y, X = self.img1.image.shape[:2] - self.ghostMaskItemLeft.initImage((Y, X)) - self.ghostMaskItemRight.initImage((Y, X)) + def setManualBackgroundImage(self): + if not self.manualBackgroundButton.isChecked(): + return - self.updateGhostMaskOpacity() + posData = self.data[self.pos_i] + if not hasattr(posData, "manualBackgroundImage"): + self.initManualBackgroundImage() - def removeManualTrackingItems(self): - self.ghostContourItemLeft.removeFromPlotItem() - self.ghostContourItemRight.removeFromPlotItem() + contours = [] + for obj in skimage.measure.regionprops(posData.manualBackgroundLab): + obj_contours = self.getObjContours(obj, all_external=True) + contours.extend(obj_contours) + textItem = self.manualBackgroundTextItems[obj.label] + textItem.setText(f"{obj.label}") + self.ax1.addItem(textItem) + yc, xc = obj.centroid + textItem.setPos(xc, yc) - self.ghostMaskItemLeft.removeFromPlotItem() - self.ghostMaskItemRight.removeFromPlotItem() + cv2.drawContours( + posData.manualBackgroundImage, contours, -1, (255, 0, 0, 200), 1 + ) + self.manualBackgroundImageItem.setImage(posData.manualBackgroundImage) - def addManualBackgroundItems(self): - self.manualBackgroundObjItem.addToPlotItem() - self.ax1.addItem(self.manualBackgroundImageItem) + def setManualBackgroundLab(self, load_from_store=False, debug=True): + posData = self.data[self.pos_i] + if posData.manualBackgroundLab is None: + self.initManualBackgroundImage() - def removeManualBackgroundItems(self): - self.manualBackgroundObjItem.removeFromPlotItem() - self.ax1.removeItem(self.manualBackgroundImageItem) + for obj in skimage.measure.regionprops(posData.manualBackgroundLab): + textItem = pg.TextItem(text="", color="r", anchor=(0.5, 0.5)) + if obj.label in self.manualBackgroundTextItems: + continue + self.manualBackgroundTextItems[obj.label] = textItem - def resetManualBackgroundSpinboxID(self): - if not self.manualBackgroundButton.isChecked(): - self.manualBackgroundObj = None - return + def setTrackedLostCentroids(self, prev_rp, tracked_lost_IDs): + """Store centroids of those IDs the tracker decided is fine to lose + (e.g., upon standard cell division the ID of the mother is fine) + + Parameters + ---------- + prev_rp : skimage.measure.RegionProperties + List of region properties of the object in previous frame + tracked_lost_IDs : iterable + List-like container of the IDs that is fine to lose from previous + frame to current frame + Note + ---- + This function stores the centroids because the user could change IDs + in multiple ways. Storing centroids is more robust. + """ posData = self.data[self.pos_i] - minID = min(posData.IDs, default=0) - self.manualBackgroundToolbar.spinboxID.setValue(minID) + frame_i = posData.frame_i - def initManualBackgroundObject(self, ID=None): - if not self.manualBackgroundButton.isChecked(): - self.manualBackgroundObj = None - return + for obj in prev_rp: + if obj.label not in tracked_lost_IDs: + continue - if ID is None: - ID = self.manualBackgroundToolbar.spinboxID.value() + int_centroid = tuple([int(val) for val in obj.centroid]) + try: + posData.tracked_lost_centroids[frame_i].add(int_centroid) + except KeyError: + posData.tracked_lost_centroids[frame_i] = {int_centroid} - posData = self.data[self.pos_i] - if ID not in posData.IDs: - self.manualBackgroundObj = None - self.manualBackgroundToolbar.showWarning( - f'The ID {ID} does not exist' + def trackFrame( + self, + prev_lab, + prev_rp, + curr_lab, + curr_rp, + curr_IDs, + assign_unique_new_IDs=True, + IDs=None, + unique_ID=None, + ): + if self.trackWithAcdcAction.isChecked(): + tracked_result = CellACDC_tracker.track_frame( + prev_lab, + prev_rp, + curr_lab, + curr_rp, + IDs_curr_untracked=curr_IDs, + setBrushID_func=self.setBrushID, + posData=self.data[self.pos_i], + assign_unique_new_IDs=assign_unique_new_IDs, + IDs=IDs, + unique_ID=unique_ID, + ) + elif self.trackWithYeazAction.isChecked(): + tracked_result = self.tracking_yeaz.correspondence( + prev_lab, curr_lab, use_modified_yeaz=True, use_scipy=True + ) + else: + tracked_result = self.trackFrameCustomTracker( + prev_lab, curr_lab, IDs=IDs, unique_ID=unique_ID ) - self.manualBackgroundObjItem.clear() - return - - ID_idx = posData.IDs_idxs[ID] - self.manualBackgroundObj = posData.rp[ID_idx] - self.manualBackgroundToolbar.clearInfoText() - self.manualBackgroundObj.contour = self.getObjContours( - self.manualBackgroundObj, local=True - ) - xx_contour = self.manualBackgroundObj.contour[:,0] - yy_contour = self.manualBackgroundObj.contour[:,1] - self.manualBackgroundObj.xx_contour = xx_contour - self.manualBackgroundObj.yy_contour = yy_contour + # Check if tracker also returns additional info + if isinstance(tracked_result, tuple): + tracked_lab, tracked_lost_IDs = tracked_result + self.handleAdditionalInfoRealTimeTracker(prev_rp, tracked_lost_IDs) + else: + tracked_lab = tracked_result - def initGhostObject(self, ID=None): - mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': - self.ghostObject = None - return + return tracked_lab - if not self.manualTrackingButton.isChecked(): - self.ghostObject = None - return + def trackFrameCustomTracker(self, prev_lab, currentLab, IDs=None, unique_ID=None): + if unique_ID is None: + unique_ID = self.setBrushID() + try: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, + currentLab, + unique_ID=unique_ID, + IDs=IDs, + **self.track_frame_params, + ) + except TypeError as err: + if str(err).find("an unexpected keyword argument 'unique_ID'") != -1: + try: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, IDs=IDs, **self.track_frame_params + ) + except TypeError as err: + if str(err).find("an unexpected keyword argument 'IDs'") != -1: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, **self.track_frame_params + ) + else: + raise err + elif str(err).find("an unexpected keyword argument 'IDs'") != -1: + try: + tracked_result = self.realTimeTracker.track_frame( + prev_lab, + currentLab, + unique_ID=unique_ID, + **self.track_frame_params, + ) + except TypeError as err: + if ( + str(err).find("an unexpected keyword argument 'unique_ID'") + != -1 + ): + tracked_result = self.realTimeTracker.track_frame( + prev_lab, currentLab, **self.track_frame_params + ) + else: + raise err + else: + raise err + return tracked_result - if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): - self.ghostObject = None - return + def trackManuallyAddedObject( + self, + added_IDs: List[int] | int | Set[int], + isNewID: bool, + wl_update: bool = True, + wl_track_og_curr: bool = False, + ): + """Track object added manually on frame that was already visited. - if ID is None: - ID = self.manualTrackingToolbar.spinboxID.value() + Parameters + ---------- + added_IDs : int | list of int | set + ID or IDs of the object added manually + isNewID : bool + If True, the added object is new - posData = self.data[self.pos_i] - if posData.frame_i == 0: - self.ghostObject = None - return + Notes + ----- + This method tracks the new added object against the previous frame + labels. If the ID determined by tracking is different from `added_ID` + (meaning that tracking thinks the new ID should be changed to the + tracked ID) and the tracked ID is not already existing (which would + otherwise causing merging) we assign the tracked ID to the object with + `added_ID`. - prevFrameRp = posData.allData_li[posData.frame_i-1]['regionprops'] - if prevFrameRp is None: - self.ghostObject = None + If instead the tracked ID is the same as `added_ID` we are dealing + with a truly new object. In this case we want to try tracking it against + the next frame (since the next frame was already validated). + As before, we assign the tracked ID (against the next frame) only if + not already existing in current frame (to avoid merging). + """ + if self.isSnapshot: return - for obj in prevFrameRp: - if obj.label != ID: - continue - self.ghostObject = obj - break - else: - self.ghostObject = None - self.manualTrackingToolbar.showWarning( - f'The ID {ID} does not exist in previous frame ' - '--> starting a new track.' - ) + if not isNewID: return - self.manualTrackingToolbar.clearInfoText() + if isinstance(added_IDs, int): + added_IDs = [added_IDs] - self.ghostObject.contour = self.getObjContours( - self.ghostObject, local=True + posData = self.data[self.pos_i] + tracked_lab = self.tracking( + enforce=True, assign_unique_new_IDs=False, return_lab=True, IDs=added_IDs ) - self.ghostObject.xx_contour = self.ghostObject.contour[:,0] - self.ghostObject.yy_contour = self.ghostObject.contour[:,1] - - self.ghostMaskItemLeft.initLookupTable(self.lut[ID]) - self.ghostMaskItemRight.initLookupTable(self.lut[ID]) + self.clearAssignedObjsSecondStep() + if tracked_lab is None: + return - def clearGhost(self): - self.clearGhostContour() - self.clearGhostMask() + # Track only new object + prevIDs = posData.allData_li[posData.frame_i - 1]["IDs"] - def clearManualBackgroundAnnotations(self): - try: - for textItem in self.manualBackgroundTextItems.values(): - textItem.setText('') - except Exception as error: - pass + # mask = np.zeros(posData.lab.shape, dtype=bool) + update_rp = False - def clearGhostContour(self): - self.ghostContourItemLeft.clear() - self.ghostContourItemRight.clear() - self.manualBackgroundObjItem.clear() + for added_ID in added_IDs: + # try: + # obj = posData.rp[added_ID] # ID not present + # mask[obj.slice][obj.image] = True - def clearGhostMask(self): - self.ghostMaskItemLeft.clear() - self.ghostMaskItemRight.clear() + # except IndexError as err: + mask = posData.lab == added_ID + try: + trackedID = tracked_lab[mask][0] + except IndexError: + # added_ID is not present + continue - def _drawGhostContour(self, x, y): - if self.ghostObject is None: - return + isTrackedIDalreadyPresentAndNotNew = ( + posData.IDs_idxs.get(trackedID) is not None and added_ID != trackedID + ) + if isTrackedIDalreadyPresentAndNotNew: + continue - ID = self.ghostObject.label - yc, xc = self.ghostObject.local_centroid - Dx = x-xc - Dy = y-yc - xx = self.ghostObject.xx_contour + Dx - yy = self.ghostObject.yy_contour + Dy - self.ghostContourItemLeft.setData( - xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) - self.ghostContourItemRight.setData( - xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) + isTrackedIDinPrevIDs = trackedID in prevIDs + if isTrackedIDinPrevIDs: + posData.lab[mask] = trackedID + else: + # New object where we can try to track against next frame + trackedID = self.trackNewIDtoNewIDsFutureFrame(added_ID, mask) + if trackedID is None: + self.clearAssignedObjsSecondStep() + continue + posData.lab[mask] = trackedID - def _drawManualBackgroundObjContour(self, x, y): - if self.manualBackgroundObj is None: - return + self.keepOnlyNewIDAssignedObjsSecondStep(trackedID) + update_rp = True - ID = self.manualBackgroundObj.label - yc, xc = self.manualBackgroundObj.local_centroid - Dx = x-xc - Dy = y-yc - xx = self.manualBackgroundObj.xx_contour + Dx - yy = self.manualBackgroundObj.yy_contour + Dy - self.manualBackgroundObjItem.setData( - xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) + if update_rp: + self.update_rp(wl_update=wl_update) - def _drawGhostMask(self, x, y): - if self.ghostObject is None: + def trackNewIDtoNewIDsFutureFrame(self, newID, newIDmask): + posData = self.data[self.pos_i] + try: + nextLab = posData.allData_li[posData.frame_i + 1]["labels"] + except IndexError: + # This is last frame --> there are no future frames return - self.clearGhostMask() - ID = self.ghostObject.label - h, w = self.ghostObject.image.shape[-2:] - yc, xc = self.ghostObject.local_centroid - Dx = int(x-xc) - Dy = int(y-yc) - bbox = ((Dy, Dy+h), (Dx, Dx+w)) - - Y, X = self.currentLab2D.shape - slices = self.geometry.local_to_global_slices(bbox, (Y, X)) - slice_global_to_local, slice_crop_local = slices - - obj_image = self.ghostObject.image[slice_crop_local] + if nextLab is None: + return - self.ghostMaskItemLeft.image[slice_global_to_local][obj_image] = ID - self.ghostMaskItemLeft.updateGhostImage( - fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) + newID_lab = np.zeros_like(posData.lab) + newID_lab[newIDmask] = newID + newLab_rp = [posData.rp[posData.IDs_idxs[newID]]] + newLab_IDs = [newID] + nextRp = posData.allData_li[posData.frame_i + 1]["regionprops"] - self.ghostMaskItemRight.image[slice_global_to_local][obj_image] = ID - self.ghostMaskItemRight.updateGhostImage( - fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x + tracked_lab = self.trackFrame( + nextLab, + nextRp, + newID_lab, + newLab_rp, + newLab_IDs, + assign_unique_new_IDs=False, ) + trackedID = tracked_lab[newID_lab > 0][0] + if trackedID == newID: + # Object does not exist in future frame --> do not track + return - def drawManualBackgroundObj(self, x, y): - if x is None or y is None: - self.clearGhost() + if posData.IDs_idxs.get(trackedID) is not None: + # Tracked ID already exists --> do not track to avoid merging return - self._drawManualBackgroundObjContour(x, y) + return trackedID - def drawManualTrackingGhost(self, x, y): - if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): + def trackSubsetIDs(self, subsetIDs: Iterable[int]): + posData = self.data[self.pos_i] + if posData.frame_i == 0: return - if x is None or y is None: - self.clearGhost() - return + subsetLab = np.zeros_like(posData.lab) + for subsetID in subsetIDs: + subsetLab[posData.lab == subsetID] = subsetID - if self.manualTrackingToolbar.ghostContourRadiobutton.isChecked(): - self._drawGhostContour(x, y) - else: - self._drawGhostMask(x, y) + prev_lab = posData.allData_li[posData.frame_i - 1]["labels"] + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + tracked_lab = self.trackFrame( + prev_lab, + prev_rp, + posData.lab, + posData.rp, + posData.IDs, + assign_unique_new_IDs=True, + ) + doUpdateRp = False + for subsetID in subsetIDs: + subsetIDmask = posData.lab == subsetID + trackedID = tracked_lab[subsetIDmask][0] + if trackedID == subsetID: + continue - def setManualBackgroundImage(self): - if not self.manualBackgroundButton.isChecked(): + for y, x, new_ID in posData.editID_info: + if new_ID == subsetID: + # Do not track because it was manually edited + break + + posData.lab[subsetIDmask] = tracked_lab[subsetIDmask] + doUpdateRp = True + + if not doUpdateRp: return - posData = self.data[self.pos_i] - if not hasattr(posData, 'manualBackgroundImage'): - self.initManualBackgroundImage() + self.update_rp() - contours = [] - for obj in skimage.measure.regionprops(posData.manualBackgroundLab): - obj_contours = self.getObjContours(obj, all_external=True) - contours.extend(obj_contours) - textItem = self.manualBackgroundTextItems[obj.label] - textItem.setText(f'{obj.label}') - self.ax1.addItem(textItem) - yc, xc = obj.centroid - textItem.setPos(xc, yc) + def tracked_lost_centroids_from_regionprops( + self, + regionprops, + tracked_lost_ids, + ) -> set[tuple[int, ...]]: + return tracked_lost_centroids_from_regionprops( + regionprops, + tracked_lost_ids, + ) - cv2.drawContours( - posData.manualBackgroundImage, contours, -1, (255, 0, 0, 200), 1 + def tracked_lost_ids_from_centroids( + self, + previous_labels, + tracked_lost_centroids, + ids_in_frame, + ) -> TrackedLostIdsResult: + return tracked_lost_ids_from_centroids( + previous_labels, + tracked_lost_centroids, + ids_in_frame, ) - self.manualBackgroundImageItem.setImage(posData.manualBackgroundImage) - def setManualBackgrounNextID(self): + @exception_handler + def tracking( + self, + enforce=False, + DoManualEdit=True, + storeUndo=False, + prev_lab=None, + prev_rp=None, + return_lab=False, + assign_unique_new_IDs=True, + separateByLabel=True, + wl_update=True, + IDs=None, + against_next=False, + ): posData = self.data[self.pos_i] - currentID = self.manualBackgroundObj.label - idx = posData.IDs_idxs[currentID] - next_idx = idx + 1 - if next_idx >= len(posData.IDs): + + if self.doSkipTracking(against_next, enforce): + self.setLostNewOldPrevIDs() return - next_ID = posData.IDs[next_idx] - self.manualBackgroundToolbar.spinboxID.setValue(next_ID) - def clearManualBackgroundObject(self, ID): - posData = self.data[self.pos_i] - mask = posData.manualBackgroundLab==ID - posData.manualBackgroundImage[mask, :] = 0 - posData.manualBackgroundLab[mask] = 0 + """Tracking starts here""" + staturBarLabelText = self.statusBarLabel.text() + self.statusBarLabel.setText("Tracking...") - def addManualBackgroundObject(self, x, y): - posData = self.data[self.pos_i] + if storeUndo: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) - if not hasattr(self, 'manualBackgroundObj'): - self.initManualBackgroundObject() + # First separate by labelling + if separateByLabel: + maxID = max(posData.IDs, default=1) + setRp = core.split_connected_components( + posData.lab, rp=posData.rp, max_ID=maxID + ) + if setRp: + self.update_rp( + wl_update=wl_update, + ) - Y, X = self.currentLab2D.shape - ymin, xmin, ymax, xmax = self.manualBackgroundObj.bbox - width, height = xmax-xmin, ymax-ymin - yc, xc = self.manualBackgroundObj.local_centroid - xstart, ystart = round(x-xc), round(y-yc) - xstart = xstart if xstart >= 0 else 0 - ystart = ystart if ystart >= 0 else 0 + if prev_lab is None: + if not against_next: + prev_lab = posData.allData_li[posData.frame_i - 1]["labels"] + else: + prev_lab = posData.allData_li[posData.frame_i + 1]["labels"] + if prev_rp is None: + if not against_next: + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + else: + prev_rp = posData.allData_li[posData.frame_i + 1]["regionprops"] - xend = xstart+width - yend = ystart+height - xend = xend if xend <= X else X - yend = yend if yend <= Y else Y + unique_ID = None + if posData.frame_i < self.get_last_tracked_i(): + unique_ID = self.setBrushID(return_val=True) - width = xend-xstart - height = yend-ystart + tracked_lab = self.trackFrame( + prev_lab, + prev_rp, + posData.lab, + posData.rp, + posData.IDs, + assign_unique_new_IDs=assign_unique_new_IDs, + IDs=IDs, + unique_ID=unique_ID, + ) - obj_image = self.manualBackgroundObj.image[:height, :width] - obj_slice = (slice(ystart, yend), slice(xstart, xend)) - ID = self.manualBackgroundObj.label - self.clearManualBackgroundObject(ID) - posData.manualBackgroundLab[obj_slice][obj_image] = ID + if DoManualEdit: + # Correct tracking with manually changed IDs + rp = skimage.measure.regionprops(tracked_lab) + IDs = [obj.label for obj in rp] + self.manuallyEditTracking(tracked_lab, IDs) - if ID in self.manualBackgroundTextItems: - self.manualBackgroundTextItems[ID].setPos(x, y) - return + if return_lab: + QTimer.singleShot( + 50, partial(self.statusBarLabel.setText, staturBarLabelText) + ) + return tracked_lab - textItem = pg.TextItem( - text=str(ID), color='r', anchor=(0.5, 0.5) + # Update labels, regionprops and determine new and lost IDs + posData.lab = tracked_lab + self.update_rp( + wl_update=wl_update, ) - textItem.setFont(font_13px) - textItem.setPos(x, y) - self.manualBackgroundTextItems[ID] = textItem - - self.ax1.addItem(textItem) + self.setAllTextAnnotations() + QTimer.singleShot(50, partial(self.statusBarLabel.setText, staturBarLabelText)) - def setManualBackgroundLab(self, load_from_store=False, debug=True): + def updateAssignedObjsAcdcTrackerSecondStep(self, newID): posData = self.data[self.pos_i] - if posData.manualBackgroundLab is None: - self.initManualBackgroundImage() + annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) + if annotInfo is None: + return - for obj in skimage.measure.regionprops(posData.manualBackgroundLab): - textItem = pg.TextItem(text='', color='r', anchor=(0.5, 0.5)) - if obj.label in self.manualBackgroundTextItems: + new_objs_1st_step, lost_objs_1st_step = annotInfo + correct_new_objs, correct_lost_objs = [], [] + for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): + newObj_ID = posData.lab[newObj.slice][newObj.image][0] + if newObj_ID == newID: + # The ID of the new object tracked with 2nd step was + # manually edit --> do not annotate its linking to lost obj anymore continue - self.manualBackgroundTextItems[obj.label] = textItem + correct_new_objs.append(newObj) + correct_lost_objs.append(lostObj) - def manualTracking_cb(self, checked): - self.manualTrackingToolbar.setVisible(checked) - if checked: - self.realTimeTrackingToggle.previousStatus = ( - self.realTimeTrackingToggle.isChecked() - ) - self.realTimeTrackingToggle.setChecked(False) - self.UserEnforced_DisabledTracking_previousStatus = ( - self.UserEnforced_DisabledTracking + if not correct_new_objs: + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None + else: + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( + correct_new_objs, + correct_lost_objs, ) - self.UserEnforced_Tracking_previousStatus = ( - self.UserEnforced_Tracking + self.annotateAssignedObjsAcdcTrackerSecondStep() + + def updateGhostMaskOpacity(self, alpha_percentage=None): + if alpha_percentage is None: + alpha_percentage = ( + self.manualTrackingToolbar.ghostMaskOpacitySpinbox.value() ) + alpha = alpha_percentage / 100 + self.ghostMaskItemLeft.setOpacity(alpha) + self.ghostMaskItemRight.setOpacity(alpha) - self.UserEnforced_DisabledTracking = True - self.UserEnforced_Tracking = False - self.initGhostObject() - self.addManualTrackingItems() + def updateLastCheckedFrameWidgets(self, last_tracked_i): + self.navigateScrollBar.setMaximum(last_tracked_i + 1) + self.navSpinBox.setMaximum(last_tracked_i + 1) + self.lastTrackedFrameLabel.setText( + f"Last checked frame n. = {last_tracked_i + 1}" + ) + + def warnRepeatTrackingVideoOnVisitedFrames(self, last_tracked_i, start_n): + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + "You are repeating tracking on frames that have already " + "been visited/tracked before.

" + "This will very likely make the annotations wrong.

" + "If you really want to repeat tracking on the frames before " + f"{last_tracked_i + 1} the annotations from frame " + f"{start_n} to frame {last_tracked_i + 1} " + "will be removed.

" + "Do you want to continue?" + ) + noButton, yesButton = msg.warning( + self, + "Repating tracking with annotations!", + txt, + buttonsTexts=( + " No, stop tracking and keep annotations.", + " Yes, repeat tracking and DELETE annotations.", + ), + ) + if msg.cancel: + return False + + if msg.clickedButton == noButton: + return False else: - self.realTimeTrackingToggle.setChecked( - self.realTimeTrackingToggle.previousStatus - ) - self.UserEnforced_DisabledTracking = ( - self.UserEnforced_DisabledTracking_previousStatus - ) - self.UserEnforced_Tracking = ( - self.UserEnforced_Tracking_previousStatus - ) - self.removeManualTrackingItems() - self.clearGhost() + return True - def manualBackground_cb(self, checked): - if checked: - posData = self.data[self.pos_i] - minID = min(posData.IDs, default=0) - if minID == self.manualBackgroundToolbar.spinboxID.value(): - self.initManualBackgroundObject() - else: - self.manualBackgroundToolbar.spinboxID.setValue(minID) - # self.initManualBackgroundObject() - # self.initManualBackgroundImage() - self.addManualBackgroundItems() - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.manualBackgroundButton) - self.connectLeftClickButtons() - self.updateAllImages() + def warnRepeatTrackingVideoWithAnnotations(self, last_tracked_i, start_n): + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + "You are repeating tracking on frames that have cell cycle " + "annotations.

" + "This will very likely make the annotations wrong.

" + "If you really want to repeat tracking on the frames before " + f"{last_tracked_i + 1} the annotations from frame " + f"{start_n} to frame {last_tracked_i + 1} " + "will be removed.

" + "Do you want to continue?" + ) + noButton, yesButton = msg.warning( + self, + "Repating tracking with annotations!", + txt, + buttonsTexts=( + " No, stop tracking and keep annotations.", + " Yes, repeat tracking and DELETE annotations.", + ), + ) + if msg.cancel: + return False + + if msg.clickedButton == noButton: + return False else: - self.removeManualTrackingItems() - self.clearGhost() - self.clearManualBackgroundAnnotations() - self.manualBackgroundToolbar.setVisible(checked) \ No newline at end of file + return True + + def warnTrackerInputNotValid(self, trackerName, warningText): + msg = widgets.myMessageBox(wrapText=False) + txt = warningText.replace("\n", "
") + txt = html_utils.paragraph( + f"{txt}

" + "Tracking process will be cancelled. Thank you for your patience!" + ) + msg.warning(self, "Invalid input for tracker", txt) diff --git a/cellacdc/mixins/undo_redo.py b/cellacdc/mixins/undo_redo.py index 7d42637ee..9c3a36100 100644 --- a/cellacdc/mixins/undo_redo.py +++ b/cellacdc/mixins/undo_redo.py @@ -8,125 +8,186 @@ from collections import defaultdict -class UndoRedoView: - """Qt-facing adapter around undo/redo actions and state restoration.""" - LEGACY_METHODS = ( - 'clearUndoQueue', - 'askPropagateChangePast', - 'propagateMergeObjsPast', - 'propagateChange', - 'addCcaState', - 'addCurrentState', - 'getCurrentState', - 'storeUndoRedoStates', - 'storeUndoRedoCca', - 'undoCustomAnnotation', - 'UndoCca', - 'undo', - 'redo', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) +class UndoRedoMixin: + """Qt-facing adapter around undo/redo actions and state restoration.""" + + # @exec_time - def clearUndoQueue(self): + def UndoCca(self): posData = self.data[self.pos_i] - self.UndoCount = 0 - self.redoAction.setEnabled(False) - self.undoAction.setEnabled(False) - posData.UndoRedoStates = self.empty_frame_stacks( - posData.SizeT - ) - posData.UndoRedoCcaStates = self.empty_frame_stacks( - posData.SizeT - ) - if hasattr(self, 'undoAddPointQueueMapper'): - self.undoAddPointQueueMapper = ( - self.empty_add_point_queue() - ) + # Undo current ccaState + storeState = False + if self.UndoCount == 0: + undoId = uuid.uuid4() + self.addCcaState(posData.frame_i, posData.cca_df, undoId) + storeState = True - def askPropagateChangePast(self, change_txt): - txt = html_utils.paragraph(f""" + # Get previously stored state + self.UndoCount += 1 + currentCcaStates = posData.UndoRedoCcaStates[posData.frame_i] + prevCcaState = currentCcaStates[self.UndoCount] + posData.cca_df = prevCcaState["cca_df"] + self.store_cca_df() + self.updateAllImages() - """Headless undo/redo stack operations.""" + # Check if we have undone all states + if len(currentCcaStates) > self.UndoCount: + # There are no states left to undo for current frame_i + self.undoAction.setEnabled(False) - def empty_frame_stacks(self, size_t: int) -> list[list]: - return [[] for _ in range(size_t)] + # Undo all past and future frames that has a last status inserted + # when modyfing current frame + prevStateId = prevCcaState["id"] + for frame_i in range(0, posData.SizeT): + if storeState: + cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) + if cca_df_i is None: + break + # Store current state to enable redoing it + self.addCcaState(frame_i, cca_df_i, undoId) - def empty_add_point_queue(self): - return defaultdict(list) + CcaStates_i = posData.UndoRedoCcaStates[frame_i] + if len(CcaStates_i) <= self.UndoCount: + # There are no states to undo for frame_i + continue - def trim_stack(self, states: list, *, max_size: int) -> None: - if len(states) > max_size: - states.pop(-1) + CcaState_i = CcaStates_i[self.UndoCount] + id_i = CcaState_i["id"] + if id_i != prevStateId: + # The id of the state in frame_i is different from current frame + continue - def can_undo_labels(self, undo_count: int, states: list) -> bool: - return undo_count < len(states) - 1 + cca_df_i = CcaState_i["cca_df"] + self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) - def can_redo_labels(self, undo_count: int) -> bool: - return undo_count > 0 + self.resetWillDivideInfo() + self.enqAutosave() - def should_disable_undo_after_cca( - self, - undo_count: int, - states: list, - ) -> bool: - return len(states) > undo_count + def addCcaState(self, frame_i, cca_df, undoId): + posData = self.data[self.pos_i] + posData.UndoRedoCcaStates[frame_i].insert( + 0, {"id": undoId, "cca_df": cca_df.copy()} + ) + def addCurrentState(self, storeImage=False, storeOnlyZoom=False): + posData = self.data[self.pos_i] + if posData.cca_df is not None: + cca_df = posData.cca_df.copy() + else: + cca_df = None + + if storeImage: + image = self.img1.image.copy() + else: + image = None + + if storeOnlyZoom: + labels, crop_slice = transformation.crop_2D( + self.currentLab2D, self.ax1.viewRange(), tolerance=10, return_copy=False + ) + if self.isSegm3D: + z = self.z_lab(checkIfProj=True) + if z is None: + z_slice = slice(0, len(posData.lab)) + crop_slice = (z_slice, *crop_slice) + labels = posData.lab[crop_slice].copy() + else: + z_slice = z + crop_slice = (z_slice, *crop_slice) + labels = labels.copy() + else: + labels = labels.copy() + else: + labels = posData.lab.copy() + crop_slice = None + + state = { + "image": image, + "labels": labels, + "editID_info": posData.editID_info.copy(), + "binnedIDs": posData.binnedIDs.copy(), + "keptObejctsIDs": self.keptObjectsIDs.copy(), + "ripIDs": posData.ripIDs.copy(), + "cca_df": cca_df, + "crop_slice": crop_slice, + } + posData.UndoRedoStates[posData.frame_i].insert(0, state) + + def askPropagateChangePast(self, change_txt): + txt = html_utils.paragraph(f""" Do you want to propagate the change "{change_txt}" to the past frames? """) msg = widgets.myMessageBox(wrapText=False) yesButton, _ = msg.question( - self.host, 'Propagate change to past frames', txt, - buttonsTexts=('Yes', 'No') + self, "Propagate change to past frames", txt, buttonsTexts=("Yes", "No") ) return msg.clickedButton == yesButton - def propagateMergeObjsPast(self, IDs_to_merge): - self.store_data(autosave=False) + def can_redo_labels(self, undo_count: int) -> bool: + return undo_count > 0 + + def can_undo_labels(self, undo_count: int, states: list) -> bool: + return undo_count < len(states) - 1 + + def clearUndoQueue(self): posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - for past_frame_i in range(posData.frame_i-1, -1, -1): - posData.frame_i = past_frame_i - self.get_data() + self.UndoCount = 0 + self.redoAction.setEnabled(False) + self.undoAction.setEnabled(False) + posData.UndoRedoStates = [[] for _ in range(posData.SizeT)] + posData.UndoRedoCcaStates = [[] for _ in range(posData.SizeT)] + if hasattr(self, "undoAddPointQueueMapper"): + self.undoAddPointQueueMapper = defaultdict(list) - IDs = posData.allData_li[past_frame_i]['IDs'] - stop_loop = False - for ID in IDs_to_merge: - if ID not in IDs: - stop_loop = True - break + def empty_add_point_queue(self): + return defaultdict(list) - if ID == 0: - continue - posData.lab[posData.lab==ID] = self.firstID - self.update_rp() + def empty_frame_stacks(self, size_t: int) -> list[list]: + return [[] for _ in range(size_t)] - self.store_data(autosave=False) + def getCurrentState(self): + posData = self.data[self.pos_i] + i = posData.frame_i + c = self.UndoCount + state = posData.UndoRedoStates[i][c] + if state["image"] is None: + image_left = None + else: + image_left = state["image"].copy() - if stop_loop: - break + crop_slice = state["crop_slice"] + if crop_slice is None: + posData.lab = state["labels"].copy() + elif self.isSegm3D: + z_slice, slice_y, slice_x = crop_slice + posData.lab[..., z_slice, slice_y, slice_x] = state["labels"].copy() + else: + slice_y, slice_x = crop_slice + posData.lab[..., slice_y, slice_x] = state["labels"].copy() - posData.frame_i = current_frame_i - self.get_data() + posData.editID_info = state["editID_info"].copy() + posData.binnedIDs = state["binnedIDs"].copy() + posData.ripIDs = state["ripIDs"].copy() + self.keptObjectsIDs = state["keptObejctsIDs"].copy() + cca_df = state["cca_df"] + if cca_df is not None: + posData.cca_df = state["cca_df"].copy() + else: + posData.cca_df = None + return image_left def propagateChange( - self, modID, modTxt, doNotShow, UndoFutFrames, - applyFutFrames, applyTrackingB=False, force=False - ): + self, + modID, + modTxt, + doNotShow, + UndoFutFrames, + applyFutFrames, + applyTrackingB=False, + force=False, + ): """ This function determines whether there are already visited future frames that contains "modID". If so, it triggers a pop-up asking the user @@ -134,46 +195,64 @@ def propagateChange( """ posData = self.data[self.pos_i] # Do not check the future for the last frame - if posData.frame_i+1 == posData.SizeT: + if posData.frame_i + 1 == posData.SizeT: # No future frames to propagate the change to return False, False, None, doNotShow includeUnvisited = posData.includeUnvisitedInfo.get(modTxt, False) - future_scan = self.host.view_model.tracking.scan_future_id_propagation( - modID, - current_frame_i=posData.frame_i, - frame_labels=( - data_dict['labels'] for data_dict in posData.allData_li - ), - fallback_frame_labels=posData.segm_data, - include_unvisited=includeUnvisited, - total_frames=posData.SizeT, - ) - last_tracked_i = future_scan.last_tracked_i + areFutureIDs_affected = [] + # Get number of future frames already visited and check if future + # frames has an ID affected by the change + last_tracked_i_found = False + segmSizeT = len(posData.segm_data) + for i in range(posData.frame_i + 1, segmSizeT): + if posData.allData_li[i]["labels"] is None: + if not last_tracked_i_found: + # We set last tracked frame at -1 first None found + last_tracked_i = i - 1 + last_tracked_i_found = True + if not includeUnvisited: + # Stop at last visited frame since includeUnvisited = False + break + else: + lab = posData.segm_data[i] + else: + lab = posData.allData_li[i]["labels"] + + if modID in lab: + areFutureIDs_affected.append(True) + + if not last_tracked_i_found: + # All frames have been visited in segm&track mode + last_tracked_i = posData.SizeT - 1 if last_tracked_i == posData.frame_i and not includeUnvisited: # No future frames to propagate the change to return False, False, None, doNotShow - if not future_scan.has_affected_future_ids and not force: + if not areFutureIDs_affected and not force: # There are future frames but they are not affected by the change return UndoFutFrames, False, None, doNotShow # Ask what to do unless the user has previously checked doNotShowAgain if doNotShow: endFrame_i = last_tracked_i - if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': - self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) + if applyFutFrames and not UndoFutFrames and modTxt == "Edit ID": + self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i + 1)) return UndoFutFrames, applyFutFrames, endFrame_i, doNotShow else: addApplyAllButton = ( - modTxt == 'Delete ID' or modTxt == 'Edit ID' - or modTxt == 'Assign new ID' + modTxt == "Delete ID" + or modTxt == "Edit ID" + or modTxt == "Assign new ID" ) ffa = apps.FutureFramesAction_QDialog( - posData.frame_i+1, last_tracked_i, modTxt, - applyTrackingB=applyTrackingB, parent=self.host, - addApplyAllButton=addApplyAllButton + posData.frame_i + 1, + last_tracked_i, + modTxt, + applyTrackingB=applyTrackingB, + parent=self, + addApplyAllButton=addApplyAllButton, ) ffa.exec_() decision = ffa.decision @@ -184,140 +263,87 @@ def propagateChange( endFrame_i = ffa.endFrame_i doNotShowAgain = ffa.doNotShowCheckbox.isChecked() askAction = self.askHowFutureFramesActions[modTxt] - askAction.setChecked( not doNotShowAgain) + askAction.setChecked(not doNotShowAgain) askAction.setDisabled(False) self.onlyTracking = False - if decision == 'apply_and_reinit': + if decision == "apply_and_reinit": UndoFutFrames = True applyFutFrames = False - elif decision == 'apply_and_NOTreinit': + elif decision == "apply_and_NOTreinit": UndoFutFrames = False applyFutFrames = False - elif decision == 'apply_to_all_visited': + elif decision == "apply_to_all_visited": UndoFutFrames = False applyFutFrames = True - elif decision == 'only_tracking': + elif decision == "only_tracking": UndoFutFrames = False applyFutFrames = True self.onlyTracking = True - elif decision == 'apply_to_all': + elif decision == "apply_to_all": UndoFutFrames = False applyFutFrames = True posData.includeUnvisitedInfo[modTxt] = True - if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': - self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) + if applyFutFrames and not UndoFutFrames and modTxt == "Edit ID": + self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i + 1)) return UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain - def addCcaState(self, frame_i, cca_df, undoId): - posData = self.data[self.pos_i] - posData.UndoRedoCcaStates[frame_i].insert( - 0, {'id': undoId, 'cca_df': cca_df.copy()} - ) - - def addCurrentState(self, storeImage=False, storeOnlyZoom=False): + def propagateMergeObjsPast(self, IDs_to_merge): + self.store_data(autosave=False) posData = self.data[self.pos_i] - if posData.cca_df is not None: - cca_df = posData.cca_df.copy() - else: - cca_df = None + current_frame_i = posData.frame_i + for past_frame_i in range(posData.frame_i - 1, -1, -1): + posData.frame_i = past_frame_i + self.get_data() - if storeImage: - image = self.img1.image.copy() - else: - image = None + IDs = posData.allData_li[past_frame_i]["IDs"] + stop_loop = False + for ID in IDs_to_merge: + if ID not in IDs: + stop_loop = True + break - if storeOnlyZoom: - labels, crop_slice = self.host.view_model.geometry.crop_2d( - self.currentLab2D, self.ax1.viewRange(), tolerance=10, - return_copy=False - ) - if self.isSegm3D: - z = self.z_lab(checkIfProj=True) - if z is None: - z_slice = slice(0, len(posData.lab)) - crop_slice = (z_slice, *crop_slice) - labels = posData.lab[crop_slice].copy() - else: - z_slice = z - crop_slice = (z_slice, *crop_slice) - labels = labels.copy() - else: - labels = labels.copy() - else: - labels = posData.lab.copy() - crop_slice = None + if ID == 0: + continue + posData.lab[posData.lab == ID] = self.firstID + self.update_rp() - state = { - 'image': image, - 'labels': labels, - 'editID_info': posData.editID_info.copy(), - 'binnedIDs': posData.binnedIDs.copy(), - 'keptObejctsIDs': self.keptObjectsIDs.copy(), - 'ripIDs': posData.ripIDs.copy(), - 'cca_df': cca_df, - 'crop_slice': crop_slice - } - posData.UndoRedoStates[posData.frame_i].insert(0, state) + self.store_data(autosave=False) - # posData.storedLab = np.array(posData.lab, order='K', copy=True) - # self.storeStateWorker.callbackOnDone = callbackOnDone - # self.storeStateWorker.enqueue(posData, self.img1.image) + if stop_loop: + break - def getCurrentState(self): - posData = self.data[self.pos_i] - i = posData.frame_i - c = self.UndoCount - state = posData.UndoRedoStates[i][c] - if state['image'] is None: - image_left = None - else: - image_left = state['image'].copy() + posData.frame_i = current_frame_i + self.get_data() - crop_slice = state['crop_slice'] - if crop_slice is None: - posData.lab = state['labels'].copy() - elif self.isSegm3D: - z_slice, slice_y, slice_x = crop_slice - posData.lab[..., z_slice, slice_y, slice_x] = state['labels'].copy() - else: - slice_y, slice_x = crop_slice - posData.lab[..., slice_y, slice_x] = state['labels'].copy() + def redo(self): + self.data[self.pos_i] + # Get previously stored state + if self.UndoCount > 0: + self.UndoCount -= 1 + # Since we have redone then it is possible to undo + self.undoAction.setEnabled(True) - posData.editID_info = state['editID_info'].copy() - posData.binnedIDs = state['binnedIDs'].copy() - posData.ripIDs = state['ripIDs'].copy() - self.keptObjectsIDs = state['keptObejctsIDs'].copy() - cca_df = state['cca_df'] - if cca_df is not None: - posData.cca_df = state['cca_df'].copy() - else: - posData.cca_df = None - return image_left + # Restore state + image_left = self.getCurrentState() + self.update_rp() + self.updateAllImages(image=image_left) + self.store_data() - # @exec_time - def storeUndoRedoStates( - self, UndoFutFrames, storeImage=False, storeOnlyZoom=False - ): - posData = self.data[self.pos_i] - if UndoFutFrames: - # Since we modified current frame all future frames that were already - # visited are not valid anymore. Undo changes there - self.reInitLastSegmFrame(updateImages=False) + if not self.UndoCount > 0: + # We have redone all available states + self.redoAction.setEnabled(False) - # Keep only 5 Undo/Redo states - self.trim_label_states( - posData.UndoRedoStates[posData.frame_i] - ) + if self.whitelistIDsButton.isChecked(): + self.whitelistHighlightIDs() - # Restart count from the most recent state (index 0) - # NOTE: index 0 is most recent state before doing last change - self.UndoCount = 0 - self.undoAction.setEnabled(True) - self.addCurrentState( - storeImage=storeImage, storeOnlyZoom=storeOnlyZoom - ) + def should_disable_undo_after_cca( + self, + undo_count: int, + states: list, + ) -> bool: + return len(states) > undo_count def storeUndoRedoCca(self, frame_i, cca_df, undoId): if self.isSnapshot: @@ -339,63 +365,29 @@ def storeUndoRedoCca(self, frame_i, cca_df, undoId): self.addCcaState(frame_i, cca_df, undoId) # Keep only 10 Undo/Redo states - self.trim_cca_states(posData.UndoRedoCcaStates[frame_i]) + if len(posData.UndoRedoCcaStates[frame_i]) > 10: + posData.UndoRedoCcaStates[frame_i].pop(-1) - def undoCustomAnnotation(self): - pass - - def UndoCca(self): + def storeUndoRedoStates(self, UndoFutFrames, storeImage=False, storeOnlyZoom=False): posData = self.data[self.pos_i] - # Undo current ccaState - storeState = False - if self.UndoCount == 0: - undoId = uuid.uuid4() - self.addCcaState(posData.frame_i, posData.cca_df, undoId) - storeState = True - - - # Get previously stored state - self.UndoCount += 1 - currentCcaStates = posData.UndoRedoCcaStates[posData.frame_i] - prevCcaState = currentCcaStates[self.UndoCount] - posData.cca_df = prevCcaState['cca_df'] - self.store_cca_df() - self.updateAllImages() - - # Check if we have undone all states - if self.should_disable_undo_after_cca( - self.UndoCount, currentCcaStates - ): - # There are no states left to undo for current frame_i - self.undoAction.setEnabled(False) - - # Undo all past and future frames that has a last status inserted - # when modyfing current frame - prevStateId = prevCcaState['id'] - for frame_i in range(0, posData.SizeT): - if storeState: - cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) - if cca_df_i is None: - break - # Store current state to enable redoing it - self.addCcaState(frame_i, cca_df_i, undoId) - - CcaStates_i = posData.UndoRedoCcaStates[frame_i] - if len(CcaStates_i) <= self.UndoCount: - # There are no states to undo for frame_i - continue + if UndoFutFrames: + # Since we modified current frame all future frames that were already + # visited are not valid anymore. Undo changes there + self.reInitLastSegmFrame(updateImages=False) - CcaState_i = CcaStates_i[self.UndoCount] - id_i = CcaState_i['id'] - if id_i != prevStateId: - # The id of the state in frame_i is different from current frame - continue + # Keep only 5 Undo/Redo states + if len(posData.UndoRedoStates[posData.frame_i]) > 5: + posData.UndoRedoStates[posData.frame_i].pop(-1) - cca_df_i = CcaState_i['cca_df'] - self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) + # Restart count from the most recent state (index 0) + # NOTE: index 0 is most recent state before doing last change + self.UndoCount = 0 + self.undoAction.setEnabled(True) + self.addCurrentState(storeImage=storeImage, storeOnlyZoom=storeOnlyZoom) - self.resetWillDivideInfo() - self.enqAutosave() + def trim_stack(self, states: list, *, max_size: int) -> None: + if len(states) > max_size: + states.pop(-1) def undo(self): addPointsByClickingButton = self.buttonAddPointsByClickingActive() @@ -410,10 +402,7 @@ def undo(self): posData = self.data[self.pos_i] # Get previously stored state - if self.can_undo_labels( - self.UndoCount, - posData.UndoRedoStates[posData.frame_i], - ): + if self.UndoCount < len(posData.UndoRedoStates[posData.frame_i]) - 1: self.UndoCount += 1 # Since we have undone then it is possible to redo self.redoAction.setEnabled(True) @@ -424,33 +413,12 @@ def undo(self): self.updateAllImages(image=image_left) self.store_data() - if not self.can_undo_labels( - self.UndoCount, - posData.UndoRedoStates[posData.frame_i], - ): + if not self.UndoCount < len(posData.UndoRedoStates[posData.frame_i]) - 1: # We have undone all available states self.undoAction.setEnabled(False) if self.whitelistIDsButton.isChecked(): self.whitelistHighlightIDs() - def redo(self): - posData = self.data[self.pos_i] - # Get previously stored state - if self.can_redo_labels(self.UndoCount): - self.UndoCount -= 1 - # Since we have redone then it is possible to undo - self.undoAction.setEnabled(True) - - # Restore state - image_left = self.getCurrentState() - self.update_rp() - self.updateAllImages(image=image_left) - self.store_data() - - if not self.can_redo_labels(self.UndoCount): - # We have redone all available states - self.redoAction.setEnabled(False) - - if self.whitelistIDsButton.isChecked(): - self.whitelistHighlightIDs() \ No newline at end of file + def undoCustomAnnotation(self): + pass diff --git a/cellacdc/mixins/whitelist.py b/cellacdc/mixins/whitelist.py index c27da2909..7827790ac 100644 --- a/cellacdc/mixins/whitelist.py +++ b/cellacdc/mixins/whitelist.py @@ -3,126 +3,28 @@ from __future__ import annotations import os -import json import numpy as np -from dataclasses import dataclass from typing import Set, List -import numpy as np import skimage.measure -from typing import Set, List, Tuple, Any +from typing import Tuple import time -from cellacdc import ( - printl, myutils, html_utils, apps, widgets, exception_handler, disableWindow, gui_utils, exec_time -) +from cellacdc import printl, html_utils, apps, widgets, exception_handler, disableWindow from cellacdc.trackers.CellACDC import CellACDC_tracker from cellacdc.whitelist import Whitelist -class WhitelistView: +class WhitelistMixin: """Qt-facing adapter for the Whitelist feature.""" - LEGACY_METHODS = ( - 'whitelistCheckOriginalLabels', - 'whitelistTrackOGagainstPreviousFrame_cb', - 'whitelistLoadOGLabs_cb', - 'whitelistLoadOGLabs', - 'whitelistViewOGIDs', - 'whitelistSetViewOGIDsToggle', - 'whitelistAddNewIDsToggled', - 'whitelistAddNewIDs', - 'whitelistIDsAccepted', - 'whitelistUpdateLab', - 'whitelistIDsUpdateText', - 'whitelistTrackOGCurr', - 'whitelistTrackCurrOG', - 'whitelistSyncIDsOG', - 'whitelistInitNewFrames', - 'whitelistPropagateIDs', - 'whitelistIDs_cb', - 'whitelistHighlightIDs', - 'whitelistIDsChanged', - 'whitelistUpdateTempLayer', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - - def whitelistCheckOriginalLabels(self, warning:bool=True, - frame_i:int=None): - posData = self.data[self.pos_i] - if posData.whitelist is None: - return False - - if frame_i is None: - frame_i = posData.frame_i - - if not self.check_original_labels(posData.whitelist, frame_i): - txt = """ - """Headless decisions and calculations for Whitelist management.""" - def filter_existing_ids(self, current_whitelist: Set[int], possible_ids: Set[int]) -> tuple[Set[int], bool]: - """Filters out non-existing IDs from the current whitelist. - - Returns a tuple: (filtered_whitelist, is_any_id_non_existing) - """ - is_any_id_non_existing = False - filtered_whitelist = set(current_whitelist) - for ID in current_whitelist: - if ID not in possible_ids: - is_any_id_non_existing = True - filtered_whitelist.discard(ID) - return filtered_whitelist, is_any_id_non_existing - - def get_missing_ids(self, current_ids: Set[int], previous_ids: Set[int]) -> Set[int]: - """Returns the set of IDs present in current frame but missing from previous frame.""" - return set(current_ids) - set(previous_ids) - - def check_original_labels(self, whitelist_obj, frame_i: int) -> bool: - """Checks if original label data is allocated and valid for the frame.""" - if whitelist_obj is None: - return False - if whitelist_obj.originalLabsIDs is None: - return False - if frame_i >= len(whitelist_obj.originalLabsIDs) or whitelist_obj.originalLabsIDs[frame_i] is None: - return False - return True - - def get_frames_range(self, frame_i: int) -> list[int]: - """Calculates navigation frame ranges for label loading.""" - if frame_i > 0: - return [frame_i - 1, frame_i] - return [frame_i] - - def get_diff_ids(self, old_ids: Set[int], prev_ids: Set[int], new_ids: Set[int]) -> Set[int]: - """Computes tracking difference intersection (new_ids - old_ids) & prev_ids.""" - return (new_ids - old_ids) & prev_ids - - def get_whitelist_missing_and_removed_ids(self, whitelist: Set[int], current_ids: Set[int]) -> tuple[list[int], list[int]]: - """Finds IDs that are missing from current_ids and IDs to be removed from current_ids.""" - missing_ids = list(whitelist - current_ids) - to_be_removed_ids = list(current_ids - whitelist) - return missing_ids, to_be_removed_ids - def apply_id_mask( self, curr_lab: np.ndarray, og_lab: np.ndarray | None, missing_ids: list[int] | np.ndarray, - to_be_removed_ids: list[int] | np.ndarray + to_be_removed_ids: list[int] | np.ndarray, ) -> np.ndarray: """Applies missing and removed ID masks to the label array.""" updated_lab = curr_lab.copy().astype(np.int32) @@ -138,187 +40,153 @@ def apply_id_mask( return updated_lab + def check_original_labels(self, whitelist_obj, frame_i: int) -> bool: + """Checks if original label data is allocated and valid for the frame.""" + if whitelist_obj is None: + return False + if whitelist_obj.originalLabsIDs is None: + return False + if ( + frame_i >= len(whitelist_obj.originalLabsIDs) + or whitelist_obj.originalLabsIDs[frame_i] is None + ): + return False + return True + def construct_og_frame( self, pos_lab: np.ndarray, og_frame_base: np.ndarray, whitelist_ids: Set[int], - og_ids: Set[int] + og_ids: Set[int], ) -> np.ndarray: """Constructs original labels overlay using np.isin masking.""" og_frame = og_frame_base.copy() - + ids_to_update = whitelist_ids & og_ids if ids_to_update: mask = np.isin(og_frame, list(ids_to_update)) og_frame[mask] = 0 mask = np.isin(pos_lab, list(ids_to_update)) og_frame[mask] = pos_lab[mask] - + ids_to_add = whitelist_ids - og_ids if ids_to_add: mask = np.isin(pos_lab, list(ids_to_add)) og_frame[mask] = pos_lab[mask] - - return og_frame - No original labels are present for the current frame, - this action cannot be performed.""" - self.logger.warning(txt) - if not warning: - return False - msg = widgets.myMessageBox.warning( - self, 'No original labels', txt, - ) - - return False - else: - return True + return og_frame - @disableWindow - def whitelistTrackOGagainstPreviousFrame_cb(self, signal_slot=None): - posData = self.data[self.pos_i] - frame_i = posData.frame_i - if not self.whitelistCheckOriginalLabels(): - return - old_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] - prev_cell_IDs = posData.allData_li[frame_i-1]['IDs'] - self.whitelistTrackOGCurr(against_prev=True) - new_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] + def filter_existing_ids( + self, current_whitelist: Set[int], possible_ids: Set[int] + ) -> tuple[Set[int], bool]: + """Filters out non-existing IDs from the current whitelist. - new_IDs = self.get_diff_ids( - old_cell_IDs, set(prev_cell_IDs), new_cell_IDs - ) + Returns a tuple: (filtered_whitelist, is_any_id_non_existing) + """ + is_any_id_non_existing = False + filtered_whitelist = set(current_whitelist) + for ID in current_whitelist: + if ID not in possible_ids: + is_any_id_non_existing = True + filtered_whitelist.discard(ID) + return filtered_whitelist, is_any_id_non_existing - self.whitelistUpdateLab( - track_og_curr=False, IDs_to_add=new_IDs, - ) + def get_diff_ids( + self, old_ids: Set[int], prev_ids: Set[int], new_ids: Set[int] + ) -> Set[int]: + """Computes tracking difference intersection (new_ids - old_ids) & prev_ids.""" + return (new_ids - old_ids) & prev_ids - def whitelistLoadOGLabs_cb(self): - posData = self.data[self.pos_i] - curr_seg_path = posData.segm_npz_path + def get_frames_range(self, frame_i: int) -> list[int]: + """Calculates navigation frame ranges for label loading.""" + if frame_i > 0: + return [frame_i - 1, frame_i] + return [frame_i] - segmFilename = os.path.basename(curr_seg_path) - custom_first = f"{segmFilename[:-4]}_not_whitelisted.npz" - images_path = posData.images_path - existingEndnames = [ - files for files in os.listdir(images_path) if files.endswith('.npz') - ] - if custom_first not in existingEndnames: - custom_first = None + def get_missing_ids( + self, current_ids: Set[int], previous_ids: Set[int] + ) -> Set[int]: + """Returns the set of IDs present in current frame but missing from previous frame.""" + return set(current_ids) - set(previous_ids) - infoText = html_utils.paragraph( - 'Select the segmentation file containing the original labels ' - 'of the objects. Pleae note that the current saved "original" ' - 'labels will be replaced with the new ones, but the filtered ' - 'labels will be kept.' - ) + def get_whitelist_missing_and_removed_ids( + self, whitelist: Set[int], current_ids: Set[int] + ) -> tuple[list[int], list[int]]: + """Finds IDs that are missing from current_ids and IDs to be removed from current_ids.""" + missing_ids = list(whitelist - current_ids) + to_be_removed_ids = list(current_ids - whitelist) + return missing_ids, to_be_removed_ids - win = apps.SelectSegmFileDialog( - existingEndnames, images_path, parent=self, - basename=posData.basename, infoText=infoText, - custom_first=custom_first - ) - win.exec_() - if win.cancel: - self.logger.info('Loading original labels canceled.') + def whitelistAddNewIDs(self, ignore_not_first_time: bool = False): + """Function which adds new IDs to the whitelist, based on the original labels. + It will check if the frame is visited the first time, unless + ignore_not_first_time is True. + It does nothing if self.addNewIDsWhitelistToggle is False. + !!!Careful, does not change the lab, just the whitelist!!! + + Parameters + ---------- + ignore_not_first_time : bool, optional + Weather it should be checked if the frame is visited + the first time, by default False + """ + mode = self.modeComboBox.currentText() + if mode != "Segmentation and Tracking": return - selected = win.selectedItemText - self.logger.info(f'Loading original labels from {selected}...') - self.whitelistLoadOGLabs(selected) - - @disableWindow - def whitelistLoadOGLabs(self, selected:str): - posData = self.data[self.pos_i] - images_path = posData.images_path - - selected_path = os.path.join(images_path, selected) - posData.whitelist.loadOGLabs(selected_path) - - self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) - @exception_handler - @disableWindow - def whitelistViewOGIDs(self, checked:bool): - switch_to_og = checked and not self.viewOriginalLabels - switch_to_seg = not checked and self.viewOriginalLabels - - if not switch_to_og and not switch_to_seg: + if not self.addNewIDsWhitelistToggle: return posData = self.data[self.pos_i] if posData.whitelist is None: return - - if posData.whitelist._debug: - printl('whitelistViewOGIDs', checked) - - frame_i = posData.frame_i - frames_range = self.get_frames_range(frame_i) - self.store_data(autosave=False) - - if not self.whitelistCheckOriginalLabels(): - return - if switch_to_og: - self.setFrameNavigationDisabled(True, why='Viewing original labels') - self.viewOriginalLabels = True + debug = posData.whitelist._debug - for i in frames_range: - posData.frame_i = i - self.get_data() - self.whitelistTrackOGCurr(frame_i=i) + if debug: + printl("whitelistAddNewIDs") - posData.lab = self.construct_og_frame( - pos_lab=posData.lab, - og_frame_base=posData.whitelist.originalLabs[i], - whitelist_ids=posData.whitelist.whitelistIDs[i], - og_ids=posData.whitelist.originalLabsIDs[i] - ) - self.update_rp(wl_update=False) - self.store_data(autosave=False) + posData = self.data[self.pos_i] + frame_i = posData.frame_i - if frame_i > 0: - missing_IDs = self.get_missing_ids(posData.IDs, posData.allData_li[frame_i-1]['IDs']) - self.trackManuallyAddedObject(missing_IDs, isNewID=True, wl_update=False) + if self.get_last_tracked_i() > frame_i and not ignore_not_first_time: + return - self.setAllTextAnnotations() - self.updateAllImages() + if frame_i == 0: + return - elif switch_to_seg: - self.viewOriginalLabels = False - self.setFrameNavigationDisabled(False, why='Viewing original labels') + if ( + self.whitelistAddNewIDsFrame is not None + and frame_i == self.whitelistAddNewIDsFrame + ): + return - for i in frames_range: - posData.frame_i = i - self.get_data() - try: - posData.whitelist.originalLabs[i] = posData.lab.copy() - posData.whitelist.originalLabsIDs[i] = set(posData.IDs) - except AttributeError: - lab = posData.segm_data[i].copy() - IDs = [obj.label for obj in skimage.measure.regionprops(lab)] - posData.whitelist.originalLabs[i] = lab - posData.whitelist.originalLabsIDs[i] = set(IDs) + self.whitelistAddNewIDsFrame = frame_i - self.update_rp(wl_update=False) - self.store_data(autosave=False) - self.whitelistUpdateLab(frame_i=i) - self.setAllTextAnnotations() - self.updateAllImages() + curr_lab = self.get_curr_lab() - def whitelistSetViewOGIDsToggle(self, checked: bool): - self.viewOriginalLabels = checked - self.whitelistIDsToolbar.viewOGToggle.blockSignals(True) - self.whitelistIDsToolbar.viewOGToggle.setChecked(checked) - self.whitelistIDsToolbar.viewOGToggle.blockSignals(False) + posData.whitelist.addNewIDs( + frame_i=frame_i, + allData_li=posData.allData_li, + IDs_curr=posData.IDs, + curr_lab=curr_lab, + ) def whitelistAddNewIDsToggled(self, checked: bool): + """Will set self.addNewIDsWhitelistToggle to checked and call + whitelistAddNewIDs if checked is True. + + Parameters + ---------- + checked : bool + True if the add new IDs toggle is checked, False otherwise. + """ self.addNewIDsWhitelistToggle = checked if checked: - self.df_settings.at['addNewIDsWhitelistToggle', 'value'] = 'Yes' + self.df_settings.at["addNewIDsWhitelistToggle", "value"] = "Yes" else: - self.df_settings.at['addNewIDsWhitelistToggle', 'value'] = 'No' + self.df_settings.at["addNewIDsWhitelistToggle", "value"] = "No" self.df_settings.to_csv(self.settings_csv_path) if checked: self.whitelistAddNewIDs(ignore_not_first_time=True) @@ -326,52 +194,81 @@ def whitelistAddNewIDsToggled(self, checked: bool): self.updateAllImages() self.whitelistIDsUpdateText() - def whitelistAddNewIDs(self, ignore_not_first_time:bool=False): - mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': - return - - if not self.addNewIDsWhitelistToggle: - return - + def whitelistCheckOriginalLabels(self, warning: bool = True, frame_i: int = None): + """Warns the user that there are no original labels labels are present + for the frame""" posData = self.data[self.pos_i] if posData.whitelist is None: - return + return False - debug = posData.whitelist._debug + if frame_i is None: + frame_i = posData.frame_i - if debug: - printl('whitelistAddNewIDs') + if posData.whitelist.originalLabsIDs is None: + return False - posData = self.data[self.pos_i] - frame_i = posData.frame_i - - if self.get_last_tracked_i() > frame_i and not ignore_not_first_time: - return - - if frame_i == 0: - return + if ( + frame_i >= len(posData.whitelist.originalLabsIDs) + or posData.whitelist.originalLabsIDs[frame_i] is None + ): + txt = """ + No original labels are present for the current frame, + this action cannot be performed.""" + self.logger.warning(txt) + if not warning: + return False + widgets.myMessageBox.warning( + self, + "No original labels", + txt, + ) + + return False + else: + return True + + def whitelistHighlightIDs(self, checked: bool = True): + """Highlights the IDs in the current frame based on the whitelist. - if self.whitelistAddNewIDsFrame is not None and frame_i == self.whitelistAddNewIDsFrame: + Parameters + ---------- + checked : bool, optional + If False, will delete all highlights, by default True + """ + if not checked: + self.removeHighlightLabelID() return - - self.whitelistAddNewIDsFrame = frame_i - curr_lab = self.get_curr_lab() + posData = self.data[self.pos_i] + + if posData.whitelist is None: + if not hasattr(self, "tempWhitelistIDs"): + self.tempWhitelistIDs = set() # not updated, only use in this context + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = posData.whitelist.get(frame_i=posData.frame_i) - posData.whitelist.addNewIDs(frame_i=frame_i, - allData_li=posData.allData_li, - IDs_curr=posData.IDs, - curr_lab=curr_lab) + for ID in current_whitelist: + self.highlightLabelID(ID) - def whitelistIDsAccepted(self, - whitelistIDs: Set[int] | List[int]): + def whitelistIDsAccepted(self, whitelistIDs: Set[int] | List[int]): + """Function which is called when the user accepts a whitelist. + Also initializes the whitelist if it is not already initialized. (Aka not loaded) + + Parameters + ---------- + whitelistIDs : set | list + The accepted IDs from the whitelist dialog. + """ + # Store undo state before modifying stuff self.storeUndoRedoStates(False) self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) self.whitelistSetViewOGIDsToggle(False) - self.setFrameNavigationDisabled(False, why='Viewing original labels') - + self.setFrameNavigationDisabled(False, why="Viewing original labels") + self.store_data(autosave=False) posData = self.data[self.pos_i] @@ -380,13 +277,14 @@ def whitelistIDsAccepted(self, posData.whitelist = Whitelist( total_frames=posData.SizeT, ) - + if posData.whitelist._debug: - printl('whitelistIDsAccepted', whitelistIDs) + printl("whitelistIDsAccepted", whitelistIDs) whitelistIDs = set(whitelistIDs) + IDs_curr = set(posData.IDs) - + posData.whitelist.IDsAccepted( whitelistIDs, segm_data=posData.segm_data, @@ -395,262 +293,468 @@ def whitelistIDsAccepted(self, IDs_curr=IDs_curr, curr_lab=posData.lab, ) - + + # self.whitelistPropagateIDs(new_whitelist=whitelistIDs, + # try_create_new_whitelists=True, + # only_future_frames=True, + # force_not_dynamic_update=True, + # update_lab=True + # ) self.whitelistUpdateLab(track_og_curr=True) + self.whitelistIDsUpdateText() self.keepIDsTempLayerLeft.clear() - def whitelistUpdateLab(self, frame_i: int=None, - track_og_curr=False, new_frame:bool=False, - IDs_to_add:List[int] | Set[int]=None, - IDs_to_remove:List[int]|Set[int]=None, - ): - got_data = False - benchmark = False - if benchmark: - ts = [time.perf_counter()] - titles = [ - '', - 'store_data', - 'whitelistSetViewOGIDsToggle', - 'get_data', - 'get what to add/remove', - 'track_og_curr', - 'get current lab', - 'add/remove IDs', - 'store data', - 'update images', - ] - - mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': + def whitelistIDsChanged( + self, whitelistIDs: Set[int] | List[int], debug: bool = False + ): + """Callback for when the whitelist IDs are changed. + This is called when the user changed the IDs in the whitelist IDs toolbar + (or when its programmatically changed, but if its not + visible it should return instantly) + Will update the temp layer and also complain when IDs + are not valid/present in the current lab + + Parameters + ---------- + whitelistIDs : set | list + The IDs that are currently in the whitelist. + debug : bool, optional + debug, by default False + """ + if not self.whitelistIDsButton.isChecked(): return - + posData = self.data[self.pos_i] - if posData.whitelist is None: - return - if frame_i is None: - frame_i = posData.frame_i - og_frame_i = frame_i - else: - og_frame_i = posData.frame_i - posData.frame_i = frame_i - - debug = posData.whitelist._debug + if posData.whitelist: + debug = posData.whitelist._debug if debug: - printl('whitelistUpdateLab', frame_i, og_frame_i) - from cellacdc import debugutils - debugutils.print_call_stack() + printl("whitelistIDsChanged", whitelistIDs) - if benchmark: - ts.append(time.perf_counter()) - - self.whitelistSetViewOGIDsToggle(False) - - if benchmark: - ts.append(time.perf_counter()) - - if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): - og_lab = posData.whitelist.originalLabs[frame_i] + if posData.whitelist is None: + wl_init = False + if not hasattr(self, "tempWhitelistIDs"): + self.tempWhitelistIDs = set() # not updated, only use in this context + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = self.tempWhitelistIDs else: - og_lab = None - if benchmark: - ts.append(time.perf_counter()) + wl_init = True + current_whitelist = posData.whitelist.get(frame_i=posData.frame_i) - whitelist = posData.whitelist.get(frame_i=frame_i) - IDs_to_add_remove_provided = IDs_to_add is not None or IDs_to_remove is not None - if not IDs_to_add_remove_provided: - self.get_data() - got_data = True - missing_IDs, to_be_removed_IDs = self.get_whitelist_missing_and_removed_ids( - whitelist, set(posData.IDs) - ) + current_whitelist_copy = current_whitelist.copy() + if ( + not hasattr(posData, "originalLabsIDs") + or posData.whitelist.originalLabsIDs is None + ): + possible_IDs = posData.IDs.copy() else: - missing_IDs = list(IDs_to_add) if IDs_to_add is not None else [] - to_be_removed_IDs = list(IDs_to_remove) if IDs_to_remove is not None else [] - - if benchmark: - ts.append(time.perf_counter()) - - if not missing_IDs and not to_be_removed_IDs: - if og_frame_i != frame_i: - posData.frame_i = og_frame_i - if got_data and og_frame_i != frame_i: - self.get_data() - if benchmark: - print('No IDs to add/remove') - ts.append(time.perf_counter()) - indx = titles.index('track_og_curr') - titles[indx + 1] = 'store_data' - time_taken = time.perf_counter() - ts[0] - print(f'\nTotal time for whitelistUpdateLab: {time_taken:.2f}s') - for i in range(1, len(ts)): - time_taken = ts[i] - ts[i-1] - print(f'Time taken for {titles[i]}: {time_taken:.2f}s') - print('') - return - - if not got_data and og_frame_i != frame_i: - self.get_data() - got_data = True - - if benchmark: - ts.append(time.perf_counter()) - - if missing_IDs and track_og_curr and not new_frame: - self.whitelistTrackOGCurr(frame_i=frame_i, - lab = posData.lab, - rp = posData.rp) - - if debug: - printl(missing_IDs, to_be_removed_IDs) - - curr_lab = posData.lab - if curr_lab is None: - try: - curr_lab = posData.allData_li[frame_i]['labels'].copy() - except: - pass - if curr_lab is None: - try: - curr_lab = posData.segm_data[frame_i].copy() - except: - pass - if curr_lab is None: - printl('No current lab?') - curr_lab = np.zeros_like(posData.segm_data[0]) - - if benchmark: - ts.append(time.perf_counter()) - - curr_lab = self.apply_id_mask( - curr_lab, og_lab, missing_IDs, to_be_removed_IDs - ) + if not self.whitelistCheckOriginalLabels(warning=False): + possible_IDs = set(posData.IDs) + else: + possible_IDs = posData.whitelist.originalLabsIDs[posData.frame_i] + possible_IDs.update(posData.IDs) - if benchmark: - ts.append(time.perf_counter()) - - posData.lab = curr_lab + isAnyIDnotExisting = False + for ID in whitelistIDs: + if ID not in possible_IDs: + isAnyIDnotExisting = True + continue + if ID not in current_whitelist_copy: + current_whitelist.add(ID) + self.highlightLabelID(ID) - self.update_rp(wl_update=False) - self.store_data() + for ID in current_whitelist_copy: + if ID not in possible_IDs: + isAnyIDnotExisting = True + continue + if ID not in whitelistIDs: + current_whitelist.remove(ID) + self.removeHighlightLabelID(IDs=[ID]) - if benchmark: - ts.append(time.perf_counter()) - if og_frame_i != frame_i: - posData.frame_i = og_frame_i - self.get_data() - - self.updateAllImages() - self.setAllTextAnnotations() + if wl_init: + posData.whitelist.whitelistIDs[posData.frame_i] = current_whitelist + else: + self.tempWhitelistIDs = current_whitelist - if benchmark: - ts.append(time.perf_counter()) - time_taken = time.perf_counter() - ts[0] - print(f'\nTotal time for whitelistUpdateLab: {time_taken:.2f}s') - for i in range(1, len(ts)): - time_taken = ts[i] - ts[i-1] - print(f'Time taken for {titles[i]}: {time_taken:.2f}s') - print('') + self.whitelistUpdateTempLayer() + if isAnyIDnotExisting: + self.whitelistIDsToolbar.whitelistLineEdit.warnNotExistingID() + else: + self.whitelistIDsToolbar.whitelistLineEdit.setInstructionsText() def whitelistIDsUpdateText(self): + """Updates the text. Carefull, triggers whitelistLineEdit.textChanged!""" mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': + if mode != "Segmentation and Tracking": return posData = self.data[self.pos_i] if posData.whitelist is None: return - + if posData.whitelist._debug: - printl('whitelistIDsUpdateText') - + printl("whitelistIDsUpdateText") + frame_i = posData.frame_i whitelist = posData.whitelist.get(frame_i=frame_i) self.whitelistIDsToolbar.whitelistLineEdit.setText(whitelist) - def whitelistTrackOGCurr(self, frame_i:int=None, - against_prev:bool=False, - lab:np.ndarray=None, - rp:list=None, - IDs: Set[int] | List[int] =None): + def whitelistIDs_cb(self, checked: bool): + """Callback for when the whitelist IDs button is checked or unchecked. + Initialises the pointlayer and the whitelist IDs toolbar if checked. + + Parameters + ---------- + checked : bool + True if the whitelist IDs button is checked, False otherwise. + """ + if checked: + self.initKeepObjLabelsLayers() + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.whitelistIDsButton) + self.connectLeftClickButtons() + + self.whitelistIDsToolbar.setVisible(checked) + self.whitelistHighlightIDs(checked) + self.whitelistIDsUpdateText() + self.whitelistUpdateTempLayer() + + if not checked: + self.setLostNewOldPrevIDs() + self.updateAllImages() + + def whitelistInitNewFrames(self, frame_i: int = None, force: bool = False): + """Initialize the whitelist for a new frame. The class whitelist keeps track + of the init frames and doesnt try to init them again, unless forced. + Does not init the class! + + Parameters + ---------- + frame_i : int, optional + frame_i to be init, posData.frame_i if not provided, by default None + force : bool, optional + if the init should be forced, by default False + + Returns + ------- + bool + if the frame was new or not + list + list of frames that were updated, and info about added/removed IDs + """ + posData = self.data[self.pos_i] if posData.whitelist is None: + return False, [] + + if frame_i is None: + frame_i = posData.frame_i + + if posData.whitelist._debug: + printl("whitelistInitNewFrames", frame_i, force) + + if frame_i not in posData.whitelist.initialized_i: + self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=True) + + new_frame, update_frames = posData.whitelist.initNewFrames( + frame_i=frame_i, force=force + ) + + self.whitelistAddNewIDs() + return new_frame, update_frames + + @disableWindow + def whitelistLoadOGLabs(self, selected: str): + """Loads the original labels from the selected files + + Parameters + ---------- + selected : str + Selected file name from the dialog. + """ + posData = self.data[self.pos_i] + images_path = posData.images_path + + selected_path = os.path.join(images_path, selected) + posData.whitelist.loadOGLabs(selected_path) + + self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) + + def whitelistLoadOGLabs_cb(self): + """Generates a dialog to load the original (not whitelisted) labels""" + posData = self.data[self.pos_i] + curr_seg_path = posData.segm_npz_path + + segmFilename = os.path.basename(curr_seg_path) + custom_first = f"{segmFilename[:-4]}_not_whitelisted.npz" + images_path = posData.images_path + existingEndnames = [ + files for files in os.listdir(images_path) if files.endswith(".npz") + ] + if custom_first not in existingEndnames: + custom_first = None + + infoText = html_utils.paragraph( + "Select the segmentation file containing the original labels " + 'of the objects. Pleae note that the current saved "original" ' + "labels will be replaced with the new ones, but the filtered " + "labels will be kept." + ) + + win = apps.SelectSegmFileDialog( + existingEndnames, + images_path, + parent=self, + basename=posData.basename, + infoText=infoText, + custom_first=custom_first, + ) + win.exec_() + if win.cancel: + self.logger.info("Loading original labels canceled.") return + selected = win.selectedItemText + self.logger.info(f"Loading original labels from {selected}...") + self.whitelistLoadOGLabs(selected) - debug = posData.whitelist._debug + def whitelistPropagateIDs( + self, + new_whitelist: Set[int] | List[int] = None, + IDs_to_add: Set[int] = None, + IDs_to_remove: Set[int] = None, + frame_i: int = None, + try_create_new_whitelists: bool = False, + curr_frame_only: bool = False, + force_not_dynamic_update: bool = False, + only_future_frames: bool = True, + allow_only_current_IDs: bool = False, + track_og_curr: bool = True, + IDs_curr: Set[int] | List[int] = None, + index_lab_combo: Tuple[int, np.ndarray] = None, + curr_rp: list = None, + curr_lab: np.ndarray = None, + store_data: bool = True, + update_lab: bool = False, + ): + """ + Propagates whitelist IDs across frames in the dataset. (Doesnt update labs) + Should also be called when viewing a new frame! + + This function updates whitelist. If curr_frame_only is True, it only updates the + whitelist of the current frame. If the frame changes, this function should be called + again to update the whitelist for the new frame (without this argument). + It should also handle cases were this is not done, but this is less safe. + Then, all the additions and removals are propagated to the other frames. + If force_not_dynamic_update is True, the function will propagate the entire whitelist to + frames, and not only the IDs which were added or removed. + + Hierarchy of arguments for current_IDs: + 1. IDs_curr (if provided) + (2. index_lab_combo (if provided) (is also passed to not current frame only + propagation if that propagation is necessary, and used when the frame_i matches)) + 3. curr_rp (if provided) + 4. curr_lab (if provided) + 5. allData_li + + Parameters + ---------- + new_whitelist : Set[int] | List[int], optional + A new set of whitelist IDs to replace the current whitelist. Cannot be + used together with `IDs_to_add` or `IDs_to_remove`, by default None. + IDs_to_add : Set[int], optional + A set of IDs to add to the current whitelist, by default None. + IDs_to_remove : Set[int], optional + A set of IDs to remove from the current whitelist, by default None. + frame_i : int, optional + The frame index for the propagation. + If None, uses posData.frame_i, by default None. + try_create_new_whitelists : bool, optional + If True, creates new whitelist entries for frames that do not already + have them. Should only be necessary when its initialized, by default False. + curr_frame_only : bool, optional + If True, only updates the whitelist for the current frame. + (See description of function), by default False. + force_not_dynamic_update : bool, optional + If True, disables dynamic updates to the whitelist. + (See description of function), by default False. + only_future_frames : bool, optional + If True, propagates changes only to future frames, by default True. + allow_only_current_IDs : bool, optional + If True, only allows IDs that are present in the current frame + to be added to the whitelist, by default True. + track_og_curr : bool, optional + If True, tracks the original labels in relation to the current + (whitelisted) labels. This is done by calling whitelistTrackOGCurr. + If its a new frame, this is done in whitelistInitNewFrames against the + previous frame, + by default True. + IDs_curr : Set[int] | List[int], optional + A set of IDs for the current frame, if None, + will be calculated from other stuff (see description), by default None. + index_lab_combo : Tuple[int, np.ndarray], optional + Combination of frame_i and current frame, + Used to get IDs_curr (see description), when the frame_i matches + (is also passed to not current frame only + propagation if that propagation is necessary, + and used when the frame_i matches), by default None. + curr_rp : list, optional + Region properties for the current frame. For IDs_curr. (see description), + by default None. + curr_lab : np.ndarray, optional + Labels for the current frame for IDs_curr. (see description), + by default None. + store_data : bool, optional + If True, stores the data before propagating the IDs. + update_lab : bool, optional + If True, updates the labels after propagating the IDs. + Will always update labels for newly init frames, by default False. + + Raises + ------ + ValueError + If both `new_whitelistIDs` and `IDs_to_add`/`IDs_to_remove` are provided. + + Example + ------- + To add IDs 5 and 6 to the whitelist for the current frame: + ```python + self.whitelistPropagateIDs(IDs_to_add={5, 6}, curr_frame_only=True) + ``` + Then when the frame changes: + ```python + self.whitelistPropagateIDs() + ``` + + To replace the whitelist for frame 10 with a new set of IDs: + ```python + self.whitelistPropagateIDs(new_whitelistIDs={1, 2, 3}, frame_i=10) + ``` + This would also propagate the changes to all other frames. + + """ + # doesnt update the frame displayed, only wl + try: # safety XD + IDs_curr = IDs_curr.copy() + except AttributeError: + pass + + IDs_curr = set(IDs_curr) if IDs_curr is not None else None + + posData = self.data[self.pos_i] + + debug = posData.whitelist._debug if posData.whitelist is not None else False if debug: - from cellacdc import debugutils - debugutils.print_call_stack(depth=2) - printl('whitelistTrackOGCurr', against_prev) + printl("Propagating IDs...") + from . import debugutils - if against_prev and (rp is not None or lab is not None): - raise ValueError('Cannot provide both rp and lab when tracking' - ' against previous frame.' - 'Instead only provide rp and lab, and dont set against_prev.') + debugutils.print_call_stack() + printl(new_whitelist, IDs_to_add, IDs_to_remove) + if posData.whitelist is None: + return + + # og_frame_i = posData.frame_i if frame_i is None: frame_i = posData.frame_i - if against_prev and frame_i == 0: - return - - if not self.whitelistCheckOriginalLabels(warning=False, - frame_i=frame_i): - if debug: - printl('No original labels, cannot track.') - return + new_frame, update_frames_init = self.whitelistInitNewFrames(frame_i=frame_i) - og_frame_i = posData.frame_i + if new_frame: + self.update_rp(wl_update=False) + # if track_og_curr and not new_frame: + # self.whitelistTrackOGCurr(frame_i=frame_i, rp=curr_rp, lab=curr_lab) - if lab is not None and not rp: - rp = skimage.measure.regionprops(lab) - - changed_frame = False - if lab is None: - if debug: - printl('No lab and no rp provided.') - if against_prev: - rp = posData.allData_li[frame_i-1]['regionprops'] - lab = posData.allData_li[frame_i-1]['labels'] - else: - if frame_i != og_frame_i: - self.store_data(autosave=False) - posData.frame_i = frame_i - self.get_data() - changed_frame = True - rp = posData.rp - lab = posData.lab - og_lab = posData.whitelist.originalLabs[frame_i] - og_rp = skimage.measure.regionprops(og_lab) + update_frames = posData.whitelist.propagateIDs( + frame_i, + posData.allData_li, + new_whitelist=new_whitelist, + IDs_to_add=IDs_to_add, + IDs_to_remove=IDs_to_remove, + try_create_new_whitelists=try_create_new_whitelists, + curr_frame_only=curr_frame_only, + force_not_dynamic_update=force_not_dynamic_update, + only_future_frames=only_future_frames, + allow_only_current_IDs=allow_only_current_IDs, + IDs_curr=IDs_curr, + index_lab_combo=index_lab_combo, + curr_rp=curr_rp, + curr_lab=curr_lab, + ) + if update_lab: + update_frames = update_frames_init + update_frames + else: + update_frames = update_frames_init + # printl(posData.whitelistIDs[frame_i]) + # posData.frame_i = og_frame_i + self.whitelistIDsUpdateText() + if store_data: + self.store_data(autosave=False) - denom_overlap_matrix = 'union' if not against_prev else 'area_prev' + for frame_i, IDs_to_add, IDs_to_remove, new_frame in update_frames: + self.whitelistUpdateLab( + frame_i=frame_i, + track_og_curr=track_og_curr, + new_frame=new_frame, + IDs_to_add=IDs_to_add, + IDs_to_remove=IDs_to_remove, + ) - og_lab = CellACDC_tracker.track_frame( - lab, rp, og_lab, og_rp, - denom_overlap_matrix=denom_overlap_matrix, - posData = posData, - setBrushID_func=self.setBrushID, - IDs=IDs, - ) + def whitelistSetViewOGIDsToggle(self, checked: bool): + """Set the view original labels toggle button to checked or unchecked. + This also updates the self.viewOriginalLabels variable. + !!! Doesn't change the actually displayed labels, use self.whitelistViewOGIDs + to do that.!!! + + Parameters + ---------- + checked : bool + True if the original labels are shown, False otherwise. + """ + self.viewOriginalLabels = checked + self.whitelistIDsToolbar.viewOGToggle.blockSignals(True) + self.whitelistIDsToolbar.viewOGToggle.setChecked(checked) + self.whitelistIDsToolbar.viewOGToggle.blockSignals(False) - posData.whitelist.originalLabs[frame_i] = og_lab - posData.whitelist.originalLabsIDs[frame_i] = {obj.label for obj in skimage.measure.regionprops(og_lab)} + def whitelistSyncIDsOG( + self, + frame_is: List[int] = None, + against_prev: bool = False, + ): + """Interates over the frames and calls whitelistTrackOGCurr for each frame. + + Parameters + ---------- + frame_is : List[int], optional + list of frame_i, if None goes through all, by default None + against_prev : bool, optional + if the original frame should be tracked against frame_i-1. + """ + posData = self.data[self.pos_i] + if frame_is is None: + frame_is = range(posData.SizeT) - if changed_frame: - posData.frame_i = og_frame_i - self.get_data() + for frame_i in frame_is: + self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=against_prev) - def whitelistTrackCurrOG(self, frame_i:int=None, against_prev:bool=False): + def whitelistTrackCurrOG(self, frame_i: int = None, against_prev: bool = False): + """Track the current (whitelisted) labels in relation to the original labels. + Parameters + ---------- + frame_i : int, optional + frame_i to be tracked, posData.frame_i if not provided, by default None + against_prev : bool, optional + if the original frame should be tracked against frame_i-1. + """ posData = self.data[self.pos_i] if posData.whitelist is None: return if posData.whitelist._debug: - printl('whitelistTrackCurrOG', frame_i, against_prev) + printl("whitelistTrackCurrOG", frame_i, against_prev) if frame_i is None: frame_i = posData.frame_i @@ -663,30 +767,34 @@ def whitelistTrackCurrOG(self, frame_i:int=None, against_prev:bool=False): self.store_data(autosave=False) posData.frame_i = frame_i self.get_data() - + lab = posData.lab rp = posData.rp - - if not self.whitelistCheckOriginalLabels(warning=False, - frame_i=frame_i if not against_prev else frame_i-1): + + if not self.whitelistCheckOriginalLabels( + warning=False, frame_i=frame_i if not against_prev else frame_i - 1 + ): if posData.whitelist._debug: - printl('No original labels, cannot track.') + printl("No original labels, cannot track.") return if against_prev: - og_lab = posData.whitelist.originalLabs[frame_i-1] + og_lab = posData.whitelist.originalLabs[frame_i - 1] else: og_lab = posData.whitelist.originalLabs[frame_i] og_rp = skimage.measure.regionprops(og_lab) - denom_overlap_matrix = 'union' if not against_prev else 'area_prev' + denom_overlap_matrix = "union" if not against_prev else "area_prev" lab = CellACDC_tracker.track_frame( - og_lab, og_rp, lab, rp, + og_lab, + og_rp, + lab, + rp, denom_overlap_matrix=denom_overlap_matrix, - posData = posData, - setBrushID_func=self.setBrushID + posData=posData, + setBrushID_func=self.setBrushID, ) posData.lab = lab @@ -698,217 +806,334 @@ def whitelistTrackCurrOG(self, frame_i:int=None, against_prev:bool=False): posData.frame_i = og_frame self.get_data() - def whitelistSyncIDsOG(self, - frame_is: List[int]=None, - against_prev: bool=False,): + def whitelistTrackOGCurr( + self, + frame_i: int = None, + against_prev: bool = False, + lab: np.ndarray = None, + rp: list = None, + IDs: Set[int] | List[int] = None, + ): + """Track the original labels in relation to the current (whitelisted) + labels. + Parameters + + Parameters + ---------- + frame_i : int, optional + frame_i to be tracked, posData.frame_i if not provided, + by default None + against_prev : bool, optional + if the original frame should be tracked against frame_i-1. + Cannot be used with rp or lab, by default False + lab : np.ndarray, optional + lab to be tracked against, by default None + rp : list, optional + regionprops for this lab, by default None + IDs : Set[int] | List[int], optional + IDs that should be tracked based on og + + Raises + ------ + ValueError + Cannot provide both rp and lab when tracking against previous frame. + Instead only provide rp and lab, and dont set against_prev. + """ posData = self.data[self.pos_i] - if frame_is is None: - frame_is = range(posData.SizeT) + if posData.whitelist is None: + return + + debug = posData.whitelist._debug + + if debug: + from . import debugutils + + debugutils.print_call_stack(depth=2) + printl("whitelistTrackOGCurr", against_prev) + + if against_prev and (rp is not None or lab is not None): + raise ValueError( + "Cannot provide both rp and lab when tracking" + " against previous frame." + "Instead only provide rp and lab, and dont set against_prev." + ) + + if frame_i is None: + frame_i = posData.frame_i + + if against_prev and frame_i == 0: + return + + if not self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): + if debug: + printl("No original labels, cannot track.") + return + + og_frame_i = posData.frame_i + ### against what should I track? + + if lab is not None and not rp: + rp = skimage.measure.regionprops(lab) + + changed_frame = False + if lab is None: + if debug: + printl("No lab and no rp provided.") + if against_prev: + rp = posData.allData_li[frame_i - 1]["regionprops"] + lab = posData.allData_li[frame_i - 1]["labels"] + else: + if frame_i != og_frame_i: + self.store_data(autosave=False) + posData.frame_i = frame_i + self.get_data() + changed_frame = True + rp = posData.rp + lab = posData.lab + og_lab = posData.whitelist.originalLabs[frame_i] + og_rp = skimage.measure.regionprops(og_lab) + # lab = lab.copy() + + denom_overlap_matrix = "union" if not against_prev else "area_prev" + + og_lab = CellACDC_tracker.track_frame( + lab, + rp, + og_lab, + og_rp, + denom_overlap_matrix=denom_overlap_matrix, + posData=posData, + setBrushID_func=self.setBrushID, + IDs=IDs, + # assign_unique_new_IDs=False, + ) + + posData.whitelist.originalLabs[frame_i] = og_lab + posData.whitelist.originalLabsIDs[frame_i] = { + obj.label for obj in skimage.measure.regionprops(og_lab) + } + + if changed_frame: + posData.frame_i = og_frame_i + self.get_data() + + @disableWindow + def whitelistTrackOGagainstPreviousFrame_cb(self, signal_slot=None): + """Tracks the original labels against the previous frame. + This is used as a callback for sigTrackOGagainstPreviousFrame signal + """ + posData = self.data[self.pos_i] + frame_i = posData.frame_i + if not self.whitelistCheckOriginalLabels(): + return + old_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] + prev_cell_IDs = posData.allData_li[frame_i - 1]["IDs"] + self.whitelistTrackOGCurr(against_prev=True) + new_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] + + new_IDs = new_cell_IDs - old_cell_IDs + new_IDs = new_IDs & set(prev_cell_IDs) + + self.whitelistUpdateLab( + track_og_curr=False, + IDs_to_add=new_IDs, + ) + + def whitelistUpdateLab( + self, + frame_i: int = None, + track_og_curr=False, + new_frame: bool = False, + IDs_to_add: List[int] | Set[int] = None, + IDs_to_remove: List[int] | Set[int] = None, + ): + # this should also work for 3D i think... + """Updates the displayed lab based on the whitelist. + + Parameters + ---------- + frame_i : int, optional + frame which should be updated. If not provided, + uses posData.frame_i, by default None + track_og_curr : bool, optional + if True, will track the original current IDs, by default False + new_frame : bool, optional + if True, will set the frame to the new frame, by default False + IDs_to_add : list, optional + IDs to add to the whitelist, by default None + IDs_to_remove : list, optional + IDs to remove from the whitelist, by default None + """ + got_data = False + benchmark = False + if benchmark: + ts = [time.perf_counter()] + titles = [ + "", + "store_data", + "whitelistSetViewOGIDsToggle", + "get_data", + "get what to add/remove", + "track_og_curr", + "get current lab", + "add/remove IDs", + "store data", + "update images", + ] - for frame_i in frame_is: - self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=against_prev) + mode = self.modeComboBox.currentText() + if mode != "Segmentation and Tracking": + return - def whitelistInitNewFrames(self, frame_i:int=None, force:bool=False): posData = self.data[self.pos_i] if posData.whitelist is None: - return False, [] + return if frame_i is None: frame_i = posData.frame_i - - if posData.whitelist._debug: - printl('whitelistInitNewFrames', frame_i, force) - - if frame_i not in posData.whitelist.initialized_i: - self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=True) - - new_frame, update_frames = posData.whitelist.initNewFrames( - frame_i=frame_i, force=force) - - self.whitelistAddNewIDs() - return new_frame, update_frames - - def whitelistPropagateIDs(self, - new_whitelist: Set[int] | List[int] = None, - IDs_to_add: Set[int] = None, - IDs_to_remove: Set[int] = None, - frame_i: int = None, - try_create_new_whitelists: bool = False, - curr_frame_only: bool = False, - force_not_dynamic_update: bool = False, - only_future_frames: bool = True, - allow_only_current_IDs: bool = False, - track_og_curr: bool = True, - IDs_curr: Set[int] | List[int] = None, - index_lab_combo: Tuple[int, np.ndarray] = None, - curr_rp: list = None, - curr_lab: np.ndarray = None, - store_data: bool = True, - update_lab: bool = False, - ): - try: - IDs_curr = IDs_curr.copy() - except AttributeError: - pass - - IDs_curr = set(IDs_curr) if IDs_curr is not None else None - - posData = self.data[self.pos_i] - debug = posData.whitelist._debug if posData.whitelist is not None else False + og_frame_i = frame_i + else: + og_frame_i = posData.frame_i + posData.frame_i = frame_i + # getting data is handles later in the code + debug = posData.whitelist._debug if debug: - printl('Propagating IDs...') - from cellacdc import debugutils - debugutils.print_call_stack() - printl(new_whitelist, IDs_to_add, IDs_to_remove) + printl("whitelistUpdateLab", frame_i, og_frame_i) + from . import debugutils - if posData.whitelist is None: - return + debugutils.print_call_stack() - if frame_i is None: - frame_i = posData.frame_i + if benchmark: + ts.append(time.perf_counter()) - new_frame, update_frames_init = self.whitelistInitNewFrames(frame_i=frame_i) + self.whitelistSetViewOGIDsToggle(False) ### - if new_frame: - self.update_rp(wl_update=False) + if benchmark: + ts.append(time.perf_counter()) - update_frames = posData.whitelist.propagateIDs( - frame_i, - posData.allData_li, - new_whitelist=new_whitelist, - IDs_to_add=IDs_to_add, - IDs_to_remove=IDs_to_remove, - try_create_new_whitelists=try_create_new_whitelists, - curr_frame_only=curr_frame_only, - force_not_dynamic_update=force_not_dynamic_update, - only_future_frames=only_future_frames, - allow_only_current_IDs=allow_only_current_IDs, - IDs_curr=IDs_curr, - index_lab_combo=index_lab_combo, - curr_rp=curr_rp, - curr_lab=curr_lab, - ) - if update_lab: - update_frames = update_frames_init + update_frames + if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): + og_lab = posData.whitelist.originalLabs[frame_i] ### else: - update_frames = update_frames_init - - self.whitelistIDsUpdateText() - if store_data: - self.store_data(autosave=False) + og_lab = None + if benchmark: + ts.append(time.perf_counter()) - for frame_i, IDs_to_add, IDs_to_remove, new_frame in update_frames: - self.whitelistUpdateLab(frame_i=frame_i, track_og_curr=track_og_curr, - new_frame=new_frame, IDs_to_add=IDs_to_add, - IDs_to_remove=IDs_to_remove, ) + #### + whitelist = posData.whitelist.get(frame_i=frame_i) + IDs_to_add_remove_provided = IDs_to_add is not None or IDs_to_remove is not None + if not IDs_to_add_remove_provided: + self.get_data() + got_data = True + current_IDs = set(posData.IDs) + missing_IDs = list(whitelist - current_IDs) + to_be_removed_IDs = list(current_IDs - whitelist) + else: + missing_IDs = list(IDs_to_add) if IDs_to_add is not None else [] + to_be_removed_IDs = list(IDs_to_remove) if IDs_to_remove is not None else [] - def whitelistIDs_cb(self, checked:bool): - if checked: - self.initKeepObjLabelsLayers() - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.whitelistIDsButton) - self.connectLeftClickButtons() - - self.whitelistIDsToolbar.setVisible(checked) - self.whitelistHighlightIDs(checked) - self.whitelistIDsUpdateText() - self.whitelistUpdateTempLayer() + ### - if not checked: - self.setLostNewOldPrevIDs() - self.updateAllImages() + if benchmark: + ts.append(time.perf_counter()) - def whitelistHighlightIDs(self, checked:bool=True): - if not checked: - self.removeHighlightLabelID() + ### + if not missing_IDs and not to_be_removed_IDs: # nothing to do + if og_frame_i != frame_i: + posData.frame_i = og_frame_i + if got_data and og_frame_i != frame_i: + self.get_data() + if benchmark: + print("No IDs to add/remove") + ts.append(time.perf_counter()) + indx = titles.index("track_og_curr") + titles[indx + 1] = "store_data" + time_taken = time.perf_counter() - ts[0] + print(f"\nTotal time for whitelistUpdateLab: {time_taken:.2f}s") + for i in range(1, len(ts)): + time_taken = ts[i] - ts[i - 1] + print(f"Time taken for {titles[i]}: {time_taken:.2f}s") + print("") return - - posData = self.data[self.pos_i] - if posData.whitelist is None: - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = posData.whitelist.get( - frame_i=posData.frame_i) - - for ID in current_whitelist: - self.highlightLabelID(ID) - - def whitelistIDsChanged(self, - whitelistIDs: Set[int] | List[int], - debug: bool=False): - if not self.whitelistIDsButton.isChecked(): - return - - posData = self.data[self.pos_i] + if not got_data and og_frame_i != frame_i: + self.get_data() + got_data = True + + if benchmark: + ts.append(time.perf_counter()) + + ### + if missing_IDs and track_og_curr and not new_frame: + self.whitelistTrackOGCurr(frame_i=frame_i, lab=posData.lab, rp=posData.rp) + + missing_IDs = np.array(missing_IDs, dtype=np.int32) + to_be_removed_IDs = np.array(to_be_removed_IDs, dtype=np.int32) - if posData.whitelist: - debug = posData.whitelist._debug if debug: - printl('whitelistIDsChanged', whitelistIDs) + printl(missing_IDs, to_be_removed_IDs) - if posData.whitelist is None: - wl_init = False - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = self.tempWhitelistIDs - else: - wl_init = True - current_whitelist = posData.whitelist.get( - frame_i=posData.frame_i) + curr_lab = posData.lab # or curr_lab = posData.lab??? + # convert values to int if they are not already + if curr_lab is None: + try: + curr_lab = posData.allData_li[frame_i]["labels"].copy() + except: + pass + if curr_lab is None: + try: + curr_lab = posData.segm_data[frame_i].copy() + except: + pass + if curr_lab is None: + printl("No current lab?") + curr_lab = np.zeros_like(posData.segm_data[0]) + curr_lab = curr_lab.astype(np.int32) + if benchmark: + ts.append(time.perf_counter()) - current_whitelist_copy = current_whitelist.copy() - if not hasattr(posData, 'originalLabsIDs') or posData.whitelist.originalLabsIDs is None: - possible_IDs = posData.IDs.copy() - else: - if not self.whitelistCheckOriginalLabels(warning=False): - possible_IDs = set(posData.IDs) - else: - possible_IDs = posData.whitelist.originalLabsIDs[posData.frame_i] - possible_IDs.update(posData.IDs) + if missing_IDs.size > 0 and og_lab is not None: + mask = np.isin(og_lab, missing_IDs) # add missing_IDs + curr_lab[mask] = og_lab[mask] - # Delegate validation of existing IDs to viewmodel/model - filtered_whitelist, isAnyIDnotExisting = self.filter_existing_ids( - whitelistIDs, possible_IDs - ) + if to_be_removed_IDs.size > 0: + curr_lab[np.isin(curr_lab, to_be_removed_IDs)] = ( + 0 # remove to_be_removed_IDs + ) - # Apply changes based on filtered_whitelist - for ID in filtered_whitelist: - if ID not in current_whitelist_copy: - current_whitelist.add(ID) - self.highlightLabelID(ID) + if benchmark: + ts.append(time.perf_counter()) - for ID in current_whitelist_copy: - if ID not in possible_IDs: - continue - if ID not in whitelistIDs: - current_whitelist.remove(ID) - self.removeHighlightLabelID(IDs=[ID]) + posData.lab = curr_lab - if wl_init: - posData.whitelist.whitelistIDs[posData.frame_i] = current_whitelist - else: - self.tempWhitelistIDs = current_whitelist + self.update_rp(wl_update=False) + self.store_data() - self.whitelistUpdateTempLayer() - if isAnyIDnotExisting: - self.whitelistIDsToolbar.whitelistLineEdit.warnNotExistingID() - else: - self.whitelistIDsToolbar.whitelistLineEdit.setInstructionsText() + if benchmark: + ts.append(time.perf_counter()) + if og_frame_i != frame_i: + posData.frame_i = og_frame_i + self.get_data() + + self.updateAllImages() + self.setAllTextAnnotations() + + if benchmark: + ts.append(time.perf_counter()) + time_taken = time.perf_counter() - ts[0] + print(f"\nTotal time for whitelistUpdateLab: {time_taken:.2f}s") + for i in range(1, len(ts)): + time_taken = ts[i] - ts[i - 1] + print(f"Time taken for {titles[i]}: {time_taken:.2f}s") + print("") def whitelistUpdateTempLayer(self): + """Updates the temp layer with the current whitelist IDs.""" if not self.whitelistIDsButton.isChecked(): self.keepIDsTempLayerLeft.clear() return - if not hasattr(self, 'keptLab'): + if not hasattr(self, "keptLab"): self.keptLab = np.zeros_like(self.currentLab2D) keptLab = self.keptLab else: @@ -917,8 +1142,8 @@ def whitelistUpdateTempLayer(self): posData = self.data[self.pos_i] if posData.whitelist is None: - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() + if not hasattr(self, "tempWhitelistIDs"): + self.tempWhitelistIDs = set() # not updated, only use in this context current_whitelist = self.tempWhitelistIDs else: current_whitelist = self.tempWhitelistIDs @@ -937,4 +1162,107 @@ def whitelistUpdateTempLayer(self): keptLab[_slice][_objMask] = obj.label - self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) \ No newline at end of file + self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) + + @exception_handler + @disableWindow + def whitelistViewOGIDs(self, checked: bool): + """Switch between selected and original labels. + Uses self.viewOriginalLabels to see what has to be done. + + Parameters + ---------- + checked : bool + True if the original labels have to be shown, False otherwise. + """ + switch_to_og = checked and not self.viewOriginalLabels + switch_to_seg = not checked and self.viewOriginalLabels + + if not switch_to_og and not switch_to_seg: + return + + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + if posData.whitelist._debug: + printl("whitelistViewOGIDs", checked) + + frame_i = posData.frame_i + if frame_i > 0: + frames_range = [frame_i - 1, frame_i] + else: + frames_range = [frame_i] + + self.store_data(autosave=False) + + if not self.whitelistCheckOriginalLabels(): + return + if switch_to_og: + self.setFrameNavigationDisabled(True, why="Viewing original labels") + self.viewOriginalLabels = True + + for i in frames_range: + posData.frame_i = i + self.get_data() + self.whitelistTrackOGCurr(frame_i=i) + + IDs = posData.IDs + + og_frame = posData.whitelist.originalLabs[i].copy() + IDs_to_uppdate = ( + posData.whitelist.whitelistIDs[i] + & posData.whitelist.originalLabsIDs[i] + ) + if IDs_to_uppdate: + mask = np.isin(og_frame, list(IDs_to_uppdate)) + og_frame[mask] = 0 + + mask = np.isin(posData.lab, list(IDs_to_uppdate)) + og_frame[mask] = posData.lab[mask] + + IDs_to_add = ( + posData.whitelist.whitelistIDs[i] + - posData.whitelist.originalLabsIDs[i] + ) + if IDs_to_add: + mask = np.isin(posData.lab, list(IDs_to_add)) + og_frame[mask] = posData.lab[mask] + + posData.lab = og_frame + self.update_rp(wl_update=False) + self.store_data(autosave=False) + + if frame_i > 0: + missing_IDs = set(posData.IDs) - set( + posData.allData_li[frame_i - 1]["IDs"] + ) + self.trackManuallyAddedObject( + missing_IDs, isNewID=True, wl_update=False + ) + + self.setAllTextAnnotations() + self.updateAllImages() + + elif switch_to_seg: + self.viewOriginalLabels = False + self.setFrameNavigationDisabled(False, why="Viewing original labels") + + for i in frames_range: + posData.frame_i = i + self.get_data() + try: + posData.whitelist.originalLabs[i] = posData.lab.copy() + posData.whitelist.originalLabsIDs[i] = set(posData.IDs) + except AttributeError: + lab = posData.segm_data[i].copy() + IDs = [obj.label for obj in skimage.measure.regionprops(lab)] + posData.whitelist.originalLabs[i] = lab + posData.whitelist.originalLabsIDs[i] = set(IDs) + + # self.whitelistTrackCurrOG() + self.update_rp(wl_update=False) + self.store_data(autosave=False) + self.whitelistUpdateLab(frame_i=i) # has update_rp and store data + self.setAllTextAnnotations() + self.updateAllImages() diff --git a/cellacdc/mixins/window_events.py b/cellacdc/mixins/window_events.py index 0cac5bdaf..c64f2ee27 100644 --- a/cellacdc/mixins/window_events.py +++ b/cellacdc/mixins/window_events.py @@ -11,7 +11,15 @@ from qtpy.QtGui import QCursor, QFont, QKeyEvent, QKeySequence, QPixmap from qtpy.QtWidgets import QAbstractSlider, QCheckBox, QMainWindow -from cellacdc import apps, exception_handler, html_utils, is_mac, printl, qutils, widgets +from cellacdc import ( + apps, + exception_handler, + html_utils, + is_mac, + printl, + qutils, + widgets, +) from cellacdc.plot import imshow @@ -19,449 +27,440 @@ _font.setPixelSize(11) -class WindowEventsView: +class WindowEventsMixin: """Qt-facing adapter for main-window and pointer event handling.""" - LEGACY_METHODS = ( - 'onKeyHome', - 'onKeyEnd', - 'onKeyPageUp', - 'onKeyPageDown', - 'keyUpCallback', - 'keyDownCallback', - 'dragEnterEvent', - 'dropEvent', - 'changeEvent', - 'leaveEvent', - 'enterEvent', - 'isPanImageClick', - 'middleClickText', - 'isDefaultMiddleClick', - 'isMiddleClick', - 'resizeBottomLayoutLineClicked', - 'resizeBottomLayoutLineDragged', - 'resizeBottomLayoutLineReleased', - 'mousePressEvent', - 'resizeLeaveSpaceTerminalBelow', - '_resizeLeaveSpaceTerminalBelow', - 'checkSetDelObjActionActive', - 'changeRightClickToLeftOnMac', - 'checkTriggerKeyPressShortcuts', - '_temp_debug', - 'checkOverlayToolbuttonClicked', - 'keyPressCheckSetSpinboxValue', - 'editingSpinboxValueTimerCallback', - 'keyPressEvent', - 'doubleRightClickTimerCallBack', - 'doubleKeyTimerCallBack', - 'doubleKeySpacebarTimerCallback', - 'updateBrushCursorOnShiftRelease', - 'onShiftReleased', - 'keyReleaseEvent', - 'clearMemory', - 'askCloseAllWindows', - 'stopPreprocWorker', - 'closeEvent', - 'readSettings', - 'saveWindowGeometry', - 'storeDefaultAndCustomColors', - 'showEvent', - 'super_show', - 'show', - 'resizeSlidersArea', - '_resizeSlidersArea', - 'resizeEvent', - 'gui_createCursors', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - - def onKeyHome(self): - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepAdd - ) - - def onKeyEnd(self): - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepSub - ) - - def onKeyPageUp(self): - isAutoPilotActive = ( - self.autoPilotZoomToObjToggle.isChecked() - and self.autoPilotZoomToObjToolbar.isVisible() - ) - if isAutoPilotActive: - self.pointsLayerAutoPilot('next') - elif self.zSliceScrollBar.isVisible(): - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepAdd - ) - - def onKeyPageDown(self): - isAutoPilotActive = ( - self.autoPilotZoomToObjToggle.isChecked() - and self.autoPilotZoomToObjToolbar.isVisible() - ) - if isAutoPilotActive: - self.pointsLayerAutoPilot('prev') - elif self.zSliceScrollBar.isVisible(): - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepAdd - ) - - def keyUpCallback( - self, isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive - ): - isAutoPilotActive = ( - self.autoPilotZoomToObjToggle.isChecked() - and self.autoPilotZoomToObjToolbar.isVisible() - ) - if isBrushActive: - brushSize = self.brushSizeSpinbox.value() - self.brushSizeSpinbox.setValue(brushSize+1) - elif isWandActive: - wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() - self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance+1) - elif isExpandLabelActive: - self.label_transform_tools_view.expand_label(dilation=True) - self.expandFootprintSize += 1 - elif isLabelRoiCircActive: - val = self.labelRoiCircularRadiusSpinbox.value() - self.labelRoiCircularRadiusSpinbox.setValue(val+1) - elif isAutoPilotActive: - self.pointsLayerAutoPilot('next') - else: - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepAdd - ) + def _resizeLeaveSpaceTerminalBelow(self): + geometry = self.geometry() + left = geometry.left() + top = geometry.top() + width = geometry.width() + height = geometry.height() + self.setGeometry(left, top + 10, width, height - 200) - def keyDownCallback( - self, isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive - ): - isAutoPilotActive = ( - self.autoPilotZoomToObjToggle.isChecked() - and self.autoPilotZoomToObjToolbar.isVisible() + def _resizeSlidersArea(self): + self.navigateScrollBar.setFixedHeight(self.newHeight) + self.zSliceScrollBar.setFixedHeight(self.newHeight) + self.zSliceOverlay_SB.setFixedHeight(self.newHeight) + self.zProjComboBox.setFixedHeight(self.newHeight) + self.zProjOverlay_CB.setFixedHeight(self.newHeight) + self.navSpinBox.setFixedHeight(self.newHeight) + self.zSliceSpinbox.setFixedHeight(self.newHeight) + try: + self.img1.alphaScrollbar.setFixedHeight(self.newHeight) + except Exception: + pass + try: + for channel, items in self.overlayLayersItems.items(): + alphaScrollbar = items[2] + alphaScrollbar.setFixedHeight(self.newHeight) + except: + pass + checkBoxStyleSheet = ( + "QCheckBox::indicator {" + f"width: {self.newCheckBoxesHeight}px;" + f"height: {self.newCheckBoxesHeight}px" + "}" ) - if isBrushActive: - brushSize = self.brushSizeSpinbox.value() - self.brushSizeSpinbox.setValue(brushSize-1) - elif isWandActive: - wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() - self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance-1) - elif isExpandLabelActive: - self.label_transform_tools_view.expand_label(dilation=False) - self.expandFootprintSize += 1 - elif isLabelRoiCircActive: - val = self.labelRoiCircularRadiusSpinbox.value() - self.labelRoiCircularRadiusSpinbox.setValue(val-1) - elif isAutoPilotActive: - self.pointsLayerAutoPilot('prev') - elif self.isNavigateActionOnNextFrame(): - posData = self.data[self.pos_i] - self.rightImageFramesScrollbar.setValue(posData.frame_i+2) - else: - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepSub - ) + for i in range(self.annotOptionsLayout.count()): + widget = self.annotOptionsLayout.itemAt(i).widget() + if isinstance(widget, QCheckBox): + widget.setStyleSheet(checkBoxStyleSheet) + for i in range(self.annotOptionsLayoutRight.count()): + widget = self.annotOptionsLayoutRight.itemAt(i).widget() + if isinstance(widget, QCheckBox): + widget.setStyleSheet(checkBoxStyleSheet) + self.zSliceCheckbox.setStyleSheet(checkBoxStyleSheet) - def dragEnterEvent(self, event): - file_path = event.mimeData().urls()[0].toLocalFile() - if os.path.isdir(file_path): - basename = os.path.basename(file_path) - if basename.find('Position_') != -1 or basename == 'Images': - event.acceptProposedAction() - else: - event.ignore() - else: - event.acceptProposedAction() + def _temp_debug(self, id=None): + posData = self.data[self.pos_i] + imshow(posData.lab, annotate_labels_idxs=[0]) - def dropEvent(self, event): - event.setDropAction(Qt.CopyAction) - file_path = event.mimeData().urls()[0].toLocalFile() - self.logger.info(f'Dragged and dropped path "{file_path}"') - if os.path.isdir(file_path): - self.openFolder(exp_path=file_path) - else: - self.openFile(file_path=file_path) + def askCloseAllWindows(self): + txt = html_utils.paragraph(""" + There are other open windows that were created from this window. +

+ If you proceed, the other windows will be closed too.
+ """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Open windows", txt, buttonsTexts=("Cancel", "Ok, close now")) + return msg.cancel def changeEvent(self, event): try: self.delObjToolAction.setChecked(False) - except Exception as err: + except Exception: return - def leaveEvent(self, event): - if self.slideshowWin is not None: - posData = self.data[self.pos_i] - mainWinGeometry = self.geometry() - slideshowWinGeometry = self.slideshowWin.geometry() + def changeRightClickToLeftOnMac(self, mouseEvent): + button = mouseEvent.button() + if not is_mac: + return button - overlap = self.windows_overlap_from_bounds( - main_left=mainWinGeometry.left(), - main_top=mainWinGeometry.top(), - main_width=mainWinGeometry.width(), - main_height=mainWinGeometry.height(), - other_left=slideshowWinGeometry.left(), - other_top=slideshowWinGeometry.top(), - ) - autoActivate = self.should_auto_activate_viewer( - is_data_loaded=self.isDataLoaded, - windows_overlap=overlap, - disable_auto_activate=posData.disableAutoActivateViewerWindow, - ) + delObjKeySequence, delObjQtButton = self.delObjAction + if delObjKeySequence is None: + return button - if autoActivate: - self.slideshowWin.setFocus() - self.slideshowWin.activateWindow() + if not delObjKeySequence.toString() == "Control": + return button - def enterEvent(self, event): - event.accept() - if self.slideshowWin is not None: - posData = self.data[self.pos_i] - mainWinGeometry = self.geometry() - slideshowWinGeometry = self.slideshowWin.geometry() + if button != Qt.MouseButton.RightButton: + return button - overlap = self.windows_overlap_from_bounds( - main_left=mainWinGeometry.left(), - main_top=mainWinGeometry.top(), - main_width=mainWinGeometry.width(), - main_height=mainWinGeometry.height(), - other_left=slideshowWinGeometry.left(), - other_top=slideshowWinGeometry.top(), - ) - autoActivate = self.should_auto_activate_viewer( - is_data_loaded=self.isDataLoaded, - windows_overlap=overlap, - disable_auto_activate=posData.disableAutoActivateViewerWindow, - ) + if delObjQtButton == Qt.MouseButton.LeftButton: + # On mac, pressing "Control" and clicking with left button changes + # it to a right click button --> here, left click is required for + # delete object --> force return of left click + return Qt.MouseButton.LeftButton - if autoActivate: - # self.setFocus() - self.activateWindow() + return button - def isPanImageClick(self, mouseEvent, modifiers): - return self.is_pan_image_click( - mouse_button=mouseEvent.button(), - left_button=Qt.MouseButton.LeftButton, - modifiers=modifiers, - alt_modifier=Qt.AltModifier, - ) + def checkOverlayToolbuttonClicked(self, event): + success = False + try: + n = int(event.text()) + toolbutton = self.allOverlayToolbuttonsByIdx.get(n, None) + toolbutton.click() + success = True + except Exception: + # printl(traceback.format_exc()) + success = False + return success - def middleClickText(self): - if self.delObjAction is None and is_mac: - return self.middle_click_text( - has_del_object_action=False, - is_mac=is_mac, - ) + def checkSetDelObjActionActive(self, event): + if self.delObjAction is None and self.is_win: + return if self.delObjAction is None: - return self.middle_click_text( - has_del_object_action=False, - is_mac=is_mac, - ) + # On mac we check for Key_Control + if event.key() == Qt.Key_Control: + self.delObjToolAction.setChecked(True) + return delObjKeySequence, delObjQtButton = self.delObjAction - - if delObjQtButton == Qt.MouseButton.LeftButton: - buttonName = 'Left click' - elif delObjQtButton == Qt.MouseButton.RightButton: - buttonName = 'Right click' - else: - buttonName = 'Middle click' + keySequenceText = widgets.QKeyEventToString(event).rstrip("+") if delObjKeySequence is None: - keySequenceText = None - else: - keySequenceText = delObjKeySequence.toString() + # self.delObjToolAction.setChecked(True) + return - return self.middle_click_text( - has_del_object_action=True, - is_mac=is_mac, - button_name=buttonName, - key_sequence_text=keySequenceText, + delObjKeySequenceText = widgets.macShortcutToWindows( + delObjKeySequence.toString() ) + keySequenceText = widgets.macShortcutToWindows(keySequenceText) - def isDefaultMiddleClick(self, mouseEvent, modifiers): - return self.is_default_middle_click( - mouse_button=mouseEvent.button(), - modifiers=modifiers, - is_mac=is_mac, - brush_is_checked=self.brushButton.isChecked(), - left_button=Qt.MouseButton.LeftButton, - middle_button=Qt.MouseButton.MiddleButton, - control_modifier=Qt.ControlModifier, - ) + # printl( + # delObjKeySequence.toString(), + # keySequenceText, + # delObjKeySequenceText + # ) - def isMiddleClick(self, mouseEvent, modifiers): - if self.delObjAction is None: - return self.isDefaultMiddleClick(mouseEvent, modifiers) + if keySequenceText == delObjKeySequenceText: + self.delObjToolAction.setChecked(True) - delObjKeySequence, delObjQtButton = self.delObjAction - mouseEventButton = self.changeRightClickToLeftOnMac(mouseEvent) - return self.is_configured_middle_click( - mouse_button=mouseEventButton, - configured_button=delObjQtButton, - key_sequence_is_none=delObjKeySequence is None, - tool_is_checked=self.delObjToolAction.isChecked(), - ) + def checkTriggerKeyPressShortcuts(self, event: QKeyEvent): + isBrushKey = event.key() == self.brushButton.keyPressShortcut + isEraserKey = event.key() == self.eraserButton.keyPressShortcut + if isBrushKey or isEraserKey: + return isBrushKey, isEraserKey - def resizeBottomLayoutLineClicked(self, event): - pass + modifierText = widgets.modifierKeyToText(event.modifiers()) + for widget in self.widgetsWithShortcut.values(): + if not hasattr(widget, "keyPressShortcut"): + continue - def resizeBottomLayoutLineDragged(self, event): - if not self.img1BottomGroupbox.isVisible(): + if event.key() == widget.keyPressShortcut: + if widget.isCheckable(): + widget.setChecked(True) + else: + widget.trigger() + continue + + shortcutText = widget.keyPressShortcut.toString() + try: + mod, key = shortcutText.split("+") + if modifierText == mod and event.key() == QKeySequence(key): + widget.trigger() + + except Exception: + pass + + return isBrushKey, isEraserKey + + def clearMemory(self): + if not hasattr(self, "data"): return - newBottomLayoutHeight = self.bottomScrollArea.minimumHeight() - event.y() - self.bottomScrollArea.setFixedHeight(newBottomLayoutHeight) + self.logger.info("Clearing memory...") + for posData in self.data: + try: + del posData.img_data + except Exception: + pass + try: + del posData.segm_data + except Exception: + pass + try: + del posData.ol_data_dict + except Exception: + pass + try: + del posData.fluo_data_dict + except Exception: + pass + try: + del posData.ol_data + except Exception: + pass + del self.data - def resizeBottomLayoutLineReleased(self): - QTimer.singleShot(100, self.autoRange) + def closeEvent(self, event): + self.setDisabled(False) + cancel = self.checkAskSavePointsLayers() + if cancel: + event.ignore() + return - def mousePressEvent(self, event) -> None: - if event.button() == Qt.MouseButton.RightButton: - pos = self.resizeBottomLayoutLine.mapFromGlobal(event.globalPos()) - if pos.y()>=0: - self.gui_raiseBottomLayoutContextMenu(event) - return QMainWindow.mousePressEvent(self.host, event) + self.onEscape() + self.saveWindowGeometry() - def resizeLeaveSpaceTerminalBelow(self): - self.setWindowState(Qt.WindowMaximized) - QTimer.singleShot(200, self._resizeLeaveSpaceTerminalBelow) + if self.newWindows: + cancel = self.askCloseAllWindows() + if cancel: + event.ignore() + return - def _resizeLeaveSpaceTerminalBelow(self): - geometry = self.geometry() - left = geometry.left() - top = geometry.top() - width = geometry.width() - height = geometry.height() - self.setGeometry(left, top+10, width, height-200) + for window in self.newWindows: + window.close() - def checkSetDelObjActionActive(self, event): - if self.delObjAction is None and self.is_win: + if self.slideshowWin is not None: + self.slideshowWin.close() + if self.ccaTableWin is not None: + self.ccaTableWin.close() + + proceed = self.askSaveOnClosing(event) + if not proceed: + event.ignore() return - if self.delObjAction is None: - # On mac we check for Key_Control - if event.key() == Qt.Key_Control: - self.delObjToolAction.setChecked(True) + self.autoSaveClose() + + if self.autoSaveActiveWorkers: + progressWin = apps.QDialogWorkerProgress( + title="Closing autosaving worker", + parent=self, + pbarDesc="Closing autosaving worker...", + ) + progressWin.show(self.app) + progressWin.mainPbar.setMaximum(0) + self.waitCloseAutoSaveWorkerLoop = qutils.QWhileLoop( + self._waitCloseAutoSaveWorker, period=250 + ) + self.waitCloseAutoSaveWorkerLoop.exec_() + progressWin.workerFinished = True + progressWin.close() + + self.stopPreprocWorker() + self.stopCombineWorker() + self.stopCcaIntegrityCheckerWorker() + + # Close the inifinte loop of the thread + if self.lazyLoader is not None: + self.lazyLoader.exit = True + self.lazyLoaderWaitCond.wakeAll() + self.waitReadH5cond.wakeAll() + + if self.storeStateWorker is not None: + # Close storeStateWorker + self.storeStateWorker._stop() + while self.storeStateWorker.isFinished: + time.sleep(0.05) + + # Block main thread while separate threads closes + time.sleep(0.1) + + self.clearMemory() + + self.logger.info("Closing GUI logger...") + self.logger.close() + + if self.lazyLoader is None: + self.sigClosed.emit(self) + + gc.collect() + + def doubleKeySpacebarTimerCallback(self): + if self.isKeyDoublePress: + self.doubleKeyTimeElapsed = False + return + self.doubleKeyTimeElapsed = True + self.countKeyPress = 0 + + def doubleKeyTimerCallBack(self): + if self.isKeyDoublePress: + self.doubleKeyTimeElapsed = False + return + self.doubleKeyTimeElapsed = True + self.countKeyPress = 0 + if self.Button is None: + return + + isBrushChecked = self.Button.isChecked() + if isBrushChecked and self.uncheck: + self.Button.setChecked(False) + c = self.defaultToolBarButtonColor + self.Button.setStyleSheet(f"background-color: {c}") + + def doubleRightClickTimerCallBack(self): + if self.isDoubleRightClick: + self.doubleRightClickTimeElapsed = False return + self.doubleRightClickTimeElapsed = True + self.countRightClicks = 0 - delObjKeySequence, delObjQtButton = self.delObjAction - keySequenceText = widgets.QKeyEventToString(event).rstrip('+') + # Time to double right click on img1 expired --> single right-click + self.gui_imgGradShowContextMenu(*self._img1_click_xy) - if delObjKeySequence is None: - # self.delObjToolAction.setChecked(True) - return + def dragEnterEvent(self, event): + file_path = event.mimeData().urls()[0].toLocalFile() + if os.path.isdir(file_path): + basename = os.path.basename(file_path) + if basename.find("Position_") != -1 or basename == "Images": + event.acceptProposedAction() + else: + event.ignore() + else: + event.acceptProposedAction() - delObjKeySequenceText = widgets.macShortcutToWindows( - delObjKeySequence.toString() - ) - keySequenceText = widgets.macShortcutToWindows(keySequenceText) + def dropEvent(self, event): + event.setDropAction(Qt.CopyAction) + file_path = event.mimeData().urls()[0].toLocalFile() + self.logger.info(f'Dragged and dropped path "{file_path}"') + os.path.basename(file_path) + if os.path.isdir(file_path): + exp_path = file_path + self.openFolder(exp_path=exp_path) + else: + self.openFile(file_path=file_path) - # printl( - # delObjKeySequence.toString(), - # keySequenceText, - # delObjKeySequenceText - # ) + def editingSpinboxValueTimerCallback(self): + self.typingEditID = False - if keySequenceText == delObjKeySequenceText: - self.delObjToolAction.setChecked(True) + def enterEvent(self, event): + event.accept() + if self.slideshowWin is not None: + posData = self.data[self.pos_i] + mainWinGeometry = self.geometry() + mainWinLeft = mainWinGeometry.left() + mainWinTop = mainWinGeometry.top() + mainWinWidth = mainWinGeometry.width() + mainWinHeight = mainWinGeometry.height() + mainWinRight = mainWinLeft + mainWinWidth + mainWinBottom = mainWinTop + mainWinHeight - def changeRightClickToLeftOnMac(self, mouseEvent): - button = mouseEvent.button() - if not is_mac: - return button + slideshowWinGeometry = self.slideshowWin.geometry() + slideshowWinLeft = slideshowWinGeometry.left() + slideshowWinTop = slideshowWinGeometry.top() + slideshowWinGeometry.width() + slideshowWinGeometry.height() + + # Determine if overlap + overlap = (slideshowWinTop < mainWinBottom) and ( + slideshowWinLeft < mainWinRight + ) - delObjKeySequence, delObjQtButton = self.delObjAction - if delObjKeySequence is None: - return button + autoActivate = ( + self.isDataLoaded + and not overlap + and not posData.disableAutoActivateViewerWindow + ) - if not delObjKeySequence.toString() == 'Control': - return button + if autoActivate: + # self.setFocus() + self.activateWindow() - if button != Qt.MouseButton.RightButton: - return button + def gui_createCursors(self): + pixmap = QPixmap(":wand_cursor.svg") + self.wandCursor = QCursor(pixmap, 16, 16) - if delObjQtButton == Qt.MouseButton.LeftButton: - # On mac, pressing "Control" and clicking with left button changes - # it to a right click button --> here, left click is required for - # delete object --> force return of left click - return Qt.MouseButton.LeftButton + pixmap = QPixmap(":curv_cursor.svg") + self.curvCursor = QCursor(pixmap, 16, 16) - return button + pixmap = QPixmap(":addDelPolyLineRoi_cursor.svg") + self.polyLineRoiCursor = QCursor(pixmap, 16, 16) + pixmap = QPixmap(":cross_cursor.svg") + self.addPointsCursor = QCursor(pixmap, 16, 16) - def checkTriggerKeyPressShortcuts(self, event: QKeyEvent): - isBrushKey = event.key() == self.brushButton.keyPressShortcut - isEraserKey = event.key() == self.eraserButton.keyPressShortcut - if isBrushKey or isEraserKey: - return isBrushKey, isEraserKey + def isDefaultMiddleClick(self, mouseEvent, modifiers): + if is_mac: + middle_click = ( + mouseEvent.button() == Qt.MouseButton.LeftButton + and modifiers == Qt.ControlModifier + and not self.brushButton.isChecked() + ) + else: + middle_click = mouseEvent.button() == Qt.MouseButton.MiddleButton + return middle_click - modifierText = widgets.modifierKeyToText(event.modifiers()) - for widget in self.widgetsWithShortcut.values(): - if not hasattr(widget, 'keyPressShortcut'): - continue + def isMiddleClick(self, mouseEvent, modifiers): + if self.delObjAction is None: + return self.isDefaultMiddleClick(mouseEvent, modifiers) - if event.key() == widget.keyPressShortcut: - if widget.isCheckable(): - widget.setChecked(True) - else: - widget.trigger() - continue + delObjKeySequence, delObjQtButton = self.delObjAction + if delObjKeySequence is None: + # Setting only middle click on mac is allowed, however the + # delObjKeySequence is None and the tool button is never checked + isDelObjectActive = True + else: + isDelObjectActive = self.delObjToolAction.isChecked() - shortcutText = widget.keyPressShortcut.toString() - try: - mod, key = shortcutText.split('+') - if modifierText == mod and event.key() == QKeySequence(key): - widget.trigger() + mouseEventButton = self.changeRightClickToLeftOnMac(mouseEvent) - except Exception as e: - pass + middle_click = mouseEventButton == delObjQtButton and isDelObjectActive - return isBrushKey, isEraserKey + return middle_click - def _temp_debug(self, id=None): - posData = self.data[self.pos_i] - imshow(posData.lab, annotate_labels_idxs=[0]) + def isPanImageClick(self, mouseEvent, modifiers): + left_click = mouseEvent.button() == Qt.MouseButton.LeftButton + return modifiers == Qt.AltModifier and left_click - def checkOverlayToolbuttonClicked(self, event): - success = False - try: - n = int(event.text()) - toolbutton = self.allOverlayToolbuttonsByIdx.get(n, None) - toolbutton.click() - success = True - except Exception as e: - # printl(traceback.format_exc()) - success = False - return success + def keyDownCallback( + self, isBrushActive, isWandActive, isExpandLabelActive, isLabelRoiCircActive + ): + isAutoPilotActive = ( + self.autoPilotZoomToObjToggle.isChecked() + and self.autoPilotZoomToObjToolbar.isVisible() + ) + if isBrushActive: + brushSize = self.brushSizeSpinbox.value() + self.brushSizeSpinbox.setValue(brushSize - 1) + elif isWandActive: + wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() + self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance - 1) + elif isExpandLabelActive: + self.expandLabel(dilation=False) + self.expandFootprintSize += 1 + elif isLabelRoiCircActive: + val = self.labelRoiCircularRadiusSpinbox.value() + self.labelRoiCircularRadiusSpinbox.setValue(val - 1) + elif isAutoPilotActive: + self.pointsLayerAutoPilot("prev") + elif self.isNavigateActionOnNextFrame(): + posData = self.data[self.pos_i] + self.rightImageFramesScrollbar.setValue(posData.frame_i + 2) + else: + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepSub + ) def keyPressCheckSetSpinboxValue(self, event, spinbox): """Check if the key pressed is a digit and set the spinbox value - - """Headless placeholder for main-window event rules.""" - accordingly.""" try: n = int(event.text()) if self.typingEditID: - value = int(f'{spinbox.value()}{n}') + value = int(f"{spinbox.value()}{n}") else: value = n self.typingEditID = True @@ -469,24 +468,19 @@ def keyPressCheckSetSpinboxValue(self, event, spinbox): try: spinbox.timer.stop() - except Exception as err: + except Exception: pass spinbox.timer = QTimer(spinbox) - spinbox.timer.timeout.connect( - self.editingSpinboxValueTimerCallback - ) + spinbox.timer.timeout.connect(self.editingSpinboxValueTimerCallback) spinbox.timer.start(2000) spinbox.timer.setSingleShot(True) success = True - except Exception as e: + except Exception: # printl(traceback.format_exc()) success = False return success - def editingSpinboxValueTimerCallback(self): - self.typingEditID = False - @exception_handler def keyPressEvent(self, ev): ctrl = ev.modifiers() == Qt.ControlModifier @@ -496,16 +490,17 @@ def keyPressEvent(self, ev): if ev.key() == Qt.Key_Q and self.debug: try: - from cellacdc import _q_debug + from . import _q_debug + _q_debug.q_debug(self) - except Exception as err: + except Exception: printl(traceback.format_exc()) printl('[ERROR]: Error with "_qdebug" module. See Traceback above.') pass if not self.isDataLoaded: self.logger.warning( - 'Data not loaded yet. Key pressing events are not connected.' + "Data not loaded yet. Key pressing events are not connected." ) return @@ -534,17 +529,22 @@ def keyPressEvent(self, ev): self.checkSetDelObjActionActive(ev) self.isZmodifier = ( - ev.key()== Qt.Key_Z and not isAltModifier - and not isCtrlModifier and not isShiftModifier + ev.key() == Qt.Key_Z + and not isAltModifier + and not isCtrlModifier + and not isShiftModifier ) if isShiftModifier: if self.brushButton.isChecked(): # Force default brush symbol with shift down self.setHoverToolSymbolColor( - 1, 1, self.ax2_BrushCirclePen, + 1, + 1, + self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush, - ID=0 + self.brushButton, + brush=self.ax2_BrushCircleBrush, + ID=0, ) if self.isSegm3D: self.changeBrushID() @@ -555,14 +555,12 @@ def keyPressEvent(self, ev): if isButtonClicked: return - isBrushActive = ( - self.brushButton.isChecked() or self.eraserButton.isChecked() - ) + isBrushActive = self.brushButton.isChecked() or self.eraserButton.isChecked() isManualTrackingActive = self.manualTrackingButton.isChecked() isManualBackgroundActive = self.manualBackgroundButton.isChecked() isTypingIDFunctionChecked = False if self.brushButton.isChecked() and not self.autoIDcheckbox.isChecked(): - success = self.keyPressCheckSetSpinboxValue(ev, self.editIDspinbox) + self.keyPressCheckSetSpinboxValue(ev, self.editIDspinbox) isTypingIDFunctionChecked = True if isManualTrackingActive: @@ -577,9 +575,9 @@ def keyPressEvent(self, ev): addPointsByClickingButton = self.buttonAddPointsByClickingActive() if ( - addPointsByClickingButton is not None - and addPointsByClickingButton.toolbar.isVisible() - ): + addPointsByClickingButton is not None + and addPointsByClickingButton.toolbar.isVisible() + ): isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( ev, addPointsByClickingButton.rightClickIDSpinbox ) @@ -592,16 +590,14 @@ def keyPressEvent(self, ev): and self.labelRoiIsCircularRadioButton.isChecked() ) how = self.drawIDsContComboBox.currentText() - isOverlaySegm = how.find('overlay segm. masks') != -1 - if ev.key()==Qt.Key_Up and not isCtrlModifier: + isOverlaySegm = how.find("overlay segm. masks") != -1 + if ev.key() == Qt.Key_Up and not isCtrlModifier: self.keyUpCallback( - isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive + isBrushActive, isWandActive, isExpandLabelActive, isLabelRoiCircActive ) - elif ev.key()==Qt.Key_Down and not isCtrlModifier: + elif ev.key() == Qt.Key_Down and not isCtrlModifier: self.keyDownCallback( - isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive + isBrushActive, isWandActive, isExpandLabelActive, isLabelRoiCircActive ) elif ev.key() == Qt.Key_Enter or ev.key() == Qt.Key_Return: if isTypingIDFunctionChecked: @@ -618,13 +614,13 @@ def keyPressEvent(self, ev): elif isCtrlModifier and isOverlaySegm: if ev.key() == Qt.Key_Up: val = self.imgGrad.labelsAlphaSlider.value() - delta = 5/self.imgGrad.labelsAlphaSlider.maximum() - val = val+delta + delta = 5 / self.imgGrad.labelsAlphaSlider.maximum() + val = val + delta self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) elif ev.key() == Qt.Key_Down: val = self.imgGrad.labelsAlphaSlider.value() - delta = 5/self.imgGrad.labelsAlphaSlider.maximum() - val = val-delta + delta = 5 / self.imgGrad.labelsAlphaSlider.maximum() + val = val - delta self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) elif ev.key() == self.zoomOutKeyValue: self.zoomToCells(enforce=True) @@ -679,81 +675,28 @@ def keyPressEvent(self, ev): c = self.defaultToolBarButtonColor else: c = self.doublePressKeyButtonColor - self.Button.setStyleSheet(f'background-color: {c}') + self.Button.setStyleSheet(f"background-color: {c}") self.countKeyPress = 0 if self.xHoverImg is not None: xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) if isBrushKey: self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, + xdata, + ydata, + self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush + self.brushButton, + brush=self.ax2_BrushCircleBrush, ) elif isEraserKey: self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, + xdata, + ydata, + self.eraserCirclePen, (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton + self.eraserButton, ) - def doubleRightClickTimerCallBack(self): - if self.isDoubleRightClick: - self.doubleRightClickTimeElapsed = False - return - self.doubleRightClickTimeElapsed = True - self.countRightClicks = 0 - - # Time to double right click on img1 expired --> single right-click - self.canvas_context_menu_view.show_img_gradient_context_menu( - *self._img1_click_xy - ) - - def doubleKeyTimerCallBack(self): - if self.isKeyDoublePress: - self.doubleKeyTimeElapsed = False - return - self.doubleKeyTimeElapsed = True - self.countKeyPress = 0 - if self.Button is None: - return - - isBrushChecked = self.Button.isChecked() - if isBrushChecked and self.uncheck: - self.Button.setChecked(False) - c = self.defaultToolBarButtonColor - self.Button.setStyleSheet(f'background-color: {c}') - - def doubleKeySpacebarTimerCallback(self): - if self.isKeyDoublePress: - self.doubleKeyTimeElapsed = False - return - self.doubleKeyTimeElapsed = True - self.countKeyPress = 0 - - # # Spacebar single press --> toggle next visualization - # currentIndex = self.drawIDsContComboBox.currentIndex() - # nItems = self.drawIDsContComboBox.count() - # nextIndex = currentIndex+1 - # if nextIndex < nItems: - # self.drawIDsContComboBox.setCurrentIndex(nextIndex) - # else: - # self.drawIDsContComboBox.setCurrentIndex(0) - - def updateBrushCursorOnShiftRelease(self): - xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush, - byPassShiftCheck=True - ) - if self.isSegm3D: - self.changeBrushID() - - def onShiftReleased(self): - if self.brushButton.isChecked() and self.xHoverImg is not None: - self.updateBrushCursorOnShiftRelease() - def keyReleaseEvent(self, ev): if self.app.overrideCursor() == Qt.SizeAllCursor: self.app.restoreOverrideCursor() @@ -784,14 +727,12 @@ def keyReleaseEvent(self, ev): showCentered=False, wrapText=False ) txt = html_utils.paragraph(f""" - Please, do not keep the key "{ev.text().upper()}" + Please, do not keep the key "{ev.text().upper()}" pressed.

It confuses me :)

Thanks! """) - self.warnKeyPressedMsg.warning( - self.host, 'Release the key, please', txt - ) + self.warnKeyPressedMsg.warning(self, "Release the key, please", txt) self.warnKeyPressedMsg = None elif ev.isAutoRepeat() and ev.key() == Qt.Key_Z and self.isZmodifier: self.zKeptDown = True @@ -802,201 +743,154 @@ def keyReleaseEvent(self, ev): self.zSliceCheckbox.setChecked(not self.zSliceCheckbox.isChecked()) self.zKeptDown = False - def clearMemory(self): - if not hasattr(self, 'data'): - return - self.logger.info('Clearing memory...') - for posData in self.data: - try: - del posData.img_data - except Exception as e: - pass - try: - del posData.segm_data - except Exception as e: - pass - try: - del posData.ol_data_dict - except Exception as e: - pass - try: - del posData.fluo_data_dict - except Exception as e: - pass - try: - del posData.ol_data - except Exception as e: - pass - del self.data - - def askCloseAllWindows(self): - txt = html_utils.paragraph(""" - There are other open windows that were created from this window. -

- If you proceed, the other windows will be closed too.
- """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning( - self.host, 'Open windows', txt, - buttonsTexts=('Cancel', 'Ok, close now') - ) - return msg.cancel - - def stopPreprocWorker(self): - self.logger.info('Closing pre-processing worker...') - try: - self.preprocWorker.stop() - except Exception as err: - pass - - def closeEvent(self, event): - self.setDisabled(False) - cancel = self.checkAskSavePointsLayers() - if cancel: - event.ignore() - return - - self.onEscape() - self.saveWindowGeometry() - - if self.newWindows: - cancel = self.askCloseAllWindows() - if cancel: - event.ignore() - return - - for window in self.newWindows: - window.close() - - if self.slideshowWin is not None: - self.slideshowWin.close() - if self.ccaTableWin is not None: - self.ccaTableWin.close() - - proceed = self.askSaveOnClosing(event) - if not proceed: - event.ignore() - return - - self.autoSaveClose() - - if self.autoSaveActiveWorkers: - progressWin = apps.QDialogWorkerProgress( - title='Closing autosaving worker', parent=self.host, - pbarDesc='Closing autosaving worker...' - ) - progressWin.show(self.app) - progressWin.mainPbar.setMaximum(0) - self.waitCloseAutoSaveWorkerLoop = qutils.QWhileLoop( - self._waitCloseAutoSaveWorker, period=250 + def keyUpCallback( + self, isBrushActive, isWandActive, isExpandLabelActive, isLabelRoiCircActive + ): + isAutoPilotActive = ( + self.autoPilotZoomToObjToggle.isChecked() + and self.autoPilotZoomToObjToolbar.isVisible() + ) + if isBrushActive: + brushSize = self.brushSizeSpinbox.value() + self.brushSizeSpinbox.setValue(brushSize + 1) + elif isWandActive: + wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() + self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance + 1) + elif isExpandLabelActive: + self.expandLabel(dilation=True) + self.expandFootprintSize += 1 + elif isLabelRoiCircActive: + val = self.labelRoiCircularRadiusSpinbox.value() + self.labelRoiCircularRadiusSpinbox.setValue(val + 1) + elif isAutoPilotActive: + self.pointsLayerAutoPilot("next") + else: + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepAdd ) - self.waitCloseAutoSaveWorkerLoop.exec_() - progressWin.workerFinished = True - progressWin.close() - - self.stopPreprocWorker() - self.stopCombineWorker() - self.stopCcaIntegrityCheckerWorker() - - # Close the inifinte loop of the thread - if self.lazyLoader is not None: - self.lazyLoader.exit = True - self.lazyLoaderWaitCond.wakeAll() - self.waitReadH5cond.wakeAll() - if self.storeStateWorker is not None: - # Close storeStateWorker - self.storeStateWorker._stop() - while self.storeStateWorker.isFinished: - time.sleep(0.05) + def leaveEvent(self, event): + if self.slideshowWin is not None: + posData = self.data[self.pos_i] + mainWinGeometry = self.geometry() + mainWinLeft = mainWinGeometry.left() + mainWinTop = mainWinGeometry.top() + mainWinWidth = mainWinGeometry.width() + mainWinHeight = mainWinGeometry.height() + mainWinRight = mainWinLeft + mainWinWidth + mainWinBottom = mainWinTop + mainWinHeight - # Block main thread while separate threads closes - time.sleep(0.1) + slideshowWinGeometry = self.slideshowWin.geometry() + slideshowWinLeft = slideshowWinGeometry.left() + slideshowWinTop = slideshowWinGeometry.top() + slideshowWinGeometry.width() + slideshowWinGeometry.height() + + # Determine if overlap + overlap = (slideshowWinTop < mainWinBottom) and ( + slideshowWinLeft < mainWinRight + ) - self.clearMemory() + autoActivate = ( + self.isDataLoaded + and not overlap + and not posData.disableAutoActivateViewerWindow + ) - self.logger.info('Closing GUI logger...') - self.logger.close() + if autoActivate: + self.slideshowWin.setFocus() + self.slideshowWin.activateWindow() - if self.lazyLoader is None: - self.sigClosed.emit(self) + def middleClickText(self): + if self.delObjAction is None and is_mac: + return "Command + Left Click" - gc.collect() + if self.delObjAction is None: + return "Middle Click" - def readSettings(self): - settings = QSettings('schmollerlab', 'acdc_gui') - if settings.value('geometry') is not None: - self.restoreGeometry(settings.value("geometry")) - # self.restoreState(settings.value("windowState")) + delObjKeySequence, delObjQtButton = self.delObjAction - def saveWindowGeometry(self): - settings = QSettings('schmollerlab', 'acdc_gui') - settings.setValue("geometry", self.saveGeometry()) - # settings.setValue("windowState", self.saveState()) + if delObjQtButton == Qt.MouseButton.LeftButton: + buttonName = "Left click" + elif delObjQtButton == Qt.MouseButton.RightButton: + buttonName = "Right click" + else: + buttonName = "Middle click" - def storeDefaultAndCustomColors(self): - c = self.overlayButton.palette().button().color().name() - self.defaultToolBarButtonColor = c - self.doublePressKeyButtonColor = '#fa693b' + if delObjKeySequence is None: + return buttonName - def showEvent(self, event): - if self.mainWin is not None: - if not self.mainWin.isMinimized(): - return - self.mainWin.showAllWindows() - # self.setFocus() - self.activateWindow() + return f"{delObjKeySequence.toString()} + {buttonName}" - def super_show(self): - QMainWindow.show(self.host) + def mousePressEvent(self, event) -> None: + if event.button() == Qt.MouseButton.RightButton: + pos = self.resizeBottomLayoutLine.mapFromGlobal(event.globalPos()) + if pos.y() >= 0: + self.gui_raiseBottomLayoutContextMenu(event) + return super().mousePressEvent(event) - def show(self): - self.setFont(_font) - QMainWindow.show(self.host) + def onKeyEnd(self): + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepSub + ) - self.setWindowState(Qt.WindowNoState) - self.setWindowState(Qt.WindowActive) - self.raise_() + def onKeyHome(self): + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepAdd + ) - self.readSettings() - self.storeDefaultAndCustomColors() + def onKeyPageDown(self): + isAutoPilotActive = ( + self.autoPilotZoomToObjToggle.isChecked() + and self.autoPilotZoomToObjToolbar.isVisible() + ) + if isAutoPilotActive: + self.pointsLayerAutoPilot("prev") + elif self.zSliceScrollBar.isVisible(): + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepAdd + ) - self.h = self.navSpinBox.size().height() - fontSizeFactor = None - heightFactor = None - if 'bottom_sliders_zoom_perc' in self.df_settings.index: - val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value']) - if val != 100: - fontSizeFactor = val/100 - heightFactor = val/100 + def onKeyPageUp(self): + isAutoPilotActive = ( + self.autoPilotZoomToObjToggle.isChecked() + and self.autoPilotZoomToObjToolbar.isVisible() + ) + if isAutoPilotActive: + self.pointsLayerAutoPilot("next") + elif self.zSliceScrollBar.isVisible(): + self.zSliceScrollBar.triggerAction( + QAbstractSlider.SliderAction.SliderSingleStepAdd + ) - self.defaultWidgetHeightBottomLayout = self.h - self.checkBoxesHeight = 14 - self.fontPixelSize = 11 - self.defaultBottomLayoutHeight = self.img1BottomGroupbox.height() + def onShiftReleased(self): + if self.brushButton.isChecked() and self.xHoverImg is not None: + self.updateBrushCursorOnShiftRelease() - self.bottomLayout.setStretch(0, 0) - self.bottomLayout.addSpacing(self.quickSettingsGroupbox.width()) - self.resizeSlidersArea( - fontSizeFactor=fontSizeFactor, heightFactor=heightFactor - ) - self.bottomScrollArea.hide() + def readSettings(self): + settings = QSettings("schmollerlab", "acdc_gui") + if settings.value("geometry") is not None: + self.restoreGeometry(settings.value("geometry")) - self.gui_initImg1BottomWidgets() - self.img1BottomGroupbox.hide() + def resizeBottomLayoutLineClicked(self, event): + pass - w = self.showPropsDockButton.width() - h = self.showPropsDockButton.height() + def resizeBottomLayoutLineDragged(self, event): + if not self.img1BottomGroupbox.isVisible(): + return + newBottomLayoutHeight = self.bottomScrollArea.minimumHeight() - event.y() + self.bottomScrollArea.setFixedHeight(newBottomLayoutHeight) - self.showPropsDockButton.setMaximumWidth(15) - self.showPropsDockButton.setMaximumHeight(120) + def resizeBottomLayoutLineReleased(self): + QTimer.singleShot(100, self.autoRange) - for toolbar in self.controlToolBars: - toolbar.setMinimumHeight( - self.secondLevelToolbar.sizeHint().height() - ) + def resizeEvent(self, event): + if hasattr(self, "ax1"): + self.ax1.autoRange() - self.graphLayout.setFocus() + def resizeLeaveSpaceTerminalBelow(self): + self.setWindowState(Qt.WindowMaximized) + QTimer.singleShot(200, self._resizeLeaveSpaceTerminalBelow) def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): global _font @@ -1004,13 +898,13 @@ def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): self.newCheckBoxesHeight = self.checkBoxesHeight self.newHeight = self.h else: - self.newHeight = round(self.h*heightFactor) - self.newCheckBoxesHeight = round(self.checkBoxesHeight*heightFactor) + self.newHeight = round(self.h * heightFactor) + self.newCheckBoxesHeight = round(self.checkBoxesHeight * heightFactor) if fontSizeFactor is None: newFontSize = self.fontPixelSize else: - newFontSize = round(self.fontPixelSize*fontSizeFactor) + newFontSize = round(self.fontPixelSize * fontSizeFactor) newFont = QFont() newFont.setPixelSize(newFontSize) _font = newFont @@ -1030,7 +924,7 @@ def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): self.rightBottomGroupbox.setFont(newFont) try: self.img1.alphaScrollbar.label.setFont(newFont) - except Exception as e: + except Exception: pass for i in range(self.annotOptionsLayout.count()): widget = self.annotOptionsLayout.itemAt(i).widget() @@ -1046,53 +940,87 @@ def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): pass QTimer.singleShot(100, self._resizeSlidersArea) - def _resizeSlidersArea(self): - self.navigateScrollBar.setFixedHeight(self.newHeight) - self.zSliceScrollBar.setFixedHeight(self.newHeight) - self.zSliceOverlay_SB.setFixedHeight(self.newHeight) - self.zProjComboBox.setFixedHeight(self.newHeight) - self.zProjOverlay_CB.setFixedHeight(self.newHeight) - self.navSpinBox.setFixedHeight(self.newHeight) - self.zSliceSpinbox.setFixedHeight(self.newHeight) - try: - self.img1.alphaScrollbar.setFixedHeight(self.newHeight) - except Exception as e: - pass - try: - for channel, items in self.overlayLayersItems.items(): - alphaScrollbar = items[2] - alphaScrollbar.setFixedHeight(self.newHeight) - except: - pass - checkBoxStyleSheet = ( - 'QCheckBox::indicator {' - f'width: {self.newCheckBoxesHeight}px;' - f'height: {self.newCheckBoxesHeight}px' - '}' - ) - for i in range(self.annotOptionsLayout.count()): - widget = self.annotOptionsLayout.itemAt(i).widget() - if isinstance(widget, QCheckBox): - widget.setStyleSheet(checkBoxStyleSheet) - for i in range(self.annotOptionsLayoutRight.count()): - widget = self.annotOptionsLayoutRight.itemAt(i).widget() - if isinstance(widget, QCheckBox): - widget.setStyleSheet(checkBoxStyleSheet) - self.zSliceCheckbox.setStyleSheet(checkBoxStyleSheet) + def saveWindowGeometry(self): + settings = QSettings("schmollerlab", "acdc_gui") + settings.setValue("geometry", self.saveGeometry()) - def resizeEvent(self, event): - if hasattr(self, 'ax1'): - self.ax1.autoRange() + def show(self): + self.setFont(_font) + QMainWindow.show(self) - def gui_createCursors(self): - pixmap = QPixmap(":wand_cursor.svg") - self.wandCursor = QCursor(pixmap, 16, 16) + self.setWindowState(Qt.WindowNoState) + self.setWindowState(Qt.WindowActive) + self.raise_() - pixmap = QPixmap(":curv_cursor.svg") - self.curvCursor = QCursor(pixmap, 16, 16) + self.readSettings() + self.storeDefaultAndCustomColors() - pixmap = QPixmap(":addDelPolyLineRoi_cursor.svg") - self.polyLineRoiCursor = QCursor(pixmap, 16, 16) + self.h = self.navSpinBox.size().height() + fontSizeFactor = None + heightFactor = None + if "bottom_sliders_zoom_perc" in self.df_settings.index: + val = int(self.df_settings.at["bottom_sliders_zoom_perc", "value"]) + if val != 100: + fontSizeFactor = val / 100 + heightFactor = val / 100 - pixmap = QPixmap(":cross_cursor.svg") - self.addPointsCursor = QCursor(pixmap, 16, 16) \ No newline at end of file + self.defaultWidgetHeightBottomLayout = self.h + self.checkBoxesHeight = 14 + self.fontPixelSize = 11 + self.defaultBottomLayoutHeight = self.img1BottomGroupbox.height() + + self.bottomLayout.setStretch(0, 0) + self.bottomLayout.addSpacing(self.quickSettingsGroupbox.width()) + self.resizeSlidersArea(fontSizeFactor=fontSizeFactor, heightFactor=heightFactor) + self.bottomScrollArea.hide() + + self.gui_initImg1BottomWidgets() + self.img1BottomGroupbox.hide() + + self.showPropsDockButton.width() + self.showPropsDockButton.height() + + self.showPropsDockButton.setMaximumWidth(15) + self.showPropsDockButton.setMaximumHeight(120) + + for toolbar in self.controlToolBars: + toolbar.setMinimumHeight(self.secondLevelToolbar.sizeHint().height()) + + self.graphLayout.setFocus() + + def showEvent(self, event): + if self.mainWin is not None: + if not self.mainWin.isMinimized(): + return + self.mainWin.showAllWindows() + # self.setFocus() + self.activateWindow() + + def stopPreprocWorker(self): + self.logger.info("Closing pre-processing worker...") + try: + self.preprocWorker.stop() + except Exception: + pass + + def storeDefaultAndCustomColors(self): + c = self.overlayButton.palette().button().color().name() + self.defaultToolBarButtonColor = c + self.doublePressKeyButtonColor = "#fa693b" + + def super_show(self): + super().show() + + def updateBrushCursorOnShiftRelease(self): + xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) + self.setHoverToolSymbolColor( + xdata, + ydata, + self.ax2_BrushCirclePen, + (self.ax2_BrushCircle, self.ax1_BrushCircle), + self.brushButton, + brush=self.ax2_BrushCircleBrush, + byPassShiftCheck=True, + ) + if self.isSegm3D: + self.changeBrushID() diff --git a/cellacdc/mixins/worker.py b/cellacdc/mixins/worker.py index 3377e6207..641c80693 100644 --- a/cellacdc/mixins/worker.py +++ b/cellacdc/mixins/worker.py @@ -7,127 +7,69 @@ from functools import partial from typing import Tuple -import numpy as np from qtpy.QtCore import QObject, QMutex, QThread, QTimer, QWaitCondition from cellacdc import apps, exception_handler, html_utils, issues_url, widgets, workers -class WorkerView: +class WorkerMixin: """Qt-facing adapter around background worker setup and callbacks.""" - LEGACY_METHODS = ( - 'gui_createLazyLoader', - 'gui_createStoreStateWorker', - 'storeStateWorkerDone', - 'storeStateWorkerClosed', - 'gui_createAutoSaveWorker', - 'autoSaveWorkerStartTimer', - 'autoSaveWorkerTimerCallback', - 'autoSaveWorkerDone', - 'autoSaveWorkerClosed', - 'workerProgress', - 'workerFinished', - 'savePreprocWorkerFinished', - 'loadingNewChunk', - 'lazyLoaderFinished', - 'lazyLoaderCritical', - 'lazyLoaderWorkerClosed', - 'ccaIntegrityWorkerCritical', - 'workerCritical', - 'workerLog', - 'saveDataWorkerCritical', - 'trackingWorkerFinished', - 'workerInitProgressbar', - 'workerUpdateProgressbar', - 'workerInitInnerPbar', - 'workerUpdateInnerPbar', - 'startTrackingWorker', - 'startRelabellingWorker', - 'startPostProcessSegmWorker', - 'relabelWorkerFinished', - 'workerDebug', - ) - - def __init__(self, host): - object.__setattr__(self, 'host', host) - def __getattr__(self, name): - return getattr(self.host, name) - - def __setattr__(self, name, value): - if name in {'host'}: - object.__setattr__(self, name, value) - else: - setattr(self.host, name, value) - - def bind_legacy_methods(self): - for name in self.LEGACY_METHODS: - setattr(self.host, name, getattr(self, name)) - - def gui_createLazyLoader(self): - if not self.lazyLoader is None: - return - - self.lazyLoaderThread = QThread() - self.lazyLoaderMutex = QMutex() - self.lazyLoaderWaitCond = QWaitCondition() - self.waitReadH5cond = QWaitCondition() - self.readH5mutex = QMutex() - self.lazyLoader = workers.LazyLoader( - self.lazyLoaderMutex, self.lazyLoaderWaitCond, - self.waitReadH5cond, self.readH5mutex - ) - self.lazyLoader.moveToThread(self.lazyLoaderThread) - self.lazyLoader.wait = True - - self.lazyLoader.signals.finished.connect(self.lazyLoaderThread.quit) - self.lazyLoader.signals.finished.connect(self.lazyLoader.deleteLater) - self.lazyLoaderThread.finished.connect(self.lazyLoaderThread.deleteLater) - - self.lazyLoader.signals.progress.connect(self.workerProgress) - self.lazyLoader.signals.sigLoadingNewChunk.connect(self.loadingNewChunk) - self.lazyLoader.sigLoadingFinished.connect(self.lazyLoaderFinished) - self.lazyLoader.signals.critical.connect(self.lazyLoaderCritical) - self.lazyLoader.signals.finished.connect(self.lazyLoaderWorkerClosed) + """Headless worker progress and lifecycle decisions.""" - self.lazyLoaderThread.started.connect(self.lazyLoader.run) - self.lazyLoaderThread.start() + def autoSaveWorkerClosed(self, worker): + if self.autoSaveActiveWorkers: + self.logger.info("Autosaving worker closed.") + try: + self.autoSaveActiveWorkers.remove(worker) + except Exception: + pass - def gui_createStoreStateWorker(self): - self.storeStateWorker = None - return - self.storeStateThread = QThread() - self.autoSaveMutex = QMutex() - self.autoSaveWaitCond = QWaitCondition() + def autoSaveWorkerDone(self): + self.setStatusBarLabel(log=False) - self.storeStateWorker = workers.StoreGuiStateWorker( - self.autoSaveMutex, self.autoSaveWaitCond + def autoSaveWorkerStartTimer(self, worker, posData): + self.autoSaveWorkerTimer = QTimer() + self.autoSaveWorkerTimer.timeout.connect( + partial(self.autoSaveWorkerTimerCallback, worker, posData) ) + self.autoSaveWorkerTimer.start(150) - self.storeStateWorker.moveToThread(self.storeStateThread) - self.storeStateWorker.finished.connect(self.storeStateThread.quit) - self.storeStateWorker.finished.connect(self.storeStateWorker.deleteLater) - self.storeStateThread.finished.connect(self.storeStateThread.deleteLater) - - self.storeStateWorker.sigDone.connect(self.storeStateWorkerDone) - self.storeStateWorker.progress.connect(self.workerProgress) - self.storeStateWorker.finished.connect(self.storeStateWorkerClosed) - - self.storeStateThread.started.connect(self.storeStateWorker.run) - self.storeStateThread.start() - - self.logger.info('Store state worker started.') + def autoSaveWorkerTimerCallback(self, worker, posData): + if not self.isSaving: + self.autoSaveWorkerTimer.stop() + worker._enqueue(posData) - def storeStateWorkerDone(self): - if self.storeStateWorker.callbackOnDone is not None: - self.storeStateWorker.callbackOnDone() - self.storeStateWorker.callbackOnDone = None + def ccaIntegrityWorkerCritical(self, error): + try: + raise error + except Exception: + self.logger.exception(traceback.format_exc()) - def storeStateWorkerClosed(self): - self.logger.info('Store state worker started.') + href = f'GitHub page' + txt = html_utils.paragraph(f""" + Unfortunately the experimental feature + check cell cycle annotations integrity raised a + critical error.

+ Cell-ACDC will now disable this feature to allow you to keep + using the software.

+ However, we kindly ask you to report the issue on our + {href}, thank you very much!

+ Please, include the log file when reporting the issue.

+ Log file location: + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning( + self, + "Experimental feature error", + txt, + commands=(self.log_path,), + path_to_browse=self.logs_path, + ) + self.disableCcaIntegrityChecker() def gui_createAutoSaveWorker(self): - if not hasattr(self, 'data'): + if not hasattr(self, "data"): return if not self.isDataLoaded: @@ -158,186 +100,228 @@ def gui_createAutoSaveWorker(self): autoSaveWorker.sigDone.connect(self.autoSaveWorkerDone) autoSaveWorker.progress.connect(self.workerProgress) autoSaveWorker.finished.connect(self.autoSaveWorkerClosed) - autoSaveWorker.sigAutoSaveCannotProceed.connect( - self.turnOffAutoSaveWorker - ) + autoSaveWorker.sigAutoSaveCannotProceed.connect(self.turnOffAutoSaveWorker) autoSaveThread.started.connect(autoSaveWorker.run) autoSaveThread.start() self.autoSaveActiveWorkers.append((autoSaveWorker, autoSaveThread)) - self.logger.info('Autosaving worker started.') + self.logger.info("Autosaving worker started.") - def autoSaveWorkerStartTimer(self, worker, posData): - self.autoSaveWorkerTimer = QTimer() - self.autoSaveWorkerTimer.timeout.connect( - partial(self.autoSaveWorkerTimerCallback, worker, posData) + def gui_createLazyLoader(self): + if self.lazyLoader is not None: + return + + self.lazyLoaderThread = QThread() + self.lazyLoaderMutex = QMutex() + self.lazyLoaderWaitCond = QWaitCondition() + self.waitReadH5cond = QWaitCondition() + self.readH5mutex = QMutex() + self.lazyLoader = workers.LazyLoader( + self.lazyLoaderMutex, + self.lazyLoaderWaitCond, + self.waitReadH5cond, + self.readH5mutex, ) - self.autoSaveWorkerTimer.start(150) + self.lazyLoader.moveToThread(self.lazyLoaderThread) + self.lazyLoader.wait = True - def autoSaveWorkerTimerCallback(self, worker, posData): - if self.should_enqueue_autosave(self.isSaving): - self.autoSaveWorkerTimer.stop() - worker._enqueue(posData) + self.lazyLoader.signals.finished.connect(self.lazyLoaderThread.quit) + self.lazyLoader.signals.finished.connect(self.lazyLoader.deleteLater) + self.lazyLoaderThread.finished.connect(self.lazyLoaderThread.deleteLater) - def autoSaveWorkerDone(self): - self.status_hover_view.set_status_bar_label(log=False) + self.lazyLoader.signals.progress.connect(self.workerProgress) + self.lazyLoader.signals.sigLoadingNewChunk.connect(self.loadingNewChunk) + self.lazyLoader.sigLoadingFinished.connect(self.lazyLoaderFinished) + self.lazyLoader.signals.critical.connect(self.lazyLoaderCritical) + self.lazyLoader.signals.finished.connect(self.lazyLoaderWorkerClosed) - def autoSaveWorkerClosed(self, worker): - if self.autoSaveActiveWorkers: - self.logger.info('Autosaving worker closed.') - try: - self.autoSaveActiveWorkers.remove(worker) - except Exception as e: - pass + self.lazyLoaderThread.started.connect(self.lazyLoader.run) + self.lazyLoaderThread.start() - def workerProgress(self, text, loggerLevel='INFO'): # used in cca and lin tree - if self.progressWin is not None: - self.progressWin.logConsole.append(text) - loggerLevel = self.progress_log_level(loggerLevel) - self.logger.log(getattr(logging, loggerLevel), text) + def gui_createStoreStateWorker(self): + self.storeStateWorker = None + return + self.storeStateThread = QThread() + self.autoSaveMutex = QMutex() + self.autoSaveWaitCond = QWaitCondition() - def workerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.logger.info('Worker process ended.') - self.updateAllImages() - self.titleLabel.setText('Done', color='w') + self.storeStateWorker = workers.StoreGuiStateWorker( + self.autoSaveMutex, self.autoSaveWaitCond + ) - def savePreprocWorkerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None + self.storeStateWorker.moveToThread(self.storeStateThread) + self.storeStateWorker.finished.connect(self.storeStateThread.quit) + self.storeStateWorker.finished.connect(self.storeStateWorker.deleteLater) + self.storeStateThread.finished.connect(self.storeStateThread.deleteLater) - self.status_hover_view.set_status_bar_label() - self.logger.info('Pre-processed data saved!') - self.titleLabel.setText('Pre-processed data saved!', color='w') + self.storeStateWorker.sigDone.connect(self.storeStateWorkerDone) + self.storeStateWorker.progress.connect(self.workerProgress) + self.storeStateWorker.finished.connect(self.storeStateWorkerClosed) - def loadingNewChunk(self, chunk_range): - desc = self.lazy_loader_progress_description(chunk_range) - self.progressWin = apps.QDialogWorkerProgress( - title='Loading data...', parent=self.host, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.show(self.app) + self.storeStateThread.started.connect(self.storeStateWorker.run) + self.storeStateThread.start() - def lazyLoaderFinished(self): - self.logger.info('Load chunk data worker done.') - if self.lazyLoader.updateImgOnFinished: - self.updateAllImages() + self.logger.info("Store state worker started.") + @exception_handler + def lazyLoaderCritical(self, error): if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None + self.lazyLoader.pause() + raise error + + def lazyLoaderFinished(self): + self.logger.info("Load chunk data worker done.") + if self.lazyLoader.updateImgOnFinished: + self.updateAllImages() - @exception_handler - def lazyLoaderCritical(self, error): if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - self.lazyLoader.pause() - raise error def lazyLoaderWorkerClosed(self): if self.lazyLoader.salute: - self.logger.info('Cell-ACDC GUI closed.') - self.sigClosed.emit(self.host) + self.logger.info("Cell-ACDC GUI closed.") + self.sigClosed.emit(self) self.lazyLoader = None - def ccaIntegrityWorkerCritical(self, error): - try: - raise error - except Exception as err: - self.logger.exception(traceback.format_exc()) - - href = f'GitHub page' - txt = html_utils.paragraph(f""" + def lazy_loader_progress_description(self, chunk_range) -> str: + coord0_chunk, coord1_chunk = chunk_range + return f"Loading new window, range = ({coord0_chunk}, {coord1_chunk})..." - """Headless worker progress and lifecycle decisions.""" + def loadingNewChunk(self, chunk_range): + coord0_chunk, coord1_chunk = chunk_range + desc = f"Loading new window, range = ({coord0_chunk}, {coord1_chunk})..." + self.progressWin = apps.QDialogWorkerProgress( + title="Loading data...", parent=self, pbarDesc=desc + ) + self.progressWin.mainPbar.setMaximum(0) + self.progressWin.show(self.app) - def progress_log_level(self, logger_level: str = 'INFO') -> str: - return logger_level or 'INFO' + def progress_log_level(self, logger_level: str = "INFO") -> str: + return logger_level or "INFO" def progressbar_maximum(self, total_iterations: int) -> int: if total_iterations == 1: return 0 return total_iterations - def lazy_loader_progress_description(self, chunk_range) -> str: - coord0_chunk, coord1_chunk = chunk_range - return ( - f'Loading new window, range = ({coord0_chunk}, {coord1_chunk})...' - ) + def relabelWorkerFinished(self): + self.updateAllImages() - def should_enqueue_autosave(self, is_saving: bool) -> bool: - return not is_saving + def saveDataWorkerCritical(self, error): + self.logger.warning("Saving process stopped because of critical error.") + self.saveWin.aborted = True + self.worker.finished.emit() + self.workerCritical(error) + + def savePreprocWorkerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + self.setStatusBarLabel() + self.logger.info("Pre-processed data saved!") + self.titleLabel.setText("Pre-processed data saved!", color="w") def should_disable_realtime_tracking( self, tracking_on_never_visited_frames: bool, realtime_tracking_enabled: bool, ) -> bool: - return ( - tracking_on_never_visited_frames - and realtime_tracking_enabled + return tracking_on_never_visited_frames and realtime_tracking_enabled + + def should_enqueue_autosave(self, is_saving: bool) -> bool: + return not is_saving + + def startPostProcessSegmWorker( + self, + postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures, + ): + self.thread = QThread() + self.postProcessWorker = workers.PostProcessSegmWorker( + postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures, + self, ) - Unfortunately the experimental feature - check cell cycle annotations integrity raised a - critical error.

- Cell-ACDC will now disable this feature to allow you to keep - using the software.

- However, we kindly ask you to report the issue on our - {href}, thank you very much!

- Please, include the log file when reporting the issue.

- Log file location: - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning( - self.host, 'Experimental feature error', txt, - commands=(self.log_path,), - path_to_browse=self.logs_path + self.postProcessWorker.moveToThread(self.thread) + self.postProcessWorker.signals.finished.connect(self.thread.quit) + self.postProcessWorker.signals.finished.connect( + self.postProcessWorker.deleteLater ) - self.disableCcaIntegrityChecker() + self.thread.finished.connect(self.thread.deleteLater) - @exception_handler - def workerCritical(self, out: Tuple[QObject, Exception]): - self.setDisabled(False) - try: - worker, error = out - except TypeError as err: - error = out - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.logger.info(error) - try: - worker.thread().quit() - worker.deleteLater() - worker.thread().deleteLater() - except Exception as err: - # Worker already closed - pass - raise error + self.postProcessWorker.signals.finished.connect( + self.postProcessSegmWorkerFinished + ) + self.postProcessWorker.signals.progress.connect(self.workerProgress) + self.postProcessWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.postProcessWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.postProcessWorker.signals.critical.connect(self.workerCritical) - def workerLog(self, text): - self.logger.info(text) + self.thread.started.connect(self.postProcessWorker.run) + self.thread.start() - def saveDataWorkerCritical(self, error): - self.logger.warning( - 'Saving process stopped because of critical error.' - ) - self.saveWin.aborted = True - self.worker.finished.emit() - self.workerCritical(error) + def startRelabellingWorker(self, posFoldernames): + self.thread = QThread() + self.worker = workers.relabelSequentialWorker(self, posFoldernames) + self.worker.moveToThread(self.thread) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + self.worker.progress.connect(self.workerProgress) + self.worker.critical.connect(self.workerCritical) + self.worker.finished.connect(self.workerFinished) + self.worker.finished.connect(self.relabelWorkerFinished) + + self.worker.debug.connect(self.workerDebug) + + self.thread.started.connect(self.worker.run) + self.thread.start() + + def startTrackingWorker(self, posData, video_to_track): + self.thread = QThread() + self.trackingWorker = workers.trackingWorker(posData, self, video_to_track) + self.trackingWorker.moveToThread(self.thread) + self.trackingWorker.finished.connect(self.thread.quit) + self.trackingWorker.finished.connect(self.trackingWorker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + # Custom signals + self.trackingWorker.signals.progress = self.trackingWorker.progress + self.trackingWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.trackingWorker.signals.initProgressBar.connect(self.workerInitProgressbar) + self.trackingWorker.signals.sigInitInnerPbar.connect(self.workerInitInnerPbar) + self.trackingWorker.progress.connect(self.workerProgress) + self.trackingWorker.critical.connect(self.workerCritical) + self.trackingWorker.finished.connect(self.trackingWorkerFinished) + + self.trackingWorker.debug.connect(self.workerDebug) + + self.thread.started.connect(self.trackingWorker.run) + self.thread.start() + + def storeStateWorkerClosed(self): + self.logger.info("Store state worker started.") + + def storeStateWorkerDone(self): + if self.storeStateWorker.callbackOnDone is not None: + self.storeStateWorker.callbackOnDone() + self.storeStateWorker.callbackOnDone = None @exception_handler def trackingWorkerFinished(self): @@ -345,36 +329,36 @@ def trackingWorkerFinished(self): self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - self.logger.info('Worker process ended.') + self.logger.info("Worker process ended.") askDisableRealTimeTracking = ( - self.should_disable_realtime_tracking( - self.trackingWorker.trackingOnNeverVisitedFrames, - self.realTimeTrackingToggle.isChecked(), - ) + self.trackingWorker.trackingOnNeverVisitedFrames + and self.realTimeTrackingToggle.isChecked() ) if askDisableRealTimeTracking: msg = widgets.myMessageBox() - title = 'Disable real-time tracking?' + title = "Disable real-time tracking?" txt = ( - 'You perfomed tracking on frames that you have ' - 'never visited.

' - 'Cell-ACDC default behaviour is to track them again when you ' - 'will visit them.

' - 'However, you can overwrite this behaviour and explicitly ' - 'disable tracking for all of the frames you already tracked.

' - 'NOTE: you can reactivate real-time tracking by clicking on the ' + "You perfomed tracking on frames that you have " + "never visited.

" + "Cell-ACDC default behaviour is to track them again when you " + "will visit them.

" + "However, you can overwrite this behaviour and explicitly " + "disable tracking for all of the frames you already tracked.

" + "NOTE: you can reactivate real-time tracking by clicking on the " '"Reset last segmented frame" button on the top toolbar.

' - 'What do you want me to do?' + "What do you want me to do?" ) _, disableTrackingButton = msg.information( - self.host, title, html_utils.paragraph(txt), + self, + title, + html_utils.paragraph(txt), buttonsTexts=( - 'Keep real-time tracking active (recommended)', - 'Disable real-time tracking' - ) + "Keep real-time tracking active (recommended)", + "Disable real-time tracking", + ), ) if msg.clickedButton == disableTrackingButton: - self.logger.info('Disabling real time tracking...') + self.logger.info("Disabling real time tracking...") self.realTimeTrackingToggle.setChecked(False) # posData = self.data[self.pos_i] # current_frame_i = posData.frame_i @@ -388,115 +372,69 @@ def trackingWorkerFinished(self): # # Back to current frame # posData.frame_i = current_frame_i # self.get_data() - posData = self.data[self.pos_i] + self.data[self.pos_i] self.updateAllImages() - self.titleLabel.setText('Done', color='w') - - def workerInitProgressbar(self, totalIter): - self.progressWin.mainPbar.setValue(0) - maximum = self.progressbar_maximum(totalIter) - self.progressWin.mainPbar.setMaximum(maximum) - - def workerUpdateProgressbar(self, step): - self.progressWin.mainPbar.update(step) - - def workerInitInnerPbar(self, totalIter): - self.progressWin.innerPbar.setValue(0) - maximum = self.progressbar_maximum(totalIter) - self.progressWin.innerPbar.setMaximum(maximum) - - def workerUpdateInnerPbar(self, step): - self.progressWin.innerPbar.update(step) + self.titleLabel.setText("Done", color="w") - def startTrackingWorker(self, posData, video_to_track): - self.thread = QThread() - self.trackingWorker = workers.trackingWorker( - posData, self.host, video_to_track - ) - self.trackingWorker.moveToThread(self.thread) - self.trackingWorker.finished.connect(self.thread.quit) - self.trackingWorker.finished.connect(self.trackingWorker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - # Custom signals - self.trackingWorker.signals.progress = self.trackingWorker.progress - self.trackingWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.trackingWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.trackingWorker.signals.sigInitInnerPbar.connect( - self.workerInitInnerPbar - ) - self.trackingWorker.progress.connect(self.workerProgress) - self.trackingWorker.critical.connect(self.workerCritical) - self.trackingWorker.finished.connect(self.trackingWorkerFinished) - - self.trackingWorker.debug.connect(self.workerDebug) - - self.thread.started.connect(self.trackingWorker.run) - self.thread.start() - - def startRelabellingWorker(self, posFoldernames): - self.thread = QThread() - self.worker = workers.relabelSequentialWorker( - self.host, posFoldernames - ) - self.worker.moveToThread(self.thread) - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) + @exception_handler + def workerCritical(self, out: Tuple[QObject, Exception]): + self.setDisabled(False) + try: + worker, error = out + except TypeError: + error = out + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.logger.info(error) + try: + worker.thread().quit() + worker.deleteLater() + worker.thread().deleteLater() + except Exception: + # Worker already closed + pass + raise error - self.worker.progress.connect(self.workerProgress) - self.worker.critical.connect(self.workerCritical) - self.worker.finished.connect(self.workerFinished) - self.worker.finished.connect(self.relabelWorkerFinished) + def workerDebug(self, item): + tracked_video, worker = item + from cellacdc.plot import imshow - self.worker.debug.connect(self.workerDebug) + imshow(tracked_video) + worker.waitCond.wakeAll() - self.thread.started.connect(self.worker.run) - self.thread.start() + def workerFinished(self): + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.logger.info("Worker process ended.") + self.updateAllImages() + self.titleLabel.setText("Done", color="w") - def startPostProcessSegmWorker( - self, postProcessKwargs, customPostProcessGroupedFeatures, - customPostProcessFeatures - ): - self.thread = QThread() - self.postProcessWorker = workers.PostProcessSegmWorker( - postProcessKwargs, customPostProcessGroupedFeatures, - customPostProcessFeatures, self.host - ) + def workerInitInnerPbar(self, totalIter): + self.progressWin.innerPbar.setValue(0) + if totalIter == 1: + totalIter = 0 + self.progressWin.innerPbar.setMaximum(totalIter) - self.postProcessWorker.moveToThread(self.thread) - self.postProcessWorker.signals.finished.connect(self.thread.quit) - self.postProcessWorker.signals.finished.connect( - self.postProcessWorker.deleteLater - ) - self.thread.finished.connect(self.thread.deleteLater) + def workerInitProgressbar(self, totalIter): + self.progressWin.mainPbar.setValue(0) + if totalIter == 1: + totalIter = 0 + self.progressWin.mainPbar.setMaximum(totalIter) - self.postProcessWorker.signals.finished.connect( - self.postProcessSegmWorkerFinished - ) - self.postProcessWorker.signals.progress.connect(self.workerProgress) - self.postProcessWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.postProcessWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.postProcessWorker.signals.critical.connect( - self.workerCritical - ) + def workerLog(self, text): + self.logger.info(text) - self.thread.started.connect(self.postProcessWorker.run) - self.thread.start() + def workerProgress(self, text, loggerLevel="INFO"): # used in cca and lin tree + if self.progressWin is not None: + self.progressWin.logConsole.append(text) + self.logger.log(getattr(logging, loggerLevel), text) - def relabelWorkerFinished(self): - self.updateAllImages() + def workerUpdateInnerPbar(self, step): + self.progressWin.innerPbar.update(step) - def workerDebug(self, item): - tracked_video, worker = item - from cellacdc.plot import imshow - imshow(tracked_video) - worker.waitCond.wakeAll() \ No newline at end of file + def workerUpdateProgressbar(self, step): + self.progressWin.mainPbar.update(step) diff --git a/cellacdc/mixins/workspace.py b/cellacdc/mixins/workspace.py index fd1ec2f65..aff394ba0 100644 --- a/cellacdc/mixins/workspace.py +++ b/cellacdc/mixins/workspace.py @@ -12,40 +12,40 @@ ) -class WorkspaceViewModel: +class WorkspaceMixin: """Application-facing commands for filesystem workspace discovery.""" - def position_folder_names( - self, - exp_path, - *, - check_if_is_sub_folder=False, - ): - return get_pos_foldernames( - str(exp_path), - check_if_is_sub_folder=check_if_is_sub_folder, - ) - - def listdir(self, path): - return listdir(str(path)) - def add_recent_path(self, path, *, logger=None): return addToRecentPaths(str(path), logger=logger) - def most_recent_path(self): - return getMostRecentPath() - def determine_folder_type(self, folder_path): is_pos_folder, is_images_folder, folder_path = determine_folder_type( str(folder_path) ) return is_pos_folder, bool(is_images_folder), folder_path - def segmentation_files(self, images_path): - return load.get_segm_files(str(images_path)) - def endnames(self, basename, files): return load.get_endnames(basename, files) + def listdir(self, path): + return listdir(str(path)) + + def most_recent_path(self): + return getMostRecentPath() + def path_from_endname(self, end_name, images_path, *, ext=None): return load.get_path_from_endname(end_name, str(images_path), ext=ext) + + def position_folder_names( + self, + exp_path, + *, + check_if_is_sub_folder=False, + ): + return get_pos_foldernames( + str(exp_path), + check_if_is_sub_folder=check_if_is_sub_folder, + ) + + def segmentation_files(self, images_path): + return load.get_segm_files(str(images_path)) From 58534beaf407813227a1feabd01d39c93949b94f Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 10:50:42 +0200 Subject: [PATCH 08/21] mixins --- cellacdc/{mixins => mixins_bak}/__init__.py | 0 cellacdc/{mixins => mixins_bak}/actions.py | 0 cellacdc/{mixins => mixins_bak}/annotation_display.py | 0 cellacdc/{mixins => mixins_bak}/app_shell.py | 0 cellacdc/{mixins => mixins_bak}/brush_tools.py | 0 cellacdc/{mixins => mixins_bak}/canvas_context_menu.py | 0 cellacdc/{mixins => mixins_bak}/canvas_drawing.py | 0 cellacdc/{mixins => mixins_bak}/canvas_events.py | 0 cellacdc/{mixins => mixins_bak}/canvas_hover.py | 0 cellacdc/{mixins => mixins_bak}/canvas_right_image.py | 0 cellacdc/{mixins => mixins_bak}/canvas_selection.py | 0 cellacdc/{mixins => mixins_bak}/canvas_tool.py | 0 cellacdc/{mixins => mixins_bak}/cca_edits.py | 0 cellacdc/{mixins => mixins_bak}/cca_workflows.py | 0 cellacdc/{mixins => mixins_bak}/cell_cycle.py | 0 cellacdc/{mixins => mixins_bak}/combine.py | 0 cellacdc/{mixins => mixins_bak}/curvature_tools.py | 0 cellacdc/{mixins => mixins_bak}/custom_annotations.py | 0 cellacdc/{mixins => mixins_bak}/data_loading.py | 0 cellacdc/{mixins => mixins_bak}/deleted_rois.py | 0 cellacdc/{mixins => mixins_bak}/display_decorations.py | 0 cellacdc/{mixins => mixins_bak}/draw_clear_region.py | 0 cellacdc/{mixins => mixins_bak}/edit_id.py | 0 cellacdc/{mixins => mixins_bak}/exporting.py | 0 cellacdc/{mixins => mixins_bak}/formatting.py | 0 cellacdc/{mixins => mixins_bak}/frame_metadata.py | 0 cellacdc/{mixins => mixins_bak}/frame_navigation.py | 0 cellacdc/{mixins => mixins_bak}/geometry.py | 0 cellacdc/{mixins => mixins_bak}/graphics.py | 0 cellacdc/{mixins => mixins_bak}/image_controls.py | 0 cellacdc/{mixins => mixins_bak}/image_display.py | 0 cellacdc/{mixins => mixins_bak}/label_editing.py | 0 cellacdc/{mixins => mixins_bak}/label_edits.py | 0 cellacdc/{mixins => mixins_bak}/label_roi.py | 0 cellacdc/{mixins => mixins_bak}/label_transform_tools.py | 0 cellacdc/{mixins => mixins_bak}/layout_controls.py | 0 cellacdc/{mixins => mixins_bak}/lineage.py | 0 cellacdc/{mixins => mixins_bak}/lineage_interactions.py | 0 cellacdc/{mixins => mixins_bak}/magic_prompts.py | 0 cellacdc/{mixins => mixins_bak}/main.py | 0 cellacdc/{mixins => mixins_bak}/main_menu.py | 0 cellacdc/{mixins => mixins_bak}/main_toolbar.py | 0 cellacdc/{mixins => mixins_bak}/measurements.py | 0 cellacdc/{mixins => mixins_bak}/mode_controls.py | 0 cellacdc/{mixins => mixins_bak}/model_registry.py | 0 cellacdc/{mixins => mixins_bak}/object_cleanup.py | 0 cellacdc/{mixins => mixins_bak}/object_counts.py | 0 cellacdc/{mixins => mixins_bak}/object_properties.py | 0 cellacdc/{mixins => mixins_bak}/object_search.py | 0 cellacdc/{mixins => mixins_bak}/points.py | 0 cellacdc/{mixins => mixins_bak}/points_layers.py | 0 cellacdc/{mixins => mixins_bak}/preprocessing.py | 0 cellacdc/{mixins => mixins_bak}/quick_settings.py | 0 cellacdc/{mixins => mixins_bak}/saving.py | 0 cellacdc/{mixins => mixins_bak}/seg_for_lost_ids.py | 0 cellacdc/{mixins => mixins_bak}/segmentation.py | 0 cellacdc/{mixins => mixins_bak}/session.py | 0 cellacdc/{mixins => mixins_bak}/status_hover.py | 0 cellacdc/{mixins => mixins_bak}/tables.py | 0 cellacdc/{mixins => mixins_bak}/tool_activation.py | 0 cellacdc/{mixins => mixins_bak}/tracking.py | 0 cellacdc/{mixins => mixins_bak}/undo_redo.py | 0 cellacdc/{mixins => mixins_bak}/whitelist.py | 0 cellacdc/{mixins => mixins_bak}/window_events.py | 0 cellacdc/{mixins => mixins_bak}/worker.py | 0 cellacdc/{mixins => mixins_bak}/workspace.py | 0 66 files changed, 0 insertions(+), 0 deletions(-) rename cellacdc/{mixins => mixins_bak}/__init__.py (100%) rename cellacdc/{mixins => mixins_bak}/actions.py (100%) rename cellacdc/{mixins => mixins_bak}/annotation_display.py (100%) rename cellacdc/{mixins => mixins_bak}/app_shell.py (100%) rename cellacdc/{mixins => mixins_bak}/brush_tools.py (100%) rename cellacdc/{mixins => mixins_bak}/canvas_context_menu.py (100%) rename cellacdc/{mixins => mixins_bak}/canvas_drawing.py (100%) rename cellacdc/{mixins => mixins_bak}/canvas_events.py (100%) rename cellacdc/{mixins => mixins_bak}/canvas_hover.py (100%) rename cellacdc/{mixins => mixins_bak}/canvas_right_image.py (100%) rename cellacdc/{mixins => mixins_bak}/canvas_selection.py (100%) rename cellacdc/{mixins => mixins_bak}/canvas_tool.py (100%) rename cellacdc/{mixins => mixins_bak}/cca_edits.py (100%) rename cellacdc/{mixins => mixins_bak}/cca_workflows.py (100%) rename cellacdc/{mixins => mixins_bak}/cell_cycle.py (100%) rename cellacdc/{mixins => mixins_bak}/combine.py (100%) rename cellacdc/{mixins => mixins_bak}/curvature_tools.py (100%) rename cellacdc/{mixins => mixins_bak}/custom_annotations.py (100%) rename cellacdc/{mixins => mixins_bak}/data_loading.py (100%) rename cellacdc/{mixins => mixins_bak}/deleted_rois.py (100%) rename cellacdc/{mixins => mixins_bak}/display_decorations.py (100%) rename cellacdc/{mixins => mixins_bak}/draw_clear_region.py (100%) rename cellacdc/{mixins => mixins_bak}/edit_id.py (100%) rename cellacdc/{mixins => mixins_bak}/exporting.py (100%) rename cellacdc/{mixins => mixins_bak}/formatting.py (100%) rename cellacdc/{mixins => mixins_bak}/frame_metadata.py (100%) rename cellacdc/{mixins => mixins_bak}/frame_navigation.py (100%) rename cellacdc/{mixins => mixins_bak}/geometry.py (100%) rename cellacdc/{mixins => mixins_bak}/graphics.py (100%) rename cellacdc/{mixins => mixins_bak}/image_controls.py (100%) rename cellacdc/{mixins => mixins_bak}/image_display.py (100%) rename cellacdc/{mixins => mixins_bak}/label_editing.py (100%) rename cellacdc/{mixins => mixins_bak}/label_edits.py (100%) rename cellacdc/{mixins => mixins_bak}/label_roi.py (100%) rename cellacdc/{mixins => mixins_bak}/label_transform_tools.py (100%) rename cellacdc/{mixins => mixins_bak}/layout_controls.py (100%) rename cellacdc/{mixins => mixins_bak}/lineage.py (100%) rename cellacdc/{mixins => mixins_bak}/lineage_interactions.py (100%) rename cellacdc/{mixins => mixins_bak}/magic_prompts.py (100%) rename cellacdc/{mixins => mixins_bak}/main.py (100%) rename cellacdc/{mixins => mixins_bak}/main_menu.py (100%) rename cellacdc/{mixins => mixins_bak}/main_toolbar.py (100%) rename cellacdc/{mixins => mixins_bak}/measurements.py (100%) rename cellacdc/{mixins => mixins_bak}/mode_controls.py (100%) rename cellacdc/{mixins => mixins_bak}/model_registry.py (100%) rename cellacdc/{mixins => mixins_bak}/object_cleanup.py (100%) rename cellacdc/{mixins => mixins_bak}/object_counts.py (100%) rename cellacdc/{mixins => mixins_bak}/object_properties.py (100%) rename cellacdc/{mixins => mixins_bak}/object_search.py (100%) rename cellacdc/{mixins => mixins_bak}/points.py (100%) rename cellacdc/{mixins => mixins_bak}/points_layers.py (100%) rename cellacdc/{mixins => mixins_bak}/preprocessing.py (100%) rename cellacdc/{mixins => mixins_bak}/quick_settings.py (100%) rename cellacdc/{mixins => mixins_bak}/saving.py (100%) rename cellacdc/{mixins => mixins_bak}/seg_for_lost_ids.py (100%) rename cellacdc/{mixins => mixins_bak}/segmentation.py (100%) rename cellacdc/{mixins => mixins_bak}/session.py (100%) rename cellacdc/{mixins => mixins_bak}/status_hover.py (100%) rename cellacdc/{mixins => mixins_bak}/tables.py (100%) rename cellacdc/{mixins => mixins_bak}/tool_activation.py (100%) rename cellacdc/{mixins => mixins_bak}/tracking.py (100%) rename cellacdc/{mixins => mixins_bak}/undo_redo.py (100%) rename cellacdc/{mixins => mixins_bak}/whitelist.py (100%) rename cellacdc/{mixins => mixins_bak}/window_events.py (100%) rename cellacdc/{mixins => mixins_bak}/worker.py (100%) rename cellacdc/{mixins => mixins_bak}/workspace.py (100%) diff --git a/cellacdc/mixins/__init__.py b/cellacdc/mixins_bak/__init__.py similarity index 100% rename from cellacdc/mixins/__init__.py rename to cellacdc/mixins_bak/__init__.py diff --git a/cellacdc/mixins/actions.py b/cellacdc/mixins_bak/actions.py similarity index 100% rename from cellacdc/mixins/actions.py rename to cellacdc/mixins_bak/actions.py diff --git a/cellacdc/mixins/annotation_display.py b/cellacdc/mixins_bak/annotation_display.py similarity index 100% rename from cellacdc/mixins/annotation_display.py rename to cellacdc/mixins_bak/annotation_display.py diff --git a/cellacdc/mixins/app_shell.py b/cellacdc/mixins_bak/app_shell.py similarity index 100% rename from cellacdc/mixins/app_shell.py rename to cellacdc/mixins_bak/app_shell.py diff --git a/cellacdc/mixins/brush_tools.py b/cellacdc/mixins_bak/brush_tools.py similarity index 100% rename from cellacdc/mixins/brush_tools.py rename to cellacdc/mixins_bak/brush_tools.py diff --git a/cellacdc/mixins/canvas_context_menu.py b/cellacdc/mixins_bak/canvas_context_menu.py similarity index 100% rename from cellacdc/mixins/canvas_context_menu.py rename to cellacdc/mixins_bak/canvas_context_menu.py diff --git a/cellacdc/mixins/canvas_drawing.py b/cellacdc/mixins_bak/canvas_drawing.py similarity index 100% rename from cellacdc/mixins/canvas_drawing.py rename to cellacdc/mixins_bak/canvas_drawing.py diff --git a/cellacdc/mixins/canvas_events.py b/cellacdc/mixins_bak/canvas_events.py similarity index 100% rename from cellacdc/mixins/canvas_events.py rename to cellacdc/mixins_bak/canvas_events.py diff --git a/cellacdc/mixins/canvas_hover.py b/cellacdc/mixins_bak/canvas_hover.py similarity index 100% rename from cellacdc/mixins/canvas_hover.py rename to cellacdc/mixins_bak/canvas_hover.py diff --git a/cellacdc/mixins/canvas_right_image.py b/cellacdc/mixins_bak/canvas_right_image.py similarity index 100% rename from cellacdc/mixins/canvas_right_image.py rename to cellacdc/mixins_bak/canvas_right_image.py diff --git a/cellacdc/mixins/canvas_selection.py b/cellacdc/mixins_bak/canvas_selection.py similarity index 100% rename from cellacdc/mixins/canvas_selection.py rename to cellacdc/mixins_bak/canvas_selection.py diff --git a/cellacdc/mixins/canvas_tool.py b/cellacdc/mixins_bak/canvas_tool.py similarity index 100% rename from cellacdc/mixins/canvas_tool.py rename to cellacdc/mixins_bak/canvas_tool.py diff --git a/cellacdc/mixins/cca_edits.py b/cellacdc/mixins_bak/cca_edits.py similarity index 100% rename from cellacdc/mixins/cca_edits.py rename to cellacdc/mixins_bak/cca_edits.py diff --git a/cellacdc/mixins/cca_workflows.py b/cellacdc/mixins_bak/cca_workflows.py similarity index 100% rename from cellacdc/mixins/cca_workflows.py rename to cellacdc/mixins_bak/cca_workflows.py diff --git a/cellacdc/mixins/cell_cycle.py b/cellacdc/mixins_bak/cell_cycle.py similarity index 100% rename from cellacdc/mixins/cell_cycle.py rename to cellacdc/mixins_bak/cell_cycle.py diff --git a/cellacdc/mixins/combine.py b/cellacdc/mixins_bak/combine.py similarity index 100% rename from cellacdc/mixins/combine.py rename to cellacdc/mixins_bak/combine.py diff --git a/cellacdc/mixins/curvature_tools.py b/cellacdc/mixins_bak/curvature_tools.py similarity index 100% rename from cellacdc/mixins/curvature_tools.py rename to cellacdc/mixins_bak/curvature_tools.py diff --git a/cellacdc/mixins/custom_annotations.py b/cellacdc/mixins_bak/custom_annotations.py similarity index 100% rename from cellacdc/mixins/custom_annotations.py rename to cellacdc/mixins_bak/custom_annotations.py diff --git a/cellacdc/mixins/data_loading.py b/cellacdc/mixins_bak/data_loading.py similarity index 100% rename from cellacdc/mixins/data_loading.py rename to cellacdc/mixins_bak/data_loading.py diff --git a/cellacdc/mixins/deleted_rois.py b/cellacdc/mixins_bak/deleted_rois.py similarity index 100% rename from cellacdc/mixins/deleted_rois.py rename to cellacdc/mixins_bak/deleted_rois.py diff --git a/cellacdc/mixins/display_decorations.py b/cellacdc/mixins_bak/display_decorations.py similarity index 100% rename from cellacdc/mixins/display_decorations.py rename to cellacdc/mixins_bak/display_decorations.py diff --git a/cellacdc/mixins/draw_clear_region.py b/cellacdc/mixins_bak/draw_clear_region.py similarity index 100% rename from cellacdc/mixins/draw_clear_region.py rename to cellacdc/mixins_bak/draw_clear_region.py diff --git a/cellacdc/mixins/edit_id.py b/cellacdc/mixins_bak/edit_id.py similarity index 100% rename from cellacdc/mixins/edit_id.py rename to cellacdc/mixins_bak/edit_id.py diff --git a/cellacdc/mixins/exporting.py b/cellacdc/mixins_bak/exporting.py similarity index 100% rename from cellacdc/mixins/exporting.py rename to cellacdc/mixins_bak/exporting.py diff --git a/cellacdc/mixins/formatting.py b/cellacdc/mixins_bak/formatting.py similarity index 100% rename from cellacdc/mixins/formatting.py rename to cellacdc/mixins_bak/formatting.py diff --git a/cellacdc/mixins/frame_metadata.py b/cellacdc/mixins_bak/frame_metadata.py similarity index 100% rename from cellacdc/mixins/frame_metadata.py rename to cellacdc/mixins_bak/frame_metadata.py diff --git a/cellacdc/mixins/frame_navigation.py b/cellacdc/mixins_bak/frame_navigation.py similarity index 100% rename from cellacdc/mixins/frame_navigation.py rename to cellacdc/mixins_bak/frame_navigation.py diff --git a/cellacdc/mixins/geometry.py b/cellacdc/mixins_bak/geometry.py similarity index 100% rename from cellacdc/mixins/geometry.py rename to cellacdc/mixins_bak/geometry.py diff --git a/cellacdc/mixins/graphics.py b/cellacdc/mixins_bak/graphics.py similarity index 100% rename from cellacdc/mixins/graphics.py rename to cellacdc/mixins_bak/graphics.py diff --git a/cellacdc/mixins/image_controls.py b/cellacdc/mixins_bak/image_controls.py similarity index 100% rename from cellacdc/mixins/image_controls.py rename to cellacdc/mixins_bak/image_controls.py diff --git a/cellacdc/mixins/image_display.py b/cellacdc/mixins_bak/image_display.py similarity index 100% rename from cellacdc/mixins/image_display.py rename to cellacdc/mixins_bak/image_display.py diff --git a/cellacdc/mixins/label_editing.py b/cellacdc/mixins_bak/label_editing.py similarity index 100% rename from cellacdc/mixins/label_editing.py rename to cellacdc/mixins_bak/label_editing.py diff --git a/cellacdc/mixins/label_edits.py b/cellacdc/mixins_bak/label_edits.py similarity index 100% rename from cellacdc/mixins/label_edits.py rename to cellacdc/mixins_bak/label_edits.py diff --git a/cellacdc/mixins/label_roi.py b/cellacdc/mixins_bak/label_roi.py similarity index 100% rename from cellacdc/mixins/label_roi.py rename to cellacdc/mixins_bak/label_roi.py diff --git a/cellacdc/mixins/label_transform_tools.py b/cellacdc/mixins_bak/label_transform_tools.py similarity index 100% rename from cellacdc/mixins/label_transform_tools.py rename to cellacdc/mixins_bak/label_transform_tools.py diff --git a/cellacdc/mixins/layout_controls.py b/cellacdc/mixins_bak/layout_controls.py similarity index 100% rename from cellacdc/mixins/layout_controls.py rename to cellacdc/mixins_bak/layout_controls.py diff --git a/cellacdc/mixins/lineage.py b/cellacdc/mixins_bak/lineage.py similarity index 100% rename from cellacdc/mixins/lineage.py rename to cellacdc/mixins_bak/lineage.py diff --git a/cellacdc/mixins/lineage_interactions.py b/cellacdc/mixins_bak/lineage_interactions.py similarity index 100% rename from cellacdc/mixins/lineage_interactions.py rename to cellacdc/mixins_bak/lineage_interactions.py diff --git a/cellacdc/mixins/magic_prompts.py b/cellacdc/mixins_bak/magic_prompts.py similarity index 100% rename from cellacdc/mixins/magic_prompts.py rename to cellacdc/mixins_bak/magic_prompts.py diff --git a/cellacdc/mixins/main.py b/cellacdc/mixins_bak/main.py similarity index 100% rename from cellacdc/mixins/main.py rename to cellacdc/mixins_bak/main.py diff --git a/cellacdc/mixins/main_menu.py b/cellacdc/mixins_bak/main_menu.py similarity index 100% rename from cellacdc/mixins/main_menu.py rename to cellacdc/mixins_bak/main_menu.py diff --git a/cellacdc/mixins/main_toolbar.py b/cellacdc/mixins_bak/main_toolbar.py similarity index 100% rename from cellacdc/mixins/main_toolbar.py rename to cellacdc/mixins_bak/main_toolbar.py diff --git a/cellacdc/mixins/measurements.py b/cellacdc/mixins_bak/measurements.py similarity index 100% rename from cellacdc/mixins/measurements.py rename to cellacdc/mixins_bak/measurements.py diff --git a/cellacdc/mixins/mode_controls.py b/cellacdc/mixins_bak/mode_controls.py similarity index 100% rename from cellacdc/mixins/mode_controls.py rename to cellacdc/mixins_bak/mode_controls.py diff --git a/cellacdc/mixins/model_registry.py b/cellacdc/mixins_bak/model_registry.py similarity index 100% rename from cellacdc/mixins/model_registry.py rename to cellacdc/mixins_bak/model_registry.py diff --git a/cellacdc/mixins/object_cleanup.py b/cellacdc/mixins_bak/object_cleanup.py similarity index 100% rename from cellacdc/mixins/object_cleanup.py rename to cellacdc/mixins_bak/object_cleanup.py diff --git a/cellacdc/mixins/object_counts.py b/cellacdc/mixins_bak/object_counts.py similarity index 100% rename from cellacdc/mixins/object_counts.py rename to cellacdc/mixins_bak/object_counts.py diff --git a/cellacdc/mixins/object_properties.py b/cellacdc/mixins_bak/object_properties.py similarity index 100% rename from cellacdc/mixins/object_properties.py rename to cellacdc/mixins_bak/object_properties.py diff --git a/cellacdc/mixins/object_search.py b/cellacdc/mixins_bak/object_search.py similarity index 100% rename from cellacdc/mixins/object_search.py rename to cellacdc/mixins_bak/object_search.py diff --git a/cellacdc/mixins/points.py b/cellacdc/mixins_bak/points.py similarity index 100% rename from cellacdc/mixins/points.py rename to cellacdc/mixins_bak/points.py diff --git a/cellacdc/mixins/points_layers.py b/cellacdc/mixins_bak/points_layers.py similarity index 100% rename from cellacdc/mixins/points_layers.py rename to cellacdc/mixins_bak/points_layers.py diff --git a/cellacdc/mixins/preprocessing.py b/cellacdc/mixins_bak/preprocessing.py similarity index 100% rename from cellacdc/mixins/preprocessing.py rename to cellacdc/mixins_bak/preprocessing.py diff --git a/cellacdc/mixins/quick_settings.py b/cellacdc/mixins_bak/quick_settings.py similarity index 100% rename from cellacdc/mixins/quick_settings.py rename to cellacdc/mixins_bak/quick_settings.py diff --git a/cellacdc/mixins/saving.py b/cellacdc/mixins_bak/saving.py similarity index 100% rename from cellacdc/mixins/saving.py rename to cellacdc/mixins_bak/saving.py diff --git a/cellacdc/mixins/seg_for_lost_ids.py b/cellacdc/mixins_bak/seg_for_lost_ids.py similarity index 100% rename from cellacdc/mixins/seg_for_lost_ids.py rename to cellacdc/mixins_bak/seg_for_lost_ids.py diff --git a/cellacdc/mixins/segmentation.py b/cellacdc/mixins_bak/segmentation.py similarity index 100% rename from cellacdc/mixins/segmentation.py rename to cellacdc/mixins_bak/segmentation.py diff --git a/cellacdc/mixins/session.py b/cellacdc/mixins_bak/session.py similarity index 100% rename from cellacdc/mixins/session.py rename to cellacdc/mixins_bak/session.py diff --git a/cellacdc/mixins/status_hover.py b/cellacdc/mixins_bak/status_hover.py similarity index 100% rename from cellacdc/mixins/status_hover.py rename to cellacdc/mixins_bak/status_hover.py diff --git a/cellacdc/mixins/tables.py b/cellacdc/mixins_bak/tables.py similarity index 100% rename from cellacdc/mixins/tables.py rename to cellacdc/mixins_bak/tables.py diff --git a/cellacdc/mixins/tool_activation.py b/cellacdc/mixins_bak/tool_activation.py similarity index 100% rename from cellacdc/mixins/tool_activation.py rename to cellacdc/mixins_bak/tool_activation.py diff --git a/cellacdc/mixins/tracking.py b/cellacdc/mixins_bak/tracking.py similarity index 100% rename from cellacdc/mixins/tracking.py rename to cellacdc/mixins_bak/tracking.py diff --git a/cellacdc/mixins/undo_redo.py b/cellacdc/mixins_bak/undo_redo.py similarity index 100% rename from cellacdc/mixins/undo_redo.py rename to cellacdc/mixins_bak/undo_redo.py diff --git a/cellacdc/mixins/whitelist.py b/cellacdc/mixins_bak/whitelist.py similarity index 100% rename from cellacdc/mixins/whitelist.py rename to cellacdc/mixins_bak/whitelist.py diff --git a/cellacdc/mixins/window_events.py b/cellacdc/mixins_bak/window_events.py similarity index 100% rename from cellacdc/mixins/window_events.py rename to cellacdc/mixins_bak/window_events.py diff --git a/cellacdc/mixins/worker.py b/cellacdc/mixins_bak/worker.py similarity index 100% rename from cellacdc/mixins/worker.py rename to cellacdc/mixins_bak/worker.py diff --git a/cellacdc/mixins/workspace.py b/cellacdc/mixins_bak/workspace.py similarity index 100% rename from cellacdc/mixins/workspace.py rename to cellacdc/mixins_bak/workspace.py From 7e26770b2f02dbc9f4acb45cd8de0bd0f3d8a470 Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 11:05:58 +0200 Subject: [PATCH 09/21] refactor: extract guiWin into mixins and slim gui.py to shell Move all guiWin methods verbatim into cellacdc/mixins with class names without the Mixin suffix, keep only __init__ and run in gui.py, and add gui_decorators for shared decorators. Remove mixins_bak trial code. Co-authored-by: Cursor --- cellacdc/gui.py | 33133 +--------------- cellacdc/gui_decorators.py | 79 + cellacdc/mixins/__init__.py | 54 + cellacdc/{mixins_bak => mixins}/actions.py | 581 +- .../annotation_display.py | 1130 +- cellacdc/{mixins_bak => mixins}/app_shell.py | 155 +- .../{mixins_bak => mixins}/brush_tools.py | 376 +- cellacdc/mixins/canvas_context_menu.py | 129 + .../{mixins_bak => mixins}/canvas_drawing.py | 314 +- .../{mixins_bak => mixins}/canvas_events.py | 635 +- .../{mixins_bak => mixins}/canvas_hover.py | 450 +- cellacdc/mixins/canvas_right_image.py | 48 + .../canvas_selection.py | 460 +- cellacdc/mixins/canvas_tool.py | 11 + cellacdc/{mixins_bak => mixins}/cell_cycle.py | 2002 +- .../{mixins_bak => mixins}/curvature_tools.py | 210 +- .../custom_annotations.py | 455 +- .../{mixins_bak => mixins}/data_loading.py | 1052 +- .../{mixins_bak => mixins}/deleted_rois.py | 404 +- cellacdc/mixins/display_decorations.py | 170 + cellacdc/mixins/draw_clear_region.py | 96 + cellacdc/{mixins_bak => mixins}/exporting.py | 352 +- .../frame_navigation.py | 741 +- cellacdc/mixins/geometry.py | 77 + cellacdc/{mixins_bak => mixins}/graphics.py | 1556 +- .../{mixins_bak => mixins}/image_controls.py | 233 +- .../{mixins_bak => mixins}/image_display.py | 700 +- .../{mixins_bak => mixins}/label_editing.py | 415 +- cellacdc/{mixins_bak => mixins}/label_roi.py | 352 +- cellacdc/mixins/label_transform_tools.py | 229 + .../{mixins_bak => mixins}/layout_controls.py | 458 +- .../lineage_interactions.py | 442 +- .../{mixins_bak => mixins}/magic_prompts.py | 414 +- cellacdc/mixins/main_menu.py | 203 + .../{mixins_bak => mixins}/main_toolbar.py | 272 +- cellacdc/mixins/measurements.py | 151 + .../{mixins_bak => mixins}/mode_controls.py | 133 +- cellacdc/mixins/object_cleanup.py | 93 + .../object_properties.py | 542 +- .../{mixins_bak => mixins}/object_search.py | 204 +- .../{mixins_bak => mixins}/points_layers.py | 827 +- .../{mixins_bak => mixins}/preprocessing.py | 354 +- cellacdc/mixins/quick_settings.py | 155 + cellacdc/{mixins_bak => mixins}/saving.py | 641 +- cellacdc/mixins/seg_for_lost_ids.py | 242 + .../{mixins_bak => mixins}/segmentation.py | 477 +- cellacdc/{mixins_bak => mixins}/session.py | 588 +- cellacdc/mixins/status_hover.py | 153 + .../{mixins_bak => mixins}/tool_activation.py | 485 +- cellacdc/{mixins_bak => mixins}/tracking.py | 786 +- cellacdc/{mixins_bak => mixins}/undo_redo.py | 190 +- .../{mixins_bak => mixins}/window_events.py | 446 +- cellacdc/{mixins_bak => mixins}/worker.py | 186 +- cellacdc/mixins_bak/__init__.py | 69 - cellacdc/mixins_bak/canvas_context_menu.py | 138 - cellacdc/mixins_bak/canvas_right_image.py | 49 - cellacdc/mixins_bak/canvas_tool.py | 49 - cellacdc/mixins_bak/cca_edits.py | 249 - cellacdc/mixins_bak/cca_workflows.py | 160 - cellacdc/mixins_bak/combine.py | 675 - cellacdc/mixins_bak/display_decorations.py | 209 - cellacdc/mixins_bak/draw_clear_region.py | 119 - cellacdc/mixins_bak/edit_id.py | 81 - cellacdc/mixins_bak/formatting.py | 57 - cellacdc/mixins_bak/frame_metadata.py | 52 - cellacdc/mixins_bak/geometry.py | 170 - cellacdc/mixins_bak/label_edits.py | 339 - cellacdc/mixins_bak/label_transform_tools.py | 226 - cellacdc/mixins_bak/lineage.py | 61 - cellacdc/mixins_bak/main.py | 63 - cellacdc/mixins_bak/main_menu.py | 204 - cellacdc/mixins_bak/measurements.py | 199 - cellacdc/mixins_bak/model_registry.py | 134 - cellacdc/mixins_bak/object_cleanup.py | 106 - cellacdc/mixins_bak/object_counts.py | 38 - cellacdc/mixins_bak/points.py | 135 - cellacdc/mixins_bak/quick_settings.py | 202 - cellacdc/mixins_bak/seg_for_lost_ids.py | 397 - cellacdc/mixins_bak/status_hover.py | 269 - cellacdc/mixins_bak/tables.py | 17 - cellacdc/mixins_bak/whitelist.py | 1268 - cellacdc/mixins_bak/workspace.py | 51 - 82 files changed, 10571 insertions(+), 50256 deletions(-) create mode 100644 cellacdc/gui_decorators.py create mode 100644 cellacdc/mixins/__init__.py rename cellacdc/{mixins_bak => mixins}/actions.py (69%) rename cellacdc/{mixins_bak => mixins}/annotation_display.py (52%) rename cellacdc/{mixins_bak => mixins}/app_shell.py (68%) rename cellacdc/{mixins_bak => mixins}/brush_tools.py (67%) create mode 100644 cellacdc/mixins/canvas_context_menu.py rename cellacdc/{mixins_bak => mixins}/canvas_drawing.py (74%) rename cellacdc/{mixins_bak => mixins}/canvas_events.py (67%) rename cellacdc/{mixins_bak => mixins}/canvas_hover.py (60%) create mode 100644 cellacdc/mixins/canvas_right_image.py rename cellacdc/{mixins_bak => mixins}/canvas_selection.py (68%) create mode 100644 cellacdc/mixins/canvas_tool.py rename cellacdc/{mixins_bak => mixins}/cell_cycle.py (61%) rename cellacdc/{mixins_bak => mixins}/curvature_tools.py (68%) rename cellacdc/{mixins_bak => mixins}/custom_annotations.py (61%) rename cellacdc/{mixins_bak => mixins}/data_loading.py (64%) rename cellacdc/{mixins_bak => mixins}/deleted_rois.py (64%) create mode 100644 cellacdc/mixins/display_decorations.py create mode 100644 cellacdc/mixins/draw_clear_region.py rename cellacdc/{mixins_bak => mixins}/exporting.py (62%) rename cellacdc/{mixins_bak => mixins}/frame_navigation.py (71%) create mode 100644 cellacdc/mixins/geometry.py rename cellacdc/{mixins_bak => mixins}/graphics.py (73%) rename cellacdc/{mixins_bak => mixins}/image_controls.py (70%) rename cellacdc/{mixins_bak => mixins}/image_display.py (74%) rename cellacdc/{mixins_bak => mixins}/label_editing.py (73%) rename cellacdc/{mixins_bak => mixins}/label_roi.py (68%) create mode 100644 cellacdc/mixins/label_transform_tools.py rename cellacdc/{mixins_bak => mixins}/layout_controls.py (72%) rename cellacdc/{mixins_bak => mixins}/lineage_interactions.py (63%) rename cellacdc/{mixins_bak => mixins}/magic_prompts.py (55%) create mode 100644 cellacdc/mixins/main_menu.py rename cellacdc/{mixins_bak => mixins}/main_toolbar.py (78%) create mode 100644 cellacdc/mixins/measurements.py rename cellacdc/{mixins_bak => mixins}/mode_controls.py (84%) create mode 100644 cellacdc/mixins/object_cleanup.py rename cellacdc/{mixins_bak => mixins}/object_properties.py (71%) rename cellacdc/{mixins_bak => mixins}/object_search.py (61%) rename cellacdc/{mixins_bak => mixins}/points_layers.py (70%) rename cellacdc/{mixins_bak => mixins}/preprocessing.py (65%) create mode 100644 cellacdc/mixins/quick_settings.py rename cellacdc/{mixins_bak => mixins}/saving.py (67%) create mode 100644 cellacdc/mixins/seg_for_lost_ids.py rename cellacdc/{mixins_bak => mixins}/segmentation.py (64%) rename cellacdc/{mixins_bak => mixins}/session.py (57%) create mode 100644 cellacdc/mixins/status_hover.py rename cellacdc/{mixins_bak => mixins}/tool_activation.py (70%) rename cellacdc/{mixins_bak => mixins}/tracking.py (73%) rename cellacdc/{mixins_bak => mixins}/undo_redo.py (76%) rename cellacdc/{mixins_bak => mixins}/window_events.py (77%) rename cellacdc/{mixins_bak => mixins}/worker.py (74%) delete mode 100644 cellacdc/mixins_bak/__init__.py delete mode 100644 cellacdc/mixins_bak/canvas_context_menu.py delete mode 100644 cellacdc/mixins_bak/canvas_right_image.py delete mode 100644 cellacdc/mixins_bak/canvas_tool.py delete mode 100644 cellacdc/mixins_bak/cca_edits.py delete mode 100644 cellacdc/mixins_bak/cca_workflows.py delete mode 100644 cellacdc/mixins_bak/combine.py delete mode 100644 cellacdc/mixins_bak/display_decorations.py delete mode 100644 cellacdc/mixins_bak/draw_clear_region.py delete mode 100644 cellacdc/mixins_bak/edit_id.py delete mode 100644 cellacdc/mixins_bak/formatting.py delete mode 100644 cellacdc/mixins_bak/frame_metadata.py delete mode 100644 cellacdc/mixins_bak/geometry.py delete mode 100644 cellacdc/mixins_bak/label_edits.py delete mode 100644 cellacdc/mixins_bak/label_transform_tools.py delete mode 100644 cellacdc/mixins_bak/lineage.py delete mode 100644 cellacdc/mixins_bak/main.py delete mode 100644 cellacdc/mixins_bak/main_menu.py delete mode 100644 cellacdc/mixins_bak/measurements.py delete mode 100644 cellacdc/mixins_bak/model_registry.py delete mode 100644 cellacdc/mixins_bak/object_cleanup.py delete mode 100644 cellacdc/mixins_bak/object_counts.py delete mode 100644 cellacdc/mixins_bak/points.py delete mode 100644 cellacdc/mixins_bak/quick_settings.py delete mode 100644 cellacdc/mixins_bak/seg_for_lost_ids.py delete mode 100644 cellacdc/mixins_bak/status_hover.py delete mode 100644 cellacdc/mixins_bak/tables.py delete mode 100644 cellacdc/mixins_bak/whitelist.py delete mode 100644 cellacdc/mixins_bak/workspace.py diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 7247f462e..91c655849 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -1,208 +1,130 @@ -import gc import sys import os -import shutil -import re -import traceback -import time -from copy import deepcopy -from datetime import datetime, timedelta -import inspect -import logging -import uuid -import json -from collections import defaultdict, Counter -import psutil -import zipfile -from functools import partial -from tqdm import tqdm -from natsort import natsorted -from typing import Literal, Iterable, Dict, Any, List, Union, Tuple, Set -import time -import cv2 -import math import numpy as np -import pandas as pd -import matplotlib -import scipy.optimize -import scipy.interpolate -import scipy.ndimage -import skimage -import skimage.io -import skimage.measure -import skimage.morphology -import skimage.draw -import skimage.exposure -import skimage.transform -import skimage.segmentation - -from functools import wraps -from skimage.color import gray2rgb, gray2rgba, label2rgb - -from qtpy.QtCore import ( - Qt, QPoint, QTextStream, QSize, QRect, QRectF, - QEventLoop, QTimer, QEvent, QObject, Signal, - QThread, QMutex, QWaitCondition, QSettings, PYQT6 -) -from qtpy.QtGui import ( - QIcon, QKeySequence, QCursor, QGuiApplication, QPixmap, QColor, - QFont, QKeyEvent, QMouseEvent -) -from qtpy.QtWidgets import ( - QAction, QLabel, QPushButton, QHBoxLayout, QSizePolicy, - QMainWindow, QMenu, QToolBar, QGroupBox, QGridLayout, - QScrollBar, QCheckBox, QToolButton, QSpinBox, QButtonGroup, QActionGroup, QFileDialog, QAbstractSlider, QMessageBox, QWidget, QGridLayout, - QDockWidget, QGraphicsProxyWidget, QVBoxLayout, QRadioButton, - QSpacerItem, QScrollArea, QFormLayout, QGraphicsSceneMouseEvent +from qtpy.QtCore import Qt, QTimer, Signal +from qtpy.QtWidgets import QMainWindow, QButtonGroup, QWidget + +from . import myutils, autopilot, whitelist, gui_combine +from .myutils import setupLogger +from .gui_decorators import get_data_exception_handler, resetViewRange +from .mixins import ( + Actions, + AnnotationDisplay, + AppShell, + BrushTools, + CanvasContextMenu, + CanvasDrawing, + CanvasEvents, + CanvasHover, + CanvasRightImage, + CanvasSelection, + CanvasTool, + CellCycle, + CurvatureTools, + CustomAnnotations, + DataLoading, + DeletedRois, + DisplayDecorations, + DrawClearRegion, + Exporting, + FrameNavigation, + Geometry, + Graphics, + ImageControls, + ImageDisplay, + LabelEditing, + LabelRoi, + LabelTransformTools, + LayoutControls, + LineageInteractions, + MagicPrompts, + MainMenu, + MainToolbar, + Measurements, + ModeControls, + ObjectCleanup, + ObjectProperties, + ObjectSearch, + PointsLayers, + Preprocessing, + QuickSettings, + Saving, + SegForLostIds, + Segmentation, + Session, + StatusHover, + ToolActivation, + Tracking, + UndoRedo, + WindowEvents, + Worker, ) -import pyqtgraph as pg -pg.setConfigOption('imageAxisOrder', 'row-major') - -from warnings import simplefilter -simplefilter(action="ignore", category=pd.errors.PerformanceWarning) - -# Custom modules -from . import exception_handler, disableWindow -from . import base_cca_dict, lineage_tree_cols, lineage_tree_cols_std_val -from . import graphLayoutBkgrColor, darkBkgrColor -from . import cca_df_colnames -from . import load, prompts, apps, workers, html_utils -from . import core, myutils, dataPrep, widgets -from . import _warnings, issues_url -from . import measurements, printl -from . import colors, annotate -from . import user_manual_url -from . import recentPaths_path, settings_folderpath, settings_csv_path -from . import favourite_func_metrics_csv_path -from . import qutils, autopilot, QtScoped -from . import _palettes -from . import transformation -from . import measure -from . import cca_functions -from . import data_structure_docs_url -from . import exporters -from . import preprocess -from . import io -from . import whitelist -from . import cli -from . import is_mac -from .trackers.CellACDC import CellACDC_tracker -from .cca_functions import _calc_rot_vol -from .myutils import exec_time, setupLogger, ArgSpec -from .help import welcome, about -from .trackers.CellACDC_normal_division.CellACDC_normal_division_tracker import ( - normal_division_lineage_tree)#, reorg_sister_cells_for_export) -from . import debugutils - -from .plot import imshow -from . import gui_utils - -from . import gui_combine - np.seterr(invalid='ignore') if os.name == 'nt': try: - # Set taskbar icon in windows import ctypes - myappid = 'schmollerlab.cellacdc.pyqt.v1' # arbitrary string + myappid = 'schmollerlab.cellacdc.pyqt.v1' ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) - except Exception as e: + except Exception: pass -GREEN_HEX = _palettes.green() - -custom_annot_path = os.path.join(settings_folderpath, 'custom_annotations.json') -shortcut_filepath = os.path.join(settings_folderpath, 'shortcuts.ini') - -_font = QFont() -_font.setPixelSize(11) - -font_13px = QFont() -font_13px.setPixelSize(13) - -SliderSingleStepAdd = QtScoped.SliderSingleStepAdd() -SliderSingleStepSub = QtScoped.SliderSingleStepSub() -SliderPageStepAdd = QtScoped.SliderPageStepAdd() -SliderPageStepSub = QtScoped.SliderPageStepSub() -SliderMove = QtScoped.SliderMove() - -def qt_debug_trace(): - from qtpy.QtCore import pyqtRemoveInputHook - pyqtRemoveInputHook() - import pdb; pdb.set_trace() - -def get_data_exception_handler(func): - @wraps(func) - def inner_function(self, *args, **kwargs): - try: - if func.__code__.co_argcount==1 and func.__defaults__ is None: - result = func(self) - elif func.__code__.co_argcount>1 and func.__defaults__ is None: - result = func(self, *args) - else: - result = func(self, *args, **kwargs) - except Exception as e: - try: - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - except AttributeError: - pass - result = None - posData = self.data[self.pos_i] - acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) - segm_filename = os.path.basename(posData.segm_npz_path) - traceback_str = traceback.format_exc() - self.logger.exception(traceback_str) - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...') - msg.setDetailedText(traceback_str) - err_msg = html_utils.paragraph(f""" - Error in function {func.__name__}.

- One possbile explanation is that either the - {acdc_df_filename} file
- or the segmentation file {segm_filename}
- are being synchronized by a cloud service (e.g., Google Drive - or OneDrive) or they are corrupted/damaged.

- Try moving these files (one by one) outside of the - {os.path.dirname(posData.relPath)} folder -
and reloading the data.

- More details below or in the terminal/console.

- Note that the error details from this session are - also saved in the following file:

- {self.log_path}

- Please send the log file when reporting a bug, thanks! - Please restart Cell-ACDC, we apologise for any inconvenience.

- - """) - msg.critical(self, 'Critical error', err_msg) - self.is_error_state = True - raise e - return result - return inner_function - -def resetViewRange(func): - @wraps(func) - def inner_function(self, *args, **kwargs): - self.storeViewRange() - if func.__code__.co_argcount==1 and func.__defaults__ is None: - result = func(self) - elif func.__code__.co_argcount>1 and func.__defaults__ is None: - result = func(self, *args) - else: - result = func(self, *args, **kwargs) - QTimer.singleShot(200, self.resetRange) - return result - return inner_function - class guiWin(QMainWindow, whitelist.WhitelistGUIElements, - gui_combine.CombineGuiElements, - gui_combine.CombineGUIWorker): + gui_combine.CombineGuiElements, + gui_combine.CombineGUIWorker, + Geometry, + Worker, + Session, + Actions, + MainMenu, + MainToolbar, + LayoutControls, + ImageControls, + QuickSettings, + CanvasTool, + CanvasEvents, + CanvasDrawing, + CanvasHover, + CanvasSelection, + CanvasContextMenu, + CanvasRightImage, + BrushTools, + CurvatureTools, + LabelRoi, + DrawClearRegion, + SegForLostIds, + ObjectSearch, + ToolActivation, + LabelEditing, + CellCycle, + DeletedRois, + DisplayDecorations, + StatusHover, + ModeControls, + AnnotationDisplay, + UndoRedo, + Tracking, + Segmentation, + Preprocessing, + Measurements, + Saving, + DataLoading, + ObjectCleanup, + ObjectProperties, + PointsLayers, + CustomAnnotations, + MagicPrompts, + ImageDisplay, + Graphics, + LineageInteractions, + Exporting, + AppShell, + FrameNavigation, + LabelTransformTools, + WindowEvents): """Main Window.""" sigClosed = Signal(object) @@ -240,33 +162,10 @@ def __init__( self.original_df_lin_tree = None self.original_df_lin_tree_i = None - def setTooltips(self): - tooltips = load.get_tooltips_from_docs() - - for key, tooltip in tooltips.items(): - setShortcut = getattr(self, key).shortcut().toString() - if 'Shortcut: ' in tooltip: - tooltip = tooltip.replace('Shortcut: ', '\nShortcut: ') - elif setShortcut != "": - tooltip = re.sub( - r'Shortcut: \"(.*)\"', - f"Shortcut: \"{setShortcut}\"", - tooltip - ) - else: - tooltip = re.sub( - r'Shortcut: \"(.*)\"', - f"Shortcut: \"No shortcut\"", - tooltip - ) - - getattr(self, key).setToolTip(tooltip) - getattr(self, key)._tooltip = tooltip - - def run(self, module='acdc_gui', logs_path=None): + def run(self, module='acdc_gui', logs_path=None): self.setWindowIcon() self.setWindowTitle() - + self.is_win = sys.platform.startswith("win") if self.is_win: self.openFolderText = 'Show in Explorer...' @@ -281,7 +180,7 @@ def run(self, module='acdc_gui', logs_path=None): logger.info(f'Initializing GUI v{self._version}') else: logger.info(f'Initializing GUI...') - + self.module = module self.logger = logger self.log_path = log_path @@ -326,7 +225,7 @@ def run(self, module='acdc_gui', logs_path=None): self.whyNavigateDisabled = set() self.autoSaveTimer = QTimer() self.dirtyPointsLayerTableEndNames = set() - + self._setup_vars_combine() if 'autoSaveIntevalValue' not in self.df_settings.index: autoSaveIntevalValue = 2 @@ -338,7 +237,7 @@ def run(self, module='acdc_gui', logs_path=None): autoSaveIntervalUnit = str( self.df_settings.at['autoSaveIntervalUnit', 'value'] ) - + self.autoSaveIntevalValueUnit = ( autoSaveIntevalValue, autoSaveIntervalUnit ) @@ -352,7 +251,6 @@ def run(self, module='acdc_gui', logs_path=None): self.toolsActiveInProj3Dsegm = set() self.customAnnotDict = {} - # Keep a list of functions that are not functional in 3D, yet self.functionsNotTested3D = [] self.isSnapshot = False @@ -362,11 +260,9 @@ def run(self, module='acdc_gui', logs_path=None): self.countKeyPress = 0 self.countRightClicks = 0 self.xHoverImg, self.yHoverImg = None, None - - # Keep track on what frames the on first visit tools already ran + self.lastFrameRanOnFirstVisitTools = 0 - - # Buttons added to QButtonGroup will be mutually exclusive + self.checkableQButtonsGroup = QButtonGroup(self) self.checkableQButtonsGroup.setExclusive(False) @@ -389,7 +285,6 @@ def run(self, module='acdc_gui', logs_path=None): self.gui_connectActions() self.gui_createStatusBar() - # self.gui_createTerminalWidget() self.gui_createGraphicsPlots() self.gui_addGraphicsItems() @@ -413,32795 +308,5 @@ def run(self, module='acdc_gui', logs_path=None): self.initShortcuts() self.show() QTimer.singleShot(100, self.resizeRangeWelcomeText) - # self.installEventFilter(self) - - self.logger.info('GUI ready.') - - def initGlobalAttr(self): - self.setOverlayColors() - - self.initImgCmap() - - # Colormap - self.setLut() - - self.fluoDataChNameActions = [] - - self.splineHoverON = False - self.tempSegmentON = False - self.xyOnCtrlPressedFirstTime = None - self.typingEditID = False - self.prevAnnotOptions = None - self.ghostObject = None - self.autoContourHoverON = False - self.navigateScrollBarStartedMoving = True - self.zSliceScrollBarStartedMoving = True - self.labelRoiRunning = False - self.isRangeReset = True - self.lastManualSeparateState = None - self.editIDmergeIDs = True - self.doNotAskAgainExistingID = False - self.doubleRightClickTimeElapsed = False - self.isRealTimeTrackerInitialized = False - self.isWarningCcaIntegrity = False - self.isDoubleRightClick = False - self.isExportingVideo = False - self.pointsLayersNeverToggled = True - self.highlightedIDopts = None - self.timestampStartTimedelta = timedelta(seconds=0) - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - self._ZprojWidgersEnabledState = None - self.imgValueFormatter = 'd' - self.rawValueFormatter = 'd' - self.lastHoverID = -1 - self.annotOptionsToRestore = None - self.annotOptionsToRestoreRight = None - self.rescaleIntensChannelHowMapper = { - self.user_ch_name: 'Rescale each 2D image' - } - self.timestampDialog = None - self.scaleBarDialog = None - self.countObjsWindow = None - self.initLabelRoiModelDialog = None - - # Second channel used by cellpose - self.secondChannelName = None - - self.ax1_viewRange = None - self.measurementsWin = None - - self.model_kwargs = None - self.segmModelName = None - self.labelRoiModel = None - self.autoSegmDoNotAskAgain = False - self.labelRoiGarbageWorkers = [] - self.labelRoiActiveWorkers = [] - - self.clickedOnBud = False - self.postProcessSegmWin = None - - self.UserEnforced_DisabledTracking = False - self.UserEnforced_Tracking = False - - self.ax1BrushHoverID = 0 - - self.disabled_cca_warnings = set() - - self.last_pos_i = -1 - self.last_frame_i = -1 - - # Plots items - self.isMouseDragImg2 = False - self.isMouseDragImg1 = False - self.isMovingLabel = False - self.isRightClickDragImg1 = False - self.clickObjYc, self.clickObjXc = None, None - - self.cca_df_colnames = cca_df_colnames - self.cca_df_dtypes = [ - str, int, int, str, int, int, bool, bool, int - ] - self.cca_df_default_values = list(base_cca_dict.values()) - self.cca_df_int_cols = [ - col for col in cca_df_colnames if type(base_cca_dict[col]) == int - ] - self.lin_tree_df_bool_col = [ - col for col in cca_df_colnames - if isinstance(base_cca_dict[col], bool) - ] - - self.lin_tree_col_checks = [ - 'generation_num', - ] - - # self.lin_tree_df_colnames = set(base_cca_df.keys()) | set(lineage_tree_cols) - # # self.lin_tree_df_dtypes = [ #dk if i need this, for now ignored - # # str, int, int, str, int, int, bool, bool, int - # # ] - # self.lin_tree_df_default_values = list(base_cca_df.values()) + lineage_tree_cols_std_val - self.lin_tree_df_int_cols = [ - 'generation_num', - 'relative_ID', - 'emerg_frame_i', - 'division_frame_i', - 'corrected_on_frame_i' - ] - self.lin_tree_df_bool_col = [ - 'is_history_known', - ] - - self.lin_tree_col_checks = [ - 'generation_num', - ] - - self.lin_tree_df_colnames = self.lin_tree_df_int_cols + self.lin_tree_df_bool_col + self.lin_tree_col_checks - self.SegForLostIDsSettings = {} - - def setWindowIcon(self, icon=None): - if icon is None: - icon = QIcon(":icon.ico") - super().setWindowIcon(icon) - - def setWindowTitle(self, title=None): - if title is None: - title = f'Cell-ACDC v{self._acdc_version} - GUI' - super().setWindowTitle(title) - - def initProfileModels(self): - self.logger.info('Initiliazing profilers...') - - from ._profile.spline_to_obj import model - - self.splineToObjModel = model.Model() - - self.splineToObjModel.fit() - - def setDisabled(self, disabled:bool, keepDisabled:bool=None, force:bool=False): - if force: - if disabled: - super().setDisabled(disabled) - return - else: - self.keepDisabled = False - super().setDisabled(disabled) - return - - if keepDisabled is not None: - self.keepDisabled = keepDisabled - - if self.keepDisabled: - if disabled: - super().setDisabled(disabled) - return - else: - return - else: - super().setDisabled(disabled) - - def readRecentPaths(self, recent_paths_path=None): - # Step 0. Remove the old options from the menu - self.openRecentMenu.clear() - - # Step 1. Read recent Paths - if recent_paths_path is None: - recent_paths_path = recentPaths_path - - if os.path.exists(recent_paths_path): - df = pd.read_csv(recent_paths_path, index_col='index') - df['path'] = df['path'].str.replace('\\', '/') - df = df.drop_duplicates(subset=['path']) - df.to_csv(recent_paths_path) - if 'opened_last_on' in df.columns: - df = df.sort_values('opened_last_on', ascending=False) - recentPaths = df['path'].to_list() - else: - recentPaths = [] - - # Step 2. Dynamically create the actions - actions = [] - for path in recentPaths: - if not os.path.exists(path): - continue - action = QAction(path, self) - action.triggered.connect(partial(self.openRecentFile, path)) - actions.append(action) - - # Step 3. Add the actions to the menu - self.openRecentMenu.addActions(actions) - - def addPathToOpenRecentMenu(self, path): - for action in self.openRecentMenu.actions(): - if path == action.text(): - break - else: - action = QAction(path, self) - action.triggered.connect(partial(self.openRecentFile, path)) - - try: - firstAction = self.openRecentMenu.actions()[0] - self.openRecentMenu.insertAction(firstAction, action) - except Exception as e: - pass - - def loadLastSessionSettings(self): - self.settings_csv_path = settings_csv_path - if os.path.exists(settings_csv_path): - self.df_settings = pd.read_csv( - settings_csv_path, index_col='setting' - ) - if 'is_bw_inverted' not in self.df_settings.index: - self.df_settings.at['is_bw_inverted', 'value'] = 'No' - else: - self.df_settings.loc['is_bw_inverted'] = ( - self.df_settings.loc['is_bw_inverted'].astype(str) - ) - if 'fontSize' not in self.df_settings.index: - self.df_settings.at['fontSize', 'value'] = 12 - if 'overlayColor' not in self.df_settings.index: - self.df_settings.at['overlayColor', 'value'] = '255-255-0' - if 'how_normIntensities' not in self.df_settings.index: - raw = 'Do not normalize. Display raw image' - self.df_settings.at['how_normIntensities', 'value'] = raw - else: - idx = ['is_bw_inverted', 'fontSize', 'overlayColor', 'how_normIntensities'] - values = ['No', 12, '255-255-0', 'raw'] - self.df_settings = pd.DataFrame({ - 'setting': idx,'value': values} - ).set_index('setting') - - if 'isLabelsVisible' not in self.df_settings.index: - self.df_settings.at['isLabelsVisible', 'value'] = 'No' - - if 'isNextFrameVisible' not in self.df_settings.index: - self.df_settings.at['isNextFrameVisible', 'value'] = 'No' - - if 'isRightImageVisible' not in self.df_settings.index: - self.df_settings.at['isRightImageVisible', 'value'] = 'Yes' - - if 'manual_separate_draw_mode' not in self.df_settings.index: - col = 'manual_separate_draw_mode' - self.df_settings.at[col, 'value'] = 'threepoints_arc' - - if 'colorScheme' in self.df_settings.index: - col = 'colorScheme' - self._colorScheme = self.df_settings.at[col, 'value'] - else: - self._colorScheme = 'light' - - self.doNotShowAgainMissingCca = False - if 'doNotShowAgainMissingCca' not in self.df_settings.index: - self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'No' - else: - val = self.df_settings.at['doNotShowAgainMissingCca', 'value'] - self.doNotShowAgainMissingCca = val=='Yes' - - def dragEnterEvent(self, event): - file_path = event.mimeData().urls()[0].toLocalFile() - if os.path.isdir(file_path): - exp_path = file_path - basename = os.path.basename(file_path) - if basename.find('Position_')!=-1 or basename=='Images': - event.acceptProposedAction() - else: - event.ignore() - else: - event.acceptProposedAction() - - def dropEvent(self, event): - event.setDropAction(Qt.CopyAction) - file_path = event.mimeData().urls()[0].toLocalFile() - self.logger.info(f'Dragged and dropped path "{file_path}"') - basename = os.path.basename(file_path) - if os.path.isdir(file_path): - exp_path = file_path - self.openFolder(exp_path=exp_path) - else: - self.openFile(file_path=file_path) - - def changeEvent(self, event): - try: - self.delObjToolAction.setChecked(False) - except Exception as err: - return - - def leaveEvent(self, event): - if self.slideshowWin is not None: - posData = self.data[self.pos_i] - mainWinGeometry = self.geometry() - mainWinLeft = mainWinGeometry.left() - mainWinTop = mainWinGeometry.top() - mainWinWidth = mainWinGeometry.width() - mainWinHeight = mainWinGeometry.height() - mainWinRight = mainWinLeft+mainWinWidth - mainWinBottom = mainWinTop+mainWinHeight - - slideshowWinGeometry = self.slideshowWin.geometry() - slideshowWinLeft = slideshowWinGeometry.left() - slideshowWinTop = slideshowWinGeometry.top() - slideshowWinWidth = slideshowWinGeometry.width() - slideshowWinHeight = slideshowWinGeometry.height() - - # Determine if overlap - overlap = ( - (slideshowWinTop < mainWinBottom) and - (slideshowWinLeft < mainWinRight) - ) - - autoActivate = ( - self.isDataLoaded and not - overlap and not - posData.disableAutoActivateViewerWindow - ) - - if autoActivate: - self.slideshowWin.setFocus() - self.slideshowWin.activateWindow() - - def enterEvent(self, event): - event.accept() - if self.slideshowWin is not None: - posData = self.data[self.pos_i] - mainWinGeometry = self.geometry() - mainWinLeft = mainWinGeometry.left() - mainWinTop = mainWinGeometry.top() - mainWinWidth = mainWinGeometry.width() - mainWinHeight = mainWinGeometry.height() - mainWinRight = mainWinLeft+mainWinWidth - mainWinBottom = mainWinTop+mainWinHeight - - slideshowWinGeometry = self.slideshowWin.geometry() - slideshowWinLeft = slideshowWinGeometry.left() - slideshowWinTop = slideshowWinGeometry.top() - slideshowWinWidth = slideshowWinGeometry.width() - slideshowWinHeight = slideshowWinGeometry.height() - - # Determine if overlap - overlap = ( - (slideshowWinTop < mainWinBottom) and - (slideshowWinLeft < mainWinRight) - ) - - autoActivate = ( - self.isDataLoaded and not - overlap and not - posData.disableAutoActivateViewerWindow - ) - - if autoActivate: - # self.setFocus() - self.activateWindow() - - def isPanImageClick(self, mouseEvent, modifiers): - left_click = mouseEvent.button() == Qt.MouseButton.LeftButton - return modifiers == Qt.AltModifier and left_click - - def middleClickText(self): - if self.delObjAction is None and is_mac: - return 'Command + Left Click' - - if self.delObjAction is None: - return 'Middle Click' - - delObjKeySequence, delObjQtButton = self.delObjAction - - if delObjQtButton == Qt.MouseButton.LeftButton: - buttonName = 'Left click' - elif delObjQtButton == Qt.MouseButton.RightButton: - buttonName = 'Right click' - else: - buttonName = 'Middle click' - - if delObjKeySequence is None: - return buttonName - - return f'{delObjKeySequence.toString()} + {buttonName}' - - def isDefaultMiddleClick(self, mouseEvent, modifiers): - if is_mac: - middle_click = ( - mouseEvent.button() == Qt.MouseButton.LeftButton - and modifiers == Qt.ControlModifier - and not self.brushButton.isChecked() - ) - else: - middle_click = mouseEvent.button() == Qt.MouseButton.MiddleButton - return middle_click - - def isMiddleClick(self, mouseEvent, modifiers): - if self.delObjAction is None: - return self.isDefaultMiddleClick(mouseEvent, modifiers) - - delObjKeySequence, delObjQtButton = self.delObjAction - if delObjKeySequence is None: - # Setting only middle click on mac is allowed, however the - # delObjKeySequence is None and the tool button is never checked - isDelObjectActive = True - else: - isDelObjectActive = self.delObjToolAction.isChecked() - - mouseEventButton = self.changeRightClickToLeftOnMac(mouseEvent) - - middle_click = ( - mouseEventButton == delObjQtButton and isDelObjectActive - ) - - return middle_click - - def gui_createCursors(self): - pixmap = QPixmap(":wand_cursor.svg") - self.wandCursor = QCursor(pixmap, 16, 16) - - pixmap = QPixmap(":curv_cursor.svg") - self.curvCursor = QCursor(pixmap, 16, 16) - - pixmap = QPixmap(":addDelPolyLineRoi_cursor.svg") - self.polyLineRoiCursor = QCursor(pixmap, 16, 16) - - pixmap = QPixmap(":cross_cursor.svg") - self.addPointsCursor = QCursor(pixmap, 16, 16) - - def gui_createMenuBar(self): - menuBar = self.menuBar() - menuBar.setNativeMenuBar(False) - - # File menu - fileMenu = QMenu("&File", self) - self.fileMenu = fileMenu - menuBar.addMenu(fileMenu) - if self.debug: - fileMenu.addAction(self.createEmptyDataAction) - fileMenu.addAction(self.newAction) - fileMenu.addAction(self.newWindowAction) - fileMenu.addSeparator() - fileMenu.addAction(self.openFolderAction) - fileMenu.addAction(self.openFileAction) - # Open Recent submenu - self.openRecentMenu = fileMenu.addMenu("Open Recent") - fileMenu.addSeparator() - fileMenu.addAction(self.manageVersionsAction) - fileMenu.addAction(self.saveAction) - fileMenu.addAction(self.saveAsAction) - fileMenu.addAction(self.quickSaveAction) - fileMenu.addSeparator() - - self.exportMenu = fileMenu.addMenu('Export') - self.exportMenu.addAction(self.exportToVideoAction) - self.exportMenu.addAction(self.exportToImageAction) - fileMenu.addSeparator() - fileMenu.addAction(self.loadFluoAction) - fileMenu.addAction(self.loadPosAction) - # Separator - self.fileMenu.lastSeparator = fileMenu.addSeparator() - fileMenu.addAction(self.exitAction) - - # Edit menu - editMenu = menuBar.addMenu("&Edit") - editMenu.addSeparator() - - editMenu.addAction(self.editShortcutsAction) - editMenu.addAction(self.editTextIDsColorAction) - editMenu.addAction(self.editOverlayColorAction) - editMenu.addAction(self.manuallyEditCcaAction) - editMenu.addAction(self.enableSmartTrackAction) - editMenu.addAction(self.enableAutoZoomToCellsAction) - - # View menu - self.viewMenu = menuBar.addMenu("&View") - self.viewMenu.addSeparator() - self.viewMenu.addAction(self.viewCcaTableAction) - - # Image menu - ImageMenu = menuBar.addMenu("&Image") - ImageMenu.addSeparator() - ImageMenu.addAction(self.imgPropertiesAction) - self.defaultRescaleIntensLutMenu = ImageMenu.addMenu( - "Default method to rescale intensities (LUT)" - ) - - self.defaultRescaleIntensActionGroup = QActionGroup( - self.defaultRescaleIntensLutMenu - ) - howTexts = ( - 'Rescale each 2D image', - 'Rescale across z-stack', - 'Rescale across time frames', - 'Do no rescale, display raw image' - ) - try: - self.defaultRescaleIntensHow = ( - self.df_settings.at['default_rescale_intens_how', 'value'] - ) - except Exception as err: - self.defaultRescaleIntensHow = howTexts[0] - - for howText in howTexts: - action = QAction(howText, self.defaultRescaleIntensLutMenu) - action.setCheckable(True) - if howText == self.defaultRescaleIntensHow: - action.setChecked(True) - - self.defaultRescaleIntensActionGroup.addAction(action) - self.defaultRescaleIntensLutMenu.addAction(action) - - ImageMenu.addAction(self.addScaleBarAction) - ImageMenu.addAction(self.addTimestampAction) - - self.rescaleIntensMenu = ImageMenu.addMenu('Rescale intensities (LUT)') - - ImageMenu.addAction(self.preprocessAction) - ImageMenu.addAction(self.combineChannelsAction) - ImageMenu.addAction(self.saveLabColormapAction) - ImageMenu.addAction(self.shuffleCmapAction) - ImageMenu.addAction(self.greedyShuffleCmapAction) - ImageMenu.addAction(self.zoomToObjsAction) - ImageMenu.addAction(self.zoomOutAction) - - # Segment menu - SegmMenu = menuBar.addMenu("&Segment") - self.segmentMenu = SegmMenu - SegmMenu.addSeparator() - self.segmSingleFrameMenu = SegmMenu.addMenu('Segment displayed frame') - for action in self.segmActions: - self.segmSingleFrameMenu.addAction(action) - - self.segmSingleFrameMenu.addSeparator() - self.segmSingleFrameMenu.addAction(self.addCustomModelFrameAction) - - self.segmVideoMenu = SegmMenu.addMenu('Segment multiple frames') - for action in self.segmActionsVideo: - self.segmVideoMenu.addAction(action) - - self.segmVideoMenu.addSeparator() - self.segmVideoMenu.addAction(self.addCustomModelVideoAction) - - self.segmWithPromptableModelMenu = SegmMenu.addMenu( - 'Segment with promptable model' - ) - - self.segmWithPromptableModelMenu.addAction( - self.segmWithPromptableModelAction - ) - - self.segmWithPromptableModelMenu.addSeparator() - self.segmWithPromptableModelMenu.addAction( - self.addCustomPromptModelAction - ) - - SegmMenu.addAction(self.EditSegForLostIDsSetSettings) - SegmMenu.addAction(self.postProcessSegmAction) - SegmMenu.addAction(self.autoSegmAction) - SegmMenu.addAction(self.relabelSequentialAction) - SegmMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) - - # Tracking menu - trackingMenu = menuBar.addMenu("&Tracking") - self.trackingMenu = trackingMenu - trackingMenu.addSeparator() - selectTrackAlgoMenu = trackingMenu.addMenu( - 'Select real-time tracking algorithm' - ) - for rtTrackerAction in self.trackingAlgosGroup.actions(): - selectTrackAlgoMenu.addAction(rtTrackerAction) - - trackingMenu.addAction(self.editRtTrackerParamsAction) - trackingMenu.addAction(self.repeatTrackingVideoAction) - - trackingMenu.addAction(self.repeatTrackingMenuAction) - trackingMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) - - if self.mainWin is not None: - trackingMenu.addAction( - self.mainWin.applyTrackingFromTableAction - ) - trackingMenu.addAction( - self.mainWin.applyTrackingFromTrackMateXMLAction - ) - - # Measurements menu - measurementsMenu = menuBar.addMenu("&Measurements") - self.measurementsMenu = measurementsMenu - measurementsMenu.addSeparator() - measurementsMenu.addAction(self.setMeasurementsAction) - measurementsMenu.addAction(self.addCustomMetricAction) - measurementsMenu.addAction(self.addCombineMetricAction) - measurementsMenu.setDisabled(True) - - # Settings menu - self.settingsMenu = QMenu("Settings", self) - menuBar.addMenu(self.settingsMenu) - self.settingsMenu.addAction(self.invertBwAction) - self.settingsMenu.addAction(self.toggleColorSchemeAction) - self.settingsMenu.addSeparator() - self.settingsMenu.addAction(self.pxModeAction) - self.settingsMenu.addAction(self.highLowResAction) - self.settingsMenu.addAction(self.editShortcutsAction) - self.settingsMenu.addAction(self.showMirroredCursorAction) - self.settingsMenu.addSeparator() - self.settingsMenu.addAction(self.editAutoSaveIntervalAction) - self.settingsMenu.addSeparator() - - # Mode menu (actions added when self.modeComboBox is created) - self.modeMenu = menuBar.addMenu('Mode') - self.modeMenu.menuAction().setVisible(False) - - # Help menu - helpMenu = menuBar.addMenu("&Help") - helpMenu.addAction(self.openLogFileAction) - helpMenu.addAction(self.showLogFilesAction) - helpMenu.addAction(self.tipsAction) - helpMenu.addAction(self.UserManualAction) - helpMenu.addSeparator() - helpMenu.addAction(self.aboutAction) - self.helpMenu = helpMenu - - def gui_createToolBars(self): - # File toolbar - fileToolBar = self.addToolBar("File") - # fileToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) - fileToolBar.setMovable(False) - - self.segmNdimIndicatorAction = fileToolBar.addWidget( - self.segmNdimIndicator - ) - self.segmNdimIndicatorAction.setVisible(False) - fileToolBar.addAction(self.newAction) - fileToolBar.addAction(self.openFolderAction) - fileToolBar.addAction(self.openFileAction) - fileToolBar.addAction(self.manageVersionsAction) - fileToolBar.addAction(self.saveAction) - fileToolBar.addAction(self.showInExplorerAction) - # fileToolBar.addAction(self.reloadAction) - fileToolBar.addAction(self.undoAction) - fileToolBar.addAction(self.redoAction) - self.fileToolBar = fileToolBar - self.setEnabledFileToolbar(False) - - self.undoAction.setEnabled(False) - self.redoAction.setEnabled(False) - - # Navigation toolbar - navigateToolBar = widgets.ToolBar("Navigation", self) - navigateToolBar.setContextMenuPolicy(Qt.PreventContextMenu) - # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) - self.addToolBar(navigateToolBar) - navigateToolBar.addAction(self.findIdAction) - - navigateToolBar.addWidget(self.zoomRectButton) - - self.slideshowButton = QToolButton(self) - self.slideshowButton.setIcon(QIcon(":eye-plus.svg")) - self.slideshowButton.setCheckable(True) - self.slideshowButton.setShortcut('Ctrl+W') - navigateToolBar.addWidget(self.slideshowButton) - - navigateToolBar.addAction(self.autoPilotButton) - - # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) - navigateToolBar.addAction(self.skipToNewIdAction) - - self.preprocessImageAction = QAction('Preprocess image', self) - self.preprocessImageAction.setIcon(QIcon(":filter_image.svg")) - navigateToolBar.addAction(self.preprocessImageAction) - - self.overlayButton = widgets.rightClickToolButton(parent=self) - self.overlayButton.setIcon(QIcon(":overlay.svg")) - self.overlayButton.setCheckable(True) - - self.overlayButtonAction = navigateToolBar.addWidget(self.overlayButton) - # self.checkableButtons.append(self.overlayButton) - # self.checkableQButtonsGroup.addButton(self.overlayButton) - - self.countObjsButton = QToolButton(self) - self.countObjsButton.setIcon(QIcon(":count_objects.svg")) - self.countObjsButton.setCheckable(True) - self.countObjsButton.setShortcut('Ctrl+Shift+C') - self.countObjsButtonAction = navigateToolBar.addWidget( - self.countObjsButton - ) - - self.togglePointsLayerAction = QAction('Activate points layer', self) - self.togglePointsLayerAction.setCheckable(True) - self.togglePointsLayerAction.setIcon(QIcon(":pointsLayer.svg")) - navigateToolBar.addAction(self.togglePointsLayerAction) - - self.overlayLabelsButton = widgets.rightClickToolButton(parent=self) - self.overlayLabelsButton.setIcon(QIcon(":overlay_labels.svg")) - self.overlayLabelsButton.setCheckable(True) - # self.overlayLabelsButton.setVisible(False) - self.overlayLabelsButtonAction = navigateToolBar.addWidget( - self.overlayLabelsButton - ) - self.overlayLabelsButtonAction.setVisible(False) - - self.rulerButton = QToolButton(self) - self.rulerButton.setIcon(QIcon(":ruler.svg")) - self.rulerButton.setCheckable(True) - navigateToolBar.addWidget(self.rulerButton) - self.checkableButtons.append(self.rulerButton) - self.LeftClickButtons.append(self.rulerButton) - - # fluorescence image color widget - colorsToolBar = widgets.ToolBar("Colors", self) - self.overlayColorButton = pg.ColorButton(self, color=(230,230,230)) - self.overlayColorButton.setDisabled(True) - colorsToolBar.addWidget(self.overlayColorButton) - - self.textIDsColorButton = pg.ColorButton(self) - colorsToolBar.addWidget(self.textIDsColorButton) - - self.addToolBar(colorsToolBar) - colorsToolBar.setVisible(False) - - self.navigateToolBar = navigateToolBar - - # cca toolbar - ccaToolBar = widgets.ToolBar("Cell cycle annotations", self) - self.addToolBar(ccaToolBar) - - # Assign mother to bud button - self.assignBudMothButton = QToolButton(self) - self.assignBudMothButton.setIcon(QIcon(":assign-motherbud.svg")) - self.assignBudMothButton.setCheckable(True) - self.assignBudMothButton.setShortcut('A') - self.assignBudMothButton.setVisible(False) - self.assignBudMothButton.action = ccaToolBar.addWidget( - self.assignBudMothButton - ) - self.checkableButtons.append(self.assignBudMothButton) - self.checkableQButtonsGroup.addButton(self.assignBudMothButton) - self.functionsNotTested3D.append(self.assignBudMothButton) - - - # Set is_history_known button - self.setIsHistoryKnownButton = QToolButton(self) - self.setIsHistoryKnownButton.setIcon(QIcon(":history.svg")) - self.setIsHistoryKnownButton.setCheckable(True) - self.setIsHistoryKnownButton.setShortcut('U') - self.setIsHistoryKnownButton.setVisible(False) - self.setIsHistoryKnownButton.action = ccaToolBar.addWidget( - self.setIsHistoryKnownButton - ) - self.checkableButtons.append(self.setIsHistoryKnownButton) - self.checkableQButtonsGroup.addButton(self.setIsHistoryKnownButton) - self.functionsNotTested3D.append(self.setIsHistoryKnownButton) - - ccaToolBar.addAction(self.assignBudMothAutoAction) - ccaToolBar.addAction(self.editCcaToolAction) - ccaToolBar.addAction(self.reInitCcaAction) - ccaToolBar.setVisible(False) - self.ccaToolBar = ccaToolBar - self.functionsNotTested3D.append(self.assignBudMothAutoAction) - self.functionsNotTested3D.append(self.reInitCcaAction) - self.functionsNotTested3D.append(self.editCcaToolAction) - - # Edit toolbar - editToolBar = widgets.ToolBar("Edit", self) - editToolBar.setContextMenuPolicy(Qt.PreventContextMenu) - - self.addToolBar(editToolBar) - - self.manulAnnotToolButtons = set() - - self.brushButton = QToolButton(self) - self.brushButton.setIcon(QIcon(":brush.svg")) - self.brushButton.setCheckable(True) - editToolBar.addWidget(self.brushButton) - self.checkableButtons.append(self.brushButton) - self.LeftClickButtons.append(self.brushButton) - self.brushButton.keyPressShortcut = Qt.Key_B - self.widgetsWithShortcut['Brush'] = self.brushButton - self.manulAnnotToolButtons.add(self.brushButton) - - self.eraserButton = QToolButton(self) - self.eraserButton.setIcon(QIcon(":eraser.svg")) - self.eraserButton.setCheckable(True) - editToolBar.addWidget(self.eraserButton) - self.eraserButton.keyPressShortcut = Qt.Key_X - self.widgetsWithShortcut['Eraser'] = self.eraserButton - self.checkableButtons.append(self.eraserButton) - self.LeftClickButtons.append(self.eraserButton) - self.manulAnnotToolButtons.add(self.eraserButton) - - self.curvToolButton = QToolButton(self) - self.curvToolButton.setIcon(QIcon(":curvature-tool.svg")) - self.curvToolButton.setCheckable(True) - self.curvToolButton.setShortcut('C') - self.curvToolButton.action = editToolBar.addWidget(self.curvToolButton) - self.LeftClickButtons.append(self.curvToolButton) - # self.functionsNotTested3D.append(self.curvToolButton) - self.widgetsWithShortcut['Curvature tool'] = self.curvToolButton - # self.checkableButtons.append(self.curvToolButton) - self.manulAnnotToolButtons.add(self.curvToolButton) - - self.wandToolButton = QToolButton(self) - self.wandToolButton.setIcon(QIcon(":magic_wand.svg")) - self.wandToolButton.setCheckable(True) - self.wandToolButton.setShortcut('Ctrl+D') - self.wandToolButton.action = editToolBar.addWidget(self.wandToolButton) - self.LeftClickButtons.append(self.wandToolButton) - self.checkableButtons.append(self.eraserButton) - self.widgetsWithShortcut['Magic wand'] = self.wandToolButton - - self.magicPromptsToolButton = QToolButton(self) - self.magicPromptsToolButton.setIcon(QIcon(":magic-prompts.svg")) - self.magicPromptsToolButton.setCheckable(True) - self.magicPromptsToolButton.setShortcut('W') - self.magicPromptsToolButton.action = editToolBar.addWidget( - self.magicPromptsToolButton - ) - self.widgetsWithShortcut['Magic prompts'] = self.magicPromptsToolButton - - self.drawClearRegionButton = QToolButton(self) - self.drawClearRegionButton.setCheckable(True) - self.drawClearRegionButton.setIcon(QIcon(":clear_freehand_region.svg")) - self.widgetsWithShortcut['Clear freehand region'] = ( - self.drawClearRegionButton - ) - self.toolsActiveInProj3Dsegm.add(self.drawClearRegionButton) - - self.checkableButtons.append(self.drawClearRegionButton) - self.LeftClickButtons.append(self.drawClearRegionButton) - - self.drawClearRegionAction = editToolBar.addWidget( - self.drawClearRegionButton - ) - - self.widgetsWithShortcut['Annotate mother/daughter pairing'] = ( - self.assignBudMothButton - ) - self.widgetsWithShortcut['Annotate unknown history'] = ( - self.setIsHistoryKnownButton - ) - - self.copyLostObjButton = QToolButton(self) - self.copyLostObjButton.setIcon(QIcon(":copyContour.svg")) - self.copyLostObjButton.setCheckable(True) - self.copyLostObjButton.setShortcut('V') - self.copyLostObjButton.action = editToolBar.addWidget( - self.copyLostObjButton - ) - self.checkableButtons.append(self.copyLostObjButton) - self.checkableQButtonsGroup.addButton(self.copyLostObjButton) - self.widgetsWithShortcut['Copy lost object contour'] = ( - self.copyLostObjButton - ) - self.functionsNotTested3D.append(self.copyLostObjButton) - - self.labelRoiButton = widgets.rightClickToolButton(parent=self) - self.labelRoiButton.setIcon(QIcon(":label_roi.svg")) - self.labelRoiButton.setCheckable(True) - self.labelRoiButton.setShortcut('L') - self.labelRoiButton.action = editToolBar.addWidget(self.labelRoiButton) - self.LeftClickButtons.append(self.labelRoiButton) - self.checkableButtons.append(self.labelRoiButton) - self.checkableQButtonsGroup.addButton(self.labelRoiButton) - self.widgetsWithShortcut['Label ROI'] = self.labelRoiButton - # self.functionsNotTested3D.append(self.labelRoiButton) - - self.manualAnnotPastButton = QToolButton(self) - self.manualAnnotPastButton.setIcon(QIcon(":lock_id_annotate_future.svg")) - self.manualAnnotPastButton.setCheckable(True) - self.manualAnnotPastButton.setShortcut('Y') - self.manualAnnotPastButton.action = editToolBar.addWidget( - self.manualAnnotPastButton - ) - self.checkableButtons.append(self.manualAnnotPastButton) - self.widgetsWithShortcut['Lock ID and annotate single object'] = ( - self.manualAnnotPastButton - ) - self.functionsNotTested3D.append(self.manualAnnotPastButton) - self.manulAnnotToolButtons.add(self.manualAnnotPastButton) - - self.segmentToolAction = QAction('Segment with last used model', self) - self.segmentToolAction.setIcon(QIcon(":segment.svg")) - self.segmentToolAction.setShortcut('R') - self.widgetsWithShortcut['Repeat segmentation'] = self.segmentToolAction - editToolBar.addAction(self.segmentToolAction) - - self.segForLostIDsButton = QToolButton(self) - self.segForLostIDsButton.setIcon(QIcon(":segForLostIDs.svg")) - self.segForLostIDsAction = editToolBar.addWidget( - self.segForLostIDsButton - ) - self.segForLostIDsButton.clicked.connect( - self.segForLostIDsButtonClicked - ) - - # self.SegForLostIDsButton.setShortcut('U') - # self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.SegForLostIDsButton - - self.manualBackgroundButton = QToolButton(self) - self.manualBackgroundButton.setIcon(QIcon(":manual_background.svg")) - self.manualBackgroundButton.setCheckable(True) - self.manualBackgroundButton.setShortcut('G') - self.LeftClickButtons.append(self.manualBackgroundButton) - self.checkableButtons.append(self.manualBackgroundButton) - self.checkableQButtonsGroup.addButton(self.manualBackgroundButton) - self.widgetsWithShortcut['Manual background'] = self.manualBackgroundButton - - self.manualBackgroundAction = editToolBar.addWidget( - self.manualBackgroundButton - ) - - self.delObjsOutSegmMaskAction = QAction( - QIcon(":del_objs_out_segm.svg"), - 'Select a segmentation file and delete all objects on the background', - self - ) - self.delObjsOutSegmMaskAction.setShortcut('I') - self.widgetsWithShortcut['Delete all objects outside segm'] = ( - self.delObjsOutSegmMaskAction - ) - editToolBar.addAction(self.delObjsOutSegmMaskAction) - - self.hullContToolButton = QToolButton(self) - self.hullContToolButton.setIcon(QIcon(":hull.svg")) - self.hullContToolButton.setCheckable(True) - self.hullContToolButton.setShortcut('O') - self.hullContToolButton.action = editToolBar.addWidget(self.hullContToolButton) - self.checkableButtons.append(self.hullContToolButton) - self.checkableQButtonsGroup.addButton(self.hullContToolButton) - self.functionsNotTested3D.append(self.hullContToolButton) - self.widgetsWithShortcut['Hull contour'] = self.hullContToolButton - - self.fillHolesToolButton = QToolButton(self) - self.fillHolesToolButton.setIcon(QIcon(":fill_holes.svg")) - self.fillHolesToolButton.setCheckable(True) - self.fillHolesToolButton.setShortcut('F') - self.fillHolesToolButton.action = editToolBar.addWidget( - self.fillHolesToolButton - ) - self.checkableButtons.append(self.fillHolesToolButton) - self.checkableQButtonsGroup.addButton(self.fillHolesToolButton) - self.functionsNotTested3D.append(self.fillHolesToolButton) - self.widgetsWithShortcut['Fill holes'] = self.fillHolesToolButton - - self.moveLabelToolButton = QToolButton(self) - self.moveLabelToolButton.setIcon(QIcon(":moveLabel.svg")) - self.moveLabelToolButton.setCheckable(True) - self.moveLabelToolButton.setShortcut('P') - self.moveLabelToolButton.action = editToolBar.addWidget(self.moveLabelToolButton) - self.checkableButtons.append(self.moveLabelToolButton) - self.checkableQButtonsGroup.addButton(self.moveLabelToolButton) - self.widgetsWithShortcut['Move label'] = self.moveLabelToolButton - - self.expandLabelToolButton = QToolButton(self) - self.expandLabelToolButton.setIcon(QIcon(":expandLabel.svg")) - self.expandLabelToolButton.setCheckable(True) - self.expandLabelToolButton.setShortcut('E') - self.expandLabelToolButton.action = editToolBar.addWidget(self.expandLabelToolButton) - self.expandLabelToolButton.hide() - self.checkableButtons.append(self.expandLabelToolButton) - self.LeftClickButtons.append(self.expandLabelToolButton) - self.checkableQButtonsGroup.addButton(self.expandLabelToolButton) - self.widgetsWithShortcut['Expand/shrink label'] = self.expandLabelToolButton - - self.editIDbutton = QToolButton(self) - self.editIDbutton.setIcon(QIcon(":edit-id.svg")) - self.editIDbutton.setCheckable(True) - self.editIDbutton.setShortcut('N') - editToolBar.addWidget(self.editIDbutton) - self.checkableButtons.append(self.editIDbutton) - self.checkableQButtonsGroup.addButton(self.editIDbutton) - self.widgetsWithShortcut['Edit ID'] = self.editIDbutton - - self.separateBudButton = QToolButton(self) - self.separateBudButton.setIcon(QIcon(":separate-bud.svg")) - self.separateBudButton.setCheckable(True) - self.separateBudButton.setShortcut('S') - self.separateBudButton.action = editToolBar.addWidget(self.separateBudButton) - self.checkableButtons.append(self.separateBudButton) - self.checkableQButtonsGroup.addButton(self.separateBudButton) - # self.functionsNotTested3D.append(self.separateBudButton) - self.widgetsWithShortcut['Separate objects'] = self.separateBudButton - - self.mergeIDsButton = QToolButton(self) - self.mergeIDsButton.setIcon(QIcon(":merge-IDs.svg")) - self.mergeIDsButton.setCheckable(True) - self.mergeIDsButton.setShortcut('M') - self.mergeIDsButton.action = editToolBar.addWidget(self.mergeIDsButton) - self.checkableButtons.append(self.mergeIDsButton) - self.checkableQButtonsGroup.addButton(self.mergeIDsButton) - # self.functionsNotTested3D.append(self.mergeIDsButton) - self.widgetsWithShortcut['Merge objects'] = self.mergeIDsButton - - self.keepIDsButton = QToolButton(self) - self.keepIDsButton.setIcon(QIcon(":keep_objects.svg")) - self.keepIDsButton.setCheckable(True) - self.keepIDsButton.action = editToolBar.addWidget(self.keepIDsButton) - self.keepIDsButton.setShortcut('K') - self.checkableButtons.append(self.keepIDsButton) - self.checkableQButtonsGroup.addButton(self.keepIDsButton) - # self.functionsNotTested3D.append(self.keepIDsButton) - self.widgetsWithShortcut['Select objects to keep'] = self.keepIDsButton - - self.whitelistIDsButton = QToolButton(self) - self.whitelistIDsButton.setIcon(QIcon(":whitelist.svg")) - self.whitelistIDsButton.setCheckable(True) - self.whitelistIDsButton.action = editToolBar.addWidget( - self.whitelistIDsButton - ) - self.whitelistIDsButton.setShortcut('Ctrl+K') - self.checkableButtons.append(self.whitelistIDsButton) - self.checkableQButtonsGroup.addButton(self.whitelistIDsButton) - self.LeftClickButtons.append(self.whitelistIDsButton) - # self.functionsNotTested3D.append(self.whitelistIDsButton) - self.widgetsWithShortcut['Select objects to add to a tracking whitelist'] = ( - self.whitelistIDsButton - ) - - self.binCellButton = QToolButton(self) - self.binCellButton.setIcon(QIcon(":bin.svg")) - self.binCellButton.setCheckable(True) - # self.binCellButton.setShortcut('R') - self.binCellButton.action = editToolBar.addWidget(self.binCellButton) - self.checkableButtons.append(self.binCellButton) - self.checkableQButtonsGroup.addButton(self.binCellButton) - # self.functionsNotTested3D.append(self.binCellButton) - - self.manualTrackingButton = QToolButton(self) - self.manualTrackingButton.setIcon(QIcon(":manual_tracking.svg")) - self.manualTrackingButton.setCheckable(True) - self.manualTrackingButton.setShortcut('T') - self.checkableQButtonsGroup.addButton(self.manualTrackingButton) - self.checkableButtons.append(self.manualTrackingButton) - self.widgetsWithShortcut['Manual tracking'] = self.manualTrackingButton - - self.ripCellButton = QToolButton(self) - self.ripCellButton.setIcon(QIcon(":rip.svg")) - self.ripCellButton.setCheckable(True) - self.ripCellButton.setShortcut('D') - self.ripCellButton.action = editToolBar.addWidget(self.ripCellButton) - self.checkableButtons.append(self.ripCellButton) - self.checkableQButtonsGroup.addButton(self.ripCellButton) - self.functionsNotTested3D.append(self.ripCellButton) - self.widgetsWithShortcut['Annotate cell as dead'] = self.ripCellButton - - editToolBar.addAction(self.addDelRoiAction) - # editToolBar.addAction(self.addDelPolyLineRoiAction) - - self.addDelPolyLineRoiAction = editToolBar.addWidget( - self.addDelPolyLineRoiButton - ) - self.addDelPolyLineRoiAction.roiType = 'polyline' - - editToolBar.addAction(self.delBorderObjAction) - self.delBorderObjAction.button = editToolBar.widgetForAction( - self.delBorderObjAction - ) - editToolBar.addAction(self.delNewObjAction) - self.delNewObjAction.button = editToolBar.widgetForAction( - self.delNewObjAction - ) - - self.addDelRoiAction.toolbar = editToolBar - self.functionsNotTested3D.append(self.addDelRoiAction) - - self.addDelPolyLineRoiAction.toolbar = editToolBar - self.functionsNotTested3D.append(self.addDelPolyLineRoiAction) - - self.delBorderObjAction.toolbar = editToolBar - self.functionsNotTested3D.append(self.delBorderObjAction) - - self.delNewObjAction.toolbar = editToolBar - # self.functionsNotTested3D.append(self.delNewObjAction) so id this doesnt work in 3d i dont know anymore - - editToolBar.addAction(self.repeatTrackingAction) - - self.manualTrackingAction = editToolBar.addWidget( - self.manualTrackingButton - ) - - self.functionsNotTested3D.append(self.repeatTrackingAction) - self.functionsNotTested3D.append(self.manualTrackingAction) - - self.reinitLastSegmFrameAction = QAction(self) - self.reinitLastSegmFrameAction.setIcon(QIcon(":reinitLastSegm.svg")) - self.reinitLastSegmFrameAction.setVisible(False) - editToolBar.addAction(self.reinitLastSegmFrameAction) - editToolBar.setVisible(False) - self.reinitLastSegmFrameAction.toolbar = editToolBar - self.functionsNotTested3D.append(self.reinitLastSegmFrameAction) - - - self.editLin_TreeBar = widgets.ToolBar("Lin Tree Edit", self) - self.editLin_TreeBar.setContextMenuPolicy(Qt.PreventContextMenu) - - self.addToolBar(self.editLin_TreeBar) - self.editLin_TreeGroup = QButtonGroup() - self.editLin_TreeGroup.setExclusive(True) - - self.findNextMotherButton = QToolButton(self) - self.findNextMotherButton.setIcon(QIcon(":magnGlass.svg")) - self.findNextMotherButton.setCheckable(True) - self.editLin_TreeBar.addWidget(self.findNextMotherButton) - self.editLin_TreeGroup.addButton(self.findNextMotherButton) - self.findNextMotherButton.setShortcut('F') - self.widgetsWithShortcut['Find next potential mother (lineage tree)'] = self.findNextMotherButton - - self.unknownLineageButton = QToolButton(self) - self.unknownLineageButton.setIcon(QIcon(":history.svg")) - self.unknownLineageButton.setCheckable(True) - self.editLin_TreeBar.addWidget(self.unknownLineageButton) - self.editLin_TreeGroup.addButton(self.unknownLineageButton) - self.unknownLineageButton.setShortcut('U') - self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.unknownLineageButton - - self.noToolLinTreeButton = QToolButton(self) - self.noToolLinTreeButton.setIcon(QIcon(":arrow_cursor.svg")) - self.noToolLinTreeButton.setCheckable(True) - self.editLin_TreeBar.addWidget(self.noToolLinTreeButton) - self.editLin_TreeGroup.addButton(self.noToolLinTreeButton) - self.noToolLinTreeButton.setShortcut('N') - self.widgetsWithShortcut['No tool (lineage tree)'] = self.noToolLinTreeButton - - self.propagateLinTreeButton = QToolButton(self) - self.propagateLinTreeButton.setIcon(QIcon(":compute.svg")) - self.editLin_TreeBar.addWidget(self.propagateLinTreeButton) - self.propagateLinTreeButton.setShortcut('P') - self.widgetsWithShortcut['Propagate (lineage tree)'] = self.propagateLinTreeButton - self.propagateLinTreeButton.clicked.connect(self.propagateLinTreeAction) - - self.viewLinTreeInfoButton = QToolButton(self) - self.viewLinTreeInfoButton.setIcon(QIcon(":addCustomAnnotation.svg")) - self.editLin_TreeBar.addWidget(self.viewLinTreeInfoButton) - self.viewLinTreeInfoButton.setShortcut('S') - self.widgetsWithShortcut['View Changes (lineage tree)'] = self.viewLinTreeInfoButton - self.viewLinTreeInfoButton.clicked.connect(self.viewLinTreeInfoAction) - - - modes_available = [ - 'Segmentation and Tracking', - 'Cell cycle analysis', - 'Viewer', - 'Custom annotations', - 'Normal division: Lineage tree' - ] - self.modeItems = modes_available - - self.modeActionGroup = QActionGroup(self.modeMenu) - for mode in self.modeItems: - action = QAction(mode) - action.setCheckable(True) - self.modeActionGroup.addAction(action) - self.modeMenu.addAction(action) - if mode == 'Viewer': - action.setChecked(True) - - self.editToolBar = editToolBar - self.editToolBar.setVisible(False) - self.navigateToolBar.setVisible(False) - self.editLin_TreeBar.setVisible(False) - - self.gui_createAnnotateToolbar() - - def gui_createAnnotateToolbar(self): - # Edit toolbar - self.annotateToolbar = widgets.ToolBar("Custom annotations", self) - self.annotateToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - self.addToolBar(Qt.LeftToolBarArea, self.annotateToolbar) - self.annotateToolbar.addAction(self.loadCustomAnnotationsAction) - self.annotateToolbar.addAction(self.addCustomAnnotationAction) - self.annotateToolbar.addAction(self.viewAllCustomAnnotAction) - self.annotateToolbar.setVisible(False) - - def gui_createLazyLoader(self): - if not self.lazyLoader is None: - return - - self.lazyLoaderThread = QThread() - self.lazyLoaderMutex = QMutex() - self.lazyLoaderWaitCond = QWaitCondition() - self.waitReadH5cond = QWaitCondition() - self.readH5mutex = QMutex() - self.lazyLoader = workers.LazyLoader( - self.lazyLoaderMutex, self.lazyLoaderWaitCond, - self.waitReadH5cond, self.readH5mutex - ) - self.lazyLoader.moveToThread(self.lazyLoaderThread) - self.lazyLoader.wait = True - - self.lazyLoader.signals.finished.connect(self.lazyLoaderThread.quit) - self.lazyLoader.signals.finished.connect(self.lazyLoader.deleteLater) - self.lazyLoaderThread.finished.connect(self.lazyLoaderThread.deleteLater) - - self.lazyLoader.signals.progress.connect(self.workerProgress) - self.lazyLoader.signals.sigLoadingNewChunk.connect(self.loadingNewChunk) - self.lazyLoader.sigLoadingFinished.connect(self.lazyLoaderFinished) - self.lazyLoader.signals.critical.connect(self.lazyLoaderCritical) - self.lazyLoader.signals.finished.connect(self.lazyLoaderWorkerClosed) - - self.lazyLoaderThread.started.connect(self.lazyLoader.run) - self.lazyLoaderThread.start() - - def gui_createStoreStateWorker(self): - self.storeStateWorker = None - return - self.storeStateThread = QThread() - self.autoSaveMutex = QMutex() - self.autoSaveWaitCond = QWaitCondition() - - self.storeStateWorker = workers.StoreGuiStateWorker( - self.autoSaveMutex, self.autoSaveWaitCond - ) - - self.storeStateWorker.moveToThread(self.storeStateThread) - self.storeStateWorker.finished.connect(self.storeStateThread.quit) - self.storeStateWorker.finished.connect(self.storeStateWorker.deleteLater) - self.storeStateThread.finished.connect(self.storeStateThread.deleteLater) - - self.storeStateWorker.sigDone.connect(self.storeStateWorkerDone) - self.storeStateWorker.progress.connect(self.workerProgress) - self.storeStateWorker.finished.connect(self.storeStateWorkerClosed) - - self.storeStateThread.started.connect(self.storeStateWorker.run) - self.storeStateThread.start() - - self.logger.info('Store state worker started.') - - def storeStateWorkerDone(self): - if self.storeStateWorker.callbackOnDone is not None: - self.storeStateWorker.callbackOnDone() - self.storeStateWorker.callbackOnDone = None - - def storeStateWorkerClosed(self): - self.logger.info('Store state worker started.') - - def gui_createAutoSaveWorker(self): - if not hasattr(self, 'data'): - return - - if not self.isDataLoaded: - return - - if self.autoSaveActiveWorkers: - garbage = self.autoSaveActiveWorkers[-1] - self.autoSaveGarbageWorkers.append(garbage) - worker = garbage[0] - worker._stop() - - posData = self.data[self.pos_i] - autoSaveThread = QThread() - self.autoSaveMutex = QMutex() - self.autoSaveWaitCond = QWaitCondition() - - savedSegmData = posData.segm_data.copy() - autoSaveWorker = workers.AutoSaveWorker( - self.autoSaveMutex, self.autoSaveWaitCond, savedSegmData - ) - autoSaveWorker.isAutoSaveON = self.autoSaveToggle.isChecked() - - autoSaveWorker.moveToThread(autoSaveThread) - autoSaveWorker.finished.connect(autoSaveThread.quit) - autoSaveWorker.finished.connect(autoSaveWorker.deleteLater) - autoSaveThread.finished.connect(autoSaveThread.deleteLater) - - autoSaveWorker.sigDone.connect(self.autoSaveWorkerDone) - autoSaveWorker.progress.connect(self.workerProgress) - autoSaveWorker.finished.connect(self.autoSaveWorkerClosed) - autoSaveWorker.sigAutoSaveCannotProceed.connect( - self.turnOffAutoSaveWorker - ) - - autoSaveThread.started.connect(autoSaveWorker.run) - autoSaveThread.start() - - self.autoSaveActiveWorkers.append((autoSaveWorker, autoSaveThread)) - - self.logger.info('Autosaving worker started.') - - def autoSaveWorkerStartTimer(self, worker, posData): - self.autoSaveWorkerTimer = QTimer() - self.autoSaveWorkerTimer.timeout.connect( - partial(self.autoSaveWorkerTimerCallback, worker, posData) - ) - self.autoSaveWorkerTimer.start(150) - - def autoSaveWorkerTimerCallback(self, worker, posData): - if not self.isSaving: - self.autoSaveWorkerTimer.stop() - worker._enqueue(posData) - - def autoSaveWorkerDone(self): - self.setStatusBarLabel(log=False) - - def ccaCheckerWorkerDone(self): - self.setStatusBarLabel(log=False) - - def preprocWorkerIsQueueEmpty(self, isEmpty: bool): - if isEmpty: - self.preprocessDialog.appliedFinished() - else: - self.preprocessDialog.setDisabled(True) - self.preprocessDialog.infoLabel.setText( - 'Computing preview...
' - '(Feel free to use Cell-ACDC while waiting)' - ) - - def preprocWorkerPreviewDone( - self, processed_data: np.ndarray, - key: Tuple[int, int, Union[int, str]] - ): - pos_i, frame_i, z_slice = key - posData = self.data[pos_i] - if not hasattr(posData, 'preproc_img_data'): - posData.preproc_img_data = preprocess.PreprocessedData( - image_data=np.zeros(posData.img_data.shape) - ) - - posData.preproc_img_data[frame_i][z_slice] = processed_data - self.img1.updateMinMaxValuesPreprocessedData( - self.data, pos_i, frame_i, z_slice - ) - - self.setImageImg1() - - def preprocWorkerDone( - self, - processed_data: np.ndarray, - how: str, - ): - self.setStatusBarLabel(log=False) - self.preprocessDialog.appliedFinished() - - posData = self.data[self.pos_i] - if not hasattr(posData, 'preproc_img_data'): - posData.preproc_img_data = preprocess.PreprocessedData() - - if how == 'current_image': - if posData.SizeZ > 1: - z_slice = self.z_slice_index() - posData.preproc_img_data[posData.frame_i][z_slice] = ( - processed_data - ) - else: - posData.preproc_img_data[posData.frame_i] = processed_data - z_slice = 0 - self.img1.updateMinMaxValuesPreprocessedData( - self.data, self.pos_i, posData.frame_i, z_slice - ) - elif how == 'z_stack': - for z_slice, processed_img in enumerate(processed_data): - posData.preproc_img_data[posData.frame_i][z_slice] = ( - processed_img - ) - self.img1.updateMinMaxValuesPreprocessedData( - self.data, self.pos_i, posData.frame_i, z_slice - ) - self.img1.updateMinMaxValuesPreprocessedProjections( - self.data, self.pos_i, posData.frame_i - ) - elif how == 'all_frames': - for frame_i, processed_frame in enumerate(processed_data): - if processed_frame.ndim == 2: - processed_frame = (processed_frame,) - - for z_slice, processed_img in enumerate(processed_frame): - posData.preproc_img_data[frame_i][z_slice] = ( - processed_img - ) - self.img1.updateMinMaxValuesPreprocessedData( - self.data, self.pos_i, frame_i, z_slice - ) - self.img1.updateMinMaxValuesPreprocessedProjections( - self.data, self.pos_i, frame_i - ) - elif how == 'all_pos': - for pos_i, processed_pos_data in enumerate(processed_data): - if processed_pos_data.ndim == 2: - processed_pos_data = (processed_pos_data,) - - posData = self.data[pos_i] - if not hasattr(posData, 'preproc_img_data'): - posData.preproc_img_data = preprocess.PreprocessedData() - for z_slice, processed_img in enumerate(processed_pos_data): - posData.preproc_img_data[0][z_slice] = ( - processed_img - ) - self.img1.updateMinMaxValuesPreprocessedData( - self.data, pos_i, 0, z_slice - ) - - if posData.SizeZ > 1: - self.img1.updateMinMaxValuesPreprocessedProjections( - self.data, pos_i, frame_i - ) - - if not self.viewPreprocDataToggle.isChecked(): - self.viewPreprocDataToggle.setChecked(True) - else: - self.setImageImg1() - - def goToFrameNumber(self, frame_n): - posData = self.data[self.pos_i] - posData.frame_i = frame_n - 1 - self.get_data() - self.updateAllImages() - self.updateScrollbars() - - def warnCcaIntegrity(self, txt, category): - self.logger.warning(f'{html_utils.to_plain_text(txt)}') - - if 'disable_all' in self.disabled_cca_warnings: - return - - if category in self.disabled_cca_warnings: - return - - if txt in self.disabled_cca_warnings: - return - - if self.isWarningCcaIntegrity: - # Some other warning is still open --> avoid opening another one - return - - self.isWarningCcaIntegrity = True - disabled_warning = _warnings.warn_cca_integrity( - txt, category, self, - go_to_frame_callback=self.goToFrameNumber - ) - if disabled_warning: - self.disabled_cca_warnings.add(disabled_warning) - - self.isWarningCcaIntegrity = False - - def fixWillDivide(self, warning_txt, IDs_will_divide_wrong): - self.logger.info(warning_txt) - self.logger.info('Fixing `will_divide` information...') - - global_cca_df = self.getConcatCcaDf() - global_cca_df = ( - global_cca_df.reset_index() - .set_index(['Cell_ID', 'generation_num']) - ) - global_cca_df.loc[IDs_will_divide_wrong, 'will_divide'] = 0 - global_cca_df = ( - global_cca_df.reset_index() - .set_index(['frame_i', 'Cell_ID']) - ) - self.storeFromConcatCcaDf(global_cca_df) - - def autoSaveWorkerClosed(self, worker): - if self.autoSaveActiveWorkers: - self.logger.info('Autosaving worker closed.') - try: - self.autoSaveActiveWorkers.remove(worker) - except Exception as e: - pass - - def ccaCheckerWorkerClosed(self, worker): - self.logger.info('Cell cycle annotations integrity checker stopped.') - self.ccaCheckerRunning = False - - def preprocWorkerClosed(self, worker): - self.logger.info('Pre-processing worker stopped.') - - def gui_createMainLayout(self): - mainLayout = QGridLayout() - row, col = 0, 1 # Leave column 1 for the overlay labels gradient editor - mainLayout.addLayout(self.leftSideDocksLayout, row, col, 2, 1) - - row = 0 - col = 2 - mainLayout.addWidget(self.graphLayout, row, col, 1, 2) - mainLayout.setRowStretch(row, 2) - - col = 4 # graphLayout spans two columns - mainLayout.addWidget(self.labelsGrad, row, col) - - col = 5 - mainLayout.addLayout(self.rightSideDocksLayout, row, col, 2, 1) - - col = 2 - row += 1 - self.resizeBottomLayoutLine = widgets.VerticalResizeHline() - mainLayout.addWidget(self.resizeBottomLayoutLine, row, col, 1, 2) - self.resizeBottomLayoutLine.dragged.connect( - self.resizeBottomLayoutLineDragged - ) - self.resizeBottomLayoutLine.clicked.connect( - self.resizeBottomLayoutLineClicked - ) - self.resizeBottomLayoutLine.released.connect( - self.resizeBottomLayoutLineReleased - ) - - # row += 1 - # mainLayout.addItem(QSpacerItem(5,5), row+1, col, 1, 2) - - # row, col = 1, 2 - # mainLayout.addLayout( - # self.bottomLayout, row, col, 1, 2, alignment=Qt.AlignLeft - # ) - - row += 1 - mainLayout.addWidget(self.bottomScrollArea, row, col, 1, 2) - mainLayout.setRowStretch(row, 0) - - # row, col = 2, 1 - # mainLayout.addWidget(self.terminal, row, col, 1, 4) - # self.terminal.hide() - - return mainLayout - - def gui_createRegionPropsDockWidget(self, side=Qt.LeftDockWidgetArea): - self.propsDockWidget = QDockWidget('Cell-ACDC objects', self) - self.guiTabControl = widgets.guiTabControl(self.propsDockWidget) - - # self.guiTabControl.setFont(_font) - - self.propsDockWidget.setWidget(self.guiTabControl) - self.propsDockWidget.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetFloatable - | QDockWidget.DockWidgetFeature.DockWidgetMovable - ) - self.propsDockWidget.setAllowedAreas( - Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea - ) - - self.addDockWidget(side, self.propsDockWidget) - self.propsDockWidget.hide() - - def gui_createControlsToolbar(self): - self.controlToolBars = [] - self.addToolBarBreak() - - # Edit toolbar - modeToolBar = widgets.ToolBar("Mode", self) - self.addToolBar(modeToolBar) - - self.modeComboBox = widgets.ComboBox() - self.modeComboBox.addItems(self.modeItems) - self.modeComboBoxLabel = QLabel(' Mode: ') - self.modeComboBoxLabel.setBuddy(self.modeComboBox) - modeToolBar.addWidget(self.modeComboBoxLabel) - modeToolBar.addWidget(self.modeComboBox) - modeToolBar.setVisible(False) - - self.modeToolBar = modeToolBar - - self.overlayToolbar = widgets.OverlayToolbar(parent=self) - self.addToolBar(Qt.TopToolBarArea, self.overlayToolbar) - self.overlayToolbar.setVisible(False) - self.overlayToolbar.sigSetTranspacency.connect( - self.setOverlayTransparency - ) - self.overlayToolbar.sigSetSingleChannel.connect( - self.setOverlaySingleChannel - ) - - self.autoPilotZoomToObjToolbar = widgets.ToolBar( - "Auto-zoom to objects", self - ) - self.autoPilotZoomToObjToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - self.autoPilotZoomToObjToolbar.setMovable(False) - self.addToolBar(Qt.TopToolBarArea, self.autoPilotZoomToObjToolbar) - # self.autoPilotZoomToObjToolbar.setIconSize(QSize(16, 16)) - self.autoPilotZoomToObjToolbar.setVisible(False) - self.autoPilotZoomToObjToolbar.keepVisibleWhenActive = True - self.controlToolBars.append(self.autoPilotZoomToObjToolbar) - - # Highlighted ID or searched ID toolbar - self.highlightIDToolbar = widgets.HighlightedIDToolbar( - parent=self - ) - self.addToolBar(Qt.TopToolBarArea, self.highlightIDToolbar) - self.highlightIDToolbar.setVisible(False) - self.highlightIDToolbar.keepVisibleWhenActive = True - self.controlToolBars.append(self.highlightIDToolbar) - - self.highlightIDToolbar.sigIDChanged.connect( - self.setHighlighedIDfromToolbar - ) - - # Widgets toolbar - brushEraserToolBar = widgets.ToolBar("Widgets", self) - self.addToolBar(Qt.TopToolBarArea, brushEraserToolBar) - self.controlToolBars.append(brushEraserToolBar) - - self.editIDspinbox = widgets.SpinBox() - # self.editIDspinbox.setMaximum(2**32-1) - editIDLabel = QLabel(' ID: ') - self.editIDLabelAction = brushEraserToolBar.addWidget(editIDLabel) - self.editIDspinboxAction = brushEraserToolBar.addWidget( - self.editIDspinbox - ) - self.editIDLabelAction.setVisible(False) - self.editIDspinboxAction.setVisible(False) - self.editIDspinboxAction.setDisabled(True) - self.editIDLabelAction.setDisabled(True) - - brushEraserToolBar.addWidget(QLabel(' ')) - self.autoIDcheckbox = QCheckBox('Auto-ID') - self.autoIDcheckbox.setChecked(True) - self.autoIDcheckboxAction = brushEraserToolBar.addWidget(self.autoIDcheckbox) - self.autoIDcheckboxAction.setVisible(False) - - self.brushSizeSpinbox = widgets.SpinBox( - disableKeyPress=True, - allowNegative=False - ) - self.brushSizeSpinbox.setValue(4) - brushSizeLabel = QLabel(' Size: ') - brushSizeLabel.setBuddy(self.brushSizeSpinbox) - self.brushSizeLabelAction = brushEraserToolBar.addWidget(brushSizeLabel) - self.brushSizeAction = brushEraserToolBar.addWidget(self.brushSizeSpinbox) - self.brushSizeLabelAction.setVisible(False) - self.brushSizeAction.setVisible(False) - - brushEraserToolBar.addWidget(QLabel(' ')) - self.brushAutoFillCheckbox = QCheckBox('Auto-fill holes') - self.brushAutoFillAction = brushEraserToolBar.addWidget( - self.brushAutoFillCheckbox - ) - self.brushAutoFillAction.setVisible(False) - if 'brushAutoFill' in self.df_settings.index: - checked = self.df_settings.at['brushAutoFill', 'value'] == 'Yes' - self.brushAutoFillCheckbox.setChecked(checked) - - brushEraserToolBar.addWidget(QLabel(' ')) - self.brushAutoHideCheckbox = QCheckBox('Hide objects when hovering') - self.brushAutoHideAction = brushEraserToolBar.addWidget( - self.brushAutoHideCheckbox - ) - self.brushAutoHideCheckbox.setChecked(True) - self.brushAutoHideAction.setVisible(False) - if 'brushAutoHide' in self.df_settings.index: - checked = self.df_settings.at['brushAutoHide', 'value'] == 'Yes' - self.brushAutoHideCheckbox.setChecked(checked) - - brushEraserToolBar.setVisible(False) - self.brushEraserToolBar = brushEraserToolBar - - self.wandControlsToolbar = widgets.WandControlsToolbar( - parent=self - ) - - self.addToolBar(Qt.TopToolBarArea , self.wandControlsToolbar) - self.wandControlsToolbar.setVisible(False) - self.controlToolBars.append(self.wandControlsToolbar) - - separatorW = 5 - self.labelRoiToolbar = widgets.ToolBar("Magic labeller controls", self) - self.labelRoiToolbar.addWidget(QLabel('ROI n. of z-slices: ')) - self.labelRoiZdepthSpinbox = widgets.SpinBox(disableKeyPress=True) - self.labelRoiToolbar.addWidget(self.labelRoiZdepthSpinbox) - - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - self.labelRoiToolbar.addWidget(widgets.QVLine()) - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - - self.labelRoiReplaceExistingObjectsCheckbox = QCheckBox( - 'Remove objs. touched by new ones' - ) - self.labelRoiToolbar.addWidget(self.labelRoiReplaceExistingObjectsCheckbox) - self.labelRoiAutoClearBorderCheckbox = QCheckBox( - 'Clear ROI borders before adding new objs.' - ) - self.labelRoiAutoClearBorderCheckbox.setChecked(True) - self.labelRoiToolbar.addWidget(self.labelRoiAutoClearBorderCheckbox) - - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - self.labelRoiToolbar.addWidget(widgets.QVLine()) - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - - group = QButtonGroup() - group.setExclusive(True) - self.labelRoiIsRectRadioButton = QRadioButton('Rect. ROI') - self.labelRoiIsRectRadioButton.setChecked(True) - self.labelRoiIsFreeHandRadioButton = QRadioButton('Freehand ROI') - self.labelRoiIsCircularRadioButton = QRadioButton('Circular ROI') - group.addButton(self.labelRoiIsRectRadioButton) - group.addButton(self.labelRoiIsFreeHandRadioButton) - group.addButton(self.labelRoiIsCircularRadioButton) - self.labelRoiToolbar.addWidget(self.labelRoiIsRectRadioButton) - self.labelRoiToolbar.addWidget(self.labelRoiIsFreeHandRadioButton) - self.labelRoiToolbar.addWidget(self.labelRoiIsCircularRadioButton) - self.labelRoiToolbar.addWidget(QLabel(' | Radius (pixel): ')) - self.labelRoiCircularRadiusSpinbox = widgets.SpinBox(disableKeyPress=True) - self.labelRoiCircularRadiusSpinbox.setMinimum(1) - self.labelRoiCircularRadiusSpinbox.setValue(11) - self.labelRoiCircularRadiusSpinbox.setDisabled(True) - self.labelRoiToolbar.addWidget(self.labelRoiCircularRadiusSpinbox) - - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - self.labelRoiToolbar.addWidget(widgets.QVLine()) - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - - startFrameLabel = QLabel('Start frame n. ') - startFrameLabel.setDisabled(True) - self.labelRoiToolbar.addWidget(startFrameLabel) - self.labelRoiStartFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) - self.labelRoiStartFrameNoSpinbox.label = startFrameLabel - self.labelRoiStartFrameNoSpinbox.setValue(1) - self.labelRoiStartFrameNoSpinbox.setMinimum(1) - self.labelRoiToolbar.addWidget(self.labelRoiStartFrameNoSpinbox) - self.labelRoiStartFrameNoSpinbox.setDisabled(True) - - self.labelRoiFromCurrentFrameAction = QAction(self) - self.labelRoiFromCurrentFrameAction.setText('Segment from current frame') - self.labelRoiFromCurrentFrameAction.setIcon(QIcon(":frames_current.svg")) - self.labelRoiToolbar.addAction(self.labelRoiFromCurrentFrameAction) - self.labelRoiFromCurrentFrameAction.setDisabled(True) - - self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=3)) - stopFrameLabel = QLabel(' Stop frame n. ') - stopFrameLabel.setDisabled(True) - self.labelRoiToolbar.addWidget(stopFrameLabel) - self.labelRoiStopFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) - self.labelRoiStopFrameNoSpinbox.label = stopFrameLabel - self.labelRoiStopFrameNoSpinbox.setValue(1) - self.labelRoiStopFrameNoSpinbox.setMinimum(1) - self.labelRoiToolbar.addWidget(self.labelRoiStopFrameNoSpinbox) - self.labelRoiStopFrameNoSpinbox.setDisabled(True) - - self.labelRoiToEndFramesAction = QAction(self) - self.labelRoiToEndFramesAction.setText('Segment all remaining frames') - self.labelRoiToEndFramesAction.setIcon(QIcon(":frames_end.svg")) - self.labelRoiToolbar.addAction(self.labelRoiToEndFramesAction) - self.labelRoiToEndFramesAction.setDisabled(True) - - self.labelRoiTrangeCheckbox = QCheckBox('Segment range of frames') - self.labelRoiToolbar.addWidget(self.labelRoiTrangeCheckbox) - - self.labelRoiViewCurrentModelAction = QAction(self) - self.labelRoiViewCurrentModelAction.setText( - 'View current model\'s parameters' - ) - self.labelRoiViewCurrentModelAction.setIcon(QIcon(":view.svg")) - self.labelRoiToolbar.addAction(self.labelRoiViewCurrentModelAction) - self.labelRoiViewCurrentModelAction.setDisabled(True) - - self.addToolBar(Qt.TopToolBarArea, self.labelRoiToolbar) - self.controlToolBars.append(self.labelRoiToolbar) - self.labelRoiToolbar.setVisible(False) - self.labelRoiTypesGroup = group - - self.loadLabelRoiLastParams() - - self.labelRoiTrangeCheckbox.toggled.connect( - self.labelRoiTrangeCheckboxToggled - ) - self.labelRoiReplaceExistingObjectsCheckbox.toggled.connect( - self.storeLabelRoiParams - ) - self.labelRoiIsCircularRadioButton.toggled.connect( - self.labelRoiIsCircularRadioButtonToggled - ) - self.labelRoiCircularRadiusSpinbox.valueChanged.connect( - self.updateLabelRoiCircularSize - ) - self.labelRoiCircularRadiusSpinbox.valueChanged.connect( - self.storeLabelRoiParams - ) - self.labelRoiZdepthSpinbox.valueChanged.connect( - self.storeLabelRoiParams - ) - self.labelRoiAutoClearBorderCheckbox.toggled.connect( - self.storeLabelRoiParams - ) - group.buttonToggled.connect(self.storeLabelRoiParams) - - self.labelRoiToEndFramesAction.triggered.connect( - self.labelRoiToEndFramesTriggered - ) - self.labelRoiFromCurrentFrameAction.triggered.connect( - self.labelRoiFromCurrentFrameTriggered - ) - self.labelRoiViewCurrentModelAction.triggered.connect( - self.labelRoiViewCurrentModel - ) - - self.keepIDsToolbar = widgets.ToolBar("Keep IDs controls", self) - self.keepIDsConfirmAction = QAction() - self.keepIDsConfirmAction.setIcon(QIcon(":greenTick.svg")) - self.keepIDsConfirmAction.setToolTip('Apply "keep IDs" selection') - self.keepIDsConfirmAction.setDisabled(True) - self.keepIDsToolbar.addAction(self.keepIDsConfirmAction) - self.keepIDsToolbar.addWidget(QLabel(' IDs to keep: ')) - instructionsText = ( - ' (Separate IDs by comma. Use a dash to denote a range of IDs)' - ) - instructionsLabel = QLabel(instructionsText) - self.keptIDsLineEdit = widgets.KeepIDsLineEdit( - instructionsLabel, parent=self - ) - self.keepIDsToolbar.addWidget(self.keptIDsLineEdit) - self.keepIDsToolbar.addWidget(instructionsLabel) - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - self.keepIDsToolbar.addWidget(spacer) - self.addToolBar(Qt.TopToolBarArea, self.keepIDsToolbar) - self.keepIDsToolbar.setVisible(False) - self.controlToolBars.append(self.keepIDsToolbar) - - self.keptIDsLineEdit.sigEnterPressed.connect(self.applyKeepObjects) - self.keptIDsLineEdit.sigIDsChanged.connect(self.updateKeepIDs) - self.keepIDsConfirmAction.triggered.connect(self.applyKeepObjects) - - # closeToolbarAction = QAction( - # QIcon(":cancelButton.svg"), "Close toolbar...", self - # ) - # closeToolbarAction.triggered.connect(self.closeToolbars) - # self.autoPilotZoomToObjToolbar.addAction(closeToolbarAction) - - self.autoPilotZoomToObjToolbar.addWidget(widgets.QVLine()) - self.autoPilotZoomToObjToolbar.addWidget( - widgets.QHWidgetSpacer(width=separatorW) - ) - - spinBox = widgets.SpinBox() - spinBox.setMinimum(1) - spinBox.label = QLabel(' Zoom to ID: ') - spinBox.labelAction = self.autoPilotZoomToObjToolbar.addWidget(spinBox.label) - spinBox.action = self.autoPilotZoomToObjToolbar.addWidget(spinBox) - spinBox.editingFinished.connect(self.zoomToObj) - spinBox.sigUpClicked.connect(self.autoZoomNextObj) - spinBox.sigDownClicked.connect(self.autoZoomPrevObj) - self.autoPilotZoomToObjSpinBox = spinBox - toggle = widgets.Toggle() - self.autoPilotZoomToObjToggle = toggle - toggle.toggled.connect(self.autoPilotZoomToObjToggled) - toggle.label = QLabel(' Auto-pilot: ') - tooltip = ( - 'When auto-pilot is active, you can use Up/Down arrows to ' - 'automatically zoom to the next/previous object.\n\n' - 'Alternatively, you can type the ID of the object you want to ' - 'zoom to.' - ) - toggle.label.setToolTip(tooltip) - toggle.setToolTip(tooltip) - self.autoPilotZoomToObjToolbar.addWidget(toggle.label) - self.autoPilotZoomToObjToolbar.addWidget(toggle) - - self.pointsLayersToolbars = [] - - self.pointsLayersToolbar = widgets.PointsLayersToolbar(parent=self) - self.pointsLayersToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - - self.pointsLayersToolbar.sigAddPointsLayer.connect( - self.addPointsLayerTriggered - ) - - self.addToolBar(Qt.TopToolBarArea, self.pointsLayersToolbar) - - self.pointsLayersToolbar.setVisible(False) - self.pointsLayersToolbar.keepVisibleWhenActive = True - self.controlToolBars.append(self.pointsLayersToolbar) - - self.pointsLayersToolbars.append( - self.pointsLayersToolbar - ) - - self.manualTrackingToolbar = widgets.ManualTrackingToolBar( - "Manual tracking controls", self - ) - self.manualTrackingToolbar.sigIDchanged.connect(self.initGhostObject) - self.manualTrackingToolbar.sigDisableGhost.connect(self.clearGhost) - self.manualTrackingToolbar.sigClearGhostContour.connect( - self.clearGhostContour - ) - self.manualTrackingToolbar.sigClearGhostMask.connect( - self.clearGhostMask - ) - self.manualTrackingToolbar.sigGhostOpacityChanged.connect( - self.updateGhostMaskOpacity - ) - - self.addToolBar(Qt.TopToolBarArea, self.manualTrackingToolbar) - self.manualTrackingToolbar.setVisible(False) - self.controlToolBars.append(self.manualTrackingToolbar) - - self.manualBackgroundToolbar = widgets.ManualBackgroundToolBar( - "Manual background controls", self - ) - self.manualBackgroundToolbar.sigIDchanged.connect( - self.initManualBackgroundObject - ) - self.addToolBar(Qt.TopToolBarArea, self.manualBackgroundToolbar) - self.manualBackgroundToolbar.setVisible(False) - self.controlToolBars.append(self.manualBackgroundToolbar) - - # Copy lost object contour toolbar - self.copyLostObjToolbar = widgets.CopyLostObjectToolbar( - "Copy lost object controls", self - ) - for name, action in self.copyLostObjToolbar.widgetsWithShortcut.items(): - self.widgetsWithShortcut[name] = action - - self.copyLostObjToolbar.sigCopyAllObjects.connect( - self.copyAllLostObjects - ) - - self.addToolBar(Qt.TopToolBarArea, self.copyLostObjToolbar) - self.copyLostObjToolbar.setVisible(False) - # self.controlToolBars.append(self.copyLostObjToolbar) - - # Copy lost object contour toolbar - self.drawClearRegionToolbar = widgets.DrawClearRegionToolbar( - "Draw freehand region and clear objects controls", self - ) - - self.addToolBar(Qt.TopToolBarArea, self.drawClearRegionToolbar) - self.drawClearRegionToolbar.setVisible(False) - self.controlToolBars.append(self.drawClearRegionToolbar) - - try: - addNewIDToggleState = self.df_settings.at['addNewIDsWhitelistToggle', 'value'] == 'Yes' - except KeyError: - addNewIDToggleState = True - - self.whitelistIDsToolbar = widgets.WhitelistIDsToolbar( - addNewIDToggleState, self - ) - for name, action in self.whitelistIDsToolbar.widgetsWithShortcut.items(): - self.widgetsWithShortcut[name] = action - - self.addToolBar(Qt.TopToolBarArea, self.whitelistIDsToolbar) - self.whitelistIDsToolbar.setVisible(False) - self.controlToolBars.append(self.whitelistIDsToolbar) - - self.magicPromptsToolbar = widgets.MagicPromptsToolbar(self) - for name, action in self.magicPromptsToolbar.widgetsWithShortcut.items(): - self.widgetsWithShortcut[name] = action - - self.magicPromptsToolbar.sigComputeOnZoom.connect( - self.magicPromptsComputeOnZoomTriggered - ) - self.magicPromptsToolbar.sigComputeOnImage.connect( - self.magicPromptsComputeOnImageTriggered - ) - self.magicPromptsToolbar.sigInitSelectedModel.connect( - self.magicPromptsInitModel - ) - self.magicPromptsToolbar.sigViewModelParams.connect( - self.viewSetMagicPromptModelParams - ) - self.magicPromptsToolbar.sigClearPoints.connect( - partial(self.magicPromptsClearPoints, only_zoom=False) - ) - self.magicPromptsToolbar.sigClearPointsOnZmom.connect( - partial(self.magicPromptsClearPoints, only_zoom=True) - ) - self.magicPromptsToolbar.sigInterpolateZslice.connect( - self.magicPromptsInterpolateZsliceToggled - ) - - self.addToolBar(Qt.TopToolBarArea, self.magicPromptsToolbar) - self.magicPromptsToolbar.setVisible(False) - self.magicPromptsToolbar.keepVisibleWhenActive = True - self.controlToolBars.append(self.magicPromptsToolbar) - - self.promptSegmentPointsLayerToolbar = ( - widgets.PromptableModelPointsLayerToolbar(parent=self) - ) - self.promptSegmentPointsLayerToolbar.setContextMenuPolicy( - Qt.PreventContextMenu - ) - - self.addToolBar(Qt.TopToolBarArea, self.promptSegmentPointsLayerToolbar) - self.promptSegmentPointsLayerToolbar.setVisible(False) - - self.pointsLayersToolbars.append( - self.promptSegmentPointsLayerToolbar - ) - - # Second level toolbar - secondLevelToolbar = widgets.ToolBar("Second level toolbar", self) - self.addToolBar(Qt.TopToolBarArea, secondLevelToolbar) - self.delObjToolAction = QAction(self) - self.delObjToolAction.setIcon(QIcon(":del_obj_click.svg")) - self.delObjToolAction.setCheckable(True) - self.delObjToolAction.setToolTip( - 'Customisable delete object action\n\n' - 'Go to the `Settings --> Customise keyboard shortcuts...` menu ' - 'on the top menubar\n' - 'to customise the action required to delete ' - 'an object with a click.\n\n' - 'When working with 3D segmentations, to delete only the z-slice mask, hold "Shift" while clicking.' - ) - secondLevelToolbar.addAction(self.delObjToolAction) - secondLevelToolbar.setMovable(False) - self.secondLevelToolbar = secondLevelToolbar - self.secondLevelToolbar.setVisible(False) - - def gui_populateToolSettingsMenu(self): - brushHoverModeActionGroup = QActionGroup(self) - brushHoverModeActionGroup.setExclusive(True) - self.brushHoverCenterModeAction = QAction() - self.brushHoverCenterModeAction.setCheckable(True) - self.brushHoverCenterModeAction.setText( - 'Use center of the brush/eraser cursor to determine hover ID' - ) - self.brushHoverCircleModeAction = QAction() - self.brushHoverCircleModeAction.setCheckable(True) - self.brushHoverCircleModeAction.setText( - 'Use the entire circle of the brush/eraser cursor to determine hover ID' - ) - brushHoverModeActionGroup.addAction(self.brushHoverCenterModeAction) - brushHoverModeActionGroup.addAction(self.brushHoverCircleModeAction) - brushHoverModeMenu = self.settingsMenu.addMenu( - 'Brush/eraser cursor hovering mode' - ) - brushHoverModeMenu.addAction(self.brushHoverCenterModeAction) - brushHoverModeMenu.addAction(self.brushHoverCircleModeAction) - - if 'useCenterBrushCursorHoverID' not in self.df_settings.index: - self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' - - useCenterBrushCursorHoverID = self.df_settings.at[ - 'useCenterBrushCursorHoverID', 'value' - ] == 'Yes' - self.brushHoverCenterModeAction.setChecked(useCenterBrushCursorHoverID) - self.brushHoverCircleModeAction.setChecked(not useCenterBrushCursorHoverID) - - self.brushHoverCenterModeAction.toggled.connect( - self.useCenterBrushCursorHoverIDtoggled - ) - - self.settingsMenu.addSeparator() - - keepToolActiveNames = { - 'Segment range of frames': self.labelRoiTrangeCheckbox - } - for button in self.checkableQButtonsGroup.buttons(): - if button.toolTip() == "": - toolName = "MISSING" - continue - else: - toolName = re.findall(r'Name: (.*)', button.toolTip())[0] - keepToolActiveNames[toolName] = button - - keepToolActiveNames = dict(natsorted(keepToolActiveNames.items())) - - applyToNewFrameNames = { - 'Segmenting for lost IDs': self.segForLostIDsButton, - 'Delete bordering objects': self.delBorderObjAction.button, - 'Delete newly segmented objects': self.delNewObjAction.button, - } - - allToolsList = list(keepToolActiveNames.keys()) + list(applyToNewFrameNames.keys()) - allToolsList = natsorted(allToolsList) - - menus = {} - - for toolName in allToolsList: - menuItemText = f'{toolName} tool'.replace(' ', ' ') - menus[toolName] = self.settingsMenu.addMenu(menuItemText) - - self.keepToolActiveActions = dict() - self.applyToolNewFrameActions = dict() - self.applyToolNewFrameButtons = dict() - all_checked = True - - for toolName, button in keepToolActiveNames.items(): - menu = menus[toolName] - action = QAction(button) - action.setText('Keep tool active after using it') - action.setCheckable(True) - if toolName in self.df_settings.index: - action.setChecked(True) - else: - all_checked = False - action.toggled.connect(self.keepToolActiveActionToggled) - menu.addAction(action) - self.keepToolActiveActions[toolName] = action - - for toolName, button in applyToNewFrameNames.items(): - menu = menus[toolName] - action = QAction(button) - action.setText('Apply when visitng new frame') - action.setCheckable(True) - action.toggled.connect(self.applyToolNewFrameActionToggled) - menu.addAction(action) - self.applyToolNewFrameActions[toolName] = action - self.applyToolNewFrameButtons[toolName] = button - - for toolName in self.applyToolNewFrameActions.keys(): - settingString = toolName.strip() - settingString = toolName.replace(' ', '_') - settingString = f'{settingString}_applyNewFrame' - if settingString in self.df_settings.index: - val = self.df_settings.at[settingString, 'value'] - if val == 'applyNewFrame': - self.applyToolNewFrameActions[toolName].setChecked(True) - - self.settingsMenu.addSeparator() - - self.keepAllToolsActiveToggle = QAction() - self.keepAllToolsActiveToggle.setText( - 'Keep all tools active after using them' - ) - self.keepAllToolsActiveToggle.setCheckable(True) - self.keepAllToolsActiveToggle.setChecked(all_checked) - self.keepAllToolsActiveToggle.toggled.connect( - self.keepAllToolsActiveActionToggled - ) - self.settingsMenu.addAction(self.keepAllToolsActiveToggle) - self.settingsMenu.addSeparator() - - askHowFutureFramesMenu = self.settingsMenu.addMenu( - 'Ask how to propagate changes to future frames' - ) - self.askHowFutureFramesActions = {} - askHowFutureFramesActionsKeys = ( - 'Delete ID', - 'Exclude cell from analysis', - 'Annotate cell as dead', - 'Edit ID', - 'Keep ID' - ) - for key in askHowFutureFramesActionsKeys: - askHowFutureFramesAction = QAction() - askHowFutureFramesAction.setText(f'Ask for "{key}" action') - askHowFutureFramesAction.setCheckable(True) - askHowFutureFramesAction.setChecked(True) - askHowFutureFramesAction.setDisabled(True) - askHowFutureFramesMenu.addAction(askHowFutureFramesAction) - self.askHowFutureFramesActions[key] = askHowFutureFramesAction - - warningsMenu = self.settingsMenu.addMenu('Warnings and pop-ups') - self.warnLostCellsAction = QAction() - self.warnLostCellsAction.setText('Show pop-up warning for lost cells') - self.warnLostCellsAction.setCheckable(True) - self.warnLostCellsAction.setChecked(True) - warningsMenu.addAction(self.warnLostCellsAction) - - warnEditingWithAnnotTexts = { - 'Delete ID': 'Show warning when deleting ID that has annotations', - 'Separate IDs': 'Show warning when separating IDs that have annotations', - 'Edit ID': 'Show warning when editing ID that has annotations', - 'Annotate ID as dead': - 'Show warning when annotating dead ID that has annotations', - 'Delete ID with eraser': - 'Show warning when erasing ID that has annotations', - 'Add new ID with brush tool': - 'Show warning when adding new ID (brush) that has annotations', - 'Merge IDs': - 'Show warning when merging IDs that have annotations', - 'Add new ID with curvature tool': - 'Show warning when adding new ID (curv. tool) that has annotations', - 'Add new ID with magic-wand': - 'Show warning when adding new ID (magic-wand) that has annotations', - 'Delete IDs using ROI': - 'Show warning when using ROIs to delete IDs that have annotations', - } - self.warnEditingWithAnnotActions = {} - for key, desc in warnEditingWithAnnotTexts.items(): - action = QAction() - action.setText(desc) - action.setCheckable(True) - action.setChecked(True) - action.removeAnnot = False - self.warnEditingWithAnnotActions[key] = action - warningsMenu.addAction(action) - - - def gui_createStatusBar(self): - self.statusbar = self.statusBar() - # Permanent widget - self.wcLabel = QLabel('') - self.statusbar.addPermanentWidget(self.wcLabel) - - # self.toggleTerminalButton = widgets.ToggleTerminalButton() - # self.statusbar.addWidget(self.toggleTerminalButton) - # self.toggleTerminalButton.sigClicked.connect( - # self.gui_terminalButtonClicked - # ) - - self.statusBarLabel = QLabel('') - self.statusbar.addWidget(self.statusBarLabel) - - def gui_createTerminalWidget(self): - self.terminal = widgets.QLog(logger=self.logger) - self.terminal.connect() - self.terminalDock = QDockWidget('Log', self) - - self.terminalDock.setWidget(self.terminal) - self.terminalDock.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetFloatable | QDockWidget.DockWidgetFeature.DockWidgetMovable - ) - self.terminalDock.setAllowedAreas(Qt.BottomDockWidgetArea) - self.addDockWidget(Qt.BottomDockWidgetArea, self.terminalDock) - # self.terminalDock.widget().layout().setContentsMargins(10,0,10,0) - self.terminalDock.setVisible(False) - - @resetViewRange - def gui_terminalButtonClicked(self, terminalVisible): - self.terminalDock.setVisible(terminalVisible) - - def gui_createActions(self): - # File actions - self.segmNdimIndicator = widgets.ToolButtonTextIcon(text='') - self.segmNdimIndicator.setCheckable(True) - self.segmNdimIndicator.setChecked(True) - # self.segmNdimIndicator.setDisabled(True) - - if self.debug: - self.createEmptyDataAction = QAction(self) - self.createEmptyDataAction.setText("DEBUG: Create empty data") - - self.newWindowAction = QAction("New Window", self) - - self.newAction = QAction(self) - self.newAction.setText("&New Segmentation File...") - self.newAction.setIcon(QIcon(":file-new.svg")) - self.openFolderAction = QAction( - QIcon(":folder-open.svg"), "&Load Folder...", self - ) - self.openFileAction = QAction( - QIcon(":image.svg"),"&Open Image/Video File...", self - ) - self.manageVersionsAction = QAction( - QIcon(":manage_versions.svg"), "Load Older Versions...", self - ) - self.manageVersionsAction.setDisabled(True) - self.saveAction = QAction(QIcon(":file-save.svg"), "Save", self) - self.saveAsAction = QAction("Save as...", self) - self.exportToVideoAction = QAction("&Video...", self) - self.exportToImageAction = QAction("&Image...", self) - self.quickSaveAction = QAction("Save Only Segmentation Masks", self) - self.loadFluoAction = QAction("Load Fluorescence Images...", self) - self.loadPosAction = QAction("Load Different Position...", self) - # self.reloadAction = QAction( - # QIcon(":reload.svg"), "Reload segmentation file", self - # ) - self.nextAction = QAction('Next', self) - self.prevAction = QAction('Previous', self) - self.showInExplorerAction = QAction( - QIcon(":drawer.svg"), f"&{self.openFolderText}", self - ) - self.exitAction = QAction("&Exit", self) - self.undoAction = QAction(QIcon(":undo.svg"), "Undo", self) - self.redoAction = QAction(QIcon(":redo.svg"), "Redo", self) - # String-based key sequences - self.newWindowAction.setShortcut('Ctrl+Shift+N') - self.newAction.setShortcut('Ctrl+N') - self.openFolderAction.setShortcut('Ctrl+O') - self.loadPosAction.setShortcut('Shift+P') - self.saveAsAction.setShortcut('Ctrl+Shift+S') - self.exportToVideoAction.setShortcut('Ctrl+Shift+V') - self.exportToImageAction.setShortcut('Ctrl+Shift+I') - self.saveAction.setShortcut('Ctrl+Alt+S') - self.quickSaveAction.setShortcut('Ctrl+S') - self.undoAction.setShortcut('Ctrl+Z') - self.redoAction.setShortcut('Ctrl+Y') - self.nextAction.setShortcut(Qt.Key_Right) - self.prevAction.setShortcut(Qt.Key_Left) - self.addAction(self.nextAction) - self.addAction(self.prevAction) - # Help tips - newTip = "Create a new segmentation file" - self.newAction.setStatusTip(newTip) - self.newAction.setWhatsThis("Create a new empty segmentation file") - - self.autoPilotButton = QAction(self) - self.autoPilotButton.setIcon(QIcon(":auto-pilot.svg")) - self.autoPilotButton.setCheckable(True) - self.autoPilotButton.setShortcut('Ctrl+Shift+A') - - self.findIdAction = QAction(self) - self.findIdAction.setIcon(QIcon(":find.svg")) - self.findIdAction.setShortcut('Ctrl+F') - - self.zoomRectButton = QToolButton(self) - self.zoomRectButton.setIcon(QIcon(":zoom_rect.svg")) - self.zoomRectButton.setCheckable(True) - self.zoomRectButton.setShortcut('Shift+Z') - self.LeftClickButtons.append(self.zoomRectButton) - self.checkableButtons.append(self.zoomRectButton) - self.checkableQButtonsGroup.addButton(self.zoomRectButton) - self.widgetsWithShortcut['Zoom to rectangular area'] = ( - self.zoomRectButton - ) - - self.skipToNewIdAction = QAction(self) - self.skipToNewIdAction.setIcon(QIcon(":skip_forward_new_ID.svg")) - self.skipToNewIdAction.setShortcut( - widgets.KeySequenceFromText(Qt.Key_PageUp) - ) - - self.skipToNewIdAction.setDisabled(True) - - # Edit actions - models = myutils.get_list_of_models() - models = [*models, 'local_seg'] # Add local_seg for SegForLostIDsAction - self.segmActions = [] - self.modelNames = [] - self.acdcSegment_li = [] - self.models = [] - for model_name in models: - action = QAction(f"{model_name}...") - self.segmActions.append(action) - self.modelNames.append(model_name) - self.models.append(None) - self.acdcSegment_li.append(None) - action.setDisabled(True) - - self.addCustomModelFrameAction = QAction('Add custom model...', self) - self.addCustomModelVideoAction = QAction('Add custom model...', self) - - self.segmWithPromptableModelAction = QAction( - 'Select promptable model...', self - ) - self.addCustomPromptModelAction = QAction( - 'Add custom promptable model...', self - ) - - self.segmActionsVideo = [] - for model_name in models: - action = QAction(f"{model_name}...") - self.segmActionsVideo.append(action) - action.setDisabled(True) - - self.postProcessSegmAction = QAction( - "Segmentation post-processing...", self - ) - self.postProcessSegmAction.setDisabled(True) - self.postProcessSegmAction.setCheckable(True) - - self.EditSegForLostIDsSetSettings = QAction( - "Edit settings for Segmenting lost IDs...", self - ) - self.EditSegForLostIDsSetSettings.triggered.connect( - self.SegForLostIDsSetSettings - ) - - self.repeatTrackingAction = QAction( - QIcon(":repeat-tracking.svg"), "Repeat tracking", self - ) - self.repeatTrackingAction.setShortcut('Shift+T') - self.widgetsWithShortcut['Repeat Tracking'] = self.repeatTrackingAction - - - self.editRtTrackerParamsAction = QAction( - 'Edit real-time tracker parameters...', self - ) - - self.repeatTrackingMenuAction = QAction( - 'Track current frame with real-time tracker...', self - ) - self.repeatTrackingMenuAction.setDisabled(True) - self.repeatTrackingMenuAction.setShortcut('Shift+T') - - self.repeatTrackingVideoAction = QAction( - 'Select a tracker and track multiple frames...', self - ) - self.repeatTrackingVideoAction.setDisabled(True) - self.repeatTrackingVideoAction.setShortcut('Alt+Shift+T') - - self.trackingAlgosGroup = QActionGroup(self) - self.trackWithAcdcAction = QAction('Cell-ACDC', self) - self.trackWithAcdcAction.setCheckable(True) - self.trackingAlgosGroup.addAction(self.trackWithAcdcAction) - - self.trackWithYeazAction = QAction('YeaZ', self) - self.trackWithYeazAction.setCheckable(True) - self.trackingAlgosGroup.addAction(self.trackWithYeazAction) - - rt_trackers = myutils.get_list_of_real_time_trackers() - for rt_tracker in rt_trackers: - rtTrackerAction = QAction(rt_tracker, self) - rtTrackerAction.setCheckable(True) - self.trackingAlgosGroup.addAction(rtTrackerAction) - - self.trackWithAcdcAction.setChecked(True) - aliases = myutils.aliases_real_time_trackers() - - if 'tracking_algorithm' in self.df_settings.index: - trackingAlgo = self.df_settings.at['tracking_algorithm', 'value'] - if trackingAlgo in aliases: - trackingAlgo = aliases[trackingAlgo] - if trackingAlgo == 'Cell-ACDC': - self.trackWithAcdcAction.setChecked(True) - elif trackingAlgo == 'YeaZ': - self.trackWithYeazAction.setChecked(True) - else: - for rtTrackerAction in self.trackingAlgosGroup.actions(): - if rtTrackerAction.text() == trackingAlgo: - rtTrackerAction.setChecked(True) - break - - self.setMeasurementsAction = QAction('Set measurements...') - self.addCustomMetricAction = QAction('Add custom measurement...') - self.addCombineMetricAction = QAction('Add combined measurement...') - - # Standard key sequence - # self.copyAction.setShortcut(QKeySequence.StandardKey.Copy) - # self.pasteAction.setShortcut(QKeySequence.StandardKey.Paste) - # self.cutAction.setShortcut(QKeySequence.StandardKey.Cut) - # Help actions - self.tipsAction = QAction("Tips and tricks...", self) - self.UserManualAction = QAction("User Documentation...", self) - self.openLogFileAction = QAction("Open log file...", self) - self.showLogFilesAction = QAction("Show log files...", self) - self.aboutAction = QAction("About Cell-ACDC", self) - # self.aboutAction = QAction("&About...", self) - - # Assign mother to bud button - self.assignBudMothAutoAction = QAction(self) - self.assignBudMothAutoAction.setIcon(QIcon(":autoAssign.svg")) - self.assignBudMothAutoAction.setVisible(False) - - self.editCcaToolAction = QAction(self) - self.editCcaToolAction.setIcon(QIcon(":edit_cca.svg")) - # self.editCcaToolAction.setDisabled(True) - self.editCcaToolAction.setVisible(False) - - self.reInitCcaAction = QAction(self) - self.reInitCcaAction.setIcon(QIcon(":reinitCca.svg")) - self.reInitCcaAction.setVisible(False) - - self.toggleColorSchemeAction = QAction( - 'Switch to light theme' - ) - self.gui_updateSwitchColorSchemeActionText() - - self.pxModeAction = widgets.CheckableAction( - 'Fixed size text annotations' - ) - self.pxModeAction.setChecked(True) - pxModeTooltip = ( - 'When the text annotations are with fixed size they scale relative ' - 'to the object when zooming in/out (fixed size in pixels).\n' - 'This is typically faster to render, but it makes annotations ' - 'smaller/larger when zooming in/out, respectively.\n\n' - 'Try activating it to speed up the annotation of many objects ' - 'in high resolution mode.\n\n' - 'After activating it, you might need to increase the font size ' - 'from the menu on the top menubar `Edit --> Font size`.' - ) - self.pxModeAction.setToolTip(pxModeTooltip) - - self.highLowResAction = widgets.CheckableAction( - 'High resolution text annotations' - ) - highLowResTooltip = ( - 'Resolution of the text annotations. High resolution results ' - 'in slower update of the annotations.\n' - 'Not recommended with a number of segmented objects > 500.\n\n' - ) - self.highLowResAction.setToolTip(highLowResTooltip) - - self.editAutoSaveIntervalAction = QAction( - 'Change autosave interval (minutes or frames)...', self - ) - - self.editShortcutsAction = QAction( - 'Customize keyboard shortcuts...', self - ) - self.editShortcutsAction.setShortcut('Ctrl+K') - - self.showMirroredCursorAction = QAction( - 'Show mirrored cursor on images', self - ) - self.showMirroredCursorAction.setCheckable(True) - if 'showMirroredCursor' in self.df_settings.index: - checked = self.df_settings.at['showMirroredCursor', 'value'] == 'Yes' - self.showMirroredCursorAction.setChecked(checked) - else: - self.showMirroredCursorAction.setChecked(True) - self.showMirroredCursorAction.setShortcut('Ctrl+M') - - self.editTextIDsColorAction = QAction('Text annotation color...', self) - self.editTextIDsColorAction.setDisabled(True) - - self.editOverlayColorAction = QAction('Overlay color...', self) - self.editOverlayColorAction.setDisabled(True) - - self.manuallyEditCcaAction = QAction( - 'Edit cell cycle annotations...', self - ) - self.manuallyEditCcaAction.setShortcut('Ctrl+Shift+P') - self.manuallyEditCcaAction.setDisabled(True) - - self.viewCcaTableAction = QAction( - 'View cell cycle annotations...', self - ) - self.viewCcaTableAction.setDisabled(True) - self.viewCcaTableAction.setShortcut('Ctrl+P') - - - self.addScaleBarAction = QAction('Add scale bar', self) - self.addScaleBarAction.setCheckable(True) - - self.addTimestampAction = QAction('Add timestamp', self) - self.addTimestampAction.setCheckable(True) - - self.invertBwAction = QAction('Invert black/white', self) - self.invertBwAction.setCheckable(True) - checked = self.df_settings.at['is_bw_inverted', 'value'] == 'Yes' - self.invertBwAction.setChecked(checked) - - self.shuffleCmapAction = QAction('Randomly shuffle colormap', self) - self.shuffleCmapAction.setShortcut('Shift+S') - - self.greedyShuffleCmapAction = QAction( - 'Greedily shuffle colormap', self - ) - self.greedyShuffleCmapAction.setShortcut('Alt+Shift+S') - - self.saveLabColormapAction = QAction( - 'Save labels colormap...', self - ) - - self.normalizeRawAction = QAction( - 'Do not normalize. Display raw image', self) - self.normalizeToFloatAction = QAction( - 'Convert to floating point format with values [0, 1]', self) - # self.normalizeToUbyteAction = QAction( - # 'Rescale to 8-bit unsigned integer format with values [0, 255]', self) - self.normalizeRescale0to1Action = QAction( - 'Rescale to [0, 1]', self) - self.normalizeByMaxAction = QAction( - 'Normalize by max value', self) - self.normalizeRawAction.setCheckable(True) - self.normalizeToFloatAction.setCheckable(True) - # self.normalizeToUbyteAction.setCheckable(True) - self.normalizeRescale0to1Action.setCheckable(True) - self.normalizeByMaxAction.setCheckable(True) - self.normalizeQActionGroup = QActionGroup(self) - self.normalizeQActionGroup.addAction(self.normalizeRawAction) - self.normalizeQActionGroup.addAction(self.normalizeToFloatAction) - # self.normalizeQActionGroup.addAction(self.normalizeToUbyteAction) - self.normalizeQActionGroup.addAction(self.normalizeRescale0to1Action) - self.normalizeQActionGroup.addAction(self.normalizeByMaxAction) - - self.preprocessAction = QAction( - 'Pre-processing...', self - ) - self.preprocessAction.setShortcut('Alt+Shift+P') - - self.combineChannelsAction = QAction( - 'Combine and manipulate channels and/or segmentation files...', self - ) - self.combineChannelsAction.setShortcut('Alt+Shift+C') - - self.zoomToObjsAction = QAction( - 'Zoom to objects (Shortcut: H key)', self - ) - self.zoomOutAction = QAction( - 'Zoom out (Shortcut: double press H key)', self - ) - - self.relabelSequentialAction = QAction( - 'Relabel IDs sequentially...', self - ) - self.relabelSequentialAction.setShortcut('Ctrl+L') - self.relabelSequentialAction.setDisabled(True) - - self.setLastUserNormAction() - - self.autoSegmAction = QAction( - 'Enable automatic segmentation', self) - self.autoSegmAction.setCheckable(True) - self.autoSegmAction.setDisabled(True) - - self.enableSmartTrackAction = QAction( - 'Smart handling of enabling/disabling tracking', self) - self.enableSmartTrackAction.setCheckable(True) - self.enableSmartTrackAction.setChecked(True) - - self.enableAutoZoomToCellsAction = QAction( - 'Automatic zoom to all cells when pressing "Next/Previous"', self) - self.enableAutoZoomToCellsAction.setCheckable(True) - - self.imgPropertiesAction = QAction('Properties...', self) - self.imgPropertiesAction.setDisabled(True) - - self.addDelRoiAction = QAction(self) - self.addDelRoiAction.roiType = 'rect' - self.addDelRoiAction.setIcon(QIcon(":addDelRoi.svg")) - - self.addDelPolyLineRoiButton = QToolButton(self) - self.addDelPolyLineRoiButton.setCheckable(True) - self.addDelPolyLineRoiButton.setIcon(QIcon(":addDelPolyLineRoi.svg")) - - self.checkableButtons.append(self.addDelPolyLineRoiButton) - self.LeftClickButtons.append(self.addDelPolyLineRoiButton) - - self.delBorderObjAction = QAction(self) - self.delBorderObjAction.setIcon(QIcon(":delBorderObj.svg")) - - self.delNewObjAction = QAction(self) - self.delNewObjAction.setIcon(QIcon(":delNewObj.svg")) - - self.loadCustomAnnotationsAction = QAction(self) - self.loadCustomAnnotationsAction.setIcon(QIcon(":load_annotation.svg")) - self.loadCustomAnnotationsAction.setToolTip( - 'Load previously used custom annotations' - ) - - self.addCustomAnnotationAction = QAction(self) - self.addCustomAnnotationAction.setIcon(QIcon(":addCustomAnnotation.svg")) - self.addCustomAnnotationAction.setToolTip('Add custom annotation') - # self.functionsNotTested3D.append(self.addCustomAnnotationAction) - - self.viewAllCustomAnnotAction = QAction(self) - self.viewAllCustomAnnotAction.setCheckable(True) - self.viewAllCustomAnnotAction.setIcon(QIcon(":eye.svg")) - self.viewAllCustomAnnotAction.setToolTip('Show all custom annotations') - # self.functionsNotTested3D.append(self.viewAllCustomAnnotAction) - - # self.imgGradLabelsAlphaUpAction = QAction(self) - # self.imgGradLabelsAlphaUpAction.setVisible(False) - # self.imgGradLabelsAlphaUpAction.setShortcut('Ctrl+Up') - - def gui_updateSwitchColorSchemeActionText(self): - if self._colorScheme == 'dark': - txt = 'Switch to light theme' - else: - txt = 'Switch to dark theme' - self.toggleColorSchemeAction.setText(txt) - - def gui_connectActions(self): - # Connect File actions - if self.debug: - self.createEmptyDataAction.triggered.connect(self._createEmptyData) - self.segmNdimIndicator.clicked.connect(self.segmNdimIndicatorClicked) - self.newWindowAction.triggered.connect(self.openNewWindow) - self.newAction.triggered.connect(self.newFile) - self.openFolderAction.triggered.connect(self.openFolder) - self.openFileAction.triggered.connect(self.openFile) - self.manageVersionsAction.triggered.connect(self.manageVersions) - self.saveAction.triggered.connect(self.saveData) - self.saveAsAction.triggered.connect(self.saveAsData) - self.exportToVideoAction.triggered.connect(self.exportToVideoTriggered) - self.exportToImageAction.triggered.connect(self.exportToImageTriggered) - self.quickSaveAction.triggered.connect(self.quickSave) - self.viewPreprocDataToggle.toggled.connect( - self.viewPreprocDataToggled - ) - self.viewCombineChannelDataToggle.toggled.connect( - self.viewCombineChannelDataToggled - ) - self.autoSaveToggle.toggled.connect(self.autoSaveToggled) - self.autoSaveAnnotToggle.toggled.connect(self.autoSaveAnnotToggled) - self.autoSaveIntervalDialog.sigValueChanged.connect( - self.autoSaveIntervalValueChanged - ) - self.autoSaveIntervalEditButton.clicked.connect( - self.autoSaveIntervalEdit - ) - self.ccaIntegrCheckerToggle.toggled.connect( - self.ccaIntegrCheckerToggled - ) - self.annotLostObjsToggle.toggled.connect(self.annotLostObjsToggled) - self.highLowResAction.clicked.connect(self.highLowResToggled) - self.showInExplorerAction.triggered.connect(self.showInExplorer_cb) - self.exitAction.triggered.connect(self.close) - self.undoAction.triggered.connect(self.undo) - self.redoAction.triggered.connect(self.redo) - self.nextAction.triggered.connect(self.nextActionTriggered) - self.prevAction.triggered.connect(self.prevActionTriggered) - - self.invertBwAction.toggled.connect(self.invertBw) - self.toggleColorSchemeAction.triggered.connect(self.onToggleColorScheme) - self.pxModeAction.clicked.connect(self.pxModeActionToggled) - self.editShortcutsAction.triggered.connect(self.editShortcuts_cb) - self.editAutoSaveIntervalAction.triggered.connect( - self.autoSaveIntervalEditButton.click - ) - self.showMirroredCursorAction.toggled.connect( - self.showMirroredCursorToggled - ) - - # Connect Help actions - self.tipsAction.triggered.connect(self.showTipsAndTricks) - self.UserManualAction.triggered.connect(myutils.browse_docs) - self.openLogFileAction.triggered.connect(self.openLogFile) - self.showLogFilesAction.triggered.connect(self.showLogFiles) - self.aboutAction.triggered.connect(self.showAbout) - # Connect Open Recent to dynamically populate it - # self.openRecentMenu.aboutToShow.connect(self.populateOpenRecent) - self.checkableQButtonsGroup.buttonClicked.connect(self.uncheckQButton) - - self.showPropsDockButton.sigClicked.connect(self.showPropsDockWidget) - - self.loadCustomAnnotationsAction.triggered.connect( - self.loadCustomAnnotations - ) - self.addCustomAnnotationAction.triggered.connect( - self.addCustomAnnotation - ) - self.viewAllCustomAnnotAction.toggled.connect( - self.viewAllCustomAnnot - ) - self.addCustomModelVideoAction.triggered.connect( - self.showInstructionsCustomModel - ) - self.addCustomModelFrameAction.triggered.connect( - self.showInstructionsCustomModel - ) - self.addCustomModelFrameAction.callback = self.segmFrameCallback - self.addCustomModelVideoAction.callback = self.segmVideoCallback - - self.addCustomPromptModelAction.triggered.connect( - self.showInstructionsCustomPromptModel - ) - self.segmWithPromptableModelAction.triggered.connect( - self.segmWithPromptableModelActionTriggered - ) - - def zProjLockViewToggled(self, checked): - self.updateZproj(self.zProjComboBox.currentText()) - - def rescaleIntensExportToVideoDialog(self, how, channel, setImage=True): - if channel == self.user_ch_name: - lutItem = self.imgGrad - else: - lutItem = self.overlayLayersItems[channel][1] - - for action in lutItem.rescaleActionGroup.actions(): - if action.text() == how: - action.trigger() - # self.rescaleIntensitiesLut(setImage=setImage) - break - - def customLevelsLutChanged(self, levels, imageItem=None): - imageItem.setLevels(levels) - - def getPreComputedMinMaxZstack(self, channel: str): - if channel != self.user_ch_name: - return None - - posData = self.data[self.pos_i] - zstack_min, zstack_max = np.inf, 0 - for z in range(posData.SizeZ): - key = (self.pos_i, posData.frame_i, z) - levels = self.img1.minMaxValuesMapper.get(key) - if levels is None: - return - - img_min, img_max = levels - if img_min < zstack_min: - zstack_min = img_min - - if img_max > zstack_max: - zstack_max = img_max - - return (zstack_min, zstack_max) - - # @exec_time - def rescaleIntensitiesLut( - self, - action: QAction=None, - setImage: bool=True, - imageItem=None - ): - if not self.isDataLoaded: - self.logger.info( - 'WARNING: Data is not loaded. ' - 'Intensities will be rescaled later.' - ) - return - - posData = self.data[self.pos_i] - if imageItem is None: - imageItem = self.img1 - channel = self.user_ch_name - image_data = posData.img_data - else: - channel = imageItem.channelName - _, filename = self.getPathFromChName(channel, posData) - image_data = posData.fluo_data_dict[filename] - - triggeredByUser = True - if action is None: - triggeredByUser = False - action = imageItem.lutItem.rescaleActionGroup.checkedAction() - - how = action.text() - - self.df_settings.at[f'how_rescale_intensities_{channel}', 'value'] = how - self.df_settings.to_csv(self.settings_csv_path) - - if how == 'Rescale each 2D image': - if how == self.rescaleIntensChannelHowMapper[channel]: - # No need to update since we have autoscale - return - - imageItem.setEnableAutoLevels(True) - if setImage: - imageItem.setImage(imageItem.image) - return - - lutLevelsCh = posData.lutLevels[channel] - - if how == 'Rescale across z-stack': - imageItem.setEnableAutoLevels(False) - levels_key = (how, posData.frame_i) - levels = lutLevelsCh.get(levels_key) - if levels is None: - levels = self.getPreComputedMinMaxZstack(channel) - - if levels is None: - image_zstack = image_data[posData.frame_i] - levels = (image_zstack.min(), image_zstack.max()) - lutLevelsCh[levels_key] = levels - imageItem.setLevels(levels) - elif how == 'Rescale across time frames': - imageItem.setEnableAutoLevels(False) - levels_key = (how, None) - levels = lutLevelsCh.get(levels_key) - if levels is None: - levels = (image_data.min(), image_data.max()) - - lutLevelsCh[levels_key] = levels - imageItem.setLevels(levels) - elif how == 'Choose custom levels...': - autoLevelsEnabledBefore = imageItem.autoLevelsEnabled - imageItem.setEnableAutoLevels(False) - if triggeredByUser: - current_min, current_max = imageItem.getLevels() - dtype_max = np.iinfo(image_data.dtype).max - max_value = image_data.max() - min_value = image_data.min() - win = apps.SetCustomLevelsLut( - init_min_value=current_min, - init_max_value=current_max, - maximum_max_value=max_value, - minimum_min_value=min_value, - parent=self - ) - win.sigLevelsChanged.connect( - partial(self.customLevelsLutChanged, imageItem=imageItem) - ) - win.exec_() - if win.cancel: - imageItem.setEnableAutoLevels(autoLevelsEnabledBefore) - self.logger.info('Custom LUT levels setting cancelled.') - self.updateAllImages() - return - selectedLevels = win.selectedLevels - else: - selectedLevels = imageItem.getLevels() - imageItem.setLevels(selectedLevels) - elif how == 'Do no rescale, display raw image': - imageItem.setEnableAutoLevels(False) - levels_key = (how, None) - levels = lutLevelsCh.get(levels_key) - if levels is None: - dtype_max = np.iinfo(image_data.dtype).max - levels = (0, dtype_max) - lutLevelsCh[levels_key] = levels - imageItem.setLevels(levels) - - self.rescaleIntensChannelHowMapper[channel] = how - - if setImage: - imageItem.setImage(imageItem.image) - - def onToggleColorScheme(self): - if self.toggleColorSchemeAction.text().find('light') != -1: - self._colorScheme = 'light' - setDarkModeToggleChecked = False - else: - self._colorScheme = 'dark' - setDarkModeToggleChecked = True - self.gui_updateSwitchColorSchemeActionText() - _warnings.warnRestartCellACDCcolorModeToggled( - self._colorScheme, app_name=self._appName, parent=self - ) - load.rename_qrc_resources_file(self._colorScheme) - self.statusBarLabel.setText(html_utils.paragraph( - f'Restart {self._appName} for the change to take effect', - font_color='red' - )) - self.df_settings.at['colorScheme', 'value'] = self._colorScheme - self.df_settings.to_csv(settings_csv_path) - - def showMirroredCursorToggled(self, checked): - value = 'Yes' if checked else 'No' - self.df_settings.at['showMirroredCursor', 'value'] = value - self.df_settings.to_csv(settings_csv_path) - - if not checked: - self.clearCursors() - - def clearCursors(self): - self.ax1_cursor.setData([], []) - self.ax2_cursor.setData([], []) - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - eraserCursors = ( - self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX - ) - self.setHoverToolSymbolData([], [], eraserCursors) - - def activeEraserCircleCursors(self, isHoverImg1): - if self.showMirroredCursorAction.isChecked(): - return self.ax1_EraserCircle, self.ax2_EraserCircle - - if isHoverImg1: - return self.ax1_EraserCircle, - else: - return self.ax2_EraserCircle, - - def activeEraserXCursors(self, isHoverImg1): - if self.showMirroredCursorAction.isChecked(): - return self.ax1_EraserX, self.ax2_EraserX - - if isHoverImg1: - return self.ax1_EraserX, - else: - return self.ax2_EraserX, - - def activeBrushCircleCursors(self, isHoverImg1): - if self.showMirroredCursorAction.isChecked(): - return self.ax1_BrushCircle, self.ax2_BrushCircle - - if isHoverImg1: - return self.ax1_BrushCircle, - else: - return self.ax2_BrushCircle, - - def gui_connectEditActions(self): - self.showInExplorerAction.setEnabled(True) - self.setEnabledFileToolbar(True) - self.loadFluoAction.setEnabled(True) - self.isEditActionsConnected = True - - self.preprocessImageAction.triggered.connect( - self.preprocessAction.trigger - ) - self.combineChannelsAction.triggered.connect( - self.combineChannelsActionTriggered - ) - - self.overlayButton.toggled.connect(self.overlay_cb) - self.countObjsButton.toggled.connect(self.countObjectsCb) - self.togglePointsLayerAction.toggled.connect(self.pointsLayerToggled) - self.overlayLabelsButton.toggled.connect(self.overlayLabels_cb) - self.overlayButton.sigRightClick.connect(self.showOverlayContextMenu) - self.labelRoiButton.sigRightClick.connect(self.showLabelRoiContextMenu) - self.overlayLabelsButton.sigRightClick.connect( - self.showOverlayLabelsContextMenu - ) - self.rulerButton.toggled.connect(self.ruler_cb) - self.loadFluoAction.triggered.connect(self.loadFluo_cb) - self.loadPosAction.triggered.connect(self.loadPosTriggered) - # self.reloadAction.triggered.connect(self.reload_cb) - self.findIdAction.triggered.connect(self.findID) - self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) - self.autoPilotButton.toggled.connect(self.autoPilotToggled) - self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID) - self.slideshowButton.toggled.connect(self.launchSlideshow) - - self.copyLostObjButton.toggled.connect(self.copyLostObjContour_cb) - self.manualAnnotPastButton.toggled.connect( - self.manualAnnotPast_cb - ) - - self.segmSingleFrameMenu.triggered.connect(self.segmFrameCallback) - self.segmVideoMenu.triggered.connect(self.segmVideoCallback) - - self.postProcessSegmAction.toggled.connect(self.postProcessSegm) - self.autoSegmAction.toggled.connect(self.autoSegm_cb) - self.realTimeTrackingToggle.clicked.connect(self.realTimeTrackingClicked) - self.repeatTrackingAction.triggered.connect(self.repeatTracking) - self.manualTrackingButton.toggled.connect(self.manualTracking_cb) - self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) - self.repeatTrackingMenuAction.triggered.connect(self.repeatTracking) - self.repeatTrackingVideoAction.triggered.connect( - self.repeatTrackingVideo - ) - for rtTrackerAction in self.trackingAlgosGroup.actions(): - rtTrackerAction.toggled.connect(self.rtTrackerActionToggled) - self.editRtTrackerParamsAction.triggered.connect( - self.initRealTimeTracker - ) - self.delObjsOutSegmMaskAction.triggered.connect( - self.delObjsOutSegmMaskActionTriggered - ) - self.mergeIDsButton.toggled.connect(self.mergeObjs_cb) - self.brushButton.toggled.connect(self.Brush_cb) - self.eraserButton.toggled.connect(self.Eraser_cb) - self.curvToolButton.toggled.connect(self.curvTool_cb) - self.wandToolButton.toggled.connect(self.wand_cb) - self.labelRoiButton.toggled.connect(self.labelRoi_cb) - self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) - self.drawClearRegionButton.toggled.connect(self.drawClearRegion_cb) - self.reInitCcaAction.triggered.connect(self.reInitCca) - self.moveLabelToolButton.toggled.connect(self.moveLabelButtonToggled) - self.editCcaToolAction.triggered.connect( - self.manualEditCcaToolbarActionTriggered - ) - self.assignBudMothAutoAction.triggered.connect( - self.autoAssignBud_YeastMate - ) - self.keepIDsButton.toggled.connect(self.keepIDs_cb) - - self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) - - self.whitelistIDsToolbar.sigWhitelistChanged.connect( - self.whitelistIDsChanged - ) - - self.whitelistIDsToolbar.sigWhitelistAccepted.connect( - self.whitelistIDsAccepted - ) - - self.whitelistIDsToolbar.sigViewOGIDs.connect(self.whitelistViewOGIDs) - - self.whitelistIDsToolbar.sigAddNewIDs.connect(self.whitelistAddNewIDsToggled) - - self.whitelistIDsToolbar.sigLoadOGLabs.connect(self.whitelistLoadOGLabs_cb) - - self.whitelistIDsToolbar.sigTrackOGagainstPreviousFrame.connect( - self.whitelistTrackOGagainstPreviousFrame_cb - ) - - self.expandLabelToolButton.toggled.connect(self.expandLabelCallback) - - self.reinitLastSegmFrameAction.triggered.connect( - self.reInitLastSegmFrame - ) - - - self.defaultRescaleIntensActionGroup.triggered.connect( - self.defaultRescaleIntensLutActionToggled - ) - - # self.repeatAutoCcaAction.triggered.connect(self.repeatAutoCca) - self.manuallyEditCcaAction.triggered.connect(self.manualEditCca) - self.addScaleBarAction.toggled.connect(self.addScaleBar) - self.addTimestampAction.toggled.connect(self.addTimestamp) - self.saveLabColormapAction.triggered.connect(self.saveLabelsColormap) - - self.enableSmartTrackAction.toggled.connect(self.enableSmartTrack) - # Brush/Eraser size action - self.brushSizeSpinbox.valueChanged.connect(self.brushSize_cb) - self.autoIDcheckbox.toggled.connect(self.autoIDtoggled) - # Mode - self.modeActionGroup.triggered.connect(self.changeModeFromMenu) - self.modeComboBox.sigTextChanged.connect(self.changeMode) - self.modeComboBox.activated.connect(self.clearComboBoxFocus) - self.equalizeHistPushButton.toggled.connect(self.equalizeHist) - - self.editOverlayColorAction.triggered.connect(self.toggleOverlayColorButton) - self.editTextIDsColorAction.triggered.connect(self.toggleTextIDsColorButton) - self.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) - self.overlayColorButton.sigColorChanged.connect(self.saveOverlayColor) - self.textIDsColorButton.sigColorChanging.connect(self.updateTextAnnotColor) - self.textIDsColorButton.sigColorChanged.connect(self.saveTextIDsColors) - - self.setMeasurementsAction.triggered.connect(self.showSetMeasurements) - self.addCustomMetricAction.triggered.connect(self.addCustomMetric) - self.addCombineMetricAction.triggered.connect(self.addCombineMetric) - - self.labelsGrad.colorButton.sigColorChanging.connect(self.updateBkgrColor) - self.labelsGrad.colorButton.sigColorChanged.connect(self.saveBkgrColor) - self.labelsGrad.sigGradientChangeFinished.connect(self.updateLabelsCmap) - self.labelsGrad.sigGradientChanged.connect(self.ticksCmapMoved) - self.labelsGrad.textColorButton.sigColorChanging.connect( - self.updateTextLabelsColor - ) - self.labelsGrad.textColorButton.sigColorChanged.connect( - self.saveTextLabelsColor - ) - # self.addFontSizeActions( - # self.labelsGrad.fontSizeMenu, self.setFontSizeActionChecked - # ) - - self.labelsGrad.shuffleCmapAction.triggered.connect(self.shuffle_cmap) - self.labelsGrad.greedyShuffleCmapAction.triggered.connect( - self.greedyShuffleCmap - ) - self.labelsGrad.permanentGreedyCmapAction.toggled.connect( - self.permanentGreedyCmapToggled - ) - self.shuffleCmapAction.triggered.connect(self.shuffle_cmap) - self.greedyShuffleCmapAction.triggered.connect(self.greedyShuffleCmap) - self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) - self.labelsGrad.sigShowLabelsImgToggled.connect(self.showLabelImageItem) - self.labelsGrad.sigShowRightImgToggled.connect(self.showRightImageItem) - self.labelsGrad.sigShowNextFrameToggled.connect(self.showNextFrameImageItem) - - self.labelsGrad.defaultSettingsAction.triggered.connect( - self.restoreDefaultSettings - ) - - # self.addFontSizeActions( - # self.imgGrad.fontSizeMenu, self.setFontSizeActionChecked - # ) - self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) - self.imgGrad.textColorButton.disconnect() - self.imgGrad.textColorButton.clicked.connect( - self.editTextIDsColorAction.trigger - ) - self.imgGrad.labelsAlphaSlider.valueChanged.connect( - self.updateLabelsAlpha - ) - self.imgGrad.defaultSettingsAction.triggered.connect( - self.restoreDefaultSettings - ) - - # Drawing mode - self.drawIDsContComboBox.currentIndexChanged.connect( - self.drawIDsContComboBox_cb - ) - self.drawIDsContComboBox.activated.connect(self.clearComboBoxFocus) - - self.annotateRightHowCombobox.currentIndexChanged.connect( - self.annotateRightHowCombobox_cb - ) - self.annotateRightHowCombobox.activated.connect(self.clearComboBoxFocus) - - self.showTreeInfoCheckbox.toggled.connect(self.setAnnotInfoMode) - - # Left - self.annotIDsCheckbox.clicked.connect(self.annotOptionClicked) - self.annotCcaInfoCheckbox.clicked.connect(self.annotOptionClicked) - self.annotContourCheckbox.clicked.connect(self.annotOptionClicked) - self.annotSegmMasksCheckbox.clicked.connect(self.annotOptionClicked) - self.drawMothBudLinesCheckbox.clicked.connect(self.annotOptionClicked) - self.drawNothingCheckbox.clicked.connect(self.annotOptionClicked) - self.annotNumZslicesCheckbox.clicked.connect(self.annotOptionClicked) - - # Right - self.annotIDsCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotCcaInfoCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotContourCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotSegmMasksCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.drawMothBudLinesCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.drawNothingCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotNumZslicesCheckboxRight.clicked.connect( - self.annotOptionClickedRight - ) - - self.segmentToolAction.triggered.connect(self.segmentToolActionTriggered) - - self.addDelRoiAction.triggered.connect(self.addDelROI) - self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) - self.delBorderObjAction.triggered.connect(self.delBorderObj) - self.delNewObjAction.triggered.connect(self.delNewObj) - - self.brushAutoFillCheckbox.toggled.connect(self.brushAutoFillToggled) - self.brushAutoHideCheckbox.toggled.connect(self.brushAutoHideToggled) - - self.imgGrad.sigAddScaleBar.connect(self.addScaleBarAction.setChecked) - self.imgGrad.sigAddTimestamp.connect(self.addTimestampAction.setChecked) - self.imgGrad.gradient.sigGradientChangeFinished.connect( - self.imgGradLUTfinished_cb - ) - - # self.normalizeQActionGroup.triggered.connect( - # self.normaliseIntensitiesActionTriggered - # ) - self.imgPropertiesAction.triggered.connect(self.editImgProperties) - - self.relabelSequentialAction.triggered.connect( - self.relabelSequentialCallback - ) - - self.zoomToObjsAction.triggered.connect(self.zoomToObjsActionCallback) - self.zoomOutAction.triggered.connect(self.zoomOut) - self.preprocessAction.triggered.connect(self.preprocessActionTriggered) - self.combineChannelsAction.triggered.connect(self.combineChannelsActionTriggered) - - self.viewCcaTableAction.triggered.connect(self.viewCcaTable) - - self.guiTabControl.propsQGBox.idSB.valueChanged.connect( - self.propsWidgetIDvalueChanged - ) - self.guiTabControl.highlightCheckbox.toggled.connect( - self.highlightIDonHoverCheckBoxToggled - ) - self.guiTabControl.highlightSearchedCheckbox.toggled.connect( - self.highlightSearchedIDcheckBoxToggled - ) - intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox - intensMeasurQGBox.additionalMeasCombobox.currentTextChanged.connect( - self.updatePropsWidget - ) - intensMeasurQGBox.channelCombobox.currentTextChanged.connect( - self.updatePropsWidget - ) - - propsQGBox = self.guiTabControl.propsQGBox - propsQGBox.additionalPropsCombobox.currentTextChanged.connect( - self.updatePropsWidget - ) - - def gui_createShowPropsButton(self, side='left'): - self.leftSideDocksLayout = QVBoxLayout() - self.leftSideDocksLayout.setSpacing(0) - self.leftSideDocksLayout.setContentsMargins(0,0,0,0) - self.rightSideDocksLayout = QVBoxLayout() - self.rightSideDocksLayout.setSpacing(0) - self.rightSideDocksLayout.setContentsMargins(0,0,0,0) - self.showPropsDockButton = widgets.expandCollapseButton() - self.showPropsDockButton.setDisabled(True) - self.showPropsDockButton.setFocusPolicy(Qt.NoFocus) - self.showPropsDockButton.setToolTip('Show object properties') - if side == 'left': - self.leftSideDocksLayout.addWidget(self.showPropsDockButton) - else: - self.rightSideDocksLayout.addWidget(self.showPropsDockButton) - - def gui_createQuickSettingsWidgets(self): - self.quickSettingsLayout = QVBoxLayout() - self.quickSettingsGroupbox = widgets.GroupBox() - self.quickSettingsGroupbox.setTitle('Quick settings') - - layout = QFormLayout() - layout.setFieldGrowthPolicy( - QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint - ) - layout.setFormAlignment(Qt.AlignRight | Qt.AlignVCenter) - - self.viewPreprocDataToggle = widgets.Toggle() - viewPreprocDataToggleTooltip = ( - 'View pre-processed data. See menu `Image --> Pre-processing...`\n' - 'on the top menubar.' - ) - self.viewPreprocDataToggle.setChecked(False) - self.viewPreprocDataToggle.setToolTip(viewPreprocDataToggleTooltip) - viewPreprocDataToggleLabel = QLabel('View pre-processed image') - viewPreprocDataToggleLabel.setToolTip(viewPreprocDataToggleTooltip) - layout.addRow(viewPreprocDataToggleLabel, self.viewPreprocDataToggle) - - self.viewCombineChannelDataToggle = widgets.Toggle() - viewCombineChannelDataToggleTooltip = ( - 'View combined channel. See menu `Image --> combing channels...`\n' - 'on the top menubar.' - ) - self.viewCombineChannelDataToggle.setChecked(False) - self.viewCombineChannelDataToggle.setToolTip( - viewCombineChannelDataToggleTooltip - ) - viewCombineChannelDataToggleLabel = QLabel('View combined channels') - viewCombineChannelDataToggleLabel.setToolTip( - viewCombineChannelDataToggleTooltip - ) - layout.addRow( - viewCombineChannelDataToggleLabel, - self.viewCombineChannelDataToggle - ) - - self.autoSaveToggle = widgets.Toggle() - autoSaveTooltip = ( - 'Automatically store a copy of the segmentation data ' - 'in the `.recovery` folder after every edit.' - ) - self.autoSaveToggle.setChecked(True) - self.autoSaveToggle.setToolTip(autoSaveTooltip) - autoSaveLabel = QLabel('Autosave segmentation') - autoSaveLabel.setToolTip(autoSaveTooltip) - layout.addRow(autoSaveLabel, self.autoSaveToggle) - - self.autoSaveAnnotToggle = widgets.Toggle() - autoSaveAnnotTooltip = ( - 'Automatically store a copy of the annotations (acdc_output CSV file) ' - 'in the `.recovery` folder after every edit.' - ) - self.autoSaveAnnotToggle.setChecked(True) - self.autoSaveAnnotToggle.setToolTip(autoSaveAnnotTooltip) - autoSaveAnnotLabel = QLabel('Autosave annotations') - autoSaveAnnotLabel.setToolTip(autoSaveAnnotTooltip) - layout.addRow(autoSaveAnnotLabel, self.autoSaveAnnotToggle) - - self.autoSaveIntervalEditButton = widgets.editPushButton( - flat=True, hoverable=True - ) - self.autoSaveIntervalLabel = QLabel('Autosave interval') - self.autoSaveIntervalSetTooltip() - layout.addRow( - self.autoSaveIntervalLabel, self.autoSaveIntervalEditButton - ) - - self.autoSaveIntervalDialog = apps.AutoSaveIntervalDialog(parent=self) - self.autoSaveIntervalDialog.setValues(*self.autoSaveIntevalValueUnit) - - self.ccaIntegrCheckerToggle = widgets.Toggle() - ccaIntegrCheckerToggleTooltip = ( - 'Toggle background cell cycle annotations integrity checker ON/OFF' - ) - self.ccaIntegrCheckerToggle.setChecked(False) - self.ccaIntegrCheckerToggle.setToolTip(ccaIntegrCheckerToggleTooltip) - label = QLabel('Cc annot. checker') - label.setToolTip(ccaIntegrCheckerToggleTooltip) - layout.addRow(label, self.ccaIntegrCheckerToggle) - if 'is_cca_integrity_checker_activated' in self.df_settings.index: - idx = 'is_cca_integrity_checker_activated' - val = int(self.df_settings.at[idx, 'value']) - self.ccaIntegrCheckerToggle.setChecked(not val) - - self.annotLostObjsToggle = widgets.Toggle() - annotLostObjsToggleTooltip = ( - 'Toggle annotation of lost objects mode ON/OFF' - ) - self.annotLostObjsToggle.setChecked(True) - self.annotLostObjsToggle.setToolTip(annotLostObjsToggleTooltip) - label = QLabel('Annot. lost objects') - label.setToolTip(annotLostObjsToggleTooltip) - layout.addRow(label, self.annotLostObjsToggle) - - self.realTimeTrackingToggle = widgets.Toggle() - self.realTimeTrackingToggle.setChecked(True) - self.realTimeTrackingToggle.setDisabled(True) - label = QLabel('Real-time tracking') - label.setDisabled(True) - self.realTimeTrackingToggle.label = label - layout.addRow(label, self.realTimeTrackingToggle) - - self.showAllContoursToggle = widgets.Toggle() - showAllContoursTooltip = ( - 'If active, all contours will be displayed, including inner contours' - '(e.g. holes and sub-objects)' - ) - self.showAllContoursToggle.setToolTip(showAllContoursTooltip) - showAllContourLabel = QLabel('Show all contours') - showAllContourLabel.setToolTip(showAllContoursTooltip) - layout.addRow(showAllContourLabel, self.showAllContoursToggle) - self.showAllContoursToggle.toggled.connect( - self.showAllContoursToggled - ) - - # Font size - self.fontSizeSpinBox = widgets.SpinBox() - self.fontSizeSpinBox.setMinimum(1) - self.fontSizeSpinBox.setMaximum(99) - layout.addRow('Font size', self.fontSizeSpinBox) - savedFontSize = str(self.df_settings.at['fontSize', 'value']) - if savedFontSize.find('pt') != -1: - savedFontSize = savedFontSize[:-2] - self.fontSize = int(savedFontSize) - if 'pxMode' not in self.df_settings.index: - # Users before introduction of pxMode had pxMode=False, but now - # the new default is True. This requires larger font size. - self.fontSize = 2*self.fontSize - self.df_settings.at['pxMode', 'value'] = 1 - self.df_settings.to_csv(settings_csv_path) - self.fontSizeSpinBox.setValue(self.fontSize) - self.fontSizeSpinBox.editingFinished.connect(self.changeFontSize) - self.fontSizeSpinBox.sigUpClicked.connect(self.changeFontSize) - self.fontSizeSpinBox.sigDownClicked.connect(self.changeFontSize) - - self.quickSettingsGroupbox.setLayout(layout) - self.quickSettingsLayout.addWidget(self.quickSettingsGroupbox) - self.quickSettingsLayout.addStretch(1) - - def showAllContoursToggled(self): - if not self.isDataLoaded: - return - - self.computeAllContours() - self.updateAllImages() - - def gui_createImg1Widgets(self): - # Toggle contours/ID combobox - self.drawIDsContComboBoxSegmItems = [ - 'Draw IDs and contours', - 'Draw IDs and overlay segm. masks', - 'Draw only cell cycle info', - 'Draw cell cycle info and contours', - 'Draw cell cycle info and overlay segm. masks', - 'Draw only mother-bud lines', - 'Draw only IDs', - 'Draw only contours', - 'Draw only overlay segm. masks', - 'Draw nothing' - ] - self.drawIDsContComboBox = widgets.ComboBox() - self.drawIDsContComboBox.setFont(_font) - self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) - self.drawIDsContComboBox.setVisible(False) - - self.annotIDsCheckbox = widgets.CheckBox( - 'IDs', keyPressCallback=self.resetFocus) - self.annotCcaInfoCheckbox = widgets.CheckBox( - 'Cell cycle info', keyPressCallback=self.resetFocus) - self.annotNumZslicesCheckbox = widgets.CheckBox( - 'No. z-slices/object', keyPressCallback=self.resetFocus) - - self.annotContourCheckbox = widgets.CheckBox( - 'Contours', keyPressCallback=self.resetFocus) - self.annotSegmMasksCheckbox = widgets.CheckBox( - 'Segm. masks', keyPressCallback=self.resetFocus) - - self.drawMothBudLinesCheckbox = widgets.CheckBox( - 'Only mother-daughter line', keyPressCallback=self.resetFocus - ) - - self.drawNothingCheckbox = widgets.CheckBox( - 'Do not annotate', keyPressCallback=self.resetFocus - ) - - self.annotOptionsWidget = QWidget() - annotOptionsLayout = QHBoxLayout() - - # Show tree info checkbox - self.showTreeInfoCheckbox = widgets.CheckBox( - 'Show tree info', keyPressCallback=self.resetFocus - ) - self.showTreeInfoCheckbox.setFont(_font) - sp = self.showTreeInfoCheckbox.sizePolicy() - sp.setRetainSizeWhenHidden(True) - self.showTreeInfoCheckbox.setSizePolicy(sp) - self.showTreeInfoCheckbox.hide() - - annotOptionsLayout.addWidget(self.showTreeInfoCheckbox) - annotOptionsLayout.addWidget(QLabel(' | ')) - annotOptionsLayout.addWidget(self.annotIDsCheckbox) - annotOptionsLayout.addWidget(self.annotCcaInfoCheckbox) - annotOptionsLayout.addWidget(self.drawMothBudLinesCheckbox) - annotOptionsLayout.addWidget(self.annotNumZslicesCheckbox) - annotOptionsLayout.addWidget(QLabel(' | ')) - annotOptionsLayout.addWidget(self.annotContourCheckbox) - annotOptionsLayout.addWidget(self.annotSegmMasksCheckbox) - annotOptionsLayout.addWidget(QLabel(' | ')) - annotOptionsLayout.addWidget(self.drawNothingCheckbox) - annotOptionsLayout.addWidget(self.drawIDsContComboBox) - self.annotOptionsLayout = annotOptionsLayout - - # Toggle highlight z+-1 objects combobox - self.highlightZneighObjCheckbox = widgets.CheckBox( - 'Highlight objects in neighbouring z-slices', - keyPressCallback=self.resetFocus - ) - self.highlightZneighObjCheckbox.setFont(_font) - self.highlightZneighObjCheckbox.hide() - - annotOptionsLayout.addWidget(self.highlightZneighObjCheckbox) - self.annotOptionsWidget.setLayout(annotOptionsLayout) - - # Annotations options right image - self.annotIDsCheckboxRight = widgets.CheckBox( - 'IDs', keyPressCallback=self.resetFocus) - self.annotCcaInfoCheckboxRight = widgets.CheckBox( - 'Cell cycle info', keyPressCallback=self.resetFocus) - self.annotNumZslicesCheckboxRight = widgets.CheckBox( - 'No. z-slices/object', keyPressCallback=self.resetFocus - ) - - self.annotContourCheckboxRight = widgets.CheckBox( - 'Contours', keyPressCallback=self.resetFocus) - self.annotSegmMasksCheckboxRight = widgets.CheckBox( - 'Segm. masks', keyPressCallback=self.resetFocus) - - self.drawMothBudLinesCheckboxRight = widgets.CheckBox( - 'Only mother-daughter line', keyPressCallback=self.resetFocus - ) - - self.drawNothingCheckboxRight = widgets.CheckBox( - 'Do not annotate', keyPressCallback=self.resetFocus) - - self.annotOptionsWidgetRight = QWidget() - annotOptionsLayoutRight = QHBoxLayout() - - annotOptionsLayoutRight.addWidget(QLabel(' ')) - annotOptionsLayoutRight.addWidget(QLabel(' | ')) - annotOptionsLayoutRight.addWidget(self.annotIDsCheckboxRight) - annotOptionsLayoutRight.addWidget(self.annotCcaInfoCheckboxRight) - annotOptionsLayoutRight.addWidget(self.drawMothBudLinesCheckboxRight) - annotOptionsLayoutRight.addWidget(self.annotNumZslicesCheckboxRight) - annotOptionsLayoutRight.addWidget(QLabel(' | ')) - annotOptionsLayoutRight.addWidget(self.annotContourCheckboxRight) - annotOptionsLayoutRight.addWidget(self.annotSegmMasksCheckboxRight) - annotOptionsLayoutRight.addWidget(QLabel(' | ')) - annotOptionsLayoutRight.addWidget(self.drawNothingCheckboxRight) - self.annotOptionsLayoutRight = annotOptionsLayoutRight - - self.annotOptionsWidgetRight.setLayout(annotOptionsLayoutRight) - - # Frames scrollbar - self.navigateScrollBar = widgets.navigateQScrollBar(Qt.Horizontal) - self.navigateScrollBar.setDisabled(True) - self.navigateScrollBar.setMinimum(1) - self.navigateScrollBar.setMaximum(1) - self.navigateScrollBar.setToolTip( - 'NOTE: The maximum frame number that can be visualized with this ' - 'scrollbar\n' - 'is the last visited frame with the selected mode\n' - '(see "Mode" selector on the top-right).\n\n' - 'If the scrollbar does not move it means that you never visited\n' - 'any frame with current mode.\n\n' - 'Note that the "Viewer" mode allows you to scroll ALL frames.' - ) - t_label = QLabel('frame n. ') - t_label.setFont(_font) - self.t_label = t_label - - # z-slice scrollbars - self.zSliceScrollBar = widgets.linkedQScrollbar(Qt.Horizontal) - - self.zProjComboBox = widgets.ComboBox() - self.zProjComboBox.setFont(_font) - self.zProjComboBox.addItems([ - 'single z-slice', - 'max z-projection', - 'mean z-projection', - 'median z-proj.' - ]) - self.zProjLockViewButton = widgets.LockPushButton() - self.zProjLockViewButton.setCheckable(True) - self.zProjLockViewButton.setToolTip( - 'If active, the selected z-slice view is applied to all frames' - ) - self.zProjLockViewButton.hide() - - self.switchPlaneCombobox = widgets.SwitchPlaneCombobox() - self.switchPlaneCombobox.setToolTip( - 'Switch viewed plane' - ) - - self.zSliceOverlay_SB = widgets.ScrollBar(Qt.Horizontal) - _z_label = QLabel('Overlay z-slice ') - _z_label.setFont(_font) - _z_label.setDisabled(True) - self.overlay_z_label = _z_label - - self.zProjOverlay_CB = widgets.ComboBox() - self.zProjOverlay_CB.setFont(_font) - self.zProjOverlay_CB.addItems([ - 'single z-slice', 'max z-projection', 'mean z-projection', - 'median z-proj.', 'same as above' - ]) - self.zProjOverlay_CB.setCurrentIndex(4) - self.zSliceOverlay_SB.setDisabled(True) - - self.img1BottomGroupbox = self.gui_getImg1BottomWidgets() - - def gui_getImg1BottomWidgets(self): - bottomLeftLayout = QGridLayout() - self.bottomLeftLayout = bottomLeftLayout - container = QGroupBox('Navigate and annotate left image') - - row = 0 - bottomLeftLayout.addWidget(self.annotOptionsWidget, row, 0, 1, 4) - # bottomLeftLayout.addWidget( - # self.drawIDsContComboBox, row, 1, 1, 2, - # alignment=Qt.AlignCenter - # ) - - # bottomLeftLayout.addWidget( - # self.showTreeInfoCheckbox, row, 0, 1, 1, - # alignment=Qt.AlignCenter - # ) - - row += 1 - navWidgetsLayout = QHBoxLayout() - self.navSpinBox = widgets.SpinBox(disableKeyPress=True) - self.navSpinBox.setMinimum(1) - self.navSpinBox.setMaximum(100) - self.navSizeLabel = QLabel('/ND') - navWidgetsLayout.addWidget(self.t_label) - navWidgetsLayout.addWidget(self.navSpinBox) - navWidgetsLayout.addWidget(self.navSizeLabel) - bottomLeftLayout.addLayout( - navWidgetsLayout, row, 0, alignment=Qt.AlignRight - ) - bottomLeftLayout.addWidget(self.navigateScrollBar, row, 1, 1, 2) - sp = self.navigateScrollBar.sizePolicy() - sp.setRetainSizeWhenHidden(True) - self.navigateScrollBar.setSizePolicy(sp) - self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) - self.navSpinBox.editingFinished.connect( - self.navigateSpinboxEditingFinished - ) - self.navSpinBox.sigUpClicked.connect( - self.navigateSpinboxEditingFinished - ) - self.navSpinBox.sigDownClicked.connect( - self.navigateSpinboxEditingFinished - ) - - self.lastTrackedFrameLabel = QLabel() - self.lastTrackedFrameLabel.setFont(_font) - bottomLeftLayout.addWidget(self.lastTrackedFrameLabel, row, 3) - - row += 1 - zSliceCheckboxLayout = QHBoxLayout() - self.zSliceCheckbox = QCheckBox('z-slice') - self.zSliceSpinbox = widgets.SpinBox(disableKeyPress=True) - self.zSliceSpinbox.setMinimum(1) - self.SizeZlabel = QLabel('/ND') - self.zSliceCheckbox.setToolTip( - 'Activate/deactivate control of the z-slices with keyboard arrows.\n\n' - 'SHORTCUT to toggle ON/OFF: "Z" key' - ) - zSliceCheckboxLayout.addWidget(self.zSliceCheckbox) - zSliceCheckboxLayout.addWidget(self.zSliceSpinbox) - zSliceCheckboxLayout.addWidget(self.SizeZlabel) - bottomLeftLayout.addLayout( - zSliceCheckboxLayout, row, 0, alignment=Qt.AlignRight - ) - bottomLeftLayout.addWidget(self.zSliceScrollBar, row, 1, 1, 2) - bottomLeftLayout.addWidget(self.zProjComboBox, row, 3) - bottomLeftLayout.addWidget(self.zProjLockViewButton, row, 4) - bottomLeftLayout.addWidget(self.switchPlaneCombobox, row, 5) - self.zSliceSpinbox.connectValueChanged(self.onZsliceSpinboxValueChange) - self.zSliceSpinbox.editingFinished.connect(self.zSliceScrollBarReleased) - - row += 1 - bottomLeftLayout.addWidget( - self.overlay_z_label, row, 0, alignment=Qt.AlignRight - ) - bottomLeftLayout.addWidget(self.zSliceOverlay_SB, row, 1, 1, 2) - - bottomLeftLayout.addWidget(self.zProjOverlay_CB, row, 3) - - row += 1 - self.alphaScrollbarRow = row - - bottomLeftLayout.setColumnStretch(0,0) - bottomLeftLayout.setColumnStretch(1,3) - bottomLeftLayout.setColumnStretch(2,0) - - container.setLayout(bottomLeftLayout) - return container - - def gui_createLabWidgets(self): - bottomRightLayout = QVBoxLayout() - self.rightBottomGroupbox = widgets.GroupBox( - 'Annotate right image independent of left image', - keyPressCallback=self.resetFocus - ) - self.rightBottomGroupbox.setCheckable(True) - self.rightBottomGroupbox.setChecked(False) - self.rightBottomGroupbox.hide() - - self.annotateRightHowCombobox = widgets.ComboBox() - self.annotateRightHowCombobox.setFont(_font) - self.annotateRightHowCombobox.addItems(self.drawIDsContComboBoxSegmItems) - self.annotateRightHowCombobox.setCurrentIndex( - self.drawIDsContComboBox.currentIndex() - ) - self.annotateRightHowCombobox.setVisible(False) - - self.annotOptionsLayoutRight.addWidget(self.annotateRightHowCombobox) - - self.rightImageFramesScrollbar = widgets.ScrollBarWithNumericControl( - labelText='Frame n. ' - ) - self.rightImageFramesScrollbar.setVisible(False) - - bottomRightLayout.addWidget(self.annotOptionsWidgetRight) - bottomRightLayout.addWidget(self.rightImageFramesScrollbar) - bottomRightLayout.addStretch(1) - - self.rightBottomGroupbox.setLayout(bottomRightLayout) - - self.rightBottomGroupbox.toggled.connect(self.rightImageControlsToggled) - - def rightImageControlsToggled(self, checked): - if self.isDataLoading: - return - if checked: - self.annotateRightHowCombobox.setCurrentText( - self.drawIDsContComboBox.currentText() - ) - self.updateAllImages() - - def setFocusGraphics(self): - self.graphLayout.setFocus() - - def setFocusMain(self): - # on macOS with Qt6 setFocus causes crashes. Disabled for now. - return - - def resetFocus(self): - self.setFocusGraphics() - self.setFocusMain() - - def gui_createBottomWidgetsToBottomLayout(self): - # self.bottomDockWidget = QDockWidget(self) - bottomScrollArea = widgets.ScrollArea(resizeVerticalOnShow=True) - bottomScrollArea.sigLeaveEvent.connect(self.setFocusMain) - bottomWidget = QWidget() - bottomScrollAreaLayout = QVBoxLayout() - self.bottomLayout = QHBoxLayout() - self.bottomLayout.addLayout(self.quickSettingsLayout) - self.bottomLayout.addStretch(1) - self.bottomLayout.addWidget(self.img1BottomGroupbox) - self.bottomLayout.addStretch(1) - self.bottomLayout.addWidget(self.rightBottomGroupbox) - self.bottomLayout.addStretch(1) - - bottomScrollAreaLayout.addLayout(self.bottomLayout) - bottomScrollAreaLayout.addStretch(1) - - bottomWidget.setLayout(bottomScrollAreaLayout) - bottomScrollArea.setWidgetResizable(True) - bottomScrollArea.setWidget(bottomWidget) - self.bottomScrollArea = bottomScrollArea - - if 'bottom_sliders_zoom_perc' in self.df_settings.index: - val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value']) - zoom_perc = val - else: - zoom_perc = 100 - self.bottomLayoutContextMenu = QMenu('Bottom layout', self) - zoomMenu = self.bottomLayoutContextMenu.addMenu('Zoom') - actions = [] - self.bottomLayoutContextMenu.zoomActionGroup = QActionGroup(zoomMenu) - for perc in np.arange(50, 151, 10): - action = QAction(f'{perc}%', zoomMenu) - action.setCheckable(True) - if perc == zoom_perc: - action.setChecked(True) - action.toggled.connect(self.zoomBottomLayoutActionTriggered) - actions.append(action) - self.bottomLayoutContextMenu.zoomActionGroup.addAction(action) - zoomMenu.addActions(actions) - resetAction = self.bottomLayoutContextMenu.addAction( - 'Reset default height' - ) - resetAction.triggered.connect(self.resizeGui) - retainSpaceAction = self.bottomLayoutContextMenu.addAction( - 'Retain space of hidden sliders' - ) - retainSpaceAction.setCheckable(True) - if 'retain_space_hidden_sliders' in self.df_settings.index: - retainSpaceChecked = ( - self.df_settings.at['retain_space_hidden_sliders', 'value'] - == 'Yes' - ) - else: - retainSpaceChecked = True - retainSpaceAction.setChecked(retainSpaceChecked) - retainSpaceAction.toggled.connect(self.retainSpaceSlidersToggled) - self.retainSpaceSlidersAction = retainSpaceAction - self.setBottomLayoutStretch() - - def gui_resetBottomLayoutHeight(self): - self.h = self.defaultWidgetHeightBottomLayout - self.checkBoxesHeight = 14 - self.fontPixelSize = 11 - self.resizeSlidersArea() - - def gui_createGraphicsPlots(self): - self.graphLayout = pg.GraphicsLayoutWidget() - if self.invertBwAction.isChecked(): - self.graphLayout.setBackground(graphLayoutBkgrColor) - self.titleColor = 'black' - else: - self.graphLayout.setBackground(darkBkgrColor) - self.titleColor = 'white' - - self.lutItemsLayout = self.graphLayout.addLayout(row=1, col=0) - # self.lutItemsLayout.setBorder('w') - - # Left plot - self.ax1 = widgets.MainPlotItem(showWelcomeText=True) - self.ax1.invertY(True) - self.ax1.setAspectLocked(True) - self.ax1.hideAxis('bottom') - self.ax1.hideAxis('left') - self.plotsCol = 1 - self.graphLayout.addItem(self.ax1, row=1, col=1) - - # Right plot - self.ax2 = widgets.MainPlotItem() - self.ax2.setAspectLocked(True) - self.ax2.invertY(True) - self.ax2.hideAxis('bottom') - self.ax2.hideAxis('left') - # self.currentFrameLabelItem = pg.LabelItem( - # color=self.titleColor, size='13px' - # ) - self.graphLayout.addItem(self.ax2, row=1, col=2) - - def gui_addGraphicsItems(self): - # Auto image adjustment button - proxy = QGraphicsProxyWidget() - equalizeHistPushButton = QPushButton("Enhance contrast") - widthHint = equalizeHistPushButton.sizeHint().width() - equalizeHistPushButton.setMaximumWidth(widthHint) - equalizeHistPushButton.setCheckable(True) - if not self.invertBwAction.isChecked(): - equalizeHistPushButton.setStyleSheet( - 'QPushButton {background-color: #282828; color: #F0F0F0;}' - ) - self.equalizeHistPushButton = equalizeHistPushButton - proxy.setWidget(equalizeHistPushButton) - self.graphLayout.addItem(proxy, row=0, col=0) - self.equalizeHistPushButton = equalizeHistPushButton - - # Left image histogram - self.imgGrad = widgets.myHistogramLUTitem(parent=self, name='image') - self.imgGrad.restoreState(self.df_settings) - self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) - for action in self.imgGrad.rescaleActionGroup.actions(): - if action.text() == self.defaultRescaleIntensHow: - action.setChecked(True) - self.rescaleIntensMenu.addAction(action) - - # Colormap gradient widget - self.labelsGrad = widgets.labelsGradientWidget(parent=self) - try: - stateFound = self.labelsGrad.restoreState(self.df_settings) - except Exception as e: - self.logger.exception(traceback.format_exc()) - print('======================================') - self.logger.info( - 'Failed to restore previously used colormap. ' - 'Using default colormap "viridis"' - ) - self.labelsGrad.item.loadPreset('viridis') - - # Add actions to imgGrad gradient item - self.imgGrad.gradient.menu.addAction( - self.labelsGrad.showLabelsImgAction - ) - self.imgGrad.gradient.menu.addAction( - self.labelsGrad.showRightImgAction - ) - self.imgGrad.gradient.menu.addAction( - self.labelsGrad.showNextFrameAction - ) - - self.imgGrad.gradient.menu.addSeparator() - - self.imgGrad.gradient.menu.addMenu(self.exportMenu) - - # Add actions to view menu - self.viewMenu.addAction(self.labelsGrad.showLabelsImgAction) - self.viewMenu.addAction(self.labelsGrad.showRightImgAction) - - # Right image histogram - self.imgGradRight = widgets.baseHistogramLUTitem( - name='image', parent=self, gradientPosition='left' - ) - self.imgGradRight.gradient.menu.addAction( - self.labelsGrad.showLabelsImgAction - ) - self.imgGradRight.gradient.menu.addAction( - self.labelsGrad.showRightImgAction - ) - self.imgGradRight.gradient.menu.addAction( - self.labelsGrad.showNextFrameAction - ) - - self.imgGrad.setChildLutItem(self.imgGradRight) - - # Title - self.titleLabel = pg.LabelItem( - justify='center', color=self.titleColor, size='14pt' - ) - self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) - - def gui_createTextAnnotColors(self, r, g, b, custom=False): - if custom: - self.objLabelAnnotRgb = (int(r), int(g), int(b)) - self.SphaseAnnotRgb = (int(r*0.9), int(r*0.9), int(b*0.9)) - self.G1phaseAnnotRgba = (int(r*0.8), int(g*0.8), int(b*0.8), 220) - else: - self.objLabelAnnotRgb = (255, 255, 255) # white - self.SphaseAnnotRgb = (229, 229, 229) - self.G1phaseAnnotRgba = (204, 204, 204, 220) - self.dividedAnnotRgb = (245, 188, 1) # orange - - self.emptyBrush = pg.mkBrush((0,0,0,0)) - self.emptyPen = pg.mkPen((0,0,0,0)) - - def gui_setTextAnnotColors(self): - self.textAnnot[0].setColors( - self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, - self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb - ) - - self.textAnnot[1].setColors( - self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, - self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb - ) - - - def gui_createPlotItems(self): - if 'textIDsColor' in self.df_settings.index: - rgbString = self.df_settings.at['textIDsColor', 'value'] - r, g, b = colors.rgb_str_to_values(rgbString) - self.gui_createTextAnnotColors(r, g, b, custom=True) - self.textIDsColorButton.setColor((r, g, b)) - else: - self.gui_createTextAnnotColors(0,0,0, custom=False) - - if 'labels_text_color' in self.df_settings.index: - rgbString = self.df_settings.at['labels_text_color', 'value'] - r, g, b = colors.rgb_str_to_values(rgbString) - self.ax2_textColor = (r, g, b) - else: - self.ax2_textColor = (255, 0, 0) - - self.emptyLab = np.zeros((2,2), dtype=np.uint8) - - # Right image item linked to left - self.rightImageItem = widgets.ChildImageItem( - linkedScrollbar=self.rightImageFramesScrollbar - ) - self.imgGradRight.setImageItem(self.rightImageItem) - self.ax2.addItem(self.rightImageItem) - - # Left image - self.img1 = widgets.ParentImageItem( - linkedImageItem=self.rightImageItem, - activatingActions=( - self.labelsGrad.showRightImgAction, - self.labelsGrad.showNextFrameAction - ) - ) - self.imgGrad.setImageItem(self.img1) - self.img1.lutItem = self.imgGrad - self.imgGrad.sigRescaleIntes.connect(self.rescaleIntensitiesLut) - self.ax1.addBaseImageItem(self.img1) - - # RGBA image for true transparency mode - self.rgbaImg1 = pg.ImageItem() - - # self.rgbaImg1.setImage(self.emptyLab) - - # Right image - self.img2 = widgets.labImageItem() - self.ax2.addItem(self.img2) - - self.topLayerItems = [] - self.topLayerItemsRight = [] - - self.gui_createContourPens() - self.gui_createMothBudLinePens() - - self.eraserCirclePen = pg.mkPen(width=1.5, color='r') - - # Temporary line item connecting bud to new mother - self.BudMothTempLine = pg.PlotDataItem(pen=self.NewBudMoth_Pen) - self.topLayerItems.append(self.BudMothTempLine) - - # Temporary line item connecting objects to merge - self.mergeObjsTempLine = widgets.PlotCurveItem(pen=self.redDashLinePen) - self.topLayerItems.append(self.mergeObjsTempLine) - - # Overlay segm. masks item - self.labelsLayerImg1 = widgets.BaseLabelsImageItem() - self.ax1.addItem(self.labelsLayerImg1) - - self.labelsLayerRightImg = widgets.BaseLabelsImageItem() - self.ax2.addItem(self.labelsLayerRightImg) - - # Red/green border rect item - self.GreenLinePen = pg.mkPen(color='g', width=2) - self.RedLinePen = pg.mkPen(color='r', width=2) - self.ax1BorderLine = pg.PlotDataItem() - self.topLayerItems.append(self.ax1BorderLine) - self.ax2BorderLine = pg.PlotDataItem(pen=pg.mkPen(color='r', width=2)) - self.topLayerItems.append(self.ax2BorderLine) - - # Brush/Eraser/Wand.. layer item - self.tempLayerRightImage = pg.ImageItem() - self.tempLayerImg1 = widgets.ParentImageItem( - linkedImageItem=self.tempLayerRightImage, - activatingAction=(self.labelsGrad.showRightImgAction, ) - ) - self.topLayerItems.append(self.tempLayerImg1) - self.topLayerItemsRight.append(self.tempLayerRightImage) - - # Highlighted ID layer items - self.highLightIDLayerImg1 = pg.ImageItem() - self.topLayerItems.append(self.highLightIDLayerImg1) - - # Highlighted ID layer items - self.highLightIDLayerRightImage = pg.ImageItem() - self.topLayerItemsRight.append(self.highLightIDLayerRightImage) - - # Keep IDs temp layers - self.keepIDsTempLayerRight = pg.ImageItem() - self.keepIDsTempLayerLeft = widgets.ParentImageItem( - linkedImageItem=self.keepIDsTempLayerRight, - activatingAction=self.labelsGrad.showRightImgAction - ) - self.topLayerItems.append(self.keepIDsTempLayerLeft) - self.topLayerItemsRight.append(self.keepIDsTempLayerRight) - - # Searched ID contour - self.searchedIDitemRight = pg.ScatterPlotItem() - self.searchedIDitemRight.setData( - [], [], symbol='s', pxMode=False, size=1, - brush=pg.mkBrush(color=(255,0,0,150)), - pen=pg.mkPen(width=2, color='r'), tip=None - ) - self.searchedIDitemLeft = pg.ScatterPlotItem() - self.searchedIDitemLeft.setData( - [], [], symbol='s', pxMode=False, size=1, - brush=pg.mkBrush(color=(255,0,0,150)), - pen=pg.mkPen(width=2, color='r'), tip=None - ) - self.topLayerItems.append(self.searchedIDitemLeft) - self.topLayerItemsRight.append(self.searchedIDitemRight) - - - # Brush circle img1 - self.ax1_BrushCircle = pg.ScatterPlotItem() - self.ax1_BrushCircle.setData( - [], [], symbol='o', pxMode=False, - brush=pg.mkBrush((255,255,255,50)), - pen=pg.mkPen(width=2), tip=None - ) - self.topLayerItems.append(self.ax1_BrushCircle) - - # Eraser circle img1 - self.ax1_EraserCircle = pg.ScatterPlotItem() - self.ax1_EraserCircle.setData( - [], [], symbol='o', pxMode=False, - brush=None, pen=self.eraserCirclePen, tip=None - ) - self.topLayerItems.append(self.ax1_EraserCircle) - - self.ax1_EraserX = pg.ScatterPlotItem() - self.ax1_EraserX.setData( - [], [], symbol='x', pxMode=False, size=3, - brush=pg.mkBrush(color=(255,0,0,50)), - pen=pg.mkPen(width=1, color='r'), tip=None - ) - self.topLayerItems.append(self.ax1_EraserX) - - # Brush circle img1 - self.labelRoiCircItemLeft = widgets.LabelRoiCircularItem() - self.labelRoiCircItemLeft.cleared = False - self.labelRoiCircItemLeft.setData( - [], [], symbol='o', pxMode=False, - brush=pg.mkBrush(color=(255,0,0,0)), - pen=pg.mkPen(color='r', width=2), tip=None - ) - self.labelRoiCircItemRight = widgets.LabelRoiCircularItem() - self.labelRoiCircItemRight.cleared = False - self.labelRoiCircItemRight.setData( - [], [], symbol='o', pxMode=False, - brush=pg.mkBrush(color=(255,0,0,0)), - pen=pg.mkPen(color='r', width=2), tip=None - ) - self.topLayerItems.append(self.labelRoiCircItemLeft) - self.topLayerItemsRight.append(self.labelRoiCircItemRight) - - self.ax1_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() - self.ax1_binnedIDs_ScatterPlot.setData( - [], [], symbol='t', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=3, color='r'), tip=None - ) - self.topLayerItems.append(self.ax1_binnedIDs_ScatterPlot) - - self.ax1_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() - self.ax1_ripIDs_ScatterPlot.setData( - [], [], symbol='x', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=2, color='r'), tip=None - ) - self.topLayerItems.append(self.ax1_ripIDs_ScatterPlot) - - # Ruler plotItem and scatterItem - rulerPen = pg.mkPen(color='r', style=Qt.DashLine, width=2) - self.ax1_rulerPlotItem = widgets.RulerPlotItem(pen=rulerPen) - self.ax1_rulerAnchorsItem = pg.ScatterPlotItem( - symbol='o', size=9, - brush=pg.mkBrush((255,0,0,50)), - pen=pg.mkPen((255,0,0), width=2), tip=None - ) - self.topLayerItems.append(self.ax1_rulerPlotItem) - self.topLayerItems.append(self.ax1_rulerPlotItem.labelItem) - self.topLayerItems.append(self.ax1_rulerAnchorsItem) - - # Start point of polyline roi - self.ax1_point_ScatterPlot = pg.ScatterPlotItem() - self.ax1_point_ScatterPlot.setData( - [], [], symbol='o', pxMode=False, size=3, - pen=pg.mkPen(width=2, color='r'), - brush=pg.mkBrush((255,0,0,50)), tip=None - ) - self.topLayerItems.append(self.ax1_point_ScatterPlot) - - # Experimental: scatter plot to add a point marker - self.startPointPolyLineItem = pg.ScatterPlotItem() - self.startPointPolyLineItem.setData( - [], [], symbol='o', size=9, - pen=pg.mkPen(width=2, color='r'), - brush=pg.mkBrush((255,0,0,50)), - hoverable=True, hoverBrush=pg.mkBrush((255,0,0,255)), tip=None - ) - self.topLayerItems.append(self.startPointPolyLineItem) - - # Eraser circle img2 - self.ax2_EraserCircle = pg.ScatterPlotItem() - self.ax2_EraserCircle.setData( - [], [], symbol='o', pxMode=False, brush=None, - pen=self.eraserCirclePen, tip=None - ) - self.ax2.addItem(self.ax2_EraserCircle) - self.ax2_EraserX = pg.ScatterPlotItem() - self.ax2_EraserX.setData( - [], [], symbol='x', pxMode=False, size=3, - brush=pg.mkBrush(color=(255,0,0,50)), - pen=pg.mkPen(width=1.5, color='r') - ) - self.ax2.addItem(self.ax2_EraserX) - - # Brush circle img2 - self.ax2_BrushCirclePen = pg.mkPen(width=2) - self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50)) - self.ax2_BrushCircle = pg.ScatterPlotItem() - self.ax2_BrushCircle.setData( - [], [], symbol='o', pxMode=False, - brush=self.ax2_BrushCircleBrush, - pen=self.ax2_BrushCirclePen, tip=None - ) - self.ax2.addItem(self.ax2_BrushCircle) - - # Annotated metadata markers (ScatterPlotItem) - self.ax2_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() - self.ax2_binnedIDs_ScatterPlot.setData( - [], [], symbol='t', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=3, color='r'), tip=None - ) - self.ax2.addItem(self.ax2_binnedIDs_ScatterPlot) - - self.ax2_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() - self.ax2_ripIDs_ScatterPlot.setData( - [], [], symbol='x', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=2, color='r'), tip=None - ) - self.ax2.addItem(self.ax2_ripIDs_ScatterPlot) - - self.freeRoiItem = widgets.PlotCurveItem( - pen=pg.mkPen(color='r', width=2) - ) - self.topLayerItems.append(self.freeRoiItem) - - self.warnPairingItem = widgets.PlotCurveItem( - pen=pg.mkPen(color='r', width=5, style=Qt.DashLine), - pxMode=False - ) - self.topLayerItems.append(self.warnPairingItem) - - self.exportMaskImageItem = pg.ImageItem() - - self.ghostContourItemLeft = widgets.GhostContourItem(self.ax1) - self.ghostContourItemRight = widgets.GhostContourItem(self.ax2) - - self.ghostMaskItemLeft = widgets.GhostMaskItem(self.ax1) - self.ghostMaskItemRight = widgets.GhostMaskItem(self.ax2) - - self.manualBackgroundObjItem = widgets.GhostContourItem( - self.ax1, penColor='r', textColor='r' - ) - self.manualBackgroundImageItem = pg.ImageItem() - - def gui_createZoomRectItem(self): - Y, X = self.currentLab2D.shape - # Label ROI rectangle - pen = pg.mkPen('r', width=3, style=Qt.DashLine) - self.zoomRectItem = widgets.ZoomROI( - (0,0), (0,0), - maxBounds=QRectF(QRect(0,0,X,Y)), - scaleSnap=True, - translateSnap=True, - pen=pen, hoverPen=pen - ) - - def gui_createLabelRoiItem(self): - Y, X = self.currentLab2D.shape - # Label ROI rectangle - pen = pg.mkPen('r', width=3) - self.labelRoiItem = widgets.ROI( - (0,0), (0,0), - maxBounds=QRectF(QRect(0,0,X,Y)), - scaleSnap=True, - translateSnap=True, - pen=pen, hoverPen=pen - ) - - posData = self.data[self.pos_i] - if self.labelRoiZdepthSpinbox.value() == 0: - self.labelRoiZdepthSpinbox.setValue(posData.SizeZ) - self.labelRoiZdepthSpinbox.setMaximum(posData.SizeZ+1) - - def gui_createOverlayColors(self): - fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] - self.logger.info( - f'Number of TIFF files detected: {len(fluoChannels)}' - ) - self.overlayColors = {} - for c, ch in enumerate(fluoChannels): - if f'{ch}_rgb' in self.df_settings.index: - rgb_text = self.df_settings.at[f'{ch}_rgb', 'value'] - rgb = tuple([int(val) for val in rgb_text.split('_')]) - self.overlayColors[ch] = rgb - else: - if c >= len(self.overlayRGBs) -1: - i = c/len(fluoChannels) - additional_color_num = c - len(self.overlayRGBs) + 1 - rgbs = [ - tuple([round(c*255) for c in self.overlayCmap(i)][:3]) - for _ in range(additional_color_num) - ] - self.overlayRGBs.extend(rgbs) - rgb = colors.FLUO_CHANNELS_COLORS.get(ch, self.overlayRGBs[c]) - self.overlayColors[ch] = rgb - - def gui_createOverlayItems(self): - self.imgGrad.setAxisLabel(self.user_ch_name) - self.baseLayerToolbutton = widgets.OverlayChannelToolButton( - self.user_ch_name, self.imgGrad - ) - self.baseLayerToolbutton.setChecked(True) - self.baseLayerToolbutton.clicked.connect( - self.overlayChannelToolbuttonClicked - ) - self.allOverlayToolbuttons = { - self.user_ch_name: self.baseLayerToolbutton - } - self.allOverlayToolbuttonsByIdx = { - 0: self.baseLayerToolbutton - } - self.baseLayerToolbutton.action = ( - self.overlayToolbar.addWidget(self.baseLayerToolbutton) - ) - self.overlayLayersItems = {} - self.overlayToolbarAreChannelsChecked = {} - fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] - for c, ch in enumerate(fluoChannels): - overlayItems = self.getOverlayItems(ch, c+1) - self.overlayLayersItems[ch] = overlayItems - imageItem, lutItem = overlayItems[:2] - self.ax1.addItem(imageItem) - self.lutItemsLayout.addItem(lutItem, row=0, col=c+1) - toolbutton = overlayItems[3] - self.allOverlayToolbuttons[ch] = toolbutton - self.allOverlayToolbuttonsByIdx[c+1] = toolbutton - - self.overlayToolbuttonsSep = self.overlayToolbar.addSeparator() - self.plotsCol = len(self.ch_names) - - self.ax1.addImageItem(self.rgbaImg1) - - def gui_getLostObjScatterItem(self): - self.objLostAnnotRgb = (245, 184, 0) - brush = pg.mkBrush((*self.objLostAnnotRgb, 150)) - pen = pg.mkPen(self.objLostAnnotRgb, width=1) - lostObjScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight+1, pen=pen, - brush=brush, pxMode=False, symbol='s' - ) - return lostObjScatterItem - - def gui_getTrackedLostObjScatterItem(self): - self.objLostTrackedAnnotRgb = (0, 255, 0) - brush = pg.mkBrush((*self.objLostTrackedAnnotRgb, 150)) - pen = pg.mkPen(self.objLostTrackedAnnotRgb, width=1) - lostObjScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight+1, pen=pen, - brush=brush, pxMode=False, symbol='s' - ) - return lostObjScatterItem - - def _gui_createGraphicsItems(self): - for _posData in self.data: - _posData.allData_li = [None]*_posData.SizeT - - posData = self.data[self.pos_i] - - allIDs, posData = core.count_objects(posData, self.logger.info) - - self.highLowResAction.setChecked(True) - numItems = len(allIDs) - if numItems > 1500: - cancel, switchToLowRes = _warnings.warnTooManyItems( - self, numItems, self.progressWin - ) - if cancel: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.loadingDataAborted() - return - if switchToLowRes: - self.highLowResAction.setChecked(False) - else: - # Many items requires pxMode active to be fast enough - self.pxModeAction.setChecked(True) - - self.logger.info(f'Creating graphical items...') - - self.ax1_contoursImageItem = pg.ImageItem() - - self.ax1_lostObjImageItem = pg.ImageItem() - self.ax2_lostObjImageItem = pg.ImageItem() - - self.ax1_lostTrackedObjImageItem = pg.ImageItem() - self.ax2_lostTrackedObjImageItem = pg.ImageItem() - - self.ax1_oldMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, - size=self.mothBudLineWeight, pen=None - ) - self.ax1_newMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.newMothBudLineBrush, - size=self.mothBudLineWeight, pen=None - ) - self.ax1_lostObjScatterItem = self.gui_getLostObjScatterItem() - self.yellowContourScatterItem = self.gui_getLostObjScatterItem() - - self.ax1_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() - self.greenContourScatterItem = self.gui_getTrackedLostObjScatterItem() - - brush = pg.mkBrush((0,255,0,200)) - pen = pg.mkPen('g', width=1) - self.ccaFailedScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight+1, pen=pen, - brush=brush, pxMode=False, symbol='s' - ) - - self.ax2_contoursImageItem = pg.ImageItem() - self.ax2_oldMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, - size=self.mothBudLineWeight, pen=None - ) - self.ax2_newMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.newMothBudLineBrush, - size=self.mothBudLineWeight, pen=None - ) - self.ax2_lostObjScatterItem = self.gui_getLostObjScatterItem() - self.ax2_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() - - self.gui_createTextAnnotItems(allIDs) # here - self.gui_setTextAnnotColors()# here - - self.setDisabledAnnotOptions(False) - - self.progressWin.mainPbar.setMaximum(0) - self.gui_addOverlayLayerItems() - self.gui_addTopLayerItems() - - self.gui_addCreatedAxesItems() - self.gui_add_ax_cursors() - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.loadingDataCompleted() - - def gui_createTextAnnotItems(self, allIDs): - self.textAnnot = {} - isHighResolution = self.highLowResAction.isChecked() - pxMode = self.pxModeAction.isChecked() - for ax in range(2): - ax_textAnnot = annotate.TextAnnotations() - ax_textAnnot.initFonts(self.fontSize) - ax_textAnnot.createItems( - isHighResolution, allIDs, pxMode=pxMode - ) - self.textAnnot[ax] = ax_textAnnot - - def gui_addOverlayLayerItems(self): - for items in self.overlayLabelsItems.values(): - imageItem, contoursItem, gradItem = items - self.ax1.addItem(imageItem) - self.ax1.addItem(contoursItem) - - def gui_addTopLayerItems(self): - for item in self.topLayerItems: - self.ax1.addItem(item) - - for item in self.topLayerItemsRight: - self.ax2.addItem(item) - - # self.ax2.addItem(self.currentFrameLabelItem) - - def gui_createMothBudLinePens(self): - if 'mothBudLineSize' in self.df_settings.index: - val = self.df_settings.at['mothBudLineSize', 'value'] - self.mothBudLineWeight = int(val) - else: - self.mothBudLineWeight = 2 - - self.newMothBudlineColor = (255, 0, 0) - if 'mothBudLineColor' in self.df_settings.index: - val = self.df_settings.at['mothBudLineColor', 'value'] - rgba = colors.rgba_str_to_values(val) - self.mothBudLineColor = rgba[0:3] - else: - self.mothBudLineColor = (255,165,0) - - try: - self.imgGrad.mothBudLineColorButton.sigColorChanging.disconnect() - self.imgGrad.mothBudLineColorButton.sigColorChanged.disconnect() - except Exception as e: - pass - try: - for act in self.imgGrad.mothBudLineWightActionGroup.actions(): - act.toggled.disconnect() - except Exception as e: - pass - for act in self.imgGrad.mothBudLineWightActionGroup.actions(): - if act.lineWeight == self.mothBudLineWeight: - act.setChecked(True) - else: - act.setChecked(False) - self.imgGrad.mothBudLineColorButton.setColor(self.mothBudLineColor[:3]) - - self.imgGrad.mothBudLineColorButton.sigColorChanging.connect( - self.updateMothBudLineColour - ) - self.imgGrad.mothBudLineColorButton.sigColorChanged.connect( - self.saveMothBudLineColour - ) - for act in self.imgGrad.mothBudLineWightActionGroup.actions(): - act.toggled.connect(self.mothBudLineWeightToggled) - - # MOther-bud lines brushes - self.NewBudMoth_Pen = pg.mkPen( - color=self.newMothBudlineColor, width=self.mothBudLineWeight+1, - style=Qt.DashLine - ) - self.OldBudMoth_Pen = pg.mkPen( - color=self.mothBudLineColor, width=self.mothBudLineWeight, - style=Qt.DashLine - ) - - self.redDashLinePen = pg.mkPen( - color='r', width=2, style=Qt.DashLine - ) - - self.oldMothBudLineBrush = pg.mkBrush(self.mothBudLineColor) - self.newMothBudLineBrush = pg.mkBrush(self.newMothBudlineColor) - - def gui_createContourPens(self): - if 'contLineWeight' in self.df_settings.index: - val = self.df_settings.at['contLineWeight', 'value'] - self.contLineWeight = int(val) - else: - self.contLineWeight = 1 - if 'contLineColor' in self.df_settings.index: - val = self.df_settings.at['contLineColor', 'value'] - rgba = colors.rgba_str_to_values(val) - self.contLineColor = rgba - self.newIDlineColor = [min(255, v+50) for v in self.contLineColor] - else: - self.contLineColor = (255, 0, 0, 200) - self.newIDlineColor = (255, 0, 0, 255) - - try: - self.imgGrad.contoursColorButton.sigColorChanging.disconnect() - self.imgGrad.contoursColorButton.sigColorChanged.disconnect() - except Exception as e: - pass - try: - for act in self.imgGrad.contLineWightActionGroup.actions(): - act.toggled.disconnect() - except Exception as e: - pass - for act in self.imgGrad.contLineWightActionGroup.actions(): - if act.lineWeight == self.contLineWeight: - act.setChecked(True) - self.imgGrad.contoursColorButton.setColor(self.contLineColor[:3]) - - self.imgGrad.contoursColorButton.sigColorChanging.connect( - self.updateContColour - ) - self.imgGrad.contoursColorButton.sigColorChanged.connect( - self.saveContColour - ) - for act in self.imgGrad.contLineWightActionGroup.actions(): - act.toggled.connect(self.contLineWeightToggled) - - # Contours pens - self.oldIDs_cpen = pg.mkPen( - color=self.contLineColor, width=self.contLineWeight - ) - self.newIDs_cpen = pg.mkPen( - color=self.newIDlineColor, width=self.contLineWeight+1 - ) - self.tempNewIDs_cpen = pg.mkPen( - color='g', width=self.contLineWeight+1 - ) - - def gui_createGraphicsItems(self): - # Create enough PlotDataItems and LabelItems to draw contours and IDs. - self.progressWin = apps.QDialogWorkerProgress( - title='Creating axes items', parent=self, - pbarDesc='Creating axes items (see progress in the terminal)...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - QTimer.singleShot(50, self._gui_createGraphicsItems) - - def gui_connectGraphicsEvents(self): - self.img1.hoverEvent = self.gui_hoverEventImg1 - self.img2.hoverEvent = self.gui_hoverEventImg2 - self.img1.mousePressEvent = self.gui_mousePressEventImg1 - self.img1.mouseMoveEvent = self.gui_mouseDragEventImg1 - self.img1.mouseReleaseEvent = self.gui_mouseReleaseEventImg1 - self.img2.mousePressEvent = self.gui_mousePressEventImg2 - self.img2.mouseMoveEvent = self.gui_mouseDragEventImg2 - self.img2.mouseReleaseEvent = self.gui_mouseReleaseEventImg2 - self.rightImageItem.mousePressEvent = self.gui_mousePressRightImage - self.rightImageItem.mouseMoveEvent = self.gui_mouseDragRightImage - self.rightImageItem.mouseReleaseEvent = self.gui_mouseReleaseRightImage - self.rightImageItem.hoverEvent = self.gui_hoverEventRightImage - # self.imgGrad.gradient.showMenu = self.gui_gradientContextMenuEvent - self.imgGradRight.gradient.showMenu = self.gui_rightImageShowContextMenu - # self.imgGrad.vb.contextMenuEvent = self.gui_gradientContextMenuEvent - self.ax1.sigRangeChanged.connect(self.viewRangeChanged) - - def gui_initImg1BottomWidgets(self): - self.zSliceScrollBar.hide() - self.zProjComboBox.hide() - self.zProjLockViewButton.hide() - self.zSliceOverlay_SB.hide() - self.zProjOverlay_CB.hide() - self.overlay_z_label.hide() - self.zSliceCheckbox.hide() - self.zSliceSpinbox.hide() - self.SizeZlabel.hide() - - @exception_handler - def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): - modifiers = QGuiApplication.keyboardModifiers() - alt = modifiers == Qt.AltModifier - shift = modifiers == Qt.ShiftModifier - shift_regardless = bool(modifiers & Qt.ShiftModifier) - isMod = alt - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - left_click = event.button() == Qt.MouseButton.LeftButton and not alt - middle_click = self.isMiddleClick(event, modifiers) - right_click = event.button() == Qt.MouseButton.RightButton and not alt - isPanImageClick = self.isPanImageClick(event, modifiers) - eraserON = self.eraserButton.isChecked() - brushON = self.brushButton.isChecked() - separateON = self.separateBudButton.isChecked() - self.typingEditID = False - - # Drag image if neither brush or eraser are On pressed - dragImg = ( - left_click and not eraserON and not - brushON and not middle_click - ) - if isPanImageClick: - dragImg = True - - # Enable dragging of the image window like pyqtgraph original code - if dragImg: - pg.ImageItem.mousePressEvent(self.img2, event) - event.ignore() - return - - if mode == 'Viewer' and middle_click: - self.startBlinkingModeCB() - event.ignore() - return - - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - Y, X = self.get_2Dlab(posData.lab).shape - if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - else: - return - - # Check if right click on ROI - isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click) - if isClickOnDelRoi: - return - - # show gradient widget menu if none of the right-click actions are ON - # and event is not coming from image 1 - is_right_click_action_ON = any([ - b.isChecked() for b in self.checkableQButtonsGroup.buttons() - ]) - is_right_click_custom_ON = any([ - b.isChecked() for b in self.customAnnotDict.keys() - ]) - is_event_from_img1 = False - if hasattr(event, 'isImg1Sender'): - is_event_from_img1 = event.isImg1Sender - - is_only_right_click = ( - right_click and not is_right_click_action_ON and not middle_click - ) - - showLabelsGradMenu = ( - is_only_right_click and not is_event_from_img1 - ) - - if showLabelsGradMenu: - self.labelsGrad.showMenu(event) - event.ignore() - return - - editInViewerMode = ( - (is_right_click_action_ON or is_right_click_custom_ON) - and (right_click or middle_click) and mode=='Viewer' - ) - - if editInViewerMode: - self.startBlinkingModeCB() - event.ignore() - return - - # Left-click is used for brush, eraser, separate bud, curvature tool - # and magic labeller - # Brush and eraser are mutually exclusive but we want to keep the eraser - # or brush ON and disable them temporarily to allow left-click with - # separate ON - canDelete = mode == 'Segmentation and Tracking' or self.isSnapshot - - # Delete ID (set to 0) - if middle_click and canDelete: - t0 = time.perf_counter() - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - delID = self.get_2Dlab(posData.lab)[ydata, xdata] - if delID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - delID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.
' - 'Enter here ID(s) that you want to delete

' - 'You can enter multiple IDs separated by comma', - parent=self, - allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - allowList=True, - isInteger=True - ) - delID_prompt.exec_() - if delID_prompt.cancel: - return - delIDs = delID_prompt.EntryID - else: - delIDs = [delID] - - # Ask to propagate change to all future visited frames - key = 'Delete ID' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - delIDs, key, doNotShow, - posData.UndoFutFrames_DelID, posData.applyFutFrames_DelID - ) - - if UndoFutFrames is None: - return - - # Store undo state before modifying stuff - self.storeUndoRedoStates(UndoFutFrames) - posData.doNotShowAgain_DelID = doNotShowAgain - posData.UndoFutFrames_DelID = UndoFutFrames - posData.applyFutFrames_DelID = applyFutFrames - includeUnvisited = posData.includeUnvisitedInfo['Delete ID'] - - delID_mask = self.deleteIDmiddleClick( - delIDs, applyFutFrames, includeUnvisited, shift=shift_regardless - ) - if delID_mask.ndim == 3: - delID_mask = delID_mask[self.z_lab()] - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete ID') - else: - self.warnEditingWithCca_df('Delete ID', update_images=False) - - self.setImageImg2() - delROIsIDs = self.setAllTextAnnotations() - self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False) - - how = self.drawIDsContComboBox.currentText() - if how.find('overlay segm. masks') != -1: - self.labelsLayerImg1.image[delID_mask] = 0 - self.labelsLayerImg1.setImage(self.labelsLayerImg1.image) - - how_ax2 = self.getAnnotateHowRightImage() - if how_ax2.find('overlay segm. masks') != -1: - self.labelsLayerRightImg.image[delID_mask] = 0 - self.labelsLayerRightImg.setImage(self.labelsLayerRightImg.image) - - self.highlightLostNew() - - # Separate bud or objects with same ID - elif right_click and separateON: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x) - sepID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here ID that you want to split', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - sepID_prompt.exec_() - if sepID_prompt.cancel: - return - else: - ID = sepID_prompt.EntryID - y, x = posData.rp[posData.IDs_idxs[ID]].centroid[-2:] - xdata, ydata = int(x), int(y) - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - max_ID = max(posData.IDs, default=1) - - if self.isSegm3D and not shift: - z = self.zSliceScrollBar.sliderPosition() - posData.lab, splittedIDs = measure.separate_with_label( - posData.lab, posData.rp, [ID], max_ID, - click_coords_list=[(z, ydata, xdata)] - ) - success = True - # self.set_2Dlab(lab2D) - elif not shift: - result = core.split_along_convexity_defects( - ID, self.get_2Dlab(posData.lab), max_ID - ) - lab2D, success, splittedIDs = result - self.set_2Dlab(lab2D) - else: - success = False - - # If automatic bud separation was not successfull call manual one - if not success: - posData.disableAutoActivateViewerWindow = True - img = self.getDisplayedImg1() - col = 'manual_separate_draw_mode' - drawMode = self.df_settings.at[col, 'value'] - manualSep = apps.manualSeparateGui( - self.get_2Dlab(posData.lab), ID, img, - fontSize=self.fontSize, - IDcolor=self.lut[ID], - parent=self, - drawMode=drawMode - ) - manualSep.setState(self.lastManualSeparateState) - manualSep.show() - manualSep.centerWindow() - manualSep.show(block=True) - if manualSep.cancel: - posData.disableAutoActivateViewerWindow = False - if not self.separateBudButton.findChild(QAction).isChecked(): - self.separateBudButton.setChecked(False) - return - self.lastManualSeparateState = manualSep.state() - lab2D = self.get_2Dlab(posData.lab) - lab2D[manualSep.lab!=0] = manualSep.lab[manualSep.lab!=0] - self.set_2Dlab(lab2D) - splittedIDs = [obj.label for obj in manualSep.rp] - posData.disableAutoActivateViewerWindow = False - self.storeManualSeparateDrawMode(manualSep.drawMode) - - # Update data (rp, etc) - self.update_rp() - - # Repeat tracking - self.trackSubsetIDs(splittedIDs) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Separate IDs') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Separate IDs') - - self.store_data() - - if not self.separateBudButton.findChild(QAction).isChecked(): - self.separateBudButton.setChecked(False) - - # Fill holes - elif right_click and self.fillHolesToolButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - clickedBkgrID = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here the ID that you want to ' - 'fill the holes of', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - clickedBkgrID.exec_() - if clickedBkgrID.cancel: - return - else: - ID = clickedBkgrID.EntryID - - if ID in posData.lab: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - obj_idx = posData.IDs.index(ID) - obj = posData.rp[obj_idx] - objMask = self.getObjImage(obj.image, obj.bbox) - localFill = scipy.ndimage.binary_fill_holes(objMask) - posData.lab[self.getObjSlice(obj.slice)][localFill] = ID - - self.update_rp() - self.updateAllImages() - - if not self.fillHolesToolButton.findChild(QAction).isChecked(): - self.fillHolesToolButton.setChecked(False) - - # Hull contour - elif right_click and self.hullContToolButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - mergeID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here the ID that you want to ' - 'replace with Hull contour', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - mergeID_prompt.exec_() - if mergeID_prompt.cancel: - return - else: - ID = mergeID_prompt.EntryID - - if ID in posData.lab: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - obj_idx = posData.IDs.index(ID) - obj = posData.rp[obj_idx] - objMask = self.getObjImage(obj.image, obj.bbox) - localHull = skimage.morphology.convex_hull_image(objMask) - posData.lab[self.getObjSlice(obj.slice)][localHull] = ID - - self.update_rp() - self.updateAllImages() - - if not self.hullContToolButton.findChild(QAction).isChecked(): - self.hullContToolButton.setChecked(False) - - # Move label - elif right_click and self.moveLabelToolButton.isChecked(): - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - x, y = event.pos().x(), event.pos().y() - self.startMovingLabel(x, y) - - # Fill holes - elif right_click and self.fillHolesToolButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - clickedBkgrID = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here the ID that you want to ' - 'fill the holes of', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - clickedBkgrID.exec_() - if clickedBkgrID.cancel: - return - else: - ID = clickedBkgrID.EntryID - - # Merge IDs - elif right_click and self.mergeIDsButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - mergeID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here first ID that you want to merge', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - mergeID_prompt.exec_() - if mergeID_prompt.cancel: - self.mergeObjsTempLine.setData([], []) - return - else: - ID = mergeID_prompt.EntryID - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - self.firstID = ID - - obj_idx = posData.IDs_idxs[ID] - obj = posData.rp[obj_idx] - yc, xc = self.getObjCentroid(obj.centroid) - self.clickObjYc, self.clickObjXc = int(yc), int(xc) - - # Edit ID - elif right_click and self.editIDbutton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - editID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here ID that you want to replace with a new one', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - editID_prompt.show(block=True) - - if editID_prompt.cancel: - return - else: - ID = editID_prompt.EntryID - - obj_idx = posData.IDs_idxs[ID] - y, x = posData.rp[obj_idx].centroid[-2:] - xdata, ydata = int(x), int(y) - - posData.disableAutoActivateViewerWindow = True - currentIDs = posData.IDs.copy() - self.setAllIDs(onlyVisited=True) - addPropagateCheckbox = ( - not self.isSnapshot - and posData.frame_i == self.navigateScrollBar.maximum() - 1 - and posData.frame_i < posData.SizeT - 1 - ) - editID = apps.EditIDDialog( - ID, posData.IDs, - doNotShowAgain=self.doNotAskAgainExistingID, - parent=self, - entryID=self.getNearestLostObjID(y, x), - nextUniqueID=self.setBrushID(return_val=True), - allIDs=posData.allIDs, - addPropagateCheckbox=addPropagateCheckbox - ) - editID.show(block=True) - if editID.cancel: - posData.disableAutoActivateViewerWindow = False - if not self.editIDbutton.findChild(QAction).isChecked(): - self.editIDbutton.setChecked(False) - return - - if editID.assignNewID: - self.assignNewIDfromClickedID(ID, event) - return - - if not self.doNotAskAgainExistingID: - self.editIDmergeIDs = editID.mergeWithExistingID - self.doNotAskAgainExistingID = editID.doNotAskAgainExistingID - - self.applyEditID( - ID, currentIDs, editID.how, x, y, - shift=shift, - doPropagateUnvisited=editID.doPropagateFutureFrames - ) - - elif (right_click or left_click) and self.keepIDsButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - keepID_win = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to keep', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - keepID_win.exec_() - if keepID_win.cancel: - return - else: - ID = keepID_win.EntryID - - if ID in self.keptObjectsIDs: - self.keptObjectsIDs.remove(ID) - self.clearHighlightedText() - else: - self.keptObjectsIDs.append(ID) - self.highlightLabelID(ID) - - self.updateTempLayerKeepIDs() - - # Annotate cell as removed from the analysis - elif right_click and self.binCellButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - binID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to remove from the analysis', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - binID_prompt.exec_() - if binID_prompt.cancel: - return - else: - ID = binID_prompt.EntryID - - # Ask to propagate change to all future visited frames - key = 'Exclude cell from analysis' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - ID, key, doNotShow, - posData.UndoFutFrames_BinID, - posData.applyFutFrames_BinID - ) - - if UndoFutFrames is None: - # User cancelled the process - return - - posData.doNotShowAgain_BinID = doNotShowAgain - posData.UndoFutFrames_BinID = UndoFutFrames - posData.applyFutFrames_BinID = applyFutFrames - - self.current_frame_i = posData.frame_i - - # Apply Exclude cell from analysis to future frames if requested - if applyFutFrames: - # Store current data before going to future frames - self.store_data() - for i in range(posData.frame_i+1, endFrame_i+1): - posData.frame_i = i - self.get_data() - if ID in posData.binnedIDs: - posData.binnedIDs.remove(ID) - else: - posData.binnedIDs.add(ID) - self.update_rp_metadata(draw=False) - self.store_data(autosave=i==endFrame_i) - - self.app.restoreOverrideCursor() - - # Back to current frame - if applyFutFrames: - posData.frame_i = self.current_frame_i - self.get_data() - - # Store undo state before modifying stuff - self.storeUndoRedoStates(UndoFutFrames) - - if ID in posData.binnedIDs: - posData.binnedIDs.remove(ID) - else: - posData.binnedIDs.add(ID) - - self.annotate_rip_and_bin_IDs(updateLabel=True) - - # Gray out ore restore binned ID - self.updateLookuptable() - - if not self.binCellButton.findChild(QAction).isChecked(): - self.binCellButton.setChecked(False) - - # Annotate cell as dead - elif right_click and self.ripCellButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - ripID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as dead', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - ripID_prompt.exec_() - if ripID_prompt.cancel: - return - else: - ID = ripID_prompt.EntryID - - # Ask to propagate change to all future visited frames - key = 'Annotate cell as dead' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - ID, key, doNotShow, - posData.UndoFutFrames_RipID, - posData.applyFutFrames_RipID - ) - - if UndoFutFrames is None: - return - - posData.doNotShowAgain_RipID = doNotShowAgain - posData.UndoFutFrames_RipID = UndoFutFrames - posData.applyFutFrames_RipID = applyFutFrames - - self.current_frame_i = posData.frame_i - - # Apply Edit ID to future frames if requested - if applyFutFrames: - # Store current data before going to future frames - self.store_data() - for i in range(posData.frame_i+1, endFrame_i+1): - posData.frame_i = i - self.get_data() - if ID in posData.ripIDs: - posData.ripIDs.remove(ID) - else: - posData.ripIDs.add(ID) - self.update_rp_metadata(draw=False) - self.store_data(autosave=i==endFrame_i) - self.app.restoreOverrideCursor() - - # Back to current frame - if applyFutFrames: - posData.frame_i = self.current_frame_i - self.get_data() - - # Store undo state before modifying stuff - self.storeUndoRedoStates(UndoFutFrames) - - if ID in posData.ripIDs: - posData.ripIDs.remove(ID) - else: - posData.ripIDs.add(ID) - - self.annotate_rip_and_bin_IDs(updateLabel=True) - - # Gray out dead ID - self.updateLookuptable() - self.store_data() - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Annotate ID as dead') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Annotate ID as dead') - - if not self.ripCellButton.findChild(QAction).isChecked(): - self.ripCellButton.setChecked(False) - - def resetExpandLabel(self): - self.expandingID = -1 - - def expandLabelCallback(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - self.connectLeftClickButtons() - self.expandFootprintSize = 1 - else: - self.clearHighlightedID() - alpha = self.imgGrad.labelsAlphaSlider.value() - self.labelsLayerImg1.setOpacity(alpha) - self.labelsLayerRightImg.setOpacity(alpha) - self.hoverLabelID = 0 - self.expandingID = 0 - self.updateAllImages() - - def expandLabel(self, dilation=True): - posData = self.data[self.pos_i] - if self.hoverLabelID == 0: - self.isExpandingLabel = False - return - - # Re-initialize label to expand when we hover on a different ID - # or we change direction - reinitExpandingLab = ( - self.expandingID != self.hoverLabelID - or dilation != self.isDilation - ) - - ID = self.hoverLabelID - - obj = posData.rp[posData.IDs.index(ID)] - - if reinitExpandingLab: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - # hoverLabelID different from previously expanded ID --> reinit - self.isExpandingLabel = True - self.expandingID = ID - self.expandingLab = np.zeros_like(self.currentLab2D) - self.expandingLab[obj.coords[:,-2], obj.coords[:,-1]] = ID - self.expandFootprintSize = 1 - - prevCoords = (obj.coords[:,-2], obj.coords[:,-1]) - self.currentLab2D[obj.coords[:,-2], obj.coords[:,-1]] = 0 - lab_2D = self.get_2Dlab(posData.lab) - lab_2D[obj.coords[:,-2], obj.coords[:,-1]] = 0 - - footprint = skimage.morphology.disk(self.expandFootprintSize) - if dilation: - expandedLab = skimage.morphology.dilation( - self.expandingLab, footprint - ) - self.isDilation = True - else: - expandedLab = skimage.morphology.erosion( - self.expandingLab, footprint - ) - self.isDilation = False - - # Prevent expanding into neighbouring labels - expandedLab[self.currentLab2D>0] = 0 - - # Get coords of the dilated/eroded object - expandedObj = skimage.measure.regionprops(expandedLab)[0] - expandedObjCoords = (expandedObj.coords[:,-2], expandedObj.coords[:,-1]) - - # Add the dilated/erored object - self.currentLab2D[expandedObjCoords] = self.expandingID - lab_2D[expandedObjCoords] = self.expandingID - - self.set_2Dlab(lab_2D) - self.currentLab2D = lab_2D - - self.update_rp() - - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.img2.setImage(img=self.currentLab2D, autoLevels=False) - - self.setTempImgExpandLabel(prevCoords, expandedObjCoords) - - def startMovingLabel(self, xPos, yPos): - posData = self.data[self.pos_i] - xdata, ydata = int(xPos), int(yPos) - lab_2D = self.get_2Dlab(posData.lab) - ID = lab_2D[ydata, xdata] - if ID == 0: - self.isMovingLabel = False - return - - posData = self.data[self.pos_i] - self.isMovingLabel = True - - self.searchedIDitemRight.setData([], []) - self.searchedIDitemLeft.setData([], []) - self.movingID = ID - self.prevMovePos = (xdata, ydata) - movingObj = posData.rp[posData.IDs.index(ID)] - self.movingObjCoords = movingObj.coords.copy() - yy, xx = movingObj.coords[:,-2], movingObj.coords[:,-1] - self.currentLab2D[yy, xx] = 0 - - def moveLabel(self, xPos, yPos): - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - xdata, ydata = int(xPos), int(yPos) - if xdata<0 or ydata<0 or xdata>=X or ydata>=Y: - return - - self.clearObjContour(ID=self.movingID, ax=0) - - xStart, yStart = self.prevMovePos - deltaX = xdata-xStart - deltaY = ydata-yStart - - yy, xx = self.movingObjCoords[:,-2], self.movingObjCoords[:,-1] - - if self.isSegm3D: - zz = self.movingObjCoords[:,0] - posData.lab[zz, yy, xx] = 0 - else: - posData.lab[yy, xx] = 0 - - self.movingObjCoords[:,-2] = self.movingObjCoords[:,-2]+deltaY - self.movingObjCoords[:,-1] = self.movingObjCoords[:,-1]+deltaX - - yy, xx = self.movingObjCoords[:,-2], self.movingObjCoords[:,-1] - - yy[yy<0] = 0 - xx[xx<0] = 0 - yy[yy>=Y] = Y-1 - xx[xx>=X] = X-1 - - if self.isSegm3D: - zz = self.movingObjCoords[:,0] - posData.lab[zz, yy, xx] = self.movingID - else: - posData.lab[yy, xx] = self.movingID - - self.currentLab2D = self.get_2Dlab(posData.lab) - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.img2.setImage(self.currentLab2D, autoLevels=False) - - self.setTempImg1MoveLabel() - - self.prevMovePos = (xdata, ydata) - - @exception_handler - def gui_mouseDragEventImg1(self, event): - x, y = event.pos().x(), event.pos().y() - - if hasattr(self, 'scaleBar'): - if self.scaleBarDialog is not None: - self.scaleBarDialog.locCombobox.setCurrentText('Custom') - if self.scaleBar.isHighlighted() and self.scaleBar.clicked: - self.scaleBar.setLocationProperty('custom') - self.scaleBar.move(x, y) - return - - if hasattr(self, 'timestamp'): - if self.timestampDialog is not None: - self.timestampDialog.locCombobox.setCurrentText('Custom') - if self.timestamp.isHighlighted() and self.timestamp.clicked: - self.timestamp.setLocationProperty('custom') - self.timestamp.move(x, y) - return - - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - return - - posData = self.data[self.pos_i] - Y, X = self.get_2Dlab(posData.lab).shape - xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): - return - - if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): - self.drawAutoContour(y, x) - - # Brush dragging mouse --> keep brushing - elif self.isMouseDragImg1 and self.brushButton.isChecked(): - lab_2D = self.get_2Dlab(posData.lab) - - # t1 = time.perf_counter() - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) - - # t2 = time.perf_counter() - - diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) - - # Build brush mask - mask = np.zeros(lab_2D.shape, bool) - mask[diskSlice][diskMask] = True - mask[rrPoly, ccPoly] = True - - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - - # t3 = time.perf_counter() - if not self.isPowerBrush() and not ctrl: - mask[lab_2D!=0] = False - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush - ) - - # t4 = time.perf_counter() - - # Apply brush mask - self.applyBrushMask(mask, posData.brushID) - - self.setImageImg2(updateLookuptable=False) - - # t5 = time.perf_counter() - - lab2D = self.get_2Dlab(posData.lab) - brushMask = np.logical_and( - lab2D[diskSlice] == posData.brushID, diskMask - ) - self.setTempImg1Brush( - False, brushMask, posData.brushID, - toLocalSlice=diskSlice - ) - - # t6 = time.perf_counter() - - # printl( - # 'Brush exec times =\n' - # f' * {(t1-t0)*1000 = :.4f} ms\n' - # f' * {(t2-t1)*1000 = :.4f} ms\n' - # f' * {(t3-t2)*1000 = :.4f} ms\n' - # f' * {(t4-t3)*1000 = :.4f} ms\n' - # f' * {(t5-t4)*1000 = :.4f} ms\n' - # f' * {(t6-t5)*1000 = :.4f} ms\n' - # f' * {(t6-t0)*1000 = :.4f} ms' - # ) - - # Eraser dragging mouse --> keep erasing - elif self.isMouseDragImg1 and self.eraserButton.isChecked(): - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - - diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) - - # Build eraser mask - mask = np.zeros(lab_2D.shape, bool) - mask[ymin:ymax, xmin:xmax][diskMask] = True - mask[rrPoly, ccPoly] = True - - if self.eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False - self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, - (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], - ID=self.erasedID - ) - - self.erasedIDs.update(lab_2D[mask]) - self.applyEraserMask(mask) - - self.setImageImg2() - - for erasedID in self.erasedIDs: - if erasedID == 0: - continue - self.erasedLab[lab_2D==erasedID] = erasedID - self.erasedLab[mask] = 0 - - eraserMask = mask[diskSlice] - self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice) - self.setTempImg1Eraser(eraserMask, toLocalSlice=diskSlice, ax=1) - - # Move label dragging mouse --> keep moving - elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - self.moveLabel(x, y) - - # Wand dragging mouse --> keep doing the magic - elif self.isMouseDragImg1 and self.wandToolButton.isChecked(): - tol = self.getMagicWandFloodTolerance() - if self.isSegm3D: - z_slice = self.zSliceScrollBar.sliderPosition() - seed = (z_slice, ydata, xdata) - else: - seed = (ydata, xdata) - - flood_mask = skimage.segmentation.flood( - self.flood_img, seed, tolerance=tol - ) - drawUnderMask = np.logical_or( - posData.lab==0, posData.lab==posData.brushID - ) - flood_mask = np.logical_and(flood_mask, drawUnderMask) - - self.flood_mask[flood_mask] = True - - if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): - self.flood_mask = core.binary_fill_holes(self.flood_mask) - - if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): - self.flood_mask = core.convex_hull_mask(self.flood_mask) - - self.setTempBrushMaskFromWand(self.flood_mask) - - # Label ROI dragging mouse --> draw ROI - elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): - if self.labelRoiIsRectRadioButton.isChecked(): - x0, y0 = self.labelRoiItem.pos() - w, h = (xdata-x0), (ydata-y0) - self.labelRoiItem.setSize((w, h)) - elif self.labelRoiIsFreeHandRadioButton.isChecked(): - self.freeRoiItem.addPoint(xdata, ydata) - - # Draw freehand clear region --> draw region - elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): - self.freeRoiItem.addPoint(xdata, ydata) - - # Label ROI dragging mouse --> draw ROI - elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): - x0, y0 = self.zoomRectItem.pos() - w, h = (xdata-x0), (ydata-y0) - self.zoomRectItem.setSize((w, h)) - - # @exec_time - def fillHolesID(self, ID, sender='brush'): - posData = self.data[self.pos_i] - if sender == 'brush': - if not self.brushAutoFillCheckbox.isChecked(): - return False - - lab2D = self.get_2Dlab(posData.lab) - mask = lab2D == ID - filledMask = scipy.ndimage.binary_fill_holes(mask) - lab2D[filledMask] = ID - - self.set_2Dlab(lab2D) - return True - return False - - def highlightIDonHoverCheckBoxToggled(self, checked): - doHighlight = ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() - ) - if not doHighlight: - self.highlightedID = 0 - self.initLookupTableLab() - else: - self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - self.highlightSearchedID(self.highlightedID, force=True) - self.updatePropsWidget(self.highlightedID) - self.updateAllImages() - - def highlightSearchedIDcheckBoxToggled(self, checked): - self.highlightIDonHoverCheckBoxToggled(checked) - if checked: - posData = self.data[self.pos_i] - self.highlightedID = self.getHighlightedID() - if self.highlightedID == 0: - return - objIdx = posData.IDs_idxs[self.highlightedID] - obj_idx = posData.IDs_idxs.get(self.highlightedID) - if obj_idx is None: - return - obj = posData.rp[objIdx] - self.goToZsliceSearchedID(obj) - - def setHighlightID(self, doHighlight): - if not doHighlight: - self.highlightedID = 0 - self.initLookupTableLab() - else: - self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - self.highlightSearchedID(self.highlightedID, force=True) - self.updatePropsWidget(self.highlightedID) - self.updateAllImages() - - def propsWidgetIDvalueChanged(self, ID): - posData = self.data[self.pos_i] - if ID == 0: - self.updatePropsWidget(int(ID)) - return - - propsQGBox = self.guiTabControl.propsQGBox - obj_idx = posData.IDs_idxs.get(ID) - if obj_idx is None: - s = f'Object ID {int(ID):d} does not exist' - propsQGBox.notExistingIDLabel.setText(s) - return - - obj = posData.rp[obj_idx] - self.goToZsliceSearchedID(obj) - self.updatePropsWidget(int(ID)) - - def updatePropsWidget(self, ID, fromHover=False): - if isinstance(ID, str): - # Function called by currentTextChanged of channelCombobox or - # additionalMeasCombobox. We set self.currentPropsID = 0 to force update - ID = self.guiTabControl.propsQGBox.idSB.value() - self.currentPropsID = -1 - - ID = int(ID) - - update = ( - self.propsDockWidget.isVisible() - and ID != 0 and ID!=self.currentPropsID - ) - if not update: - return - - posData = self.data[self.pos_i] - if not hasattr(posData, 'rp'): - return - - if posData.rp is None: - self.update_rp() - - if not posData.IDs: - # empty segmentation mask - return - - if fromHover and not self.guiTabControl.highlightCheckbox.isChecked(): - # Do not highlight on hover - return - - propsQGBox = self.guiTabControl.propsQGBox - - obj_idx = posData.IDs_idxs.get(ID) - if obj_idx is None: - s = f'Object ID {int(ID):d} does not exist' - propsQGBox.notExistingIDLabel.setText(s) - return - - propsQGBox.notExistingIDLabel.setText('') - self.currentPropsID = ID - propsQGBox.idSB.setValue(ID) - - doHighlight = ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() - ) - if doHighlight: - self.highlightSearchedID(ID) - - obj = posData.rp[obj_idx] - - if self.isSegm3D: - if self.zProjComboBox.currentText() == 'single z-slice': - local_z = self.z_lab() - obj.bbox[0] - area_pxl = np.count_nonzero(obj.image[local_z]) - else: - area_pxl = np.count_nonzero(obj.image.max(axis=0)) - else: - area_pxl = obj.area - - propsQGBox.cellAreaPxlSB.setValue(area_pxl) - - pixelSizeQGBox = self.guiTabControl.pixelSizeQGBox - PhysicalSizeX = pixelSizeQGBox.pixelWidthWidget.value() - PhysicalSizeY = pixelSizeQGBox.pixelHeightWidget.value() - PhysicalSizeZ = pixelSizeQGBox.voxelDepthWidget.value() - - yx_pxl_to_um2 = PhysicalSizeY*PhysicalSizeX - - area_um2 = area_pxl*yx_pxl_to_um2 - - propsQGBox.cellAreaUm2DSB.setValue(area_um2) - - if self.isSegm3D: - PhysicalSizeZ = posData.PhysicalSizeZ - vol_vox_3D = obj.area - vol_fl_3D = vol_vox_3D*PhysicalSizeZ*PhysicalSizeY*PhysicalSizeX - propsQGBox.cellVolVox3D_SB.setValue(vol_vox_3D) - propsQGBox.cellVolFl3D_DSB.setValue(vol_fl_3D) - - vol_vox, vol_fl = _calc_rot_vol( - obj, PhysicalSizeY, PhysicalSizeX - ) - propsQGBox.cellVolVoxSB.setValue(int(vol_vox)) - propsQGBox.cellVolFlDSB.setValue(vol_fl) - - - minor_axis_length = max(1, obj.minor_axis_length) - elongation = obj.major_axis_length/minor_axis_length - propsQGBox.elongationDSB.setValue(elongation) - - solidity = obj.solidity - propsQGBox.solidityDSB.setValue(solidity) - - additionalPropName = propsQGBox.additionalPropsCombobox.currentText() - additionalPropValue = getattr(obj, additionalPropName) - propsQGBox.additionalPropsCombobox.indicator.setValue(additionalPropValue) - - intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox - selectedChannel = intensMeasurQGBox.channelCombobox.currentText() - - try: - _, filename = self.getPathFromChName(selectedChannel, posData) - image = posData.ol_data_dict[filename][posData.frame_i] - except Exception as e: - image = posData.img_data[posData.frame_i] - - if posData.SizeZ > 1 and not self.isSegm3D: - z = self.zSliceScrollBar.sliderPosition() - objData = image[z][obj.slice][obj.image] - img = self.img1.image - else: - objData = image[obj.slice][obj.image] - img = image - - intensMeasurQGBox.minimumDSB.setValue(np.min(objData)) - intensMeasurQGBox.maximumDSB.setValue(np.max(objData)) - intensMeasurQGBox.meanDSB.setValue(np.mean(objData)) - intensMeasurQGBox.medianDSB.setValue(np.median(objData)) - - funcDesc = intensMeasurQGBox.additionalMeasCombobox.currentText() - func = intensMeasurQGBox.additionalMeasCombobox.functions[funcDesc] - if funcDesc == 'Concentration': - bkgrVal = np.median(img[posData.lab == 0]) - amount = func(objData, bkgrVal, obj.area) - value = amount/vol_vox - elif funcDesc == 'Amount': - bkgrVal = np.median(img[posData.lab == 0]) - amount = func(objData, bkgrVal, obj.area) - value = amount - else: - value = func(objData) - - intensMeasurQGBox.additionalMeasCombobox.indicator.setValue(value) - - def gui_hoverEventRightImage(self, event): - try: - posData = self.data[self.pos_i] - except AttributeError: - return - - if event.isExit(): - self.resetCursors() - - self.gui_hoverEventImg1(event, isHoverImg1=False) - setMirroredCursor = ( - self.app.overrideCursor() is None and not event.isExit() - and self.showMirroredCursorAction.isChecked() - ) - if setMirroredCursor: - x, y = event.pos() - self.ax1_cursor.setData([x], [y]) - - def onCtrlPressedFirstTime(self): - x, y = self.xHoverImg, self.yHoverImg - if x is None: - self.xyOnCtrlPressedFirstTime = None - return - - xdata, ydata = int(x), int(y) - Y, X = self.currentLab2D.shape - - if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): - self.xyOnCtrlPressedFirstTime = None - return - - ID = self.currentLab2D[ydata, xdata] - if ID == 0: - self.xyOnCtrlPressedFirstTime = None - return - - self.xyOnCtrlPressedFirstTime = (xdata, ydata) - - def onCtrlReleased(self): - self.xyOnCtrlPressedFirstTime = None - - def gui_hoverEventImg1(self, event, isHoverImg1=True): - try: - posData = self.data[self.pos_i] - except AttributeError: - return - - # Update x, y, value label bottom right - if not event.isExit(): - self.xHoverImg, self.yHoverImg = event.pos() - else: - self.xHoverImg, self.yHoverImg = None, None - - if event.isExit(): - self.resetCursor() - - if not event.isExit() and self.slideshowWin is not None: - self.slideshowWin.setMirroredCursorPos(*event.pos()) - - # Alt key was released --> restore cursor - modifiers = QGuiApplication.keyboardModifiers() - cursorsInfo = self.gui_setCursor(modifiers, event) - self.highlightHoverLostObj(modifiers, event) - - drawRulerLine = ( - (self.rulerButton.isChecked() - or self.addDelPolyLineRoiButton.isChecked()) - and self.tempSegmentON and not event.isExit() - ) - if drawRulerLine: - self.drawTempRulerLine(event) - - if not event.isExit(): - x, y = event.pos() - xdata, ydata = int(x), int(y) - _img = self.img1.image - Y, X = _img.shape[:2] - if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: - ID = self.currentLab2D[ydata, xdata] - self.updatePropsWidget(ID, fromHover=True) - activeToolButton = self.getActiveToolButton() - hoverText = self.hoverValuesFormatted( - xdata, ydata, activeToolButton, isHoverImg1 - ) - self.checkHighlightScaleBar(x, y, activeToolButton) - self.checkHighlightTimestamp(x, y, activeToolButton) - self.wcLabel.setText(hoverText) - else: - self.clickedOnBud = False - self.BudMothTempLine.setData([], []) - self.wcLabel.setText('') - - if cursorsInfo['setKeepObjCursor']: - x, y = event.pos() - self.highlightHoverIDsKeptObj(x, y) - - if cursorsInfo['setManualTrackingCursor']: - x, y = event.pos() - # self.highlightHoverID(x, y) - self.drawManualTrackingGhost(x, y) - - if cursorsInfo['setManualBackgroundCursor']: - x, y = event.pos() - # self.highlightHoverID(x, y) - self.drawManualBackgroundObj(x, y) - - if ( - not cursorsInfo['setManualTrackingCursor'] - and not cursorsInfo['setManualBackgroundCursor'] - ): - self.clearGhost() - - setMoveLabelCursor = cursorsInfo['setMoveLabelCursor'] - setExpandLabelCursor = cursorsInfo['setExpandLabelCursor'] - if setMoveLabelCursor or setExpandLabelCursor: - x, y = event.pos() - self.updateHoverLabelCursor(x, y) - - # Draw eraser circle - if cursorsInfo['setEraserCursor']: - x, y = event.pos() - self.updateEraserCursor(x, y, isHoverImg1=isHoverImg1) - self.hideItemsHoverBrush(xy=(x, y)) - elif self.eraserButton.isChecked() and not event.isExit(): - if self.xyOnCtrlPressedFirstTime is not None: - self.updateEraserCursor( - x, y, xyLocked=self.xyOnCtrlPressedFirstTime, - isHoverImg1=isHoverImg1 - ) - self.hideItemsHoverBrush(xy=(x, y)) - else: - eraserCursors = ( - self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX - ) - self.setHoverToolSymbolData([], [], eraserCursors) - - # Draw Brush circle - if cursorsInfo['setBrushCursor']: - x, y = event.pos() - self.updateBrushCursor(x, y, isHoverImg1=isHoverImg1) - self.hideItemsHoverBrush(xy=(x, y)) - elif cursorsInfo['setAddPointCursor']: - x, y = event.pos() - self.setHoverCircleAddPoint(x, y) - else: - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - - # Draw label ROi circular cursor - setLabelRoiCircCursor = cursorsInfo['setLabelRoiCircCursor'] - if setLabelRoiCircCursor: - x, y = event.pos() - else: - x, y = None, None - self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) - - drawMothBudLine = ( - self.assignBudMothButton.isChecked() and self.clickedOnBud - and not event.isExit() - ) - if drawMothBudLine: - self.drawTempMothBudLine(event, posData) - - drawMergeObjsLine = ( - self.mergeIDsButton.isChecked() and not event.isExit() - ) - if drawMergeObjsLine: - self.drawTempMergeObjsLine(event, posData, modifiers) - - # Temporarily draw spline curve - # see https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy - drawSpline = ( - self.curvToolButton.isChecked() and self.splineHoverON - and not event.isExit() - ) - if drawSpline: - self.hoverEventDrawSpline(event) - - setMirroredCursor = ( - self.app.overrideCursor() is None and not event.isExit() - and isHoverImg1 and self.showMirroredCursorAction.isChecked() - ) - if setMirroredCursor: - x, y = event.pos() - self.ax2_cursor.setData([x], [y]) - else: - self.ax2_cursor.setData([], []) - - return cursorsInfo - - def drawTempMothBudLine(self, event, posData): - x, y = event.pos() - y2, x2 = y, x - xdata, ydata = int(x), int(y) - y1, x1 = self.yClickBud, self.xClickBud - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - self.BudMothTempLine.setData([x1, x2], [y1, y2]) - else: - obj_idx = posData.IDs_idxs[ID] - obj = posData.rp[obj_idx] - y2, x2 = self.getObjCentroid(obj.centroid) - self.BudMothTempLine.setData([x1, x2], [y1, y2]) - - def drawTempMergeObjsLine(self, event, posData, modifiers): - if self.clickObjYc is None: - return - modifier = modifiers == Qt.ShiftModifier - x, y = event.pos() - y2, x2 = y, x - xdata, ydata = int(x), int(y) - y1, x1 = self.clickObjYc, self.clickObjXc - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID != 0: - obj_idx = posData.IDs_idxs[ID] - obj = posData.rp[obj_idx] - y2, x2 = self.getObjCentroid(obj.centroid) - - if modifier and ID > 0: - self.mergeObjsTempLine.addPoint(x2, y2) - elif not modifier: - self.mergeObjsTempLine.setData([x1, x2], [y1, y2]) - - def gui_add_ax_cursors(self): - try: - self.ax1.removeItem(self.ax1_cursor) - self.ax2.removeItem(self.ax2_cursor) - except Exception as e: - pass - - self.ax2_cursor = pg.ScatterPlotItem( - symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), - brush=pg.mkBrush('w'), size=16, tip=None - ) - self.ax2.addItem(self.ax2_cursor) - - self.ax1_cursor = pg.ScatterPlotItem( - symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), - brush=pg.mkBrush('w'), size=16, tip=None - ) - self.ax1.addItem(self.ax1_cursor) - - def gui_setCursor(self, modifiers, event): - noModifier = modifiers == Qt.NoModifier - shift = modifiers == Qt.ShiftModifier - ctrl = modifiers == Qt.ControlModifier - alt = modifiers == Qt.AltModifier - - # Alt key was released --> restore cursor - if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: - self.app.restoreOverrideCursor() - - setBrushCursor = ( - self.brushButton.isChecked() and not event.isExit() - and (noModifier or shift or ctrl) - ) - setEraserCursor = ( - self.eraserButton.isChecked() and not event.isExit() - and noModifier - ) - setAddDelPolyLineCursor = ( - self.addDelPolyLineRoiButton.isChecked() and not event.isExit() - and noModifier - ) - setLabelRoiCircCursor = ( - self.labelRoiButton.isChecked() and not event.isExit() - and (noModifier or shift or ctrl) - and self.labelRoiIsCircularRadioButton.isChecked() - ) - setWandCursor = ( - self.wandToolButton.isChecked() and not event.isExit() - and noModifier - ) - setLabelRoiCursor = ( - self.labelRoiButton.isChecked() and not event.isExit() - and noModifier - ) - setMoveLabelCursor = ( - self.moveLabelToolButton.isChecked() and not event.isExit() - and noModifier - ) - setExpandLabelCursor = ( - self.expandLabelToolButton.isChecked() and not event.isExit() - and noModifier - ) - setCurvCursor = ( - self.curvToolButton.isChecked() and not event.isExit() - and noModifier - ) - setKeepObjCursor = ( - self.keepIDsButton.isChecked() and not event.isExit() - and noModifier - ) - setCustomAnnotCursor = ( - self.customAnnotButton is not None and not event.isExit() - and noModifier - ) - setManualTrackingCursor = ( - self.manualTrackingButton.isChecked() - and not event.isExit() - and noModifier - ) - setManualBackgroundCursor = ( - self.manualBackgroundButton.isChecked() - and not event.isExit() - and noModifier - ) - setZoomRectCursor = ( - self.zoomRectButton.isChecked() and not event.isExit() - and noModifier - ) - setEditIDCursor = ( - self.editIDbutton.isChecked() and not event.isExit() - ) - magicPromptsON = self.magicPromptsToolButton.isChecked() - pointsLayerON = self.togglePointsLayerAction.isChecked() - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - setAddPointCursor = ( - (pointsLayerON or magicPromptsON) - and addPointsByClickingButton is not None - and not event.isExit() - and noModifier - ) - overrideCursor = self.app.overrideCursor() - setPanImageCursor = alt and not event.isExit() - if setPanImageCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.SizeAllCursor) - elif setBrushCursor or setEraserCursor or setLabelRoiCircCursor: - self.app.setOverrideCursor(Qt.CrossCursor) - elif setWandCursor and overrideCursor is None: - self.app.setOverrideCursor(self.wandCursor) - elif setLabelRoiCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.CrossCursor) - elif setCurvCursor and overrideCursor is None: - self.app.setOverrideCursor(self.curvCursor) - elif setCustomAnnotCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) - elif setAddDelPolyLineCursor: - self.app.setOverrideCursor(self.polyLineRoiCursor) - elif setCustomAnnotCursor: - x, y = event.pos() - self.highlightHoverID(x, y) - elif setKeepObjCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) - elif setManualTrackingCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) - elif setManualBackgroundCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) - elif setAddPointCursor: - self.app.setOverrideCursor(self.addPointsCursor) - elif setZoomRectCursor: - self.app.setOverrideCursor(Qt.CrossCursor) - elif setEditIDCursor and overrideCursor is None: - if shift: - self.app.setOverrideCursor(Qt.CrossCursor) - else: - self.app.restoreOverrideCursor() - - return { - 'setBrushCursor': setBrushCursor, - 'setEraserCursor': setEraserCursor, - 'setAddDelPolyLineCursor': setAddDelPolyLineCursor, - 'setLabelRoiCircCursor': setLabelRoiCircCursor, - 'setWandCursor': setWandCursor, - 'setLabelRoiCursor': setLabelRoiCursor, - 'setMoveLabelCursor': setMoveLabelCursor, - 'setExpandLabelCursor': setExpandLabelCursor, - 'setCurvCursor': setCurvCursor, - 'setKeepObjCursor': setKeepObjCursor, - 'setCustomAnnotCursor': setCustomAnnotCursor, - 'setManualTrackingCursor': setManualTrackingCursor, - 'setManualBackgroundCursor': setManualBackgroundCursor, - 'setAddPointCursor': setAddPointCursor, - 'setZoomRectCursor': setZoomRectCursor, - 'setEditIDCursor': setEditIDCursor - } - - def warnAddingPointWithExistingId(self, point_id, table_endname=''): - posData = self.data[self.pos_i] - if not point_id in posData.IDs_idxs: - return True - - msg = widgets.myMessageBox(wrapText=False) - txt = (f""" - Cell ID {point_id} already exists!

- Are you sure you want to add this point? - """) - if table_endname: - txt = (f""" - The loaded table {table_endname} has point id - {point_id}. -

However, {txt} - """) - txt = html_utils.paragraph(txt) - _, _, yesButton = msg.warning( - self, f'Cell ID {point_id} already exist', txt, - buttonsTexts=( - 'Cancel', 'No, do not add', f'Yes, add point id {point_id}' - ) - ) - return msg.clickedButton == yesButton - - def gui_hoverEventImg2(self, event): - try: - posData = self.data[self.pos_i] - except AttributeError: - return - - if not event.isExit(): - self.xHoverImg, self.yHoverImg = event.pos() - else: - self.xHoverImg, self.yHoverImg = None, None - - # Cursor left image --> restore cursor - if event.isExit() and self.app.overrideCursor() is not None: - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - # Alt key was released --> restore cursor - modifiers = QGuiApplication.keyboardModifiers() - noModifier = modifiers == Qt.NoModifier - shift = modifiers == Qt.ShiftModifier - ctrl = modifiers == Qt.ControlModifier - if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: - self.app.restoreOverrideCursor() - - setBrushCursor = ( - self.brushButton.isChecked() and not event.isExit() - and (noModifier or shift or ctrl) - ) - setEraserCursor = ( - self.eraserButton.isChecked() and not event.isExit() - and noModifier - ) - setLabelRoiCircCursor = ( - self.labelRoiButton.isChecked() and not event.isExit() - and (noModifier or shift or ctrl) - and self.labelRoiIsCircularRadioButton.isChecked() - ) - if setBrushCursor or setEraserCursor or setLabelRoiCircCursor: - self.app.setOverrideCursor(Qt.CrossCursor) - - setMoveLabelCursor = ( - self.moveLabelToolButton.isChecked() and not event.isExit() - and noModifier - ) - - setExpandLabelCursor = ( - self.expandLabelToolButton.isChecked() and not event.isExit() - and noModifier - ) - - # Cursor is moving on image while Alt key is pressed --> pan cursor - alt = QGuiApplication.keyboardModifiers() == Qt.AltModifier - setPanImageCursor = alt and not event.isExit() - if setPanImageCursor and self.app.overrideCursor() is None: - self.app.setOverrideCursor(Qt.SizeAllCursor) - - setKeepObjCursor = ( - self.keepIDsButton.isChecked() and not event.isExit() - and noModifier - ) - if setKeepObjCursor and self.app.overrideCursor() is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) - - # Update x, y, value label bottom right - if not event.isExit(): - x, y = event.pos() - xdata, ydata = int(x), int(y) - _img = self.currentLab2D - Y, X = _img.shape - # hoverText = self.hoverValuesFormatted(xdata, ydata) - # self.wcLabel.setText(hoverText) - else: - if self.eraserButton.isChecked() or self.brushButton.isChecked(): - self.gui_mouseReleaseEventImg2(event) - self.wcLabel.setText(f'') - - if setMoveLabelCursor or setExpandLabelCursor: - x, y = event.pos() - self.updateHoverLabelCursor(x, y) - - if setKeepObjCursor: - x, y = event.pos() - self.highlightHoverIDsKeptObj(x, y) - - # Draw eraser circle - if setEraserCursor: - x, y = event.pos() - self.updateEraserCursor(x, y, isHoverImg1=False) - else: - self.setHoverToolSymbolData( - [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX) - ) - - # Draw Brush circle - if setBrushCursor: - x, y = event.pos() - self.updateBrushCursor(x, y, isHoverImg1=False) - else: - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - - # Draw label ROi circular cursor - if setLabelRoiCircCursor: - x, y = event.pos() - else: - x, y = None, None - self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) - - def gui_imgGradShowContextMenu(self, x, y): - if hasattr(self, 'scaleBar'): - if self.scaleBar.isHighlighted(): - self.scaleBar.showContextMenu(x, y) - return - - if hasattr(self, 'timestamp'): - if self.timestamp.isHighlighted(): - self.timestamp.showContextMenu(x, y) - return - - self.imgGrad.gradient.menu.popup(QPoint(int(x), int(y))) - - def gui_rightImageShowContextMenu(self, event): - try: - # Convert QPointF to QPoint - self.imgGradRight.gradient.menu.popup(event.screenPos().toPoint()) - except AttributeError: - self.imgGradRight.gradient.menu.popup(event.screenPos()) - - @exception_handler - def gui_mouseDragEventImg2(self, event): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - return - - Y, X = self.get_2Dlab(posData.lab).shape - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): - return - - # Eraser dragging mouse --> keep erasing - if self.isMouseDragImg2 and self.eraserButton.isChecked(): - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - brushSize = self.brushSizeSpinbox.value() - rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - - # Build eraser mask - mask = np.zeros(lab_2D.shape, bool) - mask[ymin:ymax, xmin:xmax][diskMask] = True - mask[rrPoly, ccPoly] = True - - if self.eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False - self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, - (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], - ID=self.erasedID - ) - - self.erasedIDs.update(lab_2D[mask]) - - self.applyEraserMask(mask) - self.setImageImg2(updateLookuptable=False) - - # Brush paint dragging mouse --> keep painting - if self.isMouseDragImg2 and self.brushButton.isChecked(): - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) - - # Build brush mask - mask = np.zeros(lab_2D.shape, bool) - mask[ymin:ymax, xmin:xmax][diskMask] = True - mask[rrPoly, ccPoly] = True - - # If user double-pressed 'b' then draw over the labels - color = self.brushButton.palette().button().color().name() - if color != self.doublePressKeyButtonColor: - mask[lab_2D!=0] = False - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.eraserButton, brush=self.ax2_BrushCircleBrush - ) - - # Apply brush mask - self.applyBrushMask(mask, self.ax2BrushID) - - self.setImageImg2() - - # Move label dragging mouse --> keep moving - elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - self.moveLabel(x, y) - - @exception_handler - def gui_mouseReleaseEventImg2(self, event): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - return - - Y, X = self.get_2Dlab(posData.lab).shape - try: - x, y = event.pos().x(), event.pos().y() - except Exception as e: - return - - xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): - self.isMouseDragImg2 = False - self.updateAllImages() - return - - # Move label mouse released, update move - if self.isMovingLabel and self.moveLabelToolButton.isChecked(): - self.isMovingLabel = False - - # Update data (rp, etc) - self.update_rp() - - # Repeat tracking - self.tracking(enforce=True, assign_unique_new_IDs=False) - - self.updateAllImages() - - if not self.moveLabelToolButton.findChild(QAction).isChecked(): - self.moveLabelToolButton.setChecked(False) - - # Merge IDs - elif self.mergeIDsButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - lab2D = self.get_2Dlab(posData.lab) - ID = lab2D[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - lab2D, y, x - ) - mergeID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to merge with ID ' - f'{self.firstID}', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - mergeID_prompt.exec_() - if mergeID_prompt.cancel: - return - else: - ID = mergeID_prompt.EntryID - obj_idx = posData.IDs_idxs[ID] - obj = posData.rp[obj_idx] - y2, x2 = self.getObjCentroid(obj.centroid) - self.mergeObjsTempLine.addPoint(x2, y2) - - xx, yy = self.mergeObjsTempLine.getData() - IDs_to_merge = lab2D[yy.astype(int), xx.astype(int)] - for ID in IDs_to_merge: - if ID == 0: - continue - posData.lab[posData.lab==ID] = self.firstID - - self.mergeObjsTempLine.setData([], []) - self.clickObjYc, self.clickObjXc = None, None - - # Update data (rp, etc) - self.update_rp() - - ask_back_prop = True - - if posData.frame_i == 0: - ask_back_prop = False - prev_IDs = [] - else: - prev_IDs = posData.allData_li[posData.frame_i-1]['IDs'] - - if all(ID not in prev_IDs for ID in IDs_to_merge): - ask_back_prop = False - - if not self.isFrameCcaAnnotated() and ask_back_prop: - proceed = self.askPropagateChangePast(f'Merge IDs {IDs_to_merge}') - if proceed: - self.propagateMergeObjsPast(IDs_to_merge) - self.whitelistPropagateIDs(only_future_frames=False, update_lab=True) # in the update_rp() call, this should also be done - - # Repeat tracking - self.tracking( - enforce=True, assign_unique_new_IDs=False, - separateByLabel=False - ) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Merge IDs') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Merge IDs') - - if not self.mergeIDsButton.findChild(QAction).isChecked(): - self.mergeIDsButton.setChecked(False) - self.store_data() - - @exception_handler - def gui_mouseReleaseEventImg1(self, event): - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - alt = modifiers == Qt.AltModifier - right_click = event.button() == Qt.MouseButton.RightButton and not alt - - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - return - - Y, X = self.get_2Dlab(posData.lab).shape - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): - self.isMouseDragImg2 = False - self.updateAllImages() - return - - if hasattr(self, 'scaleBar'): - if self.scaleBar.isHighlighted() and self.scaleBar.clicked: - self.scaleBar.clicked = False - return - - if hasattr(self, 'timestamp'): - if self.timestamp.isHighlighted() and self.timestamp.clicked: - self.timestamp.clicked = False - return - - sendRightClickImg2 = ( - (mode=='Segmentation and Tracking' or self.isSnapshot) - and right_click - ) - if sendRightClickImg2: - # Allow right-click actions on both images - self.gui_mouseReleaseEventImg2(event) - - # Right-click curvature tool mouse release - if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): - self.isRightClickDragImg1 = False - try: - self.curvToolSplineToObj(isRightClick=True) - self.update_rp() - if self.autoIDcheckbox.isChecked(): - self.trackManuallyAddedObject(posData.brushID, True) - if self.isSnapshot: - self.fixCcaDfAfterEdit('Add new ID with curvature tool') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Add new ID with curvature tool') - self.clearCurvItems() - self.curvTool_cb(True) - except ValueError: - self.clearCurvItems() - self.curvTool_cb(True) - pass - - # Eraser mouse release --> update IDs and contours - elif self.isMouseDragImg1 and self.eraserButton.isChecked(): - self.isMouseDragImg1 = False - - self.clearTempBrushImage() - - # Update data (rp, etc) - self.update_rp() - - doUpdateImages = self.checkWarnDeletedIDwithEraser() - - if doUpdateImages: - self.updateAllImages() - - # Brush button mouse release - elif self.isMouseDragImg1 and self.brushButton.isChecked(): - self.isMouseDragImg1 = False - - self.clearTempBrushImage() - - self.brushReleased() - - # Wand tool release, add new object - elif self.isMouseDragImg1 and self.wandToolButton.isChecked(): - self.isMouseDragImg1 = False - - self.clearTempBrushImage() - - posData = self.data[self.pos_i] - posData.lab[self.flood_mask] = posData.brushID - - # Update data (rp, etc) - self.update_rp() - - # Repeat tracking - self.trackManuallyAddedObject(posData.brushID, self.isNewID) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Add new ID with magic-wand') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Add new ID with magic-wand') - - # Label ROI mouse release --> label the ROI with labelRoiWorker - elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): - self.labelRoiRunning = True - self.app.setOverrideCursor(Qt.WaitCursor) - self.isMouseDragImg1 = False - - if self.labelRoiIsFreeHandRadioButton.isChecked(): - self.freeRoiItem.closeCurve() - - proceed = self.labelRoiCheckStartStopFrame() - if not proceed: - self.labelRoiCancelled() - return - - roiImg, self.labelRoiSlice = self.getLabelRoiImage() - - if roiImg.size == 0: - self.labelRoiCancelled() - return - - if self.labelRoiModel is None: - cancel = self.initLabelRoiModel() - if cancel: - self.labelRoiCancelled() - return - - # Restore state of button because it was maybe unchecked by - # using other tools that are allowed --> see "elif" case in - # labelRoi_cb - self.labelRoiButton.blockSignals(True) - self.labelRoiButton.setChecked(True) - self.labelRoiToolbar.setVisible(True) - self.labelRoiButton.blockSignals(False) - - roiSecondChannel = None - if self.secondChannelName is not None: - secondChannelData = self.getSecondChannelData() - roiSecondChannel = secondChannelData[self.labelRoiSlice] - - isTimelapse = self.labelRoiTrangeCheckbox.isChecked() - if isTimelapse: - start_n = self.labelRoiStartFrameNoSpinbox.value() - stop_n = self.labelRoiStopFrameNoSpinbox.value() - self.progressWin = apps.QDialogWorkerProgress( - title='ROI segmentation', parent=self, - pbarDesc=f'Segmenting frames n. {start_n} to {stop_n}...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stop_n-start_n) - - - self.app.restoreOverrideCursor() - labelRoiWorker = self.labelRoiActiveWorkers[-1] - labelRoiWorker.start( - roiImg, posData, - roiSecondChannel=roiSecondChannel, - isTimelapse=isTimelapse - ) - self.app.setOverrideCursor(Qt.WaitCursor) - self.logger.info( - f'Magic labeller started on image ROI = {self.labelRoiSlice}...' - ) - self.titleLabel.setText('Magic labeller is doing its magic...') - self.setDisabled(True) - - # Move label mouse released, update move - elif self.isMovingLabel and self.moveLabelToolButton.isChecked(): - self.isMovingLabel = False - - # Update data (rp, etc) - self.update_rp() - - # Repeat tracking - self.tracking(enforce=True, assign_unique_new_IDs=False) - - if not self.moveLabelToolButton.findChild(QAction).isChecked(): - self.moveLabelToolButton.setChecked(False) - else: - self.updateAllImages() - - # Assign mother to bud - elif self.assignBudMothButton.isChecked() and self.clickedOnBud: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == self.get_2Dlab(posData.lab)[self.yClickBud, self.xClickBud]: - return - - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - mothID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as mother cell', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - mothID_prompt.exec_() - if mothID_prompt.cancel: - return - else: - ID = mothID_prompt.EntryID - obj_idx = posData.IDs.index(ID) - y, x = posData.rp[obj_idx].centroid - xdata, ydata = int(x), int(y) - - if self.isSnapshot: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - relationship = posData.cca_df.at[ID, 'relationship'] - ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] - is_history_known = posData.cca_df.at[ID, 'is_history_known'] - # We allow assiging a cell in G1 as mother only on first frame - # OR if the history is unknown - if relationship == 'bud' and posData.frame_i > 0 and is_history_known: - self.assignBudMothButton.setChecked(False) - txt = html_utils.paragraph( - f'You clicked on ID {ID} which is a BUD.

' - 'To assign a bud start by clicking on the bud ' - 'and release on a cell in G1' - ) - msg = widgets.myMessageBox() - msg.critical( - self, 'Released on a bud', txt - ) - self.assignBudMothButton.setChecked(True) - return - - elif posData.frame_i == 0: - # Check that clicked bud actually is smaller that mother - # otherwise warn the user that he might have clicked first - # on a mother - budID = self.get_2Dlab(posData.lab)[self.yClickBud, self.xClickBud] - new_mothID = self.get_2Dlab(posData.lab)[ydata, xdata] - bud_obj_idx = posData.IDs.index(budID) - new_moth_obj_idx = posData.IDs.index(new_mothID) - rp_budID = posData.rp[bud_obj_idx] - rp_new_mothID = posData.rp[new_moth_obj_idx] - if rp_budID.area >= rp_new_mothID.area: - self.assignBudMothButton.setChecked(False) - msg = widgets.myMessageBox() - txt = ( - f'You clicked FIRST on ID {budID} and then on {new_mothID}.
' - f'For me this means that you want ID {budID} to be the ' - f'BUD of ID {new_mothID}.
' - f'However ID {budID} is bigger than {new_mothID} ' - f'so maybe you should have clicked FIRST on {new_mothID}?

' - 'What do you want me to do?' - ) - txt = html_utils.paragraph(txt) - swapButton, keepButton = msg.warning( - self, 'Which one is bud?', txt, - buttonsTexts=( - f'Assign ID {new_mothID} as the bud of ID {budID}', - f'Keep ID {budID} as the bud of ID {new_mothID}' - ) - ) - if msg.clickedButton == swapButton: - (xdata, ydata, - self.xClickBud, self.yClickBud) = ( - self.xClickBud, self.yClickBud, - xdata, ydata - ) - self.assignBudMothButton.setChecked(True) - - elif is_history_known and not self.clickedOnHistoryKnown: - self.assignBudMothButton.setChecked(False) - budID = self.get_2Dlab(posData.lab)[ydata, xdata] - # Allow assigning an unknown cell ONLY to another unknown cell - txt = ( - f'You started by clicking on ID {budID} which has ' - 'UNKNOWN history, but you then clicked/released on ' - f'ID {ID} which has KNOWN history.\n\n' - 'Only two cells with UNKNOWN history can be assigned as ' - 'relative of each other.' - ) - msg = QMessageBox() - msg.critical( - self, 'Released on a cell with KNOWN history', txt, msg.Ok - ) - self.assignBudMothButton.setChecked(True) - return - - self.clickedOnHistoryKnown = is_history_known - self.xClickMoth, self.yClickMoth = xdata, ydata - - if ccs != 'G1' and posData.frame_i > 0: - self.assignBudMothButton.setChecked(False) - self.onMotherNotInG1(ID) - self.assignBudMothButton.setChecked(True) - else: - self.annotateBudToDifferentMother() - - if not self.assignBudMothButton.findChild(QAction).isChecked(): - self.assignBudMothButton.setChecked(False) - - self.clickedOnBud = False - self.BudMothTempLine.setData([], []) - - # Draw clear region mouse release - elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): - self.isMouseDragImg1 = False - self.freeRoiItem.closeCurve() - self.clearObjsFreehandRegion() - - # Zoom rect mouse release - elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): - self.isMouseDragImg1 = False - self.zoomRectDone() - - def gui_clickedDelRoi(self, event, left_click, right_click): - posData = self.data[self.pos_i] - x, y = event.pos().x(), event.pos().y() - - # Check if right click on ROI - delROIs = ( - posData.allData_li[posData.frame_i]['delROIs_info']['rois'].copy() - ) - for r, roi in enumerate(delROIs): - ROImask = self.getDelRoiMask(roi) - if self.isSegm3D: - clickedOnROI = ROImask[self.z_lab(), int(y), int(x)] - else: - clickedOnROI = ROImask[int(y), int(x)] - raiseContextMenuRoi = right_click and clickedOnROI - dragRoi = left_click and clickedOnROI - if raiseContextMenuRoi: - self.roi_to_del = roi - self.roiContextMenu = QMenu(self) - separator = QAction(self) - separator.setSeparator(True) - self.roiContextMenu.addAction(separator) - action = QAction('Remove ROI') - action.triggered.connect(self.removeDelROI) - self.roiContextMenu.addAction(action) - try: - # Convert QPointF to QPoint - self.roiContextMenu.exec_(event.screenPos().toPoint()) - except AttributeError: - self.roiContextMenu.exec_(event.screenPos()) - return True - elif dragRoi: - event.ignore() - return True - return False - - def gui_getHoveredSegmentsPolyLineRoi(self): - posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - segments = [] - for roi in delROIs_info['rois']: - if not isinstance(roi, pg.PolyLineROI): - continue - for seg in roi.segments: - if seg.currentPen == seg.hoverPen: - seg.roi = roi - segments.append(seg) - return segments - - def gui_getHoveredHandlesPolyLineRoi(self): - posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - handles = [] - for roi in delROIs_info['rois']: - if not isinstance(roi, pg.PolyLineROI): - continue - for handle in roi.getHandles(): - if handle.currentPen == handle.hoverPen: - handle.roi = roi - handles.append(handle) - return handles - - @exception_handler - def gui_mousePressRightImage(self, event): - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - alt = modifiers == Qt.AltModifier - isMod = alt - right_click = event.button() == Qt.MouseButton.RightButton and not isMod - is_right_click_action_ON = any([ - b.isChecked() for b in self.checkableQButtonsGroup.buttons() - ]) - self.typingEditID = False - showLabelsGradMenu = right_click and not is_right_click_action_ON - if showLabelsGradMenu: - self.gui_rightImageShowContextMenu(event) - event.ignore() - else: - self.gui_mousePressEventImg1(event) - - @exception_handler - def gui_mouseDragRightImage(self, event): - self.gui_mouseDragEventImg1(event) - - @exception_handler - def gui_mouseReleaseRightImage(self, event): - self.gui_mouseReleaseEventImg1(event) - - def drawTempRulerLine(self, event): - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - x, y = event.pos() - x1, y1 = int(x), int(y) - xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() - x0, y0 = xxRA[0], yyRA[0] - if ctrl: - x1, y1 = transformation.snap_xy_to_closest_angle( - x0, y0, x1, y1 - ) - self.ax1_rulerPlotItem.setData([x0, x1], [y0, y1]) - - @exception_handler - def gui_mousePressEventImg1(self, event: QMouseEvent): - self.typingEditID = False - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - alt = modifiers == Qt.AltModifier - isMod = alt - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - isCcaMode = mode == 'Cell cycle analysis' - isCustomAnnotMode = mode == 'Custom annotations' - left_click = event.button() == Qt.MouseButton.LeftButton and not isMod - middle_click = self.isMiddleClick(event, modifiers) - right_click = event.button() == Qt.MouseButton.RightButton - isPanImageClick = self.isPanImageClick(event, modifiers) - brushON = self.brushButton.isChecked() - curvToolON = self.curvToolButton.isChecked() - histON = self.setIsHistoryKnownButton.isChecked() - eraserON = self.eraserButton.isChecked() - rulerON = self.rulerButton.isChecked() - wandON = self.wandToolButton.isChecked() and not isPanImageClick - polyLineRoiON = self.addDelPolyLineRoiButton.isChecked() - labelRoiON = self.labelRoiButton.isChecked() - keepObjON = self.keepIDsButton.isChecked() - whitelistIDsON = self.whitelistIDsButton.isChecked() - separateON = self.separateBudButton.isChecked() - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - manualBackgroundON = self.manualBackgroundButton.isChecked() - magicPromptsON = self.magicPromptsToolButton.isChecked() - pointsLayerON = self.togglePointsLayerAction.isChecked() - copyContourON = ( - self.copyLostObjButton.isChecked() - and self.ax1_lostObjScatterItem.hoverLostID>0 - ) - findNextMotherButtonON = self.findNextMotherButton.isChecked() - unknownLineageButtonON = self.unknownLineageButton.isChecked() - drawClearRegionON = self.drawClearRegionButton.isChecked() - zoomRectON = self.zoomRectButton.isChecked() - - # Check if right-click on segment of polyline roi to add segment - segments = self.gui_getHoveredSegmentsPolyLineRoi() - if len(segments) == 1 and right_click: - seg = segments[0] - seg.roi.segmentClicked(seg, event) - return - - # Check if right-click on handle of polyline roi to remove it - handles = self.gui_getHoveredHandlesPolyLineRoi() - if len(handles) == 1 and right_click: - handle = handles[0] - handle.roi.removeHandle(handle) - return - - # Check if click on ROI - isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click) - if isClickOnDelRoi: - return - - dragImgLeft = ( - left_click and not brushON and not histON - and not curvToolON and not eraserON and not rulerON - and not wandON and not polyLineRoiON and not labelRoiON - and not middle_click and not keepObjON and not separateON - and not manualBackgroundON and not drawClearRegionON - and addPointsByClickingButton is None and not whitelistIDsON - and not zoomRectON - ) - if isPanImageClick: - dragImgLeft = True - - is_right_click_custom_ON = any([ - b.isChecked() for b in self.customAnnotDict.keys() - ]) - - canAnnotateDivision = ( - not self.assignBudMothButton.isChecked() - and not self.setIsHistoryKnownButton.isChecked() - and not self.curvToolButton.isChecked() - and not is_right_click_custom_ON - and not labelRoiON - and not separateON - ) - - # In timelapse mode division can be annotated if isCcaMode and right-click - # while in snapshot mode with Ctrl+right-click - isAnnotateDivision = ( - (right_click and isCcaMode and canAnnotateDivision) - or (right_click and ctrl and self.isSnapshot) - ) - - isCustomAnnot = ( - (right_click or dragImgLeft) - and (isCustomAnnotMode or self.isSnapshot) - and self.customAnnotButton is not None - ) - - is_right_click_action_ON = any([ - b.isChecked() for b in self.checkableQButtonsGroup.buttons() - ]) - - isOnlyRightClick = ( - right_click and canAnnotateDivision and not isAnnotateDivision - and not isMod and not is_right_click_action_ON - and not is_right_click_custom_ON and not copyContourON - and not findNextMotherButtonON and not unknownLineageButtonON - and not middle_click - ) - - if isOnlyRightClick: - # Start timer or check if it is a double-right-click - if self.countRightClicks == 0: - self.isDoubleRightClick = False - self.countRightClicks = 1 - self.doubleRightClickTimeElapsed = False - screenPos = event.screenPos() - self._img1_click_xy = (screenPos.x(), screenPos.y()) - QTimer.singleShot(400, self.doubleRightClickTimerCallBack) - return - elif ( - self.countRightClicks == 1 - and not self.doubleRightClickTimeElapsed - ): - self.isDoubleRightClick = True - self.countRightClicks = 0 - self.editIDbutton.setChecked(True) - - # Left click actions - canCurv = ( - curvToolON and not self.assignBudMothButton.isChecked() - and not brushON and not dragImgLeft and not eraserON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON - ) - canBrush = ( - brushON and not curvToolON and not rulerON - and not dragImgLeft and not eraserON and not wandON - and not labelRoiON and not manualBackgroundON - and addPointsByClickingButton is None and not drawClearRegionON - and not magicPromptsON and not zoomRectON - ) - canErase = ( - eraserON and not curvToolON and not rulerON - and not dragImgLeft and not brushON and not wandON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON - ) - canRuler = ( - rulerON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not wandON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON - ) - canWand = ( - wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON - ) - canPolyLine = ( - polyLineRoiON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not labelRoiON and not manualBackgroundON - and addPointsByClickingButton is None - and not drawClearRegionON and not magicPromptsON - and not zoomRectON - ) - canLabelRoi = ( - labelRoiON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not keepObjON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not whitelistIDsON and not magicPromptsON - and not zoomRectON - ) - canKeep = ( - keepObjON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not whitelistIDsON and not magicPromptsON - and not zoomRectON - ) - canWhitelistIDs = ( - whitelistIDsON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not keepObjON and not magicPromptsON - and not zoomRectON - ) - canAddPoint = ( - (pointsLayerON or magicPromptsON) - and addPointsByClickingButton is not None and not wandON - and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON and not keepObjON - and not manualBackgroundON and not drawClearRegionON - and not zoomRectON - ) - canAddManualBackgroundObj = ( - manualBackgroundON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not keepObjON and not drawClearRegionON - and not magicPromptsON and not whitelistIDsON - and not zoomRectON - ) - canDrawClearRegion = ( - drawClearRegionON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not labelRoiON and not manualBackgroundON - and addPointsByClickingButton is None - and not polyLineRoiON and not magicPromptsON - and not whitelistIDsON and not zoomRectON - ) - canZoomRect = ( - zoomRectON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON - and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not wandON and not whitelistIDsON and not magicPromptsON - ) - - # Enable dragging of the image window or the scalebar - if dragImgLeft and not isCustomAnnot: - x, y = event.pos().x(), event.pos().y() - if hasattr(self, 'scaleBar'): - if self.scaleBar.isHighlighted(): - self.scaleBar.mousePressed(x, y) - return - if hasattr(self, 'timestamp'): - if self.timestamp.isHighlighted(): - self.timestamp.mousePressed(x, y) - return - pg.ImageItem.mousePressEvent(self.img1, event) - event.ignore() - return - - isAllowedActionViewer = (canAddPoint or canRuler) - - if mode == 'Viewer' and not isAllowedActionViewer: - self.startBlinkingModeCB() - event.ignore() - return - - # Allow right-click or middle-click actions on both images - eventOnImg2 = ( - ( - right_click or (middle_click and not canAddPoint) - # or (left_click and separateON) - ) - and (mode=='Segmentation and Tracking' or self.isSnapshot) - and not isAnnotateDivision and not manualBackgroundON - ) - if eventOnImg2: - event.isImg1Sender = True - self.gui_mousePressEventImg2(event) - - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - Y, X = self.get_2Dlab(posData.lab).shape - if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - else: - return - - # Paint new IDs with brush and left click on the left image - if left_click and canBrush: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False, storeOnlyZoom=True) - - ID = self.getHoverID(xdata, ydata) - - if ID > 0: - posData.brushID = ID - self.isNewID = False - else: - # Update brush ID. Take care of disappearing cells to remember - # to not use their IDs anymore in the future - self.isNewID = True - self.setBrushID() - self.updateLookuptable(lenNewLut=posData.brushID+1) - - self.brushColor = self.lut[posData.brushID]/255 - - self.yPressAx2, self.xPressAx2 = y, x - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - diskSlice = (slice(ymin, ymax), slice(xmin, xmax)) - - self.isMouseDragImg1 = True - - # Draw new objects - localLab = lab_2D[diskSlice] - mask = diskMask.copy() - if not self.isPowerBrush() and not ctrl: - mask[localLab!=0] = False - - self.applyBrushMask(mask, posData.brushID, toLocalSlice=diskSlice) - - self.setImageImg2(updateLookuptable=False) - - how = self.drawIDsContComboBox.currentText() - lab2D = self.get_2Dlab(posData.lab) - self.globalBrushMask = np.zeros(lab2D.shape, dtype=bool) - brushMask = localLab == posData.brushID - brushMask = np.logical_and(brushMask, diskMask) - self.setTempImg1Brush( - True, brushMask, posData.brushID, toLocalSlice=diskSlice - ) - - self.lastHoverID = -1 - - elif left_click and canErase: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - lab_2D = self.get_2Dlab(posData.lab) - Y, X = lab_2D.shape - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False, storeOnlyZoom=True) - - self.yPressAx2, self.xPressAx2 = y, x - # Keep a list of erased IDs got erased - self.erasedIDs = set() - - if self.xyOnCtrlPressedFirstTime is not None: - self.erasedID = self.getHoverID(*self.xyOnCtrlPressedFirstTime) - else: - self.erasedID = self.getHoverID(xdata, ydata) - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - - # Build eraser mask - mask = np.zeros(lab_2D.shape, bool) - mask[ymin:ymax, xmin:xmax][diskMask] = True - - - # If user double-pressed 'b' then erase over ALL labels - color = self.eraserButton.palette().button().color().name() - eraseOnlyOneID = ( - color != self.doublePressKeyButtonColor - and self.erasedID != 0 - ) - - self.eraseOnlyOneID = eraseOnlyOneID - - if eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False - - self.setTempImg1Eraser(mask, init=True) - self.applyEraserMask(mask) - - self.erasedIDs.update(lab_2D[mask]) - - for erasedID in self.erasedIDs: - if erasedID == 0: - continue - self.erasedLab[lab_2D==erasedID] = erasedID - - self.isMouseDragImg1 = True - - elif canAddPoint: - action = addPointsByClickingButton.action - self.storeUndoAddPoint(action) - x, y = event.pos().x(), event.pos().y() - hoveredPoints = action.scatterItem.pointsAt(event.pos()) - if len(hoveredPoints) > 0: - removed_ids = self.removeClickedPoints(action, hoveredPoints) - if not magicPromptsON: - removed_id = min(removed_ids) - addPointsByClickingButton.pointIdSpinbox.setValue(removed_id) - addPointsByClickingButton.pointIdSpinbox.removedId = ( - removed_id - ) - else: - self.restorePrevPointIdRightClick(addPointsByClickingButton) - self.drawPointsLayers(computePointsLayers=False) - else: - point_id = self.getAddedPointId( - magicPromptsON, addPointsByClickingButton, - right_click, left_click, middle_click - ) - if point_id is None: - return - - self.addClickedPoint(action, x, y, point_id) - self.drawPointsLayers(computePointsLayers=False) - - point_id = self.getClickedPointNewId( - action, point_id, - addPointsByClickingButton.pointIdSpinbox, - isMagicPrompts=magicPromptsON - ) - addPointsByClickingButton.pointIdSpinbox.setValue( - point_id, setLinkedWidget=False - ) - - elif left_click and canDrawClearRegion: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - self.freeRoiItem.addPoint(xdata, ydata) - - self.isMouseDragImg1 = True - - elif left_click and canRuler or canPolyLine: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - closePolyLine = ( - len(self.startPointPolyLineItem.pointsAt(event.pos())) > 0 - ) - if not self.tempSegmentON or canPolyLine: - # Keep adding anchor points for polyline - self.ax1_rulerAnchorsItem.setData([xdata], [ydata]) - self.tempSegmentON = True - else: - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - self.tempSegmentON = False - xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() - x0, y0 = xxRA[0], yyRA[0] - if ctrl: - x1, y1 = transformation.snap_xy_to_closest_angle( - x0, y0, xdata, ydata - ) - else: - x1, y1 = xdata, ydata - lengthText = self.getRulerLengthText() - self.ax1_rulerPlotItem.setData( - [x0, x1], [y0, y1], lengthText=lengthText - ) - self.ax1_rulerAnchorsItem.setData([x0, x1], [y0, y1]) - - xxPolyLine = self.startPointPolyLineItem.getData()[0] - if canPolyLine and len(xxPolyLine) == 0: - # Create and add roi item - self.createDelPolyLineRoi() - # Add start point of polyline roi - self.startPointPolyLineItem.setData([xdata], [ydata]) - self.polyLineRoi.points.append((xdata, ydata)) - elif canPolyLine: - # Add points to polyline roi and eventually close it - if not closePolyLine: - self.polyLineRoi.points.append((xdata, ydata)) - self.addPointsPolyLineRoi(closed=closePolyLine) - if closePolyLine: - # Close polyline ROI - if len(self.polyLineRoi.getLocalHandlePositions()) == 2: - self.polyLineRoi = self.replacePolyLineRoiWithLineRoi( - self.polyLineRoi - ) - self.tempSegmentON = False - self.ax1_rulerAnchorsItem.setData([], []) - self.ax1_rulerPlotItem.setData([], []) - self.startPointPolyLineItem.setData([], []) - self.addRoiToDelRoiInfo(self.polyLineRoi) - # Call roi moving on closing ROI - self.delROImoving(self.polyLineRoi) - self.delROImovingFinished(self.polyLineRoi) - - elif left_click and canKeep: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - keepID_win = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to keep', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - keepID_win.exec_() - if keepID_win.cancel: - return - else: - ID = keepID_win.EntryID - - if ID in self.keptObjectsIDs: - self.keptObjectsIDs.remove(ID) - self.clearHighlightedText() - else: - self.keptObjectsIDs.append(ID) - self.highlightLabelID(ID) - - self.updateTempLayerKeepIDs() - - elif left_click and canWhitelistIDs: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - keepID_win = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to select', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - keepID_win.exec_() - if keepID_win.cancel: - return - else: - ID = keepID_win.EntryID - - posData = self.data[self.pos_i] - - if not posData.whitelist: - wl_init = False - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() # not updated, only use in this context - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = self.tempWhitelistIDs - else: - wl_init = True - current_whitelist = posData.whitelist.get(posData.frame_i) - - if ID in current_whitelist: - current_whitelist.remove(ID) - self.removeHighlightLabelID(IDs=[ID]) - else: - current_whitelist.add(ID) - self.highlightLabelID(ID) - - self.whitelistIDsToolbar.whitelistLineEdit.setText( - current_whitelist - ) - - if wl_init: - posData.whitelist[posData.frame_i] = current_whitelist - else: - self.tempWhitelistIDs = current_whitelist - - self.whitelistUpdateTempLayer() - - elif right_click and copyContourON: - hoverLostID = self.ax1_lostObjScatterItem.hoverLostID - self.copyLostObjectMask(hoverLostID) - self.update_rp() - self.updateAllImages() - self.store_data() - - elif right_click and canCurv: - # Draw manually assisted auto contour - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - Y, X = self.get_2Dlab(posData.lab).shape - - self.autoCont_x0 = xdata - self.autoCont_y0 = ydata - self.xxA_autoCont, self.yyA_autoCont = [], [] - self.curvAnchors.addPoints([x], [y]) - img = self.getDisplayedImg1() - self.autoContObjMask = np.zeros(img.shape, np.uint8) - self.isRightClickDragImg1 = True - - elif left_click and canCurv: - # Draw manual spline - x, y = event.pos().x(), event.pos().y() - Y, X = self.get_2Dlab(posData.lab).shape - - # Check if user clicked on starting anchor again --> close spline - closeSpline = False - clickedAnchors = self.curvAnchors.pointsAt(event.pos()) - xxA, yyA = self.curvAnchors.getData() - if len(xxA)>0: - if len(xxA) == 1: - self.splineHoverON = True - x0, y0 = xxA[0], yyA[0] - if len(clickedAnchors)>0: - xA_clicked, yA_clicked = clickedAnchors[0].pos() - if x0==xA_clicked and y0==yA_clicked: - x = x0 - y = y0 - closeSpline = True - - # Add anchors - self.curvAnchors.addPoints([x], [y]) - try: - xx, yy = self.curvHoverPlotItem.getData() - self.curvPlotItem.setData(xx, yy) - except Exception as e: - # traceback.print_exc() - pass - - if closeSpline: - self.splineHoverON = False - self.curvToolSplineToObj() - self.update_rp() - if self.autoIDcheckbox.isChecked(): - self.trackManuallyAddedObject(posData.brushID, True) - if self.isSnapshot: - self.fixCcaDfAfterEdit('Add new ID with curvature tool') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Add new ID with curvature tool') - self.clearCurvItems() - self.curvTool_cb(True) - - elif left_click and canWand: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - Y, X = self.get_2Dlab(posData.lab).shape - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - self.isNewID = False - posData.brushID = self.get_2Dlab(posData.lab)[ydata, xdata] - if posData.brushID == 0: - self.setBrushID() - self.updateLookuptable( - lenNewLut=posData.brushID+1 - ) - self.isNewID = True - self.brushColor = self.img2.lut[posData.brushID]/255 - - # NOTE: flood is on mousedrag or release - tol = self.getMagicWandFloodTolerance() - self.initFloodMaskImage() - if self.isSegm3D: - z_slice = self.zSliceScrollBar.sliderPosition() - seed = (z_slice, ydata, xdata) - else: - seed = (ydata, xdata) - - flood_mask = skimage.segmentation.flood( - self.flood_img, seed, tolerance=tol - ) - - drawUnderMask = np.logical_or( - posData.lab==0, posData.lab==posData.brushID - ) - self.flood_mask = np.logical_and(flood_mask, drawUnderMask) - - if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): - self.flood_mask = core.binary_fill_holes(self.flood_mask) - - if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): - self.flood_mask = core.convex_hull_mask(self.flood_mask) - - self.setTempBrushMaskFromWand(self.flood_mask, init=True) - self.isMouseDragImg1 = True - - elif right_click and self.manualTrackingButton.isChecked(): - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - manualTrackID = self.manualTrackingToolbar.spinboxID.value() - clickedID = self.getClickedID( - xdata, ydata, text=f'that you want to assign to {manualTrackID}' - ) - if clickedID is None: - return - - if clickedID == manualTrackID: - self.manualTrackingToolbar.showWarning( - f'The clicked object already has ID = {manualTrackID}' - ) - return - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - posData = self.data[self.pos_i] - currentIDs = posData.IDs.copy() - if manualTrackID in currentIDs: - tempID = max(currentIDs) + 1 - posData.lab[posData.lab == clickedID] = tempID - posData.lab[posData.lab == manualTrackID] = clickedID - posData.lab[posData.lab == tempID] = manualTrackID - self.manualTrackingToolbar.showWarning( - f'The ID {manualTrackID} already exists --> ' - f'ID {manualTrackID} has been swapped with {clickedID}' - ) - else: - posData.lab[posData.lab == clickedID] = manualTrackID - self.manualTrackingToolbar.showInfo( - f'ID {clickedID} changed to {manualTrackID}.' - ) - - self.update_rp() - self.updateAllImages() - - elif right_click and manualBackgroundON: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - - delID = posData.manualBackgroundLab[ydata, xdata] - if delID == 0: - return - - self.clearManualBackgroundObject(delID) - textItem = self.manualBackgroundTextItems.pop(delID) - self.ax1.removeItem(textItem) - self.setManualBackgroundImage() - - elif left_click and canAddManualBackgroundObj: - x, y = event.pos().x(), event.pos().y() - - self.addManualBackgroundObject(x, y) - self.setManualBackgroundImage() - self.setManualBackgrounNextID() - - # Label ROI mouse press - elif (left_click or right_click) and canLabelRoi: - if right_click: - # Force model initialization on mouse release - self.labelRoiModel = None - - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - - if self.labelRoiIsRectRadioButton.isChecked(): - self.labelRoiItem.setPos((xdata, ydata)) - elif self.labelRoiIsFreeHandRadioButton.isChecked(): - self.freeRoiItem.addPoint(xdata, ydata) - - self.isMouseDragImg1 = True - - # Annotate cell cycle division - elif isAnnotateDivision: - if posData.cca_df is None: - return - - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - divID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as divided', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - divID_prompt.exec_() - if divID_prompt.cancel: - return - else: - ID = divID_prompt.EntryID - obj_idx = posData.IDs.index(ID) - y, x = posData.rp[obj_idx].centroid - xdata, ydata = int(x), int(y) - - if not self.isSnapshot: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - # Annotate or undo division - self.manualCellCycleAnnotation(ID) - else: - self.undoBudMothAssignment(ID) - - # Assign bud to mother (mouse down on bud) - elif right_click and self.assignBudMothButton.isChecked(): - if self.clickedOnBud: - # NOTE: self.clickedOnBud is set to False when assigning a mother - # is successfull in mouse release event - # We still have to click on a mother - return - - if posData.cca_df is None: - return - - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - budID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID of a bud you want to correct mother assignment', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - budID_prompt.exec_() - if budID_prompt.cancel: - return - else: - ID = budID_prompt.EntryID - - obj_idx = posData.IDs.index(ID) - y, x = posData.rp[obj_idx].centroid - xdata, ydata = int(x), int(y) - - relationship = posData.cca_df.at[ID, 'relationship'] - is_history_known = posData.cca_df.at[ID, 'is_history_known'] - self.clickedOnHistoryKnown = is_history_known - # We allow assiging a cell in G1 as bud only on first frame - # OR if the history is unknown - if relationship != 'bud' and posData.frame_i > 0 and is_history_known: - txt = (f'You clicked on ID {ID} which is NOT a bud.\n' - 'To assign a bud to a cell start by clicking on a bud ' - 'and release on a cell in G1') - msg = QMessageBox() - msg.critical( - self, 'Not a bud', txt, msg.Ok - ) - return - - self.clickedOnBud = True - self.xClickBud, self.yClickBud = xdata, ydata - - # Annotate (or undo) that cell has unknown history - elif right_click and self.setIsHistoryKnownButton.isChecked(): - if posData.cca_df is None: - return - - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - unknownID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as ' - '"history UNKNOWN/KNOWN"', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - unknownID_prompt.exec_() - if unknownID_prompt.cancel: - return - else: - ID = unknownID_prompt.EntryID - obj_idx = posData.IDs.index(ID) - y, x = posData.rp[obj_idx].centroid - xdata, ydata = int(x), int(y) - - self.annotateIsHistoryKnown(ID) - if not self.setIsHistoryKnownButton.findChild(QAction).isChecked(): - self.setIsHistoryKnownButton.setChecked(False) - - elif isCustomAnnot: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) - clickedBkgrDialog = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as divided', - parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - clickedBkgrDialog.exec_() - if clickedBkgrDialog.cancel: - return - else: - ID = clickedBkgrDialog.EntryID - obj_idx = posData.IDs.index(ID) - y, x = posData.rp[obj_idx].centroid - xdata, ydata = int(x), int(y) - - button = self.doCustomAnnotation(ID) - if button is None: - return - - keepActive = self.customAnnotDict[button]['state']['keepActive'] - if not keepActive: - button.setChecked(False) - - elif right_click and findNextMotherButtonON: - if posData.frame_i == 0: - return - - self.find_mother_action(posData, event, ydata, xdata) - - elif right_click and unknownLineageButtonON: - if posData.frame_i == 0: - return - - self.annotate_unknown_lineage_action(posData, event, ydata, xdata) - - elif (left_click or right_click) and canZoomRect: - if left_click: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - - self.zoomRectItem.setPos((xdata, ydata)) - - self.isMouseDragImg1 = True - else: - try: - xRange, yRange = self.zoomRectItem.getLastRange() - self.ax1.setRange( - xRange=xRange, - yRange=yRange, - padding=0 - ) - except Exception as err: - QTimer.singleShot(100, self.autoRange) - - def repeat_click_and_backup(self, posData, event, ydata, xdata): - """ - This function is part of the lin_tree edit functionality. - It handles the back up of the original self.lineage_tree.lineage_list - df and the repeated clicking on the same ID to cycle through pssible mothers. - - Parameters - ---------- - posData : cellacdc.load.loadData - The position data. - event : QtGui.QMouseEvent - The event object. - ydata : int - The y-coordinate data. - xdata : int - The x-coordinate data. - - Returns - ------- - tuple - A tuple containing the point(tuple: (x, y) coords) and ID of clicked cell. - """ - if self.original_df_lin_tree is None: - self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() - self.original_df_lin_tree_i = posData.frame_i - elif self.original_df_lin_tree_i != posData.frame_i: - self.logger.info( - '[WARNING]: !!! Original lineage tree df changed, resetting original_df_lin_tree !!!' - ) - self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() - self.original_df_lin_tree_i = posData.frame_i - - if not self.right_click_ID: - self.right_click_i = 0 - self.right_click_ID = 0 - - x, y = event.pos().x(), event.pos().y() - point = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - - if ID == 0: - return None, None - - if self.right_click_ID != ID: - self.right_click_i = 0 - self.right_click_ID = ID - self.original_mother_skipped = False - elif event.modifiers() & Qt.ShiftModifier: - self.right_click_i -= 1 - else: - self.right_click_i += 1 - - return point, ID - - def getDistanceListMissingIDs(self, point, ID): - posData = self.data[self.pos_i] - frame_i = posData.frame_i - if self.getDistanceListMissingIDsCachedFrame != frame_i: - self.distanceListMissingIDs = dict() - self.getDistanceListMissingIDsCachedFrame = frame_i - # self.store_data(autosave=False) - # self.get_data() - - if ID not in self.distanceListMissingIDs.keys(): - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - relevant_rp = [ - obj for obj in prev_rp if obj.label not in posData.IDs - ] - len_relevant_rp = len(relevant_rp) - if len_relevant_rp == 0: - self.logger.info('No missing IDs found in previous frame.') - return [] - elif len_relevant_rp == 1: - self.distanceListMissingIDs[ID] = [relevant_rp[0].label] - return [relevant_rp[0].label] - else: - sorted_missing_IDs = myutils.sort_IDs_dist(relevant_rp, point=point) - self.distanceListMissingIDs[ID] = sorted_missing_IDs - return sorted_missing_IDs - else: - return self.distanceListMissingIDs[ID] - - def find_mother_action(self, posData, event, ydata, xdata): - """ - This function is part of the lin_tree edit functionality. - Associated with the right-click action of the 'findNextMotherButton' button. - Handles the right click action, which cycles through possible mothers of the clicked cell. - Changes the parent ID of the clicked cell to the next possible mother in self.lineage_tree.lineage_list. - - Parameters - ---------- - posData : cellacdc.load.loadData - The position data object. - event : QtGui.QMouseEvent - The event object. - ydata : int - The y-coordinate data. - xdata : int - The x-coordinate data. - """ - point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata) - - if point is None: - return - posData = self.data[self.pos_i] - acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] - filtered_IDs = self.getDistanceListMissingIDs(point, ID) - if len(filtered_IDs) == 0: - self.logger.info('No mother candidates found.') - return - - i = self.right_click_i % len(filtered_IDs) - i = abs(i) # Ensure i is non-negative - new_mother = filtered_IDs[i] - - if acdc_df_frame.loc[ID]['parent_ID_tree'] == new_mother and self.original_mother_skipped == False: # if a mother is already present, skip it - self.right_click_i += 1 - self.original_mother_skipped = True - - i = self.right_click_i % len(filtered_IDs) - i = abs(i) # Ensure i is non-negative - new_mother = filtered_IDs[i] - - acdc_df_frame.at[ID, 'parent_ID_tree'] = new_mother # update mother in the df, no need to propagate or stuff lile this - # dont need to update alldata_li as acdc_df_frame is just a view - self.drawAllLineageTreeLines() - - def annotate_unknown_lineage_action(self, posData, event, ydata, xdata): - """ - This function is part of the lin_tree edit functionality. - Associated with the right-click action of the 'unknownLineageButton' button. - Annotates an unknown lineage by setting its parent ID to -1 in the lineage tree (self.lineage_tree.lineage_list) - - Parameters - ---------- - posData : cellacdc.load.loadData - The position data. - event : QtGui.QMouseEvent - The event that triggered the annotation. - ydata : int - The y-coordinate data. - xdata : int - The x-coordinate data. - """ - point, ID = self.repeat_click_and_backup(posData, event, ydata, xdata) - - if point is None: - return - posData = self.data[self.pos_i] - acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] - acdc_df_frame.at[ID, 'parent_ID_tree'] = -1 - self.drawAllLineageTreeLines() - - def gui_addCreatedAxesItems(self): - self.ax1.addItem(self.ax1_contoursImageItem) - self.ax1.addItem(self.ax1_lostObjImageItem) - self.ax1.addItem(self.ax1_lostTrackedObjImageItem) - self.ax1.addItem(self.ax1_oldMothBudLinesItem) - self.ax1.addItem(self.ax1_newMothBudLinesItem) - self.ax1.addItem(self.ax1_lostObjScatterItem) - self.ax1.addItem(self.ax1_lostTrackedScatterItem) - self.ax1.addItem(self.ccaFailedScatterItem) - self.ax1.addItem(self.yellowContourScatterItem) - - self.ax2.addItem(self.ax2_contoursImageItem) - self.ax2.addItem(self.ax2_lostObjImageItem) - self.ax2.addItem(self.ax2_lostTrackedObjImageItem) - self.ax2.addItem(self.ax2_oldMothBudLinesItem) - self.ax2.addItem(self.ax2_newMothBudLinesItem) - self.ax2.addItem(self.ax2_lostObjScatterItem) - - self.textAnnot[0].addToPlotItem(self.ax1) - self.textAnnot[1].addToPlotItem(self.ax2) - - self.ax1.addItem(self.exportMaskImageItem) - self.ax1.exportMaskImageItem = self.exportMaskImageItem - - def SegForLostIDsSetSettings(self): - - try: - prev_model = str(self.df_settings.at['SegForLostIDsModel', 'value']) - except KeyError: - prev_model = None - win = apps.QDialogSelectModel(parent=self, customFirst=prev_model) - win.exec_() - if win.cancel: - self.logger.info('Seg for lost IDs cancelled.') - return - base_model_name = win.selectedModel - - if base_model_name: - self.df_settings.at['SegForLostIDsModel', 'value'] = base_model_name - self.df_settings.to_csv(self.settings_csv_path) - - model_name = 'local_seg' - - idx = self.modelNames.index(model_name) - acdcSegment = self.acdcSegment_li[idx] - - try: - if acdcSegment is None or base_model_name != self.local_seg_base_model_name: - self.logger.info(f'Importing {base_model_name}...') - acdcSegment = myutils.import_segment_module(base_model_name) - self.acdcSegment_li[idx] = acdcSegment - self.local_seg_base_model_name = base_model_name - except (IndexError, ImportError, KeyError) as e: - self.logger.error(f'Error importing {base_model_name}: {e}') - return - - extra_params = ['overlap_threshold', - 'padding', - 'size_perc_diff', - 'distance_filler_growth', - 'max_iterations', - 'allow_only_tracked_cells'] - - extra_types = [float, float, float, float, int, bool] - - extra_defaults = [0.5, 0.8, 0.3, 1., 2, False] - - extra_desc = ['Overlap threshold with other already segemented cells over which newly segmented cells are discarded', - 'Padding of the box used for new segmentation around the segmentation from the previous frame', - 'Relative size difference acceptable compared to previous frames', - """Cells which are already segmented are filled with random noise sampled from background - to ensure that they don't get segmented again. - This parameter controls the additional padding around the already segmented cells.""", - """The algorithm will try and segment the maximum amount - of cells in the image by running the model several - times and filling new found cells with background noise. - How many of these iterations should be run?""", - "If no new cell IDs should be permitted (based on real time tracking)"] - - extra_ArgSpec = [] - for i, param in enumerate(extra_params): - param = ArgSpec(name=param, - default=extra_defaults[i], - type=extra_types[i], - desc=extra_desc[i], - docstring='') - - extra_ArgSpec.append(param) - - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) - segment_params = [arg for arg in segment_params if arg[0] != 'diameter'] - - extraParamsTitle = 'Settings for local segmentation' - win = self.initSegmModelParams( - base_model_name, acdcSegment, init_params, segment_params, - extraParams=extra_ArgSpec, extraParamsTitle=extraParamsTitle, - initLastParams=True, ini_filename='segmentation_for_lostIDs.ini', - ) - - if win is None: - self.logger.info('Segmentation for lost IDs cancelled.') - return - - init_kwargs_new = {} - args_new = {} - for key, val in win.init_kwargs.items(): - if key in extra_params: - args_new[key] = val - else: - init_kwargs_new[key] = val - - for key, val in win.extra_kwargs.items(): - if key in extra_params: - args_new[key] = val - - self.SegForLostIDsSettings = { - 'win': win, - 'init_kwargs_new': init_kwargs_new, - 'args_new': args_new, - 'base_model_name': base_model_name, - } - - def segForLostIDsButtonClicked(self): - - self.setFrameNavigationDisabled(disable=True, why='Segmentation for lost IDs') - posData = self.data[self.pos_i] - if posData.frame_i == 0: - self.logger.info('Segmentation for lost IDs not available on first frame.') - self.setFrameNavigationDisabled(disable=False, why='Segmentation for lost IDs') - return - self.storeUndoRedoStates(False) - self.progressWin = apps.QDialogWorkerProgress( - title='Segmenting for lost IDs', parent=self, - pbarDesc=f'Segmenting for lost IDs...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.startSegForLostIDsWorker() - - def onSegForLostInit(self): - self.logger.info('Settings for segmentation for lost IDs not set.') - self.SegForLostIDsSetSettings() - self.SegForLostIDsWaitCond.wakeAll() - - def SegForLostIDsWorkerAskInstallModel(self, model_name): - myutils.check_install_package(model_name) - self.SegForLostIDsWaitCond.wakeAll() - - def startSegForLostIDsWorker(self): - self.SegForLostIDsMutex = QMutex() - self.SegForLostIDsWaitCond = QWaitCondition() - self._thread = QThread() - - # Initialize the worker with mutex and wait condition - self.SegForLostIDsWorker = workers.SegForLostIDsWorker( - self, self.SegForLostIDsMutex, self.SegForLostIDsWaitCond - ) - - # Connect the worker's signal to the main thread's slot - self.SegForLostIDsWorker.sigAskInit.connect(self.onSegForLostInit) - self.SegForLostIDsWorker.sigAskInstallModel.connect( - self.SegForLostIDsWorkerAskInstallModel - ) - self.SegForLostIDsWorker.sigshowImageDebug.connect( - self.showImageDebug - ) - - self.SegForLostIDsWorker.sigSegForLostIDsWorkerAskInstallGPU.connect( - self.SegForLostIDsWorkerAskInstallGPU - ) - - self.SegForLostIDsWorker.sigStoreData.connect(self.onSigStoreDataSegForLostIDsWorker) - self.SegForLostIDsWorker.sigUpdateRP.connect(self.onSigUpdateRPSegForLostIDsWorker) - # self.SegForLostIDsWorker.sigGetData.connect(self.onSigGetDataSegForLostIDsWorker) - # self.SegForLostIDsWorker.sigGet2Dlab.connect(self.onSigGet2DlabSegForLostIDsWorker) - # self.SegForLostIDsWorker.sigGetTrackedLostIDs.connect(self.onSigGetTrackedSegForLostIDsWorker) - # self.SegForLostIDsWorker.sigGetBrushID.connect(self.onSigGetBrushIDSegForLostIDsWorker) - self.SegForLostIDsWorker.sigTrackManuallyAddedObject.connect(self.onSigTrackManuallyAddedObjectSegForLostIDsWorker) - - # Move the worker to the thread - self.SegForLostIDsWorker.moveToThread(self._thread) - - # Manage thread lifecycle - self.SegForLostIDsWorker.signals.finished.connect(self._thread.quit) - self.SegForLostIDsWorker.signals.finished.connect(self.SegForLostIDsWorker.deleteLater) - self._thread.finished.connect(self._thread.deleteLater) - - # Connect other worker signals to the appropriate slots - self.SegForLostIDsWorker.signals.finished.connect(self.SegForLostIDsWorkerFinished) - self.SegForLostIDsWorker.signals.progress.connect(self.workerProgress) - self.SegForLostIDsWorker.signals.initProgressBar.connect(self.workerInitProgressbar) - self.SegForLostIDsWorker.signals.progressBar.connect(self.workerUpdateProgressbar) - self.SegForLostIDsWorker.signals.critical.connect(self.workerCritical) - - # Start the thread and worker - self._thread.started.connect(self.SegForLostIDsWorker.run) - self._thread.start() - - def SegForLostIDsWorkerAskInstallGPU(self, model_name, use_gpu): - result = myutils.check_gpu_available(model_name, use_gpu, qparent=self) - self.SegForLostIDsWorker.gpu_go = result - dont_force_cpu = myutils.check_gpu_available( - model_name, use_gpu, do_not_warn=True) - self.SegForLostIDsWorker.dont_force_cpu = dont_force_cpu - self.SegForLostIDsWaitCond.wakeAll() - - def onSigStoreDataSegForLostIDsWorker(self, autosave): - self.onSigStoreData( - self.SegForLostIDsWaitCond, autosave=autosave) - - def onSigUpdateRPSegForLostIDsWorker(self, wl_update, wl_track_og_curr): - self.onSigUpdateRP(self.SegForLostIDsWaitCond, - wl_update=wl_update, - wl_track_og_curr=wl_track_og_curr) - - # def onSigGetDataSegForLostIDsWorker(self): - # self.onSigGetData( - # self.SegForLostIDsWaitCond) - - # def onSigGet2DlabSegForLostIDsWorker(self): - # posData = self.data[self.pos_i] - # lab = self.get_2Dlab(posData.lab) - # self.SegForLostIDsWorker.lab = lab - # self.SegForLostIDsWaitCond.wakeAll() - - # def onSigGetTrackedSegForLostIDsWorker(self): - # self.SegForLostIDsWorker.trackedLostIDs = self.getTrackedLostIDs() - # self.SegForLostIDsWaitCond.wakeAll() - - # def onSigGetBrushIDSegForLostIDsWorker(self): - # self.SegForLostIDsWorker.brushID = self.setBrushID(useCurrentLab=True, return_val=True) - # self.SegForLostIDsWaitCond.wakeAll() - - def onSigTrackManuallyAddedObjectSegForLostIDsWorker(self, added_IDs, isNewID, wl_update, wl_track_og_curr): - self.trackManuallyAddedObject(added_IDs, isNewID, wl_update=wl_update, wl_track_og_curr=wl_track_og_curr) - self.SegForLostIDsWaitCond.wakeAll() - - - def onSigStoreData( - self, waitcond, pos_i=None, enforce=True, debug=False, - mainThread=True, autosave=True, store_cca_df_copy=False - ): - self.store_data(pos_i=pos_i, enforce=enforce, debug=debug, mainThread=mainThread, - autosave=autosave, store_cca_df_copy=store_cca_df_copy) - waitcond.wakeAll() - - def onSigUpdateRP(self, waitcond, draw=True, debug=False, update_IDs=True, - wl_update=True, wl_track_og_curr=False): - self.update_rp(draw=draw, debug=debug, update_IDs=update_IDs, - wl_update=wl_update, wl_track_og_curr=wl_track_og_curr) - waitcond.wakeAll() - - def onSigGetData(self, waitcond, debug=False): - self.get_data(debug=debug) - waitcond.wakeAll() - - def SegForLostIDsWorkerFinished(self): - self.updateAllImages() - self.update_rp() - self.store_data(autosave=True) - self.setFrameNavigationDisabled(disable=False, why='Segmentation for lost IDs') - - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - def showImageDebug(self, img): - imshow(img) - - def gui_raiseBottomLayoutContextMenu(self, event): - try: - # Convert QPointF to QPoint - self.bottomLayoutContextMenu.popup(event.globalPos().toPoint()) - except AttributeError: - self.bottomLayoutContextMenu.popup(event.globalPos()) - - def areContoursRequested(self, ax): - if ax == 0 and self.annotContourCheckbox.isChecked(): - return True - - if ax == 1: - if not self.labelsGrad.showRightImgAction.isChecked(): - return False - - isRightDifferentAnnot = self.rightBottomGroupbox.isChecked() - areContRequestedRight = self.annotContourCheckboxRight.isChecked() - - if isRightDifferentAnnot and areContRequestedRight: - return True - - areContRequestedLeft = self.annotContourCheckbox.isChecked() - if not isRightDifferentAnnot and areContRequestedLeft: - return True - return False - - def areMothBudLinesRequested(self, ax): - if ax == 0: - if self.annotCcaInfoCheckbox.isChecked(): - return True - if self.drawMothBudLinesCheckbox.isChecked(): - return True - else: - if not self.labelsGrad.showRightImgAction.isChecked(): - return False - - isRightDifferentAnnot = self.rightBottomGroupbox.isChecked() - areLinesRequestedRight = ( - self.annotCcaInfoCheckboxRight.isChecked() - or self.drawMothBudLinesCheckboxRight.isChecked() - ) - - if isRightDifferentAnnot and areLinesRequestedRight: - return True - - areLinesRequestedLeft = ( - self.drawMothBudLinesCheckbox.isChecked() - or self.annotCcaInfoCheckbox.isChecked() - ) - if not isRightDifferentAnnot and areLinesRequestedLeft: - return True - return False - - def getMothBudLineScatterItem(self, ax, new): - if ax == 0: - if new: - return self.ax1_newMothBudLinesItem - else: - return self.ax1_oldMothBudLinesItem - else: - if new: - return self.ax2_newMothBudLinesItem - else: - return self.ax2_oldMothBudLinesItem - - def labelRoiIsCircularRadioButtonToggled(self, checked): - if checked: - self.labelRoiCircularRadiusSpinbox.setDisabled(False) - else: - self.labelRoiCircularRadiusSpinbox.setDisabled(True) - - def pxModeActionToggled(self, checked): - self.df_settings.at['pxMode', 'value'] = int(checked) - self.df_settings.to_csv(self.settings_csv_path) - - if not self.isDataLoaded: - return - - if self.highLowResAction.isChecked(): - for ax in range(2): - self.textAnnot[ax].setPxMode(checked) - - self.updateAllImages() - - def relabelSequentialCallback(self): - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer' or mode == 'Cell cycle analysis': - self.startBlinkingModeCB() - return - - posData = self.data[self.pos_i] - selectedPos = (posData.pos_foldername, ) - if len(self.data) > 1: - selectedPos = self.askSelectPos(action='to process') - if selectedPos is None: - self.logger.info('Re-labelling process stopped.') - return - - self.store_data() - # acdc_df_concat = self.getConcatAcdcDf() - # load.store_unsaved_acdc_df( - # posData, acdc_df_concat, - # log_func=self.logger.info - # ) - # if posData.SizeT > 1: - self.progressWin = apps.QDialogWorkerProgress( - title='Re-labelling sequential', parent=self, - pbarDesc='Relabelling sequential...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - self.startRelabellingWorker(selectedPos) - - # elif posData: - # self.storeUndoRedoStates(False) - # posData.lab, oldIDs, newIDs = core.relabel_sequential(posData.lab) - # # Update annotations based on relabelling - # self.update_cca_df_relabelling(posData, oldIDs, newIDs) - # self.updateAnnotatedIDs(oldIDs, newIDs, logger=self.logger.info) - # self.store_data() - # self.update_rp() - # li = list(zip(oldIDs, newIDs)) - # s = '\n'.join([str(pair).replace(',', ' -->') for pair in li]) - # s = f'IDs relabelled as follows:\n{s}' - # self.logger.info(s) - # self.updateAllImages() - - def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print): - logger('Updating annotated IDs...') - posData = self.data[self.pos_i] - - mapper = dict(zip(oldIDs, newIDs)) - posData.ripIDs = set([mapper[ripID] for ripID in posData.ripIDs]) - posData.binnedIDs = set([mapper[binID] for binID in posData.binnedIDs]) - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - - customAnnotButtons = list(self.customAnnotDict.keys()) - for button in customAnnotButtons: - customAnnotValues = self.customAnnotDict[button] - annotatedIDs = customAnnotValues['annotatedIDs'][self.pos_i] - mappedAnnotIDs = {} - for frame_i, annotIDs_i in annotatedIDs.items(): - mappedIDs = [mapper[ID] for ID in annotIDs_i] - mappedAnnotIDs[frame_i] = mappedIDs - customAnnotValues['annotatedIDs'][self.pos_i] = mappedAnnotIDs - - def rtTrackerActionToggled(self, checked): - if not checked: - return - - aliases = myutils.aliases_real_time_trackers(reverse=True) - if self.sender().text() in aliases: - trackingAlgo = aliases[self.sender().text()] - else: - trackingAlgo = self.sender().text() - self.df_settings.at['tracking_algorithm', 'value'] = trackingAlgo - self.df_settings.to_csv(self.settings_csv_path) - - if self.sender().text() == 'YeaZ': - msg = widgets.myMessageBox(wrapText=False) - info_txt = html_utils.paragraph(f""" - Note that YeaZ tracking algorithm tends to be sliglhtly more accurate - overall, but it is less capable of detecting segmentation - errors.

- If you need to correct as many segmentation errors as possible - we recommend using Cell-ACDC tracking algorithm. - """) - msg.information(self, 'Info about YeaZ', info_txt) - - self.isRealTimeTrackerInitialized = False - self.initRealTimeTracker() - - def autoPilotToggled(self, checked): - self.autoPilotZoomToObjToolbar.setVisible(checked) - if checked: - self.autoPilotZoomToObjToggle.setChecked(False) - self.autoPilotZoomToObjToggle.toggle() - - def zoomRectActionToggled(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - self.connectLeftClickButtons() - self.ax1.addItem(self.zoomRectItem) - else: - self.zoomRectItem.setPos((0,0)) - self.zoomRectItem.setSize((0,0)) - self.ax1.removeItem(self.zoomRectItem) - - def zoomRectDone(self): - xRange, yRange = self.ax1.viewRange() - self.zoomRectItem.storeLastRange(xRange, yRange) - - ymin, xmin, ymax, xmax = self.zoomRectItem.bbox() - - self.zoomRectItem.setPos((0,0)) - self.zoomRectItem.setSize((0,0)) - - self.ax1.setRange( - xRange=(xmin, xmax), - yRange=(ymin, ymax), - padding=0 - ) - - def zoomRectCancelled(self): - self.isMouseDragImg1 = False - self.zoomRectItem.setPos((0,0)) - self.zoomRectItem.setSize((0,0)) - - def findID(self, checked=False, ID=None): - posData = self.data[self.pos_i] - if ID is None: - searchIDdialog = apps.FindIDDialog( - title='Search object by ID', - msg='Enter object ID to find and highlight', - parent=self, - isInteger=True - ) - searchIDdialog.exec_() - if searchIDdialog.cancel: - return - - searchedID = searchIDdialog.EntryID - else: - searchedID = ID - - if searchedID in posData.IDs: - self.goToObjectID(searchedID) - return - - if posData.SizeT == 1: - self.warnIDnotFound(searchedID) - return - - if searchedID in posData.lost_IDs: - self.goToLostObjectID(searchedID) - return - - tracked_lost_IDs = self.getTrackedLostIDs() - if searchedID in tracked_lost_IDs: - self.goToAcceptedLostObjectID(searchedID) - return - - self.logger.info(f'Searching ID {searchedID} in other frames...') - - frame_i_found = self.startSearchIDworker(searchedID) - if frame_i_found is None: - self.warnIDnotFound(searchedID) - return - - self.logger.info( - f'Object ID {searchedID} found at frame n. {frame_i_found+1}.' - ) - proceed = self.askGoToFrameFoundID(searchedID, frame_i_found) - if not proceed: - return - - posData.frame_i = frame_i_found - self.get_data() - self.updateAllImages() - self.updateScrollbars() - - self.goToObjectID(searchedID) - - @disableWindow - def startSearchIDworker(self, searchedID): - posData = self.data[self.pos_i] - - desc = 'Searching ID in all frames...' - - self.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self.mainWin, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(posData.SizeT) - self.progressWin.show(self.app) - - self.searchIDthread = QThread() - self.searchIDworker = workers.SimpleWorker( - posData, self.searchIDworkerCallback, - func_args=(searchedID, ) - ) - self.searchIDworker.frame_i_found = None - self.searchIDworker.moveToThread(self.searchIDthread) - - self.searchIDworker.signals.finished.connect( - self.searchIDthread.quit - ) - self.searchIDworker.signals.finished.connect( - self.searchIDworker.deleteLater - ) - self.searchIDthread.finished.connect(self.searchIDthread.deleteLater) - - self.searchIDworker.signals.critical.connect( - self.searchIDworkerCritical - ) - self.searchIDworker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.searchIDworker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.searchIDworker.signals.progress.connect( - self.workerProgress - ) - self.searchIDworker.signals.finished.connect( - self.searchIDworkerFinished - ) - - self.searchIDthread.started.connect(self.searchIDworker.run) - self.searchIDthread.start() - - self.searchIDworkerLoop = QEventLoop() - self.searchIDworkerLoop.exec_() - - return self.searchIDworker.frame_i_found - - def searchIDworkerCritical(self, error): - self.searchIDworkerLoop.exit() - self.workerCritical(error) - - def searchIDworkerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.searchIDworkerLoop.exit() - - def searchIDworkerCallback(self, posData, searchedID): - self.searchIDworker.signals.initProgressBar.emit(0) - self.setAllIDs() - self.searchIDworker.signals.initProgressBar.emit(posData.SizeT) - frame_i_found = None - for frame_i in range(len(posData.segm_data)): - if frame_i >= len(posData.allData_li): - break - lab = posData.allData_li[frame_i]['labels'] - if lab is None: - rp = skimage.measure.regionprops(posData.segm_data[frame_i]) - IDs = set([obj.label for obj in rp]) - else: - IDs = posData.allData_li[frame_i]['IDs'] - - if searchedID in IDs: - frame_i_found = frame_i - break - - self.searchIDworker.signals.progressBar.emit(1) - - self.searchIDworker.frame_i_found = frame_i_found - - def warnIDnotFound(self, searchedID): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(f""" - Object ID {searchedID} was not found.

- """) - msg.warning(self, f'ID {searchedID} not found', txt) - - def goToObjectID(self, ID): - posData = self.data[self.pos_i] - objIdx = posData.IDs_idxs[ID] - obj = posData.rp[objIdx] - self.goToZsliceSearchedID(obj) - - self.highlightSearchedID(ID) - propsQGBox = self.guiTabControl.propsQGBox - propsQGBox.idSB.setValue(ID) - - def goToLostObjectID(self, lostID, color=(255, 165, 0, 255)): - posData = self.data[self.pos_i] - frame_i = posData.frame_i - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] - obj = prev_rp[prev_IDs_idxs[lostID]] - self.goToZsliceSearchedID(obj) - - imageItem = self.getLostObjImageItem(0) - thickness = 1 - if not hasattr(self, 'lostObjContoursImage'): - self.initLostObjContoursImage() - else: - self.lostObjContoursImage[:] = 0 - - contours = [] - obj_contours = self.getObjContours(obj, all_external=True) - contours.extend(obj_contours) - - self.addLostObjsToLostObjImage(obj, lostID) - self.drawLostObjContoursImage( - imageItem, contours, thickness=2, color=color - ) - - def goToAcceptedLostObjectID(self, acceptedLostID): - posData = self.data[self.pos_i] - frame_i = posData.frame_i - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] - obj = prev_rp[prev_IDs_idxs[acceptedLostID]] - self.goToZsliceSearchedID(obj) - - self.updateLostTrackedContoursImage(tracked_lost_IDs=[acceptedLostID]) - - def askGoToFrameFoundID(self, searchedID, frame_i_found): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(f""" - Object ID {searchedID} was found at frame n. {frame_i_found+1}.

- Do you want to go to frame n. {frame_i_found+1}. - """) - noButton, yesButton = msg.information( - self, f'ID {searchedID} found at frame n. {frame_i_found+1}', txt, - buttonsTexts=( - 'No, stay on current frame', - f'Yes, go to frame n. {frame_i_found+1}' - ) - ) - return msg.clickedButton == yesButton - - def skipForwardToNewID(self): - self.progressWin = apps.QDialogWorkerProgress( - title='Searching the next frame with a new object', parent=self, - pbarDesc=f'Searching the next frame with a new object...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.startFindNextNewIdWorker() - - def startFindNextNewIdWorker(self): - posData = self.data[self.pos_i] - self._thread = QThread() - self.findNextNewIdWorker = workers.FindNextNewIdWorker(posData, self) - self.findNextNewIdWorker.moveToThread(self._thread) - - self.findNextNewIdWorker.signals.finished.connect(self._thread.quit) - self.findNextNewIdWorker.signals.finished.connect( - self.findNextNewIdWorker.deleteLater - ) - self._thread.finished.connect(self._thread.deleteLater) - - self.findNextNewIdWorker.signals.finished.connect( - self.findNextNewIdWorkerFinished - ) - self.findNextNewIdWorker.signals.progress.connect(self.workerProgress) - self.findNextNewIdWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.findNextNewIdWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.findNextNewIdWorker.signals.critical.connect( - self.workerCritical - ) - - self._thread.started.connect(self.findNextNewIdWorker.run) - self._thread.start() - - def findNextNewIdWorkerFinished(self, next_frame_i): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.navSpinBox.setValue(next_frame_i+1) - self.framesScrollBarReleased() - - def workerProgress(self, text, loggerLevel='INFO'): # used in cca and lin tree - if self.progressWin is not None: - self.progressWin.logConsole.append(text) - self.logger.log(getattr(logging, loggerLevel), text) - - def workerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.logger.info('Worker process ended.') - self.updateAllImages() - self.titleLabel.setText('Done', color='w') - - def savePreprocWorkerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.setStatusBarLabel() - self.logger.info('Pre-processed data saved!') - self.titleLabel.setText('Pre-processed data saved!', color='w') - - def delObjsOutSegmMaskWorkerFinished(self, result): - posData = self.data[self.pos_i] - worker, cleared_segm_data, delIDs = result - if posData.SizeT == 1: - cleared_segm_data = cleared_segm_data[np.newaxis] - - self.update_cca_df_deletedIDs(posData, delIDs) - - current_frame_i = posData.frame_i - for frame_i, cleared_lab in enumerate(cleared_segm_data): - # Store change - posData.allData_li[frame_i]['labels'] = cleared_lab - # Get the rest of the stored metadata based on the new lab - posData.frame_i = frame_i - self.get_data() - self.store_data(autosave=False) - - # Back to current frame - posData.frame_i = current_frame_i - self.get_data() - - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.logger.info('Deleting objects outside of ROIs finished.') - self.titleLabel.setText( - 'Deleting objects outside of ROIs finished.', color='w' - ) - self.updateAllImages() - - def loadingNewChunk(self, chunk_range): - coord0_chunk, coord1_chunk = chunk_range - desc = ( - f'Loading new window, range = ({coord0_chunk}, {coord1_chunk})...' - ) - self.progressWin = apps.QDialogWorkerProgress( - title='Loading data...', parent=self, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.show(self.app) - - def lazyLoaderFinished(self): - self.logger.info('Load chunk data worker done.') - if self.lazyLoader.updateImgOnFinished: - self.updateAllImages() - - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - @exception_handler - def trackingWorkerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.logger.info('Worker process ended.') - askDisableRealTimeTracking = ( - self.trackingWorker.trackingOnNeverVisitedFrames - and self.realTimeTrackingToggle.isChecked() - ) - if askDisableRealTimeTracking: - msg = widgets.myMessageBox() - title = 'Disable real-time tracking?' - txt = ( - 'You perfomed tracking on frames that you have ' - 'never visited.

' - 'Cell-ACDC default behaviour is to track them again when you ' - 'will visit them.

' - 'However, you can overwrite this behaviour and explicitly ' - 'disable tracking for all of the frames you already tracked.

' - 'NOTE: you can reactivate real-time tracking by clicking on the ' - '"Reset last segmented frame" button on the top toolbar.

' - 'What do you want me to do?' - ) - _, disableTrackingButton = msg.information( - self, title, html_utils.paragraph(txt), - buttonsTexts=( - 'Keep real-time tracking active (recommended)', - 'Disable real-time tracking' - ) - ) - if msg.clickedButton == disableTrackingButton: - self.logger.info('Disabling real time tracking...') - self.realTimeTrackingToggle.setChecked(False) - # posData = self.data[self.pos_i] - # current_frame_i = posData.frame_i - # for frame_i in range(self.start_n-1, self.stop_n): - # posData.frame_i = frame_i - # self.get_data() - # self.store_data(autosave=frame_i==self.stop_n-1) - # posData.last_tracked_i = frame_i - # self.setNavigateScrollBarMaximum() - - # # Back to current frame - # posData.frame_i = current_frame_i - # self.get_data() - posData = self.data[self.pos_i] - self.updateAllImages() - self.titleLabel.setText('Done', color='w') - - def workerInitProgressbar(self, totalIter): - self.progressWin.mainPbar.setValue(0) - if totalIter == 1: - totalIter = 0 - self.progressWin.mainPbar.setMaximum(totalIter) - - def workerUpdateProgressbar(self, step): - self.progressWin.mainPbar.update(step) - - def workerInitInnerPbar(self, totalIter): - self.progressWin.innerPbar.setValue(0) - if totalIter == 1: - totalIter = 0 - self.progressWin.innerPbar.setMaximum(totalIter) - - def workerUpdateInnerPbar(self, step): - self.progressWin.innerPbar.update(step) - - def startTrackingWorker(self, posData, video_to_track): - self.thread = QThread() - self.trackingWorker = workers.trackingWorker( - posData, self, video_to_track - ) - self.trackingWorker.moveToThread(self.thread) - self.trackingWorker.finished.connect(self.thread.quit) - self.trackingWorker.finished.connect(self.trackingWorker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - # Custom signals - self.trackingWorker.signals.progress = self.trackingWorker.progress - self.trackingWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.trackingWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.trackingWorker.signals.sigInitInnerPbar.connect( - self.workerInitInnerPbar - ) - self.trackingWorker.progress.connect(self.workerProgress) - self.trackingWorker.critical.connect(self.workerCritical) - self.trackingWorker.finished.connect(self.trackingWorkerFinished) - - self.trackingWorker.debug.connect(self.workerDebug) - - self.thread.started.connect(self.trackingWorker.run) - self.thread.start() - - def startRelabellingWorker(self, posFoldernames): - self.thread = QThread() - self.worker = workers.relabelSequentialWorker(self, posFoldernames) - self.worker.moveToThread(self.thread) - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - self.worker.progress.connect(self.workerProgress) - self.worker.critical.connect(self.workerCritical) - self.worker.finished.connect(self.workerFinished) - self.worker.finished.connect(self.relabelWorkerFinished) - - self.worker.debug.connect(self.workerDebug) - - self.thread.started.connect(self.worker.run) - self.thread.start() - - def startPostProcessSegmWorker( - self, postProcessKwargs, customPostProcessGroupedFeatures, - customPostProcessFeatures - ): - self.thread = QThread() - self.postProcessWorker = workers.PostProcessSegmWorker( - postProcessKwargs, customPostProcessGroupedFeatures, - customPostProcessFeatures, self - ) - - self.postProcessWorker.moveToThread(self.thread) - self.postProcessWorker.signals.finished.connect(self.thread.quit) - self.postProcessWorker.signals.finished.connect( - self.postProcessWorker.deleteLater - ) - self.thread.finished.connect(self.thread.deleteLater) - - self.postProcessWorker.signals.finished.connect( - self.postProcessSegmWorkerFinished - ) - self.postProcessWorker.signals.progress.connect(self.workerProgress) - self.postProcessWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.postProcessWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.postProcessWorker.signals.critical.connect( - self.workerCritical - ) - - self.thread.started.connect(self.postProcessWorker.run) - self.thread.start() - - def relabelWorkerFinished(self): - self.updateAllImages() - - def workerDebug(self, item): - tracked_video, worker = item - from cellacdc.plot import imshow - imshow(tracked_video) - worker.waitCond.wakeAll() - - def keepToolActiveActionToggled(self, checked, toolName=None): - if toolName is None: - parentToolButton = self.sender().parent() - toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] - - if checked: - self.df_settings.at[toolName, 'value'] = 'keepActive' - else: - self.df_settings = self.df_settings.drop( - index=toolName, errors='ignore' - ) - self.df_settings.to_csv(self.settings_csv_path) - - def applyToolNewFrameActionToggled(self, checked, toolName=None): - if toolName is None: - parentToolButton = self.sender().parent() - toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] - toolName = toolName.strip() - button = self.applyToolNewFrameButtons[toolName] - toolName = toolName.replace(' ', '_') - settingName = f'{toolName}_applyNewFrame' - if checked: - self.df_settings.at[settingName, 'value'] = 'applyNewFrame' - button.setStyleSheet(f'background-color: {GREEN_HEX}') - else: - self.df_settings = self.df_settings.drop( - index=settingName, errors='ignore' - ) - button.setStyleSheet('background-color: none') - self.df_settings.to_csv(self.settings_csv_path) - - def keepAllToolsActiveActionToggled(self, checked): - for action in self.keepToolActiveActions.values(): - action.setChecked(checked) - - data_loaded = True - if not hasattr(self, 'data'): - data_loaded = False - try: - self.labelRoiTrangeCheckbox.disconnect() - except TypeError: - pass - self.labelRoiTrangeCheckbox.setChecked(checked) # why this is not wrapped in a QAction? - - if data_loaded: - self.labelRoiTrangeCheckbox.toggled.connect( - self.labelRoiTrangeCheckboxToggled - ) - - def determineSlideshowWinPos(self): - screens = self.app.screens() - self.numScreens = len(screens) - winScreen = self.screen() - - # Center main window and determine location of slideshow window - # depending on number of screens available - if self.numScreens > 1: - for screen in screens: - if screen != winScreen: - winScreen = screen - break - - winScreenGeom = winScreen.geometry() - winScreenCenter = winScreenGeom.center() - winScreenCenterX = winScreenCenter.x() - winScreenCenterY = winScreenCenter.y() - winScreenLeft = winScreenGeom.left() - winScreenTop = winScreenGeom.top() - self.slideshowWinLeft = winScreenCenterX - int(850/2) - self.slideshowWinTop = winScreenCenterY - int(800/2) - - def nonViewerEditMenuOpened(self): - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - self.startBlinkingModeCB() - - def getDistantGray(self, desiredGray, bkgrGray): - isDesiredSimilarToBkgr = ( - abs(desiredGray-bkgrGray) < 0.3 - ) - if isDesiredSimilarToBkgr: - return 1-desiredGray - else: - return desiredGray - - def RGBtoGray(self, R, G, B): - # see https://stackoverflow.com/questions/17615963/standard-rgb-to-grayscale-conversion - C_linear = (0.2126*R + 0.7152*G + 0.0722*B)/255 - if C_linear <= 0.0031309: - gray = 12.92*C_linear - else: - gray = 1.055*(C_linear)**(1/2.4) - 0.055 - return gray - - def ruler_cb(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - self.connectLeftClickButtons() - else: - self.tempSegmentON = False - self.ax1_rulerPlotItem.setData([], []) - self.ax1_rulerAnchorsItem.setData([], []) - - def editImgProperties(self, checked=True): - posData = self.data[self.pos_i] - posData.askInputMetadata( - len(self.data), - ask_SizeT=True, - ask_TimeIncrement=True, - ask_PhysicalSizes=True, - save=True, singlePos=True, - askSegm3D=False - ) - if hasattr(self, 'timestamp'): - self.timestamp.setSecondsPerFrame(posData.TimeIncrement) - self.updateTimestampFrame() - - if hasattr(self, 'scaleBar'): - self.scaleBar.updatePhysicalLength(posData.PhysicalSizeX) - - def setHoverToolSymbolData(self, xx, yy, ScatterItems, size=None): - if not xx: - self.ax1_lostObjScatterItem.setVisible(True) - self.ax2_lostObjScatterItem.setVisible(True) - - self.ax1_lostTrackedScatterItem.setVisible(True) - self.ax2_lostTrackedScatterItem.setVisible(True) - - for item in ScatterItems: - if size is None: - item.setData(xx, yy) - else: - item.setData(xx, yy, size=size) - - def updateLabelRoiCircularSize(self, value): - self.labelRoiCircItemLeft.setSize(value) - self.labelRoiCircItemRight.setSize(value) - - def updateLabelRoiCircularCursor(self, x, y, checked): - if not self.labelRoiButton.isChecked(): - return - if not self.labelRoiIsCircularRadioButton.isChecked(): - return - if self.labelRoiRunning: - return - - size = self.labelRoiCircularRadiusSpinbox.value() - if not checked: - xx, yy = [], [] - else: - xx, yy = [x], [y] - - if not xx and len(self.labelRoiCircItemLeft.getData()[0]) == 0: - return - - self.labelRoiCircItemLeft.setData(xx, yy, size=size) - self.labelRoiCircItemRight.setData(xx, yy, size=size) - - def getLabelRoiImage(self): - posData = self.data[self.pos_i] - - if self.labelRoiTrangeCheckbox.isChecked(): - start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 - stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() - tRangeLen = stop_frame_n-start_frame_i - else: - tRangeLen = 1 - - if tRangeLen > 1: - tRange = (start_frame_i, stop_frame_n) - else: - tRange = None - - if self.isSegm3D: - if tRangeLen > 1: - imgData = posData.img_data - else: - # Filtered data not existing - imgData = posData.img_data[posData.frame_i] - - roi_zdepth = self.labelRoiZdepthSpinbox.value() - if roi_zdepth == posData.SizeZ: - z0 = 0 - z1 = posData.SizeZ - elif roi_zdepth == 1: - z0 = self.zSliceScrollBar.sliderPosition() - z1 = z0 + 1 - else: - if roi_zdepth%2 != 0: - roi_zdepth +=1 - half_zdepth = int(roi_zdepth/2) - zc = self.zSliceScrollBar.sliderPosition() + 1 - z0 = zc-half_zdepth - z0 = z0 if z0>=0 else 0 - z1 = zc+half_zdepth - z1 = z1 if z1 1: - imgData = posData.img_data - else: - imgData = self.img1.image - - roiImg = imgData[labelRoiSlice] - if self.labelRoiIsFreeHandRadioButton.isChecked(): - mask = self.freeRoiItem.mask() - elif self.labelRoiIsCircularRadioButton.isChecked(): - mask = self.labelRoiCircItemLeft.mask() - else: - mask = None - - if mask is not None: - # Copy roiImg otherwise we are replacing minimum inside original image - roiImg = roiImg.copy() - # Fill outside of freehand roi with minimum of the ROI image - if tRangeLen > 1: - for i in range(tRangeLen): - ith_roiImg = roiImg[i] - if self.isSegm3D: - roiImg[i, :, ~mask] = ith_roiImg.min() - else: - roiImg[i, ~mask] = ith_roiImg.min() - else: - if self.isSegm3D: - roiImg[:, ~mask] = roiImg.min() - else: - roiImg[~mask] = roiImg.min() - - return roiImg, labelRoiSlice - - def getClickedID(self, xdata, ydata, text=''): - posData = self.data[self.pos_i] - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - if ID == 0: - msg = ( - 'You clicked on the background.\n' - f'Enter here the ID {text}' - ) - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), xdata, ydata - ) - clickedBkgrID = apps.QLineEditDialog( - title='Clicked on background', - msg=msg, parent=self, allowedValues=posData.IDs, - defaultTxt=str(nearest_ID), - isInteger=True - ) - clickedBkgrID.exec_() - if clickedBkgrID.cancel: - return - else: - ID = clickedBkgrID.EntryID - return ID - - # @exec_time - def applyEditID( - self, clickedID, currentIDs, oldIDnewIDMapper, clicked_x, clicked_y, shift=False, doPropagateUnvisited=False - ): - posData = self.data[self.pos_i] - - # Ask to propagate change to all future visited frames - key = 'Edit ID' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - clickedID, key, doNotShow, - posData.UndoFutFrames_EditID, posData.applyFutFrames_EditID, - applyTrackingB=True - ) - - if UndoFutFrames is None: - return - - if shift and self.isSegm3D: - lab = self.get_2Dlab(posData.lab) - else: - lab = posData.lab - - # Store undo state before modifying stuff - self.storeUndoRedoStates(UndoFutFrames) - maxID = max(posData.IDs, default=0) - for old_ID, new_ID in oldIDnewIDMapper: - if new_ID in currentIDs and not self.editIDmergeIDs: - tempID = maxID + 1 - lab[lab == old_ID] = maxID + 1 - lab[lab == new_ID] = old_ID - lab[lab == tempID] = new_ID - maxID += 1 - - old_ID_idx = currentIDs.index(old_ID) - new_ID_idx = currentIDs.index(new_ID) - - # Append information for replicating the edit in tracking - # List of tuples (y, x, replacing ID) - objo = posData.rp[old_ID_idx] - yo, xo = self.getObjCentroid(objo.centroid) - objn = posData.rp[new_ID_idx] - yn, xn = self.getObjCentroid(objn.centroid) - if not math.isnan(yo) and not math.isnan(yn): - yn, xn = int(yn), int(xn) - posData.editID_info.append((yn, xn, new_ID)) - yo, xo = int(clicked_y), int(clicked_x) - posData.editID_info.append((yo, xo, old_ID)) - else: - lab[lab == old_ID] = new_ID - if new_ID > maxID: - maxID = new_ID - old_ID_idx = posData.IDs.index(old_ID) - - # Append information for replicating the edit in tracking - # List of tuples (y, x, replacing ID) - obj = posData.rp[old_ID_idx] - y, x = self.getObjCentroid(obj.centroid) - if not math.isnan(y) and not math.isnan(y): - y, x = int(y), int(x) - posData.editID_info.append((y, x, new_ID)) - - self.updateAssignedObjsAcdcTrackerSecondStep(new_ID) - - if shift and self.isSegm3D: - self.set_2Dlab(lab) - - # Update rps - self.update_rp() - - # Since we manually changed an ID we don't want to repeat tracking - self.setAllTextAnnotations() - self.highlightLostNew() - # self.checkIDsMultiContour() - - # Update colors for the edited IDs - self.updateLookuptable() - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Edit ID') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Edit ID', update_images=False) - - if not self.editIDbutton.findChild(QAction).isChecked(): - self.editIDbutton.setChecked(False) - - posData.disableAutoActivateViewerWindow = True - - # Perform desired action on future frames - posData.doNotShowAgain_EditID = doNotShowAgain - posData.UndoFutFrames_EditID = UndoFutFrames - posData.applyFutFrames_EditID = applyFutFrames - includeUnvisited = ( - posData.includeUnvisitedInfo['Edit ID'] - or doPropagateUnvisited - ) - - if not applyFutFrames and not doPropagateUnvisited: - return - - self.changeIDfutureFrames( - endFrame_i, oldIDnewIDMapper, includeUnvisited, - shift=shift - ) - - def getLastHoveredID(self): - if self.xHoverImg is None: - return 0 - - xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) - ID = self.currentLab2D[ydata, xdata] - return ID - - def getHoverID(self, xdata, ydata, byPassShiftCheck=False): - if not hasattr(self, 'diskMask'): - return 0 - - modifiers = QGuiApplication.keyboardModifiers() - ctrl = modifiers == Qt.ControlModifier - if byPassShiftCheck: - shift = False - else: - shift = modifiers == Qt.ShiftModifier - - if self.isPowerBrush() and not ctrl: - return 0 - - if not self.autoIDcheckbox.isChecked(): - return self.editIDspinbox.value() - - ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) - posData = self.data[self.pos_i] - lab_2D = self.get_2Dlab(posData.lab) - ID = lab_2D[ydata, xdata] - self.isHoverZneighID = False - if self.isSegm3D: - z = self.z_lab() - SizeZ = posData.lab.shape[0] - doNotLinkThroughZ = self.brushButton.isChecked() and shift - if doNotLinkThroughZ: - if self.brushHoverCenterModeAction.isChecked() or ID>0: - hoverID = ID - else: - masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] - hoverID = np.bincount(masked_lab).argmax() - else: - if z > 0: - ID_z_under = posData.lab[z-1, ydata, xdata] - if self.brushHoverCenterModeAction.isChecked() or ID_z_under>0: - hoverIDa = ID_z_under - else: - lab = posData.lab - masked_lab_a = lab[z-1, ymin:ymax, xmin:xmax][diskMask] - hoverIDa = np.bincount(masked_lab_a).argmax() - else: - hoverIDa = 0 - - if self.brushHoverCenterModeAction.isChecked() or ID>0: - hoverIDb = lab_2D[ydata, xdata] - else: - masked_lab_b = lab_2D[ymin:ymax, xmin:xmax][diskMask] - hoverIDb = np.bincount(masked_lab_b).argmax() - - if z < SizeZ-1: - ID_z_above = posData.lab[z+1, ydata, xdata] - if self.brushHoverCenterModeAction.isChecked() or ID_z_above>0: - hoverIDc = ID_z_above - else: - lab = posData.lab - masked_lab_c = lab[z+1, ymin:ymax, xmin:xmax][diskMask] - hoverIDc = np.bincount(masked_lab_c).argmax() - else: - hoverIDc = 0 - - if hoverIDa > 0: - hoverID = hoverIDa - self.isHoverZneighID = True - elif hoverIDb > 0: - hoverID = hoverIDb - elif hoverIDc > 0: - hoverID = hoverIDc - self.isHoverZneighID = True - else: - hoverID = 0 - else: - if self.brushButton.isChecked() and shift: - # Force new ID with brush and Shift - hoverID = 0 - elif self.brushHoverCenterModeAction.isChecked() or ID>0: - hoverID = ID - else: - masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] - hoverID = np.bincount(masked_lab).argmax() - - self.editIDspinbox.setValue(hoverID) - - return hoverID - - def setHoverToolSymbolColor( - self, xdata, ydata, pen, ScatterItems, button, - brush=None, hoverRGB=None, ID=None, byPassShiftCheck=False - ): - modifiers = QGuiApplication.keyboardModifiers() - if byPassShiftCheck: - shift = False - else: - shift = modifiers == Qt.ShiftModifier - - posData = self.data[self.pos_i] - Y, X = self.get_2Dlab(posData.lab).shape - if not myutils.is_in_bounds(xdata, ydata, X, Y): - return - - self.isHoverZneighID = False - if ID is None: - hoverID = self.getHoverID( - xdata, ydata, byPassShiftCheck=byPassShiftCheck - ) - else: - hoverID = ID - - if hoverID == 0: - for item in ScatterItems: - item.setPen(pen) - item.setBrush(brush) - else: - try: - rgb = self.lut[hoverID] - rgb = rgb if hoverRGB is None else hoverRGB - rgbPen = np.clip(rgb*1.1, 0, 255) - for item in ScatterItems: - item.setPen(*rgbPen, width=2) - item.setBrush(*rgb, 100) - except IndexError: - pass - - checkChangeID = ( - self.isHoverZneighID and not shift - and self.lastHoverID != hoverID - ) - if checkChangeID: - # We are hovering an ID in z+1 or z-1 - self.restoreBrushID = hoverID - # self.changeBrushID() - - self.lastHoverID = hoverID - - def isPowerBrush(self): - color = self.brushButton.palette().button().color().name() - return color == self.doublePressKeyButtonColor - - def isPowerEraser(self): - color = self.eraserButton.palette().button().color().name() - return color == self.doublePressKeyButtonColor - - def isPowerButton(self, button): - color = button.palette().button().color().name() - return color == self.doublePressKeyButtonColor - - def getCheckNormAction(self): - normalize = False - how = '' - for action in self.normalizeQActionGroup.actions(): - if action.isChecked(): - how = action.text() - normalize = True - break - return action, normalize, how - - def normalizeIntensities(self, img): - action, normalize, how = self.getCheckNormAction() - if not normalize: - return img - - if how == 'Do not normalize. Display raw image': - img = img - elif how == 'Convert to floating point format with values [0, 1]': - img = myutils.img_to_float(img) - # elif how == 'Rescale to 8-bit unsigned integer format with values [0, 255]': - # img = skimage.img_as_float(img) - # img = (img*255).astype(np.uint8) - # return img - elif how == 'Rescale to [0, 1]': - img = skimage.img_as_float(img) - img = skimage.exposure.rescale_intensity(img) - elif how == 'Normalize by max value': - img = img/np.max(img) - return img - - def removeAlldelROIsCurrentFrame(self): - posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - rois = delROIs_info['rois'].copy() - for roi in rois: - self.ax2.removeDelRoiItem(roi) - - for item in self.ax2.items: - if isinstance(item, pg.ROI): - self.ax2.removeDelRoiItem(item) - - for item in self.ax1.items: - if isinstance(item, pg.ROI) and item != self.labelRoiItem: - self.ax1.removeDelRoiItem(item) - - def removeDelROI(self, event): - posData = self.data[self.pos_i] - - for ax in (self.ax1, self.ax2): - try: - self.ax1.removeDelRoiItem(self.roi_to_del) - except Exception as err: - pass - - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - idx = delROIs_info['rois'].index(self.roi_to_del) - delROIs_info['rois'].pop(idx) - delROIs_info['delMasks'].pop(idx) - delROIs_info['delIDsROI'].pop(idx) - delROIs_info['state'].pop(idx) - - self.removeDelROIFromFutureFrames(self.roi_to_del) - self.updateAllImages() - - def removeDelROIFromFutureFrames(self, roi_to_del): - posData = self.data[self.pos_i] - - # Restore deleted IDs from already visited future frames - current_frame_i = posData.frame_i - for i in range(posData.frame_i+1, posData.SizeT): - if posData.allData_li[i]['labels'] is None: - break - - delROIs_info = posData.allData_li[i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi_to_del) - except IndexError: - continue - - posData.frame_i = i - idx = delROIs_info['rois'].index(roi_to_del) - if delROIs_info['delIDsROI'][idx]: - posData.lab = posData.allData_li[i]['labels'] - self.restoreAnnotDelROI(roi_to_del, enforce=True, draw=False) - posData.allData_li[i]['labels'] = posData.lab - self.get_data() - self.store_data(autosave=False) - delROIs_info['rois'].pop(idx) - delROIs_info['delMasks'].pop(idx) - delROIs_info['delIDsROI'].pop(idx) - delROIs_info['state'].pop(idx) - - if isinstance(self.roi_to_del, pg.PolyLineROI): - # PolyLine ROIs are only on ax1 - self.ax1.removeItem(self.roi_to_del) - elif not self.labelsGrad.showLabelsImgAction.isChecked(): - # Rect ROI is on ax1 because ax2 is hidden - self.ax1.removeItem(self.roi_to_del) - else: - # Rect ROI is on ax2 because ax2 is visible - self.ax2.removeItem(self.roi_to_del) - - # Back to current frame - posData.frame_i = current_frame_i - posData.lab = posData.allData_li[posData.frame_i]['labels'] - self.get_data() - self.store_data() - - def updateDelROIinFutureFrames(self, roi: pg.ROI): - posData = self.data[self.pos_i] - restore_current_frame = False - - roiState = roi.getState() - # Restore deleted IDs from already visited future frames - current_frame_i = posData.frame_i - delROIs_info = posData.allData_li[current_frame_i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi) - delROIs_info['state'][idx] = roiState - except Exception as err: - pass - - self.store_data() - - for i in range(posData.frame_i+1, posData.SizeT): - delROIs_info = posData.allData_li[i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi) - except Exception as err: - continue - delROIs_info['state'][idx] = roiState - if posData.allData_li[i]['labels'] is None: - continue - - posData.frame_i = i - posData.lab = posData.allData_li[i]['labels'] - self.restoreAnnotDelROI(roi, enforce=False, draw=False) - posData.allData_li[i]['labels'] = posData.lab - self.get_data() - self.store_data(autosave=False) - restore_current_frame = True - - if not restore_current_frame: - return - - # Back to current frame - posData.frame_i = current_frame_i - posData.lab = posData.allData_li[posData.frame_i]['labels'] - self.get_data() - self.store_data() - - # @exec_time - def getPolygonBrush(self, yxc2, Y, X): - # see https://en.wikipedia.org/wiki/Tangent_lines_to_circles - y1, x1 = self.yPressAx2, self.xPressAx2 - y2, x2 = yxc2 - R = self.brushSizeSpinbox.value() - r = R - - arcsin_den = np.sqrt((x2-x1)**2+(y2-y1)**2) - arctan_den = (x2-x1) - if arcsin_den!=0 and arctan_den!=0: - beta = np.arcsin((R-r)/arcsin_den) - gamma = -np.arctan((y2-y1)/arctan_den) - alpha = gamma-beta - x3 = x1 + r*np.sin(alpha) - y3 = y1 + r*np.cos(alpha) - x4 = x2 + R*np.sin(alpha) - y4 = y2 + R*np.cos(alpha) - - alpha = gamma+beta - x5 = x1 - r*np.sin(alpha) - y5 = y1 - r*np.cos(alpha) - x6 = x2 - R*np.sin(alpha) - y6 = y2 - R*np.cos(alpha) - - rr_poly, cc_poly = skimage.draw.polygon([y3, y4, y6, y5], - [x3, x4, x6, x5], - shape=(Y, X)) - else: - rr_poly, cc_poly = [], [] - - self.yPressAx2, self.xPressAx2 = y2, x2 - return rr_poly, cc_poly - - def get_dir_coords(self, alfa_dir, yd, xd, shape, connectivity=1): - h, w = shape - y_above = yd+1 if yd+1 < h else yd - y_below = yd-1 if yd > 0 else yd - x_right = xd+1 if xd+1 < w else xd - x_left = xd-1 if xd > 0 else xd - if alfa_dir == 0: - yy = [y_below, y_below, yd, y_above, y_above] - xx = [xd, x_right, x_right, x_right, xd] - elif alfa_dir == 45: - yy = [y_below, y_below, y_below, yd, y_above] - xx = [x_left, xd, x_right, x_right, x_right] - elif alfa_dir == 90: - yy = [yd, y_below, y_below, y_below, yd] - xx = [x_left, x_left, xd, x_right, x_right] - elif alfa_dir == 135: - yy = [y_above, yd, y_below, y_below, y_below] - xx = [x_left, x_left, x_left, xd, x_right] - elif alfa_dir == -180 or alfa_dir == 180: - yy = [y_above, y_above, yd, y_below, y_below] - xx = [xd, x_left, x_left, x_left, xd] - elif alfa_dir == -135: - yy = [y_below, yd, y_above, y_above, y_above] - xx = [x_left, x_left, x_left, xd, x_right] - elif alfa_dir == -90: - yy = [yd, y_above, y_above, y_above, yd] - xx = [x_left, x_left, xd, x_right, x_right] - else: - yy = [y_above, y_above, y_above, yd, y_below] - xx = [x_left, xd, x_right, x_right, x_right] - if connectivity == 1: - return yy[1:4], xx[1:4] - else: - return yy, xx - - def drawAutoContour(self, y2, x2): - y1, x1 = self.autoCont_y0, self.autoCont_x0 - Dy = abs(y2-y1) - Dx = abs(x2-x1) - edge = self.getDisplayedImg1() - if Dy != 0 or Dx != 0: - # NOTE: numIter takes care of any lag in mouseMoveEvent - numIter = int(round(max((Dy, Dx)))) - alfa = np.arctan2(y1-y2, x2-x1) - base = np.pi/4 - alfa_dir = round((base * round(alfa/base))*180/np.pi) - for _ in range(numIter): - y1, x1 = self.autoCont_y0, self.autoCont_x0 - yy, xx = self.get_dir_coords(alfa_dir, y1, x1, edge.shape) - a_dir = edge[yy, xx] - min_int = np.max(a_dir) - min_i = list(a_dir).index(min_int) - y, x = yy[min_i], xx[min_i] - try: - xx, yy = self.curvHoverPlotItem.getData() - except TypeError: - xx, yy = [], [] - - if xx is None or yy is None or len(xx) == 0 or len(yy) == 0: - xx, yy = [], [] - elif x == xx[-1] and y == yy[-1]: - # Do not append point equal to last point - return - - xx = np.r_[xx, x] - yy = np.r_[yy, y] - try: - self.curvHoverPlotItem.setData(xx, yy) - self.curvPlotItem.setData(xx, yy) - except TypeError: - pass - self.autoCont_y0, self.autoCont_x0 = y, x - # self.smoothAutoContWithSpline() - - def smoothAutoContWithSpline(self, n=3): - try: - xx, yy = self.curvHoverPlotItem.getData() - if xx is None or yy is None: - return - # Downsample by taking every nth coord - xxA, yyA = xx[::n], yy[::n] - rr, cc = skimage.draw.polygon(yyA, xxA) - self.autoContObjMask[rr, cc] = 1 - rp = skimage.measure.regionprops(self.autoContObjMask) - if not rp: - return - obj = rp[0] - cont = self.getObjContours(obj) - xxC, yyC = cont[:,0], cont[:,1] - xxA, yyA = xxC[::n], yyC[::n] - self.xxA_autoCont, self.yyA_autoCont = xxA, yyA - xxS, yyS = self.getSpline(xxA, yyA, per=True, appendFirst=True) - if len(xxS)>0: - self.curvPlotItem.setData(xxS, yyS) - except (TypeError, ValueError): - pass - - def updateIsHistoryKnown(): - """ - This function is called every time the user saves and it is used - for updating the status of cells where we don't know the history - - There are three possibilities: - - 1. The cell with unknown history is a BUD - --> we don't know when that bud emerged --> 'emerg_frame_i' = -1 - 2. The cell with unknown history is a MOTHER cell - --> we don't know emerging frame --> 'emerg_frame_i' = -1 - AND generation number --> we start from 'generation_num' = 2 - 3. The cell with unknown history is a CELL in G1 - --> we don't know emerging frame --> 'emerg_frame_i' = -1 - AND generation number --> we start from 'generation_num' = 2 - AND relative's ID in the previous cell cycle --> 'relative_ID' = -1 - """ - pass - - def getStatusKnownHistoryBud(self, ID): - posData = self.data[self.pos_i] - cca_df_ID = None - for i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - is_cell_existing = is_bud_existing = ID in cca_df_i.index - if not is_cell_existing: - bud_cca_dict = base_cca_dict.copy() - bud_cca_dict['cell_cycle_stage'] = 'S' - bud_cca_dict['generation_num'] = 0 - bud_cca_dict['relationship'] = 'bud' - bud_cca_dict['emerg_frame_i'] = i+1 - bud_cca_dict['is_history_known'] = True - cca_df_ID = pd.Series(bud_cca_dict) - return cca_df_ID - - def setHistoryKnowledge(self, ID, cca_df): - posData = self.data[self.pos_i] - is_history_known = cca_df.at[ID, 'is_history_known'] - if is_history_known: - cca_df.at[ID, 'is_history_known'] = False - cca_df.at[ID, 'cell_cycle_stage'] = 'G1' - cca_df.at[ID, 'generation_num'] += 2 - cca_df.at[ID, 'emerg_frame_i'] = -1 - cca_df.at[ID, 'relative_ID'] = -1 - cca_df.at[ID, 'relationship'] = 'mother' - else: - cca_df.loc[ID] = posData.ccaStatus_whenEmerged[ID] - - def annotateIsHistoryKnown(self, ID): - """ - This function is used for annotating that a cell has unknown or known - history. Cells with unknown history are for example the cells already - present in the first frame or cells that appear in the frame from - outside of the field of view. - - With this function we simply set 'is_history_known' to False. - When the users saves instead we update the entire staus of the cell - with unknown history with the function "updateIsHistoryKnown()" - """ - posData = self.data[self.pos_i] - is_history_known = posData.cca_df.at[ID, 'is_history_known'] - relID = posData.cca_df.at[ID, 'relative_ID'] - if relID in posData.cca_df.index: - relID_cca = self.getStatus_RelID_BeforeEmergence(ID, relID) - - if is_history_known: - # Save status of ID when emerged to allow undoing - statusID_whenEmerged = self.getStatusKnownHistoryBud(ID) - if statusID_whenEmerged is None: - return - posData.ccaStatus_whenEmerged[ID] = statusID_whenEmerged - - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - - if ID not in posData.ccaStatus_whenEmerged: - self.warnSettingHistoryKnownCellsFirstFrame(ID) - return - - self.setHistoryKnowledge(ID, posData.cca_df) - - if relID in posData.cca_df.index: - # If the cell with unknown history has a relative ID assigned to it - # we set the cca of it to the status it had BEFORE the assignment - posData.cca_df.loc[relID] = relID_cca - - # Update cell cycle info LabelItems - obj_idx = posData.IDs.index(ID) - rp_ID = posData.rp[obj_idx] - - if relID in posData.IDs: - relObj_idx = posData.IDs.index(relID) - rp_relID = posData.rp[relObj_idx] - - self.setAllTextAnnotations() - self.drawAllMothBudLines() - - self.store_cca_df() - - if self.ccaTableWin is not None: - zoomIDs = self.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - # Correct future frames - for i in range(posData.frame_i+1, posData.SizeT): - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - self.storeUndoRedoCca(i, cca_df_i, undoId) - IDs = cca_df_i.index - if ID not in IDs: - # For some reason ID disappeared from this frame - continue - else: - self.setHistoryKnowledge(ID, cca_df_i) - if relID in IDs: - cca_df_i.loc[relID] = relID_cca - self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - - - # Correct past frames - for i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - self.storeUndoRedoCca(i, cca_df_i, undoId) - IDs = cca_df_i.index - if ID not in IDs: - # we reached frame where ID was not existing yet - break - else: - relID = cca_df_i.at[ID, 'relative_ID'] - self.setHistoryKnowledge(ID, cca_df_i) - if relID in IDs: - cca_df_i.loc[relID] = relID_cca - self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - - self.enqAutosave() - - def annotateWillDivide(self, ID, relID, frame_i=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - # Store in the past frames that division has been annotated - for past_frame_i in range(frame_i-1, -1, -1): - past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True) - if past_cca_df is None: - return - - if ID not in past_cca_df.index: - # ID is a bud and is not emerged yet here - return - - if frame_i-1 == past_frame_i: - # Get generation number at first iteration - gen_num = past_cca_df.at[ID, 'generation_num'] - - if past_cca_df.at[ID, 'generation_num'] != gen_num: - # ID is a mother and the cell cycle is finished here - return - - past_cca_df.at[ID, 'will_divide'] = 1 - past_cca_df.at[relID, 'will_divide'] = 1 - - self.store_cca_df( - cca_df=past_cca_df, frame_i=past_frame_i, autosave=False - ) - - def annotateDivisionFutureFramesSwapMothers( - self, cca_df_at_future_division, mothIDofDisappearedBud, frame_i - ): - """This method is called as part of `guiWin.swapMothers`. - - It annotates cell division and propagates that to future frames to the - mother cell that stops having the correct bud because division between - wrong bud and other wrong mother was annotated in the future. - - Parameters - ---------- - cca_df_at_future_division : pd.DataFrame - _description_ - mothIDofDisappearedBud : int - Mother ID of the disappeared bud - frame_i : int - Frame since when the mother ID stops having the correct bud because - the correct bud was assigned as divided from the wrong mother - """ - posData = self.data[self.pos_i] - - relativeIDofMothID = cca_df_at_future_division.at[ - mothIDofDisappearedBud, 'relative_ID' - ] - if relativeIDofMothID not in cca_df_at_future_division.index: - # Also wrong bud ID disappeared - return - - relativeIDofMothIDrelationship = cca_df_at_future_division.at[ - relativeIDofMothID, 'relationship' - ] - if relativeIDofMothIDrelationship != 'bud': - # The wrong bud ID is a cell in G1 from future cycle --> - # the actual wrong bud ID disappeared too. - return - - wrongBudID = relativeIDofMothID - - self.annotateDivision( - cca_df_at_future_division, mothIDofDisappearedBud, wrongBudID, - frame_i=frame_i - ) - cca_df_at_future_division.at[ - mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i - self.store_cca_df( - frame_i=frame_i, cca_df=cca_df_at_future_division, autosave=False - ) - - ccaStatusToRestore = cca_df_at_future_division.loc[mothIDofDisappearedBud] - for future_i in range(frame_i+1, posData.SizeT): - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - ccs = cca_df_i.at[mothIDofDisappearedBud, 'cell_cycle_stage'] - if ccs == 'G1': - # Mother cell in G1 again, stop correcting - break - - cca_df_i.loc[mothIDofDisappearedBud] = ccaStatusToRestore - cca_df_i.at[mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i - - self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) - - def annotateDivision(self, cca_df, ID, relID, frame_i=None): - # Correct as follows: - # For frame_i > 0 --> assign to G1 and +1 on generation number - # For frame == 0 --> reinitialize to unknown cells - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - self.annotateWillDivide(ID, relID) - - store = False - cca_df.at[ID, 'cell_cycle_stage'] = 'G1' - cca_df.at[relID, 'cell_cycle_stage'] = 'G1' - - if frame_i > 0: - gen_num_clickedID = cca_df.at[ID, 'generation_num'] - cca_df.at[ID, 'generation_num'] += 1 - cca_df.at[ID, 'division_frame_i'] = frame_i - gen_num_relID = cca_df.at[relID, 'generation_num'] - cca_df.at[relID, 'generation_num'] = gen_num_relID+1 - cca_df.at[relID, 'division_frame_i'] = frame_i - if gen_num_clickedID < gen_num_relID: - cca_df.at[ID, 'relationship'] = 'mother' - else: - cca_df.at[relID, 'relationship'] = 'mother' - else: - cca_df.at[ID, 'generation_num'] = 2 - cca_df.at[relID, 'generation_num'] = 2 - - cca_df.at[ID, 'division_frame_i'] = -1 - cca_df.at[relID, 'division_frame_i'] = -1 - - cca_df.at[ID, 'relationship'] = 'mother' - cca_df.at[relID, 'relationship'] = 'mother' - - store = True - return store - - def undoDivisionAnnotation(self, cca_df, ID, relID): - # Correct as follows: - # If G1 then correct to S and -1 on generation number - store = False - cca_df.at[ID, 'cell_cycle_stage'] = 'S' - gen_num_clickedID = cca_df.at[ID, 'generation_num'] - cca_df.at[ID, 'generation_num'] -= 1 - cca_df.at[ID, 'division_frame_i'] = -1 - cca_df.at[relID, 'cell_cycle_stage'] = 'S' - gen_num_relID = cca_df.at[relID, 'generation_num'] - cca_df.at[relID, 'generation_num'] -= 1 - cca_df.at[relID, 'division_frame_i'] = -1 - if gen_num_clickedID < gen_num_relID: - cca_df.at[ID, 'relationship'] = 'bud' - else: - cca_df.at[relID, 'relationship'] = 'bud' - cca_df.at[ID, 'will_divide'] = 0 - cca_df.at[relID, 'will_divide'] = 0 - store = True - return store - - def undoBudMothAssignment(self, ID): - posData = self.data[self.pos_i] - relID = posData.cca_df.at[ID, 'relative_ID'] - ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] - if ccs == 'G1': - return - posData.cca_df.at[ID, 'relative_ID'] = -1 - posData.cca_df.at[ID, 'generation_num'] = 2 - posData.cca_df.at[ID, 'cell_cycle_stage'] = 'G1' - posData.cca_df.at[ID, 'relationship'] = 'mother' - if relID in posData.cca_df.index: - posData.cca_df.at[relID, 'relative_ID'] = -1 - posData.cca_df.at[relID, 'generation_num'] = 2 - posData.cca_df.at[relID, 'cell_cycle_stage'] = 'G1' - posData.cca_df.at[relID, 'relationship'] = 'mother' - - obj_idx = posData.IDs.index(ID) - relObj_idx = posData.IDs.index(relID) - rp_ID = posData.rp[obj_idx] - rp_relID = posData.rp[relObj_idx] - - self.store_cca_df() - - # Update cell cycle info LabelItems - self.setAllTextAnnotations() - - if self.ccaTableWin is not None: - zoomIDs = self.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - @exception_handler - def manualCellCycleAnnotation(self, ID): - """ - This function is used for both annotating division or undoing the - annotation. It can be called on any frame. - - If we annotate division (right click on a cell in S) then it will - check if there are future frames to correct. - Frames to correct are those frames where both the mother and the bud - are annotated as S phase cells. - In this case we assign all those frames to G1, relationship to mother, - and +1 generation number - - If we undo the annotation (right click on a cell in G1) then it will - correct both past and future annotated frames (if present). - Frames to correct are those frames where both the mother and the bud - are annotated as G1 phase cells. - In this case we assign all those frames to G1, relationship back to - bud, and -1 generation number - """ - posData = self.data[self.pos_i] - - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - - # Correct current frame - clicked_ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] - relID = posData.cca_df.at[ID, 'relative_ID'] - - if relID not in posData.IDs: - return - - if clicked_ccs == 'G1' and posData.frame_i == 0: - # We do not allow undoing division annotation on first frame - return - - if clicked_ccs == 'G1': - issue_frame_i = self.checkDivisionCanBeUndone(ID, relID) - if issue_frame_i is not None: - _warnings.warnDivisionAnnotationCannotBeUndone( - ID, relID, issue_frame_i, qparent=self - ) - return - - if clicked_ccs == 'S': - self.annotateDivision(posData.cca_df, ID, relID) - self.store_cca_df() - else: - self.undoDivisionAnnotation(posData.cca_df, ID, relID) - self.store_cca_df() - - # Update cell cycle info LabelItems - self.ax1_newMothBudLinesItem.setData([], []) - self.ax1_oldMothBudLinesItem.setData([], []) - self.ax2_newMothBudLinesItem.setData([], []) - self.ax2_oldMothBudLinesItem.setData([], []) - self.drawAllMothBudLines() - self.setAllTextAnnotations() - - if self.ccaTableWin is not None: - zoomIDs = self.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - # Correct future frames - for future_i in range(posData.frame_i+1, posData.SizeT): - cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - self.storeUndoRedoCca(future_i, cca_df_i, undoId) - IDs = cca_df_i.index - if ID not in IDs: - # For some reason ID disappeared from this frame - continue - - ccs = cca_df_i.at[ID, 'cell_cycle_stage'] - relID = cca_df_i.at[ID, 'relative_ID'] - if clicked_ccs == 'S': - if ccs == 'G1': - # Cell is in G1 in the future again so stop annotating - break - self.annotateDivision(cca_df_i, ID, relID) - self.store_cca_df( - frame_i=future_i, cca_df=cca_df_i, autosave=False - ) - elif ccs == 'S': - # Cell is in S in the future again so stop undoing (break) - # also leave a 1 frame duration G1 to avoid a continuous - # S phase - self.annotateDivision(cca_df_i, ID, relID) - self.store_cca_df( - frame_i=future_i, cca_df=cca_df_i, autosave=False - ) - break - else: - self.undoDivisionAnnotation(cca_df_i, ID, relID) - self.store_cca_df( - frame_i=future_i, cca_df=cca_df_i, autosave=False - ) - - # Correct past frames - for past_i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - if ID not in cca_df_i.index or relID not in cca_df_i.index: - # Bud did not exist at frame_i = i - break - - self.storeUndoRedoCca(past_i, cca_df_i, undoId) - ccs = cca_df_i.at[ID, 'cell_cycle_stage'] - relID = cca_df_i.at[ID, 'relative_ID'] - if ccs == 'S': - # We correct only those frames in which the ID was in 'G1' - break - else: - store = self.undoDivisionAnnotation(cca_df_i, ID, relID) - self.store_cca_df( - frame_i=past_i, cca_df=cca_df_i, autosave=False - ) - - self.enqAutosave() - - def warnMotherNotEligible(self, new_mothID, budID, i, why): - if why == 'not_G1_in_the_future': - err_msg = html_utils.paragraph(f""" - The requested cell in G1 (ID={new_mothID}) - at future frame {i+1} has a bud assigned to it, - therefore it cannot be assigned as the mother - of bud ID {budID}.

- You can assign a cell as the mother of bud ID {budID} - only if this cell is in G1 for the - entire life of the bud.

- One possible solution is to click on "cancel", go to - frame {i+1} and assign the bud of cell {new_mothID} - to another cell.\n' - A second solution is to assign bud ID {budID} to cell - {new_mothID} anyway by clicking "Apply".

- However to ensure correctness of - future assignments Cell-ACDC will delete any cell cycle - information from frame {i+1} to the end. Therefore, you - will have to visit those frames again.

- The deletion of cell cycle information - CANNOT BE UNDONE! - Saved data is not changed of course.

- Apply assignment or cancel process? - """) - applyButton = widgets.okPushButton(isDefault=False) - applyButton.setText('Apply and remove future annotations') - msg = widgets.myMessageBox() - _, applyButton = msg.warning( - self, 'Cell not eligible', err_msg, - buttonsTexts=('Cancel', applyButton) - ) - cancel = msg.cancel - apply = msg.clickedButton == applyButton - elif why == 'not_G1_in_the_past': - err_msg = html_utils.paragraph(f""" - The requested cell in G1 - (ID={new_mothID}) at past frame {i+1} - has a bud assigned to it, therefore it cannot be - assigned as mother of bud ID {budID}.
- You can assign a cell as the mother of bud ID {budID} - only if this cell is in G1 for the entire life of the bud.
- One possible solution is to first go to frame {i+1} and - assign the bud of cell {new_mothID} to another cell. - """) - msg = widgets.myMessageBox() - msg.warning( - self, 'Cell not eligible', err_msg - ) - cancel = msg.cancel - apply = False - elif why == 'single_frame_G1_duration': - err_msg = html_utils.paragraph(f""" - Assigning bud ID {budID} to cell ID {new_mothID} would result - in no G1 phase at all between previous cell cycle and - current cell cycle (see frame n. {i+1}).

- - The solution is to annotate division on cell ID {new_mothID} - on any frame before the frame number {i+1}, and then - proceed to correcting the bud assignment.

- - This will gurantee a G1 duration for the cell {new_mothID} - of at least 1 frame.

- Thank you for your patience! - """) - msg = widgets.myMessageBox() - msg.warning( - self, 'Cell not eligible', err_msg - ) - cancel = msg.cancel - apply = False - return cancel, apply - - def warnSettingHistoryKnownCellsFirstFrame(self, ID): - txt = html_utils.paragraph(f""" - Cell ID {ID} is a cell that is present since the first - frame.

- These cells already have history UNKNOWN assigned and the - history status cannot be changed. - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning( - self, 'First frame cells', txt - ) - - def checkMothEligibility(self, budID, new_mothID): - """ - Check that the new mother is in G1 for the entire life of the bud - and that the G1 duration is > than 1 frame - """ - last_cca_frame_i = self.navigateScrollBar.maximum()-1 - posData = self.data[self.pos_i] - eligible = True - - # Check future frames - G1_duration_future = 0 - for future_i in range(posData.frame_i, posData.SizeT): - cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) - - if cca_df_i is None: - # ith frame was not visited yet - break - - if budID not in cca_df_i.index: - # Bud disappeared - break - - is_still_bud = cca_df_i.at[budID, 'relationship'] == 'bud' - if not is_still_bud: - break - - ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] - if ccs != 'G1': - cancel, apply = self.warnMotherNotEligible( - new_mothID, budID, future_i, 'not_G1_in_the_future' - ) - if apply: - self.resetCcaFuture(future_i) - break - isG1singleFrame = G1_duration_future == 1 - isFutureFrameNotLastAnnot = future_i != last_cca_frame_i - if cancel or (isG1singleFrame and isFutureFrameNotLastAnnot): - eligible = False - return eligible - - G1_duration_future += 1 - - # Check past frames - for past_i in range(posData.frame_i-1, -1, -1): - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - - is_bud_existing = budID in cca_df_i.index - is_moth_existing = new_mothID in cca_df_i.index - - if not is_moth_existing: - # Mother not existing because it appeared from outside FOV - break - - ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] - if ccs != 'G1' and is_bud_existing: - # Requested mother not in G1 in the past - # during the life of the bud (is_bud_existing = True) - self.warnMotherNotEligible( - new_mothID, budID, past_i, 'not_G1_in_the_past' - ) - eligible = False - return eligible - - if not is_bud_existing: - # Bud stop existing --> check that mother is still in G1 - if ccs != 'G1': - eligible = False - self.warnMotherNotEligible( - new_mothID, budID, past_i, 'single_frame_G1_duration' - ) - break - - return eligible - - def checkMothersExcludedOrDead(self): - try: - posData = self.data[self.pos_i] - buds_df = posData.cca_df[ - (posData.cca_df.relationship == 'bud') - & (posData.cca_df.emerg_frame_i == posData.frame_i) - ] - acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] - moth_df = acdc_df_i.loc[buds_df.relative_ID.to_list()] - excluded_df = moth_df[ - (moth_df.is_cell_dead > 0) | (moth_df.is_cell_excluded > 0) - ] - excludedMothIDs = excluded_df.index.to_list() - if not excludedMothIDs: - self.stopBlinkingPairItem() - return True - budIDsOfExcludedMoth = excluded_df.relative_ID.to_list() - proceed = self.warnDeadOrExcludedMothers( - budIDsOfExcludedMoth, excludedMothIDs - ) - return proceed - except Exception as e: - self.logger.info(traceback.format_exc()) - print('-'*100) - self.logger.warning( - 'Checking if mother cell is excluded or dead failed.' - ) - print('^'*100) - return False - - def checkDivisionCanBeUndone(self, ID, relID): - """Check that division annotation can be undone (see Notes section) - - Parameters - ---------- - ID : int - Cell ID of the clicked cell in G1 - relID : _type_ - Relative ID of the cell that was clicked - - Notes - ----- - Division annotation can be undone only if `relID` is also in G1 for the - entire duration of the correction - """ - posData = self.data[self.pos_i] - - ccs_relID = posData.cca_df.at[relID, 'cell_cycle_stage'] - if ccs_relID == 'S': - return posData.frame_i - - # Check future frames - for future_i in range(posData.frame_i+1, posData.SizeT): - cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - ccs_relID = cca_df_i.at[relID, 'cell_cycle_stage'] - if ccs_relID == 'S': - return future_i - - # Check past frames - for past_i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - if ID not in cca_df_i.index or relID not in cca_df_i.index: - # Bud did not exist at frame_i = i - break - - ccs = cca_df_i.at[ID, 'cell_cycle_stage'] - if ccs == 'S': - break - - ccs_relID = cca_df_i.at[relID, 'cell_cycle_stage'] - if ccs_relID == 'S': - return future_i - - - def stopBlinkingPairItem(self): - self.ax1_newMothBudLinesItem.setOpacity(1.0) - self.ax1_oldMothBudLinesItem.setOpacity(1.0) - - self.warnPairingItem.setData([], []) - try: - self.blinkPairingItemTimer.stop() - except Exception as e: - pass - - def warnDeadOrExcludedMothers(self, budIDs, mothIDs): - self.startBlinkingPairingItem(budIDs, mothIDs) - msg = widgets.myMessageBox(wrapText=False) - pairings = [ - f'Mother ID {mID} --> bud ID {bID}' - for mID, bID in zip(mothIDs, budIDs) - ] - txt = html_utils.paragraph(f""" - The mother cell in the following mother-bud pairings - (blinking line on the image) is
- excluded from the analysis or dead: - {html_utils.to_list(pairings)} - """) - msg.warning( - self, 'Mother cell is excluded or dead', txt, - buttonsTexts=('Cancel', 'Ok') - ) - return not msg.cancel - - def startBlinkingPairingItem(self, budIDs, mothIDs): - self.ax1_newMothBudLinesItem.setOpacity(0.2) - self.ax1_oldMothBudLinesItem.setOpacity(0.2) - - posData = self.data[self.pos_i] - acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] - - # Blink one pairing at the time (the first found) - xc_b = acdc_df_i.loc[budIDs[0], 'x_centroid'] - yc_b = acdc_df_i.loc[budIDs[0], 'y_centroid'] - - xc_m = acdc_df_i.loc[mothIDs[0], 'x_centroid'] - yc_m = acdc_df_i.loc[mothIDs[0], 'y_centroid'] - - self.warnPairingItem.setData([xc_b, xc_m], [yc_b, yc_m]) - - self.blinkPairingItemTimer = QTimer() - self.blinkPairingItemTimer.flag = True - self.blinkPairingItemTimer.timeout.connect(self.blinkPairingItem) - self.blinkPairingItemTimer.start(300) - - def blinkPairingItem(self): - if self.blinkPairingItemTimer.flag: - opacity = 0.3 - self.blinkPairingItemTimer.flag = False - else: - opacity = 1.0 - self.blinkPairingItemTimer.flag = True - self.warnPairingItem.setOpacity(opacity) - - def getStatus_RelID_BeforeEmergence(self, budID, curr_mothID): - posData = self.data[self.pos_i] - # Get status of the current mother before it had budID assigned to it - cca_status_before_bud_emerg = None - for i in range(posData.frame_i-1, -1, -1): - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - - is_bud_existing = budID in cca_df_i.index - if not is_bud_existing: - # Bud was not emerged yet - if curr_mothID in cca_df_i.index: - cca_status_before_bud_emerg = cca_df_i.loc[curr_mothID] - return cca_status_before_bud_emerg - else: - # The bud emerged together with the mother because - # they appeared together from outside of the fov - # and they were trated as new IDs bud in S0 - bud_cca_dict = base_cca_dict.copy() - bud_cca_dict['cell_cycle_stage'] = 'S' - bud_cca_dict['generation_num'] = 0 - bud_cca_dict['relationship'] = 'bud' - bud_cca_dict['emerg_frame_i'] = i+1 - bud_cca_dict['is_history_known'] = True - cca_status_before_bud_emerg = pd.Series(bud_cca_dict) - return cca_status_before_bud_emerg - - # Mother did not have a status before bud emergence because it was - # already paired with bud at first frame --> reinit to default - cca_status_before_bud_emerg = ( - core.getBaseCca_df([curr_mothID]).loc[curr_mothID] - ) - return cca_status_before_bud_emerg - - - def annotateBudToDifferentMother(self): - """ - This function is used for correcting automatic mother-bud assignment. - - It can be called at any frame of the bud life. - - There are three cells involved: bud, current mother, new mother. - - Eligibility: - - User clicked first on a bud (checked at click time) - - User released mouse button on a cell in G1 (checked at release time) - - The new mother MUST be in G1 for all the frames of the bud life - --> if not warn - - The new mother MUST have appeared in current frame OR be already - in G1 in previous frame, otherwise there would be no G1 cycle - - Result: - - The bud only changes relative ID to the new mother - - The new mother changes relative ID and stage to 'S' - - The old mother changes its entire status to the status it had - before being assigned to the clicked bud - """ - posData = self.data[self.pos_i] - lab2D = self.get_2Dlab(posData.lab) - budID = lab2D[self.yClickBud, self.xClickBud] - new_mothID = lab2D[self.yClickMoth, self.xClickMoth] - - if budID == new_mothID: - return - - if not self.isSnapshot: - eligible = self.checkMothEligibility(budID, new_mothID) - if not eligible: - return - - budEligible = self.checkChangeMotherBudEligible( - budID, posData.frame_i - ) - if not budEligible: - return - - # Allow partial initialization of cca_df with mouse - if posData.frame_i == 0: - newMothCcs = posData.cca_df.at[new_mothID, 'cell_cycle_stage'] - if not newMothCcs == 'G1': - err_msg = ( - 'You are assigning the bud to a cell that is not in G1!' - ) - msg = QMessageBox() - msg.critical( - self, 'New mother not in G1!', err_msg, msg.Ok - ) - return - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(0, posData.cca_df, undoId) - currentRelID = posData.cca_df.at[budID, 'relative_ID'] - if currentRelID in posData.cca_df.index: - posData.cca_df.at[currentRelID, 'relative_ID'] = -1 - posData.cca_df.at[currentRelID, 'generation_num'] = 2 - posData.cca_df.at[currentRelID, 'cell_cycle_stage'] = 'G1' - posData.cca_df.at[budID, 'relationship'] = 'bud' - posData.cca_df.at[budID, 'generation_num'] = 0 - posData.cca_df.at[budID, 'relative_ID'] = new_mothID - posData.cca_df.at[budID, 'cell_cycle_stage'] = 'S' - posData.cca_df.at[new_mothID, 'relative_ID'] = budID - posData.cca_df.at[new_mothID, 'generation_num'] = 2 - posData.cca_df.at[new_mothID, 'cell_cycle_stage'] = 'S' - self.updateAllImages() - self.store_cca_df() - return - - curr_mothID = posData.cca_df.at[budID, 'relative_ID'] - if curr_mothID in posData.cca_df.index: - curr_moth_cca = self.getStatus_RelID_BeforeEmergence( - budID, curr_mothID - ) - - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - - # Correct current frames and update LabelItems - posData.cca_df.at[budID, 'relative_ID'] = new_mothID - posData.cca_df.at[budID, 'generation_num'] = 0 - posData.cca_df.at[budID, 'relative_ID'] = new_mothID - posData.cca_df.at[budID, 'relationship'] = 'bud' - posData.cca_df.at[budID, 'corrected_on_frame_i'] = posData.frame_i - posData.cca_df.at[budID, 'cell_cycle_stage'] = 'S' - - posData.cca_df.at[new_mothID, 'relative_ID'] = budID - posData.cca_df.at[new_mothID, 'cell_cycle_stage'] = 'S' - posData.cca_df.at[new_mothID, 'relationship'] = 'mother' - - - if curr_mothID in posData.cca_df.index: - # Cells with UNKNOWN history has relative's ID = -1 - # which is not an existing cell - posData.cca_df.loc[curr_mothID] = curr_moth_cca - - self.updateAllImages() - - # self.checkMultiBudMoth(draw=True) - self.store_cca_df() - proceed = self.checkMothersExcludedOrDead() - if not proceed: - # User clicked on cancel in the message box - self.UndoCca() - return - - if self.ccaTableWin is not None: - zoomIDs = self.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - # Correct future frames - for i in range(posData.frame_i+1, posData.SizeT): - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - IDs = cca_df_i.index - if budID not in IDs or new_mothID not in IDs: - # For some reason ID disappeared from this frame - continue - - self.storeUndoRedoCca(i, cca_df_i, undoId) - bud_relationship = cca_df_i.at[budID, 'relationship'] - bud_ccs = cca_df_i.at[budID, 'cell_cycle_stage'] - - if bud_relationship == 'mother' and bud_ccs == 'S': - # The bud at the ith frame budded itself --> stop - break - - cca_df_i.at[budID, 'relative_ID'] = new_mothID - cca_df_i.at[budID, 'generation_num'] = 0 - cca_df_i.at[budID, 'relative_ID'] = new_mothID - cca_df_i.at[budID, 'relationship'] = 'bud' - cca_df_i.at[budID, 'cell_cycle_stage'] = 'S' - - newMoth_bud_ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] - if newMoth_bud_ccs == 'G1': - # Assign bud to new mother only if the new mother is in G1 - # This can happen if the bud already has a G1 annotated - cca_df_i.at[new_mothID, 'relative_ID'] = budID - cca_df_i.at[new_mothID, 'cell_cycle_stage'] = 'S' - cca_df_i.at[new_mothID, 'relationship'] = 'mother' - - if curr_mothID in cca_df_i.index: - # Cells with UNKNOWN history has relative's ID = -1 - # which is not an existing cell - cca_df_i.loc[curr_mothID] = curr_moth_cca - - self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - - # Correct past frames - for i in range(posData.frame_i-1, -1, -1): - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - - is_bud_existing = budID in cca_df_i.index - if not is_bud_existing: - # Bud was not emerged yet - break - - self.storeUndoRedoCca(i, cca_df_i, undoId) - cca_df_i.at[budID, 'relative_ID'] = new_mothID - cca_df_i.at[budID, 'generation_num'] = 0 - cca_df_i.at[budID, 'relative_ID'] = new_mothID - cca_df_i.at[budID, 'relationship'] = 'bud' - cca_df_i.at[budID, 'cell_cycle_stage'] = 'S' - - cca_df_i.at[new_mothID, 'relative_ID'] = budID - cca_df_i.at[new_mothID, 'cell_cycle_stage'] = 'S' - cca_df_i.at[new_mothID, 'relationship'] = 'mother' - - if curr_mothID in cca_df_i.index: - # Cells with UNKNOWN history has relative's ID = -1 - # which is not an existing cell - cca_df_i.loc[curr_mothID] = curr_moth_cca - - self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - - self.enqAutosave() - - def onMotherNotInG1(self, mothID): - txt = html_utils.paragraph( - f'You clicked on ID={mothID} which is NOT in G1

' - 'Do you want to proceed with swapping the mother cells?

' - 'NOTE: To assign a bud start by clicking on the bud ' - 'and release on a cell in G1' - ) - msg = widgets.myMessageBox() - swapMothersButton = widgets.reloadPushButton('Swap mother cells') - _, swapMothersButton = msg.warning( - self, 'Released on a cell NOT in G1', txt, - buttonsTexts=('Cancel', swapMothersButton) - ) - if msg.cancel: - return - - pairings = self.checkSwapMothersEligibility() - if pairings is None: - self.logger.info('Swapping mothers is not possible.') - return - - self.swapMothers(*pairings) - - def _checkBudFutureNoDivision(self, budID, start_frame_i): - posData = self.data[self.pos_i] - - future_i = start_frame_i - for future_i in range(start_frame_i, posData.SizeT): - if future_i == 0: - continue - - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - return - - if budID not in cca_df_i.index: - # Bud disappears in the future --> fine - return - - ccs = cca_df_i.at[budID, 'cell_cycle_stage'] - if ccs == 'G1': - return future_i, cca_df_i.at[budID, 'relative_ID'] - - def warnBudAnnotatedDividedInFuture( - self, budID, motherID, future_division_frame_i, - action='swap mother cells' - ): - posData = self.data[self.pos_i] - - txt = html_utils.paragraph(f""" - Bud ID {budID} is annotated as divided from mother ID {motherID} - at frame n. {future_division_frame_i+1},
- therefore it is not possible to {action}.

- We recommend reinitializing cell cycle annotations on any - frame
between frames number {posData.frame_i+1} and - {future_division_frame_i} before attempting to {action}.

- Thank you for your patience! - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, f'{action} not possible'.title(), txt) - return - - def _checkMothInG1beforeBudEmergence( - self, motherID, budID, wrongBudID, start_frame_i - ): - """Check that mother is in G1 on the frame before bud emergence - - Parameters - ---------- - motherID : int - ID of mother cell - budID : int - ID of bud - start_frame_i : int - Frame index from which to start checking in the past - """ - for past_i in range(start_frame_i, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - if budID not in cca_df_i.index: - if cca_df_i.at[motherID, 'cell_cycle_stage'] == 'G1': - return - - budID_prev_cycle = cca_df_i.at[motherID, 'relative_ID'] - if budID_prev_cycle != wrongBudID: - return past_i + 1 - - break - - def warnMotherNotAtLeastOneFrameG1(self, budID, motherID, frame_no_G1): - posData = self.data[self.pos_i] - - txt = html_utils.paragraph(f""" - Assigning bud ID {budID} to cell ID {motherID} cannot be - done because cell ID {motherID} is not in G1 at frame n. - {frame_no_G1}.

- This would result in no G1 phase between previous cell cycle of - cell ID {motherID} and current one. - This is unfortunately not allowed.

- One possible solution is to annotate division on cell ID - {motherID} on any frame before frame n. {frame_no_G1}.

- Thank you for your patience! - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Swap mothers not possible', txt) - return - - def checkChangeMotherBudEligible(self, budID, frame_i): - result = self._checkBudFutureNoDivision(budID, frame_i) - if result is None: - return True - - self.warnBudAnnotatedDividedInFuture( - budID, *result, action='change mother cell' - ) - return False - - def checkSwapMothersEligibility(self): - posData = self.data[self.pos_i] - - lab2D = self.get_2Dlab(posData.lab) - budID = lab2D[self.yClickBud, self.xClickBud] - otherMothID = lab2D[self.yClickMoth, self.xClickMoth] - mothID = posData.cca_df.at[budID, 'relative_ID'] - otherBudID = posData.cca_df.at[otherMothID, 'relative_ID'] - - for _budID in (budID, otherBudID): - result = self._checkBudFutureNoDivision( - _budID, posData.frame_i - ) - if result is None: - continue - - self.warnBudAnnotatedDividedInFuture(_budID, *result) - return - - correct_pairings = { - otherBudID: mothID, budID: otherMothID - } - wrong_pairings = { - mothID: budID, otherMothID: otherBudID - } - for correctBudID, correctMothID in correct_pairings.items(): - wrongBudID = wrong_pairings[correctMothID] - frame_no_G1 = self._checkMothInG1beforeBudEmergence( - correctMothID, correctBudID, wrongBudID, posData.frame_i - ) - if frame_no_G1 is None: - continue - - self.warnMotherNotAtLeastOneFrameG1( - correctBudID, correctMothID, frame_no_G1 - ) - return - - return budID, otherBudID, otherMothID, mothID - - @exception_handler - def swapMothers(self, budID, otherBudID, otherMothID, mothID): - posData = self.data[self.pos_i] - - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - - self.logger.info( - f'Swapping assignments (requested at frame n. {posData.frame_i+1}):\n' - f' * Bud ID {budID} --> mother ID {otherMothID}\n' - f' * Bud ID {otherBudID} --> mother ID {mothID}' - ) - - correct_pairings = { - otherBudID: mothID, - budID: otherMothID - } - - for correct_budID, correct_mothID in correct_pairings.items(): - posData.cca_df.at[correct_budID, 'relative_ID'] = correct_mothID - posData.cca_df.at[correct_mothID, 'relative_ID'] = correct_budID - posData.cca_df.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i - posData.cca_df.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i - self.store_cca_df() - - # Correct past frames - corrected_budIDs_past = set() - for past_i in range(posData.frame_i-1, -1, -1): - if len(corrected_budIDs_past) == 2: - break - - for correct_budID, correct_mothID in correct_pairings.items(): - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - - if correct_budID in corrected_budIDs_past: - continue - - if correct_budID not in cca_df_i.index: - # Bud does not exist anymore in the past - corrected_budIDs_past.add(correct_budID) - - if len(corrected_budIDs_past) < 2: - self.restoreMotherToBeforeWrongBudWasAssignedToIt( - correct_mothID, cca_df_i, past_i - ) - continue - - cca_df_i.at[correct_budID, 'relative_ID'] = correct_mothID - cca_df_i.at[correct_mothID, 'relative_ID'] = correct_budID - cca_df_i.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i - cca_df_i.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i - - # Set mother cell cycle stage to S in case it is not - if cca_df_i.at[correct_mothID, 'cell_cycle_stage'] == 'G1': - cca_df_i.at[correct_mothID, 'cell_cycle_stage'] = 'S' - # cca_df_i.at[correct_mothID, 'generation_num'] -= 1 - - self.store_cca_df( - frame_i=past_i, cca_df=cca_df_i, autosave=False - ) - - # Correct future frames - corrected_budIDs_future = set() - for future_i in range(posData.frame_i+1, posData.SizeT): - if len(corrected_budIDs_future) == 2: - break - - # Get cca_df for ith frame from allData_li - cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - for correct_budID, correct_mothID in correct_pairings.items(): - if correct_budID in corrected_budIDs_future: - # Bud already corrected in the future - continue - - if correct_budID not in cca_df_i.index: - # Bud disappeared in the future - corrected_budIDs_future.add(correct_budID) - continue - - ccs_bud = cca_df_i.at[correct_budID, 'cell_cycle_stage'] - if ccs_bud == 'G1': - # Bud divided in the future, annotate division between - # correct mother and wrong bud and then stop correcting - if correct_budID not in corrected_budIDs_future: - corrected_budIDs_future.add(correct_budID) - - if len(corrected_budIDs_future) < 2: - self.annotateDivisionFutureFramesSwapMothers( - cca_df_i, correct_mothID, future_i - ) - continue - - cca_df_i.at[correct_budID, 'relative_ID'] = correct_mothID - cca_df_i.at[correct_mothID, 'relative_ID'] = correct_budID - cca_df_i.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i - cca_df_i.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i - - # Set mother cell cycle stage to S in case it is not - if cca_df_i.at[correct_mothID, 'cell_cycle_stage'] == 'G1': - cca_df_i.at[correct_mothID, 'cell_cycle_stage'] = 'S' - # cca_df_i.at[correct_mothID, 'generation_num'] -= 1 - - self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) - - self.updateAllImages() - - def restoreMotherToBeforeWrongBudWasAssignedToIt( - self, mothIDofDisappearedBud, - cca_df_at_correct_bud_ID_disappearance, - frame_i - ): - """This method is called as part of `guiWin.swapMothers`. - - Parameters - ---------- - mothIDofDisappearedBud : int - Mother ID of the disappeared bud - cca_df_at_correct_bud_ID_disappearance : pd.DataFrame - Cell cycle annotations DataFrame when the correct bud ID stopped - existing (before emergence) - frame_i : int - Frame index when the correct bud ID stopped existing - (before emergence) - - Note - ---- - It restores the mother cell cycle annotations to the status it had - before the wrong bud was assigned to it. - - We need to do it only if the swapMothers past frames loop is still - iterating to correct the other bud. - - We also need to do this only if the wrong bud ID is actually a bud. - - When we swap mothers in the past frames it can be that the correct bud - ID stops existing (before emergence). In this case the correct mother - still has the wrong bud assigned to ID so we need to restore the status - it had before the wrong bud was assigned to it. - - To determine the status we go back until the wrong bud disappear. That - is the frame before the wrong bud was assigned to the mother we want to - correct. This is the status we want to restore. - - When we go back in time it could be that the wrong bud never disappears - becuase it is already emerged at frame 0. In this case the status we - want to restore at is the default G1 status at frame 0. - """ - relativeIDofMothID = cca_df_at_correct_bud_ID_disappearance.at[ - mothIDofDisappearedBud, 'relative_ID' - ] - if relativeIDofMothID not in cca_df_at_correct_bud_ID_disappearance.index: - # Also wrong bud ID disappeared - return - - relativeIDofMothIDrelationship = cca_df_at_correct_bud_ID_disappearance.at[ - relativeIDofMothID, 'relationship' - ] - if relativeIDofMothIDrelationship != 'bud': - # The wrong bud ID is a cell in G1 from previous cycle --> - # the actual wrong bud ID disappeared too. - return - - wrongBudID = relativeIDofMothID - - mothCcaBeforeWrongBudID = base_cca_dict - # Search in the past for status of mother before wrong bud emerged - for past_i in range(frame_i, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - if wrongBudID not in cca_df_i.index: - mothCcaBeforeWrongBudID = cca_df_i.loc[mothIDofDisappearedBud] - break - - # Restore in past frames the correct mother status - for past_i in range(frame_i, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - if wrongBudID in cca_df_i.index: - cca_df_i.loc[mothIDofDisappearedBud] = mothCcaBeforeWrongBudID - cca_df_i.at[mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i - self.store_cca_df( - frame_i=past_i, cca_df=cca_df_i, autosave=False - ) - else: - break - - def getClosedSplineCoords(self): - xxS, yyS = self.curvPlotItem.getData() - bbox_area = (xxS.max()-xxS.min())*(yyS.max()-yyS.min()) - if bbox_area < 26_000: - # Using 1000 is fast enough according to profiling - return xxS, yyS - - optimalSpaceSize = self.splineToObjModel.predict( - bbox_area, max_exec_time=150 - ) - if optimalSpaceSize >= 1000: - # Using 1000 is fast enough according to model - return xxS, yyS - - if optimalSpaceSize < 100: - # Do not allow a rough spline - optimalSpaceSize = 100 - - # Get spline with optimal space size so that exec time - # or skimage.draw.polygon is less than 150 ms - xx, yy = self.curvAnchors.getData() - resolutionSpace = np.linspace(0, 1, int(optimalSpaceSize)) - xxS, yyS = self.getSpline( - xx, yy, resolutionSpace=resolutionSpace, per=True - ) - return xxS, yyS - - - def getSpline(self, xx, yy, resolutionSpace=None, per=False, appendFirst=False): - # Remove duplicates - valid = np.where(np.abs(np.diff(xx)) + np.abs(np.diff(yy)) > 0) - xx = np.r_[xx[valid], xx[-1]] - yy = np.r_[yy[valid], yy[-1]] - if appendFirst: - xx = np.r_[xx, xx[0]] - yy = np.r_[yy, yy[0]] - per = True - - # Interpolate splice - if resolutionSpace is None: - resolutionSpace = self.hoverLinSpace - k = 2 if len(xx) == 3 else 3 - - try: - tck, u = scipy.interpolate.splprep( - [xx, yy], s=0, k=k, per=per - ) - xi, yi = scipy.interpolate.splev(resolutionSpace, tck) - return xi, yi - except (ValueError, TypeError): - # Catch errors where we know why splprep fails - return [], [] - - def uncheckQButton(self, button): - # Manual exclusive where we allow to uncheck all buttons - for b in self.checkableQButtonsGroup.buttons(): - if b != button: - b.setChecked(False) - - def delBorderObj(self, checked): - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - posData = self.data[self.pos_i] - posData.lab = skimage.segmentation.clear_border( - posData.lab, buffer_size=1 - ) - oldIDs = posData.IDs.copy() - self.update_rp() - removedIDs = [ID for ID in oldIDs if ID not in posData.IDs] - if posData.cca_df is not None: - posData.cca_df = posData.cca_df.drop(index=removedIDs) - self.store_data() - self.updateAllImages() - - def delNewObj(self, checked): - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - posData = self.data[self.pos_i] - frame_i = posData.frame_i - - if frame_i == 0: - return - - prev_IDs = posData.allData_li[frame_i-1]['IDs'] - curr_IDs = posData.IDs - new_IDs = list(set(curr_IDs) - set(prev_IDs)) - - lab = posData.lab - del_mask = np.isin(lab, new_IDs) - lab[del_mask] = 0 - posData.lab = lab - - self.update_rp() - - if posData.cca_df is not None: - posData.cca_df = posData.cca_df.drop(index=new_IDs) - self.store_data() - self.updateAllImages() - - def brushAutoFillToggled(self, checked): - val = 'Yes' if checked else 'No' - self.df_settings.at['brushAutoFill', 'value'] = val - self.df_settings.to_csv(self.settings_csv_path) - - def brushAutoHideToggled(self, checked): - val = 'Yes' if checked else 'No' - self.df_settings.at['brushAutoHide', 'value'] = val - self.df_settings.to_csv(self.settings_csv_path) - - def brushReleased(self): - posData = self.data[self.pos_i] - self.fillHolesID(posData.brushID, sender='brush') - - # Update data (rp, etc) - self.update_rp(update_IDs=self.isNewID,) - - # Repeat tracking - if self.autoIDcheckbox.isChecked(): - self.trackManuallyAddedObject(posData.brushID, self.isNewID) - else: - self.update_rp(update_IDs=posData.brushID not in posData.IDs_idxs) - - # Update images - if self.isNewID: - editTxt = 'Add new ID with brush tool' - if self.isSnapshot: - self.fixCcaDfAfterEdit(editTxt) - self.updateAllImages() - else: - self.warnEditingWithCca_df(editTxt) - else: - self.updateAllImages() - - self.isNewID = False - - def addDelROI(self, event): - roi, key = self.createDelROI() - self.addRoiToDelRoiInfo(roi) - if not self.labelsGrad.showLabelsImgAction.isChecked(): - self.ax1.addDelRoiItem(roi, key) - else: - self.ax2.addDelRoiItem(roi, key) - self.applyDelROIimg1(roi, init=True) - self.applyDelROIimg1(roi, init=True, ax=1) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete IDs using ROI') - self.updateAllImages() - else: - self.warnEditingWithCca_df( - 'Delete IDs using ROI', get_cancelled=True - ) - - def replacePolyLineRoiWithLineRoi(self, roi): - x0, y0 = roi.pos().x(), roi.pos().y() - (_, point1), (_, point2) = roi.getLocalHandlePositions() - xr1, yr1 = point1.x(), point1.y() - xr2, yr2 = point2.x(), point2.y() - x1, y1 = xr1+x0, yr1+y0 - x2, y2 = xr2+x0, yr2+x0 - lineRoi = pg.LineROI((x1, y1), (x2, y2), width=0.5) - lineRoi.handleSize = 7 - self.ax1.removeItem(self.polyLineRoi) - self.ax1.addItem(lineRoi) - lineRoi.removeHandle(2) - # Connect closed ROI - lineRoi.sigRegionChanged.connect(self.delROImoving) - lineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished) - return lineRoi - - def addRoiToDelRoiInfo(self, roi: pg.ROI): - posData = self.data[self.pos_i] - for i in range(posData.frame_i, posData.SizeT): - delROIs_info = posData.allData_li[i]['delROIs_info'] - delROIs_info['rois'].append(roi) - delROIs_info['state'].append(roi.getState()) - delROIs_info['delMasks'].append(np.zeros_like(self.currentLab2D)) - delROIs_info['delIDsROI'].append(set()) - - def addDelPolyLineRoi_cb(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.addDelPolyLineRoiButton) - self.connectLeftClickButtons() - if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete IDs using ROI') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Delete IDs using ROI') - else: - self.tempSegmentON = False - self.ax1_rulerPlotItem.setData([], []) - self.ax1_rulerAnchorsItem.setData([], []) - self.startPointPolyLineItem.setData([], []) - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - def createDelPolyLineRoi(self): - Y, X = self.currentLab2D.shape - self.polyLineRoi = pg.PolyLineROI( - [], rotatable=False, - removable=True, - pen=pg.mkPen(color='r') - ) - self.polyLineRoi.handleSize = 7 - self.polyLineRoi.points = [] - key = uuid.uuid4() - self.ax1.addDelRoiItem(self.polyLineRoi, key) - - def addPointsPolyLineRoi(self, closed=False): - self.polyLineRoi.setPoints(self.polyLineRoi.points, closed=closed) - if not closed: - return - - # Connect closed ROI - self.polyLineRoi.sigRegionChanged.connect(self.delROImoving) - self.polyLineRoi.sigRegionChangeFinished.connect(self.delROImovingFinished) - - def getViewRange(self): - Y, X = self.img1.image.shape[:2] - xRange, yRange = self.ax1.viewRange() - xmin = 0 if xRange[0] < 0 else xRange[0] - ymin = 0 if yRange[0] < 0 else yRange[0] - - xmax = X if xRange[1] >= X else xRange[1] - ymax = Y if yRange[1] >= Y else yRange[1] - return int(ymin), int(ymax), int(xmin), int(xmax) - - def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None): - posData = self.data[self.pos_i] - if xl is None: - xRange, yRange = self.ax1.viewRange() - xl = 0 if xRange[0] < 0 else xRange[0] - yb = 0 if yRange[0] < 0 else yRange[0] - Y, X = self.currentLab2D.shape - if anchors is None: - roi = widgets.DelROI( - [xl, yb], [w, h], - rotatable=False, - removable=True, - pen=pg.mkPen(color='r'), - maxBounds=QRectF(QRect(0,0,X,Y)) - ) - ## handles scaling horizontally around center - roi.addScaleHandle([1, 0.5], [0, 0.5]) - roi.addScaleHandle([0, 0.5], [1, 0.5]) - - ## handles scaling vertically from opposite edge - roi.addScaleHandle([0.5, 0], [0.5, 1]) - roi.addScaleHandle([0.5, 1], [0.5, 0]) - - ## handles scaling both vertically and horizontally - roi.addScaleHandle([1, 1], [0, 0]) - roi.addScaleHandle([0, 0], [1, 1]) - roi.addScaleHandle([0, 1], [1, 0]) - roi.addScaleHandle([1, 0], [0, 1]) - - roi.handleSize = 7 - roi.sigRegionChanged.connect(self.delROImoving) - roi.sigRegionChanged.connect(self.delROIstartedMoving) - roi.sigRegionChangeFinished.connect(self.delROImovingFinished) - - key = uuid.uuid4() - - return roi, key - - def delROIstartedMoving(self, roi): - self.clearLostObjContoursItems() - - def clearLostObjContoursItems(self): - self.ax1_lostObjScatterItem.setData([], []) - self.ax2_lostObjScatterItem.setData([], []) - - self.ax1_lostTrackedScatterItem.setData([], []) - self.ax2_lostTrackedScatterItem.setData([], []) - - self.ax2_lostObjImageItem.clear() - self.ax2_lostTrackedObjImageItem.clear() - - self.ax1_lostObjImageItem.clear() - self.ax1_lostTrackedObjImageItem.clear() - - def delROImoving(self, roi): - roi.setPen(color=(255,255,0)) - # First bring back IDs if the ROI moved away - self.restoreAnnotDelROI(roi) - self.setImageImg2() - self.applyDelROIimg1(roi) - self.applyDelROIimg1(roi, ax=1) - - def delROImovingFinished(self, roi: pg.ROI): - roi.setPen(color='r') - self.update_rp() - self.updateAllImages() - QTimer.singleShot( - 300, partial(self.updateDelROIinFutureFrames, roi) - ) - - def restoreAnnotDelROI(self, roi, enforce=True, draw=True): - posData = self.data[self.pos_i] - ROImask = self.getDelRoiMask(roi) - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi) - except Exception as err: - return - - delMask = delROIs_info['delMasks'][idx] - delIDs = delROIs_info['delIDsROI'][idx] - overlapROIdelIDs = np.unique(delMask[ROImask]) - lab2D = self.get_2Dlab(posData.lab) - restoredIDs = set() - for ID in delIDs: - if ID in overlapROIdelIDs and not enforce: - continue - - restoredIDs.add(ID) - - delMaskID = delMask==ID - self.currentLab2D[delMaskID] = ID - lab2D[delMaskID] = ID - - if draw: - self.restoreDelROIimg1(delMaskID, ID, ax=0) - self.restoreDelROIimg1(delMaskID, ID, ax=1) - - delMask[delMaskID] = 0 - - delROIs_info['delIDsROI'][idx] = delIDs - restoredIDs - self.set_2Dlab(lab2D) - self.update_rp() - - def restoreDelROIimg1(self, delMaskID, delID, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - if how.find('nothing') != -1: - return - - if how.find('contours') != -1: - rp_delmask = skimage.measure.regionprops(delMaskID.astype(np.uint8)) - if len(rp_delmask) > 0: - obj = rp_delmask[0] - self.addObjContourToContoursImage(obj=obj, ax=ax) - elif how.find('overlay segm. masks') != -1: - if ax == 0: - self.labelsLayerImg1.setImage( - self.currentLab2D, autoLevels=False - ) - else: - self.labelsLayerRightImg.setImage( - self.currentLab2D, autoLevels=False - ) - - def getDelRoisIDs(self): - posData = self.data[self.pos_i] - if posData.frame_i > 0: - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - allDelIDs = set() - for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: - if ( - not self.ax1.isDelRoiItemPresent(roi) - and not self.ax2.isDelRoiItemPresent(roi) - ): - continue - - ROImask = self.getDelRoiMask(roi) - delIDs = posData.lab[ROImask] - allDelIDs.update(delIDs) - if posData.frame_i > 0: - delIDsPrevFrame = prev_lab[ROImask] - allDelIDs.update(delIDsPrevFrame) - return allDelIDs - - def getStoredDelRoiIDs(self, frame_i=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - allDelIDs = set() - delROIs_info = posData.allData_li[frame_i]['delROIs_info'] - delIDs_rois = delROIs_info['delIDsROI'] - for delIDs in delIDs_rois: - allDelIDs.update(delIDs) - return allDelIDs - - # @exec_time - def getDelROIlab(self, input_lab_2D=None): - posData = self.data[self.pos_i] - if self.delRoiLab is None: - self.initDelRoiLab() - - out_lab = self.delRoiLab - if input_lab_2D is None: - out_lab[:] = self.get_2Dlab(posData.lab, force_z=False) - else: - out_lab[:] = input_lab_2D - - allDelIDs = set() - # Iterate rois and delete IDs - for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: - if ( - not self.ax1.isDelRoiItemPresent(roi) - and not self.ax2.isDelRoiItemPresent(roi) - ): - continue - ROImask = self.getDelRoiMask(roi) - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - idx = delROIs_info['rois'].index(roi) - delObjROImask = delROIs_info['delMasks'][idx] - delIDsROI = delROIs_info['delIDsROI'][idx] - delROIlabRp = skimage.measure.regionprops(out_lab) - for delObj in delROIlabRp: - isDelObj = np.any(ROImask[delObj.slice][delObj.image]) - if not isDelObj: - continue - - delObjROImask[delObj.slice][delObj.image] = delObj.label - out_lab[delObj.slice][delObj.image] = 0 - - delIDsROI.add(delObj.label) - allDelIDs.add(delObj.label) - - # Keep a mask of deleted IDs to bring them back when roi moves - delROIs_info['delMasks'][idx] = delObjROImask - delROIs_info['delIDsROI'][idx] = delIDsROI - - # printl( - # f't1-t0: {(t1-t0)*1000:.3f} ms,', - # f't2-t1: {(t2-t1)*1000:.3f} ms,', - # f't3-t2: {(t3-t2)*1000:.3f} ms,', - # # f't4-t3: {(t4-t3)*1000:.3f} ms,', - # # f't5-t4: {(t5-t4)*1000:.3f} ms,', - # # f't6-t5: {(t6-t5)*1000:.3f} ms', - # sep='\n' - # ) - - return allDelIDs, out_lab - - def getDelRoiMask(self, roi, posData=None, z_slice=None): - if posData is None: - posData = self.data[self.pos_i] - if z_slice is None: - z_slice = self.z_lab() - ROImask = np.zeros(posData.lab.shape, bool) - if isinstance(roi, pg.PolyLineROI): - r, c = [], [] - x0, y0 = roi.pos().x(), roi.pos().y() - for _, point in roi.getLocalHandlePositions(): - xr, yr = point.x(), point.y() - r.append(int(yr+y0)) - c.append(int(xr+x0)) - if not r or not c: - return ROImask - - if len(r) == 2: - rr, cc, val = skimage.draw.line_aa(r[0], c[0], r[1], c[1]) - else: - rr, cc = skimage.draw.polygon(r, c, shape=self.currentLab2D.shape) - - Y, X = self.currentLab2D.shape - rr = rr[(rr>=0) & (rr=0) & (cc{descr} {channel}
: value={value:{ff}}' - ) - return txt - - def _addOverlayHoverValuesFormatted(self, txt, xdata, ydata): - posData = self.data[self.pos_i] - if posData.ol_data is None: - return txt - - for filename in posData.ol_data: - chName = myutils.get_chname_from_basename( - filename, posData.basename, remove_ext=False - ) - if chName not in self.checkedOverlayChannels: - continue - - raw_overlay_img = self.getRawImage(filename=filename) - raw_overlay_value = raw_overlay_img[ydata, xdata] - # raw_overlay_max_value = raw_overlay_img.max() - - raw_txt = self._channelHoverValues('Raw', chName, raw_overlay_value) - - txt = f'{txt} | {raw_txt}' - return txt - - def getActiveToolButton(self): - for button in self.LeftClickButtons: - if button.isChecked(): - return button - - def getConcatAcdcDf(self): - acdc_dfs = [] - keys = [] - posData = self.data[self.pos_i] - for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] - if lab is None: - break - - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - break - - acdc_dfs.append(acdc_df) - keys.append(frame_i) - - if not acdc_dfs: - return - - return pd.concat(acdc_dfs, keys=keys, names=['frame_i']) - - - def checkHighlightTimestamp(self, x, y, activeToolButton): - if not hasattr(self, 'timestamp'): - return - - if not self.addTimestampAction.isChecked(): - return - - if activeToolButton is not None: - return - - if hasattr(self, 'scaleBar'): - if self.scaleBar.isHighlighted(): - return - - ymin, xmin, ymax, xmax = self.timestamp.bbox() - if x < xmin: - self.timestamp.setHighlighted(False) - return - - if x > xmax: - self.timestamp.setHighlighted(False) - return - - if y < ymin: - self.timestamp.setHighlighted(False) - return - - if y > ymax: - self.timestamp.setHighlighted(False) - return - - self.timestamp.setHighlighted(True) - - def checkHighlightScaleBar(self, x, y, activeToolButton): - if not hasattr(self, 'scaleBar'): - return - - if not self.addScaleBarAction.isChecked(): - return - - if activeToolButton is not None: - return - - ymin, xmin, ymax, xmax = self.scaleBar.bbox() - if x < xmin: - self.scaleBar.setHighlighted(False) - return - - if x > xmax: - self.scaleBar.setHighlighted(False) - return - - if y < ymin: - self.scaleBar.setHighlighted(False) - return - - if y > ymax: - self.scaleBar.setHighlighted(False) - return - - self.scaleBar.setHighlighted(True) - - def getMouseDataCoordsRightImage(self): - text = self.wcLabel.text() - if not text: - return - - ax_idx = int(re.findall(r'\(ax(\d)\)', text)[0]) - if ax_idx == 0: - return - - coords = re.findall(r'x=(\d+), y=(\d+) \|', text)[0] - - return tuple([int(val) for val in coords]) - - def updateValuesStatusBar(self): - (xl, xr), (yt, yb) = self.ax1ViewRange(integers=True) - W = round(xr - xl) - H = round(yb - yt) - txt = self.wcLabel.text() - pattern = ( - r'W=.*?, H=.*? \| ' - r'x_left=.*?, y_top=.*? \| ' - r'x_right=.*?, y_bottom=.*? \| ' - ) - replacing = ( - f'W={W:d}, H={H:d} | ' - f'x_left={xl:d}, y_top={yt:d} | ' - f'x_right={xr:d}, y_bottom={yb:d} | ' - ) - txt = re.sub(pattern, replacing, txt) - self.wcLabel.setText(txt) - - def hoverValuesFormatted(self, xdata, ydata, activeToolButton, is_ax0): - (xl, xr), (yt, yb) = self.ax1ViewRange(integers=True) - W = round(xr - xl) - H = round(yb - yt) - ax_idx = 0 if is_ax0 else 1 - txt = ( - f'x={xdata:d}, y={ydata:d} | ' - f'W={W:d}, H={H:d} | ' - f'x_left={xl:d}, y_top={yt:d} | ' - f'x_right={xr:d}, y_bottom={yb:d} | ' - f'(ax{ax_idx})' - ) - if activeToolButton == self.rulerButton: - txt = self._addRulerMeasurementText(txt) - return txt - elif activeToolButton is not None: - return txt - - posData = self.data[self.pos_i] - - raw_img = self.getRawImage() - raw_value = raw_img[ydata, xdata] - # raw_max_value = raw_img.max() - - ch = self.user_ch_name - raw_txt = self._channelHoverValues('Raw', ch, raw_value) - - txt = f'{txt} | {raw_txt}' - - txt = self._addOverlayHoverValuesFormatted(txt, xdata, ydata) - - ID = self.currentLab2D[ydata, xdata] - maxID = max(posData.IDs, default=0) - - num_obj = len(posData.IDs) - lab_txt = ( - f'Objects: ID={ID}, max ID={maxID}, ' - f'num. of objects={num_obj}' - ) - txt = f'{txt} | {lab_txt}' - - txt = self._addRulerMeasurementText(txt) - return txt - - def getRulerLengthText(self): - text = self.wcLabel.text() - lengthText = re.findall(r'length = (.*)\)', text)[0] - lengthText = lengthText.replace('pxl', 'pixels') - return f'{lengthText})' - - def _addRulerMeasurementText(self, txt): - posData = self.data[self.pos_i] - xx, yy = self.ax1_rulerPlotItem.getData() - if xx is None: - return txt - - lenPxl = math.sqrt((xx[0]-xx[1])**2 + (yy[0]-yy[1])**2) - depthAxes = self.switchPlaneCombobox.depthAxes() - if depthAxes != 'z': - pxlToUm = posData.PhysicalSizeZ - else: - pxlToUm = posData.PhysicalSizeX - - length_txt = ( - f'length = {int(lenPxl)} pxl ({lenPxl*pxlToUm:.2f} μm)' - ) - txt = f'{txt} | Measurement: {length_txt}' - return txt - - def updateImageValueFormatter(self): - if self.img1.image is not None: - dtype = self.img1.image.dtype - n_digits = len(str(int(self.img1.image.max()))) - self.imgValueFormatter = myutils.get_number_fstring_formatter( - dtype, precision=abs(n_digits-5) - ) - - rawImgData = self.data[self.pos_i].img_data - dtype = rawImgData.dtype - n_digits = len(str(int(rawImgData.max()))) - self.rawValueFormatter = myutils.get_number_fstring_formatter( - dtype, precision=abs(n_digits-5) - ) - - def normaliseIntensitiesActionTriggered(self, action): - how = action.text() - self.df_settings.at['how_normIntensities', 'value'] = how - self.df_settings.to_csv(self.settings_csv_path) - self.updateAllImages() - self.updateImageValueFormatter() - - def setLastUserNormAction(self): - how = self.df_settings.at['how_normIntensities', 'value'] - for action in self.normalizeQActionGroup.actions(): - if action.text() == how: - action.setChecked(True) - break - - def saveLabelsColormap(self): - self.labelsGrad.saveColormap() - - def addFontSizeActions(self, menu, slot): - fontActionGroup = QActionGroup(self) - fontActionGroup.setExclusive(True) - for fontSize in range(4,27): - action = QAction(self) - action.setText(str(fontSize)) - action.setCheckable(True) - if fontSize == self.fontSize: - action.setChecked(True) - fontActionGroup.addAction(action) - menu.addAction(action) - action.triggered.connect(slot) - return fontActionGroup - - @exception_handler - def changeFontSize(self): - fontSize = self.fontSizeSpinBox.value() - if fontSize == self.fontSize: - return - - self.fontSize = fontSize - - self.df_settings.at['fontSize', 'value'] = self.fontSize - self.df_settings.to_csv(self.settings_csv_path) - - self.setAllIDs() - posData = self.data[self.pos_i] - for ax in range(2): - self.textAnnot[ax].changeFontSize(self.fontSize) - if self.highLowResAction.isChecked(): - self.setAllTextAnnotations() - else: - self.updateAllImages() - - def enableZstackWidgets(self, enabled): - if enabled: - myutils.setRetainSizePolicy(self.zSliceScrollBar) - myutils.setRetainSizePolicy(self.zProjComboBox) - myutils.setRetainSizePolicy(self.zSliceOverlay_SB) - myutils.setRetainSizePolicy(self.zProjOverlay_CB) - myutils.setRetainSizePolicy(self.overlay_z_label) - self.zSliceScrollBar.setDisabled(False) - self.zProjComboBox.show() - if self.data[self.pos_i].SizeT > 1: - self.zProjLockViewButton.show() - self.zSliceScrollBar.show() - self.zSliceCheckbox.show() - self.zSliceSpinbox.show() - self.switchPlaneCombobox.show() - self.switchPlaneCombobox.setDisabled(False) - self.SizeZlabel.show() - else: - myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=False) - myutils.setRetainSizePolicy(self.zProjComboBox, retain=False) - myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=False) - myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=False) - myutils.setRetainSizePolicy(self.overlay_z_label, retain=False) - self.zSliceScrollBar.setDisabled(True) - self.zProjComboBox.hide() - self.zProjComboBox.hide() - self.zSliceScrollBar.hide() - self.zSliceCheckbox.hide() - self.zSliceSpinbox.hide() - self.SizeZlabel.hide() - self.switchPlaneCombobox.hide() - self.switchPlaneCombobox.setDisabled(True) - - self.imgGrad.rescaleAcrossZstackAction.setDisabled(not enabled) - for ch, overlayItems in self.overlayLayersItems.items(): - lutItem = overlayItems[1] - lutItem.rescaleAcrossZstackAction.setDisabled(not enabled) - - def reInitCca(self): - if not self.isSnapshot: - txt = html_utils.paragraph( - 'If you decide to continue ALL cell cycle annotations from ' - 'this frame to the end will be erased from current session ' - '(saved data is not touched of course).

' - 'To annotate future frames again you will have to revisit them.

' - 'Do you want to continue?' - ) - msg = widgets.myMessageBox() - msg.warning( - self, 'Re-initialize annnotations?', txt, - buttonsTexts=('Cancel', 'Yes') - ) - posData = self.data[self.pos_i] - if msg.cancel: - return - - # Reset all future frames - self.resetCcaFuture(posData.frame_i+1) - if posData.frame_i == 0: - # Reset everything since we are on first frame - posData.cca_df = self.getBaseCca_df() - self.store_data() - self.updateAllImages() - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) - else: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - posData = self.data[self.pos_i] - posData.cca_df = self.getBaseCca_df() - self.store_data() - self.updateAllImages() - - - def repeatAutoCca(self): - # Do not allow automatic bud assignment if there are future - # frames that already contain anotations - posData = self.data[self.pos_i] - next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] - if next_df is not None: - if 'cell_cycle_stage' in next_df.columns: - msg = QMessageBox() - warn_cca = msg.critical( - self, 'Future visited frames detected!', - 'Automatic bud assignment CANNOT be performed becasue ' - 'there are future frames that already contain cell cycle ' - 'annotations. The behaviour in this case cannot be predicted.\n\n' - 'We suggest assigning the bud manually OR use the ' - '"Re-initialize cell cycle annotations" button which properly ' - 're-initialize future frames.', - msg.Ok - ) - return - - correctedAssignIDs = ( - posData.cca_df[posData.cca_df['corrected_on_frame_i']>=0].index - ) - NeverCorrectedAssignIDs = [ - ID for ID in posData.new_IDs if ID not in correctedAssignIDs - ] - - # Store cca_df temporarily if attempt_auto_cca fails - posData.cca_df_beforeRepeat = posData.cca_df.copy() - - if not all(NeverCorrectedAssignIDs): - notEnoughG1Cells, proceed = self.attempt_auto_cca() - if notEnoughG1Cells or not proceed: - posData.cca_df = posData.cca_df_beforeRepeat - else: - self.updateAllImages() - return - - msg = QMessageBox() - msg.setIcon(msg.Question) - msg.setText( - 'Do you want to automatically assign buds to mother cells for ' - 'ALL the new cells in this frame (excluding cells with unknown history) ' - 'OR only the cells where you never clicked on?' - ) - msg.setDetailedText( - f'New cells that you never touched:\n\n{NeverCorrectedAssignIDs}') - enforceAllButton = QPushButton('ALL new cells') - b = QPushButton('Only cells that I never corrected assignment') - msg.addButton(b, msg.YesRole) - msg.addButton(enforceAllButton, msg.NoRole) - msg.exec_() - if msg.clickedButton() == enforceAllButton: - notEnoughG1Cells, proceed = self.attempt_auto_cca(enforceAll=True) - else: - notEnoughG1Cells, proceed = self.attempt_auto_cca() - if notEnoughG1Cells or not proceed: - posData.cca_df = posData.cca_df_beforeRepeat - else: - self.updateAllImages() - - def manualEditCcaToolbarActionTriggered(self): - self.manualEditCca() - - def askGet2Dor3Dimage(self): - txt = html_utils.paragraph(""" - Do you want to test the denoising on the visualized 2D image or - on the entire 3D z-stack? - """) - msg = widgets.myMessageBox(wrapText=False) - _, use3Dbutton, use2Dbutton = msg.question( - self, '3D denoising?', txt, - buttonsTexts=('Cancel', 'Denoise 3D z-stack', 'Denoise 2D image') - ) - if msg.cancel: - return - - if msg.clickedButton == use3Dbutton: - posData = self.data[self.pos_i] - zslice = self.zSliceScrollBar.sliderPosition() - return posData.img_data[posData.frame_i, zslice] - else: - return self.getDisplayedImg1() - - def manualEditCca(self, checked=True): - posData = self.data[self.pos_i] - editCcaWidget = apps.editCcaTableWidget( - posData.cca_df, posData.SizeT, current_frame_i=posData.frame_i, - parent=self - ) - editCcaWidget.sigApplyChangesFutureFrames.connect( - self.applyManualCcaChangesFutureFrames - ) - editCcaWidget.exec_() - if editCcaWidget.cancel: - return - posData.cca_df = editCcaWidget.cca_df - self.store_cca_df() - # self.checkMultiBudMoth() - self.updateAllImages() - - @exception_handler - def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i): - self.store_data(autosave=False) - posData = self.data[self.pos_i] - undoId = uuid.uuid4() - for i in range(posData.frame_i, stop_frame_i): - cca_df_i = self.get_cca_df(frame_i=i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - self.storeUndoRedoCca(i, cca_df_i, undoId) - - for ID, changes_ID in changes.items(): - if ID not in cca_df_i.index: - continue - for col, (oldValue, newValue) in changes_ID.items(): - cca_df_i.at[ID, col] = newValue - self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - self.get_data() - self.updateAllImages() - - def annotateRightHowCombobox_cb(self, idx): - how = self.annotateRightHowCombobox.currentText() - saveSettings = True - if hasattr(self.annotateRightHowCombobox, 'saveSettings'): - saveSettings = self.annotateRightHowCombobox.saveSettings - - if saveSettings: - self.df_settings.at['how_draw_right_annotations', 'value'] = how - self.df_settings.to_csv(self.settings_csv_path) - - mode = self.modeComboBox.currentText() - isCcaAnnot = ( - self.annotCcaInfoCheckboxRight.isChecked() and - mode != 'Normal division: Lineage tree' - ) - isIDAnnot = (self.annotIDsCheckboxRight.isChecked() or ( - self.annotCcaInfoCheckboxRight.isChecked() and - mode == 'Normal division: Lineage tree' - )) - self.textAnnot[1].setCcaAnnot( - isCcaAnnot - ) - - self.textAnnot[1].setLabelAnnot( - isIDAnnot - ) - if not self.isDataLoading: - self.updateAllImages() - - def drawIDsContComboBox_cb(self, idx): - how = self.drawIDsContComboBox.currentText() - saveSettings = True - if hasattr(self.drawIDsContComboBox, 'saveSettings'): - saveSettings = self.drawIDsContComboBox.saveSettings - - if saveSettings: - self.df_settings.at['how_draw_annotations', 'value'] = how - self.df_settings.to_csv(self.settings_csv_path) - - mode = self.modeComboBox.currentText() - isCcaAnnot = ( - self.annotCcaInfoCheckbox.isChecked() and - mode != 'Normal division: Lineage tree' - ) - isIDAnnot = (self.annotIDsCheckbox.isChecked() or ( - self.annotCcaInfoCheckbox.isChecked() and - mode == 'Normal division: Lineage tree' - )) - self.textAnnot[0].setCcaAnnot( - isCcaAnnot - ) - - self.textAnnot[0].setLabelAnnot( - isIDAnnot - ) - - if not self.isDataLoading: - self.updateAllImages() - - if self.eraserButton.isChecked(): - self.setTempImg1Eraser(None, init=True) - - def mousePressColorButton(self, event): - posData = self.data[self.pos_i] - items = list(self.checkedOverlayChannels) - if len(items)>1: - selectFluo = widgets.QDialogListbox( - 'Select image', - 'Select which fluorescence image you want to update the color of\n', - items, multiSelection=False, parent=self - ) - selectFluo.exec_() - keys = selectFluo.selectedItemsText - if selectFluo.cancel or not keys: - return - else: - self.overlayColorButton.channel = keys[0] - else: - self.overlayColorButton.channel = items[0] - self.overlayColorButton.selectColor() - - def setEnabledCcaToolbar(self, enabled=False): - self.manuallyEditCcaAction.setDisabled(False) - self.viewCcaTableAction.setDisabled(False) - self.ccaToolBar.setVisible(enabled) - for action in self.ccaToolBar.actions(): - button = self.ccaToolBar.widgetForAction(action) - action.setVisible(enabled) - button.setEnabled(enabled) - - # def setEnabledCcaToolbar(self, enabled=False): - # self.manuallyEditCcaAction.setDisabled(False) - # self.viewCcaTableAction.setDisabled(False) - # self.ccaToolBar.setVisible(enabled) - # for action in self.ccaToolBar.actions(): - # button = self.ccaToolBar.widgetForAction(action) - # action.setVisible(enabled) - # button.setEnabled(enabled) - - def setEnabledEditToolbarButton(self, enabled=False): - for action in self.segmActions: - action.setEnabled(enabled) - - for action in self.segmActionsVideo: - action.setEnabled(enabled) - - self.relabelSequentialAction.setEnabled(enabled) - self.repeatTrackingMenuAction.setEnabled(enabled) - self.repeatTrackingVideoAction.setEnabled(enabled) - self.postProcessSegmAction.setEnabled(enabled) - self.autoSegmAction.setEnabled(enabled) - self.editToolBar.setVisible(enabled) - mode = self.modeComboBox.currentText() - ccaON = mode == 'Cell cycle analysis' - for action in self.editToolBar.actions(): - button = self.editToolBar.widgetForAction(action) - # Keep binCellButton active in cca mode - if button==self.binCellButton and not enabled and ccaON: - action.setVisible(True) - button.setEnabled(True) - else: - action.setVisible(enabled) - button.setEnabled(enabled) - if not enabled: - self.setUncheckedAllButtons() - - def setEnabledFileToolbar(self, enabled): - for action in self.fileToolBar.actions(): - button = self.fileToolBar.widgetForAction(action) - if action == self.openFolderAction or action == self.newAction: - continue - if action == self.manageVersionsAction: - continue - if action == self.openFileAction: - continue - action.setEnabled(enabled) - button.setEnabled(enabled) - - def reconnectUndoRedo(self): - try: - self.undoAction.triggered.disconnect() - self.redoAction.triggered.disconnect() - except Exception as e: - pass - mode = self.modeComboBox.currentText() - if mode == 'Segmentation and Tracking' or mode == 'Snapshot': - self.undoAction.triggered.connect(self.undo) - self.redoAction.triggered.connect(self.redo) - elif mode == 'Cell cycle analysis': - self.undoAction.triggered.connect(self.UndoCca) - elif mode == 'Custom annotations': - self.undoAction.triggered.connect(self.undoCustomAnnotation) - else: - self.undoAction.setDisabled(True) - self.redoAction.setDisabled(True) - - def enableSizeSpinbox(self, enabled): - self.brushSizeLabelAction.setVisible(enabled) - self.brushSizeAction.setVisible(enabled) - self.brushAutoFillAction.setVisible(enabled) - self.brushAutoHideAction.setVisible(enabled) - self.brushEraserToolBar.setVisible(enabled) - self.disableNonFunctionalButtons() - - def reload_cb(self): - posData = self.data[self.pos_i] - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - labData = np.load(posData.segm_npz_path) - # Keep compatibility with .npy and .npz files - try: - lab = labData['arr_0'][posData.frame_i] - except Exception as e: - lab = labData[posData.frame_i] - posData.segm_data[posData.frame_i] = lab.copy() - self.get_data() - self.tracking() - self.updateAllImages() - - def clearComboBoxFocus(self, mode): - # Remove focus from modeComboBox to avoid the key_up changes its value - self.sender().clearFocus() - try: - self.timer.stop() - self.modeComboBox.setStyleSheet('background-color: none') - except Exception as e: - pass - - def updateModeMenuAction(self): - self.modeActionGroup.triggered.disconnect() - for action in self.modeActionGroup.actions(): - if action.text() != self.modeComboBox.currentText(): - continue - action.setChecked(True) - break - self.modeActionGroup.triggered.connect(self.changeModeFromMenu) - - def changeModeFromMenu(self, action): - self.modeComboBox.setCurrentText(action.text()) - - def restorePrevAnnotOptions(self): - if self.prevAnnotOptions is None: - return - self.restoreAnnotOptions_ax1(options=self.prevAnnotOptions) - self.setDrawAnnotComboboxText() - self.prevAnnotOptions = None - - def uncheckAllButtonsFromButtonGroup(self, buttonGroup): - for button in buttonGroup.buttons(): - if not button.isCheckable(): - continue - - if not button.isChecked(): - continue - - button.setChecked(False) - - @disableWindow - def changeMode(self, text): - self.reconnectUndoRedo() - self.updateModeMenuAction() - self.clearCustomAnnot() - posData = self.data[self.pos_i] - mode = text - prevMode = self.modeComboBox.previousText() - self.annotateToolbar.setVisible(False) - if prevMode != 'Viewer': - self.store_data(autosave=True) - - self.copyLostObjButton.setChecked(False) - self.stopCcaIntegrityCheckerWorker() - self.setAutoSaveSegmentationEnabled(False) - self.setAutoSaveAnnotationsEnabled(False) - if prevMode == 'Normal division: Lineage tree': - self.askLineageTreeChanges() - self.lineage_tree = None - self.editLin_TreeBar.setVisible(False) - self.uncheckAllButtonsFromButtonGroup(self.editLin_TreeGroup) - - elif prevMode == 'Cell cycle analysis': - self.setEnabledCcaToolbar(enabled=False) - - if mode == 'Segmentation and Tracking': - self.setAutoSaveSegmentationEnabled(True) - self.setSwitchViewedPlaneDisabled(True) - self.trackingMenu.setDisabled(False) - self.modeToolBar.setVisible(True) - self.lastTrackedFrameLabel.setText('') - self.initSegmTrackMode() - self.setEnabledEditToolbarButton(enabled=True) - self.addExistingDelROIs() - self.isFirstTimeOnNextFrame() - self.setEnabledCcaToolbar(enabled=False) - self.clearComputedContours() - self.realTimeTrackingToggle.setDisabled(False) - self.realTimeTrackingToggle.label.setDisabled(False) - if posData.cca_df is not None: - self.store_cca_df() - self.restorePrevAnnotOptions() - self.whitelistViewOGIDs(False) - elif mode == 'Cell cycle analysis': - self.setAutoSaveAnnotationsEnabled(True) - self.setSwitchViewedPlaneDisabled(True) - self.startCcaIntegrityCheckerWorker() - proceed = self.initCca() - if proceed: - self.applyDelROIs() - self.modeToolBar.setVisible(True) - self.realTimeTrackingToggle.setDisabled(True) - self.realTimeTrackingToggle.label.setDisabled(True) - self.computeAllContours() - # RAWR!!!!! - # self.computeAllObjToObjCostPairs() - if proceed: - self.setEnabledEditToolbarButton(enabled=False) - if self.isSnapshot: - self.editToolBar.setVisible(True) - self.setEnabledCcaToolbar(enabled=True) - self.removeAlldelROIsCurrentFrame() - self.setAnnotOptionsCcaMode() - self.clearGhost() - elif mode == 'Viewer': - self.autoSaveTimer.stop() - self.setSwitchViewedPlaneDisabled(False) - self.modeToolBar.setVisible(True) - self.realTimeTrackingToggle.setDisabled(True) - self.realTimeTrackingToggle.label.setDisabled(True) - self.setEnabledEditToolbarButton(enabled=False) - self.setEnabledCcaToolbar(enabled=False) - self.removeAlldelROIsCurrentFrame() - self.setStatusBarLabel() - self.navigateScrollBar.setMaximum(posData.SizeT) - self.navSpinBox.setMaximum(posData.SizeT) - self.clearGhost() - self.computeAllContours() - elif mode == 'Custom annotations': - self.setAutoSaveAnnotationsEnabled(True) - self.setSwitchViewedPlaneDisabled(True) - self.modeToolBar.setVisible(True) - self.realTimeTrackingToggle.setDisabled(True) - self.realTimeTrackingToggle.label.setDisabled(True) - self.setEnabledEditToolbarButton(enabled=False) - self.setEnabledCcaToolbar(enabled=False) - self.removeAlldelROIsCurrentFrame() - self.annotateToolbar.setVisible(True) - self.clearGhost() - self.doCustomAnnotation(0) - self.computeAllContours() - elif mode == 'Snapshot': - self.setAutoSaveAnnotationsEnabled(True) - self.setSwitchViewedPlaneDisabled(False) - self.reconnectUndoRedo() - self.setEnabledSnapshotMode() - self.doCustomAnnotation(0) - self.clearComputedContours() - elif mode == 'Normal division: Lineage tree': # Mode activation for lineage tree - # self.startLinTreeIntegrityCheckerWorker() # need to replace (postponed) - proceed = self.initLinTree() - self.setEnabledCcaToolbar(enabled=False) - self.setNavigateScrollBarMaximum() - if proceed: - self.applyDelROIs() - self.modeToolBar.setVisible(True) - self.realTimeTrackingToggle.setDisabled(True) - self.realTimeTrackingToggle.label.setDisabled(True) - if proceed: - self.setAutoSaveAnnotationsEnabled(True) - self.setEnabledEditToolbarButton(enabled=False) - if self.isSnapshot: - self.editToolBar.setVisible(True) - self.removeAlldelROIsCurrentFrame() - self.setAnnotOptionsLin_treeMode() - self.clearGhost() - self.editLin_TreeBar.setVisible(True) - - self.disableNonFunctionalButtons() - - def disableEditingViewPlaneNotXY(self): - posData = self.data[self.pos_i] - self.manuallyEditCcaAction.setDisabled(True) - for action in self.segmActions: - action.setDisabled(True) - if posData.SizeT == 1: - self.segmVideoMenu.setDisabled(True) - self.postProcessSegmAction.setDisabled(True) - self.autoSegmAction.setDisabled(True) - self.ccaToolBar.setVisible(False) - self.editToolBar.setVisible(False) - for action in self.ccaToolBar.actions(): - button = self.editToolBar.widgetForAction(action) - if button is not None: - button.setDisabled(True) - action.setVisible(False) - for action in self.editToolBar.actions(): - button = self.editToolBar.widgetForAction(action) - action.setVisible(False) - if button is not None: - button.setDisabled(True) - - def setEnabledSnapshotMode(self): - posData = self.data[self.pos_i] - self.manuallyEditCcaAction.setDisabled(False) - self.viewCcaTableAction.setDisabled(False) - for action in self.segmActions: - action.setDisabled(False) - - self.segmVideoMenu.setDisabled(True) - self.trackingMenu.setDisabled(True) - self.modeToolBar.setVisible(False) - - self.relabelSequentialAction.setDisabled(False) - self.postProcessSegmAction.setDisabled(False) - self.autoSegmAction.setDisabled(False) - self.ccaToolBar.setVisible(True) - self.editToolBar.setVisible(True) - self.reinitLastSegmFrameAction.setVisible(False) - for action in self.ccaToolBar.actions(): - button = self.ccaToolBar.widgetForAction(action) - if button == self.assignBudMothButton: - button.setDisabled(False) - action.setVisible(True) - elif action == self.reInitCcaAction: - action.setVisible(True) - elif action == self.assignBudMothAutoAction and posData.SizeT==1: - action.setVisible(True) - for action in self.editToolBar.actions(): - button = self.editToolBar.widgetForAction(action) - action.setVisible(True) - button.setEnabled(True) - self.realTimeTrackingToggle.setDisabled(True) - self.realTimeTrackingToggle.label.setDisabled(True) - self.repeatTrackingAction.setVisible(False) - self.manualTrackingAction.setVisible(False) - button = self.editToolBar.widgetForAction(self.repeatTrackingAction) - button.setDisabled(True) - button = self.editToolBar.widgetForAction(self.manualTrackingAction) - button.setDisabled(True) - self.disableNonFunctionalButtons() - self.reinitLastSegmFrameAction.setVisible(False) - - def launchSlideshow(self): - posData = self.data[self.pos_i] - self.determineSlideshowWinPos() - if self.slideshowButton.isChecked(): - self.slideshowWin = apps.imageViewer( - parent=self, - button_toUncheck=self.slideshowButton, - linkWindow=posData.SizeT > 1, - enableOverlay=True, - enableMirroredCursor=True - ) - self.slideshowWin.img.minMaxValuesMapper = ( - self.img1.minMaxValuesMapper - ) - self.slideshowWin.img.setCurrentPosIndex(self.pos_i) - h = self.drawIDsContComboBox.size().height() - self.slideshowWin.framesScrollBar.setFixedHeight(h) - self.slideshowWin.overlayButton.setChecked( - self.overlayButton.isChecked() - ) - self.slideshowWin.sigHoveringImage.connect( - self.setMirroredCursorFromSecondWindow - ) - if posData.SizeZ > 1: - z_slice = self.zSliceScrollBar.sliderPosition() - self.slideshowWin.img.setCurrentZsliceIndex(z_slice) - self.slideshowWin.zSliceScrollBar.setSliderPosition(z_slice) - self.slideshowWin.z_label.setText( - f'z-slice {z_slice+1:02}/{posData.SizeZ}' - ) - self.slideshowWin.update_img() - self.slideshowWin.show( - left=self.slideshowWinLeft, top=self.slideshowWinTop - ) - else: - self.slideshowWin.close() - self.slideshowWin = None - - def setMirroredCursorFromSecondWindow(self, x, y): - if x is None: - xx, yy = [], [] - else: - xx, yy = [x], [y] - self.ax1_cursor.setData(xx, yy) - if not self.isTwoImageLayout: - return - self.ax2_cursor.setData(xx, yy) - - def goToZsliceSearchedID(self, obj): - if not self.isSegm3D: - return - - current_z = self.z_lab() - nearest_nonzero_z = core.nearest_nonzero_z_idx_from_z_centroid( - obj, current_z=current_z - ) - if nearest_nonzero_z == current_z: - self.drawPointsLayers(computePointsLayers=True) - return - - self.zSliceScrollBar.setSliderPosition(nearest_nonzero_z) - self.update_z_slice(nearest_nonzero_z) - - def disconnectLeftClickButtons(self): - for button in self.LeftClickButtons: - try: - button.toggled.disconnect() - except Exception as e: - # Not all the LeftClickButtons have toggled connected - pass - - def uncheckLeftClickButtons(self, sender): - for button in self.LeftClickButtons: - if button != sender: - button.setChecked(False) - - if button != self.labelRoiButton: - # self.labelRoiButton is disconnected so we manually call uncheck - self.labelRoi_cb(False) - self.secondLevelToolbar.setVisible(True) - for toolbar in self.controlToolBars: - try: - toolbar.keepVisibleWhenActive - if toolbar.isVisible(): - self.secondLevelToolbar.setVisible(False) - continue - except: - pass - toolbar.setVisible(False) - - self.enableSizeSpinbox(False) - if sender is not None: - self.keepIDsButton.setChecked(False) - - def connectLeftClickButtonsPointsLayersToolbar(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - if action.layerTypeIdx != 4: - continue - action.button.toggled.connect( - self.addPointsByClickingButtonToggled - ) - - def connectLeftClickButtons(self): - self.brushButton.toggled.connect(self.Brush_cb) - self.curvToolButton.toggled.connect(self.curvTool_cb) - self.rulerButton.toggled.connect(self.ruler_cb) - self.eraserButton.toggled.connect(self.Eraser_cb) - self.wandToolButton.toggled.connect(self.wand_cb) - self.labelRoiButton.toggled.connect(self.labelRoi_cb) - self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) - self.drawClearRegionButton.toggled.connect(self.drawClearRegion_cb) - self.expandLabelToolButton.toggled.connect(self.expandLabelCallback) - self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) - self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) - self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) - self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) - self.connectLeftClickButtonsPointsLayersToolbar() - - def brushSize_cb(self, value): - self.ax2_EraserCircle.setSize(value*2) - self.ax1_BrushCircle.setSize(value*2) - self.ax2_BrushCircle.setSize(value*2) - self.ax1_EraserCircle.setSize(value*2) - self.ax2_EraserX.setSize(value) - self.ax1_EraserX.setSize(value) - self.setDiskMask() - - def autoIDtoggled(self, checked): - self.editIDspinboxAction.setDisabled(checked) - self.editIDLabelAction.setDisabled(checked) - if not checked and self.editIDspinbox.value() == 0: - newID = self.setBrushID(return_val=True) - self.editIDspinbox.setValue(newID) - - def wand_cb(self, checked): - posData = self.data[self.pos_i] - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.wandToolButton) - self.connectLeftClickButtons() - self.wandControlsToolbar.setVisible(True) - # self.secondLevelToolbar.setVisible(False) - else: - self.resetCursors() - # self.secondLevelToolbar.setVisible(True) - self.wandControlsToolbar.setVisible(False) - - def magicPrompts_cb(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.magicPromptsToolButton) - self.connectLeftClickButtons() - self.magicPromptsToolbar.setVisible(True) - self.promptSegmentPointsLayerToolbar.setVisible(True) - if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit: - self.addPointsLayerTriggered( - toolbar=self.promptSegmentPointsLayerToolbar - ) - else: - self.resetCursors() - self.promptSegmentPointsLayerToolbar.setVisible(False) - self.magicPromptsToolbar.setVisible(False) - - def copyLostObjContour_cb(self, checked): - self.copyLostObjToolbar.setVisible(checked) - - self.ax1_lostObjScatterItem.hoverLostID = 0 - if not checked: - return - - self.lostObjImage = np.zeros_like(self.currentLab2D) - self.updateLostContoursImage(0) - - def manualAnnotPast_cb(self, checked): - posData = self.data[self.pos_i] - if checked: - for _ in range(3): - self.onEscape( - buttonsToNotUncheck=[self.manualAnnotPastButton], - doAutoRange=False - ) - - self.brushButton.setChecked(True) - self.store_data() - self.manualAnnotState = { - 'editID': self.editIDspinbox.value(), - 'isAutoID': self.autoIDcheckbox.isChecked(), - 'doWarnLostObj': self.warnLostCellsAction.isChecked(), - } - self.autoIDcheckbox.setChecked(False) - self.warnLostCellsAction.setChecked(False) - hoverID = self.getLastHoveredID() - if hoverID == 0: - win = apps.QLineEditDialog( - title='Not hovering any ID', - msg='You are not hovering on any ID.\n' - 'Enter the ID that you want to lock.', - parent=self, - isInteger=True, - defaultTxt=self.setBrushID(return_val=True) - ) - win.exec_() - if win.cancel: - self.manualAnnotPastButton.setChecked(False) - return - hoverID = win.EntryID - self.logger.info( - 'Setting manual annotation for ID = ' - f'{hoverID}, at frame n. {posData.frame_i+1}' - ) - self.editIDspinbox.setValue(hoverID) - try: - obj_idx = posData.IDs_idxs[hoverID] - obj = posData.rp[obj_idx] - radius = 0.9 * obj.minor_axis_length / 2 # math.sqrt(obj.area/math.pi)*0.9 - self.brushSizeSpinbox.setValue(round(radius)) - except Exception as err: - pass - - self.manualAnnotState['frame_i_to_restore'] = posData.frame_i - self.manualAnnotState['last_tracked_i'] = ( - self.navigateScrollBar.maximum()-1 - ) - self.ax1.sigRangeChanged.connect(self.highlightManualAnnotMode) - self.ax1.setHighlighted(True, color='green') - else: - self.setStatusBarLabel() - self.autoIDcheckbox.setChecked(self.manualAnnotState['isAutoID']) - self.editIDspinbox.setValue(self.manualAnnotState['editID']) - self.warnLostCellsAction.setChecked( - self.manualAnnotState['doWarnLostObj'] - ) - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - if frame_to_restore is None: - return - - self.store_data() - self.store_manual_annot_data() - - last_tracked_i_to_restore = self.manualAnnotState['last_tracked_i'] - self.manualAnnotRestoreLastTrackedFrame(last_tracked_i_to_restore) - - self.logger.info( - f'Restoring view to frame n. {posData.frame_i+1}...' - ) - posData.frame_i = frame_to_restore - self.get_data() - self.updateAllImages() - self.updateScrollbars() - self.ax1.sigRangeChanged.disconnect() - self.ax1.setHighlighted(False) - QTimer.singleShot(150, self.autoRange) - - self.setManualAnnotModeEnabledTools(checked) - - def copyLostObjectMask(self, ID: int): - posData = self.data[self.pos_i] - mask = self.lostObjImage == ID - lab2D = self.get_2Dlab(posData.lab) - lab2D[mask] = ID - self.lostObjImage[mask] = 0 - self.set_2Dlab(lab2D) - - def highlightManualAnnotMode(self, viewBox, viewRange): - self.ax1.setHighlighted(True) - - def updateHighlightedAxis(self): - if not self.manualAnnotPastButton.isChecked(): - return - - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - posData = self.data[self.pos_i] - if posData.frame_i == frame_to_restore: - color = 'green' - elif posData.frame_i < frame_to_restore: - color = 'gold' - else: - color = 'red' - - self.ax1.setHighlightingRectItemsColor(color) - - def updateLostNewCurrentIDs(self): - posData = self.data[self.pos_i] - - prev_IDs = self.getPrevFrameIDs() - tracked_lost_IDs = self.getTrackedLostIDs() - curr_IDs = posData.IDs - curr_delRoiIDs = self.getStoredDelRoiIDs() - prev_delRoiIDs = self.getStoredDelRoiIDs(frame_i=posData.frame_i-1) - lost_IDs = [ - ID for ID in prev_IDs if ID not in curr_IDs - and ID not in prev_delRoiIDs and ID not in tracked_lost_IDs - ] - new_IDs = [ - ID for ID in curr_IDs if ID not in prev_IDs - and ID not in curr_delRoiIDs - ] - IDs_with_holes = [] - posData.lost_IDs = lost_IDs - posData.new_IDs = new_IDs - posData.old_IDs = prev_IDs - posData.IDs = curr_IDs - - out = ( - lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs - ) - return out - - def _copyAllLostObjects_navigateToFrame(self, frame_i): - posData = self.data[self.pos_i] - self.store_data(mainThread=False, autosave=False) - - posData.frame_i = frame_i - self.get_data() - self.tracking(wl_update=False) - self.currentLab2D = self.get_2Dlab(posData.lab) - self.update_rp() - self.updateLostNewCurrentIDs() - self.store_data(mainThread=False, autosave=False) - - self.lostObjContoursImage[:] = 0 - self.lostObjImage[:] = 0 - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] # need to change this when merging with opt. - for lostID in posData.lost_IDs: - obj = prev_rp[prev_IDs_idxs[lostID]] - self.addLostObjsToLostObjImage(obj, lostID, force=True) - - def _copyAllLostObjects_returnToFrame(self, frame_i): - posData = self.data[self.pos_i] - self.store_data(autosave=False, mainThread=False) - posData.frame_i = frame_i - self.get_data() - - def _copyAllLostObjects_refreshRp(self): - self.update_rp(draw=False, wl_update=False) # need to change this when merging with opt. - - @disableWindow - def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): - if not self.copyLostObjButton.isChecked(): - return - - posData = self.data[self.pos_i] - - desc = 'Copying all lost objects...' - - self.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self.mainWin, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(for_future_frame_n+1) - self.progressWin.show(self.app) - - self.copyAllLostObjectsThread = QThread() - - self.copyAllLostObjectsWorker = workers.CopyAllLostObjectsWorker( - self, posData, for_future_frame_n, max_overlap_perc - ) - self.copyAllLostObjectsWorker.moveToThread(self.copyAllLostObjectsThread) - - self.copyAllLostObjectsWorker.navigateToFrame.connect( - self._copyAllLostObjects_navigateToFrame, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.returnToFrame.connect( - self._copyAllLostObjects_returnToFrame, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.copyLostObjectMask.connect( - self.copyLostObjectMask, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.refreshRp.connect( - self._copyAllLostObjects_refreshRp, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.progressBar.connect( - self.workerUpdateProgressbar - ) - self.copyAllLostObjectsWorker.critical.connect( - self.copyAllLostObjectsWorkerCritical - ) - self.copyAllLostObjectsWorker.finished.connect( - self.copyAllLostObjectsThread.quit - ) - self.copyAllLostObjectsWorker.finished.connect( - self.copyAllLostObjectsWorker.deleteLater - ) - self.copyAllLostObjectsThread.finished.connect( - self.copyAllLostObjectsThread.deleteLater - ) - self.copyAllLostObjectsWorker.finished.connect( - self.copyAllLostObjectsWorkerFinished - ) - - self.copyAllLostObjectsThread.started.connect( - self.copyAllLostObjectsWorker.run - ) - self.copyAllLostObjectsThread.start() - - self.copyAllLostObjectsWorkerLoop = QEventLoop() - self.copyAllLostObjectsWorkerLoop.exec_() - - def copyAllLostObjectsWorkerCritical(self, error): - self.copyAllLostObjectsWorkerLoop.exit() - self.workerCritical(error) - - def copyAllLostObjectsWorkerFinished(self, output): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - if output.get('doReinitLastSegmFrame', False): - self.reInitLastSegmFrame( - from_frame_i=output.get('last_visited_frame_i'), - updateImages=False, - force=True - ) - - if output.get('overlap_warning', False): - self.blinker = qutils.QControlBlink( - self.copyLostObjToolbar.maxOverlapNumberControl, - qparent=self.mainWin - ) - self.blinker.start() - - self.copyAllLostObjectsWorkerLoop.exit() - self.update_rp() - self.updateAllImages() - self.store_data() - - def labelRoiTrangeCheckboxToggled(self, checked): - disabled = not checked - self.labelRoiStartFrameNoSpinbox.setDisabled(disabled) - self.labelRoiStopFrameNoSpinbox.setDisabled(disabled) - self.labelRoiStartFrameNoSpinbox.label.setDisabled(disabled) - self.labelRoiStopFrameNoSpinbox.label.setDisabled(disabled) - self.labelRoiToEndFramesAction.setDisabled(disabled) - self.labelRoiFromCurrentFrameAction.setDisabled(disabled) - - if disabled: - return - - posData = self.data[self.pos_i] - - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) - self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) - - def drawClearRegion_cb(self, checked): - posData = self.data[self.pos_i] - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.drawClearRegionButton) - self.connectLeftClickButtons() - - self.drawClearRegionToolbar.setVisible(checked) - - if not self.isSegm3D: - self.drawClearRegionToolbar.setZslicesControlEnabled(False) - return - - if not checked: - return - - self.drawClearRegionToolbar.setZslicesControlEnabled( - True, SizeZ=posData.SizeZ - ) - - def labelRoi_cb(self, checked): - posData = self.data[self.pos_i] - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.labelRoiButton) - self.connectLeftClickButtons() - - self.labelRoiStartFrameNoSpinbox.setMaximum(posData.SizeT) - self.labelRoiStopFrameNoSpinbox.setMaximum(posData.SizeT) - - if self.labelRoiActiveWorkers: - lastActiveWorker = self.labelRoiActiveWorkers[-1] - self.labelRoiGarbageWorkers.append(lastActiveWorker) - lastActiveWorker.finished.emit() - self.logger.info('Collected garbage w5orker (magic labeller).') - - self.labelRoiToolbar.setVisible(True) - if self.isSegm3D: - self.labelRoiZdepthSpinbox.setDisabled(False) - else: - self.labelRoiZdepthSpinbox.setDisabled(True) - - # Start thread and pause it - self.labelRoiThread = QThread() - self.labelRoiMutex = QMutex() - self.labelRoiWaitCond = QWaitCondition() - - labelRoiWorker = workers.LabelRoiWorker(self) - - labelRoiWorker.moveToThread(self.labelRoiThread) - labelRoiWorker.finished.connect(self.labelRoiThread.quit) - labelRoiWorker.finished.connect(labelRoiWorker.deleteLater) - self.labelRoiThread.finished.connect( - self.labelRoiThread.deleteLater - ) - - labelRoiWorker.finished.connect(self.labelRoiWorkerFinished) - labelRoiWorker.sigLabellingDone.connect(self.labelRoiDone) - labelRoiWorker.sigProgressBar.connect(self.workerUpdateProgressbar) - - labelRoiWorker.progress.connect(self.workerProgress) - labelRoiWorker.critical.connect(self.workerCritical) - - self.labelRoiActiveWorkers.append(labelRoiWorker) - - self.labelRoiThread.started.connect(labelRoiWorker.run) - self.labelRoiThread.start() - - # Add the rectROI to ax1 - self.ax1.addItem(self.labelRoiItem) - elif self.initLabelRoiModelDialog is not None: - # User is using other tools while the dialog is still open - # --> we allow this because it's useful to be able to use - # the ruler or check things --> do nothing - pass - else: - self.labelRoiToolbar.setVisible(False) - - for worker in self.labelRoiActiveWorkers: - worker._stop() - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) - self.freeRoiItem.clear() - self.ax1.removeItem(self.labelRoiItem) - self.updateLabelRoiCircularCursor(None, None, False) - - def clearObjsFreehandRegion(self): - self.logger.info('Clearing objects inside freehand region...') - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False, storeImage=False, storeOnlyZoom=True) - - posData = self.data[self.pos_i] - zRange = None - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - z_slice = self.z_lab() - zRange = self.drawClearRegionToolbar.zRange( - z_slice, posData.SizeZ - ) - else: - zRange = (0, posData.SizeZ) - - regionSlice = self.freeRoiItem.slice(zRange=zRange) - mask = self.freeRoiItem.mask() - - regionLab = posData.lab[(...,) + regionSlice].copy() - - clearBorders = ( - self.drawClearRegionToolbar - .clearOnlyEnclosedObjsRadioButton.isChecked() - ) - if clearBorders: - if regionLab.ndim == 2: - regionLab = transformation.clear_objects_not_in_mask( - regionLab, mask - ) - regionRp = skimage.measure.regionprops(regionLab) - for obj in regionRp: - if np.all(mask[obj.slice][obj.image]): - continue - - regionLab[obj.slice][obj.image] = 0 - else: - for z, regionLab_z in enumerate(regionLab): - regionLab[z] = transformation.clear_objects_not_in_mask( - regionLab_z, mask - ) - else: - regionLab[..., ~mask] = 0 - - regionRp = skimage.measure.regionprops(regionLab) - clearIDs = [obj.label for obj in regionRp] - - if not clearIDs: - if clearBorders: - self.logger.warning( - 'None of the objects in the freehand region are ' - 'fully enclosed' - ) - else: - self.logger.warning( - 'None of the objects are touching the freehand region' - ) - return - - self.deleteIDmiddleClick(clearIDs, False, False) - self.update_cca_df_deletedIDs(posData, clearIDs) - - self.freeRoiItem.clear() - - self.updateAllImages() - - def labelRoiWorkerFinished(self): - self.logger.info('Magic labeller closed.') - worker = self.labelRoiActiveWorkers.pop(-1) - - def indexRoiLab(self, roiLab, roiLabSlice, lab, brushID): - # Delete only objects touching borders in X and Y not in Z - if self.labelRoiAutoClearBorderCheckbox.isChecked(): - mask = np.zeros(roiLab.shape, dtype=bool) - mask[..., 1:-1, 1:-1] = True - roiLab = skimage.segmentation.clear_border(roiLab, mask=mask) - - roiLabMask = roiLab>0 - roiLab[roiLabMask] += (brushID-1) - if self.labelRoiReplaceExistingObjectsCheckbox.isChecked(): - IDs_touched_by_new_objects = np.unique(lab[roiLabSlice][roiLabMask]) - for ID in IDs_touched_by_new_objects: - lab[lab==ID] = 0 - - lab[roiLabSlice][roiLabMask] = roiLab[roiLabMask] - return lab - - @exception_handler - def labelRoiDone(self, roiSegmData, isTimeLapse): - self.setDisabled(False) - - posData = self.data[self.pos_i] - self.setBrushID() - - if isTimeLapse: - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.mainPbar.setValue(0) - current_frame_i = posData.frame_i - start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 - for i, roiLab in enumerate(roiSegmData): - frame_i = start_frame_i + i - lab = posData.allData_li[frame_i]['labels'] - store = True - if lab is None: - if frame_i >= len(posData.segm_data): - lab = np.zeros_like(posData.segm_data[0]) - posData.segm_data = np.append( - posData.segm_data, lab[np.newaxis], axis=0 - ) - else: - lab = posData.segm_data[frame_i] - store = False - roiLabSlice = self.labelRoiSlice[1:] - lab = self.indexRoiLab( - roiLab, roiLabSlice, lab, posData.brushID - ) - if store: - posData.frame_i = frame_i - posData.allData_li[frame_i]['labels'] = lab.copy() - self.get_data() - self.store_data(autosave=False) - - # Back to current frame - posData.frame_i = current_frame_i - self.get_data() - else: - roiLab = roiSegmData - posData.lab = self.indexRoiLab( - roiLab, self.labelRoiSlice, posData.lab, posData.brushID - ) - - self.update_rp() - - # Repeat tracking - if self.autoIDcheckbox.isChecked(): - self.tracking(enforce=True, assign_unique_new_IDs=False) - - self.store_data() - self.updateAllImages() - - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) - self.freeRoiItem.clear() - self.logger.info('Magic labeller done!') - self.app.restoreOverrideCursor() - - self.labelRoiRunning = False - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - uncheckLabelRoiTRange = ( - self.labelRoiTrangeCheckbox.isChecked() - and not self.labelRoiTrangeCheckbox.findChild(QAction).isChecked() - ) - if uncheckLabelRoiTRange: - self.labelRoiTrangeCheckbox.setChecked(False) - - def restoreHoverObjBrush(self): - posData = self.data[self.pos_i] - if self.ax1BrushHoverID in posData.IDs: - obj_idx = posData.IDs_idxs[self.ax1BrushHoverID] - obj = posData.rp[obj_idx] - if not self.isObjVisible(obj.bbox): - return - - self.addObjContourToContoursImage(obj=obj, ax=0) - self.addObjContourToContoursImage(obj=obj, ax=1) - - def hideItemsHoverBrush(self, xy=None, ID=None, force=False): - if xy is not None: - x, y = xy - if x is None: - return - - xdata, ydata = int(x), int(y) - Y, X = self.currentLab2D.shape - - if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): - return - - if not self.brushAutoHideCheckbox.isChecked() and not force: - return - - posData = self.data[self.pos_i] - size = self.brushSizeSpinbox.value()*2 - - if xy is not None: - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - - if self.ax1_lostObjScatterItem.isVisible(): - self.ax1_lostObjScatterItem.setVisible(False) - - if self.ax1_lostTrackedScatterItem.isVisible(): - self.ax1_lostTrackedScatterItem.setVisible(False) - - if self.ax2_lostObjScatterItem.isVisible(): - self.ax2_lostObjScatterItem.setVisible(False) - - if self.ax2_lostTrackedScatterItem.isVisible(): - self.ax2_lostTrackedScatterItem.setVisible(False) - - # Restore ID previously hovered - if ID != self.ax1BrushHoverID and not self.isMouseDragImg1: - try: - self.restoreHoverObjBrush() - except Exception as e: - self.ax1BrushHoverID = 0 - return - - # Hide items hover ID - if ID != 0: - self.clearObjContour(ID=ID, ax=0) - self.clearObjContour(ID=ID, ax=1) - self.ax1BrushHoverID = ID - else: - self.ax1BrushHoverID = 0 - - def updateBrushCursor(self, x, y, isHoverImg1=True): - if x is None: - return - - xdata, ydata = int(x), int(y) - _img = self.currentLab2D - Y, X = _img.shape - - if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): - return - - size = self.brushSizeSpinbox.value()*2 - self.setHoverToolSymbolData( - [x], [y], self.activeBrushCircleCursors(isHoverImg1), - size=size - ) - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - self.activeBrushCircleCursors(isHoverImg1), - self.brushButton, brush=self.ax2_BrushCircleBrush - ) - - def moveLabelButtonToggled(self, checked): - if not checked: - self.hoverLabelID = 0 - self.highlightedID = 0 - self.highLightIDLayerImg1.clear() - self.highLightIDLayerRightImage.clear() - self.setHighlightID(False) - - def setAllIDs(self, onlyVisited=False): - for posData in self.data: - posData.allIDs = set() - for frame_i in range(len(posData.segm_data)): - if frame_i >= len(posData.allData_li): - break - lab = posData.allData_li[frame_i]['labels'] - if lab is None and onlyVisited: - break - - if lab is None: - rp = skimage.measure.regionprops(posData.segm_data[frame_i]) - else: - rp = posData.allData_li[frame_i]['regionprops'] - posData.allIDs.update([obj.label for obj in rp]) - - def countObjectsTimelapse(self): - if self.countObjsWindow is None: - activeCategories = { - 'In current frame', - 'In all visited frames', - 'In entire video', - 'Unique objects in all visited frames', - 'Unique objects in entire video' - } - else: - activeCategories = self.countObjsWindow.activeCategories() - - posData = self.data[self.pos_i] - allCategoryCountMapper = posData.countObjectsInSegmTimelapse( - activeCategories - ) - if self.countObjsWindow is None: - return allCategoryCountMapper - - categoryCountMapper = {} - for category in activeCategories: - categoryCountMapper[category] = allCategoryCountMapper[category] - - return categoryCountMapper - - - def countObjectsSnapshots(self): - posData = self.data[self.pos_i] - if self.countObjsWindow is None: - activeCategories = { - 'In current position', - 'In all visited positions (current session)', - 'In all visited positions (previous sessions)', - 'In all loaded positions', - } - if self.isSegm3D: - activeCategories.add('In current z-slice') - else: - activeCategories = self.countObjsWindow.activeCategories() - - numObjectsCurrentPos = len(posData.IDs) - numObjectsAllPos = 0 - numObjectsVisitedPosPrevious = 0 - numObjectsVisitedPosCurrent = 0 - numObjectsCurrentZslice = None - if 'In current z-slice' in activeCategories: - numObjectsCurrentZslice = len( - skimage.measure.regionprops(self.currentLab2D) - ) - - for pos_i, _posData in enumerate(self.data): - IDs = _posData.allData_li[0]['IDs'] - if os.path.exists(_posData.acdc_output_csv_path): - numObjectsVisitedPosPrevious += len(IDs) - if IDs: - numObjs = len(IDs) - numObjectsAllPos += len(IDs) - else: - lab = _posData.segm_data[0] - rp = skimage.measure.regionprops(lab) - numObjs = len(rp) - numObjectsAllPos += numObjs - - if _posData.visited: - numObjectsVisitedPosCurrent += numObjs - - allCategoryCountMapper = { - 'In current position': numObjectsCurrentPos, - 'In all visited positions (current session)': - numObjectsVisitedPosCurrent, - 'In all visited positions (previous sessions)': - numObjectsVisitedPosPrevious, - 'In all loaded positions': numObjectsAllPos, - } - if numObjectsCurrentZslice is not None: - allCategoryCountMapper['In current z-slice'] = ( - numObjectsCurrentZslice - ) - - if self.countObjsWindow is None: - return allCategoryCountMapper - - categoryCountMapper = {} - for category in activeCategories: - categoryCountMapper[category] = allCategoryCountMapper[category] - - return categoryCountMapper - - def countObjects(self): - self.logger.info('Counting objects...') - - posData = self.data[self.pos_i] - if posData.SizeT > 1: - return self.countObjectsTimelapse() - - return self.countObjectsSnapshots() - - - def updateObjectCounts(self): - if self.countObjsWindow is None: - return - - if not self.countObjsWindow.isVisible(): - return - - if not self.countObjsWindow.livePreviewCheckbox.isChecked(): - return - - categoryCountMapper = self.countObjects() - self.countObjsWindow.updateCounts(categoryCountMapper) - - def keepIDs_cb(self, checked): - if checked: - self.highlightedLab = np.zeros_like(self.currentLab2D) - if self.annotCcaInfoCheckbox.isChecked(): - self.annotCcaInfoCheckbox.setChecked(False) - self.annotIDsCheckbox.setChecked(True) - self.setDrawAnnotComboboxText() - self.uncheckLeftClickButtons(None) - self.initKeepObjLabelsLayers() - self.setAllIDs() - else: - # restore items to non-grayed out - self.clearTempBrushImage() - alpha = self.imgGrad.labelsAlphaSlider.value() - self.labelsLayerImg1.setOpacity(alpha) - self.labelsLayerRightImg.setOpacity(alpha) - self.ax1_contoursImageItem.setOpacity(1.0) - self.ax2_contoursImageItem.setOpacity(1.0) - self.ax1_lostObjImageItem.setOpacity(1.0) - self.ax2_lostObjImageItem.setOpacity(1.0) - self.ax1_lostTrackedObjImageItem.setOpacity(1.0) - self.ax2_lostTrackedObjImageItem.setOpacity(1.0) - - self.keepIDsToolbar.setVisible(checked) - self.highlightedIDopts = None - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - self.updateAllImages() - - # QTimer.singleShot(300, self.autoRange) - - def get_curr_lab(self, curr_lab: np.ndarray|None = None, frame_i: int|None = None): - """Get the current labels for the position data. Hirarchically checks: - 1. If `curr_lab` is provided, use it. - 2. If `posData.lab` is not None, use it. - 3. If `posData.allData_li[frame_i]['labels']` exists, use it. - 4. If `posData.segm_data[frame_i]` exists, use it. - - If frame_i is None, uses the current frame index from `posData`. - - Parameters - ---------- - curr_lab : np.ndarray, optional - Current labels for the position data if it should be checked - if its not None first, by default None - frame_i : int, optional - Frame index to use for retrieving labels, by default None - - Returns - ------- - np.ndarray - Current labels for the position data - """ - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - if curr_lab is None and frame_i == posData.frame_i: - curr_lab = posData.lab - - if curr_lab is None: - try: - curr_lab = posData.allData_li[frame_i]['labels'].copy() - except: - pass - - if curr_lab is None: - try: - curr_lab = posData.segm_data[frame_i].copy() - except: - pass - - return curr_lab - - def setFrameNavigationDisabled(self, disable: bool, why: str): - """Disables the frame navigation buttons and scrollbar. - This is used when the user is not allowed to navigate through frames - Call again to unlock it again. Also sets tooltips to inform the user - - Parameters - ---------- - disable : bool - if the navigation should be disabled - why : str - the reason for disabeling the navigation. - """ - - if disable: - self.whyNavigateDisabled.add(why) - else: - try: - self.whyNavigateDisabled.remove(why) - except KeyError: - pass - - if len(self.whyNavigateDisabled) == 0: - disable = False - else: - disable = True - - # Apply the disable/enable state - self.prevAction.setDisabled(disable) - self.nextAction.setDisabled(disable) - self.navigateScrollBar.setDisabled(disable) - - # Set appropriate tooltip - if not disable: - self.navigateScrollBar.setToolTip( - 'NOTE: The maximum frame number that can be visualized with this ' - 'scrollbar\n' - 'is the last visited frame with the selected mode\n' - '(see "Mode" selector on the top-right).\n\n' - 'If the scrollbar does not move it means that you never visited\n' - 'any frame with current mode.\n\n' - 'Note that the "Viewer" mode allows you to scroll ALL frames.' - ) - return - - txt = f'Frame navigation disabled: {self.whyNavigateDisabled}' - self.logger.info(txt) - self.navigateScrollBar.setToolTip(txt) - - def delObjsOutSegmMaskActionTriggered(self): - posData = self.data[self.pos_i] - segm_files = load.get_segm_files(posData.images_path) - existingSegmEndnames = load.get_endnames( - posData.basename, segm_files - ) - selectSegmWin = widgets.QDialogListbox( - 'Select segmentation file', - 'Select segmentation file to use as ROI:\n', - existingSegmEndnames, multiSelection=False, parent=self - ) - selectSegmWin.exec_() - if selectSegmWin.cancel: - self.logger.info('Delete objects process cancelled.') - return - - selectedSegmEndname = selectSegmWin.selectedItemsText[0] - - self.startDelObjsOutSegmMaskWorker(selectedSegmEndname) - - def startDelObjsOutSegmMaskWorker(self, selectedSegmEndname): - self.store_data(autosave=False) - posData = self.data[self.pos_i] - segm_data = np.squeeze(self.getStoredSegmData()) - - self.progressWin = apps.QDialogWorkerProgress( - title='Deleting objects outside of ROIs', parent=self, - pbarDesc='Deleting objects outside of ROIs...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.thread = QThread() - self.worker = workers.DelObjectsOutsideSegmROIWorker( - selectedSegmEndname, segm_data, posData.images_path - ) - self.worker.moveToThread(self.thread) - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - self.worker.progress.connect(self.workerProgress) - self.worker.critical.connect(self.workerCritical) - self.worker.finished.connect(self.delObjsOutSegmMaskWorkerFinished) - - self.worker.debug.connect(self.workerDebug) - - self.thread.started.connect(self.worker.run) - self.thread.start() - - def storeViewRange(self): - if not hasattr(self, 'isRangeReset'): - return - - if not self.isRangeReset: - return - self.ax1_viewRange = self.ax1.viewRange() - self.isRangeReset = False - - def mergeObjs_cb(self, checked): - if not checked: - self.mergeObjsTempLine.setData([], []) - - def Brush_cb(self, checked): - if checked: - self.typingEditID = False - self.setDiskMask() - self.setHoverToolSymbolData( - [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX) - ) - self.updateBrushCursor(self.xHoverImg, self.yHoverImg) - self.setBrushID() - - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - c = self.defaultToolBarButtonColor - self.eraserButton.setStyleSheet(f'background-color: {c}') - self.connectLeftClickButtons() - self.setFocusGraphics() - else: - self.ax1_lostObjScatterItem.setVisible(True) - self.ax2_lostObjScatterItem.setVisible(True) - self.ax1_lostTrackedScatterItem.setVisible(True) - self.ax2_lostTrackedScatterItem.setVisible(True) - - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - self.resetCursors() - - self.showEditIDwidgets(checked) - self.enableSizeSpinbox(checked) - - def showEditIDwidgets(self, visible): - self.editIDLabelAction.setVisible(visible) - self.editIDspinboxAction.setVisible(visible) - self.autoIDcheckboxAction.setVisible(visible) - showToolbar = ( - visible - or self.brushSizeAction.isVisible() - or self.brushAutoFillAction.isVisible() - or self.brushAutoHideAction.isVisible() - ) - self.brushEraserToolBar.setVisible(showToolbar) - - def resetCursors(self): - self.ax1_cursor.setData([], []) - self.ax2_cursor.setData([], []) - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - def setDiskMask(self): - brushSize = self.brushSizeSpinbox.value() - # diam = brushSize*2 - # center = (brushSize, brushSize) - # diskShape = (diam+1, diam+1) - # diskMask = np.zeros(diskShape, bool) - # rr, cc = skimage.draw.disk(center, brushSize+1, shape=diskShape) - # diskMask[rr, cc] = True - self.diskMask = skimage.morphology.disk(brushSize, dtype=bool) - - def getDiskMask(self, xdata, ydata): - Y, X = self.currentLab2D.shape[-2:] - - brushSize = self.brushSizeSpinbox.value() - yBottom, xLeft = ydata-brushSize, xdata-brushSize - yTop, xRight = ydata+brushSize+1, xdata+brushSize+1 - - if xLeft<0: - if yBottom<0: - # Disk mask out of bounds top-left - diskMask = self.diskMask.copy() - diskMask = diskMask[-yBottom:, -xLeft:] - yBottom = 0 - elif yTop>Y: - # Disk mask out of bounds bottom-left - diskMask = self.diskMask.copy() - diskMask = diskMask[0:Y-yBottom, -xLeft:] - yTop = Y - else: - # Disk mask out of bounds on the left - diskMask = self.diskMask.copy() - diskMask = diskMask[:, -xLeft:] - xLeft = 0 - - elif xRight>X: - if yBottom<0: - # Disk mask out of bounds top-right - diskMask = self.diskMask.copy() - diskMask = diskMask[-yBottom:, 0:X-xLeft] - yBottom = 0 - elif yTop>Y: - # Disk mask out of bounds bottom-right - diskMask = self.diskMask.copy() - diskMask = diskMask[0:Y-yBottom, 0:X-xLeft] - yTop = Y - else: - # Disk mask out of bounds on the right - diskMask = self.diskMask.copy() - diskMask = diskMask[:, 0:X-xLeft] - xRight = X - - elif yBottom<0: - # Disk mask out of bounds on top - diskMask = self.diskMask.copy() - diskMask = diskMask[-yBottom:] - yBottom = 0 - - elif yTop>Y: - # Disk mask out of bounds on bottom - diskMask = self.diskMask.copy() - diskMask = diskMask[0:Y-yBottom] - yTop = Y - - else: - # Disk mask fully inside the image - diskMask = self.diskMask - - return yBottom, xLeft, yTop, xRight, diskMask - - def setBrushID(self, useCurrentLab=True, return_val=False): - # Make sure that the brushed ID is always a new one based on - # already visited frames - posData = self.data[self.pos_i] - wl_init = posData.whitelist and posData.whitelist.whitelistIDs - if useCurrentLab: - IDs_tot = set(posData.IDs) - if wl_init: - try: - IDs_tot.update(posData.whitelist.originalLabsIDs[posData.frame_i]) - except: - pass - try: - if posData.whitelist.whitelistIDs[posData.frame_i]: - IDs_tot.update(posData.whitelist.whitelistIDs[posData.frame_i]) - except: - pass - newID = max(IDs_tot, default=0) - else: - newID = 0 - for frame_i, storedData in enumerate(posData.allData_li): - if frame_i == posData.frame_i: - continue - lab = storedData['labels'] - if lab is not None: - rp = storedData['regionprops'] - IDs_tot = {obj.label for obj in rp} - if wl_init: - if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): - IDs_tot.update(posData.whitelist.originalLabsIDs[frame_i]) - if posData.whitelist.whitelistIDs[frame_i]: - IDs_tot.update(posData.whitelist.whitelistIDs[frame_i]) - _max = max(IDs_tot, default=0) - if _max > newID: - newID = _max - else: - break - - for y, x, manual_ID in posData.editID_info: - if manual_ID > newID: - newID = manual_ID - posData.brushID = newID+1 - if return_val: - return posData.brushID - - @disableWindow - def equalizeHist(self, checked=True): - self.img1.useEqualized = checked - - if not checked: - self.updateAllImages() - return - - self.logger.info('Equalizing image histogram...') - for pos_i, _posData in enumerate(self.data): - n_dim_img = _posData.img_data.ndim - _posData.equalized_img_data = preprocess.PreprocessedData() - for frame_i, img_frame in enumerate(_posData.img_data): - if n_dim_img == 4: - for z, img_z in enumerate(img_frame): - eq_img = skimage.exposure.equalize_adapthist(img_z) - _posData.equalized_img_data[frame_i][z] = eq_img - self.img1.updateMinMaxValuesEqualizedData( - self.data, pos_i, frame_i, z - ) - self.img1.updateMinMaxValuesEqualizedDataProjections( - self.data, pos_i, frame_i - ) - else: - eq_img = skimage.exposure.equalize_adapthist(img_frame) - _posData.equalized_img_data[frame_i] = eq_img - self.img1.updateMinMaxValuesEqualizedData( - self.data, pos_i, frame_i, None - ) - - self.updateAllImages() - - def curvTool_cb(self, checked): - posData = self.data[self.pos_i] - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.curvToolButton) - self.connectLeftClickButtons() - self.hoverLinSpace = np.linspace(0, 1, 1000) - self.curvPlotItem = pg.PlotDataItem(pen=self.newIDs_cpen) - self.curvHoverPlotItem = pg.PlotDataItem(pen=self.oldIDs_cpen) - self.curvAnchors = pg.ScatterPlotItem( - symbol='o', size=9, - brush=pg.mkBrush((255,0,0,50)), - pen=pg.mkPen((255,0,0), width=2), - hoverable=True, hoverPen=pg.mkPen((255,0,0), width=3), - hoverBrush=pg.mkBrush((255,0,0)), tip=None - ) - self.ax1.addItem(self.curvAnchors) - self.ax1.addItem(self.curvPlotItem) - self.ax1.addItem(self.curvHoverPlotItem) - self.splineHoverON = True - posData.curvPlotItems.append(self.curvPlotItem) - posData.curvAnchorsItems.append(self.curvAnchors) - posData.curvHoverItems.append(self.curvHoverPlotItem) - else: - self.splineHoverON = False - self.isRightClickDragImg1 = False - self.clearCurvItems() - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - self.showEditIDwidgets(checked) - - def updateHoverLabelCursor(self, x, y): - if x is None: - self.hoverLabelID = 0 - return - - xdata, ydata = int(x), int(y) - Y, X = self.currentLab2D.shape - if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): - return - - ID = self.currentLab2D[ydata, xdata] - self.hoverLabelID = ID - - if ID == 0: - if self.highlightedID != 0: - self.updateAllImages() - self.highlightedID = 0 - return - - if self.app.overrideCursor() != Qt.SizeAllCursor: - self.app.setOverrideCursor(Qt.SizeAllCursor) - - if not self.isMovingLabel: - self.highlightSearchedID(ID) - - def updateEraserCursor(self, x, y, xyLocked=None, isHoverImg1=True): - if x is None: - return - - xdata, ydata = int(x), int(y) - _img = self.currentLab2D - Y, X = _img.shape - - if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): - return - - size = self.brushSizeSpinbox.value()*2 - self.setHoverToolSymbolData( - [x], [y], self.activeEraserCircleCursors(isHoverImg1), - size=size - ) - self.setHoverToolSymbolData( - [x], [y], self.activeEraserXCursors(isHoverImg1), - size=int(size/2) - ) - - isMouseDrag = ( - self.isMouseDragImg1 or self.isMouseDragImg2 - ) - if isMouseDrag: - return - - if xyLocked is not None: - xdata, ydata = xyLocked - - self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, - self.activeEraserCircleCursors(isHoverImg1), - self.eraserButton, hoverRGB=None - ) - - def Eraser_cb(self, checked): - if checked: - self.setDiskMask() - self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) - self.updateEraserCursor(self.xHoverImg, self.yHoverImg) - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.sender()) - c = self.defaultToolBarButtonColor - self.brushButton.setStyleSheet(f'background-color: {c}') - self.connectLeftClickButtons() - else: - self.setHoverToolSymbolData( - [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX) - ) - self.resetCursors() - self.updateAllImages() - - self.showEditIDwidgets(checked) - self.enableSizeSpinbox(checked) - - def storeCurrentAnnotOptions_ax1(self, return_value=False): - if self.annotOptionsToRestore is not None: - return - - checkboxes = [ - 'annotIDsCheckbox', - 'annotCcaInfoCheckbox', - 'annotContourCheckbox', - 'annotSegmMasksCheckbox', - 'drawMothBudLinesCheckbox', - 'annotNumZslicesCheckbox', - 'drawNothingCheckbox', - ] - annotOptions = {} - for checkboxName in checkboxes: - checkbox = getattr(self, checkboxName) - annotOptions[checkboxName] = checkbox.isChecked() - if return_value: - return annotOptions - self.annotOptionsToRestore = annotOptions - - def storeCurrentAnnotOptions_ax2(self): - if self.annotOptionsToRestoreRight is not None: - return - - checkboxes = [ - 'annotIDsCheckboxRight', - 'annotCcaInfoCheckboxRight', - 'annotContourCheckboxRight', - 'annotSegmMasksCheckboxRight', - 'drawMothBudLinesCheckboxRight', - 'annotNumZslicesCheckboxRight', - 'drawNothingCheckboxRight', - ] - self.annotOptionsToRestoreRight = {} - for checkboxName in checkboxes: - checkbox = getattr(self, checkboxName) - self.annotOptionsToRestoreRight[checkboxName] = checkbox.isChecked() - - def restoreAnnotOptions_ax1(self, options=None): - if options is None and not hasattr(self, 'annotOptionsToRestore'): - return - - if options is None: - options = self.annotOptionsToRestore - - if options is None: - return - - for option, state in options.items(): - checkbox = getattr(self, option) - checkbox.setChecked(state) - - self.setDrawAnnotComboboxText() - self.annotOptionsToRestore = None - - def restoreAnnotOptions_ax2(self): - if not hasattr(self, 'annotOptionsToRestoreRight'): - return - - if self.annotOptionsToRestoreRight is None: - return - - for option, state in self.annotOptionsToRestoreRight.items(): - checkbox = getattr(self, option) - checkbox.setChecked(state) - - self.setDrawAnnotComboboxTextRight() - self.annotOptionsToRestoreRight = None - - def setDrawNothingAnnotations(self): - self.storeCurrentAnnotOptions_ax1() - self.storeCurrentAnnotOptions_ax2() - self.drawNothingCheckbox.setChecked(True) - self.annotOptionClicked( - sender=self.drawNothingCheckbox, saveSettings=False) - self.drawNothingCheckboxRight.setChecked(True) - self.annotOptionClickedRight( - sender=self.drawNothingCheckboxRight, saveSettings=False - ) - - def restoreAnnotationsOptions(self): - self.restoreAnnotOptions_ax1() - self.restoreAnnotOptions_ax2() - - def onDoubleSpaceBar(self): - how = self.drawIDsContComboBox.currentText() - if how.find('nothing') == -1: - self.storeCurrentAnnotOptions_ax1() - self.drawNothingCheckbox.setChecked(True) - self.annotOptionClicked( - sender=self.drawNothingCheckbox, saveSettings=False - ) - else: - self.restoreAnnotOptions_ax1() - - how = self.annotateRightHowCombobox.currentText() - if how.find('nothing') == -1: - self.storeCurrentAnnotOptions_ax2() - self.drawNothingCheckboxRight.setChecked(True) - self.annotOptionClickedRight( - sender=self.drawNothingCheckboxRight, saveSettings=False - ) - else: - self.restoreAnnotOptions_ax2() - - - def resizeBottomLayoutLineClicked(self, event): - pass - - def resizeBottomLayoutLineDragged(self, event): - if not self.img1BottomGroupbox.isVisible(): - return - newBottomLayoutHeight = self.bottomScrollArea.minimumHeight() - event.y() - self.bottomScrollArea.setFixedHeight(newBottomLayoutHeight) - - def resizeBottomLayoutLineReleased(self): - QTimer.singleShot(100, self.autoRange) - - def mousePressEvent(self, event) -> None: - if event.button() == Qt.MouseButton.RightButton: - pos = self.resizeBottomLayoutLine.mapFromGlobal(event.globalPos()) - if pos.y()>=0: - self.gui_raiseBottomLayoutContextMenu(event) - return super().mousePressEvent(event) - - def zoomBottomLayoutActionTriggered(self, checked): - if not checked: - return - perc = int(re.findall(r'(\d+)%', self.sender().text())[0]) - if perc != 100: - fontSizeFactor = perc/100 - heightFactor = perc/100 - self.resizeSlidersArea( - fontSizeFactor=fontSizeFactor, heightFactor=heightFactor - ) - else: - self.gui_resetBottomLayoutHeight() - self.df_settings.at['bottom_sliders_zoom_perc', 'value'] = perc - self.df_settings.to_csv(self.settings_csv_path) - QTimer.singleShot(150, self.resizeGui) - - def defaultRescaleIntensLutActionToggled(self, action): - how = action.text() - for rescaleIntensAction in self.imgGrad.rescaleActionGroup.actions(): - if how == rescaleIntensAction.text(): - rescaleIntensAction.setChecked(True) - rescaleIntensAction.trigger() - break - - for channel, items in self.overlayLayersItems.items(): - lutItem = items[1] - for rescaleIntensAction in lutItem.rescaleActionGroup.actions(): - if how == rescaleIntensAction.text(): - rescaleIntensAction.setChecked(True) - rescaleIntensAction.trigger() - break - - self.df_settings.at['default_rescale_intens_how', 'value'] = how - self.df_settings.to_csv(self.settings_csv_path) - - def retainSpaceSlidersToggled(self, checked): - if checked: - self.df_settings.at['retain_space_hidden_sliders', 'value'] = 'Yes' - else: - self.df_settings.at['retain_space_hidden_sliders', 'value'] = 'No' - self.df_settings.to_csv(self.settings_csv_path) - if not self.zSliceScrollBar.isEnabled(): - retainSpaceZ = False - else: - retainSpaceZ = checked - myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.zProjComboBox, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.overlay_z_label, retain=retainSpaceZ) - - QTimer.singleShot(200, self.resizeGui) - - def resizeLeaveSpaceTerminalBelow(self): - self.setWindowState(Qt.WindowMaximized) - QTimer.singleShot(200, self._resizeLeaveSpaceTerminalBelow) - - def _resizeLeaveSpaceTerminalBelow(self): - geometry = self.geometry() - left = geometry.left() - top = geometry.top() - width = geometry.width() - height = geometry.height() - self.setGeometry(left, top+10, width, height-200) - - def checkSetDelObjActionActive(self, event): - if self.delObjAction is None and self.is_win: - return - - if self.delObjAction is None: - # On mac we check for Key_Control - if event.key() == Qt.Key_Control: - self.delObjToolAction.setChecked(True) - return - - delObjKeySequence, delObjQtButton = self.delObjAction - keySequenceText = widgets.QKeyEventToString(event).rstrip('+') - - if delObjKeySequence is None: - # self.delObjToolAction.setChecked(True) - return - - delObjKeySequenceText = widgets.macShortcutToWindows( - delObjKeySequence.toString() - ) - keySequenceText = widgets.macShortcutToWindows(keySequenceText) - - # printl( - # delObjKeySequence.toString(), - # keySequenceText, - # delObjKeySequenceText - # ) - - if keySequenceText == delObjKeySequenceText: - self.delObjToolAction.setChecked(True) - - def changeRightClickToLeftOnMac(self, mouseEvent): - button = mouseEvent.button() - if not is_mac: - return button - - delObjKeySequence, delObjQtButton = self.delObjAction - if delObjKeySequence is None: - return button - - if not delObjKeySequence.toString() == 'Control': - return button - - if button != Qt.MouseButton.RightButton: - return button - - if delObjQtButton == Qt.MouseButton.LeftButton: - # On mac, pressing "Control" and clicking with left button changes - # it to a right click button --> here, left click is required for - # delete object --> force return of left click - return Qt.MouseButton.LeftButton - - return button - - - def checkTriggerKeyPressShortcuts(self, event: QKeyEvent): - isBrushKey = event.key() == self.brushButton.keyPressShortcut - isEraserKey = event.key() == self.eraserButton.keyPressShortcut - if isBrushKey or isEraserKey: - return isBrushKey, isEraserKey - - modifierText = widgets.modifierKeyToText(event.modifiers()) - for widget in self.widgetsWithShortcut.values(): - if not hasattr(widget, 'keyPressShortcut'): - continue - - if event.key() == widget.keyPressShortcut: - if widget.isCheckable(): - widget.setChecked(True) - else: - widget.trigger() - continue - - shortcutText = widget.keyPressShortcut.toString() - try: - mod, key = shortcutText.split('+') - if modifierText == mod and event.key() == QKeySequence(key): - widget.trigger() - - except Exception as e: - pass - - return isBrushKey, isEraserKey - - def _temp_debug(self, id=None): - posData = self.data[self.pos_i] - imshow(posData.lab, annotate_labels_idxs=[0]) - - def checkOverlayToolbuttonClicked(self, event): - success = False - try: - n = int(event.text()) - toolbutton = self.allOverlayToolbuttonsByIdx.get(n, None) - toolbutton.click() - success = True - except Exception as e: - # printl(traceback.format_exc()) - success = False - return success - - def keyPressCheckSetSpinboxValue(self, event, spinbox): - """Check if the key pressed is a digit and set the spinbox value - accordingly.""" - try: - n = int(event.text()) - if self.typingEditID: - value = int(f'{spinbox.value()}{n}') - else: - value = n - self.typingEditID = True - spinbox.setValue(value) - - try: - spinbox.timer.stop() - except Exception as err: - pass - - spinbox.timer = QTimer(spinbox) - spinbox.timer.timeout.connect( - self.editingSpinboxValueTimerCallback - ) - spinbox.timer.start(2000) - spinbox.timer.setSingleShot(True) - success = True - except Exception as e: - # printl(traceback.format_exc()) - success = False - return success - - def editingSpinboxValueTimerCallback(self): - self.typingEditID = False - - @exception_handler - def keyPressEvent(self, ev): - ctrl = ev.modifiers() == Qt.ControlModifier - if ctrl and ev.key() == Qt.Key_D: - self.resizeLeaveSpaceTerminalBelow() - return - - if ev.key() == Qt.Key_Q and self.debug: - try: - from . import _q_debug - _q_debug.q_debug(self) - except Exception as err: - printl(traceback.format_exc()) - printl('[ERROR]: Error with "_qdebug" module. See Traceback above.') - pass - - if not self.isDataLoaded: - self.logger.warning( - 'Data not loaded yet. Key pressing events are not connected.' - ) - return - - if ev.key() == Qt.Key_Control: - if not ctrl: - self.wasCtrlPressedFirstTime = True - self.onCtrlPressedFirstTime() - - if ev.key() == Qt.Key_PageDown: - self.onKeyPageDown() - - if ev.key() == Qt.Key_PageUp: - self.onKeyPageUp() - - if ev.key() == Qt.Key_Home: - self.onKeyHome() - - if ev.key() == Qt.Key_End: - self.onKeyEnd() - - modifiers = ev.modifiers() - isAltModifier = modifiers == Qt.AltModifier - isCtrlModifier = modifiers == Qt.ControlModifier - isShiftModifier = modifiers == Qt.ShiftModifier - - self.checkSetDelObjActionActive(ev) - - self.isZmodifier = ( - ev.key()== Qt.Key_Z and not isAltModifier - and not isCtrlModifier and not isShiftModifier - ) - if isShiftModifier: - if self.brushButton.isChecked(): - # Force default brush symbol with shift down - self.setHoverToolSymbolColor( - 1, 1, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush, - ID=0 - ) - if self.isSegm3D: - self.changeBrushID() - - isAnyModifier = isAltModifier or isCtrlModifier or isShiftModifier - if not isAnyModifier and self.overlayButton.isChecked(): - isButtonClicked = self.checkOverlayToolbuttonClicked(ev) - if isButtonClicked: - return - - isBrushActive = ( - self.brushButton.isChecked() or self.eraserButton.isChecked() - ) - isManualTrackingActive = self.manualTrackingButton.isChecked() - isManualBackgroundActive = self.manualBackgroundButton.isChecked() - isTypingIDFunctionChecked = False - if self.brushButton.isChecked() and not self.autoIDcheckbox.isChecked(): - success = self.keyPressCheckSetSpinboxValue(ev, self.editIDspinbox) - isTypingIDFunctionChecked = True - - if isManualTrackingActive: - isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( - ev, self.manualTrackingToolbar.spinboxID - ) - - elif isManualBackgroundActive: - isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( - ev, self.manualBackgroundToolbar.spinboxID - ) - - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - if ( - addPointsByClickingButton is not None - and addPointsByClickingButton.toolbar.isVisible() - ): - isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( - ev, addPointsByClickingButton.rightClickIDSpinbox - ) - - isBrushKey, isEraserKey = self.checkTriggerKeyPressShortcuts(ev) - isExpandLabelActive = self.expandLabelToolButton.isChecked() - isWandActive = self.wandToolButton.isChecked() - isLabelRoiCircActive = ( - self.labelRoiButton.isChecked() - and self.labelRoiIsCircularRadioButton.isChecked() - ) - how = self.drawIDsContComboBox.currentText() - isOverlaySegm = how.find('overlay segm. masks') != -1 - if ev.key()==Qt.Key_Up and not isCtrlModifier: - self.keyUpCallback( - isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive - ) - elif ev.key()==Qt.Key_Down and not isCtrlModifier: - self.keyDownCallback( - isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive - ) - elif ev.key() == Qt.Key_Enter or ev.key() == Qt.Key_Return: - if isTypingIDFunctionChecked: - self.typingEditID = False - elif self.keepIDsButton.isChecked(): - self.keepIDsConfirmAction.trigger() - elif ev.key() == Qt.Key_Escape: - self.onEscape(isTypingIDFunctionChecked=isTypingIDFunctionChecked) - elif isAltModifier: - isCursorSizeAll = self.app.overrideCursor() == Qt.SizeAllCursor - # Alt is pressed while cursor is on images --> set SizeAllCursor - if self.xHoverImg is not None and not isCursorSizeAll: - self.app.setOverrideCursor(Qt.SizeAllCursor) - elif isCtrlModifier and isOverlaySegm: - if ev.key() == Qt.Key_Up: - val = self.imgGrad.labelsAlphaSlider.value() - delta = 5/self.imgGrad.labelsAlphaSlider.maximum() - val = val+delta - self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) - elif ev.key() == Qt.Key_Down: - val = self.imgGrad.labelsAlphaSlider.value() - delta = 5/self.imgGrad.labelsAlphaSlider.maximum() - val = val-delta - self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) - elif ev.key() == self.zoomOutKeyValue: - self.zoomToCells(enforce=True) - if self.countKeyPress == 0: - self.isKeyDoublePress = False - self.countKeyPress = 1 - self.doubleKeyTimeElapsed = False - self.Button = None - QTimer.singleShot(400, self.doubleKeyTimerCallBack) - elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed: - self.ax1.autoRange() - self.isKeyDoublePress = True - self.countKeyPress = 0 - elif ev.key() == Qt.Key_Space: - if self.countKeyPress == 0: - # Single press --> wait that it's not double press - self.isKeyDoublePress = False - self.countKeyPress = 1 - self.doubleKeyTimeElapsed = False - QTimer.singleShot(300, self.doubleKeySpacebarTimerCallback) - elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed: - self.isKeyDoublePress = True - # Double press --> toggle draw nothing - self.onDoubleSpaceBar() - self.countKeyPress = 0 - elif isBrushKey or isEraserKey: - if isBrushKey: - self.Button = self.brushButton - else: - self.Button = self.eraserButton - - if not self.Button.isVisible(): - return - - if self.countKeyPress == 0: - # If first time clicking B activate brush and start timer - # to catch double press of B - if not self.Button.isChecked(): - self.uncheck = False - self.Button.setChecked(True) - else: - self.uncheck = True - self.countKeyPress = 1 - self.isKeyDoublePress = False - self.doubleKeyTimeElapsed = False - - QTimer.singleShot(400, self.doubleKeyTimerCallBack) - elif self.countKeyPress == 1 and not self.doubleKeyTimeElapsed: - self.isKeyDoublePress = True - color = self.Button.palette().button().color().name() - if color == self.doublePressKeyButtonColor: - c = self.defaultToolBarButtonColor - else: - c = self.doublePressKeyButtonColor - self.Button.setStyleSheet(f'background-color: {c}') - self.countKeyPress = 0 - if self.xHoverImg is not None: - xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) - if isBrushKey: - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush - ) - elif isEraserKey: - self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, - (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton - ) - - def doubleRightClickTimerCallBack(self): - if self.isDoubleRightClick: - self.doubleRightClickTimeElapsed = False - return - self.doubleRightClickTimeElapsed = True - self.countRightClicks = 0 - - # Time to double right click on img1 expired --> single right-click - self.gui_imgGradShowContextMenu(*self._img1_click_xy) - - def doubleKeyTimerCallBack(self): - if self.isKeyDoublePress: - self.doubleKeyTimeElapsed = False - return - self.doubleKeyTimeElapsed = True - self.countKeyPress = 0 - if self.Button is None: - return - - isBrushChecked = self.Button.isChecked() - if isBrushChecked and self.uncheck: - self.Button.setChecked(False) - c = self.defaultToolBarButtonColor - self.Button.setStyleSheet(f'background-color: {c}') - - def doubleKeySpacebarTimerCallback(self): - if self.isKeyDoublePress: - self.doubleKeyTimeElapsed = False - return - self.doubleKeyTimeElapsed = True - self.countKeyPress = 0 - - # # Spacebar single press --> toggle next visualization - # currentIndex = self.drawIDsContComboBox.currentIndex() - # nItems = self.drawIDsContComboBox.count() - # nextIndex = currentIndex+1 - # if nextIndex < nItems: - # self.drawIDsContComboBox.setCurrentIndex(nextIndex) - # else: - # self.drawIDsContComboBox.setCurrentIndex(0) - - def updateBrushCursorOnShiftRelease(self): - xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) - self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, - (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush, - byPassShiftCheck=True - ) - if self.isSegm3D: - self.changeBrushID() - - def onShiftReleased(self): - if self.brushButton.isChecked() and self.xHoverImg is not None: - self.updateBrushCursorOnShiftRelease() - - def keyReleaseEvent(self, ev): - if self.app.overrideCursor() == Qt.SizeAllCursor: - self.app.restoreOverrideCursor() - if ev.key() == Qt.Key_Control: - self.onCtrlReleased() - elif ev.key() == Qt.Key_Shift: - self.onShiftReleased() - - canRepeat = ( - ev.key() == Qt.Key_Left - or ev.key() == Qt.Key_Right - or ev.key() == Qt.Key_Up - or ev.key() == Qt.Key_Down - or ev.key() == Qt.Key_Control - or ev.key() == Qt.Key_Backspace - or self.delObjToolAction.isChecked() - ) - - if canRepeat and ev.isAutoRepeat(): - return - - self.delObjToolAction.setChecked(False) - - if ev.isAutoRepeat() and not ev.key() == Qt.Key_Z: - if self.warnKeyPressedMsg is not None: - return - self.warnKeyPressedMsg = widgets.myMessageBox( - showCentered=False, wrapText=False - ) - txt = html_utils.paragraph(f""" - Please, do not keep the key "{ev.text().upper()}" - pressed.

- It confuses me :)

- Thanks! - """) - self.warnKeyPressedMsg.warning(self, 'Release the key, please', txt) - self.warnKeyPressedMsg = None - elif ev.isAutoRepeat() and ev.key() == Qt.Key_Z and self.isZmodifier: - self.zKeptDown = True - elif ev.key() == Qt.Key_Z and self.isZmodifier: - posData = self.data[self.pos_i] - self.isZmodifier = False - if not self.zKeptDown and posData.SizeZ > 1: - self.zSliceCheckbox.setChecked(not self.zSliceCheckbox.isChecked()) - self.zKeptDown = False - - def setUncheckedAllButtons(self, buttonsToNotUncheck=None): - self.clickedOnBud = False - if buttonsToNotUncheck is None: - buttonsToNotUncheck = set() - - try: - self.BudMothTempLine.setData([], []) - except Exception as e: - pass - for button in self.checkableButtons: - if button in buttonsToNotUncheck: - continue - button.setChecked(False) - - if self.countObjsButton not in buttonsToNotUncheck: - self.countObjsButton.setChecked(False) - self.splineHoverON = False - self.tempSegmentON = False - self.isRightClickDragImg1 = False - self.clearCurvItems(removeItems=False) - - def setUncheckedAllCustomAnnotButtons(self): - for button in self.customAnnotDict.keys(): - button.setChecked(False) - - def askPropagateChangePast(self, change_txt): - txt = html_utils.paragraph(f""" - Do you want to propagate the change "{change_txt}" to the past frames? - """) - msg = widgets.myMessageBox(wrapText=False) - yesButton, _ = msg.question( - self, 'Propagate change to past frames', txt, - buttonsTexts=('Yes', 'No') - ) - return msg.clickedButton == yesButton - - def propagateMergeObjsPast(self, IDs_to_merge): - self.store_data(autosave=False) - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - for past_frame_i in range(posData.frame_i-1, -1, -1): - posData.frame_i = past_frame_i - self.get_data() - - IDs = posData.allData_li[past_frame_i]['IDs'] - stop_loop = False - for ID in IDs_to_merge: - if ID not in IDs: - stop_loop = True - break - - if ID == 0: - continue - posData.lab[posData.lab==ID] = self.firstID - self.update_rp() - - self.store_data(autosave=False) - - if stop_loop: - break - - posData.frame_i = current_frame_i - self.get_data() - - def propagateChange( - self, modID, modTxt, doNotShow, UndoFutFrames, - applyFutFrames, applyTrackingB=False, force=False - ): - """ - This function determines whether there are already visited future frames - that contains "modID". If so, it triggers a pop-up asking the user - what to do (propagate change to future frames o not) - """ - posData = self.data[self.pos_i] - # Do not check the future for the last frame - if posData.frame_i+1 == posData.SizeT: - # No future frames to propagate the change to - return False, False, None, doNotShow - - includeUnvisited = posData.includeUnvisitedInfo.get(modTxt, False) - areFutureIDs_affected = [] - # Get number of future frames already visited and check if future - # frames has an ID affected by the change - last_tracked_i_found = False - segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i+1, segmSizeT): - if posData.allData_li[i]['labels'] is None: - if not last_tracked_i_found: - # We set last tracked frame at -1 first None found - last_tracked_i = i - 1 - last_tracked_i_found = True - if not includeUnvisited: - # Stop at last visited frame since includeUnvisited = False - break - else: - lab = posData.segm_data[i] - else: - lab = posData.allData_li[i]['labels'] - - if modID in lab: - areFutureIDs_affected.append(True) - - if not last_tracked_i_found: - # All frames have been visited in segm&track mode - last_tracked_i = posData.SizeT - 1 - - if last_tracked_i == posData.frame_i and not includeUnvisited: - # No future frames to propagate the change to - return False, False, None, doNotShow - - if not areFutureIDs_affected and not force: - # There are future frames but they are not affected by the change - return UndoFutFrames, False, None, doNotShow - - # Ask what to do unless the user has previously checked doNotShowAgain - if doNotShow: - endFrame_i = last_tracked_i - if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': - self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) - return UndoFutFrames, applyFutFrames, endFrame_i, doNotShow - else: - addApplyAllButton = ( - modTxt == 'Delete ID' or modTxt == 'Edit ID' - or modTxt == 'Assign new ID' - ) - ffa = apps.FutureFramesAction_QDialog( - posData.frame_i+1, last_tracked_i, modTxt, - applyTrackingB=applyTrackingB, parent=self, - addApplyAllButton=addApplyAllButton - ) - ffa.exec_() - decision = ffa.decision - - if decision is None: - return None, None, None, doNotShow - - endFrame_i = ffa.endFrame_i - doNotShowAgain = ffa.doNotShowCheckbox.isChecked() - askAction = self.askHowFutureFramesActions[modTxt] - askAction.setChecked( not doNotShowAgain) - askAction.setDisabled(False) - - self.onlyTracking = False - if decision == 'apply_and_reinit': - UndoFutFrames = True - applyFutFrames = False - elif decision == 'apply_and_NOTreinit': - UndoFutFrames = False - applyFutFrames = False - elif decision == 'apply_to_all_visited': - UndoFutFrames = False - applyFutFrames = True - elif decision == 'only_tracking': - UndoFutFrames = False - applyFutFrames = True - self.onlyTracking = True - elif decision == 'apply_to_all': - UndoFutFrames = False - applyFutFrames = True - posData.includeUnvisitedInfo[modTxt] = True - - if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': - self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) - return UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain - - def addCcaState(self, frame_i, cca_df, undoId): - posData = self.data[self.pos_i] - posData.UndoRedoCcaStates[frame_i].insert( - 0, {'id': undoId, 'cca_df': cca_df.copy()} - ) - - def addCurrentState(self, storeImage=False, storeOnlyZoom=False): - posData = self.data[self.pos_i] - if posData.cca_df is not None: - cca_df = posData.cca_df.copy() - else: - cca_df = None - - if storeImage: - image = self.img1.image.copy() - else: - image = None - - if storeOnlyZoom: - labels, crop_slice = transformation.crop_2D( - self.currentLab2D, self.ax1.viewRange(), tolerance=10, - return_copy=False - ) - if self.isSegm3D: - z = self.z_lab(checkIfProj=True) - if z is None: - z_slice = slice(0, len(posData.lab)) - crop_slice = (z_slice, *crop_slice) - labels = posData.lab[crop_slice].copy() - else: - z_slice = z - crop_slice = (z_slice, *crop_slice) - labels = labels.copy() - else: - labels = labels.copy() - else: - labels = posData.lab.copy() - crop_slice = None - - state = { - 'image': image, - 'labels': labels, - 'editID_info': posData.editID_info.copy(), - 'binnedIDs': posData.binnedIDs.copy(), - 'keptObejctsIDs': self.keptObjectsIDs.copy(), - 'ripIDs': posData.ripIDs.copy(), - 'cca_df': cca_df, - 'crop_slice': crop_slice - } - posData.UndoRedoStates[posData.frame_i].insert(0, state) - - # posData.storedLab = np.array(posData.lab, order='K', copy=True) - # self.storeStateWorker.callbackOnDone = callbackOnDone - # self.storeStateWorker.enqueue(posData, self.img1.image) - - def getCurrentState(self): - posData = self.data[self.pos_i] - i = posData.frame_i - c = self.UndoCount - state = posData.UndoRedoStates[i][c] - if state['image'] is None: - image_left = None - else: - image_left = state['image'].copy() - - crop_slice = state['crop_slice'] - if crop_slice is None: - posData.lab = state['labels'].copy() - elif self.isSegm3D: - z_slice, slice_y, slice_x = crop_slice - posData.lab[..., z_slice, slice_y, slice_x] = state['labels'].copy() - else: - slice_y, slice_x = crop_slice - posData.lab[..., slice_y, slice_x] = state['labels'].copy() - - posData.editID_info = state['editID_info'].copy() - posData.binnedIDs = state['binnedIDs'].copy() - posData.ripIDs = state['ripIDs'].copy() - self.keptObjectsIDs = state['keptObejctsIDs'].copy() - cca_df = state['cca_df'] - if cca_df is not None: - posData.cca_df = state['cca_df'].copy() - else: - posData.cca_df = None - return image_left - - def storeLabelRoiParams(self, value=None, checked=True): - checkedRoiType = self.labelRoiTypesGroup.checkedButton().text() - circRoiRadius = self.labelRoiCircularRadiusSpinbox.value() - roiZdepth = self.labelRoiZdepthSpinbox.value() - autoClearBorder = self.labelRoiAutoClearBorderCheckbox.isChecked() - clearBorder = 'Yes' if autoClearBorder else 'No' - self.df_settings.at['labelRoi_checkedRoiType', 'value'] = checkedRoiType - self.df_settings.at['labelRoi_circRoiRadius', 'value'] = circRoiRadius - self.df_settings.at['labelRoi_roiZdepth', 'value'] = roiZdepth - self.df_settings.at['labelRoi_autoClearBorder', 'value'] = clearBorder - self.df_settings.at['labelRoi_replaceExistingObjects', 'value'] = ( - 'Yes' if self.labelRoiReplaceExistingObjectsCheckbox.isChecked() - else 'No' - ) - self.df_settings.to_csv(self.settings_csv_path) - - def loadLabelRoiLastParams(self): - idx = 'labelRoi_checkedRoiType' - if idx in self.df_settings.index: - checkedRoiType = self.df_settings.at[idx, 'value'] - for button in self.labelRoiTypesGroup.buttons(): - if button.text() == checkedRoiType: - button.setChecked(True) - break - - idx = 'labelRoi_circRoiRadius' - if idx in self.df_settings.index: - circRoiRadius = self.df_settings.at[idx, 'value'] - self.labelRoiCircularRadiusSpinbox.setValue(int(circRoiRadius)) - - idx = 'labelRoi_roiZdepth' - if idx in self.df_settings.index: - roiZdepth = self.df_settings.at[idx, 'value'] - self.labelRoiZdepthSpinbox.setValue(int(roiZdepth)) - - idx = 'labelRoi_autoClearBorder' - if idx in self.df_settings.index: - clearBorder = self.df_settings.at[idx, 'value'] - checked = clearBorder == 'Yes' - self.labelRoiAutoClearBorderCheckbox.setChecked(checked) - - idx = 'labelRoi_replaceExistingObjects' - if idx in self.df_settings.index: - val = self.df_settings.at[idx, 'value'] - checked = val == 'Yes' - self.labelRoiReplaceExistingObjectsCheckbox.setChecked(checked) - - if self.labelRoiIsCircularRadioButton.isChecked(): - self.labelRoiCircularRadiusSpinbox.setDisabled(False) - - # @exec_time - def storeUndoRedoStates( - self, UndoFutFrames, storeImage=False, storeOnlyZoom=False - ): - posData = self.data[self.pos_i] - if UndoFutFrames: - # Since we modified current frame all future frames that were already - # visited are not valid anymore. Undo changes there - self.reInitLastSegmFrame(updateImages=False) - - # Keep only 5 Undo/Redo states - if len(posData.UndoRedoStates[posData.frame_i]) > 5: - posData.UndoRedoStates[posData.frame_i].pop(-1) - - # Restart count from the most recent state (index 0) - # NOTE: index 0 is most recent state before doing last change - self.UndoCount = 0 - self.undoAction.setEnabled(True) - self.addCurrentState( - storeImage=storeImage, storeOnlyZoom=storeOnlyZoom - ) - - def storeUndoRedoCca(self, frame_i, cca_df, undoId): - if self.isSnapshot: - # For snapshot mode we don't store anything because we have only - # segmentation undo action active - return - """ - Store current cca_df along with a unique id to know which cca_df needs - to be restored - """ - - posData = self.data[self.pos_i] - - # Restart count from the most recent state (index 0) - # NOTE: index 0 is most recent state before doing last change - self.UndoCcaCount = 0 - self.undoAction.setEnabled(True) - - self.addCcaState(frame_i, cca_df, undoId) - - # Keep only 10 Undo/Redo states - if len(posData.UndoRedoCcaStates[frame_i]) > 10: - posData.UndoRedoCcaStates[frame_i].pop(-1) - - def undoCustomAnnotation(self): - pass - - def UndoCca(self): - posData = self.data[self.pos_i] - # Undo current ccaState - storeState = False - if self.UndoCount == 0: - undoId = uuid.uuid4() - self.addCcaState(posData.frame_i, posData.cca_df, undoId) - storeState = True - - - # Get previously stored state - self.UndoCount += 1 - currentCcaStates = posData.UndoRedoCcaStates[posData.frame_i] - prevCcaState = currentCcaStates[self.UndoCount] - posData.cca_df = prevCcaState['cca_df'] - self.store_cca_df() - self.updateAllImages() - - # Check if we have undone all states - if len(currentCcaStates) > self.UndoCount: - # There are no states left to undo for current frame_i - self.undoAction.setEnabled(False) - - # Undo all past and future frames that has a last status inserted - # when modyfing current frame - prevStateId = prevCcaState['id'] - for frame_i in range(0, posData.SizeT): - if storeState: - cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) - if cca_df_i is None: - break - # Store current state to enable redoing it - self.addCcaState(frame_i, cca_df_i, undoId) - - CcaStates_i = posData.UndoRedoCcaStates[frame_i] - if len(CcaStates_i) <= self.UndoCount: - # There are no states to undo for frame_i - continue - - CcaState_i = CcaStates_i[self.UndoCount] - id_i = CcaState_i['id'] - if id_i != prevStateId: - # The id of the state in frame_i is different from current frame - continue - - cca_df_i = CcaState_i['cca_df'] - self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) - - self.resetWillDivideInfo() - self.enqAutosave() - - def undo(self): - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - if addPointsByClickingButton is not None: - done = self.undoAddPoint(addPointsByClickingButton.action) - if done: - return - - if self.UndoCount == 0: - # Store current state to enable redoing it - self.addCurrentState() - - posData = self.data[self.pos_i] - # Get previously stored state - if self.UndoCount < len(posData.UndoRedoStates[posData.frame_i])-1: - self.UndoCount += 1 - # Since we have undone then it is possible to redo - self.redoAction.setEnabled(True) - - # Restore state - image_left = self.getCurrentState() - self.update_rp() - self.updateAllImages(image=image_left) - self.store_data() - - if not self.UndoCount < len(posData.UndoRedoStates[posData.frame_i])-1: - # We have undone all available states - self.undoAction.setEnabled(False) - - if self.whitelistIDsButton.isChecked(): - self.whitelistHighlightIDs() - - def redo(self): - posData = self.data[self.pos_i] - # Get previously stored state - if self.UndoCount > 0: - self.UndoCount -= 1 - # Since we have redone then it is possible to undo - self.undoAction.setEnabled(True) - - # Restore state - image_left = self.getCurrentState() - self.update_rp() - self.updateAllImages(image=image_left) - self.store_data() - - if not self.UndoCount > 0: - # We have redone all available states - self.redoAction.setEnabled(False) - - if self.whitelistIDsButton.isChecked(): - self.whitelistHighlightIDs() - - def realTimeTrackingClicked(self, checked): - # Event called ONLY if the user click on Disable tracking - # NOT called if setChecked is called. This allows to keep track - # of the user choice. This way user con enforce tracking - # NOTE: I know two booleans doing the same thing is overkill - # but the code is more readable when we actually need them - - posData = self.data[self.pos_i] - isRealTimeTrackingDisabled = not checked - - # Turn off smart tracking - self.enableSmartTrackAction.toggled.disconnect() - self.enableSmartTrackAction.setChecked(False) - if isRealTimeTrackingDisabled: - self.UserEnforced_DisabledTracking = True - self.UserEnforced_Tracking = False - else: - txt = html_utils.paragraph(""" - - Do you want to keep tracking always active including on already - visited frames?

- Note: To re-activate automatic handling of tracking go to
- Edit --> Smart handling of enabling/disabling tracking. - - """) - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - yesButton, noButton = msg.question( - self, 'Keep tracking always active?', txt, - buttonsTexts=('Yes', 'No') - ) - if msg.clickedButton == yesButton: - self.repeatTracking() - self.UserEnforced_DisabledTracking = False - self.UserEnforced_Tracking = True - else: - self.enableSmartTrackAction.setChecked(True) - - @exception_handler - def repeatTrackingVideo(self, checked=False): - posData = self.data[self.pos_i] - win = widgets.selectTrackerGUI( - posData.SizeT, currentFrameNo=posData.frame_i+1 - ) - win.exec_() - if win.cancel: - self.logger.info('Tracking aborted.') - return - - trackerName = win.selectedItemsText[0] - start_n = win.startFrame - stop_n = win.stopFrame - video_to_track = posData.segm_data - for frame_i in range(start_n-1, stop_n): - data_dict = posData.allData_li[frame_i] - lab = data_dict['labels'] - if lab is None: - break - - video_to_track[frame_i] = lab - video_to_track = video_to_track[start_n-1:stop_n] - - self.logger.info(f'Importing {trackerName} tracker...') - self.tracker, self.track_params, init_params = myutils.init_tracker( - posData, trackerName, qparent=self, return_init_params=True - ) - if self.track_params is None: - self.logger.info('Tracking aborted.') - return - - warningText = myutils.validate_tracker_input( - self.tracker, video_to_track - ) - if warningText is not None: - self.logger.info(warningText) - self.warnTrackerInputNotValid(trackerName, warningText) - return - - if 'image_channel_name' in self.track_params: - # Remove the channel name since it was already loaded in init_tracker - del self.track_params['image_channel_name'] - - track_params_log = { - key: value for key, value in self.track_params.items() - if key != 'image' - } - self.logger.info( - 'Tracking parameters:\n\n' - f'Initialization parameters: {init_params}\n' - f'Track parameters: {track_params_log}' - ) - - last_cca_i = self.get_last_cca_frame_i() - if start_n-2 <= last_cca_i and start_n>1: - proceed = self.warnRepeatTrackingVideoWithAnnotations( - last_cca_i, start_n - ) - if not proceed: - self.logger.info('Tracking aborted.') - return - - self.logger.info(f'Removing annotations from frame n. {start_n}.') - self.resetCcaFuture(start_n-1) - - self.start_n = start_n - self.stop_n = stop_n - - info_txt = f'Tracking from frame n. {start_n} to {stop_n}...' - self.logger.info(info_txt) - - self.progressWin = apps.QDialogWorkerProgress( - title='Tracking', parent=self, pbarDesc=info_txt - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stop_n-start_n) - self.startTrackingWorker(posData, video_to_track) - - def warnTrackerInputNotValid(self, trackerName, warningText): - msg = widgets.myMessageBox(wrapText=False) - txt = warningText.replace('\n', '
') - txt = html_utils.paragraph( - f'{txt}

' - 'Tracking process will be cancelled. Thank you for your patience!' - ) - msg.warning(self, 'Invalid input for tracker', txt) - - def repeatTracking(self): - posData = self.data[self.pos_i] - prev_lab = self.get_2Dlab(posData.lab).copy() - self.tracking(enforce=True, DoManualEdit=False) - if posData.editID_info: - editedIDsInfo = { - posData.lab[y,x]:newID - for y, x, newID in posData.editID_info - if posData.lab[y,x] != newID - } - editedIDsInfoItems = [ - f'ID {oldID} --> {newID}' - for oldID, newID in editedIDsInfo.items() - ] - editIDul = html_utils.to_list(editedIDsInfoItems) - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - You requested to repeat tracking but there are manually - edited IDs (see edited IDs in the details section below) -

- Do you want to keep these edits or ignore them? - """) - keepManualEditButton = widgets.okPushButton( - 'Keep manually edited IDs' - ) - ignoreButton = widgets.noPushButton( - 'Ignore manually edited IDs' - ) - msg.question( - self, 'Repeat tracking mode', txt, - buttonsTexts=(keepManualEditButton, ignoreButton), - detailsText=editIDul - ) - if msg.cancel: - return - if msg.clickedButton == keepManualEditButton: - allIDs = [obj.label for obj in posData.rp] - lab2D = self.get_2Dlab(posData.lab) - self.manuallyEditTracking(lab2D, allIDs) - self.update_rp() - self.setAllTextAnnotations() - self.highlightLostNew() - # self.checkIDsMultiContour() - else: - posData.editID_info = [] - if np.any(posData.lab != prev_lab): - if self.isSnapshot: - self.fixCcaDfAfterEdit('Repeat tracking') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Repeat tracking') - else: - self.updateAllImages() - - def updateGhostMaskOpacity(self, alpha_percentage=None): - if alpha_percentage is None: - alpha_percentage = ( - self.manualTrackingToolbar.ghostMaskOpacitySpinbox.value() - ) - alpha = alpha_percentage/100 - self.ghostMaskItemLeft.setOpacity(alpha) - self.ghostMaskItemRight.setOpacity(alpha) - - def addManualTrackingItems(self): - self.ghostContourItemLeft.addToPlotItem() - self.ghostContourItemRight.addToPlotItem() - - self.ghostMaskItemLeft.addToPlotItem() - self.ghostMaskItemRight.addToPlotItem() - - Y, X = self.img1.image.shape[:2] - self.ghostMaskItemLeft.initImage((Y, X)) - self.ghostMaskItemRight.initImage((Y, X)) - - self.updateGhostMaskOpacity() - - def removeManualTrackingItems(self): - self.ghostContourItemLeft.removeFromPlotItem() - self.ghostContourItemRight.removeFromPlotItem() - - self.ghostMaskItemLeft.removeFromPlotItem() - self.ghostMaskItemRight.removeFromPlotItem() - - def addManualBackgroundItems(self): - self.manualBackgroundObjItem.addToPlotItem() - self.ax1.addItem(self.manualBackgroundImageItem) - - def removeManualBackgroundItems(self): - self.manualBackgroundObjItem.removeFromPlotItem() - self.ax1.removeItem(self.manualBackgroundImageItem) - - def resetManualBackgroundSpinboxID(self): - if not self.manualBackgroundButton.isChecked(): - self.manualBackgroundObj = None - return - - posData = self.data[self.pos_i] - minID = min(posData.IDs, default=0) - self.manualBackgroundToolbar.spinboxID.setValue(minID) - - def initManualBackgroundObject(self, ID=None): - if not self.manualBackgroundButton.isChecked(): - self.manualBackgroundObj = None - return - - if ID is None: - ID = self.manualBackgroundToolbar.spinboxID.value() - - posData = self.data[self.pos_i] - if ID not in posData.IDs: - self.manualBackgroundObj = None - self.manualBackgroundToolbar.showWarning( - f'The ID {ID} does not exist' - ) - self.manualBackgroundObjItem.clear() - return - - ID_idx = posData.IDs_idxs[ID] - self.manualBackgroundObj = posData.rp[ID_idx] - - self.manualBackgroundToolbar.clearInfoText() - self.manualBackgroundObj.contour = self.getObjContours( - self.manualBackgroundObj, local=True - ) - xx_contour = self.manualBackgroundObj.contour[:,0] - yy_contour = self.manualBackgroundObj.contour[:,1] - self.manualBackgroundObj.xx_contour = xx_contour - self.manualBackgroundObj.yy_contour = yy_contour - - def initGhostObject(self, ID=None): - mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': - self.ghostObject = None - return - - if not self.manualTrackingButton.isChecked(): - self.ghostObject = None - return - - if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): - self.ghostObject = None - return - - if ID is None: - ID = self.manualTrackingToolbar.spinboxID.value() - - posData = self.data[self.pos_i] - if posData.frame_i == 0: - self.ghostObject = None - return - - prevFrameRp = posData.allData_li[posData.frame_i-1]['regionprops'] - if prevFrameRp is None: - self.ghostObject = None - return - - for obj in prevFrameRp: - if obj.label != ID: - continue - self.ghostObject = obj - break - else: - self.ghostObject = None - self.manualTrackingToolbar.showWarning( - f'The ID {ID} does not exist in previous frame ' - '--> starting a new track.' - ) - return - - self.manualTrackingToolbar.clearInfoText() - - self.ghostObject.contour = self.getObjContours( - self.ghostObject, local=True - ) - self.ghostObject.xx_contour = self.ghostObject.contour[:,0] - self.ghostObject.yy_contour = self.ghostObject.contour[:,1] - - self.ghostMaskItemLeft.initLookupTable(self.lut[ID]) - self.ghostMaskItemRight.initLookupTable(self.lut[ID]) - - def clearGhost(self): - self.clearGhostContour() - self.clearGhostMask() - - def clearManualBackgroundAnnotations(self): - try: - for textItem in self.manualBackgroundTextItems.values(): - textItem.setText('') - except Exception as error: - pass - - def clearGhostContour(self): - self.ghostContourItemLeft.clear() - self.ghostContourItemRight.clear() - self.manualBackgroundObjItem.clear() - - def clearGhostMask(self): - self.ghostMaskItemLeft.clear() - self.ghostMaskItemRight.clear() - - @disableWindow - def _importInitMagicPromptModel( - self, model_name, posData, win, acdcPromptSegment, toolbar - ): - self.logger.info(f'Initializing promptable model {model_name}...') - init_kwargs = win.init_kwargs - model = myutils.init_prompt_segm_model( - acdcPromptSegment, posData, win.init_kwargs - ) - toolbar.model = model - toolbar.model_segment_kwargs = win.model_kwargs - toolbar.model_name = model_name - toolbar.viewModelParamsAction.setDisabled(False) - - self.magicPromptsToolbar.setInitializedModel( - init_kwargs, toolbar.model_segment_kwargs - ) - - self.logger.info( - f'Promptable model {model_name} successfully initialised!' - ) - - @exception_handler - def magicPromptsInitModel( - self, - model_name, - acdcPromptSegment, - init_argspecs, - segment_argspecs, - help_url, - toolbar, - ): - posData = self.data[self.pos_i] - - out = prompts.init_prompt_model_params( - posData, model_name, init_argspecs, segment_argspecs, - help_url=help_url, qparent=self, init_last_params=True - ) - win = out.get('win') - if win.cancel: - self.logger.info( - f'Initialization of {model_name} promptable model cancelled.' - ) - return - - self._importInitMagicPromptModel( - model_name, posData, win, acdcPromptSegment, toolbar - ) - - def viewSetMagicPromptModelParams( - self, - model_name, - acdcPromptSegment, - init_argspecs, - segment_argspecs, - help_url, - init_kwargs, - segment_kwargs, - toolbar - ): - posData = self.data[self.pos_i] - - init_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( - init_argspecs, init_kwargs - ) - segment_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( - segment_argspecs, segment_kwargs - ) - - out = prompts.init_prompt_model_params( - posData, model_name, init_argspecs, segment_argspecs, - help_url=help_url, qparent=self, init_last_params=False - ) - win = out.get('win') - if win.cancel: - return - - if win.model_kwargs != segment_kwargs or win.init_kwargs != init_kwargs: - self._importInitMagicPromptModel( - model_name, posData, win, acdcPromptSegment, toolbar - ) - - def getMagicPromptsInputs(self, toolbar): - if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit: - _warnings.warnPromptSegmentPointsLayerNotInit(qparent=self) - return - - if not self.magicPromptsToolbar.viewModelParamsAction.isEnabled(): - _warnings.warnPromptSegmentModelNotInit(qparent=self) - return - - posData = self.data[self.pos_i] - image = self.getDisplayedZstack() - df_points = self.promptSegmentPointsLayerToolbar.pointsLayerDf( - posData, isSegm3D=self.isSegm3D - ) - - self.logger.info( - f'Starting {toolbar.model_name} promptable segmentation with the ' - f'following prompts:\n\n{df_points}' - ) - - return image, df_points - - @disableWindow - def magicPromptsComputeOnZoomTriggered(self, toolbar): - inputs = self.getMagicPromptsInputs(toolbar) - if inputs is None: - self.logger.info( - '"Computing promptable segmentation on zoom" process cancelled.' - ) - return - - posData = self.data[self.pos_i] - image, df_points = inputs - - ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange() - Y, X = image.shape[-2:] - - xmin = int(max(0, xmin)) - xmax = int(min(X, xmax)) - ymin = int(max(0, ymin)) - ymax = int(min(Y, ymax)) - - self.logger.info( - f'Zoom range: xmin={xmin}, xmax={xmax}, ymin={ymin}, ymax={ymax}' - ) - - zoom_slice = (slice(ymin, ymax), slice(xmin, xmax)) - - image = image[..., ymin:ymax, xmin:xmax] - image_origin = (0, ymin, xmin) - - df_points = df_points[df_points['y'] >= ymin] - df_points = df_points[df_points['x'] >= xmin] - df_points = df_points[df_points['y'] < ymax] - df_points = df_points[df_points['x'] < xmax] - - df_points['y'] -= ymin - df_points['x'] -= xmin - - df_points = df_points[ df_points['frame_i'] == posData.frame_i] - - self.logger.info( - f'Image origin = {image_origin}\n' - f'Image shape = {image.shape}' - ) - - self.startMagicPromptsWorkerAndWait( - image, df_points, toolbar.model, toolbar.model_segment_kwargs, - image_origin=image_origin, zoom_slice=zoom_slice - ) - - def magicPromptsInterpolateZsliceToggled(self, checked): - # See 'self.promptSegmentPointsLayerToolbar.addPointsZslicesInterpolation' - self.promptSegmentPointsLayerToolbar.doAddPointsZslicesInterpolation = ( - checked - ) - - def magicPromptsClearPoints(self, toolbar, only_zoom=False): - posData = self.data[self.pos_i] - scatterItem = self.promptSegmentPointsLayerToolbar.scatterItem() - action = scatterItem.action - - pointsDataPos = action.pointsData.get(self.pos_i) - if pointsDataPos is None: - return - - framePointsData = action.pointsData[self.pos_i].pop( - posData.frame_i, None - ) - if framePointsData is None: - return - - if not only_zoom: - scatterItem.clear() - return - - ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange() - Y, X = posData.img_data.shape[-2:] - - xmin = int(max(0, xmin)) - xmax = int(min(X, xmax)) - ymin = int(max(0, ymin)) - ymax = int(min(Y, ymax)) - - if 'x' in framePointsData: - newFramePointsData = {'x': [], 'y': [], 'id': []} - xx = framePointsData['x'] - yy = framePointsData['y'] - ids = framePointsData['id'] - for x, y, point_id in zip(xx, yy, ids): - if x < xmin or x >= xmax or y < ymin or y >= ymax: - newFramePointsData['x'].append(x) - newFramePointsData['y'].append(y) - newFramePointsData['id'].append(point_id) - else: - newFramePointsData = {} - for z, zSliceFramePointsData in framePointsData.items(): - newFramePointsData[z] = {'x': [], 'y': [], 'id': []} - xx = zSliceFramePointsData['x'] - yy = zSliceFramePointsData['y'] - ids = zSliceFramePointsData['id'] - for x, y, point_id in zip(xx, yy, ids): - if x < xmin or x >= xmax or y < ymin or y >= ymax: - newFramePointsData[z]['x'].append(x) - newFramePointsData[z]['y'].append(y) - newFramePointsData[z]['id'].append(point_id) - - action.pointsData[self.pos_i][posData.frame_i] = newFramePointsData - self.drawPointsLayers() - - @disableWindow - def magicPromptsComputeOnImageTriggered(self, toolbar): - inputs = self.getMagicPromptsInputs(toolbar) - if inputs is None: - self.logger.info( - '"Computing promptable segmentation on entire image" ' - 'process cancelled.' - ) - return - - image, df_points = inputs - - self.startMagicPromptsWorkerAndWait( - image, df_points, toolbar.model, toolbar.model_segment_kwargs - ) - - def startMagicPromptsWorkerAndWait( - self, image, df_points, model, model_segment_kwargs, - image_origin=(0, 0, 0), zoom_slice=None - ): - desc = ( - 'Running promptable segmentation model...' - ) - self.logger.info(desc) - posData = self.data[self.pos_i] - - self.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.show(self.app) - - self.magicPromptsThread = QThread() - self.magicPromptsWorker = workers.MagicPromptsWorker( - posData, image, df_points, model, model_segment_kwargs, - image_origin=image_origin, - global_image=posData.img_data[posData.frame_i] - ) - - self.magicPromptsWorker.moveToThread( - self.magicPromptsThread - ) - - self.magicPromptsWorker.signals.finished.connect( - self.magicPromptsThread.quit - ) - self.magicPromptsWorker.signals.finished.connect( - self.magicPromptsWorker.deleteLater - ) - self.magicPromptsThread.finished.connect( - self.magicPromptsThread.deleteLater - ) - - self.magicPromptsWorker.signals.critical.connect( - self.magicPromptsWorkerCritical - ) - self.magicPromptsWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.magicPromptsWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.magicPromptsWorker.signals.progress.connect( - self.workerProgress - ) - self.magicPromptsWorker.signals.finished.connect( - partial(self.magicPromptsWorkerFinished, zoom_slice=zoom_slice) - ) - - self.magicPromptsThread.started.connect( - self.magicPromptsWorker.run - ) - self.magicPromptsThread.start() - - self.magicPromptsWorkerLoop = QEventLoop() - self.magicPromptsWorkerLoop.exec_() - - def magicPromptsWorkerCritical(self, error): - self.magicPromptsWorkerLoop.exit() - self.workerCritical(error) - - def magicPromptsWorkerFinished(self, output, zoom_slice=None): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.magicPromptsWorkerLoop.exit() - - lab_new, lab_union, lab_interesection = output - - posData = self.data[self.pos_i] - - is_zoom = True - if zoom_slice is None: - zoom_slice = (slice(None), slice(None)) - is_zoom = False - - img = ( - posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]] - ) - images = [img, img, img, img] - labels_overlays = [ - posData.lab[..., zoom_slice[0], zoom_slice[1]], - lab_new[..., zoom_slice[0], zoom_slice[1]], - lab_union[..., zoom_slice[0], zoom_slice[1]], - lab_interesection[..., zoom_slice[0], zoom_slice[1]], - ] - labels_overlays_lut = self.getLabelsImageLut() - labels_overlays_luts = [ - labels_overlays_lut, - labels_overlays_lut, - labels_overlays_lut, - labels_overlays_lut, - ] - axis_titles = [ - 'Original masks', - 'New masks', - 'Union of original and new masks', - 'Intersection of original and new masks' - ] - - from cellacdc.plot import imshow - promptSegmResultsWindow = imshow( - *images, - labels_overlays=labels_overlays, - labels_overlays_luts=labels_overlays_luts, - axis_titles=axis_titles, - window_title='Promptable segmentation results', - figure_title='Ctrl+Click to select the result to use', - annotate_labels_idxs=[0, 1, 2, 3], - selectable_images=True, - max_ncols=2, - lut='gray', - infer_rgb=False - ) - if promptSegmResultsWindow.selected_idx is None: - self.logger.info( - 'Selection of the promptable model segmentation ' - 'result cancelled.' - ) - return - - if promptSegmResultsWindow.selected_idx == 0: - self.logger.info( - 'No selection of a promptable model segmentation ' - 'result was made' - ) - return - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - results = (None, lab_new, lab_union, lab_interesection) - selected_idx = promptSegmResultsWindow.selected_idx - zoom_out_lab = results[selected_idx][..., zoom_slice[0], zoom_slice[1]] - zoom_out_lab_mask = zoom_out_lab > 0 - - lab = posData.allData_li[posData.frame_i]['labels'] - lab[..., zoom_slice[0], zoom_slice[1]][zoom_out_lab_mask] = ( - zoom_out_lab[zoom_out_lab_mask] - ) - - posData.allData_li[posData.frame_i]['labels'] = lab - self.get_data() - self.store_data(autosave=False) - self.updateAllImages() - - def manualTracking_cb(self, checked): - self.manualTrackingToolbar.setVisible(checked) - if checked: - self.realTimeTrackingToggle.previousStatus = ( - self.realTimeTrackingToggle.isChecked() - ) - self.realTimeTrackingToggle.setChecked(False) - self.UserEnforced_DisabledTracking_previousStatus = ( - self.UserEnforced_DisabledTracking - ) - self.UserEnforced_Tracking_previousStatus = ( - self.UserEnforced_Tracking - ) - - self.UserEnforced_DisabledTracking = True - self.UserEnforced_Tracking = False - self.initGhostObject() - self.addManualTrackingItems() - else: - self.realTimeTrackingToggle.setChecked( - self.realTimeTrackingToggle.previousStatus - ) - self.UserEnforced_DisabledTracking = ( - self.UserEnforced_DisabledTracking_previousStatus - ) - self.UserEnforced_Tracking = ( - self.UserEnforced_Tracking_previousStatus - ) - self.removeManualTrackingItems() - self.clearGhost() - - def manualBackground_cb(self, checked): - if checked: - posData = self.data[self.pos_i] - minID = min(posData.IDs, default=0) - if minID == self.manualBackgroundToolbar.spinboxID.value(): - self.initManualBackgroundObject() - else: - self.manualBackgroundToolbar.spinboxID.setValue(minID) - # self.initManualBackgroundObject() - # self.initManualBackgroundImage() - self.addManualBackgroundItems() - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.manualBackgroundButton) - self.connectLeftClickButtons() - self.updateAllImages() - else: - self.removeManualTrackingItems() - self.clearGhost() - self.clearManualBackgroundAnnotations() - self.manualBackgroundToolbar.setVisible(checked) - - def autoSegm_cb(self, checked): - if checked: - self.askSegmParam = True - # Ask which model - models = myutils.get_list_of_models() - win = widgets.QDialogListbox( - 'Select model', - 'Select model to use for segmentation: ', - models, - multiSelection=False, - parent=self - ) - win.exec_() - if win.cancel: - return - model_name = win.selectedItemsText[0] - self.segmModelName = model_name - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - self.updateAllImages() - self.computeSegm() - self.askSegmParam = False - else: - self.segmModelName = None - - def postProcessSegm(self, checked): - if self.isSegm3D: - SizeZ = max([posData.SizeZ for posData in self.data]) - else: - SizeZ = None - if checked: - posData = self.data[self.pos_i] - self.postProcessSegmWin = apps.PostProcessSegmDialog( - posData, mainWin=self - ) - self.postProcessSegmWin.sigClosed.connect( - self.postProcessSegmWinClosed - ) - self.postProcessSegmWin.sigValueChanged.connect( - self.postProcessSegmValueChanged - ) - self.postProcessSegmWin.sigEditingFinished.connect( - self.postProcessSegmEditingFinished - ) - self.postProcessSegmWin.sigApplyToAllFutureFrames.connect( - self.postProcessSegmApplyToAllFutureFrames - ) - self.postProcessSegmWin.show() - self.postProcessSegmWin.valueChanged(None) - else: - self.postProcessSegmWin.close() - self.postProcessSegmWin = None - - def postProcessSegmApplyToAllFutureFrames( - self, postProcessKwargs, - customPostProcessGroupedFeatures, - customPostProcessFeatures - ): - proceed = self.warnEditingWithCca_df( - 'post-processing segmentation', update_images=False - ) - if not proceed: - self.logger.info('Post-processing segmentation cancelled.') - return - - self.progressWin = apps.QDialogWorkerProgress( - title='Post-processing segmentation', parent=self, - pbarDesc=f'Post-processing segmentation masks...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.startPostProcessSegmWorker( - postProcessKwargs, customPostProcessGroupedFeatures, - customPostProcessFeatures - ) - - def postProcessSegmEditingFinished(self): - self.update_rp() - self.store_data() - self.updateAllImages() - - def postProcessSegmWorkerFinished(self): - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.get_data() - self.updateAllImages() - self.titleLabel.setText('Post-processing segmentation done!', color='w') - self.logger.info('Post-processing segmentation done!') - - def postProcessSegmWinClosed(self): - self.postProcessSegmWin = None - self.postProcessSegmAction.toggled.disconnect() - self.postProcessSegmAction.setChecked(False) - self.postProcessSegmAction.toggled.connect(self.postProcessSegm) - - def postProcessSegmValueChanged(self, lab, delObjs: dict): - for delObj in delObjs.values(): - self.clearObjContour(obj=delObj, ax=0) - self.clearObjContour(obj=delObj, ax=1) - - posData = self.data[self.pos_i] - - labelsToSkip = {} - for ID in posData.IDs: - if ID in delObjs: - labelsToSkip[ID] = True - continue - - restoreObj = self.postProcessSegmWin.origObjs[ID] - self.addObjContourToContoursImage(obj=restoreObj, ax=0) - self.addObjContourToContoursImage(obj=restoreObj, ax=1) - - # self.setAllTextAnnotations(labelsToSkip=labelsToSkip) - - posData.lab = lab - self.setImageImg2() - if self.annotSegmMasksCheckbox.isChecked(): - self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) - if self.annotSegmMasksCheckboxRight.isChecked(): - self.labelsLayerRightImg.setImage(self.currentLab2D, autoLevels=False) - - def readSavedCustomAnnot(self): - tempAnnot = {} - if os.path.exists(custom_annot_path): - self.logger.info('Loading saved custom annotations...') - tempAnnot = load.read_json( - custom_annot_path, logger_func=self.logger.info - ) - - posData = self.data[self.pos_i] - self.savedCustomAnnot = tempAnnot - for pos_i, posData in enumerate(self.data): - self.savedCustomAnnot = { - **self.savedCustomAnnot, **posData.customAnnot - } - - def addCustomAnnotButtonAllLoadedPos(self): - allPosCustomAnnot = {} - for pos_i, posData in enumerate(self.data): - self.addCustomAnnotationSavedPos(pos_i=pos_i) - allPosCustomAnnot = {**allPosCustomAnnot, **posData.customAnnot} - for posData in self.data: - posData.customAnnot = allPosCustomAnnot - - def addCustomAnnotationSavedPos(self, pos_i=None): - if pos_i is None: - pos_i = self.pos_i - - posData = self.data[pos_i] - for name, annotState in posData.customAnnot.items(): - # Check if button is already present and update only annotated IDs - buttons = [b for b in self.customAnnotDict.keys() if b.name==name] - if buttons: - toolButton = buttons[0] - allAnnotedIDs = self.customAnnotDict[toolButton]['annotatedIDs'] - allAnnotedIDs[pos_i] = posData.customAnnotIDs.get(name, {}) - continue - - try: - symbol = re.findall(r"\'(.+)\'", annotState['symbol'])[0] - except Exception as e: - self.logger.info(traceback.format_exc()) - symbol = 'o' - - symbolColor = QColor(*annotState['symbolColor']) - shortcut = annotState['shortcut'] - if shortcut is not None: - keySequence = widgets.macShortcutToWindows(shortcut) - keySequence = widgets.KeySequenceFromText(keySequence) - else: - keySequence = None - toolTip = myutils.getCustomAnnotTooltip(annotState) - keepActive = annotState.get('keepActive', True) - isHideChecked = annotState.get('isHideChecked', True) - - toolButton, action = self.addCustomAnnotationButton( - symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked - ) - allPosAnnotIDs = [ - pos.customAnnotIDs.get(name, defaultdict(list)) - for pos in self.data - ] - self.customAnnotDict[toolButton] = { - 'action': action, - 'state': annotState, - 'annotatedIDs': allPosAnnotIDs - } - - self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) - - def addCustomAnnotationButton( - self, symbol, symbolColor, keySequence, toolTip, annotName, - keepActive, isHideChecked - ): - toolButton = widgets.customAnnotToolButton( - symbol, symbolColor, parent=self, keepToolActive=keepActive, - isHideChecked=isHideChecked - ) - toolButton.setCheckable(True) - self.checkableQButtonsGroup.addButton(toolButton) - if keySequence is not None: - toolButton.setShortcut(keySequence) - toolButton.setToolTip(toolTip) - toolButton.name = annotName - toolButton.toggled.connect(self.customAnnotButtonToggled) - toolButton.sigRemoveAction.connect(self.removeCustomAnnotButton) - toolButton.sigKeepActiveAction.connect(self.customAnnotKeepActive) - toolButton.sigHideAction.connect(self.customAnnotHide) - toolButton.sigModifyAction.connect(self.customAnnotModify) - action = self.annotateToolbar.addWidget(toolButton) - return toolButton, action - - def addCustomAnnnotScatterPlot( - self, symbolColor, symbol, toolButton - ): - # Add scatter plot item - symbolColorBrush = [0, 0, 0, 50] - symbolColorBrush[:3] = symbolColor.getRgb()[:3] - scatterPlotItem = widgets.CustomAnnotationScatterPlotItem() - scatterPlotItem.setData( - [], [], symbol=symbol, pxMode=False, - brush=pg.mkBrush(symbolColorBrush), size=15, - pen=pg.mkPen(width=3, color=symbolColor), - hoverable=True, hoverBrush=pg.mkBrush(symbolColor), - tip=None - ) - scatterPlotItem.sigHovered.connect(self.customAnnotHovered) - scatterPlotItem.button = toolButton - self.customAnnotDict[toolButton]['scatterPlotItem'] = scatterPlotItem - self.ax1.addItem(scatterPlotItem) - - def addCustomAnnotationItems( - self, symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked, state - ): - toolButton, action = self.addCustomAnnotationButton( - symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked - ) - - self.customAnnotDict[toolButton] = { - 'action': action, - 'state': state, - 'annotatedIDs': [defaultdict(list) for _ in range(len(self.data))] - } - - # Save custom annotation to cellacdc/temp/custom_annotations.json - state_to_save = state.copy() - state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) - self.savedCustomAnnot[name] = state_to_save - for posData in self.data: - posData.customAnnot[name] = state_to_save - - # Add scatter plot item - self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) - - customAnnotButton = self.customAnnotDict[toolButton] - allPosAnnotatedIDs = customAnnotButton['annotatedIDs'] - # Add 0s column to acdc_df - for pos_i, posData in enumerate(self.data): - for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - if name not in acdc_df.columns: - acdc_df[name] = 0 - else: - acdc_df[name] = acdc_df[name].astype(int) - acdc_df_annot = acdc_df[acdc_df[name] == 1].reset_index() - annot_IDs = acdc_df_annot['Cell_ID'].to_list() - allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs) - - if posData.acdc_df is not None: - if name not in posData.acdc_df.columns: - posData.acdc_df[name] = 0 - else: - posData.acdc_df[name] = posData.acdc_df[name].astype(int) - acdc_df_annot = ( - posData.acdc_df[posData.acdc_df[name] == 1] - .reset_index() - ) - annot_IDs = acdc_df_annot['Cell_ID'].to_list() - allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs) - - def customAnnotHovered(self, scatterPlotItem, points, event): - # Show tool tip when hovering an annotation with annotation name and ID - vb = scatterPlotItem.getViewBox() - if vb is None: - return - if len(points) > 0: - posData = self.data[self.pos_i] - point = points[0] - x, y = point.pos().x(), point.pos().y() - xdata, ydata = int(x), int(y) - ID = self.get_2Dlab(posData.lab)[ydata, xdata] - vb.setToolTip( - f'Annotation name: {scatterPlotItem.button.name}\n' - f'ID = {ID}' - ) - else: - vb.setToolTip('') - - def loadCustomAnnotations(self): - items = list(self.savedCustomAnnot.keys()) - if len(items) == 0: - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - There are no custom annotations saved.

- Click on "Add custom annotation" button to start adding new - annotations. - """) - msg.warning(self, 'No annotations saved', txt) - return - - self.selectAnnotWin = widgets.QDialogListbox( - 'Load previously used custom annotation(s)', - 'Select annotations to load:', items, - additionalButtons=('Delete selected annnotations', ), - parent=self, multiSelection=True - ) - for button in self.selectAnnotWin._additionalButtons: - button.disconnect() - button.clicked.connect(self.deleteSavedAnnotation) - self.selectAnnotWin.exec_() - if self.selectAnnotWin.cancel: - return - - for selectedAnnotName in self.selectAnnotWin.selectedItemsText: - selectedAnnot = self.savedCustomAnnot[selectedAnnotName] - - symbol = selectedAnnot['symbol'] - symbol = re.findall(r"\'(.+)\'", symbol)[0] - symbolColor = selectedAnnot['symbolColor'] - symbolColor = pg.mkColor(symbolColor) - keySequence = widgets.KeySequenceFromText(selectedAnnot['shortcut']) - Type = selectedAnnot['type'] - toolTip = ( - f'Name: {selectedAnnotName}\n\n' - f'Type: {Type}\n\n' - f'Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n' - f'Description: {selectedAnnot["description"]}\n\n' - f'Shortcut: "{keySequence}"' - ) - keepActive = selectedAnnot['keepActive'] - isHideChecked = selectedAnnot['isHideChecked'] - state = { - 'type': Type, - 'name': selectedAnnotName, - 'symbol': selectedAnnot['symbol'], - 'shortcut': selectedAnnot['shortcut'], - 'description': selectedAnnot["description"], - 'keepActive': keepActive, - 'isHideChecked': isHideChecked, - 'symbolColor': symbolColor - } - self.addCustomAnnotationItems( - symbol, symbolColor, keySequence, toolTip, selectedAnnotName, - keepActive, isHideChecked, state - ) - for pos_i, posData in enumerate(self.data): - posData.customAnnot[selectedAnnotName] = selectedAnnot - - self.saveCustomAnnot() - - def deleteSavedAnnotation(self): - for item in self.selectAnnotWin.listBox.selectedItems(): - name = item.text() - self.savedCustomAnnot.pop(name) - self.deleteSelectedAnnot(self.selectAnnotWin.listBox.selectedItems()) - items = list(self.savedCustomAnnot.keys()) - self.selectAnnotWin.listBox.clear() - self.selectAnnotWin.listBox.addItems(items) - - def addCustomAnnotation(self): - self.readSavedCustomAnnot() - - self.addAnnotWin = apps.customAnnotationDialog( - self.savedCustomAnnot, parent=self - ) - self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) - self.addAnnotWin.exec_() - if self.addAnnotWin.cancel: - self.logger.info('Custom annotation process cancelled.') - return - - symbol = self.addAnnotWin.symbol - symbolColor = self.addAnnotWin.state['symbolColor'] - keySequence = self.addAnnotWin.shortcutWidget.widget.keySequence - toolTip = self.addAnnotWin.toolTip - name = self.addAnnotWin.state['name'] - keepActive = self.addAnnotWin.state.get('keepActive', True) - isHideChecked = self.addAnnotWin.state.get('isHideChecked', True) - - proceed = self.checkNameExists(name) - if not proceed: - self.logger.info('Custom annotation process cancelled.') - return - - self.addCustomAnnotationItems( - symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked, self.addAnnotWin.state - ) - self.saveCustomAnnot() - self.doCustomAnnotation(0) - - def askCustomAnnotationNameExists(self, name): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(f""" - The annotationa called {name} already exists in the - acdc_output CSV file.

- If you continue, this column will be used to initialize - pre-annotated objects.

- Do you want to continue? - """ - ) - noButton, yesButton = msg.question( - self, 'Custom annotation name already exists', txt, - buttonsTexts=('No, stop process', 'Yes, use existing column') - ) - return msg.clickedButton == yesButton - - - def checkNameExists(self, name): - posData = self.data[self.pos_i] - for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - if name in acdc_df.columns: - return self.askCustomAnnotationNameExists(name) - - if posData.acdc_df is not None and name in posData.acdc_df.columns: - return self.askCustomAnnotationNameExists(name) - - return True - - - def viewAllCustomAnnot(self, checked): - if not checked: - # Clear all annotations before showing only checked - for button in self.customAnnotDict.keys(): - self.clearScatterPlotCustomAnnotButton(button) - self.doCustomAnnotation(0) - - def clearScatterPlotCustomAnnotButton(self, button): - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] - scatterPlotItem.setData([], []) - - def saveCustomAnnot(self, only_temp=False): - if not hasattr(self, 'savedCustomAnnot'): - return - - if not self.savedCustomAnnot: - return - - # Save to cell acdc temp path - with open(custom_annot_path, mode='w') as file: - json.dump(self.savedCustomAnnot, file, indent=2) - - if only_temp: - return - - self.logger.info('Saving custom annotations parameters...') - # Save to pos path - for _posData in self.data: - _posData.saveCustomAnnotationParams() - - def customAnnotKeepActive(self, button): - self.customAnnotDict[button]['state']['keepActive'] = button.keepToolActive - - def customAnnotHide(self, button): - self.customAnnotDict[button]['state']['isHideChecked'] = button.isHideChecked - clearAnnot = ( - not button.isChecked() and button.isHideChecked - and not self.viewAllCustomAnnotAction.isChecked() - ) - if clearAnnot: - # User checked hide annot with the button not active --> clear - self.clearScatterPlotCustomAnnotButton(button) - elif not button.isChecked(): - # User uncheked hide annot with the button not active --> show - self.doCustomAnnotation(0) - - def deleteSelectedAnnot(self, itemsToDelete): - self.saveCustomAnnot(only_temp=True) - - def customAnnotModify(self, button): - state = self.customAnnotDict[button]['state'] - self.addAnnotWin = apps.customAnnotationDialog( - self.savedCustomAnnot, state=state - ) - self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) - self.addAnnotWin.exec_() - if self.addAnnotWin.cancel: - return - - # Rename column if existing - posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - if acdc_df is not None: - old_name = self.customAnnotDict[button]['state']['name'] - new_name = self.addAnnotWin.state['name'] - acdc_df = acdc_df.rename(columns={old_name: new_name}) - posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df - - self.customAnnotDict[button]['state'] = self.addAnnotWin.state - - name = self.addAnnotWin.state['name'] - state_to_save = self.addAnnotWin.state.copy() - symbolColor = self.addAnnotWin.state['symbolColor'] - state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) - self.savedCustomAnnot[name] = self.addAnnotWin.state - self.saveCustomAnnot() - - symbol = self.addAnnotWin.symbol - symbolColor = self.customAnnotDict[button]['state']['symbolColor'] - button.setColor(symbolColor) - button.update() - symbolColorBrush = [0, 0, 0, 50] - symbolColorBrush[:3] = symbolColor.getRgb()[:3] - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] - xx, yy = scatterPlotItem.getData() - if xx is None: - xx, yy = [], [] - scatterPlotItem.setData( - xx, yy, symbol=symbol, pxMode=False, - brush=pg.mkBrush(symbolColorBrush), size=15, - pen=pg.mkPen(width=3, color=symbolColor) - ) - - def doCustomAnnotation(self, ID): - mode = self.modeComboBox.currentText() - if not self.isSnapshot and mode != 'Custom annotations': - # Do not show annotations if timelapse and mode not annotations - return - - if self.switchPlaneCombobox.depthAxes() != 'z': - return - - # NOTE: pass 0 for ID to not add - posData = self.data[self.pos_i] - if self.viewAllCustomAnnotAction.isChecked(): - # User requested to show all annotations --> iterate all buttons - # Unless it actively clicked to annotate --> avoid annotating object - # with all the annotations present - buttons = list(self.customAnnotDict.keys()) - else: - # Annotate if the button is active or isHideChecked is False - buttons = [ - b for b in self.customAnnotDict.keys() - if (b.isChecked() or not b.isHideChecked) - ] - if not buttons: - return - - for button in buttons: - annotatedIDs = ( - self.customAnnotDict[button]['annotatedIDs'][self.pos_i] - ) - annotIDs_frame_i = annotatedIDs.get(posData.frame_i, []) - state = self.customAnnotDict[button]['state'] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - - if button.isChecked() and ID > 0: - # Annotate only if existing ID and the button is checked - if ID in annotIDs_frame_i: - annotIDs_frame_i.remove(ID) - acdc_df.at[ID, state['name']] = 0 - elif ID != 0: - annotIDs_frame_i.append(ID) - - annotPerButton = self.customAnnotDict[button] - allAnnotedIDs = annotPerButton['annotatedIDs'] - posAnnotedIDs = allAnnotedIDs[self.pos_i] - posAnnotedIDs[posData.frame_i] = annotIDs_frame_i - - if acdc_df is None: - self.store_data(autosave=False) - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - - xx, yy = [], [] - for annotID in annotIDs_frame_i: - if annotID not in posData.IDs_idxs: - continue - - obj_idx = posData.IDs_idxs[annotID] - obj = posData.rp[obj_idx] - acdc_df.at[annotID, state['name']] = 1 - if not self.isObjVisible(obj.bbox): - continue - y, x = self.getObjCentroid(obj.centroid) - xx.append(x) - yy.append(y) - - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] - scatterPlotItem.setData(xx, yy) - - posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df - - # if self.highlightedID != 0: - # self.highlightedID = 0 - # self.setHighlightID(False) - - if buttons: - return buttons[0] - - def removeCustomAnnotButton(self, button, askHow=True, save=True): - if askHow: - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - Do you want to remove also the column with annotations or - only the annotation button?
- """) - _, removeOnlyButton, removeColButton = msg.question( - self, 'Remove only button?', txt, - buttonsTexts=( - 'Cancel', 'Remove only button', - ' Remove also column with annotations ' - ) - ) - if msg.cancel: - return - removeOnlyButton = msg.clickedButton == removeOnlyButton - else: - removeOnlyButton = True - - name = self.customAnnotDict[button]['state']['name'] - # remove annotation from position - for posData in self.data: - try: - posData.customAnnot.pop(name) - posData.saveCustomAnnotationParams() - except KeyError as e: - # Current pos doesn't have any annotation button. Continue - continue - - if posData.acdc_df is None: - continue - - if removeOnlyButton: - continue - - posData.acdc_df = posData.acdc_df.drop( - columns=name, errors='ignore' - ) - for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - acdc_df = acdc_df.drop(columns=name, errors='ignore') - posData.allData_li[frame_i]['acdc_df'] = acdc_df - - self.clearScatterPlotCustomAnnotButton(button) - - action = self.customAnnotDict[button]['action'] - self.annotateToolbar.removeAction(action) - self.checkableQButtonsGroup.removeButton(button) - self.customAnnotDict.pop(button) - # self.savedCustomAnnot.pop(name) - - self.saveCustomAnnot(only_temp=True) - - def customAnnotButtonToggled(self, checked): - if checked: - self.customAnnotButton = self.sender() - # Uncheck the other buttons - for button in self.customAnnotDict.keys(): - if button == self.sender(): - continue - - button.toggled.disconnect() - self.clearScatterPlotCustomAnnotButton(button) - button.setChecked(False) - button.toggled.connect(self.customAnnotButtonToggled) - self.doCustomAnnotation(0) - else: - self.customAnnotButton = None - button = self.sender() - clearAnnotation = ( - button.isHideChecked - or not self.viewAllCustomAnnotAction.isChecked() - ) - if clearAnnotation: - self.clearScatterPlotCustomAnnotButton(button) - self.setHighlightID(False) - self.resetCursor() - - def resetCursor(self): - if self.app.overrideCursor() is not None: - while self.app.overrideCursor() is not None: - self.app.restoreOverrideCursor() - - def segmFrameCallback(self, action): - if action == self.addCustomModelFrameAction: - return - - idx = self.segmActions.index(action) - model_name = self.modelNames[idx] - self.repeatSegm(model_name=model_name, askSegmParams=True) - - def segmVideoCallback(self, action): - if action == self.addCustomModelVideoAction: - return - - posData = self.data[self.pos_i] - win = apps.startStopFramesDialog( - posData.SizeT, currentFrameNum=posData.frame_i+1 - ) - win.exec_() - if win.cancel: - self.logger.info('Segmentation on multiple frames aborted.') - return - - idx = self.segmActionsVideo.index(action) - model_name = self.modelNames[idx] - self.repeatSegmVideo(model_name, win.startFrame, win.stopFrame) - - def segmentToolActionTriggered(self): - if self.segmModelName is None: - win = apps.QDialogSelectModel(parent=self) - win.exec_() - if win.cancel: - self.logger.info('Repeat segmentation cancelled.') - return - model_name = win.selectedModel - self.repeatSegm( - model_name=model_name, askSegmParams=True - ) - else: - self.repeatSegm(model_name=self.segmModelName) - - def initSegmModelParams( - self, model_name, acdcSegment, init_params, segment_params, - is_label_roi=False, initLastParams=False, - extraParams=None, extraParamsTitle=None,ini_filename=None - - ): - posData = self.data[self.pos_i] - try: - url = acdcSegment.url_help() - except AttributeError: - url = None - - text_if_cancelled = 'Segmentation process cancelled.' - out = prompts.init_segm_model_params( - posData, model_name, init_params, segment_params, - help_url=url, qparent=self, init_last_params=initLastParams, - check_sam_embeddings=not is_label_roi, is_gui_caller=True, - extraParams=extraParams,extraParamsTitle=extraParamsTitle, - ini_filename=ini_filename, - ) - if out.get('load_sam_embeddings', False): - self.logger.info('Loading Segment Anything image embeddings...') - for _posData in self.data: - _posData.loadSamEmbeddings(logger_func=None) - text_if_cancelled = 'SAM embeddings loaded.' - - win = out.get('win') - if win is None: - self.logger.info(text_if_cancelled) - self.titleLabel.setText(text_if_cancelled) - return - - if win.cancel: - self.logger.info(text_if_cancelled) - self.titleLabel.setText(text_if_cancelled) - return - - if model_name != 'thresholding': - self.model_kwargs = win.model_kwargs - - return win - - @exception_handler - def repeatSegm( - self, model_name='', askSegmParams=False, is_label_roi=False - ): - if model_name == 'thresholding': - # thresholding model is stored as 'Automatic thresholding' - # at line of code `models.append('Automatic thresholding')` - model_name = 'Automatic thresholding' - - idx = self.modelNames.index(model_name) - # Ask segm parameters if not already set - # and not called by segmSingleFrameMenu (askSegmParams=False) - if not askSegmParams: - askSegmParams = self.model_kwargs is None - - self.downloadWin = apps.downloadModel(model_name, parent=self) - self.downloadWin.download() - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - if model_name == 'Automatic thresholding': - # Automatic thresholding is the name of the models as stored - # in self.modelNames, but the actual model is called thresholding - # (see cellacdc/models/thresholding) - model_name = 'thresholding' - - posData = self.data[self.pos_i] - # Check if model needs to be imported - acdcSegment = self.acdcSegment_li[idx] - if acdcSegment is None: - self.logger.info(f'Importing {model_name}...') - acdcSegment = myutils.import_segment_module(model_name) - self.acdcSegment_li[idx] = acdcSegment - - # Ask parameters if the user clicked on the action - # Otherwise this function is called by "computeSegm" function and - # we use loaded parameters - if askSegmParams: - if self.app.overrideCursor() == Qt.WaitCursor: - self.app.restoreOverrideCursor() - self.segmModelName = model_name - # Read all models parameters - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) - # Prompt user to enter the model parameters - try: - url = acdcSegment.url_help() - except AttributeError: - url = None - - self.preproc_recipe = None - initLastParams = True - if model_name == 'thresholding': - win = apps.QDialogAutomaticThresholding( - parent=self, isSegm3D=self.isSegm3D - ) - win.exec_() - if win.cancel: - return - self.model_kwargs = win.segment_kwargs - thresh_method = self.model_kwargs['threshold_method'] - gauss_sigma = self.model_kwargs['gauss_sigma'] - segment_params = myutils.insertModelArgSpec( - segment_params, 'threshold_method', thresh_method - ) - segment_params = myutils.insertModelArgSpec( - segment_params, 'gauss_sigma', gauss_sigma - ) - initLastParams = False - - win = self.initSegmModelParams( - model_name, acdcSegment, init_params, segment_params, - is_label_roi=is_label_roi, - initLastParams=initLastParams - ) - if win is None: - return - - self.standardPostProcessKwargs = win.standardPostProcessKwargs - self.customPostProcessFeatures = win.customPostProcessFeatures - self.customPostProcessGroupedFeatures = ( - win.customPostProcessGroupedFeatures - ) - self.applyPostProcessing = win.applyPostProcessing - self.secondChannelName = win.secondChannelName - self.preproc_recipe = win.preproc_recipe - - myutils.log_segm_params( - model_name, win.init_kwargs, win.model_kwargs, - logger_func=self.logger.info, - preproc_recipe=win.preproc_recipe, - apply_post_process=self.applyPostProcessing, - standard_postprocess_kwargs=self.standardPostProcessKwargs, - custom_postprocess_features=self.customPostProcessFeatures - ) - - use_gpu = win.init_kwargs.get('gpu', False) - proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) - if not proceed: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - - model = myutils.init_segm_model( - acdcSegment, posData, win.init_kwargs - ) - if model is None: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - try: - model.setupLogger(self.logger) - except Exception as e: - pass - self.models[idx] = model - model.model_name = model_name - else: - model = self.models[idx] - - if is_label_roi: - return model - - self.titleLabel.setText( - f'Segmenting with {model_name}... ' - '(check progress in terminal/console)', color=self.titleColor - ) - - post_process_params = { - 'applied_postprocessing': self.applyPostProcessing - } - post_process_params = { - **post_process_params, - **self.standardPostProcessKwargs, - **self.customPostProcessFeatures - } - if askSegmParams: - posData.saveSegmHyperparams( - model_name, win.init_kwargs, win.model_kwargs, - post_process_params=post_process_params, - preproc_recipe=self.preproc_recipe - ) - - if self.askRepeatSegment3D: - self.segment3D = False - if self.isSegm3D and self.askRepeatSegment3D: - msg = widgets.myMessageBox(showCentered=False) - msg.addDoNotShowAgainCheckbox(text='Do not ask again') - txt = html_utils.paragraph( - 'Do you want to segment the entire z-stack or only the ' - 'current z-slice?' - ) - _, segment3DButton, _ = msg.question( - self, '3D segmentation?', txt, - buttonsTexts=( - 'Cancel', 'Segment 3D z-stack', 'Segment 2D z-slice' - ) - ) - if msg.cancel: - self.titleLabel.setText('Segmentation process aborted.') - self.logger.info('Segmentation process aborted.') - return - self.segment3D = msg.clickedButton == segment3DButton - if msg.doNotShowAgainCheckbox.isChecked(): - self.askRepeatSegment3D = False - - if self.askZrangeSegm3D: - self.z_range = None - if self.isSegm3D and self.segment3D and self.askZrangeSegm3D: - idx = (posData.filename, posData.frame_i) - try: - orignal_z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] - except ValueError as e: - orignal_z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] - selectZtool = apps.QCropZtool( - posData.SizeZ, parent=self, cropButtonText='Ok', - addDoNotShowAgain=True, title='Select z-slice range to segment' - ) - selectZtool.sigZvalueChanged.connect(self.selectZtoolZvalueChanged) - selectZtool.sigCrop.connect(selectZtool.close) - selectZtool.exec_() - self.update_z_slice(orignal_z) - if selectZtool.cancel: - self.titleLabel.setText('Segmentation process aborted.') - self.logger.info('Segmentation process aborted.') - return - startZ = selectZtool.lowerZscrollbar.value() - stopZ = selectZtool.upperZscrollbar.value() - self.z_range = (startZ, stopZ) - if selectZtool.doNotShowAgainCheckbox.isChecked(): - self.askZrangeSegm3D = False - - secondChannelData = None - if self.secondChannelName is not None: - secondChannelData = self.getSecondChannelData() - - self.titleLabel.setText( - f'{model_name} is thinking... ' - '(check progress in terminal/console)', color=self.titleColor - ) - - self.model = model - - self.segmWorkerMutex = QMutex() - self.segmWorkerWaitCond = QWaitCondition() - self.thread = QThread() - self.worker = workers.segmWorker( - self, - secondChannelData=secondChannelData, - mutex=self.segmWorkerMutex, - waitCond=self.segmWorkerWaitCond - ) - self.worker.z_range = self.z_range - self.worker.moveToThread(self.thread) - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - if self.debug: - self.worker.debug.connect(self.debugSegmWorker) - self.thread.finished.connect(self.thread.deleteLater) - - # Custom signals - self.worker.critical.connect(self.workerCritical) - self.worker.finished.connect(self.segmWorkerFinished) - - self.thread.started.connect(self.worker.run) - self.thread.start() - - def debugSegmWorker(self, to_debug): - img, _lab, lab = to_debug - printl(img.shape, _lab.shape, lab.shape) - imshow(img, _lab, lab) - self.segmWorkerWaitCond.wakeAll() - - def selectZtoolZvalueChanged(self, whichZ, z): - self.update_z_slice(z) - - @exception_handler - def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): - if model_name == 'thresholding': - # thresholding model is stored as 'Automatic thresholding' - # at line of code `models.append('Automatic thresholding')` - model_name = 'Automatic thresholding' - - idx = self.modelNames.index(model_name) - - self.downloadWin = apps.downloadModel(model_name, parent=self) - self.downloadWin.download() - - if model_name == 'Automatic thresholding': - # Automatic thresholding is the name of the models as stored - # in self.modelNames, but the actual model is called thresholding - # (see cellacdc/models/thresholding) - model_name = 'thresholding' - - posData = self.data[self.pos_i] - # Check if model needs to be imported - acdcSegment = self.acdcSegment_li[idx] - if acdcSegment is None: - self.logger.info(f'Importing {model_name}...') - acdcSegment = myutils.import_segment_module(model_name) - self.acdcSegment_li[idx] = acdcSegment - - # Read all models parameters - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) - # Prompt user to enter the model parameters - try: - url = acdcSegment.url_help() - except AttributeError: - url = None - - if model_name == 'thresholding': - autoThreshWin = apps.QDialogAutomaticThresholding( - parent=self, isSegm3D=self.isSegm3D - ) - autoThreshWin.exec_() - if autoThreshWin.cancel: - return - - win = self.initSegmModelParams( - model_name, acdcSegment, init_params, segment_params - ) - if win is None: - return - - self.standardPostProcessKwargs = win.standardPostProcessKwargs - self.customPostProcessFeatures = win.customPostProcessFeatures - self.customPostProcessGroupedFeatures = ( - win.customPostProcessGroupedFeatures - ) - self.applyPostProcessing = win.applyPostProcessing - self.preproc_recipe = win.preproc_recipe - - myutils.log_segm_params( - model_name, win.init_kwargs, win.model_kwargs, - logger_func=self.logger.info, - preproc_recipe=win.preproc_recipe, - apply_post_process=self.applyPostProcessing, - standard_postprocess_kwargs=self.standardPostProcessKwargs, - custom_postprocess_features=self.customPostProcessFeatures - ) - - secondChannelData = None - if win.secondChannelName is not None: - secondChannelData = self.getSecondChannelData() - - use_gpu = win.init_kwargs.get('gpu', False) - proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) - if not proceed: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - - model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) - if model is None: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - try: - model.setupLogger(self.logger) - except Exception as e: - pass - - self.extendSegmDataIfNeeded(stopFrameNum) - self.reInitLastSegmFrame( - from_frame_i=startFrameNum-1, updateImages=False - ) - - self.titleLabel.setText( - f'{model_name} is thinking... ' - '(check progress in terminal/console)', color=self.titleColor - ) - - self.progressWin = apps.QDialogWorkerProgress( - title='Segmenting video', parent=self, - pbarDesc=f'Segmenting from frame n. {startFrameNum} to {stopFrameNum}...' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stopFrameNum-startFrameNum) - - self.thread = QThread() - self.worker = workers.segmVideoWorker( - posData, win, model, startFrameNum, stopFrameNum - ) - self.worker.secondChannelData = secondChannelData - self.worker.moveToThread(self.thread) - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - # Custom signals - self.worker.critical.connect(self.workerCritical) - self.worker.finished.connect(self.segmVideoWorkerFinished) - self.worker.progressBar.connect(self.workerUpdateProgressbar) - self.worker.progress.connect(self.workerProgress) - - self.thread.started.connect(self.worker.run) - self.thread.start() - - def segmVideoWorkerFinished(self, exec_time): - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.activateAnnotations() - - self.get_data() - self.tracking(enforce=True) - self.updateAllImages() - - txt = f'Done. Segmentation computed in {exec_time:.3f} s' - self.logger.info('-----------------') - self.logger.info(txt) - self.logger.info('=================') - self.titleLabel.setText(txt, color='g') - - @exception_handler - def lazyLoaderCritical(self, error): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.lazyLoader.pause() - raise error - - def ccaIntegrityWorkerCritical(self, error): - try: - raise error - except Exception as err: - self.logger.exception(traceback.format_exc()) - - href = f'GitHub page' - txt = html_utils.paragraph(f""" - Unfortunately the experimental feature - check cell cycle annotations integrity raised a - critical error.

- Cell-ACDC will now disable this feature to allow you to keep - using the software.

- However, we kindly ask you to report the issue on our - {href}, thank you very much!

- Please, include the log file when reporting the issue.

- Log file location: - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning( - self, 'Experimental feature error', txt, - commands=(self.log_path,), - path_to_browse=self.logs_path - ) - self.disableCcaIntegrityChecker() - - @exception_handler - def workerCritical(self, out: Tuple[QObject, Exception]): - self.setDisabled(False) - try: - worker, error = out - except TypeError as err: - error = out - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.logger.info(error) - try: - worker.thread().quit() - worker.deleteLater() - worker.thread().deleteLater() - except Exception as err: - # Worker already closed - pass - raise error - - def workerLog(self, text): - self.logger.info(text) - - def saveDataWorkerCritical(self, error): - self.logger.warning( - 'Saving process stopped because of critical error.' - ) - self.saveWin.aborted = True - self.worker.finished.emit() - self.workerCritical(error) - - def lazyLoaderWorkerClosed(self): - if self.lazyLoader.salute: - self.logger.info('Cell-ACDC GUI closed.') - self.sigClosed.emit(self) - - self.lazyLoader = None - - def segmWorkerFinished(self, lab, exec_time): - posData = self.data[self.pos_i] - - if posData.segmInfo_df is not None and posData.SizeZ>1: - idx = (posData.filename, posData.frame_i) - posData.segmInfo_df.at[idx, 'resegmented_in_gui'] = True - - if lab.ndim == 2 and self.isSegm3D: - self.set_2Dlab(lab) - else: - posData.lab = lab.copy() - - self.activateAnnotations() - - self.update_rp(wl_update=False) - self.tracking(enforce=True, against_next=posData.frame_i==0) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Repeat segmentation') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Repeat segmentation') - - txt = f'Done. Segmentation computed in {exec_time:.3f} s' - self.logger.info('-----------------') - self.logger.info(txt) - self.logger.info('=================') - self.titleLabel.setText(txt, color='g') - self.checkIfAutoSegm() - - QTimer.singleShot(200, self.resizeGui) - def activateAnnotations(self): - if self.annotContourCheckbox.isChecked(): - return - if self.annotSegmMasksCheckbox.isChecked(): - return - - self.annotSegmMasksCheckbox.setChecked(True) - self.setDrawAnnotComboboxText() - - # @exec_time - def getDisplayedImg1(self): - return self.img1.image - - def getDisplayedZstack(self): - posData = self.data[self.pos_i] - return posData.img_data[posData.frame_i] - - def autoAssignBud_YeastMate(self): - if not self.is_win: - txt = ( - 'YeastMate is available only on Windows OS.' - 'We are working on expading support also on macOS and Linux.\n\n' - 'Thank you for your patience!' - ) - msg = QMessageBox() - msg.critical( - self, 'Supported only on Windows', txt, msg.Ok - ) - return - - - model_name = 'YeastMate' - idx = self.modelNames.index(model_name) - - self.titleLabel.setText( - f'{model_name} is thinking... ' - '(check progress in terminal/console)', color=self.titleColor - ) - - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - posData = self.data[self.pos_i] - # Check if model needs to be imported - acdcSegment = self.acdcSegment_li[idx] - if acdcSegment is None: - acdcSegment = myutils.import_segment_module(model_name) - self.acdcSegment_li[idx] = acdcSegment - - # Read all models parameters - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) - # Prompt user to enter the model parameters - try: - url = acdcSegment.url_help() - except AttributeError: - url = None - - _SizeZ = None - if self.isSegm3D: - _SizeZ = posData.SizeZ - win = apps.QDialogModelParams( - init_params, - segment_params, - model_name, - url=url, - posData=posData, - df_metadata=posData.metadata_df - ) - win.exec_() - if win.cancel: - self.titleLabel.setText('Segmentation aborted.') - return - - use_gpu = win.init_kwargs.get('gpu', False) - proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) - if not proceed: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - - self.model_kwargs = win.model_kwargs - model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) - if model is None: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return - try: - model.setupLogger(self.logger) - except Exception as e: - pass - - self.models[idx] = model - - img = self.getDisplayedImg1() - - posData.cca_df = model.predictCcaState(img, posData.lab) - self.store_data() - self.updateAllImages() - - self.titleLabel.setText('Budding event prediction done.', color='g') - - def isNavigateActionOnNextFrame(self): - posData = self.data[self.pos_i] - if posData.SizeT == 1: - return False - - ax1_coords = self.getMouseDataCoordsRightImage() - if ax1_coords is None: - return False - - if not self.labelsGrad.showNextFrameAction.isEnabled(): - return False - - if not self.labelsGrad.showNextFrameAction.isChecked(): - return - - # Mouse is on right image and next frame action is checked - return True - - def rightImageFramesScrollbarValueChanged(self, value): - img = self.nextFrameImage(current_frame_i=value-2) - self.img1.linkedImageItem.frame_i = value - self.img1.linkedImageItem.setImage(img) - - def nextActionTriggered(self): - if self.isNavigateActionOnNextFrame(): - self.rightImageFramesScrollbar.setValue( - self.rightImageFramesScrollbar.value()+1 - ) - return - - stepAddAction = QAbstractSlider.SliderAction.SliderSingleStepAdd - if self.zKeptDown or self.zSliceCheckbox.isChecked(): - self.zSliceScrollBar.triggerAction(stepAddAction) - else: - self.navigateScrollBar.triggerAction(stepAddAction) - - def prevActionTriggered(self): - if self.isNavigateActionOnNextFrame(): - self.rightImageFramesScrollbar.setValue( - self.rightImageFramesScrollbar.value()-1 - ) - return - - stepSubAction = QAbstractSlider.SliderAction.SliderSingleStepSub - if self.zKeptDown or self.zSliceCheckbox.isChecked(): - self.zSliceScrollBar.triggerAction(stepSubAction) - else: - self.navigateScrollBar.triggerAction(stepSubAction) - - def resetNavigateScrollbar(self): - try: - self.navigateScrollBar.blockSignals(True) - self.navigateScrollBar.actionTriggered.disconnect() - self.navigateScrollBar.sliderReleased.disconnect() - self.navigateScrollBar.sliderMoved.disconnect() - # self.navigateScrollBar.valueChanged.disconnect() - self.navigateScrollBar.setSliderPosition(self.navSpinBox.value()) - except Exception as e: - if "disconnect()" not in str(e): - printl(e) - pass - - self.navigateScrollBar.blockSignals(False) - self.navigateScrollBar.actionTriggered.connect( - self.framesScrollBarActionTriggered - ) - self.navigateScrollBar.sliderReleased.connect(self.framesScrollBarReleased) - self.navigateScrollBar.sliderMoved.connect(self.framesScrollBarMoved) - - @exception_handler - def next_cb(self): - if self.isSnapshot: - self.next_pos() - else: - self.next_frame() - if self.curvToolButton.isChecked(): - self.curvTool_cb(True) - - self.updatePropsWidget('') - - @exception_handler - def prev_cb(self): - if self.isSnapshot: - self.prev_pos() - else: - self.prev_frame() - if self.curvToolButton.isChecked(): - self.curvTool_cb(True) - - self.updatePropsWidget('') - - def zoomOut(self): - self.ax1.autoRange() - - def preprocessActionTriggered(self): - self.preprocessDialog.show() - self.preprocessDialog.raise_() - self.preprocessDialog.activateWindow() - self.preprocessDialog.emitSigPreviewToggled() - - def zoomToObjsActionCallback(self): - self.zoomToCells(enforce=True) - - def zoomToCells(self, enforce=False): - if not self.enableAutoZoomToCellsAction.isChecked() and not enforce: - return - - posData = self.data[self.pos_i] - lab_mask = (self.currentLab2D>0).astype(np.uint8) - rp = skimage.measure.regionprops(lab_mask) - if not rp: - Y, X = lab_mask.shape - xRange = -0.5, X+0.5 - yRange = -0.5, Y+0.5 - else: - obj = rp[0] - min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) - xRange = min_col-10, max_col+10 - yRange = max_row+10, min_row-10 - - self.ax1.setRange(xRange=xRange, yRange=yRange) - - def viewCcaTable(self): - posData = self.data[self.pos_i] - zoomIDs = self.getZoomIDs() - - df = posData.allData_li[posData.frame_i]['acdc_df'] - current_cca_df = posData.cca_df - if zoomIDs is not None: - df = df.loc[zoomIDs] - current_cca_df = current_cca_df.loc[zoomIDs] - - for column in current_cca_df.columns: - header = ( - '================================================\n' - f'CURRENT vs STORED `{column}` column' - f'for frame number {posData.frame_i+1}:\n' - ) - df_compare = current_cca_df[[column]].copy() - df_compare[f'STORED_{column}'] = df[column] - text = f'{header}{df_compare}' - self.logger.info(text) - - if 'cell_cycle_stage' in df.columns: - cca_df = df[self.cca_df_colnames] - cca_df = cca_df.merge( - current_cca_df, how='outer', left_index=True, right_index=True, - suffixes=('_STORED', '_CURRENT') - ) - cca_df = cca_df.reindex(sorted(cca_df.columns), axis=1) - num_cols = len(cca_df.columns) - for j in range(0,num_cols,2): - df_j_x = cca_df.iloc[:,j] - df_j_y = cca_df.iloc[:,j+1] - if any(df_j_x!=df_j_y): - self.logger.info('------------------------') - self.logger.info('DIFFERENCES:') - diff_df = cca_df.iloc[:,j:j+2] - diff_mask = diff_df.iloc[:,0]!=diff_df.iloc[:,1] - self.logger.info(diff_df[diff_mask]) - else: - cca_df = None - self.logger.info(cca_df) - self.logger.info('========================') - if current_cca_df is None: - return - if current_cca_df.empty: - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Cell cycle annotations\' table is empty.
' - ) - msg.warning(self, 'Table empty', txt) - return - - df = posData.add_tree_cols_to_cca_df( - current_cca_df, frame_i=posData.frame_i - ) - if self.ccaTableWin is None: - self.ccaTableWin = apps.ViewCcaTableWindow(df, parent=self) - self.ccaTableWin.show() - self.ccaTableWin.setGeometryWindow() - self.ccaTableWin.sigUpdateCcaTable.connect( - self.onSigUpdateCcaTableWindow - ) - else: - self.ccaTableWin.setFocus() - self.ccaTableWin.activateWindow() - self.ccaTableWin.updateTable(current_cca_df) - - def updateScrollbars(self): - self.updateItemsMousePos() - self.updateFramePosLabel() - posData = self.data[self.pos_i] - navPos = self.pos_i+1 if self.isSnapshot else posData.frame_i+1 - self.navigateScrollBar.setSliderPosition(navPos) - if posData.SizeZ > 1: - self.updateZsliceScrollbar(posData.frame_i) - idx = (posData.filename, posData.frame_i) - self.zSliceScrollBar.setMaximum(posData.SizeZ-1) - self.zSliceSpinbox.setMaximum(posData.SizeZ) - self.SizeZlabel.setText(f'/{posData.SizeZ}') - - def updateItemsMousePos(self): - if self.brushButton.isChecked(): - self.updateBrushCursor(self.xHoverImg, self.yHoverImg) - - if self.eraserButton.isChecked(): - self.updateEraserCursor(self.xHoverImg, self.yHoverImg) - - @exception_handler - def postProcessing(self): - if self.postProcessSegmWin is None: - return - - self.postProcessSegmWin.setPosData() - posData = self.data[self.pos_i] - lab, delIDs = self.postProcessSegmWin.apply() - if posData.allData_li[posData.frame_i]['labels'] is None: - posData.lab = lab.copy() - self.update_rp() - else: - posData.allData_li[posData.frame_i]['labels'] = lab - self.get_data() - - def preprocessDialogRecipeChanged(self, recipe):# why does this need the recepie as an arg - recipe = self.preprocessDialog.recipe() - if recipe is None: - self.logger.warning('Pre-processing recipe not initialized yet.') - return - - self.updatePreprocessPreview(recipe=recipe) - - def debugShowImg(self, img): - imshow(img) - - def preprocessDialogSavePreprocessedData(self, dialog): - posData = self.data[self.pos_i] - - try: - posData.preprocessedDataArray() - except TypeError as e: - if 'Not all frames have been processed.' in str(e): - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Not all frames have been processed.
' - 'Please process all frames before saving.' - ) - msg.warning(self, 'Process all data before saving', txt) - return - - - helpText = ( - """ - The preprocessed image file will be saved with a different - file name.

- Insert a name to append to the end of the new file name. The rest of - the name will be the same as the original file. - """ - ) - - - win = apps.filenameDialog( - basename=f'{posData.basename}{self.user_ch_name}', - ext=".tif", - hintText='Insert a name for the preprocessed image file:', - defaultEntry='preprocessed', - helpText=helpText, - allowEmpty=False, - parent=dialog - ) - win.exec_() - if win.cancel: - return - - appendedText = win.entryText - - self.progressWin = apps.QDialogWorkerProgress( - title='Saving pre-processed image(s)', - parent=self, - pbarDesc='Saving pre-processed image(s)' - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.statusBarLabel.setText('Saving pre-processed data...') - - self.savePreprocWorker = workers.SaveProcessedDataWorker( - self.data, appendedText, ext=".tif" - ) - - self.savePreprocThread = QThread() - self.savePreprocWorker.moveToThread(self.savePreprocThread) - self.savePreprocWorker.signals.finished.connect( - self.savePreprocThread.quit - ) - self.savePreprocWorker.signals.finished.connect( - self.savePreprocWorker.deleteLater - ) - self.savePreprocThread.finished.connect( - self.savePreprocThread.deleteLater - ) - - self.savePreprocWorker.signals.critical.connect( - self.workerCritical - ) - self.savePreprocWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.savePreprocWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.savePreprocWorker.signals.progress.connect( - self.workerProgress - ) - self.savePreprocWorker.signals.finished.connect( - self.savePreprocWorkerFinished - ) - - self.savePreprocThread.started.connect( - self.savePreprocWorker.run - ) - self.savePreprocThread.start() - - - def preprocessEnqueueCurrentImage(self, recipe): - posData = self.data[self.pos_i] - func = core.preprocess_image_from_recipe - image_data = self.getImage(raw=True) - if posData.SizeZ > 1: - z_slice = self.z_slice_index() - else: - z_slice = 0 - - recipe = core.validate_multidimensional_recipe(recipe) - - key = (self.pos_i, posData.frame_i, z_slice) - self.preprocWorker.enqueue( - func, - image_data, - recipe, - key - ) - - def getChData(self, requ_ch=None, pos_i=None): - if not pos_i: - pos_i = self.pos_i - - posData = self.data[pos_i] - - if not requ_ch: - requ_ch = set(self.ch_names) - else: - requ_ch = set(requ_ch) - - posData.setLoadedChannelNames() - - loaded_channels = set(posData.loadedChNames) - missing_channels = requ_ch - loaded_channels - - self.loadFluo_cb(fluo_channels=missing_channels) - - def updatePreprocessPreview(self, *args, **kwargs): - force = kwargs.get('force', False) - - if not self.preprocessDialog.isVisible() and not force: - return - - if not self.preprocessDialog.previewCheckbox.isChecked() and not force: - return - - if kwargs.get('recipe') is None: - recipe = self.preprocessDialog.recipe() - else: - recipe = kwargs.get('recipe') - - if recipe is None: - self.logger.warning('Pre-processing recipe not initialized yet.') - return - - txt = 'Pre-processing current image...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - self.preprocessEnqueueCurrentImage(recipe) - - def next_pos(self): - self.store_data(debug=True, autosave=False) - prev_pos_i = self.pos_i - if self.pos_i < self.num_pos-1: - self.pos_i += 1 - self.updateSegmDataAutoSaveWorker() - else: - self.logger.info('You reached last position.') - self.pos_i = 0 - self.updatePos() - - def resetManualBackgroundItems(self): - self.initManualBackgroundImage() - self.resetManualBackgroundSpinboxID() - self.drawManualTrackingGhost(self.xHoverImg, self.yHoverImg) - self.drawManualBackgroundObj(self.xHoverImg, self.yHoverImg) - - def clearUndoQueue(self): - posData = self.data[self.pos_i] - self.UndoCount = 0 - self.redoAction.setEnabled(False) - self.undoAction.setEnabled(False) - posData.UndoRedoStates = [[] for _ in range(posData.SizeT)] - posData.UndoRedoCcaStates = [[] for _ in range(posData.SizeT)] - if hasattr(self, 'undoAddPointQueueMapper'): - self.undoAddPointQueueMapper = defaultdict(list) - - def updatePos(self): - self.clearUndoQueue() - self.setStatusBarLabel() - self.checkManageVersions() - self.removeAlldelROIsCurrentFrame() - self.resetManualBackgroundItems() - proceed_cca, never_visited = self.get_data(debug=False) - self.pointsLayerLoadedDfsToData() - self.flushDirtyPointsLayersAutosave() - self.initContoursImage() - self.initDelRoiLab() - self.initTextAnnot() - self.postProcessing() - self.updateScrollbars() - self.updatePreprocessPreview() - self.updateCombineChannelsPreview() - self.updateAllImages() - self.computeSegm() - self.zoomOut() - self.restartZoomAutoPilot() - self.initManualBackgroundObject() - self.updateObjectCounts() - self.updateItemsMousePos() - - def prev_pos(self): - self.store_data(debug=False, autosave=False) - prev_pos_i = self.pos_i - if self.pos_i > 0: - self.pos_i -= 1 - self.updateSegmDataAutoSaveWorker() - else: - self.logger.info('You reached first position.') - self.pos_i = self.num_pos-1 - self.updatePos() - - def updateViewerWindow(self): - if self.slideshowWin is None: - return - - if self.slideshowWin.linkWindow is None: - return - - if not self.slideshowWin.linkWindowCheckbox.isChecked(): - return - - posData = self.data[self.pos_i] - self.slideshowWin.frame_i = posData.frame_i - self.slideshowWin.update_img() - - def warnLostObjects(self, do_warn=True): - if not do_warn: - return True - - if not self.warnLostCellsAction.isChecked(): - return True - - mode = str(self.modeComboBox.currentText()) - if not mode == 'Segmentation and Tracking': - return True - - posData = self.data[self.pos_i] - if not posData.lost_IDs: - return True - - frame_i = posData.frame_i - try: - accepted_lost_IDs = posData.accepted_lost_IDs.get(frame_i, []) - already_accepted_lost = ( - Counter(accepted_lost_IDs) == Counter(posData.lost_IDs) - ) - except AttributeError as err: - already_accepted_lost = False - - if already_accepted_lost: - return True - - self.nextAction.setDisabled(True) - self.prevAction.setDisabled(True) - self.navigateScrollBar.setDisabled(True) - - msg = widgets.myMessageBox() - warn_msg = html_utils.paragraph( - 'Current frame (compared to previous frame) ' - 'has lost the following cells:

' - f'{posData.lost_IDs}

' - 'Are you sure you want to continue?
' - ) - checkBox = QCheckBox('Do not show again') - noButton, yesButton = msg.warning( - self, 'Lost cells!', warn_msg, - buttonsTexts=('No', 'Yes'), - widgets=checkBox - ) - doNotWarnLostCells = not checkBox.isChecked() - self.warnLostCellsAction.setChecked(doNotWarnLostCells) - if msg.clickedButton == noButton: - self.nextAction.setDisabled(False) - self.prevAction.setDisabled(False) - self.navigateScrollBar.setDisabled(False) - return False - - self.nextAction.setDisabled(False) - self.prevAction.setDisabled(False) - self.navigateScrollBar.setDisabled(False) - if not hasattr(posData, 'accepted_lost_IDs'): - posData.accepted_lost_IDs = {} - if frame_i not in posData.accepted_lost_IDs: - posData.accepted_lost_IDs[frame_i] = [] - - posData.accepted_lost_IDs[frame_i].extend(posData.lost_IDs) - # This section is adding the lost cells to tracked_lost_centroids... TBH I dont know why this wasnt done in the first place - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] - accepted_lost_centroids = { - tuple(int(val) for val in prev_rp[prev_IDs_idxs[ID]].centroid) - for ID in posData.lost_IDs - } - try: - posData.tracked_lost_centroids[frame_i] = ( - posData.tracked_lost_centroids[frame_i] | (accepted_lost_centroids) - ) - except KeyError: - posData.tracked_lost_centroids[frame_i] = accepted_lost_centroids - return True - - def askInitCcaFirstFrame(self): - mode = str(self.modeComboBox.currentText()) - if mode != 'Cell cycle analysis': - return True - - posData = self.data[self.pos_i] - if posData.frame_i != 0: - return True - - editCcaWidget = apps.editCcaTableWidget( - posData.cca_df, posData.SizeT, parent=self, - title='Initialize cell cycle annotations' - ) - editCcaWidget.sigApplyChangesFutureFrames.connect( - self.applyManualCcaChangesFutureFrames - ) - editCcaWidget.exec_() - if editCcaWidget.cancel: - self.resetNavigateFramesScrollbar() - return False - - if posData.cca_df is not None: - is_cca_same_as_stored = ( - (posData.cca_df == editCcaWidget.cca_df).all(axis=None) - ) - if not is_cca_same_as_stored: - reinit_cca = self.warnEditingWithCca_df( - 'Re-initialize cell cyle annotations first frame', - return_answer=True - ) - if reinit_cca: - self.resetCcaFuture(0) - - posData.cca_df = editCcaWidget.cca_df - self.store_cca_df() - - return True - - def askInitLinTreeFirstFrame(self): - mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree': - return True - - posData = self.data[self.pos_i] - if posData.frame_i != 0: - return True - - if self.lineage_tree is None: - self.initLinTree() - - return True - - def checkIfFutureFrameManualAnnotPastFrames(self): - if not self.manualAnnotPastButton.isChecked(): - return True - - posData = self.data[self.pos_i] - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - if posData.frame_i <= frame_to_restore: - return True - - warn_txt = ( - 'WARNING: Cannot navigate to future frames while in ' - 'manual annotation mode.' - ) - self.logger.info(warn_txt) - self.statusBarLabel.setText(f'

{warn_txt}

') - - return False - - # @exec_time - def next_frame(self, warn=True): - proceed = self.checkIfFutureFrameManualAnnotPastFrames() - if not proceed: - return - - proceed = self.askInitCcaFirstFrame() - if not proceed: - return - - proceed = self.askInitLinTreeFirstFrame() - if not proceed: - return - - mode = str(self.modeComboBox.currentText()) - posData = self.data[self.pos_i] - - if posData.frame_i >= posData.SizeT-1: - # Store data for current frame - if mode != 'Viewer': - self.store_data(debug=False) - msg = 'You reached the last segmented frame!' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - return - - proceed = self.warnLostObjects() - if not proceed: - self.resetNavigateScrollbar() - return - - # Store data for current frame - if mode != 'Viewer': - self.store_data(debug=False) - - self.askLineageTreeChanges() - posData.frame_i += 1 - self.removeAlldelROIsCurrentFrame() - proceed_cca, never_visited = self.get_data() - if not proceed_cca: - posData.frame_i -= 1 - self.get_data() - self.logger.info( - 'No data for current frame. ' - ) - return - - if mode == 'Segmentation and Tracking' or self.isSnapshot: - self.addExistingDelROIs() - - self.updatePreprocessPreview() - self.updateCombineChannelsPreview() - self.postProcessing() - self.tracking(storeUndo=True, wl_update=False) - notEnoughG1Cells, proceed = self.attempt_auto_cca() - if notEnoughG1Cells or not proceed: - posData.frame_i -= 1 - self.get_data() - self.setAllTextAnnotations() - self.logger.info( - 'Not enough G1 cells to compute cell cycle annotations.' - ) - return - - self.store_zslices_rp() - self.resetExpandLabel() - self.updateAllImages() - self.updateHighlightedAxis() - self.updateViewerWindow() - self.updateLastVisitedFrame(last_visited_frame_i=posData.frame_i-1) - self.setNavigateScrollBarMaximum() - self.updateScrollbars() - self.computeSegm() - self.initGhostObject() - self.whitelistPropagateIDs() - self.zoomToCells() - self.updateItemsMousePos() - self.updateObjectCounts() - - self.apply_tools_on_new_frame() - - def apply_tools_on_new_frame(self): - mode = str(self.modeComboBox.currentText()) - if mode != 'Segmentation and Tracking': - return - posData = self.data[self.pos_i] - if not (posData.last_tracked_i <= posData.frame_i) or posData.frame_i == self.lastFrameRanOnFirstVisitTools: - return - - self.lastFrameRanOnFirstVisitTools = posData.frame_i - for name, checkbox in self.applyToolNewFrameActions.items(): - if not checkbox.isChecked(): - continue - - tool_button = self.applyToolNewFrameButtons[name] - try: - if hasattr(tool_button, 'click'): - tool_button.click() - elif hasattr(tool_button, 'trigger'): - tool_button.trigger() - else: - printl( - f"Warning: {name} has no click or trigger method" - ) - except Exception as e: - self.logger.info(f"Error applying tool {name}: {e}") - - @disableWindow - def get_difference_table(self, return_css_separated=False, return_differece=False): - - if self.original_df_lin_tree is None: - return - - posData = self.data[self.pos_i] - - new_df = posData.allData_li[posData.frame_i]['acdc_df'] - original_df = self.original_df_lin_tree.copy() - - if original_df.equals(new_df): - return - - compare_columns = ['parent_ID_tree'] - - new_df = new_df[original_df.columns] - new_df = myutils.checked_reset_index_Cell_ID(new_df) - new_df = new_df[compare_columns] - new_df = new_df.sort_index() - original_df = myutils.checked_reset_index_Cell_ID(original_df) - original_df = original_df[compare_columns] - original_df = original_df.sort_index() - - differences = original_df.compare(new_df) - if differences.empty: - return - - differences = myutils.checked_reset_index_Cell_ID(differences) - - differences = differences['parent_ID_tree'] - differences = differences.reset_index() - - txt = """ - - - - - """ - - for diff in differences.itertuples(): - ID = str(int(diff.Cell_ID)) - old_parent = str(int(diff.self)) - new_parent = str(int(diff.other)) - - txt += f''' - - - - ''' - txt += '
IDold parent -->new parent
{ID}{old_parent}{new_parent}
' - - css = r''' - - ''' - if return_css_separated and not return_differece: - return css, txt - elif return_css_separated and return_differece: - return css, txt, differences - elif not return_css_separated and return_differece: - return txt, differences - else: - txt = css + html_utils.paragraph(txt) - return txt - - def viewLinTreeInfoAction(self): - mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree': - self.logger.info('This action is only available in the "Normal division: Lineage tree" mode.') - return - - if not self.lineage_tree: - self.logger.info('No lineage tree found.') - return - - posData = self.data[self.pos_i] - - if self.original_df_lin_tree_i != posData.frame_i: - # could be that this is not entirley true and self.curr_original_df_i just didnt get set right though! - txt_changes = '
No changes were made in this frame.

' - - else: - result = self.get_difference_table(return_css_separated=True) - - if result is None: - txt_changes = 'No changes were made in this frame.' - else: - css, txt_changes = result - - txt_changes = 'Changes made in this frame:' + txt_changes + '

' - - cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i) - - if orphan_cells == []: - txt_orphan_cells = 'No orphan Cells!' - else: - txt_orphan_cells = ', '.join([str(cell) for cell in orphan_cells]) - txt_orphan = f'Orphan cells:
{txt_orphan_cells}

' - - lost_cells = list(lost_cells) - if lost_cells == []: - txt_lost_cells = 'No lost Cells!' - else: - txt_lost_cells = ', '.join([str(cell) for cell in lost_cells]) - txt_lost = f'Lost cells:
{txt_lost_cells}

' - - if cells_with_parent == []: - table_cells_with_parent = '
No cells with parents!' - else: - table_cells_with_parent = """ - - - - """ - - for cell, parent in cells_with_parent: - table_cells_with_parent += f''' - - - ''' - table_cells_with_parent += '
Parent IDID
{parent}{cell}
' - - txt_cells_with_parents = f'Cells with parents:{table_cells_with_parent}

' - - css = r''' - - ''' - - txt = css + html_utils.paragraph(txt_changes + txt_orphan + txt_lost + txt_cells_with_parents) - - msg = widgets.myMessageBox() - msg.information(self, - 'lineage tree information', - txt - ) - - @disableWindow - def askLineageTreeChanges(self): - """ - Asks the user for changes in the lineage tree. - - This method is called when the user selects the 'Normal division: Lineage tree' mode. - It compared the backed up df (self.original_df from repeat_click_and_backup) with the current df (self.lineage_tree.export_df(posData.frame_i)) and propts the user to keep, propagate or discard the changes. - - """ - mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree': - return - - if not self.lineage_tree: - return - - posData = self.data[self.pos_i] - - if self.original_df_lin_tree_i is not None and self.original_df_lin_tree_i != posData.frame_i: - printl("!This should not happen!") - self.store_data(autosave=False) - og_frame = posData.frame_i - posData.frame_i = self.original_df_lin_tree_i - self.get_data() - self.logger.info('Lineage tree changes were not propagated, going back to original frame.') - self.askLineageTreeChanges() - self.store_data(autosave=False) - posData.frame_i = og_frame - self.get_data() - return - - result = self.get_difference_table(return_css_separated=True, return_differece=True) - if result is None: - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - return - - css, txt, differences = result - changed_IDs = differences['Cell_ID'].unique() - - if posData.frame_i == max(self.lineage_tree.frames_for_dfs): - # here we can just propagate the cahnged. This is super fast, since there is no recursion, no children and fast finding of parents - self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - return - - txt = txt + 'Do you want to keep, propgagte or discard the changes?' - txt = css + html_utils.paragraph('Changes made in this frame
' + txt) - - msg = widgets.myMessageBox() - - propagate_btn, discard_btn, _ = msg.question(self, - 'Changes in lineage tree', - txt, - buttonsTexts=('Propagate', 'Discard', 'Cancel'),) - - if msg.clickedButton == propagate_btn: - self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - self.logger.info('Lineage tree propagated.') - - elif msg.clickedButton == discard_btn: - posData.allData_li[posData.frame_i]['acdc_df'] = self.original_df_lin_tree.copy() - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - self.logger.info('Lineage tree changes discarded.') - - - elif msg.cancel: - # Go back to current frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(''' - Changes were kept but not propagated! - Please make sure to come back and propagate them, - otherwise your table might be inconsistent! - There is a button for this next to the edit buttons. - Please also do not visit new frames! - - ''') - msg.warning(self, 'Changes kept but not propagated!', txt) - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - self.logger.info('Lineage tree changes discarded.') - - def manualAnnotRestoreLastTrackedFrame(self, last_tracked_i_to_restore): - if self.navigateScrollBar.maximum()-1 <= last_tracked_i_to_restore: - return - - posData = self.data[self.pos_i] - for frame_i in range(last_tracked_i_to_restore+1, posData.SizeT): - data_frame_i = myutils.get_empty_stored_data_dict() - - data_frame_i['manually_edited_lab'] = ( - posData.allData_li[frame_i]['manually_edited_lab'] - ) - - posData.allData_li[frame_i] = data_frame_i - - self.navigateScrollBar.setMaximum(last_tracked_i_to_restore+1) - self.navSpinBox.setMaximum(last_tracked_i_to_restore+1) - - def setNavigateScrollBarMaximum(self): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Segmentation and Tracking': - if posData.last_tracked_i is not None: - if posData.frame_i > posData.last_tracked_i: - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) - else: - self.navigateScrollBar.setMaximum(posData.last_tracked_i+1) - self.navSpinBox.setMaximum(posData.last_tracked_i+1) - else: - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) - - self.updateLastCheckedFrameWidgets(self.navSpinBox.maximum()-1) - elif mode == 'Cell cycle analysis': - if posData.frame_i > self.last_cca_frame_i: - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) - else: - self.navigateScrollBar.setMaximum(self.last_cca_frame_i+1) - self.navSpinBox.setMaximum(self.last_cca_frame_i+1) - self.lastTrackedFrameLabel.setText( - f'Last cc annot. frame n. = {self.navSpinBox.maximum()}' - ) - elif mode == 'Normal division: Lineage tree': - if self.lineage_tree is None: - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) - else: - if self.lineage_tree.frames_for_dfs: - i = max(self.lineage_tree.frames_for_dfs) - else: - i = 0 - self.navigateScrollBar.setMaximum(i+1) - self.navSpinBox.setMaximum(i+1) - - # @exec_time - def prev_frame(self): - posData = self.data[self.pos_i] - if posData.frame_i <= 0: - msg = 'You reached the first frame!' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - return - - # Store data for current frame - mode = str(self.modeComboBox.currentText()) - if mode != 'Viewer': - self.store_data(debug=False) - - self.removeAlldelROIsCurrentFrame() - self.askLineageTreeChanges() - posData.frame_i -= 1 - _, never_visited = self.get_data() - - if mode == 'Segmentation and Tracking' or self.isSnapshot: - self.addExistingDelROIs() - - self.resetExpandLabel() - self.updatePreprocessPreview() - self.updateCombineChannelsPreview() - self.postProcessing() - self.tracking() - self.whitelistPropagateIDs(update_lab=True) - self.updateAllImages() - self.updateScrollbars() - self.updateHighlightedAxis() - self.zoomToCells() - self.initGhostObject() - self.updateViewerWindow() - self.updateItemsMousePos() - self.updateObjectCounts() - - def loadSelectedData(self, user_ch_file_paths, user_ch_name): - data = [] - numPos = len(user_ch_file_paths) - self.user_ch_file_paths = user_ch_file_paths - - self.logger.info(f'Reading {user_ch_name} channel metadata...') - # Get information from first loaded position - posData = load.loadData(user_ch_file_paths[0], user_ch_name, log_func=self.logger.info) - posData.getBasenameAndChNames(qparent=self) - posData.buildPaths() - - if posData.ext != '.h5': - self.lazyLoader.salute = False - self.lazyLoader.exit = True - self.lazyLoaderWaitCond.wakeAll() - self.waitReadH5cond.wakeAll() - - # Get end name of every existing segmentation file - existingSegmEndNames = set() - for filePath in user_ch_file_paths: - _posData = load.loadData(filePath, user_ch_name, log_func=self.logger.info) - _posData.getBasenameAndChNames(qparent=self) - segm_files = load.get_segm_files(_posData.images_path) - _existingEndnames = load.get_endnames( - _posData.basename, segm_files - ) - existingSegmEndNames.update(_existingEndnames) - - selectedSegmEndName = '' - self.newSegmEndName = '' - if self.isNewFile or not existingSegmEndNames: - self.isNewFile = True - # Remove the 'segm_' part to allow filenameDialog to check if - # a new file is existing (since we only ask for the part after - # 'segm_') - existingEndNames = [ - n.replace('segm', '', 1).replace('_', '', 1) - for n in existingSegmEndNames - ] - if posData.basename.endswith('_'): - basename = f'{posData.basename}segm' - else: - basename = f'{posData.basename}_segm' - win = apps.filenameDialog( - basename=basename, - hintText='Insert a filename for the segmentation file:', - existingNames=existingEndNames - ) - win.exec_() - if win.cancel: - self.loadingDataAborted() - return - self.newSegmEndName = win.entryText - else: - if len(existingSegmEndNames) > 0: - win = apps.SelectSegmFileDialog( - existingSegmEndNames, self.exp_path, parent=self, - addNewFileButton=True, basename=posData.basename - ) - win.exec_() - if win.cancel: - self.loadingDataAborted() - return - if win.newSegmEndName is None: - selectedSegmEndName = win.selectedItemText - self.AutoPilotProfile.storeSelectedSegmFile( - selectedSegmEndName - ) - else: - self.newSegmEndName = win.newSegmEndName - self.isNewFile = True - elif len(existingSegmEndNames) == 1: - selectedSegmEndName = list(existingSegmEndNames)[0] - - posData.loadImgData() - - required_ram = posData.getBytesImageData() - if required_ram >= 5e8: - # Disable autosave for data > 500MB - self.autoSaveToggle.setChecked(False) - - proceed = self.checkMemoryRequirements(required_ram) - if not proceed: - self.loadingDataAborted() - return - - posData.loadOtherFiles( - load_segm_data=True, - load_metadata=True, - create_new_segm=self.isNewFile, - new_endname=self.newSegmEndName, - end_filename_segm=selectedSegmEndName, - ) - self.selectedSegmEndName = selectedSegmEndName - self.labelBoolSegm = posData.labelBoolSegm - posData.labelSegmData() - - print('') - self.logger.info( - f'Segmentation filename: {posData.segm_npz_path}' - ) - - proceed = posData.askInputMetadata( - self.num_pos, - ask_SizeT=self.num_pos==1, - ask_TimeIncrement=True, - ask_PhysicalSizes=True, - singlePos=False, - save=True, - warnMultiPos=True - ) - if not proceed: - self.loadingDataAborted() - return - - self.AutoPilotProfile.storeOkAskInputMetadata() - - if posData.isSegm3D is None: - self.isSegm3D = False - else: - self.isSegm3D = posData.isSegm3D - self.SizeT = posData.SizeT - self.SizeZ = posData.SizeZ - self.TimeIncrement = posData.TimeIncrement - self.PhysicalSizeZ = posData.PhysicalSizeZ - self.PhysicalSizeY = posData.PhysicalSizeY - self.PhysicalSizeX = posData.PhysicalSizeX - self.loadSizeS = posData.loadSizeS - self.loadSizeT = posData.loadSizeT - self.loadSizeZ = posData.loadSizeZ - - self.overlayLabelsItems = {} - self.drawModeOverlayLabelsChannels = {} - - self.existingSegmEndNames = existingSegmEndNames - self.createOverlayLabelsContextMenu(existingSegmEndNames) - self.overlayLabelsButtonAction.setVisible(True) - self.createOverlayLabelsItems(existingSegmEndNames) - self.disableNonFunctionalButtons() - - self.isH5chunk = ( - posData.ext == '.h5' - and (self.loadSizeT != self.SizeT - or self.loadSizeZ != self.SizeZ) - ) - - required_ram = posData.checkH5memoryFootprint()*self.loadSizeS - if required_ram > 0: - proceed = self.checkMemoryRequirements(required_ram) - if not proceed: - self.loadingDataAborted() - return - - if posData.SizeT == 1: - self.isSnapshot = True - else: - self.isSnapshot = False - - self.progressWin = apps.QDialogWorkerProgress( - title='Loading data...', parent=self, - pbarDesc=f'Loading "{user_ch_file_paths[0]}"...' - ) - self.progressWin.show(self.app) - - func = partial( - self.startLoadDataWorker, user_ch_file_paths, user_ch_name, - posData - ) - - - QTimer.singleShot(150, func) - - def setManualAnnotModeEnabledTools(self, enabled): - for action in self.editToolBar.actions(): - toolButton = self.editToolBar.widgetForAction(action) - if toolButton in self.manulAnnotToolButtons: - continue - - toolButton.setDisabled(enabled) - action.setDisabled(enabled) - - def disableNonFunctionalButtons(self): - if not self.isSegm3D: - return - - for item in self.functionsNotTested3D: - if hasattr(item, 'action'): - toolButton = item - action = toolButton.action - toolButton.setDisabled(True) - elif hasattr(item, 'toolbar'): - toolbar = item.toolbar - action = item - toolButton = toolbar.widgetForAction(action) - toolButton.setDisabled(True) - else: - action = item - action.setDisabled(True) - - @exception_handler - def startLoadDataWorker( - self, user_ch_file_paths, user_ch_name, firstPosData - ): - self.funcDescription = 'loading data' - - self.guiTabControl.propsQGBox.idSB.setValue(0) - - self.thread = QThread() - self.loadDataMutex = QMutex() - self.loadDataWaitCond = QWaitCondition() - - self.loadDataWorker = workers.loadDataWorker( - self, user_ch_file_paths, user_ch_name, firstPosData - ) - - self.loadDataWorker.moveToThread(self.thread) - self.loadDataWorker.signals.finished.connect(self.thread.quit) - self.loadDataWorker.signals.finished.connect( - self.loadDataWorker.deleteLater - ) - self.thread.finished.connect(self.thread.deleteLater) - - self.loadDataWorker.signals.finished.connect( - self.loadDataWorkerFinished - ) - self.loadDataWorker.signals.progress.connect(self.workerProgress) - self.loadDataWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.loadDataWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.loadDataWorker.signals.critical.connect( - self.workerCritical - ) - self.loadDataWorker.signals.dataIntegrityCritical.connect( - self.loadDataWorkerDataIntegrityCritical - ) - self.loadDataWorker.signals.dataIntegrityWarning.connect( - self.loadDataWorkerDataIntegrityWarning - ) - self.loadDataWorker.signals.sigPermissionError.connect( - self.workerPermissionError - ) - self.loadDataWorker.signals.sigWarnMismatchSegmDataShape.connect( - self.askMismatchSegmDataShape - ) - self.loadDataWorker.signals.sigRecovery.connect( - self.askRecoverNotSavedData - ) - - self.thread.started.connect(self.loadDataWorker.run) - self.thread.start() - - def askRecoverNotSavedData(self, posData): - last_modified_time_unsaved = 'NEVER' - if os.path.exists(posData.segm_npz_temp_path): - recovered_file_path = posData.segm_npz_temp_path - if os.path.exists(posData.segm_npz_path): - last_modified_time_unsaved = ( - datetime.fromtimestamp( - os.path.getmtime(posData.segm_npz_path) - ).strftime("%a %d. %b. %y - %H:%M:%S") - ) - else: - posData.setTempPaths() - if os.path.exists(posData.unsaved_acdc_df_autosave_path): - zip_path = posData.unsaved_acdc_df_autosave_path - with zipfile.ZipFile(zip_path, mode='r') as zip: - csv_names = natsorted(set(zip.namelist())) - iso_key = csv_names[-1][:-4] - most_recent_unsaved_acdc_df_datetime = datetime.strptime( - iso_key, load.ISO_TIMESTAMP_FORMAT - ) - last_modified_time_unsaved = ( - most_recent_unsaved_acdc_df_datetime - ).strftime("%a %d. %b. %y - %H:%M:%S") - - if os.path.exists(posData.acdc_output_csv_path): - acdc_df_mtime = os.path.getmtime(posData.acdc_output_csv_path) - timestamp = datetime.fromtimestamp(acdc_df_mtime) - last_modified_time_saved = timestamp.strftime( - "%a %d. %b. %y - %H:%M:%S" - ) - else: - last_modified_time_saved = 'Null' - - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = html_utils.paragraph(""" - Cell-ACDC detected unsaved data.

- Do you want to load and recover the unsaved data or - load the data that was last saved by the user? - """) - details = (f""" - The unsaved data was created on {last_modified_time_unsaved}\n\n - The user saved the data last time on {last_modified_time_saved} - """) - msg.setDetailedText(details) - loadUnsavedButton = widgets.reloadPushButton('Recover unsaved data') - loadSavedButton = widgets.savePushButton('Load saved data') - infoButton = widgets.infoPushButton('More info...') - loadSafeNpzButton = '' - if posData.isSafeNpzOverwritePresent(): - loadSafeNpzButton = widgets.reloadPushButton( - 'Load .safe.npz file from crash' - ) - buttons = ( - loadSavedButton, loadUnsavedButton, loadSafeNpzButton, - infoButton - ) - else: - buttons = (loadSavedButton, loadUnsavedButton, infoButton) - msg.question( - self.progressWin, 'Recover unsaved data?', txt, - buttonsTexts=('Cancel', *buttons), - showDialog=False - ) - infoButton.disconnect() - infoButton.clicked.connect(partial(self.showInfoAutosave, posData)) - msg.exec_() - if msg.cancel: - self.loadDataWorker.abort = True - elif msg.clickedButton == loadUnsavedButton: - self.loadDataWorker.loadUnsaved = True - elif msg.clickedButton == loadSafeNpzButton: - self.loadDataWorker.loadSafeOverwriteNpz = True - - self.loadDataWorker.waitCond.wakeAll() - # self.AutoPilotProfile.storeLoadSavedData() - - def showInfoAutosave(self, posData): - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = (f""" - Cell-ACDC either detected unsaved data in a previous session and it - stored it because the Autosave
- function was active, or it crashed during saving.

- You can toggle Autosave ON and OFF from the menu on the top menubar - File --> Autosave. - """) - txt = (f""" - {txt}

- If Cell-ACDC crashed during saving, the segmentation file ending - with .new.npz
- is present and you might be able to recover the data from there. - """) - - txt = (f""" - {txt}

- You can find additional recovered data in the following folder: - """) - txt = html_utils.paragraph(txt) - msg.information( - self, 'Autosave info', txt, - path_to_browse=posData.recoveryFolderPath, - commands=(posData.recoveryFolderPath,) - ) - - def askMismatchSegmDataShape(self, posData): - msg = widgets.myMessageBox(wrapText=False) - title = 'Segm. data shape mismatch' - f = '3D' if self.isSegm3D else '2D' - f = f'{f} over time' if posData.SizeT > 1 else f - r = '2D' if self.isSegm3D else '3D' - r = f'{r} over time' if posData.SizeT > 1 else r - text = html_utils.paragraph(f""" - The segmentation masks of the first Position that you loaded is - {f},
- while {posData.pos_foldername} is {r}.

- The loaded segmentation masks must be either all 3D - or all 2D.

- Do you want to skip loading this position or cancel the process? - """) - _, skipPosButton = msg.warning( - self, title, text, buttonsTexts=('Cancel', 'Skip this Position') - ) - if skipPosButton == msg.clickedButton: - self.loadDataWorker.skipPos = True - self.loadDataWorker.waitCond.wakeAll() - - def workerPermissionError(self, txt, waitCond): - msg = widgets.myMessageBox(parent=self) - msg.setIcon(iconName='SP_MessageBoxCritical') - msg.setWindowTitle('Permission denied') - msg.addText(txt) - msg.addButton(' Ok ') - msg.exec_() - waitCond.wakeAll() - - def loadDataWorkerDataIntegrityCritical(self): - errTitle = 'All loaded positions contains frames over time!' - self.titleLabel.setText(errTitle, color='r') - - msg = widgets.myMessageBox(parent=self) - - err_msg = html_utils.paragraph(f""" - {errTitle}.

- To load data that contains frames over time you have to select - only ONE position. - """) - msg.setIcon(iconName='SP_MessageBoxCritical') - msg.setWindowTitle('Loaded multiple positions with frames!') - msg.addText(err_msg) - msg.addButton('Ok') - msg.show(block=True) - - @exception_handler - def loadDataWorkerFinished(self, data): - self.funcDescription = 'loading data worker finished' - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - if data is None or data=='abort': - self.loadingDataAborted() - return - - if data[0].onlyEditMetadata: - self.loadingDataAborted() - return - - self.pos_i = 0 - self.data = data - self.gui_createGraphicsItems() - return True - - def checkManageVersions(self): - posData = self.data[self.pos_i] - posData.setTempPaths(createFolder=False) - loaded_acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) - - if os.path.exists(posData.recoveryFolderpath()): - self.manageVersionsAction.setDisabled(False) - self.manageVersionsAction.setToolTip( - f'Load an older version of the `{loaded_acdc_df_filename}` file ' - '(table with annotations and measurements).' - ) - else: - self.manageVersionsAction.setDisabled(True) - - def preprocessPreviewToggled(self, checked): - self.viewPreprocDataToggle.setChecked(checked) - self.updatePreprocessPreview() - - - - def preprocessCurrentImage(self, recipe: List[Dict[str, Any]], *args): - txt = 'Pre-processing current image...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - func = core.preprocess_image_from_recipe - recipe = core.validate_multidimensional_recipe(recipe) - - image_data = self.getImage(raw=True) - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'current_image' - ) - - self.preprocWorker.wakeUp() - - def preprocessZStack(self, recipe: List[Dict[str, Any]], *args): - txt = 'Pre-processing z-stack...' - self.statusBarLabel.setText(txt) - self.logger.info(txt) - - posData = self.data[self.pos_i] - func = core.preprocess_zstack_from_recipe - recipe = core.validate_multidimensional_recipe( - recipe, apply_to_all_frames=False - ) - image_data = posData.img_data[posData.frame_i] - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'z_stack' - ) - - self.preprocWorker.wakeUp() - - def preprocessAllFrames(self, recipe: List[Dict[str, Any]]): - txt = 'Pre-processing all frames...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - posData = self.data[self.pos_i] - func = core.preprocess_video_from_recipe - image_data = posData.img_data - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'all_frames' - ) - self.preprocWorker.wakeUp() - - def preprocessAllPos(self, recipe: List[Dict[str, Any]]): - txt = 'Pre-processing all Positions...' - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - func = core.preprocess_multi_pos_from_recipe - recipe = core.validate_multidimensional_recipe( - recipe, apply_to_all_frames=False - ) - image_data = [posData.img_data[0] for posData in self.data] - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'all_pos' - ) - - self.preprocWorker.wakeUp() - - def setupPreprocessing(self): - posData = self.data[self.pos_i] - if self.preprocessDialog is not None: - self.preprocessDialog.close() - - self.preprocessDialog = apps.PreProcessRecipeDialog( - isTimelapse=posData.SizeT>1, - isZstack=posData.SizeZ>1, - isMultiPos=len(self.data)>1, - df_metadata=posData.metadata_df, - hideOnClosing=True, - addApplyButton=True, - parent=self - ) - self.doPreviewPreprocImage = False - self.preprocessDialog.sigApplyImage.connect( - self.preprocessCurrentImage - ) - self.preprocessDialog.sigApplyZstack.connect( - self.preprocessZStack - ) - self.preprocessDialog.sigApplyAllFrames.connect( - self.preprocessAllFrames - ) - self.preprocessDialog.sigApplyAllPos.connect( - self.preprocessAllPos - ) - self.preprocessDialog.sigPreviewToggled.connect( - self.preprocessPreviewToggled - ) - self.preprocessDialog.sigValuesChanged.connect( - self.preprocessDialogRecipeChanged - ) - self.preprocessDialog.sigSavePreprocData.connect( - self.preprocessDialogSavePreprocessedData - ) - - if self.preprocWorker is not None: - return - - self.preprocThread = QThread() - self.preprocMutex = QMutex() - self.preprocWaitCond = QWaitCondition() - - self.preprocWorker = workers.CustomPreprocessWorkerGUI( - self.preprocMutex, self.preprocWaitCond - ) - - self.preprocWorker.moveToThread(self.preprocThread) - self.preprocWorker.signals.finished.connect(self.preprocThread.quit) - self.preprocWorker.signals.finished.connect( - self.preprocWorker.deleteLater - ) - self.preprocThread.finished.connect(self.preprocThread.deleteLater) - - self.preprocWorker.sigDone.connect(self.preprocWorkerDone) - self.preprocWorker.sigIsQueueEmpty.connect( - self.preprocWorkerIsQueueEmpty - ) - self.preprocWorker.sigPreviewDone.connect(self.preprocWorkerPreviewDone) - self.preprocWorker.signals.progress.connect(self.workerProgress) - self.preprocWorker.signals.critical.connect(self.workerCritical) - self.preprocWorker.signals.finished.connect(self.preprocWorkerClosed) - - self.preprocThread.started.connect(self.preprocWorker.run) - self.preprocThread.start() - - self.logger.info('Pre-processing worker started.') - - def preprocWorkerCritical(self, error): - self.preprocessDialog.appliedFinished() - self.workerCritical(error) - - @exception_handler - def loadingDataCompleted(self): - self.isDataLoading = True - posData = self.data[self.pos_i] - - files_format = '\n'.join([ - f' - {file}' for file in posData.images_folder_files - ]) - sep = '-'*100 - self.logger.info( - f'{sep}\nFiles present in the first Position folder loaded:\n\n' - f'{files_format}\n{sep}' - ) - self.logger.info(f'Basename of the first Position: {posData.basename}') - self.secondLevelToolbar.setVisible(True) - self.updateImageValueFormatter() - self.checkManageVersions() - self.initManualBackgroundImage() - self.initPixelSizePropsDockWidget() - - self.setWindowTitle( - f'Cell-ACDC v{self._acdc_version} - GUI - "{posData.exp_path}"' - ) - - self.setupPreprocessing() - self.setupCombiningChannels() - - if self.isSegm3D: - self.segmNdimIndicator.setText('3D') - else: - self.segmNdimIndicator.setText('2D') - - self.segmNdimIndicatorAction.setVisible(True) - - self.guiTabControl.addChannels([posData.user_ch_name]) - self.showPropsDockButton.setDisabled(False) - - self.bottomScrollArea.show() - self.gui_createStoreStateWorker() - self.init_segmInfo_df() - self.connectScrollbars() - self.initPosAttr() - - self.logger.info('Pre-computing min and max values of the images...') - self.img1.preComputedMinMaxValues(self.data) - self.img2.minMaxValuesMapper = self.img1.minMaxValuesMapper - - self.initMetrics() - self.initFluoData() - self.createChannelNamesActions() - self.addActionsLutItemContextMenu(self.imgGrad) - - # Scrollbar for opacity of img1 (when overlaying) - self.img1.alphaScrollbar = self.addAlphaScrollbar( - self.user_ch_name, self.img1 - ) - - self.navigateScrollBar.setSliderPosition(posData.frame_i+1) - - # Connect events at the end of loading data process - self.gui_connectGraphicsEvents() - if not self.isEditActionsConnected: - self.gui_connectEditActions() - self.normalizeToFloatAction.setChecked(True) - - self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) - - self.setFramesSnapshotMode() - if self.isSnapshot: - self.navSizeLabel.setText(f'/{len(self.data)}') - else: - self.navSizeLabel.setText(f'/{posData.SizeT}') - - self.enableZstackWidgets(posData.SizeZ > 1) - # self.showHighlightZneighCheckbox() - - self.exportToVideoAction.setDisabled( - posData.SizeZ == 1 and posData.SizeT == 1 - ) - - self.img1BottomGroupbox.show() - - isLabVisible = self.df_settings.at['isLabelsVisible', 'value'] == 'Yes' - isRightImgVisible = ( - self.df_settings.at['isRightImageVisible', 'value'] == 'Yes' - ) - isNextFrameVisible = ( - self.df_settings.at['isNextFrameVisible', 'value'] == 'Yes' - ) - isNextFrameActive = ( - isNextFrameVisible and self.labelsGrad.showNextFrameAction.isEnabled() - ) - self.updateScrollbars() - self.openFolderAction.setEnabled(True) - self.editTextIDsColorAction.setDisabled(False) - self.imgPropertiesAction.setEnabled(True) - self.navigateToolBar.setVisible(True) - self.labelsGrad.showLabelsImgAction.setChecked(isLabVisible) - self.labelsGrad.showRightImgAction.setChecked(isRightImgVisible) - self.labelsGrad.showNextFrameAction.setChecked(isNextFrameActive) - if isRightImgVisible or isNextFrameActive: - self.rightBottomGroupbox.setChecked(True) - - isTwoImagesLayout = ( - isRightImgVisible or isLabVisible or isNextFrameActive - ) - self.setTwoImagesLayout(isTwoImagesLayout) - - self.setBottomLayoutStretch() - - if isNextFrameActive: - self.rightBottomGroupbox.show() - self.rightBottomGroupbox.setChecked(True) - self.drawNothingCheckboxRight.click() - - self.readSavedCustomAnnot() - self.addCustomAnnotButtonAllLoadedPos() - self.setStatusBarLabel() - - self.initLookupTableLab() - if self.invertBwAction.isChecked() and not self.invertBwAlreadyCalledOnce: - self.invertBw(True) - self.restoreSavedSettings() - - self.initContoursImage() - self.initTextAnnot() - self.initDelRoiLab() - - self.update_rp() - self.updateAllImages() - if posData.SizeT > 1: - self.rightImageFramesScrollbar.setValueNoSignal(posData.frame_i+2) - self.setMetricsFunc() - - self.gui_createLabelRoiItem() - self.gui_createZoomRectItem() - - self.titleLabel.setText( - 'Data successfully loaded.', - color=self.titleColor - ) - - self.disableNonFunctionalButtons() - self.setVisible3DsegmWidgets() - - if len(self.data) == 1 and posData.SizeZ > 1 and posData.SizeT == 1: - self.zSliceCheckbox.setChecked(True) - else: - self.zSliceCheckbox.setChecked(False) - - self.labelRoiCircItemLeft.setImageShape(self.currentLab2D.shape) - self.labelRoiCircItemRight.setImageShape(self.currentLab2D.shape) - - self.retainSpaceSlidersToggled(self.retainSpaceSlidersAction.isChecked()) - - self.stopAutomaticLoadingPos() - self.viewAllCustomAnnotAction.setChecked(True) - - self.updateImageValueFormatter() - - posData.loadWhitelist() - - self.setFocusGraphics() - self.setFocusMain() - - # Overwrite axes viewbox context menu - self.ax1.vb.menu = self.imgGrad.gradient.menu - self.ax2.vb.menu = self.labelsGrad.menu - - QTimer.singleShot(200, self.resizeGui) - - self.isDataLoaded = True - self.isDataLoading = False - - self.initImgGradRescaleIntensitiesHowPreference() - - self.rescaleIntensitiesLut(setImage=False) - - self.gui_createAutoSaveWorker() - - def initImgGradRescaleIntensitiesHowPreference(self): - posData = self.data[self.pos_i] - channelName = posData.user_ch_name - if f'how_rescale_intensities_{channelName}' not in self.df_settings.index: - return - - how = self.df_settings.at[ - f'how_rescale_intensities_{channelName}', 'value' - ] - self.imgGrad.setRescaleIntensitiesHow(how) - - def removeAxLimits(self): - self.ax1.vb.state['limits']['xLimits'] = [-1E307, +1E307] - self.ax1.vb.state['limits']['yLimits'] = [-1E307, +1E307] - - def resizeGui(self): - self.ax1.vb.state['limits']['xRange'] = [None, None] - self.ax1.vb.state['limits']['yRange'] = [None, None] - self.autoRange() - if self.ax1.getViewBox().state['limits']['xRange'][0] is not None: - self.bottomScrollArea._resizeVertical() - return - (xmin, xmax), (ymin, ymax) = self.ax1.viewRange() - maxYRange = int((ymax-ymin)*1.5) - maxXRange = int((xmax-xmin)*1.5) - self.ax1.setLimits( - maxYRange=maxYRange, - maxXRange=maxXRange - ) - self.bottomScrollArea._resizeVertical() - QTimer.singleShot(200, self.autoRange) - - def setVisible3DsegmWidgets(self): - self.annotNumZslicesCheckbox.setVisible(self.isSegm3D) - self.annotNumZslicesCheckboxRight.setVisible(self.isSegm3D) - if not self.isSegm3D: - self.annotNumZslicesCheckbox.setChecked(False) - self.annotNumZslicesCheckboxRight.setChecked(False) - - def showHighlightZneighCheckbox(self): - if self.isSegm3D: - layout = self.bottomLeftLayout - # layout.addWidget(self.annotOptionsWidget, 0, 1, 1, 2) - # # layout.removeWidget(self.drawIDsContComboBox) - # # layout.addWidget(self.drawIDsContComboBox, 0, 1, 1, 1, - # # alignment=Qt.AlignCenter - # # ) - # layout.addWidget(self.highlightZneighObjCheckbox, 0, 2, 1, 2, - # alignment=Qt.AlignRight - # ) - self.highlightZneighObjCheckbox.show() - self.highlightZneighObjCheckbox.setChecked(True) - self.highlightZneighObjCheckbox.toggled.connect( - self.highlightZneighLabels_cb - ) - - def restoreSavedSettings(self): - if 'how_draw_annotations' in self.df_settings.index: - how = self.df_settings.at['how_draw_annotations', 'value'] - self.drawIDsContComboBox.setCurrentText(how) - else: - self.drawIDsContComboBox.setCurrentText('Draw IDs and contours') - - if 'how_draw_right_annotations' in self.df_settings.index: - how = self.df_settings.at['how_draw_right_annotations', 'value'] - self.annotateRightHowCombobox.setCurrentText(how) - else: - self.annotateRightHowCombobox.setCurrentText( - 'Draw IDs and overlay segm. masks' - ) - - if 'addNewIDsWhitelistToggle' in self.df_settings.index: - self.addNewIDsWhitelistToggle = ( - self.df_settings.at['addNewIDsWhitelistToggle', 'value'] - ) == 'Yes' - else: - self.addNewIDsWhitelistToggle = True - - self.drawAnnotCombobox_to_options() - self.drawIDsContComboBox_cb(0) - self.annotateRightHowCombobox_cb(0) - - def uncheckAnnotOptions(self, left=True, right=True): - # Left - if left: - self.annotIDsCheckbox.setChecked(False) - self.annotCcaInfoCheckbox.setChecked(False) - self.annotContourCheckbox.setChecked(False) - self.annotSegmMasksCheckbox.setChecked(False) - self.drawMothBudLinesCheckbox.setChecked(False) - self.drawNothingCheckbox.setChecked(False) - - # Right - if right: - self.annotIDsCheckboxRight.setChecked(False) - self.annotCcaInfoCheckboxRight.setChecked(False) - self.annotContourCheckboxRight.setChecked(False) - self.annotSegmMasksCheckboxRight.setChecked(False) - self.drawMothBudLinesCheckboxRight.setChecked(False) - self.drawNothingCheckboxRight.setChecked(False) - - def setDisabledAnnotOptions(self, disabled): - # Left - self.annotIDsCheckbox.setDisabled(disabled) - self.annotCcaInfoCheckbox.setDisabled(disabled) - self.annotContourCheckbox.setDisabled(disabled) - # self.annotSegmMasksCheckbox.setDisabled(disabled) - self.drawMothBudLinesCheckbox.setDisabled(disabled) - # self.drawNothingCheckbox.setDisabled(disabled) - - # Right - self.annotIDsCheckboxRight.setDisabled(disabled) - self.annotCcaInfoCheckboxRight.setDisabled(disabled) - self.annotContourCheckboxRight.setDisabled(disabled) - # self.annotSegmMasksCheckboxRight.setDisabled(disabled) - self.drawMothBudLinesCheckboxRight.setDisabled(disabled) - # self.drawNothingCheckboxRight.setDisabled(disabled) - - def drawAnnotCombobox_to_options(self): - self.uncheckAnnotOptions() - - # Left - how = self.drawIDsContComboBox.currentText() - if how.find('IDs') != -1: - self.annotIDsCheckbox.setChecked(True) - if how.find('cell cycle info') != -1: - self.annotCcaInfoCheckbox.setChecked(True) - if how.find('contours') != -1: - self.annotContourCheckbox.setChecked(True) - if how.find('segm. masks') != -1: - self.annotSegmMasksCheckbox.setChecked(True) - if how.find('mother-bud lines') != -1: - self.drawMothBudLinesCheckbox.setChecked(True) - if how.find('nothing') != -1: - self.drawNothingCheckbox.setChecked(True) - - # Right - how = self.annotateRightHowCombobox.currentText() - if how.find('IDs') != -1: - self.annotIDsCheckboxRight.setChecked(True) - if how.find('cell cycle info') != -1: - self.annotCcaInfoCheckboxRight.setChecked(True) - if how.find('contours') != -1: - self.annotContourCheckboxRight.setChecked(True) - if how.find('segm. masks') != -1: - self.annotSegmMasksCheckboxRight.setChecked(True) - if how.find('mother-bud lines') != -1: - self.drawMothBudLinesCheckboxRight.setChecked(True) - if how.find('nothing') != -1: - self.drawNothingCheckboxRight.setChecked(True) - - def setStatusBarLabel(self, log=True): - self.statusbar.clearMessage() - posData = self.data[self.pos_i] - segmentedChannelname = posData.filename[len(posData.basename):] - segmFilename = os.path.basename(posData.segm_npz_path) - segmEndName = segmFilename[len(posData.basename):] - txt = ( - f'{posData.pos_foldername} || ' - f'Basename: {posData.basename} || ' - f'Segmented channel: {segmentedChannelname} || ' - f'Segmentation file name: {segmEndName}' - ) - mode = str(self.modeComboBox.currentText()) - if log: - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - def autoRange(self): - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.ax2.autoRange() - self.ax1.autoRange() - - def resetRange(self): - if self.ax1_viewRange is None: - return - xRange, yRange = self.ax1_viewRange - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.ax2.vb.setRange(xRange=xRange, yRange=yRange) - self.ax1.vb.setRange(xRange=xRange, yRange=yRange) - self.ax1_viewRange = None - self.isRangeReset = True - - def setFramesSnapshotMode(self): - self.measurementsMenu.setDisabled(False) - self.setPermanentGreedyCmapPreferences() - if self.isSnapshot: - self.realTimeTrackingToggle.setDisabled(True) - self.realTimeTrackingToggle.label.setDisabled(True) - try: - self.drawIDsContComboBox.currentIndexChanged.disconnect() - except Exception as e: - pass - - self.imgGrad.rescaleAcrossTimeAction.setDisabled(True) - self.repeatTrackingAction.setDisabled(True) - self.manualTrackingAction.setDisabled(True) - self.logger.info('Setting GUI mode to "Snapshots"...') - self.modeComboBox.clear() - self.modeComboBox.addItems(['Snapshot']) - self.modeComboBox.setDisabled(True) - self.modeMenu.menuAction().setVisible(False) - self.drawIDsContComboBox.clear() - self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) - self.drawIDsContComboBox.setCurrentIndex(1) - self.modeToolBar.setVisible(False) - self.skipToNewIdAction.setVisible(False) - self.skipToNewIdAction.setDisabled(True) - self.modeComboBox.setCurrentText('Snapshot') - self.annotateToolbar.setVisible(True) - self.labelsGrad.showNextFrameAction.setDisabled(True) - self.drawIDsContComboBox.currentIndexChanged.connect( - self.drawIDsContComboBox_cb - ) - self.showTreeInfoCheckbox.hide() - self.rightImageFramesScrollbar.setVisible(False) - self.rightImageFramesScrollbar.setDisabled(True) - if not self.isSegm3D: - self.manualBackgroundAction.setVisible(True) - self.manualBackgroundAction.setDisabled(False) - else: - self.manualBackgroundAction.setVisible(False) - self.manualBackgroundAction.setDisabled(True) - self.manualAnnotPastButton.setDisabled(True) - self.manualAnnotPastButton.action.setDisabled(True) - self.manualAnnotPastButton.setVisible(False) - self.manualAnnotPastButton.action.setVisible(False) - self.copyLostObjButton.setDisabled(True) - self.copyLostObjButton.action.setDisabled(True) - self.copyLostObjButton.setVisible(False) - self.copyLostObjButton.action.setVisible(False) - self.segForLostIDsAction.setVisible(False) - self.segForLostIDsAction.setDisabled(True) - self.delNewObjAction.setVisible(False) - self.delNewObjAction.setDisabled(True) - else: - self.imgGrad.rescaleAcrossTimeAction.setDisabled(False) - self.annotateToolbar.setVisible(False) - self.realTimeTrackingToggle.setDisabled(False) - self.repeatTrackingAction.setDisabled(False) - self.manualTrackingAction.setDisabled(False) - self.modeComboBox.setDisabled(False) - self.modeMenu.menuAction().setVisible(True) - self.skipToNewIdAction.setVisible(True) - self.skipToNewIdAction.setDisabled(False) - try: - self.modeComboBox.activated.disconnect() - self.modeComboBox.sigTextChanged.disconnect() - self.drawIDsContComboBox.currentIndexChanged.disconnect() - except Exception as e: - pass - # traceback.print_exc() - self.modeComboBox.clear() - self.modeComboBox.addItems(self.modeItems) - self.drawIDsContComboBox.clear() - self.drawIDsContComboBox.addItems(self.drawIDsContComboBoxSegmItems) - self.modeComboBox.sigTextChanged.connect(self.changeMode) - self.modeComboBox.activated.connect(self.clearComboBoxFocus) - self.drawIDsContComboBox.currentIndexChanged.connect( - self.drawIDsContComboBox_cb) - self.modeComboBox.setCurrentText('Viewer') - self.showTreeInfoCheckbox.show() - self.manualBackgroundAction.setVisible(False) - self.manualBackgroundAction.setDisabled(True) - self.labelsGrad.showNextFrameAction.setDisabled(False) - self.manualAnnotPastButton.setDisabled(False) - self.manualAnnotPastButton.action.setDisabled(False) - self.manualAnnotPastButton.setVisible(True) - self.manualAnnotPastButton.action.setVisible(True) - self.copyLostObjButton.setDisabled(False) - self.copyLostObjButton.action.setDisabled(False) - self.copyLostObjButton.setVisible(True) - self.copyLostObjButton.action.setVisible(True) - self.segForLostIDsAction.setVisible(True) - self.segForLostIDsAction.setDisabled(False) - self.delNewObjAction.setVisible(True) - self.delNewObjAction.setDisabled(False) - - for ch, overlayItems in self.overlayLayersItems.items(): - lutItem = overlayItems[1] - lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot) - - def checkIfAutoSegm(self): - """ - If there are any frame or position with empty segmentation mask - ask whether automatic segmentation should be turned ON - """ - if self.autoSegmAction.isChecked(): - return - if self.autoSegmDoNotAskAgain: - return - - ask = False - for posData in self.data: - if posData.SizeT > 1: - for lab in posData.segm_data: - if not np.any(lab): - ask = True - txt = 'frames' - break - else: - if not np.any(posData.segm_data): - ask = True - txt = 'positions' - break - - if not ask: - return - - questionTxt = html_utils.paragraph( - f'Some or all loaded {txt} contain empty segmentation masks.

' - 'Do you want to activate automatic segmentation* ' - f'when visiting these {txt}?

' - '* Automatic segmentation can always be turned ON/OFF from the menu
' - ' Edit --> Segmentation --> Enable automatic segmentation

' - f'NOTE: you can automatically segment all {txt} using the
' - ' segmentation module.' - ) - msg = widgets.myMessageBox(wrapText=False) - noButton, yesButton = msg.question( - self, 'Automatic segmentation?', questionTxt, - buttonsTexts=('No', 'Yes') - ) - if msg.clickedButton == yesButton: - self.autoSegmAction.setChecked(True) - else: - self.autoSegmDoNotAskAgain = True - self.autoSegmAction.setChecked(False) - - def init_segmInfo_df(self): - for posData in self.data: - if posData is None: - # posData is None when computing measurements with the utility - # and with timelapse data - continue - posData.init_segmInfo_df() - - def connectScrollbars(self): - self.t_label.show() - self.navigateScrollBar.show() - self.navigateScrollBar.setDisabled(False) - - if self.data[0].SizeZ > 1: - self.enableZstackWidgets(True) - self.zSliceScrollBar.setMaximum(self.data[0].SizeZ-1) - self.zSliceSpinbox.setMaximum(self.data[0].SizeZ) - self.SizeZlabel.setText(f'/{self.data[0].SizeZ}') - try: - self.zSliceScrollBar.actionTriggered.disconnect() - self.zSliceScrollBar.sliderReleased.disconnect() - self.zProjComboBox.currentTextChanged.disconnect() - self.zProjComboBox.activated.disconnect() - self.switchPlaneCombobox.sigPlaneChanged.disconnect() - self.zProjLockViewButton.toggled.disconnect() - except Exception as e: - pass - self.zSliceScrollBar.actionTriggered.connect( - self.zSliceScrollBarActionTriggered - ) - self.zSliceScrollBar.sliderReleased.connect( - self.zSliceScrollBarReleased - ) - self.zProjComboBox.currentTextChanged.connect(self.updateZproj) - self.zProjComboBox.activated.connect(self.clearComboBoxFocus) - self.switchPlaneCombobox.sigPlaneChanged.connect( - self.switchViewedPlane - ) - self.zProjLockViewButton.toggled.connect(self.zProjLockViewToggled) - - posData = self.data[self.pos_i] - if posData.SizeT == 1: - self.t_label.setText('Position n.') - self.navigateScrollBar.setMinimum(1) - self.navigateScrollBar.setMaximum(len(self.data)) - self.navigateScrollBar.setAbsoluteMaximum(len(self.data)) - self.navSpinBox.setMaximum(len(self.data)) - self.navigateScrollBar.connectEvents({ - 'sliderMoved': self.PosScrollBarMoved, - 'sliderReleased': self.PosScrollBarReleased, - 'actionTriggered': self.PosScrollBarAction - }) - else: - self.navigateScrollBar.setMinimum(1) - self.navigateScrollBar.setAbsoluteMaximum(posData.SizeT) - self.rightImageFramesScrollbar.setMinimum(1) - self.rightImageFramesScrollbar.setMaximum(posData.SizeT) - if posData.last_tracked_i is not None: - self.navigateScrollBar.setMaximum(posData.last_tracked_i+1) - self.navSpinBox.setMaximum(posData.last_tracked_i+1) - self.t_label.setText('Frame n.') - self.navigateScrollBar.connectEvents({ - 'sliderMoved': self.framesScrollBarMoved, - 'sliderReleased': self.framesScrollBarReleased, - 'actionTriggered': self.framesScrollBarActionTriggered - }) - self.rightImageFramesScrollbar.connectValueChanged( - self.rightImageFramesScrollbarValueChanged - ) - - def zSliceScrollBarActionTriggered(self, action): - singleMove = ( - action == SliderSingleStepAdd - or action == SliderSingleStepSub - or action == SliderPageStepAdd - or action == SliderPageStepSub - ) - if singleMove: - self.update_z_slice(self.zSliceScrollBar.sliderPosition()) - elif action == SliderMove: - if self.zSliceScrollBarStartedMoving and self.isSegm3D: - self.clearAx1Items(onlyHideText=True) - self.clearAx2Items(onlyHideText=True) - posData = self.data[self.pos_i] - idx = (posData.filename, posData.frame_i) - z = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == 'z': - posData.segmInfo_df.at[idx, 'z_slice_used_gui'] = z - self.zSliceSpinbox.setValueNoEmit(z+1) - img = self._getImageupdateAllImages(None) - self.img1.setCurrentZsliceIndex(z) - self.img1.setImage( - img, next_frame_image=self.nextFrameImage(), - scrollbar_value=posData.frame_i+2 - ) - try: - self.setOverlayImages() - except Exception as err: - pass - - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.img2.setImage(posData.lab, z=z, autoLevels=False) - self.updateViewerWindow() - self.setTextAnnotZsliceScrolling() - self.setGraphicalAnnotZsliceScrolling() - self.setOverlayLabelsItems() - self.drawPointsLayers(computePointsLayers=False) - self.zSliceScrollBarStartedMoving = False - self.highlightSearchedID(self.highlightedID, force=True) - - def zSliceScrollBarReleased(self): - self.clearTempBrushImage() - self.zSliceScrollBarStartedMoving = True - self.update_z_slice(self.zSliceScrollBar.sliderPosition()) - - def setSwitchViewedPlaneDisabled(self, disabled): - posData = self.data[self.pos_i] - if posData.SizeZ == 1: - return - - self.switchPlaneCombobox.setDisabled(disabled) - if disabled: - self.switchPlaneCombobox.setCurrentIndex(0) - - def _setViewRangeSwitchPlane(self, previousPlane): - posData = self.data[self.pos_i] - SizeZ = posData.SizeZ - SizeY, SizeX = self.img1.image.shape[:2] - currentPlane = self.switchPlaneCombobox.plane() - if previousPlane == 'xy': - if currentPlane == 'zy': - self.ax1.setRange(xRange=self.yRangePrev) - unusedRange = np.clip(self.xRangePrev, 0, SizeX) - elif currentPlane == 'zx': - self.ax1.setRange(xRange=self.xRangePrev) - unusedRange = np.clip(self.yRangePrev, 0, SizeY) - elif previousPlane == 'zy': - if currentPlane == 'xy': - self.ax1.setRange(yRange=self.xRangePrev) - unusedRange = np.clip(self.yRangePrev, 0, SizeZ) - elif currentPlane == 'zx': - self.ax1.setRange(yRange=self.yRangePrev) - unusedRange = np.clip(self.xRangePrev, 0, SizeY) - elif previousPlane == 'zx': - if currentPlane == 'xy': - self.ax1.setRange(xRange=self.xRangePrev) - unusedRange = np.clip(self.yRangePrev, 0, SizeZ) - elif currentPlane == 'zy': - self.ax1.setRange(yRange=self.yRangePrev) - unusedRange = np.clip(self.xRangePrev, 0, SizeX) - - sliceValue = round((unusedRange[0] + unusedRange[1])/2) - self.zSliceScrollBar.setSliderPosition(sliceValue) - self.update_z_slice(self.zSliceScrollBar.sliderPosition()) - - def setViewRangeSwitchPlane(self, previousPlane): - self.autoRange() - QTimer.singleShot( - 100, partial(self._setViewRangeSwitchPlane, previousPlane) - ) - - def switchViewedPlane(self, previousPlane, currentPlane): - posData = self.data[self.pos_i] - self.xRangePrev, self.yRangePrev = self.ax1.viewRange() - self.zSlicePrev = self.zSliceScrollBar.sliderPosition() - - self.zProjComboBox.setCurrentText('single z-slice') - depthAxes = self.switchPlaneCombobox.depthAxes() - self.onEscape() - self.initDelRoiLab() - if depthAxes != 'z': - # Disable projections on plane that is not xy - self.zProjComboBox.setCurrentText('single z-slice') - self.zProjComboBox.setDisabled(True) - - # Clear annotations - self.clearAllItems() - self.setHighlightID(False) - - # Disable annotations on a plane that is not yz - self.setDrawNothingAnnotations() - self.setDisabledAnnotCheckBoxesLeft(True) - self.setDisabledAnnotCheckBoxesRight(True) - self.setEnabledAnnotCheckBoxesLeftZdepthAxes() - self.overlayButtonPrevState = self.overlayButton.isChecked() - self.overlayButton.setChecked(False) - self.overlayButton.setDisabled(True) - else: - self.zProjComboBox.setDisabled(False) - self.restoreAnnotationsOptions() - self.setDisabledAnnotCheckBoxesLeft(False) - self.setDisabledAnnotCheckBoxesRight(False) - self.overlayButton.setDisabled(False) - if self.overlayButtonPrevState: - self.overlayButton.setChecked(self.overlayButtonPrevState) - self.updateZsliceScrollbar(posData.frame_i) - - SizeY, SizeX = posData.img_data[posData.frame_i].shape[-2:] - - if depthAxes != 'z' and self.isSnapshot: - # Disable editing when the plane is not xy - self.disableEditingViewPlaneNotXY() - elif self.isSnapshot: - # Re-enable editing in snapshot mode when the plane is xy - self.setEnabledSnapshotMode() - - if depthAxes == 'z': - maxSliceNum = posData.SizeZ - elif depthAxes == 'y': - maxSliceNum = SizeY - else: - maxSliceNum = SizeX - - maxSliceText = f'/{maxSliceNum}' - self.SizeZlabel.setText(maxSliceText) - self.zSliceCheckbox.setText(f'{depthAxes}-slice') - self.zSliceScrollBar.setMaximum(maxSliceNum-1) - self.zSliceSpinbox.setMaximum(maxSliceNum) - - self.initContoursImage() - self.updateAllImages() - QTimer.singleShot( - 200, partial(self.setViewRangeSwitchPlane, previousPlane) - ) - - def onZsliceSpinboxValueChange(self, value): - self.zSliceScrollBar.setSliderPosition(value-1) - - def update_z_slice(self, z): - posData = self.data[self.pos_i] - if self.switchPlaneCombobox.depthAxes() == 'z': - if self.zProjLockViewButton.isChecked(): - idx = [ - (posData.filename, frame_i) - for frame_i in range(posData.SizeT) - ] - else: - idx = [ - (posData.filename, frame_i) - for frame_i in range(posData.frame_i, posData.SizeT) - ] - posData.segmInfo_df.loc[idx, 'z_slice_used_gui'] = z - - self.updatePreprocessPreview() - self.updateCombineChannelsPreview() - self.highlightedID = self.getHighlightedID() - self.updateAllImages( - computePointsLayers=False, - computeContours=False, - updateLookuptable=True - ) - self.updateItemsMousePos() - if self.isSegm3D: - self.updateObjectCounts() - - def updateOverlayZslice(self, z): - self.setOverlayImages() - - def updateOverlayZproj(self, how): - if how.find('max') != -1 or how == 'same as above': - self.overlay_z_label.setDisabled(True) - self.zSliceOverlay_SB.setDisabled(True) - else: - self.overlay_z_label.setDisabled(False) - self.zSliceOverlay_SB.setDisabled(False) - self.setOverlayImages() - - def updateZproj(self, how): - for p, posData in enumerate(self.data[self.pos_i:]): - if self.zProjLockViewButton.isChecked(): - idx = [ - (posData.filename, frame_i) - for frame_i in range(posData.SizeT) - ] - else: - idx = [(posData.filename, posData.frame_i)] - posData.segmInfo_df.loc[idx, 'which_z_proj_gui'] = how - posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) - - posData = self.data[self.pos_i] - if how == 'single z-slice': - self.zSliceScrollBar.setDisabled(False) - self.zSliceSpinbox.setDisabled(False) - self.zSliceCheckbox.setDisabled(False) - self.setZprojDisabled(False) - self.update_z_slice(self.zSliceScrollBar.sliderPosition()) - else: - self.zSliceScrollBar.setDisabled(True) - self.zSliceSpinbox.setDisabled(True) - self.zSliceCheckbox.setDisabled(True) - self.setZprojDisabled(self.isSegm3D) - self.updateAllImages() - - def setZprojDisabled(self, disabled, storePrevState=False): - self.combineChannelsAction.setDisabled(disabled) - for action in self.editToolBar.actions(): - button = self.editToolBar.widgetForAction(action) - if button == self.eraserButton: - continue - - if button in self.toolsActiveInProj3Dsegm: - continue - - try: - tooltip = button.toolTip() - prefix = 'WARNING: Disabled due to projection mode\n\n' - if disabled: - if not tooltip.startswith(prefix): - button.setToolTip(prefix + tooltip) - else: - if tooltip.startswith(prefix): - button.setToolTip(tooltip[len(prefix):]) - except: - pass - action.setDisabled(disabled) - try: - button.setChecked(False) - except Exception as err: - pass - - def clearAx2Items(self, onlyHideText=False): - self.ax2_binnedIDs_ScatterPlot.clear() - self.ax2_ripIDs_ScatterPlot.clear() - self.ax2_contoursImageItem.clear() - self.ax2_lostObjImageItem.clear() - self.ax2_lostTrackedObjImageItem.clear() - self.textAnnot[1].clear() - self.ax2_newMothBudLinesItem.setData([], []) - self.ax2_oldMothBudLinesItem.setData([], []) - self.ax2_lostObjScatterItem.setData([], []) - - def clearAx1Items(self, onlyHideText=False): - self.ax1_binnedIDs_ScatterPlot.clear() - self.ax1_ripIDs_ScatterPlot.clear() - self.labelsLayerImg1.clear() - self.labelsLayerRightImg.clear() - self.keepIDsTempLayerLeft.clear() - self.keepIDsTempLayerRight.clear() - self.highLightIDLayerImg1.clear() - self.highLightIDLayerRightImage.clear() - self.searchedIDitemLeft.clear() - self.searchedIDitemRight.clear() - self.ax1_contoursImageItem.clear() - self.ax1_lostObjImageItem.clear() - self.ax1_lostTrackedObjImageItem.clear() - self.textAnnot[0].clear() - self.ax1_newMothBudLinesItem.setData([], []) - self.ax1_oldMothBudLinesItem.setData([], []) - self.ax1_lostObjScatterItem.setData([], []) - self.ax1_lostTrackedScatterItem.setData([], []) - self.ccaFailedScatterItem.setData([], []) - self.yellowContourScatterItem.setData([], []) - - self.clearPointsLayers() - - self.clearOverlayLabelsItems() - self.clearManualBackgroundAnnotations() - self.clearCustomAnnot() - - def clearPointsLayers(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - try: - action.scatterItem.clear() - except Exception as e: - continue - - def clearOverlayLabelsItems(self): - for segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): - items = self.overlayLabelsItems[segmEndname] - imageItem, contoursItem, gradItem = items - imageItem.clear() - contoursItem.clear() - - def clearAllItems(self): - self.clearAx1Items() - self.clearAx2Items() - - def clearCustomAnnot(self): - for button in self.customAnnotDict.keys(): - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] - scatterPlotItem.setData([], []) - - def clearCurvItems(self, removeItems=True): - try: - posData = self.data[self.pos_i] - curvItems = zip(posData.curvPlotItems, - posData.curvAnchorsItems, - posData.curvHoverItems) - for plotItem, curvAnchors, hoverItem in curvItems: - plotItem.setData([], []) - curvAnchors.setData([], []) - hoverItem.setData([], []) - if removeItems: - self.ax1.removeItem(plotItem) - self.ax1.removeItem(curvAnchors) - self.ax1.removeItem(hoverItem) - - if removeItems: - posData.curvPlotItems = [] - posData.curvAnchorsItems = [] - posData.curvHoverItems = [] - except AttributeError: - # traceback.print_exc() - pass - - # @exec_time - def curvToolSplineToObj(self, xxA=None, yyA=None, isRightClick=False): - posData = self.data[self.pos_i] - # Store undo state before modifying stuff - self.storeUndoRedoStates(False, storeOnlyZoom=True) - - if isRightClick: - xxS, yyS = self.curvPlotItem.getData() - if xxS is None: - self.setUncheckedAllButtons() - return - self.smoothAutoContWithSpline() - - xxS, yyS = self.getClosedSplineCoords() - - if self.autoIDcheckbox.isChecked(): - self.setBrushID() - curvToolID = posData.brushID - else: - curvToolID = self.editIDspinbox.value() - posData.brushID = curvToolID - - if curvToolID <= 0: - self.setBrushID() - curvToolID = posData.brushID - - lab2D = self.get_2Dlab(posData.lab).copy() - newIDMask = np.zeros(lab2D.shape, bool) - rr, cc = skimage.draw.polygon(yyS, xxS, shape=lab2D.shape) - newIDMask[rr, cc] = True - newIDMask[lab2D!=0] = False - lab2D[newIDMask] = curvToolID - self.set_2Dlab(lab2D) - self.currentLab2D = lab2D - - def addFluoChNameContextMenuAction(self, ch_name): - posData = self.data[self.pos_i] - allTexts = [ - action.text() for action in self.chNamesQActionGroup.actions() - ] - if ch_name not in allTexts: - action = QAction(self) - action.setText(ch_name) - action.setCheckable(True) - self.chNamesQActionGroup.addAction(action) - action.setChecked(True) - self.fluoDataChNameActions.append(action) - - def computeSegm(self, force=False): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer' or mode == 'Cell cycle analysis': - return - - if np.any(posData.lab) and not force: - # Do not compute segm if there is already a mask - return - - if not self.autoSegmAction.isChecked(): - return - - self.repeatSegm(model_name=self.segmModelName) - - def initImgCmap(self): - if not 'img_cmap' in self.df_settings.index: - self.df_settings.at['img_cmap', 'value'] = 'grey' - self.imgCmapName = self.df_settings.at['img_cmap', 'value'] - self.imgCmap = self.imgGrad.cmaps[self.imgCmapName] - if self.imgCmapName != 'grey': - # To ensure mapping to colors we need to normalize image - self.normalizeByMaxAction.setChecked(True) - - def initMetricsToSave(self, posData): - self._measurements_kernel._init_metrics_to_save(posData) - - def initMetrics(self): - self.logger.info('Initializing measurements...') - posData = self.data[self.pos_i] - self._measurements_kernel = cli.ComputeMeasurementsKernel( - self.logger, self.log_path, False - ) - self._measurements_kernel.init_args( - posData.chNames, posData.getSegmEndname() - ) - self._measurements_kernel._init_metrics(posData, self.isSegm3D) - - def initPosAttr(self): - exp_path = self.data[self.pos_i].exp_path - pos_foldernames = myutils.get_pos_foldernames(exp_path) - if len(pos_foldernames) == 1: - self.loadPosAction.setDisabled(True) - else: - self.loadPosAction.setDisabled(False) - - for p, posData in enumerate(self.data): - self.pos_i = p - posData.curvPlotItems = [] - posData.curvAnchorsItems = [] - posData.curvHoverItems = [] - posData.trackedLostIDs = set() - - posData.HDDmaxID = np.max(posData.segm_data) - - # Decision on what to do with changes to future frames attr - posData.doNotShowAgain_EditID = False - posData.UndoFutFrames_EditID = False - posData.applyFutFrames_EditID = False - - posData.doNotShowAgain_RipID = False - posData.UndoFutFrames_RipID = False - posData.applyFutFrames_RipID = False - - posData.doNotShowAgain_DelID = False - posData.UndoFutFrames_DelID = False - posData.applyFutFrames_DelID = False - - posData.doNotShowAgain_keepID = False - posData.UndoFutFrames_keepID = False - posData.applyFutFrames_keepID = False - - posData.doNotShowAgainAssignNewID = False - posData.UndoFutFramesAssignNewID = False - posData.applyFutFramesAssignNewID = False - - posData.includeUnvisitedInfo = { - 'Delete ID': False, 'Edit ID': False, 'Keep ID': False - } - - posData.loadTrackedLostCentroids() - posData.acdcTracker2stepsAnnotInfo = {} - - posData.doNotShowAgain_BinID = False - posData.UndoFutFrames_BinID = False - posData.applyFutFrames_BinID = False - - posData.disableAutoActivateViewerWindow = False - posData.new_IDs = [] - posData.lost_IDs = [] - posData.multiBud_mothIDs = [2] - posData.UndoRedoStates = [[] for _ in range(posData.SizeT)] - posData.UndoRedoCcaStates = [[] for _ in range(posData.SizeT)] - - posData.ol_data_dict = {} - posData.ol_data = None - - posData.ol_labels_data = None - - missing_frames = posData.SizeT - len(posData.allData_li) - if missing_frames > 0: - posData.allData_li.extend([None] * missing_frames) - for i in range(posData.SizeT): - if posData.allData_li[i] is None: - posData.allData_li[i] = ( - myutils.get_empty_stored_data_dict() - ) - - posData.lutLevels = {channel: {} for channel in self.ch_names} - - posData.ccaStatus_whenEmerged = {} - - posData.frame_i = 0 - posData.brushID = 0 - posData.binnedIDs = set() - posData.ripIDs = set() - posData.cca_df = None - if posData.last_tracked_i is not None: - last_tracked_num = posData.last_tracked_i+1 - # Load previous session data - # Keep track of which ROIs have already been added - # in previous frame - delROIshapes = [[] for _ in range(posData.SizeT)] - for i in range(last_tracked_num): - posData.frame_i = i - self.get_data(debug=True) - self.store_data( - enforce=True, autosave=False, store_cca_df_copy=True - ) - - # Ask whether to resume from last frame - if last_tracked_num>1: - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Cell-ACDC detected a previous session ended ' - f'at frame {last_tracked_num}.

' - f'Do you want to resume from frame ' - f'{last_tracked_num}?' - ) - noButton, yesButton = msg.question( - self, 'Start from last session?', txt, - buttonsTexts=(' No ', 'Yes') - ) - self.AutoPilotProfile.storeClickMessageBox( - 'Start from last session?', msg.clickedButton.text() - ) - if msg.clickedButton == yesButton: - posData.frame_i = posData.last_tracked_i - self.lastFrameRanOnFirstVisitTools = posData.frame_i - else: - posData.frame_i = 0 - - posData.img_data_min_max = ( - posData.img_data.min(), posData.img_data.max() - ) - - # Back to first position - self.pos_i = 0 - self.get_data(debug=False) - self.store_data(autosave=False) - # self.updateAllImages() - - # Link Y and X axis of both plots to scroll zoom and pan together - self.ax2.vb.setYLink(self.ax1.vb) - self.ax2.vb.setXLink(self.ax1.vb) - - self.setAllIDs() - - def navigateSpinboxValueChanged(self, value): - self.navigateScrollBar.setSliderPosition(value) - if self.isSnapshot: - self.PosScrollBarMoved(value) - else: - self.navigateScrollBarStartedMoving = True - self.framesScrollBarMoved(value) - - def navigateSpinboxEditingFinished(self): - if self.isSnapshot: - self.PosScrollBarReleased() - else: - self.framesScrollBarReleased() - - def PosScrollBarAction(self, action): - if action == SliderSingleStepAdd: - self.next_cb() - elif action == SliderSingleStepSub: - self.prev_cb() - elif action == SliderPageStepAdd: - self.PosScrollBarReleased() - elif action == SliderPageStepSub: - self.PosScrollBarReleased() - - def PosScrollBarMoved(self, pos_n): - if self.navigateScrollBarStartedMoving: - self.store_data() - - self.pos_i = pos_n-1 - self.updateFramePosLabel() - proceed_cca, never_visited = self.get_data() - self.updateAllImages() - self.setStatusBarLabel() - self.navigateScrollBarStartedMoving = False - - def PosScrollBarReleased(self): - self.navigateScrollBarStartedMoving = True - if self.pos_i == self.navigateScrollBar.sliderPosition()-1: - # Slider released without changing value --> do nothing - return - - self.pos_i = self.navigateScrollBar.sliderPosition()-1 - self.updateFramePosLabel() - self.updatePos() - - def resetNavigateFramesScrollbar(self, frame_i=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - self.navigateScrollBar.setValueNoSignal(frame_i+1) - - def framesScrollBarActionTriggered(self, action): - if action == SliderSingleStepAdd: - # Clicking on dialogs triggered by next_cb might trigger - # pressEvent of navigateQScrollBar, avoid that - self.navigateScrollBar.disableCustomPressEvent() - self.next_cb() - QTimer.singleShot(100, self.navigateScrollBar.enableCustomPressEvent) - elif action == SliderSingleStepSub: - self.prev_cb() - elif action == SliderPageStepAdd: - self.framesScrollBarReleased(do_store_data=True) - elif action == SliderPageStepSub: - self.framesScrollBarReleased(do_store_data=True) - - def framesScrollBarMoved(self, frame_n): - if self.navigateScrollBarStartedMoving: - mode = str(self.modeComboBox.currentText()) - if mode != 'Viewer': - self.store_data(debug=False) - - posData = self.data[self.pos_i] - posData.frame_i = frame_n-1 - if posData.allData_li[posData.frame_i]['labels'] is None: - if posData.frame_i < len(posData.segm_data): - posData.lab = posData.segm_data[posData.frame_i] - else: - posData.lab = np.zeros_like(posData.segm_data[0]) - else: - posData.lab = posData.allData_li[posData.frame_i]['labels'] - - self.setImageImg1() - if self.overlayButton.isChecked(): - self.setOverlayImages() - - if self.navigateScrollBarStartedMoving: - self.clearAllItems() - - self.navSpinBox.setValueNoEmit(posData.frame_i+1) - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.img2.setImage(posData.lab, z=self.z_lab(), autoLevels=False) - self.updateLookuptable() - self.updateFramePosLabel() - self.updateViewerWindow() - self.updateTimestampFrame() - self.updateHighlightedAxis() - self.navigateScrollBarStartedMoving = False - - def framesScrollBarReleased(self, do_store_data=False): - posData = self.data[self.pos_i] - if posData.frame_i == self.navigateScrollBar.sliderPosition()-1: - # Slider released without changing value --> do nothing - return - - mode = str(self.modeComboBox.currentText()) - if mode != 'Viewer' and do_store_data: - self.store_data(debug=False) - - self.navigateScrollBarStartedMoving = True - posData.frame_i = self.navigateScrollBar.sliderPosition()-1 - self.updateFramePosLabel() - proceed_cca, never_visited = self.get_data() - self.updateAllImages() - - def unstore_data(self): - posData = self.data[self.pos_i] - posData.allData_li[posData.frame_i] = myutils.get_empty_stored_data_dict() - - def getStoredSegmData(self): - posData = self.data[self.pos_i] - segm_data = [] - for data_frame_i in posData.allData_li: - lab = data_frame_i['labels'] - if lab is None: - break - segm_data.append(lab) - return np.array(segm_data) - - def trackNewIDtoNewIDsFutureFrame(self, newID, newIDmask): - posData = self.data[self.pos_i] - try: - nextLab = posData.allData_li[posData.frame_i+1]['labels'] - except IndexError: - # This is last frame --> there are no future frames - return - - if nextLab is None: - return - - newID_lab = np.zeros_like(posData.lab) - newID_lab[newIDmask] = newID - newLab_rp = [posData.rp[posData.IDs_idxs[newID]]] - newLab_IDs = [newID] - nextRp = posData.allData_li[posData.frame_i+1]['regionprops'] - - tracked_lab = self.trackFrame( - nextLab, nextRp, newID_lab, newLab_rp, newLab_IDs, - assign_unique_new_IDs=False - ) - trackedID = tracked_lab[newID_lab>0][0] - if trackedID == newID: - # Object does not exist in future frame --> do not track - return - - if posData.IDs_idxs.get(trackedID) is not None: - # Tracked ID already exists --> do not track to avoid merging - return - - return trackedID - - def store_manual_annot_data( - self, posData=None, data_frame_i=None - ): - if posData is None: - posData = self.data[self.pos_i] - - if data_frame_i is None: - data_frame_i = posData.allData_li[posData.frame_i] - - if not self.isSegm3D: - lab = [posData.lab] - else: - lab = posData.lab - - for z, lab_2D in enumerate(lab): - data_frame_i['manually_edited_lab']['lab'][z] = lab_2D - - # data_frame_i['manually_edited_lab']['zoom_slice'] = zoom_slice - - @exception_handler - def store_data( - self, pos_i=None, enforce=True, debug=False, mainThread=True, - autosave=True, store_cca_df_copy=False - ): - pos_i = self.pos_i if pos_i is None else pos_i - posData = self.data[pos_i] - if posData.frame_i < 0: - # In some cases we set frame_i = -1 and then call next_frame - # to visualize frame 0. In that case we don't store data - # for frame_i = -1 - return - - mode = str(self.modeComboBox.currentText()) - - if mode == 'Viewer' and not enforce: - return - - # if not mainThread: - # self.lin_tree_ask_changes() - - allData_li = posData.allData_li[posData.frame_i] - allData_li['regionprops'] = posData.rp.copy() - allData_li['labels'] = posData.lab.copy() - allData_li['IDs'] = posData.IDs.copy() - allData_li['manualBackgroundLab'] = ( - posData.manualBackgroundLab - ) - allData_li['IDs_idxs'] = ( - posData.IDs_idxs.copy() - ) - if self.manualAnnotPastButton.isChecked(): - self.store_manual_annot_data( - posData=posData, data_frame_i=allData_li - ) - - self.store_zslices_rp() - - # Store dynamic metadata - is_cell_dead_li = [False]*len(posData.rp) - is_cell_excluded_li = [False]*len(posData.rp) - IDs = [0]*len(posData.rp) - xx_centroid = [0]*len(posData.rp) - yy_centroid = [0]*len(posData.rp) - if self.isSegm3D: - zz_centroid = [0]*len(posData.rp) - areManuallyEdited = [0]*len(posData.rp) - editedNewIDs = [vals[2] for vals in posData.editID_info] - for i, obj in enumerate(posData.rp): - is_cell_dead_li[i] = obj.dead - is_cell_excluded_li[i] = obj.excluded - IDs[i] = obj.label - try: - xx_centroid[i] = int(self.getObjCentroid(obj.centroid)[1]) - yy_centroid[i] = int(self.getObjCentroid(obj.centroid)[0]) - except Exception as err: - printl(obj, obj.centroid, obj.label, posData.frame_i) - if self.isSegm3D: - zz_centroid[i] = int(obj.centroid[0]) - if obj.label in editedNewIDs: - areManuallyEdited[i] = 1 - - posData.STOREDmaxID = max(IDs, default=0) - - acdc_df = allData_li['acdc_df'] - if acdc_df is None: - allData_li['acdc_df'] = pd.DataFrame( - { - 'Cell_ID': IDs, - 'is_cell_dead': is_cell_dead_li, - 'is_cell_excluded': is_cell_excluded_li, - 'x_centroid': xx_centroid, - 'y_centroid': yy_centroid, - 'was_manually_edited': areManuallyEdited - } - ).set_index('Cell_ID') - - if self.isSegm3D: - allData_li['acdc_df']['z_centroid'] = ( - zz_centroid - ) - else: - # Filter or add IDs that were not stored yet - acdc_df = acdc_df.drop(columns=['time_seconds'], errors='ignore') - acdc_df = acdc_df.reindex(IDs, fill_value=0) - acdc_df['is_cell_dead'] = is_cell_dead_li - acdc_df['is_cell_excluded'] = is_cell_excluded_li - acdc_df['x_centroid'] = xx_centroid - acdc_df['y_centroid'] = yy_centroid - if self.isSegm3D: - acdc_df['z_centroid'] = zz_centroid - acdc_df['was_manually_edited'] = areManuallyEdited - allData_li['acdc_df'] = acdc_df - - if mainThread: - self.pointsLayerDataToDf(posData) - - self.store_cca_df( - pos_i=pos_i, mainThread=mainThread, autosave=autosave, - store_cca_df_copy=store_cca_df_copy - ) - - def nearest_point_2Dyx(self, points, all_others): - """ - Given 2D array of [y, x] coordinates points and all_others return the - [y, x] coordinates of the two points (one from points and one from all_others) - that have the absolute minimum distance - """ - # Compute 3D array where each ith row of each kth page is the element-wise - # difference between kth row of points and ith row in all_others array. - # (i.e. diff[k,i] = points[k] - all_others[i]) - diff = points[:, np.newaxis] - all_others - # Compute 2D array of distances where - # dist[i, j] = euclidean dist (points[i],all_others[j]) - dist = np.linalg.norm(diff, axis=2) - # Compute i, j indexes of the absolute minimum distance - i, j = np.unravel_index(dist.argmin(), dist.shape) - nearest_point = all_others[j] - point = points[i] - min_dist = np.min(dist) - return min_dist, nearest_point - - def isCurrentFrameCcaVisited(self): - posData = self.data[self.pos_i] - curr_df = posData.allData_li[posData.frame_i]['acdc_df'] - return curr_df is not None and 'cell_cycle_stage' in curr_df.columns - - def warnScellsGone(self, ScellsIDsGone, frame_i): - msg = widgets.myMessageBox() - text = html_utils.paragraph(f""" - In the next frame the followning cells' IDs in S/G2/M - (highlighted with a yellow contour) will disappear:

- {ScellsIDsGone}

- If the cell does not exist you might have deleted it at some point. - If that's the case, then try to go to some previous frames and reset - the cell cycle annotations there (button on the top toolbar).

- These cells are either buds or mother whose related IDs will not - disappear. This is likely due to cell division happening in - previous frame and the divided bud or mother will be - washed away.

- If you decide to continue these cells will be automatically - annotated as divided at frame number {frame_i}.

- Do you want to continue? - """) - _, yesButton, noButton = msg.warning( - self, 'Cells in "S/G2/M" disappeared!', text, - buttonsTexts=('Cancel', 'Yes', 'No') - ) - return msg.clickedButton == yesButton - - def checkScellsGone(self): - """Check if there are cells in S phase whose relative disappear in - current frame. Allow user to choose between automatically assign - division to these cells or cancel and not visit the frame. - - Returns - ------- - bool - False if there are no cells disappeared or the user decided - to accept automatic division. - """ - automaticallyDividedIDs = [] - - mode = str(self.modeComboBox.currentText()) - if mode.find('Cell cycle') == -1: - # No cell cycle analysis mode --> do nothing - return False, automaticallyDividedIDs - - posData = self.data[self.pos_i] - - if posData.allData_li[posData.frame_i]['labels'] is None: - # Frame never visited/checked in segm mode --> autoCca_df will raise - # a critical message - return False, automaticallyDividedIDs - - # Check if there are S cells that either only mother or only - # bud disappeared and automatically assign division to it - # or abort visiting this frame - prev_acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_cca_df = prev_acdc_df[self.cca_df_colnames].copy() - - ScellsIDsGone = [] - for ccSeries in prev_cca_df.itertuples(): - ID = ccSeries.Index - ccs = ccSeries.cell_cycle_stage - if ccs != 'S': - continue - - relID = ccSeries.relative_ID - if relID == -1: - continue - - # Check is relID is gone while ID stays - if relID not in posData.IDs and ID in posData.IDs: - ScellsIDsGone.append(relID) - - if not ScellsIDsGone: - # No cells in S that disappears --> do nothing - return False, automaticallyDividedIDs - - self.highlightNewIDs_ccaFailed(ScellsIDsGone, rp=prev_rp) - proceed = self.warnScellsGone(ScellsIDsGone, posData.frame_i) - self.clearLostObjContoursItems() - - if not proceed: - return True, automaticallyDividedIDs - - for IDgone in ScellsIDsGone: - relID = prev_cca_df.at[IDgone, 'relative_ID'] - self.annotateDisappearedBeforeDivision(relID, IDgone, prev_cca_df) - self.annotateDivision( - prev_cca_df, IDgone, relID, frame_i=posData.frame_i-1 - ) - self.annotateDivisionCurrentFrameRelativeIDgone(relID) - automaticallyDividedIDs.append(relID) - - self.store_cca_df(frame_i=posData.frame_i-1, cca_df=prev_cca_df) - - return False, automaticallyDividedIDs - - def annotateDivisionCurrentFrameRelativeIDgone(self, IDwhoseRelativeIsGone): - posData = self.data[self.pos_i] - if posData.cca_df is None: - return - ID = IDwhoseRelativeIsGone - posData.cca_df.at[ID, 'generation_num'] += 1 - posData.cca_df.at[ID, 'division_frame_i'] = posData.frame_i-1 - posData.cca_df.at[ID, 'relationship'] = 'mother' - - def annotateDisappearedBeforeDivision( - self, relID, IDgone, cca_df, frame_i=None - ): - posData = self.data[self.pos_i] - gen_num = cca_df.at[relID, 'generation_num'] - if frame_i is None: - frame_i = posData.frame_i - - for past_frame_i in range(frame_i-1, -1, -1): - past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True) - if past_cca_df is None: - return - - try: - if past_cca_df.at[relID, 'generation_num'] != gen_num: - # ID is a mother and the cell cycle is finished here - return - except Exception as err: - # Bud stops existing --> stop process - return - - past_cca_df.at[IDgone, 'disappears_before_division'] = 1 - past_cca_df.at[relID, 'daughter_disappears_before_division'] = 1 - - self.store_cca_df( - cca_df=past_cca_df, frame_i=past_frame_i, autosave=False - ) - - @exception_handler - def attempt_auto_cca(self, enforceAll=False): - mode = str(self.modeComboBox.currentText()) - posData = self.data[self.pos_i] - - if mode == 'Cell cycle analysis': - notEnoughG1Cells, proceed = self.autoCca_df( - enforceAll=enforceAll - ) - if not proceed: - return notEnoughG1Cells, proceed - - # mode = str(self.modeComboBox.currentText()) - if posData.cca_df is None: # ??? - notEnoughG1Cells = False - proceed = True - return notEnoughG1Cells, proceed - if posData.cca_df.isna().any(axis=None): - raise ValueError('Cell cycle analysis table contains NaNs') - # self.checkMultiBudMoth() - proceed = self.checkMothersExcludedOrDead() - return notEnoughG1Cells, proceed - - elif mode == 'Normal division: Lineage tree': - self.autoLinTree_df() - notEnoughG1Cells = False - proceed = True - return notEnoughG1Cells, proceed - - else: - notEnoughG1Cells = False - proceed = True - return notEnoughG1Cells, proceed - - - - def highlightIDs(self, IDs, pen): - pass - - def warnFrameNeverVisitedSegmMode(self): - msg = widgets.myMessageBox() - warn_cca = msg.critical( - self, 'Next frame NEVER visited', - 'Next frame was never visited in "Segmentation and Tracking"' - 'mode.\n You cannot perform cell cycle analysis on frames' - 'where segmentation and/or tracking errors were not' - 'checked/corrected.\n\n' - 'Switch to "Segmentation and Tracking" mode ' - 'and check/correct next frame,\n' - 'before attempting cell cycle analysis again', - ) - return False - - def checkCcaPastFramesNewIDs(self): - posData = self.data[self.pos_i] - if not posData.new_IDs: - return - - found_cca_df_IDs = [] - for frame_i in range(posData.frame_i-2, -1, -1): - acdc_df = posData.allData_li[frame_i]['acdc_df'] - cca_df_i = acdc_df[self.cca_df_colnames] - intersect_idx = cca_df_i.index.intersection(posData.new_IDs) - cca_df_i = cca_df_i.loc[intersect_idx] - if cca_df_i.empty: - continue - found_cca_df_IDs.append(cca_df_i) - - # Remove IDs found in past frames from new_IDs list - newIDs = np.array(posData.new_IDs, dtype=np.uint32) - mask_index = np.in1d(newIDs, cca_df_i.index) - posData.new_IDs = list(newIDs[~mask_index]) - if not posData.new_IDs: - return found_cca_df_IDs - return found_cca_df_IDs - - def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): - self.logger.info( - 'Initialising cell cycle annotations of missing past frames...' - ) - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - - annotated_cca_dfs = [] - for frame_i in range(last_cca_frame_i+1): - acdc_df = posData.allData_li[frame_i]['acdc_df'] - if 'cell_cycle_stage' in acdc_df.columns: - continue - - acdc_df[self.cca_df_colnames] = '' - - annotated_cca_dfs = [ - posData.allData_li[i]['acdc_df'][self.cca_df_colnames] - for i in range(last_cca_frame_i+1) - ] - keys = range(last_cca_frame_i+1) - names = ['frame_i', 'Cell_ID'] - annotated_cca_df = ( - pd.concat(annotated_cca_dfs, keys=keys, names=names) - .reset_index() - .set_index(['Cell_ID', 'frame_i']) - .sort_index() - ) - - last_annotated_cca_df = annotated_cca_df.groupby(level=0).last() - cca_df_colnames = self.cca_df_colnames - pbar = tqdm(total=current_frame_i-last_cca_frame_i+1, ncols=100) - for frame_i in range(last_cca_frame_i, current_frame_i+1): - posData.frame_i = frame_i - self.get_data() - cca_df = self.getBaseCca_df() - - idx = last_annotated_cca_df.index.intersection(cca_df.index) - cca_df.loc[idx, cca_df_colnames] = last_annotated_cca_df.loc[idx] - - self.store_cca_df(cca_df=cca_df, frame_i=frame_i, autosave=False) - pbar.update() - pbar.close() - - posData.frame_i = current_frame_i - self.get_data() - - def initMissingFramesLinTree(self, current_frame_i): # done Need to add partially missing previous frames and loading - """ - When not starting from the first frame, automatically creates lineage tree dfs for all "skipped" frames and initializes the tree if not done so before. - - Parameters - ---------- - current_frame_i : int - The index of the current frame. - - Returns - ------- - None - - Notes - ----- - This method initializes the lineage tree annotations of missing past frames. If the lineage tree has not been initialized before, it creates a new lineage tree based on the labels of the first frame. It then iterates over the missing frames and updates the lineage tree with the labels and region properties of each frame. - """ - - self.logger.info( - 'Initialising lineage tree annotations of missing past frames...' - ) - - self.store_data(autosave=False) - self.get_data() - - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - - if not self.lineage_tree: # init lin tree if not done already - self.lineage_tree = normal_division_lineage_tree(gui=self) # here frame_i!=0 - - missing_frames = list(range(current_frame_i+1)) - present_frames = list(self.lineage_tree.frames_for_dfs) if self.lineage_tree else [] - present_frames = [] if not present_frames else present_frames # deal with None - missing_frames = [frame_i for frame_i in missing_frames if frame_i not in present_frames] - missing_frames.sort() - - for frame_i in missing_frames: - lab = posData.allData_li[frame_i]['labels'] - prev_lab = posData.allData_li[frame_i-1]['labels'] - rp = posData.allData_li[frame_i]['regionprops'] - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - # i might need to change this if I need support for only partially missing frames... Although I probably never have to care about that though - self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) - - posData.frame_i = current_frame_i - self.store_data() - - def _getCcaCostMatrix( - self, numCellsG1, numNewCells, IDsCellsG1, newIDs_contours - ): - posData = self.data[self.pos_i] - dataDict = posData.allData_li[posData.frame_i] - dist_matrix_df = dataDict.get('obj_to_obj_dist_cost_matrix_df') - if dist_matrix_df is None: - cost = np.full((numCellsG1, numNewCells), np.inf) - for obj in posData.rp: - ID = obj.label - try: - i = IDsCellsG1.index(ID) - except ValueError: - continue - - cont = self.getObjContours(obj) - i = IDsCellsG1.index(ID) - - # Get distance from cell in G1 and all other new cells - for j, newID_cont in enumerate(newIDs_contours): - min_dist, nearest_xy = self.nearest_point_2Dyx( - cont, newID_cont - ) - cost[i, j] = min_dist - - return cost - - cost = dist_matrix_df.loc[IDsCellsG1, posData.new_IDs].values - - return cost - - def autoCca_df(self, enforceAll=False): - """ - Assign each bud to a mother with scipy linear sum assignment - (Hungarian or Munkres algorithm). First we build a cost matrix where - each (i, j) element is the minimum distance between bud i and mother j. - Then we minimize the cost of assigning each bud to a mother, and finally - we write the assignment info into cca_df - """ - proceed = True - notEnoughG1Cells = False - ScellsGone = False - - posData = self.data[self.pos_i] - - # Skip cca if not the right mode - mode = str(self.modeComboBox.currentText()) - if mode.find('Cell cycle') == -1: - return notEnoughG1Cells, proceed - - - # Make sure that this is a visited frame in segmentation tracking mode - if posData.allData_li[posData.frame_i]['labels'] is None: - proceed = self.warnFrameNeverVisitedSegmMode() - return notEnoughG1Cells, proceed - - # Determine if this is the last visited frame for repeating - # bud assignment on non manually correct (corrected_on_frame_i>0) buds. - # The idea is that the user could have assigned division on a cell - # by going previous and we want to check if this cell could be a - # "better" mother for those non manually corrected buds - curr_df = posData.allData_li[posData.frame_i]['acdc_df'] - isLastVisitedAgain = self.isLastVisitedAgainCca( - curr_df, enforceAll=enforceAll - ) - - frameAlreadyAnnotated = ( - posData.cca_df is not None - and not enforceAll - and not isLastVisitedAgain - ) - # Use stored cca_df and do not modify it with automatic stuff - if frameAlreadyAnnotated: - return notEnoughG1Cells, proceed - - # Keep only correctedAssignIDs if requested - # For the last visited frame we perform assignment again only on - # IDs where we didn't manually correct assignment - correctedAssignIDs = set() - if isLastVisitedAgain and not enforceAll: - try: - correctedAssignIDs = curr_df[ - curr_df['corrected_on_frame_i']>0 - ].index - except Exception as e: - correctedAssignIDs = [] - posData.new_IDs = [ - ID for ID in posData.new_IDs - if ID not in correctedAssignIDs - ] - - # Check if new IDs exist some time in the past - found_cca_df_IDs = self.checkCcaPastFramesNewIDs() - - # Check if there are some S cells that disappeared - abort, automaticallyDividedIDs = self.checkScellsGone() - if abort: - notEnoughG1Cells = False - proceed = False - return notEnoughG1Cells, proceed - - # Get previous dataframe - acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] - prev_cca_df = acdc_df[self.cca_df_colnames].copy() - - if posData.cca_df is None: - posData.cca_df = prev_cca_df.copy() - else: - posData.cca_df = curr_df[self.cca_df_colnames].copy() - - # concatenate new IDs found in past frames (before frame_i-1) - if found_cca_df_IDs is not None: - cca_df = pd.concat([posData.cca_df, *found_cca_df_IDs]) - unique_idx = ~cca_df.index.duplicated(keep='first') - posData.cca_df = cca_df[unique_idx] - - # If there are no new IDs we are done - if not posData.new_IDs: - proceed = True - self.store_cca_df() - return notEnoughG1Cells, proceed - - # Get cells in G1 (exclude dead) and check if there are enough cells in G1 - try: - prev_df_G1 = prev_cca_df[prev_cca_df['cell_cycle_stage']=='G1'] - prev_df_G1 = prev_df_G1[~acdc_df.loc[prev_df_G1.index]['is_cell_dead']] - IDsCellsG1 = set(prev_df_G1.index) - except Exception as err: - IDsCellsG1 = set() - - if isLastVisitedAgain or enforceAll: - # If we are repeating auto cca for last visited frame - # then we also add the cells in G1 that appears in current frame - # and we remove the ones that are already in S in current frame - # if they were manually corrected (i.e., they cannot be mother). - # Note that potential mother cells must be either appearing in - # current frame or in G1 also at previous frame. - # If we would consider cells that are in G1 at current frame - # but not in previous frame, assigning a bud to it would - # result in no G1 at all for the mother cell. - df_G1 = posData.cca_df[posData.cca_df['cell_cycle_stage']=='G1'] - current_G1_IDs = df_G1.index - new_cell_G1 = [ - ID for ID in current_G1_IDs if ID not in prev_cca_df.index - ] - IDsCellsG1.update(new_cell_G1) - cells_S_current = posData.cca_df[ - (posData.cca_df['cell_cycle_stage']=='S') - & (posData.cca_df['corrected_on_frame_i']==posData.frame_i) - ].index - IDsCellsG1 = IDsCellsG1 - set(cells_S_current) - - # Remove cells that disappeared - IDsCellsG1 = [ID for ID in IDsCellsG1 if ID in posData.IDs] - - numCellsG1 = len(IDsCellsG1) - numNewCells = len(posData.new_IDs) - if numCellsG1 < numNewCells: - notEnoughG1Cells, proceed = self.handleNoCellsInG1( - numCellsG1, numNewCells - ) - return notEnoughG1Cells, proceed - - # Compute new IDs contours - newIDs_contours = [] - for obj in posData.rp: - ID = obj.label - if ID in posData.new_IDs: - cont = self.getObjContours(obj) - newIDs_contours.append(cont) - - # Compute cost matrix - cost = self._getCcaCostMatrix( - numCellsG1, numNewCells, IDsCellsG1, newIDs_contours - ) - - # Run hungarian (munkres) assignment algorithm - row_idx, col_idx = scipy.optimize.linear_sum_assignment(cost) - - # New mother cells - newMothIDs = {IDsCellsG1[i] for i in row_idx} - - # Assign buds to mothers - for i, j in zip(row_idx, col_idx): - mothID = IDsCellsG1[i] - budID = posData.new_IDs[j] - - relID = None - # If we are repeating assignment for the bud then we also have to - # correct the possibily wrong mother --> it goes back to - # G1 if it's not a mother that we assign now - if budID in posData.cca_df.index: - relID = posData.cca_df.at[budID, 'relative_ID'] - if relID in prev_cca_df.index and relID not in newMothIDs: - posData.cca_df.loc[relID] = prev_cca_df.loc[relID] - - posData.cca_df.at[mothID, 'relative_ID'] = budID - posData.cca_df.at[mothID, 'cell_cycle_stage'] = 'S' - - bud_cca_dict = base_cca_dict.copy() - bud_cca_dict['cell_cycle_stage'] = 'S' - bud_cca_dict['generation_num'] = 0 - bud_cca_dict['relative_ID'] = mothID - bud_cca_dict['relationship'] = 'bud' - bud_cca_dict['emerg_frame_i'] = posData.frame_i - bud_cca_dict['is_history_known'] = True - bud_cca_dict['corrected_on_frame_i'] = -1 - posData.cca_df.loc[budID] = pd.Series(bud_cca_dict) - - # Keep only existing IDs - posData.cca_df = posData.cca_df.loc[posData.IDs] - - self.store_cca_df() - proceed = True - return notEnoughG1Cells, proceed - - def autoLinTree_df(self, enforceAll=False): - """Automatically generates a lineage tree dataframe. - - This method generates a lineage tree dataframe based on the current mode and data. - It checks if the mode is set to 'Normal division: Lineage tree' and if the current frame - is not already processed. If the conditions are met, it retrieves the necessary data - from the current position data and previous position data, and passes it to the - `real_time` method of the `lineage_tree` object. Finally, it converts the lineage tree - to an ACDC dataframe and adds the current frame to the set of frames that have been - processed. - - Parameters - ---------- - enforceAll : bool, optional - If True, enforces processing of all frames, even if they have been processed before. - If False, only processes frames that have not been processed before. Default is False. - - Returns - ------- - bool - True if there are not enough G1 cells for lineage tree generation, False otherwise. - bool - True if the lineage tree generation should proceed, False otherwise. - """ - proceed = True - notEnoughG1Cells = False - mode = str(self.modeComboBox.currentText()) - - # Skip if not the right mode - if mode != 'Normal division: Lineage tree': - return notEnoughG1Cells, proceed - - posData = self.data[self.pos_i] - frame_i = posData.frame_i - - if frame_i in self.lineage_tree.frames_for_dfs: - return notEnoughG1Cells, proceed - - # Make sure that this is a visited frame in segmentation tracking mode - if posData.allData_li[frame_i]['labels'] is None: # may need to change this - proceed = self.warnFrameNeverVisitedSegmMode() - return notEnoughG1Cells, proceed - - self.store_data(autosave=False) - self.get_data() - lab = posData.lab - prev_lab = posData.allData_li[frame_i-1]['labels'] - rp = posData.rp - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - - self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) - self.store_data() - - def getObjBbox(self, obj_bbox): - if self.isSegm3D and len(obj_bbox)==6: - obj_bbox = (obj_bbox[1], obj_bbox[2], obj_bbox[4], obj_bbox[5]) - return obj_bbox - else: - return obj_bbox - - def z_lab(self, checkIfProj=False): - if checkIfProj and self.zProjComboBox.currentText() != 'single z-slice': - return - - if not self.isSegm3D: - return - - posData = self.data[self.pos_i] - - idx = self.zSliceScrollBar.sliderPosition() - - # ensure idx doesnt exceed the number of z-slices of the position - idx_z = min(idx, posData.SizeZ-1) - - if not self.switchPlaneCombobox.isEnabled(): - return idx_z - - depthAxes = self.switchPlaneCombobox.depthAxes() - if depthAxes == 'z': - return idx_z - elif depthAxes == 'y': - idx_y = min(idx, posData.SizeY-1) - return (slice(None), idx_y) - else: - idx_x = min(idx, posData.SizeX-1) - return (slice(None), slice(None), idx_x) - - def get_2Dlab(self, lab, force_z=True): - if self.isSegm3D: - if force_z: - return lab[self.z_lab()] - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - return lab[self.z_lab()] - else: - return lab.max(axis=0) - else: - return lab - - # @exec_time - def applyEraserMask(self, mask): - posData = self.data[self.pos_i] - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - posData.lab[self.z_lab(), mask] = 0 - else: - posData.lab[:, mask] = 0 - else: - posData.lab[mask] = 0 - - def changeBrushID(self): - """Function called when pressing or releasing shift - """ - if not self.isSegm3D: - # Changing brush ID with shift is only for 3D segm - return - - if not self.brushButton.isChecked(): - # Brush if not active - return - - if not self.isMouseDragImg2 and not self.isMouseDragImg1: - # Mouse is not brushing at the moment - return - - posData = self.data[self.pos_i] - forceNewObj = not self.isNewID - - if forceNewObj: - # Shift is down --> force new object with brush - # e.g., 24 --> 28: - # 24 is hovering ID that we store as self.prevBrushID - # 24 object becomes 28 that is the new posData.brushID - self.isNewID = True - self.changedID = posData.brushID - self.restoreBrushID = posData.brushID - # Set a new ID - self.setBrushID() - else: - # Shift released or hovering on ID in z+-1 - # --> restore brush ID from before shift was pressed or from - # when we started brushing from outside an object - # but we hovered on ID in z+-1 while dragging. - # We change the entire 28 object to 24 so before changing the - # brush ID back to 24 we builg the mask with 28 to change it to 24 - self.isNewID = False - self.changedID = posData.brushID - # Restore ID - posData.brushID = self.restoreBrushID - - brushID = posData.brushID - brushIDmask = self.get_2Dlab(posData.lab) == self.changedID - self.applyBrushMask(brushIDmask, brushID) - if self.isMouseDragImg1: - self.brushColor = self.lut[posData.brushID]/255 - self.setTempImg1Brush(True, brushIDmask, posData.brushID) - - def applyBrushMask(self, mask, ID, toLocalSlice=None): - posData = self.data[self.pos_i] - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - if toLocalSlice is not None: - toLocalSlice = (self.z_lab(), *toLocalSlice) - posData.lab[toLocalSlice][mask] = ID - else: - posData.lab[self.z_lab()][mask] = ID - else: - if toLocalSlice is not None: - for z in range(len(posData.lab)): - _slice = (z, *toLocalSlice) - posData.lab[_slice][mask] = ID - else: - posData.lab[:, mask] = ID - else: - if toLocalSlice is not None: - posData.lab[toLocalSlice][mask] = ID - else: - posData.lab[mask] = ID - - def assignNewIDfromClickedID( - self, clickedID: int, event: QGraphicsSceneMouseEvent - ): - posData = self.data[self.pos_i] - x, y = event.pos().x(), event.pos().y() - newID = self.setBrushID(return_val=True) - mapper = [(clickedID, newID)] - self.applyEditID(clickedID, posData.IDs.copy(), mapper, x, y) - - def get_2Drp(self, lab=None): - if self.isSegm3D: - if lab is None: - # self.currentLab2D is defined at self.setImageImg2() - lab = self.currentLab2D - lab = self.get_2Dlab(lab) - rp = skimage.measure.regionprops(lab) - return rp - else: - return self.data[self.pos_i].rp - - def set_2Dlab(self, lab2D, lab3D=None): - posData = self.data[self.pos_i] - - if lab3D is None: - lab3D = posData.lab - - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - lab3D[self.z_lab()] = lab2D - else: - lab3D[:] = lab2D - else: - if lab3D.shape == lab2D.shape: - lab3D[...] = lab2D - else: - posData.lab = lab2D - - def get_labels( - self, - from_store=False, - frame_i=None, - return_existing=False, - return_copy=True - ): - """Get the labels array. - - Parameters - ---------- - from_store : bool, optional - If True load the labels array from the stored posData.allData_li, - i.e., from RAM. Default is False - frame_i : int, optional - If None, use the current frame index. Default is None - return_existing : bool, optional - If True, the second return element will be a boolean that - is True if the labels array was found stored in `posData.allData_li`. - Default is False - return_copy : bool, optional - If True returns a copy of the labels array - - Returns - ------- - numpy.ndarray or tuple of (numpy.ndarray, bool) - The first element is the labels array requested. If `return_existing` - is True then this method also returns a second boolean element that - is True if the labels array was found in in `posData.allData_li`. - - Note - ---- - - If `from_store` is True then this method will try to get the stored - labels array. If any error occurs then the returned labels are the - saved ones in the segmentation file (i.e., from hard drive). - - """ - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - existing = True - if from_store: - try: - labels = posData.allData_li[frame_i]['labels'] - if labels is None: - from_store = False - except Exception as err: - from_store = False - - if not from_store: - try: - labels = posData.segm_data[frame_i] - except IndexError: - existing = False - # Visting a frame that was not segmented --> empty masks - if self.isSegm3D: - shape = (posData.SizeZ, posData.SizeY, posData.SizeX) - else: - shape = (posData.SizeY, posData.SizeX) - labels = np.zeros(shape, dtype=np.uint32) - return_copy = False - - if return_copy: - labels = labels.copy() - - if return_existing: - return labels, existing - else: - return labels - - def addYXcentroidToDf(self, df): - posData = self.data[self.pos_i] - for obj in posData.rp: - y_centroid = int(self.getObjCentroid(obj.centroid)[0]) - x_centroid = int(self.getObjCentroid(obj.centroid)[1]) - df.at[obj.label, 'y_centroid'] = y_centroid - df.at[obj.label, 'x_centroid'] = x_centroid - return df - - def _get_editID_info(self, df): - if 'was_manually_edited' not in df.columns: - return [] - - if 'y_centroid' not in df.columns or 'x_centroid' not in df.columns: - df = self.addYXcentroidToDf(df) - - manually_edited_df = df[df['was_manually_edited'] > 0] - editID_info = [ - (row.y_centroid, row.x_centroid, row.Index) - for row in manually_edited_df.itertuples() - ] - return editID_info - - def apply_manual_edits_to_lab_if_needed(self, lab): - posData = self.data[self.pos_i] - data_frame_i = posData.allData_li[posData.frame_i] - edited_lab_dict = data_frame_i['manually_edited_lab']['lab'] - if not edited_lab_dict: - return lab - - # zoom_slice = data_frame_i['manually_edited_lab']['zoom_slice'] - for z, lab_edited in edited_lab_dict.items(): - if not self.isSegm3D: - # lab[zoom_slice] = lab_edited - lab = lab_edited - break - - lab[z] = lab_edited - - # lab[z, zoom_slice[0], zoom_slice[1]] = zoom_lab - - return lab - - def _get_data_unvisited(self, posData, debug=False, lin_tree_init=True,): - posData.editID_info = [] - proceed_cca = True - never_visited = True - if str(self.modeComboBox.currentText()) == 'Cell cycle analysis': - # Warn that we are visiting a frame that was never segm-checked - # on cell cycle analysis mode - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Segmentation and Tracking was never checked from ' - f'frame {posData.frame_i+1} onwards.

' - 'To ensure correct cell cell cycle analysis you have to ' - 'first visit the frames after ' - f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' - ) - warn_cca = msg.critical( - self, 'Never checked segmentation on requested frame', txt - ) - proceed_cca = False - return proceed_cca, never_visited - - elif str(self.modeComboBox.currentText()) == 'Normal division: Lineage tree': - # Warn that we are visiting a frame that was never segm-checked - # on cell cycle analysis mode - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Segmentation and Tracking was never checked from ' - f'frame {posData.frame_i+1} onwards.

' - 'To ensure correct lineage tree analysis you have to ' - 'first visit the frames after ' - f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' - ) - warn_cca = msg.critical(#??? - self, 'Never checked segmentation on requested frame', txt - ) - proceed_cca = False - return proceed_cca, never_visited - - # Requested frame was never visited before. Load from HDD - labels = self.get_labels() - posData.lab = self.apply_manual_edits_to_lab_if_needed( - labels - ) - posData.rp = skimage.measure.regionprops(posData.lab) - self.setManualBackgroundLab() - - if posData.acdc_df is not None: - frames = posData.acdc_df.index.get_level_values(0) - if posData.frame_i in frames: - # Since there was already segmentation metadata from - # previous closed session add it to current metadata - df = posData.acdc_df.loc[posData.frame_i].copy() - binnedIDs_df = df[df['is_cell_excluded']>0] - binnedIDs = set(binnedIDs_df.index).union(posData.binnedIDs) - posData.binnedIDs = binnedIDs - ripIDs_df = df[df['is_cell_dead']>0] - ripIDs = set(ripIDs_df.index).union(posData.ripIDs) - posData.ripIDs = ripIDs - posData.editID_info.extend(self._get_editID_info(df)) - # Load cca df into current metadata - if 'cell_cycle_stage' in df.columns: - cca_cols = df.columns.intersection(self.cca_df_colnames) - cca_df = df[cca_cols].dropna() - if cca_df.empty: - df = df.drop( - columns=self.cca_df_colnames, errors='ignore' - ) - else: - df = df.loc[cca_df.index] - cols = self.cca_df_int_cols - df[cols] = df[cols].astype('Int64') - - i = posData.frame_i - posData.allData_li[i]['acdc_df'] = df.copy() - - if self.lineage_tree is None and lin_tree_init: - self.initLinTree() - - self.get_cca_df() - - return proceed_cca, never_visited - - def _get_data_visited(self, posData, debug=False, lin_tree_init=True,): - # Requested frame was already visited. Load from RAM. - never_visited = False - posData.lab = self.get_labels(from_store=True) - posData.rp = skimage.measure.regionprops(posData.lab) - df = posData.allData_li[posData.frame_i]['acdc_df'] - if df is None: - posData.binnedIDs = set() - posData.ripIDs = set() - posData.editID_info = [] - else: - try: - binnedIDs_df = df[df['is_cell_excluded']>0] - except Exception as err: - df = myutils.fix_acdc_df_dtypes(df) - binnedIDs_df = df[df['is_cell_excluded']>0] - posData.binnedIDs = set(binnedIDs_df.index) - ripIDs_df = df[df['is_cell_dead']>0] - posData.ripIDs = set(ripIDs_df.index) - posData.editID_info = self._get_editID_info(df) - self.setManualBackgroundLab(load_from_store=True, debug=debug) - if self.lineage_tree is None and lin_tree_init: - self.initLinTree() - - self.get_cca_df(debug=debug) - - return True, never_visited - - @get_data_exception_handler - def get_data(self, debug=False, lin_tree_init=True): - posData = self.data[self.pos_i] - proceed_cca = True - never_visited = False - if posData.frame_i > 2: - # Remove undo states from 4 frames back to avoid memory issues - posData.UndoRedoStates[posData.frame_i-4] = [] - # Check if current frame contains undo states (not empty list) - if posData.UndoRedoStates[posData.frame_i]: - self.undoAction.setDisabled(False) - elif posData.UndoRedoCcaStates[posData.frame_i]: - self.undoAction.setDisabled(False) - else: - self.undoAction.setDisabled(True) - self.UndoCount = 0 - # If stored labels is None then it is the first time we visit this frame - if posData.allData_li[posData.frame_i]['labels'] is None: - proceed_cca, never_visited = self._get_data_unvisited( - posData, lin_tree_init=lin_tree_init, - ) - if not proceed_cca: - return proceed_cca, never_visited - else: - proceed_cca, never_visited = self._get_data_visited( - posData, lin_tree_init=lin_tree_init, debug=debug - ) - - self.update_rp_metadata(draw=False) - posData.IDs = [obj.label for obj in posData.rp] - posData.IDs_idxs = { - ID:i for ID, i in zip(posData.IDs, range(len(posData.IDs))) - } - self.get_zslices_rp() - self.pointsLayerDfsToData(posData) - return proceed_cca, never_visited - - def addIDBaseCca_df(self, posData, ID): - if ID <= 0: - # When calling update_cca_df_deletedIDs we add relative IDs - # but they could be -1 for cells in G1 - return - - _zip = zip( - self.cca_df_colnames, - self.cca_df_default_values, - ) - if posData.cca_df.empty: - posData.cca_df = pd.DataFrame( - {col: val for col, val in _zip}, - index=[ID] - ) - else: - for col, val in _zip: - posData.cca_df.at[ID, col] = val - self.store_cca_df() - - def getBaseCca_df(self, with_tree_cols=False): - posData = self.data[self.pos_i] - IDs = [obj.label for obj in posData.rp] - cca_df = core.getBaseCca_df(IDs, with_tree_cols=with_tree_cols) - return cca_df - - def get_last_tracked_i(self): - posData = self.data[self.pos_i] - last_tracked_i = 0 - for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] - if lab is None and frame_i == 0: - last_tracked_i = 0 - break - elif lab is None: - last_tracked_i = frame_i-1 - break - else: - last_tracked_i = posData.segmSizeT-1 - return last_tracked_i - - def get_last_cca_frame_i(self): - posData = self.data[self.pos_i] - - i = 0 - # Determine last annotated frame index - for i, dict_frame_i in enumerate(posData.allData_li): - df = dict_frame_i['acdc_df'] - if df is None: - break - elif 'cell_cycle_stage' not in df.columns: - break - - last_cca_frame_i = i if i==0 or i+1==len(posData.allData_li) else i-1 - - return last_cca_frame_i - - def initSegmTrackMode(self): - posData = self.data[self.pos_i] - last_tracked_i = self.get_last_tracked_i() - - if posData.frame_i > last_tracked_i: - # Prompt user to go to last tracked frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - f'The last visited frame in "Segmentation and Tracking mode" ' - f'is frame {last_tracked_i+1}.\n\n' - f'We recommend to resume from that frame.

' - 'How do you want to proceed?' - ) - goToButton, stayButton = msg.warning( - self, 'Go to last visited frame?', txt, - buttonsTexts=( - f'Resume from frame {last_tracked_i+1} (RECOMMENDED)', - f'Stay on current frame {posData.frame_i+1}' - ) - ) - if msg.clickedButton == goToButton: - posData.frame_i = last_tracked_i - self.lastFrameRanOnFirstVisitTools = posData.frame_i - self.get_data() - self.updateAllImages() - self.updateScrollbars() - else: - last_tracked_i = posData.frame_i - current_frame_i = posData.frame_i - self.lastFrameRanOnFirstVisitTools = posData.frame_i - self.logger.info( - f'Storing data up until frame n. {current_frame_i+1}...' - ) - pbar = tqdm(total=current_frame_i+1, ncols=100) - for i in range(current_frame_i): - posData.frame_i = i - self.get_data() - self.store_data(autosave=i==current_frame_i-1) - pbar.update() - pbar.close() - - posData.frame_i = current_frame_i - self.get_data() - - self.highlightLostNew() - self.updateLastCheckedFrameWidgets(last_tracked_i) - - self.isFirstTimeOnNextFrame() - self.initRealTimeTracker() - - def updateLastCheckedFrameWidgets(self, last_tracked_i): - self.navigateScrollBar.setMaximum(last_tracked_i+1) - self.navSpinBox.setMaximum(last_tracked_i+1) - self.lastTrackedFrameLabel.setText( - f'Last checked frame n. = {last_tracked_i+1}' - ) - - @exception_handler - def initCca(self): - posData = self.data[self.pos_i] - last_tracked_i = self.get_last_tracked_i() - defaultMode = 'Viewer' - if last_tracked_i == 0: - txt = html_utils.paragraph( - 'On this dataset either you never checked that the segmentation ' - 'and tracking are correct or you did not save yet.

' - 'If you already visited some frames with "Segmentation and Tracking" ' - 'mode save data before switching to "Cell cycle analysis mode".

' - 'Otherwise you first have to check (and eventually correct) some frames ' - 'in "Segmentation and Tracking" mode before proceeding ' - 'with cell cycle analysis.') - msg = widgets.myMessageBox() - msg.critical( - self, 'Tracking was never checked', txt - ) - self.modeComboBox.setCurrentText(defaultMode) - return - - proceed = True - - last_cca_frame_i = self.get_last_cca_frame_i() - if last_cca_frame_i == 0: - # Remove undoable actions from segmentation mode - posData.UndoRedoStates[0] = [] - self.undoAction.setEnabled(False) - self.redoAction.setEnabled(False) - - if posData.frame_i > last_cca_frame_i: - # Prompt user to go to last annotated frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_cca_frame_i+1}.

- Do you want to restart cell cycle analysis from frame - {last_cca_frame_i+1}?
- """) - _, goToFrameButton, stayButton = msg.warning( - self, 'Go to last annotated frame?', txt, - buttonsTexts=( - 'Cancel', f'Yes, go to frame {last_cca_frame_i+1}', - 'No, stay on current frame') - ) - if goToFrameButton == msg.clickedButton: - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - msg = 'Looking good!' - self.last_cca_frame_i = last_cca_frame_i - posData.frame_i = last_cca_frame_i - self.titleLabel.setText(msg, color=self.titleColor) - self.get_data() - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - self.updateAllImages() - self.updateScrollbars() - elif stayButton == msg.clickedButton: - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - self.initMissingFramesCca(last_cca_frame_i, posData.frame_i) - last_cca_frame_i = posData.frame_i - msg = 'Cell cycle analysis initialised!' - self.titleLabel.setText(msg, color='g') - elif msg.cancel: - msg = 'Cell cycle analysis aborted.' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - self.modeComboBox.setCurrentText(defaultMode) - proceed = False - return - elif posData.frame_i < last_cca_frame_i: - # Prompt user to go to last annotated frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_cca_frame_i+1}.

- Do you want to restart cell cycle analysis from frame - {last_cca_frame_i+1}?
- """) - yesButton, noButton, _ = msg.question( - self, 'Go to last annotated frame?', txt, - buttonsTexts=('Yes', 'No', 'Cancel') - ) - if msg.cancel: - msg = 'Cell cycle analysis aborted.' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - self.modeComboBox.setCurrentText(defaultMode) - proceed = False - return - - self.addMissingIDs_cca_df(posData) - if msg.clickedButton == yesButton: - self.addMissingIDs_cca_df(posData) - msg = 'Looking good!' - self.titleLabel.setText(msg, color=self.titleColor) - self.last_cca_frame_i = last_cca_frame_i - posData.frame_i = last_cca_frame_i - self.get_data() - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - self.updateAllImages() - self.updateScrollbars() - else: - self.get_data() - self.addMissingIDs_cca_df(posData) - self.store_cca_df() - - self.last_cca_frame_i = last_cca_frame_i - - self.navigateScrollBar.setMaximum(last_cca_frame_i+1) - self.navSpinBox.setMaximum(last_cca_frame_i+1) - self.lastTrackedFrameLabel.setText( - f'Last cc annot. frame n. = {last_cca_frame_i+1}' - ) - - if posData.cca_df is None: - posData.cca_df = self.getBaseCca_df() - self.store_cca_df() - msg = 'Cell cycle analysis initialized!' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - else: - self.get_cca_df() - - self.enqCcaIntegrityChecker() - - return proceed - @exception_handler - def initLinTree(self, force=False): - """ - Initializes the lineage tree analysis. - - This method checks if the tracking has been previously checked and saved. If not, it displays a message to the user. - It also prompts the user to go to the last annotated frame and restart the lineage tree analysis if necessary. - Finally, it initializes the necessary data structures and updates the GUI. - - Returns - ------- - proceed : bool - True if the initialization is successful, nothing otherwise. - """ - - if not force and self.lineage_tree is not None: - return - - mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree' and not force: - return - - posData = self.data[self.pos_i] - last_tracked_i = self.get_last_tracked_i() - defaultMode = 'Viewer' - if last_tracked_i == 0: - # Display message to the user - txt = html_utils.paragraph( - 'On this dataset either you never checked that the segmentation ' - 'and tracking are correct or you did not save yet.

' - 'If you already visited some frames with "Segmentation and Tracking" ' - 'mode save data before switching to "Normal division: Lineage Tree".

' - 'Otherwise you first have to check (and eventually correct) some frames ' - 'in "Segmentation and Tracking" mode before proceeding ' - 'with lineage tree analysis.') - msg = widgets.myMessageBox() - msg.critical( - self, 'Tracking was never checked', txt - ) - self.modeComboBox.setCurrentText(defaultMode) - return - - proceed = True - last_lin_tree_frame_i = 0 - # Determine last annotated frame index - for i, dict_frame_i in enumerate(posData.allData_li): - df = dict_frame_i['acdc_df'] - if (df is None or - 'generation_num_tree' not in df.columns - or df['generation_num_tree'].isin([np.nan, 0]).all() - ): - break - else: - last_lin_tree_frame_i = i - - if last_lin_tree_frame_i == 0: - # Remove undoable actions from segmentation mode - posData.UndoRedoStates[0] = [] - self.undoAction.setEnabled(False) - self.redoAction.setEnabled(False) - - if posData.frame_i > last_lin_tree_frame_i: - # Prompt user to go to last annotated frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_lin_tree_frame_i+1}.

- Do you want to restart lineage tree analysis from frame - {last_lin_tree_frame_i+1}?
- """) - _, yesButton, stayButton = msg.warning( - self, 'Go to last annotated frame?', txt, - buttonsTexts=( - 'Cancel', f'Yes, go to frame {last_lin_tree_frame_i+1}', - 'No, stay on current frame') - ) - if yesButton == msg.clickedButton: - msg = 'Looking good!' - self.last_lin_tree_frame_i = last_lin_tree_frame_i - posData.frame_i = last_lin_tree_frame_i - self.titleLabel.setText(msg, color=self.titleColor) - self.get_data(lin_tree_init=False) - self.updateAllImages() # i dont think I need to change this - self.updateScrollbars() # i dont think I need to change this - elif stayButton == msg.clickedButton: - self.initMissingFramesLinTree(posData.frame_i) #!!! - last_lin_tree_frame_i = posData.frame_i - msg = 'Lineage tree analysis initialised!' - self.titleLabel.setText(msg, color='g') - elif msg.cancel: - msg = 'Lineage tree analysis aborted.' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - self.modeComboBox.setCurrentText(defaultMode) - proceed = False - return - - elif posData.frame_i < last_lin_tree_frame_i: - # Prompt user to go to last annotated frame - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_lin_tree_frame_i+1}.

- Do you want to restart lineage tree analysis from frame - {last_lin_tree_frame_i+1}?
- """) - goTo_last_annotated_frame_i = msg.question( - self, 'Go to last annotated frame?', txt, - buttonsTexts=('Yes', 'No', 'Cancel') - )[0] - if goTo_last_annotated_frame_i == msg.clickedButton: - msg = 'Looking good!' - self.titleLabel.setText(msg, color=self.titleColor) - self.last_lin_tree_frame_i = last_lin_tree_frame_i - posData.frame_i = last_lin_tree_frame_i - self.get_data(lin_tree_init=False) - self.updateAllImages() # i dont think I need to change this - self.updateScrollbars() # i dont think I need to change this - elif msg.cancel: - msg = 'Lineage tree analysis aborted.' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - self.modeComboBox.setCurrentText(defaultMode) - proceed = False - return - else: - self.get_data(lin_tree_init=False) - - self.last_lin_tree_frame_i = last_lin_tree_frame_i - - self.navigateScrollBar.setMaximum(last_lin_tree_frame_i+1) - self.navSpinBox.setMaximum(last_lin_tree_frame_i+1) - - if self.lineage_tree is None or force: - self.store_data(autosave=False) - self.get_data(lin_tree_init=False) - self.lineage_tree = normal_division_lineage_tree(gui=self) - - msg = 'Lineage tree analysis initialized!' - self.logger.info(msg) - self.titleLabel.setText(msg, color=self.titleColor) - - return proceed - - @disableWindow - def propagateLinTreeAction(self, dummy_for_button=None): - """ - Propagates the lineage tree based on the current frame_i. Used in self.propagateLinTreeButton. - """ - posData = self.data[self.pos_i] - self.lineage_tree.propagate(posData.frame_i) - if posData.frame_i == self.original_df_lin_tree_i: - self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() - - self.logger.info('Lineage tree propagated.') - - def isCcaCheckerChecking(self): - if not self.ccaCheckerRunning: - return False - - return self.ccaIntegrityCheckerWorker.isChecking - - def getConcatCcaDf(self): - posData = self.data[self.pos_i] - cca_dfs = [] - keys = [] - for frame_i in range(0, posData.SizeT): - cca_df = self.get_cca_df(frame_i=frame_i, return_df=True) - if cca_df is None: - break - - cca_dfs.append(cca_df) - keys.append(frame_i) - - if not cca_dfs: - return - - global_cca_df = pd.concat(cca_dfs, keys=keys, names=['frame_i']) - return global_cca_df - - def storeFromConcatCcaDf(self, global_cca_df): - posData = self.data[self.pos_i] - for frame_i in range(0, posData.SizeT): - try: - cca_df = global_cca_df.loc[frame_i] - except KeyError as err: - break - - self.store_cca_df(frame_i=frame_i, cca_df=cca_df, autosave=False) - - self.get_cca_df() - - def resetWillDivideInfo(self): - global_cca_df = self.getConcatCcaDf() - if global_cca_df is None: - return - - global_cca_df = load._fix_will_divide(global_cca_df) - self.storeFromConcatCcaDf(global_cca_df) - - def ccaCheckerStopChecking(self): - if not self.ccaCheckerRunning: - return - - self.ccaIntegrityCheckerWorker.clearQueue() - - if self.ccaIntegrityCheckerWorker.isChecking: - self.ccaIntegrityCheckerWorker.abortChecking = True - - def updateLastVisitedFrame(self, last_visited_frame_i=None): - if last_visited_frame_i is None: - posData = self.data[self.pos_i] - last_visited_frame_i = posData.frame_i - - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - return - elif mode == 'Segmentation and Tracking': - posData = self.data[self.pos_i] - if posData.last_tracked_i >= last_visited_frame_i: - return - posData.last_tracked_i = last_visited_frame_i - elif mode == 'Cell cycle analysis': - if self.last_cca_frame_i >= last_visited_frame_i: - return - self.last_cca_frame_i = last_visited_frame_i - - def resetCcaFuture(self, from_frame_i): - posData = self.data[self.pos_i] - self.last_cca_frame_i = from_frame_i-1 - self.ccaCheckerStopChecking() - - self.setNavigateScrollBarMaximum() - for i in range(from_frame_i, posData.SizeT): - posData.allData_li[i].pop('cca_df', None) - posData.allData_li[i].pop('cca_df_checker', None) - - df = posData.allData_li[i]['acdc_df'] - if df is None: - # No more saved info to delete - break - - if 'cell_cycle_stage' not in df.columns: - # No cell cycle info present - continue - - df = df.drop(columns=self.cca_df_colnames) - posData.allData_li[i]['acdc_df'] = df - - if posData.acdc_df is not None: - frames = posData.acdc_df.index.get_level_values(0) - if from_frame_i in frames: - posData.acdc_df = posData.acdc_df.loc[:from_frame_i] - - self.resetWillDivideInfo() - - def removeCcaAnnotationsCurrentFrame(self): - posData = self.data[self.pos_i] - posData.cca_df = None - - posData.allData_li[posData.frame_i].pop('cca_df', None) - posData.allData_li[posData.frame_i].pop('cca_df_checker', None) - - df = posData.allData_li[posData.frame_i]['acdc_df'] - if df is None: - # No more saved info to delete - return False - - if 'cell_cycle_stage' not in df.columns: - # No cell cycle info present - return False - - df = df.drop(columns=self.cca_df_colnames) - posData.allData_li[posData.frame_i]['acdc_df'] = df - - return True - - def resetFutureCcaColCurrentFrame(self): - posData = self.data[self.pos_i] - - cca_df_S_mask = posData.cca_df.cell_cycle_stage == 'S' - posData.cca_df.loc[cca_df_S_mask, 'will_divide'] = 0 - - mothers_mask = ( - (posData.cca_df.relationship == 'mother') - & cca_df_S_mask - ) - bud_mask = posData.cca_df.relationship == 'bud' - - posData.cca_df.loc[mothers_mask, 'daughter_disappears_before_division'] = 0 - posData.cca_df.loc[bud_mask, 'disappears_before_division'] = 0 - - cca_df = self.get_cca_df(frame_i=posData.frame_i, return_df=True) - if cca_df is not None: - cca_df_S_mask = cca_df.cell_cycle_stage == 'S' - cca_df.loc[cca_df_S_mask, 'will_divide'] = 0 - - mothers_mask = ( - (cca_df.relationship == 'mother') - & cca_df_S_mask - ) - bud_mask = cca_df.relationship == 'bud' - - cca_df.loc[mothers_mask, 'daughter_disappears_before_division'] = 0 - cca_df.loc[bud_mask, 'disappears_before_division'] = 0 - - self.store_data() - - def resetLin_tree_future(self): - posData = self.data[self.pos_i] - frame_i = posData.frame_i - - for i in range(frame_i, posData.SizeT): - if self.lineage_tree is not None: - self.lineage_tree.frames_for_dfs.discard(frame_i) - df = posData.allData_li[i]['acdc_df'] - # reste lineage tree columns - if df is None: - continue - df = df.drop(columns=lineage_tree_cols, errors='ignore') - posData.allData_li[i]['acdc_df'] = df - - def get_cca_df(self, frame_i=None, return_df=False, debug=False): - # cca_df is None unless the metadata contains cell cycle annotations - # NOTE: cell cycle annotations are either from the current session - # or loaded from HDD in "initPosAttr" with a .question to the user - posData = self.data[self.pos_i] - cca_df = None - i = posData.frame_i if frame_i is None else frame_i - df = posData.allData_li[i]['acdc_df'] - if df is not None: - if 'cell_cycle_stage' in df.columns: - cca_df = df[self.cca_df_colnames].copy() - - if cca_df is None and self.isSnapshot: - cca_df = self.getBaseCca_df() - posData.cca_df = cca_df - - if cca_df is not None: - cca_df = cca_df.dropna() - - if return_df: - return cca_df - else: - posData.cca_df = cca_df - - def changeIDfutureFrames( - self, endFrame_i, oldIDnewIDMapper, includeUnvisited, - shift=False - ): - posData = self.data[self.pos_i] - self.current_frame_i = posData.frame_i - - # Store data for current frame - self.store_data() - if endFrame_i is None: - self.app.restoreOverrideCursor() - return - - segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i+1, segmSizeT): - lab = posData.allData_li[i]['labels'] - if lab is None and not includeUnvisited: - self.enqAutosave() - break - - if lab is not None: - # Visited frame - posData.frame_i = i - self.get_data(lin_tree_init=False) - if shift and self.isSegm3D: - lab = self.get_2Dlab(posData.lab) - else: - lab = posData.lab - - if self.onlyTracking: - self.tracking(enforce=True) - elif not posData.IDs: - continue - else: - maxID = max(posData.IDs, default=0) + 1 - for old_ID, new_ID in oldIDnewIDMapper: - if new_ID in lab: - tempID = maxID + 1 # lab.max() + 1 - lab[lab == old_ID] = tempID - lab[lab == new_ID] = old_ID - lab[lab == tempID] = new_ID - maxID += 1 - else: - lab[lab == old_ID] = new_ID - - if shift and self.isSegm3D: - self.set_2Dlab(lab) - - self.update_rp(draw=False) - self.store_data(autosave=i==endFrame_i) - elif includeUnvisited: - # Unvisited frame (includeUnvisited = True) - lab = posData.segm_data[i] - if shift and self.isSegm3D: - lab = self.get_2Dlab(lab) - else: - lab = lab - - for old_ID, new_ID in oldIDnewIDMapper: - if new_ID in lab: - tempID = lab.max() + 1 - lab[lab == old_ID] = tempID - lab[lab == new_ID] = old_ID - lab[lab == tempID] = new_ID - else: - lab[lab == old_ID] = new_ID - - if shift and self.isSegm3D: - posData.segm_data[i][self.z_lab()] = lab - - # Back to current frame - posData.frame_i = self.current_frame_i - self.get_data() - self.app.restoreOverrideCursor() - - def unstore_cca_df(self): - posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - for col in self.cca_df_colnames: - if col not in acdc_df.columns: - continue - acdc_df.drop(col, axis=1, inplace=True) - - def store_cca_df_checker(self, posData, frame_i, cca_df): - if not self.ccaCheckerRunning: - return - - if cca_df is None: - return - - posData.allData_li[frame_i]['cca_df_checker'] = cca_df.copy() - - def store_cca_df( - self, pos_i=None, frame_i=None, cca_df=None, mainThread=True, - autosave=True, store_cca_df_copy=False - ): - pos_i = self.pos_i if pos_i is None else pos_i - posData = self.data[pos_i] - i = posData.frame_i if frame_i is None else frame_i - if cca_df is None: - cca_df = posData.cca_df - if self.ccaTableWin is not None and mainThread: - zoomIDs = self.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - acdc_df = posData.allData_li[i]['acdc_df'] - if acdc_df is None: - current_frame_i = None - if frame_i is not None and frame_i != posData.frame_i: - current_frame_i = posData.frame_i - posData.frame_i = frame_i - self.get_data() - self.store_data() - acdc_df = posData.allData_li[i]['acdc_df'] - if current_frame_i is not None: - # Back to current frame - posData.frame_i = current_frame_i - self.get_data(debug=False) - - if 'cell_cycle_stage' in acdc_df.columns: - # Cell cycle info already present --> overwrite with new - acdc_df[self.cca_df_colnames] = cca_df[self.cca_df_colnames] - posData.allData_li[i]['acdc_df'] = acdc_df - elif cca_df is not None: - df = acdc_df.drop(cca_df.columns, axis=1, errors='ignore') - df = df.join(cca_df, how='left') - posData.allData_li[i]['acdc_df'] = df - - # Store copy for cca integrity worker - self.store_cca_df_checker(posData, i, cca_df) - - if store_cca_df_copy and cca_df is not None: - posData.allData_li[i]['cca_df'] = cca_df.copy() - - if autosave: - self.enqAutosave() - self.enqCcaIntegrityChecker() - - # def lin_tree_to_acdc_df(self, force_all=False, ignore=set(), force=set(), specific=set()): - # """ - # Syncs the lineage tree DataFrame with the acdc_df DataFrame. By default, it will only try to sync frames which have not been synced before. - # This can be changed using the optional arguments. - - # Parameters - # ---------- - # force_all : bool, optional - # If True, forces synchronization for all frames. Defaults to False. - # ignore : set, optional - # Set of frames to ignore during synchronization. Defaults to set(). - # force : set, optional - # Set of frames to force synchronization. Defaults to set(). - # specific : set, optional - # Set of frames to specifically synchronize. In this case it will ignore all other inputs and sync those no matter what. Defaults to set(). - # """ - - # if self.lineage_tree is None: - # return - - # # df_for_sync = [] - # # lineage_copy = self.lineage_tree.lineage_list.copy() - # lin_tree_set = self.lineage_tree.frames_for_dfs.copy() - - # if not force_all and not specific: - # dont_sync = self.already_synced_lin_tree - # dont_sync = {frame for frame in dont_sync if not frame in force} - # dont_sync.update(ignore) - - # lin_tree_set = lin_tree_set.difference(dont_sync) - - # if specific: - # lin_tree_set = lin_tree_set.intersection(specific) - - - # if lin_tree_set == []: - # return - - # posData = self.data[self.pos_i] - - # lin_tree_colnames = None - # self.store_data(autosave=False) - # for frame_i in lin_tree_set: - # acdc_df = posData.allData_li[frame_i]['acdc_df'] - - # lin_tree_df = self.lineage_tree.export_df(frame_i) - # if lin_tree_colnames is None: - # lin_tree_colnames = lin_tree_df.columns - - # acdc_df.loc[lin_tree_df.index, lin_tree_colnames] = lin_tree_df[lin_tree_colnames] - - # try: - # try: - # if (acdc_df['generation_num'] == 2).all() and not (acdc_df['generation_num_tree'].isna().all()): # check if generation_num is all just the default value and if yes, replace it with the tree values - # acdc_df['generation_num'] = acdc_df['generation_num_tree'] - # except KeyError: - # acdc_df['generation_num'] = acdc_df['generation_num_tree'] - # except Exception as e: - # self.logger.error(f'Error while syncing generation_num from lineage tree: {e} \n please save and restart') - - # posData.allData_li[frame_i]['acdc_df'] = acdc_df - # self.already_synced_lin_tree.add(frame_i) - - def turnOffAutoSaveWorker(self): - self.autoSaveToggle.setChecked(False) - - def autoSaveTimerTimedOut(self): - if not hasattr(self, 'data'): - # This happes when the self.autoSaveTimer times out after - # the GUI has been closed --> we simply ignore it - self.autoSaveTimer.stop() - return - - self.autoSaveTimer.stop() - self.flushDirtyPointsLayersAutosave() - self._enqueueAutoSave() - - def autoSaveTimerCountFrames(self): - if not hasattr(self, 'data'): - # This happes when the self.autoSaveTimer times out after - # the GUI has been closed --> we simply ignore it - return - - posData = self.data[self.pos_i] - autoSaveIntevalValue, autoSaveIntervalUnit = ( - self.autoSaveIntevalValueUnit - ) - isTimeToAutoSave = ( - abs(posData.frame_i - self.autoSaveTimeStartFrameIdx) - >= autoSaveIntevalValue - ) - if not isTimeToAutoSave: - return - - self.autoSaveTimeStartFrameIdx = posData.frame_i - self.flushDirtyPointsLayersAutosave() - self._enqueueAutoSave() - - def enqAutosave(self): - mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - if self.statusBarLabel.text().endswith('Autosaving...'): - self.statusBarLabel.setText( - self.statusBarLabel.text().replace(' | Autosaving...', '') - ) - return - - if not self.autoSaveActiveWorkers: - self.gui_createAutoSaveWorker() - - if not self.autoSaveActiveWorkers: - return - - if self.autoSaveTimer.isActive(): - return - - self._enqueueAutoSave() - autoSaveIntevalValue, autoSaveIntervalUnit = ( - self.autoSaveIntevalValueUnit - ) - if autoSaveIntevalValue == 0: - return - - try: - self.autoSaveTimer.timeout.disconnect() - except Exception as err: - pass - - - if autoSaveIntervalUnit == 'minutes': - autosave_interval_ms = round(autoSaveIntevalValue*60*1000) - self.autoSaveTimer.timeout.connect(self.autoSaveTimerTimedOut) - self.autoSaveTimer.start(autosave_interval_ms) - else: - self.startAutoSaveEveryNframesTimer() - - def startAutoSaveEveryNframesTimer(self): - posData = self.data[self.pos_i] - self.autoSaveTimeStartFrameIdx = posData.frame_i - self.autoSaveTimer.timeout.connect( - self.autoSaveTimerCountFrames - ) - self.autoSaveTimer.start(500) - - def _enqueueAutoSave(self): - if not self.statusBarLabel.text().endswith('Autosaving...'): - self.statusBarLabel.setText( - f'{self.statusBarLabel.text()} | Autosaving...' - ) - - timestamp = datetime.now().strftime(r'%H:%M:%S.%f')[:-3] - self.logger.info(f'Autosaving... - {timestamp}') - - posData = self.data[self.pos_i] - worker, thread = self.autoSaveActiveWorkers[-1] - worker.enqueue(posData) - - def enqCcaIntegrityChecker(self): - if not self.ccaCheckerRunning: - return - posData = self.data[self.pos_i] - self.ccaIntegrityCheckerWorker.enqueue(posData) - - def drawAllMothBudLines(self): - posData = self.data[self.pos_i] - for obj in posData.rp: - self.drawObjMothBudLines(obj, posData, ax=0) - self.drawObjMothBudLines(obj, posData, ax=1) - - def drawObjMothBudLines(self, obj, posData, ax=0): - areMothBudLinesRequested = self.areMothBudLinesRequested(ax) - if not areMothBudLinesRequested: - return - - if posData.cca_df is None: - return - - mode = str(self.modeComboBox.currentText()) - if mode == 'Normal division: Lineage Tree': - return - - ID = obj.label - try: - cca_df_ID = posData.cca_df.loc[ID] - except KeyError: - return - - isObjVisible = self.isObjVisible(obj.bbox) - if not isObjVisible: - return - - ccs_ID = cca_df_ID['cell_cycle_stage'] - if ccs_ID == 'G1': - return - - relationship = cca_df_ID['relationship'] - if relationship != 'bud': - return - - emerg_frame_i = cca_df_ID['emerg_frame_i'] - isNew = emerg_frame_i == posData.frame_i - scatterItem = self.getMothBudLineScatterItem(ax, isNew) - relative_ID = cca_df_ID['relative_ID'] - - try: - relative_rp_idx = posData.IDs_idxs[relative_ID] - except KeyError: - return - - relative_ID_obj = posData.rp[relative_rp_idx] - y1, x1 = self.getObjCentroid(obj.centroid) - y2, x2 = self.getObjCentroid(relative_ID_obj.centroid) - xx, yy = core.get_line(y1, x1, y2, x2, dashed=True) - scatterItem.addPoints(xx, yy) - - def clearAllCellToCellLines(self): - self.ax1_newMothBudLinesItem.setData([], []) - self.ax1_oldMothBudLinesItem.setData([], []) - self.ax2_newMothBudLinesItem.setData([], []) - self.ax2_oldMothBudLinesItem.setData([], []) - - def drawAllLineageTreeLines(self): - """ - Draw all lineage tree lines on the GUI. - - This method retrieves the lineage tree data and draws the lineage tree lines - connecting cells and their respective mothers when the mother has split. - """ - if self.lineage_tree is None: - return - - if len(self.lineage_tree.frames_for_dfs) < 2: - return - - self.clearAllCellToCellLines() - posData = self.data[self.pos_i] - frame_i = posData.frame_i - lin_tree_df = posData.allData_li[frame_i]['acdc_df'] - lin_tree_df_prev = posData.allData_li[frame_i-1]['acdc_df'] - rp = posData.rp - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - - self.setTitleText() - - new_cells = lin_tree_df.index.difference(lin_tree_df_prev.index) # I could use this for the if already but this is probably faster for frames where nothing changes - if new_cells.shape[0] == 0: - return - - for ax in (0, 1): - if not self.areMothBudLinesRequested(ax): - continue - - for ID in new_cells: - curr_obj = myutils.get_obj_by_label(rp, ID) - lin_tree_df_ID = lin_tree_df.loc[ID] - - # lin_tree_df_mother_ID = lin_tree_df_prev.loc[lin_tree_df_ID["parent_ID_tree"]] - if lin_tree_df_ID["parent_ID_tree"] == -1: # make sure that new obj where the parents are not known get skipped - continue - - mother_obj = myutils.get_obj_by_label(prev_rp, lin_tree_df_ID["parent_ID_tree"]) - - emerg_frame_i = lin_tree_df_ID["emerg_frame_i"] - isNew = emerg_frame_i == frame_i - - self.drawObjLin_TreeMothBudLines(ax, curr_obj, mother_obj, isNew, ID=ID) - - def drawObjLin_TreeMothBudLines(self, ax, obj, mother_obj, isNew, ID=None): - """ - Draw moth-bud lines between an object and its mother object. - - Parameters - ---------- - ax : cellacdc.widgets.MainPlotItem - The Cell-ACDC GUI axes object to draw on. - obj : Object - The object for which to draw the moth-bud lines. - mother_obj : Object - The mother object to connect with. - isNew : bool - Indicates whether the object is new or not. - ID : int, optional - The ID of the object, by default None. - """ - if not self.areMothBudLinesRequested(ax): - return - - if not ID: - ID = obj.label - - isObjVisible = self.isObjVisible(obj.bbox) - - if not isObjVisible: - return - - scatterItem = self.getMothBudLineScatterItem(ax, isNew) - - y1, x1 = self.getObjCentroid(obj.centroid) - y2, x2 = self.getObjCentroid(mother_obj.centroid) - xx, yy = core.get_line(y1, x1, y2, x2, dashed=True) - scatterItem.addPoints(xx, yy) - - def getObjCentroid(self, obj_centroid): - if self.isSegm3D: - depthAxes = self.switchPlaneCombobox.depthAxes() - zc, yc, xc = obj_centroid - if depthAxes == 'z': - return yc, xc - elif depthAxes == 'y': - return zc, xc - else: - return zc, yc - else: - return obj_centroid - - def getAnnotateHowRightImage(self): - if not self.labelsGrad.showRightImgAction.isChecked(): - return 'nothing' - - if self.rightBottomGroupbox.isChecked(): - how = self.annotateRightHowCombobox.currentText() - else: - how = self.drawIDsContComboBox.currentText() - return how - - def getObjOptsSegmLabels(self, obj): - if not self.labelsGrad.showLabelsImgAction.isChecked(): - return - - objOpts = self.getObjTextAnnotOpts(obj, 'Draw only IDs', ax=1) - return objOpts - - def store_zslices_rp(self, force_update=False): - if not self.isSegm3D: - return - - posData = self.data[self.pos_i] - are_zslices_rp_stored = ( - posData.allData_li[posData.frame_i].get('z_slices_rp') is not None - ) - if force_update or not are_zslices_rp_stored: - self._update_zslices_rp() - - posData.allData_li[posData.frame_i]['z_slices_rp'] = posData.zSlicesRp - - def removeObjectFromRp(self, delID): - posData = self.data[self.pos_i] - rp = [] - IDs = [] - IDs_idxs = {} - idx = 0 - for obj in posData.rp: - if obj.label == delID: - continue - rp.append(obj) - IDs.append(obj.label) - IDs_idxs[obj.label] = idx - idx += 1 - - posData.rp = rp - posData.IDs = IDs - posData.IDs_idxs = IDs_idxs - - if not self.isSegm3D: - return - - zSlicesRp = {} - for z, zSliceRp in posData.zSlicesRp.items(): - if delID in zSliceRp: - continue - - zSlicesRp[z] = zSlicesRp - - posData.zSlicesRp = zSlicesRp - self.store_zslices_rp(force_update=True) - - def get_zslices_rp(self): - if not self.isSegm3D: - return - - posData = self.data[self.pos_i] - self.store_zslices_rp() - posData.zSlicesRp = posData.allData_li[posData.frame_i]['z_slices_rp'] - - # @exec_time - def _update_zslices_rp(self): - if not self.isSegm3D: - return - - posData = self.data[self.pos_i] - posData.zSlicesRp = {} - for z, lab2d in enumerate(posData.lab): - lab2d_rp = skimage.measure.regionprops(lab2d) - posData.zSlicesRp[z] = {obj.label:obj for obj in lab2d_rp} - - def instructHowDeleteID(self): - if 'showInfoDeleteObject' not in self.df_settings.index: - self.df_settings.at['showInfoDeleteObject', 'value'] = 'Yes' - - showInfoDeleteObject = ( - self.df_settings.at['showInfoDeleteObject', 'value'] == 'Yes' - ) - if not showInfoDeleteObject: - return - - actionText = self.middleClickText() - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - 'You have deleted an object using the eraser tool.

' - 'Did you know that you can use the "Delete object" action
' - 'to delete an object with a single click?

' - f'To do so, use the following action: {actionText}

' - 'Note: You can also set a custom shortcut by going to the menu
' - 'Settings --> Customise keyboard shortcuts....' - ) - doNotShowAgainCheckbox = QCheckBox('Do not show again') - msg.information( - self, 'Delete objects with single click', txt, - widgets=doNotShowAgainCheckbox - ) - - showInfoDeleteObjectValue = ( - 'No' if doNotShowAgainCheckbox.isChecked() else 'Yes' - ) - self.df_settings.at['showInfoDeleteObject', 'value'] = ( - showInfoDeleteObjectValue - ) - self.df_settings.to_csv(settings_csv_path) - - - def checkWarnDeletedIDwithEraser(self): - posData = self.data[self.pos_i] - - for ID in self.erasedIDs: - if ID == 0: - continue - if ID in posData.IDs_idxs: - continue - - self.instructHowDeleteID() - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete ID with eraser') - self.updateAllImages() - else: - self.warnEditingWithCca_df('Delete ID with eraser') - - return True - - return False - - @exception_handler - def update_rp( - self, draw=True, debug=False, update_IDs=True, - wl_update=True, wl_track_og_curr=False,wl_update_lab=False - ): - - posData = self.data[self.pos_i] - # Update rp for current posData.lab (e.g. after any change) - - if wl_update: - if self.whitelistOriginalIDs is None: - old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() # for whitelist stuff - else: - old_IDs = self.whitelistOriginalIDs.copy() - self.whitelistOriginalIDs = None - elif self.whitelistOriginalIDs is None: - self.whitelist_old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() - - posData.rp = skimage.measure.regionprops(posData.lab) - if update_IDs: - IDs = [] - IDs_idxs = {} - for idx, obj in enumerate(posData.rp): - IDs.append(obj.label) - IDs_idxs[obj.label] = idx - posData.IDs = IDs - posData.IDs_idxs = IDs_idxs - self.update_rp_metadata(draw=draw) - self.store_zslices_rp(force_update=True) - - if not wl_update: - return - - # Update tracking whitelist - accepted_lost_centroids = self.getTrackedLostIDs() - new_IDs = posData.IDs - added_IDs = set(new_IDs) - set(old_IDs) - removed_IDs = ( - set(old_IDs) - - set(new_IDs) - - set(accepted_lost_centroids) - ) - - self.whitelistPropagateIDs( - IDs_to_add=added_IDs, IDs_to_remove=removed_IDs, - curr_frame_only=True, IDs_curr=new_IDs, - track_og_curr=wl_track_og_curr, - curr_lab=posData.lab, curr_rp=posData.rp, - update_lab=wl_update_lab - ) - - def extendLabelsLUT(self, lenNewLut): - posData = self.data[self.pos_i] - # Build a new lut to include IDs > than original len of lut - if lenNewLut > len(self.lut): - numNewColors = lenNewLut-len(self.lut) - # Index original lut - _lut = np.zeros((lenNewLut, 3), np.uint8) - _lut[:len(self.lut)] = self.lut - # Pick random colors and append them at the end to recycle them - randomIdx = np.random.randint(0,len(self.lut),size=numNewColors) - for i, idx in enumerate(randomIdx): - rgb = self.lut[idx] - _lut[len(self.lut)+i] = rgb - self.lut = _lut - self.initLabelsImageItems() - return True - return False - - def initLookupTableLab(self): - self.img2.setLookupTable(self.lut) - self.img2.setLevels([0, len(self.lut)]) - self.initLabelsImageItems() - - def getLabelsImageLut(self): - lut = np.zeros((len(self.lut), 4), dtype=np.uint8) - lut[:,-1] = 255 - lut[:,:-1] = self.lut - lut[0] = [0,0,0,0] - return lut - - def initLabelsImageItems(self): - lut = self.getLabelsImageLut() - self.labelsLayerImg1.setLevels([0, len(lut)]) - self.labelsLayerRightImg.setLevels([0, len(lut)]) - self.labelsLayerImg1.setLookupTable(lut) - self.labelsLayerRightImg.setLookupTable(lut) - alpha = self.imgGrad.labelsAlphaSlider.value() - self.labelsLayerImg1.setOpacity(alpha) - self.labelsLayerRightImg.setOpacity(alpha) - - def initKeepObjLabelsLayers(self): - lut = np.zeros((len(self.lut), 4), dtype=np.uint8) - lut[:,:-1] = self.lut - lut[:,-1:] = 255 - lut[0] = [0,0,0,0] - self.keepIDsTempLayerLeft.setLevels([0, len(lut)]) - self.keepIDsTempLayerLeft.setLookupTable(lut) - - - def updateTempLayerKeepIDs(self): - if not self.keepIDsButton.isChecked(): - return - - keptLab = np.zeros_like(self.currentLab2D) - - posData = self.data[self.pos_i] - for obj in posData.rp: - if obj.label not in self.keptObjectsIDs: - continue - - if not self.isObjVisible(obj.bbox): - continue - - _slice = self.getObjSlice(obj.slice) - _objMask = self.getObjImage(obj.image, obj.bbox) - - keptLab[_slice][_objMask] = obj.label - - self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) - - def highlightLabelID(self, ID, ax=0): - posData = self.data[self.pos_i] - try: - obj = posData.rp[posData.IDs_idxs[ID]] - except KeyError: - return - - self.textAnnot[ax].highlightObject(obj) - - def _keepObjects(self, keepIDs=None, lab=None, rp=None): - posData = self.data[self.pos_i] - if lab is None: - lab = posData.lab - - if rp is None: - rp = posData.rp - - if keepIDs is None: - keepIDs = self.keptObjectsIDs - - for obj in rp: - if obj.label in keepIDs: - continue - - lab[obj.slice][obj.image] = 0 - - return lab - - def clearHighlightedText(self): - pass - - def removeHighlightLabelID(self, IDs=None, ax=0): - posData = self.data[self.pos_i] - if IDs is None: - IDs = posData.IDs - - for ID in IDs: - obj = posData.rp[posData.IDs_idxs[ID]] - self.textAnnot[ax].removeHighlightObject(obj) - - def updateKeepIDs(self, IDs): - posData = self.data[self.pos_i] - - self.clearHighlightedText() - - isAnyIDnotExisting = False - # Check if IDs from line edit are present in current keptObjectIDs list - for ID in IDs: - if ID not in posData.allIDs: - isAnyIDnotExisting = True - continue - if ID not in self.keptObjectsIDs: - self.keptObjectsIDs.append(ID, editText=False) - self.highlightLabelID(ID) - - # Check if IDs in current keptObjectsIDs are present in IDs from line edit - for ID in self.keptObjectsIDs: - if ID not in posData.allIDs: - isAnyIDnotExisting = True - continue - if ID not in IDs: - self.keptObjectsIDs.remove(ID, editText=False) - - self.updateTempLayerKeepIDs() - if isAnyIDnotExisting: - self.keptIDsLineEdit.warnNotExistingID() - else: - self.keptIDsLineEdit.setInstructionsText() - - @exception_handler - def applyKeepObjects(self): - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - self._keepObjects() - self.highlightHoverIDsKeptObj(0, 0, hoverID=0) - - posData = self.data[self.pos_i] - - self.update_rp() - # Repeat tracking - self.tracking(enforce=True, assign_unique_new_IDs=False) - - if self.isSnapshot: - self.fixCcaDfAfterEdit('Deleted non-selected objects') - self.updateAllImages() - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - return - else: - removeAnnot = self.warnEditingWithCca_df( - 'Deleted non-selected objects', get_answer=True - ) - if not removeAnnot: - # We can propagate changes only if the user agrees on - # removing annotations - return - - self.current_frame_i = posData.frame_i - if posData.frame_i > 0: - txt = html_utils.paragraph(""" - Do you want to remove un-kept objects in the past frames too? - """) - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - _, _, applyToPastButton = msg.question( - self, 'Propagate to past frames?', txt, - buttonsTexts=('Cancel', 'No', 'Yes, apply to past frames') - ) - if msg.cancel: - return - if msg.clickedButton == applyToPastButton: - self.store_data() - self.logger.info('Applying keep objects to past frames...') - if not removeAnnot and posData.cca_df is not None: - delIDs = [ - ID for ID in posData.cca_df.index - if ID not in posData.IDs - ] - self.update_cca_df_deletedIDs(posData, delIDs) - - for i in tqdm(range(posData.frame_i), ncols=100): - lab = posData.allData_li[i]['labels'] - rp = posData.allData_li[i]['regionprops'] - keepLab = self._keepObjects(lab=lab, rp=rp) - # Store change - posData.allData_li[i]['labels'] = keepLab.copy() - # Get the rest of the stored metadata based on the new lab - posData.frame_i = i - self.get_data() - self.store_data(autosave=False) - - posData.frame_i = self.current_frame_i - self.get_data() - - # Ask to propagate change to all future visited frames - key = 'Keep ID' - askAction = self.askHowFutureFramesActions[key] - doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - self.keptObjectsIDs, key, doNotShow, - posData.UndoFutFrames_keepID, posData.applyFutFrames_keepID, - force=True, applyTrackingB=True - ) - - if UndoFutFrames is None: - # Empty keep object list - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - return - - posData.doNotShowAgain_keepID = doNotShowAgain - posData.UndoFutFrames_keepID = UndoFutFrames - posData.applyFutFrames_keepID = applyFutFrames - includeUnvisited = posData.includeUnvisitedInfo['Keep ID'] - - if applyFutFrames: - self.store_data() - - self.logger.info('Applying to future frames...') - pbar = tqdm(total=posData.SizeT-posData.frame_i-1, ncols=100) - segmSizeT = len(posData.segm_data) - if not removeAnnot and posData.cca_df is not None: - delIDs = [ - ID for ID in posData.cca_df.index - if ID not in posData.IDs - ] - self.update_cca_df_deletedIDs(posData, delIDs) - - for i in range(posData.frame_i+1, segmSizeT): - lab = posData.allData_li[i]['labels'] - if lab is None and not includeUnvisited: - self.enqAutosave() - pbar.update(posData.SizeT-i) - break - - rp = posData.allData_li[i]['regionprops'] - - if lab is not None: - keepLab = self._keepObjects(lab=lab, rp=rp) - # Store change - posData.allData_li[i]['labels'] = keepLab.copy() - # Get the rest of the stored metadata based on the new lab - posData.frame_i = i - self.get_data() - self.store_data(autosave=False) - elif includeUnvisited: - # Unvisited frame (includeUnvisited = True) - lab = posData.segm_data[i] - rp = skimage.measure.regionprops(lab) - keepLab = self._keepObjects(lab=lab, rp=rp) - posData.segm_data[i] = keepLab - - pbar.update() - pbar.close() - - # Back to current frame - if applyFutFrames: - posData.frame_i = self.current_frame_i - self.get_data() - - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - - def updateLookuptable(self, lenNewLut=None, delIDs=None): - posData = self.data[self.pos_i] - if lenNewLut is None: - try: - if delIDs is None: - IDs = posData.IDs - else: - # Remove IDs removed with ROI from LUT - IDs = [ID for ID in posData.IDs if ID not in delIDs] - lenNewLut = max(IDs, default=0) + 1 - except ValueError: - # Empty segmentation mask - lenNewLut = 1 - # Build a new lut to include IDs > than original len of lut - updateLevels = self.extendLabelsLUT(lenNewLut) - lut = self.lut.copy() - - try: - # lut = self.lut[:lenNewLut].copy() - for ID in posData.binnedIDs: - lut[ID] = lut[ID]*0.2 - - for ID in posData.ripIDs: - lut[ID] = lut[ID]*0.2 - except Exception as e: - err_str = traceback.format_exc() - print('='*30) - self.logger.info(err_str) - print('='*30) - - if updateLevels: - self.img2.setLevels([0, len(lut)]) - - if self.keepIDsButton.isChecked(): - lut = np.round(lut*0.3).astype(np.uint8) - keptLut = np.round(lut[self.keptObjectsIDs]/0.3).astype(np.uint8) - lut[self.keptObjectsIDs] = keptLut - - self.img2.setLookupTable(lut) - - # @exec_time - def update_rp_metadata(self, draw=True): - posData = self.data[self.pos_i] - # Add to rp dynamic metadata (e.g. cells annotated as dead) - for i, obj in enumerate(posData.rp): - ID = obj.label - obj.excluded = ID in posData.binnedIDs - obj.dead = ID in posData.ripIDs - - def annotate_rip_and_bin_IDs(self, updateLabel=False): - depthAxes = self.switchPlaneCombobox.depthAxes() - if self.switchPlaneCombobox.isEnabled() and depthAxes != 'z': - return - - posData = self.data[self.pos_i] - binnedIDs_xx = [] - binnedIDs_yy = [] - ripIDs_xx = [] - ripIDs_yy = [] - for obj in posData.rp: - obj.excluded = obj.label in posData.binnedIDs - obj.dead = obj.label in posData.ripIDs - if not self.isObjVisible(obj.bbox): - continue - - if obj.excluded: - y, x = self.getObjCentroid(obj.centroid) - binnedIDs_xx.append(x) - binnedIDs_yy.append(y) - if updateLabel: - self.getObjOptsSegmLabels(obj) - how = self.drawIDsContComboBox.currentText() - - if obj.dead: - y, x = self.getObjCentroid(obj.centroid) - ripIDs_xx.append(x) - ripIDs_yy.append(y) - if updateLabel: - self.getObjOptsSegmLabels(obj) - how = self.drawIDsContComboBox.currentText() - - self.ax2_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) - self.ax2_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) - self.ax1_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) - self.ax1_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) - - def loadNonAlignedFluoChannel(self, fluo_path): - posData = self.data[self.pos_i] - if posData.filename.find('aligned') != -1: - filename, _ = os.path.splitext(os.path.basename(fluo_path)) - path = f'.../{posData.pos_foldername}/Images/{filename}_aligned.npz' - msg = widgets.myMessageBox() - msg.critical( - self, 'Aligned fluo channel not found!', - 'Aligned data for fluorescence channel not found!\n\n' - f'You loaded aligned data for the cells channel, therefore ' - 'loading NON-aligned fluorescence data is not allowed.\n\n' - 'Run the script "dataPrep.py" to create the following file:\n\n' - f'{path}' - ) - return None - fluo_data = np.squeeze(skimage.io.imread(fluo_path)) - return fluo_data - - def load_fluo_data(self, fluo_path, isGuiThread=True): - self.logger.info(f'Loading fluorescence image data from "{fluo_path}"...') - bkgrData = None - posData = self.data[self.pos_i] - # Load overlay frames and align if needed - filename = os.path.basename(fluo_path) - filename_noEXT, ext = os.path.splitext(filename) - if ext == '.npy' or ext == '.npz': - fluo_data = np.load(fluo_path) - try: - fluo_data = np.squeeze(fluo_data['arr_0']) - except Exception as e: - fluo_data = np.squeeze(fluo_data) - - # Load background data - bkgrData_path = os.path.join( - posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' - ) - if os.path.exists(bkgrData_path): - bkgrData = np.load(bkgrData_path) - elif ext == '.tif' or ext == '.tiff': - aligned_filename = f'{filename_noEXT}_aligned.npz' - aligned_path = os.path.join(posData.images_path, aligned_filename) - if os.path.exists(aligned_path): - fluo_data = np.load(aligned_path)['arr_0'] - - # Load background data - bkgrData_path = os.path.join( - posData.images_path, f'{aligned_filename}_bkgrRoiData.npz' - ) - if os.path.exists(bkgrData_path): - bkgrData = np.load(bkgrData_path) - else: - fluo_data = self.loadNonAlignedFluoChannel(fluo_path) - if fluo_data is None: - return None, None - - # Load background data - bkgrData_path = os.path.join( - posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' - ) - if os.path.exists(bkgrData_path): - bkgrData = np.load(bkgrData_path) - elif isGuiThread: - txt = html_utils.paragraph( - f'File format {ext} is not supported!\n' - 'Choose either .tif or .npz files.' - ) - msg = widgets.myMessageBox() - msg.critical(self, 'File not supported', txt) - return None, None - - return fluo_data, bkgrData - - def setOverlayColors(self): - self.overlayRGBs = [ - (255, 255, 0), - (252, 72, 254), - (49, 222, 134), - (22, 108, 27) - ] - self.overlayCmap = matplotlib.colormaps['hsv'] - self.overlayRGBs.extend( - [tuple([round(c*255) for c in self.overlayCmap(i)][:3]) - for i in np.linspace(0,1,8)] - ) - - def getFileExtensions(self, images_path): - alignedFound = any([f.find('_aligned.np')!=-1 - for f in myutils.listdir(images_path)]) - if alignedFound: - extensions = ( - 'Aligned channels (*npz *npy);; Tif channels(*tiff *tif)' - ';;All Files (*)' - ) - else: - extensions = ( - 'Tif channels(*tiff *tif);; All Files (*)' - ) - return extensions - - def loadOverlayData(self, ol_channels, addToExisting=False): - posData = self.data[self.pos_i] - for ol_ch in ol_channels: - if ol_ch not in list(posData.loadedFluoChannels): - # Requested channel was never loaded --> load it at first - # iter i == 0 - success = self.loadFluo_cb(fluo_channels=[ol_ch]) - if not success: - return False - - lastChannelName = ol_channels[-1] - for action in self.fluoDataChNameActions: - if action.text() == lastChannelName: - action.setChecked(True) - - for p, posData in enumerate(self.data): - if addToExisting: - ol_data = posData.ol_data - else: - ol_data = {} - for i, ol_ch in enumerate(ol_channels): - _, filename = self.getPathFromChName(ol_ch, posData) - ol_data[filename] = ( - posData.ol_data_dict[filename].copy() - ) - self.addFluoChNameContextMenuAction(ol_ch) - posData.ol_data = ol_data - - return True - - def askSelectOverlayChannel(self): - ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] - selectFluo = widgets.QDialogListbox( - 'Select channel', - 'Select channel names to overlay:\n', - ch_names, multiSelection=True, parent=self - ) - selectFluo.exec_() - if selectFluo.cancel: - return - - return selectFluo.selectedItemsText - - def overlayLabels_cb(self, checked, selectedLabelsEndnames=None): - if checked: - if not self.drawModeOverlayLabelsChannels: - if selectedLabelsEndnames is None: - selectedLabelsEndnames = self.askLabelsToOverlay() - if selectedLabelsEndnames is None: - self.logger.info('Overlay labels cancelled.') - self.overlayLabelsButton.setChecked(False) - return - for selectedEndname in selectedLabelsEndnames: - self.loadOverlayLabelsData(selectedEndname) - for action in self.overlayLabelsContextMenu.actions(): - if not action.isCheckable(): - continue - if action.text() == selectedEndname: - action.setChecked(True) - lastSelectedName = selectedLabelsEndnames[-1] - for action in self.selectOverlayLabelsActionGroup.actions(): - if action.text() == lastSelectedName: - action.setChecked(True) - self.updateAllImages() - - def askLabelsToOverlay(self): - selectOverlayLabels = widgets.QDialogListbox( - 'Select segmentation to overlay', - 'Select segmentation file to overlay:\n', - natsorted(self.existingSegmEndNames), - multiSelection=True, - parent=self - ) - selectOverlayLabels.exec_() - if selectOverlayLabels.cancel: - return - - return selectOverlayLabels.selectedItemsText - - def closeToolbars(self): - for toolbar in self.sender().toolbars: - toolbar.setVisible(False) - for action in toolbar.actions(): - try: - action.button.setChecked(False) - except Exception as e: - pass - - def askSaveAddedPoints(self): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - 'Do you want to save the annotated points?' - ) - _, noButton, yesButton = msg.question( - self, 'Save?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') - ) - if msg.clickedButton != yesButton: - return - - for toolbar in self.pointsLayersToolbars: - for action in self.pointsLayersToolbar.actions(): - try: - if 'Save annotated' in action.text(): - action.trigger() - except Exception as err: - pass - - def pointsLayerToggled(self, checked): - if not checked: - for action in self.pointsLayersToolbar.actions(): - try: - if 'Save annotated' in action.text(): - self.askSaveAddedPoints() - break - except Exception as err: - pass - self.pointsLayersToolbar.setVisible(checked) - self.autoPilotZoomToObjToolbar.setVisible(checked) - if self.pointsLayersNeverToggled: - self.pointsLayersToolbar.sigAddPointsLayer.emit() - self.pointsLayersNeverToggled = False - QTimer.singleShot(200, self.autoRange) - - def addPointsLayerTriggered(self, checked=False, toolbar=None): - if toolbar is None: - toolbar = self.pointsLayersToolbar - - if self.addPointsWin is not None: - self.logger.info( - 'Add points layer window is already open. Cannot add now.' - ) - return - - onlyMouseClicks = toolbar == self.promptSegmentPointsLayerToolbar - posData = self.data[self.pos_i] - self.addPointsWin = apps.AddPointsLayerDialog( - channelNames=posData.chNames, - imagesPath=posData.images_path, - hideCentroidsSection=onlyMouseClicks, - hideWeightedCentroidsSection=onlyMouseClicks, - hideFromTableSection=onlyMouseClicks, - hideManualEntrySection=onlyMouseClicks, - hideWithMouseClicksSection=False, - parent=self, - ) - cmap = matplotlib.colormaps['gist_rainbow'] - i = np.random.default_rng(seed=123).uniform() - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - rgb = [round(c*255) for c in cmap(i)][:3] - self.addPointsWin.appearanceGroupbox.colorButton.setColor(rgb) - break - - self.addPointsWin.sigCriticalReadTable.connect(self.logger.info) - self.addPointsWin.sigLoadedTable.connect(self.logLoadedTablePointsLayer) - self.addPointsWin.sigClosed.connect( - partial(self.addPointsLayer, toolbar=toolbar) - ) - self.addPointsWin.sigCheckClickEntryTableEndnameExists.connect( - self.checkClickEntryTableEndnameExists - ) - self.addPointsWin.show() - if self.addPointsWin.clickEntryRadiobutton.isChecked(): - QTimer.singleShot( - 200, - partial( - self.addPointsWin.sigCheckClickEntryTableEndnameExists.emit, - self.addPointsWin.clickEntryTableEndname.text(), - False - ) - ) - - def logLoadedTablePointsLayer(self, df, filename: str): - separator = f'-'*100 - header = f'First 10 rows of loaded table - "{filename}":' - footer = f'Number of points: {len(df)}' - text = ( - f'{separator}\n' - f'{header}\n\n' - f'{df.head(10)}\n\n' - f'{footer}\n' - f'{separator}' - ) - if filename: - text = f'{text}\nFilename: {filename}' - self.logger.info(text) - - def buttonAddPointsByClickingActive(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - if action.layerTypeIdx == 4 and action.button.isChecked(): - return action.button - - def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): - self.LeftClickButtons.append(toolButton) - posData = self.data[self.pos_i] - tableEndName = self.addPointsWin.clickEntryTableEndnameText - if isLoadedDf is not None: - posData = self.data[self.pos_i] - tableEndName = tableEndName[len(posData.basename):] - self.loadClickEntryDfs(tableEndName) - - toolButton.toolbar = toolbar - toolButton.clickEntryTableEndName = tableEndName - self.checkableQButtonsGroup.addButton(toolButton) - toolButton.toggled.connect(self.addPointsByClickingButtonToggled) - - self.addPointsByClickingButtonToggled(sender=toolButton) - - toolButton.setToolTip(tableEndName) - - pointIdSpinbox = widgets.SpinBox() - pointIdSpinbox.setMinimum(0) - pointIdSpinbox.setValue(1) - pointIdSpinbox.label = QLabel(' Left-click ID: ') - pointIdSpinbox.labelAction = toolbar.addWidget(pointIdSpinbox.label) - if toolbar == self.promptSegmentPointsLayerToolbar: - newID = self.setBrushID(return_val=True) - pointIdSpinbox.setValue(newID) - pointIdSpinbox.setReadOnly(True) - pointIdSpinbox.setToolTip( - 'The ids added with left-click cannot be manually edited. ' - 'They are always a new, non-existing id.' - ) - - toolButton.actions.append(pointIdSpinbox.labelAction) - pointIdSpinbox.action = toolbar.addWidget(pointIdSpinbox) - toolButton.actions.append(pointIdSpinbox.action) - pointIdSpinbox.toolButton = toolButton - toolButton.pointIdSpinbox = pointIdSpinbox - - rightClickIDSpinbox = widgets.SpinBox() - pointIdSpinbox.setLinkedValueWidget(rightClickIDSpinbox) - rightClickIDSpinbox.setMaximumWidth(pointIdSpinbox.sizeHint().width()) - rightClickIDSpinbox.setValue(pointIdSpinbox.value()) - rightClickIDSpinbox.setMinimum(0) - rightClickIDSpinbox.label = QLabel(' | Right-click ID: ') - rightClickIDSpinbox.labelAction = toolbar.addWidget( - rightClickIDSpinbox.label - ) - toolButton.actions.append(rightClickIDSpinbox.labelAction) - rightClickIDSpinbox.action = toolbar.addWidget(rightClickIDSpinbox) - toolButton.actions.append(rightClickIDSpinbox.action) - rightClickIDSpinbox.toolButton = toolButton - toolButton.rightClickIDSpinbox = rightClickIDSpinbox - - saveToolbutton = widgets.SavePointsLayerButton( - tableEndName, parent=self - ) - saveToolbutton.sigRenameTableAction.connect( - self.updatePointsLayerClickEntryTableEndname - ) - saveToolbutton.sigLeftClick.connect(self.savePointsAddedByClicking) - saveAction = toolbar.addWidget(saveToolbutton) - saveToolbutton.action = saveAction - saveAction.saveToolbutton = saveToolbutton - saveAction.toolButton = toolButton - toolButton.saveAction = saveAction - toolButton.saveToolbutton = saveToolbutton - - toolButton.actions.append(saveAction) - - vlineAction = toolbar.addWidget(widgets.QVLine()) - spacerAction = toolbar.addWidget( - widgets.QHWidgetSpacer(width=5) - ) - - toolButton.actions.append(vlineAction) - toolButton.actions.append(spacerAction) - - action = toolButton.action - scatterItem = action.scatterItem - scatterItem.sigHoverEntered.connect( - self.addPointsByClickingScatterItemHoverEntered - ) - - self.pointsLayerClicksDfsToData(posData, toolbar=toolbar) - - def storeUndoAddPoint(self, action): - if not hasattr(self, 'undoAddPointQueueMapper'): - self.undoAddPointQueueMapper = defaultdict(list) - - posData = self.data[self.pos_i] - pointsDataPos = action.pointsData.get(self.pos_i) - if pointsDataPos is None: - return - - state = deepcopy(pointsDataPos) - self.undoAddPointQueueMapper[action].append(state) - self.undoAction.setEnabled(True) - - def undoAddPoint(self, action): - undoAddPointQueue = self.undoAddPointQueueMapper.get(action) - if undoAddPointQueue is None: - return False - - if len(undoAddPointQueue) == 0: - return False - - posData = self.data[self.pos_i] - state = undoAddPointQueue.pop(-1) - action.pointsData[self.pos_i] = state - self.markPointsLayerDirty(action=action) - - self.drawPointsLayers(computePointsLayers=False) - - if len(self.undoAddPointQueueMapper[action]) == 0: - self.undoAction.setEnabled(True) - - return True - - def getAddedPointId( - self, isMagicPrompts, addPointsByClickingButton, - right_click, left_click, middle_click - ): - action = addPointsByClickingButton.action - if right_click: - id = addPointsByClickingButton.rightClickIDSpinbox.value() - elif left_click: - id = addPointsByClickingButton.pointIdSpinbox.value() - id = self.getClickedPointNewId( - action, id, addPointsByClickingButton.pointIdSpinbox, - isMagicPrompts=isMagicPrompts - ) - if isMagicPrompts: - proceed = self.warnAddingPointWithExistingId(id) - if not proceed: - return - - addPointsByClickingButton.pointIdSpinbox.setValue(id) - elif middle_click: - id = 0 - - return id - - def addPointsByClickingScatterItemHoverEntered(self, item, points, event): - point = points[0] - point_id = point.data() - toolButton = item.action.button - toolButton.rightClickIDSpinbox.prevId = ( - toolButton.rightClickIDSpinbox.value() - ) - toolButton.rightClickIDSpinbox.setValue(point_id) - - def autoPilotZoomToObjToggled(self, checked): - if not checked: - self.zoomOut() - return - - posData = self.data[self.pos_i] - if not posData.IDs: - self.logger.info('There are no objects in current segmentation mask') - return - self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) - self.zoomToObj(posData.rp[0]) - - def savePointsAddedByClickingFromEndname(self, tableEndName, recovery=False): - self.pointsLayerDataToDf(self.data[self.pos_i]) - for posData in self.data: - if not posData.basename.endswith('_'): - basename = f'{posData.basename}_' - else: - basename = posData.basename - tableFilename = f'{basename}{tableEndName}.csv' - if recovery: - tableFilepath = os.path.join( - posData.recoveryFolderpath(), tableFilename - ) - else: - tableFilepath = os.path.join(posData.images_path, tableFilename) - df = posData.clickEntryPointsDfs.get(tableEndName) - if df is None: - continue - df = df.sort_values(['frame_i', 'Cell_ID']) - df.to_csv(tableFilepath, index=False) - - def markPointsLayerDirty(self, tableEndName=None, action=None): - if tableEndName is None and action is not None: - tableEndName = getattr(action, 'clickEntryTableEndName', None) - - if tableEndName is None: - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - if addPointsByClickingButton is None: - return - tableEndName = addPointsByClickingButton.clickEntryTableEndName - - self.dirtyPointsLayerTableEndNames.add(tableEndName) - - def flushDirtyPointsLayersAutosave(self): - if not self.dirtyPointsLayerTableEndNames: - return - - for tableEndName in tuple(self.dirtyPointsLayerTableEndNames): # avoid runtime error - self.savePointsAddedByClickingFromEndname( - tableEndName, recovery=True - ) - - self.dirtyPointsLayerTableEndNames.clear() - - @exception_handler - def savePointsAddedByClicking(self, button, event): - sender = button.action - toolButton = sender.toolButton - tableEndName = toolButton.clickEntryTableEndName - - self.logger.info(f'Saving _{tableEndName}.csv table...') - - self.savePointsAddedByClickingFromEndname(tableEndName) - - self.logger.info(f'{tableEndName}.csv saved!') - self.titleLabel.setText(f'{tableEndName}.csv saved!', color='g') - - def updatePointsLayerClickEntryTableEndname( - self, saveToolbutton, table_endname - ): - saveAction = saveToolbutton.action - toolButton = saveAction.toolButton - toolButton.clickEntryTableEndName = table_endname - - self.logger.info( - f'Done. Click entry table endname updated to "{table_endname}"' - ) - - def pointsLayerDfsToData(self, posData): - self.pointsLayerClicksDfsToData(posData) - - def pointsLayerLoadedDfsToData(self): - posData = self.data[self.pos_i] - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'loadedDfInfo'): - continue - - if action.loadedDfInfo is None: - continue - - endname = action.loadedDfInfo.get('endname') - if endname is None: - continue - - filename = f'{posData.basename}{endname}' - filepath = os.path.join(posData.images_path, filename) - if not os.path.exists(filepath): - action.pointsData[self.pos_i] = {} - - df = load.load_df_points_layer(filepath) - action.pointsData[self.pos_i] = ( - load.loaded_df_to_points_data( - df, action.loadedDfInfo['t'], action.loadedDfInfo['z'], - action.loadedDfInfo['y'], action.loadedDfInfo['x'] - ) - ) - self.logLoadedTablePointsLayer(df, filename=filename) - - def setPointsLayerLoadedDfEndanme(self, action): - if action.loadedDfInfo is None: - return - - posData = self.data[self.pos_i] - images_path = posData.images_path.replace('\\', '/') - - df_folderpath = os.path.dirname( - action.loadedDfInfo['filepath'].replace('\\', '/') - ) - - if images_path != df_folderpath: - return - - df_filename = os.path.basename(action.loadedDfInfo['filepath']) - - if not df_filename.startswith(posData.basename): - return - - endname = df_filename[len(posData.basename):] - action.loadedDfInfo['endname'] = endname - - action.button.setToolTip(endname) - - def pointsLayerClicksDfsToData(self, posData, toolbar=None): - if toolbar is None: - toolbar = self.pointsLayersToolbar - - for action in toolbar.actions()[1:]: - if not hasattr(action, 'button'): - continue - - if not hasattr(action.button, 'clickEntryTableEndName'): - continue - tableEndName = action.button.clickEntryTableEndName - action.pointsData[self.pos_i] = {} - if posData.clickEntryPointsDfs.get(tableEndName) is None: - continue - - df = posData.clickEntryPointsDfs[tableEndName] - - if posData.SizeZ > 1 and df['z'].isna().any(): - self.warnLoadedPointsTableIsNot3D(tableEndName) - return - - for frame_i, df_frame in df.groupby('frame_i'): - action.pointsData[self.pos_i][frame_i] = {} - if posData.SizeZ > 1: - for z, df_zlice in df_frame.groupby('z'): - xx = df_zlice['x'].to_list() - yy = df_zlice['y'].to_list() - ids = df_zlice['id'].to_list() - action.pointsData[self.pos_i][frame_i][z] = { - 'x': xx, 'y': yy, 'id': ids - } - else: - xx = df_frame['x'].to_list() - yy = df_frame['y'].to_list() - ids = df_frame['id'].to_list() - action.pointsData[self.pos_i][frame_i] = { - 'x': xx, 'y': yy, 'id': ids - } - - def pointsLayerDataToDf(self, posData, getOnlyActive=False, toolbar=None): - df = None - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'button'): - continue - if not hasattr(action.button, 'clickEntryTableEndName'): - continue - - tableEndName = action.button.clickEntryTableEndName - if getOnlyActive and not action.button.isChecked(): - continue - - df = toolbar.fromActionToDataFrame( - action, posData, isSegm3D=self.isSegm3D - ) - posData.clickEntryPointsDfs[tableEndName] = df - return df - - def restartZoomAutoPilot(self): - if not self.autoPilotZoomToObjToggle.isChecked(): - return - - posData = self.data[self.pos_i] - if not posData.IDs: - return - - self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) - self.zoomToObj(posData.rp[0]) - - def resizeRangeWelcomeText(self): - xRange, yRange = self.ax1.viewRange() - deltaX = xRange[1] - xRange[0] - deltaY = yRange[1] - yRange[0] - self.ax1.setXRange(0, deltaX) - self.ax1.setYRange(0, deltaY) - self.ax1.setLimits( - xMin=0, xMax=deltaX, yMin=0, yMax=deltaY - ) - # self.ax1.setXRange(0, 0) - # self.ax1.setYRange(0, 0) - - def zoomToObj(self, obj=None): - if not hasattr(self, 'data'): - return - posData = self.data[self.pos_i] - if obj is None: - ID = self.sender().value() - try: - ID_idx = posData.IDs_idxs[ID] - obj = obj = posData.rp[ID_idx] - except Exception as e: - self.logger.warning( - f'ID {ID} does not exist (add points by clicking)' - ) - - if obj is None: - return - - self.goToZsliceSearchedID(obj) - min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) - xRange = min_col-5, max_col+5 - yRange = max_row+5, min_row-5 - - self.ax1.setRange(xRange=xRange, yRange=yRange) - - def addPointsByClickingButtonToggled(self, checked=True, sender=None): - if sender is None: - sender = self.sender() - if not sender.isChecked(): - action = sender.action - action.scatterItem.setVisible(False) - return - - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(sender) - self.connectLeftClickButtons() - action = sender.action - action.scatterItem.setVisible(True) - self.ax1_BrushCircle.setBrush(action.brushColor) - self.ax1_BrushCircle.setPen(action.penColor) - - def autoZoomNextObj(self): - self.sender().setValue(self.sender().value() - 1) - self.pointsLayerAutoPilot('next') - self.setFocusMain() - self.setFocusGraphics() - - def autoZoomPrevObj(self): - self.sender().setValue(self.sender().value() + 1) - self.pointsLayerAutoPilot('prev') - self.setFocusMain() - self.setFocusGraphics() - - def pointsLayerAutoPilot(self, direction): - if not self.autoPilotZoomToObjToggle.isChecked(): - return - ID = self.autoPilotZoomToObjSpinBox.value() - posData = self.data[self.pos_i] - if not posData.IDs: - return - - try: - ID_idx = posData.IDs_idxs[ID] - if direction == 'next': - nextID_idx = ID_idx + 1 - else: - nextID_idx = ID_idx - 1 - obj = posData.rp[nextID_idx] - except Exception as e: - self.logger.info( - f'Auto-pilot restarted from first ID' - ) - obj = posData.rp[0] - - self.autoPilotZoomToObjSpinBox.setValue(obj.label) - self.zoomToObj(obj) - - def getClickEntryTableFilepaths(self, posData, tableEndName): - if posData.basename.endswith('_'): - basename = posData.basename - else: - basename = f'{posData.basename}_' - - csv_filename = f'{basename}{tableEndName}' - if not csv_filename.endswith('.csv'): - csv_filename = f'{csv_filename}.csv' - - filepath = os.path.join(posData.images_path, csv_filename) - recovery_filepath = os.path.join( - posData.images_path, 'recovery', csv_filename - ) - return filepath, recovery_filepath - - def getClickEntryNewerRecoveryFilepaths(self, tableEndName): - newer_recovery_filepaths = [] - for posData in self.data: - filepath, recovery_filepath = self.getClickEntryTableFilepaths( - posData, tableEndName - ) - if not os.path.exists(filepath) or not os.path.exists(recovery_filepath): - continue - - if os.path.getmtime(recovery_filepath) <= os.path.getmtime(filepath) + 15: # add a 15 second tolerance - continue - - newer_recovery_filepaths.append((filepath, recovery_filepath)) - - return newer_recovery_filepaths - - def askLoadNewerRecoveryClickEntryDfs( - self, tableEndName, newer_recovery_filepaths - ): - if not newer_recovery_filepaths: - return False - - num_tables = len(newer_recovery_filepaths) - filepath, recovery_filepath = newer_recovery_filepaths[0] - main_timestamp = datetime.fromtimestamp( - os.path.getmtime(filepath) - ).strftime('%a %d. %b. %y - %H:%M:%S') - recovery_timestamp = datetime.fromtimestamp( - os.path.getmtime(recovery_filepath) - ).strftime('%a %d. %b. %y - %H:%M:%S') - - if num_tables == 1: - text = html_utils.paragraph( - f'A newer recovery version of {tableEndName}.csv ' - 'was found.

' - f'Main table save date: {main_timestamp}
' - f'Recovery save date: {recovery_timestamp}

' - 'Do you want to load the newer recovery version?' - ) - else: - text = html_utils.paragraph( - f'Newer recovery versions of {tableEndName}.csv ' - f'were found for {num_tables} positions.

' - f'Example main table save date: {main_timestamp}
' - f'Example recovery save date: {recovery_timestamp}

' - 'Do you want to load the newer recovery version where available?' - ) - - msg = widgets.myMessageBox(wrapText=False) - _, yesButton, _ = msg.warning( - self.addPointsWin, 'Newer recovery table found', text, - buttonsTexts=('Cancel', 'Yes, load newer recovery', 'No, load main table') - ) - return msg.clickedButton == yesButton - - def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading): - doesTableExists = False - for posData in self.data: - filepath, _ = self.getClickEntryTableFilepaths(posData, tableEndName) - if os.path.exists(filepath): - doesTableExists = True - break - - if not doesTableExists: - return - - if not forceLoading: - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - f'The table {tableEndName}.csv already exists!

' - 'Do you want to load it?' - ) - _, yesButton, _ = msg.warning( - self.addPointsWin, 'Table exists!', txt, - buttonsTexts=('Cancel', 'Yes, load it', 'No, let me enter a new name') - ) - if msg.clickedButton != yesButton: - return - - newer_recovery_filepaths = self.getClickEntryNewerRecoveryFilepaths( - tableEndName - ) - load_recovery_if_newer = self.askLoadNewerRecoveryClickEntryDfs( - tableEndName, newer_recovery_filepaths - ) - - self.loadClickEntryDfs( - tableEndName, loadRecoveryIfNewer=load_recovery_if_newer - ) - - def checkLoadedTableIds(self, toolbar): - if toolbar != self.promptSegmentPointsLayerToolbar: - return True - - for posData in self.data: - for tableEndName, df in posData.clickEntryPointsDfs.items(): - for point_id in df['id'].values: - if point_id in posData.IDs_idxs: - proceed = self.warnAddingPointWithExistingId( - point_id, table_endname=tableEndName - ) - return proceed - - return True - - @exception_handler - def addPointsLayer(self, toolbar=None): - proceed = self.checkLoadedTableIds(toolbar) - - if self.addPointsWin.cancel or not proceed: - self.addPointsWin = None - self.logger.info('Adding points layer cancelled.') - return - - if toolbar is None: - toolbar = self.pointsLayersToolbar - - symbol = self.addPointsWin.symbol - color = self.addPointsWin.color - pointSize = self.addPointsWin.pointSize - zRadius = int((self.addPointsWin.zHeight-1)/2) - r,g,b,a = color.getRgb() - - scatterItem = widgets.PointsScatterPlotItem( - [], [], ax=self.ax1, symbol=symbol, pxMode=False, size=pointSize, - brush=pg.mkBrush(color=(r,g,b,100)), - pen=pg.mkPen(width=2, color=(r,g,b)), - hoverable=True, hoverBrush=pg.mkBrush((r,g,b,200)), - tip=None, show_data_as_tip=True - ) - self.ax1.addItem(scatterItem) - - toolButton = widgets.PointsLayerToolButton(symbol, color, parent=self) - toolButton.actions = [] - toolButton.setCheckable(True) - toolButton.setChecked(True) - if self.addPointsWin.keySequence is not None: - toolButton.setShortcut(self.addPointsWin.keySequence) - toolButton.toggled.connect(self.pointLayerToolbuttonToggled) - toolButton.sigEditAppearance.connect(self.editPointsLayerAppearance) - toolButton.sigShowIdsToggled.connect(self.showPointsLayerIdsToggled) - toolButton.sigRemove.connect( - partial(self.removePointsLayer, toolbar=toolbar) - ) - - action = toolbar.addWidget(toolButton) - action.state = self.addPointsWin.state() - - toolButton.action = action - action.brushColor = (r,g,b,100) - action.brushColorId0 = ( - *colors.hex_to_rgb( - colors.lighten_color( - np.array(action.brushColor)/255, 0.3 - ) - ), 100 - ) - action.penColor = (r,g,b) - action.penColorId0 = colors.lighten_color( - np.array(action.penColor)/255, 0.3 - ) - action.pointSize = pointSize - action.zRadius = zRadius - action.button = toolButton - action.scatterItem = scatterItem - scatterItem.action = action - action.layerType = self.addPointsWin.layerType - action.layerTypeIdx = self.addPointsWin.layerTypeIdx - action.loadedDf = self.addPointsWin.loadedDf - posData = self.data[self.pos_i] - action.pointsData = {} - action.pointsData[self.pos_i] = self.addPointsWin.pointsData - action.snapToMax = False - action.loadedDfInfo = self.addPointsWin.loadedDfInfo - self.setPointsLayerLoadedDfEndanme(action) - - if self.addPointsWin.layerType.startswith('Click to annotate point'): - action.snapToMax = self.addPointsWin.snapToMaxToggle.isChecked() - isLoadedDf = self.addPointsWin.clickEntryIsLoadedDf - self.setupAddPointsByClicking( - toolButton, isLoadedDf, toolbar=toolbar - ) - if self.addPointsWin.autoPilotToggle.isChecked(): - self.autoPilotZoomToObjToggle.setChecked(True) - - weighingChannel = self.addPointsWin.weighingChannel - self.loadPointsLayerWeighingData(action, weighingChannel) - - self.drawPointsLayers() - - if toolbar == self.promptSegmentPointsLayerToolbar: - self.promptSegmentPointsLayerToolbar.isPointsLayerInit = True - self.magicPromptsToolbar.clearPointsAction.setDisabled(False) - self.magicPromptsToolbar.clearPointsActionOnZoom.setDisabled(False) - QTimer.singleShot( - 200, self.magicPromptsToolbar.selectModelAction.trigger - ) - - self.addPointsWin = None - - def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): - for posData in self.data: - filepath, recovery_filepath = self.getClickEntryTableFilepaths( - posData, tableEndName - ) - - if loadRecoveryIfNewer: - recovery_exists = os.path.exists(recovery_filepath) - main_exists = os.path.exists(filepath) - if ( - recovery_exists - and ( - not main_exists - or os.path.getmtime(recovery_filepath) - > os.path.getmtime(filepath) + 15 - ) - ): - filepath = recovery_filepath - elif not main_exists: - continue - - if not os.path.exists(filepath): - continue - - self.logger.info(f'Loading points from "{filepath}"...') - df = pd.read_csv(filepath) - if 'id' not in df.columns: - df['id'] = range(1, len(df)+1) - posData.clickEntryPointsDfs[tableEndName] = df - - try: - self.addPointsWin.loadButton.confirmAction() - except Exception as err: - pass - - def removeClickedPoints(self, action, points): - posData = self.data[self.pos_i] - framePointsData = action.pointsData[self.pos_i][posData.frame_i] - if posData.SizeZ > 1: - zProjHow = self.zProjComboBox.currentText() - if zProjHow != 'single z-slice': - _warnings.warnCannotAddRemovePointsProjection() - return - zSlice = self.zSliceScrollBar.sliderPosition() - else: - zSlice = None - - removed_ids = [] - for point in points: - pos = point.pos() - x, y = pos.x(), pos.y() - if zSlice is not None: - zSliceRad = action.zRadius - sliceFramePointsData = [framePointsData[z] for z in range( - zSlice-zSliceRad, zSlice+zSliceRad+1 - ) if z in framePointsData.keys()] - else: - sliceFramePointsData = [framePointsData] - - - for sliceFramePointsData in sliceFramePointsData: - if point.data() in sliceFramePointsData['id']: - sliceFramePointsData['x'].remove(x) - sliceFramePointsData['y'].remove(y) - sliceFramePointsData['id'].remove(point.data()) - removed_ids.append(point.data()) - - if removed_ids: - self.markPointsLayerDirty(action=action) - - return removed_ids - - def restorePrevPointIdRightClick(self, addPointsByClickingButton): - # Try to restore the id that was there before hovering - # because the hovering was required only to delete the - # point - try: - prevId = addPointsByClickingButton.rightClickIDSpinbox.prevId - addPointsByClickingButton.rightClickIDSpinbox.setValue(prevId) - except Exception as err: - addPointsByClickingButton.rightClickIDSpinbox.prevId = None - - def getClickedPointNewId( - self, action, current_id, pointIdSpinbox, isMagicPrompts=False - ): - removed_id = getattr(pointIdSpinbox, 'removedId', None) - if removed_id is not None: - pointIdSpinbox.removedId = None - return removed_id - - posData = self.data[self.pos_i] - if isMagicPrompts: - is_already_new = self.isPointIdAlreadyNew(current_id, action) - if is_already_new: - return current_id - - new_ID = self.setBrushID(return_val=True) - new_id = max(current_id, new_ID) + 1 - return new_id - else: - pointsDataPos = action.pointsData.get(self.pos_i) - if pointsDataPos is None: - return 1 - - framePointsData = pointsDataPos.get(posData.frame_i) - if framePointsData is None: - return 1 - if posData.SizeZ > 1: - new_id = 1 - for z_data in framePointsData.values(): - max_id = max(z_data.get('id', 0), default=0) + 1 - if max_id > new_id: - new_id = max_id - else: - new_id = max(framePointsData.get('id', 0), default=0) + 1 - if current_id >= new_id: - return current_id - return new_id - - def setHoverCircleAddPoint(self, x, y): - addPointsByClickingButton = self.buttonAddPointsByClickingActive() - if addPointsByClickingButton is None: - return - action = addPointsByClickingButton.action - self.setHoverToolSymbolData( - [x], [y], (self.ax1_BrushCircle,), - size=action.pointSize - ) - - def isPointIdAlreadyNew(self, point_id, action): - posData = self.data[self.pos_i] - if point_id in posData.IDs_idxs: - return False - - is_ID = point_id in posData.IDs_idxs - pointsDataPos = action.pointsData.get(self.pos_i) - if pointsDataPos is None: - return not is_ID - - framePointsData = pointsDataPos.get(posData.frame_i) - if framePointsData is None: - return not is_ID - - if 'x' not in framePointsData: - is_id_already_added = False - for z, z_data in framePointsData.items(): - if point_id in z_data['id']: - is_id_already_added = True - break - else: - is_id_already_added = point_id in framePointsData['id'] - - is_already_new = not is_ID and not is_id_already_added - return is_already_new - - def addClickedPoint(self, action, x, y, id): - x, y = round(x, 2), round(y, 2) - posData = self.data[self.pos_i] - pointsDataPos = action.pointsData.get(self.pos_i) - if pointsDataPos is None: - action.pointsData[self.pos_i] = {} - - framePointsData = action.pointsData[self.pos_i].get(posData.frame_i) - if action.snapToMax: - radius = round(action.pointSize/2) - rr, cc = skimage.draw.disk((round(y), round(x)), radius) - idx_max = (self.img1.image[rr, cc]).argmax() - y, x = rr[idx_max], cc[idx_max] - - if framePointsData is None: - if posData.SizeZ > 1: - zSlice = self.zSliceScrollBar.sliderPosition() - action.pointsData[self.pos_i][posData.frame_i] = { - zSlice: {'x': [x], 'y': [y], 'id': [id]} - } - else: - action.pointsData[self.pos_i][posData.frame_i] = { - 'x': [x], 'y': [y], 'id': [id] - } - else: - if posData.SizeZ > 1: - zSlice = self.zSliceScrollBar.sliderPosition() - z_data = framePointsData.get(zSlice) - if z_data is None: - framePointsData[zSlice] = {'x': [x], 'y': [y], 'id': [id]} - else: - framePointsData[zSlice]['x'].append(x) - framePointsData[zSlice]['y'].append(y) - framePointsData[zSlice]['id'].append(id) - action.pointsData[self.pos_i][posData.frame_i] = ( - framePointsData - ) - else: - pointsDataPos = action.pointsData[self.pos_i] - framePointsData = pointsDataPos[posData.frame_i] - framePointsData['x'].append(x) - framePointsData['y'].append(y) - framePointsData['id'].append(id) - - self.markPointsLayerDirty(action=action) - - def showPointsLayerIdsToggled(self, button, checked): - button.action.scatterItem.drawIds = checked - self.drawPointsLayers() - - def removePointsLayer(self, button, toolbar=None): - button.setChecked(False) - button.action.scatterItem.setData([], []) - button.action.loadedDfInfo = None - self.ax1.removeItem(button.action.scatterItem) - toolbar.removeAction(button.action) - for action in button.actions: - toolbar.removeAction(action) - - if toolbar == self.promptSegmentPointsLayerToolbar: - self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False - - def editPointsLayerAppearance(self, button): - win = apps.EditPointsLayerAppearanceDialog(parent=self) - win.restoreState(button.action.state) - win.exec_() - if win.cancel: - return - - symbol = win.symbol - color = win.color - pointSize = win.pointSize - zRadius = int((win.zHeight-1)/2) - r,g,b,a = color.getRgb() - - scatterItem = button.action.scatterItem - scatterItem.opts['hoverBrush'] = pg.mkBrush((r,g,b,200)) - scatterItem.setSymbol(symbol, update=False) - scatterItem.setBrush(pg.mkBrush(color=(r,g,b,100)), update=False) - scatterItem.setPen(pg.mkPen(width=2, color=(r,g,b)), update=False) - scatterItem.setSize(pointSize, update=True) - - button.action.brushColor = (r,g,b,100) - button.action.penColor = (r,g,b) - button.action.pointSize = pointSize - button.action.zRadius = zRadius - - button.action.state = win.state() - - def loadPointsLayerWeighingData(self, action, weighingChannel): - if not weighingChannel: - return - - self.logger.info(f'Loading "{weighingChannel}" weighing data...') - action.weighingData = [] - for p, posData in enumerate(self.data): - if weighingChannel == posData.user_ch_name: - wData = posData.img_data - action.weighingData.append(wData) - continue - - path, filename = self.getPathFromChName(weighingChannel, posData) - if path is None: - self.criticalFluoChannelNotFound(weighingChannel, posData) - action.weighingData = [] - return - - if filename in posData.fluo_data_dict: - # Weighing data already loaded as additional fluo channel - wData = posData.fluo_data_dict[filename] - else: - # Weighing data never loaded --> load now - wData, _ = self.load_fluo_data(path) - if posData.SizeT == 1: - wData = wData[np.newaxis] - action.weighingData.append(wData) - - def pointLayerToolbuttonToggled(self, checked): - action = self.sender().action - action.scatterItem.setVisible(checked) - - def getCentroidsPointsData(self, action): - # Centroids (either weighted or not) - # NOTE: if user requested to draw from table we load that in - # apps.AddPointsLayerDialog.ok_cb() - posData = self.data[self.pos_i] - action.pointsData[self.pos_i] = {posData.frame_i: {}} - if hasattr(action, 'weighingData'): - lab = posData.lab - img = action.weighingData[self.pos_i][posData.frame_i] - rp = skimage.measure.regionprops(lab, intensity_image=img) - attr = 'weighted_centroid' - else: - rp = posData.rp - attr = 'centroid' - for i, obj in enumerate(rp): - centroid = getattr(obj, attr) - if len(centroid) == 3: - zc, yc, xc = centroid - z_int = round(zc) - if z_int not in action.pointsData[self.pos_i][posData.frame_i]: - action.pointsData[self.pos_i][posData.frame_i][z_int] = { - 'x': [xc], 'y': [yc], 'id': [obj.label] - } - else: - z_data = action.pointsData[self.pos_i][posData.frame_i][z_int] - z_data['x'].append(xc) - z_data['y'].append(yc) - z_data['id'].append(obj.label) - else: - yc, xc = centroid - if 'y' not in action.pointsData[self.pos_i][posData.frame_i]: - action.pointsData[self.pos_i][posData.frame_i]['y'] = [yc] - action.pointsData[self.pos_i][posData.frame_i]['x'] = [xc] - action.pointsData[self.pos_i][posData.frame_i]['id'] = ( - [obj.label] - ) - else: - action.pointsData[self.pos_i][posData.frame_i]['y'].append(yc) - action.pointsData[self.pos_i][posData.frame_i]['x'].append(xc) - action.pointsData[self.pos_i][posData.frame_i]['id'].append( - obj.label - ) - - def drawPointsLayers(self, computePointsLayers=True): - posData = self.data[self.pos_i] - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - - if action.layerTypeIdx < 2 and computePointsLayers: - self.getCentroidsPointsData(action) - - if not action.button.isChecked(): - continue - - frames = action.pointsData.get(self.pos_i, set()) - if posData.frame_i not in frames: - if action.layerTypeIdx != 4: - self.logger.info( - f'Frame number {posData.frame_i+1} does not have any ' - f'"{action.layerType}" point to display.' - ) - continue - - framePointsData = action.pointsData[self.pos_i][posData.frame_i] - - if 'x' not in framePointsData: - # 3D points - zProjHow = self.zProjComboBox.currentText() - isZslice = ( - zProjHow == 'single z-slice' and posData.SizeZ > 1 - ) - if isZslice: - xx, yy, ids, data = [], [], [], [] - zSlice = self.zSliceScrollBar.sliderPosition() - zRadius = action.zRadius - zRange = range(zSlice-zRadius, zSlice+zRadius+1) - for z in zRange: - z_data = framePointsData.get(z) - if z_data is None: - continue - xx.extend(z_data['x']) - yy.extend(z_data['y']) - ids.extend(z_data['id']) - try: - data.extend(z_data['data']) - except KeyError as err: - # data is needed only for loaded tables - pass - else: - xx, yy, ids, data = [], [], [], [] - # z-projection --> draw all points - for z, z_data in framePointsData.items(): - xx.extend(z_data['x']) - yy.extend(z_data['y']) - ids.extend(z_data['id']) - try: - data.extend(z_data['data']) - except KeyError as err: - # data is needed only for loaded tables - pass - else: - # 2D segmentation - xx = framePointsData['x'] - yy = framePointsData['y'] - ids = framePointsData['id'] - try: - data = framePointsData['data'] - except KeyError as err: - # data is needed only for loaded tables - pass - - brushColors = [ - action.brushColor if id != 0 else action.brushColorId0 - for id in ids - ] - brushes = [pg.mkBrush(color) for color in brushColors] - - pensColor = [ - action.penColor if id != 0 else action.penColorId0 - for id in ids - ] - pens = [pg.mkPen(color) for color in pensColor] - - if action.layerTypeIdx == 2: - # For loaded table show the rest of the table as a tooltip - data = data - show_data_as_tip = True - else: - data = ids - show_data_as_tip = False - - xx = np.array(xx) # + 0.5 - yy = np.array(yy) # + 0.5 - - action.scatterItem.show_data_as_tip = show_data_as_tip - action.scatterItem.setData( - xx, yy, data=data, brush=brushes, pen=pens - ) - - def setOverlaySingleChannel(self, *args, **kwargs): - if self.overlayToolbar.isSingleChannel(): - self.overlayToolbarAreChannelsChecked = { - channel:toolbutton.isChecked() - for channel, toolbutton in self.allOverlayToolbuttons.items() - } - firstActiveToolbutton = [ - toolbutton for toolbutton in self.allOverlayToolbuttons.values() - if toolbutton.isChecked() - ][0] - firstActiveToolbutton.click() - else: - for ch, checked in self.overlayToolbarAreChannelsChecked.items(): - toolbutton = self.allOverlayToolbuttons[ch] - toolbutton.setChecked(checked) - - self.setOverlayItemsOpacities() - - def updateTransparentOverlayRgba(self, *args, **kwargs): - self.setOverlayImages() - - def setOverlayTransparency(self, transparent: bool): - opacity = float(transparent) - opacity = opacity if opacity < 1.0 else 0.999 - self.rgbaImg1.setOpacity(opacity) - - if transparent: - self.img1.setOpacity(0.001, applyToLinked=False) - self.imgGrad.sigLookupTableChanged.connect( - self.updateTransparentOverlayRgba - ) - self.imgGrad.sigLevelsChanged.connect( - self.updateTransparentOverlayRgba - ) - - for channel, items in self.overlayLayersItems.items(): - imageItem, lutItem, alphaSB = items[:3] - if transparent: - alphaSB.valueChanged.disconnect() - alphaSB.valueChanged.connect( - self.updateTransparentOverlayRgba - ) - lutItem.sigLookupTableChanged.connect( - self.updateTransparentOverlayRgba - ) - lutItem.sigLevelsChanged.connect( - self.updateTransparentOverlayRgba - ) - imageItem.setOpacity(0) - - if not transparent: - self.setOverlayItemsOpacities() - - self.setOverlayImages() - - def overlay_cb(self, checked): - self.overlayToolbar.setVisible(checked) - - self.UserNormAction, _, _ = self.getCheckNormAction() - posData = self.data[self.pos_i] - if checked: - if posData.ol_data is None: - selectedChannels = self.askSelectOverlayChannel() - if selectedChannels is None: - self.overlayButton.toggled.disconnect() - self.overlayButton.setChecked(False) - self.overlayButton.toggled.connect(self.overlay_cb) - return - - success = self.loadOverlayData(selectedChannels) - if not success: - return False - lastChannel = selectedChannels[-1] - self.setCheckedOverlayContextMenusActions(selectedChannels) - imageItem = self.overlayLayersItems[lastChannel][0] - self.setOpacityOverlayLayersItems(None, imageItem=imageItem) - self.setOverlayChannelsToolbuttonsChecked() - - self.setRetainSizePolicyLutItems() - self.normalizeRescale0to1Action.setChecked(True) - - self.updateAllImages() - self.updateImageValueFormatter() - self.enableOverlayWidgets(True) - else: - self.img1.setOpacity(1.0) - self.updateAllImages() - self.updateImageValueFormatter() - self.enableOverlayWidgets(False) - self.clearOverlayImageItems() - - - self.setOverlayItemsVisible() - - def countObjectsCb(self, checked): - if self.countObjsWindow is None: - categoryCountMapper = self.countObjects() - self.countObjsWindow = apps.ObjectCountDialog( - categoryCountMapper=categoryCountMapper, - parent=self, - data=self.data - ) - self.countObjsWindow.sigShowEvent.connect(self.updateObjectCounts) - self.countObjsWindow.sigUpdateCounts.connect(self.updateObjectCounts) - - if checked: - self.countObjsWindow.show() - else: - self.countObjsWindow.hide() - - def showLabelRoiContextMenu(self, event): - menu = QMenu(self.labelRoiButton) - action = QAction('Re-initialize magic labeller model...') - action.triggered.connect(self.initLabelRoiModel) - menu.addAction(action) - menu.exec_(QCursor.pos()) - - def initLabelRoiModel(self): - self.app.restoreOverrideCursor() - # Ask which model - self.initLabelRoiModelDialog = apps.QDialogSelectModel(parent=self) - self.initLabelRoiModelDialog.exec_() - if self.initLabelRoiModelDialog.cancel: - self.logger.info('Magic labeller aborted.') - self.initLabelRoiModelDialog = None - return True - self.app.setOverrideCursor(Qt.WaitCursor) - model_name = self.initLabelRoiModelDialog.selectedModel - self.labelRoiModel = self.repeatSegm( - model_name=model_name, askSegmParams=True, - is_label_roi=True - ) - if self.labelRoiModel is None: - self.initLabelRoiModelDialog = None - return True - self.labelRoiViewCurrentModelAction.setDisabled(False) - self.initLabelRoiModelDialog = None - return False - - def showOverlayContextMenu(self, event): - if not self.overlayButton.isChecked(): - return - - self.overlayContextMenu.exec_(QCursor.pos()) - - def showOverlayLabelsContextMenu(self, event): - if not self.overlayLabelsButton.isChecked(): - return - - self.overlayLabelsContextMenu.exec_(QCursor.pos()) - - def showInstructionsCustomModel(self): - modelFilePath = apps.addCustomModelMessages(self) - if modelFilePath is None: - self.logger.info('Adding custom model process stopped.') - return - - myutils.store_custom_model_path(modelFilePath) - modelName = os.path.basename(os.path.dirname(modelFilePath)) - customModelAction = QAction(modelName) - self.segmSingleFrameMenu.addAction(customModelAction) - self.segmActions.append(customModelAction) - self.segmActionsVideo.append(customModelAction) - self.modelNames.append(modelName) - self.models.append(None) - self.sender().callback(customModelAction) - - def showInstructionsCustomPromptModel(self): - modelFilePath = apps.addCustomPromptModelMessages(QParent=self) - if modelFilePath is None: - self.logger.info('Adding custom promptable model process stopped.') - return - - myutils.store_custom_promptable_model_path(modelFilePath) - - msg = widgets.myMessageBox(wrapText=False) - info_txt = html_utils.paragraph(f""" - Done!

- The custom promptable model has been added to the list of models.

- Use the Magic prompts button (top toolbar) to use it.

- Have fun! - """) - msg.information(self, 'Custom promptable model added', info_txt) - - def segmWithPromptableModelActionTriggered(self): - self.blinker = qutils.QControlBlink( - self.magicPromptsToolButton, qparent=self - ) - self.blinker.start() - - def setCheckedOverlayContextMenusActions(self, channelNames): - for action in self.overlayContextMenu.actions(): - if action.text() in channelNames: - action.setChecked(True) - self.checkedOverlayChannels.add(action.text()) - - def enableOverlayWidgets(self, enabled): - posData = self.data[self.pos_i] - if enabled: - self.overlayColorButton.setDisabled(False) - self.editOverlayColorAction.setDisabled(False) - - if posData.SizeZ == 1: - return - - self.zSliceOverlay_SB.setMaximum(posData.SizeZ-1) - if self.zProjOverlay_CB.currentText().find('max') != -1: - self.overlay_z_label.setDisabled(True) - self.zSliceOverlay_SB.setDisabled(True) - else: - z = self.zSliceOverlay_SB.sliderPosition() - self.overlay_z_label.setText(f'Overlay z-slice {z+1:02}/{posData.SizeZ}') - self.zSliceOverlay_SB.setDisabled(False) - self.overlay_z_label.setDisabled(False) - self.zSliceOverlay_SB.show() - self.overlay_z_label.show() - self.zProjOverlay_CB.show() - self.zSliceOverlay_SB.valueChanged.connect(self.updateOverlayZslice) - self.zProjOverlay_CB.currentTextChanged.connect(self.updateOverlayZproj) - self.zProjOverlay_CB.activated.connect(self.clearComboBoxFocus) - else: - self.zSliceOverlay_SB.setDisabled(True) - self.zSliceOverlay_SB.hide() - self.overlay_z_label.hide() - self.zProjOverlay_CB.hide() - self.overlayColorButton.setDisabled(True) - self.editOverlayColorAction.setDisabled(True) - - if posData.SizeZ == 1: - return - - self.zSliceOverlay_SB.valueChanged.disconnect() - self.zProjOverlay_CB.currentTextChanged.disconnect() - self.zProjOverlay_CB.activated.disconnect() - - - def criticalFluoChannelNotFound(self, fluo_ch, posData): - msg = widgets.myMessageBox(showCentered=False) - ls = "\n".join(myutils.listdir(posData.images_path)) - msg.setDetailedText( - f'Files present in the {posData.relPath} folder:\n' - f'{ls}' - ) - title = 'Requested channel data not found!' - txt = html_utils.paragraph( - f'The folder {posData.pos_path} ' - 'does not contain ' - 'either one of the following files:

' - f'{posData.basename}{fluo_ch}.tif
' - f'{posData.basename}{fluo_ch}_aligned.npz

' - 'Data loading aborted.' - ) - msg.addShowInFileManagerButton(posData.images_path) - okButton = msg.warning( - self, title, txt, buttonsTexts=('Ok') - ) - - def imgGradLUTfinished_cb(self): - posData = self.data[self.pos_i] - ticks = self.imgGrad.gradient.listTicks() - - self.img1ChannelGradients[self.user_ch_name] = { - 'ticks': [(x, t.color.getRgb()) for t,x in ticks], - 'mode': 'rgb' - } - - self.df_settings = self.imgGrad.saveState(self.df_settings) - self.df_settings.to_csv(self.settings_csv_path) - - def updateContColour(self, colorButton): - color = colorButton.color().getRgb() - self.df_settings.at['contLineColor', 'value'] = str(color) - self._updateContColour(color) - self.updateAllImages() - - def _updateContColour(self, color): - self.gui_createContourPens() - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.contoursColorButton.setColor(color) - - def saveContColour(self, colorButton): - self.df_settings.to_csv(self.settings_csv_path) - - def updateMothBudLineColour(self, colorButton): - color = colorButton.color().getRgb() - self.df_settings.at['mothBudLineColor', 'value'] = str(color) - self._updateMothBudLineColour(color) - self.updateAllImages() - - def _updateMothBudLineColour(self, color): - self.gui_createMothBudLinePens() - self.ax1_newMothBudLinesItem.setBrush(self.newMothBudLineBrush) - self.ax1_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush) - self.ax2_newMothBudLinesItem.setBrush(self.newMothBudLineBrush) - self.ax2_oldMothBudLinesItem.setBrush(self.oldMothBudLineBrush) - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.mothBudLineColorButton.setColor(color) - - def saveMothBudLineColour(self, colorButton): - self.df_settings.to_csv(self.settings_csv_path) - - def contLineWeightToggled(self, checked=True): - if not checked: - return - self.imgGrad.uncheckContLineWeightActions() - w = self.sender().lineWeight - self.df_settings.at['contLineWeight', 'value'] = w - self.df_settings.to_csv(self.settings_csv_path) - self._updateContLineThickness() - self.updateAllImages() - - def _updateContLineThickness(self): - self.gui_createContourPens() - for act in self.imgGrad.contLineWightActionGroup.actions(): - if act == self.sender(): - act.setChecked(True) - act.toggled.connect(self.contLineWeightToggled) - - def mothBudLineWeightToggled(self, checked=True): - if not checked: - return - self.imgGrad.uncheckContLineWeightActions() - w = self.sender().lineWeight - self.df_settings.at['mothBudLineSize', 'value'] = w - self.df_settings.to_csv(self.settings_csv_path) - self._updateMothBudLineSize(w) - self.updateAllImages() - - def _updateMothBudLineSize(self, size): - self.gui_createMothBudLinePens() - - for act in self.imgGrad.mothBudLineWightActionGroup.actions(): - if act == self.sender(): - act.setChecked(True) - act.toggled.connect(self.mothBudLineWeightToggled) - - self.ax1_oldMothBudLinesItem.setSize(size) - self.ax1_newMothBudLinesItem.setSize(size) - self.ax2_oldMothBudLinesItem.setSize(size) - self.ax2_newMothBudLinesItem.setSize(size) - - def getOlImg(self, key, frame_i=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - img = posData.ol_data[key][frame_i] - if posData.SizeZ > 1: - zProjHow = self.zProjOverlay_CB.currentText() - z = self.zSliceOverlay_SB.sliderPosition() - if zProjHow == 'same as above': - zProjHow = self.zProjComboBox.currentText() - z = self.zSliceScrollBar.sliderPosition() - reconnect = False - try: - self.zSliceOverlay_SB.valueChanged.disconnect() - reconnect = True - except TypeError: - pass - self.zSliceOverlay_SB.setSliderPosition(z) - if reconnect: - self.zSliceOverlay_SB.valueChanged.connect( - self.updateOverlayZslice - ) - if zProjHow == 'single z-slice': - self.overlay_z_label.setText(f'Overlay z-slice {z+1:02}/{posData.SizeZ}') - ol_img = img[z].copy() - elif zProjHow == 'max z-projection': - ol_img = img.max(axis=0) - elif zProjHow == 'mean z-projection': - ol_img = img.mean(axis=0) - elif zProjHow == 'median z-proj.': - ol_img = np.median(img, axis=0) - else: - ol_img = img.copy() - - return ol_img - - def setTextAnnotZsliceScrolling(self): - pass - - def setGraphicalAnnotZsliceScrolling(self): - posData = self.data[self.pos_i] - if self.isSegm3D: - self.currentLab2D = posData.lab[self.z_lab()] - self.setOverlaySegmMasks() - self.doCustomAnnotation(0) - self.update_rp_metadata() - else: - self.currentLab2D = posData.lab - self.setOverlaySegmMasks() - self.updateContoursImage(0) - self.updateContoursImage(1) - - def initContoursImage(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.contoursImage = np.zeros((Y, X, 4), dtype=np.uint8) - - def initDelRoiLab(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.delRoiLab = np.zeros((Y, X), dtype=np.uint32) - - def initLostObjContoursImage(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.lostObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) - - def initExportMaskImage(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.exportMaskImage = np.zeros((Y, X, 4), dtype=np.uint8) - - def initLostTrackedObjContoursImage(self): - posData = self.data[self.pos_i] - z_slice = self.z_lab() - img = posData.img_data[posData.frame_i] - Y, X = img[z_slice].shape[-2:] - - self.lostTrackedObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) - - def initManualBackgroundImage(self): - posData = self.data[self.pos_i] - if hasattr(posData, 'lab'): - Y, X = posData.lab.shape[-2:] - else: - Y, X = posData.img_data.shape[-2:] - if not hasattr(self, 'manualBackgroundTextItems'): - self.manualBackgroundTextItems = {} - posData.manualBackgroundImage = np.zeros((Y, X, 4), dtype=np.uint8) - if posData.manualBackgroundLab is None: - posData.manualBackgroundLab = np.zeros((Y, X), dtype=np.uint32) - - def initTextAnnot(self, force=False): - posData = self.data[self.pos_i] - if hasattr(posData, 'lab'): - Y, X = posData.lab.shape[-2:] - else: - Y, X = posData.img_data.shape[-2:] - self.textAnnot[0].initItem((Y, X)) - self.textAnnot[1].initItem((Y, X)) - - def getObjContours( - self, obj, all_external=False, local=False, force_calc=True, - include_internal=False - ): - posData = self.data[self.pos_i] - dataDict = posData.allData_li[posData.frame_i] - allContours = dataDict.get('contours') - if allContours is not None and not force_calc: - z = self.z_lab() - key = (obj.label, str(z), all_external, local) - contours = allContours.get(key) - if contours is not None: - return contours - - obj_image = self.getObjImage(obj.image, obj.bbox).astype(np.uint8) - obj_bbox = self.getObjBbox(obj.bbox) - try: - contours = core.get_obj_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, - local=local, - all_external=all_external - ) - except Exception as e: - if all_external: - contours = [] - else: - contours = None - self.logger.warning( - f'Object ID {obj.label} contours drawing failed. ' - f'(bounding box = {obj.bbox})' - ) - return contours - - def clearComputedContours(self): - for posData in self.data: - for frame_i, dataDict in enumerate(posData.allData_li): - dataDict['contours'] = {} - - def _computeAllContours2D( - self, dataDict, obj, z, obj_bbox, include_internal=False - ): - obj_image = self.getObjImage(obj.image, obj.bbox, z_slice=z) - if obj_image is None: - return - - all_external = False - local = False - contours = core.get_obj_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, - local=local, - all_external=all_external - ) - key = (obj.label, str(z), all_external, local) - dataDict['contours'][key] = contours - - all_external = True - local = False - contours = core.get_obj_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, - local=local, - all_external=all_external, - all=include_internal - ) - key = (obj.label, str(z), all_external, local) - dataDict['contours'][key] = contours - - return dataDict - - def computeAllContours(self): - self.logger.info('Computing all contours...') - posData = self.data[self.pos_i] - zz = [None] - if self.isSegm3D: - zz.extend(range(posData.SizeZ)) - - include_internal = self.showAllContoursToggle.isChecked() - for frame_i, dataDict in enumerate(posData.allData_li): - lab = dataDict['labels'] - if lab is None: - break - - rp = dataDict['regionprops'] - if rp is None: - rp = skimage.measure.regionprops(lab) - - dataDict['contours'] = {} - for obj in rp: - obj_bbox = self.getObjBbox(obj.bbox) - for z in zz: - if not self.isObjVisible(obj.bbox, z_slice=z): - continue - - try: - self._computeAllContours2D( - dataDict, obj, z, obj_bbox, - include_internal=include_internal - ) - except Exception as err: - # Contours computation fails on weird objects - pass - - def computeAllObjToObjCostPairs(self): - desc = ( - 'Computing all object-to-object cost matrices...' - ) - self.logger.info(desc) - posData = self.data[self.pos_i] - - - self.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.show(self.app) - - self.computeAllObjCostPairsThread = QThread() - self.computeAllObjCostPairsWorker = workers.SimpleWorker( - posData, self._computeAllObjToObjCostPairs - ) - - self.computeAllObjCostPairsWorker.moveToThread( - self.computeAllObjCostPairsThread - ) - - self.computeAllObjCostPairsWorker.signals.finished.connect( - self.computeAllObjCostPairsThread.quit - ) - self.computeAllObjCostPairsWorker.signals.finished.connect( - self.computeAllObjCostPairsWorker.deleteLater - ) - self.computeAllObjCostPairsThread.finished.connect( - self.computeAllObjCostPairsThread.deleteLater - ) - - self.computeAllObjCostPairsWorker.signals.critical.connect( - self.computeAllObjCostPairsWorkerCritical - ) - self.computeAllObjCostPairsWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.computeAllObjCostPairsWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.computeAllObjCostPairsWorker.signals.progress.connect( - self.workerProgress - ) - self.computeAllObjCostPairsWorker.signals.finished.connect( - self.computeAllObjCostPairsWorkerFinished - ) - - self.computeAllObjCostPairsThread.started.connect( - self.computeAllObjCostPairsWorker.run - ) - self.computeAllObjCostPairsThread.start() - - self.computeAllObjCostPairsWorkerLoop = QEventLoop() - self.computeAllObjCostPairsWorkerLoop.exec_() - - def _computeAllObjToObjCostPairs(self, posData): - self.computeAllObjCostPairsWorker.signals.initProgressBar.emit( - len(posData.allData_li) - ) - for frame_i, dataDict in enumerate(posData.allData_li): - if frame_i == 0: - continue - - rp = dataDict['regionprops'] - if rp is None: - break - - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - dist_matrix = core._compute_all_obj_to_obj_contour_dist_pairs( - dataDict['contours'], rp, - prev_rp=prev_rp, - restrict_search=True - ) - dataDict['obj_to_obj_dist_cost_matrix_df'] = dist_matrix - self.computeAllObjCostPairsWorker.signals.progressBar.emit(1) - self.computeAllObjCostPairsWorker.signals.initProgressBar.emit(0) - - def computeAllObjCostPairsWorkerCritical(self, error): - self.computeAllObjCostPairsWorkerLoop.exit() - self.workerCritical(error) - - def computeAllObjCostPairsWorkerFinished(self, output): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.computeAllObjCostPairsWorkerLoop.exit() - - def setOverlaySegmMasks(self, force=False, forceIfNotActive=False): - if not hasattr(self, 'currentLab2D'): - return - - how = self.drawIDsContComboBox.currentText() - isOverlaySegmLeftActive = how.find('overlay segm. masks') != -1 - - how_ax2 = self.getAnnotateHowRightImage() - isOverlaySegmRightActive = ( - how_ax2.find('overlay segm. masks') != -1 - and self.labelsGrad.showRightImgAction.isChecked() - ) - - isOverlaySegmActive = ( - isOverlaySegmLeftActive or isOverlaySegmRightActive - or force - ) - if not isOverlaySegmActive and not forceIfNotActive: - return - - alpha = self.imgGrad.labelsAlphaSlider.value() - if alpha == 0: - return - - posData = self.data[self.pos_i] - maxID = max(posData.IDs, default=0) - - if maxID >= len(self.lut): - self.extendLabelsLUT(maxID+10) - - currentLab2D = self.currentLab2D - if isOverlaySegmLeftActive: - self.labelsLayerImg1.setImage(currentLab2D, autoLevels=False) - - if isOverlaySegmRightActive: - self.labelsLayerRightImg.setImage(currentLab2D, autoLevels=False) - - def getObject2DimageFromZ(self, z, obj): - posData = self.data[self.pos_i] - z_min = obj.bbox[0] - local_z = z - z_min - if local_z >= posData.SizeZ or local_z < 0: - return - return obj.image[local_z] - - def getObject2DsliceFromZ(self, z, obj): - posData = self.data[self.pos_i] - z_min = obj.bbox[0] - local_z = z - z_min - if local_z >= posData.SizeZ or local_z < 0: - return - return obj.image[local_z] - - def isObjVisible(self, obj_bbox, debug=False, z_slice=None): - if z_slice is None: - z_slice = self.z_lab() - - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if not isZslice: - # required a projection --> all obj are visible - return True - - depthAxes = self.switchPlaneCombobox.depthAxes() - - min_z, min_y, min_x, max_z, max_y, max_x = obj_bbox - if depthAxes == 'z': - min_val, max_val = min_z, max_z - val = z_slice - elif depthAxes == 'y': - min_val, max_val = min_y, max_y - val = z_slice[-1] - else: - min_val, max_val = min_x, max_x - val = z_slice[-1] - - if val >= min_val and val < max_val: - return True - else: - return False - else: - return True - - def getObjImage(self, obj_image, obj_bbox, z_slice=None): - if self.isSegm3D and len(obj_bbox)==6: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if not isZslice: - # required a projection - return obj_image.max(axis=0) - - min_z = obj_bbox[0] - if z_slice is None: - z_slice = self.z_lab() - if isinstance(z_slice, tuple): - z_slice = z_slice[-1] - - local_z = z_slice - min_z - try: - obi_image_2d = obj_image[local_z] - except Exception as err: - obi_image_2d = None - return obi_image_2d - else: - return obj_image - - def getObjSlice(self, obj_slice): - if self.isSegm3D: - return obj_slice[1:3] - else: - return obj_slice - - def setOverlayImages(self, frame_i=None): - if not self.overlayButton.isChecked(): - return - - posData = self.data[self.pos_i] - if posData.ol_data is None: - return - - rgba_imgs_info = {} - for filename in posData.ol_data: - chName = myutils.get_chname_from_basename( - filename, posData.basename, remove_ext=False - ) - if chName not in self.checkedOverlayChannels: - continue - - items = self.overlayLayersItems[chName] - imageItem, lutItem, alphaSB = items[:3] - - ol_img = self.getOlImg(filename, frame_i=frame_i) - - if self.overlayToolbar.isTransparent(): - toolbutton = items[3] - if not toolbutton.isChecked(): - continue - alpha_val = alphaSB.value()/alphaSB.maximum() - ol_img = skimage.exposure.rescale_intensity( - ol_img, out_range=(0.0, 1.0) - ) - out_range_min, out_range_max = lutItem.getLevels() - rgba_imgs_info[chName] = (ol_img, alpha_val, lutItem) - else: - self.rescaleIntensitiesLut(setImage=False, imageItem=imageItem) - imageItem.setImage(ol_img) - - if not self.overlayToolbar.isTransparent(): - return - - alpha_values = [] - images = [] - luts = [] - for channel, info in rgba_imgs_info.items(): - ol_img, alpha_val, lutItem = info - alpha_values.append(alpha_val) - images.append(ol_img) - luts.append(lutItem.gradient.getLookupTable(256, alpha=255)/255) - - weights = colors.hierarchical_weights(alpha_values) - - if self.baseLayerToolbutton.isChecked(): - image1 = self._getImageupdateAllImages() - image1 = skimage.exposure.rescale_intensity( - image1, out_range=(0.0, 1.0) - ) - images.append(image1) - baseLut = ( - self.imgGrad.gradient.getLookupTable(256, alpha=255)/255 - ) - luts.append(baseLut) - - images_rgba = [] - for img, lut in zip(images, luts): - rgba = colors.grayscale_apply_lut(img, lut) - images_rgba.append(rgba) - - rgba_merge = colors.hierarchical_blend(images_rgba, weights) - self.rgbaImg1.setImage(rgba_merge) - - def getOpacitiesFromAlphaScrollbarValues(self): - alpha_values = [] - activeOverlayImageItems = [] - for items in self.overlayLayersItems.values(): - imgItem, lutItem, alphaSB = items[:3] - _toolbutton = alphaSB.toolbutton - if not _toolbutton.isChecked() or not _toolbutton.isVisible(): - continue - - alpha_values.append(alphaSB.value()/alphaSB.maximum()) - activeOverlayImageItems.append(imgItem) - - opacities = colors.hierarchical_weights(alpha_values)[::-1] - channel_opacity_mapper = {} - for i, imgItem in enumerate(activeOverlayImageItems): - channel_opacity_mapper[imgItem.channelName] = opacities[i+1] - - channel_opacity_mapper[self.user_ch_name] = opacities[0] - - return channel_opacity_mapper - - def initShortcuts(self): - from . import config - cp = config.ConfigParser() - if os.path.exists(shortcut_filepath): - cp.read(shortcut_filepath) - - if 'keyboard.shortcuts' not in cp: - cp['keyboard.shortcuts'] = {} - - if cp.has_option('keyboard.shortcuts', 'Zoom out'): - zoomOutKeyValueStr = cp['keyboard.shortcuts']['Zoom out'] - try: - self.zoomOutKeyValue = int(zoomOutKeyValueStr) - except Exception as err: - self.logger.warning( - f'{zoomOutKeyValueStr} is not a valid key ' - 'zooming out action. Restoring default key "H".' - ) - - if 'delete_object.action' not in cp: - self.delObjAction = None - else: - delObjKeySequenceText = cp['delete_object.action']['Key sequence'] - delObjButtonText = cp['delete_object.action']['Mouse button'] - delObjQtButton = ( - Qt.MouseButton.LeftButton if delObjButtonText == 'Left click' - else Qt.MouseButton.MiddleButton - ) - if not delObjKeySequenceText: - delObjKeySequence = None - else: - delObjKeySequence = widgets.KeySequenceFromText( - delObjKeySequenceText - ) - self.delObjToolAction.setChecked(True) - self.delObjAction = delObjKeySequence, delObjQtButton - - shortcuts = {} - for name, widget in self.widgetsWithShortcut.items(): - if name not in cp.options('keyboard.shortcuts'): - if hasattr(widget, 'keyPressShortcut'): - key = widget.keyPressShortcut - shortcut = widgets.KeySequenceFromText(key) - else: - shortcut = widget.shortcut() - shortcut_text = shortcut.toString() - cp['keyboard.shortcuts'][name] = shortcut_text - else: - shortcut_text = cp['keyboard.shortcuts'][name] - shortcut = widgets.KeySequenceFromText(shortcut_text) - - shortcuts[name] = (shortcut_text, shortcut) - self.setShortcuts(shortcuts, save=False) - with open(shortcut_filepath, 'w') as ini: - cp.write(ini) - - def setShortcuts(self, shortcuts: dict, save=True): - for name, (text, shortcut) in shortcuts.items(): - widget = self.widgetsWithShortcut[name] - if shortcut is None: - shortcut = QKeySequence() - if hasattr(widget, 'keyPressShortcut'): - widget.keyPressShortcut = shortcut - else: - widget.setShortcut(shortcut) - s = widget.toolTip() - toolTip = re.sub(r'Shortcut: "(.*)"', f'Shortcut: "{text}"', s) - widget.setToolTip(toolTip) - - if not save: - return - - from . import config - cp = config.ConfigParser() - if os.path.exists(shortcut_filepath): - cp.read(shortcut_filepath) - - if 'keyboard.shortcuts' not in cp: - cp['keyboard.shortcuts'] = {} - - for name, (text, shortcut) in shortcuts.items(): - cp['keyboard.shortcuts'][name] = text - - cp['keyboard.shortcuts']['Zoom out'] = str(self.zoomOutKeyValue) - - if self.delObjAction is None: - with open(shortcut_filepath, 'w') as ini: - cp.write(ini) - return - - delObjKeySequence, delObjQtButton = self.delObjAction - try: - if delObjKeySequence is None: - delObjKeySequenceText = '' - else: - delObjKeySequenceText = delObjKeySequence.toString() - - delObjKeySequenceText = ( - delObjKeySequenceText - .encode('ascii', 'ignore') - .decode('utf-8') - ) - delObjButtonText = ( - 'Left click' if delObjQtButton == Qt.MouseButton.LeftButton - else 'Middle click' - ) - cp['delete_object.action'] = { - 'Key sequence': delObjKeySequenceText, - 'Mouse button': delObjButtonText - } - except Exception as err: - self.logger.warning( - f'{delObjKeySequence} is not a valid keys sequence for ' - 'deleting objects. Setting default action' - ) - self.delObjAction = None - cp.remove_section('delete_object.action') - - with open(shortcut_filepath, 'w') as ini: - cp.write(ini) - - def editShortcuts_cb(self): - if is_mac: - delObjKeySequenceText = 'Ctrl' - delObjButtonText = 'Left click' - else: - delObjKeySequenceText = '' - delObjButtonText = 'Middle click' - - if self.delObjAction is not None: - delObjKeySequence, delObjQtButton = self.delObjAction - if delObjKeySequence is None: - delObjKeySequenceText = '' - else: - delObjKeySequenceText = delObjKeySequence.toString() - delObjKeySequenceText = ( - delObjKeySequenceText.encode('ascii', 'ignore').decode('utf-8') - ) - delObjButtonText = ( - 'Left click' if delObjQtButton == Qt.MouseButton.LeftButton - else 'Middle click' - ) - - win = apps.ShortcutEditorDialog( - self.widgetsWithShortcut, - delObjectKey=delObjKeySequenceText, - delObjectButton=delObjButtonText, - zoomOutKeyValue=self.zoomOutKeyValue, - parent=self - ) - win.exec_() - if win.cancel: - return - - self.delObjAction = win.delObjAction - self.zoomOutKeyValue = win.zoomOutKeyValue - self.setShortcuts(win.customShortcuts) - - def toggleOverlayColorButton(self, checked=True): - self.mousePressColorButton(None) - - def toggleTextIDsColorButton(self, checked=True): - self.textIDsColorButton.selectColor() - - def updateTextAnnotColor(self, button): - r, g, b = np.array(self.textIDsColorButton.color().getRgb()[:3]) - self.imgGrad.textColorButton.setColor((r, g, b)) - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.textColorButton.setColor((r, g, b)) - self.gui_createTextAnnotColors(r,g,b, custom=True) - self.gui_setTextAnnotColors() - self.updateAllImages() - - def saveTextIDsColors(self, button): - self.df_settings.at['textIDsColor', 'value'] = self.objLabelAnnotRgb - self.df_settings.to_csv(self.settings_csv_path) - - def setLut(self, shuffle=True): - self.lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) - if shuffle: - np.random.shuffle(self.lut) - - # Insert background color - if 'labels_bkgrColor' in self.df_settings.index: - rgbString = self.df_settings.at['labels_bkgrColor', 'value'] - try: - r, g, b = rgbString - except Exception as e: - r, g, b = colors.rgb_str_to_values(rgbString) - else: - r, g, b = 25, 25, 25 - self.df_settings.at['labels_bkgrColor', 'value'] = (r, g, b) - - self.lut = np.insert(self.lut, 0, [r, g, b], axis=0) - - def useCenterBrushCursorHoverIDtoggled(self, checked): - if checked: - self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' - else: - self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'No' - self.df_settings.to_csv(self.settings_csv_path) - - def shuffle_cmap(self): - np.random.shuffle(self.lut[1:]) - self.initLabelsImageItems() - self.updateAllImages() - - def setPermanentGreedyCmapPreferences(self): - if self.isSnapshot: - option_name = 'permanent_greedy_lut_snapshots' - else: - option_name = 'permanent_greedy_lut_timelapse' - - if option_name not in self.df_settings.index: - return - - checked = self.df_settings.at[option_name, 'value'] == 'yes' - self.labelsGrad.permanentGreedyCmapAction.setChecked(checked) - - def permanentGreedyCmapToggled(self, checked): - if checked: - settings_value = 'yes' - else: - self.setLut() - self.updateLookuptable() - self.initLabelsImageItems() - settings_value = 'no' - - self.updateAllImages() - - if self.isSnapshot: - option_name = 'permanent_greedy_lut_snapshots' - else: - option_name = 'permanent_greedy_lut_timelapse' - - self.df_settings.at[option_name, 'value'] = settings_value - self.df_settings.to_csv(self.settings_csv_path) - - def greedyShuffleCmap(self, updateImages=True): - lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) - greedy_lut = colors.get_greedy_lut(self.currentLab2D, lut) - self.lut = greedy_lut - self.initLabelsImageItems() - if updateImages: - self.updateAllImages() - - def highlightZneighLabels_cb(self, checked): - if checked: - pass - else: - pass - - def setTwoImagesLayout(self, isTwoImages): - self.isTwoImageLayout = isTwoImages - if isTwoImages: - self.graphLayout.removeItem(self.titleLabel) - self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) - # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignLeft) - self.ax2.show() - self.ax2.vb.setYLink(self.ax1.vb) - self.ax2.vb.setXLink(self.ax1.vb) - else: - self.graphLayout.removeItem(self.titleLabel) - self.graphLayout.addItem(self.titleLabel, row=0, col=1) - # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignCenter) - self.ax2.hide() - oldLink = self.ax2.vb.linkedView(self.ax1.vb.YAxis) - try: - oldLink.sigYRangeChanged.disconnect() - oldLink.sigXRangeChanged.disconnect() - except TypeError: - pass - - def showNextFrameImageItem(self, checked): - self.rightImageFramesScrollbar.setVisible(checked) - self.rightImageFramesScrollbar.setDisabled(not checked) - self.setTwoImagesLayout(checked) - if checked: - self.df_settings.at['isNextFrameVisible', 'value'] = 'Yes' - self.df_settings.at['isRightImageVisible', 'value'] = 'No' - self.df_settings.at['isLabelsVisible', 'value'] = 'No' - self.graphLayout.addItem( - self.imgGradRight, row=1, col=self.plotsCol+2 - ) - self.rightBottomGroupbox.show() - self.rightBottomGroupbox.setChecked(True) - self.drawNothingCheckboxRight.click() - if not self.isDataLoading: - self.updateAllImages() - else: - self.clearAx2Items() - self.rightBottomGroupbox.hide() - self.df_settings.at['isNextFrameVisible', 'value'] = 'No' - try: - self.graphLayout.removeItem(self.imgGradRight) - except Exception: - return - self.rightImageItem.clear() - - self.df_settings.to_csv(self.settings_csv_path) - - QTimer.singleShot(300, self.resizeGui) - - self.setBottomLayoutStretch() - - - def showRightImageItem(self, checked): - self.rightImageFramesScrollbar.setVisible(not checked) - self.rightImageFramesScrollbar.setDisabled(checked) - self.setTwoImagesLayout(checked) - if checked: - self.df_settings.at['isRightImageVisible', 'value'] = 'Yes' - self.df_settings.at['isNextFrameVisible', 'value'] = 'No' - self.df_settings.at['isLabelsVisible', 'value'] = 'No' - self.graphLayout.addItem( - self.imgGradRight, row=1, col=self.plotsCol+2 - ) - self.rightBottomGroupbox.show() - if not self.isDataLoading: - self.updateAllImages() - else: - self.clearAx2Items() - self.rightBottomGroupbox.hide() - self.df_settings.at['isRightImageVisible', 'value'] = 'No' - try: - self.graphLayout.removeItem(self.imgGradRight) - except Exception: - return - self.rightImageItem.clear() - - self.df_settings.to_csv(self.settings_csv_path) - - QTimer.singleShot(300, self.resizeGui) - - self.setBottomLayoutStretch() - - def showLabelImageItem(self, checked): - self.rightImageFramesScrollbar.setVisible(not checked) - self.rightImageFramesScrollbar.setDisabled(checked) - self.setTwoImagesLayout(checked) - self.setAnnotOptionsRightImageLabelsDisabled(checked) - if checked: - self.df_settings.at['isLabelsVisible', 'value'] = 'Yes' - self.df_settings.at['isNextFrameVisible', 'value'] = 'No' - self.df_settings.at['isRightImageVisible', 'value'] = 'No' - self.rightBottomGroupbox.show() - self.rightBottomGroupbox.setChecked(True) - if not self.isDataLoading: - self.updateAllImages() - else: - self.clearAx2Items() - self.img2.clear() - self.df_settings.at['isLabelsVisible', 'value'] = 'No' - self.rightBottomGroupbox.hide() - self.moveDelRoisToLeft() - - self.df_settings.to_csv(self.settings_csv_path) - QTimer.singleShot(200, self.resizeGui) - - self.setBottomLayoutStretch() - - def setAnnotOptionsRightImageLabelsDisabled(self, disabled): - self.annotContourCheckboxRight.setDisabled(disabled) - self.annotSegmMasksCheckboxRight.setDisabled(disabled) - if disabled: - self.annotSegmMasksCheckboxRight.setChecked(False) - self.annotSegmMasksCheckboxRight.setChecked(False) - self.annotIDsCheckboxRight.setChecked(True) - - def moveDelRoisToLeft(self): - # Move del ROIs to the left image - for posData in self.data: - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - for roi in delROIs_info['rois']: - if not self.ax2.isDelRoiItemPresent(roi): - continue - - self.ax1.addDelRoiItem(roi, roi.key) - self.ax2.removeDelRoiItem(roi) - - def setBottomLayoutStretch(self): - if ( - self.labelsGrad.showRightImgAction.isChecked() - or self.labelsGrad.showNextFrameAction.isChecked() - ): - # Equally share space between the two control groupboxes - self.bottomLayout.setStretch(1, 1) - self.bottomLayout.setStretch(2, 5) - self.bottomLayout.setStretch(3, 1) - self.bottomLayout.setStretch(4, 5) - self.bottomLayout.setStretch(5, 1) - elif self.labelsGrad.showLabelsImgAction.isChecked(): - # Left control takes only left space - self.bottomLayout.setStretch(1, 1) - self.bottomLayout.setStretch(2, 5) - self.bottomLayout.setStretch(3, 5) - self.bottomLayout.setStretch(4, 1) - self.bottomLayout.setStretch(5, 1) - else: - # Left control takes all the space - self.bottomLayout.setStretch(1, 3) - self.bottomLayout.setStretch(2, 10) - self.bottomLayout.setStretch(3, 1) - self.bottomLayout.setStretch(4, 1) - self.bottomLayout.setStretch(5, 1) - - def setCheckedInvertBW(self, checked): - self.invertBwAction.setChecked(checked) - - def ticksCmapMoved(self, gradient): - pass - # posData = self.data[self.pos_i] - # self.setLut(posData, shuffle=False) - # self.updateLookuptable() - - def updateLabelsCmap(self, gradient): - self.setLut() - self.updateLookuptable() - self.initLabelsImageItems() - - self.df_settings = self.labelsGrad.saveState(self.df_settings) - self.df_settings.to_csv(self.settings_csv_path) - - self.updateAllImages() - - def updateBkgrColor(self, button): - color = button.color().getRgb()[:3] - self.lut[0] = color - self.updateLookuptable() - - def updateTextLabelsColor(self, button): - self.ax2_textColor = button.color().getRgb()[:3] - posData = self.data[self.pos_i] - if posData.rp is None: - return - - for obj in posData.rp: - self.getObjOptsSegmLabels(obj) - - def saveTextLabelsColor(self, button): - color = button.color().getRgb()[:3] - self.df_settings.at['labels_text_color', 'value'] = color - self.df_settings.to_csv(self.settings_csv_path) - - def saveBkgrColor(self, button): - color = button.color().getRgb()[:3] - self.df_settings.at['labels_bkgrColor', 'value'] = color - self.df_settings.to_csv(self.settings_csv_path) - self.updateAllImages() - - def changeOverlayColor(self, button): - rgb = button.color().getRgb()[:3] - lutItem = self.overlayLayersItems[button.channel][1] - self.initColormapOverlayLayerItem(rgb, lutItem) - lutItem.overlayColorButton.setColor(rgb) - - def saveOverlayColor(self, button): - rgb = button.color().getRgb()[:3] - rgb_text = '_'.join([str(val) for val in rgb]) - self.df_settings.at[f'{button.channel}_rgb', 'value'] = rgb_text - self.df_settings.to_csv(self.settings_csv_path) - - def getImageDataFromFilename(self, filename): - posData = self.data[self.pos_i] - if filename == posData.filename: - return posData.img_data[posData.frame_i] - else: - return posData.ol_data_dict.get(filename) - - def z_slice_index(self): - posData = self.data[self.pos_i] - if posData.SizeZ == 1: - return None - zProjHow = self.zProjComboBox.currentText() - if zProjHow != 'single z-slice': - return zProjHow - - axis_slice = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == 'x': - z_slice = ( - slice(None, None, None), slice(None, None, None), axis_slice - ) - elif self.switchPlaneCombobox.depthAxes() == 'y': - z_slice = ( - slice(None, None, None), axis_slice - ) - else: - z_slice = axis_slice - - return z_slice - - def get_2Dimg_from_3D(self, imgData, isLayer0=True, frame_i=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - if frame_i < 0: - frame_i = 0 - frame_i = posData.frame_i = 0 - - axis_slice = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == 'x': - return imgData[:, :, axis_slice].copy() - elif self.switchPlaneCombobox.depthAxes() == 'y': - return imgData[:, axis_slice].copy() - - idx = (posData.filename, frame_i) - zProjHow_L0 = self.zProjComboBox.currentText() - if isLayer0: - try: - z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] - except ValueError as e: - z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] - zProjHow = zProjHow_L0 - else: - z = self.zSliceOverlay_SB.sliderPosition() - zProjHow_L1 = self.zProjOverlay_CB.currentText() - if zProjHow_L1 == 'same as above': - zProjHow = zProjHow_L0 - else: - zProjHow = zProjHow_L1 - - if zProjHow == 'single z-slice': - img = imgData[z] #.copy() - elif zProjHow == 'max z-projection': - img = imgData.max(axis=0) - elif zProjHow == 'mean z-projection': - img = imgData.mean(axis=0) - elif zProjHow == 'median z-proj.': - img = np.median(imgData, axis=0) - return img - - def updateZsliceScrollbar(self, frame_i): - posData = self.data[self.pos_i] - if self.switchPlaneCombobox.depthAxes() != 'z': - return - - idx = (posData.filename, frame_i) - try: - z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] - except ValueError as e: - z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] - try: - zProjHow = posData.segmInfo_df.at[idx, 'which_z_proj_gui'] - except ValueError as e: - zProjHow = posData.segmInfo_df.loc[idx, 'which_z_proj_gui'].iloc[0] - - self.zProjComboBox.setCurrentText(zProjHow) - - reconnect = False - try: - self.zSliceScrollBar.actionTriggered.disconnect() - self.zSliceScrollBar.sliderReleased.disconnect() - reconnect = True - except TypeError: - pass - self.zSliceScrollBar.setSliderPosition(z) - if reconnect: - self.zSliceScrollBar.actionTriggered.connect( - self.zSliceScrollBarActionTriggered - ) - self.zSliceScrollBar.sliderReleased.connect( - self.zSliceScrollBarReleased - ) - self.zSliceSpinbox.setValueNoEmit(z+1) - - def getRawImage(self, frame_i=None, filename=None): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - if filename is None: - rawImgData = posData.img_data[frame_i] - isLayer0 = True - else: - rawImgData = posData.ol_data[filename][frame_i] - isLayer0 = False - if posData.SizeZ > 1: - rawImg = self.get_2Dimg_from_3D(rawImgData, isLayer0=isLayer0) - else: - rawImg = rawImgData - return rawImg - - def getRawImageLayer0(self, frame_i): - posData = self.data[self.pos_i] - - if posData.SizeZ > 1: - img = posData.img_data[frame_i] - self.updateZsliceScrollbar(frame_i) - img = self.get_2Dimg_from_3D(img) - else: - img = posData.img_data[frame_i].copy() - - if img.ndim == 2: - return img - if img.ndim == 3 and img.shape[-1] in (3, 4): - return img - - raise ValueError( - 'Raw image for display must be 2D (Y, X) or RGB/A (Y, X, 3 or 4); ' - f'got shape={getattr(img, "shape", None)}, ndim={getattr(img, "ndim", None)} ' - f'for frame_i={frame_i} (metadata SizeT={posData.SizeT}, SizeZ={posData.SizeZ}). ' - 'Check that metadata SizeT/SizeZ matches the loaded array (e.g. squeezed TIFF vs CSV).' - ) - - def initFloodMaskImage(self): - posData = self.data[self.pos_i] - self.flood_img = posData.img_data[posData.frame_i] - if not self.isSegm3D and posData.SizeZ > 1: - self.flood_img = self.get_2Dimg_from_3D(self.flood_img) - return - - def getMagicWandFloodTolerance(self): - tol_perc = self.wandControlsToolbar.toleranceSpinbox.value() - if tol_perc == 0: - return - - posData = self.data[self.pos_i] - _min, _max = posData.img_data_min_max - tol_fraction = tol_perc/100 - tol = (_max - _min) * tol_fraction - - return tol - - def getImage(self, frame_i=None, raw=False): - posData = self.data[self.pos_i] - if frame_i is None: - frame_i = posData.frame_i - - if raw: - return self.getRawImageLayer0(frame_i) - - if self.viewPreprocDataToggle.isChecked(): - try: - img = posData.preproc_img_data[frame_i] - if posData.SizeZ == 1: - return np.array(img) - - self.updateZsliceScrollbar(frame_i) - z_slice = self.z_slice_index() - img = img[z_slice] - return img - except Exception as err: - # self.logger.warning( - # 'Pre-processed image not existing --> returning raw image' - # ) - return self.getRawImageLayer0(frame_i) - - viewCombinedImageData = ( - self.viewCombineChannelDataToggle.isChecked() - and self.combineDialog is not None - and not self.combineDialog.saveAsSegm() - ) - - if viewCombinedImageData: - try: - img = posData.combine_img_data[frame_i] - if posData.SizeZ == 1: - return np.array(img) - - self.updateZsliceScrollbar(frame_i) - z_slice = self.z_slice_index() - img = img[z_slice] - return img - except Exception as err: - # self.logger.warning( - # 'combined image not existing --> returning raw image' - # ) - return self.getRawImageLayer0(frame_i) - - if self.equalizeHistPushButton.isChecked(): - img = posData.equalized_img_data[frame_i] - if posData.SizeZ == 1: - return np.array(img) - - self.updateZsliceScrollbar(frame_i) - z_slice = self.z_slice_index() - img = img[z_slice] - return img - - return self.getRawImageLayer0(frame_i) - - def setImageImg2(self, updateLookuptable=True, set_image=True): - posData = self.data[self.pos_i] - mode = str(self.modeComboBox.currentText()) - if mode == 'Segmentation and Tracking' or self.isSnapshot: - # self.addExistingDelROIs() - allDelIDs, lab2D = self.getDelROIlab() - else: - lab2D = self.get_2Dlab(posData.lab, force_z=False) - allDelIDs = set() - - self.currentLab2D = lab2D - if self.labelsGrad.permanentGreedyCmapAction.isChecked() and updateLookuptable: - self.greedyShuffleCmap(updateImages=False) - - if self.labelsGrad.showLabelsImgAction.isChecked() and set_image: - self.img2.setImage(lab2D, z=self.z_lab(), autoLevels=False) - - if updateLookuptable: - self.updateLookuptable(delIDs=allDelIDs) - - def applyDelROIimg1(self, roi, init=False, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): - return - - if init and how.find('contours') == -1: - self.setOverlaySegmMasks(force=True) - return - - posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - try: - idx = delROIs_info['rois'].index(roi) - except Exception as err: - try: - ax.removeDelRoiItem(roi) - except Exception as err: - pass - return - delIDs = delROIs_info['delIDsROI'][idx] - delMask = delROIs_info['delMasks'][idx] - if how.find('nothing') != -1: - return - elif how.find('contours') != -1: - self.updateContoursImage(ax=ax) - - if not delIDs: - return - - if how.find('overlay segm. masks') != -1: - lab = self.currentLab2D.copy() - lab[delMask > 0] = 0 - if ax == 0: - self.labelsLayerImg1.setImage(lab, autoLevels=False) - else: - self.labelsLayerRightImg.setImage(lab, autoLevels=False) - - self.setAllTextAnnotations(labelsToSkip={ID:True for ID in delIDs}) - - def applyDelROIs(self): - self.logger.info('Applying deletion ROIs (if present)...') - - for posData in self.data: - self.current_frame_i = posData.frame_i - for frame_i in range(posData.SizeT): - lab = posData.allData_li[frame_i]['labels'] - if lab is None: - break - delROIs_info = posData.allData_li[frame_i]['delROIs_info'] - delIDs_rois = delROIs_info['delIDsROI'] - if not delIDs_rois: - continue - for delIDs in delIDs_rois: - for delID in delIDs: - lab[lab==delID] = 0 - posData.allData_li[frame_i]['labels'] = lab - # Get the rest of the metadata and store data based on the new lab - posData.frame_i = frame_i - self.get_data() - self.store_data(autosave=False) - - # Back to current frame - posData.frame_i = self.current_frame_i - self.get_data() - - def initTempLayerBrush(self, ID, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - self.hideItemsHoverBrush(ID=ID, force=True) - Y, X = self.img1.image.shape[:2] - tempImage = np.zeros((Y, X), dtype=np.uint32) - if how.find('contours') != -1: - tempImage[self.currentLab2D==ID] = ID - self.brushImage = tempImage.copy() - self.brushContourImage = np.zeros((Y, X, 4), dtype=np.uint8) - color = self.imgGrad.contoursColorButton.color() - self.brushContoursRgba = color.getRgb() - opacity = 1.0 - else: - opacity = self.imgGrad.labelsAlphaSlider.value() - color = self.lut[ID] - lut = np.zeros((2, 4), dtype=np.uint8) - lut[1,-1] = 255 - lut[1,:-1] = color - self.tempLayerImg1.setLookupTable(lut) - self.tempLayerImg1.setOpacity(opacity) - self.tempLayerImg1.setImage(tempImage, force_set_linked=True) - - def _setTempImageBrushContour(self): - pass - - def setTempBrushMaskFromWand(self, flood_mask, init=False): - if not np.any(flood_mask): - return - - posData = self.data[self.pos_i] - mask = np.logical_or( - flood_mask, - posData.lab==posData.brushID - ) - if mask.ndim == 3: - z_slice = self.zSliceScrollBar.sliderPosition() - mask = mask[z_slice] - - self.setTempImg1Brush(init, mask, posData.brushID) - - # @exec_time - def setTempImg1Brush(self, init: bool, mask, ID, toLocalSlice=None, ax=0): - if init: - self.initTempLayerBrush(ID, ax=ax) - - if self.annotContourCheckbox.isChecked(): - brushImage = self.brushImage - else: - brushImage = self.tempLayerImg1.image - - if toLocalSlice is None: - brushImage[mask] = ID - else: - brushImage[toLocalSlice][mask] = ID - - if self.annotContourCheckbox.isChecked(): - try: - obj = skimage.measure.regionprops(brushImage)[0] - except IndexError: - return - objContour = [self.getObjContours(obj)] - # objContour = core.get_obj_contours( - # obj_image=(brushImage>0).astype(np.uint8), local=True - # ) - self.brushContourImage[:] = 0 - img = self.brushContourImage - color = self.brushContoursRgba - cv2.drawContours(img, objContour, -1, color, 1) - self.tempLayerImg1.setImage(img, force_set_linked=True) - else: - self.tempLayerImg1.setImage(brushImage, force_set_linked=True) - - def getLabelsLayerImage(self, ax=0): - if ax == 0: - return self.labelsLayerImg1.image - else: - return self.labelsLayerRightImg.image - - def clearObjFromMask(self, image, mask, toLocalSlice=None): - if mask is None: - return image - - if toLocalSlice is None: - image[mask] = 0 - else: - image[toLocalSlice][mask] = 0 - - return image - - # @exec_time - def setTempImg1Eraser(self, mask, init=False, toLocalSlice=None, ax=0): - if init: - self.erasedLab = np.zeros_like(self.currentLab2D) - - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): - return - - if how.find('contours') != -1: - self.clearObjFromMask( - self.contoursImage, mask, toLocalSlice=toLocalSlice - ) - erasedRp = skimage.measure.regionprops(self.erasedLab) - for obj in erasedRp: - self.addObjContourToContoursImage(obj=obj, ax=ax) - elif how.find('overlay segm. masks') != -1: - labelsImage = self.getLabelsLayerImage(ax=ax) - self.clearObjFromMask(labelsImage, mask, toLocalSlice=toLocalSlice) - if ax == 0: - self.labelsLayerImg1.setImage( - self.labelsLayerImg1.image, autoLevels=False - ) - else: - self.labelsLayerRightImg.setImage( - self.labelsLayerRightImg.image, autoLevels=False - ) - - def _setTempImgExpandLabelSegmMasks(self, prevCoords, ax=0): - # Remove previous overlaid mask - labelsImage = self.getLabelsLayerImage(ax=ax) - labelsImage[prevCoords] = 0 - - # Overlay new moved mask - labelsImage[prevCoords] = self.expandingID - - if ax == 0: - self.labelsLayerImg1.setImage( - self.labelsLayerImg1.image, autoLevels=False) - else: - self.labelsLayerRightImg.setImage( - self.labelsLayerRightImg.image, autoLevels=False) - - def _setTempImgExpandLabelContours(self, prevCoords, ax=0): - self.contoursImage[prevCoords] = [0,0,0,0] - currentLab2Drp = skimage.measure.regionprops(self.currentLab2D) - for obj in currentLab2Drp: - if obj.label == self.expandingID: - # self.clearObjContour(obj=obj, ax=ax) - self.addObjContourToContoursImage(obj=obj, ax=ax, force=True) - break - - def setTempImgExpandLabel(self, prevCoords, expandedObjCoords, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - self._setTempImgExpandLabelContours(prevCoords, ax=ax) - - # if how.find('overlay segm. masks') != -1: - # self._setTempImgExpandLabelSegmMasks(ax=ax) - # else: - # self._setTempImgExpandLabelContours(ax=ax) - - def setTempImg1MoveLabel(self, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - if how.find('contours') != -1: - currentLab2Drp = skimage.measure.regionprops(self.currentLab2D) - for obj in currentLab2Drp: - if obj.label == self.movingID: - self.addObjContourToContoursImage(obj=obj, ax=ax) - break - elif how.find('overlay segm. masks') != -1: - if ax == 0: - self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) - self.highLightIDLayerImg1.image[:] = 0 - mask = self.currentLab2D==self.movingID - self.highLightIDLayerImg1.image[mask] = self.movingID - highlightedImage = self.highLightIDLayerImg1.image - self.highLightIDLayerImg1.setImage(highlightedImage) - else: - self.labelsLayerRightImg.setImage( - self.currentLab2D, autoLevels=False - ) - self.highLightIDLayerRightImage.image[:] = 0 - mask = self.currentLab2D==self.movingID - self.highLightIDLayerRightImage.image[mask] = self.movingID - highlightedImage = self.highLightIDLayerRightImage.image - self.highLightIDLayerRightImage.setImage(highlightedImage) - - def addMissingIDs_cca_df(self, posData): - base_cca_df = self.getBaseCca_df() - if posData.cca_df is None: - posData.cca_df = base_cca_df - return - - posData.cca_df = posData.cca_df.combine_first(base_cca_df) - - def update_cca_df_relabelling(self, posData, oldIDs, newIDs): - relIDs = posData.cca_df['relative_ID'] - posData.cca_df['relative_ID'] = relIDs.replace(oldIDs, newIDs) - mapper = dict(zip(oldIDs, newIDs)) - posData.cca_df = posData.cca_df.rename(index=mapper) - - def update_cca_df_deletedIDs( - self, posData, deletedIDs, dropInPast=True, dropInFuture=True - ): - if posData.cca_df is None: - return - - # Store cca_df for undo action - undoId = uuid.uuid4() - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - - try: - relIDs = ( - posData.cca_df.reindex(deletedIDs, fill_value=-1) - ['relative_ID'] - ) - except KeyError as err: - return - - posData.cca_df = posData.cca_df.drop(deletedIDs, errors='ignore') - if self.isSnapshot: - self.update_cca_df_newIDs(posData, relIDs) - else: - self.updateCcaDfDeletedIDsTimelapse( - posData, relIDs, deletedIDs, undoId, dropInPast, dropInFuture - ) - - @disableWindow - def updateCcaDfDeletedIDsTimelapse( - self, posData, relIDsOfDelIDs, deletedIDs, undoId, - dropInPast, dropInFuture - ): - # Get status of the relIDs (of deleted IDs) to restore - relIDsCcaStatus = {} - for relID in relIDsOfDelIDs: - try: - ccs = posData.cca_df.at[relID, 'cell_cycle_stage'] - relationship = posData.cca_df.at[relID, 'relationship'] - except Exception as err: - continue - - ccaStatus = core.getBaseCca_df([relID]).loc[relID] - if relationship == 'mother' and ccs == 'S': - for past_frame_i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df( - frame_i=past_frame_i, return_df=True - ) - ccs_past = cca_df_i.at[relID, 'cell_cycle_stage'] - if ccs_past == 'G1': - ccaStatus = cca_df_i.loc[relID] - break - - posData.cca_df.loc[relID] = ccaStatus - self.store_data(autosave=False) - relIDsCcaStatus[relID] = ccaStatus - - for fut_frame_i in range(posData.frame_i+1, posData.SizeT): - cca_df_i = self.get_cca_df(frame_i=fut_frame_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - self.storeUndoRedoCca(fut_frame_i, cca_df_i, undoId) - - if dropInFuture: - cca_df_i = cca_df_i.drop(deletedIDs, errors='ignore') - else: - for delID in deletedIDs: - dataDict = posData.allData_li[fut_frame_i] - delIDexists = dataDict['IDs_idxs'].get(delID, False) - if not delIDexists: - continue - - cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID] - - areRelIDsPresent = False - for relID in relIDsOfDelIDs: - try: - ccs = cca_df_i.at[relID, 'cell_cycle_stage'] - relationship = cca_df_i.at[relID, 'relationship'] - ccaStatus = relIDsCcaStatus[relID] - cca_df_i.loc[relID] = ccaStatus - areRelIDsPresent = True - except Exception as err: - continue - - if not areRelIDsPresent: - break - - self.store_cca_df( - frame_i=fut_frame_i, cca_df=cca_df_i, autosave=False - ) - - # Correct past frames - for past_frame_i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_frame_i, return_df=True) - if cca_df_i is None: - # ith frame was not visited yet - break - - self.storeUndoRedoCca(past_frame_i, cca_df_i, undoId) - if dropInPast: - cca_df_i = cca_df_i.drop(deletedIDs, errors='ignore') - else: - for delID in deletedIDs: - dataDict = posData.allData_li[past_frame_i] - delIDexists = dataDict['IDs_idxs'].get(delID, False) - if not delIDexists: - continue - - cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID] - - areRelIDsPresent = False - for relID in relIDsOfDelIDs: - try: - ccs = cca_df_i.at[relID, 'cell_cycle_stage'] - relationship = cca_df_i.at[relID, 'relationship'] - ccaStatus = relIDsCcaStatus[relID] - cca_df_i.loc[relID] = ccaStatus - areRelIDsPresent = True - except Exception as err: - continue - - if not areRelIDsPresent: - break - - self.store_cca_df( - frame_i=past_frame_i, cca_df=cca_df_i, autosave=False - ) - - def update_cca_df_newIDs(self, posData, new_IDs): - for newID in new_IDs: - self.addIDBaseCca_df(posData, newID) - - def update_cca_df_snapshots(self, editTxt, posData): - cca_df = posData.cca_df - cca_df_IDs = cca_df.index - if editTxt == 'Delete ID': - deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, deleted_IDs) - - elif editTxt == 'Separate IDs': - new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] - self.update_cca_df_newIDs(posData, new_IDs) - deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, deleted_IDs) - - elif editTxt == 'Edit ID': - new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] - self.update_cca_df_newIDs(posData, new_IDs) - old_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, old_IDs) - - elif editTxt == 'Annotate ID as dead': - return - - elif editTxt == 'Deleted non-selected objects': - deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, deleted_IDs) - - elif editTxt == 'Delete ID with eraser': - deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, deleted_IDs) - - elif editTxt == 'Add new ID with brush tool': - new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] - self.update_cca_df_newIDs(posData, new_IDs) - - elif editTxt == 'Merge IDs': - deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, deleted_IDs) - - elif editTxt == 'Add new ID with curvature tool': - new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] - self.update_cca_df_newIDs(posData, new_IDs) - - elif editTxt == 'Delete IDs using ROI': - deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, deleted_IDs) - - elif editTxt == 'Repeat segmentation': - posData.cca_df = self.getBaseCca_df() - - def fixCcaDfAfterEdit(self, editTxt): - posData = self.data[self.pos_i] - if posData.cca_df is not None: - # For snapshot mode we fix or reinit cca_df depending on the edit - self.update_cca_df_snapshots(editTxt, posData) - self.store_data() - - def isFrameCcaAnnotated(self): - posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - if acdc_df is None: - return False - - return 'cell_cycle_stage' in acdc_df.columns - - def warnEditingWithCca_df( - self, editTxt, return_answer=False, get_answer=False, - get_cancelled=False, update_images=True - ): - # Function used to warn that the user is editing in "Segmentation and - # Tracking" mode a frame that contains cca annotations. - # Ask whether to remove annotations from all future frames - if self.isSnapshot: - return True - - posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - - if acdc_df is None and self.lineage_tree is None: - if update_images: - self.updateAllImages() - return True - - cell_cycle_stage_present = ( - acdc_df is not None and 'cell_cycle_stage' in acdc_df.columns - ) - lineage_tree_present = ( - self.lineage_tree is not None or 'parent_ID_tree' in acdc_df.columns - ) - if not cell_cycle_stage_present and not lineage_tree_present: - if update_images: - self.updateAllImages() - return True - - action = self.warnEditingWithAnnotActions.get(editTxt, None) - if action is not None and not action.isChecked(): - # user has checked that he does not want to be asked again AND he doesnt want to delete - if update_images: - self.updateAllImages() - return True - - msg = widgets.myMessageBox() - warn_type = 'cell cycle annotations' if cell_cycle_stage_present else 'lineage tree annotations' - txt = html_utils.paragraph( - f'You modified a frame that has {warn_type}.

' - f'The change "{editTxt}" most likely makes the ' - 'annotations wrong.

' - 'If you really want to apply this change we reccommend to remove' - f'ALL {warn_type}
' - 'from current frame to the end.

' - 'What do you want to do?' - ) - if action is not None: - checkBox = QCheckBox('Remember my choice and do not ask again') - else: - checkBox = None - - dropDelIDsNoteText = ( - '' if editTxt.find('Delete') == -1 else ' (drop removed IDs)' - ) - _, removeAnnotButton, _ = msg.warning( - self, 'Edited segmentation with annotations!', txt, - buttonsTexts=( - 'Cancel', - 'Remove annotations from future frames (RECOMMENDED)', - f'Do not remove annotations{dropDelIDsNoteText}' - ), widgets=checkBox - ) - if msg.cancel: - if get_cancelled: - return 'cancelled' - removeAnnotations = False - return removeAnnotations - - if action is not None: - action.setChecked(not checkBox.isChecked()) - action.removeAnnot = msg.clickedButton == removeAnnotButton - - if return_answer: - return msg.clickedButton == removeAnnotButton - - if (msg.clickedButton == removeAnnotButton) and cell_cycle_stage_present: - self.resetFutureCcaColCurrentFrame() - self.resetCcaFuture(posData.frame_i+1) - self.updateAllImages() - elif (msg.clickedButton == removeAnnotButton) and lineage_tree_present: - self.resetLin_tree_future() - self.updateAllImages() - else: - if dropDelIDsNoteText and posData.cca_df is not None: - delIDs = [ - ID for ID in posData.cca_df.index if ID not in posData.IDs - ] - self.update_cca_df_deletedIDs( - posData, delIDs, dropInPast=False - ) - self.addMissingIDs_cca_df(posData) - self.updateAllImages() - self.store_data() - # if action is not None: - # if action.removeAnnot: - # self.store_data() - # posData.frame_i -= 1 - # self.get_data() - # if lineage_tree_present: - # self.resetLin_tree_future() - # self.resetCcaFuture(posData.frame_i) - # self.next_frame() - - if get_answer: - return msg.clickedButton == removeAnnotButton - else: - return True - - def warnRepeatTrackingVideoOnVisitedFrames(self, last_tracked_i, start_n): - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'You are repeating tracking on frames that have already ' - 'been visited/tracked before.

' - 'This will very likely make the annotations wrong.

' - 'If you really want to repeat tracking on the frames before ' - f'{last_tracked_i+1} the annotations from frame ' - f'{start_n} to frame {last_tracked_i+1} ' - 'will be removed.

' - 'Do you want to continue?' - ) - noButton, yesButton = msg.warning( - self, 'Repating tracking with annotations!', txt, - buttonsTexts=( - ' No, stop tracking and keep annotations.', - ' Yes, repeat tracking and DELETE annotations.' - ) - ) - if msg.cancel: - return False - - if msg.clickedButton == noButton: - return False - else: - return True - - def warnRepeatTrackingVideoWithAnnotations(self, last_tracked_i, start_n): - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'You are repeating tracking on frames that have cell cycle ' - 'annotations.

' - 'This will very likely make the annotations wrong.

' - 'If you really want to repeat tracking on the frames before ' - f'{last_tracked_i+1} the annotations from frame ' - f'{start_n} to frame {last_tracked_i+1} ' - 'will be removed.

' - 'Do you want to continue?' - ) - noButton, yesButton = msg.warning( - self, 'Repating tracking with annotations!', txt, - buttonsTexts=( - ' No, stop tracking and keep annotations.', - ' Yes, repeat tracking and DELETE annotations.' - ) - ) - if msg.cancel: - return False - - if msg.clickedButton == noButton: - return False - else: - return True - - def setDelRoiState(self, roi: pg.ROI, state): - roi.sigRegionChanged.disconnect() - roi.sigRegionChangeFinished.disconnect() - roi.setState(state) - roi.sigRegionChanged.connect(self.delROImoving) - roi.sigRegionChangeFinished.connect(self.delROImovingFinished) - - def addExistingDelROIs(self): - posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - isAx2hidden = not self.labelsGrad.showLabelsImgAction.isChecked() - - for r, roi in enumerate(delROIs_info['rois']): - if isinstance(roi, pg.PolyLineROI) or isAx2hidden: - # PolyLine ROIs are only on ax1 - self.ax1.addDelRoiItem(roi, roi.key) - else: - # Rect ROI is on ax2 because ax2 is visible - self.ax2.addDelRoiItem(roi, roi.key) - - self.setDelRoiState(roi, delROIs_info['state'][r]) - - def updateFramePosLabel(self): - if self.isSnapshot: - posData = self.data[self.pos_i] - self.navSpinBox.setValueNoEmit(self.pos_i+1) - else: - posData = self.data[0] - self.navSpinBox.setValueNoEmit(posData.frame_i+1) - - def highlightHoverID(self, x, y, hoverID=None): - if hoverID is None: - try: - hoverID = self.currentLab2D[int(y), int(x)] - except IndexError: - return - - if hoverID == 0: - return - - posData = self.data[self.pos_i] - objIdx = posData.IDs_idxs[hoverID] - obj = posData.rp[objIdx] - self.goToZsliceSearchedID(obj) - self.highlightSearchedID(hoverID) - - def grayOutHighlightedLabels(self, nonGrayedIDs=None, alpha=None): - if nonGrayedIDs is None: - nonGrayedIDs = set() - - posData = self.data[self.pos_i] - if alpha is None: - alpha = self.imgGrad.labelsAlphaSlider.value() - - if not hasattr(self, 'highlightedLab'): - self.highlightedLab = np.zeros_like(self.currentLab2D) - else: - self.highlightedLab[:] = 0 - - lut = np.zeros((2, 4), dtype=np.uint8) - for _obj in posData.rp: - if not self.isObjVisible(_obj.bbox): - continue - if _obj.label not in nonGrayedIDs: - continue - _slice = self.getObjSlice(_obj.slice) - _objMask = self.getObjImage(_obj.image, _obj.bbox) - self.highlightedLab[_slice][_objMask] = _obj.label - rgb = self.lut[_obj.label].copy() - lut[1, :-1] = rgb - # Set alpha to 0.7 - lut[1, -1] = 178 - - return lut - - def grayOutOverlaySegm(self, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - isOverlaySegmActive = how.find('segm. masks') != -1 - if not isOverlaySegmActive: - return - - grayedLut = self.grayOutHighlightedLabels() - - def highlightHoverIDsKeptObj(self, x, y, hoverID=None): - if hoverID is None: - try: - hoverID = self.currentLab2D[int(y), int(x)] - except IndexError: - return - - self.highlightSearchedID(hoverID, greyOthers=False) - - if hoverID == 0 and self.highlightedID == 0: - return - - if hoverID == 0 and self.highlightedID != 0: - self.clearHighlightedKeepIDs() - for ID in self.keptObjectsIDs: - self.highlightLabelID(ID) - return - - posData = self.data[self.pos_i] - try: - objIdx = posData.IDs_idxs[hoverID] - except KeyError as err: - return - - obj = posData.rp[objIdx] - self.goToZsliceSearchedID(obj) - - for ID in self.keptObjectsIDs: - self.highlightLabelID(ID) - - def getHighlightedID(self): - if self.highlightedID > 0: - return self.highlightedID - - doHighlight = ( - self.propsDockWidget.isVisible() - and ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() - ) - ) - if not doHighlight: - return 0 - - return self.guiTabControl.propsQGBox.idSB.value() - - def clearHighlightedKeepIDs(self): - self.setAllTextAnnotations() - self.highlightedID = 0 - self.searchedIDitemRight.setData([], []) - self.searchedIDitemLeft.setData([], []) - self.highLightIDLayerImg1.clear() - self.highLightIDLayerRightImage.clear() - - def setHighlighedIDfromToolbar(self, ID: int): - self.findID(ID=ID) - - def highlightSearchedID(self, ID, force=False, greyOthers=True): - self.highlightIDToolbar.setIDNoSignals(ID) - - if ID == 0: - self.highlightIDToolbar.setVisible(False) - return - - if ID == self.highlightedID and not force: - return - - doHighlight = ( - self.propsDockWidget.isVisible() - and ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() - ) - ) - if doHighlight: - self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - ID = self.highlightedID - - if self.highlightedID > 0: - self.clearHighlightedText() - - self.searchedIDitemRight.setData([], []) - self.searchedIDitemLeft.setData([], []) - - posData = self.data[self.pos_i] - - self.highlightedID = ID - self.highlightIDToolbar.setVisible(True) - - objIdx = posData.IDs_idxs.get(ID) - if objIdx is None: - return - - obj = posData.rp[objIdx] - isObjVisible = self.isObjVisible(obj.bbox) - if not isObjVisible: - return - - if greyOthers: - self.textAnnot[0].grayOutAnnotations() - self.textAnnot[1].grayOutAnnotations() - - how_ax1 = self.drawIDsContComboBox.currentText() - how_ax2 = self.getAnnotateHowRightImage() - isOverlaySegm_ax1 = how_ax1.find('segm. masks') != -1 - isOverlaySegm_ax2 = how_ax2.find('segm. masks') != -1 - alpha = self.imgGrad.labelsAlphaSlider.value() - - if isOverlaySegm_ax1 or isOverlaySegm_ax2: - grayedLut = self.grayOutHighlightedLabels( - nonGrayedIDs={obj.label}, - alpha=alpha - ) - - cont = None - contours = None - if isOverlaySegm_ax1: - self.highLightIDLayerImg1.setLookupTable(grayedLut) - self.highLightIDLayerImg1.setImage(self.highlightedLab) - self.labelsLayerImg1.setOpacity(alpha/3) - else: - contours = self.getObjContours(obj, all_external=True) - for cont in contours: - self.searchedIDitemLeft.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) - - if isOverlaySegm_ax2: - self.highLightIDLayerRightImage.setLookupTable(grayedLut) - self.highLightIDLayerRightImage.setImage(self.highlightedLab) - self.labelsLayerRightImg.setOpacity(alpha/3) - else: - if contours is None: - contours = self.getObjContours(obj, all_external=True) - for cont in contours: - self.searchedIDitemRight.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) - - # Gray out all IDs excpet searched one - lut = self.lut.copy() # [:max(posData.IDs)+1] - lut[:ID] = lut[:ID]*0.2 - lut[ID+1:] = lut[ID+1:]*0.2 - self.img2.setLookupTable(lut) - - # Highlight text - self.highlightLabelID(ID, ax=0) - self.highlightLabelID(ID, ax=1) - - def _drawGhostContour(self, x, y): - if self.ghostObject is None: - return - - ID = self.ghostObject.label - yc, xc = self.ghostObject.local_centroid - Dx = x-xc - Dy = y-yc - xx = self.ghostObject.xx_contour + Dx - yy = self.ghostObject.yy_contour + Dy - self.ghostContourItemLeft.setData( - xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) - self.ghostContourItemRight.setData( - xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) - - def _drawManualBackgroundObjContour(self, x, y): - if self.manualBackgroundObj is None: - return - - ID = self.manualBackgroundObj.label - yc, xc = self.manualBackgroundObj.local_centroid - Dx = x-xc - Dy = y-yc - xx = self.manualBackgroundObj.xx_contour + Dx - yy = self.manualBackgroundObj.yy_contour + Dy - self.manualBackgroundObjItem.setData( - xx, yy, fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) - - def _drawGhostMask(self, x, y): - if self.ghostObject is None: - return - - self.clearGhostMask() - ID = self.ghostObject.label - h, w = self.ghostObject.image.shape[-2:] - yc, xc = self.ghostObject.local_centroid - Dx = int(x-xc) - Dy = int(y-yc) - bbox = ((Dy, Dy+h), (Dx, Dx+w)) - - Y, X = self.currentLab2D.shape - slices = myutils.get_slices_local_into_global_arr(bbox, (Y, X)) - slice_global_to_local, slice_crop_local = slices - - obj_image = self.ghostObject.image[slice_crop_local] - - self.ghostMaskItemLeft.image[slice_global_to_local][obj_image] = ID - self.ghostMaskItemLeft.updateGhostImage( - fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) - - self.ghostMaskItemRight.image[slice_global_to_local][obj_image] = ID - self.ghostMaskItemRight.updateGhostImage( - fontSize=self.fontSize, ID=ID, y_cursor=y, x_cursor=x - ) - - def drawManualBackgroundObj(self, x, y): - if x is None or y is None: - self.clearGhost() - return - - self._drawManualBackgroundObjContour(x, y) - - def drawManualTrackingGhost(self, x, y): - if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): - return - - if x is None or y is None: - self.clearGhost() - return - - if self.manualTrackingToolbar.ghostContourRadiobutton.isChecked(): - self._drawGhostContour(x, y) - else: - self._drawGhostMask(x, y) - - def restoreDefaultSettings(self): - df = self.df_settings - df.at['contLineWeight', 'value'] = 1 - df.at['mothBudLineSize', 'value'] = 1 - df.at['mothBudLineColor', 'value'] = (255, 165, 0, 255) - df.at['contLineColor', 'value'] = (205, 0, 0, 220) - - self._updateContColour((205, 0, 0, 220)) - self._updateMothBudLineColour((255, 165, 0, 255)) - self._updateMothBudLineSize(1) - self._updateContLineThickness() - - df.at['overlaySegmMasksAlpha', 'value'] = 0.3 - df.at['img_cmap', 'value'] = 'grey' - self.imgCmap = self.imgGrad.cmaps['grey'] - self.imgCmapName = 'grey' - self.labelsGrad.item.loadPreset('viridis') - df.at['labels_bkgrColor', 'value'] = (25, 25, 25) - - if df.at['is_bw_inverted', 'value'] == 'Yes': - self.invertBw(update=False) - - df = df[~df.index.str.contains('lab_cmap')] - df.to_csv(self.settings_csv_path) - self.imgGrad.restoreState(df) - for items in self.overlayLayersItems.values(): - lutItem = items[1] - lutItem.restoreState(df) - - self.labelsGrad.saveState(df) - self.labelsGrad.restoreState(df, loadCmap=False) - - self.df_settings.to_csv(self.settings_csv_path) - self.updateAllImages() - - def updateLabelsAlpha(self, value): - self.df_settings.at['overlaySegmMasksAlpha', 'value'] = value - self.df_settings.to_csv(self.settings_csv_path) - if self.keepIDsButton.isChecked(): - value = value/3 - self.labelsLayerImg1.setOpacity(value) - self.labelsLayerRightImg.setOpacity(value) - - - def _getImageupdateAllImages(self, image=None): - if image is not None: - return image - - img = self.getImage() - return img - - def setImageImg1(self, image=None): - img = self._getImageupdateAllImages(image=image) - posData = self.data[self.pos_i] - self.img1.setCurrentPosIndex(self.pos_i) - self.img1.setCurrentFrameIndex(posData.frame_i) - if posData.SizeZ > 1: - zProjHow = self.zProjComboBox.currentText() - if zProjHow == 'single z-slice': - z = self.zSliceScrollBar.sliderPosition() - else: - z = zProjHow - - self.img1.setCurrentZsliceIndex(z) - - self.img1.setImage( - img, next_frame_image=self.nextFrameImage(), - scrollbar_value=posData.frame_i+2 - ) - - def getContoursImageItem(self, ax, force=False): - if not self.areContoursRequested(ax) and not force: - return - - if ax == 0: - return self.ax1_contoursImageItem - else: - return self.ax2_contoursImageItem - - def getLostObjImageItem(self, ax): - if ax == 0: - return self.ax1_lostObjImageItem - else: - return self.ax1_lostTrackedObjImageItem - - def getLostTrackedObjImageItem(self, ax): - if ax == 0: - return self.ax1_lostTrackedObjImageItem - else: - return self.ax2_lostTrackedObjImageItem - - def setManualBackgroundImage(self): - if not self.manualBackgroundButton.isChecked(): - return - - posData = self.data[self.pos_i] - if not hasattr(posData, 'manualBackgroundImage'): - self.initManualBackgroundImage() - - contours = [] - for obj in skimage.measure.regionprops(posData.manualBackgroundLab): - obj_contours = self.getObjContours(obj, all_external=True) - contours.extend(obj_contours) - textItem = self.manualBackgroundTextItems[obj.label] - textItem.setText(f'{obj.label}') - self.ax1.addItem(textItem) - yc, xc = obj.centroid - textItem.setPos(xc, yc) - - cv2.drawContours( - posData.manualBackgroundImage, contours, -1, (255, 0, 0, 200), 1 - ) - self.manualBackgroundImageItem.setImage(posData.manualBackgroundImage) - - def setManualBackgrounNextID(self): - posData = self.data[self.pos_i] - currentID = self.manualBackgroundObj.label - idx = posData.IDs_idxs[currentID] - next_idx = idx + 1 - if next_idx >= len(posData.IDs): - return - next_ID = posData.IDs[next_idx] - self.manualBackgroundToolbar.spinboxID.setValue(next_ID) - - def clearManualBackgroundObject(self, ID): - posData = self.data[self.pos_i] - mask = posData.manualBackgroundLab==ID - posData.manualBackgroundImage[mask, :] = 0 - posData.manualBackgroundLab[mask] = 0 - - def addManualBackgroundObject(self, x, y): - posData = self.data[self.pos_i] - - if not hasattr(self, 'manualBackgroundObj'): - self.initManualBackgroundObject() - - Y, X = self.currentLab2D.shape - ymin, xmin, ymax, xmax = self.manualBackgroundObj.bbox - width, height = xmax-xmin, ymax-ymin - yc, xc = self.manualBackgroundObj.local_centroid - xstart, ystart = round(x-xc), round(y-yc) - xstart = xstart if xstart >= 0 else 0 - ystart = ystart if ystart >= 0 else 0 - - xend = xstart+width - yend = ystart+height - xend = xend if xend <= X else X - yend = yend if yend <= Y else Y - - width = xend-xstart - height = yend-ystart - - obj_image = self.manualBackgroundObj.image[:height, :width] - obj_slice = (slice(ystart, yend), slice(xstart, xend)) - ID = self.manualBackgroundObj.label - self.clearManualBackgroundObject(ID) - posData.manualBackgroundLab[obj_slice][obj_image] = ID - - if ID in self.manualBackgroundTextItems: - self.manualBackgroundTextItems[ID].setPos(x, y) - return - - textItem = pg.TextItem( - text=str(ID), color='r', anchor=(0.5, 0.5) - ) - textItem.setFont(font_13px) - textItem.setPos(x, y) - self.manualBackgroundTextItems[ID] = textItem - - self.ax1.addItem(textItem) - - def setManualBackgroundLab(self, load_from_store=False, debug=True): - posData = self.data[self.pos_i] - if posData.manualBackgroundLab is None: - self.initManualBackgroundImage() - - for obj in skimage.measure.regionprops(posData.manualBackgroundLab): - textItem = pg.TextItem(text='', color='r', anchor=(0.5, 0.5)) - if obj.label in self.manualBackgroundTextItems: - continue - self.manualBackgroundTextItems[obj.label] = textItem - - def updateContoursImage(self, ax, delROIsIDs=None, compute=True): - imageItem = self.getContoursImageItem(ax) - if imageItem is None: - return - - if not hasattr(self, 'contoursImage'): - self.initContoursImage() - else: - self.contoursImage[:] = 0 - - contours = [] - for obj in skimage.measure.regionprops(self.currentLab2D): - obj_contours = self.getObjContours( - obj, - all_external=True, - force_calc=compute, - include_internal=self.showAllContoursToggle.isChecked() - ) - contours.extend(obj_contours) - - thickness = self.contLineWeight - color = self.contLineColor - self.setContoursImage(imageItem, contours, thickness, color) - - def setContoursImage(self, imageItem, contours, thickness, color): - cv2.drawContours(self.contoursImage, contours, -1, color, thickness) - imageItem.setImage(self.contoursImage) - - def getObjFromID(self, ID): - posData = self.data[self.pos_i] - try: - idx = posData.IDs_idxs[ID] - except KeyError as e: - # Object already cleared - return - - obj = posData.rp[idx] - return obj - - def setLostObjectContour(self, obj): - allContours = self.getObjContours(obj, all_external=True) - for objContours in allContours: - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - data = [obj.label]*len(xx) - self.ax1_lostObjScatterItem.addPoints(xx, yy, data=data) - self.ax2_lostObjScatterItem.addPoints(xx, yy) - - def setTrackedLostObjectContour(self, obj): - if self.isExportingVideo: - return - - allContours = self.getObjContours(obj, all_external=True) - for objContours in allContours: - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - data = [obj.label]*len(xx) - self.ax1_lostTrackedScatterItem.addPoints(xx, yy, data=data) - self.ax2_lostTrackedScatterItem.addPoints(xx, yy) - - def updateLostContoursImage(self, ax, draw=True, delROIsIDs=None): - if draw: - imageItem = self.getLostObjImageItem(ax) - if imageItem is None: - return - - if not hasattr(self, 'lostObjContoursImage'): - self.initLostObjContoursImage() - else: - self.lostObjContoursImage[:] = 0 - - if delROIsIDs is None: - delROIsIDs = set() - - posData = self.data[self.pos_i] - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] - if posData.whitelist is not None and posData.whitelist.whitelistIDs is not None: - whitelist = posData.whitelist.whitelistIDs[posData.frame_i-1] - else: - whitelist = None - - contours = [] - for lostID in posData.lost_IDs: - if lostID in delROIsIDs or (whitelist is not None and lostID not in whitelist): - continue - - obj = prev_rp[prev_IDs_idxs[lostID]] - if not self.isObjVisible(obj.bbox): - continue - - obj_contours = self.getObjContours(obj, all_external=True) - - if ax == 0: - self.addLostObjsToLostObjImage(obj, lostID) - - contours.extend(obj_contours) - - if not draw: - return - - self.drawLostObjContoursImage(imageItem, contours) - - def drawLostObjContoursImage( - self, imageItem, contours, - thickness=1, - color=(255, 165, 0, 255) # orange - ): - img = self.lostObjContoursImage - cv2.drawContours(img, contours, -1, color, thickness) - imageItem.setImage(img) - - def updateLostTrackedContoursImage( - self, ax, delROIsIDs=None, tracked_lost_IDs=None - ): - imageItem = self.getLostTrackedObjImageItem(ax) - if imageItem is None: - return - - if not hasattr(self, 'lostTrackedObjContoursImage'): - self.initLostTrackedObjContoursImage() - else: - self.lostTrackedObjContoursImage[:] = 0 - - if delROIsIDs is None: - delROIsIDs = set() - - posData = self.data[self.pos_i] - if tracked_lost_IDs is None: - tracked_lost_IDs = self.getTrackedLostIDs() - - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] - contours = [] - for tracked_lost_ID in tracked_lost_IDs: - if tracked_lost_ID in delROIsIDs: - continue - - obj = prev_rp[prev_IDs_idxs[tracked_lost_ID]] - if not self.isObjVisible(obj.bbox): - continue - - obj_contours = self.getObjContours(obj, all_external=True) - contours.extend(obj_contours) - - self.drawLostTrackedObjContoursImage(imageItem, contours) - - def drawLostTrackedObjContoursImage(self, imageItem, contours): - thickness = 1 - color = (0, 255, 0, 255) # green - img = self.lostTrackedObjContoursImage - cv2.drawContours(img, contours, -1, color, thickness) - imageItem.setImage(img) - - def getNearestLostObjID(self, y, x): - if not self.annotLostObjsToggle.isChecked(): - return - - posData = self.data[self.pos_i] - if not posData.lost_IDs: - return - - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - if prev_lab is None: - return - - # if not hasattr(self, 'lostObjContoursImage'): - # self.store_data() - # posData.frame_i -= 1 - # self.get_data() - # self.store_data() - # posData.frame_i += 1 - # self.get_data() - # self.updateLostNewCurrentIDs() - # self.updateLostContoursImage(ax=0) - # self.updateLostContoursImage(ax=1) - # self.updateLostNewCurrentIDs() - - yy, xx, _ = np.nonzero(self.lostObjContoursImage) - lostObjsContourMask = np.zeros(self.currentLab2D.shape, dtype=bool) - lostObjsContourMask[yy.astype(int), xx.astype(int)] = True - - # Add accepted lost IDs - try: - yy, xx, _ = np.nonzero(self.lostTrackedObjContoursImage) - lostObjsContourMask[yy.astype(int), xx.astype(int)] = True - except Exception as err: - pass - - _, y_nearest, x_nearest = core.nearest_nonzero_2D( - lostObjsContourMask, y, x, return_coords=True - ) - nearest_ID = self.get_2Dlab(prev_lab)[y_nearest, x_nearest] - - if nearest_ID == 0: - return - - return nearest_ID - - def setCcaIssueContour(self, obj): - objContours = self.getObjContours(obj, all_external=True) - for cont in objContours: - xx = cont[:,0] + 0.5 - yy = cont[:,1] + 0.5 - self.ax1_lostObjScatterItem.addPoints(xx, yy) - self.textAnnot[0].addObjAnnotation( - obj, 'lost_object', f'{obj.label}?', False - ) - - def isLastVisitedAgainCca(self, curr_df, enforceAll=False): - # Determine if this is the last visited frame for repeating - # bud assignment on non manually corrected_on_frame_i buds. - # The idea is that the user could have assigned division on a cell - # by going previous and we want to check if this cell could be a - # "better" mother for those non manually corrected buds - posData = self.data[self.pos_i] - if curr_df is None: - return False - - if 'cell_cycle_stage' not in curr_df.columns: - return False - - if enforceAll: - return False - - lastVisited = False - posData.new_IDs = [ - ID for ID in posData.new_IDs - if curr_df.at[ID, 'is_history_known'] - and curr_df.at[ID, 'cell_cycle_stage'] == 'S' - ] - if posData.frame_i+1 < posData.SizeT: - next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] - if next_df is None: - lastVisited = True - else: - if 'cell_cycle_stage' not in next_df.columns: - lastVisited = True - else: - lastVisited = True - - return lastVisited - - def highlightNewCellNotEnoughG1cells(self, IDsCellsG1): - posData = self.data[self.pos_i] - for obj in posData.rp: - if obj.label not in IDsCellsG1: - continue - objContours = self.getObjContours(obj) - if objContours is not None: - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - self.ccaFailedScatterItem.addPoints(xx, yy) - self.textAnnot[0].addObjAnnotation( - obj, 'green', f'{obj.label}?', False - ) - - def handleNoCellsInG1(self, numCellsG1, numNewCells): - posData = self.data[self.pos_i] - self.highlightNewCellNotEnoughG1cells(posData.new_IDs) - continueAnyway = _warnings.warnNotEnoughG1Cells( - numCellsG1, posData.frame_i, numNewCells, qparent=self - ) - if continueAnyway: - notEnoughG1Cells = False - proceed = True - # Annotate the new IDs with unknown history - for ID in posData.new_IDs: - posData.cca_df.loc[ID] = pd.Series(base_cca_dict) - cca_df_ID = self.getStatusKnownHistoryBud(ID) - posData.ccaStatus_whenEmerged[ID] = cca_df_ID - else: - notEnoughG1Cells = True - proceed = False - - # Clear new cells annotations - self.ccaFailedScatterItem.setData([], []) - return notEnoughG1Cells, proceed - - def addObjContourToContoursImage( - self, ID=0, obj=None, ax=0, thickness=None, color=None, - force=False - ): - imageItem = self.getContoursImageItem(ax, force=force) - if imageItem is None: - return - - if obj is None: - obj = self.getObjFromID(ID) - if obj is None: - return - - contours = self.getObjContours(obj, all_external=True) - if thickness is None: - thickness = self.contLineWeight - if color is None: - color = self.contLineColor - - self.setContoursImage(imageItem, contours, thickness, color) - - def clearObjContour( - self, ID=0, obj=None, ax=0, debug=False, updateImage=True - ): - imageItem = self.getContoursImageItem(ax) - if imageItem is None: - return - - if ID > 0: - self.contoursImage[self.currentLab2D==ID] = [0,0,0,0] - else: - obj_slice = self.getObjSlice(obj.slice) - obj_image = self.getObjImage(obj.image, obj.bbox) - self.contoursImage[obj_slice][obj_image] = [0,0,0,0] - - if not updateImage: - return - - imageItem.setImage(self.contoursImage) - - def clearAnnotItems(self): - self.textAnnot[0].clear() - self.textAnnot[1].clear() - - # @exec_time - def setAllTextAnnotations(self, labelsToSkip=None): - delROIsIDs = self.setLostNewOldPrevIDs() - posData = self.data[self.pos_i] - self.textAnnot[0].setAnnotations( - posData=posData, - labelsToSkip=labelsToSkip, - isVisibleCheckFunc=self.isObjVisible, - highlightedID=self.highlightedID, - delROIsIDs=delROIsIDs, - annotateLost=self.annotLostObjsToggle.isChecked(), - getCurrentZfunc=self.z_lab, - getObjCentroidFunc=self.getObjCentroid - ) - self.textAnnot[1].setAnnotations( - posData=posData, labelsToSkip=labelsToSkip, - isVisibleCheckFunc=self.isObjVisible, - highlightedID=self.highlightedID, - delROIsIDs=delROIsIDs, - annotateLost=self.annotLostObjsToggle.isChecked(), - getCurrentZfunc=self.z_lab, - getObjCentroidFunc=self.getObjCentroid - ) - self.textAnnot[0].update() - self.textAnnot[1].update() - return delROIsIDs - - def setAllContoursImages(self, delROIsIDs=None, compute=True): - if compute: - self.computeAllContours() - self.updateContoursImage(ax=0, delROIsIDs=delROIsIDs, compute=compute) - self.updateContoursImage(ax=1, delROIsIDs=delROIsIDs, compute=compute) - - def setAllLostObjContoursImage(self, delROIsIDs=None): - self.updateLostContoursImage(ax=0, delROIsIDs=None) - self.updateLostContoursImage(ax=1, delROIsIDs=None) - - def setAllLostTrackedObjContoursImage(self, delROIsIDs=None): - self.updateLostTrackedContoursImage(ax=0, delROIsIDs=None) - self.updateLostTrackedContoursImage(ax=1, delROIsIDs=None) - - def nextFrameImage(self, current_frame_i=None): - if not self.labelsGrad.showNextFrameAction.isEnabled(): - return - - if not self.labelsGrad.showNextFrameAction.isChecked(): - return - - posData = self.data[self.pos_i] - if current_frame_i is None: - current_frame_i = posData.frame_i - - next_frame_i = current_frame_i + 1 - if next_frame_i >= len(posData.img_data): - img = posData.img_data[-1] - else: - img = posData.img_data[next_frame_i] - - if posData.SizeZ > 1: - img = self.get_2Dimg_from_3D(img, isLayer0=True) - - # img = self.normalizeIntensities(img) - - return img - - def onKeyHome(self): - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepAdd - ) - - def onKeyEnd(self): - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepSub - ) - - def onKeyPageUp(self): - isAutoPilotActive = ( - self.autoPilotZoomToObjToggle.isChecked() - and self.autoPilotZoomToObjToolbar.isVisible() - ) - if isAutoPilotActive: - self.pointsLayerAutoPilot('next') - elif self.zSliceScrollBar.isVisible(): - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepAdd - ) - - def onKeyPageDown(self): - isAutoPilotActive = ( - self.autoPilotZoomToObjToggle.isChecked() - and self.autoPilotZoomToObjToolbar.isVisible() - ) - if isAutoPilotActive: - self.pointsLayerAutoPilot('prev') - elif self.zSliceScrollBar.isVisible(): - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepAdd - ) - - def keyUpCallback( - self, isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive - ): - isAutoPilotActive = ( - self.autoPilotZoomToObjToggle.isChecked() - and self.autoPilotZoomToObjToolbar.isVisible() - ) - if isBrushActive: - brushSize = self.brushSizeSpinbox.value() - self.brushSizeSpinbox.setValue(brushSize+1) - elif isWandActive: - wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() - self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance+1) - elif isExpandLabelActive: - self.expandLabel(dilation=True) - self.expandFootprintSize += 1 - elif isLabelRoiCircActive: - val = self.labelRoiCircularRadiusSpinbox.value() - self.labelRoiCircularRadiusSpinbox.setValue(val+1) - elif isAutoPilotActive: - self.pointsLayerAutoPilot('next') - else: - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepAdd - ) - - def keyDownCallback( - self, isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive - ): - isAutoPilotActive = ( - self.autoPilotZoomToObjToggle.isChecked() - and self.autoPilotZoomToObjToolbar.isVisible() - ) - if isBrushActive: - brushSize = self.brushSizeSpinbox.value() - self.brushSizeSpinbox.setValue(brushSize-1) - elif isWandActive: - wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() - self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance-1) - elif isExpandLabelActive: - self.expandLabel(dilation=False) - self.expandFootprintSize += 1 - elif isLabelRoiCircActive: - val = self.labelRoiCircularRadiusSpinbox.value() - self.labelRoiCircularRadiusSpinbox.setValue(val-1) - elif isAutoPilotActive: - self.pointsLayerAutoPilot('prev') - elif self.isNavigateActionOnNextFrame(): - posData = self.data[self.pos_i] - self.rightImageFramesScrollbar.setValue(posData.frame_i+2) - else: - self.zSliceScrollBar.triggerAction( - QAbstractSlider.SliderAction.SliderSingleStepSub - ) - - # @exec_time - @exception_handler - def updateAllImages( - self, image=None, computePointsLayers=True, computeContours=True, - updateLookuptable=True - ): - self.clearAllItems() - - posData = self.data[self.pos_i] - - self.last_pos_i = self.pos_i - self.last_frame_i = posData.frame_i - - self.rescaleIntensitiesLut(setImage=False) - - self.setImageImg1(image=image) - self.setImageImg2(updateLookuptable=updateLookuptable) - - self.setOverlayImages() - - self.setOverlayLabelsItems() - self.setOverlaySegmMasks() - - if self.slideshowWin is not None: - self.slideshowWin.frame_i = posData.frame_i - self.slideshowWin.update_img() - - # self.update_rp() - - # Annotate ID and draw contours - delROIsIDs = self.setAllTextAnnotations() - self.setAllContoursImages( - delROIsIDs=delROIsIDs, compute=False - ) - - mode = self.modeComboBox.currentText() - self.drawAllMothBudLines() - if mode == 'Normal division: Lineage tree': - self.drawAllLineageTreeLines() - - self.highlightLostNew() - - if self.ccaTableWin is not None: # need to add for lin tree, later - zoomIDs = self.getZoomIDs() - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - self.doCustomAnnotation(0) - - self.annotate_rip_and_bin_IDs() - self.updateTempLayerKeepIDs() - self.whitelistUpdateTempLayer() - self.drawPointsLayers(computePointsLayers=computePointsLayers) - self.setManualBackgroundImage() - self.annotateAssignedObjsAcdcTrackerSecondStep() - - self.highlightSearchedID(self.highlightedID, force=True) - self.updateTimestampFrame() - - posData.visited = True - - def updateTimestampFrame(self): - if not hasattr(self, 'timestamp'): - return - - if not self.addTimestampAction.isChecked(): - return - - posData = self.data[self.pos_i] - self.timestamp.setText(posData.frame_i) - - def deleteIDFromLab( - self, lab, delID, frame_i=None, delMask=None, shift=False - ): - posData = self.data[self.pos_i] - frame_i = posData.frame_i if frame_i is None else frame_i - - if shift and self.isSegm3D: - lab3D = lab - delMask3D = delMask - lab = self.get_2Dlab(lab) - if delMask is not None: - delMask = self.get_2Dlab(delMask) - rp = skimage.measure.regionprops(lab) - IDs_idxs = {obj.label: idx for idx, obj in enumerate(rp)} - else: - if frame_i==posData.frame_i: - rp = posData.rp - IDs_idxs = posData.IDs_idxs - else: - rp = posData.allData_li[frame_i]['regionprops'] - IDs_idxs = posData.allData_li[frame_i]['IDs_idxs'] - - if isinstance(delID, int): - delID = [delID] - - is_any_id_present = False - for _delID in delID: - if _delID in IDs_idxs: - is_any_id_present = True - break - - if not is_any_id_present: - return lab, delMask - - if delMask is None: - delMask = np.zeros(lab.shape, dtype=bool) - else: - delMask[:] = False - - for _delID in delID: - idx = IDs_idxs.get(_delID, None) - if idx is None: - continue - obj = rp[idx] - delMask[obj.slice][obj.image] = True - lab[delMask] = 0 - - if shift and self.isSegm3D: - self.set_2Dlab(lab, lab3D=lab3D) - lab = lab3D - if delMask3D is not None: - self.set_2Dlab(delMask, lab3D=delMask3D) - delMask = delMask3D - - return lab, delMask - - def removeStoredContours(self, delID, frame_i=None, z_slice=None): - posData = self.data[self.pos_i] - - if frame_i is None: - frame_i = posData.frame_i - - dataDict = posData.allData_li[posData.frame_i] - try: - newContours = {} - for key, contours in dataDict['contours'].items(): - ID = key[0] - if ID == delID: - continue - - if z_slice is not None: - z_slice_i = key[1] - if z_slice_i != z_slice: - continue - - newContours[key] = contours - - dataDict['contours'] = newContours - except KeyError as err: - pass - - @disableWindow - def deleteIDmiddleClick( - self, delIDs: Iterable, applyFutFrames, includeUnvisited, - shift=False - ): - self.clearHighlightedID() - - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - - # Apply Delete ID to future frames if requested - if applyFutFrames: - delMask = np.zeros(posData.lab.shape, dtype=bool) - # Store current data before going to future frames - self.store_data() - segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i+1, segmSizeT): - lab = posData.allData_li[i]['labels'] - if lab is None and not includeUnvisited: - self.enqAutosave() - break - - if lab is not None: - # Visited frame - lab, _ = self.deleteIDFromLab( - lab, delIDs, frame_i=i, delMask=delMask, shift=shift - ) - - # Store change - posData.allData_li[i]['labels'] = lab - # Get the rest of the stored metadata based on the new lab - posData.frame_i = i - self.get_data() - self.store_data(autosave=False) - elif includeUnvisited: - # Unvisited frame (includeUnvisited = True) - lab = posData.segm_data[i] - lab, _ = self.deleteIDFromLab( - lab, delIDs, frame_i=i, delMask=delMask, shift=shift - ) - - # Back to current frame - if applyFutFrames: - posData.frame_i = current_frame_i - self.get_data() - - z_slice = None - if shift and self.isSegm3D: - z_slice = self.z_lab() - - posData.lab, delID_mask = self.deleteIDFromLab( - posData.lab, delIDs, shift=shift - ) - for _delID in delIDs: - self.clearObjContour(ID=_delID, ax=0) - self.clearObjContour(ID=_delID, ax=1) - if z_slice is None: - self.removeObjectFromRp(_delID) - self.removeStoredContours(_delID, z_slice=z_slice) - - if shift and self.isSegm3D: - self.update_rp() - - self.store_data(autosave=False) - self.whitelistPropagateIDs(IDs_to_remove=delIDs, curr_frame_only=(not applyFutFrames)) - return delID_mask - - def hideOverlayLabelsItems(self, specific=None): - if specific is None: - specific = self.overlayLabelsItems.keys() - for segmEndname in specific: - imageItem, contoursItem, gradItem = self.overlayLabelsItems[segmEndname] - imageItem.setVisible(False) - contoursItem.setVisible(False) - gradItem.setVisible(False) - - def showOverlayLabelsItems(self, specific=None): - if specific is None: - specific = self.overlayLabelsItems.keys() - for segmEndname in specific: - imageItem, contoursItem, gradItem = self.overlayLabelsItems[segmEndname] - drawMode = self.drawModeOverlayLabelsChannels[segmEndname] - if drawMode == 'Draw contours': - contoursItem.setVisible(True) - elif drawMode == 'Overlay labels': - imageItem.setVisible(True) - gradItem.setVisible(True) - - def setOverlayLabelsItems(self, specific=None): - if not self.overlayLabelsButton.isChecked(): - self.hideOverlayLabelsItems(specific=specific) - return - - if specific is None: - specific = self.drawModeOverlayLabelsChannels.keys() - - for segmEndname in specific: - drawMode = self.drawModeOverlayLabelsChannels[segmEndname] - ol_lab = self.getOverlayLabelsData(segmEndname) - items = self.overlayLabelsItems[segmEndname] - imageItem, contoursItem, gradItem = items - contoursItem.clear() - if drawMode == 'Draw contours': - for obj in skimage.measure.regionprops(ol_lab): - contours = self.getObjContours( - obj, all_external=True - ) - for cont in contours: - contoursItem.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) - elif drawMode == 'Overlay labels': - imageItem.setImage(ol_lab, autoLevels=False) - self.showOverlayLabelsItems(specific=specific) - - def getOverlayLabelsData(self, segmEndname): - posData = self.data[self.pos_i] - - if posData.ol_labels_data is None: - self.loadOverlayLabelsData(segmEndname) - elif segmEndname not in posData.ol_labels_data: - self.loadOverlayLabelsData(segmEndname) - - comb_seg = False - if 'combined segm.' == segmEndname: - comb_seg = True - if not self.isSegm3D: - zStackImg = self.data[0].SizeZ > 1 - if zStackImg: - selected_z_stack = self.zSliceScrollBar.sliderPosition() - else: - selected_z_stack = 0 - out = posData.ol_labels_data['combined segm.'][posData.frame_i][selected_z_stack] - return out.astype(np.uint32) - - if self.isSegm3D: - zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' - if isZslice: - z = self.zSliceScrollBar.sliderPosition() - ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i][z] - if comb_seg: - ol_lab = ol_lab.astype(np.uint32) - return ol_lab - else: - ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i].max(axis=0) - if comb_seg: - ol_lab = ol_lab.astype(np.uint32) - return ol_lab - else: - return posData.ol_labels_data[segmEndname][posData.frame_i] - - def loadOverlayLabelsData(self, segmEndname, pos_i=None): - if pos_i is None: - pos_i = self.pos_i - posData = self.data[pos_i] - - if posData.ol_labels_data is None: - posData.ol_labels_data = {} - if segmEndname == 'combined segm.': - posData.ol_labels_data['combined segm.'] = posData.combine_img_data - return - filePath, filename = load.get_path_from_endname( - segmEndname, posData.images_path - ) - self.logger.info(f'Loading "{segmEndname}.npz"...') - labelsData = np.load(filePath)['arr_0'] - if posData.SizeT == 1: - labelsData = labelsData[np.newaxis] - if self.isSegm3D and labelsData.ndim == 3: - # 2D segm --> stack to 3D - T, Y, X = labelsData.shape - repeat = [labelsData]*posData.SizeZ - labelsData = np.stack(repeat, axis=1) - - - posData.ol_labels_data[segmEndname] = labelsData - - def startBlinkingModeCB(self): - try: - self.timer.stop() - self.stopBlinkTimer.stop() - except Exception as e: - pass - if self.rulerButton.isChecked(): - return - self.timer = QTimer(self) - self.timer.timeout.connect(self.blinkModeComboBox) - self.timer.start(200) - self.stopBlinkTimer = QTimer(self) - self.stopBlinkTimer.timeout.connect(self.stopBlinkingCB) - self.stopBlinkTimer.start(2000) - - def blinkModeComboBox(self): - if self.flag: - self.modeComboBox.setStyleSheet('background-color: orange') - else: - self.modeComboBox.setStyleSheet('background-color: none') - self.flag = not self.flag - - def stopBlinkingCB(self): - self.timer.stop() - self.modeComboBox.setStyleSheet('background-color: none') - - def highlightNewIDs_ccaFailed(self, IDsWithIssue, rp=None): - if rp is None: - posData = self.data[self.pos_i] - rp = posData.rp - for obj in rp: - if obj.label not in IDsWithIssue: - continue - self.setCcaIssueContour(obj) - - # @exec_time - def highlightLostNew(self): - if self.modeComboBox.currentText() == 'Viewer': - return - - posData = self.data[self.pos_i] - delROIsIDs = self.getDelRoisIDs() - - # self.setAllContoursImages(delROIsIDs=delROIsIDs) - if posData.frame_i == 0: - return - - if not self.annotLostObjsToggle.isChecked(): - return - - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - - if prev_rp is None: - return - - self.setAllLostObjContoursImage(delROIsIDs=delROIsIDs) - self.setAllLostTrackedObjContoursImage(delROIsIDs=delROIsIDs) - - def addLostObjsToLostObjImage(self, lostObj, lostID, force=False): - if not force: - if not self.copyLostObjButton.isChecked(): - return - - obj_slice = self.getObjSlice(lostObj.slice) - obj_image = self.getObjImage(lostObj.image, lostObj.bbox) - self.lostObjImage[obj_slice][obj_image] = lostID - - def highlightHoverLostObj(self, modifiers, event): - noModifier = modifiers == Qt.NoModifier - if not noModifier: - return - - if not self.copyLostObjButton.isChecked(): - return - - if event.isExit(): - return - - posData = self.data[self.pos_i] - x, y = event.pos() - xdata, ydata = int(x), int(y) - try: - hoverLostID = self.lostObjImage[ydata, xdata] - except IndexError: - return - - self.ax1_lostObjScatterItem.hoverLostID = hoverLostID - if hoverLostID == 0: - self.ax1_lostObjScatterItem.setSize(self.contLineWeight+1) - self.ax1_lostObjScatterItem.setData([], []) - else: - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] - lostObj = prev_rp[prev_IDs_idxs[hoverLostID]] - obj_contours = self.getObjContours(lostObj, all_external=True) - for cont in obj_contours: - xx = cont[:,0] - yy = cont[:,1] - self.ax1_lostObjScatterItem.addPoints(xx, yy) - self.ax1_lostObjScatterItem.setSize(self.contLineWeight+2) - - def annotLostObjsToggled(self, checked): - if not self.isDataLoaded: - return - self.updateAllImages() - - def getPrevFrameIDs(self, current_frame_i=None): - posData = self.data[self.pos_i] - if current_frame_i is None: - current_frame_i = posData.frame_i - - if current_frame_i is None: - return [] - - prev_frame_i = current_frame_i - 1 - prevIDs = posData.allData_li[prev_frame_i]['IDs'] - - if prevIDs: - return prevIDs - - # IDs in previous frame were not stored --> load prev lab from HDD - prev_lab = self.get_labels( - from_store=False, - frame_i=prev_frame_i, - return_copy=False - ) - rp = skimage.measure.regionprops(prev_lab) - prevIDs = [obj.label for obj in rp] - return prevIDs - - # @exec_time - def setLostNewOldPrevIDs(self): - posData = self.data[self.pos_i] - if posData.frame_i == 0: - posData.lost_IDs = [] - posData.new_IDs = [] - posData.old_IDs = [] - # posData.multiContIDs = set() - self.titleLabel.setText('Looking good!', color=self.titleColor) - return [] - - # elif self.modeComboBox.currentText() == 'Viewer': - # pass - - out = self.updateLostNewCurrentIDs() - lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs = ( - out - ) - self.setTitleText( - lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs - ) - return curr_delRoiIDs - - - def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): - if not IDs: - return htmlTxt_li, htmlTxtFull_li - - if isinstance(IDs, set): - IDs = list(IDs) - - trim_IDs = myutils.get_trimmed_list(IDs) - txt = f'{pretxt}: {trim_IDs}' - txt_full = f'{pretxt}:
{IDs}' - - txt = f'{txt}' - txt_full = f'{txt_full}' - - htmlTxt_li.append(txt) - htmlTxtFull_li.append(txt_full) - - return htmlTxt_li, htmlTxtFull_li - - def setTitleText( - self, lost_IDs=None, new_IDs=None, IDs_with_holes=None, - tracked_lost_IDs=None - ): - if self.manualAnnotPastButton.isChecked(): - lockedID = self.editIDspinbox.value() - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - txt = ( - f'Locked ID {lockedID} ' - f'since frame n. {frame_to_restore+1}' - ) - htmlTxt = f'{txt}' - self.titleLabel.setText(htmlTxt) - return - - mode = self.modeComboBox.currentText() - try: - posData = self.data[self.pos_i] - posData.segm_data[posData.frame_i] - prev_segmented = True - except IndexError: - prev_segmented = False - - if prev_segmented: - htmlTxt_li = [] - htmlTxtFull_li = [] - else: - htmlTxt = f'Never segmented frame. ' - self.titleLabel.setText(htmlTxt) - self.titleLabel.setToolTip(htmlTxt) - return - - if mode != 'Normal division: Lineage tree': - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'IDs lost', 'orange', lost_IDs - ) - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'New IDs', 'red', new_IDs - ) - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'Acc. IDs lost', 'green', - tracked_lost_IDs - ) - - for i, htmlTxtFull in enumerate(htmlTxtFull_li): - htmlTxtFull_li[i] = htmlTxtFull.replace('Acc.', 'Accepted') - - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'IDs with holes', 'red', - IDs_with_holes - ) - else: - try: - cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i) - except IndexError or KeyError: - title = 'Processing lineage tree...' - htmlTxt = f'{title}' - self.titleLabel.setText(htmlTxt) - self.titleLabel.setToolTip(htmlTxt) - return - except AttributeError: - title = 'Lineage tree still initializing...' - htmlTxt = f'{title}' - self.titleLabel.setText(htmlTxt) - self.titleLabel.setToolTip(htmlTxt) - return - - parent_cell_txt_raw = [] - if cells_with_parent: - # aggregate same parents - parent_cell_groups = dict() - for cell, parent in cells_with_parent: - if parent not in parent_cell_groups: - parent_cell_groups[parent] = [] - parent_cell_groups[parent].append(cell) - for parent, daughters in parent_cell_groups.items(): - cells_str = ','.join([str(daughter) for daughter in daughters]) - parent_cell_txt_raw.append(f'({parent}>{cells_str})') - - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'New w/out mother', 'red', - orphan_cells - ) - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'Lost', 'yellow', lost_cells - ) - htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'Parent > Cell', 'green', - parent_cell_txt_raw - ) - - if not htmlTxt_li: - title = 'Looking good' - htmlTxt = f'{title}' - self.titleLabel.setText(htmlTxt) - self.titleLabel.setToolTip(htmlTxt) - return - - htmlTxt = ', '.join(htmlTxt_li) - htmlTxtFull = '
'.join(htmlTxtFull_li) - - self.titleLabel.setText(htmlTxt) - self.titleLabel.setToolTip(htmlTxtFull) - - def separateByLabelling(self, lab, rp, maxID=None): - """ - Label each single object in posData.lab and if the result is more than - one object then we insert the separated object into posData.lab - """ - setRp = False - posData = self.data[self.pos_i] - if maxID is None: - maxID = max(posData.IDs, default=1) - for obj in rp: - lab_obj = skimage.measure.label(obj.image) - rp_lab_obj = skimage.measure.regionprops(lab_obj) - if len(rp_lab_obj)<=1: - continue - lab_obj += maxID - _slice = obj.slice # self.getObjSlice(obj.slice) - _objMask = obj.image # self.getObjImage(obj.image) - lab[_slice][_objMask] = lab_obj[_objMask] - setRp = True - maxID += 1 - return setRp - - def isFirstTimeOnNextFrame(self): - posData = self.data[self.pos_i] - posData.last_tracked_i = self.navigateScrollBar.maximum()-1 - return posData.frame_i > posData.last_tracked_i - - def trackManuallyAddedObject( - self, added_IDs: List[int] | int | Set[int], isNewID: bool, - wl_update:bool=True, wl_track_og_curr:bool=False - ): - """Track object added manually on frame that was already visited. - - Parameters - ---------- - added_IDs : int | list of int | set - ID or IDs of the object added manually - isNewID : bool - If True, the added object is new - - Notes - ----- - This method tracks the new added object against the previous frame - labels. If the ID determined by tracking is different from `added_ID` - (meaning that tracking thinks the new ID should be changed to the - tracked ID) and the tracked ID is not already existing (which would - otherwise causing merging) we assign the tracked ID to the object with - `added_ID`. - - If instead the tracked ID is the same as `added_ID` we are dealing - with a truly new object. In this case we want to try tracking it against - the next frame (since the next frame was already validated). - As before, we assign the tracked ID (against the next frame) only if - not already existing in current frame (to avoid merging). - """ - if self.isSnapshot: - return - - if not isNewID: - return - - if isinstance(added_IDs, int): - added_IDs = [added_IDs] - - posData = self.data[self.pos_i] - tracked_lab = self.tracking( - enforce=True, assign_unique_new_IDs=False, return_lab=True, - IDs=added_IDs - ) - self.clearAssignedObjsSecondStep() - if tracked_lab is None: - return - - # Track only new object - prevIDs = posData.allData_li[posData.frame_i-1]['IDs'] - - # mask = np.zeros(posData.lab.shape, dtype=bool) - update_rp = False - - for added_ID in added_IDs: - # try: - # obj = posData.rp[added_ID] # ID not present - # mask[obj.slice][obj.image] = True - - # except IndexError as err: - mask = posData.lab == added_ID - try: - trackedID = tracked_lab[mask][0] - except IndexError as err: - # added_ID is not present - continue - - isTrackedIDalreadyPresentAndNotNew = ( - posData.IDs_idxs.get(trackedID) is not None - and added_ID != trackedID - ) - if isTrackedIDalreadyPresentAndNotNew: - continue - - isTrackedIDinPrevIDs = trackedID in prevIDs - if isTrackedIDinPrevIDs: - posData.lab[mask] = trackedID - else: - # New object where we can try to track against next frame - trackedID = self.trackNewIDtoNewIDsFutureFrame(added_ID, mask) - if trackedID is None: - self.clearAssignedObjsSecondStep() - continue - posData.lab[mask] = trackedID - - self.keepOnlyNewIDAssignedObjsSecondStep(trackedID) - update_rp = True - - if update_rp: - self.update_rp(wl_update=wl_update) - - def trackFrameCustomTracker( - self, prev_lab, currentLab, IDs=None, unique_ID=None - ): - if unique_ID is None: - unique_ID = self.setBrushID() - try: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - unique_ID=unique_ID, - IDs=IDs, - **self.track_frame_params, - ) - except TypeError as err: - if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: - try: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, IDs=IDs, - **self.track_frame_params - ) - except TypeError as err: - if str(err).find('an unexpected keyword argument \'IDs\'') != -1: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - **self.track_frame_params) - else: - raise err - elif str(err).find('an unexpected keyword argument \'IDs\'') != -1: - try: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - unique_ID=unique_ID, - **self.track_frame_params - ) - except TypeError as err: - if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: - tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - **self.track_frame_params - ) - else: - raise err - else: - raise err - return tracked_result - - def trackFrame( - self, prev_lab, prev_rp, curr_lab, curr_rp, curr_IDs, - assign_unique_new_IDs=True, IDs=None, unique_ID=None - ): - if self.trackWithAcdcAction.isChecked(): - tracked_result = CellACDC_tracker.track_frame( - prev_lab, prev_rp, curr_lab, curr_rp, - IDs_curr_untracked=curr_IDs, - setBrushID_func=self.setBrushID, - posData=self.data[self.pos_i], - assign_unique_new_IDs=assign_unique_new_IDs, - IDs=IDs, - unique_ID=unique_ID - ) - elif self.trackWithYeazAction.isChecked(): - tracked_result = self.tracking_yeaz.correspondence( - prev_lab, curr_lab, use_modified_yeaz=True, - use_scipy=True - ) - else: - tracked_result = self.trackFrameCustomTracker( - prev_lab, curr_lab, IDs=IDs, unique_ID=unique_ID - ) - - # Check if tracker also returns additional info - if isinstance(tracked_result, tuple): - tracked_lab, tracked_lost_IDs = tracked_result - self.handleAdditionalInfoRealTimeTracker(prev_rp, tracked_lost_IDs) - else: - tracked_lab = tracked_result - - return tracked_lab - - def clearAssignedObjsSecondStep(self): - posData = self.data[self.pos_i] - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None - - def trackSubsetIDs(self, subsetIDs: Iterable[int]): - posData = self.data[self.pos_i] - if posData.frame_i == 0: - return - - subsetLab = np.zeros_like(posData.lab) - for subsetID in subsetIDs: - subsetLab[posData.lab == subsetID] = subsetID - - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - tracked_lab = self.trackFrame( - prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, - assign_unique_new_IDs=True - ) - doUpdateRp = False - for subsetID in subsetIDs: - subsetIDmask = posData.lab == subsetID - trackedID = tracked_lab[subsetIDmask][0] - if trackedID == subsetID: - continue - - is_manually_edited = False - for y, x, new_ID in posData.editID_info: - if new_ID == subsetID: - # Do not track because it was manually edited - break - - posData.lab[subsetIDmask] = tracked_lab[subsetIDmask] - doUpdateRp = True - - if not doUpdateRp: - return - - self.update_rp() - - def doSkipTracking(self, against_next: bool, enforce: bool): - if self.isSnapshot: - return True - - mode = str(self.modeComboBox.currentText()) - if mode != 'Segmentation and Tracking': - return True - - if self.UserEnforced_DisabledTracking: - return True - - if not self.realTimeTrackingToggle.isChecked(): - return True - - posData = self.data[self.pos_i] - if against_next: - reference_lab = posData.allData_li[posData.frame_i+1]['labels'] - if reference_lab is None: - # Next frame never visited --> cannot track against next - return True - - if posData.frame_i == posData.SizeT - 1: - # Last frame --> cannot track against next - return True - - else: - # check that we are not on the last frame - if posData.frame_i == 0: - return True - - if enforce or self.UserEnforced_Tracking: - # Enforce even if not last visited frame - return False - - is_first_time_on_next_frame = self.isFirstTimeOnNextFrame() - skip_tracking = not is_first_time_on_next_frame - - return skip_tracking - - - # @exec_time - @exception_handler - def tracking( - self, enforce=False, DoManualEdit=True, - storeUndo=False, prev_lab=None, prev_rp=None, - return_lab=False, assign_unique_new_IDs=True, - separateByLabel=True, wl_update=True, - IDs=None, against_next=False, - ): - posData = self.data[self.pos_i] - - if self.doSkipTracking(against_next, enforce): - self.setLostNewOldPrevIDs() - return - - """Tracking starts here""" - staturBarLabelText = self.statusBarLabel.text() - self.statusBarLabel.setText('Tracking...') - - if storeUndo: - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - # First separate by labelling - if separateByLabel: - maxID = max(posData.IDs, default=1) - setRp = core.split_connected_components( - posData.lab, rp=posData.rp, max_ID=maxID - ) - if setRp: - self.update_rp(wl_update=wl_update, ) - - if prev_lab is None: - if not against_next: - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - else: - prev_lab = posData.allData_li[posData.frame_i+1]['labels'] - if prev_rp is None: - if not against_next: - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - else: - prev_rp = posData.allData_li[posData.frame_i+1]['regionprops'] - - unique_ID = None - if posData.frame_i < self.get_last_tracked_i(): - unique_ID = self.setBrushID(return_val=True) - - tracked_lab = self.trackFrame( - prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, - assign_unique_new_IDs=assign_unique_new_IDs, IDs=IDs, - unique_ID=unique_ID - ) - - if DoManualEdit: - # Correct tracking with manually changed IDs - rp = skimage.measure.regionprops(tracked_lab) - IDs = [obj.label for obj in rp] - self.manuallyEditTracking(tracked_lab, IDs) - - if return_lab: - QTimer.singleShot(50, partial( - self.statusBarLabel.setText, staturBarLabelText - )) - return tracked_lab - - # Update labels, regionprops and determine new and lost IDs - posData.lab = tracked_lab - self.update_rp(wl_update=wl_update, ) - self.setAllTextAnnotations() - QTimer.singleShot(50, partial( - self.statusBarLabel.setText, staturBarLabelText - )) - - def handleAdditionalInfoRealTimeTracker(self, prev_rp, *args): - if self._rtTrackerName == 'CellACDC_normal_division': - tracked_lost_IDs = args[0] - self.setTrackedLostCentroids(prev_rp, tracked_lost_IDs) - elif self._rtTrackerName == 'CellACDC_2steps': - if args[0] is None: - return - posData = self.data[self.pos_i] - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = args[0] - - def keepOnlyNewIDAssignedObjsSecondStep(self, trackedID): - posData = self.data[self.pos_i] - annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) - - if annotInfo is None: - return - - new_objs_1st_step, lost_objs_1st_step = annotInfo - correct_new_objs, correct_lost_objs = [], [] - for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): - newObj_ID = posData.lab[newObj.slice][newObj.image][0] - if newObj_ID != trackedID: - continue - - correct_new_objs.append(newObj) - correct_lost_objs.append(lostObj) - - if not correct_new_objs: - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None - else: - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( - correct_new_objs, correct_lost_objs - ) - # self.annotateAssignedObjsAcdcTrackerSecondStep() - - def updateAssignedObjsAcdcTrackerSecondStep(self, newID): - posData = self.data[self.pos_i] - annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) - if annotInfo is None: - return - - new_objs_1st_step, lost_objs_1st_step = annotInfo - correct_new_objs, correct_lost_objs = [], [] - for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): - newObj_ID = posData.lab[newObj.slice][newObj.image][0] - if newObj_ID == newID: - # The ID of the new object tracked with 2nd step was - # manually edit --> do not annotate its linking to lost obj anymore - continue - correct_new_objs.append(newObj) - correct_lost_objs.append(lostObj) - - if not correct_new_objs: - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None - else: - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( - correct_new_objs, correct_lost_objs - ) - self.annotateAssignedObjsAcdcTrackerSecondStep() - - - def annotateAssignedObjsAcdcTrackerSecondStep(self): - posData = self.data[self.pos_i] - annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) - if annotInfo is None: - return - - new_objs_1st_step, lost_objs_1st_step = annotInfo - for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): - allContours = self.getObjContours(lostObj, all_external=True) - for objContours in allContours: - isObjVisible = self.isObjVisible(newObj.bbox) - if not isObjVisible: - continue - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - self.yellowContourScatterItem.addPoints(xx, yy) - - y1, x1 = self.getObjCentroid(lostObj.centroid) - y2, x2 = self.getObjCentroid(newObj.centroid) - xx, yy = core.get_line(y1, x1, y2, x2, dashed=False) - self.ax1_oldMothBudLinesItem.addPoints(xx, yy) - - posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None - - def setTrackedLostCentroids(self, prev_rp, tracked_lost_IDs): - """Store centroids of those IDs the tracker decided is fine to lose - (e.g., upon standard cell division the ID of the mother is fine) - - Parameters - ---------- - prev_rp : skimage.measure.RegionProperties - List of region properties of the object in previous frame - tracked_lost_IDs : iterable - List-like container of the IDs that is fine to lose from previous - frame to current frame - - Note - ---- - This function stores the centroids because the user could change IDs - in multiple ways. Storing centroids is more robust. - """ - posData = self.data[self.pos_i] - frame_i = posData.frame_i - - for obj in prev_rp: - if obj.label not in tracked_lost_IDs: - continue - - int_centroid = tuple([int(val) for val in obj.centroid]) - try: - posData.tracked_lost_centroids[frame_i].add(int_centroid) - except KeyError: - posData.tracked_lost_centroids[frame_i] = {int_centroid} - - def getTrackedLostIDs(self, prev_lab=None, IDs_in_frames=None, frame_i=None): - trackedLostIDs = set() - posData = self.data[self.pos_i] - if self.isExportingVideo: - posData.trackedLostIDs = trackedLostIDs - return trackedLostIDs - - retrackedLostcent = set() - if frame_i is None: - frame_i = posData.frame_i - - if prev_lab is None: - prev_lab = self.get_labels( - from_store=True, - frame_i=posData.frame_i-1, - return_existing=False, - return_copy=False - ) - - if IDs_in_frames is None: - IDs_in_frames = posData.IDs - - try: - tracked_lost_centroids = posData.tracked_lost_centroids[frame_i] - except KeyError: - tracked_lost_centroids = set() - - for centroid in tracked_lost_centroids: - if len(centroid) < 3 and prev_lab.ndim == 3: - # Ignore wrongly stored centroids - continue - - ID = prev_lab[centroid] - if ID == 0: - continue - - if ID in IDs_in_frames: - retrackedLostcent.add(centroid) - continue - - trackedLostIDs.add(ID) - - posData.tracked_lost_centroids[frame_i] = ( - tracked_lost_centroids - retrackedLostcent - ) - posData.trackedLostIDs = trackedLostIDs - - return trackedLostIDs - - def manuallyEditTracking(self, tracked_lab, allIDs): - posData = self.data[self.pos_i] - infoToRemove = [] - # Correct tracking with manually changed IDs - maxID = max(allIDs, default=1) - for y, x, new_ID in posData.editID_info: - old_ID = tracked_lab[y, x] - if old_ID == 0 or old_ID == new_ID: - infoToRemove.append((y, x, new_ID)) - continue - if new_ID in allIDs: - tempID = maxID+1 - tracked_lab[tracked_lab == old_ID] = tempID - tracked_lab[tracked_lab == new_ID] = old_ID - tracked_lab[tracked_lab == tempID] = new_ID - else: - tracked_lab[tracked_lab == old_ID] = new_ID - if new_ID > maxID: - maxID = new_ID - for info in infoToRemove: - posData.editID_info.remove(info) - - def warnReinitLastSegmFrame(self): - current_frame_n = self.navigateScrollBar.value() - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - Are you sure you want to re-initialize the last visited and - validated frame to number {current_frame_n}?

- WARNING: If you save, all annotations after frame number - {current_frame_n} will be lost! - """) - msg.warning( - self, 'WARNING: Potential loss of data', txt, - buttonsTexts=('Cancel', 'Yes, I am sure') - ) - return msg.cancel - - def extendSegmDataIfNeeded(self, stopFrameNum): - posData = self.data[self.pos_i] - segmSizeT = len(posData.segm_data) - if stopFrameNum <= segmSizeT: - return - numFramesToAdd = stopFrameNum - segmSizeT - posData.allData_li.extend( - [myutils.get_empty_stored_data_dict() for i in range(numFramesToAdd)] - ) - lab_shape = posData.segm_data[0].shape - shapeToAdd = (numFramesToAdd, *lab_shape) - additionalSegmData = np.zeros(shapeToAdd, dtype=posData.segm_data.dtype) - extendedSegmData = np.concatenate((posData.segm_data, additionalSegmData)) - posData.segm_data = extendedSegmData - - def reInitLastSegmFrame( - self, checked=True, from_frame_i=None, updateImages=True, - force=False - ): - if not force: - cancel = self.warnReinitLastSegmFrame() - if cancel: - self.logger.info( - 'Re-initialization of last validated frame cancelled.' - ) - return - - posData = self.data[self.pos_i] - if from_frame_i is None: - from_frame_i = posData.frame_i - - self.lastFrameRanOnFirstVisitTools = posData.frame_i - - self.updateLastCheckedFrameWidgets(from_frame_i) - posData.last_tracked_i = from_frame_i - self.navigateScrollBar.setMaximum(from_frame_i+1) - self.navSpinBox.setMaximum(from_frame_i+1) - # self.navigateScrollBar.setMinimum(1) - - # posData.tracked_lost_centroids[from_frame_i-1] = set() - for i in range(from_frame_i, posData.SizeT): - if posData.allData_li[i]['labels'] is None: - break - - posData.segm_data[i] = posData.allData_li[i]['labels'] - posData.allData_li[i] = myutils.get_empty_stored_data_dict() - - posData.tracked_lost_centroids[i] = set() - posData.acdcTracker2stepsAnnotInfo.pop(i, None) - - if posData.acdc_df is not None: - frames = posData.acdc_df.index.get_level_values(0) - if from_frame_i in frames: - posData.acdc_df = posData.acdc_df.loc[:from_frame_i] - - self.removeAlldelROIsCurrentFrame() - - if not updateImages: - return - - self.updateAllImages() - - def resetAcceptedLostIDs(self, from_frame_i=None): - posData = self.data[self.pos_i] - if from_frame_i is None: - from_frame_i = posData.frame_i - - posData.tracked_lost_centroids[from_frame_i-1] = set() - for i in range(from_frame_i, posData.SizeT): - posData.tracked_lost_centroids[i] = set() - - def removeAllItems(self): - self.ax1.clear() - self.ax2.clear() - try: - self.chNamesQActionGroup.removeAction(self.userChNameAction) - except Exception as e: - pass - try: - posData = self.data[self.pos_i] - for action in self.fluoDataChNameActions: - self.chNamesQActionGroup.removeAction(action) - except Exception as e: - pass - try: - self.overlayButton.setChecked(False) - except Exception as e: - pass - - if hasattr(self, 'contoursImage'): - self.initContoursImage() - - def createUserChannelNameAction(self): - self.userChNameAction = QAction(self) - self.userChNameAction.setCheckable(True) - self.userChNameAction.setText(self.user_ch_name) - - def createChannelNamesActions(self): - # LUT histogram channel name context menu actions - self.chNamesQActionGroup = QActionGroup(self) - self.chNamesQActionGroup.addAction(self.userChNameAction) - posData = self.data[self.pos_i] - for action in self.fluoDataChNameActions: - self.chNamesQActionGroup.addAction(action) - action.setChecked(False) - - self.userChNameAction.setChecked(True) - - for action in self.overlayContextMenu.actions(): - action.setChecked(False) - - def restoreDefaultColors(self): - try: - color = self.defaultToolBarButtonColor - self.overlayButton.setStyleSheet(f'background-color: {color}') - except AttributeError: - # traceback.print_exc() - pass - - @exception_handler - def _createEmptyData(self): - self.MostRecentPath = self.getMostRecentPath() - exp_path = QFileDialog.getExistingDirectory( - self, - 'Select experiment folder where to create empty data', - self.MostRecentPath - ) - if not exp_path: - return - - pos_path = os.path.join(exp_path, 'Position_1') - images_path = os.path.join(pos_path, 'Images') - if os.path.exists(images_path): - raise FileExistsError(f'The following path already exists "{images_path}"') - - os.makedirs(images_path, exist_ok=True) - - basename = 'test_empty_' - tif_filename = f'{basename}channel_1.tif' - tif_filepath = os.path.join(images_path, tif_filename) - empty_img = np.zeros((256,256), dtype=np.uint8) - empty_img[0,0] = 255 - skimage.io.imsave(tif_filepath, empty_img) - - metadata_filename = f'{basename}metadata.csv' - metadata_filepath = os.path.join(images_path, metadata_filename) - df_metadata = pd.DataFrame({ - 'Description': ['basename'], - 'values': [basename] - }) - df_metadata.to_csv(metadata_filepath, index=False) - - self.isNewFile = True - self._openFolder(exp_path=images_path) - - - def segmNdimIndicatorClicked(self): - ndimText = self.segmNdimIndicator.text() - if ndimText == '2D': - alternativeNdimText = '3D' - toggleText = 'activate' - else: - alternativeNdimText = '2D' - toggleText = 'de-activate' - msg = widgets.myMessageBox(wrapText=False) - important_txt = (""" - The toggle to activate 3D segmentation is visible only when - the Number of z-slices is greater than 1. - """) - txt = html_utils.paragraph(f""" - This indicator shows that you are working with {ndimText} - segmentation masks.

- - If instead, you want to work with {alternativeNdimText} segmentation, - you need to initialize a new segmentation file.

- - To do so, go the menu on the top menubar File --> - New Segmentation File... and,
- at the dialog where you insert the metadata (Number of z-slices, - pixel size, etc.),
- {toggleText} the parameter called Work with 3D - segmentation masks (z-stack)
- as indicated in the screenshot below
. - {html_utils.to_admonition(important_txt, admonition_type='note')} -
- """) - msg.information( - self, 'Segmentation nmber of dimensions info', txt, - image_paths=':toggle_3D_screenshot.png' - ) - self.segmNdimIndicator.setChecked(True) - - def newFile(self): - self.newSegmEndName = '' - self.isNewFile = True - msg = widgets.myMessageBox(parent=self, showCentered=False) - msg.setWindowTitle('File or folder?') - msg.addText(html_utils.paragraph(f""" - Do you want to load an image file or Position - folder(s)? - """)) - loadPosButton = QPushButton('Load Position folder', msg) - loadPosButton.setIcon(QIcon(":folder-open.svg")) - loadFileButton = QPushButton('Load image file', msg) - loadFileButton.setIcon(QIcon(":image.svg")) - helpButton = widgets.helpPushButton('Help...') - msg.addButton(helpButton) - helpButton.disconnect() - helpButton.clicked.connect(self.helpNewFile) - msg.addCancelButton(connect=True) - msg.addButton(loadFileButton) - msg.addButton(loadPosButton) - loadPosButton.setDefault(True) - msg.exec_() - if msg.cancel: - return - - if msg.clickedButton == loadPosButton: - self._openFolder() - else: - self._openFile() - - def openNewWindow(self): - self.logger.info('Opening a new window...') - if self.launcherSlot is not None: - self.launcherSlot() - return - - winClass = self.__class__ - win = winClass( - self.app, parent=self, mainWin=self.mainWin, version=self._version - ) - win.run() - self.newWindows.append(win) - - def helpNewFile(self): - msg = widgets.myMessageBox(showCentered=False) - href = f'user manual' - txt = html_utils.paragraph(f""" - Cell-ACDC can open both a single image file or files structured - into Position folders.

- If you are just testing out you can load a single image file, but - in general we reccommend structuring your data into Position - folders.

- More info about Position folders in the {href} at the section - called "Create required data structure from microscopy file(s)". - """) - msg.information( - self, 'Help on Position folders', txt - ) - - def openFile(self, checked=False, file_path=None): - self.logger.info(f'Opening FILE "{file_path}"') - - self.isNewFile = False - self._openFile(file_path=file_path) - - def manageVersions(self): - posData = self.data[self.pos_i] - selectVersion = apps.SelectAcdcDfVersionToRestore(posData, parent=self) - selectVersion.exec_() - - if selectVersion.cancel: - return - - undoId = uuid.uuid4() - if posData.cca_df is not None: - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - - selectedTime = selectVersion.selectedTimestamp - - self.modeComboBox.setCurrentText('Viewer') - self.logger.info(f'Loading file from {selectedTime}...') - - acdc_df = load.read_acdc_df_from_archive( - selectVersion.archiveFilePath, selectVersion.selectedKey - ) - posData.acdc_df = acdc_df - frames = acdc_df.index.get_level_values(0) - last_visited_frame_i = frames.max() - current_frame_i = posData.frame_i - pbar = tqdm(total=last_visited_frame_i+1, ncols=100) - for frame_i in range(last_visited_frame_i+1): - posData.frame_i = frame_i - self.get_data() - if posData.cca_df is not None: - self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - if posData.allData_li[frame_i]['labels'] is None: - pbar.update() - continue - - if frame_i not in frames: - acdc_df_i = pd.DataFrame(columns=acdc_df.columns) - acdc_df_i.drop(self.cca_df_colnames, axis=1, errors='ignore') - acdc_df_i.index.name = 'Cell_ID' - else: - acdc_df_i = acdc_df.loc[frame_i].dropna(axis=1, how='all') - - posData.allData_li[frame_i]['acdc_df'] = acdc_df_i - pbar.update() - pbar.close() - - # Back to current frame - posData.frame_i = current_frame_i - self.get_data(debug=False) - self.updateAllImages() - self.logger.info('Annotations correctly recovered.') - - def askUserChannelName(self, filename_no_ext, ext): - help_txt = html_utils.paragraph(f""" - Cell-ACDC requires that every image file has a basename and some - additional text, typically the channel name.

- The basename will be common to all created files, while the additional text is used to identify the image files. - """) - - basename = filename_no_ext - underscore_splits = filename_no_ext.split('_') - if len(underscore_splits) > 1: - channel_name = underscore_splits[-1] - basename = '_'.join(underscore_splits[:-1]) - else: - channel_name = 'channel_1' - - txt = html_utils.paragraph(f""" - Provide some text (e.g., the channel name) to append at the end of the image file. - """) - win = apps.filenameDialog( - basename=basename, - ext=ext, - hintText=txt, - defaultEntry=channel_name, - helpText=help_txt, - allowEmpty=False, - parent=self, - title='Provide channel name for image file', - ) - win.exec_() - if win.cancel: - return False, '' - - return True, win.entryText - - def warnUserCreationImagesFolder(self, images_path, ext): - msg = widgets.myMessageBox(wrapText=False) - txt = (f""" - Cell-ACDC requires a specific folder structure to load the data.

- Specifically, it requires the image(s) to be located in a - folder called Images.

- The file format of the images must be TIFF or NPZ - (.tif or .npz extension).

- You can choose to let Cell-ACDC create the required data structure - from your file,
- or you can stop the - process and manually place the image(s) into a folder called - Images.

- If you choose to proceed, Cell-ACDC will create the following - folder: - {images_path} -
- """) - - if ext == '.tif' or ext == '.npz': - txt = f'{txt}How do you want to proceed?' - else: - txt = f'{txt}Do you want to proceed?' - txt = html_utils.paragraph(txt) - - if ext == '.tif' or ext == '.npz': - copyButton = widgets.copyPushButton( - 'Copy the image into the new folder' - ) - moveButton = widgets.movePushButton( - 'Move the image into the new folder' - ) - _, copyButton, moveButton = msg.information( - self, 'Creating Images folder', txt, - buttonsTexts=('Cancel', copyButton, moveButton) - ) - if msg.cancel: - return False, None - - if msg.clickedButton == copyButton: - return True, True - elif msg.clickedButton == moveButton: - return True, False - - else: - msg.information( - self, 'Creating Images folder', txt, - buttonsTexts=('Cancel', 'Yes, proceed') - ) - if msg.cancel: - return False, None - - return True, True - - @exception_handler - def _openFile(self, file_path=None): - """ - Function used for loading an image file directly. - """ - if file_path is None: - self.MostRecentPath = self.getMostRecentPath() - file_path = QFileDialog.getOpenFileName( - self, 'Select image file', self.MostRecentPath, - "Image/Video Files (*.png *.tif *.tiff *.jpg *.jpeg *.mov *.avi *.mp4)" - ";;All Files (*)")[0] - if not file_path: - return - - filename, ext = os.path.splitext(os.path.basename(file_path)) - ext = ext.lower() - dirpath = os.path.dirname(file_path) - dirname = os.path.basename(dirpath) - filename = filename.rstrip('_') - channel_name = None - do_copy = True - if dirname != 'Images': - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - acdc_folder = f'{timestamp}_acdc' - exp_path = os.path.join(dirpath, acdc_folder, 'Images') - proceed, do_copy = self.warnUserCreationImagesFolder(exp_path, ext) - if not proceed: - self.logger.info('Loading image file cancelled.') - return - - proceed, channel_name = self.askUserChannelName( - filename, '.tif' - ) - if not proceed: - self.logger.info('Loading image file cancelled.') - return - - os.makedirs(exp_path, exist_ok=True) - else: - exp_path = dirpath - - if channel_name is not None: - # Check if user wants to use the existing channel name - underscore_splits = filename.split('_') - if len(underscore_splits) > 1: - default_ch_name = underscore_splits[-1] - if channel_name == default_ch_name: - filename = '_'.join(underscore_splits[:-1]) - - basename = f'{filename}_' - new_filename = f'{filename}_{channel_name}{ext}' - df_metadata = pd.DataFrame({ - 'Description': ['basename'], - 'values': [basename] - }) - metadata_csv_filename = f'{basename}metadata.csv' - metadata_csv_filepath = os.path.join( - exp_path, metadata_csv_filename - ) - df_metadata.to_csv(metadata_csv_filepath, index=False) - else: - new_filename = f'{filename}{ext}' - - if do_copy: - action_text = 'Copying' - else: - action_text = 'Moving' - - if ext == '.tif' or ext == '.npz': - new_filepath = os.path.join(exp_path, new_filename) - if not os.path.exists(new_filepath): - self.logger.info(f'{action_text} file to Images folder...') - if do_copy: - shutil.copy2(file_path, new_filepath) - else: - shutil.move(file_path, new_filepath) - self._openFolder(exp_path=exp_path, imageFilePath=new_filepath) - else: - self.logger.info(f'{action_text} file to .tif format...') - data = load.loadData(file_path, '', log_func=self.logger.info) - data.loadImgData() - img = data.img_data - if img.ndim == 3 and (img.shape[-1] == 3 or img.shape[-1] == 4): - self.logger.info('Converting RGB image to grayscale...') - if img.shape[-1] == 3: - data.img_data = skimage.color.rgb2gray(data.img_data) - else: - data.img_data = cv2.cvtColor( - data.img_data, cv2.COLOR_RGBA2GRAY - ) - data.img_data = skimage.img_as_ubyte(data.img_data) - new_filename_no_ext, ext = os.path.splitext(new_filename) - tif_filename = f'{new_filename_no_ext}.tif' - tif_path = os.path.join(exp_path, tif_filename) - if data.img_data.ndim == 3: - SizeT = data.img_data.shape[0] - SizeZ = 1 - elif data.img_data.ndim == 4: - SizeT = data.img_data.shape[0] - SizeZ = data.img_data.shape[1] - else: - SizeT = 1 - SizeZ = 1 - is_imageJ_dtype = ( - data.img_data.dtype == np.uint8 - or data.img_data.dtype == np.uint32 - or data.img_data.dtype == np.uint32 - or data.img_data.dtype == np.float32 - ) - if not is_imageJ_dtype: - data.img_data = skimage.img_as_ubyte(data.img_data) - - myutils.to_tiff(tif_path, data.img_data) - self._openFolder(exp_path=exp_path, imageFilePath=tif_path) - - def criticalNoTifFound(self, images_path): - err_title = 'No .tif files found in folder.' - err_msg = html_utils.paragraph( - 'The following folder

' - f'{images_path}

' - 'does not contain .tif or .h5 files.

' - 'Only .tif or .h5 files can be loaded with "Open Folder" button.

' - 'Try with File --> Open image/video file... ' - 'and directly select the file you want to load.' - ) - msg = widgets.myMessageBox() - msg.addShowInFileManagerButton(images_path) - msg.critical(self, err_title, err_msg) - - def reinitStoredSegmModels(self): - self.models = [None]*len(self.models) - - def checkAskSavePointsLayers(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): - continue - if action.layerTypeIdx != 4: - continue - - scatterItem = action.scatterItem - xx, yy = scatterItem.getData() - - if xx is None or len(xx) == 0: - toolButton = action.button - tableEndName = toolButton.clickEntryTableEndName - # Check in other loaded pos - are_there_points_to_save = False - for pos_i, _posData in enumerate(self.data): - if pos_i == self.pos_i: - continue - - df = _posData.clickEntryPointsDfs.get(tableEndName) - if df is None: - continue - - are_there_points_to_save = True - break - - if not are_there_points_to_save: - continue - - cancel = self.askSavePointsLayer(action) - if cancel: - return cancel - - return False - - def askSavePointsLayer(self, action): - toolButton = action.button - tableEndName = toolButton.clickEntryTableEndName - saveAction = toolButton.saveAction - - txt = html_utils.paragraph(f""" - Do you want to save the points you added - (table called {tableEndName}.csv)? - """ - ) - msg = widgets.myMessageBox(wrapText=False) - _, _, saveButton = msg.question( - self, 'Save points layer?', txt, - buttonsTexts=('Cancel', 'No, do not save', 'Yes, save points') - ) - if msg.clickedButton == saveButton: - self.savePointsAddedByClicking(saveAction.saveToolbutton, None) - - return msg.cancel - - def removeOverlayItems(self): - self.lutItemsLayout.clear() - - try: - for toolbutton in self.allOverlayToolbuttonsByIdx.values(): - self.overlayToolbar.removeAction(toolbutton.action) - - self.overlayToolbuttonsSep.removeFromToolbar() - except Exception as err: - pass - - def clearOverlayImageItems(self): - for items in self.overlayLayersItems.values(): - imageItem = items[0] - imageItem.clear() - - self.rgbaImg1.clear() - - def reInitGui(self): - cancel = self.checkAskSavePointsLayers() - if cancel: - return False - - if self.overlayToolbar.isTransparent(): - self.overlayToolbar.setTransparent(False) - - self.secondLevelToolbar.setVisible(False) - - self.gui_createLazyLoader() - - try: - self.navSpinBox.valueChanged.disconnect() - except Exception as e: - pass - - try: - self.scaleBar.removeFromAxis(self.ax1) - except Exception as e: - pass - - self.lineage_tree = None - self.getDistanceListMissingIDsCachedFrame = None - self.isZmodifier = False - self.zKeptDown = False - self.askRepeatSegment3D = True - self.askZrangeSegm3D = True - self.isDataLoaded = False - self.retainSizeLutItems = False - self.setMeasWinState = None - self.addPointsWin = None - self.delRoiLab = None - self.showPropsDockButton.setDisabled(True) - self.removeOverlayItems() - self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) - - self.reinitWidgetsPos() - self.removeAllItems() - self.reinitCustomAnnot() - self.reinitPointsLayers() - self.gui_createPlotItems() - self.setUncheckedAllButtons() - self.setUncheckedPointsLayers() - self.restoreDefaultColors() - self.reinitStoredSegmModels() - self.removeAxLimits() - self.curvToolButton.setChecked(False) - - self.wandControlsToolbar.setVisible(False) - self.wandToolButton.setChecked(False) - self.segmNdimIndicatorAction.setVisible(False) - - self.navigateToolBar.hide() - self.ccaToolBar.hide() - self.editToolBar.hide() - self.brushEraserToolBar.hide() - self.modeToolBar.hide() - - self.modeComboBox.setCurrentText('Viewer') - - alpha = self.imgGrad.labelsAlphaSlider.value() - self.labelsLayerImg1.setOpacity(alpha) - self.labelsLayerRightImg.setOpacity(alpha) - self.lastTrackedFrameLabel.setText('') - - self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False - - for action in self.askHowFutureFramesActions.values(): - action.setChecked(True) - action.setDisabled(True) - - return True - - def reinitPointsLayers(self): - for toolbar in self.pointsLayersToolbars: - for action in toolbar.actions()[1:]: - toolbar.removeAction(action) - toolbar.setVisible(False) - self.autoPilotZoomToObjToolbar.setVisible(False) - - def reinitWidgetsPos(self): - pass - # try: - # # self.highlightZneighObjCheckbox will be connected in - # # self.showHighlightZneighCheckbox() - # self.highlightZneighObjCheckbox.toggled.disconnect() - # except Exception as e: - # pass - # layout = self.bottomLeftLayout - # self.highlightZneighObjCheckbox.hide() - # try: - # layout.removeWidget(self.highlightZneighObjCheckbox) - # except Exception as e: - # pass - # self.highlightZneighObjCheckbox.hide() - # # layout.addWidget( - # # self.drawIDsContComboBox, 0, 1, 1, 2, - # # alignment=Qt.AlignCenter - # # ) - - def reinitCustomAnnot(self): - buttons = list(self.customAnnotDict.keys()) - for button in buttons: - self.clearScatterPlotCustomAnnotButton(button) - action = self.customAnnotDict[button]['action'] - self.annotateToolbar.removeAction(action) - self.checkableQButtonsGroup.removeButton(button) - self.customAnnotDict.pop(button) - # self.savedCustomAnnot.pop(name) - - self.saveCustomAnnot(only_temp=True) - - def loadingDataAborted(self): - self.openFolderAction.setEnabled(True) - self.titleLabel.setText('Loading data aborted.') - - def cleanUpOnError(self): - self.onEscape() - caller = 'Cell-ACDC' - if self.module.startswith('spotmax'): - caller = 'spotMAX' - txt = f'WARNING: {caller} is in error state. Please, restart.' - _hl = '*'*100 - self.titleLabel.setText(txt, color='r') - self.logger.info(f'{_hl}\n{txt}\n{_hl}') - - def openFolder( - self, checked=False, exp_path=None, imageFilePath='' - ): - if exp_path is None: - self.logger.info('Asking to select a folder path...') - else: - self.logger.info(f'Opening FOLDER "{exp_path}"...') - - self.isNewFile = False - if hasattr(self, 'data') and self.titleLabel.text != 'Saved!': - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Do you want to save before loading another dataset?' - ) - _, no, yes = msg.question( - self, 'Save?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') - ) - if msg.clickedButton == yes: - func = partial(self._openFolder, exp_path, imageFilePath) - cancel = self.saveData(finishedCallback=func) - return - elif msg.cancel: - self.store_data() - return - else: - self.store_data(autosave=False) - - self._openFolder( - exp_path=exp_path, imageFilePath=imageFilePath - ) - - def addToRecentPaths(self, path, logger=None): - myutils.addToRecentPaths(path, logger=self.logger) - - def getMostRecentPath(self): - return myutils.getMostRecentPath() - - @exception_handler - def _openFolder( - self, checked=False, exp_path=None, imageFilePath='' - ): - """Main function to load data. - - Parameters - ---------- - checked : bool - kwarg needed because openFolder can be called by openFolderAction. - exp_path : string or None - Path selected by the user either directly, through openFile, - or drag and drop image file. - imageFilePath : string - Path of the image file that was either drag and dropped or opened - from File --> Open image/video file (openFileAction). - - Returns - ------- - None - """ - - if exp_path is None: - self.MostRecentPath = self.getMostRecentPath() - exp_path = QFileDialog.getExistingDirectory( - self, - 'Select experiment folder containing Position_n folders ' - 'or specific Position_n folder', - self.MostRecentPath - ) - - if not exp_path: - self.openFolderAction.setEnabled(True) - return - - proceed = self.reInitGui() - if not proceed: - self.openFolderAction.setEnabled(True) - return - - self.openFolderAction.setEnabled(False) - - if self.slideshowWin is not None: - self.slideshowWin.close() - - if self.ccaTableWin is not None: - self.ccaTableWin.close() - - self.exp_path = exp_path - self.logger.info(f'Loading from {self.exp_path}') - self.addToRecentPaths(exp_path, logger=self.logger) - self.addPathToOpenRecentMenu(exp_path) - - folder_type = myutils.determine_folder_type(exp_path) - is_pos_folder, is_images_folder, exp_path = folder_type - - self.titleLabel.setText('Loading data...', color=self.titleColor) - - skip_channels = [] - ch_name_selector = prompts.select_channel_name( - which_channel='segm', allow_abort=False - ) - user_ch_name = None - if not is_pos_folder and not is_images_folder and not imageFilePath: - images_paths = self._loadFromExperimentFolder(exp_path) - if not images_paths: - self.loadingDataAborted() - return - - elif is_pos_folder and not imageFilePath: - pos_foldername = os.path.basename(exp_path) - exp_path = os.path.dirname(exp_path) - images_paths = [os.path.join(exp_path, pos_foldername, 'Images')] - - elif is_images_folder and not imageFilePath: - images_paths = [exp_path] - pos_path = os.path.dirname(exp_path) - exp_path = os.path.dirname(pos_path) - - elif imageFilePath: - # images_path = exp_path because called by openFile func - filenames = myutils.listdir(exp_path) - ch_names, basenameNotFound = ( - ch_name_selector.get_available_channels(filenames, exp_path) - ) - filename = os.path.basename(imageFilePath) - self.ch_names = ch_names - user_ch_name = [ - chName for chName in ch_names if filename.find(chName)!=-1 - ][0] - images_paths = [exp_path] - pos_path = os.path.dirname(exp_path) - exp_path = os.path.dirname(pos_path) - - self.images_paths = images_paths - - # Get info from first position selected - images_path = self.images_paths[0] - filenames = myutils.listdir(images_path) - if ch_name_selector.is_first_call and user_ch_name is None: - ch_names, _ = ch_name_selector.get_available_channels( - filenames, images_path - ) - self.ch_names = ch_names - if not ch_names: - self.openFolderAction.setEnabled(True) - self.criticalNoTifFound(images_path) - return - if len(ch_names) > 1: - CbLabel='Select channel name to load: ' - ch_name_selector.QtPrompt( - self, ch_names, CbLabel=CbLabel - ) - if ch_name_selector.was_aborted: - self.openFolderAction.setEnabled(True) - return - skip_channels.extend([ - ch for ch in ch_names if ch!=ch_name_selector.channel_name - ]) - else: - ch_name_selector.channel_name = ch_names[0] - ch_name_selector.setUserChannelName() - user_ch_name = ch_name_selector.user_ch_name - else: - # File opened directly with self.openFile - ch_name_selector.channel_name = user_ch_name - - user_ch_file_paths = [] - not_allowed_ends = ['btrack_tracks.h5'] - for images_path in self.images_paths: - channel_file_path = load.get_filename_from_channel( - images_path, user_ch_name, skip_channels=skip_channels, - not_allowed_ends=not_allowed_ends, logger=self.logger.info - ) - if not channel_file_path: - self.criticalImgPathNotFound(images_path) - return - user_ch_file_paths.append(channel_file_path) - - ch_name_selector.setUserChannelName() - self.user_ch_name = user_ch_name - self.img1.channelName = user_ch_name - - self.AutoPilotProfile.storeSelectedChannel(self.user_ch_name) - - self.initGlobalAttr() - self.createOverlayContextMenu() - self.createUserChannelNameAction() - self.gui_createOverlayColors() - self.gui_createOverlayItems() - lastRow = self.bottomLeftLayout.rowCount() - self.bottomLeftLayout.setRowStretch(lastRow+1, 1) - - self.num_pos = len(user_ch_file_paths) - proceed = self.loadSelectedData(user_ch_file_paths, user_ch_name) - if not proceed: - self.openFolderAction.setEnabled(True) - return - - def _loadFromExperimentFolder(self, exp_path): - select_folder = load.select_exp_folder() - values = select_folder.get_values_segmGUI(exp_path) - if not values: - self.criticalInvalidPosFolder(exp_path) - self.openFolderAction.setEnabled(True) - return [] - - if len(values) > 1: - select_folder.QtPrompt(self, values, allow_cancel=False) - if select_folder.cancel: - return [] - else: - select_folder.cancel = False - select_folder.selected_pos = select_folder.pos_foldernames - - images_paths = [] - for pos in select_folder.selected_pos: - images_paths.append(os.path.join(exp_path, pos, 'Images')) - return images_paths - - def criticalInvalidPosFolder(self, exp_path): - href = html_utils.href_tag('here', data_structure_docs_url) - txt = html_utils.paragraph(f""" - The selected folder:

- - {exp_path}

- - is not a valid folder.

- - Select a folder that contains the Position_n folders, - or a specific Position.

- - If you are trying to load a single image file go to - File --> Open image/video file....

- - To load a folder containing multiple .tif files the folder must - be called either Position_n
- (with n being an integer) or Images.

- - For more information about the correct folder structure see {href}. - """) - msg = widgets.myMessageBox(wrapText=False) - helpButton = widgets.helpPushButton('Help...') - msg.addButton(helpButton) - helpButton.clicked.disconnect() - helpButton.clicked.connect( - partial(myutils.browse_url, data_structure_docs_url) - ) - msg.addShowInFileManagerButton(exp_path) - msg.critical( - self, 'Incompatible folder', txt - ) - - def createOverlayContextMenu(self): - ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] - self.overlayContextMenu = QMenu() - self.overlayContextMenu.addSeparator() - self.checkedOverlayChannels = set() - for chName in ch_names: - action = QAction(chName, self.overlayContextMenu) - action.setCheckable(True) - action.toggled.connect(self.overlayChannelToggled) - self.overlayContextMenu.addAction(action) - - def createOverlayLabelsContextMenu(self, segmEndnames): - self.overlayLabelsContextMenu = QMenu() - self.overlayLabelsContextMenu.addSeparator() - self.drawModeOverlayLabelsChannels = {} - segmEndnames_extended = list(segmEndnames.copy()) - segmEndnames_extended = ['combined segm.'] + segmEndnames_extended - for segmEndname in segmEndnames_extended: - action = QAction(segmEndname, self.overlayLabelsContextMenu) - if segmEndname == 'combined segm.': - action.setCheckable(False) - self.combineSegmViewToggle = action - else: - action.setCheckable(True) - action.toggled.connect(self.addOverlayLabelsToggled) - self.overlayLabelsContextMenu.addAction(action) - - self.overlayLabelsContextMenu.addSeparator() - action = QAction('Edit appearance...', self.overlayLabelsContextMenu) - action.triggered.connect(self.editOverlayLabelsAppearance) - self.overlayLabelsContextMenu.addAction(action) - - def editOverlayLabelsAppearance(self, *args): - segmEndname = list(self.overlayLabelsItems.keys())[0] - contoursItem = self.overlayLabelsItems[segmEndname][1] - win = apps.OverlayLabelsAppearanceDialog( - scatterPlotItem=contoursItem, parent=self - ) - win.exec_() - if win.cancel: - return - - brush = win.properties['brush'] - pen = win.properties['pen'] - for items in self.overlayLabelsItems.values(): - imageItem, contoursItem, gradItem = items - contoursItem.setBrush(brush, update=False) - contoursItem.setPen(pen) - - def createOverlayLabelsItems(self, segmEndnames): - selectActionGroup = QActionGroup(self) - segmEndnames_extended = list(segmEndnames.copy()) - segmEndnames_extended = ['combined segm.'] + segmEndnames_extended - for segmEndname in segmEndnames_extended: - action = QAction(segmEndname) - if segmEndname == 'combined segm.': - action.setCheckable(False) - else: - action.setCheckable(True) - action.toggled.connect(self.setOverlayLabelsItemsVisible) - selectActionGroup.addAction(action) - self.selectOverlayLabelsActionGroup = selectActionGroup - - self.overlayLabelsItems = {} - for segmEndname in segmEndnames_extended: - imageItem = pg.ImageItem() - - gradItem = widgets.overlayLabelsGradientWidget( - imageItem, selectActionGroup, segmEndname - ) - gradItem.hide() - gradItem.drawModeActionGroup.triggered.connect( - self.overlayLabelsDrawModeToggled - ) - self.mainLayout.addWidget(gradItem, 0, 0) - - contoursItem = pg.ScatterPlotItem() - color = colors.get_complementary_color(self.contLineColor) - r, g, b, a = colors.rgba_str_to_values(color) - qcolor = QColor(r, g, b, a) - contoursItem.setData( - [], [], symbol='s', pxMode=False, size=self.contLineWeight*2, - brush=pg.mkBrush(color=qcolor), - pen=pg.mkPen(width=3, color=qcolor), tip=None - ) - - items = (imageItem, contoursItem, gradItem) - self.overlayLabelsItems[segmEndname] = items - - def addOverlayLabelsToggled(self, checked, name=None): - if name is None: - name = self.sender().text() - if checked: - gradItem = self.overlayLabelsItems[name][-1] - drawMode = gradItem.drawModeActionGroup.checkedAction().text() - self.drawModeOverlayLabelsChannels[name] = drawMode - else: - self.drawModeOverlayLabelsChannels.pop(name) - self.hideOverlayLabelsItems(specific=[name]) - self.setOverlayLabelsItems() - - def overlayLabelsDrawModeToggled(self, action): - segmEndname = action.segmEndname - drawMode = action.text() - if segmEndname in self.drawModeOverlayLabelsChannels: - self.drawModeOverlayLabelsChannels[segmEndname] = drawMode - self.setOverlayLabelsItems() - - def overlayChannelToggled(self, checked): - # Action toggled from overlayButton context menu - channelName = self.sender().text() - posData = self.data[self.pos_i] - if checked: - if channelName not in posData.loadedFluoChannels: - self.loadOverlayData([channelName], addToExisting=True) - else: - _, filename = self.getPathFromChName(channelName, posData) - posData.ol_data[filename] = ( - posData.ol_data_dict[filename].copy() - ) - - self.checkedOverlayChannels.add(channelName) - else: - self.checkedOverlayChannels.remove(channelName) - imageItem = self.overlayLayersItems[channelName][0] - imageItem.clear() - - self.setOverlayChannelsToolbuttonsChecked() - self.setOverlayItemsVisible() - self.setRetainSizePolicyLutItems() - self.updateAllImages() - - @exception_handler - def loadDataWorkerDataIntegrityWarning(self, pos_foldername): - err_msg = ( - 'WARNING: Segmentation mask file ("..._segm.npz") not found. ' - 'You could run segmentation module first.' - ) - self.workerProgress(err_msg, 'INFO') - self.titleLabel.setText(err_msg, color='r') - abort = False - msg = widgets.myMessageBox(parent=self) - warn_msg = html_utils.paragraph(f""" - The folder {pos_foldername} does not contain a - pre-computed segmentation mask.

- You can continue with a blank mask or cancel and - pre-compute the mask with the segmentation module.

- Do you want to continue? - """) - msg.setIcon(iconName='SP_MessageBoxWarning') - msg.setWindowTitle('Segmentation file not found') - msg.addText(warn_msg) - msg.addButton('Ok') - continueWithBlankSegm = msg.addButton(' Cancel ') - msg.show(block=True) - if continueWithBlankSegm == msg.clickedButton: - abort = True - self.loadDataWorker.abort = abort - self.loadDataWaitCond.wakeAll() - - def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): - total_ram = myutils._bytes_to_GB(total_ram) - available_ram = myutils._bytes_to_GB(available_ram) - required_ram = myutils._bytes_to_GB(required_ram) - required_perc = round(100*required_ram/available_ram) - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - The total amount of data that you requested to load is about - {required_ram:.2f} GB ({required_perc}% of the available memory) - but there are only {available_ram:.2f} GB available.

- For optimal operation, we recommend loading maximum 30% - of the available memory. To do so, try to close open apps to - free up some memory. Another option is to crop the images - using the data prep module.

- If you choose to continue, the system might freeze - or your OS could simply kill the process.

- What do you want to do? - """) - cancelButton, continueButton = msg.warning( - self, 'Memory not sufficient', txt, - buttonsTexts=('Cancel', 'Continue anyway') - ) - if msg.clickedButton == continueButton: - # Disable autosaving since it would keep a copy of the data and - # we cannot afford it with low memory - self.autoSaveToggle.setChecked(False) - return True - else: - return False - - def checkMemoryRequirements(self, required_ram): - memory = psutil.virtual_memory() - total_ram = memory.total - available_ram = memory.available - if required_ram/available_ram > 0.3: - proceed = self.warnMemoryNotSufficient( - total_ram, available_ram, required_ram - ) - return proceed - else: - return True - - def criticalImgPathNotFound(self, images_path): - self.logger.info( - 'The following folder does not contain valid image files: ' - f'"{images_path}"\n\n' - 'Check that all the positions loaded contain the same channel name. ' - 'Make sure to double check for spelling mistakes or types in the ' - 'channel names.' - ) - msg = widgets.myMessageBox() - msg.addShowInFileManagerButton(images_path) - err_msg = html_utils.paragraph(f""" - The folder

- {images_path}

- does not contain any valid image file!

- Valid file formats are .h5, .tif, _aligned.h5, _aligned.npz. - """) - okButton = msg.critical( - self, 'No valid files found!', err_msg, buttonsTexts=('Ok',) - ) - - def initRealTimeTracker(self, force=False): - for rtTrackerAction in self.trackingAlgosGroup.actions(): - if rtTrackerAction.isChecked(): - break - - aliases = myutils.aliases_real_time_trackers(reverse=True) - - rtTracker = rtTrackerAction.text() - rtTracker_txt = rtTracker - - if rtTracker in aliases: - rtTracker = aliases[rtTracker] - - if rtTracker == 'Cell-ACDC': - return - if rtTracker == 'YeaZ': - return - - if self.isRealTimeTrackerInitialized and not force: - return - - self.logger.info(f'Initializing {rtTracker_txt} tracker...') - self._rtTrackerName = rtTracker - posData = self.data[self.pos_i] - realTimeTracker, track_frame_params = myutils.init_tracker( - posData, rtTracker, qparent=self, realTime=True - ) - if realTimeTracker is None: - self.logger.info(f'{rtTracker} tracker initialization cancelled.') - return - - self.realTimeTracker = realTimeTracker - self.track_frame_params = track_frame_params - self.logger.info(f'{rtTracker} tracker successfully initialized.') - if 'image_channel_name' in self.track_frame_params: - # Remove the channel name since it was already loaded in init_tracker - del self.track_frame_params['image_channel_name'] - - def initFluoData(self): - if len(self.ch_names) <= 1: - return - - if 'ask_load_fluo_at_init' in self.df_settings.index: - if self.df_settings.at['ask_load_fluo_at_init', 'value'] == 'No': - return - msg = widgets.myMessageBox(allowClose=False) - txt = ( - 'Do you also want to load fluorescence images?
' - 'You can load as many channels as you want.

' - 'If you load fluorescence images then the software will ' - 'calculate metrics for each loaded fluorescence channel ' - 'such as min, max, mean, quantiles, etc. ' - 'of each segmented object.

' - 'NOTE: You can always load them later from the menu ' - 'File --> Load fluorescence images... or when you set ' - 'measurements from the menu ' - 'Measurements --> Set measurements...' - ) - msg.addDoNotShowAgainCheckbox(text="Don't ask again") - no, yes = msg.question( - self, 'Load fluorescence images?', html_utils.paragraph(txt), - buttonsTexts=('No', 'Yes') - ) - if msg.doNotShowAgainCheckbox.isChecked(): - self.df_settings.at['ask_load_fluo_at_init', 'value'] = 'No' - self.df_settings.to_csv(self.settings_csv_path) - if msg.clickedButton == yes: - self.loadFluo_cb(None) - self.AutoPilotProfile.storeClickMessageBox( - 'Load fluorescence images?', msg.clickedButton.text() - ) - - def getPathFromChName(self, chName, posData): - ls = myutils.listdir(posData.images_path) - endnames = {f[len(posData.basename):]:f for f in ls} - validEnds = ['_aligned.npz', '_aligned.h5', '.h5', '.tif', '.npz'] - for end in validEnds: - files = [ - filename for endname, filename in endnames.items() - if endname == f'{chName}{end}' - ] - if files: - filename = files[0] - break - else: - self.criticalFluoChannelNotFound(chName, posData) - self.app.restoreOverrideCursor() - return None, None - - fluo_path = os.path.join(posData.images_path, filename) - filename, _ = os.path.splitext(filename) - return fluo_path, filename - - def loadPosTriggered(self): - if not self.isDataLoaded: - return - - self.startAutomaticLoadingPos() - - def startAutomaticLoadingPos(self): - self.AutoPilot = autopilot.AutoPilot(self) - self.AutoPilot.execLoadPos() - - def stopAutomaticLoadingPos(self): - if self.AutoPilot is None: - return - - if self.AutoPilot.timer.isActive(): - self.AutoPilot.timer.stop() - self.AutoPilot = None - - def startCcaIntegrityCheckerWorker(self): - if not hasattr(self, 'data'): - return - - if not self.isDataLoaded: - return - - if not self.ccaIntegrCheckerToggle.isChecked(): - return - - ccaCheckerThread = QThread() - self.ccaCheckerMutex = QMutex() - self.ccaCheckerWaitCond = QWaitCondition() - - worker = workers.CcaIntegrityCheckerWorker( - self.ccaCheckerMutex, self.ccaCheckerWaitCond - ) - self.ccaIntegrityCheckerWorker = worker - self.ccaCheckerThread = ccaCheckerThread - - worker.moveToThread(ccaCheckerThread) - worker.finished.connect(ccaCheckerThread.quit) - worker.finished.connect(worker.deleteLater) - ccaCheckerThread.finished.connect(ccaCheckerThread.deleteLater) - - worker.sigDone.connect(self.ccaCheckerWorkerDone) - worker.progress.connect(self.workerProgress) - worker.critical.connect(self.ccaIntegrityWorkerCritical) - worker.finished.connect(self.ccaCheckerWorkerClosed) - worker.sigWarning.connect(self.warnCcaIntegrity) - worker.sigFixWillDivide.connect(self.fixWillDivide) - - ccaCheckerThread.started.connect(worker.run) - ccaCheckerThread.start() - - self.ccaCheckerRunning = True - - self.initCcaIntegrityChecker() - - self.logger.info('Cell cycle annotations integrity checker started.') - - def initCcaIntegrityChecker(self): - posData = self.data[self.pos_i] - for frame_i, data_frame_i in enumerate(posData.allData_li): - lab = data_frame_i['labels'] - if lab is None: - break - - cca_df = self.get_cca_df(frame_i, return_df=True) - self.store_cca_df_checker(posData, frame_i, cca_df) - - self.enqCcaIntegrityChecker() - - def initCcaIntegrityChecker(self): - posData = self.data[self.pos_i] - for frame_i, data_frame_i in enumerate(posData.allData_li): - lab = data_frame_i['labels'] - if lab is None: - break - - cca_df = self.get_cca_df(frame_i, return_df=True) - self.store_cca_df_checker(posData, frame_i, cca_df) - - self.enqCcaIntegrityChecker() - - def disableCcaIntegrityChecker(self): - self.stopCcaIntegrityCheckerWorker() - - def stopCcaIntegrityCheckerWorker(self): - try: - self.ccaIntegrityCheckerWorker._stop() - except Exception as err: - pass - - def loadFluo_cb(self, checked=True, fluo_channels=None): - if fluo_channels is None: - posData = self.data[self.pos_i] - ch_names = [ - ch for ch in self.ch_names if ch != self.user_ch_name - and ch not in posData.loadedFluoChannels - ] - if not ch_names: - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'You already loaded ALL channels.

' - 'To change the overlaid channel ' - 'right-click on the overlay button.' - ) - msg.information(self, 'All channels are loaded', txt) - return False - selectFluo = widgets.QDialogListbox( - 'Select channel to load', - 'Select channel names to load:\n', - ch_names, multiSelection=True, parent=self - ) - selectFluo.exec_() - - if selectFluo.cancel: - return False - - fluo_channels = selectFluo.selectedItemsText - self.AutoPilotProfile.storeLoadedFluoChannels(fluo_channels) - - for p, posData in enumerate(self.data): - # posData.ol_data = None - for fluo_ch in fluo_channels: - fluo_path, filename = self.getPathFromChName(fluo_ch, posData) - if fluo_path is None: - self.criticalFluoChannelNotFound(fluo_ch, posData) - return False - fluo_data, bkgrData = self.load_fluo_data(fluo_path) - if fluo_data is None: - return False - posData.loadedFluoChannels.add(fluo_ch) - - if posData.SizeT == 1: - fluo_data = fluo_data[np.newaxis] - - posData.fluo_data_dict[filename] = fluo_data - posData.fluo_bkgrData_dict[filename] = bkgrData - posData.ol_data_dict[filename] = fluo_data.copy() - - self.overlayButton.setStyleSheet(f'background-color: {GREEN_HEX}') - self.guiTabControl.addChannels([ - posData.user_ch_name, *posData.loadedFluoChannels - ]) - return True - - def labelRoiCancelled(self): - self.labelRoiRunning = False - self.app.restoreOverrideCursor() - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) - self.freeRoiItem.clear() - self.logger.info('Magic labeller process cancelled.') - - def labelRoiCheckStartStopFrame(self): - if not self.labelRoiTrangeCheckbox.isChecked(): - return True - - start_n = self.labelRoiStartFrameNoSpinbox.value() - stop_n = self.labelRoiStopFrameNoSpinbox.value() - if start_n <= stop_n: - return True - - self.blinker = qutils.QControlBlink( - self.labelRoiStopFrameNoSpinbox, - qparent=self - ) - self.blinker.start() - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - Stop frame number is less than start frame number!

- What do you want to do? - """) - msg.warning( - self, 'Stop frame number lower than start', txt, - buttonsTexts=('Cancel', 'Segment only current frame') - ) - if msg.cancel: - return False - - posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) - self.labelRoiStopFrameNoSpinbox.setValue(posData.frame_i+1) - - def getSecondChannelData(self): - if self.secondChannelName is None: - return - - posData = self.data[self.pos_i] - - fluo_ch = self.secondChannelName - fluo_path, filename = self.getPathFromChName(fluo_ch, posData) - if filename in posData.fluo_data_dict: - fluo_data = posData.fluo_data_dict[filename] - else: - fluo_data, bkgrData = self.load_fluo_data(fluo_path) - posData.fluo_data_dict[filename] = fluo_data - posData.fluo_bkgrData_dict[filename] = bkgrData - - if self.labelRoiTrangeCheckbox.isChecked(): - start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 - stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() - tRangeLen = stop_frame_n-start_frame_i - else: - tRangeLen = 1 - - if tRangeLen > 1: - # fluo_img_data = fluo_data[start_frame_i:stop_frame_n] - if self.isSegm3D or posData.SizeZ == 1: - return fluo_data - else: - T, Z, Y, X = fluo_data.shape - secondChannelData = np.zeros((T, Y, X), dtype=fluo_data.dtype) - for frame_i, fluo_img in enumerate(fluo_data): - secondChannelData[frame_i] = self.get_2Dimg_from_3D( - fluo_data, frame_i=frame_i - ) - return secondChannelData - else: - if posData.SizeT > 1: - fluo_img_data = fluo_data[posData.frame_i] - else: - fluo_img_data = fluo_data - - if self.isSegm3D or posData.SizeZ == 1: - return fluo_img_data - else: - return self.get_2Dimg_from_3D(fluo_img_data) - - def addActionsLutItemContextMenu(self, lutItem): - lutItem.gradient.menu.addSection('Visible channels: ') - for action in self.overlayContextMenu.actions(): - if action.isSeparator(): - continue - lutItem.gradient.menu.addAction(action) - lutItem.gradient.menu.addSeparator() - - annotationMenu = lutItem.gradient.menu.addMenu('Annotations settings') - ID_menu = annotationMenu.addMenu('IDs') - self.annotSettingsIDmenu = QActionGroup(annotationMenu) - labID_action = QAction("Show label's ID") - labID_action.setCheckable(True) - labID_action.setChecked(True) - labID_action.toggled.connect(self.annotLabelIDtreeToggled) - treeID_action = QAction("Show tree's ID") - treeID_action.setCheckable(True) - treeID_action.toggled.connect(self.annotLabelIDtreeToggled) - self.annotSettingsIDmenu.addAction(labID_action) - self.annotSettingsIDmenu.addAction(treeID_action) - ID_menu.addAction(labID_action) - ID_menu.addAction(treeID_action) - - ID_menu = annotationMenu.addMenu('Generation number') - self.annotSettingsGenNumMenu = QActionGroup(annotationMenu) - gen_num_action = QAction("Show default generation number") - gen_num_action.setCheckable(True) - gen_num_action.setChecked(True) - gen_num_action.toggled.connect(self.annotGenNumTreeToggled) - tree_gen_num_action = QAction("Show tree generation number") - tree_gen_num_action.setCheckable(True) - tree_gen_num_action.toggled.connect(self.annotGenNumTreeToggled) - self.annotSettingsGenNumMenu.addAction(gen_num_action) - self.annotSettingsGenNumMenu.addAction(tree_gen_num_action) - ID_menu.addAction(gen_num_action) - ID_menu.addAction(tree_gen_num_action) - - def annotGenNumTreeToggled(self, checked): - self.textAnnot[0].setGenNumTreeAnnotationsEnabled(checked) - - def annotLabelIDtreeToggled(self, checked): - self.textAnnot[0].setLabelTreeAnnotationsEnabled(checked) - - def setAnnotInfoMode(self, checked): - if checked: - for action in self.annotSettingsIDmenu.actions(): - if action.text().find('tree') != -1: - self.textAnnot[0].setLabelTreeAnnotationsEnabled(True) - action.setChecked(True) - break - for action in self.annotSettingsGenNumMenu.actions(): - if action.text().find('tree') != -1: - self.textAnnot[0].setGenNumTreeAnnotationsEnabled(True) - action.setChecked(True) - break - else: - for action in self.annotSettingsIDmenu.actions(): - if action.text().find('tree') == -1: - action.setChecked(False) - self.textAnnot[0].setLabelTreeAnnotationsEnabled(False) - break - for action in self.annotSettingsGenNumMenu.actions(): - if action.text().find('tree') == -1: - action.setChecked(False) - self.textAnnot[0].setGenNumTreeAnnotationsEnabled(False) - break - self.setAllTextAnnotations() - - def annotOptionClicked(self, clicked=True, sender=None, saveSettings=True): - if sender is None: - sender = self.sender() - # First manually set exclusive with uncheckable - clickedIDs = sender == self.annotIDsCheckbox - clickedCca = sender == self.annotCcaInfoCheckbox - clickedMBline = sender == self.drawMothBudLinesCheckbox - if self.annotIDsCheckbox.isChecked() and clickedIDs: - if self.annotCcaInfoCheckbox.isChecked(): - self.annotCcaInfoCheckbox.setChecked(False) - if self.drawMothBudLinesCheckbox.isChecked(): - self.drawMothBudLinesCheckbox.setChecked(False) - - if self.annotCcaInfoCheckbox.isChecked() and clickedCca: - if self.annotIDsCheckbox.isChecked(): - self.annotIDsCheckbox.setChecked(False) - if self.drawMothBudLinesCheckbox.isChecked(): - self.drawMothBudLinesCheckbox.setChecked(False) - - if self.drawMothBudLinesCheckbox.isChecked() and clickedMBline: - if self.annotIDsCheckbox.isChecked(): - self.annotIDsCheckbox.setChecked(False) - if self.annotCcaInfoCheckbox.isChecked(): - self.annotCcaInfoCheckbox.setChecked(False) - - clickedCont = sender == self.annotContourCheckbox - clickedSegm = sender == self.annotSegmMasksCheckbox - if self.annotContourCheckbox.isChecked() and clickedCont: - if self.annotSegmMasksCheckbox.isChecked(): - self.annotSegmMasksCheckbox.setChecked(False) - - if self.annotSegmMasksCheckbox.isChecked() and clickedSegm: - if self.annotContourCheckbox.isChecked(): - self.annotContourCheckbox.setChecked(False) - - clickedDoNot = sender == self.drawNothingCheckbox - if clickedDoNot: - self.annotIDsCheckbox.setChecked(False) - self.annotCcaInfoCheckbox.setChecked(False) - self.annotContourCheckbox.setChecked(False) - self.annotSegmMasksCheckbox.setChecked(False) - self.drawMothBudLinesCheckbox.setChecked(False) - self.annotNumZslicesCheckbox.setChecked(False) - else: - self.drawNothingCheckbox.setChecked(False) - - if sender == self.annotNumZslicesCheckbox: - self.annotIDsCheckbox.setChecked(True) - self.drawNothingCheckbox.setChecked(False) - - self.setDrawAnnotComboboxText(saveSettings=saveSettings) - - def setDisabledAnnotCheckBoxesLeft(self, disabled): - self.annotIDsCheckbox.setDisabled(disabled) - self.annotCcaInfoCheckbox.setDisabled(disabled) - self.annotContourCheckbox.setDisabled(disabled) - self.annotSegmMasksCheckbox.setDisabled(disabled) - self.drawMothBudLinesCheckbox.setDisabled(disabled) - self.annotNumZslicesCheckbox.setDisabled(disabled) - self.drawNothingCheckbox.setDisabled(disabled) - - def setEnabledAnnotCheckBoxesLeftZdepthAxes(self): - if not self.isSegm3D: - return - - self.annotIDsCheckbox.setDisabled(False) - self.annotContourCheckbox.setDisabled(False) - self.annotIDsCheckbox.setChecked(True) - self.annotContourCheckbox.setChecked(True) - - self.annotOptionClicked( - sender=self.annotIDsCheckbox, saveSettings=False) - - def setDisabledAnnotCheckBoxesRight(self, disabled): - self.annotIDsCheckboxRight.setDisabled(disabled) - self.annotCcaInfoCheckboxRight.setDisabled(disabled) - self.annotContourCheckboxRight.setDisabled(disabled) - self.annotSegmMasksCheckboxRight.setDisabled(disabled) - self.drawMothBudLinesCheckboxRight.setDisabled(disabled) - self.annotNumZslicesCheckboxRight.setDisabled(disabled) - self.drawNothingCheckboxRight.setDisabled(disabled) - - def annotOptionClickedRight( - self, clicked=True, sender=None, saveSettings=True - ): - if sender is None: - sender = self.sender() - # First manually set exclusive with uncheckable - clickedIDs = sender == self.annotIDsCheckboxRight - clickedCca = sender == self.annotCcaInfoCheckboxRight - clickedMBline = sender == self.drawMothBudLinesCheckboxRight - if self.annotIDsCheckboxRight.isChecked() and clickedIDs: - if self.annotCcaInfoCheckboxRight.isChecked(): - self.annotCcaInfoCheckboxRight.setChecked(False) - if self.drawMothBudLinesCheckboxRight.isChecked(): - self.drawMothBudLinesCheckboxRight.setChecked(False) - - if self.annotCcaInfoCheckboxRight.isChecked() and clickedCca: - if self.annotIDsCheckboxRight.isChecked(): - self.annotIDsCheckboxRight.setChecked(False) - if self.drawMothBudLinesCheckboxRight.isChecked(): - self.drawMothBudLinesCheckboxRight.setChecked(False) - - if self.drawMothBudLinesCheckboxRight.isChecked() and clickedMBline: - if self.annotIDsCheckboxRight.isChecked(): - self.annotIDsCheckboxRight.setChecked(False) - if self.annotCcaInfoCheckboxRight.isChecked(): - self.annotCcaInfoCheckboxRight.setChecked(False) - - clickedCont = sender == self.annotContourCheckboxRight - clickedSegm = sender == self.annotSegmMasksCheckboxRight - if self.annotContourCheckboxRight.isChecked() and clickedCont: - if self.annotSegmMasksCheckboxRight.isChecked(): - self.annotSegmMasksCheckboxRight.setChecked(False) - - if self.annotSegmMasksCheckboxRight.isChecked() and clickedSegm: - if self.annotContourCheckboxRight.isChecked(): - self.annotContourCheckboxRight.setChecked(False) - - clickedDoNot = sender == self.drawNothingCheckboxRight - if clickedDoNot: - self.annotIDsCheckboxRight.setChecked(False) - self.annotCcaInfoCheckboxRight.setChecked(False) - self.annotContourCheckboxRight.setChecked(False) - self.annotSegmMasksCheckboxRight.setChecked(False) - self.drawMothBudLinesCheckboxRight.setChecked(False) - self.annotNumZslicesCheckboxRight.setChecked(False) - else: - self.drawNothingCheckboxRight.setChecked(False) - - if sender == self.annotNumZslicesCheckboxRight: - self.annotIDsCheckboxRight.setChecked(True) - self.drawNothingCheckboxRight.setChecked(False) - - self.setDrawAnnotComboboxTextRight(saveSettings=saveSettings) - - def setAnnotOptionsCcaMode(self): - self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( - return_value=True - ) - self.annotCcaInfoCheckbox.setChecked(True) - self.annotIDsCheckbox.setChecked(False) - self.drawMothBudLinesCheckbox.setChecked(False) - self.setDrawAnnotComboboxText() - - def setAnnotOptionsLin_treeMode(self): - # self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( - # return_value=True - # ) - self.annotCcaInfoCheckbox.setChecked(True) - self.annotIDsCheckbox.setChecked(False) - self.drawMothBudLinesCheckbox.setChecked(False) - self.setDrawAnnotComboboxText() - self.showTreeInfoCheckbox.setChecked(True) - - def setDrawAnnotComboboxText(self, saveSettings=True): - if self.annotIDsCheckbox.isChecked(): - if self.annotContourCheckbox.isChecked(): - t = 'Draw IDs and contours' - elif self.annotSegmMasksCheckbox.isChecked(): - t = 'Draw IDs and overlay segm. masks' - else: - t = 'Draw only IDs' - - elif self.annotCcaInfoCheckbox.isChecked(): - if self.annotContourCheckbox.isChecked(): - t = 'Draw cell cycle info and contours' - elif self.annotSegmMasksCheckbox.isChecked(): - t = 'Draw cell cycle info and overlay segm. masks' - else: - t = 'Draw only cell cycle info' - - elif self.annotSegmMasksCheckbox.isChecked(): - t = 'Draw only overlay segm. masks' - - elif self.annotContourCheckbox.isChecked(): - t = 'Draw only contours' - - elif self.drawMothBudLinesCheckbox.isChecked(): - t = 'Draw only mother-bud lines' - - elif self.drawNothingCheckbox.isChecked(): - t = 'Draw nothing' - else: - t = 'Draw nothing' - - if t == self.drawIDsContComboBox.currentText(): - self.drawIDsContComboBox_cb(0) - - self.drawIDsContComboBox.saveSettings = saveSettings - self.drawIDsContComboBox.setCurrentText(t) - - def setDrawAnnotComboboxTextRight(self, saveSettings=True): - if self.annotIDsCheckboxRight.isChecked(): - if self.annotContourCheckboxRight.isChecked(): - t = 'Draw IDs and contours' - elif self.annotSegmMasksCheckboxRight.isChecked(): - t = 'Draw IDs and overlay segm. masks' - else: - t = 'Draw only IDs' - - elif self.annotCcaInfoCheckboxRight.isChecked(): - if self.annotContourCheckboxRight.isChecked(): - t = 'Draw cell cycle info and contours' - elif self.annotSegmMasksCheckboxRight.isChecked(): - t = 'Draw cell cycle info and overlay segm. masks' - else: - t = 'Draw only cell cycle info' - - elif self.annotSegmMasksCheckboxRight.isChecked(): - t = 'Draw only overlay segm. masks' - - elif self.annotContourCheckboxRight.isChecked(): - t = 'Draw only contours' - - elif self.drawMothBudLinesCheckboxRight.isChecked(): - t = 'Draw only mother-bud lines' - - elif self.drawNothingCheckboxRight.isChecked(): - t = 'Draw nothing' - else: - t = 'Draw nothing' - - if t == self.annotateRightHowCombobox.currentText(): - self.annotateRightHowCombobox_cb(0) - - self.annotateRightHowCombobox.saveSettings = saveSettings - self.annotateRightHowCombobox.setCurrentText(t) - - def getOverlayItems(self, channelName, index): - imageItem = widgets.OverlayImageItem() - imageItem.setOpacity(0.5) - imageItem.channelName = channelName - - lutItem = widgets.myHistogramLUTitem( - parent=self, name='image', axisLabel=channelName - ) - imageItem.lutItem = lutItem - for action in lutItem.rescaleActionGroup.actions(): - if action.text() == self.defaultRescaleIntensHow: - action.setChecked(True) - break - - lutItem.removeAddScaleBarAction() - lutItem.removeAddTimestampAction() - lutItem.restoreState(self.df_settings) - lutItem.setImageItem(imageItem) - lutItem.vb.raiseContextMenu = lambda x: None - initColor = self.overlayColors[channelName] - self.initColormapOverlayLayerItem(initColor, lutItem) - lutItem.addOverlayColorButton(initColor, channelName) - lutItem.initColor = initColor - lutItem.hide() - - lutItem.overlayColorButton.sigColorChanging.connect( - self.changeOverlayColor - ) - lutItem.overlayColorButton.sigColorChanged.connect( - self.saveOverlayColor - ) - - lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW) - - lutItem.contoursColorButton.disconnect() - lutItem.contoursColorButton.clicked.connect( - self.imgGrad.contoursColorButton.click - ) - for act in lutItem.contLineWightActionGroup.actions(): - act.toggled.connect(self.contLineWeightToggled) - - lutItem.mothBudLineColorButton.disconnect() - lutItem.mothBudLineColorButton.clicked.connect( - self.imgGrad.mothBudLineColorButton.click - ) - for act in lutItem.mothBudLineWightActionGroup.actions(): - act.toggled.connect(self.mothBudLineWeightToggled) - - lutItem.textColorButton.disconnect() - lutItem.textColorButton.clicked.connect( - self.editTextIDsColorAction.trigger - ) - - lutItem.defaultSettingsAction.triggered.connect( - self.restoreDefaultSettings - ) - lutItem.labelsAlphaSlider.valueChanged.connect( - self.setValueLabelsAlphaSlider - ) - lutItem.sigRescaleIntes.connect( - partial(self.rescaleIntensitiesLut, imageItem=imageItem) - ) - if f'how_rescale_intensities_{channelName}' in self.df_settings.index: - how = self.df_settings.at[ - f'how_rescale_intensities_{channelName}', 'value' - ] - lutItem.setRescaleIntensitiesHow(how) - - self.rescaleIntensChannelHowMapper[channelName] = ( - 'Rescale each 2D image' - ) - - self.addActionsLutItemContextMenu(lutItem) - - alphaScrollBar = self.addAlphaScrollbar(channelName, imageItem) - - toolbutton = widgets.OverlayChannelToolButton( - channelName, lutItem, shortcut=str(index) - ) - toolbutton.action = self.overlayToolbar.addWidget(toolbutton) - toolbutton.setVisible(False) - - toolbutton.clicked.connect(self.overlayChannelToolbuttonClicked) - - alphaScrollBar.toolbutton = toolbutton - - return imageItem, lutItem, alphaScrollBar, toolbutton - - def addAlphaScrollbar(self, channelName, imageItem): - alphaScrollBar = widgets.ScrollBar(Qt.Horizontal) - imageItem.alphaScrollBar = alphaScrollBar - alphaScrollBar.channelName = channelName - - label = QLabel(f'Alpha {channelName}') - label.setFont(_font) - label.hide() - alphaScrollBar.imageItem = imageItem - alphaScrollBar.label = label - alphaScrollBar.setFixedHeight(self.h) - alphaScrollBar.hide() - alphaScrollBar.setMinimum(0) - alphaScrollBar.setMaximum(40) - alphaScrollBar.setValue(20) - alphaScrollBar.setToolTip( - f'Control the alpha value of the overlaid channel {channelName}.\n' - 'alpha=0 results in NO overlay,\n' - 'alpha=1 results in only fluorescence data visible' - ) - self.bottomLeftLayout.addWidget( - alphaScrollBar.label, self.alphaScrollbarRow, 0, - alignment=Qt.AlignRight - ) - self.bottomLeftLayout.addWidget( - alphaScrollBar, self.alphaScrollbarRow, 1, 1, 2 - ) - - alphaScrollBar.valueChanged.connect( - partial(self.setOpacityOverlayLayersItems, scrollbar=alphaScrollBar) - ) - - self.alphaScrollbarRow += 1 - return alphaScrollBar - - def setValueLabelsAlphaSlider(self, value): - self.imgGrad.labelsAlphaSlider.setValue(value) - self.updateLabelsAlpha(value) - - def setOverlayLabelsItemsVisible(self, checked): - for _segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): - items = self.overlayLabelsItems[_segmEndname] - gradItem = items[-1] - gradItem.hide() - - if checked: - segmEndname = self.sender().text() - gradItem = self.overlayLabelsItems[segmEndname][-1] - gradItem.show() - - def setRetainSizePolicyLutItems(self): - if not self.retainSizeLutItems: - return - for channel, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB = items[:3] - myutils.setRetainSizePolicy(lutItem, retain=True) - QTimer.singleShot(300, self.autoRange) - - def setOverlayChannelsToolbuttonsChecked(self): - for channel, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB, toolbutton = items[:4] - toolbutton.setChecked( - not self.overlayToolbar.isSingleChannel() - and channel in self.checkedOverlayChannels - ) - - def setOverlayItemsVisible(self): - for channel, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB, toolbutton = items[:4] - lutItem.hide() - alphaSB.hide() - alphaSB.label.hide() - toolbutton.setVisible(False) - - if not self.overlayButton.isChecked(): - return - - for channel, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB, toolbutton = items[:4] - if channel in self.checkedOverlayChannels: - lutItem.show() - alphaSB.show() - alphaSB.label.show() - toolbutton.setVisible(True) - - def overlayChannelToolbuttonClicked(self, checked=False, toolbutton=None): - if toolbutton is None: - toolbutton = self.sender() - - n_checked_buttons = ( - sum([b.isChecked() for b in self.allOverlayToolbuttons.values()]) - ) - - channelName = toolbutton.channelName() - - if n_checked_buttons == 0 or self.overlayToolbar.isSingleChannel(): - # At least one button must be checked - toolbutton.setChecked(True) - - if self.overlayToolbar.isSingleChannel(): - # Exclusive buttons - for channel, otherToolbutton in self.allOverlayToolbuttons.items(): - if channel == channelName: - continue - - otherToolbutton.setChecked(False) - - if self.overlayToolbar.isTransparent(): - self.setOverlayImages() - return - - self.setOverlayItemsOpacities() - - def setOverlayItemsOpacities(self): - n_checked_buttons = ( - sum([b.isChecked() for b in self.allOverlayToolbuttons.values()]) - ) - - isSingleChannel = ( - self.overlayToolbar.isSingleChannel() - or n_checked_buttons == 1 - ) - - channel_opacity_mapper = self.getOpacitiesFromAlphaScrollbarValues() - - # Set opacity of every layer accordingly - for channel, otherToolbutton in self.allOverlayToolbuttons.items(): - if channel == self.user_ch_name: - otherImageItem = self.img1 - alphaScrollbar = None - # alpha_value = channel_opacity_mapper[channel] - else: - otherItems = self.overlayLayersItems[channel] - otherImageItem = otherItems[0] - alphaScrollbar = otherItems[2] - # alpha_value = alphaScrollbar.value()/alphaScrollbar.maximum() - - if otherToolbutton.isChecked() and isSingleChannel: - op_val = 1.0 - elif otherToolbutton.isChecked(): - op_val = channel_opacity_mapper[channel] - else: - op_val = 0.0 - - if op_val == 0: - op_val = 0.01 - - op_val = op_val if op_val < 1.0 else 0.999 - - otherImageItem.setOpacity(op_val, applyToLinked=False) - - if alphaScrollbar is None: - continue - - alphaScrollbar.setDisabled(bool(op_val == 0)) - - def initColormapOverlayLayerItem(self, foregrColor, lutItem): - if self.invertBwAction.isChecked(): - bkgrColor = (255,255,255,255) - else: - bkgrColor = (0,0,0,255) - gradient = colors.get_pg_gradient((bkgrColor, foregrColor)) - lutItem.setGradient(gradient) - - def setOpacityOverlayLayersItems(self, value, imageItem=None, scrollbar=None): - if scrollbar is None: - scrollbar = imageItem.alphaScrollBar - - channel = scrollbar.channelName - toolbutton = self.allOverlayToolbuttons[channel] - if not toolbutton.isChecked() or not toolbutton.isVisible(): - return - - if value is None: - value = scrollbar.value() - - if imageItem is None: - imageItem = scrollbar.imageItem - alpha = value/scrollbar.maximum() - elif value > 1: - alpha = value/scrollbar.maximum() - else: - alpha = value - - alpha_values = [] - activeOverlayImageItems = [] - for items in self.overlayLayersItems.values(): - imgItem, lutItem, alphaSB = items[:3] - _toolbutton = alphaSB.toolbutton - if alphaSB.channelName == channel: - alpha_values.append(alpha) - elif not _toolbutton.isChecked() or not _toolbutton.isVisible(): - continue - else: - alpha_values.append(alphaSB.value()/alphaSB.maximum()) - - activeOverlayImageItems.append(imgItem) - - opacities = colors.hierarchical_weights(alpha_values)[::-1] - - for i, imgItem in enumerate(activeOverlayImageItems): - imgItem.setOpacity(opacities[i+1]) - - self.img1.setOpacity(opacities[0], applyToLinked=False) - - def showInExplorer_cb(self): - posData = self.data[self.pos_i] - path = posData.images_path - myutils.showInExplorer(path) - - def zSliceAbsent(self, filename, posData): - self.app.restoreOverrideCursor() - SizeZ = posData.SizeZ - chNames = posData.chNames - filenamesPresent = posData.segmInfo_df.index.get_level_values(0).unique() - chNamesPresent = [ - ch for ch in chNames - for file in filenamesPresent - if file.endswith(ch) or file.endswith(f'{ch}_aligned') - ] - win = apps.QDialogZsliceAbsent(filename, SizeZ, chNamesPresent) - win.exec_() - if win.cancel: - self.worker.abort = True - self.waitCond.wakeAll() - return - if win.useMiddleSlice: - user_ch_name = filename[len(posData.basename):] - for _posData in self.data: - if _posData is None: - continue - _, filename = self.getPathFromChName(user_ch_name, _posData) - df = myutils.getDefault_SegmInfo_df(_posData, filename) - _posData.segmInfo_df = pd.concat([df, _posData.segmInfo_df]) - unique_idx = ~_posData.segmInfo_df.index.duplicated() - _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] - _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) - elif win.useSameAsCh: - user_ch_name = filename[len(posData.basename):] - for _posData in self.data: - if _posData is None: - continue - _, srcFilename = self.getPathFromChName( - win.selectedChannel, _posData - ) - cellacdc_df = _posData.segmInfo_df.loc[srcFilename].copy() - _, dstFilename = self.getPathFromChName(user_ch_name, _posData) - if dstFilename is None: - self.worker.abort = True - self.waitCond.wakeAll() - return - dst_df = myutils.getDefault_SegmInfo_df(_posData, dstFilename) - for z_info in cellacdc_df.itertuples(): - frame_i = z_info.Index - zProjHow = z_info.which_z_proj - if zProjHow == 'single z-slice': - src_idx = (srcFilename, frame_i) - if _posData.segmInfo_df.at[src_idx, 'resegmented_in_gui']: - col = 'z_slice_used_gui' - else: - col = 'z_slice_used_dataPrep' - z_slice = _posData.segmInfo_df.at[src_idx, col] - dst_idx = (dstFilename, frame_i) - dst_df.at[dst_idx, 'z_slice_used_dataPrep'] = z_slice - dst_df.at[dst_idx, 'z_slice_used_gui'] = z_slice - _posData.segmInfo_df = pd.concat([dst_df, _posData.segmInfo_df]) - unique_idx = ~_posData.segmInfo_df.index.duplicated() - _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] - _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) - elif win.runDataPrep: - user_ch_file_paths = [] - user_ch_name = filename[len(self.data[self.pos_i].basename):] - for _posData in self.data: - if _posData is None: - continue - user_ch_path = load.get_filename_from_channel( - _posData.images_path, user_ch_name - ) - if user_ch_path is None: - self.worker.abort = True - self.waitCond.wakeAll() - return - user_ch_file_paths.append(user_ch_path) - exp_path = os.path.dirname(_posData.pos_path) - - dataPrepWin = dataPrep.dataPrepWin() - dataPrepWin.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - dataPrepWin.titleText = ( - """ - Select z-slice (or projection) for each frame/position.
- Once happy, close the window. - """) - dataPrepWin.show() - dataPrepWin.initLoading() - dataPrepWin.SizeT = self.data[0].SizeT - dataPrepWin.SizeZ = self.data[0].SizeZ - dataPrepWin.metadataAlreadyAsked = True - self.logger.info(f'Loading channel {user_ch_name} data...') - dataPrepWin.loadFiles( - exp_path, user_ch_file_paths, user_ch_name - ) - dataPrepWin.startAction.setDisabled(True) - dataPrepWin.onlySelectingZslice = True - - loop = QEventLoop(self) - dataPrepWin.loop = loop - loop.exec_() - - self.waitCond.wakeAll() - - def showSetMeasurements(self, checked=False, qparent=None): - qparent = qparent if qparent is not None else self - if self.measurementsWin is not None: - self.measurementsWin.show() - self.measurementsWin.raise_() - self.measurementsWin.activateWindow() - return - - try: - df_favourite_funcs = pd.read_csv(favourite_func_metrics_csv_path) - favourite_funcs = df_favourite_funcs['favourite_func_name'].to_list() - except Exception as e: - favourite_funcs = None - - posData = self.data[self.pos_i] - allPos_acdc_df_cols = set() - for _posData in self.data: - for frame_i, data_dict in enumerate(_posData.allData_li): - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - - allPos_acdc_df_cols.update(acdc_df.columns) - loadedChNames = posData.setLoadedChannelNames(returnList=True) - posData.fluo_data_dict.pop(self.user_ch_name, None) - if self.user_ch_name not in loadedChNames: - loadedChNames.insert(0, self.user_ch_name) - notLoadedChNames = [c for c in self.ch_names if c not in loadedChNames] - self.notLoadedChNames = notLoadedChNames - self.measurementsWin = apps.SetMeasurementsDialog( - loadedChNames, notLoadedChNames, posData.SizeZ > 1, self.isSegm3D, - favourite_funcs=favourite_funcs, - allPos_acdc_df_cols=list(allPos_acdc_df_cols), - acdc_df_path=posData.images_path, posData=posData, - addCombineMetricCallback=self.addCombineMetric, - allPosData=self.data, - parent=qparent, - state=self.setMeasWinState - ) - self.measurementsWin.sigCancel.connect(self.setMeasurementsCancelled) - self.measurementsWin.sigClosed.connect(self.setMeasurements) - self.measurementsWin.show() - - def setMeasurementsCancelled(self): - self.measurementsWin = None - - def setMeasurements(self): - posData = self.data[self.pos_i] - if self.measurementsWin.delExistingCols: - self.logger.info('Removing existing unchecked measurements...') - delCols = self.measurementsWin.existingUncheckedColnames - delRps = self.measurementsWin.existingUncheckedRps - delCols_format = [f' * {colname}' for colname in delCols] - delRps_format = [f' * {colname}' for colname in delRps] - delCols_format.extend(delRps_format) - delCols_format = '\n'.join(delCols_format) - self.logger.info(delCols_format) - for _posData in self.data: - for frame_i, data_dict in enumerate(_posData.allData_li): - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - - acdc_df = acdc_df.drop(columns=delCols, errors='ignore') - for col_rp in delRps: - drop_df_rp = acdc_df.filter(regex=fr'{col_rp}.*', axis=1) - drop_cols_rp = drop_df_rp.columns - acdc_df = acdc_df.drop(columns=drop_cols_rp, errors='ignore') - _posData.allData_li[frame_i]['acdc_df'] = acdc_df - self.setMeasWinState = self.measurementsWin.state() - self.logger.info('Setting measurements...') - self._setMetrics(self.measurementsWin) - self.logger.info('Metrics successfully set.') - self.measurementsWin = None - - def _setMetrics(self, measurementsWin): - self._measurements_kernel.set_metrics_from_set_measurements_dialog( - measurementsWin - ) - for ch in self._measurements_kernel.chNamesToProcess: - if ch not in self.notLoadedChNames: - continue - - success = self.loadFluo_cb(fluo_channels=[ch]) - if not success: - continue - - def addCustomMetric(self, checked=False): - txt = measurements.add_metrics_instructions() - metrics_path = measurements.metrics_path - msg = widgets.myMessageBox() - msg.addShowInFileManagerButton(metrics_path, 'Show example...') - title = 'Add custom metrics instructions' - msg.information(self, title, txt, buttonsTexts=('Ok',)) - - def addCombineMetric(self): - posData = self.data[self.pos_i] - isZstack = posData.SizeZ > 1 - win = apps.combineMetricsEquationDialog( - self.ch_names, isZstack, self.isSegm3D, parent=self - ) - win.sigOk.connect(self.saveCombineMetricsToPosData) - win.exec_() - win.sigOk.disconnect() - - def saveCombineMetricsToPosData(self, window): - for posData in self.data: - equationsDict, isMixedChannels = window.getEquationsDict() - for newColName, equation in equationsDict.items(): - posData.addEquationCombineMetrics( - equation, newColName, isMixedChannels - ) - posData.saveCombineMetrics() - - if self.measurementsWin is None: - return - - self.measurementsWinState = self.measurementsWin.state() - self.measurementsWin.close() - self.showSetMeasurements() - self.measurementsWin.restoreState(self.measurementsWinState) - - def labelRoiToEndFramesTriggered(self): - posData = self.data[self.pos_i] - self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) - - def labelRoiFromCurrentFrameTriggered(self): - posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) - - def labelRoiViewCurrentModel(self): - from . import config - ini_path = os.path.join( - settings_folderpath, 'last_params_segm_models.ini' - ) - configPars = config.ConfigParser() - configPars.read(ini_path) - model_name = self.labelRoiModel.model_name - txt = f'Model: {model_name}' - SECTION = f'{model_name}.init' - txt = f'{txt}

[Initialization parameters]
' - for option in configPars.options(SECTION): - value = configPars[SECTION][option] - param_txt = f'{option} = {value}
' - txt = f'{txt}{param_txt}' - - SECTION = f'{model_name}.segment' - txt = f'{txt}
[Segmentation parameters]
' - for option in configPars.options(SECTION): - value = configPars[SECTION][option] - param_txt = f'{option} = {value}
' - txt = f'{txt}{param_txt}' - - win = apps.ViewTextDialog(txt, parent=self) - win.exec_() - - def setMetricsFunc(self): - posData = self.data[self.pos_i] - self._measurements_kernel._set_metrics_func_from_posData(posData) - - def getLastTrackedFrame(self, posData): - last_tracked_i = 0 - for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] - if lab is None: - frame_i -= 1 - break - if frame_i > 0: - return frame_i - else: - return last_tracked_i - - def computeVolumeRegionprop(self): - if 'cell_vol_vox' not in self._measurements_kernel.sizeMetricsToSave: - return - - # We compute the cell volume in the main thread because calling - # skimage.transform.rotate in a separate thread causes crashes - # with segmentation fault on macOS. I don't know why yet. - self.logger.info('Computing cell volume...') - end_i = self.save_until_frame_i - pos_iter = tqdm(self.data, ncols=100) - for p, posData in enumerate(pos_iter): - if self.posToSave is not None: - if posData.pos_foldername not in self.posToSave: - continue - - PhysicalSizeY = posData.PhysicalSizeY - PhysicalSizeX = posData.PhysicalSizeX - frame_iter = tqdm( - posData.allData_li[:end_i+1], ncols=100, position=1, leave=False - ) - for frame_i, data_dict in enumerate(frame_iter): - lab = data_dict['labels'] - if lab is None: - break - rp = data_dict['regionprops'] - obj_iter = tqdm(rp, ncols=100, position=2, leave=False) - for i, obj in enumerate(obj_iter): - vol_vox, vol_fl = _calc_rot_vol( - obj, PhysicalSizeY, PhysicalSizeX - ) - obj.vol_vox = vol_vox - obj.vol_fl = vol_fl - posData.allData_li[frame_i]['regionprops'] = rp - - def askSaveOriginalSegm(self, isQuickSave=False): - if isQuickSave: - return "", True, True - - posData = self.data[self.pos_i] - if not posData.whitelist: - return "", True, True - - help_txt = html_utils.paragraph(f""" - You have whitelisted IDs in the current position.
- Do you want to save the not whitelisted segmentation data
- This will allow you to revisit the original segmentation.
- """) - - txt = html_utils.paragraph(f""" - You have whitelisted IDs in the current position.
- Do you want to save the not whitelisted segmentation data?
- """) - - found_files = load.get_segm_files(posData.images_path) - existingEndnames = load.get_endnames( - posData.basename, found_files - ) - - segmFilename = os.path.basename(posData.segm_npz_path) - segmFilename = f"{segmFilename[:-4]}_not_whitelisted" - win = apps.filenameDialog( - basename=posData.basename, - hintText=txt, - defaultEntry=segmFilename, - existingNames=existingEndnames, - helpText=help_txt, - allowEmpty=False, - parent=self, - title='Save not whitelisted segmentation data', - addDoNotSaveButton=True - ) - win.exec_() - if win.cancel: - return "", False, True - if win.doNotSave: - return "", True, True - return win.entryText, True, False - - def askSaveLastVisitedCcaMode(self, isQuickSave=False): - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - frame_i = 0 - last_tracked_i = 0 - self.save_until_frame_i = 0 - if self.isSnapshot: - return True - - for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] - if lab is None: - frame_i -= 1 - break - - self.save_until_frame_i = frame_i - self.save_cca_until_frame_i = frame_i - self.last_tracked_i = frame_i - - if isQuickSave: - return True - - last_cca_frame_i = self.navigateScrollBar.maximum()-1 - # Ask to save last visited frame or not - txt = html_utils.paragraph(f""" - You annotated the cell cycle stages up - until frame number {last_cca_frame_i+1}.

- Enter up to which frame number you want to save the - cell cycle annotations: - """) - lastFrameDialog = apps.QLineEditDialog( - title='Last annoated frame number to save', - defaultTxt=str(last_cca_frame_i+1), - msg=txt, parent=self, allowedValues=(1, last_cca_frame_i+1), - warnLastFrame=True, isInteger=True, stretchEntry=False, - lastVisitedFrame=last_cca_frame_i+1, - ) - lastFrameDialog.exec_() - if lastFrameDialog.cancel: - return False - - last_save_cca_frame_i = lastFrameDialog.enteredValue - 1 - - if last_save_cca_frame_i < last_cca_frame_i: - self.resetCcaFuture(last_cca_frame_i) - - self.save_cca_until_frame_i = last_save_cca_frame_i - - return True - - def askSaveLastVisitedSegmMode(self, isQuickSave=False): - posData = self.data[self.pos_i] - current_frame_i = posData.frame_i - frame_i = 0 - last_tracked_i = 0 - self.save_until_frame_i = 0 - self.save_cca_until_frame_i = 0 - if self.isSnapshot: - return True - - for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] - if lab is None: - frame_i -= 1 - break - - if isQuickSave: - self.save_until_frame_i = frame_i - self.save_cca_until_frame_i = frame_i - self.last_tracked_i = frame_i - return True - - # Ask to save last visited frame or not - txt = html_utils.paragraph(f""" - You visualised and corrected segmentation and tracking data up - until frame number {frame_i+1}.

- Enter up to which frame number you want to save data: - """) - lastFrameDialog = apps.QLineEditDialog( - title='Last frame number to save', defaultTxt=str(frame_i+1), - msg=txt, parent=self, allowedValues=(1, posData.SizeT), - warnLastFrame=True, isInteger=True, stretchEntry=False, - lastVisitedFrame=frame_i+1, - ) - lastFrameDialog.exec_() - if lastFrameDialog.cancel: - return False - - self.save_until_frame_i = lastFrameDialog.enteredValue - 1 - self.save_cca_until_frame_i = self.save_until_frame_i - if self.save_until_frame_i > frame_i: - self.logger.info( - f'Storing frames {frame_i+1}-{self.save_until_frame_i+1}...' - ) - current_frame_i = posData.frame_i - # User is requesting to save past the last visited frame --> - # store data as if they were visited - for i in range(frame_i+1, self.save_until_frame_i+1): - posData.frame_i = i - self.get_data() - self.store_data(autosave=False) - - # Go back to current frame - posData.frame_i = current_frame_i - self.get_data() - last_tracked_i = self.save_until_frame_i - - self.last_tracked_i = last_tracked_i - return True - - def askSaveMetrics(self): - txt = html_utils.paragraph( - """ - Do you also want to save the measurements - (e.g., cell volume, mean, amount etc.)?

- - You can find more information by clicking on the - "Set measurements" button below
- where you will be able to select which measurements - you want to save.

- If you already set the measurements and you want to save them click "Yes".

- - NOTE: Saving metrics might be slow, - we recommend doing it only when you need it.
- """) - msg = widgets.myMessageBox( - parent=self, resizeButtons=False, wrapText=False - ) - setMeasurementsButton = widgets.setPushButton('Set measurements...') - _, yesButton, noButton, _ = msg.question( - self, 'Save measurements?', txt, - buttonsTexts=('Cancel', 'Yes', 'No', setMeasurementsButton), - showDialog=False - ) - setMeasurementsButton.disconnect() - setMeasurementsButton.clicked.connect( - partial( - self.showSetMeasurements, - qparent=msg, - ) - ) - msg.exec_() - save_metrics = msg.clickedButton == yesButton - return save_metrics, msg.cancel - - def askSelectPos(self, action='to save'): - last_pos = 1 - for p, posData in enumerate(self.data): - acdc_df = posData.allData_li[0]['acdc_df'] - if acdc_df is None: - last_pos = p - break - else: - last_pos = len(self.data) - - items = [posData.pos_foldername for posData in self.data] - selectPosWin = widgets.QDialogListbox( - f'Select Positions {action}', f'Select Positions {action}:\n', - items, multiSelection=True, parent=self, - preSelectedItems=items[:last_pos] - ) - selectPosWin.exec_() - if selectPosWin.cancel: - return - - return selectPosWin.selectedItemsText - - def askPosToSave(self): - return self.askSelectPos() - - def saveMetricsCritical(self, traceback_format): - print('\n====================================') - self.logger.exception(traceback_format) - print('====================================\n') - self.logger.info('Warning: calculating metrics failed see above...') - print('------------------------------') - - msg = widgets.myMessageBox(wrapText=False) - err_msg = html_utils.paragraph(f""" - Error while saving metrics.

- More details below or in the terminal/console.

- Note that the error details from this session are also saved - in the file
- {self.log_path}

- Please send the log file when reporting a bug, thanks! - Please restart Cell-ACDC, we apologise for any inconvenience.

- - """) - msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...') - msg.setDetailedText(traceback_format, visible=True) - msg.critical(self, 'Critical error while saving metrics', err_msg) - - self.is_error_state = True - self.waitCond.wakeAll() - - def saveAsData(self, checked=True): - try: - posData = self.data[self.pos_i] - except AttributeError: - return - - existingFilenames = set() - for _posData in self.data: - segm_files = load.get_segm_files(_posData.images_path) - _existingEndnames = load.get_endnames( - _posData.basename, segm_files - ) - existingFilenames.update([ - f'{_posData.basename}{endname}.npz' - for endname in _existingEndnames - ]) - posData = self.data[self.pos_i] - if posData.basename.endswith('_'): - basename = f'{posData.basename}segm' - else: - basename = f'{posData.basename}_segm' - win = apps.filenameDialog( - basename=basename, - hintText='Insert a filename for the segmentation file:
', - existingNames=existingFilenames - ) - win.exec_() - if win.cancel: - return - - for posData in self.data: - posData.setFilePaths(new_endname=win.entryText) - - self.setStatusBarLabel() - self.saveData() - - def startExportToVideoWorker(self, preferences): - self.isExportingVideo = True - self.isTransparent = self.overlayToolbar.isTransparent() - if not self.isTransparent: - # SVG export works only with RGBA not with setOpacity - # --> only true transparency mode can be used - self.overlayToolbar.setTransparent(True) - - self.setDisabled(True) - - self.progressWin = apps.QDialogWorkerProgress( - title='Exporting to video', parent=self.mainWin, - pbarDesc='Exporting to video...' - ) - self.progressWin.show(self.app) - self.exportToVideoStopNavVarNum = preferences['stop_nav_var_num'] - self.numFramesExported = 0 - self.progressWin.mainPbar.setMaximum( - preferences['stop_nav_var_num'] - - preferences['start_nav_var_num'] + 1 - ) - self.exportToVideoPreferences = preferences - - self.store_data() - posData = self.data[self.pos_i] - if self.exportToVideoPreferences['is_timelapse']: - # Go to requested start frame - posData.frame_i = preferences['start_nav_var_num'] - 1 - self.get_data() - self.updateAllImages() - self.exportToVideoNavVarIdxToRestore = posData.frame_i - else: - self.update_z_slice(preferences['start_nav_var_num'] - 1) - self.exportToVideoNavVarIdxToRestore = ( - self.zSliceScrollBar.sliderPosition() - ) - self.exportToVideoCurrentNavVarIdx = ( - preferences['start_nav_var_num'] - 1 - ) - - self.exportToVideoImageExporter = exporters.ImageExporter( - self.ax1, - save_pngs=preferences['save_pngs'], - dpi=preferences['dpi'] - ) - self.exportToVideoExporter = exporters.VideoExporter( - preferences['avi_filepath'], preferences['fps'] - ) - - QTimer.singleShot(200, self.updateAndExportFrame) - - def updateAndExportFrame(self): - didVideoExporterFinish = ( - self.exportToVideoCurrentNavVarIdx - == self.exportToVideoStopNavVarNum - ) - if didVideoExporterFinish: - self.progressWin.mainPbar.setMaximum(0) - self.progressWin.mainPbar.setValue(0) - QTimer.singleShot(50, self.exportingFramesFinished) - return - - posData = self.data[self.pos_i] - if self.exportToVideoPreferences['is_timelapse']: - self.goToFrameNumber(self.exportToVideoCurrentNavVarIdx+1) - else: - self.update_z_slice(self.exportToVideoCurrentNavVarIdx) - - success = self.exportFrame() - if success is None: - self.exportingVideoCritical() - return - - self.exportToVideoCurrentNavVarIdx += 1 - self.progressWin.mainPbar.update(1) - - QTimer.singleShot(50, self.updateAndExportFrame) - - @exception_handler - def exportFrame(self): - nd = self.exportToVideoPreferences['num_digits'] - idx = str(self.exportToVideoCurrentNavVarIdx).zfill(nd) - filename = self.exportToVideoPreferences['filename'] - png_filename = f'{idx}_{filename}.png' - pngs_folderpath = self.exportToVideoPreferences['pngs_folderpath'] - - png_filepath = os.path.join(pngs_folderpath, png_filename) - img_bgr = self.exportToVideoImageExporter.export(png_filepath) - self.exportToVideoExporter.add_frame(img_bgr) - return True - - def exportingVideoCritical(self): - self.setDisabled(False) - - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.logger.info('Exporting video process failed.') - - def exportingFramesFinished(self): - if not self.exportToVideoPreferences['save_pngs']: - self.logger.info('Removing PNGs...') - try: - shutil.rmtree(self.exportToVideoPreferences['pngs_folderpath']) - except Exception as err: - pass - - self.logger.info('Saving video...') - - self.exportToVideoExporter.release() - - # Run ffmpeg new process - conversion_to_mp4_successful = True - if self.exportToVideoPreferences['filepath'].endswith('.mp4'): - try: - self.exportToVideoExporter.avi_to_mp4() - try: - os.remove(self.exportToVideoPreferences['avi_filepath']) - except Exception as err: - pass - except Exception as err: - self.logger.exception(traceback.format_exc()) - self.logger.info( - 'Conversion to MP4 failed. See traceback above.' - ) - conversion_to_mp4_successful = False - self.exportToVideoPreferences['filepath'] = ( - self.exportToVideoExporter._avi_filepath - ) - - self.exportToVideoFinished(conversion_to_mp4_successful) - - def exportToVideoFinished(self, conversion_to_mp4_successful): - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - # Back to current frame - if self.exportToVideoPreferences['is_timelapse']: - posData = self.data[self.pos_i] - posData.frame_i = self.exportToVideoNavVarIdxToRestore - self.get_data() - self.store_data() - self.updateAllImages() - self.navigateScrollBar.setSliderPosition(posData.frame_i+1) - self.navSpinBox.setValue(posData.frame_i+1) - else: - self.update_z_slice(self.exportToVideoNavVarIdxToRestore) - - self.setDisabled(False) - self.isExportingVideo = False - - if not self.isTransparent: - # True transparency mode was activated programmatically - # --> restore what the user had before starting to export - self.overlayToolbar.setTransparent(False) - - prompts.exportToVideoFinished( - self.exportToVideoPreferences, conversion_to_mp4_successful, - qparent=self - ) - - def exportAddScaleBar(self, checked): - self.addScaleBarAction.setChecked(checked) - - def exportToVideoAddTimestamp(self, checked): - self.addTimestampAction.setChecked(checked) - - def askTimelapseOrZslicesVideo(self): - txt = html_utils.paragraph(""" - Do you want to record a video of scrolling through the z-slices or - a Timelapse video? - """) - msg = widgets.myMessageBox(wrapText=False) - _, timelapseButton = msg.question( - self, 'Z-slices or Timelapse video?', txt, - buttonsTexts=('Z-slices', 'Timelapse') - ) - if msg.cancel: - return - - return msg.clickedButton == timelapseButton - - def exportToVideoTriggered(self): - posData = self.data[self.pos_i] - - doTimelapseVideo = posData.SizeT > 1 - if posData.SizeT > 1 and posData.SizeZ > 1: - doTimelapseVideo = self.askTimelapseOrZslicesVideo() - - if doTimelapseVideo is None: - self.logger.info('Export to video process cancelled') - return - - channels = [self.user_ch_name, *self.checkedOverlayChannels] - mode = 'timelapse' if doTimelapseVideo else 'z_slices' - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = f'{timestamp}_acdc_exported_{mode}_video' - win = apps.ExportToVideoParametersDialog( - channels, - parent=self, - startFolderpath=posData.pos_path, - startFilename=filename, - startFrameNum=posData.frame_i+1, - SizeT=posData.SizeT, - SizeZ=posData.SizeZ, - isTimelapseVideo=doTimelapseVideo, - isScaleBarPresent=self.addScaleBarAction.isChecked(), - isTimestampPresent=self.addTimestampAction.isChecked(), - rescaleIntensChannelHowMapper=self.rescaleIntensChannelHowMapper - ) - win.sigAddScaleBar.connect(self.exportAddScaleBar) - win.sigAddTimestamp.connect(self.exportToVideoAddTimestamp) - win.sigRescaleIntensLut.connect(self.rescaleIntensExportToVideoDialog) - win.exec_() - if win.cancel: - self.logger.info('Export to video process cancelled') - return - - cancel = _warnings.warnExportToVideo(qparent=self) - if cancel: - self.logger.info('Export to video process cancelled') - return - - self.startExportToVideoWorker(win.selected_preferences) - - def setExportMaskImage(self, viewRange): - if not hasattr(self, 'exportMaskImage'): - self.initExportMaskImage() - else: - self.exportMaskImage[:] = 0 - - xRange, yRange = viewRange - x0, x1 = map(round, xRange) - y0, y1 = map(round, yRange) - - if self.invertBwAction.isChecked(): - self.exportMaskImage[:, :, :3] = 255 - - if x0 > 0: - self.exportMaskImage[:, :x0, 3] = 255 - if x1 < self.exportMaskImage.shape[1]: - self.exportMaskImage[:, x1:, 3] = 255 - if y0 > 0: - self.exportMaskImage[:y0, :, 3] = 255 - if y1 < self.exportMaskImage.shape[0]: - self.exportMaskImage[y1:, :, 3] = 255 - - self.exportMaskImageItem.setImage(self.exportMaskImage) - - def setViewRangeFromExportToImageDialog(self, viewRange, win=None): - xRange, yRange = viewRange - # self.ax1.sigRangeChanged.disconnect(self.viewRangeChanged) - self.ax1.setRange(xRange=xRange, yRange=yRange) - # self.ax1.sigRangeChanged.connect(self.viewRangeChanged) - # self.viewRangeChanged( - # self.ax1.vb, viewRange, updateExportMaskImage=False - # ) - self.setExportMaskImage(viewRange) - - def getZoomIDs(self, viewRange=None): - if viewRange is None: - viewRange = self.ax1.viewRange() - - lab = self.currentLab2D - Y, X = lab.shape - ((xmin, xmax), (ymin, ymax)) = viewRange - if xmin <= 0 and ymin <= 0 and xmax >= X and ymax >= Y: - posData = self.data[self.pos_i] - return None - - xmin = xmin if xmin >= 0 else 0 - ymin = ymin if ymin >= 0 else 0 - xmax = xmax if xmax < X else X - ymax = ymax if ymax < Y else Y - - zoomSlice = ( - slice(round(ymin), round(ymax)), - slice(round(xmin), round(xmax)), - ) - - zoomLab = skimage.segmentation.clear_border(lab[zoomSlice]) - zoomRp = skimage.measure.regionprops(zoomLab) - zoomIDs = [obj.label for obj in zoomRp] - return zoomIDs - - def onSigUpdateCcaTableWindow(self, *args): - if not self.isDataLoaded: - return - - if self.ccaTableWin is None: - return - - viewRange = self.ax1.viewRange() - posData = self.data[self.pos_i] - zoomIDs = self.getZoomIDs(viewRange=viewRange) - - self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - @disableWindow - def exportToImage(self, preferences): - filepath = preferences['filepath'] - self.logger.info(f'Saving image to "{filepath}"...') - - if filepath.endswith('.svg'): - exporter = exporters.SVGExporter(self.ax1) - else: - exporter = exporters.ImageExporter(self.ax1, dpi=preferences['dpi']) - exporter.export(filepath) - self.logger.info(f'Image saved.') - - self.setDisabled(False) - self.exportMaskImage[:] = 0 - self.exportMaskImageItem.setImage(self.exportMaskImage) - prompts.exportToImageFinished(filepath, qparent=self) - - def exportToImageTriggered(self): - posData = self.data[self.pos_i] - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = f'{timestamp}_acdc_exported_image' - win = apps.ExportToImageParametersDialog( - parent=self, - startFolderpath=posData.pos_path, - startFilename=filename, - startViewRange=self.ax1.viewRange(), - isScaleBarPresent=self.addScaleBarAction.isChecked(), - ) - win.sigAddScaleBar.connect(self.exportAddScaleBar) - win.sigRangeChanged.connect( - partial(self.setViewRangeFromExportToImageDialog, win=win) - ) - # self.ax1.vb.sigRangeChanged.connect( - # win.updateViewRangeExportToImageDialog - # ) - self.setExportMaskImage(self.ax1.viewRange()) - self.exportToImageWindow = win - win.exec_() - # self.ax1.vb.sigRangeChanged.disconnect() - if win.cancel: - self.exportMaskImage[:] = 0 - self.exportMaskImageItem.setImage(self.exportMaskImage) - self.exportToImageWindow = None - self.logger.info('Export to image process cancelled') - return - - isTransparent = self.overlayToolbar.isTransparent() - if not isTransparent: - # SVG export works only with RGBA not with setOpacity - # --> only true transparency mode can be used - self.overlayToolbar.setTransparent(True) - - self.exportToImage(win.selected_preferences) - self.exportToImageWindow = None - - if not isTransparent: - self.overlayToolbar.setTransparent(False) - - def saveDataPermissionError(self, err_msg): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - msg = QMessageBox() - msg.critical(self, 'Permission denied', err_msg, msg.Ok) - self.waitCond.wakeAll() - - def saveDataProgress(self, text): - self.logger.info(text) - self.saveWin.progressLabel.setText(text) - - def saveDataCustomMetricsCritical(self, traceback_format, func_name): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - self.logger.info('') - _hl = '====================================' - self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') - self.worker.customMetricsErrors[func_name] = traceback_format - - def saveDataCombinedMetricsMissingColumn(self, error_msg, func_name): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - self.logger.info('') - warning = f'[WARNING]: {error_msg}. Metric {func_name} was skipped.' - _hl = '====================================' - self.logger.info(f'{_hl}\n{warning}\n{_hl}') - self.worker.customMetricsErrors[func_name] = warning - - def saveDataAddMetricsCritical(self, traceback_format, error_message): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - self.logger.info('') - _hl = '====================================' - self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') - self.worker.addMetricsErrors[error_message] = traceback_format - - def saveDataRegionPropsCritical(self, traceback_format, error_message): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - self.logger.info('') - _hl = '====================================' - self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') - self.worker.regionPropsErrors[error_message] = traceback_format - - def saveDataUpdateMetricsPbar(self, max, step): - if max > 0: - self.saveWin.metricsQPbar.setMaximum(max) - self.saveWin.metricsQPbar.setValue(0) - self.saveWin.metricsQPbar.setValue( - self.saveWin.metricsQPbar.value()+step - ) - - def saveDataUpdatePbar(self, step, max=-1, exec_time=0.0): - if max >= 0: - self.saveWin.QPbar.setMaximum(max) - else: - self.saveWin.QPbar.setValue(self.saveWin.QPbar.value()+step) - steps_left = self.saveWin.QPbar.maximum()-self.saveWin.QPbar.value() - seconds = round(exec_time*steps_left) - ETA = myutils.seconds_to_ETA(seconds) - self.saveWin.ETA_label.setText(f'ETA: {ETA}') - - def quickSave(self): - self.saveData(isQuickSave=True) - - def checkMissingCca(self): - proceed = True - ignore = False - doNotShowAgain = False - if not self.doNotShowAgainMissingCca: - return proceed, ignore, doNotShowAgain - - missing_cca_items = [] - for posData in self.data: - for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] - if acdc_df is None: - continue - - if 'cell_cycle_stage' not in acdc_df.columns: - continue - - cca_df = acdc_df[cca_df_colnames] - if cca_df.isnull().values.any(): - i = frame_i if not self.isSnapshot else None - missing_cca_items.append((cca_df, posData, i)) - - if not missing_cca_items: - return proceed, ignore, doNotShowAgain - - proceed = False - ignore, doNotShowAgain =_warnings.warnMissingCca( - missing_cca_items, qparent=self - ) - - if doNotShowAgain: - self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'Yes' - self.df_settings.to_csv(self.settings_csv_path) - - return proceed, ignore, doNotShowAgain - - def warnDifferentSegmChannel( - self, loaded_channel, segm_channel_hyperparams, segmEndName - ): - txt = html_utils.paragraph(f""" - You loaded the segmentation file ending with _{segmEndName}.npz - which corresponds to the channel - {segm_channel_hyperparams}.

- However, in this session you loaded the channel - {loaded_channel}.

- If you proceed with saving, the segmentation file ending with - _{segmEndName}.npz will be OVERWRITTEN.

- Are you sure you want to proceed? - """) - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.warning( - self, 'WARNING: Potential for data loss', txt, - buttonsTexts=('Cancel', 'Yes') - ) - return msg.cancel - - def waitAutoSaveWorker(self, worker): - if worker.isFinished or worker.isPaused or len(worker.dataQ) == 0: - self.waitAutoSaveWorkerLoop.exit() - self.waitAutoSaveWorkerTimer.stop() - self.setStatusBarLabel(log=False) - - @exception_handler - def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): - self.setDisabled(True, keepDisabled=True) - - self.askLineageTreeChanges() - - self.store_data(autosave=False) - self.applyDelROIs() - self.store_data() - self._isQuickSave = isQuickSave - - # Wait autosave worker to finish - for worker, thread in self.autoSaveActiveWorkers: - self.logger.info('Stopping autosaving process...') - self.statusBarLabel.setText('Stopping autosaving process...') - worker.stop() - self.waitAutoSaveWorkerTimer = QTimer() - self.waitAutoSaveWorkerTimer.timeout.connect( - partial(self.waitAutoSaveWorker, worker) - ) - self.waitAutoSaveWorkerTimer.start(100) - self.waitAutoSaveWorkerLoop = QEventLoop() - self.waitAutoSaveWorkerLoop.exec_() - - self.titleLabel.setText( - 'Saving data... (check progress in the terminal)', - color=self.titleColor - ) - - # Check channel name correspondence to warn - posData = self.data[self.pos_i] - lastSegmChannel, segmEndName = posData.getSegmentedChannelHyperparams() - if lastSegmChannel != self.user_ch_name and lastSegmChannel: - cancel = self.warnDifferentSegmChannel( - self.user_ch_name, lastSegmChannel, segmEndName - ) - if cancel: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return True - posData.updateSegmentedChannelHyperparams(self.user_ch_name) - - # Check missing cca annotations in snaphots - proceed, ignore, self.doNotShowAgainMissingCca = self.checkMissingCca() - if not proceed and not ignore: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return - - self.save_metrics = False - if not isQuickSave: - self.save_metrics, cancel = self.askSaveMetrics() - if cancel: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return True - - self.posToSave = None - if self.isSnapshot and not isQuickSave and len(self.data) > 1: - self.posToSave = self.askPosToSave() - if self.posToSave is None: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return True - - if isQuickSave: - # Quick save only current pos - self.posToSave = {self.data[self.pos_i].pos_foldername} - - if self.isSnapshot: - self.store_data(mainThread=False) - - mode = self.modeComboBox.currentText() - if mode == 'Cell cycle analysis': - proceed = self.askSaveLastVisitedCcaMode(isQuickSave=isQuickSave) - if not proceed: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return True - else: - proceed = self.askSaveLastVisitedSegmMode(isQuickSave=isQuickSave) - if not proceed: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return True - - append_name_og_whitelist, proceed, do_not_save_og_whitelist = self.askSaveOriginalSegm(isQuickSave=isQuickSave) - if not proceed: - self.cancelSavingInitialisation() - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - return True - - if self.save_metrics or mode == 'Cell cycle analysis': - self.computeVolumeRegionprop() - - infoTxt = html_utils.paragraph( - f'Saving {self.exp_path}...
', font_size='14px' - ) - - self.saveWin = apps.QDialogPbar( - parent=self, title='Saving data', infoTxt=infoTxt - ) - self.saveWin.setFont(_font) - # if not self.save_metrics: - self.saveWin.metricsQPbar.hide() - self.saveWin.progressLabel.setText('Preparing data...') - self.saveWin.show() - - # Set up separate thread for saving and show progress bar widget - self.mutex = QMutex() - self.waitCond = QWaitCondition() - self.thread = QThread() - self.worker = workers.saveDataWorker(self) - self.worker.mode = mode - self.worker.isQuickSave = isQuickSave - self.worker.append_name_og_whitelist = append_name_og_whitelist - self.worker.do_not_save_og_whitelist = do_not_save_og_whitelist - - self.worker.moveToThread(self.thread) - - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - # Custom signals - self.worker.finished.connect(self.saveDataFinished) - if finishedCallback is not None: - self.worker.finished.connect(finishedCallback) - self.worker.progress.connect(self.saveDataProgress) - self.worker.sigLog.connect(self.workerLog) - self.worker.progressBar.connect(self.saveDataUpdatePbar) - # self.worker.metricsPbarProgress.connect(self.saveDataUpdateMetricsPbar) - self.worker.critical.connect(self.saveDataWorkerCritical) - self.worker.customMetricsCritical.connect( - self.saveDataCustomMetricsCritical - ) - self.worker.sigCombinedMetricsMissingColumn.connect( - self.saveDataCombinedMetricsMissingColumn - ) - self.worker.addMetricsCritical.connect(self.saveDataAddMetricsCritical) - self.worker.regionPropsCritical.connect( - self.saveDataRegionPropsCritical - ) - self.worker.criticalPermissionError.connect(self.saveDataPermissionError) - self.worker.askZsliceAbsent.connect(self.zSliceAbsent) - self.worker.sigDebug.connect(self._workerDebug) - - self.thread.started.connect(self.worker.run) - - self.thread.start() - - return False - - def _workerDebug(self, stuff_to_debug): - pass - # from acdctools.plot import imshow - # lab, frame_i, autoBkgr_masks = stuff_to_debug - # autoBkgr_mask, autoBkgr_mask_proj = autoBkgr_masks - # imshow(lab, autoBkgr_mask) - # self.worker.waitCond.wakeAll() - - def changeTextResolution(self): - mode = 'high' if self.highLowResAction.isChecked() else 'low' - self.logger.info( - f'Switching to {mode} for the text annnotations...' - ) - self.pxModeAction.setDisabled(not self.highLowResAction.isChecked()) - if not self.isDataLoaded: - return - - self.setAllIDs() - posData = self.data[self.pos_i] - allIDs = posData.allIDs - img_shape = self.img1.image.shape[:2] - self.textAnnot[0].changeResolution(mode, allIDs, self.ax1, img_shape) - self.textAnnot[1].changeResolution(mode, allIDs, self.ax2, img_shape) - self.updateAllImages() - - def highLowResToggled(self, clicked=True): - self.changeTextResolution() - - def autoSaveClose(self): - for worker, thread in self.autoSaveActiveWorkers: - worker._stop() - - def viewPreprocDataToggled(self, checked): - self.img1.setUsePreprocessed(checked) - self.setImageImg1() - - if self.viewCombineChannelDataToggle.isChecked(): - self.viewCombineChannelDataToggle.toggled.disconnect() - self.viewCombineChannelDataToggle.setChecked(False) - self.viewCombineChannelDataToggle.toggled.connect( - self.viewCombineChannelDataToggled - ) - - def setAutoSaveSegmentationEnabled(self, enabled): - if not self.autoSaveActiveWorkers: - return - - worker, thread = self.autoSaveActiveWorkers[-1] - - if enabled: - worker.isAutoSaveON = self.autoSaveToggle.isChecked() - else: - worker.isAutoSaveON = False - - def setAutoSaveAnnotationsEnabled(self, enabled): - if not self.autoSaveActiveWorkers: - return - - worker, thread = self.autoSaveActiveWorkers[-1] - - if enabled: - worker.isAutoSaveAnnotON = self.autoSaveToggle.isChecked() - else: - worker.isAutoSaveAnnotON = False - - def autoSaveToggled(self, checked): - if not self.autoSaveActiveWorkers: - self.gui_createAutoSaveWorker() - - if not self.autoSaveActiveWorkers: - return - - worker, thread = self.autoSaveActiveWorkers[-1] - - mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': - # Autosaving segmentation makes sense only in - # "Segmentation and Tracking" mode - checked = False - - worker.isAutoSaveON = checked - - def autoSaveAnnotToggled(self, checked): - if not self.autoSaveActiveWorkers: - self.gui_createAutoSaveWorker() - - if not self.autoSaveActiveWorkers: - return - - worker, thread = self.autoSaveActiveWorkers[-1] - - mode = self.modeComboBox.currentText() - if mode != 'Viewer': - # No reason to save in viewer mode - checked = False - - worker.isAutoSaveAnnotON = checked - - def autoSaveIntervalEdit(self): - self.autoSaveIntervalDialog.show() - self.autoSaveIntervalDialog.raise_() - self.autoSaveIntervalDialog.activateWindow() - - def autoSaveIntervalValueChanged( - self, value: float, unit: Literal['minutes', 'frames'] - ): - self.autoSaveIntevalValueUnit = (value, unit) - self.autoSaveTimer.stop() - - self.df_settings.at['autoSaveIntevalValue', 'value'] = str(value) - self.df_settings.at['autoSaveIntervalUnit', 'value'] = unit - self.df_settings.to_csv(settings_csv_path) - - self.logger.info( - f'Autosave interval changed to: {value} {unit}' - ) - self.autoSaveIntervalSetTooltip() - - if unit == 'frames': - self.startAutoSaveEveryNframesTimer() - - def autoSaveIntervalSetTooltip(self): - value, unit = self.autoSaveIntevalValueUnit - autoSaveIntervalEditTooltip = ( - 'Change autosave interval to every N frames or minutes\n\n' - f'Current autosave interval: {value} {unit}' - ) - self.autoSaveIntervalLabel.setToolTip(autoSaveIntervalEditTooltip) - self.autoSaveIntervalEditButton.setToolTip(autoSaveIntervalEditTooltip) - - def ccaIntegrCheckerToggled(self, checked): - self.df_settings.at['is_cca_integrity_checker_activated', 'value'] = ( - int(checked) - ) - self.df_settings.to_csv(self.settings_csv_path) - mode = self.modeComboBox.currentText() - if mode != 'Cell cycle analysis': - return - - if checked: - self.startCcaIntegrityCheckerWorker() - else: - self.disableCcaIntegrityChecker() - - def warnErrorsCustomMetrics(self): - win = apps.ComputeMetricsErrorsDialog( - self.worker.customMetricsErrors, self.logs_path, - log_type='custom_metrics', parent=self - ) - win.exec_() - - def warnErrorsAddMetrics(self): - win = apps.ComputeMetricsErrorsDialog( - self.worker.addMetricsErrors, self.logs_path, - log_type='standard_metrics', parent=self - ) - win.exec_() - - def warnErrorsRegionProps(self): - win = apps.ComputeMetricsErrorsDialog( - self.worker.regionPropsErrors, self.logs_path, - log_type='region_props', parent=self - ) - win.exec_() - - def askConcatenate(self): - if self.mainWin is None: - return - - if self._isQuickSave: - return - - if 'showAskConcatenate' not in self.df_settings.index: - self.df_settings.at['showAskConcatenate', 'value'] = 'Yes' - - showAskConcatenate = ( - self.df_settings.at['showAskConcatenate', 'value'] == 'Yes' - ) - if not showAskConcatenate: - return - - txt = html_utils.paragraph(f""" - Do you want to concatenate the `acdc_output.csv` tables from - multiple Positions into one single CSV file?
- """) - doNotShowAgainCheckbox = QCheckBox('Do not show again') - msg = widgets.myMessageBox(wrapText=False) - noButton, yesButton = msg.question( - self, 'Concatenate tables?', txt, - buttonsTexts=('No', 'Yes'), - widgets=doNotShowAgainCheckbox - ) - showAskConcatenate = ( - 'No' if doNotShowAgainCheckbox.isChecked() else 'Yes' - ) - self.df_settings.at['showAskConcatenate', 'value'] = ( - showAskConcatenate - ) - self.df_settings.to_csv(settings_csv_path) - - if not msg.clickedButton == yesButton: - return - - txt = html_utils.paragraph(f""" - To concatenate the `acdc_output.csv` tables from - multiple Positions and multiple experiments
- launch the concatenation utility from the top menubar of the Cell-ACDC main launcher:

- Utilities --> Concatenate --> Concatenate acdc output tables from multiple Positions and experiments.... - """) - msg = widgets.myMessageBox(wrapText=False) - msg.information(self, 'How to concatenate tables', txt) - - def updateSegmDataAutoSaveWorker(self): - # Update savedSegmData in autosave worker - posData = self.data[self.pos_i] - for worker, thread in self.autoSaveActiveWorkers: - worker.savedSegmData = posData.segm_data.copy() - - def saveDataFinished(self): - self.setDisabled(False, keepDisabled=False) - self.activateWindow() - if self.saveWin.aborted or self.worker.abort: - self.titleLabel.setText('Saving process cancelled.', color='r') - elif self._isQuickSave: - self.titleLabel.setText('Saved segmentation file and annotations') - else: - self.titleLabel.setText('Saved!') - self.saveWin.workerFinished = True - self.saveWin.close() - - if not self.closeGUI: - # Update savedSegmData in autosave worker - self.updateSegmDataAutoSaveWorker() - - if self.worker.addMetricsErrors: - self.warnErrorsAddMetrics() - if self.worker.regionPropsErrors: - self.warnErrorsRegionProps() - if self.worker.customMetricsErrors: - self.warnErrorsCustomMetrics() - - self.checkManageVersions() - - self.askConcatenate() - - if self.closeGUI: - salute_string = myutils.get_salute_string() - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Data saved!. The GUI will now close.

' - f'{salute_string}' - ) - msg.information(self, 'Data saved', txt) - self.close() - - def copyContent(self): - pass - - def pasteContent(self): - pass - - def cutContent(self): - pass - - def showAbout(self): - self.aboutWin = about.QDialogAbout(parent=self) - self.aboutWin.show() - - def openLogFile(self): - self.logger.info(f'Opening log file "{self.log_path}"...') - myutils.showInExplorer(self.log_path) - - def showLogFiles(self): - log_files_path = os.path.dirname(self.log_path) - self.logger.info(f'Opening log files folder "{log_files_path}"...') - myutils.showInExplorer(log_files_path) - - def showTipsAndTricks(self): - self.welcomeWin = welcome.welcomeWin() - self.welcomeWin.showAndSetSize() - self.welcomeWin.showPage(self.welcomeWin.quickStartItem) - - def about(self): - pass - - def openRecentFile(self, path): - self.logger.info(f'Opening recent folder: {path}') - self.addToRecentPaths(path, logger=self.logger) - self.openFolder(exp_path=path) - - def _waitCloseAutoSaveWorker(self): - didWorkersFinished = [True] - for worker, thread in self.autoSaveActiveWorkers: - if worker.isFinished: - didWorkersFinished.append(True) - else: - didWorkersFinished.append(False) - if all(didWorkersFinished): - self.waitCloseAutoSaveWorkerLoop.stop() - - def cancelSavingInitialisation(self): - self.titleLabel.setText( - 'Saving data process cancelled.', color=self.titleColor - ) - self.closeGUI = False - - @disableWindow - def askSaveOnClosing(self, event): - if not self.saveAction.isEnabled(): - return True - if self.titleLabel.text == 'Saved!': - return True - if not self.isDataLoaded: - return True - - msg = widgets.myMessageBox() - txt = html_utils.paragraph('Do you want to save before closing?') - _, noButton, yesButton = msg.question( - self, 'Save?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') - ) - if msg.cancel: - event.ignore() - return False - - if msg.clickedButton == yesButton: - self.closeGUI = True - QTimer.singleShot(100, self.saveAction.trigger) - event.ignore() - return False - return True - - def clearMemory(self): - if not hasattr(self, 'data'): - return - self.logger.info('Clearing memory...') - for posData in self.data: - try: - del posData.img_data - except Exception as e: - pass - try: - del posData.segm_data - except Exception as e: - pass - try: - del posData.ol_data_dict - except Exception as e: - pass - try: - del posData.fluo_data_dict - except Exception as e: - pass - try: - del posData.ol_data - except Exception as e: - pass - del self.data - - def setUncheckedPointsLayers(self): - self.togglePointsLayerAction.setChecked(False) - self.magicPromptsToolButton.setChecked(False) - - def clearHighlightedID(self): - self.highlightIDToolbar.setVisible(False) - - try: - self.updateLostContoursImage(ax=0, delROIsIDs=None) - except Exception as err: - pass - - if self.highlightedID == 0: - return - - self.highlightedID = 0 - self.guiTabControl.highlightCheckbox.setChecked(False) - self.guiTabControl.highlightSearchedCheckbox.setChecked(False) - self.setHighlightID(False) - - def onEscape( - self, - isTypingIDFunctionChecked=False, - buttonsToNotUncheck=None, - doAutoRange=True - ): - if buttonsToNotUncheck is None: - buttonsToNotUncheck = set() - - if self.keepIDsButton.isChecked() and self.keptObjectsIDs: - self.keptObjectsIDs = widgets.KeptObjectIDsList( - self.keptIDsLineEdit, self.keepIDsConfirmAction - ) - self.highlightHoverIDsKeptObj(0, 0, hoverID=0) - QTimer.singleShot(300, self.autoRange) - return - - if self.brushButton.isChecked() and self.typingEditID: - self.autoIDcheckbox.setChecked(True) - self.typingEditID = False - QTimer.singleShot(300, self.autoRange) - return - - if isTypingIDFunctionChecked and self.typingEditID: - self.typingEditID = False - QTimer.singleShot(300, self.autoRange) - return - - if self.labelRoiButton.isChecked() and self.isMouseDragImg1: - self.isMouseDragImg1 = False - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) - self.freeRoiItem.clear() - QTimer.singleShot(300, self.autoRange) - return - - if self.zoomRectButton.isChecked(): - self.zoomRectCancelled() - QTimer.singleShot(300, self.autoRange) - return - - self.setUncheckedAllButtons(buttonsToNotUncheck=buttonsToNotUncheck) - self.setUncheckedAllCustomAnnotButtons() - self.setUncheckedPointsLayers() - self.clearTempBrushImage() - self.isMouseDragImg1 = False - self.typingEditID = False - self.clearHighlightedID() - try: - self.polyLineRoi.clearPoints() - except Exception as e: - pass - - if doAutoRange: - QTimer.singleShot(11, self.autoRange) - - def clearTempBrushImage(self, forceClearLinked=True): - if not hasattr(self, 'tempLayerImg1'): - return - - self.tempLayerImg1.setImage( - self.emptyLab, force_set_linked=forceClearLinked - ) - - try: - self.brushContourImage[:] = 0 - except Exception as err: - pass - - try: - self.brushImage[:] = 0 - except Exception as err: - pass - - def askCloseAllWindows(self): - txt = html_utils.paragraph(""" - There are other open windows that were created from this window. -

- If you proceed, the other windows will be closed too.
- """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning( - self, 'Open windows', txt, - buttonsTexts=('Cancel', 'Ok, close now') - ) - return msg.cancel - - def stopPreprocWorker(self): - self.logger.info('Closing pre-processing worker...') - try: - self.preprocWorker.stop() - except Exception as err: - pass - - def closeEvent(self, event): - self.setDisabled(False) - cancel = self.checkAskSavePointsLayers() - if cancel: - event.ignore() - return - - self.onEscape() - self.saveWindowGeometry() - - if self.newWindows: - cancel = self.askCloseAllWindows() - if cancel: - event.ignore() - return - - for window in self.newWindows: - window.close() - - if self.slideshowWin is not None: - self.slideshowWin.close() - if self.ccaTableWin is not None: - self.ccaTableWin.close() - - proceed = self.askSaveOnClosing(event) - if not proceed: - event.ignore() - return - - self.autoSaveClose() - - if self.autoSaveActiveWorkers: - progressWin = apps.QDialogWorkerProgress( - title='Closing autosaving worker', parent=self, - pbarDesc='Closing autosaving worker...' - ) - progressWin.show(self.app) - progressWin.mainPbar.setMaximum(0) - self.waitCloseAutoSaveWorkerLoop = qutils.QWhileLoop( - self._waitCloseAutoSaveWorker, period=250 - ) - self.waitCloseAutoSaveWorkerLoop.exec_() - progressWin.workerFinished = True - progressWin.close() - - self.stopPreprocWorker() - self.stopCombineWorker() - self.stopCcaIntegrityCheckerWorker() - - # Close the inifinte loop of the thread - if self.lazyLoader is not None: - self.lazyLoader.exit = True - self.lazyLoaderWaitCond.wakeAll() - self.waitReadH5cond.wakeAll() - - if self.storeStateWorker is not None: - # Close storeStateWorker - self.storeStateWorker._stop() - while self.storeStateWorker.isFinished: - time.sleep(0.05) - - # Block main thread while separate threads closes - time.sleep(0.1) - - self.clearMemory() - - self.logger.info('Closing GUI logger...') - self.logger.close() - - if self.lazyLoader is None: - self.sigClosed.emit(self) - - gc.collect() - - def storeManualSeparateDrawMode(self, mode): - self.df_settings.at['manual_separate_draw_mode', 'value'] = mode - self.df_settings.to_csv(self.settings_csv_path) - - def readSettings(self): - settings = QSettings('schmollerlab', 'acdc_gui') - if settings.value('geometry') is not None: - self.restoreGeometry(settings.value("geometry")) - # self.restoreState(settings.value("windowState")) - - def saveWindowGeometry(self): - settings = QSettings('schmollerlab', 'acdc_gui') - settings.setValue("geometry", self.saveGeometry()) - # settings.setValue("windowState", self.saveState()) - - def storeDefaultAndCustomColors(self): - c = self.overlayButton.palette().button().color().name() - self.defaultToolBarButtonColor = c - self.doublePressKeyButtonColor = '#fa693b' - - def initPixelSizePropsDockWidget(self): - posData = self.data[self.pos_i] - PhysicalSizeX = posData.PhysicalSizeX - PhysicalSizeY = posData.PhysicalSizeY - PhysicalSizeZ = posData.PhysicalSizeZ - self.guiTabControl.initPixelSize( - PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ - ) - - def showPropsDockWidget(self, checked=False): - if self.showPropsDockButton.isExpand: - self.propsDockWidget.setVisible(False) - self.setHighlightID(False) - else: - self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() - if self.isSegm3D: - self.guiTabControl.propsQGBox.cellVolVox3D_SB.show() - self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.show() - self.guiTabControl.propsQGBox.cellVolFl3D_DSB.show() - self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.show() - else: - self.guiTabControl.propsQGBox.cellVolVox3D_SB.hide() - self.guiTabControl.propsQGBox.cellVolVox3D_SB.label.hide() - self.guiTabControl.propsQGBox.cellVolFl3D_DSB.hide() - self.guiTabControl.propsQGBox.cellVolFl3D_DSB.label.hide() - - self.propsDockWidget.setVisible(True) - self.propsDockWidget.setEnabled(True) - self.updateAllImages() - - def showEvent(self, event): - if self.mainWin is not None: - if not self.mainWin.isMinimized(): - return - self.mainWin.showAllWindows() - # self.setFocus() - self.activateWindow() - - def super_show(self): - super().show() - - def show(self): - self.setFont(_font) - QMainWindow.show(self) - - self.setWindowState(Qt.WindowNoState) - self.setWindowState(Qt.WindowActive) - self.raise_() - - self.readSettings() - self.storeDefaultAndCustomColors() - - self.h = self.navSpinBox.size().height() - fontSizeFactor = None - heightFactor = None - if 'bottom_sliders_zoom_perc' in self.df_settings.index: - val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value']) - if val != 100: - fontSizeFactor = val/100 - heightFactor = val/100 - - self.defaultWidgetHeightBottomLayout = self.h - self.checkBoxesHeight = 14 - self.fontPixelSize = 11 - self.defaultBottomLayoutHeight = self.img1BottomGroupbox.height() - - self.bottomLayout.setStretch(0, 0) - self.bottomLayout.addSpacing(self.quickSettingsGroupbox.width()) - self.resizeSlidersArea( - fontSizeFactor=fontSizeFactor, heightFactor=heightFactor - ) - self.bottomScrollArea.hide() - - self.gui_initImg1BottomWidgets() - self.img1BottomGroupbox.hide() - - w = self.showPropsDockButton.width() - h = self.showPropsDockButton.height() - - self.showPropsDockButton.setMaximumWidth(15) - self.showPropsDockButton.setMaximumHeight(120) - - for toolbar in self.controlToolBars: - toolbar.setMinimumHeight( - self.secondLevelToolbar.sizeHint().height() - ) - - self.graphLayout.setFocus() - - def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): - global _font - if heightFactor is None: - self.newCheckBoxesHeight = self.checkBoxesHeight - self.newHeight = self.h - else: - self.newHeight = round(self.h*heightFactor) - self.newCheckBoxesHeight = round(self.checkBoxesHeight*heightFactor) - - if fontSizeFactor is None: - newFontSize = self.fontPixelSize - else: - newFontSize = round(self.fontPixelSize*fontSizeFactor) - newFont = QFont() - newFont.setPixelSize(newFontSize) - _font = newFont - self.zProjComboBox.setFont(newFont) - self.t_label.setFont(newFont) - self.zProjOverlay_CB.setFont(newFont) - self.annotateRightHowCombobox.setFont(newFont) - self.drawIDsContComboBox.setFont(newFont) - self.showTreeInfoCheckbox.setFont(newFont) - self.highlightZneighObjCheckbox.setFont(newFont) - self.navSpinBox.setFont(newFont) - self.zSliceSpinbox.setFont(newFont) - self.SizeZlabel.setFont(newFont) - self.navSizeLabel.setFont(newFont) - self.overlay_z_label.setFont(newFont) - self.img1BottomGroupbox.setFont(newFont) - self.rightBottomGroupbox.setFont(newFont) - try: - self.img1.alphaScrollbar.label.setFont(newFont) - except Exception as e: - pass - for i in range(self.annotOptionsLayout.count()): - widget = self.annotOptionsLayout.itemAt(i).widget() - widget.setFont(newFont) - for i in range(self.annotOptionsLayoutRight.count()): - widget = self.annotOptionsLayoutRight.itemAt(i).widget() - widget.setFont(newFont) - try: - for channel, items in self.overlayLayersItems.items(): - alphaScrollbar = items[2] - alphaScrollbar.label.setFont(newFont) - except: - pass - QTimer.singleShot(100, self._resizeSlidersArea) - - def _resizeSlidersArea(self): - self.navigateScrollBar.setFixedHeight(self.newHeight) - self.zSliceScrollBar.setFixedHeight(self.newHeight) - self.zSliceOverlay_SB.setFixedHeight(self.newHeight) - self.zProjComboBox.setFixedHeight(self.newHeight) - self.zProjOverlay_CB.setFixedHeight(self.newHeight) - self.navSpinBox.setFixedHeight(self.newHeight) - self.zSliceSpinbox.setFixedHeight(self.newHeight) - try: - self.img1.alphaScrollbar.setFixedHeight(self.newHeight) - except Exception as e: - pass - try: - for channel, items in self.overlayLayersItems.items(): - alphaScrollbar = items[2] - alphaScrollbar.setFixedHeight(self.newHeight) - except: - pass - checkBoxStyleSheet = ( - 'QCheckBox::indicator {' - f'width: {self.newCheckBoxesHeight}px;' - f'height: {self.newCheckBoxesHeight}px' - '}' - ) - for i in range(self.annotOptionsLayout.count()): - widget = self.annotOptionsLayout.itemAt(i).widget() - if isinstance(widget, QCheckBox): - widget.setStyleSheet(checkBoxStyleSheet) - for i in range(self.annotOptionsLayoutRight.count()): - widget = self.annotOptionsLayoutRight.itemAt(i).widget() - if isinstance(widget, QCheckBox): - widget.setStyleSheet(checkBoxStyleSheet) - self.zSliceCheckbox.setStyleSheet(checkBoxStyleSheet) - - def resizeEvent(self, event): - if hasattr(self, 'ax1'): - self.ax1.autoRange() - - def hoverEventDrawSpline(self, event): - x, y = event.pos() - xx, yy = self.curvAnchors.getData() - hoverAnchors = self.curvAnchors.pointsAt(event.pos()) - per = False - # If we are hovering the starting point we generate - # a closed spline - if len(xx) < 2: - return - - if len(hoverAnchors)>0: - xA_hover, yA_hover = hoverAnchors[0].pos() - if xx[0]==xA_hover and yy[0]==yA_hover: - per=True - if per: - # Append start coords and close spline - xx = np.r_[xx, xx[0]] - yy = np.r_[yy, yy[0]] - xi, yi = self.getSpline(xx, yy, per=per) - # self.curvPlotItem.setData([], []) - else: - # Append mouse coords - xx = np.r_[xx, x] - yy = np.r_[yy, y] - xi, yi = self.getSpline(xx, yy, per=per) - self.curvHoverPlotItem.setData(xi, yi) - - def updateViewRangeExportToImage(self, viewRange): - if self.exportToImageWindow is None: - return - - # prevViewRange = self.exportToImageWindow.viewRange() - prevViewRange = self._viewRange - prevXRange = prevViewRange[0] - prevYRange = prevViewRange[1] - currXRange = viewRange[0] - currYRange = viewRange[1] - - prevX0, prevX1 = prevXRange - currX0, currX1 = currXRange - prevY0, prevY1 = prevYRange - currY0, currY1 = currYRange - - deltaX = currX0 - prevX0 - deltaY = currY0 - prevY0 - - winViewRange = self.exportToImageWindow.viewRange() - winXRange = winViewRange[0] - winYRange = winViewRange[1] - winX0, winX1 = winXRange - winY0, winY1 = winYRange - - newX0 = winX0 + deltaX - newX1 = winX1 + deltaX - newY0 = winY0 + deltaY - newY1 = winY1 + deltaY - - self.exportToImageWindow.setViewRange( - (newX0, newX1), (newY0, newY1), emitSignal=False - ) - - def viewRangeChanged(self, viewBox, viewRange, updateExportImageMask=True): - # self.updateViewRangeExportToImage(viewRange) - self.updateValuesStatusBar() - - if hasattr(self, 'scaleBar'): - isScaleBarMoveWithZoom = ( - self.scaleBar.properties()['move_with_zoom'] - ) - else: - isScaleBarMoveWithZoom = False - doMoveScaleBar = ( - self.scaleBarDialog is not None or isScaleBarMoveWithZoom - ) - if doMoveScaleBar: - self.scaleBar.updatePosViewRangeChanged(viewRange) - - if hasattr(self, 'timestamp'): - isTimestampMoveWithZoom = ( - self.timestamp.properties()['move_with_zoom'] - ) - else: - isTimestampMoveWithZoom = False - - doMoveTimestamp = ( - self.timestampDialog is not None or isTimestampMoveWithZoom - ) - if doMoveTimestamp: - self.timestamp.updatePosViewRangeChanged(viewRange) - - self._viewRange = viewRange + self.logger.info('GUI ready.') diff --git a/cellacdc/gui_decorators.py b/cellacdc/gui_decorators.py new file mode 100644 index 000000000..9295db2b9 --- /dev/null +++ b/cellacdc/gui_decorators.py @@ -0,0 +1,79 @@ +"""Decorators shared by guiWin and mixins.""" + +from __future__ import annotations + +import os +import traceback +from functools import wraps + +from qtpy.QtCore import QTimer + +from . import html_utils, widgets + + +def get_data_exception_handler(func): + @wraps(func) + def inner_function(self, *args, **kwargs): + try: + if func.__code__.co_argcount == 1 and func.__defaults__ is None: + result = func(self) + elif func.__code__.co_argcount > 1 and func.__defaults__ is None: + result = func(self, *args) + else: + result = func(self, *args, **kwargs) + except Exception as e: + try: + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + except AttributeError: + pass + result = None + posData = self.data[self.pos_i] + acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) + segm_filename = os.path.basename(posData.segm_npz_path) + traceback_str = traceback.format_exc() + self.logger.exception(traceback_str) + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...') + msg.setDetailedText(traceback_str) + err_msg = html_utils.paragraph(f""" + Error in function {func.__name__}.

+ One possbile explanation is that either the + {acdc_df_filename} file
+ or the segmentation file {segm_filename}
+ are being synchronized by a cloud service (e.g., Google Drive + or OneDrive) or they are corrupted/damaged.

+ Try moving these files (one by one) outside of the + {os.path.dirname(posData.relPath)} folder +
and reloading the data.

+ More details below or in the terminal/console.

+ Note that the error details from this session are + also saved in the following file:

+ {self.log_path}

+ Please send the log file when reporting a bug, thanks! + Please restart Cell-ACDC, we apologise for any inconvenience.

+ + """) + + msg.critical(self, 'Critical error', err_msg) + self.is_error_state = True + raise e + return result + return inner_function + + +def resetViewRange(func): + @wraps(func) + def inner_function(self, *args, **kwargs): + self.storeViewRange() + if func.__code__.co_argcount == 1 and func.__defaults__ is None: + result = func(self) + elif func.__code__.co_argcount > 1 and func.__defaults__ is None: + result = func(self, *args) + else: + result = func(self, *args, **kwargs) + QTimer.singleShot(200, self.resetRange) + return result + return inner_function diff --git a/cellacdc/mixins/__init__.py b/cellacdc/mixins/__init__.py new file mode 100644 index 000000000..f79f6554c --- /dev/null +++ b/cellacdc/mixins/__init__.py @@ -0,0 +1,54 @@ +"""Mixins for gui.py.""" + +from __future__ import annotations + +from .actions import Actions +from .annotation_display import AnnotationDisplay +from .app_shell import AppShell +from .brush_tools import BrushTools +from .canvas_context_menu import CanvasContextMenu +from .canvas_drawing import CanvasDrawing +from .canvas_events import CanvasEvents +from .canvas_hover import CanvasHover +from .canvas_right_image import CanvasRightImage +from .canvas_selection import CanvasSelection +from .canvas_tool import CanvasTool +from .cell_cycle import CellCycle +from .curvature_tools import CurvatureTools +from .custom_annotations import CustomAnnotations +from .data_loading import DataLoading +from .deleted_rois import DeletedRois +from .display_decorations import DisplayDecorations +from .draw_clear_region import DrawClearRegion +from .exporting import Exporting +from .frame_navigation import FrameNavigation +from .geometry import Geometry +from .graphics import Graphics +from .image_controls import ImageControls +from .image_display import ImageDisplay +from .label_editing import LabelEditing +from .label_roi import LabelRoi +from .label_transform_tools import LabelTransformTools +from .layout_controls import LayoutControls +from .lineage_interactions import LineageInteractions +from .magic_prompts import MagicPrompts +from .main_menu import MainMenu +from .main_toolbar import MainToolbar +from .measurements import Measurements +from .mode_controls import ModeControls +from .object_cleanup import ObjectCleanup +from .object_properties import ObjectProperties +from .object_search import ObjectSearch +from .points_layers import PointsLayers +from .preprocessing import Preprocessing +from .quick_settings import QuickSettings +from .saving import Saving +from .seg_for_lost_ids import SegForLostIds +from .segmentation import Segmentation +from .session import Session +from .status_hover import StatusHover +from .tool_activation import ToolActivation +from .tracking import Tracking +from .undo_redo import UndoRedo +from .window_events import WindowEvents +from .worker import Worker diff --git a/cellacdc/mixins_bak/actions.py b/cellacdc/mixins/actions.py similarity index 69% rename from cellacdc/mixins_bak/actions.py rename to cellacdc/mixins/actions.py index d3e0be749..039f34d40 100644 --- a/cellacdc/mixins_bak/actions.py +++ b/cellacdc/mixins/actions.py @@ -14,56 +14,37 @@ shortcut_filepath = os.path.join(settings_folderpath, "shortcuts.ini") -class ActionsMixin: - """Qt-facing adapter around action construction and shortcut editing.""" - - """Headless decisions for action and shortcut workflows.""" - - keyboard_shortcuts_section = "keyboard.shortcuts" - delete_object_section = "delete_object.action" - delete_key_option = "Key sequence" - delete_button_option = "Mouse button" - - def default_delete_object_texts(self, *, is_mac: bool) -> tuple[str, str]: - if is_mac: - return "Ctrl", "Left click" - return "", "Middle click" - - def delete_object_button_is_left_click(self, text: str) -> bool: - return text == "Left click" - - def delete_object_button_text(self, *, is_left_click: bool) -> str: - return "Left click" if is_left_click else "Middle click" +class Actions: + """Extracted from guiWin.""" def editShortcuts_cb(self): if is_mac: - delObjKeySequenceText = "Ctrl" - delObjButtonText = "Left click" + delObjKeySequenceText = 'Ctrl' + delObjButtonText = 'Left click' else: - delObjKeySequenceText = "" - delObjButtonText = "Middle click" - + delObjKeySequenceText = '' + delObjButtonText = 'Middle click' + if self.delObjAction is not None: delObjKeySequence, delObjQtButton = self.delObjAction if delObjKeySequence is None: - delObjKeySequenceText = "" + delObjKeySequenceText = '' else: delObjKeySequenceText = delObjKeySequence.toString() - delObjKeySequenceText = delObjKeySequenceText.encode( - "ascii", "ignore" - ).decode("utf-8") + delObjKeySequenceText = ( + delObjKeySequenceText.encode('ascii', 'ignore').decode('utf-8') + ) delObjButtonText = ( - "Left click" - if delObjQtButton == Qt.MouseButton.LeftButton - else "Middle click" + 'Left click' if delObjQtButton == Qt.MouseButton.LeftButton + else 'Middle click' ) - + win = apps.ShortcutEditorDialog( - self.widgetsWithShortcut, + self.widgetsWithShortcut, delObjectKey=delObjKeySequenceText, delObjectButton=delObjButtonText, zoomOutKeyValue=self.zoomOutKeyValue, - parent=self, + parent=self ) win.exec_() if win.cancel: @@ -88,7 +69,9 @@ def gui_connectActions(self): self.exportToVideoAction.triggered.connect(self.exportToVideoTriggered) self.exportToImageAction.triggered.connect(self.exportToImageTriggered) self.quickSaveAction.triggered.connect(self.quickSave) - self.viewPreprocDataToggle.toggled.connect(self.viewPreprocDataToggled) + self.viewPreprocDataToggle.toggled.connect( + self.viewPreprocDataToggled + ) self.viewCombineChannelDataToggle.toggled.connect( self.viewCombineChannelDataToggled ) @@ -97,8 +80,12 @@ def gui_connectActions(self): self.autoSaveIntervalDialog.sigValueChanged.connect( self.autoSaveIntervalValueChanged ) - self.autoSaveIntervalEditButton.clicked.connect(self.autoSaveIntervalEdit) - self.ccaIntegrCheckerToggle.toggled.connect(self.ccaIntegrCheckerToggled) + self.autoSaveIntervalEditButton.clicked.connect( + self.autoSaveIntervalEdit + ) + self.ccaIntegrCheckerToggle.toggled.connect( + self.ccaIntegrCheckerToggled + ) self.annotLostObjsToggle.toggled.connect(self.annotLostObjsToggled) self.highLowResAction.clicked.connect(self.highLowResToggled) self.showInExplorerAction.triggered.connect(self.showInExplorer_cb) @@ -115,7 +102,9 @@ def gui_connectActions(self): self.editAutoSaveIntervalAction.triggered.connect( self.autoSaveIntervalEditButton.click ) - self.showMirroredCursorAction.toggled.connect(self.showMirroredCursorToggled) + self.showMirroredCursorAction.toggled.connect( + self.showMirroredCursorToggled + ) # Connect Help actions self.tipsAction.triggered.connect(self.showTipsAndTricks) @@ -129,9 +118,15 @@ def gui_connectActions(self): self.showPropsDockButton.sigClicked.connect(self.showPropsDockWidget) - self.loadCustomAnnotationsAction.triggered.connect(self.loadCustomAnnotations) - self.addCustomAnnotationAction.triggered.connect(self.addCustomAnnotation) - self.viewAllCustomAnnotAction.toggled.connect(self.viewAllCustomAnnot) + self.loadCustomAnnotationsAction.triggered.connect( + self.loadCustomAnnotations + ) + self.addCustomAnnotationAction.triggered.connect( + self.addCustomAnnotation + ) + self.viewAllCustomAnnotAction.toggled.connect( + self.viewAllCustomAnnot + ) self.addCustomModelVideoAction.triggered.connect( self.showInstructionsCustomModel ) @@ -140,7 +135,7 @@ def gui_connectActions(self): ) self.addCustomModelFrameAction.callback = self.segmFrameCallback self.addCustomModelVideoAction.callback = self.segmVideoCallback - + self.addCustomPromptModelAction.triggered.connect( self.showInstructionsCustomPromptModel ) @@ -154,7 +149,9 @@ def gui_connectEditActions(self): self.loadFluoAction.setEnabled(True) self.isEditActionsConnected = True - self.preprocessImageAction.triggered.connect(self.preprocessAction.trigger) + self.preprocessImageAction.triggered.connect( + self.preprocessAction.trigger + ) self.combineChannelsAction.triggered.connect( self.combineChannelsActionTriggered ) @@ -175,15 +172,17 @@ def gui_connectEditActions(self): self.findIdAction.triggered.connect(self.findID) self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) self.autoPilotButton.toggled.connect(self.autoPilotToggled) - self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID) + self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID) self.slideshowButton.toggled.connect(self.launchSlideshow) - + self.copyLostObjButton.toggled.connect(self.copyLostObjContour_cb) - self.manualAnnotPastButton.toggled.connect(self.manualAnnotPast_cb) + self.manualAnnotPastButton.toggled.connect( + self.manualAnnotPast_cb + ) self.segmSingleFrameMenu.triggered.connect(self.segmFrameCallback) self.segmVideoMenu.triggered.connect(self.segmVideoCallback) - + self.postProcessSegmAction.toggled.connect(self.postProcessSegm) self.autoSegmAction.toggled.connect(self.autoSegm_cb) self.realTimeTrackingToggle.clicked.connect(self.realTimeTrackingClicked) @@ -191,10 +190,14 @@ def gui_connectEditActions(self): self.manualTrackingButton.toggled.connect(self.manualTracking_cb) self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) self.repeatTrackingMenuAction.triggered.connect(self.repeatTracking) - self.repeatTrackingVideoAction.triggered.connect(self.repeatTrackingVideo) + self.repeatTrackingVideoAction.triggered.connect( + self.repeatTrackingVideo + ) for rtTrackerAction in self.trackingAlgosGroup.actions(): rtTrackerAction.toggled.connect(self.rtTrackerActionToggled) - self.editRtTrackerParamsAction.triggered.connect(self.initRealTimeTracker) + self.editRtTrackerParamsAction.triggered.connect( + self.initRealTimeTracker + ) self.delObjsOutSegmMaskAction.triggered.connect( self.delObjsOutSegmMaskActionTriggered ) @@ -211,14 +214,20 @@ def gui_connectEditActions(self): self.editCcaToolAction.triggered.connect( self.manualEditCcaToolbarActionTriggered ) - self.assignBudMothAutoAction.triggered.connect(self.autoAssignBud_YeastMate) + self.assignBudMothAutoAction.triggered.connect( + self.autoAssignBud_YeastMate + ) self.keepIDsButton.toggled.connect(self.keepIDs_cb) self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) - self.whitelistIDsToolbar.sigWhitelistChanged.connect(self.whitelistIDsChanged) + self.whitelistIDsToolbar.sigWhitelistChanged.connect( + self.whitelistIDsChanged + ) - self.whitelistIDsToolbar.sigWhitelistAccepted.connect(self.whitelistIDsAccepted) + self.whitelistIDsToolbar.sigWhitelistAccepted.connect( + self.whitelistIDsAccepted + ) self.whitelistIDsToolbar.sigViewOGIDs.connect(self.whitelistViewOGIDs) @@ -232,12 +241,15 @@ def gui_connectEditActions(self): self.expandLabelToolButton.toggled.connect(self.expandLabelCallback) - self.reinitLastSegmFrameAction.triggered.connect(self.reInitLastSegmFrame) + self.reinitLastSegmFrameAction.triggered.connect( + self.reInitLastSegmFrame + ) + self.defaultRescaleIntensActionGroup.triggered.connect( self.defaultRescaleIntensLutActionToggled ) - + # self.repeatAutoCcaAction.triggered.connect(self.repeatAutoCca) self.manuallyEditCcaAction.triggered.connect(self.manualEditCca) self.addScaleBarAction.toggled.connect(self.addScaleBar) @@ -253,7 +265,7 @@ def gui_connectEditActions(self): self.modeComboBox.sigTextChanged.connect(self.changeMode) self.modeComboBox.activated.connect(self.clearComboBoxFocus) self.equalizeHistPushButton.toggled.connect(self.equalizeHist) - + self.editOverlayColorAction.triggered.connect(self.toggleOverlayColorButton) self.editTextIDsColorAction.triggered.connect(self.toggleTextIDsColorButton) self.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) @@ -292,7 +304,7 @@ def gui_connectEditActions(self): self.labelsGrad.sigShowLabelsImgToggled.connect(self.showLabelImageItem) self.labelsGrad.sigShowRightImgToggled.connect(self.showRightImageItem) self.labelsGrad.sigShowNextFrameToggled.connect(self.showNextFrameImageItem) - + self.labelsGrad.defaultSettingsAction.triggered.connect( self.restoreDefaultSettings ) @@ -305,7 +317,9 @@ def gui_connectEditActions(self): self.imgGrad.textColorButton.clicked.connect( self.editTextIDsColorAction.trigger ) - self.imgGrad.labelsAlphaSlider.valueChanged.connect(self.updateLabelsAlpha) + self.imgGrad.labelsAlphaSlider.valueChanged.connect( + self.updateLabelsAlpha + ) self.imgGrad.defaultSettingsAction.triggered.connect( self.restoreDefaultSettings ) @@ -332,22 +346,30 @@ def gui_connectEditActions(self): self.drawNothingCheckbox.clicked.connect(self.annotOptionClicked) self.annotNumZslicesCheckbox.clicked.connect(self.annotOptionClicked) - # Right - self.annotIDsCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.annotCcaInfoCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.annotContourCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.annotSegmMasksCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.drawMothBudLinesCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.drawNothingCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.annotNumZslicesCheckboxRight.clicked.connect(self.annotOptionClickedRight) - + # Right + self.annotIDsCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.annotCcaInfoCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.annotContourCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.annotSegmMasksCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.drawMothBudLinesCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.drawNothingCheckboxRight.clicked.connect( + self.annotOptionClickedRight) + self.annotNumZslicesCheckboxRight.clicked.connect( + self.annotOptionClickedRight + ) + self.segmentToolAction.triggered.connect(self.segmentToolActionTriggered) self.addDelRoiAction.triggered.connect(self.addDelROI) self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) self.delBorderObjAction.triggered.connect(self.delBorderObj) self.delNewObjAction.triggered.connect(self.delNewObj) - + self.brushAutoFillCheckbox.toggled.connect(self.brushAutoFillToggled) self.brushAutoHideCheckbox.toggled.connect(self.brushAutoHideToggled) @@ -362,14 +384,14 @@ def gui_connectEditActions(self): # ) self.imgPropertiesAction.triggered.connect(self.editImgProperties) - self.relabelSequentialAction.triggered.connect(self.relabelSequentialCallback) + self.relabelSequentialAction.triggered.connect( + self.relabelSequentialCallback + ) self.zoomToObjsAction.triggered.connect(self.zoomToObjsActionCallback) self.zoomOutAction.triggered.connect(self.zoomOut) self.preprocessAction.triggered.connect(self.preprocessActionTriggered) - self.combineChannelsAction.triggered.connect( - self.combineChannelsActionTriggered - ) + self.combineChannelsAction.triggered.connect(self.combineChannelsActionTriggered) self.viewCcaTableAction.triggered.connect(self.viewCcaTable) @@ -389,7 +411,7 @@ def gui_connectEditActions(self): intensMeasurQGBox.channelCombobox.currentTextChanged.connect( self.updatePropsWidget ) - + propsQGBox = self.guiTabControl.propsQGBox propsQGBox.additionalPropsCombobox.currentTextChanged.connect( self.updatePropsWidget @@ -397,17 +419,17 @@ def gui_connectEditActions(self): def gui_createActions(self): # File actions - self.segmNdimIndicator = widgets.ToolButtonTextIcon(text="") + self.segmNdimIndicator = widgets.ToolButtonTextIcon(text='') self.segmNdimIndicator.setCheckable(True) self.segmNdimIndicator.setChecked(True) - # self.segmNdimIndicator.setDisabled(True) - + # self.segmNdimIndicator.setDisabled(True) + if self.debug: self.createEmptyDataAction = QAction(self) self.createEmptyDataAction.setText("DEBUG: Create empty data") - + self.newWindowAction = QAction("New Window", self) - + self.newAction = QAction(self) self.newAction.setText("&New Segmentation File...") self.newAction.setIcon(QIcon(":file-new.svg")) @@ -415,7 +437,7 @@ def gui_createActions(self): QIcon(":folder-open.svg"), "&Load Folder...", self ) self.openFileAction = QAction( - QIcon(":image.svg"), "&Open Image/Video File...", self + QIcon(":image.svg"),"&Open Image/Video File...", self ) self.manageVersionsAction = QAction( QIcon(":manage_versions.svg"), "Load Older Versions...", self @@ -431,8 +453,8 @@ def gui_createActions(self): # self.reloadAction = QAction( # QIcon(":reload.svg"), "Reload segmentation file", self # ) - self.nextAction = QAction("Next", self) - self.prevAction = QAction("Previous", self) + self.nextAction = QAction('Next', self) + self.prevAction = QAction('Previous', self) self.showInExplorerAction = QAction( QIcon(":drawer.svg"), f"&{self.openFolderText}", self ) @@ -440,17 +462,17 @@ def gui_createActions(self): self.undoAction = QAction(QIcon(":undo.svg"), "Undo", self) self.redoAction = QAction(QIcon(":redo.svg"), "Redo", self) # String-based key sequences - self.newWindowAction.setShortcut("Ctrl+Shift+N") - self.newAction.setShortcut("Ctrl+N") - self.openFolderAction.setShortcut("Ctrl+O") - self.loadPosAction.setShortcut("Shift+P") - self.saveAsAction.setShortcut("Ctrl+Shift+S") - self.exportToVideoAction.setShortcut("Ctrl+Shift+V") - self.exportToImageAction.setShortcut("Ctrl+Shift+I") - self.saveAction.setShortcut("Ctrl+Alt+S") - self.quickSaveAction.setShortcut("Ctrl+S") - self.undoAction.setShortcut("Ctrl+Z") - self.redoAction.setShortcut("Ctrl+Y") + self.newWindowAction.setShortcut('Ctrl+Shift+N') + self.newAction.setShortcut('Ctrl+N') + self.openFolderAction.setShortcut('Ctrl+O') + self.loadPosAction.setShortcut('Shift+P') + self.saveAsAction.setShortcut('Ctrl+Shift+S') + self.exportToVideoAction.setShortcut('Ctrl+Shift+V') + self.exportToImageAction.setShortcut('Ctrl+Shift+I') + self.saveAction.setShortcut('Ctrl+Alt+S') + self.quickSaveAction.setShortcut('Ctrl+S') + self.undoAction.setShortcut('Ctrl+Z') + self.redoAction.setShortcut('Ctrl+Y') self.nextAction.setShortcut(Qt.Key_Right) self.prevAction.setShortcut(Qt.Key_Left) self.addAction(self.nextAction) @@ -463,30 +485,34 @@ def gui_createActions(self): self.autoPilotButton = QAction(self) self.autoPilotButton.setIcon(QIcon(":auto-pilot.svg")) self.autoPilotButton.setCheckable(True) - self.autoPilotButton.setShortcut("Ctrl+Shift+A") - + self.autoPilotButton.setShortcut('Ctrl+Shift+A') + self.findIdAction = QAction(self) self.findIdAction.setIcon(QIcon(":find.svg")) - self.findIdAction.setShortcut("Ctrl+F") - + self.findIdAction.setShortcut('Ctrl+F') + self.zoomRectButton = QToolButton(self) self.zoomRectButton.setIcon(QIcon(":zoom_rect.svg")) self.zoomRectButton.setCheckable(True) - self.zoomRectButton.setShortcut("Shift+Z") + self.zoomRectButton.setShortcut('Shift+Z') self.LeftClickButtons.append(self.zoomRectButton) self.checkableButtons.append(self.zoomRectButton) self.checkableQButtonsGroup.addButton(self.zoomRectButton) - self.widgetsWithShortcut["Zoom to rectangular area"] = self.zoomRectButton - + self.widgetsWithShortcut['Zoom to rectangular area'] = ( + self.zoomRectButton + ) + self.skipToNewIdAction = QAction(self) self.skipToNewIdAction.setIcon(QIcon(":skip_forward_new_ID.svg")) - self.skipToNewIdAction.setShortcut(widgets.KeySequenceFromText(Qt.Key_PageUp)) + self.skipToNewIdAction.setShortcut( + widgets.KeySequenceFromText(Qt.Key_PageUp) + ) self.skipToNewIdAction.setDisabled(True) # Edit actions models = myutils.get_list_of_models() - models = [*models, "local_seg"] # Add local_seg for SegForLostIDsAction + models = [*models, 'local_seg'] # Add local_seg for SegForLostIDsAction self.segmActions = [] self.modelNames = [] self.acdcSegment_li = [] @@ -499,12 +525,14 @@ def gui_createActions(self): self.acdcSegment_li.append(None) action.setDisabled(True) - self.addCustomModelFrameAction = QAction("Add custom model...", self) - self.addCustomModelVideoAction = QAction("Add custom model...", self) - - self.segmWithPromptableModelAction = QAction("Select promptable model...", self) + self.addCustomModelFrameAction = QAction('Add custom model...', self) + self.addCustomModelVideoAction = QAction('Add custom model...', self) + + self.segmWithPromptableModelAction = QAction( + 'Select promptable model...', self + ) self.addCustomPromptModelAction = QAction( - "Add custom promptable model...", self + 'Add custom promptable model...', self ) self.segmActionsVideo = [] @@ -513,7 +541,9 @@ def gui_createActions(self): self.segmActionsVideo.append(action) action.setDisabled(True) - self.postProcessSegmAction = QAction("Segmentation post-processing...", self) + self.postProcessSegmAction = QAction( + "Segmentation post-processing...", self + ) self.postProcessSegmAction.setDisabled(True) self.postProcessSegmAction.setCheckable(True) @@ -527,31 +557,32 @@ def gui_createActions(self): self.repeatTrackingAction = QAction( QIcon(":repeat-tracking.svg"), "Repeat tracking", self ) - self.repeatTrackingAction.setShortcut("Shift+T") - self.widgetsWithShortcut["Repeat Tracking"] = self.repeatTrackingAction + self.repeatTrackingAction.setShortcut('Shift+T') + self.widgetsWithShortcut['Repeat Tracking'] = self.repeatTrackingAction + self.editRtTrackerParamsAction = QAction( - "Edit real-time tracker parameters...", self + 'Edit real-time tracker parameters...', self ) - + self.repeatTrackingMenuAction = QAction( - "Track current frame with real-time tracker...", self + 'Track current frame with real-time tracker...', self ) self.repeatTrackingMenuAction.setDisabled(True) - self.repeatTrackingMenuAction.setShortcut("Shift+T") + self.repeatTrackingMenuAction.setShortcut('Shift+T') self.repeatTrackingVideoAction = QAction( - "Select a tracker and track multiple frames...", self + 'Select a tracker and track multiple frames...', self ) self.repeatTrackingVideoAction.setDisabled(True) - self.repeatTrackingVideoAction.setShortcut("Alt+Shift+T") + self.repeatTrackingVideoAction.setShortcut('Alt+Shift+T') self.trackingAlgosGroup = QActionGroup(self) - self.trackWithAcdcAction = QAction("Cell-ACDC", self) + self.trackWithAcdcAction = QAction('Cell-ACDC', self) self.trackWithAcdcAction.setCheckable(True) self.trackingAlgosGroup.addAction(self.trackWithAcdcAction) - self.trackWithYeazAction = QAction("YeaZ", self) + self.trackWithYeazAction = QAction('YeaZ', self) self.trackWithYeazAction.setCheckable(True) self.trackingAlgosGroup.addAction(self.trackWithYeazAction) @@ -564,13 +595,13 @@ def gui_createActions(self): self.trackWithAcdcAction.setChecked(True) aliases = myutils.aliases_real_time_trackers() - if "tracking_algorithm" in self.df_settings.index: - trackingAlgo = self.df_settings.at["tracking_algorithm", "value"] + if 'tracking_algorithm' in self.df_settings.index: + trackingAlgo = self.df_settings.at['tracking_algorithm', 'value'] if trackingAlgo in aliases: trackingAlgo = aliases[trackingAlgo] - if trackingAlgo == "Cell-ACDC": + if trackingAlgo == 'Cell-ACDC': self.trackWithAcdcAction.setChecked(True) - elif trackingAlgo == "YeaZ": + elif trackingAlgo == 'YeaZ': self.trackWithYeazAction.setChecked(True) else: for rtTrackerAction in self.trackingAlgosGroup.actions(): @@ -578,9 +609,9 @@ def gui_createActions(self): rtTrackerAction.setChecked(True) break - self.setMeasurementsAction = QAction("Set measurements...") - self.addCustomMetricAction = QAction("Add custom measurement...") - self.addCombineMetricAction = QAction("Add combined measurement...") + self.setMeasurementsAction = QAction('Set measurements...') + self.addCustomMetricAction = QAction('Add custom measurement...') + self.addCombineMetricAction = QAction('Add combined measurement...') # Standard key sequence # self.copyAction.setShortcut(QKeySequence.StandardKey.Copy) @@ -608,90 +639,109 @@ def gui_createActions(self): self.reInitCcaAction.setIcon(QIcon(":reinitCca.svg")) self.reInitCcaAction.setVisible(False) - self.toggleColorSchemeAction = QAction("Switch to light theme") + self.toggleColorSchemeAction = QAction( + 'Switch to light theme' + ) self.gui_updateSwitchColorSchemeActionText() - - self.pxModeAction = widgets.CheckableAction("Fixed size text annotations") + + self.pxModeAction = widgets.CheckableAction( + 'Fixed size text annotations' + ) self.pxModeAction.setChecked(True) pxModeTooltip = ( - "When the text annotations are with fixed size they scale relative " - "to the object when zooming in/out (fixed size in pixels).\n" - "This is typically faster to render, but it makes annotations " - "smaller/larger when zooming in/out, respectively.\n\n" - "Try activating it to speed up the annotation of many objects " - "in high resolution mode.\n\n" - "After activating it, you might need to increase the font size " - "from the menu on the top menubar `Edit --> Font size`." + 'When the text annotations are with fixed size they scale relative ' + 'to the object when zooming in/out (fixed size in pixels).\n' + 'This is typically faster to render, but it makes annotations ' + 'smaller/larger when zooming in/out, respectively.\n\n' + 'Try activating it to speed up the annotation of many objects ' + 'in high resolution mode.\n\n' + 'After activating it, you might need to increase the font size ' + 'from the menu on the top menubar `Edit --> Font size`.' ) self.pxModeAction.setToolTip(pxModeTooltip) - + self.highLowResAction = widgets.CheckableAction( - "High resolution text annotations" + 'High resolution text annotations' ) highLowResTooltip = ( - "Resolution of the text annotations. High resolution results " - "in slower update of the annotations.\n" - "Not recommended with a number of segmented objects > 500.\n\n" + 'Resolution of the text annotations. High resolution results ' + 'in slower update of the annotations.\n' + 'Not recommended with a number of segmented objects > 500.\n\n' ) self.highLowResAction.setToolTip(highLowResTooltip) - + self.editAutoSaveIntervalAction = QAction( - "Change autosave interval (minutes or frames)...", self + 'Change autosave interval (minutes or frames)...', self + ) + + self.editShortcutsAction = QAction( + 'Customize keyboard shortcuts...', self + ) + self.editShortcutsAction.setShortcut('Ctrl+K') + + self.showMirroredCursorAction = QAction( + 'Show mirrored cursor on images', self ) - - self.editShortcutsAction = QAction("Customize keyboard shortcuts...", self) - self.editShortcutsAction.setShortcut("Ctrl+K") - - self.showMirroredCursorAction = QAction("Show mirrored cursor on images", self) self.showMirroredCursorAction.setCheckable(True) - if "showMirroredCursor" in self.df_settings.index: - checked = self.df_settings.at["showMirroredCursor", "value"] == "Yes" + if 'showMirroredCursor' in self.df_settings.index: + checked = self.df_settings.at['showMirroredCursor', 'value'] == 'Yes' self.showMirroredCursorAction.setChecked(checked) else: self.showMirroredCursorAction.setChecked(True) - self.showMirroredCursorAction.setShortcut("Ctrl+M") + self.showMirroredCursorAction.setShortcut('Ctrl+M') - self.editTextIDsColorAction = QAction("Text annotation color...", self) + self.editTextIDsColorAction = QAction('Text annotation color...', self) self.editTextIDsColorAction.setDisabled(True) - self.editOverlayColorAction = QAction("Overlay color...", self) + self.editOverlayColorAction = QAction('Overlay color...', self) self.editOverlayColorAction.setDisabled(True) - self.manuallyEditCcaAction = QAction("Edit cell cycle annotations...", self) - self.manuallyEditCcaAction.setShortcut("Ctrl+Shift+P") + self.manuallyEditCcaAction = QAction( + 'Edit cell cycle annotations...', self + ) + self.manuallyEditCcaAction.setShortcut('Ctrl+Shift+P') self.manuallyEditCcaAction.setDisabled(True) - self.viewCcaTableAction = QAction("View cell cycle annotations...", self) + self.viewCcaTableAction = QAction( + 'View cell cycle annotations...', self + ) self.viewCcaTableAction.setDisabled(True) - self.viewCcaTableAction.setShortcut("Ctrl+P") - - self.addScaleBarAction = QAction("Add scale bar", self) + self.viewCcaTableAction.setShortcut('Ctrl+P') + + + self.addScaleBarAction = QAction('Add scale bar', self) self.addScaleBarAction.setCheckable(True) - - self.addTimestampAction = QAction("Add timestamp", self) + + self.addTimestampAction = QAction('Add timestamp', self) self.addTimestampAction.setCheckable(True) - self.invertBwAction = QAction("Invert black/white", self) + self.invertBwAction = QAction('Invert black/white', self) self.invertBwAction.setCheckable(True) - checked = self.df_settings.at["is_bw_inverted", "value"] == "Yes" + checked = self.df_settings.at['is_bw_inverted', 'value'] == 'Yes' self.invertBwAction.setChecked(checked) - self.shuffleCmapAction = QAction("Randomly shuffle colormap", self) - self.shuffleCmapAction.setShortcut("Shift+S") + self.shuffleCmapAction = QAction('Randomly shuffle colormap', self) + self.shuffleCmapAction.setShortcut('Shift+S') - self.greedyShuffleCmapAction = QAction("Greedily shuffle colormap", self) - self.greedyShuffleCmapAction.setShortcut("Alt+Shift+S") + self.greedyShuffleCmapAction = QAction( + 'Greedily shuffle colormap', self + ) + self.greedyShuffleCmapAction.setShortcut('Alt+Shift+S') - self.saveLabColormapAction = QAction("Save labels colormap...", self) + self.saveLabColormapAction = QAction( + 'Save labels colormap...', self + ) - self.normalizeRawAction = QAction("Do not normalize. Display raw image", self) + self.normalizeRawAction = QAction( + 'Do not normalize. Display raw image', self) self.normalizeToFloatAction = QAction( - "Convert to floating point format with values [0, 1]", self - ) + 'Convert to floating point format with values [0, 1]', self) # self.normalizeToUbyteAction = QAction( # 'Rescale to 8-bit unsigned integer format with values [0, 255]', self) - self.normalizeRescale0to1Action = QAction("Rescale to [0, 1]", self) - self.normalizeByMaxAction = QAction("Normalize by max value", self) + self.normalizeRescale0to1Action = QAction( + 'Rescale to [0, 1]', self) + self.normalizeByMaxAction = QAction( + 'Normalize by max value', self) self.normalizeRawAction.setCheckable(True) self.normalizeToFloatAction.setCheckable(True) # self.normalizeToUbyteAction.setCheckable(True) @@ -704,216 +754,205 @@ def gui_createActions(self): self.normalizeQActionGroup.addAction(self.normalizeRescale0to1Action) self.normalizeQActionGroup.addAction(self.normalizeByMaxAction) - self.preprocessAction = QAction("Pre-processing...", self) - self.preprocessAction.setShortcut("Alt+Shift+P") + self.preprocessAction = QAction( + 'Pre-processing...', self + ) + self.preprocessAction.setShortcut('Alt+Shift+P') self.combineChannelsAction = QAction( - "Combine and manipulate channels and/or segmentation files...", self + 'Combine and manipulate channels and/or segmentation files...', self + ) + self.combineChannelsAction.setShortcut('Alt+Shift+C') + + self.zoomToObjsAction = QAction( + 'Zoom to objects (Shortcut: H key)', self + ) + self.zoomOutAction = QAction( + 'Zoom out (Shortcut: double press H key)', self ) - self.combineChannelsAction.setShortcut("Alt+Shift+C") - - self.zoomToObjsAction = QAction("Zoom to objects (Shortcut: H key)", self) - self.zoomOutAction = QAction("Zoom out (Shortcut: double press H key)", self) - self.relabelSequentialAction = QAction("Relabel IDs sequentially...", self) - self.relabelSequentialAction.setShortcut("Ctrl+L") + self.relabelSequentialAction = QAction( + 'Relabel IDs sequentially...', self + ) + self.relabelSequentialAction.setShortcut('Ctrl+L') self.relabelSequentialAction.setDisabled(True) self.setLastUserNormAction() - self.autoSegmAction = QAction("Enable automatic segmentation", self) + self.autoSegmAction = QAction( + 'Enable automatic segmentation', self) self.autoSegmAction.setCheckable(True) self.autoSegmAction.setDisabled(True) self.enableSmartTrackAction = QAction( - "Smart handling of enabling/disabling tracking", self - ) + 'Smart handling of enabling/disabling tracking', self) self.enableSmartTrackAction.setCheckable(True) self.enableSmartTrackAction.setChecked(True) self.enableAutoZoomToCellsAction = QAction( - 'Automatic zoom to all cells when pressing "Next/Previous"', self - ) + 'Automatic zoom to all cells when pressing "Next/Previous"', self) self.enableAutoZoomToCellsAction.setCheckable(True) - self.imgPropertiesAction = QAction("Properties...", self) + self.imgPropertiesAction = QAction('Properties...', self) self.imgPropertiesAction.setDisabled(True) self.addDelRoiAction = QAction(self) - self.addDelRoiAction.roiType = "rect" + self.addDelRoiAction.roiType = 'rect' self.addDelRoiAction.setIcon(QIcon(":addDelRoi.svg")) - + self.addDelPolyLineRoiButton = QToolButton(self) self.addDelPolyLineRoiButton.setCheckable(True) self.addDelPolyLineRoiButton.setIcon(QIcon(":addDelPolyLineRoi.svg")) - + self.checkableButtons.append(self.addDelPolyLineRoiButton) self.LeftClickButtons.append(self.addDelPolyLineRoiButton) - + self.delBorderObjAction = QAction(self) self.delBorderObjAction.setIcon(QIcon(":delBorderObj.svg")) - + self.delNewObjAction = QAction(self) self.delNewObjAction.setIcon(QIcon(":delNewObj.svg")) self.loadCustomAnnotationsAction = QAction(self) self.loadCustomAnnotationsAction.setIcon(QIcon(":load_annotation.svg")) self.loadCustomAnnotationsAction.setToolTip( - "Load previously used custom annotations" + 'Load previously used custom annotations' ) - + self.addCustomAnnotationAction = QAction(self) self.addCustomAnnotationAction.setIcon(QIcon(":addCustomAnnotation.svg")) - self.addCustomAnnotationAction.setToolTip("Add custom annotation") + self.addCustomAnnotationAction.setToolTip('Add custom annotation') # self.functionsNotTested3D.append(self.addCustomAnnotationAction) self.viewAllCustomAnnotAction = QAction(self) self.viewAllCustomAnnotAction.setCheckable(True) self.viewAllCustomAnnotAction.setIcon(QIcon(":eye.svg")) - self.viewAllCustomAnnotAction.setToolTip("Show all custom annotations") + self.viewAllCustomAnnotAction.setToolTip('Show all custom annotations') def gui_updateSwitchColorSchemeActionText(self): - if self._colorScheme == "dark": - txt = "Switch to light theme" + if self._colorScheme == 'dark': + txt = 'Switch to light theme' else: - txt = "Switch to dark theme" + txt = 'Switch to dark theme' self.toggleColorSchemeAction.setText(txt) def initShortcuts(self): from . import config - cp = config.ConfigParser() if os.path.exists(shortcut_filepath): cp.read(shortcut_filepath) - - if "keyboard.shortcuts" not in cp: - cp["keyboard.shortcuts"] = {} - - if cp.has_option("keyboard.shortcuts", "Zoom out"): - zoomOutKeyValueStr = cp["keyboard.shortcuts"]["Zoom out"] + + if 'keyboard.shortcuts' not in cp: + cp['keyboard.shortcuts'] = {} + + if cp.has_option('keyboard.shortcuts', 'Zoom out'): + zoomOutKeyValueStr = cp['keyboard.shortcuts']['Zoom out'] try: self.zoomOutKeyValue = int(zoomOutKeyValueStr) - except Exception: + except Exception as err: self.logger.warning( - f"{zoomOutKeyValueStr} is not a valid key " + f'{zoomOutKeyValueStr} is not a valid key ' 'zooming out action. Restoring default key "H".' ) - - if "delete_object.action" not in cp: + + if 'delete_object.action' not in cp: self.delObjAction = None else: - delObjKeySequenceText = cp["delete_object.action"]["Key sequence"] - delObjButtonText = cp["delete_object.action"]["Mouse button"] + delObjKeySequenceText = cp['delete_object.action']['Key sequence'] + delObjButtonText = cp['delete_object.action']['Mouse button'] delObjQtButton = ( - Qt.MouseButton.LeftButton - if delObjButtonText == "Left click" + Qt.MouseButton.LeftButton if delObjButtonText == 'Left click' else Qt.MouseButton.MiddleButton ) if not delObjKeySequenceText: delObjKeySequence = None else: - delObjKeySequence = widgets.KeySequenceFromText(delObjKeySequenceText) + delObjKeySequence = widgets.KeySequenceFromText( + delObjKeySequenceText + ) self.delObjToolAction.setChecked(True) self.delObjAction = delObjKeySequence, delObjQtButton - + shortcuts = {} for name, widget in self.widgetsWithShortcut.items(): - if name not in cp.options("keyboard.shortcuts"): - if hasattr(widget, "keyPressShortcut"): + if name not in cp.options('keyboard.shortcuts'): + if hasattr(widget, 'keyPressShortcut'): key = widget.keyPressShortcut shortcut = widgets.KeySequenceFromText(key) else: shortcut = widget.shortcut() shortcut_text = shortcut.toString() - cp["keyboard.shortcuts"][name] = shortcut_text + cp['keyboard.shortcuts'][name] = shortcut_text else: - shortcut_text = cp["keyboard.shortcuts"][name] + shortcut_text = cp['keyboard.shortcuts'][name] shortcut = widgets.KeySequenceFromText(shortcut_text) - + shortcuts[name] = (shortcut_text, shortcut) self.setShortcuts(shortcuts, save=False) - with open(shortcut_filepath, "w") as ini: + with open(shortcut_filepath, 'w') as ini: cp.write(ini) - def sanitize_key_sequence_text(self, text) -> str: - if text is None: - return "" - return str(text).encode("ascii", "ignore").decode("utf-8") - def setShortcuts(self, shortcuts: dict, save=True): for name, (text, shortcut) in shortcuts.items(): widget = self.widgetsWithShortcut[name] if shortcut is None: shortcut = QKeySequence() - if hasattr(widget, "keyPressShortcut"): + if hasattr(widget, 'keyPressShortcut'): widget.keyPressShortcut = shortcut else: widget.setShortcut(shortcut) s = widget.toolTip() toolTip = re.sub(r'Shortcut: "(.*)"', f'Shortcut: "{text}"', s) widget.setToolTip(toolTip) - - if not save: + + if not save: return - + from . import config - cp = config.ConfigParser() if os.path.exists(shortcut_filepath): cp.read(shortcut_filepath) - - if "keyboard.shortcuts" not in cp: - cp["keyboard.shortcuts"] = {} + + if 'keyboard.shortcuts' not in cp: + cp['keyboard.shortcuts'] = {} for name, (text, shortcut) in shortcuts.items(): - cp["keyboard.shortcuts"][name] = text - - cp["keyboard.shortcuts"]["Zoom out"] = str(self.zoomOutKeyValue) - + cp['keyboard.shortcuts'][name] = text + + cp['keyboard.shortcuts']['Zoom out'] = str(self.zoomOutKeyValue) + if self.delObjAction is None: - with open(shortcut_filepath, "w") as ini: + with open(shortcut_filepath, 'w') as ini: cp.write(ini) return - + delObjKeySequence, delObjQtButton = self.delObjAction try: if delObjKeySequence is None: - delObjKeySequenceText = "" + delObjKeySequenceText = '' else: delObjKeySequenceText = delObjKeySequence.toString() - - delObjKeySequenceText = delObjKeySequenceText.encode( - "ascii", "ignore" - ).decode("utf-8") + + delObjKeySequenceText = ( + delObjKeySequenceText + .encode('ascii', 'ignore') + .decode('utf-8') + ) delObjButtonText = ( - "Left click" - if delObjQtButton == Qt.MouseButton.LeftButton - else "Middle click" + 'Left click' if delObjQtButton == Qt.MouseButton.LeftButton + else 'Middle click' ) - cp["delete_object.action"] = { - "Key sequence": delObjKeySequenceText, - "Mouse button": delObjButtonText, + cp['delete_object.action'] = { + 'Key sequence': delObjKeySequenceText, + 'Mouse button': delObjButtonText } - except Exception: + except Exception as err: self.logger.warning( - f"{delObjKeySequence} is not a valid keys sequence for " - "deleting objects. Setting default action" + f'{delObjKeySequence} is not a valid keys sequence for ' + 'deleting objects. Setting default action' ) self.delObjAction = None - cp.remove_section("delete_object.action") - - with open(shortcut_filepath, "w") as ini: + cp.remove_section('delete_object.action') + + with open(shortcut_filepath, 'w') as ini: cp.write(ini) - - def should_restore_default_delete_action(self, *, had_error: bool) -> bool: - return had_error - - LEGACY_METHODS = ( - "gui_createActions", - "gui_updateSwitchColorSchemeActionText", - "gui_connectActions", - "initShortcuts", - "setShortcuts", - "editShortcuts_cb", - "gui_connectEditActions", - ) diff --git a/cellacdc/mixins_bak/annotation_display.py b/cellacdc/mixins/annotation_display.py similarity index 52% rename from cellacdc/mixins_bak/annotation_display.py rename to cellacdc/mixins/annotation_display.py index 16cb60a13..5b470239b 100644 --- a/cellacdc/mixins_bak/annotation_display.py +++ b/cellacdc/mixins/annotation_display.py @@ -11,223 +11,15 @@ GREEN_HEX = _palettes.green() -class AnnotationDisplayMixin: - """Qt-facing adapter around annotation display and tool state.""" - - # @exec_time - - # @exec_time - - def _annotation_clicked_option(self, side, sender): - for name, widget in self._annotation_option_widgets(side).items(): - if sender == widget: - return name - return None - - def _annotation_option_state(self, side): - widgets = self._annotation_option_widgets(side) - return {name: widget.isChecked() for name, widget in widgets.items()} - - def _annotation_option_widgets(self, side): - if side == "right": - return { - "ids": self.annotIDsCheckboxRight, - "cca": self.annotCcaInfoCheckboxRight, - "contours": self.annotContourCheckboxRight, - "segm_masks": self.annotSegmMasksCheckboxRight, - "mother_bud_lines": self.drawMothBudLinesCheckboxRight, - "num_zslices": self.annotNumZslicesCheckboxRight, - "nothing": self.drawNothingCheckboxRight, - } - return { - "ids": self.annotIDsCheckbox, - "cca": self.annotCcaInfoCheckbox, - "contours": self.annotContourCheckbox, - "segm_masks": self.annotSegmMasksCheckbox, - "mother_bud_lines": self.drawMothBudLinesCheckbox, - "num_zslices": self.annotNumZslicesCheckbox, - "nothing": self.drawNothingCheckbox, - } - - def _apply_add_new_ids_whitelist_toggle(self, checked): - self.addNewIDsWhitelistToggle = checked - - def _apply_annotation_mode_combobox_restore(self, side, text): - if side == "right": - self.annotateRightHowCombobox.setCurrentText(text) - else: - self.drawIDsContComboBox.setCurrentText(text) - - def _apply_annotation_mode_restore_callback(self, side): - if side == "right": - self.annotateRightHowCombobox_cb(0) - else: - self.drawIDsContComboBox_cb(0) - - def _apply_annotation_mode_text_update( - self, - side, - text, - save_settings, - ): - if side == "right": - combo = self.annotateRightHowCombobox - callback = self.annotateRightHowCombobox_cb - else: - combo = self.drawIDsContComboBox - callback = self.drawIDsContComboBox_cb - - if text == combo.currentText(): - callback(0) - - combo.saveSettings = save_settings - combo.setCurrentText(text) - - def _apply_annotation_option_checked(self, side, option, checked): - widgets = self._annotation_option_widgets(side) - widgets[option].setChecked(checked) - - def _apply_annotation_option_disabled(self, side, option, disabled): - widgets = self._annotation_option_widgets(side) - widgets[option].setDisabled(disabled) - - def _apply_annotation_option_states(self, side, state): - widgets = self._annotation_option_widgets(side) - widgets["ids"].setChecked(state.ids) - widgets["cca"].setChecked(state.cca) - widgets["contours"].setChecked(state.contours) - widgets["segm_masks"].setChecked(state.segm_masks) - widgets["mother_bud_lines"].setChecked(state.mother_bud_lines) - widgets["num_zslices"].setChecked(state.num_zslices) - widgets["nothing"].setChecked(state.nothing) - - def _apply_annotation_option_visible(self, side, option, visible): - widgets = self._annotation_option_widgets(side) - widgets[option].setVisible(visible) - - def _apply_gen_num_tree_annotations_enabled(self, checked): - self.textAnnot[0].setGenNumTreeAnnotationsEnabled(checked) - - def _apply_label_tree_annotations_enabled(self, checked): - self.textAnnot[0].setLabelTreeAnnotationsEnabled(checked) - - def _apply_text_annotation_flags( - self, - ax, - is_cca_annotation, - is_id_annotation, - ): - self.textAnnot[ax].setCcaAnnot(is_cca_annotation) - self.textAnnot[ax].setLabelAnnot(is_id_annotation) - - def _apply_text_annotation_pixel_mode(self, checked): - for ax in range(2): - self.textAnnot[ax].setPxMode(checked) - - def _apply_text_resolution_change(self, mode): - self.setAllIDs() - posData = self.data[self.pos_i] - allIDs = posData.allIDs - img_shape = self.img1.image.shape[:2] - self.textAnnot[0].changeResolution(mode, allIDs, self.ax1, img_shape) - self.textAnnot[1].changeResolution(mode, allIDs, self.ax2, img_shape) - - def _apply_tree_annotation_menu_action( - self, - menu_name, - text, - should_contain_text, - checked, - ): - if menu_name == "id": - menu = self.annotSettingsIDmenu - else: - menu = self.annotSettingsGenNumMenu - - for action in menu.actions(): - text_found = action.text().find(text) != -1 - if text_found == should_contain_text: - action.setChecked(checked) - break - - def _apply_view_model_setting_update(self, setting, value): - self.df_settings.at[setting, "value"] = value - self.df_settings.to_csv(self.settings_csv_path) - - def _apply_z_neighbor_highlight_checked(self, checked): - self.highlightZneighObjCheckbox.setChecked(checked) - - def _apply_z_neighbor_highlight_visible(self, visible): - self.highlightZneighObjCheckbox.setVisible(visible) - - def _connect_view_model_signals(self): - self.settingUpdateRequested.connect(self._apply_view_model_setting_update) - self.textAnnotationFlagsChanged.connect(self._apply_text_annotation_flags) - self.imageRefreshRequested.connect(self._refresh_images_from_view_model) - self.eraserTempResetRequested.connect(self._reset_eraser_temp_from_view_model) - self.annotationOptionStatesChanged.connect(self._apply_annotation_option_states) - self.annotationModeTextUpdateRequested.connect( - self._apply_annotation_mode_text_update - ) - self.textAnnotationPixelModeChanged.connect( - self._apply_text_annotation_pixel_mode - ) - self.logInfoRequested.connect(self.logger.info) - self.pixelModeActionDisabledChanged.connect(self.pxModeAction.setDisabled) - self.textResolutionChangeRequested.connect(self._apply_text_resolution_change) - self.treeAnnotationMenuActionRequested.connect( - self._apply_tree_annotation_menu_action - ) - self.labelTreeAnnotationsEnabledChanged.connect( - self._apply_label_tree_annotations_enabled - ) - self.genNumTreeAnnotationsEnabledChanged.connect( - self._apply_gen_num_tree_annotations_enabled - ) - self.allTextAnnotationsRefreshRequested.connect(self.setAllTextAnnotations) - self.annotationOptionDisabledChanged.connect( - self._apply_annotation_option_disabled - ) - self.annotationOptionVisibleChanged.connect( - self._apply_annotation_option_visible - ) - self.annotationOptionCheckedChanged.connect( - self._apply_annotation_option_checked - ) - self.zNeighborHighlightVisibleChanged.connect( - self._apply_z_neighbor_highlight_visible - ) - self.zNeighborHighlightCheckedChanged.connect( - self._apply_z_neighbor_highlight_checked - ) - self.zNeighborHighlightToggleConnectionRequested.connect( - self._connect_z_neighbor_highlight_toggle - ) - self.annotationModeComboboxRestoreRequested.connect( - self._apply_annotation_mode_combobox_restore - ) - self.addNewIdsWhitelistToggleChanged.connect( - self._apply_add_new_ids_whitelist_toggle - ) - self.annotationModeRestoreCallbackRequested.connect( - self._apply_annotation_mode_restore_callback - ) - - def _connect_z_neighbor_highlight_toggle(self): - self.highlightZneighObjCheckbox.toggled.connect(self.highlightZneighLabels_cb) - - def _refresh_images_from_view_model(self): - self.updateAllImages() - - def _reset_eraser_temp_from_view_model(self): - self.setTempImg1Eraser(None, init=True) +class AnnotationDisplay: + """Extracted from guiWin.""" def activateAnnotations(self): if self.annotContourCheckbox.isChecked(): return if self.annotSegmMasksCheckbox.isChecked(): return - + self.annotSegmMasksCheckbox.setChecked(True) self.setDrawAnnotComboboxText() @@ -249,29 +41,29 @@ def annotOptionClicked(self, clicked=True, sender=None, saveSettings=True): self.annotCcaInfoCheckbox.setChecked(False) if self.drawMothBudLinesCheckbox.isChecked(): self.drawMothBudLinesCheckbox.setChecked(False) - + if self.annotCcaInfoCheckbox.isChecked() and clickedCca: if self.annotIDsCheckbox.isChecked(): self.annotIDsCheckbox.setChecked(False) if self.drawMothBudLinesCheckbox.isChecked(): self.drawMothBudLinesCheckbox.setChecked(False) - + if self.drawMothBudLinesCheckbox.isChecked() and clickedMBline: if self.annotIDsCheckbox.isChecked(): self.annotIDsCheckbox.setChecked(False) if self.annotCcaInfoCheckbox.isChecked(): self.annotCcaInfoCheckbox.setChecked(False) - + clickedCont = sender == self.annotContourCheckbox clickedSegm = sender == self.annotSegmMasksCheckbox if self.annotContourCheckbox.isChecked() and clickedCont: if self.annotSegmMasksCheckbox.isChecked(): self.annotSegmMasksCheckbox.setChecked(False) - + if self.annotSegmMasksCheckbox.isChecked() and clickedSegm: if self.annotContourCheckbox.isChecked(): self.annotContourCheckbox.setChecked(False) - + clickedDoNot = sender == self.drawNothingCheckbox if clickedDoNot: self.annotIDsCheckbox.setChecked(False) @@ -282,14 +74,16 @@ def annotOptionClicked(self, clicked=True, sender=None, saveSettings=True): self.annotNumZslicesCheckbox.setChecked(False) else: self.drawNothingCheckbox.setChecked(False) - + if sender == self.annotNumZslicesCheckbox: self.annotIDsCheckbox.setChecked(True) self.drawNothingCheckbox.setChecked(False) - + self.setDrawAnnotComboboxText(saveSettings=saveSettings) - def annotOptionClickedRight(self, clicked=True, sender=None, saveSettings=True): + def annotOptionClickedRight( + self, clicked=True, sender=None, saveSettings=True + ): if sender is None: sender = self.sender() # First manually set exclusive with uncheckable @@ -301,29 +95,29 @@ def annotOptionClickedRight(self, clicked=True, sender=None, saveSettings=True): self.annotCcaInfoCheckboxRight.setChecked(False) if self.drawMothBudLinesCheckboxRight.isChecked(): self.drawMothBudLinesCheckboxRight.setChecked(False) - + if self.annotCcaInfoCheckboxRight.isChecked() and clickedCca: if self.annotIDsCheckboxRight.isChecked(): self.annotIDsCheckboxRight.setChecked(False) if self.drawMothBudLinesCheckboxRight.isChecked(): self.drawMothBudLinesCheckboxRight.setChecked(False) - + if self.drawMothBudLinesCheckboxRight.isChecked() and clickedMBline: if self.annotIDsCheckboxRight.isChecked(): self.annotIDsCheckboxRight.setChecked(False) if self.annotCcaInfoCheckboxRight.isChecked(): self.annotCcaInfoCheckboxRight.setChecked(False) - + clickedCont = sender == self.annotContourCheckboxRight clickedSegm = sender == self.annotSegmMasksCheckboxRight if self.annotContourCheckboxRight.isChecked() and clickedCont: if self.annotSegmMasksCheckboxRight.isChecked(): self.annotSegmMasksCheckboxRight.setChecked(False) - + if self.annotSegmMasksCheckboxRight.isChecked() and clickedSegm: if self.annotContourCheckboxRight.isChecked(): self.annotContourCheckboxRight.setChecked(False) - + clickedDoNot = sender == self.drawNothingCheckboxRight if clickedDoNot: self.annotIDsCheckboxRight.setChecked(False) @@ -334,7 +128,7 @@ def annotOptionClickedRight(self, clicked=True, sender=None, saveSettings=True): self.annotNumZslicesCheckboxRight.setChecked(False) else: self.drawNothingCheckboxRight.setChecked(False) - + if sender == self.annotNumZslicesCheckboxRight: self.annotIDsCheckboxRight.setChecked(True) self.drawNothingCheckboxRight.setChecked(False) @@ -344,33 +138,37 @@ def annotOptionClickedRight(self, clicked=True, sender=None, saveSettings=True): def annotateRightHowCombobox_cb(self, idx): how = self.annotateRightHowCombobox.currentText() saveSettings = True - if hasattr(self.annotateRightHowCombobox, "saveSettings"): + if hasattr(self.annotateRightHowCombobox, 'saveSettings'): saveSettings = self.annotateRightHowCombobox.saveSettings if saveSettings: - self.df_settings.at["how_draw_right_annotations", "value"] = how + self.df_settings.at['how_draw_right_annotations', 'value'] = how self.df_settings.to_csv(self.settings_csv_path) mode = self.modeComboBox.currentText() isCcaAnnot = ( - self.annotCcaInfoCheckboxRight.isChecked() - and mode != "Normal division: Lineage tree" + self.annotCcaInfoCheckboxRight.isChecked() and + mode != 'Normal division: Lineage tree' ) - isIDAnnot = self.annotIDsCheckboxRight.isChecked() or ( - self.annotCcaInfoCheckboxRight.isChecked() - and mode == "Normal division: Lineage tree" + isIDAnnot = (self.annotIDsCheckboxRight.isChecked() or ( + self.annotCcaInfoCheckboxRight.isChecked() and + mode == 'Normal division: Lineage tree' + )) + self.textAnnot[1].setCcaAnnot( + isCcaAnnot ) - self.textAnnot[1].setCcaAnnot(isCcaAnnot) - self.textAnnot[1].setLabelAnnot(isIDAnnot) + self.textAnnot[1].setLabelAnnot( + isIDAnnot + ) if not self.isDataLoading: self.updateAllImages() def annotate_rip_and_bin_IDs(self, updateLabel=False): depthAxes = self.switchPlaneCombobox.depthAxes() - if self.switchPlaneCombobox.isEnabled() and depthAxes != "z": - return - + if self.switchPlaneCombobox.isEnabled() and depthAxes != 'z': + return + posData = self.data[self.pos_i] binnedIDs_xx = [] binnedIDs_yy = [] @@ -381,234 +179,44 @@ def annotate_rip_and_bin_IDs(self, updateLabel=False): obj.dead = obj.label in posData.ripIDs if not self.isObjVisible(obj.bbox): continue - + if obj.excluded: y, x = self.getObjCentroid(obj.centroid) binnedIDs_xx.append(x) binnedIDs_yy.append(y) if updateLabel: self.getObjOptsSegmLabels(obj) - self.drawIDsContComboBox.currentText() - + how = self.drawIDsContComboBox.currentText() + if obj.dead: y, x = self.getObjCentroid(obj.centroid) ripIDs_xx.append(x) ripIDs_yy.append(y) if updateLabel: self.getObjOptsSegmLabels(obj) - self.drawIDsContComboBox.currentText() - + how = self.drawIDsContComboBox.currentText() + self.ax2_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) self.ax2_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) self.ax1_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) self.ax1_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) - def annotation_flags_from_mode_text(self, text: str) -> dict[str, bool]: - return { - "ids": "IDs" in text, - "cca": "cell cycle info" in text, - "contours": "contours" in text, - "segm_masks": "segm. masks" in text, - "mother_bud_lines": "mother-bud lines" in text, - "nothing": "nothing" in text, - } - - def annotation_mode_change_plan( - self, - *, - side: AnnotationSide, - how: str, - save_settings: bool, - annot_cca_checked: bool, - annot_ids_checked: bool, - mode: str, - is_data_loading: bool, - eraser_checked: bool = False, - ) -> AnnotationModeChangePlan: - setting_update = None - if save_settings: - setting_update = self.annotation_mode_setting_update(side, how) - - is_cca, is_id = self.text_annotation_flags( - annot_cca_checked=annot_cca_checked, - annot_ids_checked=annot_ids_checked, - mode=mode, - ) - return AnnotationModeChangePlan( - side=side, - setting_update=setting_update, - text_annotation_index=1 if side == "right" else 0, - is_cca_annotation=is_cca, - is_id_annotation=is_id, - should_refresh_images=not is_data_loading, - should_reset_eraser_temp=side == "left" and eraser_checked, - ) - - def annotation_mode_setting_update( - self, - side: AnnotationSide, - how: str, - ) -> tuple[str, str]: - setting = ( - "how_draw_right_annotations" if side == "right" else "how_draw_annotations" - ) - return setting, how - - def annotation_mode_text( - self, - *, - ids: bool = False, - cca: bool = False, - contours: bool = False, - segm_masks: bool = False, - mother_bud_lines: bool = False, - nothing: bool = False, - ) -> str: - if ids: - if contours: - return "Draw IDs and contours" - if segm_masks: - return "Draw IDs and overlay segm. masks" - return "Draw only IDs" - if cca: - if contours: - return "Draw cell cycle info and contours" - if segm_masks: - return "Draw cell cycle info and overlay segm. masks" - return "Draw only cell cycle info" - if segm_masks: - return "Draw only overlay segm. masks" - if contours: - return "Draw only contours" - if mother_bud_lines: - return "Draw only mother-bud lines" - if nothing: - return "Draw nothing" - return "Draw nothing" - - def annotation_option_change_plan( - self, - *, - side: AnnotationSide, - state: AnnotationOptionState, - clicked_option: AnnotationOption | None, - save_settings: bool, - ) -> AnnotationOptionChangePlan: - values = { - "ids": state.ids, - "cca": state.cca, - "contours": state.contours, - "segm_masks": state.segm_masks, - "mother_bud_lines": state.mother_bud_lines, - "num_zslices": state.num_zslices, - "nothing": state.nothing, - } - - if values["ids"] and clicked_option == "ids": - values["cca"] = False - values["mother_bud_lines"] = False - - if values["cca"] and clicked_option == "cca": - values["ids"] = False - values["mother_bud_lines"] = False - - if values["mother_bud_lines"] and clicked_option == "mother_bud_lines": - values["ids"] = False - values["cca"] = False - - if values["contours"] and clicked_option == "contours": - values["segm_masks"] = False - - if values["segm_masks"] and clicked_option == "segm_masks": - values["contours"] = False - - if clicked_option == "nothing": - values["ids"] = False - values["cca"] = False - values["contours"] = False - values["segm_masks"] = False - values["mother_bud_lines"] = False - values["num_zslices"] = False - else: - values["nothing"] = False - - if clicked_option == "num_zslices": - values["ids"] = True - values["nothing"] = False - - new_state = AnnotationOptionState(**values) - return AnnotationOptionChangePlan( - side=side, - state=new_state, - mode_text=self.annotation_mode_text( - ids=new_state.ids, - cca=new_state.cca, - contours=new_state.contours, - segm_masks=new_state.segm_masks, - mother_bud_lines=new_state.mother_bud_lines, - nothing=new_state.nothing, - ), - save_settings=save_settings, - ) - - def annotation_option_state_from_mode_text( - self, - text: str, - *, - num_zslices: bool = False, - ) -> AnnotationOptionState: - flags = self.annotation_flags_from_mode_text(text) - return AnnotationOptionState( - ids=flags["ids"], - cca=flags["cca"], - contours=flags["contours"], - segm_masks=flags["segm_masks"], - mother_bud_lines=flags["mother_bud_lines"], - num_zslices=num_zslices, - nothing=flags["nothing"], - ) - - def annotation_options_from_mode_text_plan( - self, - *, - left_text: str, - right_text: str, - left_num_zslices: bool = False, - right_num_zslices: bool = False, - ) -> AnnotationOptionsFromModeTextPlan: - return AnnotationOptionsFromModeTextPlan( - state_updates=( - ( - "left", - self.annotation_option_state_from_mode_text( - left_text, - num_zslices=left_num_zslices, - ), - ), - ( - "right", - self.annotation_option_state_from_mode_text( - right_text, - num_zslices=right_num_zslices, - ), - ), - ) - ) - def applyToolNewFrameActionToggled(self, checked, toolName=None): if toolName is None: parentToolButton = self.sender().parent() - toolName = re.findall(r"Name: (.*)", parentToolButton.toolTip())[0] + toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] toolName = toolName.strip() button = self.applyToolNewFrameButtons[toolName] - toolName = toolName.replace(" ", "_") - settingName = f"{toolName}_applyNewFrame" + toolName = toolName.replace(' ', '_') + settingName = f'{toolName}_applyNewFrame' if checked: - self.df_settings.at[settingName, "value"] = "applyNewFrame" - button.setStyleSheet(f"background-color: {GREEN_HEX}") + self.df_settings.at[settingName, 'value'] = 'applyNewFrame' + button.setStyleSheet(f'background-color: {GREEN_HEX}') else: - self.df_settings = self.df_settings.drop(index=settingName, errors="ignore") - button.setStyleSheet("background-color: none") + self.df_settings = self.df_settings.drop( + index=settingName, errors='ignore' + ) + button.setStyleSheet('background-color: none') self.df_settings.to_csv(self.settings_csv_path) def areContoursRequested(self, ax): @@ -621,10 +229,10 @@ def areContoursRequested(self, ax): isRightDifferentAnnot = self.rightBottomGroupbox.isChecked() areContRequestedRight = self.annotContourCheckboxRight.isChecked() - + if isRightDifferentAnnot and areContRequestedRight: return True - + areContRequestedLeft = self.annotContourCheckbox.isChecked() if not isRightDifferentAnnot and areContRequestedLeft: return True @@ -639,16 +247,16 @@ def areMothBudLinesRequested(self, ax): else: if not self.labelsGrad.showRightImgAction.isChecked(): return False - + isRightDifferentAnnot = self.rightBottomGroupbox.isChecked() areLinesRequestedRight = ( self.annotCcaInfoCheckboxRight.isChecked() or self.drawMothBudLinesCheckboxRight.isChecked() ) - + if isRightDifferentAnnot and areLinesRequestedRight: return True - + areLinesRequestedLeft = ( self.drawMothBudLinesCheckbox.isChecked() or self.annotCcaInfoCheckbox.isChecked() @@ -664,12 +272,14 @@ def autoPilotToggled(self, checked): self.autoPilotZoomToObjToggle.toggle() def changeTextResolution(self): - mode = "high" if self.highLowResAction.isChecked() else "low" - self.logger.info(f"Switching to {mode} for the text annnotations...") + mode = 'high' if self.highLowResAction.isChecked() else 'low' + self.logger.info( + f'Switching to {mode} for the text annnotations...' + ) self.pxModeAction.setDisabled(not self.highLowResAction.isChecked()) if not self.isDataLoaded: return - + self.setAllIDs() posData = self.data[self.pos_i] allIDs = posData.allIDs @@ -688,23 +298,6 @@ def clearAnnotItems(self): self.textAnnot[0].clear() self.textAnnot[1].clear() - def contours_requested( - self, - *, - ax: int, - left_contours: bool, - right_image_visible: bool, - right_specific_mode: bool, - right_contours: bool, - ) -> bool: - if ax == 0: - return left_contours - if not right_image_visible: - return False - if right_specific_mode: - return right_contours - return left_contours - def drawAllLineageTreeLines(self): """ Draw all lineage tree lines on the GUI. @@ -721,19 +314,17 @@ def drawAllLineageTreeLines(self): self.clearAllCellToCellLines() posData = self.data[self.pos_i] frame_i = posData.frame_i - lin_tree_df = posData.allData_li[frame_i]["acdc_df"] - lin_tree_df_prev = posData.allData_li[frame_i - 1]["acdc_df"] + lin_tree_df = posData.allData_li[frame_i]['acdc_df'] + lin_tree_df_prev = posData.allData_li[frame_i-1]['acdc_df'] rp = posData.rp - prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + prev_rp = posData.allData_li[frame_i-1]['regionprops'] self.setTitleText() - new_cells = lin_tree_df.index.difference( - lin_tree_df_prev.index - ) # I could use this for the if already but this is probably faster for frames where nothing changes + new_cells = lin_tree_df.index.difference(lin_tree_df_prev.index) # I could use this for the if already but this is probably faster for frames where nothing changes if new_cells.shape[0] == 0: return - + for ax in (0, 1): if not self.areMothBudLinesRequested(ax): continue @@ -743,14 +334,10 @@ def drawAllLineageTreeLines(self): lin_tree_df_ID = lin_tree_df.loc[ID] # lin_tree_df_mother_ID = lin_tree_df_prev.loc[lin_tree_df_ID["parent_ID_tree"]] - if ( - lin_tree_df_ID["parent_ID_tree"] == -1 - ): # make sure that new obj where the parents are not known get skipped + if lin_tree_df_ID["parent_ID_tree"] == -1: # make sure that new obj where the parents are not known get skipped continue - - mother_obj = myutils.get_obj_by_label( - prev_rp, lin_tree_df_ID["parent_ID_tree"] - ) + + mother_obj = myutils.get_obj_by_label(prev_rp, lin_tree_df_ID["parent_ID_tree"]) emerg_frame_i = lin_tree_df_ID["emerg_frame_i"] isNew = emerg_frame_i == frame_i @@ -768,56 +355,60 @@ def drawAnnotCombobox_to_options(self): # Left how = self.drawIDsContComboBox.currentText() - if how.find("IDs") != -1: + if how.find('IDs') != -1: self.annotIDsCheckbox.setChecked(True) - if how.find("cell cycle info") != -1: - self.annotCcaInfoCheckbox.setChecked(True) - if how.find("contours") != -1: - self.annotContourCheckbox.setChecked(True) - if how.find("segm. masks") != -1: - self.annotSegmMasksCheckbox.setChecked(True) - if how.find("mother-bud lines") != -1: - self.drawMothBudLinesCheckbox.setChecked(True) - if how.find("nothing") != -1: + if how.find('cell cycle info') != -1: + self.annotCcaInfoCheckbox.setChecked(True) + if how.find('contours') != -1: + self.annotContourCheckbox.setChecked(True) + if how.find('segm. masks') != -1: + self.annotSegmMasksCheckbox.setChecked(True) + if how.find('mother-bud lines') != -1: + self.drawMothBudLinesCheckbox.setChecked(True) + if how.find('nothing') != -1: self.drawNothingCheckbox.setChecked(True) - + # Right how = self.annotateRightHowCombobox.currentText() - if how.find("IDs") != -1: + if how.find('IDs') != -1: self.annotIDsCheckboxRight.setChecked(True) - if how.find("cell cycle info") != -1: - self.annotCcaInfoCheckboxRight.setChecked(True) - if how.find("contours") != -1: - self.annotContourCheckboxRight.setChecked(True) - if how.find("segm. masks") != -1: - self.annotSegmMasksCheckboxRight.setChecked(True) - if how.find("mother-bud lines") != -1: - self.drawMothBudLinesCheckboxRight.setChecked(True) - if how.find("nothing") != -1: + if how.find('cell cycle info') != -1: + self.annotCcaInfoCheckboxRight.setChecked(True) + if how.find('contours') != -1: + self.annotContourCheckboxRight.setChecked(True) + if how.find('segm. masks') != -1: + self.annotSegmMasksCheckboxRight.setChecked(True) + if how.find('mother-bud lines') != -1: + self.drawMothBudLinesCheckboxRight.setChecked(True) + if how.find('nothing') != -1: self.drawNothingCheckboxRight.setChecked(True) def drawIDsContComboBox_cb(self, idx): how = self.drawIDsContComboBox.currentText() saveSettings = True - if hasattr(self.drawIDsContComboBox, "saveSettings"): + if hasattr(self.drawIDsContComboBox, 'saveSettings'): saveSettings = self.drawIDsContComboBox.saveSettings - + if saveSettings: - self.df_settings.at["how_draw_annotations", "value"] = how + self.df_settings.at['how_draw_annotations', 'value'] = how self.df_settings.to_csv(self.settings_csv_path) mode = self.modeComboBox.currentText() isCcaAnnot = ( - self.annotCcaInfoCheckbox.isChecked() - and mode != "Normal division: Lineage tree" + self.annotCcaInfoCheckbox.isChecked() and + mode != 'Normal division: Lineage tree' ) - isIDAnnot = self.annotIDsCheckbox.isChecked() or ( - self.annotCcaInfoCheckbox.isChecked() - and mode == "Normal division: Lineage tree" + isIDAnnot = (self.annotIDsCheckbox.isChecked() or ( + self.annotCcaInfoCheckbox.isChecked() and + mode == 'Normal division: Lineage tree' + )) + self.textAnnot[0].setCcaAnnot( + isCcaAnnot ) - self.textAnnot[0].setCcaAnnot(isCcaAnnot) - self.textAnnot[0].setLabelAnnot(isIDAnnot) + self.textAnnot[0].setLabelAnnot( + isIDAnnot + ) if not self.isDataLoading: self.updateAllImages() @@ -847,9 +438,9 @@ def drawObjLin_TreeMothBudLines(self, ax, obj, mother_obj, isNew, ID=None): if not ID: ID = obj.label - + isObjVisible = self.isObjVisible(obj.bbox) - + if not isObjVisible: return @@ -864,36 +455,36 @@ def drawObjMothBudLines(self, obj, posData, ax=0): areMothBudLinesRequested = self.areMothBudLinesRequested(ax) if not areMothBudLinesRequested: return - + if posData.cca_df is None: - return + return mode = str(self.modeComboBox.currentText()) - if mode == "Normal division: Lineage Tree": + if mode == 'Normal division: Lineage Tree': return ID = obj.label try: cca_df_ID = posData.cca_df.loc[ID] except KeyError: - return - + return + isObjVisible = self.isObjVisible(obj.bbox) if not isObjVisible: return - - ccs_ID = cca_df_ID["cell_cycle_stage"] - if ccs_ID == "G1": + + ccs_ID = cca_df_ID['cell_cycle_stage'] + if ccs_ID == 'G1': return - relationship = cca_df_ID["relationship"] - if relationship != "bud": + relationship = cca_df_ID['relationship'] + if relationship != 'bud': return - emerg_frame_i = cca_df_ID["emerg_frame_i"] + emerg_frame_i = cca_df_ID['emerg_frame_i'] isNew = emerg_frame_i == posData.frame_i scatterItem = self.getMothBudLineScatterItem(ax, isNew) - relative_ID = cca_df_ID["relative_ID"] + relative_ID = cca_df_ID['relative_ID'] try: relative_rp_idx = posData.IDs_idxs[relative_ID] @@ -908,8 +499,8 @@ def drawObjMothBudLines(self, obj, posData, ax=0): def getAnnotateHowRightImage(self): if not self.labelsGrad.showRightImgAction.isChecked(): - return "nothing" - + return 'nothing' + if self.rightBottomGroupbox.isChecked(): how = self.annotateRightHowCombobox.currentText() else: @@ -932,12 +523,12 @@ def getObjCentroid(self, obj_centroid): if self.isSegm3D: depthAxes = self.switchPlaneCombobox.depthAxes() zc, yc, xc = obj_centroid - if depthAxes == "z": - return yc, xc - elif depthAxes == "y": - return zc, xc + if depthAxes == 'z': + return yc, xc + elif depthAxes == 'y': + return zc, xc else: - return zc, yc + return zc, yc else: return obj_centroid @@ -945,7 +536,7 @@ def getObjOptsSegmLabels(self, obj): if not self.labelsGrad.showLabelsImgAction.isChecked(): return - objOpts = self.getObjTextAnnotOpts(obj, "Draw only IDs", ax=1) + objOpts = self.getObjTextAnnotOpts(obj, 'Draw only IDs', ax=1) return objOpts def gui_raiseBottomLayoutContextMenu(self, event): @@ -969,15 +560,13 @@ def keepAllToolsActiveActionToggled(self, checked): action.setChecked(checked) data_loaded = True - if not hasattr(self, "data"): + if not hasattr(self, 'data'): data_loaded = False try: self.labelRoiTrangeCheckbox.disconnect() except TypeError: pass - self.labelRoiTrangeCheckbox.setChecked( - checked - ) # why this is not wrapped in a QAction? + self.labelRoiTrangeCheckbox.setChecked(checked) # why this is not wrapped in a QAction? if data_loaded: self.labelRoiTrangeCheckbox.toggled.connect( @@ -987,12 +576,14 @@ def keepAllToolsActiveActionToggled(self, checked): def keepToolActiveActionToggled(self, checked, toolName=None): if toolName is None: parentToolButton = self.sender().parent() - toolName = re.findall(r"Name: (.*)", parentToolButton.toolTip())[0] + toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] if checked: - self.df_settings.at[toolName, "value"] = "keepActive" + self.df_settings.at[toolName, 'value'] = 'keepActive' else: - self.df_settings = self.df_settings.drop(index=toolName, errors="ignore") + self.df_settings = self.df_settings.drop( + index=toolName, errors='ignore' + ) self.df_settings.to_csv(self.settings_csv_path) def labelRoiIsCircularRadioButtonToggled(self, checked): @@ -1001,36 +592,19 @@ def labelRoiIsCircularRadioButtonToggled(self, checked): else: self.labelRoiCircularRadiusSpinbox.setDisabled(True) - def moth_bud_lines_requested( - self, - *, - ax: int, - left_cca: bool, - left_mother_bud_lines: bool, - right_image_visible: bool, - right_specific_mode: bool, - right_cca: bool, - right_mother_bud_lines: bool, - ) -> bool: - if ax == 0: - return left_cca or left_mother_bud_lines - if not right_image_visible: - return False - if right_specific_mode: - return right_cca or right_mother_bud_lines - return left_cca or left_mother_bud_lines - def onDoubleSpaceBar(self): how = self.drawIDsContComboBox.currentText() - if how.find("nothing") == -1: + if how.find('nothing') == -1: self.storeCurrentAnnotOptions_ax1() self.drawNothingCheckbox.setChecked(True) - self.annotOptionClicked(sender=self.drawNothingCheckbox, saveSettings=False) + self.annotOptionClicked( + sender=self.drawNothingCheckbox, saveSettings=False + ) else: self.restoreAnnotOptions_ax1() - + how = self.annotateRightHowCombobox.currentText() - if how.find("nothing") == -1: + if how.find('nothing') == -1: self.storeCurrentAnnotOptions_ax2() self.drawNothingCheckboxRight.setChecked(True) self.annotOptionClickedRight( @@ -1039,84 +613,67 @@ def onDoubleSpaceBar(self): else: self.restoreAnnotOptions_ax2() - def pixel_mode_change_plan( - self, - *, - checked: bool, - is_data_loaded: bool, - high_resolution: bool, - ) -> PixelModeChangePlan: - return PixelModeChangePlan( - setting_update=("pxMode", self.pixel_mode_setting_value(checked)), - should_update_text_pixel_mode=is_data_loaded and high_resolution, - should_refresh_images=is_data_loaded, - ) - - def pixel_mode_setting_value(self, checked: bool) -> int: - return int(checked) - def pxModeActionToggled(self, checked): - self.df_settings.at["pxMode", "value"] = int(checked) + self.df_settings.at['pxMode', 'value'] = int(checked) self.df_settings.to_csv(self.settings_csv_path) - + if not self.isDataLoaded: return - + if self.highLowResAction.isChecked(): for ax in range(2): self.textAnnot[ax].setPxMode(checked) - + self.updateAllImages() - def relabelSequentialCallback(self): + def relabelSequentialCallback(self): mode = str(self.modeComboBox.currentText()) - if mode == "Viewer" or mode == "Cell cycle analysis": + if mode == 'Viewer' or mode == 'Cell cycle analysis': self.startBlinkingModeCB() return - + posData = self.data[self.pos_i] - selectedPos = (posData.pos_foldername,) + selectedPos = (posData.pos_foldername, ) if len(self.data) > 1: - selectedPos = self.askSelectPos(action="to process") + selectedPos = self.askSelectPos(action='to process') if selectedPos is None: - self.logger.info("Re-labelling process stopped.") + self.logger.info('Re-labelling process stopped.') return - + self.store_data() # acdc_df_concat = self.getConcatAcdcDf() # load.store_unsaved_acdc_df( - # posData, acdc_df_concat, + # posData, acdc_df_concat, # log_func=self.logger.info # ) # if posData.SizeT > 1: self.progressWin = apps.QDialogWorkerProgress( - title="Re-labelling sequential", - parent=self, - pbarDesc="Relabelling sequential...", + title='Re-labelling sequential', parent=self, + pbarDesc='Relabelling sequential...' ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) self.startRelabellingWorker(selectedPos) def restoreAnnotOptions_ax1(self, options=None): - if options is None and not hasattr(self, "annotOptionsToRestore"): + if options is None and not hasattr(self, 'annotOptionsToRestore'): return if options is None: options = self.annotOptionsToRestore - + if options is None: return - + for option, state in options.items(): checkbox = getattr(self, option) checkbox.setChecked(state) - + self.setDrawAnnotComboboxText() self.annotOptionsToRestore = None def restoreAnnotOptions_ax2(self): - if not hasattr(self, "annotOptionsToRestoreRight"): + if not hasattr(self, 'annotOptionsToRestoreRight'): return if self.annotOptionsToRestoreRight is None: @@ -1125,7 +682,7 @@ def restoreAnnotOptions_ax2(self): for option, state in self.annotOptionsToRestoreRight.items(): checkbox = getattr(self, option) checkbox.setChecked(state) - + self.setDrawAnnotComboboxTextRight() self.annotOptionsToRestoreRight = None @@ -1134,65 +691,31 @@ def restoreAnnotationsOptions(self): self.restoreAnnotOptions_ax2() def restoreSavedSettings(self): - if "how_draw_annotations" in self.df_settings.index: - how = self.df_settings.at["how_draw_annotations", "value"] + if 'how_draw_annotations' in self.df_settings.index: + how = self.df_settings.at['how_draw_annotations', 'value'] self.drawIDsContComboBox.setCurrentText(how) else: - self.drawIDsContComboBox.setCurrentText("Draw IDs and contours") - - if "how_draw_right_annotations" in self.df_settings.index: - how = self.df_settings.at["how_draw_right_annotations", "value"] + self.drawIDsContComboBox.setCurrentText('Draw IDs and contours') + + if 'how_draw_right_annotations' in self.df_settings.index: + how = self.df_settings.at['how_draw_right_annotations', 'value'] self.annotateRightHowCombobox.setCurrentText(how) else: self.annotateRightHowCombobox.setCurrentText( - "Draw IDs and overlay segm. masks" + 'Draw IDs and overlay segm. masks' ) - - if "addNewIDsWhitelistToggle" in self.df_settings.index: + + if 'addNewIDsWhitelistToggle' in self.df_settings.index: self.addNewIDsWhitelistToggle = ( - (self.df_settings.at["addNewIDsWhitelistToggle", "value"]) == "Yes" - ) + self.df_settings.at['addNewIDsWhitelistToggle', 'value'] + ) == 'Yes' else: self.addNewIDsWhitelistToggle = True - + self.drawAnnotCombobox_to_options() self.drawIDsContComboBox_cb(0) self.annotateRightHowCombobox_cb(0) - def restore_saved_settings_plan( - self, - settings_values: Mapping[str, object], - ) -> AnnotationDisplaySettingsRestorePlan: - return AnnotationDisplaySettingsRestorePlan( - left_mode=str( - settings_values.get( - "how_draw_annotations", - "Draw IDs and contours", - ) - ), - right_mode=str( - settings_values.get( - "how_draw_right_annotations", - "Draw IDs and overlay segm. masks", - ) - ), - add_new_ids_whitelist_toggle=( - settings_values.get("addNewIDsWhitelistToggle", "Yes") == "Yes" - ), - ) - - def right_annotation_mode( - self, - *, - show_right_image: bool, - use_right_specific_mode: bool, - right_mode: str, - left_mode: str, - ) -> str: - if not show_right_image: - return "nothing" - return right_mode if use_right_specific_mode else left_mode - def rtTrackerActionToggled(self, checked): if not checked: return @@ -1202,20 +725,20 @@ def rtTrackerActionToggled(self, checked): trackingAlgo = aliases[self.sender().text()] else: trackingAlgo = self.sender().text() - self.df_settings.at["tracking_algorithm", "value"] = trackingAlgo + self.df_settings.at['tracking_algorithm', 'value'] = trackingAlgo self.df_settings.to_csv(self.settings_csv_path) - if self.sender().text() == "YeaZ": + if self.sender().text() == 'YeaZ': msg = widgets.myMessageBox(wrapText=False) - info_txt = html_utils.paragraph(""" + info_txt = html_utils.paragraph(f""" Note that YeaZ tracking algorithm tends to be sliglhtly more accurate overall, but it is less capable of detecting segmentation errors.

If you need to correct as many segmentation errors as possible we recommend using Cell-ACDC tracking algorithm. """) - msg.information(self, "Info about YeaZ", info_txt) - + msg.information(self, 'Info about YeaZ', info_txt) + self.isRealTimeTrackerInitialized = False self.initRealTimeTracker() @@ -1223,24 +746,23 @@ def setAllTextAnnotations(self, labelsToSkip=None): delROIsIDs = self.setLostNewOldPrevIDs() posData = self.data[self.pos_i] self.textAnnot[0].setAnnotations( - posData=posData, - labelsToSkip=labelsToSkip, + posData=posData, + labelsToSkip=labelsToSkip, isVisibleCheckFunc=self.isObjVisible, - highlightedID=self.highlightedID, + highlightedID=self.highlightedID, delROIsIDs=delROIsIDs, - annotateLost=self.annotLostObjsToggle.isChecked(), - getCurrentZfunc=self.z_lab, - getObjCentroidFunc=self.getObjCentroid, + annotateLost=self.annotLostObjsToggle.isChecked(), + getCurrentZfunc=self.z_lab, + getObjCentroidFunc=self.getObjCentroid ) self.textAnnot[1].setAnnotations( - posData=posData, - labelsToSkip=labelsToSkip, + posData=posData, labelsToSkip=labelsToSkip, isVisibleCheckFunc=self.isObjVisible, - highlightedID=self.highlightedID, + highlightedID=self.highlightedID, delROIsIDs=delROIsIDs, - annotateLost=self.annotLostObjsToggle.isChecked(), - getCurrentZfunc=self.z_lab, - getObjCentroidFunc=self.getObjCentroid, + annotateLost=self.annotLostObjsToggle.isChecked(), + getCurrentZfunc=self.z_lab, + getObjCentroidFunc=self.getObjCentroid ) self.textAnnot[0].update() self.textAnnot[1].update() @@ -1249,30 +771,32 @@ def setAllTextAnnotations(self, labelsToSkip=None): def setAnnotInfoMode(self, checked): if checked: for action in self.annotSettingsIDmenu.actions(): - if action.text().find("tree") != -1: + if action.text().find('tree') != -1: self.textAnnot[0].setLabelTreeAnnotationsEnabled(True) action.setChecked(True) break for action in self.annotSettingsGenNumMenu.actions(): - if action.text().find("tree") != -1: + if action.text().find('tree') != -1: self.textAnnot[0].setGenNumTreeAnnotationsEnabled(True) action.setChecked(True) break else: for action in self.annotSettingsIDmenu.actions(): - if action.text().find("tree") == -1: + if action.text().find('tree') == -1: action.setChecked(False) self.textAnnot[0].setLabelTreeAnnotationsEnabled(False) break for action in self.annotSettingsGenNumMenu.actions(): - if action.text().find("tree") == -1: + if action.text().find('tree') == -1: action.setChecked(False) self.textAnnot[0].setGenNumTreeAnnotationsEnabled(False) break self.setAllTextAnnotations() def setAnnotOptionsCcaMode(self): - self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1(return_value=True) + self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( + return_value=True + ) self.annotCcaInfoCheckbox.setChecked(True) self.annotIDsCheckbox.setChecked(False) self.drawMothBudLinesCheckbox.setChecked(False) @@ -1315,7 +839,7 @@ def setDisabledAnnotOptions(self, disabled): self.drawMothBudLinesCheckbox.setDisabled(disabled) # self.drawNothingCheckbox.setDisabled(disabled) - # Right + # Right self.annotIDsCheckboxRight.setDisabled(disabled) self.annotCcaInfoCheckboxRight.setDisabled(disabled) self.annotContourCheckboxRight.setDisabled(disabled) @@ -1325,74 +849,74 @@ def setDisabledAnnotOptions(self, disabled): def setDrawAnnotComboboxText(self, saveSettings=True): if self.annotIDsCheckbox.isChecked(): if self.annotContourCheckbox.isChecked(): - t = "Draw IDs and contours" + t = 'Draw IDs and contours' elif self.annotSegmMasksCheckbox.isChecked(): - t = "Draw IDs and overlay segm. masks" + t = 'Draw IDs and overlay segm. masks' else: - t = "Draw only IDs" - + t = 'Draw only IDs' + elif self.annotCcaInfoCheckbox.isChecked(): if self.annotContourCheckbox.isChecked(): - t = "Draw cell cycle info and contours" + t = 'Draw cell cycle info and contours' elif self.annotSegmMasksCheckbox.isChecked(): - t = "Draw cell cycle info and overlay segm. masks" + t = 'Draw cell cycle info and overlay segm. masks' else: - t = "Draw only cell cycle info" - + t = 'Draw only cell cycle info' + elif self.annotSegmMasksCheckbox.isChecked(): - t = "Draw only overlay segm. masks" + t = 'Draw only overlay segm. masks' elif self.annotContourCheckbox.isChecked(): - t = "Draw only contours" - + t = 'Draw only contours' + elif self.drawMothBudLinesCheckbox.isChecked(): - t = "Draw only mother-bud lines" - + t = 'Draw only mother-bud lines' + elif self.drawNothingCheckbox.isChecked(): - t = "Draw nothing" + t = 'Draw nothing' else: - t = "Draw nothing" + t = 'Draw nothing' if t == self.drawIDsContComboBox.currentText(): self.drawIDsContComboBox_cb(0) - + self.drawIDsContComboBox.saveSettings = saveSettings self.drawIDsContComboBox.setCurrentText(t) def setDrawAnnotComboboxTextRight(self, saveSettings=True): if self.annotIDsCheckboxRight.isChecked(): if self.annotContourCheckboxRight.isChecked(): - t = "Draw IDs and contours" + t = 'Draw IDs and contours' elif self.annotSegmMasksCheckboxRight.isChecked(): - t = "Draw IDs and overlay segm. masks" + t = 'Draw IDs and overlay segm. masks' else: - t = "Draw only IDs" - + t = 'Draw only IDs' + elif self.annotCcaInfoCheckboxRight.isChecked(): if self.annotContourCheckboxRight.isChecked(): - t = "Draw cell cycle info and contours" + t = 'Draw cell cycle info and contours' elif self.annotSegmMasksCheckboxRight.isChecked(): - t = "Draw cell cycle info and overlay segm. masks" + t = 'Draw cell cycle info and overlay segm. masks' else: - t = "Draw only cell cycle info" - + t = 'Draw only cell cycle info' + elif self.annotSegmMasksCheckboxRight.isChecked(): - t = "Draw only overlay segm. masks" + t = 'Draw only overlay segm. masks' elif self.annotContourCheckboxRight.isChecked(): - t = "Draw only contours" - + t = 'Draw only contours' + elif self.drawMothBudLinesCheckboxRight.isChecked(): - t = "Draw only mother-bud lines" - + t = 'Draw only mother-bud lines' + elif self.drawNothingCheckboxRight.isChecked(): - t = "Draw nothing" + t = 'Draw nothing' else: - t = "Draw nothing" + t = 'Draw nothing' if t == self.annotateRightHowCombobox.currentText(): self.annotateRightHowCombobox_cb(0) - + self.annotateRightHowCombobox.saveSettings = saveSettings self.annotateRightHowCombobox.setCurrentText(t) @@ -1400,7 +924,8 @@ def setDrawNothingAnnotations(self): self.storeCurrentAnnotOptions_ax1() self.storeCurrentAnnotOptions_ax2() self.drawNothingCheckbox.setChecked(True) - self.annotOptionClicked(sender=self.drawNothingCheckbox, saveSettings=False) + self.annotOptionClicked( + sender=self.drawNothingCheckbox, saveSettings=False) self.drawNothingCheckboxRight.setChecked(True) self.annotOptionClickedRight( sender=self.drawNothingCheckboxRight, saveSettings=False @@ -1409,13 +934,14 @@ def setDrawNothingAnnotations(self): def setEnabledAnnotCheckBoxesLeftZdepthAxes(self): if not self.isSegm3D: return - + self.annotIDsCheckbox.setDisabled(False) self.annotContourCheckbox.setDisabled(False) self.annotIDsCheckbox.setChecked(True) self.annotContourCheckbox.setChecked(True) - - self.annotOptionClicked(sender=self.annotIDsCheckbox, saveSettings=False) + + self.annotOptionClicked( + sender=self.annotIDsCheckbox, saveSettings=False) def setVisible3DsegmWidgets(self): self.annotNumZslicesCheckbox.setVisible(self.isSegm3D) @@ -1424,33 +950,9 @@ def setVisible3DsegmWidgets(self): self.annotNumZslicesCheckbox.setChecked(False) self.annotNumZslicesCheckboxRight.setChecked(False) - def should_draw_lineage_tree_lines( - self, - *, - lineage_tree_available: bool, - frames_count: int, - ) -> bool: - return lineage_tree_available and frames_count >= 2 - - def should_draw_moth_bud_line( - self, - *, - cca_df_available: bool, - mode: str, - object_visible: bool, - cell_cycle_stage: str, - relationship: str, - ) -> bool: - return ( - cca_df_available - and mode != "Normal division: Lineage Tree" - and object_visible - and cell_cycle_stage != "G1" - and relationship == "bud" - ) - def showHighlightZneighCheckbox(self): if self.isSegm3D: + layout = self.bottomLeftLayout # layout.addWidget(self.annotOptionsWidget, 0, 1, 1, 2) # # layout.removeWidget(self.drawIDsContComboBox) # # layout.addWidget(self.drawIDsContComboBox, 0, 1, 1, 1, @@ -1468,15 +970,15 @@ def showHighlightZneighCheckbox(self): def storeCurrentAnnotOptions_ax1(self, return_value=False): if self.annotOptionsToRestore is not None: return - + checkboxes = [ - "annotIDsCheckbox", - "annotCcaInfoCheckbox", - "annotContourCheckbox", - "annotSegmMasksCheckbox", - "drawMothBudLinesCheckbox", - "annotNumZslicesCheckbox", - "drawNothingCheckbox", + 'annotIDsCheckbox', + 'annotCcaInfoCheckbox', + 'annotContourCheckbox', + 'annotSegmMasksCheckbox', + 'drawMothBudLinesCheckbox', + 'annotNumZslicesCheckbox', + 'drawNothingCheckbox', ] annotOptions = {} for checkboxName in checkboxes: @@ -1489,61 +991,21 @@ def storeCurrentAnnotOptions_ax1(self, return_value=False): def storeCurrentAnnotOptions_ax2(self): if self.annotOptionsToRestoreRight is not None: return - + checkboxes = [ - "annotIDsCheckboxRight", - "annotCcaInfoCheckboxRight", - "annotContourCheckboxRight", - "annotSegmMasksCheckboxRight", - "drawMothBudLinesCheckboxRight", - "annotNumZslicesCheckboxRight", - "drawNothingCheckboxRight", + 'annotIDsCheckboxRight', + 'annotCcaInfoCheckboxRight', + 'annotContourCheckboxRight', + 'annotSegmMasksCheckboxRight', + 'drawMothBudLinesCheckboxRight', + 'annotNumZslicesCheckboxRight', + 'drawNothingCheckboxRight', ] self.annotOptionsToRestoreRight = {} for checkboxName in checkboxes: checkbox = getattr(self, checkboxName) self.annotOptionsToRestoreRight[checkboxName] = checkbox.isChecked() - def text_annotation_flags( - self, - *, - annot_cca_checked: bool, - annot_ids_checked: bool, - mode: str, - ) -> tuple[bool, bool]: - is_lineage_mode = mode == "Normal division: Lineage tree" - is_cca = annot_cca_checked and not is_lineage_mode - is_id = annot_ids_checked or (annot_cca_checked and is_lineage_mode) - return is_cca, is_id - - def text_resolution_change_plan( - self, - *, - high_resolution: bool, - is_data_loaded: bool, - ) -> TextResolutionChangePlan: - mode = "high" if high_resolution else "low" - return TextResolutionChangePlan( - mode=mode, - log_message=f"Switching to {mode} for the text annnotations...", - pixel_mode_disabled=not high_resolution, - should_update_annotations=is_data_loaded, - should_refresh_images=is_data_loaded, - ) - - def tree_annotation_info_mode_plan( - self, - checked: bool, - ) -> TreeAnnotationInfoModePlan: - return TreeAnnotationInfoModePlan( - enabled=checked, - action_text_contains="tree", - action_checked=checked, - label_tree_annotations_enabled=checked, - gen_num_tree_annotations_enabled=checked, - should_refresh_annotations=True, - ) - def uncheckAnnotOptions(self, left=True, right=True): # Left if left: @@ -1554,7 +1016,7 @@ def uncheckAnnotOptions(self, left=True, right=True): self.drawMothBudLinesCheckbox.setChecked(False) self.drawNothingCheckbox.setChecked(False) - # Right + # Right if right: self.annotIDsCheckboxRight.setChecked(False) self.annotCcaInfoCheckboxRight.setChecked(False) @@ -1564,7 +1026,7 @@ def uncheckAnnotOptions(self, left=True, right=True): self.drawNothingCheckboxRight.setChecked(False) def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print): - logger("Updating annotated IDs...") + logger('Updating annotated IDs...') posData = self.data[self.pos_i] mapper = dict(zip(oldIDs, newIDs)) @@ -1577,12 +1039,12 @@ def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print): customAnnotButtons = list(self.customAnnotDict.keys()) for button in customAnnotButtons: customAnnotValues = self.customAnnotDict[button] - annotatedIDs = customAnnotValues["annotatedIDs"][self.pos_i] + annotatedIDs = customAnnotValues['annotatedIDs'][self.pos_i] mappedAnnotIDs = {} for frame_i, annotIDs_i in annotatedIDs.items(): mappedIDs = [mapper[ID] for ID in annotIDs_i] mappedAnnotIDs[frame_i] = mappedIDs - customAnnotValues["annotatedIDs"][self.pos_i] = mappedAnnotIDs + customAnnotValues['annotatedIDs'][self.pos_i] = mappedAnnotIDs def update_rp_metadata(self, draw=True): posData = self.data[self.pos_i] @@ -1592,65 +1054,6 @@ def update_rp_metadata(self, draw=True): obj.excluded = ID in posData.binnedIDs obj.dead = ID in posData.ripIDs - def visible_3d_segmentation_widgets_plan( - self, - *, - is_3d: bool, - ) -> Visible3DSegmentationWidgetsPlan: - visible_updates = ( - ("left", "num_zslices", is_3d), - ("right", "num_zslices", is_3d), - ) - checked_updates = () - if not is_3d: - checked_updates = ( - ("left", "num_zslices", False), - ("right", "num_zslices", False), - ) - return Visible3DSegmentationWidgetsPlan( - visible_updates=visible_updates, - checked_updates=checked_updates, - ) - - def z_depth_annotation_options_plan( - self, - *, - is_3d: bool, - state: AnnotationOptionState, - ) -> ZDepthAnnotationOptionsPlan: - if not is_3d: - return ZDepthAnnotationOptionsPlan(should_apply=False) - - return ZDepthAnnotationOptionsPlan( - should_apply=True, - disabled_updates=(("ids", False), ("contours", False)), - state=AnnotationOptionState( - ids=True, - cca=state.cca, - contours=True, - segm_masks=state.segm_masks, - mother_bud_lines=state.mother_bud_lines, - num_zslices=state.num_zslices, - nothing=state.nothing, - ), - clicked_option="ids", - save_settings=False, - ) - - def z_neighbor_highlight_checkbox_plan( - self, - *, - is_3d: bool, - ) -> ZNeighborHighlightCheckboxPlan: - if not is_3d: - return ZNeighborHighlightCheckboxPlan(should_apply=False) - return ZNeighborHighlightCheckboxPlan( - should_apply=True, - visible=True, - checked=True, - should_connect_toggle=True, - ) - def zoomRectActionToggled(self, checked): if checked: self.disconnectLeftClickButtons() @@ -1658,22 +1061,33 @@ def zoomRectActionToggled(self, checked): self.connectLeftClickButtons() self.ax1.addItem(self.zoomRectItem) else: - self.zoomRectItem.setPos((0, 0)) - self.zoomRectItem.setSize((0, 0)) + self.zoomRectItem.setPos((0,0)) + self.zoomRectItem.setSize((0,0)) self.ax1.removeItem(self.zoomRectItem) def zoomRectCancelled(self): self.isMouseDragImg1 = False - self.zoomRectItem.setPos((0, 0)) - self.zoomRectItem.setSize((0, 0)) + self.zoomRectItem.setPos((0,0)) + self.zoomRectItem.setSize((0,0)) def zoomRectDone(self): xRange, yRange = self.ax1.viewRange() self.zoomRectItem.storeLastRange(xRange, yRange) - + ymin, xmin, ymax, xmax = self.zoomRectItem.bbox() + + self.zoomRectItem.setPos((0,0)) + self.zoomRectItem.setSize((0,0)) + + self.ax1.setRange( + xRange=(xmin, xmax), + yRange=(ymin, ymax), + padding=0 + ) - self.zoomRectItem.setPos((0, 0)) - self.zoomRectItem.setSize((0, 0)) - - self.ax1.setRange(xRange=(xmin, xmax), yRange=(ymin, ymax), padding=0) + def showAllContoursToggled(self): + if not self.isDataLoaded: + return + + self.computeAllContours() + self.updateAllImages() diff --git a/cellacdc/mixins_bak/app_shell.py b/cellacdc/mixins/app_shell.py similarity index 68% rename from cellacdc/mixins_bak/app_shell.py rename to cellacdc/mixins/app_shell.py index 0446d48a5..b42aa7b84 100644 --- a/cellacdc/mixins_bak/app_shell.py +++ b/cellacdc/mixins/app_shell.py @@ -20,29 +20,21 @@ from cellacdc.help import about, welcome -class AppShellMixin: - """Qt-facing adapter around application shell lifecycle actions.""" - - """Headless application shell service wrappers.""" - - def _set_qwidget_disabled(self, disabled: bool): - QWidget.setDisabled(self, disabled) +class AppShell: + """Extracted from guiWin.""" def about(self): pass - def browse_docs(self): - return myutils.browse_docs() - def cleanUpOnError(self): self.onEscape() - caller = "Cell-ACDC" - if self.module.startswith("spotmax"): - caller = "spotMAX" - txt = f"WARNING: {caller} is in error state. Please, restart." - _hl = "*" * 100 - self.titleLabel.setText(txt, color="r") - self.logger.info(f"{_hl}\n{txt}\n{_hl}") + caller = 'Cell-ACDC' + if self.module.startswith('spotmax'): + caller = 'spotMAX' + txt = f'WARNING: {caller} is in error state. Please, restart.' + _hl = '*'*100 + self.titleLabel.setText(txt, color='r') + self.logger.info(f'{_hl}\n{txt}\n{_hl}') def copyContent(self): pass @@ -67,10 +59,10 @@ def determineSlideshowWinPos(self): winScreenCenter = winScreenGeom.center() winScreenCenterX = winScreenCenter.x() winScreenCenterY = winScreenCenter.y() - winScreenGeom.left() - winScreenGeom.top() - self.slideshowWinLeft = winScreenCenterX - int(850 / 2) - self.slideshowWinTop = winScreenCenterY - int(800 / 2) + winScreenLeft = winScreenGeom.left() + winScreenTop = winScreenGeom.top() + self.slideshowWinLeft = winScreenCenterX - int(850/2) + self.slideshowWinTop = winScreenCenterY - int(800/2) def initGlobalAttr(self): self.setOverlayColors() @@ -108,13 +100,13 @@ def initGlobalAttr(self): self.keptIDsLineEdit, self.keepIDsConfirmAction ) self._ZprojWidgersEnabledState = None - self.imgValueFormatter = "d" - self.rawValueFormatter = "d" + self.imgValueFormatter = 'd' + self.rawValueFormatter = 'd' self.lastHoverID = -1 self.annotOptionsToRestore = None self.annotOptionsToRestoreRight = None self.rescaleIntensChannelHowMapper = { - self.user_ch_name: "Rescale each 2D image" + self.user_ch_name: 'Rescale each 2D image' } self.timestampDialog = None self.scaleBarDialog = None @@ -141,7 +133,7 @@ def initGlobalAttr(self): self.UserEnforced_Tracking = False self.ax1BrushHoverID = 0 - + self.disabled_cca_warnings = set() self.last_pos_i = -1 @@ -155,17 +147,20 @@ def initGlobalAttr(self): self.clickObjYc, self.clickObjXc = None, None self.cca_df_colnames = cca_df_colnames - self.cca_df_dtypes = [str, int, int, str, int, int, bool, bool, int] + self.cca_df_dtypes = [ + str, int, int, str, int, int, bool, bool, int + ] self.cca_df_default_values = list(base_cca_dict.values()) self.cca_df_int_cols = [ col for col in cca_df_colnames if type(base_cca_dict[col]) == int ] self.lin_tree_df_bool_col = [ - col for col in cca_df_colnames if isinstance(base_cca_dict[col], bool) + col for col in cca_df_colnames + if isinstance(base_cca_dict[col], bool) ] self.lin_tree_col_checks = [ - "generation_num", + 'generation_num', ] # self.lin_tree_df_colnames = set(base_cca_df.keys()) | set(lineage_tree_cols) @@ -174,53 +169,49 @@ def initGlobalAttr(self): # # ] # self.lin_tree_df_default_values = list(base_cca_df.values()) + lineage_tree_cols_std_val self.lin_tree_df_int_cols = [ - "generation_num", - "relative_ID", - "emerg_frame_i", - "division_frame_i", - "corrected_on_frame_i", + 'generation_num', + 'relative_ID', + 'emerg_frame_i', + 'division_frame_i', + 'corrected_on_frame_i' ] self.lin_tree_df_bool_col = [ - "is_history_known", + 'is_history_known', ] self.lin_tree_col_checks = [ - "generation_num", + 'generation_num', ] - self.lin_tree_df_colnames = ( - self.lin_tree_df_int_cols - + self.lin_tree_df_bool_col - + self.lin_tree_col_checks - ) - self.SegForLostIDsSettings = {} + self.lin_tree_df_colnames = self.lin_tree_df_int_cols + self.lin_tree_df_bool_col + self.lin_tree_col_checks + self.SegForLostIDsSettings = {} def initProfileModels(self): - self.logger.info("Initiliazing profilers...") - + self.logger.info('Initiliazing profilers...') + from ._profile.spline_to_obj import model - + self.splineToObjModel = model.Model() self.splineToObjModel.fit() def onToggleColorScheme(self): - if self.toggleColorSchemeAction.text().find("light") != -1: - self._colorScheme = "light" + if self.toggleColorSchemeAction.text().find('light') != -1: + self._colorScheme = 'light' + setDarkModeToggleChecked = False else: - self._colorScheme = "dark" + self._colorScheme = 'dark' + setDarkModeToggleChecked = True self.gui_updateSwitchColorSchemeActionText() _warnings.warnRestartCellACDCcolorModeToggled( self._colorScheme, app_name=self._appName, parent=self ) load.rename_qrc_resources_file(self._colorScheme) - self.statusBarLabel.setText( - html_utils.paragraph( - f"Restart {self._appName} for the change to take effect", - font_color="red", - ) - ) - self.df_settings.at["colorScheme", "value"] = self._colorScheme + self.statusBarLabel.setText(html_utils.paragraph( + f'Restart {self._appName} for the change to take effect', + font_color='red' + )) + self.df_settings.at['colorScheme', 'value'] = self._colorScheme self.df_settings.to_csv(settings_csv_path) def openLogFile(self): @@ -228,7 +219,7 @@ def openLogFile(self): myutils.showInExplorer(self.log_path) def openNewWindow(self): - self.logger.info("Opening a new window...") + self.logger.info('Opening a new window...') if self.launcherSlot is not None: self.launcherSlot() return @@ -243,37 +234,7 @@ def openNewWindow(self): def pasteContent(self): pass - def read_version(self) -> str: - return myutils.read_version() - - def rename_qrc_resources_file(self, color_scheme: str): - return rename_qrc_resources_file(color_scheme) - - LEGACY_METHODS = ( - "initGlobalAttr", - "initProfileModels", - "setDisabled", - "determineSlideshowWinPos", - "setTooltips", - "setWindowIcon", - "setWindowTitle", - "onToggleColorScheme", - "showAbout", - "openLogFile", - "showLogFiles", - "showInExplorer_cb", - "showTipsAndTricks", - "openNewWindow", - "cleanUpOnError", - "copyContent", - "pasteContent", - "cutContent", - "about", - ) - - def setDisabled( - self, disabled: bool, keepDisabled: bool = None, force: bool = False - ): + def setDisabled(self, disabled:bool, keepDisabled:bool=None, force:bool=False): if force: if disabled: super().setDisabled(disabled) @@ -300,17 +261,21 @@ def setTooltips(self): for key, tooltip in tooltips.items(): setShortcut = getattr(self, key).shortcut().toString() - if "Shortcut: " in tooltip: - tooltip = tooltip.replace("Shortcut: ", "\nShortcut: ") + if 'Shortcut: ' in tooltip: + tooltip = tooltip.replace('Shortcut: ', '\nShortcut: ') elif setShortcut != "": tooltip = re.sub( - r"Shortcut: \"(.*)\"", f'Shortcut: "{setShortcut}"', tooltip + r'Shortcut: \"(.*)\"', + f"Shortcut: \"{setShortcut}\"", + tooltip ) else: tooltip = re.sub( - r"Shortcut: \"(.*)\"", 'Shortcut: "No shortcut"', tooltip + r'Shortcut: \"(.*)\"', + f"Shortcut: \"No shortcut\"", + tooltip ) - + getattr(self, key).setToolTip(tooltip) getattr(self, key)._tooltip = tooltip @@ -321,7 +286,7 @@ def setWindowIcon(self, icon=None): def setWindowTitle(self, title=None): if title is None: - title = f"Cell-ACDC v{self._acdc_version} - GUI" + title = f'Cell-ACDC v{self._acdc_version} - GUI' super().setWindowTitle(title) def showAbout(self): @@ -342,9 +307,3 @@ def showTipsAndTricks(self): self.welcomeWin = welcome.welcomeWin() self.welcomeWin.showAndSetSize() self.welcomeWin.showPage(self.welcomeWin.quickStartItem) - - def show_in_file_manager(self, path: str): - return myutils.showInExplorer(path) - - def tooltips_from_docs(self) -> dict: - return get_tooltips_from_docs() diff --git a/cellacdc/mixins_bak/brush_tools.py b/cellacdc/mixins/brush_tools.py similarity index 67% rename from cellacdc/mixins_bak/brush_tools.py rename to cellacdc/mixins/brush_tools.py index 25c41d997..1c0b2117e 100644 --- a/cellacdc/mixins_bak/brush_tools.py +++ b/cellacdc/mixins/brush_tools.py @@ -10,30 +10,16 @@ from cellacdc import html_utils, settings_csv_path, widgets -class BrushToolsMixin: - """Qt-facing adapter around brush and eraser tool workflows.""" - - # @exec_time - - # @exec_time - - # @exec_time - - # @exec_time +class BrushTools: + """Extracted from guiWin.""" def Brush_cb(self, checked): if checked: self.typingEditID = False self.setDiskMask() self.setHoverToolSymbolData( - [], - [], - ( - self.ax1_EraserCircle, - self.ax2_EraserCircle, - self.ax1_EraserX, - self.ax2_EraserX, - ), + [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX) ) self.updateBrushCursor(self.xHoverImg, self.yHoverImg) self.setBrushID() @@ -41,7 +27,7 @@ def Brush_cb(self, checked): self.disconnectLeftClickButtons() self.uncheckLeftClickButtons(self.sender()) c = self.defaultToolBarButtonColor - self.eraserButton.setStyleSheet(f"background-color: {c}") + self.eraserButton.setStyleSheet(f'background-color: {c}') self.connectLeftClickButtons() self.setFocusGraphics() else: @@ -51,12 +37,10 @@ def Brush_cb(self, checked): self.ax2_lostTrackedScatterItem.setVisible(True) self.setHoverToolSymbolData( - [], - [], - (self.ax2_BrushCircle, self.ax1_BrushCircle), + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), ) self.resetCursors() - + self.showEditIDwidgets(checked) self.enableSizeSpinbox(checked) @@ -64,30 +48,22 @@ def Eraser_cb(self, checked): if checked: self.setDiskMask() self.setHoverToolSymbolData( - [], - [], - (self.ax2_BrushCircle, self.ax1_BrushCircle), + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), ) self.updateEraserCursor(self.xHoverImg, self.yHoverImg) self.disconnectLeftClickButtons() self.uncheckLeftClickButtons(self.sender()) c = self.defaultToolBarButtonColor - self.brushButton.setStyleSheet(f"background-color: {c}") + self.brushButton.setStyleSheet(f'background-color: {c}') self.connectLeftClickButtons() else: self.setHoverToolSymbolData( - [], - [], - ( - self.ax1_EraserCircle, - self.ax2_EraserCircle, - self.ax1_EraserX, - self.ax2_EraserX, - ), + [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX) ) self.resetCursors() self.updateAllImages() - + self.showEditIDwidgets(checked) self.enableSizeSpinbox(checked) @@ -98,7 +74,7 @@ def applyBrushMask(self, mask, ID, toLocalSlice=None): posData = self.data[self.pos_i] if self.isSegm3D: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == "single z-slice" + isZslice = zProjHow == 'single z-slice' if isZslice: if toLocalSlice is not None: toLocalSlice = (self.z_lab(), *toLocalSlice) @@ -122,7 +98,7 @@ def applyEraserMask(self, mask): posData = self.data[self.pos_i] if self.isSegm3D: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == "single z-slice" + isZslice = zProjHow == 'single z-slice' if isZslice: posData.lab[self.z_lab(), mask] = 0 else: @@ -138,24 +114,22 @@ def autoIDtoggled(self, checked): self.editIDspinbox.setValue(newID) def brushAutoFillToggled(self, checked): - val = "Yes" if checked else "No" - self.df_settings.at["brushAutoFill", "value"] = val + val = 'Yes' if checked else 'No' + self.df_settings.at['brushAutoFill', 'value'] = val self.df_settings.to_csv(self.settings_csv_path) def brushAutoHideToggled(self, checked): - val = "Yes" if checked else "No" - self.df_settings.at["brushAutoHide", "value"] = val + val = 'Yes' if checked else 'No' + self.df_settings.at['brushAutoHide', 'value'] = val self.df_settings.to_csv(self.settings_csv_path) def brushReleased(self): posData = self.data[self.pos_i] - self.fillHolesID(posData.brushID, sender="brush") - + self.fillHolesID(posData.brushID, sender='brush') + # Update data (rp, etc) - self.update_rp( - update_IDs=self.isNewID, - ) - + self.update_rp(update_IDs=self.isNewID,) + # Repeat tracking if self.autoIDcheckbox.isChecked(): self.trackManuallyAddedObject(posData.brushID, self.isNewID) @@ -164,7 +138,7 @@ def brushReleased(self): # Update images if self.isNewID: - editTxt = "Add new ID with brush tool" + editTxt = 'Add new ID with brush tool' if self.isSnapshot: self.fixCcaDfAfterEdit(editTxt) self.updateAllImages() @@ -172,37 +146,21 @@ def brushReleased(self): self.warnEditingWithCca_df(editTxt) else: self.updateAllImages() - + self.isNewID = False def brushSize_cb(self, value): - self.ax2_EraserCircle.setSize(value * 2) - self.ax1_BrushCircle.setSize(value * 2) - self.ax2_BrushCircle.setSize(value * 2) - self.ax1_EraserCircle.setSize(value * 2) + self.ax2_EraserCircle.setSize(value*2) + self.ax1_BrushCircle.setSize(value*2) + self.ax2_BrushCircle.setSize(value*2) + self.ax1_EraserCircle.setSize(value*2) self.ax2_EraserX.setSize(value) self.ax1_EraserX.setSize(value) self.setDiskMask() - def brush_toolbar_visible( - self, - edit_id_visible: bool, - *, - brush_size_visible: bool, - auto_fill_visible: bool, - auto_hide_visible: bool, - ) -> bool: - return any( - ( - edit_id_visible, - brush_size_visible, - auto_fill_visible, - auto_hide_visible, - ) - ) - def changeBrushID(self): - """Function called when pressing or releasing shift""" + """Function called when pressing or releasing shift + """ if not self.isSegm3D: # Changing brush ID with shift is only for 3D segm return @@ -210,17 +168,17 @@ def changeBrushID(self): if not self.brushButton.isChecked(): # Brush if not active return - + if not self.isMouseDragImg2 and not self.isMouseDragImg1: # Mouse is not brushing at the moment return posData = self.data[self.pos_i] forceNewObj = not self.isNewID - + if forceNewObj: # Shift is down --> force new object with brush - # e.g., 24 --> 28: + # e.g., 24 --> 28: # 24 is hovering ID that we store as self.prevBrushID # 24 object becomes 28 that is the new posData.brushID self.isNewID = True @@ -229,48 +187,45 @@ def changeBrushID(self): # Set a new ID self.setBrushID() else: - # Shift released or hovering on ID in z+-1 - # --> restore brush ID from before shift was pressed or from - # when we started brushing from outside an object + # Shift released or hovering on ID in z+-1 + # --> restore brush ID from before shift was pressed or from + # when we started brushing from outside an object # but we hovered on ID in z+-1 while dragging. - # We change the entire 28 object to 24 so before changing the + # We change the entire 28 object to 24 so before changing the # brush ID back to 24 we builg the mask with 28 to change it to 24 self.isNewID = False self.changedID = posData.brushID - # Restore ID + # Restore ID posData.brushID = self.restoreBrushID - + brushID = posData.brushID brushIDmask = self.get_2Dlab(posData.lab) == self.changedID self.applyBrushMask(brushIDmask, brushID) if self.isMouseDragImg1: - self.brushColor = self.lut[posData.brushID] / 255 + self.brushColor = self.lut[posData.brushID]/255 self.setTempImg1Brush(True, brushIDmask, posData.brushID) def checkWarnDeletedIDwithEraser(self): posData = self.data[self.pos_i] - + for ID in self.erasedIDs: if ID == 0: continue if ID in posData.IDs_idxs: continue - + self.instructHowDeleteID() - + if self.isSnapshot: - self.fixCcaDfAfterEdit("Delete ID with eraser") + self.fixCcaDfAfterEdit('Delete ID with eraser') self.updateAllImages() else: - self.warnEditingWithCca_df("Delete ID with eraser") - + self.warnEditingWithCca_df('Delete ID with eraser') + return True - + return False - def checked_setting_value(self, checked: bool) -> str: - return "Yes" if checked else "No" - def clearObjFromMask(self, image, mask, toLocalSlice=None): if mask is None: return image @@ -279,81 +234,15 @@ def clearObjFromMask(self, image, mask, toLocalSlice=None): image[mask] = 0 else: image[toLocalSlice][mask] = 0 - + return image - def default_delete_object_info_value(self) -> str: - return "Yes" - - def delete_object_info_value( - self, - do_not_show_again_checked: bool, - ) -> str: - return "No" if do_not_show_again_checked else "Yes" - - def disk_mask(self, brush_size: int): - import skimage.morphology - - return skimage.morphology.disk(brush_size, dtype=bool) - - def disk_mask_bounds( - self, - image_shape: tuple[int, int], - brush_size: int, - xdata: int, - ydata: int, - disk_mask, - ): - y_size, x_size = image_shape - y_bottom, x_left = ydata - brush_size, xdata - brush_size - y_top, x_right = ydata + brush_size + 1, xdata + brush_size + 1 - - if x_left < 0: - if y_bottom < 0: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[-y_bottom:, -x_left:] - y_bottom = 0 - elif y_top > y_size: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[0 : y_size - y_bottom, -x_left:] - y_top = y_size - else: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[:, -x_left:] - x_left = 0 - - elif x_right > x_size: - if y_bottom < 0: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[-y_bottom:, 0 : x_size - x_left] - y_bottom = 0 - elif y_top > y_size: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[0 : y_size - y_bottom, 0 : x_size - x_left] - y_top = y_size - else: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[:, 0 : x_size - x_left] - x_right = x_size - - elif y_bottom < 0: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[-y_bottom:] - y_bottom = 0 - - elif y_top > y_size: - disk_mask = disk_mask.copy() - disk_mask = disk_mask[0 : y_size - y_bottom] - y_top = y_size - - return y_bottom, x_left, y_top, x_right, disk_mask - - def fillHolesID(self, ID, sender="brush"): + def fillHolesID(self, ID, sender='brush'): posData = self.data[self.pos_i] - if sender == "brush": + if sender == 'brush': if not self.brushAutoFillCheckbox.isChecked(): return False - + lab2D = self.get_2Dlab(posData.lab) mask = lab2D == ID filledMask = scipy.ndimage.binary_fill_holes(mask) @@ -367,19 +256,19 @@ def getDiskMask(self, xdata, ydata): Y, X = self.currentLab2D.shape[-2:] brushSize = self.brushSizeSpinbox.value() - yBottom, xLeft = ydata - brushSize, xdata - brushSize - yTop, xRight = ydata + brushSize + 1, xdata + brushSize + 1 + yBottom, xLeft = ydata-brushSize, xdata-brushSize + yTop, xRight = ydata+brushSize+1, xdata+brushSize+1 - if xLeft < 0: - if yBottom < 0: + if xLeft<0: + if yBottom<0: # Disk mask out of bounds top-left diskMask = self.diskMask.copy() diskMask = diskMask[-yBottom:, -xLeft:] yBottom = 0 - elif yTop > Y: + elif yTop>Y: # Disk mask out of bounds bottom-left diskMask = self.diskMask.copy() - diskMask = diskMask[0 : Y - yBottom, -xLeft:] + diskMask = diskMask[0:Y-yBottom, -xLeft:] yTop = Y else: # Disk mask out of bounds on the left @@ -387,33 +276,33 @@ def getDiskMask(self, xdata, ydata): diskMask = diskMask[:, -xLeft:] xLeft = 0 - elif xRight > X: - if yBottom < 0: + elif xRight>X: + if yBottom<0: # Disk mask out of bounds top-right diskMask = self.diskMask.copy() - diskMask = diskMask[-yBottom:, 0 : X - xLeft] + diskMask = diskMask[-yBottom:, 0:X-xLeft] yBottom = 0 - elif yTop > Y: + elif yTop>Y: # Disk mask out of bounds bottom-right diskMask = self.diskMask.copy() - diskMask = diskMask[0 : Y - yBottom, 0 : X - xLeft] + diskMask = diskMask[0:Y-yBottom, 0:X-xLeft] yTop = Y else: # Disk mask out of bounds on the right diskMask = self.diskMask.copy() - diskMask = diskMask[:, 0 : X - xLeft] + diskMask = diskMask[:, 0:X-xLeft] xRight = X - elif yBottom < 0: + elif yBottom<0: # Disk mask out of bounds on top diskMask = self.diskMask.copy() diskMask = diskMask[-yBottom:] yBottom = 0 - elif yTop > Y: + elif yTop>Y: # Disk mask out of bounds on bottom diskMask = self.diskMask.copy() - diskMask = diskMask[0 : Y - yBottom] + diskMask = diskMask[0:Y-yBottom] yTop = Y else: @@ -432,12 +321,12 @@ def getMagicWandFloodTolerance(self): tol_perc = self.wandControlsToolbar.toleranceSpinbox.value() if tol_perc == 0: return - + posData = self.data[self.pos_i] _min, _max = posData.img_data_min_max - tol_fraction = tol_perc / 100 + tol_fraction = tol_perc/100 tol = (_max - _min) * tol_fraction - + return tol def initFloodMaskImage(self): @@ -452,12 +341,12 @@ def initTempLayerBrush(self, ID, ax=0): how = self.drawIDsContComboBox.currentText() else: how = self.getAnnotateHowRightImage() - + self.hideItemsHoverBrush(ID=ID, force=True) Y, X = self.img1.image.shape[:2] tempImage = np.zeros((Y, X), dtype=np.uint32) - if how.find("contours") != -1: - tempImage[self.currentLab2D == ID] = ID + if how.find('contours') != -1: + tempImage[self.currentLab2D==ID] = ID self.brushImage = tempImage.copy() self.brushContourImage = np.zeros((Y, X, 4), dtype=np.uint8) color = self.imgGrad.contoursColorButton.color() @@ -467,56 +356,46 @@ def initTempLayerBrush(self, ID, ax=0): opacity = self.imgGrad.labelsAlphaSlider.value() color = self.lut[ID] lut = np.zeros((2, 4), dtype=np.uint8) - lut[1, -1] = 255 - lut[1, :-1] = color + lut[1,-1] = 255 + lut[1,:-1] = color self.tempLayerImg1.setLookupTable(lut) self.tempLayerImg1.setOpacity(opacity) self.tempLayerImg1.setImage(tempImage, force_set_linked=True) def instructHowDeleteID(self): - if "showInfoDeleteObject" not in self.df_settings.index: - self.df_settings.at["showInfoDeleteObject", "value"] = "Yes" - + if 'showInfoDeleteObject' not in self.df_settings.index: + self.df_settings.at['showInfoDeleteObject', 'value'] = 'Yes' + showInfoDeleteObject = ( - self.df_settings.at["showInfoDeleteObject", "value"] == "Yes" + self.df_settings.at['showInfoDeleteObject', 'value'] == 'Yes' ) if not showInfoDeleteObject: return - + actionText = self.middleClickText() msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - "You have deleted an object using the eraser tool.

" + 'You have deleted an object using the eraser tool.

' 'Did you know that you can use the "Delete object" action
' - "to delete an object with a single click?

" - f"To do so, use the following action: {actionText}

" - "Note: You can also set a custom shortcut by going to the menu
" - "Settings --> Customise keyboard shortcuts...." + 'to delete an object with a single click?

' + f'To do so, use the following action: {actionText}

' + 'Note: You can also set a custom shortcut by going to the menu
' + 'Settings --> Customise keyboard shortcuts....' ) - doNotShowAgainCheckbox = QCheckBox("Do not show again") + doNotShowAgainCheckbox = QCheckBox('Do not show again') msg.information( - self, - "Delete objects with single click", - txt, - widgets=doNotShowAgainCheckbox, + self, 'Delete objects with single click', txt, + widgets=doNotShowAgainCheckbox ) - + showInfoDeleteObjectValue = ( - "No" if doNotShowAgainCheckbox.isChecked() else "Yes" + 'No' if doNotShowAgainCheckbox.isChecked() else 'Yes' + ) + self.df_settings.at['showInfoDeleteObject', 'value'] = ( + showInfoDeleteObjectValue ) - self.df_settings.at["showInfoDeleteObject", "value"] = showInfoDeleteObjectValue self.df_settings.to_csv(settings_csv_path) - def magic_wand_flood_tolerance( - self, - tolerance_percent: float, - image_min: float, - image_max: float, - ): - if tolerance_percent == 0: - return None - return (image_max - image_min) * (tolerance_percent / 100) - def resetCursors(self): self.ax1_cursor.setData([], []) self.ax2_cursor.setData([], []) @@ -546,14 +425,12 @@ def setBrushID(self, useCurrentLab=True, return_val=False): for frame_i, storedData in enumerate(posData.allData_li): if frame_i == posData.frame_i: continue - lab = storedData["labels"] + lab = storedData['labels'] if lab is not None: - rp = storedData["regionprops"] + rp = storedData['regionprops'] IDs_tot = {obj.label for obj in rp} if wl_init: - if self.whitelistCheckOriginalLabels( - warning=False, frame_i=frame_i - ): + if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): IDs_tot.update(posData.whitelist.originalLabsIDs[frame_i]) if posData.whitelist.whitelistIDs[frame_i]: IDs_tot.update(posData.whitelist.whitelistIDs[frame_i]) @@ -566,7 +443,7 @@ def setBrushID(self, useCurrentLab=True, return_val=False): for y, x, manual_ID in posData.editID_info: if manual_ID > newID: newID = manual_ID - posData.brushID = newID + 1 + posData.brushID = newID+1 if return_val: return posData.brushID @@ -583,29 +460,32 @@ def setDiskMask(self): def setTempBrushMaskFromWand(self, flood_mask, init=False): if not np.any(flood_mask): return - + posData = self.data[self.pos_i] - mask = np.logical_or(flood_mask, posData.lab == posData.brushID) + mask = np.logical_or( + flood_mask, + posData.lab==posData.brushID + ) if mask.ndim == 3: z_slice = self.zSliceScrollBar.sliderPosition() mask = mask[z_slice] - + self.setTempImg1Brush(init, mask, posData.brushID) def setTempImg1Brush(self, init: bool, mask, ID, toLocalSlice=None, ax=0): if init: self.initTempLayerBrush(ID, ax=ax) - + if self.annotContourCheckbox.isChecked(): brushImage = self.brushImage else: brushImage = self.tempLayerImg1.image - + if toLocalSlice is None: brushImage[mask] = ID else: brushImage[toLocalSlice][mask] = ID - + if self.annotContourCheckbox.isChecked(): try: obj = skimage.measure.regionprops(brushImage)[0] @@ -631,18 +511,20 @@ def setTempImg1Eraser(self, mask, init=False, toLocalSlice=None, ax=0): how = self.drawIDsContComboBox.currentText() else: how = self.getAnnotateHowRightImage() - + if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): return - - if how.find("contours") != -1: - self.clearObjFromMask(self.contoursImage, mask, toLocalSlice=toLocalSlice) + + if how.find('contours') != -1: + self.clearObjFromMask( + self.contoursImage, mask, toLocalSlice=toLocalSlice + ) erasedRp = skimage.measure.regionprops(self.erasedLab) for obj in erasedRp: self.addObjContourToContoursImage(obj=obj, ax=ax) - elif how.find("overlay segm. masks") != -1: + elif how.find('overlay segm. masks') != -1: labelsImage = self.getLabelsLayerImage(ax=ax) - self.clearObjFromMask(labelsImage, mask, toLocalSlice=toLocalSlice) + self.clearObjFromMask(labelsImage, mask, toLocalSlice=toLocalSlice) if ax == 0: self.labelsLayerImg1.setImage( self.labelsLayerImg1.image, autoLevels=False @@ -652,17 +534,6 @@ def setTempImg1Eraser(self, mask, init=False, toLocalSlice=None, ax=0): self.labelsLayerRightImg.image, autoLevels=False ) - def should_fill_holes( - self, - sender: str, - *, - auto_fill_checked: bool, - ) -> bool: - return sender == "brush" and auto_fill_checked - - def should_show_delete_object_info(self, setting_value: Any) -> bool: - return setting_value == "Yes" - def showEditIDwidgets(self, visible): self.editIDLabelAction.setVisible(visible) self.editIDspinboxAction.setVisible(visible) @@ -686,26 +557,27 @@ def updateEraserCursor(self, x, y, xyLocked=None, isHoverImg1=True): if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): return - size = self.brushSizeSpinbox.value() * 2 + size = self.brushSizeSpinbox.value()*2 self.setHoverToolSymbolData( - [x], [y], self.activeEraserCircleCursors(isHoverImg1), size=size + [x], [y], self.activeEraserCircleCursors(isHoverImg1), + size=size ) self.setHoverToolSymbolData( - [x], [y], self.activeEraserXCursors(isHoverImg1), size=int(size / 2) + [x], [y], self.activeEraserXCursors(isHoverImg1), + size=int(size/2) ) - isMouseDrag = self.isMouseDragImg1 or self.isMouseDragImg2 + isMouseDrag = ( + self.isMouseDragImg1 or self.isMouseDragImg2 + ) if isMouseDrag: return - + if xyLocked is not None: xdata, ydata = xyLocked self.setHoverToolSymbolColor( - xdata, - ydata, - self.eraserCirclePen, + xdata, ydata, self.eraserCirclePen, self.activeEraserCircleCursors(isHoverImg1), - self.eraserButton, - hoverRGB=None, + self.eraserButton, hoverRGB=None ) diff --git a/cellacdc/mixins/canvas_context_menu.py b/cellacdc/mixins/canvas_context_menu.py new file mode 100644 index 000000000..cccd157d8 --- /dev/null +++ b/cellacdc/mixins/canvas_context_menu.py @@ -0,0 +1,129 @@ +"""View adapter for canvas context menus and deleted-ROI clicks.""" + +from __future__ import annotations + +import pyqtgraph as pg +from qtpy.QtCore import QPoint +from qtpy.QtWidgets import QAction, QMenu + + +class CanvasContextMenu: + """Extracted from guiWin.""" + + def gui_clickedDelRoi(self, event, left_click, right_click): + posData = self.data[self.pos_i] + x, y = event.pos().x(), event.pos().y() + + # Check if right click on ROI + delROIs = ( + posData.allData_li[posData.frame_i]['delROIs_info']['rois'].copy() + ) + for r, roi in enumerate(delROIs): + ROImask = self.getDelRoiMask(roi) + if self.isSegm3D: + clickedOnROI = ROImask[self.z_lab(), int(y), int(x)] + else: + clickedOnROI = ROImask[int(y), int(x)] + raiseContextMenuRoi = right_click and clickedOnROI + dragRoi = left_click and clickedOnROI + if raiseContextMenuRoi: + self.roi_to_del = roi + self.roiContextMenu = QMenu(self) + separator = QAction(self) + separator.setSeparator(True) + self.roiContextMenu.addAction(separator) + action = QAction('Remove ROI') + action.triggered.connect(self.removeDelROI) + self.roiContextMenu.addAction(action) + try: + # Convert QPointF to QPoint + self.roiContextMenu.exec_(event.screenPos().toPoint()) + except AttributeError: + self.roiContextMenu.exec_(event.screenPos()) + return True + elif dragRoi: + event.ignore() + return True + return False + + def checkHighlightScaleBar(self, x, y, activeToolButton): + if not hasattr(self, 'scaleBar'): + return + + if not self.addScaleBarAction.isChecked(): + return + + if activeToolButton is not None: + return + + ymin, xmin, ymax, xmax = self.scaleBar.bbox() + if x < xmin: + self.scaleBar.setHighlighted(False) + return + + if x > xmax: + self.scaleBar.setHighlighted(False) + return + + if y < ymin: + self.scaleBar.setHighlighted(False) + return + + if y > ymax: + self.scaleBar.setHighlighted(False) + return + + self.scaleBar.setHighlighted(True) + + def checkHighlightTimestamp(self, x, y, activeToolButton): + if not hasattr(self, 'timestamp'): + return + + if not self.addTimestampAction.isChecked(): + return + + if activeToolButton is not None: + return + + if hasattr(self, 'scaleBar'): + if self.scaleBar.isHighlighted(): + return + + ymin, xmin, ymax, xmax = self.timestamp.bbox() + if x < xmin: + self.timestamp.setHighlighted(False) + return + + if x > xmax: + self.timestamp.setHighlighted(False) + return + + if y < ymin: + self.timestamp.setHighlighted(False) + return + + if y > ymax: + self.timestamp.setHighlighted(False) + return + + self.timestamp.setHighlighted(True) + + def gui_imgGradShowContextMenu(self, x, y): + if hasattr(self, 'scaleBar'): + if self.scaleBar.isHighlighted(): + self.scaleBar.showContextMenu(x, y) + return + + if hasattr(self, 'timestamp'): + if self.timestamp.isHighlighted(): + self.timestamp.showContextMenu(x, y) + return + + self.imgGrad.gradient.menu.popup(QPoint(int(x), int(y))) + + def gui_rightImageShowContextMenu(self, event): + try: + # Convert QPointF to QPoint + self.imgGradRight.gradient.menu.popup(event.screenPos().toPoint()) + except AttributeError: + self.imgGradRight.gradient.menu.popup(event.screenPos()) diff --git a/cellacdc/mixins_bak/canvas_drawing.py b/cellacdc/mixins/canvas_drawing.py similarity index 74% rename from cellacdc/mixins_bak/canvas_drawing.py rename to cellacdc/mixins/canvas_drawing.py index 58bbf9127..ef6b606fa 100644 --- a/cellacdc/mixins_bak/canvas_drawing.py +++ b/cellacdc/mixins/canvas_drawing.py @@ -12,38 +12,8 @@ from cellacdc import apps, exception_handler, html_utils, widgets -class CanvasDrawingMixin: - """Qt-facing adapter for canvas drawing workflows.""" - - """Headless decisions for canvas drawing workflows.""" - - viewer_mode = "Viewer" - - def calculate_brush_mask( - self, - image_shape: tuple[int, int], - ymin: int, - xmin: int, - ymax: int, - xmax: int, - disk_mask: np.ndarray, - rr_poly: np.ndarray | None = None, - cc_poly: np.ndarray | None = None, - ) -> np.ndarray: - """Computes a 2D boolean mask for brush/eraser updates.""" - mask = np.zeros(image_shape, dtype=bool) - disk_slice = (slice(ymin, ymax), slice(xmin, xmax)) - mask[disk_slice][disk_mask] = True - if rr_poly is not None and cc_poly is not None: - mask[rr_poly, cc_poly] = True - return mask - - LEGACY_METHODS = ( - "gui_addCreatedAxesItems", - "gui_mouseDragEventImg1", - "gui_mouseDragEventImg2", - "gui_mouseReleaseEventImg1", - ) +class CanvasDrawing: + """Extracted from guiWin.""" def gui_addCreatedAxesItems(self): self.ax1.addItem(self.ax1_contoursImageItem) @@ -65,40 +35,39 @@ def gui_addCreatedAxesItems(self): self.textAnnot[0].addToPlotItem(self.ax1) self.textAnnot[1].addToPlotItem(self.ax2) - + self.ax1.addItem(self.exportMaskImageItem) self.ax1.exportMaskImageItem = self.exportMaskImageItem - @exception_handler def gui_mouseDragEventImg1(self, event): x, y = event.pos().x(), event.pos().y() - - if hasattr(self, "scaleBar"): + + if hasattr(self, 'scaleBar'): if self.scaleBarDialog is not None: - self.scaleBarDialog.locCombobox.setCurrentText("Custom") + self.scaleBarDialog.locCombobox.setCurrentText('Custom') if self.scaleBar.isHighlighted() and self.scaleBar.clicked: - self.scaleBar.setLocationProperty("custom") + self.scaleBar.setLocationProperty('custom') self.scaleBar.move(x, y) return - - if hasattr(self, "timestamp"): + + if hasattr(self, 'timestamp'): if self.timestampDialog is not None: - self.timestampDialog.locCombobox.setCurrentText("Custom") + self.timestampDialog.locCombobox.setCurrentText('Custom') if self.timestamp.isHighlighted() and self.timestamp.clicked: - self.timestamp.setLocationProperty("custom") + self.timestamp.setLocationProperty('custom') self.timestamp.move(x, y) return - + mode = str(self.modeComboBox.currentText()) - if mode == "Viewer": + if mode == 'Viewer': return - + posData = self.data[self.pos_i] Y, X = self.get_2Dlab(posData.lab).shape xdata, ydata = int(x), int(y) if not myutils.is_in_bounds(xdata, ydata, X, Y): return - + if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): self.drawAutoContour(y, x) @@ -119,20 +88,17 @@ def gui_mouseDragEventImg1(self, event): mask = np.zeros(lab_2D.shape, bool) mask[diskSlice][diskMask] = True mask[rrPoly, ccPoly] = True - + modifiers = QGuiApplication.keyboardModifiers() ctrl = modifiers == Qt.ControlModifier # t3 = time.perf_counter() if not self.isPowerBrush() and not ctrl: - mask[lab_2D != 0] = False + mask[lab_2D!=0] = False self.setHoverToolSymbolColor( - xdata, - ydata, - self.ax2_BrushCirclePen, + xdata, ydata, self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, - brush=self.ax2_BrushCircleBrush, + self.brushButton, brush=self.ax2_BrushCircleBrush ) # t4 = time.perf_counter() @@ -145,9 +111,12 @@ def gui_mouseDragEventImg1(self, event): # t5 = time.perf_counter() lab2D = self.get_2Dlab(posData.lab) - brushMask = np.logical_and(lab2D[diskSlice] == posData.brushID, diskMask) + brushMask = np.logical_and( + lab2D[diskSlice] == posData.brushID, diskMask + ) self.setTempImg1Brush( - False, brushMask, posData.brushID, toLocalSlice=diskSlice + False, brushMask, posData.brushID, + toLocalSlice=diskSlice ) # t6 = time.perf_counter() @@ -179,26 +148,23 @@ def gui_mouseDragEventImg1(self, event): mask[rrPoly, ccPoly] = True if self.eraseOnlyOneID: - mask[lab_2D != self.erasedID] = False + mask[lab_2D!=self.erasedID] = False self.setHoverToolSymbolColor( - xdata, - ydata, - self.eraserCirclePen, + xdata, ydata, self.eraserCirclePen, (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, - hoverRGB=self.img2.lut[self.erasedID], - ID=self.erasedID, + self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], + ID=self.erasedID ) self.erasedIDs.update(lab_2D[mask]) self.applyEraserMask(mask) self.setImageImg2() - + for erasedID in self.erasedIDs: if erasedID == 0: continue - self.erasedLab[lab_2D == erasedID] = erasedID + self.erasedLab[lab_2D==erasedID] = erasedID self.erasedLab[mask] = 0 eraserMask = mask[diskSlice] @@ -218,10 +184,12 @@ def gui_mouseDragEventImg1(self, event): seed = (z_slice, ydata, xdata) else: seed = (ydata, xdata) - - flood_mask = skimage.segmentation.flood(self.flood_img, seed, tolerance=tol) + + flood_mask = skimage.segmentation.flood( + self.flood_img, seed, tolerance=tol + ) drawUnderMask = np.logical_or( - posData.lab == 0, posData.lab == posData.brushID + posData.lab==0, posData.lab==posData.brushID ) flood_mask = np.logical_and(flood_mask, drawUnderMask) @@ -229,36 +197,35 @@ def gui_mouseDragEventImg1(self, event): if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): self.flood_mask = core.binary_fill_holes(self.flood_mask) - + if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): self.flood_mask = core.convex_hull_mask(self.flood_mask) self.setTempBrushMaskFromWand(self.flood_mask) - + # Label ROI dragging mouse --> draw ROI elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): if self.labelRoiIsRectRadioButton.isChecked(): x0, y0 = self.labelRoiItem.pos() - w, h = (xdata - x0), (ydata - y0) + w, h = (xdata-x0), (ydata-y0) self.labelRoiItem.setSize((w, h)) elif self.labelRoiIsFreeHandRadioButton.isChecked(): self.freeRoiItem.addPoint(xdata, ydata) - + # Draw freehand clear region --> draw region elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): self.freeRoiItem.addPoint(xdata, ydata) - + # Label ROI dragging mouse --> draw ROI elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): x0, y0 = self.zoomRectItem.pos() - w, h = (xdata - x0), (ydata - y0) + w, h = (xdata-x0), (ydata-y0) self.zoomRectItem.setSize((w, h)) - @exception_handler def gui_mouseDragEventImg2(self, event): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if mode == "Viewer": + if mode == 'Viewer': return Y, X = self.get_2Dlab(posData.lab).shape @@ -274,7 +241,7 @@ def gui_mouseDragEventImg2(self, event): Y, X = lab_2D.shape x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - self.brushSizeSpinbox.value() + brushSize = self.brushSizeSpinbox.value() rrPoly, ccPoly = self.getPolygonBrush((y, x), Y, X) ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) @@ -285,15 +252,12 @@ def gui_mouseDragEventImg2(self, event): mask[rrPoly, ccPoly] = True if self.eraseOnlyOneID: - mask[lab_2D != self.erasedID] = False + mask[lab_2D!=self.erasedID] = False self.setHoverToolSymbolColor( - xdata, - ydata, - self.eraserCirclePen, + xdata, ydata, self.eraserCirclePen, (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, - hoverRGB=self.img2.lut[self.erasedID], - ID=self.erasedID, + self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], + ID=self.erasedID ) self.erasedIDs.update(lab_2D[mask]) @@ -320,14 +284,11 @@ def gui_mouseDragEventImg2(self, event): # If user double-pressed 'b' then draw over the labels color = self.brushButton.palette().button().color().name() if color != self.doublePressKeyButtonColor: - mask[lab_2D != 0] = False + mask[lab_2D!=0] = False self.setHoverToolSymbolColor( - xdata, - ydata, - self.ax2_BrushCirclePen, + xdata, ydata, self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.eraserButton, - brush=self.ax2_BrushCircleBrush, + self.eraserButton, brush=self.ax2_BrushCircleBrush ) # Apply brush mask @@ -340,17 +301,17 @@ def gui_mouseDragEventImg2(self, event): x, y = event.pos().x(), event.pos().y() self.moveLabel(x, y) - @exception_handler def gui_mouseReleaseEventImg1(self, event): modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier alt = modifiers == Qt.AltModifier right_click = event.button() == Qt.MouseButton.RightButton and not alt - + posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if mode == "Viewer": + if mode == 'Viewer': return - + Y, X = self.get_2Dlab(posData.lab).shape x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) @@ -358,20 +319,21 @@ def gui_mouseReleaseEventImg1(self, event): self.isMouseDragImg2 = False self.updateAllImages() return - - if hasattr(self, "scaleBar"): + + if hasattr(self, 'scaleBar'): if self.scaleBar.isHighlighted() and self.scaleBar.clicked: self.scaleBar.clicked = False return - - if hasattr(self, "timestamp"): + + if hasattr(self, 'timestamp'): if self.timestamp.isHighlighted() and self.timestamp.clicked: self.timestamp.clicked = False return - + sendRightClickImg2 = ( - mode == "Segmentation and Tracking" or self.isSnapshot - ) and right_click + (mode=='Segmentation and Tracking' or self.isSnapshot) + and right_click + ) if sendRightClickImg2: # Allow right-click actions on both images self.gui_mouseReleaseEventImg2(event) @@ -385,10 +347,10 @@ def gui_mouseReleaseEventImg1(self, event): if self.autoIDcheckbox.isChecked(): self.trackManuallyAddedObject(posData.brushID, True) if self.isSnapshot: - self.fixCcaDfAfterEdit("Add new ID with curvature tool") + self.fixCcaDfAfterEdit('Add new ID with curvature tool') self.updateAllImages() else: - self.warnEditingWithCca_df("Add new ID with curvature tool") + self.warnEditingWithCca_df('Add new ID with curvature tool') self.clearCurvItems() self.curvTool_cb(True) except ValueError: @@ -401,12 +363,12 @@ def gui_mouseReleaseEventImg1(self, event): self.isMouseDragImg1 = False self.clearTempBrushImage() - + # Update data (rp, etc) self.update_rp() doUpdateImages = self.checkWarnDeletedIDwithEraser() - + if doUpdateImages: self.updateAllImages() @@ -415,7 +377,7 @@ def gui_mouseReleaseEventImg1(self, event): self.isMouseDragImg1 = False self.clearTempBrushImage() - + self.brushReleased() # Wand tool release, add new object @@ -426,7 +388,7 @@ def gui_mouseReleaseEventImg1(self, event): posData = self.data[self.pos_i] posData.lab[self.flood_mask] = posData.brushID - + # Update data (rp, etc) self.update_rp() @@ -434,11 +396,11 @@ def gui_mouseReleaseEventImg1(self, event): self.trackManuallyAddedObject(posData.brushID, self.isNewID) if self.isSnapshot: - self.fixCcaDfAfterEdit("Add new ID with magic-wand") + self.fixCcaDfAfterEdit('Add new ID with magic-wand') self.updateAllImages() else: - self.warnEditingWithCca_df("Add new ID with magic-wand") - + self.warnEditingWithCca_df('Add new ID with magic-wand') + # Label ROI mouse release --> label the ROI with labelRoiWorker elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): self.labelRoiRunning = True @@ -447,7 +409,7 @@ def gui_mouseReleaseEventImg1(self, event): if self.labelRoiIsFreeHandRadioButton.isChecked(): self.freeRoiItem.closeCurve() - + proceed = self.labelRoiCheckStartStopFrame() if not proceed: self.labelRoiCancelled() @@ -464,45 +426,44 @@ def gui_mouseReleaseEventImg1(self, event): if cancel: self.labelRoiCancelled() return - - # Restore state of button because it was maybe unchecked by - # using other tools that are allowed --> see "elif" case in + + # Restore state of button because it was maybe unchecked by + # using other tools that are allowed --> see "elif" case in # labelRoi_cb self.labelRoiButton.blockSignals(True) self.labelRoiButton.setChecked(True) self.labelRoiToolbar.setVisible(True) self.labelRoiButton.blockSignals(False) - + roiSecondChannel = None if self.secondChannelName is not None: secondChannelData = self.getSecondChannelData() roiSecondChannel = secondChannelData[self.labelRoiSlice] - + isTimelapse = self.labelRoiTrangeCheckbox.isChecked() if isTimelapse: start_n = self.labelRoiStartFrameNoSpinbox.value() stop_n = self.labelRoiStopFrameNoSpinbox.value() self.progressWin = apps.QDialogWorkerProgress( - title="ROI segmentation", - parent=self, - pbarDesc=f"Segmenting frames n. {start_n} to {stop_n}...", + title='ROI segmentation', parent=self, + pbarDesc=f'Segmenting frames n. {start_n} to {stop_n}...' ) self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stop_n - start_n) + self.progressWin.mainPbar.setMaximum(stop_n-start_n) - self.app.restoreOverrideCursor() + + self.app.restoreOverrideCursor() labelRoiWorker = self.labelRoiActiveWorkers[-1] labelRoiWorker.start( - roiImg, - posData, - roiSecondChannel=roiSecondChannel, - isTimelapse=isTimelapse, - ) + roiImg, posData, + roiSecondChannel=roiSecondChannel, + isTimelapse=isTimelapse + ) self.app.setOverrideCursor(Qt.WaitCursor) self.logger.info( - f"Magic labeller started on image ROI = {self.labelRoiSlice}..." + f'Magic labeller started on image ROI = {self.labelRoiSlice}...' ) - self.titleLabel.setText("Magic labeller is doing its magic...") + self.titleLabel.setText('Magic labeller is doing its magic...') self.setDisabled(True) # Move label mouse released, update move @@ -529,15 +490,16 @@ def gui_mouseReleaseEventImg1(self, event): return if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) mothID_prompt = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter ID that you want to annotate as mother cell", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as mother cell', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) mothID_prompt.exec_() if mothID_prompt.cancel: @@ -552,20 +514,22 @@ def gui_mouseReleaseEventImg1(self, event): # Store undo state before modifying stuff self.storeUndoRedoStates(False) - relationship = posData.cca_df.at[ID, "relationship"] - ccs = posData.cca_df.at[ID, "cell_cycle_stage"] - is_history_known = posData.cca_df.at[ID, "is_history_known"] + relationship = posData.cca_df.at[ID, 'relationship'] + ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] + is_history_known = posData.cca_df.at[ID, 'is_history_known'] # We allow assiging a cell in G1 as mother only on first frame # OR if the history is unknown - if relationship == "bud" and posData.frame_i > 0 and is_history_known: + if relationship == 'bud' and posData.frame_i > 0 and is_history_known: self.assignBudMothButton.setChecked(False) txt = html_utils.paragraph( - f"You clicked on ID {ID} which is a BUD.

" - "To assign a bud start by clicking on the bud " - "and release on a cell in G1" + f'You clicked on ID {ID} which is a BUD.

' + 'To assign a bud start by clicking on the bud ' + 'and release on a cell in G1' ) msg = widgets.myMessageBox() - msg.critical(self, "Released on a bud", txt) + msg.critical( + self, 'Released on a bud', txt + ) self.assignBudMothButton.setChecked(True) return @@ -583,29 +547,26 @@ def gui_mouseReleaseEventImg1(self, event): self.assignBudMothButton.setChecked(False) msg = widgets.myMessageBox() txt = ( - f"You clicked FIRST on ID {budID} and then on {new_mothID}.
" - f"For me this means that you want ID {budID} to be the " - f"BUD of ID {new_mothID}.
" - f"However ID {budID} is bigger than {new_mothID} " - f"so maybe you should have clicked FIRST on {new_mothID}?

" - "What do you want me to do?" + f'You clicked FIRST on ID {budID} and then on {new_mothID}.
' + f'For me this means that you want ID {budID} to be the ' + f'BUD of ID {new_mothID}.
' + f'However ID {budID} is bigger than {new_mothID} ' + f'so maybe you should have clicked FIRST on {new_mothID}?

' + 'What do you want me to do?' ) txt = html_utils.paragraph(txt) swapButton, keepButton = msg.warning( - self, - "Which one is bud?", - txt, + self, 'Which one is bud?', txt, buttonsTexts=( - f"Assign ID {new_mothID} as the bud of ID {budID}", - f"Keep ID {budID} as the bud of ID {new_mothID}", - ), + f'Assign ID {new_mothID} as the bud of ID {budID}', + f'Keep ID {budID} as the bud of ID {new_mothID}' + ) ) if msg.clickedButton == swapButton: - (xdata, ydata, self.xClickBud, self.yClickBud) = ( - self.xClickBud, - self.yClickBud, - xdata, - ydata, + (xdata, ydata, + self.xClickBud, self.yClickBud) = ( + self.xClickBud, self.yClickBud, + xdata, ydata ) self.assignBudMothButton.setChecked(True) @@ -614,21 +575,23 @@ def gui_mouseReleaseEventImg1(self, event): budID = self.get_2Dlab(posData.lab)[ydata, xdata] # Allow assigning an unknown cell ONLY to another unknown cell txt = ( - f"You started by clicking on ID {budID} which has " - "UNKNOWN history, but you then clicked/released on " - f"ID {ID} which has KNOWN history.\n\n" - "Only two cells with UNKNOWN history can be assigned as " - "relative of each other." + f'You started by clicking on ID {budID} which has ' + 'UNKNOWN history, but you then clicked/released on ' + f'ID {ID} which has KNOWN history.\n\n' + 'Only two cells with UNKNOWN history can be assigned as ' + 'relative of each other.' ) msg = QMessageBox() - msg.critical(self, "Released on a cell with KNOWN history", txt, msg.Ok) + msg.critical( + self, 'Released on a cell with KNOWN history', txt, msg.Ok + ) self.assignBudMothButton.setChecked(True) return self.clickedOnHistoryKnown = is_history_known self.xClickMoth, self.yClickMoth = xdata, ydata - - if ccs != "G1" and posData.frame_i > 0: + + if ccs != 'G1' and posData.frame_i > 0: self.assignBudMothButton.setChecked(False) self.onMotherNotInG1(ID) self.assignBudMothButton.setChecked(True) @@ -640,25 +603,14 @@ def gui_mouseReleaseEventImg1(self, event): self.clickedOnBud = False self.BudMothTempLine.setData([], []) - + # Draw clear region mouse release elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): self.isMouseDragImg1 = False self.freeRoiItem.closeCurve() self.clearObjsFreehandRegion() - - # Zoom rect mouse release + + # Zoom rect mouse release elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): self.isMouseDragImg1 = False self.zoomRectDone() - - def should_clear_after_out_of_bounds(self, *, image: str) -> bool: - return image == "img1" - - def should_process_canvas_event( - self, - *, - mode: str, - in_bounds: bool, - ) -> bool: - return mode != self.viewer_mode and in_bounds diff --git a/cellacdc/mixins_bak/canvas_events.py b/cellacdc/mixins/canvas_events.py similarity index 67% rename from cellacdc/mixins_bak/canvas_events.py rename to cellacdc/mixins/canvas_events.py index 3a94686e5..a118bd783 100644 --- a/cellacdc/mixins_bak/canvas_events.py +++ b/cellacdc/mixins/canvas_events.py @@ -13,31 +13,9 @@ from cellacdc import apps, exception_handler -class CanvasEventsMixin: - """Qt-facing adapter for canvas mouse event routing.""" - - """Headless canvas event routing rules and brush mask computations.""" - - def calculate_brush_mask( - self, - image_shape: tuple[int, int], - ymin: int, - xmin: int, - ymax: int, - xmax: int, - disk_mask: np.ndarray, - rr_poly: np.ndarray | None = None, - cc_poly: np.ndarray | None = None, - ) -> np.ndarray: - """Computes a 2D boolean mask for brush/eraser updates.""" - mask = np.zeros(image_shape, dtype=bool) - disk_slice = (slice(ymin, ymax), slice(xmin, xmax)) - mask[disk_slice][disk_mask] = True - if rr_poly is not None and cc_poly is not None: - mask[rr_poly, cc_poly] = True - return mask - - @exception_handler +class CanvasEvents: + """Extracted from guiWin.""" + def gui_mousePressEventImg1(self, event: QMouseEvent): self.typingEditID = False modifiers = QGuiApplication.keyboardModifiers() @@ -46,8 +24,8 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): isMod = alt posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - isCcaMode = mode == "Cell cycle analysis" - isCustomAnnotMode = mode == "Custom annotations" + isCcaMode = mode == 'Cell cycle analysis' + isCustomAnnotMode = mode == 'Custom annotations' left_click = event.button() == Qt.MouseButton.LeftButton and not isMod middle_click = self.isMiddleClick(event, modifiers) right_click = event.button() == Qt.MouseButton.RightButton @@ -69,7 +47,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): pointsLayerON = self.togglePointsLayerAction.isChecked() copyContourON = ( self.copyLostObjButton.isChecked() - and self.ax1_lostObjScatterItem.hoverLostID > 0 + and self.ax1_lostObjScatterItem.hoverLostID>0 ) findNextMotherButtonON = self.findNextMotherButton.isChecked() unknownLineageButtonON = self.unknownLineageButton.isChecked() @@ -82,7 +60,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): seg = segments[0] seg.roi.segmentClicked(seg, event) return - + # Check if right-click on handle of polyline roi to remove it handles = self.gui_getHoveredHandlesPolyLineRoi() if len(handles) == 1 and right_click: @@ -94,32 +72,22 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click) if isClickOnDelRoi: return - + dragImgLeft = ( - left_click - and not brushON - and not histON - and not curvToolON - and not eraserON - and not rulerON - and not wandON - and not polyLineRoiON - and not labelRoiON - and not middle_click - and not keepObjON - and not separateON - and not manualBackgroundON - and not drawClearRegionON - and addPointsByClickingButton is None - and not whitelistIDsON + left_click and not brushON and not histON + and not curvToolON and not eraserON and not rulerON + and not wandON and not polyLineRoiON and not labelRoiON + and not middle_click and not keepObjON and not separateON + and not manualBackgroundON and not drawClearRegionON + and addPointsByClickingButton is None and not whitelistIDsON and not zoomRectON ) if isPanImageClick: dragImgLeft = True - is_right_click_custom_ON = any( - [b.isChecked() for b in self.customAnnotDict.keys()] - ) + is_right_click_custom_ON = any([ + b.isChecked() for b in self.customAnnotDict.keys() + ]) canAnnotateDivision = ( not self.assignBudMothButton.isChecked() @@ -132,8 +100,9 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): # In timelapse mode division can be annotated if isCcaMode and right-click # while in snapshot mode with Ctrl+right-click - isAnnotateDivision = (right_click and isCcaMode and canAnnotateDivision) or ( - right_click and ctrl and self.isSnapshot + isAnnotateDivision = ( + (right_click and isCcaMode and canAnnotateDivision) + or (right_click and ctrl and self.isSnapshot) ) isCustomAnnot = ( @@ -142,23 +111,18 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): and self.customAnnotButton is not None ) - is_right_click_action_ON = any( - [b.isChecked() for b in self.checkableQButtonsGroup.buttons()] - ) + is_right_click_action_ON = any([ + b.isChecked() for b in self.checkableQButtonsGroup.buttons() + ]) isOnlyRightClick = ( - right_click - and canAnnotateDivision - and not isAnnotateDivision - and not isMod - and not is_right_click_action_ON - and not is_right_click_custom_ON - and not copyContourON - and not findNextMotherButtonON - and not unknownLineageButtonON + right_click and canAnnotateDivision and not isAnnotateDivision + and not isMod and not is_right_click_action_ON + and not is_right_click_custom_ON and not copyContourON + and not findNextMotherButtonON and not unknownLineageButtonON and not middle_click ) - + if isOnlyRightClick: # Start timer or check if it is a double-right-click if self.countRightClicks == 0: @@ -169,225 +133,132 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self._img1_click_xy = (screenPos.x(), screenPos.y()) QTimer.singleShot(400, self.doubleRightClickTimerCallBack) return - elif self.countRightClicks == 1 and not self.doubleRightClickTimeElapsed: + elif ( + self.countRightClicks == 1 + and not self.doubleRightClickTimeElapsed + ): self.isDoubleRightClick = True self.countRightClicks = 0 self.editIDbutton.setChecked(True) # Left click actions canCurv = ( - curvToolON - and not self.assignBudMothButton.isChecked() - and not brushON - and not dragImgLeft - and not eraserON - and not polyLineRoiON - and not labelRoiON + curvToolON and not self.assignBudMothButton.isChecked() + and not brushON and not dragImgLeft and not eraserON + and not polyLineRoiON and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON - and not drawClearRegionON - and not magicPromptsON - and not zoomRectON + and not manualBackgroundON and not drawClearRegionON + and not magicPromptsON and not zoomRectON ) canBrush = ( - brushON - and not curvToolON - and not rulerON - and not dragImgLeft - and not eraserON - and not wandON - and not labelRoiON - and not manualBackgroundON - and addPointsByClickingButton is None - and not drawClearRegionON - and not magicPromptsON - and not zoomRectON + brushON and not curvToolON and not rulerON + and not dragImgLeft and not eraserON and not wandON + and not labelRoiON and not manualBackgroundON + and addPointsByClickingButton is None and not drawClearRegionON + and not magicPromptsON and not zoomRectON ) canErase = ( - eraserON - and not curvToolON - and not rulerON - and not dragImgLeft - and not brushON - and not wandON - and not polyLineRoiON - and not labelRoiON + eraserON and not curvToolON and not rulerON + and not dragImgLeft and not brushON and not wandON + and not polyLineRoiON and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON - and not drawClearRegionON - and not magicPromptsON - and not zoomRectON + and not manualBackgroundON and not drawClearRegionON + and not magicPromptsON and not zoomRectON ) canRuler = ( - rulerON - and not curvToolON - and not brushON - and not dragImgLeft - and not brushON - and not wandON - and not polyLineRoiON - and not labelRoiON + rulerON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not wandON + and not polyLineRoiON and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON - and not drawClearRegionON - and not magicPromptsON - and not zoomRectON + and not manualBackgroundON and not drawClearRegionON + and not magicPromptsON and not zoomRectON ) canWand = ( - wandON - and not curvToolON - and not brushON - and not dragImgLeft - and not brushON - and not rulerON - and not polyLineRoiON - and not labelRoiON + wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON - and not drawClearRegionON - and not magicPromptsON - and not zoomRectON + and not manualBackgroundON and not drawClearRegionON + and not magicPromptsON and not zoomRectON ) canPolyLine = ( - polyLineRoiON - and not wandON - and not curvToolON - and not brushON - and not dragImgLeft - and not brushON - and not rulerON - and not labelRoiON - and not manualBackgroundON + polyLineRoiON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not labelRoiON and not manualBackgroundON and addPointsByClickingButton is None - and not drawClearRegionON - and not magicPromptsON + and not drawClearRegionON and not magicPromptsON and not zoomRectON ) canLabelRoi = ( - labelRoiON - and not wandON - and not curvToolON - and not brushON - and not dragImgLeft - and not brushON - and not rulerON - and not polyLineRoiON - and not keepObjON + labelRoiON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not keepObjON and addPointsByClickingButton is None - and not manualBackgroundON - and not drawClearRegionON - and not whitelistIDsON - and not magicPromptsON + and not manualBackgroundON and not drawClearRegionON + and not whitelistIDsON and not magicPromptsON and not zoomRectON ) canKeep = ( - keepObjON - and not wandON - and not curvToolON - and not brushON - and not dragImgLeft - and not brushON - and not rulerON - and not polyLineRoiON - and not labelRoiON + keepObjON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON - and not drawClearRegionON - and not whitelistIDsON - and not magicPromptsON + and not manualBackgroundON and not drawClearRegionON + and not whitelistIDsON and not magicPromptsON and not zoomRectON ) canWhitelistIDs = ( - whitelistIDsON - and not wandON - and not curvToolON - and not brushON - and not dragImgLeft - and not brushON - and not rulerON - and not polyLineRoiON - and not labelRoiON + whitelistIDsON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON - and not drawClearRegionON - and not keepObjON - and not magicPromptsON + and not manualBackgroundON and not drawClearRegionON + and not keepObjON and not magicPromptsON and not zoomRectON ) canAddPoint = ( (pointsLayerON or magicPromptsON) - and addPointsByClickingButton is not None - and not wandON - and not curvToolON - and not brushON - and not dragImgLeft - and not brushON - and not rulerON - and not polyLineRoiON - and not labelRoiON - and not keepObjON - and not manualBackgroundON - and not drawClearRegionON + and addPointsByClickingButton is not None and not wandON + and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON and not keepObjON + and not manualBackgroundON and not drawClearRegionON and not zoomRectON ) canAddManualBackgroundObj = ( - manualBackgroundON - and not wandON - and not curvToolON - and not brushON - and not dragImgLeft - and not brushON - and not rulerON - and not polyLineRoiON - and not labelRoiON + manualBackgroundON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON and addPointsByClickingButton is None - and not keepObjON - and not drawClearRegionON - and not magicPromptsON - and not whitelistIDsON + and not keepObjON and not drawClearRegionON + and not magicPromptsON and not whitelistIDsON and not zoomRectON ) canDrawClearRegion = ( - drawClearRegionON - and not wandON - and not curvToolON - and not brushON - and not dragImgLeft - and not brushON - and not rulerON - and not labelRoiON - and not manualBackgroundON + drawClearRegionON and not wandON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not labelRoiON and not manualBackgroundON and addPointsByClickingButton is None - and not polyLineRoiON - and not magicPromptsON - and not whitelistIDsON - and not zoomRectON + and not polyLineRoiON and not magicPromptsON + and not whitelistIDsON and not zoomRectON ) canZoomRect = ( - zoomRectON - and not curvToolON - and not brushON - and not dragImgLeft - and not brushON - and not rulerON - and not polyLineRoiON - and not labelRoiON + zoomRectON and not curvToolON and not brushON + and not dragImgLeft and not brushON and not rulerON + and not polyLineRoiON and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON - and not drawClearRegionON - and not wandON - and not whitelistIDsON - and not magicPromptsON + and not manualBackgroundON and not drawClearRegionON + and not wandON and not whitelistIDsON and not magicPromptsON ) - + # Enable dragging of the image window or the scalebar if dragImgLeft and not isCustomAnnot: x, y = event.pos().x(), event.pos().y() - if hasattr(self, "scaleBar"): + if hasattr(self, 'scaleBar'): if self.scaleBar.isHighlighted(): self.scaleBar.mousePressed(x, y) return - if hasattr(self, "timestamp"): + if hasattr(self, 'timestamp'): if self.timestamp.isHighlighted(): self.timestamp.mousePressed(x, y) return @@ -395,22 +266,21 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): event.ignore() return - isAllowedActionViewer = canAddPoint or canRuler - - if mode == "Viewer" and not isAllowedActionViewer: + isAllowedActionViewer = (canAddPoint or canRuler) + + if mode == 'Viewer' and not isAllowedActionViewer: self.startBlinkingModeCB() event.ignore() return - + # Allow right-click or middle-click actions on both images eventOnImg2 = ( ( - right_click or (middle_click and not canAddPoint) + right_click or (middle_click and not canAddPoint) # or (left_click and separateON) ) - and (mode == "Segmentation and Tracking" or self.isSnapshot) - and not isAnnotateDivision - and not manualBackgroundON + and (mode=='Segmentation and Tracking' or self.isSnapshot) + and not isAnnotateDivision and not manualBackgroundON ) if eventOnImg2: event.isImg1Sender = True @@ -430,7 +300,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xdata, ydata = int(x), int(y) lab_2D = self.get_2Dlab(posData.lab) Y, X = lab_2D.shape - + # Store undo state before modifying stuff self.storeUndoRedoStates(False, storeOnlyZoom=True) @@ -444,9 +314,9 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): # to not use their IDs anymore in the future self.isNewID = True self.setBrushID() - self.updateLookuptable(lenNewLut=posData.brushID + 1) + self.updateLookuptable(lenNewLut=posData.brushID+1) - self.brushColor = self.lut[posData.brushID] / 255 + self.brushColor = self.lut[posData.brushID]/255 self.yPressAx2, self.xPressAx2 = y, x @@ -459,13 +329,13 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): localLab = lab_2D[diskSlice] mask = diskMask.copy() if not self.isPowerBrush() and not ctrl: - mask[localLab != 0] = False + mask[localLab!=0] = False self.applyBrushMask(mask, posData.brushID, toLocalSlice=diskSlice) self.setImageImg2(updateLookuptable=False) - self.drawIDsContComboBox.currentText() + how = self.drawIDsContComboBox.currentText() lab2D = self.get_2Dlab(posData.lab) self.globalBrushMask = np.zeros(lab2D.shape, dtype=bool) brushMask = localLab == posData.brushID @@ -488,10 +358,10 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.yPressAx2, self.xPressAx2 = y, x # Keep a list of erased IDs got erased self.erasedIDs = set() - + if self.xyOnCtrlPressedFirstTime is not None: self.erasedID = self.getHoverID(*self.xyOnCtrlPressedFirstTime) - else: + else: self.erasedID = self.getHoverID(xdata, ydata) ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) @@ -500,27 +370,29 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): mask = np.zeros(lab_2D.shape, bool) mask[ymin:ymax, xmin:xmax][diskMask] = True + # If user double-pressed 'b' then erase over ALL labels color = self.eraserButton.palette().button().color().name() eraseOnlyOneID = ( - color != self.doublePressKeyButtonColor and self.erasedID != 0 + color != self.doublePressKeyButtonColor + and self.erasedID != 0 ) self.eraseOnlyOneID = eraseOnlyOneID if eraseOnlyOneID: - mask[lab_2D != self.erasedID] = False + mask[lab_2D!=self.erasedID] = False self.setTempImg1Eraser(mask, init=True) self.applyEraserMask(mask) - self.erasedIDs.update(lab_2D[mask]) + self.erasedIDs.update(lab_2D[mask]) for erasedID in self.erasedIDs: if erasedID == 0: continue - self.erasedLab[lab_2D == erasedID] = erasedID - + self.erasedLab[lab_2D==erasedID] = erasedID + self.isMouseDragImg1 = True elif canAddPoint: @@ -533,45 +405,45 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): if not magicPromptsON: removed_id = min(removed_ids) addPointsByClickingButton.pointIdSpinbox.setValue(removed_id) - addPointsByClickingButton.pointIdSpinbox.removedId = removed_id + addPointsByClickingButton.pointIdSpinbox.removedId = ( + removed_id + ) else: self.restorePrevPointIdRightClick(addPointsByClickingButton) self.drawPointsLayers(computePointsLayers=False) else: point_id = self.getAddedPointId( - magicPromptsON, - addPointsByClickingButton, - right_click, - left_click, - middle_click, + magicPromptsON, addPointsByClickingButton, + right_click, left_click, middle_click ) if point_id is None: return - + self.addClickedPoint(action, x, y, point_id) self.drawPointsLayers(computePointsLayers=False) - + point_id = self.getClickedPointNewId( - action, - point_id, + action, point_id, addPointsByClickingButton.pointIdSpinbox, - isMagicPrompts=magicPromptsON, + isMagicPrompts=magicPromptsON ) addPointsByClickingButton.pointIdSpinbox.setValue( point_id, setLinkedWidget=False ) - + elif left_click and canDrawClearRegion: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) self.freeRoiItem.addPoint(xdata, ydata) - + self.isMouseDragImg1 = True - + elif left_click and canRuler or canPolyLine: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - closePolyLine = len(self.startPointPolyLineItem.pointsAt(event.pos())) > 0 + closePolyLine = ( + len(self.startPointPolyLineItem.pointsAt(event.pos())) > 0 + ) if not self.tempSegmentON or canPolyLine: # Keep adding anchor points for polyline self.ax1_rulerAnchorsItem.setData([xdata], [ydata]) @@ -593,7 +465,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): [x0, x1], [y0, y1], lengthText=lengthText ) self.ax1_rulerAnchorsItem.setData([x0, x1], [y0, y1]) - + xxPolyLine = self.startPointPolyLineItem.getData()[0] if canPolyLine and len(xxPolyLine) == 0: # Create and add roi item @@ -620,67 +492,67 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): # Call roi moving on closing ROI self.delROImoving(self.polyLineRoi) self.delROImovingFinished(self.polyLineRoi) - + elif left_click and canKeep: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) keepID_win = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter ID that you want to keep", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to keep', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) keepID_win.exec_() if keepID_win.cancel: return else: ID = keepID_win.EntryID - + if ID in self.keptObjectsIDs: self.keptObjectsIDs.remove(ID) self.clearHighlightedText() else: self.keptObjectsIDs.append(ID) self.highlightLabelID(ID) - + self.updateTempLayerKeepIDs() - + elif left_click and canWhitelistIDs: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) keepID_win = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter ID that you want to select", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to select', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) keepID_win.exec_() if keepID_win.cancel: return else: ID = keepID_win.EntryID - + posData = self.data[self.pos_i] if not posData.whitelist: wl_init = False - if not hasattr(self, "tempWhitelistIDs"): - self.tempWhitelistIDs = ( - set() - ) # not updated, only use in this context + if not hasattr(self, 'tempWhitelistIDs'): + self.tempWhitelistIDs = set() # not updated, only use in this context current_whitelist = self.tempWhitelistIDs else: current_whitelist = self.tempWhitelistIDs @@ -694,9 +566,11 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): else: current_whitelist.add(ID) self.highlightLabelID(ID) - - self.whitelistIDsToolbar.whitelistLineEdit.setText(current_whitelist) - + + self.whitelistIDsToolbar.whitelistLineEdit.setText( + current_whitelist + ) + if wl_init: posData.whitelist[posData.frame_i] = current_whitelist else: @@ -734,13 +608,13 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): closeSpline = False clickedAnchors = self.curvAnchors.pointsAt(event.pos()) xxA, yyA = self.curvAnchors.getData() - if len(xxA) > 0: + if len(xxA)>0: if len(xxA) == 1: self.splineHoverON = True x0, y0 = xxA[0], yyA[0] - if len(clickedAnchors) > 0: + if len(clickedAnchors)>0: xA_clicked, yA_clicked = clickedAnchors[0].pos() - if x0 == xA_clicked and y0 == yA_clicked: + if x0==xA_clicked and y0==yA_clicked: x = x0 y = y0 closeSpline = True @@ -750,10 +624,10 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): try: xx, yy = self.curvHoverPlotItem.getData() self.curvPlotItem.setData(xx, yy) - except Exception: + except Exception as e: # traceback.print_exc() pass - + if closeSpline: self.splineHoverON = False self.curvToolSplineToObj() @@ -761,10 +635,10 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): if self.autoIDcheckbox.isChecked(): self.trackManuallyAddedObject(posData.brushID, True) if self.isSnapshot: - self.fixCcaDfAfterEdit("Add new ID with curvature tool") + self.fixCcaDfAfterEdit('Add new ID with curvature tool') self.updateAllImages() else: - self.warnEditingWithCca_df("Add new ID with curvature tool") + self.warnEditingWithCca_df('Add new ID with curvature tool') self.clearCurvItems() self.curvTool_cb(True) @@ -779,9 +653,11 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): posData.brushID = self.get_2Dlab(posData.lab)[ydata, xdata] if posData.brushID == 0: self.setBrushID() - self.updateLookuptable(lenNewLut=posData.brushID + 1) + self.updateLookuptable( + lenNewLut=posData.brushID+1 + ) self.isNewID = True - self.brushColor = self.img2.lut[posData.brushID] / 255 + self.brushColor = self.img2.lut[posData.brushID]/255 # NOTE: flood is on mousedrag or release tol = self.getMagicWandFloodTolerance() @@ -791,36 +667,38 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): seed = (z_slice, ydata, xdata) else: seed = (ydata, xdata) - - flood_mask = skimage.segmentation.flood(self.flood_img, seed, tolerance=tol) - + + flood_mask = skimage.segmentation.flood( + self.flood_img, seed, tolerance=tol + ) + drawUnderMask = np.logical_or( - posData.lab == 0, posData.lab == posData.brushID + posData.lab==0, posData.lab==posData.brushID ) self.flood_mask = np.logical_and(flood_mask, drawUnderMask) if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): self.flood_mask = core.binary_fill_holes(self.flood_mask) - + if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): self.flood_mask = core.convex_hull_mask(self.flood_mask) - + self.setTempBrushMaskFromWand(self.flood_mask, init=True) self.isMouseDragImg1 = True - + elif right_click and self.manualTrackingButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) manualTrackID = self.manualTrackingToolbar.spinboxID.value() clickedID = self.getClickedID( - xdata, ydata, text=f"that you want to assign to {manualTrackID}" + xdata, ydata, text=f'that you want to assign to {manualTrackID}' ) if clickedID is None: return if clickedID == manualTrackID: self.manualTrackingToolbar.showWarning( - f"The clicked object already has ID = {manualTrackID}" + f'The clicked object already has ID = {manualTrackID}' ) return @@ -835,35 +713,35 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): posData.lab[posData.lab == manualTrackID] = clickedID posData.lab[posData.lab == tempID] = manualTrackID self.manualTrackingToolbar.showWarning( - f"The ID {manualTrackID} already exists --> " - f"ID {manualTrackID} has been swapped with {clickedID}" + f'The ID {manualTrackID} already exists --> ' + f'ID {manualTrackID} has been swapped with {clickedID}' ) else: posData.lab[posData.lab == clickedID] = manualTrackID self.manualTrackingToolbar.showInfo( - f"ID {clickedID} changed to {manualTrackID}." + f'ID {clickedID} changed to {manualTrackID}.' ) - + self.update_rp() self.updateAllImages() - + elif right_click and manualBackgroundON: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - + delID = posData.manualBackgroundLab[ydata, xdata] if delID == 0: return - + self.clearManualBackgroundObject(delID) textItem = self.manualBackgroundTextItems.pop(delID) self.ax1.removeItem(textItem) self.setManualBackgroundImage() - + elif left_click and canAddManualBackgroundObj: x, y = event.pos().x(), event.pos().y() - - self.addManualBackgroundObject(x, y) + + self.addManualBackgroundObject(x, y) self.setManualBackgroundImage() self.setManualBackgrounNextID() @@ -872,7 +750,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): if right_click: # Force model initialization on mouse release self.labelRoiModel = None - + x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) @@ -880,7 +758,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.labelRoiItem.setPos((xdata, ydata)) elif self.labelRoiIsFreeHandRadioButton.isChecked(): self.freeRoiItem.addPoint(xdata, ydata) - + self.isMouseDragImg1 = True # Annotate cell cycle division @@ -892,15 +770,16 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) divID_prompt = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter ID that you want to annotate as divided", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as divided', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) divID_prompt.exec_() if divID_prompt.cancel: @@ -934,15 +813,16 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) budID_prompt = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter ID of a bud you want to correct mother assignment", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID of a bud you want to correct mother assignment', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) budID_prompt.exec_() if budID_prompt.cancel: @@ -954,19 +834,19 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): y, x = posData.rp[obj_idx].centroid xdata, ydata = int(x), int(y) - relationship = posData.cca_df.at[ID, "relationship"] - is_history_known = posData.cca_df.at[ID, "is_history_known"] + relationship = posData.cca_df.at[ID, 'relationship'] + is_history_known = posData.cca_df.at[ID, 'is_history_known'] self.clickedOnHistoryKnown = is_history_known # We allow assiging a cell in G1 as bud only on first frame # OR if the history is unknown - if relationship != "bud" and posData.frame_i > 0 and is_history_known: - txt = ( - f"You clicked on ID {ID} which is NOT a bud.\n" - "To assign a bud to a cell start by clicking on a bud " - "and release on a cell in G1" - ) + if relationship != 'bud' and posData.frame_i > 0 and is_history_known: + txt = (f'You clicked on ID {ID} which is NOT a bud.\n' + 'To assign a bud to a cell start by clicking on a bud ' + 'and release on a cell in G1') msg = QMessageBox() - msg.critical(self, "Not a bud", txt, msg.Ok) + msg.critical( + self, 'Not a bud', txt, msg.Ok + ) return self.clickedOnBud = True @@ -981,16 +861,17 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) unknownID_prompt = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter ID that you want to annotate as " - '"history UNKNOWN/KNOWN"', - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as ' + '"history UNKNOWN/KNOWN"', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) unknownID_prompt.exec_() if unknownID_prompt.cancel: @@ -1010,15 +891,16 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) clickedBkgrDialog = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter ID that you want to annotate as divided", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as divided', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) clickedBkgrDialog.exec_() if clickedBkgrDialog.cancel: @@ -1032,49 +914,38 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): button = self.doCustomAnnotation(ID) if button is None: return - - keepActive = self.customAnnotDict[button]["state"]["keepActive"] + + keepActive = self.customAnnotDict[button]['state']['keepActive'] if not keepActive: button.setChecked(False) elif right_click and findNextMotherButtonON: if posData.frame_i == 0: return - + self.find_mother_action(posData, event, ydata, xdata) elif right_click and unknownLineageButtonON: if posData.frame_i == 0: return - + self.annotate_unknown_lineage_action(posData, event, ydata, xdata) - + elif (left_click or right_click) and canZoomRect: if left_click: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - + self.zoomRectItem.setPos((xdata, ydata)) - + self.isMouseDragImg1 = True else: try: xRange, yRange = self.zoomRectItem.getLastRange() - self.ax1.setRange(xRange=xRange, yRange=yRange, padding=0) - except Exception: + self.ax1.setRange( + xRange=xRange, + yRange=yRange, + padding=0 + ) + except Exception as err: QTimer.singleShot(100, self.autoRange) - - def map_mouse_coordinates_to_label_id( - self, - mouse_pos: tuple[float, float], - label_matrix: np.ndarray, - ) -> int: - """Resolves float pixel coordinate lookup to integer label ID.""" - x, y = mouse_pos - xdata, ydata = int(x), int(y) - height, width = label_matrix.shape - if 0 <= xdata < width and 0 <= ydata < height: - return int(label_matrix[ydata, xdata]) - return 0 - - LEGACY_METHODS = ("gui_mousePressEventImg1",) diff --git a/cellacdc/mixins_bak/canvas_hover.py b/cellacdc/mixins/canvas_hover.py similarity index 60% rename from cellacdc/mixins_bak/canvas_hover.py rename to cellacdc/mixins/canvas_hover.py index ce72432d9..76f4a1c8d 100644 --- a/cellacdc/mixins_bak/canvas_hover.py +++ b/cellacdc/mixins/canvas_hover.py @@ -10,108 +10,8 @@ from cellacdc import html_utils, widgets -class CanvasHoverMixin: - """Qt-facing adapter around canvas hover workflows.""" - - """Headless decisions for hover and cursor state.""" - - def _cursor_flags(self, modifiers, event): - return self.cursor_flags( - is_exit=event.isExit(), - no_modifier=modifiers == Qt.NoModifier, - shift=modifiers == Qt.ShiftModifier, - ctrl=modifiers == Qt.ControlModifier, - alt=modifiers == Qt.AltModifier, - brush_checked=self.brushButton.isChecked(), - eraser_checked=self.eraserButton.isChecked(), - add_deleted_polyline_checked=(self.addDelPolyLineRoiButton.isChecked()), - label_roi_checked=self.labelRoiButton.isChecked(), - label_roi_circular_checked=(self.labelRoiIsCircularRadioButton.isChecked()), - wand_checked=self.wandToolButton.isChecked(), - move_label_checked=self.moveLabelToolButton.isChecked(), - expand_label_checked=self.expandLabelToolButton.isChecked(), - curvature_checked=self.curvToolButton.isChecked(), - keep_ids_checked=self.keepIDsButton.isChecked(), - custom_annotation_available=self.customAnnotButton is not None, - manual_tracking_checked=self.manualTrackingButton.isChecked(), - manual_background_checked=self.manualBackgroundButton.isChecked(), - zoom_rect_checked=self.zoomRectButton.isChecked(), - edit_id_checked=self.editIDbutton.isChecked(), - magic_prompts_checked=self.magicPromptsToolButton.isChecked(), - points_layer_checked=self.togglePointsLayerAction.isChecked(), - add_points_by_clicking_active=( - self.buttonAddPointsByClickingActive() is not None - ), - ) - - def cursor_flags( - self, - *, - is_exit: bool, - no_modifier: bool, - shift: bool, - ctrl: bool, - alt: bool, - brush_checked: bool, - eraser_checked: bool, - add_deleted_polyline_checked: bool, - label_roi_checked: bool, - label_roi_circular_checked: bool, - wand_checked: bool, - move_label_checked: bool, - expand_label_checked: bool, - curvature_checked: bool, - keep_ids_checked: bool, - custom_annotation_available: bool, - manual_tracking_checked: bool, - manual_background_checked: bool, - zoom_rect_checked: bool, - edit_id_checked: bool, - magic_prompts_checked: bool, - points_layer_checked: bool, - add_points_by_clicking_active: bool, - ) -> dict[str, bool]: - return { - "setBrushCursor": ( - brush_checked and not is_exit and (no_modifier or shift or ctrl) - ), - "setEraserCursor": eraser_checked and not is_exit and no_modifier, - "setAddDelPolyLineCursor": ( - add_deleted_polyline_checked and not is_exit and no_modifier - ), - "setLabelRoiCircCursor": ( - label_roi_checked - and not is_exit - and (no_modifier or shift or ctrl) - and label_roi_circular_checked - ), - "setWandCursor": wand_checked and not is_exit and no_modifier, - "setLabelRoiCursor": label_roi_checked and not is_exit and no_modifier, - "setMoveLabelCursor": move_label_checked and not is_exit and no_modifier, - "setExpandLabelCursor": ( - expand_label_checked and not is_exit and no_modifier - ), - "setCurvCursor": curvature_checked and not is_exit and no_modifier, - "setKeepObjCursor": keep_ids_checked and not is_exit and no_modifier, - "setCustomAnnotCursor": ( - custom_annotation_available and not is_exit and no_modifier - ), - "setManualTrackingCursor": ( - manual_tracking_checked and not is_exit and no_modifier - ), - "setManualBackgroundCursor": ( - manual_background_checked and not is_exit and no_modifier - ), - "setAddPointCursor": ( - (points_layer_checked or magic_prompts_checked) - and add_points_by_clicking_active - and not is_exit - and no_modifier - ), - "setZoomRectCursor": zoom_rect_checked and not is_exit and no_modifier, - "setEditIDCursor": edit_id_checked and not is_exit, - "setPanImageCursor": alt and not is_exit, - } +class CanvasHover: + """Extracted from guiWin.""" def drawTempMergeObjsLine(self, event, posData, modifiers): if self.clickObjYc is None: @@ -126,7 +26,7 @@ def drawTempMergeObjsLine(self, event, posData, modifiers): obj_idx = posData.IDs_idxs[ID] obj = posData.rp[obj_idx] y2, x2 = self.getObjCentroid(obj.centroid) - + if modifier and ID > 0: self.mergeObjsTempLine.addPoint(x2, y2) elif not modifier: @@ -154,33 +54,27 @@ def drawTempRulerLine(self, event): xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() x0, y0 = xxRA[0], yyRA[0] if ctrl: - x1, y1 = transformation.snap_xy_to_closest_angle(x0, y0, x1, y1) + x1, y1 = transformation.snap_xy_to_closest_angle( + x0, y0, x1, y1 + ) self.ax1_rulerPlotItem.setData([x0, x1], [y0, y1]) def gui_add_ax_cursors(self): try: self.ax1.removeItem(self.ax1_cursor) self.ax2.removeItem(self.ax2_cursor) - except Exception: + except Exception as e: pass self.ax2_cursor = pg.ScatterPlotItem( - symbol="+", - pxMode=True, - pen=pg.mkPen("k", width=1), - brush=pg.mkBrush("w"), - size=16, - tip=None, + symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), + brush=pg.mkBrush('w'), size=16, tip=None ) self.ax2.addItem(self.ax2_cursor) self.ax1_cursor = pg.ScatterPlotItem( - symbol="+", - pxMode=True, - pen=pg.mkPen("k", width=1), - brush=pg.mkBrush("w"), - size=16, - tip=None, + symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), + brush=pg.mkBrush('w'), size=16, tip=None ) self.ax1.addItem(self.ax1_cursor) @@ -189,7 +83,7 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): posData = self.data[self.pos_i] except AttributeError: return - + # Update x, y, value label bottom right if not event.isExit(): self.xHoverImg, self.yHoverImg = event.pos() @@ -198,19 +92,19 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): if event.isExit(): self.resetCursor() - + if not event.isExit() and self.slideshowWin is not None: self.slideshowWin.setMirroredCursorPos(*event.pos()) - + # Alt key was released --> restore cursor modifiers = QGuiApplication.keyboardModifiers() cursorsInfo = self.gui_setCursor(modifiers, event) self.highlightHoverLostObj(modifiers, event) - + drawRulerLine = ( - (self.rulerButton.isChecked() or self.addDelPolyLineRoiButton.isChecked()) - and self.tempSegmentON - and not event.isExit() + (self.rulerButton.isChecked() + or self.addDelPolyLineRoiButton.isChecked()) + and self.tempSegmentON and not event.isExit() ) if drawRulerLine: self.drawTempRulerLine(event) @@ -233,74 +127,68 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): else: self.clickedOnBud = False self.BudMothTempLine.setData([], []) - self.wcLabel.setText("") - - if cursorsInfo["setKeepObjCursor"]: + self.wcLabel.setText('') + + if cursorsInfo['setKeepObjCursor']: x, y = event.pos() self.highlightHoverIDsKeptObj(x, y) - - if cursorsInfo["setManualTrackingCursor"]: + + if cursorsInfo['setManualTrackingCursor']: x, y = event.pos() # self.highlightHoverID(x, y) self.drawManualTrackingGhost(x, y) - - if cursorsInfo["setManualBackgroundCursor"]: + + if cursorsInfo['setManualBackgroundCursor']: x, y = event.pos() # self.highlightHoverID(x, y) self.drawManualBackgroundObj(x, y) - + if ( - not cursorsInfo["setManualTrackingCursor"] - and not cursorsInfo["setManualBackgroundCursor"] - ): + not cursorsInfo['setManualTrackingCursor'] + and not cursorsInfo['setManualBackgroundCursor'] + ): self.clearGhost() - setMoveLabelCursor = cursorsInfo["setMoveLabelCursor"] - setExpandLabelCursor = cursorsInfo["setExpandLabelCursor"] + setMoveLabelCursor = cursorsInfo['setMoveLabelCursor'] + setExpandLabelCursor = cursorsInfo['setExpandLabelCursor'] if setMoveLabelCursor or setExpandLabelCursor: x, y = event.pos() self.updateHoverLabelCursor(x, y) # Draw eraser circle - if cursorsInfo["setEraserCursor"]: + if cursorsInfo['setEraserCursor']: x, y = event.pos() self.updateEraserCursor(x, y, isHoverImg1=isHoverImg1) self.hideItemsHoverBrush(xy=(x, y)) elif self.eraserButton.isChecked() and not event.isExit(): if self.xyOnCtrlPressedFirstTime is not None: self.updateEraserCursor( - x, - y, - xyLocked=self.xyOnCtrlPressedFirstTime, - isHoverImg1=isHoverImg1, + x, y, xyLocked=self.xyOnCtrlPressedFirstTime, + isHoverImg1=isHoverImg1 ) self.hideItemsHoverBrush(xy=(x, y)) else: eraserCursors = ( - self.ax1_EraserCircle, - self.ax2_EraserCircle, - self.ax1_EraserX, - self.ax2_EraserX, + self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX ) self.setHoverToolSymbolData([], [], eraserCursors) # Draw Brush circle - if cursorsInfo["setBrushCursor"]: + if cursorsInfo['setBrushCursor']: x, y = event.pos() self.updateBrushCursor(x, y, isHoverImg1=isHoverImg1) self.hideItemsHoverBrush(xy=(x, y)) - elif cursorsInfo["setAddPointCursor"]: + elif cursorsInfo['setAddPointCursor']: x, y = event.pos() self.setHoverCircleAddPoint(x, y) else: self.setHoverToolSymbolData( - [], - [], - (self.ax2_BrushCircle, self.ax1_BrushCircle), + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), ) - + # Draw label ROi circular cursor - setLabelRoiCircCursor = cursorsInfo["setLabelRoiCircCursor"] + setLabelRoiCircCursor = cursorsInfo['setLabelRoiCircCursor'] if setLabelRoiCircCursor: x, y = event.pos() else: @@ -308,47 +196,45 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) drawMothBudLine = ( - self.assignBudMothButton.isChecked() - and self.clickedOnBud + self.assignBudMothButton.isChecked() and self.clickedOnBud and not event.isExit() ) if drawMothBudLine: self.drawTempMothBudLine(event, posData) - drawMergeObjsLine = self.mergeIDsButton.isChecked() and not event.isExit() + drawMergeObjsLine = ( + self.mergeIDsButton.isChecked() and not event.isExit() + ) if drawMergeObjsLine: self.drawTempMergeObjsLine(event, posData, modifiers) # Temporarily draw spline curve # see https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy drawSpline = ( - self.curvToolButton.isChecked() - and self.splineHoverON + self.curvToolButton.isChecked() and self.splineHoverON and not event.isExit() ) if drawSpline: self.hoverEventDrawSpline(event) - + setMirroredCursor = ( - self.app.overrideCursor() is None - and not event.isExit() - and isHoverImg1 - and self.showMirroredCursorAction.isChecked() + self.app.overrideCursor() is None and not event.isExit() + and isHoverImg1 and self.showMirroredCursorAction.isChecked() ) if setMirroredCursor: x, y = event.pos() self.ax2_cursor.setData([x], [y]) else: self.ax2_cursor.setData([], []) - + return cursorsInfo def gui_hoverEventImg2(self, event): try: - self.data[self.pos_i] + posData = self.data[self.pos_i] except AttributeError: return - + if not event.isExit(): self.xHoverImg, self.yHoverImg = event.pos() else: @@ -368,16 +254,15 @@ def gui_hoverEventImg2(self, event): self.app.restoreOverrideCursor() setBrushCursor = ( - self.brushButton.isChecked() - and not event.isExit() + self.brushButton.isChecked() and not event.isExit() and (noModifier or shift or ctrl) ) setEraserCursor = ( - self.eraserButton.isChecked() and not event.isExit() and noModifier + self.eraserButton.isChecked() and not event.isExit() + and noModifier ) setLabelRoiCircCursor = ( - self.labelRoiButton.isChecked() - and not event.isExit() + self.labelRoiButton.isChecked() and not event.isExit() and (noModifier or shift or ctrl) and self.labelRoiIsCircularRadioButton.isChecked() ) @@ -385,11 +270,13 @@ def gui_hoverEventImg2(self, event): self.app.setOverrideCursor(Qt.CrossCursor) setMoveLabelCursor = ( - self.moveLabelToolButton.isChecked() and not event.isExit() and noModifier + self.moveLabelToolButton.isChecked() and not event.isExit() + and noModifier ) setExpandLabelCursor = ( - self.expandLabelToolButton.isChecked() and not event.isExit() and noModifier + self.expandLabelToolButton.isChecked() and not event.isExit() + and noModifier ) # Cursor is moving on image while Alt key is pressed --> pan cursor @@ -397,9 +284,10 @@ def gui_hoverEventImg2(self, event): setPanImageCursor = alt and not event.isExit() if setPanImageCursor and self.app.overrideCursor() is None: self.app.setOverrideCursor(Qt.SizeAllCursor) - + setKeepObjCursor = ( - self.keepIDsButton.isChecked() and not event.isExit() and noModifier + self.keepIDsButton.isChecked() and not event.isExit() + and noModifier ) if setKeepObjCursor and self.app.overrideCursor() is None: self.app.setOverrideCursor(Qt.PointingHandCursor) @@ -407,20 +295,20 @@ def gui_hoverEventImg2(self, event): # Update x, y, value label bottom right if not event.isExit(): x, y = event.pos() - _xdata, _ydata = int(x), int(y) + xdata, ydata = int(x), int(y) _img = self.currentLab2D - Y, X = _img.shape + Y, X = _img.shape # hoverText = self.hoverValuesFormatted(xdata, ydata) # self.wcLabel.setText(hoverText) else: if self.eraserButton.isChecked() or self.brushButton.isChecked(): self.gui_mouseReleaseEventImg2(event) - self.wcLabel.setText("") + self.wcLabel.setText(f'') if setMoveLabelCursor or setExpandLabelCursor: x, y = event.pos() self.updateHoverLabelCursor(x, y) - + if setKeepObjCursor: x, y = event.pos() self.highlightHoverIDsKeptObj(x, y) @@ -431,14 +319,8 @@ def gui_hoverEventImg2(self, event): self.updateEraserCursor(x, y, isHoverImg1=False) else: self.setHoverToolSymbolData( - [], - [], - ( - self.ax1_EraserCircle, - self.ax2_EraserCircle, - self.ax1_EraserX, - self.ax2_EraserX, - ), + [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX) ) # Draw Brush circle @@ -447,11 +329,9 @@ def gui_hoverEventImg2(self, event): self.updateBrushCursor(x, y, isHoverImg1=False) else: self.setHoverToolSymbolData( - [], - [], - (self.ax2_BrushCircle, self.ax1_BrushCircle), + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), ) - + # Draw label ROi circular cursor if setLabelRoiCircCursor: x, y = event.pos() @@ -461,7 +341,7 @@ def gui_hoverEventImg2(self, event): def gui_hoverEventRightImage(self, event): try: - self.data[self.pos_i] + posData = self.data[self.pos_i] except AttributeError: return @@ -470,8 +350,7 @@ def gui_hoverEventRightImage(self, event): self.gui_hoverEventImg1(event, isHoverImg1=False) setMirroredCursor = ( - self.app.overrideCursor() is None - and not event.isExit() + self.app.overrideCursor() is None and not event.isExit() and self.showMirroredCursorAction.isChecked() ) if setMirroredCursor: @@ -483,53 +362,60 @@ def gui_setCursor(self, modifiers, event): shift = modifiers == Qt.ShiftModifier ctrl = modifiers == Qt.ControlModifier alt = modifiers == Qt.AltModifier - + # Alt key was released --> restore cursor if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: self.app.restoreOverrideCursor() setBrushCursor = ( - self.brushButton.isChecked() - and not event.isExit() + self.brushButton.isChecked() and not event.isExit() and (noModifier or shift or ctrl) ) setEraserCursor = ( - self.eraserButton.isChecked() and not event.isExit() and noModifier + self.eraserButton.isChecked() and not event.isExit() + and noModifier ) setAddDelPolyLineCursor = ( - self.addDelPolyLineRoiButton.isChecked() - and not event.isExit() + self.addDelPolyLineRoiButton.isChecked() and not event.isExit() and noModifier ) setLabelRoiCircCursor = ( - self.labelRoiButton.isChecked() - and not event.isExit() + self.labelRoiButton.isChecked() and not event.isExit() and (noModifier or shift or ctrl) and self.labelRoiIsCircularRadioButton.isChecked() ) setWandCursor = ( - self.wandToolButton.isChecked() and not event.isExit() and noModifier + self.wandToolButton.isChecked() and not event.isExit() + and noModifier ) setLabelRoiCursor = ( - self.labelRoiButton.isChecked() and not event.isExit() and noModifier + self.labelRoiButton.isChecked() and not event.isExit() + and noModifier ) setMoveLabelCursor = ( - self.moveLabelToolButton.isChecked() and not event.isExit() and noModifier + self.moveLabelToolButton.isChecked() and not event.isExit() + and noModifier ) setExpandLabelCursor = ( - self.expandLabelToolButton.isChecked() and not event.isExit() and noModifier + self.expandLabelToolButton.isChecked() and not event.isExit() + and noModifier ) setCurvCursor = ( - self.curvToolButton.isChecked() and not event.isExit() and noModifier + self.curvToolButton.isChecked() and not event.isExit() + and noModifier ) setKeepObjCursor = ( - self.keepIDsButton.isChecked() and not event.isExit() and noModifier + self.keepIDsButton.isChecked() and not event.isExit() + and noModifier ) setCustomAnnotCursor = ( - self.customAnnotButton is not None and not event.isExit() and noModifier + self.customAnnotButton is not None and not event.isExit() + and noModifier ) setManualTrackingCursor = ( - self.manualTrackingButton.isChecked() and not event.isExit() and noModifier + self.manualTrackingButton.isChecked() + and not event.isExit() + and noModifier ) setManualBackgroundCursor = ( self.manualBackgroundButton.isChecked() @@ -537,9 +423,12 @@ def gui_setCursor(self, modifiers, event): and noModifier ) setZoomRectCursor = ( - self.zoomRectButton.isChecked() and not event.isExit() and noModifier + self.zoomRectButton.isChecked() and not event.isExit() + and noModifier + ) + setEditIDCursor = ( + self.editIDbutton.isChecked() and not event.isExit() ) - setEditIDCursor = self.editIDbutton.isChecked() and not event.isExit() magicPromptsON = self.magicPromptsToolButton.isChecked() pointsLayerON = self.togglePointsLayerAction.isChecked() addPointsByClickingButton = self.buttonAddPointsByClickingActive() @@ -567,9 +456,9 @@ def gui_setCursor(self, modifiers, event): self.app.setOverrideCursor(self.polyLineRoiCursor) elif setCustomAnnotCursor: x, y = event.pos() - self.highlightHoverID(x, y) + self.highlightHoverID(x, y) elif setKeepObjCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) + self.app.setOverrideCursor(Qt.PointingHandCursor) elif setManualTrackingCursor and overrideCursor is None: self.app.setOverrideCursor(Qt.PointingHandCursor) elif setManualBackgroundCursor and overrideCursor is None: @@ -583,31 +472,26 @@ def gui_setCursor(self, modifiers, event): self.app.setOverrideCursor(Qt.CrossCursor) else: self.app.restoreOverrideCursor() - + return { - "setBrushCursor": setBrushCursor, - "setEraserCursor": setEraserCursor, - "setAddDelPolyLineCursor": setAddDelPolyLineCursor, - "setLabelRoiCircCursor": setLabelRoiCircCursor, - "setWandCursor": setWandCursor, - "setLabelRoiCursor": setLabelRoiCursor, - "setMoveLabelCursor": setMoveLabelCursor, - "setExpandLabelCursor": setExpandLabelCursor, - "setCurvCursor": setCurvCursor, - "setKeepObjCursor": setKeepObjCursor, - "setCustomAnnotCursor": setCustomAnnotCursor, - "setManualTrackingCursor": setManualTrackingCursor, - "setManualBackgroundCursor": setManualBackgroundCursor, - "setAddPointCursor": setAddPointCursor, - "setZoomRectCursor": setZoomRectCursor, - "setEditIDCursor": setEditIDCursor, + 'setBrushCursor': setBrushCursor, + 'setEraserCursor': setEraserCursor, + 'setAddDelPolyLineCursor': setAddDelPolyLineCursor, + 'setLabelRoiCircCursor': setLabelRoiCircCursor, + 'setWandCursor': setWandCursor, + 'setLabelRoiCursor': setLabelRoiCursor, + 'setMoveLabelCursor': setMoveLabelCursor, + 'setExpandLabelCursor': setExpandLabelCursor, + 'setCurvCursor': setCurvCursor, + 'setKeepObjCursor': setKeepObjCursor, + 'setCustomAnnotCursor': setCustomAnnotCursor, + 'setManualTrackingCursor': setManualTrackingCursor, + 'setManualBackgroundCursor': setManualBackgroundCursor, + 'setAddPointCursor': setAddPointCursor, + 'setZoomRectCursor': setZoomRectCursor, + 'setEditIDCursor': setEditIDCursor } - def hover_position(self, is_exit: bool, position) -> tuple[Any, Any]: - if is_exit: - return None, None - return position - def onCtrlPressedFirstTime(self): x, y = self.xHoverImg, self.yHoverImg if x is None: @@ -620,55 +504,17 @@ def onCtrlPressedFirstTime(self): if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): self.xyOnCtrlPressedFirstTime = None return - + ID = self.currentLab2D[ydata, xdata] if ID == 0: self.xyOnCtrlPressedFirstTime = None - return - + return + self.xyOnCtrlPressedFirstTime = (xdata, ydata) def onCtrlReleased(self): self.xyOnCtrlPressedFirstTime = None - def point_in_bounds( - self, - image_shape: tuple[int, int], - xdata: int, - ydata: int, - ) -> bool: - y_size, x_size = image_shape - return 0 <= xdata < x_size and 0 <= ydata < y_size - - def should_draw_ruler_line( - self, - *, - ruler_checked: bool, - add_deleted_polyline_checked: bool, - temp_segment_on: bool, - is_exit: bool, - ) -> bool: - return ( - (ruler_checked or add_deleted_polyline_checked) - and temp_segment_on - and not is_exit - ) - - def should_set_mirrored_cursor( - self, - *, - override_cursor_is_none: bool, - is_exit: bool, - mirrored_cursor_enabled: bool, - is_hover_img1: bool = True, - ) -> bool: - return ( - override_cursor_is_none - and not is_exit - and is_hover_img1 - and mirrored_cursor_enabled - ) - def updateHoverLabelCursor(self, x, y): if x is None: self.hoverLabelID = 0 @@ -690,31 +536,57 @@ def updateHoverLabelCursor(self, x, y): if self.app.overrideCursor() != Qt.SizeAllCursor: self.app.setOverrideCursor(Qt.SizeAllCursor) - + if not self.isMovingLabel: self.highlightSearchedID(ID) - def warnAddingPointWithExistingId(self, point_id, table_endname=""): + def warnAddingPointWithExistingId(self, point_id, table_endname=''): posData = self.data[self.pos_i] - if point_id not in posData.IDs_idxs: + if not point_id in posData.IDs_idxs: return True - + msg = widgets.myMessageBox(wrapText=False) - txt = f""" + txt = (f""" Cell ID {point_id} already exists!

Are you sure you want to add this point? - """ + """) if table_endname: - txt = f""" + txt = (f""" The loaded table {table_endname} has point id {point_id}.

However, {txt} - """ + """) txt = html_utils.paragraph(txt) _, _, yesButton = msg.warning( - self, - f"Cell ID {point_id} already exist", - txt, - buttonsTexts=("Cancel", "No, do not add", f"Yes, add point id {point_id}"), + self, f'Cell ID {point_id} already exist', txt, + buttonsTexts=( + 'Cancel', 'No, do not add', f'Yes, add point id {point_id}' + ) ) return msg.clickedButton == yesButton + + def gui_getHoveredSegmentsPolyLineRoi(self): + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + segments = [] + for roi in delROIs_info['rois']: + if not isinstance(roi, pg.PolyLineROI): + continue + for seg in roi.segments: + if seg.currentPen == seg.hoverPen: + seg.roi = roi + segments.append(seg) + return segments + + def gui_getHoveredHandlesPolyLineRoi(self): + posData = self.data[self.pos_i] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + handles = [] + for roi in delROIs_info['rois']: + if not isinstance(roi, pg.PolyLineROI): + continue + for handle in roi.getHandles(): + if handle.currentPen == handle.hoverPen: + handle.roi = roi + handles.append(handle) + return handles diff --git a/cellacdc/mixins/canvas_right_image.py b/cellacdc/mixins/canvas_right_image.py new file mode 100644 index 000000000..4fca3dcd7 --- /dev/null +++ b/cellacdc/mixins/canvas_right_image.py @@ -0,0 +1,48 @@ +"""View adapter for duplicated right-image interactions.""" + +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtGui import QGuiApplication + +from cellacdc import exception_handler + + +class CanvasRightImage: + """Extracted from guiWin.""" + + def getMouseDataCoordsRightImage(self): + text = self.wcLabel.text() + if not text: + return + + ax_idx = int(re.findall(r'\(ax(\d)\)', text)[0]) + if ax_idx == 0: + return + + coords = re.findall(r'x=(\d+), y=(\d+) \|', text)[0] + + return tuple([int(val) for val in coords]) + + def gui_mousePressRightImage(self, event): + modifiers = QGuiApplication.keyboardModifiers() + ctrl = modifiers == Qt.ControlModifier + alt = modifiers == Qt.AltModifier + isMod = alt + right_click = event.button() == Qt.MouseButton.RightButton and not isMod + is_right_click_action_ON = any([ + b.isChecked() for b in self.checkableQButtonsGroup.buttons() + ]) + self.typingEditID = False + showLabelsGradMenu = right_click and not is_right_click_action_ON + if showLabelsGradMenu: + self.gui_rightImageShowContextMenu(event) + event.ignore() + else: + self.gui_mousePressEventImg1(event) + + def gui_mouseDragRightImage(self, event): + self.gui_mouseDragEventImg1(event) + + def gui_mouseReleaseRightImage(self, event): + self.gui_mouseReleaseEventImg1(event) diff --git a/cellacdc/mixins_bak/canvas_selection.py b/cellacdc/mixins/canvas_selection.py similarity index 68% rename from cellacdc/mixins_bak/canvas_selection.py rename to cellacdc/mixins/canvas_selection.py index db229bbaf..6cf9dae82 100644 --- a/cellacdc/mixins_bak/canvas_selection.py +++ b/cellacdc/mixins/canvas_selection.py @@ -15,23 +15,15 @@ from cellacdc import apps, exception_handler -class CanvasSelectionMixin: - """Qt-facing adapter for canvas selection workflows.""" +class CanvasSelection: + """Extracted from guiWin.""" - """Headless decisions for canvas selection workflows.""" - - viewer_mode = "Viewer" - segmentation_mode = "Segmentation and Tracking" - - def can_delete(self, *, mode: str, is_snapshot: bool) -> bool: - return mode == self.segmentation_mode or is_snapshot - - @exception_handler def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): modifiers = QGuiApplication.keyboardModifiers() alt = modifiers == Qt.AltModifier shift = modifiers == Qt.ShiftModifier shift_regardless = bool(modifiers & Qt.ShiftModifier) + isMod = alt posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) left_click = event.button() == Qt.MouseButton.LeftButton and not alt @@ -44,7 +36,10 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.typingEditID = False # Drag image if neither brush or eraser are On pressed - dragImg = left_click and not eraserON and not brushON and not middle_click + dragImg = ( + left_click and not eraserON and not + brushON and not middle_click + ) if isPanImageClick: dragImg = True @@ -54,7 +49,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): event.ignore() return - if mode == "Viewer" and middle_click: + if mode == 'Viewer' and middle_click: self.startBlinkingModeCB() event.ignore() return @@ -74,22 +69,24 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): # show gradient widget menu if none of the right-click actions are ON # and event is not coming from image 1 - is_right_click_action_ON = any( - [b.isChecked() for b in self.checkableQButtonsGroup.buttons()] - ) - is_right_click_custom_ON = any( - [b.isChecked() for b in self.customAnnotDict.keys()] - ) + is_right_click_action_ON = any([ + b.isChecked() for b in self.checkableQButtonsGroup.buttons() + ]) + is_right_click_custom_ON = any([ + b.isChecked() for b in self.customAnnotDict.keys() + ]) is_event_from_img1 = False - if hasattr(event, "isImg1Sender"): + if hasattr(event, 'isImg1Sender'): is_event_from_img1 = event.isImg1Sender - + is_only_right_click = ( right_click and not is_right_click_action_ON and not middle_click ) - - showLabelsGradMenu = is_only_right_click and not is_event_from_img1 - + + showLabelsGradMenu = ( + is_only_right_click and not is_event_from_img1 + ) + if showLabelsGradMenu: self.labelsGrad.showMenu(event) event.ignore() @@ -97,40 +94,41 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): editInViewerMode = ( (is_right_click_action_ON or is_right_click_custom_ON) - and (right_click or middle_click) - and mode == "Viewer" + and (right_click or middle_click) and mode=='Viewer' ) if editInViewerMode: self.startBlinkingModeCB() event.ignore() return - + # Left-click is used for brush, eraser, separate bud, curvature tool # and magic labeller # Brush and eraser are mutually exclusive but we want to keep the eraser # or brush ON and disable them temporarily to allow left-click with # separate ON - canDelete = mode == "Segmentation and Tracking" or self.isSnapshot + canDelete = mode == 'Segmentation and Tracking' or self.isSnapshot # Delete ID (set to 0) if middle_click and canDelete: - time.perf_counter() + t0 = time.perf_counter() x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) delID = self.get_2Dlab(posData.lab)[ydata, xdata] if delID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) delID_prompt = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.
" - "Enter here ID(s) that you want to delete

" - "You can enter multiple IDs separated by comma", - parent=self, + title='Clicked on background', + msg='You clicked on the background.
' + 'Enter here ID(s) that you want to delete

' + 'You can enter multiple IDs separated by comma', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), allowList=True, - isInteger=True, + isInteger=True ) delID_prompt.exec_() if delID_prompt.cancel: @@ -140,28 +138,24 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): delIDs = [delID] # Ask to propagate change to all future visited frames - key = "Delete ID" + key = 'Delete ID' askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( - self.propagateChange( - delIDs, - key, - doNotShow, - posData.UndoFutFrames_DelID, - posData.applyFutFrames_DelID, - ) + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + delIDs, key, doNotShow, + posData.UndoFutFrames_DelID, posData.applyFutFrames_DelID ) - + if UndoFutFrames is None: return # Store undo state before modifying stuff - self.storeUndoRedoStates(UndoFutFrames) + self.storeUndoRedoStates(UndoFutFrames) posData.doNotShowAgain_DelID = doNotShowAgain posData.UndoFutFrames_DelID = UndoFutFrames posData.applyFutFrames_DelID = applyFutFrames - includeUnvisited = posData.includeUnvisitedInfo["Delete ID"] + includeUnvisited = posData.includeUnvisitedInfo['Delete ID'] delID_mask = self.deleteIDmiddleClick( delIDs, applyFutFrames, includeUnvisited, shift=shift_regardless @@ -170,41 +164,41 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): delID_mask = delID_mask[self.z_lab()] if self.isSnapshot: - self.fixCcaDfAfterEdit("Delete ID") + self.fixCcaDfAfterEdit('Delete ID') else: - self.warnEditingWithCca_df("Delete ID", update_images=False) - + self.warnEditingWithCca_df('Delete ID', update_images=False) + self.setImageImg2() delROIsIDs = self.setAllTextAnnotations() self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False) how = self.drawIDsContComboBox.currentText() - if how.find("overlay segm. masks") != -1: + if how.find('overlay segm. masks') != -1: self.labelsLayerImg1.image[delID_mask] = 0 self.labelsLayerImg1.setImage(self.labelsLayerImg1.image) - + how_ax2 = self.getAnnotateHowRightImage() - if how_ax2.find("overlay segm. masks") != -1: + if how_ax2.find('overlay segm. masks') != -1: self.labelsLayerRightImg.image[delID_mask] = 0 self.labelsLayerRightImg.setImage(self.labelsLayerRightImg.image) - + self.highlightLostNew() - + # Separate bud or objects with same ID elif right_click and separateON: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x) sepID_prompt = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter here ID that you want to split", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here ID that you want to split', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) sepID_prompt.exec_() if sepID_prompt.cancel: @@ -221,11 +215,8 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if self.isSegm3D and not shift: z = self.zSliceScrollBar.sliderPosition() posData.lab, splittedIDs = measure.separate_with_label( - posData.lab, - posData.rp, - [ID], - max_ID, - click_coords_list=[(z, ydata, xdata)], + posData.lab, posData.rp, [ID], max_ID, + click_coords_list=[(z, ydata, xdata)] ) success = True # self.set_2Dlab(lab2D) @@ -237,21 +228,19 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.set_2Dlab(lab2D) else: success = False - + # If automatic bud separation was not successfull call manual one if not success: posData.disableAutoActivateViewerWindow = True img = self.getDisplayedImg1() - col = "manual_separate_draw_mode" - drawMode = self.df_settings.at[col, "value"] + col = 'manual_separate_draw_mode' + drawMode = self.df_settings.at[col, 'value'] manualSep = apps.manualSeparateGui( - self.get_2Dlab(posData.lab), - ID, - img, + self.get_2Dlab(posData.lab), ID, img, fontSize=self.fontSize, IDcolor=self.lut[ID], parent=self, - drawMode=drawMode, + drawMode=drawMode ) manualSep.setState(self.lastManualSeparateState) manualSep.show() @@ -264,7 +253,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): return self.lastManualSeparateState = manualSep.state() lab2D = self.get_2Dlab(posData.lab) - lab2D[manualSep.lab != 0] = manualSep.lab[manualSep.lab != 0] + lab2D[manualSep.lab!=0] = manualSep.lab[manualSep.lab!=0] self.set_2Dlab(lab2D) splittedIDs = [obj.label for obj in manualSep.rp] posData.disableAutoActivateViewerWindow = False @@ -277,10 +266,10 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.trackSubsetIDs(splittedIDs) if self.isSnapshot: - self.fixCcaDfAfterEdit("Separate IDs") + self.fixCcaDfAfterEdit('Separate IDs') self.updateAllImages() else: - self.warnEditingWithCca_df("Separate IDs") + self.warnEditingWithCca_df('Separate IDs') self.store_data() @@ -293,16 +282,17 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) clickedBkgrID = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter here the ID that you want to " - "fill the holes of", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here the ID that you want to ' + 'fill the holes of', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) clickedBkgrID.exec_() if clickedBkgrID.cancel: @@ -324,23 +314,24 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if not self.fillHolesToolButton.findChild(QAction).isChecked(): self.fillHolesToolButton.setChecked(False) - + # Hull contour elif right_click and self.hullContToolButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) mergeID_prompt = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter here the ID that you want to " - "replace with Hull contour", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here the ID that you want to ' + 'replace with Hull contour', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) mergeID_prompt.exec_() if mergeID_prompt.cancel: @@ -377,16 +368,17 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) clickedBkgrID = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter here the ID that you want to " - "fill the holes of", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here the ID that you want to ' + 'fill the holes of', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) clickedBkgrID.exec_() if clickedBkgrID.cancel: @@ -400,15 +392,16 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) mergeID_prompt = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter here first ID that you want to merge", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here first ID that you want to merge', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) mergeID_prompt.exec_() if mergeID_prompt.cancel: @@ -420,7 +413,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): # Store undo state before modifying stuff self.storeUndoRedoStates(False) self.firstID = ID - + obj_idx = posData.IDs_idxs[ID] obj = posData.rp[obj_idx] yc, xc = self.getObjCentroid(obj.centroid) @@ -432,15 +425,16 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) editID_prompt = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter here ID that you want to replace with a new one", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter here ID that you want to replace with a new one', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) editID_prompt.show(block=True) @@ -448,7 +442,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): return else: ID = editID_prompt.EntryID - + obj_idx = posData.IDs_idxs[ID] y, x = posData.rp[obj_idx].centroid[-2:] xdata, ydata = int(x), int(y) @@ -462,14 +456,13 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): and posData.frame_i < posData.SizeT - 1 ) editID = apps.EditIDDialog( - ID, - posData.IDs, + ID, posData.IDs, doNotShowAgain=self.doNotAskAgainExistingID, - parent=self, - entryID=self.getNearestLostObjID(y, x), - nextUniqueID=self.setBrushID(return_val=True), + parent=self, + entryID=self.getNearestLostObjID(y, x), + nextUniqueID=self.setBrushID(return_val=True), allIDs=posData.allIDs, - addPropagateCheckbox=addPropagateCheckbox, + addPropagateCheckbox=addPropagateCheckbox ) editID.show(block=True) if editID.cancel: @@ -481,49 +474,46 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if editID.assignNewID: self.assignNewIDfromClickedID(ID, event) return - - if not self.doNotAskAgainExistingID: + + if not self.doNotAskAgainExistingID: self.editIDmergeIDs = editID.mergeWithExistingID self.doNotAskAgainExistingID = editID.doNotAskAgainExistingID - + self.applyEditID( - ID, - currentIDs, - editID.how, - x, - y, + ID, currentIDs, editID.how, x, y, shift=shift, - doPropagateUnvisited=editID.doPropagateFutureFrames, + doPropagateUnvisited=editID.doPropagateFutureFrames ) - + elif (right_click or left_click) and self.keepIDsButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) keepID_win = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter ID that you want to keep", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to keep', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) keepID_win.exec_() if keepID_win.cancel: return else: ID = keepID_win.EntryID - + if ID in self.keptObjectsIDs: self.keptObjectsIDs.remove(ID) self.clearHighlightedText() else: self.keptObjectsIDs.append(ID) self.highlightLabelID(ID) - + self.updateTempLayerKeepIDs() # Annotate cell as removed from the analysis @@ -532,15 +522,16 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) binID_prompt = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter ID that you want to remove from the analysis", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to remove from the analysis', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) binID_prompt.exec_() if binID_prompt.cancel: @@ -549,17 +540,14 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): ID = binID_prompt.EntryID # Ask to propagate change to all future visited frames - key = "Exclude cell from analysis" + key = 'Exclude cell from analysis' askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( - self.propagateChange( - ID, - key, - doNotShow, - posData.UndoFutFrames_BinID, - posData.applyFutFrames_BinID, - ) + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + ID, key, doNotShow, + posData.UndoFutFrames_BinID, + posData.applyFutFrames_BinID ) if UndoFutFrames is None: @@ -576,7 +564,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if applyFutFrames: # Store current data before going to future frames self.store_data() - for i in range(posData.frame_i + 1, endFrame_i + 1): + for i in range(posData.frame_i+1, endFrame_i+1): posData.frame_i = i self.get_data() if ID in posData.binnedIDs: @@ -584,7 +572,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): else: posData.binnedIDs.add(ID) self.update_rp_metadata(draw=False) - self.store_data(autosave=i == endFrame_i) + self.store_data(autosave=i==endFrame_i) self.app.restoreOverrideCursor() @@ -615,15 +603,16 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D( + self.get_2Dlab(posData.lab), y, x + ) ripID_prompt = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter ID that you want to annotate as dead", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to annotate as dead', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) ripID_prompt.exec_() if ripID_prompt.cancel: @@ -632,17 +621,14 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): ID = ripID_prompt.EntryID # Ask to propagate change to all future visited frames - key = "Annotate cell as dead" + key = 'Annotate cell as dead' askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( - self.propagateChange( - ID, - key, - doNotShow, - posData.UndoFutFrames_RipID, - posData.applyFutFrames_RipID, - ) + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + ID, key, doNotShow, + posData.UndoFutFrames_RipID, + posData.applyFutFrames_RipID ) if UndoFutFrames is None: @@ -658,7 +644,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if applyFutFrames: # Store current data before going to future frames self.store_data() - for i in range(posData.frame_i + 1, endFrame_i + 1): + for i in range(posData.frame_i+1, endFrame_i+1): posData.frame_i = i self.get_data() if ID in posData.ripIDs: @@ -666,7 +652,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): else: posData.ripIDs.add(ID) self.update_rp_metadata(draw=False) - self.store_data(autosave=i == endFrame_i) + self.store_data(autosave=i==endFrame_i) self.app.restoreOverrideCursor() # Back to current frame @@ -689,27 +675,26 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.store_data() if self.isSnapshot: - self.fixCcaDfAfterEdit("Annotate ID as dead") + self.fixCcaDfAfterEdit('Annotate ID as dead') self.updateAllImages() else: - self.warnEditingWithCca_df("Annotate ID as dead") + self.warnEditingWithCca_df('Annotate ID as dead') if not self.ripCellButton.findChild(QAction).isChecked(): self.ripCellButton.setChecked(False) - @exception_handler def gui_mouseReleaseEventImg2(self, event): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if mode == "Viewer": + if mode == 'Viewer': return Y, X = self.get_2Dlab(posData.lab).shape try: x, y = event.pos().x(), event.pos().y() - except Exception: + except Exception as e: return - + xdata, ydata = int(x), int(y) if not myutils.is_in_bounds(xdata, ydata, X, Y): self.isMouseDragImg2 = False @@ -738,16 +723,17 @@ def gui_mouseReleaseEventImg2(self, event): lab2D = self.get_2Dlab(posData.lab) ID = lab2D[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D(lab2D, y, x) + nearest_ID = core.nearest_nonzero_2D( + lab2D, y, x + ) mergeID_prompt = apps.QLineEditDialog( - title="Clicked on background", - msg="You clicked on the background.\n" - "Enter ID that you want to merge with ID " - f"{self.firstID}", - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg='You clicked on the background.\n' + 'Enter ID that you want to merge with ID ' + f'{self.firstID}', + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) mergeID_prompt.exec_() if mergeID_prompt.cancel: @@ -758,14 +744,14 @@ def gui_mouseReleaseEventImg2(self, event): obj = posData.rp[obj_idx] y2, x2 = self.getObjCentroid(obj.centroid) self.mergeObjsTempLine.addPoint(x2, y2) - + xx, yy = self.mergeObjsTempLine.getData() IDs_to_merge = lab2D[yy.astype(int), xx.astype(int)] for ID in IDs_to_merge: if ID == 0: continue - posData.lab[posData.lab == ID] = self.firstID - + posData.lab[posData.lab==ID] = self.firstID + self.mergeObjsTempLine.setData([], []) self.clickObjYc, self.clickObjXc = None, None @@ -778,89 +764,29 @@ def gui_mouseReleaseEventImg2(self, event): ask_back_prop = False prev_IDs = [] else: - prev_IDs = posData.allData_li[posData.frame_i - 1]["IDs"] + prev_IDs = posData.allData_li[posData.frame_i-1]['IDs'] - if all(ID not in prev_IDs for ID in IDs_to_merge): + if all(ID not in prev_IDs for ID in IDs_to_merge): ask_back_prop = False - + if not self.isFrameCcaAnnotated() and ask_back_prop: - proceed = self.askPropagateChangePast(f"Merge IDs {IDs_to_merge}") + proceed = self.askPropagateChangePast(f'Merge IDs {IDs_to_merge}') if proceed: self.propagateMergeObjsPast(IDs_to_merge) - self.whitelistPropagateIDs( - only_future_frames=False, update_lab=True - ) # in the update_rp() call, this should also be done + self.whitelistPropagateIDs(only_future_frames=False, update_lab=True) # in the update_rp() call, this should also be done # Repeat tracking self.tracking( - enforce=True, assign_unique_new_IDs=False, separateByLabel=False + enforce=True, assign_unique_new_IDs=False, + separateByLabel=False ) if self.isSnapshot: - self.fixCcaDfAfterEdit("Merge IDs") + self.fixCcaDfAfterEdit('Merge IDs') self.updateAllImages() else: - self.warnEditingWithCca_df("Merge IDs") - + self.warnEditingWithCca_df('Merge IDs') + if not self.mergeIDsButton.findChild(QAction).isChecked(): self.mergeIDsButton.setChecked(False) self.store_data() - - def is_viewer_mode(self, mode: str) -> bool: - return mode == self.viewer_mode - - def should_blink_viewer_mode( - self, - *, - mode: str, - middle_click: bool, - right_action_on: bool = False, - custom_action_on: bool = False, - right_click: bool = False, - ) -> bool: - if mode != self.viewer_mode: - return False - if middle_click: - return True - return (right_action_on or custom_action_on) and (right_click or middle_click) - - def should_drag_image( - self, - *, - left_click: bool, - eraser_on: bool, - brush_on: bool, - middle_click: bool, - pan_click: bool, - ) -> bool: - return pan_click or ( - left_click and not eraser_on and not brush_on and not middle_click - ) - - def should_process_release( - self, - *, - mode: str, - in_bounds: bool, - ) -> bool: - return mode != self.viewer_mode and in_bounds - - LEGACY_METHODS = ( - "gui_mousePressEventImg2", - "gui_mouseReleaseEventImg2", - ) - - def should_show_labels_menu( - self, - *, - right_click: bool, - right_action_on: bool, - middle_click: bool, - event_from_img1: bool, - ) -> bool: - return ( - right_click - and not right_action_on - and not middle_click - and not event_from_img1 - ) diff --git a/cellacdc/mixins/canvas_tool.py b/cellacdc/mixins/canvas_tool.py new file mode 100644 index 000000000..4f147a836 --- /dev/null +++ b/cellacdc/mixins/canvas_tool.py @@ -0,0 +1,11 @@ +"""View adapter for canvas tool interaction decisions.""" + +from __future__ import annotations + + +class CanvasTool: + """Extracted from guiWin.""" + + def storeManualSeparateDrawMode(self, mode): + self.df_settings.at['manual_separate_draw_mode', 'value'] = mode + self.df_settings.to_csv(self.settings_csv_path) diff --git a/cellacdc/mixins_bak/cell_cycle.py b/cellacdc/mixins/cell_cycle.py similarity index 61% rename from cellacdc/mixins_bak/cell_cycle.py rename to cellacdc/mixins/cell_cycle.py index 91f57c8bf..9c6ddab3c 100644 --- a/cellacdc/mixins_bak/cell_cycle.py +++ b/cellacdc/mixins/cell_cycle.py @@ -21,13 +21,15 @@ from cellacdc import widgets, workers -class CellCycleMixin: - """Qt-facing adapter for cell-cycle annotation workflows.""" +class CellCycle: + """Extracted from guiWin.""" - def _getCcaCostMatrix(self, numCellsG1, numNewCells, IDsCellsG1, newIDs_contours): + def _getCcaCostMatrix( + self, numCellsG1, numNewCells, IDsCellsG1, newIDs_contours + ): posData = self.data[self.pos_i] dataDict = posData.allData_li[posData.frame_i] - dist_matrix_df = dataDict.get("obj_to_obj_dist_cost_matrix_df") + dist_matrix_df = dataDict.get('obj_to_obj_dist_cost_matrix_df') if dist_matrix_df is None: cost = np.full((numCellsG1, numNewCells), np.inf) for obj in posData.rp: @@ -39,16 +41,18 @@ def _getCcaCostMatrix(self, numCellsG1, numNewCells, IDsCellsG1, newIDs_contours cont = self.getObjContours(obj) i = IDsCellsG1.index(ID) - + # Get distance from cell in G1 and all other new cells for j, newID_cont in enumerate(newIDs_contours): - min_dist, nearest_xy = self.nearest_point_2Dyx(cont, newID_cont) + min_dist, nearest_xy = self.nearest_point_2Dyx( + cont, newID_cont + ) cost[i, j] = min_dist - + return cost cost = dist_matrix_df.loc[IDsCellsG1, posData.new_IDs].values - + return cost def addIDBaseCca_df(self, posData, ID): @@ -62,7 +66,10 @@ def addIDBaseCca_df(self, posData, ID): self.cca_df_default_values, ) if posData.cca_df.empty: - posData.cca_df = pd.DataFrame({col: val for col, val in _zip}, index=[ID]) + posData.cca_df = pd.DataFrame( + {col: val for col, val in _zip}, + index=[ID] + ) else: for col, val in _zip: posData.cca_df.at[ID, col] = val @@ -73,7 +80,7 @@ def addMissingIDs_cca_df(self, posData): if posData.cca_df is None: posData.cca_df = base_cca_df return - + posData.cca_df = posData.cca_df.combine_first(base_cca_df) def annotateBudToDifferentMother(self): @@ -89,8 +96,8 @@ def annotateBudToDifferentMother(self): - User released mouse button on a cell in G1 (checked at release time) - The new mother MUST be in G1 for all the frames of the bud life --> if not warn - - The new mother MUST have appeared in current frame OR be already - in G1 in previous frame, otherwise there would be no G1 cycle + - The new mother MUST have appeared in current frame OR be already + in G1 in previous frame, otherwise there would be no G1 cycle Result: - The bud only changes relative ID to the new mother @@ -111,57 +118,66 @@ def annotateBudToDifferentMother(self): if not eligible: return - budEligible = self.checkChangeMotherBudEligible(budID, posData.frame_i) + budEligible = self.checkChangeMotherBudEligible( + budID, posData.frame_i + ) if not budEligible: - return - - # Allow partial initialization of cca_df with mouse - if posData.frame_i == 0: - newMothCcs = posData.cca_df.at[new_mothID, "cell_cycle_stage"] - if not newMothCcs == "G1": - err_msg = "You are assigning the bud to a cell that is not in G1!" + return + + # Allow partial initialization of cca_df with mouse + if posData.frame_i == 0: + newMothCcs = posData.cca_df.at[new_mothID, 'cell_cycle_stage'] + if not newMothCcs == 'G1': + err_msg = ( + 'You are assigning the bud to a cell that is not in G1!' + ) msg = QMessageBox() - msg.critical(self, "New mother not in G1!", err_msg, msg.Ok) + msg.critical( + self, 'New mother not in G1!', err_msg, msg.Ok + ) return # Store cca_df for undo action undoId = uuid.uuid4() self.storeUndoRedoCca(0, posData.cca_df, undoId) - currentRelID = posData.cca_df.at[budID, "relative_ID"] + currentRelID = posData.cca_df.at[budID, 'relative_ID'] if currentRelID in posData.cca_df.index: - posData.cca_df.at[currentRelID, "relative_ID"] = -1 - posData.cca_df.at[currentRelID, "generation_num"] = 2 - posData.cca_df.at[currentRelID, "cell_cycle_stage"] = "G1" - posData.cca_df.at[budID, "relationship"] = "bud" - posData.cca_df.at[budID, "generation_num"] = 0 - posData.cca_df.at[budID, "relative_ID"] = new_mothID - posData.cca_df.at[budID, "cell_cycle_stage"] = "S" - posData.cca_df.at[new_mothID, "relative_ID"] = budID - posData.cca_df.at[new_mothID, "generation_num"] = 2 - posData.cca_df.at[new_mothID, "cell_cycle_stage"] = "S" + posData.cca_df.at[currentRelID, 'relative_ID'] = -1 + posData.cca_df.at[currentRelID, 'generation_num'] = 2 + posData.cca_df.at[currentRelID, 'cell_cycle_stage'] = 'G1' + posData.cca_df.at[budID, 'relationship'] = 'bud' + posData.cca_df.at[budID, 'generation_num'] = 0 + posData.cca_df.at[budID, 'relative_ID'] = new_mothID + posData.cca_df.at[budID, 'cell_cycle_stage'] = 'S' + posData.cca_df.at[new_mothID, 'relative_ID'] = budID + posData.cca_df.at[new_mothID, 'generation_num'] = 2 + posData.cca_df.at[new_mothID, 'cell_cycle_stage'] = 'S' self.updateAllImages() self.store_cca_df() return - curr_mothID = posData.cca_df.at[budID, "relative_ID"] + curr_mothID = posData.cca_df.at[budID, 'relative_ID'] if curr_mothID in posData.cca_df.index: - curr_moth_cca = self.getStatus_RelID_BeforeEmergence(budID, curr_mothID) + curr_moth_cca = self.getStatus_RelID_BeforeEmergence( + budID, curr_mothID + ) # Store cca_df for undo action undoId = uuid.uuid4() self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - + # Correct current frames and update LabelItems - posData.cca_df.at[budID, "relative_ID"] = new_mothID - posData.cca_df.at[budID, "generation_num"] = 0 - posData.cca_df.at[budID, "relative_ID"] = new_mothID - posData.cca_df.at[budID, "relationship"] = "bud" - posData.cca_df.at[budID, "corrected_on_frame_i"] = posData.frame_i - posData.cca_df.at[budID, "cell_cycle_stage"] = "S" - - posData.cca_df.at[new_mothID, "relative_ID"] = budID - posData.cca_df.at[new_mothID, "cell_cycle_stage"] = "S" - posData.cca_df.at[new_mothID, "relationship"] = "mother" - + posData.cca_df.at[budID, 'relative_ID'] = new_mothID + posData.cca_df.at[budID, 'generation_num'] = 0 + posData.cca_df.at[budID, 'relative_ID'] = new_mothID + posData.cca_df.at[budID, 'relationship'] = 'bud' + posData.cca_df.at[budID, 'corrected_on_frame_i'] = posData.frame_i + posData.cca_df.at[budID, 'cell_cycle_stage'] = 'S' + + posData.cca_df.at[new_mothID, 'relative_ID'] = budID + posData.cca_df.at[new_mothID, 'cell_cycle_stage'] = 'S' + posData.cca_df.at[new_mothID, 'relationship'] = 'mother' + + if curr_mothID in posData.cca_df.index: # Cells with UNKNOWN history has relative's ID = -1 # which is not an existing cell @@ -182,7 +198,7 @@ def annotateBudToDifferentMother(self): self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) # Correct future frames - for i in range(posData.frame_i + 1, posData.SizeT): + for i in range(posData.frame_i+1, posData.SizeT): # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=i, return_df=True) if cca_df_i is None: @@ -195,26 +211,26 @@ def annotateBudToDifferentMother(self): continue self.storeUndoRedoCca(i, cca_df_i, undoId) - bud_relationship = cca_df_i.at[budID, "relationship"] - bud_ccs = cca_df_i.at[budID, "cell_cycle_stage"] + bud_relationship = cca_df_i.at[budID, 'relationship'] + bud_ccs = cca_df_i.at[budID, 'cell_cycle_stage'] - if bud_relationship == "mother" and bud_ccs == "S": + if bud_relationship == 'mother' and bud_ccs == 'S': # The bud at the ith frame budded itself --> stop break - cca_df_i.at[budID, "relative_ID"] = new_mothID - cca_df_i.at[budID, "generation_num"] = 0 - cca_df_i.at[budID, "relative_ID"] = new_mothID - cca_df_i.at[budID, "relationship"] = "bud" - cca_df_i.at[budID, "cell_cycle_stage"] = "S" + cca_df_i.at[budID, 'relative_ID'] = new_mothID + cca_df_i.at[budID, 'generation_num'] = 0 + cca_df_i.at[budID, 'relative_ID'] = new_mothID + cca_df_i.at[budID, 'relationship'] = 'bud' + cca_df_i.at[budID, 'cell_cycle_stage'] = 'S' - newMoth_bud_ccs = cca_df_i.at[new_mothID, "cell_cycle_stage"] - if newMoth_bud_ccs == "G1": + newMoth_bud_ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] + if newMoth_bud_ccs == 'G1': # Assign bud to new mother only if the new mother is in G1 # This can happen if the bud already has a G1 annotated - cca_df_i.at[new_mothID, "relative_ID"] = budID - cca_df_i.at[new_mothID, "cell_cycle_stage"] = "S" - cca_df_i.at[new_mothID, "relationship"] = "mother" + cca_df_i.at[new_mothID, 'relative_ID'] = budID + cca_df_i.at[new_mothID, 'cell_cycle_stage'] = 'S' + cca_df_i.at[new_mothID, 'relationship'] = 'mother' if curr_mothID in cca_df_i.index: # Cells with UNKNOWN history has relative's ID = -1 @@ -224,7 +240,7 @@ def annotateBudToDifferentMother(self): self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) # Correct past frames - for i in range(posData.frame_i - 1, -1, -1): + for i in range(posData.frame_i-1, -1, -1): # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=i, return_df=True) @@ -234,15 +250,15 @@ def annotateBudToDifferentMother(self): break self.storeUndoRedoCca(i, cca_df_i, undoId) - cca_df_i.at[budID, "relative_ID"] = new_mothID - cca_df_i.at[budID, "generation_num"] = 0 - cca_df_i.at[budID, "relative_ID"] = new_mothID - cca_df_i.at[budID, "relationship"] = "bud" - cca_df_i.at[budID, "cell_cycle_stage"] = "S" + cca_df_i.at[budID, 'relative_ID'] = new_mothID + cca_df_i.at[budID, 'generation_num'] = 0 + cca_df_i.at[budID, 'relative_ID'] = new_mothID + cca_df_i.at[budID, 'relationship'] = 'bud' + cca_df_i.at[budID, 'cell_cycle_stage'] = 'S' - cca_df_i.at[new_mothID, "relative_ID"] = budID - cca_df_i.at[new_mothID, "cell_cycle_stage"] = "S" - cca_df_i.at[new_mothID, "relationship"] = "mother" + cca_df_i.at[new_mothID, 'relative_ID'] = budID + cca_df_i.at[new_mothID, 'cell_cycle_stage'] = 'S' + cca_df_i.at[new_mothID, 'relationship'] = 'mother' if curr_mothID in cca_df_i.index: # Cells with UNKNOWN history has relative's ID = -1 @@ -250,7 +266,7 @@ def annotateBudToDifferentMother(self): cca_df_i.loc[curr_mothID] = curr_moth_cca self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - + self.enqAutosave() def annotateDivision(self, cca_df, ID, relID, frame_i=None): @@ -264,30 +280,30 @@ def annotateDivision(self, cca_df, ID, relID, frame_i=None): self.annotateWillDivide(ID, relID) store = False - cca_df.at[ID, "cell_cycle_stage"] = "G1" - cca_df.at[relID, "cell_cycle_stage"] = "G1" - + cca_df.at[ID, 'cell_cycle_stage'] = 'G1' + cca_df.at[relID, 'cell_cycle_stage'] = 'G1' + if frame_i > 0: - gen_num_clickedID = cca_df.at[ID, "generation_num"] - cca_df.at[ID, "generation_num"] += 1 - cca_df.at[ID, "division_frame_i"] = frame_i - gen_num_relID = cca_df.at[relID, "generation_num"] - cca_df.at[relID, "generation_num"] = gen_num_relID + 1 - cca_df.at[relID, "division_frame_i"] = frame_i + gen_num_clickedID = cca_df.at[ID, 'generation_num'] + cca_df.at[ID, 'generation_num'] += 1 + cca_df.at[ID, 'division_frame_i'] = frame_i + gen_num_relID = cca_df.at[relID, 'generation_num'] + cca_df.at[relID, 'generation_num'] = gen_num_relID+1 + cca_df.at[relID, 'division_frame_i'] = frame_i if gen_num_clickedID < gen_num_relID: - cca_df.at[ID, "relationship"] = "mother" + cca_df.at[ID, 'relationship'] = 'mother' else: - cca_df.at[relID, "relationship"] = "mother" + cca_df.at[relID, 'relationship'] = 'mother' else: - cca_df.at[ID, "generation_num"] = 2 - cca_df.at[relID, "generation_num"] = 2 - - cca_df.at[ID, "division_frame_i"] = -1 - cca_df.at[relID, "division_frame_i"] = -1 + cca_df.at[ID, 'generation_num'] = 2 + cca_df.at[relID, 'generation_num'] = 2 - cca_df.at[ID, "relationship"] = "mother" - cca_df.at[relID, "relationship"] = "mother" + cca_df.at[ID, 'division_frame_i'] = -1 + cca_df.at[relID, 'division_frame_i'] = -1 + cca_df.at[ID, 'relationship'] = 'mother' + cca_df.at[relID, 'relationship'] = 'mother' + store = True return store @@ -303,8 +319,8 @@ def annotateIsHistoryKnown(self, ID): with unknown history with the function "updateIsHistoryKnown()" """ posData = self.data[self.pos_i] - is_history_known = posData.cca_df.at[ID, "is_history_known"] - relID = posData.cca_df.at[ID, "relative_ID"] + is_history_known = posData.cca_df.at[ID, 'is_history_known'] + relID = posData.cca_df.at[ID, 'relative_ID'] if relID in posData.cca_df.index: relID_cca = self.getStatus_RelID_BeforeEmergence(ID, relID) @@ -332,12 +348,12 @@ def annotateIsHistoryKnown(self, ID): # Update cell cycle info LabelItems obj_idx = posData.IDs.index(ID) - posData.rp[obj_idx] + rp_ID = posData.rp[obj_idx] if relID in posData.IDs: relObj_idx = posData.IDs.index(relID) - posData.rp[relObj_idx] - + rp_relID = posData.rp[relObj_idx] + self.setAllTextAnnotations() self.drawAllMothBudLines() @@ -348,7 +364,7 @@ def annotateIsHistoryKnown(self, ID): self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) # Correct future frames - for i in range(posData.frame_i + 1, posData.SizeT): + for i in range(posData.frame_i+1, posData.SizeT): cca_df_i = self.get_cca_df(frame_i=i, return_df=True) if cca_df_i is None: # ith frame was not visited yet @@ -365,8 +381,9 @@ def annotateIsHistoryKnown(self, ID): cca_df_i.loc[relID] = relID_cca self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) + # Correct past frames - for i in range(posData.frame_i - 1, -1, -1): + for i in range(posData.frame_i-1, -1, -1): cca_df_i = self.get_cca_df(frame_i=i, return_df=True) if cca_df_i is None: # ith frame was not visited yet @@ -378,12 +395,12 @@ def annotateIsHistoryKnown(self, ID): # we reached frame where ID was not existing yet break else: - relID = cca_df_i.at[ID, "relative_ID"] + relID = cca_df_i.at[ID, 'relative_ID'] self.setHistoryKnowledge(ID, cca_df_i) if relID in IDs: cca_df_i.loc[relID] = relID_cca self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - + self.enqAutosave() def annotateWillDivide(self, ID, relID, frame_i=None): @@ -392,61 +409,30 @@ def annotateWillDivide(self, ID, relID, frame_i=None): frame_i = posData.frame_i # Store in the past frames that division has been annotated - for past_frame_i in range(frame_i - 1, -1, -1): + for past_frame_i in range(frame_i-1, -1, -1): past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True) if past_cca_df is None: return - + if ID not in past_cca_df.index: # ID is a bud and is not emerged yet here return - - if frame_i - 1 == past_frame_i: + + if frame_i-1 == past_frame_i: # Get generation number at first iteration - gen_num = past_cca_df.at[ID, "generation_num"] - - if past_cca_df.at[ID, "generation_num"] != gen_num: + gen_num = past_cca_df.at[ID, 'generation_num'] + + if past_cca_df.at[ID, 'generation_num'] != gen_num: # ID is a mother and the cell cycle is finished here return + + past_cca_df.at[ID, 'will_divide'] = 1 + past_cca_df.at[relID, 'will_divide'] = 1 - past_cca_df.at[ID, "will_divide"] = 1 - past_cca_df.at[relID, "will_divide"] = 1 - - self.store_cca_df(cca_df=past_cca_df, frame_i=past_frame_i, autosave=False) - - def annotated_edit_warning_plan( - self, - *, - is_snapshot: bool, - acdc_df_missing: bool, - lineage_tree_missing: bool, - cell_cycle_stage_present: bool, - lineage_tree_present: bool, - remembered_skip_warning: bool, - ) -> AnnotatedEditWarningPlan: - if is_snapshot: - return AnnotatedEditWarningPlan(proceed_without_warning=True) - - no_annotation_source = acdc_df_missing and lineage_tree_missing - no_annotations = not cell_cycle_stage_present and not lineage_tree_present - if no_annotation_source or no_annotations or remembered_skip_warning: - return AnnotatedEditWarningPlan( - proceed_without_warning=True, - update_images=True, + self.store_cca_df( + cca_df=past_cca_df, frame_i=past_frame_i, autosave=False ) - warn_type = ( - "cell cycle annotations" - if cell_cycle_stage_present - else "lineage tree annotations" - ) - return AnnotatedEditWarningPlan( - proceed_without_warning=False, - should_prompt=True, - warn_type=warn_type, - ) - - @exception_handler def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i): self.store_data(autosave=False) posData = self.data[self.pos_i] @@ -456,9 +442,9 @@ def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i): if cca_df_i is None: # ith frame was not visited yet break - + self.storeUndoRedoCca(i, cca_df_i, undoId) - + for ID, changes_ID in changes.items(): if ID not in cca_df_i.index: continue @@ -468,33 +454,34 @@ def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i): self.get_data() self.updateAllImages() - @exception_handler def attempt_auto_cca(self, enforceAll=False): mode = str(self.modeComboBox.currentText()) posData = self.data[self.pos_i] - if mode == "Cell cycle analysis": - notEnoughG1Cells, proceed = self.autoCca_df(enforceAll=enforceAll) + if mode == 'Cell cycle analysis': + notEnoughG1Cells, proceed = self.autoCca_df( + enforceAll=enforceAll + ) if not proceed: return notEnoughG1Cells, proceed - + # mode = str(self.modeComboBox.currentText()) - if posData.cca_df is None: # ??? + if posData.cca_df is None: # ??? notEnoughG1Cells = False proceed = True return notEnoughG1Cells, proceed if posData.cca_df.isna().any(axis=None): - raise ValueError("Cell cycle analysis table contains NaNs") + raise ValueError('Cell cycle analysis table contains NaNs') # self.checkMultiBudMoth() proceed = self.checkMothersExcludedOrDead() return notEnoughG1Cells, proceed - elif mode == "Normal division: Lineage tree": + elif mode == 'Normal division: Lineage tree': self.autoLinTree_df() notEnoughG1Cells = False proceed = True return notEnoughG1Cells, proceed - + else: notEnoughG1Cells = False proceed = True @@ -503,20 +490,23 @@ def attempt_auto_cca(self, enforceAll=False): def autoAssignBud_YeastMate(self): if not self.is_win: txt = ( - "YeastMate is available only on Windows OS." - "We are working on expading support also on macOS and Linux.\n\n" - "Thank you for your patience!" + 'YeastMate is available only on Windows OS.' + 'We are working on expading support also on macOS and Linux.\n\n' + 'Thank you for your patience!' ) msg = QMessageBox() - msg.critical(self, "Supported only on Windows", txt, msg.Ok) + msg.critical( + self, 'Supported only on Windows', txt, msg.Ok + ) return - model_name = "YeastMate" + + model_name = 'YeastMate' idx = self.modelNames.index(model_name) self.titleLabel.setText( - f"{model_name} is thinking... (check progress in terminal/console)", - color=self.titleColor, + f'{model_name} is thinking... ' + '(check progress in terminal/console)', color=self.titleColor ) # Store undo state before modifying stuff @@ -539,36 +529,36 @@ def autoAssignBud_YeastMate(self): _SizeZ = None if self.isSegm3D: - _SizeZ = posData.SizeZ + _SizeZ = posData.SizeZ win = apps.QDialogModelParams( init_params, segment_params, - model_name, - url=url, + model_name, + url=url, posData=posData, - df_metadata=posData.metadata_df, + df_metadata=posData.metadata_df ) win.exec_() if win.cancel: - self.titleLabel.setText("Segmentation aborted.") + self.titleLabel.setText('Segmentation aborted.') return - use_gpu = win.init_kwargs.get("gpu", False) + use_gpu = win.init_kwargs.get('gpu', False) proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) if not proceed: - self.logger.info("Segmentation process cancelled.") - self.titleLabel.setText("Segmentation process cancelled.") + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') return - + self.model_kwargs = win.model_kwargs model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) if model is None: - self.logger.info("Segmentation process cancelled.") - self.titleLabel.setText("Segmentation process cancelled.") + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') return try: model.setupLogger(self.logger) - except Exception: + except Exception as e: pass self.models[idx] = model @@ -579,7 +569,7 @@ def autoAssignBud_YeastMate(self): self.store_data() self.updateAllImages() - self.titleLabel.setText("Budding event prediction done.", color="g") + self.titleLabel.setText('Budding event prediction done.', color='g') def autoCca_df(self, enforceAll=False): """ @@ -591,47 +581,56 @@ def autoCca_df(self, enforceAll=False): """ proceed = True notEnoughG1Cells = False + ScellsGone = False posData = self.data[self.pos_i] # Skip cca if not the right mode mode = str(self.modeComboBox.currentText()) - if mode.find("Cell cycle") == -1: + if mode.find('Cell cycle') == -1: return notEnoughG1Cells, proceed + # Make sure that this is a visited frame in segmentation tracking mode - if posData.allData_li[posData.frame_i]["labels"] is None: + if posData.allData_li[posData.frame_i]['labels'] is None: proceed = self.warnFrameNeverVisitedSegmMode() return notEnoughG1Cells, proceed - + # Determine if this is the last visited frame for repeating # bud assignment on non manually correct (corrected_on_frame_i>0) buds. # The idea is that the user could have assigned division on a cell # by going previous and we want to check if this cell could be a # "better" mother for those non manually corrected buds - curr_df = posData.allData_li[posData.frame_i]["acdc_df"] - isLastVisitedAgain = self.isLastVisitedAgainCca(curr_df, enforceAll=enforceAll) - + curr_df = posData.allData_li[posData.frame_i]['acdc_df'] + isLastVisitedAgain = self.isLastVisitedAgainCca( + curr_df, enforceAll=enforceAll + ) + frameAlreadyAnnotated = ( - posData.cca_df is not None and not enforceAll and not isLastVisitedAgain + posData.cca_df is not None + and not enforceAll + and not isLastVisitedAgain ) # Use stored cca_df and do not modify it with automatic stuff if frameAlreadyAnnotated: return notEnoughG1Cells, proceed - + # Keep only correctedAssignIDs if requested # For the last visited frame we perform assignment again only on # IDs where we didn't manually correct assignment correctedAssignIDs = set() if isLastVisitedAgain and not enforceAll: try: - correctedAssignIDs = curr_df[curr_df["corrected_on_frame_i"] > 0].index - except Exception: + correctedAssignIDs = curr_df[ + curr_df['corrected_on_frame_i']>0 + ].index + except Exception as e: correctedAssignIDs = [] posData.new_IDs = [ - ID for ID in posData.new_IDs if ID not in correctedAssignIDs + ID for ID in posData.new_IDs + if ID not in correctedAssignIDs ] - + # Check if new IDs exist some time in the past found_cca_df_IDs = self.checkCcaPastFramesNewIDs() @@ -643,18 +642,18 @@ def autoCca_df(self, enforceAll=False): return notEnoughG1Cells, proceed # Get previous dataframe - acdc_df = posData.allData_li[posData.frame_i - 1]["acdc_df"] + acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] prev_cca_df = acdc_df[self.cca_df_colnames].copy() if posData.cca_df is None: posData.cca_df = prev_cca_df.copy() else: posData.cca_df = curr_df[self.cca_df_colnames].copy() - + # concatenate new IDs found in past frames (before frame_i-1) if found_cca_df_IDs is not None: cca_df = pd.concat([posData.cca_df, *found_cca_df_IDs]) - unique_idx = ~cca_df.index.duplicated(keep="first") + unique_idx = ~cca_df.index.duplicated(keep='first') posData.cca_df = cca_df[unique_idx] # If there are no new IDs we are done @@ -665,39 +664,43 @@ def autoCca_df(self, enforceAll=False): # Get cells in G1 (exclude dead) and check if there are enough cells in G1 try: - prev_df_G1 = prev_cca_df[prev_cca_df["cell_cycle_stage"] == "G1"] - prev_df_G1 = prev_df_G1[~acdc_df.loc[prev_df_G1.index]["is_cell_dead"]] + prev_df_G1 = prev_cca_df[prev_cca_df['cell_cycle_stage']=='G1'] + prev_df_G1 = prev_df_G1[~acdc_df.loc[prev_df_G1.index]['is_cell_dead']] IDsCellsG1 = set(prev_df_G1.index) - except Exception: + except Exception as err: IDsCellsG1 = set() - + if isLastVisitedAgain or enforceAll: # If we are repeating auto cca for last visited frame # then we also add the cells in G1 that appears in current frame - # and we remove the ones that are already in S in current frame + # and we remove the ones that are already in S in current frame # if they were manually corrected (i.e., they cannot be mother). - # Note that potential mother cells must be either appearing in - # current frame or in G1 also at previous frame. - # If we would consider cells that are in G1 at current frame - # but not in previous frame, assigning a bud to it would + # Note that potential mother cells must be either appearing in + # current frame or in G1 also at previous frame. + # If we would consider cells that are in G1 at current frame + # but not in previous frame, assigning a bud to it would # result in no G1 at all for the mother cell. - df_G1 = posData.cca_df[posData.cca_df["cell_cycle_stage"] == "G1"] + df_G1 = posData.cca_df[posData.cca_df['cell_cycle_stage']=='G1'] current_G1_IDs = df_G1.index - new_cell_G1 = [ID for ID in current_G1_IDs if ID not in prev_cca_df.index] + new_cell_G1 = [ + ID for ID in current_G1_IDs if ID not in prev_cca_df.index + ] IDsCellsG1.update(new_cell_G1) cells_S_current = posData.cca_df[ - (posData.cca_df["cell_cycle_stage"] == "S") - & (posData.cca_df["corrected_on_frame_i"] == posData.frame_i) + (posData.cca_df['cell_cycle_stage']=='S') + & (posData.cca_df['corrected_on_frame_i']==posData.frame_i) ].index IDsCellsG1 = IDsCellsG1 - set(cells_S_current) # Remove cells that disappeared IDsCellsG1 = [ID for ID in IDsCellsG1 if ID in posData.IDs] - + numCellsG1 = len(IDsCellsG1) numNewCells = len(posData.new_IDs) if numCellsG1 < numNewCells: - notEnoughG1Cells, proceed = self.handleNoCellsInG1(numCellsG1, numNewCells) + notEnoughG1Cells, proceed = self.handleNoCellsInG1( + numCellsG1, numNewCells + ) return notEnoughG1Cells, proceed # Compute new IDs contours @@ -715,37 +718,37 @@ def autoCca_df(self, enforceAll=False): # Run hungarian (munkres) assignment algorithm row_idx, col_idx = scipy.optimize.linear_sum_assignment(cost) - + # New mother cells newMothIDs = {IDsCellsG1[i] for i in row_idx} - + # Assign buds to mothers for i, j in zip(row_idx, col_idx): mothID = IDsCellsG1[i] budID = posData.new_IDs[j] - + relID = None # If we are repeating assignment for the bud then we also have to - # correct the possibily wrong mother --> it goes back to + # correct the possibily wrong mother --> it goes back to # G1 if it's not a mother that we assign now if budID in posData.cca_df.index: - relID = posData.cca_df.at[budID, "relative_ID"] + relID = posData.cca_df.at[budID, 'relative_ID'] if relID in prev_cca_df.index and relID not in newMothIDs: posData.cca_df.loc[relID] = prev_cca_df.loc[relID] - - posData.cca_df.at[mothID, "relative_ID"] = budID - posData.cca_df.at[mothID, "cell_cycle_stage"] = "S" + + posData.cca_df.at[mothID, 'relative_ID'] = budID + posData.cca_df.at[mothID, 'cell_cycle_stage'] = 'S' bud_cca_dict = base_cca_dict.copy() - bud_cca_dict["cell_cycle_stage"] = "S" - bud_cca_dict["generation_num"] = 0 - bud_cca_dict["relative_ID"] = mothID - bud_cca_dict["relationship"] = "bud" - bud_cca_dict["emerg_frame_i"] = posData.frame_i - bud_cca_dict["is_history_known"] = True - bud_cca_dict["corrected_on_frame_i"] = -1 + bud_cca_dict['cell_cycle_stage'] = 'S' + bud_cca_dict['generation_num'] = 0 + bud_cca_dict['relative_ID'] = mothID + bud_cca_dict['relationship'] = 'bud' + bud_cca_dict['emerg_frame_i'] = posData.frame_i + bud_cca_dict['is_history_known'] = True + bud_cca_dict['corrected_on_frame_i'] = -1 posData.cca_df.loc[budID] = pd.Series(bud_cca_dict) - + # Keep only existing IDs posData.cca_df = posData.cca_df.loc[posData.IDs] @@ -765,28 +768,28 @@ def blinkPairingItem(self): def ccaCheckerStopChecking(self): if not self.ccaCheckerRunning: return - + self.ccaIntegrityCheckerWorker.clearQueue() - + if self.ccaIntegrityCheckerWorker.isChecking: self.ccaIntegrityCheckerWorker.abortChecking = True def ccaCheckerWorkerClosed(self, worker): - self.logger.info("Cell cycle annotations integrity checker stopped.") - self.ccaCheckerRunning = False + self.logger.info('Cell cycle annotations integrity checker stopped.') + self.ccaCheckerRunning = False def ccaCheckerWorkerDone(self): self.setStatusBarLabel(log=False) def ccaIntegrCheckerToggled(self, checked): - self.df_settings.at["is_cca_integrity_checker_activated", "value"] = int( - checked + self.df_settings.at['is_cca_integrity_checker_activated', 'value'] = ( + int(checked) ) self.df_settings.to_csv(self.settings_csv_path) mode = self.modeComboBox.currentText() - if mode != "Cell cycle analysis": + if mode != 'Cell cycle analysis': return - + if checked: self.startCcaIntegrityCheckerWorker() else: @@ -796,17 +799,17 @@ def checkCcaPastFramesNewIDs(self): posData = self.data[self.pos_i] if not posData.new_IDs: return - + found_cca_df_IDs = [] - for frame_i in range(posData.frame_i - 2, -1, -1): - acdc_df = posData.allData_li[frame_i]["acdc_df"] + for frame_i in range(posData.frame_i-2, -1, -1): + acdc_df = posData.allData_li[frame_i]['acdc_df'] cca_df_i = acdc_df[self.cca_df_colnames] intersect_idx = cca_df_i.index.intersection(posData.new_IDs) cca_df_i = cca_df_i.loc[intersect_idx] if cca_df_i.empty: continue found_cca_df_IDs.append(cca_df_i) - + # Remove IDs found in past frames from new_IDs list newIDs = np.array(posData.new_IDs, dtype=np.uint32) mask_index = np.in1d(newIDs, cca_df_i.index) @@ -819,9 +822,9 @@ def checkChangeMotherBudEligible(self, budID, frame_i): result = self._checkBudFutureNoDivision(budID, frame_i) if result is None: return True - + self.warnBudAnnotatedDividedInFuture( - budID, *result, action="change mother cell" + budID, *result, action='change mother cell' ) return False @@ -834,50 +837,50 @@ def checkDivisionCanBeUndone(self, ID, relID): Cell ID of the clicked cell in G1 relID : _type_ Relative ID of the cell that was clicked - + Notes ----- Division annotation can be undone only if `relID` is also in G1 for the entire duration of the correction - """ + """ posData = self.data[self.pos_i] - - ccs_relID = posData.cca_df.at[relID, "cell_cycle_stage"] - if ccs_relID == "S": + + ccs_relID = posData.cca_df.at[relID, 'cell_cycle_stage'] + if ccs_relID == 'S': return posData.frame_i - + # Check future frames - for future_i in range(posData.frame_i + 1, posData.SizeT): + for future_i in range(posData.frame_i+1, posData.SizeT): cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) if cca_df_i is None: # ith frame was not visited yet - break - - ccs_relID = cca_df_i.at[relID, "cell_cycle_stage"] - if ccs_relID == "S": + break + + ccs_relID = cca_df_i.at[relID, 'cell_cycle_stage'] + if ccs_relID == 'S': return future_i - + # Check past frames - for past_i in range(posData.frame_i - 1, -1, -1): + for past_i in range(posData.frame_i-1, -1, -1): cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) if ID not in cca_df_i.index or relID not in cca_df_i.index: # Bud did not exist at frame_i = i break - - ccs = cca_df_i.at[ID, "cell_cycle_stage"] - if ccs == "S": + + ccs = cca_df_i.at[ID, 'cell_cycle_stage'] + if ccs == 'S': break - - ccs_relID = cca_df_i.at[relID, "cell_cycle_stage"] - if ccs_relID == "S": - return future_i + + ccs_relID = cca_df_i.at[relID, 'cell_cycle_stage'] + if ccs_relID == 'S': + return future_i def checkMothEligibility(self, budID, new_mothID): """ Check that the new mother is in G1 for the entire life of the bud and that the G1 duration is > than 1 frame """ - last_cca_frame_i = self.navigateScrollBar.maximum() - 1 + last_cca_frame_i = self.navigateScrollBar.maximum()-1 posData = self.data[self.pos_i] eligible = True @@ -889,19 +892,19 @@ def checkMothEligibility(self, budID, new_mothID): if cca_df_i is None: # ith frame was not visited yet break - + if budID not in cca_df_i.index: # Bud disappeared break - is_still_bud = cca_df_i.at[budID, "relationship"] == "bud" + is_still_bud = cca_df_i.at[budID, 'relationship'] == 'bud' if not is_still_bud: break - ccs = cca_df_i.at[new_mothID, "cell_cycle_stage"] - if ccs != "G1": + ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] + if ccs != 'G1': cancel, apply = self.warnMotherNotEligible( - new_mothID, budID, future_i, "not_G1_in_the_future" + new_mothID, budID, future_i, 'not_G1_in_the_future' ) if apply: self.resetCcaFuture(future_i) @@ -911,11 +914,11 @@ def checkMothEligibility(self, budID, new_mothID): if cancel or (isG1singleFrame and isFutureFrameNotLastAnnot): eligible = False return eligible - + G1_duration_future += 1 # Check past frames - for past_i in range(posData.frame_i - 1, -1, -1): + for past_i in range(posData.frame_i-1, -1, -1): # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) @@ -926,35 +929,35 @@ def checkMothEligibility(self, budID, new_mothID): # Mother not existing because it appeared from outside FOV break - ccs = cca_df_i.at[new_mothID, "cell_cycle_stage"] - if ccs != "G1" and is_bud_existing: + ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] + if ccs != 'G1' and is_bud_existing: # Requested mother not in G1 in the past # during the life of the bud (is_bud_existing = True) self.warnMotherNotEligible( - new_mothID, budID, past_i, "not_G1_in_the_past" + new_mothID, budID, past_i, 'not_G1_in_the_past' ) eligible = False return eligible if not is_bud_existing: # Bud stop existing --> check that mother is still in G1 - if ccs != "G1": + if ccs != 'G1': eligible = False self.warnMotherNotEligible( - new_mothID, budID, past_i, "single_frame_G1_duration" + new_mothID, budID, past_i, 'single_frame_G1_duration' ) break - + return eligible def checkMothersExcludedOrDead(self): try: posData = self.data[self.pos_i] buds_df = posData.cca_df[ - (posData.cca_df.relationship == "bud") + (posData.cca_df.relationship == 'bud') & (posData.cca_df.emerg_frame_i == posData.frame_i) ] - acdc_df_i = posData.allData_li[posData.frame_i]["acdc_df"] + acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] moth_df = acdc_df_i.loc[buds_df.relative_ID.to_list()] excluded_df = moth_df[ (moth_df.is_cell_dead > 0) | (moth_df.is_cell_excluded > 0) @@ -968,11 +971,13 @@ def checkMothersExcludedOrDead(self): budIDsOfExcludedMoth, excludedMothIDs ) return proceed - except Exception: + except Exception as e: self.logger.info(traceback.format_exc()) - print("-" * 100) - self.logger.warning("Checking if mother cell is excluded or dead failed.") - print("^" * 100) + print('-'*100) + self.logger.warning( + 'Checking if mother cell is excluded or dead failed.' + ) + print('^'*100) return False def checkScellsGone(self): @@ -989,13 +994,13 @@ def checkScellsGone(self): automaticallyDividedIDs = [] mode = str(self.modeComboBox.currentText()) - if mode.find("Cell cycle") == -1: + if mode.find('Cell cycle') == -1: # No cell cycle analysis mode --> do nothing return False, automaticallyDividedIDs posData = self.data[self.pos_i] - if posData.allData_li[posData.frame_i]["labels"] is None: + if posData.allData_li[posData.frame_i]['labels'] is None: # Frame never visited/checked in segm mode --> autoCca_df will raise # a critical message return False, automaticallyDividedIDs @@ -1003,21 +1008,21 @@ def checkScellsGone(self): # Check if there are S cells that either only mother or only # bud disappeared and automatically assign division to it # or abort visiting this frame - prev_acdc_df = posData.allData_li[posData.frame_i - 1]["acdc_df"] - prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + prev_acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] prev_cca_df = prev_acdc_df[self.cca_df_colnames].copy() ScellsIDsGone = [] for ccSeries in prev_cca_df.itertuples(): ID = ccSeries.Index ccs = ccSeries.cell_cycle_stage - if ccs != "S": + if ccs != 'S': continue relID = ccSeries.relative_ID if relID == -1: continue - + # Check is relID is gone while ID stays if relID not in posData.IDs and ID in posData.IDs: ScellsIDsGone.append(relID) @@ -1029,42 +1034,48 @@ def checkScellsGone(self): self.highlightNewIDs_ccaFailed(ScellsIDsGone, rp=prev_rp) proceed = self.warnScellsGone(ScellsIDsGone, posData.frame_i) self.clearLostObjContoursItems() - + if not proceed: return True, automaticallyDividedIDs for IDgone in ScellsIDsGone: - relID = prev_cca_df.at[IDgone, "relative_ID"] + relID = prev_cca_df.at[IDgone, 'relative_ID'] self.annotateDisappearedBeforeDivision(relID, IDgone, prev_cca_df) self.annotateDivision( - prev_cca_df, IDgone, relID, frame_i=posData.frame_i - 1 + prev_cca_df, IDgone, relID, frame_i=posData.frame_i-1 ) self.annotateDivisionCurrentFrameRelativeIDgone(relID) automaticallyDividedIDs.append(relID) - - self.store_cca_df(frame_i=posData.frame_i - 1, cca_df=prev_cca_df) + + self.store_cca_df(frame_i=posData.frame_i-1, cca_df=prev_cca_df) return False, automaticallyDividedIDs def checkSwapMothersEligibility(self): posData = self.data[self.pos_i] - + lab2D = self.get_2Dlab(posData.lab) budID = lab2D[self.yClickBud, self.xClickBud] otherMothID = lab2D[self.yClickMoth, self.xClickMoth] - mothID = posData.cca_df.at[budID, "relative_ID"] - otherBudID = posData.cca_df.at[otherMothID, "relative_ID"] - + mothID = posData.cca_df.at[budID, 'relative_ID'] + otherBudID = posData.cca_df.at[otherMothID, 'relative_ID'] + for _budID in (budID, otherBudID): - result = self._checkBudFutureNoDivision(_budID, posData.frame_i) + result = self._checkBudFutureNoDivision( + _budID, posData.frame_i + ) if result is None: continue - + self.warnBudAnnotatedDividedInFuture(_budID, *result) return - - correct_pairings = {otherBudID: mothID, budID: otherMothID} - wrong_pairings = {mothID: budID, otherMothID: otherBudID} + + correct_pairings = { + otherBudID: mothID, budID: otherMothID + } + wrong_pairings = { + mothID: budID, otherMothID: otherBudID + } for correctBudID, correctMothID in correct_pairings.items(): wrongBudID = wrong_pairings[correctMothID] frame_no_G1 = self._checkMothInG1beforeBudEmergence( @@ -1072,66 +1083,23 @@ def checkSwapMothersEligibility(self): ) if frame_no_G1 is None: continue - + self.warnMotherNotAtLeastOneFrameG1( correctBudID, correctMothID, frame_no_G1 ) return - + return budID, otherBudID, otherMothID, mothID - def check_mothers_exclusion_or_dead( - self, - acdc_df: pd.DataFrame, - mother_ids: list[int], - ) -> list[int]: - """Checks tracking rules for cell exclusions or deaths.""" - if acdc_df is None or not mother_ids: - return [] - - valid_ids = [m_id for m_id in mother_ids if m_id in acdc_df.index] - if not valid_ids: - return [] - - mothers_df = acdc_df.loc[valid_ids] - excluded_mask = (mothers_df.get("is_cell_dead", 0) > 0) | ( - mothers_df.get("is_cell_excluded", 0) > 0 - ) - return mothers_df[excluded_mask].index.tolist() - def disableCcaIntegrityChecker(self): self.stopCcaIntegrityCheckerWorker() def enqCcaIntegrityChecker(self): if not self.ccaCheckerRunning: return - posData = self.data[self.pos_i] + posData = self.data[self.pos_i] self.ccaIntegrityCheckerWorker.enqueue(posData) - def evaluate_sister_relations( - self, - prev_cca_df: pd.DataFrame, - current_ids: set[int], - ) -> list[int]: - """Determines S-phase mother-bud dependencies and sister relation tracking rules.""" - if prev_cca_df is None or not current_ids: - return [] - - current_ids_set = set(current_ids) - disappeared_ids = [] - for cc_series in prev_cca_df.itertuples(): - if getattr(cc_series, "cell_cycle_stage", None) != "S": - continue - - cell_id = cc_series.Index - relative_id = getattr(cc_series, "relative_ID", -1) - if relative_id == -1: - continue - if relative_id not in current_ids_set and cell_id in current_ids_set: - disappeared_ids.append(relative_id) - - return disappeared_ids - def fixCcaDfAfterEdit(self, editTxt): posData = self.data[self.pos_i] if posData.cca_df is not None: @@ -1141,17 +1109,21 @@ def fixCcaDfAfterEdit(self, editTxt): def fixWillDivide(self, warning_txt, IDs_will_divide_wrong): self.logger.info(warning_txt) - self.logger.info("Fixing `will_divide` information...") - + self.logger.info('Fixing `will_divide` information...') + global_cca_df = self.getConcatCcaDf() - global_cca_df = global_cca_df.reset_index().set_index( - ["Cell_ID", "generation_num"] + global_cca_df = ( + global_cca_df.reset_index() + .set_index(['Cell_ID', 'generation_num']) + ) + global_cca_df.loc[IDs_will_divide_wrong, 'will_divide'] = 0 + global_cca_df = ( + global_cca_df.reset_index() + .set_index(['frame_i', 'Cell_ID']) ) - global_cca_df.loc[IDs_will_divide_wrong, "will_divide"] = 0 - global_cca_df = global_cca_df.reset_index().set_index(["frame_i", "Cell_ID"]) self.storeFromConcatCcaDf(global_cca_df) - def getBaseCca_df(self, with_tree_cols=False): + def getBaseCca_df(self, with_tree_cols=False): posData = self.data[self.pos_i] IDs = [obj.label for obj in posData.rp] cca_df = core.getBaseCca_df(IDs, with_tree_cols=with_tree_cols) @@ -1165,14 +1137,14 @@ def getConcatCcaDf(self): cca_df = self.get_cca_df(frame_i=frame_i, return_df=True) if cca_df is None: break - + cca_dfs.append(cca_df) keys.append(frame_i) - + if not cca_dfs: return - - global_cca_df = pd.concat(cca_dfs, keys=keys, names=["frame_i"]) + + global_cca_df = pd.concat(cca_dfs, keys=keys, names=['frame_i']) return global_cca_df def get_cca_df(self, frame_i=None, return_df=False, debug=False): @@ -1182,18 +1154,18 @@ def get_cca_df(self, frame_i=None, return_df=False, debug=False): posData = self.data[self.pos_i] cca_df = None i = posData.frame_i if frame_i is None else frame_i - df = posData.allData_li[i]["acdc_df"] + df = posData.allData_li[i]['acdc_df'] if df is not None: - if "cell_cycle_stage" in df.columns: + if 'cell_cycle_stage' in df.columns: cca_df = df[self.cca_df_colnames].copy() - + if cca_df is None and self.isSnapshot: cca_df = self.getBaseCca_df() posData.cca_df = cca_df if cca_df is not None: cca_df = cca_df.dropna() - + if return_df: return cca_df else: @@ -1201,18 +1173,18 @@ def get_cca_df(self, frame_i=None, return_df=False, debug=False): def get_last_cca_frame_i(self): posData = self.data[self.pos_i] - + i = 0 # Determine last annotated frame index for i, dict_frame_i in enumerate(posData.allData_li): - df = dict_frame_i["acdc_df"] + df = dict_frame_i['acdc_df'] if df is None: break - elif "cell_cycle_stage" not in df.columns: + elif 'cell_cycle_stage' not in df.columns: break - - last_cca_frame_i = i if i == 0 or i + 1 == len(posData.allData_li) else i - 1 - + + last_cca_frame_i = i if i==0 or i+1==len(posData.allData_li) else i-1 + return last_cca_frame_i def goToFrameNumber(self, frame_n): @@ -1239,7 +1211,7 @@ def handleNoCellsInG1(self, numCellsG1, numNewCells): else: notEnoughG1Cells = True proceed = False - + # Clear new cells annotations self.ccaFailedScatterItem.setData([], []) return notEnoughG1Cells, proceed @@ -1254,10 +1226,12 @@ def highlightNewCellNotEnoughG1cells(self, IDsCellsG1): continue objContours = self.getObjContours(obj) if objContours is not None: - xx = objContours[:, 0] + 0.5 - yy = objContours[:, 1] + 0.5 + xx = objContours[:,0] + 0.5 + yy = objContours[:,1] + 0.5 self.ccaFailedScatterItem.addPoints(xx, yy) - self.textAnnot[0].addObjAnnotation(obj, "green", f"{obj.label}?", False) + self.textAnnot[0].addObjAnnotation( + obj, 'green', f'{obj.label}?', False + ) def highlightNewIDs_ccaFailed(self, IDsWithIssue, rp=None): if rp is None: @@ -1268,57 +1242,53 @@ def highlightNewIDs_ccaFailed(self, IDsWithIssue, rp=None): continue self.setCcaIssueContour(obj) - @exception_handler def initCca(self): posData = self.data[self.pos_i] last_tracked_i = self.get_last_tracked_i() - defaultMode = "Viewer" + defaultMode = 'Viewer' if last_tracked_i == 0: txt = html_utils.paragraph( - "On this dataset either you never checked that the segmentation " - "and tracking are correct or you did not save yet.

" + 'On this dataset either you never checked that the segmentation ' + 'and tracking are correct or you did not save yet.

' 'If you already visited some frames with "Segmentation and Tracking" ' 'mode save data before switching to "Cell cycle analysis mode".

' - "Otherwise you first have to check (and eventually correct) some frames " + 'Otherwise you first have to check (and eventually correct) some frames ' 'in "Segmentation and Tracking" mode before proceeding ' - "with cell cycle analysis." - ) + 'with cell cycle analysis.') msg = widgets.myMessageBox() - msg.critical(self, "Tracking was never checked", txt) + msg.critical( + self, 'Tracking was never checked', txt + ) self.modeComboBox.setCurrentText(defaultMode) return proceed = True - + last_cca_frame_i = self.get_last_cca_frame_i() if last_cca_frame_i == 0: # Remove undoable actions from segmentation mode posData.UndoRedoStates[0] = [] self.undoAction.setEnabled(False) self.redoAction.setEnabled(False) - + if posData.frame_i > last_cca_frame_i: # Prompt user to go to last annotated frame msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_cca_frame_i + 1}.

+ The last annotated frame is frame {last_cca_frame_i+1}.

Do you want to restart cell cycle analysis from frame - {last_cca_frame_i + 1}?
+ {last_cca_frame_i+1}?
""") _, goToFrameButton, stayButton = msg.warning( - self, - "Go to last annotated frame?", - txt, + self, 'Go to last annotated frame?', txt, buttonsTexts=( - "Cancel", - f"Yes, go to frame {last_cca_frame_i + 1}", - "No, stay on current frame", - ), + 'Cancel', f'Yes, go to frame {last_cca_frame_i+1}', + 'No, stay on current frame') ) if goToFrameButton == msg.clickedButton: self.addMissingIDs_cca_df(posData) self.store_cca_df() - msg = "Looking good!" + msg = 'Looking good!' self.last_cca_frame_i = last_cca_frame_i posData.frame_i = last_cca_frame_i self.titleLabel.setText(msg, color=self.titleColor) @@ -1332,10 +1302,10 @@ def initCca(self): self.store_cca_df() self.initMissingFramesCca(last_cca_frame_i, posData.frame_i) last_cca_frame_i = posData.frame_i - msg = "Cell cycle analysis initialised!" - self.titleLabel.setText(msg, color="g") + msg = 'Cell cycle analysis initialised!' + self.titleLabel.setText(msg, color='g') elif msg.cancel: - msg = "Cell cycle analysis aborted." + msg = 'Cell cycle analysis aborted.' self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) self.modeComboBox.setCurrentText(defaultMode) @@ -1345,28 +1315,26 @@ def initCca(self): # Prompt user to go to last annotated frame msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_cca_frame_i + 1}.

+ The last annotated frame is frame {last_cca_frame_i+1}.

Do you want to restart cell cycle analysis from frame - {last_cca_frame_i + 1}?
+ {last_cca_frame_i+1}?
""") yesButton, noButton, _ = msg.question( - self, - "Go to last annotated frame?", - txt, - buttonsTexts=("Yes", "No", "Cancel"), + self, 'Go to last annotated frame?', txt, + buttonsTexts=('Yes', 'No', 'Cancel') ) if msg.cancel: - msg = "Cell cycle analysis aborted." + msg = 'Cell cycle analysis aborted.' self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) self.modeComboBox.setCurrentText(defaultMode) proceed = False return - + self.addMissingIDs_cca_df(posData) if msg.clickedButton == yesButton: self.addMissingIDs_cca_df(posData) - msg = "Looking good!" + msg = 'Looking good!' self.titleLabel.setText(msg, color=self.titleColor) self.last_cca_frame_i = last_cca_frame_i posData.frame_i = last_cca_frame_i @@ -1382,69 +1350,69 @@ def initCca(self): self.last_cca_frame_i = last_cca_frame_i - self.navigateScrollBar.setMaximum(last_cca_frame_i + 1) - self.navSpinBox.setMaximum(last_cca_frame_i + 1) + self.navigateScrollBar.setMaximum(last_cca_frame_i+1) + self.navSpinBox.setMaximum(last_cca_frame_i+1) self.lastTrackedFrameLabel.setText( - f"Last cc annot. frame n. = {last_cca_frame_i + 1}" + f'Last cc annot. frame n. = {last_cca_frame_i+1}' ) - + if posData.cca_df is None: posData.cca_df = self.getBaseCca_df() self.store_cca_df() - msg = "Cell cycle analysis initialized!" + msg = 'Cell cycle analysis initialized!' self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) else: self.get_cca_df() - + self.enqCcaIntegrityChecker() - + return proceed def initCcaIntegrityChecker(self): posData = self.data[self.pos_i] for frame_i, data_frame_i in enumerate(posData.allData_li): - lab = data_frame_i["labels"] + lab = data_frame_i['labels'] if lab is None: break - + cca_df = self.get_cca_df(frame_i, return_df=True) self.store_cca_df_checker(posData, frame_i, cca_df) - + self.enqCcaIntegrityChecker() def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): self.logger.info( - "Initialising cell cycle annotations of missing past frames..." + 'Initialising cell cycle annotations of missing past frames...' ) posData = self.data[self.pos_i] current_frame_i = posData.frame_i - + annotated_cca_dfs = [] - for frame_i in range(last_cca_frame_i + 1): - acdc_df = posData.allData_li[frame_i]["acdc_df"] - if "cell_cycle_stage" in acdc_df.columns: + for frame_i in range(last_cca_frame_i+1): + acdc_df = posData.allData_li[frame_i]['acdc_df'] + if 'cell_cycle_stage' in acdc_df.columns: continue - - acdc_df[self.cca_df_colnames] = "" - + + acdc_df[self.cca_df_colnames] = '' + annotated_cca_dfs = [ - posData.allData_li[i]["acdc_df"][self.cca_df_colnames] - for i in range(last_cca_frame_i + 1) + posData.allData_li[i]['acdc_df'][self.cca_df_colnames] + for i in range(last_cca_frame_i+1) ] - keys = range(last_cca_frame_i + 1) - names = ["frame_i", "Cell_ID"] + keys = range(last_cca_frame_i+1) + names = ['frame_i', 'Cell_ID'] annotated_cca_df = ( pd.concat(annotated_cca_dfs, keys=keys, names=names) .reset_index() - .set_index(["Cell_ID", "frame_i"]) + .set_index(['Cell_ID', 'frame_i']) .sort_index() ) - + last_annotated_cca_df = annotated_cca_df.groupby(level=0).last() cca_df_colnames = self.cca_df_colnames - pbar = tqdm(total=current_frame_i - last_cca_frame_i + 1, ncols=100) - for frame_i in range(last_cca_frame_i, current_frame_i + 1): + pbar = tqdm(total=current_frame_i-last_cca_frame_i+1, ncols=100) + for frame_i in range(last_cca_frame_i, current_frame_i+1): posData.frame_i = frame_i self.get_data() cca_df = self.getBaseCca_df() @@ -1462,21 +1430,21 @@ def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): def isCcaCheckerChecking(self): if not self.ccaCheckerRunning: return False - + return self.ccaIntegrityCheckerWorker.isChecking def isCurrentFrameCcaVisited(self): posData = self.data[self.pos_i] - curr_df = posData.allData_li[posData.frame_i]["acdc_df"] - return curr_df is not None and "cell_cycle_stage" in curr_df.columns + curr_df = posData.allData_li[posData.frame_i]['acdc_df'] + return curr_df is not None and 'cell_cycle_stage' in curr_df.columns def isFrameCcaAnnotated(self): posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] if acdc_df is None: return False - return "cell_cycle_stage" in acdc_df.columns + return 'cell_cycle_stage' in acdc_df.columns def isLastVisitedAgainCca(self, curr_df, enforceAll=False): # Determine if this is the last visited frame for repeating @@ -1487,33 +1455,31 @@ def isLastVisitedAgainCca(self, curr_df, enforceAll=False): posData = self.data[self.pos_i] if curr_df is None: return False - - if "cell_cycle_stage" not in curr_df.columns: + + if 'cell_cycle_stage' not in curr_df.columns: return False - + if enforceAll: return False - + lastVisited = False posData.new_IDs = [ - ID - for ID in posData.new_IDs - if curr_df.at[ID, "is_history_known"] - and curr_df.at[ID, "cell_cycle_stage"] == "S" + ID for ID in posData.new_IDs + if curr_df.at[ID, 'is_history_known'] + and curr_df.at[ID, 'cell_cycle_stage'] == 'S' ] - if posData.frame_i + 1 < posData.SizeT: - next_df = posData.allData_li[posData.frame_i + 1]["acdc_df"] + if posData.frame_i+1 < posData.SizeT: + next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] if next_df is None: lastVisited = True else: - if "cell_cycle_stage" not in next_df.columns: + if 'cell_cycle_stage' not in next_df.columns: lastVisited = True else: lastVisited = True - + return lastVisited - @exception_handler def manualCellCycleAnnotation(self, ID): """ This function is used for both annotating division or undoing the @@ -1540,25 +1506,25 @@ def manualCellCycleAnnotation(self, ID): self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) # Correct current frame - clicked_ccs = posData.cca_df.at[ID, "cell_cycle_stage"] - relID = posData.cca_df.at[ID, "relative_ID"] + clicked_ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] + relID = posData.cca_df.at[ID, 'relative_ID'] if relID not in posData.IDs: return - - if clicked_ccs == "G1" and posData.frame_i == 0: + + if clicked_ccs == 'G1' and posData.frame_i == 0: # We do not allow undoing division annotation on first frame return - if clicked_ccs == "G1": + if clicked_ccs == 'G1': issue_frame_i = self.checkDivisionCanBeUndone(ID, relID) if issue_frame_i is not None: _warnings.warnDivisionAnnotationCannotBeUndone( ID, relID, issue_frame_i, qparent=self ) return - - if clicked_ccs == "S": + + if clicked_ccs == 'S': self.annotateDivision(posData.cca_df, ID, relID) self.store_cca_df() else: @@ -1576,9 +1542,9 @@ def manualCellCycleAnnotation(self, ID): if self.ccaTableWin is not None: zoomIDs = self.getZoomIDs() self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - + # Correct future frames - for future_i in range(posData.frame_i + 1, posData.SizeT): + for future_i in range(posData.frame_i+1, posData.SizeT): cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) if cca_df_i is None: # ith frame was not visited yet @@ -1590,48 +1556,57 @@ def manualCellCycleAnnotation(self, ID): # For some reason ID disappeared from this frame continue - ccs = cca_df_i.at[ID, "cell_cycle_stage"] - relID = cca_df_i.at[ID, "relative_ID"] - if clicked_ccs == "S": - if ccs == "G1": + ccs = cca_df_i.at[ID, 'cell_cycle_stage'] + relID = cca_df_i.at[ID, 'relative_ID'] + if clicked_ccs == 'S': + if ccs == 'G1': # Cell is in G1 in the future again so stop annotating break self.annotateDivision(cca_df_i, ID, relID) - self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) - elif ccs == "S": + self.store_cca_df( + frame_i=future_i, cca_df=cca_df_i, autosave=False + ) + elif ccs == 'S': # Cell is in S in the future again so stop undoing (break) # also leave a 1 frame duration G1 to avoid a continuous # S phase self.annotateDivision(cca_df_i, ID, relID) - self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) + self.store_cca_df( + frame_i=future_i, cca_df=cca_df_i, autosave=False + ) break else: self.undoDivisionAnnotation(cca_df_i, ID, relID) - self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) - + self.store_cca_df( + frame_i=future_i, cca_df=cca_df_i, autosave=False + ) + # Correct past frames - for past_i in range(posData.frame_i - 1, -1, -1): + for past_i in range(posData.frame_i-1, -1, -1): cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) if ID not in cca_df_i.index or relID not in cca_df_i.index: # Bud did not exist at frame_i = i break self.storeUndoRedoCca(past_i, cca_df_i, undoId) - ccs = cca_df_i.at[ID, "cell_cycle_stage"] - relID = cca_df_i.at[ID, "relative_ID"] - if ccs == "S": + ccs = cca_df_i.at[ID, 'cell_cycle_stage'] + relID = cca_df_i.at[ID, 'relative_ID'] + if ccs == 'S': # We correct only those frames in which the ID was in 'G1' break else: - self.undoDivisionAnnotation(cca_df_i, ID, relID) - self.store_cca_df(frame_i=past_i, cca_df=cca_df_i, autosave=False) - + store = self.undoDivisionAnnotation(cca_df_i, ID, relID) + self.store_cca_df( + frame_i=past_i, cca_df=cca_df_i, autosave=False + ) + self.enqAutosave() def manualEditCca(self, checked=True): posData = self.data[self.pos_i] editCcaWidget = apps.editCcaTableWidget( - posData.cca_df, posData.SizeT, current_frame_i=posData.frame_i, parent=self + posData.cca_df, posData.SizeT, current_frame_i=posData.frame_i, + parent=self ) editCcaWidget.sigApplyChangesFutureFrames.connect( self.applyManualCcaChangesFutureFrames @@ -1663,61 +1638,60 @@ def nearest_point_2Dyx(self, points, all_others): # Compute i, j indexes of the absolute minimum distance i, j = np.unravel_index(dist.argmin(), dist.shape) nearest_point = all_others[j] - points[i] + point = points[i] min_dist = np.min(dist) return min_dist, nearest_point def onMotherNotInG1(self, mothID): txt = html_utils.paragraph( - f"You clicked on ID={mothID} which is NOT in G1

" - "Do you want to proceed with swapping the mother cells?

" - "NOTE: To assign a bud start by clicking on the bud " - "and release on a cell in G1" + f'You clicked on ID={mothID} which is NOT in G1

' + 'Do you want to proceed with swapping the mother cells?

' + 'NOTE: To assign a bud start by clicking on the bud ' + 'and release on a cell in G1' ) msg = widgets.myMessageBox() - swapMothersButton = widgets.reloadPushButton("Swap mother cells") + swapMothersButton = widgets.reloadPushButton('Swap mother cells') _, swapMothersButton = msg.warning( - self, - "Released on a cell NOT in G1", - txt, - buttonsTexts=("Cancel", swapMothersButton), + self, 'Released on a cell NOT in G1', txt, + buttonsTexts=('Cancel', swapMothersButton) ) if msg.cancel: return - + pairings = self.checkSwapMothersEligibility() if pairings is None: - self.logger.info("Swapping mothers is not possible.") + self.logger.info('Swapping mothers is not possible.') return - + self.swapMothers(*pairings) def reInitCca(self): if not self.isSnapshot: txt = html_utils.paragraph( - "If you decide to continue ALL cell cycle annotations from " - "this frame to the end will be erased from current session " - "(saved data is not touched of course).

" - "To annotate future frames again you will have to revisit them.

" - "Do you want to continue?" + 'If you decide to continue ALL cell cycle annotations from ' + 'this frame to the end will be erased from current session ' + '(saved data is not touched of course).

' + 'To annotate future frames again you will have to revisit them.

' + 'Do you want to continue?' ) msg = widgets.myMessageBox() msg.warning( - self, "Re-initialize annnotations?", txt, buttonsTexts=("Cancel", "Yes") + self, 'Re-initialize annnotations?', txt, + buttonsTexts=('Cancel', 'Yes') ) posData = self.data[self.pos_i] if msg.cancel: return - + # Reset all future frames - self.resetCcaFuture(posData.frame_i + 1) + self.resetCcaFuture(posData.frame_i+1) if posData.frame_i == 0: # Reset everything since we are on first frame posData.cca_df = self.getBaseCca_df() self.store_data() self.updateAllImages() - self.navigateScrollBar.setMaximum(posData.frame_i + 1) - self.navSpinBox.setMaximum(posData.frame_i + 1) + self.navigateScrollBar.setMaximum(posData.frame_i+1) + self.navSpinBox.setMaximum(posData.frame_i+1) else: # Store undo state before modifying stuff self.storeUndoRedoStates(False) @@ -1725,53 +1699,52 @@ def reInitCca(self): posData = self.data[self.pos_i] posData.cca_df = self.getBaseCca_df() self.store_data() - self.updateAllImages() + self.updateAllImages() def removeCcaAnnotationsCurrentFrame(self): posData = self.data[self.pos_i] posData.cca_df = None - - posData.allData_li[posData.frame_i].pop("cca_df", None) - posData.allData_li[posData.frame_i].pop("cca_df_checker", None) - - df = posData.allData_li[posData.frame_i]["acdc_df"] + + posData.allData_li[posData.frame_i].pop('cca_df', None) + posData.allData_li[posData.frame_i].pop('cca_df_checker', None) + + df = posData.allData_li[posData.frame_i]['acdc_df'] if df is None: # No more saved info to delete return False - if "cell_cycle_stage" not in df.columns: + if 'cell_cycle_stage' not in df.columns: # No cell cycle info present return False df = df.drop(columns=self.cca_df_colnames) - posData.allData_li[posData.frame_i]["acdc_df"] = df - + posData.allData_li[posData.frame_i]['acdc_df'] = df + return True def repeatAutoCca(self): # Do not allow automatic bud assignment if there are future # frames that already contain anotations posData = self.data[self.pos_i] - next_df = posData.allData_li[posData.frame_i + 1]["acdc_df"] + next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] if next_df is not None: - if "cell_cycle_stage" in next_df.columns: + if 'cell_cycle_stage' in next_df.columns: msg = QMessageBox() - msg.critical( - self, - "Future visited frames detected!", - "Automatic bud assignment CANNOT be performed becasue " - "there are future frames that already contain cell cycle " - "annotations. The behaviour in this case cannot be predicted.\n\n" - "We suggest assigning the bud manually OR use the " + warn_cca = msg.critical( + self, 'Future visited frames detected!', + 'Automatic bud assignment CANNOT be performed becasue ' + 'there are future frames that already contain cell cycle ' + 'annotations. The behaviour in this case cannot be predicted.\n\n' + 'We suggest assigning the bud manually OR use the ' '"Re-initialize cell cycle annotations" button which properly ' - "re-initialize future frames.", - msg.Ok, + 're-initialize future frames.', + msg.Ok ) return - correctedAssignIDs = posData.cca_df[ - posData.cca_df["corrected_on_frame_i"] >= 0 - ].index + correctedAssignIDs = ( + posData.cca_df[posData.cca_df['corrected_on_frame_i']>=0].index + ) NeverCorrectedAssignIDs = [ ID for ID in posData.new_IDs if ID not in correctedAssignIDs ] @@ -1790,15 +1763,14 @@ def repeatAutoCca(self): msg = QMessageBox() msg.setIcon(msg.Question) msg.setText( - "Do you want to automatically assign buds to mother cells for " - "ALL the new cells in this frame (excluding cells with unknown history) " - "OR only the cells where you never clicked on?" + 'Do you want to automatically assign buds to mother cells for ' + 'ALL the new cells in this frame (excluding cells with unknown history) ' + 'OR only the cells where you never clicked on?' ) msg.setDetailedText( - f"New cells that you never touched:\n\n{NeverCorrectedAssignIDs}" - ) - enforceAllButton = QPushButton("ALL new cells") - b = QPushButton("Only cells that I never corrected assignment") + f'New cells that you never touched:\n\n{NeverCorrectedAssignIDs}') + enforceAllButton = QPushButton('ALL new cells') + b = QPushButton('Only cells that I never corrected assignment') msg.addButton(b, msg.YesRole) msg.addButton(enforceAllButton, msg.NoRole) msg.exec_() @@ -1813,115 +1785,123 @@ def repeatAutoCca(self): def resetCcaFuture(self, from_frame_i): posData = self.data[self.pos_i] - self.last_cca_frame_i = from_frame_i - 1 + self.last_cca_frame_i = from_frame_i-1 self.ccaCheckerStopChecking() - - self.setNavigateScrollBarMaximum() + + self.setNavigateScrollBarMaximum() for i in range(from_frame_i, posData.SizeT): - posData.allData_li[i].pop("cca_df", None) - posData.allData_li[i].pop("cca_df_checker", None) - - df = posData.allData_li[i]["acdc_df"] + posData.allData_li[i].pop('cca_df', None) + posData.allData_li[i].pop('cca_df_checker', None) + + df = posData.allData_li[i]['acdc_df'] if df is None: # No more saved info to delete break - if "cell_cycle_stage" not in df.columns: + if 'cell_cycle_stage' not in df.columns: # No cell cycle info present continue df = df.drop(columns=self.cca_df_colnames) - posData.allData_li[i]["acdc_df"] = df - + posData.allData_li[i]['acdc_df'] = df + if posData.acdc_df is not None: frames = posData.acdc_df.index.get_level_values(0) if from_frame_i in frames: posData.acdc_df = posData.acdc_df.loc[:from_frame_i] - + self.resetWillDivideInfo() def resetFutureCcaColCurrentFrame(self): posData = self.data[self.pos_i] - - cca_df_S_mask = posData.cca_df.cell_cycle_stage == "S" - posData.cca_df.loc[cca_df_S_mask, "will_divide"] = 0 - - mothers_mask = (posData.cca_df.relationship == "mother") & cca_df_S_mask - bud_mask = posData.cca_df.relationship == "bud" - - posData.cca_df.loc[mothers_mask, "daughter_disappears_before_division"] = 0 - posData.cca_df.loc[bud_mask, "disappears_before_division"] = 0 - + + cca_df_S_mask = posData.cca_df.cell_cycle_stage == 'S' + posData.cca_df.loc[cca_df_S_mask, 'will_divide'] = 0 + + mothers_mask = ( + (posData.cca_df.relationship == 'mother') + & cca_df_S_mask + ) + bud_mask = posData.cca_df.relationship == 'bud' + + posData.cca_df.loc[mothers_mask, 'daughter_disappears_before_division'] = 0 + posData.cca_df.loc[bud_mask, 'disappears_before_division'] = 0 + cca_df = self.get_cca_df(frame_i=posData.frame_i, return_df=True) if cca_df is not None: - cca_df_S_mask = cca_df.cell_cycle_stage == "S" - cca_df.loc[cca_df_S_mask, "will_divide"] = 0 - - mothers_mask = (cca_df.relationship == "mother") & cca_df_S_mask - bud_mask = cca_df.relationship == "bud" - - cca_df.loc[mothers_mask, "daughter_disappears_before_division"] = 0 - cca_df.loc[bud_mask, "disappears_before_division"] = 0 - + cca_df_S_mask = cca_df.cell_cycle_stage == 'S' + cca_df.loc[cca_df_S_mask, 'will_divide'] = 0 + + mothers_mask = ( + (cca_df.relationship == 'mother') + & cca_df_S_mask + ) + bud_mask = cca_df.relationship == 'bud' + + cca_df.loc[mothers_mask, 'daughter_disappears_before_division'] = 0 + cca_df.loc[bud_mask, 'disappears_before_division'] = 0 + self.store_data() def resetWillDivideInfo(self): global_cca_df = self.getConcatCcaDf() if global_cca_df is None: return - + global_cca_df = load._fix_will_divide(global_cca_df) self.storeFromConcatCcaDf(global_cca_df) def setCcaIssueContour(self, obj): - objContours = self.getObjContours(obj, all_external=True) + objContours = self.getObjContours(obj, all_external=True) for cont in objContours: - xx = cont[:, 0] + 0.5 - yy = cont[:, 1] + 0.5 + xx = cont[:,0] + 0.5 + yy = cont[:,1] + 0.5 self.ax1_lostObjScatterItem.addPoints(xx, yy) - self.textAnnot[0].addObjAnnotation(obj, "lost_object", f"{obj.label}?", False) + self.textAnnot[0].addObjAnnotation( + obj, 'lost_object', f'{obj.label}?', False + ) def startBlinkingPairingItem(self, budIDs, mothIDs): self.ax1_newMothBudLinesItem.setOpacity(0.2) self.ax1_oldMothBudLinesItem.setOpacity(0.2) - + posData = self.data[self.pos_i] - acdc_df_i = posData.allData_li[posData.frame_i]["acdc_df"] - + acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] + # Blink one pairing at the time (the first found) - xc_b = acdc_df_i.loc[budIDs[0], "x_centroid"] - yc_b = acdc_df_i.loc[budIDs[0], "y_centroid"] - - xc_m = acdc_df_i.loc[mothIDs[0], "x_centroid"] - yc_m = acdc_df_i.loc[mothIDs[0], "y_centroid"] - + xc_b = acdc_df_i.loc[budIDs[0], 'x_centroid'] + yc_b = acdc_df_i.loc[budIDs[0], 'y_centroid'] + + xc_m = acdc_df_i.loc[mothIDs[0], 'x_centroid'] + yc_m = acdc_df_i.loc[mothIDs[0], 'y_centroid'] + self.warnPairingItem.setData([xc_b, xc_m], [yc_b, yc_m]) - + self.blinkPairingItemTimer = QTimer() self.blinkPairingItemTimer.flag = True self.blinkPairingItemTimer.timeout.connect(self.blinkPairingItem) self.blinkPairingItemTimer.start(300) def startCcaIntegrityCheckerWorker(self): - if not hasattr(self, "data"): + if not hasattr(self, 'data'): return - + if not self.isDataLoaded: return - + if not self.ccaIntegrCheckerToggle.isChecked(): return - + ccaCheckerThread = QThread() self.ccaCheckerMutex = QMutex() self.ccaCheckerWaitCond = QWaitCondition() - + worker = workers.CcaIntegrityCheckerWorker( self.ccaCheckerMutex, self.ccaCheckerWaitCond ) self.ccaIntegrityCheckerWorker = worker self.ccaCheckerThread = ccaCheckerThread - + worker.moveToThread(ccaCheckerThread) worker.finished.connect(ccaCheckerThread.quit) worker.finished.connect(worker.deleteLater) @@ -1933,30 +1913,30 @@ def startCcaIntegrityCheckerWorker(self): worker.finished.connect(self.ccaCheckerWorkerClosed) worker.sigWarning.connect(self.warnCcaIntegrity) worker.sigFixWillDivide.connect(self.fixWillDivide) - + ccaCheckerThread.started.connect(worker.run) ccaCheckerThread.start() - + self.ccaCheckerRunning = True - + self.initCcaIntegrityChecker() - - self.logger.info("Cell cycle annotations integrity checker started.") + + self.logger.info('Cell cycle annotations integrity checker started.') def stopBlinkingPairItem(self): self.ax1_newMothBudLinesItem.setOpacity(1.0) self.ax1_oldMothBudLinesItem.setOpacity(1.0) - + self.warnPairingItem.setData([], []) try: self.blinkPairingItemTimer.stop() - except Exception: + except Exception as e: pass def stopCcaIntegrityCheckerWorker(self): try: self.ccaIntegrityCheckerWorker._stop() - except Exception: + except Exception as err: pass def storeFromConcatCcaDf(self, global_cca_df): @@ -1964,22 +1944,17 @@ def storeFromConcatCcaDf(self, global_cca_df): for frame_i in range(0, posData.SizeT): try: cca_df = global_cca_df.loc[frame_i] - except KeyError: + except KeyError as err: break - + self.store_cca_df(frame_i=frame_i, cca_df=cca_df, autosave=False) - + self.get_cca_df() def store_cca_df( - self, - pos_i=None, - frame_i=None, - cca_df=None, - mainThread=True, - autosave=True, - store_cca_df_copy=False, - ): + self, pos_i=None, frame_i=None, cca_df=None, mainThread=True, + autosave=True, store_cca_df_copy=False + ): pos_i = self.pos_i if pos_i is None else pos_i posData = self.data[pos_i] i = posData.frame_i if frame_i is None else frame_i @@ -1988,8 +1963,8 @@ def store_cca_df( if self.ccaTableWin is not None and mainThread: zoomIDs = self.getZoomIDs() self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - acdc_df = posData.allData_li[i]["acdc_df"] + + acdc_df = posData.allData_li[i]['acdc_df'] if acdc_df is None: current_frame_i = None if frame_i is not None and frame_i != posData.frame_i: @@ -1997,27 +1972,27 @@ def store_cca_df( posData.frame_i = frame_i self.get_data() self.store_data() - acdc_df = posData.allData_li[i]["acdc_df"] + acdc_df = posData.allData_li[i]['acdc_df'] if current_frame_i is not None: # Back to current frame posData.frame_i = current_frame_i self.get_data(debug=False) - - if "cell_cycle_stage" in acdc_df.columns: + + if 'cell_cycle_stage' in acdc_df.columns: # Cell cycle info already present --> overwrite with new acdc_df[self.cca_df_colnames] = cca_df[self.cca_df_colnames] - posData.allData_li[i]["acdc_df"] = acdc_df + posData.allData_li[i]['acdc_df'] = acdc_df elif cca_df is not None: - df = acdc_df.drop(cca_df.columns, axis=1, errors="ignore") - df = df.join(cca_df, how="left") - posData.allData_li[i]["acdc_df"] = df - + df = acdc_df.drop(cca_df.columns, axis=1, errors='ignore') + df = df.join(cca_df, how='left') + posData.allData_li[i]['acdc_df'] = df + # Store copy for cca integrity worker self.store_cca_df_checker(posData, i, cca_df) - + if store_cca_df_copy and cca_df is not None: - posData.allData_li[i]["cca_df"] = cca_df.copy() - + posData.allData_li[i]['cca_df'] = cca_df.copy() + if autosave: self.enqAutosave() self.enqCcaIntegrityChecker() @@ -2025,139 +2000,143 @@ def store_cca_df( def store_cca_df_checker(self, posData, frame_i, cca_df): if not self.ccaCheckerRunning: return - + if cca_df is None: return + + posData.allData_li[frame_i]['cca_df_checker'] = cca_df.copy() - posData.allData_li[frame_i]["cca_df_checker"] = cca_df.copy() - - @exception_handler def swapMothers(self, budID, otherBudID, otherMothID, mothID): posData = self.data[self.pos_i] - + # Store cca_df for undo action undoId = uuid.uuid4() self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - + self.logger.info( - f"Swapping assignments (requested at frame n. {posData.frame_i + 1}):\n" - f" * Bud ID {budID} --> mother ID {otherMothID}\n" - f" * Bud ID {otherBudID} --> mother ID {mothID}" + f'Swapping assignments (requested at frame n. {posData.frame_i+1}):\n' + f' * Bud ID {budID} --> mother ID {otherMothID}\n' + f' * Bud ID {otherBudID} --> mother ID {mothID}' ) - - correct_pairings = {otherBudID: mothID, budID: otherMothID} - + + correct_pairings = { + otherBudID: mothID, + budID: otherMothID + } + for correct_budID, correct_mothID in correct_pairings.items(): - posData.cca_df.at[correct_budID, "relative_ID"] = correct_mothID - posData.cca_df.at[correct_mothID, "relative_ID"] = correct_budID - posData.cca_df.at[correct_budID, "corrected_on_frame_i"] = posData.frame_i - posData.cca_df.at[correct_mothID, "corrected_on_frame_i"] = posData.frame_i + posData.cca_df.at[correct_budID, 'relative_ID'] = correct_mothID + posData.cca_df.at[correct_mothID, 'relative_ID'] = correct_budID + posData.cca_df.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i + posData.cca_df.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i self.store_cca_df() - + # Correct past frames corrected_budIDs_past = set() - for past_i in range(posData.frame_i - 1, -1, -1): + for past_i in range(posData.frame_i-1, -1, -1): if len(corrected_budIDs_past) == 2: break - + for correct_budID, correct_mothID in correct_pairings.items(): # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - + if correct_budID in corrected_budIDs_past: continue if correct_budID not in cca_df_i.index: # Bud does not exist anymore in the past corrected_budIDs_past.add(correct_budID) - + if len(corrected_budIDs_past) < 2: self.restoreMotherToBeforeWrongBudWasAssignedToIt( correct_mothID, cca_df_i, past_i ) continue - - cca_df_i.at[correct_budID, "relative_ID"] = correct_mothID - cca_df_i.at[correct_mothID, "relative_ID"] = correct_budID - cca_df_i.at[correct_budID, "corrected_on_frame_i"] = posData.frame_i - cca_df_i.at[correct_mothID, "corrected_on_frame_i"] = posData.frame_i - + + cca_df_i.at[correct_budID, 'relative_ID'] = correct_mothID + cca_df_i.at[correct_mothID, 'relative_ID'] = correct_budID + cca_df_i.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i + cca_df_i.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i + # Set mother cell cycle stage to S in case it is not - if cca_df_i.at[correct_mothID, "cell_cycle_stage"] == "G1": - cca_df_i.at[correct_mothID, "cell_cycle_stage"] = "S" + if cca_df_i.at[correct_mothID, 'cell_cycle_stage'] == 'G1': + cca_df_i.at[correct_mothID, 'cell_cycle_stage'] = 'S' # cca_df_i.at[correct_mothID, 'generation_num'] -= 1 - - self.store_cca_df(frame_i=past_i, cca_df=cca_df_i, autosave=False) - + + self.store_cca_df( + frame_i=past_i, cca_df=cca_df_i, autosave=False + ) + # Correct future frames corrected_budIDs_future = set() - for future_i in range(posData.frame_i + 1, posData.SizeT): + for future_i in range(posData.frame_i+1, posData.SizeT): if len(corrected_budIDs_future) == 2: break - + # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) if cca_df_i is None: # ith frame was not visited yet break - + for correct_budID, correct_mothID in correct_pairings.items(): if correct_budID in corrected_budIDs_future: # Bud already corrected in the future continue - + if correct_budID not in cca_df_i.index: # Bud disappeared in the future corrected_budIDs_future.add(correct_budID) continue - - ccs_bud = cca_df_i.at[correct_budID, "cell_cycle_stage"] - if ccs_bud == "G1": - # Bud divided in the future, annotate division between + + ccs_bud = cca_df_i.at[correct_budID, 'cell_cycle_stage'] + if ccs_bud == 'G1': + # Bud divided in the future, annotate division between # correct mother and wrong bud and then stop correcting if correct_budID not in corrected_budIDs_future: corrected_budIDs_future.add(correct_budID) - - if len(corrected_budIDs_future) < 2: + + if len(corrected_budIDs_future) < 2: self.annotateDivisionFutureFramesSwapMothers( cca_df_i, correct_mothID, future_i ) continue - - cca_df_i.at[correct_budID, "relative_ID"] = correct_mothID - cca_df_i.at[correct_mothID, "relative_ID"] = correct_budID - cca_df_i.at[correct_budID, "corrected_on_frame_i"] = posData.frame_i - cca_df_i.at[correct_mothID, "corrected_on_frame_i"] = posData.frame_i - + + cca_df_i.at[correct_budID, 'relative_ID'] = correct_mothID + cca_df_i.at[correct_mothID, 'relative_ID'] = correct_budID + cca_df_i.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i + cca_df_i.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i + # Set mother cell cycle stage to S in case it is not - if cca_df_i.at[correct_mothID, "cell_cycle_stage"] == "G1": - cca_df_i.at[correct_mothID, "cell_cycle_stage"] = "S" + if cca_df_i.at[correct_mothID, 'cell_cycle_stage'] == 'G1': + cca_df_i.at[correct_mothID, 'cell_cycle_stage'] = 'S' # cca_df_i.at[correct_mothID, 'generation_num'] -= 1 - + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) - + self.updateAllImages() def undoBudMothAssignment(self, ID): posData = self.data[self.pos_i] - relID = posData.cca_df.at[ID, "relative_ID"] - ccs = posData.cca_df.at[ID, "cell_cycle_stage"] - if ccs == "G1": + relID = posData.cca_df.at[ID, 'relative_ID'] + ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] + if ccs == 'G1': return - posData.cca_df.at[ID, "relative_ID"] = -1 - posData.cca_df.at[ID, "generation_num"] = 2 - posData.cca_df.at[ID, "cell_cycle_stage"] = "G1" - posData.cca_df.at[ID, "relationship"] = "mother" + posData.cca_df.at[ID, 'relative_ID'] = -1 + posData.cca_df.at[ID, 'generation_num'] = 2 + posData.cca_df.at[ID, 'cell_cycle_stage'] = 'G1' + posData.cca_df.at[ID, 'relationship'] = 'mother' if relID in posData.cca_df.index: - posData.cca_df.at[relID, "relative_ID"] = -1 - posData.cca_df.at[relID, "generation_num"] = 2 - posData.cca_df.at[relID, "cell_cycle_stage"] = "G1" - posData.cca_df.at[relID, "relationship"] = "mother" + posData.cca_df.at[relID, 'relative_ID'] = -1 + posData.cca_df.at[relID, 'generation_num'] = 2 + posData.cca_df.at[relID, 'cell_cycle_stage'] = 'G1' + posData.cca_df.at[relID, 'relationship'] = 'mother' obj_idx = posData.IDs.index(ID) relObj_idx = posData.IDs.index(relID) - posData.rp[obj_idx] - posData.rp[relObj_idx] + rp_ID = posData.rp[obj_idx] + rp_relID = posData.rp[relObj_idx] self.store_cca_df() @@ -2172,126 +2151,132 @@ def undoDivisionAnnotation(self, cca_df, ID, relID): # Correct as follows: # If G1 then correct to S and -1 on generation number store = False - cca_df.at[ID, "cell_cycle_stage"] = "S" - gen_num_clickedID = cca_df.at[ID, "generation_num"] - cca_df.at[ID, "generation_num"] -= 1 - cca_df.at[ID, "division_frame_i"] = -1 - cca_df.at[relID, "cell_cycle_stage"] = "S" - gen_num_relID = cca_df.at[relID, "generation_num"] - cca_df.at[relID, "generation_num"] -= 1 - cca_df.at[relID, "division_frame_i"] = -1 + cca_df.at[ID, 'cell_cycle_stage'] = 'S' + gen_num_clickedID = cca_df.at[ID, 'generation_num'] + cca_df.at[ID, 'generation_num'] -= 1 + cca_df.at[ID, 'division_frame_i'] = -1 + cca_df.at[relID, 'cell_cycle_stage'] = 'S' + gen_num_relID = cca_df.at[relID, 'generation_num'] + cca_df.at[relID, 'generation_num'] -= 1 + cca_df.at[relID, 'division_frame_i'] = -1 if gen_num_clickedID < gen_num_relID: - cca_df.at[ID, "relationship"] = "bud" + cca_df.at[ID, 'relationship'] = 'bud' else: - cca_df.at[relID, "relationship"] = "bud" - cca_df.at[ID, "will_divide"] = 0 - cca_df.at[relID, "will_divide"] = 0 + cca_df.at[relID, 'relationship'] = 'bud' + cca_df.at[ID, 'will_divide'] = 0 + cca_df.at[relID, 'will_divide'] = 0 store = True return store def unstore_cca_df(self): posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] for col in self.cca_df_colnames: if col not in acdc_df.columns: continue acdc_df.drop(col, axis=1, inplace=True) - @disableWindow def updateCcaDfDeletedIDsTimelapse( - self, posData, relIDsOfDelIDs, deletedIDs, undoId, dropInPast, dropInFuture - ): + self, posData, relIDsOfDelIDs, deletedIDs, undoId, + dropInPast, dropInFuture + ): # Get status of the relIDs (of deleted IDs) to restore relIDsCcaStatus = {} for relID in relIDsOfDelIDs: try: - ccs = posData.cca_df.at[relID, "cell_cycle_stage"] - relationship = posData.cca_df.at[relID, "relationship"] - except Exception: + ccs = posData.cca_df.at[relID, 'cell_cycle_stage'] + relationship = posData.cca_df.at[relID, 'relationship'] + except Exception as err: continue - + ccaStatus = core.getBaseCca_df([relID]).loc[relID] - if relationship == "mother" and ccs == "S": - for past_frame_i in range(posData.frame_i - 1, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_frame_i, return_df=True) - ccs_past = cca_df_i.at[relID, "cell_cycle_stage"] - if ccs_past == "G1": + if relationship == 'mother' and ccs == 'S': + for past_frame_i in range(posData.frame_i-1, -1, -1): + cca_df_i = self.get_cca_df( + frame_i=past_frame_i, return_df=True + ) + ccs_past = cca_df_i.at[relID, 'cell_cycle_stage'] + if ccs_past == 'G1': ccaStatus = cca_df_i.loc[relID] break - + posData.cca_df.loc[relID] = ccaStatus self.store_data(autosave=False) relIDsCcaStatus[relID] = ccaStatus - - for fut_frame_i in range(posData.frame_i + 1, posData.SizeT): + + for fut_frame_i in range(posData.frame_i+1, posData.SizeT): cca_df_i = self.get_cca_df(frame_i=fut_frame_i, return_df=True) if cca_df_i is None: # ith frame was not visited yet break - + self.storeUndoRedoCca(fut_frame_i, cca_df_i, undoId) if dropInFuture: - cca_df_i = cca_df_i.drop(deletedIDs, errors="ignore") + cca_df_i = cca_df_i.drop(deletedIDs, errors='ignore') else: for delID in deletedIDs: dataDict = posData.allData_li[fut_frame_i] - delIDexists = dataDict["IDs_idxs"].get(delID, False) + delIDexists = dataDict['IDs_idxs'].get(delID, False) if not delIDexists: continue - + cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID] - + areRelIDsPresent = False for relID in relIDsOfDelIDs: try: - ccs = cca_df_i.at[relID, "cell_cycle_stage"] - relationship = cca_df_i.at[relID, "relationship"] + ccs = cca_df_i.at[relID, 'cell_cycle_stage'] + relationship = cca_df_i.at[relID, 'relationship'] ccaStatus = relIDsCcaStatus[relID] cca_df_i.loc[relID] = ccaStatus areRelIDsPresent = True - except Exception: + except Exception as err: continue - + if not areRelIDsPresent: break - - self.store_cca_df(frame_i=fut_frame_i, cca_df=cca_df_i, autosave=False) - + + self.store_cca_df( + frame_i=fut_frame_i, cca_df=cca_df_i, autosave=False + ) + # Correct past frames - for past_frame_i in range(posData.frame_i - 1, -1, -1): + for past_frame_i in range(posData.frame_i-1, -1, -1): cca_df_i = self.get_cca_df(frame_i=past_frame_i, return_df=True) if cca_df_i is None: # ith frame was not visited yet break - + self.storeUndoRedoCca(past_frame_i, cca_df_i, undoId) if dropInPast: - cca_df_i = cca_df_i.drop(deletedIDs, errors="ignore") + cca_df_i = cca_df_i.drop(deletedIDs, errors='ignore') else: for delID in deletedIDs: dataDict = posData.allData_li[past_frame_i] - delIDexists = dataDict["IDs_idxs"].get(delID, False) + delIDexists = dataDict['IDs_idxs'].get(delID, False) if not delIDexists: continue - + cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID] - + areRelIDsPresent = False for relID in relIDsOfDelIDs: try: - ccs = cca_df_i.at[relID, "cell_cycle_stage"] - relationship = cca_df_i.at[relID, "relationship"] + ccs = cca_df_i.at[relID, 'cell_cycle_stage'] + relationship = cca_df_i.at[relID, 'relationship'] ccaStatus = relIDsCcaStatus[relID] cca_df_i.loc[relID] = ccaStatus areRelIDsPresent = True - except Exception: + except Exception as err: continue - + if not areRelIDsPresent: break - - self.store_cca_df(frame_i=past_frame_i, cca_df=cca_df_i, autosave=False) + + self.store_cca_df( + frame_i=past_frame_i, cca_df=cca_df_i, autosave=False + ) def updateIsHistoryKnown(): """ @@ -2313,21 +2298,24 @@ def updateIsHistoryKnown(): pass def update_cca_df_deletedIDs( - self, posData, deletedIDs, dropInPast=True, dropInFuture=True - ): + self, posData, deletedIDs, dropInPast=True, dropInFuture=True + ): if posData.cca_df is None: return - + # Store cca_df for undo action undoId = uuid.uuid4() self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - + try: - relIDs = posData.cca_df.reindex(deletedIDs, fill_value=-1)["relative_ID"] - except KeyError: + relIDs = ( + posData.cca_df.reindex(deletedIDs, fill_value=-1) + ['relative_ID'] + ) + except KeyError as err: return - - posData.cca_df = posData.cca_df.drop(deletedIDs, errors="ignore") + + posData.cca_df = posData.cca_df.drop(deletedIDs, errors='ignore') if self.isSnapshot: self.update_cca_df_newIDs(posData, relIDs) else: @@ -2340,174 +2328,178 @@ def update_cca_df_newIDs(self, posData, new_IDs): self.addIDBaseCca_df(posData, newID) def update_cca_df_relabelling(self, posData, oldIDs, newIDs): - relIDs = posData.cca_df["relative_ID"] - posData.cca_df["relative_ID"] = relIDs.replace(oldIDs, newIDs) + relIDs = posData.cca_df['relative_ID'] + posData.cca_df['relative_ID'] = relIDs.replace(oldIDs, newIDs) mapper = dict(zip(oldIDs, newIDs)) posData.cca_df = posData.cca_df.rename(index=mapper) def update_cca_df_snapshots(self, editTxt, posData): cca_df = posData.cca_df cca_df_IDs = cca_df.index - if editTxt == "Delete ID": + if editTxt == 'Delete ID': deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, deleted_IDs) - elif editTxt == "Separate IDs": + elif editTxt == 'Separate IDs': new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] self.update_cca_df_newIDs(posData, new_IDs) deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, deleted_IDs) - elif editTxt == "Edit ID": + elif editTxt == 'Edit ID': new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] self.update_cca_df_newIDs(posData, new_IDs) old_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, old_IDs) - elif editTxt == "Annotate ID as dead": + elif editTxt == 'Annotate ID as dead': return - - elif editTxt == "Deleted non-selected objects": + + elif editTxt == 'Deleted non-selected objects': deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, deleted_IDs) - elif editTxt == "Delete ID with eraser": + elif editTxt == 'Delete ID with eraser': deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, deleted_IDs) - elif editTxt == "Add new ID with brush tool": + elif editTxt == 'Add new ID with brush tool': new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] self.update_cca_df_newIDs(posData, new_IDs) - elif editTxt == "Merge IDs": + elif editTxt == 'Merge IDs': deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, deleted_IDs) - elif editTxt == "Add new ID with curvature tool": + elif editTxt == 'Add new ID with curvature tool': new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] self.update_cca_df_newIDs(posData, new_IDs) - elif editTxt == "Delete IDs using ROI": + elif editTxt == 'Delete IDs using ROI': deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, deleted_IDs) - elif editTxt == "Repeat segmentation": + elif editTxt == 'Repeat segmentation': posData.cca_df = self.getBaseCca_df() def viewCcaTable(self): posData = self.data[self.pos_i] zoomIDs = self.getZoomIDs() - - df = posData.allData_li[posData.frame_i]["acdc_df"] + + df = posData.allData_li[posData.frame_i]['acdc_df'] current_cca_df = posData.cca_df if zoomIDs is not None: df = df.loc[zoomIDs] current_cca_df = current_cca_df.loc[zoomIDs] - + for column in current_cca_df.columns: header = ( - "================================================\n" - f"CURRENT vs STORED `{column}` column" - f"for frame number {posData.frame_i + 1}:\n" + '================================================\n' + f'CURRENT vs STORED `{column}` column' + f'for frame number {posData.frame_i+1}:\n' ) df_compare = current_cca_df[[column]].copy() - df_compare[f"STORED_{column}"] = df[column] - text = f"{header}{df_compare}" + df_compare[f'STORED_{column}'] = df[column] + text = f'{header}{df_compare}' self.logger.info(text) - - if "cell_cycle_stage" in df.columns: + + if 'cell_cycle_stage' in df.columns: cca_df = df[self.cca_df_colnames] cca_df = cca_df.merge( - current_cca_df, - how="outer", - left_index=True, - right_index=True, - suffixes=("_STORED", "_CURRENT"), + current_cca_df, how='outer', left_index=True, right_index=True, + suffixes=('_STORED', '_CURRENT') ) cca_df = cca_df.reindex(sorted(cca_df.columns), axis=1) num_cols = len(cca_df.columns) - for j in range(0, num_cols, 2): - df_j_x = cca_df.iloc[:, j] - df_j_y = cca_df.iloc[:, j + 1] - if any(df_j_x != df_j_y): - self.logger.info("------------------------") - self.logger.info("DIFFERENCES:") - diff_df = cca_df.iloc[:, j : j + 2] - diff_mask = diff_df.iloc[:, 0] != diff_df.iloc[:, 1] + for j in range(0,num_cols,2): + df_j_x = cca_df.iloc[:,j] + df_j_y = cca_df.iloc[:,j+1] + if any(df_j_x!=df_j_y): + self.logger.info('------------------------') + self.logger.info('DIFFERENCES:') + diff_df = cca_df.iloc[:,j:j+2] + diff_mask = diff_df.iloc[:,0]!=diff_df.iloc[:,1] self.logger.info(diff_df[diff_mask]) else: cca_df = None self.logger.info(cca_df) - self.logger.info("========================") + self.logger.info('========================') if current_cca_df is None: return if current_cca_df.empty: msg = widgets.myMessageBox() txt = html_utils.paragraph( - "Cell cycle annotations' table is empty.
" + 'Cell cycle annotations\' table is empty.
' ) - msg.warning(self, "Table empty", txt) + msg.warning(self, 'Table empty', txt) return - - df = posData.add_tree_cols_to_cca_df(current_cca_df, frame_i=posData.frame_i) + + df = posData.add_tree_cols_to_cca_df( + current_cca_df, frame_i=posData.frame_i + ) if self.ccaTableWin is None: self.ccaTableWin = apps.ViewCcaTableWindow(df, parent=self) self.ccaTableWin.show() self.ccaTableWin.setGeometryWindow() - self.ccaTableWin.sigUpdateCcaTable.connect(self.onSigUpdateCcaTableWindow) + self.ccaTableWin.sigUpdateCcaTable.connect( + self.onSigUpdateCcaTableWindow + ) else: self.ccaTableWin.setFocus() self.ccaTableWin.activateWindow() self.ccaTableWin.updateTable(current_cca_df) def warnBudAnnotatedDividedInFuture( - self, budID, motherID, future_division_frame_i, action="swap mother cells" - ): + self, budID, motherID, future_division_frame_i, + action='swap mother cells' + ): posData = self.data[self.pos_i] - + txt = html_utils.paragraph(f""" Bud ID {budID} is annotated as divided from mother ID {motherID} - at frame n. {future_division_frame_i + 1},
+ at frame n. {future_division_frame_i+1},
therefore it is not possible to {action}.

We recommend reinitializing cell cycle annotations on any - frame
between frames number {posData.frame_i + 1} and + frame
between frames number {posData.frame_i+1} and {future_division_frame_i} before attempting to {action}.

Thank you for your patience! """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, f"{action} not possible".title(), txt) + msg.warning(self, f'{action} not possible'.title(), txt) return def warnCcaIntegrity(self, txt, category): - self.logger.warning(f"{html_utils.to_plain_text(txt)}") - - if "disable_all" in self.disabled_cca_warnings: + self.logger.warning(f'{html_utils.to_plain_text(txt)}') + + if 'disable_all' in self.disabled_cca_warnings: return - + if category in self.disabled_cca_warnings: return - + if txt in self.disabled_cca_warnings: return - + if self.isWarningCcaIntegrity: # Some other warning is still open --> avoid opening another one return - + self.isWarningCcaIntegrity = True disabled_warning = _warnings.warn_cca_integrity( - txt, category, self, go_to_frame_callback=self.goToFrameNumber + txt, category, self, + go_to_frame_callback=self.goToFrameNumber ) if disabled_warning: self.disabled_cca_warnings.add(disabled_warning) - + self.isWarningCcaIntegrity = False def warnDeadOrExcludedMothers(self, budIDs, mothIDs): self.startBlinkingPairingItem(budIDs, mothIDs) msg = widgets.myMessageBox(wrapText=False) pairings = [ - f"Mother ID {mID} --> bud ID {bID}" for mID, bID in zip(mothIDs, budIDs) + f'Mother ID {mID} --> bud ID {bID}' + for mID, bID in zip(mothIDs, budIDs) ] txt = html_utils.paragraph(f""" The mother cell in the following mother-bud pairings @@ -2516,43 +2508,40 @@ def warnDeadOrExcludedMothers(self, budIDs, mothIDs): {html_utils.to_list(pairings)} """) msg.warning( - self, "Mother cell is excluded or dead", txt, buttonsTexts=("Cancel", "Ok") + self, 'Mother cell is excluded or dead', txt, + buttonsTexts=('Cancel', 'Ok') ) return not msg.cancel def warnEditingWithCca_df( - self, - editTxt, - return_answer=False, - get_answer=False, - get_cancelled=False, - update_images=True, - ): + self, editTxt, return_answer=False, get_answer=False, + get_cancelled=False, update_images=True + ): # Function used to warn that the user is editing in "Segmentation and # Tracking" mode a frame that contains cca annotations. - # Ask whether to remove annotations from all future frames + # Ask whether to remove annotations from all future frames if self.isSnapshot: return True posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] - + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + if acdc_df is None and self.lineage_tree is None: if update_images: self.updateAllImages() return True - + cell_cycle_stage_present = ( - acdc_df is not None and "cell_cycle_stage" in acdc_df.columns - ) + acdc_df is not None and 'cell_cycle_stage' in acdc_df.columns + ) lineage_tree_present = ( - self.lineage_tree is not None or "parent_ID_tree" in acdc_df.columns + self.lineage_tree is not None or 'parent_ID_tree' in acdc_df.columns ) if not cell_cycle_stage_present and not lineage_tree_present: if update_images: self.updateAllImages() return True - + action = self.warnEditingWithAnnotActions.get(editTxt, None) if action is not None and not action.isChecked(): # user has checked that he does not want to be asked again AND he doesnt want to delete @@ -2561,63 +2550,60 @@ def warnEditingWithCca_df( return True msg = widgets.myMessageBox() - warn_type = ( - "cell cycle annotations" - if cell_cycle_stage_present - else "lineage tree annotations" - ) + warn_type = 'cell cycle annotations' if cell_cycle_stage_present else 'lineage tree annotations' txt = html_utils.paragraph( - f"You modified a frame that has {warn_type}.

" + f'You modified a frame that has {warn_type}.

' f'The change "{editTxt}" most likely makes the ' - "annotations wrong.

" - "If you really want to apply this change we reccommend to remove" - f"ALL {warn_type}
" - "from current frame to the end.

" - "What do you want to do?" + 'annotations wrong.

' + 'If you really want to apply this change we reccommend to remove' + f'ALL {warn_type}
' + 'from current frame to the end.

' + 'What do you want to do?' ) if action is not None: - checkBox = QCheckBox("Remember my choice and do not ask again") + checkBox = QCheckBox('Remember my choice and do not ask again') else: checkBox = None - + dropDelIDsNoteText = ( - "" if editTxt.find("Delete") == -1 else " (drop removed IDs)" + '' if editTxt.find('Delete') == -1 else ' (drop removed IDs)' ) _, removeAnnotButton, _ = msg.warning( - self, - "Edited segmentation with annotations!", - txt, + self, 'Edited segmentation with annotations!', txt, buttonsTexts=( - "Cancel", - "Remove annotations from future frames (RECOMMENDED)", - f"Do not remove annotations{dropDelIDsNoteText}", - ), - widgets=checkBox, - ) + 'Cancel', + 'Remove annotations from future frames (RECOMMENDED)', + f'Do not remove annotations{dropDelIDsNoteText}' + ), widgets=checkBox + ) if msg.cancel: if get_cancelled: - return "cancelled" + return 'cancelled' removeAnnotations = False return removeAnnotations - + if action is not None: action.setChecked(not checkBox.isChecked()) action.removeAnnot = msg.clickedButton == removeAnnotButton - + if return_answer: return msg.clickedButton == removeAnnotButton - + if (msg.clickedButton == removeAnnotButton) and cell_cycle_stage_present: self.resetFutureCcaColCurrentFrame() - self.resetCcaFuture(posData.frame_i + 1) + self.resetCcaFuture(posData.frame_i+1) self.updateAllImages() elif (msg.clickedButton == removeAnnotButton) and lineage_tree_present: self.resetLin_tree_future() self.updateAllImages() else: if dropDelIDsNoteText and posData.cca_df is not None: - delIDs = [ID for ID in posData.cca_df.index if ID not in posData.IDs] - self.update_cca_df_deletedIDs(posData, delIDs, dropInPast=False) + delIDs = [ + ID for ID in posData.cca_df.index if ID not in posData.IDs + ] + self.update_cca_df_deletedIDs( + posData, delIDs, dropInPast=False + ) self.addMissingIDs_cca_df(posData) self.updateAllImages() self.store_data() @@ -2630,7 +2616,7 @@ def warnEditingWithCca_df( # self.resetLin_tree_future() # self.resetCcaFuture(posData.frame_i) # self.next_frame() - + if get_answer: return msg.clickedButton == removeAnnotButton else: @@ -2638,22 +2624,21 @@ def warnEditingWithCca_df( def warnFrameNeverVisitedSegmMode(self): msg = widgets.myMessageBox() - msg.critical( - self, - "Next frame NEVER visited", + warn_cca = msg.critical( + self, 'Next frame NEVER visited', 'Next frame was never visited in "Segmentation and Tracking"' - "mode.\n You cannot perform cell cycle analysis on frames" - "where segmentation and/or tracking errors were not" - "checked/corrected.\n\n" + 'mode.\n You cannot perform cell cycle analysis on frames' + 'where segmentation and/or tracking errors were not' + 'checked/corrected.\n\n' 'Switch to "Segmentation and Tracking" mode ' - "and check/correct next frame,\n" - "before attempting cell cycle analysis again", + 'and check/correct next frame,\n' + 'before attempting cell cycle analysis again', ) return False def warnMotherNotAtLeastOneFrameG1(self, budID, motherID, frame_no_G1): - self.data[self.pos_i] - + posData = self.data[self.pos_i] + txt = html_utils.paragraph(f""" Assigning bud ID {budID} to cell ID {motherID} cannot be done because cell ID {motherID} is not in G1 at frame n. @@ -2666,27 +2651,27 @@ def warnMotherNotAtLeastOneFrameG1(self, budID, motherID, frame_no_G1): Thank you for your patience! """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Swap mothers not possible", txt) + msg.warning(self, 'Swap mothers not possible', txt) return def warnMotherNotEligible(self, new_mothID, budID, i, why): - if why == "not_G1_in_the_future": + if why == 'not_G1_in_the_future': err_msg = html_utils.paragraph(f""" The requested cell in G1 (ID={new_mothID}) - at future frame {i + 1} has a bud assigned to it, + at future frame {i+1} has a bud assigned to it, therefore it cannot be assigned as the mother of bud ID {budID}.

You can assign a cell as the mother of bud ID {budID} only if this cell is in G1 for the entire life of the bud.

One possible solution is to click on "cancel", go to - frame {i + 1} and assign the bud of cell {new_mothID} + frame {i+1} and assign the bud of cell {new_mothID} to another cell.\n' A second solution is to assign bud ID {budID} to cell {new_mothID} anyway by clicking "Apply".

However to ensure correctness of future assignments Cell-ACDC will delete any cell cycle - information from frame {i + 1} to the end. Therefore, you + information from frame {i+1} to the end. Therefore, you will have to visit those frames again.

The deletion of cell cycle information CANNOT BE UNDONE! @@ -2694,36 +2679,39 @@ def warnMotherNotEligible(self, new_mothID, budID, i, why): Apply assignment or cancel process? """) applyButton = widgets.okPushButton(isDefault=False) - applyButton.setText("Apply and remove future annotations") + applyButton.setText('Apply and remove future annotations') msg = widgets.myMessageBox() _, applyButton = msg.warning( - self, "Cell not eligible", err_msg, buttonsTexts=("Cancel", applyButton) + self, 'Cell not eligible', err_msg, + buttonsTexts=('Cancel', applyButton) ) cancel = msg.cancel apply = msg.clickedButton == applyButton - elif why == "not_G1_in_the_past": + elif why == 'not_G1_in_the_past': err_msg = html_utils.paragraph(f""" The requested cell in G1 - (ID={new_mothID}) at past frame {i + 1} + (ID={new_mothID}) at past frame {i+1} has a bud assigned to it, therefore it cannot be assigned as mother of bud ID {budID}.
You can assign a cell as the mother of bud ID {budID} only if this cell is in G1 for the entire life of the bud.
- One possible solution is to first go to frame {i + 1} and + One possible solution is to first go to frame {i+1} and assign the bud of cell {new_mothID} to another cell. """) msg = widgets.myMessageBox() - msg.warning(self, "Cell not eligible", err_msg) + msg.warning( + self, 'Cell not eligible', err_msg + ) cancel = msg.cancel apply = False - elif why == "single_frame_G1_duration": + elif why == 'single_frame_G1_duration': err_msg = html_utils.paragraph(f""" Assigning bud ID {budID} to cell ID {new_mothID} would result in no G1 phase at all between previous cell cycle and - current cell cycle (see frame n. {i + 1}).

+ current cell cycle (see frame n. {i+1}).

The solution is to annotate division on cell ID {new_mothID} - on any frame before the frame number {i + 1}, and then + on any frame before the frame number {i+1}, and then proceed to correcting the bud assignment.

This will gurantee a G1 duration for the cell {new_mothID} @@ -2731,7 +2719,9 @@ def warnMotherNotEligible(self, new_mothID, budID, i, why): Thank you for your patience! """) msg = widgets.myMessageBox() - msg.warning(self, "Cell not eligible", err_msg) + msg.warning( + self, 'Cell not eligible', err_msg + ) cancel = msg.cancel apply = False return cancel, apply @@ -2754,10 +2744,8 @@ def warnScellsGone(self, ScellsIDsGone, frame_i): Do you want to continue? """) _, yesButton, noButton = msg.warning( - self, - 'Cells in "S/G2/M" disappeared!', - text, - buttonsTexts=("Cancel", "Yes", "No"), + self, 'Cells in "S/G2/M" disappeared!', text, + buttonsTexts=('Cancel', 'Yes', 'No') ) return msg.clickedButton == yesButton @@ -2769,4 +2757,298 @@ def warnSettingHistoryKnownCellsFirstFrame(self, ID): history status cannot be changed. """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "First frame cells", txt) + msg.warning( + self, 'First frame cells', txt + ) + + def getStatusKnownHistoryBud(self, ID): + posData = self.data[self.pos_i] + cca_df_ID = None + for i in range(posData.frame_i-1, -1, -1): + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + is_cell_existing = is_bud_existing = ID in cca_df_i.index + if not is_cell_existing: + bud_cca_dict = base_cca_dict.copy() + bud_cca_dict['cell_cycle_stage'] = 'S' + bud_cca_dict['generation_num'] = 0 + bud_cca_dict['relationship'] = 'bud' + bud_cca_dict['emerg_frame_i'] = i+1 + bud_cca_dict['is_history_known'] = True + cca_df_ID = pd.Series(bud_cca_dict) + return cca_df_ID + + def setHistoryKnowledge(self, ID, cca_df): + posData = self.data[self.pos_i] + is_history_known = cca_df.at[ID, 'is_history_known'] + if is_history_known: + cca_df.at[ID, 'is_history_known'] = False + cca_df.at[ID, 'cell_cycle_stage'] = 'G1' + cca_df.at[ID, 'generation_num'] += 2 + cca_df.at[ID, 'emerg_frame_i'] = -1 + cca_df.at[ID, 'relative_ID'] = -1 + cca_df.at[ID, 'relationship'] = 'mother' + else: + cca_df.loc[ID] = posData.ccaStatus_whenEmerged[ID] + + def annotateDivisionFutureFramesSwapMothers( + self, cca_df_at_future_division, mothIDofDisappearedBud, frame_i + ): + """This method is called as part of `guiWin.swapMothers`. + + It annotates cell division and propagates that to future frames to the + mother cell that stops having the correct bud because division between + wrong bud and other wrong mother was annotated in the future. + + Parameters + ---------- + cca_df_at_future_division : pd.DataFrame + _description_ + mothIDofDisappearedBud : int + Mother ID of the disappeared bud + frame_i : int + Frame since when the mother ID stops having the correct bud because + the correct bud was assigned as divided from the wrong mother + """ + posData = self.data[self.pos_i] + + relativeIDofMothID = cca_df_at_future_division.at[ + mothIDofDisappearedBud, 'relative_ID' + ] + if relativeIDofMothID not in cca_df_at_future_division.index: + # Also wrong bud ID disappeared + return + + relativeIDofMothIDrelationship = cca_df_at_future_division.at[ + relativeIDofMothID, 'relationship' + ] + if relativeIDofMothIDrelationship != 'bud': + # The wrong bud ID is a cell in G1 from future cycle --> + # the actual wrong bud ID disappeared too. + return + + wrongBudID = relativeIDofMothID + + self.annotateDivision( + cca_df_at_future_division, mothIDofDisappearedBud, wrongBudID, + frame_i=frame_i + ) + cca_df_at_future_division.at[ + mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i + self.store_cca_df( + frame_i=frame_i, cca_df=cca_df_at_future_division, autosave=False + ) + + ccaStatusToRestore = cca_df_at_future_division.loc[mothIDofDisappearedBud] + for future_i in range(frame_i+1, posData.SizeT): + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + break + + ccs = cca_df_i.at[mothIDofDisappearedBud, 'cell_cycle_stage'] + if ccs == 'G1': + # Mother cell in G1 again, stop correcting + break + + cca_df_i.loc[mothIDofDisappearedBud] = ccaStatusToRestore + cca_df_i.at[mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i + + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) + + def getStatus_RelID_BeforeEmergence(self, budID, curr_mothID): + posData = self.data[self.pos_i] + # Get status of the current mother before it had budID assigned to it + cca_status_before_bud_emerg = None + for i in range(posData.frame_i-1, -1, -1): + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=i, return_df=True) + + is_bud_existing = budID in cca_df_i.index + if not is_bud_existing: + # Bud was not emerged yet + if curr_mothID in cca_df_i.index: + cca_status_before_bud_emerg = cca_df_i.loc[curr_mothID] + return cca_status_before_bud_emerg + else: + # The bud emerged together with the mother because + # they appeared together from outside of the fov + # and they were trated as new IDs bud in S0 + bud_cca_dict = base_cca_dict.copy() + bud_cca_dict['cell_cycle_stage'] = 'S' + bud_cca_dict['generation_num'] = 0 + bud_cca_dict['relationship'] = 'bud' + bud_cca_dict['emerg_frame_i'] = i+1 + bud_cca_dict['is_history_known'] = True + cca_status_before_bud_emerg = pd.Series(bud_cca_dict) + return cca_status_before_bud_emerg + + # Mother did not have a status before bud emergence because it was + # already paired with bud at first frame --> reinit to default + cca_status_before_bud_emerg = ( + core.getBaseCca_df([curr_mothID]).loc[curr_mothID] + ) + return cca_status_before_bud_emerg + + def _checkBudFutureNoDivision(self, budID, start_frame_i): + posData = self.data[self.pos_i] + + future_i = start_frame_i + for future_i in range(start_frame_i, posData.SizeT): + if future_i == 0: + continue + + # Get cca_df for ith frame from allData_li + cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) + if cca_df_i is None: + # ith frame was not visited yet + return + + if budID not in cca_df_i.index: + # Bud disappears in the future --> fine + return + + ccs = cca_df_i.at[budID, 'cell_cycle_stage'] + if ccs == 'G1': + return future_i, cca_df_i.at[budID, 'relative_ID'] + + def _checkMothInG1beforeBudEmergence( + self, motherID, budID, wrongBudID, start_frame_i + ): + """Check that mother is in G1 on the frame before bud emergence + + Parameters + ---------- + motherID : int + ID of mother cell + budID : int + ID of bud + start_frame_i : int + Frame index from which to start checking in the past + """ + for past_i in range(start_frame_i, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + if budID not in cca_df_i.index: + if cca_df_i.at[motherID, 'cell_cycle_stage'] == 'G1': + return + + budID_prev_cycle = cca_df_i.at[motherID, 'relative_ID'] + if budID_prev_cycle != wrongBudID: + return past_i + 1 + + break + + def restoreMotherToBeforeWrongBudWasAssignedToIt( + self, mothIDofDisappearedBud, + cca_df_at_correct_bud_ID_disappearance, + frame_i + ): + """This method is called as part of `guiWin.swapMothers`. + + Parameters + ---------- + mothIDofDisappearedBud : int + Mother ID of the disappeared bud + cca_df_at_correct_bud_ID_disappearance : pd.DataFrame + Cell cycle annotations DataFrame when the correct bud ID stopped + existing (before emergence) + frame_i : int + Frame index when the correct bud ID stopped existing + (before emergence) + + Note + ---- + It restores the mother cell cycle annotations to the status it had + before the wrong bud was assigned to it. + + We need to do it only if the swapMothers past frames loop is still + iterating to correct the other bud. + + We also need to do this only if the wrong bud ID is actually a bud. + + When we swap mothers in the past frames it can be that the correct bud + ID stops existing (before emergence). In this case the correct mother + still has the wrong bud assigned to ID so we need to restore the status + it had before the wrong bud was assigned to it. + + To determine the status we go back until the wrong bud disappear. That + is the frame before the wrong bud was assigned to the mother we want to + correct. This is the status we want to restore. + + When we go back in time it could be that the wrong bud never disappears + becuase it is already emerged at frame 0. In this case the status we + want to restore at is the default G1 status at frame 0. + """ + relativeIDofMothID = cca_df_at_correct_bud_ID_disappearance.at[ + mothIDofDisappearedBud, 'relative_ID' + ] + if relativeIDofMothID not in cca_df_at_correct_bud_ID_disappearance.index: + # Also wrong bud ID disappeared + return + + relativeIDofMothIDrelationship = cca_df_at_correct_bud_ID_disappearance.at[ + relativeIDofMothID, 'relationship' + ] + if relativeIDofMothIDrelationship != 'bud': + # The wrong bud ID is a cell in G1 from previous cycle --> + # the actual wrong bud ID disappeared too. + return + + wrongBudID = relativeIDofMothID + + mothCcaBeforeWrongBudID = base_cca_dict + # Search in the past for status of mother before wrong bud emerged + for past_i in range(frame_i, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + if wrongBudID not in cca_df_i.index: + mothCcaBeforeWrongBudID = cca_df_i.loc[mothIDofDisappearedBud] + break + + # Restore in past frames the correct mother status + for past_i in range(frame_i, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + if wrongBudID in cca_df_i.index: + cca_df_i.loc[mothIDofDisappearedBud] = mothCcaBeforeWrongBudID + cca_df_i.at[mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i + self.store_cca_df( + frame_i=past_i, cca_df=cca_df_i, autosave=False + ) + else: + break + + def annotateDivisionCurrentFrameRelativeIDgone(self, IDwhoseRelativeIsGone): + posData = self.data[self.pos_i] + if posData.cca_df is None: + return + ID = IDwhoseRelativeIsGone + posData.cca_df.at[ID, 'generation_num'] += 1 + posData.cca_df.at[ID, 'division_frame_i'] = posData.frame_i-1 + posData.cca_df.at[ID, 'relationship'] = 'mother' + + def annotateDisappearedBeforeDivision( + self, relID, IDgone, cca_df, frame_i=None + ): + posData = self.data[self.pos_i] + gen_num = cca_df.at[relID, 'generation_num'] + if frame_i is None: + frame_i = posData.frame_i + + for past_frame_i in range(frame_i-1, -1, -1): + past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True) + if past_cca_df is None: + return + + try: + if past_cca_df.at[relID, 'generation_num'] != gen_num: + # ID is a mother and the cell cycle is finished here + return + except Exception as err: + # Bud stops existing --> stop process + return + + past_cca_df.at[IDgone, 'disappears_before_division'] = 1 + past_cca_df.at[relID, 'daughter_disappears_before_division'] = 1 + + self.store_cca_df( + cca_df=past_cca_df, frame_i=past_frame_i, autosave=False + ) diff --git a/cellacdc/mixins_bak/curvature_tools.py b/cellacdc/mixins/curvature_tools.py similarity index 68% rename from cellacdc/mixins_bak/curvature_tools.py rename to cellacdc/mixins/curvature_tools.py index ffc2fafcb..d2f549713 100644 --- a/cellacdc/mixins_bak/curvature_tools.py +++ b/cellacdc/mixins/curvature_tools.py @@ -8,21 +8,15 @@ import skimage.measure -class CurvatureToolsMixin: - """Qt-facing adapter around curvature tool contracts.""" - - """Headless spline drawing and label-painting operations.""" - - # @exec_time - - # @exec_time +class CurvatureTools: + """Extracted from guiWin.""" def clearCurvItems(self, removeItems=True): try: posData = self.data[self.pos_i] - curvItems = zip( - posData.curvPlotItems, posData.curvAnchorsItems, posData.curvHoverItems - ) + curvItems = zip(posData.curvPlotItems, + posData.curvAnchorsItems, + posData.curvHoverItems) for plotItem, curvAnchors, hoverItem in curvItems: plotItem.setData([], []) curvAnchors.setData([], []) @@ -40,25 +34,6 @@ def clearCurvItems(self, removeItems=True): # traceback.print_exc() pass - def closed_spline_coords( - self, - xx_spline, - yy_spline, - *, - anchor_xx=None, - anchor_yy=None, - predictor=None, - max_exec_time: int = 150, - ): - return closed_spline_coords( - xx_spline, - yy_spline, - anchor_xx=anchor_xx, - anchor_yy=anchor_yy, - predictor=predictor, - max_exec_time=max_exec_time, - ) - def curvToolSplineToObj(self, xxA=None, yyA=None, isRightClick=False): posData = self.data[self.pos_i] # Store undo state before modifying stuff @@ -83,12 +58,12 @@ def curvToolSplineToObj(self, xxA=None, yyA=None, isRightClick=False): if curvToolID <= 0: self.setBrushID() curvToolID = posData.brushID - + lab2D = self.get_2Dlab(posData.lab).copy() newIDMask = np.zeros(lab2D.shape, bool) rr, cc = skimage.draw.polygon(yyS, xxS, shape=lab2D.shape) newIDMask[rr, cc] = True - newIDMask[lab2D != 0] = False + newIDMask[lab2D!=0] = False lab2D[newIDMask] = curvToolID self.set_2Dlab(lab2D) self.currentLab2D = lab2D @@ -103,14 +78,11 @@ def curvTool_cb(self, checked): self.curvPlotItem = pg.PlotDataItem(pen=self.newIDs_cpen) self.curvHoverPlotItem = pg.PlotDataItem(pen=self.oldIDs_cpen) self.curvAnchors = pg.ScatterPlotItem( - symbol="o", - size=9, - brush=pg.mkBrush((255, 0, 0, 50)), - pen=pg.mkPen((255, 0, 0), width=2), - hoverable=True, - hoverPen=pg.mkPen((255, 0, 0), width=3), - hoverBrush=pg.mkBrush((255, 0, 0)), - tip=None, + symbol='o', size=9, + brush=pg.mkBrush((255,0,0,50)), + pen=pg.mkPen((255,0,0), width=2), + hoverable=True, hoverPen=pg.mkPen((255,0,0), width=3), + hoverBrush=pg.mkBrush((255,0,0)), tip=None ) self.ax1.addItem(self.curvAnchors) self.ax1.addItem(self.curvPlotItem) @@ -125,37 +97,20 @@ def curvTool_cb(self, checked): self.clearCurvItems() while self.app.overrideCursor() is not None: self.app.restoreOverrideCursor() - + self.showEditIDwidgets(checked) - def directional_coords( - self, - alfa_dir: int, - y: int, - x: int, - shape: tuple[int, int], - *, - connectivity: int = 1, - ) -> tuple[list[int], list[int]]: - return directional_coords( - alfa_dir, - y, - x, - shape, - connectivity=connectivity, - ) - def drawAutoContour(self, y2, x2): y1, x1 = self.autoCont_y0, self.autoCont_x0 - Dy = abs(y2 - y1) - Dx = abs(x2 - x1) + Dy = abs(y2-y1) + Dx = abs(x2-x1) edge = self.getDisplayedImg1() if Dy != 0 or Dx != 0: # NOTE: numIter takes care of any lag in mouseMoveEvent numIter = int(round(max((Dy, Dx)))) - alfa = np.arctan2(y1 - y2, x2 - x1) - base = np.pi / 4 - alfa_dir = round((base * round(alfa / base)) * 180 / np.pi) + alfa = np.arctan2(y1-y2, x2-x1) + base = np.pi/4 + alfa_dir = round((base * round(alfa/base))*180/np.pi) for _ in range(numIter): y1, x1 = self.autoCont_y0, self.autoCont_x0 yy, xx = self.get_dir_coords(alfa_dir, y1, x1, edge.shape) @@ -185,25 +140,29 @@ def drawAutoContour(self, y2, x2): def getClosedSplineCoords(self): xxS, yyS = self.curvPlotItem.getData() - bbox_area = (xxS.max() - xxS.min()) * (yyS.max() - yyS.min()) + bbox_area = (xxS.max()-xxS.min())*(yyS.max()-yyS.min()) if bbox_area < 26_000: # Using 1000 is fast enough according to profiling - return xxS, yyS - - optimalSpaceSize = self.splineToObjModel.predict(bbox_area, max_exec_time=150) + return xxS, yyS + + optimalSpaceSize = self.splineToObjModel.predict( + bbox_area, max_exec_time=150 + ) if optimalSpaceSize >= 1000: # Using 1000 is fast enough according to model return xxS, yyS - + if optimalSpaceSize < 100: # Do not allow a rough spline optimalSpaceSize = 100 - - # Get spline with optimal space size so that exec time + + # Get spline with optimal space size so that exec time # or skimage.draw.polygon is less than 150 ms xx, yy = self.curvAnchors.getData() resolutionSpace = np.linspace(0, 1, int(optimalSpaceSize)) - xxS, yyS = self.getSpline(xx, yy, resolutionSpace=resolutionSpace, per=True) + xxS, yyS = self.getSpline( + xx, yy, resolutionSpace=resolutionSpace, per=True + ) return xxS, yyS def getPolygonBrush(self, yxc2, Y, X): @@ -213,26 +172,26 @@ def getPolygonBrush(self, yxc2, Y, X): R = self.brushSizeSpinbox.value() r = R - arcsin_den = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) - arctan_den = x2 - x1 - if arcsin_den != 0 and arctan_den != 0: - beta = np.arcsin((R - r) / arcsin_den) - gamma = -np.arctan((y2 - y1) / arctan_den) - alpha = gamma - beta - x3 = x1 + r * np.sin(alpha) - y3 = y1 + r * np.cos(alpha) - x4 = x2 + R * np.sin(alpha) - y4 = y2 + R * np.cos(alpha) - - alpha = gamma + beta - x5 = x1 - r * np.sin(alpha) - y5 = y1 - r * np.cos(alpha) - x6 = x2 - R * np.sin(alpha) - y6 = y2 - R * np.cos(alpha) - - rr_poly, cc_poly = skimage.draw.polygon( - [y3, y4, y6, y5], [x3, x4, x6, x5], shape=(Y, X) - ) + arcsin_den = np.sqrt((x2-x1)**2+(y2-y1)**2) + arctan_den = (x2-x1) + if arcsin_den!=0 and arctan_den!=0: + beta = np.arcsin((R-r)/arcsin_den) + gamma = -np.arctan((y2-y1)/arctan_den) + alpha = gamma-beta + x3 = x1 + r*np.sin(alpha) + y3 = y1 + r*np.cos(alpha) + x4 = x2 + R*np.sin(alpha) + y4 = y2 + R*np.cos(alpha) + + alpha = gamma+beta + x5 = x1 - r*np.sin(alpha) + y5 = y1 - r*np.cos(alpha) + x6 = x2 - R*np.sin(alpha) + y6 = y2 - R*np.cos(alpha) + + rr_poly, cc_poly = skimage.draw.polygon([y3, y4, y6, y5], + [x3, x4, x6, x5], + shape=(Y, X)) else: rr_poly, cc_poly = [], [] @@ -255,7 +214,9 @@ def getSpline(self, xx, yy, resolutionSpace=None, per=False, appendFirst=False): k = 2 if len(xx) == 3 else 3 try: - tck, u = scipy.interpolate.splprep([xx, yy], s=0, k=k, per=per) + tck, u = scipy.interpolate.splprep( + [xx, yy], s=0, k=k, per=per + ) xi, yi = scipy.interpolate.splev(resolutionSpace, tck) return xi, yi except (ValueError, TypeError): @@ -264,10 +225,10 @@ def getSpline(self, xx, yy, resolutionSpace=None, per=False, appendFirst=False): def get_dir_coords(self, alfa_dir, yd, xd, shape, connectivity=1): h, w = shape - y_above = yd + 1 if yd + 1 < h else yd - y_below = yd - 1 if yd > 0 else yd - x_right = xd + 1 if xd + 1 < w else xd - x_left = xd - 1 if xd > 0 else xd + y_above = yd+1 if yd+1 < h else yd + y_below = yd-1 if yd > 0 else yd + x_right = xd+1 if xd+1 < w else xd + x_left = xd-1 if xd > 0 else xd if alfa_dir == 0: yy = [y_below, y_below, yd, y_above, y_above] xx = [xd, x_right, x_right, x_right, xd] @@ -305,12 +266,12 @@ def hoverEventDrawSpline(self, event): # If we are hovering the starting point we generate # a closed spline if len(xx) < 2: - return - - if len(hoverAnchors) > 0: + return + + if len(hoverAnchors)>0: xA_hover, yA_hover = hoverAnchors[0].pos() - if xx[0] == xA_hover and yy[0] == yA_hover: - per = True + if xx[0]==xA_hover and yy[0]==yA_hover: + per=True if per: # Append start coords and close spline xx = np.r_[xx, xx[0]] @@ -324,23 +285,6 @@ def hoverEventDrawSpline(self, event): xi, yi = self.getSpline(xx, yy, per=per) self.curvHoverPlotItem.setData(xi, yi) - def paint_spline_to_labels( - self, - labels_2d: np.ndarray, - xx_spline, - yy_spline, - label_id: int, - *, - empty_only: bool = True, - ) -> CurvatureLabelPaintResult: - return paint_spline_to_labels( - labels_2d, - xx_spline, - yy_spline, - label_id, - empty_only=empty_only, - ) - def smoothAutoContWithSpline(self, n=3): try: xx, yy = self.curvHoverPlotItem.getData() @@ -355,37 +299,11 @@ def smoothAutoContWithSpline(self, n=3): return obj = rp[0] cont = self.getObjContours(obj) - xxC, yyC = cont[:, 0], cont[:, 1] + xxC, yyC = cont[:,0], cont[:,1] xxA, yyA = xxC[::n], yyC[::n] self.xxA_autoCont, self.yyA_autoCont = xxA, yyA xxS, yyS = self.getSpline(xxA, yyA, per=True, appendFirst=True) - if len(xxS) > 0: + if len(xxS)>0: self.curvPlotItem.setData(xxS, yyS) except (TypeError, ValueError): pass - - def spline_coords( - self, - xx, - yy, - *, - resolution_space=None, - per: bool = False, - append_first: bool = False, - ): - return spline_coords( - xx, - yy, - resolution_space=resolution_space, - per=per, - append_first=append_first, - ) - - def tangent_brush_polygon( - self, - yx_start, - yx_end, - radius: int | float, - shape: tuple[int, int], - ) -> tuple[np.ndarray, np.ndarray]: - return tangent_brush_polygon(yx_start, yx_end, radius, shape) diff --git a/cellacdc/mixins_bak/custom_annotations.py b/cellacdc/mixins/custom_annotations.py similarity index 61% rename from cellacdc/mixins_bak/custom_annotations.py rename to cellacdc/mixins/custom_annotations.py index eebdb4b37..c5d4bd07b 100644 --- a/cellacdc/mixins_bak/custom_annotations.py +++ b/cellacdc/mixins/custom_annotations.py @@ -18,31 +18,26 @@ custom_annot_path = os.path.join(settings_folderpath, "custom_annotations.json") -class CustomAnnotationsMixin: - """Qt-facing adapter around custom annotation buttons and dialogs.""" +class CustomAnnotations: + """Extracted from guiWin.""" - """Headless custom annotation table updates.""" - - def addCustomAnnnotScatterPlot(self, symbolColor, symbol, toolButton): + def addCustomAnnnotScatterPlot( + self, symbolColor, symbol, toolButton + ): # Add scatter plot item symbolColorBrush = [0, 0, 0, 50] symbolColorBrush[:3] = symbolColor.getRgb()[:3] scatterPlotItem = widgets.CustomAnnotationScatterPlotItem() scatterPlotItem.setData( - [], - [], - symbol=symbol, - pxMode=False, - brush=pg.mkBrush(symbolColorBrush), - size=15, + [], [], symbol=symbol, pxMode=False, + brush=pg.mkBrush(symbolColorBrush), size=15, pen=pg.mkPen(width=3, color=symbolColor), - hoverable=True, - hoverBrush=pg.mkBrush(symbolColor), - tip=None, + hoverable=True, hoverBrush=pg.mkBrush(symbolColor), + tip=None ) scatterPlotItem.sigHovered.connect(self.customAnnotHovered) scatterPlotItem.button = toolButton - self.customAnnotDict[toolButton]["scatterPlotItem"] = scatterPlotItem + self.customAnnotDict[toolButton]['scatterPlotItem'] = scatterPlotItem self.ax1.addItem(scatterPlotItem) def addCustomAnnotButtonAllLoadedPos(self): @@ -62,51 +57,36 @@ def addCustomAnnotation(self): self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) self.addAnnotWin.exec_() if self.addAnnotWin.cancel: - self.logger.info("Custom annotation process cancelled.") + self.logger.info('Custom annotation process cancelled.') return symbol = self.addAnnotWin.symbol - symbolColor = self.addAnnotWin.state["symbolColor"] + symbolColor = self.addAnnotWin.state['symbolColor'] keySequence = self.addAnnotWin.shortcutWidget.widget.keySequence toolTip = self.addAnnotWin.toolTip - name = self.addAnnotWin.state["name"] - keepActive = self.addAnnotWin.state.get("keepActive", True) - isHideChecked = self.addAnnotWin.state.get("isHideChecked", True) - + name = self.addAnnotWin.state['name'] + keepActive = self.addAnnotWin.state.get('keepActive', True) + isHideChecked = self.addAnnotWin.state.get('isHideChecked', True) + proceed = self.checkNameExists(name) if not proceed: - self.logger.info("Custom annotation process cancelled.") + self.logger.info('Custom annotation process cancelled.') return self.addCustomAnnotationItems( - symbol, - symbolColor, - keySequence, - toolTip, - name, - keepActive, - isHideChecked, - self.addAnnotWin.state, + symbol, symbolColor, keySequence, toolTip, name, + keepActive, isHideChecked, self.addAnnotWin.state ) self.saveCustomAnnot() self.doCustomAnnotation(0) def addCustomAnnotationButton( - self, - symbol, - symbolColor, - keySequence, - toolTip, - annotName, - keepActive, - isHideChecked, - ): + self, symbol, symbolColor, keySequence, toolTip, annotName, + keepActive, isHideChecked + ): toolButton = widgets.customAnnotToolButton( - symbol, - symbolColor, - parent=self, - keepToolActive=keepActive, - isHideChecked=isHideChecked, + symbol, symbolColor, parent=self, keepToolActive=keepActive, + isHideChecked=isHideChecked ) toolButton.setCheckable(True) self.checkableQButtonsGroup.addButton(toolButton) @@ -123,29 +103,23 @@ def addCustomAnnotationButton( return toolButton, action def addCustomAnnotationItems( - self, - symbol, - symbolColor, - keySequence, - toolTip, - name, - keepActive, - isHideChecked, - state, - ): + self, symbol, symbolColor, keySequence, toolTip, name, + keepActive, isHideChecked, state + ): toolButton, action = self.addCustomAnnotationButton( - symbol, symbolColor, keySequence, toolTip, name, keepActive, isHideChecked + symbol, symbolColor, keySequence, toolTip, name, + keepActive, isHideChecked ) self.customAnnotDict[toolButton] = { - "action": action, - "state": state, - "annotatedIDs": [defaultdict(list) for _ in range(len(self.data))], + 'action': action, + 'state': state, + 'annotatedIDs': [defaultdict(list) for _ in range(len(self.data))] } # Save custom annotation to cellacdc/temp/custom_annotations.json state_to_save = state.copy() - state_to_save["symbolColor"] = tuple(symbolColor.getRgb()) + state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) self.savedCustomAnnot[name] = state_to_save for posData in self.data: posData.customAnnot[name] = state_to_save @@ -154,11 +128,11 @@ def addCustomAnnotationItems( self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) customAnnotButton = self.customAnnotDict[toolButton] - allPosAnnotatedIDs = customAnnotButton["annotatedIDs"] + allPosAnnotatedIDs = customAnnotButton['annotatedIDs'] # Add 0s column to acdc_df for pos_i, posData in enumerate(self.data): for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict["acdc_df"] + acdc_df = data_dict['acdc_df'] if acdc_df is None: continue if name not in acdc_df.columns: @@ -166,67 +140,64 @@ def addCustomAnnotationItems( else: acdc_df[name] = acdc_df[name].astype(int) acdc_df_annot = acdc_df[acdc_df[name] == 1].reset_index() - annot_IDs = acdc_df_annot["Cell_ID"].to_list() + annot_IDs = acdc_df_annot['Cell_ID'].to_list() allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs) - + if posData.acdc_df is not None: if name not in posData.acdc_df.columns: posData.acdc_df[name] = 0 else: posData.acdc_df[name] = posData.acdc_df[name].astype(int) - acdc_df_annot = posData.acdc_df[ - posData.acdc_df[name] == 1 - ].reset_index() - annot_IDs = acdc_df_annot["Cell_ID"].to_list() + acdc_df_annot = ( + posData.acdc_df[posData.acdc_df[name] == 1] + .reset_index() + ) + annot_IDs = acdc_df_annot['Cell_ID'].to_list() allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs) def addCustomAnnotationSavedPos(self, pos_i=None): if pos_i is None: pos_i = self.pos_i - + posData = self.data[pos_i] for name, annotState in posData.customAnnot.items(): # Check if button is already present and update only annotated IDs - buttons = [b for b in self.customAnnotDict.keys() if b.name == name] + buttons = [b for b in self.customAnnotDict.keys() if b.name==name] if buttons: toolButton = buttons[0] - allAnnotedIDs = self.customAnnotDict[toolButton]["annotatedIDs"] + allAnnotedIDs = self.customAnnotDict[toolButton]['annotatedIDs'] allAnnotedIDs[pos_i] = posData.customAnnotIDs.get(name, {}) continue try: - symbol = re.findall(r"\'(.+)\'", annotState["symbol"])[0] - except Exception: + symbol = re.findall(r"\'(.+)\'", annotState['symbol'])[0] + except Exception as e: self.logger.info(traceback.format_exc()) - symbol = "o" - - symbolColor = QColor(*annotState["symbolColor"]) - shortcut = annotState["shortcut"] + symbol = 'o' + + symbolColor = QColor(*annotState['symbolColor']) + shortcut = annotState['shortcut'] if shortcut is not None: keySequence = widgets.macShortcutToWindows(shortcut) keySequence = widgets.KeySequenceFromText(keySequence) else: keySequence = None toolTip = myutils.getCustomAnnotTooltip(annotState) - keepActive = annotState.get("keepActive", True) - isHideChecked = annotState.get("isHideChecked", True) + keepActive = annotState.get('keepActive', True) + isHideChecked = annotState.get('isHideChecked', True) toolButton, action = self.addCustomAnnotationButton( - symbol, - symbolColor, - keySequence, - toolTip, - name, - keepActive, - isHideChecked, + symbol, symbolColor, keySequence, toolTip, name, + keepActive, isHideChecked ) allPosAnnotIDs = [ - pos.customAnnotIDs.get(name, defaultdict(list)) for pos in self.data + pos.customAnnotIDs.get(name, defaultdict(list)) + for pos in self.data ] self.customAnnotDict[toolButton] = { - "action": action, - "state": annotState, - "annotatedIDs": allPosAnnotIDs, + 'action': action, + 'state': annotState, + 'annotatedIDs': allPosAnnotIDs } self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) @@ -239,51 +210,37 @@ def askCustomAnnotationNameExists(self, name): If you continue, this column will be used to initialize pre-annotated objects.

Do you want to continue? - """) + """ + ) noButton, yesButton = msg.question( - self, - "Custom annotation name already exists", - txt, - buttonsTexts=("No, stop process", "Yes, use existing column"), + self, 'Custom annotation name already exists', txt, + buttonsTexts=('No, stop process', 'Yes, use existing column') ) return msg.clickedButton == yesButton def checkNameExists(self, name): posData = self.data[self.pos_i] for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict["acdc_df"] + acdc_df = data_dict['acdc_df'] if acdc_df is None: continue if name in acdc_df.columns: return self.askCustomAnnotationNameExists(name) - + if posData.acdc_df is not None and name in posData.acdc_df.columns: return self.askCustomAnnotationNameExists(name) - + return True def clearCustomAnnot(self): for button in self.customAnnotDict.keys(): - scatterPlotItem = self.customAnnotDict[button]["scatterPlotItem"] + scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] scatterPlotItem.setData([], []) def clearScatterPlotCustomAnnotButton(self, button): - scatterPlotItem = self.customAnnotDict[button]["scatterPlotItem"] + scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] scatterPlotItem.setData([], []) - def column_exists( - self, - frame_records, - annotation_name: str, - *, - summary_acdc_df: pd.DataFrame | None = None, - ) -> bool: - return custom_annotation_column_exists( - frame_records, - annotation_name, - summary_acdc_df=summary_acdc_df, - ) - def customAnnotButtonToggled(self, checked): if checked: self.customAnnotButton = self.sender() @@ -294,25 +251,25 @@ def customAnnotButtonToggled(self, checked): button.toggled.disconnect() self.clearScatterPlotCustomAnnotButton(button) - button.setChecked(False) + button.setChecked(False) button.toggled.connect(self.customAnnotButtonToggled) self.doCustomAnnotation(0) else: self.customAnnotButton = None button = self.sender() clearAnnotation = ( - button.isHideChecked or not self.viewAllCustomAnnotAction.isChecked() + button.isHideChecked + or not self.viewAllCustomAnnotAction.isChecked() ) - if clearAnnotation: + if clearAnnotation: self.clearScatterPlotCustomAnnotButton(button) self.setHighlightID(False) self.resetCursor() def customAnnotHide(self, button): - self.customAnnotDict[button]["state"]["isHideChecked"] = button.isHideChecked + self.customAnnotDict[button]['state']['isHideChecked'] = button.isHideChecked clearAnnot = ( - not button.isChecked() - and button.isHideChecked + not button.isChecked() and button.isHideChecked and not self.viewAllCustomAnnotAction.isChecked() ) if clearAnnot: @@ -333,15 +290,18 @@ def customAnnotHovered(self, scatterPlotItem, points, event): x, y = point.pos().x(), point.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] - vb.setToolTip(f"Annotation name: {scatterPlotItem.button.name}\nID = {ID}") + vb.setToolTip( + f'Annotation name: {scatterPlotItem.button.name}\n' + f'ID = {ID}' + ) else: - vb.setToolTip("") + vb.setToolTip('') def customAnnotKeepActive(self, button): - self.customAnnotDict[button]["state"]["keepActive"] = button.keepToolActive + self.customAnnotDict[button]['state']['keepActive'] = button.keepToolActive def customAnnotModify(self, button): - state = self.customAnnotDict[button]["state"] + state = self.customAnnotDict[button]['state'] self.addAnnotWin = apps.customAnnotationDialog( self.savedCustomAnnot, state=state ) @@ -352,40 +312,36 @@ def customAnnotModify(self, button): # Rename column if existing posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] if acdc_df is not None: - old_name = self.customAnnotDict[button]["state"]["name"] - new_name = self.addAnnotWin.state["name"] + old_name = self.customAnnotDict[button]['state']['name'] + new_name = self.addAnnotWin.state['name'] acdc_df = acdc_df.rename(columns={old_name: new_name}) - posData.allData_li[posData.frame_i]["acdc_df"] = acdc_df + posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df - self.customAnnotDict[button]["state"] = self.addAnnotWin.state + self.customAnnotDict[button]['state'] = self.addAnnotWin.state - name = self.addAnnotWin.state["name"] + name = self.addAnnotWin.state['name'] state_to_save = self.addAnnotWin.state.copy() - symbolColor = self.addAnnotWin.state["symbolColor"] - state_to_save["symbolColor"] = tuple(symbolColor.getRgb()) + symbolColor = self.addAnnotWin.state['symbolColor'] + state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) self.savedCustomAnnot[name] = self.addAnnotWin.state self.saveCustomAnnot() symbol = self.addAnnotWin.symbol - symbolColor = self.customAnnotDict[button]["state"]["symbolColor"] + symbolColor = self.customAnnotDict[button]['state']['symbolColor'] button.setColor(symbolColor) button.update() symbolColorBrush = [0, 0, 0, 50] symbolColorBrush[:3] = symbolColor.getRgb()[:3] - scatterPlotItem = self.customAnnotDict[button]["scatterPlotItem"] + scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] xx, yy = scatterPlotItem.getData() if xx is None: xx, yy = [], [] scatterPlotItem.setData( - xx, - yy, - symbol=symbol, - pxMode=False, - brush=pg.mkBrush(symbolColorBrush), - size=15, - pen=pg.mkPen(width=3, color=symbolColor), + xx, yy, symbol=symbol, pxMode=False, + brush=pg.mkBrush(symbolColorBrush), size=15, + pen=pg.mkPen(width=3, color=symbolColor) ) def deleteSavedAnnotation(self): @@ -402,13 +358,13 @@ def deleteSelectedAnnot(self, itemsToDelete): def doCustomAnnotation(self, ID): mode = self.modeComboBox.currentText() - if not self.isSnapshot and mode != "Custom annotations": + if not self.isSnapshot and mode != 'Custom annotations': # Do not show annotations if timelapse and mode not annotations return - - if self.switchPlaneCombobox.depthAxes() != "z": + + if self.switchPlaneCombobox.depthAxes() != 'z': return - + # NOTE: pass 0 for ID to not add posData = self.data[self.pos_i] if self.viewAllCustomAnnotAction.isChecked(): @@ -419,55 +375,56 @@ def doCustomAnnotation(self, ID): else: # Annotate if the button is active or isHideChecked is False buttons = [ - b - for b in self.customAnnotDict.keys() + b for b in self.customAnnotDict.keys() if (b.isChecked() or not b.isHideChecked) ] if not buttons: return for button in buttons: - annotatedIDs = self.customAnnotDict[button]["annotatedIDs"][self.pos_i] + annotatedIDs = ( + self.customAnnotDict[button]['annotatedIDs'][self.pos_i] + ) annotIDs_frame_i = annotatedIDs.get(posData.frame_i, []) - state = self.customAnnotDict[button]["state"] - acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] - + state = self.customAnnotDict[button]['state'] + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + if button.isChecked() and ID > 0: # Annotate only if existing ID and the button is checked if ID in annotIDs_frame_i: annotIDs_frame_i.remove(ID) - acdc_df.at[ID, state["name"]] = 0 + acdc_df.at[ID, state['name']] = 0 elif ID != 0: annotIDs_frame_i.append(ID) - + annotPerButton = self.customAnnotDict[button] - allAnnotedIDs = annotPerButton["annotatedIDs"] + allAnnotedIDs = annotPerButton['annotatedIDs'] posAnnotedIDs = allAnnotedIDs[self.pos_i] posAnnotedIDs[posData.frame_i] = annotIDs_frame_i - + if acdc_df is None: self.store_data(autosave=False) - acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] - + acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + xx, yy = [], [] for annotID in annotIDs_frame_i: if annotID not in posData.IDs_idxs: continue - + obj_idx = posData.IDs_idxs[annotID] obj = posData.rp[obj_idx] - acdc_df.at[annotID, state["name"]] = 1 + acdc_df.at[annotID, state['name']] = 1 if not self.isObjVisible(obj.bbox): continue y, x = self.getObjCentroid(obj.centroid) xx.append(x) yy.append(y) - - scatterPlotItem = self.customAnnotDict[button]["scatterPlotItem"] + + scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] scatterPlotItem.setData(xx, yy) - posData.allData_li[posData.frame_i]["acdc_df"] = acdc_df - + posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df + # if self.highlightedID != 0: # self.highlightedID = 0 # self.setHighlightID(False) @@ -475,20 +432,6 @@ def doCustomAnnotation(self, ID): if buttons: return buttons[0] - def drop_column( - self, - acdc_df: pd.DataFrame | None, - annotation_name: str, - ) -> pd.DataFrame | None: - return drop_custom_annotation_column(acdc_df, annotation_name) - - def ensure_column( - self, - acdc_df: pd.DataFrame, - annotation_name: str, - ) -> CustomAnnotationColumnResult: - return ensure_custom_annotation_column(acdc_df, annotation_name) - def loadCustomAnnotations(self): items = list(self.savedCustomAnnot.keys()) if len(items) == 0: @@ -498,16 +441,14 @@ def loadCustomAnnotations(self): Click on "Add custom annotation" button to start adding new annotations. """) - msg.warning(self, "No annotations saved", txt) + msg.warning(self, 'No annotations saved', txt) return - + self.selectAnnotWin = widgets.QDialogListbox( - "Load previously used custom annotation(s)", - "Select annotations to load:", - items, - additionalButtons=("Delete selected annnotations",), - parent=self, - multiSelection=True, + 'Load previously used custom annotation(s)', + 'Select annotations to load:', items, + additionalButtons=('Delete selected annnotations', ), + parent=self, multiSelection=True ) for button in self.selectAnnotWin._additionalButtons: button.disconnect() @@ -515,76 +456,64 @@ def loadCustomAnnotations(self): self.selectAnnotWin.exec_() if self.selectAnnotWin.cancel: return - + for selectedAnnotName in self.selectAnnotWin.selectedItemsText: selectedAnnot = self.savedCustomAnnot[selectedAnnotName] - symbol = selectedAnnot["symbol"] + symbol = selectedAnnot['symbol'] symbol = re.findall(r"\'(.+)\'", symbol)[0] - symbolColor = selectedAnnot["symbolColor"] + symbolColor = selectedAnnot['symbolColor'] symbolColor = pg.mkColor(symbolColor) - keySequence = widgets.KeySequenceFromText(selectedAnnot["shortcut"]) - Type = selectedAnnot["type"] + keySequence = widgets.KeySequenceFromText(selectedAnnot['shortcut']) + Type = selectedAnnot['type'] toolTip = ( - f"Name: {selectedAnnotName}\n\n" - f"Type: {Type}\n\n" - f"Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n" - f"Description: {selectedAnnot['description']}\n\n" + f'Name: {selectedAnnotName}\n\n' + f'Type: {Type}\n\n' + f'Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n' + f'Description: {selectedAnnot["description"]}\n\n' f'Shortcut: "{keySequence}"' ) - keepActive = selectedAnnot["keepActive"] - isHideChecked = selectedAnnot["isHideChecked"] + keepActive = selectedAnnot['keepActive'] + isHideChecked = selectedAnnot['isHideChecked'] state = { - "type": Type, - "name": selectedAnnotName, - "symbol": selectedAnnot["symbol"], - "shortcut": selectedAnnot["shortcut"], - "description": selectedAnnot["description"], - "keepActive": keepActive, - "isHideChecked": isHideChecked, - "symbolColor": symbolColor, + 'type': Type, + 'name': selectedAnnotName, + 'symbol': selectedAnnot['symbol'], + 'shortcut': selectedAnnot['shortcut'], + 'description': selectedAnnot["description"], + 'keepActive': keepActive, + 'isHideChecked': isHideChecked, + 'symbolColor': symbolColor } self.addCustomAnnotationItems( - symbol, - symbolColor, - keySequence, - toolTip, - selectedAnnotName, - keepActive, - isHideChecked, - state, + symbol, symbolColor, keySequence, toolTip, selectedAnnotName, + keepActive, isHideChecked, state ) for pos_i, posData in enumerate(self.data): posData.customAnnot[selectedAnnotName] = selectedAnnot - + self.saveCustomAnnot() def readSavedCustomAnnot(self): tempAnnot = {} if os.path.exists(custom_annot_path): - self.logger.info("Loading saved custom annotations...") - tempAnnot = load.read_json(custom_annot_path, logger_func=self.logger.info) + self.logger.info('Loading saved custom annotations...') + tempAnnot = load.read_json( + custom_annot_path, logger_func=self.logger.info + ) posData = self.data[self.pos_i] self.savedCustomAnnot = tempAnnot for pos_i, posData in enumerate(self.data): - self.savedCustomAnnot = {**self.savedCustomAnnot, **posData.customAnnot} - - def read_saved_annotations( - self, - annotations_path: str, - *, - logger_func=None, - ) -> dict: - if not os.path.exists(annotations_path): - return {} - return load.read_json(annotations_path, logger_func=logger_func) + self.savedCustomAnnot = { + **self.savedCustomAnnot, **posData.customAnnot + } def reinitCustomAnnot(self): buttons = list(self.customAnnotDict.keys()) for button in buttons: self.clearScatterPlotCustomAnnotButton(button) - action = self.customAnnotDict[button]["action"] + action = self.customAnnotDict[button]['action'] self.annotateToolbar.removeAction(action) self.checkableQButtonsGroup.removeButton(button) self.customAnnotDict.pop(button) @@ -592,13 +521,6 @@ def reinitCustomAnnot(self): self.saveCustomAnnot(only_temp=True) - def remap_ids(self, annotated_ids_by_frame, old_ids, new_ids) -> dict: - return remap_custom_annotation_ids( - annotated_ids_by_frame, - old_ids, - new_ids, - ) - def removeCustomAnnotButton(self, button, askHow=True, save=True): if askHow: msg = widgets.myMessageBox() @@ -607,48 +529,47 @@ def removeCustomAnnotButton(self, button, askHow=True, save=True): only the annotation button?
""") _, removeOnlyButton, removeColButton = msg.question( - self, - "Remove only button?", - txt, + self, 'Remove only button?', txt, buttonsTexts=( - "Cancel", - "Remove only button", - " Remove also column with annotations ", - ), + 'Cancel', 'Remove only button', + ' Remove also column with annotations ' + ) ) if msg.cancel: return removeOnlyButton = msg.clickedButton == removeOnlyButton else: removeOnlyButton = True - - name = self.customAnnotDict[button]["state"]["name"] + + name = self.customAnnotDict[button]['state']['name'] # remove annotation from position for posData in self.data: try: posData.customAnnot.pop(name) posData.saveCustomAnnotationParams() - except KeyError: + except KeyError as e: # Current pos doesn't have any annotation button. Continue continue if posData.acdc_df is None: continue - + if removeOnlyButton: continue - posData.acdc_df = posData.acdc_df.drop(columns=name, errors="ignore") + posData.acdc_df = posData.acdc_df.drop( + columns=name, errors='ignore' + ) for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict["acdc_df"] + acdc_df = data_dict['acdc_df'] if acdc_df is None: continue - acdc_df = acdc_df.drop(columns=name, errors="ignore") - posData.allData_li[frame_i]["acdc_df"] = acdc_df + acdc_df = acdc_df.drop(columns=name, errors='ignore') + posData.allData_li[frame_i]['acdc_df'] = acdc_df self.clearScatterPlotCustomAnnotButton(button) - action = self.customAnnotDict[button]["action"] + action = self.customAnnotDict[button]['action'] self.annotateToolbar.removeAction(action) self.checkableQButtonsGroup.removeButton(button) self.customAnnotDict.pop(button) @@ -656,55 +577,25 @@ def removeCustomAnnotButton(self, button, askHow=True, save=True): self.saveCustomAnnot(only_temp=True) - def rename_column( - self, - acdc_df: pd.DataFrame | None, - old_name: str, - new_name: str, - ) -> pd.DataFrame | None: - return rename_custom_annotation_column(acdc_df, old_name, new_name) - def saveCustomAnnot(self, only_temp=False): - if not hasattr(self, "savedCustomAnnot"): + if not hasattr(self, 'savedCustomAnnot'): return if not self.savedCustomAnnot: return # Save to cell acdc temp path - with open(custom_annot_path, mode="w") as file: + with open(custom_annot_path, mode='w') as file: json.dump(self.savedCustomAnnot, file, indent=2) if only_temp: return - - self.logger.info("Saving custom annotations parameters...") + + self.logger.info('Saving custom annotations parameters...') # Save to pos path for _posData in self.data: _posData.saveCustomAnnotationParams() - def tooltip(self, annotation_state: dict) -> str: - return myutils.getCustomAnnotTooltip(annotation_state) - - def update_frame( - self, - acdc_df: pd.DataFrame, - annotation_name: str, - annotated_ids, - *, - clicked_id: int = 0, - click_is_active: bool = False, - existing_ids=None, - ) -> CustomAnnotationFrameUpdate: - return update_custom_annotation_frame( - acdc_df, - annotation_name, - annotated_ids, - clicked_id=clicked_id, - click_is_active=click_is_active, - existing_ids=existing_ids, - ) - def viewAllCustomAnnot(self, checked): if not checked: # Clear all annotations before showing only checked diff --git a/cellacdc/mixins_bak/data_loading.py b/cellacdc/mixins/data_loading.py similarity index 64% rename from cellacdc/mixins_bak/data_loading.py rename to cellacdc/mixins/data_loading.py index bd51c8eac..1e7c5990f 100644 --- a/cellacdc/mixins_bak/data_loading.py +++ b/cellacdc/mixins/data_loading.py @@ -39,41 +39,41 @@ GREEN_HEX = _palettes.green() -class DataLoadingMixin: - """Qt-facing adapter for data loading and recovery workflows.""" +class DataLoading: + """Extracted from guiWin.""" - """Headless data-loading rules and path plans.""" - - @exception_handler def _createEmptyData(self): self.MostRecentPath = self.getMostRecentPath() exp_path = QFileDialog.getExistingDirectory( self, - "Select experiment folder where to create empty data", - self.MostRecentPath, + 'Select experiment folder where to create empty data', + self.MostRecentPath ) if not exp_path: return - - pos_path = os.path.join(exp_path, "Position_1") - images_path = os.path.join(pos_path, "Images") + + pos_path = os.path.join(exp_path, 'Position_1') + images_path = os.path.join(pos_path, 'Images') if os.path.exists(images_path): raise FileExistsError(f'The following path already exists "{images_path}"') os.makedirs(images_path, exist_ok=True) - - basename = "test_empty_" - tif_filename = f"{basename}channel_1.tif" + + basename = 'test_empty_' + tif_filename = f'{basename}channel_1.tif' tif_filepath = os.path.join(images_path, tif_filename) - empty_img = np.zeros((256, 256), dtype=np.uint8) - empty_img[0, 0] = 255 + empty_img = np.zeros((256,256), dtype=np.uint8) + empty_img[0,0] = 255 skimage.io.imsave(tif_filepath, empty_img) - - metadata_filename = f"{basename}metadata.csv" + + metadata_filename = f'{basename}metadata.csv' metadata_filepath = os.path.join(images_path, metadata_filename) - df_metadata = pd.DataFrame({"Description": ["basename"], "values": [basename]}) + df_metadata = pd.DataFrame({ + 'Description': ['basename'], + 'values': [basename] + }) df_metadata.to_csv(metadata_filepath, index=False) - + self.isNewFile = True self._openFolder(exp_path=images_path) @@ -95,10 +95,9 @@ def _loadFromExperimentFolder(self, exp_path): images_paths = [] for pos in select_folder.selected_pos: - images_paths.append(os.path.join(exp_path, pos, "Images")) + images_paths.append(os.path.join(exp_path, pos, 'Images')) return images_paths - @exception_handler def _openFile(self, file_path=None): """ Function used for loading an image file directly. @@ -106,95 +105,101 @@ def _openFile(self, file_path=None): if file_path is None: self.MostRecentPath = self.getMostRecentPath() file_path = QFileDialog.getOpenFileName( - self, - "Select image file", - self.MostRecentPath, + self, 'Select image file', self.MostRecentPath, "Image/Video Files (*.png *.tif *.tiff *.jpg *.jpeg *.mov *.avi *.mp4)" - ";;All Files (*)", - )[0] + ";;All Files (*)")[0] if not file_path: return - + filename, ext = os.path.splitext(os.path.basename(file_path)) ext = ext.lower() dirpath = os.path.dirname(file_path) dirname = os.path.basename(dirpath) - filename = filename.rstrip("_") + filename = filename.rstrip('_') channel_name = None do_copy = True - if dirname != "Images": - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - acdc_folder = f"{timestamp}_acdc" - exp_path = os.path.join(dirpath, acdc_folder, "Images") + if dirname != 'Images': + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + acdc_folder = f'{timestamp}_acdc' + exp_path = os.path.join(dirpath, acdc_folder, 'Images') proceed, do_copy = self.warnUserCreationImagesFolder(exp_path, ext) if not proceed: - self.logger.info("Loading image file cancelled.") + self.logger.info('Loading image file cancelled.') return - - proceed, channel_name = self.askUserChannelName(filename, ".tif") + + proceed, channel_name = self.askUserChannelName( + filename, '.tif' + ) if not proceed: - self.logger.info("Loading image file cancelled.") + self.logger.info('Loading image file cancelled.') return - + os.makedirs(exp_path, exist_ok=True) else: exp_path = dirpath if channel_name is not None: # Check if user wants to use the existing channel name - underscore_splits = filename.split("_") + underscore_splits = filename.split('_') if len(underscore_splits) > 1: default_ch_name = underscore_splits[-1] if channel_name == default_ch_name: - filename = "_".join(underscore_splits[:-1]) - - basename = f"{filename}_" - new_filename = f"{filename}_{channel_name}{ext}" - df_metadata = pd.DataFrame( - {"Description": ["basename"], "values": [basename]} + filename = '_'.join(underscore_splits[:-1]) + + basename = f'{filename}_' + new_filename = f'{filename}_{channel_name}{ext}' + df_metadata = pd.DataFrame({ + 'Description': ['basename'], + 'values': [basename] + }) + metadata_csv_filename = f'{basename}metadata.csv' + metadata_csv_filepath = os.path.join( + exp_path, metadata_csv_filename ) - metadata_csv_filename = f"{basename}metadata.csv" - metadata_csv_filepath = os.path.join(exp_path, metadata_csv_filename) df_metadata.to_csv(metadata_csv_filepath, index=False) else: - new_filename = f"{filename}{ext}" - + new_filename = f'{filename}{ext}' + if do_copy: - action_text = "Copying" + action_text = 'Copying' else: - action_text = "Moving" - - if ext == ".tif" or ext == ".npz": + action_text = 'Moving' + + if ext == '.tif' or ext == '.npz': new_filepath = os.path.join(exp_path, new_filename) if not os.path.exists(new_filepath): - self.logger.info(f"{action_text} file to Images folder...") + self.logger.info(f'{action_text} file to Images folder...') if do_copy: shutil.copy2(file_path, new_filepath) else: shutil.move(file_path, new_filepath) self._openFolder(exp_path=exp_path, imageFilePath=new_filepath) else: - self.logger.info(f"{action_text} file to .tif format...") - data = load.loadData(file_path, "", log_func=self.logger.info) + self.logger.info(f'{action_text} file to .tif format...') + data = load.loadData(file_path, '', log_func=self.logger.info) data.loadImgData() img = data.img_data if img.ndim == 3 and (img.shape[-1] == 3 or img.shape[-1] == 4): - self.logger.info("Converting RGB image to grayscale...") + self.logger.info('Converting RGB image to grayscale...') if img.shape[-1] == 3: data.img_data = skimage.color.rgb2gray(data.img_data) else: - data.img_data = cv2.cvtColor(data.img_data, cv2.COLOR_RGBA2GRAY) + data.img_data = cv2.cvtColor( + data.img_data, cv2.COLOR_RGBA2GRAY + ) data.img_data = skimage.img_as_ubyte(data.img_data) new_filename_no_ext, ext = os.path.splitext(new_filename) - tif_filename = f"{new_filename_no_ext}.tif" + tif_filename = f'{new_filename_no_ext}.tif' tif_path = os.path.join(exp_path, tif_filename) if data.img_data.ndim == 3: - data.img_data.shape[0] + SizeT = data.img_data.shape[0] + SizeZ = 1 elif data.img_data.ndim == 4: - data.img_data.shape[0] - data.img_data.shape[1] + SizeT = data.img_data.shape[0] + SizeZ = data.img_data.shape[1] else: - pass + SizeT = 1 + SizeZ = 1 is_imageJ_dtype = ( data.img_data.dtype == np.uint8 or data.img_data.dtype == np.uint32 @@ -207,8 +212,9 @@ def _openFile(self, file_path=None): myutils.to_tiff(tif_path, data.img_data) self._openFolder(exp_path=exp_path, imageFilePath=tif_path) - @exception_handler - def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): + def _openFolder( + self, checked=False, exp_path=None, imageFilePath='' + ): """Main function to load data. Parameters @@ -231,9 +237,9 @@ def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): self.MostRecentPath = self.getMostRecentPath() exp_path = QFileDialog.getExistingDirectory( self, - "Select experiment folder containing Position_n folders " - "or specific Position_n folder", - self.MostRecentPath, + 'Select experiment folder containing Position_n folders ' + 'or specific Position_n folder', + self.MostRecentPath ) if not exp_path: @@ -254,18 +260,18 @@ def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): self.ccaTableWin.close() self.exp_path = exp_path - self.logger.info(f"Loading from {self.exp_path}") + self.logger.info(f'Loading from {self.exp_path}') self.addToRecentPaths(exp_path, logger=self.logger) self.addPathToOpenRecentMenu(exp_path) folder_type = myutils.determine_folder_type(exp_path) is_pos_folder, is_images_folder, exp_path = folder_type - self.titleLabel.setText("Loading data...", color=self.titleColor) + self.titleLabel.setText('Loading data...', color=self.titleColor) skip_channels = [] ch_name_selector = prompts.select_channel_name( - which_channel="segm", allow_abort=False + which_channel='segm', allow_abort=False ) user_ch_name = None if not is_pos_folder and not is_images_folder and not imageFilePath: @@ -277,23 +283,23 @@ def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): elif is_pos_folder and not imageFilePath: pos_foldername = os.path.basename(exp_path) exp_path = os.path.dirname(exp_path) - images_paths = [os.path.join(exp_path, pos_foldername, "Images")] + images_paths = [os.path.join(exp_path, pos_foldername, 'Images')] elif is_images_folder and not imageFilePath: images_paths = [exp_path] pos_path = os.path.dirname(exp_path) exp_path = os.path.dirname(pos_path) - + elif imageFilePath: # images_path = exp_path because called by openFile func filenames = myutils.listdir(exp_path) - ch_names, basenameNotFound = ch_name_selector.get_available_channels( - filenames, exp_path + ch_names, basenameNotFound = ( + ch_name_selector.get_available_channels(filenames, exp_path) ) filename = os.path.basename(imageFilePath) self.ch_names = ch_names user_ch_name = [ - chName for chName in ch_names if filename.find(chName) != -1 + chName for chName in ch_names if filename.find(chName)!=-1 ][0] images_paths = [exp_path] pos_path = os.path.dirname(exp_path) @@ -314,14 +320,16 @@ def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): self.criticalNoTifFound(images_path) return if len(ch_names) > 1: - CbLabel = "Select channel name to load: " - ch_name_selector.QtPrompt(self, ch_names, CbLabel=CbLabel) + CbLabel='Select channel name to load: ' + ch_name_selector.QtPrompt( + self, ch_names, CbLabel=CbLabel + ) if ch_name_selector.was_aborted: self.openFolderAction.setEnabled(True) return - skip_channels.extend( - [ch for ch in ch_names if ch != ch_name_selector.channel_name] - ) + skip_channels.extend([ + ch for ch in ch_names if ch!=ch_name_selector.channel_name + ]) else: ch_name_selector.channel_name = ch_names[0] ch_name_selector.setUserChannelName() @@ -331,14 +339,11 @@ def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): ch_name_selector.channel_name = user_ch_name user_ch_file_paths = [] - not_allowed_ends = ["btrack_tracks.h5"] + not_allowed_ends = ['btrack_tracks.h5'] for images_path in self.images_paths: channel_file_path = load.get_filename_from_channel( - images_path, - user_ch_name, - skip_channels=skip_channels, - not_allowed_ends=not_allowed_ends, - logger=self.logger.info, + images_path, user_ch_name, skip_channels=skip_channels, + not_allowed_ends=not_allowed_ends, logger=self.logger.info ) if not channel_file_path: self.criticalImgPathNotFound(images_path) @@ -357,7 +362,7 @@ def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): self.gui_createOverlayColors() self.gui_createOverlayItems() lastRow = self.bottomLeftLayout.rowCount() - self.bottomLeftLayout.setRowStretch(lastRow + 1, 1) + self.bottomLeftLayout.setRowStretch(lastRow+1, 1) self.num_pos = len(user_ch_file_paths) proceed = self.loadSelectedData(user_ch_file_paths, user_ch_name) @@ -373,11 +378,11 @@ def addToRecentPaths(self, path, logger=None): def askMismatchSegmDataShape(self, posData): msg = widgets.myMessageBox(wrapText=False) - title = "Segm. data shape mismatch" - f = "3D" if self.isSegm3D else "2D" - f = f"{f} over time" if posData.SizeT > 1 else f - r = "2D" if self.isSegm3D else "3D" - r = f"{r} over time" if posData.SizeT > 1 else r + title = 'Segm. data shape mismatch' + f = '3D' if self.isSegm3D else '2D' + f = f'{f} over time' if posData.SizeT > 1 else f + r = '2D' if self.isSegm3D else '3D' + r = f'{r} over time' if posData.SizeT > 1 else r text = html_utils.paragraph(f""" The segmentation masks of the first Position that you loaded is {f},
@@ -387,24 +392,27 @@ def askMismatchSegmDataShape(self, posData): Do you want to skip loading this position or cancel the process? """) _, skipPosButton = msg.warning( - self, title, text, buttonsTexts=("Cancel", "Skip this Position") + self, title, text, buttonsTexts=('Cancel', 'Skip this Position') ) if skipPosButton == msg.clickedButton: self.loadDataWorker.skipPos = True self.loadDataWorker.waitCond.wakeAll() def askRecoverNotSavedData(self, posData): - last_modified_time_unsaved = "NEVER" + last_modified_time_unsaved = 'NEVER' if os.path.exists(posData.segm_npz_temp_path): + recovered_file_path = posData.segm_npz_temp_path if os.path.exists(posData.segm_npz_path): - last_modified_time_unsaved = datetime.fromtimestamp( - os.path.getmtime(posData.segm_npz_path) - ).strftime("%a %d. %b. %y - %H:%M:%S") + last_modified_time_unsaved = ( + datetime.fromtimestamp( + os.path.getmtime(posData.segm_npz_path) + ).strftime("%a %d. %b. %y - %H:%M:%S") + ) else: posData.setTempPaths() if os.path.exists(posData.unsaved_acdc_df_autosave_path): zip_path = posData.unsaved_acdc_df_autosave_path - with zipfile.ZipFile(zip_path, mode="r") as zip: + with zipfile.ZipFile(zip_path, mode='r') as zip: csv_names = natsorted(set(zip.namelist())) iso_key = csv_names[-1][:-4] most_recent_unsaved_acdc_df_datetime = datetime.strptime( @@ -413,47 +421,45 @@ def askRecoverNotSavedData(self, posData): last_modified_time_unsaved = ( most_recent_unsaved_acdc_df_datetime ).strftime("%a %d. %b. %y - %H:%M:%S") - + if os.path.exists(posData.acdc_output_csv_path): acdc_df_mtime = os.path.getmtime(posData.acdc_output_csv_path) timestamp = datetime.fromtimestamp(acdc_df_mtime) - last_modified_time_saved = timestamp.strftime("%a %d. %b. %y - %H:%M:%S") + last_modified_time_saved = timestamp.strftime( + "%a %d. %b. %y - %H:%M:%S" + ) else: - last_modified_time_saved = "Null" - + last_modified_time_saved = 'Null' + msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph(""" Cell-ACDC detected unsaved data.

Do you want to load and recover the unsaved data or load the data that was last saved by the user? """) - details = f""" + details = (f""" The unsaved data was created on {last_modified_time_unsaved}\n\n The user saved the data last time on {last_modified_time_saved} - """ + """) msg.setDetailedText(details) - loadUnsavedButton = widgets.reloadPushButton("Recover unsaved data") - loadSavedButton = widgets.savePushButton("Load saved data") - infoButton = widgets.infoPushButton("More info...") - loadSafeNpzButton = "" + loadUnsavedButton = widgets.reloadPushButton('Recover unsaved data') + loadSavedButton = widgets.savePushButton('Load saved data') + infoButton = widgets.infoPushButton('More info...') + loadSafeNpzButton = '' if posData.isSafeNpzOverwritePresent(): loadSafeNpzButton = widgets.reloadPushButton( - "Load .safe.npz file from crash" + 'Load .safe.npz file from crash' ) buttons = ( - loadSavedButton, - loadUnsavedButton, - loadSafeNpzButton, - infoButton, + loadSavedButton, loadUnsavedButton, loadSafeNpzButton, + infoButton ) else: buttons = (loadSavedButton, loadUnsavedButton, infoButton) msg.question( - self.progressWin, - "Recover unsaved data?", - txt, - buttonsTexts=("Cancel", *buttons), - showDialog=False, + self.progressWin, 'Recover unsaved data?', txt, + buttonsTexts=('Cancel', *buttons), + showDialog=False ) infoButton.disconnect() infoButton.clicked.connect(partial(self.showInfoAutosave, posData)) @@ -464,25 +470,25 @@ def askRecoverNotSavedData(self, posData): self.loadDataWorker.loadUnsaved = True elif msg.clickedButton == loadSafeNpzButton: self.loadDataWorker.loadSafeOverwriteNpz = True - + self.loadDataWorker.waitCond.wakeAll() def askUserChannelName(self, filename_no_ext, ext): - help_txt = html_utils.paragraph(""" + help_txt = html_utils.paragraph(f""" Cell-ACDC requires that every image file has a basename and some additional text, typically the channel name.

The basename will be common to all created files, while the additional text is used to identify the image files. """) basename = filename_no_ext - underscore_splits = filename_no_ext.split("_") + underscore_splits = filename_no_ext.split('_') if len(underscore_splits) > 1: channel_name = underscore_splits[-1] - basename = "_".join(underscore_splits[:-1]) + basename = '_'.join(underscore_splits[:-1]) else: - channel_name = "channel_1" - - txt = html_utils.paragraph(""" + channel_name = 'channel_1' + + txt = html_utils.paragraph(f""" Provide some text (e.g., the channel name) to append at the end of the image file. """) win = apps.filenameDialog( @@ -490,40 +496,27 @@ def askUserChannelName(self, filename_no_ext, ext): ext=ext, hintText=txt, defaultEntry=channel_name, - helpText=help_txt, + helpText=help_txt, allowEmpty=False, parent=self, - title="Provide channel name for image file", + title='Provide channel name for image file', ) win.exec_() if win.cancel: - return False, "" + return False, '' return True, win.entryText - def channel_name_suggestion(self, filename_no_ext: str) -> ChannelNameSuggestion: - underscore_splits = filename_no_ext.split("_") - if len(underscore_splits) > 1: - return ChannelNameSuggestion( - basename="_".join(underscore_splits[:-1]), - channel_name=underscore_splits[-1], - ) - - return ChannelNameSuggestion( - basename=filename_no_ext, - channel_name="channel_1", - ) - def checkManageVersions(self): posData = self.data[self.pos_i] posData.setTempPaths(createFolder=False) loaded_acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) - + if os.path.exists(posData.recoveryFolderpath()): self.manageVersionsAction.setDisabled(False) self.manageVersionsAction.setToolTip( - f"Load an older version of the `{loaded_acdc_df_filename}` file " - "(table with annotations and measurements)." + f'Load an older version of the `{loaded_acdc_df_filename}` file ' + '(table with annotations and measurements).' ) else: self.manageVersionsAction.setDisabled(True) @@ -532,7 +525,7 @@ def checkMemoryRequirements(self, required_ram): memory = psutil.virtual_memory() total_ram = memory.total available_ram = memory.available - if required_ram / available_ram > 0.3: + if required_ram/available_ram > 0.3: proceed = self.warnMemoryNotSufficient( total_ram, available_ram, required_ram ) @@ -540,61 +533,34 @@ def checkMemoryRequirements(self, required_ram): else: return True - def copy_action_text(self, do_copy: bool) -> str: - return "Copying" if do_copy else "Moving" - - def copy_single_zslice_segm_info( - self, - existing_df: pd.DataFrame, - default_dst_df: pd.DataFrame, - *, - src_filename: str, - dst_filename: str, - ) -> pd.DataFrame: - dst_df = default_dst_df.copy() - src_df = existing_df.loc[src_filename].copy() - - for z_info in src_df.itertuples(): - frame_i = z_info.Index - if z_info.which_z_proj != "single z-slice": - continue - - src_idx = (src_filename, frame_i) - if existing_df.at[src_idx, "resegmented_in_gui"]: - col = "z_slice_used_gui" - else: - col = "z_slice_used_dataPrep" - - z_slice = existing_df.at[src_idx, col] - dst_idx = (dst_filename, frame_i) - dst_df.at[dst_idx, "z_slice_used_dataPrep"] = z_slice - dst_df.at[dst_idx, "z_slice_used_gui"] = z_slice - - return self.merge_default_segm_info(existing_df, dst_df) - def criticalFluoChannelNotFound(self, fluo_ch, posData): msg = widgets.myMessageBox(showCentered=False) ls = "\n".join(myutils.listdir(posData.images_path)) - msg.setDetailedText(f"Files present in the {posData.relPath} folder:\n{ls}") - title = "Requested channel data not found!" + msg.setDetailedText( + f'Files present in the {posData.relPath} folder:\n' + f'{ls}' + ) + title = 'Requested channel data not found!' txt = html_utils.paragraph( - f"The folder {posData.pos_path} " - "does not contain " - "either one of the following files:

" - f"{posData.basename}{fluo_ch}.tif
" - f"{posData.basename}{fluo_ch}_aligned.npz

" - "Data loading aborted." + f'The folder {posData.pos_path} ' + 'does not contain ' + 'either one of the following files:

' + f'{posData.basename}{fluo_ch}.tif
' + f'{posData.basename}{fluo_ch}_aligned.npz

' + 'Data loading aborted.' ) msg.addShowInFileManagerButton(posData.images_path) - msg.warning(self, title, txt, buttonsTexts=("Ok")) + okButton = msg.warning( + self, title, txt, buttonsTexts=('Ok') + ) def criticalImgPathNotFound(self, images_path): self.logger.info( - "The following folder does not contain valid image files: " + 'The following folder does not contain valid image files: ' f'"{images_path}"\n\n' - "Check that all the positions loaded contain the same channel name. " - "Make sure to double check for spelling mistakes or types in the " - "channel names." + 'Check that all the positions loaded contain the same channel name. ' + 'Make sure to double check for spelling mistakes or types in the ' + 'channel names.' ) msg = widgets.myMessageBox() msg.addShowInFileManagerButton(images_path) @@ -604,10 +570,12 @@ def criticalImgPathNotFound(self, images_path): does not contain any valid image file!

Valid file formats are .h5, .tif, _aligned.h5, _aligned.npz. """) - msg.critical(self, "No valid files found!", err_msg, buttonsTexts=("Ok",)) + okButton = msg.critical( + self, 'No valid files found!', err_msg, buttonsTexts=('Ok',) + ) def criticalInvalidPosFolder(self, exp_path): - href = html_utils.href_tag("here", data_structure_docs_url) + href = html_utils.href_tag('here', data_structure_docs_url) txt = html_utils.paragraph(f""" The selected folder:

@@ -628,55 +596,43 @@ def criticalInvalidPosFolder(self, exp_path): For more information about the correct folder structure see {href}. """) msg = widgets.myMessageBox(wrapText=False) - helpButton = widgets.helpPushButton("Help...") + helpButton = widgets.helpPushButton('Help...') msg.addButton(helpButton) helpButton.clicked.disconnect() - helpButton.clicked.connect(partial(myutils.browse_url, data_structure_docs_url)) + helpButton.clicked.connect( + partial(myutils.browse_url, data_structure_docs_url) + ) msg.addShowInFileManagerButton(exp_path) - msg.critical(self, "Incompatible folder", txt) + msg.critical( + self, 'Incompatible folder', txt + ) def criticalNoTifFound(self, images_path): - err_title = "No .tif files found in folder." + err_title = 'No .tif files found in folder.' err_msg = html_utils.paragraph( - "The following folder

" - f"{images_path}

" - "does not contain .tif or .h5 files.

" + 'The following folder

' + f'{images_path}

' + 'does not contain .tif or .h5 files.

' 'Only .tif or .h5 files can be loaded with "Open Folder" button.

' - "Try with File --> Open image/video file... " - "and directly select the file you want to load." + 'Try with File --> Open image/video file... ' + 'and directly select the file you want to load.' ) msg = widgets.myMessageBox() msg.addShowInFileManagerButton(images_path) msg.critical(self, err_title, err_msg) - def empty_data_plan(self, exp_path: str) -> EmptyDataPlan: - pos_path = os.path.join(exp_path, "Position_1") - images_path = os.path.join(pos_path, "Images") - basename = "test_empty_" - tif_filename = f"{basename}channel_1.tif" - metadata_filename = f"{basename}metadata.csv" - - return EmptyDataPlan( - exp_path=exp_path, - pos_path=pos_path, - images_path=images_path, - basename=basename, - tif_filename=tif_filename, - tif_filepath=os.path.join(images_path, tif_filename), - metadata_filename=metadata_filename, - metadata_filepath=os.path.join(images_path, metadata_filename), - ) - def getFileExtensions(self, images_path): - alignedFound = any( - [f.find("_aligned.np") != -1 for f in myutils.listdir(images_path)] - ) + alignedFound = any([f.find('_aligned.np')!=-1 + for f in myutils.listdir(images_path)]) if alignedFound: extensions = ( - "Aligned channels (*npz *npy);; Tif channels(*tiff *tif);;All Files (*)" + 'Aligned channels (*npz *npy);; Tif channels(*tiff *tif)' + ';;All Files (*)' ) else: - extensions = "Tif channels(*tiff *tif);; All Files (*)" + extensions = ( + 'Tif channels(*tiff *tif);; All Files (*)' + ) return extensions def getMostRecentPath(self): @@ -684,13 +640,12 @@ def getMostRecentPath(self): def getPathFromChName(self, chName, posData): ls = myutils.listdir(posData.images_path) - endnames = {f[len(posData.basename) :]: f for f in ls} - validEnds = ["_aligned.npz", "_aligned.h5", ".h5", ".tif", ".npz"] + endnames = {f[len(posData.basename):]:f for f in ls} + validEnds = ['_aligned.npz', '_aligned.h5', '.h5', '.tif', '.npz'] for end in validEnds: files = [ - filename - for endname, filename in endnames.items() - if endname == f"{chName}{end}" + filename for endname, filename in endnames.items() + if endname == f'{chName}{end}' ] if files: filename = files[0] @@ -716,50 +671,47 @@ def helpNewFile(self): More info about Position folders in the {href} at the section called "Create required data structure from microscopy file(s)". """) - msg.information(self, "Help on Position folders", txt) + msg.information( + self, 'Help on Position folders', txt + ) def initFluoData(self): if len(self.ch_names) <= 1: return - - if "ask_load_fluo_at_init" in self.df_settings.index: - if self.df_settings.at["ask_load_fluo_at_init", "value"] == "No": - return + + if 'ask_load_fluo_at_init' in self.df_settings.index: + if self.df_settings.at['ask_load_fluo_at_init', 'value'] == 'No': + return msg = widgets.myMessageBox(allowClose=False) txt = ( - "Do you also want to load fluorescence images?
" - "You can load as many channels as you want.

" - "If you load fluorescence images then the software will " - "calculate metrics for each loaded fluorescence channel " - "such as min, max, mean, quantiles, etc. " - "of each segmented object.

" - "NOTE: You can always load them later from the menu " - "File --> Load fluorescence images... or when you set " - "measurements from the menu " - "Measurements --> Set measurements..." + 'Do you also want to load fluorescence images?
' + 'You can load as many channels as you want.

' + 'If you load fluorescence images then the software will ' + 'calculate metrics for each loaded fluorescence channel ' + 'such as min, max, mean, quantiles, etc. ' + 'of each segmented object.

' + 'NOTE: You can always load them later from the menu ' + 'File --> Load fluorescence images... or when you set ' + 'measurements from the menu ' + 'Measurements --> Set measurements...' ) msg.addDoNotShowAgainCheckbox(text="Don't ask again") no, yes = msg.question( - self, - "Load fluorescence images?", - html_utils.paragraph(txt), - buttonsTexts=("No", "Yes"), + self, 'Load fluorescence images?', html_utils.paragraph(txt), + buttonsTexts=('No', 'Yes') ) if msg.doNotShowAgainCheckbox.isChecked(): - self.df_settings.at["ask_load_fluo_at_init", "value"] = "No" + self.df_settings.at['ask_load_fluo_at_init', 'value'] = 'No' self.df_settings.to_csv(self.settings_csv_path) if msg.clickedButton == yes: self.loadFluo_cb(None) self.AutoPilotProfile.storeClickMessageBox( - "Load fluorescence images?", msg.clickedButton.text() + 'Load fluorescence images?', msg.clickedButton.text() ) - def is_imagej_dtype(self, dtype: np.dtype) -> bool: - return dtype in (np.uint8, np.uint32, np.float32) - def loadDataWorkerDataIntegrityCritical(self): - errTitle = "All loaded positions contains frames over time!" - self.titleLabel.setText(errTitle, color="r") + errTitle = 'All loaded positions contains frames over time!' + self.titleLabel.setText(errTitle, color='r') msg = widgets.myMessageBox(parent=self) @@ -768,20 +720,19 @@ def loadDataWorkerDataIntegrityCritical(self): To load data that contains frames over time you have to select only ONE position. """) - msg.setIcon(iconName="SP_MessageBoxCritical") - msg.setWindowTitle("Loaded multiple positions with frames!") + msg.setIcon(iconName='SP_MessageBoxCritical') + msg.setWindowTitle('Loaded multiple positions with frames!') msg.addText(err_msg) - msg.addButton("Ok") + msg.addButton('Ok') msg.show(block=True) - @exception_handler def loadDataWorkerDataIntegrityWarning(self, pos_foldername): err_msg = ( 'WARNING: Segmentation mask file ("..._segm.npz") not found. ' - "You could run segmentation module first." + 'You could run segmentation module first.' ) - self.workerProgress(err_msg, "INFO") - self.titleLabel.setText(err_msg, color="r") + self.workerProgress(err_msg, 'INFO') + self.titleLabel.setText(err_msg, color='r') abort = False msg = widgets.myMessageBox(parent=self) warn_msg = html_utils.paragraph(f""" @@ -791,29 +742,28 @@ def loadDataWorkerDataIntegrityWarning(self, pos_foldername): pre-compute the mask with the segmentation module.

Do you want to continue? """) - msg.setIcon(iconName="SP_MessageBoxWarning") - msg.setWindowTitle("Segmentation file not found") + msg.setIcon(iconName='SP_MessageBoxWarning') + msg.setWindowTitle('Segmentation file not found') msg.addText(warn_msg) - msg.addButton("Ok") - continueWithBlankSegm = msg.addButton(" Cancel ") + msg.addButton('Ok') + continueWithBlankSegm = msg.addButton(' Cancel ') msg.show(block=True) if continueWithBlankSegm == msg.clickedButton: abort = True self.loadDataWorker.abort = abort self.loadDataWaitCond.wakeAll() - @exception_handler def loadDataWorkerFinished(self, data): - self.funcDescription = "loading data worker finished" + self.funcDescription = 'loading data worker finished' if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - if data is None or data == "abort": + if data is None or data=='abort': self.loadingDataAborted() return - + if data[0].onlyEditMetadata: self.loadingDataAborted() return @@ -827,25 +777,22 @@ def loadFluo_cb(self, checked=True, fluo_channels=None): if fluo_channels is None: posData = self.data[self.pos_i] ch_names = [ - ch - for ch in self.ch_names - if ch != self.user_ch_name and ch not in posData.loadedFluoChannels + ch for ch in self.ch_names if ch != self.user_ch_name + and ch not in posData.loadedFluoChannels ] if not ch_names: msg = widgets.myMessageBox() txt = html_utils.paragraph( - "You already loaded ALL channels.

" - "To change the overlaid channel " - "right-click on the overlay button." + 'You already loaded ALL channels.

' + 'To change the overlaid channel ' + 'right-click on the overlay button.' ) - msg.information(self, "All channels are loaded", txt) + msg.information(self, 'All channels are loaded', txt) return False selectFluo = widgets.QDialogListbox( - "Select channel to load", - "Select channel names to load:\n", - ch_names, - multiSelection=True, - parent=self, + 'Select channel to load', + 'Select channel names to load:\n', + ch_names, multiSelection=True, parent=self ) selectFluo.exec_() @@ -873,27 +820,26 @@ def loadFluo_cb(self, checked=True, fluo_channels=None): posData.fluo_data_dict[filename] = fluo_data posData.fluo_bkgrData_dict[filename] = bkgrData posData.ol_data_dict[filename] = fluo_data.copy() - - self.overlayButton.setStyleSheet(f"background-color: {GREEN_HEX}") - self.guiTabControl.addChannels( - [posData.user_ch_name, *posData.loadedFluoChannels] - ) + + self.overlayButton.setStyleSheet(f'background-color: {GREEN_HEX}') + self.guiTabControl.addChannels([ + posData.user_ch_name, *posData.loadedFluoChannels + ]) return True def loadNonAlignedFluoChannel(self, fluo_path): posData = self.data[self.pos_i] - if posData.filename.find("aligned") != -1: + if posData.filename.find('aligned') != -1: filename, _ = os.path.splitext(os.path.basename(fluo_path)) - path = f".../{posData.pos_foldername}/Images/{filename}_aligned.npz" + path = f'.../{posData.pos_foldername}/Images/{filename}_aligned.npz' msg = widgets.myMessageBox() msg.critical( - self, - "Aligned fluo channel not found!", - "Aligned data for fluorescence channel not found!\n\n" - f"You loaded aligned data for the cells channel, therefore " - "loading NON-aligned fluorescence data is not allowed.\n\n" + self, 'Aligned fluo channel not found!', + 'Aligned data for fluorescence channel not found!\n\n' + f'You loaded aligned data for the cells channel, therefore ' + 'loading NON-aligned fluorescence data is not allowed.\n\n' 'Run the script "dataPrep.py" to create the following file:\n\n' - f"{path}", + f'{path}' ) return None fluo_data = np.squeeze(skimage.io.imread(fluo_path)) @@ -902,22 +848,21 @@ def loadNonAlignedFluoChannel(self, fluo_path): def loadPosTriggered(self): if not self.isDataLoaded: return - + self.startAutomaticLoadingPos() def loadSelectedData(self, user_ch_file_paths, user_ch_name): - len(user_ch_file_paths) + data = [] + numPos = len(user_ch_file_paths) self.user_ch_file_paths = user_ch_file_paths - - self.logger.info(f"Reading {user_ch_name} channel metadata...") + + self.logger.info(f'Reading {user_ch_name} channel metadata...') # Get information from first loaded position - posData = load.loadData( - user_ch_file_paths[0], user_ch_name, log_func=self.logger.info - ) + posData = load.loadData(user_ch_file_paths[0], user_ch_name, log_func=self.logger.info) posData.getBasenameAndChNames(qparent=self) posData.buildPaths() - if posData.ext != ".h5": + if posData.ext != '.h5': self.lazyLoader.salute = False self.lazyLoader.exit = True self.lazyLoaderWaitCond.wakeAll() @@ -929,28 +874,30 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): _posData = load.loadData(filePath, user_ch_name, log_func=self.logger.info) _posData.getBasenameAndChNames(qparent=self) segm_files = load.get_segm_files(_posData.images_path) - _existingEndnames = load.get_endnames(_posData.basename, segm_files) + _existingEndnames = load.get_endnames( + _posData.basename, segm_files + ) existingSegmEndNames.update(_existingEndnames) - selectedSegmEndName = "" - self.newSegmEndName = "" + selectedSegmEndName = '' + self.newSegmEndName = '' if self.isNewFile or not existingSegmEndNames: self.isNewFile = True # Remove the 'segm_' part to allow filenameDialog to check if # a new file is existing (since we only ask for the part after # 'segm_') existingEndNames = [ - n.replace("segm", "", 1).replace("_", "", 1) + n.replace('segm', '', 1).replace('_', '', 1) for n in existingSegmEndNames ] - if posData.basename.endswith("_"): - basename = f"{posData.basename}segm" + if posData.basename.endswith('_'): + basename = f'{posData.basename}segm' else: - basename = f"{posData.basename}_segm" + basename = f'{posData.basename}_segm' win = apps.filenameDialog( basename=basename, - hintText="Insert a filename for the segmentation file:", - existingNames=existingEndNames, + hintText='Insert a filename for the segmentation file:', + existingNames=existingEndNames ) win.exec_() if win.cancel: @@ -960,11 +907,8 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): else: if len(existingSegmEndNames) > 0: win = apps.SelectSegmFileDialog( - existingSegmEndNames, - self.exp_path, - parent=self, - addNewFileButton=True, - basename=posData.basename, + existingSegmEndNames, self.exp_path, parent=self, + addNewFileButton=True, basename=posData.basename ) win.exec_() if win.cancel: @@ -972,7 +916,9 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): return if win.newSegmEndName is None: selectedSegmEndName = win.selectedItemText - self.AutoPilotProfile.storeSelectedSegmFile(selectedSegmEndName) + self.AutoPilotProfile.storeSelectedSegmFile( + selectedSegmEndName + ) else: self.newSegmEndName = win.newSegmEndName self.isNewFile = True @@ -980,7 +926,7 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): selectedSegmEndName = list(existingSegmEndNames)[0] posData.loadImgData() - + required_ram = posData.getBytesImageData() if required_ram >= 5e8: # Disable autosave for data > 500MB @@ -990,7 +936,7 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): if not proceed: self.loadingDataAborted() return - + posData.loadOtherFiles( load_segm_data=True, load_metadata=True, @@ -1002,22 +948,24 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): self.labelBoolSegm = posData.labelBoolSegm posData.labelSegmData() - print("") - self.logger.info(f"Segmentation filename: {posData.segm_npz_path}") + print('') + self.logger.info( + f'Segmentation filename: {posData.segm_npz_path}' + ) proceed = posData.askInputMetadata( self.num_pos, - ask_SizeT=self.num_pos == 1, + ask_SizeT=self.num_pos==1, ask_TimeIncrement=True, ask_PhysicalSizes=True, singlePos=False, - save=True, - warnMultiPos=True, + save=True, + warnMultiPos=True ) if not proceed: self.loadingDataAborted() return - + self.AutoPilotProfile.storeOkAskInputMetadata() if posData.isSegm3D is None: @@ -1043,11 +991,13 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): self.createOverlayLabelsItems(existingSegmEndNames) self.disableNonFunctionalButtons() - self.isH5chunk = posData.ext == ".h5" and ( - self.loadSizeT != self.SizeT or self.loadSizeZ != self.SizeZ + self.isH5chunk = ( + posData.ext == '.h5' + and (self.loadSizeT != self.SizeT + or self.loadSizeZ != self.SizeZ) ) - required_ram = posData.checkH5memoryFootprint() * self.loadSizeS + required_ram = posData.checkH5memoryFootprint()*self.loadSizeS if required_ram > 0: proceed = self.checkMemoryRequirements(required_ram) if not proceed: @@ -1060,16 +1010,17 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): self.isSnapshot = False self.progressWin = apps.QDialogWorkerProgress( - title="Loading data...", - parent=self, - pbarDesc=f'Loading "{user_ch_file_paths[0]}"...', + title='Loading data...', parent=self, + pbarDesc=f'Loading "{user_ch_file_paths[0]}"...' ) self.progressWin.show(self.app) func = partial( - self.startLoadDataWorker, user_ch_file_paths, user_ch_name, posData + self.startLoadDataWorker, user_ch_file_paths, user_ch_name, + posData ) + QTimer.singleShot(150, func) def load_fluo_data(self, fluo_path, isGuiThread=True): @@ -1079,28 +1030,28 @@ def load_fluo_data(self, fluo_path, isGuiThread=True): # Load overlay frames and align if needed filename = os.path.basename(fluo_path) filename_noEXT, ext = os.path.splitext(filename) - if ext == ".npy" or ext == ".npz": + if ext == '.npy' or ext == '.npz': fluo_data = np.load(fluo_path) try: - fluo_data = np.squeeze(fluo_data["arr_0"]) - except Exception: + fluo_data = np.squeeze(fluo_data['arr_0']) + except Exception as e: fluo_data = np.squeeze(fluo_data) # Load background data bkgrData_path = os.path.join( - posData.images_path, f"{filename_noEXT}_bkgrRoiData.npz" + posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' ) if os.path.exists(bkgrData_path): bkgrData = np.load(bkgrData_path) - elif ext == ".tif" or ext == ".tiff": - aligned_filename = f"{filename_noEXT}_aligned.npz" + elif ext == '.tif' or ext == '.tiff': + aligned_filename = f'{filename_noEXT}_aligned.npz' aligned_path = os.path.join(posData.images_path, aligned_filename) if os.path.exists(aligned_path): - fluo_data = np.load(aligned_path)["arr_0"] + fluo_data = np.load(aligned_path)['arr_0'] # Load background data bkgrData_path = os.path.join( - posData.images_path, f"{aligned_filename}_bkgrRoiData.npz" + posData.images_path, f'{aligned_filename}_bkgrRoiData.npz' ) if os.path.exists(bkgrData_path): bkgrData = np.load(bkgrData_path) @@ -1111,39 +1062,38 @@ def load_fluo_data(self, fluo_path, isGuiThread=True): # Load background data bkgrData_path = os.path.join( - posData.images_path, f"{filename_noEXT}_bkgrRoiData.npz" + posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' ) if os.path.exists(bkgrData_path): bkgrData = np.load(bkgrData_path) elif isGuiThread: txt = html_utils.paragraph( - f"File format {ext} is not supported!\n" - "Choose either .tif or .npz files." + f'File format {ext} is not supported!\n' + 'Choose either .tif or .npz files.' ) msg = widgets.myMessageBox() - msg.critical(self, "File not supported", txt) + msg.critical(self, 'File not supported', txt) return None, None return fluo_data, bkgrData def loadingDataAborted(self): self.openFolderAction.setEnabled(True) - self.titleLabel.setText("Loading data aborted.") + self.titleLabel.setText('Loading data aborted.') - @exception_handler def loadingDataCompleted(self): self.isDataLoading = True posData = self.data[self.pos_i] - - files_format = "\n".join( - [f" - {file}" for file in posData.images_folder_files] - ) - sep = "-" * 100 + + files_format = '\n'.join([ + f' - {file}' for file in posData.images_folder_files + ]) + sep = '-'*100 self.logger.info( - f"{sep}\nFiles present in the first Position folder loaded:\n\n" - f"{files_format}\n{sep}" + f'{sep}\nFiles present in the first Position folder loaded:\n\n' + f'{files_format}\n{sep}' ) - self.logger.info(f"Basename of the first Position: {posData.basename}") + self.logger.info(f'Basename of the first Position: {posData.basename}') self.secondLevelToolbar.setVisible(True) self.updateImageValueFormatter() self.checkManageVersions() @@ -1153,15 +1103,15 @@ def loadingDataCompleted(self): self.setWindowTitle( f'Cell-ACDC v{self._acdc_version} - GUI - "{posData.exp_path}"' ) - + self.setupPreprocessing() self.setupCombiningChannels() if self.isSegm3D: - self.segmNdimIndicator.setText("3D") + self.segmNdimIndicator.setText('3D') else: - self.segmNdimIndicator.setText("2D") - + self.segmNdimIndicator.setText('2D') + self.segmNdimIndicatorAction.setVisible(True) self.guiTabControl.addChannels([posData.user_ch_name]) @@ -1172,45 +1122,53 @@ def loadingDataCompleted(self): self.init_segmInfo_df() self.connectScrollbars() self.initPosAttr() - - self.logger.info("Pre-computing min and max values of the images...") + + self.logger.info('Pre-computing min and max values of the images...') self.img1.preComputedMinMaxValues(self.data) self.img2.minMaxValuesMapper = self.img1.minMaxValuesMapper - + self.initMetrics() self.initFluoData() self.createChannelNamesActions() self.addActionsLutItemContextMenu(self.imgGrad) - + # Scrollbar for opacity of img1 (when overlaying) - self.img1.alphaScrollbar = self.addAlphaScrollbar(self.user_ch_name, self.img1) + self.img1.alphaScrollbar = self.addAlphaScrollbar( + self.user_ch_name, self.img1 + ) - self.navigateScrollBar.setSliderPosition(posData.frame_i + 1) + self.navigateScrollBar.setSliderPosition(posData.frame_i+1) # Connect events at the end of loading data process self.gui_connectGraphicsEvents() if not self.isEditActionsConnected: self.gui_connectEditActions() self.normalizeToFloatAction.setChecked(True) - + self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) self.setFramesSnapshotMode() if self.isSnapshot: - self.navSizeLabel.setText(f"/{len(self.data)}") + self.navSizeLabel.setText(f'/{len(self.data)}') else: - self.navSizeLabel.setText(f"/{posData.SizeT}") + self.navSizeLabel.setText(f'/{posData.SizeT}') self.enableZstackWidgets(posData.SizeZ > 1) # self.showHighlightZneighCheckbox() - - self.exportToVideoAction.setDisabled(posData.SizeZ == 1 and posData.SizeT == 1) + + self.exportToVideoAction.setDisabled( + posData.SizeZ == 1 and posData.SizeT == 1 + ) self.img1BottomGroupbox.show() - isLabVisible = self.df_settings.at["isLabelsVisible", "value"] == "Yes" - isRightImgVisible = self.df_settings.at["isRightImageVisible", "value"] == "Yes" - isNextFrameVisible = self.df_settings.at["isNextFrameVisible", "value"] == "Yes" + isLabVisible = self.df_settings.at['isLabelsVisible', 'value'] == 'Yes' + isRightImgVisible = ( + self.df_settings.at['isRightImageVisible', 'value'] == 'Yes' + ) + isNextFrameVisible = ( + self.df_settings.at['isNextFrameVisible', 'value'] == 'Yes' + ) isNextFrameActive = ( isNextFrameVisible and self.labelsGrad.showNextFrameAction.isEnabled() ) @@ -1224,16 +1182,18 @@ def loadingDataCompleted(self): self.labelsGrad.showNextFrameAction.setChecked(isNextFrameActive) if isRightImgVisible or isNextFrameActive: self.rightBottomGroupbox.setChecked(True) - - isTwoImagesLayout = isRightImgVisible or isLabVisible or isNextFrameActive + + isTwoImagesLayout = ( + isRightImgVisible or isLabVisible or isNextFrameActive + ) self.setTwoImagesLayout(isTwoImagesLayout) - + self.setBottomLayoutStretch() - + if isNextFrameActive: self.rightBottomGroupbox.show() self.rightBottomGroupbox.setChecked(True) - self.drawNothingCheckboxRight.click() + self.drawNothingCheckboxRight.click() self.readSavedCustomAnnot() self.addCustomAnnotButtonAllLoadedPos() @@ -1251,13 +1211,16 @@ def loadingDataCompleted(self): self.update_rp() self.updateAllImages() if posData.SizeT > 1: - self.rightImageFramesScrollbar.setValueNoSignal(posData.frame_i + 2) + self.rightImageFramesScrollbar.setValueNoSignal(posData.frame_i+2) self.setMetricsFunc() self.gui_createLabelRoiItem() self.gui_createZoomRectItem() - self.titleLabel.setText("Data successfully loaded.", color=self.titleColor) + self.titleLabel.setText( + 'Data successfully loaded.', + color=self.titleColor + ) self.disableNonFunctionalButtons() self.setVisible3DsegmWidgets() @@ -1290,38 +1253,27 @@ def loadingDataCompleted(self): self.isDataLoaded = True self.isDataLoading = False - + self.initImgGradRescaleIntensitiesHowPreference() - + self.rescaleIntensitiesLut(setImage=False) - + self.gui_createAutoSaveWorker() - def merge_default_segm_info( - self, - existing_df: pd.DataFrame, - default_df: pd.DataFrame, - ) -> pd.DataFrame: - merged_df = pd.concat([default_df, existing_df]) - unique_idx = ~merged_df.index.duplicated() - return merged_df[unique_idx] - def newFile(self): - self.newSegmEndName = "" + self.newSegmEndName = '' self.isNewFile = True msg = widgets.myMessageBox(parent=self, showCentered=False) - msg.setWindowTitle("File or folder?") - msg.addText( - html_utils.paragraph(""" + msg.setWindowTitle('File or folder?') + msg.addText(html_utils.paragraph(f""" Do you want to load an image file or Position folder(s)? - """) - ) - loadPosButton = QPushButton("Load Position folder", msg) + """)) + loadPosButton = QPushButton('Load Position folder', msg) loadPosButton.setIcon(QIcon(":folder-open.svg")) - loadFileButton = QPushButton("Load image file", msg) + loadFileButton = QPushButton('Load image file', msg) loadFileButton.setIcon(QIcon(":image.svg")) - helpButton = widgets.helpPushButton("Help...") + helpButton = widgets.helpPushButton('Help...') msg.addButton(helpButton) helpButton.disconnect() helpButton.clicked.connect(self.helpNewFile) @@ -1332,7 +1284,7 @@ def newFile(self): msg.exec_() if msg.cancel: return - + if msg.clickedButton == loadPosButton: self._openFolder() else: @@ -1344,24 +1296,27 @@ def openFile(self, checked=False, file_path=None): self.isNewFile = False self._openFile(file_path=file_path) - def openFolder(self, checked=False, exp_path=None, imageFilePath=""): + def openFolder( + self, checked=False, exp_path=None, imageFilePath='' + ): if exp_path is None: - self.logger.info("Asking to select a folder path...") + self.logger.info('Asking to select a folder path...') else: self.logger.info(f'Opening FOLDER "{exp_path}"...') self.isNewFile = False - if hasattr(self, "data") and self.titleLabel.text != "Saved!": + if hasattr(self, 'data') and self.titleLabel.text != 'Saved!': msg = widgets.myMessageBox() txt = html_utils.paragraph( - "Do you want to save before loading another dataset?" + 'Do you want to save before loading another dataset?' ) _, no, yes = msg.question( - self, "Save?", txt, buttonsTexts=("Cancel", "No", "Yes") + self, 'Save?', txt, + buttonsTexts=('Cancel', 'No', 'Yes') ) if msg.clickedButton == yes: func = partial(self._openFolder, exp_path, imageFilePath) - self.saveData(finishedCallback=func) + cancel = self.saveData(finishedCallback=func) return elif msg.cancel: self.store_data() @@ -1369,112 +1324,15 @@ def openFolder(self, checked=False, exp_path=None, imageFilePath=""): else: self.store_data(autosave=False) - self._openFolder(exp_path=exp_path, imageFilePath=imageFilePath) + self._openFolder( + exp_path=exp_path, imageFilePath=imageFilePath + ) def openRecentFile(self, path): - self.logger.info(f"Opening recent folder: {path}") + self.logger.info(f'Opening recent folder: {path}') self.addToRecentPaths(path, logger=self.logger) self.openFolder(exp_path=path) - def open_image_file_context( - self, file_path: str, timestamp: str | None = None - ) -> OpenImageFileContext: - filename_no_ext, ext = os.path.splitext(os.path.basename(file_path)) - filename_no_ext = filename_no_ext.rstrip("_") - ext = ext.lower() - dirpath = os.path.dirname(file_path) - dirname = os.path.basename(dirpath) - requires_images_folder = dirname != "Images" - acdc_folder = None - - if requires_images_folder: - timestamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S") - acdc_folder = f"{timestamp}_acdc" - exp_path = os.path.join(dirpath, acdc_folder, "Images") - else: - exp_path = dirpath - - return OpenImageFileContext( - file_path=file_path, - filename_no_ext=filename_no_ext, - extension=ext, - source_dirpath=dirpath, - source_dirname=dirname, - exp_path=exp_path, - acdc_folder=acdc_folder, - requires_images_folder=requires_images_folder, - ) - - def open_image_file_target( - self, - context: OpenImageFileContext, - channel_name: str | None = None, - ) -> OpenImageFileTarget: - filename_no_ext = context.filename_no_ext - basename = None - metadata_csv_filename = None - metadata_csv_filepath = None - - if channel_name is not None: - underscore_splits = filename_no_ext.split("_") - if len(underscore_splits) > 1: - default_ch_name = underscore_splits[-1] - if channel_name == default_ch_name: - filename_no_ext = "_".join(underscore_splits[:-1]) - - basename = f"{filename_no_ext}_" - metadata_csv_filename = f"{basename}metadata.csv" - metadata_csv_filepath = os.path.join( - context.exp_path, metadata_csv_filename - ) - new_filename = f"{filename_no_ext}_{channel_name}{context.extension}" - else: - new_filename = f"{filename_no_ext}{context.extension}" - - new_filepath = os.path.join(context.exp_path, new_filename) - tif_filename_no_ext = os.path.splitext(new_filename)[0] - tif_filename = f"{tif_filename_no_ext}.tif" - tif_path = os.path.join(context.exp_path, tif_filename) - - return OpenImageFileTarget( - context=context, - filename_no_ext=filename_no_ext, - channel_name=channel_name, - basename=basename, - new_filename=new_filename, - new_filepath=new_filepath, - metadata_csv_filename=metadata_csv_filename, - metadata_csv_filepath=metadata_csv_filepath, - tif_filename=tif_filename, - tif_path=tif_path, - direct_copy_supported=context.extension in (".tif", ".npz"), - ) - - def prepare_tiff_image_data(self, image: np.ndarray) -> ImageDataPreparation: - converted_rgb_to_gray = False - converted_dtype = False - prepared_image = image - - if prepared_image.ndim == 3 and ( - prepared_image.shape[-1] == 3 or prepared_image.shape[-1] == 4 - ): - converted_rgb_to_gray = True - if prepared_image.shape[-1] == 3: - prepared_image = skimage.color.rgb2gray(prepared_image) - else: - prepared_image = cv2.cvtColor(prepared_image, cv2.COLOR_RGBA2GRAY) - prepared_image = skimage.img_as_ubyte(prepared_image) - - if not self.is_imagej_dtype(prepared_image.dtype): - converted_dtype = True - prepared_image = skimage.img_as_ubyte(prepared_image) - - return ImageDataPreparation( - image=prepared_image, - converted_rgb_to_gray=converted_rgb_to_gray, - converted_dtype=converted_dtype, - ) - def reload_cb(self): posData = self.data[self.pos_i] # Store undo state before modifying stuff @@ -1482,8 +1340,8 @@ def reload_cb(self): labData = np.load(posData.segm_npz_path) # Keep compatibility with .npy and .npz files try: - lab = labData["arr_0"][posData.frame_i] - except Exception: + lab = labData['arr_0'][posData.frame_i] + except Exception as e: lab = labData[posData.frame_i] posData.segm_data[posData.frame_i] = lab.copy() self.get_data() @@ -1492,41 +1350,40 @@ def reload_cb(self): def showInfoAutosave(self, posData): msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = """ + txt = (f""" Cell-ACDC either detected unsaved data in a previous session and it stored it because the Autosave
function was active, or it crashed during saving.

You can toggle Autosave ON and OFF from the menu on the top menubar File --> Autosave. - """ - txt = f""" + """) + txt = (f""" {txt}

If Cell-ACDC crashed during saving, the segmentation file ending with .new.npz
is present and you might be able to recover the data from there. - """ - - txt = f""" + """) + + txt = (f""" {txt}

You can find additional recovered data in the following folder: - """ + """) txt = html_utils.paragraph(txt) msg.information( - self, - "Autosave info", - txt, - path_to_browse=posData.recoveryFolderPath, - commands=(posData.recoveryFolderPath,), + self, 'Autosave info', txt, + path_to_browse=posData.recoveryFolderPath, + commands=(posData.recoveryFolderPath,) ) def startAutomaticLoadingPos(self): self.AutoPilot = autopilot.AutoPilot(self) self.AutoPilot.execLoadPos() - @exception_handler - def startLoadDataWorker(self, user_ch_file_paths, user_ch_name, firstPosData): - self.funcDescription = "loading data" - + def startLoadDataWorker( + self, user_ch_file_paths, user_ch_name, firstPosData + ): + self.funcDescription = 'loading data' + self.guiTabControl.propsQGBox.idSB.setValue(0) self.thread = QThread() @@ -1539,14 +1396,24 @@ def startLoadDataWorker(self, user_ch_file_paths, user_ch_name, firstPosData): self.loadDataWorker.moveToThread(self.thread) self.loadDataWorker.signals.finished.connect(self.thread.quit) - self.loadDataWorker.signals.finished.connect(self.loadDataWorker.deleteLater) + self.loadDataWorker.signals.finished.connect( + self.loadDataWorker.deleteLater + ) self.thread.finished.connect(self.thread.deleteLater) - self.loadDataWorker.signals.finished.connect(self.loadDataWorkerFinished) + self.loadDataWorker.signals.finished.connect( + self.loadDataWorkerFinished + ) self.loadDataWorker.signals.progress.connect(self.workerProgress) - self.loadDataWorker.signals.initProgressBar.connect(self.workerInitProgressbar) - self.loadDataWorker.signals.progressBar.connect(self.workerUpdateProgressbar) - self.loadDataWorker.signals.critical.connect(self.workerCritical) + self.loadDataWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.loadDataWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.loadDataWorker.signals.critical.connect( + self.workerCritical + ) self.loadDataWorker.signals.dataIntegrityCritical.connect( self.loadDataWorkerDataIntegrityCritical ) @@ -1559,7 +1426,9 @@ def startLoadDataWorker(self, user_ch_file_paths, user_ch_name, firstPosData): self.loadDataWorker.signals.sigWarnMismatchSegmDataShape.connect( self.askMismatchSegmDataShape ) - self.loadDataWorker.signals.sigRecovery.connect(self.askRecoverNotSavedData) + self.loadDataWorker.signals.sigRecovery.connect( + self.askRecoverNotSavedData + ) self.thread.started.connect(self.loadDataWorker.run) self.thread.start() @@ -1567,7 +1436,7 @@ def startLoadDataWorker(self, user_ch_file_paths, user_ch_name, firstPosData): def stopAutomaticLoadingPos(self): if self.AutoPilot is None: return - + if self.AutoPilot.timer.isActive(): self.AutoPilot.timer.stop() self.AutoPilot = None @@ -1576,7 +1445,7 @@ def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): total_ram = myutils._bytes_to_GB(total_ram) available_ram = myutils._bytes_to_GB(available_ram) required_ram = myutils._bytes_to_GB(required_ram) - required_perc = round(100 * required_ram / available_ram) + required_perc = round(100*required_ram/available_ram) msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" The total amount of data that you requested to load is about @@ -1591,13 +1460,11 @@ def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): What do you want to do? """) cancelButton, continueButton = msg.warning( - self, - "Memory not sufficient", - txt, - buttonsTexts=("Cancel", "Continue anyway"), + self, 'Memory not sufficient', txt, + buttonsTexts=('Cancel', 'Continue anyway') ) if msg.clickedButton == continueButton: - # Disable autosaving since it would keep a copy of the data and + # Disable autosaving since it would keep a copy of the data and # we cannot afford it with low memory self.autoSaveToggle.setChecked(False) return True @@ -1606,7 +1473,7 @@ def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): def warnUserCreationImagesFolder(self, images_path, ext): msg = widgets.myMessageBox(wrapText=False) - txt = f""" + txt = (f""" Cell-ACDC requires a specific folder structure to load the data.

Specifically, it requires the image(s) to be located in a folder called Images.

@@ -1621,22 +1488,24 @@ def warnUserCreationImagesFolder(self, images_path, ext): folder: {images_path}
- """ - - if ext == ".tif" or ext == ".npz": - txt = f"{txt}How do you want to proceed?" + """) + + if ext == '.tif' or ext == '.npz': + txt = f'{txt}How do you want to proceed?' else: - txt = f"{txt}Do you want to proceed?" + txt = f'{txt}Do you want to proceed?' txt = html_utils.paragraph(txt) - - if ext == ".tif" or ext == ".npz": - copyButton = widgets.copyPushButton("Copy the image into the new folder") - moveButton = widgets.movePushButton("Move the image into the new folder") + + if ext == '.tif' or ext == '.npz': + copyButton = widgets.copyPushButton( + 'Copy the image into the new folder' + ) + moveButton = widgets.movePushButton( + 'Move the image into the new folder' + ) _, copyButton, moveButton = msg.information( - self, - "Creating Images folder", - txt, - buttonsTexts=("Cancel", copyButton, moveButton), + self, 'Creating Images folder', txt, + buttonsTexts=('Cancel', copyButton, moveButton) ) if msg.cancel: return False, None @@ -1645,25 +1514,23 @@ def warnUserCreationImagesFolder(self, images_path, ext): return True, True elif msg.clickedButton == moveButton: return True, False - + else: msg.information( - self, - "Creating Images folder", - txt, - buttonsTexts=("Cancel", "Yes, proceed"), + self, 'Creating Images folder', txt, + buttonsTexts=('Cancel', 'Yes, proceed') ) if msg.cancel: return False, None - + return True, True def workerPermissionError(self, txt, waitCond): msg = widgets.myMessageBox(parent=self) - msg.setIcon(iconName="SP_MessageBoxCritical") - msg.setWindowTitle("Permission denied") + msg.setIcon(iconName='SP_MessageBoxCritical') + msg.setWindowTitle('Permission denied') msg.addText(txt) - msg.addButton(" Ok ") + msg.addButton(' Ok ') msg.exec_() waitCond.wakeAll() @@ -1673,10 +1540,9 @@ def zSliceAbsent(self, filename, posData): chNames = posData.chNames filenamesPresent = posData.segmInfo_df.index.get_level_values(0).unique() chNamesPresent = [ - ch - for ch in chNames + ch for ch in chNames for file in filenamesPresent - if file.endswith(ch) or file.endswith(f"{ch}_aligned") + if file.endswith(ch) or file.endswith(f'{ch}_aligned') ] win = apps.QDialogZsliceAbsent(filename, SizeZ, chNamesPresent) win.exec_() @@ -1685,7 +1551,7 @@ def zSliceAbsent(self, filename, posData): self.waitCond.wakeAll() return if win.useMiddleSlice: - user_ch_name = filename[len(posData.basename) :] + user_ch_name = filename[len(posData.basename):] for _posData in self.data: if _posData is None: continue @@ -1696,11 +1562,13 @@ def zSliceAbsent(self, filename, posData): _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) elif win.useSameAsCh: - user_ch_name = filename[len(posData.basename) :] + user_ch_name = filename[len(posData.basename):] for _posData in self.data: if _posData is None: continue - _, srcFilename = self.getPathFromChName(win.selectedChannel, _posData) + _, srcFilename = self.getPathFromChName( + win.selectedChannel, _posData + ) cellacdc_df = _posData.segmInfo_df.loc[srcFilename].copy() _, dstFilename = self.getPathFromChName(user_ch_name, _posData) if dstFilename is None: @@ -1711,23 +1579,23 @@ def zSliceAbsent(self, filename, posData): for z_info in cellacdc_df.itertuples(): frame_i = z_info.Index zProjHow = z_info.which_z_proj - if zProjHow == "single z-slice": + if zProjHow == 'single z-slice': src_idx = (srcFilename, frame_i) - if _posData.segmInfo_df.at[src_idx, "resegmented_in_gui"]: - col = "z_slice_used_gui" + if _posData.segmInfo_df.at[src_idx, 'resegmented_in_gui']: + col = 'z_slice_used_gui' else: - col = "z_slice_used_dataPrep" + col = 'z_slice_used_dataPrep' z_slice = _posData.segmInfo_df.at[src_idx, col] dst_idx = (dstFilename, frame_i) - dst_df.at[dst_idx, "z_slice_used_dataPrep"] = z_slice - dst_df.at[dst_idx, "z_slice_used_gui"] = z_slice + dst_df.at[dst_idx, 'z_slice_used_dataPrep'] = z_slice + dst_df.at[dst_idx, 'z_slice_used_gui'] = z_slice _posData.segmInfo_df = pd.concat([dst_df, _posData.segmInfo_df]) unique_idx = ~_posData.segmInfo_df.index.duplicated() _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) elif win.runDataPrep: user_ch_file_paths = [] - user_ch_name = filename[len(self.data[self.pos_i].basename) :] + user_ch_name = filename[len(self.data[self.pos_i].basename):] for _posData in self.data: if _posData is None: continue @@ -1743,17 +1611,20 @@ def zSliceAbsent(self, filename, posData): dataPrepWin = dataPrep.dataPrepWin() dataPrepWin.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - dataPrepWin.titleText = """ + dataPrepWin.titleText = ( + """ Select z-slice (or projection) for each frame/position.
Once happy, close the window. - """ + """) dataPrepWin.show() dataPrepWin.initLoading() dataPrepWin.SizeT = self.data[0].SizeT dataPrepWin.SizeZ = self.data[0].SizeZ dataPrepWin.metadataAlreadyAsked = True - self.logger.info(f"Loading channel {user_ch_name} data...") - dataPrepWin.loadFiles(exp_path, user_ch_file_paths, user_ch_name) + self.logger.info(f'Loading channel {user_ch_name} data...') + dataPrepWin.loadFiles( + exp_path, user_ch_file_paths, user_ch_name + ) dataPrepWin.startAction.setDisabled(True) dataPrepWin.onlySelectingZslice = True @@ -1762,3 +1633,24 @@ def zSliceAbsent(self, filename, posData): loop.exec_() self.waitCond.wakeAll() + + def getConcatAcdcDf(self): + acdc_dfs = [] + keys = [] + posData = self.data[self.pos_i] + for frame_i, data_dict in enumerate(posData.allData_li): + lab = data_dict['labels'] + if lab is None: + break + + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + break + + acdc_dfs.append(acdc_df) + keys.append(frame_i) + + if not acdc_dfs: + return + + return pd.concat(acdc_dfs, keys=keys, names=['frame_i']) diff --git a/cellacdc/mixins_bak/deleted_rois.py b/cellacdc/mixins/deleted_rois.py similarity index 64% rename from cellacdc/mixins_bak/deleted_rois.py rename to cellacdc/mixins/deleted_rois.py index 5f4904030..62a88e9d8 100644 --- a/cellacdc/mixins_bak/deleted_rois.py +++ b/cellacdc/mixins/deleted_rois.py @@ -14,12 +14,8 @@ from cellacdc import widgets -class DeletedRoisMixin: - """Qt-facing adapter around deleted-ROI workflows.""" - - """Headless decisions for deleted-ROI display and propagation.""" - - # @exec_time +class DeletedRois: + """Extracted from guiWin.""" def addDelPolyLineRoi_cb(self, checked): if checked: @@ -27,10 +23,10 @@ def addDelPolyLineRoi_cb(self, checked): self.uncheckLeftClickButtons(self.addDelPolyLineRoiButton) self.connectLeftClickButtons() if self.isSnapshot: - self.fixCcaDfAfterEdit("Delete IDs using ROI") + self.fixCcaDfAfterEdit('Delete IDs using ROI') self.updateAllImages() else: - self.warnEditingWithCca_df("Delete IDs using ROI") + self.warnEditingWithCca_df('Delete IDs using ROI') else: self.tempSegmentON = False self.ax1_rulerPlotItem.setData([], []) @@ -39,7 +35,7 @@ def addDelPolyLineRoi_cb(self, checked): while self.app.overrideCursor() is not None: self.app.restoreOverrideCursor() - def addDelROI(self, event): + def addDelROI(self, event): roi, key = self.createDelROI() self.addRoiToDelRoiInfo(roi) if not self.labelsGrad.showLabelsImgAction.isChecked(): @@ -50,25 +46,27 @@ def addDelROI(self, event): self.applyDelROIimg1(roi, init=True, ax=1) if self.isSnapshot: - self.fixCcaDfAfterEdit("Delete IDs using ROI") + self.fixCcaDfAfterEdit('Delete IDs using ROI') self.updateAllImages() else: - self.warnEditingWithCca_df("Delete IDs using ROI", get_cancelled=True) + self.warnEditingWithCca_df( + 'Delete IDs using ROI', get_cancelled=True + ) def addExistingDelROIs(self): posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] isAx2hidden = not self.labelsGrad.showLabelsImgAction.isChecked() - for r, roi in enumerate(delROIs_info["rois"]): + for r, roi in enumerate(delROIs_info['rois']): if isinstance(roi, pg.PolyLineROI) or isAx2hidden: # PolyLine ROIs are only on ax1 self.ax1.addDelRoiItem(roi, roi.key) else: # Rect ROI is on ax2 because ax2 is visible - self.ax2.addDelRoiItem(roi, roi.key) - - self.setDelRoiState(roi, delROIs_info["state"][r]) + self.ax2.addDelRoiItem(roi, roi.key) + + self.setDelRoiState(roi, delROIs_info['state'][r]) def addPointsPolyLineRoi(self, closed=False): self.polyLineRoi.setPoints(self.polyLineRoi.points, closed=closed) @@ -82,46 +80,46 @@ def addPointsPolyLineRoi(self, closed=False): def addRoiToDelRoiInfo(self, roi: pg.ROI): posData = self.data[self.pos_i] for i in range(posData.frame_i, posData.SizeT): - delROIs_info = posData.allData_li[i]["delROIs_info"] - delROIs_info["rois"].append(roi) - delROIs_info["state"].append(roi.getState()) - delROIs_info["delMasks"].append(np.zeros_like(self.currentLab2D)) - delROIs_info["delIDsROI"].append(set()) + delROIs_info = posData.allData_li[i]['delROIs_info'] + delROIs_info['rois'].append(roi) + delROIs_info['state'].append(roi.getState()) + delROIs_info['delMasks'].append(np.zeros_like(self.currentLab2D)) + delROIs_info['delIDsROI'].append(set()) def applyDelROIimg1(self, roi, init=False, ax=0): if ax == 0: how = self.drawIDsContComboBox.currentText() else: how = self.getAnnotateHowRightImage() - + if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): return - - if init and how.find("contours") == -1: + + if init and how.find('contours') == -1: self.setOverlaySegmMasks(force=True) return posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] try: - idx = delROIs_info["rois"].index(roi) - except Exception: + idx = delROIs_info['rois'].index(roi) + except Exception as err: try: ax.removeDelRoiItem(roi) - except Exception: + except Exception as err: pass return - delIDs = delROIs_info["delIDsROI"][idx] - delMask = delROIs_info["delMasks"][idx] - if how.find("nothing") != -1: + delIDs = delROIs_info['delIDsROI'][idx] + delMask = delROIs_info['delMasks'][idx] + if how.find('nothing') != -1: return - elif how.find("contours") != -1: + elif how.find('contours') != -1: self.updateContoursImage(ax=ax) - + if not delIDs: return - - if how.find("overlay segm. masks") != -1: + + if how.find('overlay segm. masks') != -1: lab = self.currentLab2D.copy() lab[delMask > 0] = 0 if ax == 0: @@ -129,30 +127,30 @@ def applyDelROIimg1(self, roi, init=False, ax=0): else: self.labelsLayerRightImg.setImage(lab, autoLevels=False) - self.setAllTextAnnotations(labelsToSkip={ID: True for ID in delIDs}) + self.setAllTextAnnotations(labelsToSkip={ID:True for ID in delIDs}) def applyDelROIs(self): - self.logger.info("Applying deletion ROIs (if present)...") - + self.logger.info('Applying deletion ROIs (if present)...') + for posData in self.data: self.current_frame_i = posData.frame_i for frame_i in range(posData.SizeT): - lab = posData.allData_li[frame_i]["labels"] + lab = posData.allData_li[frame_i]['labels'] if lab is None: break - delROIs_info = posData.allData_li[frame_i]["delROIs_info"] - delIDs_rois = delROIs_info["delIDsROI"] + delROIs_info = posData.allData_li[frame_i]['delROIs_info'] + delIDs_rois = delROIs_info['delIDsROI'] if not delIDs_rois: continue for delIDs in delIDs_rois: for delID in delIDs: - lab[lab == delID] = 0 - posData.allData_li[frame_i]["labels"] = lab + lab[lab==delID] = 0 + posData.allData_li[frame_i]['labels'] = lab # Get the rest of the metadata and store data based on the new lab posData.frame_i = frame_i self.get_data() self.store_data(autosave=False) - + # Back to current frame posData.frame_i = self.current_frame_i self.get_data() @@ -163,17 +161,19 @@ def clearLostObjContoursItems(self): self.ax1_lostTrackedScatterItem.setData([], []) self.ax2_lostTrackedScatterItem.setData([], []) - + self.ax2_lostObjImageItem.clear() self.ax2_lostTrackedObjImageItem.clear() - + self.ax1_lostObjImageItem.clear() self.ax1_lostTrackedObjImageItem.clear() def createDelPolyLineRoi(self): Y, X = self.currentLab2D.shape self.polyLineRoi = pg.PolyLineROI( - [], rotatable=False, removable=True, pen=pg.mkPen(color="r") + [], rotatable=False, + removable=True, + pen=pg.mkPen(color='r') ) self.polyLineRoi.handleSize = 7 self.polyLineRoi.points = [] @@ -181,7 +181,7 @@ def createDelPolyLineRoi(self): self.ax1.addDelRoiItem(self.polyLineRoi, key) def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None): - self.data[self.pos_i] + posData = self.data[self.pos_i] if xl is None: xRange, yRange = self.ax1.viewRange() xl = 0 if xRange[0] < 0 else xRange[0] @@ -189,12 +189,11 @@ def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None): Y, X = self.currentLab2D.shape if anchors is None: roi = widgets.DelROI( - [xl, yb], - [w, h], + [xl, yb], [w, h], rotatable=False, removable=True, - pen=pg.mkPen(color="r"), - maxBounds=QRectF(QRect(0, 0, X, Y)), + pen=pg.mkPen(color='r'), + maxBounds=QRectF(QRect(0,0,X,Y)) ) ## handles scaling horizontally around center roi.addScaleHandle([1, 0.5], [0, 0.5]) @@ -214,13 +213,13 @@ def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None): roi.sigRegionChanged.connect(self.delROImoving) roi.sigRegionChanged.connect(self.delROIstartedMoving) roi.sigRegionChangeFinished.connect(self.delROImovingFinished) - + key = uuid.uuid4() - + return roi, key def delROImoving(self, roi): - roi.setPen(color=(255, 255, 0)) + roi.setPen(color=(255,255,0)) # First bring back IDs if the ROI moved away self.restoreAnnotDelROI(roi) self.setImageImg2() @@ -228,10 +227,12 @@ def delROImoving(self, roi): self.applyDelROIimg1(roi, ax=1) def delROImovingFinished(self, roi: pg.ROI): - roi.setPen(color="r") + roi.setPen(color='r') self.update_rp() self.updateAllImages() - QTimer.singleShot(300, partial(self.updateDelROIinFutureFrames, roi)) + QTimer.singleShot( + 300, partial(self.updateDelROIinFutureFrames, roi) + ) def delROIstartedMoving(self, roi): self.clearLostObjContoursItems() @@ -240,41 +241,42 @@ def getDelROIlab(self, input_lab_2D=None): posData = self.data[self.pos_i] if self.delRoiLab is None: self.initDelRoiLab() - + out_lab = self.delRoiLab if input_lab_2D is None: out_lab[:] = self.get_2Dlab(posData.lab, force_z=False) else: out_lab[:] = input_lab_2D - + allDelIDs = set() # Iterate rois and delete IDs - for roi in posData.allData_li[posData.frame_i]["delROIs_info"]["rois"]: - if not self.ax1.isDelRoiItemPresent( - roi - ) and not self.ax2.isDelRoiItemPresent(roi): - continue + for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: + if ( + not self.ax1.isDelRoiItemPresent(roi) + and not self.ax2.isDelRoiItemPresent(roi) + ): + continue ROImask = self.getDelRoiMask(roi) - delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] - idx = delROIs_info["rois"].index(roi) - delObjROImask = delROIs_info["delMasks"][idx] - delIDsROI = delROIs_info["delIDsROI"][idx] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + idx = delROIs_info['rois'].index(roi) + delObjROImask = delROIs_info['delMasks'][idx] + delIDsROI = delROIs_info['delIDsROI'][idx] delROIlabRp = skimage.measure.regionprops(out_lab) for delObj in delROIlabRp: isDelObj = np.any(ROImask[delObj.slice][delObj.image]) if not isDelObj: continue - + delObjROImask[delObj.slice][delObj.image] = delObj.label out_lab[delObj.slice][delObj.image] = 0 - + delIDsROI.add(delObj.label) allDelIDs.add(delObj.label) # Keep a mask of deleted IDs to bring them back when roi moves - delROIs_info["delMasks"][idx] = delObjROImask - delROIs_info["delIDsROI"][idx] = delIDsROI - + delROIs_info['delMasks'][idx] = delObjROImask + delROIs_info['delIDsROI'][idx] = delIDsROI + # printl( # f't1-t0: {(t1-t0)*1000:.3f} ms,', # f't2-t1: {(t2-t1)*1000:.3f} ms,', @@ -284,7 +286,7 @@ def getDelROIlab(self, input_lab_2D=None): # # f't6-t5: {(t6-t5)*1000:.3f} ms', # sep='\n' # ) - + return allDelIDs, out_lab def getDelRoiMask(self, roi, posData=None, z_slice=None): @@ -298,20 +300,20 @@ def getDelRoiMask(self, roi, posData=None, z_slice=None): x0, y0 = roi.pos().x(), roi.pos().y() for _, point in roi.getLocalHandlePositions(): xr, yr = point.x(), point.y() - r.append(int(yr + y0)) - c.append(int(xr + x0)) + r.append(int(yr+y0)) + c.append(int(xr+x0)) if not r or not c: return ROImask - + if len(r) == 2: rr, cc, val = skimage.draw.line_aa(r[0], c[0], r[1], c[1]) else: rr, cc = skimage.draw.polygon(r, c, shape=self.currentLab2D.shape) - + Y, X = self.currentLab2D.shape - rr = rr[(rr >= 0) & (rr < Y)] - cc = cc[(cc >= 0) & (cc < X)] - + rr = rr[(rr>=0) & (rr=0) & (cc 0: - prev_lab = posData.allData_li[posData.frame_i - 1]["labels"] + prev_lab = posData.allData_li[posData.frame_i-1]['labels'] allDelIDs = set() - for roi in posData.allData_li[posData.frame_i]["delROIs_info"]["rois"]: - if not self.ax1.isDelRoiItemPresent( - roi - ) and not self.ax2.isDelRoiItemPresent(roi): + for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: + if ( + not self.ax1.isDelRoiItemPresent(roi) + and not self.ax2.isDelRoiItemPresent(roi) + ): continue - + ROImask = self.getDelRoiMask(roi) delIDs = posData.lab[ROImask] allDelIDs.update(delIDs) @@ -360,8 +363,8 @@ def getStoredDelRoiIDs(self, frame_i=None): if frame_i is None: frame_i = posData.frame_i allDelIDs = set() - delROIs_info = posData.allData_li[frame_i]["delROIs_info"] - delIDs_rois = delROIs_info["delIDsROI"] + delROIs_info = posData.allData_li[frame_i]['delROIs_info'] + delIDs_rois = delROIs_info['delIDsROI'] for delIDs in delIDs_rois: allDelIDs.update(delIDs) return allDelIDs @@ -371,47 +374,14 @@ def initDelRoiLab(self): z_slice = self.z_lab() img = posData.img_data[posData.frame_i] Y, X = img[z_slice].shape[-2:] - + self.delRoiLab = np.zeros((Y, X), dtype=np.uint32) - def labels_to_skip(self, deleted_ids: Iterable[int]) -> dict[int, bool]: - return {deleted_id: True for deleted_id in deleted_ids} - - LEGACY_METHODS = ( - "removeAlldelROIsCurrentFrame", - "removeDelROI", - "removeDelROIFromFutureFrames", - "updateDelROIinFutureFrames", - "addDelROI", - "replacePolyLineRoiWithLineRoi", - "addRoiToDelRoiInfo", - "addDelPolyLineRoi_cb", - "createDelPolyLineRoi", - "addPointsPolyLineRoi", - "createDelROI", - "delROIstartedMoving", - "clearLostObjContoursItems", - "delROImoving", - "delROImovingFinished", - "restoreAnnotDelROI", - "restoreDelROIimg1", - "getDelRoisIDs", - "getStoredDelRoiIDs", - "getDelROIlab", - "getDelRoiMask", - "initDelRoiLab", - "moveDelRoisToLeft", - "applyDelROIimg1", - "applyDelROIs", - "setDelRoiState", - "addExistingDelROIs", - ) - def moveDelRoisToLeft(self): # Move del ROIs to the left image for posData in self.data: - delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] - for roi in delROIs_info["rois"]: + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + for roi in delROIs_info['rois']: if not self.ax2.isDelRoiItemPresent(roi): continue @@ -420,66 +390,66 @@ def moveDelRoisToLeft(self): def removeAlldelROIsCurrentFrame(self): posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] - rois = delROIs_info["rois"].copy() + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + rois = delROIs_info['rois'].copy() for roi in rois: self.ax2.removeDelRoiItem(roi) for item in self.ax2.items: if isinstance(item, pg.ROI): self.ax2.removeDelRoiItem(item) - + for item in self.ax1.items: if isinstance(item, pg.ROI) and item != self.labelRoiItem: self.ax1.removeDelRoiItem(item) def removeDelROI(self, event): posData = self.data[self.pos_i] - + for ax in (self.ax1, self.ax2): try: self.ax1.removeDelRoiItem(self.roi_to_del) - except Exception: + except Exception as err: pass - - delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] - idx = delROIs_info["rois"].index(self.roi_to_del) - delROIs_info["rois"].pop(idx) - delROIs_info["delMasks"].pop(idx) - delROIs_info["delIDsROI"].pop(idx) - delROIs_info["state"].pop(idx) - + + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + idx = delROIs_info['rois'].index(self.roi_to_del) + delROIs_info['rois'].pop(idx) + delROIs_info['delMasks'].pop(idx) + delROIs_info['delIDsROI'].pop(idx) + delROIs_info['state'].pop(idx) + self.removeDelROIFromFutureFrames(self.roi_to_del) self.updateAllImages() def removeDelROIFromFutureFrames(self, roi_to_del): posData = self.data[self.pos_i] - + # Restore deleted IDs from already visited future frames - current_frame_i = posData.frame_i - for i in range(posData.frame_i + 1, posData.SizeT): - if posData.allData_li[i]["labels"] is None: + current_frame_i = posData.frame_i + for i in range(posData.frame_i+1, posData.SizeT): + if posData.allData_li[i]['labels'] is None: break - - delROIs_info = posData.allData_li[i]["delROIs_info"] + + delROIs_info = posData.allData_li[i]['delROIs_info'] try: - idx = delROIs_info["rois"].index(roi_to_del) + idx = delROIs_info['rois'].index(roi_to_del) except IndexError: continue - + posData.frame_i = i - idx = delROIs_info["rois"].index(roi_to_del) - if delROIs_info["delIDsROI"][idx]: - posData.lab = posData.allData_li[i]["labels"] + idx = delROIs_info['rois'].index(roi_to_del) + if delROIs_info['delIDsROI'][idx]: + posData.lab = posData.allData_li[i]['labels'] self.restoreAnnotDelROI(roi_to_del, enforce=True, draw=False) - posData.allData_li[i]["labels"] = posData.lab + posData.allData_li[i]['labels'] = posData.lab self.get_data() self.store_data(autosave=False) - delROIs_info["rois"].pop(idx) - delROIs_info["delMasks"].pop(idx) - delROIs_info["delIDsROI"].pop(idx) - delROIs_info["state"].pop(idx) - + delROIs_info['rois'].pop(idx) + delROIs_info['delMasks'].pop(idx) + delROIs_info['delIDsROI'].pop(idx) + delROIs_info['state'].pop(idx) + if isinstance(self.roi_to_del, pg.PolyLineROI): # PolyLine ROIs are only on ax1 self.ax1.removeItem(self.roi_to_del) @@ -492,7 +462,7 @@ def removeDelROIFromFutureFrames(self, roi_to_del): # Back to current frame posData.frame_i = current_frame_i - posData.lab = posData.allData_li[posData.frame_i]["labels"] + posData.lab = posData.allData_li[posData.frame_i]['labels'] self.get_data() self.store_data() @@ -501,8 +471,8 @@ def replacePolyLineRoiWithLineRoi(self, roi): (_, point1), (_, point2) = roi.getLocalHandlePositions() xr1, yr1 = point1.x(), point1.y() xr2, yr2 = point2.x(), point2.y() - x1, y1 = xr1 + x0, yr1 + y0 - x2, y2 = xr2 + x0, yr2 + x0 + x1, y1 = xr1+x0, yr1+y0 + x2, y2 = xr2+x0, yr2+x0 lineRoi = pg.LineROI((x1, y1), (x2, y2), width=0.5) lineRoi.handleSize = 7 self.ax1.removeItem(self.polyLineRoi) @@ -516,34 +486,34 @@ def replacePolyLineRoiWithLineRoi(self, roi): def restoreAnnotDelROI(self, roi, enforce=True, draw=True): posData = self.data[self.pos_i] ROImask = self.getDelRoiMask(roi) - delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] try: - idx = delROIs_info["rois"].index(roi) - except Exception: - return - - delMask = delROIs_info["delMasks"][idx] - delIDs = delROIs_info["delIDsROI"][idx] + idx = delROIs_info['rois'].index(roi) + except Exception as err: + return + + delMask = delROIs_info['delMasks'][idx] + delIDs = delROIs_info['delIDsROI'][idx] overlapROIdelIDs = np.unique(delMask[ROImask]) lab2D = self.get_2Dlab(posData.lab) restoredIDs = set() for ID in delIDs: if ID in overlapROIdelIDs and not enforce: continue - + restoredIDs.add(ID) - - delMaskID = delMask == ID + + delMaskID = delMask==ID self.currentLab2D[delMaskID] = ID lab2D[delMaskID] = ID - + if draw: self.restoreDelROIimg1(delMaskID, ID, ax=0) self.restoreDelROIimg1(delMaskID, ID, ax=1) - + delMask[delMaskID] = 0 - - delROIs_info["delIDsROI"][idx] = delIDs - restoredIDs + + delROIs_info['delIDsROI'][idx] = delIDs - restoredIDs self.set_2Dlab(lab2D) self.update_rp() @@ -553,29 +523,23 @@ def restoreDelROIimg1(self, delMaskID, delID, ax=0): else: how = self.getAnnotateHowRightImage() - if how.find("nothing") != -1: + if how.find('nothing') != -1: return - - if how.find("contours") != -1: + + if how.find('contours') != -1: rp_delmask = skimage.measure.regionprops(delMaskID.astype(np.uint8)) if len(rp_delmask) > 0: obj = rp_delmask[0] - self.addObjContourToContoursImage(obj=obj, ax=ax) - elif how.find("overlay segm. masks") != -1: + self.addObjContourToContoursImage(obj=obj, ax=ax) + elif how.find('overlay segm. masks') != -1: if ax == 0: - self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) + self.labelsLayerImg1.setImage( + self.currentLab2D, autoLevels=False + ) else: - self.labelsLayerRightImg.setImage(self.currentLab2D, autoLevels=False) - - def roi_axis( - self, - *, - is_polyline: bool, - labels_image_visible: bool, - ) -> str: - if is_polyline or not labels_image_visible: - return "left" - return "right" + self.labelsLayerRightImg.setImage( + self.currentLab2D, autoLevels=False + ) def setDelRoiState(self, roi: pg.ROI, state): roi.sigRegionChanged.disconnect() @@ -584,61 +548,45 @@ def setDelRoiState(self, roi: pg.ROI, state): roi.sigRegionChanged.connect(self.delROImoving) roi.sigRegionChangeFinished.connect(self.delROImovingFinished) - def should_initialize_overlay_masks( - self, - init: bool, - annotation_mode: str, - ) -> bool: - return init and not self.should_render_deleted_roi_contours(annotation_mode) - - def should_render_deleted_roi(self, annotation_mode: str) -> bool: - return "nothing" not in annotation_mode - - def should_render_deleted_roi_contours(self, annotation_mode: str) -> bool: - return "contours" in annotation_mode - - def should_render_deleted_roi_overlay(self, annotation_mode: str) -> bool: - return "overlay segm. masks" in annotation_mode - def updateDelROIinFutureFrames(self, roi: pg.ROI): posData = self.data[self.pos_i] restore_current_frame = False - + roiState = roi.getState() # Restore deleted IDs from already visited future frames - current_frame_i = posData.frame_i - delROIs_info = posData.allData_li[current_frame_i]["delROIs_info"] + current_frame_i = posData.frame_i + delROIs_info = posData.allData_li[current_frame_i]['delROIs_info'] try: - idx = delROIs_info["rois"].index(roi) - delROIs_info["state"][idx] = roiState - except Exception: + idx = delROIs_info['rois'].index(roi) + delROIs_info['state'][idx] = roiState + except Exception as err: pass - + self.store_data() - - for i in range(posData.frame_i + 1, posData.SizeT): - delROIs_info = posData.allData_li[i]["delROIs_info"] + + for i in range(posData.frame_i+1, posData.SizeT): + delROIs_info = posData.allData_li[i]['delROIs_info'] try: - idx = delROIs_info["rois"].index(roi) - except Exception: + idx = delROIs_info['rois'].index(roi) + except Exception as err: continue - delROIs_info["state"][idx] = roiState - if posData.allData_li[i]["labels"] is None: + delROIs_info['state'][idx] = roiState + if posData.allData_li[i]['labels'] is None: continue - + posData.frame_i = i - posData.lab = posData.allData_li[i]["labels"] + posData.lab = posData.allData_li[i]['labels'] self.restoreAnnotDelROI(roi, enforce=False, draw=False) - posData.allData_li[i]["labels"] = posData.lab + posData.allData_li[i]['labels'] = posData.lab self.get_data() self.store_data(autosave=False) restore_current_frame = True - + if not restore_current_frame: return - + # Back to current frame posData.frame_i = current_frame_i - posData.lab = posData.allData_li[posData.frame_i]["labels"] + posData.lab = posData.allData_li[posData.frame_i]['labels'] self.get_data() self.store_data() diff --git a/cellacdc/mixins/display_decorations.py b/cellacdc/mixins/display_decorations.py new file mode 100644 index 000000000..db7fb5221 --- /dev/null +++ b/cellacdc/mixins/display_decorations.py @@ -0,0 +1,170 @@ +"""View adapter for timestamp, scale-bar, and view-range decorations.""" + +from __future__ import annotations + +import numpy as np + +from cellacdc import apps, widgets + + +class DisplayDecorations: + """Extracted from guiWin.""" + + def addScaleBar(self, checked): + if checked: + posData = self.data[self.pos_i] + Y, X = self.img1.image.shape[:2] + viewRange = self.ax1ViewRange() + self.scaleBarDialog = apps.ScaleBarPropertiesDialog( + X, Y, posData.PhysicalSizeX, parent=self + ) + self.scaleBarDialog.show() + self.scaleBar = widgets.ScaleBar( + (Y, X), viewRange, parent=self.ax1 + ) + self.scaleBar.sigEditProperties.connect(self.editScaleBarProperties) + self.scaleBar.sigRemove.connect( + self.editScaleBarRemove + ) + self.scaleBar.addToAxis(self.ax1) + self.scaleBar.draw(**self.scaleBarDialog.kwargs()) + self.scaleBarDialog.sigValueChanged.connect(self.updateScaleBar) + self.scaleBarDialog.exec_() + if self.scaleBarDialog.cancel: + self.addScaleBarAction.setChecked(False) + return + else: + self.scaleBar.removeFromAxis(self.ax1) + + self.scaleBarDialog = None + self.imgGrad.addScaleBarAction.setChecked(checked) + + def addTimestamp(self, checked): + if checked: + posData = self.data[self.pos_i] + Y, X = self.img1.image.shape[:2] + viewRange = self.ax1ViewRange() + self.timestampDialog = apps.TimestampPropertiesDialog(parent=self) + self.timestampDialog.show() + self.timestamp = widgets.TimestampItem( + Y, X, viewRange, + secondsPerFrame=posData.TimeIncrement, + start_timedelta=self.timestampStartTimedelta + ) + self.timestamp.sigEditProperties.connect( + self.editTimestampProperties + ) + self.timestamp.sigRemove.connect( + self.editTimestampRemove + ) + self.timestamp.addToAxis(self.ax1) + self.timestamp.draw( + posData.frame_i, **self.timestampDialog.kwargs() + ) + self.timestampDialog.sigValueChanged.connect(self.updateTimestamp) + self.timestampDialog.exec_() + else: + self.timestamp.removeFromAxis(self.ax1) + + self.timestampDialog = None + self.imgGrad.addTimestampAction.setChecked(checked) + + def ax1ViewRange(self, integers=False): + if self.exportToImageWindow is None: + viewRange = self.ax1.viewRange() + else: + exportMask = np.all(self.exportMaskImage == [0, 0, 0, 0], axis=-1) + if np.all(exportMask): + viewRange = self.ax1.viewRange() + else: + viewRange = self.ax1.viewRange(exportMask) + + if not integers: + return viewRange + + xRange, yRange = viewRange + xmin = round(xRange[0]) + ymin = round(yRange[0]) + xmax = round(xRange[1]) + ymax = round(yRange[1]) + return [xmin, xmax], [ymin, ymax] + + def getViewRange(self): + Y, X = self.img1.image.shape[:2] + xRange, yRange = self.ax1.viewRange() + xmin = 0 if xRange[0] < 0 else xRange[0] + ymin = 0 if yRange[0] < 0 else yRange[0] + + xmax = X if xRange[1] >= X else xRange[1] + ymax = Y if yRange[1] >= Y else yRange[1] + return int(ymin), int(ymax), int(xmin), int(xmax) + + def editScaleBarProperties(self, properties): + Y, X = self.img1.image.shape[:2] + posData = self.data[self.pos_i] + self.scaleBarDialog = apps.ScaleBarPropertiesDialog( + X, Y, posData.PhysicalSizeX, parent=self, **properties + ) + self.scaleBarDialog.sigValueChanged.connect(self.updateScaleBar) + self.scaleBarDialog.exec_() + + def editScaleBarRemove(self, timestamp): + self.addScaleBarAction.setChecked(False) + + def editTimestampProperties(self, properties): + self.timestampDialog = apps.TimestampPropertiesDialog( + parent=self, **properties + ) + self.timestampDialog.sigValueChanged.connect(self.updateTimestamp) + self.timestampDialog.show() + + def editTimestampRemove(self, timestamp): + self.addTimestampAction.setChecked(False) + + def viewRangeChanged(self, viewBox, viewRange, updateExportImageMask=True): + # self.updateViewRangeExportToImage(viewRange) + self.updateValuesStatusBar() + + if hasattr(self, 'scaleBar'): + isScaleBarMoveWithZoom = ( + self.scaleBar.properties()['move_with_zoom'] + ) + else: + isScaleBarMoveWithZoom = False + doMoveScaleBar = ( + self.scaleBarDialog is not None or isScaleBarMoveWithZoom + ) + if doMoveScaleBar: + self.scaleBar.updatePosViewRangeChanged(viewRange) + + if hasattr(self, 'timestamp'): + isTimestampMoveWithZoom = ( + self.timestamp.properties()['move_with_zoom'] + ) + else: + isTimestampMoveWithZoom = False + + doMoveTimestamp = ( + self.timestampDialog is not None or isTimestampMoveWithZoom + ) + if doMoveTimestamp: + self.timestamp.updatePosViewRangeChanged(viewRange) + + self._viewRange = viewRange + + def updateScaleBar(self, scaleBarKwargs): + self.scaleBar.draw(**scaleBarKwargs) + + def updateTimestamp(self, timeStampKwargs): + posData = self.data[self.pos_i] + self.timestamp.draw(posData.frame_i, **timeStampKwargs) + + def updateTimestampFrame(self): + if not hasattr(self, 'timestamp'): + return + + if not self.addTimestampAction.isChecked(): + return + + posData = self.data[self.pos_i] + self.timestamp.setText(posData.frame_i) diff --git a/cellacdc/mixins/draw_clear_region.py b/cellacdc/mixins/draw_clear_region.py new file mode 100644 index 000000000..7fa9388ea --- /dev/null +++ b/cellacdc/mixins/draw_clear_region.py @@ -0,0 +1,96 @@ +"""View adapter for draw-clear-region workflows.""" + +from __future__ import annotations + + +class DrawClearRegion: + """Extracted from guiWin.""" + + def drawClearRegion_cb(self, checked): + posData = self.data[self.pos_i] + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.drawClearRegionButton) + self.connectLeftClickButtons() + + self.drawClearRegionToolbar.setVisible(checked) + + if not self.isSegm3D: + self.drawClearRegionToolbar.setZslicesControlEnabled(False) + return + + if not checked: + return + + self.drawClearRegionToolbar.setZslicesControlEnabled( + True, SizeZ=posData.SizeZ + ) + + def clearObjsFreehandRegion(self): + self.logger.info('Clearing objects inside freehand region...') + + # Store undo state before modifying stuff + self.storeUndoRedoStates(False, storeImage=False, storeOnlyZoom=True) + + posData = self.data[self.pos_i] + zRange = None + if self.isSegm3D: + zProjHow = self.zProjComboBox.currentText() + isZslice = zProjHow == 'single z-slice' + if isZslice: + z_slice = self.z_lab() + zRange = self.drawClearRegionToolbar.zRange( + z_slice, posData.SizeZ + ) + else: + zRange = (0, posData.SizeZ) + + regionSlice = self.freeRoiItem.slice(zRange=zRange) + mask = self.freeRoiItem.mask() + + regionLab = posData.lab[(...,) + regionSlice].copy() + + clearBorders = ( + self.drawClearRegionToolbar + .clearOnlyEnclosedObjsRadioButton.isChecked() + ) + if clearBorders: + if regionLab.ndim == 2: + regionLab = transformation.clear_objects_not_in_mask( + regionLab, mask + ) + regionRp = skimage.measure.regionprops(regionLab) + for obj in regionRp: + if np.all(mask[obj.slice][obj.image]): + continue + + regionLab[obj.slice][obj.image] = 0 + else: + for z, regionLab_z in enumerate(regionLab): + regionLab[z] = transformation.clear_objects_not_in_mask( + regionLab_z, mask + ) + else: + regionLab[..., ~mask] = 0 + + regionRp = skimage.measure.regionprops(regionLab) + clearIDs = [obj.label for obj in regionRp] + + if not clearIDs: + if clearBorders: + self.logger.warning( + 'None of the objects in the freehand region are ' + 'fully enclosed' + ) + else: + self.logger.warning( + 'None of the objects are touching the freehand region' + ) + return + + self.deleteIDmiddleClick(clearIDs, False, False) + self.update_cca_df_deletedIDs(posData, clearIDs) + + self.freeRoiItem.clear() + + self.updateAllImages() diff --git a/cellacdc/mixins_bak/exporting.py b/cellacdc/mixins/exporting.py similarity index 62% rename from cellacdc/mixins_bak/exporting.py rename to cellacdc/mixins/exporting.py index ded2839cd..c5bf1ac3b 100644 --- a/cellacdc/mixins_bak/exporting.py +++ b/cellacdc/mixins/exporting.py @@ -17,8 +17,8 @@ from cellacdc import exporters, html_utils, prompts, widgets -class ExportingMixin: - """Qt-facing adapter around export dialogs, exporters, and progress UI.""" +class Exporting: + """Extracted from guiWin.""" def askTimelapseOrZslicesVideo(self): txt = html_utils.paragraph(""" @@ -27,73 +27,40 @@ def askTimelapseOrZslicesVideo(self): """) msg = widgets.myMessageBox(wrapText=False) _, timelapseButton = msg.question( - self, - "Z-slices or Timelapse video?", - txt, - buttonsTexts=("Z-slices", "Timelapse"), + self, 'Z-slices or Timelapse video?', txt, + buttonsTexts=('Z-slices', 'Timelapse') ) if msg.cancel: - return - + return + return msg.clickedButton == timelapseButton - def build_export_mask_image( - self, - image_shape, - view_range, - *, - invert_bw=False, - ): - mask_image = np.zeros( - self.export_mask_image_shape(image_shape), - dtype=np.uint8, - ) - x_range, y_range = view_range - x0, x1 = map(round, x_range) - y0, y1 = map(round, y_range) - - if invert_bw: - mask_image[:, :, :3] = 255 - - if x0 > 0: - mask_image[:, :x0, 3] = 255 - if x1 < mask_image.shape[1]: - mask_image[:, x1:, 3] = 255 - if y0 > 0: - mask_image[:y0, :, 3] = 255 - if y1 < mask_image.shape[0]: - mask_image[y1:, :, 3] = 255 - - return mask_image - def exportAddScaleBar(self, checked): self.addScaleBarAction.setChecked(checked) - @exception_handler def exportFrame(self): - nd = self.exportToVideoPreferences["num_digits"] + nd = self.exportToVideoPreferences['num_digits'] idx = str(self.exportToVideoCurrentNavVarIdx).zfill(nd) - filename = self.exportToVideoPreferences["filename"] - png_filename = f"{idx}_{filename}.png" - pngs_folderpath = self.exportToVideoPreferences["pngs_folderpath"] - + filename = self.exportToVideoPreferences['filename'] + png_filename = f'{idx}_{filename}.png' + pngs_folderpath = self.exportToVideoPreferences['pngs_folderpath'] + png_filepath = os.path.join(pngs_folderpath, png_filename) img_bgr = self.exportToVideoImageExporter.export(png_filepath) self.exportToVideoExporter.add_frame(img_bgr) return True - @disableWindow - def exportToImage(self, preferences): - filepath = preferences["filepath"] + def exportToImage(self, preferences): + filepath = preferences['filepath'] self.logger.info(f'Saving image to "{filepath}"...') - - if filepath.endswith(".svg"): + + if filepath.endswith('.svg'): exporter = exporters.SVGExporter(self.ax1) else: - exporter = exporters.ImageExporter(self.ax1, dpi=preferences["dpi"]) + exporter = exporters.ImageExporter(self.ax1, dpi=preferences['dpi']) exporter.export(filepath) - self.logger.info("Image saved.") - + self.logger.info(f'Image saved.') + self.setDisabled(False) self.exportMaskImage[:] = 0 self.exportMaskImageItem.setImage(self.exportMaskImage) @@ -101,14 +68,14 @@ def exportToImage(self, preferences): def exportToImageTriggered(self): posData = self.data[self.pos_i] - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"{timestamp}_acdc_exported_image" + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f'{timestamp}_acdc_exported_image' win = apps.ExportToImageParametersDialog( - parent=self, - startFolderpath=posData.pos_path, - startFilename=filename, + parent=self, + startFolderpath=posData.pos_path, + startFilename=filename, startViewRange=self.ax1.viewRange(), - isScaleBarPresent=self.addScaleBarAction.isChecked(), + isScaleBarPresent=self.addScaleBarAction.isChecked(), ) win.sigAddScaleBar.connect(self.exportAddScaleBar) win.sigRangeChanged.connect( @@ -125,19 +92,19 @@ def exportToImageTriggered(self): self.exportMaskImage[:] = 0 self.exportMaskImageItem.setImage(self.exportMaskImage) self.exportToImageWindow = None - self.logger.info("Export to image process cancelled") + self.logger.info('Export to image process cancelled') return isTransparent = self.overlayToolbar.isTransparent() - if not isTransparent: - # SVG export works only with RGBA not with setOpacity + if not isTransparent: + # SVG export works only with RGBA not with setOpacity # --> only true transparency mode can be used self.overlayToolbar.setTransparent(True) - + self.exportToImage(win.selected_preferences) self.exportToImageWindow = None - - if not isTransparent: + + if not isTransparent: self.overlayToolbar.setTransparent(False) def exportToVideoAddTimestamp(self, checked): @@ -147,155 +114,138 @@ def exportToVideoFinished(self, conversion_to_mp4_successful): self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - + # Back to current frame - if self.exportToVideoPreferences["is_timelapse"]: + if self.exportToVideoPreferences['is_timelapse']: posData = self.data[self.pos_i] - posData.frame_i = self.exportToVideoNavVarIdxToRestore + posData.frame_i = self.exportToVideoNavVarIdxToRestore self.get_data() self.store_data() self.updateAllImages() - self.navigateScrollBar.setSliderPosition(posData.frame_i + 1) - self.navSpinBox.setValue(posData.frame_i + 1) + self.navigateScrollBar.setSliderPosition(posData.frame_i+1) + self.navSpinBox.setValue(posData.frame_i+1) else: self.update_z_slice(self.exportToVideoNavVarIdxToRestore) - + self.setDisabled(False) self.isExportingVideo = False - + if not self.isTransparent: - # True transparency mode was activated programmatically + # True transparency mode was activated programmatically # --> restore what the user had before starting to export self.overlayToolbar.setTransparent(False) - + prompts.exportToVideoFinished( - self.exportToVideoPreferences, conversion_to_mp4_successful, qparent=self + self.exportToVideoPreferences, conversion_to_mp4_successful, + qparent=self ) def exportToVideoTriggered(self): posData = self.data[self.pos_i] - + doTimelapseVideo = posData.SizeT > 1 if posData.SizeT > 1 and posData.SizeZ > 1: doTimelapseVideo = self.askTimelapseOrZslicesVideo() - + if doTimelapseVideo is None: - self.logger.info("Export to video process cancelled") + self.logger.info('Export to video process cancelled') return - + channels = [self.user_ch_name, *self.checkedOverlayChannels] - mode = "timelapse" if doTimelapseVideo else "z_slices" - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"{timestamp}_acdc_exported_{mode}_video" + mode = 'timelapse' if doTimelapseVideo else 'z_slices' + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f'{timestamp}_acdc_exported_{mode}_video' win = apps.ExportToVideoParametersDialog( channels, - parent=self, - startFolderpath=posData.pos_path, - startFilename=filename, - startFrameNum=posData.frame_i + 1, - SizeT=posData.SizeT, + parent=self, + startFolderpath=posData.pos_path, + startFilename=filename, + startFrameNum=posData.frame_i+1, + SizeT=posData.SizeT, SizeZ=posData.SizeZ, isTimelapseVideo=doTimelapseVideo, - isScaleBarPresent=self.addScaleBarAction.isChecked(), + isScaleBarPresent=self.addScaleBarAction.isChecked(), isTimestampPresent=self.addTimestampAction.isChecked(), - rescaleIntensChannelHowMapper=self.rescaleIntensChannelHowMapper, + rescaleIntensChannelHowMapper=self.rescaleIntensChannelHowMapper ) win.sigAddScaleBar.connect(self.exportAddScaleBar) win.sigAddTimestamp.connect(self.exportToVideoAddTimestamp) win.sigRescaleIntensLut.connect(self.rescaleIntensExportToVideoDialog) win.exec_() if win.cancel: - self.logger.info("Export to video process cancelled") + self.logger.info('Export to video process cancelled') return - + cancel = _warnings.warnExportToVideo(qparent=self) if cancel: - self.logger.info("Export to video process cancelled") + self.logger.info('Export to video process cancelled') return - - self.startExportToVideoWorker(win.selected_preferences) - - def export_frame_plan( - self, - *, - current_index: int, - num_digits: int, - filename: str, - pngs_folderpath: str, - ) -> ExportFramePlan: - frame_index_text = str(current_index).zfill(num_digits) - png_filename = f"{frame_index_text}_{filename}.png" - return ExportFramePlan( - frame_index_text=frame_index_text, - png_filename=png_filename, - png_filepath=os.path.join(pngs_folderpath, png_filename), - ) - - def export_mask_image_shape(self, image_shape) -> tuple[int, int, int]: - height, width = image_shape[-2:] - return height, width, 4 + + self.startExportToVideoWorker(win.selected_preferences) def exportingFramesFinished(self): - if not self.exportToVideoPreferences["save_pngs"]: - self.logger.info("Removing PNGs...") + if not self.exportToVideoPreferences['save_pngs']: + self.logger.info('Removing PNGs...') try: - shutil.rmtree(self.exportToVideoPreferences["pngs_folderpath"]) - except Exception: + shutil.rmtree(self.exportToVideoPreferences['pngs_folderpath']) + except Exception as err: pass - - self.logger.info("Saving video...") - + + self.logger.info('Saving video...') + self.exportToVideoExporter.release() - + # Run ffmpeg new process conversion_to_mp4_successful = True - if self.exportToVideoPreferences["filepath"].endswith(".mp4"): + if self.exportToVideoPreferences['filepath'].endswith('.mp4'): try: self.exportToVideoExporter.avi_to_mp4() try: - os.remove(self.exportToVideoPreferences["avi_filepath"]) - except Exception: + os.remove(self.exportToVideoPreferences['avi_filepath']) + except Exception as err: pass - except Exception: + except Exception as err: self.logger.exception(traceback.format_exc()) - self.logger.info("Conversion to MP4 failed. See traceback above.") + self.logger.info( + 'Conversion to MP4 failed. See traceback above.' + ) conversion_to_mp4_successful = False - self.exportToVideoPreferences["filepath"] = ( + self.exportToVideoPreferences['filepath'] = ( self.exportToVideoExporter._avi_filepath ) - + self.exportToVideoFinished(conversion_to_mp4_successful) def exportingVideoCritical(self): self.setDisabled(False) - + self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - - self.logger.info("Exporting video process failed.") + + self.logger.info('Exporting video process failed.') def getZoomIDs(self, viewRange=None): if viewRange is None: viewRange = self.ax1.viewRange() - + lab = self.currentLab2D Y, X = lab.shape ((xmin, xmax), (ymin, ymax)) = viewRange if xmin <= 0 and ymin <= 0 and xmax >= X and ymax >= Y: - self.data[self.pos_i] + posData = self.data[self.pos_i] return None - + xmin = xmin if xmin >= 0 else 0 ymin = ymin if ymin >= 0 else 0 xmax = xmax if xmax < X else X ymax = ymax if ymax < Y else Y - + zoomSlice = ( - slice(round(ymin), round(ymax)), - slice(round(xmin), round(xmax)), + slice(round(ymin), round(ymax)), + slice(round(xmin), round(xmax)), ) - + zoomLab = skimage.segmentation.clear_border(lab[zoomSlice]) zoomRp = skimage.measure.regionprops(zoomLab) zoomIDs = [obj.label for obj in zoomRp] @@ -306,35 +256,35 @@ def initExportMaskImage(self): z_slice = self.z_lab() img = posData.img_data[posData.frame_i] Y, X = img[z_slice].shape[-2:] - + self.exportMaskImage = np.zeros((Y, X, 4), dtype=np.uint8) def onSigUpdateCcaTableWindow(self, *args): if not self.isDataLoaded: return - + if self.ccaTableWin is None: return - + viewRange = self.ax1.viewRange() posData = self.data[self.pos_i] zoomIDs = self.getZoomIDs(viewRange=viewRange) - + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) def setExportMaskImage(self, viewRange): - if not hasattr(self, "exportMaskImage"): + if not hasattr(self, 'exportMaskImage'): self.initExportMaskImage() else: self.exportMaskImage[:] = 0 - + xRange, yRange = viewRange x0, x1 = map(round, xRange) y0, y1 = map(round, yRange) - + if self.invertBwAction.isChecked(): self.exportMaskImage[:, :, :3] = 255 - + if x0 > 0: self.exportMaskImage[:, :x0, 3] = 255 if x1 < self.exportMaskImage.shape[1]: @@ -343,7 +293,7 @@ def setExportMaskImage(self, viewRange): self.exportMaskImage[:y0, :, 3] = 255 if y1 < self.exportMaskImage.shape[0]: self.exportMaskImage[y1:, :, 3] = 255 - + self.exportMaskImageItem.setImage(self.exportMaskImage) def setViewRangeFromExportToImageDialog(self, viewRange, win=None): @@ -356,90 +306,79 @@ def setViewRangeFromExportToImageDialog(self, viewRange, win=None): # ) self.setExportMaskImage(viewRange) - def shifted_view_range(self, previous_range, current_range, window_range): - prev_x_range, prev_y_range = previous_range - curr_x_range, curr_y_range = current_range - win_x_range, win_y_range = window_range - - delta_x = curr_x_range[0] - prev_x_range[0] - delta_y = curr_y_range[0] - prev_y_range[0] - - return ( - (win_x_range[0] + delta_x, win_x_range[1] + delta_x), - (win_y_range[0] + delta_y, win_y_range[1] + delta_y), - ) - def startExportToVideoWorker(self, preferences): self.isExportingVideo = True self.isTransparent = self.overlayToolbar.isTransparent() - if not self.isTransparent: - # SVG export works only with RGBA not with setOpacity + if not self.isTransparent: + # SVG export works only with RGBA not with setOpacity # --> only true transparency mode can be used self.overlayToolbar.setTransparent(True) - + self.setDisabled(True) - + self.progressWin = apps.QDialogWorkerProgress( - title="Exporting to video", - parent=self.mainWin, - pbarDesc="Exporting to video...", + title='Exporting to video', parent=self.mainWin, + pbarDesc='Exporting to video...' ) self.progressWin.show(self.app) - self.exportToVideoStopNavVarNum = preferences["stop_nav_var_num"] + self.exportToVideoStopNavVarNum = preferences['stop_nav_var_num'] self.numFramesExported = 0 self.progressWin.mainPbar.setMaximum( - preferences["stop_nav_var_num"] - preferences["start_nav_var_num"] + 1 + preferences['stop_nav_var_num'] + - preferences['start_nav_var_num'] + 1 ) self.exportToVideoPreferences = preferences - + self.store_data() posData = self.data[self.pos_i] - if self.exportToVideoPreferences["is_timelapse"]: - # Go to requested start frame - posData.frame_i = preferences["start_nav_var_num"] - 1 + if self.exportToVideoPreferences['is_timelapse']: + # Go to requested start frame + posData.frame_i = preferences['start_nav_var_num'] - 1 self.get_data() self.updateAllImages() self.exportToVideoNavVarIdxToRestore = posData.frame_i else: - self.update_z_slice(preferences["start_nav_var_num"] - 1) - self.exportToVideoNavVarIdxToRestore = self.zSliceScrollBar.sliderPosition() - self.exportToVideoCurrentNavVarIdx = preferences["start_nav_var_num"] - 1 - + self.update_z_slice(preferences['start_nav_var_num'] - 1) + self.exportToVideoNavVarIdxToRestore = ( + self.zSliceScrollBar.sliderPosition() + ) + self.exportToVideoCurrentNavVarIdx = ( + preferences['start_nav_var_num'] - 1 + ) + self.exportToVideoImageExporter = exporters.ImageExporter( - self.ax1, save_pngs=preferences["save_pngs"], dpi=preferences["dpi"] + self.ax1, + save_pngs=preferences['save_pngs'], + dpi=preferences['dpi'] ) self.exportToVideoExporter = exporters.VideoExporter( - preferences["avi_filepath"], preferences["fps"] + preferences['avi_filepath'], preferences['fps'] ) - + QTimer.singleShot(200, self.updateAndExportFrame) - def timestamped_export_filename(self, kind: str, *, timestamp=None): - if timestamp is None: - timestamp = datetime.now() - return f"{timestamp.strftime('%Y%m%d_%H%M%S')}_acdc_exported_{kind}" - def updateAndExportFrame(self): didVideoExporterFinish = ( - self.exportToVideoCurrentNavVarIdx == self.exportToVideoStopNavVarNum + self.exportToVideoCurrentNavVarIdx + == self.exportToVideoStopNavVarNum ) if didVideoExporterFinish: self.progressWin.mainPbar.setMaximum(0) self.progressWin.mainPbar.setValue(0) QTimer.singleShot(50, self.exportingFramesFinished) return - - self.data[self.pos_i] - if self.exportToVideoPreferences["is_timelapse"]: - self.goToFrameNumber(self.exportToVideoCurrentNavVarIdx + 1) + + posData = self.data[self.pos_i] + if self.exportToVideoPreferences['is_timelapse']: + self.goToFrameNumber(self.exportToVideoCurrentNavVarIdx+1) else: self.update_z_slice(self.exportToVideoCurrentNavVarIdx) - + success = self.exportFrame() if success is None: self.exportingVideoCritical() return - + self.exportToVideoCurrentNavVarIdx += 1 self.progressWin.mainPbar.update(1) @@ -448,52 +387,33 @@ def updateAndExportFrame(self): def updateViewRangeExportToImage(self, viewRange): if self.exportToImageWindow is None: return - + # prevViewRange = self.exportToImageWindow.viewRange() prevViewRange = self._viewRange prevXRange = prevViewRange[0] prevYRange = prevViewRange[1] currXRange = viewRange[0] currYRange = viewRange[1] - + prevX0, prevX1 = prevXRange currX0, currX1 = currXRange prevY0, prevY1 = prevYRange currY0, currY1 = currYRange - + deltaX = currX0 - prevX0 deltaY = currY0 - prevY0 - + winViewRange = self.exportToImageWindow.viewRange() winXRange = winViewRange[0] winYRange = winViewRange[1] winX0, winX1 = winXRange winY0, winY1 = winYRange - + newX0 = winX0 + deltaX newX1 = winX1 + deltaX newY0 = winY0 + deltaY newY1 = winY1 + deltaY - + self.exportToImageWindow.setViewRange( (newX0, newX1), (newY0, newY1), emitSignal=False ) - - def zoom_ids(self, labels_2d, view_range): - height, width = labels_2d.shape - ((xmin, xmax), (ymin, ymax)) = view_range - if xmin <= 0 and ymin <= 0 and xmax >= width and ymax >= height: - return None - - xmin = max(xmin, 0) - ymin = max(ymin, 0) - xmax = min(xmax, width) - ymax = min(ymax, height) - - zoom_slice = ( - slice(round(ymin), round(ymax)), - slice(round(xmin), round(xmax)), - ) - zoom_labels = skimage.segmentation.clear_border(labels_2d[zoom_slice]) - zoom_regionprops = skimage.measure.regionprops(zoom_labels) - return [obj.label for obj in zoom_regionprops] diff --git a/cellacdc/mixins_bak/frame_navigation.py b/cellacdc/mixins/frame_navigation.py similarity index 71% rename from cellacdc/mixins_bak/frame_navigation.py rename to cellacdc/mixins/frame_navigation.py index b18d33d54..b50ca8846 100644 --- a/cellacdc/mixins_bak/frame_navigation.py +++ b/cellacdc/mixins/frame_navigation.py @@ -19,12 +19,8 @@ SliderMove = QtScoped.SliderMove() -class FrameNavigationMixin: - """Qt-facing adapter for frame and position navigation workflows.""" - - # @exec_time - - # @exec_time +class FrameNavigation: + """Extracted from guiWin.""" def PosScrollBarAction(self, action): if action == SliderSingleStepAdd: @@ -38,9 +34,9 @@ def PosScrollBarAction(self, action): def PosScrollBarMoved(self, pos_n): if self.navigateScrollBarStartedMoving: - self.store_data() - - self.pos_i = pos_n - 1 + self.store_data() + + self.pos_i = pos_n-1 self.updateFramePosLabel() proceed_cca, never_visited = self.get_data() self.updateAllImages() @@ -49,11 +45,11 @@ def PosScrollBarMoved(self, pos_n): def PosScrollBarReleased(self): self.navigateScrollBarStartedMoving = True - if self.pos_i == self.navigateScrollBar.sliderPosition() - 1: + if self.pos_i == self.navigateScrollBar.sliderPosition()-1: # Slider released without changing value --> do nothing return - - self.pos_i = self.navigateScrollBar.sliderPosition() - 1 + + self.pos_i = self.navigateScrollBar.sliderPosition()-1 self.updateFramePosLabel() self.updatePos() @@ -62,73 +58,70 @@ def _setViewRangeSwitchPlane(self, previousPlane): SizeZ = posData.SizeZ SizeY, SizeX = self.img1.image.shape[:2] currentPlane = self.switchPlaneCombobox.plane() - if previousPlane == "xy": - if currentPlane == "zy": + if previousPlane == 'xy': + if currentPlane == 'zy': self.ax1.setRange(xRange=self.yRangePrev) unusedRange = np.clip(self.xRangePrev, 0, SizeX) - elif currentPlane == "zx": + elif currentPlane == 'zx': self.ax1.setRange(xRange=self.xRangePrev) unusedRange = np.clip(self.yRangePrev, 0, SizeY) - elif previousPlane == "zy": - if currentPlane == "xy": + elif previousPlane == 'zy': + if currentPlane == 'xy': self.ax1.setRange(yRange=self.xRangePrev) unusedRange = np.clip(self.yRangePrev, 0, SizeZ) - elif currentPlane == "zx": + elif currentPlane == 'zx': self.ax1.setRange(yRange=self.yRangePrev) unusedRange = np.clip(self.xRangePrev, 0, SizeY) - elif previousPlane == "zx": - if currentPlane == "xy": + elif previousPlane == 'zx': + if currentPlane == 'xy': self.ax1.setRange(xRange=self.xRangePrev) unusedRange = np.clip(self.yRangePrev, 0, SizeZ) - elif currentPlane == "zy": + elif currentPlane == 'zy': self.ax1.setRange(yRange=self.yRangePrev) unusedRange = np.clip(self.xRangePrev, 0, SizeX) - - sliceValue = round((unusedRange[0] + unusedRange[1]) / 2) + + sliceValue = round((unusedRange[0] + unusedRange[1])/2) self.zSliceScrollBar.setSliderPosition(sliceValue) self.update_z_slice(self.zSliceScrollBar.sliderPosition()) def apply_tools_on_new_frame(self): mode = str(self.modeComboBox.currentText()) - if mode != "Segmentation and Tracking": + if mode != 'Segmentation and Tracking': return posData = self.data[self.pos_i] - if ( - not (posData.last_tracked_i <= posData.frame_i) - or posData.frame_i == self.lastFrameRanOnFirstVisitTools - ): + if not (posData.last_tracked_i <= posData.frame_i) or posData.frame_i == self.lastFrameRanOnFirstVisitTools: return - + self.lastFrameRanOnFirstVisitTools = posData.frame_i for name, checkbox in self.applyToolNewFrameActions.items(): if not checkbox.isChecked(): continue - + tool_button = self.applyToolNewFrameButtons[name] try: - if hasattr(tool_button, "click"): + if hasattr(tool_button, 'click'): tool_button.click() - elif hasattr(tool_button, "trigger"): + elif hasattr(tool_button, 'trigger'): tool_button.trigger() else: - printl(f"Warning: {name} has no click or trigger method") + printl( + f"Warning: {name} has no click or trigger method" + ) except Exception as e: self.logger.info(f"Error applying tool {name}: {e}") def askInitCcaFirstFrame(self): mode = str(self.modeComboBox.currentText()) - if mode != "Cell cycle analysis": + if mode != 'Cell cycle analysis': return True - + posData = self.data[self.pos_i] if posData.frame_i != 0: return True - + editCcaWidget = apps.editCcaTableWidget( - posData.cca_df, - posData.SizeT, - parent=self, - title="Initialize cell cycle annotations", + posData.cca_df, posData.SizeT, parent=self, + title='Initialize cell cycle annotations' ) editCcaWidget.sigApplyChangesFutureFrames.connect( self.applyManualCcaChangesFutureFrames @@ -137,66 +130,54 @@ def askInitCcaFirstFrame(self): if editCcaWidget.cancel: self.resetNavigateFramesScrollbar() return False - + if posData.cca_df is not None: - is_cca_same_as_stored = (posData.cca_df == editCcaWidget.cca_df).all( - axis=None + is_cca_same_as_stored = ( + (posData.cca_df == editCcaWidget.cca_df).all(axis=None) ) if not is_cca_same_as_stored: reinit_cca = self.warnEditingWithCca_df( - "Re-initialize cell cyle annotations first frame", - return_answer=True, + 'Re-initialize cell cyle annotations first frame', + return_answer=True ) if reinit_cca: self.resetCcaFuture(0) - + posData.cca_df = editCcaWidget.cca_df self.store_cca_df() - + return True def askInitLinTreeFirstFrame(self): mode = str(self.modeComboBox.currentText()) - if mode != "Normal division: Lineage tree": + if mode != 'Normal division: Lineage tree': return True - + posData = self.data[self.pos_i] if posData.frame_i != 0: return True - + if self.lineage_tree is None: - self.initLinTree() - + self.initLinTree() + return True - def blocks_future_manual_annotation( - self, - *, - manual_annotation_enabled: bool, - current_frame_i: int, - frame_to_restore, - ) -> bool: - if not manual_annotation_enabled: - return False - if frame_to_restore is None: - return False - return current_frame_i > frame_to_restore - def checkIfFutureFrameManualAnnotPastFrames(self): if not self.manualAnnotPastButton.isChecked(): return True - + posData = self.data[self.pos_i] - frame_to_restore = self.manualAnnotState.get("frame_i_to_restore") + frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') if posData.frame_i <= frame_to_restore: return True - + warn_txt = ( - "WARNING: Cannot navigate to future frames while in manual annotation mode." + 'WARNING: Cannot navigate to future frames while in ' + 'manual annotation mode.' ) self.logger.info(warn_txt) self.statusBarLabel.setText(f'

{warn_txt}

') - + return False def connectScrollbars(self): @@ -206,9 +187,9 @@ def connectScrollbars(self): if self.data[0].SizeZ > 1: self.enableZstackWidgets(True) - self.zSliceScrollBar.setMaximum(self.data[0].SizeZ - 1) + self.zSliceScrollBar.setMaximum(self.data[0].SizeZ-1) self.zSliceSpinbox.setMaximum(self.data[0].SizeZ) - self.SizeZlabel.setText(f"/{self.data[0].SizeZ}") + self.SizeZlabel.setText(f'/{self.data[0].SizeZ}') try: self.zSliceScrollBar.actionTriggered.disconnect() self.zSliceScrollBar.sliderReleased.disconnect() @@ -216,47 +197,47 @@ def connectScrollbars(self): self.zProjComboBox.activated.disconnect() self.switchPlaneCombobox.sigPlaneChanged.disconnect() self.zProjLockViewButton.toggled.disconnect() - except Exception: + except Exception as e: pass self.zSliceScrollBar.actionTriggered.connect( self.zSliceScrollBarActionTriggered ) - self.zSliceScrollBar.sliderReleased.connect(self.zSliceScrollBarReleased) + self.zSliceScrollBar.sliderReleased.connect( + self.zSliceScrollBarReleased + ) self.zProjComboBox.currentTextChanged.connect(self.updateZproj) self.zProjComboBox.activated.connect(self.clearComboBoxFocus) - self.switchPlaneCombobox.sigPlaneChanged.connect(self.switchViewedPlane) + self.switchPlaneCombobox.sigPlaneChanged.connect( + self.switchViewedPlane + ) self.zProjLockViewButton.toggled.connect(self.zProjLockViewToggled) posData = self.data[self.pos_i] if posData.SizeT == 1: - self.t_label.setText("Position n.") + self.t_label.setText('Position n.') self.navigateScrollBar.setMinimum(1) self.navigateScrollBar.setMaximum(len(self.data)) self.navigateScrollBar.setAbsoluteMaximum(len(self.data)) self.navSpinBox.setMaximum(len(self.data)) - self.navigateScrollBar.connectEvents( - { - "sliderMoved": self.PosScrollBarMoved, - "sliderReleased": self.PosScrollBarReleased, - "actionTriggered": self.PosScrollBarAction, - } - ) + self.navigateScrollBar.connectEvents({ + 'sliderMoved': self.PosScrollBarMoved, + 'sliderReleased': self.PosScrollBarReleased, + 'actionTriggered': self.PosScrollBarAction + }) else: self.navigateScrollBar.setMinimum(1) self.navigateScrollBar.setAbsoluteMaximum(posData.SizeT) self.rightImageFramesScrollbar.setMinimum(1) self.rightImageFramesScrollbar.setMaximum(posData.SizeT) if posData.last_tracked_i is not None: - self.navigateScrollBar.setMaximum(posData.last_tracked_i + 1) - self.navSpinBox.setMaximum(posData.last_tracked_i + 1) - self.t_label.setText("Frame n.") - self.navigateScrollBar.connectEvents( - { - "sliderMoved": self.framesScrollBarMoved, - "sliderReleased": self.framesScrollBarReleased, - "actionTriggered": self.framesScrollBarActionTriggered, - } - ) + self.navigateScrollBar.setMaximum(posData.last_tracked_i+1) + self.navSpinBox.setMaximum(posData.last_tracked_i+1) + self.t_label.setText('Frame n.') + self.navigateScrollBar.connectEvents({ + 'sliderMoved': self.framesScrollBarMoved, + 'sliderReleased': self.framesScrollBarReleased, + 'actionTriggered': self.framesScrollBarActionTriggered + }) self.rightImageFramesScrollbar.connectValueChanged( self.rightImageFramesScrollbarValueChanged ) @@ -293,18 +274,18 @@ def framesScrollBarActionTriggered(self, action): def framesScrollBarMoved(self, frame_n): if self.navigateScrollBarStartedMoving: mode = str(self.modeComboBox.currentText()) - if mode != "Viewer": + if mode != 'Viewer': self.store_data(debug=False) - + posData = self.data[self.pos_i] - posData.frame_i = frame_n - 1 - if posData.allData_li[posData.frame_i]["labels"] is None: + posData.frame_i = frame_n-1 + if posData.allData_li[posData.frame_i]['labels'] is None: if posData.frame_i < len(posData.segm_data): posData.lab = posData.segm_data[posData.frame_i] else: posData.lab = np.zeros_like(posData.segm_data[0]) else: - posData.lab = posData.allData_li[posData.frame_i]["labels"] + posData.lab = posData.allData_li[posData.frame_i]['labels'] self.setImageImg1() if self.overlayButton.isChecked(): @@ -313,7 +294,7 @@ def framesScrollBarMoved(self, frame_n): if self.navigateScrollBarStartedMoving: self.clearAllItems() - self.navSpinBox.setValueNoEmit(posData.frame_i + 1) + self.navSpinBox.setValueNoEmit(posData.frame_i+1) if self.labelsGrad.showLabelsImgAction.isChecked(): self.img2.setImage(posData.lab, z=self.z_lab(), autoLevels=False) self.updateLookuptable() @@ -325,16 +306,16 @@ def framesScrollBarMoved(self, frame_n): def framesScrollBarReleased(self, do_store_data=False): posData = self.data[self.pos_i] - if posData.frame_i == self.navigateScrollBar.sliderPosition() - 1: + if posData.frame_i == self.navigateScrollBar.sliderPosition()-1: # Slider released without changing value --> do nothing return - + mode = str(self.modeComboBox.currentText()) - if mode != "Viewer" and do_store_data: + if mode != 'Viewer' and do_store_data: self.store_data(debug=False) - + self.navigateScrollBarStartedMoving = True - posData.frame_i = self.navigateScrollBar.sliderPosition() - 1 + posData.frame_i = self.navigateScrollBar.sliderPosition()-1 self.updateFramePosLabel() proceed_cca, never_visited = self.get_data() self.updateAllImages() @@ -342,7 +323,7 @@ def framesScrollBarReleased(self, do_store_data=False): def goToZsliceSearchedID(self, obj): if not self.isSegm3D: return - + current_z = self.z_lab() nearest_nonzero_z = core.nearest_nonzero_z_idx_from_z_centroid( obj, current_z=current_z @@ -350,7 +331,7 @@ def goToZsliceSearchedID(self, obj): if nearest_nonzero_z == current_z: self.drawPointsLayers(computePointsLayers=True) return - + self.zSliceScrollBar.setSliderPosition(nearest_nonzero_z) self.update_z_slice(nearest_nonzero_z) @@ -358,39 +339,36 @@ def isNavigateActionOnNextFrame(self): posData = self.data[self.pos_i] if posData.SizeT == 1: return False - + ax1_coords = self.getMouseDataCoordsRightImage() if ax1_coords is None: return False - + if not self.labelsGrad.showNextFrameAction.isEnabled(): return False - + if not self.labelsGrad.showNextFrameAction.isChecked(): return - + # Mouse is on right image and next frame action is checked - return True - - def is_single_z_slice_projection(self, how: str) -> bool: - return how == "single z-slice" + return True def manualAnnotRestoreLastTrackedFrame(self, last_tracked_i_to_restore): - if self.navigateScrollBar.maximum() - 1 <= last_tracked_i_to_restore: + if self.navigateScrollBar.maximum()-1 <= last_tracked_i_to_restore: return - + posData = self.data[self.pos_i] - for frame_i in range(last_tracked_i_to_restore + 1, posData.SizeT): + for frame_i in range(last_tracked_i_to_restore+1, posData.SizeT): data_frame_i = myutils.get_empty_stored_data_dict() - - data_frame_i["manually_edited_lab"] = posData.allData_li[frame_i][ - "manually_edited_lab" - ] - + + data_frame_i['manually_edited_lab'] = ( + posData.allData_li[frame_i]['manually_edited_lab'] + ) + posData.allData_li[frame_i] = data_frame_i - - self.navigateScrollBar.setMaximum(last_tracked_i_to_restore + 1) - self.navSpinBox.setMaximum(last_tracked_i_to_restore + 1) + + self.navigateScrollBar.setMaximum(last_tracked_i_to_restore+1) + self.navSpinBox.setMaximum(last_tracked_i_to_restore+1) def navigateSpinboxEditingFinished(self): if self.isSnapshot: @@ -406,54 +384,13 @@ def navigateSpinboxValueChanged(self, value): self.navigateScrollBarStartedMoving = True self.framesScrollBarMoved(value) - def navigation_limit( - self, - *, - mode: str, - frame_i: int, - last_tracked_i: int | None, - last_cca_frame_i: int, - lineage_tree_frames, - ) -> NavigationLimit | None: - if mode == self.segmentation_mode: - if last_tracked_i is None or frame_i > last_tracked_i: - maximum = frame_i + 1 - else: - maximum = last_tracked_i + 1 - return NavigationLimit( - maximum=maximum, - last_checked_frame_i=maximum - 1, - ) - if mode == self.cell_cycle_mode: - maximum = max(frame_i, last_cca_frame_i) + 1 - return NavigationLimit( - maximum=maximum, - status_text=f"Last cc annot. frame n. = {maximum}", - ) - if mode == self.lineage_mode: - if lineage_tree_frames: - maximum = max(lineage_tree_frames) + 1 - else: - maximum = frame_i + 1 - return NavigationLimit(maximum=maximum) - return None - - def navigation_position( - self, - *, - is_snapshot: bool, - position_i: int, - frame_i: int, - ) -> int: - return position_i + 1 if is_snapshot else frame_i + 1 - def nextActionTriggered(self): if self.isNavigateActionOnNextFrame(): self.rightImageFramesScrollbar.setValue( - self.rightImageFramesScrollbar.value() + 1 + self.rightImageFramesScrollbar.value()+1 ) return - + stepAddAction = QAbstractSlider.SliderAction.SliderSingleStepAdd if self.zKeptDown or self.zSliceCheckbox.isChecked(): self.zSliceScrollBar.triggerAction(stepAddAction) @@ -463,28 +400,27 @@ def nextActionTriggered(self): def nextFrameImage(self, current_frame_i=None): if not self.labelsGrad.showNextFrameAction.isEnabled(): return - + if not self.labelsGrad.showNextFrameAction.isChecked(): return - + posData = self.data[self.pos_i] if current_frame_i is None: current_frame_i = posData.frame_i - + next_frame_i = current_frame_i + 1 if next_frame_i >= len(posData.img_data): img = posData.img_data[-1] else: img = posData.img_data[next_frame_i] - + if posData.SizeZ > 1: img = self.get_2Dimg_from_3D(img, isLayer0=True) - + # img = self.normalizeIntensities(img) - + return img - @exception_handler def next_cb(self): if self.isSnapshot: self.next_pos() @@ -492,30 +428,30 @@ def next_cb(self): self.next_frame() if self.curvToolButton.isChecked(): self.curvTool_cb(True) + + self.updatePropsWidget('') - self.updatePropsWidget("") - - def next_frame(self, warn=True): + def next_frame(self, warn=True): proceed = self.checkIfFutureFrameManualAnnotPastFrames() if not proceed: return - + proceed = self.askInitCcaFirstFrame() if not proceed: return - + proceed = self.askInitLinTreeFirstFrame() if not proceed: return - + mode = str(self.modeComboBox.currentText()) posData = self.data[self.pos_i] - - if posData.frame_i >= posData.SizeT - 1: + + if posData.frame_i >= posData.SizeT-1: # Store data for current frame - if mode != "Viewer": + if mode != 'Viewer': self.store_data(debug=False) - msg = "You reached the last segmented frame!" + msg = 'You reached the last segmented frame!' self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) return @@ -523,10 +459,10 @@ def next_frame(self, warn=True): proceed = self.warnLostObjects() if not proceed: self.resetNavigateScrollbar() - return + return # Store data for current frame - if mode != "Viewer": + if mode != 'Viewer': self.store_data(debug=False) self.askLineageTreeChanges() @@ -536,30 +472,34 @@ def next_frame(self, warn=True): if not proceed_cca: posData.frame_i -= 1 self.get_data() - self.logger.info("No data for current frame. ") + self.logger.info( + 'No data for current frame. ' + ) return - - if mode == "Segmentation and Tracking" or self.isSnapshot: + + if mode == 'Segmentation and Tracking' or self.isSnapshot: self.addExistingDelROIs() - + self.updatePreprocessPreview() self.updateCombineChannelsPreview() self.postProcessing() - self.tracking(storeUndo=True, wl_update=False) + self.tracking(storeUndo=True, wl_update=False) notEnoughG1Cells, proceed = self.attempt_auto_cca() if notEnoughG1Cells or not proceed: posData.frame_i -= 1 self.get_data() self.setAllTextAnnotations() - self.logger.info("Not enough G1 cells to compute cell cycle annotations.") + self.logger.info( + 'Not enough G1 cells to compute cell cycle annotations.' + ) return - + self.store_zslices_rp() self.resetExpandLabel() self.updateAllImages() self.updateHighlightedAxis() self.updateViewerWindow() - self.updateLastVisitedFrame(last_visited_frame_i=posData.frame_i - 1) + self.updateLastVisitedFrame(last_visited_frame_i=posData.frame_i-1) self.setNavigateScrollBarMaximum() self.updateScrollbars() self.computeSegm() @@ -568,42 +508,36 @@ def next_frame(self, warn=True): self.zoomToCells() self.updateItemsMousePos() self.updateObjectCounts() - + self.apply_tools_on_new_frame() - def next_frame_index(self, *, current_frame_i: int, frames_count: int) -> int: - next_frame_i = current_frame_i + 1 - if next_frame_i >= frames_count: - return frames_count - 1 - return next_frame_i - def next_pos(self): self.store_data(debug=True, autosave=False) - if self.pos_i < self.num_pos - 1: + prev_pos_i = self.pos_i + if self.pos_i < self.num_pos-1: self.pos_i += 1 self.updateSegmDataAutoSaveWorker() else: - self.logger.info("You reached last position.") + self.logger.info('You reached last position.') self.pos_i = 0 self.updatePos() def onZsliceSpinboxValueChange(self, value): - self.zSliceScrollBar.setSliderPosition(value - 1) + self.zSliceScrollBar.setSliderPosition(value-1) def prevActionTriggered(self): if self.isNavigateActionOnNextFrame(): self.rightImageFramesScrollbar.setValue( - self.rightImageFramesScrollbar.value() - 1 + self.rightImageFramesScrollbar.value()-1 ) return - + stepSubAction = QAbstractSlider.SliderAction.SliderSingleStepSub if self.zKeptDown or self.zSliceCheckbox.isChecked(): self.zSliceScrollBar.triggerAction(stepSubAction) else: self.navigateScrollBar.triggerAction(stepSubAction) - @exception_handler def prev_cb(self): if self.isSnapshot: self.prev_pos() @@ -611,30 +545,30 @@ def prev_cb(self): self.prev_frame() if self.curvToolButton.isChecked(): self.curvTool_cb(True) - - self.updatePropsWidget("") + + self.updatePropsWidget('') def prev_frame(self): - posData = self.data[self.pos_i] + posData = self.data[self.pos_i] if posData.frame_i <= 0: - msg = "You reached the first frame!" + msg = 'You reached the first frame!' self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) return - + # Store data for current frame mode = str(self.modeComboBox.currentText()) - if mode != "Viewer": + if mode != 'Viewer': self.store_data(debug=False) - - self.removeAlldelROIsCurrentFrame() + + self.removeAlldelROIsCurrentFrame() self.askLineageTreeChanges() posData.frame_i -= 1 _, never_visited = self.get_data() - - if mode == "Segmentation and Tracking" or self.isSnapshot: + + if mode == 'Segmentation and Tracking' or self.isSnapshot: self.addExistingDelROIs() - + self.resetExpandLabel() self.updatePreprocessPreview() self.updateCombineChannelsPreview() @@ -652,76 +586,68 @@ def prev_frame(self): def prev_pos(self): self.store_data(debug=False, autosave=False) + prev_pos_i = self.pos_i if self.pos_i > 0: self.pos_i -= 1 self.updateSegmDataAutoSaveWorker() else: - self.logger.info("You reached first position.") - self.pos_i = self.num_pos - 1 + self.logger.info('You reached first position.') + self.pos_i = self.num_pos-1 self.updatePos() - def projection_frame_indices( - self, - *, - filename, - frame_i: int, - size_t: int, - locked: bool, - ) -> list[tuple[object, int]]: - if locked: - return [(filename, i) for i in range(size_t)] - return [(filename, frame_i)] - def reInitLastSegmFrame( - self, checked=True, from_frame_i=None, updateImages=True, force=False - ): + self, checked=True, from_frame_i=None, updateImages=True, + force=False + ): if not force: cancel = self.warnReinitLastSegmFrame() if cancel: - self.logger.info("Re-initialization of last validated frame cancelled.") + self.logger.info( + 'Re-initialization of last validated frame cancelled.' + ) return posData = self.data[self.pos_i] if from_frame_i is None: from_frame_i = posData.frame_i - + self.lastFrameRanOnFirstVisitTools = posData.frame_i - + self.updateLastCheckedFrameWidgets(from_frame_i) posData.last_tracked_i = from_frame_i - self.navigateScrollBar.setMaximum(from_frame_i + 1) - self.navSpinBox.setMaximum(from_frame_i + 1) + self.navigateScrollBar.setMaximum(from_frame_i+1) + self.navSpinBox.setMaximum(from_frame_i+1) # self.navigateScrollBar.setMinimum(1) - + # posData.tracked_lost_centroids[from_frame_i-1] = set() for i in range(from_frame_i, posData.SizeT): - if posData.allData_li[i]["labels"] is None: + if posData.allData_li[i]['labels'] is None: break - - posData.segm_data[i] = posData.allData_li[i]["labels"] + + posData.segm_data[i] = posData.allData_li[i]['labels'] posData.allData_li[i] = myutils.get_empty_stored_data_dict() - + posData.tracked_lost_centroids[i] = set() - posData.acdcTracker2stepsAnnotInfo.pop(i, None) - + posData.acdcTracker2stepsAnnotInfo.pop(i, None) + if posData.acdc_df is not None: frames = posData.acdc_df.index.get_level_values(0) if from_frame_i in frames: posData.acdc_df = posData.acdc_df.loc[:from_frame_i] - + self.removeAlldelROIsCurrentFrame() - + if not updateImages: return - + self.updateAllImages() def resetAcceptedLostIDs(self, from_frame_i=None): posData = self.data[self.pos_i] if from_frame_i is None: from_frame_i = posData.frame_i - - posData.tracked_lost_centroids[from_frame_i - 1] = set() + + posData.tracked_lost_centroids[from_frame_i-1] = set() for i in range(from_frame_i, posData.SizeT): posData.tracked_lost_centroids[i] = set() @@ -729,8 +655,8 @@ def resetNavigateFramesScrollbar(self, frame_i=None): posData = self.data[self.pos_i] if frame_i is None: frame_i = posData.frame_i - - self.navigateScrollBar.setValueNoSignal(frame_i + 1) + + self.navigateScrollBar.setValueNoSignal(frame_i+1) def resetNavigateScrollbar(self): try: @@ -753,7 +679,7 @@ def resetNavigateScrollbar(self): self.navigateScrollBar.sliderMoved.connect(self.framesScrollBarMoved) def rightImageFramesScrollbarValueChanged(self, value): - img = self.nextFrameImage(current_frame_i=value - 2) + img = self.nextFrameImage(current_frame_i=value-2) self.img1.linkedImageItem.frame_i = value self.img1.linkedImageItem.setImage(img) @@ -761,7 +687,7 @@ def setFrameNavigationDisabled(self, disable: bool, why: str): """Disables the frame navigation buttons and scrollbar. This is used when the user is not allowed to navigate through frames Call again to unlock it again. Also sets tooltips to inform the user - + Parameters ---------- disable : bool @@ -769,7 +695,7 @@ def setFrameNavigationDisabled(self, disable: bool, why: str): why : str the reason for disabeling the navigation. """ - + if disable: self.whyNavigateDisabled.add(why) else: @@ -777,7 +703,7 @@ def setFrameNavigationDisabled(self, disable: bool, why: str): self.whyNavigateDisabled.remove(why) except KeyError: pass - + if len(self.whyNavigateDisabled) == 0: disable = False else: @@ -787,61 +713,61 @@ def setFrameNavigationDisabled(self, disable: bool, why: str): self.prevAction.setDisabled(disable) self.nextAction.setDisabled(disable) self.navigateScrollBar.setDisabled(disable) - + # Set appropriate tooltip if not disable: self.navigateScrollBar.setToolTip( - "NOTE: The maximum frame number that can be visualized with this " - "scrollbar\n" - "is the last visited frame with the selected mode\n" + 'NOTE: The maximum frame number that can be visualized with this ' + 'scrollbar\n' + 'is the last visited frame with the selected mode\n' '(see "Mode" selector on the top-right).\n\n' - "If the scrollbar does not move it means that you never visited\n" - "any frame with current mode.\n\n" + 'If the scrollbar does not move it means that you never visited\n' + 'any frame with current mode.\n\n' 'Note that the "Viewer" mode allows you to scroll ALL frames.' ) return - - txt = f"Frame navigation disabled: {self.whyNavigateDisabled}" + + txt = f'Frame navigation disabled: {self.whyNavigateDisabled}' self.logger.info(txt) self.navigateScrollBar.setToolTip(txt) def setNavigateScrollBarMaximum(self): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if mode == "Segmentation and Tracking": + if mode == 'Segmentation and Tracking': if posData.last_tracked_i is not None: if posData.frame_i > posData.last_tracked_i: - self.navigateScrollBar.setMaximum(posData.frame_i + 1) - self.navSpinBox.setMaximum(posData.frame_i + 1) + self.navigateScrollBar.setMaximum(posData.frame_i+1) + self.navSpinBox.setMaximum(posData.frame_i+1) else: - self.navigateScrollBar.setMaximum(posData.last_tracked_i + 1) - self.navSpinBox.setMaximum(posData.last_tracked_i + 1) + self.navigateScrollBar.setMaximum(posData.last_tracked_i+1) + self.navSpinBox.setMaximum(posData.last_tracked_i+1) else: - self.navigateScrollBar.setMaximum(posData.frame_i + 1) - self.navSpinBox.setMaximum(posData.frame_i + 1) - - self.updateLastCheckedFrameWidgets(self.navSpinBox.maximum() - 1) - elif mode == "Cell cycle analysis": + self.navigateScrollBar.setMaximum(posData.frame_i+1) + self.navSpinBox.setMaximum(posData.frame_i+1) + + self.updateLastCheckedFrameWidgets(self.navSpinBox.maximum()-1) + elif mode == 'Cell cycle analysis': if posData.frame_i > self.last_cca_frame_i: - self.navigateScrollBar.setMaximum(posData.frame_i + 1) - self.navSpinBox.setMaximum(posData.frame_i + 1) + self.navigateScrollBar.setMaximum(posData.frame_i+1) + self.navSpinBox.setMaximum(posData.frame_i+1) else: - self.navigateScrollBar.setMaximum(self.last_cca_frame_i + 1) - self.navSpinBox.setMaximum(self.last_cca_frame_i + 1) + self.navigateScrollBar.setMaximum(self.last_cca_frame_i+1) + self.navSpinBox.setMaximum(self.last_cca_frame_i+1) self.lastTrackedFrameLabel.setText( - f"Last cc annot. frame n. = {self.navSpinBox.maximum()}" + f'Last cc annot. frame n. = {self.navSpinBox.maximum()}' ) - elif mode == "Normal division: Lineage tree": + elif mode == 'Normal division: Lineage tree': if self.lineage_tree is None: - self.navigateScrollBar.setMaximum(posData.frame_i + 1) - self.navSpinBox.setMaximum(posData.frame_i + 1) + self.navigateScrollBar.setMaximum(posData.frame_i+1) + self.navSpinBox.setMaximum(posData.frame_i+1) else: if self.lineage_tree.frames_for_dfs: i = max(self.lineage_tree.frames_for_dfs) else: i = 0 - self.navigateScrollBar.setMaximum(i + 1) - self.navSpinBox.setMaximum(i + 1) + self.navigateScrollBar.setMaximum(i+1) + self.navSpinBox.setMaximum(i+1) def setSwitchViewedPlaneDisabled(self, disabled): posData = self.data[self.pos_i] @@ -854,7 +780,9 @@ def setSwitchViewedPlaneDisabled(self, disabled): def setViewRangeSwitchPlane(self, previousPlane): self.autoRange() - QTimer.singleShot(100, partial(self._setViewRangeSwitchPlane, previousPlane)) + QTimer.singleShot( + 100, partial(self._setViewRangeSwitchPlane, previousPlane) + ) def setZprojDisabled(self, disabled, storePrevState=False): self.combineChannelsAction.setDisabled(disabled) @@ -862,97 +790,45 @@ def setZprojDisabled(self, disabled, storePrevState=False): button = self.editToolBar.widgetForAction(action) if button == self.eraserButton: continue - + if button in self.toolsActiveInProj3Dsegm: continue - + try: tooltip = button.toolTip() - prefix = "WARNING: Disabled due to projection mode\n\n" + prefix = 'WARNING: Disabled due to projection mode\n\n' if disabled: if not tooltip.startswith(prefix): button.setToolTip(prefix + tooltip) else: if tooltip.startswith(prefix): - button.setToolTip(tooltip[len(prefix) :]) + button.setToolTip(tooltip[len(prefix):]) except: pass action.setDisabled(disabled) try: button.setChecked(False) - except Exception: + except Exception as err: pass - def should_apply_new_frame_tools( - self, - *, - mode: str, - last_tracked_i: int, - frame_i: int, - last_frame_ran: int, - ) -> bool: - return ( - mode == self.segmentation_mode - and last_tracked_i is not None - and last_tracked_i <= frame_i - and frame_i != last_frame_ran - ) - - def should_disable_overlay_z_slice(self, how: str) -> bool: - return how.find("max") != -1 or how == "same as above" - - def should_show_next_frame_image( - self, - *, - size_t: int, - has_right_image_coords: bool, - action_enabled: bool, - action_checked: bool, - ) -> bool: - return ( - size_t > 1 and has_right_image_coords and action_enabled and action_checked - ) - - def should_store_when_slider_moves(self, *, mode: str) -> bool: - return mode != self.viewer_mode - - def should_warn_lost_objects( - self, - *, - requested: bool, - action_checked: bool, - mode: str, - lost_ids, - already_accepted: bool, - ) -> bool: - if not requested: - return False - if not action_checked: - return False - if mode != self.segmentation_mode: - return False - if not lost_ids: - return False - return not already_accepted - def switchViewedPlane(self, previousPlane, currentPlane): posData = self.data[self.pos_i] self.xRangePrev, self.yRangePrev = self.ax1.viewRange() self.zSlicePrev = self.zSliceScrollBar.sliderPosition() - - self.zProjComboBox.setCurrentText("single z-slice") + + self.zProjComboBox.setCurrentText('single z-slice') depthAxes = self.switchPlaneCombobox.depthAxes() self.onEscape() self.initDelRoiLab() - if depthAxes != "z": + if depthAxes != 'z': # Disable projections on plane that is not xy - self.zProjComboBox.setCurrentText("single z-slice") + self.zProjComboBox.setCurrentText('single z-slice') self.zProjComboBox.setDisabled(True) - + # Clear annotations self.clearAllItems() self.setHighlightID(False) - + # Disable annotations on a plane that is not yz self.setDrawNothingAnnotations() self.setDisabledAnnotCheckBoxesLeft(True) @@ -970,40 +846,42 @@ def switchViewedPlane(self, previousPlane, currentPlane): if self.overlayButtonPrevState: self.overlayButton.setChecked(self.overlayButtonPrevState) self.updateZsliceScrollbar(posData.frame_i) - + SizeY, SizeX = posData.img_data[posData.frame_i].shape[-2:] - - if depthAxes != "z" and self.isSnapshot: + + if depthAxes != 'z' and self.isSnapshot: # Disable editing when the plane is not xy self.disableEditingViewPlaneNotXY() elif self.isSnapshot: # Re-enable editing in snapshot mode when the plane is xy self.setEnabledSnapshotMode() - - if depthAxes == "z": + + if depthAxes == 'z': maxSliceNum = posData.SizeZ - elif depthAxes == "y": + elif depthAxes == 'y': maxSliceNum = SizeY else: maxSliceNum = SizeX - - maxSliceText = f"/{maxSliceNum}" + + maxSliceText = f'/{maxSliceNum}' self.SizeZlabel.setText(maxSliceText) - self.zSliceCheckbox.setText(f"{depthAxes}-slice") - self.zSliceScrollBar.setMaximum(maxSliceNum - 1) + self.zSliceCheckbox.setText(f'{depthAxes}-slice') + self.zSliceScrollBar.setMaximum(maxSliceNum-1) self.zSliceSpinbox.setMaximum(maxSliceNum) - + self.initContoursImage() self.updateAllImages() - QTimer.singleShot(200, partial(self.setViewRangeSwitchPlane, previousPlane)) + QTimer.singleShot( + 200, partial(self.setViewRangeSwitchPlane, previousPlane) + ) def updateFramePosLabel(self): if self.isSnapshot: posData = self.data[self.pos_i] - self.navSpinBox.setValueNoEmit(self.pos_i + 1) + self.navSpinBox.setValueNoEmit(self.pos_i+1) else: posData = self.data[0] - self.navSpinBox.setValueNoEmit(posData.frame_i + 1) + self.navSpinBox.setValueNoEmit(posData.frame_i+1) def updateItemsMousePos(self): if self.brushButton.isChecked(): @@ -1013,7 +891,7 @@ def updateItemsMousePos(self): self.updateEraserCursor(self.xHoverImg, self.yHoverImg) def updateOverlayZproj(self, how): - if how.find("max") != -1 or how == "same as above": + if how.find('max') != -1 or how == 'same as above': self.overlay_z_label.setDisabled(True) self.zSliceOverlay_SB.setDisabled(True) else: @@ -1040,7 +918,7 @@ def updatePos(self): self.updateScrollbars() self.updatePreprocessPreview() self.updateCombineChannelsPreview() - self.updateAllImages() + self.updateAllImages() self.computeSegm() self.zoomOut() self.restartZoomAutoPilot() @@ -1052,13 +930,14 @@ def updateScrollbars(self): self.updateItemsMousePos() self.updateFramePosLabel() posData = self.data[self.pos_i] - navPos = self.pos_i + 1 if self.isSnapshot else posData.frame_i + 1 + navPos = self.pos_i+1 if self.isSnapshot else posData.frame_i+1 self.navigateScrollBar.setSliderPosition(navPos) if posData.SizeZ > 1: self.updateZsliceScrollbar(posData.frame_i) - self.zSliceScrollBar.setMaximum(posData.SizeZ - 1) + idx = (posData.filename, posData.frame_i) + self.zSliceScrollBar.setMaximum(posData.SizeZ-1) self.zSliceSpinbox.setMaximum(posData.SizeZ) - self.SizeZlabel.setText(f"/{posData.SizeZ}") + self.SizeZlabel.setText(f'/{posData.SizeZ}') def updateViewerWindow(self): if self.slideshowWin is None: @@ -1075,16 +954,19 @@ def updateViewerWindow(self): self.slideshowWin.update_img() def updateZproj(self, how): - for p, posData in enumerate(self.data[self.pos_i :]): + for p, posData in enumerate(self.data[self.pos_i:]): if self.zProjLockViewButton.isChecked(): - idx = [(posData.filename, frame_i) for frame_i in range(posData.SizeT)] + idx = [ + (posData.filename, frame_i) + for frame_i in range(posData.SizeT) + ] else: idx = [(posData.filename, posData.frame_i)] - posData.segmInfo_df.loc[idx, "which_z_proj_gui"] = how + posData.segmInfo_df.loc[idx, 'which_z_proj_gui'] = how posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) - + posData = self.data[self.pos_i] - if how == "single z-slice": + if how == 'single z-slice': self.zSliceScrollBar.setDisabled(False) self.zSliceSpinbox.setDisabled(False) self.zSliceCheckbox.setDisabled(False) @@ -1099,21 +981,26 @@ def updateZproj(self, how): def update_z_slice(self, z): posData = self.data[self.pos_i] - if self.switchPlaneCombobox.depthAxes() == "z": + if self.switchPlaneCombobox.depthAxes() == 'z': if self.zProjLockViewButton.isChecked(): - idx = [(posData.filename, frame_i) for frame_i in range(posData.SizeT)] + idx = [ + (posData.filename, frame_i) + for frame_i in range(posData.SizeT) + ] else: idx = [ - (posData.filename, frame_i) + (posData.filename, frame_i) for frame_i in range(posData.frame_i, posData.SizeT) ] - posData.segmInfo_df.loc[idx, "z_slice_used_gui"] = z - + posData.segmInfo_df.loc[idx, 'z_slice_used_gui'] = z + self.updatePreprocessPreview() self.updateCombineChannelsPreview() self.highlightedID = self.getHighlightedID() self.updateAllImages( - computePointsLayers=False, computeContours=False, updateLookuptable=True + computePointsLayers=False, + computeContours=False, + updateLookuptable=True ) self.updateItemsMousePos() if self.isSegm3D: @@ -1122,44 +1009,46 @@ def update_z_slice(self, z): def warnLostObjects(self, do_warn=True): if not do_warn: return True - + if not self.warnLostCellsAction.isChecked(): return True - + mode = str(self.modeComboBox.currentText()) - if not mode == "Segmentation and Tracking": + if not mode == 'Segmentation and Tracking': return True - + posData = self.data[self.pos_i] if not posData.lost_IDs: return True - + frame_i = posData.frame_i try: accepted_lost_IDs = posData.accepted_lost_IDs.get(frame_i, []) - already_accepted_lost = Counter(accepted_lost_IDs) == Counter( - posData.lost_IDs + already_accepted_lost = ( + Counter(accepted_lost_IDs) == Counter(posData.lost_IDs) ) - except AttributeError: + except AttributeError as err: already_accepted_lost = False - + if already_accepted_lost: return True self.nextAction.setDisabled(True) self.prevAction.setDisabled(True) self.navigateScrollBar.setDisabled(True) - + msg = widgets.myMessageBox() warn_msg = html_utils.paragraph( - "Current frame (compared to previous frame) " - "has lost the following cells:

" - f"{posData.lost_IDs}

" - "Are you sure you want to continue?
" + 'Current frame (compared to previous frame) ' + 'has lost the following cells:

' + f'{posData.lost_IDs}

' + 'Are you sure you want to continue?
' ) - checkBox = QCheckBox("Do not show again") + checkBox = QCheckBox('Do not show again') noButton, yesButton = msg.warning( - self, "Lost cells!", warn_msg, buttonsTexts=("No", "Yes"), widgets=checkBox + self, 'Lost cells!', warn_msg, + buttonsTexts=('No', 'Yes'), + widgets=checkBox ) doNotWarnLostCells = not checkBox.isChecked() self.warnLostCellsAction.setChecked(doNotWarnLostCells) @@ -1168,27 +1057,27 @@ def warnLostObjects(self, do_warn=True): self.prevAction.setDisabled(False) self.navigateScrollBar.setDisabled(False) return False - + self.nextAction.setDisabled(False) self.prevAction.setDisabled(False) self.navigateScrollBar.setDisabled(False) - if not hasattr(posData, "accepted_lost_IDs"): + if not hasattr(posData, 'accepted_lost_IDs'): posData.accepted_lost_IDs = {} if frame_i not in posData.accepted_lost_IDs: posData.accepted_lost_IDs[frame_i] = [] - + posData.accepted_lost_IDs[frame_i].extend(posData.lost_IDs) # This section is adding the lost cells to tracked_lost_centroids... TBH I dont know why this wasnt done in the first place - prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] - prev_IDs_idxs = posData.allData_li[posData.frame_i - 1]["IDs_idxs"] + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] accepted_lost_centroids = { - tuple(int(val) for val in prev_rp[prev_IDs_idxs[ID]].centroid) + tuple(int(val) for val in prev_rp[prev_IDs_idxs[ID]].centroid) for ID in posData.lost_IDs } try: - posData.tracked_lost_centroids[frame_i] = posData.tracked_lost_centroids[ - frame_i - ] | (accepted_lost_centroids) + posData.tracked_lost_centroids[frame_i] = ( + posData.tracked_lost_centroids[frame_i] | (accepted_lost_centroids) + ) except KeyError: posData.tracked_lost_centroids[frame_i] = accepted_lost_centroids return True @@ -1203,10 +1092,8 @@ def warnReinitLastSegmFrame(self): {current_frame_n} will be lost!
""") msg.warning( - self, - "WARNING: Potential loss of data", - txt, - buttonsTexts=("Cancel", "Yes, I am sure"), + self, 'WARNING: Potential loss of data', txt, + buttonsTexts=('Cancel', 'Yes, I am sure') ) return msg.cancel @@ -1226,21 +1113,20 @@ def zSliceScrollBarActionTriggered(self, action): posData = self.data[self.pos_i] idx = (posData.filename, posData.frame_i) z = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == "z": - posData.segmInfo_df.at[idx, "z_slice_used_gui"] = z - self.zSliceSpinbox.setValueNoEmit(z + 1) + if self.switchPlaneCombobox.depthAxes() == 'z': + posData.segmInfo_df.at[idx, 'z_slice_used_gui'] = z + self.zSliceSpinbox.setValueNoEmit(z+1) img = self._getImageupdateAllImages(None) self.img1.setCurrentZsliceIndex(z) self.img1.setImage( - img, - next_frame_image=self.nextFrameImage(), - scrollbar_value=posData.frame_i + 2, + img, next_frame_image=self.nextFrameImage(), + scrollbar_value=posData.frame_i+2 ) try: self.setOverlayImages() - except Exception: + except Exception as err: pass - + if self.labelsGrad.showLabelsImgAction.isChecked(): self.img2.setImage(posData.lab, z=z, autoLevels=False) self.updateViewerWindow() @@ -1249,21 +1135,18 @@ def zSliceScrollBarActionTriggered(self, action): self.setOverlayLabelsItems() self.drawPointsLayers(computePointsLayers=False) self.zSliceScrollBarStartedMoving = False - self.highlightSearchedID(self.highlightedID, force=True) + self.highlightSearchedID(self.highlightedID, force=True) def zSliceScrollBarReleased(self): self.clearTempBrushImage() self.zSliceScrollBarStartedMoving = True self.update_z_slice(self.zSliceScrollBar.sliderPosition()) - def z_slice_frame_indices( - self, - *, - filename, - frame_i: int, - size_t: int, - locked: bool, - ) -> list[tuple[object, int]]: - if locked: - return [(filename, i) for i in range(size_t)] - return [(filename, i) for i in range(frame_i, size_t)] + def storeViewRange(self): + if not hasattr(self, 'isRangeReset'): + return + + if not self.isRangeReset: + return + self.ax1_viewRange = self.ax1.viewRange() + self.isRangeReset = False diff --git a/cellacdc/mixins/geometry.py b/cellacdc/mixins/geometry.py new file mode 100644 index 000000000..90cb8e822 --- /dev/null +++ b/cellacdc/mixins/geometry.py @@ -0,0 +1,77 @@ +"""Mouse and interaction geometry helpers.""" + +from __future__ import annotations + +from qtpy.QtCore import Qt + +from cellacdc import is_mac + + + + + + + + + + + + +class Geometry: + """Extracted from guiWin.""" + + def isDefaultMiddleClick(self, mouseEvent, modifiers): + if is_mac: + middle_click = ( + mouseEvent.button() == Qt.MouseButton.LeftButton + and modifiers == Qt.ControlModifier + and not self.brushButton.isChecked() + ) + else: + middle_click = mouseEvent.button() == Qt.MouseButton.MiddleButton + return middle_click + + def isMiddleClick(self, mouseEvent, modifiers): + if self.delObjAction is None: + return self.isDefaultMiddleClick(mouseEvent, modifiers) + + delObjKeySequence, delObjQtButton = self.delObjAction + if delObjKeySequence is None: + # Setting only middle click on mac is allowed, however the + # delObjKeySequence is None and the tool button is never checked + isDelObjectActive = True + else: + isDelObjectActive = self.delObjToolAction.isChecked() + + mouseEventButton = self.changeRightClickToLeftOnMac(mouseEvent) + + middle_click = ( + mouseEventButton == delObjQtButton and isDelObjectActive + ) + + return middle_click + + def isPanImageClick(self, mouseEvent, modifiers): + left_click = mouseEvent.button() == Qt.MouseButton.LeftButton + return modifiers == Qt.AltModifier and left_click + + def middleClickText(self): + if self.delObjAction is None and is_mac: + return 'Command + Left Click' + + if self.delObjAction is None: + return 'Middle Click' + + delObjKeySequence, delObjQtButton = self.delObjAction + + if delObjQtButton == Qt.MouseButton.LeftButton: + buttonName = 'Left click' + elif delObjQtButton == Qt.MouseButton.RightButton: + buttonName = 'Right click' + else: + buttonName = 'Middle click' + + if delObjKeySequence is None: + return buttonName + + return f'{delObjKeySequence.toString()} + {buttonName}' diff --git a/cellacdc/mixins_bak/graphics.py b/cellacdc/mixins/graphics.py similarity index 73% rename from cellacdc/mixins_bak/graphics.py rename to cellacdc/mixins/graphics.py index 3481bb408..fc5ef6d39 100644 --- a/cellacdc/mixins_bak/graphics.py +++ b/cellacdc/mixins/graphics.py @@ -33,55 +33,38 @@ _font.setPixelSize(11) -class GraphicsMixin: - """Qt-facing adapter for graphics item construction workflows.""" +class Graphics: + """Extracted from guiWin.""" - """Headless graphics workflow rules.""" - - def _base_first_hierarchical_opacities( - self, - alpha_values: Iterable[float], - ) -> list[float]: - alphas = [1.0, *alpha_values] - if not alphas: - return alphas - - weights = [] - for i, alpha_ref in enumerate(alphas): - weight = alpha_ref - for alpha in alphas[i + 1 :]: - weight *= 1 - alpha - weights.append(weight) - - return weights - - def _computeAllContours2D(self, dataDict, obj, z, obj_bbox, include_internal=False): + def _computeAllContours2D( + self, dataDict, obj, z, obj_bbox, include_internal=False + ): obj_image = self.getObjImage(obj.image, obj.bbox, z_slice=z) if obj_image is None: return - + all_external = False local = False contours = core.get_obj_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, + obj_image=obj_image, + obj_bbox=obj_bbox, local=local, - all_external=all_external, + all_external=all_external ) key = (obj.label, str(z), all_external, local) - dataDict["contours"][key] = contours - + dataDict['contours'][key] = contours + all_external = True local = False contours = core.get_obj_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, + obj_image=obj_image, + obj_bbox=obj_bbox, local=local, all_external=all_external, - all=include_internal, + all=include_internal ) key = (obj.label, str(z), all_external, local) - dataDict["contours"][key] = contours + dataDict['contours'][key] = contours return dataDict @@ -92,27 +75,29 @@ def _computeAllObjToObjCostPairs(self, posData): for frame_i, dataDict in enumerate(posData.allData_li): if frame_i == 0: continue - - rp = dataDict["regionprops"] + + rp = dataDict['regionprops'] if rp is None: break - - prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + + prev_rp = posData.allData_li[frame_i-1]['regionprops'] dist_matrix = core._compute_all_obj_to_obj_contour_dist_pairs( - dataDict["contours"], rp, prev_rp=prev_rp, restrict_search=True + dataDict['contours'], rp, + prev_rp=prev_rp, + restrict_search=True ) - dataDict["obj_to_obj_dist_cost_matrix_df"] = dist_matrix + dataDict['obj_to_obj_dist_cost_matrix_df'] = dist_matrix self.computeAllObjCostPairsWorker.signals.progressBar.emit(1) self.computeAllObjCostPairsWorker.signals.initProgressBar.emit(0) def _gui_createGraphicsItems(self): for _posData in self.data: - _posData.allData_li = [None] * _posData.SizeT - + _posData.allData_li = [None]*_posData.SizeT + posData = self.data[self.pos_i] allIDs, posData = core.count_objects(posData, self.logger.info) - + self.highLowResAction.setChecked(True) numItems = len(allIDs) if numItems > 1500: @@ -131,29 +116,23 @@ def _gui_createGraphicsItems(self): # Many items requires pxMode active to be fast enough self.pxModeAction.setChecked(True) - self.logger.info("Creating graphical items...") + self.logger.info(f'Creating graphical items...') self.ax1_contoursImageItem = pg.ImageItem() - + self.ax1_lostObjImageItem = pg.ImageItem() self.ax2_lostObjImageItem = pg.ImageItem() - + self.ax1_lostTrackedObjImageItem = pg.ImageItem() self.ax2_lostTrackedObjImageItem = pg.ImageItem() - + self.ax1_oldMothBudLinesItem = pg.ScatterPlotItem( - symbol="s", - pxMode=False, - brush=self.oldMothBudLineBrush, - size=self.mothBudLineWeight, - pen=None, + symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, + size=self.mothBudLineWeight, pen=None ) self.ax1_newMothBudLinesItem = pg.ScatterPlotItem( - symbol="s", - pxMode=False, - brush=self.newMothBudLineBrush, - size=self.mothBudLineWeight, - pen=None, + symbol='s', pxMode=False, brush=self.newMothBudLineBrush, + size=self.mothBudLineWeight, pen=None ) self.ax1_lostObjScatterItem = self.gui_getLostObjScatterItem() self.yellowContourScatterItem = self.gui_getLostObjScatterItem() @@ -161,32 +140,27 @@ def _gui_createGraphicsItems(self): self.ax1_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() self.greenContourScatterItem = self.gui_getTrackedLostObjScatterItem() - brush = pg.mkBrush((0, 255, 0, 200)) - pen = pg.mkPen("g", width=1) + brush = pg.mkBrush((0,255,0,200)) + pen = pg.mkPen('g', width=1) self.ccaFailedScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight + 1, pen=pen, brush=brush, pxMode=False, symbol="s" + size=self.contLineWeight+1, pen=pen, + brush=brush, pxMode=False, symbol='s' ) self.ax2_contoursImageItem = pg.ImageItem() self.ax2_oldMothBudLinesItem = pg.ScatterPlotItem( - symbol="s", - pxMode=False, - brush=self.oldMothBudLineBrush, - size=self.mothBudLineWeight, - pen=None, + symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, + size=self.mothBudLineWeight, pen=None ) self.ax2_newMothBudLinesItem = pg.ScatterPlotItem( - symbol="s", - pxMode=False, - brush=self.newMothBudLineBrush, - size=self.mothBudLineWeight, - pen=None, + symbol='s', pxMode=False, brush=self.newMothBudLineBrush, + size=self.mothBudLineWeight, pen=None ) self.ax2_lostObjScatterItem = self.gui_getLostObjScatterItem() self.ax2_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() - - self.gui_createTextAnnotItems(allIDs) # here - self.gui_setTextAnnotColors() # here + + self.gui_createTextAnnotItems(allIDs) # here + self.gui_setTextAnnotColors()# here self.setDisabledAnnotOptions(False) @@ -227,27 +201,27 @@ def _updateMothBudLineColour(self, color): def _updateMothBudLineSize(self, size): self.gui_createMothBudLinePens() - + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): if act == self.sender(): act.setChecked(True) act.toggled.connect(self.mothBudLineWeightToggled) - + self.ax1_oldMothBudLinesItem.setSize(size) self.ax1_newMothBudLinesItem.setSize(size) self.ax2_oldMothBudLinesItem.setSize(size) self.ax2_newMothBudLinesItem.setSize(size) - def addActionsLutItemContextMenu(self, lutItem): - lutItem.gradient.menu.addSection("Visible channels: ") + def addActionsLutItemContextMenu(self, lutItem): + lutItem.gradient.menu.addSection('Visible channels: ') for action in self.overlayContextMenu.actions(): if action.isSeparator(): continue lutItem.gradient.menu.addAction(action) lutItem.gradient.menu.addSeparator() - annotationMenu = lutItem.gradient.menu.addMenu("Annotations settings") - ID_menu = annotationMenu.addMenu("IDs") + annotationMenu = lutItem.gradient.menu.addMenu('Annotations settings') + ID_menu = annotationMenu.addMenu('IDs') self.annotSettingsIDmenu = QActionGroup(annotationMenu) labID_action = QAction("Show label's ID") labID_action.setCheckable(True) @@ -261,7 +235,7 @@ def addActionsLutItemContextMenu(self, lutItem): ID_menu.addAction(labID_action) ID_menu.addAction(treeID_action) - ID_menu = annotationMenu.addMenu("Generation number") + ID_menu = annotationMenu.addMenu('Generation number') self.annotSettingsGenNumMenu = QActionGroup(annotationMenu) gen_num_action = QAction("Show default generation number") gen_num_action.setCheckable(True) @@ -279,8 +253,8 @@ def addAlphaScrollbar(self, channelName, imageItem): alphaScrollBar = widgets.ScrollBar(Qt.Horizontal) imageItem.alphaScrollBar = alphaScrollBar alphaScrollBar.channelName = channelName - - label = QLabel(f"Alpha {channelName}") + + label = QLabel(f'Alpha {channelName}') label.setFont(_font) label.hide() alphaScrollBar.imageItem = imageItem @@ -291,14 +265,17 @@ def addAlphaScrollbar(self, channelName, imageItem): alphaScrollBar.setMaximum(40) alphaScrollBar.setValue(20) alphaScrollBar.setToolTip( - f"Control the alpha value of the overlaid channel {channelName}.\n" - "alpha=0 results in NO overlay,\n" - "alpha=1 results in only fluorescence data visible" + f'Control the alpha value of the overlaid channel {channelName}.\n' + 'alpha=0 results in NO overlay,\n' + 'alpha=1 results in only fluorescence data visible' + ) + self.bottomLeftLayout.addWidget( + alphaScrollBar.label, self.alphaScrollbarRow, 0, + alignment=Qt.AlignRight ) self.bottomLeftLayout.addWidget( - alphaScrollBar.label, self.alphaScrollbarRow, 0, alignment=Qt.AlignRight + alphaScrollBar, self.alphaScrollbarRow, 1, 1, 2 ) - self.bottomLeftLayout.addWidget(alphaScrollBar, self.alphaScrollbarRow, 1, 1, 2) alphaScrollBar.valueChanged.connect( partial(self.setOpacityOverlayLayersItems, scrollbar=alphaScrollBar) @@ -308,8 +285,10 @@ def addAlphaScrollbar(self, channelName, imageItem): return alphaScrollBar def addFluoChNameContextMenuAction(self, ch_name): - self.data[self.pos_i] - allTexts = [action.text() for action in self.chNamesQActionGroup.actions()] + posData = self.data[self.pos_i] + allTexts = [ + action.text() for action in self.chNamesQActionGroup.actions() + ] if ch_name not in allTexts: action = QAction(self) action.setText(ch_name) @@ -319,12 +298,13 @@ def addFluoChNameContextMenuAction(self, ch_name): self.fluoDataChNameActions.append(action) def addObjContourToContoursImage( - self, ID=0, obj=None, ax=0, thickness=None, color=None, force=False - ): + self, ID=0, obj=None, ax=0, thickness=None, color=None, + force=False + ): imageItem = self.getContoursImageItem(ax, force=force) if imageItem is None: return - + if obj is None: obj = self.getObjFromID(ID) if obj is None: @@ -335,7 +315,7 @@ def addObjContourToContoursImage( thickness = self.contLineWeight if color is None: color = self.contLineColor - + self.setContoursImage(imageItem, contours, thickness, color) def addOverlayLabelsToggled(self, checked, name=None): @@ -350,32 +330,13 @@ def addOverlayLabelsToggled(self, checked, name=None): self.hideOverlayLabelsItems(specific=[name]) self.setOverlayLabelsItems() - def apply_lut_dimming_for_kept_objects( - self, - lut: np.ndarray, - kept_object_ids: list[int], - keep_ids_enabled: bool, - ) -> np.ndarray: - """Applies dimming to non-kept objects in the LUT if keep_ids is enabled.""" - import numpy as np - - if not keep_ids_enabled: - return lut - - dimmed_lut = np.round(lut * 0.3).astype(np.uint8) - valid_ids = [idx for idx in kept_object_ids if idx < len(lut)] - if valid_ids: - kept_lut = np.round(dimmed_lut[valid_ids] / 0.3).astype(np.uint8) - dimmed_lut[valid_ids] = kept_lut - return dimmed_lut - def askLabelsToOverlay(self): selectOverlayLabels = widgets.QDialogListbox( - "Select segmentation to overlay", - "Select segmentation file to overlay:\n", - natsorted(self.existingSegmEndNames), - multiSelection=True, - parent=self, + 'Select segmentation to overlay', + 'Select segmentation file to overlay:\n', + natsorted(self.existingSegmEndNames), + multiSelection=True, + parent=self ) selectOverlayLabels.exec_() if selectOverlayLabels.cancel: @@ -386,11 +347,9 @@ def askLabelsToOverlay(self): def askSelectOverlayChannel(self): ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] selectFluo = widgets.QDialogListbox( - "Select channel", - "Select channel names to overlay:\n", - ch_names, - multiSelection=True, - parent=self, + 'Select channel', + 'Select channel names to overlay:\n', + ch_names, multiSelection=True, parent=self ) selectFluo.exec_() if selectFluo.cancel: @@ -429,7 +388,7 @@ def clearAx1Items(self, onlyHideText=False): self.ax1_lostTrackedScatterItem.setData([], []) self.ccaFailedScatterItem.setData([], []) self.yellowContourScatterItem.setData([], []) - + self.clearPointsLayers() self.clearOverlayLabelsItems() @@ -450,30 +409,32 @@ def clearAx2Items(self, onlyHideText=False): def clearComputedContours(self): for posData in self.data: for frame_i, dataDict in enumerate(posData.allData_li): - dataDict["contours"] = {} + dataDict['contours'] = {} - def clearObjContour(self, ID=0, obj=None, ax=0, debug=False, updateImage=True): + def clearObjContour( + self, ID=0, obj=None, ax=0, debug=False, updateImage=True + ): imageItem = self.getContoursImageItem(ax) if imageItem is None: return if ID > 0: - self.contoursImage[self.currentLab2D == ID] = [0, 0, 0, 0] + self.contoursImage[self.currentLab2D==ID] = [0,0,0,0] else: obj_slice = self.getObjSlice(obj.slice) obj_image = self.getObjImage(obj.image, obj.bbox) - self.contoursImage[obj_slice][obj_image] = [0, 0, 0, 0] - + self.contoursImage[obj_slice][obj_image] = [0,0,0,0] + if not updateImage: return - - imageItem.setImage(self.contoursImage) + + imageItem.setImage(self.contoursImage) def clearOverlayImageItems(self): for items in self.overlayLayersItems.values(): imageItem = items[0] imageItem.clear() - + self.rgbaImg1.clear() def clearOverlayLabelsItems(self): @@ -484,38 +445,35 @@ def clearOverlayLabelsItems(self): contoursItem.clear() def computeAllContours(self): - self.logger.info("Computing all contours...") + self.logger.info('Computing all contours...') posData = self.data[self.pos_i] zz = [None] if self.isSegm3D: zz.extend(range(posData.SizeZ)) - + include_internal = self.showAllContoursToggle.isChecked() for frame_i, dataDict in enumerate(posData.allData_li): - lab = dataDict["labels"] + lab = dataDict['labels'] if lab is None: break - - rp = dataDict["regionprops"] + + rp = dataDict['regionprops'] if rp is None: rp = skimage.measure.regionprops(lab) - - dataDict["contours"] = {} + + dataDict['contours'] = {} for obj in rp: obj_bbox = self.getObjBbox(obj.bbox) for z in zz: if not self.isObjVisible(obj.bbox, z_slice=z): continue - + try: self._computeAllContours2D( - dataDict, - obj, - z, - obj_bbox, - include_internal=include_internal, + dataDict, obj, z, obj_bbox, + include_internal=include_internal ) - except Exception: + except Exception as err: # Contours computation fails on weird objects pass @@ -531,25 +489,28 @@ def computeAllObjCostPairsWorkerFinished(self, output): self.computeAllObjCostPairsWorkerLoop.exit() def computeAllObjToObjCostPairs(self): - desc = "Computing all object-to-object cost matrices..." + desc = ( + 'Computing all object-to-object cost matrices...' + ) self.logger.info(desc) posData = self.data[self.pos_i] - + + self.progressWin = apps.QDialogWorkerProgress( title=desc, parent=self, pbarDesc=desc ) self.progressWin.mainPbar.setMaximum(0) self.progressWin.show(self.app) - + self.computeAllObjCostPairsThread = QThread() self.computeAllObjCostPairsWorker = workers.SimpleWorker( posData, self._computeAllObjToObjCostPairs ) - + self.computeAllObjCostPairsWorker.moveToThread( self.computeAllObjCostPairsThread ) - + self.computeAllObjCostPairsWorker.signals.finished.connect( self.computeAllObjCostPairsThread.quit ) @@ -559,7 +520,7 @@ def computeAllObjToObjCostPairs(self): self.computeAllObjCostPairsThread.finished.connect( self.computeAllObjCostPairsThread.deleteLater ) - + self.computeAllObjCostPairsWorker.signals.critical.connect( self.computeAllObjCostPairsWorkerCritical ) @@ -569,16 +530,18 @@ def computeAllObjToObjCostPairs(self): self.computeAllObjCostPairsWorker.signals.progressBar.connect( self.workerUpdateProgressbar ) - self.computeAllObjCostPairsWorker.signals.progress.connect(self.workerProgress) + self.computeAllObjCostPairsWorker.signals.progress.connect( + self.workerProgress + ) self.computeAllObjCostPairsWorker.signals.finished.connect( self.computeAllObjCostPairsWorkerFinished ) - + self.computeAllObjCostPairsThread.started.connect( self.computeAllObjCostPairsWorker.run ) self.computeAllObjCostPairsThread.start() - + self.computeAllObjCostPairsWorkerLoop = QEventLoop() self.computeAllObjCostPairsWorkerLoop.exec_() @@ -587,20 +550,20 @@ def contLineWeightToggled(self, checked=True): return self.imgGrad.uncheckContLineWeightActions() w = self.sender().lineWeight - self.df_settings.at["contLineWeight", "value"] = w + self.df_settings.at['contLineWeight', 'value'] = w self.df_settings.to_csv(self.settings_csv_path) self._updateContLineThickness() self.updateAllImages() def createChannelNamesActions(self): # LUT histogram channel name context menu actions - self.chNamesQActionGroup = QActionGroup(self) + self.chNamesQActionGroup = QActionGroup(self) self.chNamesQActionGroup.addAction(self.userChNameAction) - self.data[self.pos_i] + posData = self.data[self.pos_i] for action in self.fluoDataChNameActions: - self.chNamesQActionGroup.addAction(action) + self.chNamesQActionGroup.addAction(action) action.setChecked(False) - + self.userChNameAction.setChecked(True) for action in self.overlayContextMenu.actions(): @@ -622,29 +585,29 @@ def createOverlayLabelsContextMenu(self, segmEndnames): self.overlayLabelsContextMenu.addSeparator() self.drawModeOverlayLabelsChannels = {} segmEndnames_extended = list(segmEndnames.copy()) - segmEndnames_extended = ["combined segm."] + segmEndnames_extended + segmEndnames_extended = ['combined segm.'] + segmEndnames_extended for segmEndname in segmEndnames_extended: action = QAction(segmEndname, self.overlayLabelsContextMenu) - if segmEndname == "combined segm.": + if segmEndname == 'combined segm.': action.setCheckable(False) self.combineSegmViewToggle = action else: action.setCheckable(True) action.toggled.connect(self.addOverlayLabelsToggled) self.overlayLabelsContextMenu.addAction(action) - + self.overlayLabelsContextMenu.addSeparator() - action = QAction("Edit appearance...", self.overlayLabelsContextMenu) + action = QAction('Edit appearance...', self.overlayLabelsContextMenu) action.triggered.connect(self.editOverlayLabelsAppearance) self.overlayLabelsContextMenu.addAction(action) def createOverlayLabelsItems(self, segmEndnames): selectActionGroup = QActionGroup(self) segmEndnames_extended = list(segmEndnames.copy()) - segmEndnames_extended = ["combined segm."] + segmEndnames_extended + segmEndnames_extended = ['combined segm.'] + segmEndnames_extended for segmEndname in segmEndnames_extended: action = QAction(segmEndname) - if segmEndname == "combined segm.": + if segmEndname == 'combined segm.': action.setCheckable(False) else: action.setCheckable(True) @@ -670,14 +633,9 @@ def createOverlayLabelsItems(self, segmEndnames): r, g, b, a = colors.rgba_str_to_values(color) qcolor = QColor(r, g, b, a) contoursItem.setData( - [], - [], - symbol="s", - pxMode=False, - size=self.contLineWeight * 2, + [], [], symbol='s', pxMode=False, size=self.contLineWeight*2, brush=pg.mkBrush(color=qcolor), - pen=pg.mkPen(width=3, color=qcolor), - tip=None, + pen=pg.mkPen(width=3, color=qcolor), tip=None ) items = (imageItem, contoursItem, gradItem) @@ -695,7 +653,7 @@ def defaultRescaleIntensLutActionToggled(self, action): rescaleIntensAction.setChecked(True) rescaleIntensAction.trigger() break - + for channel, items in self.overlayLayersItems.items(): lutItem = items[1] for rescaleIntensAction in lutItem.rescaleActionGroup.actions(): @@ -703,24 +661,22 @@ def defaultRescaleIntensLutActionToggled(self, action): rescaleIntensAction.setChecked(True) rescaleIntensAction.trigger() break - - self.df_settings.at["default_rescale_intens_how", "value"] = how + + self.df_settings.at['default_rescale_intens_how', 'value'] = how self.df_settings.to_csv(self.settings_csv_path) def drawLostObjContoursImage( - self, - imageItem, - contours, - thickness=1, - color=(255, 165, 0, 255), # orange - ): + self, imageItem, contours, + thickness=1, + color=(255, 165, 0, 255) # orange + ): img = self.lostObjContoursImage cv2.drawContours(img, contours, -1, color, thickness) imageItem.setImage(img) def drawLostTrackedObjContoursImage(self, imageItem, contours): thickness = 1 - color = (0, 255, 0, 255) # green + color = (0, 255, 0, 255) # green img = self.lostTrackedObjContoursImage cv2.drawContours(img, contours, -1, color, thickness) imageItem.setImage(img) @@ -734,16 +690,16 @@ def editOverlayLabelsAppearance(self, *args): win.exec_() if win.cancel: return - - brush = win.properties["brush"] - pen = win.properties["pen"] + + brush = win.properties['brush'] + pen = win.properties['pen'] for items in self.overlayLabelsItems.values(): imageItem, contoursItem, gradItem = items contoursItem.setBrush(brush, update=False) contoursItem.setPen(pen) def enableOverlayWidgets(self, enabled): - posData = self.data[self.pos_i] + posData = self.data[self.pos_i] if enabled: self.overlayColorButton.setDisabled(False) self.editOverlayColorAction.setDisabled(False) @@ -751,15 +707,13 @@ def enableOverlayWidgets(self, enabled): if posData.SizeZ == 1: return - self.zSliceOverlay_SB.setMaximum(posData.SizeZ - 1) - if self.zProjOverlay_CB.currentText().find("max") != -1: + self.zSliceOverlay_SB.setMaximum(posData.SizeZ-1) + if self.zProjOverlay_CB.currentText().find('max') != -1: self.overlay_z_label.setDisabled(True) self.zSliceOverlay_SB.setDisabled(True) else: z = self.zSliceOverlay_SB.sliderPosition() - self.overlay_z_label.setText( - f"Overlay z-slice {z + 1:02}/{posData.SizeZ}" - ) + self.overlay_z_label.setText(f'Overlay z-slice {z+1:02}/{posData.SizeZ}') self.zSliceOverlay_SB.setDisabled(False) self.overlay_z_label.setDisabled(False) self.zSliceOverlay_SB.show() @@ -784,69 +738,42 @@ def enableOverlayWidgets(self, enabled): self.zProjOverlay_CB.activated.disconnect() def extendLabelsLUT(self, lenNewLut): - self.data[self.pos_i] + posData = self.data[self.pos_i] # Build a new lut to include IDs > than original len of lut if lenNewLut > len(self.lut): - numNewColors = lenNewLut - len(self.lut) + numNewColors = lenNewLut-len(self.lut) # Index original lut _lut = np.zeros((lenNewLut, 3), np.uint8) - _lut[: len(self.lut)] = self.lut + _lut[:len(self.lut)] = self.lut # Pick random colors and append them at the end to recycle them - randomIdx = np.random.randint(0, len(self.lut), size=numNewColors) + randomIdx = np.random.randint(0,len(self.lut),size=numNewColors) for i, idx in enumerate(randomIdx): rgb = self.lut[idx] - _lut[len(self.lut) + i] = rgb + _lut[len(self.lut)+i] = rgb self.lut = _lut self.initLabelsImageItems() return True return False - def extend_labels_lut(self, base_lut: np.ndarray, len_new_lut: int) -> np.ndarray: - """Extends base_lut to include IDs greater than original length of base_lut.""" - import numpy as np - - if len_new_lut <= len(base_lut): - return base_lut - - num_new_colors = len_new_lut - len(base_lut) - _lut = np.zeros((len_new_lut, 3), np.uint8) - _lut[: len(base_lut)] = base_lut - - random_idx = np.random.randint(0, len(base_lut), size=num_new_colors) - for i, idx in enumerate(random_idx): - rgb = base_lut[idx] - _lut[len(base_lut) + i] = rgb - return _lut - - def generate_labels_image_lut(self, base_lut: np.ndarray) -> np.ndarray: - """Converts a 3-channel base LUT to a 4-channel RGBA LUT with background as transparent.""" - import numpy as np - - lut = np.zeros((len(base_lut), 4), dtype=np.uint8) - lut[:, -1] = 255 - lut[:, :-1] = base_lut - lut[0] = [0, 0, 0, 0] - return lut - def getLabelsImageLut(self): lut = np.zeros((len(self.lut), 4), dtype=np.uint8) - lut[:, -1] = 255 - lut[:, :-1] = self.lut - lut[0] = [0, 0, 0, 0] + lut[:,-1] = 255 + lut[:,:-1] = self.lut + lut[0] = [0,0,0,0] return lut def getNearestLostObjID(self, y, x): if not self.annotLostObjsToggle.isChecked(): return - + posData = self.data[self.pos_i] if not posData.lost_IDs: return - - prev_lab = posData.allData_li[posData.frame_i - 1]["labels"] + + prev_lab = posData.allData_li[posData.frame_i-1]['labels'] if prev_lab is None: return - + # if not hasattr(self, 'lostObjContoursImage'): # self.store_data() # posData.frame_i -= 1 @@ -858,63 +785,59 @@ def getNearestLostObjID(self, y, x): # self.updateLostContoursImage(ax=0) # self.updateLostContoursImage(ax=1) # self.updateLostNewCurrentIDs() - + yy, xx, _ = np.nonzero(self.lostObjContoursImage) lostObjsContourMask = np.zeros(self.currentLab2D.shape, dtype=bool) lostObjsContourMask[yy.astype(int), xx.astype(int)] = True - + # Add accepted lost IDs try: yy, xx, _ = np.nonzero(self.lostTrackedObjContoursImage) lostObjsContourMask[yy.astype(int), xx.astype(int)] = True - except Exception: + except Exception as err: pass - + _, y_nearest, x_nearest = core.nearest_nonzero_2D( lostObjsContourMask, y, x, return_coords=True ) nearest_ID = self.get_2Dlab(prev_lab)[y_nearest, x_nearest] - + if nearest_ID == 0: return - + return nearest_ID def getObjContours( - self, - obj, - all_external=False, - local=False, - force_calc=True, - include_internal=False, - ): + self, obj, all_external=False, local=False, force_calc=True, + include_internal=False + ): posData = self.data[self.pos_i] dataDict = posData.allData_li[posData.frame_i] - allContours = dataDict.get("contours") + allContours = dataDict.get('contours') if allContours is not None and not force_calc: z = self.z_lab() key = (obj.label, str(z), all_external, local) contours = allContours.get(key) if contours is not None: return contours - + obj_image = self.getObjImage(obj.image, obj.bbox).astype(np.uint8) obj_bbox = self.getObjBbox(obj.bbox) try: contours = core.get_obj_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, + obj_image=obj_image, + obj_bbox=obj_bbox, local=local, - all_external=all_external, + all_external=all_external ) - except Exception: + except Exception as e: if all_external: contours = [] else: contours = None self.logger.warning( - f"Object ID {obj.label} contours drawing failed. " - f"(bounding box = {obj.bbox})" + f'Object ID {obj.label} contours drawing failed. ' + f'(bounding box = {obj.bbox})' ) return contours @@ -922,10 +845,10 @@ def getObjFromID(self, ID): posData = self.data[self.pos_i] try: idx = posData.IDs_idxs[ID] - except KeyError: + except KeyError as e: # Object already cleared return - + obj = posData.rp[idx] return obj @@ -938,7 +861,7 @@ def getOlImg(self, key, frame_i=None): if posData.SizeZ > 1: zProjHow = self.zProjOverlay_CB.currentText() z = self.zSliceOverlay_SB.sliderPosition() - if zProjHow == "same as above": + if zProjHow == 'same as above': zProjHow = self.zProjComboBox.currentText() z = self.zSliceScrollBar.sliderPosition() reconnect = False @@ -949,17 +872,17 @@ def getOlImg(self, key, frame_i=None): pass self.zSliceOverlay_SB.setSliderPosition(z) if reconnect: - self.zSliceOverlay_SB.valueChanged.connect(self.updateOverlayZslice) - if zProjHow == "single z-slice": - self.overlay_z_label.setText( - f"Overlay z-slice {z + 1:02}/{posData.SizeZ}" - ) + self.zSliceOverlay_SB.valueChanged.connect( + self.updateOverlayZslice + ) + if zProjHow == 'single z-slice': + self.overlay_z_label.setText(f'Overlay z-slice {z+1:02}/{posData.SizeZ}') ol_img = img[z].copy() - elif zProjHow == "max z-projection": + elif zProjHow == 'max z-projection': ol_img = img.max(axis=0) - elif zProjHow == "mean z-projection": + elif zProjHow == 'mean z-projection': ol_img = img.mean(axis=0) - elif zProjHow == "median z-proj.": + elif zProjHow == 'median z-proj.': ol_img = np.median(img, axis=0) else: ol_img = img.copy() @@ -975,16 +898,16 @@ def getOpacitiesFromAlphaScrollbarValues(self): if not _toolbutton.isChecked() or not _toolbutton.isVisible(): continue - alpha_values.append(alphaSB.value() / alphaSB.maximum()) + alpha_values.append(alphaSB.value()/alphaSB.maximum()) activeOverlayImageItems.append(imgItem) - + opacities = colors.hierarchical_weights(alpha_values)[::-1] channel_opacity_mapper = {} for i, imgItem in enumerate(activeOverlayImageItems): - channel_opacity_mapper[imgItem.channelName] = opacities[i + 1] - + channel_opacity_mapper[imgItem.channelName] = opacities[i+1] + channel_opacity_mapper[self.user_ch_name] = opacities[0] - + return channel_opacity_mapper def getOverlayItems(self, channelName, index): @@ -993,14 +916,14 @@ def getOverlayItems(self, channelName, index): imageItem.channelName = channelName lutItem = widgets.myHistogramLUTitem( - parent=self, name="image", axisLabel=channelName + parent=self, name='image', axisLabel=channelName ) imageItem.lutItem = lutItem for action in lutItem.rescaleActionGroup.actions(): if action.text() == self.defaultRescaleIntensHow: action.setChecked(True) break - + lutItem.removeAddScaleBarAction() lutItem.removeAddTimestampAction() lutItem.restoreState(self.df_settings) @@ -1012,19 +935,23 @@ def getOverlayItems(self, channelName, index): lutItem.initColor = initColor lutItem.hide() - lutItem.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) - lutItem.overlayColorButton.sigColorChanged.connect(self.saveOverlayColor) + lutItem.overlayColorButton.sigColorChanging.connect( + self.changeOverlayColor + ) + lutItem.overlayColorButton.sigColorChanged.connect( + self.saveOverlayColor + ) lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW) - lutItem.contoursColorButton.disconnect() + lutItem.contoursColorButton.disconnect() lutItem.contoursColorButton.clicked.connect( self.imgGrad.contoursColorButton.click ) for act in lutItem.contLineWightActionGroup.actions(): act.toggled.connect(self.contLineWeightToggled) - - lutItem.mothBudLineColorButton.disconnect() + + lutItem.mothBudLineColorButton.disconnect() lutItem.mothBudLineColorButton.clicked.connect( self.imgGrad.mothBudLineColorButton.click ) @@ -1032,45 +959,55 @@ def getOverlayItems(self, channelName, index): act.toggled.connect(self.mothBudLineWeightToggled) lutItem.textColorButton.disconnect() - lutItem.textColorButton.clicked.connect(self.editTextIDsColorAction.trigger) + lutItem.textColorButton.clicked.connect( + self.editTextIDsColorAction.trigger + ) - lutItem.defaultSettingsAction.triggered.connect(self.restoreDefaultSettings) - lutItem.labelsAlphaSlider.valueChanged.connect(self.setValueLabelsAlphaSlider) + lutItem.defaultSettingsAction.triggered.connect( + self.restoreDefaultSettings + ) + lutItem.labelsAlphaSlider.valueChanged.connect( + self.setValueLabelsAlphaSlider + ) lutItem.sigRescaleIntes.connect( partial(self.rescaleIntensitiesLut, imageItem=imageItem) ) - if f"how_rescale_intensities_{channelName}" in self.df_settings.index: - how = self.df_settings.at[f"how_rescale_intensities_{channelName}", "value"] + if f'how_rescale_intensities_{channelName}' in self.df_settings.index: + how = self.df_settings.at[ + f'how_rescale_intensities_{channelName}', 'value' + ] lutItem.setRescaleIntensitiesHow(how) - - self.rescaleIntensChannelHowMapper[channelName] = "Rescale each 2D image" + + self.rescaleIntensChannelHowMapper[channelName] = ( + 'Rescale each 2D image' + ) self.addActionsLutItemContextMenu(lutItem) alphaScrollBar = self.addAlphaScrollbar(channelName, imageItem) - + toolbutton = widgets.OverlayChannelToolButton( channelName, lutItem, shortcut=str(index) ) toolbutton.action = self.overlayToolbar.addWidget(toolbutton) toolbutton.setVisible(False) - + toolbutton.clicked.connect(self.overlayChannelToolbuttonClicked) - + alphaScrollBar.toolbutton = toolbutton - + return imageItem, lutItem, alphaScrollBar, toolbutton def getOverlayLabelsData(self, segmEndname): posData = self.data[self.pos_i] - + if posData.ol_labels_data is None: - self.loadOverlayLabelsData(segmEndname) + self.loadOverlayLabelsData(segmEndname) elif segmEndname not in posData.ol_labels_data: self.loadOverlayLabelsData(segmEndname) - + comb_seg = False - if "combined segm." == segmEndname: + if 'combined segm.' == segmEndname: comb_seg = True if not self.isSegm3D: zStackImg = self.data[0].SizeZ > 1 @@ -1078,14 +1015,12 @@ def getOverlayLabelsData(self, segmEndname): selected_z_stack = self.zSliceScrollBar.sliderPosition() else: selected_z_stack = 0 - out = posData.ol_labels_data["combined segm."][posData.frame_i][ - selected_z_stack - ] + out = posData.ol_labels_data['combined segm.'][posData.frame_i][selected_z_stack] return out.astype(np.uint32) - + if self.isSegm3D: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == "single z-slice" + isZslice = zProjHow == 'single z-slice' if isZslice: z = self.zSliceScrollBar.sliderPosition() ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i][z] @@ -1093,9 +1028,7 @@ def getOverlayLabelsData(self, segmEndname): ol_lab = ol_lab.astype(np.uint32) return ol_lab else: - ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i].max( - axis=0 - ) + ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i].max(axis=0) if comb_seg: ol_lab = ol_lab.astype(np.uint32) return ol_lab @@ -1103,7 +1036,7 @@ def getOverlayLabelsData(self, segmEndname): return posData.ol_labels_data[segmEndname][posData.frame_i] def greedyShuffleCmap(self, updateImages=True): - lut = self.labelsGrad.item.colorMap().getLookupTable(0, 1, 255) + lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) greedy_lut = colors.get_greedy_lut(self.currentLab2D, lut) self.lut = greedy_lut self.initLabelsImageItems() @@ -1119,7 +1052,7 @@ def gui_addGraphicsItems(self): equalizeHistPushButton.setCheckable(True) if not self.invertBwAction.isChecked(): equalizeHistPushButton.setStyleSheet( - "QPushButton {background-color: #282828; color: #F0F0F0;}" + 'QPushButton {background-color: #282828; color: #F0F0F0;}' ) self.equalizeHistPushButton = equalizeHistPushButton proxy.setWidget(equalizeHistPushButton) @@ -1127,55 +1060,67 @@ def gui_addGraphicsItems(self): self.equalizeHistPushButton = equalizeHistPushButton # Left image histogram - self.imgGrad = widgets.myHistogramLUTitem(parent=self, name="image") + self.imgGrad = widgets.myHistogramLUTitem(parent=self, name='image') self.imgGrad.restoreState(self.df_settings) self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) for action in self.imgGrad.rescaleActionGroup.actions(): if action.text() == self.defaultRescaleIntensHow: action.setChecked(True) self.rescaleIntensMenu.addAction(action) - + # Colormap gradient widget self.labelsGrad = widgets.labelsGradientWidget(parent=self) try: - self.labelsGrad.restoreState(self.df_settings) - except Exception: + stateFound = self.labelsGrad.restoreState(self.df_settings) + except Exception as e: self.logger.exception(traceback.format_exc()) - print("======================================") + print('======================================') self.logger.info( - "Failed to restore previously used colormap. " + 'Failed to restore previously used colormap. ' 'Using default colormap "viridis"' ) - self.labelsGrad.item.loadPreset("viridis") - + self.labelsGrad.item.loadPreset('viridis') + # Add actions to imgGrad gradient item - self.imgGrad.gradient.menu.addAction(self.labelsGrad.showLabelsImgAction) - self.imgGrad.gradient.menu.addAction(self.labelsGrad.showRightImgAction) - self.imgGrad.gradient.menu.addAction(self.labelsGrad.showNextFrameAction) - + self.imgGrad.gradient.menu.addAction( + self.labelsGrad.showLabelsImgAction + ) + self.imgGrad.gradient.menu.addAction( + self.labelsGrad.showRightImgAction + ) + self.imgGrad.gradient.menu.addAction( + self.labelsGrad.showNextFrameAction + ) + self.imgGrad.gradient.menu.addSeparator() - - self.imgGrad.gradient.menu.addMenu(self.exportMenu) - + + self.imgGrad.gradient.menu.addMenu(self.exportMenu) + # Add actions to view menu self.viewMenu.addAction(self.labelsGrad.showLabelsImgAction) self.viewMenu.addAction(self.labelsGrad.showRightImgAction) - + # Right image histogram self.imgGradRight = widgets.baseHistogramLUTitem( - name="image", parent=self, gradientPosition="left" + name='image', parent=self, gradientPosition='left' ) - self.imgGradRight.gradient.menu.addAction(self.labelsGrad.showLabelsImgAction) - self.imgGradRight.gradient.menu.addAction(self.labelsGrad.showRightImgAction) - self.imgGradRight.gradient.menu.addAction(self.labelsGrad.showNextFrameAction) - + self.imgGradRight.gradient.menu.addAction( + self.labelsGrad.showLabelsImgAction + ) + self.imgGradRight.gradient.menu.addAction( + self.labelsGrad.showRightImgAction + ) + self.imgGradRight.gradient.menu.addAction( + self.labelsGrad.showNextFrameAction + ) + self.imgGrad.setChildLutItem(self.imgGradRight) # Title self.titleLabel = pg.LabelItem( - justify="center", color=self.titleColor, size="14pt" + justify='center', color=self.titleColor, size='14pt' ) - self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) + self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) def gui_addOverlayLayerItems(self): for items in self.overlayLabelsItems.values(): @@ -1186,7 +1131,7 @@ def gui_addOverlayLayerItems(self): def gui_addTopLayerItems(self): for item in self.topLayerItems: self.ax1.addItem(item) - + for item in self.topLayerItemsRight: self.ax2.addItem(item) @@ -1209,16 +1154,16 @@ def gui_connectGraphicsEvents(self): self.ax1.sigRangeChanged.connect(self.viewRangeChanged) def gui_createContourPens(self): - if "contLineWeight" in self.df_settings.index: - val = self.df_settings.at["contLineWeight", "value"] + if 'contLineWeight' in self.df_settings.index: + val = self.df_settings.at['contLineWeight', 'value'] self.contLineWeight = int(val) else: self.contLineWeight = 1 - if "contLineColor" in self.df_settings.index: - val = self.df_settings.at["contLineColor", "value"] + if 'contLineColor' in self.df_settings.index: + val = self.df_settings.at['contLineColor', 'value'] rgba = colors.rgba_str_to_values(val) self.contLineColor = rgba - self.newIDlineColor = [min(255, v + 50) for v in self.contLineColor] + self.newIDlineColor = [min(255, v+50) for v in self.contLineColor] else: self.contLineColor = (255, 0, 0, 200) self.newIDlineColor = (255, 0, 0, 255) @@ -1226,36 +1171,43 @@ def gui_createContourPens(self): try: self.imgGrad.contoursColorButton.sigColorChanging.disconnect() self.imgGrad.contoursColorButton.sigColorChanged.disconnect() - except Exception: + except Exception as e: pass try: for act in self.imgGrad.contLineWightActionGroup.actions(): act.toggled.disconnect() - except Exception: + except Exception as e: pass for act in self.imgGrad.contLineWightActionGroup.actions(): if act.lineWeight == self.contLineWeight: act.setChecked(True) self.imgGrad.contoursColorButton.setColor(self.contLineColor[:3]) - self.imgGrad.contoursColorButton.sigColorChanging.connect(self.updateContColour) - self.imgGrad.contoursColorButton.sigColorChanged.connect(self.saveContColour) + self.imgGrad.contoursColorButton.sigColorChanging.connect( + self.updateContColour + ) + self.imgGrad.contoursColorButton.sigColorChanged.connect( + self.saveContColour + ) for act in self.imgGrad.contLineWightActionGroup.actions(): act.toggled.connect(self.contLineWeightToggled) # Contours pens - self.oldIDs_cpen = pg.mkPen(color=self.contLineColor, width=self.contLineWeight) + self.oldIDs_cpen = pg.mkPen( + color=self.contLineColor, width=self.contLineWeight + ) self.newIDs_cpen = pg.mkPen( - color=self.newIDlineColor, width=self.contLineWeight + 1 + color=self.newIDlineColor, width=self.contLineWeight+1 + ) + self.tempNewIDs_cpen = pg.mkPen( + color='g', width=self.contLineWeight+1 ) - self.tempNewIDs_cpen = pg.mkPen(color="g", width=self.contLineWeight + 1) def gui_createGraphicsItems(self): # Create enough PlotDataItems and LabelItems to draw contours and IDs. self.progressWin = apps.QDialogWorkerProgress( - title="Creating axes items", - parent=self, - pbarDesc="Creating axes items (see progress in the terminal)...", + title='Creating axes items', parent=self, + pbarDesc='Creating axes items (see progress in the terminal)...' ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) @@ -1265,46 +1217,44 @@ def gui_createGraphicsItems(self): def gui_createLabelRoiItem(self): Y, X = self.currentLab2D.shape # Label ROI rectangle - pen = pg.mkPen("r", width=3) + pen = pg.mkPen('r', width=3) self.labelRoiItem = widgets.ROI( - (0, 0), - (0, 0), - maxBounds=QRectF(QRect(0, 0, X, Y)), + (0,0), (0,0), + maxBounds=QRectF(QRect(0,0,X,Y)), scaleSnap=True, translateSnap=True, - pen=pen, - hoverPen=pen, + pen=pen, hoverPen=pen ) posData = self.data[self.pos_i] if self.labelRoiZdepthSpinbox.value() == 0: self.labelRoiZdepthSpinbox.setValue(posData.SizeZ) - self.labelRoiZdepthSpinbox.setMaximum(posData.SizeZ + 1) + self.labelRoiZdepthSpinbox.setMaximum(posData.SizeZ+1) def gui_createMothBudLinePens(self): - if "mothBudLineSize" in self.df_settings.index: - val = self.df_settings.at["mothBudLineSize", "value"] + if 'mothBudLineSize' in self.df_settings.index: + val = self.df_settings.at['mothBudLineSize', 'value'] self.mothBudLineWeight = int(val) else: self.mothBudLineWeight = 2 - self.newMothBudlineColor = (255, 0, 0) - if "mothBudLineColor" in self.df_settings.index: - val = self.df_settings.at["mothBudLineColor", "value"] + self.newMothBudlineColor = (255, 0, 0) + if 'mothBudLineColor' in self.df_settings.index: + val = self.df_settings.at['mothBudLineColor', 'value'] rgba = colors.rgba_str_to_values(val) self.mothBudLineColor = rgba[0:3] else: - self.mothBudLineColor = (255, 165, 0) + self.mothBudLineColor = (255,165,0) try: self.imgGrad.mothBudLineColorButton.sigColorChanging.disconnect() self.imgGrad.mothBudLineColorButton.sigColorChanged.disconnect() - except Exception: + except Exception as e: pass try: for act in self.imgGrad.mothBudLineWightActionGroup.actions(): act.toggled.disconnect() - except Exception: + except Exception as e: pass for act in self.imgGrad.mothBudLineWightActionGroup.actions(): if act.lineWeight == self.mothBudLineWeight: @@ -1324,34 +1274,38 @@ def gui_createMothBudLinePens(self): # MOther-bud lines brushes self.NewBudMoth_Pen = pg.mkPen( - color=self.newMothBudlineColor, - width=self.mothBudLineWeight + 1, - style=Qt.DashLine, + color=self.newMothBudlineColor, width=self.mothBudLineWeight+1, + style=Qt.DashLine ) self.OldBudMoth_Pen = pg.mkPen( - color=self.mothBudLineColor, width=self.mothBudLineWeight, style=Qt.DashLine + color=self.mothBudLineColor, width=self.mothBudLineWeight, + style=Qt.DashLine + ) + + self.redDashLinePen = pg.mkPen( + color='r', width=2, style=Qt.DashLine ) - - self.redDashLinePen = pg.mkPen(color="r", width=2, style=Qt.DashLine) self.oldMothBudLineBrush = pg.mkBrush(self.mothBudLineColor) self.newMothBudLineBrush = pg.mkBrush(self.newMothBudlineColor) def gui_createOverlayColors(self): fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] - self.logger.info(f"Number of TIFF files detected: {len(fluoChannels)}") + self.logger.info( + f'Number of TIFF files detected: {len(fluoChannels)}' + ) self.overlayColors = {} for c, ch in enumerate(fluoChannels): - if f"{ch}_rgb" in self.df_settings.index: - rgb_text = self.df_settings.at[f"{ch}_rgb", "value"] - rgb = tuple([int(val) for val in rgb_text.split("_")]) + if f'{ch}_rgb' in self.df_settings.index: + rgb_text = self.df_settings.at[f'{ch}_rgb', 'value'] + rgb = tuple([int(val) for val in rgb_text.split('_')]) self.overlayColors[ch] = rgb else: - if c >= len(self.overlayRGBs) - 1: - i = c / len(fluoChannels) + if c >= len(self.overlayRGBs) -1: + i = c/len(fluoChannels) additional_color_num = c - len(self.overlayRGBs) + 1 rgbs = [ - tuple([round(c * 255) for c in self.overlayCmap(i)][:3]) + tuple([round(c*255) for c in self.overlayCmap(i)][:3]) for _ in range(additional_color_num) ] self.overlayRGBs.extend(rgbs) @@ -1364,71 +1318,77 @@ def gui_createOverlayItems(self): self.user_ch_name, self.imgGrad ) self.baseLayerToolbutton.setChecked(True) - self.baseLayerToolbutton.clicked.connect(self.overlayChannelToolbuttonClicked) - self.allOverlayToolbuttons = {self.user_ch_name: self.baseLayerToolbutton} - self.allOverlayToolbuttonsByIdx = {0: self.baseLayerToolbutton} - self.baseLayerToolbutton.action = self.overlayToolbar.addWidget( - self.baseLayerToolbutton + self.baseLayerToolbutton.clicked.connect( + self.overlayChannelToolbuttonClicked + ) + self.allOverlayToolbuttons = { + self.user_ch_name: self.baseLayerToolbutton + } + self.allOverlayToolbuttonsByIdx = { + 0: self.baseLayerToolbutton + } + self.baseLayerToolbutton.action = ( + self.overlayToolbar.addWidget(self.baseLayerToolbutton) ) self.overlayLayersItems = {} self.overlayToolbarAreChannelsChecked = {} fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] for c, ch in enumerate(fluoChannels): - overlayItems = self.getOverlayItems(ch, c + 1) + overlayItems = self.getOverlayItems(ch, c+1) self.overlayLayersItems[ch] = overlayItems imageItem, lutItem = overlayItems[:2] self.ax1.addItem(imageItem) - self.lutItemsLayout.addItem(lutItem, row=0, col=c + 1) + self.lutItemsLayout.addItem(lutItem, row=0, col=c+1) toolbutton = overlayItems[3] self.allOverlayToolbuttons[ch] = toolbutton - self.allOverlayToolbuttonsByIdx[c + 1] = toolbutton + self.allOverlayToolbuttonsByIdx[c+1] = toolbutton self.overlayToolbuttonsSep = self.overlayToolbar.addSeparator() self.plotsCol = len(self.ch_names) - + self.ax1.addImageItem(self.rgbaImg1) def gui_createPlotItems(self): - if "textIDsColor" in self.df_settings.index: - rgbString = self.df_settings.at["textIDsColor", "value"] + if 'textIDsColor' in self.df_settings.index: + rgbString = self.df_settings.at['textIDsColor', 'value'] r, g, b = colors.rgb_str_to_values(rgbString) self.gui_createTextAnnotColors(r, g, b, custom=True) self.textIDsColorButton.setColor((r, g, b)) else: - self.gui_createTextAnnotColors(0, 0, 0, custom=False) + self.gui_createTextAnnotColors(0,0,0, custom=False) - if "labels_text_color" in self.df_settings.index: - rgbString = self.df_settings.at["labels_text_color", "value"] + if 'labels_text_color' in self.df_settings.index: + rgbString = self.df_settings.at['labels_text_color', 'value'] r, g, b = colors.rgb_str_to_values(rgbString) self.ax2_textColor = (r, g, b) else: self.ax2_textColor = (255, 0, 0) - - self.emptyLab = np.zeros((2, 2), dtype=np.uint8) + + self.emptyLab = np.zeros((2,2), dtype=np.uint8) # Right image item linked to left self.rightImageItem = widgets.ChildImageItem( linkedScrollbar=self.rightImageFramesScrollbar ) - self.imgGradRight.setImageItem(self.rightImageItem) + self.imgGradRight.setImageItem(self.rightImageItem) self.ax2.addItem(self.rightImageItem) - + # Left image self.img1 = widgets.ParentImageItem( linkedImageItem=self.rightImageItem, activatingActions=( self.labelsGrad.showRightImgAction, - self.labelsGrad.showNextFrameAction, - ), + self.labelsGrad.showNextFrameAction + ) ) self.imgGrad.setImageItem(self.img1) self.img1.lutItem = self.imgGrad self.imgGrad.sigRescaleIntes.connect(self.rescaleIntensitiesLut) self.ax1.addBaseImageItem(self.img1) - + # RGBA image for true transparency mode self.rgbaImg1 = pg.ImageItem() - + # self.rgbaImg1.setImage(self.emptyLab) # Right image @@ -1441,12 +1401,12 @@ def gui_createPlotItems(self): self.gui_createContourPens() self.gui_createMothBudLinePens() - self.eraserCirclePen = pg.mkPen(width=1.5, color="r") - + self.eraserCirclePen = pg.mkPen(width=1.5, color='r') + # Temporary line item connecting bud to new mother self.BudMothTempLine = pg.PlotDataItem(pen=self.NewBudMoth_Pen) self.topLayerItems.append(self.BudMothTempLine) - + # Temporary line item connecting objects to merge self.mergeObjsTempLine = widgets.PlotCurveItem(pen=self.redDashLinePen) self.topLayerItems.append(self.mergeObjsTempLine) @@ -1459,25 +1419,25 @@ def gui_createPlotItems(self): self.ax2.addItem(self.labelsLayerRightImg) # Red/green border rect item - self.GreenLinePen = pg.mkPen(color="g", width=2) - self.RedLinePen = pg.mkPen(color="r", width=2) + self.GreenLinePen = pg.mkPen(color='g', width=2) + self.RedLinePen = pg.mkPen(color='r', width=2) self.ax1BorderLine = pg.PlotDataItem() self.topLayerItems.append(self.ax1BorderLine) - self.ax2BorderLine = pg.PlotDataItem(pen=pg.mkPen(color="r", width=2)) + self.ax2BorderLine = pg.PlotDataItem(pen=pg.mkPen(color='r', width=2)) self.topLayerItems.append(self.ax2BorderLine) # Brush/Eraser/Wand.. layer item self.tempLayerRightImage = pg.ImageItem() self.tempLayerImg1 = widgets.ParentImageItem( linkedImageItem=self.tempLayerRightImage, - activatingAction=(self.labelsGrad.showRightImgAction,), + activatingAction=(self.labelsGrad.showRightImgAction, ) ) self.topLayerItems.append(self.tempLayerImg1) self.topLayerItemsRight.append(self.tempLayerRightImage) # Highlighted ID layer items self.highLightIDLayerImg1 = pg.ImageItem() - self.topLayerItems.append(self.highLightIDLayerImg1) + self.topLayerItems.append(self.highLightIDLayerImg1) # Highlighted ID layer items self.highLightIDLayerRightImage = pg.ImageItem() @@ -1487,7 +1447,7 @@ def gui_createPlotItems(self): self.keepIDsTempLayerRight = pg.ImageItem() self.keepIDsTempLayerLeft = widgets.ParentImageItem( linkedImageItem=self.keepIDsTempLayerRight, - activatingAction=self.labelsGrad.showRightImgAction, + activatingAction=self.labelsGrad.showRightImgAction ) self.topLayerItems.append(self.keepIDsTempLayerLeft) self.topLayerItemsRight.append(self.keepIDsTempLayerRight) @@ -1495,65 +1455,42 @@ def gui_createPlotItems(self): # Searched ID contour self.searchedIDitemRight = pg.ScatterPlotItem() self.searchedIDitemRight.setData( - [], - [], - symbol="s", - pxMode=False, - size=1, - brush=pg.mkBrush(color=(255, 0, 0, 150)), - pen=pg.mkPen(width=2, color="r"), - tip=None, + [], [], symbol='s', pxMode=False, size=1, + brush=pg.mkBrush(color=(255,0,0,150)), + pen=pg.mkPen(width=2, color='r'), tip=None ) self.searchedIDitemLeft = pg.ScatterPlotItem() self.searchedIDitemLeft.setData( - [], - [], - symbol="s", - pxMode=False, - size=1, - brush=pg.mkBrush(color=(255, 0, 0, 150)), - pen=pg.mkPen(width=2, color="r"), - tip=None, + [], [], symbol='s', pxMode=False, size=1, + brush=pg.mkBrush(color=(255,0,0,150)), + pen=pg.mkPen(width=2, color='r'), tip=None ) self.topLayerItems.append(self.searchedIDitemLeft) self.topLayerItemsRight.append(self.searchedIDitemRight) + # Brush circle img1 self.ax1_BrushCircle = pg.ScatterPlotItem() self.ax1_BrushCircle.setData( - [], - [], - symbol="o", - pxMode=False, - brush=pg.mkBrush((255, 255, 255, 50)), - pen=pg.mkPen(width=2), - tip=None, + [], [], symbol='o', pxMode=False, + brush=pg.mkBrush((255,255,255,50)), + pen=pg.mkPen(width=2), tip=None ) self.topLayerItems.append(self.ax1_BrushCircle) # Eraser circle img1 self.ax1_EraserCircle = pg.ScatterPlotItem() self.ax1_EraserCircle.setData( - [], - [], - symbol="o", - pxMode=False, - brush=None, - pen=self.eraserCirclePen, - tip=None, + [], [], symbol='o', pxMode=False, + brush=None, pen=self.eraserCirclePen, tip=None ) self.topLayerItems.append(self.ax1_EraserCircle) self.ax1_EraserX = pg.ScatterPlotItem() self.ax1_EraserX.setData( - [], - [], - symbol="x", - pxMode=False, - size=3, - brush=pg.mkBrush(color=(255, 0, 0, 50)), - pen=pg.mkPen(width=1, color="r"), - tip=None, + [], [], symbol='x', pxMode=False, size=3, + brush=pg.mkBrush(color=(255,0,0,50)), + pen=pg.mkPen(width=1, color='r'), tip=None ) self.topLayerItems.append(self.ax1_EraserX) @@ -1561,63 +1498,43 @@ def gui_createPlotItems(self): self.labelRoiCircItemLeft = widgets.LabelRoiCircularItem() self.labelRoiCircItemLeft.cleared = False self.labelRoiCircItemLeft.setData( - [], - [], - symbol="o", - pxMode=False, - brush=pg.mkBrush(color=(255, 0, 0, 0)), - pen=pg.mkPen(color="r", width=2), - tip=None, + [], [], symbol='o', pxMode=False, + brush=pg.mkBrush(color=(255,0,0,0)), + pen=pg.mkPen(color='r', width=2), tip=None ) self.labelRoiCircItemRight = widgets.LabelRoiCircularItem() self.labelRoiCircItemRight.cleared = False self.labelRoiCircItemRight.setData( - [], - [], - symbol="o", - pxMode=False, - brush=pg.mkBrush(color=(255, 0, 0, 0)), - pen=pg.mkPen(color="r", width=2), - tip=None, + [], [], symbol='o', pxMode=False, + brush=pg.mkBrush(color=(255,0,0,0)), + pen=pg.mkPen(color='r', width=2), tip=None ) self.topLayerItems.append(self.labelRoiCircItemLeft) self.topLayerItemsRight.append(self.labelRoiCircItemRight) - + self.ax1_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() self.ax1_binnedIDs_ScatterPlot.setData( - [], - [], - symbol="t", - pxMode=False, - brush=pg.mkBrush((255, 0, 0, 50)), - size=15, - pen=pg.mkPen(width=3, color="r"), - tip=None, + [], [], symbol='t', pxMode=False, + brush=pg.mkBrush((255,0,0,50)), size=15, + pen=pg.mkPen(width=3, color='r'), tip=None ) self.topLayerItems.append(self.ax1_binnedIDs_ScatterPlot) - + self.ax1_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() self.ax1_ripIDs_ScatterPlot.setData( - [], - [], - symbol="x", - pxMode=False, - brush=pg.mkBrush((255, 0, 0, 50)), - size=15, - pen=pg.mkPen(width=2, color="r"), - tip=None, + [], [], symbol='x', pxMode=False, + brush=pg.mkBrush((255,0,0,50)), size=15, + pen=pg.mkPen(width=2, color='r'), tip=None ) self.topLayerItems.append(self.ax1_ripIDs_ScatterPlot) # Ruler plotItem and scatterItem - rulerPen = pg.mkPen(color="r", style=Qt.DashLine, width=2) + rulerPen = pg.mkPen(color='r', style=Qt.DashLine, width=2) self.ax1_rulerPlotItem = widgets.RulerPlotItem(pen=rulerPen) self.ax1_rulerAnchorsItem = pg.ScatterPlotItem( - symbol="o", - size=9, - brush=pg.mkBrush((255, 0, 0, 50)), - pen=pg.mkPen((255, 0, 0), width=2), - tip=None, + symbol='o', size=9, + brush=pg.mkBrush((255,0,0,50)), + pen=pg.mkPen((255,0,0), width=2), tip=None ) self.topLayerItems.append(self.ax1_rulerPlotItem) self.topLayerItems.append(self.ax1_rulerPlotItem.labelItem) @@ -1626,106 +1543,76 @@ def gui_createPlotItems(self): # Start point of polyline roi self.ax1_point_ScatterPlot = pg.ScatterPlotItem() self.ax1_point_ScatterPlot.setData( - [], - [], - symbol="o", - pxMode=False, - size=3, - pen=pg.mkPen(width=2, color="r"), - brush=pg.mkBrush((255, 0, 0, 50)), - tip=None, + [], [], symbol='o', pxMode=False, size=3, + pen=pg.mkPen(width=2, color='r'), + brush=pg.mkBrush((255,0,0,50)), tip=None ) self.topLayerItems.append(self.ax1_point_ScatterPlot) # Experimental: scatter plot to add a point marker self.startPointPolyLineItem = pg.ScatterPlotItem() self.startPointPolyLineItem.setData( - [], - [], - symbol="o", - size=9, - pen=pg.mkPen(width=2, color="r"), - brush=pg.mkBrush((255, 0, 0, 50)), - hoverable=True, - hoverBrush=pg.mkBrush((255, 0, 0, 255)), - tip=None, + [], [], symbol='o', size=9, + pen=pg.mkPen(width=2, color='r'), + brush=pg.mkBrush((255,0,0,50)), + hoverable=True, hoverBrush=pg.mkBrush((255,0,0,255)), tip=None ) self.topLayerItems.append(self.startPointPolyLineItem) # Eraser circle img2 self.ax2_EraserCircle = pg.ScatterPlotItem() self.ax2_EraserCircle.setData( - [], - [], - symbol="o", - pxMode=False, - brush=None, - pen=self.eraserCirclePen, - tip=None, + [], [], symbol='o', pxMode=False, brush=None, + pen=self.eraserCirclePen, tip=None ) self.ax2.addItem(self.ax2_EraserCircle) self.ax2_EraserX = pg.ScatterPlotItem() self.ax2_EraserX.setData( - [], - [], - symbol="x", - pxMode=False, - size=3, - brush=pg.mkBrush(color=(255, 0, 0, 50)), - pen=pg.mkPen(width=1.5, color="r"), + [], [], symbol='x', pxMode=False, size=3, + brush=pg.mkBrush(color=(255,0,0,50)), + pen=pg.mkPen(width=1.5, color='r') ) self.ax2.addItem(self.ax2_EraserX) # Brush circle img2 self.ax2_BrushCirclePen = pg.mkPen(width=2) - self.ax2_BrushCircleBrush = pg.mkBrush((255, 255, 255, 50)) + self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50)) self.ax2_BrushCircle = pg.ScatterPlotItem() self.ax2_BrushCircle.setData( - [], - [], - symbol="o", - pxMode=False, + [], [], symbol='o', pxMode=False, brush=self.ax2_BrushCircleBrush, - pen=self.ax2_BrushCirclePen, - tip=None, + pen=self.ax2_BrushCirclePen, tip=None ) self.ax2.addItem(self.ax2_BrushCircle) # Annotated metadata markers (ScatterPlotItem) self.ax2_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() self.ax2_binnedIDs_ScatterPlot.setData( - [], - [], - symbol="t", - pxMode=False, - brush=pg.mkBrush((255, 0, 0, 50)), - size=15, - pen=pg.mkPen(width=3, color="r"), - tip=None, + [], [], symbol='t', pxMode=False, + brush=pg.mkBrush((255,0,0,50)), size=15, + pen=pg.mkPen(width=3, color='r'), tip=None ) self.ax2.addItem(self.ax2_binnedIDs_ScatterPlot) - + self.ax2_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() self.ax2_ripIDs_ScatterPlot.setData( - [], - [], - symbol="x", - pxMode=False, - brush=pg.mkBrush((255, 0, 0, 50)), - size=15, - pen=pg.mkPen(width=2, color="r"), - tip=None, + [], [], symbol='x', pxMode=False, + brush=pg.mkBrush((255,0,0,50)), size=15, + pen=pg.mkPen(width=2, color='r'), tip=None ) self.ax2.addItem(self.ax2_ripIDs_ScatterPlot) - self.freeRoiItem = widgets.PlotCurveItem(pen=pg.mkPen(color="r", width=2)) + self.freeRoiItem = widgets.PlotCurveItem( + pen=pg.mkPen(color='r', width=2) + ) self.topLayerItems.append(self.freeRoiItem) - + self.warnPairingItem = widgets.PlotCurveItem( - pen=pg.mkPen(color="r", width=5, style=Qt.DashLine), pxMode=False + pen=pg.mkPen(color='r', width=5, style=Qt.DashLine), + pxMode=False ) self.topLayerItems.append(self.warnPairingItem) - + self.exportMaskImageItem = pg.ImageItem() self.ghostContourItemLeft = widgets.GhostContourItem(self.ax1) @@ -1733,25 +1620,25 @@ def gui_createPlotItems(self): self.ghostMaskItemLeft = widgets.GhostMaskItem(self.ax1) self.ghostMaskItemRight = widgets.GhostMaskItem(self.ax2) - + self.manualBackgroundObjItem = widgets.GhostContourItem( - self.ax1, penColor="r", textColor="r" + self.ax1, penColor='r', textColor='r' ) self.manualBackgroundImageItem = pg.ImageItem() def gui_createTextAnnotColors(self, r, g, b, custom=False): if custom: self.objLabelAnnotRgb = (int(r), int(g), int(b)) - self.SphaseAnnotRgb = (int(r * 0.9), int(r * 0.9), int(b * 0.9)) - self.G1phaseAnnotRgba = (int(r * 0.8), int(g * 0.8), int(b * 0.8), 220) + self.SphaseAnnotRgb = (int(r*0.9), int(r*0.9), int(b*0.9)) + self.G1phaseAnnotRgba = (int(r*0.8), int(g*0.8), int(b*0.8), 220) else: - self.objLabelAnnotRgb = (255, 255, 255) # white + self.objLabelAnnotRgb = (255, 255, 255) # white self.SphaseAnnotRgb = (229, 229, 229) self.G1phaseAnnotRgba = (204, 204, 204, 220) - self.dividedAnnotRgb = (245, 188, 1) # orange + self.dividedAnnotRgb = (245, 188, 1) # orange - self.emptyBrush = pg.mkBrush((0, 0, 0, 0)) - self.emptyPen = pg.mkPen((0, 0, 0, 0)) + self.emptyBrush = pg.mkBrush((0,0,0,0)) + self.emptyPen = pg.mkPen((0,0,0,0)) def gui_createTextAnnotItems(self, allIDs): self.textAnnot = {} @@ -1760,21 +1647,21 @@ def gui_createTextAnnotItems(self, allIDs): for ax in range(2): ax_textAnnot = annotate.TextAnnotations() ax_textAnnot.initFonts(self.fontSize) - ax_textAnnot.createItems(isHighResolution, allIDs, pxMode=pxMode) + ax_textAnnot.createItems( + isHighResolution, allIDs, pxMode=pxMode + ) self.textAnnot[ax] = ax_textAnnot def gui_createZoomRectItem(self): Y, X = self.currentLab2D.shape # Label ROI rectangle - pen = pg.mkPen("r", width=3, style=Qt.DashLine) + pen = pg.mkPen('r', width=3, style=Qt.DashLine) self.zoomRectItem = widgets.ZoomROI( - (0, 0), - (0, 0), - maxBounds=QRectF(QRect(0, 0, X, Y)), + (0,0), (0,0), + maxBounds=QRectF(QRect(0,0,X,Y)), scaleSnap=True, translateSnap=True, - pen=pen, - hoverPen=pen, + pen=pen, hoverPen=pen ) def gui_getLostObjScatterItem(self): @@ -1782,7 +1669,8 @@ def gui_getLostObjScatterItem(self): brush = pg.mkBrush((*self.objLostAnnotRgb, 150)) pen = pg.mkPen(self.objLostAnnotRgb, width=1) lostObjScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight + 1, pen=pen, brush=brush, pxMode=False, symbol="s" + size=self.contLineWeight+1, pen=pen, + brush=brush, pxMode=False, symbol='s' ) return lostObjScatterItem @@ -1791,7 +1679,8 @@ def gui_getTrackedLostObjScatterItem(self): brush = pg.mkBrush((*self.objLostTrackedAnnotRgb, 150)) pen = pg.mkPen(self.objLostTrackedAnnotRgb, width=1) lostObjScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight + 1, pen=pen, brush=brush, pxMode=False, symbol="s" + size=self.contLineWeight+1, pen=pen, + brush=brush, pxMode=False, symbol='s' ) return lostObjScatterItem @@ -1808,21 +1697,13 @@ def gui_initImg1BottomWidgets(self): def gui_setTextAnnotColors(self): self.textAnnot[0].setColors( - self.objLabelAnnotRgb, - self.dividedAnnotRgb, - self.SphaseAnnotRgb, - self.G1phaseAnnotRgba, - self.objLostAnnotRgb, - self.objLostTrackedAnnotRgb, + self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, + self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb ) self.textAnnot[1].setColors( - self.objLabelAnnotRgb, - self.dividedAnnotRgb, - self.SphaseAnnotRgb, - self.G1phaseAnnotRgba, - self.objLostAnnotRgb, - self.objLostTrackedAnnotRgb, + self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, + self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb ) def hideOverlayLabelsItems(self, specific=None): @@ -1835,22 +1716,22 @@ def hideOverlayLabelsItems(self, specific=None): gradItem.setVisible(False) def imgGradLUTfinished_cb(self): - self.data[self.pos_i] + posData = self.data[self.pos_i] ticks = self.imgGrad.gradient.listTicks() self.img1ChannelGradients[self.user_ch_name] = { - "ticks": [(x, t.color.getRgb()) for t, x in ticks], - "mode": "rgb", + 'ticks': [(x, t.color.getRgb()) for t,x in ticks], + 'mode': 'rgb' } - + self.df_settings = self.imgGrad.saveState(self.df_settings) self.df_settings.to_csv(self.settings_csv_path) def initColormapOverlayLayerItem(self, foregrColor, lutItem): if self.invertBwAction.isChecked(): - bkgrColor = (255, 255, 255, 255) + bkgrColor = (255,255,255,255) else: - bkgrColor = (0, 0, 0, 255) + bkgrColor = (0,0,0,255) gradient = colors.get_pg_gradient((bkgrColor, foregrColor)) lutItem.setGradient(gradient) @@ -1891,7 +1772,9 @@ def loadOverlayData(self, ol_channels, addToExisting=False): ol_data = {} for i, ol_ch in enumerate(ol_channels): _, filename = self.getPathFromChName(ol_ch, posData) - ol_data[filename] = posData.ol_data_dict[filename].copy() + ol_data[filename] = ( + posData.ol_data_dict[filename].copy() + ) self.addFluoChNameContextMenuAction(ol_ch) posData.ol_data = ol_data @@ -1904,21 +1787,22 @@ def loadOverlayLabelsData(self, segmEndname, pos_i=None): if posData.ol_labels_data is None: posData.ol_labels_data = {} - if segmEndname == "combined segm.": - posData.ol_labels_data["combined segm."] = posData.combine_img_data - return + if segmEndname == 'combined segm.': + posData.ol_labels_data['combined segm.'] = posData.combine_img_data + return filePath, filename = load.get_path_from_endname( segmEndname, posData.images_path ) self.logger.info(f'Loading "{segmEndname}.npz"...') - labelsData = np.load(filePath)["arr_0"] + labelsData = np.load(filePath)['arr_0'] if posData.SizeT == 1: labelsData = labelsData[np.newaxis] if self.isSegm3D and labelsData.ndim == 3: # 2D segm --> stack to 3D T, Y, X = labelsData.shape - repeat = [labelsData] * posData.SizeZ + repeat = [labelsData]*posData.SizeZ labelsData = np.stack(repeat, axis=1) + posData.ol_labels_data[segmEndname] = labelsData @@ -1927,21 +1811,19 @@ def mothBudLineWeightToggled(self, checked=True): return self.imgGrad.uncheckContLineWeightActions() w = self.sender().lineWeight - self.df_settings.at["mothBudLineSize", "value"] = w + self.df_settings.at['mothBudLineSize', 'value'] = w self.df_settings.to_csv(self.settings_csv_path) self._updateMothBudLineSize(w) self.updateAllImages() def mousePressColorButton(self, event): - self.data[self.pos_i] + posData = self.data[self.pos_i] items = list(self.checkedOverlayChannels) - if len(items) > 1: + if len(items)>1: selectFluo = widgets.QDialogListbox( - "Select image", - "Select which fluorescence image you want to update the color of\n", - items, - multiSelection=False, - parent=self, + 'Select image', + 'Select which fluorescence image you want to update the color of\n', + items, multiSelection=False, parent=self ) selectFluo.exec_() keys = selectFluo.selectedItemsText @@ -1962,14 +1844,16 @@ def overlayChannelToggled(self, checked): self.loadOverlayData([channelName], addToExisting=True) else: _, filename = self.getPathFromChName(channelName, posData) - posData.ol_data[filename] = posData.ol_data_dict[filename].copy() - - self.checkedOverlayChannels.add(channelName) + posData.ol_data[filename] = ( + posData.ol_data_dict[filename].copy() + ) + + self.checkedOverlayChannels.add(channelName) else: self.checkedOverlayChannels.remove(channelName) imageItem = self.overlayLayersItems[channelName][0] imageItem.clear() - + self.setOverlayChannelsToolbuttonsChecked() self.setOverlayItemsVisible() self.setRetainSizePolicyLutItems() @@ -1978,29 +1862,29 @@ def overlayChannelToggled(self, checked): def overlayChannelToolbuttonClicked(self, checked=False, toolbutton=None): if toolbutton is None: toolbutton = self.sender() - - n_checked_buttons = sum( - [b.isChecked() for b in self.allOverlayToolbuttons.values()] + + n_checked_buttons = ( + sum([b.isChecked() for b in self.allOverlayToolbuttons.values()]) ) - + channelName = toolbutton.channelName() - + if n_checked_buttons == 0 or self.overlayToolbar.isSingleChannel(): # At least one button must be checked toolbutton.setChecked(True) - + if self.overlayToolbar.isSingleChannel(): - # Exclusive buttons + # Exclusive buttons for channel, otherToolbutton in self.allOverlayToolbuttons.items(): if channel == channelName: continue otherToolbutton.setChecked(False) - - if self.overlayToolbar.isTransparent(): + + if self.overlayToolbar.isTransparent(): self.setOverlayImages() return - + self.setOverlayItemsOpacities() def overlayLabelsDrawModeToggled(self, action): @@ -2016,7 +1900,7 @@ def overlayLabels_cb(self, checked, selectedLabelsEndnames=None): if selectedLabelsEndnames is None: selectedLabelsEndnames = self.askLabelsToOverlay() if selectedLabelsEndnames is None: - self.logger.info("Overlay labels cancelled.") + self.logger.info('Overlay labels cancelled.') self.overlayLabelsButton.setChecked(False) return for selectedEndname in selectedLabelsEndnames: @@ -2034,7 +1918,7 @@ def overlayLabels_cb(self, checked, selectedLabelsEndnames=None): def overlay_cb(self, checked): self.overlayToolbar.setVisible(checked) - + self.UserNormAction, _, _ = self.getCheckNormAction() posData = self.data[self.pos_i] if checked: @@ -2045,8 +1929,8 @@ def overlay_cb(self, checked): self.overlayButton.setChecked(False) self.overlayButton.toggled.connect(self.overlay_cb) return - - success = self.loadOverlayData(selectedChannels) + + success = self.loadOverlayData(selectedChannels) if not success: return False lastChannel = selectedChannels[-1] @@ -2067,120 +1951,27 @@ def overlay_cb(self, checked): self.updateImageValueFormatter() self.enableOverlayWidgets(False) self.clearOverlayImageItems() - + + self.setOverlayItemsVisible() - def overlay_channel_opacity_map( - self, - base_channel: str, - active_channel_alpha_values: Mapping[str, float], - ) -> dict[str, float]: - channels = list(active_channel_alpha_values) - alpha_values = list(active_channel_alpha_values.values()) - opacities = self._base_first_hierarchical_opacities(alpha_values) - channel_opacity_mapper = { - channel: opacities[i + 1] for i, channel in enumerate(channels) - } - channel_opacity_mapper[base_channel] = opacities[0] - return channel_opacity_mapper - - def overlay_item_opacity_plan( - self, - *, - all_channels: Iterable[str], - base_channel: str, - checked_channels: Iterable[str], - toolbar_single_channel: bool, - active_channel_alpha_values: Mapping[str, float], - ) -> OverlayOpacityPlan: - checked_channels = set(checked_channels) - channel_opacity_mapper = self.overlay_channel_opacity_map( - base_channel, - active_channel_alpha_values, - ) - is_single_channel = toolbar_single_channel or len(checked_channels) == 1 - - opacities = {} - alpha_scrollbar_disabled = {} - for channel in all_channels: - if channel in checked_channels and is_single_channel: - op_val = 1.0 - elif channel in checked_channels: - op_val = channel_opacity_mapper[channel] - else: - op_val = 0.0 - - if op_val == 0: - op_val = 0.01 - - opacities[channel] = min(op_val, 0.999) - if channel != base_channel: - alpha_scrollbar_disabled[channel] = op_val == 0 - - return OverlayOpacityPlan( - opacities=opacities, - alpha_scrollbar_disabled=alpha_scrollbar_disabled, - ) - - def overlay_toolbutton_checked( - self, - channel: str, - *, - checked_channels: Iterable[str], - is_single_channel: bool, - ) -> bool: - return not is_single_channel and channel in set(checked_channels) - - def overlay_toolbutton_click_checked_channels( - self, - *, - clicked_channel: str, - all_channels: Iterable[str], - checked_channels: Iterable[str], - toolbar_single_channel: bool, - ) -> set[str]: - all_channels = set(all_channels) - checked_channels = set(checked_channels) - if not checked_channels or toolbar_single_channel: - checked_channels.add(clicked_channel) - - if toolbar_single_channel: - return {clicked_channel} - - return checked_channels & all_channels - - def overlay_visibility_plan( - self, - *, - all_channels: Iterable[str], - checked_channels: Iterable[str], - overlay_enabled: bool, - ) -> OverlayVisibilityPlan: - checked_channels = set(checked_channels) - return OverlayVisibilityPlan( - channel_visible={ - channel: overlay_enabled and channel in checked_channels - for channel in all_channels - } - ) - def permanentGreedyCmapToggled(self, checked): if checked: - settings_value = "yes" + settings_value = 'yes' else: self.setLut() self.updateLookuptable() self.initLabelsImageItems() - settings_value = "no" - + settings_value = 'no' + self.updateAllImages() - + if self.isSnapshot: - option_name = "permanent_greedy_lut_snapshots" + option_name = 'permanent_greedy_lut_snapshots' else: - option_name = "permanent_greedy_lut_timelapse" - - self.df_settings.at[option_name, "value"] = settings_value + option_name = 'permanent_greedy_lut_timelapse' + + self.df_settings.at[option_name, 'value'] = settings_value self.df_settings.to_csv(self.settings_csv_path) def removeAllItems(self): @@ -2188,64 +1979,64 @@ def removeAllItems(self): self.ax2.clear() try: self.chNamesQActionGroup.removeAction(self.userChNameAction) - except Exception: + except Exception as e: pass try: - self.data[self.pos_i] + posData = self.data[self.pos_i] for action in self.fluoDataChNameActions: self.chNamesQActionGroup.removeAction(action) - except Exception: + except Exception as e: pass try: self.overlayButton.setChecked(False) - except Exception: + except Exception as e: pass - if hasattr(self, "contoursImage"): + if hasattr(self, 'contoursImage'): self.initContoursImage() def removeOverlayItems(self): self.lutItemsLayout.clear() - + try: for toolbutton in self.allOverlayToolbuttonsByIdx.values(): self.overlayToolbar.removeAction(toolbutton.action) - + self.overlayToolbuttonsSep.removeFromToolbar() - except Exception: + except Exception as err: pass def restoreDefaultColors(self): try: color = self.defaultToolBarButtonColor - self.overlayButton.setStyleSheet(f"background-color: {color}") + self.overlayButton.setStyleSheet(f'background-color: {color}') except AttributeError: # traceback.print_exc() pass def restoreDefaultSettings(self): df = self.df_settings - df.at["contLineWeight", "value"] = 1 - df.at["mothBudLineSize", "value"] = 1 - df.at["mothBudLineColor", "value"] = (255, 165, 0, 255) - df.at["contLineColor", "value"] = (205, 0, 0, 220) + df.at['contLineWeight', 'value'] = 1 + df.at['mothBudLineSize', 'value'] = 1 + df.at['mothBudLineColor', 'value'] = (255, 165, 0, 255) + df.at['contLineColor', 'value'] = (205, 0, 0, 220) self._updateContColour((205, 0, 0, 220)) self._updateMothBudLineColour((255, 165, 0, 255)) self._updateMothBudLineSize(1) self._updateContLineThickness() - df.at["overlaySegmMasksAlpha", "value"] = 0.3 - df.at["img_cmap", "value"] = "grey" - self.imgCmap = self.imgGrad.cmaps["grey"] - self.imgCmapName = "grey" - self.labelsGrad.item.loadPreset("viridis") - df.at["labels_bkgrColor", "value"] = (25, 25, 25) - - if df.at["is_bw_inverted", "value"] == "Yes": + df.at['overlaySegmMasksAlpha', 'value'] = 0.3 + df.at['img_cmap', 'value'] = 'grey' + self.imgCmap = self.imgGrad.cmaps['grey'] + self.imgCmapName = 'grey' + self.labelsGrad.item.loadPreset('viridis') + df.at['labels_bkgrColor', 'value'] = (25, 25, 25) + + if df.at['is_bw_inverted', 'value'] == 'Yes': self.invertBw(update=False) - - df = df[~df.index.str.contains("lab_cmap")] + + df = df[~df.index.str.contains('lab_cmap')] df.to_csv(self.settings_csv_path) self.imgGrad.restoreState(df) for items in self.overlayLayersItems.values(): @@ -2260,7 +2051,7 @@ def restoreDefaultSettings(self): def saveBkgrColor(self, button): color = button.color().getRgb()[:3] - self.df_settings.at["labels_bkgrColor", "value"] = color + self.df_settings.at['labels_bkgrColor', 'value'] = color self.df_settings.to_csv(self.settings_csv_path) self.updateAllImages() @@ -2272,32 +2063,32 @@ def saveMothBudLineColour(self, colorButton): def saveOverlayColor(self, button): rgb = button.color().getRgb()[:3] - rgb_text = "_".join([str(val) for val in rgb]) - self.df_settings.at[f"{button.channel}_rgb", "value"] = rgb_text + rgb_text = '_'.join([str(val) for val in rgb]) + self.df_settings.at[f'{button.channel}_rgb', 'value'] = rgb_text self.df_settings.to_csv(self.settings_csv_path) def saveTextIDsColors(self, button): - self.df_settings.at["textIDsColor", "value"] = self.objLabelAnnotRgb + self.df_settings.at['textIDsColor', 'value'] = self.objLabelAnnotRgb self.df_settings.to_csv(self.settings_csv_path) def saveTextLabelsColor(self, button): color = button.color().getRgb()[:3] - self.df_settings.at["labels_text_color", "value"] = color + self.df_settings.at['labels_text_color', 'value'] = color self.df_settings.to_csv(self.settings_csv_path) def segmNdimIndicatorClicked(self): ndimText = self.segmNdimIndicator.text() - if ndimText == "2D": - alternativeNdimText = "3D" - toggleText = "activate" + if ndimText == '2D': + alternativeNdimText = '3D' + toggleText = 'activate' else: - alternativeNdimText = "2D" - toggleText = "de-activate" + alternativeNdimText = '2D' + toggleText = 'de-activate' msg = widgets.myMessageBox(wrapText=False) - important_txt = """ + important_txt = (""" The toggle to activate 3D segmentation is visible only when the Number of z-slices is greater than 1. - """ + """) txt = html_utils.paragraph(f""" This indicator shows that you are working with {ndimText} segmentation masks.

@@ -2312,14 +2103,12 @@ def segmNdimIndicatorClicked(self): {toggleText} the parameter called Work with 3D segmentation masks (z-stack)
as indicated in the screenshot below
. - {html_utils.to_admonition(important_txt, admonition_type="note")} + {html_utils.to_admonition(important_txt, admonition_type='note')}
""") msg.information( - self, - "Segmentation nmber of dimensions info", - txt, - image_paths=":toggle_3D_screenshot.png", + self, 'Segmentation nmber of dimensions info', txt, + image_paths=':toggle_3D_screenshot.png' ) self.segmNdimIndicator.setChecked(True) @@ -2348,29 +2137,29 @@ def setContoursImage(self, imageItem, contours, thickness, color): imageItem.setImage(self.contoursImage) def setLostObjectContour(self, obj): - allContours = self.getObjContours(obj, all_external=True) + allContours = self.getObjContours(obj, all_external=True) for objContours in allContours: - xx = objContours[:, 0] + 0.5 - yy = objContours[:, 1] + 0.5 - data = [obj.label] * len(xx) + xx = objContours[:,0] + 0.5 + yy = objContours[:,1] + 0.5 + data = [obj.label]*len(xx) self.ax1_lostObjScatterItem.addPoints(xx, yy, data=data) self.ax2_lostObjScatterItem.addPoints(xx, yy) def setLut(self, shuffle=True): - self.lut = self.labelsGrad.item.colorMap().getLookupTable(0, 1, 255) + self.lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) if shuffle: np.random.shuffle(self.lut) - + # Insert background color - if "labels_bkgrColor" in self.df_settings.index: - rgbString = self.df_settings.at["labels_bkgrColor", "value"] + if 'labels_bkgrColor' in self.df_settings.index: + rgbString = self.df_settings.at['labels_bkgrColor', 'value'] try: r, g, b = rgbString - except Exception: + except Exception as e: r, g, b = colors.rgb_str_to_values(rgbString) else: r, g, b = 25, 25, 25 - self.df_settings.at["labels_bkgrColor", "value"] = (r, g, b) + self.df_settings.at['labels_bkgrColor', 'value'] = (r, g, b) self.lut = np.insert(self.lut, 0, [r, g, b], axis=0) @@ -2382,18 +2171,18 @@ def setOpacityOverlayLayersItems(self, value, imageItem=None, scrollbar=None): toolbutton = self.allOverlayToolbuttons[channel] if not toolbutton.isChecked() or not toolbutton.isVisible(): return - + if value is None: value = scrollbar.value() - + if imageItem is None: imageItem = scrollbar.imageItem - alpha = value / scrollbar.maximum() + alpha = value/scrollbar.maximum() elif value > 1: - alpha = value / scrollbar.maximum() + alpha = value/scrollbar.maximum() else: alpha = value - + alpha_values = [] activeOverlayImageItems = [] for items in self.overlayLayersItems.values(): @@ -2404,15 +2193,15 @@ def setOpacityOverlayLayersItems(self, value, imageItem=None, scrollbar=None): elif not _toolbutton.isChecked() or not _toolbutton.isVisible(): continue else: - alpha_values.append(alphaSB.value() / alphaSB.maximum()) - + alpha_values.append(alphaSB.value()/alphaSB.maximum()) + activeOverlayImageItems.append(imgItem) - + opacities = colors.hierarchical_weights(alpha_values)[::-1] - + for i, imgItem in enumerate(activeOverlayImageItems): - imgItem.setOpacity(opacities[i + 1]) - + imgItem.setOpacity(opacities[i+1]) + self.img1.setOpacity(opacities[0], applyToLinked=False) def setOverlayChannelsToolbuttonsChecked(self): @@ -2428,24 +2217,22 @@ def setOverlayColors(self): (255, 255, 0), (252, 72, 254), (49, 222, 134), - (22, 108, 27), + (22, 108, 27) ] - self.overlayCmap = matplotlib.colormaps["hsv"] + self.overlayCmap = matplotlib.colormaps['hsv'] self.overlayRGBs.extend( - [ - tuple([round(c * 255) for c in self.overlayCmap(i)][:3]) - for i in np.linspace(0, 1, 8) - ] + [tuple([round(c*255) for c in self.overlayCmap(i)][:3]) + for i in np.linspace(0,1,8)] ) def setOverlayImages(self, frame_i=None): if not self.overlayButton.isChecked(): return - + posData = self.data[self.pos_i] if posData.ol_data is None: return - + rgba_imgs_info = {} for filename in posData.ol_data: chName = myutils.get_chname_from_basename( @@ -2453,7 +2240,7 @@ def setOverlayImages(self, frame_i=None): ) if chName not in self.checkedOverlayChannels: continue - + items = self.overlayLayersItems[chName] imageItem, lutItem, alphaSB = items[:3] @@ -2463,19 +2250,19 @@ def setOverlayImages(self, frame_i=None): toolbutton = items[3] if not toolbutton.isChecked(): continue - alpha_val = alphaSB.value() / alphaSB.maximum() + alpha_val = alphaSB.value()/alphaSB.maximum() ol_img = skimage.exposure.rescale_intensity( ol_img, out_range=(0.0, 1.0) ) - out_range_min, out_range_max = lutItem.getLevels() + out_range_min, out_range_max = lutItem.getLevels() rgba_imgs_info[chName] = (ol_img, alpha_val, lutItem) else: self.rescaleIntensitiesLut(setImage=False, imageItem=imageItem) imageItem.setImage(ol_img) - + if not self.overlayToolbar.isTransparent(): - return - + return + alpha_values = [] images = [] luts = [] @@ -2483,38 +2270,43 @@ def setOverlayImages(self, frame_i=None): ol_img, alpha_val, lutItem = info alpha_values.append(alpha_val) images.append(ol_img) - luts.append(lutItem.gradient.getLookupTable(256, alpha=255) / 255) - + luts.append(lutItem.gradient.getLookupTable(256, alpha=255)/255) + weights = colors.hierarchical_weights(alpha_values) - + if self.baseLayerToolbutton.isChecked(): image1 = self._getImageupdateAllImages() - image1 = skimage.exposure.rescale_intensity(image1, out_range=(0.0, 1.0)) + image1 = skimage.exposure.rescale_intensity( + image1, out_range=(0.0, 1.0) + ) images.append(image1) - baseLut = self.imgGrad.gradient.getLookupTable(256, alpha=255) / 255 + baseLut = ( + self.imgGrad.gradient.getLookupTable(256, alpha=255)/255 + ) luts.append(baseLut) - + images_rgba = [] for img, lut in zip(images, luts): - rgba = colors.grayscale_apply_lut(img, lut) + rgba = colors.grayscale_apply_lut(img, lut) images_rgba.append(rgba) - - rgba_merge = colors.hierarchical_blend(images_rgba, weights) + + rgba_merge = colors.hierarchical_blend(images_rgba, weights) self.rgbaImg1.setImage(rgba_merge) def setOverlayItemsOpacities(self): - n_checked_buttons = sum( - [b.isChecked() for b in self.allOverlayToolbuttons.values()] + n_checked_buttons = ( + sum([b.isChecked() for b in self.allOverlayToolbuttons.values()]) ) - + isSingleChannel = ( - self.overlayToolbar.isSingleChannel() or n_checked_buttons == 1 + self.overlayToolbar.isSingleChannel() + or n_checked_buttons == 1 ) - + channel_opacity_mapper = self.getOpacitiesFromAlphaScrollbarValues() - + # Set opacity of every layer accordingly - for channel, otherToolbutton in self.allOverlayToolbuttons.items(): + for channel, otherToolbutton in self.allOverlayToolbuttons.items(): if channel == self.user_ch_name: otherImageItem = self.img1 alphaScrollbar = None @@ -2524,24 +2316,24 @@ def setOverlayItemsOpacities(self): otherImageItem = otherItems[0] alphaScrollbar = otherItems[2] # alpha_value = alphaScrollbar.value()/alphaScrollbar.maximum() - + if otherToolbutton.isChecked() and isSingleChannel: op_val = 1.0 elif otherToolbutton.isChecked(): op_val = channel_opacity_mapper[channel] else: op_val = 0.0 - + if op_val == 0: op_val = 0.01 op_val = op_val if op_val < 1.0 else 0.999 - + otherImageItem.setOpacity(op_val, applyToLinked=False) - + if alphaScrollbar is None: continue - + alphaScrollbar.setDisabled(bool(op_val == 0)) def setOverlayItemsVisible(self): @@ -2550,11 +2342,11 @@ def setOverlayItemsVisible(self): lutItem.hide() alphaSB.hide() alphaSB.label.hide() - toolbutton.setVisible(False) - + toolbutton.setVisible(False) + if not self.overlayButton.isChecked(): return - + for channel, items in self.overlayLayersItems.items(): _, lutItem, alphaSB, toolbutton = items[:4] if channel in self.checkedOverlayChannels: @@ -2567,7 +2359,7 @@ def setOverlayLabelsItems(self, specific=None): if not self.overlayLabelsButton.isChecked(): self.hideOverlayLabelsItems(specific=specific) return - + if specific is None: specific = self.drawModeOverlayLabelsChannels.keys() @@ -2577,44 +2369,47 @@ def setOverlayLabelsItems(self, specific=None): items = self.overlayLabelsItems[segmEndname] imageItem, contoursItem, gradItem = items contoursItem.clear() - if drawMode == "Draw contours": + if drawMode == 'Draw contours': for obj in skimage.measure.regionprops(ol_lab): - contours = self.getObjContours(obj, all_external=True) + contours = self.getObjContours( + obj, all_external=True + ) for cont in contours: - contoursItem.addPoints(cont[:, 0] + 0.5, cont[:, 1] + 0.5) - elif drawMode == "Overlay labels": + contoursItem.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) + elif drawMode == 'Overlay labels': imageItem.setImage(ol_lab, autoLevels=False) self.showOverlayLabelsItems(specific=specific) - def setOverlayLabelsItemsVisible(self, checked): + def setOverlayLabelsItemsVisible(self, checked): for _segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): items = self.overlayLabelsItems[_segmEndname] gradItem = items[-1] gradItem.hide() - + if checked: segmEndname = self.sender().text() gradItem = self.overlayLabelsItems[segmEndname][-1] gradItem.show() def setOverlaySegmMasks(self, force=False, forceIfNotActive=False): - if not hasattr(self, "currentLab2D"): + if not hasattr(self, 'currentLab2D'): return how = self.drawIDsContComboBox.currentText() - isOverlaySegmLeftActive = how.find("overlay segm. masks") != -1 + isOverlaySegmLeftActive = how.find('overlay segm. masks') != -1 how_ax2 = self.getAnnotateHowRightImage() isOverlaySegmRightActive = ( - how_ax2.find("overlay segm. masks") != -1 + how_ax2.find('overlay segm. masks') != -1 and self.labelsGrad.showRightImgAction.isChecked() ) isOverlaySegmActive = ( - isOverlaySegmLeftActive or isOverlaySegmRightActive or force + isOverlaySegmLeftActive or isOverlaySegmRightActive + or force ) if not isOverlaySegmActive and not forceIfNotActive: - return + return alpha = self.imgGrad.labelsAlphaSlider.value() if alpha == 0: @@ -2624,24 +2419,23 @@ def setOverlaySegmMasks(self, force=False, forceIfNotActive=False): maxID = max(posData.IDs, default=0) if maxID >= len(self.lut): - self.extendLabelsLUT(maxID + 10) + self.extendLabelsLUT(maxID+10) currentLab2D = self.currentLab2D if isOverlaySegmLeftActive: self.labelsLayerImg1.setImage(currentLab2D, autoLevels=False) - if isOverlaySegmRightActive: + if isOverlaySegmRightActive: self.labelsLayerRightImg.setImage(currentLab2D, autoLevels=False) def setOverlaySingleChannel(self, *args, **kwargs): if self.overlayToolbar.isSingleChannel(): self.overlayToolbarAreChannelsChecked = { - channel: toolbutton.isChecked() + channel:toolbutton.isChecked() for channel, toolbutton in self.allOverlayToolbuttons.items() } firstActiveToolbutton = [ - toolbutton - for toolbutton in self.allOverlayToolbuttons.values() + toolbutton for toolbutton in self.allOverlayToolbuttons.values() if toolbutton.isChecked() ][0] firstActiveToolbutton.click() @@ -2649,45 +2443,53 @@ def setOverlaySingleChannel(self, *args, **kwargs): for ch, checked in self.overlayToolbarAreChannelsChecked.items(): toolbutton = self.allOverlayToolbuttons[ch] toolbutton.setChecked(checked) - + self.setOverlayItemsOpacities() def setOverlayTransparency(self, transparent: bool): opacity = float(transparent) opacity = opacity if opacity < 1.0 else 0.999 self.rgbaImg1.setOpacity(opacity) - + if transparent: self.img1.setOpacity(0.001, applyToLinked=False) self.imgGrad.sigLookupTableChanged.connect( self.updateTransparentOverlayRgba ) - self.imgGrad.sigLevelsChanged.connect(self.updateTransparentOverlayRgba) - + self.imgGrad.sigLevelsChanged.connect( + self.updateTransparentOverlayRgba + ) + for channel, items in self.overlayLayersItems.items(): imageItem, lutItem, alphaSB = items[:3] if transparent: alphaSB.valueChanged.disconnect() - alphaSB.valueChanged.connect(self.updateTransparentOverlayRgba) - lutItem.sigLookupTableChanged.connect(self.updateTransparentOverlayRgba) - lutItem.sigLevelsChanged.connect(self.updateTransparentOverlayRgba) + alphaSB.valueChanged.connect( + self.updateTransparentOverlayRgba + ) + lutItem.sigLookupTableChanged.connect( + self.updateTransparentOverlayRgba + ) + lutItem.sigLevelsChanged.connect( + self.updateTransparentOverlayRgba + ) imageItem.setOpacity(0) if not transparent: self.setOverlayItemsOpacities() - + self.setOverlayImages() def setPermanentGreedyCmapPreferences(self): if self.isSnapshot: - option_name = "permanent_greedy_lut_snapshots" + option_name = 'permanent_greedy_lut_snapshots' else: - option_name = "permanent_greedy_lut_timelapse" + option_name = 'permanent_greedy_lut_timelapse' if option_name not in self.df_settings.index: return - - checked = self.df_settings.at[option_name, "value"] == "yes" + + checked = self.df_settings.at[option_name, 'value'] == 'yes' self.labelsGrad.permanentGreedyCmapAction.setChecked(checked) def setRetainSizePolicyLutItems(self): @@ -2701,12 +2503,12 @@ def setRetainSizePolicyLutItems(self): def setTrackedLostObjectContour(self, obj): if self.isExportingVideo: return - - allContours = self.getObjContours(obj, all_external=True) + + allContours = self.getObjContours(obj, all_external=True) for objContours in allContours: - xx = objContours[:, 0] + 0.5 - yy = objContours[:, 1] + 0.5 - data = [obj.label] * len(xx) + xx = objContours[:,0] + 0.5 + yy = objContours[:,1] + 0.5 + data = [obj.label]*len(xx) self.ax1_lostTrackedScatterItem.addPoints(xx, yy, data=data) self.ax2_lostTrackedScatterItem.addPoints(xx, yy) @@ -2732,9 +2534,9 @@ def showOverlayLabelsItems(self, specific=None): for segmEndname in specific: imageItem, contoursItem, gradItem = self.overlayLabelsItems[segmEndname] drawMode = self.drawModeOverlayLabelsChannels[segmEndname] - if drawMode == "Draw contours": + if drawMode == 'Draw contours': contoursItem.setVisible(True) - elif drawMode == "Overlay labels": + elif drawMode == 'Overlay labels': imageItem.setVisible(True) gradItem.setVisible(True) @@ -2759,7 +2561,7 @@ def updateBkgrColor(self, button): def updateContColour(self, colorButton): color = colorButton.color().getRgb() - self.df_settings.at["contLineColor", "value"] = str(color) + self.df_settings.at['contLineColor', 'value'] = str(color) self._updateContColour(color) self.updateAllImages() @@ -2767,20 +2569,20 @@ def updateContoursImage(self, ax, delROIsIDs=None, compute=True): imageItem = self.getContoursImageItem(ax) if imageItem is None: return - - if not hasattr(self, "contoursImage"): + + if not hasattr(self, 'contoursImage'): self.initContoursImage() else: self.contoursImage[:] = 0 - + contours = [] - for obj in skimage.measure.regionprops(self.currentLab2D): + for obj in skimage.measure.regionprops(self.currentLab2D): obj_contours = self.getObjContours( - obj, - all_external=True, + obj, + all_external=True, force_calc=compute, - include_internal=self.showAllContoursToggle.isChecked(), - ) + include_internal=self.showAllContoursToggle.isChecked() + ) contours.extend(obj_contours) thickness = self.contLineWeight @@ -2817,22 +2619,22 @@ def updateLookuptable(self, lenNewLut=None, delIDs=None): try: # lut = self.lut[:lenNewLut].copy() for ID in posData.binnedIDs: - lut[ID] = lut[ID] * 0.2 + lut[ID] = lut[ID]*0.2 for ID in posData.ripIDs: - lut[ID] = lut[ID] * 0.2 - except Exception: + lut[ID] = lut[ID]*0.2 + except Exception as e: err_str = traceback.format_exc() - print("=" * 30) + print('='*30) self.logger.info(err_str) - print("=" * 30) + print('='*30) if updateLevels: self.img2.setLevels([0, len(lut)]) - + if self.keepIDsButton.isChecked(): - lut = np.round(lut * 0.3).astype(np.uint8) - keptLut = np.round(lut[self.keptObjectsIDs] / 0.3).astype(np.uint8) + lut = np.round(lut*0.3).astype(np.uint8) + keptLut = np.round(lut[self.keptObjectsIDs]/0.3).astype(np.uint8) lut[self.keptObjectsIDs] = keptLut self.img2.setLookupTable(lut) @@ -2842,39 +2644,37 @@ def updateLostContoursImage(self, ax, draw=True, delROIsIDs=None): imageItem = self.getLostObjImageItem(ax) if imageItem is None: return - - if not hasattr(self, "lostObjContoursImage"): + + if not hasattr(self, 'lostObjContoursImage'): self.initLostObjContoursImage() else: self.lostObjContoursImage[:] = 0 if delROIsIDs is None: delROIsIDs = set() - + posData = self.data[self.pos_i] - prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] - prev_IDs_idxs = posData.allData_li[posData.frame_i - 1]["IDs_idxs"] + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] if posData.whitelist is not None and posData.whitelist.whitelistIDs is not None: - whitelist = posData.whitelist.whitelistIDs[posData.frame_i - 1] + whitelist = posData.whitelist.whitelistIDs[posData.frame_i-1] else: whitelist = None contours = [] for lostID in posData.lost_IDs: - if lostID in delROIsIDs or ( - whitelist is not None and lostID not in whitelist - ): + if lostID in delROIsIDs or (whitelist is not None and lostID not in whitelist): continue - + obj = prev_rp[prev_IDs_idxs[lostID]] if not self.isObjVisible(obj.bbox): continue - + obj_contours = self.getObjContours(obj, all_external=True) - + if ax == 0: self.addLostObjsToLostObjImage(obj, lostID) - + contours.extend(obj_contours) if not draw: @@ -2883,35 +2683,35 @@ def updateLostContoursImage(self, ax, draw=True, delROIsIDs=None): self.drawLostObjContoursImage(imageItem, contours) def updateLostTrackedContoursImage( - self, ax, delROIsIDs=None, tracked_lost_IDs=None - ): + self, ax, delROIsIDs=None, tracked_lost_IDs=None + ): imageItem = self.getLostTrackedObjImageItem(ax) if imageItem is None: return - - if not hasattr(self, "lostTrackedObjContoursImage"): + + if not hasattr(self, 'lostTrackedObjContoursImage'): self.initLostTrackedObjContoursImage() else: self.lostTrackedObjContoursImage[:] = 0 - + if delROIsIDs is None: delROIsIDs = set() - + posData = self.data[self.pos_i] if tracked_lost_IDs is None: tracked_lost_IDs = self.getTrackedLostIDs() - - prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] - prev_IDs_idxs = posData.allData_li[posData.frame_i - 1]["IDs_idxs"] + + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] contours = [] for tracked_lost_ID in tracked_lost_IDs: if tracked_lost_ID in delROIsIDs: continue - + obj = prev_rp[prev_IDs_idxs[tracked_lost_ID]] if not self.isObjVisible(obj.bbox): continue - + obj_contours = self.getObjContours(obj, all_external=True) contours.extend(obj_contours) @@ -2919,7 +2719,7 @@ def updateLostTrackedContoursImage( def updateMothBudLineColour(self, colorButton): color = colorButton.color().getRgb() - self.df_settings.at["mothBudLineColor", "value"] = str(color) + self.df_settings.at['mothBudLineColor', 'value'] = str(color) self._updateMothBudLineColour(color) self.updateAllImages() @@ -2929,7 +2729,7 @@ def updateTextAnnotColor(self, button): for items in self.overlayLayersItems.values(): lutItem = items[1] lutItem.textColorButton.setColor((r, g, b)) - self.gui_createTextAnnotColors(r, g, b, custom=True) + self.gui_createTextAnnotColors(r,g,b, custom=True) self.gui_setTextAnnotColors() self.updateAllImages() diff --git a/cellacdc/mixins_bak/image_controls.py b/cellacdc/mixins/image_controls.py similarity index 70% rename from cellacdc/mixins_bak/image_controls.py rename to cellacdc/mixins/image_controls.py index f61017dfc..41ba2ad04 100644 --- a/cellacdc/mixins_bak/image_controls.py +++ b/cellacdc/mixins/image_controls.py @@ -23,20 +23,8 @@ _font.setPixelSize(11) -class ImageControlsMixin: - """Qt-facing adapter around image-control defaults and widgets.""" - - """Headless defaults for image-control UI construction.""" - - draw_ids_cont_combo_items = () - z_projection_options = () - overlay_z_projection_options = () - bottom_layout_zoom_values = tuple(range(50, 151, 10)) - - def bottom_layout_zoom_percent(self, df_settings) -> int: - if "bottom_sliders_zoom_perc" not in df_settings.index: - return 100 - return int(df_settings.at["bottom_sliders_zoom_perc", "value"]) +class ImageControls: + """Extracted from guiWin.""" def gui_createBottomWidgetsToBottomLayout(self): # self.bottomDockWidget = QDockWidget(self) @@ -50,7 +38,7 @@ def gui_createBottomWidgetsToBottomLayout(self): self.bottomLayout.addWidget(self.img1BottomGroupbox) self.bottomLayout.addStretch(1) self.bottomLayout.addWidget(self.rightBottomGroupbox) - self.bottomLayout.addStretch(1) + self.bottomLayout.addStretch(1) bottomScrollAreaLayout.addLayout(self.bottomLayout) bottomScrollAreaLayout.addStretch(1) @@ -59,18 +47,18 @@ def gui_createBottomWidgetsToBottomLayout(self): bottomScrollArea.setWidgetResizable(True) bottomScrollArea.setWidget(bottomWidget) self.bottomScrollArea = bottomScrollArea - - if "bottom_sliders_zoom_perc" in self.df_settings.index: - val = int(self.df_settings.at["bottom_sliders_zoom_perc", "value"]) + + if 'bottom_sliders_zoom_perc' in self.df_settings.index: + val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value']) zoom_perc = val else: zoom_perc = 100 - self.bottomLayoutContextMenu = QMenu("Bottom layout", self) - zoomMenu = self.bottomLayoutContextMenu.addMenu("Zoom") + self.bottomLayoutContextMenu = QMenu('Bottom layout', self) + zoomMenu = self.bottomLayoutContextMenu.addMenu('Zoom') actions = [] self.bottomLayoutContextMenu.zoomActionGroup = QActionGroup(zoomMenu) for perc in np.arange(50, 151, 10): - action = QAction(f"{perc}%", zoomMenu) + action = QAction(f'{perc}%', zoomMenu) action.setCheckable(True) if perc == zoom_perc: action.setChecked(True) @@ -78,15 +66,18 @@ def gui_createBottomWidgetsToBottomLayout(self): actions.append(action) self.bottomLayoutContextMenu.zoomActionGroup.addAction(action) zoomMenu.addActions(actions) - resetAction = self.bottomLayoutContextMenu.addAction("Reset default height") + resetAction = self.bottomLayoutContextMenu.addAction( + 'Reset default height' + ) resetAction.triggered.connect(self.resizeGui) retainSpaceAction = self.bottomLayoutContextMenu.addAction( - "Retain space of hidden sliders" + 'Retain space of hidden sliders' ) retainSpaceAction.setCheckable(True) - if "retain_space_hidden_sliders" in self.df_settings.index: + if 'retain_space_hidden_sliders' in self.df_settings.index: retainSpaceChecked = ( - self.df_settings.at["retain_space_hidden_sliders", "value"] == "Yes" + self.df_settings.at['retain_space_hidden_sliders', 'value'] + == 'Yes' ) else: retainSpaceChecked = True @@ -99,10 +90,10 @@ def gui_createGraphicsPlots(self): self.graphLayout = pg.GraphicsLayoutWidget() if self.invertBwAction.isChecked(): self.graphLayout.setBackground(graphLayoutBkgrColor) - self.titleColor = "black" + self.titleColor = 'black' else: self.graphLayout.setBackground(darkBkgrColor) - self.titleColor = "white" + self.titleColor = 'white' self.lutItemsLayout = self.graphLayout.addLayout(row=1, col=0) # self.lutItemsLayout.setBorder('w') @@ -111,8 +102,8 @@ def gui_createGraphicsPlots(self): self.ax1 = widgets.MainPlotItem(showWelcomeText=True) self.ax1.invertY(True) self.ax1.setAspectLocked(True) - self.ax1.hideAxis("bottom") - self.ax1.hideAxis("left") + self.ax1.hideAxis('bottom') + self.ax1.hideAxis('left') self.plotsCol = 1 self.graphLayout.addItem(self.ax1, row=1, col=1) @@ -120,8 +111,8 @@ def gui_createGraphicsPlots(self): self.ax2 = widgets.MainPlotItem() self.ax2.setAspectLocked(True) self.ax2.invertY(True) - self.ax2.hideAxis("bottom") - self.ax2.hideAxis("left") + self.ax2.hideAxis('bottom') + self.ax2.hideAxis('left') # self.currentFrameLabelItem = pg.LabelItem( # color=self.titleColor, size='13px' # ) @@ -130,16 +121,16 @@ def gui_createGraphicsPlots(self): def gui_createImg1Widgets(self): # Toggle contours/ID combobox self.drawIDsContComboBoxSegmItems = [ - "Draw IDs and contours", - "Draw IDs and overlay segm. masks", - "Draw only cell cycle info", - "Draw cell cycle info and contours", - "Draw cell cycle info and overlay segm. masks", - "Draw only mother-bud lines", - "Draw only IDs", - "Draw only contours", - "Draw only overlay segm. masks", - "Draw nothing", + 'Draw IDs and contours', + 'Draw IDs and overlay segm. masks', + 'Draw only cell cycle info', + 'Draw cell cycle info and contours', + 'Draw cell cycle info and overlay segm. masks', + 'Draw only mother-bud lines', + 'Draw only IDs', + 'Draw only contours', + 'Draw only overlay segm. masks', + 'Draw nothing' ] self.drawIDsContComboBox = widgets.ComboBox() self.drawIDsContComboBox.setFont(_font) @@ -147,28 +138,23 @@ def gui_createImg1Widgets(self): self.drawIDsContComboBox.setVisible(False) self.annotIDsCheckbox = widgets.CheckBox( - "IDs", keyPressCallback=self.resetFocus - ) + 'IDs', keyPressCallback=self.resetFocus) self.annotCcaInfoCheckbox = widgets.CheckBox( - "Cell cycle info", keyPressCallback=self.resetFocus - ) + 'Cell cycle info', keyPressCallback=self.resetFocus) self.annotNumZslicesCheckbox = widgets.CheckBox( - "No. z-slices/object", keyPressCallback=self.resetFocus - ) + 'No. z-slices/object', keyPressCallback=self.resetFocus) self.annotContourCheckbox = widgets.CheckBox( - "Contours", keyPressCallback=self.resetFocus - ) + 'Contours', keyPressCallback=self.resetFocus) self.annotSegmMasksCheckbox = widgets.CheckBox( - "Segm. masks", keyPressCallback=self.resetFocus - ) + 'Segm. masks', keyPressCallback=self.resetFocus) self.drawMothBudLinesCheckbox = widgets.CheckBox( - "Only mother-daughter line", keyPressCallback=self.resetFocus + 'Only mother-daughter line', keyPressCallback=self.resetFocus ) self.drawNothingCheckbox = widgets.CheckBox( - "Do not annotate", keyPressCallback=self.resetFocus + 'Do not annotate', keyPressCallback=self.resetFocus ) self.annotOptionsWidget = QWidget() @@ -176,7 +162,7 @@ def gui_createImg1Widgets(self): # Show tree info checkbox self.showTreeInfoCheckbox = widgets.CheckBox( - "Show tree info", keyPressCallback=self.resetFocus + 'Show tree info', keyPressCallback=self.resetFocus ) self.showTreeInfoCheckbox.setFont(_font) sp = self.showTreeInfoCheckbox.sizePolicy() @@ -185,23 +171,23 @@ def gui_createImg1Widgets(self): self.showTreeInfoCheckbox.hide() annotOptionsLayout.addWidget(self.showTreeInfoCheckbox) - annotOptionsLayout.addWidget(QLabel(" | ")) + annotOptionsLayout.addWidget(QLabel(' | ')) annotOptionsLayout.addWidget(self.annotIDsCheckbox) annotOptionsLayout.addWidget(self.annotCcaInfoCheckbox) annotOptionsLayout.addWidget(self.drawMothBudLinesCheckbox) annotOptionsLayout.addWidget(self.annotNumZslicesCheckbox) - annotOptionsLayout.addWidget(QLabel(" | ")) + annotOptionsLayout.addWidget(QLabel(' | ')) annotOptionsLayout.addWidget(self.annotContourCheckbox) annotOptionsLayout.addWidget(self.annotSegmMasksCheckbox) - annotOptionsLayout.addWidget(QLabel(" | ")) + annotOptionsLayout.addWidget(QLabel(' | ')) annotOptionsLayout.addWidget(self.drawNothingCheckbox) annotOptionsLayout.addWidget(self.drawIDsContComboBox) self.annotOptionsLayout = annotOptionsLayout # Toggle highlight z+-1 objects combobox self.highlightZneighObjCheckbox = widgets.CheckBox( - "Highlight objects in neighbouring z-slices", - keyPressCallback=self.resetFocus, + 'Highlight objects in neighbouring z-slices', + keyPressCallback=self.resetFocus ) self.highlightZneighObjCheckbox.setFont(_font) self.highlightZneighObjCheckbox.hide() @@ -211,46 +197,41 @@ def gui_createImg1Widgets(self): # Annotations options right image self.annotIDsCheckboxRight = widgets.CheckBox( - "IDs", keyPressCallback=self.resetFocus - ) + 'IDs', keyPressCallback=self.resetFocus) self.annotCcaInfoCheckboxRight = widgets.CheckBox( - "Cell cycle info", keyPressCallback=self.resetFocus - ) + 'Cell cycle info', keyPressCallback=self.resetFocus) self.annotNumZslicesCheckboxRight = widgets.CheckBox( - "No. z-slices/object", keyPressCallback=self.resetFocus + 'No. z-slices/object', keyPressCallback=self.resetFocus ) self.annotContourCheckboxRight = widgets.CheckBox( - "Contours", keyPressCallback=self.resetFocus - ) + 'Contours', keyPressCallback=self.resetFocus) self.annotSegmMasksCheckboxRight = widgets.CheckBox( - "Segm. masks", keyPressCallback=self.resetFocus - ) + 'Segm. masks', keyPressCallback=self.resetFocus) self.drawMothBudLinesCheckboxRight = widgets.CheckBox( - "Only mother-daughter line", keyPressCallback=self.resetFocus + 'Only mother-daughter line', keyPressCallback=self.resetFocus ) self.drawNothingCheckboxRight = widgets.CheckBox( - "Do not annotate", keyPressCallback=self.resetFocus - ) + 'Do not annotate', keyPressCallback=self.resetFocus) self.annotOptionsWidgetRight = QWidget() annotOptionsLayoutRight = QHBoxLayout() - annotOptionsLayoutRight.addWidget(QLabel(" ")) - annotOptionsLayoutRight.addWidget(QLabel(" | ")) + annotOptionsLayoutRight.addWidget(QLabel(' ')) + annotOptionsLayoutRight.addWidget(QLabel(' | ')) annotOptionsLayoutRight.addWidget(self.annotIDsCheckboxRight) annotOptionsLayoutRight.addWidget(self.annotCcaInfoCheckboxRight) annotOptionsLayoutRight.addWidget(self.drawMothBudLinesCheckboxRight) annotOptionsLayoutRight.addWidget(self.annotNumZslicesCheckboxRight) - annotOptionsLayoutRight.addWidget(QLabel(" | ")) + annotOptionsLayoutRight.addWidget(QLabel(' | ')) annotOptionsLayoutRight.addWidget(self.annotContourCheckboxRight) annotOptionsLayoutRight.addWidget(self.annotSegmMasksCheckboxRight) - annotOptionsLayoutRight.addWidget(QLabel(" | ")) + annotOptionsLayoutRight.addWidget(QLabel(' | ')) annotOptionsLayoutRight.addWidget(self.drawNothingCheckboxRight) self.annotOptionsLayoutRight = annotOptionsLayoutRight - + self.annotOptionsWidgetRight.setLayout(annotOptionsLayoutRight) # Frames scrollbar @@ -259,15 +240,15 @@ def gui_createImg1Widgets(self): self.navigateScrollBar.setMinimum(1) self.navigateScrollBar.setMaximum(1) self.navigateScrollBar.setToolTip( - "NOTE: The maximum frame number that can be visualized with this " - "scrollbar\n" - "is the last visited frame with the selected mode\n" + 'NOTE: The maximum frame number that can be visualized with this ' + 'scrollbar\n' + 'is the last visited frame with the selected mode\n' '(see "Mode" selector on the top-right).\n\n' - "If the scrollbar does not move it means that you never visited\n" - "any frame with current mode.\n\n" + 'If the scrollbar does not move it means that you never visited\n' + 'any frame with current mode.\n\n' 'Note that the "Viewer" mode allows you to scroll ALL frames.' ) - t_label = QLabel("frame n. ") + t_label = QLabel('frame n. ') t_label.setFont(_font) self.t_label = t_label @@ -276,41 +257,36 @@ def gui_createImg1Widgets(self): self.zProjComboBox = widgets.ComboBox() self.zProjComboBox.setFont(_font) - self.zProjComboBox.addItems( - [ - "single z-slice", - "max z-projection", - "mean z-projection", - "median z-proj.", - ] - ) + self.zProjComboBox.addItems([ + 'single z-slice', + 'max z-projection', + 'mean z-projection', + 'median z-proj.' + ]) self.zProjLockViewButton = widgets.LockPushButton() self.zProjLockViewButton.setCheckable(True) self.zProjLockViewButton.setToolTip( - "If active, the selected z-slice view is applied to all frames" + 'If active, the selected z-slice view is applied to all frames' ) self.zProjLockViewButton.hide() - + self.switchPlaneCombobox = widgets.SwitchPlaneCombobox() - self.switchPlaneCombobox.setToolTip("Switch viewed plane") + self.switchPlaneCombobox.setToolTip( + 'Switch viewed plane' + ) self.zSliceOverlay_SB = widgets.ScrollBar(Qt.Horizontal) - _z_label = QLabel("Overlay z-slice ") + _z_label = QLabel('Overlay z-slice ') _z_label.setFont(_font) _z_label.setDisabled(True) self.overlay_z_label = _z_label self.zProjOverlay_CB = widgets.ComboBox() self.zProjOverlay_CB.setFont(_font) - self.zProjOverlay_CB.addItems( - [ - "single z-slice", - "max z-projection", - "mean z-projection", - "median z-proj.", - "same as above", - ] - ) + self.zProjOverlay_CB.addItems([ + 'single z-slice', 'max z-projection', 'mean z-projection', + 'median z-proj.', 'same as above' + ]) self.zProjOverlay_CB.setCurrentIndex(4) self.zSliceOverlay_SB.setDisabled(True) @@ -319,8 +295,8 @@ def gui_createImg1Widgets(self): def gui_createLabWidgets(self): bottomRightLayout = QVBoxLayout() self.rightBottomGroupbox = widgets.GroupBox( - "Annotate right image independent of left image", - keyPressCallback=self.resetFocus, + 'Annotate right image independent of left image', + keyPressCallback=self.resetFocus ) self.rightBottomGroupbox.setCheckable(True) self.rightBottomGroupbox.setChecked(False) @@ -337,10 +313,10 @@ def gui_createLabWidgets(self): self.annotOptionsLayoutRight.addWidget(self.annotateRightHowCombobox) self.rightImageFramesScrollbar = widgets.ScrollBarWithNumericControl( - labelText="Frame n. " + labelText='Frame n. ' ) self.rightImageFramesScrollbar.setVisible(False) - + bottomRightLayout.addWidget(self.annotOptionsWidgetRight) bottomRightLayout.addWidget(self.rightImageFramesScrollbar) bottomRightLayout.addStretch(1) @@ -352,7 +328,7 @@ def gui_createLabWidgets(self): def gui_getImg1BottomWidgets(self): bottomLeftLayout = QGridLayout() self.bottomLeftLayout = bottomLeftLayout - container = QGroupBox("Navigate and annotate left image") + container = QGroupBox('Navigate and annotate left image') row = 0 bottomLeftLayout.addWidget(self.annotOptionsWidget, row, 0, 1, 4) @@ -371,32 +347,40 @@ def gui_getImg1BottomWidgets(self): self.navSpinBox = widgets.SpinBox(disableKeyPress=True) self.navSpinBox.setMinimum(1) self.navSpinBox.setMaximum(100) - self.navSizeLabel = QLabel("/ND") + self.navSizeLabel = QLabel('/ND') navWidgetsLayout.addWidget(self.t_label) navWidgetsLayout.addWidget(self.navSpinBox) navWidgetsLayout.addWidget(self.navSizeLabel) - bottomLeftLayout.addLayout(navWidgetsLayout, row, 0, alignment=Qt.AlignRight) - bottomLeftLayout.addWidget(self.navigateScrollBar, row, 1, 1, 2) + bottomLeftLayout.addLayout( + navWidgetsLayout, row, 0, alignment=Qt.AlignRight + ) + bottomLeftLayout.addWidget(self.navigateScrollBar, row, 1, 1, 2) sp = self.navigateScrollBar.sizePolicy() sp.setRetainSizeWhenHidden(True) self.navigateScrollBar.setSizePolicy(sp) self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) - self.navSpinBox.editingFinished.connect(self.navigateSpinboxEditingFinished) - self.navSpinBox.sigUpClicked.connect(self.navigateSpinboxEditingFinished) - self.navSpinBox.sigDownClicked.connect(self.navigateSpinboxEditingFinished) + self.navSpinBox.editingFinished.connect( + self.navigateSpinboxEditingFinished + ) + self.navSpinBox.sigUpClicked.connect( + self.navigateSpinboxEditingFinished + ) + self.navSpinBox.sigDownClicked.connect( + self.navigateSpinboxEditingFinished + ) self.lastTrackedFrameLabel = QLabel() self.lastTrackedFrameLabel.setFont(_font) bottomLeftLayout.addWidget(self.lastTrackedFrameLabel, row, 3) - + row += 1 zSliceCheckboxLayout = QHBoxLayout() - self.zSliceCheckbox = QCheckBox("z-slice") + self.zSliceCheckbox = QCheckBox('z-slice') self.zSliceSpinbox = widgets.SpinBox(disableKeyPress=True) self.zSliceSpinbox.setMinimum(1) - self.SizeZlabel = QLabel("/ND") + self.SizeZlabel = QLabel('/ND') self.zSliceCheckbox.setToolTip( - "Activate/deactivate control of the z-slices with keyboard arrows.\n\n" + 'Activate/deactivate control of the z-slices with keyboard arrows.\n\n' 'SHORTCUT to toggle ON/OFF: "Z" key' ) zSliceCheckboxLayout.addWidget(self.zSliceCheckbox) @@ -423,9 +407,9 @@ def gui_getImg1BottomWidgets(self): row += 1 self.alphaScrollbarRow = row - bottomLeftLayout.setColumnStretch(0, 0) - bottomLeftLayout.setColumnStretch(1, 3) - bottomLeftLayout.setColumnStretch(2, 0) + bottomLeftLayout.setColumnStretch(0,0) + bottomLeftLayout.setColumnStretch(1,3) + bottomLeftLayout.setColumnStretch(2,0) container.setLayout(bottomLeftLayout) return container @@ -440,11 +424,6 @@ def resetFocus(self): self.setFocusGraphics() self.setFocusMain() - def retain_space_hidden_sliders(self, df_settings) -> bool: - if "retain_space_hidden_sliders" not in df_settings.index: - return True - return df_settings.at["retain_space_hidden_sliders", "value"] == "Yes" - def rightImageControlsToggled(self, checked): if self.isDataLoading: return @@ -459,4 +438,4 @@ def setFocusGraphics(self): def setFocusMain(self): # on macOS with Qt6 setFocus causes crashes. Disabled for now. - return + return diff --git a/cellacdc/mixins_bak/image_display.py b/cellacdc/mixins/image_display.py similarity index 74% rename from cellacdc/mixins_bak/image_display.py rename to cellacdc/mixins/image_display.py index b1fefe6d9..46848e400 100644 --- a/cellacdc/mixins_bak/image_display.py +++ b/cellacdc/mixins/image_display.py @@ -22,64 +22,56 @@ ) -class ImageDisplayMixin: - """Qt-facing adapter for image display, LUT, and cursor workflows.""" - - """Headless display settings and image-display rules.""" - - # @exec_time - - # @exec_time - - # @exec_time +class ImageDisplay: + """Extracted from guiWin.""" def RGBtoGray(self, R, G, B): # see https://stackoverflow.com/questions/17615963/standard-rgb-to-grayscale-conversion - C_linear = (0.2126 * R + 0.7152 * G + 0.0722 * B) / 255 + C_linear = (0.2126*R + 0.7152*G + 0.0722*B)/255 if C_linear <= 0.0031309: - gray = 12.92 * C_linear + gray = 12.92*C_linear else: - gray = 1.055 * (C_linear) ** (1 / 2.4) - 0.055 + gray = 1.055*(C_linear)**(1/2.4) - 0.055 return gray def _getImageupdateAllImages(self, image=None): if image is not None: return image - + img = self.getImage() return img def activeBrushCircleCursors(self, isHoverImg1): if self.showMirroredCursorAction.isChecked(): return self.ax1_BrushCircle, self.ax2_BrushCircle - + if isHoverImg1: - return (self.ax1_BrushCircle,) + return self.ax1_BrushCircle, else: - return (self.ax2_BrushCircle,) + return self.ax2_BrushCircle, def activeEraserCircleCursors(self, isHoverImg1): if self.showMirroredCursorAction.isChecked(): return self.ax1_EraserCircle, self.ax2_EraserCircle - + if isHoverImg1: - return (self.ax1_EraserCircle,) + return self.ax1_EraserCircle, else: - return (self.ax2_EraserCircle,) + return self.ax2_EraserCircle, def activeEraserXCursors(self, isHoverImg1): if self.showMirroredCursorAction.isChecked(): return self.ax1_EraserX, self.ax2_EraserX - + if isHoverImg1: - return (self.ax1_EraserX,) + return self.ax1_EraserX, else: - return (self.ax2_EraserX,) + return self.ax2_EraserX, def addFontSizeActions(self, menu, slot): fontActionGroup = QActionGroup(self) fontActionGroup.setExclusive(True) - for fontSize in range(4, 27): + for fontSize in range(4,27): action = QAction(self) action.setText(str(fontSize)) action.setCheckable(True) @@ -95,19 +87,18 @@ def autoRange(self): self.ax2.autoRange() self.ax1.autoRange() - @exception_handler def changeFontSize(self): fontSize = self.fontSizeSpinBox.value() if fontSize == self.fontSize: return - + self.fontSize = fontSize - self.df_settings.at["fontSize", "value"] = self.fontSize + self.df_settings.at['fontSize', 'value'] = self.fontSize self.df_settings.to_csv(self.settings_csv_path) - + self.setAllIDs() - self.data[self.pos_i] + posData = self.data[self.pos_i] for ax in range(2): self.textAnnot[ax].changeFontSize(self.fontSize) if self.highLowResAction.isChecked(): @@ -117,17 +108,13 @@ def changeFontSize(self): def clearCursors(self): self.ax1_cursor.setData([], []) - self.ax2_cursor.setData([], []) + self.ax2_cursor.setData([], []) self.setHoverToolSymbolData( - [], - [], - (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) + [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) eraserCursors = ( - self.ax1_EraserCircle, - self.ax2_EraserCircle, - self.ax1_EraserX, - self.ax2_EraserX, + self.ax1_EraserCircle, self.ax2_EraserCircle, + self.ax1_EraserX, self.ax2_EraserX ) self.setHoverToolSymbolData([], [], eraserCursors) @@ -141,15 +128,14 @@ def editImgProperties(self, checked=True): ask_SizeT=True, ask_TimeIncrement=True, ask_PhysicalSizes=True, - save=True, - singlePos=True, - askSegm3D=False, + save=True, singlePos=True, + askSegm3D=False ) - if hasattr(self, "timestamp"): + if hasattr(self, 'timestamp'): self.timestamp.setSecondsPerFrame(posData.TimeIncrement) self.updateTimestampFrame() - - if hasattr(self, "scaleBar"): + + if hasattr(self, 'scaleBar'): self.scaleBar.updatePhysicalLength(posData.PhysicalSizeX) def enableZstackWidgets(self, enabled): @@ -184,21 +170,20 @@ def enableZstackWidgets(self, enabled): self.SizeZlabel.hide() self.switchPlaneCombobox.hide() self.switchPlaneCombobox.setDisabled(True) - + self.imgGrad.rescaleAcrossZstackAction.setDisabled(not enabled) for ch, overlayItems in self.overlayLayersItems.items(): lutItem = overlayItems[1] lutItem.rescaleAcrossZstackAction.setDisabled(not enabled) - @disableWindow def equalizeHist(self, checked=True): self.img1.useEqualized = checked - + if not checked: self.updateAllImages() return - self.logger.info("Equalizing image histogram...") + self.logger.info('Equalizing image histogram...') for pos_i, _posData in enumerate(self.data): n_dim_img = _posData.img_data.ndim _posData.equalized_img_data = preprocess.PreprocessedData() @@ -219,12 +204,12 @@ def equalizeHist(self, checked=True): self.img1.updateMinMaxValuesEqualizedData( self.data, pos_i, frame_i, None ) - + self.updateAllImages() def getCheckNormAction(self): normalize = False - how = "" + how = '' for action in self.normalizeQActionGroup.actions(): if action.isChecked(): how = action.text() @@ -235,7 +220,7 @@ def getCheckNormAction(self): def getContoursImageItem(self, ax, force=False): if not self.areContoursRequested(ax) and not force: return - + if ax == 0: return self.ax1_contoursImageItem else: @@ -249,9 +234,11 @@ def getDisplayedZstack(self): return posData.img_data[posData.frame_i] def getDistantGray(self, desiredGray, bkgrGray): - isDesiredSimilarToBkgr = abs(desiredGray - bkgrGray) < 0.3 + isDesiredSimilarToBkgr = ( + abs(desiredGray-bkgrGray) < 0.3 + ) if isDesiredSimilarToBkgr: - return 1 - desiredGray + return 1-desiredGray else: return desiredGray @@ -259,53 +246,53 @@ def getImage(self, frame_i=None, raw=False): posData = self.data[self.pos_i] if frame_i is None: frame_i = posData.frame_i - + if raw: return self.getRawImageLayer0(frame_i) - + if self.viewPreprocDataToggle.isChecked(): try: img = posData.preproc_img_data[frame_i] if posData.SizeZ == 1: return np.array(img) - + self.updateZsliceScrollbar(frame_i) z_slice = self.z_slice_index() img = img[z_slice] return img - except Exception: + except Exception as err: # self.logger.warning( # 'Pre-processed image not existing --> returning raw image' # ) return self.getRawImageLayer0(frame_i) - + viewCombinedImageData = ( self.viewCombineChannelDataToggle.isChecked() and self.combineDialog is not None and not self.combineDialog.saveAsSegm() ) - + if viewCombinedImageData: try: img = posData.combine_img_data[frame_i] if posData.SizeZ == 1: return np.array(img) - + self.updateZsliceScrollbar(frame_i) z_slice = self.z_slice_index() img = img[z_slice] return img - except Exception: + except Exception as err: # self.logger.warning( # 'combined image not existing --> returning raw image' # ) return self.getRawImageLayer0(frame_i) - + if self.equalizeHistPushButton.isChecked(): img = posData.equalized_img_data[frame_i] if posData.SizeZ == 1: return np.array(img) - + self.updateZsliceScrollbar(frame_i) z_slice = self.z_slice_index() img = img[z_slice] @@ -333,16 +320,16 @@ def getLostTrackedObjImageItem(self, ax): return self.ax2_lostTrackedObjImageItem def getObjBbox(self, obj_bbox): - if self.isSegm3D and len(obj_bbox) == 6: + if self.isSegm3D and len(obj_bbox)==6: obj_bbox = (obj_bbox[1], obj_bbox[2], obj_bbox[4], obj_bbox[5]) return obj_bbox else: return obj_bbox def getObjImage(self, obj_image, obj_bbox, z_slice=None): - if self.isSegm3D and len(obj_bbox) == 6: + if self.isSegm3D and len(obj_bbox)==6: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == "single z-slice" + isZslice = zProjHow == 'single z-slice' if not isZslice: # required a projection return obj_image.max(axis=0) @@ -352,11 +339,11 @@ def getObjImage(self, obj_image, obj_bbox, z_slice=None): z_slice = self.z_lab() if isinstance(z_slice, tuple): z_slice = z_slice[-1] - + local_z = z_slice - min_z try: obi_image_2d = obj_image[local_z] - except Exception: + except Exception as err: obi_image_2d = None return obi_image_2d else: @@ -387,7 +374,7 @@ def getObject2DsliceFromZ(self, z, obj): def getPreComputedMinMaxZstack(self, channel: str): if channel != self.user_ch_name: return None - + posData = self.data[self.pos_i] zstack_min, zstack_max = np.inf, 0 for z in range(posData.SizeZ): @@ -395,14 +382,14 @@ def getPreComputedMinMaxZstack(self, channel: str): levels = self.img1.minMaxValuesMapper.get(key) if levels is None: return - + img_min, img_max = levels if img_min < zstack_min: zstack_min = img_min - + if img_max > zstack_max: zstack_max = img_max - + return (zstack_min, zstack_max) def getRawImage(self, frame_i=None, filename=None): @@ -412,7 +399,7 @@ def getRawImage(self, frame_i=None, filename=None): if filename is None: rawImgData = posData.img_data[frame_i] isLayer0 = True - else: + else: rawImgData = posData.ol_data[filename][frame_i] isLayer0 = False if posData.SizeZ > 1: @@ -437,10 +424,10 @@ def getRawImageLayer0(self, frame_i): return img raise ValueError( - "Raw image for display must be 2D (Y, X) or RGB/A (Y, X, 3 or 4); " - f"got shape={getattr(img, 'shape', None)}, ndim={getattr(img, 'ndim', None)} " - f"for frame_i={frame_i} (metadata SizeT={posData.SizeT}, SizeZ={posData.SizeZ}). " - "Check that metadata SizeT/SizeZ matches the loaded array (e.g. squeezed TIFF vs CSV)." + 'Raw image for display must be 2D (Y, X) or RGB/A (Y, X, 3 or 4); ' + f'got shape={getattr(img, "shape", None)}, ndim={getattr(img, "ndim", None)} ' + f'for frame_i={frame_i} (metadata SizeT={posData.SizeT}, SizeZ={posData.SizeZ}). ' + 'Check that metadata SizeT/SizeZ matches the loaded array (e.g. squeezed TIFF vs CSV).' ) def get_2Dimg_from_3D(self, imgData, isLayer0=True, frame_i=None): @@ -450,36 +437,36 @@ def get_2Dimg_from_3D(self, imgData, isLayer0=True, frame_i=None): if frame_i < 0: frame_i = 0 frame_i = posData.frame_i = 0 - + axis_slice = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == "x": + if self.switchPlaneCombobox.depthAxes() == 'x': return imgData[:, :, axis_slice].copy() - elif self.switchPlaneCombobox.depthAxes() == "y": + elif self.switchPlaneCombobox.depthAxes() == 'y': return imgData[:, axis_slice].copy() - + idx = (posData.filename, frame_i) zProjHow_L0 = self.zProjComboBox.currentText() if isLayer0: try: - z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] - except ValueError: - z = posData.segmInfo_df.loc[idx, "z_slice_used_gui"].iloc[0] + z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + except ValueError as e: + z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] zProjHow = zProjHow_L0 else: z = self.zSliceOverlay_SB.sliderPosition() zProjHow_L1 = self.zProjOverlay_CB.currentText() - if zProjHow_L1 == "same as above": + if zProjHow_L1 == 'same as above': zProjHow = zProjHow_L0 else: zProjHow = zProjHow_L1 - - if zProjHow == "single z-slice": - img = imgData[z] # .copy() - elif zProjHow == "max z-projection": + + if zProjHow == 'single z-slice': + img = imgData[z] #.copy() + elif zProjHow == 'max z-projection': img = imgData.max(axis=0) - elif zProjHow == "mean z-projection": + elif zProjHow == 'mean z-projection': img = imgData.mean(axis=0) - elif zProjHow == "median z-proj.": + elif zProjHow == 'median z-proj.': img = np.median(imgData, axis=0) return img @@ -488,7 +475,7 @@ def get_2Dlab(self, lab, force_z=True): if force_z: return lab[self.z_lab()] zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == "single z-slice" + isZslice = zProjHow == 'single z-slice' if isZslice: return lab[self.z_lab()] else: @@ -496,7 +483,7 @@ def get_2Dlab(self, lab, force_z=True): else: return lab - def get_2Drp(self, lab=None): + def get_2Drp(self, lab=None): if self.isSegm3D: if lab is None: # self.currentLab2D is defined at self.setImageImg2() @@ -512,25 +499,27 @@ def initContoursImage(self): z_slice = self.z_lab() img = posData.img_data[posData.frame_i] Y, X = img[z_slice].shape[-2:] - + self.contoursImage = np.zeros((Y, X, 4), dtype=np.uint8) def initImgCmap(self): - if "img_cmap" not in self.df_settings.index: - self.df_settings.at["img_cmap", "value"] = "grey" - self.imgCmapName = self.df_settings.at["img_cmap", "value"] + if not 'img_cmap' in self.df_settings.index: + self.df_settings.at['img_cmap', 'value'] = 'grey' + self.imgCmapName = self.df_settings.at['img_cmap', 'value'] self.imgCmap = self.imgGrad.cmaps[self.imgCmapName] - if self.imgCmapName != "grey": + if self.imgCmapName != 'grey': # To ensure mapping to colors we need to normalize image self.normalizeByMaxAction.setChecked(True) def initImgGradRescaleIntensitiesHowPreference(self): posData = self.data[self.pos_i] channelName = posData.user_ch_name - if f"how_rescale_intensities_{channelName}" not in self.df_settings.index: + if f'how_rescale_intensities_{channelName}' not in self.df_settings.index: return - - how = self.df_settings.at[f"how_rescale_intensities_{channelName}", "value"] + + how = self.df_settings.at[ + f'how_rescale_intensities_{channelName}', 'value' + ] self.imgGrad.setRescaleIntensitiesHow(how) def initLostObjContoursImage(self): @@ -538,7 +527,7 @@ def initLostObjContoursImage(self): z_slice = self.z_lab() img = posData.img_data[posData.frame_i] Y, X = img[z_slice].shape[-2:] - + self.lostObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) def initLostTrackedObjContoursImage(self): @@ -546,16 +535,16 @@ def initLostTrackedObjContoursImage(self): z_slice = self.z_lab() img = posData.img_data[posData.frame_i] Y, X = img[z_slice].shape[-2:] - + self.lostTrackedObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) def initManualBackgroundImage(self): posData = self.data[self.pos_i] - if hasattr(posData, "lab"): + if hasattr(posData, 'lab'): Y, X = posData.lab.shape[-2:] else: Y, X = posData.img_data.shape[-2:] - if not hasattr(self, "manualBackgroundTextItems"): + if not hasattr(self, 'manualBackgroundTextItems'): self.manualBackgroundTextItems = {} posData.manualBackgroundImage = np.zeros((Y, X, 4), dtype=np.uint8) if posData.manualBackgroundLab is None: @@ -563,30 +552,27 @@ def initManualBackgroundImage(self): def initTextAnnot(self, force=False): posData = self.data[self.pos_i] - if hasattr(posData, "lab"): + if hasattr(posData, 'lab'): Y, X = posData.lab.shape[-2:] else: Y, X = posData.img_data.shape[-2:] self.textAnnot[0].initItem((Y, X)) - self.textAnnot[1].initItem((Y, X)) - - def intensity_normalization_setting_value(self, how: str) -> str: - return how + self.textAnnot[1].initItem((Y, X)) def invertBw(self, checked, update=True): self.invertBwAlreadyCalledOnce = True - + try: self.labelsGrad.invertBwAction.toggled.disconnect() - except Exception: + except Exception as err: pass - + self.labelsGrad.invertBwAction.setChecked(checked) self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) try: self.imgGrad.invertBwAction.toggled.disconnect() - except Exception: + except Exception as err: pass self.imgGrad.invertBwAction.setChecked(checked) self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) @@ -597,7 +583,7 @@ def invertBw(self, checked, update=True): self.imgGradRight.setInvertedColorMaps(checked) self.imgGradRight.invertCurrentColormap(checked) - if hasattr(self, "overlayLayersItems"): + if hasattr(self, 'overlayLayersItems'): for items in self.overlayLayersItems.values(): lutItem = items[1] lutItem.invertBwAction.toggled.disconnect() @@ -608,68 +594,67 @@ def invertBw(self, checked, update=True): if self.slideshowWin is not None: self.slideshowWin.is_bw_inverted = checked self.slideshowWin.update_img() - self.df_settings.at["is_bw_inverted", "value"] = "Yes" if checked else "No" + self.df_settings.at['is_bw_inverted', 'value'] = 'Yes' if checked else 'No' self.df_settings.to_csv(self.settings_csv_path) if checked: # Light mode - self.equalizeHistPushButton.setStyleSheet("") + self.equalizeHistPushButton.setStyleSheet('') self.graphLayout.setBackground(graphLayoutBkgrColor) - self.ax2_BrushCirclePen = pg.mkPen((150, 150, 150), width=2) - self.ax2_BrushCircleBrush = pg.mkBrush((200, 200, 200, 150)) - self.titleColor = "black" + self.ax2_BrushCirclePen = pg.mkPen((150,150,150), width=2) + self.ax2_BrushCircleBrush = pg.mkBrush((200,200,200,150)) + self.titleColor = 'black' else: # Dark mode self.equalizeHistPushButton.setStyleSheet( - "QPushButton {background-color: #282828; color: #F0F0F0;}" + 'QPushButton {background-color: #282828; color: #F0F0F0;}' ) self.graphLayout.setBackground(darkBkgrColor) self.ax2_BrushCirclePen = pg.mkPen(width=2) - self.ax2_BrushCircleBrush = pg.mkBrush((255, 255, 255, 50)) - self.titleColor = "white" - - if not hasattr(self, "textAnnot"): + self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50)) + self.titleColor = 'white' + + if not hasattr(self, 'textAnnot'): return - + self.textAnnot[0].invertBlackAndWhite() self.textAnnot[1].invertBlackAndWhite() - self.objLabelAnnotRgb = tuple(self.textAnnot[0].item.colors()["label"][:3]) + self.objLabelAnnotRgb = tuple( + self.textAnnot[0].item.colors()['label'][:3] + ) self.textIDsColorButton.setColor(self.objLabelAnnotRgb) self.imgGrad.textColorButton.setColor(self.objLabelAnnotRgb) for items in self.overlayLayersItems.values(): lutItem = items[1] lutItem.textColorButton.setColor(self.objLabelAnnotRgb) - + if update: self.updateAllImages() - def invert_bw_setting_value(self, checked: bool) -> str: - return "Yes" if checked else "No" - def isObjVisible(self, obj_bbox, debug=False, z_slice=None): if z_slice is None: z_slice = self.z_lab() - + if self.isSegm3D: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == "single z-slice" + isZslice = zProjHow == 'single z-slice' if not isZslice: # required a projection --> all obj are visible return True - + depthAxes = self.switchPlaneCombobox.depthAxes() - + min_z, min_y, min_x, max_z, max_y, max_x = obj_bbox - if depthAxes == "z": + if depthAxes == 'z': min_val, max_val = min_z, max_z val = z_slice - elif depthAxes == "y": + elif depthAxes == 'y': min_val, max_val = min_y, max_y val = z_slice[-1] else: min_val, max_val = min_x, max_x val = z_slice[-1] - + if val >= min_val and val < max_val: return True else: @@ -677,15 +662,6 @@ def isObjVisible(self, obj_bbox, debug=False, z_slice=None): else: return True - def labels_alpha_plan( - self, - value: float, - *, - keep_ids_checked: bool, - ) -> LabelsAlphaPlan: - opacity = value / 3 if keep_ids_checked else value - return LabelsAlphaPlan(setting_value=value, opacity=opacity) - def launchSlideshow(self): posData = self.data[self.pos_i] self.determineSlideshowWinPos() @@ -694,14 +670,18 @@ def launchSlideshow(self): parent=self, button_toUncheck=self.slideshowButton, linkWindow=posData.SizeT > 1, - enableOverlay=True, - enableMirroredCursor=True, + enableOverlay=True, + enableMirroredCursor=True + ) + self.slideshowWin.img.minMaxValuesMapper = ( + self.img1.minMaxValuesMapper ) - self.slideshowWin.img.minMaxValuesMapper = self.img1.minMaxValuesMapper self.slideshowWin.img.setCurrentPosIndex(self.pos_i) h = self.drawIDsContComboBox.size().height() self.slideshowWin.framesScrollBar.setFixedHeight(h) - self.slideshowWin.overlayButton.setChecked(self.overlayButton.isChecked()) + self.slideshowWin.overlayButton.setChecked( + self.overlayButton.isChecked() + ) self.slideshowWin.sigHoveringImage.connect( self.setMirroredCursorFromSecondWindow ) @@ -710,17 +690,19 @@ def launchSlideshow(self): self.slideshowWin.img.setCurrentZsliceIndex(z_slice) self.slideshowWin.zSliceScrollBar.setSliderPosition(z_slice) self.slideshowWin.z_label.setText( - f"z-slice {z_slice + 1:02}/{posData.SizeZ}" + f'z-slice {z_slice+1:02}/{posData.SizeZ}' ) self.slideshowWin.update_img() - self.slideshowWin.show(left=self.slideshowWinLeft, top=self.slideshowWinTop) + self.slideshowWin.show( + left=self.slideshowWinLeft, top=self.slideshowWinTop + ) else: self.slideshowWin.close() self.slideshowWin = None def normaliseIntensitiesActionTriggered(self, action): how = action.text() - self.df_settings.at["how_normIntensities", "value"] = how + self.df_settings.at['how_normIntensities', 'value'] = how self.df_settings.to_csv(self.settings_csv_path) self.updateAllImages() self.updateImageValueFormatter() @@ -729,32 +711,32 @@ def normalizeIntensities(self, img): action, normalize, how = self.getCheckNormAction() if not normalize: return img - - if how == "Do not normalize. Display raw image": - img = img - elif how == "Convert to floating point format with values [0, 1]": + + if how == 'Do not normalize. Display raw image': + img = img + elif how == 'Convert to floating point format with values [0, 1]': img = myutils.img_to_float(img) # elif how == 'Rescale to 8-bit unsigned integer format with values [0, 255]': # img = skimage.img_as_float(img) # img = (img*255).astype(np.uint8) # return img - elif how == "Rescale to [0, 1]": + elif how == 'Rescale to [0, 1]': img = skimage.img_as_float(img) img = skimage.exposure.rescale_intensity(img) - elif how == "Normalize by max value": - img = img / np.max(img) + elif how == 'Normalize by max value': + img = img/np.max(img) return img def removeAxLimits(self): - self.ax1.vb.state["limits"]["xLimits"] = [-1e307, +1e307] - self.ax1.vb.state["limits"]["yLimits"] = [-1e307, +1e307] + self.ax1.vb.state['limits']['xLimits'] = [-1E307, +1E307] + self.ax1.vb.state['limits']['yLimits'] = [-1E307, +1E307] def rescaleIntensExportToVideoDialog(self, how, channel, setImage=True): if channel == self.user_ch_name: lutItem = self.imgGrad else: lutItem = self.overlayLayersItems[channel][1] - + for action in lutItem.rescaleActionGroup.actions(): if action.text() == how: action.trigger() @@ -762,13 +744,17 @@ def rescaleIntensExportToVideoDialog(self, how, channel, setImage=True): break def rescaleIntensitiesLut( - self, action: QAction = None, setImage: bool = True, imageItem=None - ): + self, + action: QAction=None, + setImage: bool=True, + imageItem=None + ): if not self.isDataLoaded: self.logger.info( - "WARNING: Data is not loaded. Intensities will be rescaled later." + 'WARNING: Data is not loaded. ' + 'Intensities will be rescaled later.' ) - return + return posData = self.data[self.pos_i] if imageItem is None: @@ -779,55 +765,55 @@ def rescaleIntensitiesLut( channel = imageItem.channelName _, filename = self.getPathFromChName(channel, posData) image_data = posData.fluo_data_dict[filename] - + triggeredByUser = True if action is None: triggeredByUser = False action = imageItem.lutItem.rescaleActionGroup.checkedAction() - + how = action.text() - - self.df_settings.at[f"how_rescale_intensities_{channel}", "value"] = how + + self.df_settings.at[f'how_rescale_intensities_{channel}', 'value'] = how self.df_settings.to_csv(self.settings_csv_path) - - if how == "Rescale each 2D image": + + if how == 'Rescale each 2D image': if how == self.rescaleIntensChannelHowMapper[channel]: # No need to update since we have autoscale - return - + return + imageItem.setEnableAutoLevels(True) if setImage: imageItem.setImage(imageItem.image) return - + lutLevelsCh = posData.lutLevels[channel] - - if how == "Rescale across z-stack": + + if how == 'Rescale across z-stack': imageItem.setEnableAutoLevels(False) levels_key = (how, posData.frame_i) levels = lutLevelsCh.get(levels_key) if levels is None: levels = self.getPreComputedMinMaxZstack(channel) - + if levels is None: image_zstack = image_data[posData.frame_i] levels = (image_zstack.min(), image_zstack.max()) lutLevelsCh[levels_key] = levels imageItem.setLevels(levels) - elif how == "Rescale across time frames": + elif how == 'Rescale across time frames': imageItem.setEnableAutoLevels(False) levels_key = (how, None) levels = lutLevelsCh.get(levels_key) if levels is None: levels = (image_data.min(), image_data.max()) - + lutLevelsCh[levels_key] = levels imageItem.setLevels(levels) - elif how == "Choose custom levels...": + elif how == 'Choose custom levels...': autoLevelsEnabledBefore = imageItem.autoLevelsEnabled imageItem.setEnableAutoLevels(False) if triggeredByUser: - current_min, current_max = imageItem.getLevels() + current_min, current_max = imageItem.getLevels() dtype_max = np.iinfo(image_data.dtype).max max_value = image_data.max() min_value = image_data.min() @@ -836,7 +822,7 @@ def rescaleIntensitiesLut( init_max_value=current_max, maximum_max_value=max_value, minimum_min_value=min_value, - parent=self, + parent=self ) win.sigLevelsChanged.connect( partial(self.customLevelsLutChanged, imageItem=imageItem) @@ -844,14 +830,14 @@ def rescaleIntensitiesLut( win.exec_() if win.cancel: imageItem.setEnableAutoLevels(autoLevelsEnabledBefore) - self.logger.info("Custom LUT levels setting cancelled.") + self.logger.info('Custom LUT levels setting cancelled.') self.updateAllImages() return selectedLevels = win.selectedLevels else: selectedLevels = imageItem.getLevels() imageItem.setLevels(selectedLevels) - elif how == "Do no rescale, display raw image": + elif how == 'Do no rescale, display raw image': imageItem.setEnableAutoLevels(False) levels_key = (how, None) levels = lutLevelsCh.get(levels_key) @@ -860,100 +846,12 @@ def rescaleIntensitiesLut( levels = (0, dtype_max) lutLevelsCh[levels_key] = levels imageItem.setLevels(levels) - + self.rescaleIntensChannelHowMapper[channel] = how - + if setImage: imageItem.setImage(imageItem.image) - def rescale_intensity_setting_update( - self, - channel: str, - how: str, - ) -> tuple[str, str]: - return f"how_rescale_intensities_{channel}", how - - LEGACY_METHODS = ( - "getDisplayedImg1", - "getDisplayedZstack", - "getObjBbox", - "z_lab", - "get_2Dlab", - "get_2Drp", - "set_2Dlab", - "setTextAnnotZsliceScrolling", - "setGraphicalAnnotZsliceScrolling", - "initContoursImage", - "initLostObjContoursImage", - "initLostTrackedObjContoursImage", - "initManualBackgroundImage", - "initImgCmap", - "initTextAnnot", - "zoomOut", - "zoomToObjsActionCallback", - "zoomToCells", - "equalizeHist", - "getDistantGray", - "RGBtoGray", - "ruler_cb", - "editImgProperties", - "setTwoImagesLayout", - "showNextFrameImageItem", - "showRightImageItem", - "showLabelImageItem", - "setAnnotOptionsRightImageLabelsDisabled", - "setBottomLayoutStretch", - "setHoverToolSymbolData", - "getCheckNormAction", - "normalizeIntensities", - "invertBw", - "setCheckedInvertBW", - "updateImageValueFormatter", - "getImageDataFromFilename", - "z_slice_index", - "get_2Dimg_from_3D", - "updateZsliceScrollbar", - "getRawImage", - "getRawImageLayer0", - "getImage", - "updateLabelsAlpha", - "_getImageupdateAllImages", - "setImageImg1", - "setImageImg2", - "getObject2DimageFromZ", - "getObject2DsliceFromZ", - "isObjVisible", - "getObjImage", - "getObjSlice", - "getContoursImageItem", - "getLostObjImageItem", - "getLostTrackedObjImageItem", - "normaliseIntensitiesActionTriggered", - "setLastUserNormAction", - "saveLabelsColormap", - "addFontSizeActions", - "changeFontSize", - "enableZstackWidgets", - "launchSlideshow", - "setMirroredCursorFromSecondWindow", - "zProjLockViewToggled", - "rescaleIntensExportToVideoDialog", - "customLevelsLutChanged", - "getPreComputedMinMaxZstack", - "rescaleIntensitiesLut", - "showMirroredCursorToggled", - "clearCursors", - "activeEraserCircleCursors", - "activeEraserXCursors", - "activeBrushCircleCursors", - "initImgGradRescaleIntensitiesHowPreference", - "updateAllImages", - "removeAxLimits", - "resizeGui", - "autoRange", - "resetRange", - ) - def resetRange(self): if self.ax1_viewRange is None: return @@ -965,43 +863,22 @@ def resetRange(self): self.isRangeReset = True def resizeGui(self): - self.ax1.vb.state["limits"]["xRange"] = [None, None] - self.ax1.vb.state["limits"]["yRange"] = [None, None] + self.ax1.vb.state['limits']['xRange'] = [None, None] + self.ax1.vb.state['limits']['yRange'] = [None, None] self.autoRange() - if self.ax1.getViewBox().state["limits"]["xRange"][0] is not None: + if self.ax1.getViewBox().state['limits']['xRange'][0] is not None: self.bottomScrollArea._resizeVertical() return (xmin, xmax), (ymin, ymax) = self.ax1.viewRange() - maxYRange = int((ymax - ymin) * 1.5) - maxXRange = int((xmax - xmin) * 1.5) - self.ax1.setLimits(maxYRange=maxYRange, maxXRange=maxXRange) + maxYRange = int((ymax-ymin)*1.5) + maxXRange = int((xmax-xmin)*1.5) + self.ax1.setLimits( + maxYRange=maxYRange, + maxXRange=maxXRange + ) self.bottomScrollArea._resizeVertical() QTimer.singleShot(200, self.autoRange) - def right_pane_visibility_plan( - self, - mode: RightPaneMode, - checked: bool, - ) -> RightPaneVisibilityPlan: - settings_updates = { - "isNextFrameVisible": "No", - "isRightImageVisible": "No", - "isLabelsVisible": "No", - } - if checked: - setting_key = { - "next_frame": "isNextFrameVisible", - "right_image": "isRightImageVisible", - "labels": "isLabelsVisible", - }[mode] - settings_updates[setting_key] = "Yes" - - return RightPaneVisibilityPlan( - mode=mode, - checked=checked, - settings_updates=settings_updates, - ) - def ruler_cb(self, checked): if checked: self.disconnectLeftClickButtons() @@ -1086,41 +963,40 @@ def setImageImg1(self, image=None): self.img1.setCurrentFrameIndex(posData.frame_i) if posData.SizeZ > 1: zProjHow = self.zProjComboBox.currentText() - if zProjHow == "single z-slice": + if zProjHow == 'single z-slice': z = self.zSliceScrollBar.sliderPosition() else: z = zProjHow - + self.img1.setCurrentZsliceIndex(z) self.img1.setImage( - img, - next_frame_image=self.nextFrameImage(), - scrollbar_value=posData.frame_i + 2, + img, next_frame_image=self.nextFrameImage(), + scrollbar_value=posData.frame_i+2 ) def setImageImg2(self, updateLookuptable=True, set_image=True): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if mode == "Segmentation and Tracking" or self.isSnapshot: + if mode == 'Segmentation and Tracking' or self.isSnapshot: # self.addExistingDelROIs() allDelIDs, lab2D = self.getDelROIlab() else: lab2D = self.get_2Dlab(posData.lab, force_z=False) allDelIDs = set() - - self.currentLab2D = lab2D + + self.currentLab2D = lab2D if self.labelsGrad.permanentGreedyCmapAction.isChecked() and updateLookuptable: self.greedyShuffleCmap(updateImages=False) - + if self.labelsGrad.showLabelsImgAction.isChecked() and set_image: self.img2.setImage(lab2D, z=self.z_lab(), autoLevels=False) - + if updateLookuptable: self.updateLookuptable(delIDs=allDelIDs) def setLastUserNormAction(self): - how = self.df_settings.at["how_normIntensities", "value"] + how = self.df_settings.at['how_normIntensities', 'value'] for action in self.normalizeQActionGroup.actions(): if action.text() == how: action.setChecked(True) @@ -1151,7 +1027,7 @@ def setTwoImagesLayout(self, isTwoImages): else: self.graphLayout.removeItem(self.titleLabel) self.graphLayout.addItem(self.titleLabel, row=0, col=1) - # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignCenter) + # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignCenter) self.ax2.hide() oldLink = self.ax2.vb.linkedView(self.ax1.vb.YAxis) try: @@ -1162,13 +1038,13 @@ def setTwoImagesLayout(self, isTwoImages): def set_2Dlab(self, lab2D, lab3D=None): posData = self.data[self.pos_i] - + if lab3D is None: lab3D = posData.lab - + if self.isSegm3D: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == "single z-slice" + isZslice = zProjHow == 'single z-slice' if isZslice: lab3D[self.z_lab()] = lab2D else: @@ -1185,9 +1061,9 @@ def showLabelImageItem(self, checked): self.setTwoImagesLayout(checked) self.setAnnotOptionsRightImageLabelsDisabled(checked) if checked: - self.df_settings.at["isLabelsVisible", "value"] = "Yes" - self.df_settings.at["isNextFrameVisible", "value"] = "No" - self.df_settings.at["isRightImageVisible", "value"] = "No" + self.df_settings.at['isLabelsVisible', 'value'] = 'Yes' + self.df_settings.at['isNextFrameVisible', 'value'] = 'No' + self.df_settings.at['isRightImageVisible', 'value'] = 'No' self.rightBottomGroupbox.show() self.rightBottomGroupbox.setChecked(True) if not self.isDataLoading: @@ -1195,20 +1071,20 @@ def showLabelImageItem(self, checked): else: self.clearAx2Items() self.img2.clear() - self.df_settings.at["isLabelsVisible", "value"] = "No" + self.df_settings.at['isLabelsVisible', 'value'] = 'No' self.rightBottomGroupbox.hide() self.moveDelRoisToLeft() - + self.df_settings.to_csv(self.settings_csv_path) QTimer.singleShot(200, self.resizeGui) self.setBottomLayoutStretch() def showMirroredCursorToggled(self, checked): - value = "Yes" if checked else "No" - self.df_settings.at["showMirroredCursor", "value"] = value + value = 'Yes' if checked else 'No' + self.df_settings.at['showMirroredCursor', 'value'] = value self.df_settings.to_csv(settings_csv_path) - + if not checked: self.clearCursors() @@ -1217,84 +1093,84 @@ def showNextFrameImageItem(self, checked): self.rightImageFramesScrollbar.setDisabled(not checked) self.setTwoImagesLayout(checked) if checked: - self.df_settings.at["isNextFrameVisible", "value"] = "Yes" - self.df_settings.at["isRightImageVisible", "value"] = "No" - self.df_settings.at["isLabelsVisible", "value"] = "No" - self.graphLayout.addItem(self.imgGradRight, row=1, col=self.plotsCol + 2) + self.df_settings.at['isNextFrameVisible', 'value'] = 'Yes' + self.df_settings.at['isRightImageVisible', 'value'] = 'No' + self.df_settings.at['isLabelsVisible', 'value'] = 'No' + self.graphLayout.addItem( + self.imgGradRight, row=1, col=self.plotsCol+2 + ) self.rightBottomGroupbox.show() self.rightBottomGroupbox.setChecked(True) - self.drawNothingCheckboxRight.click() + self.drawNothingCheckboxRight.click() if not self.isDataLoading: self.updateAllImages() else: self.clearAx2Items() self.rightBottomGroupbox.hide() - self.df_settings.at["isNextFrameVisible", "value"] = "No" + self.df_settings.at['isNextFrameVisible', 'value'] = 'No' try: self.graphLayout.removeItem(self.imgGradRight) except Exception: return self.rightImageItem.clear() - + self.df_settings.to_csv(self.settings_csv_path) - + QTimer.singleShot(300, self.resizeGui) - self.setBottomLayoutStretch() + self.setBottomLayoutStretch() def showRightImageItem(self, checked): self.rightImageFramesScrollbar.setVisible(not checked) self.rightImageFramesScrollbar.setDisabled(checked) self.setTwoImagesLayout(checked) if checked: - self.df_settings.at["isRightImageVisible", "value"] = "Yes" - self.df_settings.at["isNextFrameVisible", "value"] = "No" - self.df_settings.at["isLabelsVisible", "value"] = "No" - self.graphLayout.addItem(self.imgGradRight, row=1, col=self.plotsCol + 2) + self.df_settings.at['isRightImageVisible', 'value'] = 'Yes' + self.df_settings.at['isNextFrameVisible', 'value'] = 'No' + self.df_settings.at['isLabelsVisible', 'value'] = 'No' + self.graphLayout.addItem( + self.imgGradRight, row=1, col=self.plotsCol+2 + ) self.rightBottomGroupbox.show() if not self.isDataLoading: self.updateAllImages() else: self.clearAx2Items() self.rightBottomGroupbox.hide() - self.df_settings.at["isRightImageVisible", "value"] = "No" + self.df_settings.at['isRightImageVisible', 'value'] = 'No' try: self.graphLayout.removeItem(self.imgGradRight) except Exception: return self.rightImageItem.clear() - + self.df_settings.to_csv(self.settings_csv_path) - + QTimer.singleShot(300, self.resizeGui) - self.setBottomLayoutStretch() + self.setBottomLayoutStretch() - @exception_handler def updateAllImages( - self, - image=None, - computePointsLayers=True, - computeContours=True, - updateLookuptable=True, - ): + self, image=None, computePointsLayers=True, computeContours=True, + updateLookuptable=True + ): self.clearAllItems() posData = self.data[self.pos_i] self.last_pos_i = self.pos_i self.last_frame_i = posData.frame_i - + self.rescaleIntensitiesLut(setImage=False) - self.setImageImg1(image=image) + self.setImageImg1(image=image) self.setImageImg2(updateLookuptable=updateLookuptable) - + self.setOverlayImages() self.setOverlayLabelsItems() self.setOverlaySegmMasks() - + if self.slideshowWin is not None: self.slideshowWin.frame_i = posData.frame_i self.slideshowWin.update_img() @@ -1302,17 +1178,19 @@ def updateAllImages( # self.update_rp() # Annotate ID and draw contours - delROIsIDs = self.setAllTextAnnotations() - self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False) + delROIsIDs = self.setAllTextAnnotations() + self.setAllContoursImages( + delROIsIDs=delROIsIDs, compute=False + ) mode = self.modeComboBox.currentText() self.drawAllMothBudLines() - if mode == "Normal division: Lineage tree": + if mode == 'Normal division: Lineage tree': self.drawAllLineageTreeLines() - self.highlightLostNew() + self.highlightLostNew() - if self.ccaTableWin is not None: # need to add for lin tree, later + if self.ccaTableWin is not None: # need to add for lin tree, later zoomIDs = self.getZoomIDs() self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) @@ -1324,10 +1202,10 @@ def updateAllImages( self.drawPointsLayers(computePointsLayers=computePointsLayers) self.setManualBackgroundImage() self.annotateAssignedObjsAcdcTrackerSecondStep() - - self.highlightSearchedID(self.highlightedID, force=True) - self.updateTimestampFrame() - + + self.highlightSearchedID(self.highlightedID, force=True) + self.updateTimestampFrame() + posData.visited = True def updateImageValueFormatter(self): @@ -1335,41 +1213,41 @@ def updateImageValueFormatter(self): dtype = self.img1.image.dtype n_digits = len(str(int(self.img1.image.max()))) self.imgValueFormatter = myutils.get_number_fstring_formatter( - dtype, precision=abs(n_digits - 5) + dtype, precision=abs(n_digits-5) ) rawImgData = self.data[self.pos_i].img_data dtype = rawImgData.dtype n_digits = len(str(int(rawImgData.max()))) self.rawValueFormatter = myutils.get_number_fstring_formatter( - dtype, precision=abs(n_digits - 5) + dtype, precision=abs(n_digits-5) ) def updateLabelsAlpha(self, value): - self.df_settings.at["overlaySegmMasksAlpha", "value"] = value + self.df_settings.at['overlaySegmMasksAlpha', 'value'] = value self.df_settings.to_csv(self.settings_csv_path) if self.keepIDsButton.isChecked(): - value = value / 3 + value = value/3 self.labelsLayerImg1.setOpacity(value) self.labelsLayerRightImg.setOpacity(value) def updateZsliceScrollbar(self, frame_i): posData = self.data[self.pos_i] - if self.switchPlaneCombobox.depthAxes() != "z": + if self.switchPlaneCombobox.depthAxes() != 'z': return - + idx = (posData.filename, frame_i) try: - z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] - except ValueError: - z = posData.segmInfo_df.loc[idx, "z_slice_used_gui"].iloc[0] + z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + except ValueError as e: + z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] try: - zProjHow = posData.segmInfo_df.at[idx, "which_z_proj_gui"] - except ValueError: - zProjHow = posData.segmInfo_df.loc[idx, "which_z_proj_gui"].iloc[0] - + zProjHow = posData.segmInfo_df.at[idx, 'which_z_proj_gui'] + except ValueError as e: + zProjHow = posData.segmInfo_df.loc[idx, 'which_z_proj_gui'].iloc[0] + self.zProjComboBox.setCurrentText(zProjHow) - + reconnect = False try: self.zSliceScrollBar.actionTriggered.disconnect() @@ -1382,37 +1260,39 @@ def updateZsliceScrollbar(self, frame_i): self.zSliceScrollBar.actionTriggered.connect( self.zSliceScrollBarActionTriggered ) - self.zSliceScrollBar.sliderReleased.connect(self.zSliceScrollBarReleased) - self.zSliceSpinbox.setValueNoEmit(z + 1) + self.zSliceScrollBar.sliderReleased.connect( + self.zSliceScrollBarReleased + ) + self.zSliceSpinbox.setValueNoEmit(z+1) def zProjLockViewToggled(self, checked): self.updateZproj(self.zProjComboBox.currentText()) def z_lab(self, checkIfProj=False): - if checkIfProj and self.zProjComboBox.currentText() != "single z-slice": + if checkIfProj and self.zProjComboBox.currentText() != 'single z-slice': return - + if not self.isSegm3D: - return - + return + posData = self.data[self.pos_i] idx = self.zSliceScrollBar.sliderPosition() - + # ensure idx doesnt exceed the number of z-slices of the position - idx_z = min(idx, posData.SizeZ - 1) - + idx_z = min(idx, posData.SizeZ-1) + if not self.switchPlaneCombobox.isEnabled(): return idx_z - + depthAxes = self.switchPlaneCombobox.depthAxes() - if depthAxes == "z": + if depthAxes == 'z': return idx_z - elif depthAxes == "y": - idx_y = min(idx, posData.SizeY - 1) + elif depthAxes == 'y': + idx_y = min(idx, posData.SizeY-1) return (slice(None), idx_y) else: - idx_x = min(idx, posData.SizeX - 1) + idx_x = min(idx, posData.SizeX-1) return (slice(None), slice(None), idx_x) def z_slice_index(self): @@ -1420,17 +1300,21 @@ def z_slice_index(self): if posData.SizeZ == 1: return None zProjHow = self.zProjComboBox.currentText() - if zProjHow != "single z-slice": + if zProjHow != 'single z-slice': return zProjHow - + axis_slice = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == "x": - z_slice = (slice(None, None, None), slice(None, None, None), axis_slice) - elif self.switchPlaneCombobox.depthAxes() == "y": - z_slice = (slice(None, None, None), axis_slice) + if self.switchPlaneCombobox.depthAxes() == 'x': + z_slice = ( + slice(None, None, None), slice(None, None, None), axis_slice + ) + elif self.switchPlaneCombobox.depthAxes() == 'y': + z_slice = ( + slice(None, None, None), axis_slice + ) else: z_slice = axis_slice - + return z_slice def zoomOut(self): @@ -1440,18 +1324,18 @@ def zoomToCells(self, enforce=False): if not self.enableAutoZoomToCellsAction.isChecked() and not enforce: return - self.data[self.pos_i] - lab_mask = (self.currentLab2D > 0).astype(np.uint8) + posData = self.data[self.pos_i] + lab_mask = (self.currentLab2D>0).astype(np.uint8) rp = skimage.measure.regionprops(lab_mask) if not rp: Y, X = lab_mask.shape - xRange = -0.5, X + 0.5 - yRange = -0.5, Y + 0.5 + xRange = -0.5, X+0.5 + yRange = -0.5, Y+0.5 else: obj = rp[0] min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) - xRange = min_col - 10, max_col + 10 - yRange = max_row + 10, min_row - 10 + xRange = min_col-10, max_col+10 + yRange = max_row+10, min_row-10 self.ax1.setRange(xRange=xRange, yRange=yRange) diff --git a/cellacdc/mixins_bak/label_editing.py b/cellacdc/mixins/label_editing.py similarity index 73% rename from cellacdc/mixins_bak/label_editing.py rename to cellacdc/mixins/label_editing.py index 497bb714e..7081f7de0 100644 --- a/cellacdc/mixins_bak/label_editing.py +++ b/cellacdc/mixins/label_editing.py @@ -13,23 +13,17 @@ from cellacdc import apps, disableWindow, exception_handler -class LabelEditingMixin: - """Qt-facing adapter around manual label editing.""" - - """Headless decisions for manual label editing.""" - - # @exec_time - - # @exec_time +class LabelEditing: + """Extracted from guiWin.""" def _get_editID_info(self, df): - if "was_manually_edited" not in df.columns: + if 'was_manually_edited' not in df.columns: return [] - - if "y_centroid" not in df.columns or "x_centroid" not in df.columns: + + if 'y_centroid' not in df.columns or 'x_centroid' not in df.columns: df = self.addYXcentroidToDf(df) - - manually_edited_df = df[df["was_manually_edited"] > 0] + + manually_edited_df = df[df['was_manually_edited'] > 0] editID_info = [ (row.y_centroid, row.x_centroid, row.Index) for row in manually_edited_df.itertuples() @@ -39,47 +33,36 @@ def _get_editID_info(self, df): def _update_zslices_rp(self): if not self.isSegm3D: return - + posData = self.data[self.pos_i] posData.zSlicesRp = {} for z, lab2d in enumerate(posData.lab): lab2d_rp = skimage.measure.regionprops(lab2d) - posData.zSlicesRp[z] = {obj.label: obj for obj in lab2d_rp} + posData.zSlicesRp[z] = {obj.label:obj for obj in lab2d_rp} def addYXcentroidToDf(self, df): posData = self.data[self.pos_i] for obj in posData.rp: y_centroid = int(self.getObjCentroid(obj.centroid)[0]) x_centroid = int(self.getObjCentroid(obj.centroid)[1]) - df.at[obj.label, "y_centroid"] = y_centroid - df.at[obj.label, "x_centroid"] = x_centroid + df.at[obj.label, 'y_centroid'] = y_centroid + df.at[obj.label, 'x_centroid'] = x_centroid return df def applyEditID( - self, - clickedID, - currentIDs, - oldIDnewIDMapper, - clicked_x, - clicked_y, - shift=False, - doPropagateUnvisited=False, - ): + self, clickedID, currentIDs, oldIDnewIDMapper, clicked_x, clicked_y, shift=False, doPropagateUnvisited=False + ): posData = self.data[self.pos_i] - + # Ask to propagate change to all future visited frames - key = "Edit ID" + key = 'Edit ID' askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( - self.propagateChange( - clickedID, - key, - doNotShow, - posData.UndoFutFrames_EditID, - posData.applyFutFrames_EditID, - applyTrackingB=True, - ) + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + clickedID, key, doNotShow, + posData.UndoFutFrames_EditID, posData.applyFutFrames_EditID, + applyTrackingB=True ) if UndoFutFrames is None: @@ -89,7 +72,7 @@ def applyEditID( lab = self.get_2Dlab(posData.lab) else: lab = posData.lab - + # Store undo state before modifying stuff self.storeUndoRedoStates(UndoFutFrames) maxID = max(posData.IDs, default=0) @@ -128,17 +111,17 @@ def applyEditID( if not math.isnan(y) and not math.isnan(y): y, x = int(y), int(x) posData.editID_info.append((y, x, new_ID)) - + self.updateAssignedObjsAcdcTrackerSecondStep(new_ID) - + if shift and self.isSegm3D: self.set_2Dlab(lab) - + # Update rps self.update_rp() # Since we manually changed an ID we don't want to repeat tracking - self.setAllTextAnnotations() + self.setAllTextAnnotations() self.highlightLostNew() # self.checkIDsMultiContour() @@ -146,11 +129,11 @@ def applyEditID( self.updateLookuptable() if self.isSnapshot: - self.fixCcaDfAfterEdit("Edit ID") + self.fixCcaDfAfterEdit('Edit ID') self.updateAllImages() else: - self.warnEditingWithCca_df("Edit ID", update_images=False) - + self.warnEditingWithCca_df('Edit ID', update_images=False) + if not self.editIDbutton.findChild(QAction).isChecked(): self.editIDbutton.setChecked(False) @@ -161,37 +144,41 @@ def applyEditID( posData.UndoFutFrames_EditID = UndoFutFrames posData.applyFutFrames_EditID = applyFutFrames includeUnvisited = ( - posData.includeUnvisitedInfo["Edit ID"] or doPropagateUnvisited + posData.includeUnvisitedInfo['Edit ID'] + or doPropagateUnvisited ) - + if not applyFutFrames and not doPropagateUnvisited: return self.changeIDfutureFrames( - endFrame_i, oldIDnewIDMapper, includeUnvisited, shift=shift + endFrame_i, oldIDnewIDMapper, includeUnvisited, + shift=shift ) def apply_manual_edits_to_lab_if_needed(self, lab): posData = self.data[self.pos_i] data_frame_i = posData.allData_li[posData.frame_i] - edited_lab_dict = data_frame_i["manually_edited_lab"]["lab"] + edited_lab_dict = data_frame_i['manually_edited_lab']['lab'] if not edited_lab_dict: return lab - + # zoom_slice = data_frame_i['manually_edited_lab']['zoom_slice'] for z, lab_edited in edited_lab_dict.items(): if not self.isSegm3D: # lab[zoom_slice] = lab_edited lab = lab_edited break - + lab[z] = lab_edited - + # lab[z, zoom_slice[0], zoom_slice[1]] = zoom_lab - + return lab - def assignNewIDfromClickedID(self, clickedID: int, event: QGraphicsSceneMouseEvent): + def assignNewIDfromClickedID( + self, clickedID: int, event: QGraphicsSceneMouseEvent + ): posData = self.data[self.pos_i] x, y = event.pos().x(), event.pos().y() newID = self.setBrushID(return_val=True) @@ -199,20 +186,21 @@ def assignNewIDfromClickedID(self, clickedID: int, event: QGraphicsSceneMouseEve self.applyEditID(clickedID, posData.IDs.copy(), mapper, x, y) def changeIDfutureFrames( - self, endFrame_i, oldIDnewIDMapper, includeUnvisited, shift=False - ): + self, endFrame_i, oldIDnewIDMapper, includeUnvisited, + shift=False + ): posData = self.data[self.pos_i] self.current_frame_i = posData.frame_i - + # Store data for current frame self.store_data() if endFrame_i is None: self.app.restoreOverrideCursor() return - + segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i + 1, segmSizeT): - lab = posData.allData_li[i]["labels"] + for i in range(posData.frame_i+1, segmSizeT): + lab = posData.allData_li[i]['labels'] if lab is None and not includeUnvisited: self.enqAutosave() break @@ -225,7 +213,7 @@ def changeIDfutureFrames( lab = self.get_2Dlab(posData.lab) else: lab = posData.lab - + if self.onlyTracking: self.tracking(enforce=True) elif not posData.IDs: @@ -234,19 +222,19 @@ def changeIDfutureFrames( maxID = max(posData.IDs, default=0) + 1 for old_ID, new_ID in oldIDnewIDMapper: if new_ID in lab: - tempID = maxID + 1 # lab.max() + 1 + tempID = maxID + 1 # lab.max() + 1 lab[lab == old_ID] = tempID lab[lab == new_ID] = old_ID lab[lab == tempID] = new_ID maxID += 1 else: lab[lab == old_ID] = new_ID - + if shift and self.isSegm3D: self.set_2Dlab(lab) - + self.update_rp(draw=False) - self.store_data(autosave=i == endFrame_i) + self.store_data(autosave=i==endFrame_i) elif includeUnvisited: # Unvisited frame (includeUnvisited = True) lab = posData.segm_data[i] @@ -254,7 +242,7 @@ def changeIDfutureFrames( lab = self.get_2Dlab(lab) else: lab = lab - + for old_ID, new_ID in oldIDnewIDMapper: if new_ID in lab: tempID = lab.max() + 1 @@ -263,10 +251,10 @@ def changeIDfutureFrames( lab[lab == tempID] = new_ID else: lab[lab == old_ID] = new_ID - + if shift and self.isSegm3D: posData.segm_data[i][self.z_lab()] = lab - + # Back to current frame posData.frame_i = self.current_frame_i self.get_data() @@ -277,7 +265,9 @@ def delBorderObj(self, checked): self.storeUndoRedoStates(False) posData = self.data[self.pos_i] - posData.lab = skimage.segmentation.clear_border(posData.lab, buffer_size=1) + posData.lab = skimage.segmentation.clear_border( + posData.lab, buffer_size=1 + ) oldIDs = posData.IDs.copy() self.update_rp() removedIDs = [ID for ID in oldIDs if ID not in posData.IDs] @@ -292,11 +282,11 @@ def delNewObj(self, checked): posData = self.data[self.pos_i] frame_i = posData.frame_i - + if frame_i == 0: return - - prev_IDs = posData.allData_li[frame_i - 1]["IDs"] + + prev_IDs = posData.allData_li[frame_i-1]['IDs'] curr_IDs = posData.IDs new_IDs = list(set(curr_IDs) - set(prev_IDs)) @@ -304,15 +294,17 @@ def delNewObj(self, checked): del_mask = np.isin(lab, new_IDs) lab[del_mask] = 0 posData.lab = lab - + self.update_rp() - + if posData.cca_df is not None: posData.cca_df = posData.cca_df.drop(index=new_IDs) self.store_data() self.updateAllImages() - def deleteIDFromLab(self, lab, delID, frame_i=None, delMask=None, shift=False): + def deleteIDFromLab( + self, lab, delID, frame_i=None, delMask=None, shift=False + ): posData = self.data[self.pos_i] frame_i = posData.frame_i if frame_i is None else frame_i @@ -325,26 +317,26 @@ def deleteIDFromLab(self, lab, delID, frame_i=None, delMask=None, shift=False): rp = skimage.measure.regionprops(lab) IDs_idxs = {obj.label: idx for idx, obj in enumerate(rp)} else: - if frame_i == posData.frame_i: + if frame_i==posData.frame_i: rp = posData.rp IDs_idxs = posData.IDs_idxs else: - rp = posData.allData_li[frame_i]["regionprops"] - IDs_idxs = posData.allData_li[frame_i]["IDs_idxs"] + rp = posData.allData_li[frame_i]['regionprops'] + IDs_idxs = posData.allData_li[frame_i]['IDs_idxs'] if isinstance(delID, int): delID = [delID] - + is_any_id_present = False for _delID in delID: if _delID in IDs_idxs: is_any_id_present = True break - + if not is_any_id_present: return lab, delMask - if delMask is None: + if delMask is None: delMask = np.zeros(lab.shape, dtype=bool) else: delMask[:] = False @@ -356,20 +348,20 @@ def deleteIDFromLab(self, lab, delID, frame_i=None, delMask=None, shift=False): obj = rp[idx] delMask[obj.slice][obj.image] = True lab[delMask] = 0 - + if shift and self.isSegm3D: self.set_2Dlab(lab, lab3D=lab3D) lab = lab3D if delMask3D is not None: self.set_2Dlab(delMask, lab3D=delMask3D) delMask = delMask3D - + return lab, delMask - @disableWindow def deleteIDmiddleClick( - self, delIDs: Iterable, applyFutFrames, includeUnvisited, shift=False - ): + self, delIDs: Iterable, applyFutFrames, includeUnvisited, + shift=False + ): self.clearHighlightedID() posData = self.data[self.pos_i] @@ -381,8 +373,8 @@ def deleteIDmiddleClick( # Store current data before going to future frames self.store_data() segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i + 1, segmSizeT): - lab = posData.allData_li[i]["labels"] + for i in range(posData.frame_i+1, segmSizeT): + lab = posData.allData_li[i]['labels'] if lab is None and not includeUnvisited: self.enqAutosave() break @@ -394,7 +386,7 @@ def deleteIDmiddleClick( ) # Store change - posData.allData_li[i]["labels"] = lab + posData.allData_li[i]['labels'] = lab # Get the rest of the stored metadata based on the new lab posData.frame_i = i self.get_data() @@ -409,44 +401,45 @@ def deleteIDmiddleClick( # Back to current frame if applyFutFrames: posData.frame_i = current_frame_i - self.get_data() + self.get_data() z_slice = None if shift and self.isSegm3D: z_slice = self.z_lab() - - posData.lab, delID_mask = self.deleteIDFromLab(posData.lab, delIDs, shift=shift) + + posData.lab, delID_mask = self.deleteIDFromLab( + posData.lab, delIDs, shift=shift + ) for _delID in delIDs: - self.clearObjContour(ID=_delID, ax=0) - self.clearObjContour(ID=_delID, ax=1) + self.clearObjContour(ID=_delID, ax=0) + self.clearObjContour(ID=_delID, ax=1) if z_slice is None: - self.removeObjectFromRp(_delID) - self.removeStoredContours(_delID, z_slice=z_slice) - + self.removeObjectFromRp(_delID) + self.removeStoredContours(_delID, z_slice=z_slice) + if shift and self.isSegm3D: self.update_rp() self.store_data(autosave=False) - self.whitelistPropagateIDs( - IDs_to_remove=delIDs, curr_frame_only=(not applyFutFrames) - ) + self.whitelistPropagateIDs(IDs_to_remove=delIDs, curr_frame_only=(not applyFutFrames)) return delID_mask - def getClickedID(self, xdata, ydata, text=""): + def getClickedID(self, xdata, ydata, text=''): posData = self.data[self.pos_i] ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - msg = f"You clicked on the background.\nEnter here the ID {text}" + msg = ( + 'You clicked on the background.\n' + f'Enter here the ID {text}' + ) nearest_ID = core.nearest_nonzero_2D( self.get_2Dlab(posData.lab), xdata, ydata ) clickedBkgrID = apps.QLineEditDialog( - title="Clicked on background", - msg=msg, - parent=self, - allowedValues=posData.IDs, + title='Clicked on background', + msg=msg, parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True, + isInteger=True ) clickedBkgrID.exec_() if clickedBkgrID.cancel: @@ -456,9 +449,9 @@ def getClickedID(self, xdata, ydata, text=""): return ID def getHoverID(self, xdata, ydata, byPassShiftCheck=False): - if not hasattr(self, "diskMask"): + if not hasattr(self, 'diskMask'): return 0 - + modifiers = QGuiApplication.keyboardModifiers() ctrl = modifiers == Qt.ControlModifier if byPassShiftCheck: @@ -467,7 +460,7 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): shift = modifiers == Qt.ShiftModifier if self.isPowerBrush() and not ctrl: - return 0 + return 0 if not self.autoIDcheckbox.isChecked(): return self.editIDspinbox.value() @@ -482,36 +475,36 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): SizeZ = posData.lab.shape[0] doNotLinkThroughZ = self.brushButton.isChecked() and shift if doNotLinkThroughZ: - if self.brushHoverCenterModeAction.isChecked() or ID > 0: + if self.brushHoverCenterModeAction.isChecked() or ID>0: hoverID = ID else: masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] hoverID = np.bincount(masked_lab).argmax() else: if z > 0: - ID_z_under = posData.lab[z - 1, ydata, xdata] - if self.brushHoverCenterModeAction.isChecked() or ID_z_under > 0: + ID_z_under = posData.lab[z-1, ydata, xdata] + if self.brushHoverCenterModeAction.isChecked() or ID_z_under>0: hoverIDa = ID_z_under else: lab = posData.lab - masked_lab_a = lab[z - 1, ymin:ymax, xmin:xmax][diskMask] + masked_lab_a = lab[z-1, ymin:ymax, xmin:xmax][diskMask] hoverIDa = np.bincount(masked_lab_a).argmax() else: hoverIDa = 0 - if self.brushHoverCenterModeAction.isChecked() or ID > 0: + if self.brushHoverCenterModeAction.isChecked() or ID>0: hoverIDb = lab_2D[ydata, xdata] else: masked_lab_b = lab_2D[ymin:ymax, xmin:xmax][diskMask] hoverIDb = np.bincount(masked_lab_b).argmax() - if z < SizeZ - 1: - ID_z_above = posData.lab[z + 1, ydata, xdata] - if self.brushHoverCenterModeAction.isChecked() or ID_z_above > 0: + if z < SizeZ-1: + ID_z_above = posData.lab[z+1, ydata, xdata] + if self.brushHoverCenterModeAction.isChecked() or ID_z_above>0: hoverIDc = ID_z_above else: lab = posData.lab - masked_lab_c = lab[z + 1, ymin:ymax, xmin:xmax][diskMask] + masked_lab_c = lab[z+1, ymin:ymax, xmin:xmax][diskMask] hoverIDc = np.bincount(masked_lab_c).argmax() else: hoverIDc = 0 @@ -530,12 +523,12 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): if self.brushButton.isChecked() and shift: # Force new ID with brush and Shift hoverID = 0 - elif self.brushHoverCenterModeAction.isChecked() or ID > 0: + elif self.brushHoverCenterModeAction.isChecked() or ID>0: hoverID = ID else: masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] hoverID = np.bincount(masked_lab).argmax() - + self.editIDspinbox.setValue(hoverID) return hoverID @@ -543,7 +536,7 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): def getLastHoveredID(self): if self.xHoverImg is None: return 0 - + xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) ID = self.currentLab2D[ydata, xdata] return ID @@ -551,10 +544,10 @@ def getLastHoveredID(self): def get_zslices_rp(self): if not self.isSegm3D: return - + posData = self.data[self.pos_i] self.store_zslices_rp() - posData.zSlicesRp = posData.allData_li[posData.frame_i]["z_slices_rp"] + posData.zSlicesRp = posData.allData_li[posData.frame_i]['z_slices_rp'] def isPowerBrush(self): color = self.brushButton.palette().button().color().name() @@ -568,14 +561,6 @@ def isPowerEraser(self): color = self.eraserButton.palette().button().color().name() return color == self.doublePressKeyButtonColor - def is_power_button_color( - self, - *, - button_color: str, - power_color: str, - ) -> bool: - return button_color == power_color - def mergeObjs_cb(self, checked): if not checked: self.mergeObjsTempLine.setData([], []) @@ -593,67 +578,59 @@ def removeObjectFromRp(self, delID): IDs.append(obj.label) IDs_idxs[obj.label] = idx idx += 1 - + posData.rp = rp posData.IDs = IDs posData.IDs_idxs = IDs_idxs - + if not self.isSegm3D: return - + zSlicesRp = {} for z, zSliceRp in posData.zSlicesRp.items(): if delID in zSliceRp: continue - + zSlicesRp[z] = zSlicesRp - + posData.zSlicesRp = zSlicesRp self.store_zslices_rp(force_update=True) def removeStoredContours(self, delID, frame_i=None, z_slice=None): posData = self.data[self.pos_i] - + if frame_i is None: frame_i = posData.frame_i - + dataDict = posData.allData_li[posData.frame_i] try: newContours = {} - for key, contours in dataDict["contours"].items(): + for key, contours in dataDict['contours'].items(): ID = key[0] if ID == delID: continue - + if z_slice is not None: z_slice_i = key[1] if z_slice_i != z_slice: continue - + newContours[key] = contours - - dataDict["contours"] = newContours - except KeyError: + + dataDict['contours'] = newContours + except KeyError as err: pass def setHoverToolSymbolColor( - self, - xdata, - ydata, - pen, - ScatterItems, - button, - brush=None, - hoverRGB=None, - ID=None, - byPassShiftCheck=False, - ): + self, xdata, ydata, pen, ScatterItems, button, + brush=None, hoverRGB=None, ID=None, byPassShiftCheck=False + ): modifiers = QGuiApplication.keyboardModifiers() if byPassShiftCheck: shift = False else: shift = modifiers == Qt.ShiftModifier - + posData = self.data[self.pos_i] Y, X = self.get_2Dlab(posData.lab).shape if not myutils.is_in_bounds(xdata, ydata, X, Y): @@ -661,10 +638,12 @@ def setHoverToolSymbolColor( self.isHoverZneighID = False if ID is None: - hoverID = self.getHoverID(xdata, ydata, byPassShiftCheck=byPassShiftCheck) + hoverID = self.getHoverID( + xdata, ydata, byPassShiftCheck=byPassShiftCheck + ) else: hoverID = ID - + if hoverID == 0: for item in ScatterItems: item.setPen(pen) @@ -673,122 +652,53 @@ def setHoverToolSymbolColor( try: rgb = self.lut[hoverID] rgb = rgb if hoverRGB is None else hoverRGB - rgbPen = np.clip(rgb * 1.1, 0, 255) + rgbPen = np.clip(rgb*1.1, 0, 255) for item in ScatterItems: item.setPen(*rgbPen, width=2) item.setBrush(*rgb, 100) except IndexError: pass - + checkChangeID = ( - self.isHoverZneighID and not shift and self.lastHoverID != hoverID + self.isHoverZneighID and not shift + and self.lastHoverID != hoverID ) if checkChangeID: # We are hovering an ID in z+1 or z-1 self.restoreBrushID = hoverID # self.changeBrushID() - + self.lastHoverID = hoverID - def should_apply_manual_edits(self, edited_labels_by_z) -> bool: - return bool(edited_labels_by_z) - - def should_force_new_hover_id( - self, - *, - brush_active: bool, - shift_pressed: bool, - ) -> bool: - return brush_active and shift_pressed - - def should_prompt_for_background_id(self, clicked_id: int) -> bool: - return clicked_id == 0 - - def should_restore_brush_id_from_hover( - self, - *, - is_hover_z_neighbor: bool, - shift_pressed: bool, - last_hover_id: int, - hover_id: int, - ) -> bool: - return is_hover_z_neighbor and not shift_pressed and last_hover_id != hover_id - - LEGACY_METHODS = ( - "mergeObjs_cb", - "assignNewIDfromClickedID", - "addYXcentroidToDf", - "_get_editID_info", - "apply_manual_edits_to_lab_if_needed", - "store_zslices_rp", - "removeObjectFromRp", - "get_zslices_rp", - "_update_zslices_rp", - "update_rp", - "delBorderObj", - "delNewObj", - "getClickedID", - "deleteIDFromLab", - "removeStoredContours", - "deleteIDmiddleClick", - "applyEditID", - "changeIDfutureFrames", - "getLastHoveredID", - "getHoverID", - "setHoverToolSymbolColor", - "isPowerBrush", - "isPowerEraser", - "isPowerButton", - ) - - def should_store_zslice_regionprops(self, *, is_segm_3d: bool) -> bool: - return is_segm_3d - - def should_update_zslice_regionprops( - self, - *, - force_update: bool, - already_stored: bool, - ) -> bool: - return force_update or not already_stored - def store_zslices_rp(self, force_update=False): if not self.isSegm3D: return - - posData = self.data[self.pos_i] + + posData = self.data[self.pos_i] are_zslices_rp_stored = ( - posData.allData_li[posData.frame_i].get("z_slices_rp") is not None + posData.allData_li[posData.frame_i].get('z_slices_rp') is not None ) if force_update or not are_zslices_rp_stored: self._update_zslices_rp() + + posData.allData_li[posData.frame_i]['z_slices_rp'] = posData.zSlicesRp - posData.allData_li[posData.frame_i]["z_slices_rp"] = posData.zSlicesRp - - @exception_handler def update_rp( - self, - draw=True, - debug=False, - update_IDs=True, - wl_update=True, - wl_track_og_curr=False, - wl_update_lab=False, - ): + self, draw=True, debug=False, update_IDs=True, + wl_update=True, wl_track_og_curr=False,wl_update_lab=False + ): posData = self.data[self.pos_i] # Update rp for current posData.lab (e.g. after any change) if wl_update: if self.whitelistOriginalIDs is None: - old_IDs = posData.allData_li[posData.frame_i][ - "IDs" - ].copy() # for whitelist stuff + old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() # for whitelist stuff else: old_IDs = self.whitelistOriginalIDs.copy() self.whitelistOriginalIDs = None elif self.whitelistOriginalIDs is None: - self.whitelist_old_IDs = posData.allData_li[posData.frame_i]["IDs"].copy() + self.whitelist_old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() posData.rp = skimage.measure.regionprops(posData.lab) if update_IDs: @@ -799,7 +709,7 @@ def update_rp( IDs_idxs[obj.label] = idx posData.IDs = IDs posData.IDs_idxs = IDs_idxs - self.update_rp_metadata(draw=draw) + self.update_rp_metadata(draw=draw) self.store_zslices_rp(force_update=True) if not wl_update: @@ -809,15 +719,16 @@ def update_rp( accepted_lost_centroids = self.getTrackedLostIDs() new_IDs = posData.IDs added_IDs = set(new_IDs) - set(old_IDs) - removed_IDs = set(old_IDs) - set(new_IDs) - set(accepted_lost_centroids) + removed_IDs = ( + set(old_IDs) + - set(new_IDs) + - set(accepted_lost_centroids) + ) self.whitelistPropagateIDs( - IDs_to_add=added_IDs, - IDs_to_remove=removed_IDs, - curr_frame_only=True, - IDs_curr=new_IDs, + IDs_to_add=added_IDs, IDs_to_remove=removed_IDs, + curr_frame_only=True, IDs_curr=new_IDs, track_og_curr=wl_track_og_curr, - curr_lab=posData.lab, - curr_rp=posData.rp, - update_lab=wl_update_lab, + curr_lab=posData.lab, curr_rp=posData.rp, + update_lab=wl_update_lab ) diff --git a/cellacdc/mixins_bak/label_roi.py b/cellacdc/mixins/label_roi.py similarity index 68% rename from cellacdc/mixins_bak/label_roi.py rename to cellacdc/mixins/label_roi.py index aed620d4e..285c38568 100644 --- a/cellacdc/mixins_bak/label_roi.py +++ b/cellacdc/mixins/label_roi.py @@ -19,50 +19,24 @@ ) -class LabelRoiMixin: - """Qt-facing adapter around Magic Labeller ROI workflows.""" - - """Headless decisions for Magic Labeller ROI workflows.""" - - yes_value = "Yes" - no_value = "No" - - def checked_from_setting_value(self, value) -> bool: - return value == self.yes_value - - def checked_setting_value(self, checked: bool) -> str: - return self.yes_value if checked else self.no_value - - def cursor_points(self, x, y, checked: bool): - if not checked: - return [], [] - return [x], [y] - - def frame_range_length( - self, - enabled: bool, - start_frame_index: int, - stop_frame_number: int, - ) -> int: - if not enabled: - return 1 - return stop_frame_number - start_frame_index +class LabelRoi: + """Extracted from guiWin.""" def getLabelRoiImage(self): posData = self.data[self.pos_i] if self.labelRoiTrangeCheckbox.isChecked(): - start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 + start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() - tRangeLen = stop_frame_n - start_frame_i + tRangeLen = stop_frame_n-start_frame_i else: tRangeLen = 1 - + if tRangeLen > 1: tRange = (start_frame_i, stop_frame_n) else: tRange = None - + if self.isSegm3D: if tRangeLen > 1: imgData = posData.img_data @@ -78,22 +52,26 @@ def getLabelRoiImage(self): z0 = self.zSliceScrollBar.sliderPosition() z1 = z0 + 1 else: - if roi_zdepth % 2 != 0: - roi_zdepth += 1 - half_zdepth = int(roi_zdepth / 2) + if roi_zdepth%2 != 0: + roi_zdepth +=1 + half_zdepth = int(roi_zdepth/2) zc = self.zSliceScrollBar.sliderPosition() + 1 - z0 = zc - half_zdepth - z0 = z0 if z0 >= 0 else 0 - z1 = zc + half_zdepth - z1 = z1 if z1 < posData.SizeZ else posData.SizeZ + z0 = zc-half_zdepth + z0 = z0 if z0>=0 else 0 + z1 = zc+half_zdepth + z1 = z1 if z1 1: # fluo_img_data = fluo_data[start_frame_i:stop_frame_n] if self.isSegm3D or posData.SizeZ == 1: @@ -173,7 +151,7 @@ def getSecondChannelData(self): fluo_img_data = fluo_data[posData.frame_i] else: fluo_img_data = fluo_data - + if self.isSegm3D or posData.SizeZ == 1: return fluo_img_data else: @@ -186,13 +164,13 @@ def indexRoiLab(self, roiLab, roiLabSlice, lab, brushID): mask[..., 1:-1, 1:-1] = True roiLab = skimage.segmentation.clear_border(roiLab, mask=mask) - roiLabMask = roiLab > 0 - roiLab[roiLabMask] += brushID - 1 + roiLabMask = roiLab>0 + roiLab[roiLabMask] += (brushID-1) if self.labelRoiReplaceExistingObjectsCheckbox.isChecked(): IDs_touched_by_new_objects = np.unique(lab[roiLabSlice][roiLabMask]) for ID in IDs_touched_by_new_objects: - lab[lab == ID] = 0 - + lab[lab==ID] = 0 + lab[roiLabSlice][roiLabMask] = roiLab[roiLabMask] return lab @@ -202,13 +180,14 @@ def initLabelRoiModel(self): self.initLabelRoiModelDialog = apps.QDialogSelectModel(parent=self) self.initLabelRoiModelDialog.exec_() if self.initLabelRoiModelDialog.cancel: - self.logger.info("Magic labeller aborted.") + self.logger.info('Magic labeller aborted.') self.initLabelRoiModelDialog = None return True self.app.setOverrideCursor(Qt.WaitCursor) model_name = self.initLabelRoiModelDialog.selectedModel self.labelRoiModel = self.repeatSegm( - model_name=model_name, askSegmParams=True, is_label_roi=True + model_name=model_name, askSegmParams=True, + is_label_roi=True ) if self.labelRoiModel is None: self.initLabelRoiModelDialog = None @@ -217,33 +196,26 @@ def initLabelRoiModel(self): self.initLabelRoiModelDialog = None return False - def is_frame_range_valid( - self, - enabled: bool, - start_frame_number: int, - stop_frame_number: int, - ) -> bool: - return not enabled or start_frame_number <= stop_frame_number - def labelRoiCancelled(self): self.labelRoiRunning = False - self.app.restoreOverrideCursor() - self.labelRoiItem.setPos((0, 0)) - self.labelRoiItem.setSize((0, 0)) + self.app.restoreOverrideCursor() + self.labelRoiItem.setPos((0,0)) + self.labelRoiItem.setSize((0,0)) self.freeRoiItem.clear() - self.logger.info("Magic labeller process cancelled.") + self.logger.info('Magic labeller process cancelled.') def labelRoiCheckStartStopFrame(self): if not self.labelRoiTrangeCheckbox.isChecked(): return True - + start_n = self.labelRoiStartFrameNoSpinbox.value() stop_n = self.labelRoiStopFrameNoSpinbox.value() if start_n <= stop_n: return True - + self.blinker = qutils.QControlBlink( - self.labelRoiStopFrameNoSpinbox, qparent=self + self.labelRoiStopFrameNoSpinbox, + qparent=self ) self.blinker.start() msg = widgets.myMessageBox() @@ -252,22 +224,19 @@ def labelRoiCheckStartStopFrame(self): What do you want to do? """) msg.warning( - self, - "Stop frame number lower than start", - txt, - buttonsTexts=("Cancel", "Segment only current frame"), + self, 'Stop frame number lower than start', txt, + buttonsTexts=('Cancel', 'Segment only current frame') ) if msg.cancel: return False - + posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i + 1) - self.labelRoiStopFrameNoSpinbox.setValue(posData.frame_i + 1) + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) + self.labelRoiStopFrameNoSpinbox.setValue(posData.frame_i+1) - @exception_handler def labelRoiDone(self, roiSegmData, isTimeLapse): self.setDisabled(False) - + posData = self.data[self.pos_i] self.setBrushID() @@ -278,7 +247,7 @@ def labelRoiDone(self, roiSegmData, isTimeLapse): start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 for i, roiLab in enumerate(roiSegmData): frame_i = start_frame_i + i - lab = posData.allData_li[frame_i]["labels"] + lab = posData.allData_li[frame_i]['labels'] store = True if lab is None: if frame_i >= len(posData.segm_data): @@ -290,13 +259,15 @@ def labelRoiDone(self, roiSegmData, isTimeLapse): lab = posData.segm_data[frame_i] store = False roiLabSlice = self.labelRoiSlice[1:] - lab = self.indexRoiLab(roiLab, roiLabSlice, lab, posData.brushID) + lab = self.indexRoiLab( + roiLab, roiLabSlice, lab, posData.brushID + ) if store: posData.frame_i = frame_i - posData.allData_li[frame_i]["labels"] = lab.copy() + posData.allData_li[frame_i]['labels'] = lab.copy() self.get_data() self.store_data(autosave=False) - + # Back to current frame posData.frame_i = current_frame_i self.get_data() @@ -307,26 +278,26 @@ def labelRoiDone(self, roiSegmData, isTimeLapse): ) self.update_rp() - + # Repeat tracking if self.autoIDcheckbox.isChecked(): self.tracking(enforce=True, assign_unique_new_IDs=False) - + self.store_data() self.updateAllImages() - - self.labelRoiItem.setPos((0, 0)) - self.labelRoiItem.setSize((0, 0)) + + self.labelRoiItem.setPos((0,0)) + self.labelRoiItem.setSize((0,0)) self.freeRoiItem.clear() - self.logger.info("Magic labeller done!") - self.app.restoreOverrideCursor() + self.logger.info('Magic labeller done!') + self.app.restoreOverrideCursor() - self.labelRoiRunning = False + self.labelRoiRunning = False if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() - self.progressWin = None - + self.progressWin = None + uncheckLabelRoiTRange = ( self.labelRoiTrangeCheckbox.isChecked() and not self.labelRoiTrangeCheckbox.findChild(QAction).isChecked() @@ -336,7 +307,7 @@ def labelRoiDone(self, roiSegmData, isTimeLapse): def labelRoiFromCurrentFrameTriggered(self): posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i + 1) + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) def labelRoiToEndFramesTriggered(self): posData = self.data[self.pos_i] @@ -356,37 +327,38 @@ def labelRoiTrangeCheckboxToggled(self, checked): posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i + 1) + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) def labelRoiViewCurrentModel(self): from . import config - - ini_path = os.path.join(settings_folderpath, "last_params_segm_models.ini") + ini_path = os.path.join( + settings_folderpath, 'last_params_segm_models.ini' + ) configPars = config.ConfigParser() configPars.read(ini_path) model_name = self.labelRoiModel.model_name - txt = f"Model: {model_name}" - SECTION = f"{model_name}.init" - txt = f"{txt}

[Initialization parameters]
" + txt = f'Model: {model_name}' + SECTION = f'{model_name}.init' + txt = f'{txt}

[Initialization parameters]
' for option in configPars.options(SECTION): value = configPars[SECTION][option] - param_txt = f"{option} = {value}
" - txt = f"{txt}{param_txt}" - - SECTION = f"{model_name}.segment" - txt = f"{txt}
[Segmentation parameters]
" + param_txt = f'{option} = {value}
' + txt = f'{txt}{param_txt}' + + SECTION = f'{model_name}.segment' + txt = f'{txt}
[Segmentation parameters]
' for option in configPars.options(SECTION): value = configPars[SECTION][option] - param_txt = f"{option} = {value}
" - txt = f"{txt}{param_txt}" - + param_txt = f'{option} = {value}
' + txt = f'{txt}{param_txt}' + win = apps.ViewTextDialog(txt, parent=self) win.exec_() def labelRoiWorkerFinished(self): - self.logger.info("Magic labeller closed.") - self.labelRoiActiveWorkers.pop(-1) + self.logger.info('Magic labeller closed.') + worker = self.labelRoiActiveWorkers.pop(-1) def labelRoi_cb(self, checked): posData = self.data[self.pos_i] @@ -402,8 +374,8 @@ def labelRoi_cb(self, checked): lastActiveWorker = self.labelRoiActiveWorkers[-1] self.labelRoiGarbageWorkers.append(lastActiveWorker) lastActiveWorker.finished.emit() - self.logger.info("Collected garbage w5orker (magic labeller).") - + self.logger.info('Collected garbage w5orker (magic labeller).') + self.labelRoiToolbar.setVisible(True) if self.isSegm3D: self.labelRoiZdepthSpinbox.setDisabled(False) @@ -420,7 +392,9 @@ def labelRoi_cb(self, checked): labelRoiWorker.moveToThread(self.labelRoiThread) labelRoiWorker.finished.connect(self.labelRoiThread.quit) labelRoiWorker.finished.connect(labelRoiWorker.deleteLater) - self.labelRoiThread.finished.connect(self.labelRoiThread.deleteLater) + self.labelRoiThread.finished.connect( + self.labelRoiThread.deleteLater + ) labelRoiWorker.finished.connect(self.labelRoiWorkerFinished) labelRoiWorker.sigLabellingDone.connect(self.labelRoiDone) @@ -437,114 +411,61 @@ def labelRoi_cb(self, checked): # Add the rectROI to ax1 self.ax1.addItem(self.labelRoiItem) elif self.initLabelRoiModelDialog is not None: - # User is using other tools while the dialog is still open - # --> we allow this because it's useful to be able to use + # User is using other tools while the dialog is still open + # --> we allow this because it's useful to be able to use # the ruler or check things --> do nothing pass else: self.labelRoiToolbar.setVisible(False) - + for worker in self.labelRoiActiveWorkers: worker._stop() while self.app.overrideCursor() is not None: self.app.restoreOverrideCursor() - - self.labelRoiItem.setPos((0, 0)) - self.labelRoiItem.setSize((0, 0)) + + self.labelRoiItem.setPos((0,0)) + self.labelRoiItem.setSize((0,0)) self.freeRoiItem.clear() self.ax1.removeItem(self.labelRoiItem) self.updateLabelRoiCircularCursor(None, None, False) def loadLabelRoiLastParams(self): - idx = "labelRoi_checkedRoiType" + idx = 'labelRoi_checkedRoiType' if idx in self.df_settings.index: - checkedRoiType = self.df_settings.at[idx, "value"] + checkedRoiType = self.df_settings.at[idx, 'value'] for button in self.labelRoiTypesGroup.buttons(): if button.text() == checkedRoiType: button.setChecked(True) break - - idx = "labelRoi_circRoiRadius" + + idx = 'labelRoi_circRoiRadius' if idx in self.df_settings.index: - circRoiRadius = self.df_settings.at[idx, "value"] + circRoiRadius = self.df_settings.at[idx, 'value'] self.labelRoiCircularRadiusSpinbox.setValue(int(circRoiRadius)) - - idx = "labelRoi_roiZdepth" + + idx = 'labelRoi_roiZdepth' if idx in self.df_settings.index: - roiZdepth = self.df_settings.at[idx, "value"] + roiZdepth = self.df_settings.at[idx, 'value'] self.labelRoiZdepthSpinbox.setValue(int(roiZdepth)) - - idx = "labelRoi_autoClearBorder" + + idx = 'labelRoi_autoClearBorder' if idx in self.df_settings.index: - clearBorder = self.df_settings.at[idx, "value"] - checked = clearBorder == "Yes" + clearBorder = self.df_settings.at[idx, 'value'] + checked = clearBorder == 'Yes' self.labelRoiAutoClearBorderCheckbox.setChecked(checked) - - idx = "labelRoi_replaceExistingObjects" + + idx = 'labelRoi_replaceExistingObjects' if idx in self.df_settings.index: - val = self.df_settings.at[idx, "value"] - checked = val == "Yes" + val = self.df_settings.at[idx, 'value'] + checked = val == 'Yes' self.labelRoiReplaceExistingObjectsCheckbox.setChecked(checked) - + if self.labelRoiIsCircularRadioButton.isChecked(): self.labelRoiCircularRadiusSpinbox.setDisabled(False) - def model_params_ini_path(self, settings_folderpath: str) -> str: - return os.path.join(settings_folderpath, "last_params_segm_models.ini") - - def params_settings( - self, - *, - checked_roi_type: str, - circ_roi_radius: int, - roi_zdepth: int, - auto_clear_border: bool, - replace_existing_objects: bool, - ) -> LabelRoiParamsSettings: - return LabelRoiParamsSettings( - updates={ - "labelRoi_checkedRoiType": checked_roi_type, - "labelRoi_circRoiRadius": circ_roi_radius, - "labelRoi_roiZdepth": roi_zdepth, - "labelRoi_autoClearBorder": self.checked_setting_value( - auto_clear_border - ), - "labelRoi_replaceExistingObjects": ( - self.checked_setting_value(replace_existing_objects) - ), - } - ) - - def should_enable_range_controls(self, checked: bool) -> bool: - return checked - - def should_show_circular_cursor( - self, - *, - label_roi_checked: bool, - circular_roi_checked: bool, - label_roi_running: bool, - cursor_checked: bool, - existing_cursor_empty: bool, - ) -> bool: - return ( - label_roi_checked - and circular_roi_checked - and not label_roi_running - and (cursor_checked or not existing_cursor_empty) - ) - - def should_uncheck_time_range( - self, - *, - time_range_checked: bool, - persistent_action_checked: bool, - ) -> bool: - return time_range_checked and not persistent_action_checked - def showLabelRoiContextMenu(self, event): menu = QMenu(self.labelRoiButton) - action = QAction("Re-initialize magic labeller model...") + action = QAction('Re-initialize magic labeller model...') action.triggered.connect(self.initLabelRoiModel) menu.addAction(action) menu.exec_(QCursor.pos()) @@ -554,33 +475,17 @@ def storeLabelRoiParams(self, value=None, checked=True): circRoiRadius = self.labelRoiCircularRadiusSpinbox.value() roiZdepth = self.labelRoiZdepthSpinbox.value() autoClearBorder = self.labelRoiAutoClearBorderCheckbox.isChecked() - clearBorder = "Yes" if autoClearBorder else "No" - self.df_settings.at["labelRoi_checkedRoiType", "value"] = checkedRoiType - self.df_settings.at["labelRoi_circRoiRadius", "value"] = circRoiRadius - self.df_settings.at["labelRoi_roiZdepth", "value"] = roiZdepth - self.df_settings.at["labelRoi_autoClearBorder", "value"] = clearBorder - self.df_settings.at["labelRoi_replaceExistingObjects", "value"] = ( - "Yes" if self.labelRoiReplaceExistingObjectsCheckbox.isChecked() else "No" + clearBorder = 'Yes' if autoClearBorder else 'No' + self.df_settings.at['labelRoi_checkedRoiType', 'value'] = checkedRoiType + self.df_settings.at['labelRoi_circRoiRadius', 'value'] = circRoiRadius + self.df_settings.at['labelRoi_roiZdepth', 'value'] = roiZdepth + self.df_settings.at['labelRoi_autoClearBorder', 'value'] = clearBorder + self.df_settings.at['labelRoi_replaceExistingObjects', 'value'] = ( + 'Yes' if self.labelRoiReplaceExistingObjectsCheckbox.isChecked() + else 'No' ) self.df_settings.to_csv(self.settings_csv_path) - def time_range( - self, - enabled: bool, - start_frame_index: int, - stop_frame_number: int, - ): - if ( - self.frame_range_length( - enabled, - start_frame_index, - stop_frame_number, - ) - > 1 - ): - return start_frame_index, stop_frame_number - return None - def updateLabelRoiCircularCursor(self, x, y, checked): if not self.labelRoiButton.isChecked(): return @@ -594,7 +499,7 @@ def updateLabelRoiCircularCursor(self, x, y, checked): xx, yy = [], [] else: xx, yy = [x], [y] - + if not xx and len(self.labelRoiCircItemLeft.getData()[0]) == 0: return @@ -604,22 +509,3 @@ def updateLabelRoiCircularCursor(self, x, y, checked): def updateLabelRoiCircularSize(self, value): self.labelRoiCircItemLeft.setSize(value) self.labelRoiCircItemRight.setSize(value) - - def z_range( - self, - roi_zdepth: int, - size_z: int, - current_z_index: int, - ) -> tuple[int, int]: - if roi_zdepth == size_z: - return 0, size_z - if roi_zdepth == 1: - return current_z_index, current_z_index + 1 - - if roi_zdepth % 2 != 0: - roi_zdepth += 1 - half_zdepth = int(roi_zdepth / 2) - zc = current_z_index + 1 - z0 = max(zc - half_zdepth, 0) - z1 = min(zc + half_zdepth, size_z) - return z0, z1 diff --git a/cellacdc/mixins/label_transform_tools.py b/cellacdc/mixins/label_transform_tools.py new file mode 100644 index 000000000..0b58f3e9e --- /dev/null +++ b/cellacdc/mixins/label_transform_tools.py @@ -0,0 +1,229 @@ +"""View adapter for label transform tools.""" + +from __future__ import annotations + +import skimage.measure + + +class LabelTransformTools: + """Extracted from guiWin.""" + + def expandLabel(self, dilation=True): + posData = self.data[self.pos_i] + if self.hoverLabelID == 0: + self.isExpandingLabel = False + return + + # Re-initialize label to expand when we hover on a different ID + # or we change direction + reinitExpandingLab = ( + self.expandingID != self.hoverLabelID + or dilation != self.isDilation + ) + + ID = self.hoverLabelID + + obj = posData.rp[posData.IDs.index(ID)] + + if reinitExpandingLab: + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + # hoverLabelID different from previously expanded ID --> reinit + self.isExpandingLabel = True + self.expandingID = ID + self.expandingLab = np.zeros_like(self.currentLab2D) + self.expandingLab[obj.coords[:,-2], obj.coords[:,-1]] = ID + self.expandFootprintSize = 1 + + prevCoords = (obj.coords[:,-2], obj.coords[:,-1]) + self.currentLab2D[obj.coords[:,-2], obj.coords[:,-1]] = 0 + lab_2D = self.get_2Dlab(posData.lab) + lab_2D[obj.coords[:,-2], obj.coords[:,-1]] = 0 + + footprint = skimage.morphology.disk(self.expandFootprintSize) + if dilation: + expandedLab = skimage.morphology.dilation( + self.expandingLab, footprint + ) + self.isDilation = True + else: + expandedLab = skimage.morphology.erosion( + self.expandingLab, footprint + ) + self.isDilation = False + + # Prevent expanding into neighbouring labels + expandedLab[self.currentLab2D>0] = 0 + + # Get coords of the dilated/eroded object + expandedObj = skimage.measure.regionprops(expandedLab)[0] + expandedObjCoords = (expandedObj.coords[:,-2], expandedObj.coords[:,-1]) + + # Add the dilated/erored object + self.currentLab2D[expandedObjCoords] = self.expandingID + lab_2D[expandedObjCoords] = self.expandingID + + self.set_2Dlab(lab_2D) + self.currentLab2D = lab_2D + + self.update_rp() + + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.img2.setImage(img=self.currentLab2D, autoLevels=False) + + self.setTempImgExpandLabel(prevCoords, expandedObjCoords) + + def expandLabelCallback(self, checked): + if checked: + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.sender()) + self.connectLeftClickButtons() + self.expandFootprintSize = 1 + else: + self.clearHighlightedID() + alpha = self.imgGrad.labelsAlphaSlider.value() + self.labelsLayerImg1.setOpacity(alpha) + self.labelsLayerRightImg.setOpacity(alpha) + self.hoverLabelID = 0 + self.expandingID = 0 + self.updateAllImages() + + def _setTempImgExpandLabelContours(self, prevCoords, ax=0): + self.contoursImage[prevCoords] = [0,0,0,0] + currentLab2Drp = skimage.measure.regionprops(self.currentLab2D) + for obj in currentLab2Drp: + if obj.label == self.expandingID: + # self.clearObjContour(obj=obj, ax=ax) + self.addObjContourToContoursImage(obj=obj, ax=ax, force=True) + break + + def _setTempImgExpandLabelSegmMasks(self, prevCoords, ax=0): + # Remove previous overlaid mask + labelsImage = self.getLabelsLayerImage(ax=ax) + labelsImage[prevCoords] = 0 + + # Overlay new moved mask + labelsImage[prevCoords] = self.expandingID + + if ax == 0: + self.labelsLayerImg1.setImage( + self.labelsLayerImg1.image, autoLevels=False) + else: + self.labelsLayerRightImg.setImage( + self.labelsLayerRightImg.image, autoLevels=False) + + def resetExpandLabel(self): + self.expandingID = -1 + + def startMovingLabel(self, xPos, yPos): + posData = self.data[self.pos_i] + xdata, ydata = int(xPos), int(yPos) + lab_2D = self.get_2Dlab(posData.lab) + ID = lab_2D[ydata, xdata] + if ID == 0: + self.isMovingLabel = False + return + + posData = self.data[self.pos_i] + self.isMovingLabel = True + + self.searchedIDitemRight.setData([], []) + self.searchedIDitemLeft.setData([], []) + self.movingID = ID + self.prevMovePos = (xdata, ydata) + movingObj = posData.rp[posData.IDs.index(ID)] + self.movingObjCoords = movingObj.coords.copy() + yy, xx = movingObj.coords[:,-2], movingObj.coords[:,-1] + self.currentLab2D[yy, xx] = 0 + + def moveLabel(self, xPos, yPos): + posData = self.data[self.pos_i] + lab_2D = self.get_2Dlab(posData.lab) + Y, X = lab_2D.shape + xdata, ydata = int(xPos), int(yPos) + if xdata<0 or ydata<0 or xdata>=X or ydata>=Y: + return + + self.clearObjContour(ID=self.movingID, ax=0) + + xStart, yStart = self.prevMovePos + deltaX = xdata-xStart + deltaY = ydata-yStart + + yy, xx = self.movingObjCoords[:,-2], self.movingObjCoords[:,-1] + + if self.isSegm3D: + zz = self.movingObjCoords[:,0] + posData.lab[zz, yy, xx] = 0 + else: + posData.lab[yy, xx] = 0 + + self.movingObjCoords[:,-2] = self.movingObjCoords[:,-2]+deltaY + self.movingObjCoords[:,-1] = self.movingObjCoords[:,-1]+deltaX + + yy, xx = self.movingObjCoords[:,-2], self.movingObjCoords[:,-1] + + yy[yy<0] = 0 + xx[xx<0] = 0 + yy[yy>=Y] = Y-1 + xx[xx>=X] = X-1 + + if self.isSegm3D: + zz = self.movingObjCoords[:,0] + posData.lab[zz, yy, xx] = self.movingID + else: + posData.lab[yy, xx] = self.movingID + + self.currentLab2D = self.get_2Dlab(posData.lab) + if self.labelsGrad.showLabelsImgAction.isChecked(): + self.img2.setImage(self.currentLab2D, autoLevels=False) + + self.setTempImg1MoveLabel() + + self.prevMovePos = (xdata, ydata) + + def setTempImgExpandLabel(self, prevCoords, expandedObjCoords, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + self._setTempImgExpandLabelContours(prevCoords, ax=ax) + + def setTempImg1MoveLabel(self, ax=0): + if ax == 0: + how = self.drawIDsContComboBox.currentText() + else: + how = self.getAnnotateHowRightImage() + + if how.find('contours') != -1: + currentLab2Drp = skimage.measure.regionprops(self.currentLab2D) + for obj in currentLab2Drp: + if obj.label == self.movingID: + self.addObjContourToContoursImage(obj=obj, ax=ax) + break + elif how.find('overlay segm. masks') != -1: + if ax == 0: + self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) + self.highLightIDLayerImg1.image[:] = 0 + mask = self.currentLab2D==self.movingID + self.highLightIDLayerImg1.image[mask] = self.movingID + highlightedImage = self.highLightIDLayerImg1.image + self.highLightIDLayerImg1.setImage(highlightedImage) + else: + self.labelsLayerRightImg.setImage( + self.currentLab2D, autoLevels=False + ) + self.highLightIDLayerRightImage.image[:] = 0 + mask = self.currentLab2D==self.movingID + self.highLightIDLayerRightImage.image[mask] = self.movingID + highlightedImage = self.highLightIDLayerRightImage.image + self.highLightIDLayerRightImage.setImage(highlightedImage) + + def moveLabelButtonToggled(self, checked): + if not checked: + self.hoverLabelID = 0 + self.highlightedID = 0 + self.highLightIDLayerImg1.clear() + self.highLightIDLayerRightImage.clear() + self.setHighlightID(False) diff --git a/cellacdc/mixins_bak/layout_controls.py b/cellacdc/mixins/layout_controls.py similarity index 72% rename from cellacdc/mixins_bak/layout_controls.py rename to cellacdc/mixins/layout_controls.py index d8e3ef542..f6ea910e4 100644 --- a/cellacdc/mixins_bak/layout_controls.py +++ b/cellacdc/mixins/layout_controls.py @@ -22,48 +22,43 @@ ) from cellacdc import myutils, widgets -from cellacdc.ui.modules.annotation.decorators import resetViewRange +from cellacdc.gui_decorators import resetViewRange -class LayoutControlsMixin: - """Qt-facing adapter around main layout and control surfaces.""" - - """Headless decisions for GUI layout controls.""" - - yes_value = "Yes" - no_value = "No" - - def checked_from_setting_value(self, value) -> bool: - return value == self.yes_value - - def checked_setting_value(self, checked: bool) -> str: - return self.yes_value if checked else self.no_value +class LayoutControls: + """Extracted from guiWin.""" def gui_createControlsToolbar(self): self.controlToolBars = [] self.addToolBarBreak() - + # Edit toolbar modeToolBar = widgets.ToolBar("Mode", self) self.addToolBar(modeToolBar) self.modeComboBox = widgets.ComboBox() self.modeComboBox.addItems(self.modeItems) - self.modeComboBoxLabel = QLabel(" Mode: ") + self.modeComboBoxLabel = QLabel(' Mode: ') self.modeComboBoxLabel.setBuddy(self.modeComboBox) modeToolBar.addWidget(self.modeComboBoxLabel) modeToolBar.addWidget(self.modeComboBox) modeToolBar.setVisible(False) - + self.modeToolBar = modeToolBar - + self.overlayToolbar = widgets.OverlayToolbar(parent=self) self.addToolBar(Qt.TopToolBarArea, self.overlayToolbar) self.overlayToolbar.setVisible(False) - self.overlayToolbar.sigSetTranspacency.connect(self.setOverlayTransparency) - self.overlayToolbar.sigSetSingleChannel.connect(self.setOverlaySingleChannel) - - self.autoPilotZoomToObjToolbar = widgets.ToolBar("Auto-zoom to objects", self) + self.overlayToolbar.sigSetTranspacency.connect( + self.setOverlayTransparency + ) + self.overlayToolbar.sigSetSingleChannel.connect( + self.setOverlaySingleChannel + ) + + self.autoPilotZoomToObjToolbar = widgets.ToolBar( + "Auto-zoom to objects", self + ) self.autoPilotZoomToObjToolbar.setContextMenuPolicy(Qt.PreventContextMenu) self.autoPilotZoomToObjToolbar.setMovable(False) self.addToolBar(Qt.TopToolBarArea, self.autoPilotZoomToObjToolbar) @@ -71,16 +66,20 @@ def gui_createControlsToolbar(self): self.autoPilotZoomToObjToolbar.setVisible(False) self.autoPilotZoomToObjToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.autoPilotZoomToObjToolbar) - + # Highlighted ID or searched ID toolbar - self.highlightIDToolbar = widgets.HighlightedIDToolbar(parent=self) + self.highlightIDToolbar = widgets.HighlightedIDToolbar( + parent=self + ) self.addToolBar(Qt.TopToolBarArea, self.highlightIDToolbar) self.highlightIDToolbar.setVisible(False) self.highlightIDToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.highlightIDToolbar) - - self.highlightIDToolbar.sigIDChanged.connect(self.setHighlighedIDfromToolbar) - + + self.highlightIDToolbar.sigIDChanged.connect( + self.setHighlighedIDfromToolbar + ) + # Widgets toolbar brushEraserToolBar = widgets.ToolBar("Widgets", self) self.addToolBar(Qt.TopToolBarArea, brushEraserToolBar) @@ -88,64 +87,69 @@ def gui_createControlsToolbar(self): self.editIDspinbox = widgets.SpinBox() # self.editIDspinbox.setMaximum(2**32-1) - editIDLabel = QLabel(" ID: ") + editIDLabel = QLabel(' ID: ') self.editIDLabelAction = brushEraserToolBar.addWidget(editIDLabel) - self.editIDspinboxAction = brushEraserToolBar.addWidget(self.editIDspinbox) + self.editIDspinboxAction = brushEraserToolBar.addWidget( + self.editIDspinbox + ) self.editIDLabelAction.setVisible(False) self.editIDspinboxAction.setVisible(False) self.editIDspinboxAction.setDisabled(True) self.editIDLabelAction.setDisabled(True) - brushEraserToolBar.addWidget(QLabel(" ")) - self.autoIDcheckbox = QCheckBox("Auto-ID") + brushEraserToolBar.addWidget(QLabel(' ')) + self.autoIDcheckbox = QCheckBox('Auto-ID') self.autoIDcheckbox.setChecked(True) self.autoIDcheckboxAction = brushEraserToolBar.addWidget(self.autoIDcheckbox) self.autoIDcheckboxAction.setVisible(False) self.brushSizeSpinbox = widgets.SpinBox( - disableKeyPress=True, allowNegative=False + disableKeyPress=True, + allowNegative=False ) self.brushSizeSpinbox.setValue(4) - brushSizeLabel = QLabel(" Size: ") + brushSizeLabel = QLabel(' Size: ') brushSizeLabel.setBuddy(self.brushSizeSpinbox) self.brushSizeLabelAction = brushEraserToolBar.addWidget(brushSizeLabel) self.brushSizeAction = brushEraserToolBar.addWidget(self.brushSizeSpinbox) self.brushSizeLabelAction.setVisible(False) self.brushSizeAction.setVisible(False) - - brushEraserToolBar.addWidget(QLabel(" ")) - self.brushAutoFillCheckbox = QCheckBox("Auto-fill holes") + + brushEraserToolBar.addWidget(QLabel(' ')) + self.brushAutoFillCheckbox = QCheckBox('Auto-fill holes') self.brushAutoFillAction = brushEraserToolBar.addWidget( self.brushAutoFillCheckbox ) self.brushAutoFillAction.setVisible(False) - if "brushAutoFill" in self.df_settings.index: - checked = self.df_settings.at["brushAutoFill", "value"] == "Yes" + if 'brushAutoFill' in self.df_settings.index: + checked = self.df_settings.at['brushAutoFill', 'value'] == 'Yes' self.brushAutoFillCheckbox.setChecked(checked) - - brushEraserToolBar.addWidget(QLabel(" ")) - self.brushAutoHideCheckbox = QCheckBox("Hide objects when hovering") + + brushEraserToolBar.addWidget(QLabel(' ')) + self.brushAutoHideCheckbox = QCheckBox('Hide objects when hovering') self.brushAutoHideAction = brushEraserToolBar.addWidget( self.brushAutoHideCheckbox ) self.brushAutoHideCheckbox.setChecked(True) self.brushAutoHideAction.setVisible(False) - if "brushAutoHide" in self.df_settings.index: - checked = self.df_settings.at["brushAutoHide", "value"] == "Yes" + if 'brushAutoHide' in self.df_settings.index: + checked = self.df_settings.at['brushAutoHide', 'value'] == 'Yes' self.brushAutoHideCheckbox.setChecked(checked) - + brushEraserToolBar.setVisible(False) self.brushEraserToolBar = brushEraserToolBar - self.wandControlsToolbar = widgets.WandControlsToolbar(parent=self) + self.wandControlsToolbar = widgets.WandControlsToolbar( + parent=self + ) - self.addToolBar(Qt.TopToolBarArea, self.wandControlsToolbar) + self.addToolBar(Qt.TopToolBarArea , self.wandControlsToolbar) self.wandControlsToolbar.setVisible(False) self.controlToolBars.append(self.wandControlsToolbar) separatorW = 5 self.labelRoiToolbar = widgets.ToolBar("Magic labeller controls", self) - self.labelRoiToolbar.addWidget(QLabel("ROI n. of z-slices: ")) + self.labelRoiToolbar.addWidget(QLabel('ROI n. of z-slices: ')) self.labelRoiZdepthSpinbox = widgets.SpinBox(disableKeyPress=True) self.labelRoiToolbar.addWidget(self.labelRoiZdepthSpinbox) @@ -154,43 +158,43 @@ def gui_createControlsToolbar(self): self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) self.labelRoiReplaceExistingObjectsCheckbox = QCheckBox( - "Remove objs. touched by new ones" + 'Remove objs. touched by new ones' ) self.labelRoiToolbar.addWidget(self.labelRoiReplaceExistingObjectsCheckbox) self.labelRoiAutoClearBorderCheckbox = QCheckBox( - "Clear ROI borders before adding new objs." + 'Clear ROI borders before adding new objs.' ) self.labelRoiAutoClearBorderCheckbox.setChecked(True) self.labelRoiToolbar.addWidget(self.labelRoiAutoClearBorderCheckbox) - + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) self.labelRoiToolbar.addWidget(widgets.QVLine()) self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) group = QButtonGroup() group.setExclusive(True) - self.labelRoiIsRectRadioButton = QRadioButton("Rect. ROI") + self.labelRoiIsRectRadioButton = QRadioButton('Rect. ROI') self.labelRoiIsRectRadioButton.setChecked(True) - self.labelRoiIsFreeHandRadioButton = QRadioButton("Freehand ROI") - self.labelRoiIsCircularRadioButton = QRadioButton("Circular ROI") + self.labelRoiIsFreeHandRadioButton = QRadioButton('Freehand ROI') + self.labelRoiIsCircularRadioButton = QRadioButton('Circular ROI') group.addButton(self.labelRoiIsRectRadioButton) group.addButton(self.labelRoiIsFreeHandRadioButton) group.addButton(self.labelRoiIsCircularRadioButton) self.labelRoiToolbar.addWidget(self.labelRoiIsRectRadioButton) self.labelRoiToolbar.addWidget(self.labelRoiIsFreeHandRadioButton) self.labelRoiToolbar.addWidget(self.labelRoiIsCircularRadioButton) - self.labelRoiToolbar.addWidget(QLabel(" | Radius (pixel): ")) + self.labelRoiToolbar.addWidget(QLabel(' | Radius (pixel): ')) self.labelRoiCircularRadiusSpinbox = widgets.SpinBox(disableKeyPress=True) self.labelRoiCircularRadiusSpinbox.setMinimum(1) self.labelRoiCircularRadiusSpinbox.setValue(11) self.labelRoiCircularRadiusSpinbox.setDisabled(True) self.labelRoiToolbar.addWidget(self.labelRoiCircularRadiusSpinbox) - + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) self.labelRoiToolbar.addWidget(widgets.QVLine()) self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - startFrameLabel = QLabel("Start frame n. ") + startFrameLabel = QLabel('Start frame n. ') startFrameLabel.setDisabled(True) self.labelRoiToolbar.addWidget(startFrameLabel) self.labelRoiStartFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) @@ -201,13 +205,13 @@ def gui_createControlsToolbar(self): self.labelRoiStartFrameNoSpinbox.setDisabled(True) self.labelRoiFromCurrentFrameAction = QAction(self) - self.labelRoiFromCurrentFrameAction.setText("Segment from current frame") + self.labelRoiFromCurrentFrameAction.setText('Segment from current frame') self.labelRoiFromCurrentFrameAction.setIcon(QIcon(":frames_current.svg")) self.labelRoiToolbar.addAction(self.labelRoiFromCurrentFrameAction) self.labelRoiFromCurrentFrameAction.setDisabled(True) self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=3)) - stopFrameLabel = QLabel(" Stop frame n. ") + stopFrameLabel = QLabel(' Stop frame n. ') stopFrameLabel.setDisabled(True) self.labelRoiToolbar.addWidget(stopFrameLabel) self.labelRoiStopFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) @@ -218,16 +222,18 @@ def gui_createControlsToolbar(self): self.labelRoiStopFrameNoSpinbox.setDisabled(True) self.labelRoiToEndFramesAction = QAction(self) - self.labelRoiToEndFramesAction.setText("Segment all remaining frames") + self.labelRoiToEndFramesAction.setText('Segment all remaining frames') self.labelRoiToEndFramesAction.setIcon(QIcon(":frames_end.svg")) self.labelRoiToolbar.addAction(self.labelRoiToEndFramesAction) self.labelRoiToEndFramesAction.setDisabled(True) - self.labelRoiTrangeCheckbox = QCheckBox("Segment range of frames") + self.labelRoiTrangeCheckbox = QCheckBox('Segment range of frames') self.labelRoiToolbar.addWidget(self.labelRoiTrangeCheckbox) self.labelRoiViewCurrentModelAction = QAction(self) - self.labelRoiViewCurrentModelAction.setText("View current model's parameters") + self.labelRoiViewCurrentModelAction.setText( + 'View current model\'s parameters' + ) self.labelRoiViewCurrentModelAction.setIcon(QIcon(":view.svg")) self.labelRoiToolbar.addAction(self.labelRoiViewCurrentModelAction) self.labelRoiViewCurrentModelAction.setDisabled(True) @@ -239,7 +245,9 @@ def gui_createControlsToolbar(self): self.loadLabelRoiLastParams() - self.labelRoiTrangeCheckbox.toggled.connect(self.labelRoiTrangeCheckboxToggled) + self.labelRoiTrangeCheckbox.toggled.connect( + self.labelRoiTrangeCheckboxToggled + ) self.labelRoiReplaceExistingObjectsCheckbox.toggled.connect( self.storeLabelRoiParams ) @@ -252,8 +260,12 @@ def gui_createControlsToolbar(self): self.labelRoiCircularRadiusSpinbox.valueChanged.connect( self.storeLabelRoiParams ) - self.labelRoiZdepthSpinbox.valueChanged.connect(self.storeLabelRoiParams) - self.labelRoiAutoClearBorderCheckbox.toggled.connect(self.storeLabelRoiParams) + self.labelRoiZdepthSpinbox.valueChanged.connect( + self.storeLabelRoiParams + ) + self.labelRoiAutoClearBorderCheckbox.toggled.connect( + self.storeLabelRoiParams + ) group.buttonToggled.connect(self.storeLabelRoiParams) self.labelRoiToEndFramesAction.triggered.connect( @@ -272,12 +284,14 @@ def gui_createControlsToolbar(self): self.keepIDsConfirmAction.setToolTip('Apply "keep IDs" selection') self.keepIDsConfirmAction.setDisabled(True) self.keepIDsToolbar.addAction(self.keepIDsConfirmAction) - self.keepIDsToolbar.addWidget(QLabel(" IDs to keep: ")) + self.keepIDsToolbar.addWidget(QLabel(' IDs to keep: ')) instructionsText = ( - " (Separate IDs by comma. Use a dash to denote a range of IDs)" + ' (Separate IDs by comma. Use a dash to denote a range of IDs)' ) instructionsLabel = QLabel(instructionsText) - self.keptIDsLineEdit = widgets.KeepIDsLineEdit(instructionsLabel, parent=self) + self.keptIDsLineEdit = widgets.KeepIDsLineEdit( + instructionsLabel, parent=self + ) self.keepIDsToolbar.addWidget(self.keptIDsLineEdit) self.keepIDsToolbar.addWidget(instructionsLabel) spacer = QWidget() @@ -290,21 +304,21 @@ def gui_createControlsToolbar(self): self.keptIDsLineEdit.sigEnterPressed.connect(self.applyKeepObjects) self.keptIDsLineEdit.sigIDsChanged.connect(self.updateKeepIDs) self.keepIDsConfirmAction.triggered.connect(self.applyKeepObjects) - + # closeToolbarAction = QAction( # QIcon(":cancelButton.svg"), "Close toolbar...", self # ) # closeToolbarAction.triggered.connect(self.closeToolbars) # self.autoPilotZoomToObjToolbar.addAction(closeToolbarAction) - + self.autoPilotZoomToObjToolbar.addWidget(widgets.QVLine()) self.autoPilotZoomToObjToolbar.addWidget( widgets.QHWidgetSpacer(width=separatorW) ) - + spinBox = widgets.SpinBox() spinBox.setMinimum(1) - spinBox.label = QLabel(" Zoom to ID: ") + spinBox.label = QLabel(' Zoom to ID: ') spinBox.labelAction = self.autoPilotZoomToObjToolbar.addWidget(spinBox.label) spinBox.action = self.autoPilotZoomToObjToolbar.addWidget(spinBox) spinBox.editingFinished.connect(self.zoomToObj) @@ -314,40 +328,48 @@ def gui_createControlsToolbar(self): toggle = widgets.Toggle() self.autoPilotZoomToObjToggle = toggle toggle.toggled.connect(self.autoPilotZoomToObjToggled) - toggle.label = QLabel(" Auto-pilot: ") + toggle.label = QLabel(' Auto-pilot: ') tooltip = ( - "When auto-pilot is active, you can use Up/Down arrows to " - "automatically zoom to the next/previous object.\n\n" - "Alternatively, you can type the ID of the object you want to " - "zoom to." + 'When auto-pilot is active, you can use Up/Down arrows to ' + 'automatically zoom to the next/previous object.\n\n' + 'Alternatively, you can type the ID of the object you want to ' + 'zoom to.' ) toggle.label.setToolTip(tooltip) toggle.setToolTip(tooltip) self.autoPilotZoomToObjToolbar.addWidget(toggle.label) self.autoPilotZoomToObjToolbar.addWidget(toggle) - + self.pointsLayersToolbars = [] - + self.pointsLayersToolbar = widgets.PointsLayersToolbar(parent=self) self.pointsLayersToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - - self.pointsLayersToolbar.sigAddPointsLayer.connect(self.addPointsLayerTriggered) - + + self.pointsLayersToolbar.sigAddPointsLayer.connect( + self.addPointsLayerTriggered + ) + self.addToolBar(Qt.TopToolBarArea, self.pointsLayersToolbar) - + self.pointsLayersToolbar.setVisible(False) self.pointsLayersToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.pointsLayersToolbar) - - self.pointsLayersToolbars.append(self.pointsLayersToolbar) + + self.pointsLayersToolbars.append( + self.pointsLayersToolbar + ) self.manualTrackingToolbar = widgets.ManualTrackingToolBar( "Manual tracking controls", self ) self.manualTrackingToolbar.sigIDchanged.connect(self.initGhostObject) self.manualTrackingToolbar.sigDisableGhost.connect(self.clearGhost) - self.manualTrackingToolbar.sigClearGhostContour.connect(self.clearGhostContour) - self.manualTrackingToolbar.sigClearGhostMask.connect(self.clearGhostMask) + self.manualTrackingToolbar.sigClearGhostContour.connect( + self.clearGhostContour + ) + self.manualTrackingToolbar.sigClearGhostMask.connect( + self.clearGhostMask + ) self.manualTrackingToolbar.sigGhostOpacityChanged.connect( self.updateGhostMaskOpacity ) @@ -355,7 +377,7 @@ def gui_createControlsToolbar(self): self.addToolBar(Qt.TopToolBarArea, self.manualTrackingToolbar) self.manualTrackingToolbar.setVisible(False) self.controlToolBars.append(self.manualTrackingToolbar) - + self.manualBackgroundToolbar = widgets.ManualBackgroundToolBar( "Manual background controls", self ) @@ -365,7 +387,7 @@ def gui_createControlsToolbar(self): self.addToolBar(Qt.TopToolBarArea, self.manualBackgroundToolbar) self.manualBackgroundToolbar.setVisible(False) self.controlToolBars.append(self.manualBackgroundToolbar) - + # Copy lost object contour toolbar self.copyLostObjToolbar = widgets.CopyLostObjectToolbar( "Copy lost object controls", self @@ -373,42 +395,42 @@ def gui_createControlsToolbar(self): for name, action in self.copyLostObjToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - self.copyLostObjToolbar.sigCopyAllObjects.connect(self.copyAllLostObjects) - + self.copyLostObjToolbar.sigCopyAllObjects.connect( + self.copyAllLostObjects + ) + self.addToolBar(Qt.TopToolBarArea, self.copyLostObjToolbar) self.copyLostObjToolbar.setVisible(False) # self.controlToolBars.append(self.copyLostObjToolbar) - + # Copy lost object contour toolbar self.drawClearRegionToolbar = widgets.DrawClearRegionToolbar( "Draw freehand region and clear objects controls", self ) - + self.addToolBar(Qt.TopToolBarArea, self.drawClearRegionToolbar) self.drawClearRegionToolbar.setVisible(False) self.controlToolBars.append(self.drawClearRegionToolbar) try: - addNewIDToggleState = ( - self.df_settings.at["addNewIDsWhitelistToggle", "value"] == "Yes" - ) + addNewIDToggleState = self.df_settings.at['addNewIDsWhitelistToggle', 'value'] == 'Yes' except KeyError: addNewIDToggleState = True - + self.whitelistIDsToolbar = widgets.WhitelistIDsToolbar( addNewIDToggleState, self ) for name, action in self.whitelistIDsToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - + self.addToolBar(Qt.TopToolBarArea, self.whitelistIDsToolbar) self.whitelistIDsToolbar.setVisible(False) self.controlToolBars.append(self.whitelistIDsToolbar) - + self.magicPromptsToolbar = widgets.MagicPromptsToolbar(self) for name, action in self.magicPromptsToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - + self.magicPromptsToolbar.sigComputeOnZoom.connect( self.magicPromptsComputeOnZoomTriggered ) @@ -435,17 +457,21 @@ def gui_createControlsToolbar(self): self.magicPromptsToolbar.setVisible(False) self.magicPromptsToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.magicPromptsToolbar) - + self.promptSegmentPointsLayerToolbar = ( widgets.PromptableModelPointsLayerToolbar(parent=self) ) - self.promptSegmentPointsLayerToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - + self.promptSegmentPointsLayerToolbar.setContextMenuPolicy( + Qt.PreventContextMenu + ) + self.addToolBar(Qt.TopToolBarArea, self.promptSegmentPointsLayerToolbar) self.promptSegmentPointsLayerToolbar.setVisible(False) - - self.pointsLayersToolbars.append(self.promptSegmentPointsLayerToolbar) - + + self.pointsLayersToolbars.append( + self.promptSegmentPointsLayerToolbar + ) + # Second level toolbar secondLevelToolbar = widgets.ToolBar("Second level toolbar", self) self.addToolBar(Qt.TopToolBarArea, secondLevelToolbar) @@ -453,11 +479,11 @@ def gui_createControlsToolbar(self): self.delObjToolAction.setIcon(QIcon(":del_obj_click.svg")) self.delObjToolAction.setCheckable(True) self.delObjToolAction.setToolTip( - "Customisable delete object action\n\n" - "Go to the `Settings --> Customise keyboard shortcuts...` menu " - "on the top menubar\n" - "to customise the action required to delete " - "an object with a click.\n\n" + 'Customisable delete object action\n\n' + 'Go to the `Settings --> Customise keyboard shortcuts...` menu ' + 'on the top menubar\n' + 'to customise the action required to delete ' + 'an object with a click.\n\n' 'When working with 3D segmentations, to delete only the z-slice mask, hold "Shift" while clicking.' ) secondLevelToolbar.addAction(self.delObjToolAction) @@ -467,7 +493,7 @@ def gui_createControlsToolbar(self): def gui_createMainLayout(self): mainLayout = QGridLayout() - row, col = 0, 1 # Leave column 1 for the overlay labels gradient editor + row, col = 0, 1 # Leave column 1 for the overlay labels gradient editor mainLayout.addLayout(self.leftSideDocksLayout, row, col, 2, 1) row = 0 @@ -475,18 +501,22 @@ def gui_createMainLayout(self): mainLayout.addWidget(self.graphLayout, row, col, 1, 2) mainLayout.setRowStretch(row, 2) - col = 4 # graphLayout spans two columns + col = 4 # graphLayout spans two columns mainLayout.addWidget(self.labelsGrad, row, col) - col = 5 + col = 5 mainLayout.addLayout(self.rightSideDocksLayout, row, col, 2, 1) col = 2 row += 1 self.resizeBottomLayoutLine = widgets.VerticalResizeHline() mainLayout.addWidget(self.resizeBottomLayoutLine, row, col, 1, 2) - self.resizeBottomLayoutLine.dragged.connect(self.resizeBottomLayoutLineDragged) - self.resizeBottomLayoutLine.clicked.connect(self.resizeBottomLayoutLineClicked) + self.resizeBottomLayoutLine.dragged.connect( + self.resizeBottomLayoutLineDragged + ) + self.resizeBottomLayoutLine.clicked.connect( + self.resizeBottomLayoutLineClicked + ) self.resizeBottomLayoutLine.released.connect( self.resizeBottomLayoutLineReleased ) @@ -510,27 +540,27 @@ def gui_createMainLayout(self): return mainLayout def gui_createRegionPropsDockWidget(self, side=Qt.LeftDockWidgetArea): - self.propsDockWidget = QDockWidget("Cell-ACDC objects", self) + self.propsDockWidget = QDockWidget('Cell-ACDC objects', self) self.guiTabControl = widgets.guiTabControl(self.propsDockWidget) # self.guiTabControl.setFont(_font) self.propsDockWidget.setWidget(self.guiTabControl) self.propsDockWidget.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetFloatable + QDockWidget.DockWidgetFeature.DockWidgetFloatable | QDockWidget.DockWidgetFeature.DockWidgetMovable ) self.propsDockWidget.setAllowedAreas( Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea ) - + self.addDockWidget(side, self.propsDockWidget) self.propsDockWidget.hide() def gui_createStatusBar(self): self.statusbar = self.statusBar() # Permanent widget - self.wcLabel = QLabel("") + self.wcLabel = QLabel('') self.statusbar.addPermanentWidget(self.wcLabel) # self.toggleTerminalButton = widgets.ToggleTerminalButton() @@ -539,18 +569,17 @@ def gui_createStatusBar(self): # self.gui_terminalButtonClicked # ) - self.statusBarLabel = QLabel("") + self.statusBarLabel = QLabel('') self.statusbar.addWidget(self.statusBarLabel) def gui_createTerminalWidget(self): self.terminal = widgets.QLog(logger=self.logger) self.terminal.connect() - self.terminalDock = QDockWidget("Log", self) + self.terminalDock = QDockWidget('Log', self) self.terminalDock.setWidget(self.terminal) self.terminalDock.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetFloatable - | QDockWidget.DockWidgetFeature.DockWidgetMovable + QDockWidget.DockWidgetFeature.DockWidgetFloatable | QDockWidget.DockWidgetFeature.DockWidgetMovable ) self.terminalDock.setAllowedAreas(Qt.BottomDockWidgetArea) self.addDockWidget(Qt.BottomDockWidgetArea, self.terminalDock) @@ -563,27 +592,27 @@ def gui_populateToolSettingsMenu(self): self.brushHoverCenterModeAction = QAction() self.brushHoverCenterModeAction.setCheckable(True) self.brushHoverCenterModeAction.setText( - "Use center of the brush/eraser cursor to determine hover ID" + 'Use center of the brush/eraser cursor to determine hover ID' ) self.brushHoverCircleModeAction = QAction() self.brushHoverCircleModeAction.setCheckable(True) self.brushHoverCircleModeAction.setText( - "Use the entire circle of the brush/eraser cursor to determine hover ID" + 'Use the entire circle of the brush/eraser cursor to determine hover ID' ) brushHoverModeActionGroup.addAction(self.brushHoverCenterModeAction) brushHoverModeActionGroup.addAction(self.brushHoverCircleModeAction) brushHoverModeMenu = self.settingsMenu.addMenu( - "Brush/eraser cursor hovering mode" + 'Brush/eraser cursor hovering mode' ) brushHoverModeMenu.addAction(self.brushHoverCenterModeAction) brushHoverModeMenu.addAction(self.brushHoverCircleModeAction) - if "useCenterBrushCursorHoverID" not in self.df_settings.index: - self.df_settings.at["useCenterBrushCursorHoverID", "value"] = "Yes" + if 'useCenterBrushCursorHoverID' not in self.df_settings.index: + self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' - useCenterBrushCursorHoverID = ( - self.df_settings.at["useCenterBrushCursorHoverID", "value"] == "Yes" - ) + useCenterBrushCursorHoverID = self.df_settings.at[ + 'useCenterBrushCursorHoverID', 'value' + ] == 'Yes' self.brushHoverCenterModeAction.setChecked(useCenterBrushCursorHoverID) self.brushHoverCircleModeAction.setChecked(not useCenterBrushCursorHoverID) @@ -593,43 +622,43 @@ def gui_populateToolSettingsMenu(self): self.settingsMenu.addSeparator() - keepToolActiveNames = {"Segment range of frames": self.labelRoiTrangeCheckbox} + keepToolActiveNames = { + 'Segment range of frames': self.labelRoiTrangeCheckbox + } for button in self.checkableQButtonsGroup.buttons(): if button.toolTip() == "": toolName = "MISSING" continue else: - toolName = re.findall(r"Name: (.*)", button.toolTip())[0] + toolName = re.findall(r'Name: (.*)', button.toolTip())[0] keepToolActiveNames[toolName] = button - + keepToolActiveNames = dict(natsorted(keepToolActiveNames.items())) - + applyToNewFrameNames = { - "Segmenting for lost IDs": self.segForLostIDsButton, - "Delete bordering objects": self.delBorderObjAction.button, - "Delete newly segmented objects": self.delNewObjAction.button, + 'Segmenting for lost IDs': self.segForLostIDsButton, + 'Delete bordering objects': self.delBorderObjAction.button, + 'Delete newly segmented objects': self.delNewObjAction.button, } - - allToolsList = list(keepToolActiveNames.keys()) + list( - applyToNewFrameNames.keys() - ) + + allToolsList = list(keepToolActiveNames.keys()) + list(applyToNewFrameNames.keys()) allToolsList = natsorted(allToolsList) - + menus = {} - + for toolName in allToolsList: - menuItemText = f"{toolName} tool".replace(" ", " ") + menuItemText = f'{toolName} tool'.replace(' ', ' ') menus[toolName] = self.settingsMenu.addMenu(menuItemText) - + self.keepToolActiveActions = dict() self.applyToolNewFrameActions = dict() self.applyToolNewFrameButtons = dict() all_checked = True - + for toolName, button in keepToolActiveNames.items(): menu = menus[toolName] action = QAction(button) - action.setText("Keep tool active after using it") + action.setText('Keep tool active after using it') action.setCheckable(True) if toolName in self.df_settings.index: action.setChecked(True) @@ -638,30 +667,32 @@ def gui_populateToolSettingsMenu(self): action.toggled.connect(self.keepToolActiveActionToggled) menu.addAction(action) self.keepToolActiveActions[toolName] = action - + for toolName, button in applyToNewFrameNames.items(): menu = menus[toolName] action = QAction(button) - action.setText("Apply when visitng new frame") + action.setText('Apply when visitng new frame') action.setCheckable(True) action.toggled.connect(self.applyToolNewFrameActionToggled) menu.addAction(action) self.applyToolNewFrameActions[toolName] = action self.applyToolNewFrameButtons[toolName] = button - + for toolName in self.applyToolNewFrameActions.keys(): settingString = toolName.strip() - settingString = toolName.replace(" ", "_") - settingString = f"{settingString}_applyNewFrame" + settingString = toolName.replace(' ', '_') + settingString = f'{settingString}_applyNewFrame' if settingString in self.df_settings.index: - val = self.df_settings.at[settingString, "value"] - if val == "applyNewFrame": + val = self.df_settings.at[settingString, 'value'] + if val == 'applyNewFrame': self.applyToolNewFrameActions[toolName].setChecked(True) - + self.settingsMenu.addSeparator() self.keepAllToolsActiveToggle = QAction() - self.keepAllToolsActiveToggle.setText("Keep all tools active after using them") + self.keepAllToolsActiveToggle.setText( + 'Keep all tools active after using them' + ) self.keepAllToolsActiveToggle.setCheckable(True) self.keepAllToolsActiveToggle.setChecked(all_checked) self.keepAllToolsActiveToggle.toggled.connect( @@ -669,17 +700,17 @@ def gui_populateToolSettingsMenu(self): ) self.settingsMenu.addAction(self.keepAllToolsActiveToggle) self.settingsMenu.addSeparator() - + askHowFutureFramesMenu = self.settingsMenu.addMenu( - "Ask how to propagate changes to future frames" + 'Ask how to propagate changes to future frames' ) self.askHowFutureFramesActions = {} askHowFutureFramesActionsKeys = ( - "Delete ID", - "Exclude cell from analysis", - "Annotate cell as dead", - "Edit ID", - "Keep ID", + 'Delete ID', + 'Exclude cell from analysis', + 'Annotate cell as dead', + 'Edit ID', + 'Keep ID' ) for key in askHowFutureFramesActionsKeys: askHowFutureFramesAction = QAction() @@ -689,25 +720,32 @@ def gui_populateToolSettingsMenu(self): askHowFutureFramesAction.setDisabled(True) askHowFutureFramesMenu.addAction(askHowFutureFramesAction) self.askHowFutureFramesActions[key] = askHowFutureFramesAction - - warningsMenu = self.settingsMenu.addMenu("Warnings and pop-ups") + + warningsMenu = self.settingsMenu.addMenu('Warnings and pop-ups') self.warnLostCellsAction = QAction() - self.warnLostCellsAction.setText("Show pop-up warning for lost cells") + self.warnLostCellsAction.setText('Show pop-up warning for lost cells') self.warnLostCellsAction.setCheckable(True) self.warnLostCellsAction.setChecked(True) warningsMenu.addAction(self.warnLostCellsAction) warnEditingWithAnnotTexts = { - "Delete ID": "Show warning when deleting ID that has annotations", - "Separate IDs": "Show warning when separating IDs that have annotations", - "Edit ID": "Show warning when editing ID that has annotations", - "Annotate ID as dead": "Show warning when annotating dead ID that has annotations", - "Delete ID with eraser": "Show warning when erasing ID that has annotations", - "Add new ID with brush tool": "Show warning when adding new ID (brush) that has annotations", - "Merge IDs": "Show warning when merging IDs that have annotations", - "Add new ID with curvature tool": "Show warning when adding new ID (curv. tool) that has annotations", - "Add new ID with magic-wand": "Show warning when adding new ID (magic-wand) that has annotations", - "Delete IDs using ROI": "Show warning when using ROIs to delete IDs that have annotations", + 'Delete ID': 'Show warning when deleting ID that has annotations', + 'Separate IDs': 'Show warning when separating IDs that have annotations', + 'Edit ID': 'Show warning when editing ID that has annotations', + 'Annotate ID as dead': + 'Show warning when annotating dead ID that has annotations', + 'Delete ID with eraser': + 'Show warning when erasing ID that has annotations', + 'Add new ID with brush tool': + 'Show warning when adding new ID (brush) that has annotations', + 'Merge IDs': + 'Show warning when merging IDs that have annotations', + 'Add new ID with curvature tool': + 'Show warning when adding new ID (curv. tool) that has annotations', + 'Add new ID with magic-wand': + 'Show warning when adding new ID (magic-wand) that has annotations', + 'Delete IDs using ROI': + 'Show warning when using ROIs to delete IDs that have annotations', } self.warnEditingWithAnnotActions = {} for key, desc in warnEditingWithAnnotTexts.items(): @@ -719,15 +757,14 @@ def gui_populateToolSettingsMenu(self): self.warnEditingWithAnnotActions[key] = action warningsMenu.addAction(action) - @resetViewRange def gui_terminalButtonClicked(self, terminalVisible): self.terminalDock.setVisible(terminalVisible) def retainSpaceSlidersToggled(self, checked): if checked: - self.df_settings.at["retain_space_hidden_sliders", "value"] = "Yes" + self.df_settings.at['retain_space_hidden_sliders', 'value'] = 'Yes' else: - self.df_settings.at["retain_space_hidden_sliders", "value"] = "No" + self.df_settings.at['retain_space_hidden_sliders', 'value'] = 'No' self.df_settings.to_csv(self.settings_csv_path) if not self.zSliceScrollBar.isEnabled(): retainSpaceZ = False @@ -738,61 +775,44 @@ def retainSpaceSlidersToggled(self, checked): myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=retainSpaceZ) myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=retainSpaceZ) myutils.setRetainSizePolicy(self.overlay_z_label, retain=retainSpaceZ) - + QTimer.singleShot(200, self.resizeGui) - def should_retain_z_slider_space( - self, - *, - checked: bool, - z_slice_enabled: bool, - ) -> bool: - return checked and z_slice_enabled - - def tool_name_from_tooltip(self, tooltip: str) -> str: - return re.findall(r"Name: (.*)", tooltip)[0] - - LEGACY_METHODS = ( - "zoomBottomLayoutActionTriggered", - "retainSpaceSlidersToggled", - "gui_createMainLayout", - "gui_createRegionPropsDockWidget", - "gui_createControlsToolbar", - "gui_populateToolSettingsMenu", - "useCenterBrushCursorHoverIDtoggled", - "gui_createStatusBar", - "gui_createTerminalWidget", - "gui_terminalButtonClicked", - ) - def useCenterBrushCursorHoverIDtoggled(self, checked): if checked: - self.df_settings.at["useCenterBrushCursorHoverID", "value"] = "Yes" + self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' else: - self.df_settings.at["useCenterBrushCursorHoverID", "value"] = "No" + self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'No' self.df_settings.to_csv(self.settings_csv_path) def zoomBottomLayoutActionTriggered(self, checked): if not checked: return - perc = int(re.findall(r"(\d+)%", self.sender().text())[0]) + perc = int(re.findall(r'(\d+)%', self.sender().text())[0]) if perc != 100: - fontSizeFactor = perc / 100 - heightFactor = perc / 100 + fontSizeFactor = perc/100 + heightFactor = perc/100 self.resizeSlidersArea( fontSizeFactor=fontSizeFactor, heightFactor=heightFactor ) else: self.gui_resetBottomLayoutHeight() - self.df_settings.at["bottom_sliders_zoom_perc", "value"] = perc + self.df_settings.at['bottom_sliders_zoom_perc', 'value'] = perc self.df_settings.to_csv(self.settings_csv_path) QTimer.singleShot(150, self.resizeGui) - def zoom_factors(self, percentage: int) -> tuple[float, float] | None: - if percentage == 100: - return None - factor = percentage / 100 - return factor, factor - - def zoom_percentage_from_text(self, text: str) -> int: - return int(re.findall(r"(\d+)%", text)[0]) + def gui_createShowPropsButton(self, side='left'): + self.leftSideDocksLayout = QVBoxLayout() + self.leftSideDocksLayout.setSpacing(0) + self.leftSideDocksLayout.setContentsMargins(0,0,0,0) + self.rightSideDocksLayout = QVBoxLayout() + self.rightSideDocksLayout.setSpacing(0) + self.rightSideDocksLayout.setContentsMargins(0,0,0,0) + self.showPropsDockButton = widgets.expandCollapseButton() + self.showPropsDockButton.setDisabled(True) + self.showPropsDockButton.setFocusPolicy(Qt.NoFocus) + self.showPropsDockButton.setToolTip('Show object properties') + if side == 'left': + self.leftSideDocksLayout.addWidget(self.showPropsDockButton) + else: + self.rightSideDocksLayout.addWidget(self.showPropsDockButton) diff --git a/cellacdc/mixins_bak/lineage_interactions.py b/cellacdc/mixins/lineage_interactions.py similarity index 63% rename from cellacdc/mixins_bak/lineage_interactions.py rename to cellacdc/mixins/lineage_interactions.py index 5bfa8277c..a5a0897cd 100644 --- a/cellacdc/mixins_bak/lineage_interactions.py +++ b/cellacdc/mixins/lineage_interactions.py @@ -20,8 +20,8 @@ ) -class LineageInteractionsMixin: - """Qt-facing adapter around lineage-tree interaction workflows.""" +class LineageInteractions: + """Extracted from guiWin.""" def annotate_unknown_lineage_action(self, posData, event, ydata, xdata): """ @@ -45,11 +45,10 @@ def annotate_unknown_lineage_action(self, posData, event, ydata, xdata): if point is None: return posData = self.data[self.pos_i] - acdc_df_frame = posData.allData_li[posData.frame_i]["acdc_df"] - acdc_df_frame.at[ID, "parent_ID_tree"] = -1 + acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] + acdc_df_frame.at[ID, 'parent_ID_tree'] = -1 self.drawAllLineageTreeLines() - @disableWindow def askLineageTreeChanges(self): """ Asks the user for changes in the lineage tree. @@ -59,42 +58,35 @@ def askLineageTreeChanges(self): """ mode = str(self.modeComboBox.currentText()) - if mode != "Normal division: Lineage tree": + if mode != 'Normal division: Lineage tree': return - + if not self.lineage_tree: return - + posData = self.data[self.pos_i] - - if ( - self.original_df_lin_tree_i is not None - and self.original_df_lin_tree_i != posData.frame_i - ): + + if self.original_df_lin_tree_i is not None and self.original_df_lin_tree_i != posData.frame_i: printl("!This should not happen!") self.store_data(autosave=False) og_frame = posData.frame_i posData.frame_i = self.original_df_lin_tree_i self.get_data() - self.logger.info( - "Lineage tree changes were not propagated, going back to original frame." - ) + self.logger.info('Lineage tree changes were not propagated, going back to original frame.') self.askLineageTreeChanges() self.store_data(autosave=False) posData.frame_i = og_frame self.get_data() return - result = self.get_difference_table( - return_css_separated=True, return_differece=True - ) + result = self.get_difference_table(return_css_separated=True, return_differece=True) if result is None: self.original_df_lin_tree = None self.original_df_lin_tree_i = None return css, txt, differences = result - changed_IDs = differences["Cell_ID"].unique() + changed_IDs = differences['Cell_ID'].unique() if posData.frame_i == max(self.lineage_tree.frames_for_dfs): # here we can just propagate the cahnged. This is super fast, since there is no recursion, no children and fast finding of parents @@ -103,47 +95,44 @@ def askLineageTreeChanges(self): self.original_df_lin_tree_i = None return - txt = txt + "Do you want to keep, propgagte or discard the changes?" - txt = css + html_utils.paragraph("Changes made in this frame
" + txt) + txt = txt + 'Do you want to keep, propgagte or discard the changes?' + txt = css + html_utils.paragraph('Changes made in this frame
' + txt) msg = widgets.myMessageBox() - propagate_btn, discard_btn, _ = msg.question( - self, - "Changes in lineage tree", - txt, - buttonsTexts=("Propagate", "Discard", "Cancel"), - ) + propagate_btn, discard_btn, _ = msg.question(self, + 'Changes in lineage tree', + txt, + buttonsTexts=('Propagate', 'Discard', 'Cancel'),) if msg.clickedButton == propagate_btn: self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) self.original_df_lin_tree = None self.original_df_lin_tree_i = None - self.logger.info("Lineage tree propagated.") + self.logger.info('Lineage tree propagated.') elif msg.clickedButton == discard_btn: - posData.allData_li[posData.frame_i]["acdc_df"] = ( - self.original_df_lin_tree.copy() - ) + posData.allData_li[posData.frame_i]['acdc_df'] = self.original_df_lin_tree.copy() self.original_df_lin_tree = None self.original_df_lin_tree_i = None - self.logger.info("Lineage tree changes discarded.") + self.logger.info('Lineage tree changes discarded.') + elif msg.cancel: # Go back to current frame msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" + txt = html_utils.paragraph(''' Changes were kept but not propagated! Please make sure to come back and propagate them, otherwise your table might be inconsistent! There is a button for this next to the edit buttons. Please also do not visit new frames! - """) - msg.warning(self, "Changes kept but not propagated!", txt) + ''') + msg.warning(self, 'Changes kept but not propagated!', txt) self.original_df_lin_tree = None self.original_df_lin_tree_i = None - self.logger.info("Lineage tree changes discarded.") + self.logger.info('Lineage tree changes discarded.') def autoLinTree_df(self, enforceAll=False): """Automatically generates a lineage tree dataframe. @@ -174,9 +163,9 @@ def autoLinTree_df(self, enforceAll=False): mode = str(self.modeComboBox.currentText()) # Skip if not the right mode - if mode != "Normal division: Lineage tree": + if mode != 'Normal division: Lineage tree': return notEnoughG1Cells, proceed - + posData = self.data[self.pos_i] frame_i = posData.frame_i @@ -184,23 +173,20 @@ def autoLinTree_df(self, enforceAll=False): return notEnoughG1Cells, proceed # Make sure that this is a visited frame in segmentation tracking mode - if posData.allData_li[frame_i]["labels"] is None: # may need to change this + if posData.allData_li[frame_i]['labels'] is None: # may need to change this proceed = self.warnFrameNeverVisitedSegmMode() return notEnoughG1Cells, proceed - + self.store_data(autosave=False) self.get_data() lab = posData.lab - prev_lab = posData.allData_li[frame_i - 1]["labels"] + prev_lab = posData.allData_li[frame_i-1]['labels'] rp = posData.rp - prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + prev_rp = posData.allData_li[frame_i-1]['regionprops'] self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) self.store_data() - def default_mode_after_failed_init(self) -> str: - return self.viewer_mode - def find_mother_action(self, posData, event, ydata, xdata): """ This function is part of the lin_tree edit functionality. @@ -224,20 +210,17 @@ def find_mother_action(self, posData, event, ydata, xdata): if point is None: return posData = self.data[self.pos_i] - acdc_df_frame = posData.allData_li[posData.frame_i]["acdc_df"] + acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] filtered_IDs = self.getDistanceListMissingIDs(point, ID) if len(filtered_IDs) == 0: - self.logger.info("No mother candidates found.") + self.logger.info('No mother candidates found.') return i = self.right_click_i % len(filtered_IDs) i = abs(i) # Ensure i is non-negative new_mother = filtered_IDs[i] - if ( - acdc_df_frame.loc[ID]["parent_ID_tree"] == new_mother - and not self.original_mother_skipped - ): # if a mother is already present, skip it + if acdc_df_frame.loc[ID]['parent_ID_tree'] == new_mother and self.original_mother_skipped == False: # if a mother is already present, skip it self.right_click_i += 1 self.original_mother_skipped = True @@ -245,9 +228,7 @@ def find_mother_action(self, posData, event, ydata, xdata): i = abs(i) # Ensure i is non-negative new_mother = filtered_IDs[i] - acdc_df_frame.at[ID, "parent_ID_tree"] = ( - new_mother # update mother in the df, no need to propagate or stuff lile this - ) + acdc_df_frame.at[ID, 'parent_ID_tree'] = new_mother # update mother in the df, no need to propagate or stuff lile this # dont need to update alldata_li as acdc_df_frame is just a view self.drawAllLineageTreeLines() @@ -261,11 +242,13 @@ def getDistanceListMissingIDs(self, point, ID): # self.get_data() if ID not in self.distanceListMissingIDs.keys(): - prev_rp = posData.allData_li[frame_i - 1]["regionprops"] - relevant_rp = [obj for obj in prev_rp if obj.label not in posData.IDs] + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + relevant_rp = [ + obj for obj in prev_rp if obj.label not in posData.IDs + ] len_relevant_rp = len(relevant_rp) if len_relevant_rp == 0: - self.logger.info("No missing IDs found in previous frame.") + self.logger.info('No missing IDs found in previous frame.') return [] elif len_relevant_rp == 1: self.distanceListMissingIDs[ID] = [relevant_rp[0].label] @@ -277,21 +260,20 @@ def getDistanceListMissingIDs(self, point, ID): else: return self.distanceListMissingIDs[ID] - @disableWindow def get_difference_table(self, return_css_separated=False, return_differece=False): if self.original_df_lin_tree is None: return posData = self.data[self.pos_i] - - new_df = posData.allData_li[posData.frame_i]["acdc_df"] + + new_df = posData.allData_li[posData.frame_i]['acdc_df'] original_df = self.original_df_lin_tree.copy() if original_df.equals(new_df): return - - compare_columns = ["parent_ID_tree"] + + compare_columns = ['parent_ID_tree'] new_df = new_df[original_df.columns] new_df = myutils.checked_reset_index_Cell_ID(new_df) @@ -304,10 +286,10 @@ def get_difference_table(self, return_css_separated=False, return_differece=Fals differences = original_df.compare(new_df) if differences.empty: return - + differences = myutils.checked_reset_index_Cell_ID(differences) - - differences = differences["parent_ID_tree"] + + differences = differences['parent_ID_tree'] differences = differences.reset_index() txt = """ @@ -321,15 +303,15 @@ def get_difference_table(self, return_css_separated=False, return_differece=Fals ID = str(int(diff.Cell_ID)) old_parent = str(int(diff.self)) new_parent = str(int(diff.other)) - - txt += f""" + + txt += f''' - """ - txt += "
{ID} {old_parent} {new_parent}
" + ''' + txt += '' - css = r""" + css = r''' - """ + ''' if return_css_separated and not return_differece: return css, txt elif return_css_separated and return_differece: @@ -350,7 +332,6 @@ def get_difference_table(self, return_css_separated=False, return_differece=Fals txt = css + html_utils.paragraph(txt) return txt - @exception_handler def initLinTree(self, force=False): """ Initializes the lineage tree analysis. @@ -367,27 +348,28 @@ def initLinTree(self, force=False): if not force and self.lineage_tree is not None: return - + mode = str(self.modeComboBox.currentText()) - if mode != "Normal division: Lineage tree" and not force: + if mode != 'Normal division: Lineage tree' and not force: return posData = self.data[self.pos_i] last_tracked_i = self.get_last_tracked_i() - defaultMode = "Viewer" + defaultMode = 'Viewer' if last_tracked_i == 0: # Display message to the user txt = html_utils.paragraph( - "On this dataset either you never checked that the segmentation " - "and tracking are correct or you did not save yet.

" + 'On this dataset either you never checked that the segmentation ' + 'and tracking are correct or you did not save yet.

' 'If you already visited some frames with "Segmentation and Tracking" ' 'mode save data before switching to "Normal division: Lineage Tree".

' - "Otherwise you first have to check (and eventually correct) some frames " + 'Otherwise you first have to check (and eventually correct) some frames ' 'in "Segmentation and Tracking" mode before proceeding ' - "with lineage tree analysis." - ) + 'with lineage tree analysis.') msg = widgets.myMessageBox() - msg.critical(self, "Tracking was never checked", txt) + msg.critical( + self, 'Tracking was never checked', txt + ) self.modeComboBox.setCurrentText(defaultMode) return @@ -395,12 +377,11 @@ def initLinTree(self, force=False): last_lin_tree_frame_i = 0 # Determine last annotated frame index for i, dict_frame_i in enumerate(posData.allData_li): - df = dict_frame_i["acdc_df"] - if ( - df is None - or "generation_num_tree" not in df.columns - or df["generation_num_tree"].isin([np.nan, 0]).all() - ): + df = dict_frame_i['acdc_df'] + if (df is None or + 'generation_num_tree' not in df.columns + or df['generation_num_tree'].isin([np.nan, 0]).all() + ): break else: last_lin_tree_frame_i = i @@ -415,65 +396,59 @@ def initLinTree(self, force=False): # Prompt user to go to last annotated frame msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_lin_tree_frame_i + 1}.

+ The last annotated frame is frame {last_lin_tree_frame_i+1}.

Do you want to restart lineage tree analysis from frame - {last_lin_tree_frame_i + 1}?
+ {last_lin_tree_frame_i+1}?
""") _, yesButton, stayButton = msg.warning( - self, - "Go to last annotated frame?", - txt, + self, 'Go to last annotated frame?', txt, buttonsTexts=( - "Cancel", - f"Yes, go to frame {last_lin_tree_frame_i + 1}", - "No, stay on current frame", - ), + 'Cancel', f'Yes, go to frame {last_lin_tree_frame_i+1}', + 'No, stay on current frame') ) if yesButton == msg.clickedButton: - msg = "Looking good!" + msg = 'Looking good!' self.last_lin_tree_frame_i = last_lin_tree_frame_i posData.frame_i = last_lin_tree_frame_i self.titleLabel.setText(msg, color=self.titleColor) self.get_data(lin_tree_init=False) - self.updateAllImages() # i dont think I need to change this - self.updateScrollbars() # i dont think I need to change this + self.updateAllImages() # i dont think I need to change this + self.updateScrollbars() # i dont think I need to change this elif stayButton == msg.clickedButton: - self.initMissingFramesLinTree(posData.frame_i) #!!! + self.initMissingFramesLinTree(posData.frame_i) #!!! last_lin_tree_frame_i = posData.frame_i - msg = "Lineage tree analysis initialised!" - self.titleLabel.setText(msg, color="g") + msg = 'Lineage tree analysis initialised!' + self.titleLabel.setText(msg, color='g') elif msg.cancel: - msg = "Lineage tree analysis aborted." + msg = 'Lineage tree analysis aborted.' self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) self.modeComboBox.setCurrentText(defaultMode) proceed = False return - + elif posData.frame_i < last_lin_tree_frame_i: # Prompt user to go to last annotated frame msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_lin_tree_frame_i + 1}.

+ The last annotated frame is frame {last_lin_tree_frame_i+1}.

Do you want to restart lineage tree analysis from frame - {last_lin_tree_frame_i + 1}?
+ {last_lin_tree_frame_i+1}?
""") goTo_last_annotated_frame_i = msg.question( - self, - "Go to last annotated frame?", - txt, - buttonsTexts=("Yes", "No", "Cancel"), + self, 'Go to last annotated frame?', txt, + buttonsTexts=('Yes', 'No', 'Cancel') )[0] if goTo_last_annotated_frame_i == msg.clickedButton: - msg = "Looking good!" + msg = 'Looking good!' self.titleLabel.setText(msg, color=self.titleColor) self.last_lin_tree_frame_i = last_lin_tree_frame_i posData.frame_i = last_lin_tree_frame_i self.get_data(lin_tree_init=False) - self.updateAllImages() # i dont think I need to change this - self.updateScrollbars() # i dont think I need to change this + self.updateAllImages() # i dont think I need to change this + self.updateScrollbars() # i dont think I need to change this elif msg.cancel: - msg = "Lineage tree analysis aborted." + msg = 'Lineage tree analysis aborted.' self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) self.modeComboBox.setCurrentText(defaultMode) @@ -484,23 +459,21 @@ def initLinTree(self, force=False): self.last_lin_tree_frame_i = last_lin_tree_frame_i - self.navigateScrollBar.setMaximum(last_lin_tree_frame_i + 1) - self.navSpinBox.setMaximum(last_lin_tree_frame_i + 1) + self.navigateScrollBar.setMaximum(last_lin_tree_frame_i+1) + self.navSpinBox.setMaximum(last_lin_tree_frame_i+1) if self.lineage_tree is None or force: self.store_data(autosave=False) self.get_data(lin_tree_init=False) self.lineage_tree = normal_division_lineage_tree(gui=self) - msg = "Lineage tree analysis initialized!" + msg = 'Lineage tree analysis initialized!' self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) return proceed - def initMissingFramesLinTree( - self, current_frame_i - ): # done Need to add partially missing previous frames and loading + def initMissingFramesLinTree(self, current_frame_i): # done Need to add partially missing previous frames and loading """ When not starting from the first frame, automatically creates lineage tree dfs for all "skipped" frames and initializes the tree if not done so before. @@ -519,7 +492,7 @@ def initMissingFramesLinTree( """ self.logger.info( - "Initialising lineage tree annotations of missing past frames..." + 'Initialising lineage tree annotations of missing past frames...' ) self.store_data(autosave=False) @@ -528,104 +501,26 @@ def initMissingFramesLinTree( posData = self.data[self.pos_i] current_frame_i = posData.frame_i - if not self.lineage_tree: # init lin tree if not done already - self.lineage_tree = normal_division_lineage_tree( - gui=self - ) # here frame_i!=0 + if not self.lineage_tree: # init lin tree if not done already + self.lineage_tree = normal_division_lineage_tree(gui=self) # here frame_i!=0 - missing_frames = list(range(current_frame_i + 1)) - present_frames = ( - list(self.lineage_tree.frames_for_dfs) if self.lineage_tree else [] - ) - present_frames = [] if not present_frames else present_frames # deal with None - missing_frames = [ - frame_i for frame_i in missing_frames if frame_i not in present_frames - ] + missing_frames = list(range(current_frame_i+1)) + present_frames = list(self.lineage_tree.frames_for_dfs) if self.lineage_tree else [] + present_frames = [] if not present_frames else present_frames # deal with None + missing_frames = [frame_i for frame_i in missing_frames if frame_i not in present_frames] missing_frames.sort() for frame_i in missing_frames: - lab = posData.allData_li[frame_i]["labels"] - prev_lab = posData.allData_li[frame_i - 1]["labels"] - rp = posData.allData_li[frame_i]["regionprops"] - prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + lab = posData.allData_li[frame_i]['labels'] + prev_lab = posData.allData_li[frame_i-1]['labels'] + rp = posData.allData_li[frame_i]['regionprops'] + prev_rp = posData.allData_li[frame_i-1]['regionprops'] # i might need to change this if I need support for only partially missing frames... Although I probably never have to care about that though self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) posData.frame_i = current_frame_i self.store_data() - def is_lineage_mode(self, mode: str) -> bool: - return mode == self.lineage_mode - - def last_annotated_frame_index( - self, - frame_records: Iterable[dict], - *, - acdc_key: str = "acdc_df", - generation_column: str = "generation_num_tree", - ) -> int: - last_frame_i = 0 - for frame_i, record in enumerate(frame_records): - acdc_df = record[acdc_key] - if ( - acdc_df is None - or generation_column not in acdc_df.columns - or acdc_df[generation_column].isin([np.nan, 0]).all() - ): - break - last_frame_i = frame_i - return last_frame_i - - def missing_frame_indices( - self, - current_frame_i: int, - present_frames: Iterable[int] | None, - ) -> list[int]: - present = set(present_frames or []) - missing = [ - frame_i for frame_i in range(current_frame_i + 1) if frame_i not in present - ] - missing.sort() - return missing - - def next_candidate_index( - self, - click_index: int, - candidates_count: int, - ) -> int: - if candidates_count <= 0: - return 0 - return abs(click_index % candidates_count) - - def parent_id_differences( - self, - original_df: pd.DataFrame, - new_df: pd.DataFrame, - reset_index_cell_id: Callable[[pd.DataFrame], pd.DataFrame], - *, - compare_columns: Sequence[str] = ("parent_ID_tree",), - ) -> pd.DataFrame | None: - if original_df.equals(new_df): - return None - - new_df = new_df[original_df.columns] - new_df = reset_index_cell_id(new_df) - new_df = new_df[list(compare_columns)] - new_df = new_df.sort_index() - - original_df = reset_index_cell_id(original_df) - original_df = original_df[list(compare_columns)] - original_df = original_df.sort_index() - - differences = original_df.compare(new_df) - if differences.empty: - return None - - differences = reset_index_cell_id(differences) - differences = differences[compare_columns[0]] - return differences.reset_index() - - @disableWindow def propagateLinTreeAction(self, dummy_for_button=None): """ Propagates the lineage tree based on the current frame_i. Used in self.propagateLinTreeButton. @@ -633,16 +528,14 @@ def propagateLinTreeAction(self, dummy_for_button=None): posData = self.data[self.pos_i] self.lineage_tree.propagate(posData.frame_i) if posData.frame_i == self.original_df_lin_tree_i: - self.original_df_lin_tree = posData.allData_li[posData.frame_i][ - "acdc_df" - ].copy() + self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() - self.logger.info("Lineage tree propagated.") + self.logger.info('Lineage tree propagated.') def repeat_click_and_backup(self, posData, event, ydata, xdata): """ - This function is part of the lin_tree edit functionality. - It handles the back up of the original self.lineage_tree.lineage_list + This function is part of the lin_tree edit functionality. + It handles the back up of the original self.lineage_tree.lineage_list df and the repeated clicking on the same ID to cycle through pssible mothers. Parameters @@ -662,17 +555,13 @@ def repeat_click_and_backup(self, posData, event, ydata, xdata): A tuple containing the point(tuple: (x, y) coords) and ID of clicked cell. """ if self.original_df_lin_tree is None: - self.original_df_lin_tree = posData.allData_li[posData.frame_i][ - "acdc_df" - ].copy() + self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() self.original_df_lin_tree_i = posData.frame_i elif self.original_df_lin_tree_i != posData.frame_i: self.logger.info( - "[WARNING]: !!! Original lineage tree df changed, resetting original_df_lin_tree !!!" + '[WARNING]: !!! Original lineage tree df changed, resetting original_df_lin_tree !!!' ) - self.original_df_lin_tree = posData.allData_li[posData.frame_i][ - "acdc_df" - ].copy() + self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() self.original_df_lin_tree_i = posData.frame_i if not self.right_click_ID: @@ -704,98 +593,56 @@ def resetLin_tree_future(self): for i in range(frame_i, posData.SizeT): if self.lineage_tree is not None: self.lineage_tree.frames_for_dfs.discard(frame_i) - df = posData.allData_li[i]["acdc_df"] + df = posData.allData_li[i]['acdc_df'] # reste lineage tree columns if df is None: continue - df = df.drop(columns=lineage_tree_cols, errors="ignore") - posData.allData_li[i]["acdc_df"] = df - - def should_backup_original( - self, - original_frame_i: int | None, - current_frame_i: int, - ) -> bool: - return original_frame_i is None or original_frame_i != current_frame_i - - def should_initialize( - self, - *, - force: bool, - mode: str, - lineage_tree_exists: bool, - ) -> bool: - if not force and lineage_tree_exists: - return False - return force or self.is_lineage_mode(mode) - - def should_process_auto_frame( - self, - *, - mode: str, - frame_i: int, - processed_frames: Iterable[int], - ) -> bool: - if not self.is_lineage_mode(mode): - return False - return frame_i not in processed_frames - - def should_skip_original_mother( - self, - current_parent_id, - candidate_parent_id, - *, - original_mother_skipped: bool, - ) -> bool: - return current_parent_id == candidate_parent_id and not original_mother_skipped + df = df.drop(columns=lineage_tree_cols, errors='ignore') + posData.allData_li[i]['acdc_df'] = df def viewLinTreeInfoAction(self): mode = str(self.modeComboBox.currentText()) - if mode != "Normal division: Lineage tree": - self.logger.info( - 'This action is only available in the "Normal division: Lineage tree" mode.' - ) + if mode != 'Normal division: Lineage tree': + self.logger.info('This action is only available in the "Normal division: Lineage tree" mode.') return - + if not self.lineage_tree: - self.logger.info("No lineage tree found.") + self.logger.info('No lineage tree found.') return - + posData = self.data[self.pos_i] if self.original_df_lin_tree_i != posData.frame_i: # could be that this is not entirley true and self.curr_original_df_i just didnt get set right though! - txt_changes = "
No changes were made in this frame.

" - + txt_changes = '
No changes were made in this frame.

' + else: result = self.get_difference_table(return_css_separated=True) if result is None: - txt_changes = "No changes were made in this frame." + txt_changes = 'No changes were made in this frame.' else: css, txt_changes = result - txt_changes = "Changes made in this frame:" + txt_changes + "

" - - cells_with_parent, orphan_cells, lost_cells = ( - self.lineage_tree.export_lin_tree_info(posData.frame_i) - ) + txt_changes = 'Changes made in this frame:' + txt_changes + '

' + + cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i) if orphan_cells == []: - txt_orphan_cells = "No orphan Cells!" + txt_orphan_cells = 'No orphan Cells!' else: - txt_orphan_cells = ", ".join([str(cell) for cell in orphan_cells]) - txt_orphan = f"Orphan cells:
{txt_orphan_cells}

" + txt_orphan_cells = ', '.join([str(cell) for cell in orphan_cells]) + txt_orphan = f'Orphan cells:
{txt_orphan_cells}

' lost_cells = list(lost_cells) if lost_cells == []: - txt_lost_cells = "No lost Cells!" + txt_lost_cells = 'No lost Cells!' else: - txt_lost_cells = ", ".join([str(cell) for cell in lost_cells]) - txt_lost = f"Lost cells:
{txt_lost_cells}

" + txt_lost_cells = ', '.join([str(cell) for cell in lost_cells]) + txt_lost = f'Lost cells:
{txt_lost_cells}

' if cells_with_parent == []: - table_cells_with_parent = "
No cells with parents!" + table_cells_with_parent = '
No cells with parents!' else: table_cells_with_parent = """ @@ -804,17 +651,15 @@ def viewLinTreeInfoAction(self): """ for cell, parent in cells_with_parent: - table_cells_with_parent += f""" + table_cells_with_parent += f''' - """ - table_cells_with_parent += "
{parent} {cell}
" + ''' + table_cells_with_parent += '' - txt_cells_with_parents = ( - f"Cells with parents:{table_cells_with_parent}

" - ) + txt_cells_with_parents = f'Cells with parents:{table_cells_with_parent}

' - css = r""" + css = r''' - """ + ''' - txt = css + html_utils.paragraph( - txt_changes + txt_orphan + txt_lost + txt_cells_with_parents - ) + txt = css + html_utils.paragraph(txt_changes + txt_orphan + txt_lost + txt_cells_with_parents) msg = widgets.myMessageBox() - msg.information(self, "lineage tree information", txt) + msg.information(self, + 'lineage tree information', + txt + ) diff --git a/cellacdc/mixins_bak/magic_prompts.py b/cellacdc/mixins/magic_prompts.py similarity index 55% rename from cellacdc/mixins_bak/magic_prompts.py rename to cellacdc/mixins/magic_prompts.py index 39a9b44c8..bfabf5c68 100644 --- a/cellacdc/mixins_bak/magic_prompts.py +++ b/cellacdc/mixins/magic_prompts.py @@ -20,16 +20,13 @@ from cellacdc import disableWindow -class MagicPromptsMixin: - """Qt-facing adapter around promptable segmentation dialogs and workers.""" +class MagicPrompts: + """Extracted from guiWin.""" - """Headless promptable-segmentation geometry and point rules.""" - - @disableWindow def _importInitMagicPromptModel( - self, model_name, posData, win, acdcPromptSegment, toolbar - ): - self.logger.info(f"Initializing promptable model {model_name}...") + self, model_name, posData, win, acdcPromptSegment, toolbar + ): + self.logger.info(f'Initializing promptable model {model_name}...') init_kwargs = win.init_kwargs model = myutils.init_prompt_segm_model( acdcPromptSegment, posData, win.init_kwargs @@ -38,116 +35,105 @@ def _importInitMagicPromptModel( toolbar.model_segment_kwargs = win.model_kwargs toolbar.model_name = model_name toolbar.viewModelParamsAction.setDisabled(False) - + self.magicPromptsToolbar.setInitializedModel( init_kwargs, toolbar.model_segment_kwargs ) - - self.logger.info(f"Promptable model {model_name} successfully initialised!") - - def _retained_points_outside_zoom_2d(self, points_data, zoom): - xmin, xmax, ymin, ymax = zoom.bounds - retained = {"x": [], "y": [], "id": []} - for x, y, point_id in zip( - points_data["x"], - points_data["y"], - points_data["id"], - ): - if x < xmin or x >= xmax or y < ymin or y >= ymax: - retained["x"].append(x) - retained["y"].append(y) - retained["id"].append(point_id) - return retained + + self.logger.info( + f'Promptable model {model_name} successfully initialised!' + ) def getMagicPromptsInputs(self, toolbar): if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit: _warnings.warnPromptSegmentPointsLayerNotInit(qparent=self) return - + if not self.magicPromptsToolbar.viewModelParamsAction.isEnabled(): _warnings.warnPromptSegmentModelNotInit(qparent=self) return - + posData = self.data[self.pos_i] image = self.getDisplayedZstack() df_points = self.promptSegmentPointsLayerToolbar.pointsLayerDf( posData, isSegm3D=self.isSegm3D ) - + self.logger.info( - f"Starting {toolbar.model_name} promptable segmentation with the " - f"following prompts:\n\n{df_points}" + f'Starting {toolbar.model_name} promptable segmentation with the ' + f'following prompts:\n\n{df_points}' ) - + return image, df_points def magicPromptsClearPoints(self, toolbar, only_zoom=False): posData = self.data[self.pos_i] scatterItem = self.promptSegmentPointsLayerToolbar.scatterItem() action = scatterItem.action - + pointsDataPos = action.pointsData.get(self.pos_i) if pointsDataPos is None: return - - framePointsData = action.pointsData[self.pos_i].pop(posData.frame_i, None) + + framePointsData = action.pointsData[self.pos_i].pop( + posData.frame_i, None + ) if framePointsData is None: return - + if not only_zoom: scatterItem.clear() return - + ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange() Y, X = posData.img_data.shape[-2:] - + xmin = int(max(0, xmin)) xmax = int(min(X, xmax)) ymin = int(max(0, ymin)) ymax = int(min(Y, ymax)) - - if "x" in framePointsData: - newFramePointsData = {"x": [], "y": [], "id": []} - xx = framePointsData["x"] - yy = framePointsData["y"] - ids = framePointsData["id"] + + if 'x' in framePointsData: + newFramePointsData = {'x': [], 'y': [], 'id': []} + xx = framePointsData['x'] + yy = framePointsData['y'] + ids = framePointsData['id'] for x, y, point_id in zip(xx, yy, ids): if x < xmin or x >= xmax or y < ymin or y >= ymax: - newFramePointsData["x"].append(x) - newFramePointsData["y"].append(y) - newFramePointsData["id"].append(point_id) + newFramePointsData['x'].append(x) + newFramePointsData['y'].append(y) + newFramePointsData['id'].append(point_id) else: newFramePointsData = {} for z, zSliceFramePointsData in framePointsData.items(): - newFramePointsData[z] = {"x": [], "y": [], "id": []} - xx = zSliceFramePointsData["x"] - yy = zSliceFramePointsData["y"] - ids = zSliceFramePointsData["id"] + newFramePointsData[z] = {'x': [], 'y': [], 'id': []} + xx = zSliceFramePointsData['x'] + yy = zSliceFramePointsData['y'] + ids = zSliceFramePointsData['id'] for x, y, point_id in zip(xx, yy, ids): if x < xmin or x >= xmax or y < ymin or y >= ymax: - newFramePointsData[z]["x"].append(x) - newFramePointsData[z]["y"].append(y) - newFramePointsData[z]["id"].append(point_id) - + newFramePointsData[z]['x'].append(x) + newFramePointsData[z]['y'].append(y) + newFramePointsData[z]['id'].append(point_id) + action.pointsData[self.pos_i][posData.frame_i] = newFramePointsData self.drawPointsLayers() - @disableWindow def magicPromptsComputeOnImageTriggered(self, toolbar): inputs = self.getMagicPromptsInputs(toolbar) if inputs is None: self.logger.info( - '"Computing promptable segmentation on entire image" process cancelled.' + '"Computing promptable segmentation on entire image" ' + 'process cancelled.' ) return image, df_points = inputs - + self.startMagicPromptsWorkerAndWait( image, df_points, toolbar.model, toolbar.model_segment_kwargs ) - @disableWindow def magicPromptsComputeOnZoomTriggered(self, toolbar): inputs = self.getMagicPromptsInputs(toolbar) if inputs is None: @@ -155,83 +141,78 @@ def magicPromptsComputeOnZoomTriggered(self, toolbar): '"Computing promptable segmentation on zoom" process cancelled.' ) return - + posData = self.data[self.pos_i] image, df_points = inputs - + ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange() Y, X = image.shape[-2:] - + xmin = int(max(0, xmin)) xmax = int(min(X, xmax)) ymin = int(max(0, ymin)) ymax = int(min(Y, ymax)) - + self.logger.info( - f"Zoom range: xmin={xmin}, xmax={xmax}, ymin={ymin}, ymax={ymax}" + f'Zoom range: xmin={xmin}, xmax={xmax}, ymin={ymin}, ymax={ymax}' ) - + zoom_slice = (slice(ymin, ymax), slice(xmin, xmax)) - + image = image[..., ymin:ymax, xmin:xmax] image_origin = (0, ymin, xmin) - - df_points = df_points[df_points["y"] >= ymin] - df_points = df_points[df_points["x"] >= xmin] - df_points = df_points[df_points["y"] < ymax] - df_points = df_points[df_points["x"] < xmax] - - df_points["y"] -= ymin - df_points["x"] -= xmin - - df_points = df_points[df_points["frame_i"] == posData.frame_i] - - self.logger.info(f"Image origin = {image_origin}\nImage shape = {image.shape}") - + + df_points = df_points[df_points['y'] >= ymin] + df_points = df_points[df_points['x'] >= xmin] + df_points = df_points[df_points['y'] < ymax] + df_points = df_points[df_points['x'] < xmax] + + df_points['y'] -= ymin + df_points['x'] -= xmin + + df_points = df_points[ df_points['frame_i'] == posData.frame_i] + + self.logger.info( + f'Image origin = {image_origin}\n' + f'Image shape = {image.shape}' + ) + self.startMagicPromptsWorkerAndWait( - image, - df_points, - toolbar.model, - toolbar.model_segment_kwargs, - image_origin=image_origin, - zoom_slice=zoom_slice, + image, df_points, toolbar.model, toolbar.model_segment_kwargs, + image_origin=image_origin, zoom_slice=zoom_slice ) - @exception_handler def magicPromptsInitModel( - self, - model_name, - acdcPromptSegment, - init_argspecs, - segment_argspecs, - help_url, - toolbar, - ): + self, + model_name, + acdcPromptSegment, + init_argspecs, + segment_argspecs, + help_url, + toolbar, + ): posData = self.data[self.pos_i] - + out = prompts.init_prompt_model_params( - posData, - model_name, - init_argspecs, - segment_argspecs, - help_url=help_url, - qparent=self, - init_last_params=True, + posData, model_name, init_argspecs, segment_argspecs, + help_url=help_url, qparent=self, init_last_params=True ) - win = out.get("win") + win = out.get('win') if win.cancel: self.logger.info( - f"Initialization of {model_name} promptable model cancelled." + f'Initialization of {model_name} promptable model cancelled.' ) return - + self._importInitMagicPromptModel( model_name, posData, win, acdcPromptSegment, toolbar ) def magicPromptsInterpolateZsliceToggled(self, checked): # See 'self.promptSegmentPointsLayerToolbar.addPointsZslicesInterpolation' - self.promptSegmentPointsLayerToolbar.doAddPointsZslicesInterpolation = checked + self.promptSegmentPointsLayerToolbar.doAddPointsZslicesInterpolation = ( + checked + ) def magicPromptsWorkerCritical(self, error): self.magicPromptsWorkerLoop.exit() @@ -243,169 +224,146 @@ def magicPromptsWorkerFinished(self, output, zoom_slice=None): self.progressWin.close() self.progressWin = None self.magicPromptsWorkerLoop.exit() - + lab_new, lab_union, lab_interesection = output - + posData = self.data[self.pos_i] - + + is_zoom = True if zoom_slice is None: zoom_slice = (slice(None), slice(None)) - - img = posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]] + is_zoom = False + + img = ( + posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]] + ) images = [img, img, img, img] labels_overlays = [ - posData.lab[..., zoom_slice[0], zoom_slice[1]], - lab_new[..., zoom_slice[0], zoom_slice[1]], - lab_union[..., zoom_slice[0], zoom_slice[1]], + posData.lab[..., zoom_slice[0], zoom_slice[1]], + lab_new[..., zoom_slice[0], zoom_slice[1]], + lab_union[..., zoom_slice[0], zoom_slice[1]], lab_interesection[..., zoom_slice[0], zoom_slice[1]], ] labels_overlays_lut = self.getLabelsImageLut() labels_overlays_luts = [ - labels_overlays_lut, - labels_overlays_lut, + labels_overlays_lut, + labels_overlays_lut, labels_overlays_lut, labels_overlays_lut, ] axis_titles = [ - "Original masks", - "New masks", - "Union of original and new masks", - "Intersection of original and new masks", + 'Original masks', + 'New masks', + 'Union of original and new masks', + 'Intersection of original and new masks' ] - + from cellacdc.plot import imshow - promptSegmResultsWindow = imshow( - *images, + *images, labels_overlays=labels_overlays, labels_overlays_luts=labels_overlays_luts, axis_titles=axis_titles, - window_title="Promptable segmentation results", - figure_title="Ctrl+Click to select the result to use", + window_title='Promptable segmentation results', + figure_title='Ctrl+Click to select the result to use', annotate_labels_idxs=[0, 1, 2, 3], - selectable_images=True, + selectable_images=True, max_ncols=2, - lut="gray", - infer_rgb=False, + lut='gray', + infer_rgb=False ) if promptSegmResultsWindow.selected_idx is None: self.logger.info( - "Selection of the promptable model segmentation result cancelled." + 'Selection of the promptable model segmentation ' + 'result cancelled.' ) return - + if promptSegmResultsWindow.selected_idx == 0: self.logger.info( - "No selection of a promptable model segmentation result was made" + 'No selection of a promptable model segmentation ' + 'result was made' ) return # Store undo state before modifying stuff self.storeUndoRedoStates(False) - + results = (None, lab_new, lab_union, lab_interesection) selected_idx = promptSegmResultsWindow.selected_idx zoom_out_lab = results[selected_idx][..., zoom_slice[0], zoom_slice[1]] zoom_out_lab_mask = zoom_out_lab > 0 - - lab = posData.allData_li[posData.frame_i]["labels"] - lab[..., zoom_slice[0], zoom_slice[1]][zoom_out_lab_mask] = zoom_out_lab[ - zoom_out_lab_mask - ] - - posData.allData_li[posData.frame_i]["labels"] = lab + + lab = posData.allData_li[posData.frame_i]['labels'] + lab[..., zoom_slice[0], zoom_slice[1]][zoom_out_lab_mask] = ( + zoom_out_lab[zoom_out_lab_mask] + ) + + posData.allData_li[posData.frame_i]['labels'] = lab self.get_data() self.store_data(autosave=False) self.updateAllImages() - def points_in_zoom(self, df_points, zoom: MagicPromptZoom, frame_i): - xmin, xmax, ymin, ymax = zoom.bounds - filtered = df_points[ - (df_points["y"] >= ymin) - & (df_points["x"] >= xmin) - & (df_points["y"] < ymax) - & (df_points["x"] < xmax) - & (df_points["frame_i"] == frame_i) - ].copy() - filtered["y"] -= ymin - filtered["x"] -= xmin - return filtered - - def retained_points_outside_zoom( - self, - frame_points_data: Mapping, - zoom: MagicPromptZoom, - ): - if "x" in frame_points_data: - return self._retained_points_outside_zoom_2d( - frame_points_data, - zoom, - ) - - return { - z: self._retained_points_outside_zoom_2d(z_points, zoom) - for z, z_points in frame_points_data.items() - } - def segmWithPromptableModelActionTriggered(self): - self.blinker = qutils.QControlBlink(self.magicPromptsToolButton, qparent=self) + self.blinker = qutils.QControlBlink( + self.magicPromptsToolButton, qparent=self + ) self.blinker.start() def showInstructionsCustomPromptModel(self): modelFilePath = apps.addCustomPromptModelMessages(QParent=self) if modelFilePath is None: - self.logger.info("Adding custom promptable model process stopped.") + self.logger.info('Adding custom promptable model process stopped.') return - + myutils.store_custom_promptable_model_path(modelFilePath) - + msg = widgets.myMessageBox(wrapText=False) - info_txt = html_utils.paragraph(""" + info_txt = html_utils.paragraph(f""" Done!

The custom promptable model has been added to the list of models.

Use the Magic prompts button (top toolbar) to use it.

Have fun! """) - msg.information(self, "Custom promptable model added", info_txt) + msg.information(self, 'Custom promptable model added', info_txt) def startMagicPromptsWorkerAndWait( - self, - image, - df_points, - model, - model_segment_kwargs, - image_origin=(0, 0, 0), - zoom_slice=None, - ): - desc = "Running promptable segmentation model..." + self, image, df_points, model, model_segment_kwargs, + image_origin=(0, 0, 0), zoom_slice=None + ): + desc = ( + 'Running promptable segmentation model...' + ) self.logger.info(desc) posData = self.data[self.pos_i] - + self.progressWin = apps.QDialogWorkerProgress( title=desc, parent=self, pbarDesc=desc ) self.progressWin.mainPbar.setMaximum(0) self.progressWin.show(self.app) - + self.magicPromptsThread = QThread() self.magicPromptsWorker = workers.MagicPromptsWorker( - posData, - image, - df_points, - model, - model_segment_kwargs, + posData, image, df_points, model, model_segment_kwargs, image_origin=image_origin, - global_image=posData.img_data[posData.frame_i], + global_image=posData.img_data[posData.frame_i] + ) + + self.magicPromptsWorker.moveToThread( + self.magicPromptsThread + ) + + self.magicPromptsWorker.signals.finished.connect( + self.magicPromptsThread.quit ) - - self.magicPromptsWorker.moveToThread(self.magicPromptsThread) - - self.magicPromptsWorker.signals.finished.connect(self.magicPromptsThread.quit) self.magicPromptsWorker.signals.finished.connect( self.magicPromptsWorker.deleteLater ) - self.magicPromptsThread.finished.connect(self.magicPromptsThread.deleteLater) - + self.magicPromptsThread.finished.connect( + self.magicPromptsThread.deleteLater + ) + self.magicPromptsWorker.signals.critical.connect( self.magicPromptsWorkerCritical ) @@ -415,66 +373,50 @@ def startMagicPromptsWorkerAndWait( self.magicPromptsWorker.signals.progressBar.connect( self.workerUpdateProgressbar ) - self.magicPromptsWorker.signals.progress.connect(self.workerProgress) + self.magicPromptsWorker.signals.progress.connect( + self.workerProgress + ) self.magicPromptsWorker.signals.finished.connect( partial(self.magicPromptsWorkerFinished, zoom_slice=zoom_slice) ) - - self.magicPromptsThread.started.connect(self.magicPromptsWorker.run) + + self.magicPromptsThread.started.connect( + self.magicPromptsWorker.run + ) self.magicPromptsThread.start() - + self.magicPromptsWorkerLoop = QEventLoop() self.magicPromptsWorkerLoop.exec_() def viewSetMagicPromptModelParams( - self, - model_name, - acdcPromptSegment, - init_argspecs, - segment_argspecs, - help_url, - init_kwargs, - segment_kwargs, - toolbar, - ): + self, + model_name, + acdcPromptSegment, + init_argspecs, + segment_argspecs, + help_url, + init_kwargs, + segment_kwargs, + toolbar + ): posData = self.data[self.pos_i] - + init_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( init_argspecs, init_kwargs ) segment_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( segment_argspecs, segment_kwargs ) - + out = prompts.init_prompt_model_params( - posData, - model_name, - init_argspecs, - segment_argspecs, - help_url=help_url, - qparent=self, - init_last_params=False, + posData, model_name, init_argspecs, segment_argspecs, + help_url=help_url, qparent=self, init_last_params=False ) - win = out.get("win") + win = out.get('win') if win.cancel: return - + if win.model_kwargs != segment_kwargs or win.init_kwargs != init_kwargs: self._importInitMagicPromptModel( model_name, posData, win, acdcPromptSegment, toolbar ) - - def zoom_region(self, view_range, image_shape) -> MagicPromptZoom: - (xmin, xmax), (ymin, ymax) = view_range - height, width = image_shape[-2:] - - xmin = int(max(0, xmin)) - xmax = int(min(width, xmax)) - ymin = int(max(0, ymin)) - ymax = int(min(height, ymax)) - - return MagicPromptZoom( - bounds=(xmin, xmax, ymin, ymax), - image_origin=(0, ymin, xmin), - zoom_slice=(slice(ymin, ymax), slice(xmin, xmax)), - ) diff --git a/cellacdc/mixins/main_menu.py b/cellacdc/mixins/main_menu.py new file mode 100644 index 000000000..84518a10e --- /dev/null +++ b/cellacdc/mixins/main_menu.py @@ -0,0 +1,203 @@ +"""View adapter for the main menu.""" + +from __future__ import annotations + +from qtpy.QtWidgets import QAction, QActionGroup, QMenu + + +class MainMenu: + """Extracted from guiWin.""" + + def gui_createMenuBar(self): + menuBar = self.menuBar() + menuBar.setNativeMenuBar(False) + + # File menu + fileMenu = QMenu("&File", self) + self.fileMenu = fileMenu + menuBar.addMenu(fileMenu) + if self.debug: + fileMenu.addAction(self.createEmptyDataAction) + fileMenu.addAction(self.newAction) + fileMenu.addAction(self.newWindowAction) + fileMenu.addSeparator() + fileMenu.addAction(self.openFolderAction) + fileMenu.addAction(self.openFileAction) + # Open Recent submenu + self.openRecentMenu = fileMenu.addMenu("Open Recent") + fileMenu.addSeparator() + fileMenu.addAction(self.manageVersionsAction) + fileMenu.addAction(self.saveAction) + fileMenu.addAction(self.saveAsAction) + fileMenu.addAction(self.quickSaveAction) + fileMenu.addSeparator() + + self.exportMenu = fileMenu.addMenu('Export') + self.exportMenu.addAction(self.exportToVideoAction) + self.exportMenu.addAction(self.exportToImageAction) + fileMenu.addSeparator() + fileMenu.addAction(self.loadFluoAction) + fileMenu.addAction(self.loadPosAction) + # Separator + self.fileMenu.lastSeparator = fileMenu.addSeparator() + fileMenu.addAction(self.exitAction) + + # Edit menu + editMenu = menuBar.addMenu("&Edit") + editMenu.addSeparator() + + editMenu.addAction(self.editShortcutsAction) + editMenu.addAction(self.editTextIDsColorAction) + editMenu.addAction(self.editOverlayColorAction) + editMenu.addAction(self.manuallyEditCcaAction) + editMenu.addAction(self.enableSmartTrackAction) + editMenu.addAction(self.enableAutoZoomToCellsAction) + + # View menu + self.viewMenu = menuBar.addMenu("&View") + self.viewMenu.addSeparator() + self.viewMenu.addAction(self.viewCcaTableAction) + + # Image menu + ImageMenu = menuBar.addMenu("&Image") + ImageMenu.addSeparator() + ImageMenu.addAction(self.imgPropertiesAction) + self.defaultRescaleIntensLutMenu = ImageMenu.addMenu( + "Default method to rescale intensities (LUT)" + ) + + self.defaultRescaleIntensActionGroup = QActionGroup( + self.defaultRescaleIntensLutMenu + ) + howTexts = ( + 'Rescale each 2D image', + 'Rescale across z-stack', + 'Rescale across time frames', + 'Do no rescale, display raw image' + ) + try: + self.defaultRescaleIntensHow = ( + self.df_settings.at['default_rescale_intens_how', 'value'] + ) + except Exception as err: + self.defaultRescaleIntensHow = howTexts[0] + + for howText in howTexts: + action = QAction(howText, self.defaultRescaleIntensLutMenu) + action.setCheckable(True) + if howText == self.defaultRescaleIntensHow: + action.setChecked(True) + + self.defaultRescaleIntensActionGroup.addAction(action) + self.defaultRescaleIntensLutMenu.addAction(action) + + ImageMenu.addAction(self.addScaleBarAction) + ImageMenu.addAction(self.addTimestampAction) + + self.rescaleIntensMenu = ImageMenu.addMenu('Rescale intensities (LUT)') + + ImageMenu.addAction(self.preprocessAction) + ImageMenu.addAction(self.combineChannelsAction) + ImageMenu.addAction(self.saveLabColormapAction) + ImageMenu.addAction(self.shuffleCmapAction) + ImageMenu.addAction(self.greedyShuffleCmapAction) + ImageMenu.addAction(self.zoomToObjsAction) + ImageMenu.addAction(self.zoomOutAction) + + # Segment menu + SegmMenu = menuBar.addMenu("&Segment") + self.segmentMenu = SegmMenu + SegmMenu.addSeparator() + self.segmSingleFrameMenu = SegmMenu.addMenu('Segment displayed frame') + for action in self.segmActions: + self.segmSingleFrameMenu.addAction(action) + + self.segmSingleFrameMenu.addSeparator() + self.segmSingleFrameMenu.addAction(self.addCustomModelFrameAction) + + self.segmVideoMenu = SegmMenu.addMenu('Segment multiple frames') + for action in self.segmActionsVideo: + self.segmVideoMenu.addAction(action) + + self.segmVideoMenu.addSeparator() + self.segmVideoMenu.addAction(self.addCustomModelVideoAction) + + self.segmWithPromptableModelMenu = SegmMenu.addMenu( + 'Segment with promptable model' + ) + + self.segmWithPromptableModelMenu.addAction( + self.segmWithPromptableModelAction + ) + + self.segmWithPromptableModelMenu.addSeparator() + self.segmWithPromptableModelMenu.addAction( + self.addCustomPromptModelAction + ) + + SegmMenu.addAction(self.EditSegForLostIDsSetSettings) + SegmMenu.addAction(self.postProcessSegmAction) + SegmMenu.addAction(self.autoSegmAction) + SegmMenu.addAction(self.relabelSequentialAction) + SegmMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) + + # Tracking menu + trackingMenu = menuBar.addMenu("&Tracking") + self.trackingMenu = trackingMenu + trackingMenu.addSeparator() + selectTrackAlgoMenu = trackingMenu.addMenu( + 'Select real-time tracking algorithm' + ) + for rtTrackerAction in self.trackingAlgosGroup.actions(): + selectTrackAlgoMenu.addAction(rtTrackerAction) + + trackingMenu.addAction(self.editRtTrackerParamsAction) + trackingMenu.addAction(self.repeatTrackingVideoAction) + + trackingMenu.addAction(self.repeatTrackingMenuAction) + trackingMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) + + if self.mainWin is not None: + trackingMenu.addAction( + self.mainWin.applyTrackingFromTableAction + ) + trackingMenu.addAction( + self.mainWin.applyTrackingFromTrackMateXMLAction + ) + + # Measurements menu + measurementsMenu = menuBar.addMenu("&Measurements") + self.measurementsMenu = measurementsMenu + measurementsMenu.addSeparator() + measurementsMenu.addAction(self.setMeasurementsAction) + measurementsMenu.addAction(self.addCustomMetricAction) + measurementsMenu.addAction(self.addCombineMetricAction) + measurementsMenu.setDisabled(True) + + # Settings menu + self.settingsMenu = QMenu("Settings", self) + menuBar.addMenu(self.settingsMenu) + self.settingsMenu.addAction(self.invertBwAction) + self.settingsMenu.addAction(self.toggleColorSchemeAction) + self.settingsMenu.addSeparator() + self.settingsMenu.addAction(self.pxModeAction) + self.settingsMenu.addAction(self.highLowResAction) + self.settingsMenu.addAction(self.editShortcutsAction) + self.settingsMenu.addAction(self.showMirroredCursorAction) + self.settingsMenu.addSeparator() + self.settingsMenu.addAction(self.editAutoSaveIntervalAction) + self.settingsMenu.addSeparator() + + # Mode menu (actions added when self.modeComboBox is created) + self.modeMenu = menuBar.addMenu('Mode') + self.modeMenu.menuAction().setVisible(False) + + # Help menu + helpMenu = menuBar.addMenu("&Help") + helpMenu.addAction(self.openLogFileAction) + helpMenu.addAction(self.showLogFilesAction) + helpMenu.addAction(self.tipsAction) + helpMenu.addAction(self.UserManualAction) + helpMenu.addSeparator() + helpMenu.addAction(self.aboutAction) + self.helpMenu = helpMenu diff --git a/cellacdc/mixins_bak/main_toolbar.py b/cellacdc/mixins/main_toolbar.py similarity index 78% rename from cellacdc/mixins_bak/main_toolbar.py rename to cellacdc/mixins/main_toolbar.py index fa8862b77..0cc49b087 100644 --- a/cellacdc/mixins_bak/main_toolbar.py +++ b/cellacdc/mixins/main_toolbar.py @@ -11,12 +11,8 @@ from cellacdc import widgets -class MainToolbarMixin: - """Qt-facing adapter around top-level toolbar construction.""" - - """Headless toolbar metadata used by the main toolbar view.""" - - mode_items = () +class MainToolbar: + """Extracted from guiWin.""" def closeToolbars(self): for toolbar in self.sender().toolbars: @@ -24,12 +20,9 @@ def closeToolbars(self): for action in toolbar.actions(): try: action.button.setChecked(False) - except Exception: + except Exception as e: pass - def default_mode_items(self) -> tuple[str, ...]: - return self.mode_items - def gui_createAnnotateToolbar(self): # Edit toolbar self.annotateToolbar = widgets.ToolBar("Custom annotations", self) @@ -46,7 +39,9 @@ def gui_createToolBars(self): # fileToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) fileToolBar.setMovable(False) - self.segmNdimIndicatorAction = fileToolBar.addWidget(self.segmNdimIndicator) + self.segmNdimIndicatorAction = fileToolBar.addWidget( + self.segmNdimIndicator + ) self.segmNdimIndicatorAction.setVisible(False) fileToolBar.addAction(self.newAction) fileToolBar.addAction(self.openFolderAction) @@ -69,21 +64,21 @@ def gui_createToolBars(self): # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) self.addToolBar(navigateToolBar) navigateToolBar.addAction(self.findIdAction) - + navigateToolBar.addWidget(self.zoomRectButton) self.slideshowButton = QToolButton(self) self.slideshowButton.setIcon(QIcon(":eye-plus.svg")) self.slideshowButton.setCheckable(True) - self.slideshowButton.setShortcut("Ctrl+W") + self.slideshowButton.setShortcut('Ctrl+W') navigateToolBar.addWidget(self.slideshowButton) - + navigateToolBar.addAction(self.autoPilotButton) - + # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) navigateToolBar.addAction(self.skipToNewIdAction) - - self.preprocessImageAction = QAction("Preprocess image", self) + + self.preprocessImageAction = QAction('Preprocess image', self) self.preprocessImageAction.setIcon(QIcon(":filter_image.svg")) navigateToolBar.addAction(self.preprocessImageAction) @@ -94,14 +89,16 @@ def gui_createToolBars(self): self.overlayButtonAction = navigateToolBar.addWidget(self.overlayButton) # self.checkableButtons.append(self.overlayButton) # self.checkableQButtonsGroup.addButton(self.overlayButton) - + self.countObjsButton = QToolButton(self) self.countObjsButton.setIcon(QIcon(":count_objects.svg")) self.countObjsButton.setCheckable(True) - self.countObjsButton.setShortcut("Ctrl+Shift+C") - self.countObjsButtonAction = navigateToolBar.addWidget(self.countObjsButton) + self.countObjsButton.setShortcut('Ctrl+Shift+C') + self.countObjsButtonAction = navigateToolBar.addWidget( + self.countObjsButton + ) - self.togglePointsLayerAction = QAction("Activate points layer", self) + self.togglePointsLayerAction = QAction('Activate points layer', self) self.togglePointsLayerAction.setCheckable(True) self.togglePointsLayerAction.setIcon(QIcon(":pointsLayer.svg")) navigateToolBar.addAction(self.togglePointsLayerAction) @@ -125,7 +122,7 @@ def gui_createToolBars(self): # fluorescence image color widget colorsToolBar = widgets.ToolBar("Colors", self) - self.overlayColorButton = pg.ColorButton(self, color=(230, 230, 230)) + self.overlayColorButton = pg.ColorButton(self, color=(230,230,230)) self.overlayColorButton.setDisabled(True) colorsToolBar.addWidget(self.overlayColorButton) @@ -145,18 +142,21 @@ def gui_createToolBars(self): self.assignBudMothButton = QToolButton(self) self.assignBudMothButton.setIcon(QIcon(":assign-motherbud.svg")) self.assignBudMothButton.setCheckable(True) - self.assignBudMothButton.setShortcut("A") + self.assignBudMothButton.setShortcut('A') self.assignBudMothButton.setVisible(False) - self.assignBudMothButton.action = ccaToolBar.addWidget(self.assignBudMothButton) + self.assignBudMothButton.action = ccaToolBar.addWidget( + self.assignBudMothButton + ) self.checkableButtons.append(self.assignBudMothButton) self.checkableQButtonsGroup.addButton(self.assignBudMothButton) self.functionsNotTested3D.append(self.assignBudMothButton) + # Set is_history_known button self.setIsHistoryKnownButton = QToolButton(self) self.setIsHistoryKnownButton.setIcon(QIcon(":history.svg")) self.setIsHistoryKnownButton.setCheckable(True) - self.setIsHistoryKnownButton.setShortcut("U") + self.setIsHistoryKnownButton.setShortcut('U') self.setIsHistoryKnownButton.setVisible(False) self.setIsHistoryKnownButton.action = ccaToolBar.addWidget( self.setIsHistoryKnownButton @@ -164,7 +164,7 @@ def gui_createToolBars(self): self.checkableButtons.append(self.setIsHistoryKnownButton) self.checkableQButtonsGroup.addButton(self.setIsHistoryKnownButton) self.functionsNotTested3D.append(self.setIsHistoryKnownButton) - + ccaToolBar.addAction(self.assignBudMothAutoAction) ccaToolBar.addAction(self.editCcaToolAction) ccaToolBar.addAction(self.reInitCcaAction) @@ -177,9 +177,9 @@ def gui_createToolBars(self): # Edit toolbar editToolBar = widgets.ToolBar("Edit", self) editToolBar.setContextMenuPolicy(Qt.PreventContextMenu) - + self.addToolBar(editToolBar) - + self.manulAnnotToolButtons = set() self.brushButton = QToolButton(self) @@ -189,7 +189,7 @@ def gui_createToolBars(self): self.checkableButtons.append(self.brushButton) self.LeftClickButtons.append(self.brushButton) self.brushButton.keyPressShortcut = Qt.Key_B - self.widgetsWithShortcut["Brush"] = self.brushButton + self.widgetsWithShortcut['Brush'] = self.brushButton self.manulAnnotToolButtons.add(self.brushButton) self.eraserButton = QToolButton(self) @@ -197,7 +197,7 @@ def gui_createToolBars(self): self.eraserButton.setCheckable(True) editToolBar.addWidget(self.eraserButton) self.eraserButton.keyPressShortcut = Qt.Key_X - self.widgetsWithShortcut["Eraser"] = self.eraserButton + self.widgetsWithShortcut['Eraser'] = self.eraserButton self.checkableButtons.append(self.eraserButton) self.LeftClickButtons.append(self.eraserButton) self.manulAnnotToolButtons.add(self.eraserButton) @@ -205,117 +205,131 @@ def gui_createToolBars(self): self.curvToolButton = QToolButton(self) self.curvToolButton.setIcon(QIcon(":curvature-tool.svg")) self.curvToolButton.setCheckable(True) - self.curvToolButton.setShortcut("C") + self.curvToolButton.setShortcut('C') self.curvToolButton.action = editToolBar.addWidget(self.curvToolButton) self.LeftClickButtons.append(self.curvToolButton) # self.functionsNotTested3D.append(self.curvToolButton) - self.widgetsWithShortcut["Curvature tool"] = self.curvToolButton + self.widgetsWithShortcut['Curvature tool'] = self.curvToolButton # self.checkableButtons.append(self.curvToolButton) self.manulAnnotToolButtons.add(self.curvToolButton) self.wandToolButton = QToolButton(self) self.wandToolButton.setIcon(QIcon(":magic_wand.svg")) self.wandToolButton.setCheckable(True) - self.wandToolButton.setShortcut("Ctrl+D") + self.wandToolButton.setShortcut('Ctrl+D') self.wandToolButton.action = editToolBar.addWidget(self.wandToolButton) self.LeftClickButtons.append(self.wandToolButton) self.checkableButtons.append(self.eraserButton) - self.widgetsWithShortcut["Magic wand"] = self.wandToolButton - + self.widgetsWithShortcut['Magic wand'] = self.wandToolButton + self.magicPromptsToolButton = QToolButton(self) self.magicPromptsToolButton.setIcon(QIcon(":magic-prompts.svg")) self.magicPromptsToolButton.setCheckable(True) - self.magicPromptsToolButton.setShortcut("W") + self.magicPromptsToolButton.setShortcut('W') self.magicPromptsToolButton.action = editToolBar.addWidget( self.magicPromptsToolButton ) - self.widgetsWithShortcut["Magic prompts"] = self.magicPromptsToolButton - + self.widgetsWithShortcut['Magic prompts'] = self.magicPromptsToolButton + self.drawClearRegionButton = QToolButton(self) self.drawClearRegionButton.setCheckable(True) self.drawClearRegionButton.setIcon(QIcon(":clear_freehand_region.svg")) - self.widgetsWithShortcut["Clear freehand region"] = self.drawClearRegionButton + self.widgetsWithShortcut['Clear freehand region'] = ( + self.drawClearRegionButton + ) self.toolsActiveInProj3Dsegm.add(self.drawClearRegionButton) - + self.checkableButtons.append(self.drawClearRegionButton) self.LeftClickButtons.append(self.drawClearRegionButton) + + self.drawClearRegionAction = editToolBar.addWidget( + self.drawClearRegionButton + ) - self.drawClearRegionAction = editToolBar.addWidget(self.drawClearRegionButton) - - self.widgetsWithShortcut["Annotate mother/daughter pairing"] = ( + self.widgetsWithShortcut['Annotate mother/daughter pairing'] = ( self.assignBudMothButton ) - self.widgetsWithShortcut["Annotate unknown history"] = ( + self.widgetsWithShortcut['Annotate unknown history'] = ( self.setIsHistoryKnownButton ) - + self.copyLostObjButton = QToolButton(self) self.copyLostObjButton.setIcon(QIcon(":copyContour.svg")) self.copyLostObjButton.setCheckable(True) - self.copyLostObjButton.setShortcut("V") - self.copyLostObjButton.action = editToolBar.addWidget(self.copyLostObjButton) + self.copyLostObjButton.setShortcut('V') + self.copyLostObjButton.action = editToolBar.addWidget( + self.copyLostObjButton + ) self.checkableButtons.append(self.copyLostObjButton) self.checkableQButtonsGroup.addButton(self.copyLostObjButton) - self.widgetsWithShortcut["Copy lost object contour"] = self.copyLostObjButton + self.widgetsWithShortcut['Copy lost object contour'] = ( + self.copyLostObjButton + ) self.functionsNotTested3D.append(self.copyLostObjButton) - + self.labelRoiButton = widgets.rightClickToolButton(parent=self) self.labelRoiButton.setIcon(QIcon(":label_roi.svg")) self.labelRoiButton.setCheckable(True) - self.labelRoiButton.setShortcut("L") + self.labelRoiButton.setShortcut('L') self.labelRoiButton.action = editToolBar.addWidget(self.labelRoiButton) self.LeftClickButtons.append(self.labelRoiButton) self.checkableButtons.append(self.labelRoiButton) self.checkableQButtonsGroup.addButton(self.labelRoiButton) - self.widgetsWithShortcut["Label ROI"] = self.labelRoiButton + self.widgetsWithShortcut['Label ROI'] = self.labelRoiButton # self.functionsNotTested3D.append(self.labelRoiButton) - + self.manualAnnotPastButton = QToolButton(self) self.manualAnnotPastButton.setIcon(QIcon(":lock_id_annotate_future.svg")) self.manualAnnotPastButton.setCheckable(True) - self.manualAnnotPastButton.setShortcut("Y") + self.manualAnnotPastButton.setShortcut('Y') self.manualAnnotPastButton.action = editToolBar.addWidget( self.manualAnnotPastButton ) self.checkableButtons.append(self.manualAnnotPastButton) - self.widgetsWithShortcut["Lock ID and annotate single object"] = ( + self.widgetsWithShortcut['Lock ID and annotate single object'] = ( self.manualAnnotPastButton ) self.functionsNotTested3D.append(self.manualAnnotPastButton) self.manulAnnotToolButtons.add(self.manualAnnotPastButton) - self.segmentToolAction = QAction("Segment with last used model", self) + self.segmentToolAction = QAction('Segment with last used model', self) self.segmentToolAction.setIcon(QIcon(":segment.svg")) - self.segmentToolAction.setShortcut("R") - self.widgetsWithShortcut["Repeat segmentation"] = self.segmentToolAction + self.segmentToolAction.setShortcut('R') + self.widgetsWithShortcut['Repeat segmentation'] = self.segmentToolAction editToolBar.addAction(self.segmentToolAction) self.segForLostIDsButton = QToolButton(self) self.segForLostIDsButton.setIcon(QIcon(":segForLostIDs.svg")) - self.segForLostIDsAction = editToolBar.addWidget(self.segForLostIDsButton) - self.segForLostIDsButton.clicked.connect(self.segForLostIDsButtonClicked) + self.segForLostIDsAction = editToolBar.addWidget( + self.segForLostIDsButton + ) + self.segForLostIDsButton.clicked.connect( + self.segForLostIDsButtonClicked + ) # self.SegForLostIDsButton.setShortcut('U') # self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.SegForLostIDsButton - + self.manualBackgroundButton = QToolButton(self) self.manualBackgroundButton.setIcon(QIcon(":manual_background.svg")) self.manualBackgroundButton.setCheckable(True) - self.manualBackgroundButton.setShortcut("G") + self.manualBackgroundButton.setShortcut('G') self.LeftClickButtons.append(self.manualBackgroundButton) self.checkableButtons.append(self.manualBackgroundButton) self.checkableQButtonsGroup.addButton(self.manualBackgroundButton) - self.widgetsWithShortcut["Manual background"] = self.manualBackgroundButton - - self.manualBackgroundAction = editToolBar.addWidget(self.manualBackgroundButton) - + self.widgetsWithShortcut['Manual background'] = self.manualBackgroundButton + + self.manualBackgroundAction = editToolBar.addWidget( + self.manualBackgroundButton + ) + self.delObjsOutSegmMaskAction = QAction( - QIcon(":del_objs_out_segm.svg"), - "Select a segmentation file and delete all objects on the background", - self, + QIcon(":del_objs_out_segm.svg"), + 'Select a segmentation file and delete all objects on the background', + self ) - self.delObjsOutSegmMaskAction.setShortcut("I") - self.widgetsWithShortcut["Delete all objects outside segm"] = ( + self.delObjsOutSegmMaskAction.setShortcut('I') + self.widgetsWithShortcut['Delete all objects outside segm'] = ( self.delObjsOutSegmMaskAction ) editToolBar.addAction(self.delObjsOutSegmMaskAction) @@ -323,98 +337,96 @@ def gui_createToolBars(self): self.hullContToolButton = QToolButton(self) self.hullContToolButton.setIcon(QIcon(":hull.svg")) self.hullContToolButton.setCheckable(True) - self.hullContToolButton.setShortcut("O") + self.hullContToolButton.setShortcut('O') self.hullContToolButton.action = editToolBar.addWidget(self.hullContToolButton) self.checkableButtons.append(self.hullContToolButton) self.checkableQButtonsGroup.addButton(self.hullContToolButton) self.functionsNotTested3D.append(self.hullContToolButton) - self.widgetsWithShortcut["Hull contour"] = self.hullContToolButton + self.widgetsWithShortcut['Hull contour'] = self.hullContToolButton self.fillHolesToolButton = QToolButton(self) self.fillHolesToolButton.setIcon(QIcon(":fill_holes.svg")) self.fillHolesToolButton.setCheckable(True) - self.fillHolesToolButton.setShortcut("F") + self.fillHolesToolButton.setShortcut('F') self.fillHolesToolButton.action = editToolBar.addWidget( self.fillHolesToolButton ) self.checkableButtons.append(self.fillHolesToolButton) self.checkableQButtonsGroup.addButton(self.fillHolesToolButton) self.functionsNotTested3D.append(self.fillHolesToolButton) - self.widgetsWithShortcut["Fill holes"] = self.fillHolesToolButton + self.widgetsWithShortcut['Fill holes'] = self.fillHolesToolButton self.moveLabelToolButton = QToolButton(self) self.moveLabelToolButton.setIcon(QIcon(":moveLabel.svg")) self.moveLabelToolButton.setCheckable(True) - self.moveLabelToolButton.setShortcut("P") - self.moveLabelToolButton.action = editToolBar.addWidget( - self.moveLabelToolButton - ) + self.moveLabelToolButton.setShortcut('P') + self.moveLabelToolButton.action = editToolBar.addWidget(self.moveLabelToolButton) self.checkableButtons.append(self.moveLabelToolButton) self.checkableQButtonsGroup.addButton(self.moveLabelToolButton) - self.widgetsWithShortcut["Move label"] = self.moveLabelToolButton + self.widgetsWithShortcut['Move label'] = self.moveLabelToolButton self.expandLabelToolButton = QToolButton(self) self.expandLabelToolButton.setIcon(QIcon(":expandLabel.svg")) self.expandLabelToolButton.setCheckable(True) - self.expandLabelToolButton.setShortcut("E") - self.expandLabelToolButton.action = editToolBar.addWidget( - self.expandLabelToolButton - ) + self.expandLabelToolButton.setShortcut('E') + self.expandLabelToolButton.action = editToolBar.addWidget(self.expandLabelToolButton) self.expandLabelToolButton.hide() self.checkableButtons.append(self.expandLabelToolButton) self.LeftClickButtons.append(self.expandLabelToolButton) self.checkableQButtonsGroup.addButton(self.expandLabelToolButton) - self.widgetsWithShortcut["Expand/shrink label"] = self.expandLabelToolButton + self.widgetsWithShortcut['Expand/shrink label'] = self.expandLabelToolButton self.editIDbutton = QToolButton(self) self.editIDbutton.setIcon(QIcon(":edit-id.svg")) self.editIDbutton.setCheckable(True) - self.editIDbutton.setShortcut("N") + self.editIDbutton.setShortcut('N') editToolBar.addWidget(self.editIDbutton) self.checkableButtons.append(self.editIDbutton) self.checkableQButtonsGroup.addButton(self.editIDbutton) - self.widgetsWithShortcut["Edit ID"] = self.editIDbutton + self.widgetsWithShortcut['Edit ID'] = self.editIDbutton self.separateBudButton = QToolButton(self) self.separateBudButton.setIcon(QIcon(":separate-bud.svg")) self.separateBudButton.setCheckable(True) - self.separateBudButton.setShortcut("S") + self.separateBudButton.setShortcut('S') self.separateBudButton.action = editToolBar.addWidget(self.separateBudButton) self.checkableButtons.append(self.separateBudButton) self.checkableQButtonsGroup.addButton(self.separateBudButton) # self.functionsNotTested3D.append(self.separateBudButton) - self.widgetsWithShortcut["Separate objects"] = self.separateBudButton + self.widgetsWithShortcut['Separate objects'] = self.separateBudButton self.mergeIDsButton = QToolButton(self) self.mergeIDsButton.setIcon(QIcon(":merge-IDs.svg")) self.mergeIDsButton.setCheckable(True) - self.mergeIDsButton.setShortcut("M") + self.mergeIDsButton.setShortcut('M') self.mergeIDsButton.action = editToolBar.addWidget(self.mergeIDsButton) self.checkableButtons.append(self.mergeIDsButton) self.checkableQButtonsGroup.addButton(self.mergeIDsButton) # self.functionsNotTested3D.append(self.mergeIDsButton) - self.widgetsWithShortcut["Merge objects"] = self.mergeIDsButton + self.widgetsWithShortcut['Merge objects'] = self.mergeIDsButton self.keepIDsButton = QToolButton(self) self.keepIDsButton.setIcon(QIcon(":keep_objects.svg")) self.keepIDsButton.setCheckable(True) self.keepIDsButton.action = editToolBar.addWidget(self.keepIDsButton) - self.keepIDsButton.setShortcut("K") + self.keepIDsButton.setShortcut('K') self.checkableButtons.append(self.keepIDsButton) self.checkableQButtonsGroup.addButton(self.keepIDsButton) # self.functionsNotTested3D.append(self.keepIDsButton) - self.widgetsWithShortcut["Select objects to keep"] = self.keepIDsButton + self.widgetsWithShortcut['Select objects to keep'] = self.keepIDsButton self.whitelistIDsButton = QToolButton(self) self.whitelistIDsButton.setIcon(QIcon(":whitelist.svg")) self.whitelistIDsButton.setCheckable(True) - self.whitelistIDsButton.action = editToolBar.addWidget(self.whitelistIDsButton) - self.whitelistIDsButton.setShortcut("Ctrl+K") + self.whitelistIDsButton.action = editToolBar.addWidget( + self.whitelistIDsButton + ) + self.whitelistIDsButton.setShortcut('Ctrl+K') self.checkableButtons.append(self.whitelistIDsButton) self.checkableQButtonsGroup.addButton(self.whitelistIDsButton) self.LeftClickButtons.append(self.whitelistIDsButton) # self.functionsNotTested3D.append(self.whitelistIDsButton) - self.widgetsWithShortcut["Select objects to add to a tracking whitelist"] = ( + self.widgetsWithShortcut['Select objects to add to a tracking whitelist'] = ( self.whitelistIDsButton ) @@ -430,35 +442,37 @@ def gui_createToolBars(self): self.manualTrackingButton = QToolButton(self) self.manualTrackingButton.setIcon(QIcon(":manual_tracking.svg")) self.manualTrackingButton.setCheckable(True) - self.manualTrackingButton.setShortcut("T") + self.manualTrackingButton.setShortcut('T') self.checkableQButtonsGroup.addButton(self.manualTrackingButton) self.checkableButtons.append(self.manualTrackingButton) - self.widgetsWithShortcut["Manual tracking"] = self.manualTrackingButton + self.widgetsWithShortcut['Manual tracking'] = self.manualTrackingButton self.ripCellButton = QToolButton(self) self.ripCellButton.setIcon(QIcon(":rip.svg")) self.ripCellButton.setCheckable(True) - self.ripCellButton.setShortcut("D") + self.ripCellButton.setShortcut('D') self.ripCellButton.action = editToolBar.addWidget(self.ripCellButton) self.checkableButtons.append(self.ripCellButton) self.checkableQButtonsGroup.addButton(self.ripCellButton) self.functionsNotTested3D.append(self.ripCellButton) - self.widgetsWithShortcut["Annotate cell as dead"] = self.ripCellButton + self.widgetsWithShortcut['Annotate cell as dead'] = self.ripCellButton editToolBar.addAction(self.addDelRoiAction) # editToolBar.addAction(self.addDelPolyLineRoiAction) - + self.addDelPolyLineRoiAction = editToolBar.addWidget( self.addDelPolyLineRoiButton ) - self.addDelPolyLineRoiAction.roiType = "polyline" - + self.addDelPolyLineRoiAction.roiType = 'polyline' + editToolBar.addAction(self.delBorderObjAction) self.delBorderObjAction.button = editToolBar.widgetForAction( self.delBorderObjAction ) editToolBar.addAction(self.delNewObjAction) - self.delNewObjAction.button = editToolBar.widgetForAction(self.delNewObjAction) + self.delNewObjAction.button = editToolBar.widgetForAction( + self.delNewObjAction + ) self.addDelRoiAction.toolbar = editToolBar self.functionsNotTested3D.append(self.addDelRoiAction) @@ -468,13 +482,15 @@ def gui_createToolBars(self): self.delBorderObjAction.toolbar = editToolBar self.functionsNotTested3D.append(self.delBorderObjAction) - + self.delNewObjAction.toolbar = editToolBar # self.functionsNotTested3D.append(self.delNewObjAction) so id this doesnt work in 3d i dont know anymore editToolBar.addAction(self.repeatTrackingAction) - - self.manualTrackingAction = editToolBar.addWidget(self.manualTrackingButton) + + self.manualTrackingAction = editToolBar.addWidget( + self.manualTrackingButton + ) self.functionsNotTested3D.append(self.repeatTrackingAction) self.functionsNotTested3D.append(self.manualTrackingAction) @@ -487,9 +503,10 @@ def gui_createToolBars(self): self.reinitLastSegmFrameAction.toolbar = editToolBar self.functionsNotTested3D.append(self.reinitLastSegmFrameAction) + self.editLin_TreeBar = widgets.ToolBar("Lin Tree Edit", self) self.editLin_TreeBar.setContextMenuPolicy(Qt.PreventContextMenu) - + self.addToolBar(self.editLin_TreeBar) self.editLin_TreeGroup = QButtonGroup() self.editLin_TreeGroup.setExclusive(True) @@ -499,53 +516,46 @@ def gui_createToolBars(self): self.findNextMotherButton.setCheckable(True) self.editLin_TreeBar.addWidget(self.findNextMotherButton) self.editLin_TreeGroup.addButton(self.findNextMotherButton) - self.findNextMotherButton.setShortcut("F") - self.widgetsWithShortcut["Find next potential mother (lineage tree)"] = ( - self.findNextMotherButton - ) + self.findNextMotherButton.setShortcut('F') + self.widgetsWithShortcut['Find next potential mother (lineage tree)'] = self.findNextMotherButton self.unknownLineageButton = QToolButton(self) self.unknownLineageButton.setIcon(QIcon(":history.svg")) self.unknownLineageButton.setCheckable(True) self.editLin_TreeBar.addWidget(self.unknownLineageButton) self.editLin_TreeGroup.addButton(self.unknownLineageButton) - self.unknownLineageButton.setShortcut("U") - self.widgetsWithShortcut["Unknown lineage (lineage tree)"] = ( - self.unknownLineageButton - ) + self.unknownLineageButton.setShortcut('U') + self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.unknownLineageButton self.noToolLinTreeButton = QToolButton(self) self.noToolLinTreeButton.setIcon(QIcon(":arrow_cursor.svg")) self.noToolLinTreeButton.setCheckable(True) self.editLin_TreeBar.addWidget(self.noToolLinTreeButton) self.editLin_TreeGroup.addButton(self.noToolLinTreeButton) - self.noToolLinTreeButton.setShortcut("N") - self.widgetsWithShortcut["No tool (lineage tree)"] = self.noToolLinTreeButton + self.noToolLinTreeButton.setShortcut('N') + self.widgetsWithShortcut['No tool (lineage tree)'] = self.noToolLinTreeButton self.propagateLinTreeButton = QToolButton(self) self.propagateLinTreeButton.setIcon(QIcon(":compute.svg")) self.editLin_TreeBar.addWidget(self.propagateLinTreeButton) - self.propagateLinTreeButton.setShortcut("P") - self.widgetsWithShortcut["Propagate (lineage tree)"] = ( - self.propagateLinTreeButton - ) + self.propagateLinTreeButton.setShortcut('P') + self.widgetsWithShortcut['Propagate (lineage tree)'] = self.propagateLinTreeButton self.propagateLinTreeButton.clicked.connect(self.propagateLinTreeAction) self.viewLinTreeInfoButton = QToolButton(self) self.viewLinTreeInfoButton.setIcon(QIcon(":addCustomAnnotation.svg")) self.editLin_TreeBar.addWidget(self.viewLinTreeInfoButton) - self.viewLinTreeInfoButton.setShortcut("S") - self.widgetsWithShortcut["View Changes (lineage tree)"] = ( - self.viewLinTreeInfoButton - ) + self.viewLinTreeInfoButton.setShortcut('S') + self.widgetsWithShortcut['View Changes (lineage tree)'] = self.viewLinTreeInfoButton self.viewLinTreeInfoButton.clicked.connect(self.viewLinTreeInfoAction) + modes_available = [ - "Segmentation and Tracking", - "Cell cycle analysis", - "Viewer", - "Custom annotations", - "Normal division: Lineage tree", + 'Segmentation and Tracking', + 'Cell cycle analysis', + 'Viewer', + 'Custom annotations', + 'Normal division: Lineage tree' ] self.modeItems = modes_available @@ -555,7 +565,7 @@ def gui_createToolBars(self): action.setCheckable(True) self.modeActionGroup.addAction(action) self.modeMenu.addAction(action) - if mode == "Viewer": + if mode == 'Viewer': action.setChecked(True) self.editToolBar = editToolBar diff --git a/cellacdc/mixins/measurements.py b/cellacdc/mixins/measurements.py new file mode 100644 index 000000000..2a99ad883 --- /dev/null +++ b/cellacdc/mixins/measurements.py @@ -0,0 +1,151 @@ +"""View adapter for measurement setup and dialogs.""" + +from __future__ import annotations + +import pandas as pd + +from cellacdc import apps, cli, favourite_func_metrics_csv_path, widgets + + +class Measurements: + """Extracted from guiWin.""" + + def _setMetrics(self, measurementsWin): + self._measurements_kernel.set_metrics_from_set_measurements_dialog( + measurementsWin + ) + for ch in self._measurements_kernel.chNamesToProcess: + if ch not in self.notLoadedChNames: + continue + + success = self.loadFluo_cb(fluo_channels=[ch]) + if not success: + continue + + def addCombineMetric(self): + posData = self.data[self.pos_i] + isZstack = posData.SizeZ > 1 + win = apps.combineMetricsEquationDialog( + self.ch_names, isZstack, self.isSegm3D, parent=self + ) + win.sigOk.connect(self.saveCombineMetricsToPosData) + win.exec_() + win.sigOk.disconnect() + + def addCustomMetric(self, checked=False): + txt = measurements.add_metrics_instructions() + metrics_path = measurements.metrics_path + msg = widgets.myMessageBox() + msg.addShowInFileManagerButton(metrics_path, 'Show example...') + title = 'Add custom metrics instructions' + msg.information(self, title, txt, buttonsTexts=('Ok',)) + + def initMetricsToSave(self, posData): + self._measurements_kernel._init_metrics_to_save(posData) + + def initMetrics(self): + self.logger.info('Initializing measurements...') + posData = self.data[self.pos_i] + self._measurements_kernel = cli.ComputeMeasurementsKernel( + self.logger, self.log_path, False + ) + self._measurements_kernel.init_args( + posData.chNames, posData.getSegmEndname() + ) + self._measurements_kernel._init_metrics(posData, self.isSegm3D) + + def showSetMeasurements(self, checked=False, qparent=None): + qparent = qparent if qparent is not None else self + if self.measurementsWin is not None: + self.measurementsWin.show() + self.measurementsWin.raise_() + self.measurementsWin.activateWindow() + return + + try: + df_favourite_funcs = pd.read_csv(favourite_func_metrics_csv_path) + favourite_funcs = df_favourite_funcs['favourite_func_name'].to_list() + except Exception as e: + favourite_funcs = None + + posData = self.data[self.pos_i] + allPos_acdc_df_cols = set() + for _posData in self.data: + for frame_i, data_dict in enumerate(_posData.allData_li): + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + continue + + allPos_acdc_df_cols.update(acdc_df.columns) + loadedChNames = posData.setLoadedChannelNames(returnList=True) + posData.fluo_data_dict.pop(self.user_ch_name, None) + if self.user_ch_name not in loadedChNames: + loadedChNames.insert(0, self.user_ch_name) + notLoadedChNames = [c for c in self.ch_names if c not in loadedChNames] + self.notLoadedChNames = notLoadedChNames + self.measurementsWin = apps.SetMeasurementsDialog( + loadedChNames, notLoadedChNames, posData.SizeZ > 1, self.isSegm3D, + favourite_funcs=favourite_funcs, + allPos_acdc_df_cols=list(allPos_acdc_df_cols), + acdc_df_path=posData.images_path, posData=posData, + addCombineMetricCallback=self.addCombineMetric, + allPosData=self.data, + parent=qparent, + state=self.setMeasWinState + ) + self.measurementsWin.sigCancel.connect(self.setMeasurementsCancelled) + self.measurementsWin.sigClosed.connect(self.setMeasurements) + self.measurementsWin.show() + + def setMeasurementsCancelled(self): + self.measurementsWin = None + + def setMeasurements(self): + posData = self.data[self.pos_i] + if self.measurementsWin.delExistingCols: + self.logger.info('Removing existing unchecked measurements...') + delCols = self.measurementsWin.existingUncheckedColnames + delRps = self.measurementsWin.existingUncheckedRps + delCols_format = [f' * {colname}' for colname in delCols] + delRps_format = [f' * {colname}' for colname in delRps] + delCols_format.extend(delRps_format) + delCols_format = '\n'.join(delCols_format) + self.logger.info(delCols_format) + for _posData in self.data: + for frame_i, data_dict in enumerate(_posData.allData_li): + acdc_df = data_dict['acdc_df'] + if acdc_df is None: + continue + + acdc_df = acdc_df.drop(columns=delCols, errors='ignore') + for col_rp in delRps: + drop_df_rp = acdc_df.filter(regex=fr'{col_rp}.*', axis=1) + drop_cols_rp = drop_df_rp.columns + acdc_df = acdc_df.drop(columns=drop_cols_rp, errors='ignore') + _posData.allData_li[frame_i]['acdc_df'] = acdc_df + self.setMeasWinState = self.measurementsWin.state() + self.logger.info('Setting measurements...') + self._setMetrics(self.measurementsWin) + self.logger.info('Metrics successfully set.') + self.measurementsWin = None + + def saveCombineMetricsToPosData(self, window): + for posData in self.data: + equationsDict, isMixedChannels = window.getEquationsDict() + for newColName, equation in equationsDict.items(): + posData.addEquationCombineMetrics( + equation, newColName, isMixedChannels + ) + posData.saveCombineMetrics() + + if self.measurementsWin is None: + return + + self.measurementsWinState = self.measurementsWin.state() + self.measurementsWin.close() + self.showSetMeasurements() + self.measurementsWin.restoreState(self.measurementsWinState) + + def setMetricsFunc(self): + posData = self.data[self.pos_i] + self._measurements_kernel._set_metrics_func_from_posData(posData) diff --git a/cellacdc/mixins_bak/mode_controls.py b/cellacdc/mixins/mode_controls.py similarity index 84% rename from cellacdc/mixins_bak/mode_controls.py rename to cellacdc/mixins/mode_controls.py index fdaab1c9c..54adc5dea 100644 --- a/cellacdc/mixins_bak/mode_controls.py +++ b/cellacdc/mixins/mode_controls.py @@ -7,39 +7,16 @@ from cellacdc import disableWindow -class ModeControlsMixin: - """Qt-facing adapter around mode-control decisions.""" - - """Headless decisions for mode toolbar and action state.""" - - viewer_mode = "Viewer" - segmentation_mode = "Segmentation and Tracking" - snapshot_mode = "Snapshot" - cca_mode = "Cell cycle analysis" - custom_annotations_mode = "Custom annotations" - - # def setEnabledCcaToolbar(self, enabled=False): - # self.manuallyEditCcaAction.setDisabled(False) - # self.viewCcaTableAction.setDisabled(False) - # self.ccaToolBar.setVisible(enabled) - # for action in self.ccaToolBar.actions(): - # button = self.ccaToolBar.widgetForAction(action) - # action.setVisible(enabled) - # button.setEnabled(enabled) +class ModeControls: + """Extracted from guiWin.""" def blinkModeComboBox(self): if self.flag: - self.modeComboBox.setStyleSheet("background-color: orange") + self.modeComboBox.setStyleSheet('background-color: orange') else: - self.modeComboBox.setStyleSheet("background-color: none") + self.modeComboBox.setStyleSheet('background-color: none') self.flag = not self.flag - def blink_styles(self, flag: bool) -> tuple[str, bool]: - if flag: - return "background-color: orange", False - return "background-color: none", True - - @disableWindow def changeMode(self, text): self.reconnectUndoRedo() self.updateModeMenuAction() @@ -48,28 +25,28 @@ def changeMode(self, text): mode = text prevMode = self.modeComboBox.previousText() self.annotateToolbar.setVisible(False) - if prevMode != "Viewer": + if prevMode != 'Viewer': self.store_data(autosave=True) - + self.copyLostObjButton.setChecked(False) self.stopCcaIntegrityCheckerWorker() self.setAutoSaveSegmentationEnabled(False) self.setAutoSaveAnnotationsEnabled(False) - if prevMode == "Normal division: Lineage tree": + if prevMode == 'Normal division: Lineage tree': self.askLineageTreeChanges() self.lineage_tree = None self.editLin_TreeBar.setVisible(False) self.uncheckAllButtonsFromButtonGroup(self.editLin_TreeGroup) - elif prevMode == "Cell cycle analysis": + elif prevMode == 'Cell cycle analysis': self.setEnabledCcaToolbar(enabled=False) - if mode == "Segmentation and Tracking": + if mode == 'Segmentation and Tracking': self.setAutoSaveSegmentationEnabled(True) self.setSwitchViewedPlaneDisabled(True) self.trackingMenu.setDisabled(False) self.modeToolBar.setVisible(True) - self.lastTrackedFrameLabel.setText("") + self.lastTrackedFrameLabel.setText('') self.initSegmTrackMode() self.setEnabledEditToolbarButton(enabled=True) self.addExistingDelROIs() @@ -82,7 +59,7 @@ def changeMode(self, text): self.store_cca_df() self.restorePrevAnnotOptions() self.whitelistViewOGIDs(False) - elif mode == "Cell cycle analysis": + elif mode == 'Cell cycle analysis': self.setAutoSaveAnnotationsEnabled(True) self.setSwitchViewedPlaneDisabled(True) self.startCcaIntegrityCheckerWorker() @@ -103,7 +80,7 @@ def changeMode(self, text): self.removeAlldelROIsCurrentFrame() self.setAnnotOptionsCcaMode() self.clearGhost() - elif mode == "Viewer": + elif mode == 'Viewer': self.autoSaveTimer.stop() self.setSwitchViewedPlaneDisabled(False) self.modeToolBar.setVisible(True) @@ -117,7 +94,7 @@ def changeMode(self, text): self.navSpinBox.setMaximum(posData.SizeT) self.clearGhost() self.computeAllContours() - elif mode == "Custom annotations": + elif mode == 'Custom annotations': self.setAutoSaveAnnotationsEnabled(True) self.setSwitchViewedPlaneDisabled(True) self.modeToolBar.setVisible(True) @@ -130,16 +107,14 @@ def changeMode(self, text): self.clearGhost() self.doCustomAnnotation(0) self.computeAllContours() - elif mode == "Snapshot": + elif mode == 'Snapshot': self.setAutoSaveAnnotationsEnabled(True) self.setSwitchViewedPlaneDisabled(False) self.reconnectUndoRedo() self.setEnabledSnapshotMode() self.doCustomAnnotation(0) self.clearComputedContours() - elif ( - mode == "Normal division: Lineage tree" - ): # Mode activation for lineage tree + elif mode == 'Normal division: Lineage tree': # Mode activation for lineage tree # self.startLinTreeIntegrityCheckerWorker() # need to replace (postponed) proceed = self.initLinTree() self.setEnabledCcaToolbar(enabled=False) @@ -158,7 +133,7 @@ def changeMode(self, text): self.setAnnotOptionsLin_treeMode() self.clearGhost() self.editLin_TreeBar.setVisible(True) - + self.disableNonFunctionalButtons() def changeModeFromMenu(self, action): @@ -169,8 +144,8 @@ def clearComboBoxFocus(self, mode): self.sender().clearFocus() try: self.timer.stop() - self.modeComboBox.setStyleSheet("background-color: none") - except Exception: + self.modeComboBox.setStyleSheet('background-color: none') + except Exception as e: pass def disableEditingViewPlaneNotXY(self): @@ -200,30 +175,27 @@ def enableSizeSpinbox(self, enabled): self.brushSizeAction.setVisible(enabled) self.brushAutoFillAction.setVisible(enabled) self.brushAutoHideAction.setVisible(enabled) - self.brushEraserToolBar.setVisible(enabled) + self.brushEraserToolBar.setVisible(enabled) self.disableNonFunctionalButtons() - def is_cca_mode(self, mode: str) -> bool: - return mode == self.cca_mode - def nonViewerEditMenuOpened(self): mode = str(self.modeComboBox.currentText()) - if mode == "Viewer": + if mode == 'Viewer': self.startBlinkingModeCB() def reconnectUndoRedo(self): try: self.undoAction.triggered.disconnect() self.redoAction.triggered.disconnect() - except Exception: + except Exception as e: pass mode = self.modeComboBox.currentText() - if mode == "Segmentation and Tracking" or mode == "Snapshot": + if mode == 'Segmentation and Tracking' or mode == 'Snapshot': self.undoAction.triggered.connect(self.undo) self.redoAction.triggered.connect(self.redo) - elif mode == "Cell cycle analysis": + elif mode == 'Cell cycle analysis': self.undoAction.triggered.connect(self.UndoCca) - elif mode == "Custom annotations": + elif mode == 'Custom annotations': self.undoAction.triggered.connect(self.undoCustomAnnotation) else: self.undoAction.setDisabled(True) @@ -259,11 +231,11 @@ def setEnabledEditToolbarButton(self, enabled=False): self.autoSegmAction.setEnabled(enabled) self.editToolBar.setVisible(enabled) mode = self.modeComboBox.currentText() - ccaON = mode == "Cell cycle analysis" + ccaON = mode == 'Cell cycle analysis' for action in self.editToolBar.actions(): button = self.editToolBar.widgetForAction(action) # Keep binCellButton active in cca mode - if button == self.binCellButton and not enabled and ccaON: + if button==self.binCellButton and not enabled and ccaON: action.setVisible(True) button.setEnabled(True) else: @@ -294,7 +266,7 @@ def setEnabledSnapshotMode(self): self.segmVideoMenu.setDisabled(True) self.trackingMenu.setDisabled(True) self.modeToolBar.setVisible(False) - + self.relabelSequentialAction.setDisabled(False) self.postProcessSegmAction.setDisabled(False) self.autoSegmAction.setDisabled(False) @@ -308,7 +280,7 @@ def setEnabledSnapshotMode(self): action.setVisible(True) elif action == self.reInitCcaAction: action.setVisible(True) - elif action == self.assignBudMothAutoAction and posData.SizeT == 1: + elif action == self.assignBudMothAutoAction and posData.SizeT==1: action.setVisible(True) for action in self.editToolBar.actions(): button = self.editToolBar.widgetForAction(action) @@ -333,15 +305,15 @@ def setFramesSnapshotMode(self): self.realTimeTrackingToggle.label.setDisabled(True) try: self.drawIDsContComboBox.currentIndexChanged.disconnect() - except Exception: + except Exception as e: pass - + self.imgGrad.rescaleAcrossTimeAction.setDisabled(True) self.repeatTrackingAction.setDisabled(True) self.manualTrackingAction.setDisabled(True) self.logger.info('Setting GUI mode to "Snapshots"...') self.modeComboBox.clear() - self.modeComboBox.addItems(["Snapshot"]) + self.modeComboBox.addItems(['Snapshot']) self.modeComboBox.setDisabled(True) self.modeMenu.menuAction().setVisible(False) self.drawIDsContComboBox.clear() @@ -350,7 +322,7 @@ def setFramesSnapshotMode(self): self.modeToolBar.setVisible(False) self.skipToNewIdAction.setVisible(False) self.skipToNewIdAction.setDisabled(True) - self.modeComboBox.setCurrentText("Snapshot") + self.modeComboBox.setCurrentText('Snapshot') self.annotateToolbar.setVisible(True) self.labelsGrad.showNextFrameAction.setDisabled(True) self.drawIDsContComboBox.currentIndexChanged.connect( @@ -391,7 +363,7 @@ def setFramesSnapshotMode(self): self.modeComboBox.activated.disconnect() self.modeComboBox.sigTextChanged.disconnect() self.drawIDsContComboBox.currentIndexChanged.disconnect() - except Exception: + except Exception as e: pass # traceback.print_exc() self.modeComboBox.clear() @@ -401,13 +373,12 @@ def setFramesSnapshotMode(self): self.modeComboBox.sigTextChanged.connect(self.changeMode) self.modeComboBox.activated.connect(self.clearComboBoxFocus) self.drawIDsContComboBox.currentIndexChanged.connect( - self.drawIDsContComboBox_cb - ) - self.modeComboBox.setCurrentText("Viewer") + self.drawIDsContComboBox_cb) + self.modeComboBox.setCurrentText('Viewer') self.showTreeInfoCheckbox.show() self.manualBackgroundAction.setVisible(False) self.manualBackgroundAction.setDisabled(True) - self.labelsGrad.showNextFrameAction.setDisabled(False) + self.labelsGrad.showNextFrameAction.setDisabled(False) self.manualAnnotPastButton.setDisabled(False) self.manualAnnotPastButton.action.setDisabled(False) self.manualAnnotPastButton.setVisible(True) @@ -420,27 +391,16 @@ def setFramesSnapshotMode(self): self.segForLostIDsAction.setDisabled(False) self.delNewObjAction.setVisible(True) self.delNewObjAction.setDisabled(False) - + for ch, overlayItems in self.overlayLayersItems.items(): lutItem = overlayItems[1] - lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot) - - def should_start_blinking( - self, - mode: str, - *, - ruler_checked: bool = False, - ) -> bool: - return mode == self.viewer_mode and not ruler_checked - - def should_store_on_mode_change(self, previous_mode: str) -> bool: - return previous_mode != self.viewer_mode + lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot) def startBlinkingModeCB(self): try: self.timer.stop() self.stopBlinkTimer.stop() - except Exception: + except Exception as e: pass if self.rulerButton.isChecked(): return @@ -453,27 +413,18 @@ def startBlinkingModeCB(self): def stopBlinkingCB(self): self.timer.stop() - self.modeComboBox.setStyleSheet("background-color: none") + self.modeComboBox.setStyleSheet('background-color: none') def uncheckAllButtonsFromButtonGroup(self, buttonGroup): for button in buttonGroup.buttons(): if not button.isCheckable(): continue - + if not button.isChecked(): continue - + button.setChecked(False) - def undo_redo_target(self, mode: str) -> str: - if mode in {self.segmentation_mode, self.snapshot_mode}: - return "labels" - if mode == self.cca_mode: - return "cca" - if mode == self.custom_annotations_mode: - return "custom_annotations" - return "disabled" - def updateModeMenuAction(self): self.modeActionGroup.triggered.disconnect() for action in self.modeActionGroup.actions(): diff --git a/cellacdc/mixins/object_cleanup.py b/cellacdc/mixins/object_cleanup.py new file mode 100644 index 000000000..a72fa0dbf --- /dev/null +++ b/cellacdc/mixins/object_cleanup.py @@ -0,0 +1,93 @@ +"""View adapter for object cleanup workflows.""" + +from __future__ import annotations + +import numpy as np +from qtpy.QtCore import QThread + +from cellacdc import apps, widgets, workers + + +class ObjectCleanup: + """Extracted from guiWin.""" + + def delObjsOutSegmMaskActionTriggered(self): + posData = self.data[self.pos_i] + segm_files = load.get_segm_files(posData.images_path) + existingSegmEndnames = load.get_endnames( + posData.basename, segm_files + ) + selectSegmWin = widgets.QDialogListbox( + 'Select segmentation file', + 'Select segmentation file to use as ROI:\n', + existingSegmEndnames, multiSelection=False, parent=self + ) + selectSegmWin.exec_() + if selectSegmWin.cancel: + self.logger.info('Delete objects process cancelled.') + return + + selectedSegmEndname = selectSegmWin.selectedItemsText[0] + + self.startDelObjsOutSegmMaskWorker(selectedSegmEndname) + + def delObjsOutSegmMaskWorkerFinished(self, result): + posData = self.data[self.pos_i] + worker, cleared_segm_data, delIDs = result + if posData.SizeT == 1: + cleared_segm_data = cleared_segm_data[np.newaxis] + + self.update_cca_df_deletedIDs(posData, delIDs) + + current_frame_i = posData.frame_i + for frame_i, cleared_lab in enumerate(cleared_segm_data): + # Store change + posData.allData_li[frame_i]['labels'] = cleared_lab + # Get the rest of the stored metadata based on the new lab + posData.frame_i = frame_i + self.get_data() + self.store_data(autosave=False) + + # Back to current frame + posData.frame_i = current_frame_i + self.get_data() + + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + self.logger.info('Deleting objects outside of ROIs finished.') + self.titleLabel.setText( + 'Deleting objects outside of ROIs finished.', color='w' + ) + self.updateAllImages() + + def startDelObjsOutSegmMaskWorker(self, selectedSegmEndname): + self.store_data(autosave=False) + posData = self.data[self.pos_i] + segm_data = np.squeeze(self.getStoredSegmData()) + + self.progressWin = apps.QDialogWorkerProgress( + title='Deleting objects outside of ROIs', parent=self, + pbarDesc='Deleting objects outside of ROIs...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + self.thread = QThread() + self.worker = workers.DelObjectsOutsideSegmROIWorker( + selectedSegmEndname, segm_data, posData.images_path + ) + self.worker.moveToThread(self.thread) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + + self.worker.progress.connect(self.workerProgress) + self.worker.critical.connect(self.workerCritical) + self.worker.finished.connect(self.delObjsOutSegmMaskWorkerFinished) + + self.worker.debug.connect(self.workerDebug) + + self.thread.started.connect(self.worker.run) + self.thread.start() diff --git a/cellacdc/mixins_bak/object_properties.py b/cellacdc/mixins/object_properties.py similarity index 71% rename from cellacdc/mixins_bak/object_properties.py rename to cellacdc/mixins/object_properties.py index c2bce9eb0..5beea7f43 100644 --- a/cellacdc/mixins_bak/object_properties.py +++ b/cellacdc/mixins/object_properties.py @@ -9,19 +9,17 @@ from cellacdc import apps, exception_handler, html_utils, widgets -class ObjectPropertiesMixin: - """Qt-facing adapter around object properties and highlighting.""" - - """Headless decisions for object-property and highlight workflows.""" +class ObjectProperties: + """Extracted from guiWin.""" def _keepObjects(self, keepIDs=None, lab=None, rp=None): posData = self.data[self.pos_i] if lab is None: lab = posData.lab - + if rp is None: rp = posData.rp - + if keepIDs is None: keepIDs = self.keptObjectsIDs @@ -30,17 +28,16 @@ def _keepObjects(self, keepIDs=None, lab=None, rp=None): continue lab[obj.slice][obj.image] = 0 - + return lab - @exception_handler def applyKeepObjects(self): # Store undo state before modifying stuff self.storeUndoRedoStates(False) self._keepObjects() self.highlightHoverIDsKeptObj(0, 0, hoverID=0) - + posData = self.data[self.pos_i] self.update_rp() @@ -48,7 +45,7 @@ def applyKeepObjects(self): self.tracking(enforce=True, assign_unique_new_IDs=False) if self.isSnapshot: - self.fixCcaDfAfterEdit("Deleted non-selected objects") + self.fixCcaDfAfterEdit('Deleted non-selected objects') self.updateAllImages() self.keptObjectsIDs = widgets.KeptObjectIDsList( self.keptIDsLineEdit, self.keepIDsConfirmAction @@ -56,13 +53,13 @@ def applyKeepObjects(self): return else: removeAnnot = self.warnEditingWithCca_df( - "Deleted non-selected objects", get_answer=True + 'Deleted non-selected objects', get_answer=True ) if not removeAnnot: - # We can propagate changes only if the user agrees on + # We can propagate changes only if the user agrees on # removing annotations return - + self.current_frame_i = posData.frame_i if posData.frame_i > 0: txt = html_utils.paragraph(""" @@ -70,50 +67,44 @@ def applyKeepObjects(self): """) msg = widgets.myMessageBox(wrapText=False, showCentered=False) _, _, applyToPastButton = msg.question( - self, - "Propagate to past frames?", - txt, - buttonsTexts=("Cancel", "No", "Yes, apply to past frames"), + self, 'Propagate to past frames?', txt, + buttonsTexts=('Cancel', 'No', 'Yes, apply to past frames') ) if msg.cancel: return if msg.clickedButton == applyToPastButton: self.store_data() - self.logger.info("Applying keep objects to past frames...") + self.logger.info('Applying keep objects to past frames...') if not removeAnnot and posData.cca_df is not None: delIDs = [ - ID for ID in posData.cca_df.index if ID not in posData.IDs + ID for ID in posData.cca_df.index + if ID not in posData.IDs ] self.update_cca_df_deletedIDs(posData, delIDs) - + for i in tqdm(range(posData.frame_i), ncols=100): - lab = posData.allData_li[i]["labels"] - rp = posData.allData_li[i]["regionprops"] + lab = posData.allData_li[i]['labels'] + rp = posData.allData_li[i]['regionprops'] keepLab = self._keepObjects(lab=lab, rp=rp) # Store change - posData.allData_li[i]["labels"] = keepLab.copy() + posData.allData_li[i]['labels'] = keepLab.copy() # Get the rest of the stored metadata based on the new lab posData.frame_i = i self.get_data() self.store_data(autosave=False) - + posData.frame_i = self.current_frame_i self.get_data() # Ask to propagate change to all future visited frames - key = "Keep ID" + key = 'Keep ID' askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( - self.propagateChange( - self.keptObjectsIDs, - key, - doNotShow, - posData.UndoFutFrames_keepID, - posData.applyFutFrames_keepID, - force=True, - applyTrackingB=True, - ) + (UndoFutFrames, applyFutFrames, endFrame_i, + doNotShowAgain) = self.propagateChange( + self.keptObjectsIDs, key, doNotShow, + posData.UndoFutFrames_keepID, posData.applyFutFrames_keepID, + force=True, applyTrackingB=True ) if UndoFutFrames is None: @@ -126,31 +117,34 @@ def applyKeepObjects(self): posData.doNotShowAgain_keepID = doNotShowAgain posData.UndoFutFrames_keepID = UndoFutFrames posData.applyFutFrames_keepID = applyFutFrames - includeUnvisited = posData.includeUnvisitedInfo["Keep ID"] + includeUnvisited = posData.includeUnvisitedInfo['Keep ID'] if applyFutFrames: self.store_data() - self.logger.info("Applying to future frames...") - pbar = tqdm(total=posData.SizeT - posData.frame_i - 1, ncols=100) + self.logger.info('Applying to future frames...') + pbar = tqdm(total=posData.SizeT-posData.frame_i-1, ncols=100) segmSizeT = len(posData.segm_data) if not removeAnnot and posData.cca_df is not None: - delIDs = [ID for ID in posData.cca_df.index if ID not in posData.IDs] + delIDs = [ + ID for ID in posData.cca_df.index + if ID not in posData.IDs + ] self.update_cca_df_deletedIDs(posData, delIDs) - - for i in range(posData.frame_i + 1, segmSizeT): - lab = posData.allData_li[i]["labels"] + + for i in range(posData.frame_i+1, segmSizeT): + lab = posData.allData_li[i]['labels'] if lab is None and not includeUnvisited: self.enqAutosave() - pbar.update(posData.SizeT - i) + pbar.update(posData.SizeT-i) break - - rp = posData.allData_li[i]["regionprops"] + + rp = posData.allData_li[i]['regionprops'] if lab is not None: keepLab = self._keepObjects(lab=lab, rp=rp) # Store change - posData.allData_li[i]["labels"] = keepLab.copy() + posData.allData_li[i]['labels'] = keepLab.copy() # Get the rest of the stored metadata based on the new lab posData.frame_i = i self.get_data() @@ -161,10 +155,10 @@ def applyKeepObjects(self): rp = skimage.measure.regionprops(lab) keepLab = self._keepObjects(lab=lab, rp=rp) posData.segm_data[i] = keepLab - + pbar.update() pbar.close() - + # Back to current frame if applyFutFrames: posData.frame_i = self.current_frame_i @@ -174,105 +168,17 @@ def applyKeepObjects(self): self.keptIDsLineEdit, self.keepIDsConfirmAction ) - def calculate_additional_measure( - self, - *, - func_desc: str, - func: callable, - obj_data: np.ndarray, - img: np.ndarray, - lab: np.ndarray, - obj_area: int, - vol_vox: float, - ) -> float: - if func_desc in ("Concentration", "Amount"): - background_pixels = img[lab == 0] - bkgr_val = ( - float(np.median(background_pixels)) - if background_pixels.size > 0 - else 0.0 - ) - amount = func(obj_data, bkgr_val, obj_area) - if func_desc == "Concentration": - return amount / vol_vox - else: - return amount - else: - return float(func(obj_data)) - - def calculate_area_pxl( - self, - *, - is_segm_3d: bool, - z_proj_text: str, - z_lab: int, - bbox_0: int, - obj_image: np.ndarray, - obj_area: int, - ) -> int: - if is_segm_3d: - if z_proj_text == "single z-slice": - local_z = z_lab - bbox_0 - return int(np.count_nonzero(obj_image[local_z])) - else: - return int(np.count_nonzero(obj_image.max(axis=0))) - else: - return obj_area - - def calculate_area_um2( - self, - *, - area_pxl: int, - physical_size_x: float, - physical_size_y: float, - ) -> float: - return area_pxl * physical_size_y * physical_size_x - - def calculate_elongation( - self, - *, - major_axis_length: float, - minor_axis_length: float, - ) -> float: - minor_axis = max(1.0, minor_axis_length) - return major_axis_length / minor_axis - - def calculate_intensity_statistics( - self, - obj_data: np.ndarray, - ) -> dict[str, float]: - if obj_data.size == 0: - return {"min": 0.0, "max": 0.0, "mean": 0.0, "median": 0.0} - return { - "min": float(np.min(obj_data)), - "max": float(np.max(obj_data)), - "mean": float(np.mean(obj_data)), - "median": float(np.median(obj_data)), - } - - def calculate_vol_3d( - self, - *, - obj_area: int, - physical_size_x: float, - physical_size_y: float, - physical_size_z: float, - ) -> tuple[float, float]: - vol_vox_3D = obj_area - vol_fl_3D = vol_vox_3D * physical_size_z * physical_size_y * physical_size_x - return float(vol_vox_3D), float(vol_fl_3D) - def clearHighlightedID(self): self.highlightIDToolbar.setVisible(False) - + try: self.updateLostContoursImage(ax=0, delROIsIDs=None) - except Exception: + except Exception as err: pass - + if self.highlightedID == 0: return - + self.highlightedID = 0 self.guiTabControl.highlightCheckbox.setChecked(False) self.guiTabControl.highlightSearchedCheckbox.setChecked(False) @@ -290,23 +196,25 @@ def clearHighlightedText(self): pass def countObjects(self): - self.logger.info("Counting objects...") - + self.logger.info('Counting objects...') + posData = self.data[self.pos_i] if posData.SizeT > 1: return self.countObjectsTimelapse() - + return self.countObjectsSnapshots() def countObjectsCb(self, checked): if self.countObjsWindow is None: categoryCountMapper = self.countObjects() self.countObjsWindow = apps.ObjectCountDialog( - categoryCountMapper=categoryCountMapper, parent=self, data=self.data + categoryCountMapper=categoryCountMapper, + parent=self, + data=self.data ) self.countObjsWindow.sigShowEvent.connect(self.updateObjectCounts) self.countObjsWindow.sigUpdateCounts.connect(self.updateObjectCounts) - + if checked: self.countObjsWindow.show() else: @@ -316,13 +224,13 @@ def countObjectsSnapshots(self): posData = self.data[self.pos_i] if self.countObjsWindow is None: activeCategories = { - "In current position", - "In all visited positions (current session)", - "In all visited positions (previous sessions)", - "In all loaded positions", + 'In current position', + 'In all visited positions (current session)', + 'In all visited positions (previous sessions)', + 'In all loaded positions', } if self.isSegm3D: - activeCategories.add("In current z-slice") + activeCategories.add('In current z-slice') else: activeCategories = self.countObjsWindow.activeCategories() @@ -331,13 +239,13 @@ def countObjectsSnapshots(self): numObjectsVisitedPosPrevious = 0 numObjectsVisitedPosCurrent = 0 numObjectsCurrentZslice = None - if "In current z-slice" in activeCategories: + if 'In current z-slice' in activeCategories: numObjectsCurrentZslice = len( skimage.measure.regionprops(self.currentLab2D) ) - + for pos_i, _posData in enumerate(self.data): - IDs = _posData.allData_li[0]["IDs"] + IDs = _posData.allData_li[0]['IDs'] if os.path.exists(_posData.acdc_output_csv_path): numObjectsVisitedPosPrevious += len(IDs) if IDs: @@ -348,79 +256,86 @@ def countObjectsSnapshots(self): rp = skimage.measure.regionprops(lab) numObjs = len(rp) numObjectsAllPos += numObjs - + if _posData.visited: numObjectsVisitedPosCurrent += numObjs - + allCategoryCountMapper = { - "In current position": numObjectsCurrentPos, - "In all visited positions (current session)": numObjectsVisitedPosCurrent, - "In all visited positions (previous sessions)": numObjectsVisitedPosPrevious, - "In all loaded positions": numObjectsAllPos, + 'In current position': numObjectsCurrentPos, + 'In all visited positions (current session)': + numObjectsVisitedPosCurrent, + 'In all visited positions (previous sessions)': + numObjectsVisitedPosPrevious, + 'In all loaded positions': numObjectsAllPos, } if numObjectsCurrentZslice is not None: - allCategoryCountMapper["In current z-slice"] = numObjectsCurrentZslice - + allCategoryCountMapper['In current z-slice'] = ( + numObjectsCurrentZslice + ) + if self.countObjsWindow is None: - return allCategoryCountMapper - + return allCategoryCountMapper + categoryCountMapper = {} for category in activeCategories: categoryCountMapper[category] = allCategoryCountMapper[category] - + return categoryCountMapper def countObjectsTimelapse(self): if self.countObjsWindow is None: activeCategories = { - "In current frame", - "In all visited frames", - "In entire video", - "Unique objects in all visited frames", - "Unique objects in entire video", + 'In current frame', + 'In all visited frames', + 'In entire video', + 'Unique objects in all visited frames', + 'Unique objects in entire video' } else: activeCategories = self.countObjsWindow.activeCategories() - - posData = self.data[self.pos_i] - allCategoryCountMapper = posData.countObjectsInSegmTimelapse(activeCategories) + + posData = self.data[self.pos_i] + allCategoryCountMapper = posData.countObjectsInSegmTimelapse( + activeCategories + ) if self.countObjsWindow is None: - return allCategoryCountMapper - + return allCategoryCountMapper + categoryCountMapper = {} for category in activeCategories: categoryCountMapper[category] = allCategoryCountMapper[category] - + return categoryCountMapper def getHighlightedID(self): if self.highlightedID > 0: return self.highlightedID - - doHighlight = self.propsDockWidget.isVisible() and ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() + + doHighlight = ( + self.propsDockWidget.isVisible() + and ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() + ) ) if not doHighlight: return 0 - + return self.guiTabControl.propsQGBox.idSB.value() - def get_curr_lab( - self, curr_lab: np.ndarray | None = None, frame_i: int | None = None - ): + def get_curr_lab(self, curr_lab: np.ndarray|None = None, frame_i: int|None = None): """Get the current labels for the position data. Hirarchically checks: 1. If `curr_lab` is provided, use it. 2. If `posData.lab` is not None, use it. 3. If `posData.allData_li[frame_i]['labels']` exists, use it. 4. If `posData.segm_data[frame_i]` exists, use it. - + If frame_i is None, uses the current frame index from `posData`. Parameters ---------- curr_lab : np.ndarray, optional - Current labels for the position data if it should be checked + Current labels for the position data if it should be checked if its not None first, by default None frame_i : int, optional Frame index to use for retrieving labels, by default None @@ -433,56 +348,37 @@ def get_curr_lab( posData = self.data[self.pos_i] if frame_i is None: frame_i = posData.frame_i - + if curr_lab is None and frame_i == posData.frame_i: curr_lab = posData.lab - + if curr_lab is None: try: - curr_lab = posData.allData_li[frame_i]["labels"].copy() + curr_lab = posData.allData_li[frame_i]['labels'].copy() except: pass - + if curr_lab is None: try: curr_lab = posData.segm_data[frame_i].copy() except: pass - + return curr_lab - def get_object_and_background_images( - self, - *, - image: np.ndarray, - is_segm_3d: bool, - pos_data_size_z: int, - z_slice: int, - obj_slice: tuple, - obj_image: np.ndarray, - img1_image: np.ndarray | None = None, - ) -> tuple[np.ndarray, np.ndarray]: - if pos_data_size_z > 1 and not is_segm_3d: - obj_data = image[z_slice][obj_slice][obj_image] - img = img1_image if img1_image is not None else image[z_slice] - else: - obj_data = image[obj_slice][obj_image] - img = image - return obj_data, img - def grayOutHighlightedLabels(self, nonGrayedIDs=None, alpha=None): if nonGrayedIDs is None: nonGrayedIDs = set() - + posData = self.data[self.pos_i] if alpha is None: alpha = self.imgGrad.labelsAlphaSlider.value() - - if not hasattr(self, "highlightedLab"): + + if not hasattr(self, 'highlightedLab'): self.highlightedLab = np.zeros_like(self.currentLab2D) else: self.highlightedLab[:] = 0 - + lut = np.zeros((2, 4), dtype=np.uint8) for _obj in posData.rp: if not self.isObjVisible(_obj.bbox): @@ -492,11 +388,11 @@ def grayOutHighlightedLabels(self, nonGrayedIDs=None, alpha=None): _slice = self.getObjSlice(_obj.slice) _objMask = self.getObjImage(_obj.image, _obj.bbox) self.highlightedLab[_slice][_objMask] = _obj.label - rgb = self.lut[_obj.label].copy() + rgb = self.lut[_obj.label].copy() lut[1, :-1] = rgb # Set alpha to 0.7 - lut[1, -1] = 178 - + lut[1, -1] = 178 + return lut def grayOutOverlaySegm(self, ax=0): @@ -504,12 +400,12 @@ def grayOutOverlaySegm(self, ax=0): how = self.drawIDsContComboBox.currentText() else: how = self.getAnnotateHowRightImage() - - isOverlaySegmActive = how.find("segm. masks") != -1 + + isOverlaySegmActive = how.find('segm. masks') != -1 if not isOverlaySegmActive: return - - self.grayOutHighlightedLabels() + + grayedLut = self.grayOutHighlightedLabels() def highlightHoverID(self, x, y, hoverID=None): if hoverID is None: @@ -520,7 +416,7 @@ def highlightHoverID(self, x, y, hoverID=None): if hoverID == 0: return - + posData = self.data[self.pos_i] objIdx = posData.IDs_idxs[hoverID] obj = posData.rp[objIdx] @@ -535,25 +431,25 @@ def highlightHoverIDsKeptObj(self, x, y, hoverID=None): return self.highlightSearchedID(hoverID, greyOthers=False) - + if hoverID == 0 and self.highlightedID == 0: return - + if hoverID == 0 and self.highlightedID != 0: self.clearHighlightedKeepIDs() for ID in self.keptObjectsIDs: self.highlightLabelID(ID) return - + posData = self.data[self.pos_i] try: objIdx = posData.IDs_idxs[hoverID] - except KeyError: - return - + except KeyError as err: + return + obj = posData.rp[objIdx] self.goToZsliceSearchedID(obj) - + for ID in self.keptObjectsIDs: self.highlightLabelID(ID) @@ -571,18 +467,18 @@ def highlightIDonHoverCheckBoxToggled(self, checked): self.updatePropsWidget(self.highlightedID) self.updateAllImages() - def highlightLabelID(self, ID, ax=0): + def highlightLabelID(self, ID, ax=0): posData = self.data[self.pos_i] try: obj = posData.rp[posData.IDs_idxs[ID]] except KeyError: return - + self.textAnnot[ax].highlightObject(obj) def highlightSearchedID(self, ID, force=False, greyOthers=True): self.highlightIDToolbar.setIDNoSignals(ID) - + if ID == 0: self.highlightIDToolbar.setVisible(False) return @@ -590,17 +486,20 @@ def highlightSearchedID(self, ID, force=False, greyOthers=True): if ID == self.highlightedID and not force: return - doHighlight = self.propsDockWidget.isVisible() and ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() + doHighlight = ( + self.propsDockWidget.isVisible() + and ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() + ) ) if doHighlight: self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() ID = self.highlightedID - + if self.highlightedID > 0: self.clearHighlightedText() - + self.searchedIDitemRight.setData([], []) self.searchedIDitemLeft.setData([], []) @@ -612,52 +511,53 @@ def highlightSearchedID(self, ID, force=False, greyOthers=True): objIdx = posData.IDs_idxs.get(ID) if objIdx is None: return - + obj = posData.rp[objIdx] isObjVisible = self.isObjVisible(obj.bbox) if not isObjVisible: return - + if greyOthers: self.textAnnot[0].grayOutAnnotations() self.textAnnot[1].grayOutAnnotations() how_ax1 = self.drawIDsContComboBox.currentText() how_ax2 = self.getAnnotateHowRightImage() - isOverlaySegm_ax1 = how_ax1.find("segm. masks") != -1 - isOverlaySegm_ax2 = how_ax2.find("segm. masks") != -1 + isOverlaySegm_ax1 = how_ax1.find('segm. masks') != -1 + isOverlaySegm_ax2 = how_ax2.find('segm. masks') != -1 alpha = self.imgGrad.labelsAlphaSlider.value() - + if isOverlaySegm_ax1 or isOverlaySegm_ax2: grayedLut = self.grayOutHighlightedLabels( - nonGrayedIDs={obj.label}, alpha=alpha + nonGrayedIDs={obj.label}, + alpha=alpha ) - + cont = None contours = None if isOverlaySegm_ax1: self.highLightIDLayerImg1.setLookupTable(grayedLut) - self.highLightIDLayerImg1.setImage(self.highlightedLab) - self.labelsLayerImg1.setOpacity(alpha / 3) + self.highLightIDLayerImg1.setImage(self.highlightedLab) + self.labelsLayerImg1.setOpacity(alpha/3) else: contours = self.getObjContours(obj, all_external=True) for cont in contours: - self.searchedIDitemLeft.addPoints(cont[:, 0] + 0.5, cont[:, 1] + 0.5) - + self.searchedIDitemLeft.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) + if isOverlaySegm_ax2: self.highLightIDLayerRightImage.setLookupTable(grayedLut) self.highLightIDLayerRightImage.setImage(self.highlightedLab) - self.labelsLayerRightImg.setOpacity(alpha / 3) + self.labelsLayerRightImg.setOpacity(alpha/3) else: if contours is None: contours = self.getObjContours(obj, all_external=True) for cont in contours: - self.searchedIDitemRight.addPoints(cont[:, 0] + 0.5, cont[:, 1] + 0.5) + self.searchedIDitemRight.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) # Gray out all IDs excpet searched one - lut = self.lut.copy() # [:max(posData.IDs)+1] - lut[:ID] = lut[:ID] * 0.2 - lut[ID + 1 :] = lut[ID + 1 :] * 0.2 + lut = self.lut.copy() # [:max(posData.IDs)+1] + lut[:ID] = lut[:ID]*0.2 + lut[ID+1:] = lut[ID+1:]*0.2 self.img2.setLookupTable(lut) # Highlight text @@ -676,13 +576,13 @@ def highlightSearchedIDcheckBoxToggled(self, checked): if obj_idx is None: return obj = posData.rp[objIdx] - self.goToZsliceSearchedID(obj) + self.goToZsliceSearchedID(obj) def initKeepObjLabelsLayers(self): lut = np.zeros((len(self.lut), 4), dtype=np.uint8) - lut[:, :-1] = self.lut - lut[:, -1:] = 255 - lut[0] = [0, 0, 0, 0] + lut[:,:-1] = self.lut + lut[:,-1:] = 255 + lut[0] = [0,0,0,0] self.keepIDsTempLayerLeft.setLevels([0, len(lut)]) self.keepIDsTempLayerLeft.setLookupTable(lut) @@ -691,7 +591,9 @@ def initPixelSizePropsDockWidget(self): PhysicalSizeX = posData.PhysicalSizeX PhysicalSizeY = posData.PhysicalSizeY PhysicalSizeZ = posData.PhysicalSizeZ - self.guiTabControl.initPixelSize(PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) + self.guiTabControl.initPixelSize( + PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ + ) def keepIDs_cb(self, checked): if checked: @@ -701,7 +603,7 @@ def keepIDs_cb(self, checked): self.annotIDsCheckbox.setChecked(True) self.setDrawAnnotComboboxText() self.uncheckLeftClickButtons(None) - self.initKeepObjLabelsLayers() + self.initKeepObjLabelsLayers() self.setAllIDs() else: # restore items to non-grayed out @@ -715,7 +617,7 @@ def keepIDs_cb(self, checked): self.ax2_lostObjImageItem.setOpacity(1.0) self.ax1_lostTrackedObjImageItem.setOpacity(1.0) self.ax2_lostTrackedObjImageItem.setOpacity(1.0) - + self.keepIDsToolbar.setVisible(checked) self.highlightedIDopts = None self.keptObjectsIDs = widgets.KeptObjectIDsList( @@ -728,14 +630,14 @@ def propsWidgetIDvalueChanged(self, ID): if ID == 0: self.updatePropsWidget(int(ID)) return - + propsQGBox = self.guiTabControl.propsQGBox obj_idx = posData.IDs_idxs.get(ID) if obj_idx is None: - s = f"Object ID {int(ID):d} does not exist" + s = f'Object ID {int(ID):d} does not exist' propsQGBox.notExistingIDLabel.setText(s) return - + obj = posData.rp[obj_idx] self.goToZsliceSearchedID(obj) self.updatePropsWidget(int(ID)) @@ -744,7 +646,7 @@ def removeHighlightLabelID(self, IDs=None, ax=0): posData = self.data[self.pos_i] if IDs is None: IDs = posData.IDs - + for ID in IDs: obj = posData.rp[posData.IDs_idxs[ID]] self.textAnnot[ax].removeHighlightObject(obj) @@ -755,14 +657,14 @@ def setAllIDs(self, onlyVisited=False): for frame_i in range(len(posData.segm_data)): if frame_i >= len(posData.allData_li): break - lab = posData.allData_li[frame_i]["labels"] + lab = posData.allData_li[frame_i]['labels'] if lab is None and onlyVisited: break - + if lab is None: rp = skimage.measure.regionprops(posData.segm_data[frame_i]) else: - rp = posData.allData_li[frame_i]["regionprops"] + rp = posData.allData_li[frame_i]['regionprops'] posData.allIDs.update([obj.label for obj in rp]) def setHighlighedIDfromToolbar(self, ID: int): @@ -778,36 +680,6 @@ def setHighlightID(self, doHighlight): self.updatePropsWidget(self.highlightedID) self.updateAllImages() - def should_highlight_props_id( - self, - *, - dock_visible: bool, - highlight_checked: bool, - searched_highlight_checked: bool, - ) -> bool: - return dock_visible and (highlight_checked or searched_highlight_checked) - - def should_show_3d_property_controls(self, is_segm_3d: bool) -> bool: - return is_segm_3d - - def should_update_object_counts( - self, - *, - window_exists: bool, - is_visible: bool, - live_preview_checked: bool, - ) -> bool: - return window_exists and is_visible and live_preview_checked - - def should_update_props_widget( - self, - *, - dock_visible: bool, - object_id: int, - current_props_id: int, - ) -> bool: - return dock_visible and object_id != 0 and object_id != current_props_id - def showPropsDockWidget(self, checked=False): if self.showPropsDockButton.isExpand: self.propsDockWidget.setVisible(False) @@ -829,26 +701,6 @@ def showPropsDockWidget(self, checked=False): self.propsDockWidget.setEnabled(True) self.updateAllImages() - def snapshot_default_categories(self, *, is_segm_3d: bool) -> set[str]: - categories = { - "In current position", - "In all visited positions (current session)", - "In all visited positions (previous sessions)", - "In all loaded positions", - } - if is_segm_3d: - categories.add("In current z-slice") - return categories - - def timelapse_default_categories(self) -> set[str]: - return { - "In current frame", - "In all visited frames", - "In entire video", - "Unique objects in all visited frames", - "Unique objects in entire video", - } - def updateKeepIDs(self, IDs): posData = self.data[self.pos_i] @@ -863,7 +715,7 @@ def updateKeepIDs(self, IDs): if ID not in self.keptObjectsIDs: self.keptObjectsIDs.append(ID, editText=False) self.highlightLabelID(ID) - + # Check if IDs in current keptObjectsIDs are present in IDs from line edit for ID in self.keptObjectsIDs: if ID not in posData.allIDs: @@ -871,7 +723,7 @@ def updateKeepIDs(self, IDs): continue if ID not in IDs: self.keptObjectsIDs.remove(ID, editText=False) - + self.updateTempLayerKeepIDs() if isAnyIDnotExisting: self.keptIDsLineEdit.warnNotExistingID() @@ -881,13 +733,13 @@ def updateKeepIDs(self, IDs): def updateObjectCounts(self): if self.countObjsWindow is None: return - + if not self.countObjsWindow.isVisible(): return - + if not self.countObjsWindow.livePreviewCheckbox.isChecked(): return - + categoryCountMapper = self.countObjects() self.countObjsWindow.updateCounts(categoryCountMapper) @@ -899,17 +751,18 @@ def updatePropsWidget(self, ID, fromHover=False): self.currentPropsID = -1 ID = int(ID) - + update = ( - self.propsDockWidget.isVisible() and ID != 0 and ID != self.currentPropsID + self.propsDockWidget.isVisible() + and ID != 0 and ID!=self.currentPropsID ) if not update: return posData = self.data[self.pos_i] - if not hasattr(posData, "rp"): - return - + if not hasattr(posData, 'rp'): + return + if posData.rp is None: self.update_rp() @@ -925,25 +778,25 @@ def updatePropsWidget(self, ID, fromHover=False): obj_idx = posData.IDs_idxs.get(ID) if obj_idx is None: - s = f"Object ID {int(ID):d} does not exist" + s = f'Object ID {int(ID):d} does not exist' propsQGBox.notExistingIDLabel.setText(s) return - propsQGBox.notExistingIDLabel.setText("") + propsQGBox.notExistingIDLabel.setText('') self.currentPropsID = ID propsQGBox.idSB.setValue(ID) - + doHighlight = ( self.guiTabControl.highlightCheckbox.isChecked() or self.guiTabControl.highlightSearchedCheckbox.isChecked() ) if doHighlight: self.highlightSearchedID(ID) - + obj = posData.rp[obj_idx] if self.isSegm3D: - if self.zProjComboBox.currentText() == "single z-slice": + if self.zProjComboBox.currentText() == 'single z-slice': local_z = self.z_lab() - obj.bbox[0] area_pxl = np.count_nonzero(obj.image[local_z]) else: @@ -957,26 +810,29 @@ def updatePropsWidget(self, ID, fromHover=False): PhysicalSizeX = pixelSizeQGBox.pixelWidthWidget.value() PhysicalSizeY = pixelSizeQGBox.pixelHeightWidget.value() PhysicalSizeZ = pixelSizeQGBox.voxelDepthWidget.value() + + yx_pxl_to_um2 = PhysicalSizeY*PhysicalSizeX - yx_pxl_to_um2 = PhysicalSizeY * PhysicalSizeX - - area_um2 = area_pxl * yx_pxl_to_um2 + area_um2 = area_pxl*yx_pxl_to_um2 propsQGBox.cellAreaUm2DSB.setValue(area_um2) if self.isSegm3D: PhysicalSizeZ = posData.PhysicalSizeZ vol_vox_3D = obj.area - vol_fl_3D = vol_vox_3D * PhysicalSizeZ * PhysicalSizeY * PhysicalSizeX + vol_fl_3D = vol_vox_3D*PhysicalSizeZ*PhysicalSizeY*PhysicalSizeX propsQGBox.cellVolVox3D_SB.setValue(vol_vox_3D) propsQGBox.cellVolFl3D_DSB.setValue(vol_fl_3D) - vol_vox, vol_fl = _calc_rot_vol(obj, PhysicalSizeY, PhysicalSizeX) + vol_vox, vol_fl = _calc_rot_vol( + obj, PhysicalSizeY, PhysicalSizeX + ) propsQGBox.cellVolVoxSB.setValue(int(vol_vox)) propsQGBox.cellVolFlDSB.setValue(vol_fl) + minor_axis_length = max(1, obj.minor_axis_length) - elongation = obj.major_axis_length / minor_axis_length + elongation = obj.major_axis_length/minor_axis_length propsQGBox.elongationDSB.setValue(elongation) solidity = obj.solidity @@ -988,11 +844,11 @@ def updatePropsWidget(self, ID, fromHover=False): intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox selectedChannel = intensMeasurQGBox.channelCombobox.currentText() - + try: _, filename = self.getPathFromChName(selectedChannel, posData) image = posData.ol_data_dict[filename][posData.frame_i] - except Exception: + except Exception as e: image = posData.img_data[posData.frame_i] if posData.SizeZ > 1 and not self.isSegm3D: @@ -1010,11 +866,11 @@ def updatePropsWidget(self, ID, fromHover=False): funcDesc = intensMeasurQGBox.additionalMeasCombobox.currentText() func = intensMeasurQGBox.additionalMeasCombobox.functions[funcDesc] - if funcDesc == "Concentration": + if funcDesc == 'Concentration': bkgrVal = np.median(img[posData.lab == 0]) amount = func(objData, bkgrVal, obj.area) - value = amount / vol_vox - elif funcDesc == "Amount": + value = amount/vol_vox + elif funcDesc == 'Amount': bkgrVal = np.median(img[posData.lab == 0]) amount = func(objData, bkgrVal, obj.area) value = amount @@ -1026,7 +882,7 @@ def updatePropsWidget(self, ID, fromHover=False): def updateTempLayerKeepIDs(self): if not self.keepIDsButton.isChecked(): return - + keptLab = np.zeros_like(self.currentLab2D) posData = self.data[self.pos_i] diff --git a/cellacdc/mixins_bak/object_search.py b/cellacdc/mixins/object_search.py similarity index 61% rename from cellacdc/mixins_bak/object_search.py rename to cellacdc/mixins/object_search.py index 00ddcc6f0..2920bfdfc 100644 --- a/cellacdc/mixins_bak/object_search.py +++ b/cellacdc/mixins/object_search.py @@ -8,63 +8,21 @@ from cellacdc import apps, html_utils, widgets, workers -class ObjectSearchMixin: - """Qt-facing adapter around object-search commands.""" - - """Headless object-search operations.""" - - def _startSearchIDworker(self, searchedID): - pos_data = self.data[self.pos_i] - - desc = "Searching ID in all frames..." - - self.progressWin = apps.QDialogWorkerProgress( - title=desc, parent=self.mainWin, pbarDesc=desc - ) - self.progressWin.mainPbar.setMaximum(pos_data.SizeT) - self.progressWin.show(self.app) - - self.searchIDthread = QThread() - self.searchIDworker = workers.SimpleWorker( - pos_data, - self.searchIDworkerCallback, - func_args=(searchedID,), - ) - self.searchIDworker.frame_i_found = None - self.searchIDworker.moveToThread(self.searchIDthread) - - self.searchIDworker.signals.finished.connect(self.searchIDthread.quit) - self.searchIDworker.signals.finished.connect(self.searchIDworker.deleteLater) - self.searchIDthread.finished.connect(self.searchIDthread.deleteLater) - - self.searchIDworker.signals.critical.connect(self.searchIDworkerCritical) - self.searchIDworker.signals.initProgressBar.connect(self.workerInitProgressbar) - self.searchIDworker.signals.progressBar.connect(self.workerUpdateProgressbar) - self.searchIDworker.signals.progress.connect(self.workerProgress) - self.searchIDworker.signals.finished.connect(self.searchIDworkerFinished) - - self.searchIDthread.started.connect(self.searchIDworker.run) - self.searchIDthread.start() - - self.searchIDworkerLoop = QEventLoop() - self.searchIDworkerLoop.exec_() - - return self.searchIDworker.frame_i_found +class ObjectSearch: + """Extracted from guiWin.""" def askGoToFrameFoundID(self, searchedID, frame_i_found): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(f""" - Object ID {searchedID} was found at frame n. {frame_i_found + 1}.

- Do you want to go to frame n. {frame_i_found + 1}. + Object ID {searchedID} was found at frame n. {frame_i_found+1}.

+ Do you want to go to frame n. {frame_i_found+1}. """) noButton, yesButton = msg.information( - self, - f"ID {searchedID} found at frame n. {frame_i_found + 1}", - txt, + self, f'ID {searchedID} found at frame n. {frame_i_found+1}', txt, buttonsTexts=( - "No, stay on current frame", - f"Yes, go to frame n. {frame_i_found + 1}", - ), + 'No, stay on current frame', + f'Yes, go to frame n. {frame_i_found+1}' + ) ) return msg.clickedButton == yesButton @@ -72,10 +30,10 @@ def findID(self, checked=False, ID=None): posData = self.data[self.pos_i] if ID is None: searchIDdialog = apps.FindIDDialog( - title="Search object by ID", - msg="Enter object ID to find and highlight", + title='Search object by ID', + msg='Enter object ID to find and highlight', parent=self, - isInteger=True, + isInteger=True ) searchIDdialog.exec_() if searchIDdialog.cancel: @@ -84,7 +42,7 @@ def findID(self, checked=False, ID=None): searchedID = searchIDdialog.EntryID else: searchedID = ID - + if searchedID in posData.IDs: self.goToObjectID(searchedID) return @@ -92,35 +50,35 @@ def findID(self, checked=False, ID=None): if posData.SizeT == 1: self.warnIDnotFound(searchedID) return - + if searchedID in posData.lost_IDs: self.goToLostObjectID(searchedID) return - + tracked_lost_IDs = self.getTrackedLostIDs() if searchedID in tracked_lost_IDs: self.goToAcceptedLostObjectID(searchedID) return - - self.logger.info(f"Searching ID {searchedID} in other frames...") - + + self.logger.info(f'Searching ID {searchedID} in other frames...') + frame_i_found = self.startSearchIDworker(searchedID) if frame_i_found is None: self.warnIDnotFound(searchedID) return - + self.logger.info( - f"Object ID {searchedID} found at frame n. {frame_i_found + 1}." + f'Object ID {searchedID} found at frame n. {frame_i_found+1}.' ) proceed = self.askGoToFrameFoundID(searchedID, frame_i_found) if not proceed: return - + posData.frame_i = frame_i_found self.get_data() self.updateAllImages() self.updateScrollbars() - + self.goToObjectID(searchedID) def findNextNewIdWorkerFinished(self, next_frame_i): @@ -128,61 +86,50 @@ def findNextNewIdWorkerFinished(self, next_frame_i): self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - - self.navSpinBox.setValue(next_frame_i + 1) + + self.navSpinBox.setValue(next_frame_i+1) self.framesScrollBarReleased() - def find_frame_with_id( - self, - pos_data, - searched_id: int, - *, - progress_callback: Callable[[int], None] | None = None, - ) -> int | None: - return find_frame_with_id( - pos_data.segm_data, - pos_data.allData_li, - searched_id, - progress_callback=progress_callback, - ) - def goToAcceptedLostObjectID(self, acceptedLostID): posData = self.data[self.pos_i] frame_i = posData.frame_i - prev_rp = posData.allData_li[frame_i - 1]["regionprops"] - prev_IDs_idxs = posData.allData_li[frame_i - 1]["IDs_idxs"] + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] obj = prev_rp[prev_IDs_idxs[acceptedLostID]] self.goToZsliceSearchedID(obj) - + self.updateLostTrackedContoursImage(tracked_lost_IDs=[acceptedLostID]) def goToLostObjectID(self, lostID, color=(255, 165, 0, 255)): posData = self.data[self.pos_i] frame_i = posData.frame_i - prev_rp = posData.allData_li[frame_i - 1]["regionprops"] - prev_IDs_idxs = posData.allData_li[frame_i - 1]["IDs_idxs"] + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] obj = prev_rp[prev_IDs_idxs[lostID]] self.goToZsliceSearchedID(obj) - + imageItem = self.getLostObjImageItem(0) - if not hasattr(self, "lostObjContoursImage"): + thickness = 1 + if not hasattr(self, 'lostObjContoursImage'): self.initLostObjContoursImage() else: - self.lostObjContoursImage[:] = 0 + self.lostObjContoursImage[:] = 0 contours = [] obj_contours = self.getObjContours(obj, all_external=True) contours.extend(obj_contours) - + self.addLostObjsToLostObjImage(obj, lostID) - self.drawLostObjContoursImage(imageItem, contours, thickness=2, color=color) + self.drawLostObjContoursImage( + imageItem, contours, thickness=2, color=color + ) def goToObjectID(self, ID): posData = self.data[self.pos_i] objIdx = posData.IDs_idxs[ID] obj = posData.rp[objIdx] self.goToZsliceSearchedID(obj) - + self.highlightSearchedID(ID) propsQGBox = self.guiTabControl.propsQGBox propsQGBox.idSB.setValue(ID) @@ -195,19 +142,19 @@ def searchIDworkerCallback(self, posData, searchedID): for frame_i in range(len(posData.segm_data)): if frame_i >= len(posData.allData_li): break - lab = posData.allData_li[frame_i]["labels"] + lab = posData.allData_li[frame_i]['labels'] if lab is None: rp = skimage.measure.regionprops(posData.segm_data[frame_i]) IDs = set([obj.label for obj in rp]) else: - IDs = posData.allData_li[frame_i]["IDs"] - + IDs = posData.allData_li[frame_i]['IDs'] + if searchedID in IDs: frame_i_found = frame_i break - + self.searchIDworker.signals.progressBar.emit(1) - + self.searchIDworker.frame_i_found = frame_i_found def searchIDworkerCritical(self, error): @@ -219,18 +166,17 @@ def searchIDworkerFinished(self): self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - + self.searchIDworkerLoop.exit() def skipForwardToNewID(self): self.progressWin = apps.QDialogWorkerProgress( - title="Searching the next frame with a new object", - parent=self, - pbarDesc="Searching the next frame with a new object...", + title='Searching the next frame with a new object', parent=self, + pbarDesc=f'Searching the next frame with a new object...' ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - + self.startFindNextNewIdWorker() def startFindNextNewIdWorker(self): @@ -238,7 +184,7 @@ def startFindNextNewIdWorker(self): self._thread = QThread() self.findNextNewIdWorker = workers.FindNextNewIdWorker(posData, self) self.findNextNewIdWorker.moveToThread(self._thread) - + self.findNextNewIdWorker.signals.finished.connect(self._thread.quit) self.findNextNewIdWorker.signals.finished.connect( self.findNextNewIdWorker.deleteLater @@ -255,46 +201,62 @@ def startFindNextNewIdWorker(self): self.findNextNewIdWorker.signals.progressBar.connect( self.workerUpdateProgressbar ) - self.findNextNewIdWorker.signals.critical.connect(self.workerCritical) + self.findNextNewIdWorker.signals.critical.connect( + self.workerCritical + ) self._thread.started.connect(self.findNextNewIdWorker.run) self._thread.start() - @disableWindow def startSearchIDworker(self, searchedID): posData = self.data[self.pos_i] - - desc = "Searching ID in all frames..." - + + desc = 'Searching ID in all frames...' + self.progressWin = apps.QDialogWorkerProgress( title=desc, parent=self.mainWin, pbarDesc=desc ) self.progressWin.mainPbar.setMaximum(posData.SizeT) self.progressWin.show(self.app) - + self.searchIDthread = QThread() self.searchIDworker = workers.SimpleWorker( - posData, self.searchIDworkerCallback, func_args=(searchedID,) + posData, self.searchIDworkerCallback, + func_args=(searchedID, ) ) self.searchIDworker.frame_i_found = None self.searchIDworker.moveToThread(self.searchIDthread) - - self.searchIDworker.signals.finished.connect(self.searchIDthread.quit) - self.searchIDworker.signals.finished.connect(self.searchIDworker.deleteLater) + + self.searchIDworker.signals.finished.connect( + self.searchIDthread.quit + ) + self.searchIDworker.signals.finished.connect( + self.searchIDworker.deleteLater + ) self.searchIDthread.finished.connect(self.searchIDthread.deleteLater) - - self.searchIDworker.signals.critical.connect(self.searchIDworkerCritical) - self.searchIDworker.signals.initProgressBar.connect(self.workerInitProgressbar) - self.searchIDworker.signals.progressBar.connect(self.workerUpdateProgressbar) - self.searchIDworker.signals.progress.connect(self.workerProgress) - self.searchIDworker.signals.finished.connect(self.searchIDworkerFinished) - + + self.searchIDworker.signals.critical.connect( + self.searchIDworkerCritical + ) + self.searchIDworker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.searchIDworker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.searchIDworker.signals.progress.connect( + self.workerProgress + ) + self.searchIDworker.signals.finished.connect( + self.searchIDworkerFinished + ) + self.searchIDthread.started.connect(self.searchIDworker.run) self.searchIDthread.start() - + self.searchIDworkerLoop = QEventLoop() self.searchIDworkerLoop.exec_() - + return self.searchIDworker.frame_i_found def warnIDnotFound(self, searchedID): @@ -302,4 +264,4 @@ def warnIDnotFound(self, searchedID): txt = html_utils.paragraph(f""" Object ID {searchedID} was not found.

""") - msg.warning(self, f"ID {searchedID} not found", txt) + msg.warning(self, f'ID {searchedID} not found', txt) diff --git a/cellacdc/mixins_bak/points_layers.py b/cellacdc/mixins/points_layers.py similarity index 70% rename from cellacdc/mixins_bak/points_layers.py rename to cellacdc/mixins/points_layers.py index 937df7abe..6b61746c0 100644 --- a/cellacdc/mixins_bak/points_layers.py +++ b/cellacdc/mixins/points_layers.py @@ -20,12 +20,8 @@ from cellacdc import _warnings, apps, colors, exception_handler, html_utils, widgets -class PointsLayersMixin: - """Qt-facing adapter around points-layer workflows.""" - - """Headless decisions for points-layer GUI workflows.""" - - recovery_tolerance_seconds = 15 +class PointsLayers: + """Extracted from guiWin.""" def addClickedPoint(self, action, x, y, id): x, y = round(x, 2), round(y, 2) @@ -33,10 +29,10 @@ def addClickedPoint(self, action, x, y, id): pointsDataPos = action.pointsData.get(self.pos_i) if pointsDataPos is None: action.pointsData[self.pos_i] = {} - + framePointsData = action.pointsData[self.pos_i].get(posData.frame_i) if action.snapToMax: - radius = round(action.pointSize / 2) + radius = round(action.pointSize/2) rr, cc = skimage.draw.disk((round(y), round(x)), radius) idx_max = (self.img1.image[rr, cc]).argmax() y, x = rr[idx_max], cc[idx_max] @@ -45,31 +41,31 @@ def addClickedPoint(self, action, x, y, id): if posData.SizeZ > 1: zSlice = self.zSliceScrollBar.sliderPosition() action.pointsData[self.pos_i][posData.frame_i] = { - zSlice: {"x": [x], "y": [y], "id": [id]} + zSlice: {'x': [x], 'y': [y], 'id': [id]} } else: action.pointsData[self.pos_i][posData.frame_i] = { - "x": [x], - "y": [y], - "id": [id], + 'x': [x], 'y': [y], 'id': [id] } else: if posData.SizeZ > 1: zSlice = self.zSliceScrollBar.sliderPosition() z_data = framePointsData.get(zSlice) if z_data is None: - framePointsData[zSlice] = {"x": [x], "y": [y], "id": [id]} + framePointsData[zSlice] = {'x': [x], 'y': [y], 'id': [id]} else: - framePointsData[zSlice]["x"].append(x) - framePointsData[zSlice]["y"].append(y) - framePointsData[zSlice]["id"].append(id) - action.pointsData[self.pos_i][posData.frame_i] = framePointsData + framePointsData[zSlice]['x'].append(x) + framePointsData[zSlice]['y'].append(y) + framePointsData[zSlice]['id'].append(id) + action.pointsData[self.pos_i][posData.frame_i] = ( + framePointsData + ) else: pointsDataPos = action.pointsData[self.pos_i] framePointsData = pointsDataPos[posData.frame_i] - framePointsData["x"].append(x) - framePointsData["y"].append(y) - framePointsData["id"].append(id) + framePointsData['x'].append(x) + framePointsData['y'].append(y) + framePointsData['id'].append(id) self.markPointsLayerDirty(action=action) @@ -80,7 +76,7 @@ def addPointsByClickingButtonToggled(self, checked=True, sender=None): action = sender.action action.scatterItem.setVisible(False) return - + self.disconnectLeftClickButtons() self.uncheckLeftClickButtons(sender) self.connectLeftClickButtons() @@ -93,40 +89,34 @@ def addPointsByClickingScatterItemHoverEntered(self, item, points, event): point = points[0] point_id = point.data() toolButton = item.action.button - toolButton.rightClickIDSpinbox.prevId = toolButton.rightClickIDSpinbox.value() + toolButton.rightClickIDSpinbox.prevId = ( + toolButton.rightClickIDSpinbox.value() + ) toolButton.rightClickIDSpinbox.setValue(point_id) - @exception_handler def addPointsLayer(self, toolbar=None): proceed = self.checkLoadedTableIds(toolbar) - + if self.addPointsWin.cancel or not proceed: self.addPointsWin = None - self.logger.info("Adding points layer cancelled.") + self.logger.info('Adding points layer cancelled.') return - + if toolbar is None: toolbar = self.pointsLayersToolbar - + symbol = self.addPointsWin.symbol color = self.addPointsWin.color pointSize = self.addPointsWin.pointSize - zRadius = int((self.addPointsWin.zHeight - 1) / 2) - r, g, b, a = color.getRgb() + zRadius = int((self.addPointsWin.zHeight-1)/2) + r,g,b,a = color.getRgb() scatterItem = widgets.PointsScatterPlotItem( - [], - [], - ax=self.ax1, - symbol=symbol, - pxMode=False, - size=pointSize, - brush=pg.mkBrush(color=(r, g, b, 100)), - pen=pg.mkPen(width=2, color=(r, g, b)), - hoverable=True, - hoverBrush=pg.mkBrush((r, g, b, 200)), - tip=None, - show_data_as_tip=True, + [], [], ax=self.ax1, symbol=symbol, pxMode=False, size=pointSize, + brush=pg.mkBrush(color=(r,g,b,100)), + pen=pg.mkPen(width=2, color=(r,g,b)), + hoverable=True, hoverBrush=pg.mkBrush((r,g,b,200)), + tip=None, show_data_as_tip=True ) self.ax1.addItem(scatterItem) @@ -139,21 +129,26 @@ def addPointsLayer(self, toolbar=None): toolButton.toggled.connect(self.pointLayerToolbuttonToggled) toolButton.sigEditAppearance.connect(self.editPointsLayerAppearance) toolButton.sigShowIdsToggled.connect(self.showPointsLayerIdsToggled) - toolButton.sigRemove.connect(partial(self.removePointsLayer, toolbar=toolbar)) - + toolButton.sigRemove.connect( + partial(self.removePointsLayer, toolbar=toolbar) + ) + action = toolbar.addWidget(toolButton) action.state = self.addPointsWin.state() toolButton.action = action - action.brushColor = (r, g, b, 100) + action.brushColor = (r,g,b,100) action.brushColorId0 = ( *colors.hex_to_rgb( - colors.lighten_color(np.array(action.brushColor) / 255, 0.3) - ), - 100, + colors.lighten_color( + np.array(action.brushColor)/255, 0.3 + ) + ), 100 + ) + action.penColor = (r,g,b) + action.penColorId0 = colors.lighten_color( + np.array(action.penColor)/255, 0.3 ) - action.penColor = (r, g, b) - action.penColorId0 = colors.lighten_color(np.array(action.penColor) / 255, 0.3) action.pointSize = pointSize action.zRadius = zRadius action.button = toolButton @@ -162,46 +157,52 @@ def addPointsLayer(self, toolbar=None): action.layerType = self.addPointsWin.layerType action.layerTypeIdx = self.addPointsWin.layerTypeIdx action.loadedDf = self.addPointsWin.loadedDf - self.data[self.pos_i] + posData = self.data[self.pos_i] action.pointsData = {} action.pointsData[self.pos_i] = self.addPointsWin.pointsData action.snapToMax = False action.loadedDfInfo = self.addPointsWin.loadedDfInfo self.setPointsLayerLoadedDfEndanme(action) - - if self.addPointsWin.layerType.startswith("Click to annotate point"): + + if self.addPointsWin.layerType.startswith('Click to annotate point'): action.snapToMax = self.addPointsWin.snapToMaxToggle.isChecked() isLoadedDf = self.addPointsWin.clickEntryIsLoadedDf - self.setupAddPointsByClicking(toolButton, isLoadedDf, toolbar=toolbar) + self.setupAddPointsByClicking( + toolButton, isLoadedDf, toolbar=toolbar + ) if self.addPointsWin.autoPilotToggle.isChecked(): self.autoPilotZoomToObjToggle.setChecked(True) - + weighingChannel = self.addPointsWin.weighingChannel self.loadPointsLayerWeighingData(action, weighingChannel) self.drawPointsLayers() - + if toolbar == self.promptSegmentPointsLayerToolbar: self.promptSegmentPointsLayerToolbar.isPointsLayerInit = True self.magicPromptsToolbar.clearPointsAction.setDisabled(False) self.magicPromptsToolbar.clearPointsActionOnZoom.setDisabled(False) - QTimer.singleShot(200, self.magicPromptsToolbar.selectModelAction.trigger) - + QTimer.singleShot( + 200, self.magicPromptsToolbar.selectModelAction.trigger + ) + self.addPointsWin = None def addPointsLayerTriggered(self, checked=False, toolbar=None): if toolbar is None: toolbar = self.pointsLayersToolbar - + if self.addPointsWin is not None: - self.logger.info("Add points layer window is already open. Cannot add now.") + self.logger.info( + 'Add points layer window is already open. Cannot add now.' + ) return - + onlyMouseClicks = toolbar == self.promptSegmentPointsLayerToolbar posData = self.data[self.pos_i] self.addPointsWin = apps.AddPointsLayerDialog( - channelNames=posData.chNames, - imagesPath=posData.images_path, + channelNames=posData.chNames, + imagesPath=posData.images_path, hideCentroidsSection=onlyMouseClicks, hideWeightedCentroidsSection=onlyMouseClicks, hideFromTableSection=onlyMouseClicks, @@ -209,15 +210,15 @@ def addPointsLayerTriggered(self, checked=False, toolbar=None): hideWithMouseClicksSection=False, parent=self, ) - cmap = matplotlib.colormaps["gist_rainbow"] + cmap = matplotlib.colormaps['gist_rainbow'] i = np.random.default_rng(seed=123).uniform() for action in toolbar.actions()[1:]: - if not hasattr(action, "layerTypeIdx"): + if not hasattr(action, 'layerTypeIdx'): continue - rgb = [round(c * 255) for c in cmap(i)][:3] + rgb = [round(c*255) for c in cmap(i)][:3] self.addPointsWin.appearanceGroupbox.colorButton.setColor(rgb) break - + self.addPointsWin.sigCriticalReadTable.connect(self.logger.info) self.addPointsWin.sigLoadedTable.connect(self.logLoadedTablePointsLayer) self.addPointsWin.sigClosed.connect( @@ -229,119 +230,121 @@ def addPointsLayerTriggered(self, checked=False, toolbar=None): self.addPointsWin.show() if self.addPointsWin.clickEntryRadiobutton.isChecked(): QTimer.singleShot( - 200, + 200, partial( self.addPointsWin.sigCheckClickEntryTableEndnameExists.emit, - self.addPointsWin.clickEntryTableEndname.text(), - False, - ), + self.addPointsWin.clickEntryTableEndname.text(), + False + ) ) - def askLoadNewerRecoveryClickEntryDfs(self, tableEndName, newer_recovery_filepaths): + def askLoadNewerRecoveryClickEntryDfs( + self, tableEndName, newer_recovery_filepaths + ): if not newer_recovery_filepaths: return False num_tables = len(newer_recovery_filepaths) filepath, recovery_filepath = newer_recovery_filepaths[0] - main_timestamp = datetime.fromtimestamp(os.path.getmtime(filepath)).strftime( - "%a %d. %b. %y - %H:%M:%S" - ) + main_timestamp = datetime.fromtimestamp( + os.path.getmtime(filepath) + ).strftime('%a %d. %b. %y - %H:%M:%S') recovery_timestamp = datetime.fromtimestamp( os.path.getmtime(recovery_filepath) - ).strftime("%a %d. %b. %y - %H:%M:%S") + ).strftime('%a %d. %b. %y - %H:%M:%S') if num_tables == 1: text = html_utils.paragraph( - f"A newer recovery version of {tableEndName}.csv " - "was found.

" - f"Main table save date: {main_timestamp}
" - f"Recovery save date: {recovery_timestamp}

" - "Do you want to load the newer recovery version?" + f'A newer recovery version of {tableEndName}.csv ' + 'was found.

' + f'Main table save date: {main_timestamp}
' + f'Recovery save date: {recovery_timestamp}

' + 'Do you want to load the newer recovery version?' ) else: text = html_utils.paragraph( - f"Newer recovery versions of {tableEndName}.csv " - f"were found for {num_tables} positions.

" - f"Example main table save date: {main_timestamp}
" - f"Example recovery save date: {recovery_timestamp}

" - "Do you want to load the newer recovery version where available?" + f'Newer recovery versions of {tableEndName}.csv ' + f'were found for {num_tables} positions.

' + f'Example main table save date: {main_timestamp}
' + f'Example recovery save date: {recovery_timestamp}

' + 'Do you want to load the newer recovery version where available?' ) msg = widgets.myMessageBox(wrapText=False) _, yesButton, _ = msg.warning( - self.addPointsWin, - "Newer recovery table found", - text, - buttonsTexts=("Cancel", "Yes, load newer recovery", "No, load main table"), + self.addPointsWin, 'Newer recovery table found', text, + buttonsTexts=('Cancel', 'Yes, load newer recovery', 'No, load main table') ) return msg.clickedButton == yesButton def askSaveAddedPoints(self): msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph("Do you want to save the annotated points?") + txt = html_utils.paragraph( + 'Do you want to save the annotated points?' + ) _, noButton, yesButton = msg.question( - self, "Save?", txt, buttonsTexts=("Cancel", "No", "Yes") + self, 'Save?', txt, + buttonsTexts=('Cancel', 'No', 'Yes') ) if msg.clickedButton != yesButton: return - + for toolbar in self.pointsLayersToolbars: for action in self.pointsLayersToolbar.actions(): try: - if "Save annotated" in action.text(): + if 'Save annotated' in action.text(): action.trigger() - except Exception: + except Exception as err: pass def askSavePointsLayer(self, action): toolButton = action.button tableEndName = toolButton.clickEntryTableEndName saveAction = toolButton.saveAction - + txt = html_utils.paragraph(f""" - Do you want to save the points you added + Do you want to save the points you added (table called {tableEndName}.csv)? - """) + """ + ) msg = widgets.myMessageBox(wrapText=False) _, _, saveButton = msg.question( - self, - "Save points layer?", - txt, - buttonsTexts=("Cancel", "No, do not save", "Yes, save points"), + self, 'Save points layer?', txt, + buttonsTexts=('Cancel', 'No, do not save', 'Yes, save points') ) if msg.clickedButton == saveButton: self.savePointsAddedByClicking(saveAction.saveToolbutton, None) - + return msg.cancel def autoPilotZoomToObjToggled(self, checked): if not checked: self.zoomOut() return - + posData = self.data[self.pos_i] if not posData.IDs: - self.logger.info("There are no objects in current segmentation mask") + self.logger.info('There are no objects in current segmentation mask') return self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) self.zoomToObj(posData.rp[0]) def autoZoomNextObj(self): self.sender().setValue(self.sender().value() - 1) - self.pointsLayerAutoPilot("next") + self.pointsLayerAutoPilot('next') self.setFocusMain() self.setFocusGraphics() def autoZoomPrevObj(self): self.sender().setValue(self.sender().value() + 1) - self.pointsLayerAutoPilot("prev") + self.pointsLayerAutoPilot('prev') self.setFocusMain() self.setFocusGraphics() def buttonAddPointsByClickingActive(self): for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, "layerTypeIdx"): + if not hasattr(action, 'layerTypeIdx'): continue if action.layerTypeIdx == 4 and action.button.isChecked(): return action.button @@ -349,14 +352,14 @@ def buttonAddPointsByClickingActive(self): def checkAskSavePointsLayers(self): for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, "layerTypeIdx"): + if not hasattr(action, 'layerTypeIdx'): continue if action.layerTypeIdx != 4: continue - + scatterItem = action.scatterItem xx, yy = scatterItem.getData() - + if xx is None or len(xx) == 0: toolButton = action.button tableEndName = toolButton.clickEntryTableEndName @@ -365,21 +368,21 @@ def checkAskSavePointsLayers(self): for pos_i, _posData in enumerate(self.data): if pos_i == self.pos_i: continue - + df = _posData.clickEntryPointsDfs.get(tableEndName) if df is None: continue - + are_there_points_to_save = True break - + if not are_there_points_to_save: continue - + cancel = self.askSavePointsLayer(action) if cancel: return cancel - + return False def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading): @@ -389,21 +392,19 @@ def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading): if os.path.exists(filepath): doesTableExists = True break - + if not doesTableExists: return - + if not forceLoading: msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - f"The table {tableEndName}.csv already exists!

" - "Do you want to load it?" + f'The table {tableEndName}.csv already exists!

' + 'Do you want to load it?' ) _, yesButton, _ = msg.warning( - self.addPointsWin, - "Table exists!", - txt, - buttonsTexts=("Cancel", "Yes, load it", "No, let me enter a new name"), + self.addPointsWin, 'Table exists!', txt, + buttonsTexts=('Cancel', 'Yes, load it', 'No, let me enter a new name') ) if msg.clickedButton != yesButton: return @@ -415,21 +416,23 @@ def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading): tableEndName, newer_recovery_filepaths ) - self.loadClickEntryDfs(tableEndName, loadRecoveryIfNewer=load_recovery_if_newer) + self.loadClickEntryDfs( + tableEndName, loadRecoveryIfNewer=load_recovery_if_newer + ) def checkLoadedTableIds(self, toolbar): if toolbar != self.promptSegmentPointsLayerToolbar: return True - + for posData in self.data: for tableEndName, df in posData.clickEntryPointsDfs.items(): - for point_id in df["id"].values: + for point_id in df['id'].values: if point_id in posData.IDs_idxs: proceed = self.warnAddingPointWithExistingId( point_id, table_endname=tableEndName ) return proceed - + return True def clearPointsLayers(self): @@ -437,25 +440,14 @@ def clearPointsLayers(self): for action in toolbar.actions()[1:]: try: action.scatterItem.clear() - except Exception: + except Exception as e: continue - def click_entry_table_filename( - self, - basename: str, - table_endname: str, - ) -> str: - table_basename = basename if basename.endswith("_") else f"{basename}_" - filename = f"{table_basename}{table_endname}" - if not filename.endswith(".csv"): - filename = f"{filename}.csv" - return filename - def drawPointsLayers(self, computePointsLayers=True): posData = self.data[self.pos_i] for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, "layerTypeIdx"): + if not hasattr(action, 'layerTypeIdx'): continue if action.layerTypeIdx < 2 and computePointsLayers: @@ -463,72 +455,76 @@ def drawPointsLayers(self, computePointsLayers=True): if not action.button.isChecked(): continue - + frames = action.pointsData.get(self.pos_i, set()) if posData.frame_i not in frames: if action.layerTypeIdx != 4: self.logger.info( - f"Frame number {posData.frame_i + 1} does not have any " + f'Frame number {posData.frame_i+1} does not have any ' f'"{action.layerType}" point to display.' ) continue - + framePointsData = action.pointsData[self.pos_i][posData.frame_i] - - if "x" not in framePointsData: + + if 'x' not in framePointsData: # 3D points zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == "single z-slice" and posData.SizeZ > 1 + isZslice = ( + zProjHow == 'single z-slice' and posData.SizeZ > 1 + ) if isZslice: xx, yy, ids, data = [], [], [], [] zSlice = self.zSliceScrollBar.sliderPosition() zRadius = action.zRadius - zRange = range(zSlice - zRadius, zSlice + zRadius + 1) + zRange = range(zSlice-zRadius, zSlice+zRadius+1) for z in zRange: z_data = framePointsData.get(z) if z_data is None: continue - xx.extend(z_data["x"]) - yy.extend(z_data["y"]) - ids.extend(z_data["id"]) + xx.extend(z_data['x']) + yy.extend(z_data['y']) + ids.extend(z_data['id']) try: - data.extend(z_data["data"]) - except KeyError: + data.extend(z_data['data']) + except KeyError as err: # data is needed only for loaded tables - pass + pass else: xx, yy, ids, data = [], [], [], [] # z-projection --> draw all points for z, z_data in framePointsData.items(): - xx.extend(z_data["x"]) - yy.extend(z_data["y"]) - ids.extend(z_data["id"]) + xx.extend(z_data['x']) + yy.extend(z_data['y']) + ids.extend(z_data['id']) try: - data.extend(z_data["data"]) - except KeyError: + data.extend(z_data['data']) + except KeyError as err: # data is needed only for loaded tables - pass + pass else: # 2D segmentation - xx = framePointsData["x"] - yy = framePointsData["y"] - ids = framePointsData["id"] + xx = framePointsData['x'] + yy = framePointsData['y'] + ids = framePointsData['id'] try: - data = framePointsData["data"] - except KeyError: + data = framePointsData['data'] + except KeyError as err: # data is needed only for loaded tables - pass - + pass + brushColors = [ - action.brushColor if id != 0 else action.brushColorId0 for id in ids + action.brushColor if id != 0 else action.brushColorId0 + for id in ids ] brushes = [pg.mkBrush(color) for color in brushColors] - + pensColor = [ - action.penColor if id != 0 else action.penColorId0 for id in ids + action.penColor if id != 0 else action.penColorId0 + for id in ids ] pens = [pg.mkPen(color) for color in pensColor] - + if action.layerTypeIdx == 2: # For loaded table show the rest of the table as a tooltip data = data @@ -536,12 +532,14 @@ def drawPointsLayers(self, computePointsLayers=True): else: data = ids show_data_as_tip = False - - xx = np.array(xx) # + 0.5 - yy = np.array(yy) # + 0.5 - + + xx = np.array(xx) # + 0.5 + yy = np.array(yy) # + 0.5 + action.scatterItem.show_data_as_tip = show_data_as_tip - action.scatterItem.setData(xx, yy, data=data, brush=brushes, pen=pens) + action.scatterItem.setData( + xx, yy, data=data, brush=brushes, pen=pens + ) def editPointsLayerAppearance(self, button): win = apps.EditPointsLayerAppearanceDialog(parent=self) @@ -549,22 +547,22 @@ def editPointsLayerAppearance(self, button): win.exec_() if win.cancel: return - + symbol = win.symbol color = win.color pointSize = win.pointSize - zRadius = int((win.zHeight - 1) / 2) - r, g, b, a = color.getRgb() + zRadius = int((win.zHeight-1)/2) + r,g,b,a = color.getRgb() scatterItem = button.action.scatterItem - scatterItem.opts["hoverBrush"] = pg.mkBrush((r, g, b, 200)) + scatterItem.opts['hoverBrush'] = pg.mkBrush((r,g,b,200)) scatterItem.setSymbol(symbol, update=False) - scatterItem.setBrush(pg.mkBrush(color=(r, g, b, 100)), update=False) - scatterItem.setPen(pg.mkPen(width=2, color=(r, g, b)), update=False) + scatterItem.setBrush(pg.mkBrush(color=(r,g,b,100)), update=False) + scatterItem.setPen(pg.mkPen(width=2, color=(r,g,b)), update=False) scatterItem.setSize(pointSize, update=True) - - button.action.brushColor = (r, g, b, 100) - button.action.penColor = (r, g, b) + + button.action.brushColor = (r,g,b,100) + button.action.penColor = (r,g,b) button.action.pointSize = pointSize button.action.zRadius = zRadius @@ -574,57 +572,51 @@ def flushDirtyPointsLayersAutosave(self): if not self.dirtyPointsLayerTableEndNames: return - for tableEndName in tuple( - self.dirtyPointsLayerTableEndNames - ): # avoid runtime error - self.savePointsAddedByClickingFromEndname(tableEndName, recovery=True) + for tableEndName in tuple(self.dirtyPointsLayerTableEndNames): # avoid runtime error + self.savePointsAddedByClickingFromEndname( + tableEndName, recovery=True + ) self.dirtyPointsLayerTableEndNames.clear() def getAddedPointId( - self, - isMagicPrompts, - addPointsByClickingButton, - right_click, - left_click, - middle_click, - ): + self, isMagicPrompts, addPointsByClickingButton, + right_click, left_click, middle_click + ): action = addPointsByClickingButton.action if right_click: id = addPointsByClickingButton.rightClickIDSpinbox.value() elif left_click: - id = addPointsByClickingButton.pointIdSpinbox.value() + id = addPointsByClickingButton.pointIdSpinbox.value() id = self.getClickedPointNewId( - action, - id, - addPointsByClickingButton.pointIdSpinbox, - isMagicPrompts=isMagicPrompts, + action, id, addPointsByClickingButton.pointIdSpinbox, + isMagicPrompts=isMagicPrompts ) if isMagicPrompts: proceed = self.warnAddingPointWithExistingId(id) if not proceed: return - + addPointsByClickingButton.pointIdSpinbox.setValue(id) elif middle_click: id = 0 - + return id def getCentroidsPointsData(self, action): # Centroids (either weighted or not) - # NOTE: if user requested to draw from table we load that in + # NOTE: if user requested to draw from table we load that in # apps.AddPointsLayerDialog.ok_cb() posData = self.data[self.pos_i] action.pointsData[self.pos_i] = {posData.frame_i: {}} - if hasattr(action, "weighingData"): + if hasattr(action, 'weighingData'): lab = posData.lab img = action.weighingData[self.pos_i][posData.frame_i] rp = skimage.measure.regionprops(lab, intensity_image=img) - attr = "weighted_centroid" + attr = 'weighted_centroid' else: rp = posData.rp - attr = "centroid" + attr = 'centroid' for i, obj in enumerate(rp): centroid = getattr(obj, attr) if len(centroid) == 3: @@ -632,25 +624,25 @@ def getCentroidsPointsData(self, action): z_int = round(zc) if z_int not in action.pointsData[self.pos_i][posData.frame_i]: action.pointsData[self.pos_i][posData.frame_i][z_int] = { - "x": [xc], - "y": [yc], - "id": [obj.label], + 'x': [xc], 'y': [yc], 'id': [obj.label] } else: z_data = action.pointsData[self.pos_i][posData.frame_i][z_int] - z_data["x"].append(xc) - z_data["y"].append(yc) - z_data["id"].append(obj.label) + z_data['x'].append(xc) + z_data['y'].append(yc) + z_data['id'].append(obj.label) else: yc, xc = centroid - if "y" not in action.pointsData[self.pos_i][posData.frame_i]: - action.pointsData[self.pos_i][posData.frame_i]["y"] = [yc] - action.pointsData[self.pos_i][posData.frame_i]["x"] = [xc] - action.pointsData[self.pos_i][posData.frame_i]["id"] = [obj.label] + if 'y' not in action.pointsData[self.pos_i][posData.frame_i]: + action.pointsData[self.pos_i][posData.frame_i]['y'] = [yc] + action.pointsData[self.pos_i][posData.frame_i]['x'] = [xc] + action.pointsData[self.pos_i][posData.frame_i]['id'] = ( + [obj.label] + ) else: - action.pointsData[self.pos_i][posData.frame_i]["y"].append(yc) - action.pointsData[self.pos_i][posData.frame_i]["x"].append(xc) - action.pointsData[self.pos_i][posData.frame_i]["id"].append( + action.pointsData[self.pos_i][posData.frame_i]['y'].append(yc) + action.pointsData[self.pos_i][posData.frame_i]['x'].append(xc) + action.pointsData[self.pos_i][posData.frame_i]['id'].append( obj.label ) @@ -663,9 +655,7 @@ def getClickEntryNewerRecoveryFilepaths(self, tableEndName): if not os.path.exists(filepath) or not os.path.exists(recovery_filepath): continue - if ( - os.path.getmtime(recovery_filepath) <= os.path.getmtime(filepath) + 15 - ): # add a 15 second tolerance + if os.path.getmtime(recovery_filepath) <= os.path.getmtime(filepath) + 15: # add a 15 second tolerance continue newer_recovery_filepaths.append((filepath, recovery_filepath)) @@ -673,33 +663,35 @@ def getClickEntryNewerRecoveryFilepaths(self, tableEndName): return newer_recovery_filepaths def getClickEntryTableFilepaths(self, posData, tableEndName): - if posData.basename.endswith("_"): + if posData.basename.endswith('_'): basename = posData.basename else: - basename = f"{posData.basename}_" + basename = f'{posData.basename}_' - csv_filename = f"{basename}{tableEndName}" - if not csv_filename.endswith(".csv"): - csv_filename = f"{csv_filename}.csv" + csv_filename = f'{basename}{tableEndName}' + if not csv_filename.endswith('.csv'): + csv_filename = f'{csv_filename}.csv' filepath = os.path.join(posData.images_path, csv_filename) - recovery_filepath = os.path.join(posData.images_path, "recovery", csv_filename) + recovery_filepath = os.path.join( + posData.images_path, 'recovery', csv_filename + ) return filepath, recovery_filepath def getClickedPointNewId( - self, action, current_id, pointIdSpinbox, isMagicPrompts=False - ): - removed_id = getattr(pointIdSpinbox, "removedId", None) + self, action, current_id, pointIdSpinbox, isMagicPrompts=False + ): + removed_id = getattr(pointIdSpinbox, 'removedId', None) if removed_id is not None: pointIdSpinbox.removedId = None return removed_id - + posData = self.data[self.pos_i] if isMagicPrompts: is_already_new = self.isPointIdAlreadyNew(current_id, action) if is_already_new: return current_id - + new_ID = self.setBrushID(return_val=True) new_id = max(current_id, new_ID) + 1 return new_id @@ -707,18 +699,18 @@ def getClickedPointNewId( pointsDataPos = action.pointsData.get(self.pos_i) if pointsDataPos is None: return 1 - + framePointsData = pointsDataPos.get(posData.frame_i) if framePointsData is None: return 1 if posData.SizeZ > 1: new_id = 1 for z_data in framePointsData.values(): - max_id = max(z_data.get("id", 0), default=0) + 1 + max_id = max(z_data.get('id', 0), default=0) + 1 if max_id > new_id: new_id = max_id else: - new_id = max(framePointsData.get("id", 0), default=0) + 1 + new_id = max(framePointsData.get('id', 0), default=0) + 1 if current_id >= new_id: return current_id return new_id @@ -727,25 +719,25 @@ def isPointIdAlreadyNew(self, point_id, action): posData = self.data[self.pos_i] if point_id in posData.IDs_idxs: return False - + is_ID = point_id in posData.IDs_idxs pointsDataPos = action.pointsData.get(self.pos_i) if pointsDataPos is None: return not is_ID - + framePointsData = pointsDataPos.get(posData.frame_i) if framePointsData is None: return not is_ID - - if "x" not in framePointsData: + + if 'x' not in framePointsData: is_id_already_added = False for z, z_data in framePointsData.items(): - if point_id in z_data["id"]: + if point_id in z_data['id']: is_id_already_added = True break else: - is_id_already_added = point_id in framePointsData["id"] - + is_id_already_added = point_id in framePointsData['id'] + is_already_new = not is_ID and not is_id_already_added return is_already_new @@ -758,10 +750,13 @@ def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): if loadRecoveryIfNewer: recovery_exists = os.path.exists(recovery_filepath) main_exists = os.path.exists(filepath) - if recovery_exists and ( - not main_exists - or os.path.getmtime(recovery_filepath) - > os.path.getmtime(filepath) + 15 + if ( + recovery_exists + and ( + not main_exists + or os.path.getmtime(recovery_filepath) + > os.path.getmtime(filepath) + 15 + ) ): filepath = recovery_filepath elif not main_exists: @@ -772,19 +767,19 @@ def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): self.logger.info(f'Loading points from "{filepath}"...') df = pd.read_csv(filepath) - if "id" not in df.columns: - df["id"] = range(1, len(df) + 1) + if 'id' not in df.columns: + df['id'] = range(1, len(df)+1) posData.clickEntryPointsDfs[tableEndName] = df - + try: self.addPointsWin.loadButton.confirmAction() - except Exception: + except Exception as err: pass def loadPointsLayerWeighingData(self, action, weighingChannel): if not weighingChannel: return - + self.logger.info(f'Loading "{weighingChannel}" weighing data...') action.weighingData = [] for p, posData in enumerate(self.data): @@ -795,10 +790,10 @@ def loadPointsLayerWeighingData(self, action, weighingChannel): path, filename = self.getPathFromChName(weighingChannel, posData) if path is None: - self.criticalFluoChannelNotFound(weighingChannel, posData) + self.criticalFluoChannelNotFound(weighingChannel, posData) action.weighingData = [] return - + if filename in posData.fluo_data_dict: # Weighing data already loaded as additional fluo channel wData = posData.fluo_data_dict[filename] @@ -810,17 +805,23 @@ def loadPointsLayerWeighingData(self, action, weighingChannel): action.weighingData.append(wData) def logLoadedTablePointsLayer(self, df, filename: str): - separator = "-" * 100 + separator = f'-'*100 header = f'First 10 rows of loaded table - "{filename}":' - footer = f"Number of points: {len(df)}" - text = f"{separator}\n{header}\n\n{df.head(10)}\n\n{footer}\n{separator}" + footer = f'Number of points: {len(df)}' + text = ( + f'{separator}\n' + f'{header}\n\n' + f'{df.head(10)}\n\n' + f'{footer}\n' + f'{separator}' + ) if filename: - text = f"{text}\nFilename: {filename}" + text = f'{text}\nFilename: {filename}' self.logger.info(text) def markPointsLayerDirty(self, tableEndName=None, action=None): if tableEndName is None and action is not None: - tableEndName = getattr(action, "clickEntryTableEndName", None) + tableEndName = getattr(action, 'clickEntryTableEndName', None) if tableEndName is None: addPointsByClickingButton = self.buttonAddPointsByClickingActive() @@ -841,77 +842,75 @@ def pointsLayerAutoPilot(self, direction): posData = self.data[self.pos_i] if not posData.IDs: return - + try: ID_idx = posData.IDs_idxs[ID] - if direction == "next": + if direction == 'next': nextID_idx = ID_idx + 1 else: nextID_idx = ID_idx - 1 obj = posData.rp[nextID_idx] - except Exception: - self.logger.info("Auto-pilot restarted from first ID") + except Exception as e: + self.logger.info( + f'Auto-pilot restarted from first ID' + ) obj = posData.rp[0] - + self.autoPilotZoomToObjSpinBox.setValue(obj.label) self.zoomToObj(obj) def pointsLayerClicksDfsToData(self, posData, toolbar=None): if toolbar is None: toolbar = self.pointsLayersToolbar - + for action in toolbar.actions()[1:]: - if not hasattr(action, "button"): + if not hasattr(action, 'button'): continue - - if not hasattr(action.button, "clickEntryTableEndName"): + + if not hasattr(action.button, 'clickEntryTableEndName'): continue tableEndName = action.button.clickEntryTableEndName action.pointsData[self.pos_i] = {} if posData.clickEntryPointsDfs.get(tableEndName) is None: continue - + df = posData.clickEntryPointsDfs[tableEndName] - - if posData.SizeZ > 1 and df["z"].isna().any(): + + if posData.SizeZ > 1 and df['z'].isna().any(): self.warnLoadedPointsTableIsNot3D(tableEndName) return - - for frame_i, df_frame in df.groupby("frame_i"): + + for frame_i, df_frame in df.groupby('frame_i'): action.pointsData[self.pos_i][frame_i] = {} if posData.SizeZ > 1: - for z, df_zlice in df_frame.groupby("z"): - xx = df_zlice["x"].to_list() - yy = df_zlice["y"].to_list() - ids = df_zlice["id"].to_list() + for z, df_zlice in df_frame.groupby('z'): + xx = df_zlice['x'].to_list() + yy = df_zlice['y'].to_list() + ids = df_zlice['id'].to_list() action.pointsData[self.pos_i][frame_i][z] = { - "x": xx, - "y": yy, - "id": ids, + 'x': xx, 'y': yy, 'id': ids } else: - xx = df_frame["x"].to_list() - yy = df_frame["y"].to_list() - ids = df_frame["id"].to_list() + xx = df_frame['x'].to_list() + yy = df_frame['y'].to_list() + ids = df_frame['id'].to_list() action.pointsData[self.pos_i][frame_i] = { - "x": xx, - "y": yy, - "id": ids, + 'x': xx, 'y': yy, 'id': ids } def pointsLayerDataToDf(self, posData, getOnlyActive=False, toolbar=None): df = None for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, "button"): + if not hasattr(action, 'button'): continue - if not hasattr(action.button, "clickEntryTableEndName"): + if not hasattr(action.button, 'clickEntryTableEndName'): continue - + tableEndName = action.button.clickEntryTableEndName if getOnlyActive and not action.button.isChecked(): continue - + df = toolbar.fromActionToDataFrame( action, posData, isSegm3D=self.isSegm3D ) @@ -925,28 +924,27 @@ def pointsLayerLoadedDfsToData(self): posData = self.data[self.pos_i] for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, "loadedDfInfo"): + if not hasattr(action, 'loadedDfInfo'): continue - + if action.loadedDfInfo is None: continue - - endname = action.loadedDfInfo.get("endname") + + endname = action.loadedDfInfo.get('endname') if endname is None: continue - - filename = f"{posData.basename}{endname}" + + filename = f'{posData.basename}{endname}' filepath = os.path.join(posData.images_path, filename) if not os.path.exists(filepath): action.pointsData[self.pos_i] = {} - - df = load.load_df_points_layer(filepath) - action.pointsData[self.pos_i] = load.loaded_df_to_points_data( - df, - action.loadedDfInfo["t"], - action.loadedDfInfo["z"], - action.loadedDfInfo["y"], - action.loadedDfInfo["x"], + + df = load.load_df_points_layer(filepath) + action.pointsData[self.pos_i] = ( + load.loaded_df_to_points_data( + df, action.loadedDfInfo['t'], action.loadedDfInfo['z'], + action.loadedDfInfo['y'], action.loadedDfInfo['x'] + ) ) self.logLoadedTablePointsLayer(df, filename=filename) @@ -954,10 +952,10 @@ def pointsLayerToggled(self, checked): if not checked: for action in self.pointsLayersToolbar.actions(): try: - if "Save annotated" in action.text(): + if 'Save annotated' in action.text(): self.askSaveAddedPoints() break - except Exception: + except Exception as err: pass self.pointsLayersToolbar.setVisible(checked) self.autoPilotZoomToObjToolbar.setVisible(checked) @@ -978,37 +976,36 @@ def removeClickedPoints(self, action, points): framePointsData = action.pointsData[self.pos_i][posData.frame_i] if posData.SizeZ > 1: zProjHow = self.zProjComboBox.currentText() - if zProjHow != "single z-slice": + if zProjHow != 'single z-slice': _warnings.warnCannotAddRemovePointsProjection() return zSlice = self.zSliceScrollBar.sliderPosition() else: zSlice = None - + removed_ids = [] for point in points: pos = point.pos() x, y = pos.x(), pos.y() if zSlice is not None: zSliceRad = action.zRadius - sliceFramePointsData = [ - framePointsData[z] - for z in range(zSlice - zSliceRad, zSlice + zSliceRad + 1) - if z in framePointsData.keys() - ] + sliceFramePointsData = [framePointsData[z] for z in range( + zSlice-zSliceRad, zSlice+zSliceRad+1 + ) if z in framePointsData.keys()] else: sliceFramePointsData = [framePointsData] + for sliceFramePointsData in sliceFramePointsData: - if point.data() in sliceFramePointsData["id"]: - sliceFramePointsData["x"].remove(x) - sliceFramePointsData["y"].remove(y) - sliceFramePointsData["id"].remove(point.data()) + if point.data() in sliceFramePointsData['id']: + sliceFramePointsData['x'].remove(x) + sliceFramePointsData['y'].remove(y) + sliceFramePointsData['id'].remove(point.data()) removed_ids.append(point.data()) if removed_ids: self.markPointsLayerDirty(action=action) - + return removed_ids def removePointsLayer(self, button, toolbar=None): @@ -1019,7 +1016,7 @@ def removePointsLayer(self, button, toolbar=None): toolbar.removeAction(button.action) for action in button.actions: toolbar.removeAction(action) - + if toolbar == self.promptSegmentPointsLayerToolbar: self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False @@ -1029,50 +1026,51 @@ def resizeRangeWelcomeText(self): deltaY = yRange[1] - yRange[0] self.ax1.setXRange(0, deltaX) self.ax1.setYRange(0, deltaY) - self.ax1.setLimits(xMin=0, xMax=deltaX, yMin=0, yMax=deltaY) + self.ax1.setLimits( + xMin=0, xMax=deltaX, yMin=0, yMax=deltaY + ) def restartZoomAutoPilot(self): if not self.autoPilotZoomToObjToggle.isChecked(): return - + posData = self.data[self.pos_i] if not posData.IDs: return - + self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) self.zoomToObj(posData.rp[0]) def restorePrevPointIdRightClick(self, addPointsByClickingButton): - # Try to restore the id that was there before hovering - # because the hovering was required only to delete the + # Try to restore the id that was there before hovering + # because the hovering was required only to delete the # point try: prevId = addPointsByClickingButton.rightClickIDSpinbox.prevId addPointsByClickingButton.rightClickIDSpinbox.setValue(prevId) - except Exception: + except Exception as err: addPointsByClickingButton.rightClickIDSpinbox.prevId = None - @exception_handler def savePointsAddedByClicking(self, button, event): sender = button.action toolButton = sender.toolButton tableEndName = toolButton.clickEntryTableEndName - - self.logger.info(f"Saving _{tableEndName}.csv table...") - + + self.logger.info(f'Saving _{tableEndName}.csv table...') + self.savePointsAddedByClickingFromEndname(tableEndName) - - self.logger.info(f"{tableEndName}.csv saved!") - self.titleLabel.setText(f"{tableEndName}.csv saved!", color="g") + + self.logger.info(f'{tableEndName}.csv saved!') + self.titleLabel.setText(f'{tableEndName}.csv saved!', color='g') def savePointsAddedByClickingFromEndname(self, tableEndName, recovery=False): self.pointsLayerDataToDf(self.data[self.pos_i]) for posData in self.data: - if not posData.basename.endswith("_"): - basename = f"{posData.basename}_" + if not posData.basename.endswith('_'): + basename = f'{posData.basename}_' else: basename = posData.basename - tableFilename = f"{basename}{tableEndName}.csv" + tableFilename = f'{basename}{tableEndName}.csv' if recovery: tableFilepath = os.path.join( posData.recoveryFolderpath(), tableFilename @@ -1082,7 +1080,7 @@ def savePointsAddedByClickingFromEndname(self, tableEndName, recovery=False): df = posData.clickEntryPointsDfs.get(tableEndName) if df is None: continue - df = df.sort_values(["frame_i", "Cell_ID"]) + df = df.sort_values(['frame_i', 'Cell_ID']) df.to_csv(tableFilepath, index=False) def setHoverCircleAddPoint(self, x, y): @@ -1091,31 +1089,32 @@ def setHoverCircleAddPoint(self, x, y): return action = addPointsByClickingButton.action self.setHoverToolSymbolData( - [x], [y], (self.ax1_BrushCircle,), size=action.pointSize + [x], [y], (self.ax1_BrushCircle,), + size=action.pointSize ) def setPointsLayerLoadedDfEndanme(self, action): if action.loadedDfInfo is None: return - + posData = self.data[self.pos_i] - images_path = posData.images_path.replace("\\", "/") - + images_path = posData.images_path.replace('\\', '/') + df_folderpath = os.path.dirname( - action.loadedDfInfo["filepath"].replace("\\", "/") + action.loadedDfInfo['filepath'].replace('\\', '/') ) - + if images_path != df_folderpath: return - - df_filename = os.path.basename(action.loadedDfInfo["filepath"]) - + + df_filename = os.path.basename(action.loadedDfInfo['filepath']) + if not df_filename.startswith(posData.basename): return - - endname = df_filename[len(posData.basename) :] - action.loadedDfInfo["endname"] = endname - + + endname = df_filename[len(posData.basename):] + action.loadedDfInfo['endname'] = endname + action.button.setToolTip(endname) def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): @@ -1124,52 +1123,56 @@ def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): tableEndName = self.addPointsWin.clickEntryTableEndnameText if isLoadedDf is not None: posData = self.data[self.pos_i] - tableEndName = tableEndName[len(posData.basename) :] + tableEndName = tableEndName[len(posData.basename):] self.loadClickEntryDfs(tableEndName) - + toolButton.toolbar = toolbar toolButton.clickEntryTableEndName = tableEndName self.checkableQButtonsGroup.addButton(toolButton) toolButton.toggled.connect(self.addPointsByClickingButtonToggled) self.addPointsByClickingButtonToggled(sender=toolButton) - + toolButton.setToolTip(tableEndName) - + pointIdSpinbox = widgets.SpinBox() pointIdSpinbox.setMinimum(0) pointIdSpinbox.setValue(1) - pointIdSpinbox.label = QLabel(" Left-click ID: ") + pointIdSpinbox.label = QLabel(' Left-click ID: ') pointIdSpinbox.labelAction = toolbar.addWidget(pointIdSpinbox.label) if toolbar == self.promptSegmentPointsLayerToolbar: newID = self.setBrushID(return_val=True) pointIdSpinbox.setValue(newID) pointIdSpinbox.setReadOnly(True) pointIdSpinbox.setToolTip( - "The ids added with left-click cannot be manually edited. " - "They are always a new, non-existing id." - ) - + 'The ids added with left-click cannot be manually edited. ' + 'They are always a new, non-existing id.' + ) + toolButton.actions.append(pointIdSpinbox.labelAction) pointIdSpinbox.action = toolbar.addWidget(pointIdSpinbox) toolButton.actions.append(pointIdSpinbox.action) pointIdSpinbox.toolButton = toolButton toolButton.pointIdSpinbox = pointIdSpinbox - + rightClickIDSpinbox = widgets.SpinBox() pointIdSpinbox.setLinkedValueWidget(rightClickIDSpinbox) rightClickIDSpinbox.setMaximumWidth(pointIdSpinbox.sizeHint().width()) rightClickIDSpinbox.setValue(pointIdSpinbox.value()) rightClickIDSpinbox.setMinimum(0) - rightClickIDSpinbox.label = QLabel(" | Right-click ID: ") - rightClickIDSpinbox.labelAction = toolbar.addWidget(rightClickIDSpinbox.label) + rightClickIDSpinbox.label = QLabel(' | Right-click ID: ') + rightClickIDSpinbox.labelAction = toolbar.addWidget( + rightClickIDSpinbox.label + ) toolButton.actions.append(rightClickIDSpinbox.labelAction) rightClickIDSpinbox.action = toolbar.addWidget(rightClickIDSpinbox) toolButton.actions.append(rightClickIDSpinbox.action) rightClickIDSpinbox.toolButton = toolButton toolButton.rightClickIDSpinbox = rightClickIDSpinbox - - saveToolbutton = widgets.SavePointsLayerButton(tableEndName, parent=self) + + saveToolbutton = widgets.SavePointsLayerButton( + tableEndName, parent=self + ) saveToolbutton.sigRenameTableAction.connect( self.updatePointsLayerClickEntryTableEndname ) @@ -1180,76 +1183,38 @@ def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): saveAction.toolButton = toolButton toolButton.saveAction = saveAction toolButton.saveToolbutton = saveToolbutton - + toolButton.actions.append(saveAction) - + vlineAction = toolbar.addWidget(widgets.QVLine()) - spacerAction = toolbar.addWidget(widgets.QHWidgetSpacer(width=5)) - + spacerAction = toolbar.addWidget( + widgets.QHWidgetSpacer(width=5) + ) + toolButton.actions.append(vlineAction) toolButton.actions.append(spacerAction) - + action = toolButton.action scatterItem = action.scatterItem scatterItem.sigHoverEntered.connect( self.addPointsByClickingScatterItemHoverEntered ) - + self.pointsLayerClicksDfsToData(posData, toolbar=toolbar) - def should_compute_points_layer( - self, - *, - layer_type_index: int, - compute_points_layers: bool, - ) -> bool: - return layer_type_index < 2 and compute_points_layers - - def should_load_recovery_table( - self, - *, - recovery_exists: bool, - main_exists: bool, - recovery_mtime: float | None, - main_mtime: float | None, - ) -> bool: - if not recovery_exists: - return False - if not main_exists: - return True - if recovery_mtime is None or main_mtime is None: - return False - return recovery_mtime > main_mtime + self.recovery_tolerance_seconds - - def should_log_missing_frame_points(self, layer_type_index: int) -> bool: - return layer_type_index != 4 - - def should_use_z_slice( - self, - *, - z_projection_mode: str, - size_z: int, - frame_points_data: Mapping, - ) -> bool: - return ( - z_projection_mode == "single z-slice" - and size_z > 1 - and "x" not in frame_points_data - ) - def showPointsLayerIdsToggled(self, button, checked): button.action.scatterItem.drawIds = checked self.drawPointsLayers() def storeUndoAddPoint(self, action): - if not hasattr(self, "undoAddPointQueueMapper"): + if not hasattr(self, 'undoAddPointQueueMapper'): self.undoAddPointQueueMapper = defaultdict(list) - self.data[self.pos_i] + posData = self.data[self.pos_i] pointsDataPos = action.pointsData.get(self.pos_i) if pointsDataPos is None: return - + state = deepcopy(pointsDataPos) self.undoAddPointQueueMapper[action].append(state) self.undoAction.setEnabled(True) @@ -1258,33 +1223,35 @@ def undoAddPoint(self, action): undoAddPointQueue = self.undoAddPointQueueMapper.get(action) if undoAddPointQueue is None: return False - + if len(undoAddPointQueue) == 0: return False - - self.data[self.pos_i] + + posData = self.data[self.pos_i] state = undoAddPointQueue.pop(-1) action.pointsData[self.pos_i] = state self.markPointsLayerDirty(action=action) - + self.drawPointsLayers(computePointsLayers=False) - + if len(self.undoAddPointQueueMapper[action]) == 0: - self.undoAction.setEnabled(True) - + self.undoAction.setEnabled(True) + return True - def updatePointsLayerClickEntryTableEndname(self, saveToolbutton, table_endname): + def updatePointsLayerClickEntryTableEndname( + self, saveToolbutton, table_endname + ): saveAction = saveToolbutton.action toolButton = saveAction.toolButton toolButton.clickEntryTableEndName = table_endname - + self.logger.info( f'Done. Click entry table endname updated to "{table_endname}"' ) def zoomToObj(self, obj=None): - if not hasattr(self, "data"): + if not hasattr(self, 'data'): return posData = self.data[self.pos_i] if obj is None: @@ -1292,15 +1259,17 @@ def zoomToObj(self, obj=None): try: ID_idx = posData.IDs_idxs[ID] obj = obj = posData.rp[ID_idx] - except Exception: - self.logger.warning(f"ID {ID} does not exist (add points by clicking)") - + except Exception as e: + self.logger.warning( + f'ID {ID} does not exist (add points by clicking)' + ) + if obj is None: return - - self.goToZsliceSearchedID(obj) + + self.goToZsliceSearchedID(obj) min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) - xRange = min_col - 5, max_col + 5 - yRange = max_row + 5, min_row - 5 + xRange = min_col-5, max_col+5 + yRange = max_row+5, min_row-5 self.ax1.setRange(xRange=xRange, yRange=yRange) diff --git a/cellacdc/mixins_bak/preprocessing.py b/cellacdc/mixins/preprocessing.py similarity index 65% rename from cellacdc/mixins_bak/preprocessing.py rename to cellacdc/mixins/preprocessing.py index c6f6b56fd..68e76d00b 100644 --- a/cellacdc/mixins_bak/preprocessing.py +++ b/cellacdc/mixins/preprocessing.py @@ -11,8 +11,8 @@ from cellacdc.plot import imshow -class PreprocessingMixin: - """Qt-facing adapter around preprocessing dialogs and workers.""" +class Preprocessing: + """Extracted from guiWin.""" def askGet2Dor3Dimage(self): txt = html_utils.paragraph(""" @@ -21,14 +21,12 @@ def askGet2Dor3Dimage(self): """) msg = widgets.myMessageBox(wrapText=False) _, use3Dbutton, use2Dbutton = msg.question( - self, - "3D denoising?", - txt, - buttonsTexts=("Cancel", "Denoise 3D z-stack", "Denoise 2D image"), + self, '3D denoising?', txt, + buttonsTexts=('Cancel', 'Denoise 3D z-stack', 'Denoise 2D image') ) if msg.cancel: - return - + return + if msg.clickedButton == use3Dbutton: posData = self.data[self.pos_i] zslice = self.zSliceScrollBar.sliderPosition() @@ -36,9 +34,6 @@ def askGet2Dor3Dimage(self): else: return self.getDisplayedImg1() - def create_preprocessed_data(self, image_data=None): - return PreprocessedData(image_data=image_data) - def debugShowImg(self, img): imshow(img) @@ -60,98 +55,84 @@ def getChData(self, requ_ch=None, pos_i=None): self.loadFluo_cb(fluo_channels=missing_channels) - def image_to_float( - self, - image, - *, - force_dtype=None, - force_missing_dtype=None, - warn=True, - ): - return img_to_float( - image, - force_dtype=force_dtype, - force_missing_dtype=force_missing_dtype, - warn=warn, - ) - - def normalize_display_image(self, image, how: str): - return normalize_display_image( - image, - how, - image_to_float=self.image_to_float, - ) - def preprocWorkerClosed(self, worker): - self.logger.info("Pre-processing worker stopped.") + self.logger.info('Pre-processing worker stopped.') def preprocWorkerCritical(self, error): self.preprocessDialog.appliedFinished() self.workerCritical(error) def preprocWorkerDone( - self, - processed_data: np.ndarray, - how: str, - ): + self, + processed_data: np.ndarray, + how: str, + ): self.setStatusBarLabel(log=False) self.preprocessDialog.appliedFinished() - + posData = self.data[self.pos_i] - if not hasattr(posData, "preproc_img_data"): + if not hasattr(posData, 'preproc_img_data'): posData.preproc_img_data = preprocess.PreprocessedData() - if how == "current_image": + if how == 'current_image': if posData.SizeZ > 1: z_slice = self.z_slice_index() - posData.preproc_img_data[posData.frame_i][z_slice] = processed_data + posData.preproc_img_data[posData.frame_i][z_slice] = ( + processed_data + ) else: posData.preproc_img_data[posData.frame_i] = processed_data z_slice = 0 self.img1.updateMinMaxValuesPreprocessedData( self.data, self.pos_i, posData.frame_i, z_slice ) - elif how == "z_stack": + elif how == 'z_stack': for z_slice, processed_img in enumerate(processed_data): - posData.preproc_img_data[posData.frame_i][z_slice] = processed_img + posData.preproc_img_data[posData.frame_i][z_slice] = ( + processed_img + ) self.img1.updateMinMaxValuesPreprocessedData( self.data, self.pos_i, posData.frame_i, z_slice ) self.img1.updateMinMaxValuesPreprocessedProjections( self.data, self.pos_i, posData.frame_i ) - elif how == "all_frames": + elif how == 'all_frames': for frame_i, processed_frame in enumerate(processed_data): if processed_frame.ndim == 2: processed_frame = (processed_frame,) - + for z_slice, processed_img in enumerate(processed_frame): - posData.preproc_img_data[frame_i][z_slice] = processed_img + posData.preproc_img_data[frame_i][z_slice] = ( + processed_img + ) self.img1.updateMinMaxValuesPreprocessedData( self.data, self.pos_i, frame_i, z_slice ) self.img1.updateMinMaxValuesPreprocessedProjections( self.data, self.pos_i, frame_i ) - elif how == "all_pos": - for pos_i, processed_pos_data in enumerate(processed_data): + elif how == 'all_pos': + for pos_i, processed_pos_data in enumerate(processed_data): if processed_pos_data.ndim == 2: processed_pos_data = (processed_pos_data,) posData = self.data[pos_i] - if not hasattr(posData, "preproc_img_data"): + if not hasattr(posData, 'preproc_img_data'): posData.preproc_img_data = preprocess.PreprocessedData() for z_slice, processed_img in enumerate(processed_pos_data): - posData.preproc_img_data[0][z_slice] = processed_img + posData.preproc_img_data[0][z_slice] = ( + processed_img + ) self.img1.updateMinMaxValuesPreprocessedData( self.data, pos_i, 0, z_slice ) - + if posData.SizeZ > 1: self.img1.updateMinMaxValuesPreprocessedProjections( self.data, pos_i, frame_i ) - + if not self.viewPreprocDataToggle.isChecked(): self.viewPreprocDataToggle.setChecked(True) else: @@ -163,23 +144,26 @@ def preprocWorkerIsQueueEmpty(self, isEmpty: bool): else: self.preprocessDialog.setDisabled(True) self.preprocessDialog.infoLabel.setText( - "Computing preview...
" - "(Feel free to use Cell-ACDC while waiting)" + 'Computing preview...
' + '(Feel free to use Cell-ACDC while waiting)' ) def preprocWorkerPreviewDone( - self, processed_data: np.ndarray, key: Tuple[int, int, Union[int, str]] - ): + self, processed_data: np.ndarray, + key: Tuple[int, int, Union[int, str]] + ): pos_i, frame_i, z_slice = key posData = self.data[pos_i] - if not hasattr(posData, "preproc_img_data"): + if not hasattr(posData, 'preproc_img_data'): posData.preproc_img_data = preprocess.PreprocessedData( image_data=np.zeros(posData.img_data.shape) ) - + posData.preproc_img_data[frame_i][z_slice] = processed_data - self.img1.updateMinMaxValuesPreprocessedData(self.data, pos_i, frame_i, z_slice) - + self.img1.updateMinMaxValuesPreprocessedData( + self.data, pos_i, frame_i, z_slice + ) + self.setImageImg1() def preprocessActionTriggered(self): @@ -189,121 +173,152 @@ def preprocessActionTriggered(self): self.preprocessDialog.emitSigPreviewToggled() def preprocessAllFrames(self, recipe: List[Dict[str, Any]]): - txt = "Pre-processing all frames..." + txt = 'Pre-processing all frames...' self.logger.info(txt) self.statusBarLabel.setText(txt) - + posData = self.data[self.pos_i] func = core.preprocess_video_from_recipe image_data = posData.img_data - self.preprocWorker.setupJob(func, image_data, recipe, "all_frames") + self.preprocWorker.setupJob( + func, + image_data, + recipe, + 'all_frames' + ) self.preprocWorker.wakeUp() def preprocessAllPos(self, recipe: List[Dict[str, Any]]): - txt = "Pre-processing all Positions..." + txt = 'Pre-processing all Positions...' self.logger.info(txt) self.statusBarLabel.setText(txt) - + func = core.preprocess_multi_pos_from_recipe recipe = core.validate_multidimensional_recipe( recipe, apply_to_all_frames=False ) image_data = [posData.img_data[0] for posData in self.data] - self.preprocWorker.setupJob(func, image_data, recipe, "all_pos") - + self.preprocWorker.setupJob( + func, + image_data, + recipe, + 'all_pos' + ) + self.preprocWorker.wakeUp() def preprocessCurrentImage(self, recipe: List[Dict[str, Any]], *args): - txt = "Pre-processing current image..." + txt = 'Pre-processing current image...' self.logger.info(txt) self.statusBarLabel.setText(txt) - + func = core.preprocess_image_from_recipe recipe = core.validate_multidimensional_recipe(recipe) - + image_data = self.getImage(raw=True) - self.preprocWorker.setupJob(func, image_data, recipe, "current_image") - + self.preprocWorker.setupJob( + func, + image_data, + recipe, + 'current_image' + ) + self.preprocWorker.wakeUp() - def preprocessDialogRecipeChanged( - self, recipe - ): # why does this need the recepie as an arg + def preprocessDialogRecipeChanged(self, recipe):# why does this need the recepie as an arg recipe = self.preprocessDialog.recipe() if recipe is None: - self.logger.warning("Pre-processing recipe not initialized yet.") + self.logger.warning('Pre-processing recipe not initialized yet.') return - + self.updatePreprocessPreview(recipe=recipe) def preprocessDialogSavePreprocessedData(self, dialog): posData = self.data[self.pos_i] - + try: posData.preprocessedDataArray() except TypeError as e: - if "Not all frames have been processed." in str(e): + if 'Not all frames have been processed.' in str(e): msg = widgets.myMessageBox() txt = html_utils.paragraph( - "Not all frames have been processed.
" - "Please process all frames before saving." + 'Not all frames have been processed.
' + 'Please process all frames before saving.' ) - msg.warning(self, "Process all data before saving", txt) + msg.warning(self, 'Process all data before saving', txt) return - helpText = """ + + helpText = ( + """ The preprocessed image file will be saved with a different file name.

Insert a name to append to the end of the new file name. The rest of the name will be the same as the original file. """ - + ) + + win = apps.filenameDialog( - basename=f"{posData.basename}{self.user_ch_name}", + basename=f'{posData.basename}{self.user_ch_name}', ext=".tif", - hintText="Insert a name for the preprocessed image file:", - defaultEntry="preprocessed", - helpText=helpText, + hintText='Insert a name for the preprocessed image file:', + defaultEntry='preprocessed', + helpText=helpText, allowEmpty=False, - parent=dialog, + parent=dialog ) win.exec_() if win.cancel: return appendedText = win.entryText - + self.progressWin = apps.QDialogWorkerProgress( - title="Saving pre-processed image(s)", + title='Saving pre-processed image(s)', parent=self, - pbarDesc="Saving pre-processed image(s)", + pbarDesc='Saving pre-processed image(s)' ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - - self.statusBarLabel.setText("Saving pre-processed data...") - + + self.statusBarLabel.setText('Saving pre-processed data...') + self.savePreprocWorker = workers.SaveProcessedDataWorker( self.data, appendedText, ext=".tif" ) - + self.savePreprocThread = QThread() self.savePreprocWorker.moveToThread(self.savePreprocThread) - self.savePreprocWorker.signals.finished.connect(self.savePreprocThread.quit) + self.savePreprocWorker.signals.finished.connect( + self.savePreprocThread.quit + ) self.savePreprocWorker.signals.finished.connect( self.savePreprocWorker.deleteLater ) - self.savePreprocThread.finished.connect(self.savePreprocThread.deleteLater) - - self.savePreprocWorker.signals.critical.connect(self.workerCritical) + self.savePreprocThread.finished.connect( + self.savePreprocThread.deleteLater + ) + + self.savePreprocWorker.signals.critical.connect( + self.workerCritical + ) self.savePreprocWorker.signals.initProgressBar.connect( self.workerInitProgressbar ) - self.savePreprocWorker.signals.progressBar.connect(self.workerUpdateProgressbar) - self.savePreprocWorker.signals.progress.connect(self.workerProgress) - self.savePreprocWorker.signals.finished.connect(self.savePreprocWorkerFinished) - - self.savePreprocThread.started.connect(self.savePreprocWorker.run) + self.savePreprocWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.savePreprocWorker.signals.progress.connect( + self.workerProgress + ) + self.savePreprocWorker.signals.finished.connect( + self.savePreprocWorkerFinished + ) + + self.savePreprocThread.started.connect( + self.savePreprocWorker.run + ) self.savePreprocThread.start() def preprocessEnqueueCurrentImage(self, recipe): @@ -314,139 +329,134 @@ def preprocessEnqueueCurrentImage(self, recipe): z_slice = self.z_slice_index() else: z_slice = 0 - + recipe = core.validate_multidimensional_recipe(recipe) - + key = (self.pos_i, posData.frame_i, z_slice) - self.preprocWorker.enqueue(func, image_data, recipe, key) + self.preprocWorker.enqueue( + func, + image_data, + recipe, + key + ) def preprocessPreviewToggled(self, checked): self.viewPreprocDataToggle.setChecked(checked) self.updatePreprocessPreview() def preprocessZStack(self, recipe: List[Dict[str, Any]], *args): - txt = "Pre-processing z-stack..." + txt = 'Pre-processing z-stack...' self.statusBarLabel.setText(txt) self.logger.info(txt) - + posData = self.data[self.pos_i] func = core.preprocess_zstack_from_recipe recipe = core.validate_multidimensional_recipe( recipe, apply_to_all_frames=False ) image_data = posData.img_data[posData.frame_i] - self.preprocWorker.setupJob(func, image_data, recipe, "z_stack") - + self.preprocWorker.setupJob( + func, + image_data, + recipe, + 'z_stack' + ) + self.preprocWorker.wakeUp() - def preprocess_image_from_recipe(self, image, recipe: list[dict[str, Any]]): - return core_preprocess_image_from_recipe(image, recipe) - - def preprocess_multi_pos_from_recipe( - self, - images, - recipe: list[dict[str, Any]], - ): - return core_preprocess_multi_pos_from_recipe(images, recipe) - - def preprocess_video_from_recipe(self, image, recipe: list[dict[str, Any]]): - return core_preprocess_video_from_recipe(image, recipe) - - def preprocess_zstack_from_recipe(self, image, recipe: list[dict[str, Any]]): - return core_preprocess_zstack_from_recipe(image, recipe) - def setupPreprocessing(self): posData = self.data[self.pos_i] if self.preprocessDialog is not None: self.preprocessDialog.close() - + self.preprocessDialog = apps.PreProcessRecipeDialog( - isTimelapse=posData.SizeT > 1, - isZstack=posData.SizeZ > 1, - isMultiPos=len(self.data) > 1, + isTimelapse=posData.SizeT>1, + isZstack=posData.SizeZ>1, + isMultiPos=len(self.data)>1, df_metadata=posData.metadata_df, - hideOnClosing=True, + hideOnClosing=True, addApplyButton=True, - parent=self, + parent=self ) self.doPreviewPreprocImage = False - self.preprocessDialog.sigApplyImage.connect(self.preprocessCurrentImage) - self.preprocessDialog.sigApplyZstack.connect(self.preprocessZStack) - self.preprocessDialog.sigApplyAllFrames.connect(self.preprocessAllFrames) - self.preprocessDialog.sigApplyAllPos.connect(self.preprocessAllPos) - self.preprocessDialog.sigPreviewToggled.connect(self.preprocessPreviewToggled) + self.preprocessDialog.sigApplyImage.connect( + self.preprocessCurrentImage + ) + self.preprocessDialog.sigApplyZstack.connect( + self.preprocessZStack + ) + self.preprocessDialog.sigApplyAllFrames.connect( + self.preprocessAllFrames + ) + self.preprocessDialog.sigApplyAllPos.connect( + self.preprocessAllPos + ) + self.preprocessDialog.sigPreviewToggled.connect( + self.preprocessPreviewToggled + ) self.preprocessDialog.sigValuesChanged.connect( self.preprocessDialogRecipeChanged ) self.preprocessDialog.sigSavePreprocData.connect( self.preprocessDialogSavePreprocessedData ) - + if self.preprocWorker is not None: return - + self.preprocThread = QThread() self.preprocMutex = QMutex() self.preprocWaitCond = QWaitCondition() - + self.preprocWorker = workers.CustomPreprocessWorkerGUI( self.preprocMutex, self.preprocWaitCond ) - + self.preprocWorker.moveToThread(self.preprocThread) self.preprocWorker.signals.finished.connect(self.preprocThread.quit) - self.preprocWorker.signals.finished.connect(self.preprocWorker.deleteLater) + self.preprocWorker.signals.finished.connect( + self.preprocWorker.deleteLater + ) self.preprocThread.finished.connect(self.preprocThread.deleteLater) self.preprocWorker.sigDone.connect(self.preprocWorkerDone) - self.preprocWorker.sigIsQueueEmpty.connect(self.preprocWorkerIsQueueEmpty) + self.preprocWorker.sigIsQueueEmpty.connect( + self.preprocWorkerIsQueueEmpty + ) self.preprocWorker.sigPreviewDone.connect(self.preprocWorkerPreviewDone) self.preprocWorker.signals.progress.connect(self.workerProgress) self.preprocWorker.signals.critical.connect(self.workerCritical) self.preprocWorker.signals.finished.connect(self.preprocWorkerClosed) - + self.preprocThread.started.connect(self.preprocWorker.run) self.preprocThread.start() - - self.logger.info("Pre-processing worker started.") + + self.logger.info('Pre-processing worker started.') def updatePreprocessPreview(self, *args, **kwargs): - force = kwargs.get("force", False) - + force = kwargs.get('force', False) + if not self.preprocessDialog.isVisible() and not force: return - + if not self.preprocessDialog.previewCheckbox.isChecked() and not force: return - - if kwargs.get("recipe") is None: + + if kwargs.get('recipe') is None: recipe = self.preprocessDialog.recipe() else: - recipe = kwargs.get("recipe") + recipe = kwargs.get('recipe') if recipe is None: - self.logger.warning("Pre-processing recipe not initialized yet.") + self.logger.warning('Pre-processing recipe not initialized yet.') return - - txt = "Pre-processing current image..." + + txt = 'Pre-processing current image...' self.logger.info(txt) self.statusBarLabel.setText(txt) - + self.preprocessEnqueueCurrentImage(recipe) - def validate_multidimensional_recipe( - self, - recipe: list[dict[str, Any]], - *, - apply_to_all_zslices: bool = False, - apply_to_all_frames: bool = False, - ): - return core_validate_multidimensional_recipe( - recipe, - apply_to_all_zslices=apply_to_all_zslices, - apply_to_all_frames=apply_to_all_frames, - ) - def viewPreprocDataToggled(self, checked): self.img1.setUsePreprocessed(checked) self.setImageImg1() diff --git a/cellacdc/mixins/quick_settings.py b/cellacdc/mixins/quick_settings.py new file mode 100644 index 000000000..1dbb8006e --- /dev/null +++ b/cellacdc/mixins/quick_settings.py @@ -0,0 +1,155 @@ +"""View adapter for quick settings and side-panel widgets.""" + +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QFormLayout, QLabel, QVBoxLayout + +from cellacdc import apps, settings_csv_path, widgets + + +class QuickSettings: + """Extracted from guiWin.""" + + def gui_createQuickSettingsWidgets(self): + self.quickSettingsLayout = QVBoxLayout() + self.quickSettingsGroupbox = widgets.GroupBox() + self.quickSettingsGroupbox.setTitle('Quick settings') + + layout = QFormLayout() + layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint + ) + layout.setFormAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self.viewPreprocDataToggle = widgets.Toggle() + viewPreprocDataToggleTooltip = ( + 'View pre-processed data. See menu `Image --> Pre-processing...`\n' + 'on the top menubar.' + ) + self.viewPreprocDataToggle.setChecked(False) + self.viewPreprocDataToggle.setToolTip(viewPreprocDataToggleTooltip) + viewPreprocDataToggleLabel = QLabel('View pre-processed image') + viewPreprocDataToggleLabel.setToolTip(viewPreprocDataToggleTooltip) + layout.addRow(viewPreprocDataToggleLabel, self.viewPreprocDataToggle) + + self.viewCombineChannelDataToggle = widgets.Toggle() + viewCombineChannelDataToggleTooltip = ( + 'View combined channel. See menu `Image --> combing channels...`\n' + 'on the top menubar.' + ) + self.viewCombineChannelDataToggle.setChecked(False) + self.viewCombineChannelDataToggle.setToolTip( + viewCombineChannelDataToggleTooltip + ) + viewCombineChannelDataToggleLabel = QLabel('View combined channels') + viewCombineChannelDataToggleLabel.setToolTip( + viewCombineChannelDataToggleTooltip + ) + layout.addRow( + viewCombineChannelDataToggleLabel, + self.viewCombineChannelDataToggle + ) + + self.autoSaveToggle = widgets.Toggle() + autoSaveTooltip = ( + 'Automatically store a copy of the segmentation data ' + 'in the `.recovery` folder after every edit.' + ) + self.autoSaveToggle.setChecked(True) + self.autoSaveToggle.setToolTip(autoSaveTooltip) + autoSaveLabel = QLabel('Autosave segmentation') + autoSaveLabel.setToolTip(autoSaveTooltip) + layout.addRow(autoSaveLabel, self.autoSaveToggle) + + self.autoSaveAnnotToggle = widgets.Toggle() + autoSaveAnnotTooltip = ( + 'Automatically store a copy of the annotations (acdc_output CSV file) ' + 'in the `.recovery` folder after every edit.' + ) + self.autoSaveAnnotToggle.setChecked(True) + self.autoSaveAnnotToggle.setToolTip(autoSaveAnnotTooltip) + autoSaveAnnotLabel = QLabel('Autosave annotations') + autoSaveAnnotLabel.setToolTip(autoSaveAnnotTooltip) + layout.addRow(autoSaveAnnotLabel, self.autoSaveAnnotToggle) + + self.autoSaveIntervalEditButton = widgets.editPushButton( + flat=True, hoverable=True + ) + self.autoSaveIntervalLabel = QLabel('Autosave interval') + self.autoSaveIntervalSetTooltip() + layout.addRow( + self.autoSaveIntervalLabel, self.autoSaveIntervalEditButton + ) + + self.autoSaveIntervalDialog = apps.AutoSaveIntervalDialog(parent=self) + self.autoSaveIntervalDialog.setValues(*self.autoSaveIntevalValueUnit) + + self.ccaIntegrCheckerToggle = widgets.Toggle() + ccaIntegrCheckerToggleTooltip = ( + 'Toggle background cell cycle annotations integrity checker ON/OFF' + ) + self.ccaIntegrCheckerToggle.setChecked(False) + self.ccaIntegrCheckerToggle.setToolTip(ccaIntegrCheckerToggleTooltip) + label = QLabel('Cc annot. checker') + label.setToolTip(ccaIntegrCheckerToggleTooltip) + layout.addRow(label, self.ccaIntegrCheckerToggle) + if 'is_cca_integrity_checker_activated' in self.df_settings.index: + idx = 'is_cca_integrity_checker_activated' + val = int(self.df_settings.at[idx, 'value']) + self.ccaIntegrCheckerToggle.setChecked(not val) + + self.annotLostObjsToggle = widgets.Toggle() + annotLostObjsToggleTooltip = ( + 'Toggle annotation of lost objects mode ON/OFF' + ) + self.annotLostObjsToggle.setChecked(True) + self.annotLostObjsToggle.setToolTip(annotLostObjsToggleTooltip) + label = QLabel('Annot. lost objects') + label.setToolTip(annotLostObjsToggleTooltip) + layout.addRow(label, self.annotLostObjsToggle) + + self.realTimeTrackingToggle = widgets.Toggle() + self.realTimeTrackingToggle.setChecked(True) + self.realTimeTrackingToggle.setDisabled(True) + label = QLabel('Real-time tracking') + label.setDisabled(True) + self.realTimeTrackingToggle.label = label + layout.addRow(label, self.realTimeTrackingToggle) + + self.showAllContoursToggle = widgets.Toggle() + showAllContoursTooltip = ( + 'If active, all contours will be displayed, including inner contours' + '(e.g. holes and sub-objects)' + ) + self.showAllContoursToggle.setToolTip(showAllContoursTooltip) + showAllContourLabel = QLabel('Show all contours') + showAllContourLabel.setToolTip(showAllContoursTooltip) + layout.addRow(showAllContourLabel, self.showAllContoursToggle) + self.showAllContoursToggle.toggled.connect( + self.showAllContoursToggled + ) + + # Font size + self.fontSizeSpinBox = widgets.SpinBox() + self.fontSizeSpinBox.setMinimum(1) + self.fontSizeSpinBox.setMaximum(99) + layout.addRow('Font size', self.fontSizeSpinBox) + savedFontSize = str(self.df_settings.at['fontSize', 'value']) + if savedFontSize.find('pt') != -1: + savedFontSize = savedFontSize[:-2] + self.fontSize = int(savedFontSize) + if 'pxMode' not in self.df_settings.index: + # Users before introduction of pxMode had pxMode=False, but now + # the new default is True. This requires larger font size. + self.fontSize = 2*self.fontSize + self.df_settings.at['pxMode', 'value'] = 1 + self.df_settings.to_csv(settings_csv_path) + self.fontSizeSpinBox.setValue(self.fontSize) + self.fontSizeSpinBox.editingFinished.connect(self.changeFontSize) + self.fontSizeSpinBox.sigUpClicked.connect(self.changeFontSize) + self.fontSizeSpinBox.sigDownClicked.connect(self.changeFontSize) + + self.quickSettingsGroupbox.setLayout(layout) + self.quickSettingsLayout.addWidget(self.quickSettingsGroupbox) + self.quickSettingsLayout.addStretch(1) diff --git a/cellacdc/mixins_bak/saving.py b/cellacdc/mixins/saving.py similarity index 67% rename from cellacdc/mixins_bak/saving.py rename to cellacdc/mixins/saving.py index f73d532d9..3bcc45acd 100644 --- a/cellacdc/mixins_bak/saving.py +++ b/cellacdc/mixins/saving.py @@ -24,22 +24,18 @@ _font.setPixelSize(11) -class SavingMixin: - """Qt-facing adapter for save and autosave workflows.""" - - """Headless decisions for save and autosave workflows.""" - - viewer_mode = "Viewer" - segmentation_mode = "Segmentation and Tracking" - cell_cycle_mode = "Cell cycle analysis" +class Saving: + """Extracted from guiWin.""" def _enqueueAutoSave(self): - if not self.statusBarLabel.text().endswith("Autosaving..."): - self.statusBarLabel.setText(f"{self.statusBarLabel.text()} | Autosaving...") - - timestamp = datetime.now().strftime(r"%H:%M:%S.%f")[:-3] - self.logger.info(f"Autosaving... - {timestamp}") - + if not self.statusBarLabel.text().endswith('Autosaving...'): + self.statusBarLabel.setText( + f'{self.statusBarLabel.text()} | Autosaving...' + ) + + timestamp = datetime.now().strftime(r'%H:%M:%S.%f')[:-3] + self.logger.info(f'Autosaving... - {timestamp}') + posData = self.data[self.pos_i] worker, thread = self.autoSaveActiveWorkers[-1] worker.enqueue(posData) @@ -57,99 +53,101 @@ def _waitCloseAutoSaveWorker(self): def askConcatenate(self): if self.mainWin is None: return - + if self._isQuickSave: return - - if "showAskConcatenate" not in self.df_settings.index: - self.df_settings.at["showAskConcatenate", "value"] = "Yes" - - showAskConcatenate = self.df_settings.at["showAskConcatenate", "value"] == "Yes" + + if 'showAskConcatenate' not in self.df_settings.index: + self.df_settings.at['showAskConcatenate', 'value'] = 'Yes' + + showAskConcatenate = ( + self.df_settings.at['showAskConcatenate', 'value'] == 'Yes' + ) if not showAskConcatenate: return - - txt = html_utils.paragraph(""" + + txt = html_utils.paragraph(f""" Do you want to concatenate the `acdc_output.csv` tables from multiple Positions into one single CSV file?
""") - doNotShowAgainCheckbox = QCheckBox("Do not show again") + doNotShowAgainCheckbox = QCheckBox('Do not show again') msg = widgets.myMessageBox(wrapText=False) noButton, yesButton = msg.question( - self, - "Concatenate tables?", - txt, - buttonsTexts=("No", "Yes"), - widgets=doNotShowAgainCheckbox, + self, 'Concatenate tables?', txt, + buttonsTexts=('No', 'Yes'), + widgets=doNotShowAgainCheckbox + ) + showAskConcatenate = ( + 'No' if doNotShowAgainCheckbox.isChecked() else 'Yes' + ) + self.df_settings.at['showAskConcatenate', 'value'] = ( + showAskConcatenate ) - showAskConcatenate = "No" if doNotShowAgainCheckbox.isChecked() else "Yes" - self.df_settings.at["showAskConcatenate", "value"] = showAskConcatenate self.df_settings.to_csv(settings_csv_path) - + if not msg.clickedButton == yesButton: return - - txt = html_utils.paragraph(""" + + txt = html_utils.paragraph(f""" To concatenate the `acdc_output.csv` tables from multiple Positions and multiple experiments
launch the concatenation utility from the top menubar of the Cell-ACDC main launcher:

Utilities --> Concatenate --> Concatenate acdc output tables from multiple Positions and experiments.... """) msg = widgets.myMessageBox(wrapText=False) - msg.information(self, "How to concatenate tables", txt) + msg.information(self, 'How to concatenate tables', txt) def askPosToSave(self): return self.askSelectPos() def askSaveLastVisitedCcaMode(self, isQuickSave=False): posData = self.data[self.pos_i] + current_frame_i = posData.frame_i frame_i = 0 + last_tracked_i = 0 self.save_until_frame_i = 0 if self.isSnapshot: return True - + for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict["labels"] + lab = data_dict['labels'] if lab is None: frame_i -= 1 break - + self.save_until_frame_i = frame_i self.save_cca_until_frame_i = frame_i self.last_tracked_i = frame_i - + if isQuickSave: return True - - last_cca_frame_i = self.navigateScrollBar.maximum() - 1 + + last_cca_frame_i = self.navigateScrollBar.maximum()-1 # Ask to save last visited frame or not txt = html_utils.paragraph(f""" You annotated the cell cycle stages up - until frame number {last_cca_frame_i + 1}.

+ until frame number {last_cca_frame_i+1}.

Enter up to which frame number you want to save the cell cycle annotations: """) lastFrameDialog = apps.QLineEditDialog( - title="Last annoated frame number to save", - defaultTxt=str(last_cca_frame_i + 1), - msg=txt, - parent=self, - allowedValues=(1, last_cca_frame_i + 1), - warnLastFrame=True, - isInteger=True, - stretchEntry=False, - lastVisitedFrame=last_cca_frame_i + 1, + title='Last annoated frame number to save', + defaultTxt=str(last_cca_frame_i+1), + msg=txt, parent=self, allowedValues=(1, last_cca_frame_i+1), + warnLastFrame=True, isInteger=True, stretchEntry=False, + lastVisitedFrame=last_cca_frame_i+1, ) lastFrameDialog.exec_() if lastFrameDialog.cancel: return False last_save_cca_frame_i = lastFrameDialog.enteredValue - 1 - + if last_save_cca_frame_i < last_cca_frame_i: self.resetCcaFuture(last_cca_frame_i) - + self.save_cca_until_frame_i = last_save_cca_frame_i - + return True def askSaveLastVisitedSegmMode(self, isQuickSave=False): @@ -163,7 +161,7 @@ def askSaveLastVisitedSegmMode(self, isQuickSave=False): return True for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict["labels"] + lab = data_dict['labels'] if lab is None: frame_i -= 1 break @@ -177,19 +175,14 @@ def askSaveLastVisitedSegmMode(self, isQuickSave=False): # Ask to save last visited frame or not txt = html_utils.paragraph(f""" You visualised and corrected segmentation and tracking data up - until frame number {frame_i + 1}.

+ until frame number {frame_i+1}.

Enter up to which frame number you want to save data: """) lastFrameDialog = apps.QLineEditDialog( - title="Last frame number to save", - defaultTxt=str(frame_i + 1), - msg=txt, - parent=self, - allowedValues=(1, posData.SizeT), - warnLastFrame=True, - isInteger=True, - stretchEntry=False, - lastVisitedFrame=frame_i + 1, + title='Last frame number to save', defaultTxt=str(frame_i+1), + msg=txt, parent=self, allowedValues=(1, posData.SizeT), + warnLastFrame=True, isInteger=True, stretchEntry=False, + lastVisitedFrame=frame_i+1, ) lastFrameDialog.exec_() if lastFrameDialog.cancel: @@ -199,27 +192,27 @@ def askSaveLastVisitedSegmMode(self, isQuickSave=False): self.save_cca_until_frame_i = self.save_until_frame_i if self.save_until_frame_i > frame_i: self.logger.info( - f"Storing frames {frame_i + 1}-{self.save_until_frame_i + 1}..." + f'Storing frames {frame_i+1}-{self.save_until_frame_i+1}...' ) current_frame_i = posData.frame_i # User is requesting to save past the last visited frame --> # store data as if they were visited - for i in range(frame_i + 1, self.save_until_frame_i + 1): + for i in range(frame_i+1, self.save_until_frame_i+1): posData.frame_i = i self.get_data() self.store_data(autosave=False) - + # Go back to current frame posData.frame_i = current_frame_i self.get_data() last_tracked_i = self.save_until_frame_i - + self.last_tracked_i = last_tracked_i return True def askSaveMetrics(self): txt = html_utils.paragraph( - """ + """ Do you also want to save the measurements (e.g., cell volume, mean, amount etc.)?

@@ -231,21 +224,20 @@ def askSaveMetrics(self): NOTE: Saving metrics might be slow, we recommend doing it only when you need it.
- """ + """) + msg = widgets.myMessageBox( + parent=self, resizeButtons=False, wrapText=False ) - msg = widgets.myMessageBox(parent=self, resizeButtons=False, wrapText=False) - setMeasurementsButton = widgets.setPushButton("Set measurements...") + setMeasurementsButton = widgets.setPushButton('Set measurements...') _, yesButton, noButton, _ = msg.question( - self, - "Save measurements?", - txt, - buttonsTexts=("Cancel", "Yes", "No", setMeasurementsButton), - showDialog=False, + self, 'Save measurements?', txt, + buttonsTexts=('Cancel', 'Yes', 'No', setMeasurementsButton), + showDialog=False ) setMeasurementsButton.disconnect() setMeasurementsButton.clicked.connect( partial( - self.showSetMeasurements, + self.showSetMeasurements, qparent=msg, ) ) @@ -253,24 +245,24 @@ def askSaveMetrics(self): save_metrics = msg.clickedButton == yesButton return save_metrics, msg.cancel - @disableWindow def askSaveOnClosing(self, event): if not self.saveAction.isEnabled(): return True - if self.titleLabel.text == "Saved!": + if self.titleLabel.text == 'Saved!': return True if not self.isDataLoaded: return True - + msg = widgets.myMessageBox() - txt = html_utils.paragraph("Do you want to save before closing?") + txt = html_utils.paragraph('Do you want to save before closing?') _, noButton, yesButton = msg.question( - self, "Save?", txt, buttonsTexts=("Cancel", "No", "Yes") + self, 'Save?', txt, + buttonsTexts=('Cancel', 'No', 'Yes') ) if msg.cancel: event.ignore() return False - + if msg.clickedButton == yesButton: self.closeGUI = True QTimer.singleShot(100, self.saveAction.trigger) @@ -285,20 +277,22 @@ def askSaveOriginalSegm(self, isQuickSave=False): posData = self.data[self.pos_i] if not posData.whitelist: return "", True, True - - help_txt = html_utils.paragraph(""" + + help_txt = html_utils.paragraph(f""" You have whitelisted IDs in the current position.
Do you want to save the not whitelisted segmentation data
This will allow you to revisit the original segmentation.
""") - txt = html_utils.paragraph(""" + txt = html_utils.paragraph(f""" You have whitelisted IDs in the current position.
Do you want to save the not whitelisted segmentation data?
""") found_files = load.get_segm_files(posData.images_path) - existingEndnames = load.get_endnames(posData.basename, found_files) + existingEndnames = load.get_endnames( + posData.basename, found_files + ) segmFilename = os.path.basename(posData.segm_npz_path) segmFilename = f"{segmFilename[:-4]}_not_whitelisted" @@ -307,11 +301,11 @@ def askSaveOriginalSegm(self, isQuickSave=False): hintText=txt, defaultEntry=segmFilename, existingNames=existingEndnames, - helpText=help_txt, + helpText=help_txt, allowEmpty=False, parent=self, - title="Save not whitelisted segmentation data", - addDoNotSaveButton=True, + title='Save not whitelisted segmentation data', + addDoNotSaveButton=True ) win.exec_() if win.cancel: @@ -320,10 +314,10 @@ def askSaveOriginalSegm(self, isQuickSave=False): return "", True, True return win.entryText, True, False - def askSelectPos(self, action="to save"): + def askSelectPos(self, action='to save'): last_pos = 1 for p, posData in enumerate(self.data): - acdc_df = posData.allData_li[0]["acdc_df"] + acdc_df = posData.allData_li[0]['acdc_df'] if acdc_df is None: last_pos = p break @@ -332,33 +326,30 @@ def askSelectPos(self, action="to save"): items = [posData.pos_foldername for posData in self.data] selectPosWin = widgets.QDialogListbox( - f"Select Positions {action}", - f"Select Positions {action}:\n", - items, - multiSelection=True, - parent=self, - preSelectedItems=items[:last_pos], + f'Select Positions {action}', f'Select Positions {action}:\n', + items, multiSelection=True, parent=self, + preSelectedItems=items[:last_pos] ) selectPosWin.exec_() if selectPosWin.cancel: return - + return selectPosWin.selectedItemsText def autoSaveAnnotToggled(self, checked): if not self.autoSaveActiveWorkers: self.gui_createAutoSaveWorker() - + if not self.autoSaveActiveWorkers: return - + worker, thread = self.autoSaveActiveWorkers[-1] - + mode = self.modeComboBox.currentText() - if mode != "Viewer": + if mode != 'Viewer': # No reason to save in viewer mode checked = False - + worker.isAutoSaveAnnotON = checked def autoSaveClose(self): @@ -373,54 +364,58 @@ def autoSaveIntervalEdit(self): def autoSaveIntervalSetTooltip(self): value, unit = self.autoSaveIntevalValueUnit autoSaveIntervalEditTooltip = ( - "Change autosave interval to every N frames or minutes\n\n" - f"Current autosave interval: {value} {unit}" + 'Change autosave interval to every N frames or minutes\n\n' + f'Current autosave interval: {value} {unit}' ) self.autoSaveIntervalLabel.setToolTip(autoSaveIntervalEditTooltip) self.autoSaveIntervalEditButton.setToolTip(autoSaveIntervalEditTooltip) def autoSaveIntervalValueChanged( - self, value: float, unit: Literal["minutes", "frames"] - ): + self, value: float, unit: Literal['minutes', 'frames'] + ): self.autoSaveIntevalValueUnit = (value, unit) self.autoSaveTimer.stop() - - self.df_settings.at["autoSaveIntevalValue", "value"] = str(value) - self.df_settings.at["autoSaveIntervalUnit", "value"] = unit + + self.df_settings.at['autoSaveIntevalValue', 'value'] = str(value) + self.df_settings.at['autoSaveIntervalUnit', 'value'] = unit self.df_settings.to_csv(settings_csv_path) - - self.logger.info(f"Autosave interval changed to: {value} {unit}") + + self.logger.info( + f'Autosave interval changed to: {value} {unit}' + ) self.autoSaveIntervalSetTooltip() - - if unit == "frames": + + if unit == 'frames': self.startAutoSaveEveryNframesTimer() def autoSaveTimerCountFrames(self): - if not hasattr(self, "data"): - # This happes when the self.autoSaveTimer times out after + if not hasattr(self, 'data'): + # This happes when the self.autoSaveTimer times out after # the GUI has been closed --> we simply ignore it return - + posData = self.data[self.pos_i] - autoSaveIntevalValue, autoSaveIntervalUnit = self.autoSaveIntevalValueUnit + autoSaveIntevalValue, autoSaveIntervalUnit = ( + self.autoSaveIntevalValueUnit + ) isTimeToAutoSave = ( abs(posData.frame_i - self.autoSaveTimeStartFrameIdx) >= autoSaveIntevalValue ) if not isTimeToAutoSave: return - + self.autoSaveTimeStartFrameIdx = posData.frame_i self.flushDirtyPointsLayersAutosave() self._enqueueAutoSave() def autoSaveTimerTimedOut(self): - if not hasattr(self, "data"): - # This happes when the self.autoSaveTimer times out after + if not hasattr(self, 'data'): + # This happes when the self.autoSaveTimer times out after # the GUI has been closed --> we simply ignore it self.autoSaveTimer.stop() return - + self.autoSaveTimer.stop() self.flushDirtyPointsLayersAutosave() self._enqueueAutoSave() @@ -428,66 +423,24 @@ def autoSaveTimerTimedOut(self): def autoSaveToggled(self, checked): if not self.autoSaveActiveWorkers: self.gui_createAutoSaveWorker() - + if not self.autoSaveActiveWorkers: return - + worker, thread = self.autoSaveActiveWorkers[-1] - + mode = self.modeComboBox.currentText() - if mode != "Segmentation and Tracking": - # Autosaving segmentation makes sense only in + if mode != 'Segmentation and Tracking': + # Autosaving segmentation makes sense only in # "Segmentation and Tracking" mode checked = False - + worker.isAutoSaveON = checked - def autosave_annotations_enabled(self, *, mode: str, checked: bool) -> bool: - if mode != self.viewer_mode: - return False - return checked - - def autosave_interval_change( - self, - value: float, - unit: Literal["minutes", "frames"], - ) -> AutosaveIntervalChange: - return AutosaveIntervalChange( - value=value, - unit=unit, - settings_updates={ - "autoSaveIntevalValue": str(value), - "autoSaveIntervalUnit": unit, - }, - log_message=f"Autosave interval changed to: {value} {unit}", - tooltip=( - "Change autosave interval to every N frames or minutes\n\n" - f"Current autosave interval: {value} {unit}" - ), - start_frame_timer=unit == "frames", - ) - - def autosave_schedule( - self, - value: float, - unit: Literal["minutes", "frames"], - ) -> AutosaveSchedule | None: - if value == 0: - return None - if unit == "frames": - return AutosaveSchedule(use_frame_timer=True) - return AutosaveSchedule( - use_frame_timer=False, - interval_ms=round(value * 60 * 1000), - ) - - def autosave_segmentation_enabled(self, *, mode: str, checked: bool) -> bool: - if mode != self.segmentation_mode: - return False - return checked - def cancelSavingInitialisation(self): - self.titleLabel.setText("Saving data process cancelled.", color=self.titleColor) + self.titleLabel.setText( + 'Saving data process cancelled.', color=self.titleColor + ) self.closeGUI = False def checkMissingCca(self): @@ -496,123 +449,103 @@ def checkMissingCca(self): doNotShowAgain = False if not self.doNotShowAgainMissingCca: return proceed, ignore, doNotShowAgain - + missing_cca_items = [] for posData in self.data: for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict["acdc_df"] + acdc_df = data_dict['acdc_df'] if acdc_df is None: continue - - if "cell_cycle_stage" not in acdc_df.columns: + + if 'cell_cycle_stage' not in acdc_df.columns: continue - + cca_df = acdc_df[cca_df_colnames] if cca_df.isnull().values.any(): i = frame_i if not self.isSnapshot else None missing_cca_items.append((cca_df, posData, i)) - + if not missing_cca_items: return proceed, ignore, doNotShowAgain - + proceed = False - ignore, doNotShowAgain = _warnings.warnMissingCca( + ignore, doNotShowAgain =_warnings.warnMissingCca( missing_cca_items, qparent=self ) - + if doNotShowAgain: - self.df_settings.at["doNotShowAgainMissingCca", "value"] = "Yes" + self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'Yes' self.df_settings.to_csv(self.settings_csv_path) - + return proceed, ignore, doNotShowAgain def computeVolumeRegionprop(self): - if "cell_vol_vox" not in self._measurements_kernel.sizeMetricsToSave: + if 'cell_vol_vox' not in self._measurements_kernel.sizeMetricsToSave: return # We compute the cell volume in the main thread because calling # skimage.transform.rotate in a separate thread causes crashes # with segmentation fault on macOS. I don't know why yet. - self.logger.info("Computing cell volume...") + self.logger.info('Computing cell volume...') end_i = self.save_until_frame_i pos_iter = tqdm(self.data, ncols=100) for p, posData in enumerate(pos_iter): if self.posToSave is not None: if posData.pos_foldername not in self.posToSave: continue - + PhysicalSizeY = posData.PhysicalSizeY PhysicalSizeX = posData.PhysicalSizeX frame_iter = tqdm( - posData.allData_li[: end_i + 1], ncols=100, position=1, leave=False + posData.allData_li[:end_i+1], ncols=100, position=1, leave=False ) for frame_i, data_dict in enumerate(frame_iter): - lab = data_dict["labels"] + lab = data_dict['labels'] if lab is None: break - rp = data_dict["regionprops"] + rp = data_dict['regionprops'] obj_iter = tqdm(rp, ncols=100, position=2, leave=False) for i, obj in enumerate(obj_iter): - vol_vox, vol_fl = _calc_rot_vol(obj, PhysicalSizeY, PhysicalSizeX) + vol_vox, vol_fl = _calc_rot_vol( + obj, PhysicalSizeY, PhysicalSizeX + ) obj.vol_vox = vol_vox obj.vol_fl = vol_fl - posData.allData_li[frame_i]["regionprops"] = rp - - def concatenate_prompt_plan( - self, - *, - has_main_window: bool, - is_quick_save: bool, - setting_exists: bool, - show_setting_value: str | None, - ) -> ConcatenatePromptPlan: - if not has_main_window or is_quick_save: - return ConcatenatePromptPlan( - should_prompt=False, - ensure_setting=False, - ) - - should_prompt = show_setting_value != "No" - return ConcatenatePromptPlan( - should_prompt=should_prompt, - ensure_setting=not setting_exists, - ) - - def concatenate_prompt_setting(self, *, do_not_show_again: bool) -> str: - if do_not_show_again: - return "No" - return "Yes" + posData.allData_li[frame_i]['regionprops'] = rp def enqAutosave(self): mode = str(self.modeComboBox.currentText()) - if mode == "Viewer": - if self.statusBarLabel.text().endswith("Autosaving..."): + if mode == 'Viewer': + if self.statusBarLabel.text().endswith('Autosaving...'): self.statusBarLabel.setText( - self.statusBarLabel.text().replace(" | Autosaving...", "") + self.statusBarLabel.text().replace(' | Autosaving...', '') ) - return - + return + if not self.autoSaveActiveWorkers: self.gui_createAutoSaveWorker() - + if not self.autoSaveActiveWorkers: return - + if self.autoSaveTimer.isActive(): return - + self._enqueueAutoSave() - autoSaveIntevalValue, autoSaveIntervalUnit = self.autoSaveIntevalValueUnit + autoSaveIntevalValue, autoSaveIntervalUnit = ( + self.autoSaveIntevalValueUnit + ) if autoSaveIntevalValue == 0: return - + try: self.autoSaveTimer.timeout.disconnect() - except Exception: + except Exception as err: pass - - if autoSaveIntervalUnit == "minutes": - autosave_interval_ms = round(autoSaveIntevalValue * 60 * 1000) + + + if autoSaveIntervalUnit == 'minutes': + autosave_interval_ms = round(autoSaveIntevalValue*60*1000) self.autoSaveTimer.timeout.connect(self.autoSaveTimerTimedOut) self.autoSaveTimer.start(autosave_interval_ms) else: @@ -629,11 +562,11 @@ def manageVersions(self): undoId = uuid.uuid4() if posData.cca_df is not None: self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - + selectedTime = selectVersion.selectedTimestamp - self.modeComboBox.setCurrentText("Viewer") - self.logger.info(f"Loading file from {selectedTime}...") + self.modeComboBox.setCurrentText('Viewer') + self.logger.info(f'Loading file from {selectedTime}...') acdc_df = load.read_acdc_df_from_archive( selectVersion.archiveFilePath, selectVersion.selectedKey @@ -642,39 +575,36 @@ def manageVersions(self): frames = acdc_df.index.get_level_values(0) last_visited_frame_i = frames.max() current_frame_i = posData.frame_i - pbar = tqdm(total=last_visited_frame_i + 1, ncols=100) - for frame_i in range(last_visited_frame_i + 1): + pbar = tqdm(total=last_visited_frame_i+1, ncols=100) + for frame_i in range(last_visited_frame_i+1): posData.frame_i = frame_i self.get_data() if posData.cca_df is not None: self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - if posData.allData_li[frame_i]["labels"] is None: + if posData.allData_li[frame_i]['labels'] is None: pbar.update() continue - + if frame_i not in frames: acdc_df_i = pd.DataFrame(columns=acdc_df.columns) - acdc_df_i.drop(self.cca_df_colnames, axis=1, errors="ignore") - acdc_df_i.index.name = "Cell_ID" + acdc_df_i.drop(self.cca_df_colnames, axis=1, errors='ignore') + acdc_df_i.index.name = 'Cell_ID' else: - acdc_df_i = acdc_df.loc[frame_i].dropna(axis=1, how="all") - - posData.allData_li[frame_i]["acdc_df"] = acdc_df_i + acdc_df_i = acdc_df.loc[frame_i].dropna(axis=1, how='all') + + posData.allData_li[frame_i]['acdc_df'] = acdc_df_i pbar.update() pbar.close() - + # Back to current frame posData.frame_i = current_frame_i self.get_data(debug=False) self.updateAllImages() - self.logger.info("Annotations correctly recovered.") + self.logger.info('Annotations correctly recovered.') def quickSave(self): self.saveData(isQuickSave=True) - def quick_save_positions(self, position_foldername: str) -> set[str]: - return {position_foldername} - def saveAsData(self, checked=True): try: posData = self.data[self.pos_i] @@ -684,19 +614,22 @@ def saveAsData(self, checked=True): existingFilenames = set() for _posData in self.data: segm_files = load.get_segm_files(_posData.images_path) - _existingEndnames = load.get_endnames(_posData.basename, segm_files) - existingFilenames.update( - [f"{_posData.basename}{endname}.npz" for endname in _existingEndnames] + _existingEndnames = load.get_endnames( + _posData.basename, segm_files ) + existingFilenames.update([ + f'{_posData.basename}{endname}.npz' + for endname in _existingEndnames + ]) posData = self.data[self.pos_i] - if posData.basename.endswith("_"): - basename = f"{posData.basename}segm" + if posData.basename.endswith('_'): + basename = f'{posData.basename}segm' else: - basename = f"{posData.basename}_segm" + basename = f'{posData.basename}_segm' win = apps.filenameDialog( basename=basename, - hintText="Insert a filename for the segmentation file:
", - existingNames=existingFilenames, + hintText='Insert a filename for the segmentation file:
', + existingNames=existingFilenames ) win.exec_() if win.cancel: @@ -708,7 +641,6 @@ def saveAsData(self, checked=True): self.setStatusBarLabel() self.saveData() - @exception_handler def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.setDisabled(True, keepDisabled=True) @@ -721,8 +653,8 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): # Wait autosave worker to finish for worker, thread in self.autoSaveActiveWorkers: - self.logger.info("Stopping autosaving process...") - self.statusBarLabel.setText("Stopping autosaving process...") + self.logger.info('Stopping autosaving process...') + self.statusBarLabel.setText('Stopping autosaving process...') worker.stop() self.waitAutoSaveWorkerTimer = QTimer() self.waitAutoSaveWorkerTimer.timeout.connect( @@ -733,7 +665,8 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.waitAutoSaveWorkerLoop.exec_() self.titleLabel.setText( - "Saving data... (check progress in the terminal)", color=self.titleColor + 'Saving data... (check progress in the terminal)', + color=self.titleColor ) # Check channel name correspondence to warn @@ -756,8 +689,8 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.cancelSavingInitialisation() self.setDisabled(False, keepDisabled=False) self.activateWindow() - return - + return + self.save_metrics = False if not isQuickSave: self.save_metrics, cancel = self.askSaveMetrics() @@ -779,12 +712,12 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): if isQuickSave: # Quick save only current pos self.posToSave = {self.data[self.pos_i].pos_foldername} - + if self.isSnapshot: self.store_data(mainThread=False) mode = self.modeComboBox.currentText() - if mode == "Cell cycle analysis": + if mode == 'Cell cycle analysis': proceed = self.askSaveLastVisitedCcaMode(isQuickSave=isQuickSave) if not proceed: self.cancelSavingInitialisation() @@ -798,30 +731,28 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.setDisabled(False, keepDisabled=False) self.activateWindow() return True - - append_name_og_whitelist, proceed, do_not_save_og_whitelist = ( - self.askSaveOriginalSegm(isQuickSave=isQuickSave) - ) + + append_name_og_whitelist, proceed, do_not_save_og_whitelist = self.askSaveOriginalSegm(isQuickSave=isQuickSave) if not proceed: self.cancelSavingInitialisation() self.setDisabled(False, keepDisabled=False) self.activateWindow() return True - if self.save_metrics or mode == "Cell cycle analysis": + if self.save_metrics or mode == 'Cell cycle analysis': self.computeVolumeRegionprop() infoTxt = html_utils.paragraph( - f"Saving {self.exp_path}...
", font_size="14px" + f'Saving {self.exp_path}...
', font_size='14px' ) self.saveWin = apps.QDialogPbar( - parent=self, title="Saving data", infoTxt=infoTxt + parent=self, title='Saving data', infoTxt=infoTxt ) self.saveWin.setFont(_font) # if not self.save_metrics: self.saveWin.metricsQPbar.hide() - self.saveWin.progressLabel.setText("Preparing data...") + self.saveWin.progressLabel.setText('Preparing data...') self.saveWin.show() # Set up separate thread for saving and show progress bar widget @@ -849,12 +780,16 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.worker.progressBar.connect(self.saveDataUpdatePbar) # self.worker.metricsPbarProgress.connect(self.saveDataUpdateMetricsPbar) self.worker.critical.connect(self.saveDataWorkerCritical) - self.worker.customMetricsCritical.connect(self.saveDataCustomMetricsCritical) + self.worker.customMetricsCritical.connect( + self.saveDataCustomMetricsCritical + ) self.worker.sigCombinedMetricsMissingColumn.connect( self.saveDataCombinedMetricsMissingColumn ) self.worker.addMetricsCritical.connect(self.saveDataAddMetricsCritical) - self.worker.regionPropsCritical.connect(self.saveDataRegionPropsCritical) + self.worker.regionPropsCritical.connect( + self.saveDataRegionPropsCritical + ) self.worker.criticalPermissionError.connect(self.saveDataPermissionError) self.worker.askZsliceAbsent.connect(self.zSliceAbsent) self.worker.sigDebug.connect(self._workerDebug) @@ -862,43 +797,43 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.thread.started.connect(self.worker.run) self.thread.start() - + return False def saveDataAddMetricsCritical(self, traceback_format, error_message): self.setDisabled(False, keepDisabled=False) self.activateWindow() - self.logger.info("") - _hl = "====================================" - self.logger.info(f"{_hl}\n{traceback_format}\n{_hl}") + self.logger.info('') + _hl = '====================================' + self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') self.worker.addMetricsErrors[error_message] = traceback_format def saveDataCombinedMetricsMissingColumn(self, error_msg, func_name): self.setDisabled(False, keepDisabled=False) self.activateWindow() - self.logger.info("") - warning = f"[WARNING]: {error_msg}. Metric {func_name} was skipped." - _hl = "====================================" - self.logger.info(f"{_hl}\n{warning}\n{_hl}") + self.logger.info('') + warning = f'[WARNING]: {error_msg}. Metric {func_name} was skipped.' + _hl = '====================================' + self.logger.info(f'{_hl}\n{warning}\n{_hl}') self.worker.customMetricsErrors[func_name] = warning def saveDataCustomMetricsCritical(self, traceback_format, func_name): self.setDisabled(False, keepDisabled=False) self.activateWindow() - self.logger.info("") - _hl = "====================================" - self.logger.info(f"{_hl}\n{traceback_format}\n{_hl}") + self.logger.info('') + _hl = '====================================' + self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') self.worker.customMetricsErrors[func_name] = traceback_format def saveDataFinished(self): self.setDisabled(False, keepDisabled=False) self.activateWindow() if self.saveWin.aborted or self.worker.abort: - self.titleLabel.setText("Saving process cancelled.", color="r") + self.titleLabel.setText('Saving process cancelled.', color='r') elif self._isQuickSave: - self.titleLabel.setText("Saved segmentation file and annotations") + self.titleLabel.setText('Saved segmentation file and annotations') else: - self.titleLabel.setText("Saved!") + self.titleLabel.setText('Saved!') self.saveWin.workerFinished = True self.saveWin.close() @@ -907,30 +842,31 @@ def saveDataFinished(self): self.updateSegmDataAutoSaveWorker() if self.worker.addMetricsErrors: - self.warnErrorsAddMetrics() + self.warnErrorsAddMetrics() if self.worker.regionPropsErrors: self.warnErrorsRegionProps() if self.worker.customMetricsErrors: self.warnErrorsCustomMetrics() - + self.checkManageVersions() - + self.askConcatenate() - + if self.closeGUI: salute_string = myutils.get_salute_string() msg = widgets.myMessageBox() txt = html_utils.paragraph( - f"Data saved!. The GUI will now close.

{salute_string}" + 'Data saved!. The GUI will now close.

' + f'{salute_string}' ) - msg.information(self, "Data saved", txt) + msg.information(self, 'Data saved', txt) self.close() def saveDataPermissionError(self, err_msg): self.setDisabled(False, keepDisabled=False) self.activateWindow() msg = QMessageBox() - msg.critical(self, "Permission denied", err_msg, msg.Ok) + msg.critical(self, 'Permission denied', err_msg, msg.Ok) self.waitCond.wakeAll() def saveDataProgress(self, text): @@ -940,33 +876,35 @@ def saveDataProgress(self, text): def saveDataRegionPropsCritical(self, traceback_format, error_message): self.setDisabled(False, keepDisabled=False) self.activateWindow() - self.logger.info("") - _hl = "====================================" - self.logger.info(f"{_hl}\n{traceback_format}\n{_hl}") + self.logger.info('') + _hl = '====================================' + self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') self.worker.regionPropsErrors[error_message] = traceback_format def saveDataUpdateMetricsPbar(self, max, step): if max > 0: self.saveWin.metricsQPbar.setMaximum(max) self.saveWin.metricsQPbar.setValue(0) - self.saveWin.metricsQPbar.setValue(self.saveWin.metricsQPbar.value() + step) + self.saveWin.metricsQPbar.setValue( + self.saveWin.metricsQPbar.value()+step + ) def saveDataUpdatePbar(self, step, max=-1, exec_time=0.0): if max >= 0: self.saveWin.QPbar.setMaximum(max) else: - self.saveWin.QPbar.setValue(self.saveWin.QPbar.value() + step) - steps_left = self.saveWin.QPbar.maximum() - self.saveWin.QPbar.value() - seconds = round(exec_time * steps_left) + self.saveWin.QPbar.setValue(self.saveWin.QPbar.value()+step) + steps_left = self.saveWin.QPbar.maximum()-self.saveWin.QPbar.value() + seconds = round(exec_time*steps_left) ETA = myutils.seconds_to_ETA(seconds) - self.saveWin.ETA_label.setText(f"ETA: {ETA}") + self.saveWin.ETA_label.setText(f'ETA: {ETA}') def saveMetricsCritical(self, traceback_format): - print("\n====================================") + print('\n====================================') self.logger.exception(traceback_format) - print("====================================\n") - self.logger.info("Warning: calculating metrics failed see above...") - print("------------------------------") + print('====================================\n') + self.logger.info('Warning: calculating metrics failed see above...') + print('------------------------------') msg = widgets.myMessageBox(wrapText=False) err_msg = html_utils.paragraph(f""" @@ -979,37 +917,19 @@ def saveMetricsCritical(self, traceback_format): Please restart Cell-ACDC, we apologise for any inconvenience.

""") - msg.addShowInFileManagerButton(self.logs_path, txt="Show log file...") + msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...') msg.setDetailedText(traceback_format, visible=True) - msg.critical(self, "Critical error while saving metrics", err_msg) + msg.critical(self, 'Critical error while saving metrics', err_msg) self.is_error_state = True self.waitCond.wakeAll() - def save_as_basename(self, basename: str) -> str: - if basename.endswith("_"): - return f"{basename}segm" - return f"{basename}_segm" - - def save_finished_title( - self, - *, - aborted: bool, - worker_aborted: bool, - is_quick_save: bool, - ) -> tuple[str, str | None]: - if aborted or worker_aborted: - return "Saving process cancelled.", "r" - if is_quick_save: - return "Saved segmentation file and annotations", None - return "Saved!", None - def setAutoSaveAnnotationsEnabled(self, enabled): if not self.autoSaveActiveWorkers: return - + worker, thread = self.autoSaveActiveWorkers[-1] - + if enabled: worker.isAutoSaveAnnotON = self.autoSaveToggle.isChecked() else: @@ -1018,41 +938,20 @@ def setAutoSaveAnnotationsEnabled(self, enabled): def setAutoSaveSegmentationEnabled(self, enabled): if not self.autoSaveActiveWorkers: return - + worker, thread = self.autoSaveActiveWorkers[-1] - + if enabled: worker.isAutoSaveON = self.autoSaveToggle.isChecked() else: worker.isAutoSaveON = False - def should_ask_positions( - self, - *, - is_snapshot: bool, - is_quick_save: bool, - position_count: int, - ) -> bool: - return is_snapshot and not is_quick_save and position_count > 1 - - def should_clear_autosave_status(self, *, mode: str) -> bool: - return mode == self.viewer_mode - - def should_compute_volume_metrics( - self, - *, - save_metrics: bool, - mode: str, - ) -> bool: - return save_metrics or mode == self.cell_cycle_mode - - def should_enqueue_autosave(self, *, mode: str, has_active_workers: bool): - return mode != self.viewer_mode and has_active_workers - def startAutoSaveEveryNframesTimer(self): posData = self.data[self.pos_i] self.autoSaveTimeStartFrameIdx = posData.frame_i - self.autoSaveTimer.timeout.connect(self.autoSaveTimerCountFrames) + self.autoSaveTimer.timeout.connect( + self.autoSaveTimerCountFrames + ) self.autoSaveTimer.start(500) def turnOffAutoSaveWorker(self): @@ -1071,8 +970,8 @@ def waitAutoSaveWorker(self, worker): self.setStatusBarLabel(log=False) def warnDifferentSegmChannel( - self, loaded_channel, segm_channel_hyperparams, segmEndName - ): + self, loaded_channel, segm_channel_hyperparams, segmEndName + ): txt = html_utils.paragraph(f""" You loaded the segmentation file ending with _{segmEndName}.npz which corresponds to the channel @@ -1085,36 +984,28 @@ def warnDifferentSegmChannel( """) msg = widgets.myMessageBox(showCentered=False, wrapText=False) msg.warning( - self, - "WARNING: Potential for data loss", - txt, - buttonsTexts=("Cancel", "Yes"), + self, 'WARNING: Potential for data loss', txt, + buttonsTexts=('Cancel', 'Yes') ) return msg.cancel def warnErrorsAddMetrics(self): win = apps.ComputeMetricsErrorsDialog( - self.worker.addMetricsErrors, - self.logs_path, - log_type="standard_metrics", - parent=self, + self.worker.addMetricsErrors, self.logs_path, + log_type='standard_metrics', parent=self ) win.exec_() def warnErrorsCustomMetrics(self): win = apps.ComputeMetricsErrorsDialog( - self.worker.customMetricsErrors, - self.logs_path, - log_type="custom_metrics", - parent=self, + self.worker.customMetricsErrors, self.logs_path, + log_type='custom_metrics', parent=self ) win.exec_() def warnErrorsRegionProps(self): win = apps.ComputeMetricsErrorsDialog( - self.worker.regionPropsErrors, - self.logs_path, - log_type="region_props", - parent=self, + self.worker.regionPropsErrors, self.logs_path, + log_type='region_props', parent=self ) win.exec_() diff --git a/cellacdc/mixins/seg_for_lost_ids.py b/cellacdc/mixins/seg_for_lost_ids.py new file mode 100644 index 000000000..99d194691 --- /dev/null +++ b/cellacdc/mixins/seg_for_lost_ids.py @@ -0,0 +1,242 @@ +"""Qt view adapter for segmenting lost IDs.""" + +from __future__ import annotations + +from typing import Any + +from qtpy.QtCore import QMutex, QThread, QWaitCondition + +from cellacdc import apps, workers +from cellacdc.plot import imshow + + +class SegForLostIds: + """Extracted from guiWin.""" + + def SegForLostIDsSetSettings(self): + + try: + prev_model = str(self.df_settings.at['SegForLostIDsModel', 'value']) + except KeyError: + prev_model = None + win = apps.QDialogSelectModel(parent=self, customFirst=prev_model) + win.exec_() + if win.cancel: + self.logger.info('Seg for lost IDs cancelled.') + return + base_model_name = win.selectedModel + + if base_model_name: + self.df_settings.at['SegForLostIDsModel', 'value'] = base_model_name + self.df_settings.to_csv(self.settings_csv_path) + + model_name = 'local_seg' + + idx = self.modelNames.index(model_name) + acdcSegment = self.acdcSegment_li[idx] + + try: + if acdcSegment is None or base_model_name != self.local_seg_base_model_name: + self.logger.info(f'Importing {base_model_name}...') + acdcSegment = myutils.import_segment_module(base_model_name) + self.acdcSegment_li[idx] = acdcSegment + self.local_seg_base_model_name = base_model_name + except (IndexError, ImportError, KeyError) as e: + self.logger.error(f'Error importing {base_model_name}: {e}') + return + + extra_params = ['overlap_threshold', + 'padding', + 'size_perc_diff', + 'distance_filler_growth', + 'max_iterations', + 'allow_only_tracked_cells'] + + extra_types = [float, float, float, float, int, bool] + + extra_defaults = [0.5, 0.8, 0.3, 1., 2, False] + + extra_desc = ['Overlap threshold with other already segemented cells over which newly segmented cells are discarded', + 'Padding of the box used for new segmentation around the segmentation from the previous frame', + 'Relative size difference acceptable compared to previous frames', + """Cells which are already segmented are filled with random noise sampled from background + to ensure that they don't get segmented again. + This parameter controls the additional padding around the already segmented cells.""", + """The algorithm will try and segment the maximum amount + of cells in the image by running the model several + times and filling new found cells with background noise. + How many of these iterations should be run?""", + "If no new cell IDs should be permitted (based on real time tracking)"] + + extra_ArgSpec = [] + for i, param in enumerate(extra_params): + param = ArgSpec(name=param, + default=extra_defaults[i], + type=extra_types[i], + desc=extra_desc[i], + docstring='') + + extra_ArgSpec.append(param) + + init_params, segment_params = myutils.getModelArgSpec(acdcSegment) + segment_params = [arg for arg in segment_params if arg[0] != 'diameter'] + + extraParamsTitle = 'Settings for local segmentation' + win = self.initSegmModelParams( + base_model_name, acdcSegment, init_params, segment_params, + extraParams=extra_ArgSpec, extraParamsTitle=extraParamsTitle, + initLastParams=True, ini_filename='segmentation_for_lostIDs.ini', + ) + + if win is None: + self.logger.info('Segmentation for lost IDs cancelled.') + return + + init_kwargs_new = {} + args_new = {} + for key, val in win.init_kwargs.items(): + if key in extra_params: + args_new[key] = val + else: + init_kwargs_new[key] = val + + for key, val in win.extra_kwargs.items(): + if key in extra_params: + args_new[key] = val + + self.SegForLostIDsSettings = { + 'win': win, + 'init_kwargs_new': init_kwargs_new, + 'args_new': args_new, + 'base_model_name': base_model_name, + } + + def SegForLostIDsWorkerAskInstallGPU(self, model_name, use_gpu): + result = myutils.check_gpu_available(model_name, use_gpu, qparent=self) + self.SegForLostIDsWorker.gpu_go = result + dont_force_cpu = myutils.check_gpu_available( + model_name, use_gpu, do_not_warn=True) + self.SegForLostIDsWorker.dont_force_cpu = dont_force_cpu + self.SegForLostIDsWaitCond.wakeAll() + + def SegForLostIDsWorkerAskInstallModel(self, model_name): + myutils.check_install_package(model_name) + self.SegForLostIDsWaitCond.wakeAll() + + def SegForLostIDsWorkerFinished(self): + self.updateAllImages() + self.update_rp() + self.store_data(autosave=True) + self.setFrameNavigationDisabled(disable=False, why='Segmentation for lost IDs') + + if self.progressWin is not None: + self.progressWin.workerFinished = True + self.progressWin.close() + self.progressWin = None + + def onSegForLostInit(self): + self.logger.info('Settings for segmentation for lost IDs not set.') + self.SegForLostIDsSetSettings() + self.SegForLostIDsWaitCond.wakeAll() + + def onSigGetData(self, waitcond, debug=False): + self.get_data(debug=debug) + waitcond.wakeAll() + + def onSigStoreData( + self, waitcond, pos_i=None, enforce=True, debug=False, + mainThread=True, autosave=True, store_cca_df_copy=False + ): + self.store_data(pos_i=pos_i, enforce=enforce, debug=debug, mainThread=mainThread, + autosave=autosave, store_cca_df_copy=store_cca_df_copy) + waitcond.wakeAll() + + def onSigStoreDataSegForLostIDsWorker(self, autosave): + self.onSigStoreData( + self.SegForLostIDsWaitCond, autosave=autosave) + + def onSigTrackManuallyAddedObjectSegForLostIDsWorker(self, added_IDs, isNewID, wl_update, wl_track_og_curr): + self.trackManuallyAddedObject(added_IDs, isNewID, wl_update=wl_update, wl_track_og_curr=wl_track_og_curr) + self.SegForLostIDsWaitCond.wakeAll() + + def onSigUpdateRP(self, waitcond, draw=True, debug=False, update_IDs=True, + wl_update=True, wl_track_og_curr=False): + self.update_rp(draw=draw, debug=debug, update_IDs=update_IDs, + wl_update=wl_update, wl_track_og_curr=wl_track_og_curr) + waitcond.wakeAll() + + def onSigUpdateRPSegForLostIDsWorker(self, wl_update, wl_track_og_curr): + self.onSigUpdateRP(self.SegForLostIDsWaitCond, + wl_update=wl_update, + wl_track_og_curr=wl_track_og_curr) + + def segForLostIDsButtonClicked(self): + + self.setFrameNavigationDisabled(disable=True, why='Segmentation for lost IDs') + posData = self.data[self.pos_i] + if posData.frame_i == 0: + self.logger.info('Segmentation for lost IDs not available on first frame.') + self.setFrameNavigationDisabled(disable=False, why='Segmentation for lost IDs') + return + self.storeUndoRedoStates(False) + self.progressWin = apps.QDialogWorkerProgress( + title='Segmenting for lost IDs', parent=self, + pbarDesc=f'Segmenting for lost IDs...' + ) + self.progressWin.show(self.app) + self.progressWin.mainPbar.setMaximum(0) + + self.startSegForLostIDsWorker() + + def showImageDebug(self, img): + imshow(img) + + def startSegForLostIDsWorker(self): + self.SegForLostIDsMutex = QMutex() + self.SegForLostIDsWaitCond = QWaitCondition() + self._thread = QThread() + + # Initialize the worker with mutex and wait condition + self.SegForLostIDsWorker = workers.SegForLostIDsWorker( + self, self.SegForLostIDsMutex, self.SegForLostIDsWaitCond + ) + + # Connect the worker's signal to the main thread's slot + self.SegForLostIDsWorker.sigAskInit.connect(self.onSegForLostInit) + self.SegForLostIDsWorker.sigAskInstallModel.connect( + self.SegForLostIDsWorkerAskInstallModel + ) + self.SegForLostIDsWorker.sigshowImageDebug.connect( + self.showImageDebug + ) + + self.SegForLostIDsWorker.sigSegForLostIDsWorkerAskInstallGPU.connect( + self.SegForLostIDsWorkerAskInstallGPU + ) + + self.SegForLostIDsWorker.sigStoreData.connect(self.onSigStoreDataSegForLostIDsWorker) + self.SegForLostIDsWorker.sigUpdateRP.connect(self.onSigUpdateRPSegForLostIDsWorker) + # self.SegForLostIDsWorker.sigGetData.connect(self.onSigGetDataSegForLostIDsWorker) + # self.SegForLostIDsWorker.sigGet2Dlab.connect(self.onSigGet2DlabSegForLostIDsWorker) + # self.SegForLostIDsWorker.sigGetTrackedLostIDs.connect(self.onSigGetTrackedSegForLostIDsWorker) + # self.SegForLostIDsWorker.sigGetBrushID.connect(self.onSigGetBrushIDSegForLostIDsWorker) + self.SegForLostIDsWorker.sigTrackManuallyAddedObject.connect(self.onSigTrackManuallyAddedObjectSegForLostIDsWorker) + + # Move the worker to the thread + self.SegForLostIDsWorker.moveToThread(self._thread) + + # Manage thread lifecycle + self.SegForLostIDsWorker.signals.finished.connect(self._thread.quit) + self.SegForLostIDsWorker.signals.finished.connect(self.SegForLostIDsWorker.deleteLater) + self._thread.finished.connect(self._thread.deleteLater) + + # Connect other worker signals to the appropriate slots + self.SegForLostIDsWorker.signals.finished.connect(self.SegForLostIDsWorkerFinished) + self.SegForLostIDsWorker.signals.progress.connect(self.workerProgress) + self.SegForLostIDsWorker.signals.initProgressBar.connect(self.workerInitProgressbar) + self.SegForLostIDsWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.SegForLostIDsWorker.signals.critical.connect(self.workerCritical) + + # Start the thread and worker + self._thread.started.connect(self.SegForLostIDsWorker.run) + self._thread.start() diff --git a/cellacdc/mixins_bak/segmentation.py b/cellacdc/mixins/segmentation.py similarity index 64% rename from cellacdc/mixins_bak/segmentation.py rename to cellacdc/mixins/segmentation.py index a58a519e3..928098301 100644 --- a/cellacdc/mixins_bak/segmentation.py +++ b/cellacdc/mixins/segmentation.py @@ -20,13 +20,8 @@ from cellacdc.plot import imshow -class SegmentationMixin: - """Qt-facing segmentation workflow adapter.""" - - def action_model_name(self, model_name: str) -> str: - if model_name == self.thresholding_backend_name: - return self.thresholding_action_name - return model_name +class Segmentation: + """Extracted from guiWin.""" def autoSegm_cb(self, checked): if checked: @@ -34,11 +29,11 @@ def autoSegm_cb(self, checked): # Ask which model models = myutils.get_list_of_models() win = widgets.QDialogListbox( - "Select model", - "Select model to use for segmentation: ", + 'Select model', + 'Select model to use for segmentation: ', models, multiSelection=False, - parent=self, + parent=self ) win.exec_() if win.cancel: @@ -53,11 +48,6 @@ def autoSegm_cb(self, checked): else: self.segmModelName = None - def backend_model_name(self, model_name: str) -> str: - if model_name == self.thresholding_action_name: - return self.thresholding_backend_name - return model_name - def checkIfAutoSegm(self): """ If there are any frame or position with empty segmentation mask @@ -74,29 +64,30 @@ def checkIfAutoSegm(self): for lab in posData.segm_data: if not np.any(lab): ask = True - txt = "frames" + txt = 'frames' break else: if not np.any(posData.segm_data): ask = True - txt = "positions" + txt = 'positions' break if not ask: return questionTxt = html_utils.paragraph( - f"Some or all loaded {txt} contain empty segmentation masks.

" - "Do you want to activate automatic segmentation* " - f"when visiting these {txt}?

" - "* Automatic segmentation can always be turned ON/OFF from the menu
" - " Edit --> Segmentation --> Enable automatic segmentation

" - f"NOTE: you can automatically segment all {txt} using the
" - " segmentation module." + f'Some or all loaded {txt} contain empty segmentation masks.

' + 'Do you want to activate automatic segmentation* ' + f'when visiting these {txt}?

' + '* Automatic segmentation can always be turned ON/OFF from the menu
' + ' Edit --> Segmentation --> Enable automatic segmentation

' + f'NOTE: you can automatically segment all {txt} using the
' + ' segmentation module.' ) msg = widgets.myMessageBox(wrapText=False) noButton, yesButton = msg.question( - self, "Automatic segmentation?", questionTxt, buttonsTexts=("No", "Yes") + self, 'Automatic segmentation?', questionTxt, + buttonsTexts=('No', 'Yes') ) if msg.clickedButton == yesButton: self.autoSegmAction.setChecked(True) @@ -107,7 +98,7 @@ def checkIfAutoSegm(self): def computeSegm(self, force=False): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if mode == "Viewer" or mode == "Cell cycle analysis": + if mode == 'Viewer' or mode == 'Cell cycle analysis': return if np.any(posData.lab) and not force: @@ -125,69 +116,46 @@ def debugSegmWorker(self, to_debug): imshow(img, _lab, lab) self.segmWorkerWaitCond.wakeAll() - def empty_segmentation_prompt(self, position_data) -> EmptySegmentationPrompt: - for pos_data in position_data: - if pos_data.SizeT > 1: - for lab in pos_data.segm_data: - if not np.any(lab): - return EmptySegmentationPrompt(True, "frames") - elif not np.any(pos_data.segm_data): - return EmptySegmentationPrompt(True, "positions") - return EmptySegmentationPrompt(False) - def initSegmModelParams( - self, - model_name, - acdcSegment, - init_params, - segment_params, - is_label_roi=False, - initLastParams=False, - extraParams=None, - extraParamsTitle=None, - ini_filename=None, - ): - posData = self.data[self.pos_i] + self, model_name, acdcSegment, init_params, segment_params, + is_label_roi=False, initLastParams=False, + extraParams=None, extraParamsTitle=None,ini_filename=None + + ): + posData = self.data[self.pos_i] try: url = acdcSegment.url_help() except AttributeError: url = None - - text_if_cancelled = "Segmentation process cancelled." + + text_if_cancelled = 'Segmentation process cancelled.' out = prompts.init_segm_model_params( - posData, - model_name, - init_params, - segment_params, - help_url=url, - qparent=self, - init_last_params=initLastParams, - check_sam_embeddings=not is_label_roi, - is_gui_caller=True, - extraParams=extraParams, - extraParamsTitle=extraParamsTitle, + posData, model_name, init_params, segment_params, + help_url=url, qparent=self, init_last_params=initLastParams, + check_sam_embeddings=not is_label_roi, is_gui_caller=True, + extraParams=extraParams,extraParamsTitle=extraParamsTitle, ini_filename=ini_filename, ) - if out.get("load_sam_embeddings", False): - self.logger.info("Loading Segment Anything image embeddings...") + if out.get('load_sam_embeddings', False): + self.logger.info('Loading Segment Anything image embeddings...') for _posData in self.data: _posData.loadSamEmbeddings(logger_func=None) - text_if_cancelled = "SAM embeddings loaded." - - win = out.get("win") + text_if_cancelled = 'SAM embeddings loaded.' + + win = out.get('win') if win is None: self.logger.info(text_if_cancelled) self.titleLabel.setText(text_if_cancelled) return - + if win.cancel: self.logger.info(text_if_cancelled) self.titleLabel.setText(text_if_cancelled) return - - if model_name != "thresholding": + + if model_name != 'thresholding': self.model_kwargs = win.model_kwargs - + return win def init_segmInfo_df(self): @@ -200,13 +168,17 @@ def init_segmInfo_df(self): def postProcessSegm(self, checked): if self.isSegm3D: - max([posData.SizeZ for posData in self.data]) + SizeZ = max([posData.SizeZ for posData in self.data]) else: - pass + SizeZ = None if checked: posData = self.data[self.pos_i] - self.postProcessSegmWin = apps.PostProcessSegmDialog(posData, mainWin=self) - self.postProcessSegmWin.sigClosed.connect(self.postProcessSegmWinClosed) + self.postProcessSegmWin = apps.PostProcessSegmDialog( + posData, mainWin=self + ) + self.postProcessSegmWin.sigClosed.connect( + self.postProcessSegmWinClosed + ) self.postProcessSegmWin.sigValueChanged.connect( self.postProcessSegmValueChanged ) @@ -223,30 +195,27 @@ def postProcessSegm(self, checked): self.postProcessSegmWin = None def postProcessSegmApplyToAllFutureFrames( - self, - postProcessKwargs, - customPostProcessGroupedFeatures, - customPostProcessFeatures, - ): + self, postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures + ): proceed = self.warnEditingWithCca_df( - "post-processing segmentation", update_images=False + 'post-processing segmentation', update_images=False ) if not proceed: - self.logger.info("Post-processing segmentation cancelled.") + self.logger.info('Post-processing segmentation cancelled.') return self.progressWin = apps.QDialogWorkerProgress( - title="Post-processing segmentation", - parent=self, - pbarDesc="Post-processing segmentation masks...", + title='Post-processing segmentation', parent=self, + pbarDesc=f'Post-processing segmentation masks...' ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) self.startPostProcessSegmWorker( - postProcessKwargs, - customPostProcessGroupedFeatures, - customPostProcessFeatures, + postProcessKwargs, customPostProcessGroupedFeatures, + customPostProcessFeatures ) def postProcessSegmEditingFinished(self): @@ -258,19 +227,19 @@ def postProcessSegmValueChanged(self, lab, delObjs: dict): for delObj in delObjs.values(): self.clearObjContour(obj=delObj, ax=0) self.clearObjContour(obj=delObj, ax=1) - + posData = self.data[self.pos_i] - + labelsToSkip = {} for ID in posData.IDs: if ID in delObjs: labelsToSkip[ID] = True continue - + restoreObj = self.postProcessSegmWin.origObjs[ID] self.addObjContourToContoursImage(obj=restoreObj, ax=0) self.addObjContourToContoursImage(obj=restoreObj, ax=1) - + # self.setAllTextAnnotations(labelsToSkip=labelsToSkip) posData.lab = lab @@ -292,46 +261,34 @@ def postProcessSegmWorkerFinished(self): self.progressWin = None self.get_data() self.updateAllImages() - self.titleLabel.setText("Post-processing segmentation done!", color="w") - self.logger.info("Post-processing segmentation done!") + self.titleLabel.setText('Post-processing segmentation done!', color='w') + self.logger.info('Post-processing segmentation done!') - @exception_handler def postProcessing(self): if self.postProcessSegmWin is None: return - + self.postProcessSegmWin.setPosData() posData = self.data[self.pos_i] lab, delIDs = self.postProcessSegmWin.apply() - if posData.allData_li[posData.frame_i]["labels"] is None: + if posData.allData_li[posData.frame_i]['labels'] is None: posData.lab = lab.copy() self.update_rp() else: - posData.allData_li[posData.frame_i]["labels"] = lab + posData.allData_li[posData.frame_i]['labels'] = lab self.get_data() - def post_process_params( - self, - *, - apply_postprocessing, - standard_postprocess_kwargs=None, - custom_postprocess_features=None, - ) -> dict: - params = {"applied_postprocessing": apply_postprocessing} - params.update(standard_postprocess_kwargs or {}) - params.update(custom_postprocess_features or {}) - return params - def reinitStoredSegmModels(self): - self.models = [None] * len(self.models) + self.models = [None]*len(self.models) - @exception_handler - def repeatSegm(self, model_name="", askSegmParams=False, is_label_roi=False): - if model_name == "thresholding": + def repeatSegm( + self, model_name='', askSegmParams=False, is_label_roi=False + ): + if model_name == 'thresholding': # thresholding model is stored as 'Automatic thresholding' # at line of code `models.append('Automatic thresholding')` - model_name = "Automatic thresholding" - + model_name = 'Automatic thresholding' + idx = self.modelNames.index(model_name) # Ask segm parameters if not already set # and not called by segmSingleFrameMenu (askSegmParams=False) @@ -344,17 +301,17 @@ def repeatSegm(self, model_name="", askSegmParams=False, is_label_roi=False): # Store undo state before modifying stuff self.storeUndoRedoStates(False) - if model_name == "Automatic thresholding": - # Automatic thresholding is the name of the models as stored + if model_name == 'Automatic thresholding': + # Automatic thresholding is the name of the models as stored # in self.modelNames, but the actual model is called thresholding # (see cellacdc/models/thresholding) - model_name = "thresholding" + model_name = 'thresholding' posData = self.data[self.pos_i] # Check if model needs to be imported acdcSegment = self.acdcSegment_li[idx] if acdcSegment is None: - self.logger.info(f"Importing {model_name}...") + self.logger.info(f'Importing {model_name}...') acdcSegment = myutils.import_segment_module(model_name) self.acdcSegment_li[idx] = acdcSegment @@ -369,13 +326,13 @@ def repeatSegm(self, model_name="", askSegmParams=False, is_label_roi=False): init_params, segment_params = myutils.getModelArgSpec(acdcSegment) # Prompt user to enter the model parameters try: - acdcSegment.url_help() + url = acdcSegment.url_help() except AttributeError: - pass - + url = None + self.preproc_recipe = None initLastParams = True - if model_name == "thresholding": + if model_name == 'thresholding': win = apps.QDialogAutomaticThresholding( parent=self, isSegm3D=self.isSegm3D ) @@ -383,160 +340,156 @@ def repeatSegm(self, model_name="", askSegmParams=False, is_label_roi=False): if win.cancel: return self.model_kwargs = win.segment_kwargs - thresh_method = self.model_kwargs["threshold_method"] - gauss_sigma = self.model_kwargs["gauss_sigma"] + thresh_method = self.model_kwargs['threshold_method'] + gauss_sigma = self.model_kwargs['gauss_sigma'] segment_params = myutils.insertModelArgSpec( - segment_params, "threshold_method", thresh_method + segment_params, 'threshold_method', thresh_method ) segment_params = myutils.insertModelArgSpec( - segment_params, "gauss_sigma", gauss_sigma + segment_params, 'gauss_sigma', gauss_sigma ) initLastParams = False - + win = self.initSegmModelParams( - model_name, - acdcSegment, - init_params, - segment_params, - is_label_roi=is_label_roi, - initLastParams=initLastParams, + model_name, acdcSegment, init_params, segment_params, + is_label_roi=is_label_roi, + initLastParams=initLastParams ) if win is None: return - + self.standardPostProcessKwargs = win.standardPostProcessKwargs self.customPostProcessFeatures = win.customPostProcessFeatures - self.customPostProcessGroupedFeatures = win.customPostProcessGroupedFeatures + self.customPostProcessGroupedFeatures = ( + win.customPostProcessGroupedFeatures + ) self.applyPostProcessing = win.applyPostProcessing self.secondChannelName = win.secondChannelName self.preproc_recipe = win.preproc_recipe - + myutils.log_segm_params( - model_name, - win.init_kwargs, - win.model_kwargs, - logger_func=self.logger.info, - preproc_recipe=win.preproc_recipe, - apply_post_process=self.applyPostProcessing, - standard_postprocess_kwargs=self.standardPostProcessKwargs, - custom_postprocess_features=self.customPostProcessFeatures, + model_name, win.init_kwargs, win.model_kwargs, + logger_func=self.logger.info, + preproc_recipe=win.preproc_recipe, + apply_post_process=self.applyPostProcessing, + standard_postprocess_kwargs=self.standardPostProcessKwargs, + custom_postprocess_features=self.customPostProcessFeatures ) - use_gpu = win.init_kwargs.get("gpu", False) + use_gpu = win.init_kwargs.get('gpu', False) proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) if not proceed: - self.logger.info("Segmentation process cancelled.") - self.titleLabel.setText("Segmentation process cancelled.") + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') return - - model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) + + model = myutils.init_segm_model( + acdcSegment, posData, win.init_kwargs + ) if model is None: - self.logger.info("Segmentation process cancelled.") - self.titleLabel.setText("Segmentation process cancelled.") - return + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') + return try: model.setupLogger(self.logger) - except Exception: + except Exception as e: pass self.models[idx] = model model.model_name = model_name else: model = self.models[idx] - + if is_label_roi: return model self.titleLabel.setText( - f"Segmenting with {model_name}... (check progress in terminal/console)", - color=self.titleColor, + f'Segmenting with {model_name}... ' + '(check progress in terminal/console)', color=self.titleColor ) - - post_process_params = {"applied_postprocessing": self.applyPostProcessing} + post_process_params = { - **post_process_params, + 'applied_postprocessing': self.applyPostProcessing + } + post_process_params = { + **post_process_params, **self.standardPostProcessKwargs, - **self.customPostProcessFeatures, + **self.customPostProcessFeatures } if askSegmParams: posData.saveSegmHyperparams( - model_name, - win.init_kwargs, - win.model_kwargs, + model_name, win.init_kwargs, win.model_kwargs, post_process_params=post_process_params, - preproc_recipe=self.preproc_recipe, + preproc_recipe=self.preproc_recipe ) if self.askRepeatSegment3D: self.segment3D = False if self.isSegm3D and self.askRepeatSegment3D: msg = widgets.myMessageBox(showCentered=False) - msg.addDoNotShowAgainCheckbox(text="Do not ask again") + msg.addDoNotShowAgainCheckbox(text='Do not ask again') txt = html_utils.paragraph( - "Do you want to segment the entire z-stack or only the " - "current z-slice?" + 'Do you want to segment the entire z-stack or only the ' + 'current z-slice?' ) _, segment3DButton, _ = msg.question( - self, - "3D segmentation?", - txt, - buttonsTexts=("Cancel", "Segment 3D z-stack", "Segment 2D z-slice"), + self, '3D segmentation?', txt, + buttonsTexts=( + 'Cancel', 'Segment 3D z-stack', 'Segment 2D z-slice' + ) ) if msg.cancel: - self.titleLabel.setText("Segmentation process aborted.") - self.logger.info("Segmentation process aborted.") + self.titleLabel.setText('Segmentation process aborted.') + self.logger.info('Segmentation process aborted.') return self.segment3D = msg.clickedButton == segment3DButton if msg.doNotShowAgainCheckbox.isChecked(): self.askRepeatSegment3D = False - + if self.askZrangeSegm3D: self.z_range = None if self.isSegm3D and self.segment3D and self.askZrangeSegm3D: idx = (posData.filename, posData.frame_i) try: - orignal_z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] - except ValueError: - orignal_z = posData.segmInfo_df.loc[idx, "z_slice_used_gui"].iloc[0] + orignal_z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + except ValueError as e: + orignal_z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] selectZtool = apps.QCropZtool( - posData.SizeZ, - parent=self, - cropButtonText="Ok", - addDoNotShowAgain=True, - title="Select z-slice range to segment", + posData.SizeZ, parent=self, cropButtonText='Ok', + addDoNotShowAgain=True, title='Select z-slice range to segment' ) selectZtool.sigZvalueChanged.connect(self.selectZtoolZvalueChanged) selectZtool.sigCrop.connect(selectZtool.close) selectZtool.exec_() self.update_z_slice(orignal_z) if selectZtool.cancel: - self.titleLabel.setText("Segmentation process aborted.") - self.logger.info("Segmentation process aborted.") + self.titleLabel.setText('Segmentation process aborted.') + self.logger.info('Segmentation process aborted.') return startZ = selectZtool.lowerZscrollbar.value() stopZ = selectZtool.upperZscrollbar.value() self.z_range = (startZ, stopZ) if selectZtool.doNotShowAgainCheckbox.isChecked(): self.askZrangeSegm3D = False - + secondChannelData = None if self.secondChannelName is not None: secondChannelData = self.getSecondChannelData() - + self.titleLabel.setText( - f"{model_name} is thinking... (check progress in terminal/console)", - color=self.titleColor, + f'{model_name} is thinking... ' + '(check progress in terminal/console)', color=self.titleColor ) self.model = model - + self.segmWorkerMutex = QMutex() self.segmWorkerWaitCond = QWaitCondition() self.thread = QThread() self.worker = workers.segmWorker( - self, + self, secondChannelData=secondChannelData, - mutex=self.segmWorkerMutex, - waitCond=self.segmWorkerWaitCond, + mutex=self.segmWorkerMutex, + waitCond=self.segmWorkerWaitCond ) self.worker.z_range = self.z_range self.worker.moveToThread(self.thread) @@ -553,29 +506,28 @@ def repeatSegm(self, model_name="", askSegmParams=False, is_label_roi=False): self.thread.started.connect(self.worker.run) self.thread.start() - @exception_handler def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): - if model_name == "thresholding": + if model_name == 'thresholding': # thresholding model is stored as 'Automatic thresholding' # at line of code `models.append('Automatic thresholding')` - model_name = "Automatic thresholding" + model_name = 'Automatic thresholding' idx = self.modelNames.index(model_name) self.downloadWin = apps.downloadModel(model_name, parent=self) self.downloadWin.download() - if model_name == "Automatic thresholding": - # Automatic thresholding is the name of the models as stored + if model_name == 'Automatic thresholding': + # Automatic thresholding is the name of the models as stored # in self.modelNames, but the actual model is called thresholding # (see cellacdc/models/thresholding) - model_name = "thresholding" + model_name = 'thresholding' posData = self.data[self.pos_i] # Check if model needs to be imported acdcSegment = self.acdcSegment_li[idx] if acdcSegment is None: - self.logger.info(f"Importing {model_name}...") + self.logger.info(f'Importing {model_name}...') acdcSegment = myutils.import_segment_module(model_name) self.acdcSegment_li[idx] = acdcSegment @@ -583,18 +535,18 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): init_params, segment_params = myutils.getModelArgSpec(acdcSegment) # Prompt user to enter the model parameters try: - acdcSegment.url_help() + url = acdcSegment.url_help() except AttributeError: - pass - - if model_name == "thresholding": + url = None + + if model_name == 'thresholding': autoThreshWin = apps.QDialogAutomaticThresholding( parent=self, isSegm3D=self.isSegm3D ) autoThreshWin.exec_() if autoThreshWin.cancel: return - + win = self.initSegmModelParams( model_name, acdcSegment, init_params, segment_params ) @@ -603,57 +555,58 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): self.standardPostProcessKwargs = win.standardPostProcessKwargs self.customPostProcessFeatures = win.customPostProcessFeatures - self.customPostProcessGroupedFeatures = win.customPostProcessGroupedFeatures + self.customPostProcessGroupedFeatures = ( + win.customPostProcessGroupedFeatures + ) self.applyPostProcessing = win.applyPostProcessing self.preproc_recipe = win.preproc_recipe - + myutils.log_segm_params( - model_name, - win.init_kwargs, - win.model_kwargs, - logger_func=self.logger.info, - preproc_recipe=win.preproc_recipe, - apply_post_process=self.applyPostProcessing, - standard_postprocess_kwargs=self.standardPostProcessKwargs, - custom_postprocess_features=self.customPostProcessFeatures, + model_name, win.init_kwargs, win.model_kwargs, + logger_func=self.logger.info, + preproc_recipe=win.preproc_recipe, + apply_post_process=self.applyPostProcessing, + standard_postprocess_kwargs=self.standardPostProcessKwargs, + custom_postprocess_features=self.customPostProcessFeatures ) - + secondChannelData = None if win.secondChannelName is not None: secondChannelData = self.getSecondChannelData() - use_gpu = win.init_kwargs.get("gpu", False) + use_gpu = win.init_kwargs.get('gpu', False) proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) if not proceed: - self.logger.info("Segmentation process cancelled.") - self.titleLabel.setText("Segmentation process cancelled.") + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') return model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) if model is None: - self.logger.info("Segmentation process cancelled.") - self.titleLabel.setText("Segmentation process cancelled.") + self.logger.info('Segmentation process cancelled.') + self.titleLabel.setText('Segmentation process cancelled.') return try: model.setupLogger(self.logger) - except Exception: + except Exception as e: pass - + self.extendSegmDataIfNeeded(stopFrameNum) - self.reInitLastSegmFrame(from_frame_i=startFrameNum - 1, updateImages=False) + self.reInitLastSegmFrame( + from_frame_i=startFrameNum-1, updateImages=False + ) self.titleLabel.setText( - f"{model_name} is thinking... (check progress in terminal/console)", - color=self.titleColor, + f'{model_name} is thinking... ' + '(check progress in terminal/console)', color=self.titleColor ) self.progressWin = apps.QDialogWorkerProgress( - title="Segmenting video", - parent=self, - pbarDesc=f"Segmenting from frame n. {startFrameNum} to {stopFrameNum}...", + title='Segmenting video', parent=self, + pbarDesc=f'Segmenting from frame n. {startFrameNum} to {stopFrameNum}...' ) self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stopFrameNum - startFrameNum) + self.progressWin.mainPbar.setMaximum(stopFrameNum-startFrameNum) self.thread = QThread() self.worker = workers.segmVideoWorker( @@ -682,7 +635,7 @@ def resetCursor(self): def segmFrameCallback(self, action): if action == self.addCustomModelFrameAction: return - + idx = self.segmActions.index(action) model_name = self.modelNames[idx] self.repeatSegm(model_name=model_name, askSegmParams=True) @@ -693,11 +646,11 @@ def segmVideoCallback(self, action): posData = self.data[self.pos_i] win = apps.startStopFramesDialog( - posData.SizeT, currentFrameNum=posData.frame_i + 1 + posData.SizeT, currentFrameNum=posData.frame_i+1 ) win.exec_() if win.cancel: - self.logger.info("Segmentation on multiple frames aborted.") + self.logger.info('Segmentation on multiple frames aborted.') return idx = self.segmActionsVideo.index(action) @@ -715,18 +668,18 @@ def segmVideoWorkerFinished(self, exec_time): self.tracking(enforce=True) self.updateAllImages() - txt = f"Done. Segmentation computed in {exec_time:.3f} s" - self.logger.info("-----------------") + txt = f'Done. Segmentation computed in {exec_time:.3f} s' + self.logger.info('-----------------') self.logger.info(txt) - self.logger.info("=================") - self.titleLabel.setText(txt, color="g") + self.logger.info('=================') + self.titleLabel.setText(txt, color='g') def segmWorkerFinished(self, lab, exec_time): posData = self.data[self.pos_i] - if posData.segmInfo_df is not None and posData.SizeZ > 1: + if posData.segmInfo_df is not None and posData.SizeZ>1: idx = (posData.filename, posData.frame_i) - posData.segmInfo_df.at[idx, "resegmented_in_gui"] = True + posData.segmInfo_df.at[idx, 'resegmented_in_gui'] = True if lab.ndim == 2 and self.isSegm3D: self.set_2Dlab(lab) @@ -734,23 +687,23 @@ def segmWorkerFinished(self, lab, exec_time): posData.lab = lab.copy() self.activateAnnotations() - + self.update_rp(wl_update=False) - self.tracking(enforce=True, against_next=posData.frame_i == 0) - + self.tracking(enforce=True, against_next=posData.frame_i==0) + if self.isSnapshot: - self.fixCcaDfAfterEdit("Repeat segmentation") + self.fixCcaDfAfterEdit('Repeat segmentation') self.updateAllImages() else: - self.warnEditingWithCca_df("Repeat segmentation") + self.warnEditingWithCca_df('Repeat segmentation') - txt = f"Done. Segmentation computed in {exec_time:.3f} s" - self.logger.info("-----------------") + txt = f'Done. Segmentation computed in {exec_time:.3f} s' + self.logger.info('-----------------') self.logger.info(txt) - self.logger.info("=================") - self.titleLabel.setText(txt, color="g") + self.logger.info('=================') + self.titleLabel.setText(txt, color='g') self.checkIfAutoSegm() - + QTimer.singleShot(200, self.resizeGui) def segmentToolActionTriggered(self): @@ -758,36 +711,24 @@ def segmentToolActionTriggered(self): win = apps.QDialogSelectModel(parent=self) win.exec_() if win.cancel: - self.logger.info("Repeat segmentation cancelled.") + self.logger.info('Repeat segmentation cancelled.') return model_name = win.selectedModel - self.repeatSegm(model_name=model_name, askSegmParams=True) + self.repeatSegm( + model_name=model_name, askSegmParams=True + ) else: - self.repeatSegm(model_name=self.segmModelName) + self.repeatSegm(model_name=self.segmModelName) def selectZtoolZvalueChanged(self, whichZ, z): self.update_z_slice(z) - def should_compute_segmentation( - self, - *, - mode: str, - has_labels: bool, - force: bool, - auto_enabled: bool, - ) -> bool: - if mode in {"Viewer", "Cell cycle analysis"}: - return False - if has_labels and not force: - return False - return auto_enabled - def showInstructionsCustomModel(self): modelFilePath = apps.addCustomModelMessages(self) if modelFilePath is None: - self.logger.info("Adding custom model process stopped.") + self.logger.info('Adding custom model process stopped.') return - + myutils.store_custom_model_path(modelFilePath) modelName = os.path.basename(os.path.dirname(modelFilePath)) customModelAction = QAction(modelName) diff --git a/cellacdc/mixins_bak/session.py b/cellacdc/mixins/session.py similarity index 57% rename from cellacdc/mixins_bak/session.py rename to cellacdc/mixins/session.py index 8af809a1c..7765a2b3c 100644 --- a/cellacdc/mixins_bak/session.py +++ b/cellacdc/mixins/session.py @@ -16,188 +16,122 @@ settings_csv_path, widgets, ) -from cellacdc.ui.modules.annotation.decorators import get_data_exception_handler +from cellacdc.gui_decorators import get_data_exception_handler -class SessionMixin: - """Qt-facing adapter around session setup and frame storage.""" +class Session: + """Extracted from guiWin.""" - def _dispatch_tool_event_if_enabled(self, event, phase="press", image="img1"): - from cellacdc.tools.adapters.gui_bridge import dispatch_gui_mouse_event - - self._init_tool_dispatcher() - return dispatch_gui_mouse_event( - self._tool_dispatcher, - self, - event, - phase=phase, - image=image, - ) - - def _get_data_unvisited( - self, - posData, - debug=False, - lin_tree_init=True, - ): + def _get_data_unvisited(self, posData, debug=False, lin_tree_init=True,): posData.editID_info = [] proceed_cca = True never_visited = True - if str(self.modeComboBox.currentText()) == "Cell cycle analysis": + if str(self.modeComboBox.currentText()) == 'Cell cycle analysis': # Warn that we are visiting a frame that was never segm-checked # on cell cycle analysis mode msg = widgets.myMessageBox() txt = html_utils.paragraph( - "Segmentation and Tracking was never checked from " - f"frame {posData.frame_i + 1} onwards.

" - "To ensure correct cell cell cycle analysis you have to " - "first visit the frames after " - f'{posData.frame_i + 1} with "Segmentation and Tracking" mode.' + 'Segmentation and Tracking was never checked from ' + f'frame {posData.frame_i+1} onwards.

' + 'To ensure correct cell cell cycle analysis you have to ' + 'first visit the frames after ' + f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' + ) + warn_cca = msg.critical( + self, 'Never checked segmentation on requested frame', txt ) - msg.critical(self, "Never checked segmentation on requested frame", txt) proceed_cca = False return proceed_cca, never_visited - elif str(self.modeComboBox.currentText()) == "Normal division: Lineage tree": + elif str(self.modeComboBox.currentText()) == 'Normal division: Lineage tree': # Warn that we are visiting a frame that was never segm-checked # on cell cycle analysis mode msg = widgets.myMessageBox() txt = html_utils.paragraph( - "Segmentation and Tracking was never checked from " - f"frame {posData.frame_i + 1} onwards.

" - "To ensure correct lineage tree analysis you have to " - "first visit the frames after " - f'{posData.frame_i + 1} with "Segmentation and Tracking" mode.' + 'Segmentation and Tracking was never checked from ' + f'frame {posData.frame_i+1} onwards.

' + 'To ensure correct lineage tree analysis you have to ' + 'first visit the frames after ' + f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' ) - msg.critical( # ??? - self, "Never checked segmentation on requested frame", txt + warn_cca = msg.critical(#??? + self, 'Never checked segmentation on requested frame', txt ) proceed_cca = False return proceed_cca, never_visited - + # Requested frame was never visited before. Load from HDD labels = self.get_labels() - posData.lab = self.apply_manual_edits_to_lab_if_needed(labels) + posData.lab = self.apply_manual_edits_to_lab_if_needed( + labels + ) posData.rp = skimage.measure.regionprops(posData.lab) self.setManualBackgroundLab() - + if posData.acdc_df is not None: frames = posData.acdc_df.index.get_level_values(0) if posData.frame_i in frames: # Since there was already segmentation metadata from # previous closed session add it to current metadata df = posData.acdc_df.loc[posData.frame_i].copy() - binnedIDs_df = df[df["is_cell_excluded"] > 0] + binnedIDs_df = df[df['is_cell_excluded']>0] binnedIDs = set(binnedIDs_df.index).union(posData.binnedIDs) posData.binnedIDs = binnedIDs - ripIDs_df = df[df["is_cell_dead"] > 0] + ripIDs_df = df[df['is_cell_dead']>0] ripIDs = set(ripIDs_df.index).union(posData.ripIDs) posData.ripIDs = ripIDs posData.editID_info.extend(self._get_editID_info(df)) # Load cca df into current metadata - if "cell_cycle_stage" in df.columns: + if 'cell_cycle_stage' in df.columns: cca_cols = df.columns.intersection(self.cca_df_colnames) cca_df = df[cca_cols].dropna() if cca_df.empty: - df = df.drop(columns=self.cca_df_colnames, errors="ignore") + df = df.drop( + columns=self.cca_df_colnames, errors='ignore' + ) else: df = df.loc[cca_df.index] cols = self.cca_df_int_cols - df[cols] = df[cols].astype("Int64") - + df[cols] = df[cols].astype('Int64') + i = posData.frame_i - posData.allData_li[i]["acdc_df"] = df.copy() - + posData.allData_li[i]['acdc_df'] = df.copy() + if self.lineage_tree is None and lin_tree_init: self.initLinTree() - + self.get_cca_df() - + return proceed_cca, never_visited - def _get_data_visited( - self, - posData, - debug=False, - lin_tree_init=True, - ): + def _get_data_visited(self, posData, debug=False, lin_tree_init=True,): # Requested frame was already visited. Load from RAM. never_visited = False posData.lab = self.get_labels(from_store=True) posData.rp = skimage.measure.regionprops(posData.lab) - df = posData.allData_li[posData.frame_i]["acdc_df"] + df = posData.allData_li[posData.frame_i]['acdc_df'] if df is None: posData.binnedIDs = set() posData.ripIDs = set() posData.editID_info = [] else: try: - binnedIDs_df = df[df["is_cell_excluded"] > 0] - except Exception: + binnedIDs_df = df[df['is_cell_excluded']>0] + except Exception as err: df = myutils.fix_acdc_df_dtypes(df) - binnedIDs_df = df[df["is_cell_excluded"] > 0] + binnedIDs_df = df[df['is_cell_excluded']>0] posData.binnedIDs = set(binnedIDs_df.index) - ripIDs_df = df[df["is_cell_dead"] > 0] + ripIDs_df = df[df['is_cell_dead']>0] posData.ripIDs = set(ripIDs_df.index) posData.editID_info = self._get_editID_info(df) self.setManualBackgroundLab(load_from_store=True, debug=debug) if self.lineage_tree is None and lin_tree_init: self.initLinTree() - + self.get_cca_df(debug=debug) return True, never_visited - def _init_tool_dispatcher(self): - from cellacdc.tools.dispatch import ToolDispatcher - - if not hasattr(self, "_tool_dispatcher"): - self._tool_dispatcher = ToolDispatcher() - self._tool_dispatcher.set_context(self._make_tool_context(self.pos_i)) - - def _make_tool_context(self, pos_i=None): - from cellacdc.tools.context import GuiToolContext - - if pos_i is None: - pos_i = self.pos_i - return GuiToolContext( - gui=self, - pos_i=pos_i, - pos_data=self.data[pos_i], - session=self.get_session(pos_i), - ) - - def _sync_all_sessions(self): - if not hasattr(self, "data"): - return - self.sessions = [None] * len(self.data) - for pos_i in range(len(self.data)): - self._sync_session(pos_i) - - def _sync_session(self, pos_i): - """Build or refresh ``PositionSession`` from ``loadData`` at ``pos_i``.""" - if not hasattr(self, "sessions"): - self.sessions = [] - while len(self.sessions) <= pos_i: - self.sessions.append(None) - pos_data = self.data[pos_i] - session = self.position_session_from_load_data(pos_data) - self.sessions[pos_i] = session - if hasattr(self, "_tool_dispatcher") and self._tool_dispatcher is not None: - self._tool_dispatcher.set_context(self._make_tool_context(pos_i)) - return session - - def _sync_session_frame_i(self, pos_i=None): - if pos_i is None: - pos_i = self.pos_i - session = self.get_session(pos_i) - if session is None: - return - pos_data = self.data[pos_i] - session.frame_i = pos_data.frame_i - if hasattr(self, "_tool_dispatcher") and self._tool_dispatcher is not None: - self._tool_dispatcher.on_frame_changed(self._make_tool_context(pos_i)) - def addPathToOpenRecentMenu(self, path): for action in self.openRecentMenu.actions(): if path == action.text(): @@ -205,47 +139,30 @@ def addPathToOpenRecentMenu(self, path): else: action = QAction(path, self) action.triggered.connect(partial(self.openRecentFile, path)) - + try: firstAction = self.openRecentMenu.actions()[0] self.openRecentMenu.insertAction(firstAction, action) - except Exception: + except Exception as e: pass - def empty_labels( - self, - *, - is_3d: bool, - size_z: int, - size_y: int, - size_x: int, - ) -> np.ndarray: - shape = self.labels_shape( - is_3d=is_3d, - size_z=size_z, - size_y=size_y, - size_x=size_x, - ) - return np.zeros(shape, dtype=np.uint32) - def getStoredSegmData(self): posData = self.data[self.pos_i] segm_data = [] for data_frame_i in posData.allData_li: - lab = data_frame_i["labels"] + lab = data_frame_i['labels'] if lab is None: break segm_data.append(lab) return np.array(segm_data) - @get_data_exception_handler def get_data(self, debug=False, lin_tree_init=True): posData = self.data[self.pos_i] proceed_cca = True never_visited = False if posData.frame_i > 2: # Remove undo states from 4 frames back to avoid memory issues - posData.UndoRedoStates[posData.frame_i - 4] = [] + posData.UndoRedoStates[posData.frame_i-4] = [] # Check if current frame contains undo states (not empty list) if posData.UndoRedoStates[posData.frame_i]: self.undoAction.setDisabled(False) @@ -255,10 +172,9 @@ def get_data(self, debug=False, lin_tree_init=True): self.undoAction.setDisabled(True) self.UndoCount = 0 # If stored labels is None then it is the first time we visit this frame - if posData.allData_li[posData.frame_i]["labels"] is None: - proceed_cca, never_visited = self._get_data_unvisited( - posData, - lin_tree_init=lin_tree_init, + if posData.allData_li[posData.frame_i]['labels'] is None: + proceed_cca, never_visited = self._get_data_unvisited( + posData, lin_tree_init=lin_tree_init, ) if not proceed_cca: return proceed_cca, never_visited @@ -266,31 +182,35 @@ def get_data(self, debug=False, lin_tree_init=True): proceed_cca, never_visited = self._get_data_visited( posData, lin_tree_init=lin_tree_init, debug=debug ) - + self.update_rp_metadata(draw=False) posData.IDs = [obj.label for obj in posData.rp] posData.IDs_idxs = { - ID: i for ID, i in zip(posData.IDs, range(len(posData.IDs))) + ID:i for ID, i in zip(posData.IDs, range(len(posData.IDs))) } self.get_zslices_rp() self.pointsLayerDfsToData(posData) return proceed_cca, never_visited def get_labels( - self, from_store=False, frame_i=None, return_existing=False, return_copy=True - ): + self, + from_store=False, + frame_i=None, + return_existing=False, + return_copy=True + ): """Get the labels array. - + Parameters ---------- from_store : bool, optional - If True load the labels array from the stored posData.allData_li, + If True load the labels array from the stored posData.allData_li, i.e., from RAM. Default is False frame_i : int, optional If None, use the current frame index. Default is None return_existing : bool, optional - If True, the second return element will be a boolean that - is True if the labels array was found stored in `posData.allData_li`. + If True, the second return element will be a boolean that + is True if the labels array was found stored in `posData.allData_li`. Default is False return_copy : bool, optional If True returns a copy of the labels array @@ -298,31 +218,31 @@ def get_labels( Returns ------- numpy.ndarray or tuple of (numpy.ndarray, bool) - The first element is the labels array requested. If `return_existing` - is True then this method also returns a second boolean element that + The first element is the labels array requested. If `return_existing` + is True then this method also returns a second boolean element that is True if the labels array was found in in `posData.allData_li`. - + Note ---- - - If `from_store` is True then this method will try to get the stored - labels array. If any error occurs then the returned labels are the + + If `from_store` is True then this method will try to get the stored + labels array. If any error occurs then the returned labels are the saved ones in the segmentation file (i.e., from hard drive). - - """ + + """ posData = self.data[self.pos_i] if frame_i is None: frame_i = posData.frame_i - + existing = True if from_store: try: - labels = posData.allData_li[frame_i]["labels"] + labels = posData.allData_li[frame_i]['labels'] if labels is None: from_store = False - except Exception: + except Exception as err: from_store = False - + if not from_store: try: labels = posData.segm_data[frame_i] @@ -335,23 +255,15 @@ def get_labels( shape = (posData.SizeY, posData.SizeX) labels = np.zeros(shape, dtype=np.uint32) return_copy = False - + if return_copy: labels = labels.copy() - + if return_existing: return labels, existing else: return labels - def get_session(self, pos_i=None): - """Return synced :class:`PositionSession` for a loaded position.""" - if pos_i is None: - pos_i = self.pos_i - if not hasattr(self, "sessions") or pos_i >= len(self.sessions): - return None - return self.sessions[pos_i] - def initPosAttr(self): exp_path = self.data[self.pos_i].exp_path pos_foldernames = myutils.get_pos_foldernames(exp_path) @@ -385,17 +297,15 @@ def initPosAttr(self): posData.doNotShowAgain_keepID = False posData.UndoFutFrames_keepID = False posData.applyFutFrames_keepID = False - + posData.doNotShowAgainAssignNewID = False posData.UndoFutFramesAssignNewID = False posData.applyFutFramesAssignNewID = False posData.includeUnvisitedInfo = { - "Delete ID": False, - "Edit ID": False, - "Keep ID": False, + 'Delete ID': False, 'Edit ID': False, 'Keep ID': False } - + posData.loadTrackedLostCentroids() posData.acdcTracker2stepsAnnotInfo = {} @@ -413,15 +323,17 @@ def initPosAttr(self): posData.ol_data_dict = {} posData.ol_data = None - posData.ol_labels_data = None - + posData.ol_labels_data = None + missing_frames = posData.SizeT - len(posData.allData_li) if missing_frames > 0: posData.allData_li.extend([None] * missing_frames) for i in range(posData.SizeT): if posData.allData_li[i] is None: - posData.allData_li[i] = myutils.get_empty_stored_data_dict() - + posData.allData_li[i] = ( + myutils.get_empty_stored_data_dict() + ) + posData.lutLevels = {channel: {} for channel in self.ch_names} posData.ccaStatus_whenEmerged = {} @@ -432,11 +344,11 @@ def initPosAttr(self): posData.ripIDs = set() posData.cca_df = None if posData.last_tracked_i is not None: - last_tracked_num = posData.last_tracked_i + 1 + last_tracked_num = posData.last_tracked_i+1 # Load previous session data # Keep track of which ROIs have already been added # in previous frame - [[] for _ in range(posData.SizeT)] + delROIshapes = [[] for _ in range(posData.SizeT)] for i in range(last_tracked_num): posData.frame_i = i self.get_data(debug=True) @@ -445,30 +357,30 @@ def initPosAttr(self): ) # Ask whether to resume from last frame - if last_tracked_num > 1: + if last_tracked_num>1: msg = widgets.myMessageBox() txt = html_utils.paragraph( - "Cell-ACDC detected a previous session ended " - f"at frame {last_tracked_num}.

" - f"Do you want to resume from frame " - f"{last_tracked_num}?" + 'Cell-ACDC detected a previous session ended ' + f'at frame {last_tracked_num}.

' + f'Do you want to resume from frame ' + f'{last_tracked_num}?' ) noButton, yesButton = msg.question( - self, - "Start from last session?", - txt, - buttonsTexts=(" No ", "Yes"), + self, 'Start from last session?', txt, + buttonsTexts=(' No ', 'Yes') ) self.AutoPilotProfile.storeClickMessageBox( - "Start from last session?", msg.clickedButton.text() + 'Start from last session?', msg.clickedButton.text() ) if msg.clickedButton == yesButton: posData.frame_i = posData.last_tracked_i self.lastFrameRanOnFirstVisitTools = posData.frame_i else: posData.frame_i = 0 - - posData.img_data_min_max = (posData.img_data.min(), posData.img_data.max()) + + posData.img_data_min_max = ( + posData.img_data.min(), posData.img_data.max() + ) # Back to first position self.pos_i = 0 @@ -482,88 +394,78 @@ def initPosAttr(self): self.setAllIDs() - def labels_shape( - self, - *, - is_3d: bool, - size_z: int, - size_y: int, - size_x: int, - ) -> tuple[int, ...]: - if is_3d: - return (size_z, size_y, size_x) - return (size_y, size_x) - def loadLastSessionSettings(self): self.settings_csv_path = settings_csv_path if os.path.exists(settings_csv_path): - self.df_settings = pd.read_csv(settings_csv_path, index_col="setting") - if "is_bw_inverted" not in self.df_settings.index: - self.df_settings.at["is_bw_inverted", "value"] = "No" + self.df_settings = pd.read_csv( + settings_csv_path, index_col='setting' + ) + if 'is_bw_inverted' not in self.df_settings.index: + self.df_settings.at['is_bw_inverted', 'value'] = 'No' else: - self.df_settings.loc["is_bw_inverted"] = self.df_settings.loc[ - "is_bw_inverted" - ].astype(str) - if "fontSize" not in self.df_settings.index: - self.df_settings.at["fontSize", "value"] = 12 - if "overlayColor" not in self.df_settings.index: - self.df_settings.at["overlayColor", "value"] = "255-255-0" - if "how_normIntensities" not in self.df_settings.index: - raw = "Do not normalize. Display raw image" - self.df_settings.at["how_normIntensities", "value"] = raw + self.df_settings.loc['is_bw_inverted'] = ( + self.df_settings.loc['is_bw_inverted'].astype(str) + ) + if 'fontSize' not in self.df_settings.index: + self.df_settings.at['fontSize', 'value'] = 12 + if 'overlayColor' not in self.df_settings.index: + self.df_settings.at['overlayColor', 'value'] = '255-255-0' + if 'how_normIntensities' not in self.df_settings.index: + raw = 'Do not normalize. Display raw image' + self.df_settings.at['how_normIntensities', 'value'] = raw else: - idx = ["is_bw_inverted", "fontSize", "overlayColor", "how_normIntensities"] - values = ["No", 12, "255-255-0", "raw"] - self.df_settings = pd.DataFrame( - {"setting": idx, "value": values} - ).set_index("setting") - - if "isLabelsVisible" not in self.df_settings.index: - self.df_settings.at["isLabelsVisible", "value"] = "No" - - if "isNextFrameVisible" not in self.df_settings.index: - self.df_settings.at["isNextFrameVisible", "value"] = "No" - - if "isRightImageVisible" not in self.df_settings.index: - self.df_settings.at["isRightImageVisible", "value"] = "Yes" - - if "manual_separate_draw_mode" not in self.df_settings.index: - col = "manual_separate_draw_mode" - self.df_settings.at[col, "value"] = "threepoints_arc" - - if "colorScheme" in self.df_settings.index: - col = "colorScheme" - self._colorScheme = self.df_settings.at[col, "value"] + idx = ['is_bw_inverted', 'fontSize', 'overlayColor', 'how_normIntensities'] + values = ['No', 12, '255-255-0', 'raw'] + self.df_settings = pd.DataFrame({ + 'setting': idx,'value': values} + ).set_index('setting') + + if 'isLabelsVisible' not in self.df_settings.index: + self.df_settings.at['isLabelsVisible', 'value'] = 'No' + + if 'isNextFrameVisible' not in self.df_settings.index: + self.df_settings.at['isNextFrameVisible', 'value'] = 'No' + + if 'isRightImageVisible' not in self.df_settings.index: + self.df_settings.at['isRightImageVisible', 'value'] = 'Yes' + + if 'manual_separate_draw_mode' not in self.df_settings.index: + col = 'manual_separate_draw_mode' + self.df_settings.at[col, 'value'] = 'threepoints_arc' + + if 'colorScheme' in self.df_settings.index: + col = 'colorScheme' + self._colorScheme = self.df_settings.at[col, 'value'] else: - self._colorScheme = "light" - + self._colorScheme = 'light' + self.doNotShowAgainMissingCca = False - if "doNotShowAgainMissingCca" not in self.df_settings.index: - self.df_settings.at["doNotShowAgainMissingCca", "value"] = "No" + if 'doNotShowAgainMissingCca' not in self.df_settings.index: + self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'No' else: - val = self.df_settings.at["doNotShowAgainMissingCca", "value"] - self.doNotShowAgainMissingCca = val == "Yes" + val = self.df_settings.at['doNotShowAgainMissingCca', 'value'] + self.doNotShowAgainMissingCca = val=='Yes' def reInitGui(self): cancel = self.checkAskSavePointsLayers() if cancel: return False - + if self.overlayToolbar.isTransparent(): self.overlayToolbar.setTransparent(False) - + self.secondLevelToolbar.setVisible(False) - + self.gui_createLazyLoader() try: self.navSpinBox.valueChanged.disconnect() - except Exception: + except Exception as e: pass - - try: + + try: self.scaleBar.removeFromAxis(self.ax1) - except Exception: + except Exception as e: pass self.lineage_tree = None @@ -580,7 +482,7 @@ def reInitGui(self): self.showPropsDockButton.setDisabled(True) self.removeOverlayItems() self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) - + self.reinitWidgetsPos() self.removeAllItems() self.reinitCustomAnnot() @@ -592,30 +494,30 @@ def reInitGui(self): self.reinitStoredSegmModels() self.removeAxLimits() self.curvToolButton.setChecked(False) - + self.wandControlsToolbar.setVisible(False) self.wandToolButton.setChecked(False) self.segmNdimIndicatorAction.setVisible(False) - + self.navigateToolBar.hide() self.ccaToolBar.hide() self.editToolBar.hide() self.brushEraserToolBar.hide() self.modeToolBar.hide() - self.modeComboBox.setCurrentText("Viewer") - + self.modeComboBox.setCurrentText('Viewer') + alpha = self.imgGrad.labelsAlphaSlider.value() self.labelsLayerImg1.setOpacity(alpha) self.labelsLayerRightImg.setOpacity(alpha) - self.lastTrackedFrameLabel.setText("") - + self.lastTrackedFrameLabel.setText('') + self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False - + for action in self.askHowFutureFramesActions.values(): action.setChecked(True) action.setDisabled(True) - + return True def readRecentPaths(self, recent_paths_path=None): @@ -624,19 +526,19 @@ def readRecentPaths(self, recent_paths_path=None): # Step 1. Read recent Paths if recent_paths_path is None: - recent_paths_path = recentPaths_path - + recent_paths_path = recentPaths_path + if os.path.exists(recent_paths_path): - df = pd.read_csv(recent_paths_path, index_col="index") - df["path"] = df["path"].str.replace("\\", "/") - df = df.drop_duplicates(subset=["path"]) + df = pd.read_csv(recent_paths_path, index_col='index') + df['path'] = df['path'].str.replace('\\', '/') + df = df.drop_duplicates(subset=['path']) df.to_csv(recent_paths_path) - if "opened_last_on" in df.columns: - df = df.sort_values("opened_last_on", ascending=False) - recentPaths = df["path"].to_list() + if 'opened_last_on' in df.columns: + df = df.sort_values('opened_last_on', ascending=False) + recentPaths = df['path'].to_list() else: recentPaths = [] - + # Step 2. Dynamically create the actions actions = [] for path in recentPaths: @@ -652,37 +554,10 @@ def readRecentPaths(self, recent_paths_path=None): def reinitWidgetsPos(self): pass - def should_disable_load_position(self, position_count: int) -> bool: - return position_count <= 1 - - def should_resume_last_session_prompt(self, last_tracked_num: int) -> bool: - return last_tracked_num > 1 - - Parameters - - def should_store_frame_data( - self, - *, - frame_i: int, - mode: str, - enforce: bool, - ) -> bool: - if frame_i < 0: - return False - if mode == "Viewer" and not enforce: - return False - return True - - @exception_handler def store_data( - self, - pos_i=None, - enforce=True, - debug=False, - mainThread=True, - autosave=True, - store_cca_df_copy=False, - ): + self, pos_i=None, enforce=True, debug=False, mainThread=True, + autosave=True, store_cca_df_copy=False + ): pos_i = self.pos_i if pos_i is None else pos_i posData = self.data[pos_i] if posData.frame_i < 0: @@ -693,32 +568,38 @@ def store_data( mode = str(self.modeComboBox.currentText()) - if mode == "Viewer" and not enforce: + if mode == 'Viewer' and not enforce: return # if not mainThread: # self.lin_tree_ask_changes() - + allData_li = posData.allData_li[posData.frame_i] - allData_li["regionprops"] = posData.rp.copy() - allData_li["labels"] = posData.lab.copy() - allData_li["IDs"] = posData.IDs.copy() - allData_li["manualBackgroundLab"] = posData.manualBackgroundLab - allData_li["IDs_idxs"] = posData.IDs_idxs.copy() + allData_li['regionprops'] = posData.rp.copy() + allData_li['labels'] = posData.lab.copy() + allData_li['IDs'] = posData.IDs.copy() + allData_li['manualBackgroundLab'] = ( + posData.manualBackgroundLab + ) + allData_li['IDs_idxs'] = ( + posData.IDs_idxs.copy() + ) if self.manualAnnotPastButton.isChecked(): - self.store_manual_annot_data(posData=posData, data_frame_i=allData_li) - + self.store_manual_annot_data( + posData=posData, data_frame_i=allData_li + ) + self.store_zslices_rp() # Store dynamic metadata - is_cell_dead_li = [False] * len(posData.rp) - is_cell_excluded_li = [False] * len(posData.rp) - IDs = [0] * len(posData.rp) - xx_centroid = [0] * len(posData.rp) - yy_centroid = [0] * len(posData.rp) + is_cell_dead_li = [False]*len(posData.rp) + is_cell_excluded_li = [False]*len(posData.rp) + IDs = [0]*len(posData.rp) + xx_centroid = [0]*len(posData.rp) + yy_centroid = [0]*len(posData.rp) if self.isSegm3D: - zz_centroid = [0] * len(posData.rp) - areManuallyEdited = [0] * len(posData.rp) + zz_centroid = [0]*len(posData.rp) + areManuallyEdited = [0]*len(posData.rp) editedNewIDs = [vals[2] for vals in posData.editID_info] for i, obj in enumerate(posData.rp): is_cell_dead_li[i] = obj.dead @@ -727,7 +608,7 @@ def store_data( try: xx_centroid[i] = int(self.getObjCentroid(obj.centroid)[1]) yy_centroid[i] = int(self.getObjCentroid(obj.centroid)[0]) - except Exception: + except Exception as err: printl(obj, obj.centroid, obj.label, posData.frame_i) if self.isSegm3D: zz_centroid[i] = int(obj.centroid[0]) @@ -736,69 +617,60 @@ def store_data( posData.STOREDmaxID = max(IDs, default=0) - acdc_df = allData_li["acdc_df"] + acdc_df = allData_li['acdc_df'] if acdc_df is None: - allData_li["acdc_df"] = pd.DataFrame( + allData_li['acdc_df'] = pd.DataFrame( { - "Cell_ID": IDs, - "is_cell_dead": is_cell_dead_li, - "is_cell_excluded": is_cell_excluded_li, - "x_centroid": xx_centroid, - "y_centroid": yy_centroid, - "was_manually_edited": areManuallyEdited, + 'Cell_ID': IDs, + 'is_cell_dead': is_cell_dead_li, + 'is_cell_excluded': is_cell_excluded_li, + 'x_centroid': xx_centroid, + 'y_centroid': yy_centroid, + 'was_manually_edited': areManuallyEdited } - ).set_index("Cell_ID") - + ).set_index('Cell_ID') + if self.isSegm3D: - allData_li["acdc_df"]["z_centroid"] = zz_centroid + allData_li['acdc_df']['z_centroid'] = ( + zz_centroid + ) else: # Filter or add IDs that were not stored yet - acdc_df = acdc_df.drop(columns=["time_seconds"], errors="ignore") + acdc_df = acdc_df.drop(columns=['time_seconds'], errors='ignore') acdc_df = acdc_df.reindex(IDs, fill_value=0) - acdc_df["is_cell_dead"] = is_cell_dead_li - acdc_df["is_cell_excluded"] = is_cell_excluded_li - acdc_df["x_centroid"] = xx_centroid - acdc_df["y_centroid"] = yy_centroid + acdc_df['is_cell_dead'] = is_cell_dead_li + acdc_df['is_cell_excluded'] = is_cell_excluded_li + acdc_df['x_centroid'] = xx_centroid + acdc_df['y_centroid'] = yy_centroid if self.isSegm3D: - acdc_df["z_centroid"] = zz_centroid - acdc_df["was_manually_edited"] = areManuallyEdited - allData_li["acdc_df"] = acdc_df + acdc_df['z_centroid'] = zz_centroid + acdc_df['was_manually_edited'] = areManuallyEdited + allData_li['acdc_df'] = acdc_df if mainThread: self.pointsLayerDataToDf(posData) - + self.store_cca_df( - pos_i=pos_i, - mainThread=mainThread, - autosave=autosave, - store_cca_df_copy=store_cca_df_copy, + pos_i=pos_i, mainThread=mainThread, autosave=autosave, + store_cca_df_copy=store_cca_df_copy ) - def store_manual_annot_data(self, posData=None, data_frame_i=None): + def store_manual_annot_data( + self, posData=None, data_frame_i=None + ): if posData is None: posData = self.data[self.pos_i] - + if data_frame_i is None: data_frame_i = posData.allData_li[posData.frame_i] - + if not self.isSegm3D: lab = [posData.lab] else: lab = posData.lab - + for z, lab_2D in enumerate(lab): - data_frame_i["manually_edited_lab"]["lab"][z] = lab_2D - - def sync_session_labels(self, pos_i=None): - """Mirror ``posData.segm_data`` into the parallel ``PositionSession``.""" - if pos_i is None: - pos_i = self.pos_i - session = self.get_session(pos_i) - pos_data = self.data[pos_i] - if session is None or not hasattr(pos_data, "segm_data"): - return - if pos_data.segm_data is not None: - session.set_labels(np.asarray(pos_data.segm_data)) + data_frame_i['manually_edited_lab']['lab'][z] = lab_2D def unstore_data(self): posData = self.data[self.pos_i] @@ -808,16 +680,16 @@ def updateLastVisitedFrame(self, last_visited_frame_i=None): if last_visited_frame_i is None: posData = self.data[self.pos_i] last_visited_frame_i = posData.frame_i - + mode = str(self.modeComboBox.currentText()) - if mode == "Viewer": + if mode == 'Viewer': return - elif mode == "Segmentation and Tracking": + elif mode == 'Segmentation and Tracking': posData = self.data[self.pos_i] if posData.last_tracked_i >= last_visited_frame_i: return posData.last_tracked_i = last_visited_frame_i - elif mode == "Cell cycle analysis": + elif mode == 'Cell cycle analysis': if self.last_cca_frame_i >= last_visited_frame_i: return self.last_cca_frame_i = last_visited_frame_i diff --git a/cellacdc/mixins/status_hover.py b/cellacdc/mixins/status_hover.py new file mode 100644 index 000000000..b86b8b18e --- /dev/null +++ b/cellacdc/mixins/status_hover.py @@ -0,0 +1,153 @@ +"""View adapter for hover and status-bar formatting.""" + +from __future__ import annotations + + +import math +import os +import re + + +class StatusHover: + """Extracted from guiWin.""" + + def _addOverlayHoverValuesFormatted(self, txt, xdata, ydata): + posData = self.data[self.pos_i] + if posData.ol_data is None: + return txt + + for filename in posData.ol_data: + chName = myutils.get_chname_from_basename( + filename, posData.basename, remove_ext=False + ) + if chName not in self.checkedOverlayChannels: + continue + + raw_overlay_img = self.getRawImage(filename=filename) + raw_overlay_value = raw_overlay_img[ydata, xdata] + # raw_overlay_max_value = raw_overlay_img.max() + + raw_txt = self._channelHoverValues('Raw', chName, raw_overlay_value) + + txt = f'{txt} | {raw_txt}' + return txt + + def _addRulerMeasurementText(self, txt): + posData = self.data[self.pos_i] + xx, yy = self.ax1_rulerPlotItem.getData() + if xx is None: + return txt + + lenPxl = math.sqrt((xx[0]-xx[1])**2 + (yy[0]-yy[1])**2) + depthAxes = self.switchPlaneCombobox.depthAxes() + if depthAxes != 'z': + pxlToUm = posData.PhysicalSizeZ + else: + pxlToUm = posData.PhysicalSizeX + + length_txt = ( + f'length = {int(lenPxl)} pxl ({lenPxl*pxlToUm:.2f} μm)' + ) + txt = f'{txt} | Measurement: {length_txt}' + return txt + + def _channelHoverValues(self, descr, channel, value, ff=None): + if ff is None: + n_digits = len(str(int(value))) + ff = myutils.get_number_fstring_formatter( + type(value), precision=abs(n_digits-5) + ) + txt = ( + f'{descr} {channel}: value={value:{ff}}' + ) + return txt + + def getActiveToolButton(self): + for button in self.LeftClickButtons: + if button.isChecked(): + return button + + def updateValuesStatusBar(self): + (xl, xr), (yt, yb) = self.ax1ViewRange(integers=True) + W = round(xr - xl) + H = round(yb - yt) + txt = self.wcLabel.text() + pattern = ( + r'W=.*?, H=.*? \| ' + r'x_left=.*?, y_top=.*? \| ' + r'x_right=.*?, y_bottom=.*? \| ' + ) + replacing = ( + f'W={W:d}, H={H:d} | ' + f'x_left={xl:d}, y_top={yt:d} | ' + f'x_right={xr:d}, y_bottom={yb:d} | ' + ) + txt = re.sub(pattern, replacing, txt) + self.wcLabel.setText(txt) + + def hoverValuesFormatted(self, xdata, ydata, activeToolButton, is_ax0): + (xl, xr), (yt, yb) = self.ax1ViewRange(integers=True) + W = round(xr - xl) + H = round(yb - yt) + ax_idx = 0 if is_ax0 else 1 + txt = ( + f'x={xdata:d}, y={ydata:d} | ' + f'W={W:d}, H={H:d} | ' + f'x_left={xl:d}, y_top={yt:d} | ' + f'x_right={xr:d}, y_bottom={yb:d} | ' + f'(ax{ax_idx})' + ) + if activeToolButton == self.rulerButton: + txt = self._addRulerMeasurementText(txt) + return txt + elif activeToolButton is not None: + return txt + + posData = self.data[self.pos_i] + + raw_img = self.getRawImage() + raw_value = raw_img[ydata, xdata] + # raw_max_value = raw_img.max() + + ch = self.user_ch_name + raw_txt = self._channelHoverValues('Raw', ch, raw_value) + + txt = f'{txt} | {raw_txt}' + + txt = self._addOverlayHoverValuesFormatted(txt, xdata, ydata) + + ID = self.currentLab2D[ydata, xdata] + maxID = max(posData.IDs, default=0) + + num_obj = len(posData.IDs) + lab_txt = ( + f'Objects: ID={ID}, max ID={maxID}, ' + f'num. of objects={num_obj}' + ) + txt = f'{txt} | {lab_txt}' + + txt = self._addRulerMeasurementText(txt) + return txt + + def setStatusBarLabel(self, log=True): + self.statusbar.clearMessage() + posData = self.data[self.pos_i] + segmentedChannelname = posData.filename[len(posData.basename):] + segmFilename = os.path.basename(posData.segm_npz_path) + segmEndName = segmFilename[len(posData.basename):] + txt = ( + f'{posData.pos_foldername} || ' + f'Basename: {posData.basename} || ' + f'Segmented channel: {segmentedChannelname} || ' + f'Segmentation file name: {segmEndName}' + ) + mode = str(self.modeComboBox.currentText()) + if log: + self.logger.info(txt) + self.statusBarLabel.setText(txt) + + def getRulerLengthText(self): + text = self.wcLabel.text() + lengthText = re.findall(r'length = (.*)\)', text)[0] + lengthText = lengthText.replace('pxl', 'pixels') + return f'{lengthText})' diff --git a/cellacdc/mixins_bak/tool_activation.py b/cellacdc/mixins/tool_activation.py similarity index 70% rename from cellacdc/mixins_bak/tool_activation.py rename to cellacdc/mixins/tool_activation.py index cb79574e8..d40b4ac4a 100644 --- a/cellacdc/mixins_bak/tool_activation.py +++ b/cellacdc/mixins/tool_activation.py @@ -9,14 +9,8 @@ from cellacdc import disableWindow -class ToolActivationMixin: - """Qt-facing adapter around active-tool workflows.""" - - """Headless decisions for active-tool and hover workflows.""" - - # @exec_time - - # @exec_time +class ToolActivation: + """Extracted from guiWin.""" def _copyAllLostObjects_navigateToFrame(self, frame_i): posData = self.data[self.pos_i] @@ -32,18 +26,14 @@ def _copyAllLostObjects_navigateToFrame(self, frame_i): self.lostObjContoursImage[:] = 0 self.lostObjImage[:] = 0 - prev_rp = posData.allData_li[frame_i - 1]["regionprops"] - prev_IDs_idxs = posData.allData_li[frame_i - 1][ - "IDs_idxs" - ] # need to change this when merging with opt. + prev_rp = posData.allData_li[frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] # need to change this when merging with opt. for lostID in posData.lost_IDs: obj = prev_rp[prev_IDs_idxs[lostID]] self.addLostObjsToLostObjImage(obj, lostID, force=True) def _copyAllLostObjects_refreshRp(self): - self.update_rp( - draw=False, wl_update=False - ) # need to change this when merging with opt. + self.update_rp(draw=False, wl_update=False) # need to change this when merging with opt. def _copyAllLostObjects_returnToFrame(self, frame_i): posData = self.data[self.pos_i] @@ -55,7 +45,7 @@ def addLostObjsToLostObjImage(self, lostObj, lostID, force=False): if not force: if not self.copyLostObjButton.isChecked(): return - + obj_slice = self.getObjSlice(lostObj.slice) obj_image = self.getObjImage(lostObj.image, lostObj.bbox) self.lostObjImage[obj_slice][obj_image] = lostID @@ -66,19 +56,21 @@ def annotLostObjsToggled(self, checked): self.updateAllImages() def clearTempBrushImage(self, forceClearLinked=True): - if not hasattr(self, "tempLayerImg1"): + if not hasattr(self, 'tempLayerImg1'): return - - self.tempLayerImg1.setImage(self.emptyLab, force_set_linked=forceClearLinked) - + + self.tempLayerImg1.setImage( + self.emptyLab, force_set_linked=forceClearLinked + ) + try: self.brushContourImage[:] = 0 - except Exception: + except Exception as err: pass - + try: self.brushImage[:] = 0 - except Exception: + except Exception as err: pass def connectLeftClickButtons(self): @@ -100,25 +92,26 @@ def connectLeftClickButtons(self): def connectLeftClickButtonsPointsLayersToolbar(self): for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, "layerTypeIdx"): + if not hasattr(action, 'layerTypeIdx'): continue if action.layerTypeIdx != 4: continue - action.button.toggled.connect(self.addPointsByClickingButtonToggled) + action.button.toggled.connect( + self.addPointsByClickingButtonToggled + ) - @disableWindow def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): if not self.copyLostObjButton.isChecked(): return posData = self.data[self.pos_i] - desc = "Copying all lost objects..." + desc = 'Copying all lost objects...' self.progressWin = apps.QDialogWorkerProgress( title=desc, parent=self.mainWin, pbarDesc=desc ) - self.progressWin.mainPbar.setMaximum(for_future_frame_n + 1) + self.progressWin.mainPbar.setMaximum(for_future_frame_n+1) self.progressWin.show(self.app) self.copyAllLostObjectsThread = QThread() @@ -129,18 +122,24 @@ def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): self.copyAllLostObjectsWorker.moveToThread(self.copyAllLostObjectsThread) self.copyAllLostObjectsWorker.navigateToFrame.connect( - self._copyAllLostObjects_navigateToFrame, Qt.BlockingQueuedConnection + self._copyAllLostObjects_navigateToFrame, + Qt.BlockingQueuedConnection ) self.copyAllLostObjectsWorker.returnToFrame.connect( - self._copyAllLostObjects_returnToFrame, Qt.BlockingQueuedConnection + self._copyAllLostObjects_returnToFrame, + Qt.BlockingQueuedConnection ) self.copyAllLostObjectsWorker.copyLostObjectMask.connect( - self.copyLostObjectMask, Qt.BlockingQueuedConnection + self.copyLostObjectMask, + Qt.BlockingQueuedConnection ) self.copyAllLostObjectsWorker.refreshRp.connect( - self._copyAllLostObjects_refreshRp, Qt.BlockingQueuedConnection + self._copyAllLostObjects_refreshRp, + Qt.BlockingQueuedConnection + ) + self.copyAllLostObjectsWorker.progressBar.connect( + self.workerUpdateProgressbar ) - self.copyAllLostObjectsWorker.progressBar.connect(self.workerUpdateProgressbar) self.copyAllLostObjectsWorker.critical.connect( self.copyAllLostObjectsWorkerCritical ) @@ -157,7 +156,9 @@ def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): self.copyAllLostObjectsWorkerFinished ) - self.copyAllLostObjectsThread.started.connect(self.copyAllLostObjectsWorker.run) + self.copyAllLostObjectsThread.started.connect( + self.copyAllLostObjectsWorker.run + ) self.copyAllLostObjectsThread.start() self.copyAllLostObjectsWorkerLoop = QEventLoop() @@ -173,16 +174,17 @@ def copyAllLostObjectsWorkerFinished(self, output): self.progressWin.close() self.progressWin = None - if output.get("doReinitLastSegmFrame", False): + if output.get('doReinitLastSegmFrame', False): self.reInitLastSegmFrame( - from_frame_i=output.get("last_visited_frame_i"), + from_frame_i=output.get('last_visited_frame_i'), updateImages=False, - force=True, + force=True ) - if output.get("overlap_warning", False): + if output.get('overlap_warning', False): self.blinker = qutils.QControlBlink( - self.copyLostObjToolbar.maxOverlapNumberControl, qparent=self.mainWin + self.copyLostObjToolbar.maxOverlapNumberControl, + qparent=self.mainWin ) self.blinker.start() @@ -193,11 +195,11 @@ def copyAllLostObjectsWorkerFinished(self, output): def copyLostObjContour_cb(self, checked): self.copyLostObjToolbar.setVisible(checked) - + self.ax1_lostObjScatterItem.hoverLostID = 0 if not checked: return - + self.lostObjImage = np.zeros_like(self.currentLab2D) self.updateLostContoursImage(0) @@ -211,19 +213,19 @@ def copyLostObjectMask(self, ID: int): def disableNonFunctionalButtons(self): if not self.isSegm3D: - return + return for item in self.functionsNotTested3D: - if hasattr(item, "action"): + if hasattr(item, 'action'): toolButton = item action = toolButton.action toolButton.setDisabled(True) - elif hasattr(item, "toolbar"): + elif hasattr(item, 'toolbar'): toolbar = item.toolbar action = item toolButton = toolbar.widgetForAction(action) - toolButton.setDisabled(True) - else: + toolButton.setDisabled(True) + else: action = item action.setDisabled(True) @@ -231,7 +233,7 @@ def disconnectLeftClickButtons(self): for button in self.LeftClickButtons: try: button.toggled.disconnect() - except Exception: + except Exception as e: # Not all the LeftClickButtons have toggled connected pass @@ -239,19 +241,21 @@ def getPrevFrameIDs(self, current_frame_i=None): posData = self.data[self.pos_i] if current_frame_i is None: current_frame_i = posData.frame_i - + if current_frame_i is None: return [] - + prev_frame_i = current_frame_i - 1 - prevIDs = posData.allData_li[prev_frame_i]["IDs"] - + prevIDs = posData.allData_li[prev_frame_i]['IDs'] + if prevIDs: return prevIDs - + # IDs in previous frame were not stored --> load prev lab from HDD prev_lab = self.get_labels( - from_store=False, frame_i=prev_frame_i, return_copy=False + from_store=False, + frame_i=prev_frame_i, + return_copy=False ) rp = skimage.measure.regionprops(prev_lab) prevIDs = [obj.label for obj in rp] @@ -271,9 +275,9 @@ def hideItemsHoverBrush(self, xy=None, ID=None, force=False): if not self.brushAutoHideCheckbox.isChecked() and not force: return - + posData = self.data[self.pos_i] - self.brushSizeSpinbox.value() * 2 + size = self.brushSizeSpinbox.value()*2 if xy is not None: ID = self.get_2Dlab(posData.lab)[ydata, xdata] @@ -283,18 +287,18 @@ def hideItemsHoverBrush(self, xy=None, ID=None, force=False): if self.ax1_lostTrackedScatterItem.isVisible(): self.ax1_lostTrackedScatterItem.setVisible(False) - + if self.ax2_lostObjScatterItem.isVisible(): self.ax2_lostObjScatterItem.setVisible(False) if self.ax2_lostTrackedScatterItem.isVisible(): self.ax2_lostTrackedScatterItem.setVisible(False) - + # Restore ID previously hovered if ID != self.ax1BrushHoverID and not self.isMouseDragImg1: try: self.restoreHoverObjBrush() - except Exception: + except Exception as e: self.ax1BrushHoverID = 0 return @@ -310,13 +314,13 @@ def highlightHoverLostObj(self, modifiers, event): noModifier = modifiers == Qt.NoModifier if not noModifier: return - + if not self.copyLostObjButton.isChecked(): return - + if event.isExit(): return - + posData = self.data[self.pos_i] x, y = event.pos() xdata, ydata = int(x), int(y) @@ -324,42 +328,42 @@ def highlightHoverLostObj(self, modifiers, event): hoverLostID = self.lostObjImage[ydata, xdata] except IndexError: return - - self.ax1_lostObjScatterItem.hoverLostID = hoverLostID + + self.ax1_lostObjScatterItem.hoverLostID = hoverLostID if hoverLostID == 0: - self.ax1_lostObjScatterItem.setSize(self.contLineWeight + 1) + self.ax1_lostObjScatterItem.setSize(self.contLineWeight+1) self.ax1_lostObjScatterItem.setData([], []) else: - prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] - prev_IDs_idxs = posData.allData_li[posData.frame_i - 1]["IDs_idxs"] + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] lostObj = prev_rp[prev_IDs_idxs[hoverLostID]] obj_contours = self.getObjContours(lostObj, all_external=True) for cont in obj_contours: - xx = cont[:, 0] - yy = cont[:, 1] + xx = cont[:,0] + yy = cont[:,1] self.ax1_lostObjScatterItem.addPoints(xx, yy) - self.ax1_lostObjScatterItem.setSize(self.contLineWeight + 2) + self.ax1_lostObjScatterItem.setSize(self.contLineWeight+2) def highlightLostNew(self): - if self.modeComboBox.currentText() == "Viewer": + if self.modeComboBox.currentText() == 'Viewer': return - + posData = self.data[self.pos_i] delROIsIDs = self.getDelRoisIDs() - + # self.setAllContoursImages(delROIsIDs=delROIsIDs) if posData.frame_i == 0: - return + return if not self.annotLostObjsToggle.isChecked(): return - - prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] - + + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + if prev_rp is None: return - self.setAllLostObjContoursImage(delROIsIDs=delROIsIDs) + self.setAllLostObjContoursImage(delROIsIDs=delROIsIDs) self.setAllLostTrackedObjContoursImage(delROIsIDs=delROIsIDs) def highlightManualAnnotMode(self, viewBox, viewRange): @@ -386,27 +390,28 @@ def manualAnnotPast_cb(self, checked): if checked: for _ in range(3): self.onEscape( - buttonsToNotUncheck=[self.manualAnnotPastButton], doAutoRange=False + buttonsToNotUncheck=[self.manualAnnotPastButton], + doAutoRange=False ) self.brushButton.setChecked(True) self.store_data() self.manualAnnotState = { - "editID": self.editIDspinbox.value(), - "isAutoID": self.autoIDcheckbox.isChecked(), - "doWarnLostObj": self.warnLostCellsAction.isChecked(), + 'editID': self.editIDspinbox.value(), + 'isAutoID': self.autoIDcheckbox.isChecked(), + 'doWarnLostObj': self.warnLostCellsAction.isChecked(), } self.autoIDcheckbox.setChecked(False) self.warnLostCellsAction.setChecked(False) hoverID = self.getLastHoveredID() if hoverID == 0: win = apps.QLineEditDialog( - title="Not hovering any ID", - msg="You are not hovering on any ID.\n" - "Enter the ID that you want to lock.", - parent=self, + title='Not hovering any ID', + msg='You are not hovering on any ID.\n' + 'Enter the ID that you want to lock.', + parent=self, isInteger=True, - defaultTxt=self.setBrushID(return_val=True), + defaultTxt=self.setBrushID(return_val=True) ) win.exec_() if win.cancel: @@ -414,42 +419,44 @@ def manualAnnotPast_cb(self, checked): return hoverID = win.EntryID self.logger.info( - "Setting manual annotation for ID = " - f"{hoverID}, at frame n. {posData.frame_i + 1}" + 'Setting manual annotation for ID = ' + f'{hoverID}, at frame n. {posData.frame_i+1}' ) self.editIDspinbox.setValue(hoverID) try: obj_idx = posData.IDs_idxs[hoverID] obj = posData.rp[obj_idx] - radius = ( - 0.9 * obj.minor_axis_length / 2 - ) # math.sqrt(obj.area/math.pi)*0.9 + radius = 0.9 * obj.minor_axis_length / 2 # math.sqrt(obj.area/math.pi)*0.9 self.brushSizeSpinbox.setValue(round(radius)) - except Exception: + except Exception as err: pass - - self.manualAnnotState["frame_i_to_restore"] = posData.frame_i - self.manualAnnotState["last_tracked_i"] = ( - self.navigateScrollBar.maximum() - 1 + + self.manualAnnotState['frame_i_to_restore'] = posData.frame_i + self.manualAnnotState['last_tracked_i'] = ( + self.navigateScrollBar.maximum()-1 ) self.ax1.sigRangeChanged.connect(self.highlightManualAnnotMode) - self.ax1.setHighlighted(True, color="green") + self.ax1.setHighlighted(True, color='green') else: - self.setStatusBarLabel() - self.autoIDcheckbox.setChecked(self.manualAnnotState["isAutoID"]) - self.editIDspinbox.setValue(self.manualAnnotState["editID"]) - self.warnLostCellsAction.setChecked(self.manualAnnotState["doWarnLostObj"]) - frame_to_restore = self.manualAnnotState.get("frame_i_to_restore") + self.setStatusBarLabel() + self.autoIDcheckbox.setChecked(self.manualAnnotState['isAutoID']) + self.editIDspinbox.setValue(self.manualAnnotState['editID']) + self.warnLostCellsAction.setChecked( + self.manualAnnotState['doWarnLostObj'] + ) + frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') if frame_to_restore is None: - return - + return + self.store_data() self.store_manual_annot_data() - - last_tracked_i_to_restore = self.manualAnnotState["last_tracked_i"] + + last_tracked_i_to_restore = self.manualAnnotState['last_tracked_i'] self.manualAnnotRestoreLastTrackedFrame(last_tracked_i_to_restore) - - self.logger.info(f"Restoring view to frame n. {posData.frame_i + 1}...") + + self.logger.info( + f'Restoring view to frame n. {posData.frame_i+1}...' + ) posData.frame_i = frame_to_restore self.get_data() self.updateAllImages() @@ -457,30 +464,18 @@ def manualAnnotPast_cb(self, checked): self.ax1.sigRangeChanged.disconnect() self.ax1.setHighlighted(False) QTimer.singleShot(150, self.autoRange) - + self.setManualAnnotModeEnabledTools(checked) - def manual_annotation_highlight_color( - self, - *, - current_frame_i: int, - frame_to_restore: int | None, - ) -> str: - if current_frame_i == frame_to_restore: - return "green" - if frame_to_restore is not None and current_frame_i < frame_to_restore: - return "gold" - return "red" - def onEscape( - self, - isTypingIDFunctionChecked=False, - buttonsToNotUncheck=None, - doAutoRange=True, - ): + self, + isTypingIDFunctionChecked=False, + buttonsToNotUncheck=None, + doAutoRange=True + ): if buttonsToNotUncheck is None: buttonsToNotUncheck = set() - + if self.keepIDsButton.isChecked() and self.keptObjectsIDs: self.keptObjectsIDs = widgets.KeptObjectIDsList( self.keptIDsLineEdit, self.keepIDsConfirmAction @@ -494,25 +489,25 @@ def onEscape( self.typingEditID = False QTimer.singleShot(300, self.autoRange) return - + if isTypingIDFunctionChecked and self.typingEditID: self.typingEditID = False QTimer.singleShot(300, self.autoRange) return - + if self.labelRoiButton.isChecked() and self.isMouseDragImg1: self.isMouseDragImg1 = False - self.labelRoiItem.setPos((0, 0)) - self.labelRoiItem.setSize((0, 0)) + self.labelRoiItem.setPos((0,0)) + self.labelRoiItem.setSize((0,0)) self.freeRoiItem.clear() QTimer.singleShot(300, self.autoRange) return - + if self.zoomRectButton.isChecked(): self.zoomRectCancelled() QTimer.singleShot(300, self.autoRange) return - + self.setUncheckedAllButtons(buttonsToNotUncheck=buttonsToNotUncheck) self.setUncheckedAllCustomAnnotButtons() self.setUncheckedPointsLayers() @@ -522,16 +517,12 @@ def onEscape( self.clearHighlightedID() try: self.polyLineRoi.clearPoints() - except Exception: + except Exception as e: pass - + if doAutoRange: QTimer.singleShot(11, self.autoRange) - def point_in_shape(self, x: int, y: int, shape: tuple[int, int]) -> bool: - height, width = shape - return x >= 0 and x < width and y >= 0 and y < height - def restoreHoverObjBrush(self): posData = self.data[self.pos_i] if self.ax1BrushHoverID in posData.IDs: @@ -539,7 +530,7 @@ def restoreHoverObjBrush(self): obj = posData.rp[obj_idx] if not self.isObjVisible(obj.bbox): return - + self.addObjContourToContoursImage(obj=obj, ax=0) self.addObjContourToContoursImage(obj=obj, ax=1) @@ -550,15 +541,19 @@ def setLostNewOldPrevIDs(self): posData.new_IDs = [] posData.old_IDs = [] # posData.multiContIDs = set() - self.titleLabel.setText("Looking good!", color=self.titleColor) + self.titleLabel.setText('Looking good!', color=self.titleColor) return [] - + # elif self.modeComboBox.currentText() == 'Viewer': # pass - + out = self.updateLostNewCurrentIDs() - lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs = out - self.setTitleText(lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs) + lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs = ( + out + ) + self.setTitleText( + lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs + ) return curr_delRoiIDs def setManualAnnotModeEnabledTools(self, enabled): @@ -566,20 +561,20 @@ def setManualAnnotModeEnabledTools(self, enabled): toolButton = self.editToolBar.widgetForAction(action) if toolButton in self.manulAnnotToolButtons: continue - - toolButton.setDisabled(enabled) - action.setDisabled(enabled) + + toolButton.setDisabled(enabled) + action.setDisabled(enabled) def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): if not IDs: return htmlTxt_li, htmlTxtFull_li - + if isinstance(IDs, set): IDs = list(IDs) trim_IDs = myutils.get_trimmed_list(IDs) - txt = f"{pretxt}: {trim_IDs}" - txt_full = f"{pretxt}:
{IDs}" + txt = f'{pretxt}: {trim_IDs}' + txt_full = f'{pretxt}:
{IDs}' txt = f'{txt}' txt_full = f'{txt_full}' @@ -589,17 +584,21 @@ def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): return htmlTxt_li, htmlTxtFull_li - def setTitleText( - self, lost_IDs=None, new_IDs=None, IDs_with_holes=None, tracked_lost_IDs=None - ): + def setTitleText( + self, lost_IDs=None, new_IDs=None, IDs_with_holes=None, + tracked_lost_IDs=None + ): if self.manualAnnotPastButton.isChecked(): lockedID = self.editIDspinbox.value() - frame_to_restore = self.manualAnnotState.get("frame_i_to_restore") - txt = f"Locked ID {lockedID} since frame n. {frame_to_restore + 1}" + frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + txt = ( + f'Locked ID {lockedID} ' + f'since frame n. {frame_to_restore+1}' + ) htmlTxt = f'{txt}' self.titleLabel.setText(htmlTxt) return - + mode = self.modeComboBox.currentText() try: posData = self.data[self.pos_i] @@ -607,51 +606,51 @@ def setTitleText( prev_segmented = True except IndexError: prev_segmented = False - + if prev_segmented: htmlTxt_li = [] htmlTxtFull_li = [] else: - htmlTxt = 'Never segmented frame. ' + htmlTxt = f'Never segmented frame. ' self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxt) return - - if mode != "Normal division: Lineage tree": + + if mode != 'Normal division: Lineage tree': htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, "IDs lost", "orange", lost_IDs + htmlTxt_li, htmlTxtFull_li, 'IDs lost', 'orange', lost_IDs ) htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, "New IDs", "red", new_IDs + htmlTxt_li, htmlTxtFull_li, 'New IDs', 'red', new_IDs ) htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, "Acc. IDs lost", "green", tracked_lost_IDs + htmlTxt_li, htmlTxtFull_li, 'Acc. IDs lost', 'green', + tracked_lost_IDs ) for i, htmlTxtFull in enumerate(htmlTxtFull_li): - htmlTxtFull_li[i] = htmlTxtFull.replace("Acc.", "Accepted") + htmlTxtFull_li[i] = htmlTxtFull.replace('Acc.', 'Accepted') htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, "IDs with holes", "red", IDs_with_holes + htmlTxt_li, htmlTxtFull_li, 'IDs with holes', 'red', + IDs_with_holes ) else: try: - cells_with_parent, orphan_cells, lost_cells = ( - self.lineage_tree.export_lin_tree_info(posData.frame_i) - ) + cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i) except IndexError or KeyError: - title = "Processing lineage tree..." + title = 'Processing lineage tree...' htmlTxt = f'{title}' self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxt) return except AttributeError: - title = "Lineage tree still initializing..." + title = 'Lineage tree still initializing...' htmlTxt = f'{title}' self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxt) return - + parent_cell_txt_raw = [] if cells_with_parent: # aggregate same parents @@ -661,32 +660,30 @@ def setTitleText( parent_cell_groups[parent] = [] parent_cell_groups[parent].append(cell) for parent, daughters in parent_cell_groups.items(): - cells_str = ",".join([str(daughter) for daughter in daughters]) - parent_cell_txt_raw.append(f"({parent}>{cells_str})") + cells_str = ','.join([str(daughter) for daughter in daughters]) + parent_cell_txt_raw.append(f'({parent}>{cells_str})') htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, "New w/out mother", "red", orphan_cells + htmlTxt_li, htmlTxtFull_li, 'New w/out mother', 'red', + orphan_cells ) htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, "Lost", "yellow", lost_cells + htmlTxt_li, htmlTxtFull_li, 'Lost', 'yellow', lost_cells ) htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, - htmlTxtFull_li, - "Parent > Cell", - "green", - parent_cell_txt_raw, + htmlTxt_li, htmlTxtFull_li, 'Parent > Cell', 'green', + parent_cell_txt_raw ) if not htmlTxt_li: - title = "Looking good" + title = 'Looking good' htmlTxt = f'{title}' self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxt) return - htmlTxt = ", ".join(htmlTxt_li) - htmlTxtFull = "
".join(htmlTxtFull_li) + htmlTxt = ', '.join(htmlTxt_li) + htmlTxtFull = '
'.join(htmlTxtFull_li) self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxtFull) @@ -695,16 +692,16 @@ def setUncheckedAllButtons(self, buttonsToNotUncheck=None): self.clickedOnBud = False if buttonsToNotUncheck is None: buttonsToNotUncheck = set() - + try: self.BudMothTempLine.setData([], []) - except Exception: + except Exception as e: pass for button in self.checkableButtons: if button in buttonsToNotUncheck: continue button.setChecked(False) - + if self.countObjsButton not in buttonsToNotUncheck: self.countObjsButton.setChecked(False) self.splineHoverON = False @@ -720,71 +717,11 @@ def setUncheckedPointsLayers(self): self.togglePointsLayerAction.setChecked(False) self.magicPromptsToolButton.setChecked(False) - def should_disable_non_functional_buttons(self, is_segm_3d: bool) -> bool: - return is_segm_3d - - LEGACY_METHODS = ( - "uncheckQButton", - "setUncheckedPointsLayers", - "setUncheckedAllButtons", - "setUncheckedAllCustomAnnotButtons", - "onEscape", - "clearTempBrushImage", - "disconnectLeftClickButtons", - "uncheckLeftClickButtons", - "connectLeftClickButtonsPointsLayersToolbar", - "connectLeftClickButtons", - "wand_cb", - "magicPrompts_cb", - "copyLostObjContour_cb", - "manualAnnotPast_cb", - "copyLostObjectMask", - "highlightManualAnnotMode", - "updateHighlightedAxis", - "updateLostNewCurrentIDs", - "highlightLostNew", - "addLostObjsToLostObjImage", - "highlightHoverLostObj", - "annotLostObjsToggled", - "getPrevFrameIDs", - "setLostNewOldPrevIDs", - "setTitleFormatter", - "setTitleText", - "_copyAllLostObjects_navigateToFrame", - "_copyAllLostObjects_returnToFrame", - "_copyAllLostObjects_refreshRp", - "copyAllLostObjects", - "copyAllLostObjectsWorkerCritical", - "copyAllLostObjectsWorkerFinished", - "restoreHoverObjBrush", - "hideItemsHoverBrush", - "updateBrushCursor", - "setManualAnnotModeEnabledTools", - "disableNonFunctionalButtons", - ) - - def should_hide_hover_objects( - self, - *, - brush_auto_hide_checked: bool, - force: bool, - ) -> bool: - return brush_auto_hide_checked or force - - def should_highlight_hover_lost_object( - self, - *, - has_no_modifier: bool, - copy_lost_object_checked: bool, - is_exit_event: bool, - ) -> bool: - return has_no_modifier and copy_lost_object_checked and not is_exit_event - def uncheckLeftClickButtons(self, sender): for button in self.LeftClickButtons: if button != sender: button.setChecked(False) - + if button != self.labelRoiButton: # self.labelRoiButton is disconnected so we manually call uncheck self.labelRoi_cb(False) @@ -797,8 +734,8 @@ def uncheckLeftClickButtons(self, sender): continue except: pass - toolbar.setVisible(False) - + toolbar.setVisible(False) + self.enableSizeSpinbox(False) if sender is not None: self.keepIDsButton.setChecked(False) @@ -820,63 +757,61 @@ def updateBrushCursor(self, x, y, isHoverImg1=True): if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): return - size = self.brushSizeSpinbox.value() * 2 + size = self.brushSizeSpinbox.value()*2 self.setHoverToolSymbolData( - [x], [y], self.activeBrushCircleCursors(isHoverImg1), size=size + [x], [y], self.activeBrushCircleCursors(isHoverImg1), + size=size ) self.setHoverToolSymbolColor( - xdata, - ydata, - self.ax2_BrushCirclePen, + xdata, ydata, self.ax2_BrushCirclePen, self.activeBrushCircleCursors(isHoverImg1), - self.brushButton, - brush=self.ax2_BrushCircleBrush, + self.brushButton, brush=self.ax2_BrushCircleBrush ) def updateHighlightedAxis(self): if not self.manualAnnotPastButton.isChecked(): return - - frame_to_restore = self.manualAnnotState.get("frame_i_to_restore") + + frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') posData = self.data[self.pos_i] if posData.frame_i == frame_to_restore: - color = "green" + color = 'green' elif posData.frame_i < frame_to_restore: - color = "gold" + color = 'gold' else: - color = "red" - + color = 'red' + self.ax1.setHighlightingRectItemsColor(color) def updateLostNewCurrentIDs(self): posData = self.data[self.pos_i] - - prev_IDs = self.getPrevFrameIDs() + + prev_IDs = self.getPrevFrameIDs() tracked_lost_IDs = self.getTrackedLostIDs() curr_IDs = posData.IDs curr_delRoiIDs = self.getStoredDelRoiIDs() - prev_delRoiIDs = self.getStoredDelRoiIDs(frame_i=posData.frame_i - 1) + prev_delRoiIDs = self.getStoredDelRoiIDs(frame_i=posData.frame_i-1) lost_IDs = [ - ID - for ID in prev_IDs - if ID not in curr_IDs - and ID not in prev_delRoiIDs - and ID not in tracked_lost_IDs + ID for ID in prev_IDs if ID not in curr_IDs + and ID not in prev_delRoiIDs and ID not in tracked_lost_IDs ] new_IDs = [ - ID for ID in curr_IDs if ID not in prev_IDs and ID not in curr_delRoiIDs + ID for ID in curr_IDs if ID not in prev_IDs + and ID not in curr_delRoiIDs ] IDs_with_holes = [] posData.lost_IDs = lost_IDs posData.new_IDs = new_IDs posData.old_IDs = prev_IDs posData.IDs = curr_IDs - - out = (lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs) + + out = ( + lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs + ) return out def wand_cb(self, checked): - self.data[self.pos_i] + posData = self.data[self.pos_i] if checked: self.disconnectLeftClickButtons() self.uncheckLeftClickButtons(self.wandToolButton) diff --git a/cellacdc/mixins_bak/tracking.py b/cellacdc/mixins/tracking.py similarity index 73% rename from cellacdc/mixins_bak/tracking.py rename to cellacdc/mixins/tracking.py index cf98a961f..e78726094 100644 --- a/cellacdc/mixins_bak/tracking.py +++ b/cellacdc/mixins/tracking.py @@ -21,21 +21,17 @@ font_13px.setPixelSize(13) -class TrackingMixin: - """Qt-facing adapter for tracking and manual tracking workflows.""" - - """Headless tracking state calculations.""" - - # @exec_time +class Tracking: + """Extracted from guiWin.""" def _drawGhostContour(self, x, y): if self.ghostObject is None: return - + ID = self.ghostObject.label yc, xc = self.ghostObject.local_centroid - Dx = x - xc - Dy = y - yc + Dx = x-xc + Dy = y-yc xx = self.ghostObject.xx_contour + Dx yy = self.ghostObject.yy_contour + Dy self.ghostContourItemLeft.setData( @@ -48,14 +44,14 @@ def _drawGhostContour(self, x, y): def _drawGhostMask(self, x, y): if self.ghostObject is None: return - + self.clearGhostMask() ID = self.ghostObject.label h, w = self.ghostObject.image.shape[-2:] yc, xc = self.ghostObject.local_centroid - Dx = int(x - xc) - Dy = int(y - yc) - bbox = ((Dy, Dy + h), (Dx, Dx + w)) + Dx = int(x-xc) + Dy = int(y-yc) + bbox = ((Dy, Dy+h), (Dx, Dx+w)) Y, X = self.currentLab2D.shape slices = myutils.get_slices_local_into_global_arr(bbox, (Y, X)) @@ -76,11 +72,11 @@ def _drawGhostMask(self, x, y): def _drawManualBackgroundObjContour(self, x, y): if self.manualBackgroundObj is None: return - + ID = self.manualBackgroundObj.label yc, xc = self.manualBackgroundObj.local_centroid - Dx = x - xc - Dy = y - yc + Dx = x-xc + Dy = y-yc xx = self.manualBackgroundObj.xx_contour + Dx yy = self.manualBackgroundObj.yy_contour + Dy self.manualBackgroundObjItem.setData( @@ -93,41 +89,43 @@ def addManualBackgroundItems(self): def addManualBackgroundObject(self, x, y): posData = self.data[self.pos_i] - - if not hasattr(self, "manualBackgroundObj"): + + if not hasattr(self, 'manualBackgroundObj'): self.initManualBackgroundObject() - + Y, X = self.currentLab2D.shape ymin, xmin, ymax, xmax = self.manualBackgroundObj.bbox - width, height = xmax - xmin, ymax - ymin + width, height = xmax-xmin, ymax-ymin yc, xc = self.manualBackgroundObj.local_centroid - xstart, ystart = round(x - xc), round(y - yc) + xstart, ystart = round(x-xc), round(y-yc) xstart = xstart if xstart >= 0 else 0 ystart = ystart if ystart >= 0 else 0 - - xend = xstart + width - yend = ystart + height + + xend = xstart+width + yend = ystart+height xend = xend if xend <= X else X yend = yend if yend <= Y else Y - - width = xend - xstart - height = yend - ystart - + + width = xend-xstart + height = yend-ystart + obj_image = self.manualBackgroundObj.image[:height, :width] obj_slice = (slice(ystart, yend), slice(xstart, xend)) ID = self.manualBackgroundObj.label self.clearManualBackgroundObject(ID) posData.manualBackgroundLab[obj_slice][obj_image] = ID - + if ID in self.manualBackgroundTextItems: self.manualBackgroundTextItems[ID].setPos(x, y) return - - textItem = pg.TextItem(text=str(ID), color="r", anchor=(0.5, 0.5)) + + textItem = pg.TextItem( + text=str(ID), color='r', anchor=(0.5, 0.5) + ) textItem.setFont(font_13px) textItem.setPos(x, y) self.manualBackgroundTextItems[ID] = textItem - + self.ax1.addItem(textItem) def addManualTrackingItems(self): @@ -148,23 +146,23 @@ def annotateAssignedObjsAcdcTrackerSecondStep(self): annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) if annotInfo is None: return - + new_objs_1st_step, lost_objs_1st_step = annotInfo for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): - allContours = self.getObjContours(lostObj, all_external=True) + allContours = self.getObjContours(lostObj, all_external=True) for objContours in allContours: isObjVisible = self.isObjVisible(newObj.bbox) if not isObjVisible: continue - xx = objContours[:, 0] + 0.5 - yy = objContours[:, 1] + 0.5 + xx = objContours[:,0] + 0.5 + yy = objContours[:,1] + 0.5 self.yellowContourScatterItem.addPoints(xx, yy) - + y1, x1 = self.getObjCentroid(lostObj.centroid) y2, x2 = self.getObjCentroid(newObj.centroid) xx, yy = core.get_line(y1, x1, y2, x2, dashed=False) self.ax1_oldMothBudLinesItem.addPoints(xx, yy) - + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None def clearAssignedObjsSecondStep(self): @@ -187,50 +185,33 @@ def clearGhostMask(self): def clearManualBackgroundAnnotations(self): try: for textItem in self.manualBackgroundTextItems.values(): - textItem.setText("") - except Exception: + textItem.setText('') + except Exception as error: pass def clearManualBackgroundObject(self, ID): posData = self.data[self.pos_i] - mask = posData.manualBackgroundLab == ID + mask = posData.manualBackgroundLab==ID posData.manualBackgroundImage[mask, :] = 0 posData.manualBackgroundLab[mask] = 0 - def compute_lost_new_ids( - self, - previous_ids, - current_ids, - *, - current_deleted_roi_ids=(), - previous_deleted_roi_ids=(), - tracked_lost_ids=(), - ) -> LostNewIdsResult: - return compute_lost_new_ids( - previous_ids, - current_ids, - current_deleted_roi_ids=current_deleted_roi_ids, - previous_deleted_roi_ids=previous_deleted_roi_ids, - tracked_lost_ids=tracked_lost_ids, - ) - def doSkipTracking(self, against_next: bool, enforce: bool): if self.isSnapshot: return True - + mode = str(self.modeComboBox.currentText()) - if mode != "Segmentation and Tracking": + if mode != 'Segmentation and Tracking': return True - + if self.UserEnforced_DisabledTracking: return True - + if not self.realTimeTrackingToggle.isChecked(): return True - + posData = self.data[self.pos_i] if against_next: - reference_lab = posData.allData_li[posData.frame_i + 1]["labels"] + reference_lab = posData.allData_li[posData.frame_i+1]['labels'] if reference_lab is None: # Next frame never visited --> cannot track against next return True @@ -238,36 +219,36 @@ def doSkipTracking(self, against_next: bool, enforce: bool): if posData.frame_i == posData.SizeT - 1: # Last frame --> cannot track against next return True - + else: # check that we are not on the last frame if posData.frame_i == 0: return True - + if enforce or self.UserEnforced_Tracking: # Enforce even if not last visited frame return False - + is_first_time_on_next_frame = self.isFirstTimeOnNextFrame() skip_tracking = not is_first_time_on_next_frame - + return skip_tracking def drawManualBackgroundObj(self, x, y): if x is None or y is None: self.clearGhost() return - + self._drawManualBackgroundObjContour(x, y) def drawManualTrackingGhost(self, x, y): if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): return - + if x is None or y is None: self.clearGhost() return - + if self.manualTrackingToolbar.ghostContourRadiobutton.isChecked(): self._drawGhostContour(x, y) else: @@ -277,7 +258,7 @@ def enableSmartTrack(self, checked): posData = self.data[self.pos_i] # Disable tracking for already visited frames - if posData.allData_li[posData.frame_i]["labels"] is not None: + if posData.allData_li[posData.frame_i]['labels'] is not None: trackingEnabled = True else: trackingEnabled = False @@ -296,7 +277,7 @@ def enableSmartTrack(self, checked): def getLastTrackedFrame(self, posData): last_tracked_i = 0 for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict["labels"] + lab = data_dict['labels'] if lab is None: frame_i -= 1 break @@ -311,17 +292,17 @@ def getTrackedLostIDs(self, prev_lab=None, IDs_in_frames=None, frame_i=None): if self.isExportingVideo: posData.trackedLostIDs = trackedLostIDs return trackedLostIDs - + retrackedLostcent = set() if frame_i is None: frame_i = posData.frame_i - + if prev_lab is None: prev_lab = self.get_labels( - from_store=True, - frame_i=posData.frame_i - 1, + from_store=True, + frame_i=posData.frame_i-1, return_existing=False, - return_copy=False, + return_copy=False ) if IDs_in_frames is None: @@ -358,22 +339,22 @@ def get_last_tracked_i(self): posData = self.data[self.pos_i] last_tracked_i = 0 for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict["labels"] + lab = data_dict['labels'] if lab is None and frame_i == 0: last_tracked_i = 0 break elif lab is None: - last_tracked_i = frame_i - 1 + last_tracked_i = frame_i-1 break else: - last_tracked_i = posData.segmSizeT - 1 + last_tracked_i = posData.segmSizeT-1 return last_tracked_i def handleAdditionalInfoRealTimeTracker(self, prev_rp, *args): - if self._rtTrackerName == "CellACDC_normal_division": + if self._rtTrackerName == 'CellACDC_normal_division': tracked_lost_IDs = args[0] self.setTrackedLostCentroids(prev_rp, tracked_lost_IDs) - elif self._rtTrackerName == "CellACDC_2steps": + elif self._rtTrackerName == 'CellACDC_2steps': if args[0] is None: return posData = self.data[self.pos_i] @@ -381,31 +362,31 @@ def handleAdditionalInfoRealTimeTracker(self, prev_rp, *args): def initGhostObject(self, ID=None): mode = self.modeComboBox.currentText() - if mode != "Segmentation and Tracking": + if mode != 'Segmentation and Tracking': self.ghostObject = None return - + if not self.manualTrackingButton.isChecked(): self.ghostObject = None return - + if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): self.ghostObject = None return - + if ID is None: ID = self.manualTrackingToolbar.spinboxID.value() - + posData = self.data[self.pos_i] if posData.frame_i == 0: self.ghostObject = None return - - prevFrameRp = posData.allData_li[posData.frame_i - 1]["regionprops"] + + prevFrameRp = posData.allData_li[posData.frame_i-1]['regionprops'] if prevFrameRp is None: self.ghostObject = None return - + for obj in prevFrameRp: if obj.label != ID: continue @@ -414,16 +395,18 @@ def initGhostObject(self, ID=None): else: self.ghostObject = None self.manualTrackingToolbar.showWarning( - f"The ID {ID} does not exist in previous frame " - "--> starting a new track." + f'The ID {ID} does not exist in previous frame ' + '--> starting a new track.' ) return - + self.manualTrackingToolbar.clearInfoText() - self.ghostObject.contour = self.getObjContours(self.ghostObject, local=True) - self.ghostObject.xx_contour = self.ghostObject.contour[:, 0] - self.ghostObject.yy_contour = self.ghostObject.contour[:, 1] + self.ghostObject.contour = self.getObjContours( + self.ghostObject, local=True + ) + self.ghostObject.xx_contour = self.ghostObject.contour[:,0] + self.ghostObject.yy_contour = self.ghostObject.contour[:,1] self.ghostMaskItemLeft.initLookupTable(self.lut[ID]) self.ghostMaskItemRight.initLookupTable(self.lut[ID]) @@ -432,26 +415,28 @@ def initManualBackgroundObject(self, ID=None): if not self.manualBackgroundButton.isChecked(): self.manualBackgroundObj = None return - + if ID is None: ID = self.manualBackgroundToolbar.spinboxID.value() - + posData = self.data[self.pos_i] if ID not in posData.IDs: self.manualBackgroundObj = None - self.manualBackgroundToolbar.showWarning(f"The ID {ID} does not exist") + self.manualBackgroundToolbar.showWarning( + f'The ID {ID} does not exist' + ) self.manualBackgroundObjItem.clear() return - + ID_idx = posData.IDs_idxs[ID] self.manualBackgroundObj = posData.rp[ID_idx] - + self.manualBackgroundToolbar.clearInfoText() self.manualBackgroundObj.contour = self.getObjContours( self.manualBackgroundObj, local=True ) - xx_contour = self.manualBackgroundObj.contour[:, 0] - yy_contour = self.manualBackgroundObj.contour[:, 1] + xx_contour = self.manualBackgroundObj.contour[:,0] + yy_contour = self.manualBackgroundObj.contour[:,1] self.manualBackgroundObj.xx_contour = xx_contour self.manualBackgroundObj.yy_contour = yy_contour @@ -459,61 +444,59 @@ def initRealTimeTracker(self, force=False): for rtTrackerAction in self.trackingAlgosGroup.actions(): if rtTrackerAction.isChecked(): break - + aliases = myutils.aliases_real_time_trackers(reverse=True) - + rtTracker = rtTrackerAction.text() rtTracker_txt = rtTracker if rtTracker in aliases: rtTracker = aliases[rtTracker] - - if rtTracker == "Cell-ACDC": + + if rtTracker == 'Cell-ACDC': return - if rtTracker == "YeaZ": + if rtTracker == 'YeaZ': return - + if self.isRealTimeTrackerInitialized and not force: return - - self.logger.info(f"Initializing {rtTracker_txt} tracker...") + + self.logger.info(f'Initializing {rtTracker_txt} tracker...') self._rtTrackerName = rtTracker posData = self.data[self.pos_i] realTimeTracker, track_frame_params = myutils.init_tracker( posData, rtTracker, qparent=self, realTime=True ) if realTimeTracker is None: - self.logger.info(f"{rtTracker} tracker initialization cancelled.") + self.logger.info(f'{rtTracker} tracker initialization cancelled.') return - + self.realTimeTracker = realTimeTracker self.track_frame_params = track_frame_params - self.logger.info(f"{rtTracker} tracker successfully initialized.") - if "image_channel_name" in self.track_frame_params: + self.logger.info(f'{rtTracker} tracker successfully initialized.') + if 'image_channel_name' in self.track_frame_params: # Remove the channel name since it was already loaded in init_tracker - del self.track_frame_params["image_channel_name"] + del self.track_frame_params['image_channel_name'] def initSegmTrackMode(self): posData = self.data[self.pos_i] last_tracked_i = self.get_last_tracked_i() - + if posData.frame_i > last_tracked_i: # Prompt user to go to last tracked frame msg = widgets.myMessageBox() txt = html_utils.paragraph( f'The last visited frame in "Segmentation and Tracking mode" ' - f"is frame {last_tracked_i + 1}.\n\n" - f"We recommend to resume from that frame.

" - "How do you want to proceed?" + f'is frame {last_tracked_i+1}.\n\n' + f'We recommend to resume from that frame.

' + 'How do you want to proceed?' ) goToButton, stayButton = msg.warning( - self, - "Go to last visited frame?", - txt, + self, 'Go to last visited frame?', txt, buttonsTexts=( - f"Resume from frame {last_tracked_i + 1} (RECOMMENDED)", - f"Stay on current frame {posData.frame_i + 1}", - ), + f'Resume from frame {last_tracked_i+1} (RECOMMENDED)', + f'Stay on current frame {posData.frame_i+1}' + ) ) if msg.clickedButton == goToButton: posData.frame_i = last_tracked_i @@ -526,19 +509,19 @@ def initSegmTrackMode(self): current_frame_i = posData.frame_i self.lastFrameRanOnFirstVisitTools = posData.frame_i self.logger.info( - f"Storing data up until frame n. {current_frame_i + 1}..." + f'Storing data up until frame n. {current_frame_i+1}...' ) - pbar = tqdm(total=current_frame_i + 1, ncols=100) + pbar = tqdm(total=current_frame_i+1, ncols=100) for i in range(current_frame_i): posData.frame_i = i self.get_data() - self.store_data(autosave=i == current_frame_i - 1) + self.store_data(autosave=i==current_frame_i-1) pbar.update() pbar.close() posData.frame_i = current_frame_i self.get_data() - + self.highlightLostNew() self.updateLastCheckedFrameWidgets(last_tracked_i) @@ -547,47 +530,33 @@ def initSegmTrackMode(self): def isFirstTimeOnNextFrame(self): posData = self.data[self.pos_i] - posData.last_tracked_i = self.navigateScrollBar.maximum() - 1 + posData.last_tracked_i = self.navigateScrollBar.maximum()-1 return posData.frame_i > posData.last_tracked_i def keepOnlyNewIDAssignedObjsSecondStep(self, trackedID): posData = self.data[self.pos_i] annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) - + if annotInfo is None: - return - + return + new_objs_1st_step, lost_objs_1st_step = annotInfo correct_new_objs, correct_lost_objs = [], [] for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): newObj_ID = posData.lab[newObj.slice][newObj.image][0] if newObj_ID != trackedID: continue - + correct_new_objs.append(newObj) correct_lost_objs.append(lostObj) - + if not correct_new_objs: posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None else: posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( - correct_new_objs, - correct_lost_objs, + correct_new_objs, correct_lost_objs ) - def last_tracked_frame_index( - self, - frame_labels, - *, - first_frame_fallback: int = 0, - total_frames: int | None = None, - ) -> int: - return last_tracked_frame_index( - frame_labels, - first_frame_fallback=first_frame_fallback, - total_frames=total_frames, - ) - def manualBackground_cb(self, checked): if checked: posData = self.data[self.pos_i] @@ -619,7 +588,9 @@ def manualTracking_cb(self, checked): self.UserEnforced_DisabledTracking_previousStatus = ( self.UserEnforced_DisabledTracking ) - self.UserEnforced_Tracking_previousStatus = self.UserEnforced_Tracking + self.UserEnforced_Tracking_previousStatus = ( + self.UserEnforced_Tracking + ) self.UserEnforced_DisabledTracking = True self.UserEnforced_Tracking = False @@ -632,7 +603,9 @@ def manualTracking_cb(self, checked): self.UserEnforced_DisabledTracking = ( self.UserEnforced_DisabledTracking_previousStatus ) - self.UserEnforced_Tracking = self.UserEnforced_Tracking_previousStatus + self.UserEnforced_Tracking = ( + self.UserEnforced_Tracking_previousStatus + ) self.removeManualTrackingItems() self.clearGhost() @@ -647,7 +620,7 @@ def manuallyEditTracking(self, tracked_lab, allIDs): infoToRemove.append((y, x, new_ID)) continue if new_ID in allIDs: - tempID = maxID + 1 + tempID = maxID+1 tracked_lab[tracked_lab == old_ID] = tempID tracked_lab[tracked_lab == new_ID] = old_ID tracked_lab[tracked_lab == tempID] = new_ID @@ -665,7 +638,7 @@ def realTimeTrackingClicked(self, checked): # NOTE: I know two booleans doing the same thing is overkill # but the code is more readable when we actually need them - self.data[self.pos_i] + posData = self.data[self.pos_i] isRealTimeTrackingDisabled = not checked # Turn off smart tracking @@ -685,7 +658,8 @@ def realTimeTrackingClicked(self, checked): """) msg = widgets.myMessageBox(showCentered=False, wrapText=False) yesButton, noButton = msg.question( - self, "Keep tracking always active?", txt, buttonsTexts=("Yes", "No") + self, 'Keep tracking always active?', txt, + buttonsTexts=('Yes', 'No') ) if msg.clickedButton == yesButton: self.repeatTracking() @@ -698,7 +672,7 @@ def removeManualBackgroundItems(self): self.manualBackgroundObjItem.removeFromPlotItem() self.ax1.removeItem(self.manualBackgroundImageItem) - def removeManualTrackingItems(self): + def removeManualTrackingItems(self): self.ghostContourItemLeft.removeFromPlotItem() self.ghostContourItemRight.removeFromPlotItem() @@ -711,29 +685,32 @@ def repeatTracking(self): self.tracking(enforce=True, DoManualEdit=False) if posData.editID_info: editedIDsInfo = { - posData.lab[y, x]: newID + posData.lab[y,x]:newID for y, x, newID in posData.editID_info - if posData.lab[y, x] != newID + if posData.lab[y,x] != newID } editedIDsInfoItems = [ - f"ID {oldID} --> {newID}" for oldID, newID in editedIDsInfo.items() + f'ID {oldID} --> {newID}' + for oldID, newID in editedIDsInfo.items() ] editIDul = html_utils.to_list(editedIDsInfoItems) msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" + txt = html_utils.paragraph(f""" You requested to repeat tracking but there are manually edited IDs (see edited IDs in the details section below)

Do you want to keep these edits or ignore them? """) - keepManualEditButton = widgets.okPushButton("Keep manually edited IDs") - ignoreButton = widgets.noPushButton("Ignore manually edited IDs") + keepManualEditButton = widgets.okPushButton( + 'Keep manually edited IDs' + ) + ignoreButton = widgets.noPushButton( + 'Ignore manually edited IDs' + ) msg.question( - self, - "Repeat tracking mode", - txt, - buttonsTexts=(keepManualEditButton, ignoreButton), - detailsText=editIDul, + self, 'Repeat tracking mode', txt, + buttonsTexts=(keepManualEditButton, ignoreButton), + detailsText=editIDul ) if msg.cancel: return @@ -749,85 +726,89 @@ def repeatTracking(self): posData.editID_info = [] if np.any(posData.lab != prev_lab): if self.isSnapshot: - self.fixCcaDfAfterEdit("Repeat tracking") + self.fixCcaDfAfterEdit('Repeat tracking') self.updateAllImages() else: - self.warnEditingWithCca_df("Repeat tracking") + self.warnEditingWithCca_df('Repeat tracking') else: self.updateAllImages() - @exception_handler def repeatTrackingVideo(self, checked=False): posData = self.data[self.pos_i] win = widgets.selectTrackerGUI( - posData.SizeT, currentFrameNo=posData.frame_i + 1 + posData.SizeT, currentFrameNo=posData.frame_i+1 ) win.exec_() if win.cancel: - self.logger.info("Tracking aborted.") + self.logger.info('Tracking aborted.') return trackerName = win.selectedItemsText[0] start_n = win.startFrame stop_n = win.stopFrame video_to_track = posData.segm_data - for frame_i in range(start_n - 1, stop_n): + for frame_i in range(start_n-1, stop_n): data_dict = posData.allData_li[frame_i] - lab = data_dict["labels"] + lab = data_dict['labels'] if lab is None: break video_to_track[frame_i] = lab - video_to_track = video_to_track[start_n - 1 : stop_n] - - self.logger.info(f"Importing {trackerName} tracker...") + video_to_track = video_to_track[start_n-1:stop_n] + + self.logger.info(f'Importing {trackerName} tracker...') self.tracker, self.track_params, init_params = myutils.init_tracker( posData, trackerName, qparent=self, return_init_params=True ) if self.track_params is None: - self.logger.info("Tracking aborted.") + self.logger.info('Tracking aborted.') return - - warningText = myutils.validate_tracker_input(self.tracker, video_to_track) + + warningText = myutils.validate_tracker_input( + self.tracker, video_to_track + ) if warningText is not None: self.logger.info(warningText) self.warnTrackerInputNotValid(trackerName, warningText) - return - - if "image_channel_name" in self.track_params: + return + + if 'image_channel_name' in self.track_params: # Remove the channel name since it was already loaded in init_tracker - del self.track_params["image_channel_name"] - + del self.track_params['image_channel_name'] + track_params_log = { - key: value for key, value in self.track_params.items() if key != "image" + key: value for key, value in self.track_params.items() + if key != 'image' } self.logger.info( - "Tracking parameters:\n\n" - f"Initialization parameters: {init_params}\n" - f"Track parameters: {track_params_log}" + 'Tracking parameters:\n\n' + f'Initialization parameters: {init_params}\n' + f'Track parameters: {track_params_log}' ) last_cca_i = self.get_last_cca_frame_i() - if start_n - 2 <= last_cca_i and start_n > 1: - proceed = self.warnRepeatTrackingVideoWithAnnotations(last_cca_i, start_n) + if start_n-2 <= last_cca_i and start_n>1: + proceed = self.warnRepeatTrackingVideoWithAnnotations( + last_cca_i, start_n + ) if not proceed: - self.logger.info("Tracking aborted.") + self.logger.info('Tracking aborted.') return - - self.logger.info(f"Removing annotations from frame n. {start_n}.") - self.resetCcaFuture(start_n - 1) + + self.logger.info(f'Removing annotations from frame n. {start_n}.') + self.resetCcaFuture(start_n-1) self.start_n = start_n self.stop_n = stop_n - - info_txt = f"Tracking from frame n. {start_n} to {stop_n}..." + + info_txt = f'Tracking from frame n. {start_n} to {stop_n}...' self.logger.info(info_txt) self.progressWin = apps.QDialogWorkerProgress( - title="Tracking", parent=self, pbarDesc=info_txt + title='Tracking', parent=self, pbarDesc=info_txt ) self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stop_n - start_n) + self.progressWin.mainPbar.setMaximum(stop_n-start_n) self.startTrackingWorker(posData, video_to_track) def resetManualBackgroundItems(self): @@ -840,30 +821,11 @@ def resetManualBackgroundSpinboxID(self): if not self.manualBackgroundButton.isChecked(): self.manualBackgroundObj = None return - + posData = self.data[self.pos_i] minID = min(posData.IDs, default=0) self.manualBackgroundToolbar.spinboxID.setValue(minID) - def scan_future_id_propagation( - self, - target_id: int, - *, - current_frame_i: int, - frame_labels, - fallback_frame_labels, - include_unvisited: bool = False, - total_frames: int | None = None, - ) -> FutureIdPropagationScan: - return scan_future_id_propagation( - target_id, - current_frame_i=current_frame_i, - frame_labels=frame_labels, - fallback_frame_labels=fallback_frame_labels, - include_unvisited=include_unvisited, - total_frames=total_frames, - ) - def separateByLabelling(self, lab, rp, maxID=None): """ Label each single object in posData.lab and if the result is more than @@ -876,11 +838,11 @@ def separateByLabelling(self, lab, rp, maxID=None): for obj in rp: lab_obj = skimage.measure.label(obj.image) rp_lab_obj = skimage.measure.regionprops(lab_obj) - if len(rp_lab_obj) <= 1: + if len(rp_lab_obj)<=1: continue lab_obj += maxID - _slice = obj.slice # self.getObjSlice(obj.slice) - _objMask = obj.image # self.getObjImage(obj.image) + _slice = obj.slice # self.getObjSlice(obj.slice) + _objMask = obj.image # self.getObjImage(obj.image) lab[_slice][_objMask] = lab_obj[_objMask] setRp = True maxID += 1 @@ -899,21 +861,21 @@ def setManualBackgrounNextID(self): def setManualBackgroundImage(self): if not self.manualBackgroundButton.isChecked(): return - + posData = self.data[self.pos_i] - if not hasattr(posData, "manualBackgroundImage"): + if not hasattr(posData, 'manualBackgroundImage'): self.initManualBackgroundImage() - + contours = [] - for obj in skimage.measure.regionprops(posData.manualBackgroundLab): - obj_contours = self.getObjContours(obj, all_external=True) + for obj in skimage.measure.regionprops(posData.manualBackgroundLab): + obj_contours = self.getObjContours(obj, all_external=True) contours.extend(obj_contours) textItem = self.manualBackgroundTextItems[obj.label] - textItem.setText(f"{obj.label}") + textItem.setText(f'{obj.label}') self.ax1.addItem(textItem) yc, xc = obj.centroid textItem.setPos(xc, yc) - + cv2.drawContours( posData.manualBackgroundImage, contours, -1, (255, 0, 0, 200), 1 ) @@ -923,15 +885,15 @@ def setManualBackgroundLab(self, load_from_store=False, debug=True): posData = self.data[self.pos_i] if posData.manualBackgroundLab is None: self.initManualBackgroundImage() - + for obj in skimage.measure.regionprops(posData.manualBackgroundLab): - textItem = pg.TextItem(text="", color="r", anchor=(0.5, 0.5)) + textItem = pg.TextItem(text='', color='r', anchor=(0.5, 0.5)) if obj.label in self.manualBackgroundTextItems: continue self.manualBackgroundTextItems[obj.label] = textItem def setTrackedLostCentroids(self, prev_rp, tracked_lost_IDs): - """Store centroids of those IDs the tracker decided is fine to lose + """Store centroids of those IDs the tracker decided is fine to lose (e.g., upon standard cell division the ID of the mother is fine) Parameters @@ -941,52 +903,43 @@ def setTrackedLostCentroids(self, prev_rp, tracked_lost_IDs): tracked_lost_IDs : iterable List-like container of the IDs that is fine to lose from previous frame to current frame - + Note ---- - This function stores the centroids because the user could change IDs + This function stores the centroids because the user could change IDs in multiple ways. Storing centroids is more robust. - """ + """ posData = self.data[self.pos_i] frame_i = posData.frame_i - + for obj in prev_rp: if obj.label not in tracked_lost_IDs: continue - + int_centroid = tuple([int(val) for val in obj.centroid]) try: posData.tracked_lost_centroids[frame_i].add(int_centroid) except KeyError: - posData.tracked_lost_centroids[frame_i] = {int_centroid} + posData.tracked_lost_centroids[frame_i] = {int_centroid} def trackFrame( - self, - prev_lab, - prev_rp, - curr_lab, - curr_rp, - curr_IDs, - assign_unique_new_IDs=True, - IDs=None, - unique_ID=None, - ): + self, prev_lab, prev_rp, curr_lab, curr_rp, curr_IDs, + assign_unique_new_IDs=True, IDs=None, unique_ID=None + ): if self.trackWithAcdcAction.isChecked(): tracked_result = CellACDC_tracker.track_frame( - prev_lab, - prev_rp, - curr_lab, - curr_rp, + prev_lab, prev_rp, curr_lab, curr_rp, IDs_curr_untracked=curr_IDs, setBrushID_func=self.setBrushID, posData=self.data[self.pos_i], - assign_unique_new_IDs=assign_unique_new_IDs, + assign_unique_new_IDs=assign_unique_new_IDs, IDs=IDs, - unique_ID=unique_ID, + unique_ID=unique_ID ) elif self.trackWithYeazAction.isChecked(): tracked_result = self.tracking_yeaz.correspondence( - prev_lab, curr_lab, use_modified_yeaz=True, use_scipy=True + prev_lab, curr_lab, use_modified_yeaz=True, + use_scipy=True ) else: tracked_result = self.trackFrameCustomTracker( @@ -999,48 +952,47 @@ def trackFrame( self.handleAdditionalInfoRealTimeTracker(prev_rp, tracked_lost_IDs) else: tracked_lab = tracked_result - + return tracked_lab - def trackFrameCustomTracker(self, prev_lab, currentLab, IDs=None, unique_ID=None): + def trackFrameCustomTracker( + self, prev_lab, currentLab, IDs=None, unique_ID=None + ): if unique_ID is None: unique_ID = self.setBrushID() try: tracked_result = self.realTimeTracker.track_frame( - prev_lab, - currentLab, + prev_lab, currentLab, unique_ID=unique_ID, IDs=IDs, **self.track_frame_params, ) except TypeError as err: - if str(err).find("an unexpected keyword argument 'unique_ID'") != -1: + if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: try: tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, IDs=IDs, **self.track_frame_params + prev_lab, currentLab, IDs=IDs, + **self.track_frame_params ) except TypeError as err: - if str(err).find("an unexpected keyword argument 'IDs'") != -1: + if str(err).find('an unexpected keyword argument \'IDs\'') != -1: tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, **self.track_frame_params - ) + prev_lab, currentLab, + **self.track_frame_params) else: raise err - elif str(err).find("an unexpected keyword argument 'IDs'") != -1: + elif str(err).find('an unexpected keyword argument \'IDs\'') != -1: try: tracked_result = self.realTimeTracker.track_frame( - prev_lab, - currentLab, + prev_lab, currentLab, unique_ID=unique_ID, - **self.track_frame_params, + **self.track_frame_params ) except TypeError as err: - if ( - str(err).find("an unexpected keyword argument 'unique_ID'") - != -1 - ): + if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, **self.track_frame_params + prev_lab, currentLab, + **self.track_frame_params ) else: raise err @@ -1049,12 +1001,9 @@ def trackFrameCustomTracker(self, prev_lab, currentLab, IDs=None, unique_ID=None return tracked_result def trackManuallyAddedObject( - self, - added_IDs: List[int] | int | Set[int], - isNewID: bool, - wl_update: bool = True, - wl_track_og_curr: bool = False, - ): + self, added_IDs: List[int] | int | Set[int], isNewID: bool, + wl_update:bool=True, wl_track_og_curr:bool=False + ): """Track object added manually on frame that was already visited. Parameters @@ -1063,41 +1012,42 @@ def trackManuallyAddedObject( ID or IDs of the object added manually isNewID : bool If True, the added object is new - + Notes ----- - This method tracks the new added object against the previous frame - labels. If the ID determined by tracking is different from `added_ID` - (meaning that tracking thinks the new ID should be changed to the - tracked ID) and the tracked ID is not already existing (which would - otherwise causing merging) we assign the tracked ID to the object with - `added_ID`. - - If instead the tracked ID is the same as `added_ID` we are dealing - with a truly new object. In this case we want to try tracking it against - the next frame (since the next frame was already validated). - As before, we assign the tracked ID (against the next frame) only if - not already existing in current frame (to avoid merging). - """ + This method tracks the new added object against the previous frame + labels. If the ID determined by tracking is different from `added_ID` + (meaning that tracking thinks the new ID should be changed to the + tracked ID) and the tracked ID is not already existing (which would + otherwise causing merging) we assign the tracked ID to the object with + `added_ID`. + + If instead the tracked ID is the same as `added_ID` we are dealing + with a truly new object. In this case we want to try tracking it against + the next frame (since the next frame was already validated). + As before, we assign the tracked ID (against the next frame) only if + not already existing in current frame (to avoid merging). + """ if self.isSnapshot: - return - + return + if not isNewID: return if isinstance(added_IDs, int): added_IDs = [added_IDs] - + posData = self.data[self.pos_i] tracked_lab = self.tracking( - enforce=True, assign_unique_new_IDs=False, return_lab=True, IDs=added_IDs + enforce=True, assign_unique_new_IDs=False, return_lab=True, + IDs=added_IDs ) self.clearAssignedObjsSecondStep() if tracked_lab is None: return - + # Track only new object - prevIDs = posData.allData_li[posData.frame_i - 1]["IDs"] + prevIDs = posData.allData_li[posData.frame_i-1]['IDs'] # mask = np.zeros(posData.lab.shape, dtype=bool) update_rp = False @@ -1111,16 +1061,17 @@ def trackManuallyAddedObject( mask = posData.lab == added_ID try: trackedID = tracked_lab[mask][0] - except IndexError: + except IndexError as err: # added_ID is not present - continue - + continue + isTrackedIDalreadyPresentAndNotNew = ( - posData.IDs_idxs.get(trackedID) is not None and added_ID != trackedID + posData.IDs_idxs.get(trackedID) is not None + and added_ID != trackedID ) if isTrackedIDalreadyPresentAndNotNew: continue - + isTrackedIDinPrevIDs = trackedID in prevIDs if isTrackedIDinPrevIDs: posData.lab[mask] = trackedID @@ -1131,47 +1082,43 @@ def trackManuallyAddedObject( self.clearAssignedObjsSecondStep() continue posData.lab[mask] = trackedID - + self.keepOnlyNewIDAssignedObjsSecondStep(trackedID) update_rp = True - + if update_rp: self.update_rp(wl_update=wl_update) def trackNewIDtoNewIDsFutureFrame(self, newID, newIDmask): posData = self.data[self.pos_i] try: - nextLab = posData.allData_li[posData.frame_i + 1]["labels"] + nextLab = posData.allData_li[posData.frame_i+1]['labels'] except IndexError: # This is last frame --> there are no future frames return - + if nextLab is None: return - + newID_lab = np.zeros_like(posData.lab) newID_lab[newIDmask] = newID newLab_rp = [posData.rp[posData.IDs_idxs[newID]]] - newLab_IDs = [newID] - nextRp = posData.allData_li[posData.frame_i + 1]["regionprops"] - + newLab_IDs = [newID] + nextRp = posData.allData_li[posData.frame_i+1]['regionprops'] + tracked_lab = self.trackFrame( - nextLab, - nextRp, - newID_lab, - newLab_rp, - newLab_IDs, - assign_unique_new_IDs=False, + nextLab, nextRp, newID_lab, newLab_rp, newLab_IDs, + assign_unique_new_IDs=False ) - trackedID = tracked_lab[newID_lab > 0][0] + trackedID = tracked_lab[newID_lab>0][0] if trackedID == newID: # Object does not exist in future frame --> do not track return - + if posData.IDs_idxs.get(trackedID) is not None: # Tracked ID already exists --> do not track to avoid merging return - + return trackedID def trackSubsetIDs(self, subsetIDs: Iterable[int]): @@ -1182,16 +1129,12 @@ def trackSubsetIDs(self, subsetIDs: Iterable[int]): subsetLab = np.zeros_like(posData.lab) for subsetID in subsetIDs: subsetLab[posData.lab == subsetID] = subsetID - - prev_lab = posData.allData_li[posData.frame_i - 1]["labels"] - prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + + prev_lab = posData.allData_li[posData.frame_i-1]['labels'] + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] tracked_lab = self.trackFrame( - prev_lab, - prev_rp, - posData.lab, - posData.rp, - posData.IDs, - assign_unique_new_IDs=True, + prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, + assign_unique_new_IDs=True ) doUpdateRp = False for subsetID in subsetIDs: @@ -1199,66 +1142,37 @@ def trackSubsetIDs(self, subsetIDs: Iterable[int]): trackedID = tracked_lab[subsetIDmask][0] if trackedID == subsetID: continue - + + is_manually_edited = False for y, x, new_ID in posData.editID_info: if new_ID == subsetID: # Do not track because it was manually edited break - + posData.lab[subsetIDmask] = tracked_lab[subsetIDmask] doUpdateRp = True - + if not doUpdateRp: return - + self.update_rp() - def tracked_lost_centroids_from_regionprops( - self, - regionprops, - tracked_lost_ids, - ) -> set[tuple[int, ...]]: - return tracked_lost_centroids_from_regionprops( - regionprops, - tracked_lost_ids, - ) - - def tracked_lost_ids_from_centroids( - self, - previous_labels, - tracked_lost_centroids, - ids_in_frame, - ) -> TrackedLostIdsResult: - return tracked_lost_ids_from_centroids( - previous_labels, - tracked_lost_centroids, - ids_in_frame, - ) - - @exception_handler def tracking( - self, - enforce=False, - DoManualEdit=True, - storeUndo=False, - prev_lab=None, - prev_rp=None, - return_lab=False, - assign_unique_new_IDs=True, - separateByLabel=True, - wl_update=True, - IDs=None, - against_next=False, - ): + self, enforce=False, DoManualEdit=True, + storeUndo=False, prev_lab=None, prev_rp=None, + return_lab=False, assign_unique_new_IDs=True, + separateByLabel=True, wl_update=True, + IDs=None, against_next=False, + ): posData = self.data[self.pos_i] - + if self.doSkipTracking(against_next, enforce): self.setLostNewOldPrevIDs() return - + """Tracking starts here""" staturBarLabelText = self.statusBarLabel.text() - self.statusBarLabel.setText("Tracking...") + self.statusBarLabel.setText('Tracking...') if storeUndo: # Store undo state before modifying stuff @@ -1271,36 +1185,29 @@ def tracking( posData.lab, rp=posData.rp, max_ID=maxID ) if setRp: - self.update_rp( - wl_update=wl_update, - ) + self.update_rp(wl_update=wl_update, ) if prev_lab is None: if not against_next: - prev_lab = posData.allData_li[posData.frame_i - 1]["labels"] + prev_lab = posData.allData_li[posData.frame_i-1]['labels'] else: - prev_lab = posData.allData_li[posData.frame_i + 1]["labels"] + prev_lab = posData.allData_li[posData.frame_i+1]['labels'] if prev_rp is None: if not against_next: - prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] else: - prev_rp = posData.allData_li[posData.frame_i + 1]["regionprops"] - + prev_rp = posData.allData_li[posData.frame_i+1]['regionprops'] + unique_ID = None if posData.frame_i < self.get_last_tracked_i(): unique_ID = self.setBrushID(return_val=True) - + tracked_lab = self.trackFrame( - prev_lab, - prev_rp, - posData.lab, - posData.rp, - posData.IDs, - assign_unique_new_IDs=assign_unique_new_IDs, - IDs=IDs, - unique_ID=unique_ID, + prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, + assign_unique_new_IDs=assign_unique_new_IDs, IDs=IDs, + unique_ID=unique_ID ) - + if DoManualEdit: # Correct tracking with manually changed IDs rp = skimage.measure.regionprops(tracked_lab) @@ -1308,42 +1215,41 @@ def tracking( self.manuallyEditTracking(tracked_lab, IDs) if return_lab: - QTimer.singleShot( - 50, partial(self.statusBarLabel.setText, staturBarLabelText) - ) + QTimer.singleShot(50, partial( + self.statusBarLabel.setText, staturBarLabelText + )) return tracked_lab - + # Update labels, regionprops and determine new and lost IDs posData.lab = tracked_lab - self.update_rp( - wl_update=wl_update, - ) + self.update_rp(wl_update=wl_update, ) self.setAllTextAnnotations() - QTimer.singleShot(50, partial(self.statusBarLabel.setText, staturBarLabelText)) + QTimer.singleShot(50, partial( + self.statusBarLabel.setText, staturBarLabelText + )) def updateAssignedObjsAcdcTrackerSecondStep(self, newID): posData = self.data[self.pos_i] annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) if annotInfo is None: return - + new_objs_1st_step, lost_objs_1st_step = annotInfo correct_new_objs, correct_lost_objs = [], [] for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): newObj_ID = posData.lab[newObj.slice][newObj.image][0] if newObj_ID == newID: - # The ID of the new object tracked with 2nd step was + # The ID of the new object tracked with 2nd step was # manually edit --> do not annotate its linking to lost obj anymore continue correct_new_objs.append(newObj) correct_lost_objs.append(lostObj) - + if not correct_new_objs: posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None else: posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( - correct_new_objs, - correct_lost_objs, + correct_new_objs, correct_lost_objs ) self.annotateAssignedObjsAcdcTrackerSecondStep() @@ -1352,37 +1258,35 @@ def updateGhostMaskOpacity(self, alpha_percentage=None): alpha_percentage = ( self.manualTrackingToolbar.ghostMaskOpacitySpinbox.value() ) - alpha = alpha_percentage / 100 + alpha = alpha_percentage/100 self.ghostMaskItemLeft.setOpacity(alpha) self.ghostMaskItemRight.setOpacity(alpha) def updateLastCheckedFrameWidgets(self, last_tracked_i): - self.navigateScrollBar.setMaximum(last_tracked_i + 1) - self.navSpinBox.setMaximum(last_tracked_i + 1) + self.navigateScrollBar.setMaximum(last_tracked_i+1) + self.navSpinBox.setMaximum(last_tracked_i+1) self.lastTrackedFrameLabel.setText( - f"Last checked frame n. = {last_tracked_i + 1}" + f'Last checked frame n. = {last_tracked_i+1}' ) def warnRepeatTrackingVideoOnVisitedFrames(self, last_tracked_i, start_n): msg = widgets.myMessageBox() txt = html_utils.paragraph( - "You are repeating tracking on frames that have already " - "been visited/tracked before.

" - "This will very likely make the annotations wrong.

" - "If you really want to repeat tracking on the frames before " - f"{last_tracked_i + 1} the annotations from frame " - f"{start_n} to frame {last_tracked_i + 1} " - "will be removed.

" - "Do you want to continue?" + 'You are repeating tracking on frames that have already ' + 'been visited/tracked before.

' + 'This will very likely make the annotations wrong.

' + 'If you really want to repeat tracking on the frames before ' + f'{last_tracked_i+1} the annotations from frame ' + f'{start_n} to frame {last_tracked_i+1} ' + 'will be removed.

' + 'Do you want to continue?' ) noButton, yesButton = msg.warning( - self, - "Repating tracking with annotations!", - txt, + self, 'Repating tracking with annotations!', txt, buttonsTexts=( - " No, stop tracking and keep annotations.", - " Yes, repeat tracking and DELETE annotations.", - ), + ' No, stop tracking and keep annotations.', + ' Yes, repeat tracking and DELETE annotations.' + ) ) if msg.cancel: return False @@ -1395,23 +1299,21 @@ def warnRepeatTrackingVideoOnVisitedFrames(self, last_tracked_i, start_n): def warnRepeatTrackingVideoWithAnnotations(self, last_tracked_i, start_n): msg = widgets.myMessageBox() txt = html_utils.paragraph( - "You are repeating tracking on frames that have cell cycle " - "annotations.

" - "This will very likely make the annotations wrong.

" - "If you really want to repeat tracking on the frames before " - f"{last_tracked_i + 1} the annotations from frame " - f"{start_n} to frame {last_tracked_i + 1} " - "will be removed.

" - "Do you want to continue?" + 'You are repeating tracking on frames that have cell cycle ' + 'annotations.

' + 'This will very likely make the annotations wrong.

' + 'If you really want to repeat tracking on the frames before ' + f'{last_tracked_i+1} the annotations from frame ' + f'{start_n} to frame {last_tracked_i+1} ' + 'will be removed.

' + 'Do you want to continue?' ) noButton, yesButton = msg.warning( - self, - "Repating tracking with annotations!", - txt, + self, 'Repating tracking with annotations!', txt, buttonsTexts=( - " No, stop tracking and keep annotations.", - " Yes, repeat tracking and DELETE annotations.", - ), + ' No, stop tracking and keep annotations.', + ' Yes, repeat tracking and DELETE annotations.' + ) ) if msg.cancel: return False @@ -1423,9 +1325,9 @@ def warnRepeatTrackingVideoWithAnnotations(self, last_tracked_i, start_n): def warnTrackerInputNotValid(self, trackerName, warningText): msg = widgets.myMessageBox(wrapText=False) - txt = warningText.replace("\n", "
") + txt = warningText.replace('\n', '
') txt = html_utils.paragraph( - f"{txt}

" - "Tracking process will be cancelled. Thank you for your patience!" + f'{txt}

' + 'Tracking process will be cancelled. Thank you for your patience!' ) - msg.warning(self, "Invalid input for tracker", txt) + msg.warning(self, 'Invalid input for tracker', txt) diff --git a/cellacdc/mixins_bak/undo_redo.py b/cellacdc/mixins/undo_redo.py similarity index 76% rename from cellacdc/mixins_bak/undo_redo.py rename to cellacdc/mixins/undo_redo.py index 9c3a36100..637fbe103 100644 --- a/cellacdc/mixins_bak/undo_redo.py +++ b/cellacdc/mixins/undo_redo.py @@ -10,10 +10,8 @@ from collections import defaultdict -class UndoRedoMixin: - """Qt-facing adapter around undo/redo actions and state restoration.""" - - # @exec_time +class UndoRedo: + """Extracted from guiWin.""" def UndoCca(self): posData = self.data[self.pos_i] @@ -24,11 +22,12 @@ def UndoCca(self): self.addCcaState(posData.frame_i, posData.cca_df, undoId) storeState = True + # Get previously stored state self.UndoCount += 1 currentCcaStates = posData.UndoRedoCcaStates[posData.frame_i] prevCcaState = currentCcaStates[self.UndoCount] - posData.cca_df = prevCcaState["cca_df"] + posData.cca_df = prevCcaState['cca_df'] self.store_cca_df() self.updateAllImages() @@ -39,7 +38,7 @@ def UndoCca(self): # Undo all past and future frames that has a last status inserted # when modyfing current frame - prevStateId = prevCcaState["id"] + prevStateId = prevCcaState['id'] for frame_i in range(0, posData.SizeT): if storeState: cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) @@ -54,21 +53,21 @@ def UndoCca(self): continue CcaState_i = CcaStates_i[self.UndoCount] - id_i = CcaState_i["id"] + id_i = CcaState_i['id'] if id_i != prevStateId: # The id of the state in frame_i is different from current frame continue - cca_df_i = CcaState_i["cca_df"] + cca_df_i = CcaState_i['cca_df'] self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) - + self.resetWillDivideInfo() self.enqAutosave() def addCcaState(self, frame_i, cca_df, undoId): posData = self.data[self.pos_i] posData.UndoRedoCcaStates[frame_i].insert( - 0, {"id": undoId, "cca_df": cca_df.copy()} + 0, {'id': undoId, 'cca_df': cca_df.copy()} ) def addCurrentState(self, storeImage=False, storeOnlyZoom=False): @@ -85,7 +84,8 @@ def addCurrentState(self, storeImage=False, storeOnlyZoom=False): if storeOnlyZoom: labels, crop_slice = transformation.crop_2D( - self.currentLab2D, self.ax1.viewRange(), tolerance=10, return_copy=False + self.currentLab2D, self.ax1.viewRange(), tolerance=10, + return_copy=False ) if self.isSegm3D: z = self.z_lab(checkIfProj=True) @@ -102,16 +102,16 @@ def addCurrentState(self, storeImage=False, storeOnlyZoom=False): else: labels = posData.lab.copy() crop_slice = None - + state = { - "image": image, - "labels": labels, - "editID_info": posData.editID_info.copy(), - "binnedIDs": posData.binnedIDs.copy(), - "keptObejctsIDs": self.keptObjectsIDs.copy(), - "ripIDs": posData.ripIDs.copy(), - "cca_df": cca_df, - "crop_slice": crop_slice, + 'image': image, + 'labels': labels, + 'editID_info': posData.editID_info.copy(), + 'binnedIDs': posData.binnedIDs.copy(), + 'keptObejctsIDs': self.keptObjectsIDs.copy(), + 'ripIDs': posData.ripIDs.copy(), + 'cca_df': cca_df, + 'crop_slice': crop_slice } posData.UndoRedoStates[posData.frame_i].insert(0, state) @@ -121,16 +121,11 @@ def askPropagateChangePast(self, change_txt): """) msg = widgets.myMessageBox(wrapText=False) yesButton, _ = msg.question( - self, "Propagate change to past frames", txt, buttonsTexts=("Yes", "No") + self, 'Propagate change to past frames', txt, + buttonsTexts=('Yes', 'No') ) return msg.clickedButton == yesButton - def can_redo_labels(self, undo_count: int) -> bool: - return undo_count > 0 - - def can_undo_labels(self, undo_count: int, states: list) -> bool: - return undo_count < len(states) - 1 - def clearUndoQueue(self): posData = self.data[self.pos_i] self.UndoCount = 0 @@ -138,56 +133,44 @@ def clearUndoQueue(self): self.undoAction.setEnabled(False) posData.UndoRedoStates = [[] for _ in range(posData.SizeT)] posData.UndoRedoCcaStates = [[] for _ in range(posData.SizeT)] - if hasattr(self, "undoAddPointQueueMapper"): + if hasattr(self, 'undoAddPointQueueMapper'): self.undoAddPointQueueMapper = defaultdict(list) - def empty_add_point_queue(self): - return defaultdict(list) - - def empty_frame_stacks(self, size_t: int) -> list[list]: - return [[] for _ in range(size_t)] - def getCurrentState(self): posData = self.data[self.pos_i] i = posData.frame_i c = self.UndoCount state = posData.UndoRedoStates[i][c] - if state["image"] is None: + if state['image'] is None: image_left = None else: - image_left = state["image"].copy() - - crop_slice = state["crop_slice"] + image_left = state['image'].copy() + + crop_slice = state['crop_slice'] if crop_slice is None: - posData.lab = state["labels"].copy() + posData.lab = state['labels'].copy() elif self.isSegm3D: z_slice, slice_y, slice_x = crop_slice - posData.lab[..., z_slice, slice_y, slice_x] = state["labels"].copy() + posData.lab[..., z_slice, slice_y, slice_x] = state['labels'].copy() else: slice_y, slice_x = crop_slice - posData.lab[..., slice_y, slice_x] = state["labels"].copy() - - posData.editID_info = state["editID_info"].copy() - posData.binnedIDs = state["binnedIDs"].copy() - posData.ripIDs = state["ripIDs"].copy() - self.keptObjectsIDs = state["keptObejctsIDs"].copy() - cca_df = state["cca_df"] + posData.lab[..., slice_y, slice_x] = state['labels'].copy() + + posData.editID_info = state['editID_info'].copy() + posData.binnedIDs = state['binnedIDs'].copy() + posData.ripIDs = state['ripIDs'].copy() + self.keptObjectsIDs = state['keptObejctsIDs'].copy() + cca_df = state['cca_df'] if cca_df is not None: - posData.cca_df = state["cca_df"].copy() + posData.cca_df = state['cca_df'].copy() else: posData.cca_df = None return image_left def propagateChange( - self, - modID, - modTxt, - doNotShow, - UndoFutFrames, - applyFutFrames, - applyTrackingB=False, - force=False, - ): + self, modID, modTxt, doNotShow, UndoFutFrames, + applyFutFrames, applyTrackingB=False, force=False + ): """ This function determines whether there are already visited future frames that contains "modID". If so, it triggers a pop-up asking the user @@ -195,7 +178,7 @@ def propagateChange( """ posData = self.data[self.pos_i] # Do not check the future for the last frame - if posData.frame_i + 1 == posData.SizeT: + if posData.frame_i+1 == posData.SizeT: # No future frames to propagate the change to return False, False, None, doNotShow @@ -205,8 +188,8 @@ def propagateChange( # frames has an ID affected by the change last_tracked_i_found = False segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i + 1, segmSizeT): - if posData.allData_li[i]["labels"] is None: + for i in range(posData.frame_i+1, segmSizeT): + if posData.allData_li[i]['labels'] is None: if not last_tracked_i_found: # We set last tracked frame at -1 first None found last_tracked_i = i - 1 @@ -217,11 +200,11 @@ def propagateChange( else: lab = posData.segm_data[i] else: - lab = posData.allData_li[i]["labels"] - + lab = posData.allData_li[i]['labels'] + if modID in lab: areFutureIDs_affected.append(True) - + if not last_tracked_i_found: # All frames have been visited in segm&track mode last_tracked_i = posData.SizeT - 1 @@ -237,22 +220,18 @@ def propagateChange( # Ask what to do unless the user has previously checked doNotShowAgain if doNotShow: endFrame_i = last_tracked_i - if applyFutFrames and not UndoFutFrames and modTxt == "Edit ID": - self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i + 1)) + if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': + self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) return UndoFutFrames, applyFutFrames, endFrame_i, doNotShow else: addApplyAllButton = ( - modTxt == "Delete ID" - or modTxt == "Edit ID" - or modTxt == "Assign new ID" + modTxt == 'Delete ID' or modTxt == 'Edit ID' + or modTxt == 'Assign new ID' ) ffa = apps.FutureFramesAction_QDialog( - posData.frame_i + 1, - last_tracked_i, - modTxt, - applyTrackingB=applyTrackingB, - parent=self, - addApplyAllButton=addApplyAllButton, + posData.frame_i+1, last_tracked_i, modTxt, + applyTrackingB=applyTrackingB, parent=self, + addApplyAllButton=addApplyAllButton ) ffa.exec_() decision = ffa.decision @@ -263,41 +242,41 @@ def propagateChange( endFrame_i = ffa.endFrame_i doNotShowAgain = ffa.doNotShowCheckbox.isChecked() askAction = self.askHowFutureFramesActions[modTxt] - askAction.setChecked(not doNotShowAgain) + askAction.setChecked( not doNotShowAgain) askAction.setDisabled(False) self.onlyTracking = False - if decision == "apply_and_reinit": + if decision == 'apply_and_reinit': UndoFutFrames = True applyFutFrames = False - elif decision == "apply_and_NOTreinit": + elif decision == 'apply_and_NOTreinit': UndoFutFrames = False applyFutFrames = False - elif decision == "apply_to_all_visited": + elif decision == 'apply_to_all_visited': UndoFutFrames = False applyFutFrames = True - elif decision == "only_tracking": + elif decision == 'only_tracking': UndoFutFrames = False applyFutFrames = True self.onlyTracking = True - elif decision == "apply_to_all": + elif decision == 'apply_to_all': UndoFutFrames = False applyFutFrames = True posData.includeUnvisitedInfo[modTxt] = True - if applyFutFrames and not UndoFutFrames and modTxt == "Edit ID": - self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i + 1)) + if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': + self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) return UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain def propagateMergeObjsPast(self, IDs_to_merge): self.store_data(autosave=False) posData = self.data[self.pos_i] current_frame_i = posData.frame_i - for past_frame_i in range(posData.frame_i - 1, -1, -1): + for past_frame_i in range(posData.frame_i-1, -1, -1): posData.frame_i = past_frame_i self.get_data() - - IDs = posData.allData_li[past_frame_i]["IDs"] + + IDs = posData.allData_li[past_frame_i]['IDs'] stop_loop = False for ID in IDs_to_merge: if ID not in IDs: @@ -306,19 +285,19 @@ def propagateMergeObjsPast(self, IDs_to_merge): if ID == 0: continue - posData.lab[posData.lab == ID] = self.firstID + posData.lab[posData.lab==ID] = self.firstID self.update_rp() - + self.store_data(autosave=False) - + if stop_loop: break - + posData.frame_i = current_frame_i self.get_data() def redo(self): - self.data[self.pos_i] + posData = self.data[self.pos_i] # Get previously stored state if self.UndoCount > 0: self.UndoCount -= 1 @@ -338,13 +317,6 @@ def redo(self): if self.whitelistIDsButton.isChecked(): self.whitelistHighlightIDs() - def should_disable_undo_after_cca( - self, - undo_count: int, - states: list, - ) -> bool: - return len(states) > undo_count - def storeUndoRedoCca(self, frame_i, cca_df, undoId): if self.isSnapshot: # For snapshot mode we don't store anything because we have only @@ -368,13 +340,15 @@ def storeUndoRedoCca(self, frame_i, cca_df, undoId): if len(posData.UndoRedoCcaStates[frame_i]) > 10: posData.UndoRedoCcaStates[frame_i].pop(-1) - def storeUndoRedoStates(self, UndoFutFrames, storeImage=False, storeOnlyZoom=False): + def storeUndoRedoStates( + self, UndoFutFrames, storeImage=False, storeOnlyZoom=False + ): posData = self.data[self.pos_i] if UndoFutFrames: # Since we modified current frame all future frames that were already # visited are not valid anymore. Undo changes there self.reInitLastSegmFrame(updateImages=False) - + # Keep only 5 Undo/Redo states if len(posData.UndoRedoStates[posData.frame_i]) > 5: posData.UndoRedoStates[posData.frame_i].pop(-1) @@ -383,11 +357,9 @@ def storeUndoRedoStates(self, UndoFutFrames, storeImage=False, storeOnlyZoom=Fal # NOTE: index 0 is most recent state before doing last change self.UndoCount = 0 self.undoAction.setEnabled(True) - self.addCurrentState(storeImage=storeImage, storeOnlyZoom=storeOnlyZoom) - - def trim_stack(self, states: list, *, max_size: int) -> None: - if len(states) > max_size: - states.pop(-1) + self.addCurrentState( + storeImage=storeImage, storeOnlyZoom=storeOnlyZoom + ) def undo(self): addPointsByClickingButton = self.buttonAddPointsByClickingActive() @@ -395,14 +367,14 @@ def undo(self): done = self.undoAddPoint(addPointsByClickingButton.action) if done: return - + if self.UndoCount == 0: # Store current state to enable redoing it self.addCurrentState() - + posData = self.data[self.pos_i] # Get previously stored state - if self.UndoCount < len(posData.UndoRedoStates[posData.frame_i]) - 1: + if self.UndoCount < len(posData.UndoRedoStates[posData.frame_i])-1: self.UndoCount += 1 # Since we have undone then it is possible to redo self.redoAction.setEnabled(True) @@ -413,10 +385,10 @@ def undo(self): self.updateAllImages(image=image_left) self.store_data() - if not self.UndoCount < len(posData.UndoRedoStates[posData.frame_i]) - 1: + if not self.UndoCount < len(posData.UndoRedoStates[posData.frame_i])-1: # We have undone all available states self.undoAction.setEnabled(False) - + if self.whitelistIDsButton.isChecked(): self.whitelistHighlightIDs() diff --git a/cellacdc/mixins_bak/window_events.py b/cellacdc/mixins/window_events.py similarity index 77% rename from cellacdc/mixins_bak/window_events.py rename to cellacdc/mixins/window_events.py index c64f2ee27..ec28c74f6 100644 --- a/cellacdc/mixins_bak/window_events.py +++ b/cellacdc/mixins/window_events.py @@ -27,8 +27,8 @@ _font.setPixelSize(11) -class WindowEventsMixin: - """Qt-facing adapter for main-window and pointer event handling.""" +class WindowEvents: + """Extracted from guiWin.""" def _resizeLeaveSpaceTerminalBelow(self): geometry = self.geometry() @@ -36,7 +36,7 @@ def _resizeLeaveSpaceTerminalBelow(self): top = geometry.top() width = geometry.width() height = geometry.height() - self.setGeometry(left, top + 10, width, height - 200) + self.setGeometry(left, top+10, width, height-200) def _resizeSlidersArea(self): self.navigateScrollBar.setFixedHeight(self.newHeight) @@ -48,7 +48,7 @@ def _resizeSlidersArea(self): self.zSliceSpinbox.setFixedHeight(self.newHeight) try: self.img1.alphaScrollbar.setFixedHeight(self.newHeight) - except Exception: + except Exception as e: pass try: for channel, items in self.overlayLayersItems.items(): @@ -57,10 +57,10 @@ def _resizeSlidersArea(self): except: pass checkBoxStyleSheet = ( - "QCheckBox::indicator {" - f"width: {self.newCheckBoxesHeight}px;" - f"height: {self.newCheckBoxesHeight}px" - "}" + 'QCheckBox::indicator {' + f'width: {self.newCheckBoxesHeight}px;' + f'height: {self.newCheckBoxesHeight}px' + '}' ) for i in range(self.annotOptionsLayout.count()): widget = self.annotOptionsLayout.itemAt(i).widget() @@ -83,36 +83,39 @@ def askCloseAllWindows(self): If you proceed, the other windows will be closed too.
""") msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Open windows", txt, buttonsTexts=("Cancel", "Ok, close now")) + msg.warning( + self, 'Open windows', txt, + buttonsTexts=('Cancel', 'Ok, close now') + ) return msg.cancel def changeEvent(self, event): try: self.delObjToolAction.setChecked(False) - except Exception: + except Exception as err: return def changeRightClickToLeftOnMac(self, mouseEvent): button = mouseEvent.button() if not is_mac: return button - + delObjKeySequence, delObjQtButton = self.delObjAction if delObjKeySequence is None: return button - - if not delObjKeySequence.toString() == "Control": + + if not delObjKeySequence.toString() == 'Control': return button - + if button != Qt.MouseButton.RightButton: return button - + if delObjQtButton == Qt.MouseButton.LeftButton: - # On mac, pressing "Control" and clicking with left button changes - # it to a right click button --> here, left click is required for + # On mac, pressing "Control" and clicking with left button changes + # it to a right click button --> here, left click is required for # delete object --> force return of left click return Qt.MouseButton.LeftButton - + return button def checkOverlayToolbuttonClicked(self, event): @@ -122,15 +125,15 @@ def checkOverlayToolbuttonClicked(self, event): toolbutton = self.allOverlayToolbuttonsByIdx.get(n, None) toolbutton.click() success = True - except Exception: + except Exception as e: # printl(traceback.format_exc()) - success = False + success = False return success def checkSetDelObjActionActive(self, event): if self.delObjAction is None and self.is_win: return - + if self.delObjAction is None: # On mac we check for Key_Control if event.key() == Qt.Key_Control: @@ -138,7 +141,7 @@ def checkSetDelObjActionActive(self, event): return delObjKeySequence, delObjQtButton = self.delObjAction - keySequenceText = widgets.QKeyEventToString(event).rstrip("+") + keySequenceText = widgets.QKeyEventToString(event).rstrip('+') if delObjKeySequence is None: # self.delObjToolAction.setChecked(True) @@ -150,11 +153,11 @@ def checkSetDelObjActionActive(self, event): keySequenceText = widgets.macShortcutToWindows(keySequenceText) # printl( - # delObjKeySequence.toString(), - # keySequenceText, + # delObjKeySequence.toString(), + # keySequenceText, # delObjKeySequenceText # ) - + if keySequenceText == delObjKeySequenceText: self.delObjToolAction.setChecked(True) @@ -163,54 +166,54 @@ def checkTriggerKeyPressShortcuts(self, event: QKeyEvent): isEraserKey = event.key() == self.eraserButton.keyPressShortcut if isBrushKey or isEraserKey: return isBrushKey, isEraserKey - + modifierText = widgets.modifierKeyToText(event.modifiers()) for widget in self.widgetsWithShortcut.values(): - if not hasattr(widget, "keyPressShortcut"): + if not hasattr(widget, 'keyPressShortcut'): continue - + if event.key() == widget.keyPressShortcut: if widget.isCheckable(): widget.setChecked(True) else: - widget.trigger() + widget.trigger() continue - + shortcutText = widget.keyPressShortcut.toString() try: - mod, key = shortcutText.split("+") + mod, key = shortcutText.split('+') if modifierText == mod and event.key() == QKeySequence(key): widget.trigger() - - except Exception: + + except Exception as e: pass - + return isBrushKey, isEraserKey def clearMemory(self): - if not hasattr(self, "data"): + if not hasattr(self, 'data'): return - self.logger.info("Clearing memory...") + self.logger.info('Clearing memory...') for posData in self.data: try: del posData.img_data - except Exception: + except Exception as e: pass try: del posData.segm_data - except Exception: + except Exception as e: pass try: del posData.ol_data_dict - except Exception: + except Exception as e: pass try: del posData.fluo_data_dict - except Exception: + except Exception as e: pass try: del posData.ol_data - except Exception: + except Exception as e: pass del self.data @@ -220,10 +223,10 @@ def closeEvent(self, event): if cancel: event.ignore() return - + self.onEscape() self.saveWindowGeometry() - + if self.newWindows: cancel = self.askCloseAllWindows() if cancel: @@ -237,19 +240,18 @@ def closeEvent(self, event): self.slideshowWin.close() if self.ccaTableWin is not None: self.ccaTableWin.close() - + proceed = self.askSaveOnClosing(event) if not proceed: event.ignore() return self.autoSaveClose() - + if self.autoSaveActiveWorkers: progressWin = apps.QDialogWorkerProgress( - title="Closing autosaving worker", - parent=self, - pbarDesc="Closing autosaving worker...", + title='Closing autosaving worker', parent=self, + pbarDesc='Closing autosaving worker...' ) progressWin.show(self.app) progressWin.mainPbar.setMaximum(0) @@ -259,34 +261,34 @@ def closeEvent(self, event): self.waitCloseAutoSaveWorkerLoop.exec_() progressWin.workerFinished = True progressWin.close() - + self.stopPreprocWorker() self.stopCombineWorker() self.stopCcaIntegrityCheckerWorker() - + # Close the inifinte loop of the thread if self.lazyLoader is not None: self.lazyLoader.exit = True self.lazyLoaderWaitCond.wakeAll() self.waitReadH5cond.wakeAll() - + if self.storeStateWorker is not None: # Close storeStateWorker self.storeStateWorker._stop() while self.storeStateWorker.isFinished: time.sleep(0.05) - + # Block main thread while separate threads closes time.sleep(0.1) self.clearMemory() - self.logger.info("Closing GUI logger...") + self.logger.info('Closing GUI logger...') self.logger.close() - + if self.lazyLoader is None: self.sigClosed.emit(self) - + gc.collect() def doubleKeySpacebarTimerCallback(self): @@ -309,7 +311,7 @@ def doubleKeyTimerCallBack(self): if isBrushChecked and self.uncheck: self.Button.setChecked(False) c = self.defaultToolBarButtonColor - self.Button.setStyleSheet(f"background-color: {c}") + self.Button.setStyleSheet(f'background-color: {c}') def doubleRightClickTimerCallBack(self): if self.isDoubleRightClick: @@ -317,15 +319,16 @@ def doubleRightClickTimerCallBack(self): return self.doubleRightClickTimeElapsed = True self.countRightClicks = 0 - + # Time to double right click on img1 expired --> single right-click - self.gui_imgGradShowContextMenu(*self._img1_click_xy) + self.gui_imgGradShowContextMenu(*self._img1_click_xy) def dragEnterEvent(self, event): file_path = event.mimeData().urls()[0].toLocalFile() if os.path.isdir(file_path): + exp_path = file_path basename = os.path.basename(file_path) - if basename.find("Position_") != -1 or basename == "Images": + if basename.find('Position_')!=-1 or basename=='Images': event.acceptProposedAction() else: event.ignore() @@ -336,7 +339,7 @@ def dropEvent(self, event): event.setDropAction(Qt.CopyAction) file_path = event.mimeData().urls()[0].toLocalFile() self.logger.info(f'Dragged and dropped path "{file_path}"') - os.path.basename(file_path) + basename = os.path.basename(file_path) if os.path.isdir(file_path): exp_path = file_path self.openFolder(exp_path=exp_path) @@ -355,24 +358,25 @@ def enterEvent(self, event): mainWinTop = mainWinGeometry.top() mainWinWidth = mainWinGeometry.width() mainWinHeight = mainWinGeometry.height() - mainWinRight = mainWinLeft + mainWinWidth - mainWinBottom = mainWinTop + mainWinHeight + mainWinRight = mainWinLeft+mainWinWidth + mainWinBottom = mainWinTop+mainWinHeight slideshowWinGeometry = self.slideshowWin.geometry() slideshowWinLeft = slideshowWinGeometry.left() slideshowWinTop = slideshowWinGeometry.top() - slideshowWinGeometry.width() - slideshowWinGeometry.height() + slideshowWinWidth = slideshowWinGeometry.width() + slideshowWinHeight = slideshowWinGeometry.height() # Determine if overlap - overlap = (slideshowWinTop < mainWinBottom) and ( - slideshowWinLeft < mainWinRight + overlap = ( + (slideshowWinTop < mainWinBottom) and + (slideshowWinLeft < mainWinRight) ) autoActivate = ( - self.isDataLoaded - and not overlap - and not posData.disableAutoActivateViewerWindow + self.isDataLoaded and not + overlap and not + posData.disableAutoActivateViewerWindow ) if autoActivate: @@ -388,101 +392,70 @@ def gui_createCursors(self): pixmap = QPixmap(":addDelPolyLineRoi_cursor.svg") self.polyLineRoiCursor = QCursor(pixmap, 16, 16) - + pixmap = QPixmap(":cross_cursor.svg") self.addPointsCursor = QCursor(pixmap, 16, 16) - def isDefaultMiddleClick(self, mouseEvent, modifiers): - if is_mac: - middle_click = ( - mouseEvent.button() == Qt.MouseButton.LeftButton - and modifiers == Qt.ControlModifier - and not self.brushButton.isChecked() - ) - else: - middle_click = mouseEvent.button() == Qt.MouseButton.MiddleButton - return middle_click - - def isMiddleClick(self, mouseEvent, modifiers): - if self.delObjAction is None: - return self.isDefaultMiddleClick(mouseEvent, modifiers) - - delObjKeySequence, delObjQtButton = self.delObjAction - if delObjKeySequence is None: - # Setting only middle click on mac is allowed, however the - # delObjKeySequence is None and the tool button is never checked - isDelObjectActive = True - else: - isDelObjectActive = self.delObjToolAction.isChecked() - - mouseEventButton = self.changeRightClickToLeftOnMac(mouseEvent) - - middle_click = mouseEventButton == delObjQtButton and isDelObjectActive - - return middle_click - - def isPanImageClick(self, mouseEvent, modifiers): - left_click = mouseEvent.button() == Qt.MouseButton.LeftButton - return modifiers == Qt.AltModifier and left_click - def keyDownCallback( - self, isBrushActive, isWandActive, isExpandLabelActive, isLabelRoiCircActive - ): + self, isBrushActive, isWandActive, isExpandLabelActive, + isLabelRoiCircActive + ): isAutoPilotActive = ( self.autoPilotZoomToObjToggle.isChecked() and self.autoPilotZoomToObjToolbar.isVisible() ) if isBrushActive: brushSize = self.brushSizeSpinbox.value() - self.brushSizeSpinbox.setValue(brushSize - 1) + self.brushSizeSpinbox.setValue(brushSize-1) elif isWandActive: wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() - self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance - 1) + self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance-1) elif isExpandLabelActive: self.expandLabel(dilation=False) self.expandFootprintSize += 1 elif isLabelRoiCircActive: val = self.labelRoiCircularRadiusSpinbox.value() - self.labelRoiCircularRadiusSpinbox.setValue(val - 1) + self.labelRoiCircularRadiusSpinbox.setValue(val-1) elif isAutoPilotActive: - self.pointsLayerAutoPilot("prev") + self.pointsLayerAutoPilot('prev') elif self.isNavigateActionOnNextFrame(): posData = self.data[self.pos_i] - self.rightImageFramesScrollbar.setValue(posData.frame_i + 2) + self.rightImageFramesScrollbar.setValue(posData.frame_i+2) else: self.zSliceScrollBar.triggerAction( QAbstractSlider.SliderAction.SliderSingleStepSub ) def keyPressCheckSetSpinboxValue(self, event, spinbox): - """Check if the key pressed is a digit and set the spinbox value + """Check if the key pressed is a digit and set the spinbox value accordingly.""" try: n = int(event.text()) if self.typingEditID: - value = int(f"{spinbox.value()}{n}") + value = int(f'{spinbox.value()}{n}') else: value = n self.typingEditID = True spinbox.setValue(value) - + try: spinbox.timer.stop() - except Exception: + except Exception as err: pass - + spinbox.timer = QTimer(spinbox) - spinbox.timer.timeout.connect(self.editingSpinboxValueTimerCallback) + spinbox.timer.timeout.connect( + self.editingSpinboxValueTimerCallback + ) spinbox.timer.start(2000) spinbox.timer.setSingleShot(True) success = True - except Exception: + except Exception as e: # printl(traceback.format_exc()) - success = False + success = False return success - @exception_handler - def keyPressEvent(self, ev): + def keyPressEvent(self, ev): ctrl = ev.modifiers() == Qt.ControlModifier if ctrl and ev.key() == Qt.Key_D: self.resizeLeaveSpaceTerminalBelow() @@ -491,16 +464,15 @@ def keyPressEvent(self, ev): if ev.key() == Qt.Key_Q and self.debug: try: from . import _q_debug - _q_debug.q_debug(self) - except Exception: + except Exception as err: printl(traceback.format_exc()) printl('[ERROR]: Error with "_qdebug" module. See Traceback above.') pass if not self.isDataLoaded: self.logger.warning( - "Data not loaded yet. Key pressing events are not connected." + 'Data not loaded yet. Key pressing events are not connected.' ) return @@ -508,103 +480,102 @@ def keyPressEvent(self, ev): if not ctrl: self.wasCtrlPressedFirstTime = True self.onCtrlPressedFirstTime() - + if ev.key() == Qt.Key_PageDown: self.onKeyPageDown() - + if ev.key() == Qt.Key_PageUp: self.onKeyPageUp() - + if ev.key() == Qt.Key_Home: self.onKeyHome() - + if ev.key() == Qt.Key_End: self.onKeyEnd() - + modifiers = ev.modifiers() isAltModifier = modifiers == Qt.AltModifier isCtrlModifier = modifiers == Qt.ControlModifier isShiftModifier = modifiers == Qt.ShiftModifier - + self.checkSetDelObjActionActive(ev) - + self.isZmodifier = ( - ev.key() == Qt.Key_Z - and not isAltModifier - and not isCtrlModifier - and not isShiftModifier + ev.key()== Qt.Key_Z and not isAltModifier + and not isCtrlModifier and not isShiftModifier ) if isShiftModifier: if self.brushButton.isChecked(): # Force default brush symbol with shift down self.setHoverToolSymbolColor( - 1, - 1, - self.ax2_BrushCirclePen, + 1, 1, self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, - brush=self.ax2_BrushCircleBrush, - ID=0, + self.brushButton, brush=self.ax2_BrushCircleBrush, + ID=0 ) if self.isSegm3D: - self.changeBrushID() - + self.changeBrushID() + isAnyModifier = isAltModifier or isCtrlModifier or isShiftModifier if not isAnyModifier and self.overlayButton.isChecked(): isButtonClicked = self.checkOverlayToolbuttonClicked(ev) if isButtonClicked: - return - - isBrushActive = self.brushButton.isChecked() or self.eraserButton.isChecked() + return + + isBrushActive = ( + self.brushButton.isChecked() or self.eraserButton.isChecked() + ) isManualTrackingActive = self.manualTrackingButton.isChecked() isManualBackgroundActive = self.manualBackgroundButton.isChecked() isTypingIDFunctionChecked = False if self.brushButton.isChecked() and not self.autoIDcheckbox.isChecked(): - self.keyPressCheckSetSpinboxValue(ev, self.editIDspinbox) + success = self.keyPressCheckSetSpinboxValue(ev, self.editIDspinbox) isTypingIDFunctionChecked = True - + if isManualTrackingActive: isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( ev, self.manualTrackingToolbar.spinboxID ) - + elif isManualBackgroundActive: isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( ev, self.manualBackgroundToolbar.spinboxID ) - + addPointsByClickingButton = self.buttonAddPointsByClickingActive() if ( - addPointsByClickingButton is not None - and addPointsByClickingButton.toolbar.isVisible() - ): + addPointsByClickingButton is not None + and addPointsByClickingButton.toolbar.isVisible() + ): isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( ev, addPointsByClickingButton.rightClickIDSpinbox ) - + isBrushKey, isEraserKey = self.checkTriggerKeyPressShortcuts(ev) isExpandLabelActive = self.expandLabelToolButton.isChecked() isWandActive = self.wandToolButton.isChecked() isLabelRoiCircActive = ( - self.labelRoiButton.isChecked() + self.labelRoiButton.isChecked() and self.labelRoiIsCircularRadioButton.isChecked() ) how = self.drawIDsContComboBox.currentText() - isOverlaySegm = how.find("overlay segm. masks") != -1 - if ev.key() == Qt.Key_Up and not isCtrlModifier: + isOverlaySegm = how.find('overlay segm. masks') != -1 + if ev.key()==Qt.Key_Up and not isCtrlModifier: self.keyUpCallback( - isBrushActive, isWandActive, isExpandLabelActive, isLabelRoiCircActive + isBrushActive, isWandActive, isExpandLabelActive, + isLabelRoiCircActive ) - elif ev.key() == Qt.Key_Down and not isCtrlModifier: + elif ev.key()==Qt.Key_Down and not isCtrlModifier: self.keyDownCallback( - isBrushActive, isWandActive, isExpandLabelActive, isLabelRoiCircActive + isBrushActive, isWandActive, isExpandLabelActive, + isLabelRoiCircActive ) elif ev.key() == Qt.Key_Enter or ev.key() == Qt.Key_Return: if isTypingIDFunctionChecked: self.typingEditID = False elif self.keepIDsButton.isChecked(): self.keepIDsConfirmAction.trigger() - elif ev.key() == Qt.Key_Escape: + elif ev.key() == Qt.Key_Escape: self.onEscape(isTypingIDFunctionChecked=isTypingIDFunctionChecked) elif isAltModifier: isCursorSizeAll = self.app.overrideCursor() == Qt.SizeAllCursor @@ -614,13 +585,13 @@ def keyPressEvent(self, ev): elif isCtrlModifier and isOverlaySegm: if ev.key() == Qt.Key_Up: val = self.imgGrad.labelsAlphaSlider.value() - delta = 5 / self.imgGrad.labelsAlphaSlider.maximum() - val = val + delta + delta = 5/self.imgGrad.labelsAlphaSlider.maximum() + val = val+delta self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) elif ev.key() == Qt.Key_Down: val = self.imgGrad.labelsAlphaSlider.value() - delta = 5 / self.imgGrad.labelsAlphaSlider.maximum() - val = val - delta + delta = 5/self.imgGrad.labelsAlphaSlider.maximum() + val = val-delta self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) elif ev.key() == self.zoomOutKeyValue: self.zoomToCells(enforce=True) @@ -654,7 +625,7 @@ def keyPressEvent(self, ev): if not self.Button.isVisible(): return - + if self.countKeyPress == 0: # If first time clicking B activate brush and start timer # to catch double press of B @@ -675,26 +646,21 @@ def keyPressEvent(self, ev): c = self.defaultToolBarButtonColor else: c = self.doublePressKeyButtonColor - self.Button.setStyleSheet(f"background-color: {c}") + self.Button.setStyleSheet(f'background-color: {c}') self.countKeyPress = 0 if self.xHoverImg is not None: xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) if isBrushKey: self.setHoverToolSymbolColor( - xdata, - ydata, - self.ax2_BrushCirclePen, + xdata, ydata, self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, - brush=self.ax2_BrushCircleBrush, + self.brushButton, brush=self.ax2_BrushCircleBrush ) elif isEraserKey: self.setHoverToolSymbolColor( - xdata, - ydata, - self.eraserCirclePen, + xdata, ydata, self.eraserCirclePen, (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, + self.eraserButton ) def keyReleaseEvent(self, ev): @@ -704,7 +670,7 @@ def keyReleaseEvent(self, ev): self.onCtrlReleased() elif ev.key() == Qt.Key_Shift: self.onShiftReleased() - + canRepeat = ( ev.key() == Qt.Key_Left or ev.key() == Qt.Key_Right @@ -713,13 +679,13 @@ def keyReleaseEvent(self, ev): or ev.key() == Qt.Key_Control or ev.key() == Qt.Key_Backspace or self.delObjToolAction.isChecked() - ) - + ) + if canRepeat and ev.isAutoRepeat(): return - + self.delObjToolAction.setChecked(False) - + if ev.isAutoRepeat() and not ev.key() == Qt.Key_Z: if self.warnKeyPressedMsg is not None: return @@ -732,7 +698,7 @@ def keyReleaseEvent(self, ev): It confuses me :)

Thanks! """) - self.warnKeyPressedMsg.warning(self, "Release the key, please", txt) + self.warnKeyPressedMsg.warning(self, 'Release the key, please', txt) self.warnKeyPressedMsg = None elif ev.isAutoRepeat() and ev.key() == Qt.Key_Z and self.isZmodifier: self.zKeptDown = True @@ -744,26 +710,27 @@ def keyReleaseEvent(self, ev): self.zKeptDown = False def keyUpCallback( - self, isBrushActive, isWandActive, isExpandLabelActive, isLabelRoiCircActive - ): + self, isBrushActive, isWandActive, isExpandLabelActive, + isLabelRoiCircActive + ): isAutoPilotActive = ( self.autoPilotZoomToObjToggle.isChecked() and self.autoPilotZoomToObjToolbar.isVisible() ) if isBrushActive: brushSize = self.brushSizeSpinbox.value() - self.brushSizeSpinbox.setValue(brushSize + 1) + self.brushSizeSpinbox.setValue(brushSize+1) elif isWandActive: wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() - self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance + 1) + self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance+1) elif isExpandLabelActive: self.expandLabel(dilation=True) self.expandFootprintSize += 1 elif isLabelRoiCircActive: val = self.labelRoiCircularRadiusSpinbox.value() - self.labelRoiCircularRadiusSpinbox.setValue(val + 1) + self.labelRoiCircularRadiusSpinbox.setValue(val+1) elif isAutoPilotActive: - self.pointsLayerAutoPilot("next") + self.pointsLayerAutoPilot('next') else: self.zSliceScrollBar.triggerAction( QAbstractSlider.SliderAction.SliderSingleStepAdd @@ -777,55 +744,35 @@ def leaveEvent(self, event): mainWinTop = mainWinGeometry.top() mainWinWidth = mainWinGeometry.width() mainWinHeight = mainWinGeometry.height() - mainWinRight = mainWinLeft + mainWinWidth - mainWinBottom = mainWinTop + mainWinHeight + mainWinRight = mainWinLeft+mainWinWidth + mainWinBottom = mainWinTop+mainWinHeight slideshowWinGeometry = self.slideshowWin.geometry() slideshowWinLeft = slideshowWinGeometry.left() slideshowWinTop = slideshowWinGeometry.top() - slideshowWinGeometry.width() - slideshowWinGeometry.height() + slideshowWinWidth = slideshowWinGeometry.width() + slideshowWinHeight = slideshowWinGeometry.height() # Determine if overlap - overlap = (slideshowWinTop < mainWinBottom) and ( - slideshowWinLeft < mainWinRight + overlap = ( + (slideshowWinTop < mainWinBottom) and + (slideshowWinLeft < mainWinRight) ) autoActivate = ( - self.isDataLoaded - and not overlap - and not posData.disableAutoActivateViewerWindow + self.isDataLoaded and not + overlap and not + posData.disableAutoActivateViewerWindow ) if autoActivate: self.slideshowWin.setFocus() self.slideshowWin.activateWindow() - def middleClickText(self): - if self.delObjAction is None and is_mac: - return "Command + Left Click" - - if self.delObjAction is None: - return "Middle Click" - - delObjKeySequence, delObjQtButton = self.delObjAction - - if delObjQtButton == Qt.MouseButton.LeftButton: - buttonName = "Left click" - elif delObjQtButton == Qt.MouseButton.RightButton: - buttonName = "Right click" - else: - buttonName = "Middle click" - - if delObjKeySequence is None: - return buttonName - - return f"{delObjKeySequence.toString()} + {buttonName}" - def mousePressEvent(self, event) -> None: if event.button() == Qt.MouseButton.RightButton: pos = self.resizeBottomLayoutLine.mapFromGlobal(event.globalPos()) - if pos.y() >= 0: + if pos.y()>=0: self.gui_raiseBottomLayoutContextMenu(event) return super().mousePressEvent(event) @@ -845,7 +792,7 @@ def onKeyPageDown(self): and self.autoPilotZoomToObjToolbar.isVisible() ) if isAutoPilotActive: - self.pointsLayerAutoPilot("prev") + self.pointsLayerAutoPilot('prev') elif self.zSliceScrollBar.isVisible(): self.zSliceScrollBar.triggerAction( QAbstractSlider.SliderAction.SliderSingleStepAdd @@ -857,7 +804,7 @@ def onKeyPageUp(self): and self.autoPilotZoomToObjToolbar.isVisible() ) if isAutoPilotActive: - self.pointsLayerAutoPilot("next") + self.pointsLayerAutoPilot('next') elif self.zSliceScrollBar.isVisible(): self.zSliceScrollBar.triggerAction( QAbstractSlider.SliderAction.SliderSingleStepAdd @@ -868,8 +815,8 @@ def onShiftReleased(self): self.updateBrushCursorOnShiftRelease() def readSettings(self): - settings = QSettings("schmollerlab", "acdc_gui") - if settings.value("geometry") is not None: + settings = QSettings('schmollerlab', 'acdc_gui') + if settings.value('geometry') is not None: self.restoreGeometry(settings.value("geometry")) def resizeBottomLayoutLineClicked(self, event): @@ -885,7 +832,7 @@ def resizeBottomLayoutLineReleased(self): QTimer.singleShot(100, self.autoRange) def resizeEvent(self, event): - if hasattr(self, "ax1"): + if hasattr(self, 'ax1'): self.ax1.autoRange() def resizeLeaveSpaceTerminalBelow(self): @@ -898,13 +845,13 @@ def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): self.newCheckBoxesHeight = self.checkBoxesHeight self.newHeight = self.h else: - self.newHeight = round(self.h * heightFactor) - self.newCheckBoxesHeight = round(self.checkBoxesHeight * heightFactor) - + self.newHeight = round(self.h*heightFactor) + self.newCheckBoxesHeight = round(self.checkBoxesHeight*heightFactor) + if fontSizeFactor is None: newFontSize = self.fontPixelSize else: - newFontSize = round(self.fontPixelSize * fontSizeFactor) + newFontSize = round(self.fontPixelSize*fontSizeFactor) newFont = QFont() newFont.setPixelSize(newFontSize) _font = newFont @@ -924,7 +871,7 @@ def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): self.rightBottomGroupbox.setFont(newFont) try: self.img1.alphaScrollbar.label.setFont(newFont) - except Exception: + except Exception as e: pass for i in range(self.annotOptionsLayout.count()): widget = self.annotOptionsLayout.itemAt(i).widget() @@ -941,7 +888,7 @@ def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): QTimer.singleShot(100, self._resizeSlidersArea) def saveWindowGeometry(self): - settings = QSettings("schmollerlab", "acdc_gui") + settings = QSettings('schmollerlab', 'acdc_gui') settings.setValue("geometry", self.saveGeometry()) def show(self): @@ -958,33 +905,37 @@ def show(self): self.h = self.navSpinBox.size().height() fontSizeFactor = None heightFactor = None - if "bottom_sliders_zoom_perc" in self.df_settings.index: - val = int(self.df_settings.at["bottom_sliders_zoom_perc", "value"]) + if 'bottom_sliders_zoom_perc' in self.df_settings.index: + val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value']) if val != 100: - fontSizeFactor = val / 100 - heightFactor = val / 100 + fontSizeFactor = val/100 + heightFactor = val/100 self.defaultWidgetHeightBottomLayout = self.h self.checkBoxesHeight = 14 self.fontPixelSize = 11 self.defaultBottomLayoutHeight = self.img1BottomGroupbox.height() - + self.bottomLayout.setStretch(0, 0) self.bottomLayout.addSpacing(self.quickSettingsGroupbox.width()) - self.resizeSlidersArea(fontSizeFactor=fontSizeFactor, heightFactor=heightFactor) + self.resizeSlidersArea( + fontSizeFactor=fontSizeFactor, heightFactor=heightFactor + ) self.bottomScrollArea.hide() self.gui_initImg1BottomWidgets() self.img1BottomGroupbox.hide() - self.showPropsDockButton.width() - self.showPropsDockButton.height() + w = self.showPropsDockButton.width() + h = self.showPropsDockButton.height() self.showPropsDockButton.setMaximumWidth(15) self.showPropsDockButton.setMaximumHeight(120) - + for toolbar in self.controlToolBars: - toolbar.setMinimumHeight(self.secondLevelToolbar.sizeHint().height()) + toolbar.setMinimumHeight( + self.secondLevelToolbar.sizeHint().height() + ) self.graphLayout.setFocus() @@ -997,30 +948,27 @@ def showEvent(self, event): self.activateWindow() def stopPreprocWorker(self): - self.logger.info("Closing pre-processing worker...") + self.logger.info('Closing pre-processing worker...') try: self.preprocWorker.stop() - except Exception: + except Exception as err: pass def storeDefaultAndCustomColors(self): c = self.overlayButton.palette().button().color().name() self.defaultToolBarButtonColor = c - self.doublePressKeyButtonColor = "#fa693b" + self.doublePressKeyButtonColor = '#fa693b' def super_show(self): super().show() - def updateBrushCursorOnShiftRelease(self): + def updateBrushCursorOnShiftRelease(self): xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) self.setHoverToolSymbolColor( - xdata, - ydata, - self.ax2_BrushCirclePen, + xdata, ydata, self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, - brush=self.ax2_BrushCircleBrush, - byPassShiftCheck=True, + self.brushButton, brush=self.ax2_BrushCircleBrush, + byPassShiftCheck=True ) if self.isSegm3D: self.changeBrushID() diff --git a/cellacdc/mixins_bak/worker.py b/cellacdc/mixins/worker.py similarity index 74% rename from cellacdc/mixins_bak/worker.py rename to cellacdc/mixins/worker.py index 641c80693..b58bca114 100644 --- a/cellacdc/mixins_bak/worker.py +++ b/cellacdc/mixins/worker.py @@ -12,17 +12,15 @@ from cellacdc import apps, exception_handler, html_utils, issues_url, widgets, workers -class WorkerMixin: - """Qt-facing adapter around background worker setup and callbacks.""" - - """Headless worker progress and lifecycle decisions.""" +class Worker: + """Extracted from guiWin.""" def autoSaveWorkerClosed(self, worker): if self.autoSaveActiveWorkers: - self.logger.info("Autosaving worker closed.") + self.logger.info('Autosaving worker closed.') try: self.autoSaveActiveWorkers.remove(worker) - except Exception: + except Exception as e: pass def autoSaveWorkerDone(self): @@ -43,9 +41,9 @@ def autoSaveWorkerTimerCallback(self, worker, posData): def ccaIntegrityWorkerCritical(self, error): try: raise error - except Exception: + except Exception as err: self.logger.exception(traceback.format_exc()) - + href = f'GitHub page' txt = html_utils.paragraph(f""" Unfortunately the experimental feature @@ -60,21 +58,19 @@ def ccaIntegrityWorkerCritical(self, error): """) msg = widgets.myMessageBox(wrapText=False) msg.warning( - self, - "Experimental feature error", - txt, + self, 'Experimental feature error', txt, commands=(self.log_path,), - path_to_browse=self.logs_path, + path_to_browse=self.logs_path ) self.disableCcaIntegrityChecker() - def gui_createAutoSaveWorker(self): - if not hasattr(self, "data"): + def gui_createAutoSaveWorker(self): + if not hasattr(self, 'data'): return - + if not self.isDataLoaded: - return - + return + if self.autoSaveActiveWorkers: garbage = self.autoSaveActiveWorkers[-1] self.autoSaveGarbageWorkers.append(garbage) @@ -100,17 +96,19 @@ def gui_createAutoSaveWorker(self): autoSaveWorker.sigDone.connect(self.autoSaveWorkerDone) autoSaveWorker.progress.connect(self.workerProgress) autoSaveWorker.finished.connect(self.autoSaveWorkerClosed) - autoSaveWorker.sigAutoSaveCannotProceed.connect(self.turnOffAutoSaveWorker) - + autoSaveWorker.sigAutoSaveCannotProceed.connect( + self.turnOffAutoSaveWorker + ) + autoSaveThread.started.connect(autoSaveWorker.run) autoSaveThread.start() self.autoSaveActiveWorkers.append((autoSaveWorker, autoSaveThread)) - self.logger.info("Autosaving worker started.") + self.logger.info('Autosaving worker started.') def gui_createLazyLoader(self): - if self.lazyLoader is not None: + if not self.lazyLoader is None: return self.lazyLoaderThread = QThread() @@ -119,10 +117,8 @@ def gui_createLazyLoader(self): self.waitReadH5cond = QWaitCondition() self.readH5mutex = QMutex() self.lazyLoader = workers.LazyLoader( - self.lazyLoaderMutex, - self.lazyLoaderWaitCond, - self.waitReadH5cond, - self.readH5mutex, + self.lazyLoaderMutex, self.lazyLoaderWaitCond, + self.waitReadH5cond, self.readH5mutex ) self.lazyLoader.moveToThread(self.lazyLoaderThread) self.lazyLoader.wait = True @@ -159,13 +155,12 @@ def gui_createStoreStateWorker(self): self.storeStateWorker.sigDone.connect(self.storeStateWorkerDone) self.storeStateWorker.progress.connect(self.workerProgress) self.storeStateWorker.finished.connect(self.storeStateWorkerClosed) - + self.storeStateThread.started.connect(self.storeStateWorker.run) self.storeStateThread.start() - self.logger.info("Store state worker started.") + self.logger.info('Store state worker started.') - @exception_handler def lazyLoaderCritical(self, error): if self.progressWin is not None: self.progressWin.workerFinished = True @@ -175,7 +170,7 @@ def lazyLoaderCritical(self, error): raise error def lazyLoaderFinished(self): - self.logger.info("Load chunk data worker done.") + self.logger.info('Load chunk data worker done.') if self.lazyLoader.updateImgOnFinished: self.updateAllImages() @@ -186,37 +181,29 @@ def lazyLoaderFinished(self): def lazyLoaderWorkerClosed(self): if self.lazyLoader.salute: - self.logger.info("Cell-ACDC GUI closed.") + self.logger.info('Cell-ACDC GUI closed.') self.sigClosed.emit(self) - + self.lazyLoader = None - def lazy_loader_progress_description(self, chunk_range) -> str: - coord0_chunk, coord1_chunk = chunk_range - return f"Loading new window, range = ({coord0_chunk}, {coord1_chunk})..." - def loadingNewChunk(self, chunk_range): coord0_chunk, coord1_chunk = chunk_range - desc = f"Loading new window, range = ({coord0_chunk}, {coord1_chunk})..." + desc = ( + f'Loading new window, range = ({coord0_chunk}, {coord1_chunk})...' + ) self.progressWin = apps.QDialogWorkerProgress( - title="Loading data...", parent=self, pbarDesc=desc + title='Loading data...', parent=self, pbarDesc=desc ) self.progressWin.mainPbar.setMaximum(0) self.progressWin.show(self.app) - def progress_log_level(self, logger_level: str = "INFO") -> str: - return logger_level or "INFO" - - def progressbar_maximum(self, total_iterations: int) -> int: - if total_iterations == 1: - return 0 - return total_iterations - def relabelWorkerFinished(self): self.updateAllImages() def saveDataWorkerCritical(self, error): - self.logger.warning("Saving process stopped because of critical error.") + self.logger.warning( + 'Saving process stopped because of critical error.' + ) self.saveWin.aborted = True self.worker.finished.emit() self.workerCritical(error) @@ -226,35 +213,21 @@ def savePreprocWorkerFinished(self): self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - + self.setStatusBarLabel() - self.logger.info("Pre-processed data saved!") - self.titleLabel.setText("Pre-processed data saved!", color="w") - - def should_disable_realtime_tracking( - self, - tracking_on_never_visited_frames: bool, - realtime_tracking_enabled: bool, - ) -> bool: - return tracking_on_never_visited_frames and realtime_tracking_enabled - - def should_enqueue_autosave(self, is_saving: bool) -> bool: - return not is_saving + self.logger.info('Pre-processed data saved!') + self.titleLabel.setText('Pre-processed data saved!', color='w') def startPostProcessSegmWorker( - self, - postProcessKwargs, - customPostProcessGroupedFeatures, - customPostProcessFeatures, - ): + self, postProcessKwargs, customPostProcessGroupedFeatures, + customPostProcessFeatures + ): self.thread = QThread() self.postProcessWorker = workers.PostProcessSegmWorker( - postProcessKwargs, - customPostProcessGroupedFeatures, - customPostProcessFeatures, - self, + postProcessKwargs, customPostProcessGroupedFeatures, + customPostProcessFeatures, self ) - + self.postProcessWorker.moveToThread(self.thread) self.postProcessWorker.signals.finished.connect(self.thread.quit) self.postProcessWorker.signals.finished.connect( @@ -269,8 +242,12 @@ def startPostProcessSegmWorker( self.postProcessWorker.signals.initProgressBar.connect( self.workerInitProgressbar ) - self.postProcessWorker.signals.progressBar.connect(self.workerUpdateProgressbar) - self.postProcessWorker.signals.critical.connect(self.workerCritical) + self.postProcessWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.postProcessWorker.signals.critical.connect( + self.workerCritical + ) self.thread.started.connect(self.postProcessWorker.run) self.thread.start() @@ -295,7 +272,9 @@ def startRelabellingWorker(self, posFoldernames): def startTrackingWorker(self, posData, video_to_track): self.thread = QThread() - self.trackingWorker = workers.trackingWorker(posData, self, video_to_track) + self.trackingWorker = workers.trackingWorker( + posData, self, video_to_track + ) self.trackingWorker.moveToThread(self.thread) self.trackingWorker.finished.connect(self.thread.quit) self.trackingWorker.finished.connect(self.trackingWorker.deleteLater) @@ -303,9 +282,15 @@ def startTrackingWorker(self, posData, video_to_track): # Custom signals self.trackingWorker.signals.progress = self.trackingWorker.progress - self.trackingWorker.signals.progressBar.connect(self.workerUpdateProgressbar) - self.trackingWorker.signals.initProgressBar.connect(self.workerInitProgressbar) - self.trackingWorker.signals.sigInitInnerPbar.connect(self.workerInitInnerPbar) + self.trackingWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) + self.trackingWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.trackingWorker.signals.sigInitInnerPbar.connect( + self.workerInitInnerPbar + ) self.trackingWorker.progress.connect(self.workerProgress) self.trackingWorker.critical.connect(self.workerCritical) self.trackingWorker.finished.connect(self.trackingWorkerFinished) @@ -316,49 +301,46 @@ def startTrackingWorker(self, posData, video_to_track): self.thread.start() def storeStateWorkerClosed(self): - self.logger.info("Store state worker started.") + self.logger.info('Store state worker started.') def storeStateWorkerDone(self): if self.storeStateWorker.callbackOnDone is not None: self.storeStateWorker.callbackOnDone() self.storeStateWorker.callbackOnDone = None - @exception_handler def trackingWorkerFinished(self): if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - self.logger.info("Worker process ended.") + self.logger.info('Worker process ended.') askDisableRealTimeTracking = ( self.trackingWorker.trackingOnNeverVisitedFrames and self.realTimeTrackingToggle.isChecked() ) if askDisableRealTimeTracking: msg = widgets.myMessageBox() - title = "Disable real-time tracking?" + title = 'Disable real-time tracking?' txt = ( - "You perfomed tracking on frames that you have " - "never visited.

" - "Cell-ACDC default behaviour is to track them again when you " - "will visit them.

" - "However, you can overwrite this behaviour and explicitly " - "disable tracking for all of the frames you already tracked.

" - "NOTE: you can reactivate real-time tracking by clicking on the " + 'You perfomed tracking on frames that you have ' + 'never visited.

' + 'Cell-ACDC default behaviour is to track them again when you ' + 'will visit them.

' + 'However, you can overwrite this behaviour and explicitly ' + 'disable tracking for all of the frames you already tracked.

' + 'NOTE: you can reactivate real-time tracking by clicking on the ' '"Reset last segmented frame" button on the top toolbar.

' - "What do you want me to do?" + 'What do you want me to do?' ) _, disableTrackingButton = msg.information( - self, - title, - html_utils.paragraph(txt), + self, title, html_utils.paragraph(txt), buttonsTexts=( - "Keep real-time tracking active (recommended)", - "Disable real-time tracking", - ), + 'Keep real-time tracking active (recommended)', + 'Disable real-time tracking' + ) ) if msg.clickedButton == disableTrackingButton: - self.logger.info("Disabling real time tracking...") + self.logger.info('Disabling real time tracking...') self.realTimeTrackingToggle.setChecked(False) # posData = self.data[self.pos_i] # current_frame_i = posData.frame_i @@ -372,16 +354,15 @@ def trackingWorkerFinished(self): # # Back to current frame # posData.frame_i = current_frame_i # self.get_data() - self.data[self.pos_i] + posData = self.data[self.pos_i] self.updateAllImages() - self.titleLabel.setText("Done", color="w") + self.titleLabel.setText('Done', color='w') - @exception_handler def workerCritical(self, out: Tuple[QObject, Exception]): self.setDisabled(False) try: worker, error = out - except TypeError: + except TypeError as err: error = out if self.progressWin is not None: self.progressWin.workerFinished = True @@ -392,7 +373,7 @@ def workerCritical(self, out: Tuple[QObject, Exception]): worker.thread().quit() worker.deleteLater() worker.thread().deleteLater() - except Exception: + except Exception as err: # Worker already closed pass raise error @@ -400,7 +381,6 @@ def workerCritical(self, out: Tuple[QObject, Exception]): def workerDebug(self, item): tracked_video, worker = item from cellacdc.plot import imshow - imshow(tracked_video) worker.waitCond.wakeAll() @@ -409,9 +389,9 @@ def workerFinished(self): self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - self.logger.info("Worker process ended.") + self.logger.info('Worker process ended.') self.updateAllImages() - self.titleLabel.setText("Done", color="w") + self.titleLabel.setText('Done', color='w') def workerInitInnerPbar(self, totalIter): self.progressWin.innerPbar.setValue(0) @@ -428,7 +408,7 @@ def workerInitProgressbar(self, totalIter): def workerLog(self, text): self.logger.info(text) - def workerProgress(self, text, loggerLevel="INFO"): # used in cca and lin tree + def workerProgress(self, text, loggerLevel='INFO'): # used in cca and lin tree if self.progressWin is not None: self.progressWin.logConsole.append(text) self.logger.log(getattr(logging, loggerLevel), text) diff --git a/cellacdc/mixins_bak/__init__.py b/cellacdc/mixins_bak/__init__.py deleted file mode 100644 index 05c736b8f..000000000 --- a/cellacdc/mixins_bak/__init__.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Mixins for gui.py.""" - -from __future__ import annotations - -from .actions import ActionsMixin -from .annotation_display import AnnotationDisplayMixin -from .app_shell import AppShellMixin -from .brush_tools import BrushToolsMixin -from .canvas_context_menu import CanvasContextMenuMixin -from .canvas_drawing import CanvasDrawingMixin -from .canvas_events import CanvasEventsMixin -from .canvas_hover import CanvasHoverMixin -from .canvas_right_image import CanvasRightImageMixin -from .canvas_selection import CanvasSelectionMixin -from .canvas_tool import CanvasToolMixin -from .cca_edits import CcaFrameEditResultMixin -from .cca_workflows import CcaWorkflowMixin -from .cell_cycle import CellCycleMixin -from .combine import CombineMixin -from .curvature_tools import CurvatureToolsMixin -from .custom_annotations import CustomAnnotationsMixin -from .data_loading import DataLoadingMixin -from .deleted_rois import DeletedRoisMixin -from .display_decorations import DisplayDecorationsMixin -from .draw_clear_region import DrawClearRegionMixin -from .edit_id import EditIdMixin -from .exporting import ExportingMixin -from .formatting import FormattingMixin -from .frame_metadata import FrameMetadataMixin -from .frame_navigation import FrameNavigationMixin -from .geometry import GeometryMixin -from .graphics import GraphicsMixin -from .image_controls import ImageControlsMixin -from .image_display import ImageDisplayMixin -from .label_edits import LabelEditMixin -from .label_editing import LabelEditingMixin -from .label_roi import LabelRoiMixin -from .label_transform_tools import LabelTransformToolsMixin -from .layout_controls import LayoutControlsMixin -from .lineage_interactions import LineageInteractionsMixin -from .lineage import LineageMixin -from .magic_prompts import MagicPromptsMixin -from .main import MainGuiMixin -from .main_menu import MainMenuMixin -from .main_toolbar import MainToolbarMixin -from .measurements import MeasurementsMixin -from .mode_controls import ModeControlsMixin -from .model_registry import ModelRegistryMixin -from .object_cleanup import ObjectCleanupMixin -from .object_counts import ObjectCountMixin -from .object_properties import ObjectPropertiesMixin -from .object_search import ObjectSearchMixin -from .points_layers import PointsLayersMixin -from .points import PointsMixin -from .preprocessing import PreprocessingMixin -from .quick_settings import QuickSettingsMixin -from .saving import SavingMixin -from .seg_for_lost_ids import SegForLostIdsMixin -from .segmentation import SegmentationMixin -from .session import SessionMixin -from .status_hover import StatusHoverMixin -from .tables import TableMixin -from .tool_activation import ToolActivationMixin -from .tracking import TrackingMixin -from .undo_redo import UndoRedoMixin -from .whitelist import WhitelistMixin -from .window_events import WindowEventsMixin -from .worker import WorkerMixin -from .workspace import WorkspaceMixin diff --git a/cellacdc/mixins_bak/canvas_context_menu.py b/cellacdc/mixins_bak/canvas_context_menu.py deleted file mode 100644 index 059280210..000000000 --- a/cellacdc/mixins_bak/canvas_context_menu.py +++ /dev/null @@ -1,138 +0,0 @@ -"""View adapter for canvas context menus and deleted-ROI clicks.""" - -from __future__ import annotations - -import pyqtgraph as pg -from qtpy.QtCore import QPoint -from qtpy.QtWidgets import QAction, QMenu - - -class CanvasContextMenuMixin: - """Qt-facing adapter around canvas context-menu contracts.""" - - """Headless canvas context-menu decision rules.""" - - scale_bar_target = "scale_bar" - timestamp_target = "timestamp" - gradient_target = "gradient" - - def _scale_bar_highlighted(self): - return hasattr(self, "scaleBar") and self.scaleBar.isHighlighted() - - def _show_deleted_roi_context_menu(self, event): - self.roiContextMenu = QMenu(self) - separator = QAction(self) - separator.setSeparator(True) - self.roiContextMenu.addAction(separator) - action = QAction("Remove ROI") - action.triggered.connect(self.removeDelROI) - self.roiContextMenu.addAction(action) - try: - screen_pos = event.screenPos().toPoint() - except AttributeError: - screen_pos = event.screenPos() - self.roiContextMenu.exec_(screen_pos) - - def _timestamp_highlighted(self): - return hasattr(self, "timestamp") and self.timestamp.isHighlighted() - - def clicked_deleted_roi(self, event, left_click, right_click): - pos_data = self.data[self.pos_i] - x, y = event.pos().x(), event.pos().y() - - del_rois = pos_data.allData_li[pos_data.frame_i]["delROIs_info"]["rois"].copy() - for roi in del_rois: - roi_mask = self.getDelRoiMask(roi) - if self.isSegm3D: - clicked_on_roi = roi_mask[self.z_lab(), int(y), int(x)] - else: - clicked_on_roi = roi_mask[int(y), int(x)] - decision = self.deleted_roi_click_decision( - clicked_on_roi=clicked_on_roi, - left_click=left_click, - right_click=right_click, - ) - if decision.show_context_menu: - self.roi_to_del = roi - self._show_deleted_roi_context_menu(event) - return True - if decision.drag_roi: - event.ignore() - return True - return False - - def deleted_roi_click_decision( - self, - *, - clicked_on_roi: bool, - left_click: bool, - right_click: bool, - ) -> DeletedRoiClickDecision: - if not clicked_on_roi: - return DeletedRoiClickDecision(handled=False) - if right_click: - return DeletedRoiClickDecision( - handled=True, - show_context_menu=True, - ) - if left_click: - return DeletedRoiClickDecision(handled=True, drag_roi=True) - return DeletedRoiClickDecision(handled=False) - - def hovered_handles_polyline_roi(self): - pos_data = self.data[self.pos_i] - del_rois_info = pos_data.allData_li[pos_data.frame_i]["delROIs_info"] - handles = [] - for roi in del_rois_info["rois"]: - if not isinstance(roi, pg.PolyLineROI): - continue - for handle in roi.getHandles(): - if handle.currentPen == handle.hoverPen: - handle.roi = roi - handles.append(handle) - return handles - - def hovered_segments_polyline_roi(self): - pos_data = self.data[self.pos_i] - del_rois_info = pos_data.allData_li[pos_data.frame_i]["delROIs_info"] - segments = [] - for roi in del_rois_info["rois"]: - if not isinstance(roi, pg.PolyLineROI): - continue - for segment in roi.segments: - if segment.currentPen == segment.hoverPen: - segment.roi = roi - segments.append(segment) - return segments - - def image_gradient_menu_target( - self, - *, - scale_bar_highlighted: bool, - timestamp_highlighted: bool, - ) -> str: - if scale_bar_highlighted: - return self.scale_bar_target - if timestamp_highlighted: - return self.timestamp_target - return self.gradient_target - - def show_img_gradient_context_menu(self, x, y): - target = self.image_gradient_menu_target( - scale_bar_highlighted=self._scale_bar_highlighted(), - timestamp_highlighted=self._timestamp_highlighted(), - ) - if target == self.scale_bar_target: - self.scaleBar.showContextMenu(x, y) - return - if target == self.timestamp_target: - self.timestamp.showContextMenu(x, y) - return - self.imgGrad.gradient.menu.popup(QPoint(int(x), int(y))) - - def show_right_image_context_menu(self, event): - try: - screen_pos = event.screenPos().toPoint() - except AttributeError: - screen_pos = event.screenPos() - self.imgGradRight.gradient.menu.popup(screen_pos) diff --git a/cellacdc/mixins_bak/canvas_right_image.py b/cellacdc/mixins_bak/canvas_right_image.py deleted file mode 100644 index b4d01094f..000000000 --- a/cellacdc/mixins_bak/canvas_right_image.py +++ /dev/null @@ -1,49 +0,0 @@ -"""View adapter for duplicated right-image interactions.""" - -from __future__ import annotations - -from qtpy.QtCore import Qt -from qtpy.QtGui import QGuiApplication - -from cellacdc import exception_handler - - -class CanvasRightImageMixin: - """Qt-facing adapter for duplicated right-image mouse events.""" - - """Headless duplicated right-image event rules.""" - - @exception_handler - def mouse_drag(self, event): - self.gui_mouseDragEventImg1(event) - - @exception_handler - def mouse_press(self, event): - modifiers = QGuiApplication.keyboardModifiers() - alt = modifiers == Qt.AltModifier - right_click = event.button() == Qt.MouseButton.RightButton and not alt - is_right_click_action_on = any( - [b.isChecked() for b in self.checkableQButtonsGroup.buttons()] - ) - self.typingEditID = False - show_menu = self.should_show_context_menu( - right_click=right_click, - is_right_click_action_on=is_right_click_action_on, - ) - if show_menu: - self.canvas_context_menu_view.show_right_image_context_menu(event) - event.ignore() - else: - self.gui_mousePressEventImg1(event) - - @exception_handler - def mouse_release(self, event): - self.gui_mouseReleaseEventImg1(event) - - def should_show_context_menu( - self, - *, - right_click: bool, - is_right_click_action_on: bool, - ) -> bool: - return right_click and not is_right_click_action_on diff --git a/cellacdc/mixins_bak/canvas_tool.py b/cellacdc/mixins_bak/canvas_tool.py deleted file mode 100644 index abd47f913..000000000 --- a/cellacdc/mixins_bak/canvas_tool.py +++ /dev/null @@ -1,49 +0,0 @@ -"""View adapter for canvas tool interaction decisions.""" - -from __future__ import annotations - - -class CanvasToolMixin: - """Qt-facing adapter around the scriptable canvas tool decision rules.""" - - manual_separate_draw_mode_key = "manual_separate_draw_mode" - - def should_forward_img1_press_to_img2( - self, - *, - right_click: bool, - middle_click: bool, - can_add_point: bool, - mode: str, - is_snapshot: bool, - is_annotate_division: bool, - manual_background_on: bool, - ) -> bool: - return ( - (right_click or (middle_click and not can_add_point)) - and (mode == "Segmentation and Tracking" or is_snapshot) - and not is_annotate_division - and not manual_background_on - ) - - def should_forward_img1_release_to_img2( - self, - *, - right_click: bool, - mode: str, - is_snapshot: bool, - ) -> bool: - return (mode == "Segmentation and Tracking" or is_snapshot) and right_click - - def store_manual_separate_draw_mode(self, settings, settings_csv_path, mode): - settings.at[self.manual_separate_draw_mode_key, "value"] = mode - settings.to_csv(settings_csv_path) - - def viewer_mode_allows_press( - self, - mode: str, - *, - can_add_point: bool = False, - can_ruler: bool = False, - ) -> bool: - return mode != "Viewer" or can_add_point or can_ruler diff --git a/cellacdc/mixins_bak/cca_edits.py b/cellacdc/mixins_bak/cca_edits.py deleted file mode 100644 index 7724aa011..000000000 --- a/cellacdc/mixins_bak/cca_edits.py +++ /dev/null @@ -1,249 +0,0 @@ -"""View-model commands for CCA table edits.""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pandas as pd - -from cellacdc.domain.cell_cycle import ( - add_base_cell_cycle_annotation, - build_base_cell_cycle_annotations, - concat_cell_cycle_annotations, - CcaSnapshotIdEditResult, - apply_manual_cca_changes, - apply_snapshot_cca_id_edits, - has_cell_cycle_annotations, - last_annotated_cell_cycle_frame_index, - merge_missing_cca_ids, - relabel_cca_ids, - remove_cell_cycle_annotations, - remove_future_cell_cycle_annotations, - reset_cca_future_flags, - split_concat_cell_cycle_annotations, -) -from cellacdc.domain.cell_cycle_deletions import ( - CcaDeletedIdsResult, - delete_cca_ids, -) -from cellacdc.domain.cell_cycle_frames import ( - normalize_loaded_cell_cycle_frame_annotations, - prepare_cell_cycle_checker_annotations, - resolve_cell_cycle_annotations, - store_cell_cycle_frame_annotations, -) - - -@dataclass(frozen=True) -class CcaFrameEditResultMixin: - """Result of a current-frame CCA edit command.""" - - cca_df: pd.DataFrame - - -class CcaEditViewModel: - """Application-facing commands for editing CCA tables. - - The Qt view owns undo, persistence, and dialogs. This view model owns the - command shape that binds view events to scriptable domain operations. - """ - - def add_base_annotation( - self, - cca_df: pd.DataFrame, - cell_ids, - *, - base_values: dict | None = None, - ) -> pd.DataFrame: - return add_base_cell_cycle_annotation( - cca_df, - cell_ids, - base_values=base_values, - ) - - def add_missing_ids( - self, - cca_df: pd.DataFrame | None, - base_cca_df: pd.DataFrame, - ) -> CcaFrameEditResult: - return CcaFrameEditResult( - cca_df=merge_missing_cca_ids(cca_df, base_cca_df), - ) - - def apply_manual_changes( - self, - cca_df: pd.DataFrame, - changes, - ) -> pd.DataFrame: - return apply_manual_cca_changes(cca_df, changes) - - def apply_snapshot_id_edits( - self, - cca_df: pd.DataFrame, - edit_text: str, - current_ids, - base_cca_df: pd.DataFrame, - *, - base_values: dict | None = None, - ) -> CcaSnapshotIdEditResult: - return apply_snapshot_cca_id_edits( - cca_df, - edit_text, - current_ids, - base_cca_df, - base_values=base_values, - ) - - def build_base_annotations( - self, - cell_ids, - *, - with_tree_cols: bool = False, - base_values: dict | None = None, - tree_values: dict | None = None, - ) -> pd.DataFrame: - return build_base_cell_cycle_annotations( - cell_ids, - with_tree_cols=with_tree_cols, - base_values=base_values, - tree_values=tree_values, - ) - - def concat_annotations( - self, - frame_records, - cca_colnames, - *, - acdc_key: str = "acdc_df", - size_t: int | None = None, - ) -> pd.DataFrame | None: - return concat_cell_cycle_annotations( - frame_records, - cca_colnames, - acdc_key=acdc_key, - size_t=size_t, - ) - - def delete_ids( - self, - cca_df: pd.DataFrame, - deleted_ids, - ) -> CcaDeletedIdsResult: - return delete_cca_ids(cca_df, deleted_ids) - - def has_annotations(self, acdc_df: pd.DataFrame | None) -> bool: - return has_cell_cycle_annotations(acdc_df) - - def last_annotated_frame_index(self, acdc_dfs) -> int: - return last_annotated_cell_cycle_frame_index(acdc_dfs) - - def normalize_loaded_frame_annotations( - self, - acdc_df: pd.DataFrame | None, - cca_colnames, - int_colnames=(), - ) -> pd.DataFrame | None: - return normalize_loaded_cell_cycle_frame_annotations( - acdc_df, - cca_colnames, - int_colnames, - ) - - def prepare_checker_annotations( - self, - cca_df: pd.DataFrame | None, - *, - checker_running: bool = True, - ) -> pd.DataFrame | None: - return prepare_cell_cycle_checker_annotations( - cca_df, - checker_running=checker_running, - ) - - def relabel_ids( - self, - cca_df: pd.DataFrame, - old_ids, - new_ids, - ) -> CcaFrameEditResult: - return CcaFrameEditResult( - cca_df=relabel_cca_ids(cca_df, old_ids, new_ids), - ) - - def remove_annotations(self, acdc_df: pd.DataFrame | None, cca_colnames): - return remove_cell_cycle_annotations(acdc_df, cca_colnames) - - def remove_future_annotations( - self, - frame_records, - cca_colnames, - from_frame_i: int, - *, - size_t: int | None = None, - concatenated_acdc_df: pd.DataFrame | None = None, - acdc_key: str = "acdc_df", - ): - return remove_future_cell_cycle_annotations( - frame_records, - cca_colnames, - from_frame_i, - size_t=size_t, - concatenated_acdc_df=concatenated_acdc_df, - acdc_key=acdc_key, - ) - - def reset_future_flags(self, cca_df: pd.DataFrame) -> pd.DataFrame: - return reset_cca_future_flags(cca_df) - - def resolve_annotations( - self, - acdc_df: pd.DataFrame | None, - cca_colnames, - *, - is_snapshot: bool = False, - snapshot_cell_ids=(), - dropna: bool = True, - base_values: dict | None = None, - tree_values: dict | None = None, - with_tree_cols: bool = False, - ): - return resolve_cell_cycle_annotations( - acdc_df, - cca_colnames, - is_snapshot=is_snapshot, - snapshot_cell_ids=snapshot_cell_ids, - dropna=dropna, - base_values=base_values, - tree_values=tree_values, - with_tree_cols=with_tree_cols, - ) - - def split_concat_annotations( - self, - global_cca_df: pd.DataFrame | None, - *, - size_t: int | None = None, - frame_level: str = "frame_i", - ) -> list[tuple[int, pd.DataFrame]]: - return split_concat_cell_cycle_annotations( - global_cca_df, - size_t=size_t, - frame_level=frame_level, - ) - - def store_frame_annotations( - self, - acdc_df: pd.DataFrame | None, - cca_df: pd.DataFrame | None, - cca_colnames, - *, - store_checker_copy: bool = False, - store_cca_df_copy: bool = False, - ): - return store_cell_cycle_frame_annotations( - acdc_df, - cca_df, - cca_colnames, - store_checker_copy=store_checker_copy, - store_cca_df_copy=store_cca_df_copy, - ) diff --git a/cellacdc/mixins_bak/cca_workflows.py b/cellacdc/mixins_bak/cca_workflows.py deleted file mode 100644 index db8105710..000000000 --- a/cellacdc/mixins_bak/cca_workflows.py +++ /dev/null @@ -1,160 +0,0 @@ -"""View-model commands for CCA workflow operations.""" - -from __future__ import annotations - -from cellacdc.domain.cell_cycle import ( - annotate_division, - base_cell_cycle_annotation_status, - collect_existing_new_id_cca_rows_from_frames, - dead_or_excluded_mother_pairs, - division_undo_blocking_frame, - extract_cell_cycle_annotations, - fix_will_divide_without_next_generation, - missing_cell_cycle_annotation_items, - overlay_last_annotated_cca, - propagate_s_phase_disappearance_divisions, - reset_will_divide_for_generations, - s_phase_relative_ids_gone, - undo_bud_mother_assignment, - undo_division_annotation, -) -from cellacdc.domain.cell_cycle_auto import ( - apply_auto_cca_assignments, - auto_cca_assignments_from_cost, - auto_cca_candidate_mother_ids, - auto_cca_cost_matrix_from_contours, - auto_cca_cost_matrix_from_distances, - auto_cca_repeat_frame_state, - nearest_point_2d_yx, - prepare_auto_cca_current_frame, - uncorrected_new_ids_for_auto_cca, -) -from cellacdc.domain.cell_cycle_deletions import ( - propagate_deleted_cell_cycle_ids, -) -from cellacdc.domain.cell_cycle_divisions import ( - bud_mother_change_eligibility, - mother_assignment_eligibility, - previous_relative_status_before_bud_emergence, - propagate_bud_mother_assignment, - propagate_manual_division_annotation, - propagate_swap_mothers_assignment, - propagate_will_divide, - swap_mothers_eligibility, -) -from cellacdc.domain.cell_cycle_frames import ( - prepare_missing_cell_cycle_frame_annotations, -) -from cellacdc.domain.cell_cycle_history import ( - known_history_status_for_bud, - propagate_history_knowledge, -) - - -class CcaWorkflowMixin: - """Application-facing commands for CCA workflows and propagation.""" - - def annotate_division(self, *args, **kwargs): - return annotate_division(*args, **kwargs) - - def apply_auto_assignments(self, *args, **kwargs): - return apply_auto_cca_assignments(*args, **kwargs) - - def auto_assignments_from_cost(self, *args, **kwargs): - return auto_cca_assignments_from_cost(*args, **kwargs) - - def auto_candidate_mother_ids(self, *args, **kwargs): - return auto_cca_candidate_mother_ids(*args, **kwargs) - - def auto_cost_matrix_from_contours(self, *args, **kwargs): - return auto_cca_cost_matrix_from_contours(*args, **kwargs) - - def auto_cost_matrix_from_distances(self, *args, **kwargs): - return auto_cca_cost_matrix_from_distances(*args, **kwargs) - - def auto_repeat_frame_state(self, *args, **kwargs): - return auto_cca_repeat_frame_state(*args, **kwargs) - - def base_status(self, base_values=None): - return base_cell_cycle_annotation_status(base_values) - - def bud_mother_change_eligibility(self, *args, **kwargs): - return bud_mother_change_eligibility(*args, **kwargs) - - def collect_existing_new_id_rows(self, *args, **kwargs): - return collect_existing_new_id_cca_rows_from_frames(*args, **kwargs) - - def dead_or_excluded_mother_pairs(self, *args, **kwargs): - return dead_or_excluded_mother_pairs(*args, **kwargs) - - def division_undo_blocking_frame(self, *args, **kwargs): - return division_undo_blocking_frame(*args, **kwargs) - - def extract_annotations(self, *args, **kwargs): - return extract_cell_cycle_annotations(*args, **kwargs) - - def fix_will_divide_without_next_generation(self, *args, **kwargs): - return fix_will_divide_without_next_generation(*args, **kwargs) - - def known_history_status_for_bud(self, *args, **kwargs): - return known_history_status_for_bud(*args, **kwargs) - - def missing_annotation_items(self, *args, **kwargs): - return missing_cell_cycle_annotation_items(*args, **kwargs) - - def mother_assignment_eligibility(self, *args, **kwargs): - return mother_assignment_eligibility(*args, **kwargs) - - def nearest_point_2d_yx(self, *args, **kwargs): - return nearest_point_2d_yx(*args, **kwargs) - - def overlay_last_annotated(self, *args, **kwargs): - return overlay_last_annotated_cca(*args, **kwargs) - - def prepare_auto_current_frame(self, *args, **kwargs): - return prepare_auto_cca_current_frame(*args, **kwargs) - - def prepare_missing_frame_annotations(self, *args, **kwargs): - return prepare_missing_cell_cycle_frame_annotations(*args, **kwargs) - - def previous_relative_status_before_bud_emergence(self, *args, **kwargs): - return previous_relative_status_before_bud_emergence(*args, **kwargs) - - def propagate_bud_mother_assignment(self, *args, **kwargs): - return propagate_bud_mother_assignment(*args, **kwargs) - - def propagate_deleted_ids(self, *args, **kwargs): - return propagate_deleted_cell_cycle_ids(*args, **kwargs) - - def propagate_history_knowledge(self, *args, **kwargs): - return propagate_history_knowledge(*args, **kwargs) - - def propagate_manual_division_annotation(self, *args, **kwargs): - return propagate_manual_division_annotation(*args, **kwargs) - - def propagate_s_phase_disappearance_divisions(self, *args, **kwargs): - return propagate_s_phase_disappearance_divisions(*args, **kwargs) - - def propagate_swap_mothers_assignment(self, *args, **kwargs): - return propagate_swap_mothers_assignment(*args, **kwargs) - - def propagate_will_divide(self, *args, **kwargs): - return propagate_will_divide(*args, **kwargs) - - def reset_will_divide_for_generations(self, *args, **kwargs): - return reset_will_divide_for_generations(*args, **kwargs) - - def s_phase_relative_ids_gone(self, *args, **kwargs): - return s_phase_relative_ids_gone(*args, **kwargs) - - def swap_mothers_eligibility(self, *args, **kwargs): - return swap_mothers_eligibility(*args, **kwargs) - - def uncorrected_new_ids_for_auto(self, *args, **kwargs): - return uncorrected_new_ids_for_auto_cca(*args, **kwargs) - - def undo_bud_mother_assignment(self, *args, **kwargs): - return undo_bud_mother_assignment(*args, **kwargs) - - def undo_division_annotation(self, *args, **kwargs): - return undo_division_annotation(*args, **kwargs) diff --git a/cellacdc/mixins_bak/combine.py b/cellacdc/mixins_bak/combine.py deleted file mode 100644 index 073a0a692..000000000 --- a/cellacdc/mixins_bak/combine.py +++ /dev/null @@ -1,675 +0,0 @@ -"""Qt view adapter for the Combine Channels feature.""" - -from __future__ import annotations - -from typing import List, Dict, Any, Tuple -import numpy as np -from qtpy.QtCore import QThread, QTimer, QMutex, QWaitCondition -from natsort import natsorted - -from cellacdc import core, workers, widgets, html_utils, apps, preprocess, myutils - - -class CombineMixin: - """Qt-facing adapter for the Combine Channels feature.""" - - """Headless state and helpers for combining channel and image arrays.""" - - def _combineDialogClosed(self): - self.combineDialog = None - - def _setup_vars_combine(self): - self.combineWorker = None - self.combineDialog = None - self.combineSegmViewToggle = None - - def combineAllFrames( - self, - steps: List[Dict[str, Any]] = None, - keep_input_data_type: bool = None, - formula: str = None, - ): - if steps and not keep_input_data_type: - raise ValueError("keep_input_data_type must be set if steps is set") - - if steps is None: - steps, keep_input_data_type, formula = self.combineDialog.steps( - return_keepInputDataType=True - ) - txt = "Combining all frames..." - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - selected_channel = core.get_selected_channels(steps) - self.getChData(requ_ch=selected_channel) - - key = (self.pos_i, None, None) - self.combineWorker.setupJob( - self.data, - steps, - keep_input_data_type, - key, - output_as_segm=self.combineDialog.saveAsSegm(), - formula=formula, - ) - - self.combineWorker.wakeUp() - - def combineAllPos( - self, - steps: List[Dict[str, Any]] = None, - keep_input_data_type: bool = None, - formula: str = None, - ): - if steps and not keep_input_data_type: - raise ValueError("keep_input_data_type must be set if steps is set") - - if steps is None: - steps, keep_input_data_type, formula = self.combineDialog.steps( - return_keepInputDataType=True - ) - txt = "Combining all Positions..." - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - selected_channel = core.get_selected_channels(steps) - - for pos_i in range(len(self.data)): - self.getChData(requ_ch=selected_channel, pos_i=pos_i) - - key = (None, None, None) - self.combineWorker.setupJob( - self.data, - steps, - keep_input_data_type, - key, - output_as_segm=self.combineDialog.saveAsSegm(), - formula=formula, - ) - - self.combineWorker.wakeUp() - - def combineChannelsActionTriggered(self): - if self.zProjComboBox is not None: - curr_proj = self.zProjComboBox.currentText() - if curr_proj != "single z-slice": - self.zProjComboBox.setCurrentText("single z-slice") - - if self.switchPlaneCombobox is not None: - depthAxes = self.switchPlaneCombobox.depthAxes() - if depthAxes != "z": - self.switchPlaneCombobox.setCurrentText("xy") - - if self.combineDialog is None: - self.setupCombiningChannels() - self.combineDialog.show() - self.combineDialog.raise_() - self.combineDialog.activateWindow() - self.combineDialog.emitSigPreviewToggled() - - def combineCurrentImage( - self, - steps: List[Dict[str, Any]] = None, - keep_input_data_type: bool = None, - formula: str = None, - ): - - if steps and keep_input_data_type is None: - raise ValueError("keep_input_data_type must be set if steps is set") - - if steps is None: - steps, keep_input_data_type, formula = self.combineDialog.steps( - return_keepInputDataType=True - ) - txt = "Combining current image..." - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - selected_channel = core.get_selected_channels(steps) - self.getChData(requ_ch=selected_channel) - - z_slice = self.zSliceScrollBar.sliderPosition() - pos_i = self.pos_i - - key = (pos_i, self.data[pos_i].frame_i, z_slice) - - self.combineWorker.setupJob( - self.data, - steps, - keep_input_data_type, - key, - output_as_segm=self.combineDialog.saveAsSegm(), - formula=formula, - ) - - self.combineWorker.wakeUp() - - def combineDialogClosed(self, window): - QTimer.singleShot(200, self._combineDialogClosed) - - def combineDialogSaveCombinedData(self, dialog): - # here check if all data has been processed? - posData = self.data[self.pos_i] - - try: - posData.combinedChannelsDataArray() - except TypeError as e: - if "Not all frames have been processed." in str(e): - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - "Not all frames have been processed.
" - "Please process all frames before saving." - ) - msg.warning(self, "Process all data before saving", txt) - return - - helpText = """ - The segm/img file will be saved with a different - file name.

- Insert a name to append to the end of the new file name. The rest of - the name will be the same as the original file base. - """ - hintText = "Insert a name for the combined channels file:" - basename = posData.basename - if self.combineDialog.saveAsSegm(): - ext = ".npz" - hintText = hintText.replace("channels", "segmentation") - helpText = helpText.replace("channels", "segmentation") - basename = f"{basename}segm" - else: - ext = ".tif" - - win = apps.filenameDialog( - basename=basename, - ext=ext, - hintText=hintText, - defaultEntry="combined", - helpText=helpText, - allowEmpty=False, - parent=dialog, - ) - win.exec_() - if win.cancel: - return - - appendedText = win.entryText - if appendedText: - filename = f"{basename}_{appendedText}{ext}" - else: - filename = f"{basename}{ext}" - - self.progressWin = apps.QDialogWorkerProgress( - title="Saving combined channels(s)", - parent=self, - pbarDesc="Saving combined channels(s)", - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.statusBarLabel.setText("Saving combined channels...") - - self.saveCombinedChannelsWorker = workers.SaveCombinedChannelsWorker( - self.data, - filename, - ) - - self.saveCombinedChannelsThread = QThread() - self.saveCombinedChannelsWorker.moveToThread(self.saveCombinedChannelsThread) - self.saveCombinedChannelsWorker.signals.finished.connect( - self.saveCombinedChannelsThread.quit - ) - self.saveCombinedChannelsWorker.signals.finished.connect( - self.saveCombinedChannelsWorker.deleteLater - ) - self.saveCombinedChannelsThread.finished.connect( - self.saveCombinedChannelsThread.deleteLater - ) - - self.saveCombinedChannelsWorker.signals.critical.connect(self.workerCritical) - self.saveCombinedChannelsWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.saveCombinedChannelsWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.saveCombinedChannelsWorker.signals.progress.connect(self.workerProgress) - self.saveCombinedChannelsWorker.signals.finished.connect( - self.saveCombinedChannelsWorkerFinished - ) - - self.saveCombinedChannelsThread.started.connect( - self.saveCombinedChannelsWorker.run - ) - - self.saveCombinedChannelsWorker.sigDebugShowImg.connect(self.debugShowImg) - - self.saveCombinedChannelsThread.start() - - def combineDialogStepsChanged(self): - steps, keep_input_data_type, formula = self.combineDialog.steps( - return_keepInputDataType=True - ) - if steps is None: - self.logger.warning("Combine channels recipe not initialized yet.") - return - - self.updateCombineChannelsPreview( - steps=steps, keep_input_data_type=keep_input_data_type, formula=formula - ) - - def combineEnqueueCurrentImage(self, steps, keep_input_data_type, formula): - posData = self.data[self.pos_i] - - if posData.SizeZ > 1: - z_slice = self.z_slice_index() - else: - z_slice = 0 - - key = (self.pos_i, posData.frame_i, z_slice) - self.combineWorker.enqueue( - self.data, - steps, - key, - keep_input_data_type, - output_as_segm=self.combineDialog.saveAsSegm(), - formula=formula, - ) - - def combinePreviewToggled(self, checked): - self.viewCombineChannelDataToggle.setChecked(checked) - self.updateCombineChannelsPreview() - - def combinePreviewViewAsSegmToggled(self, checked): - self.updateCombineChannelsPreview() - self.combineViewAsSegmSetup() - - def combineViewAsSegmSetup(self): - if self.combineDialog is None: - return - combineViewAsSegm = self.combineDialog.saveAsSegm() - if not combineViewAsSegm: - self.img1.setUseCombined(True) - if self.combineSegmViewToggle.isChecked(): - self.combineSegmViewToggle.setChecked(False) - self.combineSegmViewToggle.setCheckable(False) - - if not self.overlayLabelsButton.isChecked() and combineViewAsSegm: - self.overlayLabelsButton.blockSignals(True) - self.overlayLabelsButton.setChecked(True) - self.overlayLabels_cb( - checked=True, selectedLabelsEndnames=["combined segm."] - ) - self.overlayLabelsButton.blockSignals(False) - - if combineViewAsSegm: - if not self.combineSegmViewToggle.isChecked(): - self.combineSegmViewToggle.setCheckable(True) - - # reset view to update the overlay labels - self.combineSegmViewToggle.setChecked(False) - self.combineSegmViewToggle.setChecked(True) - - self.img1.setUseCombined(False) - self.setImageImg1() - - def combineWorkerAskLoadChannels(self, requ_channels, pos_i): - # spit channels and segm to load - segms_to_load, channels_to_load, current_segm = ( - myutils.separate_fluo_segment_channels(requ_channels) - ) - if pos_i is None: - pos_i = list(range(len(self.data))) - elif not isinstance(pos_i, list): - pos_i = [pos_i] - - for i in pos_i: - if channels_to_load: - self.getChData(requ_ch=channels_to_load, pos_i=i) - for segm in segms_to_load: - self.loadOverlayLabelsData(segm, pos_i=i) - self.combineWorker.wake_waitCondLoadFluoChannels() - - def combineWorkerClosed(self, worker): - self.logger.info("Combine worker stopped.") - - def combineWorkerCritical(self, error): - self.combineDialog.appliedFinished() - self.workerCritical(error) - - def combineWorkerDone( - self, processed_data: List[np.ndarray], keys: List[Tuple[int, int, int]] - ): - self.setStatusBarLabel(log=False) - self.combineDialog.appliedFinished() - - unique_pos = {key[0] for key in keys} - per_pos_data = {pos_i: [] for pos_i in unique_pos} - - for key, img in zip(keys, processed_data): - pos_i, frame_i, z_slice = key - per_pos_data[pos_i].append((key, img)) - - for pos_i in unique_pos: - posData = self.data[pos_i] - if not hasattr(posData, "combine_img_data"): - posData.combine_img_data = preprocess.PreprocessedData( - image_data=np.zeros(posData.img_data.shape) - ) - - n_dim_img = posData.img_data.ndim - - if n_dim_img == 4: - for key, processed_data in per_pos_data[pos_i]: - pos_i, frame_i, z_slice = key - posData.combine_img_data[frame_i][z_slice] = processed_data - if not self.combineDialog.saveAsSegm(): - self.img1.updateMinMaxValuesCombinedData( - self.data, pos_i, frame_i, z_slice - ) - if not self.combineDialog.saveAsSegm(): - self.img1.updateMinMaxValuesCombinedDataProjections( - self.data, pos_i, frame_i - ) - else: - for key, processed_data in per_pos_data[pos_i]: - pos_i, frame_i, z_slice = key - posData.combine_img_data[frame_i] = processed_data - if not self.combineDialog.saveAsSegm(): - self.img1.updateMinMaxValuesCombinedData( - self.data, pos_i, frame_i, z_slice - ) - - if not self.viewCombineChannelDataToggle.isChecked(): - self.viewCombineChannelDataToggle.setChecked(True) - else: - self.setImageImg1() - - def combineWorkerIsQueueEmpty(self, isEmpty: bool): - if isEmpty: - self.combineDialog.appliedFinished() - else: - self.combineDialog.setDisabled(True) - self.combineDialog.infoLabel.setText( - "Computing preview...
" - "(Feel free to use Cell-ACDC while waiting)" - ) - - def combineWorkerPreviewDone( - self, processed_data: List[np.ndarray], keys: List[Tuple[int, int, int]] - ): - unique_pos = {key[0] for key in keys} - per_pos_data = {pos_i: [] for pos_i in unique_pos} - - for key, img in zip(keys, processed_data): - pos_i, frame_i, z_slice = key - per_pos_data[pos_i].append((key, img)) - - for pos_i in unique_pos: - posData = self.data[pos_i] - if not hasattr(posData, "combine_img_data"): - posData.combine_img_data = preprocess.PreprocessedData( - image_data=np.zeros(posData.img_data.shape) - ) - - n_dim_img = posData.img_data.ndim - - if n_dim_img == 4: - for key, processed_data in per_pos_data[pos_i]: - pos_i, frame_i, z_slice = key - posData.combine_img_data[frame_i][z_slice] = processed_data - if not self.combineDialog.saveAsSegm(): - self.img1.updateMinMaxValuesCombinedData( - self.data, pos_i, frame_i, z_slice - ) - self.img1.updateMinMaxValuesCombinedDataProjections( - self.data, pos_i, frame_i - ) - elif n_dim_img == 3: - for key, processed_data in per_pos_data[pos_i]: - pos_i, frame_i, z_slice = key - posData.combine_img_data[frame_i] = processed_data - if not self.combineDialog.saveAsSegm(): - self.img1.updateMinMaxValuesCombinedData( - self.data, pos_i, frame_i, z_slice - ) - else: - raise ValueError("Invalid number of dimensions in img_data.") - - posData = self.data[self.pos_i] - curr_pos_i, curr_frame_i, curr_z_slice = ( - self.pos_i, - self.data[self.pos_i].frame_i, - self.z_slice_index(), - ) - if not self.combineDialog.saveAsSegm(): - self.img1.updateMinMaxValuesCombinedData( - self.data, curr_pos_i, curr_frame_i, curr_z_slice - ) - - self.combineViewAsSegmSetup() - - def combineZStack( - self, - steps: List[Dict[str, Any]] = None, - keep_input_data_type: bool = None, - formula: str = None, - ): - if self.combineDialog is not None: - keep_input_data_type = ( - self.combineDialog.keepInputDataTypeToggle.isChecked() - ) - - if steps and keep_input_data_type is None: - raise ValueError("keep_input_data_type must be set if steps is set") - - if steps is None: - steps, keep_input_data_type, formula = self.combineDialog.steps( - return_keepInputDataType=True - ) - txt = "Combining z-stack..." - self.statusBarLabel.setText(txt) - self.logger.info(txt) - - selected_channel = core.get_selected_channels(steps) - self.getChData(requ_ch=selected_channel) - - posData = self.data[self.pos_i] - key = (self.pos_i, posData.frame_i, None) - self.combineWorker.setupJob( - self.data, - steps, - keep_input_data_type, - key, - output_as_segm=self.combineDialog.saveAsSegm(), - formula=formula, - ) - - self.combineWorker.wakeUp() - - def group_processed_data_by_pos( - self, processed_data: list[np.ndarray], keys: list[tuple[int, int, int]] - ) -> dict[int, list[tuple[tuple[int, int, int], np.ndarray]]]: - """Groups raw processed preview output arrays by position index.""" - unique_pos = {key[0] for key in keys} - per_pos_data = {pos_i: [] for pos_i in unique_pos} - for key, img in zip(keys, processed_data): - pos_i, frame_i, z_slice = key - per_pos_data[pos_i].append((key, img)) - return per_pos_data - - def initialize_combine_image_data(self, pos_data) -> np.ndarray: - """Initializes pos_data.combine_img_data if not already present.""" - if not hasattr(pos_data, "combine_img_data"): - from cellacdc import preprocess - - pos_data.combine_img_data = preprocess.PreprocessedData( - image_data=np.zeros(pos_data.img_data.shape) - ) - return pos_data.combine_img_data - - def saveCombineWorkerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.setStatusBarLabel() - self.logger.info("Combined channels saved!") - self.titleLabel.setText("Combined channels saved!", color="w") - - def saveCombinedChannelsWorkerFinished(self): - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - self.setStatusBarLabel() - self.logger.info("Combined channels data saved!") - self.titleLabel.setText("Combined channels data saved!", color="w") - - def setupCombiningChannels(self): - posData = self.data[self.pos_i] - if self.combineDialog is not None: - self.combineDialog.close() - - ordered_channels = [ch for ch in posData.chNames if ch != self.user_ch_name] - ordered_channels = natsorted(ordered_channels) - ordered_channels = [self.user_ch_name] + ordered_channels - - segmentations = [segm for segm in self.existingSegmEndNames] - segmentations = natsorted(segmentations) - segmentations = ["current segm."] + segmentations - # also add segm - ordered_channels.extend(segmentations) - - self.combineDialog = apps.CombineChannelsSetupDialogGUI( - ordered_channels, - isTimelapse=posData.SizeT > 1, - isZstack=posData.SizeZ > 1, - isMultiPos=len(self.data) > 1, - df_metadata=posData.metadata_df, - hideOnClosing=True, - # addApplyButton=True, - parent=self, - ) - self.doPreviewPreprocImage = False # to do - self.combineDialog.sigApplyImage.connect(self.combineCurrentImage) - self.combineDialog.sigApplyZstack.connect(self.combineZStack) - self.combineDialog.sigApplyAllFrames.connect(self.combineAllFrames) - self.combineDialog.sigApplyAllPos.connect(self.combineAllPos) - self.combineDialog.sigPreviewToggled.connect(self.combinePreviewToggled) - self.combineDialog.sigSaveAsSegmCheckboxToggled.connect( - self.combinePreviewViewAsSegmToggled - ) - self.combineDialog.sigValuesChanged.connect(self.combineDialogStepsChanged) - self.combineDialog.sigSavePreprocData.connect( - self.combineDialogSaveCombinedData - ) - self.combineDialog.sigClose.connect(self.combineDialogClosed) - - if self.combineWorker is not None: - return - - self.combineThread = QThread() - self.combineMutex = QMutex() - self.combineWaitCond = QWaitCondition() - - self.combineWorker = workers.CombineChannelsWorkerGUI( - self.combineMutex, - self.combineWaitCond, - logger_func=self.logger.info, - # signals=self.signals # what are the singals for gui??? - ) - - self.combineWorker.moveToThread(self.combineThread) - self.combineWorker.signals.finished.connect(self.combineThread.quit) - self.combineWorker.signals.finished.connect(self.combineWorker.deleteLater) - self.combineThread.finished.connect(self.combineWorker.deleteLater) - - self.combineWorker.sigDone.connect(self.combineWorkerDone) - self.combineWorker.sigIsQueueEmpty.connect(self.combineWorkerIsQueueEmpty) - self.combineWorker.sigPreviewDone.connect(self.combineWorkerPreviewDone) - self.combineWorker.signals.progress.connect(self.workerProgress) - self.combineWorker.signals.critical.connect(self.workerCritical) - self.combineWorker.signals.finished.connect(self.combineWorkerClosed) - - self.combineWorker.sigAskLoadChannels.connect(self.combineWorkerAskLoadChannels) - - self.combineThread.started.connect(self.combineWorker.run) - self.combineThread.start() - - self.logger.info("Combine channels worker started.") - - def stopCombineWorker(self): - self.logger.info("Closing combine worker...") - try: - self.combineWorker.stop() - except Exception: - pass - - def updateCombineChannelsPreview(self, *args, **kwargs): - force = kwargs.get("force", False) - - if self.combineDialog is None: - return - - if not self.combineDialog.isVisible() and not force: - return - - if not self.combineDialog.previewCheckbox.isChecked() and not force: - return - - if kwargs.get("steps") is None: - steps, keep_input_data_type, formula = self.combineDialog.steps( - return_keepInputDataType=True - ) - else: - steps = kwargs.get("steps") - keep_input_data_type = kwargs.get("keep_input_data_type") - formula = kwargs.get("formula") - - if steps is None: - self.logger.warning("Combine channels recipe not initialized yet.") - return - - txt = "Combining..." - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - self.combineEnqueueCurrentImage(steps, keep_input_data_type, formula) - - def update_combine_image_data( - self, pos_data, pos_i_data: list[tuple[tuple[int, int, int], np.ndarray]] - ): - """Updates preprocessed combined image data container frames and z-slices.""" - n_dim_img = pos_data.img_data.ndim - self.initialize_combine_image_data(pos_data) - self.validate_dimensions(n_dim_img) - - if n_dim_img == 4: - for key, img in pos_i_data: - _, frame_i, z_slice = key - pos_data.combine_img_data[frame_i][z_slice] = img - elif n_dim_img == 3: - for key, img in pos_i_data: - _, frame_i, _ = key - pos_data.combine_img_data[frame_i] = img - - def validate_dimensions(self, ndim: int) -> bool: - """Asserts that image data dimensions are valid for combining (3D or 4D).""" - if ndim not in (3, 4): - raise ValueError("Invalid number of dimensions in img_data.") - return True - - def viewCombineChannelDataToggled(self, checked): - self.img1.setUseCombined(checked) - - if checked: - self.combineViewAsSegmSetup() - else: # setimage1 is already called in combineViewAsSegmSetup - self.setImageImg1() - - if self.viewPreprocDataToggle.isChecked(): - self.viewPreprocDataToggle.toggled.disconnect() - self.viewPreprocDataToggle.setChecked(False) - self.viewPreprocDataToggle.toggled.connect(self.viewPreprocDataToggled) diff --git a/cellacdc/mixins_bak/display_decorations.py b/cellacdc/mixins_bak/display_decorations.py deleted file mode 100644 index 604b6d977..000000000 --- a/cellacdc/mixins_bak/display_decorations.py +++ /dev/null @@ -1,209 +0,0 @@ -"""View adapter for timestamp, scale-bar, and view-range decorations.""" - -from __future__ import annotations - -import numpy as np - -from cellacdc import apps, widgets - - -class DisplayDecorationsMixin: - """Qt-facing adapter around display-decoration contracts.""" - - """Headless display-decoration decision rules.""" - - def _ax1_raw_view_range(self): - if self.exportToImageWindow is None: - return self.ax1.viewRange() - export_mask = np.all(self.exportMaskImage == [0, 0, 0, 0], axis=-1) - if np.all(export_mask): - return self.ax1.viewRange() - return self.ax1.viewRange(export_mask) - - def add_scale_bar(self, checked): - if checked: - pos_data = self.data[self.pos_i] - y_size, x_size = self.img1.image.shape[:2] - view_range = self.ax1_view_range() - self.scaleBarDialog = apps.ScaleBarPropertiesDialog( - x_size, - y_size, - pos_data.PhysicalSizeX, - parent=self, - ) - self.scaleBarDialog.show() - self.scaleBar = widgets.ScaleBar( - (y_size, x_size), view_range, parent=self.ax1 - ) - self.scaleBar.sigEditProperties.connect(self.edit_scale_bar_properties) - self.scaleBar.sigRemove.connect(self.edit_scale_bar_remove) - self.scaleBar.addToAxis(self.ax1) - self.scaleBar.draw(**self.scaleBarDialog.kwargs()) - self.scaleBarDialog.sigValueChanged.connect(self.update_scale_bar) - self.scaleBarDialog.exec_() - if self.scaleBarDialog.cancel: - self.addScaleBarAction.setChecked(False) - return - else: - self.scaleBar.removeFromAxis(self.ax1) - - self.scaleBarDialog = None - self.imgGrad.addScaleBarAction.setChecked(checked) - - def add_timestamp(self, checked): - if checked: - pos_data = self.data[self.pos_i] - y_size, x_size = self.img1.image.shape[:2] - view_range = self.ax1_view_range() - self.timestampDialog = apps.TimestampPropertiesDialog(parent=self) - self.timestampDialog.show() - self.timestamp = widgets.TimestampItem( - y_size, - x_size, - view_range, - secondsPerFrame=pos_data.TimeIncrement, - start_timedelta=self.timestampStartTimedelta, - ) - self.timestamp.sigEditProperties.connect(self.edit_timestamp_properties) - self.timestamp.sigRemove.connect(self.edit_timestamp_remove) - self.timestamp.addToAxis(self.ax1) - self.timestamp.draw(pos_data.frame_i, **self.timestampDialog.kwargs()) - self.timestampDialog.sigValueChanged.connect(self.update_timestamp) - self.timestampDialog.exec_() - else: - self.timestamp.removeFromAxis(self.ax1) - - self.timestampDialog = None - self.imgGrad.addTimestampAction.setChecked(checked) - - def ax1_view_range(self, integers=False): - view_range = self._ax1_raw_view_range() - if not integers: - return view_range - return self.integer_view_range(view_range) - - def clamped_view_range(self, image_shape, view_range): - y_size, x_size = image_shape[:2] - x_range, y_range = view_range - x_min = 0 if x_range[0] < 0 else x_range[0] - y_min = 0 if y_range[0] < 0 else y_range[0] - x_max = x_size if x_range[1] >= x_size else x_range[1] - y_max = y_size if y_range[1] >= y_size else y_range[1] - return int(y_min), int(y_max), int(x_min), int(x_max) - - def edit_scale_bar_properties(self, properties): - y_size, x_size = self.img1.image.shape[:2] - pos_data = self.data[self.pos_i] - self.scaleBarDialog = apps.ScaleBarPropertiesDialog( - x_size, - y_size, - pos_data.PhysicalSizeX, - parent=self, - **properties, - ) - self.scaleBarDialog.sigValueChanged.connect(self.update_scale_bar) - self.scaleBarDialog.exec_() - - def edit_scale_bar_remove(self, timestamp): - self.addScaleBarAction.setChecked(False) - - def edit_timestamp_properties(self, properties): - self.timestampDialog = apps.TimestampPropertiesDialog(parent=self, **properties) - self.timestampDialog.sigValueChanged.connect(self.update_timestamp) - self.timestampDialog.show() - - def edit_timestamp_remove(self, timestamp): - self.addTimestampAction.setChecked(False) - - def get_view_range(self): - return self.clamped_view_range( - self.img1.image.shape, - self.ax1.viewRange(), - ) - - def integer_view_range(self, view_range): - x_range, y_range = view_range - return ( - [round(x_range[0]), round(x_range[1])], - [round(y_range[0]), round(y_range[1])], - ) - - def should_move_decoration( - self, - *, - dialog_open: bool, - move_with_zoom: bool, - ) -> bool: - return dialog_open or move_with_zoom - - def should_store_view_range( - self, - *, - has_range_reset_state: bool, - is_range_reset: bool = False, - ) -> bool: - return has_range_reset_state and is_range_reset - - def should_update_timestamp_frame( - self, - *, - has_timestamp: bool, - timestamp_enabled: bool, - ) -> bool: - return has_timestamp and timestamp_enabled - - def store_view_range(self): - if not self.should_store_view_range( - has_range_reset_state=hasattr(self, "isRangeReset"), - is_range_reset=getattr(self, "isRangeReset", False), - ): - return - self.ax1_viewRange = self.ax1.viewRange() - self.isRangeReset = False - - def update_scale_bar(self, scale_bar_kwargs): - self.scaleBar.draw(**scale_bar_kwargs) - - def update_timestamp(self, timestamp_kwargs): - pos_data = self.data[self.pos_i] - self.timestamp.draw(pos_data.frame_i, **timestamp_kwargs) - - def update_timestamp_frame(self): - if not self.should_update_timestamp_frame( - has_timestamp=hasattr(self, "timestamp"), - timestamp_enabled=self.addTimestampAction.isChecked(), - ): - return - - pos_data = self.data[self.pos_i] - self.timestamp.setText(pos_data.frame_i) - - def view_range_changed( - self, - view_box, - view_range, - updateExportImageMask=True, - ): - self.status_hover_view.update_values_status_bar() - - if hasattr(self, "scaleBar"): - scale_bar_move_with_zoom = self.scaleBar.properties()["move_with_zoom"] - else: - scale_bar_move_with_zoom = False - if self.should_move_decoration( - dialog_open=self.scaleBarDialog is not None, - move_with_zoom=scale_bar_move_with_zoom, - ): - self.scaleBar.updatePosViewRangeChanged(view_range) - - if hasattr(self, "timestamp"): - timestamp_move_with_zoom = self.timestamp.properties()["move_with_zoom"] - else: - timestamp_move_with_zoom = False - if self.should_move_decoration( - dialog_open=self.timestampDialog is not None, - move_with_zoom=timestamp_move_with_zoom, - ): - self.timestamp.updatePosViewRangeChanged(view_range) - - self._viewRange = view_range diff --git a/cellacdc/mixins_bak/draw_clear_region.py b/cellacdc/mixins_bak/draw_clear_region.py deleted file mode 100644 index 4d98312a2..000000000 --- a/cellacdc/mixins_bak/draw_clear_region.py +++ /dev/null @@ -1,119 +0,0 @@ -"""View adapter for draw-clear-region workflows.""" - -from __future__ import annotations - - -class DrawClearRegionMixin: - """Qt-facing adapter around the scriptable draw-clear view-model.""" - - """Headless draw-clear-region decision rules.""" - - single_z_slice_projection = "single z-slice" - - def _z_range(self, size_z): - z_projection = None - single_z_range = None - if self.isSegm3D: - z_projection = self.zProjComboBox.currentText() - if self.is_single_z_projection(z_projection): - single_z_range = self.drawClearRegionToolbar.zRange( - self.z_lab(), size_z - ) - return self.z_range_for_projection( - is_segm_3d=self.isSegm3D, - z_projection=z_projection, - size_z=size_z, - single_z_range=single_z_range, - ) - - def clear_objects_in_freehand_region(self): - self.logger.info("Clearing objects inside freehand region...") - self.storeUndoRedoStates(False, storeImage=False, storeOnlyZoom=True) - - pos_data = self.data[self.pos_i] - z_range = self._z_range(pos_data.SizeZ) - region_slice = self.freeRoiItem.slice(zRange=z_range) - mask = self.freeRoiItem.mask() - region_lab = pos_data.lab[(...,) + region_slice].copy() - - enclosed_only = ( - self.drawClearRegionToolbar.clearOnlyEnclosedObjsRadioButton.isChecked() - ) - selection_result = self.view_model.label_edits.select_labels_in_region( - region_lab, - mask, - enclosed_only=enclosed_only, - ) - clear_ids = selection_result.selected_ids - - if not clear_ids: - self.logger.warning( - self.empty_selection_warning(enclosed_only=enclosed_only) - ) - return - - self.deleteIDmiddleClick(clear_ids, False, False) - self.update_cca_df_deletedIDs(pos_data, clear_ids) - self.freeRoiItem.clear() - self.updateAllImages() - - def empty_selection_warning(self, *, enclosed_only: bool) -> str: - if enclosed_only: - return "None of the objects in the freehand region are fully enclosed" - return "None of the objects are touching the freehand region" - - def is_single_z_projection(self, z_projection: str) -> bool: - return z_projection == self.single_z_slice_projection - - def toggle(self, checked): - pos_data = self.data[self.pos_i] - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.drawClearRegionButton) - self.connectLeftClickButtons() - - self.drawClearRegionToolbar.setVisible(checked) - state = self.toolbar_state( - checked=checked, - is_segm_3d=self.isSegm3D, - size_z=pos_data.SizeZ, - ) - if not state.update_z_control: - return - if state.z_control_enabled: - self.drawClearRegionToolbar.setZslicesControlEnabled( - True, SizeZ=state.size_z - ) - return - self.drawClearRegionToolbar.setZslicesControlEnabled(False) - - def toolbar_state( - self, - *, - checked: bool, - is_segm_3d: bool, - size_z: int, - ) -> DrawClearRegionToolbarState: - if not is_segm_3d: - return DrawClearRegionToolbarState(update_z_control=True) - if not checked: - return DrawClearRegionToolbarState(update_z_control=False) - return DrawClearRegionToolbarState( - update_z_control=True, - z_control_enabled=True, - size_z=size_z, - ) - - def z_range_for_projection( - self, - *, - is_segm_3d: bool, - z_projection: str, - size_z: int, - single_z_range, - ): - if not is_segm_3d: - return None - if z_projection == self.single_z_slice_projection: - return single_z_range - return (0, size_z) diff --git a/cellacdc/mixins_bak/edit_id.py b/cellacdc/mixins_bak/edit_id.py deleted file mode 100644 index 47deb7a8d..000000000 --- a/cellacdc/mixins_bak/edit_id.py +++ /dev/null @@ -1,81 +0,0 @@ -"""View-model commands for manual edit-ID operations.""" - -from __future__ import annotations - -import numpy as np -import pandas as pd - -from cellacdc.domain.edit_id import ( - ManualEditTrackingResult, - add_yx_centroids_to_df, - apply_manual_edit_tracking, - edit_id_info_from_df, - manual_edit_conflicts, - project_centroid, -) - - -class EditIdMixin: - """Application-facing commands for manual ID edit metadata.""" - - def add_yx_centroids_to_df( - self, - df: pd.DataFrame, - regionprops, - *, - is_3d: bool = False, - depth_axis: str = "z", - ) -> pd.DataFrame: - return add_yx_centroids_to_df( - df, - regionprops, - is_3d=is_3d, - depth_axis=depth_axis, - ) - - def apply_manual_edit_tracking( - self, - tracked_labels: np.ndarray, - edit_id_info, - all_ids, - ) -> ManualEditTrackingResult: - return apply_manual_edit_tracking( - tracked_labels, - edit_id_info, - all_ids, - ) - - def edit_id_info_from_df( - self, - df: pd.DataFrame, - regionprops=None, - *, - is_3d: bool = False, - depth_axis: str = "z", - ) -> list[tuple[int, int, int]]: - return edit_id_info_from_df( - df, - regionprops, - is_3d=is_3d, - depth_axis=depth_axis, - ) - - def manual_edit_conflicts( - self, - labels: np.ndarray, - edit_id_info, - ) -> dict[int, int]: - return manual_edit_conflicts(labels, edit_id_info) - - def project_centroid( - self, - centroid, - *, - is_3d: bool = False, - depth_axis: str = "z", - ) -> tuple[float, float]: - return project_centroid( - centroid, - is_3d=is_3d, - depth_axis=depth_axis, - ) diff --git a/cellacdc/mixins_bak/formatting.py b/cellacdc/mixins_bak/formatting.py deleted file mode 100644 index e78999538..000000000 --- a/cellacdc/mixins_bak/formatting.py +++ /dev/null @@ -1,57 +0,0 @@ -"""View-model commands for UI-neutral formatting helpers.""" - -from __future__ import annotations - -from cellacdc.domain.display_images import distant_gray, rgb_to_gray -from cellacdc.myutils import ( - _bytes_to_GB, - get_chname_from_basename, - get_number_fstring_formatter, - get_salute_string, - seconds_to_ETA, -) - - -class FormattingMixin: - """Application-facing commands for display string formatting.""" - - def bytes_to_gb(self, size_bytes): - return _bytes_to_GB(size_bytes) - - def channel_name_from_basename( - self, - filename, - basename, - *, - remove_ext=True, - ): - return get_chname_from_basename( - filename, - basename, - remove_ext=remove_ext, - ) - - def distant_gray( - self, - desired_gray, - background_gray, - *, - threshold=0.3, - ): - return distant_gray( - desired_gray, - background_gray, - threshold=threshold, - ) - - def number_fstring_formatter(self, dtype, *, precision=4): - return get_number_fstring_formatter(dtype, precision=precision) - - def rgb_to_gray(self, red, green, blue): - return rgb_to_gray(red, green, blue) - - def salute_string(self): - return get_salute_string() - - def seconds_to_eta(self, seconds): - return seconds_to_ETA(seconds) diff --git a/cellacdc/mixins_bak/frame_metadata.py b/cellacdc/mixins_bak/frame_metadata.py deleted file mode 100644 index f5794c931..000000000 --- a/cellacdc/mixins_bak/frame_metadata.py +++ /dev/null @@ -1,52 +0,0 @@ -"""View-model commands for ACDC frame metadata.""" - -from __future__ import annotations - -import pandas as pd - -from cellacdc.domain.frame_metadata import ( - AcdcFrameMetadataResult, - build_acdc_frame_metadata, - concat_visited_acdc_frames, -) -from cellacdc.myutils import get_empty_stored_data_dict - - -class FrameMetadataMixin: - """Application-facing commands for per-frame ACDC metadata tables.""" - - def build_acdc_frame_metadata( - self, - regionprops, - *, - edit_id_info=(), - existing_df: pd.DataFrame | None = None, - is_3d: bool = False, - depth_axis: str = "z", - ) -> AcdcFrameMetadataResult: - return build_acdc_frame_metadata( - regionprops, - edit_id_info=edit_id_info, - existing_df=existing_df, - is_3d=is_3d, - depth_axis=depth_axis, - ) - - def concat_visited_acdc_frames( - self, - frame_records, - *, - labels_key: str = "labels", - acdc_key: str = "acdc_df", - ) -> pd.DataFrame | None: - return concat_visited_acdc_frames( - frame_records, - labels_key=labels_key, - acdc_key=acdc_key, - ) - - def empty_frame_record(self): - return get_empty_stored_data_dict() - - def empty_frame_records(self, count: int): - return [self.empty_frame_record() for _ in range(count)] diff --git a/cellacdc/mixins_bak/geometry.py b/cellacdc/mixins_bak/geometry.py deleted file mode 100644 index c88aec5d8..000000000 --- a/cellacdc/mixins_bak/geometry.py +++ /dev/null @@ -1,170 +0,0 @@ -"""View-model commands for geometric interaction helpers.""" - -from __future__ import annotations - -from cellacdc.core import get_line, get_obj_contours -from cellacdc.core._legacy import _compute_all_obj_to_obj_contour_dist_pairs -from cellacdc.myutils import get_slices_local_into_global_arr, is_in_bounds -from cellacdc.transformation import crop_2D, snap_xy_to_closest_angle - - -class GeometryMixin: - """Application-facing commands for geometric interaction transforms.""" - - def crop_2d( - self, - image, - xy_range, - *, - tolerance=0, - return_copy=True, - ): - return crop_2D( - image, - xy_range, - tolerance=tolerance, - return_copy=return_copy, - ) - - def is_configured_middle_click( - self, - *, - mouse_button, - configured_button, - key_sequence_is_none: bool, - tool_is_checked: bool, - ) -> bool: - if key_sequence_is_none: - is_del_object_active = True - else: - is_del_object_active = tool_is_checked - return mouse_button == configured_button and is_del_object_active - - def is_default_middle_click( - self, - *, - mouse_button, - modifiers, - is_mac: bool, - brush_is_checked: bool, - left_button, - middle_button, - control_modifier, - ) -> bool: - if is_mac: - return ( - mouse_button == left_button - and modifiers == control_modifier - and not brush_is_checked - ) - return mouse_button == middle_button - - def is_in_bounds(self, x, y, width, height): - return is_in_bounds(x, y, width, height) - - def is_pan_image_click( - self, - *, - mouse_button, - left_button, - modifiers, - alt_modifier, - ) -> bool: - return modifiers == alt_modifier and mouse_button == left_button - - def line_coords(self, y1, x1, y2, x2, *, dashed=True): - return get_line(y1, x1, y2, x2, dashed=dashed) - - def local_to_global_slices(self, bbox_coords, global_shape): - return get_slices_local_into_global_arr(bbox_coords, global_shape) - - def middle_click_text( - self, - *, - has_del_object_action: bool, - is_mac: bool, - button_name: str | None = None, - key_sequence_text: str | None = None, - ) -> str: - if not has_del_object_action and is_mac: - return "Command + Left Click" - if not has_del_object_action: - return "Middle Click" - if key_sequence_text is None: - return button_name - return f"{key_sequence_text} + {button_name}" - - def object_contours( - self, - *, - obj=None, - obj_image=None, - obj_bbox=None, - all_external=False, - all=False, - only_longest_contour=True, - local=False, - ): - return get_obj_contours( - obj=obj, - obj_image=obj_image, - obj_bbox=obj_bbox, - all_external=all_external, - all=all, - only_longest_contour=only_longest_contour, - local=local, - ) - - def object_to_object_contour_distance_matrix( - self, - all_contours, - regionprops, - *, - previous_regionprops=None, - restrict_search=True, - ): - return _compute_all_obj_to_obj_contour_dist_pairs( - all_contours, - regionprops, - prev_rp=previous_regionprops, - restrict_search=restrict_search, - ) - - def should_auto_activate_viewer( - self, - *, - is_data_loaded: bool, - windows_overlap: bool, - disable_auto_activate: bool, - ) -> bool: - return is_data_loaded and not windows_overlap and not disable_auto_activate - - def snap_xy_to_closest_angle( - self, - x0, - y0, - x1, - y1, - angle_factor=15, - ): - return snap_xy_to_closest_angle( - x0, - y0, - x1, - y1, - angle_factor=angle_factor, - ) - - def windows_overlap_from_bounds( - self, - *, - main_left, - main_top, - main_width, - main_height, - other_left, - other_top, - ) -> bool: - main_right = main_left + main_width - main_bottom = main_top + main_height - return (other_top < main_bottom) and (other_left < main_right) diff --git a/cellacdc/mixins_bak/label_edits.py b/cellacdc/mixins_bak/label_edits.py deleted file mode 100644 index a43aa82d4..000000000 --- a/cellacdc/mixins_bak/label_edits.py +++ /dev/null @@ -1,339 +0,0 @@ -"""View-model commands for label image edits.""" - -from __future__ import annotations - -import numpy as np - -from cellacdc.core import ( - binary_fill_holes as core_binary_fill_holes, - convex_hull_mask as core_convex_hull_mask, - count_objects as core_count_objects, - nearest_nonzero_2D, - nearest_nonzero_z_idx_from_z_centroid, - split_connected_components as core_split_connected_components, - split_along_convexity_defects, -) -from cellacdc.domain.labels import ( - DeletedRoiApplyResult, - DeletedRoiRestoreResult, - LabelBorderClearResult, - LabelHoleFillResult, - LabelIdMappingResult, - LabelIdsRemovalResult, - LabelMoveResult, - LabelRegionSelectionResult, - LabelResizeResult, - LabelRoiIndexResult, - apply_deleted_roi_masks, - apply_label_id_mapping, - clear_border_labels, - collect_deleted_roi_ids, - fill_label_holes, - index_label_roi, - label_ids_from_labels, - label_ids_in_masks, - line_roi_mask, - move_label_object, - next_available_label_id, - polygon_roi_mask, - rectangle_roi_mask, - remap_id_set, - remove_new_label_ids, - resize_label_object, - restore_deleted_roi_labels, - select_labels_in_region, -) -from cellacdc.measure import separate_with_label -from cellacdc.myutils import get_trimmed_list - - -class LabelEditMixin: - """Application-facing commands for editing label arrays.""" - - def apply_deleted_roi_masks( - self, - labels_2d: np.ndarray, - roi_masks, - deleted_masks, - deleted_ids_by_roi, - ) -> DeletedRoiApplyResult: - return apply_deleted_roi_masks( - labels_2d, - roi_masks, - deleted_masks, - deleted_ids_by_roi, - ) - - def apply_id_mapping( - self, - labels: np.ndarray, - old_new_pairs, - *, - existing_ids=None, - merge_existing: bool = False, - start_max_id: int | None = None, - ) -> LabelIdMappingResult: - return apply_label_id_mapping( - labels, - old_new_pairs, - existing_ids=existing_ids, - merge_existing=merge_existing, - start_max_id=start_max_id, - ) - - def binary_fill_holes(self, mask: np.ndarray, *, slice_by_slice: bool = True): - return core_binary_fill_holes(mask, slice_by_slice=slice_by_slice) - - def clear_border_labels( - self, - labels: np.ndarray, - *, - buffer_size: int = 0, - ) -> LabelBorderClearResult: - return clear_border_labels(labels, buffer_size=buffer_size) - - def collect_deleted_roi_ids(self, deleted_ids_by_roi) -> set[int]: - return collect_deleted_roi_ids(deleted_ids_by_roi) - - def convex_hull_mask(self, mask: np.ndarray, *, slice_by_slice: bool = True): - return core_convex_hull_mask(mask, slice_by_slice=slice_by_slice) - - def count_objects(self, position_data, logger_func): - return core_count_objects(position_data, logger_func) - - def fill_label_holes( - self, - labels_2d: np.ndarray, - label_id: int, - ) -> LabelHoleFillResult: - return fill_label_holes(labels_2d, label_id) - - def format_trimmed_ids(self, ids, *, max_num_digits=10): - return get_trimmed_list(list(ids), max_num_digits=max_num_digits) - - def index_label_roi( - self, - labels: np.ndarray, - roi_labels: np.ndarray, - roi_slice, - brush_id: int, - *, - clear_border: bool = False, - replace_existing: bool = False, - ) -> LabelRoiIndexResult: - return index_label_roi( - labels, - roi_labels, - roi_slice, - brush_id, - clear_border=clear_border, - replace_existing=replace_existing, - ) - - def label_ids_from_labels(self, labels: np.ndarray) -> list[int]: - return label_ids_from_labels(labels) - - def label_ids_in_masks( - self, - labels: np.ndarray, - masks, - *, - additional_labels: np.ndarray | None = None, - ) -> set[int]: - return label_ids_in_masks( - labels, - masks, - additional_labels=additional_labels, - ) - - def line_roi_mask( - self, - shape: tuple[int, ...], - point1, - point2, - *, - z_slice=None, - ) -> np.ndarray: - return line_roi_mask(shape, point1, point2, z_slice=z_slice) - - def move_label_object( - self, - labels: np.ndarray, - object_coords: np.ndarray, - label_id: int, - *, - delta_y: int, - delta_x: int, - shape: tuple[int, int] | None = None, - ) -> LabelMoveResult: - return move_label_object( - labels, - object_coords, - label_id, - delta_y=delta_y, - delta_x=delta_x, - shape=shape, - ) - - def nearest_nonzero_2d( - self, - labels_2d: np.ndarray, - y, - x, - *, - max_dist=None, - return_coords: bool = False, - ): - return nearest_nonzero_2D( - labels_2d, - y, - x, - max_dist=max_dist, - return_coords=return_coords, - ) - - def nearest_nonzero_z_from_centroid(self, obj, *, current_z: int = -1): - return nearest_nonzero_z_idx_from_z_centroid(obj, current_z=current_z) - - def next_available_label_id( - self, - id_groups=(), - *, - manual_edit_info=(), - base_id: int = 0, - ) -> int: - return next_available_label_id( - id_groups, - manual_edit_info=manual_edit_info, - base_id=base_id, - ) - - def polygon_roi_mask( - self, - shape: tuple[int, ...], - points, - *, - z_slice=None, - ) -> np.ndarray: - return polygon_roi_mask(shape, points, z_slice=z_slice) - - def rectangle_roi_mask( - self, - shape: tuple[int, ...], - origin, - size, - *, - z_slice=None, - ) -> np.ndarray: - return rectangle_roi_mask(shape, origin, size, z_slice=z_slice) - - def remap_id_set(self, ids, old_ids, new_ids) -> set[int]: - return remap_id_set(ids, old_ids, new_ids) - - def remove_new_labels( - self, - labels: np.ndarray, - previous_ids, - current_ids, - ) -> LabelIdsRemovalResult: - return remove_new_label_ids(labels, previous_ids, current_ids) - - def resize_label_object( - self, - labels_2d: np.ndarray, - active_labels_2d: np.ndarray, - object_coords: np.ndarray, - label_id: int, - footprint_size: int, - *, - dilation: bool = True, - seed_labels: np.ndarray | None = None, - ) -> LabelResizeResult: - return resize_label_object( - labels_2d, - active_labels_2d, - object_coords, - label_id, - footprint_size, - dilation=dilation, - seed_labels=seed_labels, - ) - - def restore_deleted_roi_labels( - self, - labels_2d: np.ndarray, - display_labels_2d: np.ndarray, - deleted_mask: np.ndarray, - roi_mask: np.ndarray, - deleted_ids, - *, - enforce: bool = True, - ) -> DeletedRoiRestoreResult: - return restore_deleted_roi_labels( - labels_2d, - display_labels_2d, - deleted_mask, - roi_mask, - deleted_ids, - enforce=enforce, - ) - - def select_labels_in_region( - self, - labels: np.ndarray, - mask: np.ndarray, - *, - enclosed_only: bool = False, - ) -> LabelRegionSelectionResult: - return select_labels_in_region( - labels, - mask, - enclosed_only=enclosed_only, - ) - - def separate_with_label( - self, - labels, - regionprops, - ids_to_separate, - max_id: int, - *, - click_coords_list=None, - ): - return separate_with_label( - labels, - regionprops, - ids_to_separate, - max_id, - click_coords_list=click_coords_list, - ) - - def split_along_convexity_defects( - self, - label_id: int, - labels_2d: np.ndarray, - max_id: int, - *, - max_i: int = 1, - eps_percent: float = 0.01, - ): - return split_along_convexity_defects( - label_id, - labels_2d, - max_id, - max_i=max_i, - eps_percent=eps_percent, - ) - - def split_connected_components( - self, - labels: np.ndarray, - *, - regionprops=None, - max_id=None, - ): - return core_split_connected_components( - labels, - rp=regionprops, - max_ID=max_id, - ) diff --git a/cellacdc/mixins_bak/label_transform_tools.py b/cellacdc/mixins_bak/label_transform_tools.py deleted file mode 100644 index 0f16e5c10..000000000 --- a/cellacdc/mixins_bak/label_transform_tools.py +++ /dev/null @@ -1,226 +0,0 @@ -"""View adapter for label transform tools.""" - -from __future__ import annotations - -import skimage.measure - - -class LabelTransformToolsMixin: - """Qt-facing adapter around label transform tool contracts.""" - - """Headless decision rules for label transform tools.""" - - def _set_temp_img_expand_label_contours(self, previous_coords, ax=0): - self.contoursImage[previous_coords] = [0, 0, 0, 0] - current_lab_2d_rp = skimage.measure.regionprops(self.currentLab2D) - for obj in current_lab_2d_rp: - if obj.label == self.expandingID: - self.addObjContourToContoursImage(obj=obj, ax=ax, force=True) - break - - def _set_temp_img_expand_label_segm_masks(self, previous_coords, ax=0): - labels_image = self.getLabelsLayerImage(ax=ax) - labels_image[previous_coords] = 0 - labels_image[previous_coords] = self.expandingID - - if ax == 0: - self.labelsLayerImg1.setImage(self.labelsLayerImg1.image, autoLevels=False) - else: - self.labelsLayerRightImg.setImage( - self.labelsLayerRightImg.image, autoLevels=False - ) - - def expand_label(self, dilation=True): - pos_data = self.data[self.pos_i] - if self.hoverLabelID == 0: - self.isExpandingLabel = False - return - - reinit_expanding_lab = self.should_reinitialize_expansion( - expanding_id=self.expandingID, - hover_label_id=self.hoverLabelID, - dilation=dilation, - is_dilation=self.isDilation, - ) - label_id = self.hoverLabelID - obj = pos_data.rp[pos_data.IDs.index(label_id)] - - if reinit_expanding_lab: - self.storeUndoRedoStates(False) - self.isExpandingLabel = True - self.expandingID = label_id - self.expandingLab = None - self.expandFootprintSize = 1 - - lab_2d = self.get_2Dlab(pos_data.lab) - resize_result = self.view_model.label_edits.resize_label_object( - lab_2d, - self.currentLab2D, - obj.coords, - self.expandingID, - self.expandFootprintSize, - dilation=dilation, - seed_labels=self.expandingLab, - ) - self.expandingLab = resize_result.seed_labels - self.isDilation = dilation - previous_coords = resize_result.previous_coords - expanded_obj_coords = resize_result.resized_coords - - self.set_2Dlab(lab_2d) - self.currentLab2D = lab_2d - self.update_rp() - - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.img2.setImage(img=self.currentLab2D, autoLevels=False) - - self.set_temp_img_expand_label(previous_coords, expanded_obj_coords) - - def expand_label_callback(self, checked): - if checked: - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.expandLabelToolButton) - self.connectLeftClickButtons() - self.expandFootprintSize = 1 - return - - self.clearHighlightedID() - alpha = self.imgGrad.labelsAlphaSlider.value() - self.labelsLayerImg1.setOpacity(alpha) - self.labelsLayerRightImg.setOpacity(alpha) - self.hoverLabelID = 0 - self.expandingID = 0 - self.updateAllImages() - - def move_delta(self, *, previous_pos, current_pos) -> tuple[int, int]: - x_start, y_start = previous_pos - x_current, y_current = current_pos - return x_current - x_start, y_current - y_start - - def move_label(self, x_pos, y_pos): - pos_data = self.data[self.pos_i] - lab_2d = self.get_2Dlab(pos_data.lab) - y_size, x_size = lab_2d.shape - x_data, y_data = int(x_pos), int(y_pos) - if not self.point_in_shape( - x=x_data, - y=y_data, - shape=(y_size, x_size), - ): - return - - self.clearObjContour(ID=self.movingID, ax=0) - delta_x, delta_y = self.move_delta( - previous_pos=self.prevMovePos, - current_pos=(x_data, y_data), - ) - move_result = self.view_model.label_edits.move_label_object( - pos_data.lab, - self.movingObjCoords, - self.movingID, - delta_y=delta_y, - delta_x=delta_x, - shape=(y_size, x_size), - ) - self.movingObjCoords = move_result.moved_coords - self.currentLab2D = self.get_2Dlab(pos_data.lab) - if self.labelsGrad.showLabelsImgAction.isChecked(): - self.img2.setImage(self.currentLab2D, autoLevels=False) - - self.set_temp_img1_move_label() - self.prevMovePos = (x_data, y_data) - - def move_label_button_toggled(self, checked): - if not self.should_clear_move_state(checked=checked): - return - self.hoverLabelID = 0 - self.highlightedID = 0 - self.highLightIDLayerImg1.clear() - self.highLightIDLayerRightImage.clear() - self.setHighlightID(False) - - def point_in_shape(self, *, x: int, y: int, shape) -> bool: - y_size, x_size = shape - return x >= 0 and y >= 0 and x < x_size and y < y_size - - def reset_expand_label(self): - self.expandingID = self.reset_expand_label_id() - - def reset_expand_label_id(self) -> int: - return -1 - - def set_temp_img1_move_label(self, ax=0): - if ax == 0: - how = self.drawIDsContComboBox.currentText() - else: - how = self.getAnnotateHowRightImage() - - if how.find("contours") != -1: - current_lab_2d_rp = skimage.measure.regionprops(self.currentLab2D) - for obj in current_lab_2d_rp: - if obj.label == self.movingID: - self.addObjContourToContoursImage(obj=obj, ax=ax) - break - elif how.find("overlay segm. masks") != -1: - if ax == 0: - self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) - self.highLightIDLayerImg1.image[:] = 0 - mask = self.currentLab2D == self.movingID - self.highLightIDLayerImg1.image[mask] = self.movingID - highlighted_image = self.highLightIDLayerImg1.image - self.highLightIDLayerImg1.setImage(highlighted_image) - else: - self.labelsLayerRightImg.setImage(self.currentLab2D, autoLevels=False) - self.highLightIDLayerRightImage.image[:] = 0 - mask = self.currentLab2D == self.movingID - self.highLightIDLayerRightImage.image[mask] = self.movingID - highlighted_image = self.highLightIDLayerRightImage.image - self.highLightIDLayerRightImage.setImage(highlighted_image) - - def set_temp_img_expand_label( - self, - previous_coords, - expanded_obj_coords, - ax=0, - ): - if ax == 0: - self.drawIDsContComboBox.currentText() - else: - self.getAnnotateHowRightImage() - - self._set_temp_img_expand_label_contours(previous_coords, ax=ax) - - def should_clear_move_state(self, *, checked: bool) -> bool: - return not checked - - def should_reinitialize_expansion( - self, - *, - expanding_id: int, - hover_label_id: int, - dilation: bool, - is_dilation: bool, - ) -> bool: - return expanding_id != hover_label_id or dilation != is_dilation - - def should_start_moving_label(self, label_id: int) -> bool: - return label_id != 0 - - def start_moving_label(self, x_pos, y_pos): - pos_data = self.data[self.pos_i] - x_data, y_data = int(x_pos), int(y_pos) - lab_2d = self.get_2Dlab(pos_data.lab) - label_id = lab_2d[y_data, x_data] - if not self.should_start_moving_label(label_id): - self.isMovingLabel = False - return - - self.isMovingLabel = True - self.searchedIDitemRight.setData([], []) - self.searchedIDitemLeft.setData([], []) - self.movingID = label_id - self.prevMovePos = (x_data, y_data) - moving_obj = pos_data.rp[pos_data.IDs.index(label_id)] - self.movingObjCoords = moving_obj.coords.copy() - yy, xx = moving_obj.coords[:, -2], moving_obj.coords[:, -1] - self.currentLab2D[yy, xx] = 0 diff --git a/cellacdc/mixins_bak/lineage.py b/cellacdc/mixins_bak/lineage.py deleted file mode 100644 index 070a519b1..000000000 --- a/cellacdc/mixins_bak/lineage.py +++ /dev/null @@ -1,61 +0,0 @@ -"""View-model commands for lineage tree annotations.""" - -from __future__ import annotations - -import pandas as pd - -from cellacdc.domain.lineage import ( - LineageAnnotationsRemovalResult, - LineageFutureRemovalResult, - has_lineage_tree_annotations, - remove_future_lineage_tree_annotations, - remove_lineage_tree_annotations, -) -from cellacdc.myutils import get_obj_by_label, sort_IDs_dist - - -class LineageMixin: - """Application-facing commands for lineage annotation tables.""" - - def has_lineage_tree_annotations( - self, - acdc_df: pd.DataFrame | None, - lineage_tree=None, - *, - parent_column: str = "parent_ID_tree", - ) -> bool: - return has_lineage_tree_annotations( - acdc_df, - lineage_tree, - parent_column=parent_column, - ) - - def object_by_label(self, regionprops, label): - return get_obj_by_label(regionprops, label) - - def remove_future_lineage_tree_annotations( - self, - frame_records, - lineage_tree_colnames, - from_frame_i: int, - *, - size_t: int | None = None, - acdc_key: str = "acdc_df", - ) -> LineageFutureRemovalResult: - return remove_future_lineage_tree_annotations( - frame_records, - lineage_tree_colnames, - from_frame_i, - size_t=size_t, - acdc_key=acdc_key, - ) - - def remove_lineage_tree_annotations( - self, - acdc_df: pd.DataFrame | None, - lineage_tree_colnames, - ) -> LineageAnnotationsRemovalResult: - return remove_lineage_tree_annotations(acdc_df, lineage_tree_colnames) - - def sort_ids_by_distance(self, regionprops, *, point=None, label=None): - return sort_IDs_dist(regionprops, point=point, ID=label) diff --git a/cellacdc/mixins_bak/main.py b/cellacdc/mixins_bak/main.py deleted file mode 100644 index 33c44bab1..000000000 --- a/cellacdc/mixins_bak/main.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Main GUI mixin composition root.""" - -from __future__ import annotations - - -class MainGuiMixin: - """Composition adapter properties to support legacy view-model routing.""" - - @property - def view_model(self): - return self - - @property - def cca_edits(self): - return self - - @property - def cca_workflows(self): - return self - - @property - def edit_id(self): - return self - - @property - def frame_metadata(self): - return self - - @property - def formatting(self): - return self - - @property - def geometry(self): - return self - - @property - def label_edits(self): - return self - - @property - def lineage(self): - return self - - @property - def model_registry(self): - return self - - @property - def object_counts(self): - return self - - @property - def points(self): - return self - - @property - def tables(self): - return self - - @property - def workspace(self): - return self diff --git a/cellacdc/mixins_bak/main_menu.py b/cellacdc/mixins_bak/main_menu.py deleted file mode 100644 index 379aa9d61..000000000 --- a/cellacdc/mixins_bak/main_menu.py +++ /dev/null @@ -1,204 +0,0 @@ -"""View adapter for the main menu.""" - -from __future__ import annotations - -from qtpy.QtWidgets import QAction, QActionGroup, QMenu - - -class MainMenuMixin: - """Qt-facing adapter around the main-menu view-model.""" - - """Headless main-menu decision rules.""" - - default_rescale_intensity_options = () - - def _add_default_rescale_intensity_menu(self): - self.defaultRescaleIntensActionGroup = QActionGroup( - self.defaultRescaleIntensLutMenu - ) - self.defaultRescaleIntensHow = self.default_rescale_intensity_how( - self.df_settings - ) - for how_text in self.default_rescale_intensity_options(): - action = QAction(how_text, self.defaultRescaleIntensLutMenu) - action.setCheckable(True) - if how_text == self.defaultRescaleIntensHow: - action.setChecked(True) - - self.defaultRescaleIntensActionGroup.addAction(action) - self.defaultRescaleIntensLutMenu.addAction(action) - - def _add_edit_menu(self, menu_bar): - edit_menu = menu_bar.addMenu("&Edit") - edit_menu.addSeparator() - edit_menu.addAction(self.editShortcutsAction) - edit_menu.addAction(self.editTextIDsColorAction) - edit_menu.addAction(self.editOverlayColorAction) - edit_menu.addAction(self.manuallyEditCcaAction) - edit_menu.addAction(self.enableSmartTrackAction) - edit_menu.addAction(self.enableAutoZoomToCellsAction) - - def _add_file_menu(self, menu_bar): - file_menu = QMenu("&File", self) - self.fileMenu = file_menu - menu_bar.addMenu(file_menu) - if self.debug: - file_menu.addAction(self.createEmptyDataAction) - file_menu.addAction(self.newAction) - file_menu.addAction(self.newWindowAction) - file_menu.addSeparator() - file_menu.addAction(self.openFolderAction) - file_menu.addAction(self.openFileAction) - self.openRecentMenu = file_menu.addMenu("Open Recent") - file_menu.addSeparator() - file_menu.addAction(self.manageVersionsAction) - file_menu.addAction(self.saveAction) - file_menu.addAction(self.saveAsAction) - file_menu.addAction(self.quickSaveAction) - file_menu.addSeparator() - - self.exportMenu = file_menu.addMenu("Export") - self.exportMenu.addAction(self.exportToVideoAction) - self.exportMenu.addAction(self.exportToImageAction) - file_menu.addSeparator() - file_menu.addAction(self.loadFluoAction) - file_menu.addAction(self.loadPosAction) - self.fileMenu.lastSeparator = file_menu.addSeparator() - file_menu.addAction(self.exitAction) - - def _add_help_menu(self, menu_bar): - help_menu = menu_bar.addMenu("&Help") - help_menu.addAction(self.openLogFileAction) - help_menu.addAction(self.showLogFilesAction) - help_menu.addAction(self.tipsAction) - help_menu.addAction(self.UserManualAction) - help_menu.addSeparator() - help_menu.addAction(self.aboutAction) - self.helpMenu = help_menu - - def _add_image_menu(self, menu_bar): - image_menu = menu_bar.addMenu("&Image") - image_menu.addSeparator() - image_menu.addAction(self.imgPropertiesAction) - self.defaultRescaleIntensLutMenu = image_menu.addMenu( - "Default method to rescale intensities (LUT)" - ) - self._add_default_rescale_intensity_menu() - - image_menu.addAction(self.addScaleBarAction) - image_menu.addAction(self.addTimestampAction) - self.rescaleIntensMenu = image_menu.addMenu("Rescale intensities (LUT)") - image_menu.addAction(self.preprocessAction) - image_menu.addAction(self.combineChannelsAction) - image_menu.addAction(self.saveLabColormapAction) - image_menu.addAction(self.shuffleCmapAction) - image_menu.addAction(self.greedyShuffleCmapAction) - image_menu.addAction(self.zoomToObjsAction) - image_menu.addAction(self.zoomOutAction) - - def _add_measurements_menu(self, menu_bar): - measurements_menu = menu_bar.addMenu("&Measurements") - self.measurementsMenu = measurements_menu - measurements_menu.addSeparator() - measurements_menu.addAction(self.setMeasurementsAction) - measurements_menu.addAction(self.addCustomMetricAction) - measurements_menu.addAction(self.addCombineMetricAction) - measurements_menu.setDisabled(True) - - def _add_mode_menu(self, menu_bar): - self.modeMenu = menu_bar.addMenu("Mode") - self.modeMenu.menuAction().setVisible(False) - - def _add_segment_menu(self, menu_bar): - segment_menu = menu_bar.addMenu("&Segment") - self.segmentMenu = segment_menu - segment_menu.addSeparator() - self.segmSingleFrameMenu = segment_menu.addMenu("Segment displayed frame") - for action in self.segmActions: - self.segmSingleFrameMenu.addAction(action) - - self.segmSingleFrameMenu.addSeparator() - self.segmSingleFrameMenu.addAction(self.addCustomModelFrameAction) - - self.segmVideoMenu = segment_menu.addMenu("Segment multiple frames") - for action in self.segmActionsVideo: - self.segmVideoMenu.addAction(action) - - self.segmVideoMenu.addSeparator() - self.segmVideoMenu.addAction(self.addCustomModelVideoAction) - - self.segmWithPromptableModelMenu = segment_menu.addMenu( - "Segment with promptable model" - ) - self.segmWithPromptableModelMenu.addAction(self.segmWithPromptableModelAction) - self.segmWithPromptableModelMenu.addSeparator() - self.segmWithPromptableModelMenu.addAction(self.addCustomPromptModelAction) - - segment_menu.addAction(self.EditSegForLostIDsSetSettings) - segment_menu.addAction(self.postProcessSegmAction) - segment_menu.addAction(self.autoSegmAction) - segment_menu.addAction(self.relabelSequentialAction) - segment_menu.aboutToShow.connect( - self.mode_controls_view.nonViewerEditMenuOpened - ) - - def _add_settings_menu(self, menu_bar): - self.settingsMenu = QMenu("Settings", self) - menu_bar.addMenu(self.settingsMenu) - self.settingsMenu.addAction(self.invertBwAction) - self.settingsMenu.addAction(self.toggleColorSchemeAction) - self.settingsMenu.addSeparator() - self.settingsMenu.addAction(self.pxModeAction) - self.settingsMenu.addAction(self.highLowResAction) - self.settingsMenu.addAction(self.editShortcutsAction) - self.settingsMenu.addAction(self.showMirroredCursorAction) - self.settingsMenu.addSeparator() - self.settingsMenu.addAction(self.editAutoSaveIntervalAction) - self.settingsMenu.addSeparator() - - def _add_tracking_menu(self, menu_bar): - tracking_menu = menu_bar.addMenu("&Tracking") - self.trackingMenu = tracking_menu - tracking_menu.addSeparator() - select_track_algo_menu = tracking_menu.addMenu( - "Select real-time tracking algorithm" - ) - for action in self.trackingAlgosGroup.actions(): - select_track_algo_menu.addAction(action) - - tracking_menu.addAction(self.editRtTrackerParamsAction) - tracking_menu.addAction(self.repeatTrackingVideoAction) - tracking_menu.addAction(self.repeatTrackingMenuAction) - tracking_menu.aboutToShow.connect( - self.mode_controls_view.nonViewerEditMenuOpened - ) - - if self.mainWin is not None: - tracking_menu.addAction(self.mainWin.applyTrackingFromTableAction) - tracking_menu.addAction(self.mainWin.applyTrackingFromTrackMateXMLAction) - - def _add_view_menu(self, menu_bar): - self.viewMenu = menu_bar.addMenu("&View") - self.viewMenu.addSeparator() - self.viewMenu.addAction(self.viewCcaTableAction) - - def create_menu_bar(self): - menu_bar = self.menuBar() - menu_bar.setNativeMenuBar(False) - - self._add_file_menu(menu_bar) - self._add_edit_menu(menu_bar) - self._add_view_menu(menu_bar) - self._add_image_menu(menu_bar) - self._add_segment_menu(menu_bar) - self._add_tracking_menu(menu_bar) - self._add_measurements_menu(menu_bar) - self._add_settings_menu(menu_bar) - self._add_mode_menu(menu_bar) - self._add_help_menu(menu_bar) - - def default_rescale_intensity_how(self, settings): - try: - return settings.at["default_rescale_intens_how", "value"] - except Exception: - return self.default_rescale_intensity_options[0] diff --git a/cellacdc/mixins_bak/measurements.py b/cellacdc/mixins_bak/measurements.py deleted file mode 100644 index f5e317d3f..000000000 --- a/cellacdc/mixins_bak/measurements.py +++ /dev/null @@ -1,199 +0,0 @@ -"""View adapter for measurement setup and dialogs.""" - -from __future__ import annotations - -import pandas as pd - -from cellacdc import apps, cli, favourite_func_metrics_csv_path, widgets - - -class MeasurementsMixin: - """Qt-facing adapter around measurement view-model contracts.""" - - """Headless measurement calculation and setup rules.""" - - def _favourite_metric_functions(self): - try: - df_favourite_funcs = pd.read_csv(favourite_func_metrics_csv_path) - return df_favourite_funcs["favourite_func_name"].to_list() - except Exception: - return None - - def _log_removed_measurements(self, del_cols, del_rps): - del_cols_format = [f" * {colname}" for colname in del_cols] - del_rps_format = [f" * {colname}" for colname in del_rps] - del_cols_format.extend(del_rps_format) - del_cols_format = "\n".join(del_cols_format) - self.logger.info(del_cols_format) - - def _remove_existing_unchecked_measurements(self): - self.logger.info("Removing existing unchecked measurements...") - del_cols = self.measurementsWin.existingUncheckedColnames - del_rps = self.measurementsWin.existingUncheckedRps - self._log_removed_measurements(del_cols, del_rps) - for pos_data in self.data: - for data_dict in pos_data.allData_li: - data_dict["acdc_df"] = self.drop_unchecked_measurements( - data_dict["acdc_df"], - del_cols, - del_rps, - ) - - def _set_metrics(self, measurements_win): - self._measurements_kernel.set_metrics_from_set_measurements_dialog( - measurements_win - ) - for ch_name in self._measurements_kernel.chNamesToProcess: - if ch_name not in self.notLoadedChNames: - continue - - success = self.loadFluo_cb(fluo_channels=[ch_name]) - if not success: - continue - - def add_combine_metric(self): - pos_data = self.data[self.pos_i] - is_zstack = pos_data.SizeZ > 1 - win = apps.combineMetricsEquationDialog( - self.ch_names, - is_zstack, - self.isSegm3D, - parent=self, - ) - win.sigOk.connect(self.save_combine_metrics_to_pos_data) - win.exec_() - win.sigOk.disconnect() - - def add_custom_metric(self, checked=False): - txt = self.custom_metrics_instructions() - metrics_path = self.metrics_examples_path() - msg = widgets.myMessageBox() - msg.addShowInFileManagerButton(metrics_path, "Show example...") - title = "Add custom metrics instructions" - msg.information(self, title, txt, buttonsTexts=("Ok",)) - - def all_acdc_df_columns(self, all_pos_data): - columns = set() - for pos_data in all_pos_data: - for data_dict in pos_data.allData_li: - acdc_df = data_dict["acdc_df"] - if acdc_df is None: - continue - columns.update(acdc_df.columns) - return columns - - def custom_metrics_instructions(self): - return measurements.add_metrics_instructions() - - def drop_unchecked_measurements(self, acdc_df, columns, regionprops): - if acdc_df is None: - return None - acdc_df = acdc_df.drop(columns=columns, errors="ignore") - for col_rp in regionprops: - drop_df_rp = acdc_df.filter(regex=rf"{col_rp}.*", axis=1) - drop_cols_rp = drop_df_rp.columns - acdc_df = acdc_df.drop(columns=drop_cols_rp, errors="ignore") - return acdc_df - - def init_metrics(self): - self.logger.info("Initializing measurements...") - pos_data = self.data[self.pos_i] - self._measurements_kernel = cli.ComputeMeasurementsKernel( - self.logger, self.log_path, False - ) - self._measurements_kernel.init_args(pos_data.chNames, pos_data.getSegmEndname()) - self._measurements_kernel._init_metrics(pos_data, self.isSegm3D) - - def init_metrics_to_save(self, pos_data): - self._measurements_kernel._init_metrics_to_save(pos_data) - - def metrics_examples_path(self): - return measurements.metrics_path - - def not_loaded_channels(self, all_channel_names, loaded_channel_names): - return [c for c in all_channel_names if c not in loaded_channel_names] - - def rotational_volume( - self, - obj, - physical_size_y=1, - physical_size_x=1, - logger=None, - ): - return _calc_rot_vol( - obj, - physical_size_y, - physical_size_x, - logger=logger, - ) - - def save_combine_metrics_to_pos_data(self, window): - for pos_data in self.data: - equations_dict, is_mixed_channels = window.getEquationsDict() - for new_col_name, equation in equations_dict.items(): - pos_data.addEquationCombineMetrics( - equation, new_col_name, is_mixed_channels - ) - pos_data.saveCombineMetrics() - - if self.measurementsWin is None: - return - - self.measurementsWinState = self.measurementsWin.state() - self.measurementsWin.close() - self.show_set_measurements() - self.measurementsWin.restoreState(self.measurementsWinState) - - def set_measurements(self): - if self.measurementsWin.delExistingCols: - self._remove_existing_unchecked_measurements() - self.setMeasWinState = self.measurementsWin.state() - self.logger.info("Setting measurements...") - self._set_metrics(self.measurementsWin) - self.logger.info("Metrics successfully set.") - self.measurementsWin = None - - def set_measurements_cancelled(self): - self.measurementsWin = None - - def set_metrics_func(self): - pos_data = self.data[self.pos_i] - self._measurements_kernel._set_metrics_func_from_posData(pos_data) - - def show_set_measurements(self, checked=False, qparent=None): - qparent = qparent if qparent is not None else self - if self.measurementsWin is not None: - self.measurementsWin.show() - self.measurementsWin.raise_() - self.measurementsWin.activateWindow() - return - - favourite_funcs = self._favourite_metric_functions() - pos_data = self.data[self.pos_i] - all_pos_acdc_df_cols = self.all_acdc_df_columns(self.data) - loaded_ch_names = pos_data.setLoadedChannelNames(returnList=True) - pos_data.fluo_data_dict.pop(self.user_ch_name, None) - if self.user_ch_name not in loaded_ch_names: - loaded_ch_names.insert(0, self.user_ch_name) - not_loaded_ch_names = self.not_loaded_channels( - self.ch_names, - loaded_ch_names, - ) - self.notLoadedChNames = not_loaded_ch_names - self.measurementsWin = apps.SetMeasurementsDialog( - loaded_ch_names, - not_loaded_ch_names, - pos_data.SizeZ > 1, - self.isSegm3D, - favourite_funcs=favourite_funcs, - allPos_acdc_df_cols=list(all_pos_acdc_df_cols), - acdc_df_path=pos_data.images_path, - posData=pos_data, - addCombineMetricCallback=self.add_combine_metric, - allPosData=self.data, - parent=qparent, - state=self.setMeasWinState, - ) - self.measurementsWin.sigCancel.connect(self.set_measurements_cancelled) - self.measurementsWin.sigClosed.connect(self.set_measurements) - self.measurementsWin.show() diff --git a/cellacdc/mixins_bak/model_registry.py b/cellacdc/mixins_bak/model_registry.py deleted file mode 100644 index 6bce519c8..000000000 --- a/cellacdc/mixins_bak/model_registry.py +++ /dev/null @@ -1,134 +0,0 @@ -"""View-model commands for model registry discovery.""" - -from __future__ import annotations - -from cellacdc.myutils import ( - aliases_real_time_trackers, - check_gpu_available, - check_install_package, - getModelArgSpec, - get_list_of_models, - get_list_of_real_time_trackers, - import_segment_module, - init_prompt_segm_model, - init_segm_model, - init_tracker, - insertModelArgSpec, - log_segm_params, - setDefaultValueArgSpecsFromKwargs, - store_custom_model_path, - store_custom_promptable_model_path, - validate_tracker_input, -) - - -class ModelRegistryMixin: - """Application-facing commands for available model registries.""" - - def check_gpu_available( - self, - model_name, - use_gpu, - *, - qparent=None, - do_not_warn=False, - ): - return check_gpu_available( - model_name, - use_gpu, - qparent=qparent, - do_not_warn=do_not_warn, - ) - - def check_install_package(self, model_name): - return check_install_package(model_name) - - def import_segmentation_module(self, model_name): - return import_segment_module(model_name) - - def init_prompt_segmentation_model( - self, - acdc_prompt_segment, - position_data, - init_kwargs, - ): - return init_prompt_segm_model( - acdc_prompt_segment, - position_data, - init_kwargs, - ) - - def init_segmentation_model(self, acdc_segment, position_data, init_kwargs): - return init_segm_model(acdc_segment, position_data, init_kwargs) - - def init_tracker(self, position_data, tracker_name, **kwargs): - return init_tracker(position_data, tracker_name, **kwargs) - - def insert_model_arg_spec( - self, - params, - param_name, - param_value, - *, - param_type=None, - desc="", - docstring="", - ): - return insertModelArgSpec( - params, - param_name, - param_value, - param_type=param_type, - desc=desc, - docstring=docstring, - ) - - def log_segmentation_params( - self, - model_name, - init_params, - segment_params, - *, - logger_func=print, - preproc_recipe=None, - apply_post_process=False, - standard_postprocess_kwargs=None, - custom_postprocess_features=None, - ): - return log_segm_params( - model_name, - init_params, - segment_params, - logger_func=logger_func, - preproc_recipe=preproc_recipe, - apply_post_process=apply_post_process, - standard_postprocess_kwargs=standard_postprocess_kwargs, - custom_postprocess_features=custom_postprocess_features, - ) - - def model_arg_specs(self, acdc_segment): - return getModelArgSpec(acdc_segment) - - def real_time_tracker_aliases(self, *, reverse: bool = False): - return aliases_real_time_trackers(reverse=reverse) - - def real_time_trackers(self): - return get_list_of_real_time_trackers() - - def segmentation_models(self, *, include_local_seg: bool = False): - models = list(get_list_of_models()) - if include_local_seg and "local_seg" not in models: - models.append("local_seg") - return models - - def set_default_arg_specs_from_kwargs(self, params, kwargs): - return setDefaultValueArgSpecsFromKwargs(params, kwargs) - - def store_custom_model_path(self, model_file_path): - return store_custom_model_path(model_file_path) - - def store_custom_promptable_model_path(self, model_file_path): - return store_custom_promptable_model_path(model_file_path) - - def validate_tracker_input(self, tracker, segmentation_video): - return validate_tracker_input(tracker, segmentation_video) diff --git a/cellacdc/mixins_bak/object_cleanup.py b/cellacdc/mixins_bak/object_cleanup.py deleted file mode 100644 index 350ddc5b4..000000000 --- a/cellacdc/mixins_bak/object_cleanup.py +++ /dev/null @@ -1,106 +0,0 @@ -"""View adapter for object cleanup workflows.""" - -from __future__ import annotations - -import numpy as np -from qtpy.QtCore import QThread - -from cellacdc import apps, widgets, workers - - -class ObjectCleanupMixin: - """Qt-facing adapter around the object-cleanup view-model.""" - - """Headless object-cleanup result shaping.""" - - def cleared_segmentation_frames(self, cleared_segm_data, *, size_t: int): - if size_t == 1: - return cleared_segm_data[np.newaxis] - return cleared_segm_data - - def delete_objects_outside_mask_action_triggered(self): - pos_data = self.data[self.pos_i] - existing_segm_endnames = self.segmentation_roi_endnames( - basename=pos_data.basename, - images_path=pos_data.images_path, - ) - select_segm_win = widgets.QDialogListbox( - "Select segmentation file", - "Select segmentation file to use as ROI:\n", - existing_segm_endnames, - multiSelection=False, - parent=self, - ) - select_segm_win.exec_() - if select_segm_win.cancel: - self.logger.info("Delete objects process cancelled.") - return - - selected_segm_endname = select_segm_win.selectedItemsText[0] - self.start_delete_objects_outside_mask_worker(selected_segm_endname) - - def delete_objects_outside_mask_worker_finished(self, result): - pos_data = self.data[self.pos_i] - worker, cleared_segm_data, del_ids = result - cleared_segm_data = self.cleared_segmentation_frames( - cleared_segm_data, - size_t=pos_data.SizeT, - ) - - self.update_cca_df_deletedIDs(pos_data, del_ids) - - current_frame_i = pos_data.frame_i - for frame_i, cleared_lab in self.frame_labels(cleared_segm_data): - pos_data.allData_li[frame_i]["labels"] = cleared_lab - pos_data.frame_i = frame_i - self.get_data() - self.store_data(autosave=False) - - pos_data.frame_i = current_frame_i - self.get_data() - - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - self.logger.info("Deleting objects outside of ROIs finished.") - self.titleLabel.setText( - "Deleting objects outside of ROIs finished.", - color="w", - ) - self.updateAllImages() - - def frame_labels(self, cleared_segm_data): - return list(enumerate(cleared_segm_data)) - - def start_delete_objects_outside_mask_worker(self, selected_segm_endname): - self.store_data(autosave=False) - pos_data = self.data[self.pos_i] - segm_data = np.squeeze(self.getStoredSegmData()) - - self.progressWin = apps.QDialogWorkerProgress( - title="Deleting objects outside of ROIs", - parent=self, - pbarDesc="Deleting objects outside of ROIs...", - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.thread = QThread() - self.worker = workers.DelObjectsOutsideSegmROIWorker( - selected_segm_endname, - segm_data, - pos_data.images_path, - ) - self.worker.moveToThread(self.thread) - self.worker.finished.connect(self.thread.quit) - self.worker.finished.connect(self.worker.deleteLater) - self.thread.finished.connect(self.thread.deleteLater) - - self.worker.progress.connect(self.workerProgress) - self.worker.critical.connect(self.workerCritical) - self.worker.finished.connect(self.delete_objects_outside_mask_worker_finished) - self.worker.debug.connect(self.workerDebug) - - self.thread.started.connect(self.worker.run) - self.thread.start() diff --git a/cellacdc/mixins_bak/object_counts.py b/cellacdc/mixins_bak/object_counts.py deleted file mode 100644 index 4967e204a..000000000 --- a/cellacdc/mixins_bak/object_counts.py +++ /dev/null @@ -1,38 +0,0 @@ -"""View-model commands for object counts and label frame selection.""" - -from __future__ import annotations - -import os - -from cellacdc.domain.object_counts import ( - collect_all_ids, - current_labels, - snapshot_object_counts, -) - - -class ObjectCountMixin: - """Application-facing object count and label-frame commands.""" - - def collect_all_ids(self, pos_data, *, only_visited: bool = False) -> set[int]: - return collect_all_ids(pos_data, only_visited=only_visited) - - def current_labels(self, pos_data, *, curr_lab=None, frame_i=None): - return current_labels(pos_data, curr_lab=curr_lab, frame_i=frame_i) - - def snapshot_object_counts( - self, - positions, - current_pos_i: int, - *, - current_lab_2d=None, - include_current_z_slice: bool = False, - path_exists=os.path.exists, - ) -> dict[str, int]: - return snapshot_object_counts( - positions, - current_pos_i, - current_lab_2d=current_lab_2d, - include_current_z_slice=include_current_z_slice, - path_exists=path_exists, - ) diff --git a/cellacdc/mixins_bak/points.py b/cellacdc/mixins_bak/points.py deleted file mode 100644 index 7e7a1d26e..000000000 --- a/cellacdc/mixins_bak/points.py +++ /dev/null @@ -1,135 +0,0 @@ -"""View-model commands for point-layer data.""" - -from __future__ import annotations - -from cellacdc.io.readers.points import load_click_points_table, load_points_table -from cellacdc.io.writers.points import ( - click_points_table_filename, - save_click_points_table, -) - -from cellacdc.domain.points import ( - add_click_point, - click_points_table_to_data, - flatten_frame_points_data, - next_click_point_id, - point_id_already_new, - points_table_to_data, - remove_click_points, -) - - -class PointsMixin: - """Application-facing commands for point-layer data transforms.""" - - def add_click_point( - self, - points_data_pos, - frame_i: int, - x: float, - y: float, - point_id: int, - *, - size_z: int = 1, - z_slice: int | None = None, - ): - return add_click_point( - points_data_pos, - frame_i, - x, - y, - point_id, - size_z=size_z, - z_slice=z_slice, - ) - - def click_points_table_filename( - self, - basename: str, - table_endname: str, - ) -> str: - return click_points_table_filename(basename, table_endname) - - def click_points_table_to_data(self, df, *, size_z: int = 1): - return click_points_table_to_data(df, size_z=size_z) - - def flatten_frame_points_data( - self, - frame_points_data, - *, - z_slice: int | None = None, - z_radius: int = 0, - ): - return flatten_frame_points_data( - frame_points_data, - z_slice=z_slice, - z_radius=z_radius, - ) - - def load_click_points_table(self, filepath): - return load_click_points_table(filepath) - - def load_points_table(self, filepath): - return load_points_table(filepath) - - def loaded_table_to_points_data( - self, - df, - t_col, - z_col, - y_col, - x_col, - ): - return points_table_to_data(df, t_col, z_col, y_col, x_col) - - def next_click_point_id( - self, - points_data_pos, - frame_i: int, - current_id: int, - *, - size_z: int = 1, - ) -> int: - return next_click_point_id( - points_data_pos, - frame_i, - current_id, - size_z=size_z, - ) - - def point_id_already_new( - self, - points_data_pos, - frame_i: int, - point_id: int, - known_ids, - ) -> bool: - return point_id_already_new( - points_data_pos, - frame_i, - point_id, - known_ids, - ) - - def remove_click_points( - self, - frame_points_data, - points, - *, - z_slice: int | None = None, - z_radius: int = 0, - ) -> list[int]: - return remove_click_points( - frame_points_data, - points, - z_slice=z_slice, - z_radius=z_radius, - ) - - def save_click_points_table( - self, - filepath, - df, - sort_by=("frame_i", "Cell_ID"), - ): - return save_click_points_table(filepath, df, sort_by=sort_by) diff --git a/cellacdc/mixins_bak/quick_settings.py b/cellacdc/mixins_bak/quick_settings.py deleted file mode 100644 index e40b63cb4..000000000 --- a/cellacdc/mixins_bak/quick_settings.py +++ /dev/null @@ -1,202 +0,0 @@ -"""View adapter for quick settings and side-panel widgets.""" - -from __future__ import annotations - -from qtpy.QtCore import Qt -from qtpy.QtWidgets import QFormLayout, QLabel, QVBoxLayout - -from cellacdc import apps, settings_csv_path, widgets - - -class QuickSettingsMixin: - """Qt-facing adapter around quick-settings view-model contracts.""" - - """Headless quick-settings decision rules.""" - - def _add_autosave_interval_control(self, layout): - self.autoSaveIntervalEditButton = widgets.editPushButton( - flat=True, hoverable=True - ) - self.autoSaveIntervalLabel = QLabel("Autosave interval") - self.autoSaveIntervalSetTooltip() - layout.addRow( - self.autoSaveIntervalLabel, - self.autoSaveIntervalEditButton, - ) - - self.autoSaveIntervalDialog = apps.AutoSaveIntervalDialog(parent=self) - self.autoSaveIntervalDialog.setValues(*self.autoSaveIntevalValueUnit) - - def _add_autosave_toggles(self, layout): - self.autoSaveToggle = widgets.Toggle() - tooltip = ( - "Automatically store a copy of the segmentation data " - "in the `.recovery` folder after every edit." - ) - self.autoSaveToggle.setChecked(True) - self.autoSaveToggle.setToolTip(tooltip) - label = QLabel("Autosave segmentation") - label.setToolTip(tooltip) - layout.addRow(label, self.autoSaveToggle) - - self.autoSaveAnnotToggle = widgets.Toggle() - tooltip = ( - "Automatically store a copy of the annotations (acdc_output CSV " - "file) in the `.recovery` folder after every edit." - ) - self.autoSaveAnnotToggle.setChecked(True) - self.autoSaveAnnotToggle.setToolTip(tooltip) - label = QLabel("Autosave annotations") - label.setToolTip(tooltip) - layout.addRow(label, self.autoSaveAnnotToggle) - - def _add_cca_integrity_checker_toggle(self, layout): - self.ccaIntegrCheckerToggle = widgets.Toggle() - tooltip = "Toggle background cell cycle annotations integrity checker ON/OFF" - self.ccaIntegrCheckerToggle.setChecked(False) - self.ccaIntegrCheckerToggle.setToolTip(tooltip) - label = QLabel("Cc annot. checker") - label.setToolTip(tooltip) - layout.addRow(label, self.ccaIntegrCheckerToggle) - idx = "is_cca_integrity_checker_activated" - if idx in self.df_settings.index: - val = int(self.df_settings.at[idx, "value"]) - self.ccaIntegrCheckerToggle.setChecked(not val) - - def _add_combined_channels_toggle(self, layout): - self.viewCombineChannelDataToggle = widgets.Toggle() - tooltip = ( - "View combined channel. See menu `Image --> combing channels...`\n" - "on the top menubar." - ) - self.viewCombineChannelDataToggle.setChecked(False) - self.viewCombineChannelDataToggle.setToolTip(tooltip) - label = QLabel("View combined channels") - label.setToolTip(tooltip) - layout.addRow(label, self.viewCombineChannelDataToggle) - - def _add_font_size_control(self, layout): - self.fontSizeSpinBox = widgets.SpinBox() - self.fontSizeSpinBox.setMinimum(1) - self.fontSizeSpinBox.setMaximum(99) - layout.addRow("Font size", self.fontSizeSpinBox) - font_size_setting = self.font_size_setting( - self.df_settings.at["fontSize", "value"], - has_px_mode="pxMode" in self.df_settings.index, - ) - self.fontSize = font_size_setting.value - if font_size_setting.add_px_mode_setting: - self.df_settings.at["pxMode", "value"] = 1 - self.df_settings.to_csv(settings_csv_path) - self.fontSizeSpinBox.setValue(self.fontSize) - self.fontSizeSpinBox.editingFinished.connect(self.changeFontSize) - self.fontSizeSpinBox.sigUpClicked.connect(self.changeFontSize) - self.fontSizeSpinBox.sigDownClicked.connect(self.changeFontSize) - - def _add_lost_objects_toggle(self, layout): - self.annotLostObjsToggle = widgets.Toggle() - tooltip = "Toggle annotation of lost objects mode ON/OFF" - self.annotLostObjsToggle.setChecked(True) - self.annotLostObjsToggle.setToolTip(tooltip) - label = QLabel("Annot. lost objects") - label.setToolTip(tooltip) - layout.addRow(label, self.annotLostObjsToggle) - - def _add_realtime_tracking_toggle(self, layout): - self.realTimeTrackingToggle = widgets.Toggle() - self.realTimeTrackingToggle.setChecked(True) - self.realTimeTrackingToggle.setDisabled(True) - label = QLabel("Real-time tracking") - label.setDisabled(True) - self.realTimeTrackingToggle.label = label - layout.addRow(label, self.realTimeTrackingToggle) - - def _add_show_all_contours_toggle(self, layout): - self.showAllContoursToggle = widgets.Toggle() - tooltip = ( - "If active, all contours will be displayed, including inner " - "contours(e.g. holes and sub-objects)" - ) - self.showAllContoursToggle.setToolTip(tooltip) - label = QLabel("Show all contours") - label.setToolTip(tooltip) - layout.addRow(label, self.showAllContoursToggle) - self.showAllContoursToggle.toggled.connect(self.show_all_contours_toggled) - - def _add_view_preprocessed_toggle(self, layout): - self.viewPreprocDataToggle = widgets.Toggle() - tooltip = ( - "View pre-processed data. See menu `Image --> Pre-processing...`\n" - "on the top menubar." - ) - self.viewPreprocDataToggle.setChecked(False) - self.viewPreprocDataToggle.setToolTip(tooltip) - label = QLabel("View pre-processed image") - label.setToolTip(tooltip) - layout.addRow(label, self.viewPreprocDataToggle) - - def create_show_props_button(self, side="left"): - self.leftSideDocksLayout = QVBoxLayout() - self.leftSideDocksLayout.setSpacing(0) - self.leftSideDocksLayout.setContentsMargins(0, 0, 0, 0) - self.rightSideDocksLayout = QVBoxLayout() - self.rightSideDocksLayout.setSpacing(0) - self.rightSideDocksLayout.setContentsMargins(0, 0, 0, 0) - self.showPropsDockButton = widgets.expandCollapseButton() - self.showPropsDockButton.setDisabled(True) - self.showPropsDockButton.setFocusPolicy(Qt.NoFocus) - self.showPropsDockButton.setToolTip("Show object properties") - if side == "left": - self.leftSideDocksLayout.addWidget(self.showPropsDockButton) - else: - self.rightSideDocksLayout.addWidget(self.showPropsDockButton) - - def create_widgets(self): - self.quickSettingsLayout = QVBoxLayout() - self.quickSettingsGroupbox = widgets.GroupBox() - self.quickSettingsGroupbox.setTitle("Quick settings") - - layout = QFormLayout() - layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint) - layout.setFormAlignment(Qt.AlignRight | Qt.AlignVCenter) - - self._add_view_preprocessed_toggle(layout) - self._add_combined_channels_toggle(layout) - self._add_autosave_toggles(layout) - self._add_autosave_interval_control(layout) - self._add_cca_integrity_checker_toggle(layout) - self._add_lost_objects_toggle(layout) - self._add_realtime_tracking_toggle(layout) - self._add_show_all_contours_toggle(layout) - self._add_font_size_control(layout) - - self.quickSettingsGroupbox.setLayout(layout) - self.quickSettingsLayout.addWidget(self.quickSettingsGroupbox) - self.quickSettingsLayout.addStretch(1) - - def font_size_setting( - self, - saved_font_size, - *, - has_px_mode: bool, - ) -> FontSizeSetting: - saved_font_size = str(saved_font_size) - if saved_font_size.find("pt") != -1: - saved_font_size = saved_font_size[:-2] - font_size = int(saved_font_size) - if has_px_mode: - return FontSizeSetting(value=font_size) - return FontSizeSetting( - value=2 * font_size, - add_px_mode_setting=True, - ) - - def should_update_all_contours(self, *, is_data_loaded: bool) -> bool: - return is_data_loaded - - def show_all_contours_toggled(self): - if not self.should_update_all_contours(is_data_loaded=self.isDataLoaded): - return - - self.computeAllContours() - self.updateAllImages() diff --git a/cellacdc/mixins_bak/seg_for_lost_ids.py b/cellacdc/mixins_bak/seg_for_lost_ids.py deleted file mode 100644 index 1614a33ec..000000000 --- a/cellacdc/mixins_bak/seg_for_lost_ids.py +++ /dev/null @@ -1,397 +0,0 @@ -"""Qt view adapter for segmenting lost IDs.""" - -from __future__ import annotations - -from typing import Any - -from qtpy.QtCore import QMutex, QThread, QWaitCondition - -from cellacdc import apps, workers -from cellacdc.plot import imshow - - -class SegForLostIdsMixin: - """Qt-facing adapter around lost-ID segmentation commands.""" - - """Headless settings and launch rules for lost-ID segmentation.""" - - settings_key = "SegForLostIDsModel" - worker_model_name = "local_seg" - - def SegForLostIDsSetSettings(self): - - try: - prev_model = str(self.df_settings.at["SegForLostIDsModel", "value"]) - except KeyError: - prev_model = None - win = apps.QDialogSelectModel(parent=self, customFirst=prev_model) - win.exec_() - if win.cancel: - self.logger.info("Seg for lost IDs cancelled.") - return - base_model_name = win.selectedModel - - if base_model_name: - self.df_settings.at["SegForLostIDsModel", "value"] = base_model_name - self.df_settings.to_csv(self.settings_csv_path) - - model_name = "local_seg" - - idx = self.modelNames.index(model_name) - acdcSegment = self.acdcSegment_li[idx] - - try: - if acdcSegment is None or base_model_name != self.local_seg_base_model_name: - self.logger.info(f"Importing {base_model_name}...") - acdcSegment = myutils.import_segment_module(base_model_name) - self.acdcSegment_li[idx] = acdcSegment - self.local_seg_base_model_name = base_model_name - except (IndexError, ImportError, KeyError) as e: - self.logger.error(f"Error importing {base_model_name}: {e}") - return - - extra_params = [ - "overlap_threshold", - "padding", - "size_perc_diff", - "distance_filler_growth", - "max_iterations", - "allow_only_tracked_cells", - ] - - extra_types = [float, float, float, float, int, bool] - - extra_defaults = [0.5, 0.8, 0.3, 1.0, 2, False] - - extra_desc = [ - "Overlap threshold with other already segemented cells over which newly segmented cells are discarded", - "Padding of the box used for new segmentation around the segmentation from the previous frame", - "Relative size difference acceptable compared to previous frames", - """Cells which are already segmented are filled with random noise sampled from background - to ensure that they don't get segmented again. - This parameter controls the additional padding around the already segmented cells.""", - """The algorithm will try and segment the maximum amount - of cells in the image by running the model several - times and filling new found cells with background noise. - How many of these iterations should be run?""", - "If no new cell IDs should be permitted (based on real time tracking)", - ] - - extra_ArgSpec = [] - for i, param in enumerate(extra_params): - param = ArgSpec( - name=param, - default=extra_defaults[i], - type=extra_types[i], - desc=extra_desc[i], - docstring="", - ) - - extra_ArgSpec.append(param) - - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) - segment_params = [arg for arg in segment_params if arg[0] != "diameter"] - - extraParamsTitle = "Settings for local segmentation" - win = self.initSegmModelParams( - base_model_name, - acdcSegment, - init_params, - segment_params, - extraParams=extra_ArgSpec, - extraParamsTitle=extraParamsTitle, - initLastParams=True, - ini_filename="segmentation_for_lostIDs.ini", - ) - - if win is None: - self.logger.info("Segmentation for lost IDs cancelled.") - return - - init_kwargs_new = {} - args_new = {} - for key, val in win.init_kwargs.items(): - if key in extra_params: - args_new[key] = val - else: - init_kwargs_new[key] = val - - for key, val in win.extra_kwargs.items(): - if key in extra_params: - args_new[key] = val - - self.SegForLostIDsSettings = { - "win": win, - "init_kwargs_new": init_kwargs_new, - "args_new": args_new, - "base_model_name": base_model_name, - } - - def SegForLostIDsWorkerAskInstallGPU(self, model_name, use_gpu): - result = myutils.check_gpu_available(model_name, use_gpu, qparent=self) - self.SegForLostIDsWorker.gpu_go = result - dont_force_cpu = myutils.check_gpu_available( - model_name, use_gpu, do_not_warn=True - ) - self.SegForLostIDsWorker.dont_force_cpu = dont_force_cpu - self.SegForLostIDsWaitCond.wakeAll() - - def SegForLostIDsWorkerAskInstallModel(self, model_name): - myutils.check_install_package(model_name) - self.SegForLostIDsWaitCond.wakeAll() - - def SegForLostIDsWorkerFinished(self): - self.updateAllImages() - self.update_rp() - self.store_data(autosave=True) - self.setFrameNavigationDisabled(disable=False, why="Segmentation for lost IDs") - - if self.progressWin is not None: - self.progressWin.workerFinished = True - self.progressWin.close() - self.progressWin = None - - def can_start_from_frame(self, frame_i: int) -> bool: - return frame_i > 0 - - def extra_arg_specs(self) -> list[ArgSpec]: - extra_params = ( - "overlap_threshold", - "padding", - "size_perc_diff", - "distance_filler_growth", - "max_iterations", - "allow_only_tracked_cells", - ) - extra_types = (float, float, float, float, int, bool) - extra_defaults = (0.5, 0.8, 0.3, 1.0, 2, False) - extra_desc = ( - ( - "Overlap threshold with other already segemented cells over " - "which newly segmented cells are discarded" - ), - ( - "Padding of the box used for new segmentation around the " - "segmentation from the previous frame" - ), - ("Relative size difference acceptable compared to previous frames"), - ( - "Cells which are already segmented are filled with random " - "noise sampled from background to ensure that they do not get " - "segmented again. This parameter controls the additional " - "padding around the already segmented cells." - ), - ( - "The algorithm will try and segment the maximum amount of " - "cells in the image by running the model several times and " - "filling new found cells with background noise. How many of " - "these iterations should be run?" - ), - ("If no new cell IDs should be permitted (based on real time tracking)"), - ) - - return [ - ArgSpec( - name=name, - default=default, - type=arg_type, - desc=desc, - docstring="", - ) - for name, default, arg_type, desc in zip( - extra_params, extra_defaults, extra_types, extra_desc - ) - ] - - def onSegForLostInit(self): - self.logger.info("Settings for segmentation for lost IDs not set.") - self.SegForLostIDsSetSettings() - self.SegForLostIDsWaitCond.wakeAll() - - def onSigGetData(self, waitcond, debug=False): - self.get_data(debug=debug) - waitcond.wakeAll() - - def onSigStoreData( - self, - waitcond, - pos_i=None, - enforce=True, - debug=False, - mainThread=True, - autosave=True, - store_cca_df_copy=False, - ): - self.store_data( - pos_i=pos_i, - enforce=enforce, - debug=debug, - mainThread=mainThread, - autosave=autosave, - store_cca_df_copy=store_cca_df_copy, - ) - waitcond.wakeAll() - - def onSigStoreDataSegForLostIDsWorker(self, autosave): - self.onSigStoreData(self.SegForLostIDsWaitCond, autosave=autosave) - - def onSigTrackManuallyAddedObjectSegForLostIDsWorker( - self, added_IDs, isNewID, wl_update, wl_track_og_curr - ): - self.trackManuallyAddedObject( - added_IDs, isNewID, wl_update=wl_update, wl_track_og_curr=wl_track_og_curr - ) - self.SegForLostIDsWaitCond.wakeAll() - - def onSigUpdateRP( - self, - waitcond, - draw=True, - debug=False, - update_IDs=True, - wl_update=True, - wl_track_og_curr=False, - ): - self.update_rp( - draw=draw, - debug=debug, - update_IDs=update_IDs, - wl_update=wl_update, - wl_track_og_curr=wl_track_og_curr, - ) - waitcond.wakeAll() - - def onSigUpdateRPSegForLostIDsWorker(self, wl_update, wl_track_og_curr): - self.onSigUpdateRP( - self.SegForLostIDsWaitCond, - wl_update=wl_update, - wl_track_og_curr=wl_track_og_curr, - ) - - def previous_model_name(self, df_settings) -> str | None: - try: - return str(df_settings.at[self.settings_key, "value"]) - except KeyError: - return None - - def segForLostIDsButtonClicked(self): - - self.setFrameNavigationDisabled(disable=True, why="Segmentation for lost IDs") - posData = self.data[self.pos_i] - if posData.frame_i == 0: - self.logger.info("Segmentation for lost IDs not available on first frame.") - self.setFrameNavigationDisabled( - disable=False, why="Segmentation for lost IDs" - ) - return - self.storeUndoRedoStates(False) - self.progressWin = apps.QDialogWorkerProgress( - title="Segmenting for lost IDs", - parent=self, - pbarDesc="Segmenting for lost IDs...", - ) - self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(0) - - self.startSegForLostIDsWorker() - - def settings_from_dialog(self, win, base_model_name: str): - init_kwargs_new, args_new = self.split_model_kwargs( - win.init_kwargs, - win.extra_kwargs, - ) - return SegForLostIdsSettings( - win=win, - init_kwargs_new=init_kwargs_new, - args_new=args_new, - base_model_name=base_model_name, - ) - - def should_persist_model_choice(self, base_model_name: str | None) -> bool: - return bool(base_model_name) - - def showImageDebug(self, img): - imshow(img) - - def split_model_kwargs( - self, - init_kwargs: dict[str, Any], - extra_kwargs: dict[str, Any], - ) -> tuple[dict[str, Any], dict[str, Any]]: - extra_param_names = {arg.name for arg in self.extra_arg_specs()} - init_kwargs_new = {} - args_new = {} - - for key, val in init_kwargs.items(): - if key in extra_param_names: - args_new[key] = val - else: - init_kwargs_new[key] = val - - for key, val in extra_kwargs.items(): - if key in extra_param_names: - args_new[key] = val - - return init_kwargs_new, args_new - - def startSegForLostIDsWorker(self): - self.SegForLostIDsMutex = QMutex() - self.SegForLostIDsWaitCond = QWaitCondition() - self._thread = QThread() - - # Initialize the worker with mutex and wait condition - self.SegForLostIDsWorker = workers.SegForLostIDsWorker( - self, self.SegForLostIDsMutex, self.SegForLostIDsWaitCond - ) - - # Connect the worker's signal to the main thread's slot - self.SegForLostIDsWorker.sigAskInit.connect(self.onSegForLostInit) - self.SegForLostIDsWorker.sigAskInstallModel.connect( - self.SegForLostIDsWorkerAskInstallModel - ) - self.SegForLostIDsWorker.sigshowImageDebug.connect(self.showImageDebug) - - self.SegForLostIDsWorker.sigSegForLostIDsWorkerAskInstallGPU.connect( - self.SegForLostIDsWorkerAskInstallGPU - ) - - self.SegForLostIDsWorker.sigStoreData.connect( - self.onSigStoreDataSegForLostIDsWorker - ) - self.SegForLostIDsWorker.sigUpdateRP.connect( - self.onSigUpdateRPSegForLostIDsWorker - ) - # self.SegForLostIDsWorker.sigGetData.connect(self.onSigGetDataSegForLostIDsWorker) - # self.SegForLostIDsWorker.sigGet2Dlab.connect(self.onSigGet2DlabSegForLostIDsWorker) - # self.SegForLostIDsWorker.sigGetTrackedLostIDs.connect(self.onSigGetTrackedSegForLostIDsWorker) - # self.SegForLostIDsWorker.sigGetBrushID.connect(self.onSigGetBrushIDSegForLostIDsWorker) - self.SegForLostIDsWorker.sigTrackManuallyAddedObject.connect( - self.onSigTrackManuallyAddedObjectSegForLostIDsWorker - ) - - # Move the worker to the thread - self.SegForLostIDsWorker.moveToThread(self._thread) - - # Manage thread lifecycle - self.SegForLostIDsWorker.signals.finished.connect(self._thread.quit) - self.SegForLostIDsWorker.signals.finished.connect( - self.SegForLostIDsWorker.deleteLater - ) - self._thread.finished.connect(self._thread.deleteLater) - - # Connect other worker signals to the appropriate slots - self.SegForLostIDsWorker.signals.finished.connect( - self.SegForLostIDsWorkerFinished - ) - self.SegForLostIDsWorker.signals.progress.connect(self.workerProgress) - self.SegForLostIDsWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.SegForLostIDsWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.SegForLostIDsWorker.signals.critical.connect(self.workerCritical) - - # Start the thread and worker - self._thread.started.connect(self.SegForLostIDsWorker.run) - self._thread.start() diff --git a/cellacdc/mixins_bak/status_hover.py b/cellacdc/mixins_bak/status_hover.py deleted file mode 100644 index 1760d0fa2..000000000 --- a/cellacdc/mixins_bak/status_hover.py +++ /dev/null @@ -1,269 +0,0 @@ -"""View adapter for hover and status-bar formatting.""" - -from __future__ import annotations - - -import math -import os -import re - - -class StatusHoverMixin: - """Qt-facing adapter around status/hover view-model contracts.""" - - """Headless status-bar and hover formatting rules.""" - - def active_tool_button(self): - for button in self.LeftClickButtons: - if button.isChecked(): - return button - - def add_overlay_hover_values_formatted(self, txt, xdata, ydata): - pos_data = self.data[self.pos_i] - if pos_data.ol_data is None: - return txt - - for filename in pos_data.ol_data: - ch_name = self.view_model.formatting.channel_name_from_basename( - filename, pos_data.basename, remove_ext=False - ) - if ch_name not in self.checkedOverlayChannels: - continue - - raw_overlay_img = self.getRawImage(filename=filename) - raw_overlay_value = raw_overlay_img[ydata, xdata] - raw_txt = self.channel_hover_values("Raw", ch_name, raw_overlay_value) - txt = f"{txt} | {raw_txt}" - return txt - - def add_ruler_measurement_text(self, txt): - pos_data = self.data[self.pos_i] - xx, yy = self.ax1_rulerPlotItem.getData() - if xx is None: - return txt - - length_pixels = self.euclidean_length(xx, yy) - depth_axes = self.switchPlaneCombobox.depthAxes() - if depth_axes != "z": - pixel_to_um = pos_data.PhysicalSizeZ - else: - pixel_to_um = pos_data.PhysicalSizeX - - length_txt = self.ruler_measurement_text( - length_pixels=length_pixels, - pixel_to_um=pixel_to_um, - ) - return f"{txt} | Measurement: {length_txt}" - - def base_hover_text( - self, - *, - x, - y, - width, - height, - x_left, - y_top, - x_right, - y_bottom, - axis_index, - ): - return ( - f"x={x:d}, y={y:d} | " - f"W={width:d}, H={height:d} | " - f"x_left={x_left:d}, y_top={y_top:d} | " - f"x_right={x_right:d}, y_bottom={y_bottom:d} | " - f"(ax{axis_index})" - ) - - def channel_hover_text(self, description, channel, value, format_spec): - return f"{description} {channel}: value={value:{format_spec}}" - - def channel_hover_values(self, descr, channel, value, ff=None): - if ff is None: - n_digits = len(str(int(value))) - ff = self.view_model.formatting.number_fstring_formatter( - type(value), precision=abs(n_digits - 5) - ) - return self.channel_hover_text(descr, channel, value, ff) - - def check_highlight_scale_bar(self, x, y, active_tool_button): - if not hasattr(self, "scaleBar"): - return - highlighted = self.highlight_state( - x=x, - y=y, - bbox=self.scaleBar.bbox(), - enabled=self.addScaleBarAction.isChecked(), - active_tool=active_tool_button, - ) - if highlighted is None: - return - self.scaleBar.setHighlighted(highlighted) - - def check_highlight_timestamp(self, x, y, active_tool_button): - if not hasattr(self, "timestamp"): - return - blocked_by_scale_bar = ( - hasattr(self, "scaleBar") and self.scaleBar.isHighlighted() - ) - highlighted = self.highlight_state( - x=x, - y=y, - bbox=self.timestamp.bbox(), - enabled=self.addTimestampAction.isChecked(), - active_tool=active_tool_button, - blocked_by_other_highlight=blocked_by_scale_bar, - ) - if highlighted is None: - return - self.timestamp.setHighlighted(highlighted) - - def concat_acdc_df(self): - pos_data = self.data[self.pos_i] - return self.view_model.frame_metadata.concat_visited_acdc_frames( - pos_data.allData_li - ) - - def euclidean_length(self, x_values, y_values): - return math.sqrt( - (x_values[0] - x_values[1]) ** 2 + (y_values[0] - y_values[1]) ** 2 - ) - - def highlight_state( - self, - *, - x, - y, - bbox, - enabled, - active_tool, - blocked_by_other_highlight=False, - ): - if not enabled or active_tool is not None or blocked_by_other_highlight: - return None - y_min, x_min, y_max, x_max = bbox - return x_min <= x <= x_max and y_min <= y <= y_max - - def hover_values_formatted(self, xdata, ydata, active_tool_button, is_ax0): - (xl, xr), (yt, yb) = self.display_decorations_view.ax1_view_range(integers=True) - width = round(xr - xl) - height = round(yb - yt) - axis_index = 0 if is_ax0 else 1 - txt = self.base_hover_text( - x=xdata, - y=ydata, - width=width, - height=height, - x_left=xl, - y_top=yt, - x_right=xr, - y_bottom=yb, - axis_index=axis_index, - ) - if active_tool_button == self.rulerButton: - return self.add_ruler_measurement_text(txt) - if active_tool_button is not None: - return txt - - pos_data = self.data[self.pos_i] - raw_img = self.getRawImage() - raw_value = raw_img[ydata, xdata] - raw_txt = self.channel_hover_values("Raw", self.user_ch_name, raw_value) - txt = f"{txt} | {raw_txt}" - txt = self.add_overlay_hover_values_formatted(txt, xdata, ydata) - - label_id = self.currentLab2D[ydata, xdata] - label_txt = self.object_hover_text( - label_id=label_id, - max_id=max(pos_data.IDs, default=0), - object_count=len(pos_data.IDs), - ) - txt = f"{txt} | {label_txt}" - return self.add_ruler_measurement_text(txt) - - def mouse_data_coords_right_image(self): - return self.mouse_data_coords_right_image(self.wcLabel.text()) - - def object_hover_text(self, *, label_id, max_id, object_count): - return ( - f"Objects: ID={label_id}, max ID={max_id}, " - f"num. of objects={object_count}" - ) - - def replace_view_range_status( - self, - text, - *, - width, - height, - x_left, - y_top, - x_right, - y_bottom, - ): - pattern = ( - r"W=.*?, H=.*? \| " - r"x_left=.*?, y_top=.*? \| " - r"x_right=.*?, y_bottom=.*? \| " - ) - replacing = ( - f"W={width:d}, H={height:d} | " - f"x_left={x_left:d}, y_top={y_top:d} | " - f"x_right={x_right:d}, y_bottom={y_bottom:d} | " - ) - return re.sub(pattern, replacing, text) - - def ruler_length_text(self): - return self.ruler_length_text(self.wcLabel.text()) - - def ruler_measurement_text(self, *, length_pixels, pixel_to_um): - return ( - f"length = {int(length_pixels)} pxl ({length_pixels * pixel_to_um:.2f} μm)" - ) - - def set_status_bar_label(self, log=True): - self.statusbar.clearMessage() - pos_data = self.data[self.pos_i] - txt = self.status_bar_text( - pos_foldername=pos_data.pos_foldername, - basename=pos_data.basename, - filename=pos_data.filename, - segm_npz_path=pos_data.segm_npz_path, - ) - if log: - self.logger.info(txt) - self.statusBarLabel.setText(txt) - - def status_bar_text( - self, - *, - pos_foldername, - basename, - filename, - segm_npz_path, - ): - segmented_channel_name = filename[len(basename) :] - segm_filename = os.path.basename(segm_npz_path) - segm_end_name = segm_filename[len(basename) :] - return ( - f"{pos_foldername} || " - f"Basename: {basename} || " - f"Segmented channel: {segmented_channel_name} || " - f"Segmentation file name: {segm_end_name}" - ) - - def update_values_status_bar(self): - (xl, xr), (yt, yb) = self.display_decorations_view.ax1_view_range(integers=True) - width = round(xr - xl) - height = round(yb - yt) - txt = self.replace_view_range_status( - self.wcLabel.text(), - width=width, - height=height, - x_left=xl, - y_top=yt, - x_right=xr, - y_bottom=yb, - ) - self.wcLabel.setText(txt) diff --git a/cellacdc/mixins_bak/tables.py b/cellacdc/mixins_bak/tables.py deleted file mode 100644 index ffa505327..000000000 --- a/cellacdc/mixins_bak/tables.py +++ /dev/null @@ -1,17 +0,0 @@ -"""View-model commands for table normalization.""" - -from __future__ import annotations - -import pandas as pd - -from cellacdc.myutils import checked_reset_index_Cell_ID, fix_acdc_df_dtypes - - -class TableMixin: - """Application-facing commands for dataframe normalization.""" - - def checked_reset_index_cell_id(self, dataframe: pd.DataFrame) -> pd.DataFrame: - return checked_reset_index_Cell_ID(dataframe) - - def fix_acdc_df_dtypes(self, dataframe: pd.DataFrame) -> pd.DataFrame: - return fix_acdc_df_dtypes(dataframe) diff --git a/cellacdc/mixins_bak/whitelist.py b/cellacdc/mixins_bak/whitelist.py deleted file mode 100644 index 7827790ac..000000000 --- a/cellacdc/mixins_bak/whitelist.py +++ /dev/null @@ -1,1268 +0,0 @@ -"""Qt view adapter for the Whitelist feature.""" - -from __future__ import annotations - -import os -import numpy as np -from typing import Set, List -import skimage.measure -from typing import Tuple -import time - -from cellacdc import printl, html_utils, apps, widgets, exception_handler, disableWindow -from cellacdc.trackers.CellACDC import CellACDC_tracker -from cellacdc.whitelist import Whitelist - - -class WhitelistMixin: - """Qt-facing adapter for the Whitelist feature.""" - - """Headless decisions and calculations for Whitelist management.""" - - def apply_id_mask( - self, - curr_lab: np.ndarray, - og_lab: np.ndarray | None, - missing_ids: list[int] | np.ndarray, - to_be_removed_ids: list[int] | np.ndarray, - ) -> np.ndarray: - """Applies missing and removed ID masks to the label array.""" - updated_lab = curr_lab.copy().astype(np.int32) - missing_ids = np.array(missing_ids, dtype=np.int32) - to_be_removed_ids = np.array(to_be_removed_ids, dtype=np.int32) - - if missing_ids.size > 0 and og_lab is not None: - mask = np.isin(og_lab, missing_ids) - updated_lab[mask] = og_lab[mask] - - if to_be_removed_ids.size > 0: - updated_lab[np.isin(updated_lab, to_be_removed_ids)] = 0 - - return updated_lab - - def check_original_labels(self, whitelist_obj, frame_i: int) -> bool: - """Checks if original label data is allocated and valid for the frame.""" - if whitelist_obj is None: - return False - if whitelist_obj.originalLabsIDs is None: - return False - if ( - frame_i >= len(whitelist_obj.originalLabsIDs) - or whitelist_obj.originalLabsIDs[frame_i] is None - ): - return False - return True - - def construct_og_frame( - self, - pos_lab: np.ndarray, - og_frame_base: np.ndarray, - whitelist_ids: Set[int], - og_ids: Set[int], - ) -> np.ndarray: - """Constructs original labels overlay using np.isin masking.""" - og_frame = og_frame_base.copy() - - ids_to_update = whitelist_ids & og_ids - if ids_to_update: - mask = np.isin(og_frame, list(ids_to_update)) - og_frame[mask] = 0 - mask = np.isin(pos_lab, list(ids_to_update)) - og_frame[mask] = pos_lab[mask] - - ids_to_add = whitelist_ids - og_ids - if ids_to_add: - mask = np.isin(pos_lab, list(ids_to_add)) - og_frame[mask] = pos_lab[mask] - - return og_frame - - def filter_existing_ids( - self, current_whitelist: Set[int], possible_ids: Set[int] - ) -> tuple[Set[int], bool]: - """Filters out non-existing IDs from the current whitelist. - - Returns a tuple: (filtered_whitelist, is_any_id_non_existing) - """ - is_any_id_non_existing = False - filtered_whitelist = set(current_whitelist) - for ID in current_whitelist: - if ID not in possible_ids: - is_any_id_non_existing = True - filtered_whitelist.discard(ID) - return filtered_whitelist, is_any_id_non_existing - - def get_diff_ids( - self, old_ids: Set[int], prev_ids: Set[int], new_ids: Set[int] - ) -> Set[int]: - """Computes tracking difference intersection (new_ids - old_ids) & prev_ids.""" - return (new_ids - old_ids) & prev_ids - - def get_frames_range(self, frame_i: int) -> list[int]: - """Calculates navigation frame ranges for label loading.""" - if frame_i > 0: - return [frame_i - 1, frame_i] - return [frame_i] - - def get_missing_ids( - self, current_ids: Set[int], previous_ids: Set[int] - ) -> Set[int]: - """Returns the set of IDs present in current frame but missing from previous frame.""" - return set(current_ids) - set(previous_ids) - - def get_whitelist_missing_and_removed_ids( - self, whitelist: Set[int], current_ids: Set[int] - ) -> tuple[list[int], list[int]]: - """Finds IDs that are missing from current_ids and IDs to be removed from current_ids.""" - missing_ids = list(whitelist - current_ids) - to_be_removed_ids = list(current_ids - whitelist) - return missing_ids, to_be_removed_ids - - def whitelistAddNewIDs(self, ignore_not_first_time: bool = False): - """Function which adds new IDs to the whitelist, based on the original labels. - It will check if the frame is visited the first time, unless - ignore_not_first_time is True. - It does nothing if self.addNewIDsWhitelistToggle is False. - !!!Careful, does not change the lab, just the whitelist!!! - - Parameters - ---------- - ignore_not_first_time : bool, optional - Weather it should be checked if the frame is visited - the first time, by default False - """ - mode = self.modeComboBox.currentText() - if mode != "Segmentation and Tracking": - return - - if not self.addNewIDsWhitelistToggle: - return - - posData = self.data[self.pos_i] - if posData.whitelist is None: - return - - debug = posData.whitelist._debug - - if debug: - printl("whitelistAddNewIDs") - - posData = self.data[self.pos_i] - frame_i = posData.frame_i - - if self.get_last_tracked_i() > frame_i and not ignore_not_first_time: - return - - if frame_i == 0: - return - - if ( - self.whitelistAddNewIDsFrame is not None - and frame_i == self.whitelistAddNewIDsFrame - ): - return - - self.whitelistAddNewIDsFrame = frame_i - - curr_lab = self.get_curr_lab() - - posData.whitelist.addNewIDs( - frame_i=frame_i, - allData_li=posData.allData_li, - IDs_curr=posData.IDs, - curr_lab=curr_lab, - ) - - def whitelistAddNewIDsToggled(self, checked: bool): - """Will set self.addNewIDsWhitelistToggle to checked and call - whitelistAddNewIDs if checked is True. - - Parameters - ---------- - checked : bool - True if the add new IDs toggle is checked, False otherwise. - """ - self.addNewIDsWhitelistToggle = checked - if checked: - self.df_settings.at["addNewIDsWhitelistToggle", "value"] = "Yes" - else: - self.df_settings.at["addNewIDsWhitelistToggle", "value"] = "No" - self.df_settings.to_csv(self.settings_csv_path) - if checked: - self.whitelistAddNewIDs(ignore_not_first_time=True) - self.whitelistPropagateIDs() - self.updateAllImages() - self.whitelistIDsUpdateText() - - def whitelistCheckOriginalLabels(self, warning: bool = True, frame_i: int = None): - """Warns the user that there are no original labels labels are present - for the frame""" - posData = self.data[self.pos_i] - if posData.whitelist is None: - return False - - if frame_i is None: - frame_i = posData.frame_i - - if posData.whitelist.originalLabsIDs is None: - return False - - if ( - frame_i >= len(posData.whitelist.originalLabsIDs) - or posData.whitelist.originalLabsIDs[frame_i] is None - ): - txt = """ - No original labels are present for the current frame, - this action cannot be performed.""" - self.logger.warning(txt) - if not warning: - return False - widgets.myMessageBox.warning( - self, - "No original labels", - txt, - ) - - return False - else: - return True - - def whitelistHighlightIDs(self, checked: bool = True): - """Highlights the IDs in the current frame based on the whitelist. - - Parameters - ---------- - checked : bool, optional - If False, will delete all highlights, by default True - """ - if not checked: - self.removeHighlightLabelID() - return - - posData = self.data[self.pos_i] - - if posData.whitelist is None: - if not hasattr(self, "tempWhitelistIDs"): - self.tempWhitelistIDs = set() # not updated, only use in this context - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = posData.whitelist.get(frame_i=posData.frame_i) - - for ID in current_whitelist: - self.highlightLabelID(ID) - - def whitelistIDsAccepted(self, whitelistIDs: Set[int] | List[int]): - """Function which is called when the user accepts a whitelist. - Also initializes the whitelist if it is not already initialized. (Aka not loaded) - - Parameters - ---------- - whitelistIDs : set | list - The accepted IDs from the whitelist dialog. - """ - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) - self.whitelistSetViewOGIDsToggle(False) - self.setFrameNavigationDisabled(False, why="Viewing original labels") - - self.store_data(autosave=False) - - posData = self.data[self.pos_i] - - if not posData.whitelist: - posData.whitelist = Whitelist( - total_frames=posData.SizeT, - ) - - if posData.whitelist._debug: - printl("whitelistIDsAccepted", whitelistIDs) - - whitelistIDs = set(whitelistIDs) - - IDs_curr = set(posData.IDs) - - posData.whitelist.IDsAccepted( - whitelistIDs, - segm_data=posData.segm_data, - frame_i=posData.frame_i, - allData_li=posData.allData_li, - IDs_curr=IDs_curr, - curr_lab=posData.lab, - ) - - # self.whitelistPropagateIDs(new_whitelist=whitelistIDs, - # try_create_new_whitelists=True, - # only_future_frames=True, - # force_not_dynamic_update=True, - # update_lab=True - # ) - self.whitelistUpdateLab(track_og_curr=True) - - self.whitelistIDsUpdateText() - self.keepIDsTempLayerLeft.clear() - - def whitelistIDsChanged( - self, whitelistIDs: Set[int] | List[int], debug: bool = False - ): - """Callback for when the whitelist IDs are changed. - This is called when the user changed the IDs in the whitelist IDs toolbar - (or when its programmatically changed, but if its not - visible it should return instantly) - Will update the temp layer and also complain when IDs - are not valid/present in the current lab - - Parameters - ---------- - whitelistIDs : set | list - The IDs that are currently in the whitelist. - debug : bool, optional - debug, by default False - """ - if not self.whitelistIDsButton.isChecked(): - return - - posData = self.data[self.pos_i] - - if posData.whitelist: - debug = posData.whitelist._debug - if debug: - printl("whitelistIDsChanged", whitelistIDs) - - if posData.whitelist is None: - wl_init = False - if not hasattr(self, "tempWhitelistIDs"): - self.tempWhitelistIDs = set() # not updated, only use in this context - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = self.tempWhitelistIDs - else: - wl_init = True - current_whitelist = posData.whitelist.get(frame_i=posData.frame_i) - - current_whitelist_copy = current_whitelist.copy() - if ( - not hasattr(posData, "originalLabsIDs") - or posData.whitelist.originalLabsIDs is None - ): - possible_IDs = posData.IDs.copy() - else: - if not self.whitelistCheckOriginalLabels(warning=False): - possible_IDs = set(posData.IDs) - else: - possible_IDs = posData.whitelist.originalLabsIDs[posData.frame_i] - possible_IDs.update(posData.IDs) - - isAnyIDnotExisting = False - for ID in whitelistIDs: - if ID not in possible_IDs: - isAnyIDnotExisting = True - continue - if ID not in current_whitelist_copy: - current_whitelist.add(ID) - self.highlightLabelID(ID) - - for ID in current_whitelist_copy: - if ID not in possible_IDs: - isAnyIDnotExisting = True - continue - if ID not in whitelistIDs: - current_whitelist.remove(ID) - self.removeHighlightLabelID(IDs=[ID]) - - if wl_init: - posData.whitelist.whitelistIDs[posData.frame_i] = current_whitelist - else: - self.tempWhitelistIDs = current_whitelist - - self.whitelistUpdateTempLayer() - if isAnyIDnotExisting: - self.whitelistIDsToolbar.whitelistLineEdit.warnNotExistingID() - else: - self.whitelistIDsToolbar.whitelistLineEdit.setInstructionsText() - - def whitelistIDsUpdateText(self): - """Updates the text. Carefull, triggers whitelistLineEdit.textChanged!""" - mode = self.modeComboBox.currentText() - if mode != "Segmentation and Tracking": - return - - posData = self.data[self.pos_i] - if posData.whitelist is None: - return - - if posData.whitelist._debug: - printl("whitelistIDsUpdateText") - - frame_i = posData.frame_i - whitelist = posData.whitelist.get(frame_i=frame_i) - - self.whitelistIDsToolbar.whitelistLineEdit.setText(whitelist) - - def whitelistIDs_cb(self, checked: bool): - """Callback for when the whitelist IDs button is checked or unchecked. - Initialises the pointlayer and the whitelist IDs toolbar if checked. - - Parameters - ---------- - checked : bool - True if the whitelist IDs button is checked, False otherwise. - """ - if checked: - self.initKeepObjLabelsLayers() - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.whitelistIDsButton) - self.connectLeftClickButtons() - - self.whitelistIDsToolbar.setVisible(checked) - self.whitelistHighlightIDs(checked) - self.whitelistIDsUpdateText() - self.whitelistUpdateTempLayer() - - if not checked: - self.setLostNewOldPrevIDs() - self.updateAllImages() - - def whitelistInitNewFrames(self, frame_i: int = None, force: bool = False): - """Initialize the whitelist for a new frame. The class whitelist keeps track - of the init frames and doesnt try to init them again, unless forced. - Does not init the class! - - Parameters - ---------- - frame_i : int, optional - frame_i to be init, posData.frame_i if not provided, by default None - force : bool, optional - if the init should be forced, by default False - - Returns - ------- - bool - if the frame was new or not - list - list of frames that were updated, and info about added/removed IDs - """ - - posData = self.data[self.pos_i] - if posData.whitelist is None: - return False, [] - - if frame_i is None: - frame_i = posData.frame_i - - if posData.whitelist._debug: - printl("whitelistInitNewFrames", frame_i, force) - - if frame_i not in posData.whitelist.initialized_i: - self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=True) - - new_frame, update_frames = posData.whitelist.initNewFrames( - frame_i=frame_i, force=force - ) - - self.whitelistAddNewIDs() - return new_frame, update_frames - - @disableWindow - def whitelistLoadOGLabs(self, selected: str): - """Loads the original labels from the selected files - - Parameters - ---------- - selected : str - Selected file name from the dialog. - """ - posData = self.data[self.pos_i] - images_path = posData.images_path - - selected_path = os.path.join(images_path, selected) - posData.whitelist.loadOGLabs(selected_path) - - self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) - - def whitelistLoadOGLabs_cb(self): - """Generates a dialog to load the original (not whitelisted) labels""" - posData = self.data[self.pos_i] - curr_seg_path = posData.segm_npz_path - - segmFilename = os.path.basename(curr_seg_path) - custom_first = f"{segmFilename[:-4]}_not_whitelisted.npz" - images_path = posData.images_path - existingEndnames = [ - files for files in os.listdir(images_path) if files.endswith(".npz") - ] - if custom_first not in existingEndnames: - custom_first = None - - infoText = html_utils.paragraph( - "Select the segmentation file containing the original labels " - 'of the objects. Pleae note that the current saved "original" ' - "labels will be replaced with the new ones, but the filtered " - "labels will be kept." - ) - - win = apps.SelectSegmFileDialog( - existingEndnames, - images_path, - parent=self, - basename=posData.basename, - infoText=infoText, - custom_first=custom_first, - ) - win.exec_() - if win.cancel: - self.logger.info("Loading original labels canceled.") - return - selected = win.selectedItemText - self.logger.info(f"Loading original labels from {selected}...") - self.whitelistLoadOGLabs(selected) - - def whitelistPropagateIDs( - self, - new_whitelist: Set[int] | List[int] = None, - IDs_to_add: Set[int] = None, - IDs_to_remove: Set[int] = None, - frame_i: int = None, - try_create_new_whitelists: bool = False, - curr_frame_only: bool = False, - force_not_dynamic_update: bool = False, - only_future_frames: bool = True, - allow_only_current_IDs: bool = False, - track_og_curr: bool = True, - IDs_curr: Set[int] | List[int] = None, - index_lab_combo: Tuple[int, np.ndarray] = None, - curr_rp: list = None, - curr_lab: np.ndarray = None, - store_data: bool = True, - update_lab: bool = False, - ): - """ - Propagates whitelist IDs across frames in the dataset. (Doesnt update labs) - Should also be called when viewing a new frame! - - This function updates whitelist. If curr_frame_only is True, it only updates the - whitelist of the current frame. If the frame changes, this function should be called - again to update the whitelist for the new frame (without this argument). - It should also handle cases were this is not done, but this is less safe. - Then, all the additions and removals are propagated to the other frames. - If force_not_dynamic_update is True, the function will propagate the entire whitelist to - frames, and not only the IDs which were added or removed. - - Hierarchy of arguments for current_IDs: - 1. IDs_curr (if provided) - (2. index_lab_combo (if provided) (is also passed to not current frame only - propagation if that propagation is necessary, and used when the frame_i matches)) - 3. curr_rp (if provided) - 4. curr_lab (if provided) - 5. allData_li - - Parameters - ---------- - new_whitelist : Set[int] | List[int], optional - A new set of whitelist IDs to replace the current whitelist. Cannot be - used together with `IDs_to_add` or `IDs_to_remove`, by default None. - IDs_to_add : Set[int], optional - A set of IDs to add to the current whitelist, by default None. - IDs_to_remove : Set[int], optional - A set of IDs to remove from the current whitelist, by default None. - frame_i : int, optional - The frame index for the propagation. - If None, uses posData.frame_i, by default None. - try_create_new_whitelists : bool, optional - If True, creates new whitelist entries for frames that do not already - have them. Should only be necessary when its initialized, by default False. - curr_frame_only : bool, optional - If True, only updates the whitelist for the current frame. - (See description of function), by default False. - force_not_dynamic_update : bool, optional - If True, disables dynamic updates to the whitelist. - (See description of function), by default False. - only_future_frames : bool, optional - If True, propagates changes only to future frames, by default True. - allow_only_current_IDs : bool, optional - If True, only allows IDs that are present in the current frame - to be added to the whitelist, by default True. - track_og_curr : bool, optional - If True, tracks the original labels in relation to the current - (whitelisted) labels. This is done by calling whitelistTrackOGCurr. - If its a new frame, this is done in whitelistInitNewFrames against the - previous frame, - by default True. - IDs_curr : Set[int] | List[int], optional - A set of IDs for the current frame, if None, - will be calculated from other stuff (see description), by default None. - index_lab_combo : Tuple[int, np.ndarray], optional - Combination of frame_i and current frame, - Used to get IDs_curr (see description), when the frame_i matches - (is also passed to not current frame only - propagation if that propagation is necessary, - and used when the frame_i matches), by default None. - curr_rp : list, optional - Region properties for the current frame. For IDs_curr. (see description), - by default None. - curr_lab : np.ndarray, optional - Labels for the current frame for IDs_curr. (see description), - by default None. - store_data : bool, optional - If True, stores the data before propagating the IDs. - update_lab : bool, optional - If True, updates the labels after propagating the IDs. - Will always update labels for newly init frames, by default False. - - Raises - ------ - ValueError - If both `new_whitelistIDs` and `IDs_to_add`/`IDs_to_remove` are provided. - - Example - ------- - To add IDs 5 and 6 to the whitelist for the current frame: - ```python - self.whitelistPropagateIDs(IDs_to_add={5, 6}, curr_frame_only=True) - ``` - Then when the frame changes: - ```python - self.whitelistPropagateIDs() - ``` - - To replace the whitelist for frame 10 with a new set of IDs: - ```python - self.whitelistPropagateIDs(new_whitelistIDs={1, 2, 3}, frame_i=10) - ``` - This would also propagate the changes to all other frames. - - """ - # doesnt update the frame displayed, only wl - try: # safety XD - IDs_curr = IDs_curr.copy() - except AttributeError: - pass - - IDs_curr = set(IDs_curr) if IDs_curr is not None else None - - posData = self.data[self.pos_i] - - debug = posData.whitelist._debug if posData.whitelist is not None else False - - if debug: - printl("Propagating IDs...") - from . import debugutils - - debugutils.print_call_stack() - printl(new_whitelist, IDs_to_add, IDs_to_remove) - - if posData.whitelist is None: - return - - # og_frame_i = posData.frame_i - if frame_i is None: - frame_i = posData.frame_i - - new_frame, update_frames_init = self.whitelistInitNewFrames(frame_i=frame_i) - - if new_frame: - self.update_rp(wl_update=False) - # if track_og_curr and not new_frame: - # self.whitelistTrackOGCurr(frame_i=frame_i, rp=curr_rp, lab=curr_lab) - - update_frames = posData.whitelist.propagateIDs( - frame_i, - posData.allData_li, - new_whitelist=new_whitelist, - IDs_to_add=IDs_to_add, - IDs_to_remove=IDs_to_remove, - try_create_new_whitelists=try_create_new_whitelists, - curr_frame_only=curr_frame_only, - force_not_dynamic_update=force_not_dynamic_update, - only_future_frames=only_future_frames, - allow_only_current_IDs=allow_only_current_IDs, - IDs_curr=IDs_curr, - index_lab_combo=index_lab_combo, - curr_rp=curr_rp, - curr_lab=curr_lab, - ) - if update_lab: - update_frames = update_frames_init + update_frames - else: - update_frames = update_frames_init - # printl(posData.whitelistIDs[frame_i]) - # posData.frame_i = og_frame_i - self.whitelistIDsUpdateText() - if store_data: - self.store_data(autosave=False) - - for frame_i, IDs_to_add, IDs_to_remove, new_frame in update_frames: - self.whitelistUpdateLab( - frame_i=frame_i, - track_og_curr=track_og_curr, - new_frame=new_frame, - IDs_to_add=IDs_to_add, - IDs_to_remove=IDs_to_remove, - ) - - def whitelistSetViewOGIDsToggle(self, checked: bool): - """Set the view original labels toggle button to checked or unchecked. - This also updates the self.viewOriginalLabels variable. - !!! Doesn't change the actually displayed labels, use self.whitelistViewOGIDs - to do that.!!! - - Parameters - ---------- - checked : bool - True if the original labels are shown, False otherwise. - """ - self.viewOriginalLabels = checked - self.whitelistIDsToolbar.viewOGToggle.blockSignals(True) - self.whitelistIDsToolbar.viewOGToggle.setChecked(checked) - self.whitelistIDsToolbar.viewOGToggle.blockSignals(False) - - def whitelistSyncIDsOG( - self, - frame_is: List[int] = None, - against_prev: bool = False, - ): - """Interates over the frames and calls whitelistTrackOGCurr for each frame. - - Parameters - ---------- - frame_is : List[int], optional - list of frame_i, if None goes through all, by default None - against_prev : bool, optional - if the original frame should be tracked against frame_i-1. - """ - posData = self.data[self.pos_i] - if frame_is is None: - frame_is = range(posData.SizeT) - - for frame_i in frame_is: - self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=against_prev) - - def whitelistTrackCurrOG(self, frame_i: int = None, against_prev: bool = False): - """Track the current (whitelisted) labels in relation to the original labels. - Parameters - ---------- - frame_i : int, optional - frame_i to be tracked, posData.frame_i if not provided, by default None - against_prev : bool, optional - if the original frame should be tracked against frame_i-1. - """ - posData = self.data[self.pos_i] - if posData.whitelist is None: - return - - if posData.whitelist._debug: - printl("whitelistTrackCurrOG", frame_i, against_prev) - - if frame_i is None: - frame_i = posData.frame_i - - if against_prev and frame_i == 0: - return - - og_frame = posData.frame_i - if frame_i != og_frame: - self.store_data(autosave=False) - posData.frame_i = frame_i - self.get_data() - - lab = posData.lab - rp = posData.rp - - if not self.whitelistCheckOriginalLabels( - warning=False, frame_i=frame_i if not against_prev else frame_i - 1 - ): - if posData.whitelist._debug: - printl("No original labels, cannot track.") - return - - if against_prev: - og_lab = posData.whitelist.originalLabs[frame_i - 1] - else: - og_lab = posData.whitelist.originalLabs[frame_i] - - og_rp = skimage.measure.regionprops(og_lab) - - denom_overlap_matrix = "union" if not against_prev else "area_prev" - - lab = CellACDC_tracker.track_frame( - og_lab, - og_rp, - lab, - rp, - denom_overlap_matrix=denom_overlap_matrix, - posData=posData, - setBrushID_func=self.setBrushID, - ) - - posData.lab = lab - - self.update_rp(wl_update=False) - self.store_data(autosave=False) - - if frame_i != og_frame: - posData.frame_i = og_frame - self.get_data() - - def whitelistTrackOGCurr( - self, - frame_i: int = None, - against_prev: bool = False, - lab: np.ndarray = None, - rp: list = None, - IDs: Set[int] | List[int] = None, - ): - """Track the original labels in relation to the current (whitelisted) - labels. - Parameters - - Parameters - ---------- - frame_i : int, optional - frame_i to be tracked, posData.frame_i if not provided, - by default None - against_prev : bool, optional - if the original frame should be tracked against frame_i-1. - Cannot be used with rp or lab, by default False - lab : np.ndarray, optional - lab to be tracked against, by default None - rp : list, optional - regionprops for this lab, by default None - IDs : Set[int] | List[int], optional - IDs that should be tracked based on og - - Raises - ------ - ValueError - Cannot provide both rp and lab when tracking against previous frame. - Instead only provide rp and lab, and dont set against_prev. - """ - posData = self.data[self.pos_i] - if posData.whitelist is None: - return - - debug = posData.whitelist._debug - - if debug: - from . import debugutils - - debugutils.print_call_stack(depth=2) - printl("whitelistTrackOGCurr", against_prev) - - if against_prev and (rp is not None or lab is not None): - raise ValueError( - "Cannot provide both rp and lab when tracking" - " against previous frame." - "Instead only provide rp and lab, and dont set against_prev." - ) - - if frame_i is None: - frame_i = posData.frame_i - - if against_prev and frame_i == 0: - return - - if not self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): - if debug: - printl("No original labels, cannot track.") - return - - og_frame_i = posData.frame_i - ### against what should I track? - - if lab is not None and not rp: - rp = skimage.measure.regionprops(lab) - - changed_frame = False - if lab is None: - if debug: - printl("No lab and no rp provided.") - if against_prev: - rp = posData.allData_li[frame_i - 1]["regionprops"] - lab = posData.allData_li[frame_i - 1]["labels"] - else: - if frame_i != og_frame_i: - self.store_data(autosave=False) - posData.frame_i = frame_i - self.get_data() - changed_frame = True - rp = posData.rp - lab = posData.lab - og_lab = posData.whitelist.originalLabs[frame_i] - og_rp = skimage.measure.regionprops(og_lab) - # lab = lab.copy() - - denom_overlap_matrix = "union" if not against_prev else "area_prev" - - og_lab = CellACDC_tracker.track_frame( - lab, - rp, - og_lab, - og_rp, - denom_overlap_matrix=denom_overlap_matrix, - posData=posData, - setBrushID_func=self.setBrushID, - IDs=IDs, - # assign_unique_new_IDs=False, - ) - - posData.whitelist.originalLabs[frame_i] = og_lab - posData.whitelist.originalLabsIDs[frame_i] = { - obj.label for obj in skimage.measure.regionprops(og_lab) - } - - if changed_frame: - posData.frame_i = og_frame_i - self.get_data() - - @disableWindow - def whitelistTrackOGagainstPreviousFrame_cb(self, signal_slot=None): - """Tracks the original labels against the previous frame. - This is used as a callback for sigTrackOGagainstPreviousFrame signal - """ - posData = self.data[self.pos_i] - frame_i = posData.frame_i - if not self.whitelistCheckOriginalLabels(): - return - old_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] - prev_cell_IDs = posData.allData_li[frame_i - 1]["IDs"] - self.whitelistTrackOGCurr(against_prev=True) - new_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] - - new_IDs = new_cell_IDs - old_cell_IDs - new_IDs = new_IDs & set(prev_cell_IDs) - - self.whitelistUpdateLab( - track_og_curr=False, - IDs_to_add=new_IDs, - ) - - def whitelistUpdateLab( - self, - frame_i: int = None, - track_og_curr=False, - new_frame: bool = False, - IDs_to_add: List[int] | Set[int] = None, - IDs_to_remove: List[int] | Set[int] = None, - ): - # this should also work for 3D i think... - """Updates the displayed lab based on the whitelist. - - Parameters - ---------- - frame_i : int, optional - frame which should be updated. If not provided, - uses posData.frame_i, by default None - track_og_curr : bool, optional - if True, will track the original current IDs, by default False - new_frame : bool, optional - if True, will set the frame to the new frame, by default False - IDs_to_add : list, optional - IDs to add to the whitelist, by default None - IDs_to_remove : list, optional - IDs to remove from the whitelist, by default None - """ - got_data = False - benchmark = False - if benchmark: - ts = [time.perf_counter()] - titles = [ - "", - "store_data", - "whitelistSetViewOGIDsToggle", - "get_data", - "get what to add/remove", - "track_og_curr", - "get current lab", - "add/remove IDs", - "store data", - "update images", - ] - - mode = self.modeComboBox.currentText() - if mode != "Segmentation and Tracking": - return - - posData = self.data[self.pos_i] - if posData.whitelist is None: - return - - if frame_i is None: - frame_i = posData.frame_i - og_frame_i = frame_i - else: - og_frame_i = posData.frame_i - posData.frame_i = frame_i - # getting data is handles later in the code - - debug = posData.whitelist._debug - if debug: - printl("whitelistUpdateLab", frame_i, og_frame_i) - from . import debugutils - - debugutils.print_call_stack() - - if benchmark: - ts.append(time.perf_counter()) - - self.whitelistSetViewOGIDsToggle(False) ### - - if benchmark: - ts.append(time.perf_counter()) - - if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): - og_lab = posData.whitelist.originalLabs[frame_i] ### - else: - og_lab = None - if benchmark: - ts.append(time.perf_counter()) - - #### - whitelist = posData.whitelist.get(frame_i=frame_i) - IDs_to_add_remove_provided = IDs_to_add is not None or IDs_to_remove is not None - if not IDs_to_add_remove_provided: - self.get_data() - got_data = True - current_IDs = set(posData.IDs) - missing_IDs = list(whitelist - current_IDs) - to_be_removed_IDs = list(current_IDs - whitelist) - else: - missing_IDs = list(IDs_to_add) if IDs_to_add is not None else [] - to_be_removed_IDs = list(IDs_to_remove) if IDs_to_remove is not None else [] - - ### - - if benchmark: - ts.append(time.perf_counter()) - - ### - if not missing_IDs and not to_be_removed_IDs: # nothing to do - if og_frame_i != frame_i: - posData.frame_i = og_frame_i - if got_data and og_frame_i != frame_i: - self.get_data() - if benchmark: - print("No IDs to add/remove") - ts.append(time.perf_counter()) - indx = titles.index("track_og_curr") - titles[indx + 1] = "store_data" - time_taken = time.perf_counter() - ts[0] - print(f"\nTotal time for whitelistUpdateLab: {time_taken:.2f}s") - for i in range(1, len(ts)): - time_taken = ts[i] - ts[i - 1] - print(f"Time taken for {titles[i]}: {time_taken:.2f}s") - print("") - return - - if not got_data and og_frame_i != frame_i: - self.get_data() - got_data = True - - if benchmark: - ts.append(time.perf_counter()) - - ### - if missing_IDs and track_og_curr and not new_frame: - self.whitelistTrackOGCurr(frame_i=frame_i, lab=posData.lab, rp=posData.rp) - - missing_IDs = np.array(missing_IDs, dtype=np.int32) - to_be_removed_IDs = np.array(to_be_removed_IDs, dtype=np.int32) - - if debug: - printl(missing_IDs, to_be_removed_IDs) - - curr_lab = posData.lab # or curr_lab = posData.lab??? - # convert values to int if they are not already - if curr_lab is None: - try: - curr_lab = posData.allData_li[frame_i]["labels"].copy() - except: - pass - if curr_lab is None: - try: - curr_lab = posData.segm_data[frame_i].copy() - except: - pass - if curr_lab is None: - printl("No current lab?") - curr_lab = np.zeros_like(posData.segm_data[0]) - curr_lab = curr_lab.astype(np.int32) - if benchmark: - ts.append(time.perf_counter()) - - if missing_IDs.size > 0 and og_lab is not None: - mask = np.isin(og_lab, missing_IDs) # add missing_IDs - curr_lab[mask] = og_lab[mask] - - if to_be_removed_IDs.size > 0: - curr_lab[np.isin(curr_lab, to_be_removed_IDs)] = ( - 0 # remove to_be_removed_IDs - ) - - if benchmark: - ts.append(time.perf_counter()) - - posData.lab = curr_lab - - self.update_rp(wl_update=False) - self.store_data() - - if benchmark: - ts.append(time.perf_counter()) - if og_frame_i != frame_i: - posData.frame_i = og_frame_i - self.get_data() - - self.updateAllImages() - self.setAllTextAnnotations() - - if benchmark: - ts.append(time.perf_counter()) - time_taken = time.perf_counter() - ts[0] - print(f"\nTotal time for whitelistUpdateLab: {time_taken:.2f}s") - for i in range(1, len(ts)): - time_taken = ts[i] - ts[i - 1] - print(f"Time taken for {titles[i]}: {time_taken:.2f}s") - print("") - - def whitelistUpdateTempLayer(self): - """Updates the temp layer with the current whitelist IDs.""" - if not self.whitelistIDsButton.isChecked(): - self.keepIDsTempLayerLeft.clear() - return - - if not hasattr(self, "keptLab"): - self.keptLab = np.zeros_like(self.currentLab2D) - keptLab = self.keptLab - else: - keptLab = self.keptLab - keptLab[:] = 0 - - posData = self.data[self.pos_i] - if posData.whitelist is None: - if not hasattr(self, "tempWhitelistIDs"): - self.tempWhitelistIDs = set() # not updated, only use in this context - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = posData.whitelist.get(posData.frame_i) - - for obj in posData.rp: - if obj.label not in current_whitelist: - continue - - if not self.isObjVisible(obj.bbox): - continue - - _slice = self.getObjSlice(obj.slice) - _objMask = self.getObjImage(obj.image, obj.bbox) - - keptLab[_slice][_objMask] = obj.label - - self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) - - @exception_handler - @disableWindow - def whitelistViewOGIDs(self, checked: bool): - """Switch between selected and original labels. - Uses self.viewOriginalLabels to see what has to be done. - - Parameters - ---------- - checked : bool - True if the original labels have to be shown, False otherwise. - """ - switch_to_og = checked and not self.viewOriginalLabels - switch_to_seg = not checked and self.viewOriginalLabels - - if not switch_to_og and not switch_to_seg: - return - - posData = self.data[self.pos_i] - if posData.whitelist is None: - return - - if posData.whitelist._debug: - printl("whitelistViewOGIDs", checked) - - frame_i = posData.frame_i - if frame_i > 0: - frames_range = [frame_i - 1, frame_i] - else: - frames_range = [frame_i] - - self.store_data(autosave=False) - - if not self.whitelistCheckOriginalLabels(): - return - if switch_to_og: - self.setFrameNavigationDisabled(True, why="Viewing original labels") - self.viewOriginalLabels = True - - for i in frames_range: - posData.frame_i = i - self.get_data() - self.whitelistTrackOGCurr(frame_i=i) - - IDs = posData.IDs - - og_frame = posData.whitelist.originalLabs[i].copy() - IDs_to_uppdate = ( - posData.whitelist.whitelistIDs[i] - & posData.whitelist.originalLabsIDs[i] - ) - if IDs_to_uppdate: - mask = np.isin(og_frame, list(IDs_to_uppdate)) - og_frame[mask] = 0 - - mask = np.isin(posData.lab, list(IDs_to_uppdate)) - og_frame[mask] = posData.lab[mask] - - IDs_to_add = ( - posData.whitelist.whitelistIDs[i] - - posData.whitelist.originalLabsIDs[i] - ) - if IDs_to_add: - mask = np.isin(posData.lab, list(IDs_to_add)) - og_frame[mask] = posData.lab[mask] - - posData.lab = og_frame - self.update_rp(wl_update=False) - self.store_data(autosave=False) - - if frame_i > 0: - missing_IDs = set(posData.IDs) - set( - posData.allData_li[frame_i - 1]["IDs"] - ) - self.trackManuallyAddedObject( - missing_IDs, isNewID=True, wl_update=False - ) - - self.setAllTextAnnotations() - self.updateAllImages() - - elif switch_to_seg: - self.viewOriginalLabels = False - self.setFrameNavigationDisabled(False, why="Viewing original labels") - - for i in frames_range: - posData.frame_i = i - self.get_data() - try: - posData.whitelist.originalLabs[i] = posData.lab.copy() - posData.whitelist.originalLabsIDs[i] = set(posData.IDs) - except AttributeError: - lab = posData.segm_data[i].copy() - IDs = [obj.label for obj in skimage.measure.regionprops(lab)] - posData.whitelist.originalLabs[i] = lab - posData.whitelist.originalLabsIDs[i] = set(IDs) - - # self.whitelistTrackCurrOG() - self.update_rp(wl_update=False) - self.store_data(autosave=False) - self.whitelistUpdateLab(frame_i=i) # has update_rp and store data - self.setAllTextAnnotations() - self.updateAllImages() diff --git a/cellacdc/mixins_bak/workspace.py b/cellacdc/mixins_bak/workspace.py deleted file mode 100644 index aff394ba0..000000000 --- a/cellacdc/mixins_bak/workspace.py +++ /dev/null @@ -1,51 +0,0 @@ -"""View-model commands for workspace path helpers.""" - -from __future__ import annotations - -from cellacdc import load -from cellacdc.myutils import ( - addToRecentPaths, - determine_folder_type, - getMostRecentPath, - get_pos_foldernames, - listdir, -) - - -class WorkspaceMixin: - """Application-facing commands for filesystem workspace discovery.""" - - def add_recent_path(self, path, *, logger=None): - return addToRecentPaths(str(path), logger=logger) - - def determine_folder_type(self, folder_path): - is_pos_folder, is_images_folder, folder_path = determine_folder_type( - str(folder_path) - ) - return is_pos_folder, bool(is_images_folder), folder_path - - def endnames(self, basename, files): - return load.get_endnames(basename, files) - - def listdir(self, path): - return listdir(str(path)) - - def most_recent_path(self): - return getMostRecentPath() - - def path_from_endname(self, end_name, images_path, *, ext=None): - return load.get_path_from_endname(end_name, str(images_path), ext=ext) - - def position_folder_names( - self, - exp_path, - *, - check_if_is_sub_folder=False, - ): - return get_pos_foldernames( - str(exp_path), - check_if_is_sub_folder=check_if_is_sub_folder, - ) - - def segmentation_files(self, images_path): - return load.get_segm_files(str(images_path)) From fd3831277b590d4cbab4e88a72d90d4a5121f08c Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 11:09:07 +0200 Subject: [PATCH 10/21] refactor: move whitelist and combine GUI bases into mixins Relocate WhitelistGUIElements and combine channel classes from whitelist.py and gui_combine.py into mixins/ as WhitelistGui, CombineGui, and CombineWorker. Restore gui module path constants needed by _main.py. Co-authored-by: Cursor --- cellacdc/gui.py | 15 +- cellacdc/mixins/__init__.py | 2 + .../{gui_combine.py => mixins/combine.py} | 21 +- cellacdc/mixins/whitelist.py | 1133 +++++++++++++++++ cellacdc/whitelist.py | 1109 ---------------- 5 files changed, 1160 insertions(+), 1120 deletions(-) rename cellacdc/{gui_combine.py => mixins/combine.py} (98%) create mode 100644 cellacdc/mixins/whitelist.py diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 91c655849..c58d742e6 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -5,9 +5,12 @@ from qtpy.QtCore import Qt, QTimer, Signal from qtpy.QtWidgets import QMainWindow, QButtonGroup, QWidget -from . import myutils, autopilot, whitelist, gui_combine +from . import myutils, autopilot, favourite_func_metrics_csv_path, settings_folderpath from .myutils import setupLogger from .gui_decorators import get_data_exception_handler, resetViewRange + +custom_annot_path = os.path.join(settings_folderpath, 'custom_annotations.json') +shortcut_filepath = os.path.join(settings_folderpath, 'shortcuts.ini') from .mixins import ( Actions, AnnotationDisplay, @@ -21,6 +24,8 @@ CanvasSelection, CanvasTool, CellCycle, + CombineGui, + CombineWorker, CurvatureTools, CustomAnnotations, DataLoading, @@ -57,6 +62,7 @@ ToolActivation, Tracking, UndoRedo, + WhitelistGui, WindowEvents, Worker, ) @@ -72,9 +78,10 @@ pass -class guiWin(QMainWindow, whitelist.WhitelistGUIElements, - gui_combine.CombineGuiElements, - gui_combine.CombineGUIWorker, +class guiWin(QMainWindow, + WhitelistGui, + CombineGui, + CombineWorker, Geometry, Worker, Session, diff --git a/cellacdc/mixins/__init__.py b/cellacdc/mixins/__init__.py index f79f6554c..95177a1b6 100644 --- a/cellacdc/mixins/__init__.py +++ b/cellacdc/mixins/__init__.py @@ -14,6 +14,7 @@ from .canvas_selection import CanvasSelection from .canvas_tool import CanvasTool from .cell_cycle import CellCycle +from .combine import CombineGui, CombineWorker from .curvature_tools import CurvatureTools from .custom_annotations import CustomAnnotations from .data_loading import DataLoading @@ -50,5 +51,6 @@ from .tool_activation import ToolActivation from .tracking import Tracking from .undo_redo import UndoRedo +from .whitelist import WhitelistGui from .window_events import WindowEvents from .worker import Worker diff --git a/cellacdc/gui_combine.py b/cellacdc/mixins/combine.py similarity index 98% rename from cellacdc/gui_combine.py rename to cellacdc/mixins/combine.py index 17303c837..0955e78ab 100644 --- a/cellacdc/gui_combine.py +++ b/cellacdc/mixins/combine.py @@ -1,11 +1,18 @@ -from typing import List, Dict, Any, Tuple -from . import core, workers, widgets, html_utils, apps, preprocess, myutils, printl -from qtpy.QtCore import QThread, QTimer, QMutex, QWaitCondition -from natsort import natsorted +"""Combine channels GUI mixin extracted from gui_combine.py.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Tuple + import numpy as np -# from gui import guiWin +from natsort import natsorted +from qtpy.QtCore import QMutex, QThread, QTimer, QWaitCondition + +from cellacdc import apps, core, html_utils, myutils, preprocess, printl, widgets, workers + + -class CombineGuiElements: +class CombineGui: def _setup_vars_combine(self): self.combineWorker = None self.combineDialog = None @@ -310,7 +317,7 @@ def combineChannelsActionTriggered(self): self.combineDialog.activateWindow() self.combineDialog.emitSigPreviewToggled() -class CombineGUIWorker: +class CombineWorker: def combineEnqueueCurrentImage(self, steps, keep_input_data_type, formula): posData = self.data[self.pos_i] diff --git a/cellacdc/mixins/whitelist.py b/cellacdc/mixins/whitelist.py new file mode 100644 index 000000000..52497ed03 --- /dev/null +++ b/cellacdc/mixins/whitelist.py @@ -0,0 +1,1133 @@ +"""Whitelist GUI mixin extracted from whitelist.py.""" + +from __future__ import annotations + +import os +import time + +import numpy as np +import skimage.measure +from typing import Set, List, Tuple + +from cellacdc import ( + apps, + disableWindow, + exception_handler, + exec_time, + html_utils, + printl, + widgets, +) +from cellacdc.trackers.CellACDC import CellACDC_tracker +from cellacdc.whitelist import Whitelist + + +class WhitelistGui: + """A class to manage the whitelist GUI elements. + """ + def whitelistCheckOriginalLabels(self, warning:bool=True, + frame_i:int=None): + """Warns the user that there are no original labels labels are present + for the frame""" + posData = self.data[self.pos_i] + if posData.whitelist is None: + return False + + if frame_i is None: + frame_i = posData.frame_i + + if posData.whitelist.originalLabsIDs is None: + return False + + if (frame_i >= len(posData.whitelist.originalLabsIDs) or + posData.whitelist.originalLabsIDs[frame_i] is None): + txt = """ + No original labels are present for the current frame, + this action cannot be performed.""" + self.logger.warning(txt) + if not warning: + return False + msg = widgets.myMessageBox.warning( + self, 'No original labels', txt, + ) + + return False + else: + return True + + @disableWindow + def whitelistTrackOGagainstPreviousFrame_cb(self, signal_slot=None): + """Tracks the original labels against the previous frame. + This is used as a callback for sigTrackOGagainstPreviousFrame signal + """ + posData = self.data[self.pos_i] + frame_i = posData.frame_i + if not self.whitelistCheckOriginalLabels(): + return + old_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] + prev_cell_IDs = posData.allData_li[frame_i-1]['IDs'] + self.whitelistTrackOGCurr(against_prev=True) + new_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] + + new_IDs = new_cell_IDs - old_cell_IDs + new_IDs = new_IDs & set(prev_cell_IDs) + + self.whitelistUpdateLab( + track_og_curr=False, IDs_to_add=new_IDs, + ) + + def whitelistLoadOGLabs_cb(self): + """Generates a dialog to load the original (not whitelisted) labels + """ + posData = self.data[self.pos_i] + curr_seg_path = posData.segm_npz_path + + segmFilename = os.path.basename(curr_seg_path) + custom_first = f"{segmFilename[:-4]}_not_whitelisted.npz" + images_path = posData.images_path + existingEndnames = [ + files for files in os.listdir(images_path) if files.endswith('.npz') + ] + if custom_first not in existingEndnames: + custom_first = None + + infoText = html_utils.paragraph( + 'Select the segmentation file containing the original labels ' + 'of the objects. Pleae note that the current saved "original" ' + 'labels will be replaced with the new ones, but the filtered ' + 'labels will be kept.' + ) + + win = apps.SelectSegmFileDialog( + existingEndnames, images_path, parent=self, + basename=posData.basename, infoText=infoText, + custom_first=custom_first + ) + win.exec_() + if win.cancel: + self.logger.info('Loading original labels canceled.') + return + selected = win.selectedItemText + self.logger.info(f'Loading original labels from {selected}...') + self.whitelistLoadOGLabs(selected) + + @disableWindow + def whitelistLoadOGLabs(self, selected:str): + """Loads the original labels from the selected files + + Parameters + ---------- + selected : str + Selected file name from the dialog. + """ + posData = self.data[self.pos_i] + images_path = posData.images_path + + selected_path = os.path.join(images_path, selected) + posData.whitelist.loadOGLabs(selected_path) + + self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) + + @exception_handler + @disableWindow + def whitelistViewOGIDs(self, checked:bool): + """Switch between selected and original labels. + Uses self.viewOriginalLabels to see what has to be done. + + Parameters + ---------- + checked : bool + True if the original labels have to be shown, False otherwise. + """ + switch_to_og = checked and not self.viewOriginalLabels + switch_to_seg = not checked and self.viewOriginalLabels + + if not switch_to_og and not switch_to_seg: + return + + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + if posData.whitelist._debug: + printl('whitelistViewOGIDs', checked) + + frame_i = posData.frame_i + if frame_i > 0: + frames_range = [frame_i-1, frame_i] + else: + frames_range = [frame_i] + + self.store_data(autosave=False) + + if not self.whitelistCheckOriginalLabels(): + return + if switch_to_og: + self.setFrameNavigationDisabled(True, why='Viewing original labels') + self.viewOriginalLabels = True + + for i in frames_range: + posData.frame_i = i + self.get_data() + self.whitelistTrackOGCurr(frame_i=i) + + IDs = posData.IDs + + og_frame = posData.whitelist.originalLabs[i].copy() + IDs_to_uppdate = posData.whitelist.whitelistIDs[i] & posData.whitelist.originalLabsIDs[i] + if IDs_to_uppdate: + mask = np.isin(og_frame, list(IDs_to_uppdate)) + og_frame[mask] = 0 + + mask = np.isin(posData.lab, list(IDs_to_uppdate)) + og_frame[mask] = posData.lab[mask] + + IDs_to_add = posData.whitelist.whitelistIDs[i] - posData.whitelist.originalLabsIDs[i] + if IDs_to_add: + mask = np.isin(posData.lab, list(IDs_to_add)) + og_frame[mask] = posData.lab[mask] + + posData.lab = og_frame + self.update_rp(wl_update=False) + self.store_data(autosave=False) + + if frame_i > 0: + missing_IDs = set(posData.IDs) - set(posData.allData_li[frame_i-1]['IDs']) + self.trackManuallyAddedObject(missing_IDs,isNewID=True, wl_update=False) + + self.setAllTextAnnotations() + self.updateAllImages() + + elif switch_to_seg: + self.viewOriginalLabels = False + self.setFrameNavigationDisabled(False, why='Viewing original labels') + + for i in frames_range: + posData.frame_i = i + self.get_data() + try: + posData.whitelist.originalLabs[i] = posData.lab.copy() + posData.whitelist.originalLabsIDs[i] = set(posData.IDs) + except AttributeError: + lab = posData.segm_data[i].copy() + IDs = [obj.label for obj in skimage.measure.regionprops(lab)] + posData.whitelist.originalLabs[i] = lab + posData.whitelist.originalLabsIDs[i] = set(IDs) + + # self.whitelistTrackCurrOG() + self.update_rp(wl_update=False) + self.store_data(autosave=False) + self.whitelistUpdateLab(frame_i=i) #has update_rp and store data + self.setAllTextAnnotations() + self.updateAllImages() + + def whitelistSetViewOGIDsToggle(self, checked: bool): + """Set the view original labels toggle button to checked or unchecked. + This also updates the self.viewOriginalLabels variable. + !!! Doesn't change the actually displayed labels, use self.whitelistViewOGIDs + to do that.!!! + + Parameters + ---------- + checked : bool + True if the original labels are shown, False otherwise. + """ + self.viewOriginalLabels = checked + self.whitelistIDsToolbar.viewOGToggle.blockSignals(True) + self.whitelistIDsToolbar.viewOGToggle.setChecked(checked) + self.whitelistIDsToolbar.viewOGToggle.blockSignals(False) + + def whitelistAddNewIDsToggled(self, checked: bool): + """Will set self.addNewIDsWhitelistToggle to checked and call + whitelistAddNewIDs if checked is True. + + Parameters + ---------- + checked : bool + True if the add new IDs toggle is checked, False otherwise. + """ + self.addNewIDsWhitelistToggle = checked + if checked: + self.df_settings.at['addNewIDsWhitelistToggle', 'value'] = 'Yes' + else: + self.df_settings.at['addNewIDsWhitelistToggle', 'value'] = 'No' + self.df_settings.to_csv(self.settings_csv_path) + if checked: + self.whitelistAddNewIDs(ignore_not_first_time=True) + self.whitelistPropagateIDs() + self.updateAllImages() + self.whitelistIDsUpdateText() + + def whitelistAddNewIDs(self, ignore_not_first_time:bool=False): + """Function which adds new IDs to the whitelist, based on the original labels. + It will check if the frame is visited the first time, unless + ignore_not_first_time is True. + It does nothing if self.addNewIDsWhitelistToggle is False. + !!!Careful, does not change the lab, just the whitelist!!! + + Parameters + ---------- + ignore_not_first_time : bool, optional + Weather it should be checked if the frame is visited + the first time, by default False + """ + mode = self.modeComboBox.currentText() + if mode != 'Segmentation and Tracking': + return + + if not self.addNewIDsWhitelistToggle: + return + + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + debug = posData.whitelist._debug + + if debug: + printl('whitelistAddNewIDs') + + posData = self.data[self.pos_i] + frame_i = posData.frame_i + + if self.get_last_tracked_i() > frame_i and not ignore_not_first_time: + return + + if frame_i == 0: + return + + if self.whitelistAddNewIDsFrame is not None and frame_i == self.whitelistAddNewIDsFrame: + return + + self.whitelistAddNewIDsFrame = frame_i + + curr_lab = self.get_curr_lab() + + posData.whitelist.addNewIDs(frame_i=frame_i, + allData_li=posData.allData_li, + IDs_curr=posData.IDs, + curr_lab=curr_lab) + + + def whitelistIDsAccepted(self, + whitelistIDs: Set[int] | List[int]): + """Function which is called when the user accepts a whitelist. + Also initializes the whitelist if it is not already initialized. (Aka not loaded) + + Parameters + ---------- + whitelistIDs : set | list + The accepted IDs from the whitelist dialog. + """ + # Store undo state before modifying stuff + self.storeUndoRedoStates(False) + + self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) + self.whitelistSetViewOGIDsToggle(False) + self.setFrameNavigationDisabled(False, why='Viewing original labels') + + self.store_data(autosave=False) + + posData = self.data[self.pos_i] + + if not posData.whitelist: + posData.whitelist = Whitelist( + total_frames=posData.SizeT, + ) + + if posData.whitelist._debug: + printl('whitelistIDsAccepted', whitelistIDs) + + whitelistIDs = set(whitelistIDs) + + IDs_curr = set(posData.IDs) + + posData.whitelist.IDsAccepted( + whitelistIDs, + segm_data=posData.segm_data, + frame_i=posData.frame_i, + allData_li=posData.allData_li, + IDs_curr=IDs_curr, + curr_lab=posData.lab, + + ) + + # self.whitelistPropagateIDs(new_whitelist=whitelistIDs, + # try_create_new_whitelists=True, + # only_future_frames=True, + # force_not_dynamic_update=True, + # update_lab=True + # ) + self.whitelistUpdateLab(track_og_curr=True) + + self.whitelistIDsUpdateText() + self.keepIDsTempLayerLeft.clear() + + def whitelistUpdateLab(self, frame_i: int=None, + track_og_curr=False, new_frame:bool=False, + IDs_to_add:List[int] | Set[int]=None, + IDs_to_remove:List[int]|Set[int]=None, + ): + # this should also work for 3D i think... + """Updates the displayed lab based on the whitelist. + + Parameters + ---------- + frame_i : int, optional + frame which should be updated. If not provided, + uses posData.frame_i, by default None + track_og_curr : bool, optional + if True, will track the original current IDs, by default False + new_frame : bool, optional + if True, will set the frame to the new frame, by default False + IDs_to_add : list, optional + IDs to add to the whitelist, by default None + IDs_to_remove : list, optional + IDs to remove from the whitelist, by default None + """ + got_data = False + benchmark = False + if benchmark: + ts = [time.perf_counter()] + titles = [ + '', + 'store_data', + 'whitelistSetViewOGIDsToggle', + 'get_data', + 'get what to add/remove', + 'track_og_curr', + 'get current lab', + 'add/remove IDs', + 'store data', + 'update images', + ] + + mode = self.modeComboBox.currentText() + if mode != 'Segmentation and Tracking': + return + + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + if frame_i is None: + frame_i = posData.frame_i + og_frame_i = frame_i + else: + og_frame_i = posData.frame_i + posData.frame_i = frame_i + # getting data is handles later in the code + + debug = posData.whitelist._debug + if debug: + printl('whitelistUpdateLab', frame_i, og_frame_i) + from . import debugutils + debugutils.print_call_stack() + + if benchmark: + ts.append(time.perf_counter()) + + self.whitelistSetViewOGIDsToggle(False) ### + + if benchmark: + ts.append(time.perf_counter()) + + if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): + og_lab = posData.whitelist.originalLabs[frame_i] ### + else: + og_lab = None + if benchmark: + ts.append(time.perf_counter()) + + #### + whitelist = posData.whitelist.get(frame_i=frame_i) + IDs_to_add_remove_provided = IDs_to_add is not None or IDs_to_remove is not None + if not IDs_to_add_remove_provided: + self.get_data() + got_data = True + current_IDs = set(posData.IDs) + missing_IDs = list(whitelist - current_IDs) + to_be_removed_IDs = list(current_IDs - whitelist) + else: + missing_IDs = list(IDs_to_add) if IDs_to_add is not None else [] + to_be_removed_IDs = list(IDs_to_remove) if IDs_to_remove is not None else [] + + ### + + if benchmark: + ts.append(time.perf_counter()) + + ### + if not missing_IDs and not to_be_removed_IDs: # nothing to do + if og_frame_i != frame_i: + posData.frame_i = og_frame_i + if got_data and og_frame_i != frame_i: + self.get_data() + if benchmark: + print('No IDs to add/remove') + ts.append(time.perf_counter()) + indx = titles.index('track_og_curr') + titles[indx + 1] = 'store_data' + time_taken = time.perf_counter() - ts[0] + print(f'\nTotal time for whitelistUpdateLab: {time_taken:.2f}s') + for i in range(1, len(ts)): + time_taken = ts[i] - ts[i-1] + print(f'Time taken for {titles[i]}: {time_taken:.2f}s') + print('') + return + + if not got_data and og_frame_i != frame_i: + self.get_data() + got_data = True + + if benchmark: + ts.append(time.perf_counter()) + + ### + if missing_IDs and track_og_curr and not new_frame: + self.whitelistTrackOGCurr(frame_i=frame_i, + lab = posData.lab, + rp = posData.rp) + + missing_IDs = np.array(missing_IDs, dtype=np.int32) + to_be_removed_IDs = np.array(to_be_removed_IDs, dtype=np.int32) + + if debug: + printl(missing_IDs, to_be_removed_IDs) + + curr_lab = posData.lab # or curr_lab = posData.lab??? + # convert values to int if they are not already + if curr_lab is None: + try: + curr_lab = posData.allData_li[frame_i]['labels'].copy() + except: + pass + if curr_lab is None: + try: + curr_lab = posData.segm_data[frame_i].copy() + except: + pass + if curr_lab is None: + printl('No current lab?') + curr_lab = np.zeros_like(posData.segm_data[0]) + curr_lab = curr_lab.astype(np.int32) + if benchmark: + ts.append(time.perf_counter()) + + if missing_IDs.size > 0 and og_lab is not None: + mask = np.isin(og_lab, missing_IDs) # add missing_IDs + curr_lab[mask] = og_lab[mask] + + if to_be_removed_IDs.size > 0: + curr_lab[np.isin(curr_lab, to_be_removed_IDs)] = 0 # remove to_be_removed_IDs + + if benchmark: + ts.append(time.perf_counter()) + + posData.lab = curr_lab + + self.update_rp(wl_update=False) + self.store_data() + + if benchmark: + ts.append(time.perf_counter()) + if og_frame_i != frame_i: + posData.frame_i = og_frame_i + self.get_data() + + self.updateAllImages() + self.setAllTextAnnotations() + + if benchmark: + ts.append(time.perf_counter()) + time_taken = time.perf_counter() - ts[0] + print(f'\nTotal time for whitelistUpdateLab: {time_taken:.2f}s') + for i in range(1, len(ts)): + time_taken = ts[i] - ts[i-1] + print(f'Time taken for {titles[i]}: {time_taken:.2f}s') + print('') + + def whitelistIDsUpdateText(self): + """Updates the text. Carefull, triggers whitelistLineEdit.textChanged! + """ + mode = self.modeComboBox.currentText() + if mode != 'Segmentation and Tracking': + return + + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + if posData.whitelist._debug: + printl('whitelistIDsUpdateText') + + frame_i = posData.frame_i + whitelist = posData.whitelist.get(frame_i=frame_i) + + self.whitelistIDsToolbar.whitelistLineEdit.setText(whitelist) + + def whitelistTrackOGCurr(self, frame_i:int=None, + against_prev:bool=False, + lab:np.ndarray=None, + rp:list=None, + IDs: Set[int] | List[int] =None): + """Track the original labels in relation to the current (whitelisted) + labels. + Parameters + + Parameters + ---------- + frame_i : int, optional + frame_i to be tracked, posData.frame_i if not provided, + by default None + against_prev : bool, optional + if the original frame should be tracked against frame_i-1. + Cannot be used with rp or lab, by default False + lab : np.ndarray, optional + lab to be tracked against, by default None + rp : list, optional + regionprops for this lab, by default None + IDs : Set[int] | List[int], optional + IDs that should be tracked based on og + + Raises + ------ + ValueError + Cannot provide both rp and lab when tracking against previous frame. + Instead only provide rp and lab, and dont set against_prev. + """ + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + debug = posData.whitelist._debug + + if debug: + from . import debugutils + debugutils.print_call_stack(depth=2) + printl('whitelistTrackOGCurr', against_prev) + + if against_prev and (rp is not None or lab is not None): + raise ValueError('Cannot provide both rp and lab when tracking' + ' against previous frame.' + 'Instead only provide rp and lab, and dont set against_prev.') + + if frame_i is None: + frame_i = posData.frame_i + + if against_prev and frame_i == 0: + return + + if not self.whitelistCheckOriginalLabels(warning=False, + frame_i=frame_i): + if debug: + printl('No original labels, cannot track.') + return + + og_frame_i = posData.frame_i + ### against what should I track? + + if lab is not None and not rp: + rp = skimage.measure.regionprops(lab) + + changed_frame = False + if lab is None: + if debug: + printl('No lab and no rp provided.') + if against_prev: + rp = posData.allData_li[frame_i-1]['regionprops'] + lab = posData.allData_li[frame_i-1]['labels'] + else: + if frame_i != og_frame_i: + self.store_data(autosave=False) + posData.frame_i = frame_i + self.get_data() + changed_frame = True + rp = posData.rp + lab = posData.lab + og_lab = posData.whitelist.originalLabs[frame_i] + og_rp = skimage.measure.regionprops(og_lab) + # lab = lab.copy() + + denom_overlap_matrix = 'union' if not against_prev else 'area_prev' + + og_lab = CellACDC_tracker.track_frame( + lab, rp, og_lab, og_rp, + denom_overlap_matrix=denom_overlap_matrix, + posData = posData, + setBrushID_func=self.setBrushID, + IDs=IDs, + # assign_unique_new_IDs=False, + ) + + posData.whitelist.originalLabs[frame_i] = og_lab + posData.whitelist.originalLabsIDs[frame_i] = {obj.label for obj in skimage.measure.regionprops(og_lab)} + + if changed_frame: + posData.frame_i = og_frame_i + self.get_data() + + def whitelistTrackCurrOG(self, frame_i:int=None, against_prev:bool=False): + """Track the current (whitelisted) labels in relation to the original labels. + Parameters + ---------- + frame_i : int, optional + frame_i to be tracked, posData.frame_i if not provided, by default None + against_prev : bool, optional + if the original frame should be tracked against frame_i-1. + """ + posData = self.data[self.pos_i] + if posData.whitelist is None: + return + + if posData.whitelist._debug: + printl('whitelistTrackCurrOG', frame_i, against_prev) + + if frame_i is None: + frame_i = posData.frame_i + + if against_prev and frame_i == 0: + return + + og_frame = posData.frame_i + if frame_i != og_frame: + self.store_data(autosave=False) + posData.frame_i = frame_i + self.get_data() + + lab = posData.lab + rp = posData.rp + + if not self.whitelistCheckOriginalLabels(warning=False, + frame_i=frame_i if not against_prev else frame_i-1): + if posData.whitelist._debug: + printl('No original labels, cannot track.') + return + + if against_prev: + og_lab = posData.whitelist.originalLabs[frame_i-1] + else: + og_lab = posData.whitelist.originalLabs[frame_i] + + og_rp = skimage.measure.regionprops(og_lab) + + denom_overlap_matrix = 'union' if not against_prev else 'area_prev' + + lab = CellACDC_tracker.track_frame( + og_lab, og_rp, lab, rp, + denom_overlap_matrix=denom_overlap_matrix, + posData = posData, + setBrushID_func=self.setBrushID + ) + + posData.lab = lab + + self.update_rp(wl_update=False) + self.store_data(autosave=False) + + if frame_i != og_frame: + posData.frame_i = og_frame + self.get_data() + + def whitelistSyncIDsOG(self, + frame_is: List[int]=None, + against_prev: bool=False,): + """Interates over the frames and calls whitelistTrackOGCurr for each frame. + + Parameters + ---------- + frame_is : List[int], optional + list of frame_i, if None goes through all, by default None + against_prev : bool, optional + if the original frame should be tracked against frame_i-1. + """ + posData = self.data[self.pos_i] + if frame_is is None: + frame_is = range(posData.SizeT) + + for frame_i in frame_is: + self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=against_prev) + + def whitelistInitNewFrames(self, frame_i:int=None, force:bool=False): + """Initialize the whitelist for a new frame. The class whitelist keeps track + of the init frames and doesnt try to init them again, unless forced. + Does not init the class! + + Parameters + ---------- + frame_i : int, optional + frame_i to be init, posData.frame_i if not provided, by default None + force : bool, optional + if the init should be forced, by default False + + Returns + ------- + bool + if the frame was new or not + list + list of frames that were updated, and info about added/removed IDs + """ + + posData = self.data[self.pos_i] + if posData.whitelist is None: + return False, [] + + if frame_i is None: + frame_i = posData.frame_i + + if posData.whitelist._debug: + printl('whitelistInitNewFrames', frame_i, force) + + if frame_i not in posData.whitelist.initialized_i: + self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=True) + + new_frame, update_frames = posData.whitelist.initNewFrames( + frame_i=frame_i, force=force) + + self.whitelistAddNewIDs() + return new_frame, update_frames + + # @exec_time + def whitelistPropagateIDs(self, + new_whitelist: Set[int] | List[int] = None, + IDs_to_add: Set[int] = None, + IDs_to_remove: Set[int] = None, + frame_i: int = None, + try_create_new_whitelists: bool = False, + curr_frame_only: bool = False, + force_not_dynamic_update: bool = False, + only_future_frames: bool = True, + allow_only_current_IDs: bool = False, + track_og_curr: bool = True, + IDs_curr: Set[int] | List[int] = None, + index_lab_combo: Tuple[int, np.ndarray] = None, + curr_rp: list = None, + curr_lab: np.ndarray = None, + store_data: bool = True, + update_lab: bool = False, + ): + """ + Propagates whitelist IDs across frames in the dataset. (Doesnt update labs) + Should also be called when viewing a new frame! + + This function updates whitelist. If curr_frame_only is True, it only updates the + whitelist of the current frame. If the frame changes, this function should be called + again to update the whitelist for the new frame (without this argument). + It should also handle cases were this is not done, but this is less safe. + Then, all the additions and removals are propagated to the other frames. + If force_not_dynamic_update is True, the function will propagate the entire whitelist to + frames, and not only the IDs which were added or removed. + + Hierarchy of arguments for current_IDs: + 1. IDs_curr (if provided) + (2. index_lab_combo (if provided) (is also passed to not current frame only + propagation if that propagation is necessary, and used when the frame_i matches)) + 3. curr_rp (if provided) + 4. curr_lab (if provided) + 5. allData_li + + Parameters + ---------- + new_whitelist : Set[int] | List[int], optional + A new set of whitelist IDs to replace the current whitelist. Cannot be + used together with `IDs_to_add` or `IDs_to_remove`, by default None. + IDs_to_add : Set[int], optional + A set of IDs to add to the current whitelist, by default None. + IDs_to_remove : Set[int], optional + A set of IDs to remove from the current whitelist, by default None. + frame_i : int, optional + The frame index for the propagation. + If None, uses posData.frame_i, by default None. + try_create_new_whitelists : bool, optional + If True, creates new whitelist entries for frames that do not already + have them. Should only be necessary when its initialized, by default False. + curr_frame_only : bool, optional + If True, only updates the whitelist for the current frame. + (See description of function), by default False. + force_not_dynamic_update : bool, optional + If True, disables dynamic updates to the whitelist. + (See description of function), by default False. + only_future_frames : bool, optional + If True, propagates changes only to future frames, by default True. + allow_only_current_IDs : bool, optional + If True, only allows IDs that are present in the current frame + to be added to the whitelist, by default True. + track_og_curr : bool, optional + If True, tracks the original labels in relation to the current + (whitelisted) labels. This is done by calling whitelistTrackOGCurr. + If its a new frame, this is done in whitelistInitNewFrames against the + previous frame, + by default True. + IDs_curr : Set[int] | List[int], optional + A set of IDs for the current frame, if None, + will be calculated from other stuff (see description), by default None. + index_lab_combo : Tuple[int, np.ndarray], optional + Combination of frame_i and current frame, + Used to get IDs_curr (see description), when the frame_i matches + (is also passed to not current frame only + propagation if that propagation is necessary, + and used when the frame_i matches), by default None. + curr_rp : list, optional + Region properties for the current frame. For IDs_curr. (see description), + by default None. + curr_lab : np.ndarray, optional + Labels for the current frame for IDs_curr. (see description), + by default None. + store_data : bool, optional + If True, stores the data before propagating the IDs. + update_lab : bool, optional + If True, updates the labels after propagating the IDs. + Will always update labels for newly init frames, by default False. + + Raises + ------ + ValueError + If both `new_whitelistIDs` and `IDs_to_add`/`IDs_to_remove` are provided. + + Example + ------- + To add IDs 5 and 6 to the whitelist for the current frame: + ```python + self.whitelistPropagateIDs(IDs_to_add={5, 6}, curr_frame_only=True) + ``` + Then when the frame changes: + ```python + self.whitelistPropagateIDs() + ``` + + To replace the whitelist for frame 10 with a new set of IDs: + ```python + self.whitelistPropagateIDs(new_whitelistIDs={1, 2, 3}, frame_i=10) + ``` + This would also propagate the changes to all other frames. + + """ + #doesnt update the frame displayed, only wl + try: # safety XD + IDs_curr = IDs_curr.copy() + except AttributeError: + pass + + IDs_curr = set(IDs_curr) if IDs_curr is not None else None + + posData = self.data[self.pos_i] + + debug = posData.whitelist._debug if posData.whitelist is not None else False + + if debug: + printl('Propagating IDs...') + from . import debugutils + debugutils.print_call_stack() + printl(new_whitelist, IDs_to_add, IDs_to_remove) + + if posData.whitelist is None: + return + + # og_frame_i = posData.frame_i + if frame_i is None: + frame_i = posData.frame_i + + new_frame, update_frames_init = self.whitelistInitNewFrames(frame_i=frame_i) + + if new_frame: + self.update_rp(wl_update=False) + # if track_og_curr and not new_frame: + # self.whitelistTrackOGCurr(frame_i=frame_i, rp=curr_rp, lab=curr_lab) + + update_frames = posData.whitelist.propagateIDs( + frame_i, + posData.allData_li, + new_whitelist=new_whitelist, + IDs_to_add=IDs_to_add, + IDs_to_remove=IDs_to_remove, + try_create_new_whitelists=try_create_new_whitelists, + curr_frame_only=curr_frame_only, + force_not_dynamic_update=force_not_dynamic_update, + only_future_frames=only_future_frames, + allow_only_current_IDs=allow_only_current_IDs, + IDs_curr=IDs_curr, + index_lab_combo=index_lab_combo, + curr_rp=curr_rp, + curr_lab=curr_lab, + ) + if update_lab: + update_frames = update_frames_init + update_frames + else: + update_frames = update_frames_init + # printl(posData.whitelistIDs[frame_i]) + # posData.frame_i = og_frame_i + self.whitelistIDsUpdateText() + if store_data: + self.store_data(autosave=False) + + for frame_i, IDs_to_add, IDs_to_remove, new_frame in update_frames: + self.whitelistUpdateLab(frame_i=frame_i, track_og_curr=track_og_curr, + new_frame=new_frame, IDs_to_add=IDs_to_add, + IDs_to_remove=IDs_to_remove, ) + + def whitelistIDs_cb(self, checked:bool): + """Callback for when the whitelist IDs button is checked or unchecked. + Initialises the pointlayer and the whitelist IDs toolbar if checked. + + Parameters + ---------- + checked : bool + True if the whitelist IDs button is checked, False otherwise. + """ + if checked: + self.initKeepObjLabelsLayers() + self.disconnectLeftClickButtons() + self.uncheckLeftClickButtons(self.whitelistIDsButton) + self.connectLeftClickButtons() + + self.whitelistIDsToolbar.setVisible(checked) + self.whitelistHighlightIDs(checked) + self.whitelistIDsUpdateText() + self.whitelistUpdateTempLayer() + + if not checked: + self.setLostNewOldPrevIDs() + self.updateAllImages() + + def whitelistHighlightIDs(self, checked:bool=True): + """Highlights the IDs in the current frame based on the whitelist. + + Parameters + ---------- + checked : bool, optional + If False, will delete all highlights, by default True + """ + if not checked: + self.removeHighlightLabelID() + return + + posData = self.data[self.pos_i] + + if posData.whitelist is None: + if not hasattr(self, 'tempWhitelistIDs'): + self.tempWhitelistIDs = set() # not updated, only use in this context + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = posData.whitelist.get( + frame_i=posData.frame_i) + + for ID in current_whitelist: + self.highlightLabelID(ID) + + def whitelistIDsChanged(self, + whitelistIDs: Set[int] | List[int], + debug: bool=False): + """Callback for when the whitelist IDs are changed. + This is called when the user changed the IDs in the whitelist IDs toolbar + (or when its programmatically changed, but if its not + visible it should return instantly) + Will update the temp layer and also complain when IDs + are not valid/present in the current lab + + Parameters + ---------- + whitelistIDs : set | list + The IDs that are currently in the whitelist. + debug : bool, optional + debug, by default False + """ + if not self.whitelistIDsButton.isChecked(): + return + + posData = self.data[self.pos_i] + + if posData.whitelist: + debug = posData.whitelist._debug + if debug: + printl('whitelistIDsChanged', whitelistIDs) + + if posData.whitelist is None: + wl_init = False + if not hasattr(self, 'tempWhitelistIDs'): + self.tempWhitelistIDs = set() # not updated, only use in this context + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = self.tempWhitelistIDs + else: + wl_init = True + current_whitelist = posData.whitelist.get( + frame_i=posData.frame_i) + + current_whitelist_copy = current_whitelist.copy() + if not hasattr(posData, 'originalLabsIDs') or posData.whitelist.originalLabsIDs is None: + possible_IDs = posData.IDs.copy() + else: + if not self.whitelistCheckOriginalLabels(warning=False): + possible_IDs = set(posData.IDs) + else: + possible_IDs = posData.whitelist.originalLabsIDs[posData.frame_i] + possible_IDs.update(posData.IDs) + + isAnyIDnotExisting = False + for ID in whitelistIDs: + if ID not in possible_IDs: + isAnyIDnotExisting = True + continue + if ID not in current_whitelist_copy: + current_whitelist.add(ID) + self.highlightLabelID(ID) + + for ID in current_whitelist_copy: + if ID not in possible_IDs: + isAnyIDnotExisting = True + continue + if ID not in whitelistIDs: + current_whitelist.remove(ID) + self.removeHighlightLabelID(IDs=[ID]) + + if wl_init: + posData.whitelist.whitelistIDs[posData.frame_i] = current_whitelist + else: + self.tempWhitelistIDs = current_whitelist + + self.whitelistUpdateTempLayer() + if isAnyIDnotExisting: + self.whitelistIDsToolbar.whitelistLineEdit.warnNotExistingID() + else: + self.whitelistIDsToolbar.whitelistLineEdit.setInstructionsText() + + # @exec_time + def whitelistUpdateTempLayer(self): + """Updates the temp layer with the current whitelist IDs. + """ + if not self.whitelistIDsButton.isChecked(): + self.keepIDsTempLayerLeft.clear() + return + + if not hasattr(self, 'keptLab'): + self.keptLab = np.zeros_like(self.currentLab2D) + keptLab = self.keptLab + else: + keptLab = self.keptLab + keptLab[:] = 0 + + posData = self.data[self.pos_i] + if posData.whitelist is None: + if not hasattr(self, 'tempWhitelistIDs'): + self.tempWhitelistIDs = set() # not updated, only use in this context + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = self.tempWhitelistIDs + else: + current_whitelist = posData.whitelist.get(posData.frame_i) + + for obj in posData.rp: + if obj.label not in current_whitelist: + continue + + if not self.isObjVisible(obj.bbox): + continue + + _slice = self.getObjSlice(obj.slice) + _objMask = self.getObjImage(obj.image, obj.bbox) + + keptLab[_slice][_objMask] = obj.label + + self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) \ No newline at end of file diff --git a/cellacdc/whitelist.py b/cellacdc/whitelist.py index 0621f4514..f0cee0fef 100644 --- a/cellacdc/whitelist.py +++ b/cellacdc/whitelist.py @@ -896,1112 +896,3 @@ def propagateIDs(self, return update_frames -class WhitelistGUIElements: - """A class to manage the whitelist GUI elements. - """ - def whitelistCheckOriginalLabels(self, warning:bool=True, - frame_i:int=None): - """Warns the user that there are no original labels labels are present - for the frame""" - posData = self.data[self.pos_i] - if posData.whitelist is None: - return False - - if frame_i is None: - frame_i = posData.frame_i - - if posData.whitelist.originalLabsIDs is None: - return False - - if (frame_i >= len(posData.whitelist.originalLabsIDs) or - posData.whitelist.originalLabsIDs[frame_i] is None): - txt = """ - No original labels are present for the current frame, - this action cannot be performed.""" - self.logger.warning(txt) - if not warning: - return False - msg = widgets.myMessageBox.warning( - self, 'No original labels', txt, - ) - - return False - else: - return True - - @disableWindow - def whitelistTrackOGagainstPreviousFrame_cb(self, signal_slot=None): - """Tracks the original labels against the previous frame. - This is used as a callback for sigTrackOGagainstPreviousFrame signal - """ - posData = self.data[self.pos_i] - frame_i = posData.frame_i - if not self.whitelistCheckOriginalLabels(): - return - old_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] - prev_cell_IDs = posData.allData_li[frame_i-1]['IDs'] - self.whitelistTrackOGCurr(against_prev=True) - new_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] - - new_IDs = new_cell_IDs - old_cell_IDs - new_IDs = new_IDs & set(prev_cell_IDs) - - self.whitelistUpdateLab( - track_og_curr=False, IDs_to_add=new_IDs, - ) - - def whitelistLoadOGLabs_cb(self): - """Generates a dialog to load the original (not whitelisted) labels - """ - posData = self.data[self.pos_i] - curr_seg_path = posData.segm_npz_path - - segmFilename = os.path.basename(curr_seg_path) - custom_first = f"{segmFilename[:-4]}_not_whitelisted.npz" - images_path = posData.images_path - existingEndnames = [ - files for files in os.listdir(images_path) if files.endswith('.npz') - ] - if custom_first not in existingEndnames: - custom_first = None - - infoText = html_utils.paragraph( - 'Select the segmentation file containing the original labels ' - 'of the objects. Pleae note that the current saved "original" ' - 'labels will be replaced with the new ones, but the filtered ' - 'labels will be kept.' - ) - - win = apps.SelectSegmFileDialog( - existingEndnames, images_path, parent=self, - basename=posData.basename, infoText=infoText, - custom_first=custom_first - ) - win.exec_() - if win.cancel: - self.logger.info('Loading original labels canceled.') - return - selected = win.selectedItemText - self.logger.info(f'Loading original labels from {selected}...') - self.whitelistLoadOGLabs(selected) - - @disableWindow - def whitelistLoadOGLabs(self, selected:str): - """Loads the original labels from the selected files - - Parameters - ---------- - selected : str - Selected file name from the dialog. - """ - posData = self.data[self.pos_i] - images_path = posData.images_path - - selected_path = os.path.join(images_path, selected) - posData.whitelist.loadOGLabs(selected_path) - - self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) - - @exception_handler - @disableWindow - def whitelistViewOGIDs(self, checked:bool): - """Switch between selected and original labels. - Uses self.viewOriginalLabels to see what has to be done. - - Parameters - ---------- - checked : bool - True if the original labels have to be shown, False otherwise. - """ - switch_to_og = checked and not self.viewOriginalLabels - switch_to_seg = not checked and self.viewOriginalLabels - - if not switch_to_og and not switch_to_seg: - return - - posData = self.data[self.pos_i] - if posData.whitelist is None: - return - - if posData.whitelist._debug: - printl('whitelistViewOGIDs', checked) - - frame_i = posData.frame_i - if frame_i > 0: - frames_range = [frame_i-1, frame_i] - else: - frames_range = [frame_i] - - self.store_data(autosave=False) - - if not self.whitelistCheckOriginalLabels(): - return - if switch_to_og: - self.setFrameNavigationDisabled(True, why='Viewing original labels') - self.viewOriginalLabels = True - - for i in frames_range: - posData.frame_i = i - self.get_data() - self.whitelistTrackOGCurr(frame_i=i) - - IDs = posData.IDs - - og_frame = posData.whitelist.originalLabs[i].copy() - IDs_to_uppdate = posData.whitelist.whitelistIDs[i] & posData.whitelist.originalLabsIDs[i] - if IDs_to_uppdate: - mask = np.isin(og_frame, list(IDs_to_uppdate)) - og_frame[mask] = 0 - - mask = np.isin(posData.lab, list(IDs_to_uppdate)) - og_frame[mask] = posData.lab[mask] - - IDs_to_add = posData.whitelist.whitelistIDs[i] - posData.whitelist.originalLabsIDs[i] - if IDs_to_add: - mask = np.isin(posData.lab, list(IDs_to_add)) - og_frame[mask] = posData.lab[mask] - - posData.lab = og_frame - self.update_rp(wl_update=False) - self.store_data(autosave=False) - - if frame_i > 0: - missing_IDs = set(posData.IDs) - set(posData.allData_li[frame_i-1]['IDs']) - self.trackManuallyAddedObject(missing_IDs,isNewID=True, wl_update=False) - - self.setAllTextAnnotations() - self.updateAllImages() - - elif switch_to_seg: - self.viewOriginalLabels = False - self.setFrameNavigationDisabled(False, why='Viewing original labels') - - for i in frames_range: - posData.frame_i = i - self.get_data() - try: - posData.whitelist.originalLabs[i] = posData.lab.copy() - posData.whitelist.originalLabsIDs[i] = set(posData.IDs) - except AttributeError: - lab = posData.segm_data[i].copy() - IDs = [obj.label for obj in skimage.measure.regionprops(lab)] - posData.whitelist.originalLabs[i] = lab - posData.whitelist.originalLabsIDs[i] = set(IDs) - - # self.whitelistTrackCurrOG() - self.update_rp(wl_update=False) - self.store_data(autosave=False) - self.whitelistUpdateLab(frame_i=i) #has update_rp and store data - self.setAllTextAnnotations() - self.updateAllImages() - - def whitelistSetViewOGIDsToggle(self, checked: bool): - """Set the view original labels toggle button to checked or unchecked. - This also updates the self.viewOriginalLabels variable. - !!! Doesn't change the actually displayed labels, use self.whitelistViewOGIDs - to do that.!!! - - Parameters - ---------- - checked : bool - True if the original labels are shown, False otherwise. - """ - self.viewOriginalLabels = checked - self.whitelistIDsToolbar.viewOGToggle.blockSignals(True) - self.whitelistIDsToolbar.viewOGToggle.setChecked(checked) - self.whitelistIDsToolbar.viewOGToggle.blockSignals(False) - - def whitelistAddNewIDsToggled(self, checked: bool): - """Will set self.addNewIDsWhitelistToggle to checked and call - whitelistAddNewIDs if checked is True. - - Parameters - ---------- - checked : bool - True if the add new IDs toggle is checked, False otherwise. - """ - self.addNewIDsWhitelistToggle = checked - if checked: - self.df_settings.at['addNewIDsWhitelistToggle', 'value'] = 'Yes' - else: - self.df_settings.at['addNewIDsWhitelistToggle', 'value'] = 'No' - self.df_settings.to_csv(self.settings_csv_path) - if checked: - self.whitelistAddNewIDs(ignore_not_first_time=True) - self.whitelistPropagateIDs() - self.updateAllImages() - self.whitelistIDsUpdateText() - - def whitelistAddNewIDs(self, ignore_not_first_time:bool=False): - """Function which adds new IDs to the whitelist, based on the original labels. - It will check if the frame is visited the first time, unless - ignore_not_first_time is True. - It does nothing if self.addNewIDsWhitelistToggle is False. - !!!Careful, does not change the lab, just the whitelist!!! - - Parameters - ---------- - ignore_not_first_time : bool, optional - Weather it should be checked if the frame is visited - the first time, by default False - """ - mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': - return - - if not self.addNewIDsWhitelistToggle: - return - - posData = self.data[self.pos_i] - if posData.whitelist is None: - return - - debug = posData.whitelist._debug - - if debug: - printl('whitelistAddNewIDs') - - posData = self.data[self.pos_i] - frame_i = posData.frame_i - - if self.get_last_tracked_i() > frame_i and not ignore_not_first_time: - return - - if frame_i == 0: - return - - if self.whitelistAddNewIDsFrame is not None and frame_i == self.whitelistAddNewIDsFrame: - return - - self.whitelistAddNewIDsFrame = frame_i - - curr_lab = self.get_curr_lab() - - posData.whitelist.addNewIDs(frame_i=frame_i, - allData_li=posData.allData_li, - IDs_curr=posData.IDs, - curr_lab=curr_lab) - - - def whitelistIDsAccepted(self, - whitelistIDs: Set[int] | List[int]): - """Function which is called when the user accepts a whitelist. - Also initializes the whitelist if it is not already initialized. (Aka not loaded) - - Parameters - ---------- - whitelistIDs : set | list - The accepted IDs from the whitelist dialog. - """ - # Store undo state before modifying stuff - self.storeUndoRedoStates(False) - - self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) - self.whitelistSetViewOGIDsToggle(False) - self.setFrameNavigationDisabled(False, why='Viewing original labels') - - self.store_data(autosave=False) - - posData = self.data[self.pos_i] - - if not posData.whitelist: - posData.whitelist = Whitelist( - total_frames=posData.SizeT, - ) - - if posData.whitelist._debug: - printl('whitelistIDsAccepted', whitelistIDs) - - whitelistIDs = set(whitelistIDs) - - IDs_curr = set(posData.IDs) - - posData.whitelist.IDsAccepted( - whitelistIDs, - segm_data=posData.segm_data, - frame_i=posData.frame_i, - allData_li=posData.allData_li, - IDs_curr=IDs_curr, - curr_lab=posData.lab, - - ) - - # self.whitelistPropagateIDs(new_whitelist=whitelistIDs, - # try_create_new_whitelists=True, - # only_future_frames=True, - # force_not_dynamic_update=True, - # update_lab=True - # ) - self.whitelistUpdateLab(track_og_curr=True) - - self.whitelistIDsUpdateText() - self.keepIDsTempLayerLeft.clear() - - def whitelistUpdateLab(self, frame_i: int=None, - track_og_curr=False, new_frame:bool=False, - IDs_to_add:List[int] | Set[int]=None, - IDs_to_remove:List[int]|Set[int]=None, - ): - # this should also work for 3D i think... - """Updates the displayed lab based on the whitelist. - - Parameters - ---------- - frame_i : int, optional - frame which should be updated. If not provided, - uses posData.frame_i, by default None - track_og_curr : bool, optional - if True, will track the original current IDs, by default False - new_frame : bool, optional - if True, will set the frame to the new frame, by default False - IDs_to_add : list, optional - IDs to add to the whitelist, by default None - IDs_to_remove : list, optional - IDs to remove from the whitelist, by default None - """ - got_data = False - benchmark = False - if benchmark: - ts = [time.perf_counter()] - titles = [ - '', - 'store_data', - 'whitelistSetViewOGIDsToggle', - 'get_data', - 'get what to add/remove', - 'track_og_curr', - 'get current lab', - 'add/remove IDs', - 'store data', - 'update images', - ] - - mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': - return - - posData = self.data[self.pos_i] - if posData.whitelist is None: - return - - if frame_i is None: - frame_i = posData.frame_i - og_frame_i = frame_i - else: - og_frame_i = posData.frame_i - posData.frame_i = frame_i - # getting data is handles later in the code - - debug = posData.whitelist._debug - if debug: - printl('whitelistUpdateLab', frame_i, og_frame_i) - from . import debugutils - debugutils.print_call_stack() - - if benchmark: - ts.append(time.perf_counter()) - - self.whitelistSetViewOGIDsToggle(False) ### - - if benchmark: - ts.append(time.perf_counter()) - - if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): - og_lab = posData.whitelist.originalLabs[frame_i] ### - else: - og_lab = None - if benchmark: - ts.append(time.perf_counter()) - - #### - whitelist = posData.whitelist.get(frame_i=frame_i) - IDs_to_add_remove_provided = IDs_to_add is not None or IDs_to_remove is not None - if not IDs_to_add_remove_provided: - self.get_data() - got_data = True - current_IDs = set(posData.IDs) - missing_IDs = list(whitelist - current_IDs) - to_be_removed_IDs = list(current_IDs - whitelist) - else: - missing_IDs = list(IDs_to_add) if IDs_to_add is not None else [] - to_be_removed_IDs = list(IDs_to_remove) if IDs_to_remove is not None else [] - - ### - - if benchmark: - ts.append(time.perf_counter()) - - ### - if not missing_IDs and not to_be_removed_IDs: # nothing to do - if og_frame_i != frame_i: - posData.frame_i = og_frame_i - if got_data and og_frame_i != frame_i: - self.get_data() - if benchmark: - print('No IDs to add/remove') - ts.append(time.perf_counter()) - indx = titles.index('track_og_curr') - titles[indx + 1] = 'store_data' - time_taken = time.perf_counter() - ts[0] - print(f'\nTotal time for whitelistUpdateLab: {time_taken:.2f}s') - for i in range(1, len(ts)): - time_taken = ts[i] - ts[i-1] - print(f'Time taken for {titles[i]}: {time_taken:.2f}s') - print('') - return - - if not got_data and og_frame_i != frame_i: - self.get_data() - got_data = True - - if benchmark: - ts.append(time.perf_counter()) - - ### - if missing_IDs and track_og_curr and not new_frame: - self.whitelistTrackOGCurr(frame_i=frame_i, - lab = posData.lab, - rp = posData.rp) - - missing_IDs = np.array(missing_IDs, dtype=np.int32) - to_be_removed_IDs = np.array(to_be_removed_IDs, dtype=np.int32) - - if debug: - printl(missing_IDs, to_be_removed_IDs) - - curr_lab = posData.lab # or curr_lab = posData.lab??? - # convert values to int if they are not already - if curr_lab is None: - try: - curr_lab = posData.allData_li[frame_i]['labels'].copy() - except: - pass - if curr_lab is None: - try: - curr_lab = posData.segm_data[frame_i].copy() - except: - pass - if curr_lab is None: - printl('No current lab?') - curr_lab = np.zeros_like(posData.segm_data[0]) - curr_lab = curr_lab.astype(np.int32) - if benchmark: - ts.append(time.perf_counter()) - - if missing_IDs.size > 0 and og_lab is not None: - mask = np.isin(og_lab, missing_IDs) # add missing_IDs - curr_lab[mask] = og_lab[mask] - - if to_be_removed_IDs.size > 0: - curr_lab[np.isin(curr_lab, to_be_removed_IDs)] = 0 # remove to_be_removed_IDs - - if benchmark: - ts.append(time.perf_counter()) - - posData.lab = curr_lab - - self.update_rp(wl_update=False) - self.store_data() - - if benchmark: - ts.append(time.perf_counter()) - if og_frame_i != frame_i: - posData.frame_i = og_frame_i - self.get_data() - - self.updateAllImages() - self.setAllTextAnnotations() - - if benchmark: - ts.append(time.perf_counter()) - time_taken = time.perf_counter() - ts[0] - print(f'\nTotal time for whitelistUpdateLab: {time_taken:.2f}s') - for i in range(1, len(ts)): - time_taken = ts[i] - ts[i-1] - print(f'Time taken for {titles[i]}: {time_taken:.2f}s') - print('') - - def whitelistIDsUpdateText(self): - """Updates the text. Carefull, triggers whitelistLineEdit.textChanged! - """ - mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': - return - - posData = self.data[self.pos_i] - if posData.whitelist is None: - return - - if posData.whitelist._debug: - printl('whitelistIDsUpdateText') - - frame_i = posData.frame_i - whitelist = posData.whitelist.get(frame_i=frame_i) - - self.whitelistIDsToolbar.whitelistLineEdit.setText(whitelist) - - def whitelistTrackOGCurr(self, frame_i:int=None, - against_prev:bool=False, - lab:np.ndarray=None, - rp:list=None, - IDs: Set[int] | List[int] =None): - """Track the original labels in relation to the current (whitelisted) - labels. - Parameters - - Parameters - ---------- - frame_i : int, optional - frame_i to be tracked, posData.frame_i if not provided, - by default None - against_prev : bool, optional - if the original frame should be tracked against frame_i-1. - Cannot be used with rp or lab, by default False - lab : np.ndarray, optional - lab to be tracked against, by default None - rp : list, optional - regionprops for this lab, by default None - IDs : Set[int] | List[int], optional - IDs that should be tracked based on og - - Raises - ------ - ValueError - Cannot provide both rp and lab when tracking against previous frame. - Instead only provide rp and lab, and dont set against_prev. - """ - posData = self.data[self.pos_i] - if posData.whitelist is None: - return - - debug = posData.whitelist._debug - - if debug: - from . import debugutils - debugutils.print_call_stack(depth=2) - printl('whitelistTrackOGCurr', against_prev) - - if against_prev and (rp is not None or lab is not None): - raise ValueError('Cannot provide both rp and lab when tracking' - ' against previous frame.' - 'Instead only provide rp and lab, and dont set against_prev.') - - if frame_i is None: - frame_i = posData.frame_i - - if against_prev and frame_i == 0: - return - - if not self.whitelistCheckOriginalLabels(warning=False, - frame_i=frame_i): - if debug: - printl('No original labels, cannot track.') - return - - og_frame_i = posData.frame_i - ### against what should I track? - - if lab is not None and not rp: - rp = skimage.measure.regionprops(lab) - - changed_frame = False - if lab is None: - if debug: - printl('No lab and no rp provided.') - if against_prev: - rp = posData.allData_li[frame_i-1]['regionprops'] - lab = posData.allData_li[frame_i-1]['labels'] - else: - if frame_i != og_frame_i: - self.store_data(autosave=False) - posData.frame_i = frame_i - self.get_data() - changed_frame = True - rp = posData.rp - lab = posData.lab - og_lab = posData.whitelist.originalLabs[frame_i] - og_rp = skimage.measure.regionprops(og_lab) - # lab = lab.copy() - - denom_overlap_matrix = 'union' if not against_prev else 'area_prev' - - og_lab = CellACDC_tracker.track_frame( - lab, rp, og_lab, og_rp, - denom_overlap_matrix=denom_overlap_matrix, - posData = posData, - setBrushID_func=self.setBrushID, - IDs=IDs, - # assign_unique_new_IDs=False, - ) - - posData.whitelist.originalLabs[frame_i] = og_lab - posData.whitelist.originalLabsIDs[frame_i] = {obj.label for obj in skimage.measure.regionprops(og_lab)} - - if changed_frame: - posData.frame_i = og_frame_i - self.get_data() - - def whitelistTrackCurrOG(self, frame_i:int=None, against_prev:bool=False): - """Track the current (whitelisted) labels in relation to the original labels. - Parameters - ---------- - frame_i : int, optional - frame_i to be tracked, posData.frame_i if not provided, by default None - against_prev : bool, optional - if the original frame should be tracked against frame_i-1. - """ - posData = self.data[self.pos_i] - if posData.whitelist is None: - return - - if posData.whitelist._debug: - printl('whitelistTrackCurrOG', frame_i, against_prev) - - if frame_i is None: - frame_i = posData.frame_i - - if against_prev and frame_i == 0: - return - - og_frame = posData.frame_i - if frame_i != og_frame: - self.store_data(autosave=False) - posData.frame_i = frame_i - self.get_data() - - lab = posData.lab - rp = posData.rp - - if not self.whitelistCheckOriginalLabels(warning=False, - frame_i=frame_i if not against_prev else frame_i-1): - if posData.whitelist._debug: - printl('No original labels, cannot track.') - return - - if against_prev: - og_lab = posData.whitelist.originalLabs[frame_i-1] - else: - og_lab = posData.whitelist.originalLabs[frame_i] - - og_rp = skimage.measure.regionprops(og_lab) - - denom_overlap_matrix = 'union' if not against_prev else 'area_prev' - - lab = CellACDC_tracker.track_frame( - og_lab, og_rp, lab, rp, - denom_overlap_matrix=denom_overlap_matrix, - posData = posData, - setBrushID_func=self.setBrushID - ) - - posData.lab = lab - - self.update_rp(wl_update=False) - self.store_data(autosave=False) - - if frame_i != og_frame: - posData.frame_i = og_frame - self.get_data() - - def whitelistSyncIDsOG(self, - frame_is: List[int]=None, - against_prev: bool=False,): - """Interates over the frames and calls whitelistTrackOGCurr for each frame. - - Parameters - ---------- - frame_is : List[int], optional - list of frame_i, if None goes through all, by default None - against_prev : bool, optional - if the original frame should be tracked against frame_i-1. - """ - posData = self.data[self.pos_i] - if frame_is is None: - frame_is = range(posData.SizeT) - - for frame_i in frame_is: - self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=against_prev) - - def whitelistInitNewFrames(self, frame_i:int=None, force:bool=False): - """Initialize the whitelist for a new frame. The class whitelist keeps track - of the init frames and doesnt try to init them again, unless forced. - Does not init the class! - - Parameters - ---------- - frame_i : int, optional - frame_i to be init, posData.frame_i if not provided, by default None - force : bool, optional - if the init should be forced, by default False - - Returns - ------- - bool - if the frame was new or not - list - list of frames that were updated, and info about added/removed IDs - """ - - posData = self.data[self.pos_i] - if posData.whitelist is None: - return False, [] - - if frame_i is None: - frame_i = posData.frame_i - - if posData.whitelist._debug: - printl('whitelistInitNewFrames', frame_i, force) - - if frame_i not in posData.whitelist.initialized_i: - self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=True) - - new_frame, update_frames = posData.whitelist.initNewFrames( - frame_i=frame_i, force=force) - - self.whitelistAddNewIDs() - return new_frame, update_frames - - # @exec_time - def whitelistPropagateIDs(self, - new_whitelist: Set[int] | List[int] = None, - IDs_to_add: Set[int] = None, - IDs_to_remove: Set[int] = None, - frame_i: int = None, - try_create_new_whitelists: bool = False, - curr_frame_only: bool = False, - force_not_dynamic_update: bool = False, - only_future_frames: bool = True, - allow_only_current_IDs: bool = False, - track_og_curr: bool = True, - IDs_curr: Set[int] | List[int] = None, - index_lab_combo: Tuple[int, np.ndarray] = None, - curr_rp: list = None, - curr_lab: np.ndarray = None, - store_data: bool = True, - update_lab: bool = False, - ): - """ - Propagates whitelist IDs across frames in the dataset. (Doesnt update labs) - Should also be called when viewing a new frame! - - This function updates whitelist. If curr_frame_only is True, it only updates the - whitelist of the current frame. If the frame changes, this function should be called - again to update the whitelist for the new frame (without this argument). - It should also handle cases were this is not done, but this is less safe. - Then, all the additions and removals are propagated to the other frames. - If force_not_dynamic_update is True, the function will propagate the entire whitelist to - frames, and not only the IDs which were added or removed. - - Hierarchy of arguments for current_IDs: - 1. IDs_curr (if provided) - (2. index_lab_combo (if provided) (is also passed to not current frame only - propagation if that propagation is necessary, and used when the frame_i matches)) - 3. curr_rp (if provided) - 4. curr_lab (if provided) - 5. allData_li - - Parameters - ---------- - new_whitelist : Set[int] | List[int], optional - A new set of whitelist IDs to replace the current whitelist. Cannot be - used together with `IDs_to_add` or `IDs_to_remove`, by default None. - IDs_to_add : Set[int], optional - A set of IDs to add to the current whitelist, by default None. - IDs_to_remove : Set[int], optional - A set of IDs to remove from the current whitelist, by default None. - frame_i : int, optional - The frame index for the propagation. - If None, uses posData.frame_i, by default None. - try_create_new_whitelists : bool, optional - If True, creates new whitelist entries for frames that do not already - have them. Should only be necessary when its initialized, by default False. - curr_frame_only : bool, optional - If True, only updates the whitelist for the current frame. - (See description of function), by default False. - force_not_dynamic_update : bool, optional - If True, disables dynamic updates to the whitelist. - (See description of function), by default False. - only_future_frames : bool, optional - If True, propagates changes only to future frames, by default True. - allow_only_current_IDs : bool, optional - If True, only allows IDs that are present in the current frame - to be added to the whitelist, by default True. - track_og_curr : bool, optional - If True, tracks the original labels in relation to the current - (whitelisted) labels. This is done by calling whitelistTrackOGCurr. - If its a new frame, this is done in whitelistInitNewFrames against the - previous frame, - by default True. - IDs_curr : Set[int] | List[int], optional - A set of IDs for the current frame, if None, - will be calculated from other stuff (see description), by default None. - index_lab_combo : Tuple[int, np.ndarray], optional - Combination of frame_i and current frame, - Used to get IDs_curr (see description), when the frame_i matches - (is also passed to not current frame only - propagation if that propagation is necessary, - and used when the frame_i matches), by default None. - curr_rp : list, optional - Region properties for the current frame. For IDs_curr. (see description), - by default None. - curr_lab : np.ndarray, optional - Labels for the current frame for IDs_curr. (see description), - by default None. - store_data : bool, optional - If True, stores the data before propagating the IDs. - update_lab : bool, optional - If True, updates the labels after propagating the IDs. - Will always update labels for newly init frames, by default False. - - Raises - ------ - ValueError - If both `new_whitelistIDs` and `IDs_to_add`/`IDs_to_remove` are provided. - - Example - ------- - To add IDs 5 and 6 to the whitelist for the current frame: - ```python - self.whitelistPropagateIDs(IDs_to_add={5, 6}, curr_frame_only=True) - ``` - Then when the frame changes: - ```python - self.whitelistPropagateIDs() - ``` - - To replace the whitelist for frame 10 with a new set of IDs: - ```python - self.whitelistPropagateIDs(new_whitelistIDs={1, 2, 3}, frame_i=10) - ``` - This would also propagate the changes to all other frames. - - """ - #doesnt update the frame displayed, only wl - try: # safety XD - IDs_curr = IDs_curr.copy() - except AttributeError: - pass - - IDs_curr = set(IDs_curr) if IDs_curr is not None else None - - posData = self.data[self.pos_i] - - debug = posData.whitelist._debug if posData.whitelist is not None else False - - if debug: - printl('Propagating IDs...') - from . import debugutils - debugutils.print_call_stack() - printl(new_whitelist, IDs_to_add, IDs_to_remove) - - if posData.whitelist is None: - return - - # og_frame_i = posData.frame_i - if frame_i is None: - frame_i = posData.frame_i - - new_frame, update_frames_init = self.whitelistInitNewFrames(frame_i=frame_i) - - if new_frame: - self.update_rp(wl_update=False) - # if track_og_curr and not new_frame: - # self.whitelistTrackOGCurr(frame_i=frame_i, rp=curr_rp, lab=curr_lab) - - update_frames = posData.whitelist.propagateIDs( - frame_i, - posData.allData_li, - new_whitelist=new_whitelist, - IDs_to_add=IDs_to_add, - IDs_to_remove=IDs_to_remove, - try_create_new_whitelists=try_create_new_whitelists, - curr_frame_only=curr_frame_only, - force_not_dynamic_update=force_not_dynamic_update, - only_future_frames=only_future_frames, - allow_only_current_IDs=allow_only_current_IDs, - IDs_curr=IDs_curr, - index_lab_combo=index_lab_combo, - curr_rp=curr_rp, - curr_lab=curr_lab, - ) - if update_lab: - update_frames = update_frames_init + update_frames - else: - update_frames = update_frames_init - # printl(posData.whitelistIDs[frame_i]) - # posData.frame_i = og_frame_i - self.whitelistIDsUpdateText() - if store_data: - self.store_data(autosave=False) - - for frame_i, IDs_to_add, IDs_to_remove, new_frame in update_frames: - self.whitelistUpdateLab(frame_i=frame_i, track_og_curr=track_og_curr, - new_frame=new_frame, IDs_to_add=IDs_to_add, - IDs_to_remove=IDs_to_remove, ) - - def whitelistIDs_cb(self, checked:bool): - """Callback for when the whitelist IDs button is checked or unchecked. - Initialises the pointlayer and the whitelist IDs toolbar if checked. - - Parameters - ---------- - checked : bool - True if the whitelist IDs button is checked, False otherwise. - """ - if checked: - self.initKeepObjLabelsLayers() - self.disconnectLeftClickButtons() - self.uncheckLeftClickButtons(self.whitelistIDsButton) - self.connectLeftClickButtons() - - self.whitelistIDsToolbar.setVisible(checked) - self.whitelistHighlightIDs(checked) - self.whitelistIDsUpdateText() - self.whitelistUpdateTempLayer() - - if not checked: - self.setLostNewOldPrevIDs() - self.updateAllImages() - - def whitelistHighlightIDs(self, checked:bool=True): - """Highlights the IDs in the current frame based on the whitelist. - - Parameters - ---------- - checked : bool, optional - If False, will delete all highlights, by default True - """ - if not checked: - self.removeHighlightLabelID() - return - - posData = self.data[self.pos_i] - - if posData.whitelist is None: - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() # not updated, only use in this context - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = posData.whitelist.get( - frame_i=posData.frame_i) - - for ID in current_whitelist: - self.highlightLabelID(ID) - - def whitelistIDsChanged(self, - whitelistIDs: Set[int] | List[int], - debug: bool=False): - """Callback for when the whitelist IDs are changed. - This is called when the user changed the IDs in the whitelist IDs toolbar - (or when its programmatically changed, but if its not - visible it should return instantly) - Will update the temp layer and also complain when IDs - are not valid/present in the current lab - - Parameters - ---------- - whitelistIDs : set | list - The IDs that are currently in the whitelist. - debug : bool, optional - debug, by default False - """ - if not self.whitelistIDsButton.isChecked(): - return - - posData = self.data[self.pos_i] - - if posData.whitelist: - debug = posData.whitelist._debug - if debug: - printl('whitelistIDsChanged', whitelistIDs) - - if posData.whitelist is None: - wl_init = False - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() # not updated, only use in this context - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = self.tempWhitelistIDs - else: - wl_init = True - current_whitelist = posData.whitelist.get( - frame_i=posData.frame_i) - - current_whitelist_copy = current_whitelist.copy() - if not hasattr(posData, 'originalLabsIDs') or posData.whitelist.originalLabsIDs is None: - possible_IDs = posData.IDs.copy() - else: - if not self.whitelistCheckOriginalLabels(warning=False): - possible_IDs = set(posData.IDs) - else: - possible_IDs = posData.whitelist.originalLabsIDs[posData.frame_i] - possible_IDs.update(posData.IDs) - - isAnyIDnotExisting = False - for ID in whitelistIDs: - if ID not in possible_IDs: - isAnyIDnotExisting = True - continue - if ID not in current_whitelist_copy: - current_whitelist.add(ID) - self.highlightLabelID(ID) - - for ID in current_whitelist_copy: - if ID not in possible_IDs: - isAnyIDnotExisting = True - continue - if ID not in whitelistIDs: - current_whitelist.remove(ID) - self.removeHighlightLabelID(IDs=[ID]) - - if wl_init: - posData.whitelist.whitelistIDs[posData.frame_i] = current_whitelist - else: - self.tempWhitelistIDs = current_whitelist - - self.whitelistUpdateTempLayer() - if isAnyIDnotExisting: - self.whitelistIDsToolbar.whitelistLineEdit.warnNotExistingID() - else: - self.whitelistIDsToolbar.whitelistLineEdit.setInstructionsText() - - # @exec_time - def whitelistUpdateTempLayer(self): - """Updates the temp layer with the current whitelist IDs. - """ - if not self.whitelistIDsButton.isChecked(): - self.keepIDsTempLayerLeft.clear() - return - - if not hasattr(self, 'keptLab'): - self.keptLab = np.zeros_like(self.currentLab2D) - keptLab = self.keptLab - else: - keptLab = self.keptLab - keptLab[:] = 0 - - posData = self.data[self.pos_i] - if posData.whitelist is None: - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() # not updated, only use in this context - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = self.tempWhitelistIDs - else: - current_whitelist = posData.whitelist.get(posData.frame_i) - - for obj in posData.rp: - if obj.label not in current_whitelist: - continue - - if not self.isObjVisible(obj.bbox): - continue - - _slice = self.getObjSlice(obj.slice) - _objMask = self.getObjImage(obj.image, obj.bbox) - - keptLab[_slice][_objMask] = obj.label - - self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) \ No newline at end of file From ff30e075eeb67b4575e12733ed75d80931ea801b Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 11:20:32 +0200 Subject: [PATCH 11/21] refactor: wire mixin dependency graph with upstream parent inheritance Express mixin dependencies as a layered parent map so each class inherits its upstream mixins, trim guiWin to 21 root bases, and add tooling to maintain the graph without invalid MRO or import cycles. Co-authored-by: Cursor --- cellacdc/gui.py | 122 +++------- cellacdc/mixins/__init__.py | 71 ++---- cellacdc/mixins/_apply_parents.py | 154 ++++++++++++ cellacdc/mixins/_graph.py | 297 +++++++++++++++++++++++ cellacdc/mixins/actions.py | 3 +- cellacdc/mixins/annotation_display.py | 3 +- cellacdc/mixins/app_shell.py | 9 +- cellacdc/mixins/brush_tools.py | 4 +- cellacdc/mixins/canvas_context_menu.py | 3 +- cellacdc/mixins/canvas_drawing.py | 4 +- cellacdc/mixins/canvas_events.py | 5 +- cellacdc/mixins/canvas_hover.py | 3 +- cellacdc/mixins/canvas_right_image.py | 4 +- cellacdc/mixins/canvas_selection.py | 4 +- cellacdc/mixins/cell_cycle.py | 3 +- cellacdc/mixins/combine.py | 4 +- cellacdc/mixins/curvature_tools.py | 4 +- cellacdc/mixins/custom_annotations.py | 4 +- cellacdc/mixins/data_loading.py | 3 +- cellacdc/mixins/deleted_rois.py | 3 +- cellacdc/mixins/draw_clear_region.py | 3 +- cellacdc/mixins/exporting.py | 4 +- cellacdc/mixins/frame_navigation.py | 4 +- cellacdc/mixins/graphics.py | 3 +- cellacdc/mixins/image_controls.py | 3 +- cellacdc/mixins/image_display.py | 3 +- cellacdc/mixins/label_editing.py | 3 +- cellacdc/mixins/label_roi.py | 3 +- cellacdc/mixins/label_transform_tools.py | 4 +- cellacdc/mixins/layout_controls.py | 5 +- cellacdc/mixins/lineage_interactions.py | 4 +- cellacdc/mixins/magic_prompts.py | 3 +- cellacdc/mixins/main_toolbar.py | 3 +- cellacdc/mixins/mode_controls.py | 3 +- cellacdc/mixins/object_cleanup.py | 3 +- cellacdc/mixins/object_properties.py | 4 +- cellacdc/mixins/object_search.py | 3 +- cellacdc/mixins/points_layers.py | 3 +- cellacdc/mixins/preprocessing.py | 3 +- cellacdc/mixins/quick_settings.py | 3 +- cellacdc/mixins/saving.py | 3 +- cellacdc/mixins/seg_for_lost_ids.py | 4 +- cellacdc/mixins/segmentation.py | 3 +- cellacdc/mixins/session.py | 3 +- cellacdc/mixins/status_hover.py | 3 +- cellacdc/mixins/tool_activation.py | 3 +- cellacdc/mixins/tracking.py | 3 +- cellacdc/mixins/undo_redo.py | 3 +- cellacdc/mixins/window_events.py | 4 +- cellacdc/mixins/worker.py | 3 +- 50 files changed, 614 insertions(+), 192 deletions(-) create mode 100644 cellacdc/mixins/_apply_parents.py create mode 100644 cellacdc/mixins/_graph.py diff --git a/cellacdc/gui.py b/cellacdc/gui.py index c58d742e6..72daa3fde 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -12,59 +12,27 @@ custom_annot_path = os.path.join(settings_folderpath, 'custom_annotations.json') shortcut_filepath = os.path.join(settings_folderpath, 'shortcuts.ini') from .mixins import ( - Actions, - AnnotationDisplay, - AppShell, - BrushTools, - CanvasContextMenu, - CanvasDrawing, - CanvasEvents, - CanvasHover, + WhitelistGui, + DataLoading, CanvasRightImage, - CanvasSelection, - CanvasTool, - CellCycle, - CombineGui, + CanvasHover, + LineageInteractions, + CustomAnnotations, + MagicPrompts, + ObjectSearch, + ObjectCleanup, + SegForLostIds, + Exporting, CombineWorker, CurvatureTools, - CustomAnnotations, - DataLoading, - DeletedRois, - DisplayDecorations, DrawClearRegion, - Exporting, - FrameNavigation, - Geometry, - Graphics, - ImageControls, - ImageDisplay, - LabelEditing, - LabelRoi, LabelTransformTools, - LayoutControls, - LineageInteractions, - MagicPrompts, - MainMenu, + DeletedRois, + Saving, MainToolbar, - Measurements, - ModeControls, - ObjectCleanup, - ObjectProperties, - ObjectSearch, - PointsLayers, - Preprocessing, QuickSettings, - Saving, - SegForLostIds, - Segmentation, - Session, - StatusHover, - ToolActivation, - Tracking, - UndoRedo, - WhitelistGui, - WindowEvents, - Worker, + MainMenu, + Measurements, ) np.seterr(invalid='ignore') @@ -80,58 +48,26 @@ class guiWin(QMainWindow, WhitelistGui, - CombineGui, - CombineWorker, - Geometry, - Worker, - Session, - Actions, - MainMenu, - MainToolbar, - LayoutControls, - ImageControls, - QuickSettings, - CanvasTool, - CanvasEvents, - CanvasDrawing, - CanvasHover, - CanvasSelection, - CanvasContextMenu, - CanvasRightImage, - BrushTools, - CurvatureTools, - LabelRoi, - DrawClearRegion, - SegForLostIds, - ObjectSearch, - ToolActivation, - LabelEditing, - CellCycle, - DeletedRois, - DisplayDecorations, - StatusHover, - ModeControls, - AnnotationDisplay, - UndoRedo, - Tracking, - Segmentation, - Preprocessing, - Measurements, - Saving, DataLoading, - ObjectCleanup, - ObjectProperties, - PointsLayers, + CanvasRightImage, + CanvasHover, + LineageInteractions, CustomAnnotations, MagicPrompts, - ImageDisplay, - Graphics, - LineageInteractions, + ObjectSearch, + ObjectCleanup, + SegForLostIds, Exporting, - AppShell, - FrameNavigation, + CombineWorker, + CurvatureTools, + DrawClearRegion, LabelTransformTools, - WindowEvents): + DeletedRois, + Saving, + MainToolbar, + QuickSettings, + MainMenu, + Measurements): """Main Window.""" sigClosed = Signal(object) diff --git a/cellacdc/mixins/__init__.py b/cellacdc/mixins/__init__.py index 95177a1b6..a247ec3e1 100644 --- a/cellacdc/mixins/__init__.py +++ b/cellacdc/mixins/__init__.py @@ -2,55 +2,22 @@ from __future__ import annotations -from .actions import Actions -from .annotation_display import AnnotationDisplay -from .app_shell import AppShell -from .brush_tools import BrushTools -from .canvas_context_menu import CanvasContextMenu -from .canvas_drawing import CanvasDrawing -from .canvas_events import CanvasEvents -from .canvas_hover import CanvasHover -from .canvas_right_image import CanvasRightImage -from .canvas_selection import CanvasSelection -from .canvas_tool import CanvasTool -from .cell_cycle import CellCycle -from .combine import CombineGui, CombineWorker -from .curvature_tools import CurvatureTools -from .custom_annotations import CustomAnnotations -from .data_loading import DataLoading -from .deleted_rois import DeletedRois -from .display_decorations import DisplayDecorations -from .draw_clear_region import DrawClearRegion -from .exporting import Exporting -from .frame_navigation import FrameNavigation -from .geometry import Geometry -from .graphics import Graphics -from .image_controls import ImageControls -from .image_display import ImageDisplay -from .label_editing import LabelEditing -from .label_roi import LabelRoi -from .label_transform_tools import LabelTransformTools -from .layout_controls import LayoutControls -from .lineage_interactions import LineageInteractions -from .magic_prompts import MagicPrompts -from .main_menu import MainMenu -from .main_toolbar import MainToolbar -from .measurements import Measurements -from .mode_controls import ModeControls -from .object_cleanup import ObjectCleanup -from .object_properties import ObjectProperties -from .object_search import ObjectSearch -from .points_layers import PointsLayers -from .preprocessing import Preprocessing -from .quick_settings import QuickSettings -from .saving import Saving -from .seg_for_lost_ids import SegForLostIds -from .segmentation import Segmentation -from .session import Session -from .status_hover import StatusHover -from .tool_activation import ToolActivation -from .tracking import Tracking -from .undo_redo import UndoRedo -from .whitelist import WhitelistGui -from .window_events import WindowEvents -from .worker import Worker +import importlib + +_GRAPH = None + + +def _load_graph(): + global _GRAPH + if _GRAPH is None: + _GRAPH = importlib.import_module("cellacdc.mixins._graph") + return _GRAPH + + +def __getattr__(name: str): + graph = _load_graph() + if name not in graph.MODULE_TO_CLASS.values(): + raise AttributeError(name) + module = next(k for k, v in graph.MODULE_TO_CLASS.items() if v == name) + mod = importlib.import_module(f"cellacdc.mixins.{graph.file_module(module)}") + return getattr(mod, name) diff --git a/cellacdc/mixins/_apply_parents.py b/cellacdc/mixins/_apply_parents.py new file mode 100644 index 000000000..80dc76c77 --- /dev/null +++ b/cellacdc/mixins/_apply_parents.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Apply upstream mixin parents to mixin class definitions.""" + +from __future__ import annotations + +import importlib.util +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MIXINS = ROOT / "mixins" +GUI = ROOT / "gui.py" + + +def load_graph(): + spec = importlib.util.spec_from_file_location( + "cellacdc_mixins_graph", MIXINS / "_graph.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +GRAPH = load_graph() +MIXIN_PARENTS = GRAPH.MIXIN_PARENTS +class_name = GRAPH.class_name +file_module = GRAPH.file_module +guiwin_classes = GRAPH.guiwin_classes +guiwin_roots = GRAPH.guiwin_roots + +FILE_CLASSES: dict[str, list[tuple[str, str]]] = { + "combine": [("combine", "CombineGui"), ("combine_worker", "CombineWorker")], +} + + +def parent_imports(module: str, parents: tuple[str, ...]) -> list[str]: + lines = [] + seen: set[str] = set() + child_file = file_module(module) + for p in parents: + fm = file_module(p) + if fm == child_file: + continue + cn = class_name(p) + key = (fm, cn) + if key in seen: + continue + seen.add(key) + lines.append(f"from .{fm} import {cn}") + return lines + + +def rewrite_class_bases(content: str, cls: str, parents: tuple[str, ...]) -> str: + parent_names = [class_name(p) for p in parents] + if parent_names: + bases = ", ".join(parent_names) + content = re.sub( + rf"^class {cls}\([^)]*\)\:", + f"class {cls}({bases}):", + content, + count=1, + flags=re.MULTILINE, + ) + content = re.sub( + rf"^class {cls}\:", + f"class {cls}({bases}):", + content, + count=1, + flags=re.MULTILINE, + ) + else: + content = re.sub( + rf"^class {cls}\([^)]*\)\:", + f"class {cls}:", + content, + count=1, + flags=re.MULTILINE, + ) + return content + + +def inject_imports(content: str, import_lines: list[str]) -> str: + if not import_lines: + # strip stale mixin imports + return strip_old_mixin_imports(content) + block = "\n".join(import_lines) + content = strip_old_mixin_imports(content) + idx = content.find("\n\nclass ") + if idx == -1: + raise ValueError("Could not find class definition anchor") + return content[:idx] + "\n" + block + content[idx:] + + +def strip_old_mixin_imports(content: str) -> str: + lines = [] + for line in content.splitlines(keepends=True): + if re.match(r"from \.[a-z_]+ import [A-Z]", line): + continue + lines.append(line) + return "".join(lines) + + +def apply_file(module: str, cls: str) -> None: + parents = MIXIN_PARENTS.get(module, ()) + fp = MIXINS / f"{file_module(module)}.py" + content = fp.read_text() + content = rewrite_class_bases(content, cls, parents) + content = inject_imports(content, parent_imports(module, parents)) + fp.write_text(content) + print(f" {module}({cls}) <- {[class_name(p) for p in parents]}") + + +def update_gui() -> None: + classes = guiwin_classes() + src = GUI.read_text() + import_block = "from .mixins import (\n" + import_block += "".join(f" {c},\n" for c in classes) + import_block += ")\n" + src = re.sub( + r"from \.mixins import \(\n.*?\n\)\n", + import_block, + src, + count=1, + flags=re.DOTALL, + ) + bases = ",\n ".join(classes) + src = re.sub( + r"class guiWin\(QMainWindow,\n(?: .+\n)+?\):", + f"class guiWin(QMainWindow,\n {bases}):", + src, + count=1, + ) + GUI.write_text(src) + + +def main() -> None: + cycles = GRAPH.import_cycles() + if cycles: + raise SystemExit(f"Import cycles in mixin graph: {cycles}") + for mod in sorted(MIXIN_PARENTS): + if mod == "combine_worker": + continue + if mod in FILE_CLASSES: + continue + apply_file(mod, class_name(mod)) + for mod, cls in FILE_CLASSES["combine"]: + apply_file(mod, cls) + update_gui() + print("\nguiWin roots:", guiwin_classes()) + + +if __name__ == "__main__": + main() diff --git a/cellacdc/mixins/_graph.py b/cellacdc/mixins/_graph.py new file mode 100644 index 000000000..9e39d3296 --- /dev/null +++ b/cellacdc/mixins/_graph.py @@ -0,0 +1,297 @@ +"""Mixin dependency graph and parent assignments for guiWin MRO.""" + +from __future__ import annotations + +# Layered parent map (downstream -> upstream). Edges only go from higher layers +# to lower layers so importing a mixin loads its parents first without cycles. +_RAW_MIXIN_PARENTS: dict[str, tuple[str, ...]] = { + # Layer 0 — foundation + "display_decorations": (), + "geometry": (), + "main_menu": (), + "measurements": (), + "canvas_tool": (), + "whitelist": (), + "combine": (), + # Layer 1 — display / chrome helpers + "image_display": ("display_decorations",), + "actions": ("image_display",), + "status_hover": ("image_display",), + "main_toolbar": ("actions",), + "quick_settings": ("actions",), + # Layer 2 — workers / session + "worker": ("image_display", "status_hover"), + "session": ("image_display", "worker"), + "app_shell": ("actions", "session"), + "tool_activation": ("image_display", "session", "worker"), + # Layer 3 — tools & canvas primitives + "brush_tools": ("geometry", "image_display", "tool_activation"), + "canvas_context_menu": ("image_display",), + "canvas_selection": ("canvas_tool", "geometry", "brush_tools"), + "label_editing": ("image_display", "session", "tool_activation"), + "undo_redo": ("session", "label_editing"), + "points_layers": ("image_display", "brush_tools"), + "mode_controls": ("session", "tool_activation"), + "annotation_display": ("image_display", "tool_activation", "mode_controls"), + # Layer 4 — canvas interaction stack + "canvas_drawing": ( + "canvas_selection", + "brush_tools", + "label_editing", + "image_display", + ), + "canvas_events": ( + "geometry", + "canvas_context_menu", + "canvas_selection", + "brush_tools", + "label_editing", + "image_display", + ), + "canvas_hover": ("canvas_events", "brush_tools", "tool_activation"), + "curvature_tools": ("brush_tools", "tool_activation", "undo_redo"), + "draw_clear_region": ("label_editing", "undo_redo", "image_display"), + "label_transform_tools": ("brush_tools", "label_editing", "image_display"), + "label_roi": ("session", "image_display", "brush_tools"), + # Layer 5 — domain features + "cell_cycle": ("session", "label_editing", "undo_redo", "image_display"), + "tracking": ("session", "label_editing", "tool_activation", "undo_redo"), + "deleted_rois": ("session", "cell_cycle", "tool_activation"), + "object_properties": ("cell_cycle", "image_display", "tracking"), + "segmentation": ("session", "image_display", "tool_activation"), + "preprocessing": ("image_display", "worker", "session"), + "saving": ("session", "worker", "app_shell"), + "graphics": ("image_display", "points_layers", "worker"), + "lineage_interactions": ("annotation_display", "tracking", "image_display"), + "custom_annotations": ("annotation_display", "object_properties"), + "magic_prompts": ("graphics", "session", "worker"), + # Layer 6 — high-level orchestrators + "frame_navigation": ( + "session", + "graphics", + "label_editing", + "display_decorations", + ), + "data_loading": ( + "app_shell", + "session", + "tool_activation", + "layout_controls", + ), + "image_controls": ("image_display", "frame_navigation"), + "window_events": ( + "app_shell", + "frame_navigation", + "label_editing", + "tool_activation", + ), + "layout_controls": ("image_controls", "window_events", "label_roi"), + "canvas_right_image": ("canvas_drawing", "canvas_events", "canvas_context_menu"), + "object_search": ("frame_navigation", "graphics", "session"), + "object_cleanup": ("cell_cycle", "session", "image_display"), + "seg_for_lost_ids": ("segmentation", "frame_navigation", "label_editing", "session"), + "exporting": ("app_shell", "frame_navigation", "session"), + "combine_worker": ("combine", "graphics", "preprocessing", "worker"), +} + + +def _ancestors( + module: str, + graph: dict[str, tuple[str, ...]], + cache: dict[str, frozenset[str]], +) -> frozenset[str]: + if module not in cache: + seen: set[str] = set() + for parent in graph.get(module, ()): + seen.add(parent) + seen |= _ancestors(parent, graph, cache) + cache[module] = frozenset(seen) + return cache[module] + + +def _reduce_mixin_parents( + raw: dict[str, tuple[str, ...]], +) -> dict[str, tuple[str, ...]]: + """Drop direct parents already inherited through another direct parent.""" + cache: dict[str, frozenset[str]] = {} + reduced: dict[str, tuple[str, ...]] = {} + for module, parents in raw.items(): + kept = tuple( + parent + for parent in parents + if not any( + parent != other and parent in _ancestors(other, raw, cache) + for other in parents + ) + ) + reduced[module] = kept + return reduced + + +MIXIN_PARENTS = _reduce_mixin_parents(_RAW_MIXIN_PARENTS) + +MODULE_TO_CLASS: dict[str, str] = { + "actions": "Actions", + "annotation_display": "AnnotationDisplay", + "app_shell": "AppShell", + "brush_tools": "BrushTools", + "canvas_context_menu": "CanvasContextMenu", + "canvas_drawing": "CanvasDrawing", + "canvas_events": "CanvasEvents", + "canvas_hover": "CanvasHover", + "canvas_right_image": "CanvasRightImage", + "canvas_selection": "CanvasSelection", + "canvas_tool": "CanvasTool", + "cell_cycle": "CellCycle", + "combine": "CombineGui", + "combine_worker": "CombineWorker", + "curvature_tools": "CurvatureTools", + "custom_annotations": "CustomAnnotations", + "data_loading": "DataLoading", + "deleted_rois": "DeletedRois", + "display_decorations": "DisplayDecorations", + "draw_clear_region": "DrawClearRegion", + "exporting": "Exporting", + "frame_navigation": "FrameNavigation", + "geometry": "Geometry", + "graphics": "Graphics", + "image_controls": "ImageControls", + "image_display": "ImageDisplay", + "label_editing": "LabelEditing", + "label_roi": "LabelRoi", + "label_transform_tools": "LabelTransformTools", + "layout_controls": "LayoutControls", + "lineage_interactions": "LineageInteractions", + "magic_prompts": "MagicPrompts", + "main_menu": "MainMenu", + "main_toolbar": "MainToolbar", + "measurements": "Measurements", + "mode_controls": "ModeControls", + "object_cleanup": "ObjectCleanup", + "object_properties": "ObjectProperties", + "object_search": "ObjectSearch", + "points_layers": "PointsLayers", + "preprocessing": "Preprocessing", + "quick_settings": "QuickSettings", + "saving": "Saving", + "seg_for_lost_ids": "SegForLostIds", + "segmentation": "Segmentation", + "session": "Session", + "status_hover": "StatusHover", + "tool_activation": "ToolActivation", + "tracking": "Tracking", + "undo_redo": "UndoRedo", + "whitelist": "WhitelistGui", + "window_events": "WindowEvents", + "worker": "Worker", +} + +MODULE_FILE: dict[str, str] = { + "combine": "combine", + "combine_worker": "combine", +} + + +def class_name(module: str) -> str: + return MODULE_TO_CLASS[module] + + +def file_module(module: str) -> str: + return MODULE_FILE.get(module, module) + + +def guiwin_roots() -> list[str]: + """Modules listed directly on guiWin (not inherited via another root).""" + all_parents = {p for ps in MIXIN_PARENTS.values() for p in ps} + roots = [m for m in MODULE_TO_CLASS if m not in all_parents] + # combine is parent of combine_worker + roots = [m for m in roots if m != "combine"] + + order = [ + "whitelist", + "layout_controls", + "data_loading", + "canvas_right_image", + "canvas_hover", + "window_events", + "frame_navigation", + "graphics", + "lineage_interactions", + "custom_annotations", + "magic_prompts", + "object_search", + "object_cleanup", + "seg_for_lost_ids", + "exporting", + "combine_worker", + "curvature_tools", + "draw_clear_region", + "label_transform_tools", + "deleted_rois", + "cell_cycle", + "tracking", + "segmentation", + "preprocessing", + "saving", + "object_properties", + "annotation_display", + "mode_controls", + "main_toolbar", + "quick_settings", + "main_menu", + "measurements", + "canvas_events", + "canvas_drawing", + "canvas_selection", + "canvas_context_menu", + "brush_tools", + "canvas_tool", + "label_editing", + "label_roi", + "tool_activation", + "session", + "worker", + "app_shell", + "points_layers", + "image_controls", + "image_display", + "status_hover", + "actions", + "undo_redo", + "geometry", + "display_decorations", + ] + rank = {m: i for i, m in enumerate(order)} + return sorted(roots, key=lambda m: rank.get(m, 999)) + + +def guiwin_classes() -> list[str]: + return [class_name(m) for m in guiwin_roots()] + + +def import_cycles() -> list[list[str]]: + """Detect import cycles in the parent graph (child imports parent modules).""" + graph = MIXIN_PARENTS + mods = set(MODULE_TO_CLASS) + cycles = [] + path: list[str] = [] + visited: set[str] = set() + stack: set[str] = set() + + def dfs(node: str) -> None: + if node in stack: + cycles.append(path[path.index(node) :] + [node]) + return + if node in visited: + return + visited.add(node) + stack.add(node) + path.append(node) + for parent in graph.get(node, ()): + dfs(parent) + path.pop() + stack.remove(node) + + for mod in mods: + dfs(mod) + return cycles diff --git a/cellacdc/mixins/actions.py b/cellacdc/mixins/actions.py index 039f34d40..8ad2a6630 100644 --- a/cellacdc/mixins/actions.py +++ b/cellacdc/mixins/actions.py @@ -13,8 +13,9 @@ shortcut_filepath = os.path.join(settings_folderpath, "shortcuts.ini") +from .image_display import ImageDisplay -class Actions: +class Actions(ImageDisplay): """Extracted from guiWin.""" def editShortcuts_cb(self): diff --git a/cellacdc/mixins/annotation_display.py b/cellacdc/mixins/annotation_display.py index 5b470239b..3b390a4bb 100644 --- a/cellacdc/mixins/annotation_display.py +++ b/cellacdc/mixins/annotation_display.py @@ -10,8 +10,9 @@ GREEN_HEX = _palettes.green() +from .mode_controls import ModeControls -class AnnotationDisplay: +class AnnotationDisplay(ModeControls): """Extracted from guiWin.""" def activateAnnotations(self): diff --git a/cellacdc/mixins/app_shell.py b/cellacdc/mixins/app_shell.py index b42aa7b84..ec2ea4c50 100644 --- a/cellacdc/mixins/app_shell.py +++ b/cellacdc/mixins/app_shell.py @@ -17,10 +17,11 @@ settings_csv_path, widgets, ) -from cellacdc.help import about, welcome +from .actions import Actions +from .session import Session -class AppShell: +class AppShell(Actions, Session): """Extracted from guiWin.""" def about(self): @@ -290,6 +291,8 @@ def setWindowTitle(self, title=None): super().setWindowTitle(title) def showAbout(self): + from cellacdc.help import about + self.aboutWin = about.QDialogAbout(parent=self) self.aboutWin.show() @@ -304,6 +307,8 @@ def showLogFiles(self): myutils.showInExplorer(log_files_path) def showTipsAndTricks(self): + from cellacdc.help import welcome + self.welcomeWin = welcome.welcomeWin() self.welcomeWin.showAndSetSize() self.welcomeWin.showPage(self.welcomeWin.quickStartItem) diff --git a/cellacdc/mixins/brush_tools.py b/cellacdc/mixins/brush_tools.py index 1c0b2117e..a2335d5bc 100644 --- a/cellacdc/mixins/brush_tools.py +++ b/cellacdc/mixins/brush_tools.py @@ -9,8 +9,10 @@ from cellacdc import html_utils, settings_csv_path, widgets +from .geometry import Geometry +from .tool_activation import ToolActivation -class BrushTools: +class BrushTools(Geometry, ToolActivation): """Extracted from guiWin.""" def Brush_cb(self, checked): diff --git a/cellacdc/mixins/canvas_context_menu.py b/cellacdc/mixins/canvas_context_menu.py index cccd157d8..1b7af4e92 100644 --- a/cellacdc/mixins/canvas_context_menu.py +++ b/cellacdc/mixins/canvas_context_menu.py @@ -6,8 +6,9 @@ from qtpy.QtCore import QPoint from qtpy.QtWidgets import QAction, QMenu +from .image_display import ImageDisplay -class CanvasContextMenu: +class CanvasContextMenu(ImageDisplay): """Extracted from guiWin.""" def gui_clickedDelRoi(self, event, left_click, right_click): diff --git a/cellacdc/mixins/canvas_drawing.py b/cellacdc/mixins/canvas_drawing.py index ef6b606fa..f18e8bb25 100644 --- a/cellacdc/mixins/canvas_drawing.py +++ b/cellacdc/mixins/canvas_drawing.py @@ -11,8 +11,10 @@ from cellacdc import apps, exception_handler, html_utils, widgets +from .canvas_selection import CanvasSelection +from .label_editing import LabelEditing -class CanvasDrawing: +class CanvasDrawing(CanvasSelection, LabelEditing): """Extracted from guiWin.""" def gui_addCreatedAxesItems(self): diff --git a/cellacdc/mixins/canvas_events.py b/cellacdc/mixins/canvas_events.py index a118bd783..410f64719 100644 --- a/cellacdc/mixins/canvas_events.py +++ b/cellacdc/mixins/canvas_events.py @@ -12,8 +12,11 @@ from cellacdc import apps, exception_handler +from .canvas_context_menu import CanvasContextMenu +from .canvas_selection import CanvasSelection +from .label_editing import LabelEditing -class CanvasEvents: +class CanvasEvents(CanvasContextMenu, CanvasSelection, LabelEditing): """Extracted from guiWin.""" def gui_mousePressEventImg1(self, event: QMouseEvent): diff --git a/cellacdc/mixins/canvas_hover.py b/cellacdc/mixins/canvas_hover.py index 76f4a1c8d..4c6cc5d7a 100644 --- a/cellacdc/mixins/canvas_hover.py +++ b/cellacdc/mixins/canvas_hover.py @@ -9,8 +9,9 @@ from cellacdc import html_utils, widgets +from .canvas_events import CanvasEvents -class CanvasHover: +class CanvasHover(CanvasEvents): """Extracted from guiWin.""" def drawTempMergeObjsLine(self, event, posData, modifiers): diff --git a/cellacdc/mixins/canvas_right_image.py b/cellacdc/mixins/canvas_right_image.py index 4fca3dcd7..f3e027da4 100644 --- a/cellacdc/mixins/canvas_right_image.py +++ b/cellacdc/mixins/canvas_right_image.py @@ -7,8 +7,10 @@ from cellacdc import exception_handler +from .canvas_drawing import CanvasDrawing +from .canvas_events import CanvasEvents -class CanvasRightImage: +class CanvasRightImage(CanvasDrawing, CanvasEvents): """Extracted from guiWin.""" def getMouseDataCoordsRightImage(self): diff --git a/cellacdc/mixins/canvas_selection.py b/cellacdc/mixins/canvas_selection.py index 6cf9dae82..82a60aea8 100644 --- a/cellacdc/mixins/canvas_selection.py +++ b/cellacdc/mixins/canvas_selection.py @@ -14,8 +14,10 @@ from cellacdc import apps, exception_handler +from .canvas_tool import CanvasTool +from .brush_tools import BrushTools -class CanvasSelection: +class CanvasSelection(CanvasTool, BrushTools): """Extracted from guiWin.""" def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): diff --git a/cellacdc/mixins/cell_cycle.py b/cellacdc/mixins/cell_cycle.py index 9c6ddab3c..afb0d8f12 100644 --- a/cellacdc/mixins/cell_cycle.py +++ b/cellacdc/mixins/cell_cycle.py @@ -20,8 +20,9 @@ ) from cellacdc import widgets, workers +from .undo_redo import UndoRedo -class CellCycle: +class CellCycle(UndoRedo): """Extracted from guiWin.""" def _getCcaCostMatrix( diff --git a/cellacdc/mixins/combine.py b/cellacdc/mixins/combine.py index 0955e78ab..bfcbfd0a2 100644 --- a/cellacdc/mixins/combine.py +++ b/cellacdc/mixins/combine.py @@ -11,6 +11,8 @@ from cellacdc import apps, core, html_utils, myutils, preprocess, printl, widgets, workers +from .graphics import Graphics +from .preprocessing import Preprocessing class CombineGui: def _setup_vars_combine(self): @@ -317,7 +319,7 @@ def combineChannelsActionTriggered(self): self.combineDialog.activateWindow() self.combineDialog.emitSigPreviewToggled() -class CombineWorker: +class CombineWorker(CombineGui, Graphics, Preprocessing): def combineEnqueueCurrentImage(self, steps, keep_input_data_type, formula): posData = self.data[self.pos_i] diff --git a/cellacdc/mixins/curvature_tools.py b/cellacdc/mixins/curvature_tools.py index d2f549713..5922d9969 100644 --- a/cellacdc/mixins/curvature_tools.py +++ b/cellacdc/mixins/curvature_tools.py @@ -7,8 +7,10 @@ import skimage.draw import skimage.measure +from .brush_tools import BrushTools +from .undo_redo import UndoRedo -class CurvatureTools: +class CurvatureTools(BrushTools, UndoRedo): """Extracted from guiWin.""" def clearCurvItems(self, removeItems=True): diff --git a/cellacdc/mixins/custom_annotations.py b/cellacdc/mixins/custom_annotations.py index c5d4bd07b..01242c69b 100644 --- a/cellacdc/mixins/custom_annotations.py +++ b/cellacdc/mixins/custom_annotations.py @@ -17,8 +17,10 @@ custom_annot_path = os.path.join(settings_folderpath, "custom_annotations.json") +from .annotation_display import AnnotationDisplay +from .object_properties import ObjectProperties -class CustomAnnotations: +class CustomAnnotations(AnnotationDisplay, ObjectProperties): """Extracted from guiWin.""" def addCustomAnnnotScatterPlot( diff --git a/cellacdc/mixins/data_loading.py b/cellacdc/mixins/data_loading.py index 1e7c5990f..fa99d3827 100644 --- a/cellacdc/mixins/data_loading.py +++ b/cellacdc/mixins/data_loading.py @@ -38,8 +38,9 @@ GREEN_HEX = _palettes.green() +from .layout_controls import LayoutControls -class DataLoading: +class DataLoading(LayoutControls): """Extracted from guiWin.""" def _createEmptyData(self): diff --git a/cellacdc/mixins/deleted_rois.py b/cellacdc/mixins/deleted_rois.py index 62a88e9d8..65647ab02 100644 --- a/cellacdc/mixins/deleted_rois.py +++ b/cellacdc/mixins/deleted_rois.py @@ -13,8 +13,9 @@ from cellacdc import widgets +from .cell_cycle import CellCycle -class DeletedRois: +class DeletedRois(CellCycle): """Extracted from guiWin.""" def addDelPolyLineRoi_cb(self, checked): diff --git a/cellacdc/mixins/draw_clear_region.py b/cellacdc/mixins/draw_clear_region.py index 7fa9388ea..465eab2ae 100644 --- a/cellacdc/mixins/draw_clear_region.py +++ b/cellacdc/mixins/draw_clear_region.py @@ -2,8 +2,9 @@ from __future__ import annotations +from .undo_redo import UndoRedo -class DrawClearRegion: +class DrawClearRegion(UndoRedo): """Extracted from guiWin.""" def drawClearRegion_cb(self, checked): diff --git a/cellacdc/mixins/exporting.py b/cellacdc/mixins/exporting.py index c5bf1ac3b..efd42dd94 100644 --- a/cellacdc/mixins/exporting.py +++ b/cellacdc/mixins/exporting.py @@ -16,8 +16,10 @@ from cellacdc import _warnings, apps, disableWindow, exception_handler from cellacdc import exporters, html_utils, prompts, widgets +from .app_shell import AppShell +from .frame_navigation import FrameNavigation -class Exporting: +class Exporting(AppShell, FrameNavigation): """Extracted from guiWin.""" def askTimelapseOrZslicesVideo(self): diff --git a/cellacdc/mixins/frame_navigation.py b/cellacdc/mixins/frame_navigation.py index b50ca8846..04aa01fb4 100644 --- a/cellacdc/mixins/frame_navigation.py +++ b/cellacdc/mixins/frame_navigation.py @@ -18,8 +18,10 @@ SliderPageStepSub = QtScoped.SliderPageStepSub() SliderMove = QtScoped.SliderMove() +from .graphics import Graphics +from .label_editing import LabelEditing -class FrameNavigation: +class FrameNavigation(Graphics, LabelEditing): """Extracted from guiWin.""" def PosScrollBarAction(self, action): diff --git a/cellacdc/mixins/graphics.py b/cellacdc/mixins/graphics.py index fc5ef6d39..5b9048660 100644 --- a/cellacdc/mixins/graphics.py +++ b/cellacdc/mixins/graphics.py @@ -32,8 +32,9 @@ _font = QFont() _font.setPixelSize(11) +from .points_layers import PointsLayers -class Graphics: +class Graphics(PointsLayers): """Extracted from guiWin.""" def _computeAllContours2D( diff --git a/cellacdc/mixins/image_controls.py b/cellacdc/mixins/image_controls.py index 41ba2ad04..95195aeed 100644 --- a/cellacdc/mixins/image_controls.py +++ b/cellacdc/mixins/image_controls.py @@ -22,8 +22,9 @@ _font = QFont() _font.setPixelSize(11) +from .frame_navigation import FrameNavigation -class ImageControls: +class ImageControls(FrameNavigation): """Extracted from guiWin.""" def gui_createBottomWidgetsToBottomLayout(self): diff --git a/cellacdc/mixins/image_display.py b/cellacdc/mixins/image_display.py index 46848e400..5915e5689 100644 --- a/cellacdc/mixins/image_display.py +++ b/cellacdc/mixins/image_display.py @@ -21,8 +21,9 @@ settings_csv_path, ) +from .display_decorations import DisplayDecorations -class ImageDisplay: +class ImageDisplay(DisplayDecorations): """Extracted from guiWin.""" def RGBtoGray(self, R, G, B): diff --git a/cellacdc/mixins/label_editing.py b/cellacdc/mixins/label_editing.py index 7081f7de0..a8d3b3ab5 100644 --- a/cellacdc/mixins/label_editing.py +++ b/cellacdc/mixins/label_editing.py @@ -12,8 +12,9 @@ from cellacdc import apps, disableWindow, exception_handler +from .tool_activation import ToolActivation -class LabelEditing: +class LabelEditing(ToolActivation): """Extracted from guiWin.""" def _get_editID_info(self, df): diff --git a/cellacdc/mixins/label_roi.py b/cellacdc/mixins/label_roi.py index 285c38568..f79ec063f 100644 --- a/cellacdc/mixins/label_roi.py +++ b/cellacdc/mixins/label_roi.py @@ -18,8 +18,9 @@ workers, ) +from .brush_tools import BrushTools -class LabelRoi: +class LabelRoi(BrushTools): """Extracted from guiWin.""" def getLabelRoiImage(self): diff --git a/cellacdc/mixins/label_transform_tools.py b/cellacdc/mixins/label_transform_tools.py index 0b58f3e9e..1e1ee1cf5 100644 --- a/cellacdc/mixins/label_transform_tools.py +++ b/cellacdc/mixins/label_transform_tools.py @@ -4,8 +4,10 @@ import skimage.measure +from .brush_tools import BrushTools +from .label_editing import LabelEditing -class LabelTransformTools: +class LabelTransformTools(BrushTools, LabelEditing): """Extracted from guiWin.""" def expandLabel(self, dilation=True): diff --git a/cellacdc/mixins/layout_controls.py b/cellacdc/mixins/layout_controls.py index f6ea910e4..e7f9113d5 100644 --- a/cellacdc/mixins/layout_controls.py +++ b/cellacdc/mixins/layout_controls.py @@ -24,8 +24,11 @@ from cellacdc import myutils, widgets from cellacdc.gui_decorators import resetViewRange +from .image_controls import ImageControls +from .window_events import WindowEvents +from .label_roi import LabelRoi -class LayoutControls: +class LayoutControls(ImageControls, WindowEvents, LabelRoi): """Extracted from guiWin.""" def gui_createControlsToolbar(self): diff --git a/cellacdc/mixins/lineage_interactions.py b/cellacdc/mixins/lineage_interactions.py index a5a0897cd..3a58cadb9 100644 --- a/cellacdc/mixins/lineage_interactions.py +++ b/cellacdc/mixins/lineage_interactions.py @@ -19,8 +19,10 @@ normal_division_lineage_tree, ) +from .annotation_display import AnnotationDisplay +from .tracking import Tracking -class LineageInteractions: +class LineageInteractions(AnnotationDisplay, Tracking): """Extracted from guiWin.""" def annotate_unknown_lineage_action(self, posData, event, ydata, xdata): diff --git a/cellacdc/mixins/magic_prompts.py b/cellacdc/mixins/magic_prompts.py index bfabf5c68..54baa0f03 100644 --- a/cellacdc/mixins/magic_prompts.py +++ b/cellacdc/mixins/magic_prompts.py @@ -19,8 +19,9 @@ ) from cellacdc import disableWindow +from .graphics import Graphics -class MagicPrompts: +class MagicPrompts(Graphics): """Extracted from guiWin.""" def _importInitMagicPromptModel( diff --git a/cellacdc/mixins/main_toolbar.py b/cellacdc/mixins/main_toolbar.py index 0cc49b087..a3516b9ae 100644 --- a/cellacdc/mixins/main_toolbar.py +++ b/cellacdc/mixins/main_toolbar.py @@ -10,8 +10,9 @@ from cellacdc import widgets +from .actions import Actions -class MainToolbar: +class MainToolbar(Actions): """Extracted from guiWin.""" def closeToolbars(self): diff --git a/cellacdc/mixins/mode_controls.py b/cellacdc/mixins/mode_controls.py index 54adc5dea..3b10dd0b6 100644 --- a/cellacdc/mixins/mode_controls.py +++ b/cellacdc/mixins/mode_controls.py @@ -6,8 +6,9 @@ from cellacdc import disableWindow +from .tool_activation import ToolActivation -class ModeControls: +class ModeControls(ToolActivation): """Extracted from guiWin.""" def blinkModeComboBox(self): diff --git a/cellacdc/mixins/object_cleanup.py b/cellacdc/mixins/object_cleanup.py index a72fa0dbf..11f3f6f08 100644 --- a/cellacdc/mixins/object_cleanup.py +++ b/cellacdc/mixins/object_cleanup.py @@ -7,8 +7,9 @@ from cellacdc import apps, widgets, workers +from .cell_cycle import CellCycle -class ObjectCleanup: +class ObjectCleanup(CellCycle): """Extracted from guiWin.""" def delObjsOutSegmMaskActionTriggered(self): diff --git a/cellacdc/mixins/object_properties.py b/cellacdc/mixins/object_properties.py index 5beea7f43..1f3b28565 100644 --- a/cellacdc/mixins/object_properties.py +++ b/cellacdc/mixins/object_properties.py @@ -8,8 +8,10 @@ from cellacdc import apps, exception_handler, html_utils, widgets +from .cell_cycle import CellCycle +from .tracking import Tracking -class ObjectProperties: +class ObjectProperties(CellCycle, Tracking): """Extracted from guiWin.""" def _keepObjects(self, keepIDs=None, lab=None, rp=None): diff --git a/cellacdc/mixins/object_search.py b/cellacdc/mixins/object_search.py index 2920bfdfc..6d909ce80 100644 --- a/cellacdc/mixins/object_search.py +++ b/cellacdc/mixins/object_search.py @@ -7,8 +7,9 @@ from cellacdc import apps, html_utils, widgets, workers +from .frame_navigation import FrameNavigation -class ObjectSearch: +class ObjectSearch(FrameNavigation): """Extracted from guiWin.""" def askGoToFrameFoundID(self, searchedID, frame_i_found): diff --git a/cellacdc/mixins/points_layers.py b/cellacdc/mixins/points_layers.py index 6b61746c0..568c292b1 100644 --- a/cellacdc/mixins/points_layers.py +++ b/cellacdc/mixins/points_layers.py @@ -19,8 +19,9 @@ from cellacdc import _warnings, apps, colors, exception_handler, html_utils, widgets +from .brush_tools import BrushTools -class PointsLayers: +class PointsLayers(BrushTools): """Extracted from guiWin.""" def addClickedPoint(self, action, x, y, id): diff --git a/cellacdc/mixins/preprocessing.py b/cellacdc/mixins/preprocessing.py index 68e76d00b..f341dca6e 100644 --- a/cellacdc/mixins/preprocessing.py +++ b/cellacdc/mixins/preprocessing.py @@ -10,8 +10,9 @@ from cellacdc import apps, html_utils, widgets, workers from cellacdc.plot import imshow +from .session import Session -class Preprocessing: +class Preprocessing(Session): """Extracted from guiWin.""" def askGet2Dor3Dimage(self): diff --git a/cellacdc/mixins/quick_settings.py b/cellacdc/mixins/quick_settings.py index 1dbb8006e..84a6a87d8 100644 --- a/cellacdc/mixins/quick_settings.py +++ b/cellacdc/mixins/quick_settings.py @@ -7,8 +7,9 @@ from cellacdc import apps, settings_csv_path, widgets +from .actions import Actions -class QuickSettings: +class QuickSettings(Actions): """Extracted from guiWin.""" def gui_createQuickSettingsWidgets(self): diff --git a/cellacdc/mixins/saving.py b/cellacdc/mixins/saving.py index 3bcc45acd..d712957d9 100644 --- a/cellacdc/mixins/saving.py +++ b/cellacdc/mixins/saving.py @@ -23,8 +23,9 @@ _font = QFont() _font.setPixelSize(11) +from .app_shell import AppShell -class Saving: +class Saving(AppShell): """Extracted from guiWin.""" def _enqueueAutoSave(self): diff --git a/cellacdc/mixins/seg_for_lost_ids.py b/cellacdc/mixins/seg_for_lost_ids.py index 99d194691..e3582442b 100644 --- a/cellacdc/mixins/seg_for_lost_ids.py +++ b/cellacdc/mixins/seg_for_lost_ids.py @@ -9,8 +9,10 @@ from cellacdc import apps, workers from cellacdc.plot import imshow +from .segmentation import Segmentation +from .frame_navigation import FrameNavigation -class SegForLostIds: +class SegForLostIds(Segmentation, FrameNavigation): """Extracted from guiWin.""" def SegForLostIDsSetSettings(self): diff --git a/cellacdc/mixins/segmentation.py b/cellacdc/mixins/segmentation.py index 928098301..b9568bac9 100644 --- a/cellacdc/mixins/segmentation.py +++ b/cellacdc/mixins/segmentation.py @@ -19,8 +19,9 @@ ) from cellacdc.plot import imshow +from .tool_activation import ToolActivation -class Segmentation: +class Segmentation(ToolActivation): """Extracted from guiWin.""" def autoSegm_cb(self, checked): diff --git a/cellacdc/mixins/session.py b/cellacdc/mixins/session.py index 7765a2b3c..2019fc233 100644 --- a/cellacdc/mixins/session.py +++ b/cellacdc/mixins/session.py @@ -18,8 +18,9 @@ ) from cellacdc.gui_decorators import get_data_exception_handler +from .worker import Worker -class Session: +class Session(Worker): """Extracted from guiWin.""" def _get_data_unvisited(self, posData, debug=False, lin_tree_init=True,): diff --git a/cellacdc/mixins/status_hover.py b/cellacdc/mixins/status_hover.py index b86b8b18e..bbf9c1437 100644 --- a/cellacdc/mixins/status_hover.py +++ b/cellacdc/mixins/status_hover.py @@ -7,8 +7,9 @@ import os import re +from .image_display import ImageDisplay -class StatusHover: +class StatusHover(ImageDisplay): """Extracted from guiWin.""" def _addOverlayHoverValuesFormatted(self, txt, xdata, ydata): diff --git a/cellacdc/mixins/tool_activation.py b/cellacdc/mixins/tool_activation.py index d40b4ac4a..cb1394e02 100644 --- a/cellacdc/mixins/tool_activation.py +++ b/cellacdc/mixins/tool_activation.py @@ -8,8 +8,9 @@ from cellacdc import apps, qutils, widgets, workers from cellacdc import disableWindow +from .session import Session -class ToolActivation: +class ToolActivation(Session): """Extracted from guiWin.""" def _copyAllLostObjects_navigateToFrame(self, frame_i): diff --git a/cellacdc/mixins/tracking.py b/cellacdc/mixins/tracking.py index e78726094..6b366a8c2 100644 --- a/cellacdc/mixins/tracking.py +++ b/cellacdc/mixins/tracking.py @@ -20,8 +20,9 @@ font_13px = QFont() font_13px.setPixelSize(13) +from .undo_redo import UndoRedo -class Tracking: +class Tracking(UndoRedo): """Extracted from guiWin.""" def _drawGhostContour(self, x, y): diff --git a/cellacdc/mixins/undo_redo.py b/cellacdc/mixins/undo_redo.py index 637fbe103..c25c9b1df 100644 --- a/cellacdc/mixins/undo_redo.py +++ b/cellacdc/mixins/undo_redo.py @@ -9,8 +9,9 @@ from collections import defaultdict +from .label_editing import LabelEditing -class UndoRedo: +class UndoRedo(LabelEditing): """Extracted from guiWin.""" def UndoCca(self): diff --git a/cellacdc/mixins/window_events.py b/cellacdc/mixins/window_events.py index ec28c74f6..e7dadb7e2 100644 --- a/cellacdc/mixins/window_events.py +++ b/cellacdc/mixins/window_events.py @@ -26,8 +26,10 @@ _font = QFont() _font.setPixelSize(11) +from .app_shell import AppShell +from .frame_navigation import FrameNavigation -class WindowEvents: +class WindowEvents(AppShell, FrameNavigation): """Extracted from guiWin.""" def _resizeLeaveSpaceTerminalBelow(self): diff --git a/cellacdc/mixins/worker.py b/cellacdc/mixins/worker.py index b58bca114..bac0b0c55 100644 --- a/cellacdc/mixins/worker.py +++ b/cellacdc/mixins/worker.py @@ -11,8 +11,9 @@ from cellacdc import apps, exception_handler, html_utils, issues_url, widgets, workers +from .status_hover import StatusHover -class Worker: +class Worker(StatusHover): """Extracted from guiWin.""" def autoSaveWorkerClosed(self, worker): From 252d950b6ebc1806f47d07373d739a2f067835de Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 11:36:26 +0200 Subject: [PATCH 12/21] ruff format --- cellacdc/QtScoped.py | 12 +- cellacdc/__init__.py | 531 +- cellacdc/__main__.py | 99 +- cellacdc/_base_widgets.py | 13 +- cellacdc/_core.py | 117 +- cellacdc/_debug.py | 51 +- cellacdc/_get_app_palette.py | 22 +- cellacdc/_main.py | 1573 +-- cellacdc/_palettes.py | 228 +- cellacdc/_process.py | 34 +- cellacdc/_profile/spline_to_obj/model.py | 23 +- .../profile_skimage_draw_polygon.py | 38 +- cellacdc/_run.py | 578 +- cellacdc/_types.py | 60 +- cellacdc/_view_all_buttons.py | 30 +- cellacdc/_view_all_icons.py | 30 +- cellacdc/_warnings.py | 291 +- cellacdc/acdc_bioio_bioformats/__init__.py | 36 +- .../acdc_bioio_bioformats/_init_reader.py | 20 +- .../acdc_bioio_bioformats/_read_metadata.py | 28 +- .../_read_sample_data.py | 92 +- cellacdc/acdc_bioio_bioformats/_save_data.py | 222 +- .../_save_data_single_channel.py | 236 +- cellacdc/acdc_bioio_bioformats/_utils.py | 144 +- cellacdc/acdc_bioio_bioformats/install.py | 56 +- cellacdc/acdc_bioio_bioformats/reader.py | 208 +- cellacdc/acdc_regex.py | 55 +- cellacdc/annotate.py | 467 +- cellacdc/apps.py | 10551 ++++++++-------- cellacdc/autopilot.py | 110 +- cellacdc/bioformats/__init__.py | 259 +- cellacdc/bioformats/formatreader.py | 783 +- cellacdc/bioformats/formatwriter.py | 626 +- cellacdc/bioformats/log4j.py | 3 +- cellacdc/bioformats/metadatatools.py | 438 +- cellacdc/bioformats/noseplugin.py | 26 +- cellacdc/bioformats/omexml.py | 667 +- cellacdc/bioformats/tests/locate_jars.py | 4 +- cellacdc/cca_functions.py | 976 +- cellacdc/cli.py | 1481 +-- cellacdc/colors.py | 269 +- cellacdc/config.py | 261 +- cellacdc/core.py | 2339 ++-- cellacdc/data.py | 295 +- cellacdc/dataPrep.py | 1732 ++- cellacdc/dataReStruct.py | 169 +- cellacdc/dataStruct.py | 1650 +-- cellacdc/debugutils.py | 60 +- .../source/citations/citations_per_year.py | 38 +- cellacdc/docs/source/conf.py | 52 +- cellacdc/exporters.py | 183 +- cellacdc/features.py | 186 +- cellacdc/fiji_macros/__init__.py | 62 +- cellacdc/gui.py | 103 +- cellacdc/gui_decorators.py | 6 +- cellacdc/gui_utils.py | 5 +- cellacdc/help/about.py | 164 +- cellacdc/help/welcome.py | 378 +- cellacdc/html_utils.py | 357 +- cellacdc/info.py | 73 +- cellacdc/io.py | 156 +- cellacdc/load.py | 2908 ++--- cellacdc/measure.py | 38 +- cellacdc/measurements.py | 1325 +- cellacdc/metrics/CV.py | 23 +- .../channel_indipendent_metric_example.py | 81 +- cellacdc/metrics/combine_metrics_example.py | 51 +- cellacdc/mixins/_graph.py | 7 +- cellacdc/mixins/actions.py | 538 +- cellacdc/mixins/annotation_display.py | 507 +- cellacdc/mixins/app_shell.py | 104 +- cellacdc/mixins/brush_tools.py | 258 +- cellacdc/mixins/canvas_context_menu.py | 47 +- cellacdc/mixins/canvas_drawing.py | 264 +- cellacdc/mixins/canvas_events.py | 587 +- cellacdc/mixins/canvas_hover.py | 284 +- cellacdc/mixins/canvas_right_image.py | 19 +- cellacdc/mixins/canvas_selection.py | 383 +- cellacdc/mixins/canvas_tool.py | 2 +- cellacdc/mixins/cell_cycle.py | 1832 ++- cellacdc/mixins/combine.py | 435 +- cellacdc/mixins/curvature_tools.py | 122 +- cellacdc/mixins/custom_annotations.py | 372 +- cellacdc/mixins/data_loading.py | 833 +- cellacdc/mixins/deleted_rois.py | 324 +- cellacdc/mixins/display_decorations.py | 74 +- cellacdc/mixins/draw_clear_region.py | 53 +- cellacdc/mixins/exporting.py | 251 +- cellacdc/mixins/frame_navigation.py | 579 +- cellacdc/mixins/geometry.py | 46 +- cellacdc/mixins/graphics.py | 1350 +- cellacdc/mixins/image_controls.py | 213 +- cellacdc/mixins/image_display.py | 539 +- cellacdc/mixins/label_editing.py | 332 +- cellacdc/mixins/label_roi.py | 223 +- cellacdc/mixins/label_transform_tools.py | 86 +- cellacdc/mixins/layout_controls.py | 408 +- cellacdc/mixins/lineage_interactions.py | 323 +- cellacdc/mixins/magic_prompts.py | 343 +- cellacdc/mixins/main_menu.py | 66 +- cellacdc/mixins/main_toolbar.py | 260 +- cellacdc/mixins/measurements.py | 68 +- cellacdc/mixins/mode_controls.py | 74 +- cellacdc/mixins/object_cleanup.py | 44 +- cellacdc/mixins/object_properties.py | 371 +- cellacdc/mixins/object_search.py | 145 +- cellacdc/mixins/points_layers.py | 739 +- cellacdc/mixins/preprocessing.py | 297 +- cellacdc/mixins/quick_settings.py | 98 +- cellacdc/mixins/saving.py | 507 +- cellacdc/mixins/seg_for_lost_ids.py | 201 +- cellacdc/mixins/segmentation.py | 407 +- cellacdc/mixins/session.py | 435 +- cellacdc/mixins/status_hover.py | 80 +- cellacdc/mixins/tool_activation.py | 381 +- cellacdc/mixins/tracking.py | 697 +- cellacdc/mixins/undo_redo.py | 160 +- cellacdc/mixins/whitelist.py | 542 +- cellacdc/mixins/window_events.py | 343 +- cellacdc/mixins/worker.py | 144 +- cellacdc/myutils.py | 4147 +++--- cellacdc/napari_utils/arboretum.py | 84 +- cellacdc/path.py | 91 +- cellacdc/plot.py | 621 +- cellacdc/preprocess.py | 428 +- cellacdc/prompts.py | 370 +- cellacdc/qrc_resources_dark.py | 13 +- cellacdc/qrc_resources_light.py | 13 +- cellacdc/qutils.py | 39 +- cellacdc/record.py | 103 +- cellacdc/resources/to_dark_mode_svg.py | 49 +- cellacdc/scripts/correct_shift_X.py | 113 +- cellacdc/scripts/correct_shift_X_multi.py | 140 +- cellacdc/scripts/correct_shift_X_single.py | 141 +- cellacdc/scripts/pngtotif.py | 12 +- cellacdc/scripts/split_segm_mask_yeast.py | 152 +- cellacdc/segm.py | 902 +- cellacdc/segm_utils.py | 137 +- cellacdc/segmentation.py | 69 +- cellacdc/segmenters/BABY/__init__.py | 2 +- cellacdc/segmenters/BABY/acdcSegment.py | 40 +- .../Cellpose_germlineNuclei/__init__.py | 2 +- .../Cellpose_germlineNuclei/acdcSegment.py | 200 +- cellacdc/segmenters/DeepSea/__init__.py | 50 +- cellacdc/segmenters/DeepSea/acdcSegment.py | 22 +- cellacdc/segmenters/InstanSeg/__init__.py | 6 +- cellacdc/segmenters/InstanSeg/acdcSegment.py | 105 +- cellacdc/segmenters/StarDist/__init__.py | 21 +- cellacdc/segmenters/StarDist/acdcSegment.py | 58 +- cellacdc/segmenters/YeaZ/__init__.py | 2 +- cellacdc/segmenters/YeaZ/acdcSegment.py | 65 +- .../YeaZ/unet/LaunchBatchPrediction.py | 74 +- cellacdc/segmenters/YeaZ/unet/hungarian.py | 97 +- cellacdc/segmenters/YeaZ/unet/model.py | 142 +- .../segmenters/YeaZ/unet/neural_network.py | 53 +- cellacdc/segmenters/YeaZ/unet/segment.py | 77 +- cellacdc/segmenters/YeaZ/unet/tracking.py | 78 +- cellacdc/segmenters/YeaZ_v2/__init__.py | 44 +- cellacdc/segmenters/YeaZ_v2/acdcSegment.py | 93 +- cellacdc/segmenters/YeastMate/__init__.py | 41 +- cellacdc/segmenters/YeastMate/acdcSegment.py | 60 +- cellacdc/segmenters/__init__.py | 6 +- .../segmenters/_cellpose_base/__init__.py | 8 +- .../segmenters/_cellpose_base/_directML.py | 38 +- .../segmenters/_cellpose_base/acdcSegment.py | 469 +- cellacdc/segmenters/cellpose_v2/__init__.py | 9 +- .../segmenters/cellpose_v2/acdcSegment.py | 176 +- cellacdc/segmenters/cellpose_v3/__init__.py | 17 +- cellacdc/segmenters/cellpose_v3/_denoise.py | 356 +- .../segmenters/cellpose_v3/acdcSegment.py | 336 +- cellacdc/segmenters/cellpose_v4/__init__.py | 9 +- .../segmenters/cellpose_v4/acdcSegment.py | 276 +- cellacdc/segmenters/cellsam/__init__.py | 4 +- cellacdc/segmenters/cellsam/acdcSegment.py | 69 +- cellacdc/segmenters/delta/__init__.py | 2 +- cellacdc/segmenters/delta/acdcSegment.py | 53 +- cellacdc/segmenters/omnipose/__init__.py | 2 +- cellacdc/segmenters/omnipose/acdcSegment.py | 92 +- .../segmenters/omnipose_custom/__init__.py | 2 +- .../segmenters/omnipose_custom/acdcSegment.py | 31 +- cellacdc/segmenters/pomBseen/__init__.py | 2 +- cellacdc/segmenters/pomBseen/acdcSegment.py | 99 +- .../segmenters/pomBseen_nuclear/__init__.py | 2 +- .../pomBseen_nuclear/acdcSegment.py | 35 +- cellacdc/segmenters/sam2/__init__.py | 10 +- cellacdc/segmenters/sam2/acdcSegment.py | 178 +- .../segmenters/segment_anything/__init__.py | 12 +- .../segment_anything/acdcSegment.py | 466 +- .../skip_segmentation/acdcSegment.py | 17 +- .../segmenters/thresholding/acdcSegment.py | 19 +- .../micro-sam/__init__.py | 2 +- .../nnInteractive/acdcPromptSegment.py | 204 +- .../sam2/acdcPromptSegment.py | 22 +- .../segment_anything/acdcPromptSegment.py | 18 +- cellacdc/segmenters_promptable/utils.py | 24 +- cellacdc/syntax.py | 163 +- cellacdc/test_segm_model.py | 84 +- cellacdc/test_tracker.py | 81 +- cellacdc/trackers/BABY/BABY_tracker.py | 120 +- cellacdc/trackers/BABY/__init__.py | 3 +- .../BayesianTracker_tracker.py | 67 +- cellacdc/trackers/BayesianTracker/__init__.py | 15 +- .../trackers/CellACDC/CellACDC_tracker.py | 327 +- .../CellACDC_2steps_tracker.py | 219 +- .../CellACDC_normal_division_tracker.py | 797 +- cellacdc/trackers/DeepSea/DeepSea_tracker.py | 93 +- cellacdc/trackers/DeepSea/__init__.py | 17 +- cellacdc/trackers/TAPIR/TAPIR_tracker.py | 234 +- cellacdc/trackers/TAPIR/__init__.py | 4 +- cellacdc/trackers/TAPIR/tracking.py | 13 +- .../trackers/Trackastra/Trackastra_tracker.py | 135 +- cellacdc/trackers/Trackastra/__init__.py | 9 +- cellacdc/trackers/YeaZ/YeaZ_tracker.py | 1 + cellacdc/trackers/YeaZ/tracking.py | 89 +- cellacdc/trackers/delta/__init__.py | 2 +- cellacdc/trackers/delta/delta_tracker.py | 125 +- cellacdc/trackers/trackpy/__init__.py | 2 +- cellacdc/trackers/trackpy/trackpy_tracker.py | 196 +- cellacdc/transformation.py | 184 +- cellacdc/urls.py | 30 +- cellacdc/utils/acdcToSymDiv.py | 96 +- cellacdc/utils/align.py | 92 +- cellacdc/utils/applyTrackFromTable.py | 71 +- cellacdc/utils/applyTrackFromTrackMateXML.py | 81 +- cellacdc/utils/base.py | 328 +- cellacdc/utils/combineChannels.py | 85 +- cellacdc/utils/compute.py | 281 +- cellacdc/utils/computeMultiChannel.py | 106 +- cellacdc/utils/concat.py | 191 +- cellacdc/utils/convert.py | 383 +- cellacdc/utils/countObjects.py | 29 +- cellacdc/utils/createConnected3Dsegm.py | 49 +- cellacdc/utils/customPreprocess.py | 69 +- cellacdc/utils/fillHolesInSegm.py | 61 +- cellacdc/utils/filterObjFromCoordsTable.py | 58 +- cellacdc/utils/fromImageJroiToSegm.py | 35 +- cellacdc/utils/fucciPreprocess.py | 79 +- cellacdc/utils/generateMothBudTotalTable.py | 54 +- cellacdc/utils/rename.py | 155 +- cellacdc/utils/repeat.py | 163 +- cellacdc/utils/resize/__init__.py | 205 +- cellacdc/utils/resize/util.py | 30 +- cellacdc/utils/stack2Dinto3Dsegm.py | 50 +- cellacdc/utils/toImageJroi.py | 26 +- cellacdc/utils/toObjCoords.py | 26 +- cellacdc/utils/trackSubCellObjects.py | 67 +- cellacdc/whitelist.py | 549 +- cellacdc/widgets.py | 6653 +++++----- cellacdc/workers.py | 3879 +++--- notebooks/acdc_paper_plots.ipynb | 1632 ++- notebooks/cell_cycle_analysis.ipynb | 766 +- notebooks/workshop_analyses.ipynb | 256 +- tests/prompt_segm/test_sam.py | 4 +- tests/prompt_segm/test_sam2.py | 4 +- tests/segm/test_cellsam.py | 4 +- tests/segm/test_sam.py | 4 +- tests/segm/test_sam2.py | 4 +- tests/test_import_cellacdc.py | 1 + tests/utils/segmentation.py | 36 +- 259 files changed, 43459 insertions(+), 40213 deletions(-) diff --git a/cellacdc/QtScoped.py b/cellacdc/QtScoped.py index 86a194b0d..d533723ba 100644 --- a/cellacdc/QtScoped.py +++ b/cellacdc/QtScoped.py @@ -2,62 +2,72 @@ from qtpy.QtWidgets import QAbstractSlider, QStyle + def SliderNoAction(): if PYQT6: return QAbstractSlider.SliderAction.SliderNoAction.value else: return QAbstractSlider.SliderAction.SliderNoAction + def SliderSingleStepAdd(): if PYQT6: return QAbstractSlider.SliderAction.SliderSingleStepAdd.value else: return QAbstractSlider.SliderAction.SliderSingleStepAdd + def SliderSingleStepSub(): if PYQT6: return QAbstractSlider.SliderAction.SliderSingleStepSub.value else: return QAbstractSlider.SliderAction.SliderSingleStepSub + def SliderPageStepAdd(): if PYQT6: return QAbstractSlider.SliderAction.SliderPageStepAdd.value else: return QAbstractSlider.SliderAction.SliderPageStepAdd + def SliderPageStepSub(): if PYQT6: return QAbstractSlider.SliderAction.SliderPageStepAdd.value else: return QAbstractSlider.SliderAction.SliderPageStepAdd + def SliderToMinimum(): if PYQT6: return QAbstractSlider.SliderAction.SliderPageStepAdd.value else: return QAbstractSlider.SliderAction.SliderPageStepAdd + def SliderToMaximum(): if PYQT6: return QAbstractSlider.SliderAction.SliderPageStepAdd.value else: return QAbstractSlider.SliderAction.SliderPageStepAdd + def SliderMove(): if PYQT6: return QAbstractSlider.SliderAction.SliderMove.value else: return QAbstractSlider.SliderAction.SliderMove + def QStyleCC_ScrollBar(): if PYQT6: return QStyle.ComplexControl.CC_ScrollBar else: return QStyle.CC_ScrollBar + def QStyleSC_ScrollBarSubLine(): if PYQT6: return QStyle.SubControl.SC_ScrollBarSubLine else: - return QStyle.SC_ScrollBarSubLine \ No newline at end of file + return QStyle.SC_ScrollBarSubLine diff --git a/cellacdc/__init__.py b/cellacdc/__init__.py index 1d92a1807..5bd453141 100755 --- a/cellacdc/__init__.py +++ b/cellacdc/__init__.py @@ -3,44 +3,45 @@ import subprocess + def is_conda_env(): python_exec_path = sys.exec_prefix is_conda_python = ( - python_exec_path.find('conda') != -1 - or python_exec_path.find('mambaforge') != -1 - or python_exec_path.find('miniforge') != -1 + python_exec_path.find("conda") != -1 + or python_exec_path.find("mambaforge") != -1 + or python_exec_path.find("miniforge") != -1 ) if not is_conda_python: return False - + stdout = subprocess.DEVNULL try: - args = ['conda', '-V'] - is_conda_present = subprocess.check_call( - args, shell=True, stdout=stdout) == 0 + args = ["conda", "-V"] + is_conda_present = subprocess.check_call(args, shell=True, stdout=stdout) == 0 return True except Exception as err: pass - + try: - args = ['conda -V'] - is_conda_present = subprocess.check_call( - args, shell=True, stdout=stdout) == 0 + args = ["conda -V"] + is_conda_present = subprocess.check_call(args, shell=True, stdout=stdout) == 0 return True except Exception as err: return False - + return True + def import_torch(): if is_conda_env(): - return - + return + try: import torch except ModuleNotFoundError: return + import_torch() @@ -60,80 +61,90 @@ def import_torch(): from typing import Iterable -KNOWN_EXTENSIONS = ( - '.tif', '.npz', '.npy', '.h5', '.json', '.csv', '.txt' -) +KNOWN_EXTENSIONS = (".tif", ".npz", ".npy", ".h5", ".json", ".csv", ".txt") IMAGE_EXTENSIONS = ( - '.tif', '.tiff', '.png', '.jpg', '.jpeg', '.bmp', '.gif', + ".tif", + ".tiff", + ".png", + ".jpg", + ".jpeg", + ".bmp", + ".gif", ) VIDEO_EXTENSIONS = ( - '.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', + ".mp4", + ".avi", + ".mov", + ".mkv", + ".webm", + ".flv", ) -def _warn_ask_install_package( - commands: Iterable[str], note_txt='', caller='SpotMAX' - ): - open_str = '='*100 - sep_str = '-'*100 - commands_txt = '\n'.join([f' {command}' for command in commands]) + +def _warn_ask_install_package(commands: Iterable[str], note_txt="", caller="SpotMAX"): + open_str = "=" * 100 + sep_str = "-" * 100 + commands_txt = "\n".join([f" {command}" for command in commands]) text = ( - f'{caller} needs to run the following commands{note_txt}:\n\n' - f'{commands_txt}\n\n' + f"{caller} needs to run the following commands{note_txt}:\n\n{commands_txt}\n\n" ) question = ( - 'How do you want to proceed?: ' - '1) Run the commands now. ' - 'q) Quit, I will run the commands myself (1/q): ' + "How do you want to proceed?: " + "1) Run the commands now. " + "q) Quit, I will run the commands myself (1/q): " ) print(open_str) print(text) - + message_on_exit = ( - '[WARNING]: Execution aborted. Run the following commands before ' - f'running spotMAX again:\n\n{commands_txt}\n' + "[WARNING]: Execution aborted. Run the following commands before " + f"running spotMAX again:\n\n{commands_txt}\n" ) msg_on_invalid = ( - '$answer is not a valid answer. ' + "$answer is not a valid answer. " 'Type "1" to run the commands now or "q" to quit.' ) try: while True: answer = input(question) - if answer == 'q': + if answer == "q": print(open_str) exit(message_on_exit) - elif answer == '1': + elif answer == "1": break else: print(sep_str) - print(msg_on_invalid.replace('$answer', answer)) + print(msg_on_invalid.replace("$answer", answer)) print(sep_str) except Exception as err: traceback.print_exc() print(open_str) print(message_on_exit) + def _run_pip_commands(commands: Iterable[str]): import subprocess + for command in commands: try: - subprocess.check_call([sys.executable, '-m', *command.split()]) + subprocess.check_call([sys.executable, "-m", *command.split()]) except Exception as err: pass - + + try: import requests except Exception as err: import traceback + traceback.print_exc() - print('We detected a corrupted library, fixing it now...') + print("We detected a corrupted library, fixing it now...") commands = ( - 'pip uninstall -y charset-normalizer', - 'pip install --upgrade charset-normalizer' + "pip uninstall -y charset-normalizer", + "pip install --upgrade charset-normalizer", ) _warn_ask_install_package( - commands, note_txt=' (fixing charset-normalizer package)', - caller='Cell-ACDC' + commands, note_txt=" (fixing charset-normalizer package)", caller="Cell-ACDC" ) _run_pip_commands(commands) @@ -141,18 +152,16 @@ def _run_pip_commands(commands: Iterable[str]): import sympy except Exception as err: import traceback + traceback.print_exc() - print('Since Cell-ACDC v1.7.2, the sympy library is required.') - commands = ( - 'pip install --upgrade sympy', - ) + print("Since Cell-ACDC v1.7.2, the sympy library is required.") + commands = ("pip install --upgrade sympy",) _warn_ask_install_package( - commands, - note_txt=' (installing sympy)', - caller='Cell-ACDC' + commands, note_txt=" (installing sympy)", caller="Cell-ACDC" ) _run_pip_commands(commands) + def user_data_dir(): r""" Get OS specific data directory path for Cell-ACDC. @@ -174,32 +183,31 @@ def user_data_dir(): os_path = os.getenv("XDG_DATA_HOME", "~/.local/share") os_path = os.path.expanduser(os_path) - return os.path.join(os_path, 'Cell_ACDC') + return os.path.join(os_path, "Cell_ACDC") + cellacdc_path = os.path.dirname(os.path.abspath(__file__)) -debug_true_filepath = os.path.join(cellacdc_path, '.debug_true') -qrc_resources_path = os.path.join(cellacdc_path, 'qrc_resources.py') -qrc_resources_light_path = os.path.join(cellacdc_path, 'qrc_resources_light.py') -qrc_resources_dark_path = os.path.join(cellacdc_path, 'qrc_resources_dark.py') -old_temp_path = os.path.join(cellacdc_path, 'temp') -tooltips_rst_filepath = os.path.join( - cellacdc_path, "docs", "source", "tooltips.rst" -) +debug_true_filepath = os.path.join(cellacdc_path, ".debug_true") +qrc_resources_path = os.path.join(cellacdc_path, "qrc_resources.py") +qrc_resources_light_path = os.path.join(cellacdc_path, "qrc_resources_light.py") +qrc_resources_dark_path = os.path.join(cellacdc_path, "qrc_resources_dark.py") +old_temp_path = os.path.join(cellacdc_path, "temp") +tooltips_rst_filepath = os.path.join(cellacdc_path, "docs", "source", "tooltips.rst") user_data_folderpath = user_data_dir() user_profile_path_txt = os.path.join( - user_data_folderpath, 'acdc_user_profile_location.txt' + user_data_folderpath, "acdc_user_profile_location.txt" ) user_home_path = str(pathlib.Path.home()) -user_profile_path = os.path.join(user_home_path, 'acdc-appdata') +user_profile_path = os.path.join(user_home_path, "acdc-appdata") if os.path.exists(user_profile_path_txt): try: - with open(user_profile_path_txt, 'r') as txt: - user_profile_path = fr'{txt.read()}' + with open(user_profile_path_txt, "r") as txt: + user_profile_path = rf"{txt.read()}" except Exception as e: pass -qrc_resources_user_path = os.path.join(user_profile_path, 'qrc_resources.py') +qrc_resources_user_path = os.path.join(user_profile_path, "qrc_resources.py") try: os.makedirs(user_profile_path, exist_ok=True) @@ -213,23 +221,23 @@ def user_data_dir(): # print(f'User profile path: "{user_profile_path}"') import site + sitepackages = site.getsitepackages() -site_packages = [p for p in sitepackages if p.endswith('-packages')][0] +site_packages = [p for p in sitepackages if p.endswith("-packages")][0] cellacdc_path = os.path.dirname(os.path.abspath(__file__)) cellacdc_installation_path = os.path.dirname(cellacdc_path) if cellacdc_installation_path != site_packages: IS_CLONED = True - settings_folderpath = os.path.join(cellacdc_installation_path, '.acdc-settings') + settings_folderpath = os.path.join(cellacdc_installation_path, ".acdc-settings") else: IS_CLONED = False - settings_folderpath = os.path.join(user_profile_path, '.acdc-settings') + settings_folderpath = os.path.join(user_profile_path, ".acdc-settings") + +fiji_location_filepath = os.path.join(settings_folderpath, "fiji_location.txt") +bioio_sample_data_folderpath = os.path.join(user_profile_path, "acdc_dataStruct_temp") -fiji_location_filepath = os.path.join(settings_folderpath, 'fiji_location.txt') -bioio_sample_data_folderpath = os.path.join( - user_profile_path, 'acdc_dataStruct_temp' -) def copytree(src, dst): os.makedirs(dst, exist_ok=True) @@ -241,6 +249,7 @@ def copytree(src, dst): elif os.path.isfile(src_filepath): shutil.copy2(src_filepath, dst_filepath) + if not os.path.exists(settings_folderpath): os.makedirs(settings_folderpath, exist_ok=True) if os.path.exists(old_temp_path): @@ -248,66 +257,65 @@ def copytree(src, dst): copytree(old_temp_path, settings_folderpath) shutil.rmtree(old_temp_path) except Exception as e: - print('*'*60) + print("*" * 60) print( - '[WARNING]: could not copy settings from previous location. ' - f'Please manually copy the folder "{old_temp_path}" to "{settings_folderpath}"') - print('^'*60) + "[WARNING]: could not copy settings from previous location. " + f'Please manually copy the folder "{old_temp_path}" to "{settings_folderpath}"' + ) + print("^" * 60) import pandas as pd + # Disable pandas 3.0 strict string dtype to maintain backward compatibility # with code that assigns non-string values to DataFrames -if hasattr(pd.options, 'future') and hasattr(pd.options.future, 'infer_string'): +if hasattr(pd.options, "future") and hasattr(pd.options.future, "infer_string"): pd.options.future.infer_string = False -settings_csv_path = os.path.join(settings_folderpath, 'settings.csv') +settings_csv_path = os.path.join(settings_folderpath, "settings.csv") if not os.path.exists(settings_csv_path): - df_settings = pd.DataFrame( - {'setting': [], 'value': []}).set_index('setting') + df_settings = pd.DataFrame({"setting": [], "value": []}).set_index("setting") df_settings.to_csv(settings_csv_path) # Get color scheme if not os.path.exists(settings_csv_path): - scheme = 'light' + scheme = "light" try: - df_settings = pd.read_csv(settings_csv_path, index_col='setting') + df_settings = pd.read_csv(settings_csv_path, index_col="setting") except Exception as err: # Overwrite corrupted setttings file - df_settings = pd.DataFrame( - {'setting': [], 'value': []}).set_index('setting') + df_settings = pd.DataFrame({"setting": [], "value": []}).set_index("setting") df_settings.to_csv(settings_csv_path) - -if 'colorScheme' not in df_settings.index: - scheme = 'light' + +if "colorScheme" not in df_settings.index: + scheme = "light" else: - scheme = df_settings.at['colorScheme', 'value'] + scheme = df_settings.at["colorScheme", "value"] -does_qrc_resources_exists = ( - os.path.exists(qrc_resources_path) - or os.path.exists(qrc_resources_user_path) +does_qrc_resources_exists = os.path.exists(qrc_resources_path) or os.path.exists( + qrc_resources_user_path ) + def _copy_qrc_resources_file( - src_qrc_resources_scheme_path: os.PathLike, - dst_qrc_resources_path: os.PathLike, - user_dst_qrc_resources_path: os.PathLike = qrc_resources_user_path - ): + src_qrc_resources_scheme_path: os.PathLike, + dst_qrc_resources_path: os.PathLike, + user_dst_qrc_resources_path: os.PathLike = qrc_resources_user_path, +): try: shutil.copyfile(src_qrc_resources_scheme_path, dst_qrc_resources_path) return True except Exception as err: - # Copy to user folder because copying to cell-acdc location failed - # possibly PermissionError --> return False to stop application + # Copy to user folder because copying to cell-acdc location failed + # possibly PermissionError --> return False to stop application # and prompt the user to restart Cell-ACDC - shutil.copyfile( - src_qrc_resources_scheme_path, user_dst_qrc_resources_path - ) + shutil.copyfile(src_qrc_resources_scheme_path, user_dst_qrc_resources_path) return False + # Set default qrc resources if not does_qrc_resources_exists: - if scheme == 'light': + if scheme == "light": qrc_resources_scheme_path = qrc_resources_light_path else: qrc_resources_scheme_path = qrc_resources_dark_path @@ -323,23 +331,23 @@ def _copy_qrc_resources_file( # Replace 'from PyQt5' with 'from qtpy' in qrc_resources.py file try: save_qrc = False - with open(qrc_resources_path, 'r') as qrc_py: + with open(qrc_resources_path, "r") as qrc_py: text = qrc_py.read() - if text.find('from PyQt5') != -1: - text = text.replace('from PyQt5', 'from qtpy') + if text.find("from PyQt5") != -1: + text = text.replace("from PyQt5", "from qtpy") save_qrc = True if save_qrc: - with open(qrc_resources_path, 'w') as qrc_py: + with open(qrc_resources_path, "w") as qrc_py: qrc_py.write(text) except Exception as err: raise err try: - # Import qrc_resources explicitly so that "from . import acdc_qrc_resources" imports - # the variable defined here. Use importlib in case qrc_resouces.py is in + # Import qrc_resources explicitly so that "from . import acdc_qrc_resources" imports + # the variable defined here. Use importlib in case qrc_resouces.py is in # user folder qrc_resouces_spec = importlib.util.spec_from_file_location( - 'qrc_resources', qrc_resources_path + "qrc_resources", qrc_resources_path ) acdc_qrc_resources = importlib.util.module_from_spec(qrc_resouces_spec) qrc_resouces_spec.loader.exec_module(acdc_qrc_resources) @@ -347,31 +355,35 @@ def _copy_qrc_resources_file( # Cellacdc in the cli might not have qtpy --> ignore error pass + def try_input_install_package(pkg_name, install_command, question=None): if question is None: - question = 'Do you want to install it now ([y]/n)? ' + question = "Do you want to install it now ([y]/n)? " try: - answer = input(f'\n{question}') + answer = input(f"\n{question}") return answer except Exception as err: raise ModuleNotFoundError( f'The module "{pkg_name}" is not installed. ' - f'Install it with the command `{install_command}`.' + f"Install it with the command `{install_command}`." ) + try: # Force PyQt6 if available try: from PyQt6 import QtCore + os.environ["QT_API"] = "pyqt6" except Exception as e: pass from qtpy import QtCore import pyqtgraph import matplotlib + GUI_INSTALLED = True except Exception as e: - GUI_INSTALLED = False + GUI_INSTALLED = False import pandas as pd @@ -379,115 +391,116 @@ def try_input_install_package(pkg_name, install_command, question=None): pd.set_option("display.max_columns", 20) pd.set_option("display.max_rows", 200) -pd.set_option('display.expand_frame_repr', False) +pd.set_option("display.expand_frame_repr", False) + +open_printl_str = "*" * 100 +close_printl_str = "=" * 100 -open_printl_str = '*'*100 -close_printl_str = '='*100 def printl(*objects, pretty=False, is_decorator=False, idx=1, **kwargs): - timestap = datetime.now().strftime('%H:%M:%S') + timestap = datetime.now().strftime("%H:%M:%S") currentframe = inspect.currentframe() outerframes = inspect.getouterframes(currentframe) - idx = idx+1 if is_decorator else idx + idx = idx + 1 if is_decorator else idx callingframe = outerframes[idx].frame callingframe_info = inspect.getframeinfo(callingframe) filepath = callingframe_info.filename - fileinfo_str = ( - f'File "{filepath}", line {callingframe_info.lineno} - {timestap}:' - ) + fileinfo_str = f'File "{filepath}", line {callingframe_info.lineno} - {timestap}:' if pretty: print(open_printl_str) print(fileinfo_str) for o, object in enumerate(objects): - text = str(object) + text = str(object) pprint(text, **kwargs) print(close_printl_str) else: - sep = kwargs.get('sep', ', ') + sep = kwargs.get("sep", ", ") text = sep.join([str(object) for object in objects]) - text = f'{open_printl_str}\n{fileinfo_str}\n{text}\n{close_printl_str}' + text = f"{open_printl_str}\n{fileinfo_str}\n{text}\n{close_printl_str}" print(text) + parent_path = os.path.dirname(cellacdc_path) -html_path = os.path.join(cellacdc_path, '_html') -segmenters_path = os.path.join(cellacdc_path, 'segmenters') -segmenters_promptable_path = os.path.join(cellacdc_path, 'segmenters_promptable') -data_path = os.path.join(parent_path, 'data') -resources_folderpath = os.path.join(cellacdc_path, 'resources') -resources_filepath = os.path.join(cellacdc_path, 'resources_light.qrc') -logs_path = os.path.join(user_profile_path, '.acdc-logs') -acdc_fiji_path = os.path.join(user_profile_path, 'acdc-fiji') -acdc_ffmpeg_path = os.path.join(user_profile_path, 'acdc-ffmpeg') -resources_path = os.path.join(cellacdc_path, 'resources_light.qrc') -segmenters_list_file_path = os.path.join( - settings_folderpath, 'custom_models_paths.ini' -) +html_path = os.path.join(cellacdc_path, "_html") +segmenters_path = os.path.join(cellacdc_path, "segmenters") +segmenters_promptable_path = os.path.join(cellacdc_path, "segmenters_promptable") +data_path = os.path.join(parent_path, "data") +resources_folderpath = os.path.join(cellacdc_path, "resources") +resources_filepath = os.path.join(cellacdc_path, "resources_light.qrc") +logs_path = os.path.join(user_profile_path, ".acdc-logs") +acdc_fiji_path = os.path.join(user_profile_path, "acdc-fiji") +acdc_ffmpeg_path = os.path.join(user_profile_path, "acdc-ffmpeg") +resources_path = os.path.join(cellacdc_path, "resources_light.qrc") +segmenters_list_file_path = os.path.join(settings_folderpath, "custom_models_paths.ini") segmenters_promptable_list_file_path = os.path.join( - settings_folderpath, 'custom_promptable_models_paths.ini' + settings_folderpath, "custom_promptable_models_paths.ini" ) models_path = segmenters_path promptable_models_path = segmenters_promptable_path models_list_file_path = segmenters_list_file_path promptable_models_list_file_path = segmenters_promptable_list_file_path favourite_func_metrics_csv_path = os.path.join( - settings_folderpath, 'favourite_func_metrics.csv' + settings_folderpath, "favourite_func_metrics.csv" +) +recentPaths_path = os.path.join(settings_folderpath, "recentPaths.csv") +preproc_recipes_path = os.path.join(settings_folderpath, "preprocessing_recipes") +combine_channels_recipes_path = os.path.join(settings_folderpath, "combine_channels") +segm_recipes_path = os.path.join(settings_folderpath, "segmentation_recipes") +user_manual_url = "https://github.com/SchmollerLab/Cell_ACDC/blob/main/UserManual/Cell-ACDC_User_Manual.pdf" +github_home_url = "https://github.com/SchmollerLab/Cell_ACDC" +data_structure_docs_url = ( + "https://cell-acdc.readthedocs.io/en/latest/data-structure.html" ) -recentPaths_path = os.path.join(settings_folderpath, 'recentPaths.csv') -preproc_recipes_path = os.path.join(settings_folderpath, 'preprocessing_recipes') -combine_channels_recipes_path = os.path.join(settings_folderpath, 'combine_channels') -segm_recipes_path = os.path.join(settings_folderpath, 'segmentation_recipes') -user_manual_url = 'https://github.com/SchmollerLab/Cell_ACDC/blob/main/UserManual/Cell-ACDC_User_Manual.pdf' -github_home_url = 'https://github.com/SchmollerLab/Cell_ACDC' -data_structure_docs_url = 'https://cell-acdc.readthedocs.io/en/latest/data-structure.html' moth_bud_tot_selected_columns_filepath = os.path.join( - settings_folderpath, 'mother_bud_total_columns_selection.json' + settings_folderpath, "mother_bud_total_columns_selection.json" ) saved_measurements_selections_folderpath = os.path.join( - settings_folderpath, 'saved_measurements_selections' + settings_folderpath, "saved_measurements_selections" ) -# Use to get the acdc_output file name from `segm_filename` as +# Use to get the acdc_output file name from `segm_filename` as # `m = re.sub(segm_re_pattern, '_acdc_output', segm_filename)` -segm_re_pattern = r'_segm(?!.*_segm)' +segm_re_pattern = r"_segm(?!.*_segm)" try: from setuptools_scm import get_version - __version__ = get_version(root='..', relative_to=__file__) + + __version__ = get_version(root="..", relative_to=__file__) except Exception as e: try: from ._version import version as __version__ except ImportError: __version__ = "not-installed" -__author__ = 'Francesco Padovani and Benedikt Mairhoermann' +__author__ = "Francesco Padovani and Benedikt Mairhoermann" -cite_url = 'https://bmcbiol.biomedcentral.com/articles/10.1186/s12915-022-01372-6' -issues_url = 'https://github.com/SchmollerLab/Cell_ACDC/issues' +cite_url = "https://bmcbiol.biomedcentral.com/articles/10.1186/s12915-022-01372-6" +issues_url = "https://github.com/SchmollerLab/Cell_ACDC/issues" # Initialize variables that need to be globally accessible base_cca_dict = { - 'cell_cycle_stage': 'G1', - 'generation_num': 2, - 'relative_ID': -1, - 'relationship': 'mother', - 'emerg_frame_i': -1, - 'division_frame_i': -1, - 'is_history_known': False, - 'corrected_on_frame_i': -1, - 'will_divide': 0, - 'daughter_disappears_before_division': 0, - 'disappears_before_division': 0 + "cell_cycle_stage": "G1", + "generation_num": 2, + "relative_ID": -1, + "relationship": "mother", + "emerg_frame_i": -1, + "division_frame_i": -1, + "is_history_known": False, + "corrected_on_frame_i": -1, + "will_divide": 0, + "daughter_disappears_before_division": 0, + "disappears_before_division": 0, } cca_df_colnames = list(base_cca_dict.keys()) base_cca_tree_dict = { - 'Cell_ID_tree': -1, - 'generation_num_tree': 1, - 'parent_ID_tree': -1, - 'root_ID_tree': -1, - 'sister_ID_tree': -1 + "Cell_ID_tree": -1, + "generation_num_tree": 1, + "parent_ID_tree": -1, + "root_ID_tree": -1, + "sister_ID_tree": -1, } lineage_tree_cols = list(base_cca_tree_dict.keys()) @@ -500,47 +513,36 @@ def printl(*objects, pretty=False, is_decorator=False, idx=1, **kwargs): # 'sister_ID_tree' # ] -lineage_tree_cols_std_val = [ - -1, - -1, - -1, - -1, - -1 -] +lineage_tree_cols_std_val = [-1, -1, -1, -1, -1] default_annot_df = { - 'is_cell_dead': False, - 'is_cell_excluded': False, + "is_cell_dead": False, + "is_cell_excluded": False, } -base_acdc_df = { - **default_annot_df, - 'was_manually_edited': 0 -} +base_acdc_df = {**default_annot_df, "was_manually_edited": 0} base_acdc_df_cols = list(base_acdc_df.keys()) -sorted_cols = ['time_seconds', 'time_minutes', 'time_hours'] -sorted_cols = [ - *sorted_cols, *cca_df_colnames, *lineage_tree_cols, *base_acdc_df_cols -] +sorted_cols = ["time_seconds", "time_minutes", "time_hours"] +sorted_cols = [*sorted_cols, *cca_df_colnames, *lineage_tree_cols, *base_acdc_df_cols] cca_df_colnames_with_tree = [*cca_df_colnames, *lineage_tree_cols] all_non_metrics_cols = [*base_acdc_df_cols, *cca_df_colnames, *lineage_tree_cols] -is_linux = sys.platform.startswith('linux') -is_mac = sys.platform == 'darwin' +is_linux = sys.platform.startswith("linux") +is_mac = sys.platform == "darwin" is_win = sys.platform.startswith("win") -is_win64 = (is_win and (os.environ["PROCESSOR_ARCHITECTURE"] == "AMD64")) -is_mac_arm64 = is_mac and platform.machine() == 'arm64' +is_win64 = is_win and (os.environ["PROCESSOR_ARCHITECTURE"] == "AMD64") +is_mac_arm64 = is_mac and platform.machine() == "arm64" if is_linux and GUI_INSTALLED: from pathlib import Path acdc_exec_path = shutil.which("acdc") - logo_path = os.path.join(resources_folderpath, 'logo_square_v2.png') + logo_path = os.path.join(resources_folderpath, "logo_square_v2.png") txt = f""" [Desktop Entry] Name=Cell-ACDC @@ -568,10 +570,9 @@ def printl(*objects, pretty=False, is_decorator=False, idx=1, **kwargs): # Make the .desktop file executable (equivalent to chmod +x) import stat + mode = os.stat(desktop_file).st_mode - os.chmod( - desktop_file, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH - ) + os.chmod(desktop_file, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) # 🔄 Refresh the desktop database try: @@ -582,85 +583,82 @@ def printl(*objects, pretty=False, is_decorator=False, idx=1, **kwargs): stderr=subprocess.PIPE, ) exit( - 'Cell-ACDC had to update the desktop database. ' - 'Please re-start the software, thanks!' + "Cell-ACDC had to update the desktop database. " + "Please re-start the software, thanks!" ) except FileNotFoundError: - print("⚠️ 'update-desktop-database' not found. It’s part of the 'desktop-file-utils' package.") + print( + "⚠️ 'update-desktop-database' not found. It’s part of the 'desktop-file-utils' package." + ) except subprocess.CalledProcessError as e: print(f"⚠️ Error updating desktop database:\n{e.stderr.decode()}") yeaz_weights_filenames = [ - 'unet_weights_batchsize_25_Nepochs_100_SJR0_10.hdf5', - 'weights_budding_BF_multilab_0_1.hdf5' + "unet_weights_batchsize_25_Nepochs_100_SJR0_10.hdf5", + "weights_budding_BF_multilab_0_1.hdf5", ] yeaz_v2_weights_filenames = [ - 'weights_budding_BF_multilab_0_1', - 'weights_budding_PhC_multilab_0_1', - 'weights_fission_multilab_0_2' + "weights_budding_BF_multilab_0_1", + "weights_budding_PhC_multilab_0_1", + "weights_fission_multilab_0_2", ] segment_anything_weights_filenames = [ - 'sam_vit_h_4b8939.pth', - 'sam_vit_l_0b3195.pth', - 'sam_vit_b_01ec64.pth' + "sam_vit_h_4b8939.pth", + "sam_vit_l_0b3195.pth", + "sam_vit_b_01ec64.pth", ] sam2_weights_filenames = [ - 'sam2.1_hiera_large.pt', - 'sam2.1_hiera_base_plus.pt', - 'sam2.1_hiera_small.pt', - 'sam2.1_hiera_tiny.pt' + "sam2.1_hiera_large.pt", + "sam2.1_hiera_base_plus.pt", + "sam2.1_hiera_small.pt", + "sam2.1_hiera_tiny.pt", ] -deepsea_weights_filenames = [ - 'segmentation.pth', - 'tracker.pth' -] +deepsea_weights_filenames = ["segmentation.pth", "tracker.pth"] yeastmate_weights_filenames = [ - 'yeastmate_advanced.yaml', - 'yeastmate_weights.pth', - 'yeastmate.yaml' + "yeastmate_advanced.yaml", + "yeastmate_weights.pth", + "yeastmate.yaml", ] -tapir_weights_filenames = [ - 'tapir_checkpoint.npy' -] +tapir_weights_filenames = ["tapir_checkpoint.npy"] graphLayoutBkgrColor = (235, 235, 235) -darkBkgrColor = [255-v for v in graphLayoutBkgrColor] +darkBkgrColor = [255 - v for v in graphLayoutBkgrColor] + def _critical_exception_gui(self, func_name): from . import widgets, html_utils + result = None traceback_str = traceback.format_exc() - - if hasattr(self, 'is_error_state') and self.is_error_state: + + if hasattr(self, "is_error_state") and self.is_error_state: printl(traceback_str) return - - if hasattr(self, 'logger'): + + if hasattr(self, "logger"): self.logger.error(traceback_str) else: printl(traceback_str) - + try: self.cleanUpOnError() except Exception as e: pass - + msg = widgets.myMessageBox(wrapText=False, showCentered=False) - if hasattr(self, 'logs_path'): - msg.addShowInFileManagerButton( - self.logs_path, txt='Show log file...' - ) - if not hasattr(self, 'log_path'): - log_path = 'NULL' + if hasattr(self, "logs_path"): + msg.addShowInFileManagerButton(self.logs_path, txt="Show log file...") + if not hasattr(self, "log_path"): + log_path = "NULL" else: log_path = self.log_path - + self.is_error_state = True msg.setDetailedText(traceback_str, visible=True) href = f'GitHub page' @@ -675,15 +673,16 @@ def _critical_exception_gui(self, func_name): here: """) - msg.critical(self, 'Critical error', err_msg, commands=(log_path,)) - + msg.critical(self, "Critical error", err_msg, commands=(log_path,)) + + def exception_handler_cli(func): @wraps(func) def inner_function(self, *args, **kwargs): try: - if func.__code__.co_argcount==1 and func.__defaults__ is None: + if func.__code__.co_argcount == 1 and func.__defaults__ is None: result = func(self) - elif func.__code__.co_argcount>1 and func.__defaults__ is None: + elif func.__code__.co_argcount > 1 and func.__defaults__ is None: result = func(self, *args) else: result = func(self, *args, **kwargs) @@ -694,24 +693,28 @@ def inner_function(self, *args, **kwargs): else: raise err return result + return inner_function + def exec_time(func): @wraps(func) def inner_function(self, *args, **kwargs): t0 = time.perf_counter() - if func.__code__.co_argcount==1 and func.__defaults__ is None: + if func.__code__.co_argcount == 1 and func.__defaults__ is None: result = func(self) - elif func.__code__.co_argcount>1 and func.__defaults__ is None: + elif func.__code__.co_argcount > 1 and func.__defaults__ is None: result = func(self, *args) else: result = func(self, *args, **kwargs) t1 = time.perf_counter() - s = f'{func.__name__} execution time = {(t1-t0)*1000:.3f} ms' + s = f"{func.__name__} execution time = {(t1 - t0) * 1000:.3f} ms" printl(s, is_decorator=True) return result + return inner_function + def _exception_handler_clean_progress(self): try: if self.progressWin is not None: @@ -720,8 +723,10 @@ def _exception_handler_clean_progress(self): except AttributeError: pass + def exception_handler(func): """Decorator to handle class methods exceptions and show a critical error message.""" + @wraps(func) def inner_function(self, *args, **kwargs): try: @@ -729,10 +734,7 @@ def inner_function(self, *args, **kwargs): except TypeError as e: # Only handle the specific Qt slot error msg = str(e) - if ( - "takes 1 positional argument but 2 were given" in msg - and len(args) > 0 - ): + if "takes 1 positional argument but 2 were given" in msg and len(args) > 0: try: # Remove only the last argument (assumed to be from Qt) filtered_args = args[:-1] @@ -747,8 +749,10 @@ def inner_function(self, *args, **kwargs): _exception_handler_clean_progress(self) result = _critical_exception_gui(self, func.__name__) return result + return inner_function + def disableWindow(func): @wraps(func) def inner_function(self, *args, **kwargs): @@ -772,44 +776,43 @@ def inner_function(self, *args, **kwargs): finally: self.setDisabled(False) self.activateWindow() + return inner_function + def ignore_exception(func): @wraps(func) def inner_function(self, *args, **kwargs): try: - if func.__code__.co_argcount==1 and func.__defaults__ is None: + if func.__code__.co_argcount == 1 and func.__defaults__ is None: result = func(self) - elif func.__code__.co_argcount>1 and func.__defaults__ is None: + elif func.__code__.co_argcount > 1 and func.__defaults__ is None: result = func(self, *args) else: result = func(self, *args, **kwargs) except Exception as e: pass return result + return inner_function -error_below = f"\n{'*'*50} ERROR {'*'*50}\n" -error_close = f"\n{'^'*(len(error_below)-1)}" -error_up_str = '^'*100 -error_up_str = f'\n{error_up_str}' -error_down_str = '^'*100 -error_down_str = f'\n{error_down_str}' +error_below = f"\n{'*' * 50} ERROR {'*' * 50}\n" +error_close = f"\n{'^' * (len(error_below) - 1)}" -binary_file_extensions = ( - ".png", ".pdf" -) +error_up_str = "^" * 100 +error_up_str = f"\n{error_up_str}" +error_down_str = "^" * 100 +error_down_str = f"\n{error_down_str}" + +binary_file_extensions = (".png", ".pdf") default_index_cols = ( - 'experiment_folderpath', - 'experiment_foldername', - 'Position_n', - 'frame_i', - 'Cell_ID' + "experiment_folderpath", + "experiment_foldername", + "Position_n", + "frame_i", + "Cell_ID", ) -single_pos_index_cols = ( - 'experiment_folderpath', - 'Position_n' -) \ No newline at end of file +single_pos_index_cols = ("experiment_folderpath", "Position_n") diff --git a/cellacdc/__main__.py b/cellacdc/__main__.py index 30d670d16..0ef175de5 100755 --- a/cellacdc/__main__.py +++ b/cellacdc/__main__.py @@ -6,92 +6,100 @@ import numpy as np import site + sitepackages = site.getsitepackages() -site_packages = [p for p in sitepackages if p.endswith('site-packages')][0] +site_packages = [p for p in sitepackages if p.endswith("site-packages")][0] cellacdc_path = os.path.dirname(os.path.abspath(__file__)) cellacdc_installation_path = os.path.dirname(cellacdc_path) if cellacdc_installation_path != site_packages: - # Running developer version. Delete cellacdc folder from site_packages + # Running developer version. Delete cellacdc folder from site_packages # if present from a previous installation of cellacdc from PyPi - cellacdc_path_pypi = os.path.join(site_packages, 'cellacdc') + cellacdc_path_pypi = os.path.join(site_packages, "cellacdc") if os.path.exists(cellacdc_path_pypi): import shutil + try: shutil.rmtree(cellacdc_path_pypi) except Exception as err: print(err) print( - '[ERROR]: Previous Cell-ACDC installation detected. ' - f'Please, manually delete this folder and re-start the software ' + "[ERROR]: Previous Cell-ACDC installation detected. " + f"Please, manually delete this folder and re-start the software " f'"{cellacdc_path_pypi}". ' - 'Thank you for you patience!' + "Thank you for you patience!" ) exit() - print('*'*60) + print("*" * 60) input( - '[WARNING]: Cell-ACDC had to clean-up and older installation. ' - 'Please, re-start the software. Thank you for your patience! ' - '(Press any key to exit). ' + "[WARNING]: Cell-ACDC had to clean-up and older installation. " + "Please, re-start the software. Thank you for your patience! " + "(Press any key to exit). " ) exit() from cellacdc import _run + def run(): from cellacdc.config import parser_args - PARAMS_PATH = parser_args['params'] - - if parser_args['version'] or parser_args['info']: + PARAMS_PATH = parser_args["params"] + + if parser_args["version"] or parser_args["info"]: from cellacdc.myutils import get_info_version_text + info_txt = get_info_version_text() print(info_txt) exit() - if parser_args['reset']: + if parser_args["reset"]: from cellacdc.myutils import reset_settings + reset_info_txt = reset_settings() print(reset_info_txt) exit() - + if PARAMS_PATH: _run.run_cli(PARAMS_PATH) else: run_gui() + def main(): # Keep compatibility with users that installed older versions # where the entry point was main() run() + def run_gui(): from ._run import ( - _setup_gui_libraries, + _setup_gui_libraries, _setup_symlink_app_name_macos, - _setup_numpy, - download_model_params, - _exit_on_setup + _setup_numpy, + download_model_params, + _exit_on_setup, ) - + _setup_symlink_app_name_macos() - + requires_exit = _setup_gui_libraries(exit_at_end=False) - + _setup_numpy() - + download_model_params() - + if requires_exit: _exit_on_setup() - + from qtpy import QtGui, QtWidgets, QtCore - if os.name == 'nt': + if os.name == "nt": try: # Set taskbar icon in windows import ctypes - myappid = 'schmollerlab.cellacdc.pyqt.v1' # arbitrary string + + myappid = "schmollerlab.cellacdc.pyqt.v1" # arbitrary string ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) except Exception as e: pass @@ -105,16 +113,19 @@ def run_gui(): pass import pyqtgraph as pg + # Interpret image data as row-major instead of col-major - pg.setConfigOption('imageAxisOrder', 'row-major') + pg.setConfigOption("imageAxisOrder", "row-major") try: import numba + pg.setConfigOption("useNumba", True) except Exception as e: pass try: import cupy as cp + pg.setConfigOption("useCupy", True) except Exception as e: pass @@ -123,26 +134,24 @@ def run_gui(): app, splashScreen = _run._setup_app(splashscreen=True) from cellacdc import myutils, printl - - print('Launching application...') + + print("Launching application...") from cellacdc._main import mainWin - + if not splashScreen.isVisible(): splashScreen.show() - + win = mainWin(app) try: myutils.check_matplotlib_version(qparent=win) except Exception as e: pass - version, success = myutils.read_version( - logger=win.logger.info, return_success=True - ) + version, success = myutils.read_version(logger=win.logger.info, return_success=True) if not success: error = myutils.check_install_package( - 'setuptools_scm', pypi_name='setuptools-scm' + "setuptools_scm", pypi_name="setuptools-scm" ) if error: win.logger.info(error) @@ -155,14 +164,16 @@ def run_gui(): win.welcomeGuide.showPage(win.welcomeGuide.welcomeItem) except AttributeError: pass - win.logger.info('**********************************************') - win.logger.info(f'Welcome to Cell-ACDC v{version}') - win.logger.info('**********************************************') - win.logger.info('----------------------------------------------') - win.logger.info('NOTE: If application is not visible, it is probably minimized\n' - 'or behind some other open windows.') - win.logger.info('----------------------------------------------') + win.logger.info("**********************************************") + win.logger.info(f"Welcome to Cell-ACDC v{version}") + win.logger.info("**********************************************") + win.logger.info("----------------------------------------------") + win.logger.info( + "NOTE: If application is not visible, it is probably minimized\n" + "or behind some other open windows." + ) + win.logger.info("----------------------------------------------") splashScreen.close() # splashScreenApp.quit() # modernWin.show() - app.exec_() \ No newline at end of file + app.exec_() diff --git a/cellacdc/_base_widgets.py b/cellacdc/_base_widgets.py index 667881183..09423e2b3 100644 --- a/cellacdc/_base_widgets.py +++ b/cellacdc/_base_widgets.py @@ -1,8 +1,7 @@ from qtpy.QtWidgets import QDialog from . import printl -from qtpy.QtCore import ( - Qt, QEventLoop -) +from qtpy.QtCore import Qt, QEventLoop + class QBaseDialog(QDialog): def __init__(self, parent=None): @@ -11,7 +10,7 @@ def __init__(self, parent=None): def exec_(self, resizeWidthFactor=None): if resizeWidthFactor is not None: self.show() - self.resize(int(self.width()*resizeWidthFactor), self.height()) + self.resize(int(self.width() * resizeWidthFactor), self.height()) self.show(block=True) def show(self, block=False): @@ -28,12 +27,12 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() - + def keyPressEvent(self, event) -> None: if event.key() == Qt.Key_Escape: event.ignore() return - + super().keyPressEvent(event) diff --git a/cellacdc/_core.py b/cellacdc/_core.py index 91cea1ab2..ee676a8f0 100644 --- a/cellacdc/_core.py +++ b/cellacdc/_core.py @@ -11,49 +11,52 @@ from . import printl time_units_formats = { - 'min': 'minutes', - 'hour': 'hours', - 'second': 'seconds', - 'minutes': 'minutes', - 'seconds': 'seconds', - 'hours': 'hours', - 'H': 'hours', - 'd': 'days', - 'M': 'minutes', - 'S': 'seconds', + "min": "minutes", + "hour": "hours", + "second": "seconds", + "minutes": "minutes", + "seconds": "seconds", + "hours": "hours", + "H": "hours", + "d": "days", + "M": "minutes", + "S": "seconds", } time_units_converters = { - 'seconds -> minutes': lambda x: x/60, - 'seconds -> hours': lambda x: x/3600, - 'seconds -> days': lambda x: x/3600/24, - 'minutes -> hours': lambda x: x/60, - 'minutes -> seconds': lambda x: x*60, - 'minutes -> days': lambda x: x/60/24, - 'hours -> minutes': lambda x: x*60, - 'hours -> seconds': lambda x: x*3600, - 'hours -> days': lambda x: x/24, - 'days -> minutes': lambda x: x*24*60, - 'days -> seconds': lambda x: x*24*3600, - 'days -> hours': lambda x: x*24*3600, + "seconds -> minutes": lambda x: x / 60, + "seconds -> hours": lambda x: x / 3600, + "seconds -> days": lambda x: x / 3600 / 24, + "minutes -> hours": lambda x: x / 60, + "minutes -> seconds": lambda x: x * 60, + "minutes -> days": lambda x: x / 60 / 24, + "hours -> minutes": lambda x: x * 60, + "hours -> seconds": lambda x: x * 3600, + "hours -> days": lambda x: x / 24, + "days -> minutes": lambda x: x * 24 * 60, + "days -> seconds": lambda x: x * 24 * 3600, + "days -> hours": lambda x: x * 24 * 3600, } length_unit_converters = { - 'nm -> μm': lambda x: x/1000, - 'mm -> μm': lambda x: x*1e3, - 'cm -> μm': lambda x: x*1e4, - 'μm -> nm': lambda x: x*1000, - 'μm -> mm': lambda x: x/1e3, - 'μm -> cm': lambda x: x/1e4, - 'μm -> μm': lambda x: x, + "nm -> μm": lambda x: x / 1000, + "mm -> μm": lambda x: x * 1e3, + "cm -> μm": lambda x: x * 1e4, + "μm -> nm": lambda x: x * 1000, + "μm -> mm": lambda x: x / 1e3, + "μm -> cm": lambda x: x / 1e4, + "μm -> μm": lambda x: x, } + def convert_length(value, from_unit, to_unit): - key = f'{from_unit} -> {to_unit}' + key = f"{from_unit} -> {to_unit}" return length_unit_converters[key](value) + def round_to_significant(n, n_significant=1): - return round(n, n_significant-int(floor(log10(abs(n))))-1) + return round(n, n_significant - int(floor(log10(abs(n)))) - 1) + def convert_time_units(x, from_unit, to_unit): try: @@ -65,6 +68,7 @@ def convert_time_units(x, from_unit, to_unit): except Exception as e: return + def _calc_rotational_vol(obj, PhysicalSizeY=1, PhysicalSizeX=1, logger=None): """Given the region properties of a 2D object (from skimage.measure.regionprops). calculate the rotation volume as described in the Supplementary information of @@ -103,18 +107,20 @@ def _calc_rotational_vol(obj, PhysicalSizeY=1, PhysicalSizeX=1, logger=None): try: if is3Dobj: # For 3D objects we use a max projection for the rotation - obj_lab = obj.image.max(axis=0).astype(np.uint32)*obj.label + obj_lab = obj.image.max(axis=0).astype(np.uint32) * obj.label obj = regionprops(obj_lab)[0] - vox_to_fl = float(PhysicalSizeY)*pow(float(PhysicalSizeX), 2) + vox_to_fl = float(PhysicalSizeY) * pow(float(PhysicalSizeX), 2) rotate_ID_img = skimage_rotate( - obj.image.astype(np.single), -(obj.orientation*180/np.pi), - resize=True, order=3 + obj.image.astype(np.single), + -(obj.orientation * 180 / np.pi), + resize=True, + order=3, ) - radii = np.sum(rotate_ID_img, axis=1)/2 - vol_vox = np.sum(np.pi*(radii**2)) + radii = np.sum(rotate_ID_img, axis=1) / 2 + vol_vox = np.sum(np.pi * (radii**2)) if vox_to_fl is not None: - return vol_vox, float(vol_vox*vox_to_fl) + return vol_vox, float(vol_vox * vox_to_fl) else: return vol_vox, vol_vox except Exception as e: @@ -124,17 +130,24 @@ def _calc_rotational_vol(obj, PhysicalSizeY=1, PhysicalSizeX=1, logger=None): printl(traceback.format_exc()) return np.nan, np.nan -def _initialize_single_image(image, is_rgb=False, isZstack=False, img_shape=None, # in use, pylint cant detect it - timelapse=False, img_ndim=None, frame_index_out=None, # assumes that the order of dimesions is t, z, c, h, w - add_rgb=True, ): # for some reason doesnt move axis.... + +def _initialize_single_image( + image, + is_rgb=False, + isZstack=False, + img_shape=None, # in use, pylint cant detect it + timelapse=False, + img_ndim=None, + frame_index_out=None, # assumes that the order of dimesions is t, z, c, h, w + add_rgb=True, +): # for some reason doesnt move axis.... # See cellpose.gui.io._initialize_images if img_shape is None: img_shape = image.shape if img_ndim is None: img_ndim = len(img_shape) - - if is_rgb: # enforce 3 channels if RGB, assuming rgb is last axis + if is_rgb: # enforce 3 channels if RGB, assuming rgb is last axis # move channel axis to the end if it is not already # image = np.moveaxis(image, input_channel_axis, -1) # img_shape = list(image) @@ -143,18 +156,18 @@ def _initialize_single_image(image, is_rgb=False, isZstack=False, img_shape=None if img_shape[-1] == 3: pass elif img_shape[-1] < 3: - shape_to_concat = (img_shape[0], img_shape[1], 3-img_shape[-1]) - to_concat = np.zeros(shape_to_concat,dtype=type(image[0,0,0])) + shape_to_concat = (img_shape[0], img_shape[1], 3 - img_shape[-1]) + to_concat = np.zeros(shape_to_concat, dtype=type(image[0, 0, 0])) image = np.concatenate((image, to_concat), axis=-1) - elif img_shape[-1]<5 and img_shape[-1]>2: - image = image[:,:,:3] - + elif img_shape[-1] < 5 and img_shape[-1] > 2: + image = image[:, :, :3] + image = image.astype(np.float32) if is_rgb: # Compute min and max per channel (last axis) - img_min = image.min(axis=tuple(range(image.ndim-1)), keepdims=True) - img_max = image.max(axis=tuple(range(image.ndim-1)), keepdims=True) + img_min = image.min(axis=tuple(range(image.ndim - 1)), keepdims=True) + img_max = image.max(axis=tuple(range(image.ndim - 1)), keepdims=True) else: # Compute min and max over all channels img_min = image.min() @@ -172,7 +185,7 @@ def _initialize_single_image(image, is_rgb=False, isZstack=False, img_shape=None to_concat = np.zeros(shape_to_concat, dtype=type(image[0, 0, 0])) image = image[..., np.newaxis] # add a new axis for channels image = np.concatenate([image, to_concat], axis=-1) - + if is_rgb or add_rgb: axis_for_channels = -3 image = np.moveaxis(image, -1, axis_for_channels) @@ -182,4 +195,4 @@ def _initialize_single_image(image, is_rgb=False, isZstack=False, img_shape=None # z x W x H x c -> z x c x W x H # W x H x c -> c x W x H image = image.astype(np.float32) - return frame_index_out, image \ No newline at end of file + return frame_index_out, image diff --git a/cellacdc/_debug.py b/cellacdc/_debug.py index a0f3e7292..892703351 100644 --- a/cellacdc/_debug.py +++ b/cellacdc/_debug.py @@ -5,39 +5,45 @@ from . import printl, core + def split_segm_masks_mother_bud_line(lab, obj, obj_bud, ref_p1, ref_p2): import matplotlib.pyplot as plt - + lab = np.zeros_like(lab) lab[obj.slice][obj.image] = obj.label lab[obj_bud.slice][obj_bud.image] = obj_bud.label - + (x_ref_0, y_ref_0), (x_ref1, y_ref1) = ref_p1, ref_p2 - - plt.imshow(lab) - plt.plot([x_ref_0, x_ref1], [y_ref_0, y_ref1], 'r') + + plt.imshow(lab) + plt.plot([x_ref_0, x_ref1], [y_ref_0, y_ref1], "r") plt.show() - - import pdb; pdb.set_trace() + + import pdb + + pdb.set_trace() + def print_all_callers(): currentframe = inspect.currentframe() outerframes = inspect.getouterframes(currentframe, 2) - outerframes_format = '\n' + outerframes_format = "\n" for frame in outerframes: - outerframes_format = f'{outerframes_format} * {frame.function}\n' + outerframes_format = f"{outerframes_format} * {frame.function}\n" printl(outerframes_format) + def _debug_lineage_tree(guiWin): posData = guiWin.data[guiWin.pos_i] - columns = set() + columns = set() for frame_i in range(len(posData.allData_li)): - acdc_df = posData.allData_li[frame_i]['acdc_df'] + acdc_df = posData.allData_li[frame_i]["acdc_df"] if acdc_df is not None: columns.update(acdc_df.reset_index().columns) printl(f"Columns in acdc_df: {columns}") from pandasgui import show as pgshow + if guiWin.lineage_tree is not None and guiWin.lineage_tree.lineage_list is not None: lin_tree_df = pd.DataFrame() for i, df in enumerate(guiWin.lineage_tree.lineage_list): @@ -49,16 +55,13 @@ def _debug_lineage_tree(guiWin): if not isinstance(lin_tree_df.index, pd.RangeIndex): lin_tree_df = lin_tree_df.reset_index() - lin_tree_df = (lin_tree_df - .set_index(["frame_i", "Cell_ID"]) - .sort_index() - ) + lin_tree_df = lin_tree_df.set_index(["frame_i", "Cell_ID"]).sort_index() if "level_0" in lin_tree_df.columns: - lin_tree_df=lin_tree_df.drop(columns="level_0") + lin_tree_df = lin_tree_df.drop(columns="level_0") acdc_df = pd.DataFrame() posData = guiWin.data[guiWin.pos_i] - df_li = [posData.allData_li[i]['acdc_df'] for i in range(len(posData.allData_li))] + df_li = [posData.allData_li[i]["acdc_df"] for i in range(len(posData.allData_li))] for i, df in enumerate(df_li): if df is None: continue @@ -67,10 +70,7 @@ def _debug_lineage_tree(guiWin): df["frame_i"] = i acdc_df = pd.concat([acdc_df, df]) - acdc_df = (acdc_df - .set_index(["frame_i", "Cell_ID"]) - .sort_index() - ) + acdc_df = acdc_df.set_index(["frame_i", "Cell_ID"]).sort_index() # for key, value in guiWin.lineage_tree.family_dict.items(): if guiWin.lineage_tree is not None and guiWin.lineage_tree.lineage_list is not None: @@ -82,25 +82,24 @@ def _debug_lineage_tree(guiWin): family_df = family_df.set_index("family_name") families = pd.concat([families, family_df]) if "level_0" in families.columns: - families=families.drop(columns="level_0") + families = families.drop(columns="level_0") # lin_tree_dict_df = (lin_tree_dict_df # .set_index(["family_name", "frame_i", "Cell_ID"]) # .sort_index() # ) - + # for i, df in enumerate([acdc_df, lin_tree_df, families, lin_tree_dict_df]): # printl(f"Columns: {df.columns} for df {i}" ) # if (df.columns == df.index.name).any(): # printl(f"Index name: {df.index.name} for df {i}!!!" ) if "level_0" in acdc_df.columns: - acdc_df=acdc_df.drop(columns="level_0") - + acdc_df = acdc_df.drop(columns="level_0") if guiWin.lineage_tree is not None and guiWin.lineage_tree.lineage_list is not None: pgshow(acdc_df, lin_tree_df, families) else: pgshow(acdc_df) - # printl(posData.tracked_lost_centroids) \ No newline at end of file + # printl(posData.tracked_lost_centroids) diff --git a/cellacdc/_get_app_palette.py b/cellacdc/_get_app_palette.py index a6e3b543b..281b1eb1c 100644 --- a/cellacdc/_get_app_palette.py +++ b/cellacdc/_get_app_palette.py @@ -1,17 +1,27 @@ from qtpy import QtGui, QtWidgets, QtCore -print(f'Using Qt version {QtCore.__version__}') +print(f"Using Qt version {QtCore.__version__}") from pprint import pprint app = QtWidgets.QApplication([]) -app.setStyle(QtWidgets.QStyleFactory.create('Fusion')) +app.setStyle(QtWidgets.QStyleFactory.create("Fusion")) app.setPalette(app.style().standardPalette()) roles = ( - 'Window', 'WindowText', 'Base', 'AlternateBase', 'ToolTipBase', - 'ToolTipText', 'Text', 'Button', 'ButtonText', 'BrightText', - 'Link', 'Highlight', 'HighlightedText' + "Window", + "WindowText", + "Base", + "AlternateBase", + "ToolTipBase", + "ToolTipText", + "Text", + "Button", + "ButtonText", + "BrightText", + "Link", + "Highlight", + "HighlightedText", ) colors = {} @@ -21,4 +31,4 @@ rgba = app.palette().color(colorRole).getRgb() colors[role] = rgba -pprint(colors, sort_dicts=False) \ No newline at end of file +pprint(colors, sort_dicts=False) diff --git a/cellacdc/_main.py b/cellacdc/_main.py index f220a3b91..3f20ca434 100644 --- a/cellacdc/_main.py +++ b/cellacdc/_main.py @@ -12,22 +12,54 @@ from qtpy import QtCore, QtWidgets from qtpy.QtWidgets import ( - QMainWindow, QVBoxLayout, QPushButton, QLabel, QAction, - QMenu, QHBoxLayout, QFileDialog, QGroupBox, QCheckBox, QSplashScreen + QMainWindow, + QVBoxLayout, + QPushButton, + QLabel, + QAction, + QMenu, + QHBoxLayout, + QFileDialog, + QGroupBox, + QCheckBox, + QSplashScreen, ) from qtpy.QtCore import ( - Qt, QProcess, Signal, Slot, QTimer, QSize, - QSettings, QUrl, QCoreApplication + Qt, + QProcess, + Signal, + Slot, + QTimer, + QSize, + QSettings, + QUrl, + QCoreApplication, ) from qtpy.QtGui import ( - QFontDatabase, QIcon, QDesktopServices, QFont, QColor, - QPalette, QGuiApplication, QPixmap + QFontDatabase, + QIcon, + QDesktopServices, + QFont, + QColor, + QPalette, + QGuiApplication, + QPixmap, ) import qtpy.compat from . import ( - dataPrep, segm, gui, dataStruct, load, help, myutils, - cite_url, html_utils, widgets, apps, dataReStruct + dataPrep, + segm, + gui, + dataStruct, + load, + help, + myutils, + cite_url, + html_utils, + widgets, + apps, + dataReStruct, ) from .help import about from .utils import concat as utilsConcat @@ -63,15 +95,14 @@ from . import exception_handler from . import user_profile_path from . import cellacdc_path -from . config import parser_args +from .config import parser_args try: import spotmax from spotmax import _run as spotmaxRun + spotmax_filepath = os.path.dirname(os.path.abspath(spotmax.__file__)) - spotmax_logo_path = os.path.join( - spotmax_filepath, 'resources', 'spotMAX_logo.svg' - ) + spotmax_logo_path = os.path.join(spotmax_filepath, "resources", "spotMAX_logo.svg") SPOTMAX_INSTALLED = True except Exception as e: # traceback.print_exc() @@ -79,6 +110,7 @@ traceback.print_exc() SPOTMAX_INSTALLED = False + def restart(): QCoreApplication.quit() process = QtCore.QProcess() @@ -86,7 +118,8 @@ def restart(): # process.setStandardOutputFile(QProcess.nullDevice()) status = process.startDetached() if status: - print('Restarting Cell-ACDC...') + print("Restarting Cell-ACDC...") + class mainWin(QMainWindow): def __init__(self, app, parent=None): @@ -95,22 +128,20 @@ def __init__(self, app, parent=None): scheme = self.getColorScheme() self.welcomeGuide = None self._do_restart = False - + super().__init__(parent) self.setWindowTitle("Cell-ACDC") self.setWindowIcon(QIcon(":icon.ico")) self.setAcceptDrops(True) - + self.checkUserDataFolderPath = True - logger, logs_path, log_path, log_filename = myutils.setupLogger( - module='main' - ) + logger, logs_path, log_path, log_filename = myutils.setupLogger(module="main") self.logger = logger self.log_path = log_path self.log_filename = log_filename - self.logs_path = logs_path - + self.logs_path = logs_path + if not is_linux: self.loadFonts() @@ -125,19 +156,23 @@ def __init__(self, app, parent=None): mainLayout = QVBoxLayout() mainLayout.addStretch() - welcomeLabel = QLabel(html_utils.paragraph( - 'Welcome to Cell-ACDC!', - center=True, font_size='18px' - )) + welcomeLabel = QLabel( + html_utils.paragraph( + "Welcome to Cell-ACDC!", center=True, font_size="18px" + ) + ) # padding: top, left, bottom, right welcomeLabel.setStyleSheet("padding:0px 0px 5px 0px;") mainLayout.addWidget(welcomeLabel) - label = QLabel(html_utils.paragraph( - 'Press any of the following buttons
' - 'to launch the respective module', - center=True, font_size='14px' - )) + label = QLabel( + html_utils.paragraph( + "Press any of the following buttons
" + "to launch the respective module", + center=True, + font_size="14px", + ) + ) # padding: top, left, bottom, right label.setStyleSheet("padding:0px 0px 10px 0px;") mainLayout.addWidget(label) @@ -145,16 +180,16 @@ def __init__(self, app, parent=None): mainLayout.addStretch() iconSize = 26 - + modulesButtonsGroupBox = QGroupBox() - modulesButtonsGroupBox.setTitle('Modules') + modulesButtonsGroupBox.setTitle("Modules") modulesButtonsGroupBoxLayout = QVBoxLayout() modulesButtonsGroupBox.setLayout(modulesButtonsGroupBoxLayout) - + dataStructButton = widgets.setPushButton( - ' 0. Create data structure from microscopy/image file(s)... ' + " 0. Create data structure from microscopy/image file(s)... " ) - dataStructButton.setIconSize(QSize(iconSize,iconSize)) + dataStructButton.setIconSize(QSize(iconSize, iconSize)) font = QFont() font.setPixelSize(13) dataStructButton.setFont(font) @@ -162,9 +197,9 @@ def __init__(self, app, parent=None): self.dataStructButton = dataStructButton modulesButtonsGroupBoxLayout.addWidget(dataStructButton) - dataPrepButton = QPushButton(' 1. Launch data prep module...') - dataPrepButton.setIcon(QIcon(':prep.svg')) - dataPrepButton.setIconSize(QSize(iconSize,iconSize)) + dataPrepButton = QPushButton(" 1. Launch data prep module...") + dataPrepButton.setIcon(QIcon(":prep.svg")) + dataPrepButton.setIconSize(QSize(iconSize, iconSize)) font = QFont() font.setPixelSize(13) dataPrepButton.setFont(font) @@ -172,16 +207,16 @@ def __init__(self, app, parent=None): self.dataPrepButton = dataPrepButton modulesButtonsGroupBoxLayout.addWidget(dataPrepButton) - segmButton = QPushButton(' 2. Launch segmentation module...') - segmButton.setIcon(QIcon(':segment.svg')) - segmButton.setIconSize(QSize(iconSize,iconSize)) + segmButton = QPushButton(" 2. Launch segmentation module...") + segmButton.setIcon(QIcon(":segment.svg")) + segmButton.setIconSize(QSize(iconSize, iconSize)) segmButton.setFont(font) segmButton.clicked.connect(self.launchSegm) self.segmButton = segmButton modulesButtonsGroupBoxLayout.addWidget(segmButton) - guiButton = QPushButton(' 3. Launch GUI...') - guiButton.setIcon(QIcon(':logo.svg')) + guiButton = QPushButton(" 3. Launch GUI...") + guiButton.setIcon(QIcon(":logo.svg")) guiButton.setIconSize(QSize(iconSize, iconSize)) guiButton.setFont(font) guiButton.clicked.connect(self.launchGui) @@ -189,25 +224,25 @@ def __init__(self, app, parent=None): modulesButtonsGroupBoxLayout.addWidget(guiButton) if SPOTMAX_INSTALLED: - spotmaxButton = QPushButton(' 4. Launch SpotMAX...') + spotmaxButton = QPushButton(" 4. Launch SpotMAX...") spotmaxButton.setIcon(QIcon(spotmax_logo_path)) - spotmaxButton.setIconSize(QSize(iconSize,iconSize)) + spotmaxButton.setIconSize(QSize(iconSize, iconSize)) spotmaxButton.setFont(font) self.spotmaxButton = spotmaxButton spotmaxButton.clicked.connect(self.launchSpotmaxGui) modulesButtonsGroupBoxLayout.addWidget(spotmaxButton) - + mainLayout.addWidget(modulesButtonsGroupBox) mainLayout.addSpacing(10) - + controlsButtonsGroupBox = QGroupBox() - controlsButtonsGroupBox.setTitle('Controls') + controlsButtonsGroupBox.setTitle("Controls") controlsButtonsGroupBoxLayout = QVBoxLayout() controlsButtonsGroupBox.setLayout(controlsButtonsGroupBoxLayout) - - showAllWindowsButton = QPushButton(' Restore open windows') - showAllWindowsButton.setIcon(QIcon(':eye.svg')) - showAllWindowsButton.setIconSize(QSize(iconSize,iconSize)) + + showAllWindowsButton = QPushButton(" Restore open windows") + showAllWindowsButton.setIcon(QIcon(":eye.svg")) + showAllWindowsButton.setIconSize(QSize(iconSize, iconSize)) showAllWindowsButton.setFont(font) self.showAllWindowsButton = showAllWindowsButton showAllWindowsButton.clicked.connect(self.showAllWindows) @@ -217,20 +252,17 @@ def __init__(self, app, parent=None): font.setPixelSize(13) closeLayout = QHBoxLayout() - restartButton = QPushButton( - QIcon(":reload.svg"), - ' Restart Cell-ACDC' - ) + restartButton = QPushButton(QIcon(":reload.svg"), " Restart Cell-ACDC") restartButton.setFont(font) restartButton.setIconSize(QSize(iconSize, iconSize)) restartButton.clicked.connect(self.close) self.restartButton = restartButton self.restartButton.hide() closeLayout.addWidget(restartButton) - + closeLayout.addWidget(showAllWindowsButton) - closeButton = QPushButton(QIcon(":close.svg"), ' Close application') + closeButton = QPushButton(QIcon(":close.svg"), " Close application") closeButton.setIconSize(QSize(iconSize, iconSize)) self.closeButton = closeButton # closeButton.setIconSize(QSize(24,24)) @@ -239,9 +271,9 @@ def __init__(self, app, parent=None): closeLayout.addWidget(closeButton) controlsButtonsGroupBoxLayout.addLayout(closeLayout) - + mainLayout.addWidget(controlsButtonsGroupBox) - + mainContainer.setLayout(mainLayout) self.guiWins = [] @@ -250,81 +282,87 @@ def __init__(self, app, parent=None): self._version = None self.progressWin = None self.forceClose = False - + def addStatusBar(self, scheme): self.statusbar = self.statusBar() # Permanent widget - label = QLabel('Dark mode') + label = QLabel("Dark mode") widget = QtWidgets.QWidget() layout = QHBoxLayout() widget.setLayout(layout) layout.addWidget(label) - self.darkModeToggle = widgets.Toggle(label_text='Dark mode') + self.darkModeToggle = widgets.Toggle(label_text="Dark mode") self.darkModeToggle.ignoreEvent = False - self.darkModeToggle.warnMessageBox = True - if scheme == 'dark': + self.darkModeToggle.warnMessageBox = True + if scheme == "dark": self.darkModeToggle.ignoreEvent = True self.darkModeToggle.setChecked(True) self.darkModeToggle.toggled.connect(self.onDarkModeToggled) layout.addWidget(self.darkModeToggle) self.statusBarLayout = layout self.statusbar.addWidget(widget) - + def getColorScheme(self): from ._palettes import get_color_scheme + return get_color_scheme() - + def onDarkModeToggled(self, checked): if self.darkModeToggle.ignoreEvent: self.darkModeToggle.ignoreEvent = False return from ._palettes import getPaletteColorScheme - scheme = 'dark' if checked else 'light' + + scheme = "dark" if checked else "light" load.rename_qrc_resources_file(scheme) if not os.path.exists(settings_csv_path): - df_settings = pd.DataFrame( - {'setting': [], 'value': []}).set_index('setting') + df_settings = pd.DataFrame({"setting": [], "value": []}).set_index( + "setting" + ) else: - df_settings = pd.read_csv(settings_csv_path, index_col='setting') - df_settings.at['colorScheme', 'value'] = scheme + df_settings = pd.read_csv(settings_csv_path, index_col="setting") + df_settings.at["colorScheme", "value"] = scheme df_settings.to_csv(settings_csv_path) if self.darkModeToggle.warnMessageBox: _warnings.warnRestartCellACDCcolorModeToggled( - scheme, app_name='Cell-ACDC', parent=self + scheme, app_name="Cell-ACDC", parent=self ) self.darkModeToggle.warnMessageBox = True self.setStatusBarRestartCellACDC() self.darkModeToggle.setDisabled(True) - + def setStatusBarRestartCellACDC(self): - self.statusBarLayout.addWidget(QLabel(html_utils.paragraph( - 'Restart Cell-ACDC for the change to take effect', - font_color='red' - ))) - + self.statusBarLayout.addWidget( + QLabel( + html_utils.paragraph( + "Restart Cell-ACDC for the change to take effect", + font_color="red", + ) + ) + ) + def checkConfigFiles(self): - print('Loading configuration files...') + print("Loading configuration files...") paths_to_check = [ - gui.favourite_func_metrics_csv_path, - # gui.custom_annot_path, - gui.shortcut_filepath, - os.path.join(settings_folderpath, 'recentPaths.csv'), - load.last_entries_metadata_path, - load.additional_metadata_path, - load.last_selected_measurements_ini_path + gui.favourite_func_metrics_csv_path, + # gui.custom_annot_path, + gui.shortcut_filepath, + os.path.join(settings_folderpath, "recentPaths.csv"), + load.last_entries_metadata_path, + load.additional_metadata_path, + load.last_selected_measurements_ini_path, ] for path in paths_to_check: load.remove_duplicates_file(path) - - def dragEnterEvent(self, event) -> None: - ... - + + def dragEnterEvent(self, event) -> None: ... + def log(self, text): self.logger.info(text) - + if self.progressWin is None: return - + self.progressWin.log(text) def setVersion(self, version): @@ -352,18 +390,19 @@ def loadFonts(self): def launchWelcomeGuide(self, checked=False): if not os.path.exists(settings_csv_path): - idx = ['showWelcomeGuide'] - values = ['Yes'] + idx = ["showWelcomeGuide"] + values = ["Yes"] self.df_settings = pd.DataFrame( - {'setting': idx, 'value': values}).set_index('setting') + {"setting": idx, "value": values} + ).set_index("setting") self.df_settings.to_csv(settings_csv_path) - self.df_settings = pd.read_csv(settings_csv_path, index_col='setting') - if 'showWelcomeGuide' not in self.df_settings.index: - self.df_settings.at['showWelcomeGuide', 'value'] = 'Yes' + self.df_settings = pd.read_csv(settings_csv_path, index_col="setting") + if "showWelcomeGuide" not in self.df_settings.index: + self.df_settings.at["showWelcomeGuide", "value"] = "Yes" self.df_settings.to_csv(settings_csv_path) show = ( - self.df_settings.at['showWelcomeGuide', 'value'] == 'Yes' + self.df_settings.at["showWelcomeGuide", "value"] == "Yes" or self.sender() is not None ) if not show: @@ -374,7 +413,7 @@ def launchWelcomeGuide(self, checked=False): self.welcomeGuide.showPage(self.welcomeGuide.welcomeItem) def setColorsAndText(self): - self.moduleLaunchedColor = '#f1dd00' + self.moduleLaunchedColor = "#f1dd00" self.moduleLaunchedQColor = QColor(self.moduleLaunchedColor) defaultColor = self.guiButton.palette().button().color().name() self.defaultButtonPalette = self.guiButton.palette() @@ -384,12 +423,8 @@ def setColorsAndText(self): self.defaultTextDataPrepButton = self.dataPrepButton.text() self.defaultTextSegmButton = self.segmButton.text() self.moduleLaunchedPalette = self.guiButton.palette() - self.moduleLaunchedPalette.setColor( - QPalette.Button, self.moduleLaunchedQColor - ) - self.moduleLaunchedPalette.setColor( - QPalette.ButtonText, QColor(0, 0, 0) - ) + self.moduleLaunchedPalette.setColor(QPalette.Button, self.moduleLaunchedQColor) + self.moduleLaunchedPalette.setColor(QPalette.ButtonText, QColor(0, 0, 0)) def createMenuBar(self): menuBar = self.menuBar() @@ -397,12 +432,12 @@ def createMenuBar(self): self.recentPathsMenu = QMenu("&Recent paths", self) # On macOS an empty menu would not appear --> add dummy action - self.recentPathsMenu.addAction('dummy macos') + self.recentPathsMenu.addAction("dummy macos") menuBar.addMenu(self.recentPathsMenu) utilsMenu = menuBar.addMenu("&Utilities") - convertMenu = utilsMenu.addMenu('Convert file formats') + convertMenu = utilsMenu.addMenu("Convert file formats") convertMenu.addAction(self.npzToNpyAction) convertMenu.addAction(self.npzToTiffAction) convertMenu.addAction(self.TiffToNpzAction) @@ -411,35 +446,33 @@ def createMenuBar(self): convertMenu.addAction(self.fromImageJroiAction) convertMenu.addAction(self.toObjsCoordsAction) - segmMenu = utilsMenu.addMenu('Segmentation') + segmMenu = utilsMenu.addMenu("Segmentation") segmMenu.addAction(self.createConnected3Dsegm) segmMenu.addAction(self.stack2Dto3DsegmAction) segmMenu.addAction(self.filterObjsFromTableAction) segmMenu.addAction(self.fillHolesInSegmAction) - trackingMenu = utilsMenu.addMenu('Tracking and lineage') + trackingMenu = utilsMenu.addMenu("Tracking and lineage") trackingMenu.addAction(self.trackSubCellFeaturesAction) trackingMenu.addAction(self.applyTrackingFromTableAction) trackingMenu.addAction(self.applyTrackingFromTrackMateXMLAction) - trackingMenu.addAction(self.toSymDivAction) - + trackingMenu.addAction(self.toSymDivAction) + self.trackingMenu = trackingMenu - measurementsMenu = utilsMenu.addMenu('Measurements') + measurementsMenu = utilsMenu.addMenu("Measurements") measurementsMenu.addAction(self.calcMetricsAcdcDf) measurementsMenu.addAction(self.countObjectsInSegmAction) - measurementsMenu.addAction(self.combineMetricsMultiChannelAction) - measurementsMenu.addAction(self.generateMothBudTotTableAction) - - concatMenu = utilsMenu.addMenu('Concatenate') - concatMenu.addAction(self.concatAcdcDfsAction) + measurementsMenu.addAction(self.combineMetricsMultiChannelAction) + measurementsMenu.addAction(self.generateMothBudTotTableAction) + + concatMenu = utilsMenu.addMenu("Concatenate") + concatMenu.addAction(self.concatAcdcDfsAction) if SPOTMAX_INSTALLED: - concatMenu.addAction(self.concatSpotmaxDfsAction) + concatMenu.addAction(self.concatSpotmaxDfsAction) + + dataPrepMenu = utilsMenu.addMenu("Image and segmentation files preprocessing") - dataPrepMenu = utilsMenu.addMenu( - 'Image and segmentation files preprocessing' - ) - dataPrepMenu.addAction(self.batchConverterAction) dataPrepMenu.addAction(self.repeatDataPrepAction) dataPrepMenu.addAction(self.alignAction) @@ -447,17 +480,17 @@ def createMenuBar(self): dataPrepMenu.addAction(self.fucciPreprocessAction) dataPrepMenu.addAction(self.customPreprocessAction) dataPrepMenu.addAction(self.combineChannelsAction) - + utilsMenu.addAction(self.renameAction) self.utilsMenu = utilsMenu utilsMenu.addSeparator() - utilsHelpAction = utilsMenu.addAction('Help...') + utilsHelpAction = utilsMenu.addAction("Help...") utilsHelpAction.triggered.connect(self.showUtilsHelp) - + menuBar.addMenu(utilsMenu) - + self.settingsMenu = QMenu("&Settings", self) self.settingsMenu.addAction(self.changeUserProfileFolderPathAction) self.settingsMenu.addAction(self.openUserProfileFolderAction) @@ -485,12 +518,11 @@ def createMenuBar(self): if SPOTMAX_INSTALLED: helpMenu.addAction(self.updateSPOTMAXAction) - utilsMenu.addAction(self.debugAction) - self.debugAction.setVisible(parser_args['debug']) + self.debugAction.setVisible(parser_args["debug"]) menuBar.addMenu(helpMenu) - + def showUtilsHelp(self): treeInfo = {} for action in self.utilsMenu.actions(): @@ -502,34 +534,35 @@ def showUtilsHelp(self): ) else: treeInfo = self._addActionToTree(action, treeInfo) - + self.utilsHelpWin = apps.TreeSelectorDialog( - title='Utilities help', + title="Utilities help", infoTxt="Double click on a utility's name to get help about it
", - parent=self, multiSelection=False, widthFactor=2, heightFactor=1.5 + parent=self, + multiSelection=False, + widthFactor=2, + heightFactor=1.5, ) self.utilsHelpWin.addTree(treeInfo) self.utilsHelpWin.sigItemDoubleClicked.connect(self._showUtilHelp) self.utilsHelpWin.exec_() - + def resetUserProfileFolderPath(self): from . import user_profile_path, user_home_path - + if os.path.samefile(user_profile_path, user_home_path): msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'The user profile data is already in the default folder.' + "The user profile data is already in the default folder." ) - msg.warning(self, 'Reset user profile data', txt) + msg.warning(self, "Reset user profile data", txt) return - + acdc_folders = load.get_all_acdc_folders(user_profile_path) - acdc_folders_format = [ - f'   {folder}' for folder in acdc_folders - ] - acdc_folders_format = '
'.join(acdc_folders_format) - - txt = (f""" + acdc_folders_format = [f"   {folder}" for folder in acdc_folders] + acdc_folders_format = "
".join(acdc_folders_format) + + txt = f""" Current user profile path:

{user_profile_path}

The user profile contains the following Cell-ACDC folders:

@@ -537,86 +570,87 @@ def resetUserProfileFolderPath(self): After clicking "Ok" you Cell-ACDC will migrate the user profile data to the following folder:

{user_home_path}.
- """) - + """ + txt = html_utils.paragraph(txt) - + msg = widgets.myMessageBox(wrapText=False) msg.information( - self, 'Reset default user profile folder path', txt, - buttonsTexts=('Cancel', 'Ok') + self, + "Reset default user profile folder path", + txt, + buttonsTexts=("Cancel", "Ok"), ) if msg.cancel: - self.logger.info('Resetting user profile folder path cancelled.') + self.logger.info("Resetting user profile folder path cancelled.") return - - + new_user_profile_path = user_home_path - + self.startMigrateUserProfileWorker( user_profile_path, new_user_profile_path, acdc_folders ) - - def changeUserProfileFolderPath(self): + + def changeUserProfileFolderPath(self): acdc_folders = load.get_all_acdc_folders(user_profile_path) - acdc_folders_format = [ - f'   {folder}' for folder in acdc_folders - ] - acdc_folders_format = '
'.join(acdc_folders_format) - - txt = (f""" + acdc_folders_format = [f"   {folder}" for folder in acdc_folders] + acdc_folders_format = "
".join(acdc_folders_format) + + txt = f""" Current user profile path:

{user_profile_path}

The user profile contains the following Cell-ACDC folders:

{acdc_folders_format}

After clicking "Ok" you will be asked to select the folder where you want to migrate the user profile data.
- """) - + """ + txt = html_utils.paragraph(txt) - + msg = widgets.myMessageBox(wrapText=False) msg.information( - self, 'Change user profile folder path', txt, - buttonsTexts=('Cancel', 'Ok') + self, "Change user profile folder path", txt, buttonsTexts=("Cancel", "Ok") ) if msg.cancel: - self.logger.info('Changing user profile folder path cancelled.') + self.logger.info("Changing user profile folder path cancelled.") return from qtpy.compat import getexistingdirectory + new_user_profile_path = getexistingdirectory( parent=self, - caption='Select folder for user profile data', - basedir=user_profile_path + caption="Select folder for user profile data", + basedir=user_profile_path, ) if not new_user_profile_path: - self.logger.info('Changing user profile folder path cancelled.') + self.logger.info("Changing user profile folder path cancelled.") return - + if os.path.samefile(user_profile_path, new_user_profile_path): msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'The user profile data is already in the selected folder.' + "The user profile data is already in the selected folder." ) - msg.warning(self, 'Change user profile data folder', txt) + msg.warning(self, "Change user profile data folder", txt) return - + self.startMigrateUserProfileWorker( user_profile_path, new_user_profile_path, acdc_folders ) - + def startMigrateUserProfileWorker(self, src_path, dst_path, acdc_folders): self.progressWin = apps.QDialogWorkerProgress( - title='Migrate user profile data', parent=self, - pbarDesc='Migrating user profile data...', - showInnerPbar=True + title="Migrate user profile data", + parent=self, + pbarDesc="Migrating user profile data...", + showInnerPbar=True, ) self.progressWin.sigClosed.connect(self.progressWinClosed) self.progressWin.show(self.app) - + from . import workers - self.workerName = 'Migrating user profile data' + + self.workerName = "Migrating user profile data" self._thread = QtCore.QThread() self.migrateWorker = workers.MigrateUserProfileWorker( src_path, dst_path, acdc_folders @@ -625,27 +659,21 @@ def startMigrateUserProfileWorker(self, src_path, dst_path, acdc_folders): self.migrateWorker.finished.connect(self._thread.quit) self.migrateWorker.finished.connect(self.migrateWorker.deleteLater) self._thread.finished.connect(self._thread.deleteLater) - + self.migrateWorker.progress.connect(self.workerProgress) self.migrateWorker.critical.connect(self.workerCritical) self.migrateWorker.finished.connect(self.migrateWorkerFinished) - - self.migrateWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.migrateWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.migrateWorker.signals.sigInitInnerPbar.connect( - self.workerInitInnerPbar - ) + + self.migrateWorker.signals.initProgressBar.connect(self.workerInitProgressbar) + self.migrateWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.migrateWorker.signals.sigInitInnerPbar.connect(self.workerInitInnerPbar) self.migrateWorker.signals.sigUpdateInnerPbar.connect( self.workerUpdateInnerPbar ) - + self._thread.started.connect(self.migrateWorker.run) self._thread.start() - + def workerInitProgressbar(self, totalIter): self.progressWin.mainPbar.setValue(0) if totalIter == 1: @@ -654,16 +682,16 @@ def workerInitProgressbar(self, totalIter): def workerUpdateProgressbar(self, step): self.progressWin.mainPbar.update(step) - + def workerInitInnerPbar(self, totalIter): self.progressWin.innerPbar.setValue(0) if totalIter == 1: totalIter = 0 self.progressWin.innerPbar.setMaximum(totalIter) - + def workerUpdateInnerPbar(self, step): self.progressWin.innerPbar.update(step) - + def migrateWorkerFinished(self, worker): self.workerFinished() msg = widgets.myMessageBox(wrapText=False) @@ -671,27 +699,34 @@ def migrateWorkerFinished(self, worker): To make this change effective, please restart Cell-ACDC.

Thanks! """) - self.statusBarLayout.addWidget(QLabel(html_utils.paragraph( - 'Restart Cell-ACDC for the change to take effect', - font_color='red' - ))) - msg.information(self, 'Restart Cell-ACDC', txt) - + self.statusBarLayout.addWidget( + QLabel( + html_utils.paragraph( + "Restart Cell-ACDC for the change to take effect", + font_color="red", + ) + ) + ) + msg.information(self, "Restart Cell-ACDC", txt) + def _showUtilHelp(self, item): if item.parent() is None: return utilityName = item.text(0) infoText = html_utils.paragraph(utilsInfo[utilityName]) - runUtilityButton = widgets.playPushButton('Run utility...') + runUtilityButton = widgets.playPushButton("Run utility...") msg = widgets.myMessageBox(showCentered=False, wrapText=False) msg.information( - self.utilsHelpWin, f'"{utilityName}" help', infoText, - buttonsTexts=(runUtilityButton, 'Close'), showDialog=False + self.utilsHelpWin, + f'"{utilityName}" help', + infoText, + buttonsTexts=(runUtilityButton, "Close"), + showDialog=False, ) runUtilityButton.utilityName = utilityName runUtilityButton.clicked.connect(self._runUtility) msg.exec_() - + def _runUtility(self): self.utilsHelpWin.ok_cb() utilityName = self.sender().utilityName @@ -708,15 +743,15 @@ def _runUtility(self): else: action.trigger() break - + def _addActionToTree(self, action, treeInfo, parentMenu=None): if action.isSeparator(): return treeInfo - + text = action.text() if text not in utilsInfo: return treeInfo - + if parentMenu is None: treeInfo[text] = [] elif parentMenu.title() not in treeInfo: @@ -726,115 +761,109 @@ def _addActionToTree(self, action, treeInfo, parentMenu=None): return treeInfo def createActions(self): - self.changeUserProfileFolderPathAction = QAction( - 'Change user profile path...' - ) + self.changeUserProfileFolderPathAction = QAction("Change user profile path...") self.resetUserProfileFolderPathAction = QAction( - 'Reset default user profile path' + "Reset default user profile path" ) - self.npzToNpyAction = QAction('Convert .npz file(s) to .npy...') - self.npzToTiffAction = QAction('Convert .npz file(s) to .tif...') - self.TiffToNpzAction = QAction('Convert .tif file(s) to _segm.npz...') - self.h5ToNpzAction = QAction('Convert .h5 file(s) to _segm.npz...') + self.npzToNpyAction = QAction("Convert .npz file(s) to .npy...") + self.npzToTiffAction = QAction("Convert .npz file(s) to .tif...") + self.TiffToNpzAction = QAction("Convert .tif file(s) to _segm.npz...") + self.h5ToNpzAction = QAction("Convert .h5 file(s) to _segm.npz...") self.toImageJroiAction = QAction( - 'Convert Cell-ACDC segmentation file(s) (segm.npz) to ImageJ ROIs...' + "Convert Cell-ACDC segmentation file(s) (segm.npz) to ImageJ ROIs..." ) self.fromImageJroiAction = QAction( - 'Convert ImageJ ROIs to Cell-ACDC segmentation file(s) (segm.npz)...' + "Convert ImageJ ROIs to Cell-ACDC segmentation file(s) (segm.npz)..." ) self.toObjsCoordsAction = QAction( - 'Convert .npz segmentation file(s) to object coordinates (CSV)...' + "Convert .npz segmentation file(s) to object coordinates (CSV)..." ) - + self.fucciPreprocessAction = QAction( - 'Combine FUCCI channels and enhance nuclear signal...' - ) - + "Combine FUCCI channels and enhance nuclear signal..." + ) + self.customPreprocessAction = QAction( - 'Setup and run custom image preprocessing...' + "Setup and run custom image preprocessing..." ) self.combineChannelsAction = QAction( - 'Combine and manipulate channels and/or segmentation files...' + "Combine and manipulate channels and/or segmentation files..." ) - + self.countObjectsInSegmAction = QAction( - 'Count objects in segmentation mask and save to CSV file...' + "Count objects in segmentation mask and save to CSV file..." ) - + self.createConnected3Dsegm = QAction( - 'Create connected 3D segmentation mask from z-slices segmentation...' - ) - self.fillHolesInSegmAction = QAction( - 'Fill holes in segmentation masks...' + "Create connected 3D segmentation mask from z-slices segmentation..." ) + self.fillHolesInSegmAction = QAction("Fill holes in segmentation masks...") self.filterObjsFromTableAction = QAction( - 'Filter segmented objects using a table of coordinates (e.g., centroids)...' - ) + "Filter segmented objects using a table of coordinates (e.g., centroids)..." + ) self.stack2Dto3DsegmAction = QAction( - 'Stack 2D segmentation objects into 3D objects...' - ) + "Stack 2D segmentation objects into 3D objects..." + ) self.trackSubCellFeaturesAction = QAction( - 'Track and/or count sub-cellular objects (assign same ID as the ' - 'cell they belong to)...' - ) + "Track and/or count sub-cellular objects (assign same ID as the " + "cell they belong to)..." + ) self.applyTrackingFromTableAction = QAction( - 'Apply tracking info from tabular data...' + "Apply tracking info from tabular data..." ) self.applyTrackingFromTrackMateXMLAction = QAction( - 'Apply tracking info from TrackMate XML file...' + "Apply tracking info from TrackMate XML file..." ) self.batchConverterAction = QAction( - 'Create required data structure from image files...' + "Create required data structure from image files..." ) self.repeatDataPrepAction = QAction( - 'Re-apply data prep steps to selected channels...' + "Re-apply data prep steps to selected channels..." ) # self.TiffToHDFAction = QAction('Convert .tif file(s) to .h5py...') self.concatAcdcDfsAction = QAction( - 'Concatenate acdc output tables from multiple Positions and experiments...' + "Concatenate acdc output tables from multiple Positions and experiments..." ) if SPOTMAX_INSTALLED: self.concatSpotmaxDfsAction = QAction( - 'Concatenate spotMAX output tables from multiple Positions and experiments...' + "Concatenate spotMAX output tables from multiple Positions and experiments..." ) self.calcMetricsAcdcDf = QAction( - 'Compute measurements for one or more experiments...' + "Compute measurements for one or more experiments..." ) self.combineMetricsMultiChannelAction = QAction( - 'Combine measurements from multiple segmentation files...' + "Combine measurements from multiple segmentation files..." ) self.generateMothBudTotTableAction = QAction( - 'Generate mothers, buds, and total cell table...' + "Generate mothers, buds, and total cell table..." ) self.toSymDivAction = QAction( - 'Add lineage tree table to one or more experiments...' + "Add lineage tree table to one or more experiments..." ) - self.renameAction = QAction('Rename files by appending additional text...') - self.alignAction = QAction('Align or revert alignment...') + self.renameAction = QAction("Rename files by appending additional text...") + self.alignAction = QAction("Align or revert alignment...") - self.arboretumAction = QAction( - 'View lineage tree in napari-arboretum...' - ) + self.arboretumAction = QAction("View lineage tree in napari-arboretum...") self.resizeImagesAction = QAction( - 'Resize images (downscale or upscale) in one or more experiments...' - ) - self.welcomeGuideAction = QAction('Welcome Guide') - self.userManualAction = QAction('User documentation...') - self.aboutAction = QAction('About Cell-ACDC') - self.citeAction = QAction('Cite us...') - self.contributeAction = QAction('Contribute...') - self.showLogsAction = QAction('Show log files...') - self.openUserProfileFolderAction = QAction('Open user profile path...') - self.openSettingsFolderAction = QAction('Open settings folder...') - self.updateACDCAction = QAction('Update Cell-ACDC...') - self.updateSPOTMAXAction = QAction('Update SpotMAX...') - + "Resize images (downscale or upscale) in one or more experiments..." + ) + self.welcomeGuideAction = QAction("Welcome Guide") + self.userManualAction = QAction("User documentation...") + self.aboutAction = QAction("About Cell-ACDC") + self.citeAction = QAction("Cite us...") + self.contributeAction = QAction("Contribute...") + self.showLogsAction = QAction("Show log files...") + self.openUserProfileFolderAction = QAction("Open user profile path...") + self.openSettingsFolderAction = QAction("Open settings folder...") + self.updateACDCAction = QAction("Update Cell-ACDC...") + self.updateSPOTMAXAction = QAction("Update SpotMAX...") + if SPOTMAX_INSTALLED: - self.aboutSmaxAction = QAction('About SpotMAX') - - self.debugAction = QAction('Daje de mac') + self.aboutSmaxAction = QAction("About SpotMAX") + + self.debugAction = QAction("Daje de mac") def connectActions(self): self.changeUserProfileFolderPathAction.triggered.connect( @@ -846,39 +875,26 @@ def connectActions(self): self.alignAction.triggered.connect(self.launchAlignUtil) self.concatAcdcDfsAction.triggered.connect(self.launchConcatUtil) if SPOTMAX_INSTALLED: - self.concatSpotmaxDfsAction.triggered.connect( - self.launchConcatSpotmaxUtil - ) + self.concatSpotmaxDfsAction.triggered.connect(self.launchConcatSpotmaxUtil) self.npzToNpyAction.triggered.connect(self.launchConvertFormatUtil) self.npzToTiffAction.triggered.connect(self.launchConvertFormatUtil) self.TiffToNpzAction.triggered.connect(self.launchConvertFormatUtil) self.h5ToNpzAction.triggered.connect(self.launchConvertFormatUtil) - self.fromImageJroiAction.triggered.connect( - self.launchFromImageJroiToSegmUtil - ) + self.fromImageJroiAction.triggered.connect(self.launchFromImageJroiToSegmUtil) self.resizeImagesAction.triggered.connect(self.launchResizeUtil) self.toImageJroiAction.triggered.connect(self.launchToImageJroiUtil) - self.toObjsCoordsAction.triggered.connect( - self.launchToObjectsCoordsUtil - ) - - self.fucciPreprocessAction.triggered.connect( - self.launchFucciPreprocessUtil - ) - - self.customPreprocessAction.triggered.connect( - self.launchCustomPreprocessUtil - ) + self.toObjsCoordsAction.triggered.connect(self.launchToObjectsCoordsUtil) + + self.fucciPreprocessAction.triggered.connect(self.launchFucciPreprocessUtil) + + self.customPreprocessAction.triggered.connect(self.launchCustomPreprocessUtil) + + self.combineChannelsAction.triggered.connect(self.launchCombineChannelsUtil) - self.combineChannelsAction.triggered.connect( - self.launchCombineChannelsUtil - ) - - self.countObjectsInSegmAction.triggered.connect( self.launchCountObjectsInSegmActionUtil ) - + self.createConnected3Dsegm.triggered.connect( self.launchConnected3DsegmActionUtil ) @@ -888,9 +904,7 @@ def connectActions(self): self.stack2Dto3DsegmAction.triggered.connect( self.launchStack2Dto3DsegmActionUtil ) - self.fillHolesInSegmAction.triggered.connect( - self.launchFillHolesActionUtil - ) + self.fillHolesInSegmAction.triggered.connect(self.launchFillHolesActionUtil) self.trackSubCellFeaturesAction.triggered.connect( self.launchTrackSubCellFeaturesUtil ) @@ -899,14 +913,10 @@ def connectActions(self): ) self.generateMothBudTotTableAction.triggered.connect( self.launchGenerateMothBudTotTableUtil - ) - - self.batchConverterAction.triggered.connect( - self.launchImageBatchConverter - ) - self.repeatDataPrepAction.triggered.connect( - self.launchRepeatDataPrep - ) + ) + + self.batchConverterAction.triggered.connect(self.launchImageBatchConverter) + self.repeatDataPrepAction.triggered.connect(self.launchRepeatDataPrep) self.welcomeGuideAction.triggered.connect(self.launchWelcomeGuide) self.toSymDivAction.triggered.connect(self.launchToSymDicUtil) self.calcMetricsAcdcDf.triggered.connect(self.launchCalcMetricsUtil) @@ -922,9 +932,7 @@ def connectActions(self): ) self.recentPathsMenu.aboutToShow.connect(self.populateOpenRecent) self.showLogsAction.triggered.connect(self.showLogFiles) - self.openUserProfileFolderAction.triggered.connect( - self.openUserProfileFolder - ) + self.openUserProfileFolderAction.triggered.connect(self.openUserProfileFolder) self.openSettingsFolderAction.triggered.connect(self.openSettingsFolder) self.updateACDCAction.triggered.connect(self.launchUpdateACDC) if SPOTMAX_INSTALLED: @@ -935,35 +943,40 @@ def connectActions(self): self.applyTrackingFromTrackMateXMLAction.triggered.connect( self.launchApplyTrackingFromTrackMateXML ) - + self.debugAction.triggered.connect(self._debug) - + def openSettingsFolder(self): from . import settings_folderpath + myutils.showInExplorer(settings_folderpath) - + def openUserProfileFolder(self): from . import user_profile_path + myutils.showInExplorer(user_profile_path) - + def showLogFiles(self): logs_path = myutils.get_logs_path() myutils.showInExplorer(logs_path) - + def launchUpdateSpotmax(self): - res = myutils.update_package(self, 'spotmax',) + res = myutils.update_package( + self, + "spotmax", + ) if res: - self.showUpdateInfo('spotMAX') + self.showUpdateInfo("spotMAX") else: - self.showNoUpdateInfo('spotMAX') + self.showNoUpdateInfo("spotMAX") def launchUpdateACDC(self): - res = myutils.update_package(self, 'cellacdc') + res = myutils.update_package(self, "cellacdc") if res: - self.showUpdateInfo('Cell-ACDC') + self.showUpdateInfo("Cell-ACDC") else: - self.showNoUpdateInfo('Cell-ACDC') - + self.showNoUpdateInfo("Cell-ACDC") + def showNoUpdateInfo(self, package_name): msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" @@ -971,7 +984,7 @@ def showNoUpdateInfo(self, package_name): It is recommended to install git for a better update experience.
Download Git """) - msg.information(self, f'No update for {package_name} performed', txt) + msg.information(self, f"No update for {package_name} performed", txt) def showUpdateInfo(self, package_name): msg = widgets.myMessageBox() @@ -979,18 +992,18 @@ def showUpdateInfo(self, package_name): {package_name} has been updated.
Please restart the application for the changes to take effect. """) - msg.information(self, f'Update {package_name}', txt) + msg.information(self, f"Update {package_name}", txt) def populateOpenRecent(self): # Step 0. Remove the old options from the menu self.recentPathsMenu.clear() # Step 1. Read recent Paths - recentPaths_path = os.path.join(settings_folderpath, 'recentPaths.csv') + recentPaths_path = os.path.join(settings_folderpath, "recentPaths.csv") if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - if 'opened_last_on' in df.columns: - df = df.sort_values('opened_last_on', ascending=False) - recentPaths = df['path'].to_list() + df = pd.read_csv(recentPaths_path, index_col="index") + if "opened_last_on" in df.columns: + df = df.sort_values("opened_last_on", ascending=False) + recentPaths = df["path"].to_list() else: recentPaths = [] # Step 2. Dynamically create the actions @@ -1011,12 +1024,13 @@ def showContribute(self): def showAbout(self): self.aboutWin = about.QDialogAbout(parent=self) self.aboutWin.show() - + def showAboutSmax(self): from spotmax.dialogs import AboutSpotMAXDialog + win = AboutSpotMAXDialog(parent=self) win.exec_() - + def getSelectedPosPath(self, utilityName): msg = widgets.myMessageBox() txt = html_utils.paragraph(""" @@ -1024,27 +1038,23 @@ def getSelectedPosPath(self, utilityName): to select one position folder that contains timelapse data. """) - msg.information( - self, f'{utilityName}', txt, - buttonsTexts=('Cancel', 'Ok') - ) + msg.information(self, f"{utilityName}", txt, buttonsTexts=("Cancel", "Ok")) if msg.cancel: - print(f'{utilityName} aborted by the user.') + print(f"{utilityName} aborted by the user.") return - + mostRecentPath = myutils.getMostRecentPath() exp_path = QFileDialog.getExistingDirectory( - self, 'Select Position_n folder', - mostRecentPath + self, "Select Position_n folder", mostRecentPath ) if not exp_path: - print(f'{utilityName} aborted by the user.') + print(f"{utilityName} aborted by the user.") return - + myutils.addToRecentPaths(exp_path) baseFolder = os.path.basename(exp_path) - isPosFolder = re.search(r'Position_(\d+)$', baseFolder) is not None - isImagesFolder = baseFolder == 'Images' + isPosFolder = re.search(r"Position_(\d+)$", baseFolder) is not None + isImagesFolder = baseFolder == "Images" if isImagesFolder: posPath = os.path.dirname(exp_path) posFolders = [os.path.basename(posPath)] @@ -1057,45 +1067,41 @@ def getSelectedPosPath(self, utilityName): posFolders = myutils.get_pos_foldernames(exp_path) if not posFolders: msg = widgets.myMessageBox() - msg.addShowInFileManagerButton( - exp_path, txt='Show selected folder...' - ) + msg.addShowInFileManagerButton(exp_path, txt="Show selected folder...") _ls = "\n".join(os.listdir(exp_path)) - msg.setDetailedText(f'Files present in the folder:\n{_ls}') + msg.setDetailedText(f"Files present in the folder:\n{_ls}") txt = html_utils.paragraph(f""" The selected folder:

{exp_path}

does not contain any valid Position folders.
""") msg.warning( - self, 'Not valid folder', txt, - buttonsTexts=('Cancel', 'Try again') + self, "Not valid folder", txt, buttonsTexts=("Cancel", "Try again") ) if msg.cancel: - print(f'{utilityName} aborted by the user.') + print(f"{utilityName} aborted by the user.") return if len(posFolders) > 1: win = apps.QDialogCombobox( - 'Select position folder', posFolders, 'Select position folder', - 'Positions: ', parent=self + "Select position folder", + posFolders, + "Select position folder", + "Positions: ", + parent=self, ) win.exec_() posPath = os.path.join(exp_path, win.selectedItemText) else: posPath = os.path.join(exp_path, posFolders[0]) - + return posPath - def getSelectedExpPaths( - self, utilityName, - exp_folderpath=None, - custom_txt=None - ): + def getSelectedExpPaths(self, utilityName, exp_folderpath=None, custom_txt=None): # self._debug() - + if exp_folderpath is None: - self.logger.info('Asking to select experiment folders...') + self.logger.info("Asking to select experiment folders...") msg = widgets.myMessageBox() if custom_txt: txt = html_utils.paragraph(custom_txt) @@ -1106,54 +1112,51 @@ def getSelectedExpPaths( Next, you will be able to choose specific Positions from each selected experiment. """) - msg.information( - self, f'{utilityName}', txt, - buttonsTexts=('Cancel', 'Ok') - ) + msg.information(self, f"{utilityName}", txt, buttonsTexts=("Cancel", "Ok")) if msg.cancel: - self.logger.info(f'{utilityName} aborted by the user.') + self.logger.info(f"{utilityName} aborted by the user.") return - + expPaths = {} mostRecentPath = myutils.getMostRecentPath() warn_exp_already_selected = True while True: if exp_folderpath is None: exp_path = qtpy.compat.getexistingdirectory( - parent=self, - caption='Select experiment folder containing Position_n folders', + parent=self, + caption="Select experiment folder containing Position_n folders", basedir=mostRecentPath, # options=QFileDialog.DontUseNativeDialog ) if not exp_path: break myutils.addToRecentPaths(exp_path) - else: + else: exp_path = exp_folderpath selected_path = exp_path baseFolder = os.path.basename(exp_path) isPosFolder = myutils.is_pos_folderpath(exp_path) - isImagesFolder = baseFolder == 'Images' + isImagesFolder = baseFolder == "Images" if isImagesFolder: posPath = os.path.dirname(exp_path) posFolders = [os.path.basename(posPath)] exp_path = os.path.dirname(posPath) - selected_exp_paths = {exp_path:posFolders} + selected_exp_paths = {exp_path: posFolders} elif isPosFolder: posPath = exp_path posFolders = [os.path.basename(posPath)] exp_path = os.path.dirname(exp_path) - selected_exp_paths = {exp_path:posFolders} + selected_exp_paths = {exp_path: posFolders} else: self.logger.info(f'Scanning selected folder "{exp_path}"...') selected_exp_paths = path.get_posfolderpaths_walk(exp_path) if not selected_exp_paths: cancel = self.warnNoValidExpPaths(exp_path) if cancel: - self.logger.info(f'{utilityName} aborted by the user.') + self.logger.info(f"{utilityName} aborted by the user.") return continue - + is_multi_pos = False for exp_path, pos_folders in selected_exp_paths.items(): if exp_path in expPaths: @@ -1162,65 +1165,64 @@ def getSelectedExpPaths( selected_path, exp_path ) if not proceed: - self.logger.info(f'{utilityName} aborted by the user.') + self.logger.info(f"{utilityName} aborted by the user.") return warn_exp_already_selected = False expPaths[exp_path].extend(pos_folders) else: expPaths[exp_path] = pos_folders - + if len(pos_folders) > 1 and not is_multi_pos: is_multi_pos = True - + mostRecentPath = exp_path msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(""" Do you want to select additional experiment folders? """) noButton, yesButton = msg.question( - self, 'Select additional experiments?', txt, - buttonsTexts=('No', 'Yes') + self, "Select additional experiments?", txt, buttonsTexts=("No", "Yes") ) if msg.clickedButton == noButton: break - + if not expPaths: - self.logger.info(f'{utilityName} aborted by the user.') + self.logger.info(f"{utilityName} aborted by the user.") return if len(expPaths) > 1 or is_multi_pos: infoPaths = self.getInfoPosStatus(expPaths, utilityName) selectPosWin = apps.selectPositionsMultiExp( - expPaths, - infoPaths=infoPaths, - parent=self + expPaths, infoPaths=infoPaths, parent=self ) selectPosWin.exec_() if selectPosWin.cancel: - self.logger.info(f'{utilityName} aborted by the user.') + self.logger.info(f"{utilityName} aborted by the user.") return selectedExpPaths = selectPosWin.selectedPaths else: selectedExpPaths = expPaths - + return selectedExpPaths - + def warnNoValidExpPaths(self, selected_path): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(""" The selected folder does not contain any valid experiment folders. """) - command = selected_path.replace('\\', os.sep) - command = selected_path.replace('/', os.sep) + command = selected_path.replace("\\", os.sep) + command = selected_path.replace("/", os.sep) msg.warning( - self, 'No valid folders found', txt, - buttonsTexts=('Cancel', 'Try again'), - commands=(command,), - path_to_browse=selected_path + self, + "No valid folders found", + txt, + buttonsTexts=("Cancel", "Try again"), + commands=(command,), + path_to_browse=selected_path, ) return msg.cancel - + def warnExpPathAlreadySelected(self, selected_path, exp_path): selected_text = myutils.to_relative_path(selected_path) exp_text = myutils.to_relative_path(exp_path) @@ -1238,27 +1240,28 @@ def warnExpPathAlreadySelected(self, selected_path, exp_path): """) msg = widgets.myMessageBox(wrapText=False) msg.warning( - self, 'Folder already selected!', txt, - buttonsTexts=('Cancel', 'Yes'), - path_to_browse=selected_path + self, + "Folder already selected!", + txt, + buttonsTexts=("Cancel", "Yes"), + path_to_browse=selected_path, ) return not msg.cancel - + def _debug(self): try: from . import _q_debug + _q_debug.q_debug(self) except Exception as err: raise err - + def askRestartAcdc(self): - txt = html_utils.paragraph( - 'Are you sure you want to restart Cell-ACDC?
' - ) + txt = html_utils.paragraph("Are you sure you want to restart Cell-ACDC?
") msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Restart?', txt, buttonsTexts=('Cancel', 'Yes')) + msg.warning(self, "Restart?", txt, buttonsTexts=("Cancel", "Yes")) return msg.cancel - + def keyPressEvent(self, event): modifiers = QGuiApplication.keyboardModifiers() ctrl_shift = modifiers == Qt.ControlModifier | Qt.ShiftModifier @@ -1270,190 +1273,202 @@ def keyPressEvent(self, event): self.close() return return super().keyPressEvent(event) - + def launchApplyTrackingFromTrackMateXML(self): - posPath = self.getSelectedPosPath('Apply tracking info from tabular data') + posPath = self.getSelectedPosPath("Apply tracking info from tabular data") if posPath is None: return - - title = 'Apply tracking info from TrackMate XML file utility' - infoText = 'Launching apply tracking info from from TrackMate XML data...' + + title = "Apply tracking info from TrackMate XML file utility" + infoText = "Launching apply tracking info from from TrackMate XML data..." self.applyTrackMateXMLWin = ( utilsApplyTrackFromTrackMate.ApplyTrackingInfoFromTrackMateUtil( - self.app, title, infoText, parent=self, - callbackOnFinished=self.applyTrackingFromTackmateXMLFinished + self.app, + title, + infoText, + parent=self, + callbackOnFinished=self.applyTrackingFromTackmateXMLFinished, ) ) self.applyTrackMateXMLWin.show() func = partial( - self._runApplyTrackingFromTrackMateXML, posPath, - self.applyTrackMateXMLWin + self._runApplyTrackingFromTrackMateXML, posPath, self.applyTrackMateXMLWin ) QTimer.singleShot(200, func) - + def _runApplyTrackingFromTrackMateXML(self, posPath, win): success = win.run(posPath) if not success: self.logger.info( - 'Apply tracking info from TrackMate XML cancelled by the user.' + "Apply tracking info from TrackMate XML cancelled by the user." ) - win.close() - + win.close() + def launchApplyTrackingFromTableUtil(self): - posPath = self.getSelectedPosPath('Apply tracking info from tabular data') + posPath = self.getSelectedPosPath("Apply tracking info from tabular data") if posPath is None: return - - title = 'Apply tracking info from tabular data utility' - infoText = 'Launching apply tracking info from tabular data...' - self.applyTrackWin = ( - utilsApplyTrackFromTab.ApplyTrackingInfoFromTableUtil( - self.app, title, infoText, parent=self, - callbackOnFinished=self.applyTrackingFromTableFinished - ) + + title = "Apply tracking info from tabular data utility" + infoText = "Launching apply tracking info from tabular data..." + self.applyTrackWin = utilsApplyTrackFromTab.ApplyTrackingInfoFromTableUtil( + self.app, + title, + infoText, + parent=self, + callbackOnFinished=self.applyTrackingFromTableFinished, ) self.applyTrackWin.show() - func = partial( - self._runApplyTrackingFromTableUtil, posPath, self.applyTrackWin - ) + func = partial(self._runApplyTrackingFromTableUtil, posPath, self.applyTrackWin) QTimer.singleShot(200, func) def _runApplyTrackingFromTableUtil(self, posPath, win): success = win.run(posPath) if not success: self.logger.info( - 'Apply tracking info from tabular data cancelled by the user.' + "Apply tracking info from tabular data cancelled by the user." ) - win.close() - + win.close() + def applyTrackingFromTackmateXMLFinished(self): msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph( - 'Apply tracking info from TrackMate XML data completed.' + "Apply tracking info from TrackMate XML data completed." ) - msg.information(self, 'Process completed', txt) - self.logger.info('Apply tracking info from TrackMate XML data completed.') + msg.information(self, "Process completed", txt) + self.logger.info("Apply tracking info from TrackMate XML data completed.") self.applyTrackMateXMLWin.close() - + def applyTrackingFromTableFinished(self): msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = html_utils.paragraph( - 'Apply tracking info from tabular data completed.' - ) - msg.information(self, 'Process completed', txt) - self.logger.info('Apply tracking info from tabular data completed.') + txt = html_utils.paragraph("Apply tracking info from tabular data completed.") + msg.information(self, "Process completed", txt) + self.logger.info("Apply tracking info from tabular data completed.") self.applyTrackWin.close() - + def launchNapariUtil(self, action): - myutils.check_install_package('napari', parent=self) + myutils.check_install_package("napari", parent=self) if action == self.arboretumAction: self._launchArboretum() def _launchArboretum(self): - myutils.check_install_package('napari_arboretum', parent=self) + myutils.check_install_package("napari_arboretum", parent=self) from cellacdc.napari_utils import arboretum - - posPath = self.getSelectedPosPath('napari-arboretum') + + posPath = self.getSelectedPosPath("napari-arboretum") if posPath is None: return - title = 'napari-arboretum utility' - infoText = 'Launching napari-arboretum to visualize lineage tree...' + title = "napari-arboretum utility" + infoText = "Launching napari-arboretum to visualize lineage tree..." self.arboretumWindow = arboretum.NapariArboretumDialog( posPath, self.app, title, infoText, parent=self ) self.arboretumWindow.show() - + def launchToObjectsCoordsUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') selectedExpPaths = self.getSelectedExpPaths( - 'From _segm.npz to objects coordinates (CSV)' + "From _segm.npz to objects coordinates (CSV)" ) if selectedExpPaths is None: return - - title = 'Convert _segm.npz file(s) to objects coordinates (CSV)' - infoText = 'Launching to to objects coordinates process...' + + title = "Convert _segm.npz file(s) to objects coordinates (CSV)" + infoText = "Launching to to objects coordinates process..." progressDialogueTitle = ( - 'Converting _segm.npz file(s) to to objects coordinates (CSV)' + "Converting _segm.npz file(s) to to objects coordinates (CSV)" ) self.toObjCoordsWin = utilsToObjCoords.toObjCoordsUtil( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.toObjCoordsWin.show() - + def launchFromImageJroiToSegmUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') - myutils.check_install_package('roifile', parent=self) + myutils.check_install_package("roifile", parent=self) import roifile - selectedExpPaths = self.getSelectedExpPaths( - 'From ImageJ ROIs to _segm.npz' - ) + selectedExpPaths = self.getSelectedExpPaths("From ImageJ ROIs to _segm.npz") if selectedExpPaths is None: return - - title = 'Convert ImageJ ROIs to _segm.npz file(s)' - infoText = 'Launching ImageJ ROIs conversion process...' - progressDialogueTitle = 'Converting ImageJ ROIs to _segm.npz file(s)' + + title = "Convert ImageJ ROIs to _segm.npz file(s)" + infoText = "Launching ImageJ ROIs conversion process..." + progressDialogueTitle = "Converting ImageJ ROIs to _segm.npz file(s)" self.toImageJroiWin = utilsFromImageJroi.fromImageJRoiToSegmUtil( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) - self.toImageJroiWin.show() - + self.toImageJroiWin.show() + def launchResizeUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') - - selectedExpPaths = self.getSelectedExpPaths( - 'From _segm.npz to ImageJ ROIs' - ) + + selectedExpPaths = self.getSelectedExpPaths("From _segm.npz to ImageJ ROIs") if selectedExpPaths is None: return - - title = 'Resize images' - infoText = 'Launching resizing images process...' - progressDialogueTitle = 'Resize images' + + title = "Resize images" + infoText = "Launching resizing images process..." + progressDialogueTitle = "Resize images" self.resizeUtilWin = utilsResizePositionsUtil.ResizePositionsUtil( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.resizeUtilWin.show() - + def launchToImageJroiUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') - myutils.check_install_package('roifile', parent=self) + myutils.check_install_package("roifile", parent=self) import roifile - selectedExpPaths = self.getSelectedExpPaths( - 'From _segm.npz to ImageJ ROIs' - ) + selectedExpPaths = self.getSelectedExpPaths("From _segm.npz to ImageJ ROIs") if selectedExpPaths is None: return - - title = 'Convert _segm.npz file(s) to ImageJ ROIs' - infoText = 'Launching to ImageJ ROIs process...' - progressDialogueTitle = 'Converting _segm.npz file(s) to ImageJ ROIs' + + title = "Convert _segm.npz file(s) to ImageJ ROIs" + infoText = "Launching to ImageJ ROIs process..." + progressDialogueTitle = "Converting _segm.npz file(s) to ImageJ ROIs" self.toImageJroiWin = utilsToImageJroi.toImageRoiUtil( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.toImageJroiWin.show() - + def launchGenerateMothBudTotTableUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') - - title = 'Generate mothers, buds, and total cell table' - infoText = 'Launching generate mothers, buds, and total cell table...' + + title = "Generate mothers, buds, and total cell table" + infoText = "Launching generate mothers, buds, and total cell table..." self.genMothBudTotalTableWin = ( utilsGenerateMothBudTotTable.GenerateMothBudTotalUtil( - self.app, title, infoText, parent=self, - callbackOnFinished=self.generateMothBudTotTableFinished + self.app, + title, + infoText, + parent=self, + callbackOnFinished=self.generateMothBudTotTableFinished, ) ) self.genMothBudTotalTableWin.show() @@ -1461,74 +1476,82 @@ def launchGenerateMothBudTotTableUtil(self): self._runGenerateMothBudTotTableUtil, self.genMothBudTotalTableWin ) QTimer.singleShot(200, func) - + def _runGenerateMothBudTotTableUtil(self, win): success = win.run() if not success: self.logger.info( - 'Generating mothers, buds, and total cell table cancelled by the user.' + "Generating mothers, buds, and total cell table cancelled by the user." ) - win.close() - + win.close() + def generateMothBudTotTableFinished(self): msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph( - 'Generating mothers, buds, and total cell table completed.' - ) - msg.information(self, 'Process completed', txt) - self.logger.info( - 'Generating mothers, buds, and total cell table completed.' + "Generating mothers, buds, and total cell table completed." ) + msg.information(self, "Process completed", txt) + self.logger.info("Generating mothers, buds, and total cell table completed.") self.genMothBudTotalTableWin.close() - + def launchCombineMeatricsMultiChanneliUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') selectedExpPaths = self.getSelectedExpPaths( - 'Combine measurements from multiple channels' + "Combine measurements from multiple channels" ) if selectedExpPaths is None: return - - title = 'Compute measurements from multiple channels' - infoText = 'Launching compute measurements from multiple channels process...' - progressDialogueTitle = 'Compute measurements from multiple channels' + + title = "Compute measurements from multiple channels" + infoText = "Launching compute measurements from multiple channels process..." + progressDialogueTitle = "Compute measurements from multiple channels" self.multiChannelWin = utilsComputeMultiCh.ComputeMetricsMultiChannel( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.multiChannelWin.show() - + def launchFucciPreprocessUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') - selectedExpPaths = self.getSelectedExpPaths( - 'Combine FUCCI channels' - ) + selectedExpPaths = self.getSelectedExpPaths("Combine FUCCI channels") if selectedExpPaths is None: return - - title = 'Combine FUCCI channels' - infoText = 'Launching Combine FUCCI channels process...' - progressDialogueTitle = 'Combining FUCCI channels' + + title = "Combine FUCCI channels" + infoText = "Launching Combine FUCCI channels process..." + progressDialogueTitle = "Combining FUCCI channels" self.fucciPreprocessWin = utilsFucciPreprocess.FucciPreprocessUtil( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.fucciPreprocessWin.show() - + def launchCustomPreprocessUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') selectedExpPaths = self.getSelectedExpPaths( - 'Pre-process images with custom recipe' + "Pre-process images with custom recipe" ) if selectedExpPaths is None: return - - title = 'Pre-process images with custom recipe' - infoText = 'Launching Pre-process images with custom recipe process...' - progressDialogueTitle = 'Pre-process images' + + title = "Pre-process images with custom recipe" + infoText = "Launching Pre-process images with custom recipe process..." + progressDialogueTitle = "Pre-process images" self.customPreprocessWin = utilsCustomPreprocess.CustomPreprocessUtil( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.customPreprocessWin.show() @@ -1542,147 +1565,175 @@ def launchCombineChannelsUtil(self): recepies will be applied to all of them. """ selectedExpPaths = self.getSelectedExpPaths( - 'Combine and manipulate channels and/or segmentation files', - custom_txt=custom_txt + "Combine and manipulate channels and/or segmentation files", + custom_txt=custom_txt, ) if selectedExpPaths is None: return - - title = 'Combine and manipulate channels and/or segmentation files' - infoText = 'Launching combine and manipulate channels utility...' - progressDialogueTitle = 'Combine and manipulate channels and/or segmentation files' + + title = "Combine and manipulate channels and/or segmentation files" + infoText = "Launching combine and manipulate channels utility..." + progressDialogueTitle = ( + "Combine and manipulate channels and/or segmentation files" + ) self.CombineChannelsWin = utilsCombineChannels.CombineChannelsUtil( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.CombineChannelsWin.show() - + def launchConnected3DsegmActionUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') selectedExpPaths = self.getSelectedExpPaths( - 'Create connected 3D segmentation mask' + "Create connected 3D segmentation mask" ) if selectedExpPaths is None: return - - title = 'Create connected 3D segmentation mask' - infoText = 'Launching connected 3D segmentation mask creation process...' - progressDialogueTitle = 'Creating connected 3D segmentation mask' + + title = "Create connected 3D segmentation mask" + infoText = "Launching connected 3D segmentation mask creation process..." + progressDialogueTitle = "Creating connected 3D segmentation mask" self.connected3DsegmWin = utilsConnected3Dsegm.CreateConnected3Dsegm( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.connected3DsegmWin.show() - + def launchCountObjectsInSegmActionUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') selectedExpPaths = self.getSelectedExpPaths( - 'Create connected 3D segmentation mask' + "Create connected 3D segmentation mask" ) if selectedExpPaths is None: return - - title = 'Count objects in segmentation mask' - infoText = 'Launching count objects in segmentation masks process...' - progressDialogueTitle = 'Counting objects in segmentation mask' + + title = "Count objects in segmentation mask" + infoText = "Launching count objects in segmentation masks process..." + progressDialogueTitle = "Counting objects in segmentation mask" self.connected3DsegmWin = utilsCountObjectsInSegm.CountObjectsInsegm( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.connected3DsegmWin.show() - + def launchFillHolesActionUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') - selectedExpPaths = self.getSelectedExpPaths( - 'Fill holes in segmentation masks' - ) + selectedExpPaths = self.getSelectedExpPaths("Fill holes in segmentation masks") if selectedExpPaths is None: return - title = 'Fill holes in segmentation masks' - infoText = 'Launching fill holes in segmentation masks process...' - progressDialogueTitle = 'Filling holes in segmentation masks' + title = "Fill holes in segmentation masks" + infoText = "Launching fill holes in segmentation masks process..." + progressDialogueTitle = "Filling holes in segmentation masks" self.fillHolesWin = fillHolesInSegm.fillHolesInSegm( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.fillHolesWin.show() def launchFilterObjsFromTableActionUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') selectedExpPaths = self.getSelectedExpPaths( - 'Create connected 3D segmentation mask' + "Create connected 3D segmentation mask" ) if selectedExpPaths is None: return - - title = 'Filter segmented objects from coordinates' - infoText = 'Launching Filter segmented objects from coordinates process...' - progressDialogueTitle = 'Filtering objects' + + title = "Filter segmented objects from coordinates" + infoText = "Launching Filter segmented objects from coordinates process..." + progressDialogueTitle = "Filtering objects" self.filterObjsFromTableWin = ( - utilsFilterObjsFromTable.FilterObjsFromCoordsTable( - selectedExpPaths, self.app, title, infoText, - progressDialogueTitle, parent=self + utilsFilterObjsFromTable.FilterObjsFromCoordsTable( + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) ) self.filterObjsFromTableWin.show() - + def launchStack2Dto3DsegmActionUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') selectedExpPaths = self.getSelectedExpPaths( - 'Create 3D segmentation mask from 2D' + "Create 3D segmentation mask from 2D" ) if selectedExpPaths is None: return - + SizeZwin = apps.NumericEntryDialog( - title='Number of z-slices', - instructions='Enter number of z-slices required', - currentValue=1, parent=self, - stretch=True + title="Number of z-slices", + instructions="Enter number of z-slices required", + currentValue=1, + parent=self, + stretch=True, ) SizeZwin.exec_() if SizeZwin.cancel: return - - title = 'Create stacked 3D segmentation mask' - infoText = 'Launching stacked 3D segmentation mask creation process...' - progressDialogueTitle = 'Creating stacked 3D segmentation mask' + + title = "Create stacked 3D segmentation mask" + infoText = "Launching stacked 3D segmentation mask creation process..." + progressDialogueTitle = "Creating stacked 3D segmentation mask" self.stack2DsegmWin = utilsStack2Dto3D.Stack2DsegmTo3Dsegm( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - SizeZwin.value, parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + SizeZwin.value, + parent=self, ) self.stack2DsegmWin.show() def launchTrackSubCellFeaturesUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') - selectedExpPaths = self.getSelectedExpPaths( - 'Track sub-cellular objects' - ) + selectedExpPaths = self.getSelectedExpPaths("Track sub-cellular objects") if selectedExpPaths is None: return - + win = apps.TrackSubCellObjectsDialog() win.exec_() if win.cancel: return - - title = 'Track sub-cellular objects' - infoText = 'Launching sub-cellular objects tracker...' - progressDialogueTitle = 'Tracking sub-cellular objects' + + title = "Track sub-cellular objects" + infoText = "Launching sub-cellular objects tracker..." + progressDialogueTitle = "Tracking sub-cellular objects" self.trackSubCellObjWin = utilsTrackSubCell.TrackSubCellFeatures( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - win.trackSubCellObjParams, parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + win.trackSubCellObjParams, + parent=self, ) self.trackSubCellObjWin.show() - def launchCalcMetricsUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') - selectedExpPaths = self.getSelectedExpPaths('Compute measurements utility') + selectedExpPaths = self.getSelectedExpPaths("Compute measurements utility") if selectedExpPaths is None: return - + self._lauchCalcMetricsUtil(selectedExpPaths) def _lauchCalcMetricsUtil(self, selectedExpPaths): @@ -1690,10 +1741,10 @@ def _lauchCalcMetricsUtil(self, selectedExpPaths): selectedExpPaths, self.app, parent=self ) self.calcMeasWin.show() - + def launchToSymDicUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') - selectedExpPaths = self.getSelectedExpPaths('Lineage tree utility') + selectedExpPaths = self.getSelectedExpPaths("Lineage tree utility") if selectedExpPaths is None: return @@ -1701,12 +1752,12 @@ def launchToSymDicUtil(self): selectedExpPaths, self.app, parent=self ) self.toSymDivWin.show() - + def getInfoPosStatus(self, expPaths, utilityName): - if 'spotmax' in utilityName.lower(): - caller = 'SpotMAX' + if "spotmax" in utilityName.lower(): + caller = "SpotMAX" else: - caller = 'Cell-ACDC' + caller = "Cell-ACDC" infoPaths = {} for exp_path, posFoldernames in expPaths.items(): posFoldersInfo = {} @@ -1722,9 +1773,7 @@ def launchRenameUtil(self): if isUtilnabled: self.sender().setDisabled(True) self.renameWin = utilsRename.renameFilesWin( - parent=self, - actionToEnable=self.sender(), - mainWin=self + parent=self, actionToEnable=self.sender(), mainWin=self ) self.renameWin.show() self.renameWin.main() @@ -1735,7 +1784,7 @@ def launchRenameUtil(self): def launchConvertFormatUtil(self, checked=False): s = self.sender().text() - m = re.findall(r'Convert \.(\w+) file\(s\) to (.*)\.(\w+)...', s) + m = re.findall(r"Convert \.(\w+) file\(s\) to (.*)\.(\w+)...", s) from_, info, to = m[0] isConvertEnabled = self.sender().isEnabled() if isConvertEnabled: @@ -1743,8 +1792,10 @@ def launchConvertFormatUtil(self, checked=False): self.convertWin = utilsConvert.convertFileFormatWin( parent=self, actionToEnable=self.sender(), - mainWin=self, from_=from_, to=to, - info=info + mainWin=self, + from_=from_, + to=to, + info=info, ) self.convertWin.show() self.convertWin.main() @@ -1752,50 +1803,49 @@ def launchConvertFormatUtil(self, checked=False): geometry = self.convertWin.saveGeometry() self.convertWin.setWindowState(Qt.WindowActive) self.convertWin.restoreGeometry(geometry) - + def launchImageBatchConverter(self): self.batchConverterWin = utilsConvert.ImagesToPositions(parent=self) self.batchConverterWin.show() - + def launchRepeatDataPrep(self): self.batchConverterWin = utilsRepeat.repeatDataPrepWindow(parent=self) self.batchConverterWin.show() def launchDataStruct(self, checked=False): self.dataStructButton.setPalette(self.moduleLaunchedPalette) - self.dataStructButton.setText( - '0. Creating data structure running...' - ) + self.dataStructButton.setText("0. Creating data structure running...") QTimer.singleShot(100, self._showDataStructWin) def _showDataStructWin(self): msg = widgets.myMessageBox(wrapText=False, showCentered=False) - bioformats_url = 'https://www.openmicroscopy.org/bio-formats/' - bioformats_href = html_utils.href_tag( - 'Bio-Formats', bioformats_url - ) - - bioio_url = 'https://bioio-devs.github.io/bioio/' - bioio_href = html_utils.href_tag('BioIO', bioio_url) - - aicsimageio_url = 'https://allencellmodeling.github.io/aicsimageio/#' - aicsimageio_href = html_utils.href_tag('AICSImageIO', aicsimageio_url) - - acdc_fiji_macros_url = 'https://cell-acdc.readthedocs.io/en/latest/data-structure-fiji.html' + bioformats_url = "https://www.openmicroscopy.org/bio-formats/" + bioformats_href = html_utils.href_tag("Bio-Formats", bioformats_url) + + bioio_url = "https://bioio-devs.github.io/bioio/" + bioio_href = html_utils.href_tag("BioIO", bioio_url) + + aicsimageio_url = "https://allencellmodeling.github.io/aicsimageio/#" + aicsimageio_href = html_utils.href_tag("AICSImageIO", aicsimageio_url) + + acdc_fiji_macros_url = ( + "https://cell-acdc.readthedocs.io/en/latest/data-structure-fiji.html" + ) acdc_fiji_macros_href = html_utils.href_tag( - 'Cell-ACDC Fiji macros guide', acdc_fiji_macros_url + "Cell-ACDC Fiji macros guide", acdc_fiji_macros_url ) - + conda_important_admon = html_utils.to_admonition( f""" Java can be installed only using conda! If you are not using conda and the file format of your files requires Bio-Formats,
you will need to use the provided ImageJ/Fiji macros.
See this guide for more information: {acdc_fiji_macros_href} - """, 'important' + """, + "important", ) - + issues_href = f'GitHub page' txt = html_utils.paragraph(f""" To process microscopy files, Cell-ACDC uses the {bioio_href} library.

@@ -1822,27 +1872,24 @@ def _showDataStructWin(self): # useAICSImageIO = QPushButton( # QIcon(':AICS_logo.svg'), ' Use AICSImageIO ', msg # ) - useBioFormatsButton = QPushButton( - QIcon(':ome.svg'), ' Use BioIO ', msg - ) + useBioFormatsButton = QPushButton(QIcon(":ome.svg"), " Use BioIO ", msg) restructButton = QPushButton( - QIcon(':folders.svg'), ' Re-structure image files ', msg + QIcon(":folders.svg"), " Re-structure image files ", msg ) buttons = [useBioFormatsButton, restructButton] if is_mac: useFijiMacroButton = QPushButton( - QIcon(':fiji-logo.svg'), ' Use Fiji Macro ', msg + QIcon(":fiji-logo.svg"), " Use Fiji Macro ", msg ) buttons.insert(1, useFijiMacroButton) msg.question( - self, 'How to structure files', txt, - buttonsTexts=('Cancel', *buttons) + self, "How to structure files", txt, buttonsTexts=("Cancel", *buttons) ) if msg.cancel: - self.logger.info('Creating data structure process aborted by the user.') + self.logger.info("Creating data structure process aborted by the user.") self.restoreDefaultButtons() return - + useBioFormats = msg.clickedButton == useBioFormatsButton useFijiMacro = False if is_mac: @@ -1852,63 +1899,61 @@ def _showDataStructWin(self): self.dataStructWin = dataStruct.createDataStructWin( parent=self, version=self._version ) - if self.dataStructWin.bioformats_backend == 'python-bioformats': + if self.dataStructWin.bioformats_backend == "python-bioformats": self.dataStructButton.setPalette(self.defaultButtonPalette) self.dataStructButton.setText( - '0. Restart Cell-ACDC to enable module 0 again.') + "0. Restart Cell-ACDC to enable module 0 again." + ) self.dataStructButton.setToolTip( - 'Due to an interal limitation of the Java Virtual Machine\n' - 'moduel 0 can be launched only once.\n' - 'To use it again close and reopen Cell-ACDC' + "Due to an interal limitation of the Java Virtual Machine\n" + "moduel 0 can be launched only once.\n" + "To use it again close and reopen Cell-ACDC" ) self.dataStructButton.setDisabled(True) - + self.dataStructWin.show() self.dataStructWin.main() - if self.dataStructWin.bioformats_backend != 'python-bioformats': + if self.dataStructWin.bioformats_backend != "python-bioformats": self.restoreDefaultButtons() elif useFijiMacro: self.runFijiMacroWorkflow() if msg.clickedButton == restructButton: self.progressWin = apps.QDialogWorkerProgress( - title='Re-structure image files log', parent=self, - pbarDesc='Re-structuring image files running...' + title="Re-structure image files log", + parent=self, + pbarDesc="Re-structuring image files running...", ) self.progressWin.sigClosed.connect(self.progressWinClosed) self.progressWin.show(self.app) - self.workerName = 'Re-structure image files' + self.workerName = "Re-structure image files" success = dataReStruct.run(self) if not success: self.progressWin.workerFinished = True self.progressWin.close() self.restoreDefaultButtons() - self.logger.info('Re-structuring files NOT completed.') - + self.logger.info("Re-structuring files NOT completed.") + def runFijiMacroWorkflow(self): self.progressWin = apps.QDialogWorkerProgress( - title='Initialising Fiji', - parent=self, - pbarDesc='Initialising Fiji...' + title="Initialising Fiji", parent=self, pbarDesc="Initialising Fiji..." ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - + QTimer.singleShot(100, self._runFijiMacroWindow) - + def _runFijiMacroWindow(self): - self.dataStructWin = ( - dataStruct.InitFijiMacro(self) - ) + self.dataStructWin = dataStruct.InitFijiMacro(self) self.dataStructWin.run() self.progressWin.workerFinished = True self.progressWin.close() self.restoreDefaultButtons() self.progressWin = None - + def progressWinClosed(self): self.progressWin = None self._gc_collect() - + def workerInitProgressbar(self, totalIter): if self.progressWin is None: return @@ -1917,52 +1962,48 @@ def workerInitProgressbar(self, totalIter): if totalIter == 1: totalIter = 0 self.progressWin.mainPbar.setMaximum(totalIter) - + def workerFinished(self, worker=None): msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = html_utils.paragraph( - f'{self.workerName} process finished.' - ) - msg.information(self, 'Process finished', txt) + txt = html_utils.paragraph(f"{self.workerName} process finished.") + msg.information(self, "Process finished", txt) if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() - + self.restoreDefaultButtons() - + @exception_handler def workerCritical(self, error): if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() - raise error - + raise error + def workerUpdateProgressbar(self, step): if self.progressWin is None: return self.progressWin.mainPbar.update(step) - - def workerProgress(self, text, loggerLevel='INFO'): + + def workerProgress(self, text, loggerLevel="INFO"): if self.progressWin is not None: self.progressWin.logConsole.append(text) self.logger.log(getattr(logging, loggerLevel), text) def restoreDefaultButtons(self): self.dataStructButton.setText( - '0. Create data structure from microscopy/image file(s)...' + "0. Create data structure from microscopy/image file(s)..." ) self.dataStructButton.setPalette(self.defaultButtonPalette) def launchDataPrep(self, checked=False): - dataPrepWin = dataPrep.dataPrepWin( - mainWin=self, version=self._version - ) + dataPrepWin = dataPrep.dataPrepWin(mainWin=self, version=self._version) dataPrepWin.sigClose.connect(self.dataPrepClosed) dataPrepWin.show() self.dataPrepWins.append(dataPrepWin) - + def dataPrepClosed(self, dataPrepWin): try: self.dataPrepWins.remove(dataPrepWin) @@ -1977,13 +2018,15 @@ def launchSegm(self, checked=False): defaultText = self.defaultTextSegmButton if c != self.moduleLaunchedColor: self.segmButton.setPalette(self.moduleLaunchedPalette) - self.segmButton.setText('Segmentation is running. ' - 'Check progress in the terminal/console') + self.segmButton.setText( + "Segmentation is running. Check progress in the terminal/console" + ) self.segmWin = segm.segmWin( buttonToRestore=(self.segmButton, defaultColor, defaultText), - mainWin=self, version=self._version + mainWin=self, + version=self._version, ) - self.segmWin.sigClosed.connect(self.segmWinClosed) + self.segmWin.sigClosed.connect(self.segmWinClosed) self.segmWin.show() self.segmWin.main() else: @@ -1996,63 +2039,67 @@ def segmWinClosed(self): self._gc_collect() def launchGui(self, checked=False): - self.logger.info('Opening GUI...') + self.logger.info("Opening GUI...") guiWin = gui.guiWin( - self.app, mainWin=self, version=self._version, - launcherSlot=self.launchGui + self.app, mainWin=self, version=self._version, launcherSlot=self.launchGui ) self.guiWins.append(guiWin) guiWin.sigClosed.connect(self.guiClosed) guiWin.run() - + def launchSpotmaxGui(self, checked=False): from spotmax import icon_path, logo_path # logoDialog = apps.LogoDialog(logo_path, icon_path, parent=self) - + splashScreen = QSplashScreen() splashScreen.setPixmap(QPixmap(logo_path)) splashScreen.show() QTimer.singleShot(300, partial(self._launchSpotMaxGui, splashScreen)) - + def _launchSpotMaxGui(self, splashScreen): - self.logger.info('Launching spotMAX...') + self.logger.info("Launching spotMAX...") spotmaxWin = spotmaxRun.run_gui( - app=self.app, mainWin=self, launcherSlot=self.launchSpotmaxGui, - + app=self.app, + mainWin=self, + launcherSlot=self.launchSpotmaxGui, ) spotmaxWin.sigClosed.connect(self.spotmaxGuiClosed) self.spotmaxWins.append(spotmaxWin) splashScreen.close() - + def spotmaxGuiClosed(self, spotmaxWin): self.spotmaxWins.remove(spotmaxWin) self._gc_collect() - + def guiClosed(self, guiWin): try: self.guiWins.remove(guiWin) except ValueError: pass self._gc_collect() - + def _gc_collect(self): QTimer.singleShot(100, gc.collect) def launchAlignUtil(self, checked=False): self.logger.info(f'Launching utility "{self.sender().text()}"') selectedExpPaths = self.getSelectedExpPaths( - 'Align frames in X and Y with phase cross-correlation' + "Align frames in X and Y with phase cross-correlation" ) if selectedExpPaths is None: return - - title = 'Align frames' - infoText = 'Aligning frames in X and Y with phase cross-correlation...' - progressDialogueTitle = 'Align frames' + + title = "Align frames" + infoText = "Aligning frames in X and Y with phase cross-correlation..." + progressDialogueTitle = "Align frames" self.alignWindow = utilsAlign.alignWin( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.alignWindow.show() @@ -2061,40 +2108,48 @@ def launchConcatUtil(self, checked=False, exp_folderpath=None): f'Launching utility "Concatenate tables from multipe positions"' ) selectedExpPaths = self.getSelectedExpPaths( - 'Concatenate acdc_output files', exp_folderpath=exp_folderpath + "Concatenate acdc_output files", exp_folderpath=exp_folderpath ) if selectedExpPaths is None: return - - title = 'Concatenate acdc_output files' - infoText = 'Launching concatenate acdc_output files process...' - progressDialogueTitle = 'Concatenate acdc_output files' + + title = "Concatenate acdc_output files" + infoText = "Launching concatenate acdc_output files process..." + progressDialogueTitle = "Concatenate acdc_output files" self.concatWindow = utilsConcat.ConcatWin( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.concatWindow.show() - + def launchConcatSpotmaxUtil(self, checked=False, exp_folderpath=None): self.logger.info( f'Launching utility "Concatenate tables from multipe positions"' ) selectedExpPaths = self.getSelectedExpPaths( - 'Concatenate spotMAX output files', + "Concatenate spotMAX output files", exp_folderpath=exp_folderpath, ) if selectedExpPaths is None: return - - title = 'Concatenate spotMAX output files' - infoText = 'Launching concatenate spotMAX output files process...' - progressDialogueTitle = 'Concatenate spotMAX output files' + + title = "Concatenate spotMAX output files" + infoText = "Launching concatenate spotMAX output files process..." + progressDialogueTitle = "Concatenate spotMAX output files" self.concatWindow = utilsConcat.ConcatWin( - selectedExpPaths, self.app, title, infoText, progressDialogueTitle, - parent=self + selectedExpPaths, + self.app, + title, + infoText, + progressDialogueTitle, + parent=self, ) self.concatWindow.show() - + def showEvent(self, event): self.showAllWindows() # self.setFocus() @@ -2102,55 +2157,56 @@ def showEvent(self, event): if not self.checkUserDataFolderPath: return self.checkMigrateUserDataFolderPath() - + def checkMigrateUserDataFolderPath(self): from . import user_home_path + user_home_acdc_folders = load.get_all_acdc_folders(user_home_path) if not user_home_acdc_folders: self.checkUserDataFolderPath = False return - if 'doNotAskMigrate' in self.df_settings.index: - if str(self.df_settings.at['doNotAskMigrate', 'value']) == 'Yes': + if "doNotAskMigrate" in self.df_settings.index: + if str(self.df_settings.at["doNotAskMigrate", "value"]) == "Yes": return - + msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - 'Starting from version 1.4.0, Cell-ACDC default user profile path ' - f'has been changed to {user_profile_path}

' - 'Since you have some profile data saved in the old path, Cell-ACDC ' - 'can now migrate everything to the new folder.

' - 'Do you want to migrate now?
' + "Starting from version 1.4.0, Cell-ACDC default user profile path " + f"has been changed to {user_profile_path}

" + "Since you have some profile data saved in the old path, Cell-ACDC " + "can now migrate everything to the new folder.

" + "Do you want to migrate now?
" ) acdc_folders_format = [ - f'   {os.path.join(user_home_path, folder)}' + f"   {os.path.join(user_home_path, folder)}" for folder in user_home_acdc_folders ] - acdc_folders_format = '
'.join(acdc_folders_format) + acdc_folders_format = "
".join(acdc_folders_format) detailsText = ( - f'Folders found in the previous location:

{acdc_folders_format}' + f"Folders found in the previous location:

{acdc_folders_format}" ) - doNotAskAgainCheckbox = QCheckBox('Do not ask again') + doNotAskAgainCheckbox = QCheckBox("Do not ask again") msg.warning( - self, 'Migrate old user profile', txt, - buttonsTexts=('Cancel', 'Yes'), + self, + "Migrate old user profile", + txt, + buttonsTexts=("Cancel", "Yes"), detailsText=detailsText, - widgets=doNotAskAgainCheckbox + widgets=doNotAskAgainCheckbox, ) if doNotAskAgainCheckbox.isChecked(): - self.df_settings.at['doNotAskMigrate', 'value'] = 'Yes' + self.df_settings.at["doNotAskMigrate", "value"] = "Yes" self.df_settings.to_csv(settings_csv_path) if msg.cancel: - self.logger.info( - 'Migrating old user profile cancelled.' - ) + self.logger.info("Migrating old user profile cancelled.") self.checkUserDataFolderPath = False return self.startMigrateUserProfileWorker( user_home_path, user_profile_path, user_home_acdc_folders ) self.checkUserDataFolderPath = False - + def showAllWindows(self): openModules = self.getOpenModules() for win in openModules: @@ -2168,32 +2224,32 @@ def show(self): super().show() h = self.dataPrepButton.geometry().height() f = 1.5 - self.dataStructButton.setMinimumHeight(int(h*f)) - self.dataPrepButton.setMinimumHeight(int(h*f)) - self.segmButton.setMinimumHeight(int(h*f)) - self.guiButton.setMinimumHeight(int(h*f)) - if hasattr(self, 'spotmaxButton'): - self.spotmaxButton.setMinimumHeight(int(h*f)) - self.showAllWindowsButton.setMinimumHeight(int(h*f)) - self.restartButton.setMinimumHeight(int(int(h*f))) - self.closeButton.setMinimumHeight(int(int(h*f))) + self.dataStructButton.setMinimumHeight(int(h * f)) + self.dataPrepButton.setMinimumHeight(int(h * f)) + self.segmButton.setMinimumHeight(int(h * f)) + self.guiButton.setMinimumHeight(int(h * f)) + if hasattr(self, "spotmaxButton"): + self.spotmaxButton.setMinimumHeight(int(h * f)) + self.showAllWindowsButton.setMinimumHeight(int(h * f)) + self.restartButton.setMinimumHeight(int(int(h * f))) + self.closeButton.setMinimumHeight(int(int(h * f))) # iconWidth = int(self.closeButton.iconSize().width()*1.3) # self.closeButton.setIconSize(QSize(iconWidth, iconWidth)) self.setColorsAndText() self.readSettings() if self.app.toggle_dark_mode: - self.darkModeToggle.warnMessageBox = False + self.darkModeToggle.warnMessageBox = False self.darkModeToggle.setChecked(True) def saveWindowGeometry(self): - settings = QSettings('schmollerlab', 'acdc_main') + settings = QSettings("schmollerlab", "acdc_main") settings.setValue("geometry", self.saveGeometry()) def readSettings(self): - settings = QSettings('schmollerlab', 'acdc_main') - if settings.value('geometry') is not None: + settings = QSettings("schmollerlab", "acdc_main") + if settings.value("geometry") is not None: self.restoreGeometry(settings.value("geometry")) - + def getOpenModules(self): c2 = self.segmButton.palette().button().color().name() launchedColor = self.moduleLaunchedColor @@ -2209,7 +2265,6 @@ def getOpenModules(self): openModules.extend(self.spotmaxWins) return openModules - def checkOpenModules(self): openModules = self.getOpenModules() @@ -2218,11 +2273,11 @@ def checkOpenModules(self): msg = widgets.myMessageBox() warn_txt = html_utils.paragraph( - 'There are still other Cell-ACDC windows open.

' - 'Are you sure you want to close everything?' + "There are still other Cell-ACDC windows open.

" + "Are you sure you want to close everything?" ) _, yesButton = msg.warning( - self, 'Modules still open!', warn_txt, buttonsTexts=('Cancel', 'Yes') + self, "Modules still open!", warn_txt, buttonsTexts=("Cancel", "Yes") ) return msg.clickedButton == yesButton, openModules @@ -2253,9 +2308,9 @@ def closeEvent(self, event): restart() except Exception as e: traceback.print_exc() - print('-----------------------------------------') - print('Failed to restart Cell-ACDC. Please restart manually') + print("-----------------------------------------") + print("Failed to restart Cell-ACDC. Please restart manually") else: - self.logger.info('**********************************************') - self.logger.info(f'Cell-ACDC closed. {myutils.get_salute_string()}') - self.logger.info('**********************************************') + self.logger.info("**********************************************") + self.logger.info(f"Cell-ACDC closed. {myutils.get_salute_string()}") + self.logger.info("**********************************************") diff --git a/cellacdc/_palettes.py b/cellacdc/_palettes.py index 5de708312..e8ed7273e 100644 --- a/cellacdc/_palettes.py +++ b/cellacdc/_palettes.py @@ -7,134 +7,147 @@ if GUI_INSTALLED: from qtpy import QtGui + def _highlight_rgba(): scheme = get_color_scheme() - if scheme == 'light': + if scheme == "light": return (207, 235, 155, 255) else: return (141, 196, 39, 255) + def _highlighted_text(): return (0, 0, 0, 255) + def base_color(): scheme = get_color_scheme() - if scheme == 'light': - return '#4d4d4d' + if scheme == "light": + return "#4d4d4d" else: - return '#d9d9d9' + return "#d9d9d9" + def _light_colors(): colors = { - 'Window': (239, 239, 239, 255), - 'WindowText': (0, 0, 0, 255), - 'Base': (255, 255, 255, 255), - 'AlternateBase': (247, 247, 247, 255), - 'ToolTipBase': (255, 255, 220, 255), - 'ToolTipText': (0, 0, 0, 255), - 'Text': (0, 0, 0, 255), - 'Button': (239, 239, 239, 255), - 'ButtonText': (0, 0, 0, 255), - 'BrightText': (255, 255, 255, 255), - 'Link': (0, 0, 255, 255), - 'Highlight': _highlight_rgba(), - 'HighlightedText': _highlighted_text() + "Window": (239, 239, 239, 255), + "WindowText": (0, 0, 0, 255), + "Base": (255, 255, 255, 255), + "AlternateBase": (247, 247, 247, 255), + "ToolTipBase": (255, 255, 220, 255), + "ToolTipText": (0, 0, 0, 255), + "Text": (0, 0, 0, 255), + "Button": (239, 239, 239, 255), + "ButtonText": (0, 0, 0, 255), + "BrightText": (255, 255, 255, 255), + "Link": (0, 0, 255, 255), + "Highlight": _highlight_rgba(), + "HighlightedText": _highlighted_text(), } return colors + def _get_highligth_header_background_rgba(): scheme = get_color_scheme() - if scheme == 'light': - window_rgba = _light_colors()['Window'] - return tuple([val-40 for val in window_rgba]) + if scheme == "light": + window_rgba = _light_colors()["Window"] + return tuple([val - 40 for val in window_rgba]) else: - window_rgba = _dark_colors()['Window'] - return tuple([val+40 for val in window_rgba]) + window_rgba = _dark_colors()["Window"] + return tuple([val + 40 for val in window_rgba]) + def _get_highligth_text_background_rgba(): scheme = get_color_scheme() - if scheme == 'light': - window_rgba = _light_colors()['Window'] - return tuple([val-20 for val in window_rgba]) + if scheme == "light": + window_rgba = _light_colors()["Window"] + return tuple([val - 20 for val in window_rgba]) else: - window_rgba = _dark_colors()['Window'] - return tuple([val+20 for val in window_rgba]) + window_rgba = _dark_colors()["Window"] + return tuple([val + 20 for val in window_rgba]) + def text_float_rgba(): scheme = get_color_scheme() - if scheme == 'light': - text_rgba = _light_colors()['Text'] - return tuple([val/255 for val in text_rgba]) + if scheme == "light": + text_rgba = _light_colors()["Text"] + return tuple([val / 255 for val in text_rgba]) else: - text_rgba = _dark_colors()['Text'] - return tuple([val/255 for val in text_rgba]) + text_rgba = _dark_colors()["Text"] + return tuple([val / 255 for val in text_rgba]) + def get_disabled_colors(): scheme = get_color_scheme() - if scheme == 'light': + if scheme == "light": return _light_disabled_colors() else: return _dark_disabled_colors() + def _light_disabled_colors(): disabled_colors = { - 'ButtonText': (150, 150, 150, 255), - 'WindowText': (128, 128, 128, 255), - 'Text': (150, 150, 150, 255), - 'Light': (255, 255, 255, 255), - 'Button': (230, 230, 230, 255), + "ButtonText": (150, 150, 150, 255), + "WindowText": (128, 128, 128, 255), + "Text": (150, 150, 150, 255), + "Light": (255, 255, 255, 255), + "Button": (230, 230, 230, 255), # 'Window': (200, 200, 200, 255), # 'Highlight': (0, 0, 0, 255), # 'HighlightedText': (0, 0, 0, 255), - } return disabled_colors + def _dark_disabled_colors(): disabled_colors = { - 'ButtonText': (150, 150, 150, 255), - 'WindowText': (128, 128, 128, 255), - 'Text': (128, 128, 128, 255), - 'Light': (53, 53, 53, 255), - 'Button': (70, 70, 70, 255), + "ButtonText": (150, 150, 150, 255), + "WindowText": (128, 128, 128, 255), + "Text": (128, 128, 128, 255), + "Light": (53, 53, 53, 255), + "Button": (70, 70, 70, 255), # 'Window': (0, 0, 0, 255), } return disabled_colors + def text_pen_color(): scheme = get_color_scheme() - if scheme == 'light': - return '#4d4d4d' + if scheme == "light": + return "#4d4d4d" else: - return '#d9d9d9' + return "#d9d9d9" + def _dark_colors(): colors = { - 'Window': (50, 50, 50, 255), - 'WindowText': (240, 240, 240, 255), - 'Base': (36, 36, 36, 255), - 'AlternateBase': (43, 43, 43, 255), - 'ToolTipBase': (255, 255, 220, 255), - 'ToolTipText': (0, 0, 0, 255), - 'Text': (240, 240, 240, 255), - 'Button': (50, 50, 50, 255), - 'ButtonText': (240, 240, 240, 255), - 'BrightText': (75, 75, 75, 255), - 'Link': (48, 140, 198, 255), - 'Highlight': _highlight_rgba(), - 'HighlightedText': _highlighted_text() + "Window": (50, 50, 50, 255), + "WindowText": (240, 240, 240, 255), + "Base": (36, 36, 36, 255), + "AlternateBase": (43, 43, 43, 255), + "ToolTipBase": (255, 255, 220, 255), + "ToolTipText": (0, 0, 0, 255), + "Text": (240, 240, 240, 255), + "Button": (50, 50, 50, 255), + "ButtonText": (240, 240, 240, 255), + "BrightText": (75, 75, 75, 255), + "Link": (48, 140, 198, 255), + "Highlight": _highlight_rgba(), + "HighlightedText": _highlighted_text(), } return colors + def getPainterColor(): scheme = get_color_scheme() - if scheme == 'light': - return _light_colors()['Text'] + if scheme == "light": + return _light_colors()["Text"] else: - return _dark_colors()['Text'] + return _dark_colors()["Text"] -def getPaletteColorScheme(palette: 'QtGui.QPalette', scheme='light'): - if scheme == 'light': + +def getPaletteColorScheme(palette: "QtGui.QPalette", scheme="light"): + if scheme == "light": colors = _light_colors() disabled_colors = _light_disabled_colors() else: @@ -149,96 +162,107 @@ def getPaletteColorScheme(palette: 'QtGui.QPalette', scheme='light'): palette.setColor(ColorGroup, colorRole, QtGui.QColor(*rgba)) return palette + def get_color_scheme(): if not os.path.exists(settings_csv_path): - return 'light' - df_settings = pd.read_csv(settings_csv_path, index_col='setting') - if 'colorScheme' not in df_settings.index: - return 'light' + return "light" + df_settings = pd.read_csv(settings_csv_path, index_col="setting") + if "colorScheme" not in df_settings.index: + return "light" else: - return df_settings.at['colorScheme', 'value'] - + return df_settings.at["colorScheme", "value"] + + def lineedit_background_hex(): scheme = get_color_scheme() - if scheme == 'light': - return r'{background:#ffffff;}' + if scheme == "light": + return r"{background:#ffffff;}" else: - return r'{background:#242424;}' + return r"{background:#242424;}" + def lineedit_invalid_entry_stylesheet(): return ( # 'background: #FEF9C3;' - 'border-radius: 4px;' - 'border: 1.5px solid red;' - 'padding: 1px 0px 1px 0px' + "border-radius: 4px;border: 1.5px solid red;padding: 1px 0px 1px 0px" ) -def lineedit_warning_stylesheet(): + +def lineedit_warning_stylesheet(): scheme = get_color_scheme() - if scheme == 'light': - stylesheet = 'background: #FEF9C3;' + if scheme == "light": + stylesheet = "background: #FEF9C3;" else: - stylesheet = 'background: #FEF9C3; color: black' + stylesheet = "background: #FEF9C3; color: black" return stylesheet -def setToolTipStyleSheet(app, scheme='light'): - if scheme == 'dark': - app.setStyleSheet(r"QToolTip {" + +def setToolTipStyleSheet(app, scheme="light"): + if scheme == "dark": + app.setStyleSheet( + r"QToolTip {" "color: #e6e6e6; background-color: #3c3c3c; border: 1px solid white;" - "}" + "}" ) else: - app.setStyleSheet(r"QToolTip {" + app.setStyleSheet( + r"QToolTip {" "color: #141414; background-color: #ffffff; border: 1px solid black;" - "}" + "}" ) + def green(): scheme = get_color_scheme() - if scheme == 'light': - return '#CFEB9B' + if scheme == "light": + return "#CFEB9B" else: - return '#607a2f' + return "#607a2f" + def TreeWidgetStyleSheet(): scheme = get_color_scheme() - if scheme == 'light': - styleSheet = (""" + if scheme == "light": + styleSheet = """ QTreeWidget::item:hover {background-color:#E6E6E6; color:black;} QTreeWidget::item:selected {background-color:#CFEB9B; color:black;} QTreeView { selection-background-color: #CFEB9B; show-decoration-selected: 1; } - """) + """ else: - styleSheet = (""" + styleSheet = """ QTreeWidget::item:hover {background-color:#E6E6E6; color:black;} QTreeWidget::item:selected {background-color:#8dc427; color:black;} QTreeView { selection-background-color: #8dc427; show-decoration-selected: 1; } - """) + """ return styleSheet + def ListWidgetStyleSheet(): styleSheet = TreeWidgetStyleSheet() - styleSheet = styleSheet.replace('QTreeWidget', 'QListWidget') - styleSheet = styleSheet.replace('QTreeView', 'QListView') + styleSheet = styleSheet.replace("QTreeWidget", "QListWidget") + styleSheet = styleSheet.replace("QTreeView", "QListView") return styleSheet + def QProgressBarColor(): styleSheet = TreeWidgetStyleSheet() - hex = re.findall(r'selection-background-color: (#[A-Za-z0-9]+)', styleSheet)[0] - return QtGui.QColor(hex) + hex = re.findall(r"selection-background-color: (#[A-Za-z0-9]+)", styleSheet)[0] + return QtGui.QColor(hex) + def QProgressBarHighlightedTextColor(): return QtGui.QColor(0, 0, 0, 255) + def moduleLaunchedButtonRgb(self): scheme = get_color_scheme() - if scheme == 'light': - return (241,221,0) + if scheme == "light": + return (241, 221, 0) else: - return (241,221,0) \ No newline at end of file + return (241, 221, 0) diff --git a/cellacdc/_process.py b/cellacdc/_process.py index 4b8f11e24..027f6f4cd 100644 --- a/cellacdc/_process.py +++ b/cellacdc/_process.py @@ -7,29 +7,37 @@ import argparse ap = argparse.ArgumentParser( - prog='Cell-ACDC process', description='Used to spawn a separate process', - formatter_class=argparse.RawTextHelpFormatter + prog="Cell-ACDC process", + description="Used to spawn a separate process", + formatter_class=argparse.RawTextHelpFormatter, ) ap.add_argument( - '-c', '--command', required=True, type=str, metavar='COMMAND', - help='String of commands separated by comma.' + "-c", + "--command", + required=True, + type=str, + metavar="COMMAND", + help="String of commands separated by comma.", ) ap.add_argument( - '-l', '--log_filepath', - default='', + "-l", + "--log_filepath", + default="", type=str, - metavar='LOG_FILEPATH', - help=('Path of an additional log file') + metavar="LOG_FILEPATH", + help=("Path of an additional log file"), ) -def worker(*commands): - subprocess.run(list(commands)) # [sys.executable, r'spotmax\test.py']) -if __name__ == '__main__': +def worker(*commands): + subprocess.run(list(commands)) # [sys.executable, r'spotmax\test.py']) + + +if __name__ == "__main__": args = vars(ap.parse_args()) - command = args['command'] - commands = command.split(',') + command = args["command"] + commands = command.split(",") commands = [command.lstrip() for command in commands] process = multiprocessing.Process(target=worker, args=commands) process.start() diff --git a/cellacdc/_profile/spline_to_obj/model.py b/cellacdc/_profile/spline_to_obj/model.py index 6583cba24..7a91a759b 100644 --- a/cellacdc/_profile/spline_to_obj/model.py +++ b/cellacdc/_profile/spline_to_obj/model.py @@ -18,40 +18,35 @@ pwd_path = os.path.dirname(os.path.abspath(__file__)) + class Model: def __init__(self): pass def fit(self): # Read data - filename = '1_exec_time_space_size_step_10.csv' + filename = "1_exec_time_space_size_step_10.csv" df_path = os.path.join(pwd_path, filename) df = pd.read_csv(df_path) - + # Define predictor and response variables - X_train = df[['bbox_area', 'exec_time']] - y_train = df['space_size'] + X_train = df[["bbox_area", "exec_time"]] + y_train = df["space_size"] # Scale the data scaler = StandardScaler().fit(X_train) - X_scaled = pd.DataFrame( - scaler.transform(X_train), columns=X_train.columns - ) + X_scaled = pd.DataFrame(scaler.transform(X_train), columns=X_train.columns) self.scaler = scaler # Define regression model and fit - model = tree.DecisionTreeRegressor() # LinearRegression() + model = tree.DecisionTreeRegressor() # LinearRegression() reg = model.fit(X_scaled, y_train) self.model = model def predict(self, bbox_area, max_exec_time=150): - X_pred = pd.DataFrame({ - 'bbox_area': [bbox_area], 'exec_time': [max_exec_time] - }) - X_scaled = pd.DataFrame( - self.scaler.transform(X_pred), columns=X_pred.columns - ) + X_pred = pd.DataFrame({"bbox_area": [bbox_area], "exec_time": [max_exec_time]}) + X_scaled = pd.DataFrame(self.scaler.transform(X_pred), columns=X_pred.columns) y_pred = self.model.predict(X_scaled) pred_space_size = y_pred[0] return pred_space_size diff --git a/cellacdc/_profile/spline_to_obj/profile_skimage_draw_polygon.py b/cellacdc/_profile/spline_to_obj/profile_skimage_draw_polygon.py index 44fbd6559..00d83a5d7 100644 --- a/cellacdc/_profile/spline_to_obj/profile_skimage_draw_polygon.py +++ b/cellacdc/_profile/spline_to_obj/profile_skimage_draw_polygon.py @@ -12,7 +12,7 @@ pwd_path = os.path.dirname(os.path.abspath(__file__)) -img = np.zeros((1000,1000), dtype=np.uint8) +img = np.zeros((1000, 1000), dtype=np.uint8) dfs = [] keys = [] @@ -20,25 +20,27 @@ space_size_step = 10 square_side_range_min, square_side_range_max = 10, 600 -for space_size in tqdm(np.arange(10,1001,10), ncols=100): +for space_size in tqdm(np.arange(10, 1001, 10), ncols=100): bbox_areas = [] exec_times = [] space = np.linspace(0, 1, space_size) - for side in tqdm(np.arange(square_side_range_min,square_side_range_min+1,2), ncols=100): + for side in tqdm( + np.arange(square_side_range_min, square_side_range_min + 1, 2), ncols=100 + ): img[:] = 0 - half_side = int(side/2) + half_side = int(side / 2) - left = 500-half_side - right = 500+half_side - - anchors_xx = [left,right,right,left,left] - anchors_yy = [left,left,right,right,left] + left = 500 - half_side + right = 500 + half_side + + anchors_xx = [left, right, right, left, left] + anchors_yy = [left, left, right, right, left] bbox_area = side**2 bbox_areas.append(bbox_area) - tck, u = scipy.interpolate.splprep( + tck, u = scipy.interpolate.splprep( [anchors_xx, anchors_yy], s=0, k=3, per=False ) xi, yi = scipy.interpolate.splev(space, tck) @@ -47,7 +49,7 @@ rr, cc = skimage.draw.polygon(yi, xi, shape=img.shape) t1 = time.perf_counter() - exec_times.append((t1-t0)*1000) + exec_times.append((t1 - t0) * 1000) img[rr, cc] = 2 @@ -57,19 +59,21 @@ img[rr, cc] = 1 - df = pd.DataFrame({'bbox_area': bbox_areas, 'exec_time': exec_times}).set_index('bbox_area') + df = pd.DataFrame({"bbox_area": bbox_areas, "exec_time": exec_times}).set_index( + "bbox_area" + ) dfs.append(df) keys.append(space_size) -final_df = pd.concat(dfs, keys=keys, names=['space_size', 'bbox_area']) +final_df = pd.concat(dfs, keys=keys, names=["space_size", "bbox_area"]) df_filename = ( - f'side_range_{square_side_range_min}-{square_side_range_max}_' - f'space_size_step_{space_size_step}.csv' + f"side_range_{square_side_range_min}-{square_side_range_max}_" + f"space_size_step_{space_size_step}.csv" ) final_df.to_csv(os.path.join(pwd_path, df_filename)) -plt.plot(xi, yi, c='r') +plt.plot(xi, yi, c="r") plt.imshow(img) -plt.show() \ No newline at end of file +plt.show() diff --git a/cellacdc/_run.py b/cellacdc/_run.py index 027334eb7..5e8b68b6b 100644 --- a/cellacdc/_run.py +++ b/cellacdc/_run.py @@ -6,49 +6,52 @@ from tqdm import tqdm from . import config, myutils -def _install_tables(parent_software='Cell-ACDC'): + +def _install_tables(parent_software="Cell-ACDC"): from . import try_input_install_package, is_conda_env + try: import tables + return False except Exception as e: - if parent_software == 'Cell-ACDC': - issues_url = 'https://github.com/SchmollerLab/Cell_ACDC/issues' + if parent_software == "Cell-ACDC": + issues_url = "https://github.com/SchmollerLab/Cell_ACDC/issues" note_txt = ( - 'If the installation fails, you can still use Cell-ACDC, but we ' - 'highly recommend you report the issue (see link below) and we ' - 'will be very happy to help. Thank you for your patience!' + "If the installation fails, you can still use Cell-ACDC, but we " + "highly recommend you report the issue (see link below) and we " + "will be very happy to help. Thank you for your patience!" ) else: - issues_url = 'https://github.com/SchmollerLab/Cell_ACDC/issues' + issues_url = "https://github.com/SchmollerLab/Cell_ACDC/issues" note_txt = ( - 'If the installation fails, report the issue (see link below) and we ' - 'will be very happy to help. Thank you for your patience!' + "If the installation fails, report the issue (see link below) and we " + "will be very happy to help. Thank you for your patience!" ) while True: txt = ( - f'{parent_software} needs to install a library called `tables`.\n\n' - f'{note_txt}\n\n' - f'Report issue here: {issues_url}\n' + f"{parent_software} needs to install a library called `tables`.\n\n" + f"{note_txt}\n\n" + f"Report issue here: {issues_url}\n" ) - print('-'*60) + print("-" * 60) print(txt) conda_prefix, pip_prefix = myutils.get_pip_conda_prefix() conda_list, pip_list = myutils.get_pip_conda_prefix(list_return=True) - conda_txt = f'{conda_prefix} pytables' - pip_text = f'{pip_prefix} --upgrade tables' + conda_txt = f"{conda_prefix} pytables" + pip_text = f"{pip_prefix} --upgrade tables" - conda_list = conda_list + ['pytables'] - pip_list = pip_list + ['--upgrade', 'tables'] + conda_list = conda_list + ["pytables"] + pip_list = pip_list + ["--upgrade", "tables"] if is_conda_env(): command_txt = conda_txt alt_command_txt = pip_text cmd_args = [command_txt] alt_cmd_args1 = conda_list alt_cmd_args2 = pip_list - pkg_mng = 'conda' - alt_pkg_mng = 'pip' + pkg_mng = "conda" + alt_pkg_mng = "pip" shell = True alt_shell = False else: @@ -57,63 +60,65 @@ def _install_tables(parent_software='Cell-ACDC'): cmd_args = pip_list alt_cmd_args1 = conda_list alt_cmd_args2 = [alt_command_txt] - pkg_mng = 'pip' - alt_pkg_mng = 'conda' + pkg_mng = "pip" + alt_pkg_mng = "conda" shell = False alt_shell = True - - answer = try_input_install_package('tables', command_txt) - - if answer.lower() == 'y' or not answer: + + answer = try_input_install_package("tables", command_txt) + + if answer.lower() == "y" or not answer: import subprocess, traceback + try: subprocess.check_call(cmd_args, shell=shell) break except Exception as err: traceback.print_exc() - print('-'*100) + print("-" * 100) print( - f'[WARNING]: Installation with command `{cmd_args}` ' - f'failed. Trying with `{alt_cmd_args1}`...' + f"[WARNING]: Installation with command `{cmd_args}` " + f"failed. Trying with `{alt_cmd_args1}`..." ) - print('-'*100) - + print("-" * 100) + try: subprocess.check_call(alt_cmd_args1, shell=shell) break except Exception as err: traceback.print_exc() - print('-'*100) + print("-" * 100) print( - f'[WARNING]: Installation of `tables` with ' - f'{pkg_mng} failed. Trying with {alt_pkg_mng}...' + f"[WARNING]: Installation of `tables` with " + f"{pkg_mng} failed. Trying with {alt_pkg_mng}..." ) - print('-'*100) - + print("-" * 100) + # import pdb; pdb.set_trace() try: subprocess.check_call(alt_cmd_args2, shell=alt_shell) break except Exception as err: import traceback + traceback.print_exc() - print('*'*60) - if parent_software == 'Cell-ACDC': - msg_type = '[WARNING]' + print("*" * 60) + if parent_software == "Cell-ACDC": + msg_type = "[WARNING]" log_func = print else: - msg_type = '[ERROR]' + msg_type = "[ERROR]" log_func = exit - + log_func( - f'{msg_type}: Installation of `tables` failed. ' - 'Please report the issue here (**including the error ' - f'message above**): {issues_url}' + f"{msg_type}: Installation of `tables` failed. " + "Please report the issue here (**including the error " + f"message above**): {issues_url}" ) - print('^'*60) + print("^" * 60) finally: break - elif answer.lower() == 'n': + elif answer.lower() == "n": raise e else: print( @@ -123,45 +128,51 @@ def _install_tables(parent_software='Cell-ACDC'): return True + def _setup_symlink_app_name_macos(): - """On Mac generate a symlink from the Python path defined in the shebang - of the `acdc` binary called Cell-ACDC and modify the shebang to run - the acdc binary from the symlink. This will correctly display Cell-ACDC + """On Mac generate a symlink from the Python path defined in the shebang + of the `acdc` binary called Cell-ACDC and modify the shebang to run + the acdc binary from the symlink. This will correctly display Cell-ACDC in the menubar instead of Python. - """ + """ from . import is_mac, printl + if not is_mac: return - + import subprocess + acdc_binary_path = os.path.dirname(sys.executable) - symlink = os.path.join(acdc_binary_path, 'Cell-ACDC') + symlink = os.path.join(acdc_binary_path, "Cell-ACDC") if os.path.exists(symlink): return - - for acdc_exec_name in ('acdc', 'cellacdc'): + + for acdc_exec_name in ("acdc", "cellacdc"): acdc_exec_path = os.path.join(acdc_binary_path, acdc_exec_name) try: - with open(acdc_exec_path, 'r') as bin: + with open(acdc_exec_path, "r") as bin: acdc_exec_text = bin.read() - shebang = acdc_exec_text.split('\n')[0][2:] + shebang = acdc_exec_text.split("\n")[0][2:] if not os.path.exists(symlink): - command = f'ln -s {shebang} {symlink}' + command = f"ln -s {shebang} {symlink}" subprocess.check_call(command, shell=True) acdc_exec_text = acdc_exec_text.replace(shebang, symlink) - with open(acdc_exec_path, 'w') as bin: + with open(acdc_exec_path, "w") as bin: bin.write(acdc_exec_text) except Exception as err: printl(traceback.format_exc()) - print('[WARNING]: Failed at creating Cell-ACDC symlink') + print("[WARNING]: Failed at creating Cell-ACDC symlink") -def _setup_gui_libraries(caller_name='Cell-ACDC', exit_at_end=True): + +def _setup_gui_libraries(caller_name="Cell-ACDC", exit_at_end=True): from . import try_input_install_package, is_conda_env + warn_restart = False - + # Force PyQt6 if available try: from PyQt6 import QtCore + os.environ["QT_API"] = "pyqt6" except Exception as e: pass @@ -171,30 +182,32 @@ def _setup_gui_libraries(caller_name='Cell-ACDC', exit_at_end=True): except ModuleNotFoundError as e: conda_prefix, pip_prefix = myutils.get_pip_conda_prefix() conda_list, pip_list = myutils.get_pip_conda_prefix(list_return=True) - - command_txt = f'{pip_prefix} --upgrade qtpy' - + + command_txt = f"{pip_prefix} --upgrade qtpy" + txt = ( - f'{caller_name} needs to install the package `qtpy`.\n\n' - f'You can let {caller_name} install it now, or you can abort ' - f'and install it manually with the command `{command_txt}`.' + f"{caller_name} needs to install the package `qtpy`.\n\n" + f"You can let {caller_name} install it now, or you can abort " + f"and install it manually with the command `{command_txt}`." ) - print('-'*60) + print("-" * 60) print(txt) while True: from .config import parser_args - if parser_args['yes']: - answer = 'y' + + if parser_args["yes"]: + answer = "y" else: - answer = try_input_install_package('qtpy', command_txt) - - if answer.lower() == 'y' or not answer: + answer = try_input_install_package("qtpy", command_txt) + + if answer.lower() == "y" or not answer: import subprocess - cmd = pip_list + ['-U', 'qtpy'] + + cmd = pip_list + ["-U", "qtpy"] subprocess.check_call(cmd) break - elif answer.lower() == 'n': + elif answer.lower() == "n": raise e else: print( @@ -202,365 +215,366 @@ def _setup_gui_libraries(caller_name='Cell-ACDC', exit_at_end=True): 'Type "y" for "yes", or "n" for "no".' ) except ImportError as e: - # Ignore that qtpy is installed but there is no PyQt bindings --> this + # Ignore that qtpy is installed but there is no PyQt bindings --> this # is handled in the next block pass - + from . import is_mac_arm64 - default_qt = 'PyQt5' if is_mac_arm64 else 'PyQt6' - - try: # no need to handle no_cli, acdc is run with -y flag + + default_qt = "PyQt5" if is_mac_arm64 else "PyQt6" + + try: # no need to handle no_cli, acdc is run with -y flag from qtpy.QtCore import Qt except Exception as e: traceback.print_exc() txt = ( - f'{caller_name} needs to install a GUI library (default library is ' - f'`{default_qt}`).\n\n' + f"{caller_name} needs to install a GUI library (default library is " + f"`{default_qt}`).\n\n" 'You can install it now or you can close (press "n") and install\n' - 'a compatible GUI library with one of ' - 'the following commands:\n\n' - f' * {pip_prefix} PyQt6==6.6.0 PyQt6-Qt6==6.6.0\n' - f' * {pip_prefix} PyQt5 (or `conda install pyqt`)\n' - f' * {pip_prefix} PySide2\n' - f' * {pip_prefix} PySide6\n\n' - f'Note: If `{default_qt}` installation fails, you could try installing any ' - 'of the other libraries.\n' + "a compatible GUI library with one of " + "the following commands:\n\n" + f" * {pip_prefix} PyQt6==6.6.0 PyQt6-Qt6==6.6.0\n" + f" * {pip_prefix} PyQt5 (or `conda install pyqt`)\n" + f" * {pip_prefix} PySide2\n" + f" * {pip_prefix} PySide6\n\n" + f"Note: If `{default_qt}` installation fails, you could try installing any " + "of the other libraries.\n" ) - print('-'*60) + print("-" * 60) print(txt) - pip_command = f'{pip_prefix} -U PyQt6==6.6.0 PyQt6-Qt6==6.6.0' + pip_command = f"{pip_prefix} -U PyQt6==6.6.0 PyQt6-Qt6==6.6.0" if is_mac_arm64: - commnad_txt = f'{conda_prefix} pyqt' - pkg_name = 'pyqt' + commnad_txt = f"{conda_prefix} pyqt" + pkg_name = "pyqt" else: commnad_txt = pip_command - pkg_name = 'PyQt6' + pkg_name = "PyQt6" while True: from .config import parser_args - if parser_args['yes']: - answer = 'y' + + if parser_args["yes"]: + answer = "y" else: answer = try_input_install_package(pkg_name, commnad_txt) - if answer.lower() == 'y' or not answer: + if answer.lower() == "y" or not answer: import subprocess + if is_mac_arm64 and is_conda_env(): - subprocess.check_call( - [f'{conda_prefix} pyqt'], shell=True - ) + subprocess.check_call([f"{conda_prefix} pyqt"], shell=True) else: - pip_args = pip_list + ['-U', 'PyQt6==6.6.0', 'PyQt6-Qt6==6.6.0'] + pip_args = pip_list + ["-U", "PyQt6==6.6.0", "PyQt6-Qt6==6.6.0"] subprocess.check_call(pip_args) warn_restart = True break - elif answer.lower() == 'n': + elif answer.lower() == "n": raise e else: print( f'"{answer}" is not a valid answer. ' 'Type "y" for "yes", or "n" for "no".' ) - + try: import pyqtgraph - version = pyqtgraph.__version__.split('.') + + version = pyqtgraph.__version__.split(".") pg_major, pg_minor, pg_patch = [int(val) for val in version] # if pg_major < 1: # raise ModuleNotFoundError('pyqtgraph must be upgraded') if pg_minor < 13: - raise ModuleNotFoundError('pyqtgraph must be upgraded') + raise ModuleNotFoundError("pyqtgraph must be upgraded") if pg_minor == 13 and pg_patch < 7: - raise ModuleNotFoundError('pyqtgraph must be upgraded') + raise ModuleNotFoundError("pyqtgraph must be upgraded") except ModuleNotFoundError: import subprocess + subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', '-U', 'pyqtgraph'] + [sys.executable, "-m", "pip", "install", "-U", "pyqtgraph"] ) warn_restart = True - + try: import seaborn except ModuleNotFoundError: import subprocess - subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', '-U', 'seaborn'] - ) + + subprocess.check_call([sys.executable, "-m", "pip", "install", "-U", "seaborn"]) warn_restart = True - + if not warn_restart: return warn_restart - + if not exit_at_end: return warn_restart - + _exit_on_setup(caller_name=caller_name) - + return warn_restart -def _exit_on_setup(caller_name='Cell-ACDC'): - print('*'*60) - note_text = ( - f'[NOTE]: {caller_name} had to install the required libraries. ' - ) + +def _exit_on_setup(caller_name="Cell-ACDC"): + print("*" * 60) + note_text = f"[NOTE]: {caller_name} had to install the required libraries. " note_text = ( - f'{note_text}' - 'Please, re-start the software. Thank you for your patience! ' + f"{note_text}Please, re-start the software. Thank you for your patience! " ) - + from .config import parser_args - if parser_args['yes']: + + if parser_args["yes"]: print(note_text) else: - note_text = ( - f'{note_text}' - '(Press any key to exit). ' - ) + note_text = f"{note_text}(Press any key to exit). " input(note_text) - + exit() - + + def download_model_params(): print("Downloading specified models...") from .config import parser_args - if parser_args['cpModelsDownload'] or parser_args['AllModelsDownload']: - print('[INFO]: Downloading Cellpose models...') + + if parser_args["cpModelsDownload"] or parser_args["AllModelsDownload"]: + print("[INFO]: Downloading Cellpose models...") from cellpose import models + model_names = ["cyto", "cyto2", "cyto3", "nuclei"] try: # download size model weights from cellpose.models import size_model_path, model_path + for model_name in model_names: - print(f'[INFO]: Downloading {model_name} model weights...') + print(f"[INFO]: Downloading {model_name} model weights...") try: size_model_path(model_name) model_path(model_name) except Exception as e: - print( - f'[WARNING]: Failed to download {model_name} model weights. ' - ) + print(f"[WARNING]: Failed to download {model_name} model weights. ") print(e) pass - + from cellpose.denoise import MODEL_NAMES + for model_name in MODEL_NAMES: - print(f'[INFO]: Downloading {model_name} model weights...') + print(f"[INFO]: Downloading {model_name} model weights...") try: model_path(model_name) except Exception as e: - print( - f'[WARNING]: Failed to download {model_name} model weights. ' - ) - if model_name in ["oneclick_per_cyto2", - "oneclick_seg_cyto2", - "oneclick_rec_cyto2", - "oneclick_per_nuclei", - "oneclick_seg_nuclei", - "oneclick_rec_nuclei"]: - print(f' This model is not available for download. ') + print(f"[WARNING]: Failed to download {model_name} model weights. ") + if model_name in [ + "oneclick_per_cyto2", + "oneclick_seg_cyto2", + "oneclick_rec_cyto2", + "oneclick_per_nuclei", + "oneclick_seg_nuclei", + "oneclick_rec_nuclei", + ]: + print(f" This model is not available for download. ") print(e) pass except Exception as e: - print( - '[WARNING]: Failed to download Cellpose model weights. ' - ) + print("[WARNING]: Failed to download Cellpose model weights. ") print(e) pass - if parser_args['StarDistModelsDownload'] or parser_args['AllModelsDownload']: - print('[INFO]: Downloading StarDist models...') + if parser_args["StarDistModelsDownload"] or parser_args["AllModelsDownload"]: + print("[INFO]: Downloading StarDist models...") try: from cellacdc.segmenters import STARDIST_MODELS from stardist.models import StarDist2D, StarDist3D + for model_type in [StarDist2D, StarDist3D]: for model_name in STARDIST_MODELS: - print(f'[INFO]: Downloading {model_name} model weights...') + print(f"[INFO]: Downloading {model_name} model weights...") try: model_type.from_pretrained(model_name) except Exception as e: print( - f'[WARNING]: Failed to download {model_name} model weights. ' + f"[WARNING]: Failed to download {model_name} model weights. " ) print(e) pass except Exception as e: - print( - '[WARNING]: Failed to download StarDist model weights. ' - ) + print("[WARNING]: Failed to download StarDist model weights. ") print(e) pass - if parser_args['YeaZModelsDownload'] or parser_args['AllModelsDownload']: - print('[INFO]: Downloading YeaZ models...') + if parser_args["YeaZModelsDownload"] or parser_args["AllModelsDownload"]: + print("[INFO]: Downloading YeaZ models...") from cellacdc.myutils import _download_yeaz_models + try: _download_yeaz_models() except Exception as e: - print( - '[WARNING]: Failed to download YeaZ model weights. ' - ) + print("[WARNING]: Failed to download YeaZ model weights. ") print(e) pass - if parser_args['DeepSeaModelsDownload'] or parser_args['AllModelsDownload']: - print('[INFO]: Downloading DeepSea models...') + if parser_args["DeepSeaModelsDownload"] or parser_args["AllModelsDownload"]: + print("[INFO]: Downloading DeepSea models...") from cellacdc.myutils import _download_deepsea_models + try: _download_deepsea_models() except Exception as e: - print( - '[WARNING]: Failed to download DeepSea model weights. ' - ) + print("[WARNING]: Failed to download DeepSea model weights. ") print(e) pass - if parser_args['TrackastraModelsDownload'] or parser_args['AllModelsDownload']: - print('[INFO]: Downloading TrackAstra models...') + if parser_args["TrackastraModelsDownload"] or parser_args["AllModelsDownload"]: + print("[INFO]: Downloading TrackAstra models...") # from cellacdc.myutils import _download_trackastra_models from trackastra.model import Trackastra + try: from cellacdc.trackers.Trackastra import get_pretrained_model_names + model_names = get_pretrained_model_names() for model_name in model_names: - print(f'[INFO]: Downloading {model_name} model weights...') + print(f"[INFO]: Downloading {model_name} model weights...") try: Trackastra.from_pretrained(model_name) except Exception as e: - print( - f'[WARNING]: Failed to download {model_name} model weights. ' - ) + print(f"[WARNING]: Failed to download {model_name} model weights. ") print(e) pass except Exception as e: - print( - '[WARNING]: Failed to download TrackAstra model weights. ' - ) + print("[WARNING]: Failed to download TrackAstra model weights. ") print(e) pass - + + def _setup_app(splashscreen=False, icon_path=None, logo_path=None, scheme=None): from qtpy import QtCore + if QtCore.QCoreApplication.instance() is not None: return QtCore.QCoreApplication.instance(), None - + from qtpy import QtWidgets + # Handle high resolution displays: - if hasattr(QtCore.Qt, 'AA_EnableHighDpiScaling'): + if hasattr(QtCore.Qt, "AA_EnableHighDpiScaling"): QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True) - if hasattr(QtCore.Qt, 'AA_UseHighDpiPixmaps'): + if hasattr(QtCore.Qt, "AA_UseHighDpiPixmaps"): QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps, True) - if hasattr(QtCore.Qt, 'AA_PluginApplication'): + if hasattr(QtCore.Qt, "AA_PluginApplication"): QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_PluginApplication, False) # Check OS dark or light mode from qtpy.QtWidgets import QApplication, QStyleFactory from qtpy.QtGui import QPalette, QIcon from . import settings_csv_path, resources_folderpath, is_linux - - app = QApplication(['Cell-ACDC']) + + app = QApplication(["Cell-ACDC"]) app.setApplicationName("Cell-ACDC") - app.setStyle(QStyleFactory.create('Fusion')) + app.setStyle(QStyleFactory.create("Fusion")) is_OS_dark_mode = app.palette().color(QPalette.Window).getHsl()[2] < 100 app.toggle_dark_mode = False if is_OS_dark_mode: - # Switch to dark mode if scheme was never selected by user and OS is + # Switch to dark mode if scheme was never selected by user and OS is # dark mode import pandas as pd - df_settings = pd.read_csv(settings_csv_path, index_col='setting') - if 'colorScheme' not in df_settings.index: + + df_settings = pd.read_csv(settings_csv_path, index_col="setting") + if "colorScheme" not in df_settings.index: app.toggle_dark_mode = True - + if icon_path is None: - icon_path = os.path.join(resources_folderpath, 'icon_v2.ico') + icon_path = os.path.join(resources_folderpath, "icon_v2.ico") app.setWindowIcon(QIcon(icon_path)) if is_linux: app.setDesktopFileName("cell-acdc") - + if logo_path is None: - logo_path = os.path.join(resources_folderpath, 'logo_v2.png') - + logo_path = os.path.join(resources_folderpath, "logo_v2.png") + from qtpy import QtWidgets, QtGui splashScreen = None if splashscreen: + class SplashScreen(QtWidgets.QSplashScreen): def __init__(self, logo_path, icon_path): super().__init__() pixmap = QtGui.QPixmap(logo_path) - pixmap = pixmap.scaledToWidth( - 300, QtCore.Qt.SmoothTransformation - ) + pixmap = pixmap.scaledToWidth(300, QtCore.Qt.SmoothTransformation) self.setPixmap(pixmap) self.setWindowIcon(QIcon(icon_path)) self.setWindowFlags( - QtCore.Qt.WindowStaysOnTopHint - | QtCore.Qt.SplashScreen + QtCore.Qt.WindowStaysOnTopHint + | QtCore.Qt.SplashScreen | QtCore.Qt.FramelessWindowHint ) - + def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None: pass - + def showEvent(self, event): self.raise_() - + # Launch splashscreen splashScreen = SplashScreen(logo_path, icon_path) - splashScreen.show() - + splashScreen.show() + from ._palettes import getPaletteColorScheme, setToolTipStyleSheet from ._palettes import get_color_scheme from . import qrc_resources_path from . import acdc_qrc_resources from . import printl - + # Check if there are new icons --> replace qrc_resources.py if scheme is None: scheme = get_color_scheme() - if scheme == 'light': + if scheme == "light": from . import qrc_resources_light_path as qrc_resources_scheme_path - qrc_resources_scheme = import_module('cellacdc.qrc_resources_light') + + qrc_resources_scheme = import_module("cellacdc.qrc_resources_light") qrc_resource_data_scheme = qrc_resources_scheme.qt_resource_data else: from . import qrc_resources_dark_path as qrc_resources_scheme_path - qrc_resources_scheme = import_module('cellacdc.qrc_resources_dark') + + qrc_resources_scheme = import_module("cellacdc.qrc_resources_dark") qrc_resource_data_scheme = qrc_resources_scheme.qt_resource_data - + qrc_resource_version_required = 1 try: qrc_resource_version_required = qrc_resources_scheme.version except Exception as err: pass - + current_qrc_resource_version = 1 try: current_qrc_resource_version = acdc_qrc_resources.version except Exception as err: pass - + is_copy_qrc_required = ( qrc_resource_data_scheme != acdc_qrc_resources.qt_resource_data or qrc_resource_version_required != current_qrc_resource_version ) - + if is_copy_qrc_required: from . import _copy_qrc_resources_file, _warnings, qrc_resources_path - _copy_qrc_resources_file( - qrc_resources_scheme_path, qrc_resources_path - ) + + _copy_qrc_resources_file(qrc_resources_scheme_path, qrc_resources_path) _warnings.warnRestartAcdcIconsUpdated() exit() - + from . import load + scheme = get_color_scheme() palette = getPaletteColorScheme(app.palette(), scheme=scheme) - app.setPalette(palette) + app.setPalette(palette) # load.rename_qrc_resources_file(scheme) # setToolTipStyleSheet(app, scheme=scheme) - + return app, splashScreen + def run_segm_workflow(workflow_params, logger, log_path): - logger.info('Initializing segmentation and tracking kernel...') + logger.info("Initializing segmentation and tracking kernel...") from cellacdc import cli + kernel = cli.SegmKernel(logger, log_path, is_cli=True) kernel.init_args_from_params(workflow_params, logger.info) ch_filepaths = kernel.parse_paths(workflow_params) @@ -572,138 +586,138 @@ def run_segm_workflow(workflow_params, logger, log_path): pbar.update() pbar.close() + def run_measurements_workflow(workflow_params, logger, log_path): - logger.info('Initializing measurements kernel...') + logger.info("Initializing measurements kernel...") from cellacdc import cli + kernel = cli.ComputeMeasurementsKernel(logger, log_path, is_cli=True) ch_filepaths = kernel.parse_paths(workflow_params) stop_frame_nums = kernel.parse_stop_frame_numbers(workflow_params) - end_filename_segm = workflow_params['measurements']['end_filename_segm'] - kernel.set_metrics_from_workflow_config_params( - workflow_params['measurements'] - ) + end_filename_segm = workflow_params["measurements"]["end_filename_segm"] + kernel.set_metrics_from_workflow_config_params(workflow_params["measurements"]) pbar = tqdm(total=len(ch_filepaths), ncols=100) for ch_filepath, stop_frame_n in zip(ch_filepaths, stop_frame_nums): logger.info(f'\nProcessing "{ch_filepath}"...') kernel.run( - img_path=ch_filepath, - stop_frame_n=stop_frame_n, + img_path=ch_filepath, + stop_frame_n=stop_frame_n, end_filename_segm=end_filename_segm, ) pbar.update() pbar.close() + def run_cli(ini_filepath): from cellacdc import myutils + logger, logs_path, log_path, log_filename = myutils.setupLogger( - module='cli', logs_path=None + module="cli", logs_path=None ) - + download_model_params() - + logger.info(f'Reading workflow file "{ini_filepath}"...') from cellacdc import load + workflow_params = load.read_segm_workflow_from_config(ini_filepath) - workflow_type = workflow_params['workflow']['type'] - - if workflow_type == 'segmentation and/or tracking': + workflow_type = workflow_params["workflow"]["type"] + + if workflow_type == "segmentation and/or tracking": run_segm_workflow(workflow_params, logger, log_path) - - if 'measurements' in workflow_params.keys(): - logger.info('Loading measurements workflow...') - meas_workflow_params = load.read_measurements_workflow_from_config( - ini_filepath - ) + + if "measurements" in workflow_params.keys(): + logger.info("Loading measurements workflow...") + meas_workflow_params = load.read_measurements_workflow_from_config(ini_filepath) run_measurements_workflow(meas_workflow_params, logger, log_path) - - logger.info('**********************************************') - logger.info(f'Cell-ACDC command-line closed. {myutils.get_salute_string()}') - logger.info('**********************************************') - - -def _setup_numpy(caller_name='Cell-ACDC'): + + logger.info("**********************************************") + logger.info(f"Cell-ACDC command-line closed. {myutils.get_salute_string()}") + logger.info("**********************************************") + + +def _setup_numpy(caller_name="Cell-ACDC"): import urllib.request import json import re - + from . import try_input_install_package - + numpy_versions = [] url = "https://pypi.org/pypi/numba/json" try: with urllib.request.urlopen(url) as response: data = json.load(response) requires_dist = data["info"].get("requires_dist", []) - numpy_versions = [ - req for req in requires_dist if "numpy" in req.lower() - ] + numpy_versions = [req for req in requires_dist if "numpy" in req.lower()] except urllib.error.URLError as e: print(f"Could not update np: {e}") return - + if not numpy_versions: print( - f'[WARNING]: Could not find NumPy version requirements for Numba. ' - 'Please, install the latest version of NumPy manually.' + f"[WARNING]: Could not find NumPy version requirements for Numba. " + "Please, install the latest version of NumPy manually." ) return - + numpy_versions_txt = numpy_versions[0] - - max_version = re.findall(r'<=?(\d+\.\d+)', numpy_versions_txt) - min_version = re.findall(r'>=?(\d+\.\d+)', numpy_versions_txt) + + max_version = re.findall(r"<=?(\d+\.\d+)", numpy_versions_txt) + min_version = re.findall(r">=?(\d+\.\d+)", numpy_versions_txt) if max_version: max_version = max_version[0] else: - max_version = '' - + max_version = "" + if min_version: min_version = min_version[0] else: - min_version = '' - + min_version = "" + import numpy + installed_numpy_version = numpy.__version__ is_numpy_version_within_range = myutils.is_pkg_version_within_range( - installed_numpy_version, - min_version=min_version, - max_version=max_version + installed_numpy_version, min_version=min_version, max_version=max_version ) - + if is_numpy_version_within_range: return - + conda_prefix, pip_prefix = myutils.get_pip_conda_prefix() conda_list, pip_list = myutils.get_pip_conda_prefix(list_return=True) command_txt = f'{pip_prefix} --upgrade "{numpy_versions_txt}"' - + txt = ( - f'{caller_name} needs to upgrade the package `numpy`.\n\n' - f'The current version is {installed_numpy_version}, but it needs to be ' - f'between {min_version} and {max_version}.\n\n' - f'You can let {caller_name} install it now, or you can abort ' - f'and install it manually with the command `{command_txt}`.' + f"{caller_name} needs to upgrade the package `numpy`.\n\n" + f"The current version is {installed_numpy_version}, but it needs to be " + f"between {min_version} and {max_version}.\n\n" + f"You can let {caller_name} install it now, or you can abort " + f"and install it manually with the command `{command_txt}`." ) - print('-'*60) + print("-" * 60) print(txt) - + while True: from .config import parser_args - if parser_args['yes']: - answer = 'y' + + if parser_args["yes"]: + answer = "y" else: - answer = try_input_install_package('qtpy', command_txt) - - if answer.lower() == 'y' or not answer: + answer = try_input_install_package("qtpy", command_txt) + + if answer.lower() == "y" or not answer: import subprocess - cmd = pip_list + ['-U', numpy_versions_txt] + + cmd = pip_list + ["-U", numpy_versions_txt] subprocess.check_call(cmd) break - elif answer.lower() == 'n': - raise ModuleNotFoundError(f'Numba requires {numpy_versions_txt} ') + elif answer.lower() == "n": + raise ModuleNotFoundError(f"Numba requires {numpy_versions_txt} ") else: print( f'"{answer}" is not a valid answer. ' 'Type "y" for "yes", or "n" for "no".' - ) \ No newline at end of file + ) diff --git a/cellacdc/_types.py b/cellacdc/_types.py index 5e0894523..b2d0dd6dd 100644 --- a/cellacdc/_types.py +++ b/cellacdc/_types.py @@ -3,67 +3,78 @@ from typing import Union, Tuple, Any, List import numpy as np + class NotGUIParam: not_a_param = True + ChannelsDict = dict[str, List[np.ndarray]] + class RescaleIntensitiesInRangeHow: - values = ['percentage', 'image', 'absolute'] + values = ["percentage", "image", "absolute"] + class BaSiCpyResizeModes: - values = ['jax', 'skimage', 'skimage_dask'] + values = ["jax", "skimage", "skimage_dask"] + class BaSiCpyFittingModes: - values = ['ladmap', 'approximate'] + values = ["ladmap", "approximate"] + class BaSiCpyTimelapse: values = ["True", "False", "additive", "multiplicative"] + class Vector: - """Class used to define model parameter as a vector that will use the + """Class used to define model parameter as a vector that will use the cellacdc.widgets.VectorLineEdit widget in the automatic GUI. """ + @staticmethod def cast_dtype(value: Any) -> Union[Tuple[float], int, float]: if isinstance(value, str): - value = value.lstrip('(').rstrip(')') - value = value.lstrip('[').rstrip(']') - values = value.split(',') + value = value.lstrip("(").rstrip(")") + value = value.lstrip("[").rstrip("]") + values = value.split(",") values = tuple([float(val) for val in values]) return values elif isinstance(value, (int, float)): return value - - raise TypeError(f'Could not convert {value} {(type(value))} to Vector') - + + raise TypeError(f"Could not convert {value} {(type(value))} to Vector") + def __call__(self, value: Any) -> Union[Tuple[float], int, float]: return self.cast_dtype(value) - + + class FolderPath: - """Class used to define model parameter as a folder path control with a + """Class used to define model parameter as a folder path control with a browse button to select a folder in the automatic GUI. """ + def cast_dtype(self, value: Any) -> Union[Tuple[float], int, float]: return str(value) - + def __call__(self, value: Any) -> str: return self.cast_dtype(value) + class SecondChannelImage: pass + def is_optional(field): - return ( - typing.get_origin(field) is Union and - type(None) in typing.get_args(field) - ) + return typing.get_origin(field) is Union and type(None) in typing.get_args(field) + def is_second_channel_type(field): if is_optional(field): field = typing.get_args(field)[0] - - return getattr(field, '__name__', None) == 'SecondChannelImage' # avoid union + + return getattr(field, "__name__", None) == "SecondChannelImage" # avoid union + def is_widget_not_required(ArgSpec): try: @@ -71,21 +82,22 @@ def is_widget_not_required(ArgSpec): return True except Exception as err: pass - + try: - # If a parameter if None, python initializes it to + # If a parameter if None, python initializes it to # typing.Optional and we need to access the first type ArgSpec.type.__args__[0]().not_a_param return True except Exception as err: pass - + return False + def to_str(*args): if len(args) == 2: value = args[1] else: value = args[0] - - return str(value) \ No newline at end of file + + return str(value) diff --git a/cellacdc/_view_all_buttons.py b/cellacdc/_view_all_buttons.py index 3428725a4..d51b0700a 100644 --- a/cellacdc/_view_all_buttons.py +++ b/cellacdc/_view_all_buttons.py @@ -1,13 +1,17 @@ import sys -SCHEME = 'dark' +SCHEME = "dark" FLAT = False from qtpy.QtGui import QIcon from qtpy.QtCore import Qt, QSize from qtpy.QtWidgets import ( - QApplication, QPushButton, QStyleFactory, QWidget, QGridLayout, - QCheckBox + QApplication, + QPushButton, + QStyleFactory, + QWidget, + QGridLayout, + QCheckBox, ) from cellacdc import widgets, _run @@ -21,9 +25,9 @@ # Distribute icons over a 16:9 grid nicons = len(buttons_names) -ncols = round((nicons / 16*9)**(1/2)) +ncols = round((nicons / 16 * 9) ** (1 / 2)) nrows = nicons // ncols -left_nicons = nicons % ncols +left_nicons = nicons % ncols if left_nicons > 0: nrows += 1 @@ -52,19 +56,21 @@ max_height = max([button.sizeHint().height() for button in buttons]) for button in buttons: - button.setMinimumHeight(max_height*2) + button.setMinimumHeight(max_height * 2) + def setDisabled(checked): for button in buttons: button.setDisabled(checked) - -checkbox = QCheckBox('Disable buttons') + + +checkbox = QCheckBox("Disable buttons") checkbox.toggled.connect(setDisabled) -layout.addWidget(checkbox, i, j+1) +layout.addWidget(checkbox, i, j + 1) -layout.setRowStretch(i+1, 1) -layout.setColumnStretch(j+2, 1) +layout.setRowStretch(i + 1, 1) +layout.setColumnStretch(j + 2, 1) splashScreen.close() win.show() -app.exec_() \ No newline at end of file +app.exec_() diff --git a/cellacdc/_view_all_icons.py b/cellacdc/_view_all_icons.py index eca265fea..569480eb9 100644 --- a/cellacdc/_view_all_icons.py +++ b/cellacdc/_view_all_icons.py @@ -2,14 +2,18 @@ import os import shutil -SCHEME = 'dark' +SCHEME = "dark" FLAT = True from qtpy.QtGui import QIcon from qtpy.QtCore import Qt, QSize from qtpy.QtWidgets import ( - QApplication, QPushButton, QStyleFactory, QWidget, QGridLayout, - QCheckBox + QApplication, + QPushButton, + QStyleFactory, + QWidget, + QGridLayout, + QCheckBox, ) from cellacdc import _run @@ -22,13 +26,13 @@ # Distribute icons over a 16:9 grid nicons = len(svg_aliases) -ncols = round((nicons / 16*9)**(1/2)) +ncols = round((nicons / 16 * 9) ** (1 / 2)) nrows = nicons // ncols -left_nicons = nicons % ncols +left_nicons = nicons % ncols if left_nicons > 0: nrows += 1 -if hasattr(Qt, 'AA_UseHighDpiPixmaps'): +if hasattr(Qt, "AA_UseHighDpiPixmaps"): QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) win = QWidget() @@ -42,10 +46,10 @@ if idx == nicons: break alias = svg_aliases[idx] - icon = QIcon(f':{alias}') + icon = QIcon(f":{alias}") button = QPushButton(alias) button.setIcon(icon) - button.setIconSize(QSize(32,32)) + button.setIconSize(QSize(32, 32)) button.setCheckable(True) if FLAT: button.setFlat(True) @@ -53,14 +57,16 @@ buttons.append(button) idx += 1 + def setDisabled(checked): for button in buttons: button.setDisabled(checked) - -checkbox = QCheckBox('Disable buttons') + + +checkbox = QCheckBox("Disable buttons") checkbox.toggled.connect(setDisabled) -layout.addWidget(checkbox, i, j+1) +layout.addWidget(checkbox, i, j + 1) splashScreen.close() win.showMaximized() -app.exec_() \ No newline at end of file +app.exec_() diff --git a/cellacdc/_warnings.py b/cellacdc/_warnings.py index e313b3472..fc61e1098 100644 --- a/cellacdc/_warnings.py +++ b/cellacdc/_warnings.py @@ -8,10 +8,12 @@ from . import urls from . import error_below, error_close + def warnTooManyItems(mainWin, numItems, qparent): from . import widgets + mainWin.logger.info( - '[WARNING]: asking user what to do with too many graphical items...' + "[WARNING]: asking user what to do with too many graphical items..." ) msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(f""" @@ -24,118 +26,111 @@ def warnTooManyItems(mainWin, numItems, qparent): """) _, stayHighResButton, switchToLowResButton = msg.warning( - qparent, 'Too many objects', txt, + qparent, + "Too many objects", + txt, buttonsTexts=( - 'Cancel', 'Stay on high resolution', - widgets.reloadPushButton(' Switch to low resolution ') - ) + "Cancel", + "Stay on high resolution", + widgets.reloadPushButton(" Switch to low resolution "), + ), ) - return msg.cancel, msg.clickedButton==switchToLowResButton + return msg.cancel, msg.clickedButton == switchToLowResButton -def warnRestartCellACDCcolorModeToggled( - scheme, app_name='Cell-ACDC', parent=None - ): + +def warnRestartCellACDCcolorModeToggled(scheme, app_name="Cell-ACDC", parent=None): from . import widgets + msg = widgets.myMessageBox(wrapText=False) - txt = ( - 'In order for the change to take effect, ' - f'please restart {app_name}' - ) - if scheme == 'dark': + txt = f"In order for the change to take effect, please restart {app_name}" + if scheme == "dark": issues_href = f'GitHub page' note_txt = ( - 'NOTE: dark mode is a recent feature so if you see ' - 'if you see anything odd,
' - 'please, report it by opening an issue ' - f'on our {issues_href}.

' - 'Thanks!' + "NOTE: dark mode is a recent feature so if you see " + "if you see anything odd,
" + "please, report it by opening an issue " + f"on our {issues_href}.

" + "Thanks!" ) - txt = f'{txt}

{note_txt}' + txt = f"{txt}

{note_txt}" txt = html_utils.paragraph(txt) - msg.information(parent, f'Restart {app_name}', txt) + msg.information(parent, f"Restart {app_name}", txt) + class DataTypeWarning(RuntimeWarning): def __init__(self, message): self._message = message - + def __str__(self): return repr(self._message) + def warn_image_overflow_dtype(input_dtype, max_value, inferred_dtype): import warnings + warnings.warn( - f'The input image has data type {input_dtype}. Since it is neither ' - f'8-bit, 16-bit, nor 32-bit the data was inferred as {inferred_dtype} ' - f'from the max value of the image of {max_value}.', - DataTypeWarning + f"The input image has data type {input_dtype}. Since it is neither " + f"8-bit, 16-bit, nor 32-bit the data was inferred as {inferred_dtype} " + f"from the max value of the image of {max_value}.", + DataTypeWarning, ) + def warn_cca_integrity(txt, category, qparent, go_to_frame_callback=None): from . import widgets from qtpy.QtWidgets import QCheckBox - + preamble = html_utils.paragraph( - 'WARNING: integrity of cell cycle annotations ' - 'might be compromised:' + "WARNING: integrity of cell cycle annotations " + "might be compromised:" ) - - msg_text = f'{preamble}{txt}' - - stopSpecificMessageCheckbox = QCheckBox( - 'Stop warning with this specific message' - ) - stopCategoryCheckbox = QCheckBox( - f'Stop warning about "{category}"' - ) - disableAllWarningsCheckbox = QCheckBox( - 'Disable all warnings' - ) - + + msg_text = f"{preamble}{txt}" + + stopSpecificMessageCheckbox = QCheckBox("Stop warning with this specific message") + stopCategoryCheckbox = QCheckBox(f'Stop warning about "{category}"') + disableAllWarningsCheckbox = QCheckBox("Disable all warnings") + checkboxes = ( - stopSpecificMessageCheckbox, - stopCategoryCheckbox, - disableAllWarningsCheckbox + stopSpecificMessageCheckbox, + stopCategoryCheckbox, + disableAllWarningsCheckbox, ) - + msg = widgets.myMessageBox(wrapText=False) - if go_to_frame_callback is not None and txt.find('At frame n.') != -1: - frame_n = re.findall(r'At frame n. (\d+)', txt)[0] - goToFrameButton = widgets.NavigatePushButton(f'Go to frame n. {frame_n}') + if go_to_frame_callback is not None and txt.find("At frame n.") != -1: + frame_n = re.findall(r"At frame n. (\d+)", txt)[0] + goToFrameButton = widgets.NavigatePushButton(f"Go to frame n. {frame_n}") goToFrameButton = msg.addButton(goToFrameButton) goToFrameButton.disconnect() - goToFrameButton.clicked.connect( - partial(go_to_frame_callback, int(frame_n)) - ) - - msg.warning( - qparent, 'Annotations integrity warning', msg_text, - widgets=checkboxes - ) - + goToFrameButton.clicked.connect(partial(go_to_frame_callback, int(frame_n))) + + msg.warning(qparent, "Annotations integrity warning", msg_text, widgets=checkboxes) + if stopSpecificMessageCheckbox.isChecked(): return txt - + if stopCategoryCheckbox.isChecked(): return category - + if disableAllWarningsCheckbox.isChecked(): - return 'disable_all' - - return '' + return "disable_all" + + return "" + -def warn_installing_different_cellpose_version( - requested_version, installed_version - ): +def warn_installing_different_cellpose_version(requested_version, installed_version): from cellacdc import widgets + if not myutils.is_gui_running(): print( - f'[WARNING]: You requested to install `Cellpose {requested_version}` ' - f'but you already have `Cellpose {installed_version}`.\n\n' - f'If you proceed, Cell-ACDC will *uninstall* `{installed_version}` ' - f'and will install `{requested_version}`.' + f"[WARNING]: You requested to install `Cellpose {requested_version}` " + f"but you already have `Cellpose {installed_version}`.\n\n" + f"If you proceed, Cell-ACDC will *uninstall* `{installed_version}` " + f"and will install `{requested_version}`." ) return False - + note_text = """ You can still proceed and let Cell-ACDC take care of uninstalling/installing the right versions every time you request it. @@ -153,15 +148,14 @@ def warn_installing_different_cellpose_version( {html_utils.to_note(note_text)} """) msg = widgets.myMessageBox(wrapText=False) - msg.warning( - None, 'Cellpose already installed', txt, - buttonsTexts=('Cancel', 'Ok') - ) + msg.warning(None, "Cellpose already installed", txt, buttonsTexts=("Cancel", "Ok")) return msg.cancel + def warn_download_bioformats_jar_failed(jar_dst_filepath, qparent=None): from cellacdc import widgets - href = html_utils.href_tag('here', urls.bioformats_download_page) + + href = html_utils.href_tag("here", urls.bioformats_download_page) txt = html_utils.paragraph(f""" [WARNING]: Download of bioformats_package.jar failed.

@@ -170,26 +164,34 @@ def warn_download_bioformats_jar_failed(jar_dst_filepath, qparent=None): """) msg = widgets.myMessageBox(wrapText=False) msg.warning( - qparent, 'Download of bioformats failed', txt, - commands=(jar_dst_filepath,), - path_to_browse=os.path.dirname(jar_dst_filepath) + qparent, + "Download of bioformats failed", + txt, + commands=(jar_dst_filepath,), + path_to_browse=os.path.dirname(jar_dst_filepath), ) return msg.cancel + def warn_segment_for_lost_IDs_first_frame(qparent=None): from cellacdc import widgets + txt = html_utils.paragraph(f""" The segmentation for lost IDs is not available on the first frame.

Thank you for your patience! """) msg = widgets.myMessageBox(wrapText=False) msg.warning( - qparent, 'Not available on first frame', txt, + qparent, + "Not available on first frame", + txt, ) return msg.cancel + def warnPromptSegmentPointsLayerNotInit(qparent=None): from cellacdc import widgets + txt = html_utils.paragraph(f""" The points layer was not initialized!

To initialize it, please, deactivate and reactivate the @@ -198,12 +200,16 @@ def warnPromptSegmentPointsLayerNotInit(qparent=None): """) msg = widgets.myMessageBox(wrapText=False) msg.warning( - qparent, 'Points layer not initialized', txt, + qparent, + "Points layer not initialized", + txt, ) return msg.cancel + def warnPromptSegmentModelNotInit(qparent=None): from cellacdc import widgets + txt = html_utils.paragraph(f""" Promptable model was not initialized!

To initialize it, please, click on the {numNewCells} new object(s)
will ' - 'appear (highlighted in green on left image).

' - - f'However, in the previous frame (frame n. {frame_i}) there are ' - f'{G1_text} in G1 available.

' - - 'Note that cells must be in G1 in the previous frame too, ' - 'because if they are in G1
' - 'only at current frame, assigning a bud to it would result in no ' - 'G1 phase at all between current
' - 'and previous cell cycle.
' - - 'You can either cancel the operation and annotate division on previous ' - 'frames or continue.

' - - 'If you continue the new cell will be annotated as a ' - 'cell in G1 with unknown history.

' - - 'Do you want to continue?
' + f"In the next frame {numNewCells} new object(s) will " + "appear (highlighted in green on left image).

" + f"However, in the previous frame (frame n. {frame_i}) there are " + f"{G1_text} in G1 available.

" + "Note that cells must be in G1 in the previous frame too, " + "because if they are in G1
" + "only at current frame, assigning a bud to it would result in no " + "G1 phase at all between current
" + "and previous cell cycle.
" + "You can either cancel the operation and annotate division on previous " + "frames or continue.

" + "If you continue the new cell will be annotated as a " + "cell in G1 with unknown history.

" + "Do you want to continue?
" ) - + msg = widgets.myMessageBox(wrapText=False) _, yesButton = msg.warning( - qparent, 'No cells in G1!', text, - buttonsTexts=('Cancel', 'Continue anyway (new cells will start in G1)') + qparent, + "No cells in G1!", + text, + buttonsTexts=("Cancel", "Continue anyway (new cells will start in G1)"), ) return msg.clickedButton == yesButton - + def log_pytorch_not_installed(): print(error_below) print( - 'PyTorch is not installed. See here how to install it ' - f'{urls.install_pytorch}' + f"PyTorch is not installed. See here how to install it {urls.install_pytorch}" ) print(error_close) + def warnExportToVideo(qparent=None): from cellacdc import widgets + txt = html_utils.paragraph(f""" Exporting to video will start now.

During this process, the GUI will automatically update the images @@ -271,16 +279,17 @@ def warnExportToVideo(qparent=None): """) msg = widgets.myMessageBox(wrapText=False) msg.warning( - qparent, 'Export to video is starting', txt, - buttonsTexts=('Cancel', 'Ok') + qparent, "Export to video is starting", txt, buttonsTexts=("Cancel", "Ok") ) return msg.cancel + def warnDivisionAnnotationCannotBeUndone(ID, relID, issue_frame_i, qparent=None): from cellacdc import widgets + txt = html_utils.paragraph(f""" Cell division annotation cannot be undone because Cell ID {relID} - is in 'S' phase at frame n. {issue_frame_i+1}.

+ is in 'S' phase at frame n. {issue_frame_i + 1}.

By undoing division annotation, Cell ID {relID} would be restored as relative of Cell ID {ID}, but this cannot be done.

The only solution is to go to frame n. {issue_frame_i} and reset the @@ -288,13 +297,13 @@ def warnDivisionAnnotationCannotBeUndone(ID, relID, issue_frame_i, qparent=None) Thank you for your patience! """) msg = widgets.myMessageBox(wrapText=False) - msg.warning( - qparent, 'Division annotation cannot be undone', txt - ) + msg.warning(qparent, "Division annotation cannot be undone", txt) return msg.cancel + def warnCannotAddRemovePointsProjection(qparent=None): from cellacdc import widgets + txt = html_utils.paragraph(f""" Points cannot be added or removed in a projection!

Please, switch to "single z-slice" mode (bottom of the image on @@ -302,56 +311,56 @@ def warnCannotAddRemovePointsProjection(qparent=None): Thank you for your patience. """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(qparent, 'WARNING: Editing points in projection', txt) + msg.warning(qparent, "WARNING: Editing points in projection", txt) + def warnRestartAcdcIconsUpdated(qparent=None): from cellacdc import widgets + txt = ( - 'Cell-ACDC had to update the GUI icons. ' - 'Please re-start the application.\n\n' - 'Thank you for your patience!' + "Cell-ACDC had to update the GUI icons. " + "Please re-start the application.\n\n" + "Thank you for your patience!" ) - print('*'*100) + print("*" * 100) print(txt) - print('^'*100) - html_txt = html_utils.paragraph(txt.replace('\n', '
')) + print("^" * 100) + html_txt = html_utils.paragraph(txt.replace("\n", "
")) msg = widgets.myMessageBox(wrapText=False) - msg.information(qparent, 'GUI icons updated', txt) + msg.information(qparent, "GUI icons updated", txt) + def warnMissingCca(missing_cca_items, qparent=None): from cellacdc import widgets, printl + mainText = html_utils.paragraph(f""" Some objects have missing cell cycle annotations!

Please, fix them before saving again, thanks!

See below the list of object IDs without annotations. """) - + details_txt_list = [] for cca_df, posData, frame_i in missing_cca_items: - txt = ( - f'{posData.pos_foldername}:

' - ) - indent = '  ' + txt = f"{posData.pos_foldername}:

" + indent = "  " if frame_i is not None: - txt = (f'{txt}' - f' - Frame n. {frame_i+1}
:
' - ) - indent = '    ' + txt = f"{txt} - Frame n. {frame_i + 1}
:
" + indent = "    " missing_IDs = cca_df[cca_df.isnull().any(axis=1)].index.to_list() for missing_ID in missing_IDs: - txt = (f'{txt}' - f'{indent}* ID: {missing_ID}
' - ) - + txt = f"{txt}{indent}* ID: {missing_ID}
" + details_txt_list.append(txt) - - detailsText = '
'.join(details_txt_list) + + detailsText = "
".join(details_txt_list) msg = widgets.myMessageBox(wrapText=False) _, ignoreButton = msg.warning( - qparent, 'Missing cell cycle annotations', mainText, + qparent, + "Missing cell cycle annotations", + mainText, detailsText, - buttonsTexts=('Cancel', 'Ignore'), + buttonsTexts=("Cancel", "Ignore"), add_do_not_show_again_checkbox=True, ) doNotShowAgain = msg.doNotShowAgainCheckbox.isChecked() - return msg.clickedButton == ignoreButton, doNotShowAgain \ No newline at end of file + return msg.clickedButton == ignoreButton, doNotShowAgain diff --git a/cellacdc/acdc_bioio_bioformats/__init__.py b/cellacdc/acdc_bioio_bioformats/__init__.py index e248fa8a2..cbabf0854 100644 --- a/cellacdc/acdc_bioio_bioformats/__init__.py +++ b/cellacdc/acdc_bioio_bioformats/__init__.py @@ -5,35 +5,33 @@ conda_prefix = os.environ.get("CONDA_PREFIX") if conda_prefix is not None: if is_win64: - os.environ["JAVA_HOME"] = rf'{conda_prefix}\Library' + os.environ["JAVA_HOME"] = rf"{conda_prefix}\Library" else: os.environ["JAVA_HOME"] = conda_prefix - - print('Setting JAVA_HOME:', os.environ["JAVA_HOME"]) + + print("Setting JAVA_HOME:", os.environ["JAVA_HOME"]) EXTENSION_PACKAGE_MAPPER = { - '.czi': 'bioio-czi', - '.dv': 'bioio-dv', - '.r3d': 'bioio-dv', - '.lif': 'bioio-lif', - '.nd2': 'bioio-nd2', - '.tif': 'bioio-tifffile', - '.tiff': 'bioio-tifffile', - '.ome.tiff': 'bioio-ome-tiff', - '.zarr': 'bioio-ome-zarr', - '.sldy': 'bioio-sldy', - '.dir': 'bioio-sldy', + ".czi": "bioio-czi", + ".dv": "bioio-dv", + ".r3d": "bioio-dv", + ".lif": "bioio-lif", + ".nd2": "bioio-nd2", + ".tif": "bioio-tifffile", + ".tiff": "bioio-tifffile", + ".ome.tiff": "bioio-ome-tiff", + ".zarr": "bioio-ome-zarr", + ".sldy": "bioio-sldy", + ".dir": "bioio-sldy", } EXTENSION_BIOIMAGE_KWARGS_MAPPER = { - '.czi': {'use_aicspylibczi': True}, + ".czi": {"use_aicspylibczi": True}, } EXTENSION_METADATA_ATTR_MAPPER = { - '.czi': { - 'TimeIncrement': 'standard_metadata.timelapse_interval.total_seconds()' - } + ".czi": {"TimeIncrement": "standard_metadata.timelapse_interval.total_seconds()"} } from .reader import ImageReader, get_omexml_metadata, OMEXML, Metadata -from . import _utils \ No newline at end of file +from . import _utils diff --git a/cellacdc/acdc_bioio_bioformats/_init_reader.py b/cellacdc/acdc_bioio_bioformats/_init_reader.py index ae79af9e5..f5ca2e710 100644 --- a/cellacdc/acdc_bioio_bioformats/_init_reader.py +++ b/cellacdc/acdc_bioio_bioformats/_init_reader.py @@ -6,21 +6,21 @@ try: ap.add_argument( - '-f', - '--filepath', - required=True, - type=str, - metavar='FILEPATH', - help='Filepath of a raw microscopy file to test.' + "-f", + "--filepath", + required=True, + type=str, + metavar="FILEPATH", + help="Filepath of a raw microscopy file to test.", ) args = vars(ap.parse_args()) - raw_filepath = args['filepath'] + raw_filepath = args["filepath"] with bioformats.ImageReader(raw_filepath, qparent=None) as reader: print(reader) except Exception as err: args = vars(ap.parse_args()) - uuid4 = args['uuid'] - - bioformats._utils.dump_exception(err, uuid4) \ No newline at end of file + uuid4 = args["uuid"] + + bioformats._utils.dump_exception(err, uuid4) diff --git a/cellacdc/acdc_bioio_bioformats/_read_metadata.py b/cellacdc/acdc_bioio_bioformats/_read_metadata.py index 87e3b8bab..2873e3220 100644 --- a/cellacdc/acdc_bioio_bioformats/_read_metadata.py +++ b/cellacdc/acdc_bioio_bioformats/_read_metadata.py @@ -14,32 +14,28 @@ try: ap.add_argument( - '-f', - '--filepath', - required=True, - type=str, - metavar='FILEPATH', - help='Filepath of a raw microscopy file to test.' + "-f", + "--filepath", + required=True, + type=str, + metavar="FILEPATH", + help="Filepath of a raw microscopy file to test.", ) args = vars(ap.parse_args()) - raw_filepath = args['filepath'] + raw_filepath = args["filepath"] metadataXML = bioformats.get_omexml_metadata(raw_filepath) metadata = bioformats.OMEXML().init_from_metadata(metadataXML) os.makedirs(bioio_sample_data_folderpath, exist_ok=True) - metadataXML_filepath = os.path.join( - bioio_sample_data_folderpath, 'metadataXML.txt' - ) + metadataXML_filepath = os.path.join(bioio_sample_data_folderpath, "metadataXML.txt") metadataXML.to_file(metadataXML_filepath) - metadata_filepath = os.path.join( - bioio_sample_data_folderpath, 'metadata.txt' - ) + metadata_filepath = os.path.join(bioio_sample_data_folderpath, "metadata.txt") metadata.to_file(metadata_filepath) except Exception as err: args = vars(ap.parse_args()) - uuid4 = args['uuid'] - - bioformats._utils.dump_exception(err, uuid4) \ No newline at end of file + uuid4 = args["uuid"] + + bioformats._utils.dump_exception(err, uuid4) diff --git a/cellacdc/acdc_bioio_bioformats/_read_sample_data.py b/cellacdc/acdc_bioio_bioformats/_read_sample_data.py index bdea8020e..59da04ad1 100644 --- a/cellacdc/acdc_bioio_bioformats/_read_sample_data.py +++ b/cellacdc/acdc_bioio_bioformats/_read_sample_data.py @@ -13,61 +13,61 @@ try: ap.add_argument( - '-f', - '--filepath', - required=True, - type=str, - metavar='FILEPATH', - help='Filepath of a raw microscopy file to test.' + "-f", + "--filepath", + required=True, + type=str, + metavar="FILEPATH", + help="Filepath of a raw microscopy file to test.", ) ap.add_argument( - '-c', - '--SizeC', - required=True, - type=int, - metavar='SIZEC', - help='Number of channels in the microscopy file.' + "-c", + "--SizeC", + required=True, + type=int, + metavar="SIZEC", + help="Number of channels in the microscopy file.", ) ap.add_argument( - '-t', - '--SizeT', - required=True, - type=int, - metavar='SIZET', - help='Number of timepoints in the microscopy file.' + "-t", + "--SizeT", + required=True, + type=int, + metavar="SIZET", + help="Number of timepoints in the microscopy file.", ) ap.add_argument( - '-z', - '--SizeZ', - required=True, - type=int, - metavar='SIZEZ', - help='Number of z-slices in a single z-stack.' + "-z", + "--SizeZ", + required=True, + type=int, + metavar="SIZEZ", + help="Number of z-slices in a single z-stack.", ) - + ap.add_argument( - '-a', - '--all', - action='store_true', - help='Whether to read entire position into RAM or not.' + "-a", + "--all", + action="store_true", + help="Whether to read entire position into RAM or not.", ) args = vars(ap.parse_args()) - raw_filepath = args['filepath'] + raw_filepath = args["filepath"] + + SizeC = args["SizeC"] + SizeT = args["SizeT"] + SizeZ = args["SizeZ"] - SizeC = args['SizeC'] - SizeT = args['SizeT'] - SizeZ = args['SizeZ'] - - lazy_load = not args['all'] + lazy_load = not args["all"] if SizeT >= 4: sampleSizeT = 4 else: - sampleSizeT = SizeT + sampleSizeT = SizeT if SizeZ > 20: sampleSizeZ = 20 else: @@ -75,17 +75,17 @@ allChannelsData = [] with bioformats.ImageReader(raw_filepath, lazy_load=lazy_load) as reader: - numIter = SizeC*sampleSizeT*sampleSizeZ + numIter = SizeC * sampleSizeT * sampleSizeZ pbar = tqdm(total=numIter, ncols=100, leave=False) - + for c in range(SizeC): imgData_tz = [] - for t in range(sampleSizeT): + for t in range(sampleSizeT): imgData_z = [] for z in range(sampleSizeZ): imgData = reader.read(c=c, z=z, t=t, rescale=False) - imgData_z.append(imgData) - pbar.update() + imgData_z.append(imgData) + pbar.update() imgData_z = np.array(imgData_z, dtype=imgData.dtype) imgData_z = np.squeeze(imgData_z) imgData_tz.append(imgData_z) @@ -95,13 +95,11 @@ os.makedirs(bioio_sample_data_folderpath, exist_ok=True) for c, channel_data in enumerate(allChannelsData): - filepath = os.path.join( - bioio_sample_data_folderpath, f"sample_channel_{c}.npy" - ) + filepath = os.path.join(bioio_sample_data_folderpath, f"sample_channel_{c}.npy") np.save(filepath, channel_data) except Exception as err: args = vars(ap.parse_args()) - uuid4 = args['uuid'] - - bioformats._utils.dump_exception(err, uuid4) \ No newline at end of file + uuid4 = args["uuid"] + + bioformats._utils.dump_exception(err, uuid4) diff --git a/cellacdc/acdc_bioio_bioformats/_save_data.py b/cellacdc/acdc_bioio_bioformats/_save_data.py index 0ac7c1e1a..a0f347860 100644 --- a/cellacdc/acdc_bioio_bioformats/_save_data.py +++ b/cellacdc/acdc_bioio_bioformats/_save_data.py @@ -15,173 +15,181 @@ try: ap.add_argument( - '-f', - '--filepath', - required=True, - type=str, - metavar='FILEPATH', - help='Filepath of the raw microscopy file.' + "-f", + "--filepath", + required=True, + type=str, + metavar="FILEPATH", + help="Filepath of the raw microscopy file.", ) ap.add_argument( - '-d', - '--do_save_channels', + "-d", + "--do_save_channels", type=str, - required=True, - metavar='DO_SAVE_CHANNELS', - help='Whether to save the channel or not.' + required=True, + metavar="DO_SAVE_CHANNELS", + help="Whether to save the channel or not.", ) ap.add_argument( - '-c', - '--channel_names', + "-c", + "--channel_names", type=str, - required=True, - metavar='CHANNEL_NAMES', - help='List of channel names.' + required=True, + metavar="CHANNEL_NAMES", + help="List of channel names.", ) ap.add_argument( - '-s', - '--series_idx', - required=True, - type=int, - metavar='SERIES_IDX', - help='Index of the Position in the microscopy file.' + "-s", + "--series_idx", + required=True, + type=int, + metavar="SERIES_IDX", + help="Index of the Position in the microscopy file.", ) ap.add_argument( - '-i', - '--images_path', - required=True, - type=str, - metavar='IMAGE_PATH', - help='Images folder path.' + "-i", + "--images_path", + required=True, + type=str, + metavar="IMAGE_PATH", + help="Images folder path.", ) ap.add_argument( - '-p', - '--filename_no_ext', - required=True, - type=str, - metavar='FILENAME_NO_EXT', - help='Name of the file without extension.' + "-p", + "--filename_no_ext", + required=True, + type=str, + metavar="FILENAME_NO_EXT", + help="Name of the file without extension.", ) ap.add_argument( - '-pos', - '--pos_idx_str', - required=True, - type=str, - metavar='POS_IDX_STR', - help='String index of the Position padded with required zeros.' + "-pos", + "--pos_idx_str", + required=True, + type=str, + metavar="POS_IDX_STR", + help="String index of the Position padded with required zeros.", ) ap.add_argument( - '-t', - '--SizeT', - required=True, - type=int, - metavar='SIZET', - help='Number of timepoints in the microscopy file.' + "-t", + "--SizeT", + required=True, + type=int, + metavar="SIZET", + help="Number of timepoints in the microscopy file.", ) ap.add_argument( - '-z', - '--SizeZ', - required=True, - type=int, - metavar='SIZEZ', - help='Number of z-slices in a single z-stack.' + "-z", + "--SizeZ", + required=True, + type=int, + metavar="SIZEZ", + help="Number of z-slices in a single z-stack.", ) ap.add_argument( - '-time_increment', - '--time_increment', - type=float, - required=True, - metavar='TIME_INCREMENT', - help='Time between consecutive frames in seconds.' + "-time_increment", + "--time_increment", + type=float, + required=True, + metavar="TIME_INCREMENT", + help="Time between consecutive frames in seconds.", ) ap.add_argument( - '-zyx', - '--zyx_physical_sizes', + "-zyx", + "--zyx_physical_sizes", type=str, - required=True, - metavar='ZYX_PHYSICAL_SIZES', - help='Physical sizes in z, y, x dimensions.' + required=True, + metavar="ZYX_PHYSICAL_SIZES", + help="Physical sizes in z, y, x dimensions.", ) ap.add_argument( - '-to_h5', - '--to_h5', - action='store_true', - help='Whether to save with h5 file format.' + "-to_h5", + "--to_h5", + action="store_true", + help="Whether to save with h5 file format.", ) ap.add_argument( - '-r', - '--time_range_to_save', + "-r", + "--time_range_to_save", type=str, - required=True, - metavar='TIME_RANGE_TO_SAVE', - help='Start and end frame to save.' + required=True, + metavar="TIME_RANGE_TO_SAVE", + help="Start and end frame to save.", ) - + ap.add_argument( - '-a', - '--all', - action='store_true', - help='Whether to read entire position into RAM or not.' + "-a", + "--all", + action="store_true", + help="Whether to read entire position into RAM or not.", ) args = vars(ap.parse_args()) - raw_filepath = args['filepath'] - do_save_channels_li = args['do_save_channels'].split() - do_save_channels = [val=='True' for val in do_save_channels_li] - channel_names = args['channel_names'].split() - series = args['series_idx'] - images_path = args['images_path'] - filename_no_ext = args['filename_no_ext'] - SizeT = args['SizeT'] - SizeZ = args['SizeZ'] - TimeIncrement = args['time_increment'] - s0p = args['pos_idx_str'] - - lazy_load = not args['all'] - - zyx_physical_sizes_li = args['zyx_physical_sizes'].split() + raw_filepath = args["filepath"] + do_save_channels_li = args["do_save_channels"].split() + do_save_channels = [val == "True" for val in do_save_channels_li] + channel_names = args["channel_names"].split() + series = args["series_idx"] + images_path = args["images_path"] + filename_no_ext = args["filename_no_ext"] + SizeT = args["SizeT"] + SizeZ = args["SizeZ"] + TimeIncrement = args["time_increment"] + s0p = args["pos_idx_str"] + + lazy_load = not args["all"] + + zyx_physical_sizes_li = args["zyx_physical_sizes"].split() zyx_physical_sizes = [float(val) for val in zyx_physical_sizes_li] PhysicalSizeZ, PhysicalSizeY, PhysicalSizeX = zyx_physical_sizes - to_h5 = args['to_h5'] + to_h5 = args["to_h5"] - time_range_to_save_li = args['time_range_to_save'].split() + time_range_to_save_li = args["time_range_to_save"].split() timeRangeToSave = [int(val) for val in time_range_to_save_li] with bioformats.ImageReader(raw_filepath, lazy_load=lazy_load) as reader: iter = enumerate(zip(channel_names, do_save_channels)) - pbar = tqdm( - total=len(channel_names), - ncols=100, - desc='Saving channels' - ) + pbar = tqdm(total=len(channel_names), ncols=100, desc="Saving channels") for c, (chName, saveCh) in iter: if not saveCh: pbar.update() continue bioformats._utils.saveImgDataChannel( - reader, series, images_path, filename_no_ext, s0p, - chName, c, {}, SizeT, SizeZ, TimeIncrement, PhysicalSizeZ, - PhysicalSizeY, PhysicalSizeX, to_h5, - timeRangeToSave - ) + reader, + series, + images_path, + filename_no_ext, + s0p, + chName, + c, + {}, + SizeT, + SizeZ, + TimeIncrement, + PhysicalSizeZ, + PhysicalSizeY, + PhysicalSizeX, + to_h5, + timeRangeToSave, + ) pbar.update() pbar.close() except Exception as err: args = vars(ap.parse_args()) - uuid4 = args['uuid'] - - bioformats._utils.dump_exception(err, uuid4) \ No newline at end of file + uuid4 = args["uuid"] + + bioformats._utils.dump_exception(err, uuid4) diff --git a/cellacdc/acdc_bioio_bioformats/_save_data_single_channel.py b/cellacdc/acdc_bioio_bioformats/_save_data_single_channel.py index a96984a83..7f5c94a6d 100644 --- a/cellacdc/acdc_bioio_bioformats/_save_data_single_channel.py +++ b/cellacdc/acdc_bioio_bioformats/_save_data_single_channel.py @@ -15,172 +15,186 @@ try: ap.add_argument( - '-f', - '--filepath', - required=True, - type=str, - metavar='FILEPATH', - help='Filepath of the raw microscopy file.' + "-f", + "--filepath", + required=True, + type=str, + metavar="FILEPATH", + help="Filepath of the raw microscopy file.", ) ap.add_argument( - '-d', - '--do_save_channels', + "-d", + "--do_save_channels", type=str, - required=True, - metavar='DO_SAVE_CHANNELS', - help='Whether to save the channel or not.' + required=True, + metavar="DO_SAVE_CHANNELS", + help="Whether to save the channel or not.", ) ap.add_argument( - '-c', - '--channel_name', - type=str, - required=True, - metavar='CHANNEL_NAMES', - help='Channel name' + "-c", + "--channel_name", + type=str, + required=True, + metavar="CHANNEL_NAMES", + help="Channel name", ) ap.add_argument( - '-ch_idx', - '--ch_idx', - required=True, - type=int, - metavar='CH_IDX', - help='Index of the channel.' + "-ch_idx", + "--ch_idx", + required=True, + type=int, + metavar="CH_IDX", + help="Index of the channel.", ) ap.add_argument( - '-z', - '--SizeZ', - required=True, - type=int, - metavar='SIZEZ', - help='Number of z-slices in a single z-stack.' + "-z", + "--SizeZ", + required=True, + type=int, + metavar="SIZEZ", + help="Number of z-slices in a single z-stack.", ) ap.add_argument( - '-s', - '--series_idx', - required=True, - type=int, - metavar='SERIES_IDX', - help='Index of the Position in the microscopy file.' + "-s", + "--series_idx", + required=True, + type=int, + metavar="SERIES_IDX", + help="Index of the Position in the microscopy file.", ) ap.add_argument( - '-i', - '--images_path', - required=True, - type=str, - metavar='IMAGE_PATH', - help='Images folder path.' + "-i", + "--images_path", + required=True, + type=str, + metavar="IMAGE_PATH", + help="Images folder path.", ) ap.add_argument( - '-p', - '--filename_no_ext', - required=True, - type=str, - metavar='FILENAME_NO_EXT', - help='Name of the file without extension.' + "-p", + "--filename_no_ext", + required=True, + type=str, + metavar="FILENAME_NO_EXT", + help="Name of the file without extension.", ) ap.add_argument( - '-pos', - '--pos_idx_str', - required=True, - type=str, - metavar='POS_IDX_STR', - help='String index of the Position padded with required zeros.' + "-pos", + "--pos_idx_str", + required=True, + type=str, + metavar="POS_IDX_STR", + help="String index of the Position padded with required zeros.", ) ap.add_argument( - '-t', - '--SizeT', - required=True, - type=int, - metavar='SIZET', - help='Number of timepoints in the microscopy file.' + "-t", + "--SizeT", + required=True, + type=int, + metavar="SIZET", + help="Number of timepoints in the microscopy file.", ) ap.add_argument( - '-time_increment', - '--time_increment', - type=float, - required=True, - metavar='TIME_INCREMENT', - help='Time between consecutive frames in seconds.' + "-time_increment", + "--time_increment", + type=float, + required=True, + metavar="TIME_INCREMENT", + help="Time between consecutive frames in seconds.", ) ap.add_argument( - '-zyx', - '--zyx_physical_sizes', + "-zyx", + "--zyx_physical_sizes", type=str, - required=True, - metavar='ZYX_PHYSICAL_SIZES', - help='Physical sizes in z, y, x dimensions.' + required=True, + metavar="ZYX_PHYSICAL_SIZES", + help="Physical sizes in z, y, x dimensions.", ) ap.add_argument( - '-to_h5', - '--to_h5', - action='store_true', - help='Whether to save with h5 file format.' + "-to_h5", + "--to_h5", + action="store_true", + help="Whether to save with h5 file format.", ) ap.add_argument( - '-r', - '--time_range_to_save', + "-r", + "--time_range_to_save", type=str, - required=True, - metavar='TIME_RANGE_TO_SAVE', - help='Start and end frame to save.' + required=True, + metavar="TIME_RANGE_TO_SAVE", + help="Start and end frame to save.", ) - + ap.add_argument( - '-a', - '--all', - action='store_true', - help='Whether to read entire position into RAM or not.' + "-a", + "--all", + action="store_true", + help="Whether to read entire position into RAM or not.", ) args = vars(ap.parse_args()) - raw_filepath = args['filepath'] - do_save_channels_li = args['do_save_channels'].split() - do_save_channels = [val=='True' for val in do_save_channels_li] - - channel_name = args['channel_name'] - ch_idx = args['ch_idx'] - series = args['series_idx'] - images_path = args['images_path'] - filename_no_ext = args['filename_no_ext'] - SizeT = args['SizeT'] - SizeZ = args['SizeZ'] - TimeIncrement = args['time_increment'] - s0p = args['pos_idx_str'] - - lazy_load = not args['all'] - - zyx_physical_sizes_li = args['zyx_physical_sizes'].split() + raw_filepath = args["filepath"] + do_save_channels_li = args["do_save_channels"].split() + do_save_channels = [val == "True" for val in do_save_channels_li] + + channel_name = args["channel_name"] + ch_idx = args["ch_idx"] + series = args["series_idx"] + images_path = args["images_path"] + filename_no_ext = args["filename_no_ext"] + SizeT = args["SizeT"] + SizeZ = args["SizeZ"] + TimeIncrement = args["time_increment"] + s0p = args["pos_idx_str"] + + lazy_load = not args["all"] + + zyx_physical_sizes_li = args["zyx_physical_sizes"].split() zyx_physical_sizes = [float(val) for val in zyx_physical_sizes_li] PhysicalSizeZ, PhysicalSizeY, PhysicalSizeX = zyx_physical_sizes - to_h5 = args['to_h5'] + to_h5 = args["to_h5"] - time_range_to_save_li = args['time_range_to_save'].split() + time_range_to_save_li = args["time_range_to_save"].split() timeRangeToSave = [int(val) for val in time_range_to_save_li] with bioformats.ImageReader(raw_filepath, lazy_load=lazy_load) as reader: - print(f'Saving channel {ch_idx+1}/{len(do_save_channels)} ({channel_name})...') + print( + f"Saving channel {ch_idx + 1}/{len(do_save_channels)} ({channel_name})..." + ) bioformats._utils.saveImgDataChannel( - reader, series, images_path, filename_no_ext, s0p, - channel_name, 0, {}, SizeT, SizeZ, TimeIncrement, PhysicalSizeZ, - PhysicalSizeY, PhysicalSizeX, to_h5, - timeRangeToSave + reader, + series, + images_path, + filename_no_ext, + s0p, + channel_name, + 0, + {}, + SizeT, + SizeZ, + TimeIncrement, + PhysicalSizeZ, + PhysicalSizeY, + PhysicalSizeX, + to_h5, + timeRangeToSave, ) except Exception as err: args = vars(ap.parse_args()) - uuid4 = args['uuid'] - - bioformats._utils.dump_exception(err, uuid4) \ No newline at end of file + uuid4 = args["uuid"] + + bioformats._utils.dump_exception(err, uuid4) diff --git a/cellacdc/acdc_bioio_bioformats/_utils.py b/cellacdc/acdc_bioio_bioformats/_utils.py index 56c149630..983042088 100644 --- a/cellacdc/acdc_bioio_bioformats/_utils.py +++ b/cellacdc/acdc_bioio_bioformats/_utils.py @@ -13,119 +13,110 @@ from cellacdc import myutils, bioio_sample_data_folderpath + def setup_argparser(): ap = argparse.ArgumentParser( - prog='Cell-ACDC process', - description='Used to spawn a separate process', - formatter_class=argparse.RawTextHelpFormatter + prog="Cell-ACDC process", + description="Used to spawn a separate process", + formatter_class=argparse.RawTextHelpFormatter, ) ap.add_argument( - '-uuid', - '--uuid4', - required=False, - type=str, - metavar='UUID4', - help='String ID to use to store error for current session.', - default='42' + "-uuid", + "--uuid4", + required=False, + type=str, + metavar="UUID4", + help="String ID to use to store error for current session.", + default="42", ) return ap + def removeInvalidCharacters(chName_in): # Remove invalid charachters chName = "".join( - c if c.isalnum() or c=='_' or c=='' else '_' for c in chName_in + c if c.isalnum() or c == "_" or c == "" else "_" for c in chName_in ) - trim_ = chName.endswith('_') + trim_ = chName.endswith("_") while trim_: chName = chName[:-1] - trim_ = chName.endswith('_') + trim_ = chName.endswith("_") + -def getFilename( - filenameNOext, s0p, appendTxt, series, ext, - return_basename=False - ): +def getFilename(filenameNOext, s0p, appendTxt, series, ext, return_basename=False): # Do not allow dots in the filename since it breaks stuff here and there - filenameNOext = filenameNOext.replace('.', '_') - basename = f'{filenameNOext}_s{s0p}_' - filename = f'{basename}{appendTxt}{ext}' + filenameNOext = filenameNOext.replace(".", "_") + basename = f"{filenameNOext}_s{s0p}_" + filename = f"{basename}{appendTxt}{ext}" if return_basename: return filename, basename else: return filename + def saveImgDataChannel( - reader, - series: int, - images_path: os.PathLike, - filenameNOext: str, - s0p: str, - chName: str, - ch_idx: int, - idxs: dict, - SizeT: int, - SizeZ: int, - TimeIncrement: float, - PhysicalSizeZ: float, - PhysicalSizeY: float, - PhysicalSizeX: float, - to_h5: bool, - timeRangeToSave: Tuple[int, int], - ): + reader, + series: int, + images_path: os.PathLike, + filenameNOext: str, + s0p: str, + chName: str, + ch_idx: int, + idxs: dict, + SizeT: int, + SizeZ: int, + TimeIncrement: float, + PhysicalSizeZ: float, + PhysicalSizeY: float, + PhysicalSizeX: float, + to_h5: bool, + timeRangeToSave: Tuple[int, int], +): savedSizeT = timeRangeToSave[1] - timeRangeToSave[0] + 1 if to_h5: - filename = getFilename( - filenameNOext, s0p, chName, series, '.h5' - ) + filename = getFilename(filenameNOext, s0p, chName, series, ".h5") tempDir = tempfile.mkdtemp() tempFilepath = os.path.join(tempDir, filename) - print('==========================================================') + print("==========================================================") print(f'.h5 tempfile: "{tempFilepath}"') - print('==========================================================') - h5f = h5py.File(tempFilepath, 'w') + print("==========================================================") + h5f = h5py.File(tempFilepath, "w") # Read SizeX and SizeY from the shape of one image - imgData = reader.read( - c=ch_idx, z=0, t=0, series=series, rescale=False - ) + imgData = reader.read(c=ch_idx, z=0, t=0, series=series, rescale=False) shape = (savedSizeT, SizeZ, *imgData.shape) - chunks = (1,1,*imgData.shape) + chunks = (1, 1, *imgData.shape) imgData_ch = h5f.create_dataset( - 'data', shape, dtype=imgData.dtype, - chunks=chunks, shuffle=False + "data", shape, dtype=imgData.dtype, chunks=chunks, shuffle=False ) else: - filename = getFilename( - filenameNOext, s0p, chName, series, '.tif' - ) + filename = getFilename(filenameNOext, s0p, chName, series, ".tif") imgData_ch = [] - framesRange = range(timeRangeToSave[0]-1, timeRangeToSave[1]) + framesRange = range(timeRangeToSave[0] - 1, timeRangeToSave[1]) filePath = os.path.join(images_path, filename) - dimsIdx = {'c': ch_idx} + dimsIdx = {"c": ch_idx} numFrames = len(framesRange) - num_imgs = numFrames*SizeZ + num_imgs = numFrames * SizeZ pbar = tqdm( - total=num_imgs, - ncols=100, - desc=f'Reading image (z 0/{SizeZ}, t 0/{numFrames})' + total=num_imgs, ncols=100, desc=f"Reading image (z 0/{SizeZ}, t 0/{numFrames})" ) for out_t, t in enumerate(framesRange): imgData_z = [] - dimsIdx['t'] = t + dimsIdx["t"] = t for z in range(SizeZ): pbar.set_description( - f'Reading image (z {z+1}/{SizeZ}, t {out_t+1}/{numFrames})' + f"Reading image (z {z + 1}/{SizeZ}, t {out_t + 1}/{numFrames})" ) - dimsIdx['z'] = z + dimsIdx["z"] = z idx = None imgData = reader.read( - c=ch_idx, z=z, t=t, series=series, rescale=False, - index=idx + c=ch_idx, z=z, t=t, series=series, rescale=False, index=idx ) if to_h5: imgData_ch[out_t, z] = imgData else: imgData_z.append(imgData) - + pbar.update() if not to_h5: @@ -136,7 +127,8 @@ def saveImgDataChannel( if not to_h5: imgData_ch = np.squeeze(np.array(imgData_ch, dtype=imgData.dtype)) myutils.to_tiff( - filePath, imgData_ch, + filePath, + imgData_ch, SizeT=savedSizeT, SizeZ=SizeZ, TimeIncrement=TimeIncrement, @@ -149,25 +141,25 @@ def saveImgDataChannel( shutil.move(tempFilepath, filePath) shutil.rmtree(tempDir) + def dump_exception(err, error_id): import pickle - error_path = os.path.join( - bioio_sample_data_folderpath, f'error_{error_id}.pkl' - ) - with open(error_path, 'wb') as file: + + error_path = os.path.join(bioio_sample_data_folderpath, f"error_{error_id}.pkl") + with open(error_path, "wb") as file: pickle.dump(err, file) + def check_raise_exception(error_id): import pickle - error_path = os.path.join( - bioio_sample_data_folderpath, f'error_{error_id}.pkl' - ) + + error_path = os.path.join(bioio_sample_data_folderpath, f"error_{error_id}.pkl") if not os.path.exists(error_path): return - + with open(error_path, "rb") as file: err = pickle.load(file) - + os.remove(error_path) - - raise err \ No newline at end of file + + raise err diff --git a/cellacdc/acdc_bioio_bioformats/install.py b/cellacdc/acdc_bioio_bioformats/install.py index 8f8ddd045..8c796d14b 100644 --- a/cellacdc/acdc_bioio_bioformats/install.py +++ b/cellacdc/acdc_bioio_bioformats/install.py @@ -6,61 +6,59 @@ from . import EXTENSION_PACKAGE_MAPPER -pkg_regex = r'[a-zA-Z0-9_\-]+' +pkg_regex = r"[a-zA-Z0-9_\-]+" -def _check_install_bioio_bioformats(qparent=None): + +def _check_install_bioio_bioformats(qparent=None): myutils.check_install_package( - 'scyjava', - installer='conda', + "scyjava", + installer="conda", is_cli=qparent is None, - exact_version='1.10.2', - parent=qparent + exact_version="1.10.2", + parent=qparent, ) - + myutils.check_install_package( - 'bioio-bioformats', - installer='pip', + "bioio-bioformats", + installer="pip", is_cli=qparent is None, - min_version='1.0.0', - max_version='2.0.0', + min_version="1.0.0", + max_version="2.0.0", include_higher_version=False, include_lower_version=True, - parent=qparent + parent=qparent, ) - + return True -def _check_install_extra_format_dependency( - image_filepath: os.PathLike, - qparent=None - ): - - if image_filepath.endswith('.ome.tiff'): - ext = '.ome.tiff' + +def _check_install_extra_format_dependency(image_filepath: os.PathLike, qparent=None): + + if image_filepath.endswith(".ome.tiff"): + ext = ".ome.tiff" else: _, ext = os.path.splitext(image_filepath) package_name = EXTENSION_PACKAGE_MAPPER.get(ext) - + if package_name is None: _check_install_bioio_bioformats(qparent=qparent) return - + myutils.check_install_package( package_name, - installer='pip', + installer="pip", is_cli=qparent is None, parent=qparent, ) + def install_reader_dependencies( - image_filepath: os.PathLike, - exception: Exception, - qparent=None - ): + image_filepath: os.PathLike, exception: Exception, qparent=None +): try: success = _check_install_extra_format_dependency( image_filepath, qparent=qparent ) - + except Exception as err: - raise exception \ No newline at end of file + raise exception diff --git a/cellacdc/acdc_bioio_bioformats/reader.py b/cellacdc/acdc_bioio_bioformats/reader.py index 03a823bd8..815946c88 100644 --- a/cellacdc/acdc_bioio_bioformats/reader.py +++ b/cellacdc/acdc_bioio_bioformats/reader.py @@ -9,152 +9,164 @@ from . import EXTENSION_BIOIMAGE_KWARGS_MAPPER from . import EXTENSION_METADATA_ATTR_MAPPER + def set_reader(image_filepath, **kwargs): - if 'reader' in kwargs: + if "reader" in kwargs: return kwargs - + _, ext = os.path.splitext(image_filepath) if ext in EXTENSION_PACKAGE_MAPPER: - all_kwargs = { - **kwargs, - **EXTENSION_BIOIMAGE_KWARGS_MAPPER.get(ext, {}) - } + all_kwargs = {**kwargs, **EXTENSION_BIOIMAGE_KWARGS_MAPPER.get(ext, {})} return all_kwargs - + try: import bioio_bioformats - kwargs['reader'] = bioio_bioformats.Reader + + kwargs["reader"] = bioio_bioformats.Reader except ImportError: from bioio_base.exceptions import UnsupportedFileFormatError + raise UnsupportedFileFormatError( - 'Bioformats', 'Bioformats reader is not installed' + "Bioformats", "Bioformats reader is not installed" ) - + return kwargs + class ImageReader: def __init__( - self, image_filepath: os.PathLike, qparent=None, lazy_load=True, - **kwargs - ): + self, image_filepath: os.PathLike, qparent=None, lazy_load=True, **kwargs + ): from bioio import BioImage from bioio_base.exceptions import UnsupportedFileFormatError - + self._image_filepath = image_filepath - + # Capture BioImage error and install required dependencies try: kwargs = set_reader(image_filepath, **kwargs) self._bioioimage = BioImage(image_filepath, **kwargs) except UnsupportedFileFormatError as err: - install.install_reader_dependencies( - image_filepath, err, - qparent=qparent - ) + install.install_reader_dependencies(image_filepath, err, qparent=qparent) kwargs = set_reader(image_filepath, **kwargs) self._bioioimage = BioImage(image_filepath, **kwargs) - + self._is_lazy_load = lazy_load - + if lazy_load: return - + self.img_data = self._bioioimage.data - + def read(self, c=0, z=0, t=0, rescale=False, index=None, series=0): if self._bioioimage.current_scene_index != series: self._bioioimage.set_scene(series) if not self._is_lazy_load: self.img_data = self._bioioimage.data - + if self._is_lazy_load: lazy_img = self._bioioimage.get_image_dask_data("YX", T=t, C=c, Z=z) return lazy_img.compute() - + return self.img_data[t, c, z] - + def __enter__(self): return self - + def __exit__(self, exc_type, exc_value, traceback): return + class Metadata: def __init__(self): pass - + def to_file(self, filepath): - with open(filepath, 'w') as file: + with open(filepath, "w") as file: file.write(str(self)) - + def init_from_image_filepath(self, image_filepath, qparent=None): self.image_filepath = image_filepath self.qparent = qparent - + with ImageReader(image_filepath, qparent=qparent) as bioio_image: self.metadata = bioio_image._bioioimage.metadata - + return self def init_from_file(self, filepath): - with open(filepath, 'r') as file: + with open(filepath, "r") as file: self.metadata = file.read() - + def __str__(self): return str(self.metadata) + class Channel: pass + class Node: def __init__(self, image_filepath, bioimage_class): _, ext = os.path.splitext(image_filepath) try: self._node = { - 'TimeIncrement': bioimage_class.time_interval.total_seconds(), - 'TimeIncrementUnit': 's' + "TimeIncrement": bioimage_class.time_interval.total_seconds(), + "TimeIncrementUnit": "s", } except Exception as err: self._node = {} - + if ext not in EXTENSION_METADATA_ATTR_MAPPER: return - + name_expression_mapper = EXTENSION_METADATA_ATTR_MAPPER[ext] for name, expression in name_expression_mapper.items(): try: self._node[name] = safe_get_or_call(bioimage_class, expression) except Exception as err: self._node[name] = None - + def get(self, name): value = self._node.get(name) if value is None: raise ValueError(f"Node '{name}' not found in metadata.") - + return value -class Pixels: + +class Pixels: def Channel(self, c: int): channel = Channel() channel.Name = self.channel_names[c] return channel + def get_omexml_metadata(image_filepath, qparent=None): return Metadata().init_from_image_filepath(image_filepath, qparent=None) + class PhysicalPixelSizes: def __init__(self, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ): self.X = PhysicalSizeX self.Y = PhysicalSizeY self.Z = PhysicalSizeZ + class BioImageMetadata: def __init__( - self, SizeT, SizeC, SizeZ, SizeY, SizeX, - PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ, - channel_names, image_count - ): + self, + SizeT, + SizeC, + SizeZ, + SizeY, + SizeX, + PhysicalSizeX, + PhysicalSizeY, + PhysicalSizeZ, + channel_names, + image_count, + ): self.shape = (SizeT, SizeC, SizeZ, SizeY, SizeX) self.physical_pixel_sizes = PhysicalPixelSizes( PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ @@ -162,99 +174,107 @@ def __init__( self.channel_names = channel_names self.scenes = list(range(image_count)) + class OMEXML: def __init__(self): self.qparent = None - + def init_from_metadata(self, metadata: Metadata): self.image_filepath = metadata.image_filepath self.qparent = metadata.qparent - + image_filepath = self.image_filepath qparent = self.qparent - + with ImageReader(image_filepath, qparent=qparent) as bioio_image: self.bioimage = bioio_image._bioioimage - + self._init_Pixels(image_filepath) - + return self - + def _init_Pixels(self, image_filepath): self.Pixels = Pixels() self.Pixels.node = Node(image_filepath, self.bioimage) - self.Pixels.channel_names = self.bioimage.channel_names - + self.Pixels.channel_names = self.bioimage.channel_names + def __str__(self): self.image() txt = ( - f'Image: {self.image_filepath}\n' - f'Channels: {self.Pixels.channel_names}\n' - f'SizeC: {self.Pixels.SizeC}\n' - f'SizeT: {self.Pixels.SizeT}\n' - f'SizeZ: {self.Pixels.SizeZ}\n' - f'SizeY: {self.Pixels.SizeY}\n' - f'SizeX: {self.Pixels.SizeX}\n' - f'PhysicalSizeX: {self.bioimage.physical_pixel_sizes.X}\n' - f'PhysicalSizeY: {self.bioimage.physical_pixel_sizes.Y}\n' - f'PhysicalSizeZ: {self.bioimage.physical_pixel_sizes.Z}\n' - f'Image count: {self.get_image_count()}' + f"Image: {self.image_filepath}\n" + f"Channels: {self.Pixels.channel_names}\n" + f"SizeC: {self.Pixels.SizeC}\n" + f"SizeT: {self.Pixels.SizeT}\n" + f"SizeZ: {self.Pixels.SizeZ}\n" + f"SizeY: {self.Pixels.SizeY}\n" + f"SizeX: {self.Pixels.SizeX}\n" + f"PhysicalSizeX: {self.bioimage.physical_pixel_sizes.X}\n" + f"PhysicalSizeY: {self.bioimage.physical_pixel_sizes.Y}\n" + f"PhysicalSizeZ: {self.bioimage.physical_pixel_sizes.Z}\n" + f"Image count: {self.get_image_count()}" ) return txt - + def to_file(self, filepath): - with open(filepath, 'w') as file: + with open(filepath, "w") as file: file.write(str(self)) - + def init_from_file(self, filepath, image_filepath): - with open(filepath, 'r') as file: + with open(filepath, "r") as file: txt = file.read() - + keys_dtype_kwarg_mapper = { - 'Image': (str, 'image_filepath', ''), - 'Channels': (eval, 'channel_names', ['ch0']), - 'SizeC': (int, 'SizeC', 1), - 'SizeT': (int, 'SizeT', 1), - 'SizeZ': (int, 'SizeZ', 1), - 'SizeY': (int, 'SizeY', 1), - 'SizeX': (int, 'SizeX', 1), - 'PhysicalSizeX': (float, 'PhysicalSizeX', 1.0), - 'PhysicalSizeY': (float, 'PhysicalSizeY', 1.0), - 'PhysicalSizeZ': (float, 'PhysicalSizeZ', 1.0), - 'Image count': (int, 'image_count', 1.0), + "Image": (str, "image_filepath", ""), + "Channels": (eval, "channel_names", ["ch0"]), + "SizeC": (int, "SizeC", 1), + "SizeT": (int, "SizeT", 1), + "SizeZ": (int, "SizeZ", 1), + "SizeY": (int, "SizeY", 1), + "SizeX": (int, "SizeX", 1), + "PhysicalSizeX": (float, "PhysicalSizeX", 1.0), + "PhysicalSizeY": (float, "PhysicalSizeY", 1.0), + "PhysicalSizeZ": (float, "PhysicalSizeZ", 1.0), + "Image count": (int, "image_count", 1.0), } for key, (dtype, kwarg, default) in keys_dtype_kwarg_mapper.items(): - value = re.search(f'{key}: (.+)', txt).group(1) + value = re.search(f"{key}: (.+)", txt).group(1) print(key, value, type(value)) try: setattr(self, kwarg, dtype(value)) except Exception as err: setattr(self, kwarg, default) - + self.bioimage = BioImageMetadata( - self.SizeT, self.SizeC, self.SizeZ, self.SizeY, self.SizeX, - self.PhysicalSizeX, self.PhysicalSizeY, self.PhysicalSizeZ, - self.channel_names, self.image_count + self.SizeT, + self.SizeC, + self.SizeZ, + self.SizeY, + self.SizeX, + self.PhysicalSizeX, + self.PhysicalSizeY, + self.PhysicalSizeZ, + self.channel_names, + self.image_count, ) - + self._init_Pixels(image_filepath) - + return self - - def image(self): + + def image(self): SizeT, SizeC, SizeZ, SizeY, SizeX = self.bioimage.shape - + self.Pixels.SizeY = SizeY self.Pixels.SizeX = SizeX self.Pixels.SizeZ = SizeZ self.Pixels.SizeT = SizeT self.Pixels.SizeC = SizeC - + self.Pixels.PhysicalSizeX = self.bioimage.physical_pixel_sizes.X self.Pixels.PhysicalSizeY = self.bioimage.physical_pixel_sizes.Y self.Pixels.PhysicalSizeZ = self.bioimage.physical_pixel_sizes.Z - + return self - + def get_image_count(self): - return len(self.bioimage.scenes) \ No newline at end of file + return len(self.bioimage.scenes) diff --git a/cellacdc/acdc_regex.py b/cellacdc/acdc_regex.py index fc355f4db..28f79c03b 100644 --- a/cellacdc/acdc_regex.py +++ b/cellacdc/acdc_regex.py @@ -1,46 +1,53 @@ import re -RE_SPLIT_SPACES_IGNORE_QUOTES = re.compile(r'''((?:[^ "']|"[^"]*"|'[^']*')+)''') +RE_SPLIT_SPACES_IGNORE_QUOTES = re.compile(r"""((?:[^ "']|"[^"]*"|'[^']*')+)""") -def float_regex(allow_negative=True, left_chars='', include_nan=False): - pattern = r'[-+]?[0-9]*\.?[0-9]*[eE]?[\-+]?[0-9]+' + +def float_regex(allow_negative=True, left_chars="", include_nan=False): + pattern = r"[-+]?[0-9]*\.?[0-9]*[eE]?[\-+]?[0-9]+" if left_chars: - pattern = fr'{left_chars}{pattern}' + pattern = rf"{left_chars}{pattern}" if not allow_negative: - pattern.replace('[-+]?', '[+]?') + pattern.replace("[-+]?", "[+]?") if include_nan: - nan_pattern = r'NAN|Nan|NaN|nan' - pattern = fr'{nan_pattern}|{pattern}' + nan_pattern = r"NAN|Nan|NaN|nan" + pattern = rf"{nan_pattern}|{pattern}" return pattern -def to_alphanumeric(text, replacing_char='_'): - return re.sub(r'[^\w\-.]', '_', text) + +def to_alphanumeric(text, replacing_char="_"): + return re.sub(r"[^\w\-.]", "_", text) + def get_function_names(text, include_class_methods=True): if include_class_methods: - pattern = r'\bdef\s+([a-zA-Z_]\w*)\s*\(' + pattern = r"\bdef\s+([a-zA-Z_]\w*)\s*\(" else: - pattern = r'\ndef\s+([a-zA-Z_]\w*)\s*\(' + pattern = r"\ndef\s+([a-zA-Z_]\w*)\s*\(" return re.findall(pattern, text) + def is_alphanumeric_filename(text, allow_space=True): if allow_space: - pattern = r'^[\w\-_. ]+$' + pattern = r"^[\w\-_. ]+$" else: - pattern = r'^[\w\-_.]+$' - is_single_or_no_dot = len(re.findall(r'\.', text)) <= 1 + pattern = r"^[\w\-_.]+$" + is_single_or_no_dot = len(re.findall(r"\.", text)) <= 1 return bool(re.match(pattern, text)) and is_single_or_no_dot + def get_non_alphanumeric_characters(text): - return re.findall(r'[^\w\-.]', text) - -if __name__ == '__main__': + return re.findall(r"[^\w\-.]", text) + + +if __name__ == "__main__": import re - s = '0.5, 2.5, nan, NaN' - expr = fr'{float_regex(include_nan=True)}' - m = re.findall(expr, s.replace(' ', '')) + + s = "0.5, 2.5, nan, NaN" + expr = rf"{float_regex(include_nan=True)}" + m = re.findall(expr, s.replace(" ", "")) print(m) - - s = 'ciao_ciao_-yessa' - - print(is_alphanumeric_filename(s)) \ No newline at end of file + + s = "ciao_ciao_-yessa" + + print(is_alphanumeric_filename(s)) diff --git a/cellacdc/annotate.py b/cellacdc/annotate.py index ec09b3610..8cefd95fc 100644 --- a/cellacdc/annotate.py +++ b/cellacdc/annotate.py @@ -11,129 +11,134 @@ from PIL import Image, ImageFont, ImageDraw from qtpy.QtGui import QFont import pyqtgraph as pg - pg.setConfigOption('imageAxisOrder', 'row-major') - + + pg.setConfigOption("imageAxisOrder", "row-major") + from . import plot -INVERTIBLE_COLOR_NAMES = [ - 'label', 'S_phase_mother', 'G1_phase' -] -FONT_FAMILY = 'Helvetica' +INVERTIBLE_COLOR_NAMES = ["label", "S_phase_mother", "G1_phase"] +FONT_FAMILY = "Helvetica" font_path = os.path.join( - cellacdc_path, 'resources', 'fonts', f'{FONT_FAMILY}-Regular.ttf') + cellacdc_path, "resources", "fonts", f"{FONT_FAMILY}-Regular.ttf" +) font_bold_path = os.path.join( - cellacdc_path, 'resources', 'fonts', f'{FONT_FAMILY}-Bold.ttf' + cellacdc_path, "resources", "fonts", f"{FONT_FAMILY}-Bold.ttf" ) + def get_obj_text_label_annot( - obj, acdc_df: pd.DataFrame, is_tree_annot: bool, add_num_zslices: bool - ) -> str: + obj, acdc_df: pd.DataFrame, is_tree_annot: bool, add_num_zslices: bool +) -> str: if is_tree_annot and acdc_df is not None: try: - annot_label = acdc_df.at[obj.label, 'Cell_ID_tree'] + annot_label = acdc_df.at[obj.label, "Cell_ID_tree"] except Exception as err: # print(traceback.format_exc()) annot_label = obj.label else: annot_label = obj.label - + if not add_num_zslices: return str(annot_label) - - num_z_slices = np.sum(np.any(obj.image, axis=(1,2))) - return f'{annot_label} ({num_z_slices})' -def get_obj_text_cca_annot( - obj, acdc_df: pd.DataFrame, is_tree_annot: bool - ) -> str: + num_z_slices = np.sum(np.any(obj.image, axis=(1, 2))) + return f"{annot_label} ({num_z_slices})" + + +def get_obj_text_cca_annot(obj, acdc_df: pd.DataFrame, is_tree_annot: bool) -> str: ID = obj.label try: cca_df_obj = acdc_df.loc[ID] except Exception as e: return str(ID), None - + try: - ccs = cca_df_obj['cell_cycle_stage'] + ccs = cca_df_obj["cell_cycle_stage"] except Exception as err: - return str(ID), None + return str(ID), None try: - generation_num = int(cca_df_obj['generation_num']) + generation_num = int(cca_df_obj["generation_num"]) except Exception as e: return str(ID), None - - generation_num = 'ND' if generation_num==-1 else generation_num + + generation_num = "ND" if generation_num == -1 else generation_num if is_tree_annot: try: - generation_num = cca_df_obj['generation_num_tree'] + generation_num = cca_df_obj["generation_num_tree"] except Exception as e: generation_num = generation_num - txt = f'{ccs}-{generation_num}' + txt = f"{ccs}-{generation_num}" - is_history_known = cca_df_obj['is_history_known'] + is_history_known = cca_df_obj["is_history_known"] if not is_history_known: - txt = f'{txt}?' + txt = f"{txt}?" return txt, cca_df_obj + def get_obj_text_annot_opts( - obj, acdc_df: pd.DataFrame, is_cca_annot: bool, is_new_obj: bool, - add_num_zslices: bool, is_label_tree_annot: bool, - is_gen_num_tree_annot: bool, frame_i: int - ) -> dict: + obj, + acdc_df: pd.DataFrame, + is_cca_annot: bool, + is_new_obj: bool, + add_num_zslices: bool, + is_label_tree_annot: bool, + is_gen_num_tree_annot: bool, + frame_i: int, +) -> dict: if acdc_df is None or not is_cca_annot: bold = False if is_new_obj: - color_name = 'new_object' + color_name = "new_object" else: - color_name = 'label' + color_name = "label" text = get_obj_text_label_annot( obj, acdc_df, is_label_tree_annot, add_num_zslices ) else: - text, cca_df_obj = get_obj_text_cca_annot( - obj, acdc_df, is_gen_num_tree_annot - ) + text, cca_df_obj = get_obj_text_cca_annot(obj, acdc_df, is_gen_num_tree_annot) if cca_df_obj is None: if is_new_obj: - color_name = 'new_object' + color_name = "new_object" else: - color_name = 'label' - opts = {'text': text, 'color_name': color_name, 'bold': False} + color_name = "label" + opts = {"text": text, "color_name": color_name, "bold": False} return opts - - ccs = cca_df_obj['cell_cycle_stage'] - relationship = cca_df_obj['relationship'] - is_bud = relationship == 'bud' - emerg_frame_i = int(cca_df_obj['emerg_frame_i']) + + ccs = cca_df_obj["cell_cycle_stage"] + relationship = cca_df_obj["relationship"] + is_bud = relationship == "bud" + emerg_frame_i = int(cca_df_obj["emerg_frame_i"]) bud_emerged_now = (emerg_frame_i == frame_i) and is_bud bold = bud_emerged_now # Check if it will divide to use orange instead of red bud_will_divide = False - if ccs == 'S' and is_bud: - bud_will_divide = cca_df_obj['will_divide'] > 0 + if ccs == "S" and is_bud: + bud_will_divide = cca_df_obj["will_divide"] > 0 if bud_will_divide: - color_name = 'bud_will_divide' - elif ccs == 'S': - if relationship == 'mother': - color_name = 'S_phase_mother' + color_name = "bud_will_divide" + elif ccs == "S": + if relationship == "mother": + color_name = "S_phase_mother" else: - color_name = 'S_phase_bud' - elif ccs == 'G1': - color_name = 'G1_phase' - - opts = {'text': text, 'color_name': color_name, 'bold': bold} + color_name = "S_phase_bud" + elif ccs == "G1": + color_name = "G1_phase" + + opts = {"text": text, "color_name": color_name, "bold": bold} return opts + class TextAnnotationsImageItem(pg.ImageItem): def __init__(self, **kargs): super().__init__(**kargs) - + def initFonts(self, fontSize): self.fontSize = fontSize self.fontBold = ImageFont.truetype(font_path, fontSize) @@ -143,80 +148,81 @@ def initFonts(self, fontSize): ) self.highlighterItem.initFonts(fontSize) self.highlighterItem.initSymbols(range(10)) - + def initSizes(self): pass - + def init(self, image_shape): shape = (*image_shape, 4) self.pilImage = Image.fromarray(np.zeros(shape, dtype=np.uint8)) self.pilDraw = ImageDraw.Draw(self.pilImage) - + def clearImage(self): - self.pilDraw.rectangle([(0,0), self.pilDraw.im.size], fill=(0,0,0,0)) - + self.pilDraw.rectangle([(0, 0), self.pilDraw.im.size], fill=(0, 0, 0, 0)) + def clearData(self): self.clearImage() self.setOpacity(1.0) self.highlighterItem.setData([], []) self.texts = [] self.annotData = [] - + def update(self): pass - + def appendData(self, data, text): self.annotData.append(data) self.texts.append(text) - + def highlightObject(self, obj): self.highlighterItem.texts = self.texts self.highlighterItem.highlightObject(obj) - + def grayOutAnnotations(self, IDsToSkip=None): self.setOpacity(0.3) - + def addObjAnnot(self, pos, draw=True, **objOpts): - if objOpts['bold']: + if objOpts["bold"]: font = self.fontBold else: font = self.fontRegular - - text = objOpts['text'] - color = self._colors[objOpts['color_name']] - self.pilDraw.text(pos, text, color, font=font, anchor='mm') - return objOpts - + + text = objOpts["text"] + color = self._colors[objOpts["color_name"]] + self.pilDraw.text(pos, text, color, font=font, anchor="mm") + return objOpts + def draw(self): super().setImage(np.array(self.pilImage)) def setColors(self, colors): self._colors = colors.copy() self.highlighterItem.setColors(colors) - + def initSymbols(self, allIDs): pass def colors(self): return self._colors + class TextAnnotationsScatterItem(pg.ScatterPlotItem): def __init__(self, *args, anchor=(0.5, 0.5), **kargs): super().__init__(*args, **kargs) - self.initFonts(kargs.get('size', 10)) + self.initFonts(kargs.get("size", 10)) self.texts = [] self.annotData = [] self._anchor = anchor - + def clearData(self): self.setData([], []) self.annotData = [] self.texts = [] - + def appendData(self, data, text): self.annotData.append(data) self.texts.append(text) - + def draw(self): super().setData(self.annotData) @@ -228,31 +234,31 @@ def initFonts(self, fontSize): self.fontRegular = QFont(FONT_FAMILY.lower()) self.fontRegular.setPixelSize(fontSize) - + def init(self, *args): pass def initSymbols(self, allIDs, onlyIDs=False): - annotTexts = ['?'] + annotTexts = ["?"] for ID in allIDs: annotTexts.append(str(ID)) if not onlyIDs: - annotTexts.append(f'{ID}?') - + annotTexts.append(f"{ID}?") + if not onlyIDs: for gen_num in range(20): - annotTexts.append(f'G1-{gen_num}') - annotTexts.append(f'G1-{gen_num}?') - annotTexts.append(f'S-{gen_num}') - annotTexts.append(f'S-{gen_num}?') - - if hasattr(self, 'symbolsBold'): + annotTexts.append(f"G1-{gen_num}") + annotTexts.append(f"G1-{gen_num}?") + annotTexts.append(f"S-{gen_num}") + annotTexts.append(f"S-{gen_num}?") + + if hasattr(self, "symbolsBold"): # Symbols already created in prev. session --> add missing ones self.addSymbols(annotTexts) else: # Symbols never created --> create now self.createSymbols(annotTexts) - + def addSymbols(self, annotTexts, includeBold=True): for text in annotTexts: if includeBold: @@ -275,11 +281,11 @@ def createSymbols(self, annotTexts, includeBold=True): ) self.scalesRegular = scalesRegular self.initSizes(includeBold=includeBold) - + def initSizes(self, includeBold=True): - if not hasattr(self, 'scalesBold'): + if not hasattr(self, "scalesBold"): includeBold = False - + if includeBold: self.sizesBold = plot.get_symbol_sizes( self.scalesBold, self.symbolsBold, self.fontSize @@ -287,7 +293,7 @@ def initSizes(self, includeBold=True): self.sizesRegular = plot.get_symbol_sizes( self.scalesRegular, self.symbolsRegular, self.fontSize ) - + def setColors(self, colors): self._colors = colors.copy() self._brushes = {} @@ -295,10 +301,10 @@ def setColors(self, colors): for name, color in self._colors.items(): self._brushes[name] = pg.mkBrush(color) self._pens[name] = pg.mkPen(color[:3], width=1) - + def pens(self): return self._pens - + def brushes(self): return self._brushes @@ -314,7 +320,7 @@ def getObjTextAnnotSymbol(self, text, bold=False, initSizes=True): symbols = self.symbolsRegular font = self.fontRegular scales = self.scalesRegular - + symbol = symbols.get(text) if symbol is not None: return symbol @@ -329,12 +335,12 @@ def getObjTextAnnotSymbol(self, text, bold=False, initSizes=True): return symbol def grayOutAnnotations(self, IDsToSkip=None): - brushes = [self._brushes['grayed'] for _ in range(len(self.data))] - pens = [self._pens['grayed'] for _ in range(len(self.data))] + brushes = [self._brushes["grayed"] for _ in range(len(self.data))] + pens = [self._pens["grayed"] for _ in range(len(self.data))] if IDsToSkip is not None: pointItems = self.points() for idx, objData in enumerate(self.data): - ID = objData['data'] + ID = objData["data"] doNotGray = IDsToSkip.get(ID, False) if not doNotGray: continue @@ -350,30 +356,28 @@ def highlightObject(self, obj): ID = obj.label objIdx = None for idx, objData in enumerate(self.data): - if ID == objData['data']: + if ID == objData["data"]: objIdx = idx break if objIdx is None: - objOpts = { - 'text': str(ID), 'bold': True, 'color_name': 'new_object' - } + objOpts = {"text": str(ID), "bold": True, "color_name": "new_object"} yc, xc = obj.centroid[-2:] pos = (int(xc), int(yc)) self.addObjAnnot(pos, draw=True, **objOpts) return - + pointItem = self.points()[objIdx] symbol = self.getObjTextAnnotSymbol(str(ID), bold=True) pointItem.setSymbol(symbol) - pointItem.setBrush(self._brushes['new_object']) - pointItem.setPen(self._pens['new_object']) + pointItem.setBrush(self._brushes["new_object"]) + pointItem.setPen(self._pens["new_object"]) def removeHighlightObject(self, obj): ID = obj.label objIdx = None for idx, objData in enumerate(self.data): - if ID == objData['data']: + if ID == objData["data"]: objIdx = idx break if objIdx is None: @@ -384,28 +388,28 @@ def removeHighlightObject(self, obj): default_symbol = self.getObjTextAnnotSymbol(str(ID), bold=False) pointItem.setSymbol(default_symbol) - pointItem.setBrush(self._brushes['label']) - pointItem.setPen(self._pens['label']) - + pointItem.setBrush(self._brushes["label"]) + pointItem.setPen(self._pens["label"]) + def modifyPosAnchor(self, pointOpts, anchor, symbol): if anchor is None: return pointOpts - + xa, ya = anchor if (xa, ya) == (0.5, 0.5): return pointOpts - + br = symbol.boundingRect() - xf = br.width()*(anchor[0]-0.5) - yf = br.height()*(anchor[1]-0.5) - x, y = pointOpts['pos'] - pointOpts['pos'] = (x-xf, y-yf) - - return pointOpts - - def addObjAnnot(self, pos, draw=False, anchor=None, **objOpts): - text = objOpts['text'] - bold = objOpts['bold'] + xf = br.width() * (anchor[0] - 0.5) + yf = br.height() * (anchor[1] - 0.5) + x, y = pointOpts["pos"] + pointOpts["pos"] = (x - xf, y - yf) + + return pointOpts + + def addObjAnnot(self, pos, draw=False, anchor=None, **objOpts): + text = objOpts["text"] + bold = objOpts["bold"] symbol = self.getObjTextAnnotSymbol(text, bold) if bold: @@ -413,20 +417,21 @@ def addObjAnnot(self, pos, draw=False, anchor=None, **objOpts): else: size = self.sizesRegular[text] - color_name = objOpts['color_name'] + color_name = objOpts["color_name"] pointOpts = {} - pointOpts['brush'] = self._brushes[color_name] - pointOpts['pen'] = self._pens[color_name] - pointOpts['symbol'] = symbol - pointOpts['size'] = size - pointOpts['pos'] = tuple(pos) + pointOpts["brush"] = self._brushes[color_name] + pointOpts["pen"] = self._pens[color_name] + pointOpts["symbol"] = symbol + pointOpts["size"] = size + pointOpts["pos"] = tuple(pos) pointOpts = self.modifyPosAnchor(pointOpts, anchor, symbol) if draw: self.addPoints([pointOpts]) - - return pointOpts + + return pointOpts + class TextAnnotations: def __init__(self): @@ -436,25 +441,23 @@ def __init__(self): self._isLabelTreeAnnotation = False self._isGenNumTreeAnnotation = False self._isGenNumTreeAnnotation = False - + def initFonts(self, fontSize): self.fontSize = fontSize - + def initItem(self, *args): self.item.init(*args) - + def clear(self): self.item.clear() - if hasattr(self.item, 'highlighterItem'): + if hasattr(self.item, "highlighterItem"): self.item.highlighterItem.setData([], []) - + def invertBlackAndWhite(self): - invertedColors = { - name:color[:3] for name, color in self.item.colors().items() - } + invertedColors = {name: color[:3] for name, color in self.item.colors().items()} for color_name in INVERTIBLE_COLOR_NAMES: color = self.item.colors()[color_name] - invertedColors[color_name] = tuple([255-val for val in color[:3]]) + invertedColors[color_name] = tuple([255 - val for val in color[:3]]) self.setColors(**invertedColors) @@ -463,106 +466,109 @@ def createItems(self, isHighResolution, allIDs, pxMode=False): if isHighResolution: self._createHighResolutionItems(allIDs, pxMode=pxMode) else: - self._createLowResolutionItem() - + self._createLowResolutionItem() + def _createLowResolutionItem(self): self.item = TextAnnotationsImageItem() self.setFontSize(self.fontSize, []) - + def _createHighResolutionItems(self, allIDs, pxMode=False): - self.item = TextAnnotationsScatterItem( - size=self.fontSize, pxMode=pxMode - ) + self.item = TextAnnotationsScatterItem(size=self.fontSize, pxMode=pxMode) self.setFontSize(self.fontSize, allIDs) - + def setFontSize(self, fontSize, allIDs): self.fontSize = fontSize self.item.initFonts(self.fontSize) self.item.initSymbols(allIDs) - + def changeFontSize(self, fontSize): self.fontSize = fontSize self.item.initFonts(fontSize) self.item.initSizes() - + def changeResolution(self, mode, allIDs, ax, img_shape): self.removeFromPlotItem(ax) - highRes = True if mode == 'high' else False + highRes = True if mode == "high" else False self.createItems(highRes, allIDs, pxMode=self._pxMode) self.initItem(img_shape) self.item.setColors(self.colors()) self.item.clearData() self.addToPlotItem(ax) - + def addToPlotItem(self, ax): ax.addItem(self.item) - if hasattr(self.item, 'highlighterItem'): + if hasattr(self.item, "highlighterItem"): ax.addItem(self.item.highlighterItem) def removeFromPlotItem(self, ax): ax.removeItem(self.item) - if hasattr(self.item, 'highlighterItem'): + if hasattr(self.item, "highlighterItem"): ax.removeItem(self.item.highlighterItem) - + def addObjAnnotation(self, obj, color_name, text, bold): objOpts = { - 'text': text, - 'bold': bold, - 'color_name': color_name, + "text": text, + "bold": bold, + "color_name": color_name, } yc, xc = obj.centroid[-2:] pos = (int(xc), int(yc)) objData = self.item.addObjAnnot(pos, draw=True, **objOpts) - self.item.appendData(objData, objOpts['text']) - + self.item.appendData(objData, objOpts["text"]) + def setAnnotations(self, **kwargs): if self.isDisabled(): return - + self.item.clearData() - - labelsToSkip = kwargs.get('labelsToSkip') - posData = kwargs['posData'] - delROIsIDs = kwargs.get('delROIsIDs', []) - isObjVisibleFunc = kwargs.get('isVisibleCheckFunc') - highlightedID = kwargs.get('highlightedID') - annotateLost = kwargs.get('annotateLost') - getCurrentZfunc = kwargs.get('getCurrentZfunc') - getObjCentroidFunc = kwargs.get('getObjCentroidFunc') + + labelsToSkip = kwargs.get("labelsToSkip") + posData = kwargs["posData"] + delROIsIDs = kwargs.get("delROIsIDs", []) + isObjVisibleFunc = kwargs.get("isVisibleCheckFunc") + highlightedID = kwargs.get("highlightedID") + annotateLost = kwargs.get("annotateLost") + getCurrentZfunc = kwargs.get("getCurrentZfunc") + getObjCentroidFunc = kwargs.get("getObjCentroidFunc") currentZ = getCurrentZfunc(checkIfProj=True) isCcaAnnot = self.isCcaAnnot() isAnnotateNumZslices = self.isAnnotateNumZslices() isLabelTreeAnnotation = self.isLabelTreeAnnotation() isGenNumTreeAnnotation = self.isGenNumTreeAnnotation() - - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] if posData.cca_df is not None and acdc_df is not None: cols = posData.cca_df.columns idx = posData.cca_df.index.intersection(acdc_df.index) acdc_df.loc[idx, cols] = posData.cca_df - + if acdc_df is None and posData.cca_df is not None: acdc_df = posData.cca_df - + for obj in posData.rp: if labelsToSkip is not None: if labelsToSkip.get(obj.label, False): continue - + if not isObjVisibleFunc(obj.bbox): continue - + if obj.label in delROIsIDs: continue isNewObject = obj.label in posData.new_IDs - + objOpts = get_obj_text_annot_opts( - obj, acdc_df, isCcaAnnot, isNewObject, - isAnnotateNumZslices, isLabelTreeAnnotation, - isGenNumTreeAnnotation, posData.frame_i + obj, + acdc_df, + isCcaAnnot, + isNewObject, + isAnnotateNumZslices, + isLabelTreeAnnotation, + isGenNumTreeAnnotation, + posData.frame_i, ) - + yc, xc = getObjCentroidFunc(obj.centroid) try: rp_zslice = posData.zSlicesRp[currentZ] @@ -570,59 +576,58 @@ def setAnnotations(self, **kwargs): yc, xc = obj_2d.centroid except Exception as err: pass - + pos = (int(xc), int(yc)) - + objData = self.item.addObjAnnot(pos, draw=False, **objOpts) - objData['data'] = obj.label - self.item.appendData(objData, objOpts['text']) + objData["data"] = obj.label + self.item.appendData(objData, objOpts["text"]) if posData.trackedLostIDs and annotateLost: - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] if prev_rp is None: self.item.draw() return - + for obj in prev_rp: if obj.label not in posData.trackedLostIDs: continue if obj.label in delROIsIDs: continue - + if not isObjVisibleFunc(obj.bbox): continue objOpts = { - 'text': f'{obj.label}', - 'color_name': 'tracked_lost_object', - 'bold': False, + "text": f"{obj.label}", + "color_name": "tracked_lost_object", + "bold": False, } yc, xc = obj.centroid[-2:] pos = (int(xc), int(yc)) objData = self.item.addObjAnnot(pos, draw=False, **objOpts) - self.item.appendData(objData, objOpts['text']) - + self.item.appendData(objData, objOpts["text"]) if posData.lost_IDs and annotateLost: - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] if prev_rp is None: self.item.draw() return for obj in prev_rp: if obj.label not in posData.lost_IDs: continue - + if obj.label in delROIsIDs: continue - + if not isObjVisibleFunc(obj.bbox): continue - + objOpts = { - 'text': f'{obj.label}?', - 'color_name': 'lost_object', - 'bold': False, + "text": f"{obj.label}?", + "color_name": "lost_object", + "bold": False, } yc, xc = getObjCentroidFunc(obj.centroid) try: @@ -630,52 +635,58 @@ def setAnnotations(self, **kwargs): except Exception as err: printl("""WARNING: Could not annotate lost object, failed to get position. Skipping annotation.""") - # Sometimes xc or yc can be nan, causing an error when + # Sometimes xc or yc can be nan, causing an error when # converting to int --> skip annotation in this case continue objData = self.item.addObjAnnot(pos, draw=False, **objOpts) - self.item.appendData(objData, objOpts['text']) + self.item.appendData(objData, objOpts["text"]) self.item.draw() - + def highlightObject(self, obj): self.item.highlightObject(obj) - + def removeHighlightObject(self, obj): self.item.removeHighlightObject(obj) - + def grayOutAnnotations(self, IDsToSkip=None): self.item.grayOutAnnotations(IDsToSkip=IDsToSkip) def isDisabled(self): _isEnabled = self._isLabelAnnot or self._isCcaAnnot - return (not _isEnabled) - + return not _isEnabled + def setColors( - self, label, bud_will_divide, S_phase_mother, G1_phase, - lost_object, tracked_lost_object, **kwargs - ): + self, + label, + bud_will_divide, + S_phase_mother, + G1_phase, + lost_object, + tracked_lost_object, + **kwargs, + ): alpha = 200 if len(G1_phase) == 3: G1_phase = (*G1_phase, 220) else: G1_phase = tuple(G1_phase) colors = { - 'label': (*label, alpha), - 'bud_will_divide': (*bud_will_divide, alpha), - 'S_phase_mother': (*S_phase_mother, alpha), - 'G1_phase': G1_phase, - 'new_object': (255,0,0,255), - 'lost_object': (*lost_object, alpha), - 'tracked_lost_object': (*tracked_lost_object, alpha), - 'grayed': (100,100,100,75), - 'highlight': (255,0,0,200), - 'S_phase_bud': (255,0,0,220), - 'green': (0,255,0,220) + "label": (*label, alpha), + "bud_will_divide": (*bud_will_divide, alpha), + "S_phase_mother": (*S_phase_mother, alpha), + "G1_phase": G1_phase, + "new_object": (255, 0, 0, 255), + "lost_object": (*lost_object, alpha), + "tracked_lost_object": (*tracked_lost_object, alpha), + "grayed": (100, 100, 100, 75), + "highlight": (255, 0, 0, 200), + "S_phase_bud": (255, 0, 0, 220), + "green": (0, 255, 0, 220), } self.item.setColors(colors) self._colors = colors - + def colors(self): return self._colors @@ -684,7 +695,7 @@ def setLabelAnnot(self, isLabelAnnot): def setCcaAnnot(self, isCcaAnnot): self._isCcaAnnot = isCcaAnnot - + def isCcaAnnot(self): return self._isCcaAnnot @@ -693,24 +704,24 @@ def isLabelAnnot(self): def setAnnotateNumZslices(self, isAnnotateNumZslices): self._isAnnotateNumZslices = isAnnotateNumZslices - + def isAnnotateNumZslices(self): return self._isAnnotateNumZslices - + def setLabelTreeAnnotationsEnabled(self, isTreeAnnotations): self._isLabelTreeAnnotation = isTreeAnnotations - + def setGenNumTreeAnnotationsEnabled(self, isTreeAnnotations): self._isGenNumTreeAnnotation = isTreeAnnotations - + def isLabelTreeAnnotation(self): return self._isLabelTreeAnnotation def isGenNumTreeAnnotation(self): return self._isGenNumTreeAnnotation - + def setPxMode(self, mode): self.item.setPxMode(mode) - + def update(self): self.item.update() diff --git a/cellacdc/apps.py b/cellacdc/apps.py index 5bc4553c0..07757dbdc 100755 --- a/cellacdc/apps.py +++ b/cellacdc/apps.py @@ -13,6 +13,7 @@ from matplotlib.patches import Rectangle, Circle, PathPatch, Path import numpy as np import scipy.interpolate + try: import tkinter as tk except Exception as err: @@ -23,6 +24,7 @@ from itertools import combinations, permutations from collections import namedtuple from natsort import natsorted + # from MyWidgets import Slider, Button, MyRadioButtons from skimage.measure import label, regionprops from functools import partial @@ -34,9 +36,7 @@ import skimage.registration import skimage.color import skimage.segmentation -from matplotlib.backends.backend_tkagg import ( - FigureCanvasTkAgg, NavigationToolbar2Tk -) +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk import matplotlib.pyplot as plt import seaborn as sns import pandas as pd @@ -47,26 +47,80 @@ import html import pyqtgraph as pg -pg.setConfigOption('imageAxisOrder', 'row-major') + +pg.setConfigOption("imageAxisOrder", "row-major") from qtpy import QtCore from qtpy.QtGui import ( - QIcon, QFontMetrics, QKeySequence, QFont, QRegularExpressionValidator, - QCursor, QKeyEvent, QPixmap, QFont, QPalette, QMouseEvent, QColor + QIcon, + QFontMetrics, + QKeySequence, + QFont, + QRegularExpressionValidator, + QCursor, + QKeyEvent, + QPixmap, + QFont, + QPalette, + QMouseEvent, + QColor, ) from qtpy.QtCore import ( - Qt, QSize, QEvent, Signal, QEventLoop, QTimer, QRegularExpression + Qt, + QSize, + QEvent, + Signal, + QEventLoop, + QTimer, + QRegularExpression, ) from qtpy.QtWidgets import ( - QFileDialog, QApplication, QMainWindow, QMenu, QLabel, QToolBar, - QScrollBar, QWidget, QVBoxLayout, QLineEdit, QPushButton, - QHBoxLayout, QDialog, QFormLayout, QListWidget, QAbstractItemView, - QButtonGroup, QCheckBox, QSizePolicy, QComboBox, QSlider, QGridLayout, - QSpinBox, QToolButton, QTableView, QTextBrowser, QDoubleSpinBox, - QScrollArea, QFrame, QProgressBar, QGroupBox, QRadioButton, - QDockWidget, QMessageBox, QStyle, QPlainTextEdit, QSpacerItem, - QTreeWidget, QTreeWidgetItem, QTextEdit, QSplashScreen, QAction, - QListWidgetItem, QActionGroup, QHeaderView, QStyledItemDelegate + QFileDialog, + QApplication, + QMainWindow, + QMenu, + QLabel, + QToolBar, + QScrollBar, + QWidget, + QVBoxLayout, + QLineEdit, + QPushButton, + QHBoxLayout, + QDialog, + QFormLayout, + QListWidget, + QAbstractItemView, + QButtonGroup, + QCheckBox, + QSizePolicy, + QComboBox, + QSlider, + QGridLayout, + QSpinBox, + QToolButton, + QTableView, + QTextBrowser, + QDoubleSpinBox, + QScrollArea, + QFrame, + QProgressBar, + QGroupBox, + QRadioButton, + QDockWidget, + QMessageBox, + QStyle, + QPlainTextEdit, + QSpacerItem, + QTreeWidget, + QTreeWidgetItem, + QTextEdit, + QSplashScreen, + QAction, + QListWidgetItem, + QActionGroup, + QHeaderView, + QStyledItemDelegate, ) import qtpy.compat @@ -98,7 +152,7 @@ POSITIVE_FLOAT_REGEX = float_regex(allow_negative=False) TREEWIDGET_STYLESHEET = _palettes.TreeWidgetStyleSheet() LISTWIDGET_STYLESHEET = _palettes.ListWidgetStyleSheet() -BACKGROUND_RGBA = _palettes.get_disabled_colors()['Button'] +BACKGROUND_RGBA = _palettes.get_disabled_colors()["Button"] font = QFont() font.setPixelSize(12) @@ -106,8 +160,11 @@ italicFont.setPixelSize(12) italicFont.setItalic(True) + class ArgWidget: - def __init__(self, name, type, widget, defaultVal, valueSetter, valueGetter, changeSig=None): + def __init__( + self, name, type, widget, defaultVal, valueSetter, valueGetter, changeSig=None + ): self.name = name self.type = type self.widget = widget @@ -125,37 +182,45 @@ def addCustomModelMessages(QParent=None): Do you already have the acdcSegment.py file for your code or do you need instructions on how to set-up your custom model?
""") - infoButton = widgets.infoPushButton(' I need instructions') - browseButton = widgets.browseFileButton(' I have the model, let me select it') + infoButton = widgets.infoPushButton(" I need instructions") + browseButton = widgets.browseFileButton(" I have the model, let me select it") msg.information( - QParent, 'Add custom model', txt, - buttonsTexts=('Cancel', infoButton, browseButton), - showDialog=False + QParent, + "Add custom model", + txt, + buttonsTexts=("Cancel", infoButton, browseButton), + showDialog=False, ) browseButton.clicked.disconnect() browseButton.clicked.connect(msg.buttonCallBack) msg.exec_() if msg.cancel: return - if msg.clickedButton == infoButton: + if msg.clickedButton == infoButton: txt = myutils.get_add_custom_model_instructions() msg = widgets.myMessageBox(showCentered=False, wrapText=False) msg.information( - QParent, 'Custom model instructions', txt, buttonsTexts=('Ok',), + QParent, + "Custom model instructions", + txt, + buttonsTexts=("Ok",), path_to_browse=models_path, - browse_button_text='Open models folder...' + browse_button_text="Open models folder...", ) else: homePath = pathlib.Path.home() modelFilePath = QFileDialog.getOpenFileName( - QParent, 'Select the acdcSegment.py file of your model', - str(homePath), 'acdcSegment.py file (*.py);;All files (*)' + QParent, + "Select the acdcSegment.py file of your model", + str(homePath), + "acdcSegment.py file (*.py);;All files (*)", )[0] if not modelFilePath: return - + return modelFilePath + def addCustomPromptModelMessages(QParent=None): modelFilePath = None msg = widgets.myMessageBox(showCentered=False, wrapText=False) @@ -163,43 +228,50 @@ def addCustomPromptModelMessages(QParent=None): Do you already have the acdcPromptSegment.py file for your code or do you need instructions on how to set-up your custom model?
""") - infoButton = widgets.infoPushButton(' I need instructions') - browseButton = widgets.browseFileButton(' I have the model, let me select it') + infoButton = widgets.infoPushButton(" I need instructions") + browseButton = widgets.browseFileButton(" I have the model, let me select it") msg.information( - QParent, 'Add custom promptable model', txt, - buttonsTexts=('Cancel', infoButton, browseButton), - showDialog=False + QParent, + "Add custom promptable model", + txt, + buttonsTexts=("Cancel", infoButton, browseButton), + showDialog=False, ) browseButton.clicked.disconnect() browseButton.clicked.connect(msg.buttonCallBack) msg.exec_() if msg.cancel: return - if msg.clickedButton == infoButton: + if msg.clickedButton == infoButton: txt = myutils.get_add_custom_prompt_model_instructions() msg = widgets.myMessageBox(showCentered=False, wrapText=False) msg.information( - QParent, 'Custom promptable model instructions', - txt, buttonsTexts=('Ok',), + QParent, + "Custom promptable model instructions", + txt, + buttonsTexts=("Ok",), path_to_browse=promptable_models_path, - browse_button_text='Open promptable models folder...' + browse_button_text="Open promptable models folder...", ) else: homePath = pathlib.Path.home() modelFilePath = QFileDialog.getOpenFileName( - QParent, 'Select the acdcPromptSegment.py file of your model', - str(homePath), 'acdcPromptSegment.py file (*.py);;All files (*)' + QParent, + "Select the acdcPromptSegment.py file of your model", + str(homePath), + "acdcPromptSegment.py file (*.py);;All files (*)", )[0] if not modelFilePath: return - + return modelFilePath + class QBaseDialog(_base_widgets.QBaseDialog): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - - + + class customAnnotationDialog(QDialog): sigDeleteSelecAnnot = Signal(object) @@ -209,28 +281,24 @@ def __init__(self, savedCustomAnnot, parent=None, state=None): self.clickedButton = None self.savedCustomAnnot = savedCustomAnnot - self.internalNames = measurements.get_all_acdc_df_colnames( - include_custom=False - ) + self.internalNames = measurements.get_all_acdc_df_colnames(include_custom=False) super().__init__(parent) - self.setWindowTitle('Custom annotation') + self.setWindowTitle("Custom annotation") self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) layout = widgets.FormLayout() row = 0 typeCombobox = QComboBox() - typeCombobox.addItems([ - 'Single time-point', - 'Multiple time-points', - 'Multiple values class' - ]) + typeCombobox.addItems( + ["Single time-point", "Multiple time-points", "Multiple values class"] + ) if state is not None: - typeCombobox.setCurrentText(state['type']) + typeCombobox.setCurrentText(state["type"]) self.typeCombobox = typeCombobox - body_txt = (""" + body_txt = """ Single time-point annotation: use this to annotate an event that happens on a single frame in time (e.g. cell division). @@ -242,17 +310,20 @@ def __init__(self, savedCustomAnnot, parent=None, state=None): that has multiple values. An example could be a cell cycle stage that can have different values, such as 2-cells division or 4-cells division. - """) - typeInfoTxt = (f'{html_utils.paragraph(body_txt)}') + """ + typeInfoTxt = f"{html_utils.paragraph(body_txt)}" self.typeWidget = widgets.formWidget( - typeCombobox, addInfoButton=True, labelTextLeft='Type: ', - parent=self, infoTxt=typeInfoTxt + typeCombobox, + addInfoButton=True, + labelTextLeft="Type: ", + parent=self, + infoTxt=typeInfoTxt, ) layout.addFormWidget(self.typeWidget, row=row) typeCombobox.currentTextChanged.connect(self.warnType) row += 1 - nameInfoTxt = (""" + nameInfoTxt = """ Name of the column that will be saved in the acdc_output.csv file.

Valid charachters are letters and numbers separate by underscore @@ -260,85 +331,93 @@ def __init__(self, savedCustomAnnot, parent=None, state=None): Additionally, some names are reserved because they are used by Cell-ACDC for standard measurements.

Internally reserved names: - """) - self.nameInfoTxt = (f'{html_utils.paragraph(nameInfoTxt)}') + """ + self.nameInfoTxt = f"{html_utils.paragraph(nameInfoTxt)}" self.nameWidget = widgets.formWidget( - widgets.alphaNumericLineEdit(), addInfoButton=True, - labelTextLeft='Name: ', parent=self, infoTxt=self.nameInfoTxt + widgets.alphaNumericLineEdit(), + addInfoButton=True, + labelTextLeft="Name: ", + parent=self, + infoTxt=self.nameInfoTxt, ) self.nameWidget.infoButton.disconnect() self.nameWidget.infoButton.clicked.connect(self.showNameInfo) if state is not None: - self.nameWidget.widget.setText(state['name']) + self.nameWidget.widget.setText(state["name"]) self.nameWidget.widget.textChanged.connect(self.checkName) layout.addFormWidget(self.nameWidget, row=row) row += 1 self.nameInfoLabel = QLabel() - layout.addWidget( - self.nameInfoLabel, row, 0, 1, 2, alignment=Qt.AlignCenter - ) + layout.addWidget(self.nameInfoLabel, row, 0, 1, 2, alignment=Qt.AlignCenter) row += 1 spacing = QSpacerItem(10, 10) layout.addItem(spacing, row, 0) row += 1 - symbolInfoTxt = (""" + symbolInfoTxt = """ Symbol that will be drawn on the annotated cell at the requested time frame. - """) - symbolInfoTxt = (f'{html_utils.paragraph(symbolInfoTxt)}') + """ + symbolInfoTxt = f"{html_utils.paragraph(symbolInfoTxt)}" self.symbolWidget = widgets.formWidget( - widgets.pgScatterSymbolsCombobox(), addInfoButton=True, - labelTextLeft='Symbol: ', parent=self, infoTxt=symbolInfoTxt + widgets.pgScatterSymbolsCombobox(), + addInfoButton=True, + labelTextLeft="Symbol: ", + parent=self, + infoTxt=symbolInfoTxt, ) if state is not None: - self.symbolWidget.widget.setCurrentText(state['symbol']) + self.symbolWidget.widget.setCurrentText(state["symbol"]) layout.addFormWidget(self.symbolWidget, row=row) row += 1 - shortcutInfoTxt = (""" + shortcutInfoTxt = """ Shortcut that you can use to activate/deactivate annotation of this event.

Leave empty if you don't need a shortcut. - """) - shortcutInfoTxt = (f'{html_utils.paragraph(shortcutInfoTxt)}') + """ + shortcutInfoTxt = f"{html_utils.paragraph(shortcutInfoTxt)}" self.shortcutWidget = widgets.formWidget( - widgets.ShortcutLineEdit(), addInfoButton=True, - labelTextLeft='Shortcut: ', parent=self, infoTxt=shortcutInfoTxt + widgets.ShortcutLineEdit(), + addInfoButton=True, + labelTextLeft="Shortcut: ", + parent=self, + infoTxt=shortcutInfoTxt, ) if state is not None: - self.shortcutWidget.widget.setText(state['shortcut']) + self.shortcutWidget.widget.setText(state["shortcut"]) layout.addFormWidget(self.shortcutWidget, row=row) row += 1 - descInfoTxt = (""" + descInfoTxt = """ Description will be used as the tool tip that will be displayed when you hover with th mouse cursor on the toolbar button specific for this annotation - """) - descInfoTxt = (f'{html_utils.paragraph(descInfoTxt)}') + """ + descInfoTxt = f"{html_utils.paragraph(descInfoTxt)}" self.descWidget = widgets.formWidget( - QPlainTextEdit(), addInfoButton=True, - labelTextLeft='Description: ', parent=self, infoTxt=descInfoTxt + QPlainTextEdit(), + addInfoButton=True, + labelTextLeft="Description: ", + parent=self, + infoTxt=descInfoTxt, ) if state is not None: - self.descWidget.widget.setPlainText(state['description']) + self.descWidget.widget.setPlainText(state["description"]) layout.addFormWidget(self.descWidget, row=row) row += 1 - optionsGroupBox = QGroupBox('Additional options') + optionsGroupBox = QGroupBox("Additional options") optionsLayout = QGridLayout() toggle = widgets.Toggle() toggle.setChecked(True) self.keepActiveToggle = toggle - toggleLabel = QLabel('Keep tool active after using it: ') - colorButtonLabel = QLabel('Symbol color: ') + toggleLabel = QLabel("Keep tool active after using it: ") + colorButtonLabel = QLabel("Symbol color: ") self.hideAnnotTooggle = widgets.Toggle() self.hideAnnotTooggle.setChecked(True) - hideAnnotTooggleLabel = QLabel( - 'Hide annotation when button is not active: ' - ) + hideAnnotTooggleLabel = QLabel("Hide annotation when button is not active: ") self.colorButton = widgets.myColorButton(color=(255, 0, 0)) self.colorButton.clicked.disconnect() self.colorButton.clicked.connect(self.selectColor) @@ -368,21 +447,19 @@ def __init__(self, savedCustomAnnot, parent=None, state=None): row += 1 noteText = ( - 'NOTE: you can change these options later with
' - 'RIGHT-click on the associated left-side toolbar button.
' + "NOTE: you can change these options later with
" + "RIGHT-click on the associated left-side toolbar button.
" ) - noteLabel = QLabel(html_utils.paragraph(noteText, font_size='11px')) + noteLabel = QLabel(html_utils.paragraph(noteText, font_size="11px")) layout.addWidget(noteLabel, row, 1, 1, 3) buttonsLayout = QHBoxLayout() - self.loadSavedAnnotButton = widgets.OpenFilePushButton( - ' Load annotation... ' - ) + self.loadSavedAnnotButton = widgets.OpenFilePushButton(" Load annotation... ") if not savedCustomAnnot: self.loadSavedAnnotButton.setDisabled(True) - self.okButton = widgets.okPushButton(' Ok ') - cancelButton = widgets.cancelPushButton('Cancel') + self.okButton = widgets.okPushButton(" Ok ") + cancelButton = widgets.cancelPushButton("Cancel") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -398,11 +475,11 @@ def __init__(self, savedCustomAnnot, parent=None, state=None): mainLayout = QVBoxLayout() - noteTxt = (""" + noteTxt = """ Custom annotations will be saved in the acdc_output.csv
file as a column with the name you write in the field Name
- """) - noteTxt = (f'{html_utils.paragraph(noteTxt, font_size="15px")}') + """ + noteTxt = f"{html_utils.paragraph(noteTxt, font_size='15px')}" noteLabel = QLabel(noteTxt) noteLabel.setAlignment(Qt.AlignCenter) mainLayout.addWidget(noteLabel) @@ -416,35 +493,30 @@ def __init__(self, savedCustomAnnot, parent=None, state=None): def checkName(self, text): if not text: - txt = 'Name cannot be empty' + txt = "Name cannot be empty" self.nameInfoLabel.setText( - html_utils.paragraph( - txt, font_size='11px', font_color='red' - ) + html_utils.paragraph(txt, font_size="11px", font_color="red") ) return for name in self.internalNames: if name.find(text) != -1: - txt = ( - f'"{text}" cannot be part of the name, ' - 'because reserved.' - ) + txt = f'"{text}" cannot be part of the name, because reserved.' self.nameInfoLabel.setText( - html_utils.paragraph( - txt, font_size='11px', font_color='red' - ) + html_utils.paragraph(txt, font_size="11px", font_color="red") ) break else: - self.nameInfoLabel.setText('') + self.nameInfoLabel.setText("") def loadSavedAnnot(self): items = list(self.savedCustomAnnot.keys()) self.selectAnnotWin = widgets.QDialogListbox( - 'Load annotation parameters', - 'Select annotation to load:', items, - additionalButtons=('Delete selected annnotations', ), - parent=self, multiSelection=False + "Load annotation parameters", + "Select annotation to load:", + items, + additionalButtons=("Delete selected annnotations",), + parent=self, + multiSelection=False, ) for button in self.selectAnnotWin._additionalButtons: button.disconnect() @@ -459,31 +531,33 @@ def loadSavedAnnot(self): return selectedName = self.selectAnnotWin.selectedItemsText[-1] selectedAnnot = self.savedCustomAnnot[selectedName] - self.typeCombobox.setCurrentText(selectedAnnot['type']) - self.nameWidget.widget.setText(selectedAnnot['name']) - self.symbolWidget.widget.setCurrentText(selectedAnnot['symbol']) - self.shortcutWidget.widget.setText(selectedAnnot['shortcut']) - self.descWidget.widget.setPlainText(selectedAnnot['description']) - self.colorButton.setColor(selectedAnnot['symbolColor']) - keySequence = widgets.macShortcutToWindows(selectedAnnot['shortcut']) + self.typeCombobox.setCurrentText(selectedAnnot["type"]) + self.nameWidget.widget.setText(selectedAnnot["name"]) + self.symbolWidget.widget.setCurrentText(selectedAnnot["symbol"]) + self.shortcutWidget.widget.setText(selectedAnnot["shortcut"]) + self.descWidget.widget.setPlainText(selectedAnnot["description"]) + self.colorButton.setColor(selectedAnnot["symbolColor"]) + keySequence = widgets.macShortcutToWindows(selectedAnnot["shortcut"]) if keySequence: - self.shortcutWidget.widget.keySequence = widgets.KeySequenceFromText(keySequence) + self.shortcutWidget.widget.keySequence = widgets.KeySequenceFromText( + keySequence + ) def warnNoItemsSelected(self): msg = widgets.myMessageBox(parent=self) - msg.setIcon(iconName='SP_MessageBoxWarning') - msg.setWindowTitle('Delete annotation?') - msg.addText('You didn\'t select any annotation!') - msg.addButton(' Ok ') + msg.setIcon(iconName="SP_MessageBoxWarning") + msg.setWindowTitle("Delete annotation?") + msg.addText("You didn't select any annotation!") + msg.addButton(" Ok ") msg.exec_() def deleteSelectedAnnot(self): msg = widgets.myMessageBox(parent=self) - msg.setIcon(iconName='SP_MessageBoxWarning') - msg.setWindowTitle('Delete annotation?') - msg.addText('Are you sure you want to delete the selected annotations?') - msg.addButton('Yes') - cancelButton = msg.addButton(' Cancel ') + msg.setIcon(iconName="SP_MessageBoxWarning") + msg.setWindowTitle("Delete annotation?") + msg.addText("Are you sure you want to delete the selected annotations?") + msg.addButton("Yes") + cancelButton = msg.addButton(" Cancel ") msg.exec_() if msg.clickedButton == cancelButton: return @@ -499,37 +573,35 @@ def selectColor(self): color = self.colorButton.color() self.colorButton.origColor = color self.colorButton.colorDialog.setCurrentColor(color) - self.colorButton.colorDialog.setWindowFlags( - Qt.Window | Qt.WindowStaysOnTopHint - ) + self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) self.colorButton.colorDialog.open() w = self.width() left = self.pos().x() colorDialogTop = self.colorButton.colorDialog.pos().y() - self.colorButton.colorDialog.move(w+left+10, colorDialogTop) + self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) def warnType(self, currentText): - if currentText == 'Single time-point': + if currentText == "Single time-point": return self.typeCombobox.setCurrentIndex(0) - txt = (""" + txt = """ Unfortunately, the only annotation type that is available so far is Single time-point.

We are working on implementing the other types too, so stay tuned!

Thank you for your patience! - """) - txt = (f'{html_utils.paragraph(txt)}') + """ + txt = f"{html_utils.paragraph(txt)}" msg = widgets.myMessageBox() - msg.setIcon(iconName='SP_MessageBoxWarning') - msg.setWindowTitle(f'Feature not implemented yet') + msg.setIcon(iconName="SP_MessageBoxWarning") + msg.setWindowTitle(f"Feature not implemented yet") msg.addText(txt) - msg.addButton(' Ok ') + msg.addButton(" Ok ") msg.exec_() def showOptionsInfo(self): - info = (""" + info = """ Keep tool active after using it: Choose whether the tool should stay active or not after annotating.

Hide annotation when button is not active: Choose whether @@ -539,13 +611,13 @@ def showOptionsInfo(self): they are visible or not.

Symbol color: Choose color of the symbol that will be used to label annotated cell/object. - """) - info = (f'{html_utils.paragraph(info)}') + """ + info = f"{html_utils.paragraph(info)}" msg = widgets.myMessageBox() msg.setIcon() - msg.setWindowTitle(f'Additional options info') + msg.setWindowTitle(f"Additional options info") msg.addText(info) - msg.addButton(' Ok ') + msg.addButton(" Ok ") msg.exec_() def ok_cb(self, checked=True): @@ -564,26 +636,23 @@ def showNameInfo(self): listView.addItems(self.internalNames) # listView.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) msg.information( - self, 'Annotation Name info', self.nameInfoTxt, - widgets=listView + self, "Annotation Name info", self.nameInfoTxt, widgets=listView ) def closeEvent(self, event): - if self.clickedButton is None or self.clickedButton==self.cancelButton: + if self.clickedButton is None or self.clickedButton == self.cancelButton: # cancel button or closed with 'x' button self.cancel = True return - if self.clickedButton==self.okButton and not self.nameWidget.widget.text(): + if self.clickedButton == self.okButton and not self.nameWidget.widget.text(): msg = QMessageBox() - msg.critical( - self, 'Empty name', 'The name cannot be empty!', msg.Ok - ) + msg.critical(self, "Empty name", "The name cannot be empty!", msg.Ok) event.ignore() self.cancel = True return - if self.clickedButton==self.okButton and self.nameInfoLabel.text(): + if self.clickedButton == self.okButton and self.nameInfoLabel.text(): msg = widgets.myMessageBox() listView = widgets.listWidget(msg) listView.addItems(self.internalNames) @@ -591,23 +660,22 @@ def closeEvent(self, event): name = self.nameWidget.widget.text() txt = ( f'"{name}" cannot be part of the name, ' - 'because it is reserved for standard measurements ' - 'saved by Cell-ACDC.

' - 'Internally reserved names:' + "because it is reserved for standard measurements " + "saved by Cell-ACDC.

" + "Internally reserved names:" ) msg.critical( - self, 'Not a valid name', html_utils.paragraph(txt), - widgets=listView + self, "Not a valid name", html_utils.paragraph(txt), widgets=listView ) event.ignore() self.cancel = True return self.toolTip = ( - f'Name: {self.nameWidget.widget.text()}\n\n' - f'Type: {self.typeWidget.widget.currentText()}\n\n' - f'Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n' - f'Description: {self.descWidget.widget.toPlainText()}\n\n' + f"Name: {self.nameWidget.widget.text()}\n\n" + f"Type: {self.typeWidget.widget.currentText()}\n\n" + f"Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n" + f"Description: {self.descWidget.widget.toPlainText()}\n\n" f'SHORTCUT: "{self.shortcutWidget.widget.text()}"' ) @@ -615,14 +683,14 @@ def closeEvent(self, event): self.symbol = re.findall(r"\'(.+)\'", symbol)[0] self.state = { - 'type': self.typeWidget.widget.currentText(), - 'name': self.nameWidget.widget.text(), - 'symbol': self.symbolWidget.widget.currentText(), - 'shortcut': self.shortcutWidget.widget.text(), - 'description': self.descWidget.widget.toPlainText(), - 'keepActive': self.keepActiveToggle.isChecked(), - 'isHideChecked': self.hideAnnotTooggle.isChecked(), - 'symbolColor': self.colorButton.color() + "type": self.typeWidget.widget.currentText(), + "name": self.nameWidget.widget.text(), + "symbol": self.symbolWidget.widget.currentText(), + "shortcut": self.shortcutWidget.widget.text(), + "description": self.descWidget.widget.toPlainText(), + "keepActive": self.keepActiveToggle.isChecked(), + "isHideChecked": self.hideAnnotTooggle.isChecked(), + "symbolColor": self.colorButton.color(), } if self.loop is not None: @@ -637,115 +705,121 @@ def show(self, block=False): self.loop = QEventLoop() self.loop.exec_() + class _PointsLayerAppearanceGroupbox(QGroupBox): def __init__(self, *args): super().__init__(*args) - self.setTitle('Points appearance') + self.setTitle("Points appearance") layout = widgets.FormLayout() - '----------------------------------------------------------------------' + "----------------------------------------------------------------------" row = 0 - symbolInfoTxt = (""" + symbolInfoTxt = """ Symbol used to draw the points. - """) - symbolInfoTxt = (f'{html_utils.paragraph(symbolInfoTxt)}') + """ + symbolInfoTxt = f"{html_utils.paragraph(symbolInfoTxt)}" self.symbolWidget = widgets.formWidget( - widgets.pgScatterSymbolsCombobox(), addInfoButton=True, - labelTextLeft='Symbol: ', parent=self, infoTxt=symbolInfoTxt, - stretchWidget=False + widgets.pgScatterSymbolsCombobox(), + addInfoButton=True, + labelTextLeft="Symbol: ", + parent=self, + infoTxt=symbolInfoTxt, + stretchWidget=False, ) layout.addFormWidget(self.symbolWidget, row=row) - '----------------------------------------------------------------------' + "----------------------------------------------------------------------" - '----------------------------------------------------------------------' + "----------------------------------------------------------------------" row += 1 self.colorButton = widgets.myColorButton(color=(255, 0, 0)) self.colorWidget = widgets.formWidget( - self.colorButton, stretchWidget=True, - labelTextLeft='Colour: ', parent=self + self.colorButton, stretchWidget=True, labelTextLeft="Colour: ", parent=self ) layout.addFormWidget(self.colorWidget, align=Qt.AlignLeft, row=row) self.colorButton.clicked.disconnect() self.colorButton.clicked.connect(self.selectColor) - '----------------------------------------------------------------------' + "----------------------------------------------------------------------" - '----------------------------------------------------------------------' + "----------------------------------------------------------------------" row += 1 self.sizeSpinBox = widgets.SpinBox() self.sizeSpinBox.setValue(5) self.sizeWidget = widgets.formWidget( - self.sizeSpinBox, stretchWidget=True, - labelTextLeft='Size: ', parent=self + self.sizeSpinBox, stretchWidget=True, labelTextLeft="Size: ", parent=self ) layout.addFormWidget(self.sizeWidget, row=row) - '----------------------------------------------------------------------' - - '----------------------------------------------------------------------' + "----------------------------------------------------------------------" + + "----------------------------------------------------------------------" row += 1 zHeightTooltip = ( 'If "Z-depth" is greater than 1, the points will be annotated ' - 'in all the z-slices in the range `z - (Z-depth/2) < z < z + (Z-depth/2)`\n' - 'where `z` is the center z-slice of the added point.' + "in all the z-slices in the range `z - (Z-depth/2) < z < z + (Z-depth/2)`\n" + "where `z` is the center z-slice of the added point." ) self.zHeightSpinBox = widgets.OddSpinBox() self.zHeightSpinBox.setValue(1) self.zHeightSpinBox.setMinimum(1) self.zHeightWidget = widgets.formWidget( - self.zHeightSpinBox, stretchWidget=True, - labelTextLeft='Z-depth: ', parent=self, - toolTip=zHeightTooltip + self.zHeightSpinBox, + stretchWidget=True, + labelTextLeft="Z-depth: ", + parent=self, + toolTip=zHeightTooltip, ) layout.addFormWidget(self.zHeightWidget, row=row) - '----------------------------------------------------------------------' + "----------------------------------------------------------------------" - '----------------------------------------------------------------------' + "----------------------------------------------------------------------" row += 1 - shortcutInfoTxt = (""" + shortcutInfoTxt = """ Shortcut that you can use to hide/show points. - """) - shortcutInfoTxt = (f'{html_utils.paragraph(shortcutInfoTxt)}') + """ + shortcutInfoTxt = f"{html_utils.paragraph(shortcutInfoTxt)}" self.shortcutWidget = widgets.formWidget( - widgets.ShortcutLineEdit(), addInfoButton=True, - labelTextLeft='Shortcut: ', parent=self, infoTxt=shortcutInfoTxt + widgets.ShortcutLineEdit(), + addInfoButton=True, + labelTextLeft="Shortcut: ", + parent=self, + infoTxt=shortcutInfoTxt, ) layout.addFormWidget(self.shortcutWidget, row=row) - '----------------------------------------------------------------------' + "----------------------------------------------------------------------" self.setLayout(layout) - + def restoreState(self, state): - self.shortcutWidget.widget.setText(state['shortcut']) - self.colorButton.setColor(state['color']) - self.symbolWidget.widget.setCurrentText(state['symbol']) - self.sizeSpinBox.setValue(state['pointSize']) - self.zHeightSpinBox.setValue(state['zHeight']) - + self.shortcutWidget.widget.setText(state["shortcut"]) + self.colorButton.setColor(state["color"]) + self.symbolWidget.widget.setCurrentText(state["symbol"]) + self.sizeSpinBox.setValue(state["pointSize"]) + self.zHeightSpinBox.setValue(state["zHeight"]) + def selectColor(self): color = self.colorButton.color() self.colorButton.origColor = color self.colorButton.colorDialog.setCurrentColor(color) - self.colorButton.colorDialog.setWindowFlags( - Qt.Window | Qt.WindowStaysOnTopHint - ) - self.colorButton.colorDialog.open() + self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + self.colorButton.colorDialog.open() w = self.width() left = self.pos().x() colorDialogTop = self.colorButton.colorDialog.pos().y() - self.colorButton.colorDialog.move(w+left+10, colorDialogTop) - + self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) + def state(self): - r,g,b,a = self.colorButton.color().getRgb() + r, g, b, a = self.colorButton.color().getRgb() _state = { - 'symbol': self.symbolWidget.widget.currentText(), - 'color': (r,g,b), - 'pointSize': self.sizeSpinBox.value(), - 'zHeight': self.zHeightSpinBox.value(), - 'shortcut': self.shortcutWidget.widget.text() + "symbol": self.symbolWidget.widget.currentText(), + "color": (r, g, b), + "pointSize": self.sizeSpinBox.value(), + "zHeight": self.zHeightSpinBox.value(), + "shortcut": self.shortcutWidget.widget.text(), } return _state + class AddPointsLayerDialog(QBaseDialog): sigClosed = Signal() sigCriticalReadTable = Signal(str) @@ -753,17 +827,17 @@ class AddPointsLayerDialog(QBaseDialog): sigCheckClickEntryTableEndnameExists = Signal(str, bool) def __init__( - self, - channelNames=None, - imagesPath='', - SizeT=1, - hideCentroidsSection=False, - hideWeightedCentroidsSection=False, - hideFromTableSection=False, - hideManualEntrySection=False, - hideWithMouseClicksSection=False, - parent=None, - ): + self, + channelNames=None, + imagesPath="", + SizeT=1, + hideCentroidsSection=False, + hideWeightedCentroidsSection=False, + hideFromTableSection=False, + hideManualEntrySection=False, + hideWithMouseClicksSection=False, + parent=None, + ): self.cancel = True super().__init__(parent) @@ -771,37 +845,38 @@ def __init__( self.imagesPath = imagesPath - self.setWindowTitle('Add points layer') + self.setWindowTitle("Add points layer") self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) mainLayout = QVBoxLayout() scrollArea = widgets.ScrollArea() - typeGroupbox = QGroupBox('Points to draw') + typeGroupbox = QGroupBox("Points to draw") typeLayout = QGridLayout() typeGroupbox.setLayout(typeLayout) - typeLayout.addItem(QSpacerItem(10,1), 0, 0) + typeLayout.addItem(QSpacerItem(10, 1), 0, 0) typeLayout.setColumnStretch(0, 0) typeLayout.setColumnStretch(2, 1) vSpacing = 15 - + row = 0 - + sections = ( - ('addCentroidsSection', hideCentroidsSection), - ('addWeightedCentroidsSection', hideWeightedCentroidsSection), - ('addFromTableSection', hideFromTableSection), - ('addManualEntrySection', hideManualEntrySection), - ('addWithMouseClicksSection', hideWithMouseClicksSection) + ("addCentroidsSection", hideCentroidsSection), + ("addWeightedCentroidsSection", hideWeightedCentroidsSection), + ("addFromTableSection", hideFromTableSection), + ("addManualEntrySection", hideManualEntrySection), + ("addWithMouseClicksSection", hideWithMouseClicksSection), ) radioButtonChecked = False for section, hideSection in sections: addFunc = getattr(self, section) row, sectionWidgets = addFunc( - row, typeLayout, - imagesPath=imagesPath, + row, + typeLayout, + imagesPath=imagesPath, SizeT=SizeT, - channelNames=channelNames + channelNames=channelNames, ) if not hideSection: spacer = QSpacerItem(1, vSpacing) @@ -811,7 +886,7 @@ def __init__( sectionWidgets[0].setChecked(True) radioButtonChecked = True continue - + for widget in sectionWidgets: widget.setVisible(False) @@ -839,34 +914,32 @@ def __init__( self.setLayout(mainLayout) self.setFont(font) - + def addCentroidsSection(self, row, layout, **kwargs): sectionWidgets = [] - self.centroidsRadiobutton = QRadioButton('Centroids') + self.centroidsRadiobutton = QRadioButton("Centroids") layout.addWidget(self.centroidsRadiobutton, row, 0, 1, 2) sectionWidgets.append(self.centroidsRadiobutton) - + self.centroidsRadiobutton.setChecked(True) return row + 1, sectionWidgets - - def addWeightedCentroidsSection( - self, row, layout, channelNames=None, **kwargs - ): + + def addWeightedCentroidsSection(self, row, layout, channelNames=None, **kwargs): if channelNames is None: channelNames = [] - + sectionWidgets = [] - - self.weightedCentroidsRadiobutton = QRadioButton('Weighted centroids') + + self.weightedCentroidsRadiobutton = QRadioButton("Weighted centroids") layout.addWidget(self.weightedCentroidsRadiobutton, row, 0, 1, 2) sectionWidgets.append(self.weightedCentroidsRadiobutton) row += 1 - label = QLabel('Weighing channel: ') + label = QLabel("Weighing channel: ") label.setEnabled(False) layout.addWidget(label, row, 1) sectionWidgets.append(label) - + self.channelNameForWeightedCentr = widgets.QCenteredComboBox() if channelNames: self.channelNameForWeightedCentr.addItems(channelNames) @@ -878,22 +951,20 @@ def addWeightedCentroidsSection( self.weightedCentroidsRadiobutton.toggled.connect( self.channelNameForWeightedCentr.setEnabled ) - + return row + 1, sectionWidgets - - def addFromTableSection( - self, row, layout, imagesPath='', SizeT=1, **kwargs - ): + + def addFromTableSection(self, row, layout, imagesPath="", SizeT=1, **kwargs): sectionWidgets = [] - - self.fromTableRadiobutton = QRadioButton('From table') + + self.fromTableRadiobutton = QRadioButton("From table") layout.addWidget(self.fromTableRadiobutton, row, 0, 1, 2) sectionWidgets.append(self.fromTableRadiobutton) self.fromTableRadiobutton.widgets = [] - + row += 1 self.tablePath = widgets.ElidingLineEdit() - self.tablePath.label = QLabel('Table file path: ') + self.tablePath.label = QLabel("Table file path: ") layout.addWidget(self.tablePath.label, row, 1) layout.addWidget(self.tablePath, row, 2) self.fromTableRadiobutton.widgets.append(self.tablePath) @@ -901,7 +972,7 @@ def addFromTableSection( sectionWidgets.append(self.tablePath) browseButton = widgets.browseFileButton( - start_dir=imagesPath, ext={'Table': ['.csv', '.h5']} + start_dir=imagesPath, ext={"Table": [".csv", ".h5"]} ) layout.addWidget(browseButton, row, 3) browseButton.sigPathSelected.connect(self.tablePathSelected) @@ -911,8 +982,8 @@ def addFromTableSection( row += 1 self.xColName = widgets.QCenteredComboBox() - self.xColName.addItem('None') - self.xColName.label = QLabel('X coord. column: ') + self.xColName.addItem("None") + self.xColName.label = QLabel("X coord. column: ") layout.addWidget(self.xColName.label, row, 1) layout.addWidget(self.xColName, row, 2) self.xColName.currentTextChanged.connect(self.checkColNameX) @@ -922,8 +993,8 @@ def addFromTableSection( row += 1 self.yColName = widgets.QCenteredComboBox() - self.yColName.addItem('None') - self.yColName.label = QLabel('Y coord. column: ') + self.yColName.addItem("None") + self.yColName.label = QLabel("Y coord. column: ") layout.addWidget(self.yColName.label, row, 1) layout.addWidget(self.yColName, row, 2) self.yColName.currentTextChanged.connect(self.checkColNameY) @@ -933,8 +1004,8 @@ def addFromTableSection( row += 1 self.zColName = widgets.QCenteredComboBox() - self.zColName.addItem('None') - self.zColName.label = QLabel('Z coord. column: ') + self.zColName.addItem("None") + self.zColName.label = QLabel("Z coord. column: ") layout.addWidget(self.zColName.label, row, 1) layout.addWidget(self.zColName, row, 2) self.zColName.currentTextChanged.connect(self.checkColNameZ) @@ -944,8 +1015,8 @@ def addFromTableSection( row += 1 self.tColName = widgets.QCenteredComboBox() - self.tColName.addItem('None') - self.tColName.label = QLabel('Frame index column: ') + self.tColName.addItem("None") + self.tColName.label = QLabel("Frame index column: ") layout.addWidget(self.tColName.label, row, 1) layout.addWidget(self.tColName, row, 2) self.fromTableRadiobutton.widgets.append(self.tColName) @@ -954,26 +1025,26 @@ def addFromTableSection( if SizeT == 1: self.tColName.clear() - self.tColName.addItem('None') + self.tColName.addItem("None") self.tColName.label.setVisible(False) self.tColName.setVisible(False) - + self.fromTableRadiobutton.toggled.connect(self.enableRadioButtonWidgets) self.enableRadioButtonWidgets(False, sender=self.fromTableRadiobutton) - + return row + 1, sectionWidgets - + def addManualEntrySection(self, row, layout, SizeT=1, **kwargs): sectionWidgets = [] - - self.manualEntryRadiobutton = QRadioButton('Manual entry') + + self.manualEntryRadiobutton = QRadioButton("Manual entry") layout.addWidget(self.manualEntryRadiobutton, row, 0, 1, 2) self.manualEntryRadiobutton.widgets = [] sectionWidgets.append(self.manualEntryRadiobutton) - + row += 1 self.manualXspinbox = widgets.NumericCommaLineEdit() - self.manualXspinbox.label = QLabel('X coords: ') + self.manualXspinbox.label = QLabel("X coords: ") layout.addWidget(self.manualXspinbox.label, row, 1) layout.addWidget(self.manualXspinbox, row, 2) self.manualEntryRadiobutton.widgets.append(self.manualXspinbox) @@ -982,7 +1053,7 @@ def addManualEntrySection(self, row, layout, SizeT=1, **kwargs): row += 1 self.manualYspinbox = widgets.NumericCommaLineEdit() - self.manualYspinbox.label = QLabel('Y coords: ') + self.manualYspinbox.label = QLabel("Y coords: ") layout.addWidget(self.manualYspinbox.label, row, 1) layout.addWidget(self.manualYspinbox, row, 2) self.manualEntryRadiobutton.widgets.append(self.manualYspinbox) @@ -991,7 +1062,7 @@ def addManualEntrySection(self, row, layout, SizeT=1, **kwargs): row += 1 self.manualZspinbox = widgets.NumericCommaLineEdit() - self.manualZspinbox.label = QLabel('Z coords: ') + self.manualZspinbox.label = QLabel("Z coords: ") layout.addWidget(self.manualZspinbox.label, row, 1) layout.addWidget(self.manualZspinbox, row, 2) self.manualEntryRadiobutton.widgets.append(self.manualZspinbox) @@ -1000,7 +1071,7 @@ def addManualEntrySection(self, row, layout, SizeT=1, **kwargs): row += 1 self.manualTspinbox = widgets.NumericCommaLineEdit() - self.manualTspinbox.label = QLabel('Frame numbers: ') + self.manualTspinbox.label = QLabel("Frame numbers: ") layout.addWidget(self.manualTspinbox.label, row, 1) layout.addWidget(self.manualTspinbox, row, 2) self.manualEntryRadiobutton.widgets.append(self.manualTspinbox) @@ -1010,68 +1081,62 @@ def addManualEntrySection(self, row, layout, SizeT=1, **kwargs): if SizeT == 1: self.manualTspinbox.setVisible(False) self.manualTspinbox.label.setVisible(False) - + self.manualEntryRadiobutton.toggled.connect(self.enableRadioButtonWidgets) self.enableRadioButtonWidgets(False, sender=self.manualEntryRadiobutton) - + return row + 1, sectionWidgets - - def addWithMouseClicksSection(self, row, layout, imagesPath='', **kwargs): + + def addWithMouseClicksSection(self, row, layout, imagesPath="", **kwargs): sectionWidgets = [] - + self.clickEntryIsLoadedDf = None - - self.clickEntryRadiobutton = QRadioButton('Add points with mouse clicks') - layout.addWidget(self.clickEntryRadiobutton, row, 0, 1, 2) - self.clickEntryRadiobutton.widgets = [] + + self.clickEntryRadiobutton = QRadioButton("Add points with mouse clicks") + layout.addWidget(self.clickEntryRadiobutton, row, 0, 1, 2) + self.clickEntryRadiobutton.widgets = [] sectionWidgets.append(self.clickEntryRadiobutton) - + row += 1 self.snapToMaxToggle = widgets.Toggle() - self.snapToMaxToggle.label = QLabel('Snap to closest maximum: ') + self.snapToMaxToggle.label = QLabel("Snap to closest maximum: ") layout.addWidget(self.snapToMaxToggle.label, row, 1) - layout.addWidget( - self.snapToMaxToggle, row, 2, alignment=Qt.AlignCenter - ) + layout.addWidget(self.snapToMaxToggle, row, 2, alignment=Qt.AlignCenter) sectionWidgets.append(self.snapToMaxToggle.label) sectionWidgets.append(self.snapToMaxToggle) - + self.snapToMaxInfoButton = widgets.infoPushButton() layout.addWidget(self.snapToMaxInfoButton, row, 3) sectionWidgets.append(self.snapToMaxInfoButton) - + self.snapToMaxInfoButton.clicked.connect(self.showSnapToMaxButton) self.clickEntryRadiobutton.widgets.append(self.snapToMaxToggle) self.clickEntryRadiobutton.widgets.append(self.snapToMaxInfoButton) - + row += 1 self.autoPilotToggle = widgets.Toggle() - self.autoPilotToggle.label = QLabel('Use auto-pilot: ') + self.autoPilotToggle.label = QLabel("Use auto-pilot: ") layout.addWidget(self.autoPilotToggle.label, row, 1) - layout.addWidget( - self.autoPilotToggle, row, 2, alignment=Qt.AlignCenter - ) + layout.addWidget(self.autoPilotToggle, row, 2, alignment=Qt.AlignCenter) sectionWidgets.append(self.autoPilotToggle.label) sectionWidgets.append(self.autoPilotToggle) self.autoPilotInfoButton = widgets.infoPushButton() layout.addWidget(self.autoPilotInfoButton, row, 3) sectionWidgets.append(self.autoPilotInfoButton) - + self.autoPilotInfoButton.clicked.connect(self.showAutoPilotInfo) self.clickEntryRadiobutton.widgets.append(self.autoPilotToggle) self.clickEntryRadiobutton.widgets.append(self.autoPilotInfoButton) - + row += 1 self.clickEntryTableEndname = widgets.alphaNumericLineEdit() - self.clickEntryTableEndname.setText('points_added_by_clicking') + self.clickEntryTableEndname.setText("points_added_by_clicking") self.clickEntryTableEndname.setAlignment(Qt.AlignCenter) - self.clickEntryTableEndname.label = QLabel('Table endname: ') - loadButton = widgets.browseFileButton( - start_dir=imagesPath, ext={'CSV': '.csv'} - ) + self.clickEntryTableEndname.label = QLabel("Table endname: ") + loadButton = widgets.browseFileButton(start_dir=imagesPath, ext={"CSV": ".csv"}) layout.addWidget(loadButton, row, 3) sectionWidgets.append(loadButton) - + loadButton.sigPathSelected.connect(self.loadClickEntryTable) self.loadButton = loadButton self.clickEntryLoadTableButton = loadButton @@ -1083,57 +1148,53 @@ def addWithMouseClicksSection(self, row, layout, imagesPath='', **kwargs): ) sectionWidgets.append(self.clickEntryTableEndname) sectionWidgets.append(self.clickEntryTableEndname.label) - + row += 1 instructionsText = html_utils.paragraph( - '
Left-click to annotate a new point with a new id.

' - 'Right-click to annotate a point with the same id

' - 'Same click used to delete objects to annotate
' - 'a point with id = 0 (negative prompt)

' - 'Click on point to delete it', - font_size='11px' + "
Left-click to annotate a new point with a new id.

" + "Right-click to annotate a point with the same id

" + "Same click used to delete objects to annotate
" + "a point with id = 0 (negative prompt)

" + "Click on point to delete it", + font_size="11px", ) self.instructionsLabel = QLabel(instructionsText) - self.instructionsLabel.label = QLabel('Instructions') + self.instructionsLabel.label = QLabel("Instructions") layout.addWidget(self.instructionsLabel.label, row, 1) layout.addWidget(self.instructionsLabel, row, 2) self.clickEntryRadiobutton.widgets.append(self.instructionsLabel) sectionWidgets.append(self.instructionsLabel) sectionWidgets.append(self.instructionsLabel.label) - + self.clickEntryRadiobutton.toggled.connect(self.enableRadioButtonWidgets) self.clickEntryRadiobutton.toggled.connect( self.emitCheckClickEntryTableEndnameExists ) self.enableRadioButtonWidgets(False, sender=self.clickEntryRadiobutton) - + return row + 1, sectionWidgets - + def emitCheckClickEntryTableEndnameExists(self, *args, **kwargs): if not self.clickEntryRadiobutton.isChecked(): return self.clickEntryIsLoadedDf = None tableEndName = self.clickEntryTableEndname.text() - self.sigCheckClickEntryTableEndnameExists.emit( - tableEndName, False - ) - + self.sigCheckClickEntryTableEndnameExists.emit(tableEndName, False) + def loadClickEntryTable(self, csv_path): self.clickEntryIsLoadedDf = None - posData = load.loadData(csv_path, 'points') + posData = load.loadData(csv_path, "points") posData.getBasenameAndChNames(qparent=self) basename = posData.basename filename = os.path.basename(csv_path) filename, ext = os.path.splitext(filename) - if not basename.endswith('_'): - basename = f'{basename}_' - - endname = filename[len(basename):] + if not basename.endswith("_"): + basename = f"{basename}_" + + endname = filename[len(basename) :] self.clickEntryTableEndname.setText(endname) - self.sigCheckClickEntryTableEndnameExists.emit( - endname, True - ) - + self.sigCheckClickEntryTableEndnameExists.emit(endname, True) + def showAutoPilotInfo(self): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(""" @@ -1144,8 +1205,8 @@ def showAutoPilotInfo(self): Enter key or go back to the
previous object by pressing Backspace. """) - msg.information(self, 'Auto-pilot info', txt) - + msg.information(self, "Auto-pilot info", txt) + def showSnapToMaxButton(self): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(""" @@ -1154,11 +1215,11 @@ def showSnapToMaxButton(self): to the closest maximum within the point footprint (defined in the appearance settings). """) - msg.information(self, 'Snap to closest maximum info', txt) - + msg.information(self, "Snap to closest maximum info", txt) + def closeEvent(self, event): self.sigClosed.emit() - + def enableRadioButtonWidgets(self, enabled, sender=None): if sender is None: sender = self.sender() @@ -1168,23 +1229,23 @@ def enableRadioButtonWidgets(self, enabled, sender=None): widget.label.setDisabled(not enabled) except: pass - + def _readTable(self, path): return load.load_df_points_layer(path) - + def tryAutoFillColNames(self, df): - if 'x' in df.columns: - self.xColName.setCurrentText('x') - - if 'y' in df.columns: - self.yColName.setCurrentText('y') - - if 'z' in df.columns: - self.zColName.setCurrentText('z') - - if 'frame_i' in df.columns: - self.tColName.setCurrentText('frame_i') - + if "x" in df.columns: + self.xColName.setCurrentText("x") + + if "y" in df.columns: + self.yColName.setCurrentText("y") + + if "z" in df.columns: + self.zColName.setCurrentText("z") + + if "frame_i" in df.columns: + self.tColName.setCurrentText("frame_i") + def tablePathSelected(self, path): self.tablePath.setText(path) try: @@ -1200,23 +1261,22 @@ def tablePathSelected(self, path): traceback_format = traceback.format_exc() self.sigCriticalReadTable.emit(traceback_format) self.criticalReadTable(path, traceback_format) - self.tablePath.setText('') - - + self.tablePath.setText("") + def criticalLenMismatchManualEntry(self): txt = html_utils.paragraph(f""" X coords and Y coords must have the same length. """) msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.critical(self, f'X and Y have different length', txt) - - def criticalColNameIsNone(self, axis): + msg.critical(self, f"X and Y have different length", txt) + + def criticalColNameIsNone(self, axis): txt = html_utils.paragraph(f""" The "{axis.upper()} coord. column" cannot be "None" """) msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.critical(self, f'{axis.upper()} coord. is None', txt) - + msg.critical(self, f"{axis.upper()} coord. is None", txt) + def criticalReadTable(self, path, traceback_format): txt = html_utils.paragraph(f""" Something went wrong when reading the table from the @@ -1226,22 +1286,21 @@ def criticalReadTable(self, path, traceback_format): """) msg = widgets.myMessageBox(showCentered=False, wrapText=False) detailsText = traceback_format - msg.critical( - self, 'Error when reading table', txt, detailsText=detailsText) + msg.critical(self, "Error when reading table", txt, detailsText=detailsText) def criticalEmptyTablePath(self): txt = html_utils.paragraph(f""" The table file path cannot be empty. """) msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.critical(self, 'Table file path is empty', txt) + msg.critical(self, "Table file path is empty", txt) def state(self): - _state = self.appearanceGroupbox.state() + _state = self.appearanceGroupbox.state() return _state def _checkSelectedColName(self, colName, label): - labelsToCheck = ['z', 'y', 'x'] + labelsToCheck = ["z", "y", "x"] labelsToCheck.remove(label) for labelToCheck in labelsToCheck: if colName.find(labelToCheck) != -1: @@ -1253,86 +1312,86 @@ def _checkSelectedColName(self, colName, label): Are you sure that the {label.upper()} coord. column should contain the letter {labelToCheck}? """) - + msg = widgets.myMessageBox(wrapText=False) _, noButton, yesButton = msg.warning( - self, 'Check column name', txt, - buttonsTexts=('Cancel', 'No, let me correct it', 'Yes, I am') + self, + "Check column name", + txt, + buttonsTexts=("Cancel", "No, let me correct it", "Yes, I am"), ) if msg.cancel or msg.clickedButton == noButton: return False return True - + def checkColNameX(self, text): - accepted = self._checkSelectedColName(text, 'x') - if accepted: + accepted = self._checkSelectedColName(text, "x") + if accepted: return - self.xColName.setCurrentText('None') - + self.xColName.setCurrentText("None") + def checkColNameY(self, text): - accepted = self._checkSelectedColName(text, 'y') - if accepted: + accepted = self._checkSelectedColName(text, "y") + if accepted: return - self.yColName.setCurrentText('None') - + self.yColName.setCurrentText("None") + def checkColNameZ(self, text): - accepted = self._checkSelectedColName(text, 'z') - if accepted: + accepted = self._checkSelectedColName(text, "z") + if accepted: return - self.zColName.setCurrentText('None') + self.zColName.setCurrentText("None") def ok_cb(self): self.pointsData = {} self.loadedDfInfo = None self.loadedDf = None - self.weighingChannel = '' + self.weighingChannel = "" if self.fromTableRadiobutton.isChecked(): tablePath = self.tablePath.text() if not tablePath: self.criticalEmptyTablePath() return - + try: df = self._readTable(tablePath) tColName = self.tColName.currentText() xColName = self.xColName.currentText() yColName = self.yColName.currentText() zColName = self.zColName.currentText() - + self.loadedDfInfo = { - 'filepath': tablePath, - 't': tColName, - 'z': zColName, - 'y': yColName, - 'x': xColName + "filepath": tablePath, + "t": tColName, + "z": zColName, + "y": yColName, + "x": xColName, } - - self._df_to_pointsData( - df, tColName, zColName, yColName, xColName - ) - + + self._df_to_pointsData(df, tColName, zColName, yColName, xColName) + except Exception as e: traceback_format = traceback.format_exc() self.sigCriticalReadTable.emit(traceback_format) self.criticalReadTable(tablePath, traceback_format) return - - if self.xColName.currentText() == 'None': - self.criticalColNameIsNone('x') + + if self.xColName.currentText() == "None": + self.criticalColNameIsNone("x") return - if self.yColName.currentText() == 'None': - self.criticalColNameIsNone('y') + if self.yColName.currentText() == "None": + self.criticalColNameIsNone("y") return - + self.layerType = os.path.basename(self.tablePath.text()) self.layerTypeIdx = 2 elif self.centroidsRadiobutton.isChecked(): - self.layerType = 'Centroids' + self.layerType = "Centroids" self.layerTypeIdx = 0 elif self.weightedCentroidsRadiobutton.isChecked(): channel = self.channelNameForWeightedCentr.currentText() self.weighingChannel = channel - self.layerType = f'Centroids weighted by channel {channel}' + self.layerType = f"Centroids weighted by channel {channel}" self.layerTypeIdx = 1 elif self.manualEntryRadiobutton.isChecked(): xx = self.manualXspinbox.values() @@ -1341,32 +1400,32 @@ def ok_cb(self): self.criticalLenMismatchManualEntry() return zz = self.manualZspinbox.values() - tt = [t+1 for t in self.manualTspinbox.values()] - df = pd.DataFrame({'x': xx, 'y': yy, 'id': np.arange(1, len(xx)+1)}) + tt = [t + 1 for t in self.manualTspinbox.values()] + df = pd.DataFrame({"x": xx, "y": yy, "id": np.arange(1, len(xx) + 1)}) if tt: - df['t'] = tt - tCol = 't' + df["t"] = tt + tCol = "t" else: - tCol = 'None' + tCol = "None" if zz: - df['z'] = zz - zCol = 'z' + df["z"] = zz + zCol = "z" else: - zCol = 'None' - - self._df_to_pointsData(df, tCol, zCol, 'y', 'x') - - self.layerType = 'Manual entry' + zCol = "None" + + self._df_to_pointsData(df, tCol, zCol, "y", "x") + + self.layerType = "Manual entry" self.layerTypeIdx = 3 elif self.clickEntryRadiobutton.isChecked(): - self.layerType = ('Click to annotate point') + self.layerType = "Click to annotate point" self.description = ( - 'Left-click to add a point, click on point to delete it.\n' - 'With auto-pilot you can navigate through object with Up/Down arrows.' + "Left-click to add a point, click on point to delete it.\n" + "With auto-pilot you can navigate through object with Up/Down arrows." ) self.clickEntryTableEndnameText = self.clickEntryTableEndname.text() self.layerTypeIdx = 4 - + self.cancel = False symbol = self.appearanceGroupbox.symbolWidget.widget.currentText() self.symbol = re.findall(r"\'(.+)\'", symbol)[0] @@ -1378,12 +1437,12 @@ def ok_cb(self): self.shortcut = shortcutWidget.widget.text() self.keySequence = shortcutWidget.widget.keySequence self.close() - + def _df_to_pointsData(self, df, tColName, zColName, yColName, xColName): self.pointsData = load.loaded_df_to_points_data( df, tColName, zColName, yColName, xColName ) - + def showEvent(self, event) -> None: if self._parent is None: screen = self.screen() @@ -1391,29 +1450,31 @@ def showEvent(self, event) -> None: screen = self._parent.screen() screenWidth = screen.size().width() screenHeight = screen.size().height() - + maxHeight = screenHeight - 100 - + buttonHeight = self.buttonsLayout.okButton.minimumSizeHint().height() height = ( self.scrollArea.minimumHeightNoScrollbar() + self.appearanceGroupbox.sizeHint().height() - + buttonHeight + 70 + + buttonHeight + + 70 ) width = self.scrollArea.minimumWidthNoScrollbar() + 50 - + height = min(height, maxHeight) - + self.resize(width, height) - + screenLeft = screen.geometry().x() screenTop = screen.geometry().y() w, h = self.width(), self.height() - left = int(screenLeft + screenWidth/2 - w/2) - top = int(screenTop + screenHeight/2 - h/2 - 20) + left = int(screenLeft + screenWidth / 2 - w / 2) + top = int(screenTop + screenHeight / 2 - h / 2 - 20) self.move(left, top) + class EditPointsLayerAppearanceDialog(QBaseDialog): sigClosed = Signal() @@ -1423,7 +1484,7 @@ def __init__(self, parent=None): self._parent = parent - self.setWindowTitle('Custom annotation') + self.setWindowTitle("Custom annotation") self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) mainLayout = QVBoxLayout() @@ -1442,18 +1503,18 @@ def __init__(self, parent=None): self.setLayout(mainLayout) self.setFont(font) - + def restoreState(self, state): self.appearanceGroupbox.restoreState(state) - + def closeEvent(self, event): super().closeEvent(event) self.sigClosed.emit() - + def state(self): _state = self.appearanceGroupbox.state() return _state - + def ok_cb(self): self.cancel = False symbol = self.appearanceGroupbox.symbolWidget.widget.currentText() @@ -1466,22 +1527,32 @@ def ok_cb(self): self.keySequence = shortcutWidget.widget.keySequence self.close() + class filenameDialog(QDialog): def __init__( - self, ext='.npz', basename='', title='Insert file name', - hintText='', existingNames='', parent=None, allowEmpty=True, - helpText='', defaultEntry='', resizeOnShow=True, - additionalButtons=None, addDoNotSaveButton=False - ): + self, + ext=".npz", + basename="", + title="Insert file name", + hintText="", + existingNames="", + parent=None, + allowEmpty=True, + helpText="", + defaultEntry="", + resizeOnShow=True, + additionalButtons=None, + addDoNotSaveButton=False, + ): self.cancel = True super().__init__(parent) self.resizeOnShow = resizeOnShow - if hintText.find('segmentation') != -1: + if hintText.find("segmentation") != -1: if helpText: - helpText = (f'{helpText}') - helpText_loc = (""" + helpText = f"{helpText}" + helpText_loc = """ With Cell-ACDC you can create as many segmentation files as you want.

If you plan to create only one file then you can leave the @@ -1500,14 +1571,14 @@ def __init__( Note that the numerical features and annotations will be saved in a CSV file ending with the same text as the segmentation file,
e.g., ending with _acdc_output_phase_contr.csv. - """) - helpText = (f'{helpText}{html_utils.paragraph(helpText_loc)}') + """ + helpText = f"{helpText}{html_utils.paragraph(helpText_loc)}" - self.isSegmFile = basename.endswith('_segm') + self.isSegmFile = basename.endswith("_segm") self.allowEmpty = allowEmpty self.basename = basename - if ext and not ext.startswith('.'): - ext = f'.{ext}' + if ext and not ext.startswith("."): + ext = f".{ext}" self.ext = ext self.setWindowTitle(title) @@ -1524,41 +1595,39 @@ def __init__( self.lineEdit = widgets.alphaNumericLineEdit(onlyWarn=True) self.lineEdit.setAlignment(Qt.AlignCenter) defaultEntry = to_alphanumeric(defaultEntry) - defaultEntry = defaultEntry.replace('.', '_') + defaultEntry = defaultEntry.replace(".", "_") self.lineEdit.setText(defaultEntry) extLabel = QLabel(ext) self.filenameLabel = QLabel() - self.filenameLabel.setText(f'{basename}{ext}') + self.filenameLabel.setText(f"{basename}{ext}") entryLayout.addWidget(basenameLabel, 0, 1) entryLayout.addWidget(self.lineEdit, 0, 2) entryLayout.addWidget(extLabel, 0, 3) - entryLayout.addWidget( - self.filenameLabel, 1, 1, 1, 3, alignment=Qt.AlignCenter - ) + entryLayout.addWidget(self.filenameLabel, 1, 1, 1, 3, alignment=Qt.AlignCenter) # entryLayout.setColumnStretch(0, 1) entryLayout.setColumnStretch(2, 1) - + self.warningInvalidCharLabel = QLabel() - okButton = widgets.okPushButton('Ok') - cancelButton = widgets.cancelPushButton('Cancel') + okButton = widgets.okPushButton("Ok") + cancelButton = widgets.cancelPushButton("Cancel") self.okButton = okButton buttonsLayout.addStretch() buttonsLayout.addWidget(cancelButton) if addDoNotSaveButton: - doNotSaveButton = widgets.noPushButton('Do not save') + doNotSaveButton = widgets.noPushButton("Do not save") doNotSaveButton.clicked.connect(self.doNotSave_cb) buttonsLayout.addWidget(doNotSaveButton) self.doNotSave = False buttonsLayout.addSpacing(20) if helpText: - helpButton = widgets.helpPushButton('Help...') + helpButton = widgets.helpPushButton("Help...") helpButton.clicked.connect(partial(self.showHelp, helpText)) buttonsLayout.addWidget(helpButton) if additionalButtons is not None: @@ -1572,7 +1641,7 @@ def __init__( self.lineEdit.sigInvalidCharactersEntered.connect( self.warnInvalidCharactersEntered ) - + self.existingNames = [] if existingNames: self.existingNames = existingNames @@ -1592,43 +1661,43 @@ def __init__( if defaultEntry: self.updateFilename(defaultEntry) - + def doNotSave_cb(self): msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Are you sure you do not want to save the file?' + "Are you sure you do not want to save the file?" ) noButton, yesButton = msg.warning( - self, 'Do not save?', txt, buttonsTexts=('No', 'Yes') + self, "Do not save?", txt, buttonsTexts=("No", "Yes") ) if msg.clickedButton == noButton: return - + self.doNotSave = True self.cancel = False self.close() - + def showHelp(self, text): text = html_utils.paragraph(text) msg = widgets.myMessageBox(wrapText=False) - msg.information(self, 'Filename help', text) + msg.information(self, "Filename help", text) def _text(self): return self.lineEdit.text() - + def warnInvalidCharactersEntered(self, characters: set[str]): - statement = 'is not a valid character' + statement = "is not a valid character" if len(characters) > 1: - statement = 'are not valid characters' - - characters_str = ''.join(characters) + statement = "are not valid characters" + + characters_str = "".join(characters) characters_str = html.escape(characters_str) warning_text = html_utils.span(f""" WARNING: "{characters_str}" {statement}.
""") warning_text = ( - f'{warning_text}' - 'Valid characters are letters, numbers, underscore, and dash.' + f"{warning_text}" + "Valid characters are letters, numbers, underscore, and dash." ) self.warningInvalidCharLabel.setText(warning_text) @@ -1643,89 +1712,88 @@ def checkExistingNames(self): filename = self.filenameLabel.text() msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'The following file

' - f'{filename}

' - 'is already existing.

' - 'Do you want to overwrite the existing file?' + "The following file

" + f"{filename}

" + "is already existing.

" + "Do you want to overwrite the existing file?" ) noButton, yesButton = msg.warning( - self, 'File name existing', txt, buttonsTexts=('No', 'Yes') + self, "File name existing", txt, buttonsTexts=("No", "Yes") ) return msg.clickedButton == yesButton def updateFilename(self, text): if self.lineEdit.invalidCharacters(): return - + if not text: - self.filenameLabel.setText(f'{self.basename}{self.ext}') + self.filenameLabel.setText(f"{self.basename}{self.ext}") else: - text = text.replace(' ', '_') + text = text.replace(" ", "_") if self.basename: - if self.basename.endswith('_'): - self.filenameLabel.setText(f'{self.basename}{text}{self.ext}') + if self.basename.endswith("_"): + self.filenameLabel.setText(f"{self.basename}{text}{self.ext}") else: - self.filenameLabel.setText(f'{self.basename}_{text}{self.ext}') + self.filenameLabel.setText(f"{self.basename}_{text}{self.ext}") else: - self.filenameLabel.setText(f'{text}{self.ext}') - - self.warningInvalidCharLabel.setText('') + self.filenameLabel.setText(f"{text}{self.ext}") + + self.warningInvalidCharLabel.setText("") def checkEmptyText(self): if self.allowEmpty: return True - + if self._text(): return True - + msg = widgets.myMessageBox() msg.critical( - self, 'Empty text', - html_utils.paragraph('Text entry field cannot be empty') + self, + "Empty text", + html_utils.paragraph("Text entry field cannot be empty"), ) return False - + def checkSegmFilename(self): if not self.isSegmFile: return True - - if 'segm' not in self._text(): + + if "segm" not in self._text(): return True - + msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'The text appended to the filename cannot contain the text ' + "The text appended to the filename cannot contain the text " '"segm".

' - 'Sorry, that would confuse me. Thank you for your patience!' - ) - msg.critical( - self, 'Cannot use "segm" in filename', txt + "Sorry, that would confuse me. Thank you for your patience!" ) + msg.critical(self, 'Cannot use "segm" in filename', txt) return False - + def ok_cb(self, checked=True): if self.warningInvalidCharLabel.text(): return - + valid = self.checkExistingNames() if not valid: return - + valid = self.checkEmptyText() if not valid: return - + valid = self.checkSegmFilename() if not valid: return - + self.filename = self.filenameLabel.text() self.entryText = self._text() self.cancel = False self.close() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() def exec_(self): @@ -1734,29 +1802,30 @@ def exec_(self): def show(self, block=False): super().show() if self.resizeOnShow: - self.lineEdit.setMinimumWidth(self.lineEdit.width()*2) + self.lineEdit.setMinimumWidth(self.lineEdit.width() * 2) self.okButton.setDefault(True) if block: self.loop = QEventLoop() self.loop.exec_() - + class wandToleranceWidget(QFrame): def __init__(self, parent=None): super().__init__(parent) - self.slider = widgets.sliderWithSpinBox(title='Tolerance') + self.slider = widgets.sliderWithSpinBox(title="Tolerance") self.slider.setMaximum(255) self.slider._layout.setColumnStretch(2, 21) self.setLayout(self.slider.layout) + class TrackSubCellObjectsDialog(QBaseDialog): - def __init__(self, basename='', parent=None): + def __init__(self, basename="", parent=None): self.cancel = True super().__init__(parent=parent) - - self.setWindowTitle('Track sub-cellular objects parameters') + + self.setWindowTitle("Track sub-cellular objects parameters") mainLayout = QVBoxLayout() entriesLayout = widgets.FormLayout() @@ -1768,19 +1837,21 @@ def __init__(self, basename='', parent=None): Original segmentation masks
are not modified
. """) options = ( - 'Delete sub-cellular objects that do not belong to any cell', - 'Delete cells that do not have any sub-cellular object', - 'Delete both cells and sub-cellular objects without an assignment', - 'Only track the objects and keep all the non-tracked objects' + "Delete sub-cellular objects that do not belong to any cell", + "Delete cells that do not have any sub-cellular object", + "Delete both cells and sub-cellular objects without an assignment", + "Only track the objects and keep all the non-tracked objects", ) combobox = widgets.QCenteredComboBox() combobox.addItems(options) self.optionsWidget = widgets.formWidget( - combobox, addInfoButton=True, labelTextLeft='Tracking mode: ', - infoTxt=infoTxt + combobox, + addInfoButton=True, + labelTextLeft="Tracking mode: ", + infoTxt=infoTxt, ) entriesLayout.addFormWidget(self.optionsWidget, row=row) - + row += 1 infoTxt = html_utils.paragraph(""" Re-label sub-cellular objects before assigning them to the cell.

@@ -1789,9 +1860,11 @@ def __init__(self, basename='', parent=None): (i.e., semantic segmentation). """) self.relabelSubObjLab = widgets.formWidget( - widgets.Toggle(), addInfoButton=True, stretchWidget=False, - labelTextLeft='Re-label sub-cellular objects before tracking: ', - infoTxt=infoTxt + widgets.Toggle(), + addInfoButton=True, + stretchWidget=False, + labelTextLeft="Re-label sub-cellular objects before tracking: ", + infoTxt=infoTxt, ) entriesLayout.addFormWidget(self.relabelSubObjLab, row=row) @@ -1805,8 +1878,10 @@ def __init__(self, basename='', parent=None): spinbox.setValue(0.5) spinbox.setSingleStep(0.1) self.IoAwidget = widgets.formWidget( - spinbox, addInfoButton=True, labelTextLeft='IoA threshold: ', - infoTxt=IoAtext + spinbox, + addInfoButton=True, + labelTextLeft="IoA threshold: ", + infoTxt=IoAtext, ) entriesLayout.addFormWidget(self.IoAwidget, row=row) @@ -1818,8 +1893,11 @@ def __init__(self, basename='', parent=None): only from the cytoplasm (i.e., the sub-cellular object is the nucleus). """) self.createThirdSegmWidget = widgets.formWidget( - widgets.Toggle(), addInfoButton=True, stretchWidget=False, - labelTextLeft='Create third segmentation: ', infoTxt=infoTxt + widgets.Toggle(), + addInfoButton=True, + stretchWidget=False, + labelTextLeft="Create third segmentation: ", + infoTxt=infoTxt, ) entriesLayout.addFormWidget(self.createThirdSegmWidget, row=row) @@ -1832,19 +1910,18 @@ def __init__(self, basename='', parent=None): only from the cytoplasm (i.e., the sub-cellular object is the nucleus). """) lineEdit = widgets.alphaNumericLineEdit() - lineEdit.setText('difference') + lineEdit.setText("difference") lineEdit.setAlignment(Qt.AlignCenter) self.appendTextWidget = widgets.formWidget( - lineEdit, addInfoButton=True, labelTextLeft='Text to append: ', - infoTxt=infoTxt + lineEdit, + addInfoButton=True, + labelTextLeft="Text to append: ", + infoTxt=infoTxt, ) entriesLayout.addFormWidget(self.appendTextWidget, row=row) self.appendTextWidget.setDisabled(True) - - self.createThirdSegmWidget.widget.toggled.connect( - self.createThirdSegmToggled - ) + self.createThirdSegmWidget.widget.toggled.connect(self.createThirdSegmToggled) buttonsLayout = widgets.CancelOkButtonsLayout() @@ -1857,45 +1934,56 @@ def __init__(self, basename='', parent=None): self.setLayout(mainLayout) self.setFont(font) - + def createThirdSegmToggled(self, checked): self.appendTextWidget.setDisabled(not checked) - + def ok_cb(self): self.cancel = False if self.createThirdSegmWidget.widget.isChecked(): if not self.appendTextWidget.widget.text(): msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph( - 'When creating the third segmentation file, ' - 'the name to append cannot be empty!' + "When creating the third segmentation file, " + "the name to append cannot be empty!" ) - msg.critical(self, 'Empty name', txt) + msg.critical(self, "Empty name", txt) return - + self.trackSubCellObjParams = { - 'how': self.optionsWidget.widget.currentText(), - 'IoA': self.IoAwidget.widget.value(), - 'createThirdSegm': self.createThirdSegmWidget.widget.isChecked(), - 'relabelSubObjLab': self.relabelSubObjLab.widget.isChecked(), - 'thirdSegmAppendedText': self.appendTextWidget.widget.text() + "how": self.optionsWidget.widget.currentText(), + "IoA": self.IoAwidget.widget.value(), + "createThirdSegm": self.createThirdSegmWidget.widget.isChecked(), + "relabelSubObjLab": self.relabelSubObjLab.widget.isChecked(), + "thirdSegmAppendedText": self.appendTextWidget.widget.text(), } self.close() + class SetMeasurementsDialog(QBaseDialog): sigClosed = Signal() sigCancel = Signal() sigRestart = Signal() def __init__( - self, loadedChNames, notLoadedChNames, isZstack, isSegm3D, - favourite_funcs=None, parent=None, allPos_acdc_df_cols=None, - acdc_df_path=None, posData=None, addCombineMetricCallback=None, - allPosData=None, is_concat=False, isSingleSelection=False, - state=None - ): + self, + loadedChNames, + notLoadedChNames, + isZstack, + isSegm3D, + favourite_funcs=None, + parent=None, + allPos_acdc_df_cols=None, + acdc_df_path=None, + posData=None, + addCombineMetricCallback=None, + allPosData=None, + is_concat=False, + isSingleSelection=False, + state=None, + ): super().__init__(parent=parent) - + self.checkBoxedGroup = QButtonGroup() self.checkBoxedGroup.setExclusive(isSingleSelection) @@ -1911,27 +1999,27 @@ def __init__( self.allPosData = allPosData self.doNotWarn = False - self.setWindowTitle('Set measurements') + self.setWindowTitle("Set measurements") # self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) layout = QVBoxLayout() - + searchLayout = QHBoxLayout() - + searchLineEdit = widgets.SearchLineEdit() searchLayout.addStretch(5) searchLayout.addWidget(searchLineEdit) searchLayout.setStretch(1, 3) - + mainScrollArea = widgets.ScrollArea() mainScrollAreaWidget = QWidget() mainScrollArea.setWidget(mainScrollAreaWidget) - + groupsLayout = QGridLayout() self.groupsLayout = groupsLayout - + mainScrollAreaWidget.setLayout(groupsLayout) - + buttonsLayout = QHBoxLayout() self.chNameGroupboxes = [] @@ -1940,8 +2028,12 @@ def __init__( col = 0 for col, chName in enumerate(loadedChNames): channelGBox = widgets.channelMetricsQGBox( - isZstack, chName, isSegm3D, favourite_funcs=favourite_funcs, - posData=posData, is_concat=is_concat + isZstack, + chName, + isSegm3D, + favourite_funcs=favourite_funcs, + posData=posData, + is_concat=is_concat, ) channelGBox.chName = chName groupsLayout.addWidget(channelGBox, 0, col, 3, 1) @@ -1951,11 +2043,15 @@ def __init__( groupsLayout.setColumnStretch(col, 5) self.all_metrics.extend([c.text() for c in channelGBox.checkBoxes]) - current_col = col+1 + current_col = col + 1 for col, chName in enumerate(notLoadedChNames): channelGBox = widgets.channelMetricsQGBox( - isZstack, chName, isSegm3D, favourite_funcs=favourite_funcs, - posData=posData, is_concat=is_concat + isZstack, + chName, + isSegm3D, + favourite_funcs=favourite_funcs, + posData=posData, + is_concat=is_concat, ) channelGBox.setChecked(False) channelGBox.chName = chName @@ -1972,21 +2068,22 @@ def __init__( if posData is None: isTimelapse = False else: - isTimelapse = posData.SizeT>1 - size_metrics_desc = measurements.get_size_metrics_desc( - isSegm3D, isTimelapse - ) + isTimelapse = posData.SizeT > 1 + size_metrics_desc = measurements.get_size_metrics_desc(isSegm3D, isTimelapse) if not isSegm3D: size_metrics_desc = { - key:val for key,val in size_metrics_desc.items() - if not key.endswith('_3D') + key: val + for key, val in size_metrics_desc.items() + if not key.endswith("_3D") } - + row = 0 sizeMetricsQGBox = widgets._metricsQGBox( - size_metrics_desc, 'Physical measurements', - favourite_funcs=favourite_funcs, isZstack=isZstack, - addCalcForEachZsliceToggle=isSegm3D + size_metrics_desc, + "Physical measurements", + favourite_funcs=favourite_funcs, + isZstack=isZstack, + addCalcForEachZsliceToggle=isSegm3D, ) self.all_metrics.extend([c.text() for c in sizeMetricsQGBox.checkBoxes]) self.sizeMetricsQGBox = sizeMetricsQGBox @@ -2002,8 +2099,10 @@ def __init__( ) rp_desc = props_info_txt_mapper regionPropsQGBox = widgets._metricsQGBox( - rp_desc, 'Morphological properties', - favourite_funcs=favourite_funcs, isZstack=isZstack + rp_desc, + "Morphological properties", + favourite_funcs=favourite_funcs, + isZstack=isZstack, ) self.regionPropsQGBox = regionPropsQGBox for rpCheckbox in regionPropsQGBox.checkBoxes: @@ -2012,23 +2111,23 @@ def __init__( groupsLayout.setRowStretch(1, 2) self.all_metrics.extend([c.text() for c in regionPropsQGBox.checkBoxes]) row += 1 - + # Custom metrics that are channel indipendent self.chIndipendCustomeMetricsQGBox = None out = measurements.ch_indipend_custom_metrics_desc( - isZstack, isSegm3D=isSegm3D, + isZstack, + isSegm3D=isSegm3D, ) ch_indipend_custom_metrics_desc = out if ch_indipend_custom_metrics_desc: self.chIndipendCustomeMetricsQGBox = widgets._metricsQGBox( - ch_indipend_custom_metrics_desc, - 'Channel indipendent custom measurements', - favourite_funcs=favourite_funcs, isZstack=isZstack, - parent=self - ) - groupsLayout.addWidget( - self.chIndipendCustomeMetricsQGBox, row, current_col + ch_indipend_custom_metrics_desc, + "Channel indipendent custom measurements", + favourite_funcs=favourite_funcs, + isZstack=isZstack, + parent=self, ) + groupsLayout.addWidget(self.chIndipendCustomeMetricsQGBox, row, current_col) groupsLayout.setRowStretch(1, 1) row += 1 @@ -2038,9 +2137,12 @@ def __init__( self.mixedChannelsCombineMetricsQGBox = None if desc: self.mixedChannelsCombineMetricsQGBox = widgets._metricsQGBox( - desc, 'Mixed channels combined measurements', - favourite_funcs=favourite_funcs, isZstack=isZstack, - equations=equations, addDelButton=True + desc, + "Mixed channels combined measurements", + favourite_funcs=favourite_funcs, + isZstack=isZstack, + equations=equations, + addDelButton=True, ) self.mixedChannelsCombineMetricsQGBox.sigDelClicked.connect( self.delMixedChannelCombineMetric @@ -2060,54 +2162,46 @@ def __init__( ) else: for combCheckbox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: - combCheckbox.toggled.connect( - self.mixedChannelsMetricToggled - ) + combCheckbox.toggled.connect(self.mixedChannelsMetricToggled) row += 1 self.last_row = row self.last_col = current_col - okButton = widgets.okPushButton(' Ok ') - cancelButton = widgets.cancelPushButton('Cancel') + okButton = widgets.okPushButton(" Ok ") + cancelButton = widgets.cancelPushButton("Cancel") if addCombineMetricCallback is not None: addCombineMetricButton = widgets.addPushButton( - 'Add combined measurement...' + "Add combined measurement..." ) addCombineMetricButton.clicked.connect(addCombineMetricCallback) self.okButton = okButton - loadLastSelButton = widgets.reloadPushButton('Load last selection...') - self.deselectAllButton = QPushButton('Deselect all') - self.deselectAllButton.setIcon(QIcon(':deselect_all.svg')) + loadLastSelButton = widgets.reloadPushButton("Load last selection...") + self.deselectAllButton = QPushButton("Deselect all") + self.deselectAllButton.setIcon(QIcon(":deselect_all.svg")) buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) buttonsLayout.addSpacing(20) buttonsLayout.addWidget(self.deselectAllButton) buttonsLayout.addSpacing(20) - + if addCombineMetricCallback is not None: buttonsLayout.addWidget(addCombineMetricButton) buttonsLayout.addSpacing(20) - - saveCurrentSelectionButton = widgets.savePushButton( - 'Save current selection...' - ) - saveCurrentSelectionButton.clicked.connect( - self.saveCurrentSelectionClicked - ) - + + saveCurrentSelectionButton = widgets.savePushButton("Save current selection...") + saveCurrentSelectionButton.clicked.connect(self.saveCurrentSelectionClicked) + buttonsLayout.addWidget(saveCurrentSelectionButton) - - loadSavedSelectionButton = widgets.OpenFilePushButton( - 'Load saved selection...' - ) + + loadSavedSelectionButton = widgets.OpenFilePushButton("Load saved selection...") loadSavedSelectionButton.clicked.connect(self.loadSavedSelectionClicked) buttonsLayout.addWidget(loadSavedSelectionButton) - + buttonsLayout.addWidget(loadLastSelButton) - + buttonsLayout.addSpacing(20) buttonsLayout.addWidget(okButton) @@ -2120,7 +2214,7 @@ def __init__( layout.addLayout(buttonsLayout) self.setLayout(layout) - + if state is not None: self.setState(state) @@ -2129,197 +2223,193 @@ def __init__( okButton.clicked.connect(self.ok_cb) cancelButton.clicked.connect(self.close) loadLastSelButton.clicked.connect(self.loadLastSelection) - + self.addCheckboxesToGroup() for channelGBox in self.chNameGroupboxes: for checkbox in channelGBox.checkBoxes: self.channelCheckboxToggled(checkbox) - + def allMetricsDict(self): all_metrics = { - 'standard': {}, - 'regionprop': [], - 'size': [], - 'mixed_channels': [] + "standard": {}, + "regionprop": [], + "size": [], + "mixed_channels": [], } for chNameGroupbox in self.chNameGroupboxes: channel_name = chNameGroupbox.chName for checkBox in chNameGroupbox.checkBoxes: - if channel_name not in all_metrics['standard']: - all_metrics['standard'][channel_name] = [] - all_metrics['standard'][channel_name].append(checkBox.text()) - + if channel_name not in all_metrics["standard"]: + all_metrics["standard"][channel_name] = [] + all_metrics["standard"][channel_name].append(checkBox.text()) + for checkBox in self.regionPropsQGBox.checkBoxes: - all_metrics['regionprop'].append(checkBox.text()) - + all_metrics["regionprop"].append(checkBox.text()) + for checkBox in self.sizeMetricsQGBox.checkBoxes: - all_metrics['size'].append(checkBox.text()) - + all_metrics["size"].append(checkBox.text()) + if self.chIndipendCustomeMetricsQGBox is not None: checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes for checkBox in checkBoxes: - all_metrics['ch_indipend_custom_metric'].append(checkBox.text()) - + all_metrics["ch_indipend_custom_metric"].append(checkBox.text()) + if self.mixedChannelsCombineMetricsQGBox is None: return - + checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes for checkBox in checkBoxes: - all_metrics['mixed_channels'].append(checkBox.text()) - + all_metrics["mixed_channels"].append(checkBox.text()) + return all_metrics - + def searchAndHighlight(self, text): for chNameGroupbox in self.chNameGroupboxes: for groupbox in chNameGroupbox.groupboxes: groupbox.highlightCheckboxesFromSearchText(text) - + self.regionPropsQGBox.highlightCheckboxesFromSearchText(text) self.sizeMetricsQGBox.highlightCheckboxesFromSearchText(text) - + if self.chIndipendCustomeMetricsQGBox is not None: - self.chIndipendCustomeMetricsQGBox.highlightCheckboxesFromSearchText( - text - ) - + self.chIndipendCustomeMetricsQGBox.highlightCheckboxesFromSearchText(text) + if self.mixedChannelsCombineMetricsQGBox is None: return - - self.mixedChannelsCombineMetricsQGBox.highlightCheckboxesFromSearchText( - text - ) - + + self.mixedChannelsCombineMetricsQGBox.highlightCheckboxesFromSearchText(text) + def selectedMetricNameAndGroup(self): for chNameGroupbox in self.chNameGroupboxes: for checkBox in chNameGroupbox.checkBoxes: if checkBox.isChecked(): - return checkBox.text(), {'standard': chNameGroupbox.chName} - + return checkBox.text(), {"standard": chNameGroupbox.chName} + for checkBox in self.regionPropsQGBox.checkBoxes: if checkBox.isChecked(): - return checkBox.text(), 'regionprop' - + return checkBox.text(), "regionprop" + for checkBox in self.sizeMetricsQGBox.checkBoxes: if checkBox.isChecked(): - return checkBox.text(), 'size' - + return checkBox.text(), "size" + if self.chIndipendCustomeMetricsQGBox is not None: checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes for checkBox in checkBoxes: if checkBox.isChecked(): - return checkBox.text(), 'ch_indipend_custom_metric' - + return checkBox.text(), "ch_indipend_custom_metric" + if self.mixedChannelsCombineMetricsQGBox is None: return - + checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes for checkBox in checkBoxes: if checkBox.isChecked(): - return checkBox.text(), 'mixed_channels' - + return checkBox.text(), "mixed_channels" + def selectedMetricGroup(self): for chNameGroupbox in self.chNameGroupboxes: - for checkBox in chNameGroupbox.checkBoxes: + for checkBox in chNameGroupbox.checkBoxes: if checkBox.isChecked(): return checkBox.text() - + for checkBox in self.regionPropsQGBox.checkBoxes: if checkBox.isChecked(): return checkBox.text() - + for checkBox in self.sizeMetricsQGBox.checkBoxes: if checkBox.isChecked(): return checkBox.text() - + if self.chIndipendCustomeMetricsQGBox is not None: checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes for checkBox in checkBoxes: if checkBox.isChecked(): return checkBox.text() - + if self.mixedChannelsCombineMetricsQGBox is None: return - + checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes for checkBox in checkBoxes: if checkBox.isChecked(): return checkBox.text() - + def addCheckboxesToGroup(self): for chNameGroupbox in self.chNameGroupboxes: for checkBox in chNameGroupbox.checkBoxes: self.checkBoxedGroup.addButton(checkBox) - + for checkBox in self.regionPropsQGBox.checkBoxes: self.checkBoxedGroup.addButton(checkBox) - + for checkBox in self.sizeMetricsQGBox.checkBoxes: self.checkBoxedGroup.addButton(checkBox) - + if self.chIndipendCustomeMetricsQGBox is not None: checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes for checkBox in checkBoxes: self.checkBoxedGroup.addButton(checkBox) - + if self.mixedChannelsCombineMetricsQGBox is None: return - + checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes for checkBox in checkBoxes: self.checkBoxedGroup.addButton(checkBox) - + def channelCheckboxToggled(self, checkbox): - # Make sure to automatically check the requested cell_vol metric for - # concentration metrics - if checkbox.text().find('concentration_') == -1: + # Make sure to automatically check the requested cell_vol metric for + # concentration metrics + if checkbox.text().find("concentration_") == -1: return - + if self.is_concat: - # When this dialogue is used in concatenate pos utility we do not + # When this dialogue is used in concatenate pos utility we do not # need to check that certain metrics are present return - - pattern = r'.+_from_vol_([a-z]+)(_3D)?(_?[A-Za-z0-9]*)' - repl = r'cell_vol_\1\2' + + pattern = r".+_from_vol_([a-z]+)(_3D)?(_?[A-Za-z0-9]*)" + repl = r"cell_vol_\1\2" cell_vol_metric_name = re.sub(pattern, repl, checkbox.text()) for sizeCheckbox in self.sizeMetricsQGBox.checkBoxes: if sizeCheckbox.text() == cell_vol_metric_name: break else: # Make sure to not check for similarly named custom metrics - return - + return + if checkbox.isChecked(): sizeCheckbox.setChecked(True) sizeCheckbox.isRequired = True else: - # Do not enable cell vol checkbox is any of the other + # Do not enable cell vol checkbox is any of the other # concentration metrics requiring it is checked unit = cell_vol_metric_name[9:] - is3D = unit.endswith('3D') + is3D = unit.endswith("3D") for channelGBox in self.chNameGroupboxes: if not channelGBox.isChecked(): continue - for _checkbox in channelGBox.checkBoxes: - if _checkbox.text().find(f'_from_vol_{unit}') == -1: + for _checkbox in channelGBox.checkBoxes: + if _checkbox.text().find(f"_from_vol_{unit}") == -1: continue - if not is3D and _checkbox.text().find(f'{unit}_3D') != -1: - # Metric is 3D but the cell_vol is not + if not is3D and _checkbox.text().find(f"{unit}_3D") != -1: + # Metric is 3D but the cell_vol is not continue if _checkbox.isChecked(): return sizeCheckbox.isRequired = False - + def rpMetricToggled(self, checked): pass - + def mixedChannelsMetricToggled(self, checked): pass - + def sizeMetricToggled(self, checked): """Method called when a checkbox of a size metric is toggled. - Check if the size value is required and explain why it cannot be + Check if the size value is required and explain why it cannot be unchecked. Parameters @@ -2328,29 +2418,29 @@ def sizeMetricToggled(self, checked): State of the checkbox toggled """ checkbox = self.sender() - + if self.is_concat: - # When this dialogue is used in concatenate pos utility we do not + # When this dialogue is used in concatenate pos utility we do not # need to check that certain metrics are present return - if not hasattr(checkbox, 'isRequired'): + if not hasattr(checkbox, "isRequired"): return - + if not checkbox.isRequired: return - + if checkbox.isChecked(): return - + checkbox.setChecked(True) if self.doNotWarn: return - linked_autoBkgr_metric = checkbox.text().replace('cell', '_autoBkgr_from') + linked_autoBkgr_metric = checkbox.text().replace("cell", "_autoBkgr_from") linked_dataPrepBkgr_metric = checkbox.text().replace( - 'cell', '_dataPrepBkgr_from' + "cell", "_dataPrepBkgr_from" ) txt = html_utils.paragraph(f""" This physical measurement cannot be unchecked @@ -2362,26 +2452,26 @@ def sizeMetricToggled(self, checked): Thank you for you patience! """) msg = widgets.myMessageBox(showCentered=False) - msg.warning(self, 'Physical measurement required', txt) + msg.warning(self, "Physical measurement required", txt) def deselectAll(self): self.doNotWarn = True for chNameGroupbox in self.chNameGroupboxes: for gb in chNameGroupbox.groupboxes: gb.checkAll(None, False) - cgb = getattr(chNameGroupbox, 'customMetricsQGBox', None) + cgb = getattr(chNameGroupbox, "customMetricsQGBox", None) if cgb is not None: cgb.checkAll(None, False) - + self.sizeMetricsQGBox.checkAll(None, False) self.regionPropsQGBox.checkAll(None, False) if self.chIndipendCustomeMetricsQGBox is not None: self.chIndipendCustomeMetricsQGBox.checkAll(None, False) - + if self.mixedChannelsCombineMetricsQGBox is not None: self.mixedChannelsCombineMetricsQGBox.checkAll(None, False) self.doNotWarn = False - + def delMixedChannelCombineMetric(self, colname_to_del, hlayout): cp = measurements.read_saved_user_combine_config() for section in cp.sections(): @@ -2394,14 +2484,14 @@ def delMixedChannelCombineMetric(self, colname_to_del, hlayout): if w is None: continue w.hide() - + if self.allPosData is not None: for posData in self.allPosData: _config = posData.combineMetricsConfig for section in _config.sections(): _config.remove_option(section, colname_to_del) posData.saveCombineMetrics() - + def setState(self, state): self.doNotWarn = True for chNameGroupbox in self.chNameGroupboxes: @@ -2433,9 +2523,7 @@ def setState(self, state): checkBox.setChecked(measurementsInfo[colname]) if self.chIndipendCustomeMetricsQGBox is not None: - measurementsInfo = state.get( - self.chIndipendCustomeMetricsQGBox.title() - ) + measurementsInfo = state.get(self.chIndipendCustomeMetricsQGBox.title()) if not measurementsInfo: self.chIndipendCustomeMetricsQGBox.setChecked(False) else: @@ -2445,11 +2533,9 @@ def setState(self, state): colname = checkBox.text() key = self.chIndipendCustomeMetricsQGBox.title() checkBox.setChecked(measurementsInfo[colname]) - + if self.mixedChannelsCombineMetricsQGBox is not None: - measurementsInfo = state.get( - self.mixedChannelsCombineMetricsQGBox.title() - ) + measurementsInfo = state.get(self.mixedChannelsCombineMetricsQGBox.title()) if not measurementsInfo: self.mixedChannelsCombineMetricsQGBox.setChecked(False) else: @@ -2459,14 +2545,11 @@ def setState(self, state): colname = checkBox.text() key = self.mixedChannelsCombineMetricsQGBox.title() checkBox.setChecked(measurementsInfo[colname]) - + self.doNotWarn = False - + def state(self): - state = { - self.sizeMetricsQGBox.title(): {}, - self.regionPropsQGBox.title(): {} - } + state = {self.sizeMetricsQGBox.title(): {}, self.regionPropsQGBox.title(): {}} for chNameGroupbox in self.chNameGroupboxes: state[chNameGroupbox.title()] = {} if not chNameGroupbox.isChecked(): @@ -2503,7 +2586,7 @@ def state(self): key = self.chIndipendCustomeMetricsQGBox.title() colname = checkBox.text() state[key][colname] = checked - + if self.mixedChannelsCombineMetricsQGBox is not None: state[self.mixedChannelsCombineMetricsQGBox.title()] = {} if self.mixedChannelsCombineMetricsQGBox.isChecked(): @@ -2513,9 +2596,9 @@ def state(self): key = self.mixedChannelsCombineMetricsQGBox.title() colname = checkBox.text() state[key][colname] = checked - + return state - + def restoreState(self, state): for chNameGroupbox in self.chNameGroupboxes: _state = state.get(chNameGroupbox.title()) @@ -2526,7 +2609,7 @@ def restoreState(self, state): if isChecked is None: continue checkBox.setChecked(isChecked) - + _state = state.get(self.sizeMetricsQGBox.title()) if _state is None or not _state: pass @@ -2536,7 +2619,7 @@ def restoreState(self, state): if isChecked is None: continue checkBox.setChecked(isChecked) - + _state = state.get(self.regionPropsQGBox.title()) if _state is None or not _state: pass @@ -2546,7 +2629,7 @@ def restoreState(self, state): if isChecked is None: continue checkBox.setChecked(isChecked) - + if self.chIndipendCustomeMetricsQGBox is not None: _state = state.get(self.chIndipendCustomeMetricsQGBox.title()) if _state is None or not _state: @@ -2557,7 +2640,7 @@ def restoreState(self, state): if isChecked is None: continue checkBox.setChecked(isChecked) - + if self.mixedChannelsCombineMetricsQGBox is not None: _state = state.get(self.mixedChannelsCombineMetricsQGBox.title()) if _state is None or not _state: @@ -2568,84 +2651,81 @@ def restoreState(self, state): if isChecked is None: continue checkBox.setChecked(isChecked) - + def currentSelectionMapper(self): current_selected_meas = defaultdict(dict) - + for chNameGroupbox in self.chNameGroupboxes: if not chNameGroupbox.isChecked(): continue - + chName = chNameGroupbox.chName for checkBox in chNameGroupbox.checkBoxes: if not checkBox.isChecked(): continue - - current_selected_meas[chName][checkBox.text()] = 'Yes' - - size_selected_meas = current_selected_meas.get( - self.sizeMetricsQGBox.title() - ) + + current_selected_meas[chName][checkBox.text()] = "Yes" + + size_selected_meas = current_selected_meas.get(self.sizeMetricsQGBox.title()) if self.sizeMetricsQGBox.isChecked(): - for checkBox in self.sizeMetricsQGBox.checkBoxes: + for checkBox in self.sizeMetricsQGBox.checkBoxes: if not checkBox.isChecked(): continue - + section = self.sizeMetricsQGBox.title() - current_selected_meas[section][checkBox.text()] = 'Yes' - - size_selected_meas = current_selected_meas.get( - self.regionPropsQGBox.title() - ) + current_selected_meas[section][checkBox.text()] = "Yes" + + size_selected_meas = current_selected_meas.get(self.regionPropsQGBox.title()) if self.regionPropsQGBox.isChecked(): - for checkBox in self.regionPropsQGBox.checkBoxes: + for checkBox in self.regionPropsQGBox.checkBoxes: if not checkBox.isChecked(): continue - + section = self.regionPropsQGBox.title() - current_selected_meas[section][checkBox.text()] = 'Yes' - + current_selected_meas[section][checkBox.text()] = "Yes" + if self.chIndipendCustomeMetricsQGBox is not None: if self.chIndipendCustomeMetricsQGBox.isChecked(): - for checkBox in self.chIndipendCustomeMetricsQGBox.checkBoxes: + for checkBox in self.chIndipendCustomeMetricsQGBox.checkBoxes: if not checkBox.isChecked(): continue - + section = self.chIndipendCustomeMetricsQGBox.title() - current_selected_meas[section][checkBox.text()] = 'Yes' - + current_selected_meas[section][checkBox.text()] = "Yes" + if self.mixedChannelsCombineMetricsQGBox is not None: if self.mixedChannelsCombineMetricsQGBox.isChecked(): - for checkBox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: + for checkBox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: if not checkBox.isChecked(): continue - + section = self.mixedChannelsCombineMetricsQGBox.title() - current_selected_meas[section][checkBox.text()] = 'Yes' - + current_selected_meas[section][checkBox.text()] = "Yes" + return current_selected_meas - + def saveCurrentSelectionClicked(self): current_selection_mapper = self.currentSelectionMapper() - defaultEntry = '_and_'.join(current_selection_mapper.keys()) - defaultEntry = defaultEntry.replace(' ', '_').lower() + defaultEntry = "_and_".join(current_selection_mapper.keys()) + defaultEntry = defaultEntry.replace(" ", "_").lower() saved_selections = io.get_saved_measurements_selections() win = filenameDialog( - basename='', - ext='', - hintText='Insert a name for the current selection:', + basename="", + ext="", + hintText="Insert a name for the current selection:", existingNames=saved_selections, allowEmpty=False, - defaultEntry=defaultEntry + defaultEntry=defaultEntry, ) win.exec_() if win.cancel: return - + filename = win.filename ini_filepath = io.save_measurements_selections( - filename, current_selection_mapper) - + filename, current_selection_mapper + ) + msg = widgets.myMessageBox(wrapText=False, showCentered=False) txt = html_utils.paragraph(f""" Done!

@@ -2653,39 +2733,41 @@ def saveCurrentSelectionClicked(self): the following path: """) msg.information( - self, 'Selection saved', txt, + self, + "Selection saved", + txt, commands=(ini_filepath,), path_to_browse=os.path.dirname(ini_filepath), ) - + def loadSavedSelectionClicked(self): self.doNotWarn = True - + saved_selections = io.get_saved_measurements_selections() - + selectNameWin = widgets.QDialogListbox( - 'Choose selection to load', - 'Choose selection to load:\n', - saved_selections, - multiSelection=False, - parent=self + "Choose selection to load", + "Choose selection to load:\n", + saved_selections, + multiSelection=False, + parent=self, ) selectNameWin.exec_() if selectNameWin.cancel: return - - selection_mapper = ( - io.read_measurements_selections(selectNameWin.selectedItemsText[0]) + + selection_mapper = io.read_measurements_selections( + selectNameWin.selectedItemsText[0] ) - + self.setCurrentSelectionFromMapper(selection_mapper) - + self.doNotWarn = False - + def saveLastSelection(self): - last_selected_meas = self.currentSelectionMapper() + last_selected_meas = self.currentSelectionMapper() load.write_last_selected_set_measurements(last_selected_meas) - + def setCurrentSelectionFromMapper(self, selection_mapper): for chNameGroupbox in self.chNameGroupboxes: chName = chNameGroupbox.chName @@ -2693,7 +2775,7 @@ def setCurrentSelectionFromMapper(self, selection_mapper): if chSelectedMeas is None: chNameGroupbox.setChecked(False) continue - + chNameGroupbox.setChecked(True) for checkBox in chNameGroupbox.checkBoxes: checked = chSelectedMeas.get(checkBox.text()) @@ -2701,35 +2783,31 @@ def setCurrentSelectionFromMapper(self, selection_mapper): checkBox.setChecked(True) else: checkBox.setChecked(False) - - size_selected_meas = selection_mapper.get( - self.sizeMetricsQGBox.title() - ) + + size_selected_meas = selection_mapper.get(self.sizeMetricsQGBox.title()) if size_selected_meas is None: self.sizeMetricsQGBox.setChecked(False) else: self.sizeMetricsQGBox.setChecked(True) - for checkBox in self.sizeMetricsQGBox.checkBoxes: + for checkBox in self.sizeMetricsQGBox.checkBoxes: checked = size_selected_meas.get(checkBox.text()) if checked is not None: checkBox.setChecked(True) else: checkBox.setChecked(False) - - size_selected_meas = selection_mapper.get( - self.regionPropsQGBox.title() - ) + + size_selected_meas = selection_mapper.get(self.regionPropsQGBox.title()) if size_selected_meas is None: self.regionPropsQGBox.setChecked(False) else: self.regionPropsQGBox.setChecked(True) - for checkBox in self.regionPropsQGBox.checkBoxes: + for checkBox in self.regionPropsQGBox.checkBoxes: checked = size_selected_meas.get(checkBox.text()) if checked is not None: checkBox.setChecked(True) else: checkBox.setChecked(False) - + if self.chIndipendCustomeMetricsQGBox is not None: ch_indip_custom_metrics = selection_mapper.get( self.chIndipendCustomeMetricsQGBox.title() @@ -2738,13 +2816,13 @@ def setCurrentSelectionFromMapper(self, selection_mapper): self.chIndipendCustomeMetricsQGBox.setChecked(False) else: self.chIndipendCustomeMetricsQGBox.setChecked(True) - for checkBox in self.chIndipendCustomeMetricsQGBox.checkBoxes: + for checkBox in self.chIndipendCustomeMetricsQGBox.checkBoxes: checked = size_selected_meas.get(checkBox.text()) if checked is not None: checkBox.setChecked(True) else: checkBox.setChecked(False) - + if self.mixedChannelsCombineMetricsQGBox is not None: ch_indip_custom_metrics = selection_mapper.get( self.mixedChannelsCombineMetricsQGBox.title() @@ -2753,31 +2831,31 @@ def setCurrentSelectionFromMapper(self, selection_mapper): self.mixedChannelsCombineMetricsQGBox.setChecked(False) else: self.mixedChannelsCombineMetricsQGBox.setChecked(True) - for checkBox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: + for checkBox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: checked = size_selected_meas.get(checkBox.text()) if checked is not None: checkBox.setChecked(True) else: checkBox.setChecked(False) - + def loadLastSelection(self): self.doNotWarn = True last_selected_meas = load.read_last_selected_set_measurements() last_selected_meas = dict(last_selected_meas) - + self.setCurrentSelectionFromMapper(last_selected_meas) - + self.doNotWarn = False - + def setDisabledMetricsRequestedForCombined(self, checked): checkbox = self.sender() - + if self.is_concat: - # When this dialogue is used in concatenate pos utility we do not + # When this dialogue is used in concatenate pos utility we do not # need to check that certain metrics are present return - - # Set checked and disable those metrics that are requested for + + # Set checked and disable those metrics that are requested for # combined measurements allCheckboxes = [] @@ -2785,18 +2863,18 @@ def setDisabledMetricsRequestedForCombined(self, checked): for chCheckBox in chNameGroupbox.checkBoxes: chCheckBox.setDisabled(False) allCheckboxes.append(chCheckBox) - + for sizeCheckBox in self.sizeMetricsQGBox.checkBoxes: sizeCheckBox.setDisabled(False) allCheckboxes.append(chCheckBox) - + for rpCheckBox in self.regionPropsQGBox.checkBoxes: rpCheckBox.setDisabled(False) allCheckboxes.append(chCheckBox) - + if not self.mixedChannelsCombineMetricsQGBox.isChecked(): return - + for cb in allCheckboxes: metricName = cb.text() for combCheckbox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: @@ -2807,24 +2885,24 @@ def setDisabledMetricsRequestedForCombined(self, checked): cb.setChecked(True) cb.setDisabled(True) cb.setToolTip( - 'This metric cannot be removed because it is required ' + "This metric cannot be removed because it is required " f'by the combined measurement "{combCheckbox.text()}"' ) def keyPressEvent(self, a0: QKeyEvent) -> None: state = self.state() return super().keyPressEvent(a0) - + def closeEvent(self, event): if self.cancel: self.sigCancel.emit() super().closeEvent(event) - + def restart(self): self.cancel = False self.close() self.sigRestart.emit() - + def setDisabledNotExistingMeasurements(self, existing_colnames): self.existing_colnames = existing_colnames for chNameGroupbox in self.chNameGroupboxes: @@ -2833,11 +2911,11 @@ def setDisabledNotExistingMeasurements(self, existing_colnames): if colname in existing_colnames: checkBox.setChecked(True) continue - + checkBox.setChecked(False) checkBox.setDisabled(True) self.setNotExistingMeasurementTooltip(checkBox) - + for checkBox in self.sizeMetricsQGBox.checkBoxes: colname = checkBox.text() if colname in existing_colnames: @@ -2846,14 +2924,14 @@ def setDisabledNotExistingMeasurements(self, existing_colnames): checkBox.setChecked(False) checkBox.setDisabled(True) self.setNotExistingMeasurementTooltip(checkBox) - + for checkBox in self.regionPropsQGBox.checkBoxes: prop_name = checkBox.text() for existing_col in existing_colnames: if prop_name == existing_col: checkBox.setChecked(True) break - m = re.match(fr'{prop_name}-\d', existing_col) + m = re.match(rf"{prop_name}-\d", existing_col) if m is not None: checkBox.setChecked(True) break @@ -2861,10 +2939,10 @@ def setDisabledNotExistingMeasurements(self, existing_colnames): checkBox.setChecked(False) checkBox.setDisabled(True) self.setNotExistingMeasurementTooltip(checkBox) - + if self.mixedChannelsCombineMetricsQGBox is None: return - + for combCheckbox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: colname = combCheckbox.text() if colname in existing_colnames: @@ -2873,7 +2951,7 @@ def setDisabledNotExistingMeasurements(self, existing_colnames): combCheckbox.setChecked(False) combCheckbox.setDisabled(True) self.setNotExistingMeasurementTooltip(combCheckbox) - + def addNonMeasurementColumns(self, colnames): additionalCols = measurements.get_non_measurements_cols( colnames, self.all_metrics @@ -2881,30 +2959,29 @@ def addNonMeasurementColumns(self, colnames): if not additionalCols: return self.nonMeasurementsGroupbox = widgets.CheckboxesGroupBox( - additionalCols, title='Additional columns', checkable=True + additionalCols, title="Additional columns", checkable=True ) self.groupsLayout.addWidget( - self.nonMeasurementsGroupbox, 0, self.last_col+1, self.last_row+1, 1 + self.nonMeasurementsGroupbox, 0, self.last_col + 1, self.last_row + 1, 1 ) - - + def setNotExistingMeasurementTooltip(self, checkBox): checkBox.setToolTip( - 'Measurement is disabled because it is not present in selected ' - 'acdc_output tables, hence it cannot be addded to concatenated ' - 'table. ' + "Measurement is disabled because it is not present in selected " + "acdc_output tables, hence it cannot be addded to concatenated " + "table. " ) - def ok_cb(self): + def ok_cb(self): for chNameGroupbox in self.chNameGroupboxes: chNameGroupbox.calcForEachZsliceRequested = ( chNameGroupbox.isCalcForEachZsliceRequested() ) - + self.sizeMetricsQGBox.calcForEachZsliceRequested = ( self.sizeMetricsQGBox.isCalcForEachZsliceRequested() ) - + if self.allPos_acdc_df_cols is None: self.saveLastSelection() self.cancel = False @@ -2925,7 +3002,7 @@ def ok_cb(self): continue if not checkBox.isChecked() and is_existing: unchecked_existing_colnames.append(colname) - + for checkBox in self.sizeMetricsQGBox.checkBoxes: colname = checkBox.text() is_existing = colname in existing_colnames @@ -2955,31 +3032,34 @@ def ok_cb(self): return self.saveLastSelection() - self.cancel = False + self.cancel = False self.close() self.sigClosed.emit() - + def warnUncheckedExistingMeasurements( - self, unchecked_existing_colnames, unchecked_existing_rps - ): + self, unchecked_existing_colnames, unchecked_existing_rps + ): msg = widgets.myMessageBox() msg.setWidth(500) msg.addShowInFileManagerButton(self.acdc_df_path) txt = html_utils.paragraph( - 'You chose to not save some measurements that are ' - 'already present in the saved acdc_output.csv ' - 'file.

' - 'Do you want to delete these measurements or ' - 'keep them?

' - 'Existing measurements not selected:' + "You chose to not save some measurements that are " + "already present in the saved acdc_output.csv " + "file.

" + "Do you want to delete these measurements or " + "keep them?

" + "Existing measurements not selected:" ) listView = widgets.readOnlyQList(msg) items = unchecked_existing_colnames.copy() items.extend(unchecked_existing_rps) listView.addItems(items) _, delButton, keepButton = msg.warning( - self, 'Unchecked existing measurements', txt, - widgets=listView, buttonsTexts=('Cancel', 'Delete', 'Keep') + self, + "Unchecked existing measurements", + txt, + widgets=listView, + buttonsTexts=("Cancel", "Delete", "Keep"), ) return msg.cancel, msg.clickedButton == delButton @@ -2990,28 +3070,43 @@ def show(self, block=False): screenHeight = self.screen().size().height() screenLeft = self.screen().geometry().x() screenTop = self.screen().geometry().y() - h = screenHeight-200 - minColWith = screenWidth/5 - w = minColWith*(self.last_col+1) - xLeft = int((screenWidth-w)/2) + h = screenHeight - 200 + minColWith = screenWidth / 5 + w = minColWith * (self.last_col + 1) + xLeft = int((screenWidth - w) / 2) if w > screenWidth: - self.move(screenLeft+10, screenTop+50) - self.resize(screenWidth-20, h) + self.move(screenLeft + 10, screenTop + 50) + self.resize(screenWidth - 20, h) else: - self.move(screenLeft+xLeft, screenTop+50) + self.move(screenLeft + xLeft, screenTop + 50) self.resize(int(w), h) super().show(block=block) + class QDialogMetadataXML(QDialog): def __init__( - self, title='Metadata', - LensNA=1.0, rawFilename='test', SizeT=1, SizeZ=1, SizeC=1, SizeS=1, - TimeIncrement=1.0, TimeIncrementUnit='s', - PhysicalSizeX=1.0, PhysicalSizeY=1.0, PhysicalSizeZ=1.0, - PhysicalSizeUnit='μm', ImageName='', chNames=None, emWavelens=None, - parent=None, rawDataStruct=None, sampleImgData=None, - rawFilePath=None - ): + self, + title="Metadata", + LensNA=1.0, + rawFilename="test", + SizeT=1, + SizeZ=1, + SizeC=1, + SizeS=1, + TimeIncrement=1.0, + TimeIncrementUnit="s", + PhysicalSizeX=1.0, + PhysicalSizeY=1.0, + PhysicalSizeZ=1.0, + PhysicalSizeUnit="μm", + ImageName="", + chNames=None, + emWavelens=None, + parent=None, + rawDataStruct=None, + sampleImgData=None, + rawFilePath=None, + ): self.cancel = True self.trust = False self.overWrite = False @@ -3033,17 +3128,21 @@ def __init__( mainLayout = QVBoxLayout() entriesLayout = QGridLayout() self.channelNameLayouts = ( - QVBoxLayout(), QVBoxLayout(), QVBoxLayout(), QVBoxLayout() + QVBoxLayout(), + QVBoxLayout(), + QVBoxLayout(), + QVBoxLayout(), ) self.channelEmWLayouts = ( - QVBoxLayout(), QVBoxLayout(), QVBoxLayout(), QVBoxLayout() + QVBoxLayout(), + QVBoxLayout(), + QVBoxLayout(), + QVBoxLayout(), ) buttonsLayout = QGridLayout() infoLabel = QLabel() - infoTxt = ( - 'Confirm/Edit the metadata below.' - ) + infoTxt = "Confirm/Edit the metadata below." infoLabel.setText(infoTxt) # padding: top, left, bottom, right infoLabel.setStyleSheet("font-size:12pt; padding:0px 0px 5px 0px;") @@ -3051,10 +3150,10 @@ def __init__( noteLabel = QLabel() noteLabel.setText( - f'NOTE: If you are not sure about some of the entries ' + f"NOTE: If you are not sure about some of the entries " 'you can try to click "Ok".\n' - 'If they are wrong you will get ' - 'an error message later when trying to read the data.' + "If they are wrong you will get " + "an error message later when trying to read the data." ) noteLabel.setAlignment(Qt.AlignCenter) mainLayout.addWidget(noteLabel, alignment=Qt.AlignCenter) @@ -3064,12 +3163,12 @@ def __init__( to_tif_radiobutton.setChecked(True) to_h5_radiobutton = QRadioButton(".h5") to_h5_radiobutton.setToolTip( - '.h5 is highly recommended for big datasets to avoid memory issues.\n' - 'As a rule of thumb, if the single position, single channel file\n' - 'is larger than 1/5 of the available RAM we recommend using .h5 format' + ".h5 is highly recommended for big datasets to avoid memory issues.\n" + "As a rule of thumb, if the single position, single channel file\n" + "is larger than 1/5 of the available RAM we recommend using .h5 format" ) self.to_h5_radiobutton = to_h5_radiobutton - txt = 'File format: ' + txt = "File format: " label = QLabel(txt) fileFormatLayout = QHBoxLayout() fileFormatLayout.addStretch(1) @@ -3087,20 +3186,20 @@ def __init__( self.SizeS_SB.setMinimum(1) self.SizeS_SB.setMaximum(2147483647) self.SizeS_SB.setValue(SizeS) - txt = 'Number of positions (SizeS): ' + txt = "Number of positions (SizeS): " label = QLabel(txt) entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) entriesLayout.addWidget(self.SizeS_SB, row, 1) - + if rawDataStruct == 0: row += 1 self.SizeS_SB.setValue(1) self.SizeS_SB.setDisabled(True) self.posSelector = widgets.ExpandableListBox() - positions = ['All positions'] - positions.extend([f'Position_{i+1}' for i in range(SizeS)]) + positions = ["All positions"] + positions.extend([f"Position_{i + 1}" for i in range(SizeS)]) self.posSelector.addItems(positions) - txt = 'Positions to save: ' + txt = "Positions to save: " label = QLabel(txt) entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) entriesLayout.addWidget(self.posSelector, row, 1) @@ -3111,27 +3210,27 @@ def __init__( self.LensNA_DSB.setAlignment(Qt.AlignCenter) self.LensNA_DSB.setSingleStep(0.1) self.LensNA_DSB.setValue(LensNA) - txt = 'Numerical Aperture Objective Lens: ' + txt = "Numerical Aperture Objective Lens: " label = QLabel(txt) entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) entriesLayout.addWidget(self.LensNA_DSB, row, 1) - + row += 1 self.SizeT_SB = QSpinBox() self.SizeT_SB.setAlignment(Qt.AlignCenter) self.SizeT_SB.setMinimum(1) self.SizeT_SB.setMaximum(2147483647) self.SizeT_SB.setValue(SizeT) - txt = 'Number of frames (SizeT): ' + txt = "Number of frames (SizeT): " label = QLabel(txt) entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) entriesLayout.addWidget(self.SizeT_SB, row, 1) self.SizeT_SB.valueChanged.connect(self.hideShowTimeIncrement) - + row += 1 self.timeRangeToSaveWidget = widgets.RangeSelector(integers=True) self.timeRangeToSaveWidget.setRange(1, SizeT) - txt = 'Time range to save: ' + txt = "Time range to save: " label = QLabel(txt) self.timeRangeToSaveWidget.label = label entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) @@ -3143,7 +3242,7 @@ def __init__( self.SizeZ_SB.setMinimum(1) self.SizeZ_SB.setMaximum(2147483647) self.SizeZ_SB.setValue(SizeZ) - txt = 'Number of z-slices in the z-stack (SizeZ): ' + txt = "Number of z-slices in the z-stack (SizeZ): " label = QLabel(txt) entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) entriesLayout.addWidget(self.SizeZ_SB, row, 1) @@ -3155,18 +3254,15 @@ def __init__( ) self.TimeIncrement_DSB.setValue(TimeIncrement) self.TimeIncrement_DSB.setMinimum(0.0) - txt = 'Frame interval: ' + txt = "Frame interval: " label = QLabel(txt) self.TimeIncrement_Label = label entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) entriesLayout.addWidget(self.TimeIncrement_DSB, row, 1) self.TimeIncrementUnit_CB = QComboBox() - unitItems = [ - 'ms', 'seconds', 'minutes', 'hours' - ] - currentTxt = [unit for unit in unitItems - if unit.startswith(TimeIncrementUnit)] + unitItems = ["ms", "seconds", "minutes", "hours"] + currentTxt = [unit for unit in unitItems if unit.startswith(TimeIncrementUnit)] self.TimeIncrementUnit_CB.addItems(unitItems) if currentTxt: self.TimeIncrementUnit_CB.setCurrentText(currentTxt[0]) @@ -3181,17 +3277,14 @@ def __init__( self.PhysicalSizeX_DSB.setSingleStep(0.001) self.PhysicalSizeX_DSB.setDecimals(7) self.PhysicalSizeX_DSB.setValue(PhysicalSizeX) - txt = 'Pixel width (X): ' + txt = "Pixel width (X): " label = QLabel(txt) entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) entriesLayout.addWidget(self.PhysicalSizeX_DSB, row, 1) self.PhysicalSizeUnit_CB = QComboBox() - unitItems = [ - 'nm', 'μm', 'mm', 'cm' - ] - currentTxt = [unit for unit in unitItems - if unit.startswith(PhysicalSizeUnit)] + unitItems = ["nm", "μm", "mm", "cm"] + currentTxt = [unit for unit in unitItems if unit.startswith(PhysicalSizeUnit)] self.PhysicalSizeUnit_CB.addItems(unitItems) if currentTxt: self.PhysicalSizeUnit_CB.setCurrentText(currentTxt[0]) @@ -3209,14 +3302,14 @@ def __init__( self.PhysicalSizeY_DSB.setSingleStep(0.001) self.PhysicalSizeY_DSB.setDecimals(7) self.PhysicalSizeY_DSB.setValue(PhysicalSizeY) - txt = 'Pixel height (Y): ' + txt = "Pixel height (Y): " label = QLabel(txt) entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) entriesLayout.addWidget(self.PhysicalSizeY_DSB, row, 1) self.PhysicalSizeYUnit_Label = QLabel() self.PhysicalSizeYUnit_Label.setStyleSheet( - 'font-size:13px; padding:5px 0px 2px 0px;' + "font-size:13px; padding:5px 0px 2px 0px;" ) unit = self.PhysicalSizeUnit_CB.currentText() self.PhysicalSizeYUnit_Label.setText(unit) @@ -3229,7 +3322,7 @@ def __init__( self.PhysicalSizeZ_DSB.setSingleStep(0.001) self.PhysicalSizeZ_DSB.setDecimals(7) self.PhysicalSizeZ_DSB.setValue(PhysicalSizeZ) - txt = 'Voxel depth (Z): ' + txt = "Voxel depth (Z): " self.PSZlabel = QLabel(txt) entriesLayout.addWidget(self.PSZlabel, row, 0, alignment=Qt.AlignRight) entriesLayout.addWidget(self.PhysicalSizeZ_DSB, row, 1) @@ -3237,7 +3330,7 @@ def __init__( self.PhysicalSizeZUnit_Label = QLabel() # padding: top, left, bottom, right self.PhysicalSizeZUnit_Label.setStyleSheet( - 'font-size:13px; padding:5px 0px 2px 0px;' + "font-size:13px; padding:5px 0px 2px 0px;" ) unit = self.PhysicalSizeUnit_CB.currentText() self.PhysicalSizeZUnit_Label.setText(unit) @@ -3254,7 +3347,7 @@ def __init__( self.SizeC_SB.setMinimum(1) self.SizeC_SB.setMaximum(2147483647) self.SizeC_SB.setValue(SizeC) - txt = 'Number of channels (SizeC): ' + txt = "Number of channels (SizeC): " label = QLabel(txt) entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) entriesLayout.addWidget(self.SizeC_SB, row, 1) @@ -3269,22 +3362,22 @@ def __init__( self.filename_QLabels = [] self.showChannelDataButtons = [] - ext = 'h5' if self.to_h5_radiobutton.isChecked() else 'tif' + ext = "h5" if self.to_h5_radiobutton.isChecked() else "tif" for c in range(SizeC): chName_QLE = QLineEdit() - chName_QLE.setStyleSheet('') + chName_QLE.setStyleSheet("") chName_QLE.setAlignment(Qt.AlignCenter) chName_QLE.textChanged.connect(self.checkChNames) if chNames is not None: chName_QLE.setText(chNames[c]) else: - chName_QLE.setText(f'channel_{c}') - filename = f'' + chName_QLE.setText(f"channel_{c}") + filename = f"" - txt = f'Channel {c} name: ' + txt = f"Channel {c} name: " label = QLabel(txt) - filenameDescLabel = QLabel(f'e.g., filename for channel {c}: ') + filenameDescLabel = QLabel(f"e.g., filename for channel {c}: ") chName = chName_QLE.text() chName = self.removeInvalidCharacters(chName) @@ -3292,9 +3385,9 @@ def __init__( filenameLabel = QLabel(f"""

{rawFilename}_{chName}.{ext}

""") - filenameLabel.setToolTip(f'{self.rawFilename}_{chName}.{ext}') + filenameLabel.setToolTip(f"{self.rawFilename}_{chName}.{ext}") - checkBox = QCheckBox('Save this channel') + checkBox = QCheckBox("Save this channel") checkBox.setChecked(True) checkBox.stateChanged.connect(self.saveCh_checkBox_cb) @@ -3309,12 +3402,12 @@ def __init__( self.channelNameLayouts[2].addWidget(checkBox) if c == 0 and ImageName: - addImageName_QCB = QCheckBox('Include image name') + addImageName_QCB = QCheckBox("Include image name") addImageName_QCB.stateChanged.connect(self.addImageName_cb) self.addImageName_QCB = addImageName_QCB self.channelNameLayouts[2].addWidget(addImageName_QCB) else: - self.addImageName_QCB = QCheckBox('dummy') + self.addImageName_QCB = QCheckBox("dummy") self.addImageName_QCB.hide() self.channelNameLayouts[2].addWidget(QLabel()) @@ -3349,29 +3442,30 @@ def __init__( else: emWavelen_DSB.setValue(500.0) - txt = f'Channel {c} emission wavelength: ' + txt = f"Channel {c} emission wavelength: " label = QLabel(txt) self.channelEmWLayouts[0].addWidget(label, alignment=Qt.AlignRight) self.channelEmWLayouts[1].addWidget(emWavelen_DSB) self.emWavelens_DSBs.append(emWavelen_DSB) - unit = QLabel('nm') - unit.setStyleSheet('font-size:13px; padding:5px 0px 2px 0px;') + unit = QLabel("nm") + unit.setStyleSheet("font-size:13px; padding:5px 0px 2px 0px;") self.channelEmWLayouts[2].addWidget(unit) entriesLayout.setContentsMargins(0, 15, 0, 0) - if rawDataStruct is None or rawDataStruct!=-1: - okButton = widgets.okPushButton(' Ok ') - elif rawDataStruct==1: - okButton = QPushButton(' Load next position ') + if rawDataStruct is None or rawDataStruct != -1: + okButton = widgets.okPushButton(" Ok ") + elif rawDataStruct == 1: + okButton = QPushButton(" Load next position ") buttonsLayout.addWidget(okButton, 0, 1) self.trustButton = None self.overWriteButton = None - if rawDataStruct==1: + if rawDataStruct == 1: trustButton = QPushButton( - ' Trust metadata reader\n for all next positions ') + " Trust metadata reader\n for all next positions " + ) trustButton.setToolTip( "If you didn't have to manually modify metadata entries\n" "it is very likely that metadata from the metadata reader\n" @@ -3384,7 +3478,8 @@ def __init__( self.trustButton = trustButton overWriteButton = QPushButton( - ' Use the above metadata\n for all the next positions ') + " Use the above metadata\n for all the next positions " + ) overWriteButton.setToolTip( "If you had to manually modify metadata entries\n" "AND you know they will be the same for all next positions\n" @@ -3397,7 +3492,7 @@ def __init__( trustButton.clicked.connect(self.ok_cb) overWriteButton.clicked.connect(self.ok_cb) - cancelButton = widgets.cancelPushButton('Cancel') + cancelButton = widgets.cancelPushButton("Cancel") buttonsLayout.addWidget(cancelButton, 0, 2) buttonsLayout.setColumnStretch(0, 1) buttonsLayout.setColumnStretch(3, 1) @@ -3409,7 +3504,7 @@ def __init__( okButton.clicked.connect(self.ok_cb) cancelButton.clicked.connect(self.cancel_cb) - + self.hideShowTimeIncrement(SizeT) self.readSampleImgDataAgain = False @@ -3421,24 +3516,24 @@ def saveCh_checkBox_cb(self, state): idx = self.saveChannels_QCBs.index(self.sender()) LE = self.chNames_QLEs[idx] idx *= 2 - LE.setDisabled(state==0) + LE.setDisabled(state == 0) label = self.channelNameLayouts[0].itemAt(idx).widget() if state == 0: - label.setStyleSheet('color: gray; font-size: 10pt') + label.setStyleSheet("color: gray; font-size: 10pt") else: - label.setStyleSheet('color: black; font-size: 10pt') + label.setStyleSheet("color: black; font-size: 10pt") - label = self.channelNameLayouts[0].itemAt(idx+1).widget() + label = self.channelNameLayouts[0].itemAt(idx + 1).widget() if state == 0: - label.setStyleSheet('color: gray; font-size: 10pt') + label.setStyleSheet("color: gray; font-size: 10pt") else: - label.setStyleSheet('color: black; font-size: 10pt') + label.setStyleSheet("color: black; font-size: 10pt") - label = self.channelNameLayouts[1].itemAt(idx+1).widget() + label = self.channelNameLayouts[1].itemAt(idx + 1).widget() if state == 0: - label.setStyleSheet('color: gray; font-size: 10pt') + label.setStyleSheet("color: gray; font-size: 10pt") else: - label.setStyleSheet('color: black; font-size: 10pt') + label.setStyleSheet("color: black; font-size: 10pt") def addImageName_cb(self, state): for idx in range(self.SizeC_SB.value()): @@ -3446,36 +3541,34 @@ def addImageName_cb(self, state): def setInvalidChName_StyleSheet(self, LE): LE.setStyleSheet( - 'border-radius: 4px;' - 'border: 1.5px solid red;' - 'padding: 1px 0px 1px 0px' + "border-radius: 4px;border: 1.5px solid red;padding: 1px 0px 1px 0px" ) def removeInvalidCharacters(self, chName): # Remove invalid charachters chName = "".join( - c if c.isalnum() or c=='_' or c=='' else '_' for c in chName + c if c.isalnum() or c == "_" or c == "" else "_" for c in chName ) - trim_ = chName.endswith('_') + trim_ = chName.endswith("_") while trim_: chName = chName[:-1] - trim_ = chName.endswith('_') + trim_ = chName.endswith("_") return chName def updateFileFormat(self, is_h5): for idx in range(len(self.chNames_QLEs)): self.updateFilename(idx) - + def SizeSvalueChanged(self, SizeS): - positions = ['All positions'] - positions.extend([f'Position_{i+1}' for i in range(SizeS)]) + positions = ["All positions"] + positions.extend([f"Position_{i + 1}" for i in range(SizeS)]) self.posSelector.setItems(positions) - + def elidedRawFilename(self): n = 31 - idx = int((n-3)/2) + idx = int((n - 3) / 2) if len(self.rawFilename) > 21: - elidedText = f'{self.rawFilename[:idx]}...{self.rawFilename[-idx:]}' + elidedText = f"{self.rawFilename[:idx]}...{self.rawFilename[-idx:]}" else: elidedText = self.rawFilename return elidedText @@ -3484,34 +3577,34 @@ def updateFilename(self, idx): chName = self.chNames_QLEs[idx].text() chName = self.removeInvalidCharacters(chName) if self.rawDataStruct == 2: - rawFilename = f'{self.rawFilename}_s{idx+1}' + rawFilename = f"{self.rawFilename}_s{idx + 1}" else: rawFilename = self.rawFilename - ext = 'h5' if self.to_h5_radiobutton.isChecked() else 'tif' + ext = "h5" if self.to_h5_radiobutton.isChecked() else "tif" rawFilename = self.elidedRawFilename() filenameLabel = self.filename_QLabels[idx] if self.addImageName_QCB.isChecked(): self.ImageName = self.removeInvalidCharacters(self.ImageName) - filename = (f""" + filename = f"""

{rawFilename}_{self.ImageName}_{chName}.{ext}

- """) - fullFilename = f'{self.rawFilename}_{self.ImageName}_{chName}.{ext}' + """ + fullFilename = f"{self.rawFilename}_{self.ImageName}_{chName}.{ext}" else: - filename = (f""" + filename = f"""

{rawFilename}_{chName}.{ext}

- """) - fullFilename = f'{self.rawFilename}_{chName}.{ext}' + """ + fullFilename = f"{self.rawFilename}_{chName}.{ext}" filenameLabel.setToolTip(fullFilename) filenameLabel.setText(filename) - def checkChNames(self, text=''): + def checkChNames(self, text=""): if self.sender() in self.chNames_QLEs: idx = self.chNames_QLEs.index(self.sender()) self.updateFilename(idx) @@ -3519,13 +3612,12 @@ def checkChNames(self, text=''): idx = self.saveChannels_QCBs.index(self.sender()) self.updateFilename(idx) - areChNamesValid = True if len(self.chNames_QLEs) == 1: LE1 = self.chNames_QLEs[0] saveCh = self.saveChannels_QCBs[0].isChecked() if not saveCh: - LE1.setStyleSheet('') + LE1.setStyleSheet("") return areChNamesValid s1 = LE1.text() @@ -3533,7 +3625,7 @@ def checkChNames(self, text=''): self.setInvalidChName_StyleSheet(LE1) areChNamesValid = False else: - LE1.setStyleSheet('') + LE1.setStyleSheet("") return areChNamesValid for LE1, LE2 in combinations(self.chNames_QLEs, 2): @@ -3543,33 +3635,33 @@ def checkChNames(self, text=''): LE2_idx = self.chNames_QLEs.index(LE2) saveCh1 = self.saveChannels_QCBs[LE1_idx].isChecked() saveCh2 = self.saveChannels_QCBs[LE2_idx].isChecked() - if not s1 or not s2 or s1==s2: + if not s1 or not s2 or s1 == s2: if not s1 and saveCh1: self.setInvalidChName_StyleSheet(LE1) areChNamesValid = False else: - LE1.setStyleSheet('') + LE1.setStyleSheet("") if not s2 and saveCh2: self.setInvalidChName_StyleSheet(LE2) areChNamesValid = False else: - LE2.setStyleSheet('') + LE2.setStyleSheet("") if s1 == s2 and saveCh1 and saveCh2: self.setInvalidChName_StyleSheet(LE1) self.setInvalidChName_StyleSheet(LE2) areChNamesValid = False else: - LE1.setStyleSheet('') - LE2.setStyleSheet('') + LE1.setStyleSheet("") + LE2.setStyleSheet("") return areChNamesValid def hideShowTimeIncrement(self, value): if self.TimeIncrement_DSB.isVisible() and value == 1: self.readSampleImgDataAgain = True - + if not self.TimeIncrement_DSB.isVisible() and value > 1: self.readSampleImgDataAgain = True - + if value > 1: self.TimeIncrement_DSB.show() self.TimeIncrementUnit_CB.show() @@ -3598,7 +3690,7 @@ def hideShowPhysicalSizeZ(self, value): def updatePSUnit(self, unit): self.PhysicalSizeYUnit_Label.setText(unit) self.PhysicalSizeZUnit_Label.setText(unit) - + def warnRestart(self): msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph(""" @@ -3606,11 +3698,11 @@ def warnRestart(self): because it needs to read the image data again.

Thank you for your patience. """) - msg.warning(self, 'Restart required', txt) + msg.warning(self, "Restart required", txt) def showChannelData(self, checked=False, idx=None): if self.readSampleImgDataAgain: - # User changed SizeZ, SizeT, or SizeC --> we need to read sample + # User changed SizeZ, SizeT, or SizeC --> we need to read sample # image again del self.sampleImgData self.requestedReadingSampleImageDataAgain = True @@ -3620,35 +3712,37 @@ def showChannelData(self, checked=False, idx=None): self.cancel = False self.close() return - + if idx is None: idx = self.showChannelDataButtons.index(self.sender()) - dimsOrder = 'ctz' + dimsOrder = "ctz" imgData = self.sampleImgData[dimsOrder][idx] posData = myutils.utilClass() posData.frame_i = 0 sampleSizeT = 4 if self.SizeT_SB.value() >= 4 else self.SizeT_SB.value() posData.SizeT = sampleSizeT SizeZ = self.SizeZ_SB.value() - posData.SizeZ = 20 if SizeZ>20 else SizeZ - posData.filename = f'{self.rawFilename}_C={idx}' - posData.segmInfo_df = pd.DataFrame({ - 'filename': [posData.filename]*sampleSizeT, - 'frame_i': range(sampleSizeT), - 'which_z_proj_gui': ['single z-slice']*sampleSizeT, - 'z_slice_used_gui': [int(posData.SizeZ/2)]*sampleSizeT - }).set_index(['filename', 'frame_i']) + posData.SizeZ = 20 if SizeZ > 20 else SizeZ + posData.filename = f"{self.rawFilename}_C={idx}" + posData.segmInfo_df = pd.DataFrame( + { + "filename": [posData.filename] * sampleSizeT, + "frame_i": range(sampleSizeT), + "which_z_proj_gui": ["single z-slice"] * sampleSizeT, + "z_slice_used_gui": [int(posData.SizeZ / 2)] * sampleSizeT, + } + ).set_index(["filename", "frame_i"]) path_li = os.path.normpath(self.rawFilePath).split(os.sep) - posData.relPath = f'{f"{os.sep}".join(path_li[-3:1])}' - posData.relPath = f'{posData.relPath}{os.sep}{posData.filename}' + posData.relPath = f"{f'{os.sep}'.join(path_li[-3:1])}" + posData.relPath = f"{posData.relPath}{os.sep}{posData.filename}" if sampleSizeT == 1: - posData.img_data = [imgData] # single frame data + posData.img_data = [imgData] # single frame data else: posData.img_data = imgData if self.imageViewer is not None: self.imageViewer.close() - + self.imageViewer = imageViewer( posData=posData, isSigleFrame=False, enableOverlay=False ) @@ -3656,38 +3750,36 @@ def showChannelData(self, checked=False, idx=None): self.imageViewer.update_img() self.imageViewer.sigClosed.connect(self.imageViewerClosed) self.imageViewer.show() - + def imageViewerClosed(self): self.imageViewer = None def addRemoveChannels(self, value): self.readSampleImgDataAgain = True currentSizeC = len(self.chNames_QLEs) - DeltaChannels = abs(value-currentSizeC) - ext = 'h5' if self.to_h5_radiobutton.isChecked() else 'tif' + DeltaChannels = abs(value - currentSizeC) + ext = "h5" if self.to_h5_radiobutton.isChecked() else "tif" if value > currentSizeC: - for c in range(currentSizeC, currentSizeC+DeltaChannels): + for c in range(currentSizeC, currentSizeC + DeltaChannels): chName_QLE = QLineEdit() - chName_QLE.setStyleSheet('') + chName_QLE.setStyleSheet("") chName_QLE.setAlignment(Qt.AlignCenter) - chName_QLE.setText(f'channel_{c}') + chName_QLE.setText(f"channel_{c}") chName_QLE.textChanged.connect(self.checkChNames) - txt = f'Channel {c} name: ' + txt = f"Channel {c} name: " label = QLabel(txt) - filenameDescLabel = QLabel( - f'e.g., filename for channel {c}: ' - ) + filenameDescLabel = QLabel(f"e.g., filename for channel {c}: ") chName = chName_QLE.text() rawFilename = self.elidedRawFilename() filenameLabel = QLabel(f"""

{rawFilename}_{chName}.{ext}

""") - filenameLabel.setToolTip(f'{self.rawFilename}_{chName}.{ext}') + filenameLabel.setToolTip(f"{self.rawFilename}_{chName}.{ext}") - checkBox = QCheckBox('Save this channel') + checkBox = QCheckBox("Save this channel") checkBox.setChecked(True) checkBox.stateChanged.connect(self.saveCh_checkBox_cb) @@ -3721,24 +3813,24 @@ def addRemoveChannels(self, value): emWavelen_DSB.setSingleStep(0.001) emWavelen_DSB.setDecimals(2) emWavelen_DSB.setValue(500.0) - unit = QLabel('nm') - unit.setStyleSheet('font-size:13px; padding:5px 0px 2px 0px;') + unit = QLabel("nm") + unit.setStyleSheet("font-size:13px; padding:5px 0px 2px 0px;") - txt = f'Channel {c} emission wavelength: ' + txt = f"Channel {c} emission wavelength: " label = QLabel(txt) self.channelEmWLayouts[0].addWidget(label, alignment=Qt.AlignRight) self.channelEmWLayouts[1].addWidget(emWavelen_DSB) self.channelEmWLayouts[2].addWidget(unit) self.emWavelens_DSBs.append(emWavelen_DSB) else: - for c in range(currentSizeC, currentSizeC+DeltaChannels): - idx = (c-1)*2 + for c in range(currentSizeC, currentSizeC + DeltaChannels): + idx = (c - 1) * 2 label1 = self.channelNameLayouts[0].itemAt(idx).widget() - label2 = self.channelNameLayouts[0].itemAt(idx+1).widget() + label2 = self.channelNameLayouts[0].itemAt(idx + 1).widget() chName_QLE = self.channelNameLayouts[1].itemAt(idx).widget() - filename_L = self.channelNameLayouts[1].itemAt(idx+1).widget() + filename_L = self.channelNameLayouts[1].itemAt(idx + 1).widget() checkBox = self.channelNameLayouts[2].itemAt(idx).widget() - dummyLabel = self.channelNameLayouts[2].itemAt(idx+1).widget() + dummyLabel = self.channelNameLayouts[2].itemAt(idx + 1).widget() showButton = self.showChannelDataButtons[-1] showButton.clicked.disconnect() @@ -3755,30 +3847,28 @@ def addRemoveChannels(self, value): self.filename_QLabels.pop(-1) self.showChannelDataButtons.pop(-1) - label = self.channelEmWLayouts[0].itemAt(c-1).widget() - emWavelen_DSB = self.channelEmWLayouts[1].itemAt(c-1).widget() - unit = self.channelEmWLayouts[2].itemAt(c-1).widget() + label = self.channelEmWLayouts[0].itemAt(c - 1).widget() + emWavelen_DSB = self.channelEmWLayouts[1].itemAt(c - 1).widget() + unit = self.channelEmWLayouts[2].itemAt(c - 1).widget() self.channelEmWLayouts[0].removeWidget(label) self.channelEmWLayouts[1].removeWidget(emWavelen_DSB) self.channelEmWLayouts[2].removeWidget(unit) self.emWavelens_DSBs.pop(-1) self.adjustSize() - + def ok_cb(self, event): areChNamesValid = self.checkChNames() if not areChNamesValid: err_msg = html_utils.paragraph( - 'Channel names cannot be empty or equal to each other.' - '

' - 'Insert a unique text for each channel name.' + "Channel names cannot be empty or equal to each other." + "

" + "Insert a unique text for each channel name." ) msg = widgets.myMessageBox() - msg.critical( - self, 'Invalid channel names', err_msg - ) + msg.critical(self, "Invalid channel names", err_msg) return - + self.getValues() self.convertUnits() @@ -3802,46 +3892,46 @@ def getValues(self): self.PhysicalSizeY = self.PhysicalSizeY_DSB.value() self.PhysicalSizeZ = self.PhysicalSizeZ_DSB.value() self.to_h5 = self.to_h5_radiobutton.isChecked() - if hasattr(self, 'posSelector'): + if hasattr(self, "posSelector"): self.selectedPos = self.posSelector.selectedItemsText() else: - self.selectedPos = ['All Positions'] + self.selectedPos = ["All Positions"] self.chNames = [] - if hasattr(self, 'addImageName_QCB'): + if hasattr(self, "addImageName_QCB"): self.addImageName = self.addImageName_QCB.isChecked() else: self.addImageName = False self.saveChannels = [] for LE, QCB in zip(self.chNames_QLEs, self.saveChannels_QCBs): s = LE.text() - s = "".join(c if c.isalnum() or c=='_' or c=='' else '_' for c in s) - trim_ = s.endswith('_') + s = "".join(c if c.isalnum() or c == "_" or c == "" else "_" for c in s) + trim_ = s.endswith("_") while trim_: s = s[:-1] - trim_ = s.endswith('_') + trim_ = s.endswith("_") self.chNames.append(s) self.saveChannels.append(QCB.isChecked()) self.emWavelens = [DSB.value() for DSB in self.emWavelens_DSBs] def convertUnits(self): timeUnit = self.TimeIncrementUnit_CB.currentText() - if timeUnit == 'ms': + if timeUnit == "ms": self.TimeIncrement /= 1000 - elif timeUnit == 'minutes': + elif timeUnit == "minutes": self.TimeIncrement *= 60 - elif timeUnit == 'hours': + elif timeUnit == "hours": self.TimeIncrement *= 3600 PhysicalSizeUnit = self.PhysicalSizeUnit_CB.currentText() - if timeUnit == 'nm': + if timeUnit == "nm": self.PhysicalSizeX /= 1000 self.PhysicalSizeY /= 1000 self.PhysicalSizeZ /= 1000 - elif timeUnit == 'mm': + elif timeUnit == "mm": self.PhysicalSizeX *= 1000 self.PhysicalSizeY *= 1000 self.PhysicalSizeZ *= 1000 - elif timeUnit == 'cm': + elif timeUnit == "cm": self.PhysicalSizeX *= 1e4 self.PhysicalSizeY *= 1e4 self.PhysicalSizeZ *= 1e4 @@ -3852,7 +3942,7 @@ def cancel_cb(self, event): def exec_(self): self.show(block=True) - + def setSize(self): h = self.SizeS_SB.height() self.TimeIncrement_DSB.setMinimumHeight(h) @@ -3866,24 +3956,23 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class CellACDCTrackerParamsWin(QDialog): def __init__(self, parent=None): self.cancel = True super().__init__(parent) self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - self.setWindowTitle('Cell-ACDC tracker parameters') + self.setWindowTitle("Cell-ACDC tracker parameters") paramsLayout = QGridLayout() paramsBox = QGroupBox() row = 0 - label = QLabel(html_utils.paragraph( - 'Minimum overlap between objects' - )) + label = QLabel(html_utils.paragraph("Minimum overlap between objects")) paramsLayout.addWidget(label, row, 0) maxOverlapSpinbox = QDoubleSpinBox() maxOverlapSpinbox.setAlignment(Qt.AlignCenter) @@ -3900,8 +3989,8 @@ def __init__(self, parent=None): paramsLayout.setColumnStretch(1, 1) paramsLayout.setColumnStretch(2, 0) - cancelButton = widgets.cancelPushButton('Cancel') - okButton = widgets.okPushButton(' Ok ') + cancelButton = widgets.cancelPushButton("Cancel") + okButton = widgets.okPushButton(" Ok ") cancelButton.clicked.connect(self.cancel_cb) okButton.clicked.connect(self.ok_cb) @@ -3912,7 +4001,7 @@ def __init__(self, parent=None): buttonsLayout.addWidget(okButton) layout = QVBoxLayout() - infoText = html_utils.paragraph('Cell-ACDC tracker parameters') + infoText = html_utils.paragraph("Cell-ACDC tracker parameters") infoLabel = QLabel(infoText) layout.addWidget(infoLabel, alignment=Qt.AlignCenter) layout.addSpacing(10) @@ -3927,21 +4016,21 @@ def __init__(self, parent=None): def showInfo(self): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - 'Cell-ACDC tracker computes the percentage of overlap between ' - 'all the objects
at frame n and all the ' - 'objects in previous frame n-1.

' - 'All objects with overlap less than ' - 'Minimum overlap between objects
are considered ' - 'new objects.

' - 'Set this value to 0 if you want to force tracking of ALL the ' - 'objects
in the previous frame (e.g., if cells move a lot ' - 'between frames)' - ) - msg.information(self, 'Cell-ACDC tracker info', txt) + "Cell-ACDC tracker computes the percentage of overlap between " + "all the objects
at frame n and all the " + "objects in previous frame n-1.

" + "All objects with overlap less than " + "Minimum overlap between objects
are considered " + "new objects.

" + "Set this value to 0 if you want to force tracking of ALL the " + "objects
in the previous frame (e.g., if cells move a lot " + "between frames)" + ) + msg.information(self, "Cell-ACDC tracker info", txt) def ok_cb(self, checked=False): self.cancel = False - self.params = {'IoA_thresh': self.maxOverlapSpinbox.value()} + self.params = {"IoA_thresh": self.maxOverlapSpinbox.value()} self.close() def cancel_cb(self, event): @@ -3953,20 +4042,18 @@ def exec_(self): def show(self, block=False): super().show() - self.resize(int(self.width()*1.3), self.height()) + self.resize(int(self.width() * 1.3), self.height()) if block: self.loop = QEventLoop() self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class BayesianTrackerParamsWin(QDialog): - def __init__( - self, segmShape, parent=None, channels=None, - currentChannelName=None - ): + def __init__(self, segmShape, parent=None, channels=None, currentChannelName=None): self.cancel = True super().__init__(parent) @@ -3974,7 +4061,7 @@ def __init__( self.currentChannelName = currentChannelName self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - self.setWindowTitle('Bayesian tracker parameters') + self.setWindowTitle("Bayesian tracker parameters") paramsLayout = QGridLayout() paramsBox = QGroupBox() @@ -3982,31 +4069,30 @@ def __init__( row = 0 this_path = os.path.dirname(os.path.abspath(__file__)) default_model_path = os.path.join( - this_path, 'trackers', 'BayesianTracker', - 'model', 'cell_config.json' + this_path, "trackers", "BayesianTracker", "model", "cell_config.json" ) - label = QLabel(html_utils.paragraph('Model path')) + label = QLabel(html_utils.paragraph("Model path")) paramsLayout.addWidget(label, row, 0) modelPathLineEdit = QLineEdit() - start_dir = '' + start_dir = "" if os.path.exists(default_model_path): start_dir = os.path.dirname(default_model_path) modelPathLineEdit.setText(default_model_path) self.modelPathLineEdit = modelPathLineEdit paramsLayout.addWidget(modelPathLineEdit, row, 1) browseButton = widgets.browseFileButton( - title='Select Bayesian Tracker model file', - ext={'JSON Config': ('.json',)}, - start_dir=start_dir + title="Select Bayesian Tracker model file", + ext={"JSON Config": (".json",)}, + start_dir=start_dir, ) browseButton.sigPathSelected.connect(self.onPathSelected) paramsLayout.addWidget(browseButton, row, 2, alignment=Qt.AlignLeft) if self.channels is not None: row += 1 - label = QLabel(html_utils.paragraph('Intensity image channel: ')) + label = QLabel(html_utils.paragraph("Intensity image channel: ")) paramsLayout.addWidget(label, row, 0) - items = ['None', *self.channels] + items = ["None", *self.channels] self.channelCombobox = widgets.QCenteredComboBox() self.channelCombobox.addItems(items) paramsLayout.addWidget(self.channelCombobox, row, 1) @@ -4014,15 +4100,15 @@ def __init__( self.channelCombobox.setCurrentText(self.currentChannelName) row += 1 - label = QLabel(html_utils.paragraph('Features')) + label = QLabel(html_utils.paragraph("Features")) paramsLayout.addWidget(label, row, 0) - selectFeaturesButton = widgets.setPushButton('Select features') + selectFeaturesButton = widgets.setPushButton("Select features") paramsLayout.addWidget(selectFeaturesButton, row, 1) self.features = [] selectFeaturesButton.clicked.connect(self.selectFeatures) row += 1 - label = QLabel(html_utils.paragraph('Verbose')) + label = QLabel(html_utils.paragraph("Verbose")) paramsLayout.addWidget(label, row, 0) verboseToggle = widgets.Toggle() verboseToggle.setChecked(True) @@ -4030,7 +4116,7 @@ def __init__( paramsLayout.addWidget(verboseToggle, row, 1, alignment=Qt.AlignCenter) row += 1 - label = QLabel(html_utils.paragraph('Run optimizer')) + label = QLabel(html_utils.paragraph("Run optimizer")) paramsLayout.addWidget(label, row, 0) optimizeToggle = widgets.Toggle() optimizeToggle.setChecked(True) @@ -4038,7 +4124,7 @@ def __init__( paramsLayout.addWidget(optimizeToggle, row, 1, alignment=Qt.AlignCenter) row += 1 - label = QLabel(html_utils.paragraph('Max search radius')) + label = QLabel(html_utils.paragraph("Max search radius")) paramsLayout.addWidget(label, row, 0) maxSearchRadiusSpinbox = QSpinBox() maxSearchRadiusSpinbox.setAlignment(Qt.AlignCenter) @@ -4051,19 +4137,19 @@ def __init__( row += 1 Z, Y, X = segmShape - label = QLabel(html_utils.paragraph('Tracking volume')) + label = QLabel(html_utils.paragraph("Tracking volume")) paramsLayout.addWidget(label, row, 0) volumeLineEdit = QLineEdit() - defaultVol = f' (0, {X}), (0, {Y}) ' + defaultVol = f" (0, {X}), (0, {Y}) " if Z > 1: - defaultVol = f'{defaultVol}, (0, {Z}) ' + defaultVol = f"{defaultVol}, (0, {Z}) " volumeLineEdit.setText(defaultVol) volumeLineEdit.setAlignment(Qt.AlignCenter) self.volumeLineEdit = volumeLineEdit paramsLayout.addWidget(volumeLineEdit, row, 1) row += 1 - label = QLabel(html_utils.paragraph('Interactive mode step size')) + label = QLabel(html_utils.paragraph("Interactive mode step size")) paramsLayout.addWidget(label, row, 0) stepSizeSpinbox = QSpinBox() stepSizeSpinbox.setAlignment(Qt.AlignCenter) @@ -4074,16 +4160,16 @@ def __init__( paramsLayout.addWidget(stepSizeSpinbox, row, 1) row += 1 - label = QLabel(html_utils.paragraph('Update method')) + label = QLabel(html_utils.paragraph("Update method")) paramsLayout.addWidget(label, row, 0) updateMethodCombobox = QComboBox() - updateMethodCombobox.addItems(['EXACT', 'APPROXIMATE']) + updateMethodCombobox.addItems(["EXACT", "APPROXIMATE"]) self.updateMethodCombobox = updateMethodCombobox self.updateMethodCombobox.currentTextChanged.connect(self.methodChanged) paramsLayout.addWidget(updateMethodCombobox, row, 1) - cancelButton = widgets.cancelPushButton('Cancel') - okButton = widgets.okPushButton(' Ok ') + cancelButton = widgets.cancelPushButton("Cancel") + okButton = widgets.okPushButton(" Ok ") cancelButton.clicked.connect(self.cancel_cb) okButton.clicked.connect(self.ok_cb) @@ -4094,16 +4180,16 @@ def __init__( buttonsLayout.addWidget(okButton) layout = QVBoxLayout() - infoText = html_utils.paragraph('Bayesian Tracker parameters') + infoText = html_utils.paragraph("Bayesian Tracker parameters") infoLabel = QLabel(infoText) layout.addWidget(infoLabel, alignment=Qt.AlignCenter) layout.addSpacing(10) paramsBox.setLayout(paramsLayout) layout.addWidget(paramsBox) - url = 'https://btrack.readthedocs.io/en/latest/index.html' + url = "https://btrack.readthedocs.io/en/latest/index.html" moreInfoText = html_utils.paragraph( - 'Find more info on the Bayesian Tracker\'s ' + "Find more info on the Bayesian Tracker's " f'home page' ) moreInfoLabel = QLabel(moreInfoText) @@ -4115,14 +4201,16 @@ def __init__( layout.addStretch(1) self.setLayout(layout) self.setFont(font) - + def selectFeatures(self): features = measurements.get_btrack_features() selectWin = widgets.QDialogListbox( - 'Select features', - 'Select features to use for tracking:\n', - features, multiSelection=True, parent=self, - includeSelectionHelp=True + "Select features", + "Select features to use for tracking:\n", + features, + multiSelection=True, + parent=self, + includeSelectionHelp=True, ) for i in range(selectWin.listBox.count()): item = selectWin.listBox.item(i) @@ -4134,7 +4222,7 @@ def selectFeatures(self): self.features = selectWin.selectedItemsText def methodChanged(self, method): - if method == 'APPROXIMATE': + if method == "APPROXIMATE": self.maxSearchRadiusSpinbox.setDisabled(False) else: self.maxSearchRadiusSpinbox.setDisabled(True) @@ -4145,7 +4233,7 @@ def onPathSelected(self, path): def ok_cb(self, checked=False): self.cancel = False try: - m = re.findall(r'\((\d+), *(\d+)\)', self.volumeLineEdit.text()) + m = re.findall(r"\((\d+), *(\d+)\)", self.volumeLineEdit.text()) if len(m) < 2: raise self.volume = tuple([(int(start), int(end)) for start, end in m]) @@ -4165,45 +4253,41 @@ def ok_cb(self, checked=False): self.update_method = self.updateMethodCombobox.currentText() self.model_path = os.path.normpath(self.modelPathLineEdit.text()) self.params = { - 'model_path': self.model_path, - 'verbose': self.verbose, - 'volume': self.volume, - 'max_search_radius': self.max_search_radius, - 'update_method': self.update_method, - 'step_size': self.stepSizeSpinbox.value(), - 'optimize': self.optimizeToggle.isChecked(), - 'features': self.features + "model_path": self.model_path, + "verbose": self.verbose, + "volume": self.volume, + "max_search_radius": self.max_search_radius, + "update_method": self.update_method, + "step_size": self.stepSizeSpinbox.value(), + "optimize": self.optimizeToggle.isChecked(), + "features": self.features, } if self.channels is not None: - if self.channelCombobox.currentText() != 'None': + if self.channelCombobox.currentText() != "None": self.intensityImageChannel = self.channelCombobox.currentText() self.close() def warnNotVaidPath(self): - url = 'https://github.com/lowe-lab-ucl/segment-classify-track/tree/main/models' + url = "https://github.com/lowe-lab-ucl/segment-classify-track/tree/main/models" msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - 'The model configuration file path

' - f'{self.modelPathLineEdit.text()}

' - 'does not exist.

' - 'You can find some pre-configured models ' + "The model configuration file path

" + f"{self.modelPathLineEdit.text()}

" + "does not exist.

" + "You can find some pre-configured models " f'here.' ) - msg.critical( - self, 'Invalid volume', txt - ) + msg.critical(self, "Invalid volume", txt) def warnNotAcceptedVolume(self): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - f'{self.volumeLineEdit.text()} is not a valid volume!

' - 'Valid volume is for example (0, 2048), (0, 2048)
' - 'for 2D segmentation or (0, 2048), (0, 2048), (0, 2048)
' - 'for 3D segmentation.' - ) - msg.critical( - self, 'Invalid volume', txt + f"{self.volumeLineEdit.text()} is not a valid volume!

" + "Valid volume is for example (0, 2048), (0, 2048)
" + "for 2D segmentation or (0, 2048), (0, 2048), (0, 2048)
" + "for 3D segmentation." ) + msg.critical(self, "Invalid volume", txt) def cancel_cb(self, event): self.cancel = True @@ -4214,23 +4298,23 @@ def exec_(self): def show(self, block=False): super().show() - self.resize(int(self.width()*1.3), self.height()) + self.resize(int(self.width() * 1.3), self.height()) if block: self.loop = QEventLoop() self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() -class DeltaTrackerParamsWin(QDialog): +class DeltaTrackerParamsWin(QDialog): def __init__(self, posData=None, parent=None): self.cancel = True super().__init__(parent) self.setWindowFlags(Qt.Dialog | Qt.WindowStaysOnTopHint) - self.setWindowTitle('Delta tracker parameters') + self.setWindowTitle("Delta tracker parameters") paramsLayout = QGridLayout() paramsBox = QGroupBox() @@ -4239,19 +4323,17 @@ def __init__(self, posData=None, parent=None): this_path = os.path.dirname(os.path.abspath(__file__)) default_model_path = this_path - label = QLabel(html_utils.paragraph('Original Images path')) + label = QLabel(html_utils.paragraph("Original Images path")) paramsLayout.addWidget(label, row, 0) modelPathLineEdit = QLineEdit() - start_dir = '' + start_dir = "" if os.path.exists(default_model_path): start_dir = os.path.dirname(default_model_path) modelPathLineEdit.setText(default_model_path) self.modelPathLineEdit = modelPathLineEdit paramsLayout.addWidget(modelPathLineEdit, row, 1) browseButton = widgets.browseFileButton( - title='Select Original Images', - ext={'TIFF': ('.tif',)}, - start_dir=start_dir + title="Select Original Images", ext={"TIFF": (".tif",)}, start_dir=start_dir ) if posData is not None: modelPathLineEdit.setText(posData.imgPath) @@ -4259,17 +4341,17 @@ def __init__(self, posData=None, parent=None): paramsLayout.addWidget(browseButton, row, 2, alignment=Qt.AlignLeft) row += 1 - label = QLabel(html_utils.paragraph('Model Type')) + label = QLabel(html_utils.paragraph("Model Type")) paramsLayout.addWidget(label, row, 0) updateMethodCombobox = QComboBox() - updateMethodCombobox.addItems(['2D', 'mothermachine']) - self.model_type = '2D' + updateMethodCombobox.addItems(["2D", "mothermachine"]) + self.model_type = "2D" self.updateMethodCombobox = updateMethodCombobox self.updateMethodCombobox.currentTextChanged.connect(self.methodChanged) paramsLayout.addWidget(updateMethodCombobox, row, 1) row += 1 - label = QLabel(html_utils.paragraph('Single Mother Machine Chamber?')) + label = QLabel(html_utils.paragraph("Single Mother Machine Chamber?")) paramsLayout.addWidget(label, row, 0) chamberToggle = widgets.Toggle() chamberToggle.setChecked(True) @@ -4277,7 +4359,7 @@ def __init__(self, posData=None, parent=None): paramsLayout.addWidget(chamberToggle, row, 1, alignment=Qt.AlignCenter) row += 1 - label = QLabel(html_utils.paragraph('Verbose')) + label = QLabel(html_utils.paragraph("Verbose")) paramsLayout.addWidget(label, row, 0) verboseToggle = widgets.Toggle() verboseToggle.setChecked(True) @@ -4285,7 +4367,7 @@ def __init__(self, posData=None, parent=None): paramsLayout.addWidget(verboseToggle, row, 1, alignment=Qt.AlignCenter) row += 1 - label = QLabel(html_utils.paragraph('Legacy Save (.mat)')) + label = QLabel(html_utils.paragraph("Legacy Save (.mat)")) paramsLayout.addWidget(label, row, 0) legacyToggle = widgets.Toggle() legacyToggle.setChecked(False) @@ -4293,7 +4375,7 @@ def __init__(self, posData=None, parent=None): paramsLayout.addWidget(legacyToggle, row, 1, alignment=Qt.AlignCenter) row += 1 - label = QLabel(html_utils.paragraph('Pickle (.pkl)')) + label = QLabel(html_utils.paragraph("Pickle (.pkl)")) paramsLayout.addWidget(label, row, 0) pickleToggle = widgets.Toggle() pickleToggle.setChecked(False) @@ -4301,15 +4383,15 @@ def __init__(self, posData=None, parent=None): paramsLayout.addWidget(pickleToggle, row, 1, alignment=Qt.AlignCenter) row += 1 - label = QLabel(html_utils.paragraph('Movie (.mp4) *only for 2D images')) + label = QLabel(html_utils.paragraph("Movie (.mp4) *only for 2D images")) paramsLayout.addWidget(label, row, 0) movieToggle = widgets.Toggle() movieToggle.setChecked(False) self.movieToggle = movieToggle paramsLayout.addWidget(movieToggle, row, 1, alignment=Qt.AlignCenter) - cancelButton = widgets.cancelPushButton('Cancel') - okButton = widgets.okPushButton(' Ok ') + cancelButton = widgets.cancelPushButton("Cancel") + okButton = widgets.okPushButton(" Ok ") cancelButton.clicked.connect(self.cancel_cb) okButton.clicked.connect(self.ok_cb) @@ -4320,17 +4402,16 @@ def __init__(self, posData=None, parent=None): buttonsLayout.addWidget(okButton) layout = QVBoxLayout() - infoText = html_utils.paragraph('Delta Tracker parameters') + infoText = html_utils.paragraph("Delta Tracker parameters") infoLabel = QLabel(infoText) layout.addWidget(infoLabel, alignment=Qt.AlignCenter) layout.addSpacing(10) paramsBox.setLayout(paramsLayout) layout.addWidget(paramsBox) - url = 'https://delta.readthedocs.io/en/latest/' + url = "https://delta.readthedocs.io/en/latest/" moreInfoText = html_utils.paragraph( - 'Find more info on Delta Tracker\'s ' - f'home page' + f'Find more info on Delta Tracker\'s home page' ) moreInfoLabel = QLabel(moreInfoText) moreInfoLabel.setOpenExternalLinks(True) @@ -4343,8 +4424,8 @@ def __init__(self, posData=None, parent=None): self.setFont(font) def methodChanged(self, method): - if method == 'mothermachine': - self.model_type = 'mothermachine' + if method == "mothermachine": + self.model_type = "mothermachine" def onPathSelected(self, path): self.modelPathLineEdit.setText(path) @@ -4363,13 +4444,13 @@ def ok_cb(self, checked=False): self.chamber = self.chamberToggle.isChecked() self.model_path = os.path.normpath(self.modelPathLineEdit.text()) self.params = { - 'original_images_path': self.model_path, - 'verbose': self.verbose, - 'legacy': self.legacy, - 'pickle': self.pickle, - 'movie': self.movie, - 'model_type': self.model_type, - 'single mothermachine chamber': self.chamber + "original_images_path": self.model_path, + "verbose": self.verbose, + "legacy": self.legacy, + "pickle": self.pickle, + "movie": self.movie, + "model_type": self.model_type, + "single mothermachine chamber": self.chamber, } self.close() @@ -4382,32 +4463,36 @@ def exec_(self): def show(self, block=False): super().show() - self.resize(int(self.width()*1.3), self.height()) + self.resize(int(self.width() * 1.3), self.height()) if block: self.loop = QEventLoop() self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class QDialogWorkerProgress(QDialog): sigClosed = Signal(bool) def __init__( - self, title='Progress', infoTxt='', - showInnerPbar=False, pbarDesc='', - parent=None - ): + self, + title="Progress", + infoTxt="", + showInnerPbar=False, + pbarDesc="", + parent=None, + ): self.workerFinished = False self.aborted = False self.clickCount = 0 super().__init__(parent) - abort_text = 'Option+Command+C' if is_mac else 'Ctrl+Alt+C' + abort_text = "Option+Command+C" if is_mac else "Ctrl+Alt+C" self.abort_text = abort_text - self.setWindowTitle(f'{title} ({abort_text} to abort)') + self.setWindowTitle(f"{title} ({abort_text} to abort)") self.setWindowFlags(Qt.Window) mainLayout = QVBoxLayout() @@ -4461,8 +4546,7 @@ def askAbort(self): Are you sure you want to abort? """) yesButton, noButton = msg.critical( - self, 'Are you sure you want to abort?', txt, - buttonsTexts=('Yes', 'No') + self, "Are you sure you want to abort?", txt, buttonsTexts=("Yes", "No") ) return msg.clickedButton == yesButton @@ -4472,7 +4556,7 @@ def closeEvent(self, event): return self.sigClosed.emit(self.aborted) - + def log(self, text): self.logConsole.append(text) @@ -4485,26 +4569,33 @@ def show(self, app): parentGeometry = self.parent().geometry() mainWinLeft, mainWinWidth = parentGeometry.left(), parentGeometry.width() mainWinTop, mainWinHeight = parentGeometry.top(), parentGeometry.height() - mainWinCenterX = int(mainWinLeft+mainWinWidth/2) - mainWinCenterY = int(mainWinTop+mainWinHeight/2) + mainWinCenterX = int(mainWinLeft + mainWinWidth / 2) + mainWinCenterY = int(mainWinTop + mainWinHeight / 2) - width = int(screenWidth/3) + width = int(screenWidth / 3) width = width if self.width() < width else self.width() - height = int(screenHeight/3) - left = int(mainWinCenterX - width/2) + height = int(screenHeight / 3) + left = int(mainWinCenterX - width / 2) left = left if left >= 0 else 0 - top = int(mainWinCenterY - height/2) + top = int(mainWinCenterY - height / 2) self.setGeometry(left, top, width, height) + class QDialogCombobox(QDialog): def __init__( - self, title, ComboBoxItems, informativeText, - CbLabel='Select value: ', parent=None, - defaultChannelName=None, iconPixmap=None, centeredCombobox=False - ): + self, + title, + ComboBoxItems, + informativeText, + CbLabel="Select value: ", + parent=None, + defaultChannelName=None, + iconPixmap=None, + centeredCombobox=False, + ): self.cancel = True - self.selectedItemText = '' + self.selectedItemText = "" self.selectedItemIdx = None super().__init__(parent=parent) self.setWindowTitle(title) @@ -4542,9 +4633,9 @@ def __init__( topLayout.addWidget(combobox) topLayout.setContentsMargins(0, 10, 0, 0) - okButton = widgets.okPushButton('Ok') + okButton = widgets.okPushButton("Ok") - cancelButton = widgets.cancelPushButton('Cancel') + cancelButton = widgets.cancelPushButton("Cancel") bottomLayout.addStretch(1) bottomLayout.addWidget(cancelButton) @@ -4584,14 +4675,15 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class MultiTimePointFilePattern(QBaseDialog): def __init__(self, fileName, folderPath, readPatternFunc=None, parent=None): super().__init__(parent) - self.setWindowTitle('File name pattern') + self.setWindowTitle("File name pattern") self.cancel = True self.additionalChannelWidgets = {} @@ -4613,7 +4705,7 @@ def __init__(self, fileName, folderPath, readPatternFunc=None, parent=None): These files MUST be named exactly as the raw files.
""") - + noteLayout = QHBoxLayout() noteText = html_utils.paragraph(""" Channels do not need to have the same number of frames, @@ -4623,23 +4715,23 @@ def __init__(self, fileName, folderPath, readPatternFunc=None, parent=None): of the filename) and it will fill missing frames with zeros. """) noteLayout.addWidget( - QLabel(html_utils.to_admonition(noteText)), + QLabel(html_utils.to_admonition(noteText)), # alignment=(Qt.AlignTop | Qt.AlignRight) ) mainLayout.addWidget(QLabel(infoText)) mainLayout.addLayout(noteLayout) - noteLayout.setStretch(0,0) - noteLayout.setStretch(1,1) + noteLayout.setStretch(0, 0) + noteLayout.setStretch(1, 1) - label = QLabel(html_utils.paragraph( - f'Sample file name: {fileName}' - )) + label = QLabel( + html_utils.paragraph(f"Sample file name: {fileName}") + ) mainLayout.addWidget(label, alignment=Qt.AlignCenter) mainLayout.addSpacing(5) - channelName = '' - posName = '' + channelName = "" + posName = "" frameNumber = None if readPatternFunc is not None: posName, frameNumber, channelName = readPatternFunc(fileName) @@ -4652,28 +4744,27 @@ def __init__(self, fileName, folderPath, readPatternFunc=None, parent=None): formLayout.addLayout(l, 0, j) row = 0 - items = QLabel('Position name: '), widgets.ReadOnlyLineEdit(), QLabel() + items = QLabel("Position name: "), widgets.ReadOnlyLineEdit(), QLabel() label, self.posNameEntry, button = items self.posNameEntry.setAlignment(Qt.AlignCenter) self.posNameEntry.setText(str(posName)) for j, w in enumerate(items): self.vLayouts[j].addWidget(w) - + row += 1 - items = ( - QLabel('Frame number name: '), widgets.ReadOnlyLineEdit(), QLabel() - ) + items = (QLabel("Frame number name: "), widgets.ReadOnlyLineEdit(), QLabel()) self.frameNumberEntry = items[1] self.frameNumberEntry.setText(str(frameNumber)) self.frameNumberEntry.setAlignment(Qt.AlignCenter) for j, w in enumerate(items): self.vLayouts[j].addWidget(w) - + row += 1 self.channelNameLE = widgets.alphaNumericLineEdit() items = ( - QLabel('Channel_1 name: '), self.channelNameLE, - widgets.addPushButton(' Add channel') + QLabel("Channel_1 name: "), + self.channelNameLE, + widgets.addPushButton(" Add channel"), ) self.addChannelButton = items[2] self.addChannelButton._row = row @@ -4684,30 +4775,32 @@ def __init__(self, fileName, folderPath, readPatternFunc=None, parent=None): row += 1 items = ( - QLabel('Basename (optional): '), widgets.alphaNumericLineEdit(), - QLabel() + QLabel("Basename (optional): "), + widgets.alphaNumericLineEdit(), + QLabel(), ) label, self.baseNameLE, button = items self.baseNameLE.setAlignment(Qt.AlignCenter) for j, w in enumerate(items): self.vLayouts[j].addWidget(w) - + row += 1 - items = QLabel('File will be saved as: '), QLineEdit(), QLabel() + items = QLabel("File will be saved as: "), QLineEdit(), QLabel() label, self.relPathEntry, button = items self.relPathEntry.setAlignment(Qt.AlignCenter) for j, w in enumerate(items): self.vLayouts[j].addWidget(w) - + row += 1 items = ( - QLabel('Segmentation masks folder path: '), - widgets.ElidingLineEdit(), + QLabel("Segmentation masks folder path: "), + widgets.ElidingLineEdit(), widgets.browseFileButton( - 'Browse...', - title='Select folder containing segmentation masks', - start_dir=folderPath, openFolder=True - ) + "Browse...", + title="Select folder containing segmentation masks", + start_dir=folderPath, + openFolder=True, + ), ) label, self.segmFolderPathEntry, button = items button.sigPathSelected.connect(self.segmFolderpathSelected) @@ -4722,7 +4815,7 @@ def __init__(self, fileName, folderPath, readPatternFunc=None, parent=None): self.channelNameLE.textChanged.connect(self.updateRelativePath) self.baseNameLE.textChanged.connect(self.updateRelativePath) self.addChannelButton.clicked.connect(self.addChannel) - + mainLayout.addLayout(formLayout) buttonsLayout = widgets.CancelOkButtonsLayout() @@ -4742,19 +4835,19 @@ def __init__(self, fileName, folderPath, readPatternFunc=None, parent=None): self.setLayout(mainLayout) self.setFont(font) - + def segmFolderpathSelected(self, path): self.segmFolderPathEntry.setText(path) - + def addChannel(self): - self.addChannelButton._row += 1 + self.addChannelButton._row += 1 row = self.addChannelButton._row channel_idx = len(self.additionalChannelWidgets) items = ( - QLabel(f'Channel_{channel_idx+1} name: '), - widgets.alphaNumericLineEdit(), - widgets.subtractPushButton('Remove channel') + QLabel(f"Channel_{channel_idx + 1} name: "), + widgets.alphaNumericLineEdit(), + widgets.subtractPushButton("Remove channel"), ) label, lineEdit, button = items lineEdit.setAlignment(Qt.AlignCenter) @@ -4770,10 +4863,10 @@ def removeChannel(self): row = self.sender()._row for j, w in enumerate(self.additionalChannelWidgets[row]): self.vLayouts[j].removeWidget(w) - + self.additionalChannelWidgets.pop(row) - self.addChannelButton._row -= 1 - + self.addChannelButton._row -= 1 + def checkChannelNames(self): allChannels = [self.channelNameLE.text()] allChannels.extend( @@ -4787,24 +4880,24 @@ def checkChannelNames(self): else: # Channel names are fine return allChannels - + msg = widgets.myMessageBox(wrapText=False, showCentered=False) txt = html_utils.paragraph(""" Some channel names are empty or not different from each other. """) - msg.critical(self, 'Select two or more items', txt) + msg.critical(self, "Select two or more items", txt) return None - - def updateRelativePath(self, text=''): + + def updateRelativePath(self, text=""): posName = self.posNameEntry.text() frameNumber = self.frameNumberEntry.text() channelName = self.channelNameLE.text() basename = self.baseNameLE.text() if basename: - filename = f'{basename}_{posName}_{channelName}.tif' + filename = f"{basename}_{posName}_{channelName}.tif" else: - filename = f'{posName}_{channelName}.tif' - relPath = f'...{os.sep}Position_1{os.sep}Images{os.sep}{filename}' + filename = f"{posName}_{channelName}.tif" + relPath = f"...{os.sep}Position_1{os.sep}Images{os.sep}{filename}" self.relPathEntry.setText(relPath) def ok_cb(self): @@ -4816,15 +4909,15 @@ def ok_cb(self): self.segmFolderPath = self.segmFolderPathEntry.text() self.cancel = False self.close() - + def showEvent(self, event) -> None: self.channelNameLE.setFocus() + class OrderableListWidgetDialog(QBaseDialog): def __init__( - self, items, title='Select items', infoTxt='', helpText='', - parent=None - ): + self, items, title="Select items", infoTxt="", helpText="", parent=None + ): super().__init__(parent) self.selectedItemsText = [] @@ -4843,7 +4936,7 @@ def __init__( buttonsLayout = widgets.CancelOkButtonsLayout() if helpText: - helpButton = widgets.helpPushButton('Help...') + helpButton = widgets.helpPushButton("Help...") buttonsLayout.insertWidget(3, helpButton) helpButton.clicked.connect(self.showHelp) @@ -4855,15 +4948,15 @@ def __init__( mainLayout.addLayout(buttonsLayout) self.setLayout(mainLayout) - + def showHelp(self): msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph(self.helpText) - msg.information(self, 'Select tables help', txt) - + msg.information(self, "Select tables help", txt) + def ok_cb(self): self.cancel = False - self.selectedItemsText = [None]*len(self.listWidget.selectedItems()) + self.selectedItemsText = [None] * len(self.listWidget.selectedItems()) for itemW in self.listWidget.selectedItems(): idx = int(itemW._nrWidget.currentText()) - 1 if idx >= len(self.selectedItemsText): @@ -4878,7 +4971,7 @@ def __init__(self, parent=None, isSegm3D=True): self.cancel = True - self.setWindowTitle('Automatic thresholding parameters') + self.setWindowTitle("Automatic thresholding parameters") layout = QVBoxLayout() formLayout = QGridLayout() @@ -4890,19 +4983,20 @@ def __init__(self, parent=None, isSegm3D=True): self.sigmaGaussSpinbox.setMaximum(2**31) self.sigmaGaussSpinbox.setAlignment(Qt.AlignCenter) formLayout.addWidget( - QLabel('Gaussian filter sigma (0 to ignore): '), row, 0, - alignment=Qt.AlignRight + QLabel("Gaussian filter sigma (0 to ignore): "), + row, + 0, + alignment=Qt.AlignRight, ) formLayout.addWidget(self.sigmaGaussSpinbox, row, 1, 1, 2) row += 1 self.threshMethodCombobox = QComboBox() - self.threshMethodCombobox.addItems([ - 'Isodata', 'Li', 'Mean', 'Minimum', 'Otsu', 'Triangle', 'Yen' - ]) + self.threshMethodCombobox.addItems( + ["Isodata", "Li", "Mean", "Minimum", "Otsu", "Triangle", "Yen"] + ) formLayout.addWidget( - QLabel('Thresholding algorithm: '), row, 0, - alignment=Qt.AlignRight + QLabel("Thresholding algorithm: "), row, 0, alignment=Qt.AlignRight ) formLayout.addWidget(self.threshMethodCombobox, row, 1, 1, 2) @@ -4910,21 +5004,21 @@ def __init__(self, parent=None, isSegm3D=True): if isSegm3D: row += 1 formLayout.addWidget( - QLabel('Segment 3D volume: '), row, 0, alignment=Qt.AlignRight + QLabel("Segment 3D volume: "), row, 0, alignment=Qt.AlignRight ) group = QButtonGroup() group.setExclusive(True) - self.segment3Dcheckbox = QRadioButton('Yes') - segmentSliceBySliceCheckbox = QRadioButton('No, segment slice-by-slice') + self.segment3Dcheckbox = QRadioButton("Yes") + segmentSliceBySliceCheckbox = QRadioButton("No, segment slice-by-slice") group.addButton(self.segment3Dcheckbox) group.addButton(segmentSliceBySliceCheckbox) formLayout.addWidget(self.segment3Dcheckbox, row, 1) formLayout.addWidget(segmentSliceBySliceCheckbox, row, 2) self.segment3Dcheckbox.setChecked(True) - okButton = widgets.okPushButton('Ok') - cancelButton = widgets.cancelPushButton('Cancel') - helpButton = widgets.helpPushButton('Help...') + okButton = widgets.okPushButton("Ok") + cancelButton = widgets.cancelPushButton("Cancel") + helpButton = widgets.helpPushButton("Help...") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -4945,211 +5039,200 @@ def __init__(self, parent=None, isSegm3D=True): self.configPars = self.loadLastSelection() - def help_cb(self): import webbrowser - url = 'https://scikit-image.org/docs/stable/auto_examples/applications/plot_thresholding.html' + + url = "https://scikit-image.org/docs/stable/auto_examples/applications/plot_thresholding.html" webbrowser.open(url) def ok_cb(self): self.cancel = False self.gaussSigma = self.sigmaGaussSpinbox.value() threshMethod = self.threshMethodCombobox.currentText().lower() - self.threshMethod = f'threshold_{threshMethod}' + self.threshMethod = f"threshold_{threshMethod}" self.segment_kwargs = { - 'gauss_sigma': self.gaussSigma, - 'threshold_method': self.threshMethod, - 'segment_3D_volume': False + "gauss_sigma": self.gaussSigma, + "threshold_method": self.threshMethod, + "segment_3D_volume": False, } self.reduceMemoryUsage = False if self.segment3Dcheckbox is not None: doSegm3D = self.segment3Dcheckbox.isChecked() - self.segment_kwargs['segment_3D_volume'] = doSegm3D + self.segment_kwargs["segment_3D_volume"] = doSegm3D self.close() - + def loadLastSelection(self): - self.ini_path = os.path.join( - settings_folderpath, 'last_params_segm_models.ini' - ) + self.ini_path = os.path.join(settings_folderpath, "last_params_segm_models.ini") if not os.path.exists(self.ini_path): return configPars = config.ConfigParser() configPars.read(self.ini_path) - if 'thresholding.segment' not in configPars.sections(): + if "thresholding.segment" not in configPars.sections(): return - section = configPars['thresholding.segment'] - self.sigmaGaussSpinbox.setValue(float(section['gauss_sigma'])) + section = configPars["thresholding.segment"] + self.sigmaGaussSpinbox.setValue(float(section["gauss_sigma"])) - threshold_method = section['threshold_method'] + threshold_method = section["threshold_method"] Method = threshold_method[10:].capitalize() self.threshMethodCombobox.setCurrentText(Method) if self.segment3Dcheckbox is None: return - self.segment3Dcheckbox.setChecked(section.getboolean('segment_3D_volume')) + self.segment3Dcheckbox.setChecked(section.getboolean("segment_3D_volume")) + class GenerateMotherBudTotalTableSelectColumnsDialog(QBaseDialog): def __init__(self, df: pd.DataFrame, parent=None): super().__init__(parent) - self.setWindowTitle('Select columns to combine into the output table') - + self.setWindowTitle("Select columns to combine into the output table") + self.cancel = True - + self.columns = core.natsort_acdc_columns(df.columns) self.operations = ( - 'Sum mother and bud', - 'Copy column from mother', + "Sum mother and bud", + "Copy column from mother", ) - + self.mainLayout = QVBoxLayout() - + instructionsText = html_utils.paragraph(""" Select which columns and how you want to combine them into the output table.
""") self.mainLayout.addWidget(QLabel(instructionsText)) - - settingsLayout = QGridLayout() - + + settingsLayout = QGridLayout() + row = 0 - settingsLayout.addWidget(widgets.QHLine(), row, 0, 1, 2) - + settingsLayout.addWidget(widgets.QHLine(), row, 0, 1, 2) + row += 1 settingsLayout.addWidget( - QLabel('Copy all non-selected columns from mother cell'), row, 0 + QLabel("Copy all non-selected columns from mother cell"), row, 0 ) self.copyAllColsToggle = widgets.Toggle() - settingsLayout.addWidget( - self.copyAllColsToggle, row, 1, alignment=Qt.AlignLeft - ) - + settingsLayout.addWidget(self.copyAllColsToggle, row, 1, alignment=Qt.AlignLeft) + row += 1 - settingsLayout.addWidget(widgets.QHLine(), row, 0, 1, 2) - + settingsLayout.addWidget(widgets.QHLine(), row, 0, 1, 2) + self.mainLayout.addLayout(settingsLayout) - + scrollArea = widgets.ScrollArea() scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) scrollWidget = QWidget() scrollArea.setWidget(scrollWidget) self.centralLayout = QGridLayout() scrollWidget.setLayout(self.centralLayout) - - self.centralLayout.addWidget(QLabel('Grouping columns'), 0, 0) - self.centralLayout.addWidget(QLabel('Column'), 0, 1) - self.centralLayout.addWidget(QLabel('Operation'), 0, 2) + + self.centralLayout.addWidget(QLabel("Grouping columns"), 0, 0) + self.centralLayout.addWidget(QLabel("Column"), 0, 1) + self.centralLayout.addWidget(QLabel("Operation"), 0, 2) self.centralLayout.setRowStretch(0, 0) - + self.groupingColsListWidget = widgets.listWidget( - isMultipleSelection=True, + isMultipleSelection=True, ) self.groupingColsListWidget.addItems(self.columns) self.centralLayout.addWidget(self.groupingColsListWidget, 1, 0, 2, 1) - + selector = widgets.ComboBox(self) selector.addItems(self.columns) operationCombobox = widgets.ComboBox(self) operationCombobox.addItems(self.operations) self.addSelectorButton = widgets.addPushButton() - + dummyButton = widgets.delPushButton() dummyButton.setRetainSizeWhenHidden(True) dummyButton.hide() self.centralLayout.addWidget(dummyButton, 1, 4) - + self.centralLayout.addWidget(selector, 1, 1) self.centralLayout.addWidget(operationCombobox, 1, 2) self.centralLayout.addWidget(self.addSelectorButton, 1, 3) - + self.centralLayout.setRowStretch(1, 1) self.centralLayout.setRowStretch(2, 1) - + self.selectors = {1: (selector, operationCombobox)} - + buttonsLayout = widgets.CancelOkButtonsLayout() - saveSelectionButton = widgets.savePushButton( - 'Save current selection' - ) + saveSelectionButton = widgets.savePushButton("Save current selection") buttonsLayout.insertWidget(3, saveSelectionButton) - + loadDefaultColsButton = widgets.reloadPushButton( - 'Load default summable columns' + "Load default summable columns" ) buttonsLayout.insertWidget(4, loadDefaultColsButton) - - loadPreviousSelButton = widgets.OpenFilePushButton( - 'Load previous selection' - ) + + loadPreviousSelButton = widgets.OpenFilePushButton("Load previous selection") buttonsLayout.insertWidget(5, loadPreviousSelButton) - + saveSelectionButton.clicked.connect(self.saveSelection) loadDefaultColsButton.clicked.connect(self.loadDefaultCols) loadPreviousSelButton.clicked.connect(self.loadPreviousSelection) buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + self.mainLayout.addWidget(scrollArea) self.mainLayout.addSpacing(20) self.mainLayout.addLayout(buttonsLayout) - + self.addSelectorButton.clicked.connect(self.addSelector) - selector.currentTextChanged.connect( - self.selectorTextChanged - ) + selector.currentTextChanged.connect(self.selectorTextChanged) self.setLayout(self.mainLayout) self.setFont(font) - + def saveSelection(self): saved_selections = io.get_saved_moth_bud_tot_selections() existing_names = set(saved_selections.keys()) win = filenameDialog( - basename='', - ext='', - hintText='Insert a name for the current selection:', + basename="", + ext="", + hintText="Insert a name for the current selection:", existingNames=existing_names, allowEmpty=False, - defaultEntry='mother_bud_total_columns_selection' + defaultEntry="mother_bud_total_columns_selection", ) win.exec_() if win.cancel: return - + name = win.filename saved_selections[name] = self.selectedOptions() io.save_moth_bud_tot_selected_options(saved_selections) - + msg = widgets.myMessageBox(wrapText=False, showCentered=False) txt = html_utils.paragraph(f""" Current selection saved with name {name}. """) - msg.information(self, 'Selection saved', txt) + msg.information(self, "Selection saved", txt) def loadDefaultCols(self): from . import single_pos_index_cols - - grouping_cols = [ - col for col in single_pos_index_cols if col in self.columns - ] + + grouping_cols = [col for col in single_pos_index_cols if col in self.columns] self.groupingColsListWidget.setSelectedItems(grouping_cols) - + column_operation_mapper = { - col: 'Sum mother and bud' - for col in cca_functions.default_summable_columns + col: "Sum mother and bud" for col in cca_functions.default_summable_columns } column_operation_mapper = { - col: op for col, op in column_operation_mapper.items() + col: op + for col, op in column_operation_mapper.items() if col in self.columns and op in self.operations } self.addSelectors( - len(column_operation_mapper), + len(column_operation_mapper), callback_on_finished=partial( self.setSelectorValues, column_operation_mapper - ) + ), ) def loadPreviousSelection(self): @@ -5159,43 +5242,43 @@ def loadPreviousSelection(self): txt = html_utils.paragraph(""" There are no saved selections. """) - msg.warning(self, 'No saved selections', txt) + msg.warning(self, "No saved selections", txt) return - + existing_names = natsorted(saved_selections.keys(), key=str.casefold) - + selectNameWin = widgets.QDialogListbox( - 'Choose selection to load', - 'Choose selection to load:\n', - existing_names, - multiSelection=False, - parent=self + "Choose selection to load", + "Choose selection to load:\n", + existing_names, + multiSelection=False, + parent=self, ) selectNameWin.exec_() if selectNameWin.cancel: return - + self.loadOptions(saved_selections[selectNameWin.selectedItemsText[0]]) - + def resetSelectors(self, callback_on_finished=None): self.callback_on_finished = callback_on_finished QTimer.singleShot(1, self._removeLastSelector) - + def _removeLastSelector(self): if len(self.selectors) == 1: if self.callback_on_finished is not None: self.callback_on_finished() return - + lastRow = max(self.selectors.keys()) lastSelector, _ = self.selectors[lastRow] self.removeSelector(sender=lastSelector.delButton) QTimer.singleShot(1, self._removeLastSelector) - + def addSelectors(self, number, callback_on_finished=None): self.callback_on_finished = callback_on_finished QTimer.singleShot(1, partial(self._addSelectorRecursive, number)) - + def _addSelectorRecursive(self, number): if len(self.selectors) == number: if self.callback_on_finished is not None: @@ -5204,62 +5287,61 @@ def _addSelectorRecursive(self, number): self.addSelector() QTimer.singleShot(1, partial(self._addSelectorRecursive, number)) - + def loadOptions(self, options: dict): if len(self.selectors) > 1: - self.resetSelectors( - callback_on_finished=partial(self.loadOptions, options) - ) + self.resetSelectors(callback_on_finished=partial(self.loadOptions, options)) return - + self.copyAllColsToggle.setChecked( - options.get('do_copy_all_nonselected_columns', False) + options.get("do_copy_all_nonselected_columns", False) ) self.groupingColsListWidget.setSelectedItems( - options.get('grouping_columns', []) + options.get("grouping_columns", []) ) - column_operation_mapper = options.get('column_operation_mapper', {}) + column_operation_mapper = options.get("column_operation_mapper", {}) column_operation_mapper = { - col: op for col, op in column_operation_mapper.items() + col: op + for col, op in column_operation_mapper.items() if col in self.columns and op in self.operations } if len(column_operation_mapper) > 1: self.addSelectors( - len(column_operation_mapper), + len(column_operation_mapper), callback_on_finished=partial( self.setSelectorValues, column_operation_mapper - ) + ), ) return - + self.setSelectorValues(column_operation_mapper) - + def setSelectorValues(self, column_operation_mapper): for i, (col, op) in enumerate(column_operation_mapper.items()): - selector, operationCombobox = self.selectors[i+1] + selector, operationCombobox = self.selectors[i + 1] selector.setCurrentText(col) - operationCombobox.setCurrentText(op) - + operationCombobox.setCurrentText(op) + def resetSelectorsStyles(self): for selector, _ in self.selectors.values(): - selector.setStyleSheet('') - + selector.setStyleSheet("") + def selectorTextChanged(self, text): self.resetSelectorsStyles() selector = self.sender() for other_selector, _ in self.selectors.values(): if other_selector == selector: continue - + if selector.currentText() != other_selector.currentText(): continue - + self.setWarningStyleSelector(selector) self.setWarningStyleSelector(other_selector) - + def addSelector(self): row = len(self.selectors) + 1 - + selector = widgets.ComboBox(self) selector.addItems(self.columns) selector.setCurrentIndex(len(self.selectors)) @@ -5268,65 +5350,61 @@ def addSelector(self): delButton = widgets.delPushButton() selector.delButton = delButton delButton._row = row - + self.selectors[row] = (selector, operationCombobox) - + self.centralLayout.addWidget(selector, row, 1) self.centralLayout.addWidget(operationCombobox, row, 2) self.centralLayout.addWidget(delButton, row, 3) - + self.centralLayout.removeWidget(self.addSelectorButton) self.centralLayout.addWidget(self.addSelectorButton, row, 4) - + delButton.clicked.connect(self.removeSelector) - + self.centralLayout.removeWidget(self.groupingColsListWidget) rowSpan = self.centralLayout.rowCount() - self.centralLayout.addWidget( - self.groupingColsListWidget, 1, 0, rowSpan, 1 - ) + self.centralLayout.addWidget(self.groupingColsListWidget, 1, 0, rowSpan, 1) self.centralLayout.setRowStretch(rowSpan, 1) - - selector.currentTextChanged.connect( - self.selectorTextChanged - ) - + + selector.currentTextChanged.connect(self.selectorTextChanged) + def removeSelector(self, checked=False, sender=None): if sender is None: delButton = self.sender() else: delButton = sender - + selector, operationCombobox = self.selectors.pop(delButton._row) - + self.centralLayout.removeWidget(selector) self.centralLayout.removeWidget(operationCombobox) self.centralLayout.removeWidget(delButton) - + resorted_selectors = {} for i, (row, (sel, op)) in enumerate(self.selectors.items()): if i == 0: - resorted_selectors[i+1] = (sel, op) + resorted_selectors[i + 1] = (sel, op) continue - + delButton = sel.delButton - delButton._row = i+1 + delButton._row = i + 1 self.centralLayout.removeWidget(sel) self.centralLayout.removeWidget(op) self.centralLayout.removeWidget(delButton) - self.centralLayout.addWidget(sel, i+1, 1) - self.centralLayout.addWidget(op, i+1, 2) - self.centralLayout.addWidget(delButton, i+1, 3) - - resorted_selectors[i+1] = (sel, op) - - last_row = i+1 + self.centralLayout.addWidget(sel, i + 1, 1) + self.centralLayout.addWidget(op, i + 1, 2) + self.centralLayout.addWidget(delButton, i + 1, 3) + + resorted_selectors[i + 1] = (sel, op) + + last_row = i + 1 col = 4 if last_row > 1 else 3 self.centralLayout.removeWidget(self.addSelectorButton) - self.centralLayout.addWidget(self.addSelectorButton, i+1, col) - + self.centralLayout.addWidget(self.addSelectorButton, i + 1, col) + self.selectors = resorted_selectors - + def sizeHint(self): width = super().sizeHint().width() height = super().sizeHint().height() @@ -5335,27 +5413,27 @@ def sizeHint(self): ) width += groupingColsWidth return QSize(width, height) - + def checkDuplicatedSelectedColumns(self): for selector, _ in self.selectors.values(): - selector.setStyleSheet('background-color: none') + selector.setStyleSheet("background-color: none") for other_selector, _ in self.selectors.values(): if other_selector == selector: continue - + if other_selector.currentText() != selector.currentText(): continue - + self.warnDuplicatedSelectedColumns(selector, other_selector) return False - + return True - + def setWarningStyleSelector(self, selector): popup = selector.view() palette = popup.palette() text_color = palette.color(palette.ColorRole.Text) - warningStyleSheet = (f""" + warningStyleSheet = f""" QComboBox {{ color: black; background-color: orange; /* main area */ @@ -5363,13 +5441,13 @@ def setWarningStyleSelector(self, selector): QComboBox QAbstractItemView {{ background-color: {text_color.name()}; }} - """) + """ selector.setStyleSheet(warningStyleSheet) - + def warnDuplicatedSelectedColumns(self, selector1, selector2): self.setWarningStyleSelector(selector1) self.setWarningStyleSelector(selector2) - + msg = widgets.myMessageBox(wrapText=False, showCentered=False) txt = html_utils.paragraph(f""" The following column has been selected more than once @@ -5378,15 +5456,14 @@ def warnDuplicatedSelectedColumns(self, selector1, selector2): Please, select each column only once.

Thank you for your patience! """) - msg.warning(self, 'Duplicated selection', txt) - - + msg.warning(self, "Duplicated selection", txt) + def checkGroupingColumnsNotSelected(self): if self.groupingColsListWidget.selectedItems(): return True - + return self.warnGroupingColumnsNotSelected() - + def warnGroupingColumnsNotSelected(self): msg = widgets.myMessageBox(wrapText=False, showCentered=False) txt = html_utils.paragraph(f""" @@ -5395,52 +5472,55 @@ def warnGroupingColumnsNotSelected(self): Position folder. """) _, noButton, yesButton = msg.question( - self, 'No grouping columns selected?', txt, + self, + "No grouping columns selected?", + txt, buttonsTexts=( - 'Cancel', - 'No, let me select grouping columns', - 'Yes, I do not need grouping columns' - ) + "Cancel", + "No, let me select grouping columns", + "Yes, I do not need grouping columns", + ), ) return msg.clickedButton == yesButton - + def selectedOptions(self): selected_options = { - 'grouping_columns': self.groupingColsListWidget.selectedItemsText(), - 'column_operation_mapper': { + "grouping_columns": self.groupingColsListWidget.selectedItemsText(), + "column_operation_mapper": { selector.currentText(): operationCombobox.currentText() for selector, operationCombobox in self.selectors.values() }, - 'do_copy_all_nonselected_columns': self.copyAllColsToggle.isChecked() + "do_copy_all_nonselected_columns": self.copyAllColsToggle.isChecked(), } return selected_options - + def ok_cb(self): proceed = self.checkDuplicatedSelectedColumns() if not proceed: return - + proceed = self.checkGroupingColumnsNotSelected() if not proceed: return - + self.selected_options = self.selectedOptions() - + self.cancel = False self.close() + class ApplyTrackTableSelectColumnsDialog(QBaseDialog): def __init__(self, df, parent=None): super().__init__(parent) - self.setWindowTitle('Select columns containing tracking info') - + self.setWindowTitle("Select columns containing tracking info") + self.cancel = True self.mainLayout = QVBoxLayout() options = ( '"Frame index", "Tracked IDs" and "Segmentation mask IDs"
', - '"Frame index", "Tracked IDs", "X coord. centroid", and "Y coord. centroid"' + '"Frame index", "Tracked IDs", "X coord. centroid", and "Y coord. centroid"', ) self.instructionsText = html_utils.paragraph( f""" @@ -5457,45 +5537,43 @@ def __init__(self, df, parent=None): self.frameIndexCombobox = widgets.QCenteredComboBox() self.frameIndexCombobox.addItems(df.columns) - self.frameIndexCheckbox = QCheckBox('1st frame is index 1') + self.frameIndexCheckbox = QCheckBox("1st frame is index 1") frameIndexLayout = QHBoxLayout() frameIndexLayout.addWidget(self.frameIndexCombobox) frameIndexLayout.addWidget(self.frameIndexCheckbox) frameIndexLayout.setStretch(0, 2) frameIndexLayout.setStretch(1, 0) - formLayout.addRow( - 'Frame index: ', frameIndexLayout - ) + formLayout.addRow("Frame index: ", frameIndexLayout) self.trackedIDsCombobox = widgets.QCenteredComboBox() self.trackedIDsCombobox.addItems(df.columns) - formLayout.addRow('Tracked IDs: ', self.trackedIDsCombobox) + formLayout.addRow("Tracked IDs: ", self.trackedIDsCombobox) items = df.columns.to_list() - items.insert(0, 'None') + items.insert(0, "None") self.maskIDsCombobox = widgets.QCenteredComboBox() self.maskIDsCombobox.addItems(items) - formLayout.addRow('Segmentation mask IDs: ', self.maskIDsCombobox) + formLayout.addRow("Segmentation mask IDs: ", self.maskIDsCombobox) self.xCentroidCombobox = widgets.QCenteredComboBox() self.xCentroidCombobox.addItems(items) - formLayout.addRow('X coord. centroid: ', self.xCentroidCombobox) + formLayout.addRow("X coord. centroid: ", self.xCentroidCombobox) self.yCentroidCombobox = widgets.QCenteredComboBox() self.yCentroidCombobox.addItems(items) - formLayout.addRow('Y coord. centroid: ', self.yCentroidCombobox) + formLayout.addRow("Y coord. centroid: ", self.yCentroidCombobox) self.parentIDcombobox = widgets.QCenteredComboBox() self.parentIDcombobox.addItems(items) - formLayout.addRow('Parent ID (optional): ', self.parentIDcombobox) + formLayout.addRow("Parent ID (optional): ", self.parentIDcombobox) deleteUntrackedLayout = QHBoxLayout() self.deleteUntrackedIDsToggle = widgets.Toggle() deleteUntrackedLayout.addStretch(1) deleteUntrackedLayout.addWidget(self.deleteUntrackedIDsToggle) deleteUntrackedLayout.addStretch(1) - formLayout.addRow('Delete untracked IDs: ', deleteUntrackedLayout) - + formLayout.addRow("Delete untracked IDs: ", deleteUntrackedLayout) + buttonsLayout = widgets.CancelOkButtonsLayout() buttonsLayout.okButton.clicked.connect(self.ok_cb) @@ -5508,7 +5586,7 @@ def __init__(self, df, parent=None): self.setLayout(self.mainLayout) self.setFont(font) - + def ok_cb(self): self.cancel = False self.frameIndexCol = self.frameIndexCombobox.currentText() @@ -5517,23 +5595,25 @@ def ok_cb(self): self.xCentroidCol = self.xCentroidCombobox.currentText() self.yCentroidCol = self.yCentroidCombobox.currentText() self.deleteUntrackedIDs = self.deleteUntrackedIDsToggle.isChecked() - if self.maskIDsCol == 'None': - if self.xCentroidCol == 'None' or self.yCentroidCol == 'None': + if self.maskIDsCol == "None": + if self.xCentroidCol == "None" or self.yCentroidCol == "None": self.warnInvalidSelection() return else: - self.xCentroidCol = 'None' - self.yCentroidCol = 'None' + self.xCentroidCol = "None" + self.yCentroidCol = "None" self.parentIDcol = self.parentIDcombobox.currentText() self.isFirstFrameOne = self.frameIndexCheckbox.isChecked() self.close() - + def warnInvalidSelection(self): msg = widgets.myMessageBox(showCentered=False, wrapText=False) msg.warning( - self, 'Invalid selection', html_utils.paragraph( - f'Invalid selection
{self.instructionsText}' - ) + self, + "Invalid selection", + html_utils.paragraph( + f"Invalid selection
{self.instructionsText}" + ), ) @@ -5541,37 +5621,35 @@ class SelectPromptableModelDialog(QBaseDialog): def __init__(self, parent=None): self.cancel = True super().__init__(parent) - - self.setWindowTitle('Select model for segmentation') - + + self.setWindowTitle("Select model for segmentation") + mainLayout = QVBoxLayout() - - label = QLabel(html_utils.paragraph( - 'Select model to use for segmentation: ' - )) + + label = QLabel(html_utils.paragraph("Select model to use for segmentation: ")) mainLayout.addWidget(label, alignment=Qt.AlignCenter) - + listBox = widgets.listWidget() models = myutils.get_list_of_promptable_models() listBox.addItems(models) listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) listBox.setCurrentRow(0) listBox.itemDoubleClicked.connect(self.ok_cb) - + self.listBox = listBox - + mainLayout.addWidget(listBox) - + buttonsLayout = widgets.CancelOkButtonsLayout() buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) self.setLayout(mainLayout) - + def ok_cb(self): self.cancel = False self.model_name = self.listBox.currentItem().text() @@ -5579,12 +5657,10 @@ def ok_cb(self): class QDialogSelectModel(QDialog): - def __init__( - self, parent=None, addSkipSegmButton=False, customFirst='' - ): + def __init__(self, parent=None, addSkipSegmButton=False, customFirst=""): self.cancel = True super().__init__(parent) - self.setWindowTitle('Select model') + self.setWindowTitle("Select model") mainLayout = QVBoxLayout() topLayout = QVBoxLayout() @@ -5592,9 +5668,7 @@ def __init__( self.mainLayout = mainLayout - label = QLabel(html_utils.paragraph( - 'Select model to use for segmentation: ' - )) + label = QLabel(html_utils.paragraph("Select model to use for segmentation: ")) # padding: top, left, bottom, right label.setStyleSheet("padding:0px 0px 3px 0px;") topLayout.addWidget(label, alignment=Qt.AlignCenter) @@ -5607,12 +5681,12 @@ def __init__( idx = models.index(customFirst) models.insert(0, models.pop(idx)) except ValueError: - print(f'Warning: {customFirst} not found in models list.') + print(f"Warning: {customFirst} not found in models list.") pass listBox.setFont(font) listBox.addItems(models) - addCustomModelItem = QListWidgetItem('Add custom model...') + addCustomModelItem = QListWidgetItem("Add custom model...") addCustomModelItem.setFont(italicFont) listBox.addItem(addCustomModelItem) listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) @@ -5621,15 +5695,15 @@ def __init__( listBox.itemDoubleClicked.connect(self.ok_cb) topLayout.addWidget(listBox) - cancelButton = widgets.cancelPushButton('Cancel') - okButton = widgets.okPushButton(' Ok ') + cancelButton = widgets.cancelPushButton("Cancel") + okButton = widgets.okPushButton(" Ok ") okButton.setShortcut(Qt.Key_Enter) bottomLayout.addStretch(1) bottomLayout.addWidget(cancelButton) bottomLayout.addSpacing(20) if addSkipSegmButton: - skipSegmButton = widgets.SkipPushButton('Skip segmentation') + skipSegmButton = widgets.SkipPushButton("Skip segmentation") bottomLayout.addWidget(skipSegmButton) skipSegmButton.clicked.connect(self.skipSegm) bottomLayout.addWidget(okButton) @@ -5644,17 +5718,17 @@ def __init__( cancelButton.clicked.connect(self.cancel_cb) self.setStyleSheet(LISTWIDGET_STYLESHEET) - + def skipSegm(self): self.cancel = False - self.selectedModel = 'skip_segmentation' + self.selectedModel = "skip_segmentation" self.close() - + def keyPressEvent(self, event: QKeyEvent) -> None: if event.key() == Qt.Key_Escape: event.ignore() return - + super().keyPressEvent(event) def ok_cb(self, event): @@ -5662,7 +5736,7 @@ def ok_cb(self, event): self.cancel = False item = self.listBox.currentItem() model = item.text() - if model == 'Add custom model...': + if model == "Add custom model...": modelFilePath = addCustomModelMessages(self) if modelFilePath is None: return @@ -5671,8 +5745,8 @@ def ok_cb(self, event): item = QListWidgetItem(modelName) self.listBox.addItem(item) self.listBox.setCurrentItem(item) - elif model == 'Automatic thresholding': - self.selectedModel = 'thresholding' + elif model == "Automatic thresholding": + self.selectedModel = "thresholding" self.close() else: self.selectedModel = model @@ -5699,9 +5773,10 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class ViewTextDialog(QBaseDialog): def __init__(self, text, parent=None): super().__init__(parent) @@ -5714,7 +5789,7 @@ def __init__(self, text, parent=None): textViewWidget.setText(text) buttonsLayout = QHBoxLayout() - okButton = widgets.okPushButton('Ok') + okButton = widgets.okPushButton("Ok") okButton.clicked.connect(self.close) buttonsLayout.addStretch(1) @@ -5727,11 +5802,15 @@ def __init__(self, text, parent=None): self.setLayout(mainLayout) self.setFont(font) + class startStopFramesDialog(QBaseDialog): def __init__( - self, SizeT, currentFrameNum=0, parent=None, - windowTitle='Select frame range to segment' - ): + self, + SizeT, + currentFrameNum=0, + parent=None, + windowTitle="Select frame range to segment", + ): super().__init__(parent=parent) self.setWindowTitle(windowTitle) @@ -5745,8 +5824,8 @@ def __init__( SizeT, currentFrameNum=currentFrameNum, parent=parent ) - okButton = widgets.okPushButton('Ok') - cancelButton = widgets.cancelPushButton('Cancel') + okButton = widgets.okPushButton("Ok") + cancelButton = widgets.cancelPushButton("Cancel") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -5770,26 +5849,27 @@ def ok_cb(self): self.stopFrame = self.selectFramesGroupbox.stopFrame_SB.value() self.cancel = False self.close() - + def show(self, block=False): super().show(block=False) - self.resize(int(self.width()*1.5), self.height()) + self.resize(int(self.width() * 1.5), self.height()) if block: super().show(block=True) + class QDialogAppendTextFilename(QDialog): def __init__(self, filename, ext, parent=None, font=None): super().__init__(parent) self.cancel = True filenameNOext, _ = os.path.splitext(filename) self.filenameNOext = filenameNOext - if ext.find('.') == -1: - ext = f'.{ext}' + if ext.find(".") == -1: + ext = f".{ext}" self.ext = ext - self.setWindowTitle('Append text to file name') + self.setWindowTitle("Append text to file name") mainLayout = QVBoxLayout() formLayout = QFormLayout() @@ -5800,21 +5880,17 @@ def __init__(self, filename, ext, parent=None, font=None): self.LE = QLineEdit() self.LE.setAlignment(Qt.AlignCenter) - formLayout.addRow('Appended text', self.LE) + formLayout.addRow("Appended text", self.LE) self.LE.textChanged.connect(self.updateFinalFilename) - self.finalName_label = QLabel( - f'Final file name: "{filenameNOext}_{ext}"' - ) + self.finalName_label = QLabel(f'Final file name: "{filenameNOext}_{ext}"') # padding: top, left, bottom, right - self.finalName_label.setStyleSheet( - 'font-size:13px; padding:5px 0px 0px 0px;' - ) + self.finalName_label.setStyleSheet("font-size:13px; padding:5px 0px 0px 0px;") - okButton = widgets.okPushButton('Ok') + okButton = widgets.okPushButton("Ok") okButton.setShortcut(Qt.Key_Enter) - cancelButton = widgets.cancelPushButton('Cancel') + cancelButton = widgets.cancelPushButton("Cancel") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -5836,18 +5912,14 @@ def __init__(self, filename, ext, parent=None, font=None): # self.setModal(True) def updateFinalFilename(self, text): - finalFilename = f'{self.filenameNOext}_{text}{self.ext}' + finalFilename = f"{self.filenameNOext}_{text}{self.ext}" self.finalName_label.setText(f'Final file name: "{finalFilename}"') def ok_cb(self, event): if not self.LE.text(): - err_msg = ( - 'Appended name cannot be empty!' - ) + err_msg = "Appended name cannot be empty!" msg = QMessageBox() - msg.critical( - self, 'Empty name', err_msg, msg.Ok - ) + msg.critical(self, "Empty name", err_msg, msg.Ok) return self.cancel = False self.close() @@ -5863,12 +5935,14 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class QDialogEntriesWidget(QDialog): - def __init__(self, entriesLabels, defaultTxts, winTitle='Input', - parent=None, font=None): + def __init__( + self, entriesLabels, defaultTxts, winTitle="Input", parent=None, font=None + ): self.cancel = True self.entriesTxt = [] self.entriesLabels = entriesLabels @@ -5890,10 +5964,10 @@ def __init__(self, entriesLabels, defaultTxts, winTitle='Input', formLayout.addRow(label, LE) self.QLEs.append(LE) - okButton = widgets.okPushButton('Ok') + okButton = widgets.okPushButton("Ok") okButton.setShortcut(Qt.Key_Enter) - cancelButton = widgets.cancelPushButton('Cancel') + cancelButton = widgets.cancelPushButton("Cancel") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -5915,8 +5989,10 @@ def __init__(self, entriesLabels, defaultTxts, winTitle='Input', def ok_cb(self, event): self.cancel = False - self.entriesTxt = [self.formLayout.itemAt(i, 1).widget().text() - for i in range(len(self.entriesLabels))] + self.entriesTxt = [ + self.formLayout.itemAt(i, 1).widget().text() + for i in range(len(self.entriesLabels)) + ] self.close() def exec_(self): @@ -5930,19 +6006,34 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class QDialogMetadata(QDialog): def __init__( - self, SizeT, SizeZ, TimeIncrement, - PhysicalSizeZ, PhysicalSizeY, PhysicalSizeX, - ask_SizeT, ask_TimeIncrement, ask_PhysicalSizes, - parent=None, font=None, imgDataShape=None, posData=None, - singlePos=False, askSegm3D=True, additionalValues=None, - forceEnableAskSegm3D=False, SizeT_metadata=None, - SizeZ_metadata=None, basename='' - ): + self, + SizeT, + SizeZ, + TimeIncrement, + PhysicalSizeZ, + PhysicalSizeY, + PhysicalSizeX, + ask_SizeT, + ask_TimeIncrement, + ask_PhysicalSizes, + parent=None, + font=None, + imgDataShape=None, + posData=None, + singlePos=False, + askSegm3D=True, + additionalValues=None, + forceEnableAskSegm3D=False, + SizeT_metadata=None, + SizeZ_metadata=None, + basename="", + ): self.cancel = True self.ask_TimeIncrement = ask_TimeIncrement self.ask_PhysicalSizes = ask_PhysicalSizes @@ -5953,7 +6044,7 @@ def __init__( self.SizeT_metadata = SizeT_metadata self.SizeZ_metadata = SizeZ_metadata super().__init__(parent) - self.setWindowTitle('Image properties') + self.setWindowTitle("Image properties") mainLayout = QVBoxLayout() gridLayout = QGridLayout() @@ -5963,7 +6054,7 @@ def __init__( if imgDataShape is not None: label = QLabel( html_utils.paragraph( - f'Image data shape = {imgDataShape}
' + f"Image data shape = {imgDataShape}
" ) ) mainLayout.addWidget(label, alignment=Qt.AlignCenter) @@ -5972,28 +6063,27 @@ def __init__( self.basenameLineEdit = None if basename: gridLayout.addWidget( - QLabel('Basename (read-only)'), row, 0, alignment=Qt.AlignRight + QLabel("Basename (read-only)"), row, 0, alignment=Qt.AlignRight ) self.basenameLineEdit = QLineEdit() self.basenameLineEdit.setReadOnly(True) self.basenameLineEdit.setText(basename) minWidth = ( - self.basenameLineEdit.fontMetrics() - .boundingRect(basename).width() + 10 + self.basenameLineEdit.fontMetrics().boundingRect(basename).width() + 10 ) self.basenameLineEdit.setMinimumWidth(minWidth) self.basenameLineEdit.setAlignment(Qt.AlignCenter) gridLayout.addWidget(self.basenameLineEdit, row, 1) row += 1 - + gridLayout.addWidget( - QLabel('Number of frames (SizeT)'), row, 0, alignment=Qt.AlignRight + QLabel("Number of frames (SizeT)"), row, 0, alignment=Qt.AlignRight ) self.SizeT_SpinBox = QSpinBox() self.SizeT_SpinBox.setMinimum(1) self.SizeT_SpinBox.setMaximum(2147483647) SizeTinfoButton = widgets.infoPushButton() - self.allowEditSizeTcheckbox = QCheckBox('Let me edit it') + self.allowEditSizeTcheckbox = QCheckBox("Let me edit it") if ask_SizeT: self.SizeT_SpinBox.setValue(SizeT) SizeTinfoButton.hide() @@ -6009,13 +6099,13 @@ def __init__( self.SizeT_SpinBox.valueChanged.connect(self.TimeIncrementShowHide) gridLayout.addWidget(self.SizeT_SpinBox, row, 1) gridLayout.addWidget(SizeTinfoButton, row, 2) - gridLayout.setColumnStretch(2,0) + gridLayout.setColumnStretch(2, 0) gridLayout.addWidget(self.allowEditSizeTcheckbox, row, 3) - gridLayout.setColumnStretch(3,0) + gridLayout.setColumnStretch(3, 0) row += 1 gridLayout.addWidget( - QLabel('Number of z-slices (SizeZ)'), row, 0, alignment=Qt.AlignRight + QLabel("Number of z-slices (SizeZ)"), row, 0, alignment=Qt.AlignRight ) self.SizeZ_SpinBox = QSpinBox() self.SizeZ_SpinBox.setMinimum(1) @@ -6026,10 +6116,8 @@ def __init__( gridLayout.addWidget(self.SizeZ_SpinBox, row, 1) row += 1 - self.TimeIncrementLabel = QLabel('Time interval (s)') - gridLayout.addWidget( - self.TimeIncrementLabel, row, 0, alignment=Qt.AlignRight - ) + self.TimeIncrementLabel = QLabel("Time interval (s)") + gridLayout.addWidget(self.TimeIncrementLabel, row, 0, alignment=Qt.AlignRight) self.TimeIncrementSpinBox = widgets.FloatLineEdit() self.TimeIncrementSpinBox.setValue(TimeIncrement) gridLayout.addWidget(self.TimeIncrementSpinBox, row, 1) @@ -6039,23 +6127,19 @@ def __init__( self.TimeIncrementLabel.hide() row += 1 - self.PhysicalSizeZLabel = QLabel('Physical Size Z (um/pixel)') - gridLayout.addWidget( - self.PhysicalSizeZLabel, row, 0, alignment=Qt.AlignRight - ) + self.PhysicalSizeZLabel = QLabel("Physical Size Z (um/pixel)") + gridLayout.addWidget(self.PhysicalSizeZLabel, row, 0, alignment=Qt.AlignRight) self.PhysicalSizeZSpinBox = widgets.FloatLineEdit() self.PhysicalSizeZSpinBox.setValue(PhysicalSizeZ) gridLayout.addWidget(self.PhysicalSizeZSpinBox, row, 1) - if SizeZ==1 or not ask_PhysicalSizes: + if SizeZ == 1 or not ask_PhysicalSizes: self.PhysicalSizeZSpinBox.hide() self.PhysicalSizeZLabel.hide() row += 1 - self.PhysicalSizeYLabel = QLabel('Physical Size Y (um/pixel)') - gridLayout.addWidget( - self.PhysicalSizeYLabel, row, 0, alignment=Qt.AlignRight - ) + self.PhysicalSizeYLabel = QLabel("Physical Size Y (um/pixel)") + gridLayout.addWidget(self.PhysicalSizeYLabel, row, 0, alignment=Qt.AlignRight) self.PhysicalSizeYSpinBox = widgets.FloatLineEdit() self.PhysicalSizeYSpinBox.setValue(PhysicalSizeY) gridLayout.addWidget(self.PhysicalSizeYSpinBox, row, 1) @@ -6065,10 +6149,8 @@ def __init__( self.PhysicalSizeYLabel.hide() row += 1 - self.PhysicalSizeXLabel = QLabel('Physical Size X (um/pixel)') - gridLayout.addWidget( - self.PhysicalSizeXLabel, row, 0, alignment=Qt.AlignRight - ) + self.PhysicalSizeXLabel = QLabel("Physical Size X (um/pixel)") + gridLayout.addWidget(self.PhysicalSizeXLabel, row, 0, alignment=Qt.AlignRight) self.PhysicalSizeXSpinBox = widgets.FloatLineEdit() self.PhysicalSizeXSpinBox.setValue(PhysicalSizeX) gridLayout.addWidget(self.PhysicalSizeXSpinBox, row, 1) @@ -6091,19 +6173,13 @@ def __init__( ) if disableToggle: self.isSegm3Dtoggle.setDisabled(True) - self.isSegm3DLabel = QLabel('Work with 3D segmentation masks (z-stack)') - gridLayout.addWidget( - self.isSegm3DLabel, row, 0, alignment=Qt.AlignRight - ) - gridLayout.addWidget( - self.isSegm3Dtoggle, row, 1, alignment=Qt.AlignCenter - ) + self.isSegm3DLabel = QLabel("Work with 3D segmentation masks (z-stack)") + gridLayout.addWidget(self.isSegm3DLabel, row, 0, alignment=Qt.AlignRight) + gridLayout.addWidget(self.isSegm3Dtoggle, row, 1, alignment=Qt.AlignCenter) self.infoButtonSegm3D = QPushButton(self) self.infoButtonSegm3D.setCursor(Qt.WhatsThisCursor) self.infoButtonSegm3D.setIcon(QIcon(":info.svg")) - gridLayout.addWidget( - self.infoButtonSegm3D, row, 2, alignment=Qt.AlignLeft - ) + gridLayout.addWidget(self.infoButtonSegm3D, row, 2, alignment=Qt.AlignLeft) self.infoButtonSegm3D.clicked.connect(self.infoSegm3D) if SizeZ == 1 or not askSegm3D: self.isSegm3DLabel.hide() @@ -6113,7 +6189,7 @@ def __init__( self.SizeZvalueChanged(SizeZ) self.additionalFieldsWidgets = [] - addFieldButton = widgets.addPushButton('Add custom field') + addFieldButton = widgets.addPushButton("Add custom field") addFieldInfoButton = widgets.infoPushButton() addFieldInfoButton.clicked.connect(self.showAddFieldInfo) addFieldButton.clicked.connect(self.addField) @@ -6124,38 +6200,36 @@ def __init__( addFieldLayout.addStretch(1) if singlePos: - okTxt = 'Apply only to this Position' + okTxt = "Apply only to this Position" else: - okTxt = 'Ok for loaded Positions' + okTxt = "Ok for loaded Positions" okButton = widgets.okPushButton(okTxt) - okButton.setToolTip( - 'Save metadata only for current positionh' - ) + okButton.setToolTip("Save metadata only for current positionh") okButton.setShortcut(Qt.Key_Enter) self.okButton = okButton if ask_TimeIncrement or ask_PhysicalSizes: - okAllButton = QPushButton('Apply to ALL Positions') + okAllButton = QPushButton("Apply to ALL Positions") okAllButton.setToolTip( - 'Update existing Physical Sizes, Time interval, cell volume (fl), ' - 'cell area (um^2), and time (s) for all the positions ' - 'in the experiment folder.' + "Update existing Physical Sizes, Time interval, cell volume (fl), " + "cell area (um^2), and time (s) for all the positions " + "in the experiment folder." ) self.okAllButton = okAllButton - selectButton = QPushButton('Select the Positions to be updated') + selectButton = QPushButton("Select the Positions to be updated") selectButton.setToolTip( - 'Ask to select positions then update existing Physical Sizes, ' - 'Time interval, cell volume (fl), cell area (um^2), and time (s)' - 'for selected positions.' + "Ask to select positions then update existing Physical Sizes, " + "Time interval, cell volume (fl), cell area (um^2), and time (s)" + "for selected positions." ) self.selectButton = selectButton else: self.okAllButton = None self.selectButton = None - okButton.setText('Ok') + okButton.setText("Ok") - cancelButton = widgets.cancelPushButton('Cancel') + cancelButton = widgets.cancelPushButton("Cancel") buttonsLayout.setColumnStretch(0, 1) buttonsLayout.addWidget(okButton, 0, 1) @@ -6188,7 +6262,7 @@ def __init__( self.setLayout(mainLayout) self.setFont(font) # self.setModal(True) - + def showWhySizeTisGrayed(self): txt = html_utils.paragraph(f""" The "Number of frames" field is grayed-out because you loaded multiple Positions.

@@ -6199,9 +6273,7 @@ def showWhySizeTisGrayed(self): However, you can only edit the metadata, then the loading process will be stopped. """) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.information( - self, 'Why is the number of frames grayed out?', txt - ) + msg.information(self, "Why is the number of frames grayed out?", txt) def addAdditionalValues(self, values): if values is None: @@ -6209,9 +6281,9 @@ def addAdditionalValues(self, values): for i, (name, value) in enumerate(values.items()): self.addField() - nameWidget = self.additionalFieldsWidgets[i]['nameWidget'] - valueWidget = self.additionalFieldsWidgets[i]['valueWidget'] - nameWidget.setText(str(name).strip('__')) + nameWidget = self.additionalFieldsWidgets[i]["nameWidget"] + valueWidget = self.additionalFieldsWidgets[i]["valueWidget"] + nameWidget.setText(str(name).strip("__")) valueWidget.setText(str(value)) def addField(self): @@ -6222,29 +6294,31 @@ def addField(self): removeButton = widgets.delPushButton() fieldLayout = QGridLayout() - fieldLayout.addWidget(QLabel('Name'), 0, 0) + fieldLayout.addWidget(QLabel("Name"), 0, 0) fieldLayout.addWidget(nameWidget, 1, 0) - fieldLayout.addWidget(QLabel('Value'), 0, 1) + fieldLayout.addWidget(QLabel("Value"), 0, 1) fieldLayout.addWidget(valueWidget, 1, 1) fieldLayout.addWidget(removeButton, 1, 2) - self.additionalFieldsWidgets.append({ - 'nameWidget': nameWidget, - 'valueWidget': valueWidget, - 'removeButton': removeButton, - 'layout': fieldLayout - }) + self.additionalFieldsWidgets.append( + { + "nameWidget": nameWidget, + "valueWidget": valueWidget, + "removeButton": removeButton, + "layout": fieldLayout, + } + ) - idx = len(self.additionalFieldsWidgets)-1 + idx = len(self.additionalFieldsWidgets) - 1 removeButton.clicked.connect(partial(self.removeField, idx)) - row = self.mainLayout.count()-3 + row = self.mainLayout.count() - 3 self.mainLayout.insertLayout(row, fieldLayout) def removeField(self, idx): widgets = self.additionalFieldsWidgets[idx] - layoutToRemove = widgets['layout'] + layoutToRemove = widgets["layout"] for row in range(layoutToRemove.rowCount()): for col in range(layoutToRemove.columnCount()): item = layoutToRemove.itemAtPosition(row, col) @@ -6264,38 +6338,38 @@ def showAddFieldInfo(self): acdc_output.csv table.

Example: a strain name or the replicate number. """) - msg.information(self, 'Add field info', txt) + msg.information(self, "Add field info", txt) def infoSegm3D(self): txt = ( - 'Cell-ACDC supports both 2D and 3D segmentation. If your data ' - 'also have a time dimension, then you can choose to segment ' - 'a specific z-slice (2D segmentation mask per frame) or all of them ' - '(3D segmentation mask per frame)

' - 'In any case, if you choose to activate 3D segmentation then the ' - 'segmentation mask will have the same number of z-slices ' - 'of the image data.

' - 'Additionally, in the model parameters window, you will be able ' - 'to choose if you want to segment the entire 3D volume at once ' - 'or use the 2D model on each z-slice, one by one.

' - 'NOTE: if the toggle is disabled it means you already ' - 'loaded segmentation data and the shape cannot be changed now.
' - 'if you need to start with a blank segmentation, ' + "Cell-ACDC supports both 2D and 3D segmentation. If your data " + "also have a time dimension, then you can choose to segment " + "a specific z-slice (2D segmentation mask per frame) or all of them " + "(3D segmentation mask per frame)

" + "In any case, if you choose to activate 3D segmentation then the " + "segmentation mask will have the same number of z-slices " + "of the image data.

" + "Additionally, in the model parameters window, you will be able " + "to choose if you want to segment the entire 3D volume at once " + "or use the 2D model on each z-slice, one by one.

" + "NOTE: if the toggle is disabled it means you already " + "loaded segmentation data and the shape cannot be changed now.
" + "if you need to start with a blank segmentation, " 'use the "Create a new segmentation file" button instead of the ' '"Load folder" button.' - '
' + "
" ) msg = widgets.myMessageBox() msg.setIcon() - msg.setWindowTitle(f'3D segmentation info') + msg.setWindowTitle(f"3D segmentation info") msg.addText(html_utils.paragraph(txt)) - msg.addButton(' Ok ') + msg.addButton(" Ok ") msg.exec_() def SizeZvalueChanged(self, val): if len(self.imgDataShape) < 3: return - + if val > 1 and self.imgDataShape is not None: maxSizeZ = self.imgDataShape[-3] self.SizeZ_SpinBox.setMaximum(maxSizeZ) @@ -6316,16 +6390,16 @@ def SizeZvalueChanged(self, val): self.isSegm3DLabel.hide() self.isSegm3Dtoggle.hide() self.infoButtonSegm3D.hide() - + self.checkSegmDataShape() - + def checkSegmDataShape(self): if self.posData is None: return - + if self.isSegm3Dtoggle.isEnabled(): return - + SizeT = self.SizeT_SpinBox.value() SizeZ = self.SizeZ_SpinBox.value() segm_data_ndim = self.posData.segm_data.ndim @@ -6334,10 +6408,10 @@ def checkSegmDataShape(self): # Segm data is 4D so it must be 3D over time isSegm3D = True elif segm_data_ndim == 3 and SizeZ > 1 and SizeT == 1: - # Segm data is 3D while SizeT == 1 and SizeZ > 1 + # Segm data is 3D while SizeT == 1 and SizeZ > 1 # --> also segm is 3D z-stack isSegm3D = True - + self.isSegm3Dtoggle.setDisabled(False) self.isSegm3Dtoggle.setChecked(isSegm3D) self.isSegm3Dtoggle.setDisabled(True) @@ -6346,14 +6420,14 @@ def TimeIncrementShowHide(self, val): self.checkSegmDataShape() if not self.ask_TimeIncrement: return - + if val > 1: self.TimeIncrementSpinBox.show() self.TimeIncrementLabel.show() else: self.TimeIncrementSpinBox.hide() self.TimeIncrementLabel.hide() - + def allowEditSizeT(self, checked): if checked: self.SizeT_SpinBox.setDisabled(False) @@ -6362,7 +6436,7 @@ def allowEditSizeT(self, checked): else: self.SizeT_SpinBox.setDisabled(True) self.SizeT_SpinBox.setValue(1) - + def warnEditingMetadata(self, Size, Size_metadata, which_dim): txt = html_utils.paragraph(f""" The number of {which_dim} in the saved metadata is {Size_metadata}, @@ -6371,8 +6445,10 @@ def warnEditingMetadata(self, Size, Size_metadata, which_dim): """) msg = widgets.myMessageBox(wrapText=False, showCentered=False) _, noButton, yesButton = msg.warning( - self, 'WARNING: Edinting saved metadata', txt, - buttonsTexts=('Cancel', 'No', 'Yes, edit the metadata') + self, + "WARNING: Edinting saved metadata", + txt, + buttonsTexts=("Cancel", "No", "Yes, edit the metadata"), ) return msg.clickedButton == yesButton @@ -6384,20 +6460,19 @@ def ok_cb(self, checked=False): if self.SizeT_metadata is not None: if self.SizeT != self.SizeT_metadata: proceed = self.warnEditingMetadata( - self.SizeT, self.SizeT_metadata, 'frames' + self.SizeT, self.SizeT_metadata, "frames" ) if not proceed: return - + if self.SizeZ_metadata is not None: if self.SizeZ != self.SizeZ_metadata: proceed = self.warnEditingMetadata( - self.SizeZ, self.SizeZ_metadata, 'z-slices' + self.SizeZ, self.SizeZ_metadata, "z-slices" ) if not proceed: return - self.isSegm3D = self.isSegm3Dtoggle.isChecked() self.TimeIncrement = self.TimeIncrementSpinBox.value() @@ -6405,12 +6480,12 @@ def ok_cb(self, checked=False): self.PhysicalSizeY = self.PhysicalSizeYSpinBox.value() self.PhysicalSizeZ = self.PhysicalSizeZSpinBox.value() self._additionalValues = { - f"__{field['nameWidget'].text()}":field['valueWidget'].text() + f"__{field['nameWidget'].text()}": field["valueWidget"].text() for field in self.additionalFieldsWidgets } proceed = self.checkShapeMismatchMetadata() if not proceed: - return + return if self.posData is not None and self.sender() != self.okButton: exp_path = self.posData.exp_path @@ -6423,47 +6498,47 @@ def ok_cb(self, checked=False): ) pos_foldernames = select_folder.selected_pos for pos in pos_foldernames: - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") ls = myutils.listdir(images_path) - search = [file for file in ls if file.find('metadata.csv')!=-1] + search = [file for file in ls if file.find("metadata.csv") != -1] metadata_df = None if search: fileName = search[0] metadata_csv_path = os.path.join(images_path, fileName) - metadata_df = pd.read_csv( - metadata_csv_path - ).set_index('Description') + metadata_df = pd.read_csv(metadata_csv_path).set_index( + "Description" + ) if metadata_df is not None: - metadata_df.at['TimeIncrement', 'values'] = self.TimeIncrement - metadata_df.at['PhysicalSizeZ', 'values'] = self.PhysicalSizeZ - metadata_df.at['PhysicalSizeY', 'values'] = self.PhysicalSizeY - metadata_df.at['PhysicalSizeX', 'values'] = self.PhysicalSizeX + metadata_df.at["TimeIncrement", "values"] = self.TimeIncrement + metadata_df.at["PhysicalSizeZ", "values"] = self.PhysicalSizeZ + metadata_df.at["PhysicalSizeY", "values"] = self.PhysicalSizeY + metadata_df.at["PhysicalSizeX", "values"] = self.PhysicalSizeX metadata_df.to_csv(metadata_csv_path) - search = [file for file in ls if file.find('acdc_output.csv')!=-1] + search = [file for file in ls if file.find("acdc_output.csv") != -1] acdc_df = None if search: fileName = search[0] acdc_df_path = os.path.join(images_path, fileName) acdc_df = pd.read_csv(acdc_df_path) - yx_pxl_to_um2 = self.PhysicalSizeY*self.PhysicalSizeX - vox_to_fl = self.PhysicalSizeY*(self.PhysicalSizeX**2) - if 'cell_vol_fl' not in acdc_df.columns: + yx_pxl_to_um2 = self.PhysicalSizeY * self.PhysicalSizeX + vox_to_fl = self.PhysicalSizeY * (self.PhysicalSizeX**2) + if "cell_vol_fl" not in acdc_df.columns: continue - acdc_df['cell_vol_fl'] = acdc_df['cell_vol_vox']*vox_to_fl - acdc_df['cell_area_um2'] = acdc_df['cell_area_pxl']*yx_pxl_to_um2 - acdc_df['time_seconds'] = acdc_df['frame_i']*self.TimeIncrement + acdc_df["cell_vol_fl"] = acdc_df["cell_vol_vox"] * vox_to_fl + acdc_df["cell_area_um2"] = acdc_df["cell_area_pxl"] * yx_pxl_to_um2 + acdc_df["time_seconds"] = acdc_df["frame_i"] * self.TimeIncrement try: acdc_df.to_csv(acdc_df_path, index=False) except PermissionError: err_msg = html_utils.paragraph( - 'The below file is open in another app ' - '(Excel maybe?).

' - f'{acdc_df_path}

' + "The below file is open in another app " + "(Excel maybe?).

" + f"{acdc_df_path}

" 'Close file and then press "Ok".' ) msg = widgets.myMessageBox() - msg.critical(self, 'Permission denied', err_msg) + msg.critical(self, "Permission denied", err_msg) acdc_df.to_csv(acdc_df_path, index=False) elif self.sender() == self.selectButton: @@ -6485,48 +6560,50 @@ def checkShapeMismatchMetadata(self): valid3D = self.SizeT == TorZ or self.SizeZ == TorZ elif len(self.imgDataShape) == 2: valid2D = self.SizeT == 1 and self.SizeZ == 1 - + valid = all([valid4D, valid3D, valid2D]) if valid: return True - + if not valid4D: - txt = (f""" + txt = f""" You loaded 4D data, hence the number of frames MUST be {T}
and the number of z-slices MUST be {Z}.

What do you want to do? - """) + """ if not valid3D: - txt = (f""" + txt = f""" You loaded 3D data, hence either the number of frames or the number of z-slices is {TorZ}.

However, if the number of frames is greater than 1 then the
number of z-slices MUST be 1, and vice-versa.

What do you want to do? - """) + """ if not valid2D: - txt = (f""" + txt = f""" You loaded 2D data, hence the number of frames MUST be 1 and the number of z-slices MUST be 1.

What do you want to do? - """) - + """ + msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(txt) - - continueButton = widgets.okPushButton('Continue anyway') - correctButton = widgets.editPushButton('Let me correct') - + + continueButton = widgets.okPushButton("Continue anyway") + correctButton = widgets.editPushButton("Let me correct") + msg.warning( - self, 'Shape-metadata mismatch', txt, - buttonsTexts=(continueButton, correctButton) + self, + "Shape-metadata mismatch", + txt, + buttonsTexts=(continueButton, correctButton), ) if msg.cancel or msg.clickedButton == correctButton: return False - + return True - + def cancel_cb(self, event): self.cancel = True self.close() @@ -6542,9 +6619,10 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class QCropZtool(QBaseDialog): sigClose = Signal() sigZvalueChanged = Signal(str, int) @@ -6552,9 +6630,13 @@ class QCropZtool(QBaseDialog): sigCrop = Signal(int, int) def __init__( - self, SizeZ, cropButtonText='Apply crop', parent=None, - addDoNotShowAgain=False, title='Select z-slices' - ): + self, + SizeZ, + cropButtonText="Apply crop", + parent=None, + addDoNotShowAgain=False, + title="Select z-slices", + ): super().__init__(parent) self.cancel = True @@ -6578,29 +6660,25 @@ def __init__( self.upperZscrollbar.setMaximum(SizeZ) self.upperZscrollbar.setValue(SizeZ) - cancelButton = widgets.cancelPushButton('Cancel') + cancelButton = widgets.cancelPushButton("Cancel") cropButton = widgets.okPushButton(cropButtonText) buttonsLayout.addWidget(cropButton) buttonsLayout.addWidget(cancelButton) row = 0 - layout.addWidget( - QLabel('Lower z-slice '), row, 0, alignment=Qt.AlignRight - ) + layout.addWidget(QLabel("Lower z-slice "), row, 0, alignment=Qt.AlignRight) layout.addWidget(self.lowerZscrollbar, row, 1) row += 1 layout.setRowStretch(row, 5) row += 1 - layout.addWidget( - QLabel('Upper z-slice '), row, 0, alignment=Qt.AlignRight - ) + layout.addWidget(QLabel("Upper z-slice "), row, 0, alignment=Qt.AlignRight) layout.addWidget(self.upperZscrollbar, row, 1) row += 1 if addDoNotShowAgain: - self.doNotShowAgainCheckbox = QCheckBox('Do not ask again') + self.doNotShowAgainCheckbox = QCheckBox("Do not ask again") layout.addWidget( self.doNotShowAgainCheckbox, row, 1, alignment=Qt.AlignLeft ) @@ -6630,28 +6708,29 @@ def emitCrop(self): self.close() def updateScrollbars(self, lower_z, upper_z): - self.lowerZscrollbar.setValue(lower_z+1) - self.upperZscrollbar.setValue(upper_z+1) + self.lowerZscrollbar.setValue(lower_z + 1) + self.upperZscrollbar.setValue(upper_z + 1) def ZvalueChanged(self, value): - which = 'lower' if self.sender() == self.lowerZscrollbar else 'upper' - if which == 'lower' and value > self.upperZscrollbar.value()-1: - self.lowerZscrollbar.setValue(self.upperZscrollbar.value()-1) + which = "lower" if self.sender() == self.lowerZscrollbar else "upper" + if which == "lower" and value > self.upperZscrollbar.value() - 1: + self.lowerZscrollbar.setValue(self.upperZscrollbar.value() - 1) return - if which == 'upper' and value < self.lowerZscrollbar.value()+1: - self.upperZscrollbar.setValue(self.lowerZscrollbar.value()+1) + if which == "upper" and value < self.lowerZscrollbar.value() + 1: + self.upperZscrollbar.setValue(self.lowerZscrollbar.value() + 1) return z_slice_n = value - 1 self.sigZvalueChanged.emit(which, z_slice_n) def showEvent(self, event): - self.resize(int(self.width()*1.5), self.height()) + self.resize(int(self.width() * 1.5), self.height()) def closeEvent(self, event): super().closeEvent(event) self.sigClose.emit() + class randomWalkerDialog(QDialog): def __init__(self, mainWindow): super().__init__(mainWindow) @@ -6662,7 +6741,7 @@ def __init__(self, mainWindow): posData = self.mainWindow.data[self.mainWindow.pos_i] items = [posData.filename] else: - items = ['test'] + items = ["test"] try: posData = self.mainWindow.data[self.mainWindow.pos_i] items.extend(list(posData.ol_data_dict.keys())) @@ -6671,10 +6750,9 @@ def __init__(self, mainWindow): self.keys = items - self.setWindowTitle('Random walker segmentation') + self.setWindowTitle("Random walker segmentation") - self.colors = [self.mainWindow.RWbkgrColor, - self.mainWindow.RWforegrColor] + self.colors = [self.mainWindow.RWbkgrColor, self.mainWindow.RWforegrColor] mainLayout = QVBoxLayout() paramsLayout = QGridLayout() @@ -6683,9 +6761,9 @@ def __init__(self, mainWindow): self.mainWindow.clearAllItems() row = 0 - paramsLayout.addWidget(QLabel('Background threshold:'), row, 0) + paramsLayout.addWidget(QLabel("Background threshold:"), row, 0) row += 1 - self.bkgrThreshValLabel = QLabel('0.05') + self.bkgrThreshValLabel = QLabel("0.05") paramsLayout.addWidget(self.bkgrThreshValLabel, row, 1) self.bkgrThreshSlider = QSlider(Qt.Horizontal) self.bkgrThreshSlider.setMinimum(1) @@ -6696,12 +6774,12 @@ def __init__(self, mainWindow): paramsLayout.addWidget(self.bkgrThreshSlider, row, 0) row += 1 - foregrQSLabel = QLabel('Foreground threshold:') + foregrQSLabel = QLabel("Foreground threshold:") # padding: top, left, bottom, right foregrQSLabel.setStyleSheet("font-size:13px; padding:5px 0px 0px 0px;") paramsLayout.addWidget(foregrQSLabel, row, 0) row += 1 - self.foregrThreshValLabel = QLabel('0.95') + self.foregrThreshValLabel = QLabel("0.95") paramsLayout.addWidget(self.foregrThreshValLabel, row, 1) self.foregrThreshSlider = QSlider(Qt.Horizontal) self.foregrThreshSlider.setMinimum(1) @@ -6713,13 +6791,15 @@ def __init__(self, mainWindow): # Parameters link label row += 1 - url1 = 'https://scikit-image.org/docs/dev/auto_examples/segmentation/plot_random_walker_segmentation.html' - url2 = 'https://scikit-image.org/docs/dev/api/skimage.segmentation.html#skimage.segmentation.random_walker' - htmlTxt1 = f'here' - htmlTxt2 = f'here' + url1 = "https://scikit-image.org/docs/dev/auto_examples/segmentation/plot_random_walker_segmentation.html" + url2 = "https://scikit-image.org/docs/dev/api/skimage.segmentation.html#skimage.segmentation.random_walker" + htmlTxt1 = f'here' + htmlTxt2 = f'here' seeHereLabel = QLabel() - seeHereLabel.setText(f'See {htmlTxt1} and {htmlTxt2} for details ' - 'about Random walker segmentation.') + seeHereLabel.setText( + f"See {htmlTxt1} and {htmlTxt2} for details " + "about Random walker segmentation." + ) seeHereLabel.setTextFormat(Qt.RichText) seeHereLabel.setTextInteractionFlags(Qt.TextBrowserInteraction) seeHereLabel.setOpenExternalLinks(True) @@ -6729,8 +6809,8 @@ def __init__(self, mainWindow): seeHereLabel.setStyleSheet("padding:12px 0px 0px 0px;") paramsLayout.addWidget(seeHereLabel, row, 0, 1, 2) - computeButton = QPushButton('Compute segmentation') - closeButton = QPushButton('Close') + computeButton = QPushButton("Compute segmentation") + closeButton = QPushButton("Close") buttonsLayout.addWidget(computeButton, alignment=Qt.AlignRight) buttonsLayout.addWidget(closeButton, alignment=Qt.AlignLeft) @@ -6753,8 +6833,8 @@ def __init__(self, mainWindow): def getImage(self): img = self.mainWindow.getDisplayedImg1() - self.img = img/img.max() - self.imgRGB = (skimage.color.gray2rgb(self.img)*255).astype(np.uint8) + self.img = img / img.max() + self.imgRGB = (skimage.color.gray2rgb(self.img) * 255).astype(np.uint8) def setSize(self): x = self.pos().x() @@ -6783,32 +6863,31 @@ def plotMarkers(self): self.mainWindow.img1.setImage(imgRGB) def computeMarkers(self): - bkgrThresh = self.bkgrThreshSlider.sliderPosition()/100 - foregrThresh = self.foregrThreshSlider.sliderPosition()/100 + bkgrThresh = self.bkgrThreshSlider.sliderPosition() / 100 + foregrThresh = self.foregrThreshSlider.sliderPosition() / 100 img = self.img self.markers = np.zeros(img.shape, np.uint8) imgRange = img.max() - img.min() - imgMin = img.min() + imgRange*bkgrThresh - imgMax = img.min() + imgRange*foregrThresh + imgMin = img.min() + imgRange * bkgrThresh + imgMax = img.min() + imgRange * foregrThresh self.markers[img < imgMin] = 1 self.markers[img > imgMax] = 2 return imgMin, imgMax def computeSegm(self, checked=True): self.mainWindow.storeUndoRedoStates(False) - self.mainWindow.titleLabel.setText( - 'Randomly walking around... ', color='w') + self.mainWindow.titleLabel.setText("Randomly walking around... ", color="w") img = self.img img = skimage.exposure.rescale_intensity(img) t0 = time.time() - lab = skimage.segmentation.random_walker(img, self.markers, mode='bf') - lab = skimage.measure.label(lab>1) + lab = skimage.segmentation.random_walker(img, self.markers, mode="bf") + lab = skimage.measure.label(lab > 1) t1 = time.time() if len(np.unique(lab)) > 2: lab = skimage.morphology.remove_small_objects(lab, min_size=5) posData = self.mainWindow.data[self.mainWindow.pos_i] posData.lab = lab - return t1-t0 + return t1 - t0 def computeSegmAndPlot(self): deltaT = self.computeSegm() @@ -6818,35 +6897,40 @@ def computeSegmAndPlot(self): self.mainWindow.update_rp() self.mainWindow.tracking(enforce=True) self.mainWindow.updateAllImages() - self.mainWindow.warnEditingWithCca_df('Random Walker segmentation') - txt = f'Random Walker segmentation computed in {deltaT:.3f} s' - print('-----------------') + self.mainWindow.warnEditingWithCca_df("Random Walker segmentation") + txt = f"Random Walker segmentation computed in {deltaT:.3f} s" + print("-----------------") print(txt) - print('=================') + print("=================") # self.mainWindow.titleLabel.setText(txt, color='g') def bkgrSliderMoved(self, intVal): - self.bkgrThreshValLabel.setText(f'{intVal/100:.2f}') + self.bkgrThreshValLabel.setText(f"{intVal / 100:.2f}") self.plotMarkers() def foregrSliderMoved(self, intVal): - self.foregrThreshValLabel.setText(f'{intVal/100:.2f}') + self.foregrThreshValLabel.setText(f"{intVal / 100:.2f}") self.plotMarkers() def closeEvent(self, event): - self.mainWindow.segmModel = '' + self.mainWindow.segmModel = "" self.mainWindow.updateAllImages() + class FutureFramesAction_QDialog(QDialog): def __init__( - self, frame_i, last_tracked_i, change_txt, - applyTrackingB=False, parent=None, - addApplyAllButton=False - ): + self, + frame_i, + last_tracked_i, + change_txt, + applyTrackingB=False, + parent=None, + addApplyAllButton=False, + ): self.decision = None self.last_tracked_i = last_tracked_i super().__init__(parent) - self.setWindowTitle('Future frames action?') + self.setWindowTitle("Future frames action?") mainLayout = QVBoxLayout() txtLayout = QVBoxLayout() @@ -6854,10 +6938,10 @@ def __init__( buttonsLayout = QVBoxLayout() txt = html_utils.paragraph( - 'You already visited/checked future frames ' - f'{frame_i+1}-{last_tracked_i+1}.

' + "You already visited/checked future frames " + f"{frame_i + 1}-{last_tracked_i + 1}.

" f'The requested "{change_txt}" change might result in
' - 'NON-correct segmentation/tracking for those frames.
' + "NON-correct segmentation/tracking for those frames.
" ) txtLabel = QLabel(txt) @@ -6866,19 +6950,21 @@ def __init__( options = [ f'Apply the "{change_txt}" only to current frame and re-initialize
' - 'the future frames to the segmentation file present
' - 'on the hard drive.', - 'Apply only to this frame and keep the future frames as they are.', - 'Apply the change to ALL visited/checked future frames.' + "the future frames to the segmentation file present
" + "on the hard drive.", + "Apply only to this frame and keep the future frames as they are.", + "Apply the change to ALL visited/checked future frames.", ] if addApplyAllButton: - options.append('Apply to ALL future frames including unvisited ones.') + options.append( + "Apply to ALL future frames including unvisited ones." + ) if applyTrackingB: - options.append('Repeat ONLY tracking for all future frames (RECOMMENDED)') + options.append("Repeat ONLY tracking for all future frames (RECOMMENDED)") infoTxt = html_utils.paragraph( - f'Choose one of the following options:' - f'{html_utils.to_list(options, ordered=True)}' + f"Choose one of the following options:" + f"{html_utils.to_list(options, ordered=True)}" ) infotxtLabel = QLabel(infoTxt) @@ -6886,11 +6972,11 @@ def __init__( noteLayout = QHBoxLayout() noteTxt = html_utils.paragraph( - 'Only changes applied to current frame can be undone.
' - 'Changes applied to future frames CANNOT be UNDONE
' + "Only changes applied to current frame can be undone.
" + "Changes applied to future frames CANNOT be UNDONE
" ) noteLayout.addWidget( - QLabel(html_utils.paragraph('NOTE:')), alignment=Qt.AlignTop + QLabel(html_utils.paragraph("NOTE:")), alignment=Qt.AlignTop ) noteTxtLabel = QLabel(noteTxt) noteLayout.addWidget(noteTxtLabel) @@ -6900,46 +6986,46 @@ def __init__( # Do not show this message again checkbox doNotShowCheckbox = QCheckBox( - 'Remember my choice and do not show this message again') + "Remember my choice and do not show this message again" + ) doNotShowLayout.addWidget(doNotShowCheckbox) doNotShowLayout.setContentsMargins(50, 0, 0, 10) self.doNotShowCheckbox = doNotShowCheckbox apply_and_reinit_b = widgets.reloadPushButton( - ' 1. Apply only to this frame and re-initialize future frames' + " 1. Apply only to this frame and re-initialize future frames" ) self.apply_and_reinit_b = apply_and_reinit_b buttonsLayout.addWidget(apply_and_reinit_b) apply_and_NOTreinit_b = widgets.currentPushButton( - ' 2. Apply only to this frame and keep future frames as they are' + " 2. Apply only to this frame and keep future frames as they are" ) self.apply_and_NOTreinit_b = apply_and_NOTreinit_b buttonsLayout.addWidget(apply_and_NOTreinit_b) apply_to_all_visited_b = widgets.futurePushButton( - ' 3. Apply to all future VISITED frames' + " 3. Apply to all future VISITED frames" ) self.apply_to_all_visited_b = apply_to_all_visited_b buttonsLayout.addWidget(apply_to_all_visited_b) - if addApplyAllButton: apply_to_all_b = QPushButton( - ' 4. Apply to ALL future frames (including unvisted)' + " 4. Apply to ALL future frames (including unvisted)" ) - apply_to_all_b.setIcon(QIcon(':arrow_future_all.svg')) + apply_to_all_b.setIcon(QIcon(":arrow_future_all.svg")) self.apply_to_all_b = apply_to_all_b buttonsLayout.addWidget(apply_to_all_b) self.applyTrackingButton = None if applyTrackingB: - n = '5' if addApplyAllButton else '4' + n = "5" if addApplyAllButton else "4" applyTrackingButton = QPushButton( - f' {n}. Repeat ONLY tracking for all future frames' + f" {n}. Repeat ONLY tracking for all future frames" ) - applyTrackingButton.setIcon(QIcon(':repeat-tracking.svg')) + applyTrackingButton.setIcon(QIcon(":repeat-tracking.svg")) self.applyTrackingButton = applyTrackingButton buttonsLayout.addWidget(applyTrackingButton) @@ -6955,7 +7041,7 @@ def __init__( ButtonsGroup.addButton(apply_to_all_b) if applyTrackingB: ButtonsGroup.addButton(applyTrackingButton) - + mainLayout.addLayout(txtLayout) mainLayout.addLayout(doNotShowLayout) mainLayout.addLayout(buttonsLayout) @@ -6972,19 +7058,19 @@ def __init__( def buttonClicked(self, button): if button == self.apply_and_reinit_b: - self.decision = 'apply_and_reinit' + self.decision = "apply_and_reinit" self.endFrame_i = None elif button == self.apply_and_NOTreinit_b: - self.decision = 'apply_and_NOTreinit' + self.decision = "apply_and_NOTreinit" self.endFrame_i = None elif button == self.apply_to_all_visited_b: - self.decision = 'apply_to_all_visited' + self.decision = "apply_to_all_visited" self.endFrame_i = self.last_tracked_i elif button == self.applyTrackingButton: - self.decision = 'only_tracking' + self.decision = "only_tracking" self.endFrame_i = self.last_tracked_i elif button == self.apply_to_all_b: - self.decision = 'apply_to_all' + self.decision = "apply_to_all" self.endFrame_i = self.last_tracked_i self.close() @@ -6995,85 +7081,83 @@ def show(self, block=False): self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) super().show() for button in self.ButtonsGroup.buttons(): - button.setMinimumHeight(int(button.height()*1.2)) - if hasattr(self, 'apply_to_all_b'): + button.setMinimumHeight(int(button.height() * 1.2)) + if hasattr(self, "apply_to_all_b"): iconHeight = self.apply_to_all_b.iconSize().height() - self.apply_to_all_b.setIconSize(QSize(iconHeight*2, iconHeight)) + self.apply_to_all_b.setIconSize(QSize(iconHeight * 2, iconHeight)) if block: self.loop = QEventLoop() self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class ComputeMetricsErrorsDialog(QBaseDialog): - def __init__( - self, errorsDict, log_path='', parent=None, - log_type='custom_metrics' - ): + def __init__(self, errorsDict, log_path="", parent=None, log_type="custom_metrics"): super().__init__(parent) self.errorsDict = errorsDict layout = QGridLayout() - self.setWindowTitle('Errors summary') - + self.setWindowTitle("Errors summary") + label = QLabel(self) - standardIcon = getattr(QStyle, 'SP_MessageBoxWarning') + standardIcon = getattr(QStyle, "SP_MessageBoxWarning") icon = self.style().standardIcon(standardIcon) pixmap = icon.pixmap(60, 60) label.setPixmap(pixmap) layout.addWidget(label, 0, 0, alignment=Qt.AlignTop) - if log_type == 'custom_metrics': - infoText = (""" + if log_type == "custom_metrics": + infoText = """ When computing custom metrics the following metrics were ignored because they raised an error.

- """) - elif log_type == 'standard_metrics': - infoText = (""" + """ + elif log_type == "standard_metrics": + infoText = """ Some or all of the standard metrics were NOT saved because Cell-ACDC encoutered the following errors.

- """) - elif log_type == 'region_props': - rp_url = 'https://scikit-image.org/docs/0.18.x/api/skimage.measure.html#skimage.measure.regionprops' + """ + elif log_type == "region_props": + rp_url = "https://scikit-image.org/docs/0.18.x/api/skimage.measure.html#skimage.measure.regionprops" rp_href = f'skimage.measure.regionprops' - infoText = (f""" + infoText = f""" Region properties were NOT saved because Cell-ACDC encoutered the following errors.
Region properties are calculated using the scikit-image function called {rp_href}.

- """) - elif log_type == 'missing_annot': - infoText = (""" + """ + elif log_type == "missing_annot": + infoText = """ The following Positions were SKIPPED because they did not have cell cycle annotations.

To add lineage tree information you first need to do the cell cycle analysis in module 3 "Main GUI".

- """) + """ else: - infoText = (""" + infoText = """ Process raised the errors listed below.

- """) + """ - github_issues_href = f'here' - noteText = (f""" + github_issues_href = f"here" + noteText = f""" NOTE: If you need help understanding these errors you can open an issue on our github page {github_issues_href}. - """) - - infoLabel = QLabel(html_utils.paragraph(f'{infoText}{noteText}')) + """ + + infoLabel = QLabel(html_utils.paragraph(f"{infoText}{noteText}")) infoLabel.setOpenExternalLinks(True) layout.addWidget(infoLabel, 0, 1) scrollArea = QScrollArea() - scrollAreaWidget = QWidget() + scrollAreaWidget = QWidget() textLayout = QVBoxLayout() for func_name, traceback_format in errorsDict.items(): - nameLabel = QLabel(f'{func_name}: ') - errorMessage = f'\n{traceback_format}' + nameLabel = QLabel(f"{func_name}: ") + errorMessage = f"\n{traceback_format}" errorLabel = QLabel(errorMessage) errorLabel.setTextInteractionFlags( Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard @@ -7084,25 +7168,25 @@ def __init__( textLayout.addWidget(nameLabel) textLayout.addWidget(errorLabel) textLayout.addStretch(1) - + scrollAreaWidget.setLayout(textLayout) scrollArea.setWidget(scrollAreaWidget) - + layout.addWidget(scrollArea, 1, 1) buttonsLayout = QHBoxLayout() - showLogButton = widgets.showInFileManagerButton('Show log file...') + showLogButton = widgets.showInFileManagerButton("Show log file...") buttonsLayout.addStretch(1) buttonsLayout.addWidget(showLogButton) - copyButton = widgets.copyPushButton('Copy error message') + copyButton = widgets.copyPushButton("Copy error message") copyButton.clicked.connect(self.copyErrorMessage) buttonsLayout.addWidget(copyButton) self.copyButton = copyButton - self.copyButton.text = 'Copy error message' + self.copyButton.text = "Copy error message" self.copyButton.icon = self.copyButton.icon() - - okButton = widgets.okPushButton(' Ok ') + + okButton = widgets.okPushButton(" Ok ") buttonsLayout.addSpacing(20) buttonsLayout.addWidget(okButton) @@ -7113,39 +7197,42 @@ def __init__( self.setLayout(layout) self.setFont(font) - + def copyErrorMessage(self): cb = QApplication.clipboard() cb.clear(mode=cb.Clipboard) - copiedText = '' + copiedText = "" for _, traceback_format in self.errorsDict.items(): - errorBlock = f'{"="*30}\n{traceback_format}{"*"*30}' - copiedText = f'{copiedText}{errorBlock}' + errorBlock = f"{'=' * 30}\n{traceback_format}{'*' * 30}" + copiedText = f"{copiedText}{errorBlock}" cb.setText(copiedText, mode=cb.Clipboard) - print('Error message copied.') - self.copyButton.setIcon(QIcon(':okButton.svg')) - self.copyButton.setText(' Copied to clipboard!') + print("Error message copied.") + self.copyButton.setIcon(QIcon(":okButton.svg")) + self.copyButton.setText(" Copied to clipboard!") QTimer.singleShot(2000, self.restoreCopyButton) - + def restoreCopyButton(self): self.copyButton.setText(self.copyButton.text) self.copyButton.setIcon(self.copyButton.icon) - + def showEvent(self, a0) -> None: self.copyButton.setFixedWidth(self.copyButton.width()) return super().showEvent(a0) + class PostProcessSegmParams(QGroupBox): valueChanged = Signal(object) editingFinished = Signal() def __init__( - self, title, posData, - useSliders=False, - parent=None, - maxSize=None, - force_postprocess_2D=False - ): + self, + title, + posData, + useSliders=False, + parent=None, + maxSize=None, + force_postprocess_2D=False, + ): QGroupBox.__init__(self, title, parent) SizeZ = posData.SizeZ self.isSegm3D = posData.isSegm3D @@ -7153,7 +7240,7 @@ def __init__( self.useSliders = useSliders self.force_postprocess_2D = force_postprocess_2D if maxSize is None: - maxSize=2147483647 + maxSize = 2147483647 layout = QGridLayout() @@ -7163,19 +7250,15 @@ def __init__( label = QLabel("Minimum area (pixels) ") layout.addWidget(label, row, 0, alignment=Qt.AlignRight) - minSize_SB = widgets.PostProcessSegmWidget( - 1, 1000, 10, useSliders, label=label - ) - - txt = ( - 'Area is the total number of pixels in the segmented object.' - ) + minSize_SB = widgets.PostProcessSegmWidget(1, 1000, 10, useSliders, label=label) + + txt = "Area is the total number of pixels in the segmented object." layout.addWidget(minSize_SB, row, 1) infoButton = widgets.infoPushButton() infoButton.clicked.connect(self.showInfo) infoButton.tooltip = txt - infoButton.name = 'area' + infoButton.name = "area" infoButton.desc = f'less than "{label.text()}"' layout.addWidget(infoButton, row, 2) self.minSize_SB = minSize_SB @@ -7188,25 +7271,24 @@ def __init__( label = QLabel("Minimum solidity (0-1) ") layout.addWidget(label, row, 0, alignment=Qt.AlignRight) minSolidity_DSB = widgets.PostProcessSegmWidget( - 0, 1.0, 0.5, useSliders, isFloat=True, normalize=True, - label=label + 0, 1.0, 0.5, useSliders, isFloat=True, normalize=True, label=label ) minSolidity_DSB.setValue(0.5) minSolidity_DSB.setSingleStep(0.1) self.controlWidgets.append(minSolidity_DSB) txt = ( - 'Solidity is a measure of convexity. A solidity of 1 means ' - 'that the shape is fully convex (i.e., equal to the convex hull). ' - 'As solidity approaches 0 the object is more concave.
' - 'Write 0 for ignoring this parameter.' + "Solidity is a measure of convexity. A solidity of 1 means " + "that the shape is fully convex (i.e., equal to the convex hull). " + "As solidity approaches 0 the object is more concave.
" + "Write 0 for ignoring this parameter." ) layout.addWidget(minSolidity_DSB, row, 1) infoButton = widgets.infoPushButton() infoButton.clicked.connect(self.showInfo) infoButton.tooltip = txt - infoButton.name = 'solidity' + infoButton.name = "solidity" infoButton.desc = f'less than "{label.text()}"' layout.addWidget(infoButton, row, 2) self.minSolidity_DSB = minSolidity_DSB @@ -7215,23 +7297,22 @@ def __init__( label = QLabel("Max elongation (1=circle) ") layout.addWidget(label, row, 0, alignment=Qt.AlignRight) maxElongation_DSB = widgets.PostProcessSegmWidget( - 0, 100, 3, useSliders, isFloat=True, normalize=False, - label=label + 0, 100, 3, useSliders, isFloat=True, normalize=False, label=label ) maxElongation_DSB.setDecimals(1) maxElongation_DSB.setSingleStep(1.0) txt = ( - 'Elongation is the ratio between major and minor axis lengths. ' - 'An elongation of 1 is like a circle.
' - 'Write 0 for ignoring this parameter.' + "Elongation is the ratio between major and minor axis lengths. " + "An elongation of 1 is like a circle.
" + "Write 0 for ignoring this parameter." ) layout.addWidget(maxElongation_DSB, row, 1) infoButton = widgets.infoPushButton() infoButton.clicked.connect(self.showInfo) infoButton.tooltip = txt - infoButton.name = 'elongation' + infoButton.name = "elongation" infoButton.desc = f'greater than "{label.text()}"' layout.addWidget(infoButton, row, 2) self.maxElongation_DSB = maxElongation_DSB @@ -7242,19 +7323,16 @@ def __init__( label = QLabel("Minimum number of z-slices ") layout.addWidget(label, row, 0, alignment=Qt.AlignRight) minObjSizeZ_SB = widgets.PostProcessSegmWidget( - 0, SizeZ, 3, useSliders, isFloat=False, normalize=False, - label=label + 0, SizeZ, 3, useSliders, isFloat=False, normalize=False, label=label ) - txt = ( - 'Minimum number of z-slices per object.' - ) + txt = "Minimum number of z-slices per object." layout.addWidget(minObjSizeZ_SB, row, 1) infoButton = widgets.infoPushButton() infoButton.clicked.connect(self.showInfo) infoButton.tooltip = txt - infoButton.name = 'number of z-slices' + infoButton.name = "number of z-slices" infoButton.desc = f'less than "{label.text()}"' layout.addWidget(infoButton, row, 2) self.minObjSizeZ_SB = minObjSizeZ_SB @@ -7265,22 +7343,19 @@ def __init__( row += 1 addCustomFeatureLayout = QHBoxLayout() self.addCustomFeaturesButton = widgets.setPushButton( - 'Select custom features for post-processing...', + "Select custom features for post-processing...", ) addCustomFeatureLayout.addWidget(self.addCustomFeaturesButton) addCustomFeatureLayout.addStretch(1) self.selectedFeaturesDialog = SelectFeaturesRangeDialog( - posData=posData, parent=self, - force_postprocess_2D=force_postprocess_2D + posData=posData, parent=self, force_postprocess_2D=force_postprocess_2D ) self.selectedFeaturesDialog.hide() - self.addCustomFeaturesButton.clicked.connect( - self.selectedFeaturesDialog.show - ) + self.addCustomFeaturesButton.clicked.connect(self.selectedFeaturesDialog.show) self.selectedFeaturesDialog.sigValueChanged.connect(self.onValueChanged) - + layout.addLayout(addCustomFeatureLayout, row, 0, 1, 2) - + layout.setColumnStretch(1, 2) # layout.setRowStretch(row+1, 1) @@ -7289,79 +7364,75 @@ def __init__( for widget in self.controlWidgets: widget.valueChanged.connect(self.onValueChanged) widget.editingFinished.connect(self.onEditingFinished) - + def selectedFeaturesRange(self): return self.selectedFeaturesDialog.groupbox.selectedFeaturesRange() def groupedFeatures(self): return self.selectedFeaturesDialog.groupbox.groupedFeatures() - + def restoreDefault(self): self.minSolidity_DSB.setValue(0.5) self.minSize_SB.setValue(10) self.maxElongation_DSB.setValue(3) self.minObjSizeZ_SB.setValue(3) self.selectedFeaturesDialog.groupbox.resetFields() - + def restoreFromKwargs(self, kwargs): for name, value in kwargs.items(): - if name == 'min_solidity': + if name == "min_solidity": self.minSolidity_DSB.setValue(value) - elif name == 'min_area': + elif name == "min_area": self.minSize_SB.setValue(value) - elif name == 'max_elongation': + elif name == "max_elongation": self.maxElongation_DSB.setValue(value) - elif name == 'min_obj_no_zslices': + elif name == "min_obj_no_zslices": self.minObjSizeZ_SB.setValue(value) - + def kwargs(self): kwargs = { - 'min_solidity': self.minSolidity_DSB.value(), - 'min_area': self.minSize_SB.value(), - 'max_elongation': self.maxElongation_DSB.value(), - 'min_obj_no_zslices': self.minObjSizeZ_SB.value() + "min_solidity": self.minSolidity_DSB.value(), + "min_area": self.minSize_SB.value(), + "max_elongation": self.maxElongation_DSB.value(), + "min_obj_no_zslices": self.minObjSizeZ_SB.value(), } return kwargs - + def onValueChanged(self, value): self.valueChanged.emit(value) - + def onEditingFinished(self): self.editingFinished.emit() - + def showInfo(self): - title = f'{self.sender().text()} info' + title = f"{self.sender().text()} info" tooltip = self.sender().tooltip name = self.sender().name desc = self.sender().desc - txt = (f""" + txt = f""" The post-processing step is applied to the output of the segmentation model.

During this step, Cell-ACDC will remove all the objects with {name} {desc}.

{tooltip} - """) + """ if self.isCheckable(): note = f"""" You can deactivate this step by un-checking the checkbox called "Post-processing parameters". """ - txt = f'{txt}{note}' + txt = f"{txt}{note}" msg = widgets.myMessageBox(showCentered=False) msg.information(self, title, html_utils.paragraph(txt)) + class PostProcessSegmDialog(QBaseDialog): sigClosed = Signal() sigValueChanged = Signal(object, object) sigEditingFinished = Signal() sigApplyToAllFutureFrames = Signal(object, object, object) - def __init__( - self, posData, - mainWin=None, - useSliders=True, - maxSize=None - ): + def __init__(self, posData, mainWin=None, useSliders=True, maxSize=None): super().__init__(mainWin) self.cancel = True self.mainWin = mainWin @@ -7371,44 +7442,39 @@ def __init__( self.isMultiPos = len(self.mainWin.data) > 1 self.isTimelapse = self.mainWin.data[self.mainWin.pos_i].SizeT > 1 - self.setWindowTitle('Post-processing segmentation parameters') + self.setWindowTitle("Post-processing segmentation parameters") self.setWindowFlags(Qt.Tool | Qt.WindowStaysOnTopHint) mainLayout = QVBoxLayout() buttonsLayout = QHBoxLayout() self.postProcessGroupbox = PostProcessSegmParams( - 'Post-processing parameters', posData, - useSliders=useSliders, + "Post-processing parameters", + posData, + useSliders=useSliders, maxSize=maxSize, - parent=mainWin + parent=mainWin, ) self.postProcessGroupbox.valueChanged.connect(self.valueChanged) self.postProcessGroupbox.editingFinished.connect(self.onEditingFinished) if self.isTimelapse: - applyAllButton = widgets.futurePushButton( - 'Apply to all frames...' - ) + applyAllButton = widgets.futurePushButton("Apply to all frames...") applyAllButton.clicked.connect(self.applyAll_cb) - applyButton = widgets.okPushButton( - 'Apply', isDefault=False - ) + applyButton = widgets.okPushButton("Apply", isDefault=False) applyButton.clicked.connect(self.apply_cb) elif self.isMultiPos: - applyAllButton = widgets.futurePushButton( - 'Apply to all Positions...' - ) + applyAllButton = widgets.futurePushButton("Apply to all Positions...") applyAllButton.clicked.connect(self.applyAll_cb) - applyButton = widgets.okPushButton('Apply', isDefault=False) + applyButton = widgets.okPushButton("Apply", isDefault=False) applyButton.clicked.connect(self.apply_cb) else: - applyAllButton = widgets.okPushButton('Apply', isDefault=False) + applyAllButton = widgets.okPushButton("Apply", isDefault=False) applyAllButton.clicked.connect(self.ok_cb) applyButton = None - cancelButton = widgets.cancelPushButton('Cancel') + cancelButton = widgets.cancelPushButton("Cancel") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -7416,11 +7482,11 @@ def __init__( if applyButton is not None: buttonsLayout.addWidget(applyButton) buttonsLayout.addWidget(applyAllButton) - + emitEditingFinishedButton = widgets.okPushButton() buttonsLayout.addWidget(emitEditingFinishedButton) emitEditingFinishedButton.hide() - buttonsLayout.setContentsMargins(0,10,0,0) + buttonsLayout.setContentsMargins(0, 10, 0, 0) mainLayout.addWidget(self.postProcessGroupbox) mainLayout.addLayout(buttonsLayout) @@ -7431,7 +7497,7 @@ def __init__( if mainWin is not None: self.setPosData() - + def keyPressEvent(self, event) -> None: return super().keyPressEvent(event) @@ -7445,43 +7511,42 @@ def setPosData(self): # self.img.minMaxValuesMapper = self.mainWin.img1.minMaxValuesMapper self.origLab = self.posData.lab.copy() self.origRp = skimage.measure.regionprops(self.origLab) - self.origObjs = {obj.label:obj for obj in self.origRp} + self.origObjs = {obj.label: obj for obj in self.origRp} def valueChanged(self, value): lab, delObjs = self.apply() self.sigValueChanged.emit(lab, delObjs) - + def apply(self, origLab=None): self.mainWin.warnEditingWithCca_df( - 'post-processing segmentation mask', update_images=False + "post-processing segmentation mask", update_images=False ) ccaAnnotRemoved = self.mainWin.removeCcaAnnotationsCurrentFrame() if ccaAnnotRemoved: self.mainWin.updateAllImages() - if origLab is None: origLab = self.origLab.copy() lab, delIDs = core.post_process_segm( origLab, return_delIDs=True, **self.postProcessGroupbox.kwargs() ) - + if self.postProcessGroupbox.selectedFeaturesRange(): lab, custom_delIDs = features.custom_post_process_segm( - self.posData, - self.postProcessGroupbox.groupedFeatures(), - lab, - self.posData.img_data[self.posData.frame_i], - self.posData.frame_i, - self.posData.filename, + self.posData, + self.postProcessGroupbox.groupedFeatures(), + lab, + self.posData.img_data[self.posData.frame_i], + self.posData.frame_i, + self.posData.filename, self.posData.user_ch_name, - self.postProcessGroupbox.selectedFeaturesRange(), - return_delIDs=True + self.postProcessGroupbox.selectedFeaturesRange(), + return_delIDs=True, ) delIDs.extend(custom_delIDs) - - delObjs = {delID:self.origObjs[delID] for delID in delIDs} + + delObjs = {delID: self.origObjs[delID] for delID in delIDs} return lab, delObjs def onEditingFinished(self): @@ -7503,14 +7568,14 @@ def applyAll_cb(self): self.sigApplyToAllFutureFrames.emit( self.postProcessGroupbox.kwargs(), self.postProcessGroupbox.groupedFeatures(), - self.postProcessGroupbox.selectedFeaturesRange() + self.postProcessGroupbox.selectedFeaturesRange(), ) self.close() def cancel_cb(self): self.cancel = True self.close() - + def undoChanges(self): if self.mainWin is not None: self.posData.lab = self.origLab @@ -7518,18 +7583,18 @@ def undoChanges(self): self.mainWin.updateAllImages() # Undo if changes were applied to all future frames - if hasattr(self, 'origSegmData'): + if hasattr(self, "origSegmData"): if self.isTimelapse: current_frame_i = self.posData.frame_i for frame_i in range(self.posData.segmSizeT): self.posData.frame_i = frame_i origLab = self.origSegmData[frame_i] - lab = self.posData.allData_li[frame_i]['labels'] + lab = self.posData.allData_li[frame_i]["labels"] if lab is None: # Non-visited frame modify segm_data self.posData.segm_data[frame_i] = origLab else: - self.posData.allData_li[frame_i]['labels'] = origLab.copy() + self.posData.allData_li[frame_i]["labels"] = origLab.copy() self.posData.lab = origLab.copy() self.mainWin.update_rp() # Get the rest of the stored metadata based on the new lab @@ -7545,7 +7610,7 @@ def undoChanges(self): for pos_i, posData in enumerate(self.mainWin.data): self.mainWin.pos_i = pos_i origLab = self.origSegmData[pos_i] - self.posData.allData_li[0]['labels'] = lab.copy() + self.posData.allData_li[0]["labels"] = lab.copy() # Get the rest of the stored metadata based on the new lab self.mainWin.get_data() self.mainWin.store_data() @@ -7557,7 +7622,7 @@ def undoChanges(self): def show(self, block=False): # self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) super().show(block=False) - self.resize(int(self.width()*1.5), self.height()) + self.resize(int(self.width() * 1.5), self.height()) super().show(block=block) def closeEvent(self, event): @@ -7566,16 +7631,24 @@ def closeEvent(self, event): self.undoChanges() super().closeEvent(event) + class imageViewer(QMainWindow): """Main Window.""" + sigClosed = Signal() sigHoveringImage = Signal(object, object) def __init__( - self, parent=None, posData=None, button_toUncheck=None, - spinBox=None, linkWindow=None, enableOverlay=False, - isSigleFrame=False, enableMirroredCursor=False - ): + self, + parent=None, + posData=None, + button_toUncheck=None, + spinBox=None, + linkWindow=None, + enableOverlay=False, + isSigleFrame=False, + enableMirroredCursor=False, + ): self.button_toUncheck = button_toUncheck self.parent = parent self.posData = posData @@ -7606,7 +7679,7 @@ def __init__( self.gui_connectActions() self.gui_setSingleFrameMode(self.isSigleFrame) - + self.setupMirroredCursor() mainContainer = QWidget() @@ -7620,7 +7693,7 @@ def __init__( self.frame_i = posData.frame_i self.num_frames = posData.SizeT - + version = myutils.read_version() self.setWindowTitle(f"Cell-ACDC v{version} - {posData.relPath}") @@ -7668,25 +7741,29 @@ def gui_createToolBars(self): if self.linkWindow: # Insert a spacing - editToolBar.addWidget(QLabel(' ')) + editToolBar.addWidget(QLabel(" ")) self.linkWindowCheckbox = QCheckBox("Link to main GUI") self.linkWindowCheckbox.setChecked(True) editToolBar.addWidget(self.linkWindowCheckbox) - + if self.enableMirroredCursor: self.showMirroredCursorCheckbox = QCheckBox( - 'Show mirrored cursor from main window' + "Show mirrored cursor from main window" ) self.showMirroredCursorCheckbox.setChecked(True) editToolBar.addWidget(self.showMirroredCursorCheckbox) def setupMirroredCursor(self): self.cursor = pg.ScatterPlotItem( - symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), - brush=pg.mkBrush('w'), size=16, tip=None + symbol="+", + pxMode=True, + pen=pg.mkPen("k", width=1), + brush=pg.mkBrush("w"), + size=16, + tip=None, ) self.Plot.addItem(self.cursor) - + def gui_connectActions(self): self.exitAction.triggered.connect(self.close) self.prevAction.triggered.connect(self.prev_frame) @@ -7696,7 +7773,7 @@ def gui_connectActions(self): if self.enableOverlay: self.overlayButton.toggled.connect(self.overlay_cb) self.overlayButton.sigRightClick.connect(self.showOverlayContextMenu) - + def gui_setSingleFrameMode(self, isSingleFrame: bool): if not isSingleFrame: return @@ -7733,8 +7810,8 @@ def gui_createGraphics(self): self.Plot = pg.PlotItem() self.Plot.invertY(True) self.Plot.setAspectLocked(True) - self.Plot.hideAxis('bottom') - self.Plot.hideAxis('left') + self.Plot.hideAxis("bottom") + self.Plot.hideAxis("left") self.graphLayout.addItem(self.Plot, row=1, col=1) # Image Item @@ -7742,49 +7819,45 @@ def gui_createGraphics(self): self.img.setEnableAutoLevels(True) self.Plot.addItem(self.img) - #Image histogram + # Image histogram self.imgGrad = widgets.myHistogramLUTitem(isViewer=True) - self.imgGrad.gradient.showMenu = self.showLutItemOverlayContextMenu + self.imgGrad.gradient.showMenu = self.showLutItemOverlayContextMenu self.imgGrad.vb.raiseContextMenu = lambda x: None self.imgGrad.setImageItem(self.img) self.graphLayout.addItem(self.imgGrad, row=1, col=0) # Current frame text - self.frameLabel = pg.LabelItem(justify='center', color='w', size='14pt') - self.frameLabel.setText(' ') + self.frameLabel = pg.LabelItem(justify="center", color="w", size="14pt") + self.frameLabel.setText(" ") self.graphLayout.addItem(self.frameLabel, row=2, col=0, colspan=2) if not self.enableOverlay: return - + def gui_createOverlayItems(self): self.createOverlayChannelsActions() self.overlayLayersItems = {} for ch in self.posData.chNames: if ch == self.parent.user_ch_name: continue - overlayItems = self.getOverlayItems(ch) + overlayItems = self.getOverlayItems(ch) imageItem, lutItem, alphaScrollbar = overlayItems lutItem.vb.raiseContextMenu = lambda x: None - lutItem.gradient.showMenu = self.showLutItemOverlayContextMenu - lutItem.overlayColorButton.sigColorChanging.connect( - self.updateOlColors - ) + lutItem.gradient.showMenu = self.showLutItemOverlayContextMenu + lutItem.overlayColorButton.sigColorChanging.connect(self.updateOlColors) self.addAlphaScrollbar(ch, imageItem, alphaScrollbar) self.overlayLayersItems[ch] = overlayItems self.Plot.addItem(imageItem) - + def createOverlayChannelsActions(self): self.overlayLutItemAdditionalActions = [] separator = QAction(self) separator.setSeparator(True) self.overlayLutItemAdditionalActions.append(separator) - section = self.imgGrad.gradient.menu.addSection( - 'Select channel to adjust: ' - ) + section = self.imgGrad.gradient.menu.addSection("Select channel to adjust: ") self.overlayLutItemAdditionalActions.append(section) self.imgGrad.gradient.menu.removeAction(section) - + self.overlayChNamesActionGroup = QActionGroup(self) self.overlayChNamesActionGroup.setExclusive(True) for chName in self.posData.chNames: @@ -7796,18 +7869,18 @@ def createOverlayChannelsActions(self): self.overlayChNamesActionGroup.triggered.connect( self.chNameGradientActionClicked ) - + def chNameGradientActionClicked(self, action): # Action triggered from lutItem self.checkedOverlayChName = action.text() if action.text() == self.posData.user_ch_name: - self.setOverlayItemsVisible('', False) + self.setOverlayItemsVisible("", False) else: self.setOverlayItemsVisible(action.text(), True) def showLutItemOverlayContextMenu(self, event): lutItem = self.currentLutItem - + for action in self.overlayLutItemAdditionalActions: try: lutItem.gradient.menu.removeAction(action) @@ -7819,11 +7892,11 @@ def showLutItemOverlayContextMenu(self, event): lutItem.gradient.menu.removeAction(action) except Exception as e: pass - - if self.overlayButton.isChecked(): + + if self.overlayButton.isChecked(): for action in self.overlayLutItemAdditionalActions: lutItem.gradient.menu.addAction(action) - + for action in self.overlayChNamesActionGroup.actions(): if action.text() == self.posData.user_ch_name: lutItem.gradient.menu.addAction(action) @@ -7832,7 +7905,7 @@ def showLutItemOverlayContextMenu(self, event): if filename.endswith(action.text()): lutItem.gradient.menu.addAction(action) break - if filename.endswith(f'{action.text()}_aligned'): + if filename.endswith(f"{action.text()}_aligned"): lutItem.gradient.menu.addAction(action) break @@ -7841,7 +7914,6 @@ def showLutItemOverlayContextMenu(self, event): lutItem.gradient.menu.popup(event.screenPos().toPoint()) except AttributeError: lutItem.gradient.menu.popup(event.screenPos()) - def gui_connectImgActions(self): self.img.hoverEvent = self.gui_hoverEventImg @@ -7858,22 +7930,20 @@ def gui_createImgWidgets(self): # self.framesScrollBar.setFixedHeight(20) self.framesScrollBar.setMinimum(1) self.framesScrollBar.setMaximum(posData.SizeT) - t_label = QLabel('frame ') + t_label = QLabel("frame ") _font = QFont() _font.setPixelSize(12) t_label.setFont(_font) - self.img_Widglayout.addWidget( - t_label, 0, 0, alignment=Qt.AlignRight) - self.img_Widglayout.addWidget( - self.framesScrollBar, 0, 1, 1, 20) + self.img_Widglayout.addWidget(t_label, 0, 0, alignment=Qt.AlignRight) + self.img_Widglayout.addWidget(self.framesScrollBar, 0, 1, 1, 20) self.t_label = t_label self.framesScrollBar.valueChanged.connect(self.framesScrollBarMoved) # z-slice scrollbar self.zSliceScrollBar = QScrollBar(Qt.Horizontal) # self.zSliceScrollBar.setFixedHeight(20) - self.zSliceScrollBar.setMaximum(self.posData.SizeZ-1) - _z_label = QLabel('z-slice ') + self.zSliceScrollBar.setMaximum(self.posData.SizeZ - 1) + _z_label = QLabel("z-slice ") _font = QFont() _font.setPixelSize(12) _z_label.setFont(_font) @@ -7885,7 +7955,7 @@ def gui_createImgWidgets(self): self.zSliceScrollBar.setDisabled(True) self.zSliceScrollBar.setVisible(False) _z_label.setVisible(False) - + self.img_Widglayout.setContentsMargins(100, 0, 50, 0) self.zSliceScrollBar.valueChanged.connect(self.update_z_slice) @@ -7897,13 +7967,13 @@ def gui_createImgWidgets(self): self.img.alphaScrollbar = self.addAlphaScrollbar( self.parent.user_ch_name, self.img ) - + def getOverlayItems(self, channelName): imageItem = pg.ImageItem() imageItem.setOpacity(0.5) lutItem = widgets.myHistogramLUTitem(isViewer=True) - + lutItem.setImageItem(imageItem) lutItem.vb.raiseContextMenu = lambda x: None initColor = self.overlayRGBs.pop(0) @@ -7914,37 +7984,36 @@ def getOverlayItems(self, channelName): alphaScrollBar = self.addAlphaScrollbar(channelName, imageItem) return imageItem, lutItem, alphaScrollBar - + def setMirroredCursorPos(self, x, y): if not self.enableMirroredCursor: return - + if not self.showMirroredCursorCheckbox.isChecked(): return self.cursor.setData([x], [y]) - + def setOverlayColors(self): self.overlayRGBs = [ (255, 255, 0), (252, 72, 254), (49, 222, 134), - (22, 108, 27) + (22, 108, 27), ] - cmap = matplotlib.colormaps['gist_rainbow'] + cmap = matplotlib.colormaps["gist_rainbow"] self.overlayRGBs.extend( - [tuple([round(c*255) for c in cmap(i)][:3]) - for i in np.linspace(0,1,8)] + [tuple([round(c * 255) for c in cmap(i)][:3]) for i in np.linspace(0, 1, 8)] ) def setOpacityOverlayLayersItems(self, value, imageItem=None): if imageItem is None: imageItem = self.sender().imageItem - alpha = value/self.sender().maximum() + alpha = value / self.sender().maximum() else: alpha = value imageItem.setOpacity(alpha) - + def overlay_cb(self, checked): if checked: if self.posData.ol_data is None: @@ -7954,7 +8023,7 @@ def overlay_cb(self, checked): self.overlayButton.setChecked(False) self.overlayButton.toggled.connect(self.overlay_cb) return - success = self.parent.loadOverlayData(selectedChannels) + success = self.parent.loadOverlayData(selectedChannels) if not success: return False lastChannel = selectedChannels[-1] @@ -7964,24 +8033,21 @@ def overlay_cb(self, checked): self.img.setOpacity(0.5) self.setCheckedOverlayContextMenusActions(selectedChannels) else: - self.checkedOverlayChName = ( - self.parent.imgGrad.checkedChannelname - ) + self.checkedOverlayChName = self.parent.imgGrad.checkedChannelname selectedChannels = self.parent.checkedOverlayChannels self.setCheckedOverlayContextMenusActions(selectedChannels) self.setOverlayItemsVisible(self.checkedOverlayChName, True) else: self.img.setOpacity(1.0) - self.setOverlayItemsVisible('', False) + self.setOverlayItemsVisible("", False) for items in self.overlayLayersItems.values(): imageItem = items[0] imageItem.clear() self.update_img() - + def createOverlayContextMenu(self): ch_names = [ - ch for ch in self.posData.chNames - if ch != self.posData.user_ch_name + ch for ch in self.posData.chNames if ch != self.posData.user_ch_name ] self.overlayContextMenu = QMenu() self.overlayContextMenu.addSeparator() @@ -7991,7 +8057,7 @@ def createOverlayContextMenu(self): action.setCheckable(True) action.toggled.connect(self.overlayChannelToggled) self.overlayContextMenu.addAction(action) - + def setCheckedOverlayContextMenusActions(self, channelNames): for action in self.overlayContextMenu.actions(): if action.text() not in channelNames: @@ -8007,7 +8073,7 @@ def overlayChannelToggled(self, checked): if channelName not in posData.loadedFluoChannels: self.parent.loadOverlayData([channelName], addToExisting=True) self.setOverlayItemsVisible(channelName, True) - self.checkedOverlayChannels.add(channelName) + self.checkedOverlayChannels.add(channelName) self.updateOlColors(None) else: self.checkedOverlayChannels.remove(channelName) @@ -8017,19 +8083,19 @@ def overlayChannelToggled(self, checked): channelToShow = next(iter(self.checkedOverlayChannels)) self.setOverlayItemsVisible(channelToShow, True) except StopIteration: - self.setOverlayItemsVisible('', False) + self.setOverlayItemsVisible("", False) self.update_img() - + def updateOlColors(self, button): lutItem = self.overlayLayersItems[self.checkedOverlayChName][1] rgb = lutItem.overlayColorButton.color().getRgb()[:3] self.parent.initColormapOverlayLayerItem(rgb, lutItem) lutItem.overlayColorButton.setColor(rgb) - + def addAlphaScrollbar(self, channelName, imageItem, alphaScrollBar=None): if alphaScrollBar is None: alphaScrollBar = QScrollBar(Qt.Horizontal) - label = QLabel(f'Alpha {channelName}') + label = QLabel(f"Alpha {channelName}") label.setFont(font) label.hide() alphaScrollBar.imageItem = imageItem @@ -8040,9 +8106,9 @@ def addAlphaScrollbar(self, channelName, imageItem, alphaScrollBar=None): alphaScrollBar.setMaximum(40) alphaScrollBar.setValue(20) alphaScrollBar.setToolTip( - f'Control the alpha value of the overlaid channel {channelName}.\n' - 'alpha=0 results in NO overlay,\n' - 'alpha=1 results in only fluorescence data visible' + f"Control the alpha value of the overlaid channel {channelName}.\n" + "alpha=0 results in NO overlay,\n" + "alpha=1 results in only fluorescence data visible" ) self.img_Widglayout.addWidget( alphaScrollBar.label, 2, 0, alignment=Qt.AlignRight @@ -8051,14 +8117,14 @@ def addAlphaScrollbar(self, channelName, imageItem, alphaScrollBar=None): sp = alphaScrollBar.label.sizePolicy() sp.setRetainSizeWhenHidden(True) alphaScrollBar.label.setSizePolicy(sp) - + sp = alphaScrollBar.sizePolicy() sp.setRetainSizeWhenHidden(True) alphaScrollBar.setSizePolicy(sp) alphaScrollBar.valueChanged.connect(self.setOpacityOverlayLayersItems) return alphaScrollBar - + def setOverlayItemsVisible(self, channelName, visible): if visible: self.imgGrad.hide() @@ -8081,7 +8147,7 @@ def setOverlayItemsVisible(self, channelName, visible): self.graphLayout.removeItem(lutItem) except Exception as e: pass - + if itemsToShow is None: self.graphLayout.addItem(self.imgGrad, row=1, col=0) self.imgGrad.show() @@ -8116,10 +8182,8 @@ def setOverlayItemsVisible(self, channelName, visible): self.currentLutItem = self.imgGrad def framesScrollBarMoved(self, frame_n): - self.frame_i = frame_n-1 - self.t_label.setText( - f'frame n. {self.frame_i+1}/{self.num_frames}' - ) + self.frame_i = frame_n - 1 + self.t_label.setText(f"frame n. {self.frame_i + 1}/{self.num_frames}") if self.spinBox is not None: self.spinBox.setValue(frame_n) self.update_img() @@ -8133,15 +8197,14 @@ def gui_hoverEventImg(self, event): Y, X = _img.shape if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: val = _img[ydata, xdata] - self.wcLabel.setText(f'(x={x:.2f}, y={y:.2f}, value={val:.2f})') + self.wcLabel.setText(f"(x={x:.2f}, y={y:.2f}, value={val:.2f})") else: - self.wcLabel.setText(f'') + self.wcLabel.setText(f"") except Exception as e: - self.wcLabel.setText(f'') - + self.wcLabel.setText(f"") + emitHovering = ( - self.enableMirroredCursor and - self.showMirroredCursorCheckbox.isChecked() + self.enableMirroredCursor and self.showMirroredCursorCheckbox.isChecked() ) if emitHovering: if event.isExit(): @@ -8152,7 +8215,7 @@ def gui_hoverEventImg(self, event): self.cursor.setData([], []) def next_frame(self): - if self.frame_i < self.num_frames-1: + if self.frame_i < self.num_frames - 1: self.frame_i += 1 else: self.frame_i = 0 @@ -8162,11 +8225,11 @@ def prev_frame(self): if self.frame_i > 0: self.frame_i -= 1 else: - self.frame_i = self.num_frames-1 + self.frame_i = self.num_frames - 1 self.update_img() def skip10ahead_frames(self): - if self.frame_i < self.num_frames-10: + if self.frame_i < self.num_frames - 10: self.frame_i += 10 else: self.frame_i = 0 @@ -8176,7 +8239,7 @@ def skip10back_frames(self): if self.frame_i > 9: self.frame_i -= 10 else: - self.frame_i = self.num_frames-1 + self.frame_i = self.num_frames - 1 self.update_img() def update_z_slice(self, z): @@ -8185,9 +8248,9 @@ def update_z_slice(self, z): else: posData = self.posData idx = (posData.filename, posData.frame_i) - posData.segmInfo_df.at[idx, 'z_slice_used_gui'] = z - - self.z_label.setText(f'z-slice {z+1:02}/{posData.SizeZ}') + posData.segmInfo_df.at[idx, "z_slice_used_gui"] = z + + self.z_label.setText(f"z-slice {z + 1:02}/{posData.SizeZ}") self.img.setCurrentZsliceIndex(z) self.update_img() @@ -8196,60 +8259,59 @@ def getImage(self): frame_i = self.frame_i if posData.SizeZ > 1: idx = (posData.filename, frame_i) - z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] - zProjHow = posData.segmInfo_df.at[idx, 'which_z_proj_gui'] + z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] + zProjHow = posData.segmInfo_df.at[idx, "which_z_proj_gui"] img = posData.img_data[frame_i] - if zProjHow == 'single z-slice': + if zProjHow == "single z-slice": self.zSliceScrollBar.setSliderPosition(z) - self.z_label.setText(f'z-slice {z+1:02}/{posData.SizeZ}') + self.z_label.setText(f"z-slice {z + 1:02}/{posData.SizeZ}") img = img[z].copy() - elif zProjHow == 'max z-projection': + elif zProjHow == "max z-projection": img = img.max(axis=0).copy() - elif zProjHow == 'mean z-projection': + elif zProjHow == "mean z-projection": img = img.mean(axis=0).copy() - elif zProjHow == 'median z-proj.': + elif zProjHow == "median z-proj.": img = np.median(img, axis=0).copy() else: img = posData.img_data[frame_i].copy() return img def update_img(self): - self.frameLabel.setText( - f'Current frame = {self.frame_i+1}/{self.num_frames}' - ) + self.frameLabel.setText(f"Current frame = {self.frame_i + 1}/{self.num_frames}") if self.parent is None: img = self.getImage() else: img = self.parent.getImage(frame_i=self.frame_i, raw=True) - + self.img.setCurrentFrameIndex(self.frame_i) self.img.setImage(img) - self.framesScrollBar.setSliderPosition(self.frame_i+1) - + self.framesScrollBar.setSliderPosition(self.frame_i + 1) + if not self.enableOverlay: return if not self.overlayButton.isChecked(): return - + self.setOverlayImages(frame_i=self.frame_i) - + def askSelectOverlayChannel(self): ch_names = [ - ch for ch in self.posData.chNames - if ch != self.posData.user_ch_name + ch for ch in self.posData.chNames if ch != self.posData.user_ch_name ] selectFluo = widgets.QDialogListbox( - 'Select channel', - 'Select channel names to overlay:\n', - ch_names, multiSelection=True, parent=self + "Select channel", + "Select channel names to overlay:\n", + ch_names, + multiSelection=True, + parent=self, ) selectFluo.exec_() if selectFluo.cancel: return return selectFluo.selectedItemsText - + def setOverlayImages(self, frame_i=None): posData = self.posData for filename in posData.ol_data: @@ -8258,7 +8320,7 @@ def setOverlayImages(self, frame_i=None): ) if chName not in self.checkedOverlayChannels: continue - + imageItem = self.overlayLayersItems[chName][0] ol_img = self.parent.getOlImg(filename, frame_i=frame_i) imageItem.setImage(ol_img) @@ -8279,7 +8341,7 @@ def show(self, left=None, top=None): self.zSliceScrollBar.setFixedHeight(self.parent.h) except Exception as e: pass - + try: self.img.alphaScrollbar.setFixedHeight(self.parent.h) except Exception as e: @@ -8287,19 +8349,27 @@ def show(self, left=None, top=None): if left is not None and top is not None: self.setGeometry(left, top, 850, 800) + class TreeSelectorDialog(QBaseDialog): sigItemDoubleClicked = Signal(object) def __init__( - self, title='Tree selector', infoTxt='', parent=None, - multiSelection=True, widthFactor=None, heightFactor=None, - expandOnDoubleClick=False, isTopLevelSelectable=True, - allItemsExpanded=True, allowNoSelection=True - ): + self, + title="Tree selector", + infoTxt="", + parent=None, + multiSelection=True, + widthFactor=None, + heightFactor=None, + expandOnDoubleClick=False, + isTopLevelSelectable=True, + allItemsExpanded=True, + allowNoSelection=True, + ): super().__init__(parent) self.setWindowTitle(title) - + self.cancel = True self.widthFactor = widthFactor self.heightFactor = heightFactor @@ -8310,7 +8380,7 @@ def __init__( if infoTxt: self.mainLayout.addWidget(QLabel(html_utils.paragraph(infoTxt))) - + self.treeWidget = widgets.TreeWidget(multiSelection=multiSelection) self.treeWidget.setExpandsOnDoubleClick(expandOnDoubleClick) self.treeWidget.setHeaderHidden(True) @@ -8330,16 +8400,16 @@ def __init__( self.treeWidget.itemClicked.connect(self.onItemClicked) self.treeWidget.itemDoubleClicked.connect(self.onItemDoubleClicked) - + def onItemDoubleClicked(self, item): self.sigItemDoubleClicked.emit(item) - + def onItemClicked(self, item): if self._isTopLevelSelectable: return if item.parent() is None: item.setSelected(False) - + def addTree(self, tree: dict): for topLevel, children in tree.items(): topLevelItem = widgets.TreeWidgetItem(self.treeWidget) @@ -8350,7 +8420,7 @@ def addTree(self, tree: dict): if not self.allItemsExpanded: continue topLevelItem.setExpanded(True) - + def resizeVertical(self): if not self.isVisible(): self.show() @@ -8365,11 +8435,11 @@ def resizeVertical(self): childItem = topLevelItem.child(j) rect = self.treeWidget.visualItemRect(childItem) treeWidgetHeight += rect.height() - + deltaHeight = treeWidgetHeight - currentTreeWidgetHeight + 10 self.resize(self.width(), self.height() + deltaHeight) self.move(self.x(), 20) - + def setCurrentItem(self, itemText: dict): if not itemText: return @@ -8387,7 +8457,7 @@ def setCurrentItem(self, itemText: dict): topLevelItem.setExpanded(True) self.treeWidget.scrollToItem(topLevelItem) break - + def selectedItems(self): self._selectedItems = {} for i in range(self.treeWidget.topLevelItemCount()): @@ -8410,31 +8480,31 @@ def warnSelectionIsEmpty(self): Thanks! """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Selection is empty', txt) - + msg.warning(self, "Selection is empty", txt) + def ok_cb(self): if not self.allowNoSelection and not self.selectedItems(): self.warnSelectionIsEmpty() return self.cancel = False self.close() - + def showEvent(self, event) -> None: super().showEvent(event) if self.widthFactor is not None: - self.resize(int(self.width()*self.widthFactor), self.height()) + self.resize(int(self.width() * self.widthFactor), self.height()) if self.heightFactor is not None: - self.resize(self.width(), int(self.height()*self.heightFactor)) + self.resize(self.width(), int(self.height() * self.heightFactor)) + class TreesSelectorDialog(QBaseDialog): def __init__( - self, trees, groupsDescr=None, title='Trees selector', - infoTxt='', parent=None - ): + self, trees, groupsDescr=None, title="Trees selector", infoTxt="", parent=None + ): super().__init__(parent) self.setWindowTitle(title) - + self.cancel = True self.mainLayout = QVBoxLayout() @@ -8443,13 +8513,13 @@ def __init__( self.treeWidgets = {} self.setLayout(self.mainLayout) - + createdGroupLayouts = {} for treeName, tree in trees.items(): if groupsDescr is None: - groupName = '' + groupName = "" else: - groupName = groupsDescr.get(treeName, 'Group info missing') + groupName = groupsDescr.get(treeName, "Group info missing") groupLayout = createdGroupLayouts.get(groupName, None) if groupLayout is None: self.mainLayout.addWidget(QLabel(html_utils.paragraph(groupName))) @@ -8473,7 +8543,7 @@ def __init__( self.treeWidgets[treeName] = treeWidget groupLayout.addWidget(treeWidget) self.mainLayout.addSpacing(20) - + buttonsLayout = widgets.CancelOkButtonsLayout() buttonsLayout.okButton.clicked.connect(self.ok_cb) @@ -8481,7 +8551,7 @@ def __init__( self.mainLayout.addSpacing(10) self.mainLayout.addLayout(buttonsLayout) - + def ok_cb(self): self.cancel = False self.selectedItems = {} @@ -8501,13 +8571,17 @@ def ok_cb(self): class MultiListSelector(QBaseDialog): def __init__( - self, lists: dict, groupsDescr: dict=None, - title='Lists selector', infoTxt='', parent=None - ): + self, + lists: dict, + groupsDescr: dict = None, + title="Lists selector", + infoTxt="", + parent=None, + ): super().__init__(parent) self.setWindowTitle(title) - + self.cancel = True mainLayout = QVBoxLayout() @@ -8518,9 +8592,9 @@ def __init__( createdGroupLayouts = {} for listName, listItems in lists.items(): if groupsDescr is None: - groupName = '' + groupName = "" else: - groupName = groupsDescr.get(listName, 'Group info missing') + groupName = groupsDescr.get(listName, "Group info missing") groupLayout = createdGroupLayouts.get(listName, None) if groupLayout is None: mainLayout.addWidget(QLabel(html_utils.paragraph(groupName))) @@ -8533,12 +8607,14 @@ def __init__( groupLayout.addSpacing(10) groupLayout.addWidget(QLabel(html_utils.paragraph(listName))) listWidget = widgets.listWidget() - listWidget.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + listWidget.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) listWidget.addItems(listItems) groupLayout.addWidget(listWidget) mainLayout.addSpacing(20) self.listWidgets[listName] = listWidget - + buttonsLayout = widgets.CancelOkButtonsLayout() buttonsLayout.okButton.clicked.connect(self.ok_cb) @@ -8548,7 +8624,7 @@ def __init__( mainLayout.addLayout(buttonsLayout) self.setLayout(mainLayout) - + def ok_cb(self): self.cancel = False self.selectedItems = {} @@ -8560,8 +8636,9 @@ def ok_cb(self): ] self.close() + class selectPositionsMultiExp(QBaseDialog): - def __init__(self, expPaths: dict, infoPaths: dict=None, parent=None): + def __init__(self, expPaths: dict, infoPaths: dict = None, parent=None): super().__init__(parent=parent) self.expPaths = expPaths @@ -8569,14 +8646,14 @@ def __init__(self, expPaths: dict, infoPaths: dict=None, parent=None): mainLayout = QVBoxLayout() - self.setWindowTitle('Select Positions to process') + self.setWindowTitle("Select Positions to process") infoTxt = html_utils.paragraph( - 'Select one or more Positions to process

' - 'Click on experiment path to select all positions
' - 'Ctrl+Click to select multiple items
' - 'Shift+Click to select a range of items
', - center=True + "Select one or more Positions to process

" + "Click on experiment path to select all positions
" + "Ctrl+Click to select multiple items
" + "Shift+Click to select a range of items
", + center=True, ) infoLabel = QLabel(infoTxt) @@ -8593,7 +8670,7 @@ def __init__(self, expPaths: dict, infoPaths: dict=None, parent=None): posFoldersInfo = infoPaths.get(exp_path) if len(pathLevels) > 4: itemText = os.path.join(*pathLevels[-4:]) - itemText = f'...{itemText}' + itemText = f"...{itemText}" else: itemText = exp_path exp_path_item = QTreeWidgetItem([itemText]) @@ -8603,10 +8680,10 @@ def __init__(self, expPaths: dict, infoPaths: dict=None, parent=None): postions_items = [] for pos in positions: if posFoldersInfo is not None: - status = posFoldersInfo.get(pos, '') + status = posFoldersInfo.get(pos, "") else: - status = '' - pos_item_text = f'{pos}{status}' + status = "" + pos_item_text = f"{pos}{status}" pos_item = QTreeWidgetItem(exp_path_item, [pos_item_text]) pos_item.posFoldername = pos postions_items.append(pos_item) @@ -8616,8 +8693,8 @@ def __init__(self, expPaths: dict, infoPaths: dict=None, parent=None): self.treeWidget.itemClicked.connect(self.selectAllChildren) buttonsLayout = QHBoxLayout() - cancelButton = widgets.cancelPushButton('Cancel') - okButton = widgets.okPushButton(' Ok ') + cancelButton = widgets.cancelPushButton("Cancel") + okButton = widgets.okPushButton(" Ok ") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -8646,8 +8723,8 @@ def selectAllChildren(self, item, col): def ok_cb(self): if not self.treeWidget.selectedItems(): msg = widgets.myMessageBox(wrapText=False) - txt = 'You did not select any experiment/Position folder!' - msg.warning(self, 'Empty selection!', html_utils.paragraph(txt)) + txt = "You did not select any experiment/Position folder!" + msg.warning(self, "Empty selection!", html_utils.paragraph(txt)) return self.cancel = False @@ -8665,16 +8742,20 @@ def ok_cb(self): self.close() def showEvent(self, event): - self.resize(int(self.width()*2), self.height()) + self.resize(int(self.width() * 2), self.height()) class editCcaTableWidget(QDialog): sigApplyChangesFutureFrames = Signal(object, int) - + def __init__( - self, cca_df, SizeT, title='Edit cell cycle annotations', - parent=None, current_frame_i=0 - ): + self, + cca_df, + SizeT, + title="Edit cell cycle annotations", + parent=None, + current_frame_i=0, + ): self.inputCca_df = cca_df self.cancel = True self.SizeT = SizeT @@ -8697,60 +8778,60 @@ def __init__( # Header labels col = 0 row = 0 - IDsLabel = QLabel('Cell ID') + IDsLabel = QLabel("Cell ID") AC = Qt.AlignCenter IDsLabel.setAlignment(AC) headerLayout.addWidget(IDsLabel, 0, col, alignment=AC) col += 1 - ccsLabel = QLabel('Cell cycle stage') + ccsLabel = QLabel("Cell cycle stage") ccsLabel.setAlignment(Qt.AlignCenter) headerLayout.addWidget(ccsLabel, 0, col, alignment=AC) col += 1 - relIDLabel = QLabel('Relative ID') + relIDLabel = QLabel("Relative ID") relIDLabel.setAlignment(Qt.AlignCenter) headerLayout.addWidget(relIDLabel, 0, col, alignment=AC) col += 1 - genNumLabel = QLabel('Generation number') + genNumLabel = QLabel("Generation number") genNumLabel.setAlignment(Qt.AlignCenter) headerLayout.addWidget(genNumLabel, 0, col, alignment=AC) genNumColWidth = genNumLabel.sizeHint().width() col += 1 - relationshipLabel = QLabel('Relationship') + relationshipLabel = QLabel("Relationship") relationshipLabel.setAlignment(Qt.AlignCenter) headerLayout.addWidget(relationshipLabel, 0, col, alignment=AC) col += 1 - emergFrameLabel = QLabel('Emerging frame num.') + emergFrameLabel = QLabel("Emerging frame num.") emergFrameLabel.setAlignment(Qt.AlignCenter) headerLayout.addWidget(emergFrameLabel, 0, col, alignment=AC) col += 1 - divitionFrameLabel = QLabel('Division frame num.') + divitionFrameLabel = QLabel("Division frame num.") divitionFrameLabel.setAlignment(Qt.AlignCenter) headerLayout.addWidget(divitionFrameLabel, 0, col, alignment=AC) col += 1 - historyKnownLabel = QLabel('Is history known?') + historyKnownLabel = QLabel("Is history known?") historyKnownLabel.setAlignment(Qt.AlignCenter) headerLayout.addWidget(historyKnownLabel, 0, col, alignment=AC) - + self.headerLayout = headerLayout tableLayout.setHorizontalSpacing(20) self.tableLayout = tableLayout # Add buttons - cancelButton = widgets.cancelPushButton('Cancel') - moreInfoButton = widgets.helpPushButton('More info...') - moreInfoButton.setIcon(QIcon(':info.svg')) + cancelButton = widgets.cancelPushButton("Cancel") + moreInfoButton = widgets.helpPushButton("More info...") + moreInfoButton.setIcon(QIcon(":info.svg")) applyToFutureFramesbutton = widgets.futurePushButton( - 'Apply changes to future frames...' + "Apply changes to future frames..." ) - okButton = widgets.okPushButton('Ok') + okButton = widgets.okPushButton("Ok") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -8777,7 +8858,7 @@ def __init__( IDs = cca_df.index self.IDs = IDs.to_list() relIDsOptions = [str(ID) for ID in IDs] - relIDsOptions.insert(0, '-1') + relIDsOptions.insert(0, "-1") self.IDlabels = [] self.ccsComboBoxes = [] self.genNumSpinBoxes = [] @@ -8790,27 +8871,27 @@ def __init__( self.historyKnownCheckBoxes = [] for row, ID in enumerate(IDs): col = 0 - IDlabel = QLabel(f'{ID}') + IDlabel = QLabel(f"{ID}") IDlabel.setAlignment(Qt.AlignCenter) - tableLayout.addWidget(IDlabel, row+1, col, alignment=AC) + tableLayout.addWidget(IDlabel, row + 1, col, alignment=AC) self.IDlabels.append(IDlabel) col += 1 ccsComboBox = QComboBox() ccsComboBox.setFocusPolicy(Qt.StrongFocus) ccsComboBox.installEventFilter(self) - ccsComboBox.addItems(['G1', 'S/G2/M']) - ccsValue = cca_df.at[ID, 'cell_cycle_stage'] - if ccsValue == 'S': - ccsValue = 'S/G2/M' - + ccsComboBox.addItems(["G1", "S/G2/M"]) + ccsValue = cca_df.at[ID, "cell_cycle_stage"] + if ccsValue == "S": + ccsValue = "S/G2/M" + try: ccsComboBox.setCurrentText(ccsValue) except Exception as err: printl(ccsValue) printl(cca_df) raise err - tableLayout.addWidget(ccsComboBox, row+1, col, alignment=AC) + tableLayout.addWidget(ccsComboBox, row + 1, col, alignment=AC) self.ccsComboBoxes.append(ccsComboBox) ccsComboBox.activated.connect(self.clearComboboxFocus) @@ -8819,8 +8900,8 @@ def __init__( relIDComboBox.setFocusPolicy(Qt.StrongFocus) relIDComboBox.installEventFilter(self) relIDComboBox.addItems(relIDsOptions) - relIDComboBox.setCurrentText(str(cca_df.at[ID, 'relative_ID'])) - tableLayout.addWidget(relIDComboBox, row+1, col) + relIDComboBox.setCurrentText(str(cca_df.at[ID, "relative_ID"])) + tableLayout.addWidget(relIDComboBox, row + 1, col) self.relIDComboBoxes.append(relIDComboBox) relIDComboBox.currentIndexChanged.connect(self.setRelID) relIDComboBox.activated.connect(self.clearComboboxFocus) @@ -8832,23 +8913,22 @@ def __init__( genNumSpinBox.setValue(2) genNumSpinBox.setMaximum(2147483647) genNumSpinBox.setAlignment(Qt.AlignCenter) - genNumSpinBox.setFixedWidth(int(genNumColWidth*2/3)) - genNumSpinBox.setValue(int(cca_df.at[ID, 'generation_num'])) - tableLayout.addWidget(genNumSpinBox, row+1, col, alignment=AC) + genNumSpinBox.setFixedWidth(int(genNumColWidth * 2 / 3)) + genNumSpinBox.setValue(int(cca_df.at[ID, "generation_num"])) + tableLayout.addWidget(genNumSpinBox, row + 1, col, alignment=AC) self.genNumSpinBoxes.append(genNumSpinBox) col += 1 relationshipComboBox = QComboBox() relationshipComboBox.setFocusPolicy(Qt.StrongFocus) relationshipComboBox.installEventFilter(self) - relationshipComboBox.addItems(['mother', 'bud']) - relationshipComboBox.setCurrentText( - str(cca_df.at[ID, 'relationship']) - ) - tableLayout.addWidget(relationshipComboBox, row+1, col) + relationshipComboBox.addItems(["mother", "bud"]) + relationshipComboBox.setCurrentText(str(cca_df.at[ID, "relationship"])) + tableLayout.addWidget(relationshipComboBox, row + 1, col) self.relationshipComboBoxes.append(relationshipComboBox) relationshipComboBox.currentIndexChanged.connect( - self.relationshipChanged_cb) + self.relationshipChanged_cb + ) relationshipComboBox.activated.connect(self.clearComboboxFocus) col += 1 @@ -8859,16 +8939,15 @@ def __init__( emergFrameSpinBox.setMinimum(-1) emergFrameSpinBox.setValue(-1) emergFrameSpinBox.setAlignment(Qt.AlignCenter) - emergFrameSpinBox.setFixedWidth(int(genNumColWidth*2/3)) - emergFrame_i = cca_df.at[ID, 'emerg_frame_i'] - val = emergFrame_i+1 if emergFrame_i>=0 else -1 + emergFrameSpinBox.setFixedWidth(int(genNumColWidth * 2 / 3)) + emergFrame_i = cca_df.at[ID, "emerg_frame_i"] + val = emergFrame_i + 1 if emergFrame_i >= 0 else -1 emergFrameSpinBox.setValue(val) - tableLayout.addWidget(emergFrameSpinBox, row+1, col, alignment=AC) + tableLayout.addWidget(emergFrameSpinBox, row + 1, col, alignment=AC) self.emergFrameSpinBoxes.append(emergFrameSpinBox) self.emergFrameSpinPrevValues.append(emergFrameSpinBox.value()) emergFrameSpinBox.valueChanged.connect(self.skip0emergFrame) - col += 1 divisFrameSpinBox = widgets.SpinBox() divisFrameSpinBox.setFocusPolicy(Qt.StrongFocus) @@ -8877,19 +8956,19 @@ def __init__( divisFrameSpinBox.setMaximum(SizeT) divisFrameSpinBox.setValue(-1) divisFrameSpinBox.setAlignment(Qt.AlignCenter) - divisFrameSpinBox.setFixedWidth(int(genNumColWidth*2/3)) - divisFrame_i = int(cca_df.at[ID, 'division_frame_i']) - val = divisFrame_i+1 if divisFrame_i>=0 else -1 + divisFrameSpinBox.setFixedWidth(int(genNumColWidth * 2 / 3)) + divisFrame_i = int(cca_df.at[ID, "division_frame_i"]) + val = divisFrame_i + 1 if divisFrame_i >= 0 else -1 divisFrameSpinBox.setValue(val) - tableLayout.addWidget(divisFrameSpinBox, row+1, col, alignment=AC) + tableLayout.addWidget(divisFrameSpinBox, row + 1, col, alignment=AC) self.divisFrameSpinBoxes.append(divisFrameSpinBox) self.divisFrameSpinPrevValues.append(divisFrameSpinBox.value()) divisFrameSpinBox.valueChanged.connect(self.skip0divisFrame) col += 1 HistoryCheckBox = QCheckBox() - HistoryCheckBox.setChecked(bool(cca_df.at[ID, 'is_history_known'])) - tableLayout.addWidget(HistoryCheckBox, row+1, col, alignment=AC) + HistoryCheckBox.setChecked(bool(cca_df.at[ID, "is_history_known"])) + tableLayout.addWidget(HistoryCheckBox, row + 1, col, alignment=AC) self.historyKnownCheckBoxes.append(HistoryCheckBox) self.setLayout(mainLayout) @@ -8901,7 +8980,7 @@ def __init__( applyToFutureFramesbutton.clicked.connect(self.applyToFutureFrames) # self.setModal(True) - + def getChanges(self): newCcaDf = self.getCca_df() changes = {} @@ -8912,29 +8991,33 @@ def getChanges(self): newValue = getattr(row, col) if newValue == inputValue: continue - + if ID not in changes: changes[ID] = {col: (inputValue, newValue)} else: changes[ID][col] = (inputValue, newValue) return changes - def applyToFutureFrames(self): - txt = 'Enter up to which frame you want to apply the changes
' + def applyToFutureFrames(self): + txt = "Enter up to which frame you want to apply the changes
" win = NumericEntryDialog( - title='Stop frame', instructions=txt, parent=self, minValue=1, - maxValue=self.SizeT, currentValue=self.current_frame_i + title="Stop frame", + instructions=txt, + parent=self, + minValue=1, + maxValue=self.SizeT, + currentValue=self.current_frame_i, ) win.exec_() if win.cancel: return - + stop_frame_i = win.value changes = self.getChanges() changes_format = myutils.format_cca_manual_changes(changes) detailsText = ( - f'Changes that will be applied from frame n. {self.current_frame_i+1}' - f' to frame n. {stop_frame_i+1}:\n\n{changes_format}' + f"Changes that will be applied from frame n. {self.current_frame_i + 1}" + f" to frame n. {stop_frame_i + 1}:\n\n{changes_format}" ) txt = html_utils.paragraph(""" Use this feature with caution!

@@ -8943,23 +9026,21 @@ def applyToFutureFrames(self): """) msg = widgets.myMessageBox(wrapText=False) msg.setDetailedText(detailsText, visible=True) - msg.warning( - self, 'Caution!', txt, buttonsTexts=('Yes, I am sure', 'Cancel') - ) + msg.warning(self, "Caution!", txt, buttonsTexts=("Yes, I am sure", "Cancel")) if msg.cancel: return - - self.sigApplyChangesFutureFrames.emit(changes, stop_frame_i) - + + self.sigApplyChangesFutureFrames.emit(changes, stop_frame_i) + def moreInfo(self, checked=True): desc = myutils.get_cca_colname_desc() msg = widgets.myMessageBox(parent=self) - msg.setWindowTitle('Cell cycle annotations info') + msg.setWindowTitle("Cell cycle annotations info") msg.setWidth(400) msg.setIcon() for col, txt in desc.items(): - msg.addText(html_utils.paragraph(f'{col}: {txt}')) - msg.addButton(' Ok ') + msg.addText(html_utils.paragraph(f"{col}: {txt}")) + msg.addButton(" Ok ") msg.exec_() def setRelID(self, itemIndex): @@ -8993,82 +9074,87 @@ def skip0divisFrame(self, value): def relationshipChanged_cb(self, itemIndex): idx = self.relationshipComboBoxes.index(self.sender()) ccs = self.sender().currentText() - if ccs == 'bud': - self.ccsComboBoxes[idx].setCurrentText('S/G2/M') + if ccs == "bud": + self.ccsComboBoxes[idx].setCurrentText("S/G2/M") self.genNumSpinBoxes[idx].setValue(0) def getCca_df(self): ccsValues = [var.currentText() for var in self.ccsComboBoxes] - ccsValues = [val if val=='G1' else 'S' for val in ccsValues] + ccsValues = [val if val == "G1" else "S" for val in ccsValues] genNumValues = [var.value() for var in self.genNumSpinBoxes] relIDValues = [int(var.currentText()) for var in self.relIDComboBoxes] relatValues = [var.currentText() for var in self.relationshipComboBoxes] emergFrameValues = [ - var.value()-1 if var.value()>0 else -1 + var.value() - 1 if var.value() > 0 else -1 for var in self.emergFrameSpinBoxes ] divisFrameValues = [ - var.value()-1 if var.value()>0 else -1 + var.value() - 1 if var.value() > 0 else -1 for var in self.divisFrameSpinBoxes ] - historyValues = [ - var.isChecked() for var in self.historyKnownCheckBoxes - ] + historyValues = [var.isChecked() for var in self.historyKnownCheckBoxes] check_rel = [ID == relID for ID, relID in zip(self.IDs, relIDValues)] - + # Buds in S phase must have 0 as number of cycles check_buds_S = [ - ccs=='S' and rel_ship=='bud' and not numc==0 - for ccs, rel_ship, numc - in zip(ccsValues, relatValues, genNumValues) + ccs == "S" and rel_ship == "bud" and not numc == 0 + for ccs, rel_ship, numc in zip(ccsValues, relatValues, genNumValues) ] - + # Mother cells must have at least 1 as number of cycles if history known check_mothers = [ - rel_ship=='mother' and not numc>=1 - if is_history_known else False - for rel_ship, numc, is_history_known - in zip(relatValues, genNumValues, historyValues) + rel_ship == "mother" and not numc >= 1 if is_history_known else False + for rel_ship, numc, is_history_known in zip( + relatValues, genNumValues, historyValues + ) ] - + # Buds cannot be in G1 check_buds_G1 = [ - ccs=='G1' and rel_ship=='bud' for ccs, rel_ship - in zip(ccsValues, relatValues) + ccs == "G1" and rel_ship == "bud" + for ccs, rel_ship in zip(ccsValues, relatValues) ] - + # The number of cells in S phase must be half mothers and half buds - num_moth_S = len([ - 0 for ccs, rel_ship in zip(ccsValues, relatValues) - if ccs=='S' and rel_ship=='mother' - ]) - num_bud_S = len([ - 0 for ccs, rel_ship in zip(ccsValues, relatValues) - if ccs=='S' and rel_ship=='bud' - ]) - + num_moth_S = len( + [ + 0 + for ccs, rel_ship in zip(ccsValues, relatValues) + if ccs == "S" and rel_ship == "mother" + ] + ) + num_bud_S = len( + [ + 0 + for ccs, rel_ship in zip(ccsValues, relatValues) + if ccs == "S" and rel_ship == "bud" + ] + ) + # Cells in S phase cannot have -1 as relative's ID check_relID_S = [ - ccs=='S' and relID==-1 - for ccs, relID in zip(ccsValues, relIDValues) + ccs == "S" and relID == -1 for ccs, relID in zip(ccsValues, relIDValues) ] - + # Mother cells with unknown history at emergence is recommended to have # generation number = 2 (easier downstream analysis) check_unknown_mothers = [ - rel_ship=='mother' and not is_history_known and gen_num!=2 - and (emerg_frame_i == self.current_frame_i or self.current_frame_i==0) - for rel_ship, is_history_known, gen_num, emerg_frame_i - in zip(relatValues, historyValues, genNumValues, emergFrameValues) + rel_ship == "mother" + and not is_history_known + and gen_num != 2 + and (emerg_frame_i == self.current_frame_i or self.current_frame_i == 0) + for rel_ship, is_history_known, gen_num, emerg_frame_i in zip( + relatValues, historyValues, genNumValues, emergFrameValues + ) ] - + if any(check_rel): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(""" Some cells are mother or bud of itself!

Make sure that the relative ID is different from the Cell ID. """) - msg.critical(self, 'Some IDs are equal to relative ID', txt) + msg.critical(self, "Some IDs are equal to relative ID", txt) return None elif any(check_unknown_mothers): txt = html_utils.paragraph(""" @@ -9080,30 +9166,26 @@ def getCca_df(self): makes downstream analysis easier.

What do you want to do? """) - correctButtonText = ' Fine, let me correct. ' - keepButtonText = ' Keep the generation number that I chose. ' + correctButtonText = " Fine, let me correct. " + keepButtonText = " Keep the generation number that I chose. " buttonsTexts = (correctButtonText, keepButtonText) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.warning(self, 'Recommendation', txt, buttonsTexts=buttonsTexts) + msg.warning(self, "Recommendation", txt, buttonsTexts=buttonsTexts) if msg.cancel or msg.clickedButton == correctButtonText: return None elif any(check_buds_S): msg = widgets.myMessageBox(wrapText=False) - title = ( - 'Bud in S/G2/M not in 0 Generation number' - ) + title = "Bud in S/G2/M not in 0 Generation number" txt = html_utils.paragraph( - 'Some buds ' - 'in S phase do not have 0 as Generation number!
' + "Some buds " + "in S phase do not have 0 as Generation number!
" 'Buds in S phase must have 0 as "Generation number"' ) msg.critical(self, title, txt) return None elif any(check_mothers): msg = widgets.myMessageBox(wrapText=False) - title = ( - 'Mother not in >=1 Generation number' - ) + title = "Mother not in >=1 Generation number" txt = html_utils.paragraph( 'Some mother cells do not have >=1 as "Generation number"!
' 'Mothers MUST have >1 "Generation number"' @@ -9112,69 +9194,64 @@ def getCca_df(self): return None elif any(check_buds_G1): msg = widgets.myMessageBox(wrapText=False) - title = ( - 'Buds in G1!' - ) + title = "Buds in G1!" txt = html_utils.paragraph( - 'Some buds are in G1 phase!

' - 'Buds MUST be in S/G2/M phase' + "Some buds are in G1 phase!

Buds MUST be in S/G2/M phase" ) msg.critical(self, title, txt) return None elif num_moth_S != num_bud_S: msg = widgets.myMessageBox(wrapText=False) - title = ( - 'Number of mothers-buds mismatch!' - ) + title = "Number of mothers-buds mismatch!" txt = html_utils.paragraph( f'There are {num_moth_S} mother cells in "S/G2/M" phase,' - f'but there are {num_bud_S} bud cells.

' + f"but there are {num_bud_S} bud cells.

" 'The number of mothers and buds in "S/G2/M" ' - 'phase must be equal!' + "phase must be equal!" ) msg.critical(self, title, txt) return None elif any(check_relID_S): msg = widgets.myMessageBox(wrapText=False) - title = ( - 'Relative\'s ID of cells in S/G2/M = -1' - ) + title = "Relative's ID of cells in S/G2/M = -1" txt = html_utils.paragraph( 'Some cells are in "S/G2/M" phase but have -1 as Relative\'s ID!
' 'Cells in "S/G2/M" phase must have an existing ' - 'ID as Relative\'s ID!' + "ID as Relative's ID!" ) msg.critical(self, title, txt) return None - - corrected_on_frame_i = self.inputCca_df['corrected_on_frame_i'] - cca_df = pd.DataFrame({ - 'cell_cycle_stage': ccsValues, - 'generation_num': genNumValues, - 'relative_ID': relIDValues, - 'relationship': relatValues, - 'emerg_frame_i': emergFrameValues, - 'division_frame_i': divisFrameValues, - 'is_history_known': historyValues, - 'corrected_on_frame_i': corrected_on_frame_i, - 'will_divide': self.inputCca_df['will_divide'], - }, index=self.IDs - ) - cca_df.index.name = 'Cell_ID' - + + corrected_on_frame_i = self.inputCca_df["corrected_on_frame_i"] + cca_df = pd.DataFrame( + { + "cell_cycle_stage": ccsValues, + "generation_num": genNumValues, + "relative_ID": relIDValues, + "relationship": relatValues, + "emerg_frame_i": emergFrameValues, + "division_frame_i": divisFrameValues, + "is_history_known": historyValues, + "corrected_on_frame_i": corrected_on_frame_i, + "will_divide": self.inputCca_df["will_divide"], + }, + index=self.IDs, + ) + cca_df.index.name = "Cell_ID" + # Add missing columns for column, default in base_cca_dict.items(): if column in cca_df.columns: continue - + value = self.inputCca_df.get(column, default=default) cca_df[column] = value - + # Check that every pair of cells in S are relative of each other proceed = self.check_ID_rel_ID_mismatches(cca_df) if not proceed: return None - + d = dict.fromkeys(cca_df.select_dtypes(np.int64).columns, np.int32) cca_df = cca_df.astype(d) return cca_df @@ -9182,33 +9259,32 @@ def getCca_df(self): def check_ID_rel_ID_mismatches(self, cca_df): ID_rel_ID_mismatches = [] for row in cca_df.itertuples(): - if row.cell_cycle_stage == 'G1': + if row.cell_cycle_stage == "G1": continue - + ID = row.Index relID = row.relative_ID - relID_of_relID = cca_df.at[relID, 'relative_ID'] - + relID_of_relID = cca_df.at[relID, "relative_ID"] + if relID_of_relID != ID: ID_rel_ID_mismatches.append((ID, relID, relID_of_relID)) - + if not ID_rel_ID_mismatches: return True - + items = [ - f'Cell ID {ID} has relative ID = {relID}, ' - f'while cell ID {relID} has relative ID = {relID_of_relID}' + f"Cell ID {ID} has relative ID = {relID}, " + f"while cell ID {relID} has relative ID = {relID_of_relID}" for ID, relID, relID_of_relID in ID_rel_ID_mismatches ] - title = '`ID-relative_ID` mismatches' + title = "`ID-relative_ID` mismatches" txt = html_utils.paragraph( - f'`ID-relative_ID` mismatches:' - f'{html_utils.to_list(items)}' + f"`ID-relative_ID` mismatches:{html_utils.to_list(items)}" ) msg = widgets.myMessageBox(wrapText=False) msg.critical(self, title, txt) return False - + def ok_cb(self, checked): cca_df = self.getCca_df() if cca_df is None: @@ -9228,11 +9304,13 @@ def show(self, block=False): self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) super().show() ncols = self.tableLayout.columnCount() - maxLabelWidth = max([ - self.headerLayout.itemAt(j).widget().sizeHint().width() - for j in range(ncols) - ]) - minWidth = (maxLabelWidth+5)*ncols + maxLabelWidth = max( + [ + self.headerLayout.itemAt(j).widget().sizeHint().width() + for j in range(ncols) + ] + ) + minWidth = (maxLabelWidth + 5) * ncols self.setMinimumWidth(minWidth) if block: self.loop = QEventLoop() @@ -9249,18 +9327,17 @@ def clearComboboxFocus(self): self.sender().clearFocus() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() - + + class askStopFrameSegm(QDialog): - def __init__( - self, user_ch_file_paths, user_ch_name, parent=None - ): + def __init__(self, user_ch_file_paths, user_ch_name, parent=None): self.parent = parent self.cancel = True super().__init__(parent) - self.setWindowTitle('Enter stop frame') + self.setWindowTitle("Enter stop frame") self.visualizeWindows = [] @@ -9295,7 +9372,7 @@ def __init__( self.tab_idx = 0 iter_items = exp_path_pos_mapper.items() self.groupboxScrollAreas = [] - + for col, (exp_path, pos_folders_files) in enumerate(iter_items): groupboxScrollArea = widgets.ScrollArea() self.groupboxScrollAreas.append(groupboxScrollArea) @@ -9306,11 +9383,11 @@ def __init__( groupbox.setLayout(groupboxLayout) groupboxScrollArea.setWidget(groupbox) columnsLayout.addWidget(groupboxScrollArea) - pos_folders = pos_folders_files['pos_foldernames'] - filenames = pos_folders_files['filenames'] + pos_folders = pos_folders_files["pos_foldernames"] + filenames = pos_folders_files["filenames"] for i, pos_foldername in enumerate(pos_folders): img_filename = filenames[i] - images_path = os.path.join(exp_path, pos_foldername, 'Images') + images_path = os.path.join(exp_path, pos_foldername, "Images") img_path = os.path.join(images_path, img_filename) spinBox = widgets.mySpinBox() spinBox.sigTabEvent.connect(self.keyTabEventSpinbox) @@ -9329,9 +9406,9 @@ def __init__( else: spinBox.setValue(stopFrameNum) spinBox.setAlignment(Qt.AlignCenter) - visualizeButton = widgets.viewPushButton('Visualize') + visualizeButton = widgets.viewPushButton("Visualize") visualizeButton.clicked.connect(self.visualize_cb) - formLabel = QLabel(html_utils.paragraph(f'{pos_foldername} ')) + formLabel = QLabel(html_utils.paragraph(f"{pos_foldername} ")) layout = QHBoxLayout() layout.addWidget(formLabel, alignment=Qt.AlignRight) layout.addWidget(spinBox) @@ -9340,7 +9417,7 @@ def __init__( groupboxLayout.addRow(layout) spinBox.idx = i self.spinBoxes.append(spinBox) - + fm = QFontMetrics(self.font()) elidedTitle = fm.elidedText( exp_path, Qt.ElideLeft, groupbox.sizeHint().width() @@ -9350,12 +9427,12 @@ def __init__( mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) mainLayout.addWidget(mainScrollArea) - okButton = widgets.okPushButton('Ok') + okButton = widgets.okPushButton("Ok") okButton.setShortcut(Qt.Key_Enter) - cancelButton = widgets.cancelPushButton('Cancel') + cancelButton = widgets.cancelPushButton("Cancel") - buttonsLayout.addStretch(1) + buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) buttonsLayout.addSpacing(20) buttonsLayout.addWidget(okButton) @@ -9371,7 +9448,7 @@ def __init__( # # self.setModal(True) def keyTabEventSpinbox(self, event, sender): - self.tab_idx += 1 + self.tab_idx += 1 if self.tab_idx >= len(self.spinBoxes): self.tab_idx = 0 focusSpinbox = self.spinBoxes[self.tab_idx] @@ -9379,7 +9456,7 @@ def keyTabEventSpinbox(self, event, sender): def saveStopFrameNumbers(self): for spinBox, posData in self.dataDict.values(): - posData.metadata_df.at['stop_frame_num', 'values'] = spinBox.value() + posData.metadata_df.at["stop_frame_num", "values"] = spinBox.value() posData.metadataToCsv() def ok_cb(self, event): @@ -9392,7 +9469,7 @@ def ok_cb(self, event): spinBox.value() for spinBox, posData in self.dataDict.values() ] self.close() - + def closeEvent(self, event): for window in self.visualizeWindows: window.close() @@ -9400,14 +9477,11 @@ def closeEvent(self, event): def visualize_cb(self, checked=True): self.setDisabled(True) spinBox, posData = self.dataDict[self.sender()] - print('Loading image data...') + print("Loading image data...") posData.loadImgData() - posData.frame_i = spinBox.value()-1 + posData.frame_i = spinBox.value() - 1 win = plot.imshow( - posData.img_data, - lut='gray', - figure_title=posData.relPath, - block=False + posData.img_data, lut="gray", figure_title=posData.relPath, block=False ) self.visualizeWindows.append(win) self.setDisabled(False) @@ -9425,16 +9499,13 @@ def show(self, block=False): scrollAreaHeight = scrollArea.minimumHeightNoScrollbar() if scrollAreaHeight > height: height = scrollAreaHeight - + width += 70 - height += ( - self.sizeHint().height() - - self.mainScrollArea.sizeHint().height() - ) + height += self.sizeHint().height() - self.mainScrollArea.sizeHint().height() if width > maxWidth: width = maxWidth - + if height > maxHeight: height = maxHeight @@ -9447,17 +9518,28 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class QLineEditDialog(QDialog): def __init__( - self, title='Entry messagebox', msg='Entry value', - defaultTxt='', parent=None, allowedValues=None, - warnLastFrame=False, isInteger=False, isFloat=False, - stretchEntry=True, allowEmpty=True, allowedTextEntries=None, - allowText=False, lastVisitedFrame=None, allowList=False - ): + self, + title="Entry messagebox", + msg="Entry value", + defaultTxt="", + parent=None, + allowedValues=None, + warnLastFrame=False, + isInteger=False, + isFloat=False, + stretchEntry=True, + allowEmpty=True, + allowedTextEntries=None, + allowText=False, + lastVisitedFrame=None, + allowList=False, + ): QDialog.__init__(self, parent) self.loop = None @@ -9482,18 +9564,18 @@ def __init__( buttonsLayout = QHBoxLayout() # Widgets - if not msg.startswith(' np.iinfo(np.uint32).max: self.entryWidget.setText(str(np.iinfo(np.uint32).max)) except Exception as e: - text = text.replace(newChar, '') + text = text.replace(newChar, "") self.entryWidget.setText(text) return - + if self.allowedValues is not None: currentVal = self.value() if self.allowList: currentVal = currentVal[-1] if currentVal not in self.allowedValues: - self.notValidLabel.setText(f'{currentVal} not existing!') + self.notValidLabel.setText(f"{currentVal} not existing!") else: - self.notValidLabel.setText('') + self.notValidLabel.setText("") def warnValLessLastFrame(self, val): msg = widgets.myMessageBox() @@ -9629,12 +9711,14 @@ def warnValLessLastFrame(self, val): WARNING: saving until a frame number below the last visited frame ({self.lastVisitedFrame}) will result in LOSS of information about any edit or annotation you did on frames - {val+1}-{self.lastVisitedFrame}.

+ {val + 1}-{self.lastVisitedFrame}.


Are you sure you want to proceed? """) msg.warning( - self, 'WARNING: Potential loss of information', warn_txt, - buttonsTexts=('Cancel', 'Yes, I am sure.') + self, + "WARNING: Potential loss of information", + warn_txt, + buttonsTexts=("Cancel", "Yes, I am sure."), ) return msg.cancel @@ -9646,17 +9730,20 @@ def warnValMoreLastVisitedFrame(self, val): Are you sure you want to save until frame n. {val}?
""") msg.warning( - self, 'Saving past last visited frame', warn_txt, - buttonsTexts=('Cancel', 'Yes, I am sure.') + self, + "Saving past last visited frame", + warn_txt, + buttonsTexts=("Cancel", "Yes, I am sure."), ) return msg.cancel - + def ok_cb(self, event): if not self.allowEmpty and not self.entryWidget.text(): msg = widgets.myMessageBox(showCentered=False, wrapText=False) msg.critical( - self, 'Empty text', - html_utils.paragraph('Text entry field cannot be empty') + self, + "Empty text", + html_utils.paragraph("Text entry field cannot be empty"), ) return if self.allowedTextEntries is not None: @@ -9664,18 +9751,18 @@ def ok_cb(self, event): msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph( f'"{self.entryWidget.text()}" is not a valid entry.

' - 'Valid entries are:
' - f'{html_utils.to_list(self.allowedTextEntries)}' + "Valid entries are:
" + f"{html_utils.to_list(self.allowedTextEntries)}" ) - msg.critical(self, 'Not a valid entry', txt) + msg.critical(self, "Not a valid entry", txt) return - + if self.allowedValues: if self.notValidLabel.text(): return val = self.value() - + if self.warnLastFrame and self.lastVisitedFrame is not None: if val < self.lastVisitedFrame: cancel = self.warnValLessLastFrame(val) @@ -9693,7 +9780,7 @@ def ok_cb(self, event): self.EntryID = int(val) except Exception as err: self.EntryID = val - + self.enteredValue = val self.close() @@ -9712,22 +9799,29 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class FindIDDialog(QLineEditDialog): def __init__(self, **kwargs): super().__init__(**kwargs) - - self.okButton.setIcon(QIcon(':magnGlass.svg')) - self.okButton.setText(' Find ') + + self.okButton.setIcon(QIcon(":magnGlass.svg")) + self.okButton.setText(" Find ") + class NumericEntryDialog(QBaseDialog): def __init__( - self, title='Entry a value', currentValue=0, - instructions='Entry value', parent=None, - maxValue=None, minValue=None, stretch=False - ): + self, + title="Entry a value", + currentValue=0, + instructions="Entry value", + parent=None, + maxValue=None, + minValue=None, + stretch=False, + ): super().__init__(parent=parent) self.setWindowTitle(title) self.cancel = False @@ -9736,47 +9830,50 @@ def __init__( cancelOkLayout = widgets.CancelOkButtonsLayout() cancelOkLayout.okButton.clicked.connect(self.ok_cb) cancelOkLayout.cancelButton.clicked.connect(self.close) - + instructionsLabel = QLabel(html_utils.paragraph(instructions)) mainLayout.addWidget(instructionsLabel) - + if type(currentValue) == int: self.entryWidget = widgets.SpinBox() self.entryWidget.setValue(currentValue) - self.valueGetter = 'value' + self.valueGetter = "value" if maxValue is not None: self.entryWidget.setMaximum(maxValue) if minValue is not None: self.entryWidget.setMinimum(minValue) - + if stretch: entryLayout.addWidget(self.entryWidget) else: entryLayout.addStretch(1) entryLayout.addWidget(self.entryWidget) entryLayout.addStretch(1) - + mainLayout.addLayout(entryLayout) mainLayout.addSpacing(20) mainLayout.addLayout(cancelOkLayout) - + self.setLayout(mainLayout) - + def ok_cb(self): self.cancel = False self.value = getattr(self.entryWidget, self.valueGetter)() self.close() + class EditIDDialog(QDialog): def __init__( - self, clickedID, IDs, - entryID=None, - doNotShowAgain=False, - parent=None, - nextUniqueID=1, - allIDs=None, - addPropagateCheckbox=False - ): + self, + clickedID, + IDs, + entryID=None, + doNotShowAgain=False, + parent=None, + nextUniqueID=1, + allIDs=None, + addPropagateCheckbox=False, + ): self.assignNewID = False self.IDs = IDs self.clickedID = clickedID @@ -9794,7 +9891,7 @@ def __init__( mainLayout = QVBoxLayout() VBoxLayout = QVBoxLayout() - msg = QLabel(f'Replace ID {clickedID} with:') + msg = QLabel(f"Replace ID {clickedID} with:") _font = QFont() _font.setPixelSize(12) msg.setFont(_font) @@ -9812,19 +9909,17 @@ def __init__( entryWidget.selectAll() VBoxLayout.addWidget( - QLabel(f'Next unique ID = {nextUniqueID}'), alignment=Qt.AlignCenter + QLabel(f"Next unique ID = {nextUniqueID}"), alignment=Qt.AlignCenter ) - + VBoxLayout.addWidget(widgets.QHLine()) - + self.warnExistingIDLabel = QLabel() - self.warnExistingIDLabel.setStyleSheet('color: red') - VBoxLayout.addWidget( - self.warnExistingIDLabel, alignment=Qt.AlignCenter - ) - + self.warnExistingIDLabel.setStyleSheet("color: red") + VBoxLayout.addWidget(self.warnExistingIDLabel, alignment=Qt.AlignCenter) + note = QLabel( - 'NOTE: To replace multiple IDs at once\n' + "NOTE: To replace multiple IDs at once\n" 'write "(old ID, new ID), (old ID, new ID)" etc.' ) note.setFont(_font) @@ -9833,17 +9928,17 @@ def __init__( note.setStyleSheet("padding:12px 0px 0px 0px;") VBoxLayout.addWidget(note, alignment=Qt.AlignCenter) mainLayout.addLayout(VBoxLayout) - + self.propagateCheckbox = None if addPropagateCheckbox: mainLayout.addSpacing(10) - self.propagateCheckbox = QCheckBox('Apply to future frames') + self.propagateCheckbox = QCheckBox("Apply to future frames") mainLayout.addWidget(self.propagateCheckbox) buttonsLayout = QHBoxLayout() - okButton = widgets.okPushButton('Ok') - cancelButton = widgets.cancelPushButton('Cancel') - applyNewIDButton = widgets.AssignNewIDButton('Assign new, unique ID') + okButton = widgets.okPushButton("Ok") + cancelButton = widgets.cancelPushButton("Cancel") + applyNewIDButton = widgets.AssignNewIDButton("Assign new, unique ID") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -9857,79 +9952,79 @@ def __init__( self.setLayout(mainLayout) # Connect events - self.prevText = '' + self.prevText = "" entryWidget.textChanged[str].connect(self.onTextChanged) okButton.clicked.connect(self.ok_cb) cancelButton.clicked.connect(self.cancel_cb) applyNewIDButton.clicked.connect(self.assignNewIDclicked) - + # self.setModal(True) def onTextChanged(self, text): - self.warnExistingIDLabel.setText('') + self.warnExistingIDLabel.setText("") try: ID = int(text) if ID in self.allIDs: - self.warnExistingIDLabel.setText( - f'WARNING: ID {ID} was already used' - ) + self.warnExistingIDLabel.setText(f"WARNING: ID {ID} was already used") except Exception as err: pass - + # Get inserted char idx = self.entryWidget.cursorPosition() if idx == 0: return - newChar = text[idx-1] + newChar = text[idx - 1] # Do nothing if user is deleting text - if idx == 0 or len(text) uint32_max: text = self.entryWidget.text() - text = f'{text[:m.start()]}{uint32_max}{text[m.end():]}' + text = f"{text[: m.start()]}{uint32_max}{text[m.end() :]}" self.entryWidget.setText(text) # Automatically close ( bracket - if newChar == '(': - text += ')' + if newChar == "(": + text += ")" self.entryWidget.setText(text) self.prevText = text - + def _warnExistingID(self, existingID, newID): warn_msg = html_utils.paragraph(f""" ID {existingID} is already existing.

How do you want to proceed?
""") msg = widgets.myMessageBox() - doNotAskAgainCheckbox = QCheckBox('Remember my choice and do not ask again') - swapButton = widgets.reloadPushButton(f'Swap {newID} with {existingID}') - mergeButton = widgets.mergePushButton(f'Merge {newID} with {existingID}') + doNotAskAgainCheckbox = QCheckBox("Remember my choice and do not ask again") + swapButton = widgets.reloadPushButton(f"Swap {newID} with {existingID}") + mergeButton = widgets.mergePushButton(f"Merge {newID} with {existingID}") msg.warning( - self, 'Existing ID', warn_msg, - buttonsTexts=('Cancel', mergeButton, swapButton), - widgets=doNotAskAgainCheckbox + self, + "Existing ID", + warn_msg, + buttonsTexts=("Cancel", mergeButton, swapButton), + widgets=doNotAskAgainCheckbox, ) if msg.cancel: return False self.doNotAskAgainExistingID = doNotAskAgainCheckbox.isChecked() - self.mergeWithExistingID = msg.clickedButton == mergeButton + self.mergeWithExistingID = msg.clickedButton == mergeButton return True def assignNewIDclicked(self): @@ -9937,7 +10032,7 @@ def assignNewIDclicked(self): self.how = None self.assignNewID = True self.close() - + def ok_cb(self, event): txt = self.entryWidget.text() valid = False @@ -9954,7 +10049,7 @@ def ok_cb(self, event): else: valid = True except ValueError: - pattern = r'\((\d+),\s*(\d+)\)' + pattern = r"\((\d+),\s*(\d+)\)" fa = re.findall(pattern, txt) if fa: how = [(int(g[0]), int(g[1])) for g in fa] @@ -9964,24 +10059,21 @@ def ok_cb(self, event): if not valid: err_msg = html_utils.paragraph( - 'You entered invalid text. Valid text is either a single integer' - f' ID that will be used to replace ID {self.clickedID} ' - 'or a list of elements enclosed in parenthesis separated by a comma
' - 'such as (5, 10), (8, 27) to replace ID 5 with ID 10 and ID 8 with ID 27' + "You entered invalid text. Valid text is either a single integer" + f" ID that will be used to replace ID {self.clickedID} " + "or a list of elements enclosed in parenthesis separated by a comma
" + "such as (5, 10), (8, 27) to replace ID 5 with ID 10 and ID 8 with ID 27" ) msg = widgets.myMessageBox() - msg.warning( - self, 'Invalid entry', err_msg - ) + msg.warning(self, "Invalid entry", err_msg) return - + self.cancel = False self.how = how self.doPropagateFutureFrames = False if self.propagateCheckbox is not None: self.doPropagateFutureFrames = self.propagateCheckbox.isChecked() self.close() - def cancel_cb(self, event): self.cancel = True @@ -9998,17 +10090,22 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class QtSelectItems(QDialog): def __init__( - self, title, items, informativeText, - CbLabel='Select value: ', parent=None, - showInFileManagerPath=None - ): + self, + title, + items, + informativeText, + CbLabel="Select value: ", + parent=None, + showInFileManagerPath=None, + ): self.cancel = True - self.selectedItemsText = '' + self.selectedItemsText = "" self.selectedItemsIdx = None self.showInFileManagerPath = showInFileManagerPath self.items = items @@ -10034,8 +10131,8 @@ def __init__( self.ComboBox = combobox topLayout.addWidget(combobox) - okButton = widgets.okPushButton('Ok') - cancelButton = widgets.cancelPushButton('Cancel') + okButton = widgets.okPushButton("Ok") + cancelButton = widgets.cancelPushButton("Cancel") if showInFileManagerPath is not None: txt = myutils.get_open_filemaneger_os_string() showInFileManagerButton = widgets.showInFileManagerButton(txt) @@ -10047,7 +10144,7 @@ def __init__( bottomLayout.addWidget(showInFileManagerButton) bottomLayout.addWidget(okButton) - multiPosButton = QPushButton('Multiple selection') + multiPosButton = QPushButton("Multiple selection") multiPosButton.setCheckable(True) self.multiPosButton = multiPosButton bottomLayout.addWidget(multiPosButton, alignment=Qt.AlignLeft) @@ -10061,7 +10158,7 @@ def __init__( listBox.hide() self.ListBox = listBox - mainLayout.addLayout(topLayout) + mainLayout.addLayout(topLayout) mainLayout.addSpacing(20) mainLayout.addLayout(bottomLayout) @@ -10089,10 +10186,10 @@ def setSelectedItems(self, selectedItemsText): else: idx = self.items.index(selectedItemsText[0]) self.ComboBox.setCurrentIndex(idx) - + def showInFileManager(self): selectedTexts, _ = self.getSelectedItems() - folder = selectedTexts[0].split('(')[0].strip() + folder = selectedTexts[0].split("(")[0].strip() path = os.path.join(self.showInFileManagerPath, folder) if os.path.exists(path) and os.path.isdir(path): showPath = path @@ -10102,7 +10199,7 @@ def showInFileManager(self): def toggleMultiSelection(self, checked): if checked: - self.multiPosButton.setText('Single selection') + self.multiPosButton.setText("Single selection") self.ComboBox.hide() self.ListBox.show() # Show 10 items @@ -10111,13 +10208,13 @@ def toggleMultiSelection(self, checked): h = sum([self.ListBox.sizeHintForRow(i) for i in range(10)]) else: h = sum([self.ListBox.sizeHintForRow(i) for i in range(n)]) - self.ListBox.setMinimumHeight(h+5) + self.ListBox.setMinimumHeight(h + 5) self.ListBox.setFocusPolicy(Qt.StrongFocus) self.ListBox.setFocus() self.ListBox.setCurrentRow(0) self.mainLayout.setStretchFactor(self.topLayout, 2) else: - self.multiPosButton.setText('Multiple selection') + self.multiPosButton.setText("Multiple selection") self.ListBox.hide() self.ComboBox.show() self.resize(self.width(), self.singleSelectionHeight) @@ -10127,9 +10224,7 @@ def getSelectedItems(self): selectedItems = self.ListBox.selectedItems() selectedItemsText = [item.text() for item in selectedItems] selectedItemsText = natsorted(selectedItemsText) - selectedItemsIdx = [ - self.items.index(txt) for txt in selectedItemsText - ] + selectedItemsIdx = [self.items.index(txt) for txt in selectedItemsText] else: selectedItemsText = [self.ComboBox.currentText()] selectedItemsIdx = [self.ComboBox.currentIndex()] @@ -10152,24 +10247,31 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() class manualSeparateGui(QMainWindow): def __init__( - self, lab, ID, img, fontSize='12pt', IDcolor=[255, 255, 0], - parent=None, loop=None, drawMode='threepoints_arc' - ): + self, + lab, + ID, + img, + fontSize="12pt", + IDcolor=[255, 255, 0], + parent=None, + loop=None, + drawMode="threepoints_arc", + ): super().__init__(parent) self.loop = loop self.cancel = True self.drawMode = drawMode self._parent = parent self.lab = lab.copy() - self.lab[lab!=ID] = 0 + self.lab[lab != ID] = 0 self.ID = ID - self.img = skimage.exposure.equalize_adapthist(img/img.max()) + self.img = skimage.exposure.equalize_adapthist(img / img.max()) self.IDcolor = IDcolor self.countClicks = 0 self.prevLabs = [] @@ -10216,19 +10318,19 @@ def centerWindow(self): mainWinTop = mainWinGeometry.top() mainWinWidth = mainWinGeometry.width() mainWinHeight = mainWinGeometry.height() - mainWinCenterX = int(mainWinLeft + mainWinWidth/2) - mainWinCenterY = int(mainWinTop + mainWinHeight/2) + mainWinCenterX = int(mainWinLeft + mainWinWidth / 2) + mainWinCenterY = int(mainWinTop + mainWinHeight / 2) winGeometry = self.geometry() winWidth = winGeometry.width() winHeight = winGeometry.height() - winLeft = int(mainWinCenterX - winWidth/2) - winRight = int(mainWinCenterY - winHeight/2) + winLeft = int(mainWinCenterX - winWidth / 2) + winRight = int(mainWinCenterY - winHeight / 2) self.move(winLeft, winRight) def gui_createActions(self): # File actions self.exitAction = QAction("&Exit", self) - self.helpAction = QAction('Help', self) + self.helpAction = QAction("Help", self) self.undoAction = QAction(QIcon(":undo.svg"), "Undo (Ctrl+Z)", self) self.undoAction.setEnabled(False) self.undoAction.setShortcut("Ctrl+Z") @@ -10239,60 +10341,52 @@ def gui_createActions(self): self.drawModesActionGroup = QActionGroup(self) self.threePointsArcAction = QAction( - QIcon(":threepoints_arc.svg"), 'Separate with three-points arc', - self + QIcon(":threepoints_arc.svg"), "Separate with three-points arc", self ) self.threePointsArcAction.setCheckable(True) - self.threePointsArcAction.drawMode = 'threepoints_arc' + self.threePointsArcAction.drawMode = "threepoints_arc" self.drawModesActionGroup.addAction(self.threePointsArcAction) self.freeHandAction = QAction( - QIcon(":freehand.svg"), 'Separate with freehand line', self + QIcon(":freehand.svg"), "Separate with freehand line", self ) self.freeHandAction.setCheckable(True) - self.freeHandAction.drawMode = 'freehand' + self.freeHandAction.drawMode = "freehand" self.drawModesActionGroup.addAction(self.freeHandAction) - if self.drawMode == 'threepoints_arc': + if self.drawMode == "threepoints_arc": self.threePointsArcAction.setChecked(True) - elif self.drawMode == 'freehand': + elif self.drawMode == "freehand": self.freeHandAction.setChecked(True) - - self.swapIDsAction = QAction( - QIcon(":reload.svg"), "Swap IDs", self - ) - self.swapIDsAction.setToolTip( - 'Swap the two displayed IDs\n\n' - 'Shortcut: "S"' - ) - self.swapIDsAction.setShortcut('S') - + + self.swapIDsAction = QAction(QIcon(":reload.svg"), "Swap IDs", self) + self.swapIDsAction.setToolTip('Swap the two displayed IDs\n\nShortcut: "S"') + self.swapIDsAction.setShortcut("S") + def state(self): return { - 'is_overlay_active': self.overlayButton.isChecked(), - 'is_three_points_active': self.threePointsArcAction.isChecked(), - 'is_free_hand_active': self.freeHandAction.isChecked() + "is_overlay_active": self.overlayButton.isChecked(), + "is_three_points_active": self.threePointsArcAction.isChecked(), + "is_free_hand_active": self.freeHandAction.isChecked(), } - + def show(self, block=False): super().show() if not block: return self.loop = QEventLoop(self) self.loop.exec_() - + def setState(self, state): if state is None: return - self.overlayButton.setChecked(state.get('is_overlay_active', False)) - self.threePointsArcAction.setChecked( - state.get('is_three_points_active', True) - ) - self.freeHandAction.setChecked(state.get('is_free_hand_active', False)) - + self.overlayButton.setChecked(state.get("is_overlay_active", False)) + self.threePointsArcAction.setChecked(state.get("is_three_points_active", True)) + self.freeHandAction.setChecked(state.get("is_free_hand_active", False)) + def gui_storeDrawMode(self): self.drawMode = self.sender().drawMode - + def gui_createMenuBar(self): menuBar = self.menuBar() # style = "QMenuBar::item:selected { background: white; }" @@ -10319,19 +10413,16 @@ def gui_createToolBars(self): self.overlayButton = QToolButton(self) self.overlayButton.setIcon(QIcon(":overlay.svg")) self.overlayButton.setCheckable(True) - self.overlayButton.setToolTip( - 'Overlay channel\'s image' - ) + self.overlayButton.setToolTip("Overlay channel's image") editToolBar.addWidget(self.overlayButton) editToolBar.addAction(self.threePointsArcAction) editToolBar.addAction(self.freeHandAction) - + editToolBar.addAction(self.swapIDsAction) - + self.warnLabel = QLabel() editToolBar.addWidget(self.warnLabel) - def gui_connectActions(self): self.exitAction.triggered.connect(self.close) @@ -10358,54 +10449,55 @@ def gui_createGraphics(self): self.ax = pg.PlotItem() self.ax.invertY(True) self.ax.setAspectLocked(True) - self.ax.hideAxis('bottom') - self.ax.hideAxis('left') + self.ax.hideAxis("bottom") + self.ax.hideAxis("left") self.graphLayout.addItem(self.ax, row=1, col=1) # Image Item - self.imgItem = pg.ImageItem(np.zeros((512,512))) + self.imgItem = pg.ImageItem(np.zeros((512, 512))) self.ax.addItem(self.imgItem) - #Image histogram + # Image histogram self.imgGrad = widgets.myHistogramLUTitem() # Curvature items self.hoverLinSpace = np.linspace(0, 1, 1000) - self.hoverLinePen = pg.mkPen(color=(200, 0, 0, 255*0.5), - width=2, style=Qt.DashLine) - self.hoverCurvePen = pg.mkPen(color=(200, 0, 0, 255*0.5), width=3) + self.hoverLinePen = pg.mkPen( + color=(200, 0, 0, 255 * 0.5), width=2, style=Qt.DashLine + ) + self.hoverCurvePen = pg.mkPen(color=(200, 0, 0, 255 * 0.5), width=3) self.lineHoverPlotItem = pg.PlotDataItem(pen=self.hoverLinePen) self.curvHoverPlotItem = pg.PlotDataItem(pen=self.hoverCurvePen) self.curvAnchors = pg.ScatterPlotItem( - symbol='o', size=9, - brush=pg.mkBrush((255,0,0,50)), - pen=pg.mkPen((255,0,0), width=2), - hoverable=True, hoverPen=pg.mkPen((255,0,0), width=3), - hoverBrush=pg.mkBrush((255,0,0)) + symbol="o", + size=9, + brush=pg.mkBrush((255, 0, 0, 50)), + pen=pg.mkPen((255, 0, 0), width=2), + hoverable=True, + hoverPen=pg.mkPen((255, 0, 0), width=3), + hoverBrush=pg.mkBrush((255, 0, 0)), ) self.ax.addItem(self.curvAnchors) self.ax.addItem(self.curvHoverPlotItem) self.ax.addItem(self.lineHoverPlotItem) - self.freeHandItem = widgets.PlotCurveItem( - pen=pg.mkPen(color='r', width=2) - ) + self.freeHandItem = widgets.PlotCurveItem(pen=pg.mkPen(color="r", width=2)) self.ax.addItem(self.freeHandItem) def gui_createImgWidgets(self): self.img_Widglayout = QGridLayout() self.img_Widglayout.setContentsMargins(50, 0, 50, 0) - alphaScrollBar_label = QLabel('Overlay alpha ') + alphaScrollBar_label = QLabel("Overlay alpha ") alphaScrollBar = QScrollBar(Qt.Horizontal) alphaScrollBar.setFixedHeight(20) alphaScrollBar.setMinimum(0) alphaScrollBar.setMaximum(40) alphaScrollBar.setValue(12) alphaScrollBar.setToolTip( - 'Control the alpha value of the overlay.\n' - 'alpha=0 results in NO overlay,\n' - 'alpha=1 results in only labels visible' + "Control the alpha value of the overlay.\n" + "alpha=0 results in NO overlay,\n" + "alpha=1 results in only labels visible" ) alphaScrollBar.sliderMoved.connect(self.alphaScrollBarMoved) self.alphaScrollBar = alphaScrollBar @@ -10432,11 +10524,11 @@ def gui_hoverEventImg(self, event): Y, X = _img.shape if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: val = _img[ydata, xdata] - self.wcLabel.setText(f'(x={x:.2f}, y={y:.2f}, ID={val:.0f})') + self.wcLabel.setText(f"(x={x:.2f}, y={y:.2f}, ID={val:.0f})") else: - self.wcLabel.setText(f'') + self.wcLabel.setText(f"") except Exception as e: - self.wcLabel.setText(f'') + self.wcLabel.setText(f"") if event.isExit(): return @@ -10447,16 +10539,16 @@ def gui_mousePressEventImg(self, event): right_click = event.button() == Qt.MouseButton.RightButton left_click = event.button() == Qt.MouseButton.LeftButton - dragImg = (left_click) + dragImg = left_click if dragImg: pg.ImageItem.mousePressEvent(self.imgItem, event) - + if not right_click: return self.drawPressEvent(event) - + def gui_mouseDragEventImg(self, event): pass @@ -10470,7 +10562,7 @@ def gui_mouseReleaseEventImg(self, event): self.splitObjectAlongCurve() self.freeHandItem.setData([], []) self.curvAnchors.setData([], []) - + def getSpline(self, xx, yy): tck, u = scipy.interpolate.splprep([xx, yy], s=0, k=2) xi, yi = scipy.interpolate.splev(self.hoverLinSpace, tck) @@ -10483,13 +10575,13 @@ def drawPressEvent(self, event): self.curvAnchors.addPoints([x], [y]) elif self.threePointsArcAction.isChecked(): self.threePointsArcPressEvent(event) - + def drawHoverEvent(self, x, y): if self.freeHandAction.isChecked(): self.freeHandHoverEvent(x, y) elif self.threePointsArcAction.isChecked(): self.threePointsArcHoverEvent(x, y) - + def freeHandHoverEvent(self, x, y): if self.countClicks == 0: return @@ -10511,7 +10603,7 @@ def threePointsArcHoverEvent(self, x, y): self.curvHoverPlotItem.setData([], []) self.lineHoverPlotItem.setData([], []) self.curvAnchors.setData([], []) - + def threePointsArcPressEvent(self, event): if self.countClicks == 0: x, y = event.pos().x(), event.pos().y() @@ -10535,18 +10627,18 @@ def threePointsArcPressEvent(self, event): yy, xx = np.round(yi).astype(int), np.round(xi).astype(int) self.setSplitCurveCoords(xx, yy) self.splitObjectAlongCurve() - + def setSplitCurveCoords(self, xx, yy): self.storeUndoState() xxCurve, yyCurve = [], [] for i, (r0, c0) in enumerate(zip(yy, xx)): - if i == len(yy)-1: + if i == len(yy) - 1: break - r1 = yy[i+1] - c1 = xx[i+1] + r1 = yy[i + 1] + c1 = xx[i + 1] rr, cc, _ = skimage.draw.line_aa(r0, c0, r1, c1) # rr, cc = skimage.draw.line(r0, c0, r1, c1) - nonzeroMask = self.lab[rr, cc]>0 + nonzeroMask = self.lab[rr, cc] > 0 xxCurve.extend(cc[nonzeroMask]) yyCurve.extend(rr[nonzeroMask]) self.AllCutsCoords.append((yyCurve, xxCurve)) @@ -10563,22 +10655,21 @@ def swapIDs(self, checked=False): if len(self.rp) == 1: self.warnLabel.setText( html_utils.paragraph( - 'WARNING: Split the object before swapping IDs', - font_color='red' + "WARNING: Split the object before swapping IDs", font_color="red" ) ) return - - self.warnLabel.setText('') - + + self.warnLabel.setText("") + obj1 = self.rp[0] obj2 = self.rp[1] - + self.lab[obj1.slice][obj1.image] = obj2.label self.lab[obj2.slice][obj2.image] = obj1.label - + self.updateImg() - + def updateImg(self): self.updateLookuptable() rp = skimage.measure.regionprops(self.lab) @@ -10596,23 +10687,21 @@ def updateImg(self): self.labelItemsIDs = [] for obj in rp: labelItemID = widgets.myLabelItem() - labelItemID.setText( - f'{obj.label}', color='r', size=f'{self.fontSize}px' - ) + labelItemID.setText(f"{obj.label}", color="r", size=f"{self.fontSize}px") y, x = obj.centroid w, h = labelItemID.rect().right(), labelItemID.rect().bottom() - labelItemID.setPos(x-w/2, y-h/2) + labelItemID.setPos(x - w / 2, y - h / 2) self.labelItemsIDs.append(labelItemID) self.ax.addItem(labelItemID) def zoomToObj(self): # Zoom to object - lab_mask = (self.lab>0).astype(np.uint8) + lab_mask = (self.lab > 0).astype(np.uint8) rp = skimage.measure.regionprops(lab_mask) obj = rp[0] min_row, min_col, max_row, max_col = obj.bbox - xRange = min_col-10, max_col+10 - yRange = max_row+10, min_row-10 + xRange = min_col - 10, max_col + 10 + yRange = max_row + 10, min_row - 10 self.ax.setRange(xRange=xRange, yRange=yRange) def storeUndoState(self): @@ -10639,14 +10728,14 @@ def splitObjectAlongCurve(self): areas = [obj.area for obj in rp] IDs = [obj.label for obj in rp] maxAreaIdx = areas.index(max(areas)) - maxAreaID = IDs[maxAreaIdx] + maxAreaID = IDs[maxAreaIdx] if self.ID not in self.lab: - self.lab[self.lab==maxAreaID] = self.ID + self.lab[self.lab == maxAreaID] = self.ID else: tempID = self.lab.max() + 1 - self.lab[self.lab==maxAreaID] = tempID - self.lab[self.lab==self.ID] = maxAreaID - self.lab[self.lab==tempID] = self.ID + self.lab[self.lab == maxAreaID] = tempID + self.lab[self.lab == self.ID] = maxAreaID + self.lab[self.lab == tempID] = self.ID # Keep only the two largest objects larger_areas = nlargest(2, areas) @@ -10673,25 +10762,25 @@ def splitObjectAlongCurve(self): self.cutLab = self.lab.copy() for rr, cc in self.AllCutsCoords: for y, x in zip(rr, cc): - top_row = self.cutLab[y+1, x-1:x+2] - bot_row = self.cutLab[y-1, x-1:x+1] - left_col = self.cutLab[y-1, x-1] - right_col = self.cutLab[y:y+2, x+1] + top_row = self.cutLab[y + 1, x - 1 : x + 2] + bot_row = self.cutLab[y - 1, x - 1 : x + 1] + left_col = self.cutLab[y - 1, x - 1] + right_col = self.cutLab[y : y + 2, x + 1] allNeigh = list(top_row) allNeigh.extend(bot_row) allNeigh.append(left_col) allNeigh.extend(right_col) newID = max(allNeigh) - self.lab[y,x] = newID + self.lab[y, x] = newID self.rp = skimage.measure.regionprops(self.lab) self.updateImg() def updateLookuptable(self): # Lookup table - self.cmap = colors.getFromMatplotlib('viridis') - self.lut = self.cmap.getLookupTable(0,1,self.lab.max()+1) - self.lut[0] = [25,25,25] + self.cmap = colors.getFromMatplotlib("viridis") + self.lut = self.cmap.getLookupTable(0, 1, self.lab.max() + 1) + self.lut[0] = [25, 25, 25] self.lut[self.ID] = self.IDcolor if self.overlayButton.isChecked(): self.imgItem.setLookupTable(None) @@ -10713,22 +10802,22 @@ def getOverlay(self): min = self.imgGrad.gradient.listTicks()[0][1] max = self.imgGrad.gradient.listTicks()[1][1] img = skimage.exposure.rescale_intensity(self.img, in_range=(min, max)) - alpha = self.alphaScrollBar.value()/self.alphaScrollBar.maximum() + alpha = self.alphaScrollBar.value() / self.alphaScrollBar.maximum() # Convert img and lab to RGBs rgb_shape = (self.lab.shape[0], self.lab.shape[1], 3) labRGB = np.zeros(rgb_shape) - labRGB[self.lab>0] = [1, 1, 1] + labRGB[self.lab > 0] = [1, 1, 1] imgRGB = skimage.color.gray2rgb(img) - overlay = imgRGB*(1.0-alpha) + labRGB*alpha + overlay = imgRGB * (1.0 - alpha) + labRGB * alpha # Color eaach label for obj in self.rp: - rgb = self.lut[obj.label]/255 + rgb = self.lut[obj.label] / 255 overlay[obj.slice][obj.image] *= rgb # Convert (0,1) to (0,255) - overlay = (np.clip(overlay, 0, 1)*255).astype(np.uint8) + overlay = (np.clip(overlay, 0, 1) * 255).astype(np.uint8) return overlay def alphaScrollBarMoved(self, alpha_int): @@ -10748,17 +10837,20 @@ def toggleOverlay(self, checked): def help(self): msg = QMessageBox() - msg.information(self, 'Help', - 'Separate object along a curved line.\n\n' - 'To draw a curved line you will need 3 right-clicks:\n\n' - '1. Right-click outside of the object --> a line appears.\n' - '2. Right-click to end the line and a curve going through the ' - 'mouse cursor will appear.\n' - '3. Once you are happy with the cutting curve right-click again ' - 'and the object will be separated along the curve.\n\n' - 'Note that you can separate as many times as you want.\n\n' - 'Once happy click on the green tick on top-right or ' - 'cancel the process with the "X" button') + msg.information( + self, + "Help", + "Separate object along a curved line.\n\n" + "To draw a curved line you will need 3 right-clicks:\n\n" + "1. Right-click outside of the object --> a line appears.\n" + "2. Right-click to end the line and a curve going through the " + "mouse cursor will appear.\n" + "3. Once you are happy with the cutting curve right-click again " + "and the object will be separated along the curve.\n\n" + "Note that you can separate as many times as you want.\n\n" + "Once happy click on the green tick on top-right or " + 'cancel the process with the "X" button', + ) def ok_cb(self, checked): self.cancel = False @@ -10768,6 +10860,7 @@ def closeEvent(self, event): if self.loop is not None: self.loop.exit() + class DataFrameModel(QtCore.QAbstractTableModel): # https://stackoverflow.com/questions/44603119/how-to-display-a-pandas-data-frame-with-pyqt5-pyside2 DtypeRole = QtCore.Qt.UserRole + 1000 @@ -10785,13 +10878,15 @@ def setDataFrame(self, dataframe): def dataFrame(self): return self._dataframe - dataFrame = QtCore.Property(pd.DataFrame, fget=dataFrame, - fset=setDataFrame) + dataFrame = QtCore.Property(pd.DataFrame, fget=dataFrame, fset=setDataFrame) @QtCore.Slot(int, QtCore.Qt.Orientation, result=str) - def headerData(self, section: int, - orientation: QtCore.Qt.Orientation, - role: int = QtCore.Qt.DisplayRole): + def headerData( + self, + section: int, + orientation: QtCore.Qt.Orientation, + role: int = QtCore.Qt.DisplayRole, + ): if role == QtCore.Qt.DisplayRole: if orientation == QtCore.Qt.Horizontal: return self._dataframe.columns[section] @@ -10810,8 +10905,10 @@ def columnCount(self, parent=QtCore.QModelIndex()): return self._dataframe.columns.size def data(self, index, role=QtCore.Qt.DisplayRole): - if not index.isValid() or not (0 <= index.row() < self.rowCount() \ - and 0 <= index.column() < self.columnCount()): + if not index.isValid() or not ( + 0 <= index.row() < self.rowCount() + and 0 <= index.column() < self.columnCount() + ): return QtCore.QVariant() row = self._dataframe.index[index.row()] col = self._dataframe.columns[index.column()] @@ -10831,17 +10928,18 @@ def data(self, index, role=QtCore.Qt.DisplayRole): def roleNames(self): roles = { - QtCore.Qt.DisplayRole: b'display', - DataFrameModel.DtypeRole: b'dtype', - DataFrameModel.ValueRole: b'value' + QtCore.Qt.DisplayRole: b"display", + DataFrameModel.DtypeRole: b"dtype", + DataFrameModel.ValueRole: b"value", } return roles + class pdDataFrameWidget(QMainWindow): def __init__(self, df, parent=None): super().__init__(parent) self.parent = parent - self.setWindowTitle('Cell cycle annotations') + self.setWindowTitle("Cell cycle annotations") self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) @@ -10863,10 +10961,10 @@ def __init__(self, df, parent=None): def updateTable(self, df, IDs=None): if df is None: df = self.parent.getBaseCca_df() - + if IDs is not None: df = df.loc[IDs] - + df = df.reset_index() model = DataFrameModel(df) self.tableView.setModel(model) @@ -10878,8 +10976,8 @@ def setGeometryWindow(self, maxWidth=1024): for j in range(self.tableView.model().columnCount()): width += self.tableView.columnWidth(j) + 4 height = self.tableView.horizontalHeader().height() + 4 - h = height + (self.tableView.rowHeight(0) + 4)*10 - w = width if width
{filename}

however you never selected which z-slice
you want to use when calculating metrics
(e.g., mean, median, amount...etc.)

Choose one of following options: - """, center=True + """, + center=True, ) infoLabel = QLabel(txt) mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) runDataPrepButton = QPushButton( - ' Visualize the data now and select a z-slice ' + " Visualize the data now and select a z-slice " ) buttonsLayout.addWidget(runDataPrepButton, 0, 1, 1, 2) runDataPrepButton.clicked.connect(self.runDataPrep_cb) useMiddleSliceButton = QPushButton( - f' Use the middle z-slice ({int(SizeZ/2)+1}) ' + f" Use the middle z-slice ({int(SizeZ / 2) + 1}) " ) buttonsLayout.addWidget(useMiddleSliceButton, 1, 1, 1, 2) useMiddleSliceButton.clicked.connect(self.useMiddleSlice_cb) - useSameAsChButton = QPushButton( - ' Use the same z-slice used for the channel: ' - ) + useSameAsChButton = QPushButton(" Use the same z-slice used for the channel: ") useSameAsChButton.clicked.connect(self.useSameAsCh_cb) chNameComboBox = QComboBox() @@ -10953,20 +11052,16 @@ def __init__(self, filename, SizeZ, filenamesWithInfo, parent=None): buttonsLayout.addWidget(useSameAsChButton, 2, 1) buttonsLayout.addWidget(chNameComboBox, 2, 2) - - buttonsLayout.setColumnStretch(0, 1) buttonsLayout.setColumnStretch(3, 1) buttonsLayout.setContentsMargins(10, 0, 10, 0) - - cancelButtonLayout = QHBoxLayout() - cancelButton = widgets.cancelPushButton('Cancel') + cancelButton = widgets.cancelPushButton("Cancel") cancelButtonLayout.addStretch(1) cancelButtonLayout.addWidget(cancelButton) cancelButtonLayout.addStretch(1) - cancelButtonLayout.setStretch(1,1) + cancelButtonLayout.setStretch(1, 1) cancelButton.clicked.connect(self.close) mainLayout.addLayout(buttonsLayout) @@ -10981,7 +11076,7 @@ def __init__(self, filename, SizeZ, filenamesWithInfo, parent=None): self.setFont(font) # self.setModal(True) - + def ok_cb(self, checked=True): self.cancel = False self.close() @@ -11010,18 +11105,25 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class SelectSegmFileDialog(QDialog): def __init__( - self, images_ls, parent_path, parent=None, - addNewFileButton=False, basename='', infoText=None, - fileType='segmentation', allowMultipleSelection=False, - custom_first=None - ): + self, + images_ls, + parent_path, + parent=None, + addNewFileButton=False, + basename="", + infoText=None, + fileType="segmentation", + allowMultipleSelection=False, + custom_first=None, + ): self.cancel = True - self.selectedItemText = '' + self.selectedItemText = "" self.selectedItemIdx = None self.removeOthers = False self.okAllPos = False @@ -11037,7 +11139,7 @@ def __init__( # a new file is existing (since we only ask for the part after # 'segm_') self.existingEndNames = [ - n.replace('segm', '', 1).replace('_', '', 1) for n in images_ls + n.replace("segm", "", 1).replace("_", "", 1) for n in images_ls ] self.images_ls = images_ls @@ -11049,7 +11151,7 @@ def __init__( {len(self.existingEndNames)} {fileType} masks
""") - self.setWindowTitle(f'{fileType.capitalize()} files detected') + self.setWindowTitle(f"{fileType.capitalize()} files detected") is_win = sys.platform.startswith("win") mainLayout = QVBoxLayout() @@ -11059,7 +11161,7 @@ def __init__( # Standard Qt Question icon label = QLabel() - standardIcon = getattr(QStyle, 'SP_MessageBoxQuestion') + standardIcon = getattr(QStyle, "SP_MessageBoxQuestion") icon = self.style().standardIcon(standardIcon) pixmap = icon.pixmap(60, 60) label.setPixmap(pixmap) @@ -11071,7 +11173,7 @@ def __init__( mainLayout.addLayout(infoLayout) if infoText is None: - infoText = f'Select which {fileType} file to load:' + infoText = f"Select which {fileType} file to load:" questionText = html_utils.paragraph(infoText) label = QLabel(questionText) @@ -11086,13 +11188,13 @@ def __init__( self.items = list(images_ls) self.listWidget = listWidget - okButton = widgets.okPushButton(' Load selected ') - txt = 'Reveal in Finder...' if is_mac else 'Show in Explorer...' + okButton = widgets.okPushButton(" Load selected ") + txt = "Reveal in Finder..." if is_mac else "Show in Explorer..." showInFileManagerButton = widgets.showInFileManagerButton(txt) - cancelButton = widgets.cancelPushButton(' Cancel ') + cancelButton = widgets.cancelPushButton(" Cancel ") if addNewFileButton: - newFileButton = widgets.newFilePushButton('New file...') + newFileButton = widgets.newFilePushButton("New file...") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -11122,18 +11224,18 @@ def __init__( newFileButton.clicked.connect(self.newFile_cb) cancelButton.clicked.connect(self.close) showInFileManagerButton.clicked.connect(self.showInFileManager) - + def listDoubleClicked(self, item): self.ok_cb() def showInFileManager(self, checked=True): myutils.showInExplorer(self.parent_path) - + def newFile_cb(self): win = filenameDialog( - basename=f'{self.basename}segm', - hintText='Insert a filename for the segmentation file:', - existingNames=self.existingEndNames + basename=f"{self.basename}segm", + hintText="Insert a filename for the segmentation file:", + existingNames=self.existingEndNames, ) win.exec_() if win.cancel: @@ -11141,7 +11243,7 @@ def newFile_cb(self): self.cancel = False self.newSegmEndName = win.entryText self.close() - + def setSelectedItemFromText(self, itemText): for i in range(self.listWidget.count()): if self.listWidget.item(i).text() == itemText: @@ -11158,8 +11260,7 @@ def ok_cb(self, event=None): return self.selectedItemIdx = self.items.index(self.selectedItemText) self.selectedItemTexts = [ - selectedItem.text() - for selectedItem in self.listWidget.selectedItems() + selectedItem.text() for selectedItem in self.listWidget.selectedItems() ] self.close() @@ -11174,20 +11275,21 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() + class QDialogPbar(QDialog): - def __init__(self, title='Progress', infoTxt='', parent=None): + def __init__(self, title="Progress", infoTxt="", parent=None): self.workerFinished = False self.aborted = False self.clickCount = 0 super().__init__(parent) - abort_text = 'Option+Command+C' if is_mac else 'Ctrl+Alt+C' + abort_text = "Option+Command+C" if is_mac else "Ctrl+Alt+C" self.abort_text = abort_text - self.setWindowTitle(f'{title} ({abort_text} to abort)') + self.setWindowTitle(f"{title} ({abort_text} to abort)") self.setWindowFlags(Qt.Window) mainLayout = QVBoxLayout() @@ -11201,14 +11303,14 @@ def __init__(self, title='Progress', infoTxt='', parent=None): self.QPbar = widgets.ProgressBar(self) pBarLayout.addWidget(self.QPbar, 0, 0) - self.ETA_label = QLabel('NDh:NDm:NDs') + self.ETA_label = QLabel("NDh:NDm:NDs") pBarLayout.addWidget(self.ETA_label, 0, 1) self.metricsQPbar = widgets.ProgressBar(self) self.metricsQPbar.setValue(0) pBarLayout.addWidget(self.metricsQPbar, 1, 0) - #pBarLayout.setColumnStretch(2, 1) + # pBarLayout.setColumnStretch(2, 1) mainLayout.addWidget(self.progressLabel) mainLayout.addLayout(pBarLayout) @@ -11235,12 +11337,10 @@ def askAbort(self): Are you sure you want to abort? """) yesButton, noButton = msg.critical( - self, 'Are you sure you want to abort?', txt, - buttonsTexts=('Yes', 'No') + self, "Are you sure you want to abort?", txt, buttonsTexts=("Yes", "No") ) return msg.clickedButton == yesButton - def abort(self): self.clickCount += 1 self.aborted = True @@ -11252,89 +11352,91 @@ def closeEvent(self, event): if not self.workerFinished: event.ignore() + class FunctionParamsDialog(QBaseDialog): sigValuesChanged = Signal(dict) - + def __init__( - self, params_argspecs, - function_name='Function', - df_metadata=None, - parent=None, - addApplyButton=False - ): + self, + params_argspecs, + function_name="Function", + df_metadata=None, + parent=None, + addApplyButton=False, + ): self.cancel = True self.df_metadata = df_metadata - + super().__init__(parent) - - self.setWindowTitle(f'{function_name} parameters') - + + self.setWindowTitle(f"{function_name} parameters") + self.mainLayout = QVBoxLayout() - + widgetsLayout, self.argsWidgets = self.getWidgetsLayout(params_argspecs) - + buttonsLayout = widgets.CancelOkButtonsLayout() self.buttonsLayout = buttonsLayout buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + if addApplyButton: - applyButton = widgets.viewPushButton('Apply') + applyButton = widgets.viewPushButton("Apply") applyButton.clicked.connect(self.emitValuesChanged) buttonsLayout.insertWidget(3, applyButton) self.applyButton = applyButton - + self.mainLayout.addLayout(widgetsLayout) self.mainLayout.addSpacing(20) self.mainLayout.addLayout(buttonsLayout) self.setLayout(self.mainLayout) - + def emitValuesChanged(self, *args, **kwargs): self.sigValuesChanged.emit(self.functionKwargs()) - - def functionKwargs(self): + + def functionKwargs(self): function_kwargs = { - argWidget.name:argWidget.valueGetter(argWidget.widget) + argWidget.name: argWidget.valueGetter(argWidget.widget) for argWidget in self.argsWidgets } return function_kwargs - + def kwargWidgetMapper(self) -> Dict[str, tuple]: kwarg_widget_mapper = { - argWidget.name:(argWidget.widget, argWidget.valueSetter) + argWidget.name: (argWidget.widget, argWidget.valueSetter) for argWidget in self.argsWidgets } return kwarg_widget_mapper - + def ok_cb(self): self.cancel = False - + self.function_kwargs = self.functionKwargs() - + self.close() - + def getValueFromMetadata(self, name): try: - value = self.df_metadata.at[name, 'values'] + value = self.df_metadata.at[name, "values"] except Exception as e: # traceback.print_exc() value = None return value - + def getWidgetsLayout(self, params_argspecs): widgetsLayout = QGridLayout() ArgsWidgets_list = [] - + for row, ArgSpec in enumerate(params_argspecs): if _types.is_widget_not_required(ArgSpec): continue - - arg_name = ArgSpec.name - var_name = arg_name.replace('_', ' ') - var_name = f'{var_name[0].upper()}{var_name[1:]}' - label = QLabel(f'{var_name}: ') + + arg_name = ArgSpec.name + var_name = arg_name.replace("_", " ") + var_name = f"{var_name[0].upper()}{var_name[1:]}" + label = QLabel(f"{var_name}: ") metadata_val = self.getValueFromMetadata(ArgSpec.name) widgetsLayout.addWidget(label, row, 0, alignment=Qt.AlignLeft) try: @@ -11342,23 +11444,23 @@ def getWidgetsLayout(self, params_argspecs): isCustomListType = True except Exception as err: isCustomListType = False - + isVectorEntry = False try: if isinstance(ArgSpec.type(), _types.Vector): isVectorEntry = True except Exception as err: pass - + isFolderPath = False try: if isinstance(ArgSpec.type(), _types.FolderPath): isFolderPath = True except Exception as err: pass - - isCustomWidget = hasattr(ArgSpec.type, 'isWidget') - + + isCustomWidget = hasattr(ArgSpec.type, "isWidget") + if isCustomWidget: widget = ArgSpec.type().widget self.checkIfTypeCLassHasCastDtype(widget) @@ -11460,21 +11562,21 @@ def getWidgetsLayout(self, params_argspecs): valueGetter = QLineEdit.text widgetsLayout.addWidget(lineEdit, row, 1, 1, 2) widget.editingFinished.connect(self.emitValuesChanged) - + if ArgSpec.desc: infoButton = self.getInfoButton(ArgSpec.name, ArgSpec.desc) widgetsLayout.addWidget(infoButton, row, 3) - + argsInfo = ArgWidget( name=ArgSpec.name, type=ArgSpec.type, widget=widget, defaultVal=defaultVal, valueSetter=valueSetter, - valueGetter=valueGetter + valueGetter=valueGetter, ) ArgsWidgets_list.append(argsInfo) - + widgetsLayout.setColumnStretch(0, 0) widgetsLayout.setColumnStretch(1, 1) widgetsLayout.setColumnStretch(3, 0) @@ -11482,26 +11584,26 @@ def getWidgetsLayout(self, params_argspecs): return widgetsLayout, ArgsWidgets_list def checkIfTypeCLassHasCastDtype(self, cls): - cast_dtype = getattr(cls, 'cast_dtype', None) + cast_dtype = getattr(cls, "cast_dtype", None) if callable(cast_dtype): return - + raise AttributeError( - 'The custom type or widget does not have the `cast_dtype` method. ' - 'Please, implement it. The method should cast the value to the ' - 'correct type.' + "The custom type or widget does not have the `cast_dtype` method. " + "Please, implement it. The method should cast the value to the " + "correct type." ) - + def getInfoButton(self, param_name, infoText): infoButton = widgets.infoPushButton() infoButton.param_name = param_name infoButton.setToolTip( - f'Click to get more info about `{param_name}` parameter...' + f"Click to get more info about `{param_name}` parameter..." ) infoButton.infoText = infoText infoButton.clicked.connect(self.showInfoParam) return infoButton - + def showInfoParam(self): text = self.sender().infoText text = html_utils.rst_urls_to_html(text) @@ -11509,33 +11611,34 @@ def showInfoParam(self): text = html_utils.paragraph(text) param_name = self.sender().param_name msg = widgets.myMessageBox(wrapText=False) - msg.information(self, f'Info about `{param_name}` parameter', text) - + msg.information(self, f"Info about `{param_name}` parameter", text) + + class QDialogModelParams(QDialog): def __init__( - self, - init_params, - segment_params, - model_name, - is_tracker=False, - url=None, - parent=None, - initLastParams=True, - posData=None, - channels=None, - currentChannelName=None, - segmFileEndnames=None, - df_metadata=None, - force_postprocess_2D=False, - model_module=None, - action_type='', - addPreProcessParams=True, - addPostProcessParams=True, - extraParams=None, - extraParamsTitle=None, - ini_filename=None, - add_additional_segm_params=False - ): + self, + init_params, + segment_params, + model_name, + is_tracker=False, + url=None, + parent=None, + initLastParams=True, + posData=None, + channels=None, + currentChannelName=None, + segmFileEndnames=None, + df_metadata=None, + force_postprocess_2D=False, + model_module=None, + action_type="", + addPreProcessParams=True, + addPostProcessParams=True, + extraParams=None, + extraParamsTitle=None, + ini_filename=None, + add_additional_segm_params=False, + ): self.cancel = True super().__init__(parent) self.channels = channels @@ -11548,7 +11651,7 @@ def __init__( self.skipSegmentation = False if len(segment_params) > 0: - if segment_params[0].name.lower().find('skip_segmentation') != -1: + if segment_params[0].name.lower().find("skip_segmentation") != -1: self.skipSegmentation = True addPreProcessParams = False else: @@ -11556,28 +11659,28 @@ def __init__( if ini_filename is not None: self.ini_filename = ini_filename elif is_tracker: - self.ini_filename = 'last_params_trackers.ini' + self.ini_filename = "last_params_trackers.ini" addPreProcessParams = False addPostProcessParams = False else: - self.ini_filename = 'last_params_segm_models.ini' + self.ini_filename = "last_params_segm_models.ini" self.addPreProcessParams = addPreProcessParams - + self.model_name = model_name - self.setWindowTitle(f'{model_name} parameters') + self.setWindowTitle(f"{model_name} parameters") # Create main vertical layout and horizontal layout for two columns mainLayout = QVBoxLayout() - + gridLayout = QGridLayout() self.gridLayout = gridLayout - + loadFunc = self.loadLastSelection - + self.paramsGroupPosMapper = {} - + # LEFT COLUMN: Preprocessing params row, col = 0, 0 preProcessLayout = None @@ -11589,45 +11692,41 @@ def __init__( ) self.preProcessParamsWidget.setChecked(False) preProcessLayout.addWidget(self.preProcessParamsWidget) - self.preProcessParamsWidget.sigLoadRecipe.connect( - self.loadPreprocRecipe - ) + self.preProcessParamsWidget.sigLoadRecipe.connect(self.loadPreprocRecipe) gridLayout.addLayout(preProcessLayout, row, col, 1, 2) self.paramsGroupPosMapper[self.preProcessParamsWidget] = (row, col) - gridLayout.addItem(QSpacerItem(10, 5), 0, col+1) + gridLayout.addItem(QSpacerItem(10, 5), 0, col + 1) # gridLayout.setColumnMinimumWidth(col+1, 15) - col += 2 - + col += 2 + # Center COLUMN: Init, Segmentation/Eval row = 0 self.secondColLayout = QVBoxLayout() self.initParamsScrollArea = widgets.ScrollArea() initParamsScrollAreaLayout = QVBoxLayout() self.initParamsScrollArea.setVerticalLayout(initParamsScrollAreaLayout) - + initGroupBox, self.init_argsWidgets = self.createGroupParams( - init_params, 'Parameters for model initialization' + init_params, "Parameters for model initialization" ) self.init_params = init_params - initDefaultButton = widgets.reloadPushButton('Restore default') - initLoadLastSelButton = widgets.OpenFilePushButton( - 'Load last parameters' - ) - initLoadLastSelButton.setIcon(QIcon(':folder-open.svg')) + initDefaultButton = widgets.reloadPushButton("Restore default") + initLoadLastSelButton = widgets.OpenFilePushButton("Load last parameters") + initLoadLastSelButton.setIcon(QIcon(":folder-open.svg")) initButtonsLayout = QHBoxLayout() initButtonsLayout.addStretch(1) initButtonsLayout.addWidget(initDefaultButton) initButtonsLayout.addWidget(initLoadLastSelButton) initDefaultButton.clicked.connect(self.restoreDefaultInit) initLoadLastSelButton.clicked.connect( - partial(loadFunc, f'{self.model_name}.init', self.init_argsWidgets) + partial(loadFunc, f"{self.model_name}.init", self.init_argsWidgets) ) - + initParamsScrollAreaLayout.addWidget(initGroupBox) - + initParamsLayout = QVBoxLayout() - initParamsLayout.addWidget(QLabel(f'{initGroupBox.title()}')) - initGroupBox.setTitle('') + initParamsLayout.addWidget(QLabel(f"{initGroupBox.title()}")) + initGroupBox.setTitle("") initParamsLayout.addWidget(self.initParamsScrollArea) initParamsLayout.addLayout(initButtonsLayout) self.secondColLayout.addLayout(initParamsLayout) @@ -11641,72 +11740,67 @@ def __init__( segmentParamsScrollAreaLayout ) if action_type: - runGroupboxTitle = f'Parameters for {action_type}' + runGroupboxTitle = f"Parameters for {action_type}" elif is_tracker: - runGroupboxTitle = 'Parameters for tracking' + runGroupboxTitle = "Parameters for tracking" else: - runGroupboxTitle = 'Parameters for segmentation' - + runGroupboxTitle = "Parameters for segmentation" + segmentGroupBox, self.argsWidgets = self.createGroupParams( - segment_params, runGroupboxTitle, - addChannelSelector=True - ) + segment_params, runGroupboxTitle, addChannelSelector=True + ) self.segment_params = segment_params self.segmentGroupBox = segmentGroupBox - segmentDefaultButton = widgets.reloadPushButton('Restore default') + segmentDefaultButton = widgets.reloadPushButton("Restore default") segmentLoadLastSelButton = widgets.OpenFilePushButton( - 'Load last parameters' + "Load last parameters" ) segmentButtonsLayout = QHBoxLayout() segmentButtonsLayout.addStretch(1) segmentButtonsLayout.addWidget(segmentDefaultButton) segmentButtonsLayout.addWidget(segmentLoadLastSelButton) segmentDefaultButton.clicked.connect(self.restoreDefaultSegment) - section = f'{self.model_name}.segment' + section = f"{self.model_name}.segment" segmentLoadLastSelButton.clicked.connect( partial(loadFunc, section, self.argsWidgets) ) segmentParamsScrollAreaLayout.addWidget(segmentGroupBox) - + segmentParamsLayout = QVBoxLayout() - segmentParamsLayout.addWidget( - QLabel(f'{segmentGroupBox.title()}') - ) - segmentGroupBox.setTitle('') + segmentParamsLayout.addWidget(QLabel(f"{segmentGroupBox.title()}")) + segmentGroupBox.setTitle("") segmentParamsLayout.addWidget(self.segmentParamsScrollArea) segmentParamsLayout.addLayout(segmentButtonsLayout) self.secondColLayout.addLayout(segmentParamsLayout) self.paramsGroupPosMapper[self.segmentParamsScrollArea] = (1, col) gridLayout.addLayout(self.secondColLayout, row, col) - - gridLayout.addItem(QSpacerItem(10, 5), 0, col+1) - col += 2 - + + gridLayout.addItem(QSpacerItem(10, 5), 0, col + 1) + col += 2 + # Buttons layout (spans both columns) buttonsLayout = QHBoxLayout() - cancelButton = widgets.cancelPushButton(' Cancel ') - okButton = widgets.okPushButton(' Ok ') - - enableLoadingSavingRecipe = ( - not is_tracker and (addPreProcessParams or addPostProcessParams) + cancelButton = widgets.cancelPushButton(" Cancel ") + okButton = widgets.okPushButton(" Ok ") + + enableLoadingSavingRecipe = not is_tracker and ( + addPreProcessParams or addPostProcessParams ) - + buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) buttonsLayout.addSpacing(20) if enableLoadingSavingRecipe: - loadEntireRecipeButton = widgets.OpenFilePushButton( - 'Load saved recipe...' - ) + loadEntireRecipeButton = widgets.OpenFilePushButton("Load saved recipe...") saveEntireRecipeButton = widgets.savePushButton( - 'Save all parameters to recipe file...' + "Save all parameters to recipe file..." ) buttonsLayout.addWidget(loadEntireRecipeButton) buttonsLayout.addWidget(saveEntireRecipeButton) loadEntireRecipeButton.clicked.connect(self.loadEntireRecipe) saveEntireRecipeButton.clicked.connect(self.saveEntireRecipe) - + buttonsLayout.addWidget(okButton) buttonsLayout.setContentsMargins(0, 10, 0, 10) @@ -11714,8 +11808,8 @@ def __init__( okButton.clicked.connect(self.ok_cb) cancelButton.clicked.connect(self.close) - self.okButton = okButton - + self.okButton = okButton + # Extra params in right column row = 0 self.extraArgsWidgets = None @@ -11723,35 +11817,31 @@ def __init__( if extraParams is not None: self.extraParamsScrollArea = widgets.ScrollArea() extraParamsScrollAreaLayout = QVBoxLayout() - self.extraParamsScrollArea.setVerticalLayout( - extraParamsScrollAreaLayout - ) + self.extraParamsScrollArea.setVerticalLayout(extraParamsScrollAreaLayout) if extraParamsTitle is None: - extraParamsTitle = 'Additional parameters' + extraParamsTitle = "Additional parameters" self.extraGroupBox, self.extraArgsWidgets = self.createGroupParams( extraParams, extraParamsTitle ) - extraDefaultButton = widgets.reloadPushButton('Restore default') - extraLoadLastSelButton = widgets.OpenFilePushButton( - 'Load last parameters' - ) + extraDefaultButton = widgets.reloadPushButton("Restore default") + extraLoadLastSelButton = widgets.OpenFilePushButton("Load last parameters") extraButtonsLayout = QHBoxLayout() extraButtonsLayout.addStretch(1) extraButtonsLayout.addWidget(extraDefaultButton) extraButtonsLayout.addWidget(extraLoadLastSelButton) extraDefaultButton.clicked.connect(self.restoreDefaultExtra) - section = f'{self.model_name}.extra' + section = f"{self.model_name}.extra" extraLoadLastSelButton.clicked.connect( partial(loadFunc, section, self.extraArgsWidgets) ) extraParamsScrollAreaLayout.addWidget(self.extraGroupBox) - + extraParamsLayout = QVBoxLayout() - extraParamsLayout.addWidget(QLabel(f'{self.extraGroupBox.title()}')) - self.extraGroupBox.setTitle('') + extraParamsLayout.addWidget(QLabel(f"{self.extraGroupBox.title()}")) + self.extraGroupBox.setTitle("") extraParamsLayout.addWidget(self.extraParamsScrollArea) extraParamsLayout.addLayout(extraButtonsLayout) self.paramsGroupPosMapper[self.extraParamsScrollArea] = (row, col) @@ -11762,11 +11852,12 @@ def __init__( self.postProcessGroupbox = None self.seeHereLabel = None thirdColumnLayout = QVBoxLayout() - if addPostProcessParams: + if addPostProcessParams: # Add minimum size spinbox which is valid for all models postProcessGroupbox = PostProcessSegmParams( - 'Post-processing segmentation parameters', posData, - force_postprocess_2D=force_postprocess_2D + "Post-processing segmentation parameters", + posData, + force_postprocess_2D=force_postprocess_2D, ) postProcessGroupbox.setCheckable(True) postProcessGroupbox.setChecked(False) @@ -11774,27 +11865,23 @@ def __init__( thirdColumnLayout.addWidget(postProcessGroupbox) - postProcDefaultButton = widgets.reloadPushButton('Restore default') + postProcDefaultButton = widgets.reloadPushButton("Restore default") postProcLoadLastSelButton = widgets.OpenFilePushButton( - 'Load last parameters' + "Load last parameters" ) postProcButtonsLayout = QHBoxLayout() postProcButtonsLayout.addStretch(1) postProcButtonsLayout.addWidget(postProcDefaultButton) postProcButtonsLayout.addWidget(postProcLoadLastSelButton) postProcDefaultButton.clicked.connect(self.restoreDefaultPostprocess) - postProcLoadLastSelButton.clicked.connect( - self.loadLastSelectionPostProcess - ) + postProcLoadLastSelButton.clicked.connect(self.loadLastSelectionPostProcess) thirdColumnLayout.addLayout(postProcButtonsLayout) thirdColumnLayout.addSpacing(15) if url is not None: self.seeHereLabel = self.createSeeHereLabel(url) - thirdColumnLayout.addWidget( - self.seeHereLabel, alignment=Qt.AlignCenter - ) - + thirdColumnLayout.addWidget(self.seeHereLabel, alignment=Qt.AlignCenter) + self.paramsGroupPosMapper[self.preProcessParamsWidget] = (row, col) # Additional segmentation params in right column @@ -11805,11 +11892,11 @@ def __init__( thirdColumnLayout.addWidget(additionalSegmGroupbox) self.additionalSegmGroupbox = additionalSegmGroupbox self.paramsGroupPosMapper[self.additionalSegmGroupbox] = (row, col) - + thirdColumnLayout.addStretch(1) gridLayout.addLayout(thirdColumnLayout, row, col) row += 1 - + # Add everything to main layout mainLayout.addLayout(gridLayout) mainLayout.addSpacing(20) @@ -11826,10 +11913,10 @@ def __init__( initLoadLastSelButton.click() if not self.skipSegmentation: segmentLoadLastSelButton.click() - + if self.extraArgsWidgets is not None: extraLoadLastSelButton.click() - + if self.postProcessGroupbox is not None: postProcLoadLastSelButton.click() @@ -11837,125 +11924,120 @@ def __init__( self.connectCustomSignals(model_module) except Exception as e: printl(traceback.format_exc()) - + self.setLayout(mainLayout) self.setFont(font) # self.setModal(True) - + def warningNoSegmRecipes(self): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - 'No segmentation recipes found!

' - 'To create a segmentation recipe you need click on ' - 'Save all parameters to recipe file... ' - 'button.' + "No segmentation recipes found!

" + "To create a segmentation recipe you need click on " + "Save all parameters to recipe file... " + "button." ) - msg.warning(self, 'No segmentation recipes found!', txt) - + msg.warning(self, "No segmentation recipes found!", txt) + def selectIniFileToLoadEntireRecipe(self): import qtpy.compat + recipe_filepath = qtpy.compat.getopenfilename( - parent=self, - caption='Select INI file to load entire recipe', - filters='INI (*.ini);;All Files (*)' + parent=self, + caption="Select INI file to load entire recipe", + filters="INI (*.ini);;All Files (*)", )[0] if not recipe_filepath: return - + self.loadRecipeFromFilepath(recipe_filepath) - - txt = html_utils.paragraph( - 'Done!

' - 'Segmentation recipe loaded from:' - ) + + txt = html_utils.paragraph("Done!

Segmentation recipe loaded from:") msg = widgets.myMessageBox() msg.information( - self, 'Segmentation recipe loaded!', txt, + self, + "Segmentation recipe loaded!", + txt, commands=(recipe_filepath,), - path_to_browse=os.path.dirname(recipe_filepath) + path_to_browse=os.path.dirname(recipe_filepath), ) - - print('Done. Segmentation recipe loaded from:', recipe_filepath) + + print("Done. Segmentation recipe loaded from:", recipe_filepath) def loadEntireRecipe(self): - segm_recipes_path_model = os.path.join( - segm_recipes_path, self.model_name - ) + segm_recipes_path_model = os.path.join(segm_recipes_path, self.model_name) if not os.path.exists(segm_recipes_path_model): # self.warningNoSegmRecipes() self.selectIniFileToLoadEntireRecipe() return - + recipe_files = os.listdir(segm_recipes_path_model) if not recipe_files: # self.warningNoSegmRecipes() self.selectIniFileToLoadEntireRecipe() return - - headerLabels = ['Name', 'Date Created'] + + headerLabels = ["Name", "Date Created"] items = [] for recipe_file in recipe_files: cp = config.ConfigParser() cp.read(os.path.join(segm_recipes_path_model, recipe_file)) - date_created = cp['info']['created_on'] + date_created = cp["info"]["created_on"] items.append((recipe_file, date_created)) browseButton = widgets.browseFileButton( - 'Select INI file...', - title='Select INI file to load entire recipe', + "Select INI file...", + title="Select INI file to load entire recipe", openFolder=False, start_dir=myutils.getMostRecentPath(), - ext={'INI': '.ini'} + ext={"INI": ".ini"}, ) win = QTreeDialog( items, headerLabels=headerLabels, - title='Select a segmentation recipe to load', - infoText='Select a segmentation recipe to load:
', + title="Select a segmentation recipe to load", + infoText="Select a segmentation recipe to load:
", path_to_browse=segm_recipes_path_model, - additional_buttons=(browseButton, ) + additional_buttons=(browseButton,), ) browseButton.sigPathSelected.connect( partial( - self.entireRecipeIniFileSelected, - selectRecipeWin=win, - sender=browseButton + self.entireRecipeIniFileSelected, + selectRecipeWin=win, + sender=browseButton, ) ) win.exec_() - if win.cancel or not hasattr(win, 'selectedText'): - print('Loading segmentation recipe cancelled.') + if win.cancel or not hasattr(win, "selectedText"): + print("Loading segmentation recipe cancelled.") return - + if win.clickedButton == browseButton: recipe_filepath = win.selectedIniFilepath else: recipe_filename = win.selectedText - recipe_filepath = os.path.join( - segm_recipes_path_model, recipe_filename - ) - + recipe_filepath = os.path.join(segm_recipes_path_model, recipe_filename) + self.loadRecipeFromFilepath(recipe_filepath) - - txt = html_utils.paragraph( - 'Done!

' - 'Segmentation recipe loaded from:' - ) + + txt = html_utils.paragraph("Done!

Segmentation recipe loaded from:") msg = widgets.myMessageBox() msg.information( - self, 'Segmentation recipe laoded!', txt, + self, + "Segmentation recipe laoded!", + txt, commands=(recipe_filepath,), - path_to_browse=os.path.dirname(recipe_filepath) + path_to_browse=os.path.dirname(recipe_filepath), ) - - print('Done. Segmentation recipe loaded from:', recipe_filepath) - + + print("Done. Segmentation recipe loaded from:", recipe_filepath) + def entireRecipeIniFileSelected( - self, recipe_filepath, selectRecipeWin=None, sender=None - ): - selectRecipeWin.selectedText = 'None' + self, recipe_filepath, selectRecipeWin=None, sender=None + ): + selectRecipeWin.selectedText = "None" selectRecipeWin.clickedButton = sender selectRecipeWin.selectedIniFilepath = recipe_filepath selectRecipeWin.cancel = False @@ -11964,104 +12046,97 @@ def entireRecipeIniFileSelected( def loadRecipeFromFilepath(self, recipe_filepath): cp = config.ConfigParser() cp.read(recipe_filepath) - + self.loadPreprocRecipe(configPars=cp) self.loadLastSelection( - f'{self.model_name}.init', self.init_argsWidgets, configPars=cp + f"{self.model_name}.init", self.init_argsWidgets, configPars=cp ) self.loadLastSelection( - f'{self.model_name}.segment', self.argsWidgets, configPars=cp + f"{self.model_name}.segment", self.argsWidgets, configPars=cp ) if self.extraArgsWidgets: self.loadLastSelection( - f'{self.model_name}.extra', self.extraArgsWidgets, configPars=cp + f"{self.model_name}.extra", self.extraArgsWidgets, configPars=cp ) self.loadLastSelectionPostProcess(configPars=cp) - + def saveEntireRecipe(self): - segm_recipes_path_model = os.path.join( - segm_recipes_path, self.model_name - ) + segm_recipes_path_model = os.path.join(segm_recipes_path, self.model_name) try: - existingNames=os.listdir(segm_recipes_path_model) + existingNames = os.listdir(segm_recipes_path_model) except FileNotFoundError: existingNames = [] win = filenameDialog( - title='Filename for segmentation recipe', - basename='segmentation_recipe', - ext='.ini', - hintText='Insert a filename for the segmentation recipe:', + title="Filename for segmentation recipe", + basename="segmentation_recipe", + ext=".ini", + hintText="Insert a filename for the segmentation recipe:", allowEmpty=False, parent=self, - existingNames=existingNames + existingNames=existingNames, ) win.exec_() if win.cancel: return - + ini_filename = win.filename os.makedirs(segm_recipes_path, exist_ok=True) os.makedirs(segm_recipes_path_model, exist_ok=True) ini_filepath = os.path.join(segm_recipes_path_model, ini_filename) - + configPars = self.getConfigPars(create_new=True) - if hasattr(self, 'reduceMemUsageToggle'): - configPars[f'{self.model_name}.additional_segm_params'] = {} + if hasattr(self, "reduceMemUsageToggle"): + configPars[f"{self.model_name}.additional_segm_params"] = {} reduceMemoryUsage = self.reduceMemUsageToggle.isChecked() option = self.reduceMemUsageToggle.label - configPars[f'{self.model_name}.additional_segm_params'][option] = ( - str(reduceMemoryUsage) + configPars[f"{self.model_name}.additional_segm_params"][option] = str( + reduceMemoryUsage ) - - configPars['info'] = {} - configPars['info']['created_on'] = datetime.datetime.now().strftime( - r'%Y/%m/%d %H:%M' + + configPars["info"] = {} + configPars["info"]["created_on"] = datetime.datetime.now().strftime( + r"%Y/%m/%d %H:%M" ) - - with open(ini_filepath, 'w') as configfile: + + with open(ini_filepath, "w") as configfile: configPars.write(configfile) - - txt = html_utils.paragraph( - 'Done!

' - 'Segmentation recipe saved to:' - ) + + txt = html_utils.paragraph("Done!

Segmentation recipe saved to:") msg = widgets.myMessageBox() msg.information( - self, 'Segmnentation recipe saved!', txt, + self, + "Segmnentation recipe saved!", + txt, commands=(ini_filepath,), - path_to_browse=os.path.dirname(ini_filepath) + path_to_browse=os.path.dirname(ini_filepath), ) - - print('Done. Segmentation recipe saved to:', ini_filepath) - + + print("Done. Segmentation recipe saved to:", ini_filepath) + def getAdditionalSegmParams(self): - additionalSegmGroupbox = QGroupBox('Additional segmentation parameters') + additionalSegmGroupbox = QGroupBox("Additional segmentation parameters") local_row = 0 additionalSegmLayout = QGridLayout() - option = 'Reduce memory usage' + option = "Reduce memory usage" additionalSegmLayout.addWidget( - QLabel(f'{option}: '), local_row, 0, - alignment=Qt.AlignRight + QLabel(f"{option}: "), local_row, 0, alignment=Qt.AlignRight ) self.reduceMemUsageToggle = widgets.Toggle() additionalSegmLayout.addWidget( - self.reduceMemUsageToggle, local_row, 1, 1, 2, - alignment=Qt.AlignCenter + self.reduceMemUsageToggle, local_row, 1, 1, 2, alignment=Qt.AlignCenter ) self.reduceMemUsageToggle.label = option reduceMemUsageInfoButton = widgets.infoPushButton() additionalSegmLayout.addWidget(reduceMemUsageInfoButton, local_row, 3) - reduceMemUsageInfoButton.clicked.connect( - self.showInfoReduceMemUsage - ) + reduceMemUsageInfoButton.clicked.connect(self.showInfoReduceMemUsage) additionalSegmLayout.setColumnStretch(0, 0) additionalSegmLayout.setColumnStretch(1, 1) additionalSegmLayout.setColumnStretch(3, 0) additionalSegmGroupbox.setLayout(additionalSegmLayout) return additionalSegmGroupbox - + def showInfoReduceMemUsage(self): infoText = html_utils.paragraph(f""" If you are experiencing memory issues, you can try reducing the @@ -12070,50 +12145,48 @@ def showInfoReduceMemUsage(self): frame-by-frame instead of all frames at once. """) msg = widgets.myMessageBox(wrapText=False) - msg.information( - self, 'Reduce memory usage', infoText - ) - + msg.information(self, "Reduce memory usage", infoText) + def loadPreprocRecipe(self, configPars=None): if self.configPars is None and configPars is None: return - + if configPars is None: configPars = self.configPars - + preprocConfigPars = {} for section in configPars.sections(): - if not section.startswith(f'{self.model_name}.preprocess'): - continue - + if not section.startswith(f"{self.model_name}.preprocess"): + continue + preprocConfigPars[section] = configPars[section] - + if not preprocConfigPars: return - + self.preProcessParamsWidget.loadRecipe(preprocConfigPars) - + def connectCustomSignals(self, model_module): if model_module is None: return - if not hasattr(model_module, 'CustomSignals'): + if not hasattr(model_module, "CustomSignals"): return - + customSignals = model_module.CustomSignals() for slot_info in customSignals.slots_info: - group = slot_info['group'] - widget_name = slot_info['widget_name'] - if group == 'init': + group = slot_info["group"] + widget_name = slot_info["widget_name"] + if group == "init": ArgsWidgets_list = self.init_argsWidgets else: ArgsWidgets_list = self.argsWidgets for argwidget in ArgsWidgets_list: if argwidget.name == widget_name: - signal = getattr(argwidget.widget, slot_info['signal']) - signal.connect(partial(slot_info['slot'], self)) + signal = getattr(argwidget.widget, slot_info["signal"]) + signal.connect(partial(slot_info["slot"], self)) break - + def selectedFeaturesRange(self): if self.postProcessGroupbox is None: return {} @@ -12123,34 +12196,33 @@ def groupedFeatures(self): if self.postProcessGroupbox is None: return {} return self.postProcessGroupbox.groupedFeatures() - + def setChannelNames(self, chNames): - if not hasattr(self, 'channelsCombobox'): + if not hasattr(self, "channelsCombobox"): return - - items = ['None'] + + items = ["None"] items.extend(chNames) self.channelsCombobox.addItems(items) - + def getValueFromMetadata(self, name): try: - value = self.df_metadata.at[name, 'values'] + value = self.df_metadata.at[name, "values"] except Exception as e: # traceback.print_exc() value = None return value def criticalSegmFileRequiredButNoneAvailable(self): - model_name = f'{self.model_name} model' + model_name = f"{self.model_name} model" action_txt = ( - 'Please, segment the correct channel before using ' - f'{self.model_name}.' + f"Please, segment the correct channel before using {self.model_name}." ) - if self.model_name == 'skip_segmentation': - model_name = 'Skipping the segmentation' + if self.model_name == "skip_segmentation": + model_name = "Skipping the segmentation" action_txt = ( - 'To be able to skip the segmentation step, you need ' - 'create at least one segmentation file.' + "To be able to skip the segmentation step, you need " + "create at least one segmentation file." ) txt = html_utils.paragraph(f""" {model_name} @@ -12160,30 +12232,26 @@ def criticalSegmFileRequiredButNoneAvailable(self):

Thank you for you patience! """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Segmentation file required', txt) + msg.warning(self, "Segmentation file required", txt) raise FileNotFoundError( - 'Model requires segmentation file but none are available.' + "Model requires segmentation file but none are available." ) - - + def checkAddSegmEndnameCombobox(self, ArgSpec, groupBoxLayout, row): - if ArgSpec.name != 'Auxiliary segmentation file': + if ArgSpec.name != "Auxiliary segmentation file": return False - + if self.segmFileEndnames is None or not self.segmFileEndnames: self.criticalSegmFileRequiredButNoneAvailable() - - label = QLabel(f'{ArgSpec.name}: ') - groupBoxLayout.addWidget( - label, row, 0, alignment=Qt.AlignRight - ) + + label = QLabel(f"{ArgSpec.name}: ") + groupBoxLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) items = self.segmFileEndnames self.segmEndnameCombobox = widgets.QCenteredComboBox() self.segmEndnameCombobox.addItems(items) groupBoxLayout.addWidget(self.segmEndnameCombobox, row, 1, 1, 2) return True - - + def createGroupParams(self, ArgSpecs_list, groupName, addChannelSelector=False): ArgsWidgets_list = [] groupBox = QGroupBox(groupName) @@ -12191,78 +12259,70 @@ def createGroupParams(self, ArgSpecs_list, groupName, addChannelSelector=False): start_row = 0 if self.is_tracker and self.channels is not None and addChannelSelector: - label = QLabel(f'Input image: ') - groupBoxLayout.addWidget( - label, start_row, 0, alignment=Qt.AlignRight - ) - items = ['None', *self.channels] + label = QLabel(f"Input image: ") + groupBoxLayout.addWidget(label, start_row, 0, alignment=Qt.AlignRight) + items = ["None", *self.channels] self.channelCombobox = widgets.QCenteredComboBox() self.channelCombobox.addItems(items) groupBoxLayout.addWidget(self.channelCombobox, start_row, 1, 1, 2) if self.currentChannelName is not None: self.channelCombobox.setCurrentText(self.currentChannelName) infoText = ( - 'Some trackers require the intensity image as input.

' - 'If this one does not require it, leave the selected value ' - 'to `None`.' + "Some trackers require the intensity image as input.

" + "If this one does not require it, leave the selected value " + "to `None`." ) - infoButton = self.getInfoButton('Input image', infoText) + infoButton = self.getInfoButton("Input image", infoText) groupBoxLayout.addWidget(infoButton, start_row, 3) start_row += 1 - + addSecondChannelSelector = addChannelSelector if len(ArgSpecs_list) > 0: if addSecondChannelSelector and ArgSpecs_list[0].docstring is not None: - isSingleChannel = ArgSpecs_list[0].docstring.lower().find( - 'single channel only' - ) != -1 + isSingleChannel = ( + ArgSpecs_list[0].docstring.lower().find("single channel only") != -1 + ) if isSingleChannel: addSecondChannelSelector = False - - isDualChannelModel = ( - self.model_name.find('cellpose') != -1 - or any([ - _types.is_second_channel_type(ArgSpec.type) - for ArgSpec in ArgSpecs_list - ]) + + isDualChannelModel = self.model_name.find("cellpose") != -1 or any( + [_types.is_second_channel_type(ArgSpec.type) for ArgSpec in ArgSpecs_list] ) askSecondChannel = isDualChannelModel and addSecondChannelSelector - + if askSecondChannel: - label = QLabel('Second channel (optional): ') + label = QLabel("Second channel (optional): ") groupBoxLayout.addWidget(label, start_row, 0, alignment=Qt.AlignRight) self.channelsCombobox = widgets.QCenteredComboBox() groupBoxLayout.addWidget(self.channelsCombobox, start_row, 1, 1, 2) infoText = ( - 'Some models can merge two channels (e.g., cyto + ' - 'nucleus) to obtain better perfomance.\n\n' - 'Select a channel as additional input to the model.' + "Some models can merge two channels (e.g., cyto + " + "nucleus) to obtain better perfomance.\n\n" + "Select a channel as additional input to the model." ) - infoButton = self.getInfoButton('Second channel', infoText) + infoButton = self.getInfoButton("Second channel", infoText) groupBoxLayout.addWidget(infoButton, start_row, 3) start_row += 1 - + exclusive_withs = dict() default_exclusives = dict() row_mapper = dict() for row, ArgSpec in enumerate(ArgSpecs_list): if _types.is_second_channel_type(ArgSpec.type): continue - + if _types.is_widget_not_required(ArgSpec): continue - + row = row + start_row - skip = self.checkAddSegmEndnameCombobox( - ArgSpec, groupBoxLayout, row - ) + skip = self.checkAddSegmEndnameCombobox(ArgSpec, groupBoxLayout, row) if skip: continue - - arg_name = ArgSpec.name - var_name = arg_name.replace('_', ' ') - var_name = f'{var_name[0].upper()}{var_name[1:]}' - label = QLabel(f'{var_name}: ') + + arg_name = ArgSpec.name + var_name = arg_name.replace("_", " ") + var_name = f"{var_name[0].upper()}{var_name[1:]}" + label = QLabel(f"{var_name}: ") metadata_val = self.getValueFromMetadata(ArgSpec.name) groupBoxLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) try: @@ -12270,14 +12330,14 @@ def createGroupParams(self, ArgSpecs_list, groupName, addChannelSelector=False): isCustomListType = True except Exception as err: isCustomListType = False - + isVectorEntry = False try: if isinstance(ArgSpec.type(), _types.Vector): isVectorEntry = True except Exception as err: pass - + isFolderPath = False try: if isinstance(ArgSpec.type(), _types.FolderPath): @@ -12289,18 +12349,18 @@ def createGroupParams(self, ArgSpecs_list, groupName, addChannelSelector=False): exclusive_with = ArgSpec.type().is_exclusive_with except Exception as err: exclusive_with = [] - + try: default_exclusive = ArgSpec.type().default_exclusive except Exception as err: - default_exclusive = '' + default_exclusive = "" exclusive_withs[arg_name] = exclusive_with default_exclusives[arg_name] = default_exclusive row_mapper[arg_name] = row - - isCustomWidget = hasattr(ArgSpec.type, 'isWidget') - + + isCustomWidget = hasattr(ArgSpec.type, "isWidget") + if isCustomWidget: widget = ArgSpec.type().widget defaultVal = ArgSpec.default @@ -12395,11 +12455,11 @@ def createGroupParams(self, ArgSpecs_list, groupName, addChannelSelector=False): valueGetter = QLineEdit.text changeSig = lineEdit.editingFinished groupBoxLayout.addWidget(lineEdit, row, 1, 1, 2) - + if ArgSpec.desc: infoButton = self.getInfoButton(ArgSpec.name, ArgSpec.desc) groupBoxLayout.addWidget(infoButton, row, 3) - + argsInfo = ArgWidget( name=ArgSpec.name, type=ArgSpec.type, @@ -12407,13 +12467,11 @@ def createGroupParams(self, ArgSpecs_list, groupName, addChannelSelector=False): defaultVal=defaultVal, valueSetter=valueSetter, valueGetter=valueGetter, - changeSig=changeSig + changeSig=changeSig, ) ArgsWidgets_list.append(argsInfo) - - exclusive_group = core.connected_components_in_undirected_graph( - exclusive_withs - ) + + exclusive_group = core.connected_components_in_undirected_graph(exclusive_withs) for group in exclusive_group: if len(group) == 1: @@ -12438,8 +12496,12 @@ def createGroupParams(self, ArgSpecs_list, groupName, addChannelSelector=False): argsInfo_other = ArgsWidgets_list[row_other] changeSig_other = argsInfo_other.changeSig changeSig_other.connect( - partial(qutils.set_exclusive_valueSetter, widget, - valueSetter, default_exclusive) + partial( + qutils.set_exclusive_valueSetter, + widget, + valueSetter, + default_exclusive, + ) ) groupBoxLayout.setColumnStretch(0, 0) @@ -12447,7 +12509,7 @@ def createGroupParams(self, ArgSpecs_list, groupName, addChannelSelector=False): groupBoxLayout.setColumnStretch(3, 0) nrows = groupBoxLayout.rowCount() groupBoxLayout.setRowStretch(nrows, 1) - + groupBox.setLayout(groupBoxLayout) return groupBox, ArgsWidgets_list @@ -12455,73 +12517,65 @@ def getInfoButton(self, param_name, infoText): infoButton = widgets.infoPushButton() infoButton.param_name = param_name infoButton.setToolTip( - f'Click to get more info about `{param_name}` parameter...' + f"Click to get more info about `{param_name}` parameter..." ) infoButton.infoText = infoText infoButton.clicked.connect(self.showInfoParam) return infoButton - + def showInfoParam(self): text = self.sender().infoText - text = text.replace('\n', '
') + text = text.replace("\n", "
") text = html_utils.rst_urls_to_html(text) text = html_utils.rst_to_html(text) text = html_utils.paragraph(text) param_name = self.sender().param_name msg = widgets.myMessageBox(wrapText=False) - msg.information(self, f'Info about `{param_name}` parameter', text) - + msg.information(self, f"Info about `{param_name}` parameter", text) + def restoreDefaultInit(self): for argWidget in self.init_argsWidgets: defaultVal = argWidget.defaultVal widget = argWidget.widget valueSetter = argWidget.valueSetter - qutils.set_exclusive_valueSetter( - widget, valueSetter, defaultVal - ) + qutils.set_exclusive_valueSetter(widget, valueSetter, defaultVal) def restoreDefaultSegment(self): for argWidget in self.argsWidgets: defaultVal = argWidget.defaultVal widget = argWidget.widget valueSetter = argWidget.valueSetter - qutils.set_exclusive_valueSetter( - widget, valueSetter, defaultVal - ) + qutils.set_exclusive_valueSetter(widget, valueSetter, defaultVal) def restoreDefaultExtra(self): for argWidget in self.extraArgsWidgets: defaultVal = argWidget.defaultVal widget = argWidget.widget valueSetter = argWidget.valueSetter - qutils.set_exclusive_valueSetter( - widget, valueSetter, defaultVal - ) + qutils.set_exclusive_valueSetter(widget, valueSetter, defaultVal) def restoreDefaultPostprocess(self): self.postProcessGroupbox.restoreDefault() def readLastSelection(self): self.ini_path = os.path.join(settings_folderpath, self.ini_filename) - + if not os.path.exists(self.ini_path): return None - print(f'Reading last selected parameters from: {self.ini_path}') + print(f"Reading last selected parameters from: {self.ini_path}") configPars = config.ConfigParser() configPars.read(self.ini_path) return configPars def setValuesFromParams(self, init_params, segment_params, extra_params=None): sections = { - f'{self.model_name}.init': (init_params, self.init_argsWidgets), - f'{self.model_name}.segment': (segment_params, self.argsWidgets), + f"{self.model_name}.init": (init_params, self.init_argsWidgets), + f"{self.model_name}.segment": (segment_params, self.argsWidgets), } if extra_params is not None: - sections[f'{self.model_name}.extra'] = ( - extra_params, self.extraArgsWidgets - ) - + sections[f"{self.model_name}.extra"] = (extra_params, self.extraArgsWidgets) + for section, values in sections.items(): params, argWidgetList = values for argWidget in argWidgetList: @@ -12536,22 +12590,20 @@ def setValuesFromParams(self, init_params, segment_params, extra_params=None): break except Exception as e: continue - - def loadLastSelection( - self, section, argWidgetList, checked=False, configPars=None - ): + + def loadLastSelection(self, section, argWidgetList, checked=False, configPars=None): if self.configPars is None and configPars is None: return if configPars is None: configPars = self.configPars - - getters = ['getboolean', 'getint', 'getfloat', 'get'] + + getters = ["getboolean", "getint", "getfloat", "get"] try: options = configPars.options(section) except Exception: return - + for argWidget in argWidgetList: option = argWidget.name val = None @@ -12562,20 +12614,18 @@ def loadLastSelection( except Exception as err: pass widget = argWidget.widget - - if hasattr(widget, 'isMetadataValue'): + + if hasattr(widget, "isMetadataValue"): continue if val is None: continue - + casters = [lambda x: x, int, float, str, bool] for caster in casters: try: val = caster(val) valueSetter = argWidget.valueSetter - qutils.set_exclusive_valueSetter( - widget, valueSetter, val - ) + qutils.set_exclusive_valueSetter(widget, valueSetter, val) break except Exception as e: printl(traceback.format_exc()) @@ -12584,8 +12634,8 @@ def loadLastSelection( def loadLastSelectionPostProcess(self, checked=False, configPars=None): if self.postProcessGroupbox is None: return - - postProcessSection = f'{self.model_name}.postprocess' + + postProcessSection = f"{self.model_name}.postprocess" if isinstance(configPars, bool): configPars = None @@ -12595,57 +12645,53 @@ def loadLastSelectionPostProcess(self, checked=False, configPars=None): if postProcessSection in configPars.sections(): try: - minSize = configPars.getint( - postProcessSection, 'minSize', fallback=10 - ) + minSize = configPars.getint(postProcessSection, "minSize", fallback=10) except ValueError: minSize = 10 try: minSolidity = configPars.getfloat( - postProcessSection, 'minSolidity', fallback=0.5 + postProcessSection, "minSolidity", fallback=0.5 ) except ValueError: minSolidity = 0.5 - try: + try: maxElongation = configPars.getfloat( - postProcessSection, 'maxElongation', fallback=3 + postProcessSection, "maxElongation", fallback=3 ) except ValueError: maxElongation = 3 - + try: minObjSizeZ = configPars.getint( - postProcessSection, 'min_obj_no_zslices', fallback=3 + postProcessSection, "min_obj_no_zslices", fallback=3 ) except ValueError: minObjSizeZ = 3 - + kwargs = { - 'min_solidity': minSolidity, - 'min_area': minSize, - 'max_elongation': maxElongation, - 'min_obj_no_zslices': minObjSizeZ + "min_solidity": minSolidity, + "min_area": minSize, + "max_elongation": maxElongation, + "min_obj_no_zslices": minObjSizeZ, } self.postProcessGroupbox.restoreFromKwargs(kwargs) applyPostProcessing = configPars.getboolean( - postProcessSection, 'applyPostProcessing' + postProcessSection, "applyPostProcessing" ) self.postProcessGroupbox.setChecked(applyPostProcessing) - customPostProcessSection = f'{self.model_name}.custom_postprocess' + customPostProcessSection = f"{self.model_name}.custom_postprocess" if postProcessSection not in configPars.sections(): return - - selectFeaturesWidget = ( - self.postProcessGroupbox.selectedFeaturesDialog.groupbox - ) + + selectFeaturesWidget = self.postProcessGroupbox.selectedFeaturesDialog.groupbox selectFeaturesWidget.resetFields() f = 0 for col_name, value in configPars[customPostProcessSection].items(): - low, high = value.split(',') + low, high = value.split(",") low = low.strip() high = high.strip() if f > 0: @@ -12658,7 +12704,7 @@ def loadLastSelectionPostProcess(self, checked=False, configPars=None): feature_group = measurements.get_metric_group_name(col_name) selector.featureGroup = feature_group - if low != 'None': + if low != "None": try: low_val = int(low) except ValueError: @@ -12667,7 +12713,7 @@ def loadLastSelectionPostProcess(self, checked=False, configPars=None): selector.lowRangeWidgets.checkbox.setChecked(True) selector.lowRangeWidgets.spinbox.setValue(low_val) - if high != 'None': + if high != "None": try: high_val = int(high) except ValueError: @@ -12679,7 +12725,7 @@ def loadLastSelectionPostProcess(self, checked=False, configPars=None): f += 1 def createSeeHereLabel(self, url): - htmlTxt = f'here' + htmlTxt = f'here' seeHereLabel = QLabel() seeHereLabel.setText(f"""

@@ -12694,32 +12740,30 @@ def createSeeHereLabel(self, url): def argsWidgets_to_kwargs(self, argsWidgets): kwargs_dict = { - argWidget.name:argWidget.valueGetter(argWidget.widget) + argWidget.name: argWidget.valueGetter(argWidget.widget) for argWidget in argsWidgets } return kwargs_dict def getInitKwargs(self): init_kwargs = self.argsWidgets_to_kwargs(self.init_argsWidgets) - if hasattr(self, 'segmEndnameCombobox'): - init_kwargs['segm_endname'] = ( - self.segmEndnameCombobox.currentText() - ) - + if hasattr(self, "segmEndnameCombobox"): + init_kwargs["segm_endname"] = self.segmEndnameCombobox.currentText() + return init_kwargs - + def getModelKwargs(self): if self.skipSegmentation: return {} - + return self.argsWidgets_to_kwargs(self.argsWidgets) - + def getExtraKwargs(self): if self.extraArgsWidgets is None: return {} - + return self.argsWidgets_to_kwargs(self.extraArgsWidgets) - + def ok_cb(self, checked): self.cancel = False self.preproc_recipe = None @@ -12727,7 +12771,7 @@ def ok_cb(self, checked): self.preproc_recipe = self.preProcessParamsWidget.recipe() if self.preproc_recipe is None: return - + self.init_kwargs = self.getInitKwargs() if self.extraArgsWidgets: @@ -12740,16 +12784,16 @@ def ok_cb(self, checked): self.applyPostProcessing = self.postProcessGroupbox.isChecked() self.standardPostProcessKwargs = self.postProcessGroupbox.kwargs() self.secondChannelName = None - if hasattr(self, 'channelsCombobox'): + if hasattr(self, "channelsCombobox"): self.secondChannelName = self.channelsCombobox.currentText() - if self.secondChannelName == 'None': + if self.secondChannelName == "None": self.secondChannelName = None - self.inputChannelName = 'None' + self.inputChannelName = "None" if self.channelCombobox is not None: self.inputChannelName = self.channelCombobox.currentText() - + self.reduceMemoryUsage = False - if hasattr(self, 'reduceMemUsageToggle'): + if hasattr(self, "reduceMemUsageToggle"): self.reduceMemoryUsage = self.reduceMemUsageToggle.isChecked() self.customPostProcessFeatures = self.selectedFeaturesRange() self.customPostProcessGroupedFeatures = self.groupedFeatures() @@ -12758,14 +12802,18 @@ def ok_cb(self, checked): self.close() def freePosData(self): - if hasattr(self, 'postProcessGroupbox'): + if hasattr(self, "postProcessGroupbox"): try: - for selector in self.postProcessGroupbox.selectedFeaturesDialog.groupbox.selectors: + for ( + selector + ) in self.postProcessGroupbox.selectedFeaturesDialog.groupbox.selectors: qutils.hardDelete(selector) except AttributeError: pass try: - qutils.hardDelete(self.postProcessGroupbox.selectedFeaturesDialog.groupbox) + qutils.hardDelete( + self.postProcessGroupbox.selectedFeaturesDialog.groupbox + ) except AttributeError: pass try: @@ -12782,47 +12830,43 @@ def getConfigPars(self, create_new=False): configPars = config.ConfigParser() else: configPars = self.configPars - + if self.preProcessParamsWidget is not None: - preprocCp = self.preProcessParamsWidget.recipeConfigPars( - self.model_name - ) + preprocCp = self.preProcessParamsWidget.recipeConfigPars(self.model_name) for section in preprocCp.sections(): configPars[section] = preprocCp[section] - - configPars[f'{self.model_name}.init'] = {} - configPars[f'{self.model_name}.segment'] = {} - configPars[f'{self.model_name}.extra'] = {} + + configPars[f"{self.model_name}.init"] = {} + configPars[f"{self.model_name}.segment"] = {} + configPars[f"{self.model_name}.extra"] = {} init_kwargs = self.getInitKwargs() model_kwargs = self.getModelKwargs() - + for key, val in init_kwargs.items(): - configPars[f'{self.model_name}.init'][key] = str(val) + configPars[f"{self.model_name}.init"][key] = str(val) for key, val in model_kwargs.items(): - configPars[f'{self.model_name}.segment'][key] = str(val) + configPars[f"{self.model_name}.segment"][key] = str(val) if self.extraArgsWidgets: extra_kwargs = self.getExtraKwargs() for key, val in extra_kwargs.items(): - configPars[f'{self.model_name}.extra'][key] = str(val) + configPars[f"{self.model_name}.extra"][key] = str(val) - configPars[f'{self.model_name}.postprocess'] = {} + configPars[f"{self.model_name}.postprocess"] = {} if self.postProcessGroupbox is not None: postProcKwargs = self.postProcessGroupbox.kwargs() - postProcessConfig = configPars[f'{self.model_name}.postprocess'] - postProcessConfig['minSize'] = str(postProcKwargs['min_area']) - postProcessConfig['minSolidity'] = str(postProcKwargs['min_solidity']) - postProcessConfig['maxElongation'] = str( - postProcKwargs['max_elongation'] - ) - postProcessConfig['min_obj_no_zslices'] = str( - postProcKwargs['min_obj_no_zslices'] + postProcessConfig = configPars[f"{self.model_name}.postprocess"] + postProcessConfig["minSize"] = str(postProcKwargs["min_area"]) + postProcessConfig["minSolidity"] = str(postProcKwargs["min_solidity"]) + postProcessConfig["maxElongation"] = str(postProcKwargs["max_elongation"]) + postProcessConfig["min_obj_no_zslices"] = str( + postProcKwargs["min_obj_no_zslices"] ) - postProcessConfig['applyPostProcessing'] = str( + postProcessConfig["applyPostProcessing"] = str( self.postProcessGroupbox.isChecked() ) - - custom_postproc_section = f'{self.model_name}.custom_postprocess' + + custom_postproc_section = f"{self.model_name}.custom_postprocess" configPars[custom_postproc_section] = {} if self.postProcessGroupbox is not None: selectFeaturesWidget = ( @@ -12830,8 +12874,8 @@ def getConfigPars(self, create_new=False): ) for selector in selectFeaturesWidget.selectors: col_name = selector.selectButton.text() - lowStr = 'None' - highStr = 'None' + lowStr = "None" + highStr = "None" if selector.lowRangeWidgets.checkbox.isChecked(): lowVal = selector.lowRangeWidgets.spinbox.value() lowStr = str(lowVal) @@ -12839,29 +12883,26 @@ def getConfigPars(self, create_new=False): highVal = selector.highRangeWidgets.spinbox.value() highStr = str(highVal) - configPars[custom_postproc_section][col_name] = ( - f'{lowStr}, {highStr}' - ) + configPars[custom_postproc_section][col_name] = f"{lowStr}, {highStr}" - return configPars - + def saveLastSelection(self): self.configPars = self.getConfigPars() - with open(self.ini_path, 'w') as configfile: + with open(self.ini_path, "w") as configfile: self.configPars.write(configfile) - mode = 'Segmentation' if not self.is_tracker else 'Tracking' - + mode = "Segmentation" if not self.is_tracker else "Tracking" + print(f'{mode} parameters saved at "{self.ini_path}"') - + def exec_(self): self.show(block=True) def show(self, block=False): self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) super().show() - if self.model_name == 'thresholding': + if self.model_name == "thresholding": self.segmentGroupBox.setDisabled(True) if block: self.loop = QEventLoop() @@ -12869,18 +12910,16 @@ def show(self, block=False): def closeEvent(self, event): self.freePosData() - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() def cancel_cb(self, checked): self.cancel = True self.freePosData() - + def showEvent(self, event) -> None: buttonHeight = self.okButton.minimumSizeHint().height() - heightInitParams = ( - self.initParamsScrollArea.minimumHeightNoScrollbar() - ) + heightInitParams = self.initParamsScrollArea.minimumHeightNoScrollbar() heightLeft = 70 + buttonHeight heightCenter = heightInitParams heightRight = 0 @@ -12888,35 +12927,33 @@ def showEvent(self, event) -> None: heightSegmentParams = ( self.segmentParamsScrollArea.minimumHeightNoScrollbar() ) - heightCenter += (heightSegmentParams+ 70 + buttonHeight) - + heightCenter += heightSegmentParams + 70 + buttonHeight + rowInitParams, _ = self.paramsGroupPosMapper[self.initParamsScrollArea] rowSegmParams, _ = self.paramsGroupPosMapper[self.segmentParamsScrollArea] - + numInitParams = len(self.init_params) numSegmentParams = len(self.segment_params) - + try: - segmentParamsStretch = max(1, round(numSegmentParams/numInitParams)) + segmentParamsStretch = max(1, round(numSegmentParams / numInitParams)) except ZeroDivisionError as err: segmentParamsStretch = 1 self.secondColLayout.setStretch(rowInitParams, 1) self.secondColLayout.setStretch(rowSegmParams, segmentParamsStretch) - + if self.extraParamsScrollArea is not None: heightRight += ( self.extraParamsScrollArea.minimumHeightNoScrollbar() - + 70 + buttonHeight + + 70 + + buttonHeight ) - - + if self.additionalSegmGroupbox is not None: heightRight += self.additionalSegmGroupbox.minimumSizeHint().height() heightRight += buttonHeight if self.preProcessParamsWidget is not None: - heightPreprocParams = ( - self.preProcessParamsWidget.minimumSizeHint().height() - ) + heightPreprocParams = self.preProcessParamsWidget.minimumSizeHint().height() heightLeft += heightPreprocParams heightLeft += buttonHeight if self.postProcessGroupbox is not None: @@ -12931,13 +12968,14 @@ def showEvent(self, event) -> None: screenRight = screenGeom.right() screenCenter = (screenLeft + screenRight) / 2 width = self.sizeHint().width() - windowLeft = int(screenCenter - width/2) + windowLeft = int(screenCenter - width / 2) self.move(windowLeft, 20) - + if height >= screenHeight - 150: height = screenHeight - 150 self.resize(width, height) + class downloadModel: def __init__(self, model_name, parent=None): self.loop = None @@ -12948,10 +12986,8 @@ def download(self): model_url = myutils._model_url(self.model_name) if model_url is None: return - - _, model_path = myutils.get_model_path( - self.model_name, create_temp_dir=False - ) + + _, model_path = myutils.get_model_path(self.model_name, create_temp_dir=False) model_name = self.model_name model_exists = myutils.check_model_exists(model_path, model_name) if not model_exists: @@ -12962,30 +12998,29 @@ def download(self): ) except Exception as err: pass - + success = myutils.download_model(self.model_name) if not success: self.criticalDowloadFailed() - + def warnDownloadModel(self, model_path, model_name): txt = html_utils.paragraph( - 'Cell-ACDC needs to download the model ' - f'{model_name}.

' - 'The files will be dowloaded into the following folder:

' - f'{model_path}

' - 'Progress will be displayed in the terminal.
' + "Cell-ACDC needs to download the model " + f"{model_name}.

" + "The files will be dowloaded into the following folder:

" + f"{model_path}

" + "Progress will be displayed in the terminal.
" ) msg = widgets.myMessageBox() - msg.information(self._parent, 'Download model', txt) + msg.information(self._parent, "Download model", txt) def criticalDowloadFailed(self): import cellacdc + model_name = self.model_name m = model_name.lower() - weights_filenames = getattr(cellacdc, f'{m}_weights_filenames') - url, alternative_url = myutils._model_url( - model_name, return_alternative=True - ) + weights_filenames = getattr(cellacdc, f"{m}_weights_filenames") + url, alternative_url = myutils._model_url(model_name, return_alternative=True) url_href = f'this link' alternative_url_href = f'this link' _, model_path = myutils.get_model_path(model_name, create_temp_dir=False) @@ -13003,15 +13038,14 @@ def criticalDowloadFailed(self): {alternative_url} """) weights_paths = [os.path.join(model_path, f) for f in weights_filenames] - weights = '\n\n'.join(weights_paths) - detailsText = ( - f'Files that {model_name} requires:\n\n' - f'{weights}' - ) + weights = "\n\n".join(weights_paths) + detailsText = f"Files that {model_name} requires:\n\n{weights}" msg = widgets.myMessageBox() msg.critical( - self._parent, f'Download of {model_name} failed', txt, - detailsText=detailsText + self._parent, + f"Download of {model_name} failed", + txt, + detailsText=detailsText, ) self.close_() @@ -13022,19 +13056,19 @@ def close_(self): # if self.loop is not None: # self.loop.exit() + class combineMetricsEquationDialog(QBaseDialog): sigOk = Signal(object) def __init__( - self, allChNames, isZstack, isSegm3D, parent=None, debug=False, - closeOnOk=True - ): + self, allChNames, isZstack, isSegm3D, parent=None, debug=False, closeOnOk=True + ): super().__init__(parent) - self.setWindowTitle('Add combined measurement') + self.setWindowTitle("Add combined measurement") self.initAttributes() - + self.allChNames = allChNames self.cancel = True @@ -13051,7 +13085,7 @@ def __init__( for chName in allChNames: channelTreeItem = QTreeWidgetItem(metricsTreeWidget) - channelTreeItem.setText(0, f'{chName} measurements') + channelTreeItem.setText(0, f"{chName} measurements") metricsTreeWidget.addTopLevelItem(channelTreeItem) metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc( @@ -13062,52 +13096,41 @@ def __init__( ) foregrMetricsTreeItem = QTreeWidgetItem(channelTreeItem) - foregrMetricsTreeItem.setText(0, 'Cell signal measurements') + foregrMetricsTreeItem.setText(0, "Cell signal measurements") channelTreeItem.addChild(foregrMetricsTreeItem) bkgrMetricsTreeItem = QTreeWidgetItem(channelTreeItem) - bkgrMetricsTreeItem.setText(0, 'Background values') + bkgrMetricsTreeItem.setText(0, "Background values") channelTreeItem.addChild(bkgrMetricsTreeItem) if custom_metrics_desc: customMetricsTreeItem = QTreeWidgetItem(channelTreeItem) - customMetricsTreeItem.setText(0, 'Custom measurements') + customMetricsTreeItem.setText(0, "Custom measurements") channelTreeItem.addChild(customMetricsTreeItem) - self.addTreeItems( - foregrMetricsTreeItem, metrics_desc.keys(), isCol=True - ) - self.addTreeItems( - bkgrMetricsTreeItem, bkgr_val_desc.keys(), isCol=True - ) + self.addTreeItems(foregrMetricsTreeItem, metrics_desc.keys(), isCol=True) + self.addTreeItems(bkgrMetricsTreeItem, bkgr_val_desc.keys(), isCol=True) if custom_metrics_desc: self.addTreeItems( - customMetricsTreeItem, custom_metrics_desc.keys(), - isCol=True + customMetricsTreeItem, custom_metrics_desc.keys(), isCol=True ) self.addChannelLessItems(isZstack, isSegm3D=isSegm3D) sizeMetricsTreeItem = QTreeWidgetItem(metricsTreeWidget) - sizeMetricsTreeItem.setText(0, 'Size measurements') + sizeMetricsTreeItem.setText(0, "Size measurements") metricsTreeWidget.addTopLevelItem(sizeMetricsTreeItem) - size_metrics_desc = measurements.get_size_metrics_desc( - isSegm3D, True - ) - self.addTreeItems( - sizeMetricsTreeItem, size_metrics_desc.keys(), isCol=True - ) + size_metrics_desc = measurements.get_size_metrics_desc(isSegm3D, True) + self.addTreeItems(sizeMetricsTreeItem, size_metrics_desc.keys(), isCol=True) propMetricsTreeItem = QTreeWidgetItem(metricsTreeWidget) - propMetricsTreeItem.setText(0, 'Region properties') + propMetricsTreeItem.setText(0, "Region properties") metricsTreeWidget.addTopLevelItem(propMetricsTreeItem) props_names = measurements.get_props_names() - self.addTreeItems( - propMetricsTreeItem, props_names, isCol=True - ) + self.addTreeItems(propMetricsTreeItem, props_names, isCol=True) operatorsLayout = QHBoxLayout() operatorsLayout.addStretch(1) @@ -13116,23 +13139,23 @@ def __init__( self.operatorButtons = [] self.operators = [ - ('add', '+'), - ('subtract', '-'), - ('multiply', '*'), - ('divide', '/'), - ('open_bracket', '('), - ('close_bracket', ')'), - ('square', '**2'), - ('pow', '**'), - ('ln', 'log('), - ('log10', 'log10('), + ("add", "+"), + ("subtract", "-"), + ("multiply", "*"), + ("divide", "/"), + ("open_bracket", "("), + ("close_bracket", ")"), + ("square", "**2"), + ("pow", "**"), + ("ln", "log("), + ("log10", "log10("), ] operatorFont = QFont() operatorFont.setPixelSize(16) for name, text in self.operators: button = QPushButton() - button.setIcon(QIcon(f':{name}.svg')) - button.setIconSize(QSize(iconSize,iconSize)) + button.setIcon(QIcon(f":{name}.svg")) + button.setIconSize(QSize(iconSize, iconSize)) button.text = text operatorsLayout.addWidget(button) self.operatorButtons.append(button) @@ -13140,14 +13163,14 @@ def __init__( # button.setFont(operatorFont) clearButton = QPushButton() - clearButton.setIcon(QIcon(':clear.svg')) - clearButton.setIconSize(QSize(iconSize,iconSize)) + clearButton.setIcon(QIcon(":clear.svg")) + clearButton.setIconSize(QSize(iconSize, iconSize)) clearButton.setFont(operatorFont) clearEntryButton = QPushButton() - clearEntryButton.setIcon(QIcon(':backspace.svg')) + clearEntryButton.setIcon(QIcon(":backspace.svg")) clearEntryButton.setFont(operatorFont) - clearEntryButton.setIconSize(QSize(iconSize,iconSize)) + clearEntryButton.setIconSize(QSize(iconSize, iconSize)) operatorsLayout.addWidget(clearButton) operatorsLayout.addWidget(clearEntryButton) @@ -13158,34 +13181,34 @@ def __init__( newColNameLineEdit.setAlignment(Qt.AlignCenter) self.newColNameLineEdit = newColNameLineEdit newColNameLayout.addStretch(1) - newColNameLayout.addWidget(QLabel('New measurement name:')) + newColNameLayout.addWidget(QLabel("New measurement name:")) newColNameLayout.addWidget(newColNameLineEdit) newColNameLayout.addStretch(1) equationDisplayLayout = QVBoxLayout() - equationDisplayLayout.addWidget(QLabel('Equation:')) + equationDisplayLayout.addWidget(QLabel("Equation:")) equationDisplay = QPlainTextEdit() # equationDisplay.setReadOnly(True) self.equationDisplay = equationDisplay equationDisplayLayout.addWidget(equationDisplay) - equationDisplayLayout.setStretch(0,0) - equationDisplayLayout.setStretch(1,1) + equationDisplayLayout.setStretch(0, 0) + equationDisplayLayout.setStretch(1, 1) equationLayout.addLayout(newColNameLayout) - equationLayout.addWidget(QLabel(' = ')) + equationLayout.addWidget(QLabel(" = ")) equationLayout.addLayout(equationDisplayLayout) - equationLayout.setStretch(0,1) - equationLayout.setStretch(1,0) - equationLayout.setStretch(2,2) + equationLayout.setStretch(0, 1) + equationLayout.setStretch(1, 0) + equationLayout.setStretch(2, 2) testOutputLayout = QVBoxLayout() - testOutputLayout.addWidget(QLabel('Result of test with random inputs:')) + testOutputLayout.addWidget(QLabel("Result of test with random inputs:")) testOutputDisplay = QTextEdit() testOutputDisplay.setReadOnly(True) self.testOutputDisplay = testOutputDisplay testOutputLayout.addWidget(testOutputDisplay) - testOutputLayout.setStretch(0,0) - testOutputLayout.setStretch(1,1) + testOutputLayout.setStretch(0, 0) + testOutputLayout.setStretch(1, 1) instructions = html_utils.paragraph(""" Double-click on any of the available measurements @@ -13198,17 +13221,17 @@ def __init__( buttonsLayout = QHBoxLayout() - cancelButton = widgets.cancelPushButton('Cancel') - helpButton = widgets.infoPushButton(' Help...') - testButton = widgets.calcPushButton('Test output') - okButton = widgets.okPushButton(' Ok ') + cancelButton = widgets.cancelPushButton("Cancel") + helpButton = widgets.infoPushButton(" Help...") + testButton = widgets.calcPushButton("Test output") + okButton = widgets.okPushButton(" Ok ") okButton.setDisabled(True) self.okButton = okButton buttonsLayout.addStretch(1) if debug: - debugButton = QPushButton('Debug') + debugButton = QPushButton("Debug") debugButton.clicked.connect(self._debug) buttonsLayout.addWidget(debugButton) @@ -13219,7 +13242,7 @@ def __init__( buttonsLayout.addWidget(okButton) mainLayout.addWidget(QLabel(instructions)) - mainLayout.addWidget(QLabel('Available measurements:')) + mainLayout.addWidget(QLabel("Available measurements:")) mainLayout.addWidget(metricsTreeWidget) mainLayout.addLayout(operatorsLayout) mainLayout.addLayout(equationLayout) @@ -13243,51 +13266,51 @@ def __init__( def addChannelLessItems(self, isZstack, isSegm3D=False): allChannelsTreeItem = QTreeWidgetItem(self.metricsTreeWidget) - allChannelsTreeItem.setText(0, f'All channels measurements') + allChannelsTreeItem.setText(0, f"All channels measurements") metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc( - isZstack, '', isSegm3D=isSegm3D + isZstack, "", isSegm3D=isSegm3D ) custom_metrics_desc = measurements.custom_metrics_desc( - isZstack, '', isSegm3D=isSegm3D + isZstack, "", isSegm3D=isSegm3D ) foregrMetricsTreeItem = QTreeWidgetItem(allChannelsTreeItem) - foregrMetricsTreeItem.setText(0, 'Cell signal measurements') + foregrMetricsTreeItem.setText(0, "Cell signal measurements") allChannelsTreeItem.addChild(foregrMetricsTreeItem) bkgrMetricsTreeItem = QTreeWidgetItem(allChannelsTreeItem) - bkgrMetricsTreeItem.setText(0, 'Background values') + bkgrMetricsTreeItem.setText(0, "Background values") allChannelsTreeItem.addChild(bkgrMetricsTreeItem) if custom_metrics_desc: customMetricsTreeItem = QTreeWidgetItem(allChannelsTreeItem) - customMetricsTreeItem.setText(0, 'Custom measurements') + customMetricsTreeItem.setText(0, "Custom measurements") allChannelsTreeItem.addChild(customMetricsTreeItem) self.addTreeItems( - foregrMetricsTreeItem, metrics_desc.keys(), isCol=True, - isChannelLess=True + foregrMetricsTreeItem, metrics_desc.keys(), isCol=True, isChannelLess=True ) self.addTreeItems( - bkgrMetricsTreeItem, bkgr_val_desc.keys(), isCol=True, - isChannelLess=True + bkgrMetricsTreeItem, bkgr_val_desc.keys(), isCol=True, isChannelLess=True ) if custom_metrics_desc: self.addTreeItems( - customMetricsTreeItem, custom_metrics_desc.keys(), - isCol=True, isChannelLess=True + customMetricsTreeItem, + custom_metrics_desc.keys(), + isCol=True, + isChannelLess=True, ) def addOperator(self): button = self.sender() - text = f'{self.equationDisplay.toPlainText()}{button.text}' + text = f"{self.equationDisplay.toPlainText()}{button.text}" self.equationDisplay.setPlainText(text) self.clearLenghts.append(len(button.text)) def clearEquation(self): self.isOperatorMode = False - self.equationDisplay.setPlainText('') + self.equationDisplay.setPlainText("") self.initAttributes() def initAttributes(self): @@ -13300,8 +13323,8 @@ def clearEntryEquation(self): return text = self.equationDisplay.toPlainText() - newText = text[:-self.clearLenghts[-1]] - clearedText = text[-self.clearLenghts[-1]:] + newText = text[: -self.clearLenghts[-1]] + clearedText = text[-self.clearLenghts[-1] :] self.clearLenghts.pop(-1) self.equationDisplay.setPlainText(newText) if clearedText in self.equationColNames: @@ -13309,9 +13332,7 @@ def clearEntryEquation(self): if clearedText in self.channelLessColnames: self.channelLessColnames.remove(clearedText) - def addTreeItems( - self, parentItem, itemsText, isCol=False, isChannelLess=False - ): + def addTreeItems(self, parentItem, itemsText, isCol=False, isChannelLess=False): for text in itemsText: _item = QTreeWidgetItem(parentItem) _item.setText(0, text) @@ -13320,13 +13341,12 @@ def addTreeItems( _item.isCol = True _item.isChannelLess = isChannelLess - def addColname(self, item, column): - if not hasattr(item, 'isCol'): + if not hasattr(item, "isCol"): return colName = item.text(0) - text = f'{self.equationDisplay.toPlainText()}{colName}' + text = f"{self.equationDisplay.toPlainText()}{colName}" self.equationDisplay.setPlainText(text) self.clearLenghts.append(len(colName)) self.equationColNames.append(colName) @@ -13348,13 +13368,13 @@ def getEquationsDict(self): if len(chNamesInTerms) == 1: # Equation uses metrics from a single channel --> append channel name chName = chNamesInTerms.pop() - chColName = f'{chName}_{newColName}' + chColName = f"{chName}_{newColName}" isMixedChannels = False - return {chColName:equation}, isMixedChannels + return {chColName: equation}, isMixedChannels else: # Equation doesn't use all channels metrics nor is single channel isMixedChannels = True - return {newColName:equation}, isMixedChannels + return {newColName: equation}, isMixedChannels isMixedChannels = False equations = {} @@ -13363,9 +13383,9 @@ def getEquationsDict(self): chEquationName = newColName # Append each channel name to channelLess terms for colName in self.channelLessColnames: - chColName = f'{chName}{colName}' + chColName = f"{chName}{colName}" chEquation = chEquation.replace(colName, chColName) - chEquationName = f'{chName}_{newColName}' + chEquationName = f"{chName}_{newColName}" equations[chEquationName] = chEquation return equations, isMixedChannels @@ -13373,7 +13393,7 @@ def ok_cb(self): if not self.newColNameLineEdit.text(): self.warnEmptyEquationName() return - + self.cancel = False # Save equation to "/acdc-metrics/combine_metrics.ini" file @@ -13390,14 +13410,13 @@ def ok_cb(self): channelLess_equation = self.equationDisplay.toPlainText() equation_name = self.newColNameLineEdit.text() config = measurements.add_channelLess_combine_metrics( - config, channelLess_equation, equation_name, - self.channelLessColnames + config, channelLess_equation, equation_name, self.channelLessColnames ) measurements.save_common_combine_metrics(config) self.sigOk.emit(self) - + if self.closeOnOk: self.close() @@ -13406,28 +13425,25 @@ def warnEmptyEquationName(self): txt = html_utils.paragraph(""" "New measurement name" field cannot be empty! """) - msg.critical( - self, 'Empty new measurement name', txt - ) + msg.critical(self, "Empty new measurement name", txt) def showHelp(self): txt = measurements.get_combine_metrics_help_txt() msg = widgets.myMessageBox( - showCentered=False, wrapText=False, - scrollableText=True, enlargeWidthFactor=1.7 + showCentered=False, + wrapText=False, + scrollableText=True, + enlargeWidthFactor=1.7, ) path = measurements.acdc_metrics_path - msg.addShowInFileManagerButton(path, txt='Show saved file...') - msg.information(self, 'Combine measurements help', txt) + msg.addShowInFileManagerButton(path, txt="Show saved file...") + msg.information(self, "Combine measurements help", txt) def test_cb(self): # Evaluate equation with random inputs equation = self.equationDisplay.toPlainText() - random_data = np.random.rand(1, len(self.equationColNames))*5 - df = pd.DataFrame( - data=random_data, - columns=self.equationColNames - ).round(5) + random_data = np.random.rand(1, len(self.equationColNames)) * 5 + df = pd.DataFrame(data=random_data, columns=self.equationColNames).round(5) newColName = self.newColNameLineEdit.text() try: df[newColName] = df.eval(equation) @@ -13450,7 +13466,7 @@ def test_cb(self): # Format output into html text cols = self.equationColNames - inputs_txt = [f'{col} = {input}' for col, input in zip(cols, inputs)] + inputs_txt = [f"{col} = {input}" for col, input in zip(cols, inputs)] list_html = html_utils.to_list(inputs_txt) text = html_utils.paragraph(f""" By substituting the following random inputs: @@ -13462,28 +13478,28 @@ def test_cb(self): """) self.testOutputDisplay.setHtml(text) + class stopFrameDialog(QBaseDialog): def __init__(self, posDatas, parent=None): super().__init__(parent=parent) self.cancel = True - self.setWindowTitle('Stop frame') + self.setWindowTitle("Stop frame") mainLayout = QVBoxLayout() infoTxt = html_utils.paragraph( - 'Enter a stop frame number for each of the loaded Positions', - center=True + "Enter a stop frame number for each of the loaded Positions", + center=True, ) exp_path = posDatas[0].exp_path exp_path = os.path.normpath(exp_path).split(os.sep) - exp_path = f'...{f"{os.sep}".join(exp_path[-4:])}' + exp_path = f"...{f'{os.sep}'.join(exp_path[-4:])}" subInfoTxt = html_utils.paragraph( - f'Experiment folder: {exp_path}', font_size='12px', - center=True + f"Experiment folder: {exp_path}", font_size="12px", center=True ) - infoLabel = QLabel(f'{infoTxt}{subInfoTxt}') + infoLabel = QLabel(f"{infoTxt}{subInfoTxt}") infoLabel.setToolTip(posDatas[0].exp_path) mainLayout.addWidget(infoLabel) mainLayout.addSpacing(20) @@ -13492,7 +13508,7 @@ def __init__(self, posDatas, parent=None): for posData in posDatas: _layout = QHBoxLayout() _layout.addStretch(1) - _label = QLabel(html_utils.paragraph(f'{posData.pos_foldername}')) + _label = QLabel(html_utils.paragraph(f"{posData.pos_foldername}")) _layout.addWidget(_label) _spinBox = QSpinBox() @@ -13500,7 +13516,7 @@ def __init__(self, posDatas, parent=None): _spinBox.setAlignment(Qt.AlignCenter) _spinBox.setFont(font) if posData.acdc_df is not None: - _val = posData.acdc_df.index.get_level_values(0).max()+1 + _val = posData.acdc_df.index.get_level_values(0).max() + 1 else: _val = posData.readLastUsedStopFrameNumber() if _val is None: @@ -13511,10 +13527,8 @@ def __init__(self, posDatas, parent=None): _layout.addWidget(_spinBox) - viewButton = widgets.viewPushButton('Visualize...') - viewButton.clicked.connect( - partial(self.viewChannelData, posData, _spinBox) - ) + viewButton = widgets.viewPushButton("Visualize...") + viewButton.clicked.connect(partial(self.viewChannelData, posData, _spinBox)) _layout.addWidget(viewButton, alignment=Qt.AlignRight) _layout.addStretch(1) @@ -13523,8 +13537,8 @@ def __init__(self, posDatas, parent=None): buttonsLayout = QHBoxLayout() - okButton = widgets.okPushButton(' Ok ') - cancelButton = widgets.cancelPushButton(' Cancel ') + okButton = widgets.okPushButton(" Ok ") + cancelButton = widgets.cancelPushButton(" Cancel ") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -13538,39 +13552,35 @@ def __init__(self, posDatas, parent=None): cancelButton.clicked.connect(self.close) self.setLayout(mainLayout) - + def viewChannelData(self, posData, spinBox): - self.sender().setText('Loading...') + self.sender().setText("Loading...") QTimer.singleShot( 200, partial(self._viewChannelData, posData, spinBox, self.sender()) ) - def _viewChannelData(self, posData, spinBox, senderButton): + def _viewChannelData(self, posData, spinBox, senderButton): chNames = posData.chNames if len(chNames) > 1: ch_name_selector = prompts.select_channel_name( - which_channel='segm', allow_abort=False + which_channel="segm", allow_abort=False ) ch_name_selector.QtPrompt( - self, chNames,'Select channel name to visualize: ' + self, chNames, "Select channel name to visualize: " ) if ch_name_selector.was_aborted: return chName = ch_name_selector.channel_name else: chName = chNames[0] - - channel_file_path = load.get_filename_from_channel( - posData.images_path, chName - ) + + channel_file_path = load.get_filename_from_channel(posData.images_path, chName) posData.frame_i = 0 posData.loadImgData(imgPath=channel_file_path) - self.slideshowWin = imageViewer( - posData=posData, spinBox=spinBox - ) + self.slideshowWin = imageViewer(posData=posData, spinBox=spinBox) self.slideshowWin.update_img() self.slideshowWin.show() - senderButton.setText('Visualize...') + senderButton.setText("Visualize...") def ok_cb(self): self.cancel = False @@ -13579,6 +13589,7 @@ def ok_cb(self): posData.stopFrameNum = stopFrameNum self.close() + class pgTestWindow(QWidget): def __init__(self, parent=None): super().__init__(parent) @@ -13602,7 +13613,7 @@ class CombineMetricsMultiDfsDialog(QBaseDialog): def __init__(self, acdcDfs, allChNames, parent=None, debug=False): super().__init__(parent) - self.setWindowTitle('Add combined measurement') + self.setWindowTitle("Add combined measurement") self.initAttributes() @@ -13625,63 +13636,63 @@ def __init__(self, acdcDfs, allChNames, parent=None, debug=False): for chName in allChNames: channelTreeItem = QTreeWidgetItem(metricsTreeWidget) - channelTreeItem.setText(0, f'{chName} measurements') + channelTreeItem.setText(0, f"{chName} measurements") metricsTreeWidget.addTopLevelItem(channelTreeItem) - standard_metrics = classified_metrics['foregr'][chName] - bkgr_metrics = classified_metrics['bkgr'][chName] - custom_metrics = classified_metrics['custom'][chName] + standard_metrics = classified_metrics["foregr"][chName] + bkgr_metrics = classified_metrics["bkgr"][chName] + custom_metrics = classified_metrics["custom"][chName] if standard_metrics: foregrMetricsTreeItem = QTreeWidgetItem(channelTreeItem) - foregrMetricsTreeItem.setText(0, 'Cell signal measurements') + foregrMetricsTreeItem.setText(0, "Cell signal measurements") channelTreeItem.addChild(foregrMetricsTreeItem) self.addTreeItems( - foregrMetricsTreeItem, standard_metrics, - isCol=True, index=i + foregrMetricsTreeItem, standard_metrics, isCol=True, index=i ) if bkgr_metrics: bkgrMetricsTreeItem = QTreeWidgetItem(channelTreeItem) - bkgrMetricsTreeItem.setText(0, 'Background values') + bkgrMetricsTreeItem.setText(0, "Background values") channelTreeItem.addChild(bkgrMetricsTreeItem) self.addTreeItems( - bkgrMetricsTreeItem, bkgr_metrics, - isCol=True, index=i + bkgrMetricsTreeItem, bkgr_metrics, isCol=True, index=i ) if custom_metrics: customMetricsTreeItem = QTreeWidgetItem(channelTreeItem) - customMetricsTreeItem.setText(0, 'Custom measurements') + customMetricsTreeItem.setText(0, "Custom measurements") channelTreeItem.addChild(customMetricsTreeItem) self.addTreeItems( - customMetricsTreeItem, custom_metrics, - isCol=True, index=i + customMetricsTreeItem, custom_metrics, isCol=True, index=i ) - if classified_metrics['size']: + if classified_metrics["size"]: sizeMetricsTreeItem = QTreeWidgetItem(metricsTreeWidget) - sizeMetricsTreeItem.setText(0, 'Size measurements') + sizeMetricsTreeItem.setText(0, "Size measurements") metricsTreeWidget.addTopLevelItem(sizeMetricsTreeItem) self.addTreeItems( - sizeMetricsTreeItem, classified_metrics['size'], - isCol=True, index=i + sizeMetricsTreeItem, classified_metrics["size"], isCol=True, index=i ) - if classified_metrics['props']: + if classified_metrics["props"]: propMetricsTreeItem = QTreeWidgetItem(metricsTreeWidget) - propMetricsTreeItem.setText(0, 'Region properties') + propMetricsTreeItem.setText(0, "Region properties") metricsTreeWidget.addTopLevelItem(propMetricsTreeItem) self.addTreeItems( - propMetricsTreeItem, classified_metrics['props'], - isCol=True, index=i + propMetricsTreeItem, + classified_metrics["props"], + isCol=True, + index=i, ) treeLayout = QVBoxLayout() - treeTitle = QLabel(html_utils.paragraph( - f'{i+1}. {acdc_df_endname} measurements ' - )) - treeLayout.addWidget(treeTitle) + treeTitle = QLabel( + html_utils.paragraph( + f"{i + 1}. {acdc_df_endname} measurements " + ) + ) + treeLayout.addWidget(treeTitle) treeLayout.addWidget(metricsTreeWidget) treesLayout.addLayout(treeLayout) @@ -13695,23 +13706,23 @@ def __init__(self, acdcDfs, allChNames, parent=None, debug=False): self.operatorButtons = [] self.operators = [ - ('add', '+'), - ('subtract', '-'), - ('multiply', '*'), - ('divide', '/'), - ('open_bracket', '('), - ('close_bracket', ')'), - ('square', '**2'), - ('pow', '**'), - ('ln', 'log('), - ('log10', 'log10('), + ("add", "+"), + ("subtract", "-"), + ("multiply", "*"), + ("divide", "/"), + ("open_bracket", "("), + ("close_bracket", ")"), + ("square", "**2"), + ("pow", "**"), + ("ln", "log("), + ("log10", "log10("), ] operatorFont = QFont() operatorFont.setPixelSize(16) for name, text in self.operators: button = QPushButton() - button.setIcon(QIcon(f':{name}.svg')) - button.setIconSize(QSize(iconSize,iconSize)) + button.setIcon(QIcon(f":{name}.svg")) + button.setIconSize(QSize(iconSize, iconSize)) button.text = text operatorsLayout.addWidget(button) self.operatorButtons.append(button) @@ -13719,14 +13730,14 @@ def __init__(self, acdcDfs, allChNames, parent=None, debug=False): # button.setFont(operatorFont) clearButton = QPushButton() - clearButton.setIcon(QIcon(':clear.svg')) - clearButton.setIconSize(QSize(iconSize,iconSize)) + clearButton.setIcon(QIcon(":clear.svg")) + clearButton.setIconSize(QSize(iconSize, iconSize)) clearButton.setFont(operatorFont) clearEntryButton = QPushButton() - clearEntryButton.setIcon(QIcon(':backspace.svg')) + clearEntryButton.setIcon(QIcon(":backspace.svg")) clearEntryButton.setFont(operatorFont) - clearEntryButton.setIconSize(QSize(iconSize,iconSize)) + clearEntryButton.setIconSize(QSize(iconSize, iconSize)) operatorsLayout.addWidget(clearButton) operatorsLayout.addWidget(clearEntryButton) @@ -13737,25 +13748,25 @@ def __init__(self, acdcDfs, allChNames, parent=None, debug=False): newColNameLineEdit.setAlignment(Qt.AlignCenter) self.newColNameLineEdit = newColNameLineEdit newColNameLayout.addStretch(1) - newColNameLayout.addWidget(QLabel('New measurement name:')) + newColNameLayout.addWidget(QLabel("New measurement name:")) newColNameLayout.addWidget(newColNameLineEdit) newColNameLayout.addStretch(1) equationDisplayLayout = QVBoxLayout() - equationDisplayLayout.addWidget(QLabel('Equation:')) + equationDisplayLayout.addWidget(QLabel("Equation:")) equationDisplay = QPlainTextEdit() # equationDisplay.setReadOnly(True) self.equationDisplay = equationDisplay equationDisplayLayout.addWidget(equationDisplay) - equationDisplayLayout.setStretch(0,0) - equationDisplayLayout.setStretch(1,1) + equationDisplayLayout.setStretch(0, 0) + equationDisplayLayout.setStretch(1, 1) equationLayout.addLayout(newColNameLayout) - equationLayout.addWidget(QLabel(' = ')) + equationLayout.addWidget(QLabel(" = ")) equationLayout.addLayout(equationDisplayLayout) - equationLayout.setStretch(0,1) - equationLayout.setStretch(1,0) - equationLayout.setStretch(2,2) + equationLayout.setStretch(0, 1) + equationLayout.setStretch(1, 0) + equationLayout.setStretch(2, 2) instructions = html_utils.paragraph(""" Double-click on any of the available measurements @@ -13768,14 +13779,14 @@ def __init__(self, acdcDfs, allChNames, parent=None, debug=False): buttonsLayout = QHBoxLayout() - cancelButton = widgets.cancelPushButton('Cancel') - testButton = widgets.calcPushButton('Test equation') - okButton = widgets.okPushButton(' Ok ') + cancelButton = widgets.cancelPushButton("Cancel") + testButton = widgets.calcPushButton("Test equation") + okButton = widgets.okPushButton(" Ok ") okButton.setDisabled(True) self.okButton = okButton if debug: - debugButton = QPushButton('Debug') + debugButton = QPushButton("Debug") debugButton.clicked.connect(self._debug) buttonsLayout.addWidget(debugButton) @@ -13808,31 +13819,31 @@ def __init__(self, acdcDfs, allChNames, parent=None, debug=False): self.setFont(font) self.setStyleSheet(TREEWIDGET_STYLESHEET) - + def setLogger(self, logger, logs_path, log_path): self.logger = logger self.logs_path = logs_path self.log_path = log_path - + def closeEvent(self, event): self.sigClose.emit(self.cancel) return super().closeEvent(event) - + def getCombinedDf(self): dfs = [] for i, acdc_df in enumerate(self.acdcDfs.values()): - dfs.append(acdc_df.add_suffix(f'_table{i+1}')) + dfs.append(acdc_df.add_suffix(f"_table{i + 1}")) return pd.concat(dfs, axis=1) - + def _log(self, txt): - if hasattr(self, 'logger'): + if hasattr(self, "logger"): self.logger.info(txt) else: - print(f'[INFO]: {txt}') - + print(f"[INFO]: {txt}") + def equationChanged(self): self.okButton.setDisabled(True) - self.statusLabel.setText('') + self.statusLabel.setText("") @exception_handler def test_cb(self): @@ -13842,20 +13853,18 @@ def test_cb(self): newColName = self.newColNameLineEdit.text() new_df[newColName] = combined_df.eval(equation) self.okButton.setDisabled(False) - self._log('Equation test was successful.') - self.statusLabel.setText( - 'Equation test was successful. You can now click OK.' - ) + self._log("Equation test was successful.") + self.statusLabel.setText("Equation test was successful. You can now click OK.") def addOperator(self): button = self.sender() - text = f'{self.equationDisplay.toPlainText()}{button.text}' + text = f"{self.equationDisplay.toPlainText()}{button.text}" self.equationDisplay.setPlainText(text) self.clearLenghts.append(len(button.text)) def clearEquation(self): self.isOperatorMode = False - self.equationDisplay.setPlainText('') + self.equationDisplay.setPlainText("") self.initAttributes() def initAttributes(self): @@ -13868,8 +13877,8 @@ def clearEntryEquation(self): return text = self.equationDisplay.toPlainText() - newText = text[:-self.clearLenghts[-1]] - clearedText = text[-self.clearLenghts[-1]:] + newText = text[: -self.clearLenghts[-1]] + clearedText = text[-self.clearLenghts[-1] :] self.clearLenghts.pop(-1) self.equationDisplay.setPlainText(newText) if clearedText in self.equationColNames: @@ -13878,9 +13887,8 @@ def clearEntryEquation(self): self.channelLessColnames.remove(clearedText) def addTreeItems( - self, parentItem, itemsText, isCol=False, isChannelLess=False, - index=None - ): + self, parentItem, itemsText, isCol=False, isChannelLess=False, index=None + ): for text in itemsText: _item = QTreeWidgetItem(parentItem) _item.setText(0, text) @@ -13892,11 +13900,11 @@ def addTreeItems( _item.isChannelLess = isChannelLess def addColname(self, item, column): - if not hasattr(item, 'isCol'): + if not hasattr(item, "isCol"): return - colName = f'{item.text(0)}_table{item.index+1}' - text = f'{self.equationDisplay.toPlainText()}{colName}' + colName = f"{item.text(0)}_table{item.index + 1}" + text = f"{self.equationDisplay.toPlainText()}{colName}" self.equationDisplay.setPlainText(text) self.clearLenghts.append(len(colName)) @@ -13920,31 +13928,26 @@ def ok_cb(self): self.cancel = False self.sigOk.emit(self.newColname, self.expression) self.close() - + def warnEmptyEquation(self): msg = widgets.myMessageBox() txt = html_utils.paragraph(""" "Equation" field cannot be empty! """) - msg.critical( - self, 'Empty equation', txt - ) + msg.critical(self, "Empty equation", txt) def warnEmptyEquationName(self): msg = widgets.myMessageBox() txt = html_utils.paragraph(""" "New measurement name" field cannot be empty! """) - msg.critical( - self, 'Empty new measurement name', txt - ) + msg.critical(self, "Empty new measurement name", txt) + class CombineMetricsMultiDfsSummaryDialog(QBaseDialog): sigLoadAdditionalAcdcDf = Signal() - def __init__( - self, acdcDfs, allChNames, parent=None, debug=False - ): + def __init__(self, acdcDfs, allChNames, parent=None, debug=False): super().__init__(parent) self.editedIndex = None @@ -13952,19 +13955,19 @@ def __init__( self.acdcDfs = acdcDfs self.allChNames = allChNames - self.setWindowTitle('Combine measurements summary') - + self.setWindowTitle("Combine measurements summary") + mainLayout = QVBoxLayout() viewLayout = QGridLayout() buttonsLayout = QHBoxLayout() row = 0 - txt = html_utils.paragraph('Selected acdc_output tables:') + txt = html_utils.paragraph("Selected acdc_output tables:") viewLayout.addWidget(QLabel(txt), row, 0) row += 1 items = [ - f'• Table {i+1}: {e}' + f"• Table {i + 1}: {e}" for i, e in enumerate(acdcDfs.keys()) ] selectedAcdcDfsList = widgets.readOnlyQList() @@ -13972,14 +13975,11 @@ def __init__( self.selectedAcdcDfsList = selectedAcdcDfsList tablesButtonsLayout = QVBoxLayout() - loadAcdcDfButton = widgets.showInFileManagerButton('Load additional tables') + loadAcdcDfButton = widgets.showInFileManagerButton("Load additional tables") tablesButtonsLayout.addWidget(loadAcdcDfButton) - - loadEquationsButton = widgets.reloadPushButton( - 'Load previously used equations' - ) + + loadEquationsButton = widgets.reloadPushButton("Load previously used equations") tablesButtonsLayout.addWidget(loadEquationsButton) - tablesButtonsLayout.addStretch(1) @@ -13988,20 +13988,21 @@ def __init__( viewLayout.setRowStretch(row, 1) row += 1 - txt = html_utils.paragraph('Equations:') + txt = html_utils.paragraph("Equations:") viewLayout.addWidget(QLabel(txt), row, 0) row += 1 self.equationsList = widgets.TreeWidget() self.equationsList.setFont(font) - self.equationsList.setHeaderLabels(['Metric', 'Expression']) + self.equationsList.setHeaderLabels(["Metric", "Expression"]) self.equationsList.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection) + QAbstractItemView.SelectionMode.ExtendedSelection + ) equationsButtonsLayout = QVBoxLayout() - addEquationButton = widgets.addPushButton('Add metric') - removeEquationButton = widgets.subtractPushButton('Remove metric(s)') - editEquationButton = widgets.editPushButton('Edit metric') + addEquationButton = widgets.addPushButton("Add metric") + removeEquationButton = widgets.subtractPushButton("Remove metric(s)") + editEquationButton = widgets.editPushButton("Edit metric") removeEquationButton.setDisabled(True) editEquationButton.setDisabled(True) self.removeEquationButton = removeEquationButton @@ -14011,13 +14012,13 @@ def __init__( equationsButtonsLayout.addWidget(removeEquationButton) equationsButtonsLayout.addWidget(editEquationButton) equationsButtonsLayout.addStretch(1) - + viewLayout.addWidget(self.equationsList, row, 0) viewLayout.addLayout(equationsButtonsLayout, row, 1) viewLayout.setRowStretch(row, 2) - cancelButton = widgets.cancelPushButton('Cancel') - okButton = widgets.okPushButton('Ok') + cancelButton = widgets.cancelPushButton("Cancel") + okButton = widgets.okPushButton("Ok") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -14041,53 +14042,58 @@ def __init__( ) self.setLayout(mainLayout) - + def setLogger(self, logger, logs_path, log_path): self.logger = logger self.logs_path = logs_path self.log_path = log_path - + def loadEquationsButtonClicked(self): MostRecentPath = myutils.getMostRecentPath() file_path = QFileDialog.getOpenFileName( - self, 'Select equations file', MostRecentPath, "Config Files (*.ini)" - ";;All Files (*)")[0] - if file_path == '': + self, + "Select equations file", + MostRecentPath, + "Config Files (*.ini);;All Files (*)", + )[0] + if file_path == "": return cp = config.ConfigParser() cp.read(file_path) - sectionToMatch = [ - f'table{i+1}:{end}' for i, end in enumerate(self.acdcDfs) - ] - sectionToMatch = ';'.join(sectionToMatch) - + sectionToMatch = [f"table{i + 1}:{end}" for i, end in enumerate(self.acdcDfs)] + sectionToMatch = ";".join(sectionToMatch) + lists = {} nonMatchingLists = {} groupsDescr = {} - + for section in cp.sections(): # Tag acdc_output names with html and table(\d+) with html bold tag - listName = ';'.join([ - re.sub(r'table(\d+):(.*)', r'table\g<1>: \g<2>', s) - for s in section.split(';') - ]) - listName = listName.replace(';', ' ; ') - children = [f'{opt} = {cp[section][opt]}' for opt in cp[section]] + listName = ";".join( + [ + re.sub( + r"table(\d+):(.*)", r"table\g<1>: \g<2>", s + ) + for s in section.split(";") + ] + ) + listName = listName.replace(";", " ; ") + children = [f"{opt} = {cp[section][opt]}" for opt in cp[section]] if section == sectionToMatch: groupsDescr[listName] = ( - 'Equations that were calculated from the same ' - 'table names you loaded' + "Equations that were calculated from the same " + "table names you loaded" ) lists[listName] = children else: groupsDescr[listName] = ( - 'Equations that were calculated from table names that ' - 'you did not load now' + "Equations that were calculated from table names that " + "you did not load now" ) nonMatchingLists[listName] = children # # Not implemented yet --> selecting from non matching table names - # # would require an additional widget where the user sets + # # would require an additional widget where the user sets # # what df1 and df2 are. # trees[treeName] = children @@ -14100,16 +14106,18 @@ def loadEquationsButtonClicked(self): """) with open(file_path) as iniFile: detailedText = iniFile.read() - - msg.warning(self, 'Not the same tables', txt, showDialog=False) + + msg.warning(self, "Not the same tables", txt, showDialog=False) msg.setDetailedText(detailedText, visible=True) msg.addShowInFileManagerButton(os.path.dirname(file_path)) msg.exec_() return selectWindow = MultiListSelector( - lists, groupsDescr=groupsDescr, title='Select equations to load', - infoTxt='Select equations you want to load' + lists, + groupsDescr=groupsDescr, + title="Select equations to load", + infoTxt="Select equations you want to load", ) selectWindow.exec_() if selectWindow.cancel or not selectWindow.selectedItems: @@ -14117,9 +14125,8 @@ def loadEquationsButtonClicked(self): for listName, equations in selectWindow.selectedItems.items(): for equation in equations: - metricName, expression = equation.split(' = ') + metricName, expression = equation.split(" = ") self.addEquation(metricName, expression) - def ok_cb(self): self.cancel = False @@ -14129,19 +14136,19 @@ def ok_cb(self): self.equations[item.text(0)] = item.text(1) self.close() - + def loadButtonClicked(self): self.sigLoadAdditionalAcdcDf.emit() - + def removeButtonClicked(self): for item in self.equationsList.selectedItems(): self.equationsList.invisibleRootItem().removeChild(item) - + def editButtonClicked(self): self.editedItem = self.equationsList.selectedItems()[0] self.editedIndex = self.equationsList.indexOfTopLevelItem(self.editedItem) self.addEquation_cb() - + def onEquationItemSelectionChanged(self): selectedItems = self.equationsList.selectedItems() if len(selectedItems) == 1: @@ -14153,11 +14160,11 @@ def onEquationItemSelectionChanged(self): else: self.removeEquationButton.setDisabled(True) self.editEquationButton.setDisabled(True) - + def addAcdcDfs(self, acdcDfsDict): self.acdcDfs = {**self.acdcDfs, **acdcDfsDict} items = [ - f'• Table {i+1}: {e}' + f"• Table {i + 1}: {e}" for i, e in enumerate(self.acdcDfs.keys()) ] self.selectedAcdcDfsList = widgets.readOnlyQList() @@ -14179,15 +14186,13 @@ def addEquation(self, newColname, expression): self.equationsList.resizeColumnToContents(0) self.equationsList.resizeColumnToContents(1) self.editedIndex = None - + def addEquation_cb(self): self.addEquationWin = CombineMetricsMultiDfsDialog( self.acdcDfs, self.allChNames, parent=self ) - if hasattr(self, 'logger'): - self.addEquationWin.setLogger( - self.logger, self.logs_path, self.log_path - ) + if hasattr(self, "logger"): + self.addEquationWin.setLogger(self.logger, self.logs_path, self.log_path) if self.editedIndex is not None: editedMetricName = self.editedItem.text(0) self.addEquationWin.newColNameLineEdit.setText(editedMetricName) @@ -14196,26 +14201,28 @@ def addEquation_cb(self): self.addEquationWin.show() self.addEquationWin.sigOk.connect(self.addEquation) self.addEquationWin.sigClose.connect(self.addEquationClosed) - + def addEquationClosed(self, cancelled): if cancelled: self.editedIndex = None - + def showEvent(self, event) -> None: - self.resize(int(self.width()*2), self.height()) + self.resize(int(self.width() * 2), self.height()) + class ShortcutEditorDialog(QBaseDialog): def __init__( - self, widgetsWithShortcut: dict, - delObjectKey='', - delObjectButton: Literal['Middle click', 'Left click']='Middle click', - zoomOutKeyValue: int=None, - parent=None - ): + self, + widgetsWithShortcut: dict, + delObjectKey="", + delObjectButton: Literal["Middle click", "Left click"] = "Middle click", + zoomOutKeyValue: int = None, + parent=None, + ): self.cancel = True super().__init__(parent) - self.setWindowTitle('Customize keyboard shortcuts') + self.setWindowTitle("Customize keyboard shortcuts") mainLayout = QVBoxLayout() @@ -14226,7 +14233,7 @@ def __init__( scrollArea.setWidgetResizable(True) scrollAreaWidget = QWidget() entriesLayout = QGridLayout() - + row = 0 button = widgets.PushButton(self, flat=True) button.setIcon(QIcon(":del_obj_click.svg")) @@ -14236,32 +14243,30 @@ def __init__( if delObjectKey is not None: self.delObjShortcutLineEdit.setText(delObjectKey) self.delObjButtonCombobox = QComboBox() - self.delObjButtonCombobox.addItems(['Middle click', 'Left click']) + self.delObjButtonCombobox.addItems(["Middle click", "Left click"]) self.delObjButtonCombobox.setCurrentText(delObjectButton) entriesLayout.addWidget(button, row, 0) - entriesLayout.addWidget(QLabel('Delete object:'), row, 1) + entriesLayout.addWidget(QLabel("Delete object:"), row, 1) entriesLayout.addWidget(self.delObjShortcutLineEdit, row, 2) entriesLayout.addWidget( self.delObjButtonCombobox, row, 3, alignment=Qt.AlignLeft ) - + row += 1 - name = 'Zoom out' + name = "Zoom out" button = widgets.PushButton(self, flat=True) - label = QLabel('Zoom out:') + label = QLabel("Zoom out:") self.zoomShortcutLineEdit = widgets.ShortcutLineEdit() if zoomOutKeyValue is not None: zoomOutKeySequence = widgets.KeySequenceFromText(zoomOutKeyValue) self.zoomShortcutLineEdit.setText(zoomOutKeySequence.toString()) self.zoomShortcutLineEdit.key = zoomOutKeyValue - self.zoomShortcutLineEdit.textChanged.connect( - self.checkDuplicateShortcuts - ) + self.zoomShortcutLineEdit.textChanged.connect(self.checkDuplicateShortcuts) entriesLayout.addWidget(button, row, 0) entriesLayout.addWidget(label, row, 1) entriesLayout.addWidget(self.zoomShortcutLineEdit, row, 2) self.shortcutLineEdits[name] = self.zoomShortcutLineEdit - + row += 1 for row, (name, widget) in enumerate(widgetsWithShortcut.items(), start=row): button = widgets.PushButton(self, flat=True) @@ -14269,9 +14274,9 @@ def __init__( button.setIcon(widget.icon()) except: pass - label = QLabel(f'{name}:') + label = QLabel(f"{name}:") shortcutLineEdit = widgets.ShortcutLineEdit() - if hasattr(widget, 'keyPressShortcut'): + if hasattr(widget, "keyPressShortcut"): shortcutLineEdit.key = widget.keyPressShortcut shortcut = widgets.KeySequenceFromText(widget.keyPressShortcut) isShortcutKeyPress = True @@ -14285,7 +14290,7 @@ def __init__( entriesLayout.addWidget(label, row, 1) entriesLayout.addWidget(shortcutLineEdit, row, 2) self.shortcutLineEdits[name] = shortcutLineEdit - + entriesLayout.setColumnStretch(0, 0) entriesLayout.setColumnStretch(1, 0) entriesLayout.setColumnStretch(2, 1) @@ -14304,133 +14309,127 @@ def __init__( self.setFont(font) self.setLayout(mainLayout) - + def checkDuplicateShortcuts(self, text): for name, shortcutLineEdit in self.shortcutLineEdits.items(): if shortcutLineEdit == self.sender(): continue if shortcutLineEdit.text() != text: continue - shortcutLineEdit.setText('') - + shortcutLineEdit.setText("") + def warnInvalidKeySequenceDelObjWithLeftClick(self): txt = html_utils.paragraph( 'The selected key sequence to delete objects with "Left click" ' - 'is invalid.

' + "is invalid.

" 'Only "Middle click" can be used without pressing keys.

' - 'Thank you for your patience!' + "Thank you for your patience!" ) msg = widgets.myMessageBox() - msg.warning(self, 'Invalid key sequence to delete objects', txt) - + msg.warning(self, "Invalid key sequence to delete objects", txt) + def ok_cb(self): delObjButtonText = self.delObjButtonCombobox.currentText() delObjKeySequence = self.delObjShortcutLineEdit.keySequence - if delObjButtonText == 'Left click' and delObjKeySequence is None: + if delObjButtonText == "Left click" and delObjKeySequence is None: self.warnInvalidKeySequenceDelObjWithLeftClick() return - - self.shortcutLineEdits.pop('Zoom out') + + self.shortcutLineEdits.pop("Zoom out") self.cancel = False for name, shortcutLineEdit in self.shortcutLineEdits.items(): text = shortcutLineEdit.text() if shortcutLineEdit.isShortcutKeyPress: self.customShortcuts[name] = (text, shortcutLineEdit.key) else: - self.customShortcuts[name] = ( - text, shortcutLineEdit.keySequence - ) - + self.customShortcuts[name] = (text, shortcutLineEdit.keySequence) + delObjQtButton = ( - Qt.MouseButton.LeftButton if delObjButtonText == 'Left click' + Qt.MouseButton.LeftButton + if delObjButtonText == "Left click" else Qt.MouseButton.MiddleButton ) self.delObjAction = delObjKeySequence, delObjQtButton self.zoomOutKeyValue = self.zoomShortcutLineEdit.key - + self.close() - + def showEvent(self, event) -> None: - self.resize(int(self.width()*1.2), self.height()) + self.resize(int(self.width() * 1.2), self.height()) self.move(self.x(), 100) + class SelectAcdcDfVersionToRestore(QBaseDialog): def __init__(self, posData, parent=None): super().__init__(parent=parent) - + self.cancel = True - - self.setWindowTitle('Select annotations table to restore') - + + self.setWindowTitle("Select annotations table to restore") + mainLayout = QVBoxLayout() - + acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) instructionsLabel = html_utils.paragraph( - f'Select an older version of the {acdc_df_filename} ' - 'annotations table to load.

' - 'The datetime refers to the time you replaced the old version with ' - 'a newer one.

' + f"Select an older version of the {acdc_df_filename} " + "annotations table to load.

" + "The datetime refers to the time you replaced the old version with " + "a newer one.

" ) mainLayout.addWidget(QLabel(instructionsLabel)) - + self.savedListBox = None if os.path.exists(posData.acdc_output_backup_zip_path): zip_path = posData.acdc_output_backup_zip_path self.savedArchivefilepath = zip_path - with zipfile.ZipFile(zip_path, mode='r') as zip: + with zipfile.ZipFile(zip_path, mode="r") as zip: csv_names = natsorted(zip.namelist(), reverse=True) - + keys = [csv_name[:-4] for csv_name in csv_names] self.savedKeys = keys f = load.ISO_TIMESTAMP_FORMAT timestamps = [datetime.datetime.strptime(key, f) for key in keys] - items = [date.strftime(r'%d %b %Y, %H:%M:%S') for date in timestamps] - mainLayout.addWidget(QLabel('Saved annotations:')) + items = [date.strftime(r"%d %b %Y, %H:%M:%S") for date in timestamps] + mainLayout.addWidget(QLabel("Saved annotations:")) self.savedListBox = widgets.listWidget() self.savedListBox.addItems(items) mainLayout.addWidget(self.savedListBox) - self.savedListBox.itemSelectionChanged.connect( - self.onItemSelectionChanged - ) - + self.savedListBox.itemSelectionChanged.connect(self.onItemSelectionChanged) + recovery_folderpath = posData.recoveryFolderpath() - unsaved_recovery_folderpath = os.path.join( - recovery_folderpath, 'never_saved' - ) + unsaved_recovery_folderpath = os.path.join(recovery_folderpath, "never_saved") self.neverSavedFolderpath = unsaved_recovery_folderpath files = myutils.listdir(unsaved_recovery_folderpath) - csv_files = [file for file in files if file.endswith('.csv')] + csv_files = [file for file in files if file.endswith(".csv")] self.neverSavedListBox = None if csv_files: csv_names = natsorted(csv_files, reverse=True) keys = [csv_name[:-4] for csv_name in csv_names] self.neverSavedKeys = keys f = load.ISO_TIMESTAMP_FORMAT - timestamps = [ - datetime.datetime.strptime(key, f) for key in keys - ] - items = [date.strftime(r'%d %b %Y, %H:%M:%S') for date in timestamps] - mainLayout.addWidget(QLabel('Never saved annotations:')) + timestamps = [datetime.datetime.strptime(key, f) for key in keys] + items = [date.strftime(r"%d %b %Y, %H:%M:%S") for date in timestamps] + mainLayout.addWidget(QLabel("Never saved annotations:")) self.neverSavedListBox = widgets.listWidget() self.neverSavedListBox.addItems(items) mainLayout.addWidget(self.neverSavedListBox) self.neverSavedListBox.itemSelectionChanged.connect( self.onItemSelectionChanged ) - + cancelOkLayout = widgets.CancelOkButtonsLayout() - + cancelOkLayout.okButton.clicked.connect(self.ok_cb) cancelOkLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addSpacing(20) mainLayout.addLayout(cancelOkLayout) - + self.setLayout(mainLayout) - + self.setFont(font) - + def ok_cb(self): self.cancel = False try: @@ -14443,7 +14442,7 @@ def ok_cb(self): break except Exception as e: pass - + try: for i in range(self.neverSavedListBox.count()): item = self.neverSavedListBox.item(i) @@ -14455,10 +14454,11 @@ def ok_cb(self): except Exception as e: pass self.close() - + def onItemSelectionChanged(self): otherListBox = ( - self.savedListBox if self.sender() == self.neverSavedListBox + self.savedListBox + if self.sender() == self.neverSavedListBox else self.neverSavedListBox ) if otherListBox is None: @@ -14467,69 +14467,65 @@ def onItemSelectionChanged(self): item = otherListBox.item(i) item.setSelected(False) + class ChangeUserProfileFolderPathDialog(QBaseDialog): def __init__(self, posData, parent=None): super().__init__(parent=parent) - + self.cancel = True - - self.setWindowTitle('Change user profile folder path') - + + self.setWindowTitle("Change user profile folder path") + mainLayout = QVBoxLayout() - + acdc_folders = load.get_all_acdc_folders(user_profile_path) - acdc_folders_format = [f' - {folder}' for folder in acdc_folders] - acdc_folders_format = '
'.join(acdc_folders_format) - - txt = (f""" + acdc_folders_format = [f" - {folder}" for folder in acdc_folders] + acdc_folders_format = "
".join(acdc_folders_format) + + txt = f""" Current user profile path:

{user_profile_path}

The user profile contains the following Cell-ACDC folders:

{acdc_folders_format}

After clicking "Ok" you will be asked to select the folder where you want to migrate the user profile data. - """) - + """ + txt = html_utils.paragraph(txt) label = QLabel(txt) - + mainLayout.addWidget(label) - + buttonsLayout = widgets.CancelOkButtonsLayout() buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) mainLayout.addStretch() - + self.setLayout(mainLayout) - + def ok_cb(self): self.cancel = False self.close() - + + class SelectFeaturesRange: def __init__( - self, - posData, - force_postprocess_2D=False, - qparent=None, - sigValueChanged=None - ) -> None: + self, posData, force_postprocess_2D=False, qparent=None, sigValueChanged=None + ) -> None: self.posData = posData self.qparent = qparent self.force_postprocess_2D = force_postprocess_2D self.sigValueChanged = sigValueChanged - + self.lowRangeWidgets = widgets.CheckableSpinBoxWidgets() - self.highRangeWidgets = widgets.CheckableSpinBoxWidgets() - - self.selectButton = widgets.FeatureSelectorButton( - 'Click to select feature...' - ) + self.highRangeWidgets = widgets.CheckableSpinBoxWidgets() + + self.selectButton = widgets.FeatureSelectorButton("Click to select feature...") self.selectButton.setSizeLongestText( - 'Spotfit intens. metric, Foregr. integral gauss. peak' + "Spotfit intens. metric, Foregr. integral gauss. peak" ) self.selectButton.clicked.connect(self.selectFeature) self.selectButton.setCursor(Qt.PointingHandCursor) @@ -14537,34 +14533,39 @@ def __init__( self.selectedFeatureGroups = {} self.widgets = [ - {'pos': (0, 0), 'widget': self.lowRangeWidgets.checkbox}, - {'pos': (1, 0), 'widget': self.lowRangeWidgets.spinbox}, - {'pos': (1, 1), 'widget': widgets.LessThanPushButton(flat=True)}, - {'pos': (1, 2), 'widget': self.selectButton}, - {'pos': (1, 3), 'widget': widgets.LessThanPushButton(flat=True)}, - {'pos': (0, 4), 'widget': self.highRangeWidgets.checkbox}, - {'pos': (1, 4), 'widget': self.highRangeWidgets.spinbox}, - {'pos': (2, 0), 'widget': widgets.VerticalSpacerEmptyWidget(height=10)} + {"pos": (0, 0), "widget": self.lowRangeWidgets.checkbox}, + {"pos": (1, 0), "widget": self.lowRangeWidgets.spinbox}, + {"pos": (1, 1), "widget": widgets.LessThanPushButton(flat=True)}, + {"pos": (1, 2), "widget": self.selectButton}, + {"pos": (1, 3), "widget": widgets.LessThanPushButton(flat=True)}, + {"pos": (0, 4), "widget": self.highRangeWidgets.checkbox}, + {"pos": (1, 4), "widget": self.highRangeWidgets.spinbox}, + {"pos": (2, 0), "widget": widgets.VerticalSpacerEmptyWidget(height=10)}, ] self.columnsStretches = {0: 0, 1: 0, 2: 1, 3: 0, 4: 0} - + def setText(self, text): self.selectButton.setText(text) - + def selectFeature(self): loadedChNames = [self.posData.user_ch_name] notLoadedChNames = [] isZstack = self.posData.SizeZ > 1 and not self.force_postprocess_2D isSegm3D = self.posData.isSegm3D and not self.force_postprocess_2D self.selectFeatureDialog = SetMeasurementsDialog( - loadedChNames, notLoadedChNames, isZstack, isSegm3D, - posData=self.posData, parent=self.qparent, - isSingleSelection=True, is_concat=True + loadedChNames, + notLoadedChNames, + isZstack, + isSegm3D, + posData=self.posData, + parent=self.qparent, + isSingleSelection=True, + is_concat=True, ) # self.selectFeatureDialog.resizeVertical() self.selectFeatureDialog.sigClosed.connect(self.setFeatureText) self.selectFeatureDialog.show() - + def setFeatureText(self): if self.selectFeatureDialog.cancel: return @@ -14575,72 +14576,71 @@ def setFeatureText(self): self.selectButton.setText(selectedMetricName) self.featureGroup = selectedMetricGroup + class SelectFeaturesRangeDialog(QBaseDialog): sigValueChanged = Signal(object) - + def __init__(self, posData=None, parent=None, force_postprocess_2D=False): super().__init__(parent) - + self.force_postprocess_2D = force_postprocess_2D - + layout = QVBoxLayout() - self.setWindowTitle('Custom features for post-processing') - + self.setWindowTitle("Custom features for post-processing") + self.groupbox = SelectFeaturesRangeGroupbox( - posData=posData, parent=parent, - force_postprocess_2D=force_postprocess_2D + posData=posData, parent=parent, force_postprocess_2D=force_postprocess_2D ) - + buttonsLayout = QHBoxLayout() - okPushButton = widgets.okPushButton(' Ok ') - + okPushButton = widgets.okPushButton(" Ok ") + buttonsLayout.addStretch(1) buttonsLayout.addWidget(okPushButton) - + okPushButton.clicked.connect(self.ok_cb) layout.addWidget(self.groupbox) layout.addSpacing(10) layout.addLayout(buttonsLayout) - + self.setLayout(layout) - + def ok_cb(self): if self.groupbox.selectedFeaturesRange(): self.sigValueChanged.emit(None) self.hide() + class SelectFeaturesRangeGroupbox(QGroupBox): - def __init__( - self, posData=None, parent=None, force_postprocess_2D=False - ): + def __init__(self, posData=None, parent=None, force_postprocess_2D=False): super().__init__(parent) - self.setTitle('Features and thresholds for filtering segmented objects') + self.setTitle("Features and thresholds for filtering segmented objects") # self.setCheckable(True) self.posData = posData self.force_postprocess_2D = force_postprocess_2D - + self._layout = QGridLayout() self._layout.setVerticalSpacing(0) firstSelector = SelectFeaturesRange( posData, force_postprocess_2D=force_postprocess_2D ) - self.addButton = widgets.addPushButton(' Add feature ') + self.addButton = widgets.addPushButton(" Add feature ") self.addButton.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) for col, widget in enumerate(firstSelector.widgets): - row, col = widget['pos'] - self._layout.addWidget(widget['widget'], row, col) + row, col = widget["pos"] + self._layout.addWidget(widget["widget"], row, col) for col, stretch in firstSelector.columnsStretches.items(): self._layout.setColumnStretch(col, stretch) - + lastCol = self._layout.columnCount() - self._layout.addWidget(self.addButton, 0, lastCol+1, 2, 1) - self.lastCol = lastCol+1 + self._layout.addWidget(self.addButton, 0, lastCol + 1, 2, 1) + self.lastCol = lastCol + 1 self.selectors = [firstSelector] self.setLayout(self._layout) @@ -14654,52 +14654,52 @@ def addFeatureField(self): selector = SelectFeaturesRange( self.posData, force_postprocess_2D=self.force_postprocess_2D ) - delButton = widgets.delPushButton('Remove feature') + delButton = widgets.delPushButton("Remove feature") delButton.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) delButton.selector = selector selector.delButton = delButton for col, widget in enumerate(selector.widgets): - relRow, col = widget['pos'] - self._layout.addWidget(widget['widget'], relRow+row, col) + relRow, col = widget["pos"] + self._layout.addWidget(widget["widget"], relRow + row, col) self._layout.addWidget(delButton, row, self.lastCol, 2, 1) self.selectors.append(selector) delButton.clicked.connect(self.removeFeatureField) - + def resetFields(self): while len(self.selectors) > 1: selector = self.selectors[-1] selector.delButton.click() firstSelector = self.selectors[0] - firstSelector.selectButton.setText('Click to select feature...') + firstSelector.selectButton.setText("Click to select feature...") firstSelector.lowRangeWidgets.checkbox.setChecked(False) firstSelector.highRangeWidgets.checkbox.setChecked(False) - + def removeFeatureField(self): delButton = self.sender() for widget in delButton.selector.widgets: - self._layout.removeWidget(widget['widget']) + self._layout.removeWidget(widget["widget"]) self._layout.removeWidget(delButton) self.selectors.remove(delButton.selector) - + def selectedFeaturesRange(self): featuresRange = {} for selector in self.selectors: - if selector.selectButton.text().find('Click') != -1: + if selector.selectButton.text().find("Click") != -1: continue featuresRange[selector.selectButton.text()] = ( - selector.lowRangeWidgets.value(), - selector.highRangeWidgets.value() + selector.lowRangeWidgets.value(), + selector.highRangeWidgets.value(), ) return featuresRange def selectedFeaturesGroup(self): featuresGroup = {} for selector in self.selectors: - if selector.selectButton.text().find('Click') != -1: + if selector.selectButton.text().find("Click") != -1: continue - group = selector.featureGroup + group = selector.featureGroup featuresGroup[selector.selectButton.text()] = group return featuresGroup @@ -14721,219 +14721,211 @@ def groupedFeatures(self): groupedFeatures[key][channel] = [] groupedFeatures[key][channel].append(feature) return groupedFeatures - + def setValue(self, value): pass + def get_existing_directory(allow_images_path=True, **kwargs): while True: folder_path = qtpy.compat.getexistingdirectory(**kwargs) if not folder_path: return - + if allow_images_path: return folder_path - + pos_folderpath = os.path.dirname(folder_path) is_images_folder = ( - folder_path.endswith('Images') - and os.path.basename(pos_folderpath).startswith('Position_') + folder_path.endswith("Images") + and os.path.basename(pos_folderpath).startswith("Position_") and os.path.isdir(folder_path) ) if not is_images_folder: return folder_path - + txt = html_utils.paragraph( - 'You cannot save to the Images folder ' - 'because it is reserved to files that start with the same ' - 'basename.

Thank you for your patience!' + "You cannot save to the Images folder " + "because it is reserved to files that start with the same " + "basename.

Thank you for your patience!" ) msg = widgets.myMessageBox() - msg.warning(kwargs['parent'], 'Cannot save here', txt) + msg.warning(kwargs["parent"], "Cannot save here", txt) + class ScaleBarPropertiesDialog(QBaseDialog): sigValueChanged = Signal(object) - + def __init__( - self, maxLength, maxThickness, PhysicalSizeX, parent=None, - **properties - ): + self, maxLength, maxThickness, PhysicalSizeX, parent=None, **properties + ): super().__init__(parent=parent) - + self.cancel = True - self.setWindowTitle('Scale bar properties') - + self.setWindowTitle("Scale bar properties") + self.PhysicalSizeX = PhysicalSizeX - + mainLayout = QVBoxLayout() - + formLayout = widgets.FormLayout() formLayout.setVerticalSpacing(10) formLayout.setHorizontalSpacing(50) - + row = 0 unitCombobox = QComboBox() - unitFormWidget = widgets.formWidget( - unitCombobox, labelTextLeft='Physical unit' - ) - unitCombobox.addItems( - ['nm', 'μm', 'mm', 'cm'] - ) - if properties.get('unit') is None: + unitFormWidget = widgets.formWidget(unitCombobox, labelTextLeft="Physical unit") + unitCombobox.addItems(["nm", "μm", "mm", "cm"]) + if properties.get("unit") is None: unitCombobox.setCurrentIndex(1) else: - unitCombobox.setCurrentText(properties.get('unit')) + unitCombobox.setCurrentText(properties.get("unit")) formLayout.addFormWidget( - unitFormWidget, row=row, - leftLabelAlignment=Qt.AlignLeft - ) + unitFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) self.unitCombobox = unitCombobox - + row += 1 lengthDoubleSpinbox = widgets.DoubleSpinBox() lengthDoubleSpinbox.setMaximum(maxLength) lengthDoubleSpinbox.setMinimum(PhysicalSizeX) lengthDoubleSpinbox.setDecimals(1) - if properties.get('length_unit') is not None: - lengthDoubleSpinbox.setValue(properties.get('length_unit')) + if properties.get("length_unit") is not None: + lengthDoubleSpinbox.setValue(properties.get("length_unit")) else: - deafultLength = np.ceil(PhysicalSizeX*15) + deafultLength = np.ceil(PhysicalSizeX * 15) lengthDoubleSpinbox.setValue(round(deafultLength)) lengthFormWidget = widgets.formWidget( - lengthDoubleSpinbox, labelTextLeft='Length (μm)' + lengthDoubleSpinbox, labelTextLeft="Length (μm)" ) self.lengthFormWidget = lengthFormWidget self.lengthDoubleSpinbox = lengthDoubleSpinbox formLayout.addFormWidget( - lengthFormWidget, row=row, - leftLabelAlignment=Qt.AlignLeft - ) - + lengthFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + row += 1 thicknessSpinbox = widgets.DoubleSpinBox() thicknessSpinbox.setMaximum(maxThickness) thicknessSpinbox.setMinimum(1) - if properties.get('thickness') is not None: - thicknessSpinbox.setValue(properties.get('thickness')) + if properties.get("thickness") is not None: + thicknessSpinbox.setValue(properties.get("thickness")) else: thicknessSpinbox.setValue(round(4, 1)) thicknessSpinbox.setDecimals(1) thicknessFormWidget = widgets.formWidget( - thicknessSpinbox, labelTextLeft='Thickness (pixel)' + thicknessSpinbox, labelTextLeft="Thickness (pixel)" ) formLayout.addFormWidget( - thicknessFormWidget, row=row, - leftLabelAlignment=Qt.AlignLeft - ) + thicknessFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) self.thicknessSpinbox = thicknessSpinbox - + row += 1 locCombobox = QComboBox() - locFormWidget = widgets.formWidget( - locCombobox, labelTextLeft='Location' - ) + locFormWidget = widgets.formWidget(locCombobox, labelTextLeft="Location") locCombobox.addItems( - ['Bottom-right', 'Bottom-left', 'Top-left', 'Top-right', 'Custom'] + ["Bottom-right", "Bottom-left", "Top-left", "Top-right", "Custom"] ) - loc = properties.get('loc') + loc = properties.get("loc") if isinstance(loc, str): locCombobox.setCurrentText(loc.capitalize()) formLayout.addFormWidget( - locFormWidget, row=row, - leftLabelAlignment=Qt.AlignLeft - ) + locFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) self.locCombobox = locCombobox - + row += 1 self.colorButton = widgets.myColorButton(color=(255, 255, 255)) - if properties.get('color') is not None: - self.colorButton.setColor(properties.get('color')) + if properties.get("color") is not None: + self.colorButton.setColor(properties.get("color")) colorFormWidget = widgets.formWidget( - self.colorButton, labelTextLeft='Color', - widgetAlignment=Qt.AlignCenter, stretchWidget=False + self.colorButton, + labelTextLeft="Color", + widgetAlignment=Qt.AlignCenter, + stretchWidget=False, ) formLayout.addFormWidget( - colorFormWidget, row=row, - leftLabelAlignment=Qt.AlignLeft - ) - + colorFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + row += 1 displayTextToggle = widgets.Toggle() - if properties.get('is_text_visible') is not None: - displayTextToggle.setChecked(properties.get('is_text_visible')) + if properties.get("is_text_visible") is not None: + displayTextToggle.setChecked(properties.get("is_text_visible")) else: displayTextToggle.setChecked(True) displayTextFormWidget = widgets.formWidget( - displayTextToggle, labelTextLeft='Display text', - widgetAlignment=Qt.AlignCenter, stretchWidget=False + displayTextToggle, + labelTextLeft="Display text", + widgetAlignment=Qt.AlignCenter, + stretchWidget=False, ) formLayout.addFormWidget( - displayTextFormWidget, row=row, - leftLabelAlignment=Qt.AlignLeft - ) + displayTextFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) self.displayTextToggle = displayTextToggle - + row += 1 fontSizeSpinbox = widgets.SpinBox() - if properties.get('font_size') is not None: - fontSizeSpinbox.setValue(int(properties.get('font_size'))) + if properties.get("font_size") is not None: + fontSizeSpinbox.setValue(int(properties.get("font_size"))) else: fontSizeSpinbox.setValue(12) fontSizeFormWidget = widgets.formWidget( - fontSizeSpinbox, labelTextLeft='Font size (px)' + fontSizeSpinbox, labelTextLeft="Font size (px)" ) self.fontSizeSpinbox = fontSizeSpinbox formLayout.addFormWidget( - fontSizeFormWidget, row=row, - leftLabelAlignment=Qt.AlignLeft - ) - + fontSizeFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + row += 1 decimalsSpinbox = widgets.SpinBox() decimalsSpinbox.setMaximum(6) decimalsSpinbox.setMinimum(0) - if properties.get('num_decimals') is not None: - decimalsSpinbox.setValue(properties.get('num_decimals')) + if properties.get("num_decimals") is not None: + decimalsSpinbox.setValue(properties.get("num_decimals")) else: decimalsSpinbox.setValue(0) decimalsFormWidget = widgets.formWidget( - decimalsSpinbox, labelTextLeft='Number of decimals' + decimalsSpinbox, labelTextLeft="Number of decimals" ) formLayout.addFormWidget( - decimalsFormWidget, row=row, - leftLabelAlignment=Qt.AlignLeft - ) + decimalsFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) self.decimalsSpinbox = decimalsSpinbox - + row += 1 moveWithZoomToggle = widgets.Toggle() moveWithZoomWidget = widgets.formWidget( - moveWithZoomToggle, labelTextLeft='Move scale bar with zoom', - widgetAlignment=Qt.AlignCenter, stretchWidget=False + moveWithZoomToggle, + labelTextLeft="Move scale bar with zoom", + widgetAlignment=Qt.AlignCenter, + stretchWidget=False, ) formLayout.addFormWidget( - moveWithZoomWidget, row=row, - leftLabelAlignment=Qt.AlignLeft + moveWithZoomWidget, row=row, leftLabelAlignment=Qt.AlignLeft ) self.moveWithZoomToggle = moveWithZoomToggle - + mainLayout.addLayout(formLayout) - + buttonsLayout = widgets.CancelOkButtonsLayout() buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) mainLayout.addStretch() - + self.setLayout(mainLayout) self.setFont(font) - + self.unitCombobox.currentTextChanged.connect(self.updateLengthUnit) self.colorButton.clicked.disconnect() self.colorButton.clicked.connect(self.selectColor) - + self.colorButton.sigColorChanging.connect(self.onValueChanged) self.lengthDoubleSpinbox.valueChanged.connect(self.onValueChanged) self.thicknessSpinbox.valueChanged.connect(self.onValueChanged) @@ -14942,113 +14934,107 @@ def __init__( self.fontSizeSpinbox.valueChanged.connect(self.onValueChanged) self.decimalsSpinbox.valueChanged.connect(self.onValueChanged) self.moveWithZoomToggle.toggled.connect(self.onValueChanged) - + def onValueChanged(self, *args, **kwargs): self.sigValueChanged.emit(self.kwargs()) - + def selectColor(self): color = self.colorButton.color() self.colorButton.origColor = color self.colorButton.colorDialog.setCurrentColor(color) - self.colorButton.colorDialog.setWindowFlags( - Qt.Window | Qt.WindowStaysOnTopHint - ) + self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) self.colorButton.colorDialog.setParent(self) self.colorButton.colorDialog.open() w = self.width() left = self.pos().x() colorDialogTop = self.colorButton.colorDialog.pos().y() - self.colorButton.colorDialog.move(w+left+10, colorDialogTop) - + self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) + def updateLengthUnit(self, unit): - newText = re.sub( - r'\(.*\)', f'({unit})', - self.lengthFormWidget.labelLeft.text() - ) + newText = re.sub(r"\(.*\)", f"({unit})", self.lengthFormWidget.labelLeft.text()) self.lengthFormWidget.labelLeft.setText(newText) self.onValueChanged(self) - + def kwargs(self): unit = self.unitCombobox.currentText() length_unit = self.lengthDoubleSpinbox.value() - length_um = _core.convert_length(length_unit, unit, 'μm') - length_pixel = length_um/self.PhysicalSizeX + length_um = _core.convert_length(length_unit, unit, "μm") + length_pixel = length_um / self.PhysicalSizeX kwargs = { - 'thickness': self.thicknessSpinbox.value(), - 'length_pixel': length_pixel, - 'length_unit': length_unit, - 'is_text_visible': self.displayTextToggle.isChecked(), - 'color': self.colorButton.color(), - 'loc': self.locCombobox.currentText().lower(), - 'font_size': self.fontSizeSpinbox.value(), - 'unit': unit, - 'num_decimals': self.decimalsSpinbox.value(), - 'move_with_zoom': self.moveWithZoomToggle.isChecked() + "thickness": self.thicknessSpinbox.value(), + "length_pixel": length_pixel, + "length_unit": length_unit, + "is_text_visible": self.displayTextToggle.isChecked(), + "color": self.colorButton.color(), + "loc": self.locCombobox.currentText().lower(), + "font_size": self.fontSizeSpinbox.value(), + "unit": unit, + "num_decimals": self.decimalsSpinbox.value(), + "move_with_zoom": self.moveWithZoomToggle.isChecked(), } return kwargs - + def ok_cb(self): self.cancel = False self.close() + class SetColumnNamesDialog(QBaseDialog): - def __init__( - self, columnNames, categories, - optionalCategories=None, parent=None - ): + def __init__(self, columnNames, categories, optionalCategories=None, parent=None): super().__init__(parent) - + if not optionalCategories: optionalCategories = None - + self.cancel = True - + mainLayout = QVBoxLayout() - - mainLayout.addWidget(QLabel(html_utils.paragraph( - 'Assign a column to the following categories:
' - ))) - + + mainLayout.addWidget( + QLabel( + html_utils.paragraph("Assign a column to the following categories:
") + ) + ) + self.categoriesWidgets = {} formLayout = QFormLayout() for row, category in enumerate(categories): combobox = widgets.ComboBox() combobox.addItems(columnNames) if optionalCategories is not None: - text = f'* {category}' + text = f"* {category}" else: text = category formLayout.addRow(text, combobox) self.categoriesWidgets[category] = combobox - + if optionalCategories is not None: - optionalItems = ['None', *columnNames] + optionalItems = ["None", *columnNames] for row, category in enumerate(optionalCategories): combobox = widgets.ComboBox() combobox.addItems(optionalItems) formLayout.addRow(category, combobox) self.categoriesWidgets[category] = combobox - + mainLayout.addLayout(formLayout) if optionalCategories is not None: mainLayout.addSpacing(10) - mainLayout.addWidget(QLabel(html_utils.paragraph( - '* mandatory', font_size='11px' - ))) - + mainLayout.addWidget( + QLabel(html_utils.paragraph("* mandatory", font_size="11px")) + ) + buttonsLayout = widgets.CancelOkButtonsLayout() buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - - + mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) - + self.setLayout(mainLayout) self.setFont(font) - + def _warnNonUniqueCategories(self, category_1, category_2): txt = html_utils.paragraph(f""" The following categories have the same column assigned to it.

@@ -15057,92 +15043,96 @@ def _warnNonUniqueCategories(self, category_1, category_2): {html_utils.to_list((category_1, category_2))} """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Non-unique columns', txt) - + msg.warning(self, "Non-unique columns", txt) + def _checkUniqueNames(self): self.textToCategoryMapper = {} for category, combobox in self.categoriesWidgets.items(): - if combobox.text() == 'None': + if combobox.text() == "None": continue - + if combobox.text() not in self.textToCategoryMapper: self.textToCategoryMapper[combobox.text()] = category continue - + sameCategory = self.textToCategoryMapper[combobox.text()] self._warnNonUniqueCategories(category, sameCategory) return False - + return True - + def ok_cb(self): proceed = self._checkUniqueNames() if not proceed: return - + self.selectedColumns = { - category:combobox.text() - for category, combobox in self.categoriesWidgets.items() + category: combobox.text() + for category, combobox in self.categoriesWidgets.items() } self.cancel = False self.close() + class CombineFeaturesCalculator(QBaseDialog): sigOk = Signal(object) - + def __init__( - self, features_groups: dict, - group_name_to_col_mapper: dict=None, - title='Combine features calculator', - parent=None - ): + self, + features_groups: dict, + group_name_to_col_mapper: dict = None, + title="Combine features calculator", + parent=None, + ): super().__init__(parent) - + self.cancel = True - + self.setWindowTitle(title) self.initAttributes() - + mainLayout = QVBoxLayout() equationLayout = QHBoxLayout() - + metricsTreeWidget = QTreeWidget() metricsTreeWidget.setHeaderHidden(True) metricsTreeWidget.setFont(font) self.metricsTreeWidget = metricsTreeWidget - + for groupName, features in features_groups.items(): topLevelTreeWidgetItem = QTreeWidgetItem(metricsTreeWidget) topLevelTreeWidgetItem.setText(0, groupName) metricsTreeWidget.addTopLevelItem(topLevelTreeWidgetItem) self.addTreeItems( - topLevelTreeWidgetItem, features, isCol=True, - name_to_col_mapper=group_name_to_col_mapper.get(groupName) + topLevelTreeWidgetItem, + features, + isCol=True, + name_to_col_mapper=group_name_to_col_mapper.get(groupName), ) - + operatorsLayout = self.createOperatorsLayout() newFeatureNameLayout = self.createNewFeatureNameLayout() equationDisplayLayout = self.createEquationDisplayLayout() - + equationLayout.addLayout(newFeatureNameLayout) - equationLayout.addWidget(QLabel(' = ')) + equationLayout.addWidget(QLabel(" = ")) equationLayout.addLayout(equationDisplayLayout) - equationLayout.setStretch(0,1) - equationLayout.setStretch(1,0) - equationLayout.setStretch(2,2) - + equationLayout.setStretch(0, 1) + equationLayout.setStretch(1, 0) + equationLayout.setStretch(2, 2) + testOutputLayout = self.createTestOutputLayout() buttonsLayout = self.createButtonsOutputLayout() - + instructions = html_utils.paragraph(""" Double-click on any of the available measurements to add it to the equation.

Before clicking the `Ok` button, check that the equation returns the expected result by clicking the `Test output` button. """) - + mainLayout.addWidget(QLabel(instructions)) - mainLayout.addWidget(QLabel('Available measurements:')) + mainLayout.addWidget(QLabel("Available measurements:")) mainLayout.addWidget(metricsTreeWidget) mainLayout.addLayout(operatorsLayout) mainLayout.addLayout(equationLayout) @@ -15150,13 +15140,12 @@ def __init__( mainLayout.addLayout(buttonsLayout) mainLayout.addLayout(testOutputLayout) - metricsTreeWidget.itemDoubleClicked.connect(self.addFeatureName) self.setLayout(mainLayout) self.setFont(font) self.setStyleSheet(TREEWIDGET_STYLESHEET) - + def setExpandedAll(self, expanded): if expanded: self.expandAll() @@ -15164,15 +15153,13 @@ def setExpandedAll(self, expanded): for i in range(self.metricsTreeWidget.topLevelItemCount()): topLevelItem = self.metricsTreeWidget.topLevelItem(i) topLevelItem.setExpanded(False) - + def expandAll(self): for i in range(self.metricsTreeWidget.topLevelItemCount()): topLevelItem = self.metricsTreeWidget.topLevelItem(i) topLevelItem.setExpanded(True) - - def addTreeItems( - self, parentItem, itemsText, isCol=False, name_to_col_mapper=None - ): + + def addTreeItems(self, parentItem, itemsText, isCol=False, name_to_col_mapper=None): for text in itemsText: _item = QTreeWidgetItem(parentItem) _item.setText(0, text) @@ -15182,36 +15169,35 @@ def addTreeItems( _item.variable_name = text if name_to_col_mapper is None: continue - + col_name = name_to_col_mapper.get(text, None) if col_name is None: continue - + _item.variable_name = col_name - - + def addFeatureName(self, item, column): - if not hasattr(item, 'isCol'): + if not hasattr(item, "isCol"): return colName = item.variable_name - text = f'{self.equationDisplay.toPlainText()}{colName}' + text = f"{self.equationDisplay.toPlainText()}{colName}" self.equationDisplay.setPlainText(text) self.clearLenghts.append(len(colName)) self.equationColNames.append(colName) - + def clearEquation(self): self.isOperatorMode = False - self.equationDisplay.setPlainText('') + self.equationDisplay.setPlainText("") self.initAttributes() - + def createButtonsOutputLayout(self): buttonsLayout = QHBoxLayout() - cancelButton = widgets.cancelPushButton('Cancel') - helpButton = widgets.infoPushButton(' Help...') - testButton = widgets.calcPushButton('Test output') - okButton = widgets.okPushButton(' Ok ') + cancelButton = widgets.cancelPushButton("Cancel") + helpButton = widgets.infoPushButton(" Help...") + testButton = widgets.calcPushButton("Test output") + okButton = widgets.okPushButton(" Ok ") okButton.setDisabled(True) self.okButton = okButton @@ -15221,33 +15207,30 @@ def createButtonsOutputLayout(self): buttonsLayout.addWidget(helpButton) buttonsLayout.addWidget(testButton) buttonsLayout.addWidget(okButton) - + helpButton.clicked.connect(self.showHelp) okButton.clicked.connect(self.ok_cb) cancelButton.clicked.connect(self.close) testButton.clicked.connect(self.test_cb) - + return buttonsLayout def ok_cb(self): if not self.newFeatureNameLineEdit.text(): self.warnEmptyEquationName() return - + self.equation = self.equationDisplay.toPlainText() self.newFeatureName = self.newFeatureNameLineEdit.text() self.cancel = False self.close() self.sigOk.emit(self) - + def test_cb(self): # Evaluate equation with random inputs equation = self.equationDisplay.toPlainText() - random_data = np.random.rand(1, len(self.equationColNames))*5 - df = pd.DataFrame( - data=random_data, - columns=self.equationColNames - ).round(5) + random_data = np.random.rand(1, len(self.equationColNames)) * 5 + df = pd.DataFrame(data=random_data, columns=self.equationColNames).round(5) newColName = self.newFeatureNameLineEdit.text() try: df[newColName] = df.eval(equation) @@ -15270,7 +15253,7 @@ def test_cb(self): # Format output into html text cols = self.equationColNames - inputs_txt = [f'{col} = {input}' for col, input in zip(cols, inputs)] + inputs_txt = [f"{col} = {input}" for col, input in zip(cols, inputs)] list_html = html_utils.to_list(inputs_txt) text = html_utils.paragraph(f""" By substituting the following random inputs: @@ -15281,53 +15264,51 @@ def test_cb(self):   {newColName} = {result} """) self.testOutputDisplay.setHtml(text) - + def warnEmptyEquationName(self): msg = widgets.myMessageBox() txt = html_utils.paragraph(""" "New measurement name" field cannot be empty! """) - msg.critical( - self, 'Empty new measurement name', txt - ) - + msg.critical(self, "Empty new measurement name", txt) + def showHelp(self): pass - + def createTestOutputLayout(self): testOutputLayout = QVBoxLayout() - testOutputLayout.addWidget(QLabel('Result of test with random inputs:')) + testOutputLayout.addWidget(QLabel("Result of test with random inputs:")) testOutputDisplay = QTextEdit() testOutputDisplay.setReadOnly(True) self.testOutputDisplay = testOutputDisplay testOutputLayout.addWidget(testOutputDisplay) - testOutputLayout.setStretch(0,0) - testOutputLayout.setStretch(1,1) - + testOutputLayout.setStretch(0, 0) + testOutputLayout.setStretch(1, 1) + return testOutputLayout - + def createEquationDisplayLayout(self): equationDisplayLayout = QVBoxLayout() - equationDisplayLayout.addWidget(QLabel('Equation:')) + equationDisplayLayout.addWidget(QLabel("Equation:")) equationDisplay = QPlainTextEdit() # equationDisplay.setReadOnly(True) self.equationDisplay = equationDisplay equationDisplayLayout.addWidget(equationDisplay) - equationDisplayLayout.setStretch(0,0) - equationDisplayLayout.setStretch(1,1) + equationDisplayLayout.setStretch(0, 0) + equationDisplayLayout.setStretch(1, 1) return equationDisplayLayout - + def createNewFeatureNameLayout(self): newFeatureNameLayout = QVBoxLayout() newFeatureNameLineEdit = widgets.alphaNumericLineEdit() newFeatureNameLineEdit.setAlignment(Qt.AlignCenter) self.newFeatureNameLineEdit = newFeatureNameLineEdit newFeatureNameLayout.addStretch(1) - newFeatureNameLayout.addWidget(QLabel('New measurement name:')) + newFeatureNameLayout.addWidget(QLabel("New measurement name:")) newFeatureNameLayout.addWidget(newFeatureNameLineEdit) newFeatureNameLayout.addStretch(1) return newFeatureNameLayout - + def createOperatorsLayout(self): operatorsLayout = QHBoxLayout() operatorsLayout.addStretch(1) @@ -15336,23 +15317,23 @@ def createOperatorsLayout(self): self.operatorButtons = [] self.operators = [ - ('add', '+'), - ('subtract', '-'), - ('multiply', '*'), - ('divide', '/'), - ('open_bracket', '('), - ('close_bracket', ')'), - ('square', '**2'), - ('pow', '**'), - ('ln', 'log('), - ('log10', 'log10('), + ("add", "+"), + ("subtract", "-"), + ("multiply", "*"), + ("divide", "/"), + ("open_bracket", "("), + ("close_bracket", ")"), + ("square", "**2"), + ("pow", "**"), + ("ln", "log("), + ("log10", "log10("), ] operatorFont = QFont() operatorFont.setPixelSize(16) for name, text in self.operators: button = QPushButton() - button.setIcon(QIcon(f':{name}.svg')) - button.setIconSize(QSize(iconSize,iconSize)) + button.setIcon(QIcon(f":{name}.svg")) + button.setIconSize(QSize(iconSize, iconSize)) button.text = text operatorsLayout.addWidget(button) self.operatorButtons.append(button) @@ -15360,47 +15341,47 @@ def createOperatorsLayout(self): # button.setFont(operatorFont) clearButton = QPushButton() - clearButton.setIcon(QIcon(':clear.svg')) - clearButton.setIconSize(QSize(iconSize,iconSize)) + clearButton.setIcon(QIcon(":clear.svg")) + clearButton.setIconSize(QSize(iconSize, iconSize)) clearButton.setFont(operatorFont) clearEntryButton = QPushButton() - clearEntryButton.setIcon(QIcon(':backspace.svg')) + clearEntryButton.setIcon(QIcon(":backspace.svg")) clearEntryButton.setFont(operatorFont) - clearEntryButton.setIconSize(QSize(iconSize,iconSize)) + clearEntryButton.setIconSize(QSize(iconSize, iconSize)) operatorsLayout.addWidget(clearButton) operatorsLayout.addWidget(clearEntryButton) operatorsLayout.addStretch(1) - + clearButton.clicked.connect(self.clearEquation) clearEntryButton.clicked.connect(self.clearEntryEquation) - + return operatorsLayout def addOperator(self): button = self.sender() - text = f'{self.equationDisplay.toPlainText()}{button.text}' + text = f"{self.equationDisplay.toPlainText()}{button.text}" self.equationDisplay.setPlainText(text) self.clearLenghts.append(len(button.text)) def clearEquation(self): self.isOperatorMode = False - self.equationDisplay.setPlainText('') + self.equationDisplay.setPlainText("") self.initAttributes() - + def initAttributes(self): self.clearLenghts = [] self.equationColNames = [] self.channelLessColnames = [] - + def clearEntryEquation(self): if not self.clearLenghts: return text = self.equationDisplay.toPlainText() - newText = text[:-self.clearLenghts[-1]] - clearedText = text[-self.clearLenghts[-1]:] + newText = text[: -self.clearLenghts[-1]] + clearedText = text[-self.clearLenghts[-1] :] self.clearLenghts.pop(-1) self.equationDisplay.setPlainText(newText) if clearedText in self.equationColNames: @@ -15408,8 +15389,9 @@ def clearEntryEquation(self): if clearedText in self.channelLessColnames: self.channelLessColnames.remove(clearedText) + class QInput(QBaseDialog): - def __init__(self, parent=None, title='Input'): + def __init__(self, parent=None, title="Input"): self.cancel = True self.allowEmpty = True @@ -15441,11 +15423,11 @@ def __init__(self, parent=None, title='Input'): self.setFont(font) self.setLayout(self.mainLayout) - - def askText(self, prompt, infoText='', allowEmpty=False): + + def askText(self, prompt, infoText="", allowEmpty=False): self.allowEmpty = allowEmpty if infoText: - infoText = f'{infoText}
' + infoText = f"{infoText}
" self.infoLabel.setText(html_utils.paragraph(infoText)) self.promptLabel.setText(prompt) self.exec_(resizeWidthFactor=1.5) @@ -15454,35 +15436,39 @@ def ok_cb(self): self.answer = self.lineEdit.text() if not self.allowEmpty and not self.answer: msg = widgets.myMessageBox(showCentered=False) - msg.critical(self, 'Empty', 'Entry cannot be empty.') + msg.critical(self, "Empty", "Entry cannot be empty.") return self.cancel = False self.close() + class InstallPyTorchDialog(QBaseDialog): - def __init__(self, parent=None, caller_name='Cell-ACDC'): + def __init__(self, parent=None, caller_name="Cell-ACDC"): super().__init__(parent=parent) - + self.cancel = True - + mainLayout = QVBoxLayout() - + innerLayout = QGridLayout() - + iconLabel = QLabel(self) - standardIcon = getattr(QStyle, 'SP_MessageBoxInformation') + standardIcon = getattr(QStyle, "SP_MessageBoxInformation") icon = self.style().standardIcon(standardIcon) pixmap = icon.pixmap(60, 60) iconLabel.setPixmap(pixmap) innerLayout.addWidget(iconLabel, 0, 0, alignment=Qt.AlignTop) - - href = html_utils.href_tag('How to install PyTorch', urls.install_pytorch) - important = html_utils.to_admonition(""" + + href = html_utils.href_tag("How to install PyTorch", urls.install_pytorch) + important = html_utils.to_admonition( + """ Should you choose to install PyTorch yourself, make sure to activate
the correct acdc environment first
. - """, admonition_type='important') - + """, + admonition_type="important", + ) + infoText = html_utils.paragraph(f""" {caller_name} needs to install the package PyTorch.

Select your preferences and click ok to install it now. @@ -15494,150 +15480,159 @@ def __init__(self, parent=None, caller_name='Cell-ACDC'): """) innerLayout.addWidget(QLabel(infoText), 0, 1) innerLayout.addItem(QSpacerItem(10, 10), 1, 1) - + preferencesLayout = QGridLayout() - + row = 0 self.osCombobox = QComboBox() - self.osCombobox.addItems(['Linux', 'Mac', 'Windows']) - preferencesLayout.addWidget(QLabel('Your OS'), row, 0) + self.osCombobox.addItems(["Linux", "Mac", "Windows"]) + preferencesLayout.addWidget(QLabel("Your OS"), row, 0) preferencesLayout.addWidget(self.osCombobox, row, 1) - + if is_mac: - self.osCombobox.setCurrentText('Mac') + self.osCombobox.setCurrentText("Mac") elif is_win: - self.osCombobox.setCurrentText('Windows') - + self.osCombobox.setCurrentText("Windows") + row += 1 self.pkgManagerCombobox = QComboBox() - self.pkgManagerCombobox.addItems(['Pip']) + self.pkgManagerCombobox.addItems(["Pip"]) if not is_conda_env(): - self.pkgManagerCombobox.setCurrentText('Pip') + self.pkgManagerCombobox.setCurrentText("Pip") self.pkgManagerCombobox.setDisabled(True) - - preferencesLayout.addWidget(QLabel('Package manager'), row, 0) + + preferencesLayout.addWidget(QLabel("Package manager"), row, 0) preferencesLayout.addWidget(self.pkgManagerCombobox, row, 1) - + row += 1 self.cmptPlatformCombobox = QComboBox() self.cmptPlatformCombobox.addItems( - ['CPU', 'CUDA 11.8 (NVIDIA GPU)', 'CUDA 12.1 (NVIDIA GPU)'] + ["CPU", "CUDA 11.8 (NVIDIA GPU)", "CUDA 12.1 (NVIDIA GPU)"] ) - - preferencesLayout.addWidget(QLabel('Compute Platform'), row, 0) + + preferencesLayout.addWidget(QLabel("Compute Platform"), row, 0) preferencesLayout.addWidget(self.cmptPlatformCombobox, row, 1) - + row += 1 pip_prefix, conda_prefix = myutils.get_pip_conda_prefix() self.commandWidget = widgets.CopiableCommandWidget( - command=f'{pip_prefix} torch' + command=f"{pip_prefix} torch" ) - preferencesLayout.addWidget(QLabel('Run this command: '), row, 0) + preferencesLayout.addWidget(QLabel("Run this command: "), row, 0) preferencesLayout.addWidget(self.commandWidget, row, 1, 1, 2) preferencesLayout.setColumnStretch(0, 0) preferencesLayout.setColumnStretch(1, 0) preferencesLayout.setColumnStretch(2, 1) - + innerLayout.addLayout(preferencesLayout, 2, 1) - + buttonsLayout = widgets.CancelOkButtonsLayout() buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addLayout(innerLayout) mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) - + self.setLayout(mainLayout) - + self.osCombobox.currentTextChanged.connect(self.updateCommand) self.pkgManagerCombobox.currentTextChanged.connect(self.updateCommand) self.cmptPlatformCombobox.currentTextChanged.connect(self.updateCommand) - + self.updateCommand() - + def updateCommand(self, *args, **kwargs): osText = self.osCombobox.currentText() pkgManager = self.pkgManagerCombobox.currentText() cmptPlatform = self.cmptPlatformCombobox.currentText() command = myutils.get_pytorch_command()[osText][pkgManager][cmptPlatform] self.commandWidget.setCommand(command) - + def ok_cb(self): self.command = self.commandWidget.command() self.cancel = False self.close() + class ExportToVideoParametersDialog(QBaseDialog): sigOk = Signal(dict) sigAddScaleBar = Signal(bool) sigAddTimestamp = Signal(bool) sigRescaleIntensLut = Signal(str, str) sigChangeStartTime = Signal(str) - + def __init__( - self, channels, parent=None, startFolderpath='', startFilename='', - startFrameNum=1, SizeT=1, SizeZ=1, isTimelapseVideo=True, - isScaleBarPresent=False, isTimestampPresent=False, - rescaleIntensChannelHowMapper=None, - startTime=None - ): + self, + channels, + parent=None, + startFolderpath="", + startFilename="", + startFrameNum=1, + SizeT=1, + SizeZ=1, + isTimelapseVideo=True, + isScaleBarPresent=False, + isTimestampPresent=False, + rescaleIntensChannelHowMapper=None, + startTime=None, + ): self.cancel = True - + if rescaleIntensChannelHowMapper is None: rescaleIntensChannelHowMapper = {} - + super().__init__(parent=parent) - - self.setWindowTitle('Preferences for output video') - + + self.setWindowTitle("Preferences for output video") + mainLayout = QVBoxLayout() - + gridLayout = QGridLayout() - - navVar = 'frame number' if isTimelapseVideo else 'z-slice' + + navVar = "frame number" if isTimelapseVideo else "z-slice" maxNavVar = SizeT if isTimelapseVideo else SizeZ - + self.isTimelapseVideo = isTimelapseVideo - + row = 0 - gridLayout.addWidget(QLabel(f'Start {navVar}:'), row, 0) + gridLayout.addWidget(QLabel(f"Start {navVar}:"), row, 0) self.startNavVarNumberEntry = widgets.SpinBox() self.startNavVarNumberEntry.setMinimum(1) - self.startNavVarNumberEntry.setMaximum(maxNavVar-1) + self.startNavVarNumberEntry.setMaximum(maxNavVar - 1) self.startNavVarNumberEntry.setValue(startFrameNum) gridLayout.addWidget(self.startNavVarNumberEntry, row, 1) - + row += 1 - gridLayout.addWidget(QLabel(f'Stop {navVar}:'), row, 0) + gridLayout.addWidget(QLabel(f"Stop {navVar}:"), row, 0) self.stopNavVarNumberEntry = widgets.SpinBox() self.stopNavVarNumberEntry.setMinimum(2) self.stopNavVarNumberEntry.setMaximum(maxNavVar) self.stopNavVarNumberEntry.setValue(maxNavVar) gridLayout.addWidget(self.stopNavVarNumberEntry, row, 1) - + row += 1 - gridLayout.addWidget(QLabel('File format:'), row, 0) + gridLayout.addWidget(QLabel("File format:"), row, 0) self.fileFormatCombobox = QComboBox() - self.fileFormatCombobox.addItems(['MP4', 'AVI']) + self.fileFormatCombobox.addItems(["MP4", "AVI"]) gridLayout.addWidget(self.fileFormatCombobox, row, 1) - + row += 1 - gridLayout.addWidget(QLabel('Frame rate (FPS):'), row, 0) + gridLayout.addWidget(QLabel("Frame rate (FPS):"), row, 0) self.fpsWidget = widgets.FloatLineEdit(allowNegative=False) self.fpsWidget.setValue(10.0) gridLayout.addWidget(self.fpsWidget, row, 1) - + row += 1 self.dpiWidget = widgets.IntLineEdit(allowNegative=False) self.dpiWidget.setValue(300) - self.dpiWidget.label = QLabel('DPI') + self.dpiWidget.label = QLabel("DPI") gridLayout.addWidget(self.dpiWidget.label, row, 0) gridLayout.addWidget(self.dpiWidget, row, 1) - + row += 1 - gridLayout.addWidget(QLabel('Folder path:'), row, 0) + gridLayout.addWidget(QLabel("Folder path:"), row, 0) self.folderPathLineEdit = widgets.ElidingLineEdit(minWidth=240) self.folderPathLineEdit.setText(startFolderpath) gridLayout.addWidget(self.folderPathLineEdit, row, 1) @@ -15645,44 +15640,42 @@ def __init__( start_dir=startFolderpath, openFolder=True ) gridLayout.addWidget(self.browseButton, row, 2) - + row += 1 - gridLayout.addWidget(QLabel('Filename:'), row, 0) + gridLayout.addWidget(QLabel("Filename:"), row, 0) self.filenameLineEdit = widgets.alphaNumericLineEdit() self.filenameLineEdit.setAlignment(Qt.AlignCenter) self.filenameLineEdit.setText(startFilename) gridLayout.addWidget(self.filenameLineEdit, row, 1) - self.fileFormatLabel = QLabel('.mp4') + self.fileFormatLabel = QLabel(".mp4") gridLayout.addWidget(self.fileFormatLabel, row, 2) - + row += 1 - gridLayout.addWidget(QLabel('Add Scale Bar:'), row, 0) + gridLayout.addWidget(QLabel("Add Scale Bar:"), row, 0) self.addScaleBarToggle = widgets.Toggle() - gridLayout.addWidget( - self.addScaleBarToggle, row, 1, alignment=Qt.AlignCenter - ) + gridLayout.addWidget(self.addScaleBarToggle, row, 1, alignment=Qt.AlignCenter) self.addScaleBarToggle.setChecked(isScaleBarPresent) - + if isTimelapseVideo: row += 1 - gridLayout.addWidget(QLabel('Add timestamp:'), row, 0) + gridLayout.addWidget(QLabel("Add timestamp:"), row, 0) self.addTimestampToggle = widgets.Toggle() gridLayout.addWidget( self.addTimestampToggle, row, 1, alignment=Qt.AlignCenter ) self.addTimestampToggle.setChecked(isTimestampPresent) - + for channel in channels: row += 1 - labelText = f'Rescale intensities (LUT) {channel}:' + labelText = f"Rescale intensities (LUT) {channel}:" gridLayout.addWidget(QLabel(labelText), row, 0) - rescaleItems = ['Rescale each 2D image'] + rescaleItems = ["Rescale each 2D image"] if SizeZ > 1: - rescaleItems.append('Rescale across z-stack') + rescaleItems.append("Rescale across z-stack") if isTimelapseVideo: - rescaleItems.append('Rescale across time frames') - rescaleItems.append('Choose custom levels...') - rescaleItems.append('Do no rescale, display raw image') + rescaleItems.append("Rescale across time frames") + rescaleItems.append("Choose custom levels...") + rescaleItems.append("Do no rescale, display raw image") rescaleIntensCombobox = QComboBox() rescaleIntensCombobox.addItems(rescaleItems) rescaleIntensHow = rescaleIntensChannelHowMapper.get(channel) @@ -15692,117 +15685,107 @@ def __init__( rescaleIntensCombobox.textActivated.connect( partial(self.emitRescaleIntens, channel=channel) ) - + row += 1 - gridLayout.addWidget(QLabel('Save a PNG for each frame:'), row, 0) + gridLayout.addWidget(QLabel("Save a PNG for each frame:"), row, 0) self.saveFramesToggle = widgets.Toggle() - gridLayout.addWidget( - self.saveFramesToggle, row, 1, alignment=Qt.AlignCenter - ) - + gridLayout.addWidget(self.saveFramesToggle, row, 1, alignment=Qt.AlignCenter) + gridLayout.setColumnStretch(0, 0) gridLayout.setColumnStretch(1, 1) gridLayout.setColumnStretch(2, 0) - - self.fileFormatCombobox.currentTextChanged.connect( - self.updateFileFormat - ) + + self.fileFormatCombobox.currentTextChanged.connect(self.updateFileFormat) self.browseButton.sigPathSelected.connect(self.updateFolderPath) self.addScaleBarToggle.toggled.connect(self.addScaleBarToggled) if isTimelapseVideo: self.addTimestampToggle.toggled.connect(self.addTimestampToggled) - + buttonsLayout = widgets.CancelOkButtonsLayout() - buttonsLayout.okButton.setText('Export') - + buttonsLayout.okButton.setText("Export") + buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addLayout(gridLayout) mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) - + self.setLayout(mainLayout) - - def emitRescaleIntens(self, how, channel=''): + + def emitRescaleIntens(self, how, channel=""): self.sigRescaleIntensLut.emit(how, channel) - + def addScaleBarToggled(self, checked): self.sigAddScaleBar.emit(checked) - + def addTimestampToggled(self, checked): self.sigAddTimestamp.emit(checked) - + def updateFolderPath(self, folderPath): self.folderPathLineEdit.setText(folderPath) self.browseButton.setStartPath(folderPath) - + def updateFileFormat(self, fileFormat): - self.fileFormatLabel.setText(f'.{fileFormat.lower()}') - + self.fileFormatLabel.setText(f".{fileFormat.lower()}") + def validateFolderPath(self): folderPath = self.folderPathLineEdit.text() if os.path.exists(folderPath) and os.path.isdir(folderPath): return True - + text = html_utils.paragraph( - 'The selected folder path is not a valid folder or does not exist' + "The selected folder path is not a valid folder or does not exist" ) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Not a valid folder', text) + msg.warning(self, "Not a valid folder", text) return False - + def validateFilename(self): filename = self.filenameLineEdit.text() if filename: return True - - text = html_utils.paragraph( - 'The filename cannot be empty!' - ) + + text = html_utils.paragraph("The filename cannot be empty!") msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Not a valid folder', text) + msg.warning(self, "Not a valid folder", text) return False - + def validate(self): proceed = self.validateFolderPath() if not proceed: return False - + proceed = self.validateFilename() if not proceed: return False - + return True - + def preferences(self, makedirs=True): - filename = f'{self.filenameLineEdit.text()}{self.fileFormatLabel.text()}' - avi_filename = f'{self.filenameLineEdit.text()}.avi' + filename = f"{self.filenameLineEdit.text()}{self.fileFormatLabel.text()}" + avi_filename = f"{self.filenameLineEdit.text()}.avi" avi_filepath = os.path.join(self.folderPathLineEdit.text(), avi_filename) - png_foldername = ( - f'{self.filenameLineEdit.text()}_frames_PNG' - ) - pngs_folderpath = os.path.join( - self.folderPathLineEdit.text(), png_foldername - ) + png_foldername = f"{self.filenameLineEdit.text()}_frames_PNG" + pngs_folderpath = os.path.join(self.folderPathLineEdit.text(), png_foldername) if makedirs: os.makedirs(pngs_folderpath, exist_ok=True) - + preferences = { - 'start_nav_var_num': self.startNavVarNumberEntry.value(), - 'stop_nav_var_num': self.stopNavVarNumberEntry.value(), - 'filepath': os.path.join(self.folderPathLineEdit.text(), filename), - 'filename': self.filenameLineEdit.text(), - 'avi_filepath': avi_filepath, - 'pngs_folderpath': pngs_folderpath, - 'num_digits': len(str(self.stopNavVarNumberEntry.value())), - 'fps': self.fpsWidget.value(), - 'save_pngs': self.saveFramesToggle.isChecked(), - 'is_timelapse': self.isTimelapseVideo, - 'dpi': self.dpiWidget.value(), + "start_nav_var_num": self.startNavVarNumberEntry.value(), + "stop_nav_var_num": self.stopNavVarNumberEntry.value(), + "filepath": os.path.join(self.folderPathLineEdit.text(), filename), + "filename": self.filenameLineEdit.text(), + "avi_filepath": avi_filepath, + "pngs_folderpath": pngs_folderpath, + "num_digits": len(str(self.stopNavVarNumberEntry.value())), + "fps": self.fpsWidget.value(), + "save_pngs": self.saveFramesToggle.isChecked(), + "is_timelapse": self.isTimelapseVideo, + "dpi": self.dpiWidget.value(), } return preferences - + def ok_cb(self): proceed = self.validate() if not proceed: @@ -15811,186 +15794,186 @@ def ok_cb(self): self.sigOk.emit(self.preferences()) self.selected_preferences = self.preferences() self.close() - + + class TimestampPropertiesDialog(QBaseDialog): sigValueChanged = Signal(object) - + def __init__(self, parent=None, **properties): super().__init__(parent=parent) - + self.cancel = True - self.setWindowTitle('Timestamp preferences') - + self.setWindowTitle("Timestamp preferences") + mainLayout = QVBoxLayout() - + formLayout = widgets.FormLayout() formLayout.setVerticalSpacing(10) formLayout.setHorizontalSpacing(50) - + row = 0 self.startTimeWidget = widgets.TimeWidget() - if properties.get('start_timedelta') is not None: + if properties.get("start_timedelta") is not None: self.startTimeWidget.setValuesFromTimedelta( - properties.get('start_timedelta') + properties.get("start_timedelta") ) startTimeFormWidget = widgets.formWidget( - self.startTimeWidget, labelTextLeft='Start time', + self.startTimeWidget, + labelTextLeft="Start time", ) formLayout.addFormWidget( - startTimeFormWidget, row=row, - leftLabelAlignment=Qt.AlignLeft + startTimeFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft ) - + row += 1 self.colorButton = widgets.myColorButton(color=(255, 255, 255)) - if properties.get('color') is not None: - self.colorButton.setColor(properties.get('color')) + if properties.get("color") is not None: + self.colorButton.setColor(properties.get("color")) colorFormWidget = widgets.formWidget( - self.colorButton, labelTextLeft='Color', - widgetAlignment=Qt.AlignCenter, stretchWidget=False + self.colorButton, + labelTextLeft="Color", + widgetAlignment=Qt.AlignCenter, + stretchWidget=False, ) formLayout.addFormWidget( - colorFormWidget, row=row, - leftLabelAlignment=Qt.AlignLeft - ) - + colorFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + row += 1 fontSizeWidget = widgets.FontSizeWidget() - if properties.get('font_size') is not None: - fontSizeWidget.setValue(properties.get('font_size')) + if properties.get("font_size") is not None: + fontSizeWidget.setValue(properties.get("font_size")) else: fontSizeWidget.setValue(12) fontSizeFormWidget = widgets.formWidget( - fontSizeWidget, labelTextLeft='Font size (px)' + fontSizeWidget, labelTextLeft="Font size (px)" ) self.fontSizeWidget = fontSizeWidget formLayout.addFormWidget( - fontSizeFormWidget, row=row, - leftLabelAlignment=Qt.AlignLeft + fontSizeFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft ) - + row += 1 locCombobox = QComboBox() - locFormWidget = widgets.formWidget( - locCombobox, labelTextLeft='Location' - ) + locFormWidget = widgets.formWidget(locCombobox, labelTextLeft="Location") locCombobox.addItems( - ['Top-left', 'Top-right', 'Bottom-left', 'Bottom-right', 'Custom'] + ["Top-left", "Top-right", "Bottom-left", "Bottom-right", "Custom"] ) - loc = properties.get('loc') + loc = properties.get("loc") if isinstance(loc, str): locCombobox.setCurrentText(loc.capitalize()) formLayout.addFormWidget( - locFormWidget, row=row, - leftLabelAlignment=Qt.AlignLeft - ) + locFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) self.locCombobox = locCombobox - + row += 1 moveWithZoomToggle = widgets.Toggle() moveWithZoomWidget = widgets.formWidget( - moveWithZoomToggle, labelTextLeft='Move timestamp with zoom', - widgetAlignment=Qt.AlignCenter, stretchWidget=False + moveWithZoomToggle, + labelTextLeft="Move timestamp with zoom", + widgetAlignment=Qt.AlignCenter, + stretchWidget=False, ) formLayout.addFormWidget( - moveWithZoomWidget, row=row, - leftLabelAlignment=Qt.AlignLeft + moveWithZoomWidget, row=row, leftLabelAlignment=Qt.AlignLeft ) self.moveWithZoomToggle = moveWithZoomToggle - + mainLayout.addLayout(formLayout) - + buttonsLayout = widgets.CancelOkButtonsLayout() buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) mainLayout.addStretch() - + self.setLayout(mainLayout) self.setFont(font) - + self.colorButton.clicked.disconnect() self.colorButton.clicked.connect(self.selectColor) - + self.startTimeWidget.sigValueChanged.connect(self.onValueChanged) - + self.locCombobox.currentTextChanged.connect(self.onValueChanged) self.fontSizeWidget.sigTextChanged.connect(self.onValueChanged) self.moveWithZoomToggle.toggled.connect(self.onValueChanged) - + def onValueChanged(self, *args, **kwargs): self.sigValueChanged.emit(self.kwargs()) - + def selectColor(self): color = self.colorButton.color() self.colorButton.origColor = color self.colorButton.colorDialog.setCurrentColor(color) - self.colorButton.colorDialog.setWindowFlags( - Qt.Window | Qt.WindowStaysOnTopHint - ) + self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) self.colorButton.colorDialog.setParent(self) self.colorButton.colorDialog.open() w = self.width() left = self.pos().x() colorDialogTop = self.colorButton.colorDialog.pos().y() - self.colorButton.colorDialog.move(w+left+10, colorDialogTop) - + self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) + def kwargs(self): kwargs = { - 'color': self.colorButton.color(), - 'start_timedelta': self.startTimeWidget.timedelta(), - 'loc': self.locCombobox.currentText().lower(), - 'font_size': self.fontSizeWidget.text(), - 'move_with_zoom': self.moveWithZoomToggle.isChecked() + "color": self.colorButton.color(), + "start_timedelta": self.startTimeWidget.timedelta(), + "loc": self.locCombobox.currentText().lower(), + "font_size": self.fontSizeWidget.text(), + "move_with_zoom": self.moveWithZoomToggle.isChecked(), } return kwargs - + def ok_cb(self): self.cancel = False self.close() + class ExportToImageParametersDialog(QBaseDialog): sigOk = Signal(dict) sigAddScaleBar = Signal(bool) sigRangeChanged = Signal(object) - + def __init__( - self, parent=None, startFolderpath='', startFilename='', - startViewRange=None, isScaleBarPresent=False - ): + self, + parent=None, + startFolderpath="", + startFilename="", + startViewRange=None, + isScaleBarPresent=False, + ): self.cancel = True - + super().__init__(parent=parent) - - self.setWindowTitle('Preferences for output image') - + + self.setWindowTitle("Preferences for output image") + mainLayout = QVBoxLayout() - + gridLayout = QGridLayout() - + row = 0 - gridLayout.addWidget(QLabel('View range X axis:'), row, 0) + gridLayout.addWidget(QLabel("View range X axis:"), row, 0) self.xRangeSelector = widgets.RangeSelector(integers=True) if startViewRange is not None: xRange, yRange = startViewRange self.xRangeSelector.setRange(*xRange) gridLayout.addWidget(self.xRangeSelector, row, 1) - + row += 1 - gridLayout.addWidget(QLabel('View range Y axis:'), row, 0) + gridLayout.addWidget(QLabel("View range Y axis:"), row, 0) self.yRangeSelector = widgets.RangeSelector(integers=True) if startViewRange is not None: xRange, yRange = startViewRange self.yRangeSelector.setRange(*yRange) gridLayout.addWidget(self.yRangeSelector, row, 1) - + row += 1 - gridLayout.addWidget(QLabel('Width and Height:'), row, 0) - self.widthHeightSelector = widgets.RangeSelector( - integers=True, ordered=False - ) + gridLayout.addWidget(QLabel("Width and Height:"), row, 0) + self.widthHeightSelector = widgets.RangeSelector(integers=True, ordered=False) if startViewRange is not None: xRange, yRange = startViewRange width = int(xRange[1] - xRange[0]) @@ -15999,28 +15982,26 @@ def __init__( gridLayout.addWidget(self.widthHeightSelector, row, 1) self.lockSizeButton = widgets.LockPushButton() self.lockSizeButton.setCheckable(True) - self.lockSizeButton.setToolTip( - 'Lock width and height' - ) + self.lockSizeButton.setToolTip("Lock width and height") gridLayout.addWidget(self.lockSizeButton, row, 2) - + row += 1 - gridLayout.addWidget(QLabel('File format:'), row, 0) + gridLayout.addWidget(QLabel("File format:"), row, 0) self.fileFormatCombobox = QComboBox() - self.fileFormatCombobox.addItems(['SVG', 'PNG', 'TIFF', 'JPEG']) + self.fileFormatCombobox.addItems(["SVG", "PNG", "TIFF", "JPEG"]) gridLayout.addWidget(self.fileFormatCombobox, row, 1) - + row += 1 self.dpiWidget = widgets.IntLineEdit(allowNegative=False) self.dpiWidget.setValue(300) - self.dpiWidget.label = QLabel('DPI') + self.dpiWidget.label = QLabel("DPI") gridLayout.addWidget(self.dpiWidget.label, row, 0) gridLayout.addWidget(self.dpiWidget, row, 1) self.dpiWidget.hide() self.dpiWidget.label.hide() - + row += 1 - gridLayout.addWidget(QLabel('Folder path:'), row, 0) + gridLayout.addWidget(QLabel("Folder path:"), row, 0) self.folderPathLineEdit = widgets.ElidingLineEdit(minWidth=240) self.folderPathLineEdit.setText(startFolderpath) gridLayout.addWidget(self.folderPathLineEdit, row, 1) @@ -16028,29 +16009,25 @@ def __init__( start_dir=startFolderpath, openFolder=True ) gridLayout.addWidget(self.browseButton, row, 2) - + row += 1 - gridLayout.addWidget(QLabel('Filename:'), row, 0) + gridLayout.addWidget(QLabel("Filename:"), row, 0) self.filenameLineEdit = widgets.alphaNumericLineEdit() self.filenameLineEdit.setAlignment(Qt.AlignCenter) self.filenameLineEdit.setText(startFilename) gridLayout.addWidget(self.filenameLineEdit, row, 1) self.fileFormatLabel = QLabel( - f'.{self.fileFormatCombobox.currentText().lower()}' + f".{self.fileFormatCombobox.currentText().lower()}" ) gridLayout.addWidget(self.fileFormatLabel, row, 2) - + row += 1 - gridLayout.addWidget(QLabel('Add Scale Bar:'), row, 0) + gridLayout.addWidget(QLabel("Add Scale Bar:"), row, 0) self.addScaleBarToggle = widgets.Toggle() - gridLayout.addWidget( - self.addScaleBarToggle, row, 1, alignment=Qt.AlignCenter - ) + gridLayout.addWidget(self.addScaleBarToggle, row, 1, alignment=Qt.AlignCenter) self.addScaleBarToggle.setChecked(isScaleBarPresent) - - self.fileFormatCombobox.currentTextChanged.connect( - self.updateFileFormat - ) + + self.fileFormatCombobox.currentTextChanged.connect(self.updateFileFormat) self.browseButton.sigPathSelected.connect(self.updateFolderPath) self.addScaleBarToggle.toggled.connect(self.addScaleBarToggled) self.xRangeSelector.sigLowValueChanged.connect(self.x0Changed) @@ -16062,24 +16039,24 @@ def __init__( self.widthHeightSelector.sigRangeManuallyChanged.connect( self.widthHeightManuallyChanged ) - + buttonsLayout = widgets.CancelOkButtonsLayout() - buttonsLayout.okButton.setText('Export') - + buttonsLayout.okButton.setText("Export") + buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + gridLayout.setColumnStretch(2, 0) - + mainLayout.addLayout(gridLayout) mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) - + self.setLayout(mainLayout) - + def widthHeightManuallyChanged(self, *args): self.lockSizeButton.setChecked(True) - + def x0Changed(self, *args): if self.lockSizeButton.isChecked(): x0, _ = self.xRangeSelector.range() @@ -16092,12 +16069,12 @@ def x0Changed(self, *args): yRange = self.yRangeSelector.range() _, height = self.widthHeightSelector.range() width = int(xRange[1] - xRange[0]) - + self.xRangeSelector.setRangeNoEmit(*xRange) self.yRangeSelector.setRangeNoEmit(*yRange) self.widthHeightSelector.setRangeNoEmit(width, height) self.rangeChanged() - + def x1Changed(self, *args): if self.lockSizeButton.isChecked(): _, x1 = self.xRangeSelector.range() @@ -16110,13 +16087,13 @@ def x1Changed(self, *args): yRange = self.yRangeSelector.range() _, height = self.widthHeightSelector.range() width = int(xRange[1] - xRange[0]) - + self.xRangeSelector.setRangeNoEmit(*xRange) self.yRangeSelector.setRangeNoEmit(*yRange) self.widthHeightSelector.setRangeNoEmit(width, height) - + self.rangeChanged() - + def y0Changed(self, *args): if self.lockSizeButton.isChecked(): xRange = self.xRangeSelector.range() @@ -16129,13 +16106,13 @@ def y0Changed(self, *args): yRange = self.yRangeSelector.range() width, _ = self.widthHeightSelector.range() height = int(yRange[1] - yRange[0]) - + self.xRangeSelector.setRangeNoEmit(*xRange) self.yRangeSelector.setRangeNoEmit(*yRange) self.widthHeightSelector.setRangeNoEmit(width, height) - + self.rangeChanged() - + def y1Changed(self, *args): if self.lockSizeButton.isChecked(): xRange = self.xRangeSelector.range() @@ -16148,26 +16125,26 @@ def y1Changed(self, *args): yRange = self.yRangeSelector.range() width, _ = self.widthHeightSelector.range() height = int(yRange[1] - yRange[0]) - + self.xRangeSelector.setRangeNoEmit(*xRange) self.yRangeSelector.setRangeNoEmit(*yRange) self.widthHeightSelector.setRangeNoEmit(width, height) - + self.rangeChanged() - + def widthChanged(self, *args): self.widthHeightChanged() self.rangeChanged() - + def heightChanged(self, *args): self.widthHeightChanged() self.rangeChanged() - + def updateViewRangeExportToImageDialog(self, viewBox, viewRange, changed): xRange, yRange = viewRange self.xRangeSelector.setRangeNoEmit(*xRange) self.yRangeSelector.setRangeNoEmit(*yRange) - + def widthHeightChanged(self, *args): x0, _ = self.xRangeSelector.range() y0, _ = self.yRangeSelector.range() @@ -16177,64 +16154,62 @@ def widthHeightChanged(self, *args): self.xRangeSelector.setRangeNoEmit(x0, x1) self.yRangeSelector.setRangeNoEmit(y0, y1) self.rangeChanged() - + def rangeChanged(self, *args): xRange = self.xRangeSelector.range() yRange = self.yRangeSelector.range() self.sigRangeChanged.emit((xRange, yRange)) - + def addScaleBarToggled(self, checked): self.sigAddScaleBar.emit(checked) - + def updateFolderPath(self, folderPath): self.folderPathLineEdit.setText(folderPath) self.browseButton.setStartPath(folderPath) - + def updateFileFormat(self, fileFormat): - if fileFormat == 'SVG': + if fileFormat == "SVG": self.dpiWidget.hide() self.dpiWidget.label.hide() else: self.dpiWidget.show() self.dpiWidget.label.show() - - self.fileFormatLabel.setText(f'.{fileFormat.lower()}') - + + self.fileFormatLabel.setText(f".{fileFormat.lower()}") + def validateFolderPath(self): folderPath = self.folderPathLineEdit.text() if os.path.exists(folderPath) and os.path.isdir(folderPath): return True - + text = html_utils.paragraph( - 'The selected folder path is not a valid folder or does not exist' + "The selected folder path is not a valid folder or does not exist" ) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Not a valid folder', text) + msg.warning(self, "Not a valid folder", text) return False - + def validateFilename(self): filename = self.filenameLineEdit.text() if filename: return True - - text = html_utils.paragraph( - 'The filename cannot be empty!' - ) + + text = html_utils.paragraph("The filename cannot be empty!") msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Not a valid folder', text) + msg.warning(self, "Not a valid folder", text) return False - + def validate(self): proceed = self.validateFolderPath() if not proceed: return False - + proceed = self.validateFilename() if not proceed: return False - + return True - + def setViewRange(self, xRange, yRange, emitSignal=True): if self.lockSizeButton.isChecked(): x0, _ = xRange @@ -16247,31 +16222,31 @@ def setViewRange(self, xRange, yRange, emitSignal=True): else: width = int(xRange[1] - xRange[0]) height = int(yRange[1] - yRange[0]) - + self.xRangeSelector.setRangeNoEmit(*xRange) self.yRangeSelector.setRangeNoEmit(*yRange) self.widthHeightSelector.setRangeNoEmit(width, height) if not emitSignal: return - + self.rangeChanged() - + def viewRange(self): xRange = self.xRangeSelector.range() yRange = self.yRangeSelector.range() return (xRange, yRange) - + def preferences(self): - filename = f'{self.filenameLineEdit.text()}{self.fileFormatLabel.text()}' + filename = f"{self.filenameLineEdit.text()}{self.fileFormatLabel.text()}" preferences = { - 'view_range_x': self.xRangeSelector.range(), - 'view_range_y': self.yRangeSelector.range(), - 'filepath': os.path.join(self.folderPathLineEdit.text(), filename), - 'filename': self.filenameLineEdit.text(), - 'dpi': self.dpiWidget.value(), + "view_range_x": self.xRangeSelector.range(), + "view_range_y": self.yRangeSelector.range(), + "filepath": os.path.join(self.folderPathLineEdit.text(), filename), + "filename": self.filenameLineEdit.text(), + "dpi": self.dpiWidget.value(), } return preferences - + def ok_cb(self): proceed = self.validate() if not proceed: @@ -16280,170 +16255,171 @@ def ok_cb(self): self.sigOk.emit(self.preferences()) self.selected_preferences = self.preferences() self.close() - + + class DataPrepSubCropsPathsDialog(QBaseDialog): def __init__(self, cropPaths=None, parent=None): self.cancel = True - + super().__init__(parent=parent) - + mainLayout = QVBoxLayout() - - gridLayout = QGridLayout() + + gridLayout = QGridLayout() row = 0 - + if cropPaths is None: - cropPaths = {os.path.expanduser('~'): 1} - - if any([numCrops>1 for numCrops in cropPaths.values()]): + cropPaths = {os.path.expanduser("~"): 1} + + if any([numCrops > 1 for numCrops in cropPaths.values()]): row += 1 - gridLayout.addWidget( - QLabel('Same folder for all crops:'), row, 0 - ) + gridLayout.addWidget(QLabel("Same folder for all crops:"), row, 0) self.sameFolderPathToggle = widgets.Toggle() gridLayout.addWidget( self.sameFolderPathToggle, row, 1, alignment=Qt.AlignCenter ) self.sameFolderPathToggle.setChecked(True) self.sameFolderPathToggle.toggled.connect(self.setSameFolderPath) - + self.windowMinWidth = 0 - minWidth = int(self.screen().size().width()/3) + minWidth = int(self.screen().size().width() / 3) self.folderPathLineEdits = defaultdict(list) for path, numCrops in cropPaths.items(): row += 1 - gridLayout.addWidget(QLabel('Master Position:'), row, 0) - masterPathLabel = QLabel(f'{path}') + gridLayout.addWidget(QLabel("Master Position:"), row, 0) + masterPathLabel = QLabel(f"{path}") gridLayout.addWidget(masterPathLabel, row, 1) - + scrollArea = QScrollArea() scrollArea.setWidgetResizable(True) scrollAreaLayout = QGridLayout() for i in range(numCrops): - label = QLabel(f'Crop {i+1} folder path:') + label = QLabel(f"Crop {i + 1} folder path:") scrollAreaLayout.addWidget(label, i, 0) folderPathLineEdit = widgets.ElidingLineEdit() folderPathLineEdit.label = label folderPathLineEdit.setText(path) scrollAreaLayout.addWidget(folderPathLineEdit, i, 1) - browseButton = widgets.browseFileButton( - start_dir=path, openFolder=True - ) + browseButton = widgets.browseFileButton(start_dir=path, openFolder=True) scrollAreaLayout.addWidget(browseButton, i, 2) browseButton.sigPathSelected.connect( partial(self.updateFolderPath, lineEdit=folderPathLineEdit) ) self.folderPathLineEdits[path].append(folderPathLineEdit) folderPathLineEdit.browseButton = browseButton - + scrollAreaLayout.setColumnStretch(0, 0) scrollAreaLayout.setColumnStretch(1, 1) scrollAreaLayout.setColumnStretch(2, 0) container = QWidget() container.setLayout(scrollAreaLayout) scrollArea.setWidget(container) - + row += 1 gridLayout.addWidget(scrollArea, row, 0, 1, 2) noHorizontalScrollbarWidth = ( - container.sizeHint().width() - + scrollArea.verticalScrollBar().sizeHint().width() + 20 + container.sizeHint().width() + + scrollArea.verticalScrollBar().sizeHint().width() + + 20 ) if noHorizontalScrollbarWidth > self.windowMinWidth: self.windowMinWidth = noHorizontalScrollbarWidth row += 1 gridLayout.addWidget(widgets.QHLine(), row, 0, 1, 2) - + row += 1 gridLayout.addItem(QSpacerItem(10, 10), row, 0, 1, 2) - + row += 1 - + buttonsLayout = widgets.CancelOkButtonsLayout() - + buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addLayout(gridLayout) mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) - + self.setLayout(mainLayout) - + def show(self, block=False): self.resize(self.windowMinWidth, self.sizeHint().height()) super().show(block=block) - + def setSameFolderPath(self, checked): for masterPath, lineEdits in self.folderPathLineEdits.items(): referencePath = lineEdits[0].text() for lineEdit in lineEdits[1:]: if checked: lineEdit.setText(referencePath) - + lineEdit.setDisabled(checked) lineEdit.browseButton.setDisabled(checked) lineEdit.label.setDisabled(checked) - + def updateFolderPath(self, path, lineEdit=None): lineEdit.setText(path) lineEdit.browseButton.setStartPath(path) - + def warnFolderPathNotValid(self, cropNum, masterPath, folderPath): text = html_utils.paragraph( - f'The following folder path for crop number {cropNum} ' - 'is not a valid folder or does not exist:' + f"The following folder path for crop number {cropNum} " + "is not a valid folder or does not exist:" ) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Not a valid folder', text, commands=(folderPath,)) - + msg.warning(self, "Not a valid folder", text, commands=(folderPath,)) + def askOverwritingPaths(self, overwritingPaths): text = html_utils.paragraph( - 'Data in the following paths will be overwritten with ' - 'cropped data.

' - 'Are you sure you want to continue?' + "Data in the following paths will be overwritten with " + "cropped data.

" + "Are you sure you want to continue?" ) msg = widgets.myMessageBox(wrapText=False) _, yesButton = msg.warning( - self, 'Not a valid folder', text, commands=overwritingPaths, - buttonsTexts=('No, let me edit paths', 'Yes, overwrite') + self, + "Not a valid folder", + text, + commands=overwritingPaths, + buttonsTexts=("No, let me edit paths", "Yes, overwrite"), ) return msg.clickedButton == yesButton - + def validatePaths(self): for masterPath, lineEdits in self.folderPathLineEdits.items(): for i, lineEdit in enumerate(lineEdits): path = lineEdit.text() if os.path.exists(path) and os.path.isdir(path): continue - - self.warnFolderPathNotValid(i+1, masterPath, path) + + self.warnFolderPathNotValid(i + 1, masterPath, path) return False overwritingPaths = [] for masterPath, lineEdits in self.folderPathLineEdits.items(): - masterPath = masterPath.replace('\\', '/') - if not masterPath.endswith('Images'): + masterPath = masterPath.replace("\\", "/") + if not masterPath.endswith("Images"): continue - + for i, lineEdit in enumerate(lineEdits): path = lineEdit.text() - path = path.replace('\\', '/') + path = path.replace("\\", "/") if path == masterPath: overwritingPaths.append(masterPath) - + if not overwritingPaths: return True - + return self.askOverwritingPaths(overwritingPaths) - + def paths(self): selectedPaths = {} for masterPath, lineEdits in self.folderPathLineEdits.items(): selectedPaths[masterPath] = [le.text() for le in lineEdits] return selectedPaths - + def ok_cb(self): proceed = self.validatePaths() if not proceed: @@ -16453,119 +16429,120 @@ def ok_cb(self): self.cancel = False self.close() + class PreProcessParamsWidget(QWidget): sigLoadRecipe = Signal() sigLoadSavedRecipe = Signal() sigValuesChanged = Signal(list) - + def __init__(self, df_metadata=None, addApplyButton=False, parent=None): super().__init__(parent) - + mainLayout = QVBoxLayout() - + self.df_metadata = df_metadata self.addApplyButton = addApplyButton - + groupbox = QGroupBox() self.groupbox = groupbox - - groupbox.setTitle('Pre-processing') + + groupbox.setTitle("Pre-processing") groupbox.setCheckable(True) - - self.gridLayout = QGridLayout() + + self.gridLayout = QGridLayout() self.row = -1 self.stepsWidgets = {} - + self.gridLayout.setColumnStretch(0, 0) self.gridLayout.setColumnStretch(1, 1) self.gridLayout.setColumnStretch(2, 0) self.gridLayout.setColumnStretch(3, 0) self.gridLayout.setColumnStretch(4, 0) groupbox.setLayout(self.gridLayout) - + buttonsLayout = QGridLayout() row = 0 col = 0 buttonsLayout.setColumnStretch(col, 1) - - loadRecipeButton = widgets.OpenFilePushButton('Load saved recipe...') + + loadRecipeButton = widgets.OpenFilePushButton("Load saved recipe...") self.loadRecipeButton = loadRecipeButton - buttonsLayout.addWidget(loadRecipeButton, row, col+2) - - saveRecipeButton = widgets.savePushButton('Save current recipe...') + buttonsLayout.addWidget(loadRecipeButton, row, col + 2) + + saveRecipeButton = widgets.savePushButton("Save current recipe...") self.saveRecipeButton = saveRecipeButton - buttonsLayout.addWidget(saveRecipeButton, row+1, col+2) - - loadLastRecipeButton = widgets.reloadPushButton('Load last parameters') + buttonsLayout.addWidget(saveRecipeButton, row + 1, col + 2) + + loadLastRecipeButton = widgets.reloadPushButton("Load last parameters") self.loadLastRecipeButton = loadLastRecipeButton - buttonsLayout.addWidget(loadLastRecipeButton, row, col+1) - + buttonsLayout.addWidget(loadLastRecipeButton, row, col + 1) + self.buttonsLayout = buttonsLayout - + loadLastRecipeButton.clicked.connect(self.emitLoadRecipe) saveRecipeButton.clicked.connect(self.saveRecipe) loadRecipeButton.clicked.connect(self.selectAndLoadRecipe) - + mainLayout.addWidget(groupbox) mainLayout.addSpacing(10) mainLayout.addLayout(buttonsLayout) - + self.addStep(is_first=True) - + mainLayout.setContentsMargins(0, 0, 0, 0) self.setLayout(mainLayout) - + def stepSizeHeightHint(self): stepWidgets = self.stepsWidgets[1] height = ( - stepWidgets['stepLabel'].minimumSizeHint().height() - + stepWidgets['selector'].minimumSizeHint().height() + stepWidgets["stepLabel"].minimumSizeHint().height() + + stepWidgets["selector"].minimumSizeHint().height() ) return height - + def setChecked(self, checked): self.groupbox.setChecked(checked) - + def emitLoadRecipe(self): self.sigLoadRecipe.emit() - + def loadRecipe(self, configPars: dict): for stepWidgets in list(self.stepsWidgets.values()): try: - stepWidgets['delButton'].click() + stepWidgets["delButton"].click() except Exception as err: pass - + configPars = self.sortStepsConfigPars(configPars) for s in range(1, len(configPars)): - self.stepsWidgets[1]['addButton'].click() - + self.stepsWidgets[1]["addButton"].click() + for i, (section, section_items) in enumerate(configPars.items()): - step_n = i+1 - selector = self.stepsWidgets[step_n]['selector'] + step_n = i + 1 + selector = self.stepsWidgets[step_n]["selector"] kwarg_to_value_mapper = {} for option, value in section_items.items(): - if option == 'method': + if option == "method": selector.setCurrentText(value) method = value else: kwarg_to_value_mapper[option] = value selector.setParams(method, kwarg_to_value_mapper) - + self.setChecked(True) - + def sortStepsConfigPars(self, configPars: dict): sortedConfigPars = {} sortedKeys = sorted( - configPars.keys(), - key=lambda key: int(re.findall(r'step(\d+)', key)[0]) + configPars.keys(), key=lambda key: int(re.findall(r"step(\d+)", key)[0]) ) for key in sortedKeys: sortedConfigPars[key] = configPars[key] return sortedConfigPars - - def saveRecipeUI(self, folder_path, ext, title, basename, hintText, - default_text):# -> tuple[Literal[False], Literal['']] | tuple[Literal[True], Any]: + + def saveRecipeUI( + self, folder_path, ext, title, basename, hintText, default_text + ): # -> tuple[Literal[False], Literal['']] | tuple[Literal[True], Any]: win = filenameDialog( title=title, basename=basename, @@ -16577,93 +16554,92 @@ def saveRecipeUI(self, folder_path, ext, title, basename, hintText, ) win.exec_() if win.cancel: - return False, '' - + return False, "" + self.cancel = False filepath = win.filename os.makedirs(folder_path, exist_ok=True) filepath = os.path.join(folder_path, filepath) - + if os.path.exists(filepath): proceed = self.warnExistingRecipeFile(filepath) if not proceed: - return False, '' - + return False, "" + return True, filepath - + def saveRecipe(self): recipe = self.recipe() if recipe is None: return - - default_text = '' + + default_text = "" for step in recipe[:2]: - method = step['method'] - func_name = config.PREPROCESS_MAPPER[method]['function_name'] - default_text = f'{default_text}-{func_name}' - default_text = default_text.lstrip('-') - - proceed, ini_filepath = self.saveRecipeUI(preproc_recipes_path, '.ini', - 'Filename for pre-processing recipe', - 'preprocessing_recipe', - 'Insert a filename for the pre-processing recipe:', - default_text - ) + method = step["method"] + func_name = config.PREPROCESS_MAPPER[method]["function_name"] + default_text = f"{default_text}-{func_name}" + default_text = default_text.lstrip("-") + + proceed, ini_filepath = self.saveRecipeUI( + preproc_recipes_path, + ".ini", + "Filename for pre-processing recipe", + "preprocessing_recipe", + "Insert a filename for the pre-processing recipe:", + default_text, + ) if not proceed: return - - cp = self.recipeConfigPars('acdc') - with open(ini_filepath, 'w') as configfile: + + cp = self.recipeConfigPars("acdc") + with open(ini_filepath, "w") as configfile: cp.write(configfile) - + self.communicateSavingRecipeFinished(ini_filepath) - + def warnExistingRecipeFile(self, ini_filename): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - 'A file with the following name

' - f'{ini_filename}

' - 'already exists.

' - 'Do you want to overwrite the existing file?' + "A file with the following name

" + f"{ini_filename}

" + "already exists.

" + "Do you want to overwrite the existing file?" ) noButton, yesButton = msg.warning( - self, 'File name existing', txt, - buttonsTexts=( - 'No, stop saving process', - 'Yes, overwrite existing file' - ) + self, + "File name existing", + txt, + buttonsTexts=("No, stop saving process", "Yes, overwrite existing file"), ) return msg.clickedButton == yesButton def warnNoAvailableRecipesToLoad(self): - text = html_utils.paragraph( - 'There are no recipes saved. Sorry about that :(' - ) + text = html_utils.paragraph("There are no recipes saved. Sorry about that :(") msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'No recipes saved', text) - + msg.warning(self, "No recipes saved", text) + # def selectIniFileToLoadRecipe(self): # import qtpy.compat # ini_filepath = qtpy.compat.getopenfilename( - # parent=self, - # caption='Select INI file to load pre-processing recipe', + # parent=self, + # caption='Select INI file to load pre-processing recipe', # filters='INI (*.ini);;All Files (*)' # )[0] # if not ini_filepath: # return - + # cp = config.ConfigParser() # cp.read(ini_filepath) # preprocConfigPars = {} # for section in cp.sections(): # if not section.startswith('acdc.preprocess'): - # continue - + # continue + # preprocConfigPars[section] = cp[section] - + # if not preprocConfigPars: # return - + # self.loadRecipe(preprocConfigPars) def selectRecipeFilepath(self, recipes_path, recipe_prefix, ext_label, ext): @@ -16672,39 +16648,40 @@ def selectRecipeFilepath(self, recipes_path, recipe_prefix, ext_label, ext): for file in myutils.listdir(recipes_path): if not file.startswith(recipe_prefix): continue - endname = file.split(f'{recipe_prefix}_')[1] + endname = file.split(f"{recipe_prefix}_")[1] availableRecipes.append(endname) - + if not availableRecipes: import qtpy.compat + filepath = qtpy.compat.getopenfilename( parent=self, - caption=f'Select {ext_label} file to load recipe', - filters=f'{ext_label} (*.{ext});;All Files (*)' + caption=f"Select {ext_label} file to load recipe", + filters=f"{ext_label} (*.{ext});;All Files (*)", )[0] return filepath or None - + browseButton = widgets.browseFileButton( - f'Select {ext_label} file...', - title=f'Select {ext_label} file to load recipe', + f"Select {ext_label} file...", + title=f"Select {ext_label} file to load recipe", openFolder=False, start_dir=myutils.getMostRecentPath(), - ext={ext_label: f'.{ext}'} + ext={ext_label: f".{ext}"}, ) selectRecipeWin = widgets.QDialogListbox( - 'Select recipe', - 'Select recipe to load:\n', + "Select recipe", + "Select recipe to load:\n", availableRecipes, multiSelection=False, allowEmptySelection=False, parent=self, - additionalButtons=(browseButton,) + additionalButtons=(browseButton,), ) browseButton.sigPathSelected.connect( partial( self.recipeIniFileSelected, selectRecipeWin=selectRecipeWin, - sender=browseButton + sender=browseButton, ) ) selectRecipeWin.exec_() @@ -16713,256 +16690,245 @@ def selectRecipeFilepath(self, recipes_path, recipe_prefix, ext_label, ext): if selectRecipeWin.clickedButton == browseButton: return selectRecipeWin.selectedIniFilepath - + selected_endname = selectRecipeWin.selectedItemsText[0] - filename = f'{recipe_prefix}_{selected_endname}' + filename = f"{recipe_prefix}_{selected_endname}" return os.path.join(recipes_path, filename) - + def selectAndLoadRecipe(self): filepath = self.selectRecipeFilepath( - preproc_recipes_path, 'preprocessing_recipe', 'INI', 'ini' + preproc_recipes_path, "preprocessing_recipe", "INI", "ini" ) if filepath is None: return cp = config.ConfigParser() cp.read(filepath) preprocConfigPars = { - s: cp[s] for s in cp.sections() - if s.startswith('acdc.preprocess') + s: cp[s] for s in cp.sections() if s.startswith("acdc.preprocess") } if not preprocConfigPars: return self.loadRecipe(preprocConfigPars) - - def recipeIniFileSelected( - self, ini_filepath, selectRecipeWin=None, sender=None - ): + + def recipeIniFileSelected(self, ini_filepath, selectRecipeWin=None, sender=None): selectRecipeWin.clickedButton = sender selectRecipeWin.selectedIniFilepath = ini_filepath selectRecipeWin.cancel = False selectRecipeWin.close() def communicateSavingRecipeFinished(self, ini_filepath): - text = html_utils.paragraph( - 'Done!

' - 'Pre-processing recipe saved to:' - ) + text = html_utils.paragraph("Done!

Pre-processing recipe saved to:") msg = widgets.myMessageBox(wrapText=False) msg.information( - self, 'Pre-processing recipe saved!', text, + self, + "Pre-processing recipe saved!", + text, commands=(ini_filepath,), - path_to_browse=os.path.dirname(ini_filepath) + path_to_browse=os.path.dirname(ini_filepath), ) - + def addStep(self, is_first=False): stepWidgets = {} - + self.row += 1 - - step_n = len(self.stepsWidgets)+1 - label = QLabel(f'Step {step_n}: ') + + step_n = len(self.stepsWidgets) + 1 + label = QLabel(f"Step {step_n}: ") self.gridLayout.addWidget(label, self.row, 0) - stepWidgets['stepLabel'] = label - + stepWidgets["stepLabel"] = label + selector = widgets.PreProcessingSelector() self.gridLayout.addWidget(selector, self.row, 1) - stepWidgets['selector'] = selector - + stepWidgets["selector"] = selector + setParamsButton = widgets.setPushButton() - setParamsButton.setToolTip( - 'Set step parameters' - ) + setParamsButton.setToolTip("Set step parameters") self.gridLayout.addWidget(setParamsButton, self.row, 2) - setParamsButton.clicked.connect( - partial(self.setParamsStep, selector=selector) - ) - stepWidgets['setParamsButton'] = setParamsButton - + setParamsButton.clicked.connect(partial(self.setParamsStep, selector=selector)) + stepWidgets["setParamsButton"] = setParamsButton + infoButton = widgets.infoPushButton() self.gridLayout.addWidget(infoButton, self.row, 3) infoButton.clicked.connect(partial(self.showInfo, selector=selector)) - stepWidgets['infoButton'] = infoButton - + stepWidgets["infoButton"] = infoButton + if is_first: addButton = widgets.addPushButton() self.gridLayout.addWidget(addButton, self.row, 4) addButton.clicked.connect(self.addStep) - stepWidgets['addButton'] = addButton + stepWidgets["addButton"] = addButton else: delButton = widgets.delPushButton() self.gridLayout.addWidget(delButton, self.row, 4) delButton.clicked.connect(self.removeStep) delButton.step_n = step_n - stepWidgets['delButton'] = delButton - + stepWidgets["delButton"] = delButton + self.row += 1 selector.row = self.row selector.step_n = step_n hline = widgets.QHLine() self.gridLayout.addWidget(hline, self.row, 0, 1, 6) - stepWidgets['hline'] = hline + stepWidgets["hline"] = hline self.row += 1 - + self.stepsWidgets[step_n] = stepWidgets - + selector.sigValuesChanged.connect(self.emitValuesChanged) selector.currentTextChanged.connect( partial(self.clearInitKwargs, step_n=step_n) ) - + self.resetStretch() - + def emitValuesChanged(self, functionKwargs, step_n): - self.stepsWidgets[step_n]['step_kwargs'] = functionKwargs - + self.stepsWidgets[step_n]["step_kwargs"] = functionKwargs + recipe = self.recipe(warn=False) if recipe is None: return - + self.sigValuesChanged.emit(recipe) - + def clearInitKwargs(self, selected_method, step_n=0): stepWidgets = self.stepsWidgets[step_n] - stepWidgets.pop('step_kwargs', None) - + stepWidgets.pop("step_kwargs", None) + def resetStretch(self): for row in range(self.gridLayout.rowCount()): self.gridLayout.setRowStretch(row, 0) self.gridLayout.setRowStretch(self.gridLayout.rowCount(), 1) self.row = self.gridLayout.rowCount() - 1 - + def showInfo(self, checked=False, selector=None): if selector is None: return - + htmlText = selector.htmlInfo() htmlText = html_utils.paragraph(htmlText) - + method = selector.currentText() msg = widgets.myMessageBox(wrapText=False) - msg.information(self, f'Info about `{method}`', htmlText) - + msg.information(self, f"Info about `{method}`", htmlText) + def setParamsStep( - self, checked=False, - selector: 'widgets.PreProcessingSelector'=None - ): + self, checked=False, selector: "widgets.PreProcessingSelector" = None + ): step_n = selector.step_n stepFunctionKwargs = selector.askSetParams( - df_metadata=self.df_metadata, - addApplyButton=self.addApplyButton + df_metadata=self.df_metadata, addApplyButton=self.addApplyButton ) if stepFunctionKwargs is None: return - - self.stepsWidgets[step_n]['step_kwargs'] = stepFunctionKwargs - - def removeStep(self, checked=False, step_n=None): + + self.stepsWidgets[step_n]["step_kwargs"] = stepFunctionKwargs + + def removeStep(self, checked=False, step_n=None): if step_n is None: step_n = self.sender().step_n - + stepWidgets = self.stepsWidgets[step_n] - - stepWidgets['stepLabel'].hide() - self.gridLayout.removeWidget(stepWidgets['stepLabel']) - - stepWidgets['selector'].hide() - self.gridLayout.removeWidget(stepWidgets['selector']) - - stepWidgets['infoButton'].hide() - self.gridLayout.removeWidget(stepWidgets['infoButton']) - + + stepWidgets["stepLabel"].hide() + self.gridLayout.removeWidget(stepWidgets["stepLabel"]) + + stepWidgets["selector"].hide() + self.gridLayout.removeWidget(stepWidgets["selector"]) + + stepWidgets["infoButton"].hide() + self.gridLayout.removeWidget(stepWidgets["infoButton"]) + # stepWidgets['addButton'].hide() # self.gridLayout.removeWidget(stepWidgets['addButton']) - - stepWidgets['setParamsButton'].hide() - self.gridLayout.removeWidget(stepWidgets['setParamsButton']) - - stepWidgets['delButton'].hide() - self.gridLayout.removeWidget(stepWidgets['delButton']) + + stepWidgets["setParamsButton"].hide() + self.gridLayout.removeWidget(stepWidgets["setParamsButton"]) + + stepWidgets["delButton"].hide() + self.gridLayout.removeWidget(stepWidgets["delButton"]) self.row -= 1 - - stepWidgets['hline'].hide() - self.gridLayout.removeWidget(stepWidgets['hline']) + + stepWidgets["hline"].hide() + self.gridLayout.removeWidget(stepWidgets["hline"]) self.row -= 1 self.stepsWidgets.pop(step_n) - + stepsWidgetsMapper = {1: self.stepsWidgets[1]} for i, stepWidgets in enumerate(self.stepsWidgets.values()): if i == 0: continue step_n = i + 1 - label = stepWidgets['stepLabel'] - label.setText(f'Step {step_n}: ') - stepWidgets['delButton'].step_n = step_n - stepWidgets['selector'].step_n = step_n + label = stepWidgets["stepLabel"] + label.setText(f"Step {step_n}: ") + stepWidgets["delButton"].step_n = step_n + stepWidgets["selector"].step_n = step_n stepsWidgetsMapper[step_n] = stepWidgets - + self.stepsWidgets = stepsWidgetsMapper - + self.resetStretch() def isChecked(self): return self.groupbox.isChecked() - + def warnStepNotInit(self, method): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - f'The parameters for the preprocessing step {method} ' - 'were not initialized.

' - 'Please, click on the corresponding Set step parameters ' - 'button to initialize this step (cog icon).

' - 'Thank you for your patience!' + f"The parameters for the preprocessing step {method} " + "were not initialized.

" + "Please, click on the corresponding Set step parameters " + "button to initialize this step (cog icon).

" + "Thank you for your patience!" ) - msg.warning(self, 'Params not initialized!', txt) + msg.warning(self, "Params not initialized!", txt) def recipe(self, warn=True): recipe = [] if not self.groupbox.isChecked() and self.groupbox.isCheckable(): return recipe - + for stepWidgets in self.stepsWidgets.values(): - method = stepWidgets['selector'].currentText() - step_kwargs = stepWidgets.get('step_kwargs') + method = stepWidgets["selector"].currentText() + step_kwargs = stepWidgets.get("step_kwargs") if step_kwargs is None: if warn: self.warnStepNotInit(method) return try: - init_func = config.PREPROCESS_INIT_MAPPER[method]['function'] + init_func = config.PREPROCESS_INIT_MAPPER[method]["function"] init_func(**step_kwargs) except Exception as err: pass - - recipe.append({ - 'method': method, 'kwargs': step_kwargs - }) - + + recipe.append({"method": method, "kwargs": step_kwargs}) + return recipe - + def recipeConfigPars(self, model_name): cp = config.ConfigParser() if not self.groupbox.isChecked() and self.groupbox.isCheckable(): return cp - + for s, step in enumerate(self.recipe()): - section = f'{model_name}.preprocess.step{s+1}' + section = f"{model_name}.preprocess.step{s + 1}" cp[section] = {} - cp[section]['method'] = step['method'] - for option, value in step['kwargs'].items(): + cp[section]["method"] = step["method"] + for option, value in step["kwargs"].items(): cp[section][option] = str(value) return cp + # class QComboBoxChangeColor(QComboBox): # def __init__(self, forbidden_items=None, parent=None): # super().__init__(parent) # self.forbiddenItems = forbidden_items or set() # self._defaultStyleSheet = self.styleSheet() # self.currentTextChanged.connect(self._updateColor) - + # def _updateColor(self, text=None): # if not hasattr(self, '_defaultStyleSheet'): # self._defaultStyleSheet = self.styleSheet() @@ -16983,12 +16949,11 @@ def recipeConfigPars(self, model_name): # else: # self.setStyleSheet(self._defaultStyleSheet) - - + class CombineChannelsWidget(PreProcessParamsWidget): sigValuesChangedCombineChannels = Signal() - - def __init__(self, channel_names:Iterable[str], parent=None): + + def __init__(self, channel_names: Iterable[str], parent=None): self.channel_names = channel_names super().__init__(parent) @@ -17000,207 +16965,179 @@ def __init__(self, channel_names:Iterable[str], parent=None): def addStep(self, is_first=False): stepWidgets = {} - + self.row += 1 if is_first: self.row += 1 - - step_n = len(self.stepsWidgets)+1 - tooltip = ( - 'Use this text in the formula' - ) + + step_n = len(self.stepsWidgets) + 1 + tooltip = "Use this text in the formula" if is_first: - label = QLabel('Formula var') - label.setToolTip( - tooltip - ) - self.gridLayout.addWidget(label, self.row-1, 1) - name_edit = QLineEdit(text=f'img{step_n}') - name_edit.setToolTip( - tooltip - ) + label = QLabel("Formula var") + label.setToolTip(tooltip) + self.gridLayout.addWidget(label, self.row - 1, 1) + name_edit = QLineEdit(text=f"img{step_n}") + name_edit.setToolTip(tooltip) self.gridLayout.addWidget(name_edit, self.row, 1) - stepWidgets['name_edit'] = name_edit + stepWidgets["name_edit"] = name_edit name_edit.textChanged.connect(self.emitValuesChanged) - tooltip = ( - 'Select a channel or a segmentation mask' - ) + tooltip = "Select a channel or a segmentation mask" if is_first: - label = QLabel('Channel') - label.setToolTip( - tooltip - ) - self.gridLayout.addWidget(label, self.row-1, 2) + label = QLabel("Channel") + label.setToolTip(tooltip) + self.gridLayout.addWidget(label, self.row - 1, 2) ch_selector = QComboBox() - ch_selector.setToolTip( - tooltip - ) + ch_selector.setToolTip(tooltip) ch_selector.addItems(self.channel_names) self.gridLayout.addWidget(ch_selector, self.row, 2) - stepWidgets['selector'] = ch_selector + stepWidgets["selector"] = ch_selector ch_selector.currentTextChanged.connect(self.setBinarizeCheckableAndNorm) # add binarisaion spinbox tooltip = ( - 'If binarize is selected, the channel will be binarized first, before applying offset and multiplier.\n' - 'If inverse binarize is selected, the channel will be binerized and ' - 'then the logical NOT will be applied.' + "If binarize is selected, the channel will be binarized first, before applying offset and multiplier.\n" + "If inverse binarize is selected, the channel will be binerized and " + "then the logical NOT will be applied." ) if is_first: - label = QLabel('Binarize') - label.setToolTip( - tooltip - ) - self.gridLayout.addWidget(label, self.row-1, 5) - options = ['No', 'binarize', 'inverse binarize'] + label = QLabel("Binarize") + label.setToolTip(tooltip) + self.gridLayout.addWidget(label, self.row - 1, 5) + options = ["No", "binarize", "inverse binarize"] self.binarizeCombobox = QComboBox() self.binarizeCombobox.addItems(options) self.binarizeCombobox.setCurrentIndex(0) self.binarizeCombobox.setEnabled(False) - self.binarizeCombobox.setToolTip( - tooltip - ) + self.binarizeCombobox.setToolTip(tooltip) self.binarizeCombobox.currentIndexChanged.connect(self.emitValuesChanged) self.gridLayout.addWidget(self.binarizeCombobox, self.row, 5) - stepWidgets['binarize'] = self.binarizeCombobox - - tooltip = ( - 'Min value of the channel to be normalized to.' - ) + stepWidgets["binarize"] = self.binarizeCombobox + + tooltip = "Min value of the channel to be normalized to." if is_first: - label = QLabel('Min val') - label.setToolTip( - tooltip - ) - self.gridLayout.addWidget(label, self.row-1, 6) + label = QLabel("Min val") + label.setToolTip(tooltip) + self.gridLayout.addWidget(label, self.row - 1, 6) self.minValueSpinbox = QDoubleSpinBox() self.minValueSpinbox.setRange(-np.inf, np.inf) self.minValueSpinbox.setSingleStep(0.1) self.minValueSpinbox.setValue(0) - self.minValueSpinbox.setToolTip( - tooltip - ) - + self.minValueSpinbox.setToolTip(tooltip) + self.minValueSpinbox.valueChanged.connect(self.emitValuesChanged) self.gridLayout.addWidget(self.minValueSpinbox, self.row, 6) - stepWidgets['minValueSpinbox'] = self.minValueSpinbox - - tooltip = ( - 'Max value of the channel to be normalized to.' - ) + stepWidgets["minValueSpinbox"] = self.minValueSpinbox + + tooltip = "Max value of the channel to be normalized to." if is_first: - label = QLabel('Max val') - label.setToolTip( - tooltip - ) - self.gridLayout.addWidget(label, self.row-1, 7) + label = QLabel("Max val") + label.setToolTip(tooltip) + self.gridLayout.addWidget(label, self.row - 1, 7) self.maxValueSpinbox = QDoubleSpinBox() self.maxValueSpinbox.setRange(-np.inf, np.inf) self.maxValueSpinbox.setSingleStep(0.1) self.maxValueSpinbox.setValue(1) - self.maxValueSpinbox.setToolTip( - tooltip - ) - + self.maxValueSpinbox.setToolTip(tooltip) + self.maxValueSpinbox.valueChanged.connect(self.emitValuesChanged) self.gridLayout.addWidget(self.maxValueSpinbox, self.row, 7) - stepWidgets['maxValueSpinbox'] = self.maxValueSpinbox + stepWidgets["maxValueSpinbox"] = self.maxValueSpinbox if is_first: addButton = widgets.addPushButton() self.gridLayout.addWidget(addButton, self.row, 8) addButton.clicked.connect(self.addStep) - stepWidgets['addButton'] = addButton - + stepWidgets["addButton"] = addButton + else: delButton = widgets.delPushButton() self.gridLayout.addWidget(delButton, self.row, 8) delButton.clicked.connect(self.removeStep) delButton.step_n = step_n - stepWidgets['delButton'] = delButton - + stepWidgets["delButton"] = delButton + self.row += 1 ch_selector.row = self.row ch_selector.step_n = step_n hline = widgets.QHLine() self.gridLayout.addWidget(hline, self.row, 0, 1, 8) - stepWidgets['hline'] = hline + stepWidgets["hline"] = hline self.row += 1 - + self.stepsWidgets[step_n] = stepWidgets - + self.resetStretch() self.sigValuesChangedCombineChannels.emit() self.setBinarizeCheckableAndNorm() - + def emitValuesChanged(self, *args): self.sigValuesChangedCombineChannels.emit() - + def setBinarizeCheckableAndNorm(self): for step_n, stepWidgets in self.stepsWidgets.items(): - binarizeSelector = stepWidgets['binarize'] - channel = stepWidgets['selector'].currentText() + binarizeSelector = stepWidgets["binarize"] + channel = stepWidgets["selector"].currentText() if "segm" in channel: binarizeSelector.setEnabled(True) # set min and max to 0 and 1 and disable - stepWidgets['minValueSpinbox'].setValue(0) - stepWidgets['maxValueSpinbox'].setValue(1) - stepWidgets['minValueSpinbox'].setEnabled(False) - stepWidgets['maxValueSpinbox'].setEnabled(False) + stepWidgets["minValueSpinbox"].setValue(0) + stepWidgets["maxValueSpinbox"].setValue(1) + stepWidgets["minValueSpinbox"].setEnabled(False) + stepWidgets["maxValueSpinbox"].setEnabled(False) else: binarizeSelector.setEnabled(False) binarizeSelector.setCurrentIndex(0) # set min and max to 0 and 1 and enable - stepWidgets['minValueSpinbox'].setEnabled(True) - stepWidgets['maxValueSpinbox'].setEnabled(True) - + stepWidgets["minValueSpinbox"].setEnabled(True) + stepWidgets["maxValueSpinbox"].setEnabled(True) + self.emitValuesChanged() - def removeStep(self, checked=False, step_n=None): + def removeStep(self, checked=False, step_n=None): if step_n is None: step_n = self.sender().step_n - + stepWidgets = self.stepsWidgets[step_n] - - stepWidgets['name_edit'].hide() - self.gridLayout.removeWidget(stepWidgets['name_edit']) - - stepWidgets['selector'].hide() - self.gridLayout.removeWidget(stepWidgets['selector']) - - stepWidgets['binarize'].hide() - self.gridLayout.removeWidget(stepWidgets['binarize']) - - stepWidgets['minValueSpinbox'].hide() - self.gridLayout.removeWidget(stepWidgets['minValueSpinbox']) - - stepWidgets['maxValueSpinbox'].hide() - self.gridLayout.removeWidget(stepWidgets['maxValueSpinbox']) - - stepWidgets['delButton'].hide() - self.gridLayout.removeWidget(stepWidgets['delButton']) - + + stepWidgets["name_edit"].hide() + self.gridLayout.removeWidget(stepWidgets["name_edit"]) + + stepWidgets["selector"].hide() + self.gridLayout.removeWidget(stepWidgets["selector"]) + + stepWidgets["binarize"].hide() + self.gridLayout.removeWidget(stepWidgets["binarize"]) + + stepWidgets["minValueSpinbox"].hide() + self.gridLayout.removeWidget(stepWidgets["minValueSpinbox"]) + + stepWidgets["maxValueSpinbox"].hide() + self.gridLayout.removeWidget(stepWidgets["maxValueSpinbox"]) + + stepWidgets["delButton"].hide() + self.gridLayout.removeWidget(stepWidgets["delButton"]) + self.row -= 1 - - stepWidgets['hline'].hide() - self.gridLayout.removeWidget(stepWidgets['hline']) + + stepWidgets["hline"].hide() + self.gridLayout.removeWidget(stepWidgets["hline"]) self.row -= 1 self.stepsWidgets.pop(step_n) - + stepsWidgetsMapper = {1: self.stepsWidgets[1]} for i, stepWidgets in enumerate(self.stepsWidgets.values()): if i == 0: continue step_n = i + 1 - stepWidgets['delButton'].step_n = step_n - stepWidgets['selector'].step_n = step_n + stepWidgets["delButton"].step_n = step_n + stepWidgets["selector"].step_n = step_n stepsWidgetsMapper[step_n] = stepWidgets - + self.stepsWidgets = stepsWidgetsMapper - + self.resetStretch() self.sigValuesChangedCombineChannels.emit() @@ -17208,24 +17145,25 @@ def steps(self): steps = {} if not self.groupbox.isChecked() and self.groupbox.isCheckable(): return steps - + for step_number, stepWidgets in self.stepsWidgets.items(): - name = stepWidgets['name_edit'].text() - channel = stepWidgets['selector'].currentText() - binarize = stepWidgets['binarize'].currentText() - min_val = stepWidgets['minValueSpinbox'].value() - max_val = stepWidgets['maxValueSpinbox'].value() + name = stepWidgets["name_edit"].text() + channel = stepWidgets["selector"].currentText() + binarize = stepWidgets["binarize"].currentText() + min_val = stepWidgets["minValueSpinbox"].value() + max_val = stepWidgets["maxValueSpinbox"].value() steps[step_number] = { - 'name': name, - 'channel': channel, - 'binarize': binarize, - 'min_val': min_val, - 'max_val': max_val, + "name": name, + "channel": channel, + "binarize": binarize, + "min_val": min_val, + "max_val": max_val, } steps = dict(sorted(steps.items())) return steps - + + class FormulaEditWidget(QWidget): sigFormulaChanged = Signal(str, bool) # formula_str, is_valid @@ -17238,17 +17176,17 @@ def __init__(self, variable_names=None, parent=None): layout.setSpacing(4) self._edit = QLineEdit() - self._edit.setPlaceholderText('e.g. img1 + img2 * 0.5') + self._edit.setPlaceholderText("e.g. img1 + img2 * 0.5") layout.addWidget(self._edit) self._status_label = QLabel() self._status_label.setWordWrap(True) - self._status_label.setStyleSheet('font-size: 11px;') + self._status_label.setStyleSheet("font-size: 11px;") layout.addWidget(self._status_label) self._edit.textChanged.connect(self._onTextChanged) self._clearStatus() - + self.parent = parent def setVariableNames(self, variable_names): @@ -17259,7 +17197,7 @@ def setVariableNames(self, variable_names): variable_names : list list of variable names (strings) """ - + self._variable_names = variable_names self._onTextChanged(self._edit.text()) @@ -17272,8 +17210,8 @@ def setText(self, text): self._edit.setText(text) def _clearStatus(self): - self._status_label.setText('') - self._status_label.setStyleSheet('font-size: 11px;') + self._status_label.setText("") + self._status_label.setStyleSheet("font-size: 11px;") def _onTextChanged(self, text): if not text.strip(): @@ -17282,15 +17220,11 @@ def _onTextChanged(self, text): success, reconstructed_str = self.checkValidity(self._variable_names) if success: - self._status_label.setText(f'→ {reconstructed_str}') - self._status_label.setStyleSheet( - 'font-size: 11px; color: green;' - ) + self._status_label.setText(f"→ {reconstructed_str}") + self._status_label.setStyleSheet("font-size: 11px; color: green;") else: self._status_label.setText(reconstructed_str) - self._status_label.setStyleSheet( - 'font-size: 11px; color: red;' - ) + self._status_label.setStyleSheet("font-size: 11px; color: red;") self.sigFormulaChanged.emit(text, success) @@ -17300,75 +17234,83 @@ def checkValidity(self, variable_names=None): formula_str = self._edit.text() arrays = {name: 1 for name in variable_names} success = False - reconstructed_str = 'ERROR' + reconstructed_str = "ERROR" forb_ch = self.parent.forbiddenChannels if forb_ch: stepsWidgets = self.parent.combineChannelsWidget.stepsWidgets - channels = {stepsWidget['selector'].currentText() for stepsWidget in stepsWidgets.values()} + channels = { + stepsWidget["selector"].currentText() + for stepsWidget in stepsWidgets.values() + } if forb_ch.intersection(channels): reconstructed_str = ( - 'Channels that are forbidden are not allowed to be used!:\n' - f'{forb_ch}' + "Channels that are forbidden are not allowed to be used!:\n" + f"{forb_ch}" ) return False, reconstructed_str - if formula_str == '': - reconstructed_str = 'First channel is returned/applied' + if formula_str == "": + reconstructed_str = "First channel is returned/applied" return True, reconstructed_str try: symbols = {name: sp.Symbol(name) for name in arrays} expr = sp.sympify(formula_str, locals=symbols) missing = {str(s) for s in expr.free_symbols} - arrays.keys() if missing: - reconstructed_str = f'Missing variables: {missing}' + reconstructed_str = f"Missing variables: {missing}" return False, reconstructed_str - if formula_str == '': - reconstructed_str = '' + if formula_str == "": + reconstructed_str = "" return True, reconstructed_str - + # filter out expressions that have no variables if not any(s.is_Symbol for s in expr.free_symbols): - reconstructed_str = 'No variables used' + reconstructed_str = "No variables used" return False, reconstructed_str - + reconstructed_str = str(expr) success = True except Exception as e: - if 'syntax' in str(e): - reconstructed_str = f'Syntax error' + if "syntax" in str(e): + reconstructed_str = f"Syntax error" else: reconstructed_str = str(e) success = False return success, reconstructed_str + class InitFijiMacroDialog(QBaseDialog): def __init__(self, parent=None): self.cancel = True - + super().__init__(parent=parent) - + mainLayout = QVBoxLayout() - - infoLabel = QLabel(html_utils.paragraph( - """ + + infoLabel = QLabel( + html_utils.paragraph( + """ Place all the raw microscopy files in a folder without any other file
and provide the following information: """ - )) + ) + ) mainLayout.addWidget(infoLabel) - - gridLayout = QGridLayout() - + + gridLayout = QGridLayout() + row = 0 - label = QLabel('Files internal structure: ') + label = QLabel("Files internal structure: ") gridLayout.addWidget(label, row, 0) self.filesStructureCombobox = QComboBox() - self.filesStructureCombobox.addItems([ - 'Positions (aka "series") embedded in the file', - 'Positions (aka "series") separated, one for each file', - 'Positions (aka "series") and channels separated, one for each file' - ]) + self.filesStructureCombobox.addItems( + [ + 'Positions (aka "series") embedded in the file', + 'Positions (aka "series") separated, one for each file', + 'Positions (aka "series") and channels separated, one for each file', + ] + ) gridLayout.addWidget(self.filesStructureCombobox, row, 1) self.filesStructureCombobox.currentTextChanged.connect( self.fileStructureChanged @@ -17376,9 +17318,9 @@ def __init__(self, parent=None): infoButton = widgets.infoPushButton() gridLayout.addWidget(infoButton, row, 2) infoButton.clicked.connect(self.showInfoFileStructure) - + row += 1 - label = QLabel('Folder with raw microscopy files: ') + label = QLabel("Folder with raw microscopy files: ") gridLayout.addWidget(label, row, 0) self.folderPathLineEdit = widgets.ElidingLineEdit() gridLayout.addWidget(self.folderPathLineEdit, row, 1) @@ -17388,24 +17330,22 @@ def __init__(self, parent=None): partial(self.updateFolderPath, lineEdit=self.folderPathLineEdit) ) self.folderPathLineEdit.textChanged.connect(self.srcFolderPathChanged) - + row += 1 - label = QLabel('Destination folder: ') + label = QLabel("Destination folder: ") gridLayout.addWidget(label, row, 0) self.dstfolderPathLineEdit = widgets.ElidingLineEdit() gridLayout.addWidget(self.dstfolderPathLineEdit, row, 1) browseButton = widgets.browseFileButton(openFolder=True) gridLayout.addWidget(browseButton, row, 2) browseButton.sigPathSelected.connect(self.dstfolderPathLineEdit.setText) - + row += 1 - label = QLabel('Channel(s) name: ') + label = QLabel("Channel(s) name: ") gridLayout.addWidget(label, row, 0) - self.channelNamesLineEdit = widgets.alphaNumericLineEdit( - additionalChars=' ,' - ) + self.channelNamesLineEdit = widgets.alphaNumericLineEdit(additionalChars=" ,") gridLayout.addWidget(self.channelNamesLineEdit, row, 1) - checkButton = widgets.TestPushButton('Check') + checkButton = widgets.TestPushButton("Check") gridLayout.addWidget(checkButton, row, 3) checkButton.clicked.connect(self.checkChannelNames) checkButton.setDisabled(True) @@ -17413,12 +17353,12 @@ def __init__(self, parent=None): infoButton = widgets.infoPushButton() gridLayout.addWidget(infoButton, row, 2) infoButton.clicked.connect(self.showInfoChannelName) - + buttonsLayout = widgets.CancelOkButtonsLayout() - + buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + gridLayout.setColumnStretch(0, 0) gridLayout.setColumnStretch(1, 1) gridLayout.setColumnStretch(2, 0) @@ -17427,45 +17367,42 @@ def __init__(self, parent=None): mainLayout.addLayout(gridLayout) mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) - + self.setLayout(mainLayout) - + def fileStructureChanged(self, text): - self.checkButton.setDisabled(not 'channels separated' in text) - + self.checkButton.setDisabled(not "channels separated" in text) + def checkChannelNames(self, checked=False): proceed = self.validate() if not proceed: return - + src_folderpath = self.folderPath() channel_names = self.channelNames() - extension = os.listdir(src_folderpath)[0].split('.')[-1] + extension = os.listdir(src_folderpath)[0].split(".")[-1] basenames = io.move_separate_channels_tiffs_to_pos_folders( - src_folderpath, channel_names, get_only_basenames=True, - extension=extension + src_folderpath, channel_names, get_only_basenames=True, extension=extension ) pos_folders_texts = [] for p, basename in enumerate(basenames): - pos_folders_texts.append(f'Position_{p+1}: {basename}') + pos_folders_texts.append(f"Position_{p + 1}: {basename}") - pos_folders_html_list = html_utils.to_list( - pos_folders_texts, ordered=True - ) + pos_folders_html_list = html_utils.to_list(pos_folders_texts, ordered=True) text = html_utils.paragraph( - 'The following Position folders will be created based on the provided channel names:
' - f'{pos_folders_html_list}' + "The following Position folders will be created based on the provided channel names:
" + f"{pos_folders_html_list}" ) msg = widgets.myMessageBox(wrapText=False) - msg.information(self, 'Position folders', text) + msg.information(self, "Position folders", text) def srcFolderPathChanged(self, text): if self.dstfolderPathLineEdit.text(): return - + folderPath = self.folderPathLineEdit.text() self.dstfolderPathLineEdit.setText(folderPath) - + def showInfoFileStructure(self): txt = html_utils.paragraph(""" Select whether the microscopy files contains multiple "series".

@@ -17475,8 +17412,8 @@ def showInfoFileStructure(self): positions. """) msg = widgets.myMessageBox(wrapText=False) - msg.information(self, 'Files structure info', txt) - + msg.information(self, "Files structure info", txt) + def showInfoChannelName(self): txt = html_utils.paragraph(""" Enter the channels name. Separate multiple channels with a comma.

@@ -17492,9 +17429,9 @@ def showInfoChannelName(self): The number of Positions that will be created will be displayed alongside the basename. """) msg = widgets.myMessageBox(wrapText=False) - msg.information(self, 'Files structure info', txt) - - def updateFolderPath(self, path, lineEdit=''): + msg.information(self, "Files structure info", txt) + + def updateFolderPath(self, path, lineEdit=""): for file in os.listdir(path): if not is_alphanumeric_filename(file): msg = widgets.myMessageBox(wrapText=False) @@ -17508,12 +17445,10 @@ def updateFolderPath(self, path, lineEdit=''): Thank you for your patience! """ ) - msg.critical( - self, 'Invalid filename', txt, path_to_browse=path - ) - lineEdit.setText('') + msg.critical(self, "Invalid filename", txt, path_to_browse=path) + lineEdit.setText("") return - + lineEdit.setText(path) def warnPathEmpty(self, path_name): @@ -17521,24 +17456,24 @@ def warnPathEmpty(self, path_name): {path_name} cannot be empty. """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Empty folder path', txt) - + msg.warning(self, "Empty folder path", txt) + def warnSelectedPathDoesNotExist(self, path): txt = html_utils.paragraph(""" The selected path does not exist.

Selected path: """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Folder path does not exist', txt, commands=(path,)) - + msg.warning(self, "Folder path does not exist", txt, commands=(path,)) + def warnSelectedPathNotAFolder(self, path): txt = html_utils.paragraph(""" The selected path is not a folder.

Selected path: """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Selected path not a folder', txt, commands=(path,)) - + msg.warning(self, "Selected path not a folder", txt, commands=(path,)) + def warnMultipleExtensionsPresent(self, path, extensions): txt = html_utils.paragraph(f""" The selected path contains files with different extensions. @@ -17549,37 +17484,35 @@ def warnMultipleExtensionsPresent(self, path, extensions): Selected path: """) msg = widgets.myMessageBox(wrapText=False) - msg.warning( - self, 'Multiple file extensions detected', txt, commands=(path,) - ) - + msg.warning(self, "Multiple file extensions detected", txt, commands=(path,)) + def warnChannelNamesEmpty(self): txt = html_utils.paragraph(""" Channel(s) name cannot be empty. """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Empty channel name', txt) - + msg.warning(self, "Empty channel name", txt) + def validate(self): path = self.folderPath() dst_path = self.dstfolderPathLineEdit.text() paths = { - 'Source folder': path, - 'Destination folder': dst_path, + "Source folder": path, + "Destination folder": dst_path, } for _path_name, _path in paths.items(): if not _path: self.warnPathEmpty(_path_name) return False - + if not os.path.exists(_path): self.warnSelectedPathDoesNotExist(_path) return False - + if not os.path.isdir(_path): self.warnSelectedPathNotAFolder(_path) return False - + files = myutils.listdir(path) extensions = set([os.path.splitext(file)[1] for file in files]) if len(extensions) > 1: @@ -17589,115 +17522,117 @@ def validate(self): if not self.channelNamesLineEdit.text(): self.warnChannelNamesEmpty() return False - + return True - + def folderPath(self): return self.folderPathLineEdit.text() - + def channelNames(self): - channel_names = self.channelNamesLineEdit.text().split(',') + channel_names = self.channelNamesLineEdit.text().split(",") channel_names = [ch.strip() for ch in channel_names] return channel_names - + def ok_cb(self): proceed = self.validate() if not proceed: return - + self.selectedFolderPath = self.folderPath() self.filesStructure = self.filesStructureCombobox.currentText() - is_multiple_files = self.filesStructure.find('separated') != -1 - is_separate_channels = 'channels separated' in self.filesStructure + is_multiple_files = self.filesStructure.find("separated") != -1 + is_separate_channels = "channels separated" in self.filesStructure dst_folderpath = self.dstfolderPathLineEdit.text() self.init_macro_args = ( - self.folderPath(), - is_multiple_files, + self.folderPath(), + is_multiple_files, is_separate_channels, dst_folderpath, self.channelNames(), ) self.cancel = False self.close() - + + class ImageJRoisToSegmManager(QBaseDialog): def __init__( - self, rois_filepath, TZYX_shape, - addUseSamePropsForNextPosButton=False, parent=None - ): + self, + rois_filepath, + TZYX_shape, + addUseSamePropsForNextPosButton=False, + parent=None, + ): import roifile - + self.cancel = True super().__init__(parent) - - self.setWindowTitle('ROI Manager') - + + self.setWindowTitle("ROI Manager") + mainLayout = QVBoxLayout() - + rois = roifile.roiread(rois_filepath) self.rois = {roi.name: roi for roi in rois} - + roisNamesTreeWidget = widgets.TreeWidget() - roisNamesTreeWidget.setHeaderLabels(['ROI name', 'Cell_ID']) - roisNamesTreeWidget.header().setSectionResizeMode( - QHeaderView.ResizeToContents - ) + roisNamesTreeWidget.setHeaderLabels(["ROI name", "Cell_ID"]) + roisNamesTreeWidget.header().setSectionResizeMode(QHeaderView.ResizeToContents) # roisNamesTreeWidget.header().setStretchLastSection(False) for r, roi in enumerate(rois): item = widgets.TreeWidgetItem() item.setText(0, roi.name) - item.setText(1, str(r+1)) + item.setText(1, str(r + 1)) roisNamesTreeWidget.addTopLevelItem(item) roisNamesTreeWidget.setSelectionMode( QAbstractItemView.SelectionMode.ExtendedSelection ) roisNamesTreeWidget.selectAll() - mainLayout.addWidget(QLabel('Select ROIs to convert')) + mainLayout.addWidget(QLabel("Select ROIs to convert")) mainLayout.addWidget(roisNamesTreeWidget) self.roisNamesTreeWidget = roisNamesTreeWidget mainLayout.addSpacing(10) mainLayout.addWidget(widgets.QHLine()) mainLayout.addSpacing(5) - + gridLayout = None self.lowZspinbox = None - + SizeT, SizeZ, SizeY, SizeX = TZYX_shape if SizeZ > 1: gridLayout = QGridLayout() self.lowZspinbox = widgets.SpinBox() self.lowZspinbox.setMinimum(0) - self.lowZspinbox.setMaximum(SizeZ-1) - + self.lowZspinbox.setMaximum(SizeZ - 1) + self.highZspinbox = widgets.SpinBox() self.highZspinbox.setMinimum(0) - self.highZspinbox.setMaximum(SizeZ-1) - self.highZspinbox.setValue(SizeZ-1) - - gridLayout.addWidget(QLabel('Repeat 2D ROIs over z-range: '), 1, 0) - - gridLayout.addWidget(QLabel('Start z-slice'), 0, 1) + self.highZspinbox.setMaximum(SizeZ - 1) + self.highZspinbox.setValue(SizeZ - 1) + + gridLayout.addWidget(QLabel("Repeat 2D ROIs over z-range: "), 1, 0) + + gridLayout.addWidget(QLabel("Start z-slice"), 0, 1) gridLayout.addWidget(self.lowZspinbox, 1, 1) - - gridLayout.addWidget(QLabel('Stop z-slice'), 0, 2) + + gridLayout.addWidget(QLabel("Stop z-slice"), 0, 2) gridLayout.addWidget(self.highZspinbox, 1, 2) - + if gridLayout is not None: mainLayout.addLayout(gridLayout) mainLayout.addSpacing(5) mainLayout.addWidget(widgets.QHLine()) mainLayout.addSpacing(10) - + self.rescaleRoisGroupbox = widgets.RescaleImageJroisGroupbox(TZYX_shape) self.rescaleRoisGroupbox.setChecked(False) mainLayout.addWidget(self.rescaleRoisGroupbox) - + buttonsLayout = widgets.CancelOkButtonsLayout() - + self.useSamePropsForNextPos = False if addUseSamePropsForNextPosButton: useSamePropsForNextPosButton = widgets.reloadPushButton( - 'Keep the same preferences for all next Positions' + "Keep the same preferences for all next Positions" ) buttonsLayout.insertWidget(3, useSamePropsForNextPosButton) useSamePropsForNextPosButton.clicked.connect( @@ -17706,95 +17641,95 @@ def __init__( buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) - + self.setLayout(mainLayout) - + def useSamePropsForNextPosClicked(self): self.useSamePropsForNextPos = True self.ok_cb() - + def warnRoiSelectionEmpty(self): txt = html_utils.paragraph(f""" You did not select any ROI.

ROIs selection cannot be empty. Thank you for your patience! """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'ROIs selection empty', txt) - + msg.warning(self, "ROIs selection empty", txt) + def ok_cb(self): selectedRois = self.roisNamesTreeWidget.selectedItems() if not selectedRois: self.useSamePropsForNextPos = False self.warnRoiSelectionEmpty() return - + self.IDsToRoisMapper = {} for item in selectedRois: roiName = item.text(0) ID = int(item.text(1)) self.IDsToRoisMapper[ID] = self.rois[roiName] - + numRois = self.roisNamesTreeWidget.topLevelItemCount() self.areAllRoisSelected = len(self.IDsToRoisMapper) == numRois - + self.rescaleSizes = self.rescaleRoisGroupbox.inputOutputSizes() self.repeatRoisZslicesRange = None if self.lowZspinbox is not None: self.repeatRoisZslicesRange = ( - self.lowZspinbox.value(), self.highZspinbox.value()+1 + self.lowZspinbox.value(), + self.highZspinbox.value() + 1, ) - + self.cancel = False self.close() + class ResizeUtilProps(QBaseDialog): - def __init__(self, input_path='', parent=None): + def __init__(self, input_path="", parent=None): self.cancel = True super().__init__(parent) - - self.setWindowTitle('Resize Data Properties') - + + self.setWindowTitle("Resize Data Properties") + mainLayout = QVBoxLayout() - + paramsLayout = QGridLayout() - + self._input_path = input_path - + row = 0 - paramsLayout.addWidget(QLabel('Overwrite raw data: '), row, 0) + paramsLayout.addWidget(QLabel("Overwrite raw data: "), row, 0) self.overwriteToggle = widgets.Toggle() self.overwriteToggle.setChecked(True) paramsLayout.addWidget( self.overwriteToggle, row, 1, 1, 2, alignment=Qt.AlignCenter ) - + row += 1 - paramsLayout.addWidget( - QLabel('Folder path for resized images: '), row, 0 - ) + paramsLayout.addWidget(QLabel("Folder path for resized images: "), row, 0) self.folderPathOutControl = widgets.filePathControl( - browseFolder=True, - fileManagerTitle='Select folder where to save resized data', - elide=True, - startFolder=myutils.getMostRecentPath() + browseFolder=True, + fileManagerTitle="Select folder where to save resized data", + elide=True, + startFolder=myutils.getMostRecentPath(), ) self.folderPathOutControl.setDisabled(True) paramsLayout.addWidget(self.folderPathOutControl, row, 1, 1, 2) - + row += 1 - paramsLayout.addWidget(QLabel('Text to append to files: '), row, 0) + paramsLayout.addWidget(QLabel("Text to append to files: "), row, 0) self.textToAppendLineEdit = widgets.alphaNumericLineEdit() self.textToAppendLineEdit.setAlignment(Qt.AlignCenter) self.textToAppendLineEdit.setDisabled(True) paramsLayout.addWidget(self.textToAppendLineEdit, row, 1, 1, 2) - + row += 1 - paramsLayout.addWidget(QLabel('Resize mode: '), row, 0) - self.downScaleRadioButton = QRadioButton('Downscale') - self.upScaleRadioButton = QRadioButton('Upscale') + paramsLayout.addWidget(QLabel("Resize mode: "), row, 0) + self.downScaleRadioButton = QRadioButton("Downscale") + self.upScaleRadioButton = QRadioButton("Upscale") self.downScaleRadioButton.setChecked(True) paramsLayout.addWidget( self.downScaleRadioButton, row, 1, alignment=Qt.AlignCenter @@ -17802,64 +17737,64 @@ def __init__(self, input_path='', parent=None): paramsLayout.addWidget( self.upScaleRadioButton, row, 2, alignment=Qt.AlignCenter ) - + row += 1 - paramsLayout.addWidget(QLabel('Resize factor: '), row, 0) + paramsLayout.addWidget(QLabel("Resize factor: "), row, 0) self.factorSpinbox = widgets.FloatLineEdit(allowNegative=False) self.factorSpinbox.setMinimum(1.0) self.factorSpinbox.setValue(2.0) paramsLayout.addWidget(self.factorSpinbox, row, 1, 1, 2) - + paramsLayout.setColumnStretch(0, 0) paramsLayout.setVerticalSpacing(10) - + self.overwriteToggle.toggled.connect(self.overwriteToggled) - + buttonsLayout = widgets.CancelOkButtonsLayout() - + buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addLayout(paramsLayout) mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) mainLayout.addStretch(1) - + # self.textToAppendLineEdit.setText(self._getDefaultTextToAppend()) - + self.setLayout(mainLayout) - + def _getDefaultTextToAppend(self): - rescale_mode = 'up' if self.upScaleRadioButton.isChecked() else 'down' + rescale_mode = "up" if self.upScaleRadioButton.isChecked() else "down" factor = self.factorSpinbox.value() - text = f'{rescale_mode}scaled_factor_{factor}' + text = f"{rescale_mode}scaled_factor_{factor}" return text - + def overwriteToggled(self, checked): self.folderPathOutControl.setDisabled(checked) self.textToAppendLineEdit.setDisabled(checked) if checked: - text = '' + text = "" else: text = self._getDefaultTextToAppend() self.textToAppendLineEdit.setText(text) - + def warnFolderPathEmpty(self): txt = html_utils.paragraph(""" To prevent overwriting raw data the Folder path for resized images cannot be empty. """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Empty folder path', txt) - + msg.warning(self, "Empty folder path", txt) + def warnTextToAppendEmpty(self): txt = html_utils.paragraph(""" To prevent overwriting raw data the text to append cannot be empty. """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Empty text to append', txt) - + msg.warning(self, "Empty text to append", txt) + def ok_cb(self): self.expFolderpathOut = self.folderPathOutControl.path() self.textToAppend = self.textToAppendLineEdit.text() @@ -17871,237 +17806,234 @@ def ok_cb(self): if isAccidentalOverwrite: self.warnTextToAppendEmpty() return - - if self.textToAppend and not self.textToAppend.startswith('_'): - self.textToAppend = f'_{self.textToAppend}' - + + if self.textToAppend and not self.textToAppend.startswith("_"): + self.textToAppend = f"_{self.textToAppend}" + if self.overwriteToggle.isChecked(): self.expFolderpathOut = None - + factor = self.factorSpinbox.value() self.resizeFactor = ( - factor if self.upScaleRadioButton.isChecked() else 1/factor + factor if self.upScaleRadioButton.isChecked() else 1 / factor ) - + self.cancel = False self.close() + class LogoDialog(QDialog): def __init__(self, logo_path, icon_path, parent=None): super().__init__(parent) - + layout = QVBoxLayout() - + self.setWindowFlags(Qt.FramelessWindowHint) # self.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint) # self.setAttribute(Qt.WA_TranslucentBackground) # self.setWindowIcon(QIcon(icon_path)) - + labelLogo = QLabel() pixmapLogo = QPixmap(logo_path) labelLogo.setPixmap(pixmapLogo) - + layout.addWidget(labelLogo) - + self.setLayout(layout) + class SetCustomLevelsLut(QBaseDialog): sigLevelsChanged = Signal(object) - + def __init__( - self, - init_min_value=None, - init_max_value=None, - minimum_min_value=0, - maximum_max_value=None, - parent=None - ): + self, + init_min_value=None, + init_max_value=None, + minimum_min_value=0, + maximum_max_value=None, + parent=None, + ): super().__init__(parent=parent) - + self.cancel = True - - self.setWindowTitle('Custom LUT levels') - + + self.setWindowTitle("Custom LUT levels") + layout = QVBoxLayout() - + self.minLevelSlider = widgets.sliderWithSpinBox( - title='Minimum', - title_loc='top', + title="Minimum", + title_loc="top", ) self.minLevelSlider.setMinimum(minimum_min_value) - + if init_min_value is not None: self.minLevelSlider.setValue(init_min_value) - + layout.addWidget(self.minLevelSlider) - + self.maxLevelSlider = widgets.sliderWithSpinBox( - title='Maximum', - title_loc='top', + title="Maximum", + title_loc="top", ) self.maxLevelSlider.setMinimum(minimum_min_value) if init_max_value is not None: self.maxLevelSlider.setValue(init_max_value) - + if maximum_max_value is not None: self.maxLevelSlider.setMaximum(maximum_max_value) self.minLevelSlider.setMaximum(maximum_max_value) - + layout.addWidget(self.maxLevelSlider) - + self.minLevelSlider.sigValueChange.connect(self.emitLevelsChanged) self.maxLevelSlider.sigValueChange.connect(self.emitLevelsChanged) - + buttonsLayout = widgets.CancelOkButtonsLayout() buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + layout.addSpacing(20) layout.addLayout(buttonsLayout) - + self.setLayout(layout) - + def sizeHint(self): heightHint = super().sizeHint().height() - widthHint = super().sizeHint().width()*2 + widthHint = super().sizeHint().width() * 2 return QSize(widthHint, heightHint) - - + def levels(self): levels = (self.minLevelSlider.value(), self.maxLevelSlider.value()) return levels - + def emitLevelsChanged(self, value): self.sigLevelsChanged.emit(self.levels()) - + def ok_cb(self): self.cancel = False self.selectedLevels = self.levels() self.close() + class FucciPreprocessDialog(FunctionParamsDialog): def __init__( - self, channel_names, - df_metadata=None, - parent=None, - ): - + self, + channel_names, + df_metadata=None, + parent=None, + ): + from cellacdc.preprocess import fucci_filter + params_argspecs = myutils.get_function_argspec(fucci_filter) - + super().__init__( - params_argspecs, - function_name='FUCCI pre-processing', + params_argspecs, + function_name="FUCCI pre-processing", df_metadata=df_metadata, parent=parent, ) - + channelNamesLayout = QGridLayout() - + row = 0 - label = QLabel('First channel name: ') + label = QLabel("First channel name: ") channelNamesLayout.addWidget(label, row, 0, alignment=Qt.AlignLeft) self.firstChNameWidget = QComboBox() self.firstChNameWidget.addItems(channel_names) channelNamesLayout.addWidget(self.firstChNameWidget, row, 1) - + row += 1 - label = QLabel('Second channel name: ') + label = QLabel("Second channel name: ") channelNamesLayout.addWidget(label, row, 0, alignment=Qt.AlignLeft) self.secondChNameWidget = QComboBox() self.secondChNameWidget.addItems(channel_names) self.secondChNameWidget.setCurrentText(list(channel_names)[1]) channelNamesLayout.addWidget(self.secondChNameWidget, row, 1) - + channelNamesLayout.setColumnStretch(0, 0) channelNamesLayout.setColumnStretch(1, 1) - + self.mainLayout.insertLayout(0, channelNamesLayout) self.mainLayout.insertWidget(1, widgets.QHLine()) - + def ok_cb(self): self.firstChannelName = self.firstChNameWidget.currentText() self.secondChannelName = self.secondChNameWidget.currentText() super().ok_cb() - + + class ViewCcaTableWindow(pdDataFrameWidget): sigUpdateCcaTable = Signal(object) - + def __init__(self, df, parent=None): super().__init__(df, parent=parent) - - updateTableButton = widgets.reloadPushButton( - 'Update table with visible IDs...' - ) + + updateTableButton = widgets.reloadPushButton("Update table with visible IDs...") buttonsLayout = QHBoxLayout() buttonsLayout.addStretch(1) buttonsLayout.addWidget(updateTableButton) - + self._layout.insertLayout(0, buttonsLayout) - + updateTableButton.clicked.connect(self.emitUpdateCcaTable) - + def emitUpdateCcaTable(self): self.sigUpdateCcaTable.emit(self) - - + + class ObjectCountDialog(QBaseDialog): sigShowEvent = Signal() sigUpdateCounts = Signal() - + def __init__( - self, - categoryCountMapper: dict, - parent=None, - data: list['load.loadData'] | None=None - ): + self, + categoryCountMapper: dict, + parent=None, + data: list["load.loadData"] | None = None, + ): super().__init__(parent=parent) - self.setWindowTitle('Object count') - + self.setWindowTitle("Object count") + self.cancel = False mainLayout = QVBoxLayout() cancelOkLayout = widgets.CancelOkButtonsLayout() cancelOkLayout.okButton.clicked.connect(self.ok_cb) cancelOkLayout.cancelButton.clicked.connect(self.close) - + self.data = data if data is not None: - saveCountsButton = widgets.savePushButton( - 'Export counts to CSV table' - ) + saveCountsButton = widgets.savePushButton("Export counts to CSV table") saveCountsButton.clicked.connect(self.saveCounts) cancelOkLayout.insertWidget(3, saveCountsButton) - - updateCountsButton = widgets.reloadPushButton('Update counts') + + updateCountsButton = widgets.reloadPushButton("Update counts") cancelOkLayout.insertWidget(3, updateCountsButton) updateCountsButton.clicked.connect(self.emitUpdateCounts) - + mainLayout.addWidget( - QLabel(html_utils.paragraph('Object count
', font_size='18px')), - alignment=Qt.AlignLeft + QLabel(html_utils.paragraph("Object count
", font_size="18px")), + alignment=Qt.AlignLeft, ) self.showHideButtons = [] self.categoryLabelMapper = {} for category, count in categoryCountMapper.items(): categoryLayout = QHBoxLayout() categoryLayout.addSpacing(10) - catText = html_utils.paragraph( - f'
{category}
', font_size='13px' - ) + catText = html_utils.paragraph(f"
{category}
", font_size="13px") catLabel = QLabel(catText) categoryLayout.addWidget(catLabel) categoryLayout.addStretch(1) - - countText = html_utils.paragraph( - f'
{count}
', font_size='13px' - ) + + countText = html_utils.paragraph(f"
{count}
", font_size="13px") countLabel = QLabel(countText) categoryLayout.addWidget(countLabel) - + self.categoryLabelMapper[category] = countLabel - - showHideButton = widgets.showDetailsButton(txt='') + + showHideButton = widgets.showDetailsButton(txt="") showHideButton.setChecked(True) showHideButton.sigToggled.connect( partial(self.showHideCount, labels=(catLabel, countLabel)) @@ -18110,100 +18042,98 @@ def __init__( categoryLayout.addSpacing(10) categoryLayout.addWidget(showHideButton) showHideButton.category = category - + self.showHideButtons.append(showHideButton) - + categoryLayout.setStretch(0, 0) categoryLayout.setStretch(1, 0) categoryLayout.setStretch(3, 0) - + mainLayout.addLayout(categoryLayout) mainLayout.addWidget(widgets.QHLine()) - + mainLayout.addSpacing(10) - + infoLayout = QHBoxLayout() - self.livePreviewCheckbox = QCheckBox('Live preview') + self.livePreviewCheckbox = QCheckBox("Live preview") self.livePreviewCheckbox.setChecked(True) infoLayout.addWidget(self.livePreviewCheckbox) infoLayout.addStretch(1) - self.warnLabel = QLabel('') + self.warnLabel = QLabel("") infoLayout.addWidget(self.warnLabel) self.livePreviewCheckbox.toggled.connect(self.updateWarnLabel) mainLayout.addLayout(infoLayout) - + mainLayout.addSpacing(30) mainLayout.addStretch(1) mainLayout.addLayout(cancelOkLayout) - + self.setLayout(mainLayout) - + def saveCounts(self, checked=False): categories = self.activeCategories() for posData in self.data: countMapper = posData.countObjectsInSegm(categories) - countMapper.pop('In current frame', None) + countMapper.pop("In current frame", None) df_count_endname = posData.saveObjCounts(countMapper) - + txt = html_utils.paragraph(f""" Done!

Objects count table saved in every loaded Position folder
as a CSV file ending with {df_count_endname} """) msg = widgets.myMessageBox(wrapText=False) - msg.information(self, 'Objects count saved', txt) - + msg.information(self, "Objects count saved", txt) + def updateWarnLabel(self, checked): if not checked: self.warnLabel.setText( html_utils.paragraph( - 'WARNING: without live preview, counts are not updated', - font_color='red' + "WARNING: without live preview, counts are not updated", + font_color="red", ) ) else: - self.warnLabel.setText('') - + self.warnLabel.setText("") + def emitUpdateCounts(self): self.sigUpdateCounts.emit() - + def activeCategories(self) -> List[str]: activeCategories = [] for showHideButton in self.showHideButtons: if not showHideButton.isChecked(): continue activeCategories.append(showHideButton.category) - + return activeCategories - + def showHideCount(self, checked, labels): for label in labels: label.setVisible(checked) - + QTimer.singleShot(100, self.resizeToHeightHint) def updateCounts(self, categoryCountMapper): for category, count in categoryCountMapper.items(): countLabel = self.categoryLabelMapper[category] - countText = html_utils.paragraph( - f'
{count}
', font_size='13px' - ) + countText = html_utils.paragraph(f"
{count}
", font_size="13px") countLabel.setText(countText) - - + def resizeToHeightHint(self): heightHint = self.sizeHint().height() self.resize(self.width(), heightHint) - + def showEvent(self, event): widthHint = self.sizeHint().width() - self.resize(int(widthHint*1.5), self.height()) + self.resize(int(widthHint * 1.5), self.height()) self.sigShowEvent.emit() - + def ok_cb(self): self.cancel = False self.close() + class PreProcessRecipeDialog(QBaseDialog): sigApplyImage = Signal(object) sigApplyZstack = Signal(object) @@ -18213,79 +18143,71 @@ class PreProcessRecipeDialog(QBaseDialog): sigValuesChanged = Signal(list) sigSavePreprocData = Signal(object) sigClose = Signal(object) - + def __init__( - self, - isTimelapse=False, - isZstack=False, - isMultiPos=False, - df_metadata=None, - addApplyButton=False, - parent=None, - hideOnClosing=False, - ): + self, + isTimelapse=False, + isZstack=False, + isMultiPos=False, + df_metadata=None, + addApplyButton=False, + parent=None, + hideOnClosing=False, + ): super().__init__(parent=parent) - - self.setWindowTitle('Pre-processing recipe') - + + self.setWindowTitle("Pre-processing recipe") + self.cancel = True self.hideOnClosing = hideOnClosing - + mainLayout = QVBoxLayout() - + keepInputDataTypeLayout = QHBoxLayout() self.keepInputDataTypeToggle = widgets.Toggle() self.keepInputDataTypeToggle.setChecked(True) self.keepInputDataTypeToggle.toggled.connect(self.emitValuesChanged) keepInputDataTypeLayout.addStretch(1) - keepInputDataTypeLayout.addWidget(QLabel('Keep input data type: ')) + keepInputDataTypeLayout.addWidget(QLabel("Keep input data type: ")) keepInputDataTypeLayout.addWidget(self.keepInputDataTypeToggle) keepInputDataTypeInfoButton = widgets.infoPushButton() keepInputDataTypeLayout.addWidget(keepInputDataTypeInfoButton) - keepInputDataTypeInfoButton.clicked.connect( - self.showInfoKeepInputDataType - ) + keepInputDataTypeInfoButton.clicked.connect(self.showInfoKeepInputDataType) self.keepInputDataTypeLayout = keepInputDataTypeLayout - + self.preProcessParamsWidget = PreProcessParamsWidget( - df_metadata=df_metadata, - addApplyButton=addApplyButton, - parent=self + df_metadata=df_metadata, addApplyButton=addApplyButton, parent=self ) self.preProcessParamsWidget.groupbox.setCheckable(False) - - buttonsLayout = QGridLayout() # self.preProcessParamsWidget.buttonsLayout + + buttonsLayout = QGridLayout() # self.preProcessParamsWidget.buttonsLayout self.buttonsLayout = buttonsLayout - self.previewCheckbox = QCheckBox('Preview') + self.previewCheckbox = QCheckBox("Preview") buttonsLayout.addWidget(self.previewCheckbox, 0, 0) - + # Relocate buttons of PreProcessParamsWidget to this dialog pPPWBL = self.preProcessParamsWidget.buttonsLayout - loadRecipeButtIdx = pPPWBL.indexOf( - self.preProcessParamsWidget.loadRecipeButton - ) + loadRecipeButtIdx = pPPWBL.indexOf(self.preProcessParamsWidget.loadRecipeButton) self.loadRecipeButton = pPPWBL.takeAt(loadRecipeButtIdx).widget() buttonsLayout.addWidget(self.loadRecipeButton, 0, 1) - - saveRecipeButtIdx = pPPWBL.indexOf( - self.preProcessParamsWidget.saveRecipeButton - ) + + saveRecipeButtIdx = pPPWBL.indexOf(self.preProcessParamsWidget.saveRecipeButton) self.saveRecipeButton = pPPWBL.takeAt(saveRecipeButtIdx).widget() buttonsLayout.addWidget(self.saveRecipeButton, 1, 1) - + loadLastRecipeButtIdx = pPPWBL.indexOf( self.preProcessParamsWidget.loadLastRecipeButton ) self.loadLastRecipeButton = pPPWBL.takeAt(loadLastRecipeButtIdx).widget() buttonsLayout.addWidget(self.loadLastRecipeButton, 1, 0) - + self.loadLastRecipeButton.hide() - + # self.cancelButton = widgets.cancelPushButton('Cancel') # buttonsLayout.insertWidget(2, self.cancelButton) # buttonsLayout.insertSpacing(3, 20) - + self.allButtons = [ self.previewCheckbox, self.loadRecipeButton, @@ -18293,61 +18215,50 @@ def __init__( ] col = 3 row = 0 - self.applyCurrentFrameButton = widgets.okPushButton( - 'Apply to displayed image' - ) + self.applyCurrentFrameButton = widgets.okPushButton("Apply to displayed image") buttonsLayout.addWidget(self.applyCurrentFrameButton, row, col) self.applyCurrentFrameButton.clicked.connect( partial(self.apply, signal=self.sigApplyImage) ) self.allButtons.append(self.applyCurrentFrameButton) - + infoLayout = QHBoxLayout() buttonsHeight = self.applyCurrentFrameButton.sizeHint().height() - self.loadingCircle = widgets.LoadingCircleAnimation( - size=buttonsHeight - ) + self.loadingCircle = widgets.LoadingCircleAnimation(size=buttonsHeight) sp = self.loadingCircle.sizePolicy() sp.setRetainSizeWhenHidden(True) self.loadingCircle.setSizePolicy(sp) self.loadingCircle.setVisible(False) infoLayout.addWidget(self.loadingCircle) - - self.infoLabel = QLabel( - "(Feel free to use Cell-ACDC while waiting)" - ) + + self.infoLabel = QLabel("(Feel free to use Cell-ACDC while waiting)") sp = self.infoLabel.sizePolicy() sp.setRetainSizeWhenHidden(True) self.infoLabel.setSizePolicy(sp) self.infoLabel.hide() infoLayout.addWidget(self.infoLabel) - + buttonsLayout.addLayout( - infoLayout, row+1, 0, 3, 2, - alignment=Qt.AlignBottom | Qt.AlignLeft + infoLayout, row + 1, 0, 3, 2, alignment=Qt.AlignBottom | Qt.AlignLeft ) if isZstack: row += 1 self.applyAllZslicesButton = widgets.threeDPushButton( - 'Apply to all z-slices of current image' + "Apply to all z-slices of current image" ) buttonsLayout.addWidget(self.applyAllZslicesButton, row, col) self.applyAllZslicesButton.clicked.connect(self.applyAllZslices) self.allButtons.append(self.applyAllZslicesButton) if isTimelapse: row += 1 - self.applyAllFramesButton = widgets.futurePushButton( - 'Apply to all frames' - ) + self.applyAllFramesButton = widgets.futurePushButton("Apply to all frames") buttonsLayout.addWidget(self.applyAllFramesButton, row, col) self.applyAllFramesButton.clicked.connect(self.applyAllFrames) self.allButtons.append(self.applyAllFramesButton) if isMultiPos: row += 1 - self.applyAllPosButton = widgets.futurePushButton( - 'Apply to all Positions' - ) + self.applyAllPosButton = widgets.futurePushButton("Apply to all Positions") buttonsLayout.addWidget(self.applyAllPosButton, row, col) self.applyAllPosButton.clicked.connect( partial(self.apply, signal=self.sigApplyAllPos) @@ -18355,40 +18266,36 @@ def __init__( self.allButtons.append(self.applyAllPosButton) row += 1 - self.savePreprocButton = widgets.savePushButton( - 'Save pre-processed data...' - ) + self.savePreprocButton = widgets.savePushButton("Save pre-processed data...") buttonsLayout.addWidget(self.savePreprocButton, row, col) self.allButtons.append(self.savePreprocButton) self.savePreprocButton.clicked.connect(self.emitSignalSavePreprocData) - + self.previewCheckbox.toggled.connect(self.emitSigPreviewToggled) - self.preProcessParamsWidget.sigValuesChanged.connect( - self.emitValuesChanged - ) - + self.preProcessParamsWidget.sigValuesChanged.connect(self.emitValuesChanged) + # self.cancelButton.clicked.connect(self.close) - + mainLayout.addLayout(keepInputDataTypeLayout) mainLayout.addSpacing(20) mainLayout.addWidget(self.preProcessParamsWidget) mainLayout.addLayout(buttonsLayout) self.mainLayout = mainLayout - + self.setLayout(mainLayout) def applyAllZslices(self, checked=False): - # Preview needs to be turned off because we are computing on every - # z-slice + # Preview needs to be turned off because we are computing on every + # z-slice self.previewCheckbox.setChecked(False) self.apply(signal=self.sigApplyZstack) - + def applyAllFrames(self, checked=False): # Preview needs to be turned off because we are computing on all frames self.previewCheckbox.setChecked(False) self.apply(signal=self.sigApplyAllFrames) - + def emitSigPreviewToggled(self): self.sigPreviewToggled.emit(self.previewCheckbox.isChecked()) @@ -18402,18 +18309,18 @@ def showInfoKeepInputDataType(self): We recommend keeping this option checked. """) msg = widgets.myMessageBox(wrapText=False) - msg.information(self, 'Keep input data type', txt) + msg.information(self, "Keep input data type", txt) def emitSignalSavePreprocData(self): self.sigSavePreprocData.emit(self) - + def emitValuesChanged(self): recipe = self.recipe(warn=False) if recipe is None: return - + self.sigValuesChanged.emit(recipe) - + def setDisabled(self, disabled: bool): self.preProcessParamsWidget.setDisabled(disabled) self.loadingCircle.setVisible(disabled) @@ -18423,17 +18330,17 @@ def setDisabled(self, disabled: bool): button.setDisabled(disabled) except RuntimeError as e: printl(traceback.format_exc()) - printl(f'Error: {e}') - printl(f'Button: {button}') - - def apply(self, checked=False, signal: Signal=None): + printl(f"Error: {e}") + printl(f"Button: {button}") + + def apply(self, checked=False, signal: Signal = None): recipe = self.recipe() if recipe is None: return - + if signal is not None: signal.emit(recipe) - + if self.hideOnClosing: self.setDisabled(True) self.infoLabel.setText( @@ -18442,23 +18349,21 @@ def apply(self, checked=False, signal: Signal=None): ) else: self.ok_cb() - + def appliedFinished(self): self.setDisabled(False) - + def recipe(self, warn=True): recipe = self.preProcessParamsWidget.recipe(warn=warn) if recipe is None: return - + for step in recipe: - step['keep_input_data_type'] = ( - self.keepInputDataTypeToggle.isChecked() - ) + step["keep_input_data_type"] = self.keepInputDataTypeToggle.isChecked() return recipe - + def recipeConfigPars(self): - return self.preProcessParamsWidget.recipeConfigPars('acdc') + return self.preProcessParamsWidget.recipeConfigPars("acdc") def ok_cb(self): if self.hideOnClosing: @@ -18467,79 +18372,78 @@ def ok_cb(self): self.cancel = False self.close() - + def close(self): super().close() self.sigClose.emit(self) + class PreProcessRecipeDialogUtil(PreProcessRecipeDialog): def __init__( - self, - channel_names: Iterable[str], - df_metadata=None, - parent=None, - ): + self, + channel_names: Iterable[str], + df_metadata=None, + parent=None, + ): self.cancel = True - + super().__init__( - isTimelapse=False, - isZstack=False, - isMultiPos=False, + isTimelapse=False, + isZstack=False, + isMultiPos=False, addApplyButton=False, df_metadata=df_metadata, parent=parent, - hideOnClosing=False + hideOnClosing=False, ) - + self.listSelector = widgets.listWidget( isMultipleSelection=True, minimizeHeight=True ) self.listSelector.addItems(channel_names) self.listSelector.setCurrentRow(0) - + self.mainLayout.insertWidget(0, self.listSelector) - self.mainLayout.insertWidget( - 0, QLabel('Select channel(s) to pre-process:') - ) + self.mainLayout.insertWidget(0, QLabel("Select channel(s) to pre-process:")) self.mainLayout.insertSpacing(2, 10) self.mainLayout.insertWidget(2, widgets.QHLine()) - + self.savePreprocButton.hide() self.previewCheckbox.hide() - self.applyCurrentFrameButton.setText('Ok') - + self.applyCurrentFrameButton.setText("Ok") + buttonsLayout = self.preProcessParamsWidget.buttonsLayout - + saveRecipeButtonIndex = buttonsLayout.indexOf( self.preProcessParamsWidget.saveRecipeButton - ) - + ) + if saveRecipeButtonIndex == -1: return - + saveRecipeButtonItem = buttonsLayout.takeAt(saveRecipeButtonIndex) - + buttonsLayout.addItem(saveRecipeButtonItem, 0, 2) - + def warnChannelSelectionEmpty(self): txt = html_utils.paragraph(""" You did not select any channel.

Channel selection cannot be empty.

Thank you for your patience! """) - + def ok_cb(self): selectedChannelItems = self.listSelector.selectedItems() if not selectedChannelItems: self.warnChannelSelectionEmpty() - + recipe = self.recipe() if recipe is None: return - + self.selectedRecipe = recipe self.selectedChannels = [item.text() for item in selectedChannelItems] - + self.cancel = False self.close() @@ -18554,7 +18458,8 @@ def ok_cb(self): # if text in self.bad_values: # option.palette.setColor(option.palette.Text, QColor("red")) # super().paint(painter, option, index) - + + class CombineChannelsSetupDialog(PreProcessRecipeDialog): sigApplyImage = Signal(dict, bool, str) sigApplyZstack = Signal(dict, bool, str) @@ -18563,27 +18468,28 @@ class CombineChannelsSetupDialog(PreProcessRecipeDialog): sigValuesChanged = Signal() sigSaveAsSegmCheckboxToggled = Signal(bool) - # sigApplyAllZslices = Signal(dict, bool, str) # sigApplyAllFramesZslices = Signal(dict, bool, str) def __init__( - self, - channel_names, - df_metadata=None, - parent=None, - hideOnClosing=False, - isTimelapse=False, - isZstack=False, - isMultiPos=False, - ): - + self, + channel_names, + df_metadata=None, + parent=None, + hideOnClosing=False, + isTimelapse=False, + isZstack=False, + isMultiPos=False, + ): + self.combineChannelsWidget = CombineChannelsWidget(channel_names, parent=self) self.warnExistingRecipeFile = self.combineChannelsWidget.warnExistingRecipeFile - self.communicateSavingRecipeFinished = self.combineChannelsWidget.communicateSavingRecipeFinished + self.communicateSavingRecipeFinished = ( + self.combineChannelsWidget.communicateSavingRecipeFinished + ) self.saveRecipeUI = self.combineChannelsWidget.saveRecipeUI self.selectRecipeFilepath = self.combineChannelsWidget.selectRecipeFilepath - + super().__init__( isTimelapse=isTimelapse, isZstack=isZstack, @@ -18596,73 +18502,74 @@ def __init__( self.combineChannelsWidget.sigValuesChangedCombineChannels.connect( self.emitValuesChangedSteps ) - self.segm_blinked = False - self.validFormula = True # allow empty formula - self.forbiddenChannels = set() # channels that cannot be combined + self.validFormula = True # allow empty formula + self.forbiddenChannels = set() # channels that cannot be combined self.mainLayout.setSpacing(4) - + self.mainLayout.insertWidget(2, self.combineChannelsWidget) self.combineChannelsWidget.groupbox.setCheckable(False) - self.combineChannelsWidget.groupbox.setTitle('Combine and manipulate channels and/or segmentation files') - + self.combineChannelsWidget.groupbox.setTitle( + "Combine and manipulate channels and/or segmentation files" + ) + self.formulaEditWidget = FormulaEditWidget(parent=self) self._updateFormulaVariableNames() self.formulaEditWidget.sigFormulaChanged.connect(self.formulaChanged) self.formulaEditWidget.setToolTip( - 'Enter a formula to combine the channels. For example ' - '"img1 + img2 * 0.5"' + 'Enter a formula to combine the channels. For example "img1 + img2 * 0.5"' ) self.mainLayout.insertWidget(3, self.formulaEditWidget) - + buttonsLayoutSaveGroup = QGridLayout() - + row = 0 col = 0 - loadRecipeButton = widgets.OpenFilePushButton('Load saved recipe') + loadRecipeButton = widgets.OpenFilePushButton("Load saved recipe") self.loadRecipeButtonComb = loadRecipeButton buttonsLayoutSaveGroup.addWidget(loadRecipeButton, row, col) self.loadRecipeButtonComb.clicked.connect(self.selectAndLoadRecipe) - + col += 1 - saveRecipeButton = widgets.savePushButton('Save current recipe') + saveRecipeButton = widgets.savePushButton("Save current recipe") self.saveRecipeButtonComb = saveRecipeButton buttonsLayoutSaveGroup.addWidget(saveRecipeButton, row, col) saveRecipeButton.clicked.connect(self.saveRecipe) saveRecipeButton.setToolTip( - 'Save the current recipe to a file\n' - f'Location: {combine_channels_recipes_path}' + "Save the current recipe to a file\n" + f"Location: {combine_channels_recipes_path}" ) - + col += 1 - loadLastRecipeButton = widgets.reloadPushButton('Load last recipe') + loadLastRecipeButton = widgets.reloadPushButton("Load last recipe") self.loadLastRecipeButtonComb = loadLastRecipeButton - buttonsLayoutSaveGroup.addWidget(loadLastRecipeButton, row, col) + buttonsLayoutSaveGroup.addWidget(loadLastRecipeButton, row, col) self.mainLayout.addLayout(buttonsLayoutSaveGroup) loadLastRecipeButton.clicked.connect(self.loadLastRecipe) self.setLoadLastRecipe() - - loadLastRecipeButton.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + + loadLastRecipeButton.setContextMenuPolicy( + Qt.ContextMenuPolicy.CustomContextMenu + ) loadLastRecipeButton.customContextMenuRequested.connect( self._showLoadRecipeContextMenu ) self.cancel = True - self.setWindowTitle('Combine and manipulate channels and/or segmentation files') + self.setWindowTitle("Combine and manipulate channels and/or segmentation files") self.preProcessParamsWidget.hide() self.mainLayout.removeWidget(self.preProcessParamsWidget) - self.savePreprocButton.setText('Save combined data...') - - + self.savePreprocButton.setText("Save combined data...") + tooltip = ( - 'Save as a segmentation file, for example ' - 'when combining a binary mask with a segmentation mask.' - ) - label = QLabel('Save as segmentation:') + "Save as a segmentation file, for example " + "when combining a binary mask with a segmentation mask." + ) + label = QLabel("Save as segmentation:") self.saveAsSegmlabel = label label.setToolTip(tooltip) self.saveAsSegmCheckbox = widgets.Toggle() @@ -18670,7 +18577,7 @@ def __init__( self.saveAsSegmCheckbox.setChecked(False) self.saveAsSegmCheckbox.setEnabled(False) self.saveAsSegmCheckbox.toggled.connect(self.emitSaveAsSegmCheckboxToggled) - + self.keepInputDataTypeLayout.insertWidget(0, label) self.keepInputDataTypeLayout.insertWidget(1, self.saveAsSegmCheckbox) @@ -18687,7 +18594,7 @@ def returLoadSecondLastRecipe(self): def _showLoadRecipeContextMenu(self, pos): menu = QMenu(self) - action = menu.addAction('Load recipe from before the last one') + action = menu.addAction("Load recipe from before the last one") action.triggered.connect(self.loadPreviousRecipe) action.setEnabled(self.returLoadSecondLastRecipe()) menu.exec(self.loadLastRecipeButtonComb.mapToGlobal(pos)) @@ -18703,9 +18610,9 @@ def loadLastRecipe(self): filepath = self._lastRecipePath() if not os.path.exists(filepath): return - + self.loadRecipe(filepath) - + def saveLastRecipe(self): os.makedirs(combine_channels_recipes_path, exist_ok=True) filepath = self._lastRecipePath() @@ -18713,7 +18620,7 @@ def saveLastRecipe(self): same = False if os.path.exists(filepath): steps_curr = self._getSaveRecipyDict() - with open(filepath, 'r') as f: + with open(filepath, "r") as f: steps_prev = json.load(f) same = self._recipesMatch(steps_curr, steps_prev) @@ -18727,7 +18634,6 @@ def saveLastRecipe(self): os.rename(filepath, new_filename) self.saveRecipe(filepath=filepath) - def _recipesMatch(self, steps_curr, steps_prev): # Normalize current dict to strings for comparison with JSON-loaded dict def normalize(d): @@ -18737,7 +18643,7 @@ def normalize(d): key = str(raw_key) if key not in steps_prev: return False - if key in ('formula', 'keep_input_data_type', 'save_as_segm'): + if key in ("formula", "keep_input_data_type", "save_as_segm"): if str(steps_curr[raw_key]) != str(steps_prev[key]): return False else: @@ -18749,128 +18655,130 @@ def normalize(d): if val2 != str(step_dict_prev[key2]): return False return True - + def _lastRecipePath(self): - return os.path.join(combine_channels_recipes_path, '.last_combine_channels_recipe.json') - + return os.path.join( + combine_channels_recipes_path, ".last_combine_channels_recipe.json" + ) + def _secondLastRecipePath(self): - return os.path.join(combine_channels_recipes_path, '.previous_combine_channels_recipe.json') - + return os.path.join( + combine_channels_recipes_path, ".previous_combine_channels_recipe.json" + ) + def _getSaveRecipyDict(self): - steps = self.combineChannelsWidget.steps() # already returns a copy + steps = self.combineChannelsWidget.steps() # already returns a copy formula = self.formulaEditWidget.text() - steps['formula'] = formula - steps['keep_input_data_type'] = self.keepInputDataTypeToggle.isChecked() - steps['save_as_segm'] = self.saveAsSegmCheckbox.isChecked() + steps["formula"] = formula + steps["keep_input_data_type"] = self.keepInputDataTypeToggle.isChecked() + steps["save_as_segm"] = self.saveAsSegmCheckbox.isChecked() return steps - + def saveRecipe(self, dummy=None, filepath=None): os.makedirs(combine_channels_recipes_path, exist_ok=True) - + filepath_provided = filepath is not None if not filepath_provided: folder_content = myutils.listdir(combine_channels_recipes_path) num_recipes = len(folder_content) - default_text = f'{num_recipes + 1}' + default_text = f"{num_recipes + 1}" proceed, filepath = self.saveRecipeUI( - combine_channels_recipes_path, '.json', - 'Save recipe', 'combine_channels_recipe', - 'Insert a filename for the recipe:', - default_text + combine_channels_recipes_path, + ".json", + "Save recipe", + "combine_channels_recipe", + "Insert a filename for the recipe:", + default_text, ) - + if not proceed: return - + steps = self._getSaveRecipyDict() - - with open(filepath, 'w') as f: + + with open(filepath, "w") as f: json.dump(steps, f, indent=2) - + if not filepath_provided: self.communicateSavingRecipeFinished(filepath) - + def selectAndLoadRecipe(self): filepath = self.selectRecipeFilepath( - combine_channels_recipes_path, - 'combine_channels_recipe', 'JSON', 'json' + combine_channels_recipes_path, "combine_channels_recipe", "JSON", "json" ) if filepath is None: return self.loadRecipe(filepath) - + def loadRecipe(self, filepath): - with open(filepath, 'r') as f: + with open(filepath, "r") as f: recipe = json.load(f) - + recipe = dict(sorted(recipe.items())) keys_used = set() for key, value in recipe.items(): - if key == 'formula': + if key == "formula": formula = value continue - if key == 'keep_input_data_type': + if key == "keep_input_data_type": self.keepInputDataTypeToggle.setChecked(value) continue - if key == 'save_as_segm': + if key == "save_as_segm": self.saveAsSegmCheckbox.setChecked(value) continue - - name = value['name'] - channel = value['channel'] - binarize = value['binarize'] - min_val = float(value['min_val']) - max_val = float(value['max_val']) + + name = value["name"] + channel = value["channel"] + binarize = value["binarize"] + min_val = float(value["min_val"]) + max_val = float(value["max_val"]) key = int(key) stepWidgetsNum = len(self.combineChannelsWidget.stepsWidgets) if key > stepWidgetsNum: self.combineChannelsWidget.addStep() - + stepWidgets = self.combineChannelsWidget.stepsWidgets[key] - idx = stepWidgets['selector'].findText(channel) + idx = stepWidgets["selector"].findText(channel) if idx == -1: - stepWidgets['selector'].addItem(channel) + stepWidgets["selector"].addItem(channel) # stepWidgets['selector'].forbiddenItems.add(channel) - blinker = qutils.QControlBlink( - stepWidgets['selector'], - qparent=self - ) + blinker = qutils.QControlBlink(stepWidgets["selector"], qparent=self) blinker.start() - stepWidgets['selector'].blinker = blinker + stepWidgets["selector"].blinker = blinker self.forbiddenChannels.add(channel) - - stepWidgets['selector'].setCurrentText(channel) - stepWidgets['name_edit'].setText(name) - stepWidgets['binarize'].setCurrentText(binarize) - stepWidgets['minValueSpinbox'].setValue(min_val) - stepWidgets['maxValueSpinbox'].setValue(max_val) - + + stepWidgets["selector"].setCurrentText(channel) + stepWidgets["name_edit"].setText(name) + stepWidgets["binarize"].setCurrentText(binarize) + stepWidgets["minValueSpinbox"].setValue(min_val) + stepWidgets["maxValueSpinbox"].setValue(max_val) + keys_used.add(key) - + # remove extra steps - keys_present = set(range(1, len(self.combineChannelsWidget.stepsWidgets)+1)) + keys_present = set(range(1, len(self.combineChannelsWidget.stepsWidgets) + 1)) extra_keys = keys_present - keys_used extra_keys = list(extra_keys) extra_keys.sort(reverse=True) for key in extra_keys: - self.combineChannelsWidget.removeStep(step_n = key) + self.combineChannelsWidget.removeStep(step_n=key) # updates key dynamically so I have to rely that missing indx are always last steps # update formula self.formulaEditWidget.setText(formula) - + for stepWidgets in self.combineChannelsWidget.stepsWidgets.values(): - combo = stepWidgets['selector'] + combo = stepWidgets["selector"] # set forbidden channels red in all steps for i in range(combo.count()): item = combo.itemText(i) if item in self.forbiddenChannels: - combo.setItemData(i, QColor('red'), Qt.ForegroundRole) - + combo.setItemData(i, QColor("red"), Qt.ForegroundRole) + def _updateFormulaVariableNames(self): names = [ - stepWidgets['name_edit'].text() + stepWidgets["name_edit"].text() for stepWidgets in self.combineChannelsWidget.stepsWidgets.values() ] self.formulaEditWidget.setVariableNames(names) @@ -18880,7 +18788,7 @@ def formulaChanged(self, formula_str, is_valid): self.validFormula = is_valid if is_valid: self.sigValuesChanged.emit() - + def setButtonsEnabled(self, enabled): for i in range(self.buttonsLayout.count()): item = self.buttonsLayout.itemAt(i) @@ -18889,7 +18797,7 @@ def setButtonsEnabled(self, enabled): continue if isinstance(widget, QPushButton): label = widget.text().lower().rstrip().lstrip() - if 'apply' in label or 'save' in label or 'ok' in label: + if "apply" in label or "save" in label or "ok" in label: if enabled: try: widget.setEnabled(True) @@ -18900,23 +18808,22 @@ def setButtonsEnabled(self, enabled): widget.setDisabled(True) except: pass - - + def saveAsSegm(self): return self.saveAsSegmCheckbox.isChecked() - + def emitSaveAsSegmCheckboxToggled(self): if self.validFormula: self.sigSaveAsSegmCheckboxToggled.emit(self.saveAsSegm()) - + def autoCheckSaveAsSegmCheckbox(self): any_not_seg = False for step in self.combineChannelsWidget.steps().values(): - channel = step['channel'] - if 'segm' not in channel: + channel = step["channel"] + if "segm" not in channel: any_not_seg = True break - + if any_not_seg: self.saveAsSegmCheckbox.setChecked(False) self.saveAsSegmCheckbox.setEnabled(False) @@ -18924,25 +18831,23 @@ def autoCheckSaveAsSegmCheckbox(self): if not self.segm_blinked: self.saveAsSegmCheckbox.setEnabled(True) self.blinker = qutils.QControlBlink( - self.saveAsSegmCheckbox, - qparent=self + self.saveAsSegmCheckbox, qparent=self ) self.blinker.start() self.segm_blinked = True - def apply(self, checked=False, signal: Signal=None): + def apply(self, checked=False, signal: Signal = None): steps = self.combineChannelsWidget.steps() formula = self.formulaEditWidget.text() keep_input_dtype = self.keepInputDataTypeToggle.isChecked() if not steps or not self.validFormula: return - + if signal is not None: try: signal.emit(steps, formula) except TypeError as err: signal.emit(steps, keep_input_dtype, formula) - self.saveLastRecipe() if self.hideOnClosing: @@ -18953,13 +18858,14 @@ def apply(self, checked=False, signal: Signal=None): ) else: self.ok_cb(saveLastRecipe=False) + # Not needed anymore since now we funnel all changes to the formulaEditWidget, which then verifies the formula and # emits a signal via formulaChangeda # def emitValuesChanged(self): # if not self.validFormula: # return # self.sigValuesChanged.emit() - + def emitValuesChangedSteps(self): self.autoCheckSaveAsSegmCheckbox() self._updateFormulaVariableNames() @@ -18967,33 +18873,29 @@ def emitValuesChangedSteps(self): def ok_cb(self, dummy=None, saveLastRecipe=True): if not self.validFormula: return - + if saveLastRecipe: self.saveLastRecipe() - + self.keepInputDataType = self.keepInputDataTypeToggle.isChecked() self.selectedSteps = self.combineChannelsWidget.steps() self.formula = self.formulaEditWidget.text() self.cancel = False self.close() + class CombineChannelsSetupDialogUtil(CombineChannelsSetupDialog): def __init__( - self, - channel_names, - df_metadata=None, - parent=None, - ): + self, + channel_names, + df_metadata=None, + parent=None, + ): + + super().__init__(channel_names, parent=parent, df_metadata=df_metadata) - super().__init__( - channel_names, - parent=parent, - df_metadata=df_metadata - ) - # add int input for number of workers - self.mainLayout.addSpacing(20) qutils.hide_and_delete_layout(self.buttonsLayout) @@ -19003,7 +18905,7 @@ def __init__( buttonsLayout.cancelButton.clicked.connect(self.close) self.mainLayout.addLayout(buttonsLayout) - + self.nThreadsSpinBox = QSpinBox() self.nThreadsSpinBox.setMinimum(1) self.nThreadsSpinBox.setValue(4) @@ -19011,23 +18913,24 @@ def __init__( self.mainLayout.addWidget(QLabel("Number of threads:")) self.mainLayout.addWidget(self.nThreadsSpinBox) + class CombineChannelsSetupDialogGUI(CombineChannelsSetupDialog): def __init__( - self, - channel_names: Iterable[str], - df_metadata=None, - isTimelapse=False, - isZstack=False, - isMultiPos=False, - parent=None, - hideOnClosing=False - ): + self, + channel_names: Iterable[str], + df_metadata=None, + isTimelapse=False, + isZstack=False, + isMultiPos=False, + parent=None, + hideOnClosing=False, + ): super().__init__( channel_names, df_metadata=df_metadata, - isTimelapse=isTimelapse, - isZstack=isZstack, - isMultiPos=isMultiPos, + isTimelapse=isTimelapse, + isZstack=isZstack, + isMultiPos=isMultiPos, parent=parent, hideOnClosing=hideOnClosing, ) @@ -19042,17 +18945,18 @@ def __init__( self.allButtons.remove(self.loadRecipeButton) self.previewCheckbox.setChecked(True) - self.saveAsSegmlabel.setText('Save and view as segmentation') - + self.saveAsSegmlabel.setText("Save and view as segmentation") + def steps(self, return_keepInputDataType=False): steps = self.combineChannelsWidget.steps() formula = self.formulaEditWidget.text() # if not return_keepInputDataType: # return steps, formula - + keep_input_dtype = self.keepInputDataTypeToggle.isChecked() return steps, keep_input_dtype, formula + class QCropTrangeTool(QBaseDialog): sigClose = Signal() sigTvalueChanged = Signal(int) @@ -19060,12 +18964,13 @@ class QCropTrangeTool(QBaseDialog): sigCrop = Signal(int, int) def __init__( - self, SizeT, - cropButtonText='Apply crop', - parent=None, - addDoNotShowAgain=False, - title='Select frames range' - ): + self, + SizeT, + cropButtonText="Apply crop", + parent=None, + addDoNotShowAgain=False, + title="Select frames range", + ): super().__init__(parent) self.cancel = True @@ -19081,29 +18986,25 @@ def __init__( buttonsLayout = QHBoxLayout() self.startFrameScrollbar = widgets.sliderWithSpinBox( - spinbox_loc='left', - maximum_on_label=SizeT + spinbox_loc="left", maximum_on_label=SizeT ) self.startFrameScrollbar.setMaximum(SizeT, including_spinbox=True) self.startFrameScrollbar.setMinimum(1, including_spinbox=True) self.endFrameScrollbar = widgets.sliderWithSpinBox( - spinbox_loc='left', - maximum_on_label=SizeT + spinbox_loc="left", maximum_on_label=SizeT ) self.endFrameScrollbar.setMaximum(SizeT, including_spinbox=True) self.endFrameScrollbar.setMinimum(1, including_spinbox=True) self.endFrameScrollbar.setValue(SizeT) - cancelButton = widgets.cancelPushButton('Cancel') + cancelButton = widgets.cancelPushButton("Cancel") cropButton = widgets.okPushButton(cropButtonText) buttonsLayout.addWidget(cropButton) buttonsLayout.addWidget(cancelButton) row = 0 - layout.addWidget( - QLabel('Start frame n. '), row, 0, alignment=Qt.AlignRight - ) + layout.addWidget(QLabel("Start frame n. "), row, 0, alignment=Qt.AlignRight) layout.addWidget(self.startFrameScrollbar, row, 2) row += 1 @@ -19111,21 +19012,19 @@ def __init__( layout.addItem(QSpacerItem(10, 10), row, 0) row += 1 - layout.addWidget( - QLabel('Stop frame n. '), row, 0, alignment=Qt.AlignRight - ) + layout.addWidget(QLabel("Stop frame n. "), row, 0, alignment=Qt.AlignRight) layout.addWidget(self.endFrameScrollbar, row, 2) row += 1 if addDoNotShowAgain: - self.doNotShowAgainCheckbox = QCheckBox('Do not ask again') + self.doNotShowAgainCheckbox = QCheckBox("Do not ask again") layout.addWidget( self.doNotShowAgainCheckbox, row, 2, alignment=Qt.AlignLeft ) row += 1 layout.addItem(QSpacerItem(10, 20), row, 0) - layout.addLayout(buttonsLayout, row+1, 2, alignment=Qt.AlignRight) + layout.addLayout(buttonsLayout, row + 1, 2, alignment=Qt.AlignRight) layout.setColumnStretch(0, 0) layout.setColumnStretch(1, 0) @@ -19158,55 +19057,54 @@ def TvalueChanged(self, value): self.sigTvalueChanged.emit(frame_i) def showEvent(self, event): - self.resize(int(self.width()*2.0), self.height()) + self.resize(int(self.width() * 2.0), self.height()) def closeEvent(self, event): super().closeEvent(event) self.sigClose.emit() + class QTreeDialog(QBaseDialog): def __init__( - self, - items: List[Tuple[str]], - headerLabels: List[str]=None, - parent=None, - infoText='Select item', - title='Select item', - path_to_browse=None, - additional_buttons=None, - ): + self, + items: List[Tuple[str]], + headerLabels: List[str] = None, + parent=None, + infoText="Select item", + title="Select item", + path_to_browse=None, + additional_buttons=None, + ): self.cancel = True super().__init__(parent) - + self.setWindowTitle(title) - + mainLayout = QVBoxLayout() - + infoLabel = QLabel(html_utils.paragraph(infoText)) - + self.treeWidget = widgets.TreeWidget() if headerLabels is not None: self.treeWidget.setHeaderLabels(headerLabels) else: self.treeWidget.setHeaderHidden(True) - + for row, texts in enumerate(items): item = widgets.TreeWidgetItem(self.treeWidget) for i, text in enumerate(texts): item.setText(i, text) self.treeWidget.addTopLevelItem(item) - + self.treeWidget.resizeColumnToContents(0) self.treeWidget.resizeColumnToContents(1) - + # self.treeWidget.header().setStretchLastSection(False) - + buttonsLayout = widgets.CancelOkButtonsLayout() - + if path_to_browse is not None: - browseButton = widgets.showInFileManagerButton( - setDefaultText=True - ) + browseButton = widgets.showInFileManagerButton(setDefaultText=True) browseButton.setPathToBrowse(path_to_browse) buttonsLayout.insertWidget(3, browseButton) @@ -19216,20 +19114,20 @@ def __init__( buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addWidget(infoLabel) mainLayout.addWidget(self.treeWidget) mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) - + self.setLayout(mainLayout) - + def show(self, block=False): w = self.sizeHint().width() h = self.sizeHint().height() - self.resize(int(w*1.3), h) + self.resize(int(w * 1.3), h) super().show(block=block) - + def ok_cb(self): self.clickedButton = self.sender() self.cancel = False @@ -19237,121 +19135,122 @@ def ok_cb(self): self.selectedText = self.selectedItem.text(0) self.close() + class SelectFoldersToAnalyse(QBaseDialog): def __init__( - self, parent=None, - preSelectedPaths=None, - onlyExpPaths=False, - scanFolderTree=True, - instructionsText='Select experiment folders to analyse', - askSelectPosFolders=False - ): + self, + parent=None, + preSelectedPaths=None, + onlyExpPaths=False, + scanFolderTree=True, + instructionsText="Select experiment folders to analyse", + askSelectPosFolders=False, + ): super().__init__(parent) - + self.cancel = True self.onlyExpPaths = onlyExpPaths - self.setWindowTitle('Select experiments to analyse') + self.setWindowTitle("Select experiments to analyse") self.scanTree = scanFolderTree self.askSelectPosFolders = askSelectPosFolders - + mainLayout = QVBoxLayout() - + instructionsText = html_utils.paragraph( - f'{instructionsText}

' - 'Drag and drop folders or click on Add folder button to ' - 'add as many folders ' - 'as needed.
', font_size='14px' + f"{instructionsText}

" + "Drag and drop folders or click on Add folder button to " + "add as many folders " + "as needed.
", + font_size="14px", ) instructionsLabel = QLabel(instructionsText) instructionsLabel.setAlignment(Qt.AlignCenter) - - infoText = html_utils.paragraph( - 'A valid folder is either a Position folder, ' - 'or an experiment folder (containing Position_n folders),
' - 'or any folder that contains multiple experiment folders.

' - - 'In the last case, Cell-ACDC will automatically scan the entire tree of ' - 'sub-directories
' - 'and will add all experiments having the right folder structure.
', - font_size='12px' + + infoText = html_utils.paragraph( + "A valid folder is either a Position folder, " + "or an experiment folder (containing Position_n folders),
" + "or any folder that contains multiple experiment folders.

" + "In the last case, Cell-ACDC will automatically scan the entire tree of " + "sub-directories
" + "and will add all experiments having the right folder structure.
", + font_size="12px", ) infoLabel = QLabel(infoText) infoLabel.setAlignment(Qt.AlignCenter) - + self.listWidget = widgets.listWidget() self.listWidget.setSelectionMode( QAbstractItemView.SelectionMode.ExtendedSelection ) if preSelectedPaths is not None: self.listWidget.addItems(preSelectedPaths) - + buttonsLayout = widgets.CancelOkButtonsLayout() - delButton = widgets.delPushButton('Remove selected path(s)') + delButton = widgets.delPushButton("Remove selected path(s)") browseButton = widgets.browseFileButton( - 'Add folder...', openFolder=True, - start_dir=myutils.getMostRecentPath() + "Add folder...", openFolder=True, start_dir=myutils.getMostRecentPath() ) - + buttonsLayout.insertWidget(3, delButton) buttonsLayout.insertWidget(4, browseButton) - + buttonsLayout.okButton.clicked.connect(self.ok_cb) browseButton.sigPathSelected.connect(self.addFolderPath) delButton.clicked.connect(self.removePaths) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addWidget(instructionsLabel) mainLayout.addWidget(infoLabel) mainLayout.addWidget(self.listWidget) - + mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) mainLayout.addStretch(1) - + self.setLayout(mainLayout) - + self.setAcceptDrops(True) - + self.setFont(font) - + def dragEnterEvent(self, event): event.acceptProposedAction() - + def dropEvent(self, event): event.setDropAction(Qt.CopyAction) for url in event.mimeData().urls(): dropped_path = url.toLocalFile() if os.path.isfile(dropped_path): dropped_path = os.path.dirname(dropped_path) - + QTimer.singleShot(50, partial(self.addFolderPath, dropped_path)) - + def pathsList(self): return [ - self.listWidget.item(i).text().replace('\\', '/') + self.listWidget.item(i).text().replace("\\", "/") for i in range(self.listWidget.count()) ] - + def expFolderToPosFoldernamesMapper(self): expPathsPosFoldernamesMapper = defaultdict(set) for selectedPath in self.pathsList(): pos_foldernames = myutils.get_pos_foldernames( selectedPath, check_if_is_sub_folder=True ) - if not pos_foldernames: + if not pos_foldernames: images_path = myutils.get_images_folderpath(selectedPath) - expPathsPosFoldernamesMapper[selectedPath].add('') + expPathsPosFoldernamesMapper[selectedPath].add("") else: expPath = load.get_exp_path(selectedPath) expPathsPosFoldernamesMapper[expPath].update(pos_foldernames) - + expPathsPosFoldernamesMapper = { - expPath: natsorted(pos_foldernames) + expPath: natsorted(pos_foldernames) for expPath, pos_foldernames in expPathsPosFoldernamesMapper.items() } return expPathsPosFoldernamesMapper - + def ok_cb(self): self.cancel = False self.paths = self.pathsList() @@ -19359,7 +19258,7 @@ def ok_cb(self): self.expFolderToPosFoldernamesMapper() ) self.close() - + def warnNoValidPathsFound(self, selected_path): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(""" @@ -19371,63 +19270,67 @@ def warnNoValidPathsFound(self, selected_path): Selected path: """) msg.warning( - self, 'Training workflow generated', txt, - commands=(f'{selected_path}',), - path_to_browse=selected_path + self, + "Training workflow generated", + txt, + commands=(f"{selected_path}",), + path_to_browse=selected_path, ) - + def warnNoValidExpPaths(self, selected_path): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(""" The selected folder does not contain any valid experiment folders. """) - command = selected_path.replace('\\', os.sep) - command = selected_path.replace('/', os.sep) + command = selected_path.replace("\\", os.sep) + command = selected_path.replace("/", os.sep) msg.warning( - self, 'No valid folders found', txt, - commands=(command,), - path_to_browse=selected_path + self, + "No valid folders found", + txt, + commands=(command,), + path_to_browse=selected_path, ) - - def parse_select_from_exp_paths( - self, exp_paths: dict[os.PathLike, Iterable[str]] - ): + + def parse_select_from_exp_paths(self, exp_paths: dict[os.PathLike, Iterable[str]]): if not self.askSelectPosFolders: return list(exp_paths.keys()) - + paths = [] for exp_path, pos_foldernames in exp_paths.items(): if len(pos_foldernames) == 1: paths.append(exp_path) continue - + informativeText = html_utils.paragraph( - 'The following experiment folder

' - f'{exp_path}

' - 'contains multiple Position folders.

' - 'Please, select which Position folder(s) you want to analyse:
' + "The following experiment folder

" + f"{exp_path}

" + "contains multiple Position folders.

" + "Please, select which Position folder(s) you want to analyse:
" ) select_folder = load.select_exp_folder() values = select_folder.get_values_dataprep(exp_path) select_folder.QtPrompt( - self, values, toggleMulti=True, + self, + values, + toggleMulti=True, informativeText=informativeText, - selectedValues=values + selectedValues=values, ) if select_folder.cancel: return - + for pos in select_folder.selected_pos: paths.append(os.path.join(exp_path, pos)) - + return paths - + def addFolderPath(self, selected_path): myutils.addToRecentPaths(selected_path) - - folder_type = myutils.determine_folder_type(selected_path) - is_pos_folder, is_images_folder, folder_path = folder_type + + folder_type = myutils.determine_folder_type(selected_path) + is_pos_folder, is_images_folder, folder_path = folder_type if is_pos_folder: paths = [selected_path] elif is_images_folder: @@ -19438,285 +19341,285 @@ def addFolderPath(self, selected_path): if not exp_paths: self.warnNoValidExpPaths(selected_path) return - + paths = self.parse_select_from_exp_paths(exp_paths) if paths is None: return else: paths = [selected_path] - + if not paths: self.warnNoValidPathsFound(selected_path) - + for selectedPath in paths: if self.onlyExpPaths: selectedPath = load.get_exp_path(selectedPath) - - selectedPath = selectedPath.replace('\\', '/') + + selectedPath = selectedPath.replace("\\", "/") if selectedPath in self.pathsList(): print( - f'[WARNING]: The following path was already selected: ' + f"[WARNING]: The following path was already selected: " f'"{selectedPath}"' ) return - + self.listWidget.addItem(selectedPath) - + def removePaths(self): for item in self.listWidget.selectedItems(): row = self.listWidget.row(item) self.listWidget.takeItem(row) + class OverlayLabelsAppearanceDialog(QBaseDialog): sigValuesChanged = Signal(object) - - def __init__(self, scatterPlotItem: pg.ScatterPlotItem=None, parent=None): + + def __init__(self, scatterPlotItem: pg.ScatterPlotItem = None, parent=None): super().__init__(parent) - + self.cancel = True - - self.setWindowTitle('Overlay contours appearance properties') + + self.setWindowTitle("Overlay contours appearance properties") mainLayout = QVBoxLayout() - + formLayout = widgets.FormLayout() - + row = -1 - + row += 1 self.colorButton = widgets.myColorButton(color=(255, 0, 0)) self.colorButton.clicked.disconnect() self.colorButton.clicked.connect(self.selectColor) self.colorButton.setCursor(Qt.PointingHandCursor) self.colorWidget = widgets.formWidget( - self.colorButton, addInfoButton=False, stretchWidget=False, - labelTextLeft='Symbol color: ', parent=self, - widgetAlignment='left' + self.colorButton, + addInfoButton=False, + stretchWidget=False, + labelTextLeft="Symbol color: ", + parent=self, + widgetAlignment="left", ) if scatterPlotItem is not None: - pen = scatterPlotItem.opts['pen'] + pen = scatterPlotItem.opts["pen"] color = pen.color() self.colorButton.setColor(color) formLayout.addFormWidget(self.colorWidget, row=row) - + row += 1 self.penWidthSpinBox = widgets.SpinBox() self.penWidthSpinBox.setMinimum(0) self.penWidthSpinBox.setValue(2) self.penWidthWidget = widgets.formWidget( - self.penWidthSpinBox, addInfoButton=False, stretchWidget=False, - labelTextLeft='Symbol weight: ', parent=self, - widgetAlignment='left' + self.penWidthSpinBox, + addInfoButton=False, + stretchWidget=False, + labelTextLeft="Symbol weight: ", + parent=self, + widgetAlignment="left", ) if scatterPlotItem is not None: - pen = scatterPlotItem.opts['pen'] + pen = scatterPlotItem.opts["pen"] width = pen.width() self.penWidthSpinBox.setValue(width) formLayout.addFormWidget(self.penWidthWidget, row=row) - + row += 1 - self.opacitySlider = widgets.sliderWithSpinBox( - isFloat=True, normalize=True - ) + self.opacitySlider = widgets.sliderWithSpinBox(isFloat=True, normalize=True) self.opacitySlider.setMinimum(0) self.opacitySlider.setMaximum(100) self.opacitySlider.setValue(0.8) self.opacityWidget = widgets.formWidget( - self.opacitySlider, addInfoButton=False, stretchWidget=True, - labelTextLeft='Symbol opacity: ', parent=self + self.opacitySlider, + addInfoButton=False, + stretchWidget=True, + labelTextLeft="Symbol opacity: ", + parent=self, ) if scatterPlotItem is not None: - brush = scatterPlotItem.opts['brush'] + brush = scatterPlotItem.opts["brush"] alpha = brush.color().alpha() - opacity = alpha/255 + opacity = alpha / 255 self.opacitySlider.setValue(opacity) formLayout.addFormWidget(self.opacityWidget, row=row) - + buttonsLayout = widgets.CancelOkButtonsLayout() - + buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addLayout(formLayout) mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) - + self.setLayout(mainLayout) - + def selectColor(self): color = self.colorButton.color() self.colorButton.origColor = color self.colorButton.colorDialog.setCurrentColor(color) - self.colorButton.colorDialog.setWindowFlags( - Qt.Window | Qt.WindowStaysOnTopHint - ) + self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) self.colorButton.colorDialog.open() w = self.width() left = self.pos().x() colorDialogTop = self.colorButton.colorDialog.pos().y() - self.colorButton.colorDialog.move(w+left+10, colorDialogTop) - + self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) + def getBrush(self): r, g, b, _ = self.colorButton.color().getRgb() - alpha = round(self.opacitySlider.value()*255) + alpha = round(self.opacitySlider.value() * 255) brushColor = (r, g, b, alpha) brush = pg.mkBrush(brushColor) return brush - + def getPen(self): color = self.colorButton.color() penWidth = self.penWidthSpinBox.value() if penWidth == 0: return - + pen = pg.mkPen(color, width=penWidth) return pen - + def ok_cb(self): self.cancel = False - self.properties = { - 'brush': self.getBrush(), - 'pen': self.getPen() - } + self.properties = {"brush": self.getBrush(), "pen": self.getPen()} self.close() + class AutoSaveIntervalDialog(QBaseDialog): sigValueChanged = Signal(float, str) - + def __init__(self, parent=None): super().__init__(parent) - + self.cancel = True - - self.setWindowTitle('Change autosave interval') + + self.setWindowTitle("Change autosave interval") mainLayout = QVBoxLayout() - - self.autoSaveIntervalWidget = ( - widgets.AutoSaveIntervalWidget(parent=self) - ) - - mainLayout.addWidget(QLabel('Autosave interval:')) + + self.autoSaveIntervalWidget = widgets.AutoSaveIntervalWidget(parent=self) + + mainLayout.addWidget(QLabel("Autosave interval:")) mainLayout.addWidget(self.autoSaveIntervalWidget) - + buttonsLayout = widgets.CancelOkButtonsLayout() - + buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) - + self.setLayout(mainLayout) - + def setValues(self, autoSaveIntevalValue, autoSaveIntervalUnit): self.autoSaveIntervalWidget.spinbox.setValue(autoSaveIntevalValue) - self.autoSaveIntervalWidget.unitCombobox.setCurrentText( - autoSaveIntervalUnit - ) - + self.autoSaveIntervalWidget.unitCombobox.setCurrentText(autoSaveIntervalUnit) + def sizeHint(self): defaultWidth = super().sizeHint().width() defaultHeight = super().sizeHint().height() - return QSize(defaultWidth*2, defaultHeight) - + return QSize(defaultWidth * 2, defaultHeight) + def ok_cb(self): self.cancel = False self.sigValueChanged.emit( - self.autoSaveIntervalWidget.spinbox.value(), - self.autoSaveIntervalWidget.unitCombobox.currentText() + self.autoSaveIntervalWidget.spinbox.value(), + self.autoSaveIntervalWidget.unitCombobox.currentText(), ) self.close() + class TestSegmModelInitalDialog(QBaseDialog): def __init__(self, parent=None): super().__init__(parent) - + self.cancel = True - + mainLayout = QVBoxLayout() entriesLayout = widgets.FormLayout() - + row = 0 self.startFrameNumberSpinbox = widgets.SpinBox() self.startFrameNumberSpinbox.setMinimum(1) - + self.startFrameNumberFormWidget = widgets.formWidget( - self.startFrameNumberSpinbox, - labelTextLeft='Start frame number', - addActivateCheckbox=True + self.startFrameNumberSpinbox, + labelTextLeft="Start frame number", + addActivateCheckbox=True, ) entriesLayout.addFormWidget(self.startFrameNumberFormWidget, row=row) - + row += 1 self.stopFrameNumberSpinbox = widgets.SpinBox() self.stopFrameNumberSpinbox.setMinimum(1) - + self.stopFrameNumberFormWidget = widgets.formWidget( - self.stopFrameNumberSpinbox, - labelTextLeft='Stop frame number', - addActivateCheckbox=True + self.stopFrameNumberSpinbox, + labelTextLeft="Stop frame number", + addActivateCheckbox=True, ) entriesLayout.addFormWidget(self.stopFrameNumberFormWidget, row=row) - + row += 1 self.startZsliceNumberSpinbox = widgets.SpinBox() self.startZsliceNumberSpinbox.setMinimum(1) - + self.startZsliceNumberFormWidget = widgets.formWidget( - self.startZsliceNumberSpinbox, - labelTextLeft='Start z-slice number', - addActivateCheckbox=True + self.startZsliceNumberSpinbox, + labelTextLeft="Start z-slice number", + addActivateCheckbox=True, ) entriesLayout.addFormWidget(self.startZsliceNumberFormWidget, row=row) - + row += 1 self.stopZsliceNumberSpinbox = widgets.SpinBox() self.stopZsliceNumberSpinbox.setMinimum(1) - + self.stopZsliceNumberFormWidget = widgets.formWidget( - self.stopZsliceNumberSpinbox, - labelTextLeft='Stop z-slice number', - addActivateCheckbox=True + self.stopZsliceNumberSpinbox, + labelTextLeft="Stop z-slice number", + addActivateCheckbox=True, ) entriesLayout.addFormWidget(self.stopZsliceNumberFormWidget, row=row) - + row += 1 - + self.isTimelapseToggleFormWidget = widgets.formWidget( - widgets.Toggle(), - labelTextLeft='Is timelapse?', + widgets.Toggle(), + labelTextLeft="Is timelapse?", stretchWidget=False, - valueGetterName='isChecked' + valueGetterName="isChecked", ) entriesLayout.addFormWidget(self.isTimelapseToggleFormWidget, row=row) - - + # self.stopFrameNumberSpinbox # self.startZsliceNumberSpinbox # self.stopZsliceNumberSpinbox # self.isTimelapseToggle - + buttonsLayout = widgets.CancelOkButtonsLayout() buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) - + mainLayout.addLayout(entriesLayout) mainLayout.addSpacing(20) mainLayout.addLayout(buttonsLayout) - + self.setLayout(mainLayout) - + def ok_cb(self): self.cancel = False - + self.start_frame_n = self.startFrameNumberFormWidget.value() self.stop_frame_n = self.stopFrameNumberFormWidget.value() self.start_z_slice_n = self.startZsliceNumberFormWidget.value() self.stop_z_slice_n = self.stopZsliceNumberFormWidget.value() self.is_timelapse = self.isTimelapseToggleFormWidget.value() - - self.close() \ No newline at end of file + + self.close() diff --git a/cellacdc/autopilot.py b/cellacdc/autopilot.py index 2c44fd7e6..a5453ecfa 100644 --- a/cellacdc/autopilot.py +++ b/cellacdc/autopilot.py @@ -1,68 +1,79 @@ import os -from qtpy.QtCore import ( - QTimer, QThread, Signal, QObject -) +from qtpy.QtCore import QTimer, QThread, Signal, QObject from . import load, printl, myutils + class AutoPilotProfile: def __init__(self): self.lastLoadingProfile = [] def storeSelectedChannel(self, user_channel): - self.lastLoadingProfile.append({ - 'windowTitle': 'Select channel name', - 'windowActions': ('ComboBox.setCurrentText', 'ok_cb'), - 'windowActionsArgs': ((user_channel,), tuple()) - }) + self.lastLoadingProfile.append( + { + "windowTitle": "Select channel name", + "windowActions": ("ComboBox.setCurrentText", "ok_cb"), + "windowActionsArgs": ((user_channel,), tuple()), + } + ) def storeSelectedSegmFile(self, selectedSegmEndName): - self.lastLoadingProfile.append({ - 'windowTitle': 'Multiple segm.npz files detected', - 'windowActions': ('listWidget.setSelectedItemFromText', 'ok_cb'), - 'windowActionsArgs': ((selectedSegmEndName,), tuple()) - }) - + self.lastLoadingProfile.append( + { + "windowTitle": "Multiple segm.npz files detected", + "windowActions": ("listWidget.setSelectedItemFromText", "ok_cb"), + "windowActionsArgs": ((selectedSegmEndName,), tuple()), + } + ) + def storeOkAskInputMetadata(self): - self.lastLoadingProfile.append({ - 'windowTitle': 'Image properties', - 'windowActions': ('ok_cb',), - 'windowActionsArgs': (tuple(),) - }) - + self.lastLoadingProfile.append( + { + "windowTitle": "Image properties", + "windowActions": ("ok_cb",), + "windowActionsArgs": (tuple(),), + } + ) + def storeLoadSavedData(self): - self.lastLoadingProfile.append({ - 'windowTitle': 'Recover unsaved data?', - 'windowActions': ('clickButtonFromText',), - 'windowActionsArgs': (('Load saved data',),) - }) - + self.lastLoadingProfile.append( + { + "windowTitle": "Recover unsaved data?", + "windowActions": ("clickButtonFromText",), + "windowActionsArgs": (("Load saved data",),), + } + ) + def storeClickMessageBox(self, windowTitle, buttonTextToClick): - self.lastLoadingProfile.append({ - 'windowTitle': windowTitle, - 'windowActions': ('clickButtonFromText',), - 'windowActionsArgs': ((buttonTextToClick,),) - }) - + self.lastLoadingProfile.append( + { + "windowTitle": windowTitle, + "windowActions": ("clickButtonFromText",), + "windowActionsArgs": ((buttonTextToClick,),), + } + ) + def storeLoadedFluoChannels(self, loadedChannels): - self.lastLoadingProfile.append({ - 'windowTitle': 'Select channel to load', - 'windowActions': ('setSelectedItems', 'ok_cb'), - 'windowActionsArgs': ((loadedChannels,), tuple()) - }) - + self.lastLoadingProfile.append( + { + "windowTitle": "Select channel to load", + "windowActions": ("setSelectedItems", "ok_cb"), + "windowActionsArgs": ((loadedChannels,), tuple()), + } + ) + def getCopy(self): return self.lastLoadingProfile.copy() -class AutoPilot: +class AutoPilot: def __init__(self, parentWin) -> None: self.parentWin = parentWin self.app = parentWin.app self.isFinished = True self.loadingProfile = parentWin.AutoPilotProfile.getCopy() - + def _askSelectPos(self): posData = self.parentWin.data[self.parentWin.pos_i] exp_path = posData.exp_path @@ -74,16 +85,16 @@ def _askSelectPos(self): select_folder.QtPrompt(self.parentWin, values, allowMultiSelection=False) if select_folder.cancel: return - + posPath = os.path.join(exp_path, select_folder.selected_pos[0]) return posPath def execLoadPos(self): posPath = self._askSelectPos() if posPath is None: - self.parentWin.logger.info('Loading Position cancelled.') + self.parentWin.logger.info("Loading Position cancelled.") return - + self.isFinished = False self.timer = QTimer() self.timer.timeout.connect(self.loadPosTimerCallback) @@ -96,8 +107,8 @@ def loadPosTimerCallback(self): if not self.loadingProfile: self.timer.stop() return - - windowTitle = self.loadingProfile[0]['windowTitle'] + + windowTitle = self.loadingProfile[0]["windowTitle"] for window in openWindows: if not window.windowTitle(): continue @@ -105,13 +116,12 @@ def loadPosTimerCallback(self): continue if not windowTitle == window.windowTitle(): continue - - windowActions = self.loadingProfile[0]['windowActions'] - windowActionsArgs = self.loadingProfile[0]['windowActionsArgs'] + + windowActions = self.loadingProfile[0]["windowActions"] + windowActionsArgs = self.loadingProfile[0]["windowActionsArgs"] for action, args in zip(windowActions, windowActionsArgs): func = myutils.get_chained_attr(window, action) func(*args) - + self.loadingProfile.pop(0) break - \ No newline at end of file diff --git a/cellacdc/bioformats/__init__.py b/cellacdc/bioformats/__init__.py index 920de1d63..7944d6b6c 100755 --- a/cellacdc/bioformats/__init__.py +++ b/cellacdc/bioformats/__init__.py @@ -5,9 +5,7 @@ # Copyright (c) 2009-2014 Broad Institute # All rights reserved. -'''Bioformats package - wrapper for loci.bioformats java code - -''' +"""Bioformats package - wrapper for loci.bioformats java code""" from __future__ import absolute_import, unicode_literals @@ -22,50 +20,228 @@ from . import formatreader as _formatreader from . import formatwriter as _formatwriter -_jars_dir = os.path.join(os.path.dirname(__file__), 'jars') +_jars_dir = os.path.join(os.path.dirname(__file__), "jars") -JAR_VERSION = '6.5.1' +JAR_VERSION = "6.5.1" -JARS = javabridge.JARS + [os.path.realpath(os.path.join(_jars_dir, name + '.jar')) - for name in ['bioformats_package']] +JARS = javabridge.JARS + [ + os.path.realpath(os.path.join(_jars_dir, name + ".jar")) + for name in ["bioformats_package"] +] """List of directories, jar files, and zip files that should be added to the Java virtual machine's class path.""" # See http://www.loci.wisc.edu/software/bio-formats -READABLE_FORMATS = ('1sc', '2fl', 'acff', 'afi', 'afm', 'aim', 'al3d', 'ali', - 'am', 'amiramesh', 'apl', 'arf', 'avi', 'bif', 'bin', 'bip', - 'bmp', 'btf', 'c01', 'cfg', 'ch5', 'cif', 'cr2', 'crw', - 'cxd', 'czi', 'dat', 'dcm', 'dib', 'dicom', 'dm2', 'dm3', - 'dm4', 'dti', 'dv', 'eps', 'epsi', 'exp', 'fdf', 'fff', - 'ffr', 'fits', 'flex', 'fli', 'frm', 'gel', 'gif', 'grey', - 'h5', 'hdf', 'hdr', 'hed', 'his', 'htd', 'html', 'hx', 'i2i', - 'ics', 'ids', 'im3', 'img', 'ims', 'inr', 'ipl', 'ipm', 'ipw', - 'j2k', 'jp2', 'jpeg', 'jpf', 'jpg', 'jpk', 'jpx', 'klb', - 'l2d', 'labels', 'lei', 'lif', 'liff', 'lim', 'lms', 'lsm', - 'map', 'mdb', 'mea', 'mnc', 'mng', 'mod', 'mov', 'mrc', 'mrcs', - 'mrw', 'msr', 'mtb', 'mvd2', 'naf', 'nd', 'nd2', 'ndpi', 'ndpis', - 'nef', 'nhdr', 'nii', 'nii.gz', 'nrrd', 'obf', 'obsep', 'oib', - 'oif', 'oir', 'ome', 'ome.btf', 'ome.tf2', 'ome.tf8', 'ome.tif', - 'ome.tiff', 'ome.xml', 'par', 'pbm', 'pcoraw', 'pcx', 'pds', - 'pgm', 'pic', 'pict', 'png', 'pnl', 'ppm', 'pr3', 'ps', 'psd', - 'qptiff', 'r3d', 'raw', 'rcpnl', 'rec', 'res', 'scn', 'sdt', - 'seq', 'sif', 'sld', 'sm2', 'sm3', 'spc', 'spe', 'spi', 'st', - 'stk', 'stp', 'svs', 'sxm', 'tc.', 'tf2', 'tf8', 'tfr', 'tga', - 'tif', 'tiff', 'tnb', 'top', 'txt', 'v', 'vff', 'vms', 'vsi', - 'vws', 'wat', 'wlz', 'wpi', 'xdce', 'xml', 'xqd', 'xqf', 'xv', - 'xys', 'zfp', 'zfr', 'zvi') - -WRITABLE_FORMATS = ('avi', 'eps', 'epsi', 'ics', 'ids', 'jp2', 'jpeg', 'jpg', - 'mov', 'ome', 'ome.tiff', 'png', 'ps', 'tif', 'tiff') +READABLE_FORMATS = ( + "1sc", + "2fl", + "acff", + "afi", + "afm", + "aim", + "al3d", + "ali", + "am", + "amiramesh", + "apl", + "arf", + "avi", + "bif", + "bin", + "bip", + "bmp", + "btf", + "c01", + "cfg", + "ch5", + "cif", + "cr2", + "crw", + "cxd", + "czi", + "dat", + "dcm", + "dib", + "dicom", + "dm2", + "dm3", + "dm4", + "dti", + "dv", + "eps", + "epsi", + "exp", + "fdf", + "fff", + "ffr", + "fits", + "flex", + "fli", + "frm", + "gel", + "gif", + "grey", + "h5", + "hdf", + "hdr", + "hed", + "his", + "htd", + "html", + "hx", + "i2i", + "ics", + "ids", + "im3", + "img", + "ims", + "inr", + "ipl", + "ipm", + "ipw", + "j2k", + "jp2", + "jpeg", + "jpf", + "jpg", + "jpk", + "jpx", + "klb", + "l2d", + "labels", + "lei", + "lif", + "liff", + "lim", + "lms", + "lsm", + "map", + "mdb", + "mea", + "mnc", + "mng", + "mod", + "mov", + "mrc", + "mrcs", + "mrw", + "msr", + "mtb", + "mvd2", + "naf", + "nd", + "nd2", + "ndpi", + "ndpis", + "nef", + "nhdr", + "nii", + "nii.gz", + "nrrd", + "obf", + "obsep", + "oib", + "oif", + "oir", + "ome", + "ome.btf", + "ome.tf2", + "ome.tf8", + "ome.tif", + "ome.tiff", + "ome.xml", + "par", + "pbm", + "pcoraw", + "pcx", + "pds", + "pgm", + "pic", + "pict", + "png", + "pnl", + "ppm", + "pr3", + "ps", + "psd", + "qptiff", + "r3d", + "raw", + "rcpnl", + "rec", + "res", + "scn", + "sdt", + "seq", + "sif", + "sld", + "sm2", + "sm3", + "spc", + "spe", + "spi", + "st", + "stk", + "stp", + "svs", + "sxm", + "tc.", + "tf2", + "tf8", + "tfr", + "tga", + "tif", + "tiff", + "tnb", + "top", + "txt", + "v", + "vff", + "vms", + "vsi", + "vws", + "wat", + "wlz", + "wpi", + "xdce", + "xml", + "xqd", + "xqf", + "xv", + "xys", + "zfp", + "zfr", + "zvi", +) + +WRITABLE_FORMATS = ( + "avi", + "eps", + "epsi", + "ics", + "ids", + "jp2", + "jpeg", + "jpg", + "mov", + "ome", + "ome.tiff", + "png", + "ps", + "tif", + "tiff", +) OMETiffWriter = _formatwriter.make_ome_tiff_writer_class() ChannelSeparator = _formatreader.make_reader_wrapper_class( - "loci/formats/ChannelSeparator") + "loci/formats/ChannelSeparator" +) from .metadatatools import createOMEXMLMetadata as create_ome_xml_metadata from .metadatatools import wrap_imetadata_object from . import metadatatools as _metadatatools + PixelType = _metadatatools.make_pixel_type_class() get_metadata_options = _metadatatools.get_metadata_options @@ -84,6 +260,7 @@ # Metadata from .omexml import OMEXML + get_omexml_metadata = _formatreader.get_omexml_metadata # Writing images @@ -94,10 +271,20 @@ # Omero -from .formatreader import use_omero_credentials, set_omero_credentials, get_omero_credentials +from .formatreader import ( + use_omero_credentials, + set_omero_credentials, + get_omero_credentials, +) from .formatreader import set_omero_login_hook, omero_logout, has_omero_packages -from .formatreader import K_OMERO_SERVER, K_OMERO_PORT, K_OMERO_USER, K_OMERO_SESSION_ID,\ - K_OMERO_PASSWORD, K_OMERO_CONFIG_FILE +from .formatreader import ( + K_OMERO_SERVER, + K_OMERO_PORT, + K_OMERO_USER, + K_OMERO_SESSION_ID, + K_OMERO_PASSWORD, + K_OMERO_CONFIG_FILE, +) from . import omexml diff --git a/cellacdc/bioformats/formatreader.py b/cellacdc/bioformats/formatreader.py index a63d5b2c0..73ad66c10 100755 --- a/cellacdc/bioformats/formatreader.py +++ b/cellacdc/bioformats/formatreader.py @@ -5,7 +5,7 @@ # Copyright (c) 2009-2014 Broad Institute # All rights reserved. -'''formatreader.py - mechanism to wrap a bioformats ReaderWrapper and ImageReader +"""formatreader.py - mechanism to wrap a bioformats ReaderWrapper and ImageReader Example: import bioformats.formatreader as biordr @@ -20,13 +20,14 @@ my_red_image, my_green_image, my_blue_image = \ [cs.open_bytes(cs.getIndex(0,i,0)) for i in range(3)] -''' +""" from __future__ import absolute_import, unicode_literals __version__ = "$Revision$" import logging + logger = logging.getLogger(__name__) import errno import numpy as np @@ -40,6 +41,7 @@ else: from urllib import url2pathname from urllib2 import urlopen, urlparse, unquote + urlparse = urlparse.urlparse import shutil @@ -56,6 +58,7 @@ try: from omero_reader import OmeroReader, OMERO_IMPORTED from omero_reader.utils import omero_reader_enabled + OMERO_READER_IMPORTED = True except ImportError: pass @@ -65,162 +68,221 @@ K_OMERO_USER = "omero_user" K_OMERO_SESSION_ID = "omero_session_id" K_OMERO_CONFIG_FILE = "omero_config_file" -'''The cleartext password - only used if password is provided on command-line''' +"""The cleartext password - only used if password is provided on command-line""" K_OMERO_PASSWORD = "omero_password" + def make_format_tools_class(): - '''Get a wrapper for the loci/formats/FormatTools class + """Get a wrapper for the loci/formats/FormatTools class The FormatTools class has many of the constants needed by other classes as statics. - ''' + """ + class FormatTools(object): - '''A wrapper for loci.formats.FormatTools + """A wrapper for loci.formats.FormatTools See http://hudson.openmicroscopy.org.uk/job/LOCI/javadoc/loci/formats/FormatTools.html - ''' + """ + env = jutil.get_env() - klass = env.find_class('loci/formats/FormatTools') - CAN_GROUP = jutil.get_static_field(klass, 'CAN_GROUP','I') - CANNOT_GROUP = jutil.get_static_field(klass, 'CANNOT_GROUP','I') - DOUBLE = jutil.get_static_field(klass, 'DOUBLE','I') - FLOAT = jutil.get_static_field(klass, 'FLOAT', 'I') - INT16 = jutil.get_static_field(klass, 'INT16', 'I') - INT32 = jutil.get_static_field(klass, 'INT32', 'I') - INT8 = jutil.get_static_field(klass, 'INT8', 'I') - MUST_GROUP = jutil.get_static_field(klass, 'MUST_GROUP', 'I') - UINT16 = jutil.get_static_field(klass, 'UINT16', 'I') - UINT32 = jutil.get_static_field(klass, 'UINT32', 'I') - UINT8 = jutil.get_static_field(klass, 'UINT8', 'I') + klass = env.find_class("loci/formats/FormatTools") + CAN_GROUP = jutil.get_static_field(klass, "CAN_GROUP", "I") + CANNOT_GROUP = jutil.get_static_field(klass, "CANNOT_GROUP", "I") + DOUBLE = jutil.get_static_field(klass, "DOUBLE", "I") + FLOAT = jutil.get_static_field(klass, "FLOAT", "I") + INT16 = jutil.get_static_field(klass, "INT16", "I") + INT32 = jutil.get_static_field(klass, "INT32", "I") + INT8 = jutil.get_static_field(klass, "INT8", "I") + MUST_GROUP = jutil.get_static_field(klass, "MUST_GROUP", "I") + UINT16 = jutil.get_static_field(klass, "UINT16", "I") + UINT32 = jutil.get_static_field(klass, "UINT32", "I") + UINT8 = jutil.get_static_field(klass, "UINT8", "I") @classmethod def getPixelTypeString(cls, pixel_type): - return jutil.static_call('loci/formats/FormatTools', 'getPixelTypeString', '(I)Ljava/lang/String;', pixel_type) + return jutil.static_call( + "loci/formats/FormatTools", + "getPixelTypeString", + "(I)Ljava/lang/String;", + pixel_type, + ) return FormatTools + def make_iformat_reader_class(): - '''Bind a Java class that implements IFormatReader to a Python class + """Bind a Java class that implements IFormatReader to a Python class Returns a class that implements IFormatReader through calls to the implemented class passed in. The returned class can be subclassed to provide additional bindings. - ''' + """ + class IFormatReader(object): - '''A wrapper for loci.formats.IFormatReader + """A wrapper for loci.formats.IFormatReader See http://hudson.openmicroscopy.org.uk/job/LOCI/javadoc/loci/formats/ImageReader.html - ''' - close = jutil.make_method('close','()V', - 'Close the currently open file and free memory') - getDimensionOrder = jutil.make_method('getDimensionOrder', - '()Ljava/lang/String;', - 'Return the dimension order as a five-character string, e.g. "XYCZT"') - getGlobalMetadata = jutil.make_method('getGlobalMetadata', - '()Ljava/util/Hashtable;', - 'Obtains the hashtable containing the global metadata field/value pairs') + """ + + close = jutil.make_method( + "close", "()V", "Close the currently open file and free memory" + ) + getDimensionOrder = jutil.make_method( + "getDimensionOrder", + "()Ljava/lang/String;", + 'Return the dimension order as a five-character string, e.g. "XYCZT"', + ) + getGlobalMetadata = jutil.make_method( + "getGlobalMetadata", + "()Ljava/util/Hashtable;", + "Obtains the hashtable containing the global metadata field/value pairs", + ) getMetadata = getGlobalMetadata - getMetadataValue = jutil.make_method('getMetadataValue', - '(Ljava/lang/String;)' - 'Ljava/lang/Object;', - 'Look up a specific metadata value from the store') - getSeriesMetadata = jutil.make_method('getSeriesMetadata', - '()Ljava/util/Hashtable;', - 'Obtains the hashtable contaning the series metadata field/value pairs') - getSeriesCount = jutil.make_method('getSeriesCount', - '()I', - 'Return the # of image series in the file') - getSeries = jutil.make_method('getSeries', '()I', - 'Return the currently selected image series') - getImageCount = jutil.make_method('getImageCount', - '()I','Determines the number of images in the current file') - getIndex = jutil.make_method('getIndex', '(III)I', - 'Get the plane index given z, c, t') - getRGBChannelCount = jutil.make_method('getRGBChannelCount', - '()I','Gets the number of channels per RGB image (if not RGB, this returns 1') - getSizeC = jutil.make_method('getSizeC', '()I', - 'Get the number of color planes') - getSizeT = jutil.make_method('getSizeT', '()I', - 'Get the number of frames in the image') - getSizeX = jutil.make_method('getSizeX', '()I', - 'Get the image width') - getSizeY = jutil.make_method('getSizeY', '()I', - 'Get the image height') - getSizeZ = jutil.make_method('getSizeZ', '()I', - 'Get the image depth') - getPixelType = jutil.make_method('getPixelType', '()I', - 'Get the pixel type: see FormatTools for types') - isLittleEndian = jutil.make_method('isLittleEndian', - '()Z','Return True if the data is in little endian order') - isRGB = jutil.make_method('isRGB', '()Z', - 'Return True if images in the file are RGB') - isInterleaved = jutil.make_method('isInterleaved', '()Z', - 'Return True if image colors are interleaved within a plane') - isIndexed = jutil.make_method('isIndexed', '()Z', - 'Return True if the raw data is indexes in a lookup table') - openBytes = jutil.make_method('openBytes','(I)[B', - 'Get the specified image plane as a byte array') - openBytesXYWH = jutil.make_method('openBytes','(IIIII)[B', - '''Get the specified image plane as a byte array + getMetadataValue = jutil.make_method( + "getMetadataValue", + "(Ljava/lang/String;)Ljava/lang/Object;", + "Look up a specific metadata value from the store", + ) + getSeriesMetadata = jutil.make_method( + "getSeriesMetadata", + "()Ljava/util/Hashtable;", + "Obtains the hashtable contaning the series metadata field/value pairs", + ) + getSeriesCount = jutil.make_method( + "getSeriesCount", "()I", "Return the # of image series in the file" + ) + getSeries = jutil.make_method( + "getSeries", "()I", "Return the currently selected image series" + ) + getImageCount = jutil.make_method( + "getImageCount", + "()I", + "Determines the number of images in the current file", + ) + getIndex = jutil.make_method( + "getIndex", "(III)I", "Get the plane index given z, c, t" + ) + getRGBChannelCount = jutil.make_method( + "getRGBChannelCount", + "()I", + "Gets the number of channels per RGB image (if not RGB, this returns 1", + ) + getSizeC = jutil.make_method( + "getSizeC", "()I", "Get the number of color planes" + ) + getSizeT = jutil.make_method( + "getSizeT", "()I", "Get the number of frames in the image" + ) + getSizeX = jutil.make_method("getSizeX", "()I", "Get the image width") + getSizeY = jutil.make_method("getSizeY", "()I", "Get the image height") + getSizeZ = jutil.make_method("getSizeZ", "()I", "Get the image depth") + getPixelType = jutil.make_method( + "getPixelType", "()I", "Get the pixel type: see FormatTools for types" + ) + isLittleEndian = jutil.make_method( + "isLittleEndian", "()Z", "Return True if the data is in little endian order" + ) + isRGB = jutil.make_method( + "isRGB", "()Z", "Return True if images in the file are RGB" + ) + isInterleaved = jutil.make_method( + "isInterleaved", + "()Z", + "Return True if image colors are interleaved within a plane", + ) + isIndexed = jutil.make_method( + "isIndexed", + "()Z", + "Return True if the raw data is indexes in a lookup table", + ) + openBytes = jutil.make_method( + "openBytes", "(I)[B", "Get the specified image plane as a byte array" + ) + openBytesXYWH = jutil.make_method( + "openBytes", + "(IIIII)[B", + """Get the specified image plane as a byte array (corresponds to openBytes(int no, int x, int y, int w, int h)) no - image plane number x,y - offset into image - w,h - dimensions of image to return''') - setSeries = jutil.make_method('setSeries','(I)V','Set the currently selected image series') - setGroupFiles = jutil.make_method('setGroupFiles', '(Z)V', - 'Force reader to group or not to group files in a multi-file set') - setMetadataStore = jutil.make_method('setMetadataStore', - '(Lloci/formats/meta/MetadataStore;)V', - 'Sets the default metadata store for this reader.') - setMetadataOptions = jutil.make_method('setMetadataOptions', - '(Lloci/formats/in/MetadataOptions;)V', - 'Sets the metadata options used when reading metadata') + w,h - dimensions of image to return""", + ) + setSeries = jutil.make_method( + "setSeries", "(I)V", "Set the currently selected image series" + ) + setGroupFiles = jutil.make_method( + "setGroupFiles", + "(Z)V", + "Force reader to group or not to group files in a multi-file set", + ) + setMetadataStore = jutil.make_method( + "setMetadataStore", + "(Lloci/formats/meta/MetadataStore;)V", + "Sets the default metadata store for this reader.", + ) + setMetadataOptions = jutil.make_method( + "setMetadataOptions", + "(Lloci/formats/in/MetadataOptions;)V", + "Sets the metadata options used when reading metadata", + ) isThisTypeS = jutil.make_method( - 'isThisType', - '(Ljava/lang/String;)Z', - 'Return true if the filename might be handled by this reader') + "isThisType", + "(Ljava/lang/String;)Z", + "Return true if the filename might be handled by this reader", + ) isThisTypeSZ = jutil.make_method( - 'isThisType', - '(Ljava/lang/String;Z)Z', - '''Return true if the named file is handled by this reader. + "isThisType", + "(Ljava/lang/String;Z)Z", + """Return true if the named file is handled by this reader. filename - name of file allowOpen - True if the reader is allowed to open files when making its determination - ''') + """, + ) isThisTypeStream = jutil.make_method( - 'isThisType', - '(Lloci/common/RandomAccessInputStream;)Z', - '''Return true if the stream might be parseable by this reader. + "isThisType", + "(Lloci/common/RandomAccessInputStream;)Z", + """Return true if the stream might be parseable by this reader. stream - the RandomAccessInputStream to be used to read the file contents Note that both isThisTypeS and isThisTypeStream must return true - for the type to truly be handled.''') - def setId(self, path): - '''Set the name of the file''' - jutil.call(self.o, 'setId', - '(Ljava/lang/String;)V', - path) + for the type to truly be handled.""", + ) - getMetadataStore = jutil.make_method('getMetadataStore', '()Lloci/formats/meta/MetadataStore;', - 'Retrieves the current metadata store for this reader.') + def setId(self, path): + """Set the name of the file""" + jutil.call(self.o, "setId", "(Ljava/lang/String;)V", path) + + getMetadataStore = jutil.make_method( + "getMetadataStore", + "()Lloci/formats/meta/MetadataStore;", + "Retrieves the current metadata store for this reader.", + ) get8BitLookupTable = jutil.make_method( - 'get8BitLookupTable', - '()[[B', 'Get a lookup table for 8-bit indexed images') + "get8BitLookupTable", "()[[B", "Get a lookup table for 8-bit indexed images" + ) get16BitLookupTable = jutil.make_method( - 'get16BitLookupTable', - '()[[S', 'Get a lookup table for 16-bit indexed images') + "get16BitLookupTable", + "()[[S", + "Get a lookup table for 16-bit indexed images", + ) + def get_class_name(self): - return jutil.call(jutil.call(self.o, 'getClass', '()Ljava/lang/Class;'), - 'getName', '()Ljava/lang/String;') + return jutil.call( + jutil.call(self.o, "getClass", "()Ljava/lang/Class;"), + "getName", + "()Ljava/lang/String;", + ) @property def suffixNecessary(self): - if self.get_class_name() == 'loci.formats.in.JPKReader': - return True; + if self.get_class_name() == "loci.formats.in.JPKReader": + return True env = jutil.get_env() klass = env.get_object_class(self.o) field_id = env.get_field_id(klass, "suffixNecessary", "Z") @@ -230,8 +292,8 @@ def suffixNecessary(self): @property def suffixSufficient(self): - if self.get_class_name() == 'loci.formats.in.JPKReader': - return True; + if self.get_class_name() == "loci.formats.in.JPKReader": + return True env = jutil.get_env() klass = env.get_object_class(self.o) field_id = env.get_field_id(klass, "suffixSufficient", "Z") @@ -239,94 +301,109 @@ def suffixSufficient(self): return None return env.get_boolean_field(self.o, field_id) - return IFormatReader + def get_class_list(): - '''Return a wrapped instance of loci.formats.ClassList''' + """Return a wrapped instance of loci.formats.ClassList""" + # # This uses the reader.txt file from inside the loci_tools.jar # class ClassList(object): remove_class = jutil.make_method( - 'removeClass', '(Ljava/lang/Class;)V', - 'Remove the given class from the class list') + "removeClass", + "(Ljava/lang/Class;)V", + "Remove the given class from the class list", + ) add_class = jutil.make_method( - 'addClass', '(Ljava/lang/Class;)V', - 'Add the given class to the back of the class list') + "addClass", + "(Ljava/lang/Class;)V", + "Add the given class to the back of the class list", + ) get_classes = jutil.make_method( - 'getClasses', '()[Ljava/lang/Class;', - 'Get the classes in the list as an array') + "getClasses", + "()[Ljava/lang/Class;", + "Get the classes in the list as an array", + ) def __init__(self): env = jutil.get_env() - class_name = 'loci/formats/ImageReader' + class_name = "loci/formats/ImageReader" klass = env.find_class(class_name) - base_klass = env.find_class('loci/formats/IFormatReader') - self.o = jutil.make_instance("loci/formats/ClassList", - "(Ljava/lang/String;" - "Ljava/lang/Class;" # base - "Ljava/lang/Class;)V", # location in jar - "readers.txt", base_klass, klass) + base_klass = env.find_class("loci/formats/IFormatReader") + self.o = jutil.make_instance( + "loci/formats/ClassList", + "(Ljava/lang/String;" + "Ljava/lang/Class;" # base + "Ljava/lang/Class;)V", # location in jar + "readers.txt", + base_klass, + klass, + ) problem_classes = [ # BDReader will read all .tif files in an experiment if it's # called to load a .tif. # - 'loci.formats.in.BDReader', + "loci.formats.in.BDReader", # # MRCReader will read .stk files which should be read # by MetamorphReader # - 'loci.formats.in.MRCReader' - ] + "loci.formats.in.MRCReader", + ] for problem_class in problem_classes: # Move to back klass = jutil.class_for_name(problem_class) self.remove_class(klass) self.add_class(klass) + return ClassList() def make_image_reader_class(): - '''Return an image reader class for the given Java environment''' + """Return an image reader class for the given Java environment""" env = jutil.get_env() - class_name = 'loci/formats/ImageReader' + class_name = "loci/formats/ImageReader" klass = env.find_class(class_name) - base_klass = env.find_class('loci/formats/IFormatReader') + base_klass = env.find_class("loci/formats/IFormatReader") IFormatReader = make_iformat_reader_class() class_list = get_class_list() class ImageReader(IFormatReader): - new_fn = jutil.make_new(class_name, '(Lloci/formats/ClassList;)V') + new_fn = jutil.make_new(class_name, "(Lloci/formats/ClassList;)V") + def __init__(self): self.new_fn(class_list.o) - getFormat = jutil.make_method('getFormat', - '()Ljava/lang/String;', - 'Get a string describing the format of this file') - getReader = jutil.make_method('getReader', - '()Lloci/formats/IFormatReader;') + + getFormat = jutil.make_method( + "getFormat", + "()Ljava/lang/String;", + "Get a string describing the format of this file", + ) + getReader = jutil.make_method("getReader", "()Lloci/formats/IFormatReader;") + def allowOpenToCheckType(self, allow): - '''Allow the "isThisType" function to open files + """Allow the "isThisType" function to open files For the cluster, you want to tell potential file formats not to open the image file to test if it's their format. - ''' + """ if not hasattr(self, "allowOpenToCheckType_method"): self.allowOpenToCheckType_method = None class_wrapper = jutil.get_class_wrapper(self.o) methods = class_wrapper.getMethods() for method in jutil.get_env().get_object_array_elements(methods): m = jutil.get_method_wrapper(method) - if m.getName() in ('allowOpenToCheckType', 'setAllowOpenFiles'): + if m.getName() in ("allowOpenToCheckType", "setAllowOpenFiles"): self.allowOpenToCheckType_method = m if self.allowOpenToCheckType_method is not None: - object_class = env.find_class('java/lang/Object') + object_class = env.find_class("java/lang/Object") jexception = jutil.get_env().exception_occurred() if jexception is not None: raise jutil.JavaException(jexception) - boolean_value = jutil.make_instance('java/lang/Boolean', - '(Z)V', allow) + boolean_value = jutil.make_instance("java/lang/Boolean", "(Z)V", allow) args = jutil.get_env().make_object_array(1, object_class) jexception = jutil.get_env().exception_occurred() if jexception is not None: @@ -336,51 +413,68 @@ def allowOpenToCheckType(self, allow): if jexception is not None: raise jutil.JavaException(jexception) self.allowOpenToCheckType_method.invoke(self.o, args) + return ImageReader def make_reader_wrapper_class(class_name): - '''Make an ImageReader wrapper class + """Make an ImageReader wrapper class class_name - the name of the wrapper class, for instance, "loci/formats/ChannelSeparator" You can instantiate an instance of the wrapper class like this: rdr = ChannelSeparator(ImageReader()) - ''' + """ IFormatReader = make_iformat_reader_class() + class ReaderWrapper(IFormatReader): - __doc__ = '''A wrapper for %s + __doc__ = ( + """A wrapper for %s See http://hudson.openmicroscopy.org.uk/job/LOCI/javadoc/loci/formats/ImageReader.html - '''%class_name - new_fn = jutil.make_new(class_name, '(Lloci/formats/IFormatReader;)V') + """ + % class_name + ) + new_fn = jutil.make_new(class_name, "(Lloci/formats/IFormatReader;)V") + def __init__(self, rdr): self.new_fn(rdr) - setId = jutil.make_method('setId', '(Ljava/lang/String;)V', - 'Set the name of the data file') + setId = jutil.make_method( + "setId", "(Ljava/lang/String;)V", "Set the name of the data file" + ) + return ReaderWrapper + __has_omero_jars = None + + def has_omero_packages(): - '''Return True if we can find the packages needed for OMERO + """Return True if we can find the packages needed for OMERO In order to run OMERO, you'll need the OMERO client and ICE on your class path (not supplied with python-bioformats and specific to your server's version) - ''' + """ global __has_omero_jars if __has_omero_jars is None: class_loader = jutil.static_call( - "java/lang/ClassLoader", "getSystemClassLoader", - "()Ljava/lang/ClassLoader;") - for klass in ("Glacier2.PermissionDeniedException", - "loci.ome.io.OmeroReader", "omero.client"): + "java/lang/ClassLoader", "getSystemClassLoader", "()Ljava/lang/ClassLoader;" + ) + for klass in ( + "Glacier2.PermissionDeniedException", + "loci.ome.io.OmeroReader", + "omero.client", + ): try: jutil.call( - class_loader, "loadClass", - "(Ljava/lang/String;)Ljava/lang/Class;", klass) + class_loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + klass, + ) except: __has_omero_jars = False break @@ -388,6 +482,7 @@ def has_omero_packages(): __has_omero_jars = True return __has_omero_jars + __omero_server = None __omero_username = None __omero_session_id = None @@ -398,8 +493,9 @@ def has_omero_packages(): # __omero_password = None + def set_omero_credentials(omero_server, omero_port, omero_username, omero_password): - '''Set the credentials to be used to connect to the Omero server + """Set the credentials to be used to connect to the Omero server :param omero_server: DNS name of the server @@ -412,7 +508,7 @@ def set_omero_credentials(omero_server, omero_port, omero_username, omero_passwo The session ID is valid after this function is called. An exception is thrown if the login fails. :func:`bioformats.omero_logout()` can be called to log out. - ''' + """ global __omero_server global __omero_username global __omero_session_id @@ -425,60 +521,74 @@ def set_omero_credentials(omero_server, omero_port, omero_username, omero_passwo var serverFactory = client.createSession(user, password); client.getSessionId(); """ - __omero_session_id = jutil.run_script(script, dict( - server = __omero_server, - port = __omero_port, - user = __omero_username, - password = omero_password)) + __omero_session_id = jutil.run_script( + script, + dict( + server=__omero_server, + port=__omero_port, + user=__omero_username, + password=omero_password, + ), + ) return __omero_session_id + def get_omero_credentials(): - '''Return a pickleable dictionary representing the Omero credentials. + """Return a pickleable dictionary representing the Omero credentials. Call :func:`bioformats.use_omero_credentials` in some other process to use this. - ''' + """ if __omero_session_id is None: omero_login() - return dict(omero_server = __omero_server, - omero_port = __omero_port, - omero_user = __omero_username, - omero_session_id = __omero_session_id) + return dict( + omero_server=__omero_server, + omero_port=__omero_port, + omero_user=__omero_username, + omero_session_id=__omero_session_id, + ) + def omero_login(): if __omero_config_file is not None and os.path.isfile(__omero_config_file): env = jutil.get_env() config = env.make_object_array(1, env.find_class("java/lang/String")) env.set_object_array_element( - config, 0, env.new_string("--Ice.Config=%s" % __omero_config_file)) + config, 0, env.new_string("--Ice.Config=%s" % __omero_config_file) + ) script = """ var client = Packages.omero.client(config); client.createSession(); client.getSessionId(); """ __omero_session_id = jutil.run_script(script, dict(config=config)) - elif all([x is not None for x in - (__omero_server, __omero_port, __omero_username, __omero_password)]): - set_omero_credentials(__omero_server, __omero_port, __omero_username, - __omero_password) + elif all( + [ + x is not None + for x in (__omero_server, __omero_port, __omero_username, __omero_password) + ] + ): + set_omero_credentials( + __omero_server, __omero_port, __omero_username, __omero_password + ) else: __omero_login_fn() return __omero_session_id -def omero_logout(): - '''Abandon any current Omero session. - ''' +def omero_logout(): + """Abandon any current Omero session.""" global __omero_session_id __omero_session_id = None + def use_omero_credentials(credentials): - '''Use the session ID from an existing login as credentials. + """Use the session ID from an existing login as credentials. :param credentials: credentials from get_omero_credentials. - ''' + """ global __omero_server global __omero_username global __omero_session_id @@ -492,18 +602,18 @@ def use_omero_credentials(credentials): __omero_config_file = credentials.get(K_OMERO_CONFIG_FILE, None) __omero_password = credentials.get(K_OMERO_PASSWORD, None) + __omero_login_fn = None -def set_omero_login_hook(fn): - '''Set the function to be called when a login to Omero is needed. - ''' + +def set_omero_login_hook(fn): + """Set the function to be called when a login to Omero is needed.""" global __omero_login_fn __omero_login_fn = fn -def get_omero_reader(): - '''Return an ``loci.ome.io.OMEROReader`` instance, wrapped as a FormatReader. - ''' +def get_omero_reader(): + """Return an ``loci.ome.io.OMEROReader`` instance, wrapped as a FormatReader.""" script = """ var rdr = new Packages.loci.ome.io.OmeroReader(); rdr.setServer(server); @@ -515,31 +625,41 @@ def get_omero_reader(): if __omero_session_id is None: omero_login() - jrdr = jutil.run_script(script, dict( - server = __omero_server, - port = __omero_port, - username = __omero_username, - sessionID = __omero_session_id)) + jrdr = jutil.run_script( + script, + dict( + server=__omero_server, + port=__omero_port, + username=__omero_username, + sessionID=__omero_session_id, + ), + ) rdr = make_iformat_reader_class()() rdr.o = jrdr return rdr -def load_using_bioformats_url(url, c=None, z=0, t=0, series=None, index=None, - rescale = True, - wants_max_intensity = False, - channel_names = None): - '''Load a file from Bio-formats via a URL - - ''' +def load_using_bioformats_url( + url, + c=None, + z=0, + t=0, + series=None, + index=None, + rescale=True, + wants_max_intensity=False, + channel_names=None, +): + """Load a file from Bio-formats via a URL""" with ImageReader(url=url) as rdr: - return rdr.read(c, z, t, series, index, rescale, wants_max_intensity, - channel_names) + return rdr.read( + c, z, t, series, index, rescale, wants_max_intensity, channel_names + ) class ImageReader(object): - '''Find the appropriate reader for a file. + """Find the appropriate reader for a file. This class is meant to be harnessed to a scope like this: @@ -549,7 +669,7 @@ class ImageReader(object): It uses `__enter__` and `__exit__` to manage the random access stream that can be used to cache the file contents in memory. - ''' + """ def __init__(self, path=None, url=None, perform_init=True): self.stream = None @@ -559,7 +679,7 @@ def __init__(self, path=None, url=None, perform_init=True): if url is not None: url = str(url) if url.lower().startswith(file_scheme): - url = url2pathname(url[len(file_scheme):]) + url = url2pathname(url[len(file_scheme) :]) path = url self.path = path @@ -578,12 +698,11 @@ def __init__(self, path=None, url=None, perform_init=True): return except jutil.JavaException as e: je = e.throwable + if jutil.is_instance_of(je, "loci/formats/FormatException"): + je = jutil.call(je, "getCause", "()Ljava/lang/Throwable;") if jutil.is_instance_of( - je, "loci/formats/FormatException"): - je = jutil.call(je, "getCause", - "()Ljava/lang/Throwable;") - if jutil.is_instance_of( - je, "Glacier2/PermissionDeniedException"): + je, "Glacier2/PermissionDeniedException" + ): omero_logout() omero_login() else: @@ -591,13 +710,18 @@ def __init__(self, path=None, url=None, perform_init=True): for line in traceback.format_exc().split("\n"): logger.warn(line) if jutil.is_instance_of( - je, "java/io/FileNotFoundException"): + je, "java/io/FileNotFoundException" + ): raise IOError( errno.ENOENT, - "The file, \"%s\", does not exist." % path, - path) + 'The file, "%s", does not exist.' % path, + path, + ) e2 = IOError( - errno.EINVAL, "Could not load the file as an image (see log for details)", path.encode('utf-8')) + errno.EINVAL, + "Could not load the file as an image (see log for details)", + path.encode("utf-8"), + ) raise e2 else: # @@ -610,14 +734,11 @@ def __init__(self, path=None, url=None, perform_init=True): filename = os.path.split(path)[1] if not os.path.isfile(self.path): - raise IOError( - errno.ENOENT, - "The file, \"%s\", does not exist." % path, - path) + raise IOError(errno.ENOENT, 'The file, "%s", does not exist.' % path, path) - self.stream = jutil.make_instance('loci/common/RandomAccessInputStream', - '(Ljava/lang/String;)V', - self.path) + self.stream = jutil.make_instance( + "loci/common/RandomAccessInputStream", "(Ljava/lang/String;)V", self.path + ) self.rdr = None class_list = get_class_list() @@ -657,9 +778,10 @@ def __init__(self, path=None, url=None, perform_init=True): rdr; """ IFormatReader = make_iformat_reader_class() - jrdr = jutil.run_script(find_rdr_script, dict(class_list = class_list, - filename = filename, - stream = self.stream)) + jrdr = jutil.run_script( + find_rdr_script, + dict(class_list=class_list, filename=filename, stream=self.stream), + ) if jrdr is None: raise ValueError("Could not find a Bio-Formats reader for %s", self.path) self.rdr = IFormatReader() @@ -669,24 +791,26 @@ def __init__(self, path=None, url=None, perform_init=True): def download(self, url): scheme = urlparse(url)[0] - ext = url[url.rfind("."):] + ext = url[url.rfind(".") :] urlpath = urlparse(url)[2] filename = unquote(urlpath.split("/")[-1]) self.using_temp_file = True - if scheme == 's3': - client = boto3.client('s3') - bucket_name, key = re.compile('s3://([\w\d\-\.]+)/(.*)').search(url).groups() + if scheme == "s3": + client = boto3.client("s3") + bucket_name, key = ( + re.compile("s3://([\w\d\-\.]+)/(.*)").search(url).groups() + ) url = client.generate_presigned_url( - 'get_object', - Params={'Bucket': bucket_name, 'Key': key.replace("+", " ")} + "get_object", + Params={"Bucket": bucket_name, "Key": key.replace("+", " ")}, ) cellacdc = urlopen(url) dest_fd, self.path = tempfile.mkstemp(suffix=ext) try: - with os.fdopen(dest_fd, 'wb') as dest: + with os.fdopen(dest_fd, "wb") as dest: shutil.copyfileobj(cellacdc, dest) except: os.remove(self.path) @@ -707,7 +831,7 @@ def close(self): del self.rdr.o del self.rdr if hasattr(self, "stream") and self.stream is not None: - jutil.call(self.stream, 'close', '()V') + jutil.call(self.stream, "close", "()V") del self.stream if self.using_temp_file: os.remove(self.path) @@ -715,7 +839,7 @@ def close(self): # # Run the Java garbage collector here. # - jutil.static_call("java/lang/System", "gc","()V") + jutil.static_call("java/lang/System", "gc", "()V") def init_reader(self): mdoptions = metadatatools.get_metadata_options(metadatatools.ALL) @@ -731,29 +855,38 @@ def init_reader(self): logger.warn(line) je = e.throwable if has_omero_packages() and jutil.is_instance_of( - je, "Glacier2/PermissionDeniedException"): + je, "Glacier2/PermissionDeniedException" + ): # Handle at a higher level raise - if jutil.is_instance_of( - je, "loci/formats/FormatException"): - je = jutil.call(je, "getCause", - "()Ljava/lang/Throwable;") - if jutil.is_instance_of( - je, "java/io/FileNotFoundException"): + if jutil.is_instance_of(je, "loci/formats/FormatException"): + je = jutil.call(je, "getCause", "()Ljava/lang/Throwable;") + if jutil.is_instance_of(je, "java/io/FileNotFoundException"): raise IOError( errno.ENOENT, - "The file, \"%s\", does not exist." % self.path, - self.path) + 'The file, "%s", does not exist.' % self.path, + self.path, + ) e2 = IOError( - errno.EINVAL, "Could not load the file as an image (see log for details)", - self.path.encode('utf-8')) + errno.EINVAL, + "Could not load the file as an image (see log for details)", + self.path.encode("utf-8"), + ) raise e2 - - def read(self, c = None, z = 0, t = 0, series = None, index = None, - rescale = True, wants_max_intensity = False, channel_names = None, - XYWH=None): - '''Read a single plane from the image reader file. + def read( + self, + c=None, + z=0, + t=0, + series=None, + index=None, + rescale=True, + wants_max_intensity=False, + channel_names=None, + XYWH=None, + ): + """Read a single plane from the image reader file. :param c: read from this channel. `None` = read color image if multichannel or interleaved RGB. :param z: z-stack index @@ -766,17 +899,18 @@ def read(self, c = None, z = 0, t = 0, series = None, index = None, return a tuple of image and max intensity :param channel_names: provide the channel names for the OME metadata :param XYWH: a (x, y, w, h) tuple - ''' + """ FormatTools = make_format_tools_class() - ChannelSeparator = make_reader_wrapper_class( - "loci/formats/ChannelSeparator") + ChannelSeparator = make_reader_wrapper_class("loci/formats/ChannelSeparator") env = jutil.get_env() if series is not None: self.rdr.setSeries(series) if XYWH is not None: assert isinstance(XYWH, tuple) and len(XYWH) == 4, "Invalid XYWH tuple" - openBytes_func = lambda x: self.rdr.openBytesXYWH(x, XYWH[0], XYWH[1], XYWH[2], XYWH[3]) + openBytes_func = lambda x: self.rdr.openBytesXYWH( + x, XYWH[0], XYWH[1], XYWH[2], XYWH[3] + ) width, height = XYWH[2], XYWH[3] else: openBytes_func = self.rdr.openBytes @@ -791,32 +925,34 @@ def read(self, c = None, z = 0, t = 0, series = None, index = None, dtype = np.uint8 scale = 255 elif pixel_type == FormatTools.UINT16: - dtype = 'u2' + dtype = "u2" scale = 65535 elif pixel_type == FormatTools.INT16: - dtype = 'i2' + dtype = "i2" scale = 65535 elif pixel_type == FormatTools.UINT32: - dtype = 'u4' + dtype = "u4" scale = 2147483647 elif pixel_type == FormatTools.INT32: - dtype = 'i4' - scale = 2147483647-1 + dtype = "i4" + scale = 2147483647 - 1 elif pixel_type == FormatTools.FLOAT: - dtype = 'f4' + dtype = "f4" scale = 1 elif pixel_type == FormatTools.DOUBLE: - dtype = 'f8' + dtype = "f8" scale = 1 - max_sample_value = self.rdr.getMetadataValue('MaxSampleValue') + max_sample_value = self.rdr.getMetadataValue("MaxSampleValue") if max_sample_value is not None: try: - scale = jutil.call(max_sample_value, 'intValue', '()I') + scale = jutil.call(max_sample_value, "intValue", "()I") except: - logger.warning("WARNING: failed to get MaxSampleValue for image. Intensities may be improperly scaled.") + logger.warning( + "WARNING: failed to get MaxSampleValue for image. Intensities may be improperly scaled." + ) if index is not None: image = np.frombuffer(openBytes_func(index), dtype) - if len(image) / height / width in (3,4): + if len(image) / height / width in (3, 4): n_channels = int(len(image) / height / width) if self.rdr.isInterleaved(): image.shape = (height, width, n_channels) @@ -826,13 +962,13 @@ def read(self, c = None, z = 0, t = 0, series = None, index = None, else: image.shape = (height, width) elif self.rdr.isRGB() and self.rdr.isInterleaved(): - index = self.rdr.getIndex(z,0,t) + index = self.rdr.getIndex(z, 0, t) image = np.frombuffer(openBytes_func(index), dtype) image.shape = (height, width, self.rdr.getSizeC()) if image.shape[2] > 3: image = image[:, :, :3] elif c is not None and self.rdr.getRGBChannelCount() == 1: - index = self.rdr.getIndex(z,c,t) + index = self.rdr.getIndex(z, c, t) image = np.frombuffer(openBytes_func(index), dtype) image.shape = (height, width) elif self.rdr.getRGBChannelCount() > 1: @@ -840,10 +976,17 @@ def read(self, c = None, z = 0, t = 0, series = None, index = None, rdr = ChannelSeparator(self.rdr) planes = [ np.frombuffer( - (rdr.openBytes(rdr.getIndex(z,i,t)) if XYWH is None else - rdr.openBytesXYWH(rdr.getIndex(z,i,t), XYWH[0], XYWH[1], XYWH[2], XYWH[3])), - dtype - ) for i in range(n_planes)] + ( + rdr.openBytes(rdr.getIndex(z, i, t)) + if XYWH is None + else rdr.openBytesXYWH( + rdr.getIndex(z, i, t), XYWH[0], XYWH[1], XYWH[2], XYWH[3] + ) + ), + dtype, + ) + for i in range(n_planes) + ] if len(planes) > 3: planes = planes[:3] @@ -852,12 +995,13 @@ def read(self, c = None, z = 0, t = 0, series = None, index = None, # see issue #775 planes.append(np.zeros(planes[0].shape, planes[0].dtype)) image = np.dstack(planes) - image.shape=(height, width, 3) + image.shape = (height, width, 3) del rdr elif self.rdr.getSizeC() > 1: images = [ - np.frombuffer(openBytes_func(self.rdr.getIndex(z,i,t)), dtype) - for i in range(self.rdr.getSizeC())] + np.frombuffer(openBytes_func(self.rdr.getIndex(z, i, t)), dtype) + for i in range(self.rdr.getSizeC()) + ] image = np.dstack(images) image.shape = (height, width, self.rdr.getSizeC()) if not channel_names is None: @@ -874,30 +1018,35 @@ def read(self, c = None, z = 0, t = 0, series = None, index = None, # But sometimes the table is the identity table and just generates # a monochrome RGB image # - index = self.rdr.getIndex(z,0,t) - image = np.frombuffer(openBytes_func(index),dtype) + index = self.rdr.getIndex(z, 0, t) + image = np.frombuffer(openBytes_func(index), dtype) if pixel_type in (FormatTools.INT16, FormatTools.UINT16): lut = self.rdr.get16BitLookupTable() if lut is not None: lut = np.array( - [env.get_short_array_elements(d) - for d in env.get_object_array_elements(lut)])\ - .transpose() + [ + env.get_short_array_elements(d) + for d in env.get_object_array_elements(lut) + ] + ).transpose() else: lut = self.rdr.get8BitLookupTable() if lut is not None: lut = np.array( - [env.get_byte_array_elements(d) - for d in env.get_object_array_elements(lut)])\ - .transpose() + [ + env.get_byte_array_elements(d) + for d in env.get_object_array_elements(lut) + ] + ).transpose() image.shape = (height, width) - if (lut is not None) \ - and not np.all(lut == np.arange(lut.shape[0])[:, np.newaxis]): + if (lut is not None) and not np.all( + lut == np.arange(lut.shape[0])[:, np.newaxis] + ): image = lut[image, :] else: - index = self.rdr.getIndex(z,0,t) - image = np.frombuffer(openBytes_func(index),dtype) - image.shape = (height,width) + index = self.rdr.getIndex(z, 0, t) + image = np.frombuffer(openBytes_func(index), dtype) + image.shape = (height, width) if rescale: image = image.astype(np.float32) / float(scale) @@ -905,6 +1054,7 @@ def read(self, c = None, z = 0, t = 0, series = None, index = None, return image, scale return image + ################### # # A cache mechanism for image readers @@ -924,14 +1074,15 @@ def read(self, c = None, z = 0, t = 0, series = None, index = None, # The image reader cache associates path/url with a reader __image_reader_cache = {} + def get_image_reader(key, path=None, url=None): - '''Make or find an image reader appropriate for the given path + """Make or find an image reader appropriate for the given path path - pathname to the reader on disk. key - use this key to keep only a single cache member associated with that key open at a time. - ''' + """ logger.debug("Getting image reader for: %s, %s, %s" % (key, path, url)) if key in __image_reader_key_cache: old_path, old_url = __image_reader_key_cache[key] @@ -946,23 +1097,26 @@ def get_image_reader(key, path=None, url=None): # is True OMERO python reader can be used to directly request # the image pixels from the server. # Following this route gives almost 10x speed up. - if OMERO_READER_IMPORTED and OMERO_IMPORTED and \ - omero_reader_enabled() and \ - url is not None and url.lower().startswith("omero:"): + if ( + OMERO_READER_IMPORTED + and OMERO_IMPORTED + and omero_reader_enabled() + and url is not None + and url.lower().startswith("omero:") + ): logger.debug("Initializing Python reader.") rdr = OmeroReader(__omero_server, __omero_session_id, url=url) else: logger.debug("Falling back to Java reader.") rdr = ImageReader(path=path, url=url) old_count = 0 - __image_reader_cache[path, url] = (old_count+1, rdr) + __image_reader_cache[path, url] = (old_count + 1, rdr) __image_reader_key_cache[key] = (path, url) return rdr -def release_image_reader(key): - '''Tell the cache that it should flush the reference for the given key - ''' +def release_image_reader(key): + """Tell the cache that it should flush the reference for the given key""" if key in __image_reader_key_cache: path, url = __image_reader_key_cache[key] del __image_reader_key_cache[key] @@ -971,21 +1125,30 @@ def release_image_reader(key): rdr.close() del __image_reader_cache[path, url] else: - __image_reader_cache[path, url] = (old_count-1, rdr) + __image_reader_cache[path, url] = (old_count - 1, rdr) + def clear_image_reader_cache(): - '''Get rid of any open image readers''' + """Get rid of any open image readers""" for use_count, rdr in __image_reader_cache.values(): logger.debug("Closing reader %s" % rdr) rdr.close() __image_reader_cache.clear() __image_reader_key_cache.clear() -def load_using_bioformats(path, c=None, z=0, t=0, series=None, index=None, - rescale = True, - wants_max_intensity = False, - channel_names = None): - '''Load the given image file using the Bioformats library. + +def load_using_bioformats( + path, + c=None, + z=0, + t=0, + series=None, + index=None, + rescale=True, + wants_max_intensity=False, + channel_names=None, +): + """Load the given image file using the Bioformats library. :param path: path to the file :param z: the frame index in the `z` (depth) dimension. @@ -994,14 +1157,16 @@ def load_using_bioformats(path, c=None, z=0, t=0, series=None, index=None, :returns: either a 2-d (grayscale) or 3-d (2-d + 3 RGB planes) image. - ''' + """ with ImageReader(path=path) as rdr: - return rdr.read(c, z, t, series, index, rescale, wants_max_intensity, - channel_names) + return rdr.read( + c, z, t, series, index, rescale, wants_max_intensity, channel_names + ) + def get_omexml_metadata(path=None, url=None): - '''Read the OME metadata from a file using Bio-formats + """Read the OME metadata from a file using Bio-formats :param path: path to the file @@ -1010,7 +1175,7 @@ def get_omexml_metadata(path=None, url=None): :returns: the metdata as XML. - ''' + """ with ImageReader(path=path, url=url, perform_init=False) as rdr: # # Below, "in" is a keyword and Rhino's parser is just a little wonky I fear. @@ -1033,5 +1198,5 @@ def get_omexml_metadata(path=None, url=None): var xml = service.getOMEXML(metadata); xml; """ - xml = jutil.run_script(script, dict(path=rdr.path, reader = rdr.rdr)) + xml = jutil.run_script(script, dict(path=rdr.path, reader=rdr.rdr)) return xml diff --git a/cellacdc/bioformats/formatwriter.py b/cellacdc/bioformats/formatwriter.py index c60c10ca0..834a002d6 100755 --- a/cellacdc/bioformats/formatwriter.py +++ b/cellacdc/bioformats/formatwriter.py @@ -5,7 +5,7 @@ # Copyright (c) 2009-2014 Broad Institute # All rights reserved. -'''formatwriter.py - mechanism to wrap a bioformats WriterWrapper and ImageWriter +"""formatwriter.py - mechanism to wrap a bioformats WriterWrapper and ImageWriter The following file formats can be written using Bio-Formats: @@ -28,7 +28,7 @@ and is especially useful for formats that do not support multiple images per file. -''' +""" from __future__ import absolute_import, print_function, unicode_literals @@ -43,10 +43,19 @@ import javabridge from ..bioformats import omexml as ome -def write_image(pathname, pixels, pixel_type, - c = 0, z = 0, t = 0, - size_c = 1, size_z = 1, size_t = 1, - channel_names = None): + +def write_image( + pathname, + pixels, + pixel_type, + c=0, + z=0, + t=0, + size_c=1, + size_z=1, + size_t=1, + channel_names=None, +): """Write the image using bioformats. :param filename: save to this filename @@ -86,7 +95,8 @@ def write_image(pathname, pixels, pixel_type, p.SizeC = pixels.shape[2] p.Channel(0).SamplesPerPixel = pixels.shape[2] omexml.structured_annotations.add_original_metadata( - ome.OM_SAMPLES_PER_PIXEL, str(pixels.shape[2])) + ome.OM_SAMPLES_PER_PIXEL, str(pixels.shape[2]) + ) elif size_c > 1: p.channel_count = size_c @@ -105,21 +115,20 @@ def write_image(pathname, pixels, pixel_type, writer.saveBytes(index, buffer); writer.close(); """ - jutil.run_script(script, - dict(path=pathname, - xml=xml, - index=index, - buffer=pixel_buffer)) + jutil.run_script( + script, dict(path=pathname, xml=xml, index=index, buffer=pixel_buffer) + ) + def convert_pixels_to_buffer(pixels, pixel_type): - '''Convert the pixels in the image into a buffer of the right pixel type + """Convert the pixels in the image into a buffer of the right pixel type pixels - a 2d monochrome or color image pixel_type - one of the OME pixel types returns a 1-d byte array - ''' + """ if pixel_type in (ome.PT_UINT8, ome.PT_INT8, ome.PT_BIT): as_dtype = np.uint8 elif pixel_type in (ome.PT_UINT16, ome.PT_INT16): @@ -136,269 +145,404 @@ def convert_pixels_to_buffer(pixels, pixel_type): env = jutil.get_env() return env.make_byte_array(buf) + def make_iformat_writer_class(class_name): - '''Bind a Java class that implements IFormatWriter to a Python class + """Bind a Java class that implements IFormatWriter to a Python class Returns a class that implements IFormatWriter through calls to the implemented class passed in. The returned class can be subclassed to provide additional bindings. - ''' + """ + class IFormatWriter(object): - '''A wrapper for loci.formats.IFormatWriter + """A wrapper for loci.formats.IFormatWriter See http://hudson.openmicroscopy.org.uk/job/LOCI/javadoc/loci/formats/ImageWriter.html - ''' - canDoStacks = jutil.make_method('canDoStacks', '()Z', - 'Reports whether the writer can save multiple images to a single file.') - getColorModel = jutil.make_method('getColorModel', '()Ljava/awt/image/ColorModel;', - 'Gets the color model.') - getCompression = jutil.make_method('getCompression', '()Ljava/lang/String;', - 'Gets the current compression type.') - getCompressionTypes = jutil.make_method('getCompressionTypes', '()[Ljava/lang/String;', - 'Gets the available compression types.') - getFramesPerSecond = jutil.make_method('getFramesPerSecond', '()I', - 'Gets the frames per second to use when writing.') - getMetadataRetrieve = jutil.make_method('getMetadataRetrieve', '()Lloci/formats/meta/MetadataRetrieve;', - 'Retrieves the current metadata retrieval object for this writer.') - getPixelTypes = jutil.make_method('getPixelTypes', '()[I', - 'Gets the supported pixel types.') -# getPixelTypes = jutil.make_method('getPixelTypes', '(Ljava/lang/String;)[I', -# 'Gets the supported pixel types for the given codec.') - isInterleaved = jutil.make_method('isInterleaved', '()Z', - 'Gets whether or not the channels in an image are interleaved.') - isSupportedType = jutil.make_method('isSupportedType', '(I)Z', - 'Checks if the given pixel type is supported.') - saveBytes = jutil.make_method('saveBytes', '([BZ)V', - 'Saves the given byte array to the current file.') - saveBytesIB = jutil.make_method('saveBytes', '(I[B)V', - 'Saves bytes, first arg is image #') -# saveBytes = jutil.make_method('saveBytes', '([BIZZ)V', -# 'Saves the given byte array to the given series in the current file.') - savePlane = jutil.make_method('savePlane', '(Ljava/lang/Object;Z)V', - 'Saves the given image plane to the current file.') -# savePlane = jutil.make_method('savePlane', '(Ljava/lang/Object;IZZ)V', -# 'Saves the given image plane to the given series in the current file.') - setColorModel = jutil.make_method('setColorModel', '(Ljava/awt/image/ColorModel;)V', - 'Sets the color model.') - setCompression = jutil.make_method('setCompression', '(Ljava/lang/String;)V', - 'Sets the current compression type.') - setFramesPerSecond = jutil.make_method('setFramesPerSecond', '(I)V', - 'Sets the frames per second to use when writing.') - setInterleaved = jutil.make_method('setInterleaved', '(Z)V', - 'Sets whether or not the channels in an image are interleaved.') - setMetadataRetrieve = jutil.make_method('setMetadataRetrieve', '(Lloci/formats/meta/MetadataRetrieve;)V', - 'Sets the metadata retrieval object from which to retrieve standardized metadata.') + """ + + canDoStacks = jutil.make_method( + "canDoStacks", + "()Z", + "Reports whether the writer can save multiple images to a single file.", + ) + getColorModel = jutil.make_method( + "getColorModel", "()Ljava/awt/image/ColorModel;", "Gets the color model." + ) + getCompression = jutil.make_method( + "getCompression", + "()Ljava/lang/String;", + "Gets the current compression type.", + ) + getCompressionTypes = jutil.make_method( + "getCompressionTypes", + "()[Ljava/lang/String;", + "Gets the available compression types.", + ) + getFramesPerSecond = jutil.make_method( + "getFramesPerSecond", + "()I", + "Gets the frames per second to use when writing.", + ) + getMetadataRetrieve = jutil.make_method( + "getMetadataRetrieve", + "()Lloci/formats/meta/MetadataRetrieve;", + "Retrieves the current metadata retrieval object for this writer.", + ) + getPixelTypes = jutil.make_method( + "getPixelTypes", "()[I", "Gets the supported pixel types." + ) + # getPixelTypes = jutil.make_method('getPixelTypes', '(Ljava/lang/String;)[I', + # 'Gets the supported pixel types for the given codec.') + isInterleaved = jutil.make_method( + "isInterleaved", + "()Z", + "Gets whether or not the channels in an image are interleaved.", + ) + isSupportedType = jutil.make_method( + "isSupportedType", "(I)Z", "Checks if the given pixel type is supported." + ) + saveBytes = jutil.make_method( + "saveBytes", "([BZ)V", "Saves the given byte array to the current file." + ) + saveBytesIB = jutil.make_method( + "saveBytes", "(I[B)V", "Saves bytes, first arg is image #" + ) + # saveBytes = jutil.make_method('saveBytes', '([BIZZ)V', + # 'Saves the given byte array to the given series in the current file.') + savePlane = jutil.make_method( + "savePlane", + "(Ljava/lang/Object;Z)V", + "Saves the given image plane to the current file.", + ) + # savePlane = jutil.make_method('savePlane', '(Ljava/lang/Object;IZZ)V', + # 'Saves the given image plane to the given series in the current file.') + setColorModel = jutil.make_method( + "setColorModel", "(Ljava/awt/image/ColorModel;)V", "Sets the color model." + ) + setCompression = jutil.make_method( + "setCompression", + "(Ljava/lang/String;)V", + "Sets the current compression type.", + ) + setFramesPerSecond = jutil.make_method( + "setFramesPerSecond", + "(I)V", + "Sets the frames per second to use when writing.", + ) + setInterleaved = jutil.make_method( + "setInterleaved", + "(Z)V", + "Sets whether or not the channels in an image are interleaved.", + ) + setMetadataRetrieve = jutil.make_method( + "setMetadataRetrieve", + "(Lloci/formats/meta/MetadataRetrieve;)V", + "Sets the metadata retrieval object from which to retrieve standardized metadata.", + ) setValidBitsPerPixel = jutil.make_method( - 'setValidBitsPerPixel', '(I)V', - 'Sets the number of valid bits per pixel') + "setValidBitsPerPixel", "(I)V", "Sets the number of valid bits per pixel" + ) setSeries = jutil.make_method( - 'setSeries', '(I)V', - '''Set the series for the image file + "setSeries", + "(I)V", + """Set the series for the image file series - the zero-based index of the image stack in the file, - for instance in a multi-image tif.''') + for instance in a multi-image tif.""", + ) return IFormatWriter + def make_image_writer_class(): - '''Return an image writer class for the given Java environment''' + """Return an image writer class for the given Java environment""" env = jutil.get_env() - class_name = 'loci/formats/ImageWriter' + class_name = "loci/formats/ImageWriter" klass = env.find_class(class_name) - base_klass = env.find_class('loci/formats/IFormatWriter') + base_klass = env.find_class("loci/formats/IFormatWriter") IFormatWriter = make_iformat_writer_class(class_name) # # This uses the writers.txt file from inside the loci_tools.jar # - class_list = jutil.make_instance("loci/formats/ClassList", - "(Ljava/lang/String;" - "Ljava/lang/Class;" # base - "Ljava/lang/Class;)V", # location in jar - "writers.txt", base_klass, klass) + class_list = jutil.make_instance( + "loci/formats/ClassList", + "(Ljava/lang/String;" + "Ljava/lang/Class;" # base + "Ljava/lang/Class;)V", # location in jar + "writers.txt", + base_klass, + klass, + ) + class ImageWriter(IFormatWriter): - new_fn = jutil.make_new(class_name, '(Lloci/formats/ClassList;)V') + new_fn = jutil.make_new(class_name, "(Lloci/formats/ClassList;)V") + def __init__(self): self.new_fn(class_list) - setId = jutil.make_method('setId', '(Ljava/lang/String;)V', - 'Sets the current file name.') - addStatusListener = jutil.make_method('addStatusListener', '()Lloci/formats/StatusListener;', - 'Adds a listener for status update events.') - close = jutil.make_method('close','()V', - 'Closes currently open file(s) and frees allocated memory.') - getFormat = jutil.make_method('getFormat', '()Ljava/lang/String;', - 'Gets the name of this file format.') - getNativeDataType = jutil.make_method('getNativeDataType', '()Ljava/lang/Class;', - 'Returns the native data type of image planes for this reader, as returned by IFormatReader.openPlane(int, int, int, int, int) or IFormatWriter#saveData.') - getStatusListeners = jutil.make_method('getStatusListeners', '()[Lloci/formats/StatusListener;', - 'Gets a list of all registered status update listeners.') - getSuffixes = jutil.make_method('getSuffixes', '()Ljava/lang/String;', - 'Gets the default file suffixes for this file format.') - getWriter = jutil.make_method('getWriter', '()Lloci/formats/IFormatWriter;', - 'Gets the writer used to save the current file.') -# getWriter = jutil.make_method('getWriter', '(Ljava/lang/Class)Lloci/formats/IFormatWriter;', -# 'Gets the file format writer instance matching the given class.') -# getWriter = jutil.make_method('getWriter', '(Ljava/lang/String;)Lloci/formats/IFormatWriter;', -# 'Gets the writer used to save the given file.') - getWriters = jutil.make_method('getWriters', '()[Lloci/formats/IFormatWriter;', - 'Gets all constituent file format writers.') - isThisType = jutil.make_method('isThisType', '(Ljava/lang/String;)Z', - 'Checks if the given string is a valid filename for this file format.') - removeStatusListener = jutil.make_method('removeStatusListener', '(Lloci/formats/StatusListener;)V', - 'Saves the given byte array to the current file.') + setId = jutil.make_method( + "setId", "(Ljava/lang/String;)V", "Sets the current file name." + ) + addStatusListener = jutil.make_method( + "addStatusListener", + "()Lloci/formats/StatusListener;", + "Adds a listener for status update events.", + ) + close = jutil.make_method( + "close", "()V", "Closes currently open file(s) and frees allocated memory." + ) + getFormat = jutil.make_method( + "getFormat", "()Ljava/lang/String;", "Gets the name of this file format." + ) + getNativeDataType = jutil.make_method( + "getNativeDataType", + "()Ljava/lang/Class;", + "Returns the native data type of image planes for this reader, as returned by IFormatReader.openPlane(int, int, int, int, int) or IFormatWriter#saveData.", + ) + getStatusListeners = jutil.make_method( + "getStatusListeners", + "()[Lloci/formats/StatusListener;", + "Gets a list of all registered status update listeners.", + ) + getSuffixes = jutil.make_method( + "getSuffixes", + "()Ljava/lang/String;", + "Gets the default file suffixes for this file format.", + ) + getWriter = jutil.make_method( + "getWriter", + "()Lloci/formats/IFormatWriter;", + "Gets the writer used to save the current file.", + ) + # getWriter = jutil.make_method('getWriter', '(Ljava/lang/Class)Lloci/formats/IFormatWriter;', + # 'Gets the file format writer instance matching the given class.') + # getWriter = jutil.make_method('getWriter', '(Ljava/lang/String;)Lloci/formats/IFormatWriter;', + # 'Gets the writer used to save the given file.') + getWriters = jutil.make_method( + "getWriters", + "()[Lloci/formats/IFormatWriter;", + "Gets all constituent file format writers.", + ) + isThisType = jutil.make_method( + "isThisType", + "(Ljava/lang/String;)Z", + "Checks if the given string is a valid filename for this file format.", + ) + removeStatusListener = jutil.make_method( + "removeStatusListener", + "(Lloci/formats/StatusListener;)V", + "Saves the given byte array to the current file.", + ) + return ImageWriter + def make_ome_tiff_writer_class(): - '''Return a class that wraps loci.formats.out.OMETiffWriter''' - class_name = 'loci/formats/out/OMETiffWriter' + """Return a class that wraps loci.formats.out.OMETiffWriter""" + class_name = "loci/formats/out/OMETiffWriter" IFormatWriter = make_iformat_writer_class(class_name) class OMETiffWriter(IFormatWriter): - def __init__(self): - self.new_fn = jutil.make_new(self.class_name, '()V') - self.setId = jutil.make_method('setId', '(Ljava/lang/String;)V', - 'Sets the current file name.') + self.new_fn = jutil.make_new(self.class_name, "()V") + self.setId = jutil.make_method( + "setId", "(Ljava/lang/String;)V", "Sets the current file name." + ) self.close = jutil.make_method( - 'close','()V', - 'Closes currently open file(s) and frees allocated memory.') + "close", + "()V", + "Closes currently open file(s) and frees allocated memory.", + ) self.saveBytesIFD = jutil.make_method( - 'saveBytes', '(I[BLloci/formats/tiff/IFD;)V', - '''save a byte array to an image channel + "saveBytes", + "(I[BLloci/formats/tiff/IFD;)V", + """save a byte array to an image channel index - image index bytes - byte array to save ifd - a loci.formats.tiff.IFD instance that gives all of the - IFD values associated with the channel''') + IFD values associated with the channel""", + ) self.new_fn() return OMETiffWriter + def make_writer_wrapper_class(class_name): - '''Make an ImageWriter wrapper class + """Make an ImageWriter wrapper class class_name - the name of the wrapper class You can instantiate an instance of the wrapper class like this: writer = XXX(ImageWriter()) - ''' + """ IFormatWriter = make_iformat_writer_class(class_name) + class WriterWrapper(IFormatWriter): - __doc__ = '''A wrapper for %s + __doc__ = ( + """A wrapper for %s See http://hudson.openmicroscopy.org.uk/job/LOCI/javadoc/loci/formats/ImageWriter.html - '''%class_name - new_fn = jutil.make_new(class_name, '(Lloci/formats/IFormatWriter;)V') + """ + % class_name + ) + new_fn = jutil.make_new(class_name, "(Lloci/formats/IFormatWriter;)V") + def __init__(self, writer): self.new_fn(writer) - setId = jutil.make_method('setId', '(Ljava/lang/String;)V', - 'Sets the current file name.') + setId = jutil.make_method( + "setId", "(Ljava/lang/String;)V", "Sets the current file name." + ) + return WriterWrapper def make_format_writer_class(class_name): - '''Make a FormatWriter wrapper class + """Make a FormatWriter wrapper class class_name - the name of a class that implements loci.formats.FormatWriter Known names in the loci.formats.out package: APNGWriter, AVIWriter, EPSWriter, ICSWriter, ImageIOWriter, JPEG2000Writer, JPEGWriter, LegacyQTWriter, OMETiffWriter, OMEXMLWriter, QTWriter, TiffWriter - ''' - new_fn = jutil.make_new(class_name, - '(Ljava/lang/String;Ljava/lang/String;)V') + """ + new_fn = jutil.make_new(class_name, "(Ljava/lang/String;Ljava/lang/String;)V") + class FormatWriter(object): - __doc__ = '''A wrapper for %s implementing loci.formats.FormatWriter - See http://hudson.openmicroscopy.org.uk/job/LOCI/javadoc/loci/formats/FormatWriter'''%class_name + __doc__ = ( + """A wrapper for %s implementing loci.formats.FormatWriter + See http://hudson.openmicroscopy.org.uk/job/LOCI/javadoc/loci/formats/FormatWriter""" + % class_name + ) + def __init__(self): self.new_fn() - canDoStacks = jutil.make_method('canDoStacks','()Z', - 'Reports whether the writer can save multiple images to a single file') - getColorModel = jutil.make_method('getColorModel', - '()Ljava/awt/image/ColorModel;', - 'Gets the color model') - getCompression = jutil.make_method('getCompression', - '()Ljava/lang/String;', - 'Gets the current compression type') - getCompressionTypes = jutil.make_method('getCompressionTypes', - '()[Ljava/lang/String;', - 'Gets the available compression types') - getFramesPerSecond = jutil.make_method('getFramesPerSecond', - '()I', "Gets the frames per second to use when writing") - getMetadataRetrieve = jutil.make_method('getMetadataRetrieve', - '()Lloci/formats/meta/MetadataRetrieve;', - 'Retrieves the current metadata retrieval object for this writer.') - - getPixelTypes = jutil.make_method('getPixelTypes', - '()[I') - isInterleaved = jutil.make_method('isInterleaved','()Z', - 'Gets whether or not the channels in an image are interleaved') - isSupportedType = jutil.make_method('isSupportedType','(I)Z', - 'Checks if the given pixel type is supported') - saveBytes = jutil.make_method('saveBytes', '([BZ)V', - 'Saves the given byte array to the current file') - setColorModel = jutil.make_method('setColorModel', - '(Ljava/awt/image/ColorModel;)V', - 'Sets the color model') - setCompression = jutil.make_method('setCompression', - '(Ljava/lang/String;)V', - 'Sets the current compression type') - setFramesPerSecond = jutil.make_method('setFramesPerSecond', - '(I)V', - 'Sets the frames per second to use when writing') - setId = jutil.make_method('setId','(Ljava/lang/String;)V', - 'Sets the current file name') - setInterleaved = jutil.make_method('setInterleaved', '(Z)V', - 'Sets whether or not the channels in an image are interleaved') - setMetadataRetrieve = jutil.make_method('setMetadataRetrieve', - '(Lloci/formats/meta/MetadataRetrieve;)V', - 'Sets the metadata retrieval object from which to retrieve standardized metadata') + canDoStacks = jutil.make_method( + "canDoStacks", + "()Z", + "Reports whether the writer can save multiple images to a single file", + ) + getColorModel = jutil.make_method( + "getColorModel", "()Ljava/awt/image/ColorModel;", "Gets the color model" + ) + getCompression = jutil.make_method( + "getCompression", + "()Ljava/lang/String;", + "Gets the current compression type", + ) + getCompressionTypes = jutil.make_method( + "getCompressionTypes", + "()[Ljava/lang/String;", + "Gets the available compression types", + ) + getFramesPerSecond = jutil.make_method( + "getFramesPerSecond", + "()I", + "Gets the frames per second to use when writing", + ) + getMetadataRetrieve = jutil.make_method( + "getMetadataRetrieve", + "()Lloci/formats/meta/MetadataRetrieve;", + "Retrieves the current metadata retrieval object for this writer.", + ) + + getPixelTypes = jutil.make_method("getPixelTypes", "()[I") + isInterleaved = jutil.make_method( + "isInterleaved", + "()Z", + "Gets whether or not the channels in an image are interleaved", + ) + isSupportedType = jutil.make_method( + "isSupportedType", "(I)Z", "Checks if the given pixel type is supported" + ) + saveBytes = jutil.make_method( + "saveBytes", "([BZ)V", "Saves the given byte array to the current file" + ) + setColorModel = jutil.make_method( + "setColorModel", "(Ljava/awt/image/ColorModel;)V", "Sets the color model" + ) + setCompression = jutil.make_method( + "setCompression", + "(Ljava/lang/String;)V", + "Sets the current compression type", + ) + setFramesPerSecond = jutil.make_method( + "setFramesPerSecond", + "(I)V", + "Sets the frames per second to use when writing", + ) + setId = jutil.make_method( + "setId", "(Ljava/lang/String;)V", "Sets the current file name" + ) + setInterleaved = jutil.make_method( + "setInterleaved", + "(Z)V", + "Sets whether or not the channels in an image are interleaved", + ) + setMetadataRetrieve = jutil.make_method( + "setMetadataRetrieve", + "(Lloci/formats/meta/MetadataRetrieve;)V", + "Sets the metadata retrieval object from which to retrieve standardized metadata", + ) + return FormatWriter + def getRGBColorSpace(): - '''Get a Java object that represents an RGB color space + """Get a Java object that represents an RGB color space See java.awt.color.ColorSpace: this returns the linear RGB color space - ''' - cs_linear_rgb = jutil.get_static_field('java/awt/color/ColorSpace', - 'CS_LINEAR_RGB', 'I') - return jutil.static_call('java/awt/color/ColorSpace', 'getInstance', - '(I)Ljava/awt/color/ColorSpace;', - cs_linear_rgb) + """ + cs_linear_rgb = jutil.get_static_field( + "java/awt/color/ColorSpace", "CS_LINEAR_RGB", "I" + ) + return jutil.static_call( + "java/awt/color/ColorSpace", + "getInstance", + "(I)Ljava/awt/color/ColorSpace;", + cs_linear_rgb, + ) + def getGrayColorSpace(): - '''Get a Java object that represents an RGB color space + """Get a Java object that represents an RGB color space See java.awt.color.ColorSpace: this returns the linear RGB color space - ''' - cs_gray = jutil.get_static_field('java/awt/color/ColorSpace', - 'CS_GRAY', 'I') - return jutil.static_call('java/awt/color/ColorSpace', 'getInstance', - '(I)Ljava/awt/color/ColorSpace;', - cs_gray) - -'''Constant for color model transparency indicating bitmask transparency''' -BITMASK = 'BITMASK' -'''Constant for color model transparency indicting an opaque color model''' -OPAQUE = 'OPAQUE' -'''Constant for color model transparency indicating a transparent color model''' -TRANSPARENT = 'TRANSPARENT' -'''Constant for color model transfer type indicating byte per pixel''' -TYPE_BYTE = 'TYPE_BYTE' -'''Constant for color model transfer type indicating unsigned short per pixel''' -TYPE_USHORT = 'TYPE_USHORT' -'''Constant for color model transfer type indicating integer per pixel''' -TYPE_INT = 'TYPE_INT' - -def getColorModel(color_space, - has_alpha=False, - is_alpha_premultiplied = False, - transparency = OPAQUE, - transfer_type = TYPE_BYTE): - '''Return a java.awt.image.ColorModel color model + """ + cs_gray = jutil.get_static_field("java/awt/color/ColorSpace", "CS_GRAY", "I") + return jutil.static_call( + "java/awt/color/ColorSpace", + "getInstance", + "(I)Ljava/awt/color/ColorSpace;", + cs_gray, + ) + + +"""Constant for color model transparency indicating bitmask transparency""" +BITMASK = "BITMASK" +"""Constant for color model transparency indicting an opaque color model""" +OPAQUE = "OPAQUE" +"""Constant for color model transparency indicating a transparent color model""" +TRANSPARENT = "TRANSPARENT" +"""Constant for color model transfer type indicating byte per pixel""" +TYPE_BYTE = "TYPE_BYTE" +"""Constant for color model transfer type indicating unsigned short per pixel""" +TYPE_USHORT = "TYPE_USHORT" +"""Constant for color model transfer type indicating integer per pixel""" +TYPE_INT = "TYPE_INT" + + +def getColorModel( + color_space, + has_alpha=False, + is_alpha_premultiplied=False, + transparency=OPAQUE, + transfer_type=TYPE_BYTE, +): + """Return a java.awt.image.ColorModel color model color_space - a java.awt.color.ColorSpace such as returned by getGrayColorSpace or getRGBColorSpace @@ -412,16 +556,22 @@ def getColorModel(color_space, transparency - one of BITMASK, OPAQUE or TRANSPARENT. transfer_type - one of TYPE_BYTE, TYPE_USHORT, TYPE_INT - ''' - jtransparency = jutil.get_static_field('java/awt/Transparency', - transparency, - 'I') - jtransfer_type = jutil.get_static_field('java/awt/image/DataBuffer', - transfer_type, 'I') - return jutil.make_instance('java/awt/image/ComponentColorModel', - '(Ljava/awt/color/ColorSpace;ZZII)V', - color_space, has_alpha, is_alpha_premultiplied, - jtransparency, jtransfer_type) + """ + jtransparency = jutil.get_static_field("java/awt/Transparency", transparency, "I") + jtransfer_type = jutil.get_static_field( + "java/awt/image/DataBuffer", transfer_type, "I" + ) + return jutil.make_instance( + "java/awt/image/ComponentColorModel", + "(Ljava/awt/color/ColorSpace;ZZII)V", + color_space, + has_alpha, + is_alpha_premultiplied, + jtransparency, + jtransfer_type, + ) + + if __name__ == "__main__": import wx import matplotlib.backends.backend_wxagg as mmmm @@ -431,22 +581,24 @@ def getColorModel(color_space, app = wx.PySimpleApp() -# dlg = wx.FileDialog(None) -# if dlg.ShowModal()==wx.ID_OK: -# filename = dlg.Path -# else: -# app.Exit() -# sys.exit() + # dlg = wx.FileDialog(None) + # if dlg.ShowModal()==wx.ID_OK: + # filename = dlg.Path + # else: + # app.Exit() + # sys.exit() - filename = '/Users/afraser/Desktop/cpa_example/images/AS_09125_050116000001_A01f00d0.png' - filename = '/Users/afraser/Desktop/wedding/header.jpg' + filename = ( + "/Users/afraser/Desktop/cpa_example/images/AS_09125_050116000001_A01f00d0.png" + ) + filename = "/Users/afraser/Desktop/wedding/header.jpg" - out_file = '/Users/afraser/Desktop/test_output.avi' + out_file = "/Users/afraser/Desktop/test_output.avi" try: os.remove(out_file) - print('previous output file deleted') + print("previous output file deleted") except: - print('no output file to delete') + print("no output file to delete") env = jutil.attach() ImageReader = make_image_reader_class() @@ -464,13 +616,13 @@ def getColorModel(color_space, t = 4 images = [] for tt in range(t): - images += [(np.random.rand(w, h, c) * 255).astype('uint8')] + images += [(np.random.rand(w, h, c) * 255).astype("uint8")] imeta = createOMEXMLMetadata() meta = wrap_imetadata_object(imeta) meta.createRoot() meta.setPixelsBigEndian(True, 0, 0) - meta.setPixelsDimensionOrder('XYCZT', 0, 0) + meta.setPixelsDimensionOrder("XYCZT", 0, 0) meta.setPixelsPixelType(FormatTools.getPixelTypeString(FormatTools.UINT8), 0, 0) meta.setPixelsSizeX(w, 0, 0) meta.setPixelsSizeY(h, 0, 0) @@ -479,30 +631,34 @@ def getColorModel(color_space, meta.setPixelsSizeT(t, 0, 0) meta.setLogicalChannelSamplesPerPixel(c, 0, 0) - print('big endian:', meta.getPixelsBigEndian(0, 0)) - print('dim order:', meta.getPixelsDimensionOrder(0, 0)) - print('pixel type:', meta.getPixelsPixelType(0, 0)) - print('size x:', meta.getPixelsSizeX(0, 0)) - print('size y:', meta.getPixelsSizeY(0, 0)) - print('size c:', meta.getPixelsSizeC(0, 0)) - print('size z:', meta.getPixelsSizeZ(0, 0)) - print('size t:', meta.getPixelsSizeT(0, 0)) - print('samples per pixel:', meta.getLogicalChannelSamplesPerPixel(0, 0)) + print("big endian:", meta.getPixelsBigEndian(0, 0)) + print("dim order:", meta.getPixelsDimensionOrder(0, 0)) + print("pixel type:", meta.getPixelsPixelType(0, 0)) + print("size x:", meta.getPixelsSizeX(0, 0)) + print("size y:", meta.getPixelsSizeY(0, 0)) + print("size c:", meta.getPixelsSizeC(0, 0)) + print("size z:", meta.getPixelsSizeZ(0, 0)) + print("size t:", meta.getPixelsSizeT(0, 0)) + print("samples per pixel:", meta.getLogicalChannelSamplesPerPixel(0, 0)) writer.setMetadataRetrieve(meta) writer.setId(out_file) for image in images: - if len(image.shape)==3 and image.shape[2] == 3: - save_im = np.array([image[:,:,0], image[:,:,1], image[:,:,2]]).astype(np.uint8).flatten() + if len(image.shape) == 3 and image.shape[2] == 3: + save_im = ( + np.array([image[:, :, 0], image[:, :, 1], image[:, :, 2]]) + .astype(np.uint8) + .flatten() + ) else: save_im = image.astype(np.uint8).flatten() writer.saveBytes(env.make_byte_array(save_im), (image is images[-1])) writer.close() - print('Done writing image :)') -# import PIL.Image as Image -# im = Image.open(out_file, 'r') -# im.show() + print("Done writing image :)") + # import PIL.Image as Image + # im = Image.open(out_file, 'r') + # im.show() jutil.detach() app.MainLoop() diff --git a/cellacdc/bioformats/log4j.py b/cellacdc/bioformats/log4j.py index 90d838b03..657d2f76a 100755 --- a/cellacdc/bioformats/log4j.py +++ b/cellacdc/bioformats/log4j.py @@ -7,8 +7,9 @@ import javabridge + def basic_config(): - '''Configure logging for "ERROR" level''' + """Configure logging for "ERROR" level""" log4j = javabridge.JClassWrapper("loci.common.Log4jTools") log4j.enableLogging() log4j.setRootLevel("ERROR") diff --git a/cellacdc/bioformats/metadatatools.py b/cellacdc/bioformats/metadatatools.py index ed6f4d1bf..cd95dd467 100755 --- a/cellacdc/bioformats/metadatatools.py +++ b/cellacdc/bioformats/metadatatools.py @@ -5,9 +5,7 @@ # Copyright (c) 2009-2014 Broad Institute # All rights reserved. -''' metadatatools.py - mechanism to wrap some bioformats metadata classes - -''' +"""metadatatools.py - mechanism to wrap some bioformats metadata classes""" from __future__ import absolute_import, unicode_literals @@ -16,191 +14,327 @@ from javabridge import jutil from .. import bioformats + def createOMEXMLMetadata(): - '''Creates an OME-XML metadata object using reflection, to avoid direct + """Creates an OME-XML metadata object using reflection, to avoid direct dependencies on the optional loci.formats.ome package. - ''' - return jutil.static_call('loci/formats/MetadataTools', 'createOMEXMLMetadata', '()Lloci/formats/meta/IMetadata;') + """ + return jutil.static_call( + "loci/formats/MetadataTools", + "createOMEXMLMetadata", + "()Lloci/formats/meta/IMetadata;", + ) class MetadataStore(object): - ''' ''' + """ """ + def __init__(self, o): self.o = o - createRoot = jutil.make_method('createRoot', '()V', '') + createRoot = jutil.make_method("createRoot", "()V", "") + def setPixelsBigEndian(self, bigEndian, imageIndex, binDataIndex): - '''Set the endianness for a particular image + """Set the endianness for a particular image bigEndian - True for big-endian, False for little-endian imageIndex - index of the image in question from IFormatReader.get_index? binDataIndex - ??? - ''' + """ # Post loci_tools 4.2 try: - jutil.call(self.o, 'setPixelsBinDataBigEndian', - '(Ljava/lang/Boolean;II)V', - bigEndian, imageIndex, binDataIndex) + jutil.call( + self.o, + "setPixelsBinDataBigEndian", + "(Ljava/lang/Boolean;II)V", + bigEndian, + imageIndex, + binDataIndex, + ) except jutil.JavaException: - jutil.call(self.o, 'setPixelsBigEndian', '(Ljava/lang/Boolean;II)V', - bigEndian, imageIndex, binDataIndex) + jutil.call( + self.o, + "setPixelsBigEndian", + "(Ljava/lang/Boolean;II)V", + bigEndian, + imageIndex, + binDataIndex, + ) def setPixelsDimensionOrder(self, dimension_order, imageIndex, binDataIndex): - '''Set the dimension order for a series''' + """Set the dimension order for a series""" # Post loci_tools 4.2 - use ome.xml.model.DimensionOrder try: jdimension_order = jutil.static_call( - 'ome/xml/model/enums/DimensionOrder', 'fromString', - '(Ljava/lang/String;)Lome/xml/model/enums/DimensionOrder;', - dimension_order) - jutil.call(self.o, 'setPixelsDimensionOrder', - '(Lome/xml/model/enums/DimensionOrder;I)V', - jdimension_order, imageIndex) + "ome/xml/model/enums/DimensionOrder", + "fromString", + "(Ljava/lang/String;)Lome/xml/model/enums/DimensionOrder;", + dimension_order, + ) + jutil.call( + self.o, + "setPixelsDimensionOrder", + "(Lome/xml/model/enums/DimensionOrder;I)V", + jdimension_order, + imageIndex, + ) except jutil.JavaException: - jutil.call(self.o, 'setPixelsDimensionOrder', - '(Ljava/lang/String;II)V', - dimension_order, imageIndex, binDataIndex) + jutil.call( + self.o, + "setPixelsDimensionOrder", + "(Ljava/lang/String;II)V", + dimension_order, + imageIndex, + binDataIndex, + ) setPixelsPixelType = jutil.make_method( - 'setPixelsPixelType', '(Ljava/lang/String;II)V', - '''Sets the pixel storage type + "setPixelsPixelType", + "(Ljava/lang/String;II)V", + """Sets the pixel storage type pixel_type - text representation of the type, e.g. "uint8" imageIndex - ? binDataIndex - ? WARNING: only available in BioFormats < 4.2 - ''') + """, + ) setPixelsType = jutil.make_method( - 'setPixelsType', '(Lome/xml/model/enums/PixelType;I)V', - '''Set the pixel storage type + "setPixelsType", + "(Lome/xml/model/enums/PixelType;I)V", + """Set the pixel storage type pixel_type - one of the enumerated values from PixelType. imageIndex - ? See the ome.xml.model.enums.PixelType and make_pixel_type_class's PixelType for possible values. - ''') + """, + ) def setPixelsSizeX(self, x, imageIndex, binDataIndex): try: - jutil.call(self.o, 'setPixelsSizeX', - '(Lome/xml/model/primitives/PositiveInteger;I)V', - PositiveInteger(x), imageIndex) + jutil.call( + self.o, + "setPixelsSizeX", + "(Lome/xml/model/primitives/PositiveInteger;I)V", + PositiveInteger(x), + imageIndex, + ) except jutil.JavaException: - jutil.call(self.o, 'setPixelsSizeX', - '(Ljava/lang/Integer;II)V', x, imageIndex, binDataIndex) + jutil.call( + self.o, + "setPixelsSizeX", + "(Ljava/lang/Integer;II)V", + x, + imageIndex, + binDataIndex, + ) def setPixelsSizeY(self, y, imageIndex, binDataIndex): try: - jutil.call(self.o, 'setPixelsSizeY', - '(Lome/xml/model/primitives/PositiveInteger;I)V', - PositiveInteger(y), imageIndex) + jutil.call( + self.o, + "setPixelsSizeY", + "(Lome/xml/model/primitives/PositiveInteger;I)V", + PositiveInteger(y), + imageIndex, + ) except jutil.JavaException: - jutil.call(self.o, 'setPixelsSizeY', - '(Ljava/lang/Integer;II)V', y, imageIndex, binDataIndex) + jutil.call( + self.o, + "setPixelsSizeY", + "(Ljava/lang/Integer;II)V", + y, + imageIndex, + binDataIndex, + ) def setPixelsSizeZ(self, z, imageIndex, binDataIndex): try: - jutil.call(self.o, 'setPixelsSizeZ', - '(Lome/xml/model/primitives/PositiveInteger;I)V', - PositiveInteger(z), imageIndex) + jutil.call( + self.o, + "setPixelsSizeZ", + "(Lome/xml/model/primitives/PositiveInteger;I)V", + PositiveInteger(z), + imageIndex, + ) except jutil.JavaException: - jutil.call(self.o, 'setPixelsSizeZ', - '(Ljava/lang/Integer;II)V', z, imageIndex, binDataIndex) + jutil.call( + self.o, + "setPixelsSizeZ", + "(Ljava/lang/Integer;II)V", + z, + imageIndex, + binDataIndex, + ) def setPixelsSizeC(self, c, imageIndex, binDataIndex): try: - jutil.call(self.o, 'setPixelsSizeC', - '(Lome/xml/model/primitives/PositiveInteger;I)V', - PositiveInteger(c), imageIndex) + jutil.call( + self.o, + "setPixelsSizeC", + "(Lome/xml/model/primitives/PositiveInteger;I)V", + PositiveInteger(c), + imageIndex, + ) except jutil.JavaException: - jutil.call(self.o, 'setPixelsSizeC', - '(Ljava/lang/Integer;II)V', c, imageIndex, binDataIndex) + jutil.call( + self.o, + "setPixelsSizeC", + "(Ljava/lang/Integer;II)V", + c, + imageIndex, + binDataIndex, + ) def setPixelsSizeT(self, t, imageIndex, binDataIndex): try: - jutil.call(self.o, 'setPixelsSizeT', - '(Lome/xml/model/primitives/PositiveInteger;I)V', - PositiveInteger(t), imageIndex) + jutil.call( + self.o, + "setPixelsSizeT", + "(Lome/xml/model/primitives/PositiveInteger;I)V", + PositiveInteger(t), + imageIndex, + ) except jutil.JavaException: - jutil.call(self.o, 'setPixelsSizeT', - '(Ljava/lang/Integer;II)V', t, imageIndex, binDataIndex) - - def setLogicalChannelSamplesPerPixel(self, samplesPerPixel, imageIndex, channelIndex): - 'For a particular LogicalChannel, sets number of channel components in the logical channel.' + jutil.call( + self.o, + "setPixelsSizeT", + "(Ljava/lang/Integer;II)V", + t, + imageIndex, + binDataIndex, + ) + + def setLogicalChannelSamplesPerPixel( + self, samplesPerPixel, imageIndex, channelIndex + ): + "For a particular LogicalChannel, sets number of channel components in the logical channel." try: - jutil.call(self.o, 'setChannelSamplesPerPixel', - '(Lome/xml/model/primitives/PositiveInteger;II)V', - PositiveInteger(samplesPerPixel), - imageIndex, channelIndex) + jutil.call( + self.o, + "setChannelSamplesPerPixel", + "(Lome/xml/model/primitives/PositiveInteger;II)V", + PositiveInteger(samplesPerPixel), + imageIndex, + channelIndex, + ) except jutil.JavaException: - jutil.call(self.o, 'setLogicalChannelSamplesPerPixel', - '(Ljava/lang/Integer;II)V', samplesPerPixel, - imageIndex, channelIndex) + jutil.call( + self.o, + "setLogicalChannelSamplesPerPixel", + "(Ljava/lang/Integer;II)V", + samplesPerPixel, + imageIndex, + channelIndex, + ) + setImageID = jutil.make_method( - 'setImageID', '(Ljava/lang/String;I)V', - '''Tag the indexed image with a name + "setImageID", + "(Ljava/lang/String;I)V", + """Tag the indexed image with a name id - the name, for instance Image:0 imageIndex - the index of the image (series???) - ''') + """, + ) setPixelsID = jutil.make_method( - 'setPixelsID', '(Ljava/lang/String;I)V', - '''Tag the pixels with a name (???) + "setPixelsID", + "(Ljava/lang/String;I)V", + """Tag the pixels with a name (???) id - the name, for instance Pixels:0 imageIndex - the index of the image (???) - ''') + """, + ) setChannelID = jutil.make_method( - 'setChannelID', '(Ljava/lang/String;II)V', - '''Give an ID name to the given channel + "setChannelID", + "(Ljava/lang/String;II)V", + """Give an ID name to the given channel id - the name of the channel imageIndex - (???) - channelIndex - index of the channel to be ID'ed''') + channelIndex - index of the channel to be ID'ed""", + ) + class MetadataRetrieve(object): - ''' ''' + """ """ + def __init__(self, o): self.o = o - getPixelsBigEndian = jutil.make_method('getPixelsBigEndian', '(II)Ljava/lang/Boolean;', - 'For a particular Pixels, gets endianness of the pixels set.') - getPixelsDimensionOrder = jutil.make_method('getPixelsDimensionOrder', '(II)Ljava/lang/String;', - 'For a particular Pixels, gets the dimension order of the pixels set.') - getPixelsPixelType = jutil.make_method('getPixelsPixelType', '(II)Ljava/lang/String;', - 'For a particular Pixels, gets the pixel type.') - getPixelsSizeX = jutil.make_method('getPixelsSizeX', '(II)Ljava/lang/Integer;', - 'For a particular Pixels, gets The size of an individual plane or section\'s X axis (width).') - getPixelsSizeY = jutil.make_method('getPixelsSizeY', '(II)Ljava/lang/Integer;', - 'For a particular Pixels, gets The size of an individual plane or section\'s Y axis (height).') - getPixelsSizeZ = jutil.make_method('getPixelsSizeZ', '(II)Ljava/lang/Integer;', - 'For a particular Pixels, gets number of optical sections per stack.') - getPixelsSizeC = jutil.make_method('getPixelsSizeC', '(II)Ljava/lang/Integer;', - 'For a particular Pixels, gets number of channels per timepoint.') - getPixelsSizeT = jutil.make_method('getPixelsSizeT', '(II)Ljava/lang/Integer;', - 'For a particular Pixels, gets number of timepoints.') - getLogicalChannelSamplesPerPixel = jutil.make_method('getLogicalChannelSamplesPerPixel', '(II)Ljava/lang/Integer;', - 'For a particular LogicalChannel, gets number of channel components in the logical channel.') - getChannelName = jutil.make_method('getChannelName', - '(II)Ljava/lang/String;', - '''Get the name for a particular channel. + getPixelsBigEndian = jutil.make_method( + "getPixelsBigEndian", + "(II)Ljava/lang/Boolean;", + "For a particular Pixels, gets endianness of the pixels set.", + ) + getPixelsDimensionOrder = jutil.make_method( + "getPixelsDimensionOrder", + "(II)Ljava/lang/String;", + "For a particular Pixels, gets the dimension order of the pixels set.", + ) + getPixelsPixelType = jutil.make_method( + "getPixelsPixelType", + "(II)Ljava/lang/String;", + "For a particular Pixels, gets the pixel type.", + ) + getPixelsSizeX = jutil.make_method( + "getPixelsSizeX", + "(II)Ljava/lang/Integer;", + "For a particular Pixels, gets The size of an individual plane or section's X axis (width).", + ) + getPixelsSizeY = jutil.make_method( + "getPixelsSizeY", + "(II)Ljava/lang/Integer;", + "For a particular Pixels, gets The size of an individual plane or section's Y axis (height).", + ) + getPixelsSizeZ = jutil.make_method( + "getPixelsSizeZ", + "(II)Ljava/lang/Integer;", + "For a particular Pixels, gets number of optical sections per stack.", + ) + getPixelsSizeC = jutil.make_method( + "getPixelsSizeC", + "(II)Ljava/lang/Integer;", + "For a particular Pixels, gets number of channels per timepoint.", + ) + getPixelsSizeT = jutil.make_method( + "getPixelsSizeT", + "(II)Ljava/lang/Integer;", + "For a particular Pixels, gets number of timepoints.", + ) + getLogicalChannelSamplesPerPixel = jutil.make_method( + "getLogicalChannelSamplesPerPixel", + "(II)Ljava/lang/Integer;", + "For a particular LogicalChannel, gets number of channel components in the logical channel.", + ) + getChannelName = jutil.make_method( + "getChannelName", + "(II)Ljava/lang/String;", + """Get the name for a particular channel. imageIndex - image # to query (use C = 0) - channelIndex - channel # to query''') - getChannelID = jutil.make_method('getChannelID', - '(II)Ljava/lang/String;', - '''Get the OME channel ID for a particular channel. + channelIndex - channel # to query""", + ) + getChannelID = jutil.make_method( + "getChannelID", + "(II)Ljava/lang/String;", + """Get the OME channel ID for a particular channel. imageIndex - image # to query (use C = 0) - channelIndex - channel # to query''') + channelIndex - channel # to query""", + ) def wrap_imetadata_object(o): - ''' Returns a python object wrapping the functionality of the given - IMetaData object (as returned by createOMEXMLMetadata) ''' + """Returns a python object wrapping the functionality of the given + IMetaData object (as returned by createOMEXMLMetadata)""" + class IMetadata(MetadataStore, MetadataRetrieve): - ''' ''' + """ """ + def __init__(self, o): MetadataStore.__init__(self, o) MetadataRetrieve.__init__(self, o) @@ -208,55 +342,91 @@ def __init__(self, o): return IMetadata(o) + __pixel_type_class = None + + def make_pixel_type_class(): - '''The class, ome.xml.model.enums.PixelType + """The class, ome.xml.model.enums.PixelType The Java class has enumerations for the various image data types such as UINT8 or DOUBLE - ''' + """ global __pixel_type_class if __pixel_type_class is None: + class PixelType(object): - '''Provide enums from ome.xml.model.enums.PixelType''' + """Provide enums from ome.xml.model.enums.PixelType""" + def __init__(self): - klass = jutil.get_env().find_class('ome/xml/model/enums/PixelType') - self.INT8 = jutil.get_static_field(klass, 'INT8', 'Lome/xml/model/enums/PixelType;') - self.INT16 = jutil.get_static_field(klass, 'INT16', 'Lome/xml/model/enums/PixelType;') - self.INT32 = jutil.get_static_field(klass, 'INT32', 'Lome/xml/model/enums/PixelType;') - self.UINT8 = jutil.get_static_field(klass, 'UINT8', 'Lome/xml/model/enums/PixelType;') - self.UINT16 = jutil.get_static_field(klass, 'UINT16', 'Lome/xml/model/enums/PixelType;') - self.UINT32 = jutil.get_static_field(klass, 'UINT32', 'Lome/xml/model/enums/PixelType;') - self.FLOAT = jutil.get_static_field(klass, 'FLOAT', 'Lome/xml/model/enums/PixelType;') - self.BIT = jutil.get_static_field(klass, 'BIT', 'Lome/xml/model/enums/PixelType;') - self.DOUBLE = jutil.get_static_field(klass, 'DOUBLE', 'Lome/xml/model/enums/PixelType;') - self.COMPLEX = jutil.get_static_field(klass, 'COMPLEX', 'Lome/xml/model/enums/PixelType;') - self.DOUBLECOMPLEX = jutil.get_static_field(klass, 'DOUBLECOMPLEX', 'Lome/xml/model/enums/PixelType;') + klass = jutil.get_env().find_class("ome/xml/model/enums/PixelType") + self.INT8 = jutil.get_static_field( + klass, "INT8", "Lome/xml/model/enums/PixelType;" + ) + self.INT16 = jutil.get_static_field( + klass, "INT16", "Lome/xml/model/enums/PixelType;" + ) + self.INT32 = jutil.get_static_field( + klass, "INT32", "Lome/xml/model/enums/PixelType;" + ) + self.UINT8 = jutil.get_static_field( + klass, "UINT8", "Lome/xml/model/enums/PixelType;" + ) + self.UINT16 = jutil.get_static_field( + klass, "UINT16", "Lome/xml/model/enums/PixelType;" + ) + self.UINT32 = jutil.get_static_field( + klass, "UINT32", "Lome/xml/model/enums/PixelType;" + ) + self.FLOAT = jutil.get_static_field( + klass, "FLOAT", "Lome/xml/model/enums/PixelType;" + ) + self.BIT = jutil.get_static_field( + klass, "BIT", "Lome/xml/model/enums/PixelType;" + ) + self.DOUBLE = jutil.get_static_field( + klass, "DOUBLE", "Lome/xml/model/enums/PixelType;" + ) + self.COMPLEX = jutil.get_static_field( + klass, "COMPLEX", "Lome/xml/model/enums/PixelType;" + ) + self.DOUBLECOMPLEX = jutil.get_static_field( + klass, "DOUBLECOMPLEX", "Lome/xml/model/enums/PixelType;" + ) + __pixel_type_class = PixelType return __pixel_type_class -MINIMUM = 'MINIMUM' -NO_OVERLAYS = 'NO_OVERLAYS' -ALL = 'ALL' + +MINIMUM = "MINIMUM" +NO_OVERLAYS = "NO_OVERLAYS" +ALL = "ALL" + def get_metadata_options(level): - '''Get an instance of the MetadataOptions interface + """Get an instance of the MetadataOptions interface level - MINIMUM, NO_OVERLAYS or ALL to set the metadata retrieval level The object returned can be used in setMetadataOptions in a format reader. - ''' - jlevel = jutil.get_static_field('loci/formats/in/MetadataLevel', level, - 'Lloci/formats/in/MetadataLevel;') - return jutil.make_instance('loci/formats/in/DefaultMetadataOptions', - '(Lloci/formats/in/MetadataLevel;)V', - jlevel) + """ + jlevel = jutil.get_static_field( + "loci/formats/in/MetadataLevel", level, "Lloci/formats/in/MetadataLevel;" + ) + return jutil.make_instance( + "loci/formats/in/DefaultMetadataOptions", + "(Lloci/formats/in/MetadataLevel;)V", + jlevel, + ) def PositiveInteger(some_number): - '''Return an instance of ome.xml.model.primitives.PositiveInteger + """Return an instance of ome.xml.model.primitives.PositiveInteger some_number - the number to be wrapped up in the class - ''' - return jutil.make_instance('ome/xml/model/primitives/PositiveInteger', - '(Ljava/lang/Integer;)V', some_number) + """ + return jutil.make_instance( + "ome/xml/model/primitives/PositiveInteger", + "(Ljava/lang/Integer;)V", + some_number, + ) diff --git a/cellacdc/bioformats/noseplugin.py b/cellacdc/bioformats/noseplugin.py index 0e6ca5efb..d95fbed7f 100755 --- a/cellacdc/bioformats/noseplugin.py +++ b/cellacdc/bioformats/noseplugin.py @@ -17,22 +17,24 @@ class Log4JPlugin(Plugin): - ''' + """ Plugin that initializes Log4J in order to avoid Bioformats error messages. - ''' + """ + enabled = False name = "log4j" - score = 90 # Less than the score of javabridge.nosetests.JavaBridgePlugin + score = 90 # Less than the score of javabridge.nosetests.JavaBridgePlugin def begin(self): - javabridge.static_call("org/apache/log4j/BasicConfigurator", - "configure", "()V") - log4j_logger = javabridge.static_call("org/apache/log4j/Logger", - "getRootLogger", - "()Lorg/apache/log4j/Logger;") - warn_level = javabridge.get_static_field("org/apache/log4j/Level","ERROR", - "Lorg/apache/log4j/Level;") - javabridge.call(log4j_logger, "setLevel", "(Lorg/apache/log4j/Level;)V", - warn_level) + javabridge.static_call("org/apache/log4j/BasicConfigurator", "configure", "()V") + log4j_logger = javabridge.static_call( + "org/apache/log4j/Logger", "getRootLogger", "()Lorg/apache/log4j/Logger;" + ) + warn_level = javabridge.get_static_field( + "org/apache/log4j/Level", "ERROR", "Lorg/apache/log4j/Level;" + ) + javabridge.call( + log4j_logger, "setLevel", "(Lorg/apache/log4j/Level;)V", warn_level + ) diff --git a/cellacdc/bioformats/omexml.py b/cellacdc/bioformats/omexml.py index 739054711..8f2381876 100755 --- a/cellacdc/bioformats/omexml.py +++ b/cellacdc/bioformats/omexml.py @@ -5,9 +5,7 @@ # Copyright (c) 2009-2014 Broad Institute # All rights reserved. -"""omexml.py read and write OME xml - -""" +"""omexml.py read and write OME xml""" from __future__ import absolute_import, unicode_literals @@ -15,24 +13,30 @@ from xml.etree import cElementTree as ElementTree import sys + if sys.version_info.major == 3: from io import StringIO - uenc = 'unicode' + + uenc = "unicode" else: from cStringIO import StringIO - uenc = 'utf-8' + + uenc = "utf-8" import datetime import logging from functools import reduce + logger = logging.getLogger(__file__) import re import uuid + def xsd_now(): - '''Return the current time in xsd:dateTime format''' + """Return the current time in xsd:dateTime format""" return datetime.datetime.now().isoformat() + DEFAULT_NOW = xsd_now() # # The namespaces @@ -69,7 +73,10 @@ def xsd_now(): -""".format(ns_ome_default=NS_DEFAULT.format(ns_key='ome'), ns_sa_default=NS_DEFAULT.format(ns_key='sa')) +""".format( + ns_ome_default=NS_DEFAULT.format(ns_key="ome"), + ns_sa_default=NS_DEFAULT.format(ns_key="sa"), +) # # These are the OME-XML pixel types - not all supported by subimager @@ -99,16 +106,16 @@ def xsd_now(): # The text for these can be found in # loci.formats.in.BaseTiffReader.initStandardMetadata # -'''IFD # 254''' +"""IFD # 254""" OM_NEW_SUBFILE_TYPE = "NewSubfileType" -'''IFD # 256''' +"""IFD # 256""" OM_IMAGE_WIDTH = "ImageWidth" -'''IFD # 257''' +"""IFD # 257""" OM_IMAGE_LENGTH = "ImageLength" -'''IFD # 258''' +"""IFD # 258""" OM_BITS_PER_SAMPLE = "BitsPerSample" -'''IFD # 262''' +"""IFD # 262""" OM_PHOTOMETRIC_INTERPRETATION = "PhotometricInterpretation" PI_WHITE_IS_ZERO = "WhiteIsZero" PI_BLACK_IS_ZERO = "BlackIsZero" @@ -120,89 +127,89 @@ def xsd_now(): PI_CIE_LAB = "CIELAB" PI_CFA_ARRAY = "Color Filter Array" -'''BioFormats infers the image type from the photometric interpretation''' +"""BioFormats infers the image type from the photometric interpretation""" OM_METADATA_PHOTOMETRIC_INTERPRETATION = "MetaDataPhotometricInterpretation" MPI_RGB = "RGB" MPI_MONOCHROME = "Monochrome" MPI_CMYK = "CMYK" -'''IFD # 263''' -OM_THRESHHOLDING = "Threshholding" # (sic) -'''IFD # 264 (but can be 265 if the orientation = 8)''' +"""IFD # 263""" +OM_THRESHHOLDING = "Threshholding" # (sic) +"""IFD # 264 (but can be 265 if the orientation = 8)""" OM_CELL_WIDTH = "CellWidth" -'''IFD # 265''' +"""IFD # 265""" OM_CELL_LENGTH = "CellLength" -'''IFD # 266''' +"""IFD # 266""" OM_FILL_ORDER = "FillOrder" -'''IFD # 279''' +"""IFD # 279""" OM_DOCUMENT_NAME = "Document Name" -'''IFD # 271''' +"""IFD # 271""" OM_MAKE = "Make" -'''IFD # 272''' +"""IFD # 272""" OM_MODEL = "Model" -'''IFD # 274''' +"""IFD # 274""" OM_ORIENTATION = "Orientation" -'''IFD # 277''' +"""IFD # 277""" OM_SAMPLES_PER_PIXEL = "SamplesPerPixel" -'''IFD # 280''' +"""IFD # 280""" OM_MIN_SAMPLE_VALUE = "MinSampleValue" -'''IFD # 281''' +"""IFD # 281""" OM_MAX_SAMPLE_VALUE = "MaxSampleValue" -'''IFD # 282''' +"""IFD # 282""" OM_X_RESOLUTION = "XResolution" -'''IFD # 283''' +"""IFD # 283""" OM_Y_RESOLUTION = "YResolution" -'''IFD # 284''' +"""IFD # 284""" OM_PLANAR_CONFIGURATION = "PlanarConfiguration" PC_CHUNKY = "Chunky" PC_PLANAR = "Planar" -'''IFD # 286''' +"""IFD # 286""" OM_X_POSITION = "XPosition" -'''IFD # 287''' +"""IFD # 287""" OM_Y_POSITION = "YPosition" -'''IFD # 288''' +"""IFD # 288""" OM_FREE_OFFSETS = "FreeOffsets" -'''IFD # 289''' +"""IFD # 289""" OM_FREE_BYTECOUNTS = "FreeByteCounts" -'''IFD # 290''' +"""IFD # 290""" OM_GRAY_RESPONSE_UNIT = "GrayResponseUnit" -'''IFD # 291''' +"""IFD # 291""" OM_GRAY_RESPONSE_CURVE = "GrayResponseCurve" -'''IFD # 292''' +"""IFD # 292""" OM_T4_OPTIONS = "T4Options" -'''IFD # 293''' +"""IFD # 293""" OM_T6_OPTIONS = "T6Options" -'''IFD # 296''' +"""IFD # 296""" OM_RESOLUTION_UNIT = "ResolutionUnit" -'''IFD # 297''' +"""IFD # 297""" OM_PAGE_NUMBER = "PageNumber" -'''IFD # 301''' +"""IFD # 301""" OM_TRANSFER_FUNCTION = "TransferFunction" -'''IFD # 305''' +"""IFD # 305""" OM_SOFTWARE = "Software" -'''IFD # 306''' +"""IFD # 306""" OM_DATE_TIME = "DateTime" -'''IFD # 315''' +"""IFD # 315""" OM_ARTIST = "Artist" -'''IFD # 316''' +"""IFD # 316""" OM_HOST_COMPUTER = "HostComputer" -'''IFD # 317''' +"""IFD # 317""" OM_PREDICTOR = "Predictor" -'''IFD # 318''' +"""IFD # 318""" OM_WHITE_POINT = "WhitePoint" -'''IFD # 322''' +"""IFD # 322""" OM_TILE_WIDTH = "TileWidth" -'''IFD # 323''' +"""IFD # 323""" OM_TILE_LENGTH = "TileLength" -'''IFD # 324''' +"""IFD # 324""" OM_TILE_OFFSETS = "TileOffsets" -'''IFD # 325''' +"""IFD # 325""" OM_TILE_BYTE_COUNT = "TileByteCount" -'''IFD # 332''' +"""IFD # 332""" OM_INK_SET = "InkSet" -'''IFD # 33432''' +"""IFD # 33432""" OM_COPYRIGHT = "Copyright" # # Well row/column naming conventions @@ -210,72 +217,82 @@ def xsd_now(): NC_LETTER = "letter" NC_NUMBER = "number" + def page_name_original_metadata(index): - '''Get the key name for the page name metadata data for the indexed tiff page + """Get the key name for the page name metadata data for the indexed tiff page These are TIFF IFD #'s 285+ index - zero-based index of the page - ''' + """ return "PageName #%d" % index + def get_text(node): - '''Get the contents of text nodes in a parent node''' + """Get the contents of text nodes in a parent node""" return node.text + def set_text(node, text): - '''Set the text of a parent''' + """Set the text of a parent""" node.text = text + def qn(namespace, tag_name): - '''Return the qualified name for a given namespace and tag name + """Return the qualified name for a given namespace and tag name This is the ElementTree representation of a qualified name - ''' + """ return "{%s}%s" % (namespace, tag_name) + def split_qn(qn): - '''Split a qualified tag name or return None if namespace not present''' - m = re.match('\{(.*)\}(.*)', qn) + """Split a qualified tag name or return None if namespace not present""" + m = re.match("\{(.*)\}(.*)", qn) return m.group(1), m.group(2) if m else None + def get_namespaces(node): - '''Get top-level XML namespaces from a node.''' - ns_lib = {'ome': None, 'sa': None, 'spw': None} + """Get top-level XML namespaces from a node.""" + ns_lib = {"ome": None, "sa": None, "spw": None} for child in node.iter(): ns = split_qn(child.tag)[0] match = re.match(NS_RE, ns) if match: - ns_key = match.group('ns_key').lower() + ns_key = match.group("ns_key").lower() ns_lib[ns_key] = ns return ns_lib + def get_float_attr(node, attribute): - '''Cast an element attribute to a float or return None if not present''' + """Cast an element attribute to a float or return None if not present""" attr = node.get(attribute) return None if attr is None else float(attr) + def get_int_attr(node, attribute): - '''Cast an element attribute to an int or return None if not present''' + """Cast an element attribute to an int or return None if not present""" attr = node.get(attribute) return None if attr is None else int(attr) + def make_text_node(parent, namespace, tag_name, text): - '''Either make a new node and add the given text or replace the text + """Either make a new node and add the given text or replace the text parent - the parent node to the node to be created or found namespace - the namespace of the node's qualified name tag_name - the tag name of the node's qualified name text - the text to be inserted - ''' + """ qname = qn(namespace, tag_name) node = parent.find(qname) if node is None: node = ElementTree.SubElement(parent, qname) set_text(node, text) + class OMEXML(object): - '''Reads and writes OME-XML with methods to get and set it. + """Reads and writes OME-XML with methods to get and set it. The OMEXML class has four main purposes: to parse OME-XML, to output OME-XML, to provide a structured mechanism for inspecting OME-XML and to @@ -316,7 +333,8 @@ class OMEXML(object): See the `OME-XML schema documentation `_. - ''' + """ + def __init__(self, xml=None): if xml is None: xml = default_xml @@ -329,7 +347,7 @@ def __init__(self, xml=None): self.dom = ElementTree.ElementTree(ElementTree.fromstring(xml)) # determine OME namespaces self.ns = get_namespaces(self.dom.getroot()) - if self.ns['ome'] is None: + if self.ns["ome"] is None: raise Exception("Error: String not in OME-XML format") def __str__(self): @@ -342,9 +360,9 @@ def __str__(self): ElementTree.register_namespace(ns_key, ns) ElementTree.register_namespace("om", NS_ORIGINAL_METADATA) result = StringIO() - ElementTree.ElementTree(self.root_node).write(result, - encoding=uenc, - method="xml") + ElementTree.ElementTree(self.root_node).write( + result, encoding=uenc, method="xml" + ) return result.getvalue() def to_xml(self, indent="\t", newline="\n", encoding=uenc): @@ -358,24 +376,27 @@ def root_node(self): return self.dom.getroot() def get_image_count(self): - '''The number of images (= series) specified by the XML''' - return len(self.root_node.findall(qn(self.ns['ome'], "Image"))) + """The number of images (= series) specified by the XML""" + return len(self.root_node.findall(qn(self.ns["ome"], "Image"))) def set_image_count(self, value): - '''Add or remove image nodes as needed''' + """Add or remove image nodes as needed""" assert value > 0 root = self.root_node if self.image_count > value: - image_nodes = root.find(qn(self.ns['ome'], "Image")) + image_nodes = root.find(qn(self.ns["ome"], "Image")) for image_node in image_nodes[value:]: root.remove(image_node) - while(self.image_count < value): - new_image = self.Image(ElementTree.SubElement(root, qn(self.ns['ome'], "Image"))) + while self.image_count < value: + new_image = self.Image( + ElementTree.SubElement(root, qn(self.ns["ome"], "Image")) + ) new_image.ID = str(uuid.uuid4()) new_image.Name = "default.png" new_image.AcquisitionDate = xsd_now() new_pixels = self.Pixels( - ElementTree.SubElement(new_image.node, qn(self.ns['ome'], "Pixels"))) + ElementTree.SubElement(new_image.node, qn(self.ns["ome"], "Pixels")) + ) new_pixels.ID = str(uuid.uuid4()) new_pixels.DimensionOrder = DO_XYCTZ new_pixels.PixelType = PT_UINT8 @@ -385,7 +406,8 @@ def set_image_count(self, value): new_pixels.SizeY = 512 new_pixels.SizeZ = 1 new_channel = self.Channel( - ElementTree.SubElement(new_pixels.node, qn(self.ns['ome'], "Channel"))) + ElementTree.SubElement(new_pixels.node, qn(self.ns["ome"], "Channel")) + ) new_channel.ID = "Channel%d:0" % self.image_count new_channel.Name = new_channel.ID new_channel.SamplesPerPixel = 1 @@ -398,21 +420,23 @@ def plates(self): @property def structured_annotations(self): - '''Return the structured annotations container + """Return the structured annotations container returns a wrapping of OME/StructuredAnnotations. It creates the element if it doesn't exist. - ''' - node = self.root_node.find(qn(self.ns['sa'], "StructuredAnnotations")) + """ + node = self.root_node.find(qn(self.ns["sa"], "StructuredAnnotations")) if node is None: node = ElementTree.SubElement( - self.root_node, qn(self.ns['sa'], "StructuredAnnotations")) + self.root_node, qn(self.ns["sa"], "StructuredAnnotations") + ) return self.StructuredAnnotations(node) class Image(object): - '''Representation of the OME/Image element''' + """Representation of the OME/Image element""" + def __init__(self, node): - '''Initialize with the DOM Image node''' + """Initialize with the DOM Image node""" self.node = node self.ns = get_namespaces(self.node) @@ -426,12 +450,14 @@ def set_ID(self, value): def get_Name(self): return self.node.get("Name") + def set_Name(self, value): self.node.set("Name", value) + Name = property(get_Name, set_Name) def get_AcquisitionDate(self): - '''The date in ISO-8601 format''' + """The date in ISO-8601 format""" acquired_date = self.node.find(qn(self.ns["ome"], "AcquisitionDate")) if acquired_date is None: return None @@ -441,14 +467,15 @@ def set_AcquisitionDate(self, date): acquired_date = self.node.find(qn(self.ns["ome"], "AcquisitionDate")) if acquired_date is None: acquired_date = ElementTree.SubElement( - self.node, qn(self.ns["ome"], "AcquisitionDate")) + self.node, qn(self.ns["ome"], "AcquisitionDate") + ) set_text(acquired_date, date) - AcquisitionDate = property(get_AcquisitionDate, set_AcquisitionDate) + AcquisitionDate = property(get_AcquisitionDate, set_AcquisitionDate) @property def Pixels(self): - '''The OME/Image/Pixels element. + """The OME/Image/Pixels element. Example: @@ -458,49 +485,57 @@ def Pixels(self): >>> stack_count = pixels.SizeZ >>> timepoint_count = pixels.SizeT - ''' - return OMEXML.Pixels(self.node.find(qn(self.ns['ome'], "Pixels"))) + """ + return OMEXML.Pixels(self.node.find(qn(self.ns["ome"], "Pixels"))) def roiref(self, index=0): - '''The OME/Image/ROIRef element''' - return OMEXML.ROIRef(self.node.findall(qn(self.ns['ome'], "ROIRef"))[index]) + """The OME/Image/ROIRef element""" + return OMEXML.ROIRef(self.node.findall(qn(self.ns["ome"], "ROIRef"))[index]) def get_roiref_count(self): - return len(self.node.findall(qn(self.ns['ome'], "ROIRef"))) + return len(self.node.findall(qn(self.ns["ome"], "ROIRef"))) + def set_roiref_count(self, value): - '''Add or remove roirefs as needed''' + """Add or remove roirefs as needed""" assert value > 0 if self.roiref_count > value: - roiref_nodes = self.node.find(qn(self.ns['ome'], "ROIRef")) + roiref_nodes = self.node.find(qn(self.ns["ome"], "ROIRef")) for roiref_node in roiref_nodes[value:]: self.node.remove(roiref_node) - while(self.roiref_count < value): + while self.roiref_count < value: iteration = self.roiref_count - 1 - new_roiref = OMEXML.ROIRef(ElementTree.SubElement(self.node, qn(self.ns['ome'], "ROIRef"))) + new_roiref = OMEXML.ROIRef( + ElementTree.SubElement(self.node, qn(self.ns["ome"], "ROIRef")) + ) new_roiref.set_ID("ROI:" + str(iteration)) roiref_count = property(get_roiref_count, set_roiref_count) def image(self, index=0): - '''Return an image node by index''' - return self.Image(self.root_node.findall(qn(self.ns['ome'], "Image"))[index]) + """Return an image node by index""" + return self.Image(self.root_node.findall(qn(self.ns["ome"], "Image"))[index]) class Channel(object): - '''The OME/Image/Pixels/Channel element''' + """The OME/Image/Pixels/Channel element""" + def __init__(self, node): self.node = node self.ns = get_namespaces(node) def get_ID(self): return self.node.get("ID") + def set_ID(self, value): self.node.set("ID", value) + ID = property(get_ID, set_ID) def get_Name(self): return self.node.get("Name") + def set_Name(self, value): self.node.set("Name", value) + Name = property(get_Name, set_Name) def get_SamplesPerPixel(self): @@ -508,9 +543,10 @@ def get_SamplesPerPixel(self): def set_SamplesPerPixel(self, value): self.node.set("SamplesPerPixel", str(value)) + SamplesPerPixel = property(get_SamplesPerPixel, set_SamplesPerPixel) - #--------------------- + # --------------------- # The following section is from the Allen Institute for Cell Science version of this file # which can be found at https://github.com/AllenCellModeling/aicsimageio/blob/master/aicsimageio/vendor/omexml.py class TiffData(object): @@ -526,7 +562,7 @@ def __init__(self, node): self.ns = get_namespaces(self.node) def get_FirstZ(self): - '''The Z index of the plane''' + """The Z index of the plane""" return get_int_attr(self.node, "FirstZ") def set_FirstZ(self, value): @@ -535,7 +571,7 @@ def set_FirstZ(self, value): FirstZ = property(get_FirstZ, set_FirstZ) def get_FirstC(self): - '''The channel index of the plane''' + """The channel index of the plane""" return get_int_attr(self.node, "FirstC") def set_FirstC(self, value): @@ -544,7 +580,7 @@ def set_FirstC(self, value): FirstC = property(get_FirstC, set_FirstC) def get_FirstT(self): - '''The T index of the plane''' + """The T index of the plane""" return get_int_attr(self.node, "FirstT") def set_FirstT(self, value): @@ -553,7 +589,7 @@ def set_FirstT(self, value): FirstT = property(get_FirstT, set_FirstT) def get_IFD(self): - '''plane index within tiff file''' + """plane index within tiff file""" return get_int_attr(self.node, "IFD") def set_IFD(self, value): @@ -562,7 +598,7 @@ def set_IFD(self, value): IFD = property(get_IFD, set_IFD) def get_plane_count(self): - '''How many planes in this TiffData. Should always be 1''' + """How many planes in this TiffData. Should always be 1""" return get_int_attr(self.node, "PlaneCount") def set_plane_count(self, value): @@ -571,18 +607,19 @@ def set_plane_count(self, value): plane_count = property(get_plane_count, set_plane_count) class Plane(object): - '''The OME/Image/Pixels/Plane element + """The OME/Image/Pixels/Plane element The Plane element represents one 2-dimensional image plane. It has the Z, C and T indices of the plane and optionally has the X, Y, Z, exposure time and a relative time delta. - ''' + """ + def __init__(self, node): self.node = node self.ns = get_namespaces(self.node) def get_TheZ(self): - '''The Z index of the plane''' + """The Z index of the plane""" return get_int_attr(self.node, "TheZ") def set_TheZ(self, value): @@ -591,7 +628,7 @@ def set_TheZ(self, value): TheZ = property(get_TheZ, set_TheZ) def get_TheC(self): - '''The channel index of the plane''' + """The channel index of the plane""" return get_int_attr(self.node, "TheC") def set_TheC(self, value): @@ -600,7 +637,7 @@ def set_TheC(self, value): TheC = property(get_TheC, set_TheC) def get_TheT(self): - '''The T index of the plane''' + """The T index of the plane""" return get_int_attr(self.node, "TheT") def set_TheT(self, value): @@ -609,7 +646,7 @@ def set_TheT(self, value): TheT = property(get_TheT, set_TheT) def get_DeltaT(self): - '''# of seconds since the beginning of the experiment''' + """# of seconds since the beginning of the experiment""" return get_float_attr(self.node, "DeltaT") def set_DeltaT(self, value): @@ -624,13 +661,13 @@ def get_ExposureTime(self): return None def set_ExposureTime(self, value): - '''Units are seconds. Duration of acquisition????''' + """Units are seconds. Duration of acquisition????""" self.node.set("ExposureTime", str(value)) ExposureTime = property(get_ExposureTime, set_ExposureTime) def get_PositionX(self): - '''X position of stage''' + """X position of stage""" position_x = self.node.get("PositionX") if position_x is not None: return float(position_x) @@ -642,7 +679,7 @@ def set_PositionX(self, value): PositionX = property(get_PositionX, set_PositionX) def get_PositionY(self): - '''Y position of stage''' + """Y position of stage""" return get_float_attr(self.node, "PositionY") def set_PositionY(self, value): @@ -651,7 +688,7 @@ def set_PositionY(self, value): PositionY = property(get_PositionY, set_PositionY) def get_PositionZ(self): - '''Z position of stage''' + """Z position of stage""" return get_float_attr(self.node, "PositionZ") def set_PositionZ(self, value): @@ -684,129 +721,155 @@ def set_PositionZUnit(self, value): PositionZUnit = property(get_PositionZUnit, set_PositionZUnit) class Pixels(object): - '''The OME/Image/Pixels element + """The OME/Image/Pixels element The Pixels element represents the pixels in an OME image and, for an OME-XML encoded image, will actually contain the base-64 encoded pixel data. It has the X, Y, Z, C, and T extents of the image and it specifies the channel interleaving and channel depth. - ''' + """ + def __init__(self, node): self.node = node self.ns = get_namespaces(self.node) def get_ID(self): return self.node.get("ID") + def set_ID(self, value): self.node.set("ID", value) + ID = property(get_ID, set_ID) def get_DimensionOrder(self): - '''The ordering of image planes in the file + """The ordering of image planes in the file A 5-letter code indicating the ordering of pixels, from the most rapidly varying to least. Use the DO_* constants (for instance DO_XYZCT) to compare and set this. - ''' + """ return self.node.get("DimensionOrder") + def set_DimensionOrder(self, value): self.node.set("DimensionOrder", value) + DimensionOrder = property(get_DimensionOrder, set_DimensionOrder) def get_PixelType(self): - '''The pixel bit type, for instance PT_UINT8 + """The pixel bit type, for instance PT_UINT8 The pixel type specifies the datatype used to encode pixels in the image data. You can use the PT_* constants to compare and set the pixel type. - ''' + """ return self.node.get("Type") def get_PhysicalSizeXUnit(self): - '''The unit of length of a pixel in X direction.''' + """The unit of length of a pixel in X direction.""" return self.node.get("PhysicalSizeXUnit") + def set_PhysicalSizeXUnit(self, value): self.node.set("PhysicalSizeXUnit", str(value)) + PhysicalSizeXUnit = property(get_PhysicalSizeXUnit, set_PhysicalSizeXUnit) def get_PhysicalSizeYUnit(self): - '''The unit of length of a pixel in Y direction.''' + """The unit of length of a pixel in Y direction.""" return self.node.get("PhysicalSizeYUnit") + def set_PhysicalSizeYUnit(self, value): self.node.set("PhysicalSizeYUnit", str(value)) + PhysicalSizeYUnit = property(get_PhysicalSizeYUnit, set_PhysicalSizeYUnit) def get_PhysicalSizeZUnit(self): - '''The unit of length of a voxel in Z direction.''' + """The unit of length of a voxel in Z direction.""" return self.node.get("PhysicalSizeZUnit") + def set_PhysicalSizeZUnit(self, value): self.node.set("PhysicalSizeZUnit", str(value)) + PhysicalSizeZUnit = property(get_PhysicalSizeZUnit, set_PhysicalSizeZUnit) def get_PhysicalSizeX(self): - '''The length of a single pixel in X direction.''' + """The length of a single pixel in X direction.""" return get_float_attr(self.node, "PhysicalSizeX") + def set_PhysicalSizeX(self, value): self.node.set("PhysicalSizeX", str(value)) + PhysicalSizeX = property(get_PhysicalSizeX, set_PhysicalSizeX) def get_PhysicalSizeY(self): - '''The length of a single pixel in Y direction.''' + """The length of a single pixel in Y direction.""" return get_float_attr(self.node, "PhysicalSizeY") + def set_PhysicalSizeY(self, value): self.node.set("PhysicalSizeY", str(value)) + PhysicalSizeY = property(get_PhysicalSizeY, set_PhysicalSizeY) def get_PhysicalSizeZ(self): - '''The size of a voxel in Z direction or None for 2D images.''' + """The size of a voxel in Z direction or None for 2D images.""" return get_float_attr(self.node, "PhysicalSizeZ") + def set_PhysicalSizeZ(self, value): self.node.set("PhysicalSizeZ", str(value)) + PhysicalSizeZ = property(get_PhysicalSizeZ, set_PhysicalSizeZ) def set_PixelType(self, value): self.node.set("Type", value) + PixelType = property(get_PixelType, set_PixelType) def get_SizeX(self): - '''The dimensions of the image in the X direction in pixels''' + """The dimensions of the image in the X direction in pixels""" return get_int_attr(self.node, "SizeX") + def set_SizeX(self, value): self.node.set("SizeX", str(value)) + SizeX = property(get_SizeX, set_SizeX) def get_SizeY(self): - '''The dimensions of the image in the Y direction in pixels''' + """The dimensions of the image in the Y direction in pixels""" return get_int_attr(self.node, "SizeY") + def set_SizeY(self, value): self.node.set("SizeY", str(value)) + SizeY = property(get_SizeY, set_SizeY) def get_SizeZ(self): - '''The dimensions of the image in the Z direction in pixels''' + """The dimensions of the image in the Z direction in pixels""" return get_int_attr(self.node, "SizeZ") def set_SizeZ(self, value): self.node.set("SizeZ", str(value)) + SizeZ = property(get_SizeZ, set_SizeZ) def get_SizeT(self): - '''The dimensions of the image in the T direction in pixels''' + """The dimensions of the image in the T direction in pixels""" return get_int_attr(self.node, "SizeT") def set_SizeT(self, value): self.node.set("SizeT", str(value)) + SizeT = property(get_SizeT, set_SizeT) def get_SizeC(self): - '''The dimensions of the image in the C direction in pixels''' + """The dimensions of the image in the C direction in pixels""" return get_int_attr(self.node, "SizeC") + def set_SizeC(self, value): self.node.set("SizeC", str(value)) + SizeC = property(get_SizeC, set_SizeC) def get_channel_count(self): - '''The number of channels in the image + """The number of channels in the image You can change the number of channels in the image by setting the channel_count: @@ -814,20 +877,21 @@ def get_channel_count(self): pixels.channel_count = 3 pixels.Channel(0).Name = "Red" ... - ''' - return len(self.node.findall(qn(self.ns['ome'], "Channel"))) + """ + return len(self.node.findall(qn(self.ns["ome"], "Channel"))) def set_channel_count(self, value): assert value > 0 channel_count = self.channel_count if channel_count > value: - channels = self.node.findall(qn(self.ns['ome'], "Channel")) + channels = self.node.findall(qn(self.ns["ome"], "Channel")) for channel in channels[value:]: self.node.remove(channel) else: for _ in range(channel_count, value): new_channel = OMEXML.Channel( - ElementTree.SubElement(self.node, qn(self.ns['ome'], "Channel"))) + ElementTree.SubElement(self.node, qn(self.ns["ome"], "Channel")) + ) new_channel.ID = str(uuid.uuid4()) new_channel.Name = new_channel.ID new_channel.SamplesPerPixel = 1 @@ -835,13 +899,14 @@ def set_channel_count(self, value): channel_count = property(get_channel_count, set_channel_count) def Channel(self, index=0): - '''Get the indexed channel from the Pixels element''' - channel = self.node.findall(qn(self.ns['ome'], "Channel"))[index] + """Get the indexed channel from the Pixels element""" + channel = self.node.findall(qn(self.ns["ome"], "Channel"))[index] return OMEXML.Channel(channel) + channel = Channel def get_plane_count(self): - '''The number of planes in the image + """The number of planes in the image An image with only one plane or an interleaved color plane will often not have any planes. @@ -852,49 +917,53 @@ def get_plane_count(self): pixels.plane_count = 3 pixels.Plane(0).TheZ=pixels.Plane(0).TheC=pixels.Plane(0).TheT=0 ... - ''' - return len(self.node.findall(qn(self.ns['ome'], "Plane"))) + """ + return len(self.node.findall(qn(self.ns["ome"], "Plane"))) def set_plane_count(self, value): assert value >= 0 plane_count = self.plane_count if plane_count > value: - planes = self.node.findall(qn(self.ns['ome'], "Plane")) + planes = self.node.findall(qn(self.ns["ome"], "Plane")) for plane in planes[value:]: self.node.remove(plane) else: for _ in range(plane_count, value): new_plane = OMEXML.Plane( - ElementTree.SubElement(self.node, qn(self.ns['ome'], "Plane"))) + ElementTree.SubElement(self.node, qn(self.ns["ome"], "Plane")) + ) plane_count = property(get_plane_count, set_plane_count) def Plane(self, index=0): - '''Get the indexed plane from the Pixels element''' - plane = self.node.findall(qn(self.ns['ome'], "Plane"))[index] + """Get the indexed plane from the Pixels element""" + plane = self.node.findall(qn(self.ns["ome"], "Plane"))[index] return OMEXML.Plane(plane) + plane = Plane def get_tiffdata_count(self): - return len(self.node.findall(qn(self.ns['ome'], "TiffData"))) + return len(self.node.findall(qn(self.ns["ome"], "TiffData"))) def set_tiffdata_count(self, value): assert value >= 0 - tiffdatas = self.node.findall(qn(self.ns['ome'], "TiffData")) + tiffdatas = self.node.findall(qn(self.ns["ome"], "TiffData")) for td in tiffdatas: self.node.remove(td) for _ in range(0, value): new_tiffdata = OMEXML.TiffData( - ElementTree.SubElement(self.node, qn(self.ns['ome'], "TiffData"))) + ElementTree.SubElement(self.node, qn(self.ns["ome"], "TiffData")) + ) tiffdata_count = property(get_tiffdata_count, set_tiffdata_count) def tiffdata(self, index=0): - data = self.node.findall(qn(self.ns['ome'], "TiffData"))[index] + data = self.node.findall(qn(self.ns["ome"], "TiffData"))[index] return OMEXML.TiffData(data) class Instrument(object): - '''Representation of the OME/Instrument element''' + """Representation of the OME/Instrument element""" + def __init__(self, node): self.node = node self.ns = get_namespaces(self.node) @@ -909,16 +978,16 @@ def set_ID(self, value): @property def Detector(self): - return OMEXML.Detector(self.node.find(qn(self.ns['ome'], "Detector"))) + return OMEXML.Detector(self.node.find(qn(self.ns["ome"], "Detector"))) @property def Objective(self): - return OMEXML.Objective(self.node.find(qn(self.ns['ome'], "Objective"))) - + return OMEXML.Objective(self.node.find(qn(self.ns["ome"], "Objective"))) def instrument(self, index=0): - return self.Instrument(self.root_node.findall(qn(self.ns['ome'], "Instrument"))[index]) - + return self.Instrument( + self.root_node.findall(qn(self.ns["ome"], "Instrument"))[index] + ) class Objective(object): def __init__(self, node): @@ -927,8 +996,10 @@ def __init__(self, node): def get_ID(self): return self.node.get("ID") + def set_ID(self, value): self.node.set("ID", value) + ID = property(get_ID, set_ID) def get_LensNA(self): @@ -936,18 +1007,25 @@ def get_LensNA(self): def set_LensNA(self, value): self.node.set("LensNA", value) + LensNA = property(get_LensNA, set_LensNA) def get_NominalMagnification(self): return self.node.get("NominalMagnification") + def set_NominalMagnification(self, value): self.node.set("NominalMagnification", value) - NominalMagnification = property(get_NominalMagnification, set_NominalMagnification) + + NominalMagnification = property( + get_NominalMagnification, set_NominalMagnification + ) def get_WorkingDistanceUnit(self): return get_int_attr(self.node, "WorkingDistanceUnit") + def set_WorkingDistanceUnit(self, value): self.node.set("WorkingDistanceUnit", str(value)) + WorkingDistanceUnit = property(get_WorkingDistanceUnit, set_WorkingDistanceUnit) class Detector(object): @@ -957,8 +1035,10 @@ def __init__(self, node): def get_ID(self): return self.node.get("ID") + def set_ID(self, value): self.node.set("ID", value) + ID = property(get_ID, set_ID) def get_Gain(self): @@ -966,24 +1046,27 @@ def get_Gain(self): def set_Gain(self, value): self.node.set("Gain", value) + Gain = property(get_Gain, set_Gain) def get_Model(self): return self.node.get("Model") + def set_Model(self, value): self.node.set("Model", value) + Model = property(get_Model, set_Model) def get_Type(self): return get_int_attr(self.node, "Type") + def set_Type(self, value): self.node.set("Type", str(value)) - Type = property(get_Type, set_Type) - + Type = property(get_Type, set_Type) class StructuredAnnotations(dict): - '''The OME/StructuredAnnotations element + """The OME/StructuredAnnotations element Structured annotations let OME-XML represent metadata from other file formats, for example the tag metadata in TIFF files. The @@ -1000,7 +1083,7 @@ class StructuredAnnotations(dict): callers will be using these to read tag data that's not represented in OME-XML such as the bits per sample and min and max sample values. - ''' + """ def __init__(self, node): self.node = node @@ -1016,8 +1099,9 @@ def __contains__(self, key): return self.has_key(key) def keys(self): - return filter(lambda x: x is not None, - [child.get("ID") for child in self.node]) + return filter( + lambda x: x is not None, [child.get("ID") for child in self.node] + ) def has_key(self, key): for child in self.node: @@ -1026,30 +1110,33 @@ def has_key(self, key): return False def add_original_metadata(self, key, value): - '''Create an original data key/value pair + """Create an original data key/value pair key - the original metadata's key name, for instance OM_PHOTOMETRIC_INTERPRETATION value - the value, for instance, "RGB" returns the ID for the structured annotation. - ''' + """ xml_annotation = ElementTree.SubElement( - self.node, qn(self.ns['sa'], "XMLAnnotation")) + self.node, qn(self.ns["sa"], "XMLAnnotation") + ) node_id = str(uuid.uuid4()) xml_annotation.set("ID", node_id) - xa_value = ElementTree.SubElement(xml_annotation, qn(self.ns['sa'], "Value")) + xa_value = ElementTree.SubElement( + xml_annotation, qn(self.ns["sa"], "Value") + ) ov = ElementTree.SubElement( - xa_value, qn(NS_ORIGINAL_METADATA, "OriginalMetadata")) + xa_value, qn(NS_ORIGINAL_METADATA, "OriginalMetadata") + ) ov_key = ElementTree.SubElement(ov, qn(NS_ORIGINAL_METADATA, "Key")) set_text(ov_key, key) - ov_value = ElementTree.SubElement( - ov, qn(NS_ORIGINAL_METADATA, "Value")) + ov_value = ElementTree.SubElement(ov, qn(NS_ORIGINAL_METADATA, "Value")) set_text(ov_value, value) return node_id def iter_original_metadata(self): - '''An iterator over the original metadata in structured annotations + """An iterator over the original metadata in structured annotations returns (, ()) @@ -1059,7 +1146,7 @@ def iter_original_metadata(self): is the original metadata key, typically one of the OM_* names of a TIFF tag is the value for the metadata - ''' + """ # # Here's the XML we're traversing: # @@ -1074,13 +1161,18 @@ def iter_original_metadata(self): # # # - for annotation_node in self.node.findall(qn(self.ns['sa'], "XMLAnnotation")): + for annotation_node in self.node.findall( + qn(self.ns["sa"], "XMLAnnotation") + ): # annotation_id = annotation_node.get("ID") - for xa_value_node in annotation_node.findall(qn(self.ns['sa'], "Value")): + for xa_value_node in annotation_node.findall( + qn(self.ns["sa"], "Value") + ): # for om_node in xa_value_node.findall( - qn(NS_ORIGINAL_METADATA, "OriginalMetadata")): + qn(NS_ORIGINAL_METADATA, "OriginalMetadata") + ): # key_node = om_node.find(qn(NS_ORIGINAL_METADATA, "Key")) value_node = om_node.find(qn(NS_ORIGINAL_METADATA, "Value")) @@ -1090,35 +1182,38 @@ def iter_original_metadata(self): if key_text is not None and value_text is not None: yield annotation_id, (key_text, value_text) else: - logger.warn("Original metadata was missing key or value:" + om_node.toxml()) + logger.warn( + "Original metadata was missing key or value:" + + om_node.toxml() + ) return def has_original_metadata(self, key): - '''True if there is an original metadata item with the given key''' - return any([k == key - for annotation_id, (k, v) - in self.iter_original_metadata()]) + """True if there is an original metadata item with the given key""" + return any( + [k == key for annotation_id, (k, v) in self.iter_original_metadata()] + ) def get_original_metadata_value(self, key, default=None): - '''Return the value for a particular original metadata key + """Return the value for a particular original metadata key key - key to search for default - default value to return if not found - ''' + """ for annotation_id, (k, v) in self.iter_original_metadata(): if k == key: return v return default def get_original_metadata_refs(self, ids): - '''For a given ID, get the matching original metadata references + """For a given ID, get the matching original metadata references ids - collection of IDs to match returns a dictionary of key to value - ''' + """ d = {} - for annotation_id, (k,v) in self.iter_original_metadata(): + for annotation_id, (k, v) in self.iter_original_metadata(): if annotation_id in ids: d[k] = v return d @@ -1128,13 +1223,14 @@ def OriginalMetadata(self): return OMEXML.OriginalMetadata(self) class OriginalMetadata(dict): - '''View original metadata as a dictionary + """View original metadata as a dictionary Original metadata holds "vendor-specific" metadata including TIFF tag values. - ''' + """ + def __init__(self, sa): - '''Initialized with the structured_annotations class instance''' + """Initialized with the structured_annotations class instance""" self.sa = sa def __getitem__(self, key): @@ -1154,9 +1250,9 @@ def __len__(self): return len(list(self.sa_iter_original_metadata())) def keys(self): - return [key - for annotation_id, (key, value) - in self.sa.iter_original_metadata()] + return [ + key for annotation_id, (key, value) in self.sa.iter_original_metadata() + ] def has_key(self, key): for annotation_id, (k, value) in self.sa.iter_original_metadata(): @@ -1169,38 +1265,41 @@ def iteritems(self): yield key, value class PlatesDucktype(object): - '''It looks like a list of plates''' + """It looks like a list of plates""" + def __init__(self, root): self.root = root self.ns = get_namespaces(self.root) def __getitem__(self, key): - plates = self.root.findall(qn(self.ns['spw'], "Plate")) + plates = self.root.findall(qn(self.ns["spw"], "Plate")) if isinstance(key, slice): return [OMEXML.Plate(plate) for plate in plates[key]] return OMEXML.Plate(plates[key]) def __len__(self): - return len(self.root.findall(qn(self.ns['spw'], "Plate"))) + return len(self.root.findall(qn(self.ns["spw"], "Plate"))) def __iter__(self): - for plate in self.root.iterfind(qn(self.ns['spw'], "Plate")): + for plate in self.root.iterfind(qn(self.ns["spw"], "Plate")): yield OMEXML.Plate(plate) - def newPlate(self, name, plate_id = str(uuid.uuid4())): + def newPlate(self, name, plate_id=str(uuid.uuid4())): new_plate_node = ElementTree.SubElement( - self.root, qn(self.ns['spw'], "Plate")) + self.root, qn(self.ns["spw"], "Plate") + ) new_plate = OMEXML.Plate(new_plate_node) new_plate.ID = plate_id new_plate.Name = name return new_plate class Plate(object): - '''The SPW:Plate element + """The SPW:Plate element This represents the plate element of the SPW schema: http://www.openmicroscopy.org/Schemas/SPW/2007-06/ - ''' + """ + def __init__(self, node): self.node = node self.ns = get_namespaces(self.node) @@ -1244,8 +1343,10 @@ def get_ColumnNamingConvention(self): def set_ColumnNamingConvention(self, value): assert value in (NC_LETTER, NC_NUMBER) self.node.set("ColumnNamingConvention", value) - ColumnNamingConvention = property(get_ColumnNamingConvention, - set_ColumnNamingConvention) + + ColumnNamingConvention = property( + get_ColumnNamingConvention, set_ColumnNamingConvention + ) def get_RowNamingConvention(self): # Consider a default if not defined of NC_LETTER @@ -1254,14 +1355,15 @@ def get_RowNamingConvention(self): def set_RowNamingConvention(self, value): assert value in (NC_LETTER, NC_NUMBER) self.node.set("RowNamingConvention", value) - RowNamingConvention = property(get_RowNamingConvention, - set_RowNamingConvention) + + RowNamingConvention = property(get_RowNamingConvention, set_RowNamingConvention) def get_WellOriginX(self): return get_float_attr(self.node, "WellOriginX") def set_WellOriginX(self, value): self.node.set("WellOriginX", str(value)) + WellOriginX = property(get_WellOriginX, set_WellOriginX) def get_WellOriginY(self): @@ -1269,6 +1371,7 @@ def get_WellOriginY(self): def set_WellOriginY(self, value): self.node.set("WellOriginY", str(value)) + WellOriginY = property(get_WellOriginY, set_WellOriginY) def get_Rows(self): @@ -1288,32 +1391,39 @@ def set_Columns(self, value): Columns = property(get_Columns, set_Columns) def get_Description(self): - description = self.node.find(qn(self.ns['spw'], "Description")) + description = self.node.find(qn(self.ns["spw"], "Description")) if description is None: return None return get_text(description) def set_Description(self, text): - make_text_node(self.node, self.ns['spw'], "Description", text) + make_text_node(self.node, self.ns["spw"], "Description", text) + Description = property(get_Description, set_Description) def get_Well(self): - '''The well dictionary / list''' + """The well dictionary / list""" return OMEXML.WellsDucktype(self) + Well = property(get_Well) def get_well_name(self, well): - '''Get a well's name, using the row and column convention''' - result = "".join([ - "%02d" % (i+1) if convention == NC_NUMBER - else "ABCDEFGHIJKLMNOP"[i] - for i, convention - in ((well.Row, self.RowNamingConvention or NC_LETTER), - (well.Column, self.ColumnNamingConvention or NC_NUMBER))]) + """Get a well's name, using the row and column convention""" + result = "".join( + [ + "%02d" % (i + 1) + if convention == NC_NUMBER + else "ABCDEFGHIJKLMNOP"[i] + for i, convention in ( + (well.Row, self.RowNamingConvention or NC_LETTER), + (well.Column, self.ColumnNamingConvention or NC_NUMBER), + ) + ] + ) return result class WellsDucktype(dict): - '''The WellsDucktype lets you retrieve and create wells + """The WellsDucktype lets you retrieve and create wells The WellsDucktype looks like a dictionary but lets you reference the wells in a plate using indexing. Types of indexes: @@ -1326,17 +1436,18 @@ class WellsDucktype(dict): by ID - e.g. plate.Well["Well:0:0:0"] If the ducktype is unable to parse a well name, it assumes you're using an ID. - ''' + """ + def __init__(self, plate): self.plate_node = plate.node self.plate = plate self.ns = get_namespaces(self.plate_node) def __len__(self): - return len(self.plate_node.findall(qn(self.ns['spw'], "Well"))) + return len(self.plate_node.findall(qn(self.ns["spw"], "Well"))) def __getitem__(self, key): - all_wells = self.plate_node.findall(qn(self.ns['spw'], "Well")) + all_wells = self.plate_node.findall(qn(self.ns["spw"], "Well")) if isinstance(key, slice): return [OMEXML.Well(w) for w in all_wells[key]] if hasattr(key, "__len__") and len(key) == 2: @@ -1357,26 +1468,27 @@ def __getitem__(self, key): return None def __iter__(self): - '''Return the standard name for all wells on the plate + """Return the standard name for all wells on the plate for instance, 'B03' for a well with Row=1, Column=2 for a plate with the standard row and column naming convention - ''' - all_wells = self.plate_node.findall(qn(self.ns['spw'], "Well")) + """ + all_wells = self.plate_node.findall(qn(self.ns["spw"], "Well")) well = OMEXML.Well(None) for w in all_wells: well.node = w yield self.plate.get_well_name(well) - def new(self, row, column, well_id = str(uuid.uuid4())): - '''Create a new well at the given row and column + def new(self, row, column, well_id=str(uuid.uuid4())): + """Create a new well at the given row and column row - index of well's row column - index of well's column well_id - the ID attribute for the well - ''' + """ well_node = ElementTree.SubElement( - self.plate_node, qn(self.ns['spw'], "Well")) + self.plate_node, qn(self.ns["spw"], "Well") + ) well = OMEXML.Well(well_node) well.Row = row well.Column = column @@ -1389,24 +1501,31 @@ def __init__(self, node): def get_Column(self): return get_int_attr(self.node, "Column") + def set_Column(self, value): self.node.set("Column", str(value)) + Column = property(get_Column, set_Column) def get_Row(self): return get_int_attr(self.node, "Row") + def set_Row(self, value): self.node.set("Row", str(value)) + Row = property(get_Row, set_Row) def get_ID(self): return self.node.get("ID") + def set_ID(self, value): self.node.set("ID", value) + ID = property(get_ID, set_ID) def get_Sample(self): return OMEXML.WellSampleDucktype(self.node) + Sample = property(get_Sample) def get_ExternalDescription(self): @@ -1434,59 +1553,64 @@ def set_Color(self, value): Color = property(get_Color, set_Color) class WellSampleDucktype(list): - '''The WellSample elements in a well + """The WellSample elements in a well This is made to look like an indexable list so that you can do things like: wellsamples[0:2] - ''' + """ + def __init__(self, well_node): self.well_node = well_node self.ns = get_namespaces(self.well_node) def __len__(self): - return len(self.well_node.findall(qn(self.ns['spw'], "WellSample"))) + return len(self.well_node.findall(qn(self.ns["spw"], "WellSample"))) def __getitem__(self, key): - all_samples = self.well_node.findall(qn(self.ns['spw'], "WellSample")) + all_samples = self.well_node.findall(qn(self.ns["spw"], "WellSample")) if isinstance(key, slice): - return [OMEXML.WellSample(s) - for s in all_samples[key]] + return [OMEXML.WellSample(s) for s in all_samples[key]] return OMEXML.WellSample(all_samples[int(key)]) def __iter__(self): - '''Iterate through the well samples.''' - all_samples = self.well_node.findall(qn(self.ns['spw'], "WellSample")) + """Iterate through the well samples.""" + all_samples = self.well_node.findall(qn(self.ns["spw"], "WellSample")) for s in all_samples: yield OMEXML.WellSample(s) - def new(self, wellsample_id = str(uuid.uuid4()), index = None): - '''Create a new well sample - ''' + def new(self, wellsample_id=str(uuid.uuid4()), index=None): + """Create a new well sample""" if index is None: index = reduce(max, [s.Index for s in self], -1) + 1 new_node = ElementTree.SubElement( - self.well_node, qn(self.ns['spw'], "WellSample")) + self.well_node, qn(self.ns["spw"], "WellSample") + ) s = OMEXML.WellSample(new_node) s.ID = wellsample_id s.Index = index class WellSample(object): - '''The WellSample is a location within a well''' + """The WellSample is a location within a well""" + def __init__(self, node): self.node = node self.ns = get_namespaces(self.node) def get_ID(self): return self.node.get("ID") + def set_ID(self, value): self.node.set("ID", value) + ID = property(get_ID, set_ID) def get_PositionX(self): return get_float_attr(self.node, "PositionX") + def set_PositionX(self, value): self.node.set("PositionX", str(value)) + PositionX = property(get_PositionX, set_PositionX) def get_PositionY(self): @@ -1494,6 +1618,7 @@ def get_PositionY(self): def set_PositionY(self, value): self.node.set("PositionY", str(value)) + PositionY = property(get_PositionY, set_PositionY) def get_Timepoint(self): @@ -1503,6 +1628,7 @@ def set_Timepoint(self, value): if isinstance(value, datetime.datetime): value = value.isoformat() self.node.set("Timepoint", value) + Timepoint = property(get_Timepoint, set_Timepoint) def get_Index(self): @@ -1514,22 +1640,22 @@ def set_Index(self, value): Index = property(get_Index, set_Index) def get_ImageRef(self): - '''Get the ID of the image of this site''' - ref = self.node.find(qn(self.ns['spw'], "ImageRef")) + """Get the ID of the image of this site""" + ref = self.node.find(qn(self.ns["spw"], "ImageRef")) if ref is None: return None return ref.get("ID") def set_ImageRef(self, value): - '''Add a reference to the image of this site''' - ref = self.node.find(qn(self.ns['spw'], "ImageRef")) + """Add a reference to the image of this site""" + ref = self.node.find(qn(self.ns["spw"], "ImageRef")) if ref is None: - ref = ElementTree.SubElement(self.node, qn(self.ns['spw'], "ImageRef")) + ref = ElementTree.SubElement(self.node, qn(self.ns["spw"], "ImageRef")) ref.set("ID", value) + ImageRef = property(get_ImageRef, set_ImageRef) class ROIRef(object): - def __init__(self, node): self.node = node self.ns = get_namespaces(self.node) @@ -1538,36 +1664,38 @@ def get_ID(self): return self.node.get("ID") def set_ID(self, value): - ''' + """ ID will automatically be in the format "ROI:value" and must match the ROI ID (that uses the same formatting) - ''' + """ self.node.set("ID", "ROI:" + str(value)) ID = property(get_ID, set_ID) def get_roi_count(self): - return len(self.root_node.findall(qn(self.ns['ome'], "ROI"))) + return len(self.root_node.findall(qn(self.ns["ome"], "ROI"))) def set_roi_count(self, value): - '''Add or remove roi nodes as needed''' + """Add or remove roi nodes as needed""" assert value > 0 root = self.root_node if self.roi_count > value: - roi_nodes = root.find(qn(self.ns['ome'], "ROI")) + roi_nodes = root.find(qn(self.ns["ome"], "ROI")) for roi_node in roi_nodes[value:]: root.remove(roi_node) - while(self.roi_count < value): + while self.roi_count < value: iteration = self.roi_count - 1 - new_roi = self.ROI(ElementTree.SubElement(root, qn(self.ns['ome'], "ROI"))) + new_roi = self.ROI(ElementTree.SubElement(root, qn(self.ns["ome"], "ROI"))) new_roi.ID = str(iteration) new_roi.Name = "Marker " + str(iteration) new_Union = self.Union( - ElementTree.SubElement(new_roi.node, qn(self.ns['ome'], "Union"))) + ElementTree.SubElement(new_roi.node, qn(self.ns["ome"], "Union")) + ) new_Rectangle = self.Rectangle( - ElementTree.SubElement(new_Union.node, qn(self.ns['ome'], "Rectangle"))) + ElementTree.SubElement(new_Union.node, qn(self.ns["ome"], "Rectangle")) + ) new_Rectangle.set_ID("Shape:" + str(iteration) + ":0") new_Rectangle.set_TheZ(0) new_Rectangle.set_TheC(0) @@ -1583,11 +1711,10 @@ def set_roi_count(self, value): roi_count = property(get_roi_count, set_roi_count) def roi(self, index=0): - '''Return an ROI node by index''' - return self.ROI(self.root_node.findall(qn(self.ns['ome'], "ROI"))[index]) + """Return an ROI node by index""" + return self.ROI(self.root_node.findall(qn(self.ns["ome"], "ROI"))[index]) class ROI(object): - def __init__(self, node): self.node = node self.ns = get_namespaces(self.node) @@ -1596,11 +1723,11 @@ def get_ID(self): return self.node.get("ID") def set_ID(self, value): - ''' + """ ID will automatically be in the format "ROI:value" and must match the ROIRef ID (that uses the same formatting) - ''' + """ self.node.set("ID", "ROI:" + str(value)) ID = property(get_ID, set_ID) @@ -1615,21 +1742,19 @@ def set_Name(self, value): @property def Union(self): - '''The OME/ROI/Union element.''' - return OMEXML.Union(self.node.find(qn(self.ns['ome'], "Union"))) + """The OME/ROI/Union element.""" + return OMEXML.Union(self.node.find(qn(self.ns["ome"], "Union"))) class Union(object): - def __init__(self, node): self.node = node self.ns = get_namespaces(self.node) def Rectangle(self): - '''The OME/ROI/Union element. Currently only rectangle ROIs are available.''' - return OMEXML.Rectangle(self.node.find(qn(self.ns['ome'], "Rectangle"))) + """The OME/ROI/Union element. Currently only rectangle ROIs are available.""" + return OMEXML.Rectangle(self.node.find(qn(self.ns["ome"], "Rectangle"))) class Rectangle(object): - def __init__(self, node): self.node = node self.ns = get_namespaces(self.node) @@ -1654,12 +1779,12 @@ def get_StrokeWidth(self): return self.node.get("StrokeWidth") def set_StrokeWidth(self, value): - ''' + """ Colour is set using RGBA to integer conversion calculated using function from: https://docs.openmicroscopy.org/omero/5.5.1/developers/Python.html RGB colours: Red=-16776961, Green=16711935, Blue=65535 - ''' + """ self.node.set("StrokeWidth", str(value)) StrokeWidth = property(get_StrokeWidth, set_StrokeWidth) @@ -1705,7 +1830,7 @@ def set_Y(self, value): Y = property(get_Y, set_Y) def get_TheZ(self): - '''The Z index of the plane''' + """The Z index of the plane""" return get_int_attr(self.node, "TheZ") def set_TheZ(self, value): @@ -1714,7 +1839,7 @@ def set_TheZ(self, value): TheZ = property(get_TheZ, set_TheZ) def get_TheC(self): - '''The channel index of the plane''' + """The channel index of the plane""" return get_int_attr(self.node, "TheC") def set_TheC(self, value): @@ -1723,7 +1848,7 @@ def set_TheC(self, value): TheC = property(get_TheC, set_TheC) def get_TheT(self): - '''The T index of the plane''' + """The T index of the plane""" return get_int_attr(self.node, "TheT") def set_TheT(self, value): diff --git a/cellacdc/bioformats/tests/locate_jars.py b/cellacdc/bioformats/tests/locate_jars.py index 9c284289e..da3a162c1 100755 --- a/cellacdc/bioformats/tests/locate_jars.py +++ b/cellacdc/bioformats/tests/locate_jars.py @@ -11,9 +11,9 @@ jars = bioformats.JARS print(jars) jv.start_vm(class_path=jars) -paths = jv.JClassWrapper('java.lang.System').getProperty('java.class.path').split(";") +paths = jv.JClassWrapper("java.lang.System").getProperty("java.class.path").split(";") for path in paths: - print("%s: %s" %("exists" if os.path.isfile(path) else "missing", path)) + print("%s: %s" % ("exists" if os.path.isfile(path) else "missing", path)) jv.kill_vm() diff --git a/cellacdc/cca_functions.py b/cellacdc/cca_functions.py index cd87389b5..df55e51ce 100755 --- a/cellacdc/cca_functions.py +++ b/cellacdc/cca_functions.py @@ -15,6 +15,7 @@ from typing import Iterable from . import GUI_INSTALLED + if GUI_INSTALLED: from qtpy.QtWidgets import QFileDialog from . import widgets @@ -24,27 +25,29 @@ from . import myutils, prompts, html_utils, printl default_summable_columns = ( - 'cell_area_um2', - 'cell_vol_fl', - 'cell_vol_vox', - 'cell_area_pxl', - 'num_spots', - 'ref_ch_vol_um3', - 'ref_ch_num_fragments', - 'ref_ch_vol_vox' + "cell_area_um2", + "cell_vol_fl", + "cell_vol_vox", + "cell_area_pxl", + "num_spots", + "ref_ch_vol_um3", + "ref_ch_num_fragments", + "ref_ch_vol_vox", ) + def configuration_dialog(): app, _ = _run._setup_app(splashscreen=False) - + continue_selection = True data_dirs = [] positions = [] while continue_selection: MostRecentPath = myutils.getMostRecentPath() data_dir = QFileDialog.getExistingDirectory( - None, 'Select experiment folder containing Position_n folders ', - MostRecentPath + None, + "Select experiment folder containing Position_n folders ", + MostRecentPath, ) if not data_dir: continue_selection = False @@ -52,34 +55,34 @@ def configuration_dialog(): myutils.addToRecentPaths(data_dir) foldername = os.path.basename(data_dir) - if foldername == 'Images': + if foldername == "Images": pos_path = os.path.dirname(data_dir) data_dir = os.path.dirname(pos_path) pos = [os.path.basename(pos_path)] - elif foldername.find('Position_') != -1: + elif foldername.find("Position_") != -1: pos_path = data_dir data_dir = os.path.dirname(data_dir) pos = [os.path.basename(pos_path)] else: available_pos = myutils.get_pos_foldernames(data_dir) if not available_pos: - print('******************************') - print('Selected folder does not contain any Position folders.') + print("******************************") + print("Selected folder does not contain any Position folders.") print(f'Selected folder: "{data_dir}"') - print('******************************') + print("******************************") raise FileNotFoundError win = widgets.QDialogListbox( - 'Position Selection', - 'Select which position(s) you want to analyse', - available_pos + "Position Selection", + "Select which position(s) you want to analyse", + available_pos, ) win.show() win.exec_() if win.cancel: - print('******************************') - print('Execution aborted by the user') - print('******************************') + print("******************************") + print("Execution aborted by the user") + print("******************************") raise InterruptedError pos = win.selectedItemsText @@ -87,140 +90,160 @@ def configuration_dialog(): positions.append(pos) msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Do you wish to select Positions from other experiments?' + "Do you wish to select Positions from other experiments?" ) yes, no = msg.question( - None, 'Continue selection?', txt, buttonsTexts=(' Yes ', ' No ') + None, "Continue selection?", txt, buttonsTexts=(" Yes ", " No ") ) continue_selection = msg.clickedButton == yes if len(data_dirs) == 0: - print('******************************') + print("******************************") print("No positions selected!") - print('******************************') + print("******************************") raise IndexError("No positions selected!") return data_dirs, positions, app + def find_available_channels(filenames, first_pos_dir): ch_name_selector = prompts.select_channel_name() - ch_names, warn = ch_name_selector.get_available_channels( - filenames, first_pos_dir - ) + ch_names, warn = ch_name_selector.get_available_channels(filenames, first_pos_dir) return ch_names, ch_name_selector.basename + def get_segm_endname(images_path, basename): segm_files = load.get_segm_files(images_path) - segm_endnames = load.get_endnames( - basename, segm_files - ) + segm_endnames = load.get_endnames(basename, segm_files) if not segm_endnames: msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" The following position does not contain valid segmentation files.

{images_path}
""") - msg.critical(None, 'Segmentation file(s) not found', txt) + msg.critical(None, "Segmentation file(s) not found", txt) raise FileNotFoundError(f'Segmentation files not found in "{images_path}"') if len(segm_endnames) == 1: return segm_endnames[0] - + selectSegmWin = widgets.QDialogListbox( - 'Select segmentation file', - 'Select segmentation file to use as ROI:\n', - segm_endnames, multiSelection=False, parent=None + "Select segmentation file", + "Select segmentation file to use as ROI:\n", + segm_endnames, + multiSelection=False, + parent=None, ) selectSegmWin.exec_() if selectSegmWin.cancel: - raise FileNotFoundError(f'Segmentation file selection aborted by the user.') - + raise FileNotFoundError(f"Segmentation file selection aborted by the user.") + return selectSegmWin.selectedItemsText[0] - - + + def calculate_downstream_data( - file_names, - image_folders, - positions, - channels, - segm_endname, - force_recalculation=False, - calculate_fluo_metrics=True, - save_features_to_acdc_df=False, - ): + file_names, + image_folders, + positions, + channels, + segm_endname, + force_recalculation=False, + calculate_fluo_metrics=True, + save_features_to_acdc_df=False, +): no_of_channels = len(channels) overall_df = pd.DataFrame() for file_idx, file in enumerate(file_names): for pos_idx, pos_dir in enumerate(image_folders[file_idx]): - channel_data = ('placeholder')*no_of_channels - print(f'Load files for {file}, {positions[file_idx][pos_idx]}...') + channel_data = ("placeholder") * no_of_channels + print(f"Load files for {file}, {positions[file_idx][pos_idx]}...") acdc_df_path = None try: *channel_data, seg_mask, cc_data, metadata, cc_props, acdc_df_path = ( _load_files( - pos_dir, channels, segm_endname, - load_channels_data=calculate_fluo_metrics + pos_dir, + channels, + segm_endname, + load_channels_data=calculate_fluo_metrics, ) ) except TypeError: - print(f'File {file}, position {positions[file_idx][pos_idx]} skipped due to missing segmentation mask/CC annotations.') + print( + f"File {file}, position {positions[file_idx][pos_idx]} skipped due to missing segmentation mask/CC annotations." + ) continue - print(f'Number of cells in position: {len(cc_data.Cell_ID.unique())}') - print(f'Number of annotated frames in position: {cc_data.frame_i.max()+1}') + print(f"Number of cells in position: {len(cc_data.Cell_ID.unique())}") + print( + f"Number of annotated frames in position: {cc_data.frame_i.max() + 1}" + ) cc_data = _rename_columns(cc_data) is_timelapse_data, is_zstack_data = False, False - if int(metadata.loc['SizeT'])>1: - is_timelapse_data=True - if int(metadata.loc['SizeZ'])>1: - is_zstack_data=True + if int(metadata.loc["SizeT"]) > 1: + is_timelapse_data = True + if int(metadata.loc["SizeZ"]) > 1: + is_zstack_data = True if cc_props is not None and not force_recalculation: - print('Cell Cycle property data already existing, loaded from disk...') - overall_df = pd.concat([overall_df, cc_props], ignore_index=True).reset_index(drop=True) + print("Cell Cycle property data already existing, loaded from disk...") + overall_df = pd.concat( + [overall_df, cc_props], ignore_index=True + ).reset_index(drop=True) else: - print(f'Calculate regionprops on each frame based on Segmentation...') - rp_df = _calculate_rp_df(seg_mask, is_timelapse_data, is_zstack_data, metadata, max_frame=cc_data.frame_i.max()+1) - print(f'Calculate signal metrics for every channel and cell...') + print(f"Calculate regionprops on each frame based on Segmentation...") + rp_df = _calculate_rp_df( + seg_mask, + is_timelapse_data, + is_zstack_data, + metadata, + max_frame=cc_data.frame_i.max() + 1, + ) + print(f"Calculate signal metrics for every channel and cell...") flu_signal_df = _calculate_flu_signal( seg_mask, channel_data, channels, cc_data, is_timelapse_data, - is_zstack_data + is_zstack_data, ) temp_df = cc_data.merge( - rp_df, on=['frame_i', 'Cell_ID'], how='left', - suffixes=('_gui', '') + rp_df, on=["frame_i", "Cell_ID"], how="left", suffixes=("_gui", "") ) temp_df = temp_df.merge( - flu_signal_df, on=['frame_i', 'Cell_ID'], how='left', - suffixes=('_gui', '') + flu_signal_df, + on=["frame_i", "Cell_ID"], + how="left", + suffixes=("_gui", ""), ) # calculate amount of corrected signal by multiplying mean with area if is_timelapse_data: for channel in channels: - temp_df[f'{channel}_corrected_amount'] = ( - temp_df[f'{channel}_corrected_mean'] - * temp_df['area'] + temp_df[f"{channel}_corrected_amount"] = ( + temp_df[f"{channel}_corrected_mean"] * temp_df["area"] ) try: - temp_df[f'{channel}_corrected_concentration'] = ( - temp_df[f'{channel}_corrected_amount'] - / temp_df['cell_vol_fl'] + temp_df[f"{channel}_corrected_concentration"] = ( + temp_df[f"{channel}_corrected_amount"] + / temp_df["cell_vol_fl"] ) except KeyError: - print(f'Volume is missing in acdc output, NaNs inserted in concentration columns of channel {channel}') - temp_df[f'{channel}_corrected_concentration'] = None - temp_df['max_frame_pos'] = cc_data.frame_i.max() - temp_df['file'] = file - temp_df['selection_subset'] = file_idx - temp_df['position'] = positions[file_idx][pos_idx] - temp_df['directory'] = pos_dir - print('Saving calculated data for next time...') + print( + f"Volume is missing in acdc output, NaNs inserted in concentration columns of channel {channel}" + ) + temp_df[f"{channel}_corrected_concentration"] = None + temp_df["max_frame_pos"] = cc_data.frame_i.max() + temp_df["file"] = file + temp_df["selection_subset"] = file_idx + temp_df["position"] = positions[file_idx][pos_idx] + temp_df["directory"] = pos_dir + print("Saving calculated data for next time...") files_in_curr_dir = myutils.listdir(pos_dir) common_prefix = _determine_common_prefix(files_in_curr_dir) - save_path = os.path.join(pos_dir, f'{common_prefix}cca_properties_downstream.csv') + save_path = os.path.join( + pos_dir, f"{common_prefix}cca_properties_downstream.csv" + ) temp_df.to_csv(save_path, index=False) - overall_df = pd.concat([overall_df, temp_df], ignore_index=True).reset_index(drop=True) + overall_df = pd.concat( + [overall_df, temp_df], ignore_index=True + ).reset_index(drop=True) # if save_features_to_acdc_df: # acdc_df = cc_data.set_index(['frame_i', 'Cell_ID']) @@ -228,7 +251,7 @@ def calculate_downstream_data( # acdc_df_path # import pdb; pdb.set_trace() - print('Done!') + print("Done!") return overall_df, is_timelapse_data, is_zstack_data @@ -237,108 +260,148 @@ def calculate_relatives_data(overall_df, channels): overall_df_rel = overall_df.copy() overall_df = overall_df.merge( overall_df_rel, - how='left', - left_on=['frame_i', 'relative_ID', 'max_frame_pos', 'file', 'selection_subset', 'position', 'directory'], - right_on=['frame_i', 'Cell_ID', 'max_frame_pos', 'file', 'selection_subset', 'position', 'directory'], - suffixes = ('', '_rel') + how="left", + left_on=[ + "frame_i", + "relative_ID", + "max_frame_pos", + "file", + "selection_subset", + "position", + "directory", + ], + right_on=[ + "frame_i", + "Cell_ID", + "max_frame_pos", + "file", + "selection_subset", + "position", + "directory", + ], + suffixes=("", "_rel"), ) # for every channel, calculate amount from mother and bud cells combined for ch in channels: try: - overall_df[f'{ch}_combined_amount_mother_bud'] = overall_df.apply( + overall_df[f"{ch}_combined_amount_mother_bud"] = overall_df.apply( lambda x: ( - x.loc[f'{ch}_corrected_amount'] - + x.loc[f'{ch}_corrected_amount_rel'] - if x.loc['cell_cycle_stage']=='S' - else x.loc[f'{ch}_corrected_amount'] - ), - axis=1 + x.loc[f"{ch}_corrected_amount"] + + x.loc[f"{ch}_corrected_amount_rel"] + if x.loc["cell_cycle_stage"] == "S" + else x.loc[f"{ch}_corrected_amount"] + ), + axis=1, ) - overall_df[f'{ch}_combined_raw_sum_mother_bud'] = overall_df.apply( + overall_df[f"{ch}_combined_raw_sum_mother_bud"] = overall_df.apply( lambda x: ( - x.loc[f'{ch}_raw_sum'] - + x.loc[f'{ch}_raw_sum_rel'] - if x.loc['cell_cycle_stage']=='S' - else x.loc[f'{ch}_raw_sum'] - ), - axis=1 + x.loc[f"{ch}_raw_sum"] + x.loc[f"{ch}_raw_sum_rel"] + if x.loc["cell_cycle_stage"] == "S" + else x.loc[f"{ch}_raw_sum"] + ), + axis=1, ) except KeyError: continue - overall_df['combined_mother_bud_volume'] = overall_df.apply( - lambda x: x.loc['cell_vol_fl']+x.loc['cell_vol_fl_rel'] if\ - x.loc['cell_cycle_stage']=='S' else\ - x.loc['cell_vol_fl'], - axis=1 + overall_df["combined_mother_bud_volume"] = overall_df.apply( + lambda x: ( + x.loc["cell_vol_fl"] + x.loc["cell_vol_fl_rel"] + if x.loc["cell_cycle_stage"] == "S" + else x.loc["cell_vol_fl"] + ), + axis=1, ) return overall_df def calculate_per_phase_quantities(overall_df, group_cols, channels): # group by group columns, aggregate some other columns - phase_grouped = overall_df.sort_values( - 'frame_i' - ).groupby(group_cols).agg( - # perform some calculations relating to the whole phase: - phase_area_growth=('cell_area_um2', lambda x: x.iloc[-1]-x.iloc[0]), - phase_volume_growth=('cell_vol_fl', lambda x: x.iloc[-1]-x.iloc[0]), - phase_area_at_beginning=('cell_area_um2', 'first'), - phase_volume_at_beginning=('cell_vol_fl', 'first'), - phase_volume_at_end=('cell_vol_fl', 'last'), - phase_daughter_area_growth=('cell_area_um2_rel', lambda x: x.iloc[-1]-x.iloc[0]), - phase_daughter_volume_growth=('cell_vol_fl_rel', lambda x: x.iloc[-1]-x.iloc[0]), - phase_length=('frame_i', lambda x: max(x)-min(x)), - phase_begin = ('frame_i', 'min'), - phase_end = ('frame_i', 'max'), - phase_combined_volume_at_end = ('combined_mother_bud_volume','last') - ).reset_index() + phase_grouped = ( + overall_df.sort_values("frame_i") + .groupby(group_cols) + .agg( + # perform some calculations relating to the whole phase: + phase_area_growth=("cell_area_um2", lambda x: x.iloc[-1] - x.iloc[0]), + phase_volume_growth=("cell_vol_fl", lambda x: x.iloc[-1] - x.iloc[0]), + phase_area_at_beginning=("cell_area_um2", "first"), + phase_volume_at_beginning=("cell_vol_fl", "first"), + phase_volume_at_end=("cell_vol_fl", "last"), + phase_daughter_area_growth=( + "cell_area_um2_rel", + lambda x: x.iloc[-1] - x.iloc[0], + ), + phase_daughter_volume_growth=( + "cell_vol_fl_rel", + lambda x: x.iloc[-1] - x.iloc[0], + ), + phase_length=("frame_i", lambda x: max(x) - min(x)), + phase_begin=("frame_i", "min"), + phase_end=("frame_i", "max"), + phase_combined_volume_at_end=("combined_mother_bud_volume", "last"), + ) + .reset_index() + ) # calculate some quantities in a for loop for all available channels and merge results. phase_grouped_flu = pd.DataFrame(columns=group_cols) for ch in channels: - if f'{ch}_corrected_mean' in overall_df.columns: - flu_temp = overall_df.sort_values( - 'frame_i' - ).groupby(group_cols).agg({ - # perform some calculations on flu data: - f'{ch}_corrected_amount': 'first', - f'{ch}_corrected_mean': 'first', - f'{ch}_corrected_concentration': ['first','last'], - f'{ch}_combined_amount_mother_bud': ['first','last'] - }).reset_index() + if f"{ch}_corrected_mean" in overall_df.columns: + flu_temp = ( + overall_df.sort_values("frame_i") + .groupby(group_cols) + .agg( + { + # perform some calculations on flu data: + f"{ch}_corrected_amount": "first", + f"{ch}_corrected_mean": "first", + f"{ch}_corrected_concentration": ["first", "last"], + f"{ch}_combined_amount_mother_bud": ["first", "last"], + } + ) + .reset_index() + ) # collapse multiindex into column name with aggregation as suffix - flu_temp.columns = ['_'.join(col) if col[1]!='' else col[0] for col in flu_temp.columns.values] + flu_temp.columns = [ + "_".join(col) if col[1] != "" else col[0] + for col in flu_temp.columns.values + ] # rename columns into meaningful names - flu_temp = flu_temp.rename({ - f'{ch}_corrected_amount_first': f'phase_{ch}_amount_at_beginning', - f'{ch}_corrected_mean_first': f'phase_{ch}_mean_at_beginning', - f'{ch}_corrected_concentration_first': f'phase_{ch}_concentration_at_beginning', - f'{ch}_corrected_concentration_last': f'phase_{ch}_concentration_at_end', - f'{ch}_combined_amount_mother_bud_first': f'phase_{ch}_combined_amount_at_beginning', - f'{ch}_combined_amount_mother_bud_last': f'phase_{ch}_combined_amount_at_end', - }, axis=1) - phase_grouped_flu = phase_grouped_flu.merge(flu_temp, how='right', on=group_cols, suffixes=('','')) + flu_temp = flu_temp.rename( + { + f"{ch}_corrected_amount_first": f"phase_{ch}_amount_at_beginning", + f"{ch}_corrected_mean_first": f"phase_{ch}_mean_at_beginning", + f"{ch}_corrected_concentration_first": f"phase_{ch}_concentration_at_beginning", + f"{ch}_corrected_concentration_last": f"phase_{ch}_concentration_at_end", + f"{ch}_combined_amount_mother_bud_first": f"phase_{ch}_combined_amount_at_beginning", + f"{ch}_combined_amount_mother_bud_last": f"phase_{ch}_combined_amount_at_end", + }, + axis=1, + ) + phase_grouped_flu = phase_grouped_flu.merge( + flu_temp, how="right", on=group_cols, suffixes=("", "") + ) # detect complete cell cycle phases and complete cell cycles temp = np.logical_and( phase_grouped.phase_begin > 0, - phase_grouped.phase_end < phase_grouped.max_frame_pos + phase_grouped.phase_end < phase_grouped.max_frame_pos, ) # this or is for disappearing cells - if 'max_t' in overall_df.columns: + if "max_t" in overall_df.columns: complete_phase_indices = np.logical_and( - temp, - phase_grouped.phase_end < phase_grouped.max_t + temp, phase_grouped.phase_end < phase_grouped.max_t ) else: complete_phase_indices = temp - phase_grouped['complete_phase'] = complete_phase_indices.astype(int) + phase_grouped["complete_phase"] = complete_phase_indices.astype(int) no_of_compl_phases_per_cycle = phase_grouped.groupby( - ['Cell_ID', 'generation_num', 'position', 'file'] - )['complete_phase'].transform('sum') + ["Cell_ID", "generation_num", "position", "file"] + )["complete_phase"].transform("sum") complete_cycle_indices = no_of_compl_phases_per_cycle == 2 - phase_grouped['complete_cycle'] = complete_cycle_indices.astype(int) + phase_grouped["complete_cycle"] = complete_cycle_indices.astype(int) # join phase-grouped data with - phase_grouped = phase_grouped.merge(phase_grouped_flu, how='left', on=group_cols, suffixes=('','')) + phase_grouped = phase_grouped.merge( + phase_grouped_flu, how="left", on=group_cols, suffixes=("", "") + ) return phase_grouped @@ -348,9 +411,8 @@ def _determine_common_prefix(filenames): # Determine the basename based on intersection of all .tif _, ext = os.path.splitext(file) sm = difflib.SequenceMatcher(None, file, basename) - i, j, k = sm.find_longest_match(0, len(file), - 0, len(basename)) - basename = file[i:i+k] + i, j, k = sm.find_longest_match(0, len(file), 0, len(basename)) + basename = file[i : i + k] return basename @@ -377,7 +439,7 @@ def _auto_rescale_intensity(img, perc=0.01, clip_min=False): scaled to [0,1] afterwards """ if perc > 0: - vmin, vmax = np.percentile(img, q=(perc, 100-perc)) + vmin, vmax = np.percentile(img, q=(perc, 100 - perc)) clip_min_indices = img < vmin clip_max_indices = img > vmax if clip_min: @@ -385,83 +447,90 @@ def _auto_rescale_intensity(img, perc=0.01, clip_min=False): img[clip_max_indices] = vmax else: vmin, vmax = img.min(), img.max() - scaled_img = (img-vmin)/(vmax-vmin) + scaled_img = (img - vmin) / (vmax - vmin) return scaled_img -def load_acdc_output_only( - file_names, - image_folders, - positions, - segm_endnames - ): + +def load_acdc_output_only(file_names, image_folders, positions, segm_endnames): """ Function to load only the acdc output. Use when fluorescent file is too big to load into RAM. #TODO: move to cca_functions """ - + overall_df = pd.DataFrame() for file_idx, file in enumerate(file_names): - acdc_output_endname = segm_endnames[file_idx].replace('segm', 'acdc_output') + acdc_output_endname = segm_endnames[file_idx].replace("segm", "acdc_output") for pos_idx, pos_dir in enumerate(image_folders[file_idx]): try: cc_stage_path = glob.glob( - os.path.join(f'{pos_dir}', f'*{acdc_output_endname}.csv') + os.path.join(f"{pos_dir}", f"*{acdc_output_endname}.csv") )[0] except IndexError: - cc_stage_path = glob.glob(os.path.join(f'{pos_dir}', '*cc_stage.csv'))[0] + cc_stage_path = glob.glob(os.path.join(f"{pos_dir}", "*cc_stage.csv"))[ + 0 + ] temp_df = pd.read_csv(cc_stage_path) - temp_df['max_frame_pos'] = temp_df.frame_i.max() - temp_df['file'] = file - temp_df['selection_subset'] = file_idx - temp_df['position'] = positions[file_idx][pos_idx] - temp_df['directory'] = pos_dir + temp_df["max_frame_pos"] = temp_df.frame_i.max() + temp_df["file"] = file + temp_df["selection_subset"] = file_idx + temp_df["position"] = positions[file_idx][pos_idx] + temp_df["directory"] = pos_dir overall_df = pd.concat([overall_df, temp_df]) return overall_df + def _load_channels_data(file_dir, channel_names, no_of_aligned_files): channel_files = [] if no_of_aligned_files > 0: for channel in channel_names: try: - ch_aligned_path = glob.glob(os.path.join(f'{file_dir}', f'*{channel}_aligned.npz'))[0] - channel_files.append(np.load(ch_aligned_path)['arr_0']) + ch_aligned_path = glob.glob( + os.path.join(f"{file_dir}", f"*{channel}_aligned.npz") + )[0] + channel_files.append(np.load(ch_aligned_path)["arr_0"]) except IndexError: try: - ch_aligned_path = glob.glob(os.path.join(f'{file_dir}', f'*{channel}_aligned.npy'))[0] + ch_aligned_path = glob.glob( + os.path.join(f"{file_dir}", f"*{channel}_aligned.npy") + )[0] channel_files.append(np.load(ch_aligned_path)) except IndexError: - print(f'Could not find an aligned file for channel {channel}') - print(f'Resulting data will not contain fluorescent data for this channel') + print(f"Could not find an aligned file for channel {channel}") + print( + f"Resulting data will not contain fluorescent data for this channel" + ) channel_files.append(None) else: for channel in channel_names: try: - ch_not_aligned_path = ( - glob.glob(os.path.join(f'{file_dir}', f'*{channel}.tif'))[0] - ) + ch_not_aligned_path = glob.glob( + os.path.join(f"{file_dir}", f"*{channel}.tif") + )[0] channel_files.append(imread(ch_not_aligned_path)) except IndexError: - print(f'Could not find any file for channel {channel}') - print(f'Resulting data will not contain fluorescent data for this channel') + print(f"Could not find any file for channel {channel}") + print( + f"Resulting data will not contain fluorescent data for this channel" + ) channel_files.append(None) return channel_files + def _load_files(file_dir, channels, segm_endname, load_channels_data=True): """ Function to load files of all given channels and the corresponding segmentation masks. Check first if aligned files are available and use them if so. """ - acdc_output_endname = segm_endname.replace('segm', 'acdc_output') - no_of_aligned_files = len( - glob.glob(os.path.join(f'{file_dir}', '*aligned.npz')) + acdc_output_endname = segm_endname.replace("segm", "acdc_output") + no_of_aligned_files = len(glob.glob(os.path.join(f"{file_dir}", "*aligned.npz"))) + seg_mask_available = ( + len(glob.glob(os.path.join(f"{file_dir}", f"*_{segm_endname}.npz"))) > 0 ) - seg_mask_available = len( - glob.glob(os.path.join(f'{file_dir}', f'*_{segm_endname}.npz')) - ) > 0 acdc_output_available = ( - len(glob.glob(os.path.join(f'{file_dir}', f'*{acdc_output_endname}.csv'))) - + len(glob.glob(os.path.join(f'{file_dir}', '*cc_stage*'))) > 0 + len(glob.glob(os.path.join(f"{file_dir}", f"*{acdc_output_endname}.csv"))) + + len(glob.glob(os.path.join(f"{file_dir}", "*cc_stage*"))) + > 0 ) if not (seg_mask_available and acdc_output_available): return None @@ -473,97 +542,145 @@ def _load_files(file_dir, channels, segm_endname, load_channels_data=True): # append segmentation file try: segm_file_path = glob.glob( - os.path.join(f'{file_dir}', f'*_{segm_endname}.npz') + os.path.join(f"{file_dir}", f"*_{segm_endname}.npz") )[0] - channel_files.append(np.load(segm_file_path)['arr_0']) + channel_files.append(np.load(segm_file_path)["arr_0"]) except IndexError: - segm_file_path = glob.glob(os.path.join(f'{file_dir}', '*_segm.npy'))[0] + segm_file_path = glob.glob(os.path.join(f"{file_dir}", "*_segm.npy"))[0] # assume segmentation mask to be .npy channel_files.append(np.load(segm_file_path)) # append cc-data try: cc_stage_path = glob.glob( - os.path.join(f'{file_dir}', f'*{acdc_output_endname}.csv') + os.path.join(f"{file_dir}", f"*{acdc_output_endname}.csv") )[0] except IndexError: - cc_stage_path = glob.glob(os.path.join(f'{file_dir}', '*cc_stage.csv'))[0] + cc_stage_path = glob.glob(os.path.join(f"{file_dir}", "*cc_stage.csv"))[0] # assume cell cycle output of ACDC to be .csv channel_files.append(pd.read_csv(cc_stage_path)) # append metadata if available, else append None - if len(glob.glob(os.path.join(f'{file_dir}', '*metadata*'))) > 0: - metadata_path = glob.glob(os.path.join(f'{file_dir}', '*metadata.csv'))[0] + if len(glob.glob(os.path.join(f"{file_dir}", "*metadata*"))) > 0: + metadata_path = glob.glob(os.path.join(f"{file_dir}", "*metadata.csv"))[0] # assume calculated metadata to be .csv - channel_files.append(pd.read_csv(metadata_path).set_index('Description')) + channel_files.append(pd.read_csv(metadata_path).set_index("Description")) else: channel_files.append(None) # append cc-properties if available, else append None - if len(glob.glob(os.path.join(f'{file_dir}', '*_downstream*'))) > 0: - cc_props_path = glob.glob(os.path.join(f'{file_dir}', '*_downstream*'))[0] + if len(glob.glob(os.path.join(f"{file_dir}", "*_downstream*"))) > 0: + cc_props_path = glob.glob(os.path.join(f"{file_dir}", "*_downstream*"))[0] # assume calculated cc properties to be .csv channel_files.append(pd.read_csv(cc_props_path)) else: channel_files.append(None) return (*channel_files, cc_stage_path) -def _calculate_rp_df(seg_mask, is_timelapse_data, is_zstack_data, metadata, max_frame=1, label_input=False): + +def _calculate_rp_df( + seg_mask, + is_timelapse_data, + is_zstack_data, + metadata, + max_frame=1, + label_input=False, +): """ function to calculate regionprops based on a 2D(!) segmentation mask. TODO: insert check if 3D segmentation mask is available and calculate more regionprops. """ if label_input: - #generate labeled video only when input is not labeled yet + # generate labeled video only when input is not labeled yet labeled_data = label(seg_mask) else: labeled_data = seg_mask.copy() # calculate rp's for rings t_df = pd.DataFrame() - props = ('label', 'area', 'convex_area', 'filled_area','major_axis_length', - 'minor_axis_length', 'orientation', 'perimeter', 'centroid', 'solidity') - rename_dict = {'label':'Cell_ID', 'centroid-0':'centroid_y', 'centroid-1':'centroid_x'} + props = ( + "label", + "area", + "convex_area", + "filled_area", + "major_axis_length", + "minor_axis_length", + "orientation", + "perimeter", + "centroid", + "solidity", + ) + rename_dict = { + "label": "Cell_ID", + "centroid-0": "centroid_y", + "centroid-1": "centroid_x", + } if is_timelapse_data: for t, img in enumerate(tqdm(labeled_data)): # build time-dependent dataframes for further use (later for cca) if img.max() > 0: - t_rp_df = pd.DataFrame(regionprops_table(img.astype(int), properties=props)).rename(columns=rename_dict) - t_rp_df['frame_i'] = t + t_rp_df = pd.DataFrame( + regionprops_table(img.astype(int), properties=props) + ).rename(columns=rename_dict) + t_rp_df["frame_i"] = t # calculate volumes based on regionprops if metadata is None: warnings.warn("No metadata available. Volumes are not calculated") - t_rp_df['cell_vol_vox_downstream'] = 0 - t_rp_df['cell_vol_fl_downstream'] = 0 + t_rp_df["cell_vol_vox_downstream"] = 0 + t_rp_df["cell_vol_fl_downstream"] = 0 else: t_rp = regionprops(img.astype(int)) - vol_vox = [_calc_rot_vol(obj, metadata.loc["PhysicalSizeY"], metadata.loc["PhysicalSizeX"])[0] for obj in t_rp] - vol_fl = [_calc_rot_vol(obj, metadata.loc["PhysicalSizeY"], metadata.loc["PhysicalSizeX"])[1] for obj in t_rp] + vol_vox = [ + _calc_rot_vol( + obj, + metadata.loc["PhysicalSizeY"], + metadata.loc["PhysicalSizeX"], + )[0] + for obj in t_rp + ] + vol_fl = [ + _calc_rot_vol( + obj, + metadata.loc["PhysicalSizeY"], + metadata.loc["PhysicalSizeX"], + )[1] + for obj in t_rp + ] assert len(t_rp_df) == len(vol_vox) - t_rp_df['cell_vol_vox_downstream'] = vol_vox - t_rp_df['cell_vol_fl_downstream'] = vol_fl + t_rp_df["cell_vol_vox_downstream"] = vol_vox + t_rp_df["cell_vol_fl_downstream"] = vol_fl # determine id's which are falsely merged by 3D-labeling for r_id in t_rp_df.Cell_ID.unique(): - bin_label = label((img==r_id).astype(int)) - t_rp_df.loc[t_rp_df['Cell_ID']==r_id, '2d_label_count'] = bin_label.max() + bin_label = label((img == r_id).astype(int)) + t_rp_df.loc[t_rp_df["Cell_ID"] == r_id, "2d_label_count"] = ( + bin_label.max() + ) t_df = pd.concat([t_df, t_rp_df], ignore_index=True) # calculate global features by grouping - grouped_df = t_df.groupby('Cell_ID').agg( - min_t=('frame_i', min), - max_t=('frame_i', max), - lifespan=('frame_i', lambda x: max(x)-min(x)+1) - ).reset_index() - merged_df = t_df.merge(grouped_df, how='left', on='Cell_ID') + grouped_df = ( + t_df.groupby("Cell_ID") + .agg( + min_t=("frame_i", min), + max_t=("frame_i", max), + lifespan=("frame_i", lambda x: max(x) - min(x) + 1), + ) + .reset_index() + ) + merged_df = t_df.merge(grouped_df, how="left", on="Cell_ID") # calculate further indicators based on merged data - merged_df['age'] = merged_df['frame_i'] - merged_df['min_t'] + 1 - merged_df['frames_till_gone'] = merged_df['max_t'] - merged_df['frame_i'] - merged_df['elongation'] = merged_df['major_axis_length']/merged_df['minor_axis_length'] + merged_df["age"] = merged_df["frame_i"] - merged_df["min_t"] + 1 + merged_df["frames_till_gone"] = merged_df["max_t"] - merged_df["frame_i"] + merged_df["elongation"] = ( + merged_df["major_axis_length"] / merged_df["minor_axis_length"] + ) return merged_df else: - rp_df = pd.DataFrame(regionprops_table(labeled_data.astype(int), properties=props)).rename(columns=rename_dict) + rp_df = pd.DataFrame( + regionprops_table(labeled_data.astype(int), properties=props) + ).rename(columns=rename_dict) for r_id in rp_df.Cell_ID.unique(): - bin_label = label((labeled_data==r_id).astype(int)) - rp_df.loc[rp_df['Cell_ID']==r_id, '2d_label_count'] = bin_label.max() - rp_df['elongation'] = rp_df['major_axis_length']/rp_df['minor_axis_length'] - rp_df['frame_i'] = 0 + bin_label = label((labeled_data == r_id).astype(int)) + rp_df.loc[rp_df["Cell_ID"] == r_id, "2d_label_count"] = bin_label.max() + rp_df["elongation"] = rp_df["major_axis_length"] / rp_df["minor_axis_length"] + rp_df["frame_i"] = 0 return rp_df @@ -605,18 +722,20 @@ def _calc_rot_vol(obj, PhysicalSizeY=1, PhysicalSizeX=1, logger=None): try: if is3Dobj: # For 3D objects we use a max projection for the rotation - obj_lab = obj.image.max(axis=0).astype(np.uint32)*obj.label + obj_lab = obj.image.max(axis=0).astype(np.uint32) * obj.label obj = regionprops(obj_lab)[0] - vox_to_fl = float(PhysicalSizeY)*pow(float(PhysicalSizeX), 2) + vox_to_fl = float(PhysicalSizeY) * pow(float(PhysicalSizeX), 2) rotate_ID_img = skimage.transform.rotate( - obj.image.astype(np.single), -(obj.orientation*180/np.pi), - resize=True, order=3 + obj.image.astype(np.single), + -(obj.orientation * 180 / np.pi), + resize=True, + order=3, ) - radii = np.sum(rotate_ID_img, axis=1)/2 - vol_vox = np.sum(np.pi*(radii**2)) + radii = np.sum(rotate_ID_img, axis=1) / 2 + vol_vox = np.sum(np.pi * (radii**2)) if vox_to_fl is not None: - return vol_vox, float(vol_vox*vox_to_fl) + return vol_vox, float(vol_vox * vox_to_fl) else: return vol_vox, vol_vox except Exception as e: @@ -627,79 +746,87 @@ def _calc_rot_vol(obj, PhysicalSizeY=1, PhysicalSizeX=1, logger=None): return np.nan, np.nan -def _calculate_flu_signal(seg_mask, channel_data, channels, cc_data, is_timelapse_data, is_zstack_data): +def _calculate_flu_signal( + seg_mask, channel_data, channels, cc_data, is_timelapse_data, is_zstack_data +): """ function to calculate sum and scaled sum of fluorescence signal per frame and cell. channel_data is a list-like of TYX arrays, one for each channel. channels are the name of the channels in the tuple. cc_data the output of acdc. - """ + """ max_frame = cc_data.frame_i.max() - df = pd.DataFrame(columns=['frame_i', 'Cell_ID']) + df = pd.DataFrame(columns=["frame_i", "Cell_ID"]) bg_medians = [] - + if seg_mask.ndim == 4: raise TypeError( - '4D segmentation masks not supported. ' - 'Feel free to request the new feature on our GitHub page ' - 'https://github.com/SchmollerLab/Cell_ACDC/issues' + "4D segmentation masks not supported. " + "Feel free to request the new feature on our GitHub page " + "https://github.com/SchmollerLab/Cell_ACDC/issues" ) - + for i, ch_img in enumerate(channel_data): if ch_img.ndim == 3: continue - + # Use sum projections for 4D data channel_data[i] = ch_img.sum(axis=1) - + for ch_idx, ch_array in enumerate(channel_data): if ch_array is None: bg_medians.append(None) else: bg_index = np.logical_and( - seg_mask[:max_frame+1]==0, ch_array[:max_frame+1]!=0 + seg_mask[: max_frame + 1] == 0, ch_array[: max_frame + 1] != 0 ) - ch_medians = [np.median(ch_array[t][bg_index[t]]) for t in range(max_frame+1)] + ch_medians = [ + np.median(ch_array[t][bg_index[t]]) for t in range(max_frame + 1) + ] bg_medians.append(ch_medians) if is_timelapse_data: for cell_id in tqdm(cc_data.Cell_ID.unique()): - temp_df = pd.DataFrame(columns=['frame_i', 'Cell_ID']) - times = range(max_frame+1) - temp_df['frame_i'] = times; temp_df['Cell_ID'] = cell_id - index_array = (seg_mask[:max_frame+1] == cell_id) - channel_data_cut = [c_arr[:max_frame+1] if c_arr is not None else None for c_arr in channel_data] + temp_df = pd.DataFrame(columns=["frame_i", "Cell_ID"]) + times = range(max_frame + 1) + temp_df["frame_i"] = times + temp_df["Cell_ID"] = cell_id + index_array = seg_mask[: max_frame + 1] == cell_id + channel_data_cut = [ + c_arr[: max_frame + 1] if c_arr is not None else None + for c_arr in channel_data + ] for c_idx, c_array in enumerate(channel_data_cut): if c_array is not None: - cell_signal = c_array*index_array + cell_signal = c_array * index_array # cell_signal = c_array[index_array] - summed = np.sum(cell_signal, axis=(1,2)) + summed = np.sum(cell_signal, axis=(1, 2)) # count = np.sum(cell_signal!=0, axis=(1,2)) - count = np.sum(index_array, axis=(1,2)) - mean_signal = np.divide(summed, count, where=count!=0) + count = np.sum(index_array, axis=(1, 2)) + mean_signal = np.divide(summed, count, where=count != 0) # mean_signal = np.mean(cell_signal, axis=(1,2)) corrected_signal = mean_signal - np.array(bg_medians[c_idx]) - temp_df[f'{channels[c_idx]}_corrected_mean'] = corrected_signal - temp_df[f'{channels[c_idx]}_raw_sum'] = summed + temp_df[f"{channels[c_idx]}_corrected_mean"] = corrected_signal + temp_df[f"{channels[c_idx]}_raw_sum"] = summed else: - temp_df[f'{channels[c_idx]}_corrected_mean'] = 0 - temp_df[f'{channels[c_idx]}_raw_sum'] = 0 + temp_df[f"{channels[c_idx]}_corrected_mean"] = 0 + temp_df[f"{channels[c_idx]}_raw_sum"] = 0 df = pd.concat([df, temp_df], ignore_index=True) - signal_indices = np.array(['_corrected_mean' in col for col in df.columns]) - keep_rows = df.loc[:,signal_indices].sum(axis=1) > 0 + signal_indices = np.array(["_corrected_mean" in col for col in df.columns]) + keep_rows = df.loc[:, signal_indices].sum(axis=1) > 0 df = df[keep_rows] - df = df.sort_values(['frame_i', 'Cell_ID']).reset_index(drop=True) + df = df.sort_values(["frame_i", "Cell_ID"]).reset_index(drop=True) return df def _rename_columns(cc_data): rename_dict = { - 'Cell cycle stage': 'cell_cycle_stage', - '# of cycles': 'generation_num', - "Relative's ID": 'relative_ID', - 'Relationship': 'relationship', - 'Emerg_frame_i': 'emerg_frame_i', - 'Division_frame_i': 'division_frame_i', - 'Discard': 'is_cell_excluded' + "Cell cycle stage": "cell_cycle_stage", + "# of cycles": "generation_num", + "Relative's ID": "relative_ID", + "Relationship": "relationship", + "Emerg_frame_i": "emerg_frame_i", + "Division_frame_i": "division_frame_i", + "Discard": "is_cell_excluded", } cc_data.columns = [rename_dict.get(col, col) for col in cc_data.columns] return cc_data @@ -710,133 +837,131 @@ def binned_mean_stats(x, values, nbins, bins_min_count): function to calculate binned means and corresponding standard errors for evenly spaced bins in the data ("x" gets distributed in bins, stats are calculated on "values") """ - bin_counts, _, _ = binned_statistic(x, values, statistic='count', bins=nbins) + bin_counts, _, _ = binned_statistic(x, values, statistic="count", bins=nbins) bin_means, bin_edges, _ = binned_statistic(x, values, bins=nbins) - bin_std, _, _ = binned_statistic(x, values, statistic='std', bins=nbins) - bin_standard_errors = bin_std/np.sqrt(bin_counts) - bin_width = (bin_edges[1] - bin_edges[0]) - bin_centers = bin_edges[1:] - bin_width/2 - x_errorbar = bin_centers[bin_counts>bins_min_count] - y_errorbar = bin_means[bin_counts>bins_min_count] - err_errorbar = 1.96 * bin_standard_errors[bin_counts>bins_min_count] + bin_std, _, _ = binned_statistic(x, values, statistic="std", bins=nbins) + bin_standard_errors = bin_std / np.sqrt(bin_counts) + bin_width = bin_edges[1] - bin_edges[0] + bin_centers = bin_edges[1:] - bin_width / 2 + x_errorbar = bin_centers[bin_counts > bins_min_count] + y_errorbar = bin_means[bin_counts > bins_min_count] + err_errorbar = 1.96 * bin_standard_errors[bin_counts > bins_min_count] return x_errorbar, y_errorbar, err_errorbar -def calculate_effect_size_cohen(data, group1, group2, cat_column='size_category', val_column='Pp38_concentration'): +def calculate_effect_size_cohen( + data, group1, group2, cat_column="size_category", val_column="Pp38_concentration" +): assert cat_column in data.columns and val_column in data.columns - data_gr1 = data[data[cat_column]==group1] - data_gr2 = data[data[cat_column]==group2] + data_gr1 = data[data[cat_column] == group1] + data_gr2 = data[data[cat_column] == group2] n1 = len(data_gr1) n2 = len(data_gr2) s1 = np.var(data_gr1[val_column]) s2 = np.var(data_gr2[val_column]) - cohen_s = np.sqrt( - ((n1-1)*s1+(n2-1)*s2) / (n1+n2-2) - ) - effect_size = (np.mean(data_gr1[val_column])- np.mean(data_gr2[val_column])) / cohen_s + cohen_s = np.sqrt(((n1 - 1) * s1 + (n2 - 1) * s2) / (n1 + n2 - 2)) + effect_size = ( + np.mean(data_gr1[val_column]) - np.mean(data_gr2[val_column]) + ) / cohen_s return effect_size -def calculate_effect_size_glass(data, group1, group2, cat_column='size_category', val_column='Pp38_concentration'): + +def calculate_effect_size_glass( + data, group1, group2, cat_column="size_category", val_column="Pp38_concentration" +): assert cat_column in data.columns and val_column in data.columns - data_gr1 = data[data[cat_column]==group1] - data_gr2 = data[data[cat_column]==group2] + data_gr1 = data[data[cat_column] == group1] + data_gr2 = data[data[cat_column] == group2] glass_s = np.std(data_gr2[val_column]) - effect_size = (np.mean(data_gr1[val_column])- np.mean(data_gr2[val_column])) / glass_s + effect_size = ( + np.mean(data_gr1[val_column]) - np.mean(data_gr2[val_column]) + ) / glass_s return effect_size + def _add_end_of_frame_i_column(acdc_df): cca_df_idx = acdc_df.cell_cycle_stage.dropna().index cca_df = acdc_df.loc[cca_df_idx][cca_df_colnames] - acdc_df['end_of_cell_cycle_frame_i'] = np.nan - + acdc_df["end_of_cell_cycle_frame_i"] = np.nan + will_divice_cca_df_S = cca_df[ - (cca_df.cell_cycle_stage == 'S') & (cca_df.will_divide > 0) + (cca_df.cell_cycle_stage == "S") & (cca_df.will_divide > 0) ].reset_index() - - cca_df['end_of_cell_cycle_frame_i'] = -1 - grouped_ID_gen_num = will_divice_cca_df_S.groupby( - ['Cell_ID', 'generation_num'] - ) - + + cca_df["end_of_cell_cycle_frame_i"] = -1 + grouped_ID_gen_num = will_divice_cca_df_S.groupby(["Cell_ID", "generation_num"]) + end_cc_frame_i_per_cycle = grouped_ID_gen_num.agg( - end_of_cell_cycle_frame_i=('frame_i', 'max') + end_of_cell_cycle_frame_i=("frame_i", "max") ) - + cca_df_with_gen_num_idx = ( - cca_df.reset_index() - .set_index(['Cell_ID', 'generation_num']) - .sort_index() + cca_df.reset_index().set_index(["Cell_ID", "generation_num"]).sort_index() ) - + for row in end_cc_frame_i_per_cycle.itertuples(): ID, gen_num = row.Index end_cc_frame_i = row.end_of_cell_cycle_frame_i idx = (ID, gen_num) - cca_df_with_gen_num_idx.loc[idx, 'end_of_cell_cycle_frame_i'] = ( - end_cc_frame_i - ) - + cca_df_with_gen_num_idx.loc[idx, "end_of_cell_cycle_frame_i"] = end_cc_frame_i + cca_df = ( cca_df_with_gen_num_idx.reset_index() - .set_index(['frame_i', 'Cell_ID']) + .set_index(["frame_i", "Cell_ID"]) .sort_index() ) - - acdc_df.loc[cca_df_idx, 'end_of_cell_cycle_frame_i'] = ( - cca_df['end_of_cell_cycle_frame_i'] - ) + + acdc_df.loc[cca_df_idx, "end_of_cell_cycle_frame_i"] = cca_df[ + "end_of_cell_cycle_frame_i" + ] return acdc_df + def _extend_will_divide_to_G1(acdc_df): - acdc_df = acdc_df.drop(columns=['level_0', 'index'], errors='ignore') + acdc_df = acdc_df.drop(columns=["level_0", "index"], errors="ignore") acdc_df = acdc_df.reset_index() - acdc_df_will_divide_true = acdc_df[acdc_df['will_divide'] > 0] - grouped = acdc_df_will_divide_true.groupby(['Cell_ID', 'generation_num']) - for (ID, gen_num) in grouped.groups.keys(): - mask = ( - (acdc_df['Cell_ID'] == ID) - & (acdc_df['generation_num'] == gen_num) - ) - acdc_df.loc[mask, 'will_divide'] = 1.0 - acdc_df = ( - acdc_df.reset_index() - .set_index(['frame_i', 'Cell_ID']) - .sort_index() - ) + acdc_df_will_divide_true = acdc_df[acdc_df["will_divide"] > 0] + grouped = acdc_df_will_divide_true.groupby(["Cell_ID", "generation_num"]) + for ID, gen_num in grouped.groups.keys(): + mask = (acdc_df["Cell_ID"] == ID) & (acdc_df["generation_num"] == gen_num) + acdc_df.loc[mask, "will_divide"] = 1.0 + acdc_df = acdc_df.reset_index().set_index(["frame_i", "Cell_ID"]).sort_index() return acdc_df - + + def add_derived_cell_cycle_columns(acdc_df: pd.DataFrame): - if 'cell_cycle_stage' not in acdc_df.columns: + if "cell_cycle_stage" not in acdc_df.columns: return acdc_df - + acdc_df = _extend_will_divide_to_G1(acdc_df) acdc_df = _add_end_of_frame_i_column(acdc_df) - + return acdc_df - + + def add_generation_num_of_relative_ID( - acdc_df, prefix_index: Iterable[str]=None, reset_index=True - ): - relID_index_col = ['frame_i', 'relative_ID', 'Cell_ID'] - ID_index_col = ['frame_i', 'Cell_ID', 'relative_ID'] - + acdc_df, prefix_index: Iterable[str] = None, reset_index=True +): + relID_index_col = ["frame_i", "relative_ID", "Cell_ID"] + ID_index_col = ["frame_i", "Cell_ID", "relative_ID"] + if prefix_index is not None: relID_index_col = [*prefix_index, *relID_index_col] ID_index_col = [*prefix_index, *ID_index_col] - + if reset_index: acdc_df = acdc_df.reset_index() - + acdc_df_by_rel_ID = acdc_df.set_index(relID_index_col) acdc_df_by_rel_ID.index relative_ID_idx = acdc_df_by_rel_ID.index acdc_df_by_frame_i = acdc_df.set_index(ID_index_col) relative_ID_idx = relative_ID_idx.intersection(acdc_df_by_frame_i.index) - acdc_df_by_frame_i['generation_num_relID'] = -1 + acdc_df_by_frame_i["generation_num_relID"] = -1 - acdc_df_by_frame_i.loc[relative_ID_idx, 'generation_num_relID'] = ( - acdc_df_by_rel_ID.loc[relative_ID_idx, 'generation_num'] + acdc_df_by_frame_i.loc[relative_ID_idx, "generation_num_relID"] = ( + acdc_df_by_rel_ID.loc[relative_ID_idx, "generation_num"] ) # Fix where generation_num_relID is still -1 @@ -844,29 +969,29 @@ def add_generation_num_of_relative_ID( acdc_df_to_fix = ( acdc_df_by_frame_i[to_fix_mask] .reset_index() - .set_index([*prefix_index, 'frame_i', 'relative_ID']) + .set_index([*prefix_index, "frame_i", "relative_ID"]) ) - acdc_df_by_cellID = ( - acdc_df_by_rel_ID.reset_index() - .set_index([*prefix_index, 'frame_i', 'Cell_ID']) + acdc_df_by_cellID = acdc_df_by_rel_ID.reset_index().set_index( + [*prefix_index, "frame_i", "Cell_ID"] ) # Intersection takes care of disappearing relative_IDs fixing_idx = acdc_df_to_fix.index.intersection(acdc_df_by_cellID.index) - acdc_df_to_fix.loc[fixing_idx, 'generation_num_relID'] = ( - acdc_df_by_cellID.loc[fixing_idx, 'generation_num'].values - ) + acdc_df_to_fix.loc[fixing_idx, "generation_num_relID"] = acdc_df_by_cellID.loc[ + fixing_idx, "generation_num" + ].values index_to_fix = acdc_df_by_frame_i[to_fix_mask].index - acdc_df_by_frame_i.loc[index_to_fix, 'generation_num_relID'] = ( - acdc_df_to_fix['generation_num_relID'].values - ) - + acdc_df_by_frame_i.loc[index_to_fix, "generation_num_relID"] = acdc_df_to_fix[ + "generation_num_relID" + ].values + acdc_df_with_col = acdc_df_by_frame_i.reset_index() return acdc_df_with_col - + + def get_IDs_gen_num_will_divide_wrong(global_cca_df): - """Get a list of (ID, gen_num) of cells whose `will_divide`>0 but the + """Get a list of (ID, gen_num) of cells whose `will_divide`>0 but the next generation does not exist (i.e., `will_divide` is wrong) Parameters @@ -877,86 +1002,89 @@ def get_IDs_gen_num_will_divide_wrong(global_cca_df): Returns ------- list of tuples - List of (ID, gen_num) of cells whose `will_divide`>0 but the + List of (ID, gen_num) of cells whose `will_divide`>0 but the next generation does not exist (i.e., `will_divide` is wrong) - + Notes ----- - To get the (ID, gen_num) where `will_divide` is wrong we first get an - index of (ID, gen_num) where `will_divide`>0. - - Then we get the same index but with (ID, gen_num+1) which is the next - generation. - - Finally we check if (ID, gen_num+1) actually exists in the annotations. - If not, those are wrongly annotated with `will_divide`>0. To check for - the existence we get the difference between the next gen index and the - whole DataFrame (i.e., get the (ID, gen_num+1) that do not exist in + To get the (ID, gen_num) where `will_divide` is wrong we first get an + index of (ID, gen_num) where `will_divide`>0. + + Then we get the same index but with (ID, gen_num+1) which is the next + generation. + + Finally we check if (ID, gen_num+1) actually exists in the annotations. + If not, those are wrongly annotated with `will_divide`>0. To check for + the existence we get the difference between the next gen index and the + whole DataFrame (i.e., get the (ID, gen_num+1) that do not exist in annotations). - """ + """ global_cca_will_divide = ( - global_cca_df[(global_cca_df['will_divide'] > 0)] + global_cca_df[(global_cca_df["will_divide"] > 0)] ).reset_index() - + ID_gen_num_index = ( - global_cca_df.reset_index() - .set_index(['Cell_ID', 'generation_num']) - .index + global_cca_df.reset_index().set_index(["Cell_ID", "generation_num"]).index ) - + # Next generation index - next_gen_will_divide_cca_df = ( - global_cca_will_divide[['Cell_ID', 'generation_num']].copy() - ) - next_gen_will_divide_cca_df['generation_num'] += 1 + next_gen_will_divide_cca_df = global_cca_will_divide[ + ["Cell_ID", "generation_num"] + ].copy() + next_gen_will_divide_cca_df["generation_num"] += 1 next_gen_will_divide_index = ( next_gen_will_divide_cca_df.reset_index() - .set_index(['Cell_ID', 'generation_num']) + .set_index(["Cell_ID", "generation_num"]) .index ) - - # (ID, gen_num) list of cells with will_divide>0 but whose next + + # (ID, gen_num) list of cells with will_divide>0 but whose next # generation number actually does not exist IDs_will_divide_next_gen_does_not_exist = ( next_gen_will_divide_index.difference(ID_gen_num_index) - .to_frame().to_numpy() # .to_list() + .to_frame() + .to_numpy() # .to_list() ) IDs_will_divide_next_gen_does_not_exist[:, -1] -= 1 - - IDs_will_divide_wrong = list(zip( - IDs_will_divide_next_gen_does_not_exist[:,0], - IDs_will_divide_next_gen_does_not_exist[:, 1] - )) + + IDs_will_divide_wrong = list( + zip( + IDs_will_divide_next_gen_does_not_exist[:, 0], + IDs_will_divide_next_gen_does_not_exist[:, 1], + ) + ) return IDs_will_divide_wrong - + + def generate_mother_bud_total_df( - df, - column_operation_mapper: dict[str, str], - do_copy_all_nonselected_columns=True, - grouping_columns=None, - entity_colname='entity' - ): + df, + column_operation_mapper: dict[str, str], + do_copy_all_nonselected_columns=True, + grouping_columns=None, + entity_colname="entity", +): if grouping_columns is None: grouping_columns = [] - - df_G1 = df[df['cell_cycle_stage'] == 'G1'] - df_S = df[(df['cell_cycle_stage'] == 'S')] - df_S_bud = df_S[df_S['relationship'] == 'bud'] - df_S_moth = df_S[df_S['relationship'] == 'mother'] - + + df_G1 = df[df["cell_cycle_stage"] == "G1"] + df_S = df[(df["cell_cycle_stage"] == "S")] + df_S_bud = df_S[df_S["relationship"] == "bud"] + df_S_moth = df_S[df_S["relationship"] == "mother"] + df_S_bud_relID = df_S_bud.reset_index().set_index( - [*grouping_columns, 'frame_i', 'relative_ID'] + [*grouping_columns, "frame_i", "relative_ID"] ) - df_S_bud_relID.index.names = [*grouping_columns, 'frame_i', 'Cell_ID'] + df_S_bud_relID.index.names = [*grouping_columns, "frame_i", "Cell_ID"] df_S_moth = df_S_moth.reset_index().set_index( - [*grouping_columns, 'frame_i', 'Cell_ID'] + [*grouping_columns, "frame_i", "Cell_ID"] ) - + columns_to_add = [ - col for col, operation in column_operation_mapper.items() - if 'sum' in operation.lower() + col + for col, operation in column_operation_mapper.items() + if "sum" in operation.lower() ] - + if do_copy_all_nonselected_columns: df_S_tot = df_S_moth.copy() else: @@ -965,22 +1093,18 @@ def generate_mother_bud_total_df( df_G1 = df_G1[columns_to_keep].copy() df_S_bud = df_S_bud[columns_to_keep].copy() df_S_moth = df_S_moth[columns_to_keep].copy() - - df_S_tot[columns_to_add] = ( - df_S_tot[columns_to_add] + df_S_bud_relID[columns_to_add] - ) - - df_S_tot = df_S_tot.drop(columns='level_0', errors='ignore').reset_index() - df_S_moth = df_S_moth.drop(columns='level_0', errors='ignore').reset_index() + + df_S_tot[columns_to_add] = df_S_tot[columns_to_add] + df_S_bud_relID[columns_to_add] + + df_S_tot = df_S_tot.drop(columns="level_0", errors="ignore").reset_index() + df_S_moth = df_S_moth.drop(columns="level_0", errors="ignore").reset_index() df_S_bud = df_S_bud.reset_index() - + final_df = pd.concat( - [df_G1, df_S_moth, df_S_bud, df_S_tot], - keys=['G1', 'Mother', 'Bud', 'Total'], + [df_G1, df_S_moth, df_S_bud, df_S_tot], + keys=["G1", "Mother", "Bud", "Total"], names=[entity_colname], - ignore_index=True + ignore_index=True, ) - + return final_df - - \ No newline at end of file diff --git a/cellacdc/cli.py b/cellacdc/cli.py index 04b4e578f..1154b43ad 100644 --- a/cellacdc/cli.py +++ b/cellacdc/cli.py @@ -24,20 +24,23 @@ from . import favourite_func_metrics_csv_path from . import cca_functions + class HeadlessSignal: def __init__(self, *args): pass - + def emit(self, *args, **kwargs): pass + class ProgressCliSignal: def __init__(self, logger_func): self.logger_func = logger_func - + def emit(self, text): self.logger_func(text) + class KernelCliSignals: def __init__(self, logger_func): self.finished = HeadlessSignal(float) @@ -51,36 +54,36 @@ def __init__(self, logger_func): self.debug = HeadlessSignal(object) self.critical = HeadlessSignal(object) + class _WorkflowKernel: def __init__(self, logger, log_path, is_cli=False): self.logger = logger self.log_path = log_path self.is_cli = is_cli - + @exception_handler_cli def parse_paths(self, workflow_params): - paths_to_segm = workflow_params['paths_info']['paths'] - if 'initialization' in workflow_params: - ch_name = workflow_params['initialization']['user_ch_name'] - elif 'measurements' in workflow_params: - channels = workflow_params['measurements']['channels'] - channel_names_to_skip = ( - workflow_params['measurements']['channel_names_to_skip'] - ) + paths_to_segm = workflow_params["paths_info"]["paths"] + if "initialization" in workflow_params: + ch_name = workflow_params["initialization"]["user_ch_name"] + elif "measurements" in workflow_params: + channels = workflow_params["measurements"]["channels"] + channel_names_to_skip = workflow_params["measurements"][ + "channel_names_to_skip" + ] channels = [ch for ch in channels if ch not in channel_names_to_skip] ch_name = channels[0] else: printl(workflow_params, pretty=True) raise KeyError( - 'Cannot find channel name in workflow parameters. ' - 'See above.' + "Cannot find channel name in workflow parameters. See above." ) parsed_paths = [] for path in paths_to_segm: if os.path.isfile(path): parsed_paths.append(path) continue - + images_paths = load.get_images_paths(path) ch_filepaths = load.get_user_ch_paths(images_paths, ch_name) parsed_paths.extend(ch_filepaths) @@ -88,39 +91,38 @@ def parse_paths(self, workflow_params): @exception_handler_cli def parse_stop_frame_numbers(self, workflow_params): - stop_frames_param = ( - workflow_params['paths_info']['stop_frame_numbers'] - ) + stop_frames_param = workflow_params["paths_info"]["stop_frame_numbers"] return [int(n) for n in stop_frames_param] - + def quit(self, error=None): if not self.is_cli and error is not None: raise error - - self.logger.info('='*50) + + self.logger.info("=" * 50) if error is not None: self.logger.exception(traceback.format_exc()) - print('-'*60) - self.logger.info(f'[ERROR]: {error}{error_up_str}') + print("-" * 60) + self.logger.info(f"[ERROR]: {error}{error_up_str}") err_msg = ( - 'Cell-ACDC aborted due to **error**. ' - 'More details above or in the following log file:\n\n' - f'{self.log_path}\n\n' - 'If you cannot solve it, you can report this error by opening ' - 'an issue on our ' - 'GitHub page at the following link:\n\n' - f'{issues_url}\n\n' - 'Please **send the log file** when reporting a bug, thanks!' + "Cell-ACDC aborted due to **error**. " + "More details above or in the following log file:\n\n" + f"{self.log_path}\n\n" + "If you cannot solve it, you can report this error by opening " + "an issue on our " + "GitHub page at the following link:\n\n" + f"{issues_url}\n\n" + "Please **send the log file** when reporting a bug, thanks!" ) self.logger.info(err_msg) else: self.logger.info( - 'Cell-ACDC command-line interface closed. ' - f'{myutils.get_salute_string()}' + "Cell-ACDC command-line interface closed. " + f"{myutils.get_salute_string()}" ) - self.logger.info('='*50) + self.logger.info("=" * 50) exit() + class SegmKernel(_WorkflowKernel): def __init__(self, logger, log_path, is_cli): super().__init__(logger, log_path, is_cli=is_cli) @@ -129,100 +131,92 @@ def __init__(self, logger, log_path, is_cli): def parse_custom_postproc_features_grouped(self, workflow_params): custom_postproc_grouped_features = {} for section, options in workflow_params.items(): - if not section.startswith('postprocess_features.'): + if not section.startswith("postprocess_features."): continue - category = section.split('.')[-1] + category = section.split(".")[-1] for option, value in options.items(): - if option == 'names': - values = value.strip('\n').strip().split('\n') + if option == "names": + values = value.strip("\n").strip().split("\n") custom_postproc_grouped_features[category] = values continue channel = option if category not in custom_postproc_grouped_features: - custom_postproc_grouped_features[category] = { - channel: [value] - } + custom_postproc_grouped_features[category] = {channel: [value]} elif channel not in custom_postproc_grouped_features[category]: - custom_postproc_grouped_features[category][channel] = ( - [value] - ) + custom_postproc_grouped_features[category][channel] = [value] else: custom_postproc_grouped_features[category][channel].append(value) return custom_postproc_grouped_features - - @exception_handler_cli + + @exception_handler_cli def init_args_from_params(self, workflow_params, logger_func): - args = workflow_params['initialization'].copy() - - initialization_section = workflow_params['initialization'] - args['use3DdataFor2Dsegm'] = initialization_section.get( - 'use3DdataFor2Dsegm', False + args = workflow_params["initialization"].copy() + + initialization_section = workflow_params["initialization"] + args["use3DdataFor2Dsegm"] = initialization_section.get( + "use3DdataFor2Dsegm", False ) - args['model_kwargs'] = workflow_params['segmentation_model_params'] - args['track_params'] = workflow_params.get('tracker_params', {}) - args['standard_postrocess_kwargs'] = ( - workflow_params.get('standard_postprocess_features', {}) + args["model_kwargs"] = workflow_params["segmentation_model_params"] + args["track_params"] = workflow_params.get("tracker_params", {}) + args["standard_postrocess_kwargs"] = workflow_params.get( + "standard_postprocess_features", {} ) - args['custom_postproc_features'] = ( - workflow_params.get('custom_postprocess_features', {}) + args["custom_postproc_features"] = workflow_params.get( + "custom_postprocess_features", {} ) - args['custom_postproc_grouped_features'] = ( + args["custom_postproc_grouped_features"] = ( self.parse_custom_postproc_features_grouped(workflow_params) ) - - args['SizeT'] = workflow_params['metadata']['SizeT'] - args['SizeZ'] = workflow_params['metadata']['SizeZ'] - args['logger_func'] = logger_func - args['init_model_kwargs'] = ( - workflow_params.get('init_segmentation_model_params', {}) - ) - args['init_tracker_kwargs'] = ( - workflow_params.get('init_tracker_params', {}) - ) - - args['preproc_recipe'] = config.preprocess_ini_items_to_recipe( - workflow_params + + args["SizeT"] = workflow_params["metadata"]["SizeT"] + args["SizeZ"] = workflow_params["metadata"]["SizeZ"] + args["logger_func"] = logger_func + args["init_model_kwargs"] = workflow_params.get( + "init_segmentation_model_params", {} ) - args['reduce_memory_usage'] = initialization_section.get( - 'reduce_memory_usage', False + args["init_tracker_kwargs"] = workflow_params.get("init_tracker_params", {}) + + args["preproc_recipe"] = config.preprocess_ini_items_to_recipe(workflow_params) + args["reduce_memory_usage"] = initialization_section.get( + "reduce_memory_usage", False ) - + self.init_args(**args) - + @exception_handler_cli def init_args( - self, - user_ch_name, - segm_endname, - model_name, - do_tracking, - do_postprocess, - do_save, - image_channel_tracker, - standard_postrocess_kwargs, - custom_postproc_grouped_features, - custom_postproc_features, - isSegm3D, - use_ROI, - second_channel_name, - use3DdataFor2Dsegm, - model_kwargs, - track_params, - SizeT, - SizeZ, - tracker_name='', - model=None, - preproc_recipe=None, - init_model_kwargs=None, - init_tracker_kwargs=None, - tracker=None, - signals=None, - logger_func=print, - innerPbar_available=False, - is_segment3DT_available=False, - reduce_memory_usage=False, - use_freehand_ROI=True - ): + self, + user_ch_name, + segm_endname, + model_name, + do_tracking, + do_postprocess, + do_save, + image_channel_tracker, + standard_postrocess_kwargs, + custom_postproc_grouped_features, + custom_postproc_features, + isSegm3D, + use_ROI, + second_channel_name, + use3DdataFor2Dsegm, + model_kwargs, + track_params, + SizeT, + SizeZ, + tracker_name="", + model=None, + preproc_recipe=None, + init_model_kwargs=None, + init_tracker_kwargs=None, + tracker=None, + signals=None, + logger_func=print, + innerPbar_available=False, + is_segment3DT_available=False, + reduce_memory_usage=False, + use_freehand_ROI=True, + ): self.user_ch_name = user_ch_name self.segm_endname = segm_endname self.model_name = model_name @@ -256,14 +250,13 @@ def init_args( self.model_kwargs = model_kwargs self.tracker_name = tracker_name self.init_tracker( - self.do_tracking, track_params, tracker_name=tracker_name, - tracker=tracker + self.do_tracking, track_params, tracker_name=tracker_name, tracker=tracker ) - + @exception_handler_cli def init_segm_model(self, posData): self.signals.progress.emit( - f'\nInitializing {self.model_name} segmentation model...' + f"\nInitializing {self.model_name} segmentation model..." ) acdcSegment = myutils.import_segment_module(self.model_name) init_argspecs, segment_argspecs = myutils.getModelArgSpec(acdcSegment) @@ -274,7 +267,7 @@ def init_segm_model(self, posData): segment_argspecs, self.model_kwargs ) if self.second_channel_name is not None: - self.init_model_kwargs['is_rgb'] = True + self.init_model_kwargs["is_rgb"] = True self.model = myutils.init_segm_model( acdcSegment, posData, self.init_model_kwargs @@ -283,19 +276,17 @@ def init_segm_model(self, posData): # The model was not initialized correctly return self.is_segment3DT_available = any( - [name=='segment3DT' for name in dir(self.model)] + [name == "segment3DT" for name in dir(self.model)] ) - + @exception_handler_cli - def init_tracker( - self, do_tracking, track_params, tracker_name='', tracker=None - ): + def init_tracker(self, do_tracking, track_params, tracker_name="", tracker=None): if not do_tracking: self.tracker = None return - + if tracker is None: - self.signals.progress.emit(f'Initializing {tracker_name} tracker...') + self.signals.progress.emit(f"Initializing {tracker_name} tracker...") tracker_module = myutils.import_tracker_module(tracker_name) init_argspecs, track_argspecs = myutils.getTrackerArgSpec( tracker_module, realTime=False @@ -306,31 +297,27 @@ def init_tracker( self.init_tracker_kwargs = myutils.parse_model_params( init_argspecs, self.init_tracker_kwargs ) - track_params = myutils.parse_model_params( - track_argspecs, track_params - ) + track_params = myutils.parse_model_params(track_argspecs, track_params) tracker = tracker_module.tracker(**self.init_tracker_kwargs) - + self.track_params = track_params self.tracker = tracker - + def _tracker_track(self, lab, tracker_input_img=None): tracked_lab = core.tracker_track( - lab, self.tracker, self.track_params, - intensity_img=tracker_input_img, - logger_func=self.logger_func + lab, + self.tracker, + self.track_params, + intensity_img=tracker_input_img, + logger_func=self.logger_func, ) return tracked_lab - + @exception_handler_cli - def run( - self, - img_path, - stop_frame_n - ): + def run(self, img_path, stop_frame_n): posData = load.loadData(img_path, self.user_ch_name) - self.logger_func(f'Loading {posData.relPath}...') + self.logger_func(f"Loading {posData.relPath}...") posData.getBasenameAndChNames() posData.buildPaths() @@ -346,13 +333,11 @@ def run( load_last_tracked_i=False, load_metadata=True, load_dataprep_free_roi=True, - end_filename_segm=self.segm_endname + end_filename_segm=self.segm_endname, ) # Get only name from the string 'segm_.npz' endName = ( - self.segm_endname.replace('segm', '', 1) - .replace('_', '', 1) - .split('.')[0] + self.segm_endname.replace("segm", "", 1).replace("_", "", 1).split(".")[0] ) if endName: # Create a new file that is not the default 'segm.npz' @@ -360,7 +345,7 @@ def run( segmFilename = os.path.basename(posData.segm_npz_path) if self.do_save: - self.logger_func(f'\nSegmentation file {segmFilename}...') + self.logger_func(f"\nSegmentation file {segmFilename}...") posData.SizeT = self.SizeT if self.SizeZ > 1: @@ -371,26 +356,24 @@ def run( posData.isSegm3D = self.isSegm3D posData.saveMetadata() - + isROIactive = False if posData.dataPrep_ROIcoords is not None and self.use_ROI: df_roi = posData.dataPrep_ROIcoords.loc[0] - isROIactive = df_roi.at['cropped', 'value'] == 0 - x0, x1, y0, y1 = df_roi['value'].astype(int)[:4] + isROIactive = df_roi.at["cropped", "value"] == 0 + x0, x1, y0, y1 = df_roi["value"].astype(int)[:4] Y, X = posData.img_data.shape[-2:] - x0 = x0 if x0>0 else 0 - y0 = y0 if y0>0 else 0 - x1 = x1 if x1 0 else 0 + y0 = y0 if y0 > 0 else 0 + x1 = x1 if x1 < X else X + y1 = y1 if y1 < Y else Y # Note that stop_i is not used when SizeT == 1 so it does not matter # which value it has in that case stop_i = stop_frame_n if self.second_channel_name is not None: - self.logger_func( - f'Loading second channel "{self.second_channel_name}"...' - ) + self.logger_func(f'Loading second channel "{self.second_channel_name}"...') secondChFilePath = load.get_filename_from_channel( posData.images_path, self.second_channel_name ) @@ -403,21 +386,21 @@ def run( img_data = posData.img_data if self.second_channel_name is not None: - second_ch_data_slice = secondChImgData[self.t0:stop_i] + second_ch_data_slice = secondChImgData[self.t0 : stop_i] if isROIactive: Y, X = img_data.shape[-2:] img_data = img_data[:, :, y0:y1, x0:x1] if self.second_channel_name is not None: second_ch_data_slice = second_ch_data_slice[:, :, y0:y1, x0:x1] - pad_info = ((0, 0), (y0, Y-y1), (x0, X-x1)) + pad_info = ((0, 0), (y0, Y - y1), (x0, X - x1)) - img_data_slice = img_data[self.t0:stop_i] + img_data_slice = img_data[self.t0 : stop_i] postprocess_img = img_data - + Y, X = img_data.shape[-2:] newShape = (stop_i, Y, X) img_data = np.zeros(newShape, img_data.dtype) - + if self.second_channel_name is not None: second_ch_data = np.zeros(newShape, secondChImgData.dtype) df = posData.segmInfo_df.loc[posData.filename] @@ -428,46 +411,46 @@ def run( img = img_data_slice[i] if self.second_channel_name is not None: second_ch_img = second_ch_data_slice[i] - if zProjHow == 'single z-slice': + if zProjHow == "single z-slice": img_data[i] = img[z] if self.second_channel_name is not None: second_ch_data[i] = second_ch_img[z] - elif zProjHow == 'max z-projection': + elif zProjHow == "max z-projection": img_data[i] = img.max(axis=0) if self.second_channel_name is not None: second_ch_data[i] = second_ch_img.max(axis=0) - elif zProjHow == 'mean z-projection': + elif zProjHow == "mean z-projection": img_data[i] = img.mean(axis=0) if self.second_channel_name is not None: second_ch_data[i] = second_ch_img.mean(axis=0) - elif zProjHow == 'median z-proj.': + elif zProjHow == "median z-proj.": img_data[i] = np.median(img, axis=0) if self.second_channel_name is not None: second_ch_data[i] = np.median(second_ch_img, axis=0) elif posData.SizeZ > 1 and (self.isSegm3D or self.use3DdataFor2Dsegm): # 3D segmentation on 3D data over time - img_data = posData.img_data[self.t0:stop_i] + img_data = posData.img_data[self.t0 : stop_i] postprocess_img = img_data if self.second_channel_name is not None: - second_ch_data = secondChImgData[self.t0:stop_i] + second_ch_data = secondChImgData[self.t0 : stop_i] if isROIactive: Y, X = img_data.shape[-2:] img_data = img_data[:, :, y0:y1, x0:x1] if self.second_channel_name is not None: second_ch_data = second_ch_data[:, :, y0:y1, x0:x1] - pad_info = ((0, 0), (0, 0), (y0, Y-y1), (x0, X-x1)) + pad_info = ((0, 0), (0, 0), (y0, Y - y1), (x0, X - x1)) else: # 2D data over time - img_data = posData.img_data[self.t0:stop_i] + img_data = posData.img_data[self.t0 : stop_i] postprocess_img = img_data if self.second_channel_name is not None: - second_ch_data = secondChImgData[self.t0:stop_i] + second_ch_data = secondChImgData[self.t0 : stop_i] if isROIactive: Y, X = img_data.shape[-2:] img_data = img_data[:, y0:y1, x0:x1] if self.second_channel_name is not None: second_ch_data = second_ch_data[:, :, y0:y1, x0:x1] - pad_info = ((0, 0), (y0, Y-y1), (x0, X-x1)) + pad_info = ((0, 0), (y0, Y - y1), (x0, X - x1)) else: if posData.SizeZ > 1 and not self.isSegm3D and not self.use3DdataFor2Dsegm: img_data = posData.img_data @@ -475,7 +458,7 @@ def run( second_ch_data = secondChImgData if isROIactive: Y, X = img_data.shape[-2:] - pad_info = ((y0, Y-y1), (x0, X-x1)) + pad_info = ((y0, Y - y1), (x0, X - x1)) img_data = img_data[:, y0:y1, x0:x1] if self.second_channel_name is not None: second_ch_data = second_ch_data[:, :, y0:y1, x0:x1] @@ -485,19 +468,19 @@ def run( z_info = posData.segmInfo_df.loc[posData.filename].iloc[0] z = z_info.z_slice_used_dataPrep zProjHow = z_info.which_z_proj - if zProjHow == 'single z-slice': + if zProjHow == "single z-slice": img_data = img_data[z] if self.second_channel_name is not None: second_ch_data = second_ch_data[z] - elif zProjHow == 'max z-projection': + elif zProjHow == "max z-projection": img_data = img_data.max(axis=0) if self.second_channel_name is not None: second_ch_data = second_ch_data.max(axis=0) - elif zProjHow == 'mean z-projection': + elif zProjHow == "mean z-projection": img_data = img_data.mean(axis=0) if self.second_channel_name is not None: second_ch_data = second_ch_data.mean(axis=0) - elif zProjHow == 'median z-proj.': + elif zProjHow == "median z-proj.": img_data = np.median(img_data, axis=0) if self.second_channel_name is not None: second_ch_data[i] = np.median(second_ch_data, axis=0) @@ -508,7 +491,7 @@ def run( second_ch_data = secondChImgData if isROIactive: Y, X = img_data.shape[-2:] - pad_info = ((0, 0), (y0, Y-y1), (x0, X-x1)) + pad_info = ((0, 0), (y0, Y - y1), (x0, X - x1)) img_data = img_data[:, y0:y1, x0:x1] if self.second_channel_name is not None: second_ch_data = second_ch_data[:, y0:y1, x0:x1] @@ -520,43 +503,43 @@ def run( second_ch_data = secondChImgData if isROIactive: Y, X = img_data.shape[-2:] - pad_info = ((y0, Y-y1), (x0, X-x1)) + pad_info = ((y0, Y - y1), (x0, X - x1)) img_data = img_data[y0:y1, x0:x1] if self.second_channel_name is not None: second_ch_data = second_ch_data[y0:y1, x0:x1] postprocess_img = img_data - self.logger_func(f'\nImage shape = {img_data.shape}') + self.logger_func(f"\nImage shape = {img_data.shape}") if self.model is None: self.init_segm_model(posData) - + if self.model is None: self.logger_func( - f'\nSegmentation model {self.model_name} was not initialized!' + f"\nSegmentation model {self.model_name} was not initialized!" ) return - + """Segmentation routine""" - self.logger_func(f'\nSegmenting with {self.model_name}...') + self.logger_func(f"\nSegmenting with {self.model_name}...") t0 = time.perf_counter() if posData.SizeT > 1: if self.innerPbar_available and self.signals is not None: self.signals.resetInnerPbar.emit(len(img_data)) - + if self.is_segment3DT_available and img_data.ndim == 3: - self.model_kwargs['signals'] = ( - self.signals, self.innerPbar_available - ) + self.model_kwargs["signals"] = (self.signals, self.innerPbar_available) if self.second_channel_name is not None: img_data = self.model.second_ch_img_to_stack( img_data, second_ch_data ) lab_stack = core.segm_model_segment( - self.model, img_data, self.model_kwargs, - is_timelapse_model_and_data=True, - preproc_recipe=self.preproc_recipe, - posData=posData + self.model, + img_data, + self.model_kwargs, + is_timelapse_model_and_data=True, + preproc_recipe=self.preproc_recipe, + posData=posData, ) if self.innerPbar_available: # emit one pos done @@ -566,14 +549,15 @@ def run( pbar = tqdm(total=len(img_data), ncols=100) for t, img in enumerate(img_data): if self.second_channel_name is not None: - img = self.model.second_ch_img_to_stack( - img, second_ch_data[t] - ) - + img = self.model.second_ch_img_to_stack(img, second_ch_data[t]) + lab = core.segm_model_segment( - self.model, img, self.model_kwargs, frame_i=t, - preproc_recipe=self.preproc_recipe, - posData=posData + self.model, + img, + self.model_kwargs, + frame_i=t, + preproc_recipe=self.preproc_recipe, + posData=posData, ) lab_stack.append(lab) if self.innerPbar_available: @@ -588,28 +572,27 @@ def run( self.signals.progressBar.emit(1) else: if self.second_channel_name is not None: - img_data = self.model.second_ch_img_to_stack( - img_data, second_ch_data - ) + img_data = self.model.second_ch_img_to_stack(img_data, second_ch_data) lab_stack = core.segm_model_segment( - self.model, img_data, self.model_kwargs, frame_i=0, - preproc_recipe=self.preproc_recipe, - posData=posData + self.model, + img_data, + self.model_kwargs, + frame_i=0, + preproc_recipe=self.preproc_recipe, + posData=posData, ) self.signals.progressBar.emit(1) # lab_stack = smooth_contours(lab_stack, radius=2) posData.saveSamEmbeddings(logger_func=self.logger_func) - + if len(posData.dataPrepFreeRoiPoints) > 0 and self.use_freehand_ROI: - self.logger_func( - 'Removing objects outside the dataprep free-hand ROI...' - ) + self.logger_func("Removing objects outside the dataprep free-hand ROI...") lab_stack = posData.clearSegmObjsDataPrepFreeRoi( lab_stack, is_timelapse=posData.SizeT > 1 ) - + if self.do_postprocess: if posData.SizeT > 1: pbar = tqdm(total=len(lab_stack), ncols=100) @@ -620,9 +603,14 @@ def run( lab_stack[t] = lab_cleaned if self.custom_postproc_features: lab_filtered = features.custom_post_process_segm( - posData, self.custom_postproc_grouped_features, - lab_cleaned, postprocess_img, t, posData.filename, - posData.user_ch_name, self.custom_postproc_features + posData, + self.custom_postproc_grouped_features, + lab_cleaned, + postprocess_img, + t, + posData.filename, + posData.user_ch_name, + self.custom_postproc_features, ) lab_stack[t] = lab_filtered pbar.update() @@ -633,31 +621,37 @@ def run( ) if self.custom_postproc_features: lab_stack = features.custom_post_process_segm( - posData, self.custom_postproc_grouped_features, - lab_stack, postprocess_img, 0, posData.filename, - posData.user_ch_name, self.custom_postproc_features + posData, + self.custom_postproc_grouped_features, + lab_stack, + postprocess_img, + 0, + posData.filename, + posData.user_ch_name, + self.custom_postproc_features, ) - if posData.SizeT > 1 and self.do_tracking: - self.logger_func(f'\nTracking with {self.tracker_name} tracker...') + if posData.SizeT > 1 and self.do_tracking: + self.logger_func(f"\nTracking with {self.tracker_name} tracker...") if self.do_save: - # Since tracker could raise errors we save the not-tracked + # Since tracker could raise errors we save the not-tracked # version which will eventually be overwritten - self.logger_func(f'Saving NON-tracked masks of {posData.relPath}...') + self.logger_func(f"Saving NON-tracked masks of {posData.relPath}...") io.savez_compressed(posData.segm_npz_path, lab_stack) self.signals.innerPbar_available = self.innerPbar_available - self.track_params['signals'] = self.signals + self.track_params["signals"] = self.signals if self.image_channel_tracker is not None: # Check if loading the image for the tracker is required - if 'image' in self.track_params: - trackerInputImage = self.track_params.pop('image') + if "image" in self.track_params: + trackerInputImage = self.track_params.pop("image") else: self.logger_func( - 'Loading image data of channel ' - f'"{self.image_channel_tracker}"') + f'Loading image data of channel "{self.image_channel_tracker}"' + ) trackerInputImage = posData.loadChannelData( - self.image_channel_tracker) + self.image_channel_tracker + ) tracked_stack = self._tracker_track( lab_stack, tracker_input_img=trackerInputImage ) @@ -678,51 +672,57 @@ def run( self.signals.progressBar.emit(1) if isROIactive: - self.logger_func(f'Padding with zeros {pad_info}...') - tracked_stack = np.pad(tracked_stack, pad_info, mode='constant') + self.logger_func(f"Padding with zeros {pad_info}...") + tracked_stack = np.pad(tracked_stack, pad_info, mode="constant") if self.do_save: - self.logger_func(f'Saving {posData.relPath}...') + self.logger_func(f"Saving {posData.relPath}...") io.savez_compressed(posData.segm_npz_path, tracked_stack) t_end = time.perf_counter() - self.logger_func(f'\n{posData.relPath} done.') + self.logger_func(f"\n{posData.relPath} done.") + class ComputeMeasurementsKernel(_WorkflowKernel): def __init__(self, logger, log_path, is_cli): super().__init__(logger, log_path, is_cli=is_cli) self.setup_done = False - + def init_args(self, channel_names, end_filename_segm): self.ch_names = channel_names self.end_filename_segm = end_filename_segm self.notLoadedChNames = [] self.save_object_counts_table = False - - def log(self, message, level='INFO'): + + def log(self, message, level="INFO"): try: self.logger.log(message, level=level) return except Exception as err: pass - + try: self.logger.log(message) return except Exception as err: pass - + try: log_func = getattr(self.logger, level.lower()) log_func(message) return except Exception as err: pass - + def _set_metrics_func_from_posData(self, posData): - (metrics_func, all_metrics_names, custom_func_dict, total_metrics, - ch_indipend_custom_func_dict) = measurements.getMetricsFunc(posData) + ( + metrics_func, + all_metrics_names, + custom_func_dict, + total_metrics, + ch_indipend_custom_func_dict, + ) = measurements.getMetricsFunc(posData) self.metrics_func = metrics_func self.all_metrics_names = all_metrics_names self.total_metrics = total_metrics @@ -731,106 +731,95 @@ def _set_metrics_func_from_posData(self, posData): self.mixed_channel_combine_metrics = [] self.channel_names = posData.chNames self.not_loaded_channel_names = [] - + def to_workflow_config_params(self): params = { - 'channels': '\n'.join(self.ch_names), - 'end_filename_segm': self.end_filename_segm + "channels": "\n".join(self.ch_names), + "end_filename_segm": self.end_filename_segm, } - params['channel_names_to_skip'] = '\n'.join(self.chNamesToSkip) - params['channel_names_to_process'] = '\n'.join(self.chNamesToProcess) + params["channel_names_to_skip"] = "\n".join(self.chNamesToSkip) + params["channel_names_to_process"] = "\n".join(self.chNamesToProcess) calc_for_each_zslice = [ - f'{channel},{value}' + f"{channel},{value}" for channel, value in self.calc_for_each_zslice_mapper.items() ] - params['calc_for_each_zslice_channels'] = '\n'.join(calc_for_each_zslice) - + params["calc_for_each_zslice_channels"] = "\n".join(calc_for_each_zslice) + for channel, colnames in self.metricsToSkip.items(): - params[f'metrics_to_skip_{channel}'] = '\n'.join(colnames) - + params[f"metrics_to_skip_{channel}"] = "\n".join(colnames) + for channel, colnames in self.metricsToSave.items(): - params[f'metrics_to_save_{channel}'] = '\n'.join(colnames) - - params['calc_for_each_zslice_size'] = str( - self.calc_size_for_each_zslice - ) - - params['size_metrics_to_save'] = '\n'.join(self.sizeMetricsToSave) - params['regionprops_to_save'] = '\n'.join(self.regionPropsToSave) - if hasattr(self, 'chIndipendCustomMetricsToSave'): - params['channel_indipendent_custom_metrics_to_save'] = ( - '\n'.join(self.chIndipendCustomMetricsToSave) + params[f"metrics_to_save_{channel}"] = "\n".join(colnames) + + params["calc_for_each_zslice_size"] = str(self.calc_size_for_each_zslice) + + params["size_metrics_to_save"] = "\n".join(self.sizeMetricsToSave) + params["regionprops_to_save"] = "\n".join(self.regionPropsToSave) + if hasattr(self, "chIndipendCustomMetricsToSave"): + params["channel_indipendent_custom_metrics_to_save"] = "\n".join( + self.chIndipendCustomMetricsToSave ) - if hasattr(self, 'mixedChCombineMetricsToSkip'): - params['mixed_combine_metrics_to_skip'] = ( - '\n'.join(self.mixedChCombineMetricsToSkip) + if hasattr(self, "mixedChCombineMetricsToSkip"): + params["mixed_combine_metrics_to_skip"] = "\n".join( + self.mixedChCombineMetricsToSkip ) - - params['save_object_counts_table'] = self.save_object_counts_table - + + params["save_object_counts_table"] = self.save_object_counts_table + return params - + def set_metrics_from_workflow_config_params(self, config_params): - self.init_args( - config_params['channels'], - config_params['end_filename_segm'] - ) - - self.chNamesToSkip = config_params['channel_names_to_skip'] + self.init_args(config_params["channels"], config_params["end_filename_segm"]) + + self.chNamesToSkip = config_params["channel_names_to_skip"] self.chNamesToProcess = config_params.get( - 'channel_names_to_process', config_params['channels'] + "channel_names_to_process", config_params["channels"] ) - self.metricsToSkip = {chName:[] for chName in self.ch_names} - self.metricsToSave = {chName:[] for chName in self.ch_names} + self.metricsToSkip = {chName: [] for chName in self.ch_names} + self.metricsToSave = {chName: [] for chName in self.ch_names} self.mixedChCombineMetricsToSkip = [] self.calc_for_each_zslice_mapper = {} - self.calc_size_for_each_zslice = ( - config_params['calc_for_each_zslice_size'] - ) - self.sizeMetricsToSave = config_params['size_metrics_to_save'] - self.regionPropsToSave = config_params['regionprops_to_save'] + self.calc_size_for_each_zslice = config_params["calc_for_each_zslice_size"] + self.sizeMetricsToSave = config_params["size_metrics_to_save"] + self.regionPropsToSave = config_params["regionprops_to_save"] self.save_object_counts_table = config_params.get( - 'save_object_counts_table', False + "save_object_counts_table", False ) - if 'channel_indipendent_custom_metrics_to_save' in config_params: - self.chIndipendCustomMetricsToSave = ( - config_params['channel_indipendent_custom_metrics_to_save'] - ) - - if 'mixed_combine_metrics_to_skip' in config_params: - self.mixedChCombineMetricsToSkip = ( - config_params['mixed_combine_metrics_to_skip'] - ) - - for channel_value in config_params['calc_for_each_zslice_channels']: - channel, value = channel_value.split(',') - value = value.lower() == 'true' + if "channel_indipendent_custom_metrics_to_save" in config_params: + self.chIndipendCustomMetricsToSave = config_params[ + "channel_indipendent_custom_metrics_to_save" + ] + + if "mixed_combine_metrics_to_skip" in config_params: + self.mixedChCombineMetricsToSkip = config_params[ + "mixed_combine_metrics_to_skip" + ] + + for channel_value in config_params["calc_for_each_zslice_channels"]: + channel, value = channel_value.split(",") + value = value.lower() == "true" self.calc_for_each_zslice_mapper[channel] = value - + for channel in self.ch_names: - metrics_to_skip = config_params.get( - f'metrics_to_skip_{channel}', '' - ) + metrics_to_skip = config_params.get(f"metrics_to_skip_{channel}", "") if metrics_to_skip: self.metricsToSkip[channel] = metrics_to_skip - - metrics_to_save = config_params.get( - f'metrics_to_save_{channel}', '' - ) + + metrics_to_save = config_params.get(f"metrics_to_save_{channel}", "") if metrics_to_save: self.metricsToSave[channel] = metrics_to_save - + def set_save_objects_count_table(self, yes: bool): self.save_object_counts_table = yes - + def set_metrics_from_set_measurements_dialog(self, setMeasurementsDialog): self.chNamesToSkip = [] self.chNamesToProcess = [] - self.metricsToSkip = {chName:[] for chName in self.ch_names} - self.metricsToSave = {chName:[] for chName in self.ch_names} + self.metricsToSkip = {chName: [] for chName in self.ch_names} + self.metricsToSave = {chName: [] for chName in self.ch_names} self.calc_for_each_zslice_mapper = {} self.calc_size_for_each_zslice = False - + # Remove unchecked metrics and load checked not loaded channels for chNameGroupbox in setMeasurementsDialog.chNameGroupboxes: chName = chNameGroupbox.chName @@ -838,7 +827,7 @@ def set_metrics_from_set_measurements_dialog(self, setMeasurementsDialog): # Skip entire channel self.chNamesToSkip.append(chName) continue - + self.chNamesToProcess.append(chName) self.calc_for_each_zslice_mapper[chName] = ( chNameGroupbox.calcForEachZsliceRequested @@ -849,7 +838,7 @@ def set_metrics_from_set_measurements_dialog(self, setMeasurementsDialog): self.metricsToSkip[chName].append(colname) else: self.metricsToSave[chName].append(colname) - func_name = colname[len(chName):] + func_name = colname[len(chName) :] self.calc_size_for_each_zslice = ( setMeasurementsDialog.sizeMetricsQGBox.calcForEachZsliceRequested @@ -871,7 +860,7 @@ def set_metrics_from_set_measurements_dialog(self, setMeasurementsDialog): for checkBox in setMeasurementsDialog.regionPropsQGBox.checkBoxes: if checkBox.isChecked(): self.regionPropsToSave.append(checkBox.text()) - + self.regionPropsToSave = tuple(self.regionPropsToSave) if setMeasurementsDialog.chIndipendCustomeMetricsQGBox is not None: @@ -886,14 +875,12 @@ def set_metrics_from_set_measurements_dialog(self, setMeasurementsDialog): for checkBox in checkBoxes: if skipAll: continue - + if checkBox.isChecked(): - chIndipendCustomMetricsToSave.append(checkBox.text()) + chIndipendCustomMetricsToSave.append(checkBox.text()) + + self.chIndipendCustomMetricsToSave = tuple(chIndipendCustomMetricsToSave) - self.chIndipendCustomMetricsToSave = tuple( - chIndipendCustomMetricsToSave - ) - self.mixedChCombineMetricsToSkip = [] if setMeasurementsDialog.mixedChannelsCombineMetricsQGBox is not None: skipAll = ( @@ -917,67 +904,72 @@ def _init_metrics_to_save(self, posData): self.isSegm3D = posData.getIsSegm3D() if self.metricsToSave is None: - # self.metricsToSave means that the user did not set + # self.metricsToSave means that the user did not set # through setMeasurements dialog --> save all measurements - self.metricsToSave = {chName:[] for chName in posData.loadedChNames} + self.metricsToSave = {chName: [] for chName in posData.loadedChNames} isManualBackgrPresent = posData.manualBackgroundLab is not None for chName in posData.loadedChNames: metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc( - posData.SizeZ>1, chName, isSegm3D=self.isSegm3D, - isManualBackgrPresent=isManualBackgrPresent + posData.SizeZ > 1, + chName, + isSegm3D=self.isSegm3D, + isManualBackgrPresent=isManualBackgrPresent, ) self.metricsToSave[chName].extend(metrics_desc.keys()) self.metricsToSave[chName].extend(bkgr_val_desc.keys()) custom_metrics_desc = measurements.custom_metrics_desc( - posData.SizeZ>1, chName, posData=posData, - isSegm3D=self.isSegm3D, return_combine=False - ) - self.metricsToSave[chName].extend( - custom_metrics_desc.keys() + posData.SizeZ > 1, + chName, + posData=posData, + isSegm3D=self.isSegm3D, + return_combine=False, ) - + self.metricsToSave[chName].extend(custom_metrics_desc.keys()) + # Get metrics parameters --> function name, how etc self.metrics_func, _ = measurements.standard_metrics_func() self.custom_func_dict = measurements.get_custom_metrics_func() params = measurements.get_metrics_params( self.metricsToSave, self.metrics_func, self.custom_func_dict ) - (bkgr_metrics_params, foregr_metrics_params, - concentration_metrics_params, custom_metrics_params) = params + ( + bkgr_metrics_params, + foregr_metrics_params, + concentration_metrics_params, + custom_metrics_params, + ) = params self.bkgr_metrics_params = bkgr_metrics_params self.foregr_metrics_params = foregr_metrics_params self.concentration_metrics_params = concentration_metrics_params self.custom_metrics_params = custom_metrics_params - + self.ch_indipend_custom_func_dict = ( measurements.get_channel_indipendent_custom_metrics_func() ) - if not hasattr(self, 'chIndipendCustomMetricsToSave'): + if not hasattr(self, "chIndipendCustomMetricsToSave"): self.chIndipendCustomMetricsToSave = list( measurements.ch_indipend_custom_metrics_desc( - posData.SizeZ>1, isSegm3D=self.isSegm3D, + posData.SizeZ > 1, + isSegm3D=self.isSegm3D, ).keys() ) - + self.ch_indipend_custom_func_params = ( measurements.get_channel_indipend_custom_metrics_params( - self.ch_indipend_custom_func_dict, - self.chIndipendCustomMetricsToSave + self.ch_indipend_custom_func_dict, self.chIndipendCustomMetricsToSave ) ) - + def _load_posData(self, img_path, end_filename_segm): images_path = os.path.dirname(img_path) - exp_foldername = os.path.basename( - os.path.dirname(os.path.dirname(images_path)) - ) + exp_foldername = os.path.basename(os.path.dirname(os.path.dirname(images_path))) basename, channel_names = myutils.getBasenameAndChNames( - images_path, useExt=('.tif', '.h5') + images_path, useExt=(".tif", ".h5") ) posData = load.loadData(img_path, channel_names[0]) - - posData.getBasenameAndChNames(useExt=('.tif', '.h5')) + + posData.getBasenameAndChNames(useExt=(".tif", ".h5")) posData.buildPaths() posData.loadImgData() @@ -994,84 +986,82 @@ def _load_posData(self, img_path, end_filename_segm): load_customAnnot=True, load_customCombineMetrics=True, end_filename_segm=end_filename_segm, - load_dataPrep_ROIcoords=True + load_dataPrep_ROIcoords=True, ) posData.labelSegmData() - + self.isSegm3D = posData.getIsSegm3D() - + # Allow single 2D/3D image if posData.SizeT == 1: posData.img_data = posData.img_data[np.newaxis] - + if posData.segm_data is not None: posData.segm_data = posData.segm_data[np.newaxis] - + return posData - + def _load_image_data(self, posData, channel_names): if posData.fluo_data_dict: - return - + return + # Load fluorescence channels data since not loaded in GUI posData.loadedChNames = [] for c, channel in enumerate(channel_names): if channel in self.chNamesToSkip: - continue - + continue + if channel == posData.user_ch_name: img_data = posData.img_data filename = posData.filename bkgrData = posData.bkgrData else: - filepath = load.get_filename_from_channel( - posData.images_path, channel - ) + filepath = load.get_filename_from_channel(posData.images_path, channel) img_data, bkgrData = self._load_channel_data(filepath) if posData.SizeT == 1: img_data = img_data[np.newaxis] - + filename_ext = os.path.basename(filepath) filename, _ = os.path.splitext(filename_ext) - + posData.loadedChNames.append(channel) posData.loadedFluoChannels.add(channel) posData.fluo_data_dict[filename] = img_data posData.fluo_bkgrData_dict[filename] = bkgrData - + def init_signals(self, computeMetricsWorker, saveDataWorker): self.customMetricsCritical = HeadlessSignal() self.regionPropsCritical = HeadlessSignal() - + if saveDataWorker is not None: self.customMetricsCritical = saveDataWorker.customMetricsCritical self.regionPropsCritical = saveDataWorker.regionPropsCritical - + elif computeMetricsWorker is not None: saveDataWorker = computeMetricsWorker.mainWin.gui.saveDataWorker self.customMetricsCritical = saveDataWorker.customMetricsCritical self.regionPropsCritical = saveDataWorker.regionPropsCritical - + @exception_handler_cli def run( - self, - img_path: os.PathLike='', - stop_frame_n: int=1, - end_filename_segm: str='', - computeMetricsWorker=None, - saveDataWorker=None, - posData=None, - save_metrics=True, - do_init_metrics=True, - last_cca_frame_i=None - ): + self, + img_path: os.PathLike = "", + stop_frame_n: int = 1, + end_filename_segm: str = "", + computeMetricsWorker=None, + saveDataWorker=None, + posData=None, + save_metrics=True, + do_init_metrics=True, + last_cca_frame_i=None, + ): if posData is None: posData = self._load_posData(img_path, end_filename_segm) - + channel_names = posData.chNames images_path = posData.images_path exp_foldername = os.path.basename(posData.exp_path) - + self._set_metrics_func_from_posData(posData) if computeMetricsWorker is not None and do_init_metrics: @@ -1079,7 +1069,7 @@ def run( if computeMetricsWorker.abort: computeMetricsWorker.signals.finished.emit(computeMetricsWorker) return - + if self.setup_done: computeMetricsWorker.signals.finished.emit(computeMetricsWorker) return @@ -1087,46 +1077,41 @@ def run( computeMetricsWorker.emitSigAskRunNow() if computeMetricsWorker.abort or computeMetricsWorker.savedToWorkflow: computeMetricsWorker.signals.finished.emit(computeMetricsWorker) - return - + return + if not posData.segmFound: - rel_path = ( - f'...{os.sep}{exp_foldername}' - f'{os.sep}{posData.pos_foldername}' - ) - self.log( - f'Skipping "{rel_path}" ' - f'because segm. file was not found.' - ) + rel_path = f"...{os.sep}{exp_foldername}{os.sep}{posData.pos_foldername}" + self.log(f'Skipping "{rel_path}" because segm. file was not found.') return - + self.init_signals(computeMetricsWorker, saveDataWorker) - + self.log( - 'Loading the following files:\n' - f'Segmentation file name: {os.path.basename(posData.segm_npz_path)}\n' - f'ACDC output file name: {os.path.basename(posData.acdc_output_csv_path)}' + "Loading the following files:\n" + f"Segmentation file name: {os.path.basename(posData.segm_npz_path)}\n" + f"ACDC output file name: {os.path.basename(posData.acdc_output_csv_path)}" ) - + posData.init_segmInfo_df() - + if computeMetricsWorker is not None: computeMetricsWorker.emitSigComputeVolume(posData, stop_frame_n) - + self._init_metrics_to_save(posData) - + if computeMetricsWorker is not None: computeMetricsWorker.signals.initProgressBar.emit(stop_frame_n) - + channels_to_load = [ - ch for ch in channel_names if not ch in self.chNamesToSkip - and ch in self.chNamesToProcess + ch + for ch in channel_names + if not ch in self.chNamesToSkip and ch in self.chNamesToProcess ] - - self.log(f'Loading channels {channels_to_load}...') - + + self.log(f"Loading channels {channels_to_load}...") + self._load_image_data(posData, channels_to_load) - + acdc_df_li = [] keys = [] for frame_i in range(stop_frame_n): @@ -1134,29 +1119,29 @@ def run( stop = saveDataWorker.checkAbort() if stop: break - - lab = posData.segm_data[frame_i] + + lab = posData.segm_data[frame_i] if not np.any(lab): # Empty segmentation mask --> skip continue - + acdc_df = None if computeMetricsWorker is not None: - rp = posData.allData_li[frame_i]['regionprops'] + rp = posData.allData_li[frame_i]["regionprops"] elif saveDataWorker is not None: - rp = posData.allData_li[frame_i]['regionprops'] - acdc_df = posData.allData_li[frame_i]['acdc_df'] + rp = posData.allData_li[frame_i]["regionprops"] + acdc_df = posData.allData_li[frame_i]["acdc_df"] if acdc_df is None: continue else: if frame_i == 0: - self.log('\nComputing cell volume...') + self.log("\nComputing cell volume...") rp = skimage.measure.regionprops(lab) rp = self._calc_volume_metrics(rp, posData) - + posData.lab = lab posData.rp = rp - + if acdc_df is None: if posData.acdc_df is None: acdc_df = myutils.getBaseAcdcDf(rp) @@ -1165,41 +1150,37 @@ def run( acdc_df = posData.acdc_df.loc[frame_i].copy() except: acdc_df = myutils.getBaseAcdcDf(rp) - - key = (frame_i, posData.TimeIncrement*frame_i) + + key = (frame_i, posData.TimeIncrement * frame_i) acdc_df = load.pd_bool_and_float_to_int_to_str( acdc_df, inplace=False, colsToCastInt=[] ) - + if not save_metrics: if saveDataWorker is not None: saveDataWorker.emitUpdateProgressBar() acdc_df_li.append(acdc_df) keys.append(key) continue - + try: acdc_df = self._add_volume_metrics(acdc_df, rp, posData) acdc_df, calc_metrics_addtional_args = self._init_calc_metrics( - acdc_df, rp, frame_i, lab, posData, - saveDataWorker=saveDataWorker + acdc_df, rp, frame_i, lab, posData, saveDataWorker=saveDataWorker ) acdc_df = self._calc_metrics_iter_channels( - acdc_df, rp, frame_i, lab, posData, - *calc_metrics_addtional_args + acdc_df, rp, frame_i, lab, posData, *calc_metrics_addtional_args ) except Exception as error: - traceback_format = traceback.format_exc() - self.log(f'\n{traceback_format}') + traceback_format = traceback.format_exc() + self.log(f"\n{traceback_format}") if computeMetricsWorker is not None: computeMetricsWorker.standardMetricsErrors[str(error)] = ( traceback_format ) if saveDataWorker is not None: - saveDataWorker.addMetricsCritical.emit( - traceback_format, str(error) - ) - + saveDataWorker.addMetricsCritical.emit(traceback_format, str(error)) + if frame_i == 0: if saveDataWorker is not None: saveDataWorker.emitUpdateProgressBar() @@ -1208,142 +1189,149 @@ def run( continue try: - prev_lab = posData.segm_data[frame_i-1] + prev_lab = posData.segm_data[frame_i - 1] acdc_df = self._add_velocity_measurement( acdc_df, prev_lab, lab, posData ) except Exception as error: traceback_format = traceback.format_exc() - self.log(f'\n{traceback_format}') + self.log(f"\n{traceback_format}") if computeMetricsWorker is not None: e = str(error) - computeMetricsWorker.standardMetricsErrors[e] = ( - traceback_format - ) - + computeMetricsWorker.standardMetricsErrors[e] = traceback_format + acdc_df_li.append(acdc_df) keys.append(key) if computeMetricsWorker is not None: computeMetricsWorker.signals.progressBar.emit(1) - + if saveDataWorker is not None: saveDataWorker.emitUpdateProgressBar() - + if not acdc_df_li: - print('-'*30) + print("-" * 30) self.log( - 'All selected positions in the experiment folder ' - f'{exp_foldername} have EMPTY segmentation mask. ' - 'Metrics will not be saved.' + "All selected positions in the experiment folder " + f"{exp_foldername} have EMPTY segmentation mask. " + "Metrics will not be saved." ) - print('-'*30) + print("-" * 30) return - + self._concat_and_save_acdc_df( - acdc_df_li, keys, posData, save_metrics, - computeMetricsWorker=computeMetricsWorker, + acdc_df_li, + keys, + posData, + save_metrics, + computeMetricsWorker=computeMetricsWorker, saveDataWorker=saveDataWorker, - last_cca_frame_i=last_cca_frame_i + last_cca_frame_i=last_cca_frame_i, ) - + def _concat_and_save_acdc_df( - self, acdc_df_li, keys, posData, save_metrics, - computeMetricsWorker=None, saveDataWorker=None, - last_cca_frame_i=None - ): - + self, + acdc_df_li, + keys, + posData, + save_metrics, + computeMetricsWorker=None, + saveDataWorker=None, + last_cca_frame_i=None, + ): + all_frames_acdc_df = pd.concat( - acdc_df_li, keys=keys, names=['frame_i', 'time_seconds', 'Cell_ID'] + acdc_df_li, keys=keys, names=["frame_i", "time_seconds", "Cell_ID"] ) - + if save_metrics: self._add_combined_metrics( posData, all_frames_acdc_df, saveDataWorker=saveDataWorker ) - + all_frames_acdc_df = self._add_additional_metadata( posData, all_frames_acdc_df, posData.segm_data ) - all_frames_acdc_df = self._remove_deprecated_rows( - all_frames_acdc_df - ) - all_frames_acdc_df = self._add_derived_cell_cycle_columns( - all_frames_acdc_df - ) + all_frames_acdc_df = self._remove_deprecated_rows(all_frames_acdc_df) + all_frames_acdc_df = self._add_derived_cell_cycle_columns(all_frames_acdc_df) all_frames_acdc_df = load._fix_will_divide(all_frames_acdc_df) custom_annot_columns = posData.getCustomAnnotColumnNames() - self.log( - f'Saving acdc_output to: "{posData.acdc_output_csv_path}"' - ) - + self.log(f'Saving acdc_output to: "{posData.acdc_output_csv_path}"') + self._save_acdc_df( - all_frames_acdc_df, posData, custom_annot_columns, - computeMetricsWorker=computeMetricsWorker, + all_frames_acdc_df, + posData, + custom_annot_columns, + computeMetricsWorker=computeMetricsWorker, saveDataWorker=saveDataWorker, - last_cca_frame_i=last_cca_frame_i + last_cca_frame_i=last_cca_frame_i, ) - + if not self.save_object_counts_table: return - + countMapper = posData.countObjectsInSegm() - countMapper.pop('In current frame', None) + countMapper.pop("In current frame", None) df_count_endname = posData.saveObjCounts(countMapper) - - self.log( - 'Saved object counts table to file ending with: ' - f'"{df_count_endname}"' - ) - + + self.log(f'Saved object counts table to file ending with: "{df_count_endname}"') + def _remove_deprecated_rows(self, df): v1_2_4_rc25_deprecated_cols = [ - 'editIDclicked_x', 'editIDclicked_y', - 'editIDnewID', 'editIDnewIDs' + "editIDclicked_x", + "editIDclicked_y", + "editIDnewID", + "editIDnewIDs", ] - df = df.drop(columns=v1_2_4_rc25_deprecated_cols, errors='ignore') + df = df.drop(columns=v1_2_4_rc25_deprecated_cols, errors="ignore") # Remove old gui_ columns from version < v1.2.4.rc-7 - gui_columns = df.filter(regex='gui_*').columns - df = df.drop(columns=gui_columns, errors='ignore') - cell_id_cols = df.filter(regex='Cell_ID.*').columns - df = df.drop(columns=cell_id_cols, errors='ignore') - time_seconds_cols = df.filter(regex='time_seconds.*').columns - df = df.drop(columns=time_seconds_cols, errors='ignore') - df = df.drop(columns='relative_ID_tree', errors='ignore') - df = df.drop(columns=['level_0', 'index'], errors='ignore') + gui_columns = df.filter(regex="gui_*").columns + df = df.drop(columns=gui_columns, errors="ignore") + cell_id_cols = df.filter(regex="Cell_ID.*").columns + df = df.drop(columns=cell_id_cols, errors="ignore") + time_seconds_cols = df.filter(regex="time_seconds.*").columns + df = df.drop(columns=time_seconds_cols, errors="ignore") + df = df.drop(columns="relative_ID_tree", errors="ignore") + df = df.drop(columns=["level_0", "index"], errors="ignore") return df - + def _save_acdc_df( - self, all_frames_acdc_df, posData, custom_annot_columns, - computeMetricsWorker=None, saveDataWorker=None, - last_cca_frame_i=None - ): + self, + all_frames_acdc_df, + posData, + custom_annot_columns, + computeMetricsWorker=None, + saveDataWorker=None, + last_cca_frame_i=None, + ): try: if saveDataWorker is not None: load.store_copy_acdc_df( - posData, posData.acdc_output_csv_path, - log_func=saveDataWorker.progress.emit + posData, + posData.acdc_output_csv_path, + log_func=saveDataWorker.progress.emit, ) load.save_acdc_df_file( - all_frames_acdc_df, posData.acdc_output_csv_path, + all_frames_acdc_df, + posData.acdc_output_csv_path, custom_annot_columns=custom_annot_columns, - last_cca_frame_i=last_cca_frame_i + last_cca_frame_i=last_cca_frame_i, ) posData.acdc_df = all_frames_acdc_df except PermissionError as error: traceback_str = traceback.format_exc() if computeMetricsWorker is not None: computeMetricsWorker.emitSigPermissionErrorAndSave( - posData, traceback_str, all_frames_acdc_df, - custom_annot_columns + posData, traceback_str, all_frames_acdc_df, custom_annot_columns ) - + if saveDataWorker is not None: saveDataWorker.emitSigPermissionErrorAndSave( - all_frames_acdc_df, posData.acdc_output_csv_path, - custom_annot_columns + all_frames_acdc_df, + posData.acdc_output_csv_path, + custom_annot_columns, ) except Exception as error: if saveDataWorker is not None: @@ -1351,7 +1339,7 @@ def _save_acdc_df( saveDataWorker.critical.emit(error) saveDataWorker.waitCond.wait(saveDataWorker.mutex) saveDataWorker.mutex.unlock() - + def _load_channel_data(self, channel_path): self.log(f'Loading fluorescence image data from "{channel_path}"...') images_path = os.path.dirname(channel_path) @@ -1359,28 +1347,28 @@ def _load_channel_data(self, channel_path): # Load overlay frames and align if needed filename = os.path.basename(channel_path) filename_noEXT, ext = os.path.splitext(filename) - if ext == '.npy' or ext == '.npz': + if ext == ".npy" or ext == ".npz": img_data = np.load(channel_path) try: - img_data = np.squeeze(img_data['arr_0']) + img_data = np.squeeze(img_data["arr_0"]) except Exception as e: img_data = np.squeeze(img_data) # Load background data bkgrData_path = os.path.join( - images_path, f'{filename_noEXT}_bkgrRoiData.npz' + images_path, f"{filename_noEXT}_bkgrRoiData.npz" ) if os.path.exists(bkgrData_path): bkgrData = np.load(bkgrData_path) - elif ext == '.tif' or ext == '.tiff': - aligned_filename = f'{filename_noEXT}_aligned.npz' + elif ext == ".tif" or ext == ".tiff": + aligned_filename = f"{filename_noEXT}_aligned.npz" aligned_path = os.path.join(images_path, aligned_filename) if os.path.exists(aligned_path): - img_data = np.load(aligned_path)['arr_0'] + img_data = np.load(aligned_path)["arr_0"] # Load background data bkgrData_path = os.path.join( - images_path, f'{aligned_filename}_bkgrRoiData.npz' + images_path, f"{aligned_filename}_bkgrRoiData.npz" ) if os.path.exists(bkgrData_path): bkgrData = np.load(bkgrData_path) @@ -1389,7 +1377,7 @@ def _load_channel_data(self, channel_path): # Load background data bkgrData_path = os.path.join( - images_path, f'{filename_noEXT}_bkgrRoiData.npz' + images_path, f"{filename_noEXT}_bkgrRoiData.npz" ) if os.path.exists(bkgrData_path): bkgrData = np.load(bkgrData_path) @@ -1397,7 +1385,7 @@ def _load_channel_data(self, channel_path): return None, None return img_data, bkgrData - + def _calc_volume_metrics(self, rp, posData): PhysicalSizeY = posData.PhysicalSizeY PhysicalSizeX = posData.PhysicalSizeX @@ -1409,14 +1397,14 @@ def _calc_volume_metrics(self, rp, posData): obj.vol_vox = vol_vox obj.vol_fl = vol_fl return rp - + def _add_volume_metrics(self, df, rp, posData): PhysicalSizeY = posData.PhysicalSizeY PhysicalSizeX = posData.PhysicalSizeX - yx_pxl_to_um2 = PhysicalSizeY*PhysicalSizeX - vox_to_fl_3D = PhysicalSizeY*PhysicalSizeX*posData.PhysicalSizeZ - - init_list = [-2]*len(rp) + yx_pxl_to_um2 = PhysicalSizeY * PhysicalSizeX + vox_to_fl_3D = PhysicalSizeY * PhysicalSizeX * posData.PhysicalSizeZ + + init_list = [-2] * len(rp) IDs = init_list.copy() IDs_vol_vox = init_list.copy() IDs_area_pxl = init_list.copy() @@ -1435,54 +1423,52 @@ def _add_volume_metrics(self, df, rp, posData): IDs_vol_vox[i] = np.nan IDs_vol_fl[i] = np.nan IDs_area_pxl[i] = obj.area - IDs_area_um2[i] = obj.area*yx_pxl_to_um2 + IDs_area_um2[i] = obj.area * yx_pxl_to_um2 if self.isSegm3D: IDs_vol_vox_3D[i] = obj.area - IDs_vol_fl_3D[i] = obj.area*vox_to_fl_3D - - df['cell_area_pxl'] = pd.Series(data=IDs_area_pxl, index=IDs, dtype=float) - df['cell_vol_vox'] = pd.Series(data=IDs_vol_vox, index=IDs, dtype=float) - df['cell_area_um2'] = pd.Series(data=IDs_area_um2, index=IDs, dtype=float) - df['cell_vol_fl'] = pd.Series(data=IDs_vol_fl, index=IDs, dtype=float) + IDs_vol_fl_3D[i] = obj.area * vox_to_fl_3D + + df["cell_area_pxl"] = pd.Series(data=IDs_area_pxl, index=IDs, dtype=float) + df["cell_vol_vox"] = pd.Series(data=IDs_vol_vox, index=IDs, dtype=float) + df["cell_area_um2"] = pd.Series(data=IDs_area_um2, index=IDs, dtype=float) + df["cell_vol_fl"] = pd.Series(data=IDs_vol_fl, index=IDs, dtype=float) if self.isSegm3D: - df['cell_vol_vox_3D'] = pd.Series( + df["cell_vol_vox_3D"] = pd.Series( data=IDs_vol_vox_3D, index=IDs, dtype=float ) - df['cell_vol_fl_3D'] = pd.Series( - data=IDs_vol_fl_3D, index=IDs, dtype=float - ) + df["cell_vol_fl_3D"] = pd.Series(data=IDs_vol_fl_3D, index=IDs, dtype=float) return df - + def _check_zSlice(self, posData, frame_i, saveDataWorker=None): if posData.SizeZ == 1: return True - + # Iteare fluo channels and get 2D data from 3D if needed filenames = posData.fluo_data_dict.keys() for chName, filename in zip(posData.loadedChNames, filenames): if chName in self.chNamesToSkip: - continue - + continue + idx = (filename, frame_i) try: - if posData.segmInfo_df.at[idx, 'resegmented_in_gui']: - col = 'z_slice_used_gui' + if posData.segmInfo_df.at[idx, "resegmented_in_gui"]: + col = "z_slice_used_gui" else: - col = 'z_slice_used_dataPrep' + col = "z_slice_used_dataPrep" z_slice = posData.segmInfo_df.at[idx, col] except KeyError: try: # Try to see if the user already selected z-slice in prev pos segmInfo_df = pd.read_csv(posData.segmInfo_df_csv_path) - index_col = ['filename', 'frame_i'] + index_col = ["filename", "frame_i"] posData.segmInfo_df = segmInfo_df.set_index(index_col) - col = 'z_slice_used_dataPrep' + col = "z_slice_used_dataPrep" z_slice = posData.segmInfo_df.at[idx, col] except KeyError as e: if saveDataWorker is not None: saveDataWorker.progress.emit( f'z-slice for channel "{chName}" absent. ' - 'Follow instructions on pop-up dialogs.' + "Follow instructions on pop-up dialogs." ) saveDataWorker.mutex.lock() saveDataWorker.askZsliceAbsent.emit(filename, posData) @@ -1491,41 +1477,40 @@ def _check_zSlice(self, posData, frame_i, saveDataWorker=None): if saveDataWorker.abort: return False saveDataWorker.progress.emit( - f'Saving (check terminal for additional progress info)...' + f"Saving (check terminal for additional progress info)..." ) segmInfo_df = pd.read_csv(posData.segmInfo_df_csv_path) - index_col = ['filename', 'frame_i'] + index_col = ["filename", "frame_i"] posData.segmInfo_df = segmInfo_df.set_index(index_col) - col = 'z_slice_used_dataPrep' + col = "z_slice_used_dataPrep" z_slice = posData.segmInfo_df.at[idx, col] else: print( - f'[WARNING]: z-slice for channel {chName} absent. ' - 'Using middle z-slice for calculating metrics.' + f"[WARNING]: z-slice for channel {chName} absent. " + "Using middle z-slice for calculating metrics." ) middle_z = round(np.median(np.arange(posData.SizeZ))) - new_row = pd.DataFrame({ - 'z_slice_used_dataPrep': [middle_z], - 'resegmented_in_gui': [0], - 'which_z_proj': 'single z-slice', - 'is_from_dataPrep': [0], - 'z_slice_used_gui': [-1], - 'which_z_proj_gui': 'single z-slice', + new_row = pd.DataFrame( + { + "z_slice_used_dataPrep": [middle_z], + "resegmented_in_gui": [0], + "which_z_proj": "single z-slice", + "is_from_dataPrep": [0], + "z_slice_used_gui": [-1], + "which_z_proj_gui": "single z-slice", }, - index=[idx] - ) - posData.segmInfo_df = pd.concat( - [posData.segmInfo_df, new_row] + index=[idx], ) + posData.segmInfo_df = pd.concat([posData.segmInfo_df, new_row]) posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) return True - + def _init_calc_metrics( - self, acdc_df, rp, frame_i, lab, posData, saveDataWorker=None - ): - yx_pxl_to_um2 = posData.PhysicalSizeY*posData.PhysicalSizeX + self, acdc_df, rp, frame_i, lab, posData, saveDataWorker=None + ): + yx_pxl_to_um2 = posData.PhysicalSizeY * posData.PhysicalSizeX vox_to_fl_3D = ( - posData.PhysicalSizeY*posData.PhysicalSizeX*posData.PhysicalSizeZ + posData.PhysicalSizeY * posData.PhysicalSizeX * posData.PhysicalSizeZ ) manualBackgrLab = posData.manualBackgroundLab @@ -1538,7 +1523,7 @@ def _init_calc_metrics( size_metrics_to_save = self.sizeMetricsToSave regionprops_to_save = self.regionPropsToSave custom_func_dict = self.custom_func_dict - + calc_size_for_each_zslice = self.calc_size_for_each_zslice # Pre-populate columns with zeros @@ -1554,35 +1539,36 @@ def _init_calc_metrics( df = df.combine_first(acdc_df) # Check if z-slice is present for 3D z-stack data - proceed = self._check_zSlice( - posData, frame_i, saveDataWorker=saveDataWorker - ) + proceed = self._check_zSlice(posData, frame_i, saveDataWorker=saveDataWorker) if not proceed: return df, [] - + df = measurements.add_size_metrics( - df, rp, size_metrics_to_save, isSegm3D, yx_pxl_to_um2, - vox_to_fl_3D, calc_size_for_each_zslice=calc_size_for_each_zslice + df, + rp, + size_metrics_to_save, + isSegm3D, + yx_pxl_to_um2, + vox_to_fl_3D, + calc_size_for_each_zslice=calc_size_for_each_zslice, ) - + # Get background masks - autoBkgr_masks = measurements.get_autoBkgr_mask( - lab, isSegm3D, posData, frame_i - ) + autoBkgr_masks = measurements.get_autoBkgr_mask(lab, isSegm3D, posData, frame_i) # self._emitSigDebug((lab, frame_i, autoBkgr_masks)) - + autoBkgr_mask, autoBkgr_mask_proj = autoBkgr_masks dataPrepBkgrROI_mask = measurements.get_bkgrROI_mask(posData, isSegm3D) - + calc_metrics_addtional_args = ( - autoBkgr_mask, - autoBkgr_mask_proj, + autoBkgr_mask, + autoBkgr_mask_proj, dataPrepBkgrROI_mask, - manualBackgrRp + manualBackgrRp, ) - + return df, calc_metrics_addtional_args - + def _init_metrics(self, posData, isSegm3D): self.chNamesToSkip = [] loadedChannels = posData.setLoadedChannelNames(returnList=True) @@ -1598,26 +1584,25 @@ def _init_metrics(self, posData, isSegm3D): if isSegm3D: self.regionPropsToSave = measurements.get_props_names_3D() else: - self.regionPropsToSave = measurements.get_props_names() + self.regionPropsToSave = measurements.get_props_names() self.mixedChCombineMetricsToSkip = [] self.chIndipendCustomMetricsToSave = list( measurements.ch_indipend_custom_metrics_desc( - posData.SizeZ>1, isSegm3D=isSegm3D, + posData.SizeZ > 1, + isSegm3D=isSegm3D, ).keys() ) self.sizeMetricsToSave = list( - measurements.get_size_metrics_desc( - isSegm3D, posData.SizeT>1 - ).keys() + measurements.get_size_metrics_desc(isSegm3D, posData.SizeT > 1).keys() ) - + exp_path = posData.exp_path posFoldernames = myutils.get_pos_foldernames(exp_path) for pos in posFoldernames: - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") for file in myutils.listdir(images_path): - if not file.endswith('custom_combine_metrics.ini'): + if not file.endswith("custom_combine_metrics.ini"): continue filePath = os.path.join(images_path, file) configPars = load.read_config_metrics(filePath) @@ -1625,75 +1610,98 @@ def _init_metrics(self, posData, isSegm3D): posData.combineMetricsConfig = load.add_configPars_metrics( configPars, posData.combineMetricsConfig ) - + def _add_custom_metrics( - self, posData, frame_i, isSegm3D, df, rp, custom_metrics_params, - lab, calc_for_each_zslice_mapper - ): - iter_channels = zip( - posData.loadedChNames, - posData.fluo_data_dict.items() - ) + self, + posData, + frame_i, + isSegm3D, + df, + rp, + custom_metrics_params, + lab, + calc_for_each_zslice_mapper, + ): + iter_channels = zip(posData.loadedChNames, posData.fluo_data_dict.items()) # Add custom measurements for channel, (filename, channel_data) in iter_channels: if channel in self.chNamesToSkip: - continue - + continue + foregr_img = channel_data[frame_i] - + iter_other_channels = zip( - posData.loadedChNames, - posData.fluo_data_dict.items() + posData.loadedChNames, posData.fluo_data_dict.items() ) other_channels_foregr_imgs = { - ch:ch_data[frame_i] for ch, (_, ch_data) in iter_other_channels + ch: ch_data[frame_i] + for ch, (_, ch_data) in iter_other_channels if ch != channel } - + # Get the z-slice if we have z-stacks z = posData.zSliceSegmentation(filename, frame_i) - + foregr_data = measurements.get_foregr_data(foregr_img, isSegm3D, z) - + df = measurements.add_custom_metrics( - df, rp, channel, foregr_data, - custom_metrics_params[channel], - isSegm3D, lab, foregr_img, + df, + rp, + channel, + foregr_data, + custom_metrics_params[channel], + isSegm3D, + lab, + foregr_img, other_channels_foregr_imgs, z_slice=z, customMetricsCritical=self.customMetricsCritical, ) - + if not calc_for_each_zslice_mapper.get(channel, False): continue - + # Repeat measureemnts for each z-slice pbar_z = tqdm( - total=posData.SizeZ, desc='Computing for z-slices: ', - ncols=100, leave=False, unit='z-slice' + total=posData.SizeZ, + desc="Computing for z-slices: ", + ncols=100, + leave=False, + unit="z-slice", ) for z in range(posData.SizeZ): - foregr_data = measurements.get_foregr_data( - foregr_img, isSegm3D, z - ) - foregr_data = {'zSlice': foregr_data['zSlice']} - + foregr_data = measurements.get_foregr_data(foregr_img, isSegm3D, z) + foregr_data = {"zSlice": foregr_data["zSlice"]} + df = measurements.add_custom_metrics( - df, rp, channel, foregr_data, - custom_metrics_params[channel], - isSegm3D, lab, foregr_img, + df, + rp, + channel, + foregr_data, + custom_metrics_params[channel], + isSegm3D, + lab, + foregr_img, other_channels_foregr_imgs, z_slice=z, text_to_append_to_col=str(z), - customMetricsCritical=self.customMetricsCritical, + customMetricsCritical=self.customMetricsCritical, ) - + return df - + def _calc_metrics_iter_channels( - self, acdc_df, rp, frame_i, lab, posData, autoBkgr_mask, - autoBkgr_mask_proj, dataPrepBkgrROI_mask, manualBackgrRp - ): + self, + acdc_df, + rp, + frame_i, + lab, + posData, + autoBkgr_mask, + autoBkgr_mask_proj, + dataPrepBkgrROI_mask, + manualBackgrRp, + ): all_channels_foregr_data = {} all_channels_foregr_imgs = {} all_channels_z_slices = {} @@ -1705,94 +1713,123 @@ def _calc_metrics_iter_channels( concentration_metrics_params = self.concentration_metrics_params regionprops_to_save = self.regionPropsToSave custom_metrics_params = self.custom_metrics_params - ch_indipend_custom_func_params = ( - self.ch_indipend_custom_func_params - ) + ch_indipend_custom_func_params = self.ch_indipend_custom_func_params images_path = posData.images_path # Iterate channels - iter_channels = zip( - posData.loadedChNames, - posData.fluo_data_dict.items() - ) + iter_channels = zip(posData.loadedChNames, posData.fluo_data_dict.items()) for channel, (filename, channel_data) in iter_channels: if channel in self.chNamesToSkip: - continue + continue foregr_img = channel_data[frame_i] # Get the z-slice if we have z-stacks z = posData.zSliceSegmentation(filename, frame_i) - + # Get the background data bkgr_data = measurements.get_bkgr_data( - foregr_img, posData, filename, frame_i, autoBkgr_mask, z, - autoBkgr_mask_proj, dataPrepBkgrROI_mask, isSegm3D, lab + foregr_img, + posData, + filename, + frame_i, + autoBkgr_mask, + z, + autoBkgr_mask_proj, + dataPrepBkgrROI_mask, + isSegm3D, + lab, ) - + foregr_data = measurements.get_foregr_data(foregr_img, isSegm3D, z) - + all_channels_foregr_data[channel] = foregr_data all_channels_foregr_imgs[channel] = foregr_img all_channels_z_slices[channel] = z # Compute background values acdc_df = measurements.add_bkgr_values( - acdc_df, bkgr_data, bkgr_metrics_params[channel], metrics_func, - manualBackgrRp=manualBackgrRp, foregr_data=foregr_data + acdc_df, + bkgr_data, + bkgr_metrics_params[channel], + metrics_func, + manualBackgrRp=manualBackgrRp, + foregr_data=foregr_data, ) # Iterate objects and compute foreground metrics acdc_df = measurements.add_foregr_standard_metrics( - acdc_df, rp, channel, foregr_data, - foregr_metrics_params[channel], - metrics_func, isSegm3D, - lab, foregr_img, + acdc_df, + rp, + channel, + foregr_data, + foregr_metrics_params[channel], + metrics_func, + isSegm3D, + lab, + foregr_img, manualBackgrRp=manualBackgrRp, - z_slice=z + z_slice=z, ) if not calc_for_each_zslice_mapper.get(channel, False): continue - + # Repeat measureemnts for each z-slice pbar_z = tqdm( - total=posData.SizeZ, desc='Computing for z-slices: ', - ncols=100, leave=False, unit='z-slice' + total=posData.SizeZ, + desc="Computing for z-slices: ", + ncols=100, + leave=False, + unit="z-slice", ) for z in range(posData.SizeZ): # Get the background data bkgr_data = measurements.get_bkgr_data( - foregr_img, posData, filename, frame_i, autoBkgr_mask, z, - autoBkgr_mask_proj, dataPrepBkgrROI_mask, isSegm3D, lab + foregr_img, + posData, + filename, + frame_i, + autoBkgr_mask, + z, + autoBkgr_mask_proj, + dataPrepBkgrROI_mask, + isSegm3D, + lab, ) bkgr_data = { - 'autoBkgr': {'zSlice': bkgr_data['autoBkgr']['zSlice']}, - 'dataPrepBkgr': {'zSlice': bkgr_data['dataPrepBkgr']['zSlice']} + "autoBkgr": {"zSlice": bkgr_data["autoBkgr"]["zSlice"]}, + "dataPrepBkgr": {"zSlice": bkgr_data["dataPrepBkgr"]["zSlice"]}, } - - foregr_data = measurements.get_foregr_data( - foregr_img, isSegm3D, z - ) - foregr_data = {'zSlice': foregr_data['zSlice']} + + foregr_data = measurements.get_foregr_data(foregr_img, isSegm3D, z) + foregr_data = {"zSlice": foregr_data["zSlice"]} # Compute background values acdc_df = measurements.add_bkgr_values( - acdc_df, bkgr_data, bkgr_metrics_params[channel], + acdc_df, + bkgr_data, + bkgr_metrics_params[channel], metrics_func, - manualBackgrRp=manualBackgrRp, + manualBackgrRp=manualBackgrRp, foregr_data=foregr_data, - text_to_append_to_col=str(z) + text_to_append_to_col=str(z), ) # Iterate objects and compute foreground metrics acdc_df = measurements.add_foregr_standard_metrics( - acdc_df, rp, channel, foregr_data, - foregr_metrics_params[channel], - metrics_func, isSegm3D, - lab, foregr_img, + acdc_df, + rp, + channel, + foregr_data, + foregr_metrics_params[channel], + metrics_func, + isSegm3D, + lab, + foregr_img, manualBackgrRp=manualBackgrRp, - z_slice=z, text_to_append_to_col=str(z) + z_slice=z, + text_to_append_to_col=str(z), ) pbar_z.update() pbar_z.close() @@ -1800,77 +1837,81 @@ def _calc_metrics_iter_channels( acdc_df = measurements.add_concentration_metrics( acdc_df, concentration_metrics_params ) - + # Add region properties try: acdc_df, rp_errors = measurements.add_regionprops_metrics( - acdc_df, lab, regionprops_to_save, - logger_func=self.logger.exception + acdc_df, lab, regionprops_to_save, logger_func=self.logger.exception ) if rp_errors: - print('\n') + print("\n") err_message = ( - 'WARNING: Some objects had the following errors:\n' - f'{rp_errors}\n' - 'Region properties with errors were saved as `Not A Number`.' + "WARNING: Some objects had the following errors:\n" + f"{rp_errors}\n" + "Region properties with errors were saved as `Not A Number`." ) self.logger.exception(err_message) - err_txt = 'Morphological properties error' + err_txt = "Morphological properties error" self.regionPropsCritical.emit(err_message, err_txt) except Exception as error: traceback_format = traceback.format_exc() self.regionPropsCritical.emit(traceback_format, str(error)) acdc_df = self._add_custom_metrics( - posData, frame_i, isSegm3D, acdc_df, rp, custom_metrics_params, - lab, calc_for_each_zslice_mapper + posData, + frame_i, + isSegm3D, + acdc_df, + rp, + custom_metrics_params, + lab, + calc_for_each_zslice_mapper, ) - + acdc_df = measurements.add_ch_indipend_custom_metrics( - acdc_df, rp, all_channels_foregr_data, - ch_indipend_custom_func_params, - isSegm3D, lab, all_channels_foregr_imgs, + acdc_df, + rp, + all_channels_foregr_data, + ch_indipend_custom_func_params, + isSegm3D, + lab, + all_channels_foregr_imgs, all_channels_z_slices=all_channels_z_slices, - customMetricsCritical=self.customMetricsCritical, + customMetricsCritical=self.customMetricsCritical, ) - + # Remove 0s columns acdc_df = acdc_df.loc[:, (acdc_df != -2).any(axis=0)] return acdc_df - + def _add_velocity_measurement(self, acdc_df, prev_lab, lab, posData): - if 'velocity_pixel' not in self.sizeMetricsToSave: + if "velocity_pixel" not in self.sizeMetricsToSave: return acdc_df - - if 'velocity_um' not in self.sizeMetricsToSave: - spacing = None + + if "velocity_um" not in self.sizeMetricsToSave: + spacing = None elif self.isSegm3D: - spacing = np.array([ - posData.PhysicalSizeZ, - posData.PhysicalSizeY, - posData.PhysicalSizeX - ]) + spacing = np.array( + [posData.PhysicalSizeZ, posData.PhysicalSizeY, posData.PhysicalSizeX] + ) else: - spacing = np.array([ - posData.PhysicalSizeY, - posData.PhysicalSizeX - ]) + spacing = np.array([posData.PhysicalSizeY, posData.PhysicalSizeX]) velocities_pxl, velocities_um = core.compute_twoframes_velocity( prev_lab, lab, spacing=spacing ) - acdc_df['velocity_pixel'] = velocities_pxl - acdc_df['velocity_um'] = velocities_um + acdc_df["velocity_pixel"] = velocities_pxl + acdc_df["velocity_um"] = velocities_um return acdc_df - + def _add_combined_metrics(self, posData, df, saveDataWorker=None): - # Add channel specifc combined metrics (from equations and + # Add channel specifc combined metrics (from equations and # from user_path_equations sections) config = posData.combineMetricsConfig for chName in posData.loadedChNames: metricsToSkipChannel = self.metricsToSkip.get(chName, []) - posDataEquations = config['equations'] - userPathChEquations = config['user_path_equations'] + posDataEquations = config["equations"] + userPathChEquations = config["user_path_equations"] for newColName, equation in posDataEquations.items(): if not newColName.startswith(chName): continue @@ -1889,16 +1930,16 @@ def _add_combined_metrics(self, posData, df, saveDataWorker=None): ) # Add mixed channels combined metrics - mixedChannelsEquations = config['mixed_channels_equations'] + mixedChannelsEquations = config["mixed_channels_equations"] for newColName, equation in mixedChannelsEquations.items(): if newColName in self.mixedChCombineMetricsToSkip: continue - cols = re.findall(r'[A-Za-z0-9]+_[A-Za-z0-9_]+', equation) + cols = re.findall(r"[A-Za-z0-9]+_[A-Za-z0-9_]+", equation) if all([col in df.columns for col in cols]): self._df_eval_equation( df, newColName, equation, saveDataWorker=saveDataWorker ) - + def _df_eval_equation(self, df, newColName, expr, saveDataWorker=None): try: df[newColName] = df.eval(expr) @@ -1907,7 +1948,7 @@ def _df_eval_equation(self, df, newColName, expr, saveDataWorker=None): saveDataWorker.sigCombinedMetricsMissingColumn.emit( str(error), newColName ) - + try: df[newColName] = df.eval(expr) except Exception as error: @@ -1915,77 +1956,71 @@ def _df_eval_equation(self, df, newColName, expr, saveDataWorker=None): saveDataWorker.customMetricsCritical.emit( traceback.format_exc(), newColName ) - + def _add_additional_metadata( - self, posData: load.loadData, df: pd.DataFrame, saved_segm_data - ): + self, posData: load.loadData, df: pd.DataFrame, saved_segm_data + ): for col, val in posData.additionalMetadataValues().items(): if col in df.columns: df.pop(col) df.insert(0, col, val) - + try: - df.pop('time_minutes') + df.pop("time_minutes") except Exception as e: pass try: - df.pop('time_hours') + df.pop("time_hours") except Exception as e: pass try: - time_seconds = df.index.get_level_values('time_seconds') - df.insert(0, 'time_minutes', time_seconds/60) - df.insert(1, 'time_hours', time_seconds/3600) + time_seconds = df.index.get_level_values("time_seconds") + df.insert(0, "time_minutes", time_seconds / 60) + df.insert(1, "time_hours", time_seconds / 3600) except Exception as e: pass - + df = self._add_disappears_before_end(df, saved_segm_data) return df - - def _add_disappears_before_end( - self, acdc_df: pd.DataFrame, saved_segm_data - ): - acdc_df = acdc_df.drop('time_seconds', axis=1, errors='ignore') - acdc_df = ( - acdc_df.reset_index() - .set_index(['frame_i', 'Cell_ID']) - .sort_index() - ) - acdc_df['disappears_before_end'] = 0 + + def _add_disappears_before_end(self, acdc_df: pd.DataFrame, saved_segm_data): + acdc_df = acdc_df.drop("time_seconds", axis=1, errors="ignore") + acdc_df = acdc_df.reset_index().set_index(["frame_i", "Cell_ID"]).sort_index() + acdc_df["disappears_before_end"] = 0 for frame_i, lab in enumerate(saved_segm_data): if frame_i == 0: continue - + try: df_frame = acdc_df.loc[frame_i] except KeyError: break - - prev_lab = saved_segm_data[frame_i-1] + + prev_lab = saved_segm_data[frame_i - 1] prev_rp = skimage.measure.regionprops(prev_lab) - + curr_rp = skimage.measure.regionprops(lab) curr_rp_mapper = {obj.label: obj for obj in curr_rp} lost_IDs = [] for prev_obj in prev_rp: if curr_rp_mapper.get(prev_obj.label) is None: lost_IDs.append(prev_obj.label) - - if 'parent_ID_tree' in df_frame.columns: - parent_IDs = set(df_frame['parent_ID_tree'].values) + + if "parent_ID_tree" in df_frame.columns: + parent_IDs = set(df_frame["parent_ID_tree"].values) lost_IDs = [ID for ID in lost_IDs if ID not in parent_IDs] - + if not lost_IDs: continue - - idx = pd.IndexSlice[frame_i-1, lost_IDs] + + idx = pd.IndexSlice[frame_i - 1, lost_IDs] try: - acdc_df.loc[idx, 'disappears_before_end'] = 1 + acdc_df.loc[idx, "disappears_before_end"] = 1 except Exception as err: printl(frame_i, lost_IDs) - + return acdc_df - + def _add_derived_cell_cycle_columns(self, all_frames_acdc_df): try: all_frames_acdc_df = cca_functions.add_derived_cell_cycle_columns( @@ -1993,5 +2028,5 @@ def _add_derived_cell_cycle_columns(self, all_frames_acdc_df): ) except Exception as err: self.sigLog.emit(traceback.format_exc()) - + return all_frames_acdc_df diff --git a/cellacdc/colors.py b/cellacdc/colors.py index 0c71ef0e7..69aabf2ad 100644 --- a/cellacdc/colors.py +++ b/cellacdc/colors.py @@ -19,27 +19,30 @@ try: import networkx as nx + NETWORKX_INSTALLED = True except: NETWORKX_INSTALLED = False -__all__ = ['ColorMap'] +__all__ = ["ColorMap"] FLUO_CHANNELS_COLORS = { - 'mCardinal': (255, 0, 255), - 'mNeonGreen': (0, 255, 0), - 'NeonGreen': (0, 255, 0), - 'mNG': (0, 255, 0), - 'mScarlet': (255, 0, 255), - 'mScarlet-I3': (255, 0, 255), - 'mKate': (255, 0, 255), - 'mKate2': (255, 0, 255), - 'GFP': (0, 255, 0), - 'EGFP': (0, 255, 0), - 'mCitrine': (255, 255, 0) + "mCardinal": (255, 0, 255), + "mNeonGreen": (0, 255, 0), + "NeonGreen": (0, 255, 0), + "mNG": (0, 255, 0), + "mScarlet": (255, 0, 255), + "mScarlet-I3": (255, 0, 255), + "mKate": (255, 0, 255), + "mKate2": (255, 0, 255), + "GFP": (0, 255, 0), + "EGFP": (0, 255, 0), + "mCitrine": (255, 255, 0), } _mapCache = {} + + def getFromMatplotlib(name): """ Added to pyqtgraph 0.12 copied/pasted here to allow pyqtgraph <0.12. Link: @@ -55,45 +58,49 @@ def getFromMatplotlib(name): return None cm = None col_map = plt.get_cmap(name) - if hasattr(col_map, '_segmentdata'): # handle LinearSegmentedColormap + if hasattr(col_map, "_segmentdata"): # handle LinearSegmentedColormap data = col_map._segmentdata - if ('red' in data) and isinstance(data['red'], (Sequence, np.ndarray)): - positions = set() # super-set of handle positions in individual channels - for key in ['red','green','blue']: + if ("red" in data) and isinstance(data["red"], (Sequence, np.ndarray)): + positions = set() # super-set of handle positions in individual channels + for key in ["red", "green", "blue"]: for tup in data[key]: positions.add(tup[0]) - col_data = np.zeros((len(positions),4 )) - col_data[:,-1] = sorted(positions) - for idx, key in enumerate(['red','green','blue']): - positions = np.zeros( len(data[key] ) ) - comp_vals = np.zeros( len(data[key] ) ) - for idx2, tup in enumerate( data[key] ): + col_data = np.zeros((len(positions), 4)) + col_data[:, -1] = sorted(positions) + for idx, key in enumerate(["red", "green", "blue"]): + positions = np.zeros(len(data[key])) + comp_vals = np.zeros(len(data[key])) + for idx2, tup in enumerate(data[key]): positions[idx2] = tup[0] - comp_vals[idx2] = tup[1] # these are sorted in the raw data - col_data[:,idx] = np.interp(col_data[:,3], positions, comp_vals) - cm = ColorMap(pos=col_data[:,-1], color=255*col_data[:,:3]+0.5) + comp_vals[idx2] = tup[1] # these are sorted in the raw data + col_data[:, idx] = np.interp(col_data[:, 3], positions, comp_vals) + cm = ColorMap(pos=col_data[:, -1], color=255 * col_data[:, :3] + 0.5) # some color maps (gnuplot in particular) are defined by RGB component functions: - elif ('red' in data) and isinstance(data['red'], Callable): + elif ("red" in data) and isinstance(data["red"], Callable): col_data = np.zeros((64, 4)) - col_data[:,-1] = np.linspace(0., 1., 64) - for idx, key in enumerate(['red','green','blue']): - col_data[:,idx] = np.clip( data[key](col_data[:,-1]), 0, 1) - cm = ColorMap(pos=col_data[:,-1], color=255*col_data[:,:3]+0.5) - elif hasattr(col_map, 'colors'): # handle ListedColormap + col_data[:, -1] = np.linspace(0.0, 1.0, 64) + for idx, key in enumerate(["red", "green", "blue"]): + col_data[:, idx] = np.clip(data[key](col_data[:, -1]), 0, 1) + cm = ColorMap(pos=col_data[:, -1], color=255 * col_data[:, :3] + 0.5) + elif hasattr(col_map, "colors"): # handle ListedColormap col_data = np.array(col_map.colors) - cm = ColorMap(pos=np.linspace(0.0, 1.0, col_data.shape[0]), - color=255*col_data[:,:3]+0.5 ) + cm = ColorMap( + pos=np.linspace(0.0, 1.0, col_data.shape[0]), + color=255 * col_data[:, :3] + 0.5, + ) if cm is not None: cm.name = name _mapCache[name] = cm return cm + def get_pg_gradient(colors): - ticks_pos = np.linspace(0,1,len(colors)) + ticks_pos = np.linspace(0, 1, len(colors)) ticks = [(tick_pos, color) for tick_pos, color in zip(ticks_pos, colors)] - gradient = {'ticks': ticks, 'mode': 'rgb'} + gradient = {"ticks": ticks, "mode": "rgb"} return gradient + def lighten_color(color, amount=0.3, hex=True): """ Lightens the given color by multiplying (1-luminosity) by the given amount. @@ -109,31 +116,33 @@ def lighten_color(color, amount=0.3, hex=True): c = matplotlib.colors.cnames[color] except: c = color - + c = colorsys.rgb_to_hls(*matplotlib.colors.to_rgb(c)) lightened_c = colorsys.hls_to_rgb(c[0], 1 - amount * (1 - c[1]), c[2]) if hex: - lightened_c = tuple([int(round(v*255)) for v in lightened_c]) - lightened_c = '#%02x%02x%02x' % lightened_c + lightened_c = tuple([int(round(v * 255)) for v in lightened_c]) + lightened_c = "#%02x%02x%02x" % lightened_c return lightened_c -def rgb_str_to_values(rgbString, errorRgb=(255,255,255)): + +def rgb_str_to_values(rgbString, errorRgb=(255, 255, 255)): try: - r, g, b = re.findall(r'(\d+), (\d+), (\d+)', rgbString)[0] + r, g, b = re.findall(r"(\d+), (\d+), (\d+)", rgbString)[0] r, g, b = int(r), int(g), int(b) except TypeError: try: r, g, b = rgbString except Exception as e: - print('======================') + print("======================") traceback.print_exc() - print('======================') + print("======================") r, g, b = errorRgb return r, g, b -def rgba_str_to_values(rgbaString, errorRgb=(255,255,255,255)): + +def rgba_str_to_values(rgbaString, errorRgb=(255, 255, 255, 255)): try: - m = re.findall(r'(\d+), *(\d+), *(\d+),* *(\d+)*', rgbaString) + m = re.findall(r"(\d+), *(\d+), *(\d+),* *(\d+)*", rgbaString) r, g, b, a = m[0] if a: r, g, b, a = int(r), int(g), int(b), int(a) @@ -147,13 +156,15 @@ def rgba_str_to_values(rgbaString, errorRgb=(255,255,255,255)): r, g, b, a = errorRgb return r, g, b, a -def get_lut_from_colors(colors, name='mycmap', N=256, to_uint8=False): + +def get_lut_from_colors(colors, name="mycmap", N=256, to_uint8=False): cmap = LinearSegmentedColormap.from_list(name, colors, N=256) - lut = np.array([cmap(i)[:3] for i in np.linspace(0,1,256)]) + lut = np.array([cmap(i)[:3] for i in np.linspace(0, 1, 256)]) if to_uint8: - lut = (lut*255).astype(np.uint8) + lut = (lut * 255).astype(np.uint8) return lut + def plt_colormap_to_pg_lut(name: str, ncolors=256): cmap = plt.get_cmap(name) colors = [cmap(i) for i in np.linspace(0, 1, ncolors)] @@ -161,6 +172,7 @@ def plt_colormap_to_pg_lut(name: str, ncolors=256): lut = np.round(lut_float * 255).astype(np.uint8) return lut + def invertRGB(rgb_img, max_val=1.0): # see https://forum.image.sc/t/invert-rgb-image-without-changing-colors/33571 R = rgb_img[:, :, 0] @@ -169,42 +181,43 @@ def invertRGB(rgb_img, max_val=1.0): GB_mean = np.mean([G, B], axis=0) RB_mean = np.mean([R, B], axis=0) RG_mean = np.mean([R, G], axis=0) - rgb_img[:, :, 0] = max_val-GB_mean - rgb_img[:, :, 1] = max_val-RB_mean - rgb_img[:, :, 2] = max_val-RG_mean + rgb_img[:, :, 0] = max_val - GB_mean + rgb_img[:, :, 1] = max_val - RB_mean + rgb_img[:, :, 2] = max_val - RG_mean return rgb_img + def rescale_RGB(rgb_img, saturation_val=1.0): - rescaled_rgb = rgb_img-rgb_img.min() + rescaled_rgb = rgb_img - rgb_img.min() max_val = rescaled_rgb.max() - brightness = saturation_val/max_val - return rescaled_rgb*brightness - + brightness = saturation_val / max_val + return rescaled_rgb * brightness + -def get_greedy_lut(lab, lut, ids=None): +def get_greedy_lut(lab, lut, ids=None): if ids is None: ids = [obj.label for obj in skimage.measure.regionprops(lab)] - + if len(ids) == 0: return lut - + if len(ids) == 1: greedy_lut = np.copy(lut) greedy_lut[:] = greedy_lut[-1] - greedy_lut[0] = [0]*lut.shape[-1] + greedy_lut[0] = [0] * lut.shape[-1] return greedy_lut - + max_ID = max(ids, default=0) if max_ID + 1 > len(lut): # Repeat lut entries if not enough colors - lut = np.concatenate([lut]*((max_ID // len(lut))+1), axis=0) - + lut = np.concatenate([lut] * ((max_ID // len(lut)) + 1), axis=0) + if lab.ndim == 3: lab = lab.max(axis=0) - + expanded = skimage.segmentation.expand_labels(lab, distance=7) - adj_M = np.zeros([expanded.max() + 1]*2, dtype=bool) - + adj_M = np.zeros([expanded.max() + 1] * 2, dtype=bool) + # Taken from https://stackoverflow.com/questions/26486898/matrix-of-labels-to-adjacency-matrix adj_M[expanded[:, :-1], expanded[:, 1:]] = 1 adj_M[expanded[:, 1:], expanded[:, :-1]] = 1 @@ -216,15 +229,14 @@ def get_greedy_lut(lab, lut, ids=None): # adj_M = adj_M[1:, 1:] graph = nx.from_numpy_array(adj_M) - color_ids = nx.coloring.greedy_color( - graph, strategy='connected_sequential' - ) - - n_foregr_colors = len(lut)-1 + color_ids = nx.coloring.greedy_color(graph, strategy="connected_sequential") + + n_foregr_colors = len(lut) - 1 n_colors_greedy = max([color_id for color_id in color_ids.values()]) color_idxs = { - id:abs(int(n_foregr_colors * c/n_colors_greedy)-n_foregr_colors) - for id, c in color_ids.items() if id!=0 + id: abs(int(n_foregr_colors * c / n_colors_greedy) - n_foregr_colors) + for id, c in color_ids.items() + if id != 0 } greedy_lut = np.copy(lut) @@ -232,115 +244,129 @@ def get_greedy_lut(lab, lut, ids=None): return greedy_lut + def rgb_uint_to_html_hex(rgb): r, g, b = rgb - hex_color = f'#{r:02x}{g:02x}{b:02x}' + hex_color = f"#{r:02x}{g:02x}{b:02x}" return hex_color + def hex_to_rgb(hex): - if hex.startswith('#'): + if hex.startswith("#"): hex = hex[1:] - - return tuple(int(hex[i:i+2], 16) for i in (0, 2, 4)) + + return tuple(int(hex[i : i + 2], 16) for i in (0, 2, 4)) + def hierarchical_weights(alphas): alphas = np.array([1.0, *alphas]) if len(alphas) == 0: return alphas - + weights = [] - for i, a_ref in enumerate(alphas): - weight = np.prod(1-alphas[i+1:]) * a_ref + for i, a_ref in enumerate(alphas): + weight = np.prod(1 - alphas[i + 1 :]) * a_ref weights.append(weight) - + return weights[::-1] + def hierarchical_blend(images, weights): if len(images) == 1: return images[0] - + # Stack all images and do weighted sum stacked = np.stack(images, axis=0) # shape: (N, H, W) return np.tensordot(weights, stacked, axes=(0, 0)) + def merge_two_grayscale_imgs( - img1, img2, rgb1, rgb2, alpha=0.5, - brightness1=1.0, brightness2=1.0, dtype=np.uint8, - inverted=False - ): + img1, + img2, + rgb1, + rgb2, + alpha=0.5, + brightness1=1.0, + brightness2=1.0, + dtype=np.uint8, + inverted=False, +): if img1.max() > 1.0: img1 = skimage.exposure.rescale_intensity(img1, out_range=(0, 1.0)) - + if img2.max() > 1.0: img2 = skimage.exposure.rescale_intensity(img2, out_range=(0, 1.0)) - - img1_bright = np.clip(img1*brightness1, 0, 1.0) - img2_bright = np.clip(img2*brightness1, 0, 1.0) - - img1_rgb = (skimage.color.gray2rgb(img1_bright)*rgb1).astype(dtype) - img2_rgb = (skimage.color.gray2rgb(img2_bright)*rgb2).astype(dtype) - - merge = (alpha*img1_rgb + (1-alpha)*img2_rgb).astype(dtype) - + + img1_bright = np.clip(img1 * brightness1, 0, 1.0) + img2_bright = np.clip(img2 * brightness1, 0, 1.0) + + img1_rgb = (skimage.color.gray2rgb(img1_bright) * rgb1).astype(dtype) + img2_rgb = (skimage.color.gray2rgb(img2_bright) * rgb2).astype(dtype) + + merge = (alpha * img1_rgb + (1 - alpha) * img2_rgb).astype(dtype) + if inverted: merge_inverted = merge.copy() - merge_inverted[..., 0] = 255-((merge[..., 1]+merge[..., 2])/2) - merge_inverted[..., 1] = 255-((merge[..., 0]+merge[..., 2])/2) - merge_inverted[..., 2] = 255-((merge[..., 1]+merge[..., 0])/2) + merge_inverted[..., 0] = 255 - ((merge[..., 1] + merge[..., 2]) / 2) + merge_inverted[..., 1] = 255 - ((merge[..., 0] + merge[..., 2]) / 2) + merge_inverted[..., 2] = 255 - ((merge[..., 1] + merge[..., 0]) / 2) merge = merge_inverted return merge + def pg_ticks_to_colormap(ticks): positions = [] colors = [] for pos, color in ticks: positions.append(pos) colors.append(color) - + cmap = ColorMap(positions, colors) return cmap -def color_palette(name='Okabe_lto', **sns_color_palette_kwargs): + +def color_palette(name="Okabe_lto", **sns_color_palette_kwargs): """Create seaborn color palette (default or custom ones). Parameters ---------- name : str, optional Name of the color palette. Default 'Okabe_lto'. - + References ---------- https://thenode.biologists.com/data-visualization-with-flying-colors/research/ https://www.nature.com/articles/nmeth.1618 """ - if name == 'Okabe_lto': + if name == "Okabe_lto": colors = ( - '#0072B2', - '#F0E442', - '#009E73', - '#56B4E9', - '#E69F00', - '#000000', - '#CC79A7', - '#D55E00', + "#0072B2", + "#F0E442", + "#009E73", + "#56B4E9", + "#E69F00", + "#000000", + "#CC79A7", + "#D55E00", ) return sns.color_palette(colors) - elif name == 'Wong': + elif name == "Wong": colors = ( - (0, 0, 0), - (230, 159, 0), - (86, 180, 233), - (0, 158, 115), - (240, 228, 66), - (0, 114, 178), - (213, 94, 0), - (204, 121, 167), + (0, 0, 0), + (230, 159, 0), + (86, 180, 233), + (0, 158, 115), + (240, 228, 66), + (0, 114, 178), + (213, 94, 0), + (204, 121, 167), ) return sns.color_palette(colors) - + return sns.color_palette(**sns_color_palette_kwargs) + def grayscale_apply_lut(image, lut): """ Map a grayscale image to RGBA using a lookup table. @@ -359,11 +385,12 @@ def grayscale_apply_lut(image, lut): """ # Normalize image to [0, N-1] N = lut.shape[0] - img = np.clip(image, 0, 1) if image.dtype.kind == 'f' else image / 255.0 + img = np.clip(image, 0, 1) if image.dtype.kind == "f" else image / 255.0 indices = np.clip((img * (N - 1)).astype(int), 0, N - 1) rgba = lut[indices] return rgba + def get_complementary_color(rgba_str: str) -> str: r, g, b, a = rgba_str_to_values(rgba_str) - return f'rgba({255 - r}, {255 - g}, {255 - b}, {a})' \ No newline at end of file + return f"rgba({255 - r}, {255 - g}, {255 - b}, {a})" diff --git a/cellacdc/config.py b/cellacdc/config.py index da319ee89..cb09d747e 100755 --- a/cellacdc/config.py +++ b/cellacdc/config.py @@ -4,23 +4,25 @@ import os import json -from typing import get_type_hints +from typing import get_type_hints import re from . import printl, debug_true_filepath + class ConfigParser(configparser.ConfigParser): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.optionxform = str - + def __repr__(self) -> str: string = pprint.pformat( {section: dict(self[section]) for section in self.sections()} ) return string + from . import GUI_INSTALLED if GUI_INSTALLED: @@ -30,7 +32,7 @@ class QtWarningHandler(QObject): sigGeometryWarning = Signal(object) def _resizeWarningHandler(self, msg_type, msg_log_context, msg_string): - if msg_string.find('Unable to set geometry') != -1: + if msg_string.find("Unable to set geometry") != -1: try: self.sigGeometryWarning.emit(msg_string) except Exception as e: @@ -42,113 +44,114 @@ def _resizeWarningHandler(self, msg_type, msg_log_context, msg_string): qInstallMessageHandler(warningHandler._resizeWarningHandler) help_text = ( - 'Welcome to Cell-ACDC!\n\n' - 'You can run Cell-ACDC both as a GUI or in the command line.\n' - 'To run the GUI type `acdc`. To run the command line type `acdc -p `.\n' - 'The `` must be a workflow INI file.\n' - 'If you do not have one, use the GUI to set up the parameters.\n\n' - 'Enjoy!' + "Welcome to Cell-ACDC!\n\n" + "You can run Cell-ACDC both as a GUI or in the command line.\n" + "To run the GUI type `acdc`. To run the command line type `acdc -p `.\n" + "The `` must be a workflow INI file.\n" + "If you do not have one, use the GUI to set up the parameters.\n\n" + "Enjoy!" ) try: ap = argparse.ArgumentParser( - prog='acdc', description=help_text, - formatter_class=argparse.RawTextHelpFormatter + prog="acdc", + description=help_text, + formatter_class=argparse.RawTextHelpFormatter, ) - + ap.add_argument( - '-p', '--params', - default='', + "-p", + "--params", + default="", type=str, - metavar='PATH_TO_PARAMS', - help=('Path of the ".ini" workflow file') - ) - - ap.add_argument( - '-v', '--version', action='store_true', - help=( - 'Get information about Cell-ACDC version and environment' - ) + metavar="PATH_TO_PARAMS", + help=('Path of the ".ini" workflow file'), ) - + ap.add_argument( - '--reset', action='store_true', - help=( - 'Reset Cell-ACDC settings' - ) + "-v", + "--version", + action="store_true", + help=("Get information about Cell-ACDC version and environment"), ) - + + ap.add_argument("--reset", action="store_true", help=("Reset Cell-ACDC settings")) + ap.add_argument( - '-info', '--info', action='store_true', - help=( - 'Get information about Cell-ACDC version and environment' - ) + "-info", + "--info", + action="store_true", + help=("Get information about Cell-ACDC version and environment"), ) - + ap.add_argument( - '-y', '--yes', action='store_true', + "-y", + "--yes", + action="store_true", help=( 'Sets confirmation values to "yes" automatically. Users will ' - 'not be prompted for confirmation when installing Cell-ACDC for the first time.' - ) + "not be prompted for confirmation when installing Cell-ACDC for the first time." + ), ) - + ap.add_argument( - '-d', '--debug', action='store_true', + "-d", + "--debug", + action="store_true", help=( - 'Used for debugging. Test code with' + "Used for debugging. Test code with" '"from cellacdc.config import parser_args, debug = parser_args["debug"]", ' - 'if debug: ' - ) + "if debug: " + ), ) ap.add_argument( - '--install_details', - default='', + "--install_details", + default="", type=str, - metavar='PATH_TO_INSTALL_DETAILS', - help=('Path of the "install_details.json" file') + metavar="PATH_TO_INSTALL_DETAILS", + help=('Path of the "install_details.json" file'), ) - + ap.add_argument( - '--cpModelsDownload', - action='store_true', - help=('Whether to download cellpose models'), + "--cpModelsDownload", + action="store_true", + help=("Whether to download cellpose models"), # metavar='CP_MODELS_DOWNLOAD_FLAG' ) ap.add_argument( - '--YeaZModelsDownload', - action='store_true', - help=('Whether to download YeaZ models'), + "--YeaZModelsDownload", + action="store_true", + help=("Whether to download YeaZ models"), # metavar='YEAZ_MODELS_DOWNLOAD_FLAG' ) ap.add_argument( - '--DeepSeaModelsDownload', - action='store_true', - help=('Whether to download DeepSea models'), + "--DeepSeaModelsDownload", + action="store_true", + help=("Whether to download DeepSea models"), # metavar='DEEPSEA_MODELS_DOWNLOAD_FLAG' ) ap.add_argument( - '--StarDistModelsDownload', - action='store_true', - help=('Whether to download StarDist models'), + "--StarDistModelsDownload", + action="store_true", + help=("Whether to download StarDist models"), # metavar='STARDIST_MODELS_DOWNLOAD_FLAG' ) ap.add_argument( - '--TrackastraModelsDownload', - action='store_true', - help=('Whether to download Trackastra models'), + "--TrackastraModelsDownload", + action="store_true", + help=("Whether to download Trackastra models"), # metavar='TRACKASTRA_MODELS_DOWNLOAD_FLAG' ) - + ap.add_argument( - '--AllModelsDownload', - action='store_true', + "--AllModelsDownload", + action="store_true", help=( - 'Whether to download models for Cellpose, YeaZ, DeepSea, StarDist, Trackastra.' + "Whether to download models for Cellpose, YeaZ, DeepSea, StarDist, Trackastra." ), ) @@ -158,106 +161,121 @@ def _resizeWarningHandler(self, msg_type, msg_log_context, msg_string): parser_args, unknown = ap.parse_known_args() parser_args = vars(parser_args) if os.path.exists(debug_true_filepath): - parser_args['debug'] = True - - install_details = parser_args.get('install_details') - if install_details and install_details != '': + parser_args["debug"] = True + + install_details = parser_args.get("install_details") + if install_details and install_details != "": try: - with open(parser_args['install_details'], 'r') as f: + with open(parser_args["install_details"], "r") as f: install_details = json.load(f) - for pathlike in ['conda_path', 'clone_path', 'venv_path', 'target_dir',]: + for pathlike in [ + "conda_path", + "clone_path", + "venv_path", + "target_dir", + ]: if pathlike in install_details: - install_details[pathlike] = f'"{os.path.abspath(install_details[pathlike])}"' - parser_args['install_details'] = install_details + install_details[pathlike] = ( + f'"{os.path.abspath(install_details[pathlike])}"' + ) + parser_args["install_details"] = install_details except Exception as e: printl( - 'Error reading install details from file: ' - f'{parser_args["install_details"]}. Error: {e}' + "Error reading install details from file: " + f"{parser_args['install_details']}. Error: {e}" ) - parser_args['install_details'] = {} - - + parser_args["install_details"] = {} + + except Exception as err: - import pdb; pdb.set_trace() - print('Importing from notebook, ignoring Cell-ACDC argument parser...') + import pdb + + pdb.set_trace() + print("Importing from notebook, ignoring Cell-ACDC argument parser...") parser_args = {} - parser_args['debug'] = False + parser_args["debug"] = False + def preprocessing_mapper(): from cellacdc import preprocess, cellacdc_path, acdc_regex from inspect import getmembers, isfunction + functions = getmembers(preprocess, isfunction) - preprocess_py_path = os.path.join(cellacdc_path, 'preprocess.py') - with open(preprocess_py_path, 'r') as py_file: + preprocess_py_path = os.path.join(cellacdc_path, "preprocess.py") + with open(preprocess_py_path, "r") as py_file: text = py_file.read() valid_functions_names = acdc_regex.get_function_names(text) mapper = {} for func_name, func in functions: - if func_name.startswith('_'): + if func_name.startswith("_"): continue - - if func_name == 'dummy_filter' and not parser_args['debug']: + + if func_name == "dummy_filter" and not parser_args["debug"]: continue - + if func_name not in valid_functions_names: continue - - method = func_name.title().replace('_', ' ') + + method = func_name.title().replace("_", " ") mapper[method] = { - 'function': func, - 'docstring': func.__doc__, - 'function_name': func_name - } + "function": func, + "docstring": func.__doc__, + "function_name": func_name, + } return mapper + def preprocessing_init_func_mapper(): from cellacdc import preprocess, cellacdc_path, acdc_regex from inspect import getmembers, isfunction + functions = getmembers(preprocess, isfunction) - preprocess_py_path = os.path.join(cellacdc_path, 'preprocess.py') - with open(preprocess_py_path, 'r') as py_file: + preprocess_py_path = os.path.join(cellacdc_path, "preprocess.py") + with open(preprocess_py_path, "r") as py_file: text = py_file.read() valid_functions_names = acdc_regex.get_function_names(text) mapper = {} for func_name, func in functions: - if not func_name.startswith('_init_'): + if not func_name.startswith("_init_"): continue - - method = func_name.lstrip('_init_').title().replace('_', ' ') + + method = func_name.lstrip("_init_").title().replace("_", " ") mapper[method] = { - 'function': func, - 'docstring': func.__doc__, - 'function_name': func_name - } + "function": func, + "docstring": func.__doc__, + "function_name": func_name, + } return mapper + def preprocess_recipe_to_ini_items(preproc_recipe): if preproc_recipe is None: return {} - + ini_items = {} for s, step in enumerate(preproc_recipe): - section = f'preprocess.step{s+1}' + section = f"preprocess.step{s + 1}" ini_items[section] = {} - ini_items[section]['method'] = step['method'] - for option, value in step['kwargs'].items(): + ini_items[section]["method"] = step["method"] + for option, value in step["kwargs"].items(): ini_items[section][option] = str(value) return ini_items + def preprocess_ini_items_to_recipe(ini_items): recipe = {} - + for section, section_items in ini_items.items(): - if not section.startswith('preprocess.step'): + if not section.startswith("preprocess.step"): continue - - step_n = int(re.findall(r'step(\d+)', section)[0]) - recipe[step_n] = {'method': section_items['method']} + + step_n = int(re.findall(r"step(\d+)", section)[0]) + recipe[step_n] = {"method": section_items["method"]} kwargs = {} for option, value_str in section_items.items(): - if option == 'method': + if option == "method": continue - + value = value_str if isinstance(value_str, str): for _type in (int, float, str): @@ -266,17 +284,18 @@ def preprocess_ini_items_to_recipe(ini_items): break except Exception as e: continue - + kwargs[option] = value - - recipe[step_n]['kwargs'] = kwargs - + + recipe[step_n]["kwargs"] = kwargs + recipe = [value for key, value in sorted(recipe.items())] - + if not recipe: return - + return recipe + PREPROCESS_MAPPER = preprocessing_mapper() -PREPROCESS_INIT_MAPPER = preprocessing_init_func_mapper() \ No newline at end of file +PREPROCESS_INIT_MAPPER = preprocessing_init_func_mapper() diff --git a/cellacdc/core.py b/cellacdc/core.py index 7044d0144..6a1009a4d 100755 --- a/cellacdc/core.py +++ b/cellacdc/core.py @@ -3,9 +3,7 @@ from typing import List, Dict, Any, Iterable, Tuple, Callable, Union, Literal import os import time -from concurrent.futures import ( - ThreadPoolExecutor, ProcessPoolExecutor, as_completed -) +from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed from functools import partial from importlib import import_module import numpy as np @@ -51,40 +49,41 @@ from . import favourite_func_metrics_csv_path from . import default_index_cols -from ._types import ( - ChannelsDict -) +from ._types import ChannelsDict + def get_indices_dash_pattern(arr, line_length, gap): n = len(arr) - sampling_rate = (line_length+gap) + sampling_rate = line_length + gap n_lines = n // sampling_rate - tot_len = n_lines*sampling_rate - indices_2D = np.arange(tot_len).reshape((n_lines,sampling_rate)) + tot_len = n_lines * sampling_rate + indices_2D = np.arange(tot_len).reshape((n_lines, sampling_rate)) indices = (indices_2D[:, :line_length]).flatten() return indices + def get_line(r0, c0, r1, c1, dashed=True): x1, x2 = sorted((c0, c1)) - Dc = (c0-c1) - Dr = (r0-r1) - dist = np.ceil(np.sqrt(np.square(Dr)+np.square(Dc))) - + Dc = c0 - c1 + Dr = r0 - r1 + dist = np.ceil(np.sqrt(np.square(Dr) + np.square(Dc))) + if Dc == 0: - xx = np.array([c0]*int(dist)) + xx = np.array([c0] * int(dist)) y1, y2 = sorted((r0, r1)) yy = np.linspace(y1, y2, len(xx)) else: xx = np.linspace(x1, x2, int(dist)) - m = Dr/Dc - q = (c0*r1 - c1*r0)/Dc - yy = xx*m+q + m = Dr / Dc + q = (c0 * r1 - c1 * r0) / Dc + yy = xx * m + q if dashed: indices = get_indices_dash_pattern(xx, 4, 3) xx = xx[indices] yy = yy[indices] return xx, yy + def np_replace_values(arr, old_values, new_values): # See method_jdehesa https://stackoverflow.com/questions/45735230/how-to-replace-a-list-of-values-in-a-numpy-array old_values = np.asarray(old_values) @@ -97,6 +96,7 @@ def np_replace_values(arr, old_values, new_values): arr = replacer[arr - n_min] return arr + def nearest_nonzero_2D(a, y, x, max_dist=None, return_coords=False): value = a[round(y), round(x)] if value > 0: @@ -105,7 +105,7 @@ def nearest_nonzero_2D(a, y, x, max_dist=None, return_coords=False): else: return value r, c = np.nonzero(a) - dist = ((r - y)**2 + (c - x)**2) + dist = (r - y) ** 2 + (c - x) ** 2 if dist.size == 0: if return_coords: return 0, 0, 0 @@ -124,23 +124,25 @@ def nearest_nonzero_2D(a, y, x, max_dist=None, return_coords=False): else: return a[y_nearest, x_nearest] + def nearest_nonzero_1D(arr, x, return_index=False): if arr[x] > 0: if return_index: return arr[x], x else: return arr[x] - nonzero_idxs, = np.nonzero(arr) - dist = (nonzero_idxs - x)**2 + (nonzero_idxs,) = np.nonzero(arr) + dist = (nonzero_idxs - x) ** 2 min_idx = dist.argmin() nearest_nonzero_idx = nonzero_idxs[min_idx] val = arr[nearest_nonzero_idx] - + if return_index: return val, nearest_nonzero_idx else: return val + def nearest_nonzero_z_idx_from_z_centroid(obj, current_z=-1): zc = obj.local_centroid[0] z_obj_local = int(zc) @@ -148,20 +150,21 @@ def nearest_nonzero_z_idx_from_z_centroid(obj, current_z=-1): z_obj_global = z_obj_local + obj.bbox[0] if current_z == z_obj_global and is_obj_slice_not_empty: return current_z - - zslices_not_empty_arr = np.any(obj.image, axis=(1,2)).astype(np.uint8) + + zslices_not_empty_arr = np.any(obj.image, axis=(1, 2)).astype(np.uint8) _, nearest_nonzero_z_local = nearest_nonzero_1D( zslices_not_empty_arr, z_obj_local, return_index=True ) nearest_nonzero_z_global = nearest_nonzero_z_local + obj.bbox[0] return nearest_nonzero_z_global + def compute_twoframes_velocity(prev_lab, lab, spacing=None): prev_rp = skimage.measure.regionprops(prev_lab) rp = skimage.measure.regionprops(lab) prev_IDs = [obj.label for obj in prev_rp] - velocities_pxl = [0]*len(rp) - velocities_um = [0]*len(rp) + velocities_pxl = [0] * len(rp) + velocities_um = [0] * len(rp) for i, obj in enumerate(rp): if obj.label not in prev_IDs: continue @@ -171,17 +174,18 @@ def compute_twoframes_velocity(prev_lab, lab, spacing=None): v_pixel = np.linalg.norm(diff) velocities_pxl[i] = v_pixel if spacing is not None: - v_um = np.linalg.norm(diff*spacing) + v_um = np.linalg.norm(diff * spacing) velocities_um[i] = v_um return velocities_pxl, velocities_um + def nearest_points_objects(objs_arr: np.ndarray, other_obj: np.ndarray): """Find the nearest points between all objects in objs_arr and other_obj Parameters ---------- - objs_arr : (N, P, 2) np.ndarray of floats - Array with N pages (one for each object), P rows (number of points) + objs_arr : (N, P, 2) np.ndarray of floats + Array with N pages (one for each object), P rows (number of points) and 2 columns for y, x coordinates other_obj : (P1, 2) np.ndarray Array with P1 rows (number of points) and 2 columns for y, x coordinates @@ -189,20 +193,21 @@ def nearest_points_objects(objs_arr: np.ndarray, other_obj: np.ndarray): Returns ------- (N,) np.ndarray - Array with N elements where the ith element is the minimum distance + Array with N elements where the ith element is the minimum distance between object objs_arr[i] and other_obj - """ + """ # diff[l, k, i] = objs_arr[l][k] - other_obj[i] diff = objs_arr[:, :, np.newaxis] - other_obj - + # dist[l, i, j] = math.dist(objs_arr[l][i], other_obj[j]) dist = np.linalg.norm(diff, axis=3) - + # min_dist[l] = min_dist(objs_arr[l], other_obj) min_dist = np.nanmin(dist, axis=(1, 2)) - + return min_dist + def nearest_point_2Dyx(points, all_others): """ Given 2D array of [y, x] coordinates points and all_others return the @@ -223,6 +228,7 @@ def nearest_point_2Dyx(points, all_others): min_dist = np.min(dist) return min_dist, nearest_point + def lab_replace_values(lab, rp, oldIDs, newIDs, in_place=True): if not in_place: lab = lab.copy() @@ -240,15 +246,16 @@ def lab_replace_values(lab, rp, oldIDs, newIDs, in_place=True): lab[obj.slice][obj.image] = newIDs[idx] except OverflowError: # it should be uint32 already but sometimes it was not - lab = lab.astype(np.uint32) + lab = lab.astype(np.uint32) lab[obj.slice][obj.image] = newIDs[idx] return lab + def post_process_segm(labels, return_delIDs=False, **kwargs): - min_solidity = kwargs.get('min_solidity') - min_area = kwargs.get('min_area') - max_elongation = kwargs.get('max_elongation') - min_obj_no_zslices = kwargs.get('min_obj_no_zslices') + min_solidity = kwargs.get("min_solidity") + min_area = kwargs.get("min_area") + max_elongation = kwargs.get("max_elongation") + min_obj_no_zslices = kwargs.get("min_obj_no_zslices") if labels.ndim == 3: delIDs = set() if min_obj_no_zslices is not None: @@ -259,11 +266,10 @@ def post_process_segm(labels, return_delIDs=False, **kwargs): if obj_no_zslices < min_obj_no_zslices: labels[obj.slice][obj.image] = 0 delIDs.add(obj.label) - + for z, lab in enumerate(labels): _result = post_process_segm_lab2D( - lab, min_solidity, min_area, max_elongation, - return_delIDs=return_delIDs + lab, min_solidity, min_area, max_elongation, return_delIDs=return_delIDs ) if return_delIDs: lab, _delIDs = _result @@ -277,8 +283,7 @@ def post_process_segm(labels, return_delIDs=False, **kwargs): result = labels else: result = post_process_segm_lab2D( - labels, min_solidity, min_area, max_elongation, - return_delIDs=return_delIDs + labels, min_solidity, min_area, max_elongation, return_delIDs=return_delIDs ) if return_delIDs: @@ -288,10 +293,10 @@ def post_process_segm(labels, return_delIDs=False, **kwargs): labels = result return labels + def post_process_segm_lab2D( - lab, min_solidity=None, min_area=None, max_elongation=None, - return_delIDs=False - ): + lab, min_solidity=None, min_area=None, max_elongation=None, return_delIDs=False +): """ function to remove cells with areamax_elongation @@ -317,7 +322,7 @@ def post_process_segm_lab2D( if max_elongation is not None: # NOTE: single pixel horizontal or vertical lines minor_axis_length=0 minor_axis_length = max(1, obj.minor_axis_length) - elongation = obj.major_axis_length/minor_axis_length + elongation = obj.major_axis_length / minor_axis_length if elongation > max_elongation: lab[obj.slice][obj.image] = 0 if return_delIDs: @@ -328,6 +333,7 @@ def post_process_segm_lab2D( else: return lab + def connect_3Dlab_zboundaries(lab): connected_lab = np.zeros_like(lab) rp = skimage.measure.regionprops(lab) @@ -335,30 +341,32 @@ def connect_3Dlab_zboundaries(lab): if len(obj.image) == 1: lab[obj.slice][obj.image] = obj.label continue - + # Take the center non-zero z-area as reference object z_areas = [np.count_nonzero(z_img) for z_img in obj.image] nonzero_z_areas = [z_area for z_area in z_areas if z_area > 0] - nonzero_center_idx = int(len(nonzero_z_areas)/2) + nonzero_center_idx = int(len(nonzero_z_areas) / 2) nonzero_center_z_area = nonzero_z_areas[nonzero_center_idx] center_idx = z_areas.index(nonzero_center_z_area) max_obj_image = obj.image[center_idx] num_zslices = len(obj.image) - + for z in range(num_zslices): connected_lab[obj.slice][z][max_obj_image] = obj.label - + return connected_lab + def stack_2Dlab_to_3D(lab, SizeZ): return np.tile(lab, (SizeZ, 1, 1)) + def track_sub_cell_objects_third_segm_acdc_df( - track_parent_objs_segm_data, parent_objs_acdc_df - ): + track_parent_objs_segm_data, parent_objs_acdc_df +): if parent_objs_acdc_df is None: return - + keys = [] dfs = [] for frame_i, lab in enumerate(track_parent_objs_segm_data): @@ -370,24 +378,28 @@ def track_sub_cell_objects_third_segm_acdc_df( acdc_df_frame_i = parent_objs_acdc_df.loc[frame_i] cols = acdc_df_frame_i.columns.intersection(all_non_metrics_cols) acdc_df_frame_i = acdc_df_frame_i[cols] - + dfs.append(acdc_df_frame_i) keys.append(frame_i) - third_segm_acdc_df = pd.concat( - dfs, keys=keys, names=['frame_i', 'Cell_ID'] - ) + third_segm_acdc_df = pd.concat(dfs, keys=keys, names=["frame_i", "Cell_ID"]) return third_segm_acdc_df - + + def track_sub_cell_objects_acdc_df( - tracked_subobj_segm_data, subobj_acdc_df, all_old_sub_ids, - all_num_objects_per_cells, SizeT=None, sigProgress=None, - tracked_cells_segm_data=None, cells_acdc_df=None - ): + tracked_subobj_segm_data, + subobj_acdc_df, + all_old_sub_ids, + all_num_objects_per_cells, + SizeT=None, + sigProgress=None, + tracked_cells_segm_data=None, + cells_acdc_df=None, +): if SizeT == 1: tracked_subobj_segm_data = tracked_subobj_segm_data[np.newaxis] if tracked_cells_segm_data is not None: tracked_cells_segm_data = tracked_cells_segm_data[np.newaxis] - + if tracked_cells_segm_data is not None: acdc_df_list = [] sub_acdc_df_list = [] @@ -402,18 +414,16 @@ def track_sub_cell_objects_acdc_df( elif frame_i not in subobj_acdc_df.index.get_level_values(0): sub_acdc_df_frame_i = myutils.getBaseAcdcDf(rp_sub) else: - sub_acdc_df_frame_i = ( - subobj_acdc_df.loc[frame_i].rename(index=old_sub_ids) - ) - if 'relative_ID' in sub_acdc_df_frame_i.columns: - sub_acdc_df_frame_i['relative_ID'] = ( - sub_acdc_df_frame_i['relative_ID'].replace(old_sub_ids) - ) - + sub_acdc_df_frame_i = subobj_acdc_df.loc[frame_i].rename(index=old_sub_ids) + if "relative_ID" in sub_acdc_df_frame_i.columns: + sub_acdc_df_frame_i["relative_ID"] = sub_acdc_df_frame_i[ + "relative_ID" + ].replace(old_sub_ids) + cols = sub_acdc_df_frame_i.columns.intersection(all_non_metrics_cols) sub_acdc_df_list.append(sub_acdc_df_frame_i.loc[sub_ids, cols]) keys_sub.append(frame_i) - + if tracked_cells_segm_data is not None: num_objects_per_cells = all_num_objects_per_cells[frame_i] lab = tracked_cells_segm_data[frame_i] @@ -425,97 +435,98 @@ def track_sub_cell_objects_acdc_df( acdc_df_frame_i = myutils.getBaseAcdcDf(rp) else: acdc_df_frame_i = cells_acdc_df.loc[[frame_i]].copy() - + cols = acdc_df_frame_i.columns.intersection(all_non_metrics_cols) acdc_df_frame_i = acdc_df_frame_i[cols] - - acdc_df_frame_i['num_sub_cell_objs_per_cell'] = 0 - acdc_df_frame_i.loc[IDs_with_sub_obj, 'num_sub_cell_objs_per_cell'] = ([ + + acdc_df_frame_i["num_sub_cell_objs_per_cell"] = 0 + acdc_df_frame_i.loc[IDs_with_sub_obj, "num_sub_cell_objs_per_cell"] = [ num_objects_per_cells[id] for id in IDs_with_sub_obj - ]) + ] acdc_df_list.append(acdc_df_frame_i) keys_cells.append(frame_i) if sigProgress is not None: sigProgress.emit(1) - + sub_tracked_acdc_df = pd.concat( - sub_acdc_df_list, keys=keys_sub, names=['frame_i', 'Cell_ID'] + sub_acdc_df_list, keys=keys_sub, names=["frame_i", "Cell_ID"] ) - + tracked_acdc_df = None if tracked_cells_segm_data is not None: tracked_acdc_df = pd.concat( - acdc_df_list, keys=keys_cells, names=['frame_i', 'Cell_ID'] + acdc_df_list, keys=keys_cells, names=["frame_i", "Cell_ID"] ) - + return sub_tracked_acdc_df, tracked_acdc_df - + + def track_sub_cell_objects( - cells_segm_data, - subobj_segm_data, - IoAthresh, - how: Literal[ - 'delete_sub', 'delete_cells', 'delete_both', 'only_track' - ]='delete_sub', - SizeT: int | None =None, - sigProgress=None, - relabel_sub_obj_lab=False - ): - """Function used to track sub-cellular objects and assign the same ID of - the cell they belong to. - - For each sub-cellular object calculate the interesection over area with cells - --> get max IoA in case it is touching more than one cell + cells_segm_data, + subobj_segm_data, + IoAthresh, + how: Literal[ + "delete_sub", "delete_cells", "delete_both", "only_track" + ] = "delete_sub", + SizeT: int | None = None, + sigProgress=None, + relabel_sub_obj_lab=False, +): + """Function used to track sub-cellular objects and assign the same ID of + the cell they belong to. + + For each sub-cellular object calculate the interesection over area with cells + --> get max IoA in case it is touching more than one cell --> assign that cell if IoA >= IoA thresh Args: - cells_segm_data (ndarray): 2D, 3D or 4D array of `int` type cotaining + cells_segm_data (ndarray): 2D, 3D or 4D array of `int` type cotaining the cells segmentation masks. - subobj_segm_data (ndarray): 2D, 3D or 4D array of `int` type cotaining + subobj_segm_data (ndarray): 2D, 3D or 4D array of `int` type cotaining the sub-cellular segmentation masks (e.g., nuclei). - IoAthresh (float): Minimum percentage (0-1) of the sub-cellular object's + IoAthresh (float): Minimum percentage (0-1) of the sub-cellular object's area to assign it to a cell - how (str, optional): Strategy to take with untracked objects. - Options are 'delete_sub' to delete untracked sub-cellular objects, - 'delete_cells' to delete cells that do not have any sub-cellular - object assigned to it, 'delete_both', and 'only_track' to keep - untracked objects. Note that 'delete_sub' is actually not used - because we add tracked sub-objects to an array initialized with + how (str, optional): Strategy to take with untracked objects. + Options are 'delete_sub' to delete untracked sub-cellular objects, + 'delete_cells' to delete cells that do not have any sub-cellular + object assigned to it, 'delete_both', and 'only_track' to keep + untracked objects. Note that 'delete_sub' is actually not used + because we add tracked sub-objects to an array initialized with zeros. Defaults to 'delete_sub'. SizeT (int, optional): Number of frames. Pass `SizeT=1` for non-timelapse data. Defaults to None --> assume first dimension of segm data is SizeT. - sigProgress (qtpy.QtCore.Signal, optional): If provided it will emit - 1 for each complete frame. Used to update GUI progress bars. + sigProgress (qtpy.QtCore.Signal, optional): If provided it will emit + 1 for each complete frame. Used to update GUI progress bars. Defaults to None --> do not emit signal. - - relabel_sub_obj_lab (bool, optional): Re-label sub-cellular objects + + relabel_sub_obj_lab (bool, optional): Re-label sub-cellular objects segmentation labels before tracking them. - + Returns: - tuple: A tuple `(tracked_subobj_segm_data, tracked_cells_segm_data, - all_num_objects_per_cells, old_sub_ids)` where `tracked_subobj_segm_data` is the - segmentation mask of the sub-cellular objects with the same IDs of - the cells they belong to, `tracked_cells_segm_data` is the segmentation - masks of the cells that do have at least on sub-cellular object - (`None` if `how != 'delete_sub'`), `all_num_objects_per_cells` is - a list of dictionary (one per frame) where the dictionaries have + tuple: A tuple `(tracked_subobj_segm_data, tracked_cells_segm_data, + all_num_objects_per_cells, old_sub_ids)` where `tracked_subobj_segm_data` is the + segmentation mask of the sub-cellular objects with the same IDs of + the cells they belong to, `tracked_cells_segm_data` is the segmentation + masks of the cells that do have at least on sub-cellular object + (`None` if `how != 'delete_sub'`), `all_num_objects_per_cells` is + a list of dictionary (one per frame) where the dictionaries have cell IDs as keys and the number of sub-cellular objects per cell as values, and `all_old_sub_ids` is a list of dictionaries (one per frame) - where each dictionary has the new sub-cellular objects' ids as keys and + where each dictionary has the new sub-cellular objects' ids as keys and the old (replaced) ids. - """ + """ if SizeT == 1: cells_segm_data = cells_segm_data[np.newaxis] subobj_segm_data = subobj_segm_data[np.newaxis] tracked_cells_segm_data = None - tracked_subobj_segm_data = np.zeros_like(subobj_segm_data) + tracked_subobj_segm_data = np.zeros_like(subobj_segm_data) segm_data_zip = zip(cells_segm_data, subobj_segm_data) old_tracked_sub_obj_IDs = set() @@ -525,7 +536,7 @@ def track_sub_cell_objects( all_old_sub_ids = [{} for _ in range(len(cells_segm_data))] for frame_i, (lab, lab_sub) in enumerate(segm_data_zip): rp = skimage.measure.regionprops(lab) - num_objects_per_cells = {obj.label:0 for obj in rp} + num_objects_per_cells = {obj.label: 0 for obj in rp} if relabel_sub_obj_lab: lab_sub = skimage.measure.label(lab_sub) rp_sub = skimage.measure.regionprops(lab_sub) @@ -535,41 +546,39 @@ def track_sub_cell_objects( untracked_sub_objs_frame_i = set() for sub_obj in rp_sub: intersect_mask = lab[sub_obj.slice][sub_obj.image] - intersect_IDs, intersections = np.unique( - intersect_mask, return_counts=True - ) + intersect_IDs, intersections = np.unique(intersect_mask, return_counts=True) if intersect_IDs[0] == 0: intersect_IDs = intersect_IDs[1:] intersections = intersections[1:] - + if len(intersect_IDs) == 0: untracked_sub_objs_frame_i.add(sub_obj.label) continue - + argmax = intersections.argmax() intersect_ID = intersect_IDs[argmax] intersection = intersections[argmax] - - IoA = intersection/sub_obj.area + + IoA = intersection / sub_obj.area if IoA < IoAthresh: # Do not add untracked sub-obj untracked_sub_objs_frame_i.add(sub_obj.label) continue - + all_old_sub_ids[frame_i][sub_obj.label] = intersect_ID tracked_lab_sub[sub_obj.slice][sub_obj.image] = intersect_ID num_objects_per_cells[intersect_ID] += 1 old_tracked_sub_obj_IDs.add(sub_obj.label) cells_IDs_with_sub_obj.append(intersect_ID) tracked_sub_obj_original_IDs.append(sub_obj.label) - + all_num_objects_per_cells.append(num_objects_per_cells) all_cells_IDs_with_sub_obj.append(cells_IDs_with_sub_obj) - + if sigProgress is not None: sigProgress.emit(1) - - if how == 'delete_both' or how == 'delete_cells': + + if how == "delete_both" or how == "delete_cells": # Delete cells that do not have a sub-cellular object tracked_cells_segm_data = cells_segm_data.copy() for frame_i, lab in enumerate(tracked_cells_segm_data): @@ -580,11 +589,11 @@ def track_sub_cell_objects( if obj.label in cells_IDs_with_sub_obj: # Cell has sub-object do not delete continue - + tracked_lab[obj.slice][obj.image] = 0 - - if how == 'only_track' or how == 'delete_cells': - # Assign unique IDs to untracked sub-cellular objects and add them + + if how == "only_track" or how == "delete_cells": + # Assign unique IDs to untracked sub-cellular objects and add them # to all_old_sub_ids maxSubObjID = tracked_subobj_segm_data.max() + 1 for sub_obj_ID in np.unique(subobj_segm_data): @@ -594,9 +603,9 @@ def track_sub_cell_objects( if sub_obj_ID in old_tracked_sub_obj_IDs: # sub_obj_ID has already ben tracked continue - + tracked_subobj_segm_data[subobj_segm_data == sub_obj_ID] = maxSubObjID - + for frame_i, lab_sub in enumerate(subobj_segm_data): if sub_obj_ID not in lab_sub: continue @@ -605,59 +614,62 @@ def track_sub_cell_objects( if SizeT == 1: tracked_subobj_segm_data = tracked_subobj_segm_data[0] - if how == 'delete_both': + if how == "delete_both": tracked_cells_segm_data = tracked_cells_segm_data[0] - + return ( - tracked_subobj_segm_data, tracked_cells_segm_data, - all_num_objects_per_cells, all_old_sub_ids + tracked_subobj_segm_data, + tracked_cells_segm_data, + all_num_objects_per_cells, + all_old_sub_ids, ) + def _calc_airy_radius(wavelen, NA): - airy_radius_nm = (1.22 * wavelen)/(2*NA) - airy_radius_um = airy_radius_nm*1E-3 #convert nm to µm + airy_radius_nm = (1.22 * wavelen) / (2 * NA) + airy_radius_um = airy_radius_nm * 1e-3 # convert nm to µm return airy_radius_nm, airy_radius_um + def calc_resolution_limited_vol( - wavelen, NA, yx_resolution_multi, zyx_vox_dim, z_resolution_limit - ): + wavelen, NA, yx_resolution_multi, zyx_vox_dim, z_resolution_limit +): airy_radius_nm, airy_radius_um = _calc_airy_radius(wavelen, NA) - yx_resolution = airy_radius_um*yx_resolution_multi - zyx_resolution = np.asarray( - [z_resolution_limit, yx_resolution, yx_resolution] - ) - zyx_resolution_pxl = zyx_resolution/np.asarray(zyx_vox_dim) + yx_resolution = airy_radius_um * yx_resolution_multi + zyx_resolution = np.asarray([z_resolution_limit, yx_resolution, yx_resolution]) + zyx_resolution_pxl = zyx_resolution / np.asarray(zyx_vox_dim) return zyx_resolution, zyx_resolution_pxl, airy_radius_nm + def align_frames_3D(data, slices=None, user_shifts=None, sigPyqt=None): - registered_shifts = np.zeros((len(data),2), int) + registered_shifts = np.zeros((len(data), 2), int) data_aligned = np.copy(data) for frame_i, frame_V in enumerate(data): if frame_i == 0: # skip first frame - continue + continue if user_shifts is None: slice = slices[frame_i] curr_frame_img = frame_V[slice] - prev_frame_img = data_aligned[frame_i-1, slice] + prev_frame_img = data_aligned[frame_i - 1, slice] shifts = skimage.registration.phase_cross_correlation( prev_frame_img, curr_frame_img - )[0] + )[0] else: shifts = user_shifts[frame_i] - + shifts = shifts.astype(int) aligned_frame_V = np.copy(frame_V) - aligned_frame_V = np.roll(aligned_frame_V, tuple(shifts), axis=(1,2)) + aligned_frame_V = np.roll(aligned_frame_V, tuple(shifts), axis=(1, 2)) # Pad rolled sides with 0s y, x = shifts - if y>0: + if y > 0: aligned_frame_V[:, :y] = 0 - elif y<0: + elif y < 0: aligned_frame_V[:, y:] = 0 - if x>0: + if x > 0: aligned_frame_V[:, :, :x] = 0 - elif x<0: + elif x < 0: aligned_frame_V[:, :, x:] = 0 data_aligned[frame_i] = aligned_frame_V registered_shifts[frame_i] = shifts @@ -669,6 +681,7 @@ def align_frames_3D(data, slices=None, user_shifts=None, sigPyqt=None): # plt.show() return data_aligned, registered_shifts + def revert_alignment(saved_shifts, img_data, sigPyqt=None): shifts = -saved_shifts reverted_data = np.zeros_like(img_data) @@ -683,36 +696,36 @@ def revert_alignment(saved_shifts, img_data, sigPyqt=None): sigPyqt.emit(1) return reverted_data + def align_frames_2D( - data, slices=None, register=True, user_shifts=None, pbar=False, - sigPyqt=None - ): - registered_shifts = np.zeros((len(data),2), int) + data, slices=None, register=True, user_shifts=None, pbar=False, sigPyqt=None +): + registered_shifts = np.zeros((len(data), 2), int) data_aligned = np.copy(data) for frame_i, frame_V in enumerate(tqdm(data, ncols=100)): if frame_i == 0: # skip first frame - continue - + continue + curr_frame_img = frame_V - prev_frame_img = data_aligned[frame_i-1] #previously aligned frame, slice + prev_frame_img = data_aligned[frame_i - 1] # previously aligned frame, slice if user_shifts is None: shifts = skimage.registration.phase_cross_correlation( prev_frame_img, curr_frame_img - )[0] + )[0] else: shifts = user_shifts[frame_i] shifts = shifts.astype(int) aligned_frame_V = np.copy(frame_V) - aligned_frame_V = np.roll(aligned_frame_V, tuple(shifts), axis=(0,1)) + aligned_frame_V = np.roll(aligned_frame_V, tuple(shifts), axis=(0, 1)) y, x = shifts - if y>0: + if y > 0: aligned_frame_V[:y] = 0 - elif y<0: + elif y < 0: aligned_frame_V[y:] = 0 - if x>0: + if x > 0: aligned_frame_V[:, :x] = 0 - elif x<0: + elif x < 0: aligned_frame_V[:, x:] = 0 data_aligned[frame_i] = aligned_frame_V registered_shifts[frame_i] = shifts @@ -724,6 +737,7 @@ def align_frames_2D( # plt.show() return data_aligned, registered_shifts + def label_3d_segm(labels): """Label objects in 3D array that is the result of applying 2D segmentation model on each z-slice. @@ -747,38 +761,37 @@ def label_3d_segm(labels): return labels + def get_obj_contours( - obj=None, - obj_image=None, - obj_bbox=None, - all_external=False, - all=False, - only_longest_contour=True, - local=False, - ): + obj=None, + obj_image=None, + obj_bbox=None, + all_external=False, + all=False, + only_longest_contour=True, + local=False, +): if all: retrieveMode = cv2.RETR_CCOMP else: retrieveMode = cv2.RETR_EXTERNAL - + if obj_image is None: obj_image = obj.image - + obj_image = obj_image.astype(np.uint8) - + if obj_bbox is None and not local: obj_bbox = obj.bbox - - contours, _ = cv2.findContours( - obj_image, retrieveMode, cv2.CHAIN_APPROX_NONE - ) + + contours, _ = cv2.findContours(obj_image, retrieveMode, cv2.CHAIN_APPROX_NONE) if all or all_external: if local: return [np.squeeze(cont, axis=1) for cont in contours] else: min_y, min_x, _, _ = obj_bbox - return [np.squeeze(cont, axis=1)+[min_x, min_y] for cont in contours] - + return [np.squeeze(cont, axis=1) + [min_x, min_y] for cont in contours] + if len(contours) > 1 and only_longest_contour: contours_len = [len(c) for c in contours] max_len_idx = contours_len.index(max(contours_len)) @@ -792,25 +805,29 @@ def get_obj_contours( contour += [min_x, min_y] return contour + def smooth_contours(lab, radius=2): - sigma = 2*radius + 1 + sigma = 2 * radius + 1 smooth_lab = np.zeros_like(lab) for obj in skimage.measure.regionprops(lab): cont = get_obj_contours(obj) - x = cont[:,0] - y = cont[:,1] + x = cont[:, 0] + y = cont[:, 1] x = np.append(x, x[0:sigma]) y = np.append(y, y[0:sigma]) - x = np.round(skimage.filters.gaussian(x, sigma=sigma, - preserve_range=True)).astype(int) - y = np.round(skimage.filters.gaussian(y, sigma=sigma, - preserve_range=True)).astype(int) + x = np.round( + skimage.filters.gaussian(x, sigma=sigma, preserve_range=True) + ).astype(int) + y = np.round( + skimage.filters.gaussian(y, sigma=sigma, preserve_range=True) + ).astype(int) temp_mask = np.zeros(lab.shape, bool) temp_mask[y, x] = True temp_mask = scipy.ndimage.morphology.binary_fill_holes(temp_mask) smooth_lab[temp_mask] = obj.label return smooth_lab + def get_labels_to_IDs_mapper(tracked_labels): labels_to_IDs_mapper = {} uniqueID = 1 @@ -819,40 +836,38 @@ def get_labels_to_IDs_mapper(tracked_labels): if tracked_label in labels_to_IDs_mapper: # Cell existed in the past, ID already stored continue - - parent_label, _, sister_label = tracked_label.rpartition('_') + + parent_label, _, sister_label = tracked_label.rpartition("_") if not parent_label: # Single-cell that was not mapped yet ID = uniqueID uniqueID += 1 - elif sister_label == '0': + elif sister_label == "0": # Sister label == 0 --> keep mother ID - ID = labels_to_IDs_mapper[parent_label].split('_')[0] + ID = labels_to_IDs_mapper[parent_label].split("_")[0] elif ( - sister_label == '1' - and f'{parent_label}_0' not in tracked_frame_labels - ): + sister_label == "1" and f"{parent_label}_0" not in tracked_frame_labels + ): # Daughter cell without a sister --> keep mother ID - ID = labels_to_IDs_mapper[parent_label].split('_')[0] + ID = labels_to_IDs_mapper[parent_label].split("_")[0] else: # Sister label == 1 --> assign new ID ID = uniqueID uniqueID += 1 - labels_to_IDs_mapper[tracked_label] = f'{ID}_{frame_i}' + labels_to_IDs_mapper[tracked_label] = f"{ID}_{frame_i}" return labels_to_IDs_mapper + def annotate_lineage_tree_from_labels(tracked_labels, labels_to_IDs_mapper): - IDs_to_labels_mapper = { - ID:label for label, ID in labels_to_IDs_mapper.items() - } + IDs_to_labels_mapper = {ID: label for label, ID in labels_to_IDs_mapper.items()} cca_dfs = [] keys = [] pbar = tqdm(total=len(tracked_labels), ncols=100) for frame_i, tracked_frame_labels in enumerate(tracked_labels): keys.append(frame_i) IDs = [ - int(labels_to_IDs_mapper[label].split('_')[0]) + int(labels_to_IDs_mapper[label].split("_")[0]) for label in tracked_frame_labels ] if frame_i == 0: @@ -860,9 +875,9 @@ def annotate_lineage_tree_from_labels(tracked_labels, labels_to_IDs_mapper): cca_dfs.append(cca_df) pbar.update() continue - + # Get cca_df from previous frame for existing cells - cca_df = cca_dfs[frame_i-1] + cca_df = cca_dfs[frame_i - 1] is_in_index = cca_df.index.isin(IDs) cca_df = cca_df[is_in_index] new_cells_cca_dfs = [] @@ -870,86 +885,95 @@ def annotate_lineage_tree_from_labels(tracked_labels, labels_to_IDs_mapper): for ID in IDs: if ID in cca_df.index: continue - + newID = ID # New cell --> store cca info - label = IDs_to_labels_mapper[f'{newID}_{frame_i}'] - parent_label, _, sister_label = label.rpartition('_') + label = IDs_to_labels_mapper[f"{newID}_{frame_i}"] + parent_label, _, sister_label = label.rpartition("_") if not parent_label: # New single-cell --> check if it existed in past frames - for i in range(frame_i-2, -1, -1): - past_cca_df = cca_dfs[frame_i-1] + for i in range(frame_i - 2, -1, -1): + past_cca_df = cca_dfs[frame_i - 1] if newID in past_cca_df.index: cca_df_single_ID = past_cca_df.loc[[newID]] break else: cca_df_single_ID = getBaseCca_df([newID]) - cca_df_single_ID.loc[newID, 'emerg_frame_i'] = frame_i + cca_df_single_ID.loc[newID, "emerg_frame_i"] = frame_i else: # New cell resulting from division --> store division - mothID = int(labels_to_IDs_mapper[parent_label].split('_')[0]) + mothID = int(labels_to_IDs_mapper[parent_label].split("_")[0]) cca_df_single_ID = getBaseCca_df([newID]) try: - cca_df.at[mothID, 'generation_num'] += 1 + cca_df.at[mothID, "generation_num"] += 1 except Exception as e: - import pdb; pdb.set_trace() - cca_df.at[mothID, 'division_frame_i'] = frame_i - cca_df.at[mothID, 'relative_ID'] = newID - cca_df_single_ID.at[newID, 'emerg_frame_i'] = frame_i - cca_df_single_ID.at[newID, 'division_frame_i'] = frame_i - cca_df_single_ID.at[newID, 'generation_num'] = 1 - cca_df_single_ID.at[newID, 'relative_ID'] = mothID + import pdb + + pdb.set_trace() + cca_df.at[mothID, "division_frame_i"] = frame_i + cca_df.at[mothID, "relative_ID"] = newID + cca_df_single_ID.at[newID, "emerg_frame_i"] = frame_i + cca_df_single_ID.at[newID, "division_frame_i"] = frame_i + cca_df_single_ID.at[newID, "generation_num"] = 1 + cca_df_single_ID.at[newID, "relative_ID"] = mothID new_cells_cca_dfs.append(cca_df_single_ID) - + cca_df = pd.concat([cca_df, *new_cells_cca_dfs]).sort_index() cca_dfs.append(cca_df) pbar.update() pbar.close() return cca_dfs + def getBaseCca_df(IDs, with_tree_cols=False): row_data = base_cca_dict if with_tree_cols: row_data = {**base_cca_dict, **base_cca_tree_dict} - data = [row_data]*len(IDs) - cca_df = pd.DataFrame(data, index=IDs) + data = [row_data] * len(IDs) + cca_df = pd.DataFrame(data, index=IDs) if with_tree_cols: - cca_df['Cell_ID_tree'] = IDs - cca_df.index.name = 'Cell_ID' + cca_df["Cell_ID_tree"] = IDs + cca_df.index.name = "Cell_ID" return cca_df -def apply_tracking_from_table( - segmData, trackColsInfo, src_df, signal=None, logger=print, - pbarMax=None, debug=False - ): - frameIndexCol = trackColsInfo['frameIndexCol'] - if trackColsInfo['isFirstFrameOne']: +def apply_tracking_from_table( + segmData, + trackColsInfo, + src_df, + signal=None, + logger=print, + pbarMax=None, + debug=False, +): + frameIndexCol = trackColsInfo["frameIndexCol"] + + if trackColsInfo["isFirstFrameOne"]: # Zeroize frames since first frame starts at 1 src_df[frameIndexCol] = src_df[frameIndexCol] - 1 - logger('Applying tracking info...') + logger("Applying tracking info...") grouped = src_df.groupby(frameIndexCol) iterable = grouped if signal is not None else tqdm(grouped, ncols=100) - trackIDsCol = trackColsInfo['trackIDsCol'] - maskIDsCol = trackColsInfo['maskIDsCol'] - xCentroidCol = trackColsInfo['xCentroidCol'] - yCentroidCol = trackColsInfo['yCentroidCol'] - deleteUntrackedIDs = trackColsInfo['deleteUntrackedIDs'] + trackIDsCol = trackColsInfo["trackIDsCol"] + maskIDsCol = trackColsInfo["maskIDsCol"] + xCentroidCol = trackColsInfo["xCentroidCol"] + yCentroidCol = trackColsInfo["yCentroidCol"] + deleteUntrackedIDs = trackColsInfo["deleteUntrackedIDs"] trackedIDsMapper = {} deleteIDsMapper = {} for frame_i, df_frame in iterable: if frame_i == len(segmData): - print('') + print("") logger( - '[WARNING]: segmentation data has less frames than the ' + "[WARNING]: segmentation data has less frames than the " f'frames in the "{frameIndexCol}" column.' ) if signal is not None and pbarMax is not None: - signal.emit(pbarMax-frame_i) + signal.emit(pbarMax - frame_i) break lab = segmData[frame_i] @@ -968,7 +992,7 @@ def apply_tracking_from_table( deleteIDs = [] if deleteUntrackedIDs: - if xCentroidCol == 'None': + if xCentroidCol == "None": maskIDsTracked = df_frame[maskIDsCol].dropna().apply(round).values else: xx = df_frame[xCentroidCol].dropna().apply(round).values @@ -987,16 +1011,16 @@ def apply_tracking_from_table( # First iterate IDs and make sure there are no overlapping IDs for row in df_frame.itertuples(): trackedID = getattr(row, trackIDsCol) - if xCentroidCol == 'None': + if xCentroidCol == "None": maskID = getattr(row, maskIDsCol) else: xc = getattr(row, xCentroidCol) yc = getattr(row, yCentroidCol) maskID = lab[round(yc), round(xc)] - + if not maskID > 0: continue - + if maskID == trackedID: continue @@ -1011,56 +1035,55 @@ def apply_tracking_from_table( if uniqueID in trackIDs: uniqueID = maxTrackID + 1 maxTrackID += 1 - - lab[lab==trackedID] = uniqueID + + lab[lab == trackedID] = uniqueID firstPassMapper_i[int(trackedID)] = int(uniqueID) - if xCentroidCol == 'None': - mask = df_frame[maskIDsCol]==trackedID + if xCentroidCol == "None": + mask = df_frame[maskIDsCol] == trackedID df_frame.loc[mask, maskIDsCol] = int(uniqueID) - + # print(f'First = {int(trackedID)} --> {int(uniqueID)}') if firstPassMapper_i: - trackedIDsMapper[str(frame_i)] = {'first_pass': firstPassMapper_i} + trackedIDsMapper[str(frame_i)] = {"first_pass": firstPassMapper_i} secondPassMapper_i = {} for row in df_frame.itertuples(): trackedID = getattr(row, trackIDsCol) - if xCentroidCol == 'None': + if xCentroidCol == "None": maskID = getattr(row, maskIDsCol) else: xc = getattr(row, xCentroidCol) yc = getattr(row, yCentroidCol) maskID = lab[round(yc), round(xc)] - + if not maskID > 0: continue - + if maskID == trackedID: continue - lab[lab==maskID] = trackedID - secondPassMapper_i[int(maskID)] = int(trackedID) + lab[lab == maskID] = trackedID + secondPassMapper_i[int(maskID)] = int(trackedID) + + # print(f'Second = {int(maskID)} --> {int(trackedID)}') - # print(f'Second = {int(maskID)} --> {int(trackedID)}') - if secondPassMapper_i: if firstPassMapper_i: - trackedIDsMapper[str(frame_i)]['second_pass'] = secondPassMapper_i + trackedIDsMapper[str(frame_i)]["second_pass"] = secondPassMapper_i else: - trackedIDsMapper[str(frame_i)] = {'second_pass': secondPassMapper_i} + trackedIDsMapper[str(frame_i)] = {"second_pass": secondPassMapper_i} if signal is not None: signal.emit(1) # print('*'*40) # import pdb; pdb.set_trace() - + return segmData, trackedIDsMapper, deleteIDsMapper -def apply_trackedIDs_mapper_to_acdc_df( - tracked_IDs_mapper, deleted_IDs_mapper, acdc_df - ): + +def apply_trackedIDs_mapper_to_acdc_df(tracked_IDs_mapper, deleted_IDs_mapper, acdc_df): acdc_dfs_renamed = [] for frame_i, acdc_df_i in acdc_df.groupby(level=0): df_renamed = acdc_df_i @@ -1073,81 +1096,100 @@ def apply_trackedIDs_mapper_to_acdc_df( if mapper_i is None: acdc_dfs_renamed.append(df_renamed) continue - - first_pass = mapper_i.get('first_pass') + + first_pass = mapper_i.get("first_pass") if first_pass is not None: - first_pass = {int(k):int(v) for k,v in first_pass.items()} + first_pass = {int(k): int(v) for k, v in first_pass.items()} # Substitute mask IDs with tracked IDs df_renamed = df_renamed.rename(index=first_pass, level=1) - if 'relative_ID' in df_renamed.columns: - relIDs = df_renamed['relative_ID'] - df_renamed['relative_ID'] = relIDs.replace(tracked_IDs_mapper) - - second_pass = mapper_i.get('second_pass') + if "relative_ID" in df_renamed.columns: + relIDs = df_renamed["relative_ID"] + df_renamed["relative_ID"] = relIDs.replace(tracked_IDs_mapper) + + second_pass = mapper_i.get("second_pass") if second_pass is not None: - second_pass = {int(k):int(v) for k,v in second_pass.items()} + second_pass = {int(k): int(v) for k, v in second_pass.items()} # Substitute mask IDs with tracked IDs df_renamed = df_renamed.rename(index=second_pass, level=1) - if 'relative_ID' in df_renamed.columns: - relIDs = df_renamed['relative_ID'] - df_renamed['relative_ID'] = relIDs.replace(tracked_IDs_mapper) - + if "relative_ID" in df_renamed.columns: + relIDs = df_renamed["relative_ID"] + df_renamed["relative_ID"] = relIDs.replace(tracked_IDs_mapper) + acdc_dfs_renamed.append(df_renamed) - + acdc_df = pd.concat(acdc_dfs_renamed).sort_index() return acdc_df + def _get_cca_info_warn_text( - newID, parentID, frame_i, maskID_colname, x_colname, y_colname, - df_frame, src_df, frame_idx_colname, trackID_colname - ): + newID, + parentID, + frame_i, + maskID_colname, + x_colname, + y_colname, + df_frame, + src_df, + frame_idx_colname, + trackID_colname, +): txt = ( - f'\n[WARNING]: The parent ID of {newID} at frame index ' - f'{frame_i} is {parentID}, but this parent {parentID} ' - f'does not exist at previous frame {frame_i-1} -->\n' - f' --> Setting ID {newID} as a new cell without a parent.\n\n' - 'More details:\n' + f"\n[WARNING]: The parent ID of {newID} at frame index " + f"{frame_i} is {parentID}, but this parent {parentID} " + f"does not exist at previous frame {frame_i - 1} -->\n" + f" --> Setting ID {newID} as a new cell without a parent.\n\n" + "More details:\n" ) try: - df_prev_frame = src_df[src_df[frame_idx_colname] == frame_i-1] + df_prev_frame = src_df[src_df[frame_idx_colname] == frame_i - 1] df_prev_frame = df_prev_frame.set_index(trackID_colname) - if maskID_colname != 'None': + if maskID_colname != "None": maskID_of_newID = df_frame.at[newID, maskID_colname] maskID_of_parentID = df_prev_frame.at[parentID, maskID_colname] details_txt = ( f' - "{maskID_colname}" of ID {newID} = {maskID_of_newID}\n' f' - "{maskID_colname}" of ID {parentID} = {maskID_of_parentID}\n' ) - txt = f'{txt}{details_txt}' - if x_colname != 'None': + txt = f"{txt}{details_txt}" + if x_colname != "None": xc_of_newID = df_frame.at[newID, x_colname] xc_of_parentID = df_prev_frame.at[parentID, x_colname] yc_of_newID = df_frame.at[newID, y_colname] yc_of_parentID = df_prev_frame.at[parentID, y_colname] details_txt = ( - f' - (x,y) coordinates of ID {newID} = {(xc_of_newID, yc_of_newID)}\n' - f' - (x,y) coordinates of ID {parentID} = {(xc_of_parentID, yc_of_parentID)}\n' + f" - (x,y) coordinates of ID {newID} = {(xc_of_newID, yc_of_newID)}\n" + f" - (x,y) coordinates of ID {parentID} = {(xc_of_parentID, yc_of_parentID)}\n" ) - txt = f'{txt}{details_txt}' + txt = f"{txt}{details_txt}" except Exception as e: # import pdb; pdb.set_trace() pass return txt + def add_cca_info_from_parentID_col( - src_df, acdc_df, frame_idx_colname, IDs_colname, parentID_colname, - SizeT, signal=None, trackedData=None, logger=print, - maskID_colname='None', x_colname='None', y_colname='None' - ): + src_df, + acdc_df, + frame_idx_colname, + IDs_colname, + parentID_colname, + SizeT, + signal=None, + trackedData=None, + logger=print, + maskID_colname="None", + x_colname="None", + y_colname="None", +): grouped = src_df.groupby(frame_idx_colname) acdc_dfs = [] keys = [] - iterable = grouped if signal is not None else tqdm(grouped, ncols=100) + iterable = grouped if signal is not None else tqdm(grouped, ncols=100) for frame_i, df_frame in iterable: if frame_i == SizeT: break - + if trackedData is not None: lab = trackedData[frame_i] @@ -1160,13 +1202,13 @@ def add_cca_info_from_parentID_col( oldIDs = [] newIDs = IDs else: - prevIDs = acdc_df.loc[frame_i-1].index.values + prevIDs = acdc_df.loc[frame_i - 1].index.values newIDs = [ID for ID in IDs if ID not in prevIDs] oldIDs = [ID for ID in IDs if ID in prevIDs] - + if oldIDs: # For the oldIDs copy from previous cca_df - prev_acdc_df = acdc_dfs[frame_i-1].filter(oldIDs, axis=0) + prev_acdc_df = acdc_dfs[frame_i - 1].filter(oldIDs, axis=0) cca_df.loc[prev_acdc_df.index] = prev_acdc_df for newID in newIDs: @@ -1174,80 +1216,80 @@ def add_cca_info_from_parentID_col( parentID = int(df_frame.at[newID, parentID_colname]) except Exception as e: parentID = -1 - + parentGenNum = None if parentID > 1: - prev_acdc_df = acdc_dfs[frame_i-1] + prev_acdc_df = acdc_dfs[frame_i - 1] try: - parentGenNum = prev_acdc_df.at[parentID, 'generation_num'] + parentGenNum = prev_acdc_df.at[parentID, "generation_num"] except Exception as e: parentGenNum = None - logger('*'*40) + logger("*" * 40) warn_txt = _get_cca_info_warn_text( - newID, parentID, frame_i, maskID_colname, x_colname, - y_colname, df_frame, src_df, frame_idx_colname, - IDs_colname + newID, + parentID, + frame_i, + maskID_colname, + x_colname, + y_colname, + df_frame, + src_df, + frame_idx_colname, + IDs_colname, ) logger(warn_txt) - logger('*'*40) - + logger("*" * 40) + if parentGenNum is not None: - prentGenNumTree = ( - prev_acdc_df.at[parentID, 'generation_num_tree'] - ) - newGenNumTree = prentGenNumTree+1 - parentRootID = ( - prev_acdc_df.at[parentID, 'root_ID_tree'] - ) - cca_df.at[newID, 'is_history_known'] = True - cca_df.at[newID, 'cell_cycle_stage'] = 'G1' - cca_df.at[newID, 'generation_num'] = parentGenNum+1 - cca_df.at[newID, 'emerg_frame_i'] = frame_i - cca_df.at[newID, 'division_frame_i'] = frame_i - cca_df.at[newID, 'relationship'] = 'mother' - cca_df.at[newID, 'generation_num_tree'] = newGenNumTree - cca_df.at[newID, 'Cell_ID_tree'] = newID - cca_df.at[newID, 'root_ID_tree'] = parentRootID - cca_df.at[newID, 'parent_ID_tree'] = parentID + prentGenNumTree = prev_acdc_df.at[parentID, "generation_num_tree"] + newGenNumTree = prentGenNumTree + 1 + parentRootID = prev_acdc_df.at[parentID, "root_ID_tree"] + cca_df.at[newID, "is_history_known"] = True + cca_df.at[newID, "cell_cycle_stage"] = "G1" + cca_df.at[newID, "generation_num"] = parentGenNum + 1 + cca_df.at[newID, "emerg_frame_i"] = frame_i + cca_df.at[newID, "division_frame_i"] = frame_i + cca_df.at[newID, "relationship"] = "mother" + cca_df.at[newID, "generation_num_tree"] = newGenNumTree + cca_df.at[newID, "Cell_ID_tree"] = newID + cca_df.at[newID, "root_ID_tree"] = parentRootID + cca_df.at[newID, "parent_ID_tree"] = parentID # sister ID is the other cell with the same parent ID - sisterIDmask = ( - (df_frame[parentID_colname] == parentID) - & (df_frame.index != newID) + sisterIDmask = (df_frame[parentID_colname] == parentID) & ( + df_frame.index != newID ) sisterID_df = df_frame[sisterIDmask] if len(sisterID_df) == 1: sisterID = sisterID_df.index[0] else: sisterID = -1 - cca_df.at[newID, 'sister_ID_tree'] = sisterID + cca_df.at[newID, "sister_ID_tree"] = sisterID else: # Set new ID without a parent as history unknown - cca_df.at[newID, 'is_history_known'] = False - cca_df.at[newID, 'cell_cycle_stage'] = 'G1' - cca_df.at[newID, 'generation_num'] = 2 - cca_df.at[newID, 'emerg_frame_i'] = frame_i - cca_df.at[newID, 'division_frame_i'] = -1 - cca_df.at[newID, 'relationship'] = 'mother' - cca_df.at[newID, 'generation_num_tree'] = 1 - cca_df.at[newID, 'Cell_ID_tree'] = newID - cca_df.at[newID, 'root_ID_tree'] = newID - cca_df.at[newID, 'parent_ID_tree'] = -1 - cca_df.at[newID, 'sister_ID_tree'] = -1 - + cca_df.at[newID, "is_history_known"] = False + cca_df.at[newID, "cell_cycle_stage"] = "G1" + cca_df.at[newID, "generation_num"] = 2 + cca_df.at[newID, "emerg_frame_i"] = frame_i + cca_df.at[newID, "division_frame_i"] = -1 + cca_df.at[newID, "relationship"] = "mother" + cca_df.at[newID, "generation_num_tree"] = 1 + cca_df.at[newID, "Cell_ID_tree"] = newID + cca_df.at[newID, "root_ID_tree"] = newID + cca_df.at[newID, "parent_ID_tree"] = -1 + cca_df.at[newID, "sister_ID_tree"] = -1 + acdc_df_i[cca_df.columns] = cca_df acdc_dfs.append(acdc_df_i) keys.append(frame_i) if signal is not None: signal.emit(1) - + if acdc_dfs: - acdc_df_with_cca = pd.concat( - acdc_dfs, keys=keys, names=['frame_i', 'Cell_ID'] - ) + acdc_df_with_cca = pd.concat(acdc_dfs, keys=keys, names=["frame_i", "Cell_ID"]) if len(acdc_df_with_cca) == len(acdc_df): # All frames from existing acdc_df were cca annotated in src_table acdc_df_with_cca = pd.concat( - acdc_dfs, keys=keys, names=['frame_i', 'Cell_ID'] + acdc_dfs, keys=keys, names=["frame_i", "Cell_ID"] ) return acdc_df_with_cca else: @@ -1258,10 +1300,10 @@ def add_cca_info_from_parentID_col( else: # No annotations present in src_table return acdc_df - - + return acdc_df + def cca_df_to_acdc_df(cca_df, rp, acdc_df=None): if acdc_df is None: IDs = [] @@ -1275,138 +1317,136 @@ def cca_df_to_acdc_df(cca_df, rp, acdc_df=None): is_cell_excluded_li.append(0) xx_centroid.append(int(obj.centroid[1])) yy_centroid.append(int(obj.centroid[0])) - acdc_df = pd.DataFrame({ - 'Cell_ID': IDs, - 'is_cell_dead': is_cell_dead_li, - 'is_cell_excluded': is_cell_excluded_li, - 'x_centroid': xx_centroid, - 'y_centroid': yy_centroid, - 'was_manually_edited': is_cell_excluded_li.copy() - }).set_index('Cell_ID') - - acdc_df = acdc_df.join(cca_df, how='left') + acdc_df = pd.DataFrame( + { + "Cell_ID": IDs, + "is_cell_dead": is_cell_dead_li, + "is_cell_excluded": is_cell_excluded_li, + "x_centroid": xx_centroid, + "y_centroid": yy_centroid, + "was_manually_edited": is_cell_excluded_li.copy(), + } + ).set_index("Cell_ID") + + acdc_df = acdc_df.join(cca_df, how="left") return acdc_df + class LineageTree: def __init__(self, acdc_df, logging_func=print, debug=False) -> None: - acdc_df = load.pd_bool_and_float_to_int_to_str(acdc_df, colsToCastInt=[]).reset_index() + acdc_df = load.pd_bool_and_float_to_int_to_str( + acdc_df, colsToCastInt=[] + ).reset_index() acdc_df = self._normalize_gen_num(acdc_df).reset_index() - acdc_df = acdc_df.drop(columns=['index', 'level_0'], errors='ignore') - self.acdc_df = acdc_df.set_index(['frame_i', 'Cell_ID']) + acdc_df = acdc_df.drop(columns=["index", "level_0"], errors="ignore") + self.acdc_df = acdc_df.set_index(["frame_i", "Cell_ID"]) self.df = acdc_df.copy() self.cca_df_colnames = cca_df_colnames self.log = logging_func self.debug = debug - + def build(self): - self.log('Building lineage tree...') + self.log("Building lineage tree...") try: - df_G1 = self.acdc_df[self.acdc_df['cell_cycle_stage'] == 'G1'] + df_G1 = self.acdc_df[self.acdc_df["cell_cycle_stage"] == "G1"] self.df_G1 = df_G1[self.cca_df_colnames].copy() - self.new_col_loc = df_G1.columns.get_loc('division_frame_i') + 1 + self.new_col_loc = df_G1.columns.get_loc("division_frame_i") + 1 except Exception as error: return error - + self.df = self.add_lineage_tree_table_to_acdc_df() - self.log('Lineage tree built successfully!') - + self.log("Lineage tree built successfully!") + def _normalize_gen_num(self, acdc_df): - ''' + """ Since the user is allowed to start the generation_num of unknown mother cells with any number we need to normalise this to 2 --> Create a new 'normalized_gen_num' column where we make sure that mother cells with unknown history have 'normalized_gen_num' starting from 2 (required by the logic of _build_tree) - ''' - acdc_df = acdc_df.drop(columns=['level_0', 'index'], errors='ignore') - acdc_df = ( - acdc_df.reset_index() - .drop(columns='index', errors='ignore') - ) + """ + acdc_df = acdc_df.drop(columns=["level_0", "index"], errors="ignore") + acdc_df = acdc_df.reset_index().drop(columns="index", errors="ignore") # Get the starting generation number of the unknown mother cells - df_emerg = acdc_df.groupby('Cell_ID').agg('first') - history_unknown_mask = df_emerg['is_history_known'] == 0 - moth_mask = df_emerg['relationship'] == 'mother' + df_emerg = acdc_df.groupby("Cell_ID").agg("first") + history_unknown_mask = df_emerg["is_history_known"] == 0 + moth_mask = df_emerg["relationship"] == "mother" df_emerg_moth_uknown = df_emerg[(history_unknown_mask) & (moth_mask)] # Get the difference from 2 - df_diff = 2 - df_emerg_moth_uknown['generation_num'] + df_diff = 2 - df_emerg_moth_uknown["generation_num"] # Build a normalizing df with the number to be added for each cell - normalizing_df = pd.DataFrame( - data=acdc_df[['frame_i', 'Cell_ID']] - ).set_index('Cell_ID') - normalizing_df['gen_num_diff'] = 0 - normalizing_df.loc[df_emerg_moth_uknown.index, 'gen_num_diff'] = ( - df_diff + normalizing_df = pd.DataFrame(data=acdc_df[["frame_i", "Cell_ID"]]).set_index( + "Cell_ID" ) + normalizing_df["gen_num_diff"] = 0 + normalizing_df.loc[df_emerg_moth_uknown.index, "gen_num_diff"] = df_diff # Add the normalising_df to create the new normalized_gen_num col - normalizing_df = normalizing_df.reset_index().set_index( - ['frame_i', 'Cell_ID'] - ) - acdc_df = acdc_df.set_index(['frame_i', 'Cell_ID']) - acdc_df['normalized_gen_num'] = ( - acdc_df['generation_num'] + normalizing_df['gen_num_diff'] + normalizing_df = normalizing_df.reset_index().set_index(["frame_i", "Cell_ID"]) + acdc_df = acdc_df.set_index(["frame_i", "Cell_ID"]) + acdc_df["normalized_gen_num"] = ( + acdc_df["generation_num"] + normalizing_df["gen_num_diff"] ) return acdc_df - + def _build_tree(self, gen_df, ID): current_ID = gen_df.index.get_level_values(1)[0] if current_ID != ID: return gen_df - ''' + """ Add generation number tree: --> At the start of a branch we set the generation number as either 0 (if also start of tree) or relative ID generation number tree --> This value called gen_num_relID_tree is added to the current generation_num - ''' + """ ID_slice = pd.IndexSlice[:, ID] - relID = gen_df.loc[ID_slice, 'relative_ID'].iloc[0] + relID = gen_df.loc[ID_slice, "relative_ID"].iloc[0] relID_slice = pd.IndexSlice[:, relID] - gen_nums_tree = gen_df['generation_num_tree'].values + gen_nums_tree = gen_df["generation_num_tree"].values start_frame_i = gen_df.index.get_level_values(0)[0] if self.is_new_tree: - try: - gen_num_relID_tree = self.df_G1.at[ - (start_frame_i, relID), 'generation_num_tree' - ] - 1 + try: + gen_num_relID_tree = ( + self.df_G1.at[(start_frame_i, relID), "generation_num_tree"] - 1 + ) except Exception as e: gen_num_relID_tree = 0 self.branch_start_gen_num[ID] = gen_num_relID_tree else: gen_num_relID_tree = self.branch_start_gen_num[ID] - + updated_gen_nums_tree = gen_nums_tree + gen_num_relID_tree - gen_df['generation_num_tree'] = updated_gen_nums_tree - - '''Assign unique ID every consecutive division''' + gen_df["generation_num_tree"] = updated_gen_nums_tree + + """Assign unique ID every consecutive division""" if self.is_new_tree: # Keep start ID for cell at the top of the branch Cell_ID_tree = ID - gen_df['Cell_ID_tree'] = [ID]*len(gen_df) + gen_df["Cell_ID_tree"] = [ID] * len(gen_df) else: Cell_ID_tree = self.uniqueID self.uniqueID += 1 - - gen_df['Cell_ID_tree'] = [Cell_ID_tree]*len(gen_df) - ''' + gen_df["Cell_ID_tree"] = [Cell_ID_tree] * len(gen_df) + + """ Assign parent ID --> existing ID between relID and ID in prev gen_num_tree - ''' - gen_num_tree = gen_df.loc[ID_slice, 'generation_num_tree'].iloc[0] - + """ + gen_num_tree = gen_df.loc[ID_slice, "generation_num_tree"].iloc[0] + prev_gen_G1_existing = True - if gen_num_tree > 1: + if gen_num_tree > 1: prev_gen_num_tree = gen_num_tree - 1 try: # Parent ID is the Cell_ID_tree that current ID had in prev gen prev_gen_df = self.gen_dfs[(ID, prev_gen_num_tree)] except Exception as e: - # Parent ID is the Cell_ID_tree that the relative of the + # Parent ID is the Cell_ID_tree that the relative of the # current ID had in prev gen try: prev_gen_df = self.gen_dfs[(relID, prev_gen_num_tree)] @@ -1415,21 +1455,21 @@ def _build_tree(self, gen_df, ID): # starts at 2 (cell appeared in S and then started G1) prev_gen_G1_existing = False pass - + if prev_gen_G1_existing: try: - parent_ID = prev_gen_df.loc[relID_slice, 'Cell_ID_tree'].iloc[0] + parent_ID = prev_gen_df.loc[relID_slice, "Cell_ID_tree"].iloc[0] except Exception as e: - parent_ID = prev_gen_df.loc[ID_slice, 'Cell_ID_tree'].iloc[0] - gen_df['parent_ID_tree'] = parent_ID + parent_ID = prev_gen_df.loc[ID_slice, "Cell_ID_tree"].iloc[0] + gen_df["parent_ID_tree"] = parent_ID else: # Cell appeared in S in previous frame - idx = (start_frame_i-1, ID) - was_bud = self.acdc_df.loc[idx, 'relationship'] == 'bud' + idx = (start_frame_i - 1, ID) + was_bud = self.acdc_df.loc[idx, "relationship"] == "bud" if was_bud: - # This is a bud of the first frame where the algorithm + # This is a bud of the first frame where the algorithm # thinks is a new tree --> correct - parent_ID = self.acdc_df.loc[idx, 'relative_ID'] + parent_ID = self.acdc_df.loc[idx, "relative_ID"] try: self.branch_start_gen_num[ID] = ( self.branch_start_gen_num[parent_ID] + 2 @@ -1439,20 +1479,20 @@ def _build_tree(self, gen_df, ID): self.branch_start_gen_num[ID] = gen_num_parentID_tree else: parent_ID = ID - + Cell_ID_tree = self.uniqueID self.uniqueID += 1 - gen_df['Cell_ID_tree'] = [Cell_ID_tree]*len(gen_df) + gen_df["Cell_ID_tree"] = [Cell_ID_tree] * len(gen_df) else: parent_ID = -1 - - ''' + + """ Assign root ID --> at start of branch (self.is_new_tree is True) the root_ID is ID if gen_num_tree == 1 otherwise we go back until the parent_ID == -1 --> store this and use when traversing branch - ''' + """ if self.is_new_tree: if gen_num_tree == 2 and prev_gen_G1_existing: root_ID_tree = parent_ID @@ -1460,14 +1500,14 @@ def _build_tree(self, gen_df, ID): prev_gen_num_tree = gen_num_tree - 1 prev_gen_idx = parent_ID parent_ID_df = self.gen_dfs_by_ID_tree[prev_gen_idx] - root_ID_tree = parent_ID_df['parent_ID_tree'].iloc[0] + root_ID_tree = parent_ID_df["parent_ID_tree"].iloc[0] while prev_gen_num_tree > 2: prev_gen_num_tree -= 1 prev_gen_idx = root_ID_tree parent_ID_df = self.gen_dfs_by_ID_tree[prev_gen_idx] - root_ID_tree = parent_ID_df['parent_ID_tree'].iloc[0] + root_ID_tree = parent_ID_df["parent_ID_tree"].iloc[0] if root_ID_tree == -1: - root_ID_tree = parent_ID_df['root_ID_tree'].iloc[0] + root_ID_tree = parent_ID_df["root_ID_tree"].iloc[0] elif parent_ID > 0: # We started a new tree of a bud that appeared already in S # --> the root ID is the parent_ID (mother cell) @@ -1477,49 +1517,51 @@ def _build_tree(self, gen_df, ID): self.root_IDs_trees[ID] = root_ID_tree else: root_ID_tree = self.root_IDs_trees[ID] - - gen_df['root_ID_tree'] = root_ID_tree + + gen_df["root_ID_tree"] = root_ID_tree if self.debug: printl( - f'Traversing ID: {ID}\n' - f'Parent ID: {parent_ID}\n' - f'Is new tree: {self.is_new_tree}\n' - f'Relative ID: {relID}\n' - f'Relative ID generation num tree: {gen_num_relID_tree}\n' - f'Generation number tree: {gen_num_tree}\n' - f'New cell ID tree: {Cell_ID_tree}\n' - f'Start branch gen number: {self.branch_start_gen_num[ID]}\n' - f'Start of tree frame n.: {start_frame_i+1}\n' - f'root_ID_tree: {root_ID_tree}' + f"Traversing ID: {ID}\n" + f"Parent ID: {parent_ID}\n" + f"Is new tree: {self.is_new_tree}\n" + f"Relative ID: {relID}\n" + f"Relative ID generation num tree: {gen_num_relID_tree}\n" + f"Generation number tree: {gen_num_tree}\n" + f"New cell ID tree: {Cell_ID_tree}\n" + f"Start branch gen number: {self.branch_start_gen_num[ID]}\n" + f"Start of tree frame n.: {start_frame_i + 1}\n" + f"root_ID_tree: {root_ID_tree}" ) - import pdb; pdb.set_trace() - + import pdb + + pdb.set_trace() + self.gen_dfs[(ID, gen_num_tree)] = gen_df self.gen_dfs_by_ID_tree[Cell_ID_tree] = gen_df - + self.is_new_tree = False - + return gen_df - + def add_lineage_tree_table_to_acdc_df(self): Cell_ID_tree_vals = self.df_G1.index.get_level_values(1) - self.df_G1['Cell_ID_tree'] = Cell_ID_tree_vals - self.df_G1['parent_ID_tree'] = -1 - self.df_G1['root_ID_tree'] = -1 - self.df_G1['sister_ID_tree'] = -1 - - self.df_G1['generation_num_tree'] = self.df_G1['generation_num'] - + self.df_G1["Cell_ID_tree"] = Cell_ID_tree_vals + self.df_G1["parent_ID_tree"] = -1 + self.df_G1["root_ID_tree"] = -1 + self.df_G1["sister_ID_tree"] = -1 + + self.df_G1["generation_num_tree"] = self.df_G1["generation_num"] + # For cells that starts at ccs = 2 subtract 1 - history_unknown_mask = self.df_G1['is_history_known'] == 0 - ccs_greater_one_mask = self.df_G1['generation_num'] > 1 + history_unknown_mask = self.df_G1["is_history_known"] == 0 + ccs_greater_one_mask = self.df_G1["generation_num"] > 1 subtract_gen_num_mask = (history_unknown_mask) & (ccs_greater_one_mask) - self.df_G1.loc[subtract_gen_num_mask, 'generation_num_tree'] = ( - self.df_G1.loc[subtract_gen_num_mask, 'generation_num'] - 1 + self.df_G1.loc[subtract_gen_num_mask, "generation_num_tree"] = ( + self.df_G1.loc[subtract_gen_num_mask, "generation_num"] - 1 ) - - cols_tree = [col for col in self.df_G1.columns if col.endswith('_tree')] + + cols_tree = [col for col in self.df_G1.columns if col.endswith("_tree")] frames_idx = self.df_G1.dropna().index.get_level_values(0).unique() not_annotated_IDs = self.df_G1.index.get_level_values(1).unique().to_list() @@ -1534,24 +1576,22 @@ def add_lineage_tree_table_to_acdc_df(self): if not not_annotated_IDs: # Built tree for every ID --> exit break - + df_i = self.df_G1.loc[frame_i] IDs = np.sort(df_i.index.array) for ID in IDs: if ID not in not_annotated_IDs: # Tree already built in previous frame iteration --> skip continue - + self.is_new_tree = True # Iterate the branch till the end - df_tree_iter = ( - self.df_G1 - .groupby(['Cell_ID', 'generation_num'], group_keys=False) - .apply(self._build_tree, ID) - ) + df_tree_iter = self.df_G1.groupby( + ["Cell_ID", "generation_num"], group_keys=False + ).apply(self._build_tree, ID) self.df_G1 = df_tree_iter not_annotated_IDs.remove(ID) - + self._add_sister_ID() for c, col_tree in enumerate(cols_tree): @@ -1563,107 +1603,110 @@ def add_lineage_tree_table_to_acdc_df(self): self._build_tree_S(cols_tree) return self.acdc_df - + def _err_msg_add_sister_ID(self, relative_ID, frame_i, df): ID = df.index.get_level_values(1)[0] txt = ( - f'There is a problem with Cell ID {relative_ID} ' - f'at frame n. {frame_i+1}. ' - 'Make sure that annotations are correct before trying again.\n\n' - 'More info: error happened when trying to set the `sister_ID` of ' - f'cell ID {ID} to {relative_ID}. It might be that ID {relative_ID} ' - f'is not in G1 at frame n. {frame_i+1}' + f"There is a problem with Cell ID {relative_ID} " + f"at frame n. {frame_i + 1}. " + "Make sure that annotations are correct before trying again.\n\n" + "More info: error happened when trying to set the `sister_ID` of " + f"cell ID {ID} to {relative_ID}. It might be that ID {relative_ID} " + f"is not in G1 at frame n. {frame_i + 1}" ) return txt - + def _add_sister_ID(self): - grouped_ID_tree = self.df_G1.groupby('Cell_ID_tree') + grouped_ID_tree = self.df_G1.groupby("Cell_ID_tree") for Cell_ID_tree, df in grouped_ID_tree: - relative_ID = df['relative_ID'].iloc[0] + relative_ID = df["relative_ID"].iloc[0] if relative_ID == -1: continue start_frame_i = df.index.get_level_values(0)[0] try: sister_ID_tree = self.df_G1.at[ - (start_frame_i, relative_ID), 'Cell_ID_tree' + (start_frame_i, relative_ID), "Cell_ID_tree" ] except KeyError as error: raise KeyError( self._err_msg_add_sister_ID(relative_ID, start_frame_i, df) ) from error - - self.df_G1.loc[df.index, 'sister_ID_tree'] = sister_ID_tree - + + self.df_G1.loc[df.index, "sister_ID_tree"] = sister_ID_tree + def _build_tree_S(self, cols_tree): - '''In S we consider the bud still the same as the mother in the tree - --> either copy the tree information from the G1 phase or, in case + """In S we consider the bud still the same as the mother in the tree + --> either copy the tree information from the G1 phase or, in case the cell doesn't have a G1 (before S) because it appeared already in S, copy from the current S phase (e.g., Cell_ID_tree = Cell_ID) - ''' - S_mask = self.acdc_df['cell_cycle_stage'] == 'S' + """ + S_mask = self.acdc_df["cell_cycle_stage"] == "S" df_S = self.acdc_df[S_mask].copy() - gen_acdc_df = self.acdc_df.reset_index().set_index( - ['Cell_ID', 'generation_num', 'cell_cycle_stage'] - ).sort_index() + gen_acdc_df = ( + self.acdc_df.reset_index() + .set_index(["Cell_ID", "generation_num", "cell_cycle_stage"]) + .sort_index() + ) for row_S in df_S.itertuples(): relationship = row_S.relationship - if relationship == 'mother': + if relationship == "mother": idx_ID = row_S.Index[1] idx_gen_num = row_S.generation_num else: idx_ID = row_S.relative_ID frame_i = row_S.Index[0] - idx_gen_num = self.acdc_df.at[(frame_i, idx_ID), 'generation_num'] + idx_gen_num = self.acdc_df.at[(frame_i, idx_ID), "generation_num"] cc_df = gen_acdc_df.loc[(idx_ID, idx_gen_num)] - if 'G1' in cc_df.index: - row_G1 = cc_df.loc[['G1']].iloc[0] + if "G1" in cc_df.index: + row_G1 = cc_df.loc[["G1"]].iloc[0] for col_tree in cols_tree: self.acdc_df.loc[row_S.Index, col_tree] = row_G1[col_tree] else: # Cell that was already in S at appearance --> There is not G1 to copy from - sister_ID = cc_df.iloc[0]['relative_ID'] - self.acdc_df.loc[row_S.Index, 'Cell_ID_tree'] = idx_ID - self.acdc_df.loc[row_S.Index, 'parent_ID_tree'] = -1 - self.acdc_df.loc[row_S.Index, 'root_ID_tree'] = idx_ID - self.acdc_df.loc[row_S.Index, 'generation_num_tree'] = 1 - self.acdc_df.loc[row_S.Index, 'sister_ID_tree'] = sister_ID - + sister_ID = cc_df.iloc[0]["relative_ID"] + self.acdc_df.loc[row_S.Index, "Cell_ID_tree"] = idx_ID + self.acdc_df.loc[row_S.Index, "parent_ID_tree"] = -1 + self.acdc_df.loc[row_S.Index, "root_ID_tree"] = idx_ID + self.acdc_df.loc[row_S.Index, "generation_num_tree"] = 1 + self.acdc_df.loc[row_S.Index, "sister_ID_tree"] = sister_ID + def newick(self): - if 'Cell_ID_tree' not in self.acdc_df.columns: + if "Cell_ID_tree" not in self.acdc_df.columns: self.build() - + df = self.df.reset_index() - + def plot(self): - if 'Cell_ID_tree' not in self.acdc_df.columns: + if "Cell_ID_tree" not in self.acdc_df.columns: self.build() - + df = self.df.reset_index() - + def to_arboretum(self, rebuild=False): # See https://github.com/lowe-lab-ucl/arboretum/blob/main/examples/show_sample_data.py - if 'Cell_ID_tree' not in self.acdc_df.columns or rebuild: + if "Cell_ID_tree" not in self.acdc_df.columns or rebuild: self.build() df = self.df.reset_index() - tracks_cols = ['Cell_ID_tree', 'frame_i', 'y_centroid', 'x_centroid'] + tracks_cols = ["Cell_ID_tree", "frame_i", "y_centroid", "x_centroid"] tracks_data = df[tracks_cols].to_numpy() - graph_df = df.groupby('Cell_ID_tree').agg('first').reset_index() + graph_df = df.groupby("Cell_ID_tree").agg("first").reset_index() graph_df = graph_df[graph_df.parent_ID_tree > 0] graph = { - child_ID:[parent_ID] for child_ID, parent_ID - in zip(graph_df.Cell_ID_tree, graph_df.parent_ID_tree) + child_ID: [parent_ID] + for child_ID, parent_ID in zip( + graph_df.Cell_ID_tree, graph_df.parent_ID_tree + ) } - properties = pd.DataFrame({ - 't': df.frame_i, - 'root': df.root_ID_tree, - 'parent': df.parent_ID_tree - }) + properties = pd.DataFrame( + {"t": df.frame_i, "root": df.root_ID_tree, "parent": df.parent_ID_tree} + ) return tracks_data, graph, properties + def brownian(x0, n, dt, delta, out=None): """ Generate an instance of Brownian motion (i.e. the Wiener process): @@ -1675,7 +1718,7 @@ def brownian(x0, n, dt, delta, out=None): independence of N on different time intervals; that is, if [t0, t1) and [t2, t3) are disjoint intervals, then N(a, b; t0, t1) and N(a, b; t2, t3) are independent. - + Written as an iteration scheme, X(t + dt) = X(t) + N(0, delta**2 * dt; t, t+dt) @@ -1705,7 +1748,7 @@ def brownian(x0, n, dt, delta, out=None): Returns ------- A numpy array of floats with shape `x0.shape + (n,)`. - + Note that the initial value `x0` is not included in the returned array. """ @@ -1713,14 +1756,14 @@ def brownian(x0, n, dt, delta, out=None): # For each element of x0, generate a sample of n numbers from a # normal distribution. - r = norm.rvs(size=x0.shape + (n,), scale=delta*sqrt(dt)) + r = norm.rvs(size=x0.shape + (n,), scale=delta * sqrt(dt)) # If `out` was not given, create an output array. if out is None: out = np.empty(r.shape) # This computes the Brownian motion by forming the cumulative sum of - # the random samples. + # the random samples. np.cumsum(r, axis=-1, out=out) # Add the initial condition. @@ -1728,229 +1771,219 @@ def brownian(x0, n, dt, delta, out=None): return out + def preprocess_multi_pos_from_recipe( - image_data: Iterable[np.ndarray], - recipe: List[Dict[str, Any]] - ): - pbar = tqdm(total=len(image_data), unit='Position', ncols=100) + image_data: Iterable[np.ndarray], recipe: List[Dict[str, Any]] +): + pbar = tqdm(total=len(image_data), unit="Position", ncols=100) preprocessed_data = [] for pos_i, image in enumerate(image_data): - preprocessed_image = preprocess_zstack_from_recipe( - image, recipe, pbar_pos=1 - ) + preprocessed_image = preprocess_zstack_from_recipe(image, recipe, pbar_pos=1) preprocessed_data.append(preprocessed_image) pbar.update() pbar.close() return preprocessed_data -def preprocess_video_from_recipe( - image, recipe: List[Dict[str, Any]], pbar_pos=0 - ): + +def preprocess_video_from_recipe(image, recipe: List[Dict[str, Any]], pbar_pos=0): if image.ndim < 3: raise TypeError( - 'Only 3D or 4D videos allowed. ' - f'Input image has {image.ndim} dimensions!' + f"Only 3D or 4D videos allowed. Input image has {image.ndim} dimensions!" ) preprocessed_image = image for step in recipe: - method = step['method'] - func = PREPROCESS_MAPPER[method]['function'] - kwargs = step['kwargs'] + method = step["method"] + func = PREPROCESS_MAPPER[method]["function"] + kwargs = step["kwargs"] argspecs = inspect.getfullargspec(func) is_func_time_capable = False is_func_zstack_capable = False for arg in argspecs.args: - if arg == 'apply_to_all_frames': + if arg == "apply_to_all_frames": is_func_time_capable = True - elif arg == 'apply_to_all_zslices': + elif arg == "apply_to_all_zslices": is_func_zstack_capable = True - + if is_func_time_capable and is_func_zstack_capable: kwargs["apply_to_all_zslices"] = True kwargs["apply_to_all_frames"] = True - preprocessed_image = func( - preprocessed_image, - **kwargs - ) + preprocessed_image = func(preprocessed_image, **kwargs) else: pbar = tqdm( - total=len(preprocessed_image), unit='frame', ncols=100, - position=pbar_pos + total=len(preprocessed_image), + unit="frame", + ncols=100, + position=pbar_pos, ) for frame_i, frame_img in enumerate(preprocessed_image): if frame_img.ndim == 3: preprocessed_img = preprocess_zstack_from_recipe( - frame_img, (step,), pbar_pos=pbar_pos+1 + frame_img, (step,), pbar_pos=pbar_pos + 1 ) if preprocessed_img.dtype != preprocessed_image.dtype: - preprocessed_image = ( - preprocessed_image.astype(preprocessed_img.dtype) + preprocessed_image = preprocessed_image.astype( + preprocessed_img.dtype ) preprocessed_image[frame_i] = preprocessed_img else: - preprocessed_img = preprocess_image_from_recipe( - frame_img, (step,) - ) + preprocessed_img = preprocess_image_from_recipe(frame_img, (step,)) if preprocessed_img.dtype != preprocessed_image.dtype: - preprocessed_image = ( - preprocessed_image.astype(preprocessed_img.dtype) + preprocessed_image = preprocessed_image.astype( + preprocessed_img.dtype ) preprocessed_image[frame_i] = preprocessed_img pbar.update() pbar.close() - + return preprocessed_image - -def preprocess_zstack_from_recipe( - image, recipe: List[Dict[str, Any]], pbar_pos=0 - ): + + +def preprocess_zstack_from_recipe(image, recipe: List[Dict[str, Any]], pbar_pos=0): if image.ndim != 3: raise TypeError( - 'Only 3D z-stack images allowed. ' - f'Input image has {image.ndim} dimensions!' + f"Only 3D z-stack images allowed. Input image has {image.ndim} dimensions!" ) - + preprocessed_image = image for step in recipe: - method = step['method'] - func = PREPROCESS_MAPPER[method]['function'] - kwargs = step['kwargs'] + method = step["method"] + func = PREPROCESS_MAPPER[method]["function"] + kwargs = step["kwargs"] argspecs = inspect.getfullargspec(func) is_func_zstack_capable = False for arg in argspecs.args: - if arg == 'apply_to_all_zslices': + if arg == "apply_to_all_zslices": is_func_zstack_capable = True break - + if is_func_zstack_capable: - kwargs['apply_to_all_zslices'] = True - preprocessed_image = func( - preprocessed_image, **kwargs - ) + kwargs["apply_to_all_zslices"] = True + preprocessed_image = func(preprocessed_image, **kwargs) else: pbar = tqdm( - total=len(preprocessed_image), unit='z-slice', ncols=100, - position=pbar_pos + total=len(preprocessed_image), + unit="z-slice", + ncols=100, + position=pbar_pos, ) for z_slice, img in enumerate(preprocessed_image): preprocessed_img = func(img, **kwargs) if preprocessed_img.dtype != preprocessed_image.dtype: - preprocessed_image = ( - preprocessed_image.astype(preprocessed_img.dtype) + preprocessed_image = preprocessed_image.astype( + preprocessed_img.dtype ) preprocessed_image[z_slice] = preprocessed_img pbar.update() pbar.close() - + return preprocessed_image + all_kwargs_to_pop = ( - ('apply_to_all_zslices',), - ('apply_to_all_frames',), - ('apply_to_all_frames', 'apply_to_all_zslices'), + ("apply_to_all_zslices",), + ("apply_to_all_frames",), + ("apply_to_all_frames", "apply_to_all_zslices"), ) + + def preprocess_image_from_recipe(image, recipe: List[Dict[str, Any]]): preprocessed_image = image for step in recipe: - method = step['method'] - func = PREPROCESS_MAPPER[method]['function'] - kwargs = step['kwargs'] + method = step["method"] + func = PREPROCESS_MAPPER[method]["function"] + kwargs = step["kwargs"] for kwargs_to_pop in all_kwargs_to_pop: test_kwargs = kwargs.copy() try: preprocessed_image = func(preprocessed_image, **test_kwargs) break except TypeError as err: - if not 'unexpected keyword argument' in str(err): + if not "unexpected keyword argument" in str(err): raise err - + for kwarg_to_pop in kwargs_to_pop: test_kwargs.pop(kwarg_to_pop, None) - + return preprocessed_image + def pop_signals_kwarg_if_not_needed(func, kwargs): args = inspect.getfullargspec(func).args - if 'signals' in args: + if "signals" in args: return kwargs - - kwargs.pop('signals', None) + + kwargs.pop("signals", None) return kwargs + def segm_model_segment( - model, image, model_kwargs, frame_i=None, preproc_recipe=None, - is_timelapse_model_and_data=False, posData=None, start_z_slice=0, - ): + model, + image, + model_kwargs, + frame_i=None, + preproc_recipe=None, + is_timelapse_model_and_data=False, + posData=None, + start_z_slice=0, +): if preproc_recipe is not None: if is_timelapse_model_and_data: filtered_image = np.zeros(image.shape) for i, img in enumerate(image): img = preprocess_image_from_recipe(img, preproc_recipe) filtered_image[i] = img - image = filtered_image # .astype(image.dtype) + image = filtered_image # .astype(image.dtype) else: image = preprocess_image_from_recipe(image, preproc_recipe) - + if is_timelapse_model_and_data: - model_kwargs = pop_signals_kwarg_if_not_needed( - model.segment3DT, model_kwargs - ) + model_kwargs = pop_signals_kwarg_if_not_needed(model.segment3DT, model_kwargs) segm_data = model.segment3DT(image, **model_kwargs) - return segm_data - - model_kwargs = pop_signals_kwarg_if_not_needed( - model.segment, model_kwargs - ) + return segm_data + + model_kwargs = pop_signals_kwarg_if_not_needed(model.segment, model_kwargs) # Some models have `start_z_slice` kwarg try: lab = model.segment( - image, - frame_i=frame_i, - posData=posData, - start_z_slice=start_z_slice, - **model_kwargs + image, + frame_i=frame_i, + posData=posData, + start_z_slice=start_z_slice, + **model_kwargs, ) return lab except TypeError as err: - if str(err).find('unexpected keyword argument') == -1: + if str(err).find("unexpected keyword argument") == -1: # Raise error since it's not about the missing posData kwarg raise err - + # Some models have posData as kwarg and frame_i as second arg try: - lab = model.segment( - image, - frame_i=frame_i, - posData=posData, - **model_kwargs - ) + lab = model.segment(image, frame_i=frame_i, posData=posData, **model_kwargs) return lab except TypeError as err: - if str(err).find('unexpected keyword argument') == -1: + if str(err).find("unexpected keyword argument") == -1: # Raise error since it's not about the missing posData kwarg raise err - + # Some models have frame_i as second arg try: - lab = model.segment( - image, - frame_i=frame_i, - **model_kwargs - ) + lab = model.segment(image, frame_i=frame_i, **model_kwargs) return lab except TypeError as err: pass - + lab = model.segment(image, **model_kwargs) return lab + def filter_segm_objs_from_table_coords(lab, df): cols = [] if lab.ndim == 3: - cols = ['z'] - cols.extend(('y', 'x')) + cols = ["z"] + cols.extend(("y", "x")) coords = df[cols].values.T IDs_to_keep = lab[tuple(coords)] mask_to_keep = np.isin(lab, IDs_to_keep) @@ -1958,16 +1991,16 @@ def filter_segm_objs_from_table_coords(lab, df): filtered_lab[~mask_to_keep] = 0 return filtered_lab + def tracker_track( - segm_data, tracker, track_params, intensity_img=None, - logger_func=print - ): + segm_data, tracker, track_params, intensity_img=None, logger_func=print +): if intensity_img is not None: args_to_try = (tuple(), (intensity_img,)) else: args_to_try = (tuple(),) - kwargs_to_remove = ('', 'signals') + kwargs_to_remove = ("", "signals") for args, kwarg_to_remove in product(args_to_try, kwargs_to_remove): kwargs = track_params.copy() kwargs.pop(kwarg_to_remove, None) @@ -1975,17 +2008,18 @@ def tracker_track( tracked_video = tracker.track(segm_data, *args, **kwargs) return tracked_video except Exception as err: - is_unexpected_kwarg = (str(err).find( - "got an unexpected keyword argument 'signals'" - ) != -1) - is_missing_arg = (str(err).find( - "missing 1 required positional argument:" - ) != -1) + is_unexpected_kwarg = ( + str(err).find("got an unexpected keyword argument 'signals'") != -1 + ) + is_missing_arg = ( + str(err).find("missing 1 required positional argument:") != -1 + ) if is_unexpected_kwarg or is_missing_arg: continue else: raise err + def _relabel_sequential(segm_data): relabelled, fw, inv = skimage.segmentation.relabel_sequential(segm_data) newIDs = list(inv.in_values) @@ -1994,6 +2028,7 @@ def _relabel_sequential(segm_data): oldIDs.append(-1) return relabelled, oldIDs, newIDs + def _relabel_sequential_timelapse(segm_data): """Relabel IDs sequentially frame-by-frame @@ -2005,14 +2040,14 @@ def _relabel_sequential_timelapse(segm_data): Returns ------- 3-tuple of (numpy.ndarray, list, list) - First element is the relabelled segmentation data. + First element is the relabelled segmentation data. Second element is the list of the old IDs. Third element is the list of the new IDs. - """ + """ mapper_old_to_new_IDs = {-1: -1} relabelled = np.zeros_like(segm_data) lastID = 0 - pbar = tqdm(total=len(segm_data), ncols=100, unit=' frame') + pbar = tqdm(total=len(segm_data), ncols=100, unit=" frame") for frame_i, lab in enumerate(segm_data): if frame_i == 0: relab, oldIDs_i, newIDs_i = _relabel_sequential(lab) @@ -2020,7 +2055,7 @@ def _relabel_sequential_timelapse(segm_data): lastID = max(newIDs_i) relabelled[frame_i] = relab continue - + rp = skimage.measure.regionprops(lab) for obj in rp: newID = mapper_old_to_new_IDs.get(obj.label) @@ -2038,6 +2073,7 @@ def _relabel_sequential_timelapse(segm_data): newIDs = list(mapper_old_to_new_IDs.values()) return relabelled, oldIDs, newIDs + def relabel_sequential(segm_data, is_timelapse=False): if is_timelapse: relabelled, oldIDs, newIDs = _relabel_sequential_timelapse(segm_data) @@ -2045,48 +2081,42 @@ def relabel_sequential(segm_data, is_timelapse=False): relabelled, oldIDs, newIDs = _relabel_sequential(segm_data) return relabelled, oldIDs, newIDs + class CcaIntegrityChecker: def __init__(self, cca_df, lab, lab_IDs): self.lab = lab self.lab_IDs = lab_IDs self.cca_df = cca_df - self.cca_df_S = cca_df[cca_df['cell_cycle_stage'] == 'S'] - self.cca_df_G1 = cca_df[cca_df['cell_cycle_stage'] == 'G1'] + self.cca_df_S = cca_df[cca_df["cell_cycle_stage"] == "S"] + self.cca_df_G1 = cca_df[cca_df["cell_cycle_stage"] == "G1"] def get_num_mothers_and_buds_in_S(self): cca_df_S = self.cca_df_S - cca_df_S_buds = cca_df_S[cca_df_S['relationship'] == 'bud'] - cca_df_S_mothers = cca_df_S[cca_df_S['relationship'] == 'mother'] + cca_df_S_buds = cca_df_S[cca_df_S["relationship"] == "bud"] + cca_df_S_mothers = cca_df_S[cca_df_S["relationship"] == "mother"] num_buds = len(cca_df_S_buds) num_mothers = len(cca_df_S_mothers) return num_mothers, num_buds - + def get_mother_IDs_with_multiple_buds(self): cca_df_S = self.cca_df_S - cca_df_S_buds = cca_df_S[cca_df_S['relationship'] == 'bud'] - mothers_of_buds = cca_df_S_buds['relative_ID'] - mother_IDs_with_multiple_buds = ( - mothers_of_buds[mothers_of_buds.duplicated()] - ) + cca_df_S_buds = cca_df_S[cca_df_S["relationship"] == "bud"] + mothers_of_buds = cca_df_S_buds["relative_ID"] + mother_IDs_with_multiple_buds = mothers_of_buds[mothers_of_buds.duplicated()] return mother_IDs_with_multiple_buds.values - + def get_IDs_cycles_without_G1(self, global_cca_df): - global_cca_df_moths_hist_known = ( - global_cca_df[ - (global_cca_df['relationship'] == 'mother') - & (global_cca_df['is_history_known'] > 0) - ] - ) + global_cca_df_moths_hist_known = global_cca_df[ + (global_cca_df["relationship"] == "mother") + & (global_cca_df["is_history_known"] > 0) + ] grouped_cycles = global_cca_df_moths_hist_known.reset_index().groupby( - ['Cell_ID', 'generation_num'] + ["Cell_ID", "generation_num"] ) - G1_not_present_mask = ( - grouped_cycles['cell_cycle_stage'] - .agg(lambda x: ~x.eq('G1').any()) - ) - IDs_cycles_without_G1 = ( - G1_not_present_mask[G1_not_present_mask].index.to_list() + G1_not_present_mask = grouped_cycles["cell_cycle_stage"].agg( + lambda x: ~x.eq("G1").any() ) + IDs_cycles_without_G1 = G1_not_present_mask[G1_not_present_mask].index.to_list() return IDs_cycles_without_G1 def get_IDs_gen_num_will_divide_wrong(self, global_cca_df): @@ -2094,150 +2124,146 @@ def get_IDs_gen_num_will_divide_wrong(self, global_cca_df): global_cca_df ) return IDs_will_divide_wrong - + def get_bud_IDs_gen_num_nonzero(self): cca_df_S = self.cca_df_S - cca_df_S_buds = cca_df_S[cca_df_S['relationship'] == 'bud'] - bud_IDs_gen_num_nonzero = ( - cca_df_S_buds[cca_df_S_buds['generation_num'] != 0] - .index.to_list() - ) + cca_df_S_buds = cca_df_S[cca_df_S["relationship"] == "bud"] + bud_IDs_gen_num_nonzero = cca_df_S_buds[ + cca_df_S_buds["generation_num"] != 0 + ].index.to_list() return bud_IDs_gen_num_nonzero - + def get_moth_IDs_gen_num_non_greater_one(self): cca_df_S = self.cca_df_S - cca_df_S_moths = cca_df_S[cca_df_S['relationship'] == 'mother'] - moth_IDs_gen_num_non_greater_one = ( - cca_df_S_moths[cca_df_S_moths['generation_num'] < 1] - .index.to_list() - ) + cca_df_S_moths = cca_df_S[cca_df_S["relationship"] == "mother"] + moth_IDs_gen_num_non_greater_one = cca_df_S_moths[ + cca_df_S_moths["generation_num"] < 1 + ].index.to_list() return moth_IDs_gen_num_non_greater_one - + def get_buds_G1(self): cca_df_S = self.cca_df_S - cca_df_S_buds = cca_df_S[cca_df_S['relationship'] == 'bud'] - buds_G1 = ( - cca_df_S_buds[cca_df_S_buds['cell_cycle_stage'] == 'G1'] - .index.to_list() - ) + cca_df_S_buds = cca_df_S[cca_df_S["relationship"] == "bud"] + buds_G1 = cca_df_S_buds[ + cca_df_S_buds["cell_cycle_stage"] == "G1" + ].index.to_list() return buds_G1 - + def get_cell_S_rel_ID_zero(self): cca_df_S = self.cca_df_S - cell_S_rel_ID_zero = ( - cca_df_S[cca_df_S['relative_ID'] < 1] - .index.to_list() - ) + cell_S_rel_ID_zero = cca_df_S[cca_df_S["relative_ID"] < 1].index.to_list() return cell_S_rel_ID_zero - + def get_ID_rel_ID_mismatches(self): ID_rel_ID_mismatches = [] for row in self.cca_df_S.itertuples(): ID = row.Index relID = row.relative_ID - relID_of_relID = self.cca_df.at[relID, 'relative_ID'] - + relID_of_relID = self.cca_df.at[relID, "relative_ID"] + if relID_of_relID != ID: ID_rel_ID_mismatches.append((ID, relID, relID_of_relID)) - + return ID_rel_ID_mismatches def get_lonely_cells_in_S(self): lonely_cells_in_S = [] for row in self.cca_df_S.itertuples(): - ID = row.Index + ID = row.Index if row.relative_ID in self.lab_IDs: continue - + if ID not in self.lab_IDs: # Mother-bud pair gone entirely continue - + # ID is in S but its relative_ID does not exist in lab lonely_cells_in_S.append(ID) - + return lonely_cells_in_S + def cellpose_v3_run_denoise( - image, - run_params, - denoise_model=None, - init_params=None, - timelapse=False, - isZstack=False - ): + image, + run_params, + denoise_model=None, + init_params=None, + timelapse=False, + isZstack=False, +): if denoise_model is None: from cellacdc.segmenters.cellpose_v3 import _denoise + denoise_model = _denoise.CellposeDenoiseModel(**init_params) - - denoised_img = denoise_model.run(image, timelapse=timelapse,isZstack=isZstack, **run_params)# may have to give rgb stuff too! + + denoised_img = denoise_model.run( + image, timelapse=timelapse, isZstack=isZstack, **run_params + ) # may have to give rgb stuff too! return denoised_img -def closest_n_divisible_by_m(n, m) : + +def closest_n_divisible_by_m(n, m): # Find the quotient q = int(n / m) - + # 1st possible closest number n1 = m * q - + # 2nd possible closest number - if((n * m) > 0) : - n2 = (m * (q + 1)) - else : - n2 = (m * (q - 1)) - + if (n * m) > 0: + n2 = m * (q + 1) + else: + n2 = m * (q - 1) + # if true, then n1 is the required closest number - if (abs(n - n1) < abs(n - n2)) : + if abs(n - n1) < abs(n - n2): return n1 - - # else n2 is the required closest number + + # else n2 is the required closest number return n2 + def fucci_pipeline_executor_map(input, **filter_kwargs): frame_i, (ch1_img, ch2_img) = input - - ch1_img = skimage.exposure.rescale_intensity( - ch1_img, out_range=(0, 0.5) - ) - ch2_img = skimage.exposure.rescale_intensity( - ch2_img, out_range=(0, 0.5) - ) - + + ch1_img = skimage.exposure.rescale_intensity(ch1_img, out_range=(0, 0.5)) + ch2_img = skimage.exposure.rescale_intensity(ch2_img, out_range=(0, 0.5)) + sum_img = ch1_img + ch2_img - + processed_img = preprocess.fucci_filter(sum_img, **filter_kwargs) - + return frame_i, processed_img + def preprocess_exceutor_map( - input: Tuple[int, np.ndarray], - recipe: List[Dict[str, Any]]=None, - ): + input: Tuple[int, np.ndarray], + recipe: List[Dict[str, Any]] = None, +): if recipe is None: return input - + frame_i, image = input if image.ndim == 3: preprocessed_image = preprocess_zstack_from_recipe(image, recipe) else: preprocessed_image = preprocess_image_from_recipe(image, recipe) - + return frame_i, preprocessed_image + def preprocess_image_from_recipe_multithread( - image: np.ndarray, - recipe: List[Dict[str, Any]], - n_threads: int=None - ): + image: np.ndarray, recipe: List[Dict[str, Any]], n_threads: int = None +): preprocessed_image = image for step in recipe: - method = step['method'] - func = PREPROCESS_MAPPER[method]['function'] - kwargs = step['kwargs'] + method = step["method"] + func = PREPROCESS_MAPPER[method]["function"] + kwargs = step["kwargs"] argspecs = inspect.getfullargspec(func) is_func_time_capable = False for arg in argspecs.args: - if arg == 'apply_to_all_frames': + if arg == "apply_to_all_frames": is_func_time_capable = True break @@ -2250,10 +2276,7 @@ def preprocess_image_from_recipe_multithread( pbar = tqdm(total=num_frames, ncols=100) with ThreadPoolExecutor(max_workers=n_threads) as executor: iterable = enumerate(preprocessed_image) - func = partial( - preprocess_exceutor_map, - recipe=(step,) - ) + func = partial(preprocess_exceutor_map, recipe=(step,)) futures = {executor.submit(func, arg) for arg in iterable} for future in as_completed(futures): try: @@ -2264,28 +2287,27 @@ def preprocess_image_from_recipe_multithread( printl(e) raise e pbar.close() - + return preprocessed_image + def combine_channels_multithread( - steps: Dict[str, Dict[str, Any]], - formula: str, - images_paths: List[str], - keep_input_data_type: bool, - save_filepaths: List[str]=None, - n_threads: int=None, - signals=None, - logger_func: Callable=None, - output_as_segm: bool = False, - ): + steps: Dict[str, Dict[str, Any]], + formula: str, + images_paths: List[str], + keep_input_data_type: bool, + save_filepaths: List[str] = None, + n_threads: int = None, + signals=None, + logger_func: Callable = None, + output_as_segm: bool = False, +): with ThreadPoolExecutor(max_workers=n_threads) as executor: if signals: signals.initProgressBar.emit(len(images_paths)) else: - pbar = tqdm( - total=len(images_paths), ncols=100, desc='Combining channels' - ) - + pbar = tqdm(total=len(images_paths), ncols=100, desc="Combining channels") + func = partial( combine_channels_executor_map, keep_input_data_type=keep_input_data_type, @@ -2303,21 +2325,21 @@ def combine_channels_multithread( else: pbar.update() -def combine_channels_multithread_return_imgs( - steps: Dict[str, Dict[str, Any]], - data: list['load.loadData'], - keep_input_data_type: bool, - keys: List[Tuple[Union[int, None], Union[int, None], Union[int, None]]], - n_threads: int=None, - signals=None, - logger_func: Callable=None, - output_as_segm: bool = False, - formula: str = None, - ): +def combine_channels_multithread_return_imgs( + steps: Dict[str, Dict[str, Any]], + data: list["load.loadData"], + keep_input_data_type: bool, + keys: List[Tuple[Union[int, None], Union[int, None], Union[int, None]]], + n_threads: int = None, + signals=None, + logger_func: Callable = None, + output_as_segm: bool = False, + formula: str = None, +): total = len(keys) - + output_imgs = [None] * total keys_out = [0] * total res_i = 0 @@ -2327,7 +2349,7 @@ def combine_channels_multithread_return_imgs( if signals: signals.initProgressBar.emit(total) else: - pbar = tqdm(total=len(total), ncols=100, desc='Combining channels') + pbar = tqdm(total=len(total), ncols=100, desc="Combining channels") func = partial( combine_channels_executor_map_return_img, data=data, @@ -2364,41 +2386,44 @@ def combine_channels_multithread_return_imgs( return output_imgs, keys_out + def combine_channels_executor_map(args, **kwargs): images_path, save_filepath = args - kwargs['save_filepath'] = save_filepath - kwargs['images_path'] = images_path + kwargs["save_filepath"] = save_filepath + kwargs["images_path"] = images_path return combine_channels_func(**kwargs) + def combine_channels_executor_map_return_img(args, **kwargs): key = args - kwargs['key'] = key + kwargs["key"] = key return combine_channels_func(**kwargs) + def _combine_channels_multiplier_apply(binarize, input_img): - if binarize == 'binarize': - input_img = (input_img > 0) - elif binarize == 'inverse binarize': + if binarize == "binarize": + input_img = input_img > 0 + elif binarize == "inverse binarize": input_img = ~(input_img > 0) return input_img + def _get_img_from_data_key(data, key, num_dim, seg=False): - n_dim_data = data.ndim - 1 # - 1 dim for x y + n_dim_data = data.ndim - 1 # - 1 dim for x y n_dim_key = len(key) if seg and n_dim_key == n_dim_data + 1: # here a 2D segmentation is used for 3D image return data[key[1]] - if num_dim == 3: # t x y + if num_dim == 3: # t x y return data[key[1]] - elif num_dim == 4: # t z x y + elif num_dim == 4: # t z x y return data[key[1]][key[2]] - elif num_dim == 2: # z x y, but t is always there + elif num_dim == 2: # z x y, but t is always there return data[0] else: - raise ValueError( - f'Invalid number of dimensions in img_data. {num_dim}' - ) - + raise ValueError(f"Invalid number of dimensions in img_data. {num_dim}") + + def _log_printl_fallback(txt, logger_func): if logger_func is not None: try: @@ -2408,7 +2433,8 @@ def _log_printl_fallback(txt, logger_func): pass else: printl(txt) - + + def _add_missing_dims(segm, target_shape, use_broadcast=False): """ Expand segmentation by replicating existing data along missing dims. @@ -2426,7 +2452,7 @@ def _add_missing_dims(segm, target_shape, use_broadcast=False): segm_expanded : np.ndarray text : str """ - text = '' + text = "" if segm.shape == target_shape: return segm, text @@ -2434,8 +2460,8 @@ def _add_missing_dims(segm, target_shape, use_broadcast=False): # 2D -> 3D (Y,X -> Z,Y,X) if segm.ndim == 2 and len(target_shape) == 3: text = ( - 'The segmentation mask is 2D but the image data is 3D. ' - 'Replicating mask across Z.' + "The segmentation mask is 2D but the image data is 3D. " + "Replicating mask across Z." ) y, x = segm.shape z = target_shape[0] @@ -2452,8 +2478,8 @@ def _add_missing_dims(segm, target_shape, use_broadcast=False): # 3D -> 4D (T,Y,X -> T,Z,Y,X) if segm.ndim == 3 and len(target_shape) == 4: text = ( - 'The segmentation mask is 2Dt but the image data is 3Dt. ' - 'Replicating mask across Z.' + "The segmentation mask is 2Dt but the image data is 3Dt. " + "Replicating mask across Z." ) t, y, x = segm.shape z = target_shape[1] @@ -2467,26 +2493,27 @@ def _add_missing_dims(segm, target_shape, use_broadcast=False): return segm_expanded, text - raise ValueError( - f'Invalid shape. segm: {segm.shape}, target: {target_shape}' - ) - + raise ValueError(f"Invalid shape. segm: {segm.shape}, target: {target_shape}") + + def _verify_shape_ndim(img_data, target_dims, target_shape, is_segm=False): def _shape_mismatch_error(indices, img_data, target_shape): mismatches = [ - f' axis {i}: got {img_data.shape[idx]}, expected {target_shape[i]}' + f" axis {i}: got {img_data.shape[idx]}, expected {target_shape[i]}" for idx, i in indices if img_data.shape[idx] != target_shape[i] ] if mismatches: raise ValueError( - f'Shape mismatch:\n' + '\n'.join(mismatches) + '\n' - f'img shape={img_data.shape}, target shape={target_shape}' + f"Shape mismatch:\n" + "\n".join(mismatches) + "\n" + f"img shape={img_data.shape}, target shape={target_shape}" ) if img_data.ndim == target_dims: # Check all axes directly - _shape_mismatch_error([(i, i) for i in range(target_dims)], img_data, target_shape) + _shape_mismatch_error( + [(i, i) for i in range(target_dims)], img_data, target_shape + ) elif is_segm and img_data.ndim + 1 == target_dims: if target_dims == 3: @@ -2497,15 +2524,16 @@ def _shape_mismatch_error(indices, img_data, target_shape): _shape_mismatch_error([(0, 0), (1, 2), (2, 3)], img_data, target_shape) else: raise ValueError( - f'Invalid segmentation mask dimensions: ' - f'got {img_data.ndim}D mask for {target_dims}D target.' + f"Invalid segmentation mask dimensions: " + f"got {img_data.ndim}D mask for {target_dims}D target." ) else: raise ValueError( - f'Invalid image dimensions: ' - f'got {img_data.ndim}D data, expected {target_dims}D.' + f"Invalid image dimensions: " + f"got {img_data.ndim}D data, expected {target_dims}D." ) + def _update_target_shape_target_dims(target_dims, target_shape, img_data): if target_dims < img_data.ndim: target_dims_old = target_dims @@ -2516,7 +2544,7 @@ def _update_target_shape_target_dims(target_dims, target_shape, img_data): img_shape = [0, 0] + img_shape elif img_data.ndim == 3: img_shape = img_shape[:1] + [0] + img_shape[1:] - + try: for i in range(len(target_shape)): if target_shape[i] < img_shape[i]: @@ -2526,64 +2554,66 @@ def _update_target_shape_target_dims(target_dims, target_shape, img_data): raise err return target_dims, target_shape - + def combine_channels_func( - steps: Dict[str, Dict[str, Any]], - keep_input_data_type: bool, - save_filepath: str=None, - return_img: bool=False, - logger_func: Callable=None, - images_path: str = None, - key: str = None, - data = None, - output_as_segm: bool = False, - formula: str = None, - ):# -> tuple[Any | ndarray, str, str | Any] | None: + steps: Dict[str, Dict[str, Any]], + keep_input_data_type: bool, + save_filepath: str = None, + return_img: bool = False, + logger_func: Callable = None, + images_path: str = None, + key: str = None, + data=None, + output_as_segm: bool = False, + formula: str = None, +): # -> tuple[Any | ndarray, str, str | Any] | None: if not save_filepath and not return_img: - raise ValueError('Either save_filepath must be provided or return_img must be true') - + raise ValueError( + "Either save_filepath must be provided or return_img must be true" + ) + provided = sum(x is not None for x in (data, key)) provided += return_img if provided not in (0, 3): - raise ValueError('return_img, data, and key must all be provided together or not at all') - + raise ValueError( + "return_img, data, and key must all be provided together or not at all" + ) + fluo_ch_data_list = dict() segm_ch_data_list = dict() - - channel_names = [step['channel'] for step in steps.values()] + + channel_names = [step["channel"] for step in steps.values()] channel_keys = steps.keys() - segm_channels, fluo_channel_names, current_segm = myutils.separate_fluo_segment_channels(channel_names) + segm_channels, fluo_channel_names, current_segm = ( + myutils.separate_fluo_segment_channels(channel_names) + ) original_dtype = None - + target_dims = 0 target_shape = [0, 0, 0, 0] if data is None: for channel in fluo_channel_names: - ch_filepath = load.get_filepath_from_endname( - images_path, channel - ) + ch_filepath = load.get_filepath_from_endname(images_path, channel) ch_image_data = load.load_image_file(ch_filepath) if original_dtype is None: original_dtype = ch_image_data.dtype - + ch_image_data = myutils.img_to_float(ch_image_data) target_dims, target_shape = _update_target_shape_target_dims( - target_dims, target_shape, ch_image_data + target_dims, target_shape, ch_image_data ) fluo_ch_data_list[channel] = ch_image_data for channel in segm_channels: - ch_filepath = load.get_filepath_from_endname( - images_path, channel - ) + ch_filepath = load.get_filepath_from_endname(images_path, channel) ch_image_data = load.load_image_file(ch_filepath) if original_dtype is None: original_dtype = ch_image_data.dtype ch_image_data = ch_image_data.astype(np.uint32) target_dims, target_shape = _update_target_shape_target_dims( - target_dims, target_shape, ch_image_data + target_dims, target_shape, ch_image_data ) segm_ch_data_list[channel] = ch_image_data else: @@ -2593,7 +2623,9 @@ def combine_channels_func( n_dim -= 1 # if posData.SizeT == 1: # actually t is always there, we only need to subtract for curr. segm # n_dim -= 1 - is_2D_segm_on_3D = posData.SizeZ != 1 and posData.allData_li[0]['labels'].ndim == 2 + is_2D_segm_on_3D = ( + posData.SizeZ != 1 and posData.allData_li[0]["labels"].ndim == 2 + ) fluo_data_dict = posData.fluo_data_dict segm_data_dict = posData.ol_labels_data imgs_path = posData.images_path @@ -2604,8 +2636,10 @@ def combine_channels_func( ) channel_full_name = pathlib.Path(channel_path).stem # remove the file extension - - channel_img_data = _get_img_from_data_key(fluo_data_dict[channel_full_name], key, n_dim) + + channel_img_data = _get_img_from_data_key( + fluo_data_dict[channel_full_name], key, n_dim + ) if original_dtype is None: original_dtype = channel_img_data.dtype channel_img_data_float = myutils.img_to_float(channel_img_data) @@ -2614,7 +2648,9 @@ def combine_channels_func( ) fluo_ch_data_list[channel] = channel_img_data_float for channel in segm_channels: - channel_img_data = _get_img_from_data_key(segm_data_dict[channel], key, n_dim, seg=True) + channel_img_data = _get_img_from_data_key( + segm_data_dict[channel], key, n_dim, seg=True + ) if original_dtype is None: original_dtype = channel_img_data.dtype channel_img_data_int = channel_img_data.astype(np.uint32) @@ -2622,22 +2658,24 @@ def combine_channels_func( target_dims, target_shape, channel_img_data_int ) segm_ch_data_list[channel] = channel_img_data_int - if current_segm: # here we dont need to get/appply target dim, as we already ignore z slice key if segm is 2D and image 3D (time is always treated differently!) - if posData.SizeT == 1: # actually t is always there, we only need to subtract for curr. segm + if current_segm: # here we dont need to get/appply target dim, as we already ignore z slice key if segm is 2D and image 3D (time is always treated differently!) + if ( + posData.SizeT == 1 + ): # actually t is always there, we only need to subtract for curr. segm n_dim -= 1 if posData.frame_i != key[1]: if n_dim == 4 and not is_2D_segm_on_3D: - channel_img_data = posData.allData_li[key[1]]['labels'][key[2]] + channel_img_data = posData.allData_li[key[1]]["labels"][key[2]] elif n_dim == 4 and is_2D_segm_on_3D: - channel_img_data = posData.allData_li[key[1]]['labels'] + channel_img_data = posData.allData_li[key[1]]["labels"] elif n_dim == 3 and posData.SizeZ == 1: - channel_img_data = posData.allData_li[key[1]]['labels'] + channel_img_data = posData.allData_li[key[1]]["labels"] elif n_dim == 3 and posData.SizeT == 1 and not is_2D_segm_on_3D: - channel_img_data = posData.allData_li[0]['labels'][key[2]] + channel_img_data = posData.allData_li[0]["labels"][key[2]] elif n_dim == 3 and posData.SizeT == 1 and is_2D_segm_on_3D: - channel_img_data = posData.allData_li[0]['labels'] + channel_img_data = posData.allData_li[0]["labels"] else: - channel_img_data = posData.allData_li[0]['labels'] + channel_img_data = posData.allData_li[0]["labels"] else: if n_dim == 4 and not is_2D_segm_on_3D: channel_img_data = posData.lab[key[2]] @@ -2653,14 +2691,14 @@ def combine_channels_func( target_dims, target_shape = _update_target_shape_target_dims( target_dims, target_shape, channel_img_data_int ) - segm_ch_data_list['current segm.'] = channel_img_data_int - + segm_ch_data_list["current segm."] = channel_img_data_int + target_shape_new = [] for dim in target_shape: if dim == 0: continue target_shape_new.append(dim) - + target_shape = tuple(target_shape_new) # _log_printl_fallback(f'target shape: {target_shape}', logger_func) for i, ch in zip(channel_keys, channel_names): @@ -2673,31 +2711,32 @@ def combine_channels_func( ch_image_data, text = _add_missing_dims(ch_image_data, target_shape) if text: _log_printl_fallback(text, logger_func) - _verify_shape_ndim(ch_image_data, target_dims, target_shape, is_segm=False) # false since we already expanded + _verify_shape_ndim( + ch_image_data, target_dims, target_shape, is_segm=False + ) # false since we already expanded segm_ch_data_list[ch] = ch_image_data else: raise ValueError(f'Channel "{ch}" not found.') - if steps[i]['channel'] != ch: - raise ValueError(f'Channel "{ch}" not found.') - steps[i]['channel_data'] = ch_image_data - + if steps[i]["channel"] != ch: + raise ValueError(f'Channel "{ch}" not found.') + steps[i]["channel_data"] = ch_image_data + for i, step_info in steps.items(): - binarize = step_info['binarize'] - steps[i]['channel_data'] = _combine_channels_multiplier_apply( - binarize, step_info['channel_data'] + binarize = step_info["binarize"] + steps[i]["channel_data"] = _combine_channels_multiplier_apply( + binarize, step_info["channel_data"] ) - norm_min, norm_max = step_info['min_val'], step_info['max_val'] + norm_min, norm_max = step_info["min_val"], step_info["max_val"] # use rescale_intensity to normalize if norm_min == 0 and norm_max == 1: - continue # cases where either the fields where disabled/reset or default, where we already normalized - steps[i]['channel_data'] = skimage.exposure.rescale_intensity( - steps[i]['channel_data'], - out_range=(norm_min, norm_max) + continue # cases where either the fields where disabled/reset or default, where we already normalized + steps[i]["channel_data"] = skimage.exposure.rescale_intensity( + steps[i]["channel_data"], out_range=(norm_min, norm_max) ) - - if formula != '': - input_img_data = {step['name']: step['channel_data'] for step in steps.values()} - + + if formula != "": + input_img_data = {step["name"]: step["channel_data"] for step in steps.values()} + symbols = {name: sp.Symbol(name) for name in input_img_data} expr = sp.sympify(formula, locals=symbols) @@ -2705,173 +2744,166 @@ def combine_channels_func( func = sp.lambdify( [symbols[v] for v in used_vars], # fixed order! expr, - modules="numpy" + modules="numpy", ) args = [input_img_data[v] for v in used_vars] output_img = func(*args) else: key0 = list(steps.keys())[0] - output_img = steps[key0]['channel_data'] + output_img = steps[key0]["channel_data"] if not output_as_segm: - output_img = skimage.exposure.rescale_intensity( - output_img, out_range=(0, 1) - ) - - txt = '' + output_img = skimage.exposure.rescale_intensity(output_img, out_range=(0, 1)) + + txt = "" if keep_input_data_type and not output_as_segm: try: - output_img = myutils.convert_to_dtype( - output_img, original_dtype - ) - method = 'cellacdc.myutils.convert_to_dtype' - warning = 'safe' - prefix = '' + output_img = myutils.convert_to_dtype(output_img, original_dtype) + method = "cellacdc.myutils.convert_to_dtype" + warning = "safe" + prefix = "" except Exception as err: dtype_info = np.iinfo(original_dtype) dtype_max = dtype_info.max dtype_min = dtype_info.min is_in_bounds = ( - output_img.max() <= dtype_max - and output_img.min() >= dtype_min + output_img.max() <= dtype_max and output_img.min() >= dtype_min ) if is_in_bounds: output_img = output_img.astype(original_dtype) - method = 'output_img.astype(original_dtype)' - warning = 'safe' # if weights were set correctly' - prefix = '[WARNING]: ' + method = "output_img.astype(original_dtype)" + warning = "safe" # if weights were set correctly' + prefix = "[WARNING]: " else: output_img = skimage.exposure.rescale_intensity( output_img, out_range=(dtype_min, dtype_max) ) output_img = output_img.astype(original_dtype) method = ( - 'skimage.exposure.rescale_intensity ' - '-> output_img.astype (original_dtype)' + "skimage.exposure.rescale_intensity " + "-> output_img.astype (original_dtype)" ) - warning = '!RESCALING! the image data' - prefix = '[WARNING]: ' + warning = "!RESCALING! the image data" + prefix = "[WARNING]: " txt = ( - f'{prefix}Converted output image to {original_dtype} ' - f'using {method}, which is {warning}' + f"{prefix}Converted output image to {original_dtype} " + f"using {method}, which is {warning}" ) if not return_img: _log_printl_fallback(txt, logger_func) elif output_as_segm: - output_img[output_img<0] = 0 + output_img[output_img < 0] = 0 output_img = output_img.astype(np.uint32) - + if return_img: return output_img, key, txt - - txt = f'Saving combined {"segmentation" if output_as_segm else "image"} to {save_filepath}' + + txt = f"Saving combined {'segmentation' if output_as_segm else 'image'} to {save_filepath}" _log_printl_fallback(txt, logger_func) - - io.save_image_data( # handles saving img and segm + io.save_image_data( # handles saving img and segm save_filepath, output_img ) return None + def get_selected_channels(steps): selected_channel = set() for step in steps.values(): - ch = step['channel'] - if ch == 'current segm.': + ch = step["channel"] + if ch == "current segm.": continue selected_channel.add(ch) - + return selected_channel + def split_segm_masks_mother_bud_line( - cells_segm_data, segm_data_to_split, acdc_df, - debug=False - ): - acdc_df = acdc_df.set_index(['frame_i', 'Cell_ID']) + cells_segm_data, segm_data_to_split, acdc_df, debug=False +): + acdc_df = acdc_df.set_index(["frame_i", "Cell_ID"]) split_segm_away = np.zeros_like(segm_data_to_split) split_segm_close = np.zeros_like(segm_data_to_split) - + pbar = tqdm(total=len(cells_segm_data), ncols=100, position=1, leave=False) for frame_i, lab in enumerate(cells_segm_data): rp = skimage.measure.regionprops(lab) - rp_mapper = {obj.label:obj for obj in rp} + rp_mapper = {obj.label: obj for obj in rp} for obj in rp: try: - ccs = acdc_df.at[(frame_i, obj.label), 'cell_cycle_stage'] + ccs = acdc_df.at[(frame_i, obj.label), "cell_cycle_stage"] except Exception as err: continue - - if ccs != 'S': + + if ccs != "S": continue - + try: - relationship = acdc_df.at[(frame_i, obj.label), 'relationship'] + relationship = acdc_df.at[(frame_i, obj.label), "relationship"] except Exception as err: continue - - if relationship == 'bud': + + if relationship == "bud": continue - - bud_ID = int(acdc_df.at[(frame_i, obj.label), 'relative_ID']) + + bud_ID = int(acdc_df.at[(frame_i, obj.label), "relative_ID"]) obj_bud = rp_mapper[bud_ID] - + moth_ID = obj.label yc_m, xc_m = obj.centroid yc_b, xc_b = obj_bud.centroid - - slope_mb = (yc_b - yc_m)/(xc_b - yc_b) + + slope_mb = (yc_b - yc_m) / (xc_b - yc_b) if slope_mb != 0: - slope_perp = -1/slope_mb - interc_perp = yc_m - xc_m*slope_perp + slope_perp = -1 / slope_mb + interc_perp = yc_m - xc_m * slope_perp else: slope_perp = np.inf interc_perp = np.nan - + ref_p1, ref_p2 = get_split_line_ref_points_img( lab, slope_perp, interc_perp, xc_m, yc_m ) - + if debug: from cellacdc import _debug + _debug.split_segm_masks_mother_bud_line( lab, obj, obj_bud, ref_p1, ref_p2 ) - + for z, lab_split in enumerate(segm_data_to_split[frame_i]): - lab_split_yy, lab_split_xx = np.nonzero(lab_split==obj.label) + lab_split_yy, lab_split_xx = np.nonzero(lab_split == obj.label) if len(lab_split_yy) == 0: continue - + query_points = np.column_stack((lab_split_xx, lab_split_yy)) close_to_bud_mask = classify_points_plane_split_by_line( ref_p1, ref_p2, query_points, (xc_b, yc_b) ) - + split_close_yy = lab_split_yy[close_to_bud_mask] split_close_xx = lab_split_xx[close_to_bud_mask] - - split_segm_close[frame_i, z, split_close_yy, split_close_xx] = ( - obj.label - ) - + + split_segm_close[frame_i, z, split_close_yy, split_close_xx] = obj.label + split_away_yy = lab_split_yy[~close_to_bud_mask] split_away_xx = lab_split_xx[~close_to_bud_mask] - - split_segm_away[frame_i, z, split_away_yy, split_away_xx] = ( - obj.label - ) - + + split_segm_away[frame_i, z, split_away_yy, split_away_xx] = obj.label + pbar.update() - pbar.close() - + pbar.close() + return split_segm_close, split_segm_away + def classify_points_plane_split_by_line( - p1, p2, query_points: np.ndarray, relative_to_p - ): + p1, p2, query_points: np.ndarray, relative_to_p +): """Classify points on plane crossed by a line connecting p1 and p2 relative to `relative_to_p` point @@ -2880,24 +2912,24 @@ def classify_points_plane_split_by_line( p1 : (x, y) of floats First point of the line p2 : (x, y) of floats - Second point + Second point query_points : (N, 2) np.ndarray (x, y) coordinates of the points to classify - + References ---------- https://stackoverflow.com/questions/45766534/finding-cross-product-to-find-points-above-below-a-line-in-matplotlib - """ + """ relative_p_arr = np.array([relative_to_p]) a = np.array(p1) b = np.array(p2) - - class_relative_p = (np.cross(relative_p_arr-a, b-a) <= 0).astype(int)[0] - class_query_points = (np.cross(query_points-a, b-a) <= 0).astype(int) + + class_relative_p = (np.cross(relative_p_arr - a, b - a) <= 0).astype(int)[0] + class_query_points = (np.cross(query_points - a, b - a) <= 0).astype(int) query_points_mask = class_query_points == class_relative_p - - return query_points_mask - + + return query_points_mask + def get_split_line_ref_points_img(img, slope, interc, xc, yc): Y, X = img.shape @@ -2913,46 +2945,48 @@ def get_split_line_ref_points_img(img, slope, interc, xc, yc): y_ref1 = yc else: y0 = 0 - x0 = y0 - interc/slope - + x0 = y0 - interc / slope + x1 = X - y1 = slope*x1 + interc - + y1 = slope * x1 + interc + x2 = 0 y2 = interc - + y3 = Y - x3 = (y3 - interc)/slope - + x3 = (y3 - interc) / slope + if x0 < X: x_ref_0 = x0 y_ref_0 = y0 else: x_ref_0 = x1 y_ref_0 = y1 - + if x3 > 0: x_ref1 = x3 y_ref1 = y3 else: x_ref1 = x2 y_ref1 = y2 - + return (x_ref_0, y_ref_0), (x_ref1, y_ref1) + def _compute_obj_to_all_objs_contour_dist_pairs( - input, all_objs_contours_arr=None, all_contours=None, pbar=None - ): + input, all_objs_contours_arr=None, all_contours=None, pbar=None +): j, other_obj = input - other_obj_contours = all_contours[(other_obj.label, 'None', False, False)] + other_obj_contours = all_contours[(other_obj.label, "None", False, False)] min_distances_to_other = nearest_points_objects( all_objs_contours_arr, other_obj_contours - ) + ) return other_obj.label, min_distances_to_other + def _compute_all_obj_to_obj_contour_dist_pairs( - all_contours: dict, rp, prev_rp=None, restrict_search=True - ): + all_contours: dict, rp, prev_rp=None, restrict_search=True +): if prev_rp is not None: prev_IDs = set([obj.label for obj in prev_rp]) new_IDs = set([obj.label for obj in rp if obj.label not in prev_IDs]) @@ -2963,36 +2997,32 @@ def _compute_all_obj_to_obj_contour_dist_pairs( current_rp = rp other_rp = rp num_cols = len(current_rp) - + max_distance = np.inf if restrict_search: - max_distance = 3*np.max([obj.major_axis_length for obj in rp]) - + max_distance = 3 * np.max([obj.major_axis_length for obj in rp]) + calculated_pairs = {} num_rows = len(current_rp) num_objs = len(rp) IDs = [obj.label for obj in rp] dist_matrix_df = pd.DataFrame( - index=IDs, - columns=IDs, - data=np.full((num_objs, num_objs), np.inf) - ) - len_longest_contour = np.max( - [len(contours) for contours in all_contours.values()] + index=IDs, columns=IDs, data=np.full((num_objs, num_objs), np.inf) ) + len_longest_contour = np.max([len(contours) for contours in all_contours.values()]) all_objs_contours_arr = np.full((num_rows, len_longest_contour, 2), np.nan) current_rp_mapper = {} for o, obj in enumerate(current_rp): - obj_contours = all_contours[(obj.label, 'None', False, False)] - all_objs_contours_arr[o, :len(obj_contours)] = obj_contours + obj_contours = all_contours[(obj.label, "None", False, False)] + all_objs_contours_arr[o, : len(obj_contours)] = obj_contours current_rp_mapper[o] = obj - - pbar = tqdm(total=num_rows*num_cols, ncols=100, leave=False) + + pbar = tqdm(total=num_rows * num_cols, ncols=100, leave=False) with ThreadPoolExecutor() as executor: iterable = enumerate(other_rp) - + func = partial( - _compute_obj_to_all_objs_contour_dist_pairs, + _compute_obj_to_all_objs_contour_dist_pairs, all_objs_contours_arr=all_objs_contours_arr, all_contours=all_contours, pbar=pbar, @@ -3005,39 +3035,46 @@ def _compute_all_obj_to_obj_contour_dist_pairs( return dist_matrix_df + def convexity_defects(img, eps_percent): img = img.astype(np.uint8) - contours, _ = cv2.findContours(img,2,1) + contours, _ = cv2.findContours(img, 2, 1) cnt = contours[0] - cnt = cv2.approxPolyDP(cnt,eps_percent*cv2.arcLength(cnt,True),True) # see https://www.programcreek.com/python/example/89457/cv22.convexityDefects - hull = cv2.convexHull(cnt,returnPoints = False) # see https://opencv-python-tutroals.readthedocs.io/en/latest/py_tutorials/py_imgproc/py_contours/py_contours_more_functions/py_contours_more_functions.html - defects = cv2.convexityDefects(cnt,hull) # see https://opencv-python-tutroals.readthedocs.io/en/latest/py_tutorials/py_imgproc/py_contours/py_contours_more_functions/py_contours_more_functions.html + cnt = cv2.approxPolyDP( + cnt, eps_percent * cv2.arcLength(cnt, True), True + ) # see https://www.programcreek.com/python/example/89457/cv22.convexityDefects + hull = cv2.convexHull( + cnt, returnPoints=False + ) # see https://opencv-python-tutroals.readthedocs.io/en/latest/py_tutorials/py_imgproc/py_contours/py_contours_more_functions/py_contours_more_functions.html + defects = cv2.convexityDefects( + cnt, hull + ) # see https://opencv-python-tutroals.readthedocs.io/en/latest/py_tutorials/py_imgproc/py_contours/py_contours_more_functions/py_contours_more_functions.html return cnt, defects -def split_connected_components(lab, rp=None, max_ID=None): + +def split_connected_components(lab, rp=None, max_ID=None): if rp is None: lab = skimage.measure.regionprops(lab) - + if max_ID is None: max_ID = max([obj.label for obj in rp], default=1) - + split_occured = False for obj in rp: lab_obj = skimage.measure.label(obj.image) rp_lab_obj = skimage.measure.regionprops(lab_obj) - if len(rp_lab_obj)<=1: + if len(rp_lab_obj) <= 1: continue lab_obj += max_ID - _slice = obj.slice # self.getObjSlice(obj.slice) - _objMask = obj.image # self.getObjImage(obj.image) + _slice = obj.slice # self.getObjSlice(obj.slice) + _objMask = obj.image # self.getObjImage(obj.image) lab[_slice][_objMask] = lab_obj[_objMask] split_occured = True max_ID += 1 return split_occured -def split_along_convexity_defects( - ID, lab, max_ID, max_i=1, eps_percent=0.01 - ): + +def split_along_convexity_defects(ID, lab, max_ID, max_i=1, eps_percent=0.01): lab_ID_bool = lab == ID # First try separating by labelling lab_ID = lab_ID_bool.astype(int) @@ -3058,20 +3095,18 @@ def split_along_convexity_defects( if len(defects) != 2: return lab, success, [] - defects_points = [0]*len(defects) + defects_points = [0] * len(defects) for i, defect in enumerate(defects): - s,e,f,d = defect[0] - x,y = tuple(cnt[f][0]) - defects_points[i] = (y,x) + s, e, f, d = defect[0] + x, y = tuple(cnt[f][0]) + defects_points[i] = (y, x) (r0, c0), (r1, c1) = defects_points rr, cc, _ = skimage.draw.line_aa(r0, c0, r1, c1) sep_bud_img = np.copy(lab_ID_bool) sep_bud_img[rr, cc] = False - - sep_bud_label = skimage.measure.label( - sep_bud_img, connectivity=2 - ) - + + sep_bud_label = skimage.measure.label(sep_bud_img, connectivity=2) + rp_sep = skimage.measure.regionprops(sep_bud_label) IDs_sep = [obj.label for obj in rp_sep] areas = [obj.area for obj in rp_sep] @@ -3080,16 +3115,16 @@ def split_along_convexity_defects( orig_sblab = np.copy(sep_bud_label) # sep_bud_label = np.zeros_like(sep_bud_label) ID1 = ID - ID2 = max_ID+max_i - sep_bud_label[orig_sblab==curr_ID_moth] = ID1 - sep_bud_label[orig_sblab==curr_ID_bud] = ID2 + ID2 = max_ID + max_i + sep_bud_label[orig_sblab == curr_ID_moth] = ID1 + sep_bud_label[orig_sblab == curr_ID_bud] = ID2 splittedIDs = [ID1, ID2] # sep_bud_label *= (max_ID+max_i) temp_sep_bud_lab = sep_bud_label.copy() for r, c in zip(rr, cc): if lab_ID_bool[r, c]: nearest_ID = nearest_nonzero_2D(sep_bud_label, r, c) - temp_sep_bud_lab[r,c] = nearest_ID + temp_sep_bud_lab[r, c] = nearest_ID sep_bud_label = temp_sep_bud_lab sep_bud_label_mask = sep_bud_label != 0 # plt.imshow_tk(sep_bud_label, dots_coords=np.asarray(defects_points)) @@ -3098,25 +3133,25 @@ def split_along_convexity_defects( success = True return lab, success, splittedIDs + def validate_multidimensional_recipe( - recipe: List[Dict[str, Any]], - apply_to_all_zslices=False, - apply_to_all_frames=False - ): + recipe: List[Dict[str, Any]], apply_to_all_zslices=False, apply_to_all_frames=False +): for step in recipe: - method = step['method'] - func = PREPROCESS_MAPPER[method]['function'] - kwargs = step['kwargs'] - + method = step["method"] + func = PREPROCESS_MAPPER[method]["function"] + kwargs = step["kwargs"] + argspecs = inspect.getfullargspec(func) for arg in argspecs.args: - if arg == 'apply_to_all_frames': - kwargs['apply_to_all_frames'] = apply_to_all_frames - if arg == 'apply_to_all_zslices': - kwargs['apply_to_all_zslices'] = apply_to_all_zslices - + if arg == "apply_to_all_frames": + kwargs["apply_to_all_frames"] = apply_to_all_frames + if arg == "apply_to_all_zslices": + kwargs["apply_to_all_zslices"] = apply_to_all_zslices + return recipe + def insert_missing_object(lab_dst, obj, all_dst_IDs, assignments_mapper): added_ID = assignments_mapper.get(obj.label) if obj.label not in all_dst_IDs: @@ -3136,24 +3171,23 @@ def insert_missing_object(lab_dst, obj, all_dst_IDs, assignments_mapper): # --> need to assign the same ID as before lab_dst[obj.slice][obj.image] = added_ID all_dst_IDs.add(added_ID) - + return lab_dst, assignments_mapper, all_dst_IDs -def insert_missing_objects( - segm_dst, segm_src, is_timelapse=True, display_pbar=True - ): + +def insert_missing_objects(segm_dst, segm_src, is_timelapse=True, display_pbar=True): if not is_timelapse: segm_dst = segm_dst[np.newaxis] segm_src = segm_src[np.newaxis] - + all_dst_IDs = set() for lab_dst in segm_dst: rp = skimage.measure.regionprops(lab_dst) all_dst_IDs.update([obj.label for obj in rp]) - + if display_pbar: pbar = tqdm(total=len(segm_src), ncols=100, leave=False) - + assignments_mapper = {} for frame_i, (lab_src, lab_dst) in enumerate(zip(segm_src, segm_dst)): rp = skimage.measure.regionprops(lab_src) @@ -3170,50 +3204,52 @@ def insert_missing_objects( lab_dst, assignments_mapper, all_dst_IDs = out segm_dst[frame_i] = lab_dst continue - + # Check if merged --> the masks do not coincide obj_dst = rp_dst_mapper[obj_dst_ID] is_merged = not ( len(obj_dst.coords) == len(obj.coords) and np.all(obj_dst.coords == obj.coords) ) - + if not is_merged: continue - + lab_dst, assignments_mapper, all_dst_IDs = insert_missing_object( lab_dst, obj, all_dst_IDs, assignments_mapper ) segm_dst[frame_i] = lab_dst - + if display_pbar: pbar.update() - + if display_pbar: pbar.close() - + return segm_dst - + + def process_lab(task): i, lab = task # Assuming this function processes each lab independently data_dict = {} rp = skimage.measure.regionprops(lab) IDs = [obj.label for obj in rp] - data_dict['IDs'] = IDs - data_dict['regionprops'] = rp - data_dict['IDs_idxs'] = {ID: idx for idx, ID in enumerate(IDs)} - + data_dict["IDs"] = IDs + data_dict["regionprops"] = rp + data_dict["IDs_idxs"] = {ID: idx for idx, ID in enumerate(IDs)} + return i, data_dict, IDs # Return index, data_dict, and IDs + def parallel_count_objects(posData, logger_func): benchmark = True - #futile attempt to use multiprocessing to speed things up - logger_func('Counting total number of segmented objects...') - + # futile attempt to use multiprocessing to speed things up + logger_func("Counting total number of segmented objects...") + allIDs = set() seg_data = posData.segm_data - + # Initialize empty data dictionary to avoid recalculating each time tasks = [(i, lab) for i, lab in enumerate(seg_data)] @@ -3222,22 +3258,25 @@ def parallel_count_objects(posData, logger_func): # Process in batches to optimize memory usage and control parallelism with ThreadPoolExecutor() as executor: futures = [executor.submit(process_lab, task) for task in tasks] - + # Process results as they are completed for future in tqdm(as_completed(futures), total=len(futures), ncols=100): i, data_dict, IDs = future.result() - posData.allData_li[i] = myutils.get_empty_stored_data_dict() # or directly assign if it's mutable - posData.allData_li[i]['IDs'] = data_dict['IDs'] - posData.allData_li[i]['regionprops'] = data_dict['regionprops'] - posData.allData_li[i]['IDs_idxs'] = data_dict['IDs_idxs'] + posData.allData_li[i] = ( + myutils.get_empty_stored_data_dict() + ) # or directly assign if it's mutable + posData.allData_li[i]["IDs"] = data_dict["IDs"] + posData.allData_li[i]["regionprops"] = data_dict["regionprops"] + posData.allData_li[i]["IDs_idxs"] = data_dict["IDs_idxs"] allIDs.update(IDs) - + if benchmark: t1 = time.perf_counter() - logger_func(f'Counting objects took {(t1 - t0)*1000:.2f} ms') + logger_func(f"Counting objects took {(t1 - t0) * 1000:.2f} ms") return allIDs, posData + def count_objects(posData, logger_func): benchmark = False @@ -3247,8 +3286,8 @@ def count_objects(posData, logger_func): if not np.any(segm_data): allIDs = [] return allIDs, posData - - logger_func('Counting total number of segmented objects...') + + logger_func("Counting total number of segmented objects...") pbar = tqdm(total=len(segm_data), ncols=100) if benchmark: t0 = time.perf_counter() @@ -3256,9 +3295,9 @@ def count_objects(posData, logger_func): posData.allData_li[i] = myutils.get_empty_stored_data_dict() rp = skimage.measure.regionprops(lab) IDs = [obj.label for obj in rp] - posData.allData_li[i]['IDs'] = IDs - posData.allData_li[i]['regionprops'] = rp - posData.allData_li[i]['IDs_idxs'] = { # IDs_idxs[obj.label] = idx + posData.allData_li[i]["IDs"] = IDs + posData.allData_li[i]["regionprops"] = rp + posData.allData_li[i]["IDs_idxs"] = { # IDs_idxs[obj.label] = idx ID: idx for idx, ID in enumerate(IDs) } allIDs.update(IDs) @@ -3266,9 +3305,10 @@ def count_objects(posData, logger_func): pbar.close() if benchmark: t1 = time.perf_counter() - logger_func(f'Counting objects took {(t1 - t0)*1000:.2f} ms') + logger_func(f"Counting objects took {(t1 - t0) * 1000:.2f} ms") return allIDs, posData + def fix_sparse_directML(verbose=True): """DirectML does not support sparse tensors, so we need to fallback to CPU. This function replaces `torch.sparse_coo_tensor`, `torch._C._sparse_coo_tensor_unsafe`, @@ -3284,9 +3324,9 @@ def fix_sparse_directML(verbose=True): import warnings def fallback_to_cpu_on_sparse_error(func, verbose=True): - @functools.wraps(func) # wrapper shinanigans (thanks chatgpt) + @functools.wraps(func) # wrapper shinanigans (thanks chatgpt) def wrapper(*args, **kwargs): - device_arg = kwargs.get('device', None) # get desired device from kwargs + device_arg = kwargs.get("device", None) # get desired device from kwargs # Ensure indices are int64 if args[0] looks like indices, # I got random errors from it not being int64 @@ -3294,65 +3334,87 @@ def wrapper(*args, **kwargs): if args[0].dtype != torch.int64: args = (args[0].to(dtype=torch.int64),) + args[1:] - try: # try to perform the operation and move to dml if possible - result = func(*args, **kwargs) # run function with current args and kwargs + try: # try to perform the operation and move to dml if possible + result = func( + *args, **kwargs + ) # run function with current args and kwargs if device_arg is not None and str(device_arg).lower() == "dml": - try: # try to move result to dml + try: # try to move result to dml result.to("dml") - except RuntimeError as e: # moving failed, falling back to cpu + except RuntimeError as e: # moving failed, falling back to cpu if verbose: - warnings.warn(f"Sparse op failed on DirectML, falling back to CPU: {e}") - kwargs['device'] = torch.device("cpu") - return func(*args, **kwargs) # try again, after setting device to cpu - return result # just return result if all worked well - - except RuntimeError as e: # try and run on dlm, if it fails, fallback to cpu + warnings.warn( + f"Sparse op failed on DirectML, falling back to CPU: {e}" + ) + kwargs["device"] = torch.device("cpu") + return func( + *args, **kwargs + ) # try again, after setting device to cpu + return result # just return result if all worked well + + except ( + RuntimeError + ) as e: # try and run on dlm, if it fails, fallback to cpu if "sparse" in str(e).lower() or "not implemented" in str(e).lower(): if verbose: - warnings.warn(f"Sparse op failed on DirectML, falling back to CPU: {e}") - kwargs['device'] = torch.device("cpu") # if rutime warning caused by sparse tensor, set device to cpu + warnings.warn( + f"Sparse op failed on DirectML, falling back to CPU: {e}" + ) + kwargs["device"] = torch.device( + "cpu" + ) # if rutime warning caused by sparse tensor, set device to cpu # Re-apply indices dtype correction before retrying on CPU. Just in case (maybe first one not needed?) if len(args) >= 1 and isinstance(args[0], torch.Tensor): if args[0].dtype != torch.int64: args = (args[0].to(dtype=torch.int64),) + args[1:] - return func(*args, **kwargs) # run function again with cpu device + return func(*args, **kwargs) # run function again with cpu device else: - raise e # catch and other runtime errors + raise e # catch and other runtime errors return wrapper # --- Patch Sparse Tensor Constructors --- # High-level API - torch.sparse_coo_tensor = fallback_to_cpu_on_sparse_error(torch.sparse_coo_tensor, verbose=verbose) + torch.sparse_coo_tensor = fallback_to_cpu_on_sparse_error( + torch.sparse_coo_tensor, verbose=verbose + ) # Low-level API if hasattr(torch._C, "_sparse_coo_tensor_unsafe"): - torch._C._sparse_coo_tensor_unsafe = fallback_to_cpu_on_sparse_error(torch._C._sparse_coo_tensor_unsafe, verbose=verbose) + torch._C._sparse_coo_tensor_unsafe = fallback_to_cpu_on_sparse_error( + torch._C._sparse_coo_tensor_unsafe, verbose=verbose + ) if hasattr(torch._C, "_sparse_coo_tensor_with_dims_and_tensors"): - torch._C._sparse_coo_tensor_with_dims_and_tensors = fallback_to_cpu_on_sparse_error( - torch._C._sparse_coo_tensor_with_dims_and_tensors, verbose=verbose + torch._C._sparse_coo_tensor_with_dims_and_tensors = ( + fallback_to_cpu_on_sparse_error( + torch._C._sparse_coo_tensor_with_dims_and_tensors, verbose=verbose + ) + ) + + if hasattr(torch.sparse, "SparseTensor"): + torch.sparse.SparseTensor = fallback_to_cpu_on_sparse_error( + torch.sparse.SparseTensor, verbose=verbose ) - if hasattr(torch.sparse, 'SparseTensor'): - torch.sparse.SparseTensor = fallback_to_cpu_on_sparse_error(torch.sparse.SparseTensor, verbose=verbose) - # suppress warnings if not verbose: import warnings + warnings.filterwarnings("once", message="Sparse op failed on DirectML*") -def connected_components_in_undirected_graph(undirected_graph:dict): + +def connected_components_in_undirected_graph(undirected_graph: dict): # Build undirected graph graph = defaultdict(set) for key, val in undirected_graph.items(): for other in val: graph[key].add(other) graph[other].add(key) # Make it bidirectional - + visited = set() groups = [] @@ -3361,31 +3423,34 @@ def dfs(node, group): group.append(node) for neighbor in graph[node]: if neighbor not in visited: - dfs(neighbor, group) # recursive call to visit neighbors + dfs(neighbor, group) # recursive call to visit neighbors for key in graph: if key not in visited: group = [] dfs(key, group) groups.append(group) - + return groups -def apply_func_to_imgs(image:np.ndarray, - func: Callable, - *args, - workers: int = 10, - iter_axis:List[int]|int= None, - target_shape:List[int] = None, - target_type: type = None, - target_axis_iter: List[int]|int = None, - parallel: bool = True, - benchmark: bool = False, - processpool: bool = False, - **kwargs): + +def apply_func_to_imgs( + image: np.ndarray, + func: Callable, + *args, + workers: int = 10, + iter_axis: List[int] | int = None, + target_shape: List[int] = None, + target_type: type = None, + target_axis_iter: List[int] | int = None, + parallel: bool = True, + benchmark: bool = False, + processpool: bool = False, + **kwargs, +): """Apply a function to each image. This is done along the iter_axis (can also be a single int). Then the processed image is put in the target_axis_iter (can also be a single int). - (If target_axis_iter, target_shape or target_type are None, + (If target_axis_iter, target_shape or target_type are None, they are taken from the input image). Example of iter_axis: [0, 1] and target_axis_iter: [1, 0] means that the function is applied to each [0, 1, ...] slice of the input and the processed image is put in the [1, 0, ...] slice of the output image. @@ -3397,11 +3462,11 @@ def apply_func_to_imgs(image:np.ndarray, ---------- image : np.ndarray Image to be processed - + func : Callable Function to be applied to each image. First argument should be the image itself, one kwarg should be `frame_index_out`, - should `return processed_image, frame_index_out`. `frame_index_out` just needs to + should `return processed_image, frame_index_out`. `frame_index_out` just needs to be passed along, no need to slice the image in `func`. *args : tuple Additional arguments to be passed to the function @@ -3444,28 +3509,27 @@ def apply_func_to_imgs(image:np.ndarray, out = func(image, *args, **kwargs, frame_index_out=None)[1] if benchmark: t1 = time.perf_counter() - printl(f"Processing time: {(t1 - t0)*1000:.2f} ms, no parallel since iter_axis is None") - return out - + printl( + f"Processing time: {(t1 - t0) * 1000:.2f} ms, no parallel since iter_axis is None" + ) + return out + if isinstance(iter_axis, int): iter_axis = [iter_axis] - + if isinstance(target_axis_iter, int): iter_axis = [target_axis_iter] - + if target_axis_iter is None: target_axis_iter = iter_axis - + if target_shape is None: target_shape = image_shape - + if target_type is None: target_type = type(image.flat[0]) - - image_out = np.empty( - target_shape, dtype=target_type - ) + image_out = np.empty(target_shape, dtype=target_type) input_output_mapper = myutils.get_input_output_mapper( image_shape, iter_axis, target_shape, target_axis_iter @@ -3478,25 +3542,25 @@ def apply_func_to_imgs(image:np.ndarray, executor_func = ThreadPoolExecutor with executor_func() as executor: futures = { - executor.submit(func, image[i_in], *args, frame_index_out=i_out, **kwargs) + executor.submit( + func, image[i_in], *args, frame_index_out=i_out, **kwargs + ) for i_in, i_out in input_output_mapper } for future in tqdm( - as_completed(futures), - total=len(futures), - desc="Processing frames" - ): + as_completed(futures), total=len(futures), desc="Processing frames" + ): i, processed = future.result() image_out[i] = processed else: for i_in, i_out in tqdm(input_output_mapper, desc="Processing frames"): processed = func(image[i_in], *args, frame_index_out=i_out, **kwargs)[1] image_out[i_out] = processed - + if benchmark: t1 = time.perf_counter() - printl(f"Processing time: {(t1 - t0)*1000:.2f} ms") + printl(f"Processing time: {(t1 - t0) * 1000:.2f} ms") return image_out @@ -3506,7 +3570,7 @@ def fill_holes_in_segmentation(labels): for obj in skimage.measure.regionprops(labels): label_id = obj.label mask_filled = scipy.ndimage.binary_fill_holes(obj.image) - + region = filled[obj.slice] # Only fill where mask_filled is True and region is still background fill_mask = mask_filled & (region == 0) @@ -3515,25 +3579,24 @@ def fill_holes_in_segmentation(labels): return filled -def natsort_acdc_columns( - columns: Iterable[str], - prepend_default_index_cols=True - ): + +def natsort_acdc_columns(columns: Iterable[str], prepend_default_index_cols=True): sorted_cols = natsorted(columns, key=str.casefold) if not prepend_default_index_cols: return sorted_cols - + cols_to_prepend = [] for col in default_index_cols: if col not in sorted_cols: continue - + sorted_cols.remove(col) cols_to_prepend.append(col) - + sorted_cols = [*cols_to_prepend, *sorted_cols] return sorted_cols + def linear_fit_3d(xx, yy, zz): points = np.column_stack((xx, yy, zz)) centroid = points.mean(axis=0) @@ -3543,42 +3606,44 @@ def linear_fit_3d(xx, yy, zz): return centroid, d + def binary_fill_holes(mask, slice_by_slice=True): if not slice_by_slice: mask = scipy.ndimage.binary_fill_holes(mask) return mask - + if mask.ndim == 2: mask = scipy.ndimage.binary_fill_holes(mask) return mask - + for z, mask_z in enumerate(mask): if not np.any(mask_z): continue - + mask[z] = scipy.ndimage.binary_fill_holes(mask_z) - + return mask + def convex_hull_mask(mask: np.ndarray, slice_by_slice=True): if not slice_by_slice: mask = skimage.morphology.convex_hull_image(mask) return mask - + if mask.ndim == 2: mask = skimage.morphology.convex_hull_image(mask) return mask - + mask_rp = skimage.measure.regionprops(mask.astype(np.uint8)) if len(mask_rp) == 0: return mask - + mask_obj = mask_rp[0] for z, mask_obj_img_z in enumerate(mask_obj.image): if not np.any(mask_obj_img_z): continue - + mask_obj_hull_z = skimage.morphology.convex_hull_image(mask_obj_img_z) mask[mask_obj.slice][z] = mask_obj_hull_z - - return mask \ No newline at end of file + + return mask diff --git a/cellacdc/data.py b/cellacdc/data.py index da20e2398..19ab6f14f 100644 --- a/cellacdc/data.py +++ b/cellacdc/data.py @@ -6,33 +6,33 @@ from . import data_path, load, base_cca_dict, cca_df_colnames + class _Data: def __init__( - self, images_path, intensity_image_path, acdc_df_path, segm_path, - basename - ): + self, images_path, intensity_image_path, acdc_df_path, segm_path, basename + ): self.images_path = images_path self.intensity_image_path = intensity_image_path self.acdc_df_path = acdc_df_path self.segm_path = segm_path self.basename = basename - + def filename(self): return os.path.basename(self.intensity_image_path) - + def channel_name(self): filename, ext = os.path.splitext(self.filename()) - return filename[len(self.basename):] - + return filename[len(self.basename) :] + def acdc_df(self): return load._load_acdc_df_file(self.acdc_df_path) - + def image_data(self): return load.load_image_file(self.intensity_image_path) - + def segm_data(self): - return np.load(self.segm_path)['arr_0'] - + return np.load(self.segm_path)["arr_0"] + def cca_df(self): acdc_df = load._load_acdc_df_file(self.acdc_df_path).dropna() cca_df = acdc_df[cca_df_colnames] @@ -40,103 +40,100 @@ def cca_df(self): cca_df = cca_df.astype(dtypes) return cca_df + class FissionYeastAnnotated(_Data): def __init__(self): images_path = os.path.join( - data_path, 'test_symm_div_acdc_tracker', 'Images', - ) - intensity_image_path = os.path.join( - images_path, 'bknapp_Movie_S1.tif' - ) - acdc_df_path = os.path.join( - images_path, 'bknapp_Movie_S1_acdc_output.csv' - ) - segm_path = os.path.join( - images_path, 'bknapp_Movie_S1_segm.npz' - ) - basename = 'bknapp_Movie_S1_' + data_path, + "test_symm_div_acdc_tracker", + "Images", + ) + intensity_image_path = os.path.join(images_path, "bknapp_Movie_S1.tif") + acdc_df_path = os.path.join(images_path, "bknapp_Movie_S1_acdc_output.csv") + segm_path = os.path.join(images_path, "bknapp_Movie_S1_segm.npz") + basename = "bknapp_Movie_S1_" super().__init__( - images_path, intensity_image_path, acdc_df_path, segm_path, - basename + images_path, intensity_image_path, acdc_df_path, segm_path, basename ) - + def posData(self): from . import load - return load.loadData(self.intensity_image_path, '') - + + return load.loadData(self.intensity_image_path, "") + + class DeepSeaAnnotation(_Data): def __init__(self): images_path = os.path.join( - data_path, 'deep_sea', 'Images', - ) - intensity_image_path = os.path.join( - images_path, 'set_22_MESC.tif' - ) - acdc_df_path = os.path.join( - images_path, 'set_22_MESC_acdc_output.csv' - ) - segm_path = os.path.join( - images_path, 'set_22_MESC_segm.tif' - ) - basename = 'set_22_MESC_' + data_path, + "deep_sea", + "Images", + ) + intensity_image_path = os.path.join(images_path, "set_22_MESC.tif") + acdc_df_path = os.path.join(images_path, "set_22_MESC_acdc_output.csv") + segm_path = os.path.join(images_path, "set_22_MESC_segm.tif") + basename = "set_22_MESC_" super().__init__( - images_path, intensity_image_path, acdc_df_path, segm_path, - basename + images_path, intensity_image_path, acdc_df_path, segm_path, basename ) - + def posData(self): from . import load - return load.loadData(self.intensity_image_path, '') + + return load.loadData(self.intensity_image_path, "") + class YeastTimeLapseAnnotated(_Data): def __init__(self): images_path = os.path.join( - data_path, 'test_timelapse', 'Yagya_Kurt_presentation', - 'Position_6', 'Images' + data_path, + "test_timelapse", + "Yagya_Kurt_presentation", + "Position_6", + "Images", ) intensity_image_path = os.path.join( - images_path, 'SCGE_5strains_23092021_Dia_Ph3.tif' + images_path, "SCGE_5strains_23092021_Dia_Ph3.tif" ) acdc_df_path = os.path.join( - images_path, 'SCGE_5strains_23092021_acdc_output.csv' - ) - segm_path = os.path.join( - images_path, 'SCGE_5strains_23092021_segm.npz' + images_path, "SCGE_5strains_23092021_acdc_output.csv" ) - basename = 'SCGE_5strains_23092021_' + segm_path = os.path.join(images_path, "SCGE_5strains_23092021_segm.npz") + basename = "SCGE_5strains_23092021_" super().__init__( - images_path, intensity_image_path, acdc_df_path, segm_path, - basename + images_path, intensity_image_path, acdc_df_path, segm_path, basename ) - + def posData(self): from . import load - return load.loadData(self.intensity_image_path, 'Dia_Ph3') + + return load.loadData(self.intensity_image_path, "Dia_Ph3") + class pomBseenDualChannelData(_Data): def __init__(self): images_path = os.path.join( - data_path, 'test_pomBseen', 'dual_channel', - 'Position_3', 'Images' + data_path, "test_pomBseen", "dual_channel", "Position_3", "Images" ) intensity_image_path = os.path.join( - images_path, 'Demo_two_channel_input_image_BF.tif' + images_path, "Demo_two_channel_input_image_BF.tif" ) acdc_df_path = os.path.join( - images_path, 'Demo_two_channel_input_image_acdc_output.csv' + images_path, "Demo_two_channel_input_image_acdc_output.csv" ) segm_path = os.path.join( - images_path, 'Demo_two_channel_input_image_segm_bf.npz' + images_path, "Demo_two_channel_input_image_segm_bf.npz" ) - basename = 'Demo_two_channel_input_image_' + basename = "Demo_two_channel_input_image_" super().__init__( - images_path, intensity_image_path, acdc_df_path, segm_path, - basename + images_path, intensity_image_path, acdc_df_path, segm_path, basename ) - + def posData(self): from . import load - return load.loadData(self.intensity_image_path, 'BF') + + return load.loadData(self.intensity_image_path, "BF") + class _YeastTimeLapseAnnotatedJordan(_Data): def __init__(self, custom_data_path=None): @@ -145,162 +142,160 @@ def __init__(self, custom_data_path=None): else: _data_path = data_path images_path = os.path.join( - _data_path, 'gh_issue_394_Jordan', 'Position_1', 'Images' + _data_path, "gh_issue_394_Jordan", "Position_1", "Images" ) intensity_image_path = os.path.join( - images_path, '220630_JX_MS380_2ng-uL-aTc_pos04_g_s1_Phase.tif' + images_path, "220630_JX_MS380_2ng-uL-aTc_pos04_g_s1_Phase.tif" ) acdc_df_path = os.path.join( - images_path, '220630_JX_MS380_2ng-uL-aTc_pos04_g_s1_acdc_output.csv' + images_path, "220630_JX_MS380_2ng-uL-aTc_pos04_g_s1_acdc_output.csv" ) segm_path = os.path.join( - images_path, '220630_JX_MS380_2ng-uL-aTc_pos04_g_s1_segm.npz' + images_path, "220630_JX_MS380_2ng-uL-aTc_pos04_g_s1_segm.npz" ) - basename = '220630_JX_MS380_2ng-uL-aTc_pos04_g_s1_' + basename = "220630_JX_MS380_2ng-uL-aTc_pos04_g_s1_" super().__init__( - images_path, intensity_image_path, acdc_df_path, segm_path, - basename + images_path, intensity_image_path, acdc_df_path, segm_path, basename ) - + def posData(self): from . import load - return load.loadData(self.intensity_image_path, 'Dia_Ph3') + + return load.loadData(self.intensity_image_path, "Dia_Ph3") + class Cdc42TimeLapseData(_Data): def __init__(self): images_path = os.path.join( - data_path, 'test_timelapse', 'Kurt_ring', 'Cdc42', - 'Position_1', 'Images' + data_path, "test_timelapse", "Kurt_ring", "Cdc42", "Position_1", "Images" ) intensity_image_path = os.path.join( - images_path, 'SCGE_DLY16570_1-15_DLY16571_16-30_corr_s01_Dia_Ph3.tif' + images_path, "SCGE_DLY16570_1-15_DLY16571_16-30_corr_s01_Dia_Ph3.tif" ) acdc_df_path = os.path.join( - images_path, 'SCGE_DLY16570_1-15_DLY16571_16-30_corr_s01_acdc_output.csv' + images_path, "SCGE_DLY16570_1-15_DLY16571_16-30_corr_s01_acdc_output.csv" ) segm_path = os.path.join( - images_path, 'SCGE_DLY16570_1-15_DLY16571_16-30_corr_s01_segm.npz' + images_path, "SCGE_DLY16570_1-15_DLY16571_16-30_corr_s01_segm.npz" ) - basename = 'SCGE_DLY16570_1-15_DLY16571_16-30_corr_s01_' + basename = "SCGE_DLY16570_1-15_DLY16571_16-30_corr_s01_" super().__init__( - images_path, intensity_image_path, acdc_df_path, segm_path, - basename + images_path, intensity_image_path, acdc_df_path, segm_path, basename ) self.intensity_image_path = intensity_image_path - + def cdc42_data(self): - return load.imread(os.path.join( - self.images_path, - 'SCGE_DLY16570_1-15_DLY16571_16-30_corr_s01_tdTomato_Ph3__YEAST.tif' - )) - + return load.imread( + os.path.join( + self.images_path, + "SCGE_DLY16570_1-15_DLY16571_16-30_corr_s01_tdTomato_Ph3__YEAST.tif", + ) + ) + def posData(self): from . import load - return load.loadData(self.intensity_image_path, 'Ph3__YEAST') + + return load.loadData(self.intensity_image_path, "Ph3__YEAST") + class YeastMitoTimelapse(_Data): def __init__(self): images_path = os.path.join( - data_path, 'test_4D', 'Lisa_mito', 'Position_5', 'Images' + data_path, "test_4D", "Lisa_mito", "Position_5", "Images" ) intensity_image_path = os.path.join( - images_path, 'Point0019_ChannelGFP,mCardinal,Ph-3_Seq0019_s5_Ph_3.tif' + images_path, "Point0019_ChannelGFP,mCardinal,Ph-3_Seq0019_s5_Ph_3.tif" ) acdc_df_path = os.path.join( - images_path, 'Point0019_ChannelGFP,mCardinal,Ph-3_Seq0019_s5_acdc_output.csv' + images_path, + "Point0019_ChannelGFP,mCardinal,Ph-3_Seq0019_s5_acdc_output.csv", ) segm_path = os.path.join( - images_path, 'Point0019_ChannelGFP,mCardinal,Ph-3_Seq0019_s5_segm.npz' + images_path, "Point0019_ChannelGFP,mCardinal,Ph-3_Seq0019_s5_segm.npz" ) - basename = 'Point0019_ChannelGFP,mCardinal,Ph-3_Seq0019_s5_' + basename = "Point0019_ChannelGFP,mCardinal,Ph-3_Seq0019_s5_" super().__init__( - images_path, intensity_image_path, acdc_df_path, segm_path, - basename + images_path, intensity_image_path, acdc_df_path, segm_path, basename ) - + def mito_segm(self): - return np.load(os.path.join( - self.images_path, - 'Point0019_ChannelGFP,mCardinal,Ph-3_Seq0019_s5_GFP_segm_mask_otsu.npz' - ))['arr_0'] - + return np.load( + os.path.join( + self.images_path, + "Point0019_ChannelGFP,mCardinal,Ph-3_Seq0019_s5_GFP_segm_mask_otsu.npz", + ) + )["arr_0"] + def cells_3D_segm(self): - return np.load(os.path.join( - self.images_path, - 'Point0019_ChannelGFP,mCardinal,Ph-3_Seq0019_s5_segm_7slices.npz' - ))['arr_0'] + return np.load( + os.path.join( + self.images_path, + "Point0019_ChannelGFP,mCardinal,Ph-3_Seq0019_s5_segm_7slices.npz", + ) + )["arr_0"] + class BABYtestData(_Data): def __init__(self): images_path = os.path.join( - data_path, 'test_BABY', 'evolve_testG_Brightfield', 'Position_1', - 'Images' - ) - intensity_image_path = os.path.join( - images_path, 'evolve_testG_Brightfield.tif' - ) - acdc_df_path = os.path.join( - images_path, 'evolve_testG_acdc_output.csv' + data_path, "test_BABY", "evolve_testG_Brightfield", "Position_1", "Images" ) - segm_path = os.path.join( - images_path, 'evolve_testG_segm.npz' - ) - basename = 'evolve_testG_' + intensity_image_path = os.path.join(images_path, "evolve_testG_Brightfield.tif") + acdc_df_path = os.path.join(images_path, "evolve_testG_acdc_output.csv") + segm_path = os.path.join(images_path, "evolve_testG_segm.npz") + basename = "evolve_testG_" super().__init__( - images_path, intensity_image_path, acdc_df_path, segm_path, - basename + images_path, intensity_image_path, acdc_df_path, segm_path, basename ) - + def posData(self): from . import load - return load.loadData(self.intensity_image_path, 'Brightfield') + + return load.loadData(self.intensity_image_path, "Brightfield") + class YeastMitoSnapshotData(_Data): def __init__(self): images_path = os.path.join( - data_path, 'test_snapshots', 'mtDNA_Anika', 'Position_10', - 'Images' - ) - intensity_image_path = os.path.join( - images_path, 'ASY15-1_0nM-10_s10_mNeon.tif' - ) - acdc_df_path = os.path.join( - images_path, 'ASY15-1_0nM-10_s10_acdc_output.csv' + data_path, "test_snapshots", "mtDNA_Anika", "Position_10", "Images" ) - segm_path = os.path.join( - images_path, 'ASY15-1_0nM-10_s10_segm.npz' - ) - basename = 'ASY15-1_0nM-10_s10_' + intensity_image_path = os.path.join(images_path, "ASY15-1_0nM-10_s10_mNeon.tif") + acdc_df_path = os.path.join(images_path, "ASY15-1_0nM-10_s10_acdc_output.csv") + segm_path = os.path.join(images_path, "ASY15-1_0nM-10_s10_segm.npz") + basename = "ASY15-1_0nM-10_s10_" super().__init__( - images_path, intensity_image_path, acdc_df_path, segm_path, - basename + images_path, intensity_image_path, acdc_df_path, segm_path, basename ) - + def posData(self): from . import load - return load.loadData(self.intensity_image_path, 'mNeon') + + return load.loadData(self.intensity_image_path, "mNeon") + class MIA_KC_htb1_mCitrine(_Data): def __init__(self): images_path = os.path.join( - data_path, 'budding_yeast', 'TimeLapse_2D', - 'MIA_KC_htb1_mCitrine_labeled', 'Position_2', 'Images' + data_path, + "budding_yeast", + "TimeLapse_2D", + "MIA_KC_htb1_mCitrine_labeled", + "Position_2", + "Images", ) intensity_image_path = os.path.join( - images_path, '19-03-2021_KCY050_SCGE_s02_phase_contr.tif' + images_path, "19-03-2021_KCY050_SCGE_s02_phase_contr.tif" ) acdc_df_path = os.path.join( - images_path, '19-03-2021_KCY050_SCGE_s02_acdc_output.csv' - ) - segm_path = os.path.join( - images_path, '19-03-2021_KCY050_SCGE_s02_segm.npz' + images_path, "19-03-2021_KCY050_SCGE_s02_acdc_output.csv" ) - basename = '19-03-2021_KCY050_SCGE_s02_' + segm_path = os.path.join(images_path, "19-03-2021_KCY050_SCGE_s02_segm.npz") + basename = "19-03-2021_KCY050_SCGE_s02_" super().__init__( - images_path, intensity_image_path, acdc_df_path, segm_path, - basename + images_path, intensity_image_path, acdc_df_path, segm_path, basename ) def posData(self): from . import load - return load.loadData(self.intensity_image_path, 'phase_contr') \ No newline at end of file + + return load.loadData(self.intensity_image_path, "phase_contr") diff --git a/cellacdc/dataPrep.py b/cellacdc/dataPrep.py index 10b558445..275b17be4 100755 --- a/cellacdc/dataPrep.py +++ b/cellacdc/dataPrep.py @@ -18,21 +18,46 @@ from tifffile.tifffile import TiffWriter, TiffFile from qtpy.QtCore import ( - Qt, QFile, QEventLoop, QSize, QRect, QRectF, - QObject, QThread, Signal, QSettings, QMutex, QWaitCondition + Qt, + QFile, + QEventLoop, + QSize, + QRect, + QRectF, + QObject, + QThread, + Signal, + QSettings, + QMutex, + QWaitCondition, ) from qtpy.QtGui import ( - QIcon, QKeySequence, QCursor, QTextBlockFormat, - QTextCursor, QFont + QIcon, + QKeySequence, + QCursor, + QTextBlockFormat, + QTextCursor, + QFont, ) from qtpy.QtWidgets import ( - QAction, QLabel, QWidget, QMainWindow, QMenu, QToolBar, QGridLayout, - QScrollBar, QComboBox, QFileDialog, QAbstractSlider, QMessageBox + QAction, + QLabel, + QWidget, + QMainWindow, + QMenu, + QToolBar, + QGridLayout, + QScrollBar, + QComboBox, + QFileDialog, + QAbstractSlider, + QMessageBox, ) from qtpy.compat import getexistingdirectory import pyqtgraph as pg -pg.setConfigOption('imageAxisOrder', 'row-major') + +pg.setConfigOption("imageAxisOrder", "row-major") # Custom modules from . import exception_handler @@ -45,15 +70,17 @@ from . import io from .help import about -if os.name == 'nt': +if os.name == "nt": try: # Set taskbar icon in windows import ctypes - myappid = 'schmollerlab.cellacdc.pyqt.v1' # arbitrary string + + myappid = "schmollerlab.cellacdc.pyqt.v1" # arbitrary string ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) except Exception as e: pass + class toCsvWorker(QObject): finished = Signal() progress = Signal(int) @@ -66,29 +93,28 @@ def run(self): posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) self.finished.emit() + class dataPrepWin(QMainWindow): sigClose = Signal(object) - def __init__( - self, parent=None, buttonToRestore=None, mainWin=None, - version=None - ): + def __init__(self, parent=None, buttonToRestore=None, mainWin=None, version=None): from .config import parser_args - self.debug = parser_args['debug'] + + self.debug = parser_args["debug"] super().__init__(parent) self._version = version logger, logs_path, log_path, log_filename = myutils.setupLogger( - module='dataPrep' + module="dataPrep" ) self.logger = logger if self._version is not None: - logger.info(f'Initializing Data Prep module v{self._version}...') + logger.info(f"Initializing Data Prep module v{self._version}...") else: - logger.info(f'Initializing Data Prep module...') + logger.info(f"Initializing Data Prep module...") self.log_path = log_path self.log_filename = log_filename @@ -149,17 +175,15 @@ def keyPressEvent(self, event): printl(self.freeRoiItem.bbox()) printl(self.freeRoiItem.mask().shape) from cellacdc.plot import imshow - - imshow( - posData.dataPrepFreeRoiLocalMask, self.freeRoiItem.mask() - ) + + imshow(posData.dataPrepFreeRoiLocalMask, self.freeRoiItem.mask()) cropROI = posData.cropROIs[0] x0, y0 = [int(round(c)) for c in cropROI.pos()] w, h = [int(round(c)) for c in cropROI.size()] - x1, y1 = x0+w, y0+h - + x1, y1 = x0 + w, y0 + h + printl(x0, y0) - + # printl(posData.all_npz_paths) # printl(posData.tif_paths) # for r, roi in enumerate(posData.bkgrROIs): @@ -191,15 +215,12 @@ def keyPressEvent(self, event): def gui_createActions(self): # File actions self.aboutAction = QAction("About Cell-ACDC", self) - self.infoAction = ( - QAction(QIcon(":info.svg"), "&How to prep the data...", self) - ) - self.openFolderAction = QAction( - QIcon(":folder-open.svg"), "&Open...", self - ) + self.infoAction = QAction(QIcon(":info.svg"), "&How to prep the data...", self) + self.openFolderAction = QAction(QIcon(":folder-open.svg"), "&Open...", self) self.exitAction = QAction("&Exit", self) - self.showInExplorerAction = QAction(QIcon(":drawer.svg"), - "&Show in Explorer/Finder", self) + self.showInExplorerAction = QAction( + QIcon(":drawer.svg"), "&Show in Explorer/Finder", self + ) self.showInExplorerAction.setDisabled(True) # Toolbar actions @@ -214,7 +235,7 @@ def gui_createActions(self): self.loadPosAction = QAction("Load different Position...", self) self.loadPosAction.setShortcut("Shift+P") - + toolTip = ( "Add crop ROI for multiple crops\n\n" "Multiple crops will be saved as Position_1, Position_2 " @@ -227,87 +248,85 @@ def gui_createActions(self): self.addBkrgRoiActon = QAction(QIcon(":bkgrRoi.svg"), toolTip, self) self.addBkrgRoiActon.setDisabled(True) - self.ZbackAction = QAction(QIcon(":zback.svg"), - "Use same z-slice from first frame to here", - self) + self.ZbackAction = QAction( + QIcon(":zback.svg"), "Use same z-slice from first frame to here", self + ) self.ZbackAction.setEnabled(False) - self.ZforwAction = QAction(QIcon(":zforw.svg"), - "Use same z-slice from here to last frame", - self) + self.ZforwAction = QAction( + QIcon(":zforw.svg"), "Use same z-slice from here to last frame", self + ) self.ZforwAction.setEnabled(False) - self.interpAction = QAction(QIcon(":interp.svg"), - "Interpolate z-slice from first slice to here", - self) + self.interpAction = QAction( + QIcon(":interp.svg"), "Interpolate z-slice from first slice to here", self + ) self.interpAction.setEnabled(False) - self.cropAction = QAction(QIcon(":crop.svg"), "Crop XY", self) self.cropAction.setToolTip( - 'Crop XY.\n\n' - 'If the button is disabled you need to click on the Start button ' - 'first.\n\n' - 'You can add as many crop ROIs as needed. If you use more than ' - 'one, the cropped data will be saved into sub-folders of each ' - 'cropped Position\n\n' - 'After adjusting the crop ROIs, click this button to apply the ' - 'crop and activate the save button.\n\n' - 'To save the cropped data click the Save button.' + "Crop XY.\n\n" + "If the button is disabled you need to click on the Start button " + "first.\n\n" + "You can add as many crop ROIs as needed. If you use more than " + "one, the cropped data will be saved into sub-folders of each " + "cropped Position\n\n" + "After adjusting the crop ROIs, click this button to apply the " + "crop and activate the save button.\n\n" + "To save the cropped data click the Save button." ) self.cropZaction = QAction(QIcon(":cropZ.svg"), "Crop z-slices", self) self.cropZaction.setToolTip( - 'Crop upper and bottom Z-slices.\n\n' - 'If the button is disabled you need to click on the Start button ' - 'first.\n\n' - 'USAGE: Click this button, adjust the lower and upper z-slices ' + "Crop upper and bottom Z-slices.\n\n" + "If the button is disabled you need to click on the Start button " + "first.\n\n" + "USAGE: Click this button, adjust the lower and upper z-slices " 'and click on "Apply crop" to activate the save button.\n\n' - 'To save the cropped data click the Save button.' + "To save the cropped data click the Save button." ) self.cropZaction.setEnabled(False) self.cropZaction.setCheckable(True) - + self.cropTaction = QAction( QIcon(":cropT.svg"), "Crop frames (time points)", self ) self.cropTaction.setToolTip( - 'Crop a specified time range.\n\n' - 'If the button is disabled you need to click on the Start button ' - 'first.\n\n' - 'USAGE: Click this button, adjust the start and end frame numbers ' + "Crop a specified time range.\n\n" + "If the button is disabled you need to click on the Start button " + "first.\n\n" + "USAGE: Click this button, adjust the start and end frame numbers " 'and click on "Apply crop" to activate the save button.\n\n' - 'To save the cropped data click the Save button.' + "To save the cropped data click the Save button." ) self.cropTaction.setEnabled(False) self.cropTaction.setCheckable(True) - + self.freeRoiAction = QAction( - QIcon(':drawFreeRoi.svg'), "Draw a freehand ROI", self + QIcon(":drawFreeRoi.svg"), "Draw a freehand ROI", self ) self.freeRoiAction.setToolTip( - 'Draw a freehand ROI.\n\n' - 'To remove a previously drawn ROI, activate the tool, ' + "Draw a freehand ROI.\n\n" + "To remove a previously drawn ROI, activate the tool, " 'right-click on the ROI, and select "Remove free-hand ROI".\n\n' - 'When running segmentation later in the segmentation module, ' - 'the objects outside of this ROI will be automatically removed ' - 'from the segmentation masks.\n\n' + "When running segmentation later in the segmentation module, " + "the objects outside of this ROI will be automatically removed " + "from the segmentation masks.\n\n" ) self.freeRoiAction.setEnabled(False) self.freeRoiAction.setCheckable(True) - - self.saveAction = QAction( - QIcon(":file-save.svg"), "Crop and save", self) + + self.saveAction = QAction(QIcon(":file-save.svg"), "Crop and save", self) self.saveAction.setEnabled(False) self.saveAction.setToolTip( - 'Save the image data.\n\n' - 'Saving is needed only to save the aligned (timelapse) and/or ' - 'cropped image data.\n\n' - 'If you did not align and you do not need cropping, there is ' - 'no need to save. The information about the z-slice to use for ' - 'segmentation, the background ROIs, and the ROI has already ' - 'been saved automatically.\n\n' - 'If the button is disabled you need to click on the Start button ' - 'first.' + "Save the image data.\n\n" + "Saving is needed only to save the aligned (timelapse) and/or " + "cropped image data.\n\n" + "If you did not align and you do not need cropping, there is " + "no need to save. The information about the z-slice to use for " + "segmentation, the background ROIs, and the ROI has already " + "been saved automatically.\n\n" + "If the button is disabled you need to click on the Start button " + "first." ) self.startAction = QAction(QIcon(":start.svg"), "Start process!", self) @@ -318,7 +337,7 @@ def gui_createActions(self): def gui_createMenuBar(self): menuBar = self.menuBar() menuBar.setNativeMenuBar(False) - + # File menu fileMenu = QMenu("&File", self) menuBar.addMenu(fileMenu) @@ -329,7 +348,7 @@ def gui_createMenuBar(self): fileMenu.addAction(self.loadPosAction) fileMenu.addSeparator() fileMenu.addAction(self.exitAction) - + # Help menu helpMenu = menuBar.addMenu("&Help") helpMenu.addAction(self.infoAction) @@ -348,17 +367,17 @@ def gui_createToolBars(self): fileToolBar.addAction(self.openFolderAction) fileToolBar.addAction(self.showInExplorerAction) fileToolBar.addAction(self.saveAction) - + editToolbar = self.addToolBar("Edit") # fileToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) editToolbar.setMovable(False) - + editToolbar.addAction(self.startAction) editToolbar.addAction(self.cropAction) editToolbar.addAction(self.cropZaction) editToolbar.addAction(self.cropTaction) editToolbar.addAction(self.freeRoiAction) - + navigateToolbar = QToolBar("Navigate", self) # navigateToolbar.setIconSize(QSize(toolbarSize, toolbarSize)) self.addToolBar(navigateToolbar) @@ -374,16 +393,18 @@ def gui_createToolBars(self): self.ROIshapeComboBox = QComboBox() self.ROIshapeComboBox.setFont(apps.font) - self.ROIshapeComboBox.SizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) - self.ROIshapeComboBox.addItems([' 256x256 ']) - ROIshapeLabel = QLabel(html_utils.paragraph( - '   ROI standard shape: ') + self.ROIshapeComboBox.SizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToContents + ) + self.ROIshapeComboBox.addItems([" 256x256 "]) + ROIshapeLabel = QLabel( + html_utils.paragraph("   ROI standard shape: ") ) ROIshapeLabel.setBuddy(self.ROIshapeComboBox) navigateToolbar.addWidget(ROIshapeLabel) navigateToolbar.addWidget(self.ROIshapeComboBox) - self.ROIshapeLabel = QLabel(' Current ROI shape: 256 x 256') + self.ROIshapeLabel = QLabel(" Current ROI shape: 256 x 256") navigateToolbar.addWidget(self.ROIshapeLabel) def gui_connectActions(self): @@ -424,18 +445,17 @@ def gui_addGraphicsItems(self): self.ax1 = pg.PlotItem() self.ax1.invertY(True) self.ax1.setAspectLocked(True) - self.ax1.hideAxis('bottom') - self.ax1.hideAxis('left') + self.ax1.hideAxis("bottom") + self.ax1.hideAxis("left") self.graphLayout.addItem(self.ax1, row=1, col=1) - #Image histogram + # Image histogram self.hist = widgets.myHistogramLUTitem() self.graphLayout.addItem(self.hist, row=1, col=0) # Title - self.titleLabel = pg.LabelItem(justify='center', color='w', size='14pt') - self.titleLabel.setText( - 'File --> Open or Open recent to start the process') + self.titleLabel = pg.LabelItem(justify="center", color="w", size="14pt") + self.titleLabel.setText("File --> Open or Open recent to start the process") self.graphLayout.addItem(self.titleLabel, row=0, col=1) # Current frame text @@ -459,14 +479,14 @@ def removeFreeRoi(self): self.freeRoiMask = None posData = self.data[self.pos_i] posData.removeDataPrepFreeRoi(logger_func=self.logger.info) - + def showRemoveFreeRoiContextMenu(self, event): self.removeFreeRoiMenu = QMenu(self) - action = QAction('Remove free-hand ROI') + action = QAction("Remove free-hand ROI") action.triggered.connect(self.removeFreeRoi) self.removeFreeRoiMenu.addAction(action) self.removeFreeRoiMenu.exec_(event.screenPos()) - + def gui_connectGraphicsEvents(self): self.img.hoverEvent = self.gui_hoverEventImg self.img.mousePressEvent = self.gui_mousePressEventImg @@ -482,23 +502,26 @@ def gui_createImgWidgets(self): self.navigateScrollbar = QScrollBar(Qt.Horizontal) self.navigateScrollbar.setFixedHeight(20) self.navigateScrollbar.setDisabled(True) - navSB_label = QLabel('') + navSB_label = QLabel("") navSB_label.setFont(_font) self.navigateSB_label = navSB_label - self.zSliceScrollBar = QScrollBar(Qt.Horizontal) self.zSliceScrollBar.setFixedHeight(20) self.zSliceScrollBar.setDisabled(True) - _z_label = QLabel('z-slice ') + _z_label = QLabel("z-slice ") _z_label.setFont(_font) self.z_label = _z_label self.zProjComboBox = QComboBox() - self.zProjComboBox.addItems(['single z-slice', - 'max z-projection', - 'mean z-projection', - 'median z-proj.']) + self.zProjComboBox.addItems( + [ + "single z-slice", + "max z-projection", + "mean z-projection", + "median z-proj.", + ] + ) self.zProjComboBox.setDisabled(True) self.img_Widglayout.addWidget(navSB_label, 0, 0, alignment=Qt.AlignCenter) @@ -520,45 +543,43 @@ def gui_hoverEventImg(self, event): Y, X = _img.shape if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: val = _img[ydata, xdata] - self.wcLabel.setText( - f'(x={xdata:.2f}, y={ydata:.2f}, value={val:.2f})' - ) + self.wcLabel.setText(f"(x={xdata:.2f}, y={ydata:.2f}, value={val:.2f})") else: - self.wcLabel.setText(f'') + self.wcLabel.setText(f"") except Exception as e: - self.wcLabel.setText(f'') + self.wcLabel.setText(f"") def showInExplorer(self): try: posData = self.data[self.pos_i] systems = { - 'nt': os.startfile, - 'posix': lambda foldername: os.system('xdg-open "%s"' % foldername), - 'os2': lambda foldername: os.system('open "%s"' % foldername) - } + "nt": os.startfile, + "posix": lambda foldername: os.system('xdg-open "%s"' % foldername), + "os2": lambda foldername: os.system('open "%s"' % foldername), + } systems.get(os.name, os.startfile)(posData.images_path) except AttributeError: pass - + def loadPosTriggered(self): if not self.isDataLoaded: return - + self.startAutomaticLoadingPos() - + def startAutomaticLoadingPos(self): self.AutoPilot = autopilot.AutoPilot(self) self.AutoPilot.execLoadPos() - + def stopAutomaticLoadingPos(self): if self.AutoPilot is None: return - + if self.AutoPilot.timer.isActive(): self.AutoPilot.timer.stop() self.AutoPilot = None - + def updatePos(self): self.updateCropZtool() self.setImageNameText() @@ -567,13 +588,13 @@ def updatePos(self): self.updateFreeRoiItem() self.updateBkgrROIs() self.saveBkgrROIs(self.data[self.pos_i]) - + def clearCurrentPos(self): self.removeBkgrROIs() self.removeCropROIs() def skip10ahead_frames(self): - if self.frame_i < self.num_frames-10: + if self.frame_i < self.num_frames - 10: self.frame_i += 10 else: self.frame_i = 0 @@ -583,7 +604,7 @@ def skip10back_frames(self): if self.frame_i > 9: self.frame_i -= 10 else: - self.frame_i = self.num_frames-1 + self.frame_i = self.num_frames - 1 self.update_img() def updateNavigateItems(self): @@ -592,24 +613,22 @@ def updateNavigateItems(self): # self.frameLabel.setText( # f'Current position = {self.pos_i+1}/{self.num_pos} ' # f'({posData.pos_foldername})') - self.navigateSB_label.setText(f'Pos n. {self.pos_i+1}') + self.navigateSB_label.setText(f"Pos n. {self.pos_i + 1}") try: self.navigateScrollbar.valueChanged.disconnect() except TypeError: pass - self.navigateScrollbar.setValue(self.pos_i+1) + self.navigateScrollbar.setValue(self.pos_i + 1) else: # self.frameLabel.setText( # f'Current frame = {self.frame_i+1}/{self.num_frames}') - self.navigateSB_label.setText(f'frame n. {self.frame_i+1}') + self.navigateSB_label.setText(f"frame n. {self.frame_i + 1}") try: self.navigateScrollbar.valueChanged.disconnect() except TypeError: pass - self.navigateScrollbar.setValue(self.frame_i+1) - self.navigateScrollbar.valueChanged.connect( - self.navigateScrollbarValueChanged - ) + self.navigateScrollbar.setValue(self.frame_i + 1) + self.navigateScrollbar.valueChanged.connect(self.navigateScrollbarValueChanged) def getImage(self, posData, img_data, frame_i, force_z=None): if posData.SizeT > 1: @@ -618,19 +637,19 @@ def getImage(self, posData, img_data, frame_i, force_z=None): img = img_data.copy() if posData.SizeZ > 1: if force_z is not None: - self.z_label.setText(f'z-slice {force_z+1}/{posData.SizeZ}') + self.z_label.setText(f"z-slice {force_z + 1}/{posData.SizeZ}") img = img[force_z] return img - df = posData.segmInfo_df + df = posData.segmInfo_df idx = (posData.filename, frame_i) try: - z = df.at[idx, 'z_slice_used_dataPrep'] + z = df.at[idx, "z_slice_used_dataPrep"] except Exception as e: duplicated_idx = df.index.duplicated() posData.segmInfo_df = df[~duplicated_idx] - z = posData.segmInfo_df.at[idx, 'z_slice_used_dataPrep'] - - zProjHow = posData.segmInfo_df.at[idx, 'which_z_proj'] + z = posData.segmInfo_df.at[idx, "z_slice_used_dataPrep"] + + zProjHow = posData.segmInfo_df.at[idx, "which_z_proj"] try: self.zProjComboBox.currentTextChanged.disconnect() except TypeError: @@ -638,17 +657,17 @@ def getImage(self, posData, img_data, frame_i, force_z=None): self.zProjComboBox.setCurrentText(zProjHow) self.zProjComboBox.currentTextChanged.connect(self.updateZproj) - if zProjHow == 'single z-slice': + if zProjHow == "single z-slice": self.zSliceScrollBar.valueChanged.disconnect() self.zSliceScrollBar.setSliderPosition(z) self.zSliceScrollBar.valueChanged.connect(self.update_z_slice) - self.z_label.setText(f'z-slice {z+1}/{posData.SizeZ}') + self.z_label.setText(f"z-slice {z + 1}/{posData.SizeZ}") img = img[z] - elif zProjHow == 'max z-projection': + elif zProjHow == "max z-projection": img = img.max(axis=0) - elif zProjHow == 'mean z-projection': + elif zProjHow == "mean z-projection": img = img.mean(axis=0) - elif zProjHow == 'median z-proj.': + elif zProjHow == "median z-proj.": img = np.median(img, axis=0) return img @@ -657,16 +676,16 @@ def update_img(self): self.updateNavigateItems() posData = self.data[self.pos_i] img = self.getImage(posData, posData.img_data, self.frame_i) - if self.zProjComboBox.currentText() == 'single z-slice': + if self.zProjComboBox.currentText() == "single z-slice": zslice = self.zSliceScrollBar.sliderPosition() else: zslice = None - + self.img.setCurrentZsliceIndex(zslice) self.img.setCurrentPosIndex(self.pos_i) self.img.setCurrentFrameIndex(self.frame_i) self.img.setImage(img) - self.zSliceScrollBar.setMaximum(posData.SizeZ-1) + self.zSliceScrollBar.setMaximum(posData.SizeZ - 1) def addAndConnectROI(self, roi): if roi not in self.ax1.items: @@ -675,15 +694,15 @@ def addAndConnectROI(self, roi): roi.sigRegionChanged.connect(self.updateCurrentRoiShape) roi.sigRegionChangeFinished.connect(self.ROImovingFinished) - + def addAndConnectCropROIs(self): if self.startAction.isEnabled() or self.onlySelectingZslice: return posData = self.data[self.pos_i] - if not hasattr(posData, 'cropROIs'): + if not hasattr(posData, "cropROIs"): return - + for cropROI in posData.cropROIs: self.addAndConnectROI(cropROI) @@ -692,9 +711,9 @@ def removeCropROIs(self): return posData = self.data[self.pos_i] - if not hasattr(posData, 'cropROIs'): - return - + if not hasattr(posData, "cropROIs"): + return + if posData.cropROIs is None: return @@ -707,9 +726,9 @@ def removeCropROIs(self): cropROI.sigRegionChangeFinished.disconnect() except TypeError: pass - + for c, cropROI in enumerate(posData.cropROIs): - cropROI.label.setText(f'ROI n. {c+1}') + cropROI.label.setText(f"ROI n. {c + 1}") def updateBkgrROIs(self): if self.startAction.isEnabled() or self.onlySelectingZslice: @@ -749,9 +768,7 @@ def init_attr(self): else: self.navigateScrollbar.setDisabled(True) self.navigateScrollbar.setValue(1) - self.navigateScrollbar.valueChanged.connect( - self.navigateScrollbarValueChanged - ) + self.navigateScrollbar.valueChanged.connect(self.navigateScrollbarValueChanged) self.startFrameIdxCrop = 0 self.endFrameIdxCrop = None self.isFreeRoiDrag = False @@ -760,10 +777,10 @@ def navigateScrollbarValueChanged(self, value): if self.num_pos > 1: self.removeBkgrROIs() self.removeCropROIs() - self.pos_i = value-1 + self.pos_i = value - 1 self.updatePos() else: - self.frame_i = value-1 + self.frame_i = value - 1 self.update_img() @exception_handler @@ -772,36 +789,34 @@ def crop(self, data, posData, cropROI): x0, y0 = [int(round(c)) for c in cropROI.pos()] w, h = [int(round(c)) for c in cropROI.size()] if data.ndim == 4: - croppedData = croppedData[:, :, y0:y0+h, x0:x0+w] + croppedData = croppedData[:, :, y0 : y0 + h, x0 : x0 + w] elif data.ndim == 3: - croppedData = croppedData[:, y0:y0+h, x0:x0+w] + croppedData = croppedData[:, y0 : y0 + h, x0 : x0 + w] elif data.ndim == 2: - croppedData = croppedData[y0:y0+h, x0:x0+w] - + croppedData = croppedData[y0 : y0 + h, x0 : x0 + w] + SizeZ = posData.SizeZ if posData.SizeZ > 1: idx = (posData.filename, 0) try: - lower_z = int(posData.segmInfo_df['crop_lower_z_slice'].iloc[0]) + lower_z = int(posData.segmInfo_df["crop_lower_z_slice"].iloc[0]) except KeyError: lower_z = 0 try: - upper_z = int(posData.segmInfo_df['crop_upper_z_slice'].iloc[0]) + upper_z = int(posData.segmInfo_df["crop_upper_z_slice"].iloc[0]) except KeyError: - upper_z = posData.SizeZ-1 + upper_z = posData.SizeZ - 1 if croppedData.ndim == 4: - croppedData = croppedData[:, lower_z:upper_z+1] + croppedData = croppedData[:, lower_z : upper_z + 1] elif croppedData.ndim == 3: - croppedData = croppedData[lower_z:upper_z+1] - SizeZ = (upper_z-lower_z)+1 - + croppedData = croppedData[lower_z : upper_z + 1] + SizeZ = (upper_z - lower_z) + 1 + if posData.SizeT > 1: - croppedData = croppedData[ - self.startFrameIdxCrop:self.endFrameIdxCrop - ] - + croppedData = croppedData[self.startFrameIdxCrop : self.endFrameIdxCrop] + return croppedData, SizeZ def saveBkgrROIs(self, posData): @@ -809,7 +824,7 @@ def saveBkgrROIs(self, posData): return ROIstates = [roi.saveState() for roi in posData.bkgrROIs] - with open(posData.dataPrepBkgrROis_path, 'w') as json_fp: + with open(posData.dataPrepBkgrROis_path, "w") as json_fp: json.dump(ROIstates, json_fp) def saveBkgrData(self, posData): @@ -825,18 +840,18 @@ def saveBkgrData(self, posData): for file in myutils.listdir(posData.images_path): filePath = os.path.join(posData.images_path, file) filenameNOext, _ = os.path.splitext(file) - if file.endswith(f'{chName}_aligned.npz'): + if file.endswith(f"{chName}_aligned.npz"): aligned_filename = filenameNOext aligned_filePath = filePath alignedFound = True - elif file.find(f'{chName}.tif') != -1: + elif file.find(f"{chName}.tif") != -1: tif_filename = filenameNOext tif_path = filePath tifFound = True if alignedFound: filename = aligned_filename - chData = np.load(aligned_filePath)['arr_0'] + chData = np.load(aligned_filePath)["arr_0"] elif tifFound: filename = tif_filename chData = load.imread(tif_path) @@ -845,7 +860,7 @@ def saveBkgrData(self, posData): for r, roi in enumerate(posData.bkgrROIs): xl, yt = [int(round(c)) for c in roi.pos()] w, h = [int(round(c)) for c in roi.size()] - if not yt+h>yt or not xl+w>xl: + if not yt + h > yt or not xl + w > xl: # Prevent 0 height or 0 width roi continue is4D = posData.SizeT > 1 and posData.SizeZ > 1 @@ -853,21 +868,21 @@ def saveBkgrData(self, posData): is3Dt = posData.SizeT > 1 and posData.SizeZ == 1 is2D = posData.SizeT == 1 and posData.SizeZ == 1 if is4D: - bkgr_data = chData[:, :, yt:yt+h, xl:xl+w] + bkgr_data = chData[:, :, yt : yt + h, xl : xl + w] elif is3Dz or is3Dt: - bkgr_data = chData[:, yt:yt+h, xl:xl+w] + bkgr_data = chData[:, yt : yt + h, xl : xl + w] elif is2D: - bkgr_data = chData[yt:yt+h, xl:xl+w] - bkgrROI_data[f'roi{r}_data'] = bkgr_data + bkgr_data = chData[yt : yt + h, xl : xl + w] + bkgrROI_data[f"roi{r}_data"] = bkgr_data if bkgrROI_data: - bkgr_data_fn = f'{filename}_bkgrRoiData.npz' + bkgr_data_fn = f"{filename}_bkgrRoiData.npz" bkgr_data_path = os.path.join(posData.images_path, bkgr_data_fn) - print('---------------------------------') - self.logger.info('Saving background data to:') + print("---------------------------------") + self.logger.info("Saving background data to:") self.logger.info(bkgr_data_path) - print('*********************************') - print('') + print("*********************************") + print("") io.savez_compressed(bkgr_data_path, **bkgrROI_data) def removeAllROIs(self, event): @@ -889,8 +904,8 @@ def removeROI(self, event): except Exception as e: posData.cropROIs.remove(self.roi_to_del) for c, cropROI in enumerate(posData.cropROIs): - cropROI.label.setText(f'ROI n. {c+1}') - + cropROI.label.setText(f"ROI n. {c + 1}") + self.ax1.removeItem(self.roi_to_del.label) self.ax1.removeItem(self.roi_to_del) if not posData.bkgrROIs: @@ -900,7 +915,7 @@ def removeROI(self, event): pass else: self.saveBkgrROIs(posData) - + def gui_raiseContextMenuRoi(self, roi, event, is_bkgr_ROI=True): self.roi_to_del = roi self.roiContextMenu = QMenu(self) @@ -908,17 +923,17 @@ def gui_raiseContextMenuRoi(self, roi, event, is_bkgr_ROI=True): separator.setSeparator(True) self.roiContextMenu.addAction(separator) if is_bkgr_ROI: - action1 = QAction('Remove background ROI') + action1 = QAction("Remove background ROI") else: - action1 = QAction('Remove crop ROI') + action1 = QAction("Remove crop ROI") action1.triggered.connect(self.removeROI) self.roiContextMenu.addAction(action1) if is_bkgr_ROI: - action2 = QAction('Remove ALL background ROIs') + action2 = QAction("Remove ALL background ROIs") action2.triggered.connect(self.removeAllROIs) self.roiContextMenu.addAction(action2) self.roiContextMenu.exec_(event.screenPos()) - + def gui_mousePressEventImg(self, event): posData = self.data[self.pos_i] right_click = event.button() == Qt.MouseButton.RightButton @@ -926,7 +941,7 @@ def gui_mousePressEventImg(self, event): freeRoiActive = self.freeRoiAction.isChecked() dragImg = left_click and not freeRoiActive - + if dragImg: pg.ImageItem.mousePressEvent(self.img, event) event.ignore() @@ -934,21 +949,23 @@ def gui_mousePressEventImg(self, event): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - + if freeRoiActive and self.freeRoiMask is not None and right_click: if self.isClickOnFreeRoi(xdata, ydata): self.showRemoveFreeRoiContextMenu(event) return - + handleSize = 7 # Check if right click on ROI for r, roi in enumerate(posData.bkgrROIs): x0, y0 = [int(c) for c in roi.pos()] w, h = [int(c) for c in roi.size()] - x1, y1 = x0+w, y0+h + x1, y1 = x0 + w, y0 + h clickedOnROI = ( - x>=x0-handleSize and x<=x1+handleSize - and y>=y0-handleSize and y<=y1+handleSize + x >= x0 - handleSize + and x <= x1 + handleSize + and y >= y0 - handleSize + and y <= y1 + handleSize ) raiseContextMenuRoi = right_click and clickedOnROI dragRoi = left_click and clickedOnROI @@ -958,34 +975,36 @@ def gui_mousePressEventImg(self, event): elif dragRoi and not freeRoiActive: event.ignore() return - + if left_click and freeRoiActive: self.isFreeRoiDrag = True self.freeRoiItem.clear() return - - if not hasattr(posData, 'cropROIs'): + + if not hasattr(posData, "cropROIs"): return - + if posData.cropROIs is None: return - + for c, cropROI in enumerate(posData.cropROIs): x0, y0 = [int(c) for c in cropROI.pos()] w, h = [int(c) for c in cropROI.size()] - x1, y1 = x0+w, y0+h + x1, y1 = x0 + w, y0 + h clickedOnROI = ( - x>=x0-handleSize and x<=x1+handleSize - and y>=y0-handleSize and y<=y1+handleSize + x >= x0 - handleSize + and x <= x1 + handleSize + and y >= y0 - handleSize + and y <= y1 + handleSize ) dragRoi = left_click and clickedOnROI if dragRoi: event.ignore() return - raiseContextMenuRoi = right_click and clickedOnROI and c>0 + raiseContextMenuRoi = right_click and clickedOnROI and c > 0 if raiseContextMenuRoi: self.gui_raiseContextMenuRoi(cropROI, event, is_bkgr_ROI=False) - + def gui_mouseDragEventImg(self, event): posData = self.data[self.pos_i] x, y = event.pos().x(), event.pos().y() @@ -993,10 +1012,10 @@ def gui_mouseDragEventImg(self, event): xdata, ydata = int(x), int(y) if not myutils.is_in_bounds(xdata, ydata, X, Y): return - + if self.isFreeRoiDrag: self.freeRoiItem.addPoint(xdata, ydata) - + def saveFreeRoi(self): posData = self.data[self.pos_i] xx, yy = self.freeRoiItem.getData() @@ -1006,25 +1025,25 @@ def saveFreeRoi(self): self.freeRoiItem, logger_func=self.logger.info ) self.dataPrepFreeRoiSaved() - + def gui_mouseReleaseEventImg(self, event): posData = self.data[self.pos_i] if self.isFreeRoiDrag: self.freeRoiItem.closeCurve() self.saveFreeRoi() - + def dataPrepFreeRoiSaved(self): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - 'Free-hand ROI saved.

' - 'When you segment the data, the objects outside of this ROI will be ' - 'automatically removed from the segmentation masks.

' - 'To remove the free-hand ROI, right-click on it and select ' + "Free-hand ROI saved.

" + "When you segment the data, the objects outside of this ROI will be " + "automatically removed from the segmentation masks.

" + "To remove the free-hand ROI, right-click on it and select " '"Remove free-hand ROI".

' - 'See you at the next one!' + "See you at the next one!" ) - msg.information(self, 'Free-hand ROI saved', txt) - + msg.information(self, "Free-hand ROI saved", txt) + def isClickOnFreeRoi(self, xdata, ydata): y0, x0 = self.freeRoiYXorigin local_x = xdata - x0 @@ -1033,61 +1052,60 @@ def isClickOnFreeRoi(self, xdata, ydata): return False if local_y < 0 or local_y >= self.freeRoiMask.shape[0]: return False - + return self.freeRoiMask[local_y, local_x] - + def getAllChannelsPaths(self, posData): _zip = zip(posData.tif_paths, posData.all_npz_paths) for tif_path, npz_path in _zip: if self.align: - uncropped_data = np.load(npz_path)['arr_0'] + uncropped_data = np.load(npz_path)["arr_0"] else: uncropped_data = load.imread(tif_path) - + yield uncropped_data, npz_path, tif_path - + def saveCroppedChannel(self, cropped_data, npz_path, tif_path, posData): - self.logger.info( - f'Saving cropped data with shape {cropped_data.shape}' - ) + self.logger.info(f"Saving cropped data with shape {cropped_data.shape}") if self.align or os.path.exists(npz_path): - self.logger.info(f'Saving: {npz_path}') + self.logger.info(f"Saving: {npz_path}") temp_npz = self.getTempfilePath(npz_path) io.savez_compressed(temp_npz, cropped_data) self.moveTempFile(temp_npz, npz_path) - self.logger.info(f'Saving: {tif_path}') + self.logger.info(f"Saving: {tif_path}") temp_tif = self.getTempfilePath(tif_path) myutils.to_tiff( - temp_tif, cropped_data, - SizeT=getattr(posData, 'SizeT', None), - SizeZ=getattr(posData, 'SizeZ', None), - TimeIncrement=getattr(posData, 'TimeIncrement', None), - PhysicalSizeZ=getattr(posData, 'PhysicalSizeZ', None), - PhysicalSizeY=getattr(posData, 'PhysicalSizeY', None), - PhysicalSizeX=getattr(posData, 'PhysicalSizeX', None), + temp_tif, + cropped_data, + SizeT=getattr(posData, "SizeT", None), + SizeZ=getattr(posData, "SizeZ", None), + TimeIncrement=getattr(posData, "TimeIncrement", None), + PhysicalSizeZ=getattr(posData, "PhysicalSizeZ", None), + PhysicalSizeY=getattr(posData, "PhysicalSizeY", None), + PhysicalSizeX=getattr(posData, "PhysicalSizeX", None), ) self.moveTempFile(temp_tif, tif_path) - + def saveCroppedSegmData(self, posData, segm_npz_path, cropROI): if not posData.segmFound: return - self.logger.info(f'Saving: {segm_npz_path}') + self.logger.info(f"Saving: {segm_npz_path}") croppedSegm, _ = self.crop(posData.segm_data, posData, cropROI) temp_npz = self.getTempfilePath(segm_npz_path) io.savez_compressed(temp_npz, croppedSegm) self.moveTempFile(temp_npz, segm_npz_path) - + def correctAcdcDfCrop(self, posData, acdc_output_csv_path, cropROI): try: # Correct acdc_df if present and save if posData.acdc_df is not None: x0, y0 = [int(round(c)) for c in cropROI.pos()] - self.logger.info(f'Saving: {acdc_output_csv_path}') + self.logger.info(f"Saving: {acdc_output_csv_path}") df = posData.acdc_df - df['x_centroid'] -= x0 - df['y_centroid'] -= y0 + df["x_centroid"] -= x0 + df["y_centroid"] -= y0 try: df.to_csv(acdc_output_csv_path) except PermissionError: @@ -1095,194 +1113,173 @@ def correctAcdcDfCrop(self, posData, acdc_output_csv_path, cropROI): df.to_csv(acdc_output_csv_path) except Exception as e: pass - + def copyAdditionalFilesToCropFolder( - self, posData, subImagesPath, cropBasename, cropIdx=0 - ): - subImagesPath = subImagesPath.replace('\\', '/') - parentImagesPath = posData.images_path.replace('\\', '/') + self, posData, subImagesPath, cropBasename, cropIdx=0 + ): + subImagesPath = subImagesPath.replace("\\", "/") + parentImagesPath = posData.images_path.replace("\\", "/") if parentImagesPath == subImagesPath: return - + basename = posData.basename try: df_roi = posData.dataPrep_ROIcoords.loc[[cropIdx]] - df_roi_filename = os.path.basename( - posData.dataPrepROI_coords_path - ) - df_roi_endname = df_roi_filename[len(basename):] - crop_df_roi_filename = f'{cropBasename}{df_roi_endname}' - df_roi_filepath = os.path.join( - subImagesPath, crop_df_roi_filename - ) + df_roi_filename = os.path.basename(posData.dataPrepROI_coords_path) + df_roi_endname = df_roi_filename[len(basename) :] + crop_df_roi_filename = f"{cropBasename}{df_roi_endname}" + df_roi_filepath = os.path.join(subImagesPath, crop_df_roi_filename) df_roi.to_csv(df_roi_filepath) except IndexError: pass - - for file in myutils.listdir(posData.images_path): + + for file in myutils.listdir(posData.images_path): copy_file = ( - file.endswith('bkgrRoiData.npz') - or file.endswith('dataPrep_bkgrROIs.json') - or file.endswith('segmInfo.csv') - or file.endswith('dataPrepFreeRoi.npz') + file.endswith("bkgrRoiData.npz") + or file.endswith("dataPrep_bkgrROIs.json") + or file.endswith("segmInfo.csv") + or file.endswith("dataPrepFreeRoi.npz") ) - is_metadata_file = file.endswith('metadata.csv') + is_metadata_file = file.endswith("metadata.csv") if not copy_file and not is_metadata_file: continue - + src_filepath = os.path.join(posData.images_path, file) - endname = file[len(basename):] - crop_filename = f'{cropBasename}{endname}' + endname = file[len(basename) :] + crop_filename = f"{cropBasename}{endname}" sub_filepath = os.path.join(subImagesPath, crop_filename) if os.path.exists(sub_filepath): continue - + if copy_file: shutil.copyfile(src_filepath, sub_filepath) elif is_metadata_file: - df_metadata = pd.read_csv( - src_filepath, index_col='Description' - ) - df_metadata.at['basename', 'values'] = cropBasename + df_metadata = pd.read_csv(src_filepath, index_col="Description") + df_metadata.at["basename", "values"] = cropBasename df_metadata.to_csv(sub_filepath) - + def saveSingleCrop(self, posData, cropROI, dstPath): if dstPath != posData.images_path: currentSubPosFolders = myutils.get_pos_foldernames(dstPath) currentSubPosNumbers = [ - int(pos.split('_')[-1]) for pos in currentSubPosFolders + int(pos.split("_")[-1]) for pos in currentSubPosFolders ] startPosNumber = max(currentSubPosNumbers, default=0) + 1 cropNum = startPosNumber - subPosFolder = f'Position_{cropNum}' + subPosFolder = f"Position_{cropNum}" subPosFolderPath = os.path.join(dstPath, subPosFolder) - subImagesPath = os.path.join(subPosFolderPath, 'Images') + subImagesPath = os.path.join(subPosFolderPath, "Images") os.makedirs(subImagesPath) - cropBasename = f'{posData.basename}crop{cropNum}_' + cropBasename = f"{posData.basename}crop{cropNum}_" else: subImagesPath = dstPath cropBasename = posData.basename - + self._saveCroppedData(posData, subImagesPath, cropROI, cropBasename) - + def _saveCroppedData( - self, posData, subImagesPath, cropROI, cropBasename, cropIdx=0 - ): + self, posData, subImagesPath, cropROI, cropBasename, cropIdx=0 + ): basename = posData.basename _iter = self.getAllChannelsPaths(posData) for uncropped_data, npz_path, tif_path in _iter: cropped_data, _ = self.crop(uncropped_data, posData, cropROI) npz_filename = os.path.basename(npz_path) tif_filename = os.path.basename(tif_path) - npz_endname = npz_filename[len(basename):] - tif_endname = tif_filename[len(basename):] - crop_npz_filename = f'{cropBasename}{npz_endname}' - crop_tif_filename = f'{cropBasename}{tif_endname}' + npz_endname = npz_filename[len(basename) :] + tif_endname = tif_filename[len(basename) :] + crop_npz_filename = f"{cropBasename}{npz_endname}" + crop_tif_filename = f"{cropBasename}{tif_endname}" sub_npz_filepath = os.path.join(subImagesPath, crop_npz_filename) sub_tif_filepath = os.path.join(subImagesPath, crop_tif_filename) self.saveCroppedChannel( - cropped_data, sub_npz_filepath, sub_tif_filepath, - posData + cropped_data, sub_npz_filepath, sub_tif_filepath, posData ) - + segm_filename = os.path.basename(posData.segm_npz_path) - segm_endname = segm_filename[len(basename):] - crop_segm_filename = f'{cropBasename}{segm_endname}' + segm_endname = segm_filename[len(basename) :] + crop_segm_filename = f"{cropBasename}{segm_endname}" sub_segm_filepath = os.path.join(subImagesPath, crop_segm_filename) self.saveCroppedSegmData(posData, sub_segm_filepath, cropROI) - + acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) - acdc_df_endname = acdc_df_filename[len(basename):] - crop_acdc_df_filename = f'{cropBasename}{acdc_df_endname}' + acdc_df_endname = acdc_df_filename[len(basename) :] + crop_acdc_df_filename = f"{cropBasename}{acdc_df_endname}" acdc_df_filepath = os.path.join(subImagesPath, crop_acdc_df_filename) self.correctAcdcDfCrop(posData, acdc_df_filepath, cropROI) - - self.saveMasterFolderPathTxt( - posData, subImagesPath, basename=cropBasename - ) - + + self.saveMasterFolderPathTxt(posData, subImagesPath, basename=cropBasename) + self.copyAdditionalFilesToCropFolder( posData, subImagesPath, cropBasename, cropIdx=cropIdx ) - + def saveMasterFolderPathTxt(self, posData, subImagesPath, basename=None): - subImagesPath = subImagesPath.replace('\\', '/') - parentImagesPath = posData.images_path.replace('\\', '/') + subImagesPath = subImagesPath.replace("\\", "/") + parentImagesPath = posData.images_path.replace("\\", "/") if parentImagesPath == subImagesPath: return - + if basename is None: basename = posData.basename - - filename = f'{basename}master_position.txt' + + filename = f"{basename}master_position.txt" filepath = os.path.join(subImagesPath, filename) - masterPos = posData.pos_path.replace('\\', os.sep).replace('/', os.sep) - with open(filepath, 'w') as txt: + masterPos = posData.pos_path.replace("\\", os.sep).replace("/", os.sep) + with open(filepath, "w") as txt: txt.write(masterPos) - + def startCropWorker(self, posData, dstPath): # Disable clicks on image during alignment self.img.mousePressEvent = None - + if posData.SizeT > 1: self.progressWin = apps.QDialogWorkerProgress( - title='Saving cropped data', + title="Saving cropped data", parent=self, - pbarDesc=f'Saving cropped data...' + pbarDesc=f"Saving cropped data...", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - + self._thread = QThread() - + self.cropWorker = workers.DataPrepCropWorker(posData, self, dstPath) self.cropWorker.moveToThread(self._thread) - + self.cropWorker.moveToThread(self._thread) self.cropWorker.signals.finished.connect(self._thread.quit) - self.cropWorker.signals.finished.connect( - self.cropWorker.deleteLater - ) + self.cropWorker.signals.finished.connect(self.cropWorker.deleteLater) self._thread.finished.connect(self._thread.deleteLater) - self.cropWorker.signals.finished.connect( - self.cropWorkerFinished - ) + self.cropWorker.signals.finished.connect(self.cropWorkerFinished) self.cropWorker.signals.progress.connect(self.workerProgress) - self.cropWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.cropWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.cropWorker.signals.critical.connect( - self.workerCritical - ) - + self.cropWorker.signals.initProgressBar.connect(self.workerInitProgressbar) + self.cropWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.cropWorker.signals.critical.connect(self.workerCritical) + self._thread.started.connect(self.cropWorker.run) self._thread.start() return self.cropWorker - + def startSaveBkgrDataWorker(self, posData): # Disable clicks on image during alignment self.img.mousePressEvent = None - + if posData.SizeT > 1: self.progressWin = apps.QDialogWorkerProgress( - title='Saving background data', + title="Saving background data", parent=self, - pbarDesc=f'Saving background data...' + pbarDesc=f"Saving background data...", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - + self._thread = QThread() - - self.saveBkgrDataWorker = workers.DataPrepSaveBkgrDataWorker( - posData, self - ) + + self.saveBkgrDataWorker = workers.DataPrepSaveBkgrDataWorker(posData, self) self.saveBkgrDataWorker.moveToThread(self._thread) - + self.saveBkgrDataWorker.moveToThread(self._thread) self.saveBkgrDataWorker.signals.finished.connect(self._thread.quit) self.saveBkgrDataWorker.signals.finished.connect( @@ -1300,14 +1297,12 @@ def startSaveBkgrDataWorker(self, posData): self.saveBkgrDataWorker.signals.progressBar.connect( self.workerUpdateProgressbar ) - self.saveBkgrDataWorker.signals.critical.connect( - self.workerCritical - ) - + self.saveBkgrDataWorker.signals.critical.connect(self.workerCritical) + self._thread.started.connect(self.saveBkgrDataWorker.run) self._thread.start() return self.saveBkgrDataWorker - + def saveCroppedData(self, posData, cropDstPaths): if len(posData.cropROIs) == 1: worker = self.startCropWorker(posData, cropDstPaths[0]) @@ -1315,31 +1310,31 @@ def saveCroppedData(self, posData, cropDstPaths): else: self.saveMultiCrops(posData, cropDstPaths) - self.logger.info(f'{posData.pos_foldername} saved!') - print(f'--------------------------------') - print('') - - def saveMultiCrops(self, posData, cropDstPaths): + self.logger.info(f"{posData.pos_foldername} saved!") + print(f"--------------------------------") + print("") + + def saveMultiCrops(self, posData, cropDstPaths): basename = posData.basename for p, cropROI in enumerate(posData.cropROIs): parentSubPosPath = cropDstPaths[p] currentSubPosFolders = myutils.get_pos_foldernames(parentSubPosPath) currentSubPosNumbers = [ - int(pos.split('_')[-1]) for pos in currentSubPosFolders + int(pos.split("_")[-1]) for pos in currentSubPosFolders ] startPosNumber = max(currentSubPosNumbers, default=0) + 1 cropNum = startPosNumber - subPosFolder = f'Position_{cropNum}' + subPosFolder = f"Position_{cropNum}" subPosFolderPath = os.path.join(parentSubPosPath, subPosFolder) - subImagesPath = os.path.join(subPosFolderPath, 'Images') + subImagesPath = os.path.join(subPosFolderPath, "Images") os.makedirs(subImagesPath) - - cropBasename = f'{basename}crop{cropNum}_' - + + cropBasename = f"{basename}crop{cropNum}_" + self._saveCroppedData( posData, subImagesPath, cropROI, cropBasename, cropIdx=p ) - + def saveROIcoords(self, doCrop, posData): dfs = [] keys = [] @@ -1348,43 +1343,39 @@ def saveROIcoords(self, doCrop, posData): w, h = [int(round(c)) for c in cropROI.size()] Y, X = self.img.image.shape - x1, y1 = x0+w, y0+h + x1, y1 = x0 + w, y0 + h - x0 = x0 if x0>0 else 0 - y0 = y0 if y0>0 else 0 - x1 = x1 if x1 0 else 0 + y0 = y0 if y0 > 0 else 0 + x1 = x1 if x1 < X else X + y1 = y1 if y1 < Y else Y - if x0<=0 and y0<=0 and x1>=X and y1>=Y: + if x0 <= 0 and y0 <= 0 and x1 >= X and y1 >= Y: # ROI coordinates are the exact image shape. No need to save them continue - + keys.append(c) - description = ['x_left', 'x_right', 'y_top', 'y_bottom', 'cropped'] + description = ["x_left", "x_right", "y_top", "y_bottom", "cropped"] values = [x0, x1, y0, y1, int(doCrop)] - df_roi = ( - pd.DataFrame({'description': description, 'value': values}) - .set_index('description') - ) - + df_roi = pd.DataFrame( + {"description": description, "value": values} + ).set_index("description") + dfs.append(df_roi) - + if not dfs: return - - df = pd.concat(dfs, keys=keys, names=['roi_id']) - self.logger.info( - f'Saving ROI coords ' - f'to "{posData.dataPrepROI_coords_path}"' - ) + df = pd.concat(dfs, keys=keys, names=["roi_id"]) + + self.logger.info(f'Saving ROI coords to "{posData.dataPrepROI_coords_path}"') try: df.to_csv(posData.dataPrepROI_coords_path) except PermissionError: self.permissionErrorCritical(posData.dataPrepROI_coords_path) df.to_csv(posData.dataPrepROI_coords_path) - + posData.dataPrep_ROIcoords = df def openCropZtool(self, checked): @@ -1401,7 +1392,7 @@ def openCropZtool(self, checked): # Restore original z-slice df = posData.segmInfo_df idx = (posData.filename, self.frame_i) - z = posData.segmInfo_df.at[idx, 'z_slice_used_dataPrep'] + z = posData.segmInfo_df.at[idx, "z_slice_used_dataPrep"] self.zSliceScrollBar.setValue(z) def openCropTtool(self, checked): @@ -1415,7 +1406,7 @@ def openCropTtool(self, checked): else: self.cropZtool.close() self.cropZtool = None - + def cropZtoolvalueChanged(self, whichZ, z): self.zSliceScrollBar.valueChanged.disconnect() self.zSliceScrollBar.setValue(z) @@ -1438,12 +1429,12 @@ def updateCropZtool(self): return try: - lower_z = int(posData.segmInfo_df['crop_lower_z_slice'].iloc[0]) + lower_z = int(posData.segmInfo_df["crop_lower_z_slice"].iloc[0]) except KeyError: lower_z = 0 try: - upper_z = int(posData.segmInfo_df['crop_upper_z_slice'].iloc[0]) + upper_z = int(posData.segmInfo_df["crop_upper_z_slice"].iloc[0]) except KeyError: upper_z = posData.SizeZ @@ -1455,59 +1446,57 @@ def cropZtoolClosed(self): self.cropZtool = None posData = self.data[self.pos_i] idx = (posData.filename, self.frame_i) - z = posData.segmInfo_df.at[idx, 'z_slice_used_dataPrep'] + z = posData.segmInfo_df.at[idx, "z_slice_used_dataPrep"] self.zSliceScrollBar.setSliderPosition(z) self.cropZaction.toggled.disconnect() self.cropZaction.setChecked(False) self.cropZaction.toggled.connect(self.openCropZtool) - + def cropTtoolClosed(self): self.cropTtool = None self.cropTaction.toggled.disconnect() self.cropTaction.setChecked(False) self.cropTaction.toggled.connect(self.openCropTtool) - + def cropTtoolvalueChanged(self, frame_i): - self.navigateScrollbar.setValue(frame_i+1) - + self.navigateScrollbar.setValue(frame_i + 1) + def applyCropTrange(self, start_frame_i, end_frame_i): self.startFrameIdxCrop = start_frame_i self.endFrameIdxCrop = end_frame_i + 1 self.logger.info( - f'Previewing cropped frames ({start_frame_i+1},{end_frame_i+1})...' + f"Previewing cropped frames ({start_frame_i + 1},{end_frame_i + 1})..." ) for posData in self.data: posData.img_data[:start_frame_i] = 0 - posData.img_data[end_frame_i+1:] = 0 - + posData.img_data[end_frame_i + 1 :] = 0 + self.update_img() note_text = ( - f'Done. Frames outside of the range ({start_frame_i+1},{end_frame_i+1}) ' + f"Done. Frames outside of the range ({start_frame_i + 1},{end_frame_i + 1}) " 'will appear black now. To save cropped data, click on the "Save" ' - 'button on the top toolbar.' + "button on the top toolbar." ) self.logger.info(note_text) - + txt = html_utils.paragraph(f""" Cropping frames applied.

Note that this is just a preview where the frames outside of the - range ({start_frame_i+1},{end_frame_i+1}) will look black.

+ range ({start_frame_i + 1},{end_frame_i + 1}) will look black.

To save cropped data, click on the Save cropped data button on the top toolbar. """) msg = widgets.myMessageBox(wrapText=False) - msg.information(self, 'Preview cropped frames', txt) + msg.information(self, "Preview cropped frames", txt) def addFreeRoiItem(self): if self.freeRoiItem is not None: return - - self.freeRoiItem = widgets.PlotCurveItem( - pen=pg.mkPen(color='r', width=2) - ) + + self.freeRoiItem = widgets.PlotCurveItem(pen=pg.mkPen(color="r", width=2)) self.ax1.addItem(self.freeRoiItem) self.updateFreeRoiItem() - + def updateFreeRoiItem(self): posData = self.data[self.pos_i] for point in posData.dataPrepFreeRoiPoints: @@ -1516,25 +1505,25 @@ def updateFreeRoiItem(self): if len(posData.dataPrepFreeRoiPoints) == 0: self.freeRoiMask = None return - + xx, yy = self.freeRoiItem.getData() self.freeRoiYXorigin = (yy.min(), xx.min()) self.freeRoiMask = self.freeRoiItem.mask() - + def freeRoiActionToggled(self, checked): if checked: self.hideROIs() self.addFreeRoiItem() else: self.reAddROIs() - + def getCroppedData(self, askCropping=True, doValidateFreeRoi=False): for p, posData in enumerate(self.data): self.saveBkgrROIs(posData) # Get crop shape and print it data = posData.img_data - + allCropsData = [] for cropROI in posData.cropROIs: croppedData, SizeZ = self.crop(data, posData, cropROI) @@ -1542,74 +1531,70 @@ def getCroppedData(self, askCropping=True, doValidateFreeRoi=False): croppedShapes = [cropped.shape for cropped in allCropsData] isCropped = any([shape != data.shape for shape in croppedShapes]) - + proceed = True if isCropped: if p == 0 and askCropping: - proceed = self.askCropping( - data.shape, croppedShapes - ) + proceed = self.askCropping(data.shape, croppedShapes) doCrop = proceed else: doCrop = True else: doCrop = False - + if not proceed: self.setEnabledCropActions(True) - txt = ('Cropping cancelled.') - self.titleLabel.setText(txt, color='r') + txt = "Cropping cancelled." + self.titleLabel.setText(txt, color="r") self.logger.info(txt) yield None elif not isCropped: self.setEnabledCropActions(True) txt = ( - 'Crop ROI has same shape of the image --> no need to crop. ' - 'Process stopped.' + "Crop ROI has same shape of the image --> no need to crop. " + "Process stopped." ) - self.titleLabel.setText(txt, color='r') + self.titleLabel.setText(txt, color="r") self.logger.info(txt) - yield 'continue' + yield "continue" elif not doValidateFreeRoi: yield croppedShapes, posData, SizeZ, doCrop else: - proceed = self.validateFreeRoi(posData, warn=p==0) + proceed = self.validateFreeRoi(posData, warn=p == 0) if not proceed: self.setEnabledCropActions(True) - txt = ('Cropping cancelled because overlaps with free roi.') - self.titleLabel.setText(txt, color='r') + txt = "Cropping cancelled because overlaps with free roi." + self.titleLabel.setText(txt, color="r") self.logger.info(txt) yield None else: yield croppedShapes, posData, SizeZ, doCrop - + def validateFreeRoi(self, posData, warn=True): posData.loadDataPrepFreeRoi(logger_func=self.logger.info) if len(posData.dataPrepFreeRoiPoints) == 0: return True - + if len(posData.cropROIs) > 1: if warn: proceed = self.warnMultiCropsWithFreeRoi() else: proceed = True - + if proceed: posData.removeDataPrepFreeRoi() self.freeRoiItem.clear() self.freeRoiMask = None - return proceed - + return proceed + cropROI = posData.cropROIs[0] x0, y0 = [int(round(c)) for c in cropROI.pos()] w, h = [int(round(c)) for c in cropROI.size()] - x1, y1 = x0+w, y0+h - + x1, y1 = x0 + w, y0 + h + y0f, x0f, y1f, x1f = posData.dataPrepFreeRoiBbox - - is_free_roi_in_crop_bounds = ( - x0f >= x0 and x1f <= x1 and y0f >= y0 and y1f <= y1 - ) + + is_free_roi_in_crop_bounds = x0f >= x0 and x1f <= x1 and y0f >= y0 and y1f <= y1 if not is_free_roi_in_crop_bounds and warn: proceed = self.warnFreeRoiOverlapsWithCropRoi() else: @@ -1617,7 +1602,7 @@ def validateFreeRoi(self, posData, warn=True): if not proceed: return False - + # Adjust free-hand ROI according to crop ROI local_mask = posData.dataPrepFreeRoiLocalMask crop_x0, crop_y0, crop_x1, crop_y1 = None, None, None, None @@ -1626,36 +1611,36 @@ def validateFreeRoi(self, posData, warn=True): x0f = 0 else: x0f = x0f - x0 - + if y0f < y0: crop_y0 = y0 - y0f y0f = 0 else: y0f = y0f - y0 - + if x1f > x1: crop_x1 = x1 - x1f x1f = w else: x1f = x1f - x0 - + if y1f > y1: crop_y1 = y1 - y1f y1f = h else: y1f = y1f - y0 - local_mask = posData.dataPrepFreeRoiLocalMask[ - crop_y0:crop_y1, crop_x0:crop_x1 - ] + local_mask = posData.dataPrepFreeRoiLocalMask[crop_y0:crop_y1, crop_x0:crop_x1] bbox = (y0f, x0f, y1f, x1f) posData.saveDataPrepFreeRoi( - self.freeRoiItem, logger_func=self.logger.info, - bbox=bbox, local_mask=local_mask + self.freeRoiItem, + logger_func=self.logger.info, + bbox=bbox, + local_mask=local_mask, ) - + return proceed - + def warnFreeRoiOverlapsWithCropRoi(self): txt = html_utils.paragraph(f""" The crop ROI is smaller than the free-hand ROI.

@@ -1663,14 +1648,13 @@ def warnFreeRoiOverlapsWithCropRoi(self): """) msg = widgets.myMessageBox(wrapText=False) noButton, yesButton = msg.warning( - self, 'Crop ROI is smaller than free-hand ROI', txt, - buttonsTexts=( - 'No, stop cropping process', - 'Yes, continue with cropping' - ), + self, + "Crop ROI is smaller than free-hand ROI", + txt, + buttonsTexts=("No, stop cropping process", "Yes, continue with cropping"), ) return msg.clickedButton == yesButton - + def warnMultiCropsWithFreeRoi(self): txt = html_utils.paragraph(f""" You are about to create multiple crops and you also have a @@ -1682,94 +1666,91 @@ def warnMultiCropsWithFreeRoi(self): """) msg = widgets.myMessageBox(wrapText=False) noButton, yesButton = msg.warning( - self, 'Multiple crops with free-hand ROI', txt, - buttonsTexts=( - 'No, stop cropping process', - 'Yes, continue with cropping' - ), + self, + "Multiple crops with free-hand ROI", + txt, + buttonsTexts=("No, stop cropping process", "Yes, continue with cropping"), ) return msg.clickedButton == yesButton - + def applyCropZslices(self, low_z, high_z): self.logger.info( - f'Previewing cropped z-slices in the range ({low_z+1},{high_z+1})...' + f"Previewing cropped z-slices in the range ({low_z + 1},{high_z + 1})..." ) for posData in self.data: - posData.segmInfo_df['crop_lower_z_slice'] = low_z - posData.segmInfo_df['crop_upper_z_slice'] = high_z + posData.segmInfo_df["crop_lower_z_slice"] = low_z + posData.segmInfo_df["crop_upper_z_slice"] = high_z if posData.SizeT > 1: posData.img_data[:, :low_z] = 0 - posData.img_data[:, high_z+1:] = 0 + posData.img_data[:, high_z + 1 :] = 0 else: posData.img_data[:low_z] = 0 - posData.img_data[high_z+1:] = 0 - + posData.img_data[high_z + 1 :] = 0 + self.update_img() note_text = ( - f'Done. Z-slices outside of the range ({low_z+1},{high_z+1}) ' + f"Done. Z-slices outside of the range ({low_z + 1},{high_z + 1}) " 'will appear black now. To save cropped data, click on the "Save" ' - 'button on the top toolbar.' + "button on the top toolbar." ) self.logger.info(note_text) - + txt = html_utils.paragraph(f""" Cropping z-slice applied.

Note that this is just a preview where the z-slices outside of the - range ({low_z+1},{high_z+1}) will look black.

+ range ({low_z + 1},{high_z + 1}) will look black.

To save cropped data, click on the Save cropped data button on the top toolbar. """) msg = widgets.myMessageBox(wrapText=False) - msg.information(self, 'Preview cropped z-slices', txt) - + msg.information(self, "Preview cropped z-slices", txt) + def applyCropYX(self): for posData in self.data: for cropROI in posData.cropROIs: x0, y0 = [int(round(c)) for c in cropROI.pos()] w, h = [int(round(c)) for c in cropROI.size()] cropMask = np.zeros(posData.img_data.shape, dtype=bool) - cropMask[..., y0:y0+h, x0:x0+w] = True + cropMask[..., y0 : y0 + h, x0 : x0 + w] = True posData.img_data[~cropMask] = 0 - self.update_img() - + self.update_img() + def saveActionTriggered(self): if self.tempFilesToMove: cancel = self.warnSaveAlignedNotReversible() if not cancel: self.startMoveTempFilesWorker() self.waitMoveTempFilesWorker() - + self.cropAndSave() - + @exception_handler def cropAndSave(self): cropPaths = {} - for cropInfo in self.getCroppedData( - askCropping=True, doValidateFreeRoi=True - ): + for cropInfo in self.getCroppedData(askCropping=True, doValidateFreeRoi=True): if cropInfo is None: # Process cancelled by the user return - if cropInfo == 'continue': + if cropInfo == "continue": continue - + croppedShapes, posData, SizeZ, doCrop = cropInfo if len(croppedShapes) == 1: masterPath = posData.images_path else: masterPath = posData.pos_path - + cropPaths[masterPath] = len(croppedShapes) - + if not cropPaths: return - + win = apps.DataPrepSubCropsPathsDialog(cropPaths=cropPaths) win.exec_() if win.cancel: - txt = 'Cropping cancelled.' - self.titleLabel.setText(txt, color='r') + txt = "Cropping cancelled." + self.titleLabel.setText(txt, color="r") return dstPaths = win.folderPaths @@ -1778,92 +1759,90 @@ def cropAndSave(self): if cropInfo is None: # Process cancelled by the user return - - if cropInfo == 'continue': + + if cropInfo == "continue": continue - + croppedShapes, posData, SizeZ, doCrop = cropInfo posData.SizeZ = SizeZ # Update metadata with cropped SizeZ - posData.metadata_df.at['SizeZ', 'values'] = SizeZ + posData.metadata_df.at["SizeZ", "values"] = SizeZ posData.metadata_df.to_csv(posData.metadata_csv_path) - self.logger.info(f'Cropping {posData.relPath}...') + self.logger.info(f"Cropping {posData.relPath}...") self.titleLabel.setText( - 'Cropping... (check progress in the terminal)', - color='w') + "Cropping... (check progress in the terminal)", color="w" + ) - croppedShapesFormat = [f' --> {shape}' for shape in croppedShapes] - croppedShapesFormat = '\n'.join(croppedShapesFormat) - self.logger.info(f'Cropped data shape:\n{croppedShapesFormat}') + croppedShapesFormat = [f" --> {shape}" for shape in croppedShapes] + croppedShapesFormat = "\n".join(croppedShapesFormat) + self.logger.info(f"Cropped data shape:\n{croppedShapesFormat}") self.saveROIcoords(doCrop, posData) - self.logger.info('Saving background data...') - + self.logger.info("Saving background data...") + worker = self.startSaveBkgrDataWorker(posData) self.waitWorker(worker) - + if len(croppedShapes) == 1: masterPath = posData.images_path else: masterPath = posData.pos_path - - self.logger.info('Cropping...') + + self.logger.info("Cropping...") self.saveCroppedData(posData, dstPaths[masterPath]) - + for posData in self.data: self.disconnectROIs(posData) if posData.SizeZ > 1: # Save segmInfo try: - low_z = posData.segmInfo_df['crop_lower_z_slice'] - posData.segmInfo_df['z_slice_used_dataPrep'] -= low_z + low_z = posData.segmInfo_df["crop_lower_z_slice"] + posData.segmInfo_df["z_slice_used_dataPrep"] -= low_z except Exception as err: - pass - + pass + posData.segmInfo_df = posData.segmInfo_df.drop( - columns=['crop_lower_z_slice', 'crop_upper_z_slice'], - errors='ignore' + columns=["crop_lower_z_slice", "crop_upper_z_slice"], + errors="ignore", ) posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) - txt = ( - 'Saved! You can close the program or load another position.' - ) - self.titleLabel.setText(txt, color='g') + txt = "Saved! You can close the program or load another position." + self.titleLabel.setText(txt, color="g") msg = widgets.myMessageBox() - txt = html_utils.paragraph(txt.replace('! ', '!

')) - msg.information(self, 'Data prep done', txt) - - self.saveAction.setEnabled(False) - + txt = html_utils.paragraph(txt.replace("! ", "!

")) + msg.information(self, "Data prep done", txt) + + self.saveAction.setEnabled(False) + def setEnabledCropActions(self, enabled): self.cropAction.setEnabled(enabled) self.cropZaction.setEnabled(enabled) self.saveAction.setEnabled(enabled) self.cropTaction.setEnabled(enabled) self.freeRoiAction.setEnabled(enabled) - - if not hasattr(self, 'data'): + + if not hasattr(self, "data"): return - + posData = self.data[self.pos_i] if posData.SizeZ == 1: self.cropZaction.setEnabled(False) - + if posData.SizeT == 1: self.cropTaction.setEnabled(False) - + def removeAllHandles(self, roi): for handle in roi.handles: - item = handle['item'] + item = handle["item"] item.disconnectROI(roi) if len(item.rois) == 0 and roi.scene() is not None: roi.scene().removeItem(item) roi.handles = [] roi.stateChanged() - + def disconnectROIs(self, posData): for cropROI in posData.cropROIs: try: @@ -1884,41 +1863,41 @@ def disconnectROIs(self, posData): roi.removable = False self.removeAllHandles(roi) - + self.addCropRoiActon.setDisabled(True) self.addBkrgRoiActon.setDisabled(True) self.cropTaction.setDisabled(True) self.freeRoiAction.setDisabled(True) - - self.logger.info('ROIs disconnected.') + + self.logger.info("ROIs disconnected.") def permissionErrorCritical(self, path): msg = QMessageBox() msg.critical( - self, 'Permission denied', - f'The below file is open in another app (Excel maybe?).\n\n' - f'{path}\n\n' + self, + "Permission denied", + f"The below file is open in another app (Excel maybe?).\n\n" + f"{path}\n\n" 'Close file and then press "Ok".', - msg.Ok + msg.Ok, ) def askCropping(self, dataShape, croppedShapes): - header_text = (f""" + header_text = f""" Data-prep information saved.

- """) + """ if len(self.data) > 1: - info_text = (""" + info_text = """ Do you also want to save cropped data?

- """) + """ else: - info_text = (f""" + info_text = f""" Do you also want to save cropped data from shape {dataShape} to the following shapes: {html_utils.to_list(croppedShapes, ordered=True)} - """) + """ important = html_utils.to_admonition( - 'Saving cropped data cannot be undone.', - admonition_type='Important' + "Saving cropped data cannot be undone.", admonition_type="Important" ) msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(f""" @@ -1931,28 +1910,28 @@ def askCropping(self, dataShape, croppedShapes): Do you want to continue with saving cropped data? """) noButton, yesButton = msg.warning( - self, 'Crop?', txt, - buttonsTexts=('No, do not crop.', 'Yes, crop please.') + self, "Crop?", txt, buttonsTexts=("No, do not crop.", "Yes, crop please.") ) return msg.clickedButton == yesButton def getDefaultROI(self, shrinkFactor=1): Y, X = self.img.image.shape - w, h = int(X*shrinkFactor), int(Y*shrinkFactor) + w, h = int(X * shrinkFactor), int(Y * shrinkFactor) - xc, yc = int(round(X/2)), int(round(Y/2)) + xc, yc = int(round(X / 2)), int(round(Y / 2)) # yt, xl = int(round(xc-w/2)), int(round(yc-h/2)) yt, xl = 0, 0 # Add ROI Rectangle cropROI = pg.ROI( - [xl, yt], [w, h], + [xl, yt], + [w, h], rotatable=False, removable=False, - pen=pg.mkPen(color='r'), - maxBounds=QRectF(QRect(0,0,X,Y)), + pen=pg.mkPen(color="r"), + maxBounds=QRectF(QRect(0, 0, X, Y)), scaleSnap=True, - translateSnap=True + translateSnap=True, ) return cropROI @@ -1960,7 +1939,7 @@ def setROIprops(self, roi, roiNumber=1): xl, yt = [int(round(c)) for c in roi.pos()] roi.handleSize = 7 - roi.label = pg.TextItem(f'ROI n. {roiNumber}', color='r') + roi.label = pg.TextItem(f"ROI n. {roiNumber}", color="r") roi.label.setFont(self.roiLabelFont) # hLabel = roi.label.rect().bottom() roi.label.setPos(xl, yt) @@ -1998,7 +1977,7 @@ def init_data(self, user_ch_file_paths, user_ch_name): load_last_tracked_i=False, load_metadata=True, load_dataprep_free_roi=True, - getTifPath=True + getTifPath=True, ) # If data was cropped then dataPrep_ROIcoords are useless @@ -2006,22 +1985,23 @@ def init_data(self, user_ch_file_paths, user_ch_name): posData.dataPrep_ROIcoords = None posData.loadAllImgPaths() - if f==0 and not self.metadataAlreadyAsked: + if f == 0 and not self.metadataAlreadyAsked: proceed = posData.askInputMetadata( self.num_pos, - ask_SizeT=self.num_pos==1, + ask_SizeT=self.num_pos == 1, ask_TimeIncrement=False, ask_PhysicalSizes=False, save=True, - askSegm3D=False + askSegm3D=False, ) self.isSegm3D = posData.isSegm3D self.SizeT = posData.SizeT self.SizeZ = posData.SizeZ if not proceed: self.titleLabel.setText( - 'File --> Open or Open recent to start the process', - color='w') + "File --> Open or Open recent to start the process", + color="w", + ) return False self.AutoPilotProfile.storeOkAskInputMetadata() else: @@ -2042,20 +2022,20 @@ def init_data(self, user_ch_file_paths, user_ch_name): posData.SizeT = 1 posData.saveMetadata() except AttributeError: - print('') - print('====================================') + print("") + print("====================================") traceback.print_exc() - print('====================================') - print('') + print("====================================") + print("") self.titleLabel.setText( - 'File --> Open or Open recent to start the process', - color='w') + "File --> Open or Open recent to start the process", color="w" + ) return False if posData is None: self.titleLabel.setText( - 'File --> Open or Open recent to start the process', - color='w') + "File --> Open or Open recent to start the process", color="w" + ) return False img_shape = posData.img_data.shape @@ -2063,31 +2043,32 @@ def init_data(self, user_ch_file_paths, user_ch_name): self.user_ch_name = user_ch_name SizeT = posData.SizeT SizeZ = posData.SizeZ - if f==0: - print('') - self.logger.info(f'Data shape = {img_shape}') - self.logger.info(f'Number of frames = {SizeT}') - self.logger.info(f'Number of z-slices per frame = {SizeZ}') + if f == 0: + print("") + self.logger.info(f"Data shape = {img_shape}") + self.logger.info(f"Number of frames = {SizeT}") + self.logger.info(f"Number of z-slices per frame = {SizeZ}") data.append(posData) - if SizeT>1 and self.num_pos>1: + if SizeT > 1 and self.num_pos > 1: path = os.path.normpath(file_path) path_li = path.split(os.sep) - rel_path = f'.../{"/".join(path_li[-3:])}' + rel_path = f".../{'/'.join(path_li[-3:])}" msg = QMessageBox() msg.critical( - self, 'Multiple Pos loading not allowed.', - f'The file {rel_path} has multiple frames over time.\n\n' - 'Loading multiple positions that contain frames over time ' - 'is not allowed.\n\n' - 'To analyse frames over time load one position at the time', - msg.Ok + self, + "Multiple Pos loading not allowed.", + f"The file {rel_path} has multiple frames over time.\n\n" + "Loading multiple positions that contain frames over time " + "is not allowed.\n\n" + "To analyse frames over time load one position at the time", + msg.Ok, ) self.titleLabel.setText( - 'File --> Open or Open recent to start the process', - color='w') + "File --> Open or Open recent to start the process", color="w" + ) return False - + self.data = data self.init_segmInfo_df() self.init_attr() @@ -2121,7 +2102,7 @@ def init_segmInfo_df(self): self.z_label.setDisabled(False) self.zSliceScrollBar.setDisabled(False) self.zProjComboBox.setDisabled(False) - self.zSliceScrollBar.setMaximum(posData.SizeZ-1) + self.zSliceScrollBar.setMaximum(posData.SizeZ - 1) self.zSliceScrollBar.valueChanged.connect(self.update_z_slice) self.zProjComboBox.currentTextChanged.connect(self.updateZproj) if posData.SizeT > 1: @@ -2130,7 +2111,7 @@ def init_segmInfo_df(self): self.ZforwAction.setEnabled(True) df = posData.segmInfo_df idx = (posData.filename, self.frame_i) - how = posData.segmInfo_df.at[idx, 'which_z_proj'] + how = posData.segmInfo_df.at[idx, "which_z_proj"] self.zProjComboBox.setCurrentText(how) else: self.zSliceScrollBar.setDisabled(True) @@ -2138,37 +2119,36 @@ def init_segmInfo_df(self): self.z_label.setDisabled(True) def update_z_slice(self, z): - if self.zProjComboBox.currentText() == 'single z-slice': + if self.zProjComboBox.currentText() == "single z-slice": posData = self.data[self.pos_i] df = posData.segmInfo_df idx = (posData.filename, self.frame_i) - posData.segmInfo_df.at[idx, 'z_slice_used_dataPrep'] = z - posData.segmInfo_df.at[idx, 'z_slice_used_gui'] = z + posData.segmInfo_df.at[idx, "z_slice_used_dataPrep"] = z + posData.segmInfo_df.at[idx, "z_slice_used_gui"] = z self.update_img() posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) - def updateZproj(self, how): posData = self.data[self.pos_i] for frame_i in range(self.frame_i, posData.SizeT): df = posData.segmInfo_df idx = (posData.filename, self.frame_i) - posData.segmInfo_df.at[idx, 'which_z_proj'] = how - posData.segmInfo_df.at[idx, 'which_z_proj_gui'] = how - if how == 'single z-slice': + posData.segmInfo_df.at[idx, "which_z_proj"] = how + posData.segmInfo_df.at[idx, "which_z_proj_gui"] = how + if how == "single z-slice": self.zSliceScrollBar.setDisabled(False) - self.z_label.setStyleSheet('color: black') + self.z_label.setStyleSheet("color: black") self.update_z_slice(self.zSliceScrollBar.sliderPosition()) else: self.zSliceScrollBar.setDisabled(True) - self.z_label.setStyleSheet('color: gray') + self.z_label.setStyleSheet("color: gray") self.update_img() # Apply same z-proj to future pos if posData.SizeT == 1: - for posData in self.data[self.pos_i+1:]: + for posData in self.data[self.pos_i + 1 :]: idx = (posData.filename, self.frame_i) - posData.segmInfo_df.at[idx, 'which_z_proj'] = how + posData.segmInfo_df.at[idx, "which_z_proj"] = how self.save_segmInfo_df_pos() @@ -2189,205 +2169,190 @@ def useSameZ_fromHereBack(self, event): how = self.zProjComboBox.currentText() posData = self.data[self.pos_i] df = posData.segmInfo_df - z = df.at[(posData.filename, self.frame_i), 'z_slice_used_dataPrep'] + z = df.at[(posData.filename, self.frame_i), "z_slice_used_dataPrep"] if posData.SizeT > 1: for i in range(0, self.frame_i): - df.at[(posData.filename, i), 'z_slice_used_dataPrep'] = z - df.at[(posData.filename, i), 'z_slice_used_gui'] = z - df.at[(posData.filename, i), 'which_z_proj'] = how + df.at[(posData.filename, i), "z_slice_used_dataPrep"] = z + df.at[(posData.filename, i), "z_slice_used_gui"] = z + df.at[(posData.filename, i), "which_z_proj"] = how posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) elif posData.SizeZ > 1: - for _posData in self.data[:self.pos_i]: + for _posData in self.data[: self.pos_i]: df = _posData.segmInfo_df - df.at[(_posData.filename, 0), 'z_slice_used_dataPrep'] = z - df.at[(_posData.filename, 0), 'z_slice_used_gui'] = z - df.at[(_posData.filename, 0), 'which_z_proj'] = how + df.at[(_posData.filename, 0), "z_slice_used_dataPrep"] = z + df.at[(_posData.filename, 0), "z_slice_used_gui"] = z + df.at[(_posData.filename, 0), "which_z_proj"] = how self.save_segmInfo_df_pos() def useSameZ_fromHereForw(self, event): how = self.zProjComboBox.currentText() posData = self.data[self.pos_i] df = posData.segmInfo_df - z = df.at[(posData.filename, self.frame_i), 'z_slice_used_dataPrep'] + z = df.at[(posData.filename, self.frame_i), "z_slice_used_dataPrep"] if posData.SizeT > 1: for i in range(self.frame_i, posData.SizeT): - df.at[(posData.filename, i), 'z_slice_used_dataPrep'] = z - df.at[(posData.filename, i), 'z_slice_used_gui'] = z - df.at[(posData.filename, i), 'which_z_proj'] = how + df.at[(posData.filename, i), "z_slice_used_dataPrep"] = z + df.at[(posData.filename, i), "z_slice_used_gui"] = z + df.at[(posData.filename, i), "which_z_proj"] = how posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) elif posData.SizeZ > 1: - for _posData in self.data[self.pos_i:]: + for _posData in self.data[self.pos_i :]: df = _posData.segmInfo_df - df.at[(_posData.filename, 0), 'z_slice_used_dataPrep'] = z - df.at[(_posData.filename, 0), 'z_slice_used_gui'] = z - df.at[(_posData.filename, 0), 'which_z_proj'] = how + df.at[(_posData.filename, 0), "z_slice_used_dataPrep"] = z + df.at[(_posData.filename, 0), "z_slice_used_gui"] = z + df.at[(_posData.filename, 0), "which_z_proj"] = how self.save_segmInfo_df_pos() def interp_z(self, event): posData = self.data[self.pos_i] df = posData.segmInfo_df - x0, z0 = 0, df.at[(posData.filename, 0), 'z_slice_used_dataPrep'] + x0, z0 = 0, df.at[(posData.filename, 0), "z_slice_used_dataPrep"] x1 = self.frame_i - z1 = df.at[(posData.filename, x1), 'z_slice_used_dataPrep'] + z1 = df.at[(posData.filename, x1), "z_slice_used_dataPrep"] f = scipy.interpolate.interp1d([x0, x1], [z0, z1]) xx = np.arange(0, self.frame_i) zz = np.round(f(xx)).astype(int) for i in range(self.frame_i): - df.at[(posData.filename, i), 'z_slice_used_dataPrep'] = zz[i] - df.at[(posData.filename, i), 'z_slice_used_gui'] = zz[i] - df.at[(posData.filename, i), 'which_z_proj'] = 'single z-slice' + df.at[(posData.filename, i), "z_slice_used_dataPrep"] = zz[i] + df.at[(posData.filename, i), "z_slice_used_gui"] = zz[i] + df.at[(posData.filename, i), "which_z_proj"] = "single z-slice" posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) - + def waitAlignDataWorker(self): self.alignDataWorkerLoop = QEventLoop(self) self.alignDataWorkerLoop.exec_() - + def waitWorker(self, worker): worker.loop = QEventLoop(self) worker.loop.exec_() - - def workerProgress(self, text, loggerLevel='INFO'): + + def workerProgress(self, text, loggerLevel="INFO"): if self.progressWin is not None: - self.progressWin.logConsole.append('-'*60) + self.progressWin.logConsole.append("-" * 60) self.progressWin.logConsole.append(text) self.logger.log(getattr(logging, loggerLevel), text) - + def startAlignDataWorker(self, posData, align, user_ch_name, progressText): # Disable clicks on image during alignment self.img.mousePressEvent = None - + if posData.SizeT > 1: self.progressWin = apps.QDialogWorkerProgress( - title='Aligning data', - parent=self, - pbarDesc=progressText + title="Aligning data", parent=self, pbarDesc=progressText ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - + self._thread = QThread() self.alignDataWorkerMutex = QMutex() self.alignDataWorkerWaitCond = QWaitCondition() - + self.alignDataWorker = workers.AlignDataWorker( - posData, self, self.alignDataWorkerMutex, - self.alignDataWorkerWaitCond + posData, self, self.alignDataWorkerMutex, self.alignDataWorkerWaitCond ) self.alignDataWorker.set_attr(align, user_ch_name) self.alignDataWorker.moveToThread(self._thread) - + self.alignDataWorker.signals.finished.connect(self._thread.quit) - self.alignDataWorker.signals.finished.connect( - self.alignDataWorker.deleteLater - ) + self.alignDataWorker.signals.finished.connect(self.alignDataWorker.deleteLater) self._thread.finished.connect(self._thread.deleteLater) - self.alignDataWorker.signals.finished.connect( - self.alignDataWorkerFinished - ) + self.alignDataWorker.signals.finished.connect(self.alignDataWorkerFinished) self.alignDataWorker.signals.progress.connect(self.workerProgress) - self.alignDataWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.alignDataWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.alignDataWorker.signals.critical.connect( - self.workerCritical - ) + self.alignDataWorker.signals.initProgressBar.connect(self.workerInitProgressbar) + self.alignDataWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.alignDataWorker.signals.critical.connect(self.workerCritical) + + self.alignDataWorker.sigAskAlignSegmData.connect(self.askAlignSegmData) + self.alignDataWorker.sigWarnTifAligned.connect(self.warnTifAligned) - self.alignDataWorker.sigAskAlignSegmData.connect( - self.askAlignSegmData - ) - self.alignDataWorker.sigWarnTifAligned.connect( - self.warnTifAligned - ) - self._thread.started.connect(self.alignDataWorker.run) self._thread.start() - + @exception_handler def prepData(self, event): self.titleLabel.setText( - 'Prepping data... (check progress in the terminal)', - color='w') + "Prepping data... (check progress in the terminal)", color="w" + ) self.tempFilesToMove = {} doZip = False for p, posData in enumerate(self.data): self.startAction.setDisabled(True) nonTifFound = ( - any([npz is not None for npz in posData.npz_paths]) or - any([npy is not None for npy in posData.npy_paths]) or - posData.segmFound + any([npz is not None for npz in posData.npz_paths]) + or any([npy is not None for npy in posData.npy_paths]) + or posData.segmFound ) imagesPath = posData.images_path - zipPath = f'{imagesPath}.zip' - if nonTifFound and p==0: + zipPath = f"{imagesPath}.zip" + if nonTifFound and p == 0: txt = ( - 'Additional NON-tif files detected.

' - 'The requested experiment folder already contains .npy ' - 'or .npz files ' - 'most likely from previous analysis runs.

' - 'To avoid data losses we recommend zipping the ' + "Additional NON-tif files detected.

" + "The requested experiment folder already contains .npy " + "or .npz files " + "most likely from previous analysis runs.

" + "To avoid data losses we recommend zipping the " '"Images" folder.

' - 'If everything looks fine after prepping the data, ' - 'you can manually ' - 'delete the zip archive.

' - 'Do you want to automatically zip now?

' - 'PS: Zip archive location:

' - f'{zipPath}' + "If everything looks fine after prepping the data, " + "you can manually " + "delete the zip archive.

" + "Do you want to automatically zip now?

" + "PS: Zip archive location:

" + f"{zipPath}" ) txt = html_utils.paragraph(txt) msg = widgets.myMessageBox() _, yes, no = msg.warning( - self, 'NON-Tif data detected!', txt, - buttonsTexts=('Cancel', 'Yes', 'No') + self, + "NON-Tif data detected!", + txt, + buttonsTexts=("Cancel", "Yes", "No"), ) if msg.cancel: self.setEnabledCropActions(True) - self.titleLabel.setText('Process aborted', color='w') + self.titleLabel.setText("Process aborted", color="w") return if yes == msg.clickedButton: doZip = True if doZip: - self.logger.info(f'Zipping Images folder: {zipPath}') - shutil.make_archive(imagesPath, 'zip', imagesPath) + self.logger.info(f"Zipping Images folder: {zipPath}") + shutil.make_archive(imagesPath, "zip", imagesPath) success = self.alignData(self.user_ch_name, posData) if not success: - self.titleLabel.setText('Data prep cancelled.', color='r') + self.titleLabel.setText("Data prep cancelled.", color="r") return - if posData.SizeZ>1: + if posData.SizeZ > 1: posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) # For loop did not break, proceed with the rest self.update_img() - self.logger.info('Done.') + self.logger.info("Done.") self.addROIs() self.saveROIcoords(False, self.data[self.pos_i]) self.saveBkgrROIs(self.data[self.pos_i]) self.setEnabledCropActions(True) txt = ( - 'Data successfully prepped. You can now crop the images, ' - 'place the background ROIs, or close the program' + "Data successfully prepped. You can now crop the images, " + "place the background ROIs, or close the program" ) - self.titleLabel.setText(txt, color='w') + self.titleLabel.setText(txt, color="w") msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(txt) - msg.information(self, 'Dataprep completed', txt) + msg.information(self, "Dataprep completed", txt) def setStandardRoiShape(self, text): posData = self.data[self.pos_i] - if not hasattr(posData, 'cropROIs'): - return - + if not hasattr(posData, "cropROIs"): + return + if posData.cropROIs is None: return - if len(posData.cropROIs)>1: + if len(posData.cropROIs) > 1: return Y, X = posData.img_data.shape[-2:] - m = re.findall(r'(\d+)x(\d+)', text) + m = re.findall(r"(\d+)x(\d+)", text) w, h = int(m[0][0]), int(m[0][1]) # xc, yc = int(round(X/2)), int(round(Y/2)) # yt, xl = int(round(xc-w/2)), int(round(yc-h/2)) @@ -2396,34 +2361,34 @@ def setStandardRoiShape(self, text): def hideROIs(self): posData = self.data[self.pos_i] - if not hasattr(posData, 'cropROIs'): + if not hasattr(posData, "cropROIs"): return - + if posData.cropROIs is None: return - + for cropROI in posData.cropROIs: self.ax1.removeItem(cropROI.label) self.ax1.removeItem(cropROI) - + def reAddROIs(self): posData = self.data[self.pos_i] - if not hasattr(posData, 'cropROIs'): + if not hasattr(posData, "cropROIs"): return - + if posData.cropROIs is None: return - + for cropROI in posData.cropROIs: self.ax1.addItem(cropROI.label) self.ax1.addItem(cropROI) - + def addROIs(self): Y, X = self.img.image.shape - max_size = round(int(np.log2(min([Y, X])/16))) - items = [f'{16*(2**i)}x{16*(2**i)}' for i in range(1, max_size+1)] - items.append(f'{X}x{Y}') + max_size = round(int(np.log2(min([Y, X]) / 16))) + items = [f"{16 * (2**i)}x{16 * (2**i)}" for i in range(1, max_size + 1)] + items.append(f"{X}x{Y}") self.ROIshapeComboBox.clear() self.ROIshapeComboBox.addItems(items) self.ROIshapeComboBox.setCurrentText(items[-1]) @@ -2440,32 +2405,31 @@ def addROIs(self): n = 1 for roi_id, df_roi in grouped: df_roi = df_roi.loc[roi_id] - xl = df_roi.at['x_left', 'value'] - yt = df_roi.at['y_top', 'value'] - w = df_roi.at['x_right', 'value'] - xl - h = df_roi.at['y_bottom', 'value'] - yt + xl = df_roi.at["x_left", "value"] + yt = df_roi.at["y_top", "value"] + w = df_roi.at["x_right", "value"] - xl + h = df_roi.at["y_bottom", "value"] - yt cropROI = pg.ROI( - [xl, yt], [w, h], + [xl, yt], + [w, h], rotatable=False, removable=False, - pen=pg.mkPen(color='r'), - maxBounds=QRectF(QRect(0,0,X,Y)), + pen=pg.mkPen(color="r"), + maxBounds=QRectF(QRect(0, 0, X, Y)), scaleSnap=True, - translateSnap=True + translateSnap=True, ) self.setROIprops(cropROI, roiNumber=n) posData.cropROIs.append(cropROI) n += 1 - + self.addAndConnectCropROIs() try: self.ROIshapeComboBox.currentTextChanged.disconnect() except Exception as e: pass - self.ROIshapeComboBox.currentTextChanged.connect( - self.setStandardRoiShape - ) + self.ROIshapeComboBox.currentTextChanged.connect(self.setStandardRoiShape) self.addBkrgRoiActon.setDisabled(False) self.addCropRoiActon.setDisabled(False) @@ -2486,15 +2450,16 @@ def getDefaultBkgrROI(self): Y, X = self.img.image.shape xRange, yRange = self.ax1.viewRange() xl, yt = abs(xRange[0]), abs(yRange[0]) - w, h = int(X/8), int(Y/8) + w, h = int(X / 8), int(Y / 8) bkgrROI = pg.ROI( - [xl, yt], [w, h], + [xl, yt], + [w, h], rotatable=False, removable=False, - pen=pg.mkPen(color=(255,255,255)), - maxBounds=QRectF(QRect(0,0,X,Y)), + pen=pg.mkPen(color=(255, 255, 255)), + maxBounds=QRectF(QRect(0, 0, X, Y)), scaleSnap=True, - translateSnap=True + translateSnap=True, ) return bkgrROI @@ -2502,7 +2467,7 @@ def setBkgrROIprops(self, bkgrROI): bkgrROI.handleSize = 7 xl, yt = [int(round(c)) for c in bkgrROI.pos()] - bkgrROI.label = pg.TextItem('Bkgr. ROI', color=(255,255,255)) + bkgrROI.label = pg.TextItem("Bkgr. ROI", color=(255, 255, 255)) bkgrROI.label.setFont(self.roiLabelFont) # hLabel = bkgrROI.label.rect().bottom() bkgrROI.label.setPos(xl, yt) @@ -2527,7 +2492,7 @@ def setBkgrROIprops(self, bkgrROI): def addCropROI(self): cropROI = self.getDefaultROI(shrinkFactor=0.5) posData = self.data[self.pos_i] - self.setROIprops(cropROI, roiNumber=len(posData.cropROIs)+1) + self.setROIprops(cropROI, roiNumber=len(posData.cropROIs) + 1) posData.cropROIs.append(cropROI) self.addAndConnectROI(cropROI) @@ -2544,8 +2509,8 @@ def addDefaultBkgrROI(self, checked=False): bkgrROI.sigRegionChangeFinished.connect(self.bkgrROImovingFinished) def bkgrROIMoving(self, roi): - roi.setPen(color=(255,255,0)) - roi.label.setColor((255,255,0)) + roi.setPen(color=(255, 255, 0)) + roi.label.setColor((255, 255, 0)) # roi.label.setText(txt, color=(255,255,0), size=self.roiLabelSize) xl, yt = [int(round(c)) for c in roi.pos()] # hLabel = roi.label.rect().bottom() @@ -2553,8 +2518,8 @@ def bkgrROIMoving(self, roi): def bkgrROImovingFinished(self, roi): txt = roi.label.toPlainText() - roi.setPen(color=(255,255,255)) - roi.label.setColor((255,255,255)) + roi.setPen(color=(255, 255, 255)) + roi.label.setColor((255, 255, 255)) # roi.label.setText(txt, color=(150,150,150), size=self.roiLabelSize) posData = self.data[self.pos_i] idx = posData.bkgrROIs.index(roi) @@ -2563,21 +2528,21 @@ def bkgrROImovingFinished(self, roi): def ROImovingFinished(self, roi): txt = roi.label.toPlainText() - roi.setPen(color='r') - roi.label.setColor('r') + roi.setPen(color="r") + roi.label.setColor("r") # roi.label.setText(txt, color='r', size=self.roiLabelSize) self.saveROIcoords(False, self.data[self.pos_i]) def updateCurrentRoiShape(self, roi): - roi.setPen(color=(255,255,0)) - roi.label.setColor((255,255,0)) + roi.setPen(color=(255, 255, 0)) + roi.label.setColor((255, 255, 0)) # roi.label.setText('ROI', color=(255,255,0), size=self.roiLabelSize) xl, yt = [int(round(c)) for c in roi.pos()] # hLabel = roi.label.rect().bottom() roi.label.setPos(xl, yt) w, h = [int(round(c)) for c in roi.size()] - self.ROIshapeLabel.setText(f' Current ROI shape: {w} x {h}') - + self.ROIshapeLabel.setText(f" Current ROI shape: {w} x {h}") + def alignDataWorkerFinished(self, result): if self.progressWin is not None: self.progressWin.workerFinished = True @@ -2585,7 +2550,7 @@ def alignDataWorkerFinished(self, result): self.progressWin = None self.alignDataWorkerLoop.exit() self.img.mousePressEvent = self.gui_mousePressEventImg - + def saveBkgrDataWorkerFinished(self, result): if self.progressWin is not None: self.progressWin.workerFinished = True @@ -2593,7 +2558,7 @@ def saveBkgrDataWorkerFinished(self, result): self.progressWin = None self.saveBkgrDataWorker.loop.exit() self.img.mousePressEvent = self.gui_mousePressEventImg - + def cropWorkerFinished(self, result): if self.progressWin is not None: self.progressWin.workerFinished = True @@ -2601,23 +2566,23 @@ def cropWorkerFinished(self, result): self.progressWin = None self.cropWorker.loop.exit() self.img.mousePressEvent = self.gui_mousePressEventImg - + def workerInitProgressbar(self, totalIter): self.progressWin.mainPbar.setValue(0) if totalIter == 1: totalIter = 0 self.progressWin.mainPbar.setMaximum(totalIter) - + def workerUpdateProgressbar(self, step): self.progressWin.mainPbar.update(step) - + @exception_handler def workerCritical(self, error): if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() raise error - + def warnZeroPaddingAlignment(self): txt = html_utils.paragraph(""" To align the frames, Cell-ACDC needs to shift the images @@ -2632,16 +2597,15 @@ def warnZeroPaddingAlignment(self): """) msg = widgets.myMessageBox(showCentered=False, wrapText=False) msg.information( - self, 'Padding alignment shifts', txt, - buttonsTexts=('Cancel', 'Ok') + self, "Padding alignment shifts", txt, buttonsTexts=("Cancel", "Ok") ) if msg.cancel: return False return True - + def alignData(self, user_ch_name, posData): align = False - progressText = 'Aligning data...' + progressText = "Aligning data..." if posData.SizeT > 1: msg = widgets.myMessageBox(showCentered=False) if posData.loaded_shifts is not None: @@ -2665,16 +2629,15 @@ def alignData(self, user_ch_name, posData): aligning. """) _, yesButton, noButton = msg.question( - self, 'Align frames?', txt, - buttonsTexts=('Cancel', 'Yes', 'No') + self, "Align frames?", txt, buttonsTexts=("Cancel", "Yes", "No") ) if msg.cancel: return False elif msg.clickedButton == noButton: align = False # Create 0, 0 shifts to perform 0 alignment - posData.loaded_shifts = np.zeros((self.num_frames,2), int) - progressText = 'Skipping alignment...' + posData.loaded_shifts = np.zeros((self.num_frames, 2), int) + progressText = "Skipping alignment..." else: if posData.loaded_shifts is not None: # Discard current shifts since we want to repeat it @@ -2683,8 +2646,8 @@ def alignData(self, user_ch_name, posData): elif posData.SizeT == 1: align = False # Create 0, 0 shifts to perform 0 alignment - posData.loaded_shifts = np.zeros((self.num_frames,2), int) - + posData.loaded_shifts = np.zeros((self.num_frames, 2), int) + if align: proceed = self.warnZeroPaddingAlignment() if not proceed: @@ -2694,22 +2657,21 @@ def alignData(self, user_ch_name, posData): if align: self.logger.info(progressText) - self.titleLabel.setText(progressText, color='w') + self.titleLabel.setText(progressText, color="w") self.startAlignDataWorker(posData, align, user_ch_name, progressText) self.waitAlignDataWorker() - + return not self.alignDataWorker.doAbort def askAlignSegmData(self): msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Cell-ACDC found an existing segmentation mask.

' - 'Do you need to align that too?' + "Cell-ACDC found an existing segmentation mask.

" + "Do you need to align that too?" ) _, noButton = msg.question( - self, 'Align segmentation data?', txt, - buttonsTexts=('Yes', 'No') + self, "Align segmentation data?", txt, buttonsTexts=("Yes", "No") ) self.alignDataWorker.doNotAlignSegmData = msg.clickedButton == noButton self.alignDataWorker.restart() @@ -2721,24 +2683,26 @@ def detectTifAlignment(self, tif_data, posData): for img in tif_data: if posData.SizeZ > 1: firtsCol = img[:, :, 0] - lastCol = img[:, : -1] + lastCol = img[:, :-1] firstRow = img[:, 0] lastRow = img[:, -1] else: firtsCol = img[:, 0] - lastCol = img[: -1] + lastCol = img[:-1] firstRow = img[0] lastRow = img[-1] someZeros = ( - not np.any(firstRow) or not np.any(firtsCol) - or not np.any(lastRow) or not np.any(lastCol) + not np.any(firstRow) + or not np.any(firtsCol) + or not np.any(lastRow) + or not np.any(lastCol) ) if someZeros: numFramesWith0s += 1 return numFramesWith0s def warnTifAligned(self, numFramesWith0s, tifPath, posData): - if numFramesWith0s>0 and posData.loaded_shifts is not None: + if numFramesWith0s > 0 and posData.loaded_shifts is not None: msg = widgets.myMessageBox() txt = html_utils.paragraph(""" Cell-ACDC detected that the .tif file contains LREADY @@ -2750,8 +2714,7 @@ def warnTifAligned(self, numFramesWith0s, tifPath, posData): Do you want to continue? """) msg.warning( - self, 'Tif data ALREADY aligned!', txt, - buttonsTexts=('Cancel', 'Yes') + self, "Tif data ALREADY aligned!", txt, buttonsTexts=("Cancel", "Yes") ) if msg.cancel: self.alignDataWorker.doAbort = True @@ -2764,35 +2727,35 @@ def getTempfilePath(self, path): return tempFilePath def moveTempFile(self, source, dst): - self.logger.info(f'Moving temp file: {source}') + self.logger.info(f"Moving temp file: {source}") tempDir = os.path.dirname(source) shutil.move(source, dst) shutil.rmtree(tempDir) def storeTempFileMove(self, source, dst): self.tempFilesToMove[source] = dst - + def getMostRecentPath(self): if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - if 'opened_last_on' in df.columns: - df = df.sort_values('opened_last_on', ascending=False) - self.MostRecentPath = df.iloc[0]['path'] + df = pd.read_csv(recentPaths_path, index_col="index") + if "opened_last_on" in df.columns: + df = df.sort_values("opened_last_on", ascending=False) + self.MostRecentPath = df.iloc[0]["path"] if not isinstance(self.MostRecentPath, str): - self.MostRecentPath = '' + self.MostRecentPath = "" else: - self.MostRecentPath = '' + self.MostRecentPath = "" def addToRecentPaths(self, exp_path): if not os.path.exists(exp_path): return if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - recentPaths = df['path'].to_list() - if 'opened_last_on' in df.columns: - openedOn = df['opened_last_on'].to_list() + df = pd.read_csv(recentPaths_path, index_col="index") + recentPaths = df["path"].to_list() + if "opened_last_on" in df.columns: + openedOn = df["opened_last_on"].to_list() else: - openedOn = [np.nan]*len(recentPaths) + openedOn = [np.nan] * len(recentPaths) if exp_path in recentPaths: pop_idx = recentPaths.index(exp_path) recentPaths.pop(pop_idx) @@ -2806,10 +2769,13 @@ def addToRecentPaths(self, exp_path): else: recentPaths = [exp_path] openedOn = [datetime.datetime.now()] - df = pd.DataFrame({'path': recentPaths, - 'opened_last_on': pd.Series(openedOn, - dtype='datetime64[ns]')}) - df.index.name = 'index' + df = pd.DataFrame( + { + "path": recentPaths, + "opened_last_on": pd.Series(openedOn, dtype="datetime64[ns]"), + } + ) + df.index.name = "index" df.to_csv(recentPaths_path) def populateOpenRecent(self): @@ -2817,10 +2783,10 @@ def populateOpenRecent(self): self.openRecentMenu.clear() # Step 1. Read recent Paths if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - if 'opened_last_on' in df.columns: - df = df.sort_values('opened_last_on', ascending=False) - recentPaths = df['path'].to_list() + df = pd.read_csv(recentPaths_path, index_col="index") + if "opened_last_on" in df.columns: + df = df.sort_values("opened_last_on", ascending=False) + recentPaths = df["path"].to_list() else: recentPaths = [] # Step 2. Dynamically create the actions @@ -2837,8 +2803,7 @@ def populateOpenRecent(self): @exception_handler def loadFiles(self, exp_path, user_ch_file_paths, user_ch_name): self.titleLabel.setText( - 'Loading data (check progress in the terminal)...', - color='w' + "Loading data (check progress in the terminal)...", color="w" ) version = self._acdc_version self.setWindowTitle(f'Cell-ACDC v{version} - Data Prep. - "{exp_path}"') @@ -2853,7 +2818,7 @@ def loadFiles(self, exp_path, user_ch_file_paths, user_ch_name): # Connect events at the end of loading data process self.gui_connectGraphicsEvents() - + exp_path = self.data[self.pos_i].exp_path pos_foldernames = myutils.get_pos_foldernames(exp_path) if len(pos_foldernames) == 1: @@ -2864,21 +2829,20 @@ def loadFiles(self, exp_path, user_ch_file_paths, user_ch_name): if self.titleText is None: self.titleLabel.setText( - 'Data successfully loaded.
' + "Data successfully loaded.
" 'Press "START" button (top-left) to start prepping your data.', - color='w') + color="w", + ) else: - self.titleLabel.setText( - self.titleText, - color='w') + self.titleLabel.setText(self.titleText, color="w") self.openFolderAction.setEnabled(True) self.startAction.setEnabled(True) self.showInExplorerAction.setEnabled(True) self.setImageNameText() - + self.img.preComputedMinMaxValues(self.data) - + self.update_img() self.setFontSizeROIlabels() @@ -2888,9 +2852,9 @@ def setImageNameText(self): self.statusbar.clearMessage() posData = self.data[self.pos_i] txt = ( - f'{posData.pos_foldername} || ' - f'Basename: {posData.basename} || ' - f'Loaded channel: {posData.filename_ext}' + f"{posData.pos_foldername} || " + f"Basename: {posData.basename} || " + f"Loaded channel: {posData.filename_ext}" ) self.statusbar.showMessage(txt) @@ -2905,21 +2869,21 @@ def initLoading(self): self.setCenterAlignmentTitle() self.openFolderAction.setEnabled(False) self.setEnabledCropActions(False) - + self.freeRoiItem = None self.freeRoiMask = None - + self.saveSegmInfoWorkers = [] def showAbout(self): self.aboutWin = about.QDialogAbout(parent=self) self.aboutWin.show() - + def showHowToDataPrep(self): myutils.browse_url(urls.dataprep_docs) - + def openRecentFile(self, path): - self.logger.info(f'Opening recent folder: {path}') + self.logger.info(f"Opening recent folder: {path}") self.openFolder(exp_path=path) def openFolder(self, checked=False, exp_path=None): @@ -2928,29 +2892,32 @@ def openFolder(self, checked=False, exp_path=None): if exp_path is None: self.getMostRecentPath() exp_path = QFileDialog.getExistingDirectory( - self, 'Select experiment folder containing Position_n folders ' - 'or specific Position_n folder', self.MostRecentPath) + self, + "Select experiment folder containing Position_n folders " + "or specific Position_n folder", + self.MostRecentPath, + ) self.addToRecentPaths(exp_path) - if exp_path == '': + if exp_path == "": self.openFolderAction.setEnabled(True) self.titleLabel.setText( - 'File --> Open or Open recent to start the process', - color='w') + "File --> Open or Open recent to start the process", color="w" + ) return folder_type = myutils.determine_folder_type(exp_path) is_pos_folder, is_images_folder, exp_path = folder_type - self.titleLabel.setText('Loading data...', color='w') + self.titleLabel.setText("Loading data...", color="w") self.setWindowTitle( f'Cell-ACDC v{self._acdc_version} - Data Prep - "{exp_path}"' ) self.setCenterAlignmentTitle() ch_name_selector = prompts.select_channel_name( - which_channel='segm', allow_abort=False + which_channel="segm", allow_abort=False ) if not is_pos_folder and not is_images_folder: @@ -2958,44 +2925,42 @@ def openFolder(self, checked=False, exp_path=None): values = select_folder.get_values_dataprep(exp_path) if not values: txt = ( - 'The selected folder:\n\n ' - f'{exp_path}\n\n' - 'is not a valid folder. ' - 'Select a folder that contains the Position_n folders' + "The selected folder:\n\n " + f"{exp_path}\n\n" + "is not a valid folder. " + "Select a folder that contains the Position_n folders" ) msg = QMessageBox() - msg.critical( - self, 'Incompatible folder', txt, msg.Ok - ) + msg.critical(self, "Incompatible folder", txt, msg.Ok) self.titleLabel.setText( - 'File --> Open or Open recent to start the process', - color='w') + "File --> Open or Open recent to start the process", color="w" + ) self.openFolderAction.setEnabled(True) return select_folder.QtPrompt(self, values, allow_cancel=False) if select_folder.cancel: self.titleLabel.setText( - 'File --> Open or Open recent to start the process', - color='w') + "File --> Open or Open recent to start the process", color="w" + ) self.openFolderAction.setEnabled(True) return images_paths = [] for pos in select_folder.selected_pos: - images_paths.append(os.path.join(exp_path, pos, 'Images')) + images_paths.append(os.path.join(exp_path, pos, "Images")) if select_folder.cancel: self.titleLabel.setText( - 'File --> Open or Open recent to start the process', - color='w') + "File --> Open or Open recent to start the process", color="w" + ) self.openFolderAction.setEnabled(True) return elif is_pos_folder: pos_foldername = os.path.basename(exp_path) exp_path = os.path.dirname(exp_path) - images_paths = [os.path.join(exp_path, pos_foldername, 'Images')] + images_paths = [os.path.join(exp_path, pos_foldername, "Images")] elif is_images_folder: images_paths = [exp_path] @@ -3006,19 +2971,19 @@ def openFolder(self, checked=False, exp_path=None): images_path = self.images_paths[0] filenames = myutils.listdir(images_path) if ch_name_selector.is_first_call: - ch_names, warn = ( - ch_name_selector.get_available_channels(filenames, images_path) + ch_names, warn = ch_name_selector.get_available_channels( + filenames, images_path ) ch_names = ch_name_selector.askChannelName( filenames, images_path, warn, ch_names ) if ch_name_selector.was_aborted: self.titleLabel.setText( - 'File --> Open or Open recent to start the process', - color='w') + "File --> Open or Open recent to start the process", color="w" + ) self.openFolderAction.setEnabled(True) return - + if not ch_names: self.criticalNoTifFound(images_path) elif len(ch_names) > 1: @@ -3028,15 +2993,13 @@ def openFolder(self, checked=False, exp_path=None): ch_name_selector.setUserChannelName() if ch_name_selector.was_aborted: self.titleLabel.setText( - 'File --> Open or Open recent to start the process', - color='w') + "File --> Open or Open recent to start the process", color="w" + ) self.openFolderAction.setEnabled(True) return user_ch_name = ch_name_selector.user_ch_name - user_ch_file_paths = load.get_user_ch_paths( - self.images_paths, user_ch_name - ) + user_ch_file_paths = load.get_user_ch_paths(self.images_paths, user_ch_name) self.AutoPilotProfile.storeSelectedChannel(user_ch_name) self.loadFiles(exp_path, user_ch_file_paths, user_ch_name) @@ -3047,18 +3010,18 @@ def openFolder(self, checked=False, exp_path=None): def setFontSizeROIlabels(self): Y, X = self.img.image.shape factor = 50 - self.pt = int(X/factor) - self.roiLabelSize = '11px' + self.pt = int(X / factor) + self.roiLabelSize = "11px" self.roiLabelFont = QFont() self.roiLabelFont.setPixelSize(13) def criticalNoTifFound(self, images_path): - err_title = f'No .tif files found in folder.' + err_title = f"No .tif files found in folder." err_msg = ( f'The folder "{images_path}" does not contain .tif files.\n\n' 'Only .tif files can be loaded with "Open Folder" button.\n\n' 'Try with "File --> Open image/video file..." and directly select ' - 'the file you want to load.' + "the file you want to load." ) msg = QMessageBox() msg.critical(self, err_title, err_msg, msg.Ok) @@ -3081,53 +3044,37 @@ def askSaveAlignedData(self): Cell-ACDC detected aligned data that was not saved.

Do you want to save the aligned data? """) - buttonsTexts = ( - 'Cancel', 'No, close data-prep', 'Yes, save aligned data' - ) + buttonsTexts = ("Cancel", "No, close data-prep", "Yes, save aligned data") _, noButton, yesAlignButton = msg.question( - self, 'Save cropped data?', txt, buttonsTexts=buttonsTexts + self, "Save cropped data?", txt, buttonsTexts=buttonsTexts ) return msg.clickedButton == yesAlignButton, msg.cancel - + def startMoveTempFilesWorker(self): self.progressWin = apps.QDialogWorkerProgress( - title='Saving aligned data', - parent=self, - pbarDesc='Saving aligned data' + title="Saving aligned data", parent=self, pbarDesc="Saving aligned data" ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(len(self.tempFilesToMove)) - + self.saveAlignedThread = QThread() - self.saveAlignedWorker = workers.MoveTempFilesWorker( - self.tempFilesToMove - ) - + self.saveAlignedWorker = workers.MoveTempFilesWorker(self.tempFilesToMove) + self.saveAlignedWorker.moveToThread(self.saveAlignedThread) - self.saveAlignedWorker.signals.finished.connect( - self.saveAlignedThread.quit - ) + self.saveAlignedWorker.signals.finished.connect(self.saveAlignedThread.quit) self.saveAlignedWorker.signals.finished.connect( self.saveAlignedWorker.deleteLater ) - self.saveAlignedThread.finished.connect( - self.saveAlignedThread.deleteLater - ) - - self.saveAlignedWorker.signals.finished.connect( - self.saveAlignedWorkerFinished - ) + self.saveAlignedThread.finished.connect(self.saveAlignedThread.deleteLater) + + self.saveAlignedWorker.signals.finished.connect(self.saveAlignedWorkerFinished) self.saveAlignedWorker.signals.progress.connect(self.workerProgress) self.saveAlignedWorker.signals.initProgressBar.connect( self.workerInitProgressbar ) - self.saveAlignedWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.saveAlignedWorker.signals.critical.connect( - self.workerCritical - ) - + self.saveAlignedWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.saveAlignedWorker.signals.critical.connect(self.workerCritical) + self.saveAlignedThread.started.connect(self.saveAlignedWorker.run) self.saveAlignedThread.start() @@ -3138,51 +3085,49 @@ def saveAlignedWorkerFinished(self): self.progressWin = None self.saveAlignedWorkerLoop.exit() self.tempFilesToMove = {} - + def waitMoveTempFilesWorker(self): self.saveAlignedWorkerLoop = QEventLoop(self) self.saveAlignedWorkerLoop.exec_() - + def removeAlignShiftsFile(self): for posData in self.data: posData = self.data[self.pos_i] if posData.align_shifts_path is None: continue - + if not os.path.exists(posData.align_shifts_path): continue - - self.logger.info( - f'Removing align shifts file: {posData.align_shifts_path}' - ) + + self.logger.info(f"Removing align shifts file: {posData.align_shifts_path}") try: os.remove(posData.align_shifts_path) except Exception as e: pass - + def handleAlignedDataOnClosing(self): - if not hasattr(self, 'tempFilesToMove'): + if not hasattr(self, "tempFilesToMove"): return True - + if not self.tempFilesToMove: return True - + saveAligned, cancel = self.askSaveAlignedData() if cancel: return False - + if not saveAligned: self.removeAlignShiftsFile() return True - + cancel = self.warnSaveAlignedNotReversible() if cancel: return True - + self.startMoveTempFilesWorker() self.waitMoveTempFilesWorker() return True - + def warnSaveAlignedNotReversible(self): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(""" @@ -3190,59 +3135,57 @@ def warnSaveAlignedNotReversible(self): Do you want to continue with saving the aligned data? """) _, yesButton = msg.warning( - self, 'Save aligned data?', txt, - buttonsTexts=('Cancel', 'Yes, save aligned data') + self, + "Save aligned data?", + txt, + buttonsTexts=("Cancel", "Yes, save aligned data"), ) return msg.cancel - + def askCropAndSave(self): if not self.saveAction.isEnabled(): return True - + isCropped = False for p, posData in enumerate(self.data): - data = posData.img_data + data = posData.img_data allCropsData = [] for cropROI in posData.cropROIs: croppedData, SizeZ = self.crop(data, posData, cropROI) allCropsData.append(croppedData) - isCropped = any( - [cropped.shape != data.shape for cropped in allCropsData] - ) + isCropped = any([cropped.shape != data.shape for cropped in allCropsData]) if isCropped: break - + if not isCropped: return True - + msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(""" You seem to have cropping information that you did not save.

Do you want to save cropped data? """) - buttonsTexts = ( - 'Cancel', 'No, close data-prep', 'Yes, save cropped data' - ) + buttonsTexts = ("Cancel", "No, close data-prep", "Yes, save cropped data") _, noButton, yesButton = msg.question( - self, 'Save cropped data?', txt, buttonsTexts=buttonsTexts + self, "Save cropped data?", txt, buttonsTexts=buttonsTexts ) if msg.cancel: return False - + if msg.clickedButton == yesButton: self.cropAndSave() - + return True - + def closeEvent(self, event): self.saveWindowGeometry() - + proceed = self.handleAlignedDataOnClosing() if not proceed: event.ignore() return - + proceed = self.askCropAndSave() if not proceed: event.ignore() @@ -3251,8 +3194,7 @@ def closeEvent(self, event): if self.buttonToRestore is not None: button, color, text = self.buttonToRestore button.setText(text) - button.setStyleSheet( - f'QPushButton {{background-color: {color};}}') + button.setStyleSheet(f"QPushButton {{background-color: {color};}}") self.mainWin.setWindowState(Qt.WindowNoState) self.mainWin.setWindowState(Qt.WindowActive) self.mainWin.raise_() @@ -3260,7 +3202,7 @@ def closeEvent(self, event): event.ignore() self.hide() - self.logger.info('Closing dataPrep logger...') + self.logger.info("Closing dataPrep logger...") handlers = self.logger.handlers[:] for handler in handlers: handler.close() @@ -3268,17 +3210,17 @@ def closeEvent(self, event): if self.loop is not None: self.loop.exit() - + self.sigClose.emit(self) gc.collect() def saveWindowGeometry(self): - settings = QSettings('schmollerlab', 'acdc_dataPrep') + settings = QSettings("schmollerlab", "acdc_dataPrep") settings.setValue("geometry", self.saveGeometry()) def readSettings(self): - settings = QSettings('schmollerlab', 'acdc_dataPrep') - if settings.value('geometry') is not None: + settings = QSettings("schmollerlab", "acdc_dataPrep") + if settings.value("geometry") is not None: self.restoreGeometry(settings.value("geometry")) def show(self): diff --git a/cellacdc/dataReStruct.py b/cellacdc/dataReStruct.py index b1f629f4a..c50e23048 100644 --- a/cellacdc/dataReStruct.py +++ b/cellacdc/dataReStruct.py @@ -14,14 +14,12 @@ from . import apps, html_utils, myutils, printl, widgets, workers # Frame number must be at the end with .ext, e.g., _t01.tif -frame_name_patterns = ( - r'_(day)?(\d+)\.[A-Za-z0-9]+$', - r'_(t)?(\d+)\.[A-Za-z0-9]+$' -) +frame_name_patterns = (r"_(day)?(\d+)\.[A-Za-z0-9]+$", r"_(t)?(\d+)\.[A-Za-z0-9]+$") + def get_frame_num_and_pattern(filename): # Start with random un-matching pattern - matching_frame_name_pattern = r'^\.+' + matching_frame_name_pattern = r"^\.+" for frame_name_pattern in frame_name_patterns: try: frameNumber = re.findall(frame_name_pattern, filename)[0][1] @@ -31,42 +29,46 @@ def get_frame_num_and_pattern(filename): frameNumber = None return matching_frame_name_pattern, frameNumber + def readFilenamePattern(fileName): - matching_frame_name_pattern, frameNumber = get_frame_num_and_pattern( - fileName - ) - - s = re.sub(matching_frame_name_pattern, '', fileName) + matching_frame_name_pattern, frameNumber = get_frame_num_and_pattern(fileName) + + s = re.sub(matching_frame_name_pattern, "", fileName) for i, c in enumerate(s[::-1]): - if c == '_': + if c == "_": break channelName = s[-i:] - posName = s[:-i-1] - if channelName.endswith('.tif'): + posName = s[: -i - 1] + if channelName.endswith(".tif"): channelName = channelName[:-4] - + return posName, frameNumber, channelName def _log(mainWin, text): mainWin.log(text) + def run(mainWin): items = ( - 'Multiple files, one for each time-point', - 'Multiple files, one for each channel' + "Multiple files, one for each time-point", + "Multiple files, one for each channel", ) selectHowWin = apps.QDialogCombobox( - 'Select how files are structured', items, - 'Select how files are structured', - CbLabel='', parent=mainWin + "Select how files are structured", + items, + "Select how files are structured", + CbLabel="", + parent=mainWin, ) selectHowWin.exec_() if selectHowWin.cancel: return False - - mainWin.log(f'[Data Re-Struct] Selected file structure = "{selectHowWin.selectedItemText}"') + + mainWin.log( + f'[Data Re-Struct] Selected file structure = "{selectHowWin.selectedItemText}"' + ) msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph(""" @@ -74,39 +76,35 @@ def run(mainWin): into an empty folder before closing this dialogue.

Note that there should be no other files in this folder. - """ - ) + """) msg.information( - mainWin, 'Microscopy files location', txt, - buttonsTexts=('Cancel', 'Done') + mainWin, "Microscopy files location", txt, buttonsTexts=("Cancel", "Done") ) if msg.cancel: return False - + mainWin.log( - '[Data Re-Struct] Asking to select the folder that contains the image files...' + "[Data Re-Struct] Asking to select the folder that contains the image files..." ) MostRecentPath = myutils.getMostRecentPath() rootFolderPath = QFileDialog.getExistingDirectory( - mainWin.progressWin, 'Select folder containing the image files', - MostRecentPath) + mainWin.progressWin, "Select folder containing the image files", MostRecentPath + ) myutils.addToRecentPaths(rootFolderPath) if not rootFolderPath: return False - - mainWin.log( - '[Data Re-Struct] Asking in which folder to save the images files...' - ) + + mainWin.log("[Data Re-Struct] Asking in which folder to save the images files...") dstFolderPath = QFileDialog.getExistingDirectory( - mainWin.progressWin, - 'Select the folder in which to save the images files', - rootFolderPath + mainWin.progressWin, + "Select the folder in which to save the images files", + rootFolderPath, ) myutils.addToRecentPaths(dstFolderPath) if not rootFolderPath: return False - - mainWin.log('[Data Re-Struct] Checking file format of loaded files...') + + mainWin.log("[Data Re-Struct] Checking file format of loaded files...") validFilenames = checkFileFormat(rootFolderPath, mainWin) if not validFilenames: return False @@ -118,46 +116,48 @@ def run(mainWin): return started elif selectHowWin.selectedItemIdx == 1: msg = widgets.myMessageBox(wrapText=False) - copyButton = widgets.copyPushButton('Copy files') - moveButton = widgets.movePushButton('Move files') + copyButton = widgets.copyPushButton("Copy files") + moveButton = widgets.movePushButton("Move files") txt = html_utils.paragraph( - 'Do you want to copy or move the files to the ' - 'Position folders?' + "Do you want to copy or move the files to the Position folders?" ) msg.question( - mainWin, 'Copy or move files?', txt, - buttonsTexts=('Cancel', copyButton, moveButton) + mainWin, + "Copy or move files?", + txt, + buttonsTexts=("Cancel", copyButton, moveButton), ) if msg.cancel: return False - action = 'copy' if msg.clickedButton == copyButton else 'move' + action = "copy" if msg.clickedButton == copyButton else "move" started = _run_multi_files_multi_pos( mainWin, rootFolderPath, dstFolderPath, action ) return started - + return True + def checkFileFormat(folderPath, mainWin): ls = natsorted(myutils.listdir(folderPath)) files = [ - filename for filename in ls + filename + for filename in ls if os.path.isfile(os.path.join(folderPath, filename)) ] if not files: msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - 'The following folder

' - f'{folderPath}

' - 'does not contain any file!
' + "The following folder

" + f"{folderPath}

" + "does not contain any file!
" ) msg.addShowInFileManagerButton(folderPath) - msg.critical( - mainWin, 'Multiple extensions detected', txt - ) + msg.critical(mainWin, "Multiple extensions detected", txt) return [] all_ext = [ - os.path.splitext(filename)[1] for filename in ls + os.path.splitext(filename)[1] + for filename in ls if os.path.isfile(os.path.join(folderPath, filename)) ] counter = collections.Counter(all_ext) @@ -167,21 +167,21 @@ def checkFileFormat(folderPath, mainWin): if not is_ext_unique: msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - 'The following folder

' - f'{folderPath}

' - 'contains files with different file extensions ' - f'(extensions detected: {unique_ext})

' - f'However, the most common extension is {most_common_ext}, ' - 'do you want to proceed with
' - f'loading only files with extension {most_common_ext}?' + "The following folder

" + f"{folderPath}

" + "contains files with different file extensions " + f"(extensions detected: {unique_ext})

" + f"However, the most common extension is {most_common_ext}, " + "do you want to proceed with
" + f"loading only files with extension {most_common_ext}?" ) _, proceedWithMostCommon = msg.warning( - mainWin, 'Multiple extensions detected', txt, - buttonsTexts=('Cancel', 'Yes') + mainWin, "Multiple extensions detected", txt, buttonsTexts=("Cancel", "Yes") ) if proceedWithMostCommon == msg.clickedButton: files = [ - filename for filename in files + filename + for filename in files if os.path.splitext(filename)[1] == most_common_ext ] otherExt = [ext for ext in unique_ext if ext != most_common_ext] @@ -190,14 +190,14 @@ def checkFileFormat(folderPath, mainWin): return files + def saveTiff(filePath, data, waitCond): myutils.to_tiff(filePath, data) waitCond.wakeAll() del data -def _run_multi_files_timepoints( - mainWin, validFilenames, rootFolderPath, dstFolderPath - ): + +def _run_multi_files_timepoints(mainWin, validFilenames, rootFolderPath, dstFolderPath): sampleFilename = validFilenames[0] win = apps.MultiTimePointFilePattern( @@ -206,21 +206,21 @@ def _run_multi_files_timepoints( win.exec_() if win.cancel: return False - - matching_frame_name_pattern, frameNumber = get_frame_num_and_pattern( - sampleFilename - ) + + matching_frame_name_pattern, frameNumber = get_frame_num_and_pattern(sampleFilename) mainWin.thread = QThread() mainWin.restructWorker = workers.RestructMultiTimepointsWorker( - win.allChannels, matching_frame_name_pattern, win.basename, - validFilenames, rootFolderPath, dstFolderPath, - segmFolderPath=win.segmFolderPath + win.allChannels, + matching_frame_name_pattern, + win.basename, + validFilenames, + rootFolderPath, + dstFolderPath, + segmFolderPath=win.segmFolderPath, ) mainWin.restructWorker.moveToThread(mainWin.thread) mainWin.restructWorker.signals.finished.connect(mainWin.thread.quit) - mainWin.restructWorker.signals.finished.connect( - mainWin.restructWorker.deleteLater - ) + mainWin.restructWorker.signals.finished.connect(mainWin.restructWorker.deleteLater) mainWin.thread.finished.connect(mainWin.thread.deleteLater) # Custom signals @@ -230,9 +230,7 @@ def _run_multi_files_timepoints( mainWin.restructWorker.signals.initProgressBar.connect( mainWin.workerInitProgressbar ) - mainWin.restructWorker.signals.progressBar.connect( - mainWin.workerUpdateProgressbar - ) + mainWin.restructWorker.signals.progressBar.connect(mainWin.workerUpdateProgressbar) mainWin.restructWorker.sigSaveTiff.connect(saveTiff) mainWin.thread.started.connect(mainWin.restructWorker.run) @@ -240,6 +238,7 @@ def _run_multi_files_timepoints( return True + def _run_multi_files_multi_pos(mainWin, rootFolderPath, dstFolderPath, action): mainWin.thread = QThread() mainWin.restructWorker = workers.RestructMultiPosWorker( @@ -247,9 +246,7 @@ def _run_multi_files_multi_pos(mainWin, rootFolderPath, dstFolderPath, action): ) mainWin.restructWorker.moveToThread(mainWin.thread) mainWin.restructWorker.signals.finished.connect(mainWin.thread.quit) - mainWin.restructWorker.signals.finished.connect( - mainWin.restructWorker.deleteLater - ) + mainWin.restructWorker.signals.finished.connect(mainWin.restructWorker.deleteLater) mainWin.thread.finished.connect(mainWin.thread.deleteLater) # Custom signals @@ -259,11 +256,9 @@ def _run_multi_files_multi_pos(mainWin, rootFolderPath, dstFolderPath, action): mainWin.restructWorker.signals.initProgressBar.connect( mainWin.workerInitProgressbar ) - mainWin.restructWorker.signals.progressBar.connect( - mainWin.workerUpdateProgressbar - ) + mainWin.restructWorker.signals.progressBar.connect(mainWin.workerUpdateProgressbar) mainWin.thread.started.connect(mainWin.restructWorker.run) mainWin.thread.start() - return True \ No newline at end of file + return True diff --git a/cellacdc/dataStruct.py b/cellacdc/dataStruct.py index b41dbbc28..3a5e4f557 100755 --- a/cellacdc/dataStruct.py +++ b/cellacdc/dataStruct.py @@ -23,14 +23,19 @@ from itertools import permutations from qtpy.QtWidgets import ( - QApplication, QMainWindow, QFileDialog, - QVBoxLayout, QPushButton, QLabel, QStyleFactory, - QWidget, QMessageBox, QPlainTextEdit, QHBoxLayout -) -from qtpy.QtCore import ( - Qt, QObject, Signal, QThread, QMutex, QWaitCondition, - QEventLoop + QApplication, + QMainWindow, + QFileDialog, + QVBoxLayout, + QPushButton, + QLabel, + QStyleFactory, + QWidget, + QMessageBox, + QPlainTextEdit, + QHBoxLayout, ) +from qtpy.QtCore import Qt, QObject, Signal, QThread, QMutex, QWaitCondition, QEventLoop from qtpy import QtGui # Here we use from cellacdc because this script is laucnhed in @@ -47,15 +52,17 @@ from . import acdc_regex from . import io -if os.name == 'nt': +if os.name == "nt": try: # Set taskbar icon in windows import ctypes - myappid = 'schmollerlab.cellacdc.pyqt.v1' # arbitrary string + + myappid = "schmollerlab.cellacdc.pyqt.v1" # arbitrary string ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) except: pass + def worker_exception_handler(func): @wraps(func) def run(self): @@ -65,8 +72,10 @@ def run(self): result = None self.critical.emit(error) return result + return run + class bioFormatsWorker(QObject): finished = Signal() progress = Signal(str) @@ -74,21 +83,43 @@ class bioFormatsWorker(QObject): initPbar = Signal(int) criticalError = Signal(str, str, str) confirmMetadata = Signal( - str, float, int, int, int, int, - float, str, float, float, float, - str, list, list, str, str, object + str, + float, + int, + int, + int, + int, + float, + str, + float, + float, + float, + str, + list, + list, + str, + str, + object, ) critical = Signal(object) sigFinishedReadingSampleImageData = Signal(object) def __init__( - self, raw_src_path, rawFilenames, exp_dst_path, - mutex, waitCond, rawDataStruct, - bioformats_backend: Literal['bioio', 'python-bioformats'], - lazy_load=True, move_raw_microscopy_files=True, - overwrite=False, add_files=False, create_new=False, - start_pos_n=1 - ): + self, + raw_src_path, + rawFilenames, + exp_dst_path, + mutex, + waitCond, + rawDataStruct, + bioformats_backend: Literal["bioio", "python-bioformats"], + lazy_load=True, + move_raw_microscopy_files=True, + overwrite=False, + add_files=False, + create_new=False, + start_pos_n=1, + ): QObject.__init__(self) self.raw_src_path = raw_src_path self.exp_dst_path = exp_dst_path @@ -107,34 +138,41 @@ def __init__( self.bioformats_backend = bioformats_backend self.lazy_load = lazy_load self.move_raw_microscopy_files = move_raw_microscopy_files - + def _readSampleDataPythonBioformats( - self, bioformats, rawFilePath, sampleImgData, SizeC, SizeT, SizeZ, - sampleSizeT, sampleSizeZ - ): + self, + bioformats, + rawFilePath, + sampleImgData, + SizeC, + SizeT, + SizeZ, + sampleSizeT, + sampleSizeZ, + ): dimsIdx = {} allChannelsData = None with bioformats.ImageReader(rawFilePath) as reader: permut_pbar = tqdm(total=6, ncols=100) - for dimsOrd in permutations('zct', 3): - if allChannelsData is not None and self.bioformats_backend == 'bioio': - sampleImgData[''.join(dimsOrd)] = allChannelsData + for dimsOrd in permutations("zct", 3): + if allChannelsData is not None and self.bioformats_backend == "bioio": + sampleImgData["".join(dimsOrd)] = allChannelsData permut_pbar.update(1) continue - + allChannelsData = [] idxs = self.buildIndexes(SizeC, SizeT, SizeZ, dimsOrd) - numIter = SizeC*sampleSizeT*sampleSizeZ + numIter = SizeC * sampleSizeT * sampleSizeZ pbar = tqdm(total=numIter, ncols=100, leave=False) skipPermutation = False for c in range(SizeC): - dimsIdx['c'] = c + dimsIdx["c"] = c imgData_tz = [] - for t in range(sampleSizeT): - dimsIdx['t'] = t + for t in range(sampleSizeT): + dimsIdx["t"] = t imgData_z = [] for z in range(sampleSizeZ): - dimsIdx['z'] = z + dimsIdx["z"] = z try: idx = self.getIndex(idxs, dimsIdx, dimsOrd) imgData = reader.read( @@ -143,10 +181,10 @@ def _readSampleDataPythonBioformats( except Exception as e: skipPermutation = True break - imgData_z.append(imgData) + imgData_z.append(imgData) pbar.update() if skipPermutation: - break + break imgData_z = np.array(imgData_z, dtype=imgData.dtype) imgData_z = np.squeeze(imgData_z) imgData_tz.append(imgData_z) @@ -157,49 +195,49 @@ def _readSampleDataPythonBioformats( pbar.close() permut_pbar.update(1) if not skipPermutation: - sampleImgData[''.join(dimsOrd)] = allChannelsData + sampleImgData["".join(dimsOrd)] = allChannelsData permut_pbar.close() return sampleImgData - + def readSampleData(self, rawFilePath, SizeC, SizeT, SizeZ): - if self.bioformats_backend == 'bioio': + if self.bioformats_backend == "bioio": from cellacdc import acdc_bioio_bioformats as bioformats else: import javabridge from cellacdc import bioformats - + sampleImgData = {} - self.progress.emit('Reading sample image data...') - - if self.bioformats_backend == 'bioio': - # To avoid running Java in the main process, we spawn a new + self.progress.emit("Reading sample image data...") + + if self.bioformats_backend == "bioio": + # To avoid running Java in the main process, we spawn a new # process that runs a python script to read sample data, save # it to disk, and then load it back here. import subprocess from . import _process, bioio_sample_data_folderpath - + read_sample_data_py_filepath = os.path.join( - os.path.dirname(bioformats.__file__), '_read_sample_data.py' + os.path.dirname(bioformats.__file__), "_read_sample_data.py" ) uuid4 = uuid.uuid4() command = ( - f'{sys.executable}, ' - f'{read_sample_data_py_filepath}, ' - f'-f, {rawFilePath}, ' - f'-c, {SizeC}, ' - f'-t, {SizeT}, ' - f'-z, {SizeZ},' - f'-uuid, {uuid4}' + f"{sys.executable}, " + f"{read_sample_data_py_filepath}, " + f"-f, {rawFilePath}, " + f"-c, {SizeC}, " + f"-t, {SizeT}, " + f"-z, {SizeZ}," + f"-uuid, {uuid4}" ) if not self.lazy_load: - command = f'{command}, -a' - - args = [sys.executable, _process.__file__, '-c', command] + command = f"{command}, -a" + + args = [sys.executable, _process.__file__, "-c", command] subprocess.run(args) - + bioformats._utils.check_raise_exception(uuid4) - + allChannelsData = [] for c in range(SizeC): filepath = os.path.join( @@ -208,35 +246,41 @@ def readSampleData(self, rawFilePath, SizeC, SizeT, SizeZ): channel_data = np.load(filepath) allChannelsData.append(channel_data) os.remove(filepath) - - for dimsOrd in permutations('zct', 3): - sampleImgData[''.join(dimsOrd)] = allChannelsData + + for dimsOrd in permutations("zct", 3): + sampleImgData["".join(dimsOrd)] = allChannelsData else: if SizeT >= 4: sampleSizeT = 4 else: - sampleSizeT = SizeT + sampleSizeT = SizeT if SizeZ > 20: sampleSizeZ = 20 else: sampleSizeZ = SizeZ sampleImgData = self._readSampleDataPythonBioformats( - bioformats, rawFilePath, sampleImgData, SizeC, SizeT, SizeZ, - sampleSizeT, sampleSizeZ + bioformats, + rawFilePath, + sampleImgData, + SizeC, + SizeT, + SizeZ, + sampleSizeT, + sampleSizeZ, ) - + self.sigFinishedReadingSampleImageData.emit(sampleImgData) return sampleImgData def getSizeZ(self, rawFilePath): - if self.bioformats_backend == 'bioio': + if self.bioformats_backend == "bioio": from cellacdc import acdc_bioio_bioformats as bioformats else: import javabridge from cellacdc import bioformats - + try: - if rawFilePath.endswith('.ome.tif'): + if rawFilePath.endswith(".ome.tif"): metadata = load.OMEXML(rawFilePath) metadataXML = metadata.omexml_string else: @@ -249,51 +293,46 @@ def getSizeZ(self, rawFilePath): def _readMetadataBioIO(self, rawFilePath): from . import bioio_sample_data_folderpath, _process from . import acdc_bioio_bioformats as bioformats - + import subprocess - + read_metadata_py_filepath = os.path.join( - os.path.dirname(bioformats.__file__), '_read_metadata.py' + os.path.dirname(bioformats.__file__), "_read_metadata.py" ) uuid4 = uuid.uuid4() command = ( - f'{sys.executable}, {read_metadata_py_filepath}, ' - f'-f, {rawFilePath}, ' - f'-uuid, {uuid4}' + f"{sys.executable}, {read_metadata_py_filepath}, " + f"-f, {rawFilePath}, " + f"-uuid, {uuid4}" ) - - args = [sys.executable, _process.__file__, '-c', command] + + args = [sys.executable, _process.__file__, "-c", command] subprocess.run(args) - + bioformats._utils.check_raise_exception(uuid4) metadataXML_filepath = os.path.join( - bioio_sample_data_folderpath, 'metadataXML.txt' + bioio_sample_data_folderpath, "metadataXML.txt" ) metadataXML = bioformats.Metadata().init_from_file(metadataXML_filepath) - metadata_filepath = os.path.join( - bioio_sample_data_folderpath, 'metadata.txt' - ) - metadata = bioformats.OMEXML().init_from_file( - metadata_filepath, rawFilePath - ) + metadata_filepath = os.path.join(bioio_sample_data_folderpath, "metadata.txt") + metadata = bioformats.OMEXML().init_from_file(metadata_filepath, rawFilePath) return metadata, metadataXML - - + def readMetadata(self, raw_src_path, filename): - if self.bioformats_backend == 'bioio': + if self.bioformats_backend == "bioio": from cellacdc import acdc_bioio_bioformats as bioformats else: import javabridge from cellacdc import bioformat - + rawFilePath = os.path.join(raw_src_path, filename) - self.progress.emit('Reading OME metadata...') + self.progress.emit("Reading OME metadata...") try: - if rawFilePath.endswith('.ome.tif'): + if rawFilePath.endswith(".ome.tif"): metadata = load.OMEXML(rawFilePath) metadataXML = metadata.omexml_string else: @@ -304,20 +343,17 @@ def readMetadata(self, raw_src_path, filename): traceback.print_exc() self.isCriticalError = True self.criticalError.emit( - 'reading image data or metadata', - traceback.format_exc(), filename + "reading image data or metadata", traceback.format_exc(), filename ) return True try: LensNA = float(metadata.instrument().Objective.LensNA) except Exception as e: - self.progress.emit( - '===================================================') + self.progress.emit("===================================================") self.progress.emit(rawFilePath) - self.progress.emit('WARNING: LensNA not found in metadata.') - self.progress.emit( - '===================================================') + self.progress.emit("WARNING: LensNA not found in metadata.") + self.progress.emit("===================================================") LensNA = 1.4 if self.rawDataStruct != 2: @@ -325,11 +361,13 @@ def readMetadata(self, raw_src_path, filename): SizeS = int(metadata.get_image_count()) except Exception as e: self.progress.emit( - '===================================================') + "===================================================" + ) self.progress.emit(rawFilePath) - self.progress.emit('WARNING: SizeS not found in metadata.') + self.progress.emit("WARNING: SizeS not found in metadata.") self.progress.emit( - '===================================================') + "===================================================" + ) SizeS = 1 else: SizeS = self.SizeS @@ -337,126 +375,105 @@ def readMetadata(self, raw_src_path, filename): try: SizeZ = int(metadata.image().Pixels.SizeZ) except Exception as e: - self.progress.emit( - '===================================================') + self.progress.emit("===================================================") self.progress.emit(rawFilePath) - self.progress.emit('WARNING: SizeZ not found in metadata.') - self.progress.emit( - '===================================================') + self.progress.emit("WARNING: SizeZ not found in metadata.") + self.progress.emit("===================================================") SizeZ = 1 try: SizeT = int(metadata.image().Pixels.SizeT) except Exception as e: - self.progress.emit( - '===================================================') + self.progress.emit("===================================================") self.progress.emit(rawFilePath) - self.progress.emit('WARNING: SizeT not found in metadata.') - self.progress.emit( - '===================================================') + self.progress.emit("WARNING: SizeT not found in metadata.") + self.progress.emit("===================================================") SizeT = 1 try: Pixels = metadata.image().Pixels - TimeIncrement = float(Pixels.node.get('TimeIncrement')) + TimeIncrement = float(Pixels.node.get("TimeIncrement")) except Exception as e: - self.progress.emit( - '===================================================') + self.progress.emit("===================================================") self.progress.emit(rawFilePath) - self.progress.emit('WARNING: TimeIncrement not found in metadata.') - self.progress.emit( - '===================================================') + self.progress.emit("WARNING: TimeIncrement not found in metadata.") + self.progress.emit("===================================================") TimeIncrement = 1.0 try: Pixels = metadata.image().Pixels - TimeIncrementUnit = Pixels.node.get('TimeIncrementUnit') + TimeIncrementUnit = Pixels.node.get("TimeIncrementUnit") if TimeIncrementUnit is None: raise except Exception as e: - self.progress.emit( - '===================================================') + self.progress.emit("===================================================") self.progress.emit(rawFilePath) - self.progress.emit('WARNING: TimeIncrementUnit not found in metadata.') - self.progress.emit( - '===================================================') - TimeIncrementUnit = 's' + self.progress.emit("WARNING: TimeIncrementUnit not found in metadata.") + self.progress.emit("===================================================") + TimeIncrementUnit = "s" try: SizeC = int(metadata.image().Pixels.SizeC) except Exception as e: - self.progress.emit( - '===================================================') + self.progress.emit("===================================================") self.progress.emit(rawFilePath) - self.progress.emit('WARNING: SizeC not found in metadata.') - self.progress.emit( - '===================================================') + self.progress.emit("WARNING: SizeC not found in metadata.") + self.progress.emit("===================================================") SizeC = 1 try: PhysicalSizeX = float(metadata.image().Pixels.PhysicalSizeX) except Exception as e: - self.progress.emit( - '===================================================') + self.progress.emit("===================================================") self.progress.emit(rawFilePath) - self.progress.emit('WARNING: PhysicalSizeX not found in metadata.') - self.progress.emit( - '===================================================') + self.progress.emit("WARNING: PhysicalSizeX not found in metadata.") + self.progress.emit("===================================================") PhysicalSizeX = 1.0 try: PhysicalSizeY = float(metadata.image().Pixels.PhysicalSizeY) except Exception as e: - self.progress.emit( - '===================================================') + self.progress.emit("===================================================") self.progress.emit(rawFilePath) - self.progress.emit('WARNING: PhysicalSizeY not found in metadata.') - self.progress.emit( - '===================================================') + self.progress.emit("WARNING: PhysicalSizeY not found in metadata.") + self.progress.emit("===================================================") PhysicalSizeY = 1.0 try: PhysicalSizeZ = float(metadata.image().Pixels.PhysicalSizeZ) except Exception as e: - self.progress.emit( - '===================================================') + self.progress.emit("===================================================") self.progress.emit(rawFilePath) - self.progress.emit('WARNING: PhysicalSizeZ not found in metadata.') - self.progress.emit( - '===================================================') + self.progress.emit("WARNING: PhysicalSizeZ not found in metadata.") + self.progress.emit("===================================================") PhysicalSizeZ = 1.0 try: Pixels = metadata.image().Pixels - PhysicalSizeUnit = Pixels.node.get('PhysicalSizeXUnit') + PhysicalSizeUnit = Pixels.node.get("PhysicalSizeXUnit") if PhysicalSizeUnit is None: raise except Exception as e: - self.progress.emit( - '===================================================') + self.progress.emit("===================================================") self.progress.emit(rawFilePath) - self.progress.emit('WARNING: PhysicalSizeUnit not found in metadata.') - self.progress.emit( - '===================================================') - PhysicalSizeUnit = 'μm' + self.progress.emit("WARNING: PhysicalSizeUnit not found in metadata.") + self.progress.emit("===================================================") + PhysicalSizeUnit = "μm" try: ImageName = metadata.image().Name if ImageName is None: raise except Exception as e: - self.progress.emit( - '===================================================') + self.progress.emit("===================================================") self.progress.emit(rawFilePath) - self.progress.emit('WARNING: Image Name not found in metadata.') - self.progress.emit( - '===================================================') - ImageName = '' - + self.progress.emit("WARNING: Image Name not found in metadata.") + self.progress.emit("===================================================") + ImageName = "" if self.rawDataStruct != 2: try: - chNames = ['']*SizeC + chNames = [""] * SizeC for c in range(SizeC): try: chNames[c] = metadata.image().Pixels.Channel(c).Name @@ -464,19 +481,21 @@ def readMetadata(self, raw_src_path, filename): pass except Exception as e: self.progress.emit( - '===================================================') + "===================================================" + ) self.progress.emit(rawFilePath) - self.progress.emit('WARNING: chNames not found in metadata.') + self.progress.emit("WARNING: chNames not found in metadata.") self.progress.emit( - '===================================================') - chNames = ['']*SizeC + "===================================================" + ) + chNames = [""] * SizeC else: chNames = self.chNames SizeC = len(self.chNames) if self.rawDataStruct != 2: try: - emWavelens = [500.0]*SizeC + emWavelens = [500.0] * SizeC for c in range(SizeC): try: Channel = metadata.image().Pixels.Channel(c) @@ -487,14 +506,16 @@ def readMetadata(self, raw_src_path, filename): except Exception as e: traceback.print_exc() self.progress.emit( - '===================================================') + "===================================================" + ) self.progress.emit(rawFilePath) - self.progress.emit('WARNING: EmissionWavelength not found in metadata.') + self.progress.emit("WARNING: EmissionWavelength not found in metadata.") self.progress.emit( - '===================================================') - emWavelens = [500.0]*SizeC + "===================================================" + ) + emWavelens = [500.0] * SizeC else: - emWavelens = [500.0]*SizeC + emWavelens = [500.0] * SizeC if self.trustMetadataReader: self.LensNA = LensNA @@ -514,14 +535,25 @@ def readMetadata(self, raw_src_path, filename): while True: self.mutex.lock() if self.rawDataStruct != 2: - sampleImgData = self.readSampleData( - rawFilePath, SizeC, SizeT, SizeZ - ) + sampleImgData = self.readSampleData(rawFilePath, SizeC, SizeT, SizeZ) self.confirmMetadata.emit( - filename, LensNA, SizeT, SizeZ, SizeC, SizeS, - TimeIncrement, TimeIncrementUnit, PhysicalSizeX, PhysicalSizeY, - PhysicalSizeZ, PhysicalSizeUnit, chNames, emWavelens, ImageName, - rawFilePath, sampleImgData + filename, + LensNA, + SizeT, + SizeZ, + SizeC, + SizeS, + TimeIncrement, + TimeIncrementUnit, + PhysicalSizeX, + PhysicalSizeY, + PhysicalSizeZ, + PhysicalSizeUnit, + chNames, + emWavelens, + ImageName, + rawFilePath, + sampleImgData, ) self.waitCond.wait(self.mutex) self.mutex.unlock() @@ -566,50 +598,58 @@ def readMetadata(self, raw_src_path, filename): self.saveChannels = self.metadataWin.saveChannels self.emWavelens = self.metadataWin.emWavelens self.addImageName = self.metadataWin.addImageName - + return False def saveToPosFolder( - self, p, raw_src_path, exp_dst_path, filename, series, pos_n, - p_idx=0, - ): + self, + p, + raw_src_path, + exp_dst_path, + filename, + series, + pos_n, + p_idx=0, + ): rawFilePath = os.path.join(raw_src_path, filename) - if os.path.basename(raw_src_path) == 'raw_microscopy_files': + if os.path.basename(raw_src_path) == "raw_microscopy_files": raw_src_path = os.path.dirname(raw_src_path) - in_file_pos_name = f'Position_{p+1}' + in_file_pos_name = f"Position_{p + 1}" savePos = ( - 'All Positions' in self.selectedPos - or in_file_pos_name in self.selectedPos + "All Positions" in self.selectedPos or in_file_pos_name in self.selectedPos ) if not savePos: return False - pos_path = os.path.join(exp_dst_path, f'Position_{pos_n}') - images_path = os.path.join(pos_path, 'Images') + pos_path = os.path.join(exp_dst_path, f"Position_{pos_n}") + images_path = os.path.join(pos_path, "Images") if os.path.exists(images_path) and self.overwritePos: shutil.rmtree(images_path) - + if os.path.exists(images_path) and self.createNew: - images_path = re.sub( - r'Position_\d+', f'Position_{pos_n}', images_path - ) - + images_path = re.sub(r"Position_\d+", f"Position_{pos_n}", images_path) + if not os.path.exists(images_path): os.makedirs(images_path, exist_ok=True) - + self.saveData( - images_path, rawFilePath, filename, p, series, pos_n, p_idx=p_idx, + images_path, + rawFilePath, + filename, + p, + series, + pos_n, + p_idx=p_idx, ) - + return False def _saveDataPythonBioformats( - self, bioformats, rawFilePath, series, images_path, filenameNOext, - s0p, idxs - ): + self, bioformats, rawFilePath, series, images_path, filenameNOext, s0p, idxs + ): SizeZ = self.getSizeZ(rawFilePath) with bioformats.ImageReader(rawFilePath) as reader: iter = enumerate(zip(self.chNames, self.saveChannels)) @@ -619,155 +659,166 @@ def _saveDataPythonBioformats( continue self.progress.emit( - f' Saving channel {c+1}/{len(self.chNames)} ({chName})' + f" Saving channel {c + 1}/{len(self.chNames)} ({chName})" ) self.saveImgDataChannel( - reader, series, images_path, filenameNOext, s0p, - chName, c, idxs, SizeZ + reader, + series, + images_path, + filenameNOext, + s0p, + chName, + c, + idxs, + SizeZ, ) - + def _saveDataPythonBioformatsSingleChannel( - self, bioformats, rawFilePath, series, images_path, filenameNOext, - s0p, idxs, chName, c_idx - ): + self, + bioformats, + rawFilePath, + series, + images_path, + filenameNOext, + s0p, + idxs, + chName, + c_idx, + ): SizeZ = self.getSizeZ(rawFilePath) with bioformats.ImageReader(rawFilePath) as reader: self.progress.emit( - f' Saving channel {c_idx+1}/{len(self.chNames)} ({chName})' + f" Saving channel {c_idx + 1}/{len(self.chNames)} ({chName})" ) imgData_ch = [] self.saveImgDataChannel( - reader, series, images_path, filenameNOext, s0p, - chName, 0, idxs, SizeZ + reader, series, images_path, filenameNOext, s0p, chName, 0, idxs, SizeZ ) - + def removeInvalidCharacters(self, chName_in): # Remove invalid charachters chName = "".join( - c if c.isalnum() or c=='_' or c=='' else '_' for c in chName_in + c if c.isalnum() or c == "_" or c == "" else "_" for c in chName_in ) - trim_ = chName.endswith('_') + trim_ = chName.endswith("_") while trim_: chName = chName[:-1] - trim_ = chName.endswith('_') + trim_ = chName.endswith("_") def getFilename( - self, filenameNOext, s0p, appendTxt, series, ext, - return_basename=False - ): + self, filenameNOext, s0p, appendTxt, series, ext, return_basename=False + ): # Do not allow dots in the filename since it breaks stuff here and there - filenameNOext = filenameNOext.replace('.', '_') + filenameNOext = filenameNOext.replace(".", "_") if self.addImageName: try: ImageName = self.metadata.image(index=series).Name if not isinstance(ImageName, str): raise except Exception as e: - ImageName = '' + ImageName = "" self.removeInvalidCharacters(ImageName) - basename = f'{filenameNOext}_{ImageName}_s{s0p}_' - filename = f'{basename}{appendTxt}{ext}' + basename = f"{filenameNOext}_{ImageName}_s{s0p}_" + filename = f"{basename}{appendTxt}{ext}" else: - basename = f'{filenameNOext}_s{s0p}_' - filename = f'{basename}{appendTxt}{ext}' + basename = f"{filenameNOext}_s{s0p}_" + filename = f"{basename}{appendTxt}{ext}" if return_basename: return filename, basename else: return filename - + def buildIndexes(self, SizeC, SizeT, SizeZ): - SizesCTZ = {'c': SizeC, 't': SizeT, 'z': SizeZ} + SizesCTZ = {"c": SizeC, "t": SizeT, "z": SizeZ} idxs = {} - k_key, i_key, j_key = 'ztc' + k_key, i_key, j_key = "ztc" idx = 0 for k in range(SizesCTZ[k_key]): for i in range(SizesCTZ[i_key]): for j in range(SizesCTZ[j_key]): - idxs[(k,i,j)] = idx + idxs[(k, i, j)] = idx idx += 1 return idxs def getIndex(self, idxs, dimsIdx): - dims = tuple([dimsIdx.get(v, 0) for v in 'ztc']) + dims = tuple([dimsIdx.get(v, 0) for v in "ztc"]) return idxs[dims] - + def saveImgDataChannel( - self, reader, series, images_path, filenameNOext, s0p, chName, - ch_idx, idxs, SizeZ - ): + self, + reader, + series, + images_path, + filenameNOext, + s0p, + chName, + ch_idx, + idxs, + SizeZ, + ): savedSizeT = self.timeRangeToSave[1] - self.timeRangeToSave[0] + 1 if self.to_h5: - filename = self.getFilename( - filenameNOext, s0p, chName, series, '.h5' - ) + filename = self.getFilename(filenameNOext, s0p, chName, series, ".h5") tempDir = tempfile.mkdtemp() tempFilepath = os.path.join(tempDir, filename) - print('==========================================================') + print("==========================================================") print(f'.h5 tempfile: "{tempFilepath}"') - print('==========================================================') - h5f = h5py.File(tempFilepath, 'w') + print("==========================================================") + h5f = h5py.File(tempFilepath, "w") # Read SizeX and SizeY from the shape of one image - imgData = reader.read( - c=ch_idx, z=0, t=0, series=series, rescale=False - ) + imgData = reader.read(c=ch_idx, z=0, t=0, series=series, rescale=False) shape = (savedSizeT, SizeZ, *imgData.shape) - chunks = (1,1,*imgData.shape) + chunks = (1, 1, *imgData.shape) imgData_ch = h5f.create_dataset( - 'data', shape, dtype=imgData.dtype, - chunks=chunks, shuffle=False + "data", shape, dtype=imgData.dtype, chunks=chunks, shuffle=False ) else: - filename = self.getFilename( - filenameNOext, s0p, chName, series, '.tif' - ) + filename = self.getFilename(filenameNOext, s0p, chName, series, ".tif") imgData_ch = [] - framesRange = range( - self.timeRangeToSave[0]-1, - self.timeRangeToSave[1] - ) + framesRange = range(self.timeRangeToSave[0] - 1, self.timeRangeToSave[1]) filePath = os.path.join(images_path, filename) - dimsIdx = {'c': ch_idx} + dimsIdx = {"c": ch_idx} numFrames = len(framesRange) - num_imgs = numFrames*SizeZ + num_imgs = numFrames * SizeZ pbar = tqdm( - total=num_imgs, - ncols=100, - desc=f'Reading image (z 0/{SizeZ}, t 0/{numFrames})' + total=num_imgs, + ncols=100, + desc=f"Reading image (z 0/{SizeZ}, t 0/{numFrames})", ) for out_t, t in enumerate(framesRange): imgData_z = [] - dimsIdx['t'] = t + dimsIdx["t"] = t for z in range(SizeZ): pbar.set_description( - f'Reading image (z {z+1}/{SizeZ}, t {out_t+1}/{numFrames})' + f"Reading image (z {z + 1}/{SizeZ}, t {out_t + 1}/{numFrames})" ) - dimsIdx['z'] = z + dimsIdx["z"] = z if self.rawDataStruct != 2: idx = self.getIndex(idxs, dimsIdx) else: idx = None imgData = reader.read( - c=ch_idx, z=z, t=t, series=series, rescale=False, - index=idx + c=ch_idx, z=z, t=t, series=series, rescale=False, index=idx ) if self.to_h5: imgData_ch[out_t, z] = imgData else: imgData_z.append(imgData) - + pbar.update() if not self.to_h5: imgData_z = np.squeeze(np.array(imgData_z, dtype=imgData.dtype)) imgData_ch.append(imgData_z) pbar.close() - + if not self.to_h5: imgData_ch = np.squeeze(np.array(imgData_ch, dtype=imgData.dtype)) myutils.to_tiff( - filePath, imgData_ch, + filePath, + imgData_ch, SizeT=savedSizeT, SizeZ=self.SizeZ, TimeIncrement=self.TimeIncrement, @@ -780,91 +831,91 @@ def saveImgDataChannel( shutil.move(tempFilepath, filePath) shutil.rmtree(tempDir) - def saveData( - self, images_path, rawFilePath, filename, p, series, pos_n, - p_idx=0 - ): - if self.bioformats_backend == 'bioio': + def saveData(self, images_path, rawFilePath, filename, p, series, pos_n, p_idx=0): + if self.bioformats_backend == "bioio": from cellacdc import acdc_bioio_bioformats as bioformats else: import javabridge from cellacdc import bioformats - + s0p = str(pos_n).zfill(self.numPosDigits) self.progress.emit( - f'Position {pos_n}/{self.numPos}: saving data to {images_path}...' + f"Position {pos_n}/{self.numPos}: saving data to {images_path}..." ) filenameNOext, ext = os.path.splitext(filename) metadataXML_path = os.path.join( images_path, - self.getFilename(filenameNOext, s0p, 'metadataXML', series, '.txt') + self.getFilename(filenameNOext, s0p, "metadataXML", series, ".txt"), ) - with open(metadataXML_path, 'w', encoding="utf-8") as txt: + with open(metadataXML_path, "w", encoding="utf-8") as txt: txt.write(str(self.metadataXML)) metadata_filename, basename = self.getFilename( - filenameNOext, s0p, 'metadata', series, '.csv', - return_basename=True + filenameNOext, s0p, "metadata", series, ".csv", return_basename=True ) metadata_csv_path = os.path.join(images_path, metadata_filename) - savedSizeT = ( - self.timeRangeToSave[1] - self.timeRangeToSave[0] + 1 - ) - df = pd.DataFrame({ - 'LensNA': self.LensNA, - 'SizeT': savedSizeT, - 'SizeZ': self.SizeZ, - 'TimeIncrement': self.TimeIncrement, - 'PhysicalSizeZ': self.PhysicalSizeZ, - 'PhysicalSizeY': self.PhysicalSizeY, - 'PhysicalSizeX': self.PhysicalSizeX, - 'basename': basename - }, index=['values']).T - df.index.name = 'Description' + savedSizeT = self.timeRangeToSave[1] - self.timeRangeToSave[0] + 1 + df = pd.DataFrame( + { + "LensNA": self.LensNA, + "SizeT": savedSizeT, + "SizeZ": self.SizeZ, + "TimeIncrement": self.TimeIncrement, + "PhysicalSizeZ": self.PhysicalSizeZ, + "PhysicalSizeY": self.PhysicalSizeY, + "PhysicalSizeX": self.PhysicalSizeX, + "basename": basename, + }, + index=["values"], + ).T + df.index.name = "Description" ch_metadata = [ - chName for c, chName in enumerate(self.chNames) - if self.saveChannels[c] + chName for c, chName in enumerate(self.chNames) if self.saveChannels[c] ] description = [ - f'channel_{c}_name' for c in range(self.SizeC) - if self.saveChannels[c] + f"channel_{c}_name" for c in range(self.SizeC) if self.saveChannels[c] ] - ch_metadata.extend([ - wavelen for c, wavelen in enumerate(self.emWavelens) - if self.saveChannels[c] - ]) - description.extend([ - f'channel_{c}_emWavelen' for c in range(self.SizeC) - if self.saveChannels[c] - ]) - - df_channelNames = pd.DataFrame({ - 'Description': description, - 'values': ch_metadata - }).set_index('Description') + ch_metadata.extend( + [ + wavelen + for c, wavelen in enumerate(self.emWavelens) + if self.saveChannels[c] + ] + ) + description.extend( + [ + f"channel_{c}_emWavelen" + for c in range(self.SizeC) + if self.saveChannels[c] + ] + ) + + df_channelNames = pd.DataFrame( + {"Description": description, "values": ch_metadata} + ).set_index("Description") df = pd.concat([df, df_channelNames]) if os.path.exists(metadata_csv_path): # Keep channel names already existing and not saved now - existing_df = pd.read_csv(metadata_csv_path).set_index('Description') + existing_df = pd.read_csv(metadata_csv_path).set_index("Description") for c, chName in enumerate(self.chNames): if self.saveChannels[c]: continue - chName_idx = f'channel_{c}_name' - chWavelen_idx = f'channel_{c}_emWavelen' + chName_idx = f"channel_{c}_name" + chWavelen_idx = f"channel_{c}_emWavelen" try: - existing_chName = existing_df.at[chName_idx, 'values'] - df.at[chName_idx, 'values'] = existing_chName + existing_chName = existing_df.at[chName_idx, "values"] + df.at[chName_idx, "values"] = existing_chName except Exception as e: traceback.print_exc() pass - + try: - existing_chWavelen = existing_df.at[chWavelen_idx, 'values'] - df.at[chWavelen_idx, 'values'] = existing_chWavelen + existing_chWavelen = existing_df.at[chWavelen_idx, "values"] + df.at[chWavelen_idx, "values"] = existing_chWavelen except Exception as e: traceback.print_exc() pass @@ -872,53 +923,58 @@ def saveData( df.to_csv(metadata_csv_path) idxs = self.buildIndexes(self.SizeC, self.SizeT, self.SizeZ) - if self.rawDataStruct != 2: - if self.bioformats_backend == 'bioio': + if self.rawDataStruct != 2: + if self.bioformats_backend == "bioio": import subprocess from . import _process - + save_data_py_filepath = os.path.join( - os.path.dirname(bioformats.__file__), '_save_data.py' + os.path.dirname(bioformats.__file__), "_save_data.py" ) zyx_physical_sizes = ( - self.PhysicalSizeZ, self.PhysicalSizeY, self.PhysicalSizeX - ) - zyx_physical_sizes = " ".join( - [str(val) for val in zyx_physical_sizes] + self.PhysicalSizeZ, + self.PhysicalSizeY, + self.PhysicalSizeX, ) + zyx_physical_sizes = " ".join([str(val) for val in zyx_physical_sizes]) uuid4 = uuid.uuid4() command = ( - f'{sys.executable}, {save_data_py_filepath}, ' - f'-f, {rawFilePath}, ' - f'-d, {" ".join([str(val) for val in self.saveChannels])}, ' - f'-c, {" ".join(self.chNames)}, ' - f'-s, {series}, ' - f'-i, {images_path}, ' - f'-p, {filenameNOext}, ' - f'-pos, {s0p}, ' - f'-t, {self.SizeT}, ' - f'-z, {self.getSizeZ(rawFilePath)}, ' - f'-time_increment, {self.TimeIncrement}, ' - f'-zyx, {zyx_physical_sizes}, ' - f'-r, {" ".join([str(val) for val in self.timeRangeToSave])}, ' - f'-uuid, {uuid4}' + f"{sys.executable}, {save_data_py_filepath}, " + f"-f, {rawFilePath}, " + f"-d, {' '.join([str(val) for val in self.saveChannels])}, " + f"-c, {' '.join(self.chNames)}, " + f"-s, {series}, " + f"-i, {images_path}, " + f"-p, {filenameNOext}, " + f"-pos, {s0p}, " + f"-t, {self.SizeT}, " + f"-z, {self.getSizeZ(rawFilePath)}, " + f"-time_increment, {self.TimeIncrement}, " + f"-zyx, {zyx_physical_sizes}, " + f"-r, {' '.join([str(val) for val in self.timeRangeToSave])}, " + f"-uuid, {uuid4}" ) if self.to_h5: - command = f'{command}, -to_h5' - + command = f"{command}, -to_h5" + if not self.lazy_load: - command = f'{command}, -a' - - args = [sys.executable, _process.__file__, '-c', command] + command = f"{command}, -a" + + args = [sys.executable, _process.__file__, "-c", command] subprocess.run(args) - + bioformats._utils.check_raise_exception(uuid4) - + self.progressPbar.emit(len(self.chNames)) - else: + else: self._saveDataPythonBioformats( - bioformats, rawFilePath, series, images_path, - filenameNOext, s0p, idxs + bioformats, + rawFilePath, + series, + images_path, + filenameNOext, + s0p, + idxs, ) elif self.rawDataStruct == 2: @@ -930,74 +986,81 @@ def saveData( if not saveCh: continue - rawFilename = f'{basename}{pos_n}_{chName}' + rawFilename = f"{basename}{pos_n}_{chName}" pos_rawFilenames.append(rawFilename) raw_src_path = os.path.dirname(rawFilePath) rawFilePath = [ os.path.join(raw_src_path, f) for f in myutils.listdir(raw_src_path) - if f.find(rawFilename)!=-1 + if f.find(rawFilename) != -1 ][0] - if self.bioformats_backend == 'bioio': + if self.bioformats_backend == "bioio": import subprocess from . import _process - + save_data_py_filepath = os.path.join( - os.path.dirname(bioformats.__file__), - '_save_data_single_channel.py' + os.path.dirname(bioformats.__file__), + "_save_data_single_channel.py", ) zyx_physical_sizes = ( - self.PhysicalSizeZ, - self.PhysicalSizeY, - self.PhysicalSizeX + self.PhysicalSizeZ, + self.PhysicalSizeY, + self.PhysicalSizeX, ) zyx_physical_sizes = " ".join( [str(val) for val in zyx_physical_sizes] ) uuid4 = uuid.uuid4() command = ( - f'{sys.executable}, {save_data_py_filepath}, ' - f'-f, {rawFilePath}, ' - f'-d, {" ".join([str(val) for val in self.saveChannels])}, ' - f'-c, {chName}, ' - f'-ch_idx, {c}, ' - f'-s, {series}, ' - f'-i, {images_path}, ' - f'-p, {filenameNOext}, ' - f'-pos, {s0p}, ' - f'-t, {self.SizeT}, ' - f'-z, {self.getSizeZ(rawFilePath)}, ' - f'-time_increment, {self.TimeIncrement}, ' - f'-zyx, {zyx_physical_sizes}, ' - f'-r, {" ".join([str(val) for val in self.timeRangeToSave])}, ' - f'-uuid, {uuid4}' + f"{sys.executable}, {save_data_py_filepath}, " + f"-f, {rawFilePath}, " + f"-d, {' '.join([str(val) for val in self.saveChannels])}, " + f"-c, {chName}, " + f"-ch_idx, {c}, " + f"-s, {series}, " + f"-i, {images_path}, " + f"-p, {filenameNOext}, " + f"-pos, {s0p}, " + f"-t, {self.SizeT}, " + f"-z, {self.getSizeZ(rawFilePath)}, " + f"-time_increment, {self.TimeIncrement}, " + f"-zyx, {zyx_physical_sizes}, " + f"-r, {' '.join([str(val) for val in self.timeRangeToSave])}, " + f"-uuid, {uuid4}" ) if self.to_h5: - command = f'{command}, -to_h5' - - args = [sys.executable, _process.__file__, '-c', command] + command = f"{command}, -to_h5" + + args = [sys.executable, _process.__file__, "-c", command] subprocess.run(args) - + bioformats._utils.check_raise_exception(uuid4) - + self.progressPbar.emit(1) - else: + else: self._saveDataPythonBioformatsSingleChannel( - bioformats, rawFilePath, series, images_path, - filenameNOext, s0p, idxs, chName, c + bioformats, + rawFilePath, + series, + images_path, + filenameNOext, + s0p, + idxs, + chName, + c, ) if self.moveOtherFiles or self.copyOtherFiles: # Move the other files present in the folder if they # contain "otherFilename" in the name - otherFilename = f'{basename}{pos_n}' + otherFilename = f"{basename}{pos_n}" rawFilePath = set() for f in myutils.listdir(raw_src_path): notRawFile = all( - [f.find(rawName)==-1 for rawName in pos_rawFilenames] + [f.find(rawName) == -1 for rawName in pos_rawFilenames] ) - isPosFile = f.find(otherFilename)!=-1 + isPosFile = f.find(otherFilename) != -1 if isPosFile and notRawFile: rawFilePath.add(os.path.join(raw_src_path, f)) @@ -1005,12 +1068,12 @@ def saveData( # Determine basename, posNum and chName to build # filename as "basename_s01_chName.ext" _filename = os.path.basename(file) - m = re.findall(fr'{basename}(\d+)_(.+)', _filename) - if not m or len(m[0])!=2: + m = re.findall(rf"{basename}(\d+)_(.+)", _filename) + if not m or len(m[0]) != 2: dst = os.path.join(images_path, _filename) else: _chNameWithExt = m[0][1] - _filename = f'{filenameNOext}_s{s0p}_{_chNameWithExt}' + _filename = f"{filenameNOext}_s{s0p}_{_chNameWithExt}" dst = os.path.join(images_path, _filename) if self.moveOtherFiles: try: @@ -1027,16 +1090,17 @@ def saveData( def run(self): raw_src_path = self.raw_src_path exp_dst_path = self.exp_dst_path - - if self.bioformats_backend == 'python-bioformats': + + if self.bioformats_backend == "python-bioformats": import javabridge from cellacdc import bioformats + javabridge.start_vm(class_path=bioformats.JARS, run_headless=True) - self.progress.emit('Java VM running.') - + self.progress.emit("Java VM running.") + self.cancelled = False self.isCriticalError = False - + for p, filename in enumerate(self.rawFilenames): pos_n = p + self.start_pos_n if self.rawDataStruct == 0: @@ -1049,12 +1113,16 @@ def run(self): self.numPos = self.SizeS self.numPosDigits = len(str(self.numPos)) if p == 0: - self.initPbar.emit(self.numPos*self.SizeC) - + self.initPbar.emit(self.numPos * self.SizeC) + for in_file_p in range(self.SizeS): cancel = self.saveToPosFolder( - in_file_p, raw_src_path, exp_dst_path, filename, - in_file_p, pos_n + in_file_p, + raw_src_path, + exp_dst_path, + filename, + in_file_p, + pos_n, ) if cancel: self.cancelled = True @@ -1069,7 +1137,7 @@ def run(self): self.numPos = len(self.rawFilenames) self.numPosDigits = len(str(self.numPos)) if p == 0: - self.initPbar.emit(self.numPos*self.SizeC) + self.initPbar.emit(self.numPos * self.SizeC) cancel = self.saveToPosFolder( p, raw_src_path, exp_dst_path, filename, 0, pos_n ) @@ -1081,9 +1149,7 @@ def run(self): break # Move files to raw_microscopy_files folder - self.move_to_raw_microscopy_files_folder( - self.raw_src_path, filename - ) + self.move_to_raw_microscopy_files_folder(self.raw_src_path, filename) if self.rawDataStruct == 2: filename = self.rawFilenames[0] @@ -1091,48 +1157,45 @@ def run(self): abort = self.readMetadata(raw_src_path, filename) if abort: self.cancelled = True - if self.bioformats_backend == 'python-bioformats': + if self.bioformats_backend == "python-bioformats": javabridge.kill_vm() self.finished.emit() return - + self.numPos = len(self.posNums) self.numPosDigits = len(str(self.numPos)) - self.initPbar.emit(self.numPos*self.SizeC) + self.initPbar.emit(self.numPos * self.SizeC) for p_idx, pos in enumerate(self.posNums): - p = pos-1 + p = pos - 1 abort = self.saveToPosFolder( - p, raw_src_path, exp_dst_path, self.basename, 0, - pos, p_idx=p_idx + p, raw_src_path, exp_dst_path, self.basename, 0, pos, p_idx=p_idx ) if abort: self.cancelled = True break for filename in self.rawFilenames: - self.move_to_raw_microscopy_files_folder( - self.raw_src_path, filename - ) + self.move_to_raw_microscopy_files_folder(self.raw_src_path, filename) - if self.bioformats_backend == 'python-bioformats': + if self.bioformats_backend == "python-bioformats": javabridge.kill_vm() self.finished.emit() - + def move_to_raw_microscopy_files_folder(self, raw_src_path, filename): # Move files to raw_microscopy_files folder foldername = os.path.basename(raw_src_path) - + if self.cancelled: return - - if foldername == 'raw_microscopy_files': + + if foldername == "raw_microscopy_files": return - + if not self.move_raw_microscopy_files: return - + rawFilePath = os.path.join(self.raw_src_path, filename) - raw_path = os.path.join(raw_src_path, 'raw_microscopy_files') + raw_path = os.path.join(raw_src_path, "raw_microscopy_files") if not os.path.exists(raw_path): os.mkdir(raw_path) dst = os.path.join(raw_path, filename) @@ -1141,17 +1204,23 @@ def move_to_raw_microscopy_files_folder(self, raw_src_path, filename): except PermissionError as e: self.progress.emit(e) + class createDataStructWin(QMainWindow): def __init__( - self, parent=None, allowExit=False, buttonToRestore=None, - mainWin=None, start_JVM=True, version=None - ): + self, + parent=None, + allowExit=False, + buttonToRestore=None, + mainWin=None, + start_JVM=True, + version=None, + ): super().__init__(parent) self._version = version logger, logs_path, log_path, log_filename = myutils.setupLogger( - module='dataStruct' + module="dataStruct" ) self.logger = logger self.log_path = log_path @@ -1159,9 +1228,9 @@ def __init__( self.logs_path = logs_path if self._version is not None: - logger.info(f'Initializing Data structure module v{self._version}...') + logger.info(f"Initializing Data structure module v{self._version}...") else: - logger.info(f'Initializing Data structure module...') + logger.info(f"Initializing Data structure module...") self.start_JVM = start_JVM self.allowExit = allowExit @@ -1169,9 +1238,7 @@ def __init__( self.buttonToRestore = buttonToRestore self.mainWin = mainWin self.metadataDialogIsOpen = False - self.df_settings = pd.read_csv( - settings_csv_path, index_col='setting' - ) + self.df_settings = pd.read_csv(settings_csv_path, index_col="setting") version = myutils.read_version() self.setWindowTitle(f"Cell-ACDC v{version} - Data structure") @@ -1182,9 +1249,7 @@ def __init__( mainLayout = QVBoxLayout() - label = QLabel( - 'Creating data structure from raw microscopy file(s)...' - ) + label = QLabel("Creating data structure from raw microscopy file(s)...") label.setStyleSheet("padding:5px 10px 10px 10px;") label.setAlignment(Qt.AlignCenter) @@ -1194,8 +1259,7 @@ def __init__( label.setFont(font) mainLayout.addWidget(label) - informativeHtml = ( - """ + informativeHtml = """ @@ -1224,7 +1288,6 @@ def __init__( """ - ) informativeText = QLabel(self) @@ -1237,104 +1300,104 @@ def __init__( self.logWin.setReadOnly(True) mainLayout.addWidget(self.logWin) - abortButton = widgets.cancelPushButton(' Stop processs ') + abortButton = widgets.cancelPushButton(" Stop processs ") abortButton.clicked.connect(self.close) - + buttonsLayout = QHBoxLayout() buttonsLayout.addStretch(1) buttonsLayout.addWidget(abortButton) - + mainLayout.addLayout(buttonsLayout) mainLayout.setContentsMargins(20, 0, 20, 20) mainContainer.setLayout(mainLayout) self.mainLayout = mainLayout - + try: import javabridge from cellacdc import bioformats - self.bioformats_backend = 'python-bioformats' + + self.bioformats_backend = "python-bioformats" except Exception as e: pass - - self.bioformats_backend = 'bioio' + + self.bioformats_backend = "bioio" success = self.checkInstallBioIO(parent) if success: return - - self.bioformats_backend = 'python-bioformats' + + self.bioformats_backend = "python-bioformats" self.checkInstallPythonBioformats(parent) def checkInstallBioIO(self, parent): myutils.check_install_package( - 'BioIO', - import_pkg_name='bioio', - pypi_name='bioio', - min_version='0.1.0', + "BioIO", + import_pkg_name="bioio", + pypi_name="bioio", + min_version="0.1.0", parent=parent, ) - + return True - + def checkInstallPythonBioformats(self, parent): from . import is_win, is_mac - + if not is_win and not is_mac: if parent is None: self.show() self.criticalOSnotSupported() self.close() - raise OSError('This module is supported ONLY on Windows 10/10 and macOS') + raise OSError("This module is supported ONLY on Windows 10/10 and macOS") success, jar_dst_path = myutils.download_bioformats_jar( - qparent=self, logger_info=self.logger.info, - logger_exception=self.logger.exception + qparent=self, + logger_info=self.logger.info, + logger_exception=self.logger.exception, ) - self.logger.info('Checking if Java is installed...') + self.logger.info("Checking if Java is installed...") myutils.check_upgrade_javabridge() try: import javabridge except ModuleNotFoundError as e: - print('======================================') + print("======================================") traceback_str = traceback.format_exc() self.logger.exception(traceback_str) - print('======================================') + print("======================================") cancel = myutils.install_javabridge_help(parent=self) if cancel: - raise ModuleNotFoundError( - 'User aborted javabridge installation' - ) + raise ModuleNotFoundError("User aborted javabridge installation") isGitInstalled = myutils.check_git_installed(parent=self) if not isGitInstalled: raise ModuleNotFoundError( - 'Git is not installed. Install from ' - 'https://git-scm.com/book/en/v2/Getting-Started-Installing-Git' + "Git is not installed. Install from " + "https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" ) try: jre_path, jdk_path, url = myutils.download_java() except Exception as e: - print('======================================') + print("======================================") traceback_str = traceback.format_exc() self.logger.exception(traceback_str) - print('======================================') + print("======================================") java_info = myutils.get_java_url() url, file_size, os_foldername, unzipped_foldername = java_info acdc_java_path, _ = myutils.get_acdc_java_path() java_href = f'this' s = ( - f'1. Download {java_href} .zip file and unzip it.
' - '2. Inside the unzipped folder there should be a folder called ' + f"1. Download {java_href} .zip file and unzip it.
" + "2. Inside the unzipped folder there should be a folder called " f'"{unzipped_foldername}". Open that folder and copy its ' - 'content to the following path:

' - f'{os.path.join(acdc_java_path, os_foldername)}' + "content to the following path:

" + f"{os.path.join(acdc_java_path, os_foldername)}" ) note = ( - '

NOTE: if clicking on the link above does not work ' - 'copy the link below and paste it into the browser

' - f'{url}' + "

NOTE: if clicking on the link above does not work " + "copy the link below and paste it into the browser

" + f"{url}" ) msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(f""" @@ -1343,79 +1406,69 @@ def checkInstallPythonBioformats(self, parent): launching this module again.

{s}{note} """) - msg.warning(self, 'Java not found', txt) - - err = s.replace('
', ' ') - err = err.replace('this', '') + msg.warning(self, "Java not found", txt) + + err = s.replace("
", " ") + err = err.replace("this", "") raise ModuleNotFoundError( - 'Installation of module "javabridge" failed. ' - f'{err}' + f'Installation of module "javabridge" failed. {err}' ) if not is_win: cancel = myutils.install_java() if cancel: - raise ModuleNotFoundError( - 'User aborted Java installation' - ) + raise ModuleNotFoundError("User aborted Java installation") return myutils.install_javabridge() except Exception as e: - print('======================================') + print("======================================") traceback_str = traceback.format_exc() self.logger.exception(traceback_str) - print('======================================') + print("======================================") cancel = myutils.install_java() if cancel: - raise ModuleNotFoundError( - 'User aborted Java installation' - ) + raise ModuleNotFoundError("User aborted Java installation") return - myutils.install_javabridge( - force_compile=True, attempt_uninstall_first=True - ) + myutils.install_javabridge(force_compile=True, attempt_uninstall_first=True) try: import javabridge from cellacdc import bioformats except Exception as e: - print('===============================================================') + print("===============================================================") traceback_str = traceback.format_exc() self.logger.exception(traceback_str) error_msg = ( 'Error while importing "javabridge" and "bioformats".\n\n' - f'Please report error here: {issues_url}\n' + f"Please report error here: {issues_url}\n" ) print(error_msg) - print('===============================================================') + print("===============================================================") - title = 'Import javabridge/bioformats error' - txt = error_msg.replace('\n', '
') - txt = txt.replace( - issues_url, html_utils.href_tag(issues_url, issues_url) - ) + title = "Import javabridge/bioformats error" + txt = error_msg.replace("\n", "
") + txt = txt.replace(issues_url, html_utils.href_tag(issues_url, issues_url)) txt = html_utils.paragraph(txt) msg = widgets.myMessageBox(wrapText=False) - msg.critical( - self, title, txt, detailsText=traceback_str - ) + msg.critical(self, title, txt, detailsText=traceback_str) raise ModuleNotFoundError( - 'Error when importing javabridge. See above for details.' + "Error when importing javabridge. See above for details." ) def criticalOSnotSupported(self): from cellacdc import widgets + if self.parent() is None: msg = widgets.myMessageBox(self) else: msg = widgets.myMessageBox(self.parent()) - msg.setIcon(iconName='SP_MessageBoxCritical') - msg.setWindowTitle('Not a supported OS') - msg.addButton(' Ok ') - err_msg = (f""" + msg.setIcon(iconName="SP_MessageBoxCritical") + msg.setWindowTitle("Not a supported OS") + msg.addButton(" Ok ") + err_msg = f"""

Unfortunately, the module "0. Create data structure from microscopy file(s)" is functional only on Windows 10/11 and macOS.

@@ -1439,58 +1492,56 @@ def criticalOSnotSupported(self): here .

- """) + """ msg.addText(err_msg) # msg_label = msg.findChild(QLabel, "qt_msgbox_label") # msg_label.setOpenExternalLinks(False) # msg_label.linkActivated.connect(self.on_linkActivated) msg.exec_() - def on_linkActivated(self, link): - if link == 'manual': + if link == "manual": systems = { - 'nt': os.startfile, - 'posix': lambda foldername: os.system('xdg-open "%s"' % foldername), - 'os2': lambda foldername: os.system('open "%s"' % foldername) - } + "nt": os.startfile, + "posix": lambda foldername: os.system('xdg-open "%s"' % foldername), + "os2": lambda foldername: os.system('open "%s"' % foldername), + } main_path = pathlib.Path(__file__).resolve().parents[1] - userManual_path = main_path / 'UserManual' + userManual_path = main_path / "UserManual" systems.get(os.name, os.startfile)(userManual_path) - elif link == 'fiji': + elif link == "fiji": systems = { - 'nt': os.startfile, - 'posix': lambda foldername: os.system('xdg-open "%s"' % foldername), - 'os2': lambda foldername: os.system('open "%s"' % foldername) - } + "nt": os.startfile, + "posix": lambda foldername: os.system('xdg-open "%s"' % foldername), + "os2": lambda foldername: os.system('open "%s"' % foldername), + } main_path = pathlib.Path(__file__).resolve().parents[1] - fijiMacros_path = main_path / 'FijiMacros' + fijiMacros_path = main_path / "FijiMacros" systems.get(os.name, os.startfile)(fijiMacros_path) - def getMostRecentPath(self): if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - if 'opened_last_on' in df.columns: - df = df.sort_values('opened_last_on', ascending=False) - self.MostRecentPath = df.iloc[0]['path'] + df = pd.read_csv(recentPaths_path, index_col="index") + if "opened_last_on" in df.columns: + df = df.sort_values("opened_last_on", ascending=False) + self.MostRecentPath = df.iloc[0]["path"] if not isinstance(self.MostRecentPath, str): - self.MostRecentPath = '' + self.MostRecentPath = "" else: - self.MostRecentPath = '' + self.MostRecentPath = "" def addToRecentPaths(self, raw_src_path): if not os.path.exists(raw_src_path): return if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - recentPaths = df['path'].to_list() - if 'opened_last_on' in df.columns: - openedOn = df['opened_last_on'].to_list() + df = pd.read_csv(recentPaths_path, index_col="index") + recentPaths = df["path"].to_list() + if "opened_last_on" in df.columns: + openedOn = df["opened_last_on"].to_list() else: - openedOn = [np.nan]*len(recentPaths) + openedOn = [np.nan] * len(recentPaths) if raw_src_path in recentPaths: pop_idx = recentPaths.index(raw_src_path) recentPaths.pop(pop_idx) @@ -1504,15 +1555,18 @@ def addToRecentPaths(self, raw_src_path): else: recentPaths = [raw_src_path] openedOn = [datetime.datetime.now()] - df = pd.DataFrame({'path': recentPaths, - 'opened_last_on': pd.Series(openedOn, - dtype='datetime64[ns]')}) - df.index.name = 'index' + df = pd.DataFrame( + { + "path": recentPaths, + "opened_last_on": pd.Series(openedOn, dtype="datetime64[ns]"), + } + ) + df.index.name = "index" df.to_csv(recentPaths_path) @exception_handler def main(self): - self.log('Asking how raw data is structured...') + self.log("Asking how raw data is structured...") rawDataStruct, abort = self.askRawDataStruct() if abort: self.close() @@ -1525,39 +1579,32 @@ def main(self): self.close() return - self.log('Instructing to move raw data...') + self.log("Instructing to move raw data...") proceed = self.instructMoveRawFiles() if not proceed: self.close() return - self.log( - 'Asking to select the folder that contains the microscopy files...' - ) + self.log("Asking to select the folder that contains the microscopy files...") self.getMostRecentPath() raw_src_path = QFileDialog.getExistingDirectory( - self, 'Select folder containing the microscopy files', - self.MostRecentPath + self, "Select folder containing the microscopy files", self.MostRecentPath ) self.addToRecentPaths(raw_src_path) - if raw_src_path == '': + if raw_src_path == "": self.close() return - + self.log(f'Selected folder: "{raw_src_path}"') - - self.log( - 'Checking file format of loaded files...' - ) + + self.log("Checking file format of loaded files...") rawFilenames = self.checkFileFormat(raw_src_path) if not rawFilenames: self.close() return - - self.log( - 'Checking file names of loaded files...' - ) + + self.log("Checking file names of loaded files...") proceed, rawFilenames = self.checkFileNames(rawFilenames, raw_src_path) if not proceed: self.close() @@ -1569,41 +1616,38 @@ def main(self): self.close() return - self.log( - 'Asking in which folder to save the images files...' - ) + self.log("Asking in which folder to save the images files...") exp_dst_path = QFileDialog.getExistingDirectory( - self, 'Select the folder in which to save the images files', - raw_src_path + self, "Select the folder in which to save the images files", raw_src_path ) if not exp_dst_path: self.close() return - + out = self.askPosFoldersExisting(exp_dst_path) if out is None: self.close() return overwrite, add_files, create_new, start_pos_n = out - - self.log('Instructing to move raw data...') + + self.log("Instructing to move raw data...") loadEntirePosIntoRam = self.askHowToLoadData() if loadEntirePosIntoRam is None: self.close() return - + if not loadEntirePosIntoRam: self._installLazyLoadModules() - + self.loadEntirePosIntoRam = loadEntirePosIntoRam self.addToRecentPaths(exp_dst_path) self.addPbar() - + self.initBioIO(raw_src_path, rawFilenames) - + move_raw_microscopy_files = False if exp_dst_path == raw_src_path: move_raw_microscopy_files, cancel = self.askMoveRawMicroscopyFiles() @@ -1616,9 +1660,13 @@ def main(self): self.waitCond = QWaitCondition() self.thread = QThread() self.worker = bioFormatsWorker( - raw_src_path, rawFilenames, exp_dst_path, - self.mutex, self.waitCond, rawDataStruct, - self.bioformats_backend, + raw_src_path, + rawFilenames, + exp_dst_path, + self.mutex, + self.waitCond, + rawDataStruct, + self.bioformats_backend, lazy_load=not self.loadEntirePosIntoRam, move_raw_microscopy_files=move_raw_microscopy_files, overwrite=overwrite, @@ -1649,7 +1697,7 @@ def main(self): self.thread.started.connect(self.worker.run) self.thread.start() - + def askMoveRawMicroscopyFiles(self): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(f""" @@ -1658,29 +1706,28 @@ def askMoveRawMicroscopyFiles(self): to a sub-folder called raw_microscopy_files? """) _, doNotMoveButton, moveButton = msg.warning( - self, 'Too many objects', txt, - buttonsTexts=( - 'Cancel', 'No, do not move the files', 'Yes, move the files' - ) + self, + "Too many objects", + txt, + buttonsTexts=("Cancel", "No, do not move the files", "Yes, move the files"), ) return msg.clickedButton == moveButton, msg.cancel - + def _installLazyLoadModules(self): myutils.check_install_package( - 'zarr', - installer='pip', + "zarr", + installer="pip", is_cli=False, parent=self, ) - + @exception_handler def workerCritical(self, error): raise error def instructManualStruct(self): - manual_url = 'https://github.com/SchmollerLab/Cell_ACDC/blob/main/UserManual/Cell-ACDC_User_Manual.pdf' - txt = ( - f""" + manual_url = "https://github.com/SchmollerLab/Cell_ACDC/blob/main/UserManual/Cell-ACDC_User_Manual.pdf" + txt = f"""

If you would like to add compatibility with your raw microscopy files,
you can request a new feature here.

@@ -1692,9 +1739,8 @@ def instructManualStruct(self): "Manually create data structure from microscopy file(s)"

""" - ) msg = QMessageBox(self) - msg.setWindowTitle('Data structure not available') + msg.setWindowTitle("Data structure not available") msg.setIcon(msg.Information) msg.setText(txt) msg.setTextInteractionFlags(Qt.TextBrowserInteraction) @@ -1702,46 +1748,47 @@ def instructManualStruct(self): msg.exec_() def initBioIO(self, raw_src_path, raw_filenames): - if self.bioformats_backend == 'python-bioformats': + if self.bioformats_backend == "python-bioformats": return - + from cellacdc import acdc_bioio_bioformats as bioformats + raw_filepath = os.path.join(raw_src_path, raw_filenames[0]) - + bioformats.install.install_reader_dependencies( - raw_filepath, + raw_filepath, exception=Exception( - 'Failed installing reader dependencies from the GUI, ' - 'trying from terminal...' + "Failed installing reader dependencies from the GUI, " + "trying from terminal..." ), - qparent=self + qparent=self, ) - + import subprocess from . import _process - + init_reader_py_filepath = os.path.join( - os.path.dirname(bioformats.__file__), '_init_reader.py' + os.path.dirname(bioformats.__file__), "_init_reader.py" ) uuid4 = uuid.uuid4() command = ( - f'{sys.executable}, {init_reader_py_filepath}, ' - f'-f, {raw_filepath}, ' - f'-uuid, {uuid4}' + f"{sys.executable}, {init_reader_py_filepath}, " + f"-f, {raw_filepath}, " + f"-uuid, {uuid4}" ) - - args = [sys.executable, _process.__file__, '-c', command] + + args = [sys.executable, _process.__file__, "-c", command] subprocess.run(args) - + bioformats._utils.check_raise_exception(uuid4) - + def addPbar(self): self.QPbar = widgets.ProgressBar(self) self.QPbar.setValue(0) self.mainLayout.insertWidget(3, self.QPbar) def updatePbar(self, deltaPbar): - self.QPbar.setValue(self.QPbar.value()+deltaPbar) + self.QPbar.setValue(self.QPbar.value() + deltaPbar) def setPbarMax(self, max): self.QPbar.setMaximum(max) @@ -1749,23 +1796,18 @@ def setPbarMax(self, max): def taskEnded(self): if self.worker.cancelled and not self.worker.isCriticalError: msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - 'Conversion task cancelled.' - ) - msg.critical( - self, 'Conversion task cancelled.', txt - ) + txt = html_utils.paragraph("Conversion task cancelled.") + msg.critical(self, "Conversion task cancelled.", txt) self.close() elif not self.worker.cancelled: msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - 'Conversion task ended.

' - 'Files saved to' - ) + txt = html_utils.paragraph("Conversion task ended.

Files saved to") abort = msg.information( - self, 'Conversion task ended.', txt, - commands=(self.worker.exp_dst_path,), - path_to_browse=self.worker.exp_dst_path + self, + "Conversion task ended.", + txt, + commands=(self.worker.exp_dst_path,), + path_to_browse=self.worker.exp_dst_path, ) self.close() @@ -1774,18 +1816,20 @@ def log(self, text): self.logger.info(text) def askRawDataStruct(self): - infoText = html_utils.paragraph( - 'Select how you have your raw microscopy files arranged' + infoText = html_utils.paragraph( + "Select how you have your raw microscopy files arranged" ) win = apps.QDialogCombobox( - 'Raw data structure', + "Raw data structure", [ - 'Single microscopy file with multiple positions', - 'One or more microscopy files, one for each position', - 'One or more microscopy files, one for each channel', - 'NONE of the above' + "Single microscopy file with multiple positions", + "One or more microscopy files, one for each position", + "One or more microscopy files, one for each channel", + "NONE of the above", ], - infoText, CbLabel='', parent=self + infoText, + CbLabel="", + parent=self, ) win.exec_() if not win.cancel: @@ -1795,9 +1839,9 @@ def askRawDataStruct(self): def instructMoveRawFiles(self): msg = widgets.myMessageBox(showCentered=False, wrapText=False) tip_admon = html_utils.to_admonition( - 'If you have a single gray-scale TIFF file, ' - 'placing into a folder called Images will be enough.', - admonition_type='tip', + "If you have a single gray-scale TIFF file, " + "placing into a folder called Images will be enough.", + admonition_type="tip", ) txt = html_utils.paragraph(f""" Put all of the raw microscopy files from the same experiment @@ -1809,11 +1853,12 @@ def instructMoveRawFiles(self): by the microscope, for example '.czi' (Zeiss), '.nd2' (Nikon), '.lif' (Leica), etc.

{tip_admon} - """ - ) + """) msg.information( - self, 'Microscopy files location', txt, - buttonsTexts=('Cancel', widgets.okPushButton('Done')) + self, + "Microscopy files location", + txt, + buttonsTexts=("Cancel", widgets.okPushButton("Done")), ) if msg.cancel: return False @@ -1834,18 +1879,20 @@ def askHowToLoadData(self): """ ) _, loadFrameButton, loadPosButton = msg.warning( - self, 'Loading data', txt, + self, + "Loading data", + txt, buttonsTexts=( - 'Cancel', - widgets.twoDPushButton('No, load one frame (2D) at a time'), - widgets.FutureAllPushButton('Yes, load entire position at once') - ) + "Cancel", + widgets.twoDPushButton("No, load one frame (2D) at a time"), + widgets.FutureAllPushButton("Yes, load entire position at once"), + ), ) if msg.cancel: return None return msg.clickedButton == loadPosButton - + def warnSelectedPathEmpty(self, raw_src_path): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( @@ -1857,24 +1904,28 @@ def warnSelectedPathEmpty(self, raw_src_path): """ ) msg.warning( - self, 'Empty folder', txt, - commands=(raw_src_path, ), - path_to_browse=raw_src_path + self, + "Empty folder", + txt, + commands=(raw_src_path,), + path_to_browse=raw_src_path, ) - + def checkFileFormat(self, raw_src_path): self.moveOtherFiles = False self.copyOtherFiles = False ls = natsorted(myutils.listdir(raw_src_path)) files = [ - filename for filename in ls + filename + for filename in ls if os.path.isfile(os.path.join(raw_src_path, filename)) ] if not files: self.warnSelectedPathEmpty(raw_src_path) return [] all_ext = [ - os.path.splitext(filename)[1] for filename in ls + os.path.splitext(filename)[1] + for filename in ls if os.path.isfile(os.path.join(raw_src_path, filename)) ] counter = Counter(all_ext) @@ -1883,10 +1934,10 @@ def checkFileFormat(self, raw_src_path): most_common_ext, _ = counter.most_common(1)[0] if not is_ext_unique: if not most_common_ext: - most_common_ext_msg = '' + most_common_ext_msg = "" else: most_common_ext_msg = most_common_ext - + msg = widgets.myMessageBox(showCentered=False) txt = html_utils.paragraph(f""" The following folder @@ -1902,21 +1953,24 @@ def checkFileFormat(self, raw_src_path):
""") _, yesButton, noButton = msg.warning( - self, 'Multiple extensions detected', txt, + self, + "Multiple extensions detected", + txt, buttonsTexts=( - 'Cancel', 'Yes, load only most common', - 'No, load all files' - ) + "Cancel", + "Yes, load only most common", + "No, load all files", + ), ) if msg.cancel: return [] if msg.clickedButton == yesButton: files = [ - filename for filename in files + filename + for filename in files if os.path.splitext(filename)[1] == most_common_ext ] - otherExt = [ - ext for ext in unique_ext if ext != most_common_ext] + otherExt = [ext for ext in unique_ext if ext != most_common_ext] files = self.askActionWithOtherFiles(files, otherExt) else: return files @@ -1948,40 +2002,38 @@ def checkFileNames(self, raw_filenames, raw_src_path): 'Rename file (replace invalid characters with "-")' ) msg.warning( - self, 'Invalid filename', txt, + self, + "Invalid filename", + txt, path_to_browse=raw_src_path, buttonsTexts=( - 'Let me rename files myself', - renameWithUnderscoresButton, - renameWithDashesButton - ) + "Let me rename files myself", + renameWithUnderscoresButton, + renameWithDashesButton, + ), ) if msg.clickedButton == renameWithUnderscoresButton: - self.log( - 'Renaming files to replace invalid characters with "_"...' - ) + self.log('Renaming files to replace invalid characters with "_"...') renamed_filenames = io.rename_files_replace_invalid_chars( - raw_filenames, raw_src_path, replacement_char='_' + raw_filenames, raw_src_path, replacement_char="_" ) return True, renamed_filenames elif msg.clickedButton == renameWithDashesButton: - self.log( - 'Renaming files to replace invalid characters with "-"...' - ) + self.log('Renaming files to replace invalid characters with "-"...') renamed_filenames = io.rename_files_replace_invalid_chars( - raw_filenames, raw_src_path, replacement_char='-' + raw_filenames, raw_src_path, replacement_char="-" ) return True, renamed_filenames else: return False, [] return True, raw_filenames - + def askActionWithOtherFiles(self, files, otherExt): self.moveOtherFiles = False msg = QMessageBox(self) - msg.setWindowTitle('Action with the other files?') - txt = (f""" + msg.setWindowTitle("Action with the other files?") + txt = f"""
) - """, admonition_type='important') - + """, + admonition_type="important", + ) + howToInstallLayout.addWidget(QLabel(importantText)) - + # layout.addWidget(self.howToInstallWidget, row, 0, 1, 3) howToInstallLayout.addLayout(buttonsLayout) self.howToInstallDialog.hide() - + self.setLayout(layout) - + def copyCellACDCpath(self): cb = QApplication.clipboard() cb.clear(mode=cb.Clipboard) cb.setText(cellacdc_path, mode=cb.Clipboard) - + def showHotToInstallInstructions(self): self.howToInstallDialog.show() + def _test(): import sys from qtpy.QtWidgets import QStyleFactory, QApplication + app = QApplication(sys.argv) - app.setStyle(QStyleFactory.create('Fusion')) + app.setStyle(QStyleFactory.create("Fusion")) win = QDialogAbout() win.show() app.exec_() diff --git a/cellacdc/help/welcome.py b/cellacdc/help/welcome.py index 6cafa9cf2..6e18d5831 100755 --- a/cellacdc/help/welcome.py +++ b/cellacdc/help/welcome.py @@ -5,18 +5,30 @@ import pandas as pd import numpy as np -from qtpy.QtGui import ( - QIcon, QFont, QFontMetrics, QPixmap, QPalette, QColor -) -from qtpy.QtCore import ( - Qt, QSize, QEvent, Signal, QObject, QThread, QTimer -) +from qtpy.QtGui import QIcon, QFont, QFontMetrics, QPixmap, QPalette, QColor +from qtpy.QtCore import Qt, QSize, QEvent, Signal, QObject, QThread, QTimer from qtpy.QtWidgets import ( - QApplication, QWidget, QGridLayout, QTextEdit, QPushButton, - QListWidget, QListWidgetItem, QCheckBox, QFrame, QStyleFactory, - QLabel, QTreeWidget, QTreeWidgetItem, QTreeWidgetItemIterator, - QScrollArea, QComboBox, QHBoxLayout, QToolButton, QMainWindow, - QProgressBar, QAction + QApplication, + QWidget, + QGridLayout, + QTextEdit, + QPushButton, + QListWidget, + QListWidgetItem, + QCheckBox, + QFrame, + QStyleFactory, + QLabel, + QTreeWidget, + QTreeWidgetItem, + QTreeWidgetItemIterator, + QScrollArea, + QComboBox, + QHBoxLayout, + QToolButton, + QMainWindow, + QProgressBar, + QAction, ) script_path = os.path.dirname(os.path.realpath(__file__)) @@ -27,15 +39,17 @@ # NOTE: Enable icons from .. import cellacdc_path, settings_folderpath -if os.name == 'nt': +if os.name == "nt": try: # Set taskbar icon in windows import ctypes - myappid = 'schmollerlab.cellacdc.pyqt.v1' # arbitrary string + + myappid = "schmollerlab.cellacdc.pyqt.v1" # arbitrary string ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) except Exception as e: pass + class downloadWorker(QObject): finished = Signal() progress = Signal(int, int) @@ -45,11 +59,10 @@ def __init__(self, which): self.which = which def run(self): - self.exp_path = myutils.download_examples( - self.which, progress=self.progress - ) + self.exp_path = myutils.download_examples(self.which, progress=self.progress) self.finished.emit() + class QHLine(QFrame): def __init__(self): super(QHLine, self).__init__() @@ -63,7 +76,7 @@ def __init__(self, parent=None, mainWin=None, app=None): self.mainWin = mainWin self.app = app super().__init__(parent) - self.setWindowTitle('Welcome') + self.setWindowTitle("Welcome") self.setWindowIcon(QIcon(":icon.ico")) self.loadSettings() @@ -103,23 +116,23 @@ def __init__(self, parent=None, mainWin=None, app=None): # self.setDebuggingTools() def setDebuggingTools(self): - self.debugButton = QPushButton('debug') + self.debugButton = QPushButton("debug") self.debugButton.clicked.connect(self.debug) self.mainLayout.addWidget(self.debugButton, 2, 0) # self.debugAction.hide() def loadSettings(self): - csv_path = os.path.join(settings_folderpath, 'settings.csv') + csv_path = os.path.join(settings_folderpath, "settings.csv") if os.path.exists(csv_path): - self.df_settings = pd.read_csv(csv_path, index_col='setting') - if 'showWelcomeGuide' not in self.df_settings.index: - self.df_settings.at['showWelcomeGuide', 'value'] = 'Yes' + self.df_settings = pd.read_csv(csv_path, index_col="setting") + if "showWelcomeGuide" not in self.df_settings.index: + self.df_settings.at["showWelcomeGuide", "value"] = "Yes" else: - idx = ['showWelcomeGuide'] - values = ['Yes'] - self.df_settings = pd.DataFrame({'setting': idx, - 'value': values} - ).set_index('setting') + idx = ["showWelcomeGuide"] + values = ["Yes"] + self.df_settings = pd.DataFrame( + {"setting": idx, "value": values} + ).set_index("setting") self.df_settings.to_csv(csv_path) self.df_settings_path = csv_path @@ -129,13 +142,13 @@ def addtreeSelector(self): treeSelector.setFrameStyle(QFrame.Shape.NoFrame) self.welcomeItem = QTreeWidgetItem(treeSelector) - self.welcomeItem.setIcon(0, QIcon(':home.svg')) - self.welcomeItem.setText(0, 'Welcome') + self.welcomeItem.setIcon(0, QIcon(":home.svg")) + self.welcomeItem.setText(0, "Welcome") treeSelector.addTopLevelItem(self.welcomeItem) self.quickStartItem = QTreeWidgetItem(treeSelector) - self.quickStartItem.setIcon(0, QIcon(':quickStart.svg')) - self.quickStartItem.setText(0, 'Quick Start') + self.quickStartItem.setIcon(0, QIcon(":quickStart.svg")) + self.quickStartItem.setText(0, "Quick Start") treeSelector.addTopLevelItem(self.quickStartItem) # self.settingsItem = QTreeWidgetItem(treeSelector) @@ -144,18 +157,17 @@ def addtreeSelector(self): # treeSelector.addTopLevelItem(self.settingsItem) self.manualItem = QTreeWidgetItem(treeSelector) - self.manualItem.setIcon(0, QIcon(':book.svg')) + self.manualItem.setIcon(0, QIcon(":book.svg")) # textLabel = QLabel() # textLabel.setText(""" #

# User Manual #

# """) - self.manualItem.setText(0, 'User Manual') + self.manualItem.setText(0, "User Manual") treeSelector.addTopLevelItem(self.manualItem) # treeSelector.setItemWidget(self.manualItem, 0, textLabel) - # self.manualDataPrepItem = QTreeWidgetItem(self.manualItem) # self.manualDataPrepItem.setText(0, ' Data Prep module') # self.manualItem.addChild(self.manualDataPrepItem) @@ -167,8 +179,8 @@ def addtreeSelector(self): # self.manualItem.addChild(self.manualGUIItem) self.contributeItem = QTreeWidgetItem(treeSelector) - self.contributeItem.setIcon(0, QIcon(':contribute.svg')) - self.contributeItem.setText(0, 'Contribute') + self.contributeItem.setIcon(0, QIcon(":contribute.svg")) + self.contributeItem.setText(0, "Contribute") treeSelector.addTopLevelItem(self.contributeItem) # treeSelector.setSpacing(3) @@ -189,7 +201,6 @@ def treeItemChanged(self, currentItem, prevItem=None): else: frame.hide() - def addWelcomePage(self): self.welcomeFrame = QFrame(self) welcomeLayout = QGridLayout() @@ -202,8 +213,7 @@ def addWelcomePage(self): # welcomeTextWidget.setFrameStyle(QFrame.Shape.NoFrame) # welcomeTextWidget.viewport().setAutoFillBackground(False) - htmlTxt = ( - """ + htmlTxt = """ @@ -252,37 +262,37 @@ def addWelcomePage(self): """ - ) # welcomeTextWidget.setHtml(htmlTxt) welcomeTextWidget.setText(htmlTxt) welcomeTextWidget.linkActivated.connect(self.linkActivated_cb) - welcomeLayout.addWidget(welcomeTextWidget, 0, 0, 1, 5, - alignment=Qt.AlignTop) + welcomeLayout.addWidget(welcomeTextWidget, 0, 0, 1, 5, alignment=Qt.AlignTop) - startWizardButton = QPushButton(' Launch Wizard') - startWizardButton.setIcon(QIcon(':wizard.svg')) + startWizardButton = QPushButton(" Launch Wizard") + startWizardButton.setIcon(QIcon(":wizard.svg")) startWizardButton.clicked.connect(self.launchDataStruct) welcomeLayout.addWidget(startWizardButton, 1, 0) - testMyImageButton = QPushButton(' Test segmentation with my image/video') - testMyImageButton.setIcon(QIcon(':image.svg')) + testMyImageButton = QPushButton(" Test segmentation with my image/video") + testMyImageButton.setIcon(QIcon(":image.svg")) testMyImageButton.clicked.connect(self.openGUIsingleImage) welcomeLayout.addWidget(testMyImageButton, 1, 1) testTimeLapseButton = QPushButton( - text='Download and test with a time-lapse example') - testTimeLapseButton.setIcon(QIcon(':download.svg')) + text="Download and test with a time-lapse example" + ) + testTimeLapseButton.setIcon(QIcon(":download.svg")) testTimeLapseButton.clicked.connect(self.testTimeLapseExample) welcomeLayout.addWidget(testTimeLapseButton, 1, 2) test3DzStackButton = QPushButton( - text='Download and test with a 3D z-stack example') - test3DzStackButton.setIcon(QIcon(':download.svg')) + text="Download and test with a 3D z-stack example" + ) + test3DzStackButton.setIcon(QIcon(":download.svg")) test3DzStackButton.clicked.connect(self.test3DzStacksExample) welcomeLayout.addWidget(test3DzStackButton, 1, 3) @@ -308,13 +318,12 @@ def addQuickStartPage(self): QuickStartLayout = QGridLayout() - fs = 13 # font size + fs = 13 # font size row = 0 QuickStartTextWidget = QLabel() - htmlHead = ( - """ + htmlHead = """ @@ -330,10 +339,8 @@ def addQuickStartPage(self): """ - ) - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -349,18 +356,15 @@ def addQuickStartPage(self): """ - ) QuickStartTextWidget.setText(htmlTxt) QuickStartTextWidget.linkActivated.connect(self.linkActivated_cb) - QuickStartLayout.addWidget(QuickStartTextWidget, row, 0, - alignment=Qt.AlignTop) + QuickStartLayout.addWidget(QuickStartTextWidget, row, 0, alignment=Qt.AlignTop) row += 1 QuickStartTextWidget = QLabel() - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -390,19 +394,16 @@ def addQuickStartPage(self): """ - ) QuickStartTextWidget.setText(htmlTxt) QuickStartTextWidget.linkActivated.connect(self.linkActivated_cb) - QuickStartLayout.addWidget(QuickStartTextWidget, row, 0, - alignment=Qt.AlignTop) + QuickStartLayout.addWidget(QuickStartTextWidget, row, 0, alignment=Qt.AlignTop) row += 1 QS_tipTxtLabel = QLabel() - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -416,14 +417,12 @@ def addQuickStartPage(self):

""" - ) QS_tipTxtLabel.setText(htmlTxt) - QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, - alignment=Qt.AlignTop) + QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, alignment=Qt.AlignTop) row += 1 - pixmap = QPixmap(':toolbar.png') + pixmap = QPixmap(":toolbar.png") label = QLabel() # padding: top, left, bottom, right label.setStyleSheet("padding:5px 0px 10px 40px;") @@ -433,8 +432,7 @@ def addQuickStartPage(self): row += 1 QS_tipTxtLabel = QLabel() - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -447,18 +445,15 @@ def addQuickStartPage(self):

""" - ) QS_tipTxtLabel.setText(htmlTxt) - QS_tipTxtLabel.setStyleSheet('padding-bottom: 10px') - QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, - alignment=Qt.AlignTop) + QS_tipTxtLabel.setStyleSheet("padding-bottom: 10px") + QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, alignment=Qt.AlignTop) row += 1 QS_tipTxtLabel = QLabel() - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -472,14 +467,12 @@ def addQuickStartPage(self):

""" - ) QS_tipTxtLabel.setText(htmlTxt) - QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, - alignment=Qt.AlignTop) + QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, alignment=Qt.AlignTop) row += 1 - pixmap = QPixmap(':toolTip.png') + pixmap = QPixmap(":toolTip.png") label = QLabel() label.setStyleSheet("padding:5px 0px 10px 40px;") label.setPixmap(pixmap) @@ -488,8 +481,7 @@ def addQuickStartPage(self): row += 1 QS_tipTxtLabel = QLabel() - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -503,18 +495,15 @@ def addQuickStartPage(self):

""" - ) QS_tipTxtLabel.setText(htmlTxt) - QS_tipTxtLabel.setStyleSheet('padding-bottom: 10px') - QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, - alignment=Qt.AlignTop) + QS_tipTxtLabel.setStyleSheet("padding-bottom: 10px") + QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, alignment=Qt.AlignTop) row += 1 QS_tipTxtLabel = QLabel() - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -556,18 +545,15 @@ def addQuickStartPage(self):

""" - ) QS_tipTxtLabel.setText(htmlTxt) - QS_tipTxtLabel.setStyleSheet('padding-bottom: 10px') - QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, - alignment=Qt.AlignTop) + QS_tipTxtLabel.setStyleSheet("padding-bottom: 10px") + QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, alignment=Qt.AlignTop) row += 1 QS_tipTxtLabel = QLabel() - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -580,19 +566,15 @@ def addQuickStartPage(self):

""" - ) QS_tipTxtLabel.setText(htmlTxt) - QS_tipTxtLabel.setStyleSheet('padding-bottom: 10px') - QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, - alignment=Qt.AlignTop) + QS_tipTxtLabel.setStyleSheet("padding-bottom: 10px") + QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, alignment=Qt.AlignTop) row += 1 QS_tipTxtLabel = QLabel() - - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -606,21 +588,19 @@ def addQuickStartPage(self):

""" - ) QS_tipTxtLabel.setText(htmlTxt) - QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, - alignment=Qt.AlignTop) + QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, alignment=Qt.AlignTop) row += 1 modeComboBox = QComboBox() - modeComboBox.addItems(['Segmentation and Tracking', - 'Cell cycle analysis', - 'Viewer']) - modeComboBox.setCurrentText('Viewer') + modeComboBox.addItems( + ["Segmentation and Tracking", "Cell cycle analysis", "Viewer"] + ) + modeComboBox.setCurrentText("Viewer") modeComboBox.setFocusPolicy(Qt.StrongFocus) modeComboBox.installEventFilter(self) - modeComboBoxLabel = QLabel(' Mode: ') + modeComboBoxLabel = QLabel(" Mode: ") layout = QHBoxLayout() layout.addWidget(modeComboBoxLabel) layout.addWidget(modeComboBox) @@ -631,8 +611,7 @@ def addQuickStartPage(self): row += 1 QS_tipTxtLabel = QLabel() - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -646,18 +625,14 @@ def addQuickStartPage(self):

""" - ) QS_tipTxtLabel.setText(htmlTxt) - QS_tipTxtLabel.setStyleSheet('padding-bottom: 10px') - QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, - alignment=Qt.AlignTop) - + QS_tipTxtLabel.setStyleSheet("padding-bottom: 10px") + QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, alignment=Qt.AlignTop) row += 1 QS_tipTxtLabel = QLabel() - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -671,27 +646,22 @@ def addQuickStartPage(self):

""" - ) QS_tipTxtLabel.setText(htmlTxt) - QS_tipTxtLabel.setStyleSheet('padding-bottom: 8px') + QS_tipTxtLabel.setStyleSheet("padding-bottom: 8px") viewerButton = QToolButton() - viewerButton.setIcon(QIcon(':eye-plus.svg')) - viewerButton.setIconSize(QSize(24, 24)); - + viewerButton.setIcon(QIcon(":eye-plus.svg")) + viewerButton.setIconSize(QSize(24, 24)) layout = QHBoxLayout() layout.addWidget(QS_tipTxtLabel, alignment=Qt.AlignBottom) layout.addWidget(viewerButton) layout.addStretch(1) QuickStartLayout.addLayout(layout, row, 0) - - row += 1 QS_tipTxtLabel = QLabel() - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -706,17 +676,14 @@ def addQuickStartPage(self):

""" - ) QS_tipTxtLabel.setText(htmlTxt) - QS_tipTxtLabel.setStyleSheet('padding-top: 2px') - QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, - alignment=Qt.AlignTop) + QS_tipTxtLabel.setStyleSheet("padding-top: 2px") + QuickStartLayout.addWidget(QS_tipTxtLabel, row, 0, alignment=Qt.AlignTop) - row +=1 + row += 1 QuickStartTextWidget = QLabel() - htmlTxt = ( - f""" + htmlTxt = f""" {htmlHead}
@@ -729,29 +696,28 @@ def addQuickStartPage(self): """ - ) QuickStartTextWidget.setText(htmlTxt) - QuickStartLayout.addWidget(QuickStartTextWidget, row, 0, - alignment=Qt.AlignTop) + QuickStartLayout.addWidget(QuickStartTextWidget, row, 0, alignment=Qt.AlignTop) row += 1 layout = QHBoxLayout() - testMyImage = QPushButton( - text='Test segmentation with my image/video') - testMyImage.setIcon(QIcon(':image.svg')) + testMyImage = QPushButton(text="Test segmentation with my image/video") + testMyImage.setIcon(QIcon(":image.svg")) layout.addWidget(testMyImage) testMyImage.clicked.connect(self.openGUIsingleImage) testTimeLapseButton = QPushButton( - text='Download and test with a time-lapse example') - testTimeLapseButton.setIcon(QIcon(':download.svg')) + text="Download and test with a time-lapse example" + ) + testTimeLapseButton.setIcon(QIcon(":download.svg")) layout.addWidget(testTimeLapseButton) testTimeLapseButton.clicked.connect(self.testTimeLapseExample) test3DzStackButton = QPushButton( - text='Download and test with a 3D z-stack example') - test3DzStackButton.setIcon(QIcon(':download.svg')) + text="Download and test with a 3D z-stack example" + ) + test3DzStackButton.setIcon(QIcon(":download.svg")) layout.addWidget(test3DzStackButton) test3DzStackButton.clicked.connect(self.test3DzStacksExample) @@ -775,7 +741,7 @@ def addManualPage(self): manualLayout = QGridLayout() openManualButton = widgets.showInFileManagerButton( - ' Download and open user manual... ' + " Download and open user manual... " ) openManualButton.clicked.connect(myutils.browse_docs) @@ -794,15 +760,15 @@ def addContributePage(self): layout = QGridLayout() - contribute_href = html_utils.href_tag('here', urls.contribute_url) - github_href = html_utils.href_tag('GitHub page', urls.github_url) - issues_href = html_utils.href_tag('Issues', urls.issues_url) - forum_href = html_utils.href_tag('Discussions', urls.forum_url) - resources_href = html_utils.href_tag('here', urls.resources_url) - my_contact_href = html_utils.href_tag('my email', urls.my_contact_url) - user_manual_href = html_utils.href_tag('User Manual', urls.user_manual_url) + contribute_href = html_utils.href_tag("here", urls.contribute_url) + github_href = html_utils.href_tag("GitHub page", urls.github_url) + issues_href = html_utils.href_tag("Issues", urls.issues_url) + forum_href = html_utils.href_tag("Discussions", urls.forum_url) + resources_href = html_utils.href_tag("here", urls.resources_url) + my_contact_href = html_utils.href_tag("my email", urls.my_contact_url) + user_manual_href = html_utils.href_tag("User Manual", urls.user_manual_url) - text = (f""" + text = f"""

Here at Cell-ACDC we want to keep a community-centred approach.

@@ -833,7 +799,7 @@ def addContributePage(self): Additional resources {resources_href}.

- """) + """ label = QLabel() label.setText(text) @@ -844,28 +810,27 @@ def addContributePage(self): self.mainLayout.addWidget(self.contributeFrame, 0, 1) self.itemsDict[self.contributeItem.text(0)] = self.contributeFrame - def linkActivated_cb(self, link): - if link == 'DataPrepMore': + if link == "DataPrepMore": pass - elif link == 'paper': + elif link == "paper": url = cite_url webbrowser.open(url) - elif link == 'tweet': - url = 'https://twitter.com/frank_pado/status/1443957038841794561?s=20' + elif link == "tweet": + url = "https://twitter.com/frank_pado/status/1443957038841794561?s=20" webbrowser.open(url) - elif link == 'segmMore': + elif link == "segmMore": pass - elif link == 'guiMore': + elif link == "guiMore": pass - elif link == 'quickStart': + elif link == "quickStart": self.showPage(self.quickStartItem) - elif link == 'userManual': + elif link == "userManual": self.showPage(self.manualItem) def addShowGuideCheckbox(self): - checkBox = QCheckBox('Show Welcome Guide when opening Cell-ACDC') - checked = self.df_settings.at['showWelcomeGuide', 'value'] == 'Yes' + checkBox = QCheckBox("Show Welcome Guide when opening Cell-ACDC") + checked = self.df_settings.at["showWelcomeGuide", "value"] == "Yes" checkBox.setChecked(checked) self.mainLayout.addWidget(checkBox, 1, 1, alignment=Qt.AlignRight) @@ -873,13 +838,13 @@ def addShowGuideCheckbox(self): def showWelcomeGuideCheckBox_cb(self, state): if state == 0: - show = 'No' + show = "No" else: - show = 'Yes' - self.df_settings.loc['showWelcomeGuide'] = ( - self.df_settings.loc['showWelcomeGuide'].astype(str) - ) - self.df_settings.at['showWelcomeGuide', 'value'] = show + show = "Yes" + self.df_settings.loc["showWelcomeGuide"] = self.df_settings.loc[ + "showWelcomeGuide" + ].astype(str) + self.df_settings.at["showWelcomeGuide", "value"] = show self.saveSettings() def saveSettings(self): @@ -893,10 +858,8 @@ def openGUIsingleImage(self): You will then be asked to select an image file (e.g., .tif or .png), or a video file (e.g., .avi). """) - msg.information( - self, 'Test with my image', txt - ) - + msg.information(self, "Test with my image", txt) + if self.mainWin is not None: self.mainWin.launchGui() guiWin = self.mainWin.guiWins[-1] @@ -910,9 +873,7 @@ def openGUIfolder(self, exp_path): if self.mainWin is not None: self.mainWin.launchGui() guiWin = self.mainWin.guiWins[-1] - QTimer.singleShot( - 200, partial(guiWin.openFolder, exp_path=exp_path) - ) + QTimer.singleShot(200, partial(guiWin.openFolder, exp_path=exp_path)) else: self.guiWin = gui.guiWin(self.app) self.guiWin.showAndSetSize() @@ -927,14 +888,12 @@ def addPbar(self): self.welcomeLayout.addWidget(self.QPbar, 3, 0, 1, 3) def testTimeLapseExample(self, checked=True): - _, example_path, _, _ = myutils.get_examples_path('time_lapse_2D') - txt = ( - f""" + _, example_path, _, _ = myutils.get_examples_path("time_lapse_2D") + txt = f"""


Downloading example to {example_path}...

""" - ) self.infoTextWidget.setText(txt) if self.QPbar is None: @@ -943,7 +902,7 @@ def testTimeLapseExample(self, checked=True): self.QPbar.setVisible(True) self.thread = QThread() - self.worker = downloadWorker('time_lapse_2D') + self.worker = downloadWorker("time_lapse_2D") self.worker.moveToThread(self.thread) self.worker.progress.connect(self.downloadProgress) self.worker.finished.connect(self.thread.quit) @@ -958,7 +917,7 @@ def downloadProgress(self, file_size, len_chunk): if file_size != -1: self.QPbar.setMaximum(file_size) elif len_chunk != -1: - self.QPbar.setValue(self.QPbar.value()+len_chunk) + self.QPbar.setValue(self.QPbar.value() + len_chunk) elif len_chunk == 0: self.QPbar.setValue(self.QPbar.maximum()) @@ -971,40 +930,37 @@ def downloadExampleWorkerFinished(self): Do you want to open it in the GUI? """) _, yesButton = msg.question( - self, 'Open downloaded dataset?', txt, - buttonsTexts=('No, thanks', 'Yes, please, open the GUI'), + self, + "Open downloaded dataset?", + txt, + buttonsTexts=("No, thanks", "Yes, please, open the GUI"), commands=(self.worker.exp_path,), - path_to_browse=self.worker.exp_path + path_to_browse=self.worker.exp_path, ) self.infoTextWidget.setText( - '
Example downloaded to ' - f'{self.worker.exp_path}.
' + f"
Example downloaded to {self.worker.exp_path}.
" ) if msg.clickedButton == yesButton: self.openGUIexample() - + def openGUIexample(self): - txt = ( - f""" + txt = f"""


Example downloaded to {self.worker.exp_path}.
Opening GUI...

""" - ) self.infoTextWidget.setText(txt) self.openGUIfolder(self.worker.exp_path) def test3DzStacksExample(self, checked=True): - _, example_path, _, _ = myutils.get_examples_path('snapshots_3D') - txt = ( - f""" + _, example_path, _, _ = myutils.get_examples_path("snapshots_3D") + txt = f"""


Downloading example to {example_path}...

""" - ) self.infoTextWidget.setText(txt) if self.QPbar is None: @@ -1013,7 +969,7 @@ def test3DzStacksExample(self, checked=True): self.QPbar.setVisible(True) self.thread = QThread() - self.worker = downloadWorker('snapshots_3D') + self.worker = downloadWorker("snapshots_3D") self.worker.moveToThread(self.thread) self.worker.progress.connect(self.downloadProgress) self.worker.finished.connect(self.thread.quit) @@ -1030,7 +986,7 @@ def debug(self): def showAndSetSize(self): font = QFont() font.setPixelSize(13) - font.setFamily('Ubuntu') + font.setFamily("Ubuntu") self.treeSelector.setFont(font) self.showPage(self.quickStartItem) @@ -1044,7 +1000,7 @@ def showAndSetSize(self): def resizeScrollbar(self): if self.quickStartScrollArea.horizontalScrollBar().isVisible(): - self.resize(self.width()+5, self.height()) + self.resize(self.width() + 5, self.height()) else: self.timer.stop() self.moveWindow() @@ -1065,21 +1021,20 @@ def moveWindow(self): left = screenLeft + 10 top = screenTop + 70 width = w - height = int(h*Dh) - if height > 0.9*screenHeight: - height = int(0.9*screenHeight) + height = int(h * Dh) + if height > 0.9 * screenHeight: + height = int(0.9 * screenHeight) self.setGeometry(left, top, width, height) if self.mainWin is not None: mainWinWidth = self.mainWin.width() - welcomeWinRight = left+width - if welcomeWinRight+mainWinWidth > screenRight: + welcomeWinRight = left + width + if welcomeWinRight + mainWinWidth > screenRight: # The right edge of the welcome window is out of screen bounds # Keep it in the screen - welcomeWinRight = screenRight-mainWinWidth + welcomeWinRight = screenRight - mainWinWidth self.mainWin.move(welcomeWinRight, top) - def showPage(self, currentItem): self.treeSelector.setCurrentItem(currentItem, 0) @@ -1090,11 +1045,12 @@ def eventFilter(self, object, event): return True return False -if __name__ == '__main__': + +if __name__ == "__main__": app = QApplication([]) win = welcomeWin(app=app) win.showAndSetSize() win.showPage(win.welcomeItem) # win.showPage(win.quickStartItem) - app.setStyle(QStyleFactory.create('Fusion')) + app.setStyle(QStyleFactory.create("Fusion")) sys.exit(app.exec_()) diff --git a/cellacdc/html_utils.py b/cellacdc/html_utils.py index 8bc832553..dfc6e6cd5 100755 --- a/cellacdc/html_utils.py +++ b/cellacdc/html_utils.py @@ -7,14 +7,15 @@ from . import GUI_INSTALLED, myutils from ._palettes import ( - _get_highligth_header_background_rgba, _get_highligth_text_background_rgba + _get_highligth_header_background_rgba, + _get_highligth_text_background_rgba, ) from .colors import rgb_uint_to_html_hex if GUI_INSTALLED: from matplotlib.colors import to_hex -is_mac = sys.platform == 'darwin' +is_mac = sys.platform == "darwin" RST_NOTE_DIR_RGBA = _get_highligth_header_background_rgba() RST_NOTE_DIR_HEX_COLOR = rgb_uint_to_html_hex(RST_NOTE_DIR_RGBA[:3]) @@ -23,78 +24,84 @@ RST_NOTE_TXT_HEX_COLOR = rgb_uint_to_html_hex(RST_NOTE_TXT_RGBA[:3]) ADMONITION_TYPES = ( - 'topic', - 'admonition', - 'attention', - 'caution', - 'danger', - 'error', - 'hint', - 'important', - 'note', - 'seealso', - 'tip', - 'todo', - 'warning', - 'versionadded', - 'versionchanged', - 'deprecated' + "topic", + "admonition", + "attention", + "caution", + "danger", + "error", + "hint", + "important", + "note", + "seealso", + "tip", + "todo", + "warning", + "versionadded", + "versionchanged", + "deprecated", ) -HTML_TAGS = ( - 'code', 'i', 'b', 'br' -) +HTML_TAGS = ("code", "i", "b", "br") + def _tag(tag_info='p style="font-size:10px"'): def wrapper(func): @wraps(func) def inner(text): - tag = tag_info.split(' ')[0] - text = f'<{tag_info}>{text}' + tag = tag_info.split(" ")[0] + text = f"<{tag_info}>{text}" return text + return inner + return wrapper + def tag(text, tag_info='p style="font-size:10pt"'): - tag = tag_info.split(' ')[0] - text = f'<{tag_info}>{text}' + tag = tag_info.split(" ")[0] + text = f"<{tag_info}>{text}" return text + def to_plain_text(html_text): - html_text = re.sub(r' +', ' ', html_text) - html_text = html_text.replace('\n ', '\n') - html_text = html_text.strip('\n') - html_text = html_text.replace('', '`') - html_text = html_text.replace('', '`') - html_text = html_text.replace('
', '\n') - html_text = html_text.replace('
  • ', '\n * ') - html_text = re.sub(r'', '', html_text) - html_text = re.sub(r'<.+>', '', html_text) - html_text = html_text.strip('\n') + html_text = re.sub(r" +", " ", html_text) + html_text = html_text.replace("\n ", "\n") + html_text = html_text.strip("\n") + html_text = html_text.replace("", "`") + html_text = html_text.replace("", "`") + html_text = html_text.replace("
    ", "\n") + html_text = html_text.replace("
  • ", "\n * ") + html_text = re.sub(r"", "", html_text) + html_text = re.sub(r"<.+>", "", html_text) + html_text = html_text.strip("\n") return html_text + def href_tag(text, url): txt = tag(text, tag_info=f'a href="{url}"') return txt + def to_list(items, ordered=False): - list_tag = 'ol' if ordered else 'ul' - items_txt = ''.join([f'
  • {item}
  • ' for item in items]) + list_tag = "ol" if ordered else "ul" + items_txt = "".join([f"
  • {item}
  • " for item in items]) txt = tag(items_txt, tag_info=list_tag) return txt -def span(text, color='r', font_size=None, bold=False): + +def span(text, color="r", font_size=None, bold=False): span_text = f'{text}' if color is not None: try: c = to_hex(color) except Exception as e: - if color == 'r': - c = 'red' - elif color == 'g': - c = 'green' - elif color == 'k': - c = 'black' + if color == "r": + c = "red" + elif color == "g": + c = "green" + elif color == "k": + c = "black" else: c = color span_text = f'{text}' @@ -104,10 +111,11 @@ def span(text, color='r', font_size=None, bold=False): span_text = span_text.replace('">', f'; font-weight:bold;">') return span_text + def css_head(txt): # if is_mac: # txt = txt.replace(',', ', ') - s = (f""" + s = f""" @@ -115,21 +123,23 @@ def css_head(txt): {txt} - """) + """ return s + def html_body(txt): if is_mac: - txt = txt.replace(',', ', ') - s = (f""" + txt = txt.replace(",", ", ") + s = f""" {txt} - """) + """ return s -def paragraph(txt, font_size='13px', font_color=None, wrap=True, center=False): + +def paragraph(txt, font_size="13px", font_color=None, wrap=True, center=False): # if is_mac: # # Qt < 5.15.3 has a bug on macOS and the space after comma and perdiod # # are super small. Force a non-breaking space (except for 'e.g.,'). @@ -140,248 +150,251 @@ def paragraph(txt, font_size='13px', font_color=None, wrap=True, center=False): # txt = txt.replace('i. e. ', 'i.e.') # txt = txt.replace('etc. )', 'etc.)') if not wrap: - txt = txt.replace(' ', ' ') + txt = txt.replace(" ", " ") if font_color is None: - s = (f""" + s = f"""

    {txt}

    - """) + """ else: - s = (f""" + s = f"""

    {txt}

    - """) + """ if center: s = re.sub(r'

    ', r'

    ', s) return s + def rst_urls_to_html(rst_text): - links = re.findall(r'`(.*) ?<(.*)>`_', rst_text) + links = re.findall(r"`(.*) ?<(.*)>`_", rst_text) html_text = rst_text for text, link in links: if not text: text = link repl = href_tag(text.rstrip(), link) - pattern = fr'`{text} ?<{link}>`_' + pattern = rf"`{text} ?<{link}>`_" html_text = re.sub(pattern, repl, html_text) return html_text + def rst_to_html(rst_text, parse_urls=False, keep_spacing=False): if parse_urls: rst_text = rst_urls_to_html(rst_text) - valid_chars = r'[,A-Za-z0-9μ\-\.=_ \<\>\(\)\\\&;]' - html_text = re.sub(rf'\`\`([^\`]*)\`\`', r'\1', rst_text) - html_text = re.sub(rf'\`([^\`]*)\`', r'\1', html_text) - html_text = html_text.replace('<', '<').replace('>', '>') - + valid_chars = r"[,A-Za-z0-9μ\-\.=_ \<\>\(\)\\\&;]" + html_text = re.sub(rf"\`\`([^\`]*)\`\`", r"\1", rst_text) + html_text = re.sub(rf"\`([^\`]*)\`", r"\1", html_text) + html_text = html_text.replace("<", "<").replace(">", ">") + # Insert back the allowed html tags as actual tags for html_tag in HTML_TAGS: - html_text = html_text.replace(f'<{html_tag}>', f'<{html_tag}>') - html_text = html_text.replace(f'</{html_tag}>', f'') - - html_text = html_text.replace('\n', '
    ') + html_text = html_text.replace(f"<{html_tag}>", f"<{html_tag}>") + html_text = html_text.replace(f"</{html_tag}>", f"") + + html_text = html_text.replace("\n", "
    ") if keep_spacing: - html_text = re.sub( - r'(\s\s+)', lambda m: ' '*len(m.group(0)), html_text - ) + html_text = re.sub(r"(\s\s+)", lambda m: " " * len(m.group(0)), html_text) return html_text + def rst_docstring_filter_args(rst_doc, args_to_keep): - start_idx = rst_doc.find('Parameters') + start_idx = rst_doc.find("Parameters") before_params_text = rst_doc[:start_idx] - start_params_idx = before_params_text.rfind('\n') + 1 - - params_text = rst_doc[start_params_idx:] + start_params_idx = before_params_text.rfind("\n") + 1 + + params_text = rst_doc[start_params_idx:] numls = len(params_text) - len(params_text.lstrip()) - ul = ' '*numls + '-'*len('Parameters') - section = ' '*numls + 'Parameters' - section_header = f'{section}\n{ul}\n' - - found_end = re.findall(r'\n *\n', params_text) + ul = " " * numls + "-" * len("Parameters") + section = " " * numls + "Parameters" + section_header = f"{section}\n{ul}\n" + + found_end = re.findall(r"\n *\n", params_text) if not found_end: stop_idx = None else: stop_idx = params_text.find(found_end[0]) - + params_text = params_text[:stop_idx] filtered_params_text = params_text - found_args = re.findall(r'([A-Za-z0-9_]+) \: (.*)', params_text) + found_args = re.findall(r"([A-Za-z0-9_]+) \: (.*)", params_text) for a, (arg_name, arg_dtype) in enumerate(found_args): if arg_name in args_to_keep: continue - - arg_doc = f' {arg_name} : {arg_dtype}' + + arg_doc = f" {arg_name} : {arg_dtype}" start_idx = filtered_params_text.find(arg_doc) + 1 - - if a+1 == len(found_args): + + if a + 1 == len(found_args): stop_idx = None else: - next_arg, next_arg_type = found_args[a+1] - next_arg_doc = f' {next_arg} : {next_arg_type}' + next_arg, next_arg_type = found_args[a + 1] + next_arg_doc = f" {next_arg} : {next_arg_type}" stop_idx = filtered_params_text.find(next_arg_doc) - + text_to_remove = filtered_params_text[start_idx:stop_idx] - filtered_params_text = filtered_params_text.replace(text_to_remove, '') - - filtered_params_text = filtered_params_text.rstrip().rstrip('\n') + filtered_params_text = filtered_params_text.replace(text_to_remove, "") + + filtered_params_text = filtered_params_text.rstrip().rstrip("\n") filtered_doc = rst_doc.replace(params_text, filtered_params_text) return filtered_doc + def rst_docstring_to_html(rst_doc: str, args_subset=None): html_text = rst_doc # ignore lines which start with a # - html_new = '' - for line in html_text.split('\n'): + html_new = "" + for line in html_text.split("\n"): try: first_char = line.lstrip()[0] except IndexError: - first_char = '' - - if first_char == '#': + first_char = "" + + if first_char == "#": continue - html_new += line + '\n' + html_new += line + "\n" html_text = html_new - + if args_subset is not None: html_text = rst_docstring_filter_args(html_text, args_subset) - + # Replace args with indented `bold : italic` - found_args = re.findall(r'([A-Za-z0-9_]+) \: (.*)', html_text) + found_args = re.findall(r"([A-Za-z0-9_]+) \: (.*)", html_text) for a, (arg_name, arg_dtype) in enumerate(found_args): - arg_doc = f' {arg_name} : {arg_dtype}' + arg_doc = f" {arg_name} : {arg_dtype}" html_text = html_text.replace( - arg_doc, - f'
      {arg_name} : {arg_dtype}', + arg_doc, + f"
      {arg_name} : {arg_dtype}", ) - + # Indent description of arg more admon_sections = [] - found_sections = re.findall(r'([A-Za-z ]+)\n *[\-]+\n', rst_doc) + found_sections = re.findall(r"([A-Za-z ]+)\n *[\-]+\n", rst_doc) for s, section in enumerate(found_sections): section_lstrip = section.lstrip() - section_admon = section_lstrip.replace(' ', '').lower() + section_admon = section_lstrip.replace(" ", "").lower() if section_admon in ADMONITION_TYPES: admon_sections.append(section) continue - + numls = len(section) - len(section_lstrip) - ul = ' '*numls + '-'*len(section_lstrip) - section_header = f'{section}\n{ul}\n' + ul = " " * numls + "-" * len(section_lstrip) + section_header = f"{section}\n{ul}\n" start_idx = html_text.find(section_header) + len(section_header) - if s+1 == len(found_sections): + if s + 1 == len(found_sections): stop_idx = None else: - next_section = found_sections[s+1] + next_section = found_sections[s + 1] stop_idx = html_text.find(next_section) - + section_text = html_text[start_idx:stop_idx] section_indented = re.sub( - r'(\n\s\s+)', '
        ', section_text + r"(\n\s\s+)", "
        ", section_text ) - + html_text = list(html_text) html_text[start_idx:stop_idx] = section_indented - html_text = ''.join(html_text) - + html_text = "".join(html_text) + # Replace section header with 16px bold html for section in found_sections: if section in admon_sections: continue - - section_lstrip = section.lstrip() + + section_lstrip = section.lstrip() numls = len(section) - len(section_lstrip) - ul = ' '*numls + '-'*len(section_lstrip) + ul = " " * numls + "-" * len(section_lstrip) html_text = html_text.replace( - f'{section}\n{ul}', - span(section.strip(), font_size='16px', color=None, bold=True) + f"{section}\n{ul}", + span(section.strip(), font_size="16px", color=None, bold=True), ) - + # Replace admonition sections with html table for admon_section in admon_sections: - section_lstrip = admon_section.lstrip() + section_lstrip = admon_section.lstrip() numls = len(admon_section) - len(section_lstrip) - ul = ' '*numls + '-'*len(section_lstrip) - section_header = f'{admon_section}\n{ul}\n' - + ul = " " * numls + "-" * len(section_lstrip) + section_header = f"{admon_section}\n{ul}\n" + start_idx = html_text.find(section_header) + len(section_header) section_text = html_text[start_idx:] - found_end = re.findall(r'\n *\n', section_text) + found_end = re.findall(r"\n *\n", section_text) if not found_end: stop_idx = None else: stop_idx = section_text.find(found_end[0]) - + section_text = section_text[:stop_idx] html_admon = to_admonition(section_text, admonition_type=section_lstrip) html_text = html_text.replace(section_text, html_admon) - html_text = html_text.replace(section_header, '') - + html_text = html_text.replace(section_header, "") + # Replace last charachaters to html html_text = rst_urls_to_html(html_text) - html_text = html_text.replace('\n', '
    ') - html_text = re.sub(rf'\`\`([^\`]*)\`\`', r'\1', html_text) - html_text = re.sub(rf'\`([^\`]*)\`', r'\1', html_text) - + html_text = html_text.replace("\n", "
    ") + html_text = re.sub(rf"\`\`([^\`]*)\`\`", r"\1", html_text) + html_text = re.sub(rf"\`([^\`]*)\`", r"\1", html_text) + return html_text -def to_admonition(text, admonition_type='note'): - if text.find('
    ') == -1: + +def to_admonition(text, admonition_type="note"): + if text.find("
    ") == -1: wrapped_list = textwrap.wrap(text, width=130) - text = '
    '.join(wrapped_list) + text = "
    ".join(wrapped_list) title = admonition_type.capitalize() title_row = tag( - f'! {title}', - tag_info=f'tr bgcolor="{RST_NOTE_DIR_HEX_COLOR}"' + f"! {title}", tag_info=f'tr bgcolor="{RST_NOTE_DIR_HEX_COLOR}"' ) text_row = tag( - f'{text}', - tag_info=f'tr bgcolor="{RST_NOTE_TXT_HEX_COLOR}"' + f"{text}", tag_info=f'tr bgcolor="{RST_NOTE_TXT_HEX_COLOR}"' ) admonition_html = ( - '' - f'{title_row}{text_row}' - '

    ' + "" + f"{title_row}{text_row}" + "

    " ) return admonition_html + def to_note(note_text): - note_html = to_admonition(note_text, admonition_type='note') + note_html = to_admonition(note_text, admonition_type="note") return note_html + # Syntax highlighting html -func_color = (111/255,66/255,205/255) # purplish -kwargs_color = (208/255,88/255,9/255) # reddish/orange -class_color = (215/255,58/255,73/255) # reddish -blue_color = (0/255,92/255,197/255) # blueish -class_sh = span('class', color=class_color) -def_sh = span('def', color=class_color) -if_sh = span('if', color=class_color) -elif_sh = span('elif', color=class_color) -kwargs_sh = span('**kwargs', color=kwargs_color) -Model_sh = span('Model', color=func_color) -segment_sh = span('segment', color=func_color) -add_prompt_sh = span('add_prompt', color=func_color) -predict_sh = span('predict', color=func_color) -CV_sh = span('CV', color=func_color) -init_sh = span('__init__', color=blue_color) -myModel_sh = span('MyModel', color=func_color) -return_sh = span('return', color=class_color) -equal_sh = span('=', color=class_color) -open_par_sh = span('(', color=blue_color) -close_par_sh = span(')', color=blue_color) -image_sh = span('image', color=kwargs_color) -from_sh = span('from', color=class_color) -import_sh = span('import', color=class_color) -is_not_sh = span('is not', color=class_color) -np_mean_sh = span('np.mean', color=class_color) -np_std_sh = span('np.std', color=class_color) +func_color = (111 / 255, 66 / 255, 205 / 255) # purplish +kwargs_color = (208 / 255, 88 / 255, 9 / 255) # reddish/orange +class_color = (215 / 255, 58 / 255, 73 / 255) # reddish +blue_color = (0 / 255, 92 / 255, 197 / 255) # blueish +class_sh = span("class", color=class_color) +def_sh = span("def", color=class_color) +if_sh = span("if", color=class_color) +elif_sh = span("elif", color=class_color) +kwargs_sh = span("**kwargs", color=kwargs_color) +Model_sh = span("Model", color=func_color) +segment_sh = span("segment", color=func_color) +add_prompt_sh = span("add_prompt", color=func_color) +predict_sh = span("predict", color=func_color) +CV_sh = span("CV", color=func_color) +init_sh = span("__init__", color=blue_color) +myModel_sh = span("MyModel", color=func_color) +return_sh = span("return", color=class_color) +equal_sh = span("=", color=class_color) +open_par_sh = span("(", color=blue_color) +close_par_sh = span(")", color=blue_color) +image_sh = span("image", color=kwargs_color) +from_sh = span("from", color=class_color) +import_sh = span("import", color=class_color) +is_not_sh = span("is not", color=class_color) +np_mean_sh = span("np.mean", color=class_color) +np_std_sh = span("np.std", color=class_color) import textwrap -table_style_header = textwrap.dedent('''\ +table_style_header = textwrap.dedent("""\ -''') \ No newline at end of file +""") diff --git a/cellacdc/info.py b/cellacdc/info.py index dc177a4f6..c44afa508 100644 --- a/cellacdc/info.py +++ b/cellacdc/info.py @@ -1,14 +1,16 @@ from . import urls, html_utils -forum_href = html_utils.href_tag('forum page', urls.forum_url) +forum_href = html_utils.href_tag("forum page", urls.forum_url) utilsInfo = { - 'Convert _segm.npz file(s) to ImageJ ROIs...': (f""" + "Convert _segm.npz file(s) to ImageJ ROIs...": ( + f""" Not documented yet. You can ask help about utilities on our {forum_href}.

    Thank you for your patience! - """), - - 'Create connected 3D segmentation mask from z-slices segmentation...': (f""" + """ + ), + "Create connected 3D segmentation mask from z-slices segmentation...": ( + f""" This utility is used to create a 3D segmentation mask by projecting the center z-slice of the 3D objects to their own z-boundaries.

    @@ -16,15 +18,17 @@ a "cylindrical" object,
    where the largest z-slice is projected up and down to the max and min z-slice. - """), - - 'Track sub-cellular objects (assign same ID as the cell they belong to)...': (f""" + """ + ), + "Track sub-cellular objects (assign same ID as the cell they belong to)...": ( + f""" Not documented yet. You can ask help about utilities on our {forum_href}.

    Thank you for your patience! - """), - - 'Apply tracking info from tabular data...': (f""" + """ + ), + "Apply tracking info from tabular data...": ( + f""" This utility is used to load the information of an external tracker into Cell-ACDC.

    @@ -44,41 +48,48 @@ Note that to use this utility you need to have a Cell-ACDC compatible segmentation file. - """), - - 'Create required data structure from image files...': (f""" + """ + ), + "Create required data structure from image files...": ( + f""" Not documented yet. You can ask help about utilities on our {forum_href}.

    Thank you for your patience! - """), - - 'Re-apply data prep steps to selected channels...': (f""" + """ + ), + "Re-apply data prep steps to selected channels...": ( + f""" Not documented yet. You can ask help about utilities on our {forum_href}.

    Thank you for your patience! - """), - - 'Concatenate acdc output tables from multiple Positions...': (f""" + """ + ), + "Concatenate acdc output tables from multiple Positions...": ( + f""" Not documented yet. You can ask help about utilities on our {forum_href}.

    Thank you for your patience! - """), - - 'Compute measurements for one or more experiments...': (f""" + """ + ), + "Compute measurements for one or more experiments...": ( + f""" Not documented yet. You can ask help about utilities on our {forum_href}.

    Thank you for your patience! - """), - - 'Combine measurements from multiple segmentation files...': (f""" + """ + ), + "Combine measurements from multiple segmentation files...": ( + f""" Not documented yet. You can ask help about utilities on our {forum_href}.

    Thank you for your patience! - """), - - 'Add lineage tree table to one or more experiments...': (f""" + """ + ), + "Add lineage tree table to one or more experiments...": ( + f""" Not documented yet. You can ask help about utilities on our {forum_href}.

    Thank you for your patience! - """) -} \ No newline at end of file + """ + ), +} diff --git a/cellacdc/io.py b/cellacdc/io.py index 96edb34db..4f85bc2f0 100644 --- a/cellacdc/io.py +++ b/cellacdc/io.py @@ -19,86 +19,89 @@ from . import saved_measurements_selections_folderpath from . import config + def get_saved_measurements_selections(): if not os.path.exists(saved_measurements_selections_folderpath): return [] - + return list(os.listdir(saved_measurements_selections_folderpath)) + def save_measurements_selections( - selected_measurements_filename, selected_measurements_dict - ): - os.makedirs( - saved_measurements_selections_folderpath, exist_ok=True - ) - + selected_measurements_filename, selected_measurements_dict +): + os.makedirs(saved_measurements_selections_folderpath, exist_ok=True) + configPars = config.ConfigParser() for section, values in selected_measurements_dict.items(): configPars[section] = {} for option, value in values.items(): configPars[section][option] = str(value) - + ini_filepath = os.path.join( saved_measurements_selections_folderpath, selected_measurements_filename ) - with open(ini_filepath, 'w') as configfile: + with open(ini_filepath, "w") as configfile: configPars.write(configfile) - + return ini_filepath + def read_measurements_selections(selected_measurements_filename): ini_filepath = os.path.join( saved_measurements_selections_folderpath, selected_measurements_filename ) - + cp = config.ConfigParser() cp.read(ini_filepath) - + return dict(cp) + def get_saved_moth_bud_tot_selections(): if not os.path.exists(moth_bud_tot_selected_columns_filepath): return {} - + with open(moth_bud_tot_selected_columns_filepath) as file: json_data = json.load(file) - + return json_data + def save_moth_bud_tot_selected_options(selected_options): - with open(moth_bud_tot_selected_columns_filepath, mode='w') as file: + with open(moth_bud_tot_selected_columns_filepath, mode="w") as file: json.dump(selected_options, file, indent=2) + def get_filepath_from_channel_name(images_path, channel_name): - h5_aligned_path = '' - h5_path = '' - npz_aligned_path = '' - img_path = '' - is_segm_ch = channel_name.find('segm') != -1 - segm_npy_path = '' - segm_npz_path = '' + h5_aligned_path = "" + h5_path = "" + npz_aligned_path = "" + img_path = "" + is_segm_ch = channel_name.find("segm") != -1 + segm_npy_path = "" + segm_npz_path = "" for file in path.listdir(images_path): filepath = os.path.join(images_path, file) if file.endswith(channel_name): return filepath - is_segm_npz_file = is_segm_ch and file.endswith(f'{channel_name}.npz') - is_segm_npy_file = is_segm_ch and file.endswith(f'{channel_name}.npy') + is_segm_npz_file = is_segm_ch and file.endswith(f"{channel_name}.npz") + is_segm_npy_file = is_segm_ch and file.endswith(f"{channel_name}.npy") if is_segm_npz_file: segm_npz_path = filepath if is_segm_npy_file: segm_npy_path = filepath - if file.endswith(f'{channel_name}_aligned.h5'): + if file.endswith(f"{channel_name}_aligned.h5"): h5_aligned_path = filepath - elif file.endswith(f'{channel_name}.h5'): + elif file.endswith(f"{channel_name}.h5"): h5_path = filepath - elif file.endswith(f'{channel_name}_aligned.npz'): + elif file.endswith(f"{channel_name}_aligned.npz"): npz_aligned_path = filepath - elif ( - file.endswith(f'{channel_name}.tif') - or file.endswith(f'{channel_name}.npz') - ): + elif file.endswith(f"{channel_name}.tif") or file.endswith( + f"{channel_name}.npz" + ): img_path = filepath - + if segm_npz_path: return segm_npz_path elif segm_npy_path: @@ -112,83 +115,85 @@ def get_filepath_from_channel_name(images_path, channel_name): elif img_path: return img_path else: - return '' + return "" + def _validate_filename(filename: str, is_path=False): if is_path: - pattern = r'[A-Za-z0-9_\\\/\:\.\-]+' + pattern = r"[A-Za-z0-9_\\\/\:\.\-]+" else: - pattern = r'[A-Za-z0-9_\.\-]+' + pattern = r"[A-Za-z0-9_\.\-]+" m = list(re.finditer(pattern, filename)) invalid_matches = [] for i, valid_chars in enumerate(m): start_idx, stop_idx = valid_chars.span() - if i == len(m)-1: + if i == len(m) - 1: invalid_chars = filename[stop_idx:] else: - next_valid_chars = m[i+1] + next_valid_chars = m[i + 1] start_next_idx = next_valid_chars.span()[0] invalid_chars = filename[stop_idx:start_next_idx] if invalid_chars: invalid_matches.append(invalid_chars) return set(invalid_matches) + def get_filename_cli( - question='Insert a filename', logger_func=print, check_exists=False, - is_path=False - ): + question="Insert a filename", logger_func=print, check_exists=False, is_path=False +): while True: filename = input(f'{question} (type "q" to cancel): ') - if filename.lower() == 'q': + if filename.lower() == "q": return - + if not is_path: invalid = _validate_filename(filename, is_path=is_path) if invalid: logger_func( - f'[ERROR]: The filename contains invalid charachters: {invalid}' - 'Valid charachters are letters, numbers, underscore, full stop, and hyphen.\n' + f"[ERROR]: The filename contains invalid charachters: {invalid}" + "Valid charachters are letters, numbers, underscore, full stop, and hyphen.\n" ) continue if check_exists and not os.path.exists(filename): - logger_func( - f'[ERROR] The provided path "{filename}" does not exist.' - ) + logger_func(f'[ERROR] The provided path "{filename}" does not exist.') continue return filename + def save_image_data(filepath, img_data): - if filepath.endswith('.h5'): + if filepath.endswith(".h5"): load.save_to_h5(filepath, img_data) - elif filepath.endswith('.npz'): + elif filepath.endswith(".npz"): savez_compressed(filepath, img_data) - elif filepath.endswith('.npy'): + elif filepath.endswith(".npy"): np.save(filepath, img_data) else: myutils.to_tiff(filepath, img_data) return np.squeeze(img_data) + def savez_compressed(filepath, *args, safe=True, **kwargs): if not safe: np.savez_compressed(filepath, *args, **kwargs) - return - + return + if not os.path.exists(filepath): np.savez_compressed(filepath, *args, **kwargs) return - + try: pathlib.Path(filepath).unlink() - temp_filepath = filepath.replace('.npz', '.new.npz') + temp_filepath = filepath.replace(".npz", ".new.npz") np.savez_compressed(temp_filepath, *args, **kwargs) os.replace(temp_filepath, filepath) except PermissionError as err: np.savez_compressed(filepath, *args, **kwargs) -def rename_files_replace_invalid_chars(files, src_path, replacement_char='_'): + +def rename_files_replace_invalid_chars(files, src_path, replacement_char="_"): renamed_files = [] for file in files: invalid_chars = _validate_filename(file, is_path=False) @@ -202,17 +207,18 @@ def rename_files_replace_invalid_chars(files, src_path, replacement_char='_'): renamed_files.append(new_file) return renamed_files + def move_separate_channels_tiffs_to_pos_folders( - tiffs_folderpath: os.PathLike, - channel_names: Sequence[str], - get_only_basenames=False, - extension='.tif' - ): + tiffs_folderpath: os.PathLike, + channel_names: Sequence[str], + get_only_basenames=False, + extension=".tif", +): basenames = set() for file in myutils.listdir(tiffs_folderpath): if not file.endswith(extension): continue - + filename_no_ext = os.path.splitext(file)[0] for channel in channel_names: splits = filename_no_ext.split(channel) @@ -220,33 +226,33 @@ def move_separate_channels_tiffs_to_pos_folders( basename = splits[0] basenames.add(basename) break - - basenames = natsorted(basenames) - + + basenames = natsorted(basenames) + if get_only_basenames: return basenames - + for p, basename in enumerate(basenames): - pos_folderpath = os.path.join(tiffs_folderpath, f'Position_{p+1}') - images_path = os.path.join(pos_folderpath, 'Images') - + pos_folderpath = os.path.join(tiffs_folderpath, f"Position_{p + 1}") + images_path = os.path.join(pos_folderpath, "Images") + os.makedirs(images_path, exist_ok=True) for file in myutils.listdir(tiffs_folderpath): if not file.startswith(basename): continue - + src_filepath = os.path.join(tiffs_folderpath, file) - if file.endswith('.tif'): + if file.endswith(".tif"): dst_filepath = os.path.join(images_path, file) shutil.move(src_filepath, dst_filepath) - elif file.endswith('_metadata.csv'): - dst_filename = f'{basename}metadata.csv' + elif file.endswith("_metadata.csv"): + dst_filename = f"{basename}metadata.csv" dst_filepath = os.path.join(images_path, dst_filename) - df_metadata = pd.read_csv(src_filepath, index_col='Description') - df_metadata.at['basename', 'values'] = basename + df_metadata = pd.read_csv(src_filepath, index_col="Description") + df_metadata.at["basename", "values"] = basename df_metadata.to_csv(dst_filepath) try: os.remove(src_filepath) except Exception as err: pass - return True \ No newline at end of file + return True diff --git a/cellacdc/load.py b/cellacdc/load.py index 62a336464..e21a9451a 100755 --- a/cellacdc/load.py +++ b/cellacdc/load.py @@ -23,10 +23,11 @@ import skimage import skimage.io -import skimage.measure - +import skimage.measure + import warnings -warnings.simplefilter(action='ignore', category=FutureWarning) + +warnings.simplefilter(action="ignore", category=FutureWarning) from . import prompts from . import myutils, measurements, config @@ -46,11 +47,10 @@ if GUI_INSTALLED: from qtpy import QtGui from qtpy.QtCore import Qt, QRect, QRectF - from qtpy.QtWidgets import ( - QApplication, QMessageBox, QFileDialog - ) + from qtpy.QtWidgets import QApplication, QMessageBox, QFileDialog import pyqtgraph as pg - pg.setConfigOption('imageAxisOrder', 'row-major') + + pg.setConfigOption("imageAxisOrder", "row-major") from . import apps from . import widgets from . import qrc_resources_path, qrc_resources_light_path @@ -58,90 +58,97 @@ from . import whitelist acdc_df_bool_cols = [ - 'is_cell_dead', - 'is_cell_excluded', - 'is_history_known', + "is_cell_dead", + "is_cell_excluded", + "is_history_known", ] -acdc_df_str_cols = {'cell_cycle_stage': str, 'relationship': str} +acdc_df_str_cols = {"cell_cycle_stage": str, "relationship": str} acdc_df_int_cols = { - 'frame_i': int, - 'Cell_ID': int, - 'generation_num': int, - 'emerg_frame_i': int, - 'division_frame_i': int, - 'generation_num_tree': int, - 'parent_ID_tree': int, - 'root_ID_tree': int, - 'sister_ID_tree': int, - 'num_objects': int, + "frame_i": int, + "Cell_ID": int, + "generation_num": int, + "emerg_frame_i": int, + "division_frame_i": int, + "generation_num_tree": int, + "parent_ID_tree": int, + "root_ID_tree": int, + "sister_ID_tree": int, + "num_objects": int, } acdc_df_dtype_id_checker_mapper = { - 'float': pd.api.types.is_float_dtype, - 'string': pd.api.types.is_string_dtype, - 'object': pd.api.types.is_object_dtype, - 'bool': pd.api.types.is_bool_dtype, + "float": pd.api.types.is_float_dtype, + "string": pd.api.types.is_string_dtype, + "object": pd.api.types.is_object_dtype, + "bool": pd.api.types.is_bool_dtype, } -additional_metadata_path = os.path.join(settings_folderpath, 'additional_metadata.json') -last_entries_metadata_path = os.path.join(settings_folderpath, 'last_entries_metadata.csv') -last_selected_measurements_ini_path = os.path.join( - settings_folderpath, 'last_selected_measurements.ini' +additional_metadata_path = os.path.join(settings_folderpath, "additional_metadata.json") +last_entries_metadata_path = os.path.join( + settings_folderpath, "last_entries_metadata.csv" ) -channel_file_formats = ( - '_aligned.h5', '.h5', '_aligned.npz', '.tif' +last_selected_measurements_ini_path = os.path.join( + settings_folderpath, "last_selected_measurements.ini" ) -ISO_TIMESTAMP_FORMAT = r'iso%Y%m%d%H%M%S' +channel_file_formats = ("_aligned.h5", ".h5", "_aligned.npz", ".tif") +ISO_TIMESTAMP_FORMAT = r"iso%Y%m%d%H%M%S" + class FileNameError(Exception): pass + def _pd_cast_float_and_bool_to_int(df, col, _): - df[col] = df[col].astype("Int64") # preserves NA values + df[col] = df[col].astype("Int64") # preserves NA values return df + def _pd_cast_string_to_int(df, col, not_nan_mask): - df[col] = (df[col].astype(str).str.lower() == 'true').astype("Int64") + df[col] = (df[col].astype(str).str.lower() == "true").astype("Int64") df.loc[~not_nan_mask, col] = pd.NA return df + acdc_df_dtype_id_func_mapper = { - 'float': _pd_cast_float_and_bool_to_int, - 'string': _pd_cast_string_to_int, - 'object': _pd_cast_string_to_int, - 'bool': _pd_cast_float_and_bool_to_int, + "float": _pd_cast_float_and_bool_to_int, + "string": _pd_cast_string_to_int, + "object": _pd_cast_string_to_int, + "bool": _pd_cast_float_and_bool_to_int, } -def read_json(json_path, logger_func=print, desc='custom annotations'): + +def read_json(json_path, logger_func=print, desc="custom annotations"): json_data = {} try: with open(json_path) as file: json_data = json.load(file) except Exception as e: - print('****************************') + print("****************************") logger_func(traceback.format_exc()) - print('****************************') - logger_func(f'json path: {json_path}') - print('----------------------------') + print("****************************") + logger_func(f"json path: {json_path}") + print("----------------------------") logger_func(f'Error while reading saved "{desc}". See above') - print('============================') + print("============================") return json_data + def remove_duplicates_file(filepath): if not os.path.exists(filepath): return - with open(filepath, 'r') as file: + with open(filepath, "r") as file: first_line = file.readline() rest_of_text = file.read() duplicate_first_line_idx = rest_of_text.find(first_line) if duplicate_first_line_idx == -1: return - unique_text = f'{first_line}{rest_of_text[:duplicate_first_line_idx]}' - with open(filepath, 'w') as file: + unique_text = f"{first_line}{rest_of_text[:duplicate_first_line_idx]}" + with open(filepath, "w") as file: file.write(unique_text) + def to_csv_through_temp(df, csv_path): filename = os.path.basename(csv_path) with tempfile.TemporaryDirectory() as temp_dir: @@ -149,15 +156,16 @@ def to_csv_through_temp(df, csv_path): df.to_csv(tmp_filepath) shutil.copy2(tmp_filepath, csv_path) + def get_all_acdc_folders(user_profile_path): models = myutils.get_list_of_models() - acdc_folders = [f'acdc-{model}' for model in models] - acdc_folders.append('acdc-java') - acdc_folders.append('.acdc-logs') - acdc_folders.append('.acdc-settings') - acdc_folders.append('acdc-manual') - acdc_folders.append('acdc-metrics') - acdc_folders.append('acdc-examples') + acdc_folders = [f"acdc-{model}" for model in models] + acdc_folders.append("acdc-java") + acdc_folders.append(".acdc-logs") + acdc_folders.append(".acdc-settings") + acdc_folders.append("acdc-manual") + acdc_folders.append("acdc-metrics") + acdc_folders.append("acdc-examples") existing_acdc_folders = [] for file in os.listdir(user_profile_path): filepath = os.path.join(user_profile_path, file) @@ -168,72 +176,76 @@ def get_all_acdc_folders(user_profile_path): existing_acdc_folders.append(file) return existing_acdc_folders + def write_json(json_data, json_path, indent=2): - with open(json_path, mode='w') as file: + with open(json_path, mode="w") as file: json.dump(json_data, file, indent=indent) + def read_last_selected_set_measurements(logger_func=print): if not os.path.exists(last_selected_measurements_ini_path): return {} - + cp = config.ConfigParser() cp.read(last_selected_measurements_ini_path) - + return cp + def write_last_selected_set_measurements(last_selected_meas: dict[str, dict]): configPars = config.ConfigParser() for section, values in last_selected_meas.items(): configPars[section] = {} for option, value in values.items(): configPars[section][option] = str(value) - - with open(last_selected_measurements_ini_path, 'w') as configfile: + + with open(last_selected_measurements_ini_path, "w") as configfile: configPars.write(configfile) + def migrate_models_paths(dst_path): models = myutils.get_list_of_models() - user_profile_path = dst_path.replace('\\', '/') + user_profile_path = dst_path.replace("\\", "/") for model in models: - model_path = os.path.join(models_path, model, 'model') - weight_location_txt_path = os.path.join( - model_path, 'weights_location_path.txt' - ) + model_path = os.path.join(models_path, model, "model") + weight_location_txt_path = os.path.join(model_path, "weights_location_path.txt") if not os.path.exists(weight_location_txt_path): continue - with open(weight_location_txt_path, 'r') as txt: + with open(weight_location_txt_path, "r") as txt: model_location = os.path.expanduser(txt.read()) - model_location = model_location.replace('\\', '/') + model_location = model_location.replace("\\", "/") model_folder = os.path.basename(model_location) model_location = os.path.join(user_profile_path, model_folder) - model_location = model_location.replace('\\', '/') - with open(weight_location_txt_path, 'w') as txt: + model_location = model_location.replace("\\", "/") + with open(weight_location_txt_path, "w") as txt: txt.write(model_location) + def save_workflow_to_config( - filepath, - ini_items: dict, - paths: list[str], - stop_frame_nums: list[int], - type='segment' - ): - paths = [path.replace('\\', '/') for path in paths] - paths_param = '\n'.join(paths) - paths_param = f'\n{paths_param}' + filepath, + ini_items: dict, + paths: list[str], + stop_frame_nums: list[int], + type="segment", +): + paths = [path.replace("\\", "/") for path in paths] + paths_param = "\n".join(paths) + paths_param = f"\n{paths_param}" configPars = config.ConfigParser() - configPars['paths_info'] = {'paths': paths_param} - - stop_frames_param = '\n'.join([str(n) for n in stop_frame_nums]) - stop_frames_param = f'\n{stop_frames_param}' - configPars['paths_info']['stop_frame_numbers'] = stop_frames_param - + configPars["paths_info"] = {"paths": paths_param} + + stop_frames_param = "\n".join([str(n) for n in stop_frame_nums]) + stop_frames_param = f"\n{stop_frames_param}" + configPars["paths_info"]["stop_frame_numbers"] = stop_frames_param + for section, options in ini_items.items(): configPars[section] = {} for option, value in options.items(): configPars[section][option] = str(value) - with open(filepath, 'w') as configfile: + with open(filepath, "w") as configfile: configPars.write(configfile) + def read_segm_workflow_from_config(filepath) -> dict: configPars = config.ConfigParser() configPars.read(filepath) @@ -242,20 +254,20 @@ def read_segm_workflow_from_config(filepath) -> dict: options = dict(configPars[section]) ini_items[section] = {} for option, value in options.items(): - if section == 'paths_info' or section == 'paths_to_segment': - value_list = value.strip('\n').strip().split('\n') - if option == 'paths': + if section == "paths_info" or section == "paths_to_segment": + value_list = value.strip("\n").strip().split("\n") + if option == "paths": abs_paths = [] folderpath = os.path.dirname(filepath) for path in value_list: if os.path.exists(path): abs_paths.append(path) continue - - abs_path = f'{folderpath}{os.sep}{path}' + + abs_path = f"{folderpath}{os.sep}{path}" if not os.path.exists(abs_path): raise FileNotFoundError( - 'The following path to analyse does not exist:' + "The following path to analyse does not exist:" f'\n\n"{path}"\n' ) @@ -265,322 +277,340 @@ def read_segm_workflow_from_config(filepath) -> dict: else: ini_items[section][option] = value_list continue - if value == 'False': + if value == "False": value = False - elif value == 'True': + elif value == "True": value = True - elif value == 'None': + elif value == "None": value = None - elif option == 'SizeT' or option == 'SizeZ': + elif option == "SizeT" or option == "SizeZ": value = int(value) - - if section == 'standard_postprocess_features' and value is not None: + + if section == "standard_postprocess_features" and value is not None: for _type in (int, float, str): try: value = _type(value) break except Exception as e: continue - - elif section == 'custom_postprocess_features': - low, high = value.strip().strip('(').strip(')').split(',') - if low.strip().lower() == 'none': + + elif section == "custom_postprocess_features": + low, high = value.strip().strip("(").strip(")").split(",") + if low.strip().lower() == "none": low = None else: low = float(low) - if high.strip().lower() == 'none': + if high.strip().lower() == "none": high = None else: high = float(high) value = (low, high) - + ini_items[section][option] = value return ini_items + def get_images_paths(folder_path): - folder_type = myutils.determine_folder_type(folder_path) - is_pos_folder, is_images_folder, folder_path = folder_type + folder_type = myutils.determine_folder_type(folder_path) + is_pos_folder, is_images_folder, folder_path = folder_type if not is_pos_folder and not is_images_folder: pos_foldernames = myutils.get_pos_foldernames(folder_path) images_paths = [ - os.path.join(folder_path, pos, 'Images') for pos in pos_foldernames + os.path.join(folder_path, pos, "Images") for pos in pos_foldernames ] elif is_pos_folder: - images_paths = [os.path.join(folder_path, 'Images')] + images_paths = [os.path.join(folder_path, "Images")] elif is_images_folder: images_paths = [folder_path] return images_paths + def read_config_metrics(ini_path): configPars = config.ConfigParser() configPars.read(ini_path) - if 'equations' not in configPars: - configPars['equations'] = {} + if "equations" not in configPars: + configPars["equations"] = {} + + if "mixed_channels_equations" not in configPars: + configPars["mixed_channels_equations"] = {} - if 'mixed_channels_equations' not in configPars: - configPars['mixed_channels_equations'] = {} + if "user_path_equations" not in configPars: + configPars["user_path_equations"] = {} - if 'user_path_equations' not in configPars: - configPars['user_path_equations'] = {} - return configPars + def add_configPars_metrics(configPars_ref, configPars2_to_add): - configPars_ref['equations'] = { - **configPars2_to_add['equations'], **configPars_ref['equations'] + configPars_ref["equations"] = { + **configPars2_to_add["equations"], + **configPars_ref["equations"], } - configPars_ref['mixed_channels_equations'] = { - **configPars2_to_add['mixed_channels_equations'], - **configPars_ref['mixed_channels_equations'] + configPars_ref["mixed_channels_equations"] = { + **configPars2_to_add["mixed_channels_equations"], + **configPars_ref["mixed_channels_equations"], } - configPars_ref['user_path_equations'] = { - **configPars2_to_add['user_path_equations'], - **configPars_ref['user_path_equations'] + configPars_ref["user_path_equations"] = { + **configPars2_to_add["user_path_equations"], + **configPars_ref["user_path_equations"], } keep_user_path_equations = { - key:val for key, val in configPars_ref['user_path_equations'].items() - if key not in configPars_ref['equations'] - } - configPars_ref['user_path_equations'] = keep_user_path_equations + key: val + for key, val in configPars_ref["user_path_equations"].items() + if key not in configPars_ref["equations"] + } + configPars_ref["user_path_equations"] = keep_user_path_equations return configPars_ref -def h5py_iter(g, prefix=''): + +def h5py_iter(g, prefix=""): for key, item in g.items(): path = os.path.join(prefix, key) - if isinstance(item, h5py.Dataset): # test for dataset + if isinstance(item, h5py.Dataset): # test for dataset yield (path, item) - elif isinstance(item, h5py.Group): # test for group (go down) + elif isinstance(item, h5py.Group): # test for group (go down) yield from h5py_iter(item, path) + def h5dump_to_arr(h5path): data_dict = {} - with h5py.File(h5path, 'r') as f: - for (path, dset) in h5py_iter(f): + with h5py.File(h5path, "r") as f: + for path, dset in h5py_iter(f): data_dict[dset.name] = dset[()] sorted_keys = natsorted(data_dict.keys()) arr = np.array([data_dict[key] for key in sorted_keys]) return arr + def save_to_h5(dst_filepath, data): filename = os.path.basename(dst_filepath) tempDir = tempfile.mkdtemp() tempFilepath = os.path.join(tempDir, filename) - chunks = [1]*data.ndim + chunks = [1] * data.ndim chunks[-2:] = data.shape[-2:] - with h5py.File(tempFilepath, 'w') as h5f: + with h5py.File(tempFilepath, "w") as h5f: dataset = h5f.create_dataset( - 'data', data.shape, dtype=data.dtype, - chunks=chunks, shuffle=False + "data", data.shape, dtype=data.dtype, chunks=chunks, shuffle=False ) dataset[:] = data shutil.move(tempFilepath, dst_filepath) shutil.rmtree(tempDir) -def load_segm_file(images_path, end_name_segm_file='segm', return_path=False): - if not end_name_segm_file.endswith('.npz'): - end_name_segm_file = f'{end_name_segm_file}.npz' - + +def load_segm_file(images_path, end_name_segm_file="segm", return_path=False): + if not end_name_segm_file.endswith(".npz"): + end_name_segm_file = f"{end_name_segm_file}.npz" + found_files = [ - file for file in myutils.listdir(images_path) + file + for file in myutils.listdir(images_path) if file.endswith(end_name_segm_file) ] try: if len(found_files) == 0: segm_data = None - segm_filepath = '' + segm_filepath = "" elif len(found_files) == 1: segm_filepath = os.path.join(images_path, found_files[0]) - segm_data = np.load(segm_filepath)['arr_0'].astype(np.uint32) + segm_data = np.load(segm_filepath)["arr_0"].astype(np.uint32) else: found_files.sort(key=len) segm_filepath = os.path.join(images_path, found_files[0]) - segm_data = np.load(segm_filepath)['arr_0'].astype(np.uint32) + segm_data = np.load(segm_filepath)["arr_0"].astype(np.uint32) except OSError as e: - if str(e).find("[Errno 22] Invalid argument") != -1 and segm_filepath.find("OneDrive") != -1: + if ( + str(e).find("[Errno 22] Invalid argument") != -1 + and segm_filepath.find("OneDrive") != -1 + ): print(traceback.print_exc()) - raise OSError("If the file is online only, and syncing is disabled, this file cannot be accessed.") + raise OSError( + "If the file is online only, and syncing is disabled, this file cannot be accessed." + ) else: raise e - + if return_path: return segm_data, segm_filepath else: return segm_data + def get_tzyx_shape(images_path): df_metadata = load_metadata_df(images_path) - channel = df_metadata.at['channel_0_name', 'values'] + channel = df_metadata.at["channel_0_name", "values"] img_filepath = get_filename_from_channel(images_path, channel) img_data = load_image_file(img_filepath) if img_data.ndim == 4: return img_data.shape - - SizeZ = int(df_metadata.at['SizeZ', 'values']) - SizeT = int(df_metadata.at['SizeT', 'values']) + + SizeZ = int(df_metadata.at["SizeZ", "values"]) + SizeT = int(df_metadata.at["SizeT", "values"]) YX = img_data.shape[-2:] return (SizeT, SizeZ, *YX) - + + def load_metadata_df(images_path): for file in myutils.listdir(images_path): - if not file.endswith('metadata.csv'): + if not file.endswith("metadata.csv"): continue filepath = os.path.join(images_path, file) parse_metadata_csv_file(filepath) - return pd.read_csv(filepath).set_index('Description') + return pd.read_csv(filepath).set_index("Description") + def _add_will_divide_column(acdc_df): - if 'cell_cycle_stage' not in acdc_df.columns: + if "cell_cycle_stage" not in acdc_df.columns: return acdc_df - if 'will_divide' in acdc_df.columns: + if "will_divide" in acdc_df.columns: return acdc_df - acdc_df['will_divide'] = np.nan - last_index_cca_df = acdc_df[['cell_cycle_stage']].last_valid_index() + acdc_df["will_divide"] = np.nan + last_index_cca_df = acdc_df[["cell_cycle_stage"]].last_valid_index() cca_df = acdc_df.loc[:last_index_cca_df, cca_df_colnames].reset_index() - cca_df['will_divide'] = 0.0 + cca_df["will_divide"] = 0.0 cca_df_buds = cca_df.query('relationship == "bud"') - for budID, bud_cca_df in cca_df_buds.groupby('Cell_ID'): - all_gen_nums = cca_df.query(f'Cell_ID == {budID}')['generation_num'] + for budID, bud_cca_df in cca_df_buds.groupby("Cell_ID"): + all_gen_nums = cca_df.query(f"Cell_ID == {budID}")["generation_num"] if not (all_gen_nums > 0).any(): # bud division is annotated in the future - continue + continue - cca_df.loc[bud_cca_df.index, 'will_divide'] = 1 - - mothID = int(bud_cca_df['relative_ID'].iloc[0]) - first_frame_bud = bud_cca_df['frame_i'].iloc[0] + cca_df.loc[bud_cca_df.index, "will_divide"] = 1 + + mothID = int(bud_cca_df["relative_ID"].iloc[0]) + first_frame_bud = bud_cca_df["frame_i"].iloc[0] gen_num_moth = cca_df.query( - f'(frame_i == {first_frame_bud}) & (Cell_ID == {mothID})' - )['generation_num'].iloc[0] - - mothMask = ( - (cca_df['Cell_ID'] == mothID) - & (cca_df['generation_num'] == gen_num_moth) + f"(frame_i == {first_frame_bud}) & (Cell_ID == {mothID})" + )["generation_num"].iloc[0] + + mothMask = (cca_df["Cell_ID"] == mothID) & ( + cca_df["generation_num"] == gen_num_moth ) - cca_df.loc[mothMask, 'will_divide'] = 1 - - cca_df = cca_df.set_index(['frame_i', 'Cell_ID']) + cca_df.loc[mothMask, "will_divide"] = 1 + + cca_df = cca_df.set_index(["frame_i", "Cell_ID"]) acdc_df.loc[cca_df.index, cca_df.columns] = cca_df return acdc_df + def _fix_corrected_assignment_i(acdc_df: pd.DataFrame): - """Replaces the column 'corrected_assignment' with the newer + """Replaces the column 'corrected_assignment' with the newer 'corrected_on_frame_i' Parameters ---------- acdc_df : pd.DataFrame - Annotations and metrics dataframe (from the `acdc_output` CSV file) + Annotations and metrics dataframe (from the `acdc_output` CSV file) with ['frame_i', 'Cell_ID'] as index Returns ------- pd.DataFrame - acdc_df with correct `corrected_on_frame_i` and `corrected_assignment` + acdc_df with correct `corrected_on_frame_i` and `corrected_assignment` removed. - """ - - if 'corrected_assignment' not in acdc_df.columns: + """ + + if "corrected_assignment" not in acdc_df.columns: return acdc_df - - if 'corrected_on_frame_i' in acdc_df.columns: - if (acdc_df['corrected_on_frame_i'] > -1).any(): - acdc_df = acdc_df.drop( - columns='corrected_assignment', errors='ignore' - ) + + if "corrected_on_frame_i" in acdc_df.columns: + if (acdc_df["corrected_on_frame_i"] > -1).any(): + acdc_df = acdc_df.drop(columns="corrected_assignment", errors="ignore") return acdc_df - + for ID, df in acdc_df.groupby(level=1): # df = df[['corrected_assignment']].sort_index() - df['block'] = ( - df['corrected_assignment'].shift(1) != df['corrected_assignment'] - ).astype(int).cumsum() - df = df[df['corrected_assignment']>0] - for block, df_block in df.reset_index().groupby('block'): - corr_on_frame_i = df_block['frame_i'].min() - df_block = df_block.set_index(['frame_i', 'Cell_ID']) + df["block"] = ( + (df["corrected_assignment"].shift(1) != df["corrected_assignment"]) + .astype(int) + .cumsum() + ) + df = df[df["corrected_assignment"] > 0] + for block, df_block in df.reset_index().groupby("block"): + corr_on_frame_i = df_block["frame_i"].min() + df_block = df_block.set_index(["frame_i", "Cell_ID"]) corr_on_index = df_block.index - acdc_df.loc[corr_on_index, 'corrected_on_frame_i'] = corr_on_frame_i - + acdc_df.loc[corr_on_index, "corrected_on_frame_i"] = corr_on_frame_i + # acdc_df['corrected_on_frame_i'] = acdc_df['corrected_on_frame_i'].astype(int) - acdc_df = acdc_df.drop(columns='corrected_assignment') - + acdc_df = acdc_df.drop(columns="corrected_assignment") + return acdc_df + def _fix_will_divide(acdc_df): - """Resetting annotaions in GUI sometimes does not fully reset `will_divide` - column. Here we set `will_divide` back to 0 for those cells whose + """Resetting annotaions in GUI sometimes does not fully reset `will_divide` + column. Here we set `will_divide` back to 0 for those cells whose next generation does not exist (division was not annotated) Parameters ---------- acdc_df : pd.DataFrame - Annotations and metrics dataframe (from the `acdc_output` CSV file) + Annotations and metrics dataframe (from the `acdc_output` CSV file) with ['frame_i', 'Cell_ID'] as index Returns ------- pd.DataFrame acdc_df with `will_divide` corrected. - """ - if 'cell_cycle_stage' not in acdc_df.columns: + """ + if "cell_cycle_stage" not in acdc_df.columns: return acdc_df - - required_cols = ['frame_i', 'Cell_ID', 'generation_num', 'will_divide'] - - cca_df_mask = ~acdc_df['cell_cycle_stage'].isna() + + required_cols = ["frame_i", "Cell_ID", "generation_num", "will_divide"] + + cca_df_mask = ~acdc_df["cell_cycle_stage"].isna() cca_df = acdc_df[cca_df_mask].reset_index()[required_cols] - - IDs_will_divide_wrong = ( - cca_functions.get_IDs_gen_num_will_divide_wrong(cca_df) - ) + + IDs_will_divide_wrong = cca_functions.get_IDs_gen_num_will_divide_wrong(cca_df) if not IDs_will_divide_wrong: return acdc_df - - cca_df = cca_df.reset_index().set_index(['Cell_ID', 'generation_num']) - cca_df.loc[IDs_will_divide_wrong, 'will_divide'] = 0 + + cca_df = cca_df.reset_index().set_index(["Cell_ID", "generation_num"]) + cca_df.loc[IDs_will_divide_wrong, "will_divide"] = 0 cca_df = cca_df.reset_index() acdc_df = acdc_df.reset_index() - cca_df = cca_df.set_index(['frame_i', 'Cell_ID']) - acdc_df = acdc_df.set_index(['frame_i', 'Cell_ID']) - + cca_df = cca_df.set_index(["frame_i", "Cell_ID"]) + acdc_df = acdc_df.set_index(["frame_i", "Cell_ID"]) + cca_df_index = cca_df_mask[cca_df_mask].index - acdc_df.loc[cca_df_index, 'will_divide'] = cca_df['will_divide'] - + acdc_df.loc[cca_df_index, "will_divide"] = cca_df["will_divide"] + return acdc_df + def _add_missing_columns(acdc_df): - if 'is_cell_excluded' not in acdc_df.columns: - acdc_df['is_cell_excluded'] = 0 - - if 'is_cell_dead' not in acdc_df.columns: - acdc_df['is_cell_dead'] = 0 - - if 'cell_cycle_stage' not in acdc_df.columns: + if "is_cell_excluded" not in acdc_df.columns: + acdc_df["is_cell_excluded"] = 0 + + if "is_cell_dead" not in acdc_df.columns: + acdc_df["is_cell_dead"] = 0 + + if "cell_cycle_stage" not in acdc_df.columns: return acdc_df - - last_index_cca_df = acdc_df[['cell_cycle_stage']].last_valid_index() - + + last_index_cca_df = acdc_df[["cell_cycle_stage"]].last_valid_index() + for col, default in base_cca_dict.items(): - if col == 'will_divide': + if col == "will_divide": # Already taken care by _add_will_divide_column continue - + if col in acdc_df.columns: continue - + acdc_df[col] = np.nan acdc_df.loc[:last_index_cca_df, col] = default - + return acdc_df + def _ensure_acdc_df_latest_compatibility(acdc_df): acdc_df = _parse_loaded_acdc_df(acdc_df) acdc_df = _add_missing_columns(acdc_df) @@ -589,10 +619,11 @@ def _ensure_acdc_df_latest_compatibility(acdc_df): acdc_df = _fix_corrected_assignment_i(acdc_df) return acdc_df + def _parse_loaded_acdc_df(acdc_df): - acdc_df = acdc_df.set_index(['frame_i', 'Cell_ID']).sort_index() + acdc_df = acdc_df.set_index(["frame_i", "Cell_ID"]).sort_index() # remove duplicates saved by mistake or bugs - duplicated = acdc_df.index.duplicated(keep='first') + duplicated = acdc_df.index.duplicated(keep="first") acdc_df = acdc_df[~duplicated] acdc_df = pd_bool_and_float_to_int_to_str( acdc_df, acdc_df_bool_cols, colsToCastInt=[], inplace=True @@ -600,16 +631,17 @@ def _parse_loaded_acdc_df(acdc_df): acdc_df = pd_int_to_bool(acdc_df, acdc_df_bool_cols) return acdc_df + def _remove_redundant_columns(acdc_df): - acdc_df = acdc_df.drop(columns=['index', 'level_0'], errors='ignore') + acdc_df = acdc_df.drop(columns=["index", "level_0"], errors="ignore") return acdc_df + def read_acdc_df_csv(acdc_df_filepath, index_col=None): - acdc_df = pd.read_csv( - acdc_df_filepath, dtype=acdc_df_str_cols, index_col=index_col - ) + acdc_df = pd.read_csv(acdc_df_filepath, dtype=acdc_df_str_cols, index_col=index_col) return acdc_df + def _load_acdc_df_file(acdc_df_file_path): acdc_df = read_acdc_df_csv(acdc_df_file_path) acdc_df = _remove_redundant_columns(acdc_df) @@ -618,25 +650,25 @@ def _load_acdc_df_file(acdc_df_file_path): acdc_df[acdc_df_drop_cca.columns] = acdc_df_drop_cca except KeyError: pass - + acdc_df = _ensure_acdc_df_latest_compatibility(acdc_df) return acdc_df + def load_acdc_df_file( - images_path, - end_name_acdc_df_file='acdc_output', - return_path=False - ): - if not end_name_acdc_df_file.endswith('.csv'): - end_name_acdc_df_file = f'{end_name_acdc_df_file}.csv' - + images_path, end_name_acdc_df_file="acdc_output", return_path=False +): + if not end_name_acdc_df_file.endswith(".csv"): + end_name_acdc_df_file = f"{end_name_acdc_df_file}.csv" + found_files = [ - file for file in myutils.listdir(images_path) + file + for file in myutils.listdir(images_path) if file.endswith(end_name_acdc_df_file) ] if len(found_files) == 0: acdc_df = None - acdc_df_file_path = '' + acdc_df_file_path = "" elif len(found_files) == 1: acdc_df_file_path = os.path.join(images_path, found_files[0]) acdc_df = _load_acdc_df_file(acdc_df_file_path).reset_index() @@ -644,52 +676,52 @@ def load_acdc_df_file( found_files.sort(key=len) acdc_df_file_path = os.path.join(images_path, found_files[0]) acdc_df = _load_acdc_df_file(acdc_df_file_path).reset_index() - + if return_path: return acdc_df, acdc_df_file_path else: return acdc_df + def save_acdc_df_file( - acdc_df, csv_path, custom_annot_columns=None, - last_cca_frame_i=None - ): + acdc_df, csv_path, custom_annot_columns=None, last_cca_frame_i=None +): if custom_annot_columns is not None: new_order_cols = [*sorted_cols, *custom_annot_columns] else: new_order_cols = sorted_cols - + for col in new_order_cols.copy(): if col in acdc_df.columns: continue new_order_cols.remove(col) - + for col in acdc_df.columns: if col in new_order_cols: continue new_order_cols.append(col) - + acdc_df = acdc_df[new_order_cols] - + if last_cca_frame_i is not None: - max_frame_i = acdc_df.index.get_level_values('frame_i').max() + max_frame_i = acdc_df.index.get_level_values("frame_i").max() if last_cca_frame_i < max_frame_i: - acdc_df.loc[last_cca_frame_i+1:, cca_df_colnames] = pd.NA - + acdc_df.loc[last_cca_frame_i + 1 :, cca_df_colnames] = pd.NA + try: acdc_df.to_csv(csv_path) except Exception as err: - printl(f'[WARNING]: {err}') + printl(f"[WARNING]: {err}") return + def store_copy_acdc_df(posData, acdc_output_csv_path, log_func=printl): try: if not os.path.exists(acdc_output_csv_path): return - - df = ( - pd.read_csv(acdc_output_csv_path, dtype=acdc_df_str_cols) - .set_index(['frame_i', 'Cell_ID']) + + df = pd.read_csv(acdc_output_csv_path, dtype=acdc_df_str_cols).set_index( + ["frame_i", "Cell_ID"] ) posData.setTempPaths() zip_path = posData.acdc_output_backup_zip_path @@ -697,184 +729,182 @@ def store_copy_acdc_df(posData, acdc_output_csv_path, log_func=printl): except Exception as e: log_func(traceback.format_exc()) + def _copy_acdc_dfs_to_temp_archive( - zip_path, temp_zip_path, csv_names, compression_opts - ): - if not os.path.exists(zip_path): + zip_path, temp_zip_path, csv_names, compression_opts +): + if not os.path.exists(zip_path): return - - with zipfile.ZipFile(zip_path, mode='r') as zip: + + with zipfile.ZipFile(zip_path, mode="r") as zip: for csv_name in csv_names: with warnings.catch_warnings(): - warnings.simplefilter("ignore") - acdc_df = pd.read_csv( - zip.open(csv_name), dtype=acdc_df_str_cols - ) + warnings.simplefilter("ignore") + acdc_df = pd.read_csv(zip.open(csv_name), dtype=acdc_df_str_cols) acdc_df = _ensure_acdc_df_latest_compatibility(acdc_df) acdc_df = pd_bool_and_float_to_int_to_str(acdc_df, inplace=False) - compression_opts['archive_name'] = csv_name - acdc_df.to_csv( - temp_zip_path, compression=compression_opts - ) + compression_opts["archive_name"] = csv_name + acdc_df.to_csv(temp_zip_path, compression=compression_opts) + def _store_acdc_df_archive(zip_path, acdc_df_to_store): csv_names = [] if os.path.exists(zip_path): - with zipfile.ZipFile(zip_path, mode='r') as zip: + with zipfile.ZipFile(zip_path, mode="r") as zip: csv_names = natsorted(set(zip.namelist())) - + new_key = datetime.now().strftime(ISO_TIMESTAMP_FORMAT) - csv_name = f'{new_key}.csv' + csv_name = f"{new_key}.csv" if csv_name in csv_names: # Do not save duplicates within the same second return - + if len(csv_names) > 20: # Delete oldest df and resave remaining 19 csv_names.pop(0) - + zip_filename = os.path.basename(zip_path) - temp_zip_filename = zip_filename.replace('.csv', '_temp.csv') + temp_zip_filename = zip_filename.replace(".csv", "_temp.csv") temp_dirpath = tempfile.mkdtemp() temp_zip_path = os.path.join(temp_dirpath, temp_zip_filename) - compression_opts = {'method': 'zip', 'compresslevel': zipfile.ZIP_STORED} - _copy_acdc_dfs_to_temp_archive( - zip_path, temp_zip_path, csv_names, compression_opts - ) - - - compression_opts['archive_name'] = csv_name + compression_opts = {"method": "zip", "compresslevel": zipfile.ZIP_STORED} + _copy_acdc_dfs_to_temp_archive(zip_path, temp_zip_path, csv_names, compression_opts) + + compression_opts["archive_name"] = csv_name acdc_df = pd_bool_and_float_to_int_to_str(acdc_df_to_store, inplace=False) acdc_df.to_csv(temp_zip_path, compression=compression_opts) shutil.move(temp_zip_path, zip_path) shutil.rmtree(temp_dirpath) + def store_unsaved_acdc_df(recovery_folderpath, df, log_func=printl): new_key = datetime.now().strftime(ISO_TIMESTAMP_FORMAT) - csv_name = f'{new_key}.csv' - unsaved_recovery_folderpath = os.path.join( - recovery_folderpath, 'never_saved' - ) + csv_name = f"{new_key}.csv" + unsaved_recovery_folderpath = os.path.join(recovery_folderpath, "never_saved") if not os.path.exists(unsaved_recovery_folderpath): os.mkdir(unsaved_recovery_folderpath) - + files = myutils.listdir(unsaved_recovery_folderpath) - csv_files = [file for file in files if file.endswith('.csv')] + csv_files = [file for file in files if file.endswith(".csv")] if len(files) > 20: csv_files = natsorted(csv_files) files_to_remove = csv_files[:-20] for file_to_remove in files_to_remove: os.remove(os.path.join(unsaved_recovery_folderpath, file_to_remove)) - + csv_path = os.path.join(unsaved_recovery_folderpath, csv_name) df.to_csv(csv_path) + def get_last_stored_unsaved_acdc_df_filepath(recovery_folderpath): if not os.path.exists(recovery_folderpath): return - - unsaved_recovery_folderpath = os.path.join( - recovery_folderpath, 'never_saved' - ) + + unsaved_recovery_folderpath = os.path.join(recovery_folderpath, "never_saved") if not os.path.exists(unsaved_recovery_folderpath): return - + files = myutils.listdir(unsaved_recovery_folderpath) - csv_files = [file for file in files if file.endswith('.csv')] + csv_files = [file for file in files if file.endswith(".csv")] if not csv_files: return - + csv_files = natsorted(csv_files) csv_name = csv_files[-1] - + return os.path.join(unsaved_recovery_folderpath, csv_name) + def get_last_stored_unsaved_acdc_df(recovery_folderpath): if not os.path.exists(recovery_folderpath): return - - unsaved_recovery_folderpath = os.path.join( - recovery_folderpath, 'never_saved' - ) + + unsaved_recovery_folderpath = os.path.join(recovery_folderpath, "never_saved") if not os.path.exists(unsaved_recovery_folderpath): return - + files = myutils.listdir(unsaved_recovery_folderpath) - csv_files = [file for file in files if file.endswith('.csv')] + csv_files = [file for file in files if file.endswith(".csv")] if not csv_files: return - + csv_files = natsorted(csv_files) csv_name = csv_files[-1] acdc_df = pd.read_csv(os.path.join(unsaved_recovery_folderpath, csv_name)) acdc_df = _ensure_acdc_df_latest_compatibility(acdc_df) - + return acdc_df + def read_acdc_df_from_archive(archive_path, key): - if not key.endswith('.csv'): - csv_name = f'{key}.csv' + if not key.endswith(".csv"): + csv_name = f"{key}.csv" else: csv_name = key - - if archive_path.endswith('.zip'): - with zipfile.ZipFile(archive_path, 'r') as zip: + + if archive_path.endswith(".zip"): + with zipfile.ZipFile(archive_path, "r") as zip: acdc_df = pd.read_csv(zip.open(csv_name)) else: - csv_path = os.path.join(archive_path, f'{key}.csv') + csv_path = os.path.join(archive_path, f"{key}.csv") acdc_df = pd.read_csv(csv_path) - + acdc_df = _ensure_acdc_df_latest_compatibility(acdc_df) return acdc_df + def get_user_ch_paths(images_paths, user_ch_name): user_ch_file_paths = [] for images_path in images_paths: img_aligned_found = False for filename in myutils.listdir(images_path): - if filename.find(f'{user_ch_name}_aligned.np') != -1: - img_path_aligned = f'{images_path}/{filename}' + if filename.find(f"{user_ch_name}_aligned.np") != -1: + img_path_aligned = f"{images_path}/{filename}" img_aligned_found = True - elif filename.find(f'{user_ch_name}.tif') != -1: - img_path_tif = f'{images_path}/{filename}' + elif filename.find(f"{user_ch_name}.tif") != -1: + img_path_tif = f"{images_path}/{filename}" if img_aligned_found: img_path = img_path_aligned else: img_path = img_path_tif user_ch_file_paths.append(img_path) - print(f'Loading {img_path}...') + print(f"Loading {img_path}...") return user_ch_file_paths + def get_acdc_output_files(images_path): ls = myutils.listdir(images_path) acdc_output_files = [ - file for file in ls - if file.find('acdc_output') != -1 and file.endswith('.csv') + file for file in ls if file.find("acdc_output") != -1 and file.endswith(".csv") ] return acdc_output_files + def get_segm_files(images_path): ls = myutils.listdir(images_path) segm_files = [ - file for file in ls if file.endswith('segm.npz') - or file.find('segm_raw_postproc') != -1 - or file.endswith('segm_raw.npz') - or (file.endswith('.npz') and file.find('segm') != -1) - or file.endswith('_segm.npy') + file + for file in ls + if file.endswith("segm.npz") + or file.find("segm_raw_postproc") != -1 + or file.endswith("segm_raw.npz") + or (file.endswith(".npz") and file.find("segm") != -1) + or file.endswith("_segm.npy") ] - return segm_files + return segm_files + def get_segm_endnames_from_exp_path(exp_path, pos_foldernames=None): if pos_foldernames is None: pos_foldernames = myutils.get_pos_foldernames(exp_path) - + existingEndNames = set() for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") basename, chNames = myutils.getBasenameAndChNames(images_path) # Use first found channel, it doesn't matter for metrics for chName in chNames: @@ -889,53 +919,58 @@ def get_segm_endnames_from_exp_path(exp_path, pos_foldernames=None): _posData = loadData(filePath, chName) _posData.getBasenameAndChNames() found_files = get_segm_files(_posData.images_path) - _existingEndnames = get_endnames( - _posData.basename, found_files - ) + _existingEndnames = get_endnames(_posData.basename, found_files) existingEndNames.update(_existingEndnames) - + return existingEndNames -def get_files_with(images_path: os.PathLike, with_text: str, ext: str=None): + +def get_files_with(images_path: os.PathLike, with_text: str, ext: str = None): ls = myutils.listdir(images_path) found_files = [] for file in ls: if file.find(with_text) == -1: continue - + if ext is not None and not file.endswith(ext): continue - + found_files.append(file) - + return found_files + def load_segmInfo_df(pos_path): - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") for file in myutils.listdir(images_path): - if file.endswith('segmInfo.csv'): + if file.endswith("segmInfo.csv"): csv_path = os.path.join(images_path, file) df = pd.read_csv(csv_path) - df = df.set_index(['filename', 'frame_i']).sort_index() + df = df.set_index(["filename", "frame_i"]).sort_index() df = df[~df.index.duplicated()] return df + def get_filename_from_channel( - images_path, channel_name, not_allowed_ends=None, logger=None, - basename=None, skip_channels=None - ): + images_path, + channel_name, + not_allowed_ends=None, + logger=None, + basename=None, + skip_channels=None, +): if not_allowed_ends is None: not_allowed_ends = tuple() if skip_channels is None: skip_channels = tuple() if basename is None: - basename = '' - - channel_filepath = '' - h5_aligned_path = '' - h5_path = '' - npz_aligned_path = '' - tif_path = '' + basename = "" + + channel_filepath = "" + h5_aligned_path = "" + h5_path = "" + npz_aligned_path = "" + tif_path = "" for file in myutils.listdir(images_path): isValidEnd = True for not_allowed_end in not_allowed_ends: @@ -944,101 +979,107 @@ def get_filename_from_channel( break if not isValidEnd: continue - + is_channel_to_skip = False for channel_to_skip in skip_channels: for ff in channel_file_formats: - if file.endswith(f'{basename}{channel_to_skip}{ff}'): + if file.endswith(f"{basename}{channel_to_skip}{ff}"): is_channel_to_skip = channel_name not in file break if is_channel_to_skip: break - + if is_channel_to_skip: continue channelDataPath = os.path.join(images_path, file) - if file == f'{basename}{channel_name}': + if file == f"{basename}{channel_name}": channel_filepath = channelDataPath - elif file.endswith(f'{basename}{channel_name}_aligned.h5'): + elif file.endswith(f"{basename}{channel_name}_aligned.h5"): h5_aligned_path = channelDataPath - elif file.endswith(f'{basename}{channel_name}.h5'): + elif file.endswith(f"{basename}{channel_name}.h5"): h5_path = channelDataPath - elif file.endswith(f'{basename}{channel_name}_aligned.npz'): + elif file.endswith(f"{basename}{channel_name}_aligned.npz"): npz_aligned_path = channelDataPath - elif file.endswith(f'{basename}{channel_name}.tif'): + elif file.endswith(f"{basename}{channel_name}.tif"): tif_path = channelDataPath - + if channel_filepath: if logger is not None: - logger(f'Using channel file ({channel_filepath})...') + logger(f"Using channel file ({channel_filepath})...") return channel_filepath elif h5_aligned_path: if logger is not None: - logger(f'Using .h5 aligned file ({h5_aligned_path})...') + logger(f"Using .h5 aligned file ({h5_aligned_path})...") return h5_aligned_path elif h5_path: if logger is not None: - logger(f'Using .h5 file ({h5_path})...') + logger(f"Using .h5 file ({h5_path})...") return h5_path elif npz_aligned_path: if logger is not None: - logger(f'Using .npz aligned file ({npz_aligned_path})...') + logger(f"Using .npz aligned file ({npz_aligned_path})...") return npz_aligned_path elif tif_path: if logger is not None: - logger(f'Using .tif file ({tif_path})...') + logger(f"Using .tif file ({tif_path})...") return tif_path else: - return '' + return "" + def imread(path): - if path.endswith('.tif') or path.endswith('.tiff'): + if path.endswith(".tif") or path.endswith(".tiff"): return tifffile.imread(path) else: return skimage.io.imread(path) + def load_image_file(filepath): - if filepath.endswith('.h5'): - with h5py.File(filepath, 'r') as h5f: - img_data = h5f['data'][()] - elif filepath.endswith('.npz'): + if filepath.endswith(".h5"): + with h5py.File(filepath, "r") as h5f: + img_data = h5f["data"][()] + elif filepath.endswith(".npz"): with np.load(filepath) as archive: files = archive.files img_data = archive[files[0]] - elif filepath.endswith('.npy'): + elif filepath.endswith(".npy"): img_data = np.load(filepath) else: img_data = imread(filepath) return np.squeeze(img_data) + def load_image_data_from_channel(images_path: os.PathLike, channel_name: str): filepath = get_filename_from_channel(images_path, channel_name) return load_image_file(filepath) + def get_endnames(basename, files): endnames = [] for f in files: filename, _ = os.path.splitext(f) - endname = filename[len(basename):] + endname = filename[len(basename) :] endnames.append(endname) return endnames + def get_filepath_from_endname(images_path, endname): channel_filepath = get_filename_from_channel(images_path, endname) if channel_filepath: return channel_filepath - + for file in myutils.listdir(images_path): if file.endswith(endname): - return os.path.join(images_path, file) + return os.path.join(images_path, file) for file in myutils.listdir(images_path): file_noext, ext = os.path.splitext(file) if file_noext.endswith(endname): - return os.path.join(images_path, file) - - return '' + return os.path.join(images_path, file) + + return "" + def get_exp_path(path): folder_type = myutils.determine_folder_type(path) @@ -1051,60 +1092,64 @@ def get_exp_path(path): exp_path = path return exp_path + def get_endname_from_channels(filename, channels): endname = None for ch in channels: - ch_aligned = f'{ch}_aligned' - m = re.search(fr'{ch}(.\w+)*$', filename) - m_aligned = re.search(fr'{ch_aligned}(.\w+)*$', filename) + ch_aligned = f"{ch}_aligned" + m = re.search(rf"{ch}(.\w+)*$", filename) + m_aligned = re.search(rf"{ch_aligned}(.\w+)*$", filename) if m_aligned is not None: return endname elif m is not None: return endname + def get_endname_from_filepath(filepath, allow_empty=False): parent_folderpath = os.path.dirname(filepath) - if not parent_folderpath.endswith('Images'): - return - + if not parent_folderpath.endswith("Images"): + return + filename = os.path.basename(filepath) filename_noext, ext = os.path.splitext(filename) images_files = myutils.listdir(parent_folderpath) basename = os.path.commonprefix(images_files) - endname = filename_noext[len(basename):] + endname = filename_noext[len(basename) :] if not endname: - endname = basename.split('_')[-1] - + endname = basename.split("_")[-1] + return endname - + def get_endnames_from_basename(basename, filenames): - return [os.path.splitext(f)[0][len(basename):] for f in filenames] + return [os.path.splitext(f)[0][len(basename) :] for f in filenames] + def get_path_from_endname(end_name, images_path, ext=None): if ext is None: end_name, ext = myutils.remove_known_extension(end_name) - - if os.path.exists(os.path.join(images_path, f'{end_name}{ext}')): - return os.path.join(images_path, f'{end_name}{ext}') - + + if os.path.exists(os.path.join(images_path, f"{end_name}{ext}")): + return os.path.join(images_path, f"{end_name}{ext}") + basename = os.path.commonprefix(myutils.listdir(images_path)) - searched_file = f'{basename}{end_name}{ext}' + searched_file = f"{basename}{end_name}{ext}" for file in myutils.listdir(images_path): filename, ext = os.path.splitext(file) if file == searched_file: return os.path.join(images_path, file), file elif filename == searched_file: return os.path.join(images_path, file), file - + for file in myutils.listdir(images_path): filename, ext = os.path.splitext(file) if file.endswith(end_name): return os.path.join(images_path, file), file elif filename.endswith(end_name): return os.path.join(images_path, file), file - - return '', '' + + return "", "" + def pd_int_to_bool(acdc_df, colsToCast=None): if colsToCast is None: @@ -1116,15 +1161,12 @@ def pd_int_to_bool(acdc_df, colsToCast=None): continue return acdc_df + def pd_bool_and_float_to_int_to_str( - acdc_df, - colsToCastBool=None, - colsToCastInt=None, - csv_path=None, - inplace=True - ): - """Converts boolean columns to 0s and 1s, float columns to integers, - and then to "string" to ensure smooth saving to CSV. Save to CSV if + acdc_df, colsToCastBool=None, colsToCastInt=None, csv_path=None, inplace=True +): + """Converts boolean columns to 0s and 1s, float columns to integers, + and then to "string" to ensure smooth saving to CSV. Save to CSV if `csv_path` is not None. Parameters @@ -1147,30 +1189,24 @@ def pd_bool_and_float_to_int_to_str( """ if not inplace: acdc_df = acdc_df.copy() - + if colsToCastBool is None: colsToCastBool = acdc_df_bool_cols - + if colsToCastInt is None: colsToCastInt = acdc_df_int_cols additional_sister_cols = [ - col for col in acdc_df.columns if col.startswith('sister_ID_tree') + col for col in acdc_df.columns if col.startswith("sister_ID_tree") ] - additional_sister_cols = { - col: int for col in additional_sister_cols - } + additional_sister_cols = {col: int for col in additional_sister_cols} colsToCastInt = ({**colsToCastInt, **additional_sister_cols}).keys() - + for col in colsToCastInt: try: series = acdc_df[col] notna_idx = series.notna() acdc_df[col] = ( - acdc_df[col] - .astype(float) - .fillna(0) - .astype(int) - .astype("string") + acdc_df[col].astype(float).fillna(0).astype(int).astype("string") ) acdc_df.loc[~notna_idx, col] = "" except KeyError: @@ -1178,20 +1214,20 @@ def pd_bool_and_float_to_int_to_str( except Exception as e: printl(col) traceback.print_exc() - + for col in colsToCastBool: try: series = acdc_df[col] - notna_idx = (series.notna()) & (series != '') + notna_idx = (series.notna()) & (series != "") notna_series = series.loc[notna_idx] dtype_id = None for dtype_id, dtype_checker in acdc_df_dtype_id_checker_mapper.items(): if dtype_checker(notna_series): break - + if dtype_id is None: break - + casting_func = acdc_df_dtype_id_func_mapper[dtype_id] acdc_df = casting_func(acdc_df, col, notna_idx) except KeyError: @@ -1199,82 +1235,85 @@ def pd_bool_and_float_to_int_to_str( except Exception as e: printl(col) traceback.print_exc() - + if csv_path is not None: acdc_df.to_csv(csv_path) - + return acdc_df + def parse_metadata_csv_file(csv_filepath): - with open(csv_filepath, 'r') as file: + with open(csv_filepath, "r") as file: txt = file.read() - - lines = txt.split('\n') + + lines = txt.split("\n") for l, line in enumerate(lines.copy()): - is_channel_name_line = re.search(r'channel_\d+_name', line) - if line.startswith('basename') or is_channel_name_line: - parts = line.split(',') + is_channel_name_line = re.search(r"channel_\d+_name", line) + if line.startswith("basename") or is_channel_name_line: + parts = line.split(",") if len(parts) == 2: continue - + if parts[1].startswith('"') and parts[-1].endswith('"'): continue - + quoted_value = f'"{"".join(parts[1:])}"' - parsed_line = f'{parts[0]},{quoted_value}' + parsed_line = f"{parts[0]},{quoted_value}" lines[l] = parsed_line - - with open(csv_filepath, 'w') as file: - file.write('\n'.join(lines)) + + with open(csv_filepath, "w") as file: + file.write("\n".join(lines)) + def get_posData_metadata(images_path, basename): # First check if metadata.csv already has the channel names for file in myutils.listdir(images_path): - if file.endswith('metadata.csv'): + if file.endswith("metadata.csv"): metadata_csv_path = os.path.join(images_path, file) parse_metadata_csv_file(metadata_csv_path) - df_metadata = pd.read_csv(metadata_csv_path).set_index('Description') + df_metadata = pd.read_csv(metadata_csv_path).set_index("Description") break else: - df_metadata = ( - pd.DataFrame( - columns=['Description', 'values']).set_index('Description') - ) - if basename.endswith('_'): + df_metadata = pd.DataFrame(columns=["Description", "values"]).set_index( + "Description" + ) + if basename.endswith("_"): basename = basename[:-1] - metadata_csv_path = os.path.join(images_path, f'{basename}_metadata.csv') + metadata_csv_path = os.path.join(images_path, f"{basename}_metadata.csv") return df_metadata, metadata_csv_path + def is_pos_prepped(images_path): filenames = myutils.listdir(images_path) for filename in filenames: - if filename.endswith('dataPrepROIs_coords.csv'): + if filename.endswith("dataPrepROIs_coords.csv"): return True - elif filename.endswith('dataPrep_bkgrROIs.json'): + elif filename.endswith("dataPrep_bkgrROIs.json"): return True - elif filename.endswith('aligned.npz'): + elif filename.endswith("aligned.npz"): return True - elif filename.endswith('align_shift.npy'): + elif filename.endswith("align_shift.npy"): return True - elif filename.endswith('bkgrRoiData.npz'): + elif filename.endswith("bkgrRoiData.npz"): return True return False + def is_bkgrROIs_present(images_path): filenames = myutils.listdir(images_path) for filename in filenames: - if filename.endswith('dataPrep_bkgrROIs.json'): + if filename.endswith("dataPrep_bkgrROIs.json"): return True - elif filename.endswith('bkgrRoiData.npz'): + elif filename.endswith("bkgrRoiData.npz"): return True return False + class loadData: def __init__( - self, imgPath, user_ch_name, - relPathDepth=3, QParent=None, log_func=None - ): + self, imgPath, user_ch_name, relPathDepth=3, QParent=None, log_func=None + ): self.fluo_data_dict = {} self.fluo_bkgrData_dict = {} self.bkgrROIs = [] @@ -1285,7 +1324,7 @@ def __init__( self.images_path = os.path.dirname(imgPath) self.images_folder_files = os.listdir(self.images_path) self.pos_path = os.path.dirname(self.images_path) - self.spotmax_out_path = os.path.join(self.pos_path, 'spotMAX_output') + self.spotmax_out_path = os.path.join(self.pos_path, "spotMAX_output") self.exp_path = os.path.dirname(self.pos_path) self.pos_foldername = os.path.basename(self.pos_path) self.pos_num = self.getPosNum() @@ -1298,75 +1337,75 @@ def __init__( self.frame_i = 0 self.clickEntryPointsDfs = {} path_li = os.path.normpath(imgPath).split(os.sep) - self.relPath = f'{f"{os.sep}".join(path_li[-relPathDepth:])}' + self.relPath = f"{f'{os.sep}'.join(path_li[-relPathDepth:])}" filename_ext = os.path.basename(imgPath) self.filename_ext = filename_ext self.filename, self.ext = os.path.splitext(filename_ext) self._additionalMetadataValues = None self.loadLastEntriesMetadata() self.attempFixBasenameBug() - self.non_aligned_ext = '.tif' - if filename_ext.endswith('aligned.npz'): + self.non_aligned_ext = ".tif" + if filename_ext.endswith("aligned.npz"): for file in myutils.listdir(self.images_path): - if file.endswith(f'{user_ch_name}.h5'): - self.non_aligned_ext = '.h5' + if file.endswith(f"{user_ch_name}.h5"): + self.non_aligned_ext = ".h5" break self.tracked_lost_centroids = None - if not hasattr(self, 'whitelist'): + if not hasattr(self, "whitelist"): self.whitelist = None self.log_func = log_func def attempFixBasenameBug(self): - r'''Attempt removing _s(\d+)_ from filenames if not present in basename - + r"""Attempt removing _s(\d+)_ from filenames if not present in basename + This was a bug introduced when saving the basename with data structure, it was not saving the _s(\d+)_ part. - ''' + """ try: ls = myutils.listdir(self.images_path) for file in ls: - if file.endswith('metadata.csv'): + if file.endswith("metadata.csv"): metadata_csv_path = os.path.join(self.images_path, file) break else: return - + parse_metadata_csv_file(metadata_csv_path) - df_metadata = pd.read_csv(metadata_csv_path).set_index('Description') + df_metadata = pd.read_csv(metadata_csv_path).set_index("Description") try: - basename = df_metadata.at['basename', 'values'] + basename = df_metadata.at["basename", "values"] except Exception as e: return - + numPos = len(myutils.get_pos_foldernames(self.exp_path)) numPosDigits = len(str(numPos)) - s0p = str(self.pos_num+1).zfill(numPosDigits) + s0p = str(self.pos_num + 1).zfill(numPosDigits) - if basename.endswith(f'_s{s0p}_'): + if basename.endswith(f"_s{s0p}_"): return - + for file in ls: - endname = file[len(basename):] - if not endname.startswith(f's{s0p}_'): + endname = file[len(basename) :] + if not endname.startswith(f"s{s0p}_"): continue - fixed_endname = endname[len(f's{s0p}_'):] - fixed_filename = f'{basename}{fixed_endname}' + fixed_endname = endname[len(f"s{s0p}_") :] + fixed_filename = f"{basename}{fixed_endname}" fixed_filepath = os.path.join(self.images_path, fixed_filename) filepath = os.path.join(self.images_path, file) - hidden_filepath = os.path.join(self.images_path, f'.{file}') + hidden_filepath = os.path.join(self.images_path, f".{file}") shutil.copy2(filepath, fixed_filepath) try: os.rename(filepath, hidden_filepath) except Exception as e: pass - + except Exception as e: traceback.print_exc() - + def isPrepped(self): return is_pos_prepped(self.images_path) - + def isBkgrROIpresent(self): return is_bkgrROIs_present(self.images_path) @@ -1375,9 +1414,9 @@ def setLoadedChannelNames(self, returnList=False): loadedChNames = [] for key in fluo_keys: - chName = key[len(self.basename):] - if chName.endswith('_aligned'): - aligned_idx = chName.find('_aligned') + chName = key[len(self.basename) :] + if chName.endswith("_aligned"): + aligned_idx = chName.find("_aligned") chName = chName[:aligned_idx] loadedChNames.append(chName) @@ -1388,7 +1427,7 @@ def setLoadedChannelNames(self, returnList=False): def getPosNum(self): try: - pos_num = int(re.findall(r'Position_(\d+)', self.pos_foldername))[0] + pos_num = int(re.findall(r"Position_(\d+)", self.pos_foldername))[0] except Exception: pos_num = 0 return pos_num @@ -1397,76 +1436,75 @@ def loadLastEntriesMetadata(self): if not os.path.exists(settings_folderpath): self.last_md_df = None return - csv_path = os.path.join(settings_folderpath, 'last_entries_metadata.csv') + csv_path = os.path.join(settings_folderpath, "last_entries_metadata.csv") if not os.path.exists(csv_path): self.last_md_df = None else: parse_metadata_csv_file(csv_path) - self.last_md_df = pd.read_csv(csv_path).set_index('Description') + self.last_md_df = pd.read_csv(csv_path).set_index("Description") def saveLastEntriesMetadata(self): if not os.path.exists(settings_folderpath): return self.metadata_df.to_csv(last_entries_metadata_path) - + def getCustomAnnotColumnNames(self): - if not hasattr(self, 'customAnnot'): - return - + if not hasattr(self, "customAnnot"): + return + return natsorted(self.customAnnot.keys()) - + def saveCustomAnnotationParams(self): - if not hasattr(self, 'customAnnot'): - return - + if not hasattr(self, "customAnnot"): + return + if not self.customAnnot: return - - with open(self.custom_annot_json_path, mode='w') as file: + + with open(self.custom_annot_json_path, mode="w") as file: json.dump(self.customAnnot, file, indent=2) def addYXcentroidColsIfMissing(self, show_progress=False): if not self.segmFound: return - + if not self.acdc_df_found: return - + is_centroid_present = ( - 'y_centroid' in self.acdc_df.columns - and 'x_centroid' in self.acdc_df.columns + "y_centroid" in self.acdc_df.columns + and "x_centroid" in self.acdc_df.columns ) if is_centroid_present: return - + segm_data = self.segm_data if self.SizeT == 1: segm_data = (segm_data,) - - last_frame_i = self.acdc_df.reset_index()['frame_i'].max() + + last_frame_i = self.acdc_df.reset_index()["frame_i"].max() if show_progress: pbar = tqdm( - total=last_frame_i+1, - desc='Adding centroid columns to acdc_df' + total=last_frame_i + 1, desc="Adding centroid columns to acdc_df" ) - - for frame_i in range(last_frame_i+1): + + for frame_i in range(last_frame_i + 1): lab = segm_data[frame_i] rp = skimage.measure.regionprops(lab) for obj in rp: ID = obj.label y_centroid, x_centroid = obj.centroid[-2:] - self.acdc_df.loc[(frame_i, ID), 'y_centroid'] = y_centroid - self.acdc_df.loc[(frame_i, ID), 'x_centroid'] = x_centroid - + self.acdc_df.loc[(frame_i, ID), "y_centroid"] = y_centroid + self.acdc_df.loc[(frame_i, ID), "x_centroid"] = x_centroid + if show_progress: pbar.update(1) - + if show_progress: pbar.close() - + self.acdc_df.to_csv(self.acdc_output_csv_path) - + def getBasenameAndChNames(self, useExt=None, qparent=None): ls = myutils.listdir(self.images_path) selector = prompts.select_channel_name() @@ -1479,41 +1517,39 @@ def getBasenameAndChNames(self, useExt=None, qparent=None): filename, _ = os.path.splitext(file) if filename != self.basename: continue - - sep = '*'*100 + + sep = "*" * 100 error_text = ( f'The file "{file}" has the same name as ' - f'the basename of all other files.\n\n' - f'Please, rename the file to include something ' + f"the basename of all other files.\n\n" + f"Please, rename the file to include something " f'after "{self.basename}", e.g., "{self.basename}_channel_name".' ) if qparent is not None: - html_error_text = f'[WARNING]: {error_text}' - html_error_text = html_error_text.replace('\n', '
    ') + html_error_text = f"[WARNING]: {error_text}" + html_error_text = html_error_text.replace("\n", "
    ") html_error_text = ( - html_error_text.replace( - f'"{file}"', f'{file}' - ).replace( - f'"{self.basename}"', f'{self.basename}' - ).replace( - f'"{self.basename}_channel_name"', - f'{self.basename}_channel_name' + html_error_text.replace(f'"{file}"', f"{file}") + .replace(f'"{self.basename}"', f"{self.basename}") + .replace( + f'"{self.basename}_channel_name"', + f"{self.basename}_channel_name", ) ) html_error_text = html_utils.paragraph(html_error_text) msg = widgets.myMessageBox(wrapText=False) - msg.warning(qparent, 'Rename files', html_error_text) - - raise FileNameError(f'\n\n{sep}\n[ERROR]: {error_text}') + msg.warning(qparent, "Rename files", html_error_text) + + raise FileNameError(f"\n\n{sep}\n[ERROR]: {error_text}") def loadImgData(self, imgPath=None, signals=None): if imgPath is None: imgPath = self.imgPath self.z0_window = 0 self.t0_window = 0 - if self.ext == '.h5': - self.h5f = h5py.File(imgPath, 'r') - self.dset = self.h5f['data'] + if self.ext == ".h5": + self.h5f = h5py.File(imgPath, "r") + self.dset = self.h5f["data"] self.img_data_shape = self.dset.shape readH5 = self.loadSizeT is not None and self.loadSizeZ is not None if not readH5: @@ -1524,33 +1560,33 @@ def loadImgData(self, imgPath=None, signals=None): is3Dt = self.SizeZ == 1 and self.SizeT > 1 is2D = self.SizeZ == 1 and self.SizeT == 1 if is4D: - midZ = int(self.SizeZ/2) - halfZLeft = int(self.loadSizeZ/2) - halfZRight = self.loadSizeZ-halfZLeft - z0 = midZ-halfZLeft - z1 = midZ+halfZRight + midZ = int(self.SizeZ / 2) + halfZLeft = int(self.loadSizeZ / 2) + halfZRight = self.loadSizeZ - halfZLeft + z0 = midZ - halfZLeft + z1 = midZ + halfZRight self.z0_window = z0 self.t0_window = 0 - self.img_data = self.dset[:self.loadSizeT, z0:z1] + self.img_data = self.dset[: self.loadSizeT, z0:z1] elif is3Dz: - midZ = int(self.SizeZ/2) - halfZLeft = int(self.loadSizeZ/2) - halfZRight = self.loadSizeZ-halfZLeft - z0 = midZ-halfZLeft - z1 = midZ+halfZRight + midZ = int(self.SizeZ / 2) + halfZLeft = int(self.loadSizeZ / 2) + halfZRight = self.loadSizeZ - halfZLeft + z0 = midZ - halfZLeft + z1 = midZ + halfZRight self.z0_window = z0 self.img_data = np.squeeze(self.dset[z0:z1]) elif is3Dt: self.t0_window = 0 - self.img_data = np.squeeze(self.dset[:self.loadSizeT]) + self.img_data = np.squeeze(self.dset[: self.loadSizeT]) elif is2D: self.img_data = np.squeeze(self.dset[:]) - elif self.ext == '.npz': - self.img_data = np.squeeze(np.load(imgPath)['arr_0']) + elif self.ext == ".npz": + self.img_data = np.squeeze(np.load(imgPath)["arr_0"]) self.dset = self.img_data self.img_data_shape = self.img_data.shape - elif self.ext == '.npy': + elif self.ext == ".npy": self.img_data = np.squeeze(np.load(imgPath)) self.dset = self.img_data self.img_data_shape = self.img_data.shape @@ -1562,7 +1598,7 @@ def loadImgData(self, imgPath=None, signals=None): except Exception as err: traceback.print_exc() self.criticalExtNotValid(signals=signals) - elif self.ext in VIDEO_EXTENSIONS: + elif self.ext in VIDEO_EXTENSIONS: try: self.img_data = self._loadVideo(imgPath) self.dset = self.img_data @@ -1572,11 +1608,11 @@ def loadImgData(self, imgPath=None, signals=None): self.criticalExtNotValid(signals=signals) else: self.criticalExtNotValid(signals=signals) - + def loadChannelData(self, channelName): if channelName == self.user_ch_name: return self.img_data - + dataPath = get_filename_from_channel(self.images_path, channelName) if dataPath: data = load_image_file(dataPath) @@ -1586,20 +1622,17 @@ def loadChannelData(self, channelName): def init_segmInfo_df(self): if self.SizeZ > 1 and self.segmInfo_df is not None: - if 'z_slice_used_gui' not in self.segmInfo_df.columns: - self.segmInfo_df['z_slice_used_gui'] = ( - self.segmInfo_df['z_slice_used_dataPrep'] - ) - if 'which_z_proj_gui' not in self.segmInfo_df.columns: - self.segmInfo_df['which_z_proj_gui'] = ( - self.segmInfo_df['which_z_proj'] - ) - self.segmInfo_df['resegmented_in_gui'] = False + if "z_slice_used_gui" not in self.segmInfo_df.columns: + self.segmInfo_df["z_slice_used_gui"] = self.segmInfo_df[ + "z_slice_used_dataPrep" + ] + if "which_z_proj_gui" not in self.segmInfo_df.columns: + self.segmInfo_df["which_z_proj_gui"] = self.segmInfo_df["which_z_proj"] + self.segmInfo_df["resegmented_in_gui"] = False self.segmInfo_df.to_csv(self.segmInfo_df_csv_path) NO_segmInfo = ( - self.segmInfo_df is None - or self.filename not in self.segmInfo_df.index + self.segmInfo_df is None or self.filename not in self.segmInfo_df.index ) if NO_segmInfo and self.SizeZ > 1: filename = self.filename @@ -1611,7 +1644,7 @@ def init_segmInfo_df(self): unique_idx = ~self.segmInfo_df.index.duplicated() self.segmInfo_df = self.segmInfo_df[unique_idx] self.segmInfo_df.to_csv(self.segmInfo_df_csv_path) - + def _loadVideo(self, path): video = cv2.VideoCapture(path) num_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) @@ -1626,247 +1659,243 @@ def _loadVideo(self, path): def countObjectsInSegmTimelapse(self, categories: set[str] | list[str]): numObjsCurrentFrame = len(self.IDs) - + uniqueIDsVisited = None uniqueIDsAll = None numObjsVisitedFrames = None numObjsTotal = None - if 'Unique objects in all visited frames' in categories: + if "Unique objects in all visited frames" in categories: uniqueIDsVisited = set() - - if 'Unique objects in entire video' in categories: + + if "Unique objects in entire video" in categories: uniqueIDsAll = set() - - if 'In all visited frames' in categories: + + if "In all visited frames" in categories: numObjsVisitedFrames = 0 - - if 'In entire video' in categories: + + if "In entire video" in categories: numObjsTotal = 0 - + for frame_i in range(len(self.segm_data)): - lab = self.allData_li[frame_i]['labels'] + lab = self.allData_li[frame_i]["labels"] if lab is not None: - IDsFrame = self.allData_li[frame_i]['IDs'] - + IDsFrame = self.allData_li[frame_i]["IDs"] + if uniqueIDsVisited is not None: uniqueIDsVisited.update(IDsFrame) - + if uniqueIDsAll is not None: uniqueIDsAll.update(IDsFrame) - + numObjsFrame = len(IDsFrame) - + if numObjsVisitedFrames is not None: numObjsVisitedFrames += numObjsFrame - + if numObjsTotal is not None: numObjsTotal += numObjsFrame else: lab = self.segm_data[frame_i] - + if numObjsTotal is not None or numObjsTotal is not None: rp = skimage.measure.regionprops(self.segm_data[frame_i]) - + if numObjsTotal is not None: numObjsTotal += len(rp) - + if uniqueIDsAll is not None: uniqueIDsAll.update([obj.label for obj in rp]) - + numUniqueObjsVisitedFrames = None if uniqueIDsVisited is not None: numUniqueObjsVisitedFrames = len(uniqueIDsVisited) - + numUniqueObjsTotal = None if uniqueIDsAll is not None: numUniqueObjsTotal = len(uniqueIDsAll) - + allCategoryCountMapper = { - 'In current frame': numObjsCurrentFrame, - 'In all visited frames': numObjsVisitedFrames, - 'In entire video': numObjsTotal, - 'Unique objects in all visited frames': numUniqueObjsVisitedFrames, - 'Unique objects in entire video': numUniqueObjsTotal + "In current frame": numObjsCurrentFrame, + "In all visited frames": numObjsVisitedFrames, + "In entire video": numObjsTotal, + "Unique objects in all visited frames": numUniqueObjsVisitedFrames, + "Unique objects in entire video": numUniqueObjsTotal, } - + return allCategoryCountMapper - + def countObjectsInSegmSnapshots(self, categories: set[str] | list[str]): - if hasattr(self, 'IDs'): + if hasattr(self, "IDs"): numObjs = len(self.IDs) else: lab = np.squeeze(self.segm_data) rp = skimage.measure.regionprops(lab) numObjs = len(rp) - - mapper = { - 'In current position': numObjs - } - + + mapper = {"In current position": numObjs} + return mapper - - def countObjectsInSegm(self, categories: set[str] | list[str] | None=None): + + def countObjectsInSegm(self, categories: set[str] | list[str] | None = None): if self.SizeT > 1: if categories is None: - categories = ['In entire video'] - + categories = ["In entire video"] + return self.countObjectsInSegmTimelapse(categories) else: if categories is None: - categories = ['In current position'] - + categories = ["In current position"] + return self.countObjectsInSegmSnapshots(categories) - + def saveObjCounts(self, countMapper: dict[str, int]): df = pd.DataFrame(countMapper, index=[0]) segmFilename = os.path.basename(self.segm_npz_path) - segmEndname = segmFilename[len(self.basename):] - dfCountEndname = ( - segmEndname - .replace('segm', 'acdc_objects_count') - .replace('.npz', '.csv') + segmEndname = segmFilename[len(self.basename) :] + dfCountEndname = segmEndname.replace("segm", "acdc_objects_count").replace( + ".npz", ".csv" ) - - dfCountFilename = f'{self.basename}{dfCountEndname}' + + dfCountFilename = f"{self.basename}{dfCountEndname}" dfCountFilepath = os.path.join(self.images_path, dfCountFilename) - + df.to_csv(dfCountFilepath, index=False) - + return dfCountEndname - - + def detectMultiSegmNpz( - self, multiPos=False, signals=None, - mutex=None, waitCond=None, askMultiSegmFunc=None, - newEndFilenameSegm='' - ): + self, + multiPos=False, + signals=None, + mutex=None, + waitCond=None, + askMultiSegmFunc=None, + newEndFilenameSegm="", + ): if newEndFilenameSegm: - return '', newEndFilenameSegm, False + return "", newEndFilenameSegm, False segm_files = get_segm_files(self.images_path) if askMultiSegmFunc is None: return segm_files - is_multi_npz = len(segm_files)>0 + is_multi_npz = len(segm_files) > 0 if is_multi_npz and askMultiSegmFunc is not None: askMultiSegmFunc(segm_files, self, waitCond) - endFilename = self.selectedItemText[len(self.basename):] + endFilename = self.selectedItemText[len(self.basename) :] return self.selectedItemText, endFilename, self.cancel - elif len(segm_files)==1: + elif len(segm_files) == 1: segmFilename = segm_files[0] - endFilename = segmFilename[len(self.basename):] + endFilename = segmFilename[len(self.basename) :] return segm_files[0], endFilename, False else: - return '', '', False + return "", "", False def readLastUsedStopFrameNumber(self): - if not hasattr(self, 'metadata_df'): + if not hasattr(self, "metadata_df"): return - + if self.metadata_df is None: return - + try: - stop_frame_num = int(self.metadata_df.at['stop_frame_num', 'values']) + stop_frame_num = int(self.metadata_df.at["stop_frame_num", "values"]) except Exception as err: stop_frame_num = None - + return stop_frame_num - + def getSamEmbeddingsPath(self): - sam_embed_filename = ( - f'{self.basename}_{self.user_ch_name}_sam_embeddings.pt' - ) + sam_embed_filename = f"{self.basename}_{self.user_ch_name}_sam_embeddings.pt" sam_embeddings_path = os.path.join(self.images_path, sam_embed_filename) return sam_embeddings_path - + def storeSamEmbeddings(self, samAcdcSegment, frame_i=0, z=0): # See here how to save embeddings # https://github.com/facebookresearch/segment-anything/issues/217 - - if not hasattr(self, 'sam_embeddings'): + + if not hasattr(self, "sam_embeddings"): self.sam_embeddings = {} - + if frame_i not in self.sam_embeddings: self.sam_embeddings[frame_i] = {} - - if hasattr(samAcdcSegment.model, 'predictor'): + + if hasattr(samAcdcSegment.model, "predictor"): predictor = samAcdcSegment.model.predictor else: predictor = samAcdcSegment.model - + embedding = { - 'original_size': predictor.original_size, - 'input_size': predictor.input_size, - 'features': predictor.features, - 'is_image_set': True, + "original_size": predictor.original_size, + "input_size": predictor.input_size, + "features": predictor.features, + "is_image_set": True, } self.sam_embeddings[frame_i][z] = embedding - - def saveSamEmbeddings(self, logger_func=print): - if not hasattr(self, 'sam_embeddings'): - return - - logger_func( - f'\nSaving SAM image embeddings to "{self.sam_embeddings_path}"...' - ) + + def saveSamEmbeddings(self, logger_func=print): + if not hasattr(self, "sam_embeddings"): + return + + logger_func(f'\nSaving SAM image embeddings to "{self.sam_embeddings_path}"...') import torch + torch.save(self.sam_embeddings, self.sam_embeddings_path) - + def loadSamEmbeddings(self, force_reload=False, logger_func=None): - if hasattr(self, 'sam_embeddings') and not force_reload: - return - + if hasattr(self, "sam_embeddings") and not force_reload: + return + if not os.path.exists(self.sam_embeddings_path): return - + if logger_func is not None: logger_func( f'\nLoading SAM image embeddings from "{self.sam_embeddings_path}"...' ) - + import torch + self.sam_embeddings = torch.load(self.sam_embeddings_path) - + def getSamEmbeddings(self, frame_i=0, z=0): - if not hasattr(self, 'sam_embeddings'): - return - + if not hasattr(self, "sam_embeddings"): + return + frame_embeddings = self.sam_embeddings.get(frame_i) if frame_embeddings is None: return - + img_embeddings = frame_embeddings.get(z) if img_embeddings is None: return - + return img_embeddings - - + def loadOtherFiles( - self, - load_segm_data=True, - create_new_segm=False, - load_acdc_df=False, - load_shifts=False, - loadSegmInfo=False, - load_delROIsInfo=False, - load_bkgr_data=False, - loadBkgrROIs=False, - load_last_tracked_i=False, - load_metadata=False, - load_dataPrep_ROIcoords=False, - load_customAnnot=False, - load_customCombineMetrics=False, - load_manual_bkgr_lab=False, - load_dataprep_free_roi=False, - getTifPath=False, - end_filename_segm='', - new_endname='', - labelBoolSegm=None, - load_whitelistIDs=False, - ): + self, + load_segm_data=True, + create_new_segm=False, + load_acdc_df=False, + load_shifts=False, + loadSegmInfo=False, + load_delROIsInfo=False, + load_bkgr_data=False, + loadBkgrROIs=False, + load_last_tracked_i=False, + load_metadata=False, + load_dataPrep_ROIcoords=False, + load_customAnnot=False, + load_customCombineMetrics=False, + load_manual_bkgr_lab=False, + load_dataprep_free_roi=False, + getTifPath=False, + end_filename_segm="", + new_endname="", + labelBoolSegm=None, + load_whitelistIDs=False, + ): self.segmFound = False if load_segm_data else None self.acdc_df_found = False if load_acdc_df else None self.shiftsFound = False if load_shifts else None @@ -1884,35 +1913,36 @@ def loadOtherFiles( self.labelBoolSegm = labelBoolSegm self.bkgrDataExists = False ls = myutils.listdir(self.images_path) - + if end_filename_segm: - end_filename_segm = end_filename_segm.replace('.npz', '') + end_filename_segm = end_filename_segm.replace(".npz", "") linked_acdc_filename = None if end_filename_segm and load_acdc_df: # Check if there is an acdc_output file linked to selected .npz - _acdc_df_end_fn = end_filename_segm.replace('segm', 'acdc_output') - _acdc_df_end_fn = f'{_acdc_df_end_fn}.csv' + _acdc_df_end_fn = end_filename_segm.replace("segm", "acdc_output") + _acdc_df_end_fn = f"{_acdc_df_end_fn}.csv" self._acdc_df_end_fn = _acdc_df_end_fn - _linked_acdc_fn = f'{self.basename}{_acdc_df_end_fn}' + _linked_acdc_fn = f"{self.basename}{_acdc_df_end_fn}" acdc_df_path = os.path.join(self.images_path, _linked_acdc_fn) self.acdc_output_csv_path = acdc_df_path linked_acdc_filename = _linked_acdc_fn - - if not hasattr(self, 'basename'): + + if not hasattr(self, "basename"): self.getBasenameAndChNames() dataPrepFreeRoiPath = self.dataPrepFreeRoiPath() dataPrepFreeRoiFilename = os.path.basename(dataPrepFreeRoiPath) - + for file in ls: filePath = os.path.join(self.images_path, file) filename, segmExt = os.path.splitext(file) - endName = filename[len(self.basename):] + endName = filename[len(self.basename) :] loadMetadata = ( - load_metadata and file.endswith('metadata.csv') - and not file.endswith('segm_metadata.csv') + load_metadata + and file.endswith("metadata.csv") + and not file.endswith("segm_metadata.csv") ) if new_endname: @@ -1923,10 +1953,10 @@ def loadOtherFiles( elif end_filename_segm: # Load the segmentation file selected by the user self._segm_end_fn = end_filename_segm - is_segm_file = endName == end_filename_segm and segmExt == '.npz' + is_segm_file = endName == end_filename_segm and segmExt == ".npz" else: # Load default segmentation file - is_segm_file = file.endswith('segm.npz') + is_segm_file = file.endswith("segm.npz") if linked_acdc_filename is not None: is_acdc_df_file = file == linked_acdc_filename @@ -1935,13 +1965,13 @@ def loadOtherFiles( # do not load acdc_df file is_acdc_df_file = False else: - is_acdc_df_file = file.endswith('acdc_output.csv') + is_acdc_df_file = file.endswith("acdc_output.csv") is_acdc_df_file = file == linked_acdc_filename - + if load_dataprep_free_roi and file == dataPrepFreeRoiFilename: self.loadDataPrepFreeRoi() - + if load_segm_data and is_segm_file and not create_new_segm: self.segmFound = True self.segm_npz_path = filePath @@ -1956,16 +1986,16 @@ def loadOtherFiles( if squeezed_arr.shape != self.segm_data.shape: self.segm_data = squeezed_arr io.savez_compressed(filePath, squeezed_arr) - elif getTifPath and file.find(f'{self.user_ch_name}.tif')!=-1: + elif getTifPath and file.find(f"{self.user_ch_name}.tif") != -1: self.tif_path = filePath self.TifPathFound = True elif load_acdc_df and is_acdc_df_file and not create_new_segm: self.acdc_df_found = True self.loadAcdcDf(filePath) - elif load_shifts and file.endswith('align_shift.npy'): + elif load_shifts and file.endswith("align_shift.npy"): self.shiftsFound = True self.loaded_shifts = np.load(filePath) - elif loadSegmInfo and file.endswith('segmInfo.csv'): + elif loadSegmInfo and file.endswith("segmInfo.csv"): self.segmInfoFound = True try: remove_duplicates_file(filePath) @@ -1973,77 +2003,77 @@ def loadOtherFiles( printl(filePath) printl(traceback.format_exc()) df = pd.read_csv(filePath).dropna() - # In some old versions, there was a bug that removed the - # 'filename', and the 'frame_i' column names, so - # we check if they are not present and rename the + # In some old versions, there was a bug that removed the + # 'filename', and the 'frame_i' column names, so + # we check if they are not present and rename the # 'Unnamed: 0' and 'Unnamed: 1' to filename and frame_i - if 'Unnamed: 0' in df.columns and 'Unnamed: 1' in df.columns: + if "Unnamed: 0" in df.columns and "Unnamed: 1" in df.columns: df = df.rename( - columns={ - 'Unnamed: 0': 'filename', - 'Unnamed: 1': 'frame_i' - } + columns={"Unnamed: 0": "filename", "Unnamed: 1": "frame_i"} ) - if 'filename' not in df.columns: - df['filename'] = self.filename - df = df.set_index(['filename', 'frame_i']).sort_index() + if "filename" not in df.columns: + df["filename"] = self.filename + df = df.set_index(["filename", "frame_i"]).sort_index() df = df[~df.index.duplicated()] self.segmInfo_df = df.sort_index() self.segmInfo_df.to_csv(filePath) - elif load_delROIsInfo and file.endswith('delROIsInfo.npz'): + elif load_delROIsInfo and file.endswith("delROIsInfo.npz"): self.delROIsInfoFound = True self.delROIsInfo_npz = np.load(filePath) - elif file.endswith(f'{self.filename}_bkgrRoiData.npz'): + elif file.endswith(f"{self.filename}_bkgrRoiData.npz"): self.bkgrDataExists = True if load_bkgr_data: self.bkgrDataFound = True self.bkgrData = np.load(filePath) - elif loadBkgrROIs and file.endswith('dataPrep_bkgrROIs.json'): + elif loadBkgrROIs and file.endswith("dataPrep_bkgrROIs.json"): self.bkgrROisFound = True with open(filePath) as json_fp: bkgROIs_states = json.load(json_fp) - if hasattr(self, 'img_data'): + if hasattr(self, "img_data"): for roi_state in bkgROIs_states: Y, X = self.img_data.shape[-2:] roi = pg.ROI( - [0, 0], [1, 1], + [0, 0], + [1, 1], rotatable=False, removable=False, - pen=pg.mkPen(color=(150,150,150)), - maxBounds=QRectF(QRect(0,0,X,Y)), + pen=pg.mkPen(color=(150, 150, 150)), + maxBounds=QRectF(QRect(0, 0, X, Y)), scaleSnap=True, - translateSnap=True + translateSnap=True, ) roi.setState(roi_state) self.bkgrROIs.append(roi) - elif load_dataPrep_ROIcoords and file.endswith('dataPrepROIs_coords.csv'): + elif load_dataPrep_ROIcoords and file.endswith("dataPrepROIs_coords.csv"): df = pd.read_csv(filePath) - if 'roi_id' not in df.columns: - df['roi_id'] = 0 - if 'description' in df.columns and 'value' in df.columns: - df = df.set_index(['roi_id', 'description']) + if "roi_id" not in df.columns: + df["roi_id"] = 0 + if "description" in df.columns and "value" in df.columns: + df = df.set_index(["roi_id", "description"]) self.dataPrep_ROIcoordsFound = True self.dataPrep_ROIcoords = df elif loadMetadata: self.metadataFound = True remove_duplicates_file(filePath) parse_metadata_csv_file(filePath) - self.metadata_df = pd.read_csv(filePath).set_index('Description') - elif load_customAnnot and file.endswith('custom_annot_params.json'): + self.metadata_df = pd.read_csv(filePath).set_index("Description") + elif load_customAnnot and file.endswith("custom_annot_params.json"): self.customAnnotFound = True self.customAnnot = read_json(filePath) - elif load_customCombineMetrics and file.endswith('custom_combine_metrics.ini'): + elif load_customCombineMetrics and file.endswith( + "custom_combine_metrics.ini" + ): self.combineMetricsFound = True self.setCombineMetricsConfig(ini_path=filePath) if self.metadataFound is not None and self.metadataFound: self.extractMetadata() - + # Check if there is the old segm.npy if not self.segmFound and not create_new_segm: for file in ls: - is_segm_npy = file.endswith('segm.npy') + is_segm_npy = file.endswith("segm.npy") filePath = os.path.join(self.images_path, file) if load_segm_data and is_segm_npy and not self.segmFound: self.segmFound = True @@ -2053,8 +2083,7 @@ def loadOtherFiles( self.last_tracked_i_found = True try: self.last_tracked_i = max( - self.acdc_df.index.get_level_values(0), - default=None + self.acdc_df.index.get_level_values(0), default=None ) except AttributeError as e: # traceback.print_exc() @@ -2069,117 +2098,114 @@ def loadOtherFiles( if load_whitelistIDs: self.loadWhitelist() - + def checkAndFixZsliceSegmInfo(self): - if not hasattr(self, 'segmInfo_df'): + if not hasattr(self, "segmInfo_df"): return - + if self.segmInfo_df is None: return - - if not hasattr(self, 'SizeZ'): + + if not hasattr(self, "SizeZ"): return - + if self.SizeZ == 1: return - - middleZslice = int(self.SizeZ/2) - + + middleZslice = int(self.SizeZ / 2) + try: - mask = self.segmInfo_df['z_slice_used_dataPrep'] >= self.SizeZ + mask = self.segmInfo_df["z_slice_used_dataPrep"] >= self.SizeZ valid_idx = mask[mask].index - self.segmInfo_df.loc[valid_idx, 'z_slice_used_dataPrep'] = middleZslice + self.segmInfo_df.loc[valid_idx, "z_slice_used_dataPrep"] = middleZslice except Exception as err: pass - + try: - mask = self.segmInfo_df['z_slice_used_gui'] >= self.SizeZ + mask = self.segmInfo_df["z_slice_used_gui"] >= self.SizeZ valid_idx = mask[mask].index - self.segmInfo_df.loc[valid_idx, 'z_slice_used_gui'] = middleZslice + self.segmInfo_df.loc[valid_idx, "z_slice_used_gui"] = middleZslice except Exception as err: pass - + def loadMostRecentUnsavedAcdcDf(self): acdc_df = get_last_stored_unsaved_acdc_df(self.recoveryFolderpath()) if acdc_df is None: return self.acdc_df = acdc_df self.acdc_df_found = True - self.last_tracked_i = max( - self.acdc_df.index.get_level_values(0), - default=None - ) - + self.last_tracked_i = max(self.acdc_df.index.get_level_values(0), default=None) + def loadAcdcDf(self, filePath, updatePaths=True, return_df=False): acdc_df = _load_acdc_df_file(filePath) if acdc_df.empty: self.acdc_df_found = False return - + if updatePaths: self.acdc_df = acdc_df self.acdc_df_found = True self.last_tracked_i = max( - self.acdc_df.index.get_level_values(0), - default=None + self.acdc_df.index.get_level_values(0), default=None ) if return_df: return acdc_df - + def dataPrepFreeRoiPath(self): dataPrepFreeRoiPath = os.path.join( - self.images_path, f'{self.basename}dataPrepFreeRoi.npz' + self.images_path, f"{self.basename}dataPrepFreeRoi.npz" ) return dataPrepFreeRoiPath - + def saveDataPrepFreeRoi( - self, - roiItem: 'widgets.PlotCurveItem', - logger_func=print, - local_mask=None, bbox=None - ): + self, + roiItem: "widgets.PlotCurveItem", + logger_func=print, + local_mask=None, + bbox=None, + ): dataPrepFreeRoiPath = self.dataPrepFreeRoiPath() - + logger_func(f'\nSaving free ROI to file "{dataPrepFreeRoiPath}"...') - + if local_mask is None: local_mask = roiItem.mask() - + if bbox is None: bbox = roiItem.bbox() - + y0, x0, y1, x1 = bbox - key = f'{x0}_{y0}_{x1}_{y1}' + key = f"{x0}_{y0}_{x1}_{y1}" data = {key: local_mask} np.savez_compressed(dataPrepFreeRoiPath, **data) - + def removeDataPrepFreeRoi(self, logger_func=print): self.dataPrepFreeRoiPoints = [] dataPrepFreeRoiPath = self.dataPrepFreeRoiPath() if not os.path.exists(dataPrepFreeRoiPath): return - + logger_func(f'\nRemoving free ROI file "{dataPrepFreeRoiPath}"...') os.remove(dataPrepFreeRoiPath) - + def loadDataPrepFreeRoi(self, logger_func=print): self.dataPrepFreeRoiPoints = [] dataPrepFreeRoiPath = self.dataPrepFreeRoiPath() if not os.path.exists(dataPrepFreeRoiPath): return - + logger_func(f'\nLoading free ROI from file "{dataPrepFreeRoiPath}"...') archive = np.load(dataPrepFreeRoiPath) key = archive.files[0] - x0, y0, x1, y1 = [int(coord) for coord in key.split('_')] + x0, y0, x1, y1 = [int(coord) for coord in key.split("_")] mask = archive[key] obj = skimage.measure.regionprops(mask.astype(np.uint8))[0] contours = core.get_obj_contours(obj=obj, only_longest_contour=False) self.dataPrepFreeRoiPoints = contours + (int(x0), int(y0)) self.dataPrepFreeRoiLocalMask = mask - self.dataPrepFreeRoiSlice = (slice(y0, y1+1), slice(x0, x1+1)) + self.dataPrepFreeRoiSlice = (slice(y0, y1 + 1), slice(x0, x1 + 1)) self.dataPrepFreeRoiBbox = (y0, x0, y1, x1) - + def clearSegmObjsDataPrepFreeRoi(self, segm_data, is_timelapse=True): local_mask = self.dataPrepFreeRoiLocalMask local_slice = self.dataPrepFreeRoiSlice @@ -2193,7 +2219,7 @@ def clearSegmObjsDataPrepFreeRoi(self, segm_data, is_timelapse=True): for obj in rp: if not np.any(delMask[obj.slice][obj.image]): continue - + lab[obj.slice][obj.image] = 0 segm_data[i] = lab else: @@ -2204,17 +2230,18 @@ def clearSegmObjsDataPrepFreeRoi(self, segm_data, is_timelapse=True): for obj in rp: if not np.any(delMask[obj.slice][obj.image]): continue - + lab[obj.slice][obj.image] = 0 segm_data = lab - + return segm_data - + def getSpotmaxSingleSpotsfiles(self): from spotmax import DFs_FILENAMES + spotmax_files = myutils.listdir(self.spotmax_out_path) patterns = [ - filename.replace('*rn*', '').replace('*desc*', '') + filename.replace("*rn*", "").replace("*desc*", "") for filename in DFs_FILENAMES.values() ] valid_files = [] @@ -2222,7 +2249,7 @@ def getSpotmaxSingleSpotsfiles(self): filepath = os.path.join(self.spotmax_out_path, file) if not os.path.isfile(filepath): continue - if file.endswith('aggregated.csv'): + if file.endswith("aggregated.csv"): continue for pattern in patterns: if file.find(pattern) != -1: @@ -2230,24 +2257,26 @@ def getSpotmaxSingleSpotsfiles(self): else: continue valid_files.append(file) - + return reversed(valid_files) def askBooleanSegm(self): segmFilename = os.path.basename(self.segm_npz_path) msg = widgets.myMessageBox() txt = html_utils.paragraph( - f'The loaded segmentation file

    ' + f"The loaded segmentation file

    " f'"{segmFilename}"

    ' - 'has boolean data type.

    ' - 'To correctly load it, Cell-ACDC needs to convert it ' - 'to integer data type.

    ' - 'Do you want to label the mask to separate the objects ' - '(recommended) or do you want to keep one single object?
    ' + "has boolean data type.

    " + "To correctly load it, Cell-ACDC needs to convert it " + "to integer data type.

    " + "Do you want to label the mask to separate the objects " + "(recommended) or do you want to keep one single object?
    " ) - LabelButton, _ = msg.question( - self.parent, 'Boolean segmentation mask?', txt, - buttonsTexts=('Label (recommended)', 'Keep single object') + LabelButton, _ = msg.question( + self.parent, + "Boolean segmentation mask?", + txt, + buttonsTexts=("Label (recommended)", "Keep single object"), ) if msg.clickedButton == LabelButton: self.labelBoolSegm = True @@ -2273,64 +2302,62 @@ def labelSegmData(self): self.segm_data = self.segm_data.astype(np.uint32) def setFilePaths(self, new_endname): - if self.basename.endswith('_'): + if self.basename.endswith("_"): basename = self.basename else: - basename = f'{self.basename}_' + basename = f"{self.basename}_" if new_endname: - segm_new_filename = f'{basename}segm_{new_endname}.npz' - acdc_output_filename = f'{basename}acdc_output_{new_endname}.csv' + segm_new_filename = f"{basename}segm_{new_endname}.npz" + acdc_output_filename = f"{basename}acdc_output_{new_endname}.csv" else: - segm_new_filename = f'{basename}segm.npz' - acdc_output_filename = f'{basename}acdc_output.csv' - + segm_new_filename = f"{basename}segm.npz" + acdc_output_filename = f"{basename}acdc_output.csv" + filePath = os.path.join(self.images_path, segm_new_filename) self.segm_npz_path = filePath filePath = os.path.join(self.images_path, acdc_output_filename) self.acdc_output_csv_path = filePath - - def fromTrackerToAcdcDf( - self, tracker, tracked_video, save=False, start_frame_i=0 - ): - cca_dfs_attr = hasattr(tracker, 'cca_dfs') - cca_dfs_auto_attr = hasattr(tracker, 'cca_dfs_auto') - if hasattr(tracker, 'tracked_lost_centroids'): + def fromTrackerToAcdcDf(self, tracker, tracked_video, save=False, start_frame_i=0): + cca_dfs_attr = hasattr(tracker, "cca_dfs") + cca_dfs_auto_attr = hasattr(tracker, "cca_dfs_auto") + + if hasattr(tracker, "tracked_lost_centroids"): self.saveTrackedLostCentroids(tracker.tracked_lost_centroids) if not cca_dfs_attr and not cca_dfs_auto_attr: return - + if cca_dfs_attr: - end_frame_i = start_frame_i+len(tracker.cca_dfs) + end_frame_i = start_frame_i + len(tracker.cca_dfs) keys = list(range(start_frame_i, end_frame_i)) - acdc_df = pd.concat(tracker.cca_dfs, keys=keys, names=['frame_i']) + acdc_df = pd.concat(tracker.cca_dfs, keys=keys, names=["frame_i"]) else: - end_frame_i = start_frame_i+len(tracker.cca_dfs_auto) + end_frame_i = start_frame_i + len(tracker.cca_dfs_auto) keys = list(range(start_frame_i, end_frame_i)) - acdc_df = pd.concat(tracker.cca_dfs_auto, keys=keys, names=['frame_i']) + acdc_df = pd.concat(tracker.cca_dfs_auto, keys=keys, names=["frame_i"]) - acdc_df['is_cell_dead'] = 0 - acdc_df['is_cell_excluded'] = 0 - acdc_df['was_manually_edited'] = 0 - acdc_df['x_centroid'] = 0 - acdc_df['y_centroid'] = 0 + acdc_df["is_cell_dead"] = 0 + acdc_df["is_cell_excluded"] = 0 + acdc_df["was_manually_edited"] = 0 + acdc_df["x_centroid"] = 0 + acdc_df["y_centroid"] = 0 for i, lab in enumerate(tracked_video): frame_i = start_frame_i + i rp = skimage.measure.regionprops(lab) for obj in rp: centroid = obj.centroid yc, xc = obj.centroid[-2:] - acdc_df.at[(frame_i, obj.label), 'x_centroid'] = int(xc) - acdc_df.at[(frame_i, obj.label), 'y_centroid'] = int(yc) + acdc_df.at[(frame_i, obj.label), "x_centroid"] = int(xc) + acdc_df.at[(frame_i, obj.label), "y_centroid"] = int(yc) if len(centroid) == 3: - if 'z_centroid' not in acdc_df.columns: - acdc_df['z_centroid'] = 0 + if "z_centroid" not in acdc_df.columns: + acdc_df["z_centroid"] = 0 zc = obj.centroid[0] - acdc_df.at[(frame_i, obj.label), 'z_centroid'] = int(zc) + acdc_df.at[(frame_i, obj.label), "z_centroid"] = int(zc) if not save: return acdc_df @@ -2343,29 +2370,29 @@ def fromTrackerToAcdcDf( acdc_df.to_csv(self.acdc_output_auto_csv_path) def getAcdcDfEndname(self): - if not hasattr(self, 'acdc_output_csv_path'): + if not hasattr(self, "acdc_output_csv_path"): return - - if not hasattr(self, 'basename'): + + if not hasattr(self, "basename"): return - + filename = os.path.basename(self.acdc_output_csv_path) filename, _ = os.path.splitext(filename) - endname = filename[len(self.basename):].lstrip('_') + endname = filename[len(self.basename) :].lstrip("_") return endname - + def getSegmEndname(self): - if not hasattr(self, 'segm_npz_path'): + if not hasattr(self, "segm_npz_path"): return - - if not hasattr(self, 'basename'): + + if not hasattr(self, "basename"): return - + filename = os.path.basename(self.segm_npz_path) filename, _ = os.path.splitext(filename) - endname = filename[len(self.basename):].lstrip('_') + endname = filename[len(self.basename) :].lstrip("_") return endname - + def getCustomAnnotatedIDs(self): self.customAnnotIDs = {} @@ -2387,7 +2414,7 @@ def getCustomAnnotatedIDs(self): self.acdc_df[name] = 0 for frame_i, df in self.acdc_df.groupby(level=0): series = df[name] - series = series[series>0] + series = series[series > 0] annotatedIDs = list(series.index.get_level_values(1).unique()) self.customAnnotIDs[name][frame_i] = annotatedIDs @@ -2395,12 +2422,14 @@ def isCropped(self): if self.dataPrep_ROIcoords is None: return False df = self.dataPrep_ROIcoords - _isCropped = any([ - df_roi.at[(roi_id, 'cropped'), 'value'] > 0 - for roi_id, df_roi in df.groupby(level=0) - ]) + _isCropped = any( + [ + df_roi.at[(roi_id, "cropped"), "value"] > 0 + for roi_id, df_roi in df.groupby(level=0) + ] + ) return _isCropped - + def getIsSegm3D(self): if self.SizeZ == 1: return False @@ -2411,7 +2440,7 @@ def getIsSegm3D(self): if not self.segmFound: return - if hasattr(self, 'img_data'): + if hasattr(self, "img_data"): return self.segm_data.ndim == self.img_data.ndim else: if self.SizeT > 1: @@ -2420,142 +2449,132 @@ def getIsSegm3D(self): return self.segm_data.ndim == 3 def getBytesImageData(self): - if not hasattr(self, 'img_data'): + if not hasattr(self, "img_data"): return 0 - + return sys.getsizeof(self.img_data) - + def extractMetadata(self): - self.metadata_df['values'] = self.metadata_df['values'].astype(str) - if 'SizeT' in self.metadata_df.index: - self.SizeT = float(self.metadata_df.at['SizeT', 'values']) + self.metadata_df["values"] = self.metadata_df["values"].astype(str) + if "SizeT" in self.metadata_df.index: + self.SizeT = float(self.metadata_df.at["SizeT", "values"]) self.SizeT = int(self.SizeT) - elif self.last_md_df is not None and 'SizeT' in self.last_md_df.index: - self.SizeT = float(self.last_md_df.at['SizeT', 'values']) + elif self.last_md_df is not None and "SizeT" in self.last_md_df.index: + self.SizeT = float(self.last_md_df.at["SizeT", "values"]) self.SizeT = int(self.SizeT) else: self.SizeT = 1 self.SizeZ_found = False - if 'SizeZ' in self.metadata_df.index: - self.SizeZ = float(self.metadata_df.at['SizeZ', 'values']) + if "SizeZ" in self.metadata_df.index: + self.SizeZ = float(self.metadata_df.at["SizeZ", "values"]) self.SizeZ = int(self.SizeZ) self.SizeZ_found = True - elif self.last_md_df is not None and 'SizeZ' in self.last_md_df.index: - self.SizeZ = float(self.last_md_df.at['SizeZ', 'values']) + elif self.last_md_df is not None and "SizeZ" in self.last_md_df.index: + self.SizeZ = float(self.last_md_df.at["SizeZ", "values"]) self.SizeZ = int(self.SizeZ) else: self.SizeZ = 1 - if 'SizeY' in self.metadata_df.index: - self.SizeY = float(self.metadata_df.at['SizeY', 'values']) + if "SizeY" in self.metadata_df.index: + self.SizeY = float(self.metadata_df.at["SizeY", "values"]) self.SizeY = int(self.SizeY) - self.SizeX = float(self.metadata_df.at['SizeX', 'values']) + self.SizeX = float(self.metadata_df.at["SizeX", "values"]) self.SizeX = int(self.SizeX) else: - if hasattr(self, 'img_data_shape'): + if hasattr(self, "img_data_shape"): self.SizeY, self.SizeX = self.img_data_shape[-2:] else: self.SizeY, self.SizeX = 1, 1 self.isSegm3D = False - if hasattr(self, 'segm_npz_path'): + if hasattr(self, "segm_npz_path"): segmEndName = self.getSegmEndname() - isSegm3Dkey = f'{segmEndName}_isSegm3D' + isSegm3Dkey = f"{segmEndName}_isSegm3D" if isSegm3Dkey in self.metadata_df.index: - isSegm3D = str(self.metadata_df.at[isSegm3Dkey, 'values']) - self.isSegm3D = isSegm3D.lower() == 'true' + isSegm3D = str(self.metadata_df.at[isSegm3Dkey, "values"]) + self.isSegm3D = isSegm3D.lower() == "true" - if 'TimeIncrement' in self.metadata_df.index: - self.TimeIncrement = float( - self.metadata_df.at['TimeIncrement', 'values'] - ) - elif self.last_md_df is not None and 'TimeIncrement' in self.last_md_df.index: - self.TimeIncrement = float(self.last_md_df.at['TimeIncrement', 'values']) + if "TimeIncrement" in self.metadata_df.index: + self.TimeIncrement = float(self.metadata_df.at["TimeIncrement", "values"]) + elif self.last_md_df is not None and "TimeIncrement" in self.last_md_df.index: + self.TimeIncrement = float(self.last_md_df.at["TimeIncrement", "values"]) else: self.TimeIncrement = 1 - if 'PhysicalSizeX' in self.metadata_df.index: - self.PhysicalSizeX = float( - self.metadata_df.at['PhysicalSizeX', 'values'] - ) - elif self.last_md_df is not None and 'PhysicalSizeX' in self.last_md_df.index: - self.PhysicalSizeX = float(self.last_md_df.at['PhysicalSizeX', 'values']) + if "PhysicalSizeX" in self.metadata_df.index: + self.PhysicalSizeX = float(self.metadata_df.at["PhysicalSizeX", "values"]) + elif self.last_md_df is not None and "PhysicalSizeX" in self.last_md_df.index: + self.PhysicalSizeX = float(self.last_md_df.at["PhysicalSizeX", "values"]) else: self.PhysicalSizeX = 1 - if 'PhysicalSizeY' in self.metadata_df.index: - self.PhysicalSizeY = float( - self.metadata_df.at['PhysicalSizeY', 'values'] - ) - elif self.last_md_df is not None and 'PhysicalSizeY' in self.last_md_df.index: - self.PhysicalSizeY = float(self.last_md_df.at['PhysicalSizeY', 'values']) + if "PhysicalSizeY" in self.metadata_df.index: + self.PhysicalSizeY = float(self.metadata_df.at["PhysicalSizeY", "values"]) + elif self.last_md_df is not None and "PhysicalSizeY" in self.last_md_df.index: + self.PhysicalSizeY = float(self.last_md_df.at["PhysicalSizeY", "values"]) else: self.PhysicalSizeY = 1 - if 'PhysicalSizeZ' in self.metadata_df.index: - self.PhysicalSizeZ = float( - self.metadata_df.at['PhysicalSizeZ', 'values'] - ) - elif self.last_md_df is not None and 'PhysicalSizeZ' in self.last_md_df.index: - self.PhysicalSizeZ = float(self.last_md_df.at['PhysicalSizeZ', 'values']) + if "PhysicalSizeZ" in self.metadata_df.index: + self.PhysicalSizeZ = float(self.metadata_df.at["PhysicalSizeZ", "values"]) + elif self.last_md_df is not None and "PhysicalSizeZ" in self.last_md_df.index: + self.PhysicalSizeZ = float(self.last_md_df.at["PhysicalSizeZ", "values"]) else: self.PhysicalSizeZ = 1 - if 'LensNA' in self.metadata_df.index: - self.numAperture = float( - self.metadata_df.at['LensNA', 'values'] - ) + if "LensNA" in self.metadata_df.index: + self.numAperture = float(self.metadata_df.at["LensNA", "values"]) else: self.numAperture = 1.4 - - emWavelenMask = self.metadata_df.index.str.contains(r'_emWavelen') + + emWavelenMask = self.metadata_df.index.str.contains(r"_emWavelen") df_emWavelens = self.metadata_df[emWavelenMask] self.emWavelens = {} try: for channel_i_emWavelen, emWavelen in df_emWavelens.itertuples(): - channel_i_name = channel_i_emWavelen.replace('_emWavelen', '_name') - chName = self.metadata_df.at[channel_i_name, 'values'] + channel_i_name = channel_i_emWavelen.replace("_emWavelen", "_name") + chName = self.metadata_df.at[channel_i_name, "values"] self.emWavelens[chName] = float(emWavelen) except Exception as e: pass - + self._additionalMetadataValues = {} for name in self.metadata_df.index: - if name.startswith('__') and len(name) > 2: - value = self.metadata_df.at[name, 'values'] + if name.startswith("__") and len(name) > 2: + value = self.metadata_df.at[name, "values"] self._additionalMetadataValues[name] = value - + if not self._additionalMetadataValues: # Load metadata values saved in temp folder if os.path.exists(additional_metadata_path): self._additionalMetadataValues = read_json( - additional_metadata_path, desc='additional metadata' + additional_metadata_path, desc="additional metadata" ) def saveIsSegm3Dmetadata(self, segm_npz_path): segmFilename = os.path.basename(segm_npz_path) segmFilename = os.path.splitext(segmFilename)[0] - segmEndName = segmFilename[len(self.basename):] - isSegm3Dkey = f'{segmEndName}_isSegm3D' - self.metadata_df.at[isSegm3Dkey, 'values'] = self.isSegm3D + segmEndName = segmFilename[len(self.basename) :] + isSegm3Dkey = f"{segmEndName}_isSegm3D" + self.metadata_df.at[isSegm3Dkey, "values"] = self.isSegm3D self.metadata_df.to_csv(self.metadata_csv_path) - + def additionalMetadataValues(self): additionalMetadataValues = {} for name in self.metadata_df.index: - if name.startswith('__'): - value = self.metadata_df.at[name, 'values'] - key = name.replace('__', '', 1) + if name.startswith("__"): + value = self.metadata_df.at[name, "values"] + key = name.replace("__", "", 1) additionalMetadataValues[key] = value return additionalMetadataValues - + def add_tree_cols_to_cca_df(self, cca_df, frame_i=None): cca_df = cca_df.sort_index().reset_index() if self.acdc_df is None: return cca_df - + if frame_i is not None: df = self.acdc_df.loc[frame_i].sort_index().reset_index() else: @@ -2563,10 +2582,10 @@ def add_tree_cols_to_cca_df(self, cca_df, frame_i=None): cols = cca_df.columns.to_list() for col in df.columns: - if not col.endswith('tree'): + if not col.endswith("tree"): continue - ref_col = col[:col.find('_tree')] + ref_col = col[: col.find("_tree")] if ref_col in cols: ref_col_idx = cols.index(ref_col) + 1 else: @@ -2576,20 +2595,20 @@ def add_tree_cols_to_cca_df(self, cca_df, frame_i=None): cca_df[col] = df[col] else: cca_df.insert(ref_col_idx, col, df[col]) - + return cca_df - + def getManualBackgroudDataFilepath(self): segmFilename = os.path.basename(self.segm_npz_path) - segmEndname = segmFilename[len(self.basename):] - manualBackgrEndname = segmEndname.replace('segm', 'manualBackground') - manualBackgrFilename = f'{self.basename}{manualBackgrEndname}' + segmEndname = segmFilename[len(self.basename) :] + manualBackgrEndname = segmEndname.replace("segm", "manualBackground") + manualBackgrFilename = f"{self.basename}{manualBackgrEndname}" filepath = os.path.join(self.images_path, manualBackgrFilename) return filepath def saveManualBackgroundData(self, data: np.ndarray): if data is None: - return + return filepath = self.getManualBackgroudDataFilepath() io.savez_compressed(filepath, data) @@ -2600,30 +2619,30 @@ def loadManualBackgroundData(self): return archive = np.load(filepath) self.manualBackgroundLab = archive[archive.files[0]] - + def setNotFoundData(self): if self.segmFound is not None and not self.segmFound: self.segm_data = None # Segmentation file not found and a specifc one was requested # --> set the path - if hasattr(self, '_segm_end_fn'): - if self.basename.endswith('_'): + if hasattr(self, "_segm_end_fn"): + if self.basename.endswith("_"): basename = self.basename else: - basename = f'{self.basename}_' + basename = f"{self.basename}_" base_path = os.path.join(self.images_path, basename) - self.segm_npz_path = f'{base_path}{self._segm_end_fn}.npz' + self.segm_npz_path = f"{base_path}{self._segm_end_fn}.npz" if self.acdc_df_found is not None and not self.acdc_df_found: self.acdc_df = None # Set the file path for selected acdc_output.csv file # since it was not found - if hasattr(self, '_acdc_df_end_fn'): - if self.basename.endswith('_'): + if hasattr(self, "_acdc_df_end_fn"): + if self.basename.endswith("_"): basename = self.basename else: - basename = f'{self.basename}_' + basename = f"{self.basename}_" base_path = os.path.join(self.images_path, basename) - self.acdc_output_csv_path = f'{base_path}{self._acdc_df_end_fn}' + self.acdc_output_csv_path = f"{base_path}{self._acdc_df_end_fn}" if self.shiftsFound is not None and not self.shiftsFound: self.loaded_shifts = None if self.segmInfoFound is not None and not self.segmInfoFound: @@ -2638,7 +2657,10 @@ def setNotFoundData(self): if self.bkgrDataExists: # Do not load bkgrROIs if bkgrDataFound to avoid addMetrics to use it self.bkgrROIs = [] - if self.dataPrep_ROIcoordsFound is not None and not self.dataPrep_ROIcoordsFound: + if ( + self.dataPrep_ROIcoordsFound is not None + and not self.dataPrep_ROIcoordsFound + ): self.dataPrep_ROIcoords = None if self.last_tracked_i_found is not None and not self.last_tracked_i_found: self.last_tracked_i = None @@ -2656,7 +2678,7 @@ def setNotFoundData(self): if self.metadataFound: return - if hasattr(self, 'img_data'): + if hasattr(self, "img_data"): if self.img_data.ndim == 3: if len(self.img_data) > 49: self.SizeT, self.SizeZ = len(self.img_data), 1 @@ -2668,7 +2690,7 @@ def setNotFoundData(self): self.SizeT, self.SizeZ = 1, 1 else: self.SizeT, self.SizeZ = 1, 1 - + try: self.SizeY, self.SizeX = self.img_data_shape[-2:] except Exception as e: @@ -2692,127 +2714,114 @@ def setNotFoundData(self): # self.SizeT = int(self.last_md_df.at['SizeT', 'values']) # if 'SizeZ' in self.last_md_df.index and self.SizeZ == 1: # self.SizeZ = int(self.last_md_df.at['SizeZ', 'values']) - if 'TimeIncrement' in self.last_md_df.index: - self.TimeIncrement = float( - self.last_md_df.at['TimeIncrement', 'values'] - ) - if 'PhysicalSizeX' in self.last_md_df.index: - self.PhysicalSizeX = float( - self.last_md_df.at['PhysicalSizeX', 'values'] - ) - if 'PhysicalSizeY' in self.last_md_df.index: - self.PhysicalSizeY = float( - self.last_md_df.at['PhysicalSizeY', 'values'] - ) - if 'PhysicalSizeZ' in self.last_md_df.index: - self.PhysicalSizeZ = float( - self.last_md_df.at['PhysicalSizeZ', 'values'] - ) + if "TimeIncrement" in self.last_md_df.index: + self.TimeIncrement = float(self.last_md_df.at["TimeIncrement", "values"]) + if "PhysicalSizeX" in self.last_md_df.index: + self.PhysicalSizeX = float(self.last_md_df.at["PhysicalSizeX", "values"]) + if "PhysicalSizeY" in self.last_md_df.index: + self.PhysicalSizeY = float(self.last_md_df.at["PhysicalSizeY", "values"]) + if "PhysicalSizeZ" in self.last_md_df.index: + self.PhysicalSizeZ = float(self.last_md_df.at["PhysicalSizeZ", "values"]) def preprocessedDataArray(self, check_integrity=True): - if not hasattr(self, 'preproc_img_data'): + if not hasattr(self, "preproc_img_data"): return - + preprocess_data = [] for frame_i, raw_img in enumerate(self.img_data): preprocess_img = self.preproc_img_data.get(frame_i) if preprocess_img is None: if check_integrity: - raise TypeError( - 'Not all frames have been processed.' - ) + raise TypeError("Not all frames have been processed.") else: continue - + preprocess_img = np.squeeze(preprocess_img) - preprocess_data.append(preprocess_img) - + preprocess_data.append(preprocess_img) + preprocess_data_arr = np.array(preprocess_data) return preprocess_data_arr - + def combinedChannelsDataArray(self, check_integrity=True): - if not hasattr(self, 'combine_img_data'): + if not hasattr(self, "combine_img_data"): return - + combined_channels_data = [] for frame_i, raw_img in enumerate(self.img_data): combined_channels_img = self.combine_img_data.get(frame_i) if combined_channels_img is None: if check_integrity: - raise TypeError( - 'Not all frames have been processed.' - ) + raise TypeError("Not all frames have been processed.") else: continue - + combined_channels_img = np.squeeze(combined_channels_img) - combined_channels_data.append(combined_channels_img) - + combined_channels_data.append(combined_channels_img) + combined_channels_data_arr = np.array(combined_channels_data) return combined_channels_data_arr - + def addEquationCombineMetrics(self, equation, colName, isMixedChannels): - section = 'mixed_channels_equations' if isMixedChannels else 'equations' + section = "mixed_channels_equations" if isMixedChannels else "equations" self.combineMetricsConfig[section][colName] = equation - def setCombineMetricsConfig(self, ini_path=''): + def setCombineMetricsConfig(self, ini_path=""): if ini_path: configPars = config.ConfigParser() configPars.read(ini_path) else: configPars = config.ConfigParser() - if 'equations' not in configPars: - configPars['equations'] = {} + if "equations" not in configPars: + configPars["equations"] = {} - if 'mixed_channels_equations' not in configPars: - configPars['mixed_channels_equations'] = {} + if "mixed_channels_equations" not in configPars: + configPars["mixed_channels_equations"] = {} - if 'user_path_equations' not in configPars: - configPars['user_path_equations'] = {} + if "user_path_equations" not in configPars: + configPars["user_path_equations"] = {} # Append channel specific equations from the user_profile_path ini file - userPathChEquations = configPars['user_path_equations'] + userPathChEquations = configPars["user_path_equations"] for chName in self.chNames: - chName_equations = measurements.get_user_combine_metrics_equations( - chName - ) + chName_equations = measurements.get_user_combine_metrics_equations(chName) chName_equations = { - key:val for key, val in chName_equations.items() - if key not in configPars['equations'] + key: val + for key, val in chName_equations.items() + if key not in configPars["equations"] } userPathChEquations = {**userPathChEquations, **chName_equations} - configPars['user_path_equations'] = userPathChEquations + configPars["user_path_equations"] = userPathChEquations # Append mixed channels equations from the user_profile_path ini file - configPars['mixed_channels_equations'] = { - **configPars['mixed_channels_equations'], - **measurements.get_user_combine_mixed_channels_equations() + configPars["mixed_channels_equations"] = { + **configPars["mixed_channels_equations"], + **measurements.get_user_combine_mixed_channels_equations(), } self.combineMetricsConfig = configPars def saveCombineMetrics(self): - with open(self.custom_combine_metrics_path, 'w') as configfile: + with open(self.custom_combine_metrics_path, "w") as configfile: self.combineMetricsConfig.write(configfile) - + def saveClickEntryPointsDfs(self): for tableEndName, df in self.clickEntryPointsDfs.items(): - if not self.basename.endswith('_'): - basename = f'{self.basename}_' + if not self.basename.endswith("_"): + basename = f"{self.basename}_" else: basename = self.basename - tableFilename = f'{basename}{tableEndName}.csv' + tableFilename = f"{basename}{tableEndName}.csv" tableFilepath = os.path.join(self.images_path, tableFilename) - df = df.sort_values(['frame_i', 'Cell_ID']) + df = df.sort_values(["frame_i", "Cell_ID"]) df.to_csv(tableFilepath, index=False) def check_acdc_df_integrity(self): check = ( - self.acdc_df_found is not None # acdc_df was laoded if present - and self.acdc_df is not None # acdc_df was present - and self.segmFound is not None # segm data was loaded if present - and self.segm_data is not None # segm data was present + self.acdc_df_found is not None # acdc_df was laoded if present + and self.acdc_df is not None # acdc_df was present + and self.segmFound is not None # segm data was loaded if present + and self.segm_data is not None # segm data was present ) if check: if self.SizeT > 1: @@ -2846,177 +2855,172 @@ def _fix_acdc_df(self, lab, frame_i=0): continue self.acdc_df.at[idx, col] = val y, x = obj.centroid - self.acdc_df.at[idx, 'x_centroid'] = x - self.acdc_df.at[idx, 'y_centroid'] = y + self.acdc_df.at[idx, "x_centroid"] = x + self.acdc_df.at[idx, "y_centroid"] = y def getSegmEndname(self): segmFilename = os.path.basename(self.segm_npz_path) segmFilename = os.path.splitext(segmFilename)[0] - segmEndName = segmFilename[len(self.basename):] + segmEndName = segmFilename[len(self.basename) :] return segmEndName - + def getSegmentedChannelHyperparams(self): run_num = self.getSegmHyperparamsNewRunNumber() cp = config.ConfigParser() if os.path.exists(self.segm_hyperparams_ini_path): cp.read(self.segm_hyperparams_ini_path) segmEndName = self.getSegmEndname() - metadata_section = f'{segmEndName}.metadata.run_number_{run_num}' + metadata_section = f"{segmEndName}.metadata.run_number_{run_num}" section = segmEndName - option = 'segmented_channel' - channel_name = cp.get( - metadata_section, option, fallback=self.user_ch_name - ) + option = "segmented_channel" + channel_name = cp.get(metadata_section, option, fallback=self.user_ch_name) return channel_name, segmEndName else: - return self.user_ch_name, '' - + return self.user_ch_name, "" + def getSegmHyperparamsNewRunNumber(self): run_num = 1 if not os.path.exists(self.segm_hyperparams_ini_path): return run_num - + cp = config.ConfigParser() cp.read(self.segm_hyperparams_ini_path) segmEndName = self.getSegmEndname() - metadata_section = f'{segmEndName}.metadata' + metadata_section = f"{segmEndName}.metadata" for section in cp.sections(): if section.startswith(metadata_section): run_num += 1 - + return run_num - + def updateSegmentedChannelHyperparams(self, channelName): if not os.path.exists(self.segm_hyperparams_ini_path): return - + cp = config.ConfigParser() cp.read(self.segm_hyperparams_ini_path) segmEndName = self.getSegmEndname() run_num = self.getSegmHyperparamsNewRunNumber() - metadata_section = f'{segmEndName}.metadata.run_number_{run_num}' + metadata_section = f"{segmEndName}.metadata.run_number_{run_num}" if metadata_section not in cp.sections(): return - - option = 'segmented_channel' + + option = "segmented_channel" cp[metadata_section][option] = channelName - with open(self.segm_hyperparams_ini_path, 'w') as configfile: + with open(self.segm_hyperparams_ini_path, "w") as configfile: cp.write(configfile) def saveSegmHyperparams( - self, model_name, init_kwargs, segment_kwargs, - post_process_params=None, - preproc_recipe=None - ): + self, + model_name, + init_kwargs, + segment_kwargs, + post_process_params=None, + preproc_recipe=None, + ): cp = config.ConfigParser() if os.path.exists(self.segm_hyperparams_ini_path): cp.read(self.segm_hyperparams_ini_path) - + segmEndName = self.getSegmEndname() - + # Remove old sections if present cp.remove_section(segmEndName) - + segm_filename = os.path.basename(self.segm_npz_path) - + run_num = self.getSegmHyperparamsNewRunNumber() - metadata_section = f'{segmEndName}.metadata' - metadata_section = f'{metadata_section}.run_number_{run_num}' + metadata_section = f"{segmEndName}.metadata" + metadata_section = f"{metadata_section}.run_number_{run_num}" cp[metadata_section] = {} - - cp[metadata_section]['segmentation_filename'] = segm_filename - cp[metadata_section]['segmented_channel'] = self.user_ch_name - now = datetime.now().strftime(r'%Y-%m-%d %H:%M:%S.%u') - cp[metadata_section]['segmented_on'] = now - cp[metadata_section]['model_name'] = model_name - - init_section = f'{segmEndName}.init' - init_section = f'{init_section}.run_number_{run_num}' - + + cp[metadata_section]["segmentation_filename"] = segm_filename + cp[metadata_section]["segmented_channel"] = self.user_ch_name + now = datetime.now().strftime(r"%Y-%m-%d %H:%M:%S.%u") + cp[metadata_section]["segmented_on"] = now + cp[metadata_section]["model_name"] = model_name + + init_section = f"{segmEndName}.init" + init_section = f"{init_section}.run_number_{run_num}" + cp[init_section] = {} for key, value in init_kwargs.items(): cp[init_section][key] = str(value) - - segment_section = f'{segmEndName}.segment' - segment_section = f'{segment_section}.run_number_{run_num}' + + segment_section = f"{segmEndName}.segment" + segment_section = f"{segment_section}.run_number_{run_num}" cp[segment_section] = {} for key, value in segment_kwargs.items(): cp[segment_section][key] = str(value) if post_process_params is not None: - post_process_section = f'{segmEndName}.postprocess' - post_process_section = f'{post_process_section}.run_number_{run_num}' + post_process_section = f"{segmEndName}.postprocess" + post_process_section = f"{post_process_section}.run_number_{run_num}" cp[post_process_section] = {} for key, value in post_process_params.items(): cp[post_process_section][key] = str(value) if preproc_recipe is not None: - preproc_ini_items = config.preprocess_recipe_to_ini_items( - preproc_recipe - ) + preproc_ini_items = config.preprocess_recipe_to_ini_items(preproc_recipe) for preproc_section, section_items in preproc_ini_items.items(): - segm_preproc_section = f'{segmEndName}.{preproc_section}' - segm_preproc_section = ( - f'{segm_preproc_section}.run_number_{run_num}' - ) + segm_preproc_section = f"{segmEndName}.{preproc_section}" + segm_preproc_section = f"{segm_preproc_section}.run_number_{run_num}" cp[segm_preproc_section] = {} for key, value in section_items.items(): cp[segm_preproc_section][key] = str(value) - - with open(self.segm_hyperparams_ini_path, 'w') as configfile: + + with open(self.segm_hyperparams_ini_path, "w") as configfile: cp.write(configfile) - + def isRecoveredAcdcDfPresent(self): recovery_folderpath = self.recoveryFolderpath() - unsaved_recovery_folderpath = os.path.join( - recovery_folderpath, 'never_saved' - ) + unsaved_recovery_folderpath = os.path.join(recovery_folderpath, "never_saved") if not os.path.exists(unsaved_recovery_folderpath): return - + files = myutils.listdir(unsaved_recovery_folderpath) - csv_files = [file for file in files if file.endswith('.csv')] + csv_files = [file for file in files if file.endswith(".csv")] if not csv_files: return - + if not os.path.exists(self.acdc_output_csv_path): acdc_df_mtime = 0 else: acdc_df_mtime = os.path.getmtime(self.acdc_output_csv_path) - + acdc_df_mdatetime = datetime.fromtimestamp(acdc_df_mtime) - + csv_files = natsorted(csv_files) iso_key = csv_files[-1][:-4] most_recent_unsaved_acdc_df_datetime = datetime.strptime( iso_key, ISO_TIMESTAMP_FORMAT ) return most_recent_unsaved_acdc_df_datetime > acdc_df_mdatetime - + def isSafeNpzOverwritePresent(self): - if not hasattr(self, 'segm_npz_path'): + if not hasattr(self, "segm_npz_path"): return False - - safe_npz_path = self.segm_npz_path.replace('.npz', '.new.npz') + + safe_npz_path = self.segm_npz_path.replace(".npz", ".new.npz") return os.path.exists(safe_npz_path) - + def getSafeNpzOverwritePath(self): - if not hasattr(self, 'segm_npz_path'): + if not hasattr(self, "segm_npz_path"): return - - safe_npz_path = self.segm_npz_path.replace('.npz', '.new.npz') + + safe_npz_path = self.segm_npz_path.replace(".npz", ".new.npz") return safe_npz_path - + def recoveryFolderpath(self, create_if_missing=True): - recovery_folder = os.path.join(self.images_path, 'recovery') + recovery_folder = os.path.join(self.images_path, "recovery") if not os.path.exists(recovery_folder) and create_if_missing: os.mkdir(recovery_folder) return recovery_folder - + def setTempPaths(self, createFolder=True): - temp_folder = os.path.join(self.images_path, 'recovery') + temp_folder = os.path.join(self.images_path, "recovery") self.recoveryFolderPath = temp_folder if not os.path.exists(temp_folder) and createFolder: os.mkdir(temp_folder) @@ -3024,63 +3028,59 @@ def setTempPaths(self, createFolder=True): acdc_df_filename = os.path.basename(self.acdc_output_csv_path) self.segm_npz_temp_path = os.path.join(temp_folder, segm_filename) self.acdc_output_backup_zip_path = os.path.join( - temp_folder, acdc_df_filename.replace('.csv', '.zip') - ) - unsaved_acdc_df_filename = acdc_df_filename.replace( - '.csv', '_autosave.zip' + temp_folder, acdc_df_filename.replace(".csv", ".zip") ) + unsaved_acdc_df_filename = acdc_df_filename.replace(".csv", "_autosave.zip") self.unsaved_acdc_df_autosave_path = os.path.join( temp_folder, unsaved_acdc_df_filename ) - + def buildPaths(self): - if self.basename.endswith('_'): + if self.basename.endswith("_"): basename = self.basename else: - basename = f'{self.basename}_' + basename = f"{self.basename}_" base_path = os.path.join(self.images_path, basename) - self.slice_used_align_path = f'{base_path}slice_used_alignment.csv' - self.slice_used_segm_path = f'{base_path}slice_segm.csv' - self.align_npz_path = f'{base_path}{self.user_ch_name}_aligned.npz' - self.align_old_path = f'{base_path}phc_aligned.npy' - self.align_shifts_path = f'{base_path}align_shift.npy' - self.segm_npz_path = f'{base_path}segm.npz' - self.last_tracked_i_path = f'{base_path}last_tracked_i.txt' - self.acdc_output_csv_path = f'{base_path}acdc_output.csv' - self.segmInfo_df_csv_path = f'{base_path}segmInfo.csv' - self.delROIs_info_path = f'{base_path}delROIsInfo.npz' - self.dataPrepROI_coords_path = f'{base_path}dataPrepROIs_coords.csv' + self.slice_used_align_path = f"{base_path}slice_used_alignment.csv" + self.slice_used_segm_path = f"{base_path}slice_segm.csv" + self.align_npz_path = f"{base_path}{self.user_ch_name}_aligned.npz" + self.align_old_path = f"{base_path}phc_aligned.npy" + self.align_shifts_path = f"{base_path}align_shift.npy" + self.segm_npz_path = f"{base_path}segm.npz" + self.last_tracked_i_path = f"{base_path}last_tracked_i.txt" + self.acdc_output_csv_path = f"{base_path}acdc_output.csv" + self.segmInfo_df_csv_path = f"{base_path}segmInfo.csv" + self.delROIs_info_path = f"{base_path}delROIsInfo.npz" + self.dataPrepROI_coords_path = f"{base_path}dataPrepROIs_coords.csv" # self.dataPrepBkgrValues_path = f'{base_path}dataPrep_bkgrValues.csv' - self.dataPrepBkgrROis_path = f'{base_path}dataPrep_bkgrROIs.json' - self.metadata_csv_path = f'{base_path}metadata.csv' - self.mot_events_path = f'{base_path}mot_events' - self.mot_metrics_csv_path = f'{base_path}mot_metrics' - self.raw_segm_npz_path = f'{base_path}segm_raw.npz' - self.raw_postproc_segm_path = f'{base_path}segm_raw_postproc' - self.post_proc_mot_metrics = f'{base_path}post_proc_mot_metrics' - self.segm_hyperparams_ini_path = f'{base_path}segm_hyperparams.ini' - self.custom_annot_json_path = f'{base_path}custom_annot_params.json' - self.custom_combine_metrics_path = ( - f'{base_path}custom_combine_metrics.ini' + self.dataPrepBkgrROis_path = f"{base_path}dataPrep_bkgrROIs.json" + self.metadata_csv_path = f"{base_path}metadata.csv" + self.mot_events_path = f"{base_path}mot_events" + self.mot_metrics_csv_path = f"{base_path}mot_metrics" + self.raw_segm_npz_path = f"{base_path}segm_raw.npz" + self.raw_postproc_segm_path = f"{base_path}segm_raw_postproc" + self.post_proc_mot_metrics = f"{base_path}post_proc_mot_metrics" + self.segm_hyperparams_ini_path = f"{base_path}segm_hyperparams.ini" + self.custom_annot_json_path = f"{base_path}custom_annot_params.json" + self.custom_combine_metrics_path = f"{base_path}custom_combine_metrics.ini" + self.sam_embeddings_path = f"{base_path}{self.user_ch_name}_sam_embeddings.pt" + self.tracked_lost_centroids_json_path = ( + f"{base_path}tracked_lost_centroids.json" ) - self.sam_embeddings_path =( - f'{base_path}{self.user_ch_name}_sam_embeddings.pt' - ) - self.tracked_lost_centroids_json_path = f'{base_path}tracked_lost_centroids.json' - self.acdc_output_auto_csv_path = f'{base_path}acdc_output_auto.csv' - + self.acdc_output_auto_csv_path = f"{base_path}acdc_output_auto.csv" + def get_btrack_export_path(self): - btrack_path = self.segm_npz_path.replace('.npz', '.h5') - btrack_path = btrack_path.replace('_segm', '_btrack_tracks') + btrack_path = self.segm_npz_path.replace(".npz", ".h5") + btrack_path = btrack_path.replace("_segm", "_btrack_tracks") return btrack_path - + def get_tracker_export_path(self, trackerName, ext): - tracker_path = self.segm_npz_path.replace('_segm', f'_{trackerName}_tracks') - tracker_path = tracker_path.replace('.npz', ext) + tracker_path = self.segm_npz_path.replace("_segm", f"_{trackerName}_tracks") + tracker_path = tracker_path.replace(".npz", ext) return tracker_path def setBlankSegmData(self, SizeT, SizeZ, SizeY, SizeX): - if not hasattr(self, 'img_data'): + if not hasattr(self, "img_data"): self.segm_data = None return @@ -3103,12 +3103,12 @@ def loadAllImgPaths(self): for filename in myutils.listdir(self.images_path): file_path = os.path.join(self.images_path, filename) f, ext = os.path.splitext(filename) - m = re.match(fr'{basename}.*\.tif', filename) + m = re.match(rf"{basename}.*\.tif", filename) if m is not None: tif_paths.append(file_path) # Search for npy fluo data - npy = f'{f}_aligned.npy' - npz = f'{f}_aligned.npz' + npy = f"{f}_aligned.npy" + npz = f"{f}_aligned.npz" npy_found = False npz_found = False for name in myutils.listdir(self.images_path): @@ -3128,15 +3128,15 @@ def loadAllImgPaths(self): self.npz_paths = npz_paths def checkH5memoryFootprint(self): - if self.ext != '.h5': + if self.ext != ".h5": return 0 else: Y, X = self.dset.shape[-2:] - size = self.loadSizeT*self.loadSizeZ*Y*X + size = self.loadSizeT * self.loadSizeZ * Y * X itemsize = self.dset.dtype.itemsize - required_memory = size*itemsize + required_memory = size * itemsize return required_memory - + def _warnMultiPosTimeLapse(self, SizeT_metadata): txt = html_utils.paragraph(f""" You are trying to load multiple Positions of what it seems to be @@ -3150,47 +3150,62 @@ def _warnMultiPosTimeLapse(self, SizeT_metadata): """) msg = widgets.myMessageBox(wrapText=False, showCentered=False) _, noButton, yesButton = msg.warning( - self.parent, 'WARNING: Edinting saved metadata', txt, - buttonsTexts=('Cancel', 'No, stop the process', 'Yes, proceed anyway') + self.parent, + "WARNING: Edinting saved metadata", + txt, + buttonsTexts=("Cancel", "No, stop the process", "Yes, proceed anyway"), ) return msg.clickedButton == yesButton def askInputMetadata( - self, numPos, - ask_SizeT=False, - ask_TimeIncrement=False, - ask_PhysicalSizes=False, - singlePos=False, - save=False, - askSegm3D=True, - forceEnableAskSegm3D=False, - warnMultiPos=False - ): + self, + numPos, + ask_SizeT=False, + ask_TimeIncrement=False, + ask_PhysicalSizes=False, + singlePos=False, + save=False, + askSegm3D=True, + forceEnableAskSegm3D=False, + warnMultiPos=False, + ): from . import apps + SizeZ_metadata = None SizeT_metadata = None - if hasattr(self, 'metadataFound'): + if hasattr(self, "metadataFound"): if self.metadataFound: SizeT_metadata = self.SizeT SizeZ_metadata = self.SizeZ - if SizeT_metadata>1 and numPos>1 and warnMultiPos: + if SizeT_metadata > 1 and numPos > 1 and warnMultiPos: proceed_anyway = self._warnMultiPosTimeLapse(SizeT_metadata) if not proceed_anyway: return False - - basename = '' - if hasattr(self, 'basename'): + + basename = "" + if hasattr(self, "basename"): basename = self.basename metadataWin = apps.QDialogMetadata( - self.SizeT, self.SizeZ, self.TimeIncrement, - self.PhysicalSizeZ, self.PhysicalSizeY, self.PhysicalSizeX, - ask_SizeT, ask_TimeIncrement, ask_PhysicalSizes, - parent=self.parent, font=apps.font, imgDataShape=self.img_data_shape, - posData=self, singlePos=singlePos, askSegm3D=askSegm3D, + self.SizeT, + self.SizeZ, + self.TimeIncrement, + self.PhysicalSizeZ, + self.PhysicalSizeY, + self.PhysicalSizeX, + ask_SizeT, + ask_TimeIncrement, + ask_PhysicalSizes, + parent=self.parent, + font=apps.font, + imgDataShape=self.img_data_shape, + posData=self, + singlePos=singlePos, + askSegm3D=askSegm3D, additionalValues=self._additionalMetadataValues, - forceEnableAskSegm3D=forceEnableAskSegm3D, - SizeT_metadata=SizeT_metadata, SizeZ_metadata=SizeZ_metadata, - basename=basename + forceEnableAskSegm3D=forceEnableAskSegm3D, + SizeT_metadata=SizeT_metadata, + SizeZ_metadata=SizeZ_metadata, + basename=basename, ) metadataWin.exec_() if metadataWin.cancel: @@ -3218,21 +3233,21 @@ def askInputMetadata( self._additionalMetadataValues = metadataWin._additionalValues if save: self.saveMetadata(additionalMetadata=metadataWin._additionalValues) - + metadataWin.deleteLater() return True - - def zSliceSegmentation(self, filename, frame_i, errors='raise'): + + def zSliceSegmentation(self, filename, frame_i, errors="raise"): if self.SizeZ > 1: idx = (filename, frame_i) try: - if self.segmInfo_df.at[idx, 'resegmented_in_gui']: - col = 'z_slice_used_gui' + if self.segmInfo_df.at[idx, "resegmented_in_gui"]: + col = "z_slice_used_gui" else: - col = 'z_slice_used_dataPrep' + col = "z_slice_used_dataPrep" z = self.segmInfo_df.at[idx, col] except Exception as err: - if errors == 'raise': + if errors == "raise": raise err else: return round(self.SizeZ / 2) @@ -3251,19 +3266,17 @@ def metadataToCsv(self, signals=None, mutex=None, waitCond=None): try: self.metadata_df.to_csv(self.metadata_csv_path) except PermissionError: - print('='*20) + print("=" * 20) traceback.print_exc() - print('='*20) + print("=" * 20) permissionErrorTxt = html_utils.paragraph( - f'The below file is open in another app (Excel maybe?).

    ' - f'{self.metadata_csv_path}

    ' + f"The below file is open in another app (Excel maybe?).

    " + f"{self.metadata_csv_path}

    " 'Close file and then press "Ok".' ) if signals is None: msg = widgets.myMessageBox(self.parent) - msg.warning( - self, 'Permission denied', permissionErrorTxt - ) + msg.warning(self, "Permission denied", permissionErrorTxt) self.metadata_df.to_csv(self.metadata_csv_path) else: mutex.lock() @@ -3273,46 +3286,45 @@ def metadataToCsv(self, signals=None, mutex=None, waitCond=None): self.metadata_df.to_csv(self.metadata_csv_path) def saveMetadata( - self, signals=None, mutex=None, waitCond=None, - additionalMetadata=None - ): + self, signals=None, mutex=None, waitCond=None, additionalMetadata=None + ): segmEndName = self.getSegmEndname() - isSegm3Dkey = f'{segmEndName}_isSegm3D' + isSegm3Dkey = f"{segmEndName}_isSegm3D" if self.metadata_df is None: metadata_dict = { - 'SizeT': self.SizeT, - 'SizeZ': self.SizeZ, - 'SizeY': self.SizeY, - 'SizeX': self.SizeX, - 'TimeIncrement': self.TimeIncrement, - 'PhysicalSizeZ': self.PhysicalSizeZ, - 'PhysicalSizeY': self.PhysicalSizeY, - 'PhysicalSizeX': self.PhysicalSizeX, - isSegm3Dkey: self.isSegm3D + "SizeT": self.SizeT, + "SizeZ": self.SizeZ, + "SizeY": self.SizeY, + "SizeX": self.SizeX, + "TimeIncrement": self.TimeIncrement, + "PhysicalSizeZ": self.PhysicalSizeZ, + "PhysicalSizeY": self.PhysicalSizeY, + "PhysicalSizeX": self.PhysicalSizeX, + isSegm3Dkey: self.isSegm3D, } if additionalMetadata is not None: metadata_dict = {**metadata_dict, **additionalMetadata} for key in list(metadata_dict.keys()): - if key.startswith('__') and key not in additionalMetadata: + if key.startswith("__") and key not in additionalMetadata: metadata_dict.pop(key) - self.metadata_df = pd.DataFrame(metadata_dict, index=['values']).T - self.metadata_df.index.name = 'Description' + self.metadata_df = pd.DataFrame(metadata_dict, index=["values"]).T + self.metadata_df.index.name = "Description" else: - self.metadata_df.at['SizeT', 'values'] = self.SizeT - self.metadata_df.at['SizeZ', 'values'] = self.SizeZ - self.metadata_df.at['TimeIncrement', 'values'] = self.TimeIncrement - self.metadata_df.at['PhysicalSizeZ', 'values'] = self.PhysicalSizeZ - self.metadata_df.at['PhysicalSizeY', 'values'] = self.PhysicalSizeY - self.metadata_df.at['PhysicalSizeX', 'values'] = self.PhysicalSizeX - self.metadata_df.at[isSegm3Dkey, 'values'] = self.isSegm3D + self.metadata_df.at["SizeT", "values"] = self.SizeT + self.metadata_df.at["SizeZ", "values"] = self.SizeZ + self.metadata_df.at["TimeIncrement", "values"] = self.TimeIncrement + self.metadata_df.at["PhysicalSizeZ", "values"] = self.PhysicalSizeZ + self.metadata_df.at["PhysicalSizeY", "values"] = self.PhysicalSizeY + self.metadata_df.at["PhysicalSizeX", "values"] = self.PhysicalSizeX + self.metadata_df.at[isSegm3Dkey, "values"] = self.isSegm3D if additionalMetadata is not None: for name, value in additionalMetadata.items(): - self.metadata_df.at[name, 'values'] = value + self.metadata_df.at[name, "values"] = value idx_to_drop = [] for name in self.metadata_df.index: - if name.startswith('__') and name not in additionalMetadata: + if name.startswith("__") and name not in additionalMetadata: idx_to_drop.append(name) self.metadata_df = self.metadata_df.drop(idx_to_drop) @@ -3323,137 +3335,171 @@ def saveMetadata( pass if additionalMetadata is not None: try: - with open(additional_metadata_path, mode='w') as file: + with open(additional_metadata_path, mode="w") as file: json.dump(additionalMetadata, file, indent=2) except PermissionError: pass def criticalExtNotValid(self, signals=None): - err_title = f'File extension {self.ext} not valid.' + err_title = f"File extension {self.ext} not valid." err_msg = ( - f'The requested file {self.relPath}\n' - 'has an invalid extension.\n\n' - 'Valid extensions are .tif, .tiff, .npy or .npz' + f"The requested file {self.relPath}\n" + "has an invalid extension.\n\n" + "Valid extensions are .tif, .tiff, .npy or .npz" ) if self.parent is None: - print('-------------------------') + print("-------------------------") print(err_msg) - print('-------------------------') + print("-------------------------") raise FileNotFoundError(err_title) elif signals is None: - print('-------------------------') + print("-------------------------") print(err_msg) - print('-------------------------') + print("-------------------------") msg = QMessageBox() msg.critical(self.parent, err_title, err_msg, msg.Ok) return None elif signals is not None: raise FileNotFoundError(err_title) - - def saveTrackedLostCentroids(self, tracked_lost_centroids_list=None, _tracked_lost_centroids_list=None): - if not (self.tracked_lost_centroids or tracked_lost_centroids_list or _tracked_lost_centroids_list): + def saveTrackedLostCentroids( + self, tracked_lost_centroids_list=None, _tracked_lost_centroids_list=None + ): + + if not ( + self.tracked_lost_centroids + or tracked_lost_centroids_list + or _tracked_lost_centroids_list + ): return if _tracked_lost_centroids_list is not None: tracked_lost_centroids_list = _tracked_lost_centroids_list elif tracked_lost_centroids_list is not None: - tracked_lost_centroids_list = {k: v for k, v in tracked_lost_centroids_list.items()} + tracked_lost_centroids_list = { + k: v for k, v in tracked_lost_centroids_list.items() + } else: - tracked_lost_centroids_list = {k: list(v) for k, v in self.tracked_lost_centroids.items()} + tracked_lost_centroids_list = { + k: list(v) for k, v in self.tracked_lost_centroids.items() + } # printl(tracked_lost_centroids_list) try: - with open(self.tracked_lost_centroids_json_path, 'w') as json_file: + with open(self.tracked_lost_centroids_json_path, "w") as json_file: json.dump(tracked_lost_centroids_list, json_file, indent=4) except PermissionError: - print('='*20) + print("=" * 20) traceback.print_exc() - print('='*20) + print("=" * 20) permissionErrorTxt = html_utils.paragraph( - f'The below file is open in another app (Excel maybe?).

    ' - f'{self.tracked_lost_centroids_json_path}

    ' + f"The below file is open in another app (Excel maybe?).

    " + f"{self.tracked_lost_centroids_json_path}

    " 'Close file and then press "Ok", or press "Cancel" to abort.' ) msg = widgets.myMessageBox(self.parent) msg.warning( - self, 'Permission denied', permissionErrorTxt, buttonsTexts=('Cancel', 'Ok') + self, + "Permission denied", + permissionErrorTxt, + buttonsTexts=("Cancel", "Ok"), ) if msg.cancel: return - - self.saveTrackedLostCentroids(_tracked_lost_centroids_list=tracked_lost_centroids_list) + + self.saveTrackedLostCentroids( + _tracked_lost_centroids_list=tracked_lost_centroids_list + ) def loadTrackedLostCentroids(self): try: - with open(self.tracked_lost_centroids_json_path, 'r') as json_file: + with open(self.tracked_lost_centroids_json_path, "r") as json_file: tracked_lost_centroids_list = json.load(json_file) - self.tracked_lost_centroids = {int(k): {tuple(int(val) for val in centroid) for centroid in v} for k, v in tracked_lost_centroids_list.items()} + self.tracked_lost_centroids = { + int(k): {tuple(int(val) for val in centroid) for centroid in v} + for k, v in tracked_lost_centroids_list.items() + } except FileNotFoundError: # print(f"No file found at {self.tracked_lost_centroids_json_path}") self.tracked_lost_centroids = { - frame_i:set() for frame_i in range(self.SizeT) - } + frame_i: set() for frame_i in range(self.SizeT) + } except PermissionError: - print('='*20) + print("=" * 20) traceback.print_exc() - print('='*20) + print("=" * 20) permissionErrorTxt = html_utils.paragraph( - f'The below file is open in another app (Excel maybe?).

    ' - f'{self.tracked_lost_centroids_json_path}

    ' + f"The below file is open in another app (Excel maybe?).

    " + f"{self.tracked_lost_centroids_json_path}

    " 'Close file and then press "Ok", or press "Cancel" to abort.' ) msg = widgets.myMessageBox(self.parent) msg.warning( - self, 'Permission denied', permissionErrorTxt, buttonsTexts=('Cancel', 'Ok') + self, + "Permission denied", + permissionErrorTxt, + buttonsTexts=("Cancel", "Ok"), ) if msg.cancel: self.tracked_lost_centroids = { - frame_i:set() for frame_i in range(self.SizeT) - } + frame_i: set() for frame_i in range(self.SizeT) + } return - + self.loadTrackedLostCentroids() - + def loadWhitelist(self): self.whitelist = whitelist.Whitelist( total_frames=self.SizeT, ) - whitelist_path = self.segm_npz_path.replace('.npz', '_whitelistIDs.json') - new_centroids_path = self.segm_npz_path.replace('.npz', '_new_centroids.json') + whitelist_path = self.segm_npz_path.replace(".npz", "_whitelistIDs.json") + new_centroids_path = self.segm_npz_path.replace(".npz", "_new_centroids.json") success = self.whitelist.load( - whitelist_path, new_centroids_path, self.segm_data, self.allData_li, + whitelist_path, + new_centroids_path, + self.segm_data, + self.allData_li, ) if self.log_func and success: filename = os.path.basename(whitelist_path) - self.log_func(f'Loaded whitelist from file: {filename}') + self.log_func(f"Loaded whitelist from file: {filename}") if not success: self.whitelist = None - + class select_exp_folder: def __init__(self): self.exp_path = None def QtPrompt( - self, parentQWidget, values, - current=0, title='Select Position folder', - CbLabel="Select folder to load:", - showinexplorer_button=False, full_paths=None, - allow_cancel=True, show=False, toggleMulti=False, - allowMultiSelection=True, - informativeText='', - selectedValues=None - ): + self, + parentQWidget, + values, + current=0, + title="Select Position folder", + CbLabel="Select folder to load:", + showinexplorer_button=False, + full_paths=None, + allow_cancel=True, + show=False, + toggleMulti=False, + allowMultiSelection=True, + informativeText="", + selectedValues=None, + ): from . import apps + font = QtGui.QFont() font.setPixelSize(13) win = apps.QtSelectItems( - title, values, informativeText, CbLabel=CbLabel, + title, + values, + informativeText, + CbLabel=CbLabel, parent=parentQWidget, - showInFileManagerPath=self.exp_path + showInFileManagerPath=self.exp_path, ) win.setFont(font) toFront = win.windowState() & ~Qt.WindowMinimized | Qt.WindowActive @@ -3474,22 +3520,22 @@ def QtPrompt( ] def append_last_cca_frame(self, acdc_df, text): - if 'cell_cycle_stage' not in acdc_df.columns: + if "cell_cycle_stage" not in acdc_df.columns: return text - + try: - colnames = ['frame_i', *cca_df_colnames] + colnames = ["frame_i", *cca_df_colnames] cca_df = acdc_df[colnames].dropna() except Exception as e: return text - last_cca_frame_i = max(cca_df['frame_i'], default=None) + last_cca_frame_i = max(cca_df["frame_i"], default=None) if last_cca_frame_i is None: return text - to_append = f', last cc annotated frame: {last_cca_frame_i+1})' - text = text.replace(')', to_append) + to_append = f", last cc annotated frame: {last_cca_frame_i + 1})" + text = text.replace(")", to_append) return text - + def get_values_segmGUI(self, exp_path): self.exp_path = exp_path pos_foldernames = myutils.get_pos_foldernames(exp_path) @@ -3498,18 +3544,18 @@ def get_values_segmGUI(self, exp_path): for pos in pos_foldernames: last_tracked_i_found = False pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") filenames = myutils.listdir(images_path) for filename in filenames: - if filename.find('acdc_output.csv') != -1: + if filename.find("acdc_output.csv") != -1: last_tracked_i_found = True acdc_df_path = os.path.join(images_path, filename) acdc_df = _load_acdc_df_file(acdc_df_path).reset_index() - last_tracked_i = acdc_df['frame_i'].max() + last_tracked_i = acdc_df["frame_i"].max() break - + if last_tracked_i_found: - text = f'{pos} (Last tracked frame: {last_tracked_i+1})' + text = f"{pos} (Last tracked frame: {last_tracked_i + 1})" text = self.append_last_cca_frame(acdc_df, text) values.append(text) else: @@ -3529,45 +3575,45 @@ def get_values_dataprep(self, exp_path): is_roi_info_present = False are_zslices_selected = False pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") filenames = myutils.listdir(images_path) for filename in filenames: - if filename.endswith('dataPrepROIs_coords.csv'): + if filename.endswith("dataPrepROIs_coords.csv"): is_roi_info_present = True filepath = os.path.join(images_path, filename) - df = pd.read_csv(filepath, index_col='description') - is_cropped = (df.loc[['cropped'], 'value'] > 0).any() - elif filename.endswith('dataPrep_bkgrROIs.json'): + df = pd.read_csv(filepath, index_col="description") + is_cropped = (df.loc[["cropped"], "value"] > 0).any() + elif filename.endswith("dataPrep_bkgrROIs.json"): is_bkgr_roi_info_present = True - elif filename.endswith('aligned.npz'): + elif filename.endswith("aligned.npz"): is_aligned = True - elif filename.endswith('align_shift.npy'): + elif filename.endswith("align_shift.npy"): is_aligned = True - elif filename.endswith('bkgrRoiData.npz'): + elif filename.endswith("bkgrRoiData.npz"): is_cropped = True - elif filename.endswith('segmInfo.csv'): + elif filename.endswith("segmInfo.csv"): are_zslices_selected = True - + is_bkgr_roi_info_present is_cropped is_roi_info_present - - info_txt = f'{pos} (' + + info_txt = f"{pos} (" if are_zslices_selected: - info_txt = f'{info_txt} z-slices selected,' + info_txt = f"{info_txt} z-slices selected," if is_aligned: - info_txt = f'{info_txt} aligned,' + info_txt = f"{info_txt} aligned," if is_roi_info_present: - info_txt = f'{info_txt} ROI info present,' + info_txt = f"{info_txt} ROI info present," if is_bkgr_roi_info_present: - info_txt = f'{info_txt} bkgr ROI info present,' + info_txt = f"{info_txt} bkgr ROI info present," if is_cropped: - info_txt = f'{info_txt} cropped' - - if info_txt.endswith('('): + info_txt = f"{info_txt} cropped" + + if info_txt.endswith("("): values.append(pos) else: - values.append(f'{info_txt})') + values.append(f"{info_txt})") self.values = values return values @@ -3575,7 +3621,7 @@ def get_values_cca(self, exp_path): self.exp_path = exp_path pos_foldernames = natsorted(myutils.listdir(exp_path)) pos_foldernames = [ - pos for pos in pos_foldernames if re.match(r'^Position_(\d+)', pos) + pos for pos in pos_foldernames if re.match(r"^Position_(\d+)", pos) ] self.pos_foldernames = pos_foldernames values = [] @@ -3583,21 +3629,20 @@ def get_values_cca(self, exp_path): cc_stage_found = False pos_path = os.path.join(exp_path, pos) if os.path.isdir(pos_path): - images_path = f'{exp_path}/{pos}/Images' + images_path = f"{exp_path}/{pos}/Images" filenames = myutils.listdir(images_path) for filename in filenames: - if filename.find('cc_stage.csv') != -1: + if filename.find("cc_stage.csv") != -1: cc_stage_found = True - cc_stage_path = f'{images_path}/{filename}' + cc_stage_path = f"{images_path}/{filename}" cca_df = pd.read_csv( - cc_stage_path, index_col=['frame_i', 'Cell_ID'] - ) - last_analyzed_frame_i = ( - cca_df.index.get_level_values(0).max() + cc_stage_path, index_col=["frame_i", "Cell_ID"] ) + last_analyzed_frame_i = cca_df.index.get_level_values(0).max() if cc_stage_found: - values.append(f'{pos} (Last analyzed frame: ' - f'{last_analyzed_frame_i})') + values.append( + f"{pos} (Last analyzed frame: {last_analyzed_frame_i})" + ) else: values.append(pos) self.values = values @@ -3619,7 +3664,7 @@ def on_closing(self): self.root.quit() self.root.destroy() if self.allow_abort: - exit('Execution aborted by the user') + exit("Execution aborted by the user") def load_shifts(parent_path, basename=None): @@ -3627,12 +3672,12 @@ def load_shifts(parent_path, basename=None): shifts = None if basename is None: for filename in myutils.listdir(parent_path): - if filename.find('align_shift.npy')>0: + if filename.find("align_shift.npy") > 0: shifts_found = True shifts_path = os.path.join(parent_path, filename) shifts = np.load(shifts_path) else: - align_shift_fn = f'{basename}_align_shift.npy' + align_shift_fn = f"{basename}_align_shift.npy" if align_shift_fn in myutils.listdir(parent_path): shifts_found = True shifts_path = os.path.join(parent_path, align_shift_fn) @@ -3641,6 +3686,7 @@ def load_shifts(parent_path, basename=None): shifts = None return shifts, shifts_found + class OMEXML_image: def __init__(self, Pixels, ome_schema): if Pixels is None: @@ -3649,19 +3695,23 @@ def __init__(self, Pixels, ome_schema): node = Pixels.attrib self.Pixels = OMEXML_Pixels(Pixels, node, ome_schema) + class OMEXML_objective: def __init__(self) -> None: self.LensNA = 1.4 + class OMEXML_intrument: def __init__(self): self.Objective = OMEXML_objective() + class OMEXML_Channel: def __init__(self, Channel) -> None: - self.Name = Channel.attrib.get('Name', '') + self.Name = Channel.attrib.get("Name", "") self.node = Channel.attrib + class OMEXML_Pixels: def __init__(self, Pixels, node, ome_schema) -> None: self.node = node @@ -3675,70 +3725,72 @@ def __init__(self, Pixels, node, ome_schema) -> None: self.PhysicalSizeY = 1.0 self.PhysicalSizeZ = 1.0 else: - self.SizeZ = node.get('SizeZ', 1) - self.SizeT = node.get('SizeT', 1) - self.SizeC = node.get('SizeC', 1) - self.PhysicalSizeX = node.get('PhysicalSizeX', 1.0) - self.PhysicalSizeY = node.get('PhysicalSizeY', 1.0) - self.PhysicalSizeZ = node.get('PhysicalSizeZ', 1.0) - + self.SizeZ = node.get("SizeZ", 1) + self.SizeT = node.get("SizeT", 1) + self.SizeC = node.get("SizeC", 1) + self.PhysicalSizeX = node.get("PhysicalSizeX", 1.0) + self.PhysicalSizeY = node.get("PhysicalSizeY", 1.0) + self.PhysicalSizeZ = node.get("PhysicalSizeZ", 1.0) + def Channel(self, channel_index=0): - Channel = self.Pixels.findall(f'{self.ome_schema}Channel')[channel_index] + Channel = self.Pixels.findall(f"{self.ome_schema}Channel")[channel_index] return OMEXML_Channel(Channel) + class OMEXML: def __init__(self, ometiff_filepath): self.filepath = ometiff_filepath self.read_omexml_string() self.parse_metadata() - + def read_omexml_string(self): with TiffFile(self.filepath) as tif: return tif.ome_metadata - + def parse_metadata(self): self.omexml_string = self.read_omexml_string() self.root = ET.fromstring(self.omexml_string) - self.ome_schema = re.findall(r'({.+})OME', self.root.tag)[0] - + self.ome_schema = re.findall(r"({.+})OME", self.root.tag)[0] + def instrument(self): instrument = OMEXML_intrument() - instrument_xml = self.root.find(f'{self.ome_schema}Instrument') + instrument_xml = self.root.find(f"{self.ome_schema}Instrument") if instrument_xml is None: return instrument - objective_xml = instrument_xml.find(f'{self.ome_schema}Objective') + objective_xml = instrument_xml.find(f"{self.ome_schema}Objective") if objective_xml is None: return instrument - LensNA = objective_xml.attrib.get('LensNA') + LensNA = objective_xml.attrib.get("LensNA") if LensNA is None: return instrument instrument.Objective.LensNA = LensNA return instrument def get_image_count(self): - return len(self.root.findall(f'{self.ome_schema}Image')) + return len(self.root.findall(f"{self.ome_schema}Image")) def image(self): - Image = self.root.find(f'{self.ome_schema}Image') - Pixels = Image.find(f'{self.ome_schema}Pixels') + Image = self.root.find(f"{self.ome_schema}Image") + Pixels = Image.find(f"{self.ome_schema}Pixels") image = OMEXML_image(Pixels, self.ome_schema) - image.Name = Image.attrib.get('Name', '') + image.Name = Image.attrib.get("Name", "") return image + def _restructure_multi_files_multi_pos( - src_path, dst_path, action='copy', signals=None, logger=print - ): + src_path, dst_path, action="copy", signals=None, logger=print +): if signals is not None: signals.initProgressBar.emit(0) - logger('Scanning files...') + logger("Scanning files...") files = list(os.listdir(src_path)) files = [f for f in files if os.path.isfile(os.path.join(src_path, f))] - + # Group files with same starting string with all possible splits files_scanned = list(files) groups = {} for f, file in enumerate(files): - splits = file.split('_') + splits = file.split("_") current_split = splits[0] for split in splits[1:]: for other_file in files_scanned: @@ -3747,9 +3799,9 @@ def _restructure_multi_files_multi_pos( groups[current_split] = {other_file} else: groups[current_split].add(other_file) - current_split = f'{current_split}_{split}' + current_split = f"{current_split}_{split}" files_scanned.pop(0) - + # Determine the keys of duplicated groups keys_duplicates = {} keys_scanned = list(groups.keys()) @@ -3763,11 +3815,11 @@ def _restructure_multi_files_multi_pos( else: keys_duplicates[key].add(other_key) keys_scanned.pop(0) - + # Get unique splits and sort them by length unique_splits = {max(splits, key=len) for splits in keys_duplicates.values()} unique_splits = sorted(list(unique_splits), key=len) - + # Get groups of files sharing the same starting groups_files = {} for split in unique_splits: @@ -3777,54 +3829,54 @@ def _restructure_multi_files_multi_pos( groups_files[split] = {file} else: groups_files[split].add(file) - + # Sort the files according to exp and pos splits - groups_n_splits = {len(split.split('_')):set() for split in groups_files} + groups_n_splits = {len(split.split("_")): set() for split in groups_files} for split in groups_files: - n_splits = len(split.split('_')) + n_splits = len(split.split("_")) groups_n_splits[n_splits].add(split) - + sorted_n_splits = sorted(groups_n_splits.keys()) n_splits_exp, n_splits_pos = sorted_n_splits[-2:] - final_structure = {} + final_structure = {} for split_exp in groups_n_splits[n_splits_exp]: exp_folder_path = os.path.join(dst_path, split_exp) exp_files = groups_files[split_exp] - pos_splits = groups_n_splits[n_splits_pos] + pos_splits = groups_n_splits[n_splits_pos] for exp_file in exp_files: p = 1 for pos_split in pos_splits: if not pos_split.startswith(split_exp): continue try: - pos_n = pos_split.split('_')[-1] + pos_n = pos_split.split("_")[-1] pos_n = int(pos_n) except Exception as e: pos_n = p - pos_path = os.path.join(exp_folder_path, f'Position_{pos_n}') - images_path = os.path.join(pos_path, 'Images') + pos_path = os.path.join(exp_folder_path, f"Position_{pos_n}") + images_path = os.path.join(pos_path, "Images") final_structure[images_path] = [] if not os.path.exists(images_path): os.makedirs(images_path, exist_ok=True) for file in files: if not file.startswith(pos_split): - continue + continue final_structure[images_path].append(file) - + p += 1 - + # Move or copy the files if signals is not None: signals.initProgressBar.emit(len(files)) - action_str = 'Copying' if action=='copy' else 'Moving' - logger(f'{action_str} files...') - pbar = tqdm(total=len(files), ncols=100, unit='file') + action_str = "Copying" if action == "copy" else "Moving" + logger(f"{action_str} files...") + pbar = tqdm(total=len(files), ncols=100, unit="file") for images_path, files in final_structure.items(): for file in files: dst_file = os.path.join(images_path, file) src_file = os.path.join(src_path, file) try: - if action == 'copy': + if action == "copy": shutil.copy2(src_file, dst_file) else: shutil.move(src_file, dst_file) @@ -3834,44 +3886,51 @@ def _restructure_multi_files_multi_pos( if signals is not None: signals.progressBar.emit(1) pbar.close() - - action_str = 'copied' if action=='copy' else 'moved' + + action_str = "copied" if action == "copy" else "moved" logger(f'Done! Files {action_str} and restructured into "{src_path}"') + def get_all_svg_icons_aliases(sort=True): from . import resources_filepath - with open(resources_filepath, 'r') as resources_file: + + with open(resources_filepath, "r") as resources_file: resources_txt = resources_file.read() - + aliases = re.findall(r'', resources_txt) if sort: aliases = natsorted(aliases) return aliases + def get_all_buttons_names(sort=True): - widgets_filepath = os.path.join(cellacdc_path, 'widgets.py') - with open(widgets_filepath, 'r') as py_file: + widgets_filepath = os.path.join(cellacdc_path, "widgets.py") + with open(widgets_filepath, "r") as py_file: txt = py_file.read() - - all_buttons_names = re.findall(r'class (\w+)\(Q?PushButton\):', txt) + + all_buttons_names = re.findall(r"class (\w+)\(Q?PushButton\):", txt) if sort: all_buttons_names = natsorted(all_buttons_names) return all_buttons_names -def rename_qrc_resources_file(scheme='light'): + +def rename_qrc_resources_file(scheme="light"): os.remove(qrc_resources_path) - - if scheme == 'dark' and os.path.exists(qrc_resources_dark_path): + + if scheme == "dark" and os.path.exists(qrc_resources_dark_path): shutil.copyfile(qrc_resources_dark_path, qrc_resources_path) - elif scheme == 'light' and os.path.exists(qrc_resources_light_path): + elif scheme == "light" and os.path.exists(qrc_resources_light_path): shutil.copyfile(qrc_resources_light_path, qrc_resources_path) -def autoLineBreak(text, length): #automatic line breaking for tooltips. Keeps indentation with spaces and preexisting line breaks + +def autoLineBreak( + text, length +): # automatic line breaking for tooltips. Keeps indentation with spaces and preexisting line breaks lines = [] current_line = [] # Split the text into lines while preserving existing newline characters - existing_lines = text.split('\n') + existing_lines = text.split("\n") for existing_line in existing_lines: # Calculate the indentation for the current line @@ -3879,22 +3938,25 @@ def autoLineBreak(text, length): #automatic line breaking for tooltips. Keeps in words = existing_line.lstrip().split() # Split each line into words for word in words: - if len(' '.join(current_line + [word])) + indent <= length: + if len(" ".join(current_line + [word])) + indent <= length: current_line.append(word) else: - lines.append(' ' * indent + ' '.join(current_line)) + lines.append(" " * indent + " ".join(current_line)) current_line = [word] if current_line: # Add any remaining words as the last line - lines.append(' ' * indent + ' '.join(current_line)) + lines.append(" " * indent + " ".join(current_line)) # Reset the current line for the next existing line current_line = [] - return '\n'.join(lines) + return "\n".join(lines) -def format_bullet_points(text): #indentation for bullet points in tooltips. Implementation not robust - lines = text.split('\n') + +def format_bullet_points( + text, +): # indentation for bullet points in tooltips. Implementation not robust + lines = text.split("\n") formatted_lines = [] indent = False indentNo = 0 @@ -3914,19 +3976,21 @@ def format_bullet_points(text): #indentation for bullet points in tooltips. Impl formatted_lines.append(formatted_line) - return '\n'.join(formatted_lines) + return "\n".join(formatted_lines) + -def format_number_list(text): #indentation for number points in tooltips. Implementation not robust - lines = text.split('\n') +def format_number_list( + text, +): # indentation for number points in tooltips. Implementation not robust + lines = text.split("\n") formatted_lines = [] indent = False indentNo = 0 for line in lines: - if line.strip().startswith(( - "0. ", "1. ", "2. ", "3. ", "4. ", - "5. ", "6. ", "7. ", "8. ", "9. " - )): + if line.strip().startswith( + ("0. ", "1. ", "2. ", "3. ", "4. ", "5. ", "6. ", "7. ", "8. ", "9. ") + ): indent = True formatted_line = line indentNo = len(line) - len(line.lstrip()) @@ -3940,10 +4004,10 @@ def format_number_list(text): #indentation for number points in tooltips. Implem formatted_lines.append(formatted_line) - return '\n'.join(formatted_lines) + return "\n".join(formatted_lines) -def get_tooltips_from_docs(): +def get_tooltips_from_docs(): # gets tooltips for GUI from .\Cell_ACDC\docs\source\tooltips.rst var_pattern = r"\|(\S*)\|" shortcut_pattern = r"\*\*(\".*\")\):\*\*" @@ -3951,17 +4015,26 @@ def get_tooltips_from_docs(): if not os.path.exists(tooltips_rst_filepath): return {} - + with open(tooltips_rst_filepath, "r") as file: lines = file.readlines() new_lines = [] for line in lines: - if not (line.startswith("..") or line.startswith(" :target:") or line.startswith(" :alt:") or line.startswith(" :width:") or line.startswith(" :height:") or line==""): + if not ( + line.startswith("..") + or line.startswith(" :target:") + or line.startswith(" :alt:") + or line.startswith(" :width:") + or line.startswith(" :height:") + or line == "" + ): new_lines.append(line) lines = new_lines - non_empty_lines = [line.replace("\n", "") for line in lines if line.strip()] #also removes \n from lines + non_empty_lines = [ + line.replace("\n", "") for line in lines if line.strip() + ] # also removes \n from lines lines = non_empty_lines tipdict = {} @@ -3977,7 +4050,7 @@ def get_tooltips_from_docs(): if shortcut: shortcut = shortcut.group(1) else: - shortcut = "\"No shortcut\"" + shortcut = '"No shortcut"' desc = line.split("):**")[1].lstrip(" ") @@ -3992,7 +4065,7 @@ def get_tooltips_from_docs(): followMatch = re.search(var_pattern, followLine) if followMatch or followLine.startswith("* **"): break - else: + else: descList.append(followLine) if descList != []: @@ -4000,9 +4073,7 @@ def get_tooltips_from_docs(): descList.pop(-1) descList.pop(-1) - for entry in descList: - entry = entry.replace("| ", "") if entry.startswith(" " * 4): @@ -4024,85 +4095,91 @@ def get_tooltips_from_docs(): tipdict[name] = f"Name: {title}\nShortcut: {shortcut}\n\n{desc}" return tipdict + def save_df_to_csv_temp_path(df, csv_filename, **to_csv_kwargs): tempDir = tempfile.mkdtemp() tempFilepath = os.path.join(tempDir, csv_filename) df.to_csv(tempFilepath, **to_csv_kwargs) return tempFilepath + def loaded_df_to_points_data(df, t_col, z_col, y_col, x_col): points_data = {} - if 'id' not in df.columns: - df['id'] = '' - - if t_col != 'None': + if "id" not in df.columns: + df["id"] = "" + + if t_col != "None": grouped = df.groupby(t_col) else: grouped = [(0, df)] - + for frame_i, df_frame in grouped: - if z_col != 'None': + if z_col != "None": df_frame[z_col] = df_frame[z_col].round().astype(int) # Use integer z zz = df_frame[z_col] - points_data[frame_i] = {} + points_data[frame_i] = {} for z in zz.values: df_z = df_frame[df_frame[z_col] == z] z_int = round(z) if z_int in points_data[frame_i]: continue points_data[frame_i][z_int] = { - 'x': df_z[x_col].to_list(), - 'y': df_z[y_col].to_list(), - 'id': df_z['id'].to_list(), - 'data': [row.to_string() for _, row in df_z.iterrows()] + "x": df_z[x_col].to_list(), + "y": df_z[y_col].to_list(), + "id": df_z["id"].to_list(), + "data": [row.to_string() for _, row in df_z.iterrows()], } else: points_data[frame_i] = { - 'x': df[x_col].to_list(), - 'y': df[y_col].to_list(), - 'id': df['id'].to_list(), - 'data': [row.to_string() for _, row in df.iterrows()] + "x": df[x_col].to_list(), + "y": df[y_col].to_list(), + "id": df["id"].to_list(), + "data": [row.to_string() for _, row in df.iterrows()], } return points_data + def load_df_points_layer(filepath): df = None - if filepath.endswith('.csv'): + if filepath.endswith(".csv"): df = pd.read_csv(filepath) - elif filepath.endswith('.h5'): + elif filepath.endswith(".h5"): with pd.HDFStore(filepath) as h5: keys = h5.keys() dfs = [h5.get(key) for key in keys] - df = pd.concat(dfs, keys=keys, names=['h5_key']) + df = pd.concat(dfs, keys=keys, names=["h5_key"]) return df + def get_unique_exp_paths(paths: List): unique_exp_paths = set() for path in paths: exp_path = get_exp_path(path) - unique_exp_paths.add(exp_path.replace('\\', '/')) + unique_exp_paths.add(exp_path.replace("\\", "/")) return unique_exp_paths + def search_filepath_in_pos_path_from_endname( - pos_path, endname, include_spotmax_out=False - ): - images_path = os.path.join(pos_path, 'Images') - spotmax_out_path = os.path.join(pos_path, 'spotMAX_output') + pos_path, endname, include_spotmax_out=False +): + images_path = os.path.join(pos_path, "Images") + spotmax_out_path = os.path.join(pos_path, "spotMAX_output") if include_spotmax_out and os.path.exists(spotmax_out_path): for sm_file in os.listdir(spotmax_out_path): if endname == sm_file: return os.path.join(spotmax_out_path, sm_file) - + images_files = myutils.listdir(images_path) sample_filepath = os.path.join(images_path, images_files[0]) - posData = loadData(sample_filepath, '') + posData = loadData(sample_filepath, "") posData.getBasenameAndChNames() - to_match = f'{posData.basename}{endname}' + to_match = f"{posData.basename}{endname}" for file in images_files: if file == to_match: return os.path.join(images_path, file) + def search_filepath_from_endname(exp_path, endname, include_spotmax_out=False): pos_foldernames = myutils.get_pos_foldernames(exp_path) for pos in pos_foldernames: @@ -4112,40 +4189,33 @@ def search_filepath_from_endname(exp_path, endname, include_spotmax_out=False): ) return filepath -def askOpenCsvFile( - title='Open CSV file', - start_dir=None, - qparent=None - ): + +def askOpenCsvFile(title="Open CSV file", start_dir=None, qparent=None): if start_dir is None: start_dir = myutils.getMostRecentPath() - - file_types = f'CSV files (*.csv);;All Files (*)' - + + file_types = f"CSV files (*.csv);;All Files (*)" + fileDialog = QFileDialog.getOpenFileName - args = ( - qparent, - title, - start_dir, - file_types - ) + args = (qparent, title, start_dir, file_types) file_path = fileDialog(*args) if not isinstance(file_path, str): file_path = file_path[0] return file_path + def read_measurements_workflow_from_config(filepath): configPars = config.ConfigParser() configPars.read(filepath) options_that_are_lists = { - 'channels', - 'calc_for_each_zslice_channels', - 'size_metrics_to_save', - 'regionprops_to_save', - 'channel_indipendent_custom_metrics_to_save', - 'mixed_combine_metrics_to_skip', - 'channel_names_to_skip', - 'channel_names_to_process' + "channels", + "calc_for_each_zslice_channels", + "size_metrics_to_save", + "regionprops_to_save", + "channel_indipendent_custom_metrics_to_save", + "mixed_combine_metrics_to_skip", + "channel_names_to_skip", + "channel_names_to_process", } ini_items = {} for section in configPars.sections(): @@ -4153,23 +4223,23 @@ def read_measurements_workflow_from_config(filepath): ini_items[section] = {} for option, value in options.items(): is_list = ( - section == 'paths_info' + section == "paths_info" or option in options_that_are_lists - or option.startswith('metrics_to_skip_') - or option.startswith('metrics_to_save_') + or option.startswith("metrics_to_skip_") + or option.startswith("metrics_to_save_") ) if is_list: if value: - value = value.strip('\n').strip().split('\n') + value = value.strip("\n").strip().split("\n") else: value = [] ini_items[section][option] = value continue - - if value.lower() == 'false': + + if value.lower() == "false": value = False - elif value.lower() == 'true': + elif value.lower() == "true": value = True - + ini_items[section][option] = value - return ini_items \ No newline at end of file + return ini_items diff --git a/cellacdc/measure.py b/cellacdc/measure.py index b18b7c646..eecc87f12 100644 --- a/cellacdc/measure.py +++ b/cellacdc/measure.py @@ -3,10 +3,13 @@ import skimage.transform import skimage.measure + def rotational_volume( - obj: skimage.measure._regionprops.RegionProperties, - PhysicalSizeY=1.0, PhysicalSizeX=1.0, vox_to_fl=None - ): + obj: skimage.measure._regionprops.RegionProperties, + PhysicalSizeY=1.0, + PhysicalSizeX=1.0, + vox_to_fl=None, +): """Given the region properties of a 2D or 3D object (from skimage.measure.regionprops). calculate the rotation volume as described in the Supplementary information of https://www.nature.com/articles/s41467-020-16764-x @@ -21,7 +24,7 @@ def rotational_volume( PhysicalSizeX : float, optional Physical size of the pixel in the X-diretion in micrometer/pixel. By default 1.0 - + Returns ------- tuple @@ -29,11 +32,11 @@ def rotational_volume( Notes ------- - For 3D objects we take the max projection. + For 3D objects we take the max projection. We convert PhysicalSizeY and PhysicalSizeX to float because when they are read from csv they might be a string value. - """ + """ if obj.image.ndim == 3: obj_image = obj.image.max(axis=0) obj_rp = skimage.measure.regionprops(obj_image.astype(np.uint8))[0] @@ -41,17 +44,21 @@ def rotational_volume( else: obj_image = obj.image obj_orientation = obj.orientation - + if vox_to_fl is None: - vox_to_fl = float(PhysicalSizeY)*(float(PhysicalSizeX)**2) - + vox_to_fl = float(PhysicalSizeY) * (float(PhysicalSizeX) ** 2) + rotate_ID_img = skimage.transform.rotate( - obj_image.astype(np.uint8), -(obj_orientation*180/np.pi), - resize=True, order=3, preserve_range=True + obj_image.astype(np.uint8), + -(obj_orientation * 180 / np.pi), + resize=True, + order=3, + preserve_range=True, ) - radii = np.sum(rotate_ID_img, axis=1)/2 - vol_vox = np.sum(np.pi*(radii**2)) - return vol_vox, float(vol_vox*vox_to_fl) + radii = np.sum(rotate_ID_img, axis=1) / 2 + vol_vox = np.sum(np.pi * (radii**2)) + return vol_vox, float(vol_vox * vox_to_fl) + def separate_with_label(lab, rp, IDs_to_separate, maxID, click_coords_list=None): separate_lab = lab.copy() @@ -80,7 +87,7 @@ def separate_with_label(lab, rp, IDs_to_separate, maxID, click_coords_list=None) click_y_local = yclick - ymin click_x_local = xclick - xmin id_to_keep = label_obj[click_y_local, click_x_local] - + separate_lab[obj.slice][obj.image] = 0 separateIDs = [] for sub_obj_idx, sub_obj in enumerate(label_obj_rp): @@ -91,4 +98,3 @@ def separate_with_label(lab, rp, IDs_to_separate, maxID, click_coords_list=None) separate_lab[obj.slice][sub_obj.slice][sub_obj.image] = new_ID separateIDs.append(new_ID) return separate_lab, separateIDs - \ No newline at end of file diff --git a/cellacdc/measurements.py b/cellacdc/measurements.py index 51404e67c..f2ff15930 100755 --- a/cellacdc/measurements.py +++ b/cellacdc/measurements.py @@ -15,60 +15,62 @@ from . import core, base_cca_dict, cca_df_colnames, html_utils, config, printl from . import user_profile_path, cca_functions -skimage_rp_url = 'https://scikit-image.org/docs/0.18.x/api/skimage.measure.html#skimage.measure.regionprops' +skimage_rp_url = "https://scikit-image.org/docs/0.18.x/api/skimage.measure.html#skimage.measure.regionprops" import warnings + warnings.filterwarnings("ignore", message="Failed to get convex hull image.") warnings.filterwarnings("ignore", message="divide by zero encountered in long_scalars") warnings.filterwarnings("ignore", message="Mean of empty slice.") warnings.filterwarnings("ignore", message="invalid value encountered in double_scalars") -acdc_metrics_path = os.path.join(user_profile_path, 'acdc-metrics') +acdc_metrics_path = os.path.join(user_profile_path, "acdc-metrics") if not os.path.exists(acdc_metrics_path): os.makedirs(acdc_metrics_path, exist_ok=True) sys.path.append(acdc_metrics_path) -combine_metrics_ini_path = os.path.join(acdc_metrics_path, 'combine_metrics.ini') +combine_metrics_ini_path = os.path.join(acdc_metrics_path, "combine_metrics.ini") cellacdc_path = os.path.dirname(os.path.abspath(__file__)) -metrics_path = os.path.join(cellacdc_path, 'metrics') +metrics_path = os.path.join(cellacdc_path, "metrics") -how_3D_to_2D_pattern = r'zSlice|3D|maxProj|meanProj|(?=\s*$)' +how_3D_to_2D_pattern = r"zSlice|3D|maxProj|meanProj|(?=\s*$)" # Copy metrics to acdc-metrics user path for file in os.listdir(metrics_path): - if not file.endswith('.py'): + if not file.endswith(".py"): continue src = os.path.join(metrics_path, file) dst = os.path.join(acdc_metrics_path, file) shutil.copy(src, dst) PROPS_DTYPES = { - 'label': int, - 'major_axis_length': float, - 'minor_axis_length': float, - 'eccentricity': float, - 'circularity': float, - 'roundness': float, - 'aspect_ratio': float, - 'inertia_tensor_eigvals': tuple, - 'equivalent_diameter': float, - 'moments': np.ndarray, - 'area': int, - 'solidity': float, - 'extent': float, - 'inertia_tensor': np.ndarray, - 'filled_area': int, - 'centroid': tuple, - 'bbox_area': int, - 'local_centroid': tuple, - 'convex_area': int, - 'euler_number': int, - 'moments_normalized': np.ndarray, - 'moments_central': np.ndarray, - 'bbox': tuple + "label": int, + "major_axis_length": float, + "minor_axis_length": float, + "eccentricity": float, + "circularity": float, + "roundness": float, + "aspect_ratio": float, + "inertia_tensor_eigvals": tuple, + "equivalent_diameter": float, + "moments": np.ndarray, + "area": int, + "solidity": float, + "extent": float, + "inertia_tensor": np.ndarray, + "filled_area": int, + "centroid": tuple, + "bbox_area": int, + "local_centroid": tuple, + "convex_area": int, + "euler_number": int, + "moments_normalized": np.ndarray, + "moments_central": np.ndarray, + "bbox": tuple, } + def getMetricsFunc(posData): metrics_func, all_metrics_names = standard_metrics_func() total_metrics = len(metrics_func) @@ -81,35 +83,40 @@ def getMetricsFunc(posData): # defined in loadData.setCombineMetricsConfig method for key, section in posData.combineMetricsConfig.items(): total_metrics += len(section) - + out = ( - metrics_func, all_metrics_names, custom_func_dict, total_metrics, - ch_indipend_custom_func_dict + metrics_func, + all_metrics_names, + custom_func_dict, + total_metrics, + ch_indipend_custom_func_dict, ) return out + def get_metric_group_name(col_name: str): size_metrics_names = set(get_size_metrics_desc(True, True).keys()) if col_name in size_metrics_names: - return 'size' - + return "size" + props_names = get_props_names() if col_name in props_names: - return 'regionprop' + return "regionprop" ch_indip_custom_metrics_names = _get_ch_indipendent_custom_metrics_names() if col_name in ch_indip_custom_metrics_names: - return 'ch_indipend_custom_metric' - + return "ch_indipend_custom_metric" + ch_indip_custom_metrics_names = _get_ch_indipendent_custom_metrics_names() if col_name in ch_indip_custom_metrics_names: - return 'mixed_channels' + return "mixed_channels" standard_metrics_names = set(_get_metrics_names().keys()) for col in standard_metrics_names: - if f'_{col_name}' in col: - channel_name = col_name.split(f'_{col_name}')[0] - return {'standard': channel_name} + if f"_{col_name}" in col: + channel_name = col_name.split(f"_{col_name}")[0] + return {"standard": channel_name} + def get_all_metrics_names(include_custom=True): all_metrics_names = [] @@ -126,93 +133,98 @@ def get_all_metrics_names(include_custom=True): all_metrics_names.extend(props_names) return all_metrics_names + def get_all_acdc_df_colnames(include_custom=True): all_acdc_df_colnames = get_all_metrics_names(include_custom=include_custom) - all_acdc_df_colnames.append('frame_i') - all_acdc_df_colnames.append('time_seconds') - all_acdc_df_colnames.append('Cell_ID') + all_acdc_df_colnames.append("frame_i") + all_acdc_df_colnames.append("time_seconds") + all_acdc_df_colnames.append("Cell_ID") all_acdc_df_colnames.extend(cca_df_colnames) additional_colnames = [ - 'is_cell_dead', - 'is_cell_excluded', - 'x_centroid', - 'y_centroid', - 'was_manually_edited' + "is_cell_dead", + "is_cell_excluded", + "x_centroid", + "y_centroid", + "was_manually_edited", ] all_acdc_df_colnames.extend(additional_colnames) return all_acdc_df_colnames + def get_user_combine_metrics_equations(chName, isSegm3D=False): _, equations = channel_combine_metrics_desc(chName, isSegm3D=isSegm3D) return equations + def get_custom_metrics_func(): scripts = os.listdir(acdc_metrics_path) custom_func_dict = {} for file in scripts: - if file == '__init__.py': + if file == "__init__.py": continue module_name, ext = os.path.splitext(file) - if ext != '.py': + if ext != ".py": # print(f'The file {file} is not a python file. Ignoring it.') continue - if module_name == 'combine_metrics_example': + if module_name == "combine_metrics_example": # Ignore the example continue - if module_name == 'channel_indipendent_metric_example': + if module_name == "channel_indipendent_metric_example": # Ignore the example continue try: module = import_module(module_name) - if not getattr(module, 'CALCULATE_FOR_EACH_CHANNEL', True): + if not getattr(module, "CALCULATE_FOR_EACH_CHANNEL", True): continue - + func = getattr(module, module_name) custom_func_dict[module_name] = func except Exception: traceback.print_exc() return custom_func_dict + def get_channel_indipendent_custom_metrics_func(): scripts = os.listdir(acdc_metrics_path) custom_func_dict = {} for file in scripts: - if file == '__init__.py': + if file == "__init__.py": continue module_name, ext = os.path.splitext(file) - if ext != '.py': + if ext != ".py": # print(f'The file {file} is not a python file. Ignoring it.') continue - if module_name == 'combine_metrics_example': + if module_name == "combine_metrics_example": # Ignore the example continue - if module_name == 'channel_indipendent_metric_example': + if module_name == "channel_indipendent_metric_example": # Ignore the example continue try: module = import_module(module_name) - if getattr(module, 'CALCULATE_FOR_EACH_CHANNEL', True): + if getattr(module, "CALCULATE_FOR_EACH_CHANNEL", True): continue - + func = getattr(module, module_name) custom_func_dict[module_name] = func except Exception: traceback.print_exc() return custom_func_dict + def read_saved_user_combine_config(): configPars = _get_saved_user_combine_config() if configPars is None: configPars = config.ConfigParser() - if 'equations' not in configPars: - configPars['equations'] = {} + if "equations" not in configPars: + configPars["equations"] = {} - if 'mixed_channels_equations' not in configPars: - configPars['mixed_channels_equations'] = {} + if "mixed_channels_equations" not in configPars: + configPars["mixed_channels_equations"] = {} - if 'channelLess_equations' not in configPars: - configPars['channelLess_equations'] = {} + if "channelLess_equations" not in configPars: + configPars["channelLess_equations"] = {} return configPars @@ -222,7 +234,7 @@ def _get_saved_user_combine_config(): configPars = None for file in files: module_name, ext = os.path.splitext(file) - if ext != '.ini': + if ext != ".ini": continue filePath = os.path.join(acdc_metrics_path, file) @@ -230,44 +242,50 @@ def _get_saved_user_combine_config(): configPars.read(filePath) return configPars + def add_user_combine_metrics(configPars, equation, colName, isMixedChannels): - section = 'mixed_channels_equations' if isMixedChannels else 'equations' + section = "mixed_channels_equations" if isMixedChannels else "equations" if section not in configPars: configPars[section] = {} configPars[section][colName] = equation return configPars + def add_channelLess_combine_metrics(configPars, equation, equation_name, terms): - if 'channelLess_equations' not in configPars: - configPars['channelLess_equations'] = {} - terms = ','.join(terms) - equation_terms = f'{equation};{terms}' - configPars['channelLess_equations'][equation_name] = equation_terms + if "channelLess_equations" not in configPars: + configPars["channelLess_equations"] = {} + terms = ",".join(terms) + equation_terms = f"{equation};{terms}" + configPars["channelLess_equations"][equation_name] = equation_terms return configPars + def save_common_combine_metrics(configPars): - with open(combine_metrics_ini_path, 'w') as configfile: + with open(combine_metrics_ini_path, "w") as configfile: configPars.write(configfile) + def _get_custom_metrics_names(): custom_func_dict = get_custom_metrics_func() keys = custom_func_dict.keys() - custom_metrics_names = {func_name:func_name for func_name in keys} + custom_metrics_names = {func_name: func_name for func_name in keys} return custom_metrics_names + def _get_ch_indipendent_custom_metrics_names(): custom_func_dict = get_channel_indipendent_custom_metrics_func() keys = custom_func_dict.keys() - custom_metrics_names = {func_name:func_name for func_name in keys} + custom_metrics_names = {func_name: func_name for func_name in keys} return custom_metrics_names + def ch_indipend_custom_metrics_desc(isZstack, isSegm3D=False): how_3Dto2D, how_3Dto2D_desc = get_how_3Dto2D(isZstack, isSegm3D) custom_metrics_names = _get_ch_indipendent_custom_metrics_names() custom_metrics_desc = {} for how, how_desc in zip(how_3Dto2D, how_3Dto2D_desc): for func_name, func_desc in custom_metrics_names.items(): - metric_name = f'{func_name}{how}' + metric_name = f"{func_name}{how}" if isZstack: note_txt = html_utils.paragraph(f""" {_get_zStack_note(how_desc)} @@ -276,7 +294,7 @@ def ch_indipend_custom_metrics_desc(isZstack, isSegm3D=False): converting 3D to 2D {how_desc} """) else: - note_txt = '' + note_txt = "" desc = html_utils.paragraph(f""" {func_desc} is a custom defined measurement.

    @@ -285,19 +303,19 @@ def ch_indipend_custom_metrics_desc(isZstack, isSegm3D=False): {note_txt} """) custom_metrics_desc[metric_name] = desc - + return custom_metrics_desc + def custom_metrics_desc( - isZstack, chName, posData=None, isSegm3D=False, - return_combine=False - ): + isZstack, chName, posData=None, isSegm3D=False, return_combine=False +): how_3Dto2D, how_3Dto2D_desc = get_how_3Dto2D(isZstack, isSegm3D) custom_metrics_names = _get_custom_metrics_names() custom_metrics_desc = {} for how, how_desc in zip(how_3Dto2D, how_3Dto2D_desc): for func_name, func_desc in custom_metrics_names.items(): - metric_name = f'{chName}_{func_name}{how}' + metric_name = f"{chName}_{func_name}{how}" if isZstack: note_txt = html_utils.paragraph(f""" {_get_zStack_note(how_desc)} @@ -306,7 +324,7 @@ def custom_metrics_desc( converting 3D to 2D {how_desc} """) else: - note_txt = '' + note_txt = "" desc = html_utils.paragraph(f""" {func_desc} is a custom defined measurement.

    @@ -326,13 +344,14 @@ def custom_metrics_desc( else: return custom_metrics_desc + def channel_combine_metrics_desc(chName, posData=None, isSegm3D=False): combine_metrics_configPars = read_saved_user_combine_config() how_3Dto2D, how_3Dto2D_desc = get_how_3Dto2D(True, isSegm3D) - combine_metrics = combine_metrics_configPars['equations'] + combine_metrics = combine_metrics_configPars["equations"] if posData is not None: - posDataEquations = posData.combineMetricsConfig['equations'] + posDataEquations = posData.combineMetricsConfig["equations"] combine_metrics = {**combine_metrics, **posDataEquations} combine_metrics_desc = {} all_metrics_names = get_all_metrics_names() @@ -354,7 +373,7 @@ def channel_combine_metrics_desc(chName, posData=None, isSegm3D=False): how_desc = how_3Dto2D_present[0] note_txt = html_utils.paragraph(f"""{_get_zStack_note(how_desc)}""") else: - note_txt = '' + note_txt = "" desc = html_utils.paragraph(f""" {metric_name} is a custom combined measurement that is the @@ -365,14 +384,14 @@ def channel_combine_metrics_desc(chName, posData=None, isSegm3D=False): combine_metrics_desc[metric_name] = desc equations[metric_name] = equation - channelLess_combine_metrics = combine_metrics_configPars['channelLess_equations'] + channelLess_combine_metrics = combine_metrics_configPars["channelLess_equations"] for name, equation_terms in channelLess_combine_metrics.items(): - channelLess_equation, terms = equation_terms.split(';') - _colNames = terms.split(',') - metric_name = f'{chName}_{name}' + channelLess_equation, terms = equation_terms.split(";") + _colNames = terms.split(",") + metric_name = f"{chName}_{name}" equation = channelLess_equation for _col in _colNames: - equation = equation.replace(_col, f'{chName}{_col}') + equation = equation.replace(_col, f"{chName}{_col}") if not any([metric in equation for metric in all_metrics_names]): # Equation does not contain any of the available metrics --> Skip it @@ -385,7 +404,7 @@ def channel_combine_metrics_desc(chName, posData=None, isSegm3D=False): how_desc = how_3Dto2D_present[0] note_txt = html_utils.paragraph(f"""{_get_zStack_note(how_desc)}""") else: - note_txt = '' + note_txt = "" desc = html_utils.paragraph(f""" {metric_name} is a custom combined measurement that is the @@ -398,14 +417,17 @@ def channel_combine_metrics_desc(chName, posData=None, isSegm3D=False): return combine_metrics_desc, equations + def get_user_combine_mixed_channels_equations(isSegm3D=False): _, equations = _combine_mixed_channels_desc(isSegm3D=isSegm3D) return equations + def get_combine_mixed_channels_desc(isSegm3D=False): desc, _ = _combine_mixed_channels_desc(isSegm3D=isSegm3D) return desc + def _combine_mixed_channels_desc(isSegm3D=False, configPars=None): if configPars is None: configPars = _get_saved_user_combine_config() @@ -415,7 +437,7 @@ def _combine_mixed_channels_desc(isSegm3D=False, configPars=None): equations = {} mixed_channels_desc = {} how_3Dto2D, how_3Dto2D_desc = get_how_3Dto2D(True, isSegm3D) - mixed_channels_combine_metrics = configPars['mixed_channels_equations'] + mixed_channels_combine_metrics = configPars["mixed_channels_equations"] all_metrics_names = get_all_metrics_names() equations = {} for name, equation in mixed_channels_combine_metrics.items(): @@ -431,7 +453,7 @@ def _combine_mixed_channels_desc(isSegm3D=False, configPars=None): how_desc = how_3Dto2D_present[0] note_txt = html_utils.paragraph(f"""{_get_zStack_note(how_desc)}""") else: - note_txt = '' + note_txt = "" desc = html_utils.paragraph(f""" {metric_name} is a custom combined measurement that is the @@ -443,6 +465,7 @@ def _combine_mixed_channels_desc(isSegm3D=False, configPars=None): equations[metric_name] = equation return mixed_channels_desc, equations + def combine_mixed_channels_desc(posData=None, isSegm3D=False, available_cols=None): desc, equations = _combine_mixed_channels_desc(isSegm3D=isSegm3D) if posData is None: @@ -454,13 +477,13 @@ def combine_mixed_channels_desc(posData=None, isSegm3D=False, available_cols=Non ) all_desc = {**desc, **pos_desc} all_equations = {**equations, **pos_equations} - + if available_cols is not None: # Check that user folder combine metrics have the right columns available_desc = {} available_equations = {} for name, equation in all_equations.items(): - cols = re.findall(r'[A-Za-z0-9]+_[A-Za-z0-9_]+', equation) + cols = re.findall(r"[A-Za-z0-9]+_[A-Za-z0-9_]+", equation) if all([col in available_cols for col in cols]): available_desc[name] = all_desc[name] available_equations[name] = equation @@ -468,34 +491,40 @@ def combine_mixed_channels_desc(posData=None, isSegm3D=False, available_cols=Non else: return all_desc, all_equations + def _um3(): - return 'µm3' + return "µm3" + def _um2(): - return 'µm2' + return "µm2" + def _um(): - return 'µ' + return "µ" + def _fl(): - return 'fl' + return "fl" + def _get_zStack_note(how_desc): - s = (f""" + s = f""" NOTE: since you loaded 3D z-stacks, Cell-ACDC needs to convert the z-stacks to 2D images {how_desc} for this metric.
    This is specified in the name of the column.

    - """) + """ return s + def get_size_metrics_desc(isSegm3D, is_timelapse): - url = 'https://www.nature.com/articles/s41467-020-16764-x#Sec16' + url = "https://www.nature.com/articles/s41467-020-16764-x#Sec16" size_metrics = { - 'cell_area_pxl': html_utils.paragraph(""" + "cell_area_pxl": html_utils.paragraph(""" Area of the segmented object in pixels, i.e., total number of pixels in the object. """), - 'cell_vol_vox': html_utils.paragraph(f""" + "cell_vol_vox": html_utils.paragraph(f""" Estimated volume of the segmented object in voxels.


    To calculate object volume based on 2D masks, the object is first aligned along its major axis.

    @@ -521,13 +550,13 @@ def get_size_metrics_desc(isSegm3D, is_timelapse): (see in the Standard measurements group) and it cannot be unchecked.

    """), - 'cell_area_um2': html_utils.paragraph(f""" + "cell_area_um2": html_utils.paragraph(f""" Area of the segmented object in {_um2()}, i.e., total number of pixels in the object.

    Conversion from pixels to {_um2()} is perfomed using the provided pixel size. """), - 'cell_vol_fl': html_utils.paragraph(f""" + "cell_vol_fl": html_utils.paragraph(f""" Estimated volume of the segmented object in {_um3()}.


    To calculate object volume based on 2D masks, the object is first @@ -556,11 +585,11 @@ def get_size_metrics_desc(isSegm3D, is_timelapse): by the concentration metric that you requested to save (see in the Standard measurements group) and it cannot be unchecked.

    - """) + """), } if isSegm3D: size_metrics_3D = { - 'cell_vol_vox_3D': html_utils.paragraph(f""" + "cell_vol_vox_3D": html_utils.paragraph(f""" Volume of the segmented object in voxels.

    This is given by the total number of voxels inside the object.

    @@ -569,7 +598,7 @@ def get_size_metrics_desc(isSegm3D, is_timelapse): (see in the Standard measurements group) and it cannot be unchecked.

    """), - 'cell_vol_fl_3D': html_utils.paragraph(f""" + "cell_vol_fl_3D": html_utils.paragraph(f""" Volume of the segmented object in {_fl()}.

    This is given by the total number of voxels inside the object multiplied by the voxel volume.

    @@ -589,61 +618,59 @@ def get_size_metrics_desc(isSegm3D, is_timelapse): size_metrics = {**size_metrics, **size_metrics_3D} if is_timelapse: velocity_metrics = { - 'velocity_pixel': html_utils.paragraph(f""" + "velocity_pixel": html_utils.paragraph(f""" Velocity in [pixel/frame] of the segmented object between previous and current frame. """), - 'velocity_um': html_utils.paragraph(f""" + "velocity_um": html_utils.paragraph(f""" Velocity in [{_um()}/frame] of the segmented object between previous and current frame. - """) + """), } size_metrics = {**size_metrics, **velocity_metrics} return size_metrics + def get_how_3Dto2D(isZstack, isSegm3D): - how_3Dto2D = ['_maxProj', '_meanProj', '_zSlice'] if isZstack else [''] + how_3Dto2D = ["_maxProj", "_meanProj", "_zSlice"] if isZstack else [""] if isSegm3D: - how_3Dto2D.append('_3D') + how_3Dto2D.append("_3D") how_3Dto2D_desc = [ - 'using a max projection', - 'using a mean projection (recommended for confocal imaging)', - 'using the z-slice you used for segmentation ' - '(recommended for epifluorescence imaging)' - 'NOTE: if segmentation mask is 3D, Cell-ACDC will use the ' - 'center z-slice of each object.', - 'using 3D data' + "using a max projection", + "using a mean projection (recommended for confocal imaging)", + "using the z-slice you used for segmentation " + "(recommended for epifluorescence imaging)" + "NOTE: if segmentation mask is 3D, Cell-ACDC will use the " + "center z-slice of each object.", + "using 3D data", ] return how_3Dto2D, how_3Dto2D_desc + def standard_metrics_desc( - isZstack, chName, isManualBackgrPresent=False, isSegm3D=False - ): + isZstack, chName, isManualBackgrPresent=False, isSegm3D=False +): how_3Dto2D, how_3Dto2D_desc = get_how_3Dto2D(isZstack, isSegm3D) - metrics_names = _get_metrics_names( - is_manual_bkgr_present=isManualBackgrPresent - ) - bkgr_val_names = _get_bkgr_val_names( - is_manual_bkgr_present=isManualBackgrPresent - ) + metrics_names = _get_metrics_names(is_manual_bkgr_present=isManualBackgrPresent) + bkgr_val_names = _get_bkgr_val_names(is_manual_bkgr_present=isManualBackgrPresent) metrics_desc = {} bkgr_val_desc = {} for how, how_desc in zip(how_3Dto2D, how_3Dto2D_desc): for func_name, func_desc in metrics_names.items(): - metric_name = f'{chName}_{func_name}{how}' + metric_name = f"{chName}_{func_name}{how}" if isZstack: - note_txt = (f""" + note_txt = f""" {_get_zStack_note(how_desc)} Example: {metric_name} is the {func_desc.lower()} of the {chName} signal after converting 3D to 2D {how_desc} - """) + """ else: - note_txt = '' + note_txt = "" - if func_desc == 'Amount': + if func_desc == "Amount": amount_formula = _get_amount_formula_str(func_name) - amount_desc = (f""" + amount_desc = f""" Amount is the background corrected (subtracted) total fluorescence intensity, which is usually the best proxy for the amount of the tagged molecule, e.g., @@ -655,32 +682,32 @@ def standard_metrics_desc( where _obj refers to the pixels inside the segmented object.

    - """) - main_desc = f'{func_desc} computed from' - elif func_desc == 'Concentration': - amount_desc = (""" + """ + main_desc = f"{func_desc} computed from" + elif func_desc == "Concentration": + amount_desc = """ Concentration is given by Amount/cell_volume, where amount is the background corrected (subtracted) total fluorescence intensity. Amount is usually the best proxy for the amount of the tagged molecule, e.g., protein amount.

    - """) - main_desc = f'{func_desc} computed from' + """ + main_desc = f"{func_desc} computed from" else: - amount_desc = '' - main_desc = f'{func_desc} computed from' + amount_desc = "" + main_desc = f"{func_desc} computed from" - if func_name == 'amount_autoBkgr': - bkgr_desc = (""" + if func_name == "amount_autoBkgr": + bkgr_desc = """ autoBkgr means that the background value used to correct the intensities is computed as the median of ALL the pixels outside of the segmented objects (i.e., pixels with ID 0 in the segmentation mask)

    - """) - elif func_name == 'amount_dataPrepBkgr': - bkgr_desc = (""" + """ + elif func_name == "amount_dataPrepBkgr": + bkgr_desc = """ dataPrepBkgr means that the background value used to correct the intensities is computed as the median of the pixels from the pixels inside the rectangular @@ -688,17 +715,17 @@ def standard_metrics_desc( data prep module (module 1.).

    Note taht this metric is grayed out and it cannot be selected if the selection of the background ROIs was not performed. - """) - elif func_name.find('_manualBkgr') != -1: - bkgr_desc = (""" + """ + elif func_name.find("_manualBkgr") != -1: + bkgr_desc = """ manualBkgr means that the background value used to correct the intensities is computed as the mean of the pixels from the pixels inside each background objects that you selected in the GUI module (module 3).

    - """) + """ else: - bkgr_desc = '' + bkgr_desc = "" desc = html_utils.paragraph(f""" {main_desc} the pixels inside @@ -707,34 +734,34 @@ def standard_metrics_desc( """) metrics_desc[metric_name] = desc - median_note = (""" + median_note = """ Note that this value might be grayed out because it is required by the corresponding amount metric that you requested to save (see above in the Standard measurements group) and it cannot be unchecked.

    - """) + """ for bkgr_name, bkgr_desc in bkgr_val_names.items(): - bkgr_colname = f'{chName}_{bkgr_name}{how}' + bkgr_colname = f"{chName}_{bkgr_name}{how}" if isZstack: - note_txt = (f""" + note_txt = f""" {_get_zStack_note(how_desc)} Example: {bkgr_colname} is the {bkgr_desc.lower()} of the {chName} background after converting 3D to 2D {how_desc} - """) + """ else: - note_txt = '' + note_txt = "" - if bkgr_name.find('autoBkgr') != -1: - bkgr_type_desc = (""" + if bkgr_name.find("autoBkgr") != -1: + bkgr_type_desc = """ autoBkgr means that the background value is computed from ALL the pixels outside of the segmented objects (i.e., pixels with ID 0 in the segmentation mask)

    - """) + """ else: - bkgr_type_desc = (""" + bkgr_type_desc = """ dataPrepBkgr means that the background value is computed from the pixels inside the rectangular background ROIs that you selected in the @@ -742,9 +769,9 @@ def standard_metrics_desc( Note taht this metric is grayed out and it cannot be selected if the selection of the background ROIs was not performed.

    - """) - if bkgr_name.find('bkgrVal_median') != -1: - bkgr_type_desc = f'{bkgr_type_desc}{median_note}' + """ + if bkgr_name.find("bkgrVal_median") != -1: + bkgr_type_desc = f"{bkgr_type_desc}{median_note}" bkgr_final_desc = html_utils.paragraph(f""" {bkgr_desc} of the background intensities.

    @@ -754,38 +781,36 @@ def standard_metrics_desc( return metrics_desc, bkgr_val_desc + def get_conc_keys(amount_colname): conc_key_vox = re.sub( - r'amount_([A-Za-z]+)', - r'concentration_\1_from_vol_vox', - amount_colname + r"amount_([A-Za-z]+)", r"concentration_\1_from_vol_vox", amount_colname ) - conc_key_fl = conc_key_vox.replace('from_vol_vox', 'from_vol_fl') + conc_key_fl = conc_key_vox.replace("from_vol_vox", "from_vol_fl") return conc_key_vox, conc_key_fl + def classify_acdc_df_colnames(acdc_df, channels): standard_funcs = _get_metrics_names() size_metrics_desc = get_size_metrics_desc(True, True) props_names = get_props_names() - foregr_metrics = {ch:[] for ch in channels} - bkgr_metrics = {ch:[] for ch in channels} - custom_metrics = {ch:[] for ch in channels} + foregr_metrics = {ch: [] for ch in channels} + bkgr_metrics = {ch: [] for ch in channels} + custom_metrics = {ch: [] for ch in channels} size_metrics = [] props_metrics = [] for col in acdc_df.columns: for ch in channels: - if col.startswith(f'{ch}_'): + if col.startswith(f"{ch}_"): # Channel specific metric - if col.find('_bkgrVal_') != -1: + if col.find("_bkgrVal_") != -1: # Bkgr metric bkgr_metrics[ch].append(col) else: # Foregr metric - is_standard = any( - [col.find(f'_{f}') != -1 for f in standard_funcs] - ) + is_standard = any([col.find(f"_{f}") != -1 for f in standard_funcs]) if is_standard: # Standard metric foregr_metrics[ch].append(col) @@ -801,70 +826,74 @@ def classify_acdc_df_colnames(acdc_df, channels): elif col in props_names: # Regionprop metric props_metrics.append(col) - + metrics = { - 'foregr': foregr_metrics, - 'bkgr': bkgr_metrics, - 'custom': custom_metrics, - 'size': size_metrics, - 'props': props_metrics + "foregr": foregr_metrics, + "bkgr": bkgr_metrics, + "custom": custom_metrics, + "size": size_metrics, + "props": props_metrics, } return metrics + def _get_metrics_names(is_manual_bkgr_present=False): metrics_names = { - 'mean': 'Mean', - 'sum': 'Sum', - 'amount_autoBkgr': 'Amount', - 'amount_dataPrepBkgr': 'Amount', - 'amount_manualBkgr': 'Amount', - 'mean_manualBkgr': 'Mean', - 'concentration_autoBkgr_from_vol_vox': 'Concentration', - 'concentration_dataPrepBkgr_from_vol_vox': 'Concentration', - 'concentration_autoBkgr_from_vol_fl': 'Concentration', - 'concentration_dataPrepBkgr_from_vol_fl': 'Concentration', - 'median': 'Median', - 'min': 'Minimum', - 'max': 'Maximum', - 'q25': '25 percentile', - 'q75': '75 percentile', - 'q05': '5 percentile', - 'q95': '95 percentile', + "mean": "Mean", + "sum": "Sum", + "amount_autoBkgr": "Amount", + "amount_dataPrepBkgr": "Amount", + "amount_manualBkgr": "Amount", + "mean_manualBkgr": "Mean", + "concentration_autoBkgr_from_vol_vox": "Concentration", + "concentration_dataPrepBkgr_from_vol_vox": "Concentration", + "concentration_autoBkgr_from_vol_fl": "Concentration", + "concentration_dataPrepBkgr_from_vol_fl": "Concentration", + "median": "Median", + "min": "Minimum", + "max": "Maximum", + "q25": "25 percentile", + "q75": "75 percentile", + "q05": "5 percentile", + "q95": "95 percentile", } return metrics_names + def _get_amount_formula_str(func_name): - if func_name.find('manualBkgr') != -1: - formula = 'amount = (mean_obj - mean_background)*area_obj' + if func_name.find("manualBkgr") != -1: + formula = "amount = (mean_obj - mean_background)*area_obj" else: - formula = 'amount = (mean_obj - median_background)*area_obj' + formula = "amount = (mean_obj - median_background)*area_obj" return formula + def _get_bkgr_val_names(is_manual_bkgr_present=False): bkgr_val_names = { - 'autoBkgr_bkgrVal_median': 'Median', - 'autoBkgr_bkgrVal_mean': 'Mean', - 'autoBkgr_bkgrVal_q75': '75 percentile', - 'autoBkgr_bkgrVal_q25': '25 percentile', - 'autoBkgr_bkgrVal_q95': '95 percentile', - 'autoBkgr_bkgrVal_q05': '5 percentile', - 'dataPrepBkgr_bkgrVal_median': 'Median', - 'dataPrepBkgr_bkgrVal_mean': 'Mean', - 'dataPrepBkgr_bkgrVal_q75': '75 percentile', - 'dataPrepBkgr_bkgrVal_q25': '25 percentile', - 'dataPrepBkgr_bkgrVal_q95': '95 percentile', - 'dataPrepBkgr_bkgrVal_q05': '5 percentile', + "autoBkgr_bkgrVal_median": "Median", + "autoBkgr_bkgrVal_mean": "Mean", + "autoBkgr_bkgrVal_q75": "75 percentile", + "autoBkgr_bkgrVal_q25": "25 percentile", + "autoBkgr_bkgrVal_q95": "95 percentile", + "autoBkgr_bkgrVal_q05": "5 percentile", + "dataPrepBkgr_bkgrVal_median": "Median", + "dataPrepBkgr_bkgrVal_mean": "Mean", + "dataPrepBkgr_bkgrVal_q75": "75 percentile", + "dataPrepBkgr_bkgrVal_q25": "25 percentile", + "dataPrepBkgr_bkgrVal_q95": "95 percentile", + "dataPrepBkgr_bkgrVal_q05": "5 percentile", } if is_manual_bkgr_present: - bkgr_val_names['manualBkgr_bkgrVal_median'] = 'Median' - bkgr_val_names['manualBkgr_bkgrVal_mean'] = 'Mean' - bkgr_val_names['manualBkgr_bkgrVal_q75'] = '75 percentile' - bkgr_val_names['manualBkgr_bkgrVal_q25'] = '25 percentile' - bkgr_val_names['manualBkgr_bkgrVal_q95'] = '95 percentile' - bkgr_val_names['manualBkgr_bkgrVal_q05'] = '5 percentile' + bkgr_val_names["manualBkgr_bkgrVal_median"] = "Median" + bkgr_val_names["manualBkgr_bkgrVal_mean"] = "Mean" + bkgr_val_names["manualBkgr_bkgrVal_q75"] = "75 percentile" + bkgr_val_names["manualBkgr_bkgrVal_q25"] = "25 percentile" + bkgr_val_names["manualBkgr_bkgrVal_q95"] = "95 percentile" + bkgr_val_names["manualBkgr_bkgrVal_q05"] = "5 percentile" return bkgr_val_names + def _get_props_info_txt(): txt = html_utils.paragraph(f""" Morphological properties are calculated using the function @@ -875,6 +904,7 @@ def _get_props_info_txt(): """) return txt + def _get_info_circularity(): info_txt = html_utils.paragraph(f""" Circularity is defined as the ratio between @@ -888,6 +918,7 @@ def _get_info_circularity(): """) return info_txt + def _get_info_roundness(): info_txt = html_utils.paragraph(f""" Roundness is defined as the ratio between @@ -898,11 +929,12 @@ def _get_info_roundness(): You can find more details about the major axis and the area here scikit-image regionprops. """) - + return info_txt + def _get_info_aspect_ratio(): - + info_txt = html_utils.paragraph(f""" Aspect ratio is defined as the ratio between the major and minor axis of the object.

    @@ -912,9 +944,10 @@ def _get_info_aspect_ratio(): You can find more details about major and minor axis here scikit-image regionprops. """) - + return info_txt + def get_props_info_txt_mapper(isSegm3D=False): skimage_desc = _get_props_info_txt() if isSegm3D: @@ -922,19 +955,19 @@ def get_props_info_txt_mapper(isSegm3D=False): else: props_names = get_props_names() mapper = {prop: skimage_desc for prop in props_names} - - mapper['circularity'] = _get_info_circularity() - mapper['roundness'] = _get_info_roundness() - mapper['aspect_ratio'] = _get_info_aspect_ratio() - + + mapper["circularity"] = _get_info_circularity() + mapper["roundness"] = _get_info_roundness() + mapper["aspect_ratio"] = _get_info_aspect_ratio() + return mapper + def _is_numeric_dtype(dtype): - is_numeric = ( - dtype is float or dtype is int - ) + is_numeric = dtype is float or dtype is int return is_numeric + def get_bkgrROI_mask(posData, isSegm3D): if posData.bkgrROIs: ROI_bkgrMask = np.zeros(posData.lab.shape, bool) @@ -943,18 +976,17 @@ def get_bkgrROI_mask(posData, isSegm3D): xl, yl = [int(round(c)) for c in roi.pos()] w, h = [int(round(c)) for c in roi.size()] if isSegm3D: - ROI_bkgrMask[:, yl:yl+h, xl:xl+w] = True + ROI_bkgrMask[:, yl : yl + h, xl : xl + w] = True else: - ROI_bkgrMask[yl:yl+h, xl:xl+w] = True + ROI_bkgrMask[yl : yl + h, xl : xl + w] = True else: ROI_bkgrMask = None return ROI_bkgrMask + def get_autoBkgr_mask(lab, isSegm3D, posData, frame_i): autoBkgr_mask = lab == 0 - autoBkgr_mask = _mask_0valued_pixels_from_alignment( - autoBkgr_mask, frame_i, posData - ) + autoBkgr_mask = _mask_0valued_pixels_from_alignment(autoBkgr_mask, frame_i, posData) if isSegm3D: autoBkgr_mask_proj = lab.max(axis=0) == 0 autoBkgr_mask_proj = _mask_0valued_pixels_from_alignment( @@ -962,15 +994,16 @@ def get_autoBkgr_mask(lab, isSegm3D, posData, frame_i): ) else: autoBkgr_mask_proj = autoBkgr_mask - + return autoBkgr_mask, autoBkgr_mask_proj + def regionprops_table(labels, props, logger_func=None): rp = skimage.measure.regionprops(labels) - if 'label' not in props: - props = ('label', *props) - - empty_metric = [None]*len(rp) + if "label" not in props: + props = ("label", *props) + + empty_metric = [None] * len(rp) rp_table = {} error_ids = {} pbar = tqdm(total=len(props), ncols=100, leave=False) @@ -987,15 +1020,15 @@ def regionprops_table(labels, props, logger_func=None): rp_table[prop][o] = metric elif _type == tuple: for m, val in enumerate(metric): - prop_1d = f'{prop}-{m}' + prop_1d = f"{prop}-{m}" if prop_1d not in rp_table: rp_table[prop_1d] = empty_metric.copy() rp_table[prop_1d][o] = val elif _type == np.ndarray: for i, val in enumerate(metric.flatten()): indices = np.unravel_index(i, metric.shape) - s = '-'.join([str(idx) for idx in indices]) - prop_1d = f'{prop}-{s}' + s = "-".join([str(idx) for idx in indices]) + prop_1d = f"{prop}-{s}" if prop_1d not in rp_table: rp_table[prop_1d] = empty_metric.copy() rp_table[prop_1d][o] = val @@ -1005,61 +1038,66 @@ def regionprops_table(labels, props, logger_func=None): printl(format_exception) else: logger_func(format_exception) - + if prop not in error_ids: - error_ids[prop] = {'ids': [obj.label], 'error': e} + error_ids[prop] = {"ids": [obj.label], "error": e} else: - error_ids[prop]['ids'].append(obj.label) + error_ids[prop]["ids"].append(obj.label) pbar.update(1) return rp_table, error_ids + def get_btrack_features(): features = ( - 'area', - 'major_axis_length', - 'minor_axis_length', - 'equivalent_diameter', - 'solidity', - 'extent', - 'filled_area', - 'bbox_area', - 'convex_area', - 'euler_number', - 'orientation' + "area", + "major_axis_length", + "minor_axis_length", + "equivalent_diameter", + "solidity", + "extent", + "filled_area", + "bbox_area", + "convex_area", + "euler_number", + "orientation", ) return features + def get_non_measurements_cols(colnames, metrics_colnames): non_metrics_colnames = [] for col in colnames: if col in metrics_colnames: continue non_metrics_colnames.append(col) - + non_metrics_non_rp_colnames = [] props = get_props_names() # Remove composite regionprops for col in non_metrics_colnames: for prop in props: - match = re.match(rf'{prop}-\d', col) + match = re.match(rf"{prop}-\d", col) if match is not None: break - match = re.match(rf'{col}-\d-\d', col) + match = re.match(rf"{col}-\d-\d", col) if match is not None: break else: non_metrics_non_rp_colnames.append(col) - return non_metrics_non_rp_colnames - + return non_metrics_non_rp_colnames + + def get_props_names_3D(): props_3D = list(PROPS_DTYPES.keys()) - props_3D.remove('solidity') - props_3D.remove('eccentricity') + props_3D.remove("solidity") + props_3D.remove("eccentricity") return props_3D + def get_props_names(): return list(PROPS_DTYPES.keys()) + def _try_metric_func(func, *args): try: val = func(*args) @@ -1067,6 +1105,7 @@ def _try_metric_func(func, *args): val = np.nan return val + def _quantile(arr, q): try: val = np.quantile(arr, q=q) @@ -1074,151 +1113,159 @@ def _quantile(arr, q): val = np.nan return val + def _amount(arr, bkgr, area): try: - val = (np.mean(arr)-bkgr)*area + val = (np.mean(arr) - bkgr) * area except Exception as e: val = np.nan return val + def _mean_corrected(arr, bkgr): try: - val = np.mean(arr)-bkgr + val = np.mean(arr) - bkgr except Exception as e: val = np.nan return val -def get_obj_size_metric( - col_name, obj, isSegm3D, yx_pxl_to_um2, vox_to_fl_3D - ): - if col_name == 'cell_area_pxl': + +def get_obj_size_metric(col_name, obj, isSegm3D, yx_pxl_to_um2, vox_to_fl_3D): + if col_name == "cell_area_pxl": if isSegm3D: return np.count_nonzero(obj.image.max(axis=0)) else: return obj.area - elif col_name == 'cell_area_um2': + elif col_name == "cell_area_um2": if isSegm3D: - return np.count_nonzero(obj.image.max(axis=0))*yx_pxl_to_um2 + return np.count_nonzero(obj.image.max(axis=0)) * yx_pxl_to_um2 else: - return obj.area*yx_pxl_to_um2 - elif col_name == 'cell_vol_vox': - if not hasattr(obj, 'vol_vox'): + return obj.area * yx_pxl_to_um2 + elif col_name == "cell_vol_vox": + if not hasattr(obj, "vol_vox"): PhysicalSizeY = PhysicalSizeX = np.sqrt(yx_pxl_to_um2) vol_vox, vol_fl = cca_functions._calc_rot_vol( obj, PhysicalSizeY, PhysicalSizeX ) obj.vol_vox, obj.vol_fl = vol_vox, vol_fl return obj.vol_vox - elif col_name == 'cell_vol_fl': - if not hasattr(obj, 'vol_fl'): + elif col_name == "cell_vol_fl": + if not hasattr(obj, "vol_fl"): PhysicalSizeY = PhysicalSizeX = np.sqrt(yx_pxl_to_um2) vol_vox, vol_fl = cca_functions._calc_rot_vol( obj, PhysicalSizeY, PhysicalSizeX ) obj.vol_vox, obj.vol_fl = vol_vox, vol_fl return obj.vol_fl - elif col_name == 'cell_vol_vox_3D': + elif col_name == "cell_vol_vox_3D": return obj.area - elif col_name == 'cell_vol_fl_3D': - return obj.area*vox_to_fl_3D + elif col_name == "cell_vol_fl_3D": + return obj.area * vox_to_fl_3D + def get_foregr_data(foregr_img, isSegm3D, z): isZstack = foregr_img.ndim == 3 foregr_data = {} if isSegm3D: - foregr_data['3D'] = foregr_img - + foregr_data["3D"] = foregr_img + if isZstack: - foregr_data['maxProj'] = foregr_img.max(axis=0) - foregr_data['meanProj'] = foregr_img.mean(axis=0) - foregr_data['zSlice'] = foregr_img[z] - foregr_data[''] = foregr_img + foregr_data["maxProj"] = foregr_img.max(axis=0) + foregr_data["meanProj"] = foregr_img.mean(axis=0) + foregr_data["zSlice"] = foregr_img[z] + foregr_data[""] = foregr_img return foregr_data + def get_cell_volumes_areas(df): try: - cell_vol_vox = df['cell_vol_vox'].to_list() + cell_vol_vox = df["cell_vol_vox"].to_list() except Exception as e: - cell_vol_vox = [np.nan]*len(df) - + cell_vol_vox = [np.nan] * len(df) + try: - cell_vol_fl = df['cell_vol_fl'].to_list() + cell_vol_fl = df["cell_vol_fl"].to_list() except Exception as e: - cell_vol_fl = [np.nan]*len(df) - + cell_vol_fl = [np.nan] * len(df) + try: - cell_vol_vox_3D = df['cell_vol_vox_3D'].to_list() + cell_vol_vox_3D = df["cell_vol_vox_3D"].to_list() except Exception as e: - cell_vol_vox_3D = [np.nan]*len(df) - + cell_vol_vox_3D = [np.nan] * len(df) + try: - cell_vol_fl_3D = df['cell_vol_fl_3D'].to_list() + cell_vol_fl_3D = df["cell_vol_fl_3D"].to_list() except Exception as e: - cell_vol_fl_3D = [np.nan]*len(df) - + cell_vol_fl_3D = [np.nan] * len(df) + try: - cell_area_pxl = df['cell_area_pxl'].to_list() + cell_area_pxl = df["cell_area_pxl"].to_list() except Exception as e: - cell_area_pxl = [np.nan]*len(df) - + cell_area_pxl = [np.nan] * len(df) + try: - cell_area_um2 = df['cell_vol_fl_3D'].to_list() + cell_area_um2 = df["cell_vol_fl_3D"].to_list() except Exception as e: - cell_area_um2 = [np.nan]*len(df) - + cell_area_um2 = [np.nan] * len(df) + items = ( - cell_vol_vox, cell_vol_fl, cell_vol_vox_3D, cell_vol_fl_3D, - cell_area_pxl, cell_area_um2 + cell_vol_vox, + cell_vol_fl, + cell_vol_vox_3D, + cell_vol_fl_3D, + cell_area_pxl, + cell_area_um2, ) return items + def get_bkgrVals(df, channel, how, ID, bkgr_type=None): try: if how: - autoBkgr_col = f'{channel}_autoBkgr_bkgrVal_median_{how}' + autoBkgr_col = f"{channel}_autoBkgr_bkgrVal_median_{how}" else: - autoBkgr_col = f'{channel}_autoBkgr_bkgrVal_median' + autoBkgr_col = f"{channel}_autoBkgr_bkgrVal_median" autoBkgrVal = df.at[ID, autoBkgr_col] except Exception as e: autoBkgrVal = np.nan - + try: if how: - dataPrepBkgr_col = f'{channel}_dataPrepBkgr_bkgrVal_median_{how}' + dataPrepBkgr_col = f"{channel}_dataPrepBkgr_bkgrVal_median_{how}" else: - dataPrepBkgr_col = f'{channel}_dataPrepBkgr_bkgrVal_median' + dataPrepBkgr_col = f"{channel}_dataPrepBkgr_bkgrVal_median" dataPrepBkgrVal = df.at[ID, dataPrepBkgr_col] except Exception as e: dataPrepBkgrVal = np.nan if bkgr_type is None: return autoBkgrVal, dataPrepBkgrVal - - if bkgr_type.find('dataPrep') != -1: + + if bkgr_type.find("dataPrep") != -1: return dataPrepBkgrVal else: return autoBkgrVal + def get_manualBkgr_bkgrVal(df, channel, how, ID): try: if how: - bkgr_col = f'{channel}_manualBkgr_bkgrVal_mean_{how}' + bkgr_col = f"{channel}_manualBkgr_bkgrVal_mean_{how}" else: - bkgr_col = f'{channel}_dataPrepBkgr_bkgrVal_mean' + bkgr_col = f"{channel}_dataPrepBkgr_bkgrVal_mean" bkgrVal = df.at[ID, bkgr_col] except Exception as e: bkgrVal = np.nan return bkgrVal + def get_foregr_obj_array(foregr_arr, obj, isSegm3D, z_slice=None, how=None): if foregr_arr.ndim == 3 and isSegm3D: # 3D mask on 3D data return foregr_arr[obj.slice][obj.image], obj.area elif foregr_arr.ndim == 2 and isSegm3D: # 3D mask on 2D data - use_proj = ( - z_slice is None or how is None or how != 'zSlice' - ) + use_proj = z_slice is None or how is None or how != "zSlice" obj_slice = obj.slice[1:3] if use_proj: obj_image = obj.image.max(axis=0) @@ -1237,60 +1284,72 @@ def get_foregr_obj_array(foregr_arr, obj, isSegm3D, z_slice=None, how=None): # 2D mask on 2D data return foregr_arr[obj.slice][obj.image], obj.area + def _mask_0valued_pixels_from_alignment(bkgr_mask, frame_i, posData): if posData.loaded_shifts is None: # Not aligned --> there are no 0-valued pixels return bkgr_mask - + if posData.dataPrep_ROIcoords is not None: df_roi = posData.dataPrep_ROIcoords.loc[0] - is_cropped = int(df_roi.at['cropped', 'value']) + is_cropped = int(df_roi.at["cropped", "value"]) if is_cropped: # Do not mask 0valued pixels if image was cropped return bkgr_mask - + shifts = posData.loaded_shifts[frame_i] dy, dx = shifts - if dy>0: + if dy > 0: bkgr_mask[..., :dy, :] = False - elif dy<0: + elif dy < 0: bkgr_mask[..., dy:, :] = False - if dx>0: + if dx > 0: bkgr_mask[..., :dx] = False - elif dx<0: + elif dx < 0: bkgr_mask[..., dx:] = False - + return bkgr_mask + def get_bkgr_data( - foregr_img, posData, filename, frame_i, autoBkgr_mask, z, - autoBkgr_mask_proj, dataPrepBkgrROI_mask, isSegm3D, lab - ): + foregr_img, + posData, + filename, + frame_i, + autoBkgr_mask, + z, + autoBkgr_mask_proj, + dataPrepBkgrROI_mask, + isSegm3D, + lab, +): isZstack = foregr_img.ndim == 3 bkgr_data = {} """Auto Background""" - bkgr_data['autoBkgr'] = { - '': 0, 'maxProj': 0, 'meanProj': 0, 'zSlice': 0, '3D': 0 - } + bkgr_data["autoBkgr"] = {"": 0, "maxProj": 0, "meanProj": 0, "zSlice": 0, "3D": 0} if isZstack: if isSegm3D: autoBkr_3D = foregr_img[autoBkgr_mask] - bkgr_data['autoBkgr']['3D'] = autoBkr_3D + bkgr_data["autoBkgr"]["3D"] = autoBkr_3D autoBkgr_maxP = foregr_img.max(axis=0)[autoBkgr_mask_proj] autoBkgr_meanP = foregr_img.mean(axis=0)[autoBkgr_mask_proj] autoBkgr_zSlice = foregr_img[int(z)][autoBkgr_mask_proj] - bkgr_data['autoBkgr']['maxProj'] = autoBkgr_maxP - bkgr_data['autoBkgr']['meanProj'] = autoBkgr_meanP - bkgr_data['autoBkgr']['zSlice'] = autoBkgr_zSlice + bkgr_data["autoBkgr"]["maxProj"] = autoBkgr_maxP + bkgr_data["autoBkgr"]["meanProj"] = autoBkgr_meanP + bkgr_data["autoBkgr"]["zSlice"] = autoBkgr_zSlice else: autoBkgr_data = foregr_img[autoBkgr_mask] - bkgr_data['autoBkgr'][''] = autoBkgr_data + bkgr_data["autoBkgr"][""] = autoBkgr_data """DataPrep Background""" bkgr_archive = posData.fluo_bkgrData_dict[filename] - bkgr_data['dataPrepBkgr'] = { - '': [], 'maxProj': [], 'meanProj': [], 'zSlice': [], '3D': [] + bkgr_data["dataPrepBkgr"] = { + "": [], + "maxProj": [], + "meanProj": [], + "zSlice": [], + "3D": [], } dataPrepBkgr_present = False if bkgr_archive is not None: @@ -1306,11 +1365,11 @@ def get_bkgr_data( if isSegm3D: bkgrRoi_3D = bkgrRoi_data else: - bkgrRoi = bkgrRoi_data - dataPrepBkgr_present = True + bkgrRoi = bkgrRoi_data + dataPrepBkgr_present = True elif dataPrepBkgrROI_mask is not None: # Get background data from the bkgr ROI mask - dataPrepBkgrROI_mask = np.logical_and(dataPrepBkgrROI_mask, lab==0) + dataPrepBkgrROI_mask = np.logical_and(dataPrepBkgrROI_mask, lab == 0) if isZstack: if isSegm3D: bkgrRoi_3D = foregr_img[dataPrepBkgrROI_mask] @@ -1319,39 +1378,39 @@ def get_bkgr_data( dataPrepBkgrROI_mask_2D = dataPrepBkgrROI_mask bkgrRoi_maxP = foregr_img.max(axis=0)[dataPrepBkgrROI_mask_2D] bkgrRoi_meanP = foregr_img.mean(axis=0)[dataPrepBkgrROI_mask_2D] - bkgrRoi_zSlice = foregr_img[z][dataPrepBkgrROI_mask_2D] + bkgrRoi_zSlice = foregr_img[z][dataPrepBkgrROI_mask_2D] else: bkgrRoi = foregr_img[dataPrepBkgrROI_mask] - dataPrepBkgr_present = True - + dataPrepBkgr_present = True + if isZstack and dataPrepBkgr_present: # Note: we do not try to exclude 0-valued pixels, see issue #285 - bkgr_data['dataPrepBkgr']['maxProj'].extend(bkgrRoi_maxP) - bkgr_data['dataPrepBkgr']['meanProj'].extend(bkgrRoi_meanP) - bkgr_data['dataPrepBkgr']['zSlice'].extend(bkgrRoi_zSlice) + bkgr_data["dataPrepBkgr"]["maxProj"].extend(bkgrRoi_maxP) + bkgr_data["dataPrepBkgr"]["meanProj"].extend(bkgrRoi_meanP) + bkgr_data["dataPrepBkgr"]["zSlice"].extend(bkgrRoi_zSlice) if isSegm3D: - bkgr_data['dataPrepBkgr']['3D'].extend(bkgrRoi_3D) + bkgr_data["dataPrepBkgr"]["3D"].extend(bkgrRoi_3D) elif dataPrepBkgr_present: - bkgr_data['dataPrepBkgr'][''].extend(bkgrRoi) - + bkgr_data["dataPrepBkgr"][""].extend(bkgrRoi) + return bkgr_data - + def standard_metrics_func(): metrics_func = { - 'sum': lambda arr: _try_metric_func(np.sum, arr), - 'amount_autoBkgr': lambda arr, bkgr, area: _amount(arr, bkgr, area), - 'amount_dataPrepBkgr': lambda arr, bkgr, area: _amount(arr, bkgr, area), - 'amount_manualBkgr': lambda arr, bkgr, area: _amount(arr, bkgr, area), - 'mean_manualBkgr': lambda arr, bkgr, area: _mean_corrected(arr, bkgr), - 'mean': lambda arr: _try_metric_func(np.mean, arr), - 'median': lambda arr: _try_metric_func(np.median, arr), - 'min': lambda arr: _try_metric_func(np.min, arr), - 'max': lambda arr: _try_metric_func(np.max, arr), - 'q25': lambda arr: _quantile(arr, 0.25), - 'q75': lambda arr: _quantile(arr, 0.75), - 'q05': lambda arr: _quantile(arr, 0.05), - 'q95': lambda arr: _quantile(arr, 0.95) + "sum": lambda arr: _try_metric_func(np.sum, arr), + "amount_autoBkgr": lambda arr, bkgr, area: _amount(arr, bkgr, area), + "amount_dataPrepBkgr": lambda arr, bkgr, area: _amount(arr, bkgr, area), + "amount_manualBkgr": lambda arr, bkgr, area: _amount(arr, bkgr, area), + "mean_manualBkgr": lambda arr, bkgr, area: _mean_corrected(arr, bkgr), + "mean": lambda arr: _try_metric_func(np.mean, arr), + "median": lambda arr: _try_metric_func(np.median, arr), + "min": lambda arr: _try_metric_func(np.min, arr), + "max": lambda arr: _try_metric_func(np.max, arr), + "q25": lambda arr: _quantile(arr, 0.25), + "q75": lambda arr: _quantile(arr, 0.75), + "q05": lambda arr: _quantile(arr, 0.05), + "q95": lambda arr: _quantile(arr, 0.95), } all_metrics_names = list(_get_metrics_names().keys()) @@ -1360,10 +1419,11 @@ def standard_metrics_func(): return metrics_func, all_metrics_names + def add_metrics_instructions(): - url = 'https://github.com/SchmollerLab/Cell_ACDC/issues' + url = "https://github.com/SchmollerLab/Cell_ACDC/issues" href = f'here' - rp_url = f'https://scikit-image.org/docs/stable/api/skimage.measure.html#skimage.measure.regionprops' + rp_url = f"https://scikit-image.org/docs/stable/api/skimage.measure.html#skimage.measure.regionprops" rp_href = f'skimage.measure.regionproperties' def_sh = html_utils.def_sh CV_sh = html_utils.CV_sh @@ -1377,8 +1437,9 @@ def add_metrics_instructions(): return_sh = html_utils.return_sh is_not_sh = html_utils.is_not_sh args_sh = html_utils.span( - 'signal, autoBkgr, dataPrepBkgr, objectRp, correct_with_bkgr=False, ' - 'which_bkgr="auto"', color=html_utils.kwargs_color + "signal, autoBkgr, dataPrepBkgr, objectRp, correct_with_bkgr=False, " + 'which_bkgr="auto"', + color=html_utils.kwargs_color, ) s = html_utils.paragraph(f""" To add custom metrics to the acdc_output.csv @@ -1416,6 +1477,7 @@ def add_metrics_instructions(): """) return s + def _get_combine_metrics_examples_list(): examples = [ """ @@ -1445,12 +1507,13 @@ def _get_combine_metrics_examples_list(): ch1_minus_ch2_mean
    with the result of the subtraction between the channel_1 signal's mean and the channel_2 signal's mean. - """ + """, ] return examples + def get_combine_metrics_help_txt(): - pandas_eval_url = 'https://pandas.pydata.org/docs/reference/api/pandas.eval.html' + pandas_eval_url = "https://pandas.pydata.org/docs/reference/api/pandas.eval.html" examples = _get_combine_metrics_examples_list() txt = html_utils.paragraph(f""" This dialog allows you to write an equation that will be used to @@ -1501,7 +1564,7 @@ def get_combine_metrics_help_txt(): Cell-ACDC uses the Python package pandas to evaluate the expression.
    You can read more about it - {html_utils.href_tag('here', pandas_eval_url)}

    + {html_utils.href_tag("here", pandas_eval_url)}

    The equations will be saved to both the loaded Position folder
    @@ -1514,71 +1577,80 @@ def get_combine_metrics_help_txt(): """) return txt + def add_concentration_metrics(df, concentration_metrics_params): for col, (func_name, how) in concentration_metrics_params.items(): - idx = col.find('_from_vol_') + idx = col.find("_from_vol_") amount_col = col[:idx] - amount_col = amount_col.replace('concentration_', 'amount_') + amount_col = amount_col.replace("concentration_", "amount_") if how: - amount_col = f'{amount_col}_{how}' + amount_col = f"{amount_col}_{how}" - if col.find('from_vol_vox') != -1: + if col.find("from_vol_vox") != -1: try: - if how == '3D': - cell_vol_values = df['cell_vol_vox_3D'] + if how == "3D": + cell_vol_values = df["cell_vol_vox_3D"] else: - cell_vol_values = df['cell_vol_vox'] - concentration_values = df[amount_col]/cell_vol_values + cell_vol_values = df["cell_vol_vox"] + concentration_values = df[amount_col] / cell_vol_values except Exception as e: concentration_values = np.nan df[col] = concentration_values - elif col.find('from_vol_fl') != -1: + elif col.find("from_vol_fl") != -1: try: - if how == '3D': - cell_vol_values = df['cell_vol_fl_3D'] + if how == "3D": + cell_vol_values = df["cell_vol_fl_3D"] else: - cell_vol_values = df['cell_vol_fl'] - concentration_values = df[amount_col]/cell_vol_values + cell_vol_values = df["cell_vol_fl"] + concentration_values = df[amount_col] / cell_vol_values except Exception as e: concentration_values = np.nan df[col] = concentration_values return df + def add_size_metrics( - df, rp, size_metrics_to_save, isSegm3D, yx_pxl_to_um2, vox_to_fl_3D, - calc_size_for_each_zslice=False - ): + df, + rp, + size_metrics_to_save, + isSegm3D, + yx_pxl_to_um2, + vox_to_fl_3D, + calc_size_for_each_zslice=False, +): for o, obj in enumerate(tqdm(rp, ncols=100, leave=False)): for col in size_metrics_to_save: - val = get_obj_size_metric( - col, obj, isSegm3D, yx_pxl_to_um2, vox_to_fl_3D - ) + val = get_obj_size_metric(col, obj, isSegm3D, yx_pxl_to_um2, vox_to_fl_3D) df.at[obj.label, col] = val - + if not calc_size_for_each_zslice: continue - + z0 = obj.bbox[0] for local_z, obj_img_z in enumerate(obj.image): z_slice = z0 + local_z area_pxl_z = np.count_nonzero(obj_img_z) - area_pxl_zslice_col = f'cell_area_pxl_zslice{z_slice}' + area_pxl_zslice_col = f"cell_area_pxl_zslice{z_slice}" df.at[obj.label, area_pxl_zslice_col] = area_pxl_z - - area_um2_z = area_pxl_z*yx_pxl_to_um2 - area_um2_zslice_col = f'cell_area_um2_zslice{z_slice}' + + area_um2_z = area_pxl_z * yx_pxl_to_um2 + area_um2_zslice_col = f"cell_area_um2_zslice{z_slice}" df.at[obj.label, area_um2_zslice_col] = area_um2_z return df + def add_ch_indipend_custom_metrics( - df: pd.DataFrame, - rp, all_channels_foregr_data, - ch_indipend_custom_func_params, - isSegm3D, lab, all_channels_foregr_imgs, - all_channels_z_slices=None, - text_to_append_to_col='', - customMetricsCritical=None - ): + df: pd.DataFrame, + rp, + all_channels_foregr_data, + ch_indipend_custom_func_params, + isSegm3D, + lab, + all_channels_foregr_imgs, + all_channels_z_slices=None, + text_to_append_to_col="", + customMetricsCritical=None, +): for o, obj in enumerate(tqdm(rp, ncols=100, leave=False)): ID = obj.label for col, (custom_func, how) in ch_indipend_custom_func_params.items(): @@ -1589,111 +1661,149 @@ def add_ch_indipend_custom_metrics( if all_channels_z_slices is not None: z_slice = all_channels_z_slices[channel] else: - z_slice = None + z_slice = None foregr_arr = foregr_data.get(how) if foregr_arr is None: continue - + foregr_obj_arr, obj_area = get_foregr_obj_array( foregr_arr, obj, isSegm3D, z_slice=z_slice, how=how ) - - autoBkgrVal, dataPrepBkgrVal = get_bkgrVals( - df, channel, how, ID - ) - + + autoBkgrVal, dataPrepBkgrVal = get_bkgrVals(df, channel, how, ID) + all_channels_obj_intens[channel] = foregr_obj_arr all_channels_autoBkgr[channel] = autoBkgrVal all_channels_dataPrepBkgr[channel] = dataPrepBkgrVal - - metrics_values = df.to_dict('list') + + metrics_values = df.to_dict("list") items = get_cell_volumes_areas(df) - (cell_vols_vox, cell_vols_fl, cell_vols_vox_3D, cell_vols_fl_3D, - cell_areas_pxl, cell_areas_um2) = items + ( + cell_vols_vox, + cell_vols_fl, + cell_vols_vox_3D, + cell_vols_fl_3D, + cell_areas_pxl, + cell_areas_um2, + ) = items custom_error, custom_val, custom_col_name = ( get_ch_indipend_custom_metric_value( - custom_func, - all_channels_obj_intens, - all_channels_autoBkgr, - all_channels_dataPrepBkgr, - obj, o, - metrics_values, - cell_vols_vox, cell_vols_fl, cell_areas_pxl, - cell_areas_um2, - all_channels_foregr_imgs, - lab, - isSegm3D, + custom_func, + all_channels_obj_intens, + all_channels_autoBkgr, + all_channels_dataPrepBkgr, + obj, + o, + metrics_values, + cell_vols_vox, + cell_vols_fl, + cell_areas_pxl, + cell_areas_um2, + all_channels_foregr_imgs, + lab, + isSegm3D, col, - cell_vols_vox_3D=cell_vols_vox_3D, - cell_vols_fl_3D=cell_vols_fl_3D + cell_vols_vox_3D=cell_vols_vox_3D, + cell_vols_fl_3D=cell_vols_fl_3D, ) ) if custom_col_name is None: - df.at[ID, f'{col}{text_to_append_to_col}'] = custom_val + df.at[ID, f"{col}{text_to_append_to_col}"] = custom_val else: for custom_col, value in zip(custom_col_name, custom_val): - df.at[ID, f'{custom_col}{text_to_append_to_col}'] = value - + df.at[ID, f"{custom_col}{text_to_append_to_col}"] = value + if customMetricsCritical is not None and custom_error: customMetricsCritical.emit(custom_error, col) - + return df def add_custom_metrics( - df: pd.DataFrame, - rp, channel, foregr_data, custom_metrics_params, - isSegm3D, lab, foregr_img, other_channels_foregr_imgs, - z_slice=None, text_to_append_to_col='', - customMetricsCritical=None - ): + df: pd.DataFrame, + rp, + channel, + foregr_data, + custom_metrics_params, + isSegm3D, + lab, + foregr_img, + other_channels_foregr_imgs, + z_slice=None, + text_to_append_to_col="", + customMetricsCritical=None, +): for o, obj in enumerate(tqdm(rp, ncols=100, leave=False)): - for col, (custom_func, how) in custom_metrics_params.items(): + for col, (custom_func, how) in custom_metrics_params.items(): foregr_arr = foregr_data.get(how) if foregr_arr is None: continue - + foregr_obj_arr, obj_area = get_foregr_obj_array( foregr_arr, obj, isSegm3D, z_slice=z_slice, how=how ) ID = obj.label autoBkgrVal, dataPrepBkgrVal = get_bkgrVals(df, channel, how, ID) - metrics_values = df.to_dict('list') + metrics_values = df.to_dict("list") items = get_cell_volumes_areas(df) - (cell_vols_vox, cell_vols_fl, cell_vols_vox_3D, cell_vols_fl_3D, - cell_areas_pxl, cell_areas_um2) = items + ( + cell_vols_vox, + cell_vols_fl, + cell_vols_vox_3D, + cell_vols_fl_3D, + cell_areas_pxl, + cell_areas_um2, + ) = items custom_error, custom_val, custom_col_name = get_custom_metric_value( - custom_func, foregr_obj_arr, autoBkgrVal, dataPrepBkgrVal, obj, - o, metrics_values, cell_vols_vox, cell_vols_fl, cell_areas_pxl, - cell_areas_um2, foregr_img, lab, isSegm3D, - other_channels_foregr_imgs, col, - cell_vols_vox_3D=cell_vols_vox_3D, - cell_vols_fl_3D=cell_vols_fl_3D + custom_func, + foregr_obj_arr, + autoBkgrVal, + dataPrepBkgrVal, + obj, + o, + metrics_values, + cell_vols_vox, + cell_vols_fl, + cell_areas_pxl, + cell_areas_um2, + foregr_img, + lab, + isSegm3D, + other_channels_foregr_imgs, + col, + cell_vols_vox_3D=cell_vols_vox_3D, + cell_vols_fl_3D=cell_vols_fl_3D, ) if custom_col_name is None: - df.at[ID, f'{col}{text_to_append_to_col}'] = custom_val + df.at[ID, f"{col}{text_to_append_to_col}"] = custom_val else: for custom_col, value in zip(custom_col_name, custom_val): - df.at[ID, f'{custom_col}{text_to_append_to_col}'] = value - + df.at[ID, f"{custom_col}{text_to_append_to_col}"] = value + if customMetricsCritical is not None and custom_error: customMetricsCritical.emit(custom_error, col) - + return df + def add_foregr_standard_metrics( - df, rp, channel, foregr_data, - foregr_metrics_params, - metrics_func, isSegm3D, - lab, foregr_img, - z_slice=None, - manualBackgrRp=None, - customMetricsCritical=None, - text_to_append_to_col='' - ): + df, + rp, + channel, + foregr_data, + foregr_metrics_params, + metrics_func, + isSegm3D, + lab, + foregr_img, + z_slice=None, + manualBackgrRp=None, + customMetricsCritical=None, + text_to_append_to_col="", +): if manualBackgrRp is not None: manualBackgrRp = {obj.label for obj in manualBackgrRp} - custom_errors = '' + custom_errors = "" # Iterate objects and compute foreground metrics for o, obj in enumerate(tqdm(rp, ncols=100, leave=False)): for col, (func_name, how) in foregr_metrics_params.items(): @@ -1701,14 +1811,14 @@ def add_foregr_standard_metrics( foregr_arr = foregr_data.get(how) if foregr_arr is None: continue - + foregr_obj_arr, obj_area = get_foregr_obj_array( foregr_arr, obj, isSegm3D, z_slice=z_slice, how=how ) - is_manual_bkgr_metric = func_name.find('manualBkgr') != -1 - is_amount_metric = func_name.find('amount_') != -1 + is_manual_bkgr_metric = func_name.find("manualBkgr") != -1 + is_amount_metric = func_name.find("amount_") != -1 if is_amount_metric and not is_manual_bkgr_metric: - bkgr_type = func_name[len('amount_'):] + bkgr_type = func_name[len("amount_") :] try: bkgr_val = get_bkgrVals( df, channel, how, obj.label, bkgr_type=bkgr_type @@ -1724,100 +1834,118 @@ def add_foregr_standard_metrics( else: func = metrics_func[func_name] val = func(foregr_obj_arr) - df.at[obj.label, f'{col}{text_to_append_to_col}'] = val + df.at[obj.label, f"{col}{text_to_append_to_col}"] = val return df + def add_bkgr_values( - df, bkgr_data, bkgr_metrics_params, metrics_func, - manualBackgrRp=None, foregr_data=None, - text_to_append_to_col='' - ): + df, + bkgr_data, + bkgr_metrics_params, + metrics_func, + manualBackgrRp=None, + foregr_data=None, + text_to_append_to_col="", +): # Compute background values for col, (bkgr_type, func_name, how) in bkgr_metrics_params.items(): bkgr_func = metrics_func[func_name] - if bkgr_type == 'manualBkgr': - add_manual_bkgr_values( - manualBackgrRp, foregr_data, df, col, how, bkgr_func - ) + if bkgr_type == "manualBkgr": + add_manual_bkgr_values(manualBackgrRp, foregr_data, df, col, how, bkgr_func) continue bkgr_arr = bkgr_data[bkgr_type].get(how) if bkgr_arr is None: continue - + bkgr_val = bkgr_func(bkgr_arr) - df[f'{col}{text_to_append_to_col}'] = bkgr_val + df[f"{col}{text_to_append_to_col}"] = bkgr_val return df + def add_manual_bkgr_values(manualBackgrRp, foregr_data, df, col, how, bkgr_func): if manualBackgrRp is None: return if foregr_data is None: return - + foregr_img = foregr_data.get(how) if foregr_img is None: return - + for obj in manualBackgrRp: bkgr_obj_arr = foregr_img[obj.slice][obj.image] bkgr_val = bkgr_func(bkgr_obj_arr) df.at[obj.label, col] = bkgr_val + def add_regionprops_metrics(df, lab, regionprops_to_save, logger_func=None): if not regionprops_to_save: return df, [] - if 'label' not in regionprops_to_save: - regionprops_to_save = ('label', *regionprops_to_save) + if "label" not in regionprops_to_save: + regionprops_to_save = ("label", *regionprops_to_save) rp_table, rp_errors = regionprops_table( lab, regionprops_to_save, logger_func=logger_func ) - df_rp = pd.DataFrame(rp_table).set_index('label') - df_rp.index.name = 'Cell_ID' + df_rp = pd.DataFrame(rp_table).set_index("label") + df_rp.index.name = "Cell_ID" # Drop regionprops that were already calculated in a prev session - df = df.drop(columns=df_rp.columns, errors='ignore') + df = df.drop(columns=df_rp.columns, errors="ignore") df = df.join(df_rp) return df, rp_errors + def get_custom_metric_value( - custom_func, foregr_obj_arr, autoBkgrVal, dataPrepBkgrVal, obj, - i, metrics_values, cell_vols_vox, cell_vols_fl, cell_areas_pxl, - cell_areas_um2, foregr_img, lab, isSegm3D, - other_channels_foregr_imgs: Dict[str, np.ndarray], col_name, - cell_vols_vox_3D=None, - cell_vols_fl_3D=None - ): + custom_func, + foregr_obj_arr, + autoBkgrVal, + dataPrepBkgrVal, + obj, + i, + metrics_values, + cell_vols_vox, + cell_vols_fl, + cell_areas_pxl, + cell_areas_um2, + foregr_img, + lab, + isSegm3D, + other_channels_foregr_imgs: Dict[str, np.ndarray], + col_name, + cell_vols_vox_3D=None, + cell_vols_fl_3D=None, +): base_args = (foregr_obj_arr, autoBkgrVal, dataPrepBkgrVal) - - metrics_obj = {key:mm[i] for key, mm in metrics_values.items()} - metrics_obj['cell_vol_vox'] = cell_vols_vox[i] - metrics_obj['cell_vol_fl'] = cell_vols_fl[i] - metrics_obj['cell_area_pxl'] = cell_areas_pxl[i] - metrics_obj['cell_area_um2'] = cell_areas_um2[i] + + metrics_obj = {key: mm[i] for key, mm in metrics_values.items()} + metrics_obj["cell_vol_vox"] = cell_vols_vox[i] + metrics_obj["cell_vol_fl"] = cell_vols_fl[i] + metrics_obj["cell_area_pxl"] = cell_areas_pxl[i] + metrics_obj["cell_area_um2"] = cell_areas_um2[i] if isSegm3D and cell_vols_vox_3D is not None and cell_vols_fl_3D is not None: - metrics_obj['cell_vol_vox_3D'] = cell_vols_vox_3D[i] - metrics_obj['cell_vol_fl_3D'] = cell_vols_fl_3D[i] - + metrics_obj["cell_vol_vox_3D"] = cell_vols_vox_3D[i] + metrics_obj["cell_vol_fl_3D"] = cell_vols_fl_3D[i] + additional_args_kwargs = ( ((), {}), - ((obj,), {}), + ((obj,), {}), ((obj, metrics_obj), {}), - ((obj, metrics_obj, foregr_img, lab), {'isSegm3D': isSegm3D}), - ) + ((obj, metrics_obj, foregr_img, lab), {"isSegm3D": isSegm3D}), + ) error = None for args, kwargs in additional_args_kwargs: try: custom_val = custom_func(*base_args, *args, **kwargs) - return '', custom_val, None + return "", custom_val, None except TypeError as err: - if 'required positional arguments' in str(err): + if "required positional arguments" in str(err): continue except Exception as error: return traceback.format_exc(), np.nan, None - + # Test if custom metric function requires the other channels images custom_vals_vs_other_ch = [] col_names = [] @@ -1825,92 +1953,102 @@ def get_custom_metric_value( other_channel_foregr_img = {other_channel: other_ch_img} try: custom_val = custom_func( - *base_args, obj, metrics_obj, foregr_img, lab, - other_channel_foregr_img, isSegm3D=isSegm3D + *base_args, + obj, + metrics_obj, + foregr_img, + lab, + other_channel_foregr_img, + isSegm3D=isSegm3D, ) custom_vals_vs_other_ch.append(custom_val) - col_names.append(f'{col_name}_vs_{other_channel}') + col_names.append(f"{col_name}_vs_{other_channel}") except Exception as error: return traceback.format_exc(), np.nan, None - - return '', custom_vals_vs_other_ch, col_names + + return "", custom_vals_vs_other_ch, col_names + def get_ch_indipend_custom_metric_value( - custom_func, - all_channels_obj_intens, - all_channels_autoBkgr, - all_channels_dataPrepBkgr, - obj, i, - metrics_values, cell_vols_vox, cell_vols_fl, cell_areas_pxl, - cell_areas_um2, - all_channels_foregr_imgs, - lab, - isSegm3D, - col_name, - cell_vols_vox_3D=None, - cell_vols_fl_3D=None - ): + custom_func, + all_channels_obj_intens, + all_channels_autoBkgr, + all_channels_dataPrepBkgr, + obj, + i, + metrics_values, + cell_vols_vox, + cell_vols_fl, + cell_areas_pxl, + cell_areas_um2, + all_channels_foregr_imgs, + lab, + isSegm3D, + col_name, + cell_vols_vox_3D=None, + cell_vols_fl_3D=None, +): base_args = ( - all_channels_obj_intens, - all_channels_autoBkgr, - all_channels_dataPrepBkgr + all_channels_obj_intens, + all_channels_autoBkgr, + all_channels_dataPrepBkgr, ) - - metrics_obj = {key:mm[i] for key, mm in metrics_values.items()} - metrics_obj['cell_vol_vox'] = cell_vols_vox[i] - metrics_obj['cell_vol_fl'] = cell_vols_fl[i] - metrics_obj['cell_area_pxl'] = cell_areas_pxl[i] - metrics_obj['cell_area_um2'] = cell_areas_um2[i] + + metrics_obj = {key: mm[i] for key, mm in metrics_values.items()} + metrics_obj["cell_vol_vox"] = cell_vols_vox[i] + metrics_obj["cell_vol_fl"] = cell_vols_fl[i] + metrics_obj["cell_area_pxl"] = cell_areas_pxl[i] + metrics_obj["cell_area_um2"] = cell_areas_um2[i] if isSegm3D and cell_vols_vox_3D is not None and cell_vols_fl_3D is not None: - metrics_obj['cell_vol_vox_3D'] = cell_vols_vox_3D[i] - metrics_obj['cell_vol_fl_3D'] = cell_vols_fl_3D[i] + metrics_obj["cell_vol_vox_3D"] = cell_vols_vox_3D[i] + metrics_obj["cell_vol_fl_3D"] = cell_vols_fl_3D[i] additional_args_kwargs = ( ((), {}), - ((obj,), {}), + ((obj,), {}), ((obj, metrics_obj), {}), - ((obj, metrics_obj, all_channels_foregr_imgs, lab), {'isSegm3D': isSegm3D}), - ) + ((obj, metrics_obj, all_channels_foregr_imgs, lab), {"isSegm3D": isSegm3D}), + ) traceback_text = None for args, kwargs in additional_args_kwargs: try: custom_val = custom_func(*base_args, *args, **kwargs) - return '', custom_val, None + return "", custom_val, None except TypeError as err: - if 'required positional arguments' in str(err): + if "required positional arguments" in str(err): continue except Exception as error: traceback_text = traceback.format_exc() - + return traceback_text, np.nan, None + def get_channel_indipend_custom_metrics_params( - ch_indipend_custom_func_dict, ch_indipend_custom_metric_cols - ): + ch_indipend_custom_func_dict, ch_indipend_custom_metric_cols +): custom_metrics_params = {} - + for col in ch_indipend_custom_metric_cols: for metric, custom_func in ch_indipend_custom_func_dict.items(): - custom_pattern = ( - rf'({metric})_?({how_3D_to_2D_pattern}*)' - ) + custom_pattern = rf"({metric})_?({how_3D_to_2D_pattern}*)" m = re.findall(custom_pattern, col) if m: - # Metric is a standard metric + # Metric is a standard metric func_name, how = m[0] custom_metrics_params[col] = (custom_func, how) break - + return custom_metrics_params + def get_metrics_params(all_channels_metrics, metrics_func, custom_func_dict): channel_names = list(all_channels_metrics.keys()) - bkgr_metrics_params = {ch:{} for ch in channel_names} - foregr_metrics_params = {ch:{} for ch in channel_names} + bkgr_metrics_params = {ch: {} for ch in channel_names} + foregr_metrics_params = {ch: {} for ch in channel_names} concentration_metrics_params = {} - custom_metrics_params = {ch:{} for ch in channel_names} - az = r'[A-Za-z0-9]' - bkgrVal_pattern = fr'_({az}+)_bkgrVal_({az}+)_?({az}*)$' + custom_metrics_params = {ch: {} for ch in channel_names} + az = r"[A-Za-z0-9]" + bkgrVal_pattern = rf"_({az}+)_bkgrVal_({az}+)_?({az}*)$" for channel_name, columns in all_channels_metrics.items(): for col in columns: @@ -1918,31 +2056,29 @@ def get_metrics_params(all_channels_metrics, metrics_func, custom_func_dict): if m: # The metric is a bkgrVal metric bkgr_type, func_name, how = m[0] - bkgr_metrics_params[channel_name][col] = ( - bkgr_type, func_name, how - ) + bkgr_metrics_params[channel_name][col] = (bkgr_type, func_name, how) continue - + is_standard_foregr = False for metric in metrics_func: foregr_pattern = ( - rf'{channel_name}_({metric})_?({how_3D_to_2D_pattern}*)$' + rf"{channel_name}_({metric})_?({how_3D_to_2D_pattern}*)$" ) m = re.findall(foregr_pattern, col) if m: - # Metric is a standard metric + # Metric is a standard metric func_name, how = m[0] foregr_metrics_params[channel_name][col] = (func_name, how) is_standard_foregr = True break - + if is_standard_foregr: continue # Metric is concentration - conc_pattern = rf'concentration_{az}+_from_vol_[a-z]+' + conc_pattern = rf"concentration_{az}+_from_vol_[a-z]+" conc_metric_pattern = ( - rf'{channel_name}_({conc_pattern})_?({how_3D_to_2D_pattern}*)' + rf"{channel_name}_({conc_pattern})_?({how_3D_to_2D_pattern}*)" ) m = re.findall(conc_metric_pattern, col) if m: @@ -1952,21 +2088,24 @@ def get_metrics_params(all_channels_metrics, metrics_func, custom_func_dict): for metric, custom_func in custom_func_dict.items(): custom_pattern = ( - rf'{channel_name}_({metric})_?({how_3D_to_2D_pattern}*)' + rf"{channel_name}_({metric})_?({how_3D_to_2D_pattern}*)" ) m = re.findall(custom_pattern, col) if m: - # Metric is a standard metric + # Metric is a standard metric func_name, how = m[0] custom_metrics_params[channel_name][col] = (custom_func, how) break - + params = ( - bkgr_metrics_params, foregr_metrics_params, - concentration_metrics_params, custom_metrics_params + bkgr_metrics_params, + foregr_metrics_params, + concentration_metrics_params, + custom_metrics_params, ) return params + def get_regionprops_columns(existing_colnames, selected_props_names): selected_rp_cols = [] for col in existing_colnames: @@ -1974,38 +2113,36 @@ def get_regionprops_columns(existing_colnames, selected_props_names): if selected_prop == col: selected_rp_cols.append(col) continue - m = re.match(fr'{selected_prop}-\d', col) + m = re.match(rf"{selected_prop}-\d", col) if m is not None: selected_rp_cols.append(col) return selected_rp_cols + def calc_circularity(obj): if obj.image.ndim == 3: - raise TypeError( - 'Circularity can only be calculated for 2D objects.' - ) - + raise TypeError("Circularity can only be calculated for 2D objects.") + circularity = 4 * np.pi * obj.area / pow(obj.perimeter, 2) return circularity + def calc_roundness(obj): if obj.image.ndim == 3: - raise TypeError( - 'Roundness can only be calculated for 2D objects.' - ) - + raise TypeError("Roundness can only be calculated for 2D objects.") + roundness = 4 * obj.area / np.pi / pow(obj.major_axis_length, 2) return roundness + def calc_aspect_ratio(obj): if obj.image.ndim == 3: - raise TypeError( - 'Roundness can only be calculated for 2D objects.' - ) - + raise TypeError("Roundness can only be calculated for 2D objects.") + roundness = obj.major_axis_length / obj.minor_axis_length return roundness + def calc_additional_regionprops(obj): if obj.image.ndim == 3: circularity_sum = 0 @@ -2028,11 +2165,11 @@ def calc_additional_regionprops(obj): aspect_ratio = calc_aspect_ratio(obj) else: raise TypeError( - 'Additional regionprops can be calculated only for 2D or 3D objects.' + "Additional regionprops can be calculated only for 2D or 3D objects." ) - + obj.circularity = circularity obj.roundness = roundness obj.aspect_ratio = aspect_ratio - - return obj \ No newline at end of file + + return obj diff --git a/cellacdc/metrics/CV.py b/cellacdc/metrics/CV.py index 35fb9d004..5c08025e5 100755 --- a/cellacdc/metrics/CV.py +++ b/cellacdc/metrics/CV.py @@ -1,13 +1,14 @@ import numpy as np + def CV( - signal: np.ndarray, - autoBkgr: float, - dataPrepBkgr: float, - objectRp, - correct_with_bkgr=False, - which_bkgr='auto' - ): + signal: np.ndarray, + autoBkgr: float, + dataPrepBkgr: float, + objectRp, + correct_with_bkgr=False, + which_bkgr="auto", +): """Function used to calculate coefficient of variation. NOTE: Make sure to name the function with the same name as the Python file @@ -28,7 +29,7 @@ def CV( data prep step (Cell-ACDC module 1). Pass None if background correction with this vaue is not needed. objectRp: skimage.measure.RegionProperties class - Region properties for the single object. + Region properties for the single object. Refer to `skimage.measure.regionprops` for more information on the available region properties. correct_with_bkgr : boolean @@ -43,14 +44,14 @@ def CV( Coefficient of Variation """ - + if correct_with_bkgr: - if which_bkgr=='auto': + if which_bkgr == "auto": signal = signal - autoBkgr elif dataPrepBkgr is not None: signal = signal - dataPrepBkgr # Here goes your custom metric computation - CV = np.std(signal)/np.mean(signal) + CV = np.std(signal) / np.mean(signal) return CV diff --git a/cellacdc/metrics/channel_indipendent_metric_example.py b/cellacdc/metrics/channel_indipendent_metric_example.py index 2920243bf..a4d4f304a 100644 --- a/cellacdc/metrics/channel_indipendent_metric_example.py +++ b/cellacdc/metrics/channel_indipendent_metric_example.py @@ -4,36 +4,37 @@ from cellacdc import printl -# If you want to calculate the metric for each channel, set this to True. -# If you want to calculate the metric only once after metrics for all channels +# If you want to calculate the metric for each channel, set this to True. +# If you want to calculate the metric only once after metrics for all channels # have been computed, set this to False. CALCULATE_FOR_EACH_CHANNEL = False + def channel_indipendent_metric( - all_channels_signals, - all_channels_autoBkgr, - all_channels_dataPrepBkgr, - objectRp, - metrics_values, - images, - lab, - isSegm3D=False - ): - """Shows how to combine multiple metrics in a channel-indipendent manner + all_channels_signals, + all_channels_autoBkgr, + all_channels_dataPrepBkgr, + objectRp, + metrics_values, + images, + lab, + isSegm3D=False, +): + """Shows how to combine multiple metrics in a channel-indipendent manner using a custom function. Parameters ---------- all_channels_signals : dictionary of numpy 1D arrays - Dictionary with channel names as keys and the numpy array as value + Dictionary with channel names as keys and the numpy array as value with all the intensities of the signal from each single segmented object. all_channels_autoBkgr : dictionary of single numeric value - Dictionary with channel names as keys and as value the median of all + Dictionary with channel names as keys and as value the median of all the background pixels (i.e. pixels with value 0 in the segmentation mask). Pass None if background correction with this value is not needed. all_channels_dataPrepBkgr : dictionary of single numeric value - Dictionary with channel names as keys and as value the median of all + Dictionary with channel names as keys and as value the median of all the pixels inside the background ROIs added during the data prep step (Cell-ACDC module 1). Pass None if background correction with this vaue is not needed. @@ -41,15 +42,15 @@ def channel_indipendent_metric( Refer to `skimage.measure.regionprops` for more information on the available region properties. metrics_values : dict - Dictionary of metrics values of the specific segmented object - (i.e., cell). You can access these values with the name of the + Dictionary of metrics values of the specific segmented object + (i.e., cell). You can access these values with the name of the specific metric (i.e., column name in the acdc_output.csv file) - Examples: + Examples: - mCitrine_mean = metrics_values['mCitrine_mean'] - - _mean_key = [key for key in metrics_values if key.endswith('_mean')][0] + - _mean_key = [key for key in metrics_values if key.endswith('_mean')][0] _mean = metrics_values[_mean_key] images : dictionary of numpy array - Dictionary with channel names as keys and the corresponding image + Dictionary with channel names as keys and the corresponding image signal as value lab : numpy array Segmentation mask of `image` @@ -58,45 +59,45 @@ def channel_indipendent_metric( ------- float Numerical value of the computed metric - + Notes ----- - - 1. The function must have the same name as the Python file containing it + + 1. The function must have the same name as the Python file containing it (e.g., if this file is called CV.py the function must be called CV) 2. The function must return a single number. You will need one .py for each additional custom metric. - - This implementation shows how to compute the ratio of the amount between - the first two channels (alphabetically) divided by the cell_vol_fl. + + This implementation shows how to compute the ratio of the amount between + the first two channels (alphabetically) divided by the cell_vol_fl. """ - + channels = list(all_channels_signals.keys()) channels = natsorted(channels) channel_1 = channels[0] - + try: channel_2 = channels[1] except IndexError: # Only one channel loaded. Returning 0. return 0.0 - + ch1_amount_key = [ - key for key in metrics_values - if key.startswith(f'{channel_1}_amount_autoBkgr')][0] - + key for key in metrics_values if key.startswith(f"{channel_1}_amount_autoBkgr") + ][0] + ch1_amount = metrics_values[ch1_amount_key] - + ch2_amount_key = [ - key for key in metrics_values - if key.startswith(f'{channel_2}_amount_autoBkgr')][0] + key for key in metrics_values if key.startswith(f"{channel_2}_amount_autoBkgr") + ][0] ch2_amount = metrics_values[ch2_amount_key] - - cell_vol_fl = metrics_values['cell_vol_fl'] - + + cell_vol_fl = metrics_values["cell_vol_fl"] + amount_ratio = ch1_amount / ch2_amount - - totally_useless_metric = amount_ratio/cell_vol_fl + + totally_useless_metric = amount_ratio / cell_vol_fl return totally_useless_metric diff --git a/cellacdc/metrics/combine_metrics_example.py b/cellacdc/metrics/combine_metrics_example.py index 6698cee51..d53e1d356 100644 --- a/cellacdc/metrics/combine_metrics_example.py +++ b/cellacdc/metrics/combine_metrics_example.py @@ -1,10 +1,19 @@ import numpy as np + def combine_metrics_example( - signal, autoBkgr, dataPrepBkgr, objectRp, metrics_values, image, lab, - other_channel_foregr_img, correct_with_bkgr=False, which_bkgr='auto', - isSegm3D=False - ): + signal, + autoBkgr, + dataPrepBkgr, + objectRp, + metrics_values, + image, + lab, + other_channel_foregr_img, + correct_with_bkgr=False, + which_bkgr="auto", + isSegm3D=False, +): """Shows how to combine multiple metrics in a custom function. Parameters @@ -24,21 +33,21 @@ def combine_metrics_example( Refer to `skimage.measure.regionprops` for more information on the available region properties. metrics_values : dict - Dictionary of metrics values of the specific segmented object - (i.e., cell). You can access these values with the name of the + Dictionary of metrics values of the specific segmented object + (i.e., cell). You can access these values with the name of the specific metric (i.e., column name in the acdc_output.csv file) - Examples: + Examples: - mCitrine_mean = metrics_values['mCitrine_mean'] - - _mean_key = [key for key in metrics_values if key.endswith('_mean')][0] + - _mean_key = [key for key in metrics_values if key.endswith('_mean')][0] _mean = metrics_values[_mean_key] image : numpy array Image signal being analysed (if time-lapse this is the current frame) lab : numpy array Segmentation mask of `image` other_channel_foregr_img : dict - Dictionary with a single key another loaded channel name and values - the corresponding channel signal. Cell-ACDC will run this function - for as many other channles were loaded. Do not include in your custom + Dictionary with a single key another loaded channel name and values + the corresponding channel signal. Cell-ACDC will run this function + for as many other channles were loaded. Do not include in your custom function if you don't need it. correct_with_bkgr : boolean Pass True if you need background correction. @@ -50,23 +59,23 @@ def combine_metrics_example( ------- float Numerical value of the computed metric - + Notes ----- - - 1. The function must have the same name as the Python file containing it + + 1. The function must have the same name as the Python file containing it (e.g., this file is called CV.py and the function is called CV) 2. The function must return a single number. You will need one .py for each additional custom metric. - - This implementation shows how to compute the concentration for all the - available channels. Concentration is calculated as the ratio between - columns ending with `_amount_autoBkgr` and `cell_vol_fl`. + + This implementation shows how to compute the concentration for all the + available channels. Concentration is calculated as the ratio between + columns ending with `_amount_autoBkgr` and `cell_vol_fl`. """ - _amount_key = [key for key in metrics_values if key.endswith('_amount_autoBkgr')][0] + _amount_key = [key for key in metrics_values if key.endswith("_amount_autoBkgr")][0] _amount = metrics_values[_amount_key] - cell_vol_fl = metrics_values['cell_vol_fl'] - _concentration = _amount/cell_vol_fl + cell_vol_fl = metrics_values["cell_vol_fl"] + _concentration = _amount / cell_vol_fl return _concentration diff --git a/cellacdc/mixins/_graph.py b/cellacdc/mixins/_graph.py index 9e39d3296..3ed139242 100644 --- a/cellacdc/mixins/_graph.py +++ b/cellacdc/mixins/_graph.py @@ -89,7 +89,12 @@ "canvas_right_image": ("canvas_drawing", "canvas_events", "canvas_context_menu"), "object_search": ("frame_navigation", "graphics", "session"), "object_cleanup": ("cell_cycle", "session", "image_display"), - "seg_for_lost_ids": ("segmentation", "frame_navigation", "label_editing", "session"), + "seg_for_lost_ids": ( + "segmentation", + "frame_navigation", + "label_editing", + "session", + ), "exporting": ("app_shell", "frame_navigation", "session"), "combine_worker": ("combine", "graphics", "preprocessing", "worker"), } diff --git a/cellacdc/mixins/actions.py b/cellacdc/mixins/actions.py index 8ad2a6630..e0716fa29 100644 --- a/cellacdc/mixins/actions.py +++ b/cellacdc/mixins/actions.py @@ -15,37 +15,39 @@ from .image_display import ImageDisplay + class Actions(ImageDisplay): """Extracted from guiWin.""" def editShortcuts_cb(self): if is_mac: - delObjKeySequenceText = 'Ctrl' - delObjButtonText = 'Left click' + delObjKeySequenceText = "Ctrl" + delObjButtonText = "Left click" else: - delObjKeySequenceText = '' - delObjButtonText = 'Middle click' - + delObjKeySequenceText = "" + delObjButtonText = "Middle click" + if self.delObjAction is not None: delObjKeySequence, delObjQtButton = self.delObjAction if delObjKeySequence is None: - delObjKeySequenceText = '' + delObjKeySequenceText = "" else: delObjKeySequenceText = delObjKeySequence.toString() - delObjKeySequenceText = ( - delObjKeySequenceText.encode('ascii', 'ignore').decode('utf-8') - ) + delObjKeySequenceText = delObjKeySequenceText.encode( + "ascii", "ignore" + ).decode("utf-8") delObjButtonText = ( - 'Left click' if delObjQtButton == Qt.MouseButton.LeftButton - else 'Middle click' + "Left click" + if delObjQtButton == Qt.MouseButton.LeftButton + else "Middle click" ) - + win = apps.ShortcutEditorDialog( - self.widgetsWithShortcut, + self.widgetsWithShortcut, delObjectKey=delObjKeySequenceText, delObjectButton=delObjButtonText, zoomOutKeyValue=self.zoomOutKeyValue, - parent=self + parent=self, ) win.exec_() if win.cancel: @@ -70,9 +72,7 @@ def gui_connectActions(self): self.exportToVideoAction.triggered.connect(self.exportToVideoTriggered) self.exportToImageAction.triggered.connect(self.exportToImageTriggered) self.quickSaveAction.triggered.connect(self.quickSave) - self.viewPreprocDataToggle.toggled.connect( - self.viewPreprocDataToggled - ) + self.viewPreprocDataToggle.toggled.connect(self.viewPreprocDataToggled) self.viewCombineChannelDataToggle.toggled.connect( self.viewCombineChannelDataToggled ) @@ -81,12 +81,8 @@ def gui_connectActions(self): self.autoSaveIntervalDialog.sigValueChanged.connect( self.autoSaveIntervalValueChanged ) - self.autoSaveIntervalEditButton.clicked.connect( - self.autoSaveIntervalEdit - ) - self.ccaIntegrCheckerToggle.toggled.connect( - self.ccaIntegrCheckerToggled - ) + self.autoSaveIntervalEditButton.clicked.connect(self.autoSaveIntervalEdit) + self.ccaIntegrCheckerToggle.toggled.connect(self.ccaIntegrCheckerToggled) self.annotLostObjsToggle.toggled.connect(self.annotLostObjsToggled) self.highLowResAction.clicked.connect(self.highLowResToggled) self.showInExplorerAction.triggered.connect(self.showInExplorer_cb) @@ -103,9 +99,7 @@ def gui_connectActions(self): self.editAutoSaveIntervalAction.triggered.connect( self.autoSaveIntervalEditButton.click ) - self.showMirroredCursorAction.toggled.connect( - self.showMirroredCursorToggled - ) + self.showMirroredCursorAction.toggled.connect(self.showMirroredCursorToggled) # Connect Help actions self.tipsAction.triggered.connect(self.showTipsAndTricks) @@ -119,15 +113,9 @@ def gui_connectActions(self): self.showPropsDockButton.sigClicked.connect(self.showPropsDockWidget) - self.loadCustomAnnotationsAction.triggered.connect( - self.loadCustomAnnotations - ) - self.addCustomAnnotationAction.triggered.connect( - self.addCustomAnnotation - ) - self.viewAllCustomAnnotAction.toggled.connect( - self.viewAllCustomAnnot - ) + self.loadCustomAnnotationsAction.triggered.connect(self.loadCustomAnnotations) + self.addCustomAnnotationAction.triggered.connect(self.addCustomAnnotation) + self.viewAllCustomAnnotAction.toggled.connect(self.viewAllCustomAnnot) self.addCustomModelVideoAction.triggered.connect( self.showInstructionsCustomModel ) @@ -136,7 +124,7 @@ def gui_connectActions(self): ) self.addCustomModelFrameAction.callback = self.segmFrameCallback self.addCustomModelVideoAction.callback = self.segmVideoCallback - + self.addCustomPromptModelAction.triggered.connect( self.showInstructionsCustomPromptModel ) @@ -150,9 +138,7 @@ def gui_connectEditActions(self): self.loadFluoAction.setEnabled(True) self.isEditActionsConnected = True - self.preprocessImageAction.triggered.connect( - self.preprocessAction.trigger - ) + self.preprocessImageAction.triggered.connect(self.preprocessAction.trigger) self.combineChannelsAction.triggered.connect( self.combineChannelsActionTriggered ) @@ -173,17 +159,15 @@ def gui_connectEditActions(self): self.findIdAction.triggered.connect(self.findID) self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) self.autoPilotButton.toggled.connect(self.autoPilotToggled) - self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID) + self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID) self.slideshowButton.toggled.connect(self.launchSlideshow) - + self.copyLostObjButton.toggled.connect(self.copyLostObjContour_cb) - self.manualAnnotPastButton.toggled.connect( - self.manualAnnotPast_cb - ) + self.manualAnnotPastButton.toggled.connect(self.manualAnnotPast_cb) self.segmSingleFrameMenu.triggered.connect(self.segmFrameCallback) self.segmVideoMenu.triggered.connect(self.segmVideoCallback) - + self.postProcessSegmAction.toggled.connect(self.postProcessSegm) self.autoSegmAction.toggled.connect(self.autoSegm_cb) self.realTimeTrackingToggle.clicked.connect(self.realTimeTrackingClicked) @@ -191,14 +175,10 @@ def gui_connectEditActions(self): self.manualTrackingButton.toggled.connect(self.manualTracking_cb) self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) self.repeatTrackingMenuAction.triggered.connect(self.repeatTracking) - self.repeatTrackingVideoAction.triggered.connect( - self.repeatTrackingVideo - ) + self.repeatTrackingVideoAction.triggered.connect(self.repeatTrackingVideo) for rtTrackerAction in self.trackingAlgosGroup.actions(): rtTrackerAction.toggled.connect(self.rtTrackerActionToggled) - self.editRtTrackerParamsAction.triggered.connect( - self.initRealTimeTracker - ) + self.editRtTrackerParamsAction.triggered.connect(self.initRealTimeTracker) self.delObjsOutSegmMaskAction.triggered.connect( self.delObjsOutSegmMaskActionTriggered ) @@ -215,20 +195,14 @@ def gui_connectEditActions(self): self.editCcaToolAction.triggered.connect( self.manualEditCcaToolbarActionTriggered ) - self.assignBudMothAutoAction.triggered.connect( - self.autoAssignBud_YeastMate - ) + self.assignBudMothAutoAction.triggered.connect(self.autoAssignBud_YeastMate) self.keepIDsButton.toggled.connect(self.keepIDs_cb) self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) - self.whitelistIDsToolbar.sigWhitelistChanged.connect( - self.whitelistIDsChanged - ) + self.whitelistIDsToolbar.sigWhitelistChanged.connect(self.whitelistIDsChanged) - self.whitelistIDsToolbar.sigWhitelistAccepted.connect( - self.whitelistIDsAccepted - ) + self.whitelistIDsToolbar.sigWhitelistAccepted.connect(self.whitelistIDsAccepted) self.whitelistIDsToolbar.sigViewOGIDs.connect(self.whitelistViewOGIDs) @@ -242,15 +216,12 @@ def gui_connectEditActions(self): self.expandLabelToolButton.toggled.connect(self.expandLabelCallback) - self.reinitLastSegmFrameAction.triggered.connect( - self.reInitLastSegmFrame - ) + self.reinitLastSegmFrameAction.triggered.connect(self.reInitLastSegmFrame) - self.defaultRescaleIntensActionGroup.triggered.connect( self.defaultRescaleIntensLutActionToggled ) - + # self.repeatAutoCcaAction.triggered.connect(self.repeatAutoCca) self.manuallyEditCcaAction.triggered.connect(self.manualEditCca) self.addScaleBarAction.toggled.connect(self.addScaleBar) @@ -266,7 +237,7 @@ def gui_connectEditActions(self): self.modeComboBox.sigTextChanged.connect(self.changeMode) self.modeComboBox.activated.connect(self.clearComboBoxFocus) self.equalizeHistPushButton.toggled.connect(self.equalizeHist) - + self.editOverlayColorAction.triggered.connect(self.toggleOverlayColorButton) self.editTextIDsColorAction.triggered.connect(self.toggleTextIDsColorButton) self.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) @@ -305,7 +276,7 @@ def gui_connectEditActions(self): self.labelsGrad.sigShowLabelsImgToggled.connect(self.showLabelImageItem) self.labelsGrad.sigShowRightImgToggled.connect(self.showRightImageItem) self.labelsGrad.sigShowNextFrameToggled.connect(self.showNextFrameImageItem) - + self.labelsGrad.defaultSettingsAction.triggered.connect( self.restoreDefaultSettings ) @@ -318,9 +289,7 @@ def gui_connectEditActions(self): self.imgGrad.textColorButton.clicked.connect( self.editTextIDsColorAction.trigger ) - self.imgGrad.labelsAlphaSlider.valueChanged.connect( - self.updateLabelsAlpha - ) + self.imgGrad.labelsAlphaSlider.valueChanged.connect(self.updateLabelsAlpha) self.imgGrad.defaultSettingsAction.triggered.connect( self.restoreDefaultSettings ) @@ -347,30 +316,22 @@ def gui_connectEditActions(self): self.drawNothingCheckbox.clicked.connect(self.annotOptionClicked) self.annotNumZslicesCheckbox.clicked.connect(self.annotOptionClicked) - # Right - self.annotIDsCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotCcaInfoCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotContourCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotSegmMasksCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.drawMothBudLinesCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.drawNothingCheckboxRight.clicked.connect( - self.annotOptionClickedRight) - self.annotNumZslicesCheckboxRight.clicked.connect( - self.annotOptionClickedRight - ) - + # Right + self.annotIDsCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.annotCcaInfoCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.annotContourCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.annotSegmMasksCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.drawMothBudLinesCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.drawNothingCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.annotNumZslicesCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.segmentToolAction.triggered.connect(self.segmentToolActionTriggered) self.addDelRoiAction.triggered.connect(self.addDelROI) self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) self.delBorderObjAction.triggered.connect(self.delBorderObj) self.delNewObjAction.triggered.connect(self.delNewObj) - + self.brushAutoFillCheckbox.toggled.connect(self.brushAutoFillToggled) self.brushAutoHideCheckbox.toggled.connect(self.brushAutoHideToggled) @@ -385,14 +346,14 @@ def gui_connectEditActions(self): # ) self.imgPropertiesAction.triggered.connect(self.editImgProperties) - self.relabelSequentialAction.triggered.connect( - self.relabelSequentialCallback - ) + self.relabelSequentialAction.triggered.connect(self.relabelSequentialCallback) self.zoomToObjsAction.triggered.connect(self.zoomToObjsActionCallback) self.zoomOutAction.triggered.connect(self.zoomOut) self.preprocessAction.triggered.connect(self.preprocessActionTriggered) - self.combineChannelsAction.triggered.connect(self.combineChannelsActionTriggered) + self.combineChannelsAction.triggered.connect( + self.combineChannelsActionTriggered + ) self.viewCcaTableAction.triggered.connect(self.viewCcaTable) @@ -412,7 +373,7 @@ def gui_connectEditActions(self): intensMeasurQGBox.channelCombobox.currentTextChanged.connect( self.updatePropsWidget ) - + propsQGBox = self.guiTabControl.propsQGBox propsQGBox.additionalPropsCombobox.currentTextChanged.connect( self.updatePropsWidget @@ -420,17 +381,17 @@ def gui_connectEditActions(self): def gui_createActions(self): # File actions - self.segmNdimIndicator = widgets.ToolButtonTextIcon(text='') + self.segmNdimIndicator = widgets.ToolButtonTextIcon(text="") self.segmNdimIndicator.setCheckable(True) self.segmNdimIndicator.setChecked(True) - # self.segmNdimIndicator.setDisabled(True) - + # self.segmNdimIndicator.setDisabled(True) + if self.debug: self.createEmptyDataAction = QAction(self) self.createEmptyDataAction.setText("DEBUG: Create empty data") - + self.newWindowAction = QAction("New Window", self) - + self.newAction = QAction(self) self.newAction.setText("&New Segmentation File...") self.newAction.setIcon(QIcon(":file-new.svg")) @@ -438,7 +399,7 @@ def gui_createActions(self): QIcon(":folder-open.svg"), "&Load Folder...", self ) self.openFileAction = QAction( - QIcon(":image.svg"),"&Open Image/Video File...", self + QIcon(":image.svg"), "&Open Image/Video File...", self ) self.manageVersionsAction = QAction( QIcon(":manage_versions.svg"), "Load Older Versions...", self @@ -454,8 +415,8 @@ def gui_createActions(self): # self.reloadAction = QAction( # QIcon(":reload.svg"), "Reload segmentation file", self # ) - self.nextAction = QAction('Next', self) - self.prevAction = QAction('Previous', self) + self.nextAction = QAction("Next", self) + self.prevAction = QAction("Previous", self) self.showInExplorerAction = QAction( QIcon(":drawer.svg"), f"&{self.openFolderText}", self ) @@ -463,17 +424,17 @@ def gui_createActions(self): self.undoAction = QAction(QIcon(":undo.svg"), "Undo", self) self.redoAction = QAction(QIcon(":redo.svg"), "Redo", self) # String-based key sequences - self.newWindowAction.setShortcut('Ctrl+Shift+N') - self.newAction.setShortcut('Ctrl+N') - self.openFolderAction.setShortcut('Ctrl+O') - self.loadPosAction.setShortcut('Shift+P') - self.saveAsAction.setShortcut('Ctrl+Shift+S') - self.exportToVideoAction.setShortcut('Ctrl+Shift+V') - self.exportToImageAction.setShortcut('Ctrl+Shift+I') - self.saveAction.setShortcut('Ctrl+Alt+S') - self.quickSaveAction.setShortcut('Ctrl+S') - self.undoAction.setShortcut('Ctrl+Z') - self.redoAction.setShortcut('Ctrl+Y') + self.newWindowAction.setShortcut("Ctrl+Shift+N") + self.newAction.setShortcut("Ctrl+N") + self.openFolderAction.setShortcut("Ctrl+O") + self.loadPosAction.setShortcut("Shift+P") + self.saveAsAction.setShortcut("Ctrl+Shift+S") + self.exportToVideoAction.setShortcut("Ctrl+Shift+V") + self.exportToImageAction.setShortcut("Ctrl+Shift+I") + self.saveAction.setShortcut("Ctrl+Alt+S") + self.quickSaveAction.setShortcut("Ctrl+S") + self.undoAction.setShortcut("Ctrl+Z") + self.redoAction.setShortcut("Ctrl+Y") self.nextAction.setShortcut(Qt.Key_Right) self.prevAction.setShortcut(Qt.Key_Left) self.addAction(self.nextAction) @@ -486,34 +447,30 @@ def gui_createActions(self): self.autoPilotButton = QAction(self) self.autoPilotButton.setIcon(QIcon(":auto-pilot.svg")) self.autoPilotButton.setCheckable(True) - self.autoPilotButton.setShortcut('Ctrl+Shift+A') - + self.autoPilotButton.setShortcut("Ctrl+Shift+A") + self.findIdAction = QAction(self) self.findIdAction.setIcon(QIcon(":find.svg")) - self.findIdAction.setShortcut('Ctrl+F') - + self.findIdAction.setShortcut("Ctrl+F") + self.zoomRectButton = QToolButton(self) self.zoomRectButton.setIcon(QIcon(":zoom_rect.svg")) self.zoomRectButton.setCheckable(True) - self.zoomRectButton.setShortcut('Shift+Z') + self.zoomRectButton.setShortcut("Shift+Z") self.LeftClickButtons.append(self.zoomRectButton) self.checkableButtons.append(self.zoomRectButton) self.checkableQButtonsGroup.addButton(self.zoomRectButton) - self.widgetsWithShortcut['Zoom to rectangular area'] = ( - self.zoomRectButton - ) - + self.widgetsWithShortcut["Zoom to rectangular area"] = self.zoomRectButton + self.skipToNewIdAction = QAction(self) self.skipToNewIdAction.setIcon(QIcon(":skip_forward_new_ID.svg")) - self.skipToNewIdAction.setShortcut( - widgets.KeySequenceFromText(Qt.Key_PageUp) - ) + self.skipToNewIdAction.setShortcut(widgets.KeySequenceFromText(Qt.Key_PageUp)) self.skipToNewIdAction.setDisabled(True) # Edit actions models = myutils.get_list_of_models() - models = [*models, 'local_seg'] # Add local_seg for SegForLostIDsAction + models = [*models, "local_seg"] # Add local_seg for SegForLostIDsAction self.segmActions = [] self.modelNames = [] self.acdcSegment_li = [] @@ -526,14 +483,12 @@ def gui_createActions(self): self.acdcSegment_li.append(None) action.setDisabled(True) - self.addCustomModelFrameAction = QAction('Add custom model...', self) - self.addCustomModelVideoAction = QAction('Add custom model...', self) - - self.segmWithPromptableModelAction = QAction( - 'Select promptable model...', self - ) + self.addCustomModelFrameAction = QAction("Add custom model...", self) + self.addCustomModelVideoAction = QAction("Add custom model...", self) + + self.segmWithPromptableModelAction = QAction("Select promptable model...", self) self.addCustomPromptModelAction = QAction( - 'Add custom promptable model...', self + "Add custom promptable model...", self ) self.segmActionsVideo = [] @@ -542,9 +497,7 @@ def gui_createActions(self): self.segmActionsVideo.append(action) action.setDisabled(True) - self.postProcessSegmAction = QAction( - "Segmentation post-processing...", self - ) + self.postProcessSegmAction = QAction("Segmentation post-processing...", self) self.postProcessSegmAction.setDisabled(True) self.postProcessSegmAction.setCheckable(True) @@ -558,32 +511,31 @@ def gui_createActions(self): self.repeatTrackingAction = QAction( QIcon(":repeat-tracking.svg"), "Repeat tracking", self ) - self.repeatTrackingAction.setShortcut('Shift+T') - self.widgetsWithShortcut['Repeat Tracking'] = self.repeatTrackingAction - + self.repeatTrackingAction.setShortcut("Shift+T") + self.widgetsWithShortcut["Repeat Tracking"] = self.repeatTrackingAction self.editRtTrackerParamsAction = QAction( - 'Edit real-time tracker parameters...', self + "Edit real-time tracker parameters...", self ) - + self.repeatTrackingMenuAction = QAction( - 'Track current frame with real-time tracker...', self + "Track current frame with real-time tracker...", self ) self.repeatTrackingMenuAction.setDisabled(True) - self.repeatTrackingMenuAction.setShortcut('Shift+T') + self.repeatTrackingMenuAction.setShortcut("Shift+T") self.repeatTrackingVideoAction = QAction( - 'Select a tracker and track multiple frames...', self + "Select a tracker and track multiple frames...", self ) self.repeatTrackingVideoAction.setDisabled(True) - self.repeatTrackingVideoAction.setShortcut('Alt+Shift+T') + self.repeatTrackingVideoAction.setShortcut("Alt+Shift+T") self.trackingAlgosGroup = QActionGroup(self) - self.trackWithAcdcAction = QAction('Cell-ACDC', self) + self.trackWithAcdcAction = QAction("Cell-ACDC", self) self.trackWithAcdcAction.setCheckable(True) self.trackingAlgosGroup.addAction(self.trackWithAcdcAction) - self.trackWithYeazAction = QAction('YeaZ', self) + self.trackWithYeazAction = QAction("YeaZ", self) self.trackWithYeazAction.setCheckable(True) self.trackingAlgosGroup.addAction(self.trackWithYeazAction) @@ -596,13 +548,13 @@ def gui_createActions(self): self.trackWithAcdcAction.setChecked(True) aliases = myutils.aliases_real_time_trackers() - if 'tracking_algorithm' in self.df_settings.index: - trackingAlgo = self.df_settings.at['tracking_algorithm', 'value'] + if "tracking_algorithm" in self.df_settings.index: + trackingAlgo = self.df_settings.at["tracking_algorithm", "value"] if trackingAlgo in aliases: trackingAlgo = aliases[trackingAlgo] - if trackingAlgo == 'Cell-ACDC': + if trackingAlgo == "Cell-ACDC": self.trackWithAcdcAction.setChecked(True) - elif trackingAlgo == 'YeaZ': + elif trackingAlgo == "YeaZ": self.trackWithYeazAction.setChecked(True) else: for rtTrackerAction in self.trackingAlgosGroup.actions(): @@ -610,9 +562,9 @@ def gui_createActions(self): rtTrackerAction.setChecked(True) break - self.setMeasurementsAction = QAction('Set measurements...') - self.addCustomMetricAction = QAction('Add custom measurement...') - self.addCombineMetricAction = QAction('Add combined measurement...') + self.setMeasurementsAction = QAction("Set measurements...") + self.addCustomMetricAction = QAction("Add custom measurement...") + self.addCombineMetricAction = QAction("Add combined measurement...") # Standard key sequence # self.copyAction.setShortcut(QKeySequence.StandardKey.Copy) @@ -640,109 +592,90 @@ def gui_createActions(self): self.reInitCcaAction.setIcon(QIcon(":reinitCca.svg")) self.reInitCcaAction.setVisible(False) - self.toggleColorSchemeAction = QAction( - 'Switch to light theme' - ) + self.toggleColorSchemeAction = QAction("Switch to light theme") self.gui_updateSwitchColorSchemeActionText() - - self.pxModeAction = widgets.CheckableAction( - 'Fixed size text annotations' - ) + + self.pxModeAction = widgets.CheckableAction("Fixed size text annotations") self.pxModeAction.setChecked(True) pxModeTooltip = ( - 'When the text annotations are with fixed size they scale relative ' - 'to the object when zooming in/out (fixed size in pixels).\n' - 'This is typically faster to render, but it makes annotations ' - 'smaller/larger when zooming in/out, respectively.\n\n' - 'Try activating it to speed up the annotation of many objects ' - 'in high resolution mode.\n\n' - 'After activating it, you might need to increase the font size ' - 'from the menu on the top menubar `Edit --> Font size`.' + "When the text annotations are with fixed size they scale relative " + "to the object when zooming in/out (fixed size in pixels).\n" + "This is typically faster to render, but it makes annotations " + "smaller/larger when zooming in/out, respectively.\n\n" + "Try activating it to speed up the annotation of many objects " + "in high resolution mode.\n\n" + "After activating it, you might need to increase the font size " + "from the menu on the top menubar `Edit --> Font size`." ) self.pxModeAction.setToolTip(pxModeTooltip) - + self.highLowResAction = widgets.CheckableAction( - 'High resolution text annotations' + "High resolution text annotations" ) highLowResTooltip = ( - 'Resolution of the text annotations. High resolution results ' - 'in slower update of the annotations.\n' - 'Not recommended with a number of segmented objects > 500.\n\n' + "Resolution of the text annotations. High resolution results " + "in slower update of the annotations.\n" + "Not recommended with a number of segmented objects > 500.\n\n" ) self.highLowResAction.setToolTip(highLowResTooltip) - + self.editAutoSaveIntervalAction = QAction( - 'Change autosave interval (minutes or frames)...', self - ) - - self.editShortcutsAction = QAction( - 'Customize keyboard shortcuts...', self - ) - self.editShortcutsAction.setShortcut('Ctrl+K') - - self.showMirroredCursorAction = QAction( - 'Show mirrored cursor on images', self + "Change autosave interval (minutes or frames)...", self ) + + self.editShortcutsAction = QAction("Customize keyboard shortcuts...", self) + self.editShortcutsAction.setShortcut("Ctrl+K") + + self.showMirroredCursorAction = QAction("Show mirrored cursor on images", self) self.showMirroredCursorAction.setCheckable(True) - if 'showMirroredCursor' in self.df_settings.index: - checked = self.df_settings.at['showMirroredCursor', 'value'] == 'Yes' + if "showMirroredCursor" in self.df_settings.index: + checked = self.df_settings.at["showMirroredCursor", "value"] == "Yes" self.showMirroredCursorAction.setChecked(checked) else: self.showMirroredCursorAction.setChecked(True) - self.showMirroredCursorAction.setShortcut('Ctrl+M') + self.showMirroredCursorAction.setShortcut("Ctrl+M") - self.editTextIDsColorAction = QAction('Text annotation color...', self) + self.editTextIDsColorAction = QAction("Text annotation color...", self) self.editTextIDsColorAction.setDisabled(True) - self.editOverlayColorAction = QAction('Overlay color...', self) + self.editOverlayColorAction = QAction("Overlay color...", self) self.editOverlayColorAction.setDisabled(True) - self.manuallyEditCcaAction = QAction( - 'Edit cell cycle annotations...', self - ) - self.manuallyEditCcaAction.setShortcut('Ctrl+Shift+P') + self.manuallyEditCcaAction = QAction("Edit cell cycle annotations...", self) + self.manuallyEditCcaAction.setShortcut("Ctrl+Shift+P") self.manuallyEditCcaAction.setDisabled(True) - self.viewCcaTableAction = QAction( - 'View cell cycle annotations...', self - ) + self.viewCcaTableAction = QAction("View cell cycle annotations...", self) self.viewCcaTableAction.setDisabled(True) - self.viewCcaTableAction.setShortcut('Ctrl+P') - - - self.addScaleBarAction = QAction('Add scale bar', self) + self.viewCcaTableAction.setShortcut("Ctrl+P") + + self.addScaleBarAction = QAction("Add scale bar", self) self.addScaleBarAction.setCheckable(True) - - self.addTimestampAction = QAction('Add timestamp', self) + + self.addTimestampAction = QAction("Add timestamp", self) self.addTimestampAction.setCheckable(True) - self.invertBwAction = QAction('Invert black/white', self) + self.invertBwAction = QAction("Invert black/white", self) self.invertBwAction.setCheckable(True) - checked = self.df_settings.at['is_bw_inverted', 'value'] == 'Yes' + checked = self.df_settings.at["is_bw_inverted", "value"] == "Yes" self.invertBwAction.setChecked(checked) - self.shuffleCmapAction = QAction('Randomly shuffle colormap', self) - self.shuffleCmapAction.setShortcut('Shift+S') + self.shuffleCmapAction = QAction("Randomly shuffle colormap", self) + self.shuffleCmapAction.setShortcut("Shift+S") - self.greedyShuffleCmapAction = QAction( - 'Greedily shuffle colormap', self - ) - self.greedyShuffleCmapAction.setShortcut('Alt+Shift+S') + self.greedyShuffleCmapAction = QAction("Greedily shuffle colormap", self) + self.greedyShuffleCmapAction.setShortcut("Alt+Shift+S") - self.saveLabColormapAction = QAction( - 'Save labels colormap...', self - ) + self.saveLabColormapAction = QAction("Save labels colormap...", self) - self.normalizeRawAction = QAction( - 'Do not normalize. Display raw image', self) + self.normalizeRawAction = QAction("Do not normalize. Display raw image", self) self.normalizeToFloatAction = QAction( - 'Convert to floating point format with values [0, 1]', self) + "Convert to floating point format with values [0, 1]", self + ) # self.normalizeToUbyteAction = QAction( # 'Rescale to 8-bit unsigned integer format with values [0, 255]', self) - self.normalizeRescale0to1Action = QAction( - 'Rescale to [0, 1]', self) - self.normalizeByMaxAction = QAction( - 'Normalize by max value', self) + self.normalizeRescale0to1Action = QAction("Rescale to [0, 1]", self) + self.normalizeByMaxAction = QAction("Normalize by max value", self) self.normalizeRawAction.setCheckable(True) self.normalizeToFloatAction.setCheckable(True) # self.normalizeToUbyteAction.setCheckable(True) @@ -755,142 +688,135 @@ def gui_createActions(self): self.normalizeQActionGroup.addAction(self.normalizeRescale0to1Action) self.normalizeQActionGroup.addAction(self.normalizeByMaxAction) - self.preprocessAction = QAction( - 'Pre-processing...', self - ) - self.preprocessAction.setShortcut('Alt+Shift+P') + self.preprocessAction = QAction("Pre-processing...", self) + self.preprocessAction.setShortcut("Alt+Shift+P") self.combineChannelsAction = QAction( - 'Combine and manipulate channels and/or segmentation files...', self - ) - self.combineChannelsAction.setShortcut('Alt+Shift+C') - - self.zoomToObjsAction = QAction( - 'Zoom to objects (Shortcut: H key)', self - ) - self.zoomOutAction = QAction( - 'Zoom out (Shortcut: double press H key)', self + "Combine and manipulate channels and/or segmentation files...", self ) + self.combineChannelsAction.setShortcut("Alt+Shift+C") - self.relabelSequentialAction = QAction( - 'Relabel IDs sequentially...', self - ) - self.relabelSequentialAction.setShortcut('Ctrl+L') + self.zoomToObjsAction = QAction("Zoom to objects (Shortcut: H key)", self) + self.zoomOutAction = QAction("Zoom out (Shortcut: double press H key)", self) + + self.relabelSequentialAction = QAction("Relabel IDs sequentially...", self) + self.relabelSequentialAction.setShortcut("Ctrl+L") self.relabelSequentialAction.setDisabled(True) self.setLastUserNormAction() - self.autoSegmAction = QAction( - 'Enable automatic segmentation', self) + self.autoSegmAction = QAction("Enable automatic segmentation", self) self.autoSegmAction.setCheckable(True) self.autoSegmAction.setDisabled(True) self.enableSmartTrackAction = QAction( - 'Smart handling of enabling/disabling tracking', self) + "Smart handling of enabling/disabling tracking", self + ) self.enableSmartTrackAction.setCheckable(True) self.enableSmartTrackAction.setChecked(True) self.enableAutoZoomToCellsAction = QAction( - 'Automatic zoom to all cells when pressing "Next/Previous"', self) + 'Automatic zoom to all cells when pressing "Next/Previous"', self + ) self.enableAutoZoomToCellsAction.setCheckable(True) - self.imgPropertiesAction = QAction('Properties...', self) + self.imgPropertiesAction = QAction("Properties...", self) self.imgPropertiesAction.setDisabled(True) self.addDelRoiAction = QAction(self) - self.addDelRoiAction.roiType = 'rect' + self.addDelRoiAction.roiType = "rect" self.addDelRoiAction.setIcon(QIcon(":addDelRoi.svg")) - + self.addDelPolyLineRoiButton = QToolButton(self) self.addDelPolyLineRoiButton.setCheckable(True) self.addDelPolyLineRoiButton.setIcon(QIcon(":addDelPolyLineRoi.svg")) - + self.checkableButtons.append(self.addDelPolyLineRoiButton) self.LeftClickButtons.append(self.addDelPolyLineRoiButton) - + self.delBorderObjAction = QAction(self) self.delBorderObjAction.setIcon(QIcon(":delBorderObj.svg")) - + self.delNewObjAction = QAction(self) self.delNewObjAction.setIcon(QIcon(":delNewObj.svg")) self.loadCustomAnnotationsAction = QAction(self) self.loadCustomAnnotationsAction.setIcon(QIcon(":load_annotation.svg")) self.loadCustomAnnotationsAction.setToolTip( - 'Load previously used custom annotations' + "Load previously used custom annotations" ) - + self.addCustomAnnotationAction = QAction(self) self.addCustomAnnotationAction.setIcon(QIcon(":addCustomAnnotation.svg")) - self.addCustomAnnotationAction.setToolTip('Add custom annotation') + self.addCustomAnnotationAction.setToolTip("Add custom annotation") # self.functionsNotTested3D.append(self.addCustomAnnotationAction) self.viewAllCustomAnnotAction = QAction(self) self.viewAllCustomAnnotAction.setCheckable(True) self.viewAllCustomAnnotAction.setIcon(QIcon(":eye.svg")) - self.viewAllCustomAnnotAction.setToolTip('Show all custom annotations') + self.viewAllCustomAnnotAction.setToolTip("Show all custom annotations") def gui_updateSwitchColorSchemeActionText(self): - if self._colorScheme == 'dark': - txt = 'Switch to light theme' + if self._colorScheme == "dark": + txt = "Switch to light theme" else: - txt = 'Switch to dark theme' + txt = "Switch to dark theme" self.toggleColorSchemeAction.setText(txt) def initShortcuts(self): from . import config + cp = config.ConfigParser() if os.path.exists(shortcut_filepath): cp.read(shortcut_filepath) - - if 'keyboard.shortcuts' not in cp: - cp['keyboard.shortcuts'] = {} - - if cp.has_option('keyboard.shortcuts', 'Zoom out'): - zoomOutKeyValueStr = cp['keyboard.shortcuts']['Zoom out'] + + if "keyboard.shortcuts" not in cp: + cp["keyboard.shortcuts"] = {} + + if cp.has_option("keyboard.shortcuts", "Zoom out"): + zoomOutKeyValueStr = cp["keyboard.shortcuts"]["Zoom out"] try: self.zoomOutKeyValue = int(zoomOutKeyValueStr) except Exception as err: self.logger.warning( - f'{zoomOutKeyValueStr} is not a valid key ' + f"{zoomOutKeyValueStr} is not a valid key " 'zooming out action. Restoring default key "H".' ) - - if 'delete_object.action' not in cp: + + if "delete_object.action" not in cp: self.delObjAction = None else: - delObjKeySequenceText = cp['delete_object.action']['Key sequence'] - delObjButtonText = cp['delete_object.action']['Mouse button'] + delObjKeySequenceText = cp["delete_object.action"]["Key sequence"] + delObjButtonText = cp["delete_object.action"]["Mouse button"] delObjQtButton = ( - Qt.MouseButton.LeftButton if delObjButtonText == 'Left click' + Qt.MouseButton.LeftButton + if delObjButtonText == "Left click" else Qt.MouseButton.MiddleButton ) if not delObjKeySequenceText: delObjKeySequence = None else: - delObjKeySequence = widgets.KeySequenceFromText( - delObjKeySequenceText - ) + delObjKeySequence = widgets.KeySequenceFromText(delObjKeySequenceText) self.delObjToolAction.setChecked(True) self.delObjAction = delObjKeySequence, delObjQtButton - + shortcuts = {} for name, widget in self.widgetsWithShortcut.items(): - if name not in cp.options('keyboard.shortcuts'): - if hasattr(widget, 'keyPressShortcut'): + if name not in cp.options("keyboard.shortcuts"): + if hasattr(widget, "keyPressShortcut"): key = widget.keyPressShortcut shortcut = widgets.KeySequenceFromText(key) else: shortcut = widget.shortcut() shortcut_text = shortcut.toString() - cp['keyboard.shortcuts'][name] = shortcut_text + cp["keyboard.shortcuts"][name] = shortcut_text else: - shortcut_text = cp['keyboard.shortcuts'][name] + shortcut_text = cp["keyboard.shortcuts"][name] shortcut = widgets.KeySequenceFromText(shortcut_text) - + shortcuts[name] = (shortcut_text, shortcut) self.setShortcuts(shortcuts, save=False) - with open(shortcut_filepath, 'w') as ini: + with open(shortcut_filepath, "w") as ini: cp.write(ini) def setShortcuts(self, shortcuts: dict, save=True): @@ -898,62 +824,62 @@ def setShortcuts(self, shortcuts: dict, save=True): widget = self.widgetsWithShortcut[name] if shortcut is None: shortcut = QKeySequence() - if hasattr(widget, 'keyPressShortcut'): + if hasattr(widget, "keyPressShortcut"): widget.keyPressShortcut = shortcut else: widget.setShortcut(shortcut) s = widget.toolTip() toolTip = re.sub(r'Shortcut: "(.*)"', f'Shortcut: "{text}"', s) widget.setToolTip(toolTip) - - if not save: + + if not save: return - + from . import config + cp = config.ConfigParser() if os.path.exists(shortcut_filepath): cp.read(shortcut_filepath) - - if 'keyboard.shortcuts' not in cp: - cp['keyboard.shortcuts'] = {} + + if "keyboard.shortcuts" not in cp: + cp["keyboard.shortcuts"] = {} for name, (text, shortcut) in shortcuts.items(): - cp['keyboard.shortcuts'][name] = text - - cp['keyboard.shortcuts']['Zoom out'] = str(self.zoomOutKeyValue) - + cp["keyboard.shortcuts"][name] = text + + cp["keyboard.shortcuts"]["Zoom out"] = str(self.zoomOutKeyValue) + if self.delObjAction is None: - with open(shortcut_filepath, 'w') as ini: + with open(shortcut_filepath, "w") as ini: cp.write(ini) return - + delObjKeySequence, delObjQtButton = self.delObjAction try: if delObjKeySequence is None: - delObjKeySequenceText = '' + delObjKeySequenceText = "" else: delObjKeySequenceText = delObjKeySequence.toString() - - delObjKeySequenceText = ( - delObjKeySequenceText - .encode('ascii', 'ignore') - .decode('utf-8') - ) + + delObjKeySequenceText = delObjKeySequenceText.encode( + "ascii", "ignore" + ).decode("utf-8") delObjButtonText = ( - 'Left click' if delObjQtButton == Qt.MouseButton.LeftButton - else 'Middle click' + "Left click" + if delObjQtButton == Qt.MouseButton.LeftButton + else "Middle click" ) - cp['delete_object.action'] = { - 'Key sequence': delObjKeySequenceText, - 'Mouse button': delObjButtonText + cp["delete_object.action"] = { + "Key sequence": delObjKeySequenceText, + "Mouse button": delObjButtonText, } except Exception as err: self.logger.warning( - f'{delObjKeySequence} is not a valid keys sequence for ' - 'deleting objects. Setting default action' + f"{delObjKeySequence} is not a valid keys sequence for " + "deleting objects. Setting default action" ) self.delObjAction = None - cp.remove_section('delete_object.action') - - with open(shortcut_filepath, 'w') as ini: + cp.remove_section("delete_object.action") + + with open(shortcut_filepath, "w") as ini: cp.write(ini) diff --git a/cellacdc/mixins/annotation_display.py b/cellacdc/mixins/annotation_display.py index 3b390a4bb..32a96a557 100644 --- a/cellacdc/mixins/annotation_display.py +++ b/cellacdc/mixins/annotation_display.py @@ -12,6 +12,7 @@ from .mode_controls import ModeControls + class AnnotationDisplay(ModeControls): """Extracted from guiWin.""" @@ -20,7 +21,7 @@ def activateAnnotations(self): return if self.annotSegmMasksCheckbox.isChecked(): return - + self.annotSegmMasksCheckbox.setChecked(True) self.setDrawAnnotComboboxText() @@ -42,29 +43,29 @@ def annotOptionClicked(self, clicked=True, sender=None, saveSettings=True): self.annotCcaInfoCheckbox.setChecked(False) if self.drawMothBudLinesCheckbox.isChecked(): self.drawMothBudLinesCheckbox.setChecked(False) - + if self.annotCcaInfoCheckbox.isChecked() and clickedCca: if self.annotIDsCheckbox.isChecked(): self.annotIDsCheckbox.setChecked(False) if self.drawMothBudLinesCheckbox.isChecked(): self.drawMothBudLinesCheckbox.setChecked(False) - + if self.drawMothBudLinesCheckbox.isChecked() and clickedMBline: if self.annotIDsCheckbox.isChecked(): self.annotIDsCheckbox.setChecked(False) if self.annotCcaInfoCheckbox.isChecked(): self.annotCcaInfoCheckbox.setChecked(False) - + clickedCont = sender == self.annotContourCheckbox clickedSegm = sender == self.annotSegmMasksCheckbox if self.annotContourCheckbox.isChecked() and clickedCont: if self.annotSegmMasksCheckbox.isChecked(): self.annotSegmMasksCheckbox.setChecked(False) - + if self.annotSegmMasksCheckbox.isChecked() and clickedSegm: if self.annotContourCheckbox.isChecked(): self.annotContourCheckbox.setChecked(False) - + clickedDoNot = sender == self.drawNothingCheckbox if clickedDoNot: self.annotIDsCheckbox.setChecked(False) @@ -75,16 +76,14 @@ def annotOptionClicked(self, clicked=True, sender=None, saveSettings=True): self.annotNumZslicesCheckbox.setChecked(False) else: self.drawNothingCheckbox.setChecked(False) - + if sender == self.annotNumZslicesCheckbox: self.annotIDsCheckbox.setChecked(True) self.drawNothingCheckbox.setChecked(False) - + self.setDrawAnnotComboboxText(saveSettings=saveSettings) - def annotOptionClickedRight( - self, clicked=True, sender=None, saveSettings=True - ): + def annotOptionClickedRight(self, clicked=True, sender=None, saveSettings=True): if sender is None: sender = self.sender() # First manually set exclusive with uncheckable @@ -96,29 +95,29 @@ def annotOptionClickedRight( self.annotCcaInfoCheckboxRight.setChecked(False) if self.drawMothBudLinesCheckboxRight.isChecked(): self.drawMothBudLinesCheckboxRight.setChecked(False) - + if self.annotCcaInfoCheckboxRight.isChecked() and clickedCca: if self.annotIDsCheckboxRight.isChecked(): self.annotIDsCheckboxRight.setChecked(False) if self.drawMothBudLinesCheckboxRight.isChecked(): self.drawMothBudLinesCheckboxRight.setChecked(False) - + if self.drawMothBudLinesCheckboxRight.isChecked() and clickedMBline: if self.annotIDsCheckboxRight.isChecked(): self.annotIDsCheckboxRight.setChecked(False) if self.annotCcaInfoCheckboxRight.isChecked(): self.annotCcaInfoCheckboxRight.setChecked(False) - + clickedCont = sender == self.annotContourCheckboxRight clickedSegm = sender == self.annotSegmMasksCheckboxRight if self.annotContourCheckboxRight.isChecked() and clickedCont: if self.annotSegmMasksCheckboxRight.isChecked(): self.annotSegmMasksCheckboxRight.setChecked(False) - + if self.annotSegmMasksCheckboxRight.isChecked() and clickedSegm: if self.annotContourCheckboxRight.isChecked(): self.annotContourCheckboxRight.setChecked(False) - + clickedDoNot = sender == self.drawNothingCheckboxRight if clickedDoNot: self.annotIDsCheckboxRight.setChecked(False) @@ -129,7 +128,7 @@ def annotOptionClickedRight( self.annotNumZslicesCheckboxRight.setChecked(False) else: self.drawNothingCheckboxRight.setChecked(False) - + if sender == self.annotNumZslicesCheckboxRight: self.annotIDsCheckboxRight.setChecked(True) self.drawNothingCheckboxRight.setChecked(False) @@ -139,37 +138,33 @@ def annotOptionClickedRight( def annotateRightHowCombobox_cb(self, idx): how = self.annotateRightHowCombobox.currentText() saveSettings = True - if hasattr(self.annotateRightHowCombobox, 'saveSettings'): + if hasattr(self.annotateRightHowCombobox, "saveSettings"): saveSettings = self.annotateRightHowCombobox.saveSettings if saveSettings: - self.df_settings.at['how_draw_right_annotations', 'value'] = how + self.df_settings.at["how_draw_right_annotations", "value"] = how self.df_settings.to_csv(self.settings_csv_path) mode = self.modeComboBox.currentText() isCcaAnnot = ( - self.annotCcaInfoCheckboxRight.isChecked() and - mode != 'Normal division: Lineage tree' + self.annotCcaInfoCheckboxRight.isChecked() + and mode != "Normal division: Lineage tree" ) - isIDAnnot = (self.annotIDsCheckboxRight.isChecked() or ( - self.annotCcaInfoCheckboxRight.isChecked() and - mode == 'Normal division: Lineage tree' - )) - self.textAnnot[1].setCcaAnnot( - isCcaAnnot + isIDAnnot = self.annotIDsCheckboxRight.isChecked() or ( + self.annotCcaInfoCheckboxRight.isChecked() + and mode == "Normal division: Lineage tree" ) + self.textAnnot[1].setCcaAnnot(isCcaAnnot) - self.textAnnot[1].setLabelAnnot( - isIDAnnot - ) + self.textAnnot[1].setLabelAnnot(isIDAnnot) if not self.isDataLoading: self.updateAllImages() def annotate_rip_and_bin_IDs(self, updateLabel=False): depthAxes = self.switchPlaneCombobox.depthAxes() - if self.switchPlaneCombobox.isEnabled() and depthAxes != 'z': - return - + if self.switchPlaneCombobox.isEnabled() and depthAxes != "z": + return + posData = self.data[self.pos_i] binnedIDs_xx = [] binnedIDs_yy = [] @@ -180,7 +175,7 @@ def annotate_rip_and_bin_IDs(self, updateLabel=False): obj.dead = obj.label in posData.ripIDs if not self.isObjVisible(obj.bbox): continue - + if obj.excluded: y, x = self.getObjCentroid(obj.centroid) binnedIDs_xx.append(x) @@ -188,7 +183,7 @@ def annotate_rip_and_bin_IDs(self, updateLabel=False): if updateLabel: self.getObjOptsSegmLabels(obj) how = self.drawIDsContComboBox.currentText() - + if obj.dead: y, x = self.getObjCentroid(obj.centroid) ripIDs_xx.append(x) @@ -196,7 +191,7 @@ def annotate_rip_and_bin_IDs(self, updateLabel=False): if updateLabel: self.getObjOptsSegmLabels(obj) how = self.drawIDsContComboBox.currentText() - + self.ax2_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) self.ax2_ripIDs_ScatterPlot.setData(ripIDs_xx, ripIDs_yy) self.ax1_binnedIDs_ScatterPlot.setData(binnedIDs_xx, binnedIDs_yy) @@ -205,19 +200,17 @@ def annotate_rip_and_bin_IDs(self, updateLabel=False): def applyToolNewFrameActionToggled(self, checked, toolName=None): if toolName is None: parentToolButton = self.sender().parent() - toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] + toolName = re.findall(r"Name: (.*)", parentToolButton.toolTip())[0] toolName = toolName.strip() button = self.applyToolNewFrameButtons[toolName] - toolName = toolName.replace(' ', '_') - settingName = f'{toolName}_applyNewFrame' + toolName = toolName.replace(" ", "_") + settingName = f"{toolName}_applyNewFrame" if checked: - self.df_settings.at[settingName, 'value'] = 'applyNewFrame' - button.setStyleSheet(f'background-color: {GREEN_HEX}') + self.df_settings.at[settingName, "value"] = "applyNewFrame" + button.setStyleSheet(f"background-color: {GREEN_HEX}") else: - self.df_settings = self.df_settings.drop( - index=settingName, errors='ignore' - ) - button.setStyleSheet('background-color: none') + self.df_settings = self.df_settings.drop(index=settingName, errors="ignore") + button.setStyleSheet("background-color: none") self.df_settings.to_csv(self.settings_csv_path) def areContoursRequested(self, ax): @@ -230,10 +223,10 @@ def areContoursRequested(self, ax): isRightDifferentAnnot = self.rightBottomGroupbox.isChecked() areContRequestedRight = self.annotContourCheckboxRight.isChecked() - + if isRightDifferentAnnot and areContRequestedRight: return True - + areContRequestedLeft = self.annotContourCheckbox.isChecked() if not isRightDifferentAnnot and areContRequestedLeft: return True @@ -248,16 +241,16 @@ def areMothBudLinesRequested(self, ax): else: if not self.labelsGrad.showRightImgAction.isChecked(): return False - + isRightDifferentAnnot = self.rightBottomGroupbox.isChecked() areLinesRequestedRight = ( self.annotCcaInfoCheckboxRight.isChecked() or self.drawMothBudLinesCheckboxRight.isChecked() ) - + if isRightDifferentAnnot and areLinesRequestedRight: return True - + areLinesRequestedLeft = ( self.drawMothBudLinesCheckbox.isChecked() or self.annotCcaInfoCheckbox.isChecked() @@ -273,14 +266,12 @@ def autoPilotToggled(self, checked): self.autoPilotZoomToObjToggle.toggle() def changeTextResolution(self): - mode = 'high' if self.highLowResAction.isChecked() else 'low' - self.logger.info( - f'Switching to {mode} for the text annnotations...' - ) + mode = "high" if self.highLowResAction.isChecked() else "low" + self.logger.info(f"Switching to {mode} for the text annnotations...") self.pxModeAction.setDisabled(not self.highLowResAction.isChecked()) if not self.isDataLoaded: return - + self.setAllIDs() posData = self.data[self.pos_i] allIDs = posData.allIDs @@ -315,17 +306,19 @@ def drawAllLineageTreeLines(self): self.clearAllCellToCellLines() posData = self.data[self.pos_i] frame_i = posData.frame_i - lin_tree_df = posData.allData_li[frame_i]['acdc_df'] - lin_tree_df_prev = posData.allData_li[frame_i-1]['acdc_df'] + lin_tree_df = posData.allData_li[frame_i]["acdc_df"] + lin_tree_df_prev = posData.allData_li[frame_i - 1]["acdc_df"] rp = posData.rp - prev_rp = posData.allData_li[frame_i-1]['regionprops'] + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] self.setTitleText() - new_cells = lin_tree_df.index.difference(lin_tree_df_prev.index) # I could use this for the if already but this is probably faster for frames where nothing changes + new_cells = lin_tree_df.index.difference( + lin_tree_df_prev.index + ) # I could use this for the if already but this is probably faster for frames where nothing changes if new_cells.shape[0] == 0: return - + for ax in (0, 1): if not self.areMothBudLinesRequested(ax): continue @@ -335,10 +328,14 @@ def drawAllLineageTreeLines(self): lin_tree_df_ID = lin_tree_df.loc[ID] # lin_tree_df_mother_ID = lin_tree_df_prev.loc[lin_tree_df_ID["parent_ID_tree"]] - if lin_tree_df_ID["parent_ID_tree"] == -1: # make sure that new obj where the parents are not known get skipped + if ( + lin_tree_df_ID["parent_ID_tree"] == -1 + ): # make sure that new obj where the parents are not known get skipped continue - - mother_obj = myutils.get_obj_by_label(prev_rp, lin_tree_df_ID["parent_ID_tree"]) + + mother_obj = myutils.get_obj_by_label( + prev_rp, lin_tree_df_ID["parent_ID_tree"] + ) emerg_frame_i = lin_tree_df_ID["emerg_frame_i"] isNew = emerg_frame_i == frame_i @@ -356,60 +353,56 @@ def drawAnnotCombobox_to_options(self): # Left how = self.drawIDsContComboBox.currentText() - if how.find('IDs') != -1: + if how.find("IDs") != -1: self.annotIDsCheckbox.setChecked(True) - if how.find('cell cycle info') != -1: - self.annotCcaInfoCheckbox.setChecked(True) - if how.find('contours') != -1: - self.annotContourCheckbox.setChecked(True) - if how.find('segm. masks') != -1: - self.annotSegmMasksCheckbox.setChecked(True) - if how.find('mother-bud lines') != -1: - self.drawMothBudLinesCheckbox.setChecked(True) - if how.find('nothing') != -1: + if how.find("cell cycle info") != -1: + self.annotCcaInfoCheckbox.setChecked(True) + if how.find("contours") != -1: + self.annotContourCheckbox.setChecked(True) + if how.find("segm. masks") != -1: + self.annotSegmMasksCheckbox.setChecked(True) + if how.find("mother-bud lines") != -1: + self.drawMothBudLinesCheckbox.setChecked(True) + if how.find("nothing") != -1: self.drawNothingCheckbox.setChecked(True) - + # Right how = self.annotateRightHowCombobox.currentText() - if how.find('IDs') != -1: + if how.find("IDs") != -1: self.annotIDsCheckboxRight.setChecked(True) - if how.find('cell cycle info') != -1: - self.annotCcaInfoCheckboxRight.setChecked(True) - if how.find('contours') != -1: - self.annotContourCheckboxRight.setChecked(True) - if how.find('segm. masks') != -1: - self.annotSegmMasksCheckboxRight.setChecked(True) - if how.find('mother-bud lines') != -1: - self.drawMothBudLinesCheckboxRight.setChecked(True) - if how.find('nothing') != -1: + if how.find("cell cycle info") != -1: + self.annotCcaInfoCheckboxRight.setChecked(True) + if how.find("contours") != -1: + self.annotContourCheckboxRight.setChecked(True) + if how.find("segm. masks") != -1: + self.annotSegmMasksCheckboxRight.setChecked(True) + if how.find("mother-bud lines") != -1: + self.drawMothBudLinesCheckboxRight.setChecked(True) + if how.find("nothing") != -1: self.drawNothingCheckboxRight.setChecked(True) def drawIDsContComboBox_cb(self, idx): how = self.drawIDsContComboBox.currentText() saveSettings = True - if hasattr(self.drawIDsContComboBox, 'saveSettings'): + if hasattr(self.drawIDsContComboBox, "saveSettings"): saveSettings = self.drawIDsContComboBox.saveSettings - + if saveSettings: - self.df_settings.at['how_draw_annotations', 'value'] = how + self.df_settings.at["how_draw_annotations", "value"] = how self.df_settings.to_csv(self.settings_csv_path) mode = self.modeComboBox.currentText() isCcaAnnot = ( - self.annotCcaInfoCheckbox.isChecked() and - mode != 'Normal division: Lineage tree' + self.annotCcaInfoCheckbox.isChecked() + and mode != "Normal division: Lineage tree" ) - isIDAnnot = (self.annotIDsCheckbox.isChecked() or ( - self.annotCcaInfoCheckbox.isChecked() and - mode == 'Normal division: Lineage tree' - )) - self.textAnnot[0].setCcaAnnot( - isCcaAnnot + isIDAnnot = self.annotIDsCheckbox.isChecked() or ( + self.annotCcaInfoCheckbox.isChecked() + and mode == "Normal division: Lineage tree" ) + self.textAnnot[0].setCcaAnnot(isCcaAnnot) - self.textAnnot[0].setLabelAnnot( - isIDAnnot - ) + self.textAnnot[0].setLabelAnnot(isIDAnnot) if not self.isDataLoading: self.updateAllImages() @@ -439,9 +432,9 @@ def drawObjLin_TreeMothBudLines(self, ax, obj, mother_obj, isNew, ID=None): if not ID: ID = obj.label - + isObjVisible = self.isObjVisible(obj.bbox) - + if not isObjVisible: return @@ -456,36 +449,36 @@ def drawObjMothBudLines(self, obj, posData, ax=0): areMothBudLinesRequested = self.areMothBudLinesRequested(ax) if not areMothBudLinesRequested: return - + if posData.cca_df is None: - return + return mode = str(self.modeComboBox.currentText()) - if mode == 'Normal division: Lineage Tree': + if mode == "Normal division: Lineage Tree": return ID = obj.label try: cca_df_ID = posData.cca_df.loc[ID] except KeyError: - return - + return + isObjVisible = self.isObjVisible(obj.bbox) if not isObjVisible: return - - ccs_ID = cca_df_ID['cell_cycle_stage'] - if ccs_ID == 'G1': + + ccs_ID = cca_df_ID["cell_cycle_stage"] + if ccs_ID == "G1": return - relationship = cca_df_ID['relationship'] - if relationship != 'bud': + relationship = cca_df_ID["relationship"] + if relationship != "bud": return - emerg_frame_i = cca_df_ID['emerg_frame_i'] + emerg_frame_i = cca_df_ID["emerg_frame_i"] isNew = emerg_frame_i == posData.frame_i scatterItem = self.getMothBudLineScatterItem(ax, isNew) - relative_ID = cca_df_ID['relative_ID'] + relative_ID = cca_df_ID["relative_ID"] try: relative_rp_idx = posData.IDs_idxs[relative_ID] @@ -500,8 +493,8 @@ def drawObjMothBudLines(self, obj, posData, ax=0): def getAnnotateHowRightImage(self): if not self.labelsGrad.showRightImgAction.isChecked(): - return 'nothing' - + return "nothing" + if self.rightBottomGroupbox.isChecked(): how = self.annotateRightHowCombobox.currentText() else: @@ -524,12 +517,12 @@ def getObjCentroid(self, obj_centroid): if self.isSegm3D: depthAxes = self.switchPlaneCombobox.depthAxes() zc, yc, xc = obj_centroid - if depthAxes == 'z': - return yc, xc - elif depthAxes == 'y': - return zc, xc + if depthAxes == "z": + return yc, xc + elif depthAxes == "y": + return zc, xc else: - return zc, yc + return zc, yc else: return obj_centroid @@ -537,7 +530,7 @@ def getObjOptsSegmLabels(self, obj): if not self.labelsGrad.showLabelsImgAction.isChecked(): return - objOpts = self.getObjTextAnnotOpts(obj, 'Draw only IDs', ax=1) + objOpts = self.getObjTextAnnotOpts(obj, "Draw only IDs", ax=1) return objOpts def gui_raiseBottomLayoutContextMenu(self, event): @@ -561,13 +554,15 @@ def keepAllToolsActiveActionToggled(self, checked): action.setChecked(checked) data_loaded = True - if not hasattr(self, 'data'): + if not hasattr(self, "data"): data_loaded = False try: self.labelRoiTrangeCheckbox.disconnect() except TypeError: pass - self.labelRoiTrangeCheckbox.setChecked(checked) # why this is not wrapped in a QAction? + self.labelRoiTrangeCheckbox.setChecked( + checked + ) # why this is not wrapped in a QAction? if data_loaded: self.labelRoiTrangeCheckbox.toggled.connect( @@ -577,14 +572,12 @@ def keepAllToolsActiveActionToggled(self, checked): def keepToolActiveActionToggled(self, checked, toolName=None): if toolName is None: parentToolButton = self.sender().parent() - toolName = re.findall(r'Name: (.*)', parentToolButton.toolTip())[0] + toolName = re.findall(r"Name: (.*)", parentToolButton.toolTip())[0] if checked: - self.df_settings.at[toolName, 'value'] = 'keepActive' + self.df_settings.at[toolName, "value"] = "keepActive" else: - self.df_settings = self.df_settings.drop( - index=toolName, errors='ignore' - ) + self.df_settings = self.df_settings.drop(index=toolName, errors="ignore") self.df_settings.to_csv(self.settings_csv_path) def labelRoiIsCircularRadioButtonToggled(self, checked): @@ -595,17 +588,15 @@ def labelRoiIsCircularRadioButtonToggled(self, checked): def onDoubleSpaceBar(self): how = self.drawIDsContComboBox.currentText() - if how.find('nothing') == -1: + if how.find("nothing") == -1: self.storeCurrentAnnotOptions_ax1() self.drawNothingCheckbox.setChecked(True) - self.annotOptionClicked( - sender=self.drawNothingCheckbox, saveSettings=False - ) + self.annotOptionClicked(sender=self.drawNothingCheckbox, saveSettings=False) else: self.restoreAnnotOptions_ax1() - + how = self.annotateRightHowCombobox.currentText() - if how.find('nothing') == -1: + if how.find("nothing") == -1: self.storeCurrentAnnotOptions_ax2() self.drawNothingCheckboxRight.setChecked(True) self.annotOptionClickedRight( @@ -615,66 +606,67 @@ def onDoubleSpaceBar(self): self.restoreAnnotOptions_ax2() def pxModeActionToggled(self, checked): - self.df_settings.at['pxMode', 'value'] = int(checked) + self.df_settings.at["pxMode", "value"] = int(checked) self.df_settings.to_csv(self.settings_csv_path) - + if not self.isDataLoaded: return - + if self.highLowResAction.isChecked(): for ax in range(2): self.textAnnot[ax].setPxMode(checked) - + self.updateAllImages() - def relabelSequentialCallback(self): + def relabelSequentialCallback(self): mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer' or mode == 'Cell cycle analysis': + if mode == "Viewer" or mode == "Cell cycle analysis": self.startBlinkingModeCB() return - + posData = self.data[self.pos_i] - selectedPos = (posData.pos_foldername, ) + selectedPos = (posData.pos_foldername,) if len(self.data) > 1: - selectedPos = self.askSelectPos(action='to process') + selectedPos = self.askSelectPos(action="to process") if selectedPos is None: - self.logger.info('Re-labelling process stopped.') + self.logger.info("Re-labelling process stopped.") return - + self.store_data() # acdc_df_concat = self.getConcatAcdcDf() # load.store_unsaved_acdc_df( - # posData, acdc_df_concat, + # posData, acdc_df_concat, # log_func=self.logger.info # ) # if posData.SizeT > 1: self.progressWin = apps.QDialogWorkerProgress( - title='Re-labelling sequential', parent=self, - pbarDesc='Relabelling sequential...' + title="Re-labelling sequential", + parent=self, + pbarDesc="Relabelling sequential...", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) self.startRelabellingWorker(selectedPos) def restoreAnnotOptions_ax1(self, options=None): - if options is None and not hasattr(self, 'annotOptionsToRestore'): + if options is None and not hasattr(self, "annotOptionsToRestore"): return if options is None: options = self.annotOptionsToRestore - + if options is None: return - + for option, state in options.items(): checkbox = getattr(self, option) checkbox.setChecked(state) - + self.setDrawAnnotComboboxText() self.annotOptionsToRestore = None def restoreAnnotOptions_ax2(self): - if not hasattr(self, 'annotOptionsToRestoreRight'): + if not hasattr(self, "annotOptionsToRestoreRight"): return if self.annotOptionsToRestoreRight is None: @@ -683,7 +675,7 @@ def restoreAnnotOptions_ax2(self): for option, state in self.annotOptionsToRestoreRight.items(): checkbox = getattr(self, option) checkbox.setChecked(state) - + self.setDrawAnnotComboboxTextRight() self.annotOptionsToRestoreRight = None @@ -692,27 +684,27 @@ def restoreAnnotationsOptions(self): self.restoreAnnotOptions_ax2() def restoreSavedSettings(self): - if 'how_draw_annotations' in self.df_settings.index: - how = self.df_settings.at['how_draw_annotations', 'value'] + if "how_draw_annotations" in self.df_settings.index: + how = self.df_settings.at["how_draw_annotations", "value"] self.drawIDsContComboBox.setCurrentText(how) else: - self.drawIDsContComboBox.setCurrentText('Draw IDs and contours') - - if 'how_draw_right_annotations' in self.df_settings.index: - how = self.df_settings.at['how_draw_right_annotations', 'value'] + self.drawIDsContComboBox.setCurrentText("Draw IDs and contours") + + if "how_draw_right_annotations" in self.df_settings.index: + how = self.df_settings.at["how_draw_right_annotations", "value"] self.annotateRightHowCombobox.setCurrentText(how) else: self.annotateRightHowCombobox.setCurrentText( - 'Draw IDs and overlay segm. masks' + "Draw IDs and overlay segm. masks" ) - - if 'addNewIDsWhitelistToggle' in self.df_settings.index: + + if "addNewIDsWhitelistToggle" in self.df_settings.index: self.addNewIDsWhitelistToggle = ( - self.df_settings.at['addNewIDsWhitelistToggle', 'value'] - ) == 'Yes' + (self.df_settings.at["addNewIDsWhitelistToggle", "value"]) == "Yes" + ) else: self.addNewIDsWhitelistToggle = True - + self.drawAnnotCombobox_to_options() self.drawIDsContComboBox_cb(0) self.annotateRightHowCombobox_cb(0) @@ -726,10 +718,10 @@ def rtTrackerActionToggled(self, checked): trackingAlgo = aliases[self.sender().text()] else: trackingAlgo = self.sender().text() - self.df_settings.at['tracking_algorithm', 'value'] = trackingAlgo + self.df_settings.at["tracking_algorithm", "value"] = trackingAlgo self.df_settings.to_csv(self.settings_csv_path) - if self.sender().text() == 'YeaZ': + if self.sender().text() == "YeaZ": msg = widgets.myMessageBox(wrapText=False) info_txt = html_utils.paragraph(f""" Note that YeaZ tracking algorithm tends to be sliglhtly more accurate @@ -738,8 +730,8 @@ def rtTrackerActionToggled(self, checked): If you need to correct as many segmentation errors as possible we recommend using Cell-ACDC tracking algorithm. """) - msg.information(self, 'Info about YeaZ', info_txt) - + msg.information(self, "Info about YeaZ", info_txt) + self.isRealTimeTrackerInitialized = False self.initRealTimeTracker() @@ -747,23 +739,24 @@ def setAllTextAnnotations(self, labelsToSkip=None): delROIsIDs = self.setLostNewOldPrevIDs() posData = self.data[self.pos_i] self.textAnnot[0].setAnnotations( - posData=posData, - labelsToSkip=labelsToSkip, + posData=posData, + labelsToSkip=labelsToSkip, isVisibleCheckFunc=self.isObjVisible, - highlightedID=self.highlightedID, + highlightedID=self.highlightedID, delROIsIDs=delROIsIDs, - annotateLost=self.annotLostObjsToggle.isChecked(), - getCurrentZfunc=self.z_lab, - getObjCentroidFunc=self.getObjCentroid + annotateLost=self.annotLostObjsToggle.isChecked(), + getCurrentZfunc=self.z_lab, + getObjCentroidFunc=self.getObjCentroid, ) self.textAnnot[1].setAnnotations( - posData=posData, labelsToSkip=labelsToSkip, + posData=posData, + labelsToSkip=labelsToSkip, isVisibleCheckFunc=self.isObjVisible, - highlightedID=self.highlightedID, + highlightedID=self.highlightedID, delROIsIDs=delROIsIDs, - annotateLost=self.annotLostObjsToggle.isChecked(), - getCurrentZfunc=self.z_lab, - getObjCentroidFunc=self.getObjCentroid + annotateLost=self.annotLostObjsToggle.isChecked(), + getCurrentZfunc=self.z_lab, + getObjCentroidFunc=self.getObjCentroid, ) self.textAnnot[0].update() self.textAnnot[1].update() @@ -772,32 +765,30 @@ def setAllTextAnnotations(self, labelsToSkip=None): def setAnnotInfoMode(self, checked): if checked: for action in self.annotSettingsIDmenu.actions(): - if action.text().find('tree') != -1: + if action.text().find("tree") != -1: self.textAnnot[0].setLabelTreeAnnotationsEnabled(True) action.setChecked(True) break for action in self.annotSettingsGenNumMenu.actions(): - if action.text().find('tree') != -1: + if action.text().find("tree") != -1: self.textAnnot[0].setGenNumTreeAnnotationsEnabled(True) action.setChecked(True) break else: for action in self.annotSettingsIDmenu.actions(): - if action.text().find('tree') == -1: + if action.text().find("tree") == -1: action.setChecked(False) self.textAnnot[0].setLabelTreeAnnotationsEnabled(False) break for action in self.annotSettingsGenNumMenu.actions(): - if action.text().find('tree') == -1: + if action.text().find("tree") == -1: action.setChecked(False) self.textAnnot[0].setGenNumTreeAnnotationsEnabled(False) break self.setAllTextAnnotations() def setAnnotOptionsCcaMode(self): - self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1( - return_value=True - ) + self.prevAnnotOptions = self.storeCurrentAnnotOptions_ax1(return_value=True) self.annotCcaInfoCheckbox.setChecked(True) self.annotIDsCheckbox.setChecked(False) self.drawMothBudLinesCheckbox.setChecked(False) @@ -840,7 +831,7 @@ def setDisabledAnnotOptions(self, disabled): self.drawMothBudLinesCheckbox.setDisabled(disabled) # self.drawNothingCheckbox.setDisabled(disabled) - # Right + # Right self.annotIDsCheckboxRight.setDisabled(disabled) self.annotCcaInfoCheckboxRight.setDisabled(disabled) self.annotContourCheckboxRight.setDisabled(disabled) @@ -850,74 +841,74 @@ def setDisabledAnnotOptions(self, disabled): def setDrawAnnotComboboxText(self, saveSettings=True): if self.annotIDsCheckbox.isChecked(): if self.annotContourCheckbox.isChecked(): - t = 'Draw IDs and contours' + t = "Draw IDs and contours" elif self.annotSegmMasksCheckbox.isChecked(): - t = 'Draw IDs and overlay segm. masks' + t = "Draw IDs and overlay segm. masks" else: - t = 'Draw only IDs' - + t = "Draw only IDs" + elif self.annotCcaInfoCheckbox.isChecked(): if self.annotContourCheckbox.isChecked(): - t = 'Draw cell cycle info and contours' + t = "Draw cell cycle info and contours" elif self.annotSegmMasksCheckbox.isChecked(): - t = 'Draw cell cycle info and overlay segm. masks' + t = "Draw cell cycle info and overlay segm. masks" else: - t = 'Draw only cell cycle info' - + t = "Draw only cell cycle info" + elif self.annotSegmMasksCheckbox.isChecked(): - t = 'Draw only overlay segm. masks' + t = "Draw only overlay segm. masks" elif self.annotContourCheckbox.isChecked(): - t = 'Draw only contours' - + t = "Draw only contours" + elif self.drawMothBudLinesCheckbox.isChecked(): - t = 'Draw only mother-bud lines' - + t = "Draw only mother-bud lines" + elif self.drawNothingCheckbox.isChecked(): - t = 'Draw nothing' + t = "Draw nothing" else: - t = 'Draw nothing' + t = "Draw nothing" if t == self.drawIDsContComboBox.currentText(): self.drawIDsContComboBox_cb(0) - + self.drawIDsContComboBox.saveSettings = saveSettings self.drawIDsContComboBox.setCurrentText(t) def setDrawAnnotComboboxTextRight(self, saveSettings=True): if self.annotIDsCheckboxRight.isChecked(): if self.annotContourCheckboxRight.isChecked(): - t = 'Draw IDs and contours' + t = "Draw IDs and contours" elif self.annotSegmMasksCheckboxRight.isChecked(): - t = 'Draw IDs and overlay segm. masks' + t = "Draw IDs and overlay segm. masks" else: - t = 'Draw only IDs' - + t = "Draw only IDs" + elif self.annotCcaInfoCheckboxRight.isChecked(): if self.annotContourCheckboxRight.isChecked(): - t = 'Draw cell cycle info and contours' + t = "Draw cell cycle info and contours" elif self.annotSegmMasksCheckboxRight.isChecked(): - t = 'Draw cell cycle info and overlay segm. masks' + t = "Draw cell cycle info and overlay segm. masks" else: - t = 'Draw only cell cycle info' - + t = "Draw only cell cycle info" + elif self.annotSegmMasksCheckboxRight.isChecked(): - t = 'Draw only overlay segm. masks' + t = "Draw only overlay segm. masks" elif self.annotContourCheckboxRight.isChecked(): - t = 'Draw only contours' - + t = "Draw only contours" + elif self.drawMothBudLinesCheckboxRight.isChecked(): - t = 'Draw only mother-bud lines' - + t = "Draw only mother-bud lines" + elif self.drawNothingCheckboxRight.isChecked(): - t = 'Draw nothing' + t = "Draw nothing" else: - t = 'Draw nothing' + t = "Draw nothing" if t == self.annotateRightHowCombobox.currentText(): self.annotateRightHowCombobox_cb(0) - + self.annotateRightHowCombobox.saveSettings = saveSettings self.annotateRightHowCombobox.setCurrentText(t) @@ -925,8 +916,7 @@ def setDrawNothingAnnotations(self): self.storeCurrentAnnotOptions_ax1() self.storeCurrentAnnotOptions_ax2() self.drawNothingCheckbox.setChecked(True) - self.annotOptionClicked( - sender=self.drawNothingCheckbox, saveSettings=False) + self.annotOptionClicked(sender=self.drawNothingCheckbox, saveSettings=False) self.drawNothingCheckboxRight.setChecked(True) self.annotOptionClickedRight( sender=self.drawNothingCheckboxRight, saveSettings=False @@ -935,14 +925,13 @@ def setDrawNothingAnnotations(self): def setEnabledAnnotCheckBoxesLeftZdepthAxes(self): if not self.isSegm3D: return - + self.annotIDsCheckbox.setDisabled(False) self.annotContourCheckbox.setDisabled(False) self.annotIDsCheckbox.setChecked(True) self.annotContourCheckbox.setChecked(True) - - self.annotOptionClicked( - sender=self.annotIDsCheckbox, saveSettings=False) + + self.annotOptionClicked(sender=self.annotIDsCheckbox, saveSettings=False) def setVisible3DsegmWidgets(self): self.annotNumZslicesCheckbox.setVisible(self.isSegm3D) @@ -971,15 +960,15 @@ def showHighlightZneighCheckbox(self): def storeCurrentAnnotOptions_ax1(self, return_value=False): if self.annotOptionsToRestore is not None: return - + checkboxes = [ - 'annotIDsCheckbox', - 'annotCcaInfoCheckbox', - 'annotContourCheckbox', - 'annotSegmMasksCheckbox', - 'drawMothBudLinesCheckbox', - 'annotNumZslicesCheckbox', - 'drawNothingCheckbox', + "annotIDsCheckbox", + "annotCcaInfoCheckbox", + "annotContourCheckbox", + "annotSegmMasksCheckbox", + "drawMothBudLinesCheckbox", + "annotNumZslicesCheckbox", + "drawNothingCheckbox", ] annotOptions = {} for checkboxName in checkboxes: @@ -992,15 +981,15 @@ def storeCurrentAnnotOptions_ax1(self, return_value=False): def storeCurrentAnnotOptions_ax2(self): if self.annotOptionsToRestoreRight is not None: return - + checkboxes = [ - 'annotIDsCheckboxRight', - 'annotCcaInfoCheckboxRight', - 'annotContourCheckboxRight', - 'annotSegmMasksCheckboxRight', - 'drawMothBudLinesCheckboxRight', - 'annotNumZslicesCheckboxRight', - 'drawNothingCheckboxRight', + "annotIDsCheckboxRight", + "annotCcaInfoCheckboxRight", + "annotContourCheckboxRight", + "annotSegmMasksCheckboxRight", + "drawMothBudLinesCheckboxRight", + "annotNumZslicesCheckboxRight", + "drawNothingCheckboxRight", ] self.annotOptionsToRestoreRight = {} for checkboxName in checkboxes: @@ -1017,7 +1006,7 @@ def uncheckAnnotOptions(self, left=True, right=True): self.drawMothBudLinesCheckbox.setChecked(False) self.drawNothingCheckbox.setChecked(False) - # Right + # Right if right: self.annotIDsCheckboxRight.setChecked(False) self.annotCcaInfoCheckboxRight.setChecked(False) @@ -1027,7 +1016,7 @@ def uncheckAnnotOptions(self, left=True, right=True): self.drawNothingCheckboxRight.setChecked(False) def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print): - logger('Updating annotated IDs...') + logger("Updating annotated IDs...") posData = self.data[self.pos_i] mapper = dict(zip(oldIDs, newIDs)) @@ -1040,12 +1029,12 @@ def updateAnnotatedIDs(self, oldIDs, newIDs, logger=print): customAnnotButtons = list(self.customAnnotDict.keys()) for button in customAnnotButtons: customAnnotValues = self.customAnnotDict[button] - annotatedIDs = customAnnotValues['annotatedIDs'][self.pos_i] + annotatedIDs = customAnnotValues["annotatedIDs"][self.pos_i] mappedAnnotIDs = {} for frame_i, annotIDs_i in annotatedIDs.items(): mappedIDs = [mapper[ID] for ID in annotIDs_i] mappedAnnotIDs[frame_i] = mappedIDs - customAnnotValues['annotatedIDs'][self.pos_i] = mappedAnnotIDs + customAnnotValues["annotatedIDs"][self.pos_i] = mappedAnnotIDs def update_rp_metadata(self, draw=True): posData = self.data[self.pos_i] @@ -1062,33 +1051,29 @@ def zoomRectActionToggled(self, checked): self.connectLeftClickButtons() self.ax1.addItem(self.zoomRectItem) else: - self.zoomRectItem.setPos((0,0)) - self.zoomRectItem.setSize((0,0)) + self.zoomRectItem.setPos((0, 0)) + self.zoomRectItem.setSize((0, 0)) self.ax1.removeItem(self.zoomRectItem) def zoomRectCancelled(self): self.isMouseDragImg1 = False - self.zoomRectItem.setPos((0,0)) - self.zoomRectItem.setSize((0,0)) + self.zoomRectItem.setPos((0, 0)) + self.zoomRectItem.setSize((0, 0)) def zoomRectDone(self): xRange, yRange = self.ax1.viewRange() self.zoomRectItem.storeLastRange(xRange, yRange) - + ymin, xmin, ymax, xmax = self.zoomRectItem.bbox() - - self.zoomRectItem.setPos((0,0)) - self.zoomRectItem.setSize((0,0)) - - self.ax1.setRange( - xRange=(xmin, xmax), - yRange=(ymin, ymax), - padding=0 - ) + + self.zoomRectItem.setPos((0, 0)) + self.zoomRectItem.setSize((0, 0)) + + self.ax1.setRange(xRange=(xmin, xmax), yRange=(ymin, ymax), padding=0) def showAllContoursToggled(self): if not self.isDataLoaded: return - + self.computeAllContours() self.updateAllImages() diff --git a/cellacdc/mixins/app_shell.py b/cellacdc/mixins/app_shell.py index ec2ea4c50..7850c6442 100644 --- a/cellacdc/mixins/app_shell.py +++ b/cellacdc/mixins/app_shell.py @@ -21,6 +21,7 @@ from .actions import Actions from .session import Session + class AppShell(Actions, Session): """Extracted from guiWin.""" @@ -29,13 +30,13 @@ def about(self): def cleanUpOnError(self): self.onEscape() - caller = 'Cell-ACDC' - if self.module.startswith('spotmax'): - caller = 'spotMAX' - txt = f'WARNING: {caller} is in error state. Please, restart.' - _hl = '*'*100 - self.titleLabel.setText(txt, color='r') - self.logger.info(f'{_hl}\n{txt}\n{_hl}') + caller = "Cell-ACDC" + if self.module.startswith("spotmax"): + caller = "spotMAX" + txt = f"WARNING: {caller} is in error state. Please, restart." + _hl = "*" * 100 + self.titleLabel.setText(txt, color="r") + self.logger.info(f"{_hl}\n{txt}\n{_hl}") def copyContent(self): pass @@ -62,8 +63,8 @@ def determineSlideshowWinPos(self): winScreenCenterY = winScreenCenter.y() winScreenLeft = winScreenGeom.left() winScreenTop = winScreenGeom.top() - self.slideshowWinLeft = winScreenCenterX - int(850/2) - self.slideshowWinTop = winScreenCenterY - int(800/2) + self.slideshowWinLeft = winScreenCenterX - int(850 / 2) + self.slideshowWinTop = winScreenCenterY - int(800 / 2) def initGlobalAttr(self): self.setOverlayColors() @@ -101,13 +102,13 @@ def initGlobalAttr(self): self.keptIDsLineEdit, self.keepIDsConfirmAction ) self._ZprojWidgersEnabledState = None - self.imgValueFormatter = 'd' - self.rawValueFormatter = 'd' + self.imgValueFormatter = "d" + self.rawValueFormatter = "d" self.lastHoverID = -1 self.annotOptionsToRestore = None self.annotOptionsToRestoreRight = None self.rescaleIntensChannelHowMapper = { - self.user_ch_name: 'Rescale each 2D image' + self.user_ch_name: "Rescale each 2D image" } self.timestampDialog = None self.scaleBarDialog = None @@ -134,7 +135,7 @@ def initGlobalAttr(self): self.UserEnforced_Tracking = False self.ax1BrushHoverID = 0 - + self.disabled_cca_warnings = set() self.last_pos_i = -1 @@ -148,20 +149,17 @@ def initGlobalAttr(self): self.clickObjYc, self.clickObjXc = None, None self.cca_df_colnames = cca_df_colnames - self.cca_df_dtypes = [ - str, int, int, str, int, int, bool, bool, int - ] + self.cca_df_dtypes = [str, int, int, str, int, int, bool, bool, int] self.cca_df_default_values = list(base_cca_dict.values()) self.cca_df_int_cols = [ col for col in cca_df_colnames if type(base_cca_dict[col]) == int ] self.lin_tree_df_bool_col = [ - col for col in cca_df_colnames - if isinstance(base_cca_dict[col], bool) + col for col in cca_df_colnames if isinstance(base_cca_dict[col], bool) ] self.lin_tree_col_checks = [ - 'generation_num', + "generation_num", ] # self.lin_tree_df_colnames = set(base_cca_df.keys()) | set(lineage_tree_cols) @@ -170,49 +168,55 @@ def initGlobalAttr(self): # # ] # self.lin_tree_df_default_values = list(base_cca_df.values()) + lineage_tree_cols_std_val self.lin_tree_df_int_cols = [ - 'generation_num', - 'relative_ID', - 'emerg_frame_i', - 'division_frame_i', - 'corrected_on_frame_i' + "generation_num", + "relative_ID", + "emerg_frame_i", + "division_frame_i", + "corrected_on_frame_i", ] self.lin_tree_df_bool_col = [ - 'is_history_known', + "is_history_known", ] self.lin_tree_col_checks = [ - 'generation_num', + "generation_num", ] - self.lin_tree_df_colnames = self.lin_tree_df_int_cols + self.lin_tree_df_bool_col + self.lin_tree_col_checks - self.SegForLostIDsSettings = {} + self.lin_tree_df_colnames = ( + self.lin_tree_df_int_cols + + self.lin_tree_df_bool_col + + self.lin_tree_col_checks + ) + self.SegForLostIDsSettings = {} def initProfileModels(self): - self.logger.info('Initiliazing profilers...') - + self.logger.info("Initiliazing profilers...") + from ._profile.spline_to_obj import model - + self.splineToObjModel = model.Model() self.splineToObjModel.fit() def onToggleColorScheme(self): - if self.toggleColorSchemeAction.text().find('light') != -1: - self._colorScheme = 'light' + if self.toggleColorSchemeAction.text().find("light") != -1: + self._colorScheme = "light" setDarkModeToggleChecked = False else: - self._colorScheme = 'dark' + self._colorScheme = "dark" setDarkModeToggleChecked = True self.gui_updateSwitchColorSchemeActionText() _warnings.warnRestartCellACDCcolorModeToggled( self._colorScheme, app_name=self._appName, parent=self ) load.rename_qrc_resources_file(self._colorScheme) - self.statusBarLabel.setText(html_utils.paragraph( - f'Restart {self._appName} for the change to take effect', - font_color='red' - )) - self.df_settings.at['colorScheme', 'value'] = self._colorScheme + self.statusBarLabel.setText( + html_utils.paragraph( + f"Restart {self._appName} for the change to take effect", + font_color="red", + ) + ) + self.df_settings.at["colorScheme", "value"] = self._colorScheme self.df_settings.to_csv(settings_csv_path) def openLogFile(self): @@ -220,7 +224,7 @@ def openLogFile(self): myutils.showInExplorer(self.log_path) def openNewWindow(self): - self.logger.info('Opening a new window...') + self.logger.info("Opening a new window...") if self.launcherSlot is not None: self.launcherSlot() return @@ -235,7 +239,9 @@ def openNewWindow(self): def pasteContent(self): pass - def setDisabled(self, disabled:bool, keepDisabled:bool=None, force:bool=False): + def setDisabled( + self, disabled: bool, keepDisabled: bool = None, force: bool = False + ): if force: if disabled: super().setDisabled(disabled) @@ -262,21 +268,17 @@ def setTooltips(self): for key, tooltip in tooltips.items(): setShortcut = getattr(self, key).shortcut().toString() - if 'Shortcut: ' in tooltip: - tooltip = tooltip.replace('Shortcut: ', '\nShortcut: ') + if "Shortcut: " in tooltip: + tooltip = tooltip.replace("Shortcut: ", "\nShortcut: ") elif setShortcut != "": tooltip = re.sub( - r'Shortcut: \"(.*)\"', - f"Shortcut: \"{setShortcut}\"", - tooltip + r"Shortcut: \"(.*)\"", f'Shortcut: "{setShortcut}"', tooltip ) else: tooltip = re.sub( - r'Shortcut: \"(.*)\"', - f"Shortcut: \"No shortcut\"", - tooltip + r"Shortcut: \"(.*)\"", f'Shortcut: "No shortcut"', tooltip ) - + getattr(self, key).setToolTip(tooltip) getattr(self, key)._tooltip = tooltip @@ -287,7 +289,7 @@ def setWindowIcon(self, icon=None): def setWindowTitle(self, title=None): if title is None: - title = f'Cell-ACDC v{self._acdc_version} - GUI' + title = f"Cell-ACDC v{self._acdc_version} - GUI" super().setWindowTitle(title) def showAbout(self): diff --git a/cellacdc/mixins/brush_tools.py b/cellacdc/mixins/brush_tools.py index a2335d5bc..334e88018 100644 --- a/cellacdc/mixins/brush_tools.py +++ b/cellacdc/mixins/brush_tools.py @@ -12,6 +12,7 @@ from .geometry import Geometry from .tool_activation import ToolActivation + class BrushTools(Geometry, ToolActivation): """Extracted from guiWin.""" @@ -20,8 +21,14 @@ def Brush_cb(self, checked): self.typingEditID = False self.setDiskMask() self.setHoverToolSymbolData( - [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX) + [], + [], + ( + self.ax1_EraserCircle, + self.ax2_EraserCircle, + self.ax1_EraserX, + self.ax2_EraserX, + ), ) self.updateBrushCursor(self.xHoverImg, self.yHoverImg) self.setBrushID() @@ -29,7 +36,7 @@ def Brush_cb(self, checked): self.disconnectLeftClickButtons() self.uncheckLeftClickButtons(self.sender()) c = self.defaultToolBarButtonColor - self.eraserButton.setStyleSheet(f'background-color: {c}') + self.eraserButton.setStyleSheet(f"background-color: {c}") self.connectLeftClickButtons() self.setFocusGraphics() else: @@ -39,10 +46,12 @@ def Brush_cb(self, checked): self.ax2_lostTrackedScatterItem.setVisible(True) self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + [], + [], + (self.ax2_BrushCircle, self.ax1_BrushCircle), ) self.resetCursors() - + self.showEditIDwidgets(checked) self.enableSizeSpinbox(checked) @@ -50,22 +59,30 @@ def Eraser_cb(self, checked): if checked: self.setDiskMask() self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + [], + [], + (self.ax2_BrushCircle, self.ax1_BrushCircle), ) self.updateEraserCursor(self.xHoverImg, self.yHoverImg) self.disconnectLeftClickButtons() self.uncheckLeftClickButtons(self.sender()) c = self.defaultToolBarButtonColor - self.brushButton.setStyleSheet(f'background-color: {c}') + self.brushButton.setStyleSheet(f"background-color: {c}") self.connectLeftClickButtons() else: self.setHoverToolSymbolData( - [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX) + [], + [], + ( + self.ax1_EraserCircle, + self.ax2_EraserCircle, + self.ax1_EraserX, + self.ax2_EraserX, + ), ) self.resetCursors() self.updateAllImages() - + self.showEditIDwidgets(checked) self.enableSizeSpinbox(checked) @@ -76,7 +93,7 @@ def applyBrushMask(self, mask, ID, toLocalSlice=None): posData = self.data[self.pos_i] if self.isSegm3D: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' + isZslice = zProjHow == "single z-slice" if isZslice: if toLocalSlice is not None: toLocalSlice = (self.z_lab(), *toLocalSlice) @@ -100,7 +117,7 @@ def applyEraserMask(self, mask): posData = self.data[self.pos_i] if self.isSegm3D: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' + isZslice = zProjHow == "single z-slice" if isZslice: posData.lab[self.z_lab(), mask] = 0 else: @@ -116,22 +133,24 @@ def autoIDtoggled(self, checked): self.editIDspinbox.setValue(newID) def brushAutoFillToggled(self, checked): - val = 'Yes' if checked else 'No' - self.df_settings.at['brushAutoFill', 'value'] = val + val = "Yes" if checked else "No" + self.df_settings.at["brushAutoFill", "value"] = val self.df_settings.to_csv(self.settings_csv_path) def brushAutoHideToggled(self, checked): - val = 'Yes' if checked else 'No' - self.df_settings.at['brushAutoHide', 'value'] = val + val = "Yes" if checked else "No" + self.df_settings.at["brushAutoHide", "value"] = val self.df_settings.to_csv(self.settings_csv_path) def brushReleased(self): posData = self.data[self.pos_i] - self.fillHolesID(posData.brushID, sender='brush') - + self.fillHolesID(posData.brushID, sender="brush") + # Update data (rp, etc) - self.update_rp(update_IDs=self.isNewID,) - + self.update_rp( + update_IDs=self.isNewID, + ) + # Repeat tracking if self.autoIDcheckbox.isChecked(): self.trackManuallyAddedObject(posData.brushID, self.isNewID) @@ -140,7 +159,7 @@ def brushReleased(self): # Update images if self.isNewID: - editTxt = 'Add new ID with brush tool' + editTxt = "Add new ID with brush tool" if self.isSnapshot: self.fixCcaDfAfterEdit(editTxt) self.updateAllImages() @@ -148,21 +167,20 @@ def brushReleased(self): self.warnEditingWithCca_df(editTxt) else: self.updateAllImages() - + self.isNewID = False def brushSize_cb(self, value): - self.ax2_EraserCircle.setSize(value*2) - self.ax1_BrushCircle.setSize(value*2) - self.ax2_BrushCircle.setSize(value*2) - self.ax1_EraserCircle.setSize(value*2) + self.ax2_EraserCircle.setSize(value * 2) + self.ax1_BrushCircle.setSize(value * 2) + self.ax2_BrushCircle.setSize(value * 2) + self.ax1_EraserCircle.setSize(value * 2) self.ax2_EraserX.setSize(value) self.ax1_EraserX.setSize(value) self.setDiskMask() def changeBrushID(self): - """Function called when pressing or releasing shift - """ + """Function called when pressing or releasing shift""" if not self.isSegm3D: # Changing brush ID with shift is only for 3D segm return @@ -170,17 +188,17 @@ def changeBrushID(self): if not self.brushButton.isChecked(): # Brush if not active return - + if not self.isMouseDragImg2 and not self.isMouseDragImg1: # Mouse is not brushing at the moment return posData = self.data[self.pos_i] forceNewObj = not self.isNewID - + if forceNewObj: # Shift is down --> force new object with brush - # e.g., 24 --> 28: + # e.g., 24 --> 28: # 24 is hovering ID that we store as self.prevBrushID # 24 object becomes 28 that is the new posData.brushID self.isNewID = True @@ -189,43 +207,43 @@ def changeBrushID(self): # Set a new ID self.setBrushID() else: - # Shift released or hovering on ID in z+-1 - # --> restore brush ID from before shift was pressed or from - # when we started brushing from outside an object + # Shift released or hovering on ID in z+-1 + # --> restore brush ID from before shift was pressed or from + # when we started brushing from outside an object # but we hovered on ID in z+-1 while dragging. - # We change the entire 28 object to 24 so before changing the + # We change the entire 28 object to 24 so before changing the # brush ID back to 24 we builg the mask with 28 to change it to 24 self.isNewID = False self.changedID = posData.brushID - # Restore ID + # Restore ID posData.brushID = self.restoreBrushID - + brushID = posData.brushID brushIDmask = self.get_2Dlab(posData.lab) == self.changedID self.applyBrushMask(brushIDmask, brushID) if self.isMouseDragImg1: - self.brushColor = self.lut[posData.brushID]/255 + self.brushColor = self.lut[posData.brushID] / 255 self.setTempImg1Brush(True, brushIDmask, posData.brushID) def checkWarnDeletedIDwithEraser(self): posData = self.data[self.pos_i] - + for ID in self.erasedIDs: if ID == 0: continue if ID in posData.IDs_idxs: continue - + self.instructHowDeleteID() - + if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete ID with eraser') + self.fixCcaDfAfterEdit("Delete ID with eraser") self.updateAllImages() else: - self.warnEditingWithCca_df('Delete ID with eraser') - + self.warnEditingWithCca_df("Delete ID with eraser") + return True - + return False def clearObjFromMask(self, image, mask, toLocalSlice=None): @@ -236,15 +254,15 @@ def clearObjFromMask(self, image, mask, toLocalSlice=None): image[mask] = 0 else: image[toLocalSlice][mask] = 0 - + return image - def fillHolesID(self, ID, sender='brush'): + def fillHolesID(self, ID, sender="brush"): posData = self.data[self.pos_i] - if sender == 'brush': + if sender == "brush": if not self.brushAutoFillCheckbox.isChecked(): return False - + lab2D = self.get_2Dlab(posData.lab) mask = lab2D == ID filledMask = scipy.ndimage.binary_fill_holes(mask) @@ -258,19 +276,19 @@ def getDiskMask(self, xdata, ydata): Y, X = self.currentLab2D.shape[-2:] brushSize = self.brushSizeSpinbox.value() - yBottom, xLeft = ydata-brushSize, xdata-brushSize - yTop, xRight = ydata+brushSize+1, xdata+brushSize+1 + yBottom, xLeft = ydata - brushSize, xdata - brushSize + yTop, xRight = ydata + brushSize + 1, xdata + brushSize + 1 - if xLeft<0: - if yBottom<0: + if xLeft < 0: + if yBottom < 0: # Disk mask out of bounds top-left diskMask = self.diskMask.copy() diskMask = diskMask[-yBottom:, -xLeft:] yBottom = 0 - elif yTop>Y: + elif yTop > Y: # Disk mask out of bounds bottom-left diskMask = self.diskMask.copy() - diskMask = diskMask[0:Y-yBottom, -xLeft:] + diskMask = diskMask[0 : Y - yBottom, -xLeft:] yTop = Y else: # Disk mask out of bounds on the left @@ -278,33 +296,33 @@ def getDiskMask(self, xdata, ydata): diskMask = diskMask[:, -xLeft:] xLeft = 0 - elif xRight>X: - if yBottom<0: + elif xRight > X: + if yBottom < 0: # Disk mask out of bounds top-right diskMask = self.diskMask.copy() - diskMask = diskMask[-yBottom:, 0:X-xLeft] + diskMask = diskMask[-yBottom:, 0 : X - xLeft] yBottom = 0 - elif yTop>Y: + elif yTop > Y: # Disk mask out of bounds bottom-right diskMask = self.diskMask.copy() - diskMask = diskMask[0:Y-yBottom, 0:X-xLeft] + diskMask = diskMask[0 : Y - yBottom, 0 : X - xLeft] yTop = Y else: # Disk mask out of bounds on the right diskMask = self.diskMask.copy() - diskMask = diskMask[:, 0:X-xLeft] + diskMask = diskMask[:, 0 : X - xLeft] xRight = X - elif yBottom<0: + elif yBottom < 0: # Disk mask out of bounds on top diskMask = self.diskMask.copy() diskMask = diskMask[-yBottom:] yBottom = 0 - elif yTop>Y: + elif yTop > Y: # Disk mask out of bounds on bottom diskMask = self.diskMask.copy() - diskMask = diskMask[0:Y-yBottom] + diskMask = diskMask[0 : Y - yBottom] yTop = Y else: @@ -323,12 +341,12 @@ def getMagicWandFloodTolerance(self): tol_perc = self.wandControlsToolbar.toleranceSpinbox.value() if tol_perc == 0: return - + posData = self.data[self.pos_i] _min, _max = posData.img_data_min_max - tol_fraction = tol_perc/100 + tol_fraction = tol_perc / 100 tol = (_max - _min) * tol_fraction - + return tol def initFloodMaskImage(self): @@ -343,12 +361,12 @@ def initTempLayerBrush(self, ID, ax=0): how = self.drawIDsContComboBox.currentText() else: how = self.getAnnotateHowRightImage() - + self.hideItemsHoverBrush(ID=ID, force=True) Y, X = self.img1.image.shape[:2] tempImage = np.zeros((Y, X), dtype=np.uint32) - if how.find('contours') != -1: - tempImage[self.currentLab2D==ID] = ID + if how.find("contours") != -1: + tempImage[self.currentLab2D == ID] = ID self.brushImage = tempImage.copy() self.brushContourImage = np.zeros((Y, X, 4), dtype=np.uint8) color = self.imgGrad.contoursColorButton.color() @@ -358,44 +376,44 @@ def initTempLayerBrush(self, ID, ax=0): opacity = self.imgGrad.labelsAlphaSlider.value() color = self.lut[ID] lut = np.zeros((2, 4), dtype=np.uint8) - lut[1,-1] = 255 - lut[1,:-1] = color + lut[1, -1] = 255 + lut[1, :-1] = color self.tempLayerImg1.setLookupTable(lut) self.tempLayerImg1.setOpacity(opacity) self.tempLayerImg1.setImage(tempImage, force_set_linked=True) def instructHowDeleteID(self): - if 'showInfoDeleteObject' not in self.df_settings.index: - self.df_settings.at['showInfoDeleteObject', 'value'] = 'Yes' - + if "showInfoDeleteObject" not in self.df_settings.index: + self.df_settings.at["showInfoDeleteObject", "value"] = "Yes" + showInfoDeleteObject = ( - self.df_settings.at['showInfoDeleteObject', 'value'] == 'Yes' + self.df_settings.at["showInfoDeleteObject", "value"] == "Yes" ) if not showInfoDeleteObject: return - + actionText = self.middleClickText() msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - 'You have deleted an object using the eraser tool.

    ' + "You have deleted an object using the eraser tool.

    " 'Did you know that you can use the "Delete object" action
    ' - 'to delete an object with a single click?

    ' - f'To do so, use the following action: {actionText}

    ' - 'Note: You can also set a custom shortcut by going to the menu
    ' - 'Settings --> Customise keyboard shortcuts....' + "to delete an object with a single click?

    " + f"To do so, use the following action: {actionText}

    " + "Note: You can also set a custom shortcut by going to the menu
    " + "Settings --> Customise keyboard shortcuts...." ) - doNotShowAgainCheckbox = QCheckBox('Do not show again') + doNotShowAgainCheckbox = QCheckBox("Do not show again") msg.information( - self, 'Delete objects with single click', txt, - widgets=doNotShowAgainCheckbox + self, + "Delete objects with single click", + txt, + widgets=doNotShowAgainCheckbox, ) - + showInfoDeleteObjectValue = ( - 'No' if doNotShowAgainCheckbox.isChecked() else 'Yes' - ) - self.df_settings.at['showInfoDeleteObject', 'value'] = ( - showInfoDeleteObjectValue + "No" if doNotShowAgainCheckbox.isChecked() else "Yes" ) + self.df_settings.at["showInfoDeleteObject", "value"] = showInfoDeleteObjectValue self.df_settings.to_csv(settings_csv_path) def resetCursors(self): @@ -427,12 +445,14 @@ def setBrushID(self, useCurrentLab=True, return_val=False): for frame_i, storedData in enumerate(posData.allData_li): if frame_i == posData.frame_i: continue - lab = storedData['labels'] + lab = storedData["labels"] if lab is not None: - rp = storedData['regionprops'] + rp = storedData["regionprops"] IDs_tot = {obj.label for obj in rp} if wl_init: - if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): + if self.whitelistCheckOriginalLabels( + warning=False, frame_i=frame_i + ): IDs_tot.update(posData.whitelist.originalLabsIDs[frame_i]) if posData.whitelist.whitelistIDs[frame_i]: IDs_tot.update(posData.whitelist.whitelistIDs[frame_i]) @@ -445,7 +465,7 @@ def setBrushID(self, useCurrentLab=True, return_val=False): for y, x, manual_ID in posData.editID_info: if manual_ID > newID: newID = manual_ID - posData.brushID = newID+1 + posData.brushID = newID + 1 if return_val: return posData.brushID @@ -462,32 +482,29 @@ def setDiskMask(self): def setTempBrushMaskFromWand(self, flood_mask, init=False): if not np.any(flood_mask): return - + posData = self.data[self.pos_i] - mask = np.logical_or( - flood_mask, - posData.lab==posData.brushID - ) + mask = np.logical_or(flood_mask, posData.lab == posData.brushID) if mask.ndim == 3: z_slice = self.zSliceScrollBar.sliderPosition() mask = mask[z_slice] - + self.setTempImg1Brush(init, mask, posData.brushID) def setTempImg1Brush(self, init: bool, mask, ID, toLocalSlice=None, ax=0): if init: self.initTempLayerBrush(ID, ax=ax) - + if self.annotContourCheckbox.isChecked(): brushImage = self.brushImage else: brushImage = self.tempLayerImg1.image - + if toLocalSlice is None: brushImage[mask] = ID else: brushImage[toLocalSlice][mask] = ID - + if self.annotContourCheckbox.isChecked(): try: obj = skimage.measure.regionprops(brushImage)[0] @@ -513,20 +530,18 @@ def setTempImg1Eraser(self, mask, init=False, toLocalSlice=None, ax=0): how = self.drawIDsContComboBox.currentText() else: how = self.getAnnotateHowRightImage() - + if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): return - - if how.find('contours') != -1: - self.clearObjFromMask( - self.contoursImage, mask, toLocalSlice=toLocalSlice - ) + + if how.find("contours") != -1: + self.clearObjFromMask(self.contoursImage, mask, toLocalSlice=toLocalSlice) erasedRp = skimage.measure.regionprops(self.erasedLab) for obj in erasedRp: self.addObjContourToContoursImage(obj=obj, ax=ax) - elif how.find('overlay segm. masks') != -1: + elif how.find("overlay segm. masks") != -1: labelsImage = self.getLabelsLayerImage(ax=ax) - self.clearObjFromMask(labelsImage, mask, toLocalSlice=toLocalSlice) + self.clearObjFromMask(labelsImage, mask, toLocalSlice=toLocalSlice) if ax == 0: self.labelsLayerImg1.setImage( self.labelsLayerImg1.image, autoLevels=False @@ -559,27 +574,26 @@ def updateEraserCursor(self, x, y, xyLocked=None, isHoverImg1=True): if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): return - size = self.brushSizeSpinbox.value()*2 + size = self.brushSizeSpinbox.value() * 2 self.setHoverToolSymbolData( - [x], [y], self.activeEraserCircleCursors(isHoverImg1), - size=size + [x], [y], self.activeEraserCircleCursors(isHoverImg1), size=size ) self.setHoverToolSymbolData( - [x], [y], self.activeEraserXCursors(isHoverImg1), - size=int(size/2) + [x], [y], self.activeEraserXCursors(isHoverImg1), size=int(size / 2) ) - isMouseDrag = ( - self.isMouseDragImg1 or self.isMouseDragImg2 - ) + isMouseDrag = self.isMouseDragImg1 or self.isMouseDragImg2 if isMouseDrag: return - + if xyLocked is not None: xdata, ydata = xyLocked self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, + xdata, + ydata, + self.eraserCirclePen, self.activeEraserCircleCursors(isHoverImg1), - self.eraserButton, hoverRGB=None + self.eraserButton, + hoverRGB=None, ) diff --git a/cellacdc/mixins/canvas_context_menu.py b/cellacdc/mixins/canvas_context_menu.py index 1b7af4e92..14aa2cbf9 100644 --- a/cellacdc/mixins/canvas_context_menu.py +++ b/cellacdc/mixins/canvas_context_menu.py @@ -8,6 +8,7 @@ from .image_display import ImageDisplay + class CanvasContextMenu(ImageDisplay): """Extracted from guiWin.""" @@ -16,9 +17,7 @@ def gui_clickedDelRoi(self, event, left_click, right_click): x, y = event.pos().x(), event.pos().y() # Check if right click on ROI - delROIs = ( - posData.allData_li[posData.frame_i]['delROIs_info']['rois'].copy() - ) + delROIs = posData.allData_li[posData.frame_i]["delROIs_info"]["rois"].copy() for r, roi in enumerate(delROIs): ROImask = self.getDelRoiMask(roi) if self.isSegm3D: @@ -33,7 +32,7 @@ def gui_clickedDelRoi(self, event, left_click, right_click): separator = QAction(self) separator.setSeparator(True) self.roiContextMenu.addAction(separator) - action = QAction('Remove ROI') + action = QAction("Remove ROI") action.triggered.connect(self.removeDelROI) self.roiContextMenu.addAction(action) try: @@ -48,28 +47,28 @@ def gui_clickedDelRoi(self, event, left_click, right_click): return False def checkHighlightScaleBar(self, x, y, activeToolButton): - if not hasattr(self, 'scaleBar'): + if not hasattr(self, "scaleBar"): return - + if not self.addScaleBarAction.isChecked(): return - + if activeToolButton is not None: return - + ymin, xmin, ymax, xmax = self.scaleBar.bbox() if x < xmin: self.scaleBar.setHighlighted(False) return - + if x > xmax: self.scaleBar.setHighlighted(False) return - + if y < ymin: self.scaleBar.setHighlighted(False) return - + if y > ymax: self.scaleBar.setHighlighted(False) return @@ -77,32 +76,32 @@ def checkHighlightScaleBar(self, x, y, activeToolButton): self.scaleBar.setHighlighted(True) def checkHighlightTimestamp(self, x, y, activeToolButton): - if not hasattr(self, 'timestamp'): + if not hasattr(self, "timestamp"): return - + if not self.addTimestampAction.isChecked(): return - + if activeToolButton is not None: return - - if hasattr(self, 'scaleBar'): + + if hasattr(self, "scaleBar"): if self.scaleBar.isHighlighted(): return - + ymin, xmin, ymax, xmax = self.timestamp.bbox() if x < xmin: self.timestamp.setHighlighted(False) return - + if x > xmax: self.timestamp.setHighlighted(False) return - + if y < ymin: self.timestamp.setHighlighted(False) return - + if y > ymax: self.timestamp.setHighlighted(False) return @@ -110,16 +109,16 @@ def checkHighlightTimestamp(self, x, y, activeToolButton): self.timestamp.setHighlighted(True) def gui_imgGradShowContextMenu(self, x, y): - if hasattr(self, 'scaleBar'): + if hasattr(self, "scaleBar"): if self.scaleBar.isHighlighted(): self.scaleBar.showContextMenu(x, y) return - - if hasattr(self, 'timestamp'): + + if hasattr(self, "timestamp"): if self.timestamp.isHighlighted(): self.timestamp.showContextMenu(x, y) return - + self.imgGrad.gradient.menu.popup(QPoint(int(x), int(y))) def gui_rightImageShowContextMenu(self, event): diff --git a/cellacdc/mixins/canvas_drawing.py b/cellacdc/mixins/canvas_drawing.py index f18e8bb25..b3558e574 100644 --- a/cellacdc/mixins/canvas_drawing.py +++ b/cellacdc/mixins/canvas_drawing.py @@ -14,6 +14,7 @@ from .canvas_selection import CanvasSelection from .label_editing import LabelEditing + class CanvasDrawing(CanvasSelection, LabelEditing): """Extracted from guiWin.""" @@ -37,39 +38,39 @@ def gui_addCreatedAxesItems(self): self.textAnnot[0].addToPlotItem(self.ax1) self.textAnnot[1].addToPlotItem(self.ax2) - + self.ax1.addItem(self.exportMaskImageItem) self.ax1.exportMaskImageItem = self.exportMaskImageItem def gui_mouseDragEventImg1(self, event): x, y = event.pos().x(), event.pos().y() - - if hasattr(self, 'scaleBar'): + + if hasattr(self, "scaleBar"): if self.scaleBarDialog is not None: - self.scaleBarDialog.locCombobox.setCurrentText('Custom') + self.scaleBarDialog.locCombobox.setCurrentText("Custom") if self.scaleBar.isHighlighted() and self.scaleBar.clicked: - self.scaleBar.setLocationProperty('custom') + self.scaleBar.setLocationProperty("custom") self.scaleBar.move(x, y) return - - if hasattr(self, 'timestamp'): + + if hasattr(self, "timestamp"): if self.timestampDialog is not None: - self.timestampDialog.locCombobox.setCurrentText('Custom') + self.timestampDialog.locCombobox.setCurrentText("Custom") if self.timestamp.isHighlighted() and self.timestamp.clicked: - self.timestamp.setLocationProperty('custom') + self.timestamp.setLocationProperty("custom") self.timestamp.move(x, y) return - + mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': + if mode == "Viewer": return - + posData = self.data[self.pos_i] Y, X = self.get_2Dlab(posData.lab).shape xdata, ydata = int(x), int(y) if not myutils.is_in_bounds(xdata, ydata, X, Y): return - + if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): self.drawAutoContour(y, x) @@ -90,17 +91,20 @@ def gui_mouseDragEventImg1(self, event): mask = np.zeros(lab_2D.shape, bool) mask[diskSlice][diskMask] = True mask[rrPoly, ccPoly] = True - + modifiers = QGuiApplication.keyboardModifiers() ctrl = modifiers == Qt.ControlModifier # t3 = time.perf_counter() if not self.isPowerBrush() and not ctrl: - mask[lab_2D!=0] = False + mask[lab_2D != 0] = False self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, + xdata, + ydata, + self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush + self.brushButton, + brush=self.ax2_BrushCircleBrush, ) # t4 = time.perf_counter() @@ -113,12 +117,9 @@ def gui_mouseDragEventImg1(self, event): # t5 = time.perf_counter() lab2D = self.get_2Dlab(posData.lab) - brushMask = np.logical_and( - lab2D[diskSlice] == posData.brushID, diskMask - ) + brushMask = np.logical_and(lab2D[diskSlice] == posData.brushID, diskMask) self.setTempImg1Brush( - False, brushMask, posData.brushID, - toLocalSlice=diskSlice + False, brushMask, posData.brushID, toLocalSlice=diskSlice ) # t6 = time.perf_counter() @@ -150,23 +151,26 @@ def gui_mouseDragEventImg1(self, event): mask[rrPoly, ccPoly] = True if self.eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False + mask[lab_2D != self.erasedID] = False self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, + xdata, + ydata, + self.eraserCirclePen, (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], - ID=self.erasedID + self.eraserButton, + hoverRGB=self.img2.lut[self.erasedID], + ID=self.erasedID, ) self.erasedIDs.update(lab_2D[mask]) self.applyEraserMask(mask) self.setImageImg2() - + for erasedID in self.erasedIDs: if erasedID == 0: continue - self.erasedLab[lab_2D==erasedID] = erasedID + self.erasedLab[lab_2D == erasedID] = erasedID self.erasedLab[mask] = 0 eraserMask = mask[diskSlice] @@ -186,12 +190,10 @@ def gui_mouseDragEventImg1(self, event): seed = (z_slice, ydata, xdata) else: seed = (ydata, xdata) - - flood_mask = skimage.segmentation.flood( - self.flood_img, seed, tolerance=tol - ) + + flood_mask = skimage.segmentation.flood(self.flood_img, seed, tolerance=tol) drawUnderMask = np.logical_or( - posData.lab==0, posData.lab==posData.brushID + posData.lab == 0, posData.lab == posData.brushID ) flood_mask = np.logical_and(flood_mask, drawUnderMask) @@ -199,35 +201,35 @@ def gui_mouseDragEventImg1(self, event): if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): self.flood_mask = core.binary_fill_holes(self.flood_mask) - + if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): self.flood_mask = core.convex_hull_mask(self.flood_mask) self.setTempBrushMaskFromWand(self.flood_mask) - + # Label ROI dragging mouse --> draw ROI elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): if self.labelRoiIsRectRadioButton.isChecked(): x0, y0 = self.labelRoiItem.pos() - w, h = (xdata-x0), (ydata-y0) + w, h = (xdata - x0), (ydata - y0) self.labelRoiItem.setSize((w, h)) elif self.labelRoiIsFreeHandRadioButton.isChecked(): self.freeRoiItem.addPoint(xdata, ydata) - + # Draw freehand clear region --> draw region elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): self.freeRoiItem.addPoint(xdata, ydata) - + # Label ROI dragging mouse --> draw ROI elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): x0, y0 = self.zoomRectItem.pos() - w, h = (xdata-x0), (ydata-y0) + w, h = (xdata - x0), (ydata - y0) self.zoomRectItem.setSize((w, h)) def gui_mouseDragEventImg2(self, event): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': + if mode == "Viewer": return Y, X = self.get_2Dlab(posData.lab).shape @@ -254,12 +256,15 @@ def gui_mouseDragEventImg2(self, event): mask[rrPoly, ccPoly] = True if self.eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False + mask[lab_2D != self.erasedID] = False self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, + xdata, + ydata, + self.eraserCirclePen, (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton, hoverRGB=self.img2.lut[self.erasedID], - ID=self.erasedID + self.eraserButton, + hoverRGB=self.img2.lut[self.erasedID], + ID=self.erasedID, ) self.erasedIDs.update(lab_2D[mask]) @@ -286,11 +291,14 @@ def gui_mouseDragEventImg2(self, event): # If user double-pressed 'b' then draw over the labels color = self.brushButton.palette().button().color().name() if color != self.doublePressKeyButtonColor: - mask[lab_2D!=0] = False + mask[lab_2D != 0] = False self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, + xdata, + ydata, + self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.eraserButton, brush=self.ax2_BrushCircleBrush + self.eraserButton, + brush=self.ax2_BrushCircleBrush, ) # Apply brush mask @@ -308,12 +316,12 @@ def gui_mouseReleaseEventImg1(self, event): ctrl = modifiers == Qt.ControlModifier alt = modifiers == Qt.AltModifier right_click = event.button() == Qt.MouseButton.RightButton and not alt - + posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': + if mode == "Viewer": return - + Y, X = self.get_2Dlab(posData.lab).shape x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) @@ -321,21 +329,20 @@ def gui_mouseReleaseEventImg1(self, event): self.isMouseDragImg2 = False self.updateAllImages() return - - if hasattr(self, 'scaleBar'): + + if hasattr(self, "scaleBar"): if self.scaleBar.isHighlighted() and self.scaleBar.clicked: self.scaleBar.clicked = False return - - if hasattr(self, 'timestamp'): + + if hasattr(self, "timestamp"): if self.timestamp.isHighlighted() and self.timestamp.clicked: self.timestamp.clicked = False return - + sendRightClickImg2 = ( - (mode=='Segmentation and Tracking' or self.isSnapshot) - and right_click - ) + mode == "Segmentation and Tracking" or self.isSnapshot + ) and right_click if sendRightClickImg2: # Allow right-click actions on both images self.gui_mouseReleaseEventImg2(event) @@ -349,10 +356,10 @@ def gui_mouseReleaseEventImg1(self, event): if self.autoIDcheckbox.isChecked(): self.trackManuallyAddedObject(posData.brushID, True) if self.isSnapshot: - self.fixCcaDfAfterEdit('Add new ID with curvature tool') + self.fixCcaDfAfterEdit("Add new ID with curvature tool") self.updateAllImages() else: - self.warnEditingWithCca_df('Add new ID with curvature tool') + self.warnEditingWithCca_df("Add new ID with curvature tool") self.clearCurvItems() self.curvTool_cb(True) except ValueError: @@ -365,12 +372,12 @@ def gui_mouseReleaseEventImg1(self, event): self.isMouseDragImg1 = False self.clearTempBrushImage() - + # Update data (rp, etc) self.update_rp() doUpdateImages = self.checkWarnDeletedIDwithEraser() - + if doUpdateImages: self.updateAllImages() @@ -379,7 +386,7 @@ def gui_mouseReleaseEventImg1(self, event): self.isMouseDragImg1 = False self.clearTempBrushImage() - + self.brushReleased() # Wand tool release, add new object @@ -390,7 +397,7 @@ def gui_mouseReleaseEventImg1(self, event): posData = self.data[self.pos_i] posData.lab[self.flood_mask] = posData.brushID - + # Update data (rp, etc) self.update_rp() @@ -398,11 +405,11 @@ def gui_mouseReleaseEventImg1(self, event): self.trackManuallyAddedObject(posData.brushID, self.isNewID) if self.isSnapshot: - self.fixCcaDfAfterEdit('Add new ID with magic-wand') + self.fixCcaDfAfterEdit("Add new ID with magic-wand") self.updateAllImages() else: - self.warnEditingWithCca_df('Add new ID with magic-wand') - + self.warnEditingWithCca_df("Add new ID with magic-wand") + # Label ROI mouse release --> label the ROI with labelRoiWorker elif self.isMouseDragImg1 and self.labelRoiButton.isChecked(): self.labelRoiRunning = True @@ -411,7 +418,7 @@ def gui_mouseReleaseEventImg1(self, event): if self.labelRoiIsFreeHandRadioButton.isChecked(): self.freeRoiItem.closeCurve() - + proceed = self.labelRoiCheckStartStopFrame() if not proceed: self.labelRoiCancelled() @@ -428,44 +435,45 @@ def gui_mouseReleaseEventImg1(self, event): if cancel: self.labelRoiCancelled() return - - # Restore state of button because it was maybe unchecked by - # using other tools that are allowed --> see "elif" case in + + # Restore state of button because it was maybe unchecked by + # using other tools that are allowed --> see "elif" case in # labelRoi_cb self.labelRoiButton.blockSignals(True) self.labelRoiButton.setChecked(True) self.labelRoiToolbar.setVisible(True) self.labelRoiButton.blockSignals(False) - + roiSecondChannel = None if self.secondChannelName is not None: secondChannelData = self.getSecondChannelData() roiSecondChannel = secondChannelData[self.labelRoiSlice] - + isTimelapse = self.labelRoiTrangeCheckbox.isChecked() if isTimelapse: start_n = self.labelRoiStartFrameNoSpinbox.value() stop_n = self.labelRoiStopFrameNoSpinbox.value() self.progressWin = apps.QDialogWorkerProgress( - title='ROI segmentation', parent=self, - pbarDesc=f'Segmenting frames n. {start_n} to {stop_n}...' + title="ROI segmentation", + parent=self, + pbarDesc=f"Segmenting frames n. {start_n} to {stop_n}...", ) self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stop_n-start_n) + self.progressWin.mainPbar.setMaximum(stop_n - start_n) - - self.app.restoreOverrideCursor() + self.app.restoreOverrideCursor() labelRoiWorker = self.labelRoiActiveWorkers[-1] labelRoiWorker.start( - roiImg, posData, - roiSecondChannel=roiSecondChannel, - isTimelapse=isTimelapse - ) + roiImg, + posData, + roiSecondChannel=roiSecondChannel, + isTimelapse=isTimelapse, + ) self.app.setOverrideCursor(Qt.WaitCursor) self.logger.info( - f'Magic labeller started on image ROI = {self.labelRoiSlice}...' + f"Magic labeller started on image ROI = {self.labelRoiSlice}..." ) - self.titleLabel.setText('Magic labeller is doing its magic...') + self.titleLabel.setText("Magic labeller is doing its magic...") self.setDisabled(True) # Move label mouse released, update move @@ -492,16 +500,15 @@ def gui_mouseReleaseEventImg1(self, event): return if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) mothID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as mother cell', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to annotate as mother cell", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) mothID_prompt.exec_() if mothID_prompt.cancel: @@ -516,22 +523,20 @@ def gui_mouseReleaseEventImg1(self, event): # Store undo state before modifying stuff self.storeUndoRedoStates(False) - relationship = posData.cca_df.at[ID, 'relationship'] - ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] - is_history_known = posData.cca_df.at[ID, 'is_history_known'] + relationship = posData.cca_df.at[ID, "relationship"] + ccs = posData.cca_df.at[ID, "cell_cycle_stage"] + is_history_known = posData.cca_df.at[ID, "is_history_known"] # We allow assiging a cell in G1 as mother only on first frame # OR if the history is unknown - if relationship == 'bud' and posData.frame_i > 0 and is_history_known: + if relationship == "bud" and posData.frame_i > 0 and is_history_known: self.assignBudMothButton.setChecked(False) txt = html_utils.paragraph( - f'You clicked on ID {ID} which is a BUD.

    ' - 'To assign a bud start by clicking on the bud ' - 'and release on a cell in G1' + f"You clicked on ID {ID} which is a BUD.

    " + "To assign a bud start by clicking on the bud " + "and release on a cell in G1" ) msg = widgets.myMessageBox() - msg.critical( - self, 'Released on a bud', txt - ) + msg.critical(self, "Released on a bud", txt) self.assignBudMothButton.setChecked(True) return @@ -549,26 +554,29 @@ def gui_mouseReleaseEventImg1(self, event): self.assignBudMothButton.setChecked(False) msg = widgets.myMessageBox() txt = ( - f'You clicked FIRST on ID {budID} and then on {new_mothID}.
    ' - f'For me this means that you want ID {budID} to be the ' - f'BUD of ID {new_mothID}.
    ' - f'However ID {budID} is bigger than {new_mothID} ' - f'so maybe you should have clicked FIRST on {new_mothID}?

    ' - 'What do you want me to do?' + f"You clicked FIRST on ID {budID} and then on {new_mothID}.
    " + f"For me this means that you want ID {budID} to be the " + f"BUD of ID {new_mothID}.
    " + f"However ID {budID} is bigger than {new_mothID} " + f"so maybe you should have clicked FIRST on {new_mothID}?

    " + "What do you want me to do?" ) txt = html_utils.paragraph(txt) swapButton, keepButton = msg.warning( - self, 'Which one is bud?', txt, + self, + "Which one is bud?", + txt, buttonsTexts=( - f'Assign ID {new_mothID} as the bud of ID {budID}', - f'Keep ID {budID} as the bud of ID {new_mothID}' - ) + f"Assign ID {new_mothID} as the bud of ID {budID}", + f"Keep ID {budID} as the bud of ID {new_mothID}", + ), ) if msg.clickedButton == swapButton: - (xdata, ydata, - self.xClickBud, self.yClickBud) = ( - self.xClickBud, self.yClickBud, - xdata, ydata + (xdata, ydata, self.xClickBud, self.yClickBud) = ( + self.xClickBud, + self.yClickBud, + xdata, + ydata, ) self.assignBudMothButton.setChecked(True) @@ -577,23 +585,21 @@ def gui_mouseReleaseEventImg1(self, event): budID = self.get_2Dlab(posData.lab)[ydata, xdata] # Allow assigning an unknown cell ONLY to another unknown cell txt = ( - f'You started by clicking on ID {budID} which has ' - 'UNKNOWN history, but you then clicked/released on ' - f'ID {ID} which has KNOWN history.\n\n' - 'Only two cells with UNKNOWN history can be assigned as ' - 'relative of each other.' + f"You started by clicking on ID {budID} which has " + "UNKNOWN history, but you then clicked/released on " + f"ID {ID} which has KNOWN history.\n\n" + "Only two cells with UNKNOWN history can be assigned as " + "relative of each other." ) msg = QMessageBox() - msg.critical( - self, 'Released on a cell with KNOWN history', txt, msg.Ok - ) + msg.critical(self, "Released on a cell with KNOWN history", txt, msg.Ok) self.assignBudMothButton.setChecked(True) return self.clickedOnHistoryKnown = is_history_known self.xClickMoth, self.yClickMoth = xdata, ydata - - if ccs != 'G1' and posData.frame_i > 0: + + if ccs != "G1" and posData.frame_i > 0: self.assignBudMothButton.setChecked(False) self.onMotherNotInG1(ID) self.assignBudMothButton.setChecked(True) @@ -605,14 +611,14 @@ def gui_mouseReleaseEventImg1(self, event): self.clickedOnBud = False self.BudMothTempLine.setData([], []) - + # Draw clear region mouse release elif self.isMouseDragImg1 and self.drawClearRegionButton.isChecked(): self.isMouseDragImg1 = False self.freeRoiItem.closeCurve() self.clearObjsFreehandRegion() - - # Zoom rect mouse release + + # Zoom rect mouse release elif self.isMouseDragImg1 and self.zoomRectButton.isChecked(): self.isMouseDragImg1 = False self.zoomRectDone() diff --git a/cellacdc/mixins/canvas_events.py b/cellacdc/mixins/canvas_events.py index 410f64719..53dcc3945 100644 --- a/cellacdc/mixins/canvas_events.py +++ b/cellacdc/mixins/canvas_events.py @@ -16,6 +16,7 @@ from .canvas_selection import CanvasSelection from .label_editing import LabelEditing + class CanvasEvents(CanvasContextMenu, CanvasSelection, LabelEditing): """Extracted from guiWin.""" @@ -27,8 +28,8 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): isMod = alt posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - isCcaMode = mode == 'Cell cycle analysis' - isCustomAnnotMode = mode == 'Custom annotations' + isCcaMode = mode == "Cell cycle analysis" + isCustomAnnotMode = mode == "Custom annotations" left_click = event.button() == Qt.MouseButton.LeftButton and not isMod middle_click = self.isMiddleClick(event, modifiers) right_click = event.button() == Qt.MouseButton.RightButton @@ -50,7 +51,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): pointsLayerON = self.togglePointsLayerAction.isChecked() copyContourON = ( self.copyLostObjButton.isChecked() - and self.ax1_lostObjScatterItem.hoverLostID>0 + and self.ax1_lostObjScatterItem.hoverLostID > 0 ) findNextMotherButtonON = self.findNextMotherButton.isChecked() unknownLineageButtonON = self.unknownLineageButton.isChecked() @@ -63,7 +64,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): seg = segments[0] seg.roi.segmentClicked(seg, event) return - + # Check if right-click on handle of polyline roi to remove it handles = self.gui_getHoveredHandlesPolyLineRoi() if len(handles) == 1 and right_click: @@ -75,22 +76,32 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): isClickOnDelRoi = self.gui_clickedDelRoi(event, left_click, right_click) if isClickOnDelRoi: return - + dragImgLeft = ( - left_click and not brushON and not histON - and not curvToolON and not eraserON and not rulerON - and not wandON and not polyLineRoiON and not labelRoiON - and not middle_click and not keepObjON and not separateON - and not manualBackgroundON and not drawClearRegionON - and addPointsByClickingButton is None and not whitelistIDsON + left_click + and not brushON + and not histON + and not curvToolON + and not eraserON + and not rulerON + and not wandON + and not polyLineRoiON + and not labelRoiON + and not middle_click + and not keepObjON + and not separateON + and not manualBackgroundON + and not drawClearRegionON + and addPointsByClickingButton is None + and not whitelistIDsON and not zoomRectON ) if isPanImageClick: dragImgLeft = True - is_right_click_custom_ON = any([ - b.isChecked() for b in self.customAnnotDict.keys() - ]) + is_right_click_custom_ON = any( + [b.isChecked() for b in self.customAnnotDict.keys()] + ) canAnnotateDivision = ( not self.assignBudMothButton.isChecked() @@ -103,9 +114,8 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): # In timelapse mode division can be annotated if isCcaMode and right-click # while in snapshot mode with Ctrl+right-click - isAnnotateDivision = ( - (right_click and isCcaMode and canAnnotateDivision) - or (right_click and ctrl and self.isSnapshot) + isAnnotateDivision = (right_click and isCcaMode and canAnnotateDivision) or ( + right_click and ctrl and self.isSnapshot ) isCustomAnnot = ( @@ -114,18 +124,23 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): and self.customAnnotButton is not None ) - is_right_click_action_ON = any([ - b.isChecked() for b in self.checkableQButtonsGroup.buttons() - ]) + is_right_click_action_ON = any( + [b.isChecked() for b in self.checkableQButtonsGroup.buttons()] + ) isOnlyRightClick = ( - right_click and canAnnotateDivision and not isAnnotateDivision - and not isMod and not is_right_click_action_ON - and not is_right_click_custom_ON and not copyContourON - and not findNextMotherButtonON and not unknownLineageButtonON + right_click + and canAnnotateDivision + and not isAnnotateDivision + and not isMod + and not is_right_click_action_ON + and not is_right_click_custom_ON + and not copyContourON + and not findNextMotherButtonON + and not unknownLineageButtonON and not middle_click ) - + if isOnlyRightClick: # Start timer or check if it is a double-right-click if self.countRightClicks == 0: @@ -136,132 +151,225 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self._img1_click_xy = (screenPos.x(), screenPos.y()) QTimer.singleShot(400, self.doubleRightClickTimerCallBack) return - elif ( - self.countRightClicks == 1 - and not self.doubleRightClickTimeElapsed - ): + elif self.countRightClicks == 1 and not self.doubleRightClickTimeElapsed: self.isDoubleRightClick = True self.countRightClicks = 0 self.editIDbutton.setChecked(True) # Left click actions canCurv = ( - curvToolON and not self.assignBudMothButton.isChecked() - and not brushON and not dragImgLeft and not eraserON - and not polyLineRoiON and not labelRoiON + curvToolON + and not self.assignBudMothButton.isChecked() + and not brushON + and not dragImgLeft + and not eraserON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON + and not manualBackgroundON + and not drawClearRegionON + and not magicPromptsON + and not zoomRectON ) canBrush = ( - brushON and not curvToolON and not rulerON - and not dragImgLeft and not eraserON and not wandON - and not labelRoiON and not manualBackgroundON - and addPointsByClickingButton is None and not drawClearRegionON - and not magicPromptsON and not zoomRectON + brushON + and not curvToolON + and not rulerON + and not dragImgLeft + and not eraserON + and not wandON + and not labelRoiON + and not manualBackgroundON + and addPointsByClickingButton is None + and not drawClearRegionON + and not magicPromptsON + and not zoomRectON ) canErase = ( - eraserON and not curvToolON and not rulerON - and not dragImgLeft and not brushON and not wandON - and not polyLineRoiON and not labelRoiON + eraserON + and not curvToolON + and not rulerON + and not dragImgLeft + and not brushON + and not wandON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON + and not manualBackgroundON + and not drawClearRegionON + and not magicPromptsON + and not zoomRectON ) canRuler = ( - rulerON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not wandON - and not polyLineRoiON and not labelRoiON + rulerON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not wandON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON + and not manualBackgroundON + and not drawClearRegionON + and not magicPromptsON + and not zoomRectON ) canWand = ( - wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON + wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not magicPromptsON and not zoomRectON + and not manualBackgroundON + and not drawClearRegionON + and not magicPromptsON + and not zoomRectON ) canPolyLine = ( - polyLineRoiON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not labelRoiON and not manualBackgroundON + polyLineRoiON + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not labelRoiON + and not manualBackgroundON and addPointsByClickingButton is None - and not drawClearRegionON and not magicPromptsON + and not drawClearRegionON + and not magicPromptsON and not zoomRectON ) canLabelRoi = ( - labelRoiON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not keepObjON + labelRoiON + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not keepObjON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not whitelistIDsON and not magicPromptsON + and not manualBackgroundON + and not drawClearRegionON + and not whitelistIDsON + and not magicPromptsON and not zoomRectON ) canKeep = ( - keepObjON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON + keepObjON + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not whitelistIDsON and not magicPromptsON + and not manualBackgroundON + and not drawClearRegionON + and not whitelistIDsON + and not magicPromptsON and not zoomRectON ) canWhitelistIDs = ( - whitelistIDsON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON + whitelistIDsON + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not keepObjON and not magicPromptsON + and not manualBackgroundON + and not drawClearRegionON + and not keepObjON + and not magicPromptsON and not zoomRectON ) canAddPoint = ( (pointsLayerON or magicPromptsON) - and addPointsByClickingButton is not None and not wandON - and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON and not keepObjON - and not manualBackgroundON and not drawClearRegionON + and addPointsByClickingButton is not None + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not labelRoiON + and not keepObjON + and not manualBackgroundON + and not drawClearRegionON and not zoomRectON ) canAddManualBackgroundObj = ( - manualBackgroundON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON + manualBackgroundON + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not keepObjON and not drawClearRegionON - and not magicPromptsON and not whitelistIDsON + and not keepObjON + and not drawClearRegionON + and not magicPromptsON + and not whitelistIDsON and not zoomRectON ) canDrawClearRegion = ( - drawClearRegionON and not wandON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not labelRoiON and not manualBackgroundON + drawClearRegionON + and not wandON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not labelRoiON + and not manualBackgroundON and addPointsByClickingButton is None - and not polyLineRoiON and not magicPromptsON - and not whitelistIDsON and not zoomRectON + and not polyLineRoiON + and not magicPromptsON + and not whitelistIDsON + and not zoomRectON ) canZoomRect = ( - zoomRectON and not curvToolON and not brushON - and not dragImgLeft and not brushON and not rulerON - and not polyLineRoiON and not labelRoiON + zoomRectON + and not curvToolON + and not brushON + and not dragImgLeft + and not brushON + and not rulerON + and not polyLineRoiON + and not labelRoiON and addPointsByClickingButton is None - and not manualBackgroundON and not drawClearRegionON - and not wandON and not whitelistIDsON and not magicPromptsON + and not manualBackgroundON + and not drawClearRegionON + and not wandON + and not whitelistIDsON + and not magicPromptsON ) - + # Enable dragging of the image window or the scalebar if dragImgLeft and not isCustomAnnot: x, y = event.pos().x(), event.pos().y() - if hasattr(self, 'scaleBar'): + if hasattr(self, "scaleBar"): if self.scaleBar.isHighlighted(): self.scaleBar.mousePressed(x, y) return - if hasattr(self, 'timestamp'): + if hasattr(self, "timestamp"): if self.timestamp.isHighlighted(): self.timestamp.mousePressed(x, y) return @@ -269,21 +377,22 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): event.ignore() return - isAllowedActionViewer = (canAddPoint or canRuler) - - if mode == 'Viewer' and not isAllowedActionViewer: + isAllowedActionViewer = canAddPoint or canRuler + + if mode == "Viewer" and not isAllowedActionViewer: self.startBlinkingModeCB() event.ignore() return - + # Allow right-click or middle-click actions on both images eventOnImg2 = ( ( - right_click or (middle_click and not canAddPoint) + right_click or (middle_click and not canAddPoint) # or (left_click and separateON) ) - and (mode=='Segmentation and Tracking' or self.isSnapshot) - and not isAnnotateDivision and not manualBackgroundON + and (mode == "Segmentation and Tracking" or self.isSnapshot) + and not isAnnotateDivision + and not manualBackgroundON ) if eventOnImg2: event.isImg1Sender = True @@ -303,7 +412,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xdata, ydata = int(x), int(y) lab_2D = self.get_2Dlab(posData.lab) Y, X = lab_2D.shape - + # Store undo state before modifying stuff self.storeUndoRedoStates(False, storeOnlyZoom=True) @@ -317,9 +426,9 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): # to not use their IDs anymore in the future self.isNewID = True self.setBrushID() - self.updateLookuptable(lenNewLut=posData.brushID+1) + self.updateLookuptable(lenNewLut=posData.brushID + 1) - self.brushColor = self.lut[posData.brushID]/255 + self.brushColor = self.lut[posData.brushID] / 255 self.yPressAx2, self.xPressAx2 = y, x @@ -332,7 +441,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): localLab = lab_2D[diskSlice] mask = diskMask.copy() if not self.isPowerBrush() and not ctrl: - mask[localLab!=0] = False + mask[localLab != 0] = False self.applyBrushMask(mask, posData.brushID, toLocalSlice=diskSlice) @@ -361,10 +470,10 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.yPressAx2, self.xPressAx2 = y, x # Keep a list of erased IDs got erased self.erasedIDs = set() - + if self.xyOnCtrlPressedFirstTime is not None: self.erasedID = self.getHoverID(*self.xyOnCtrlPressedFirstTime) - else: + else: self.erasedID = self.getHoverID(xdata, ydata) ymin, xmin, ymax, xmax, diskMask = self.getDiskMask(xdata, ydata) @@ -373,29 +482,27 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): mask = np.zeros(lab_2D.shape, bool) mask[ymin:ymax, xmin:xmax][diskMask] = True - # If user double-pressed 'b' then erase over ALL labels color = self.eraserButton.palette().button().color().name() eraseOnlyOneID = ( - color != self.doublePressKeyButtonColor - and self.erasedID != 0 + color != self.doublePressKeyButtonColor and self.erasedID != 0 ) self.eraseOnlyOneID = eraseOnlyOneID if eraseOnlyOneID: - mask[lab_2D!=self.erasedID] = False + mask[lab_2D != self.erasedID] = False self.setTempImg1Eraser(mask, init=True) self.applyEraserMask(mask) - self.erasedIDs.update(lab_2D[mask]) + self.erasedIDs.update(lab_2D[mask]) for erasedID in self.erasedIDs: if erasedID == 0: continue - self.erasedLab[lab_2D==erasedID] = erasedID - + self.erasedLab[lab_2D == erasedID] = erasedID + self.isMouseDragImg1 = True elif canAddPoint: @@ -408,45 +515,45 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): if not magicPromptsON: removed_id = min(removed_ids) addPointsByClickingButton.pointIdSpinbox.setValue(removed_id) - addPointsByClickingButton.pointIdSpinbox.removedId = ( - removed_id - ) + addPointsByClickingButton.pointIdSpinbox.removedId = removed_id else: self.restorePrevPointIdRightClick(addPointsByClickingButton) self.drawPointsLayers(computePointsLayers=False) else: point_id = self.getAddedPointId( - magicPromptsON, addPointsByClickingButton, - right_click, left_click, middle_click + magicPromptsON, + addPointsByClickingButton, + right_click, + left_click, + middle_click, ) if point_id is None: return - + self.addClickedPoint(action, x, y, point_id) self.drawPointsLayers(computePointsLayers=False) - + point_id = self.getClickedPointNewId( - action, point_id, + action, + point_id, addPointsByClickingButton.pointIdSpinbox, - isMagicPrompts=magicPromptsON + isMagicPrompts=magicPromptsON, ) addPointsByClickingButton.pointIdSpinbox.setValue( point_id, setLinkedWidget=False ) - + elif left_click and canDrawClearRegion: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) self.freeRoiItem.addPoint(xdata, ydata) - + self.isMouseDragImg1 = True - + elif left_click and canRuler or canPolyLine: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - closePolyLine = ( - len(self.startPointPolyLineItem.pointsAt(event.pos())) > 0 - ) + closePolyLine = len(self.startPointPolyLineItem.pointsAt(event.pos())) > 0 if not self.tempSegmentON or canPolyLine: # Keep adding anchor points for polyline self.ax1_rulerAnchorsItem.setData([xdata], [ydata]) @@ -468,7 +575,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): [x0, x1], [y0, y1], lengthText=lengthText ) self.ax1_rulerAnchorsItem.setData([x0, x1], [y0, y1]) - + xxPolyLine = self.startPointPolyLineItem.getData()[0] if canPolyLine and len(xxPolyLine) == 0: # Create and add roi item @@ -495,67 +602,67 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): # Call roi moving on closing ROI self.delROImoving(self.polyLineRoi) self.delROImovingFinished(self.polyLineRoi) - + elif left_click and canKeep: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) keepID_win = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to keep', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to keep", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) keepID_win.exec_() if keepID_win.cancel: return else: ID = keepID_win.EntryID - + if ID in self.keptObjectsIDs: self.keptObjectsIDs.remove(ID) self.clearHighlightedText() else: self.keptObjectsIDs.append(ID) self.highlightLabelID(ID) - + self.updateTempLayerKeepIDs() - + elif left_click and canWhitelistIDs: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) keepID_win = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to select', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to select", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) keepID_win.exec_() if keepID_win.cancel: return else: ID = keepID_win.EntryID - + posData = self.data[self.pos_i] if not posData.whitelist: wl_init = False - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() # not updated, only use in this context + if not hasattr(self, "tempWhitelistIDs"): + self.tempWhitelistIDs = ( + set() + ) # not updated, only use in this context current_whitelist = self.tempWhitelistIDs else: current_whitelist = self.tempWhitelistIDs @@ -569,11 +676,9 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): else: current_whitelist.add(ID) self.highlightLabelID(ID) - - self.whitelistIDsToolbar.whitelistLineEdit.setText( - current_whitelist - ) - + + self.whitelistIDsToolbar.whitelistLineEdit.setText(current_whitelist) + if wl_init: posData.whitelist[posData.frame_i] = current_whitelist else: @@ -611,13 +716,13 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): closeSpline = False clickedAnchors = self.curvAnchors.pointsAt(event.pos()) xxA, yyA = self.curvAnchors.getData() - if len(xxA)>0: + if len(xxA) > 0: if len(xxA) == 1: self.splineHoverON = True x0, y0 = xxA[0], yyA[0] - if len(clickedAnchors)>0: + if len(clickedAnchors) > 0: xA_clicked, yA_clicked = clickedAnchors[0].pos() - if x0==xA_clicked and y0==yA_clicked: + if x0 == xA_clicked and y0 == yA_clicked: x = x0 y = y0 closeSpline = True @@ -630,7 +735,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): except Exception as e: # traceback.print_exc() pass - + if closeSpline: self.splineHoverON = False self.curvToolSplineToObj() @@ -638,10 +743,10 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): if self.autoIDcheckbox.isChecked(): self.trackManuallyAddedObject(posData.brushID, True) if self.isSnapshot: - self.fixCcaDfAfterEdit('Add new ID with curvature tool') + self.fixCcaDfAfterEdit("Add new ID with curvature tool") self.updateAllImages() else: - self.warnEditingWithCca_df('Add new ID with curvature tool') + self.warnEditingWithCca_df("Add new ID with curvature tool") self.clearCurvItems() self.curvTool_cb(True) @@ -656,11 +761,9 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): posData.brushID = self.get_2Dlab(posData.lab)[ydata, xdata] if posData.brushID == 0: self.setBrushID() - self.updateLookuptable( - lenNewLut=posData.brushID+1 - ) + self.updateLookuptable(lenNewLut=posData.brushID + 1) self.isNewID = True - self.brushColor = self.img2.lut[posData.brushID]/255 + self.brushColor = self.img2.lut[posData.brushID] / 255 # NOTE: flood is on mousedrag or release tol = self.getMagicWandFloodTolerance() @@ -670,38 +773,36 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): seed = (z_slice, ydata, xdata) else: seed = (ydata, xdata) - - flood_mask = skimage.segmentation.flood( - self.flood_img, seed, tolerance=tol - ) - + + flood_mask = skimage.segmentation.flood(self.flood_img, seed, tolerance=tol) + drawUnderMask = np.logical_or( - posData.lab==0, posData.lab==posData.brushID + posData.lab == 0, posData.lab == posData.brushID ) self.flood_mask = np.logical_and(flood_mask, drawUnderMask) if self.wandControlsToolbar.autoFillHolesCheckbox.isChecked(): self.flood_mask = core.binary_fill_holes(self.flood_mask) - + if self.wandControlsToolbar.useConvexHullCheckbox.isChecked(): self.flood_mask = core.convex_hull_mask(self.flood_mask) - + self.setTempBrushMaskFromWand(self.flood_mask, init=True) self.isMouseDragImg1 = True - + elif right_click and self.manualTrackingButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) manualTrackID = self.manualTrackingToolbar.spinboxID.value() clickedID = self.getClickedID( - xdata, ydata, text=f'that you want to assign to {manualTrackID}' + xdata, ydata, text=f"that you want to assign to {manualTrackID}" ) if clickedID is None: return if clickedID == manualTrackID: self.manualTrackingToolbar.showWarning( - f'The clicked object already has ID = {manualTrackID}' + f"The clicked object already has ID = {manualTrackID}" ) return @@ -716,35 +817,35 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): posData.lab[posData.lab == manualTrackID] = clickedID posData.lab[posData.lab == tempID] = manualTrackID self.manualTrackingToolbar.showWarning( - f'The ID {manualTrackID} already exists --> ' - f'ID {manualTrackID} has been swapped with {clickedID}' + f"The ID {manualTrackID} already exists --> " + f"ID {manualTrackID} has been swapped with {clickedID}" ) else: posData.lab[posData.lab == clickedID] = manualTrackID self.manualTrackingToolbar.showInfo( - f'ID {clickedID} changed to {manualTrackID}.' + f"ID {clickedID} changed to {manualTrackID}." ) - + self.update_rp() self.updateAllImages() - + elif right_click and manualBackgroundON: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - + delID = posData.manualBackgroundLab[ydata, xdata] if delID == 0: return - + self.clearManualBackgroundObject(delID) textItem = self.manualBackgroundTextItems.pop(delID) self.ax1.removeItem(textItem) self.setManualBackgroundImage() - + elif left_click and canAddManualBackgroundObj: x, y = event.pos().x(), event.pos().y() - - self.addManualBackgroundObject(x, y) + + self.addManualBackgroundObject(x, y) self.setManualBackgroundImage() self.setManualBackgrounNextID() @@ -753,7 +854,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): if right_click: # Force model initialization on mouse release self.labelRoiModel = None - + x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) @@ -761,7 +862,7 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): self.labelRoiItem.setPos((xdata, ydata)) elif self.labelRoiIsFreeHandRadioButton.isChecked(): self.freeRoiItem.addPoint(xdata, ydata) - + self.isMouseDragImg1 = True # Annotate cell cycle division @@ -773,16 +874,15 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) divID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as divided', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to annotate as divided", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) divID_prompt.exec_() if divID_prompt.cancel: @@ -816,16 +916,15 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) budID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID of a bud you want to correct mother assignment', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID of a bud you want to correct mother assignment", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) budID_prompt.exec_() if budID_prompt.cancel: @@ -837,19 +936,19 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): y, x = posData.rp[obj_idx].centroid xdata, ydata = int(x), int(y) - relationship = posData.cca_df.at[ID, 'relationship'] - is_history_known = posData.cca_df.at[ID, 'is_history_known'] + relationship = posData.cca_df.at[ID, "relationship"] + is_history_known = posData.cca_df.at[ID, "is_history_known"] self.clickedOnHistoryKnown = is_history_known # We allow assiging a cell in G1 as bud only on first frame # OR if the history is unknown - if relationship != 'bud' and posData.frame_i > 0 and is_history_known: - txt = (f'You clicked on ID {ID} which is NOT a bud.\n' - 'To assign a bud to a cell start by clicking on a bud ' - 'and release on a cell in G1') - msg = QMessageBox() - msg.critical( - self, 'Not a bud', txt, msg.Ok + if relationship != "bud" and posData.frame_i > 0 and is_history_known: + txt = ( + f"You clicked on ID {ID} which is NOT a bud.\n" + "To assign a bud to a cell start by clicking on a bud " + "and release on a cell in G1" ) + msg = QMessageBox() + msg.critical(self, "Not a bud", txt, msg.Ok) return self.clickedOnBud = True @@ -864,17 +963,16 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) unknownID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as ' - '"history UNKNOWN/KNOWN"', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to annotate as " + '"history UNKNOWN/KNOWN"', + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) unknownID_prompt.exec_() if unknownID_prompt.cancel: @@ -894,16 +992,15 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) clickedBkgrDialog = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as divided', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to annotate as divided", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) clickedBkgrDialog.exec_() if clickedBkgrDialog.cancel: @@ -917,38 +1014,34 @@ def gui_mousePressEventImg1(self, event: QMouseEvent): button = self.doCustomAnnotation(ID) if button is None: return - - keepActive = self.customAnnotDict[button]['state']['keepActive'] + + keepActive = self.customAnnotDict[button]["state"]["keepActive"] if not keepActive: button.setChecked(False) elif right_click and findNextMotherButtonON: if posData.frame_i == 0: return - + self.find_mother_action(posData, event, ydata, xdata) elif right_click and unknownLineageButtonON: if posData.frame_i == 0: return - + self.annotate_unknown_lineage_action(posData, event, ydata, xdata) - + elif (left_click or right_click) and canZoomRect: if left_click: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - + self.zoomRectItem.setPos((xdata, ydata)) - + self.isMouseDragImg1 = True else: try: xRange, yRange = self.zoomRectItem.getLastRange() - self.ax1.setRange( - xRange=xRange, - yRange=yRange, - padding=0 - ) + self.ax1.setRange(xRange=xRange, yRange=yRange, padding=0) except Exception as err: QTimer.singleShot(100, self.autoRange) diff --git a/cellacdc/mixins/canvas_hover.py b/cellacdc/mixins/canvas_hover.py index 4c6cc5d7a..13679525a 100644 --- a/cellacdc/mixins/canvas_hover.py +++ b/cellacdc/mixins/canvas_hover.py @@ -11,6 +11,7 @@ from .canvas_events import CanvasEvents + class CanvasHover(CanvasEvents): """Extracted from guiWin.""" @@ -27,7 +28,7 @@ def drawTempMergeObjsLine(self, event, posData, modifiers): obj_idx = posData.IDs_idxs[ID] obj = posData.rp[obj_idx] y2, x2 = self.getObjCentroid(obj.centroid) - + if modifier and ID > 0: self.mergeObjsTempLine.addPoint(x2, y2) elif not modifier: @@ -55,9 +56,7 @@ def drawTempRulerLine(self, event): xxRA, yyRA = self.ax1_rulerAnchorsItem.getData() x0, y0 = xxRA[0], yyRA[0] if ctrl: - x1, y1 = transformation.snap_xy_to_closest_angle( - x0, y0, x1, y1 - ) + x1, y1 = transformation.snap_xy_to_closest_angle(x0, y0, x1, y1) self.ax1_rulerPlotItem.setData([x0, x1], [y0, y1]) def gui_add_ax_cursors(self): @@ -68,14 +67,22 @@ def gui_add_ax_cursors(self): pass self.ax2_cursor = pg.ScatterPlotItem( - symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), - brush=pg.mkBrush('w'), size=16, tip=None + symbol="+", + pxMode=True, + pen=pg.mkPen("k", width=1), + brush=pg.mkBrush("w"), + size=16, + tip=None, ) self.ax2.addItem(self.ax2_cursor) self.ax1_cursor = pg.ScatterPlotItem( - symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), - brush=pg.mkBrush('w'), size=16, tip=None + symbol="+", + pxMode=True, + pen=pg.mkPen("k", width=1), + brush=pg.mkBrush("w"), + size=16, + tip=None, ) self.ax1.addItem(self.ax1_cursor) @@ -84,7 +91,7 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): posData = self.data[self.pos_i] except AttributeError: return - + # Update x, y, value label bottom right if not event.isExit(): self.xHoverImg, self.yHoverImg = event.pos() @@ -93,19 +100,19 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): if event.isExit(): self.resetCursor() - + if not event.isExit() and self.slideshowWin is not None: self.slideshowWin.setMirroredCursorPos(*event.pos()) - + # Alt key was released --> restore cursor modifiers = QGuiApplication.keyboardModifiers() cursorsInfo = self.gui_setCursor(modifiers, event) self.highlightHoverLostObj(modifiers, event) - + drawRulerLine = ( - (self.rulerButton.isChecked() - or self.addDelPolyLineRoiButton.isChecked()) - and self.tempSegmentON and not event.isExit() + (self.rulerButton.isChecked() or self.addDelPolyLineRoiButton.isChecked()) + and self.tempSegmentON + and not event.isExit() ) if drawRulerLine: self.drawTempRulerLine(event) @@ -128,68 +135,74 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): else: self.clickedOnBud = False self.BudMothTempLine.setData([], []) - self.wcLabel.setText('') - - if cursorsInfo['setKeepObjCursor']: + self.wcLabel.setText("") + + if cursorsInfo["setKeepObjCursor"]: x, y = event.pos() self.highlightHoverIDsKeptObj(x, y) - - if cursorsInfo['setManualTrackingCursor']: + + if cursorsInfo["setManualTrackingCursor"]: x, y = event.pos() # self.highlightHoverID(x, y) self.drawManualTrackingGhost(x, y) - - if cursorsInfo['setManualBackgroundCursor']: + + if cursorsInfo["setManualBackgroundCursor"]: x, y = event.pos() # self.highlightHoverID(x, y) self.drawManualBackgroundObj(x, y) - + if ( - not cursorsInfo['setManualTrackingCursor'] - and not cursorsInfo['setManualBackgroundCursor'] - ): + not cursorsInfo["setManualTrackingCursor"] + and not cursorsInfo["setManualBackgroundCursor"] + ): self.clearGhost() - setMoveLabelCursor = cursorsInfo['setMoveLabelCursor'] - setExpandLabelCursor = cursorsInfo['setExpandLabelCursor'] + setMoveLabelCursor = cursorsInfo["setMoveLabelCursor"] + setExpandLabelCursor = cursorsInfo["setExpandLabelCursor"] if setMoveLabelCursor or setExpandLabelCursor: x, y = event.pos() self.updateHoverLabelCursor(x, y) # Draw eraser circle - if cursorsInfo['setEraserCursor']: + if cursorsInfo["setEraserCursor"]: x, y = event.pos() self.updateEraserCursor(x, y, isHoverImg1=isHoverImg1) self.hideItemsHoverBrush(xy=(x, y)) elif self.eraserButton.isChecked() and not event.isExit(): if self.xyOnCtrlPressedFirstTime is not None: self.updateEraserCursor( - x, y, xyLocked=self.xyOnCtrlPressedFirstTime, - isHoverImg1=isHoverImg1 + x, + y, + xyLocked=self.xyOnCtrlPressedFirstTime, + isHoverImg1=isHoverImg1, ) self.hideItemsHoverBrush(xy=(x, y)) else: eraserCursors = ( - self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX + self.ax1_EraserCircle, + self.ax2_EraserCircle, + self.ax1_EraserX, + self.ax2_EraserX, ) self.setHoverToolSymbolData([], [], eraserCursors) # Draw Brush circle - if cursorsInfo['setBrushCursor']: + if cursorsInfo["setBrushCursor"]: x, y = event.pos() self.updateBrushCursor(x, y, isHoverImg1=isHoverImg1) self.hideItemsHoverBrush(xy=(x, y)) - elif cursorsInfo['setAddPointCursor']: + elif cursorsInfo["setAddPointCursor"]: x, y = event.pos() self.setHoverCircleAddPoint(x, y) else: self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + [], + [], + (self.ax2_BrushCircle, self.ax1_BrushCircle), ) - + # Draw label ROi circular cursor - setLabelRoiCircCursor = cursorsInfo['setLabelRoiCircCursor'] + setLabelRoiCircCursor = cursorsInfo["setLabelRoiCircCursor"] if setLabelRoiCircCursor: x, y = event.pos() else: @@ -197,37 +210,39 @@ def gui_hoverEventImg1(self, event, isHoverImg1=True): self.updateLabelRoiCircularCursor(x, y, setLabelRoiCircCursor) drawMothBudLine = ( - self.assignBudMothButton.isChecked() and self.clickedOnBud + self.assignBudMothButton.isChecked() + and self.clickedOnBud and not event.isExit() ) if drawMothBudLine: self.drawTempMothBudLine(event, posData) - drawMergeObjsLine = ( - self.mergeIDsButton.isChecked() and not event.isExit() - ) + drawMergeObjsLine = self.mergeIDsButton.isChecked() and not event.isExit() if drawMergeObjsLine: self.drawTempMergeObjsLine(event, posData, modifiers) # Temporarily draw spline curve # see https://stackoverflow.com/questions/33962717/interpolating-a-closed-curve-using-scipy drawSpline = ( - self.curvToolButton.isChecked() and self.splineHoverON + self.curvToolButton.isChecked() + and self.splineHoverON and not event.isExit() ) if drawSpline: self.hoverEventDrawSpline(event) - + setMirroredCursor = ( - self.app.overrideCursor() is None and not event.isExit() - and isHoverImg1 and self.showMirroredCursorAction.isChecked() + self.app.overrideCursor() is None + and not event.isExit() + and isHoverImg1 + and self.showMirroredCursorAction.isChecked() ) if setMirroredCursor: x, y = event.pos() self.ax2_cursor.setData([x], [y]) else: self.ax2_cursor.setData([], []) - + return cursorsInfo def gui_hoverEventImg2(self, event): @@ -235,7 +250,7 @@ def gui_hoverEventImg2(self, event): posData = self.data[self.pos_i] except AttributeError: return - + if not event.isExit(): self.xHoverImg, self.yHoverImg = event.pos() else: @@ -255,15 +270,16 @@ def gui_hoverEventImg2(self, event): self.app.restoreOverrideCursor() setBrushCursor = ( - self.brushButton.isChecked() and not event.isExit() + self.brushButton.isChecked() + and not event.isExit() and (noModifier or shift or ctrl) ) setEraserCursor = ( - self.eraserButton.isChecked() and not event.isExit() - and noModifier + self.eraserButton.isChecked() and not event.isExit() and noModifier ) setLabelRoiCircCursor = ( - self.labelRoiButton.isChecked() and not event.isExit() + self.labelRoiButton.isChecked() + and not event.isExit() and (noModifier or shift or ctrl) and self.labelRoiIsCircularRadioButton.isChecked() ) @@ -271,13 +287,11 @@ def gui_hoverEventImg2(self, event): self.app.setOverrideCursor(Qt.CrossCursor) setMoveLabelCursor = ( - self.moveLabelToolButton.isChecked() and not event.isExit() - and noModifier + self.moveLabelToolButton.isChecked() and not event.isExit() and noModifier ) setExpandLabelCursor = ( - self.expandLabelToolButton.isChecked() and not event.isExit() - and noModifier + self.expandLabelToolButton.isChecked() and not event.isExit() and noModifier ) # Cursor is moving on image while Alt key is pressed --> pan cursor @@ -285,10 +299,9 @@ def gui_hoverEventImg2(self, event): setPanImageCursor = alt and not event.isExit() if setPanImageCursor and self.app.overrideCursor() is None: self.app.setOverrideCursor(Qt.SizeAllCursor) - + setKeepObjCursor = ( - self.keepIDsButton.isChecked() and not event.isExit() - and noModifier + self.keepIDsButton.isChecked() and not event.isExit() and noModifier ) if setKeepObjCursor and self.app.overrideCursor() is None: self.app.setOverrideCursor(Qt.PointingHandCursor) @@ -298,18 +311,18 @@ def gui_hoverEventImg2(self, event): x, y = event.pos() xdata, ydata = int(x), int(y) _img = self.currentLab2D - Y, X = _img.shape + Y, X = _img.shape # hoverText = self.hoverValuesFormatted(xdata, ydata) # self.wcLabel.setText(hoverText) else: if self.eraserButton.isChecked() or self.brushButton.isChecked(): self.gui_mouseReleaseEventImg2(event) - self.wcLabel.setText(f'') + self.wcLabel.setText(f"") if setMoveLabelCursor or setExpandLabelCursor: x, y = event.pos() self.updateHoverLabelCursor(x, y) - + if setKeepObjCursor: x, y = event.pos() self.highlightHoverIDsKeptObj(x, y) @@ -320,8 +333,14 @@ def gui_hoverEventImg2(self, event): self.updateEraserCursor(x, y, isHoverImg1=False) else: self.setHoverToolSymbolData( - [], [], (self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX) + [], + [], + ( + self.ax1_EraserCircle, + self.ax2_EraserCircle, + self.ax1_EraserX, + self.ax2_EraserX, + ), ) # Draw Brush circle @@ -330,9 +349,11 @@ def gui_hoverEventImg2(self, event): self.updateBrushCursor(x, y, isHoverImg1=False) else: self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), + [], + [], + (self.ax2_BrushCircle, self.ax1_BrushCircle), ) - + # Draw label ROi circular cursor if setLabelRoiCircCursor: x, y = event.pos() @@ -351,7 +372,8 @@ def gui_hoverEventRightImage(self, event): self.gui_hoverEventImg1(event, isHoverImg1=False) setMirroredCursor = ( - self.app.overrideCursor() is None and not event.isExit() + self.app.overrideCursor() is None + and not event.isExit() and self.showMirroredCursorAction.isChecked() ) if setMirroredCursor: @@ -363,60 +385,53 @@ def gui_setCursor(self, modifiers, event): shift = modifiers == Qt.ShiftModifier ctrl = modifiers == Qt.ControlModifier alt = modifiers == Qt.AltModifier - + # Alt key was released --> restore cursor if self.app.overrideCursor() == Qt.SizeAllCursor and noModifier: self.app.restoreOverrideCursor() setBrushCursor = ( - self.brushButton.isChecked() and not event.isExit() + self.brushButton.isChecked() + and not event.isExit() and (noModifier or shift or ctrl) ) setEraserCursor = ( - self.eraserButton.isChecked() and not event.isExit() - and noModifier + self.eraserButton.isChecked() and not event.isExit() and noModifier ) setAddDelPolyLineCursor = ( - self.addDelPolyLineRoiButton.isChecked() and not event.isExit() + self.addDelPolyLineRoiButton.isChecked() + and not event.isExit() and noModifier ) setLabelRoiCircCursor = ( - self.labelRoiButton.isChecked() and not event.isExit() + self.labelRoiButton.isChecked() + and not event.isExit() and (noModifier or shift or ctrl) and self.labelRoiIsCircularRadioButton.isChecked() ) setWandCursor = ( - self.wandToolButton.isChecked() and not event.isExit() - and noModifier + self.wandToolButton.isChecked() and not event.isExit() and noModifier ) setLabelRoiCursor = ( - self.labelRoiButton.isChecked() and not event.isExit() - and noModifier + self.labelRoiButton.isChecked() and not event.isExit() and noModifier ) setMoveLabelCursor = ( - self.moveLabelToolButton.isChecked() and not event.isExit() - and noModifier + self.moveLabelToolButton.isChecked() and not event.isExit() and noModifier ) setExpandLabelCursor = ( - self.expandLabelToolButton.isChecked() and not event.isExit() - and noModifier + self.expandLabelToolButton.isChecked() and not event.isExit() and noModifier ) setCurvCursor = ( - self.curvToolButton.isChecked() and not event.isExit() - and noModifier + self.curvToolButton.isChecked() and not event.isExit() and noModifier ) setKeepObjCursor = ( - self.keepIDsButton.isChecked() and not event.isExit() - and noModifier + self.keepIDsButton.isChecked() and not event.isExit() and noModifier ) setCustomAnnotCursor = ( - self.customAnnotButton is not None and not event.isExit() - and noModifier + self.customAnnotButton is not None and not event.isExit() and noModifier ) setManualTrackingCursor = ( - self.manualTrackingButton.isChecked() - and not event.isExit() - and noModifier + self.manualTrackingButton.isChecked() and not event.isExit() and noModifier ) setManualBackgroundCursor = ( self.manualBackgroundButton.isChecked() @@ -424,12 +439,9 @@ def gui_setCursor(self, modifiers, event): and noModifier ) setZoomRectCursor = ( - self.zoomRectButton.isChecked() and not event.isExit() - and noModifier - ) - setEditIDCursor = ( - self.editIDbutton.isChecked() and not event.isExit() + self.zoomRectButton.isChecked() and not event.isExit() and noModifier ) + setEditIDCursor = self.editIDbutton.isChecked() and not event.isExit() magicPromptsON = self.magicPromptsToolButton.isChecked() pointsLayerON = self.togglePointsLayerAction.isChecked() addPointsByClickingButton = self.buttonAddPointsByClickingActive() @@ -457,9 +469,9 @@ def gui_setCursor(self, modifiers, event): self.app.setOverrideCursor(self.polyLineRoiCursor) elif setCustomAnnotCursor: x, y = event.pos() - self.highlightHoverID(x, y) + self.highlightHoverID(x, y) elif setKeepObjCursor and overrideCursor is None: - self.app.setOverrideCursor(Qt.PointingHandCursor) + self.app.setOverrideCursor(Qt.PointingHandCursor) elif setManualTrackingCursor and overrideCursor is None: self.app.setOverrideCursor(Qt.PointingHandCursor) elif setManualBackgroundCursor and overrideCursor is None: @@ -473,24 +485,24 @@ def gui_setCursor(self, modifiers, event): self.app.setOverrideCursor(Qt.CrossCursor) else: self.app.restoreOverrideCursor() - + return { - 'setBrushCursor': setBrushCursor, - 'setEraserCursor': setEraserCursor, - 'setAddDelPolyLineCursor': setAddDelPolyLineCursor, - 'setLabelRoiCircCursor': setLabelRoiCircCursor, - 'setWandCursor': setWandCursor, - 'setLabelRoiCursor': setLabelRoiCursor, - 'setMoveLabelCursor': setMoveLabelCursor, - 'setExpandLabelCursor': setExpandLabelCursor, - 'setCurvCursor': setCurvCursor, - 'setKeepObjCursor': setKeepObjCursor, - 'setCustomAnnotCursor': setCustomAnnotCursor, - 'setManualTrackingCursor': setManualTrackingCursor, - 'setManualBackgroundCursor': setManualBackgroundCursor, - 'setAddPointCursor': setAddPointCursor, - 'setZoomRectCursor': setZoomRectCursor, - 'setEditIDCursor': setEditIDCursor + "setBrushCursor": setBrushCursor, + "setEraserCursor": setEraserCursor, + "setAddDelPolyLineCursor": setAddDelPolyLineCursor, + "setLabelRoiCircCursor": setLabelRoiCircCursor, + "setWandCursor": setWandCursor, + "setLabelRoiCursor": setLabelRoiCursor, + "setMoveLabelCursor": setMoveLabelCursor, + "setExpandLabelCursor": setExpandLabelCursor, + "setCurvCursor": setCurvCursor, + "setKeepObjCursor": setKeepObjCursor, + "setCustomAnnotCursor": setCustomAnnotCursor, + "setManualTrackingCursor": setManualTrackingCursor, + "setManualBackgroundCursor": setManualBackgroundCursor, + "setAddPointCursor": setAddPointCursor, + "setZoomRectCursor": setZoomRectCursor, + "setEditIDCursor": setEditIDCursor, } def onCtrlPressedFirstTime(self): @@ -505,12 +517,12 @@ def onCtrlPressedFirstTime(self): if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): self.xyOnCtrlPressedFirstTime = None return - + ID = self.currentLab2D[ydata, xdata] if ID == 0: self.xyOnCtrlPressedFirstTime = None - return - + return + self.xyOnCtrlPressedFirstTime = (xdata, ydata) def onCtrlReleased(self): @@ -537,43 +549,43 @@ def updateHoverLabelCursor(self, x, y): if self.app.overrideCursor() != Qt.SizeAllCursor: self.app.setOverrideCursor(Qt.SizeAllCursor) - + if not self.isMovingLabel: self.highlightSearchedID(ID) - def warnAddingPointWithExistingId(self, point_id, table_endname=''): + def warnAddingPointWithExistingId(self, point_id, table_endname=""): posData = self.data[self.pos_i] if not point_id in posData.IDs_idxs: return True - + msg = widgets.myMessageBox(wrapText=False) - txt = (f""" + txt = f""" Cell ID {point_id} already exists!

    Are you sure you want to add this point? - """) + """ if table_endname: - txt = (f""" + txt = f""" The loaded table {table_endname} has point id {point_id}.

    However, {txt} - """) + """ txt = html_utils.paragraph(txt) _, _, yesButton = msg.warning( - self, f'Cell ID {point_id} already exist', txt, - buttonsTexts=( - 'Cancel', 'No, do not add', f'Yes, add point id {point_id}' - ) + self, + f"Cell ID {point_id} already exist", + txt, + buttonsTexts=("Cancel", "No, do not add", f"Yes, add point id {point_id}"), ) return msg.clickedButton == yesButton def gui_getHoveredSegmentsPolyLineRoi(self): posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] segments = [] - for roi in delROIs_info['rois']: + for roi in delROIs_info["rois"]: if not isinstance(roi, pg.PolyLineROI): - continue - for seg in roi.segments: + continue + for seg in roi.segments: if seg.currentPen == seg.hoverPen: seg.roi = roi segments.append(seg) @@ -581,12 +593,12 @@ def gui_getHoveredSegmentsPolyLineRoi(self): def gui_getHoveredHandlesPolyLineRoi(self): posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] handles = [] - for roi in delROIs_info['rois']: + for roi in delROIs_info["rois"]: if not isinstance(roi, pg.PolyLineROI): - continue - for handle in roi.getHandles(): + continue + for handle in roi.getHandles(): if handle.currentPen == handle.hoverPen: handle.roi = roi handles.append(handle) diff --git a/cellacdc/mixins/canvas_right_image.py b/cellacdc/mixins/canvas_right_image.py index f3e027da4..f9f5710f1 100644 --- a/cellacdc/mixins/canvas_right_image.py +++ b/cellacdc/mixins/canvas_right_image.py @@ -10,6 +10,7 @@ from .canvas_drawing import CanvasDrawing from .canvas_events import CanvasEvents + class CanvasRightImage(CanvasDrawing, CanvasEvents): """Extracted from guiWin.""" @@ -17,13 +18,13 @@ def getMouseDataCoordsRightImage(self): text = self.wcLabel.text() if not text: return - - ax_idx = int(re.findall(r'\(ax(\d)\)', text)[0]) + + ax_idx = int(re.findall(r"\(ax(\d)\)", text)[0]) if ax_idx == 0: return - - coords = re.findall(r'x=(\d+), y=(\d+) \|', text)[0] - + + coords = re.findall(r"x=(\d+), y=(\d+) \|", text)[0] + return tuple([int(val) for val in coords]) def gui_mousePressRightImage(self, event): @@ -32,15 +33,15 @@ def gui_mousePressRightImage(self, event): alt = modifiers == Qt.AltModifier isMod = alt right_click = event.button() == Qt.MouseButton.RightButton and not isMod - is_right_click_action_ON = any([ - b.isChecked() for b in self.checkableQButtonsGroup.buttons() - ]) + is_right_click_action_ON = any( + [b.isChecked() for b in self.checkableQButtonsGroup.buttons()] + ) self.typingEditID = False showLabelsGradMenu = right_click and not is_right_click_action_ON if showLabelsGradMenu: self.gui_rightImageShowContextMenu(event) event.ignore() - else: + else: self.gui_mousePressEventImg1(event) def gui_mouseDragRightImage(self, event): diff --git a/cellacdc/mixins/canvas_selection.py b/cellacdc/mixins/canvas_selection.py index 82a60aea8..63642c269 100644 --- a/cellacdc/mixins/canvas_selection.py +++ b/cellacdc/mixins/canvas_selection.py @@ -17,6 +17,7 @@ from .canvas_tool import CanvasTool from .brush_tools import BrushTools + class CanvasSelection(CanvasTool, BrushTools): """Extracted from guiWin.""" @@ -38,10 +39,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.typingEditID = False # Drag image if neither brush or eraser are On pressed - dragImg = ( - left_click and not eraserON and not - brushON and not middle_click - ) + dragImg = left_click and not eraserON and not brushON and not middle_click if isPanImageClick: dragImg = True @@ -51,7 +49,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): event.ignore() return - if mode == 'Viewer' and middle_click: + if mode == "Viewer" and middle_click: self.startBlinkingModeCB() event.ignore() return @@ -71,24 +69,22 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): # show gradient widget menu if none of the right-click actions are ON # and event is not coming from image 1 - is_right_click_action_ON = any([ - b.isChecked() for b in self.checkableQButtonsGroup.buttons() - ]) - is_right_click_custom_ON = any([ - b.isChecked() for b in self.customAnnotDict.keys() - ]) + is_right_click_action_ON = any( + [b.isChecked() for b in self.checkableQButtonsGroup.buttons()] + ) + is_right_click_custom_ON = any( + [b.isChecked() for b in self.customAnnotDict.keys()] + ) is_event_from_img1 = False - if hasattr(event, 'isImg1Sender'): + if hasattr(event, "isImg1Sender"): is_event_from_img1 = event.isImg1Sender - + is_only_right_click = ( right_click and not is_right_click_action_ON and not middle_click ) - - showLabelsGradMenu = ( - is_only_right_click and not is_event_from_img1 - ) - + + showLabelsGradMenu = is_only_right_click and not is_event_from_img1 + if showLabelsGradMenu: self.labelsGrad.showMenu(event) event.ignore() @@ -96,20 +92,21 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): editInViewerMode = ( (is_right_click_action_ON or is_right_click_custom_ON) - and (right_click or middle_click) and mode=='Viewer' + and (right_click or middle_click) + and mode == "Viewer" ) if editInViewerMode: self.startBlinkingModeCB() event.ignore() return - + # Left-click is used for brush, eraser, separate bud, curvature tool # and magic labeller # Brush and eraser are mutually exclusive but we want to keep the eraser # or brush ON and disable them temporarily to allow left-click with # separate ON - canDelete = mode == 'Segmentation and Tracking' or self.isSnapshot + canDelete = mode == "Segmentation and Tracking" or self.isSnapshot # Delete ID (set to 0) if middle_click and canDelete: @@ -118,19 +115,17 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) delID = self.get_2Dlab(posData.lab)[ydata, xdata] if delID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) delID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.
    ' - 'Enter here ID(s) that you want to delete

    ' - 'You can enter multiple IDs separated by comma', - parent=self, + title="Clicked on background", + msg="You clicked on the background.
    " + "Enter here ID(s) that you want to delete

    " + "You can enter multiple IDs separated by comma", + parent=self, allowedValues=posData.IDs, defaultTxt=str(nearest_ID), allowList=True, - isInteger=True + isInteger=True, ) delID_prompt.exec_() if delID_prompt.cancel: @@ -140,24 +135,28 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): delIDs = [delID] # Ask to propagate change to all future visited frames - key = 'Delete ID' + key = "Delete ID" askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - delIDs, key, doNotShow, - posData.UndoFutFrames_DelID, posData.applyFutFrames_DelID + (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( + self.propagateChange( + delIDs, + key, + doNotShow, + posData.UndoFutFrames_DelID, + posData.applyFutFrames_DelID, + ) ) - + if UndoFutFrames is None: return # Store undo state before modifying stuff - self.storeUndoRedoStates(UndoFutFrames) + self.storeUndoRedoStates(UndoFutFrames) posData.doNotShowAgain_DelID = doNotShowAgain posData.UndoFutFrames_DelID = UndoFutFrames posData.applyFutFrames_DelID = applyFutFrames - includeUnvisited = posData.includeUnvisitedInfo['Delete ID'] + includeUnvisited = posData.includeUnvisitedInfo["Delete ID"] delID_mask = self.deleteIDmiddleClick( delIDs, applyFutFrames, includeUnvisited, shift=shift_regardless @@ -166,41 +165,41 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): delID_mask = delID_mask[self.z_lab()] if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete ID') + self.fixCcaDfAfterEdit("Delete ID") else: - self.warnEditingWithCca_df('Delete ID', update_images=False) - + self.warnEditingWithCca_df("Delete ID", update_images=False) + self.setImageImg2() delROIsIDs = self.setAllTextAnnotations() self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False) how = self.drawIDsContComboBox.currentText() - if how.find('overlay segm. masks') != -1: + if how.find("overlay segm. masks") != -1: self.labelsLayerImg1.image[delID_mask] = 0 self.labelsLayerImg1.setImage(self.labelsLayerImg1.image) - + how_ax2 = self.getAnnotateHowRightImage() - if how_ax2.find('overlay segm. masks') != -1: + if how_ax2.find("overlay segm. masks") != -1: self.labelsLayerRightImg.image[delID_mask] = 0 self.labelsLayerRightImg.setImage(self.labelsLayerRightImg.image) - + self.highlightLostNew() - + # Separate bud or objects with same ID elif right_click and separateON: x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) sepID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here ID that you want to split', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter here ID that you want to split", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) sepID_prompt.exec_() if sepID_prompt.cancel: @@ -217,8 +216,11 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if self.isSegm3D and not shift: z = self.zSliceScrollBar.sliderPosition() posData.lab, splittedIDs = measure.separate_with_label( - posData.lab, posData.rp, [ID], max_ID, - click_coords_list=[(z, ydata, xdata)] + posData.lab, + posData.rp, + [ID], + max_ID, + click_coords_list=[(z, ydata, xdata)], ) success = True # self.set_2Dlab(lab2D) @@ -230,19 +232,21 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.set_2Dlab(lab2D) else: success = False - + # If automatic bud separation was not successfull call manual one if not success: posData.disableAutoActivateViewerWindow = True img = self.getDisplayedImg1() - col = 'manual_separate_draw_mode' - drawMode = self.df_settings.at[col, 'value'] + col = "manual_separate_draw_mode" + drawMode = self.df_settings.at[col, "value"] manualSep = apps.manualSeparateGui( - self.get_2Dlab(posData.lab), ID, img, + self.get_2Dlab(posData.lab), + ID, + img, fontSize=self.fontSize, IDcolor=self.lut[ID], parent=self, - drawMode=drawMode + drawMode=drawMode, ) manualSep.setState(self.lastManualSeparateState) manualSep.show() @@ -255,7 +259,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): return self.lastManualSeparateState = manualSep.state() lab2D = self.get_2Dlab(posData.lab) - lab2D[manualSep.lab!=0] = manualSep.lab[manualSep.lab!=0] + lab2D[manualSep.lab != 0] = manualSep.lab[manualSep.lab != 0] self.set_2Dlab(lab2D) splittedIDs = [obj.label for obj in manualSep.rp] posData.disableAutoActivateViewerWindow = False @@ -268,10 +272,10 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.trackSubsetIDs(splittedIDs) if self.isSnapshot: - self.fixCcaDfAfterEdit('Separate IDs') + self.fixCcaDfAfterEdit("Separate IDs") self.updateAllImages() else: - self.warnEditingWithCca_df('Separate IDs') + self.warnEditingWithCca_df("Separate IDs") self.store_data() @@ -284,17 +288,16 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) clickedBkgrID = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here the ID that you want to ' - 'fill the holes of', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter here the ID that you want to " + "fill the holes of", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) clickedBkgrID.exec_() if clickedBkgrID.cancel: @@ -316,24 +319,23 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if not self.fillHolesToolButton.findChild(QAction).isChecked(): self.fillHolesToolButton.setChecked(False) - + # Hull contour elif right_click and self.hullContToolButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) mergeID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here the ID that you want to ' - 'replace with Hull contour', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter here the ID that you want to " + "replace with Hull contour", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) mergeID_prompt.exec_() if mergeID_prompt.cancel: @@ -370,17 +372,16 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) clickedBkgrID = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here the ID that you want to ' - 'fill the holes of', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter here the ID that you want to " + "fill the holes of", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) clickedBkgrID.exec_() if clickedBkgrID.cancel: @@ -394,16 +395,15 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) mergeID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here first ID that you want to merge', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter here first ID that you want to merge", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) mergeID_prompt.exec_() if mergeID_prompt.cancel: @@ -415,7 +415,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): # Store undo state before modifying stuff self.storeUndoRedoStates(False) self.firstID = ID - + obj_idx = posData.IDs_idxs[ID] obj = posData.rp[obj_idx] yc, xc = self.getObjCentroid(obj.centroid) @@ -427,16 +427,15 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) editID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter here ID that you want to replace with a new one', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter here ID that you want to replace with a new one", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) editID_prompt.show(block=True) @@ -444,7 +443,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): return else: ID = editID_prompt.EntryID - + obj_idx = posData.IDs_idxs[ID] y, x = posData.rp[obj_idx].centroid[-2:] xdata, ydata = int(x), int(y) @@ -458,13 +457,14 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): and posData.frame_i < posData.SizeT - 1 ) editID = apps.EditIDDialog( - ID, posData.IDs, + ID, + posData.IDs, doNotShowAgain=self.doNotAskAgainExistingID, - parent=self, - entryID=self.getNearestLostObjID(y, x), - nextUniqueID=self.setBrushID(return_val=True), + parent=self, + entryID=self.getNearestLostObjID(y, x), + nextUniqueID=self.setBrushID(return_val=True), allIDs=posData.allIDs, - addPropagateCheckbox=addPropagateCheckbox + addPropagateCheckbox=addPropagateCheckbox, ) editID.show(block=True) if editID.cancel: @@ -476,46 +476,49 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if editID.assignNewID: self.assignNewIDfromClickedID(ID, event) return - - if not self.doNotAskAgainExistingID: + + if not self.doNotAskAgainExistingID: self.editIDmergeIDs = editID.mergeWithExistingID self.doNotAskAgainExistingID = editID.doNotAskAgainExistingID - + self.applyEditID( - ID, currentIDs, editID.how, x, y, + ID, + currentIDs, + editID.how, + x, + y, shift=shift, - doPropagateUnvisited=editID.doPropagateFutureFrames + doPropagateUnvisited=editID.doPropagateFutureFrames, ) - + elif (right_click or left_click) and self.keepIDsButton.isChecked(): x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) keepID_win = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to keep', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to keep", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) keepID_win.exec_() if keepID_win.cancel: return else: ID = keepID_win.EntryID - + if ID in self.keptObjectsIDs: self.keptObjectsIDs.remove(ID) self.clearHighlightedText() else: self.keptObjectsIDs.append(ID) self.highlightLabelID(ID) - + self.updateTempLayerKeepIDs() # Annotate cell as removed from the analysis @@ -524,16 +527,15 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) binID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to remove from the analysis', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to remove from the analysis", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) binID_prompt.exec_() if binID_prompt.cancel: @@ -542,14 +544,17 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): ID = binID_prompt.EntryID # Ask to propagate change to all future visited frames - key = 'Exclude cell from analysis' + key = "Exclude cell from analysis" askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - ID, key, doNotShow, - posData.UndoFutFrames_BinID, - posData.applyFutFrames_BinID + (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( + self.propagateChange( + ID, + key, + doNotShow, + posData.UndoFutFrames_BinID, + posData.applyFutFrames_BinID, + ) ) if UndoFutFrames is None: @@ -566,7 +571,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if applyFutFrames: # Store current data before going to future frames self.store_data() - for i in range(posData.frame_i+1, endFrame_i+1): + for i in range(posData.frame_i + 1, endFrame_i + 1): posData.frame_i = i self.get_data() if ID in posData.binnedIDs: @@ -574,7 +579,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): else: posData.binnedIDs.add(ID) self.update_rp_metadata(draw=False) - self.store_data(autosave=i==endFrame_i) + self.store_data(autosave=i == endFrame_i) self.app.restoreOverrideCursor() @@ -605,16 +610,15 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - self.get_2Dlab(posData.lab), y, x - ) + nearest_ID = core.nearest_nonzero_2D(self.get_2Dlab(posData.lab), y, x) ripID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to annotate as dead', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to annotate as dead", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) ripID_prompt.exec_() if ripID_prompt.cancel: @@ -623,14 +627,17 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): ID = ripID_prompt.EntryID # Ask to propagate change to all future visited frames - key = 'Annotate cell as dead' + key = "Annotate cell as dead" askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - ID, key, doNotShow, - posData.UndoFutFrames_RipID, - posData.applyFutFrames_RipID + (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( + self.propagateChange( + ID, + key, + doNotShow, + posData.UndoFutFrames_RipID, + posData.applyFutFrames_RipID, + ) ) if UndoFutFrames is None: @@ -646,7 +653,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): if applyFutFrames: # Store current data before going to future frames self.store_data() - for i in range(posData.frame_i+1, endFrame_i+1): + for i in range(posData.frame_i + 1, endFrame_i + 1): posData.frame_i = i self.get_data() if ID in posData.ripIDs: @@ -654,7 +661,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): else: posData.ripIDs.add(ID) self.update_rp_metadata(draw=False) - self.store_data(autosave=i==endFrame_i) + self.store_data(autosave=i == endFrame_i) self.app.restoreOverrideCursor() # Back to current frame @@ -677,10 +684,10 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): self.store_data() if self.isSnapshot: - self.fixCcaDfAfterEdit('Annotate ID as dead') + self.fixCcaDfAfterEdit("Annotate ID as dead") self.updateAllImages() else: - self.warnEditingWithCca_df('Annotate ID as dead') + self.warnEditingWithCca_df("Annotate ID as dead") if not self.ripCellButton.findChild(QAction).isChecked(): self.ripCellButton.setChecked(False) @@ -688,7 +695,7 @@ def gui_mousePressEventImg2(self, event: QGraphicsSceneMouseEvent): def gui_mouseReleaseEventImg2(self, event): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': + if mode == "Viewer": return Y, X = self.get_2Dlab(posData.lab).shape @@ -696,7 +703,7 @@ def gui_mouseReleaseEventImg2(self, event): x, y = event.pos().x(), event.pos().y() except Exception as e: return - + xdata, ydata = int(x), int(y) if not myutils.is_in_bounds(xdata, ydata, X, Y): self.isMouseDragImg2 = False @@ -725,17 +732,16 @@ def gui_mouseReleaseEventImg2(self, event): lab2D = self.get_2Dlab(posData.lab) ID = lab2D[ydata, xdata] if ID == 0: - nearest_ID = core.nearest_nonzero_2D( - lab2D, y, x - ) + nearest_ID = core.nearest_nonzero_2D(lab2D, y, x) mergeID_prompt = apps.QLineEditDialog( - title='Clicked on background', - msg='You clicked on the background.\n' - 'Enter ID that you want to merge with ID ' - f'{self.firstID}', - parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg="You clicked on the background.\n" + "Enter ID that you want to merge with ID " + f"{self.firstID}", + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) mergeID_prompt.exec_() if mergeID_prompt.cancel: @@ -746,14 +752,14 @@ def gui_mouseReleaseEventImg2(self, event): obj = posData.rp[obj_idx] y2, x2 = self.getObjCentroid(obj.centroid) self.mergeObjsTempLine.addPoint(x2, y2) - + xx, yy = self.mergeObjsTempLine.getData() IDs_to_merge = lab2D[yy.astype(int), xx.astype(int)] for ID in IDs_to_merge: if ID == 0: continue - posData.lab[posData.lab==ID] = self.firstID - + posData.lab[posData.lab == ID] = self.firstID + self.mergeObjsTempLine.setData([], []) self.clickObjYc, self.clickObjXc = None, None @@ -766,29 +772,30 @@ def gui_mouseReleaseEventImg2(self, event): ask_back_prop = False prev_IDs = [] else: - prev_IDs = posData.allData_li[posData.frame_i-1]['IDs'] + prev_IDs = posData.allData_li[posData.frame_i - 1]["IDs"] - if all(ID not in prev_IDs for ID in IDs_to_merge): + if all(ID not in prev_IDs for ID in IDs_to_merge): ask_back_prop = False - + if not self.isFrameCcaAnnotated() and ask_back_prop: - proceed = self.askPropagateChangePast(f'Merge IDs {IDs_to_merge}') + proceed = self.askPropagateChangePast(f"Merge IDs {IDs_to_merge}") if proceed: self.propagateMergeObjsPast(IDs_to_merge) - self.whitelistPropagateIDs(only_future_frames=False, update_lab=True) # in the update_rp() call, this should also be done + self.whitelistPropagateIDs( + only_future_frames=False, update_lab=True + ) # in the update_rp() call, this should also be done # Repeat tracking self.tracking( - enforce=True, assign_unique_new_IDs=False, - separateByLabel=False + enforce=True, assign_unique_new_IDs=False, separateByLabel=False ) if self.isSnapshot: - self.fixCcaDfAfterEdit('Merge IDs') + self.fixCcaDfAfterEdit("Merge IDs") self.updateAllImages() else: - self.warnEditingWithCca_df('Merge IDs') - + self.warnEditingWithCca_df("Merge IDs") + if not self.mergeIDsButton.findChild(QAction).isChecked(): self.mergeIDsButton.setChecked(False) self.store_data() diff --git a/cellacdc/mixins/canvas_tool.py b/cellacdc/mixins/canvas_tool.py index 4f147a836..2a48c7f6e 100644 --- a/cellacdc/mixins/canvas_tool.py +++ b/cellacdc/mixins/canvas_tool.py @@ -7,5 +7,5 @@ class CanvasTool: """Extracted from guiWin.""" def storeManualSeparateDrawMode(self, mode): - self.df_settings.at['manual_separate_draw_mode', 'value'] = mode + self.df_settings.at["manual_separate_draw_mode", "value"] = mode self.df_settings.to_csv(self.settings_csv_path) diff --git a/cellacdc/mixins/cell_cycle.py b/cellacdc/mixins/cell_cycle.py index afb0d8f12..dee2bbf63 100644 --- a/cellacdc/mixins/cell_cycle.py +++ b/cellacdc/mixins/cell_cycle.py @@ -22,15 +22,14 @@ from .undo_redo import UndoRedo + class CellCycle(UndoRedo): """Extracted from guiWin.""" - def _getCcaCostMatrix( - self, numCellsG1, numNewCells, IDsCellsG1, newIDs_contours - ): + def _getCcaCostMatrix(self, numCellsG1, numNewCells, IDsCellsG1, newIDs_contours): posData = self.data[self.pos_i] dataDict = posData.allData_li[posData.frame_i] - dist_matrix_df = dataDict.get('obj_to_obj_dist_cost_matrix_df') + dist_matrix_df = dataDict.get("obj_to_obj_dist_cost_matrix_df") if dist_matrix_df is None: cost = np.full((numCellsG1, numNewCells), np.inf) for obj in posData.rp: @@ -42,18 +41,16 @@ def _getCcaCostMatrix( cont = self.getObjContours(obj) i = IDsCellsG1.index(ID) - + # Get distance from cell in G1 and all other new cells for j, newID_cont in enumerate(newIDs_contours): - min_dist, nearest_xy = self.nearest_point_2Dyx( - cont, newID_cont - ) + min_dist, nearest_xy = self.nearest_point_2Dyx(cont, newID_cont) cost[i, j] = min_dist - + return cost cost = dist_matrix_df.loc[IDsCellsG1, posData.new_IDs].values - + return cost def addIDBaseCca_df(self, posData, ID): @@ -67,10 +64,7 @@ def addIDBaseCca_df(self, posData, ID): self.cca_df_default_values, ) if posData.cca_df.empty: - posData.cca_df = pd.DataFrame( - {col: val for col, val in _zip}, - index=[ID] - ) + posData.cca_df = pd.DataFrame({col: val for col, val in _zip}, index=[ID]) else: for col, val in _zip: posData.cca_df.at[ID, col] = val @@ -81,7 +75,7 @@ def addMissingIDs_cca_df(self, posData): if posData.cca_df is None: posData.cca_df = base_cca_df return - + posData.cca_df = posData.cca_df.combine_first(base_cca_df) def annotateBudToDifferentMother(self): @@ -97,8 +91,8 @@ def annotateBudToDifferentMother(self): - User released mouse button on a cell in G1 (checked at release time) - The new mother MUST be in G1 for all the frames of the bud life --> if not warn - - The new mother MUST have appeared in current frame OR be already - in G1 in previous frame, otherwise there would be no G1 cycle + - The new mother MUST have appeared in current frame OR be already + in G1 in previous frame, otherwise there would be no G1 cycle Result: - The bud only changes relative ID to the new mother @@ -119,66 +113,57 @@ def annotateBudToDifferentMother(self): if not eligible: return - budEligible = self.checkChangeMotherBudEligible( - budID, posData.frame_i - ) + budEligible = self.checkChangeMotherBudEligible(budID, posData.frame_i) if not budEligible: - return - - # Allow partial initialization of cca_df with mouse - if posData.frame_i == 0: - newMothCcs = posData.cca_df.at[new_mothID, 'cell_cycle_stage'] - if not newMothCcs == 'G1': - err_msg = ( - 'You are assigning the bud to a cell that is not in G1!' - ) + return + + # Allow partial initialization of cca_df with mouse + if posData.frame_i == 0: + newMothCcs = posData.cca_df.at[new_mothID, "cell_cycle_stage"] + if not newMothCcs == "G1": + err_msg = "You are assigning the bud to a cell that is not in G1!" msg = QMessageBox() - msg.critical( - self, 'New mother not in G1!', err_msg, msg.Ok - ) + msg.critical(self, "New mother not in G1!", err_msg, msg.Ok) return # Store cca_df for undo action undoId = uuid.uuid4() self.storeUndoRedoCca(0, posData.cca_df, undoId) - currentRelID = posData.cca_df.at[budID, 'relative_ID'] + currentRelID = posData.cca_df.at[budID, "relative_ID"] if currentRelID in posData.cca_df.index: - posData.cca_df.at[currentRelID, 'relative_ID'] = -1 - posData.cca_df.at[currentRelID, 'generation_num'] = 2 - posData.cca_df.at[currentRelID, 'cell_cycle_stage'] = 'G1' - posData.cca_df.at[budID, 'relationship'] = 'bud' - posData.cca_df.at[budID, 'generation_num'] = 0 - posData.cca_df.at[budID, 'relative_ID'] = new_mothID - posData.cca_df.at[budID, 'cell_cycle_stage'] = 'S' - posData.cca_df.at[new_mothID, 'relative_ID'] = budID - posData.cca_df.at[new_mothID, 'generation_num'] = 2 - posData.cca_df.at[new_mothID, 'cell_cycle_stage'] = 'S' + posData.cca_df.at[currentRelID, "relative_ID"] = -1 + posData.cca_df.at[currentRelID, "generation_num"] = 2 + posData.cca_df.at[currentRelID, "cell_cycle_stage"] = "G1" + posData.cca_df.at[budID, "relationship"] = "bud" + posData.cca_df.at[budID, "generation_num"] = 0 + posData.cca_df.at[budID, "relative_ID"] = new_mothID + posData.cca_df.at[budID, "cell_cycle_stage"] = "S" + posData.cca_df.at[new_mothID, "relative_ID"] = budID + posData.cca_df.at[new_mothID, "generation_num"] = 2 + posData.cca_df.at[new_mothID, "cell_cycle_stage"] = "S" self.updateAllImages() self.store_cca_df() return - curr_mothID = posData.cca_df.at[budID, 'relative_ID'] + curr_mothID = posData.cca_df.at[budID, "relative_ID"] if curr_mothID in posData.cca_df.index: - curr_moth_cca = self.getStatus_RelID_BeforeEmergence( - budID, curr_mothID - ) + curr_moth_cca = self.getStatus_RelID_BeforeEmergence(budID, curr_mothID) # Store cca_df for undo action undoId = uuid.uuid4() self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - + # Correct current frames and update LabelItems - posData.cca_df.at[budID, 'relative_ID'] = new_mothID - posData.cca_df.at[budID, 'generation_num'] = 0 - posData.cca_df.at[budID, 'relative_ID'] = new_mothID - posData.cca_df.at[budID, 'relationship'] = 'bud' - posData.cca_df.at[budID, 'corrected_on_frame_i'] = posData.frame_i - posData.cca_df.at[budID, 'cell_cycle_stage'] = 'S' - - posData.cca_df.at[new_mothID, 'relative_ID'] = budID - posData.cca_df.at[new_mothID, 'cell_cycle_stage'] = 'S' - posData.cca_df.at[new_mothID, 'relationship'] = 'mother' - - + posData.cca_df.at[budID, "relative_ID"] = new_mothID + posData.cca_df.at[budID, "generation_num"] = 0 + posData.cca_df.at[budID, "relative_ID"] = new_mothID + posData.cca_df.at[budID, "relationship"] = "bud" + posData.cca_df.at[budID, "corrected_on_frame_i"] = posData.frame_i + posData.cca_df.at[budID, "cell_cycle_stage"] = "S" + + posData.cca_df.at[new_mothID, "relative_ID"] = budID + posData.cca_df.at[new_mothID, "cell_cycle_stage"] = "S" + posData.cca_df.at[new_mothID, "relationship"] = "mother" + if curr_mothID in posData.cca_df.index: # Cells with UNKNOWN history has relative's ID = -1 # which is not an existing cell @@ -199,7 +184,7 @@ def annotateBudToDifferentMother(self): self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) # Correct future frames - for i in range(posData.frame_i+1, posData.SizeT): + for i in range(posData.frame_i + 1, posData.SizeT): # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=i, return_df=True) if cca_df_i is None: @@ -212,26 +197,26 @@ def annotateBudToDifferentMother(self): continue self.storeUndoRedoCca(i, cca_df_i, undoId) - bud_relationship = cca_df_i.at[budID, 'relationship'] - bud_ccs = cca_df_i.at[budID, 'cell_cycle_stage'] + bud_relationship = cca_df_i.at[budID, "relationship"] + bud_ccs = cca_df_i.at[budID, "cell_cycle_stage"] - if bud_relationship == 'mother' and bud_ccs == 'S': + if bud_relationship == "mother" and bud_ccs == "S": # The bud at the ith frame budded itself --> stop break - cca_df_i.at[budID, 'relative_ID'] = new_mothID - cca_df_i.at[budID, 'generation_num'] = 0 - cca_df_i.at[budID, 'relative_ID'] = new_mothID - cca_df_i.at[budID, 'relationship'] = 'bud' - cca_df_i.at[budID, 'cell_cycle_stage'] = 'S' + cca_df_i.at[budID, "relative_ID"] = new_mothID + cca_df_i.at[budID, "generation_num"] = 0 + cca_df_i.at[budID, "relative_ID"] = new_mothID + cca_df_i.at[budID, "relationship"] = "bud" + cca_df_i.at[budID, "cell_cycle_stage"] = "S" - newMoth_bud_ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] - if newMoth_bud_ccs == 'G1': + newMoth_bud_ccs = cca_df_i.at[new_mothID, "cell_cycle_stage"] + if newMoth_bud_ccs == "G1": # Assign bud to new mother only if the new mother is in G1 # This can happen if the bud already has a G1 annotated - cca_df_i.at[new_mothID, 'relative_ID'] = budID - cca_df_i.at[new_mothID, 'cell_cycle_stage'] = 'S' - cca_df_i.at[new_mothID, 'relationship'] = 'mother' + cca_df_i.at[new_mothID, "relative_ID"] = budID + cca_df_i.at[new_mothID, "cell_cycle_stage"] = "S" + cca_df_i.at[new_mothID, "relationship"] = "mother" if curr_mothID in cca_df_i.index: # Cells with UNKNOWN history has relative's ID = -1 @@ -241,7 +226,7 @@ def annotateBudToDifferentMother(self): self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) # Correct past frames - for i in range(posData.frame_i-1, -1, -1): + for i in range(posData.frame_i - 1, -1, -1): # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=i, return_df=True) @@ -251,15 +236,15 @@ def annotateBudToDifferentMother(self): break self.storeUndoRedoCca(i, cca_df_i, undoId) - cca_df_i.at[budID, 'relative_ID'] = new_mothID - cca_df_i.at[budID, 'generation_num'] = 0 - cca_df_i.at[budID, 'relative_ID'] = new_mothID - cca_df_i.at[budID, 'relationship'] = 'bud' - cca_df_i.at[budID, 'cell_cycle_stage'] = 'S' + cca_df_i.at[budID, "relative_ID"] = new_mothID + cca_df_i.at[budID, "generation_num"] = 0 + cca_df_i.at[budID, "relative_ID"] = new_mothID + cca_df_i.at[budID, "relationship"] = "bud" + cca_df_i.at[budID, "cell_cycle_stage"] = "S" - cca_df_i.at[new_mothID, 'relative_ID'] = budID - cca_df_i.at[new_mothID, 'cell_cycle_stage'] = 'S' - cca_df_i.at[new_mothID, 'relationship'] = 'mother' + cca_df_i.at[new_mothID, "relative_ID"] = budID + cca_df_i.at[new_mothID, "cell_cycle_stage"] = "S" + cca_df_i.at[new_mothID, "relationship"] = "mother" if curr_mothID in cca_df_i.index: # Cells with UNKNOWN history has relative's ID = -1 @@ -267,7 +252,7 @@ def annotateBudToDifferentMother(self): cca_df_i.loc[curr_mothID] = curr_moth_cca self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - + self.enqAutosave() def annotateDivision(self, cca_df, ID, relID, frame_i=None): @@ -281,30 +266,30 @@ def annotateDivision(self, cca_df, ID, relID, frame_i=None): self.annotateWillDivide(ID, relID) store = False - cca_df.at[ID, 'cell_cycle_stage'] = 'G1' - cca_df.at[relID, 'cell_cycle_stage'] = 'G1' - + cca_df.at[ID, "cell_cycle_stage"] = "G1" + cca_df.at[relID, "cell_cycle_stage"] = "G1" + if frame_i > 0: - gen_num_clickedID = cca_df.at[ID, 'generation_num'] - cca_df.at[ID, 'generation_num'] += 1 - cca_df.at[ID, 'division_frame_i'] = frame_i - gen_num_relID = cca_df.at[relID, 'generation_num'] - cca_df.at[relID, 'generation_num'] = gen_num_relID+1 - cca_df.at[relID, 'division_frame_i'] = frame_i + gen_num_clickedID = cca_df.at[ID, "generation_num"] + cca_df.at[ID, "generation_num"] += 1 + cca_df.at[ID, "division_frame_i"] = frame_i + gen_num_relID = cca_df.at[relID, "generation_num"] + cca_df.at[relID, "generation_num"] = gen_num_relID + 1 + cca_df.at[relID, "division_frame_i"] = frame_i if gen_num_clickedID < gen_num_relID: - cca_df.at[ID, 'relationship'] = 'mother' + cca_df.at[ID, "relationship"] = "mother" else: - cca_df.at[relID, 'relationship'] = 'mother' + cca_df.at[relID, "relationship"] = "mother" else: - cca_df.at[ID, 'generation_num'] = 2 - cca_df.at[relID, 'generation_num'] = 2 + cca_df.at[ID, "generation_num"] = 2 + cca_df.at[relID, "generation_num"] = 2 - cca_df.at[ID, 'division_frame_i'] = -1 - cca_df.at[relID, 'division_frame_i'] = -1 + cca_df.at[ID, "division_frame_i"] = -1 + cca_df.at[relID, "division_frame_i"] = -1 + + cca_df.at[ID, "relationship"] = "mother" + cca_df.at[relID, "relationship"] = "mother" - cca_df.at[ID, 'relationship'] = 'mother' - cca_df.at[relID, 'relationship'] = 'mother' - store = True return store @@ -320,8 +305,8 @@ def annotateIsHistoryKnown(self, ID): with unknown history with the function "updateIsHistoryKnown()" """ posData = self.data[self.pos_i] - is_history_known = posData.cca_df.at[ID, 'is_history_known'] - relID = posData.cca_df.at[ID, 'relative_ID'] + is_history_known = posData.cca_df.at[ID, "is_history_known"] + relID = posData.cca_df.at[ID, "relative_ID"] if relID in posData.cca_df.index: relID_cca = self.getStatus_RelID_BeforeEmergence(ID, relID) @@ -354,7 +339,7 @@ def annotateIsHistoryKnown(self, ID): if relID in posData.IDs: relObj_idx = posData.IDs.index(relID) rp_relID = posData.rp[relObj_idx] - + self.setAllTextAnnotations() self.drawAllMothBudLines() @@ -365,7 +350,7 @@ def annotateIsHistoryKnown(self, ID): self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) # Correct future frames - for i in range(posData.frame_i+1, posData.SizeT): + for i in range(posData.frame_i + 1, posData.SizeT): cca_df_i = self.get_cca_df(frame_i=i, return_df=True) if cca_df_i is None: # ith frame was not visited yet @@ -382,9 +367,8 @@ def annotateIsHistoryKnown(self, ID): cca_df_i.loc[relID] = relID_cca self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - # Correct past frames - for i in range(posData.frame_i-1, -1, -1): + for i in range(posData.frame_i - 1, -1, -1): cca_df_i = self.get_cca_df(frame_i=i, return_df=True) if cca_df_i is None: # ith frame was not visited yet @@ -396,12 +380,12 @@ def annotateIsHistoryKnown(self, ID): # we reached frame where ID was not existing yet break else: - relID = cca_df_i.at[ID, 'relative_ID'] + relID = cca_df_i.at[ID, "relative_ID"] self.setHistoryKnowledge(ID, cca_df_i) if relID in IDs: cca_df_i.loc[relID] = relID_cca self.store_cca_df(frame_i=i, cca_df=cca_df_i, autosave=False) - + self.enqAutosave() def annotateWillDivide(self, ID, relID, frame_i=None): @@ -410,29 +394,27 @@ def annotateWillDivide(self, ID, relID, frame_i=None): frame_i = posData.frame_i # Store in the past frames that division has been annotated - for past_frame_i in range(frame_i-1, -1, -1): + for past_frame_i in range(frame_i - 1, -1, -1): past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True) if past_cca_df is None: return - + if ID not in past_cca_df.index: # ID is a bud and is not emerged yet here return - - if frame_i-1 == past_frame_i: + + if frame_i - 1 == past_frame_i: # Get generation number at first iteration - gen_num = past_cca_df.at[ID, 'generation_num'] - - if past_cca_df.at[ID, 'generation_num'] != gen_num: + gen_num = past_cca_df.at[ID, "generation_num"] + + if past_cca_df.at[ID, "generation_num"] != gen_num: # ID is a mother and the cell cycle is finished here return - - past_cca_df.at[ID, 'will_divide'] = 1 - past_cca_df.at[relID, 'will_divide'] = 1 - self.store_cca_df( - cca_df=past_cca_df, frame_i=past_frame_i, autosave=False - ) + past_cca_df.at[ID, "will_divide"] = 1 + past_cca_df.at[relID, "will_divide"] = 1 + + self.store_cca_df(cca_df=past_cca_df, frame_i=past_frame_i, autosave=False) def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i): self.store_data(autosave=False) @@ -443,9 +425,9 @@ def applyManualCcaChangesFutureFrames(self, changes, stop_frame_i): if cca_df_i is None: # ith frame was not visited yet break - + self.storeUndoRedoCca(i, cca_df_i, undoId) - + for ID, changes_ID in changes.items(): if ID not in cca_df_i.index: continue @@ -459,30 +441,28 @@ def attempt_auto_cca(self, enforceAll=False): mode = str(self.modeComboBox.currentText()) posData = self.data[self.pos_i] - if mode == 'Cell cycle analysis': - notEnoughG1Cells, proceed = self.autoCca_df( - enforceAll=enforceAll - ) + if mode == "Cell cycle analysis": + notEnoughG1Cells, proceed = self.autoCca_df(enforceAll=enforceAll) if not proceed: return notEnoughG1Cells, proceed - + # mode = str(self.modeComboBox.currentText()) - if posData.cca_df is None: # ??? + if posData.cca_df is None: # ??? notEnoughG1Cells = False proceed = True return notEnoughG1Cells, proceed if posData.cca_df.isna().any(axis=None): - raise ValueError('Cell cycle analysis table contains NaNs') + raise ValueError("Cell cycle analysis table contains NaNs") # self.checkMultiBudMoth() proceed = self.checkMothersExcludedOrDead() return notEnoughG1Cells, proceed - elif mode == 'Normal division: Lineage tree': + elif mode == "Normal division: Lineage tree": self.autoLinTree_df() notEnoughG1Cells = False proceed = True return notEnoughG1Cells, proceed - + else: notEnoughG1Cells = False proceed = True @@ -491,23 +471,20 @@ def attempt_auto_cca(self, enforceAll=False): def autoAssignBud_YeastMate(self): if not self.is_win: txt = ( - 'YeastMate is available only on Windows OS.' - 'We are working on expading support also on macOS and Linux.\n\n' - 'Thank you for your patience!' + "YeastMate is available only on Windows OS." + "We are working on expading support also on macOS and Linux.\n\n" + "Thank you for your patience!" ) msg = QMessageBox() - msg.critical( - self, 'Supported only on Windows', txt, msg.Ok - ) + msg.critical(self, "Supported only on Windows", txt, msg.Ok) return - - model_name = 'YeastMate' + model_name = "YeastMate" idx = self.modelNames.index(model_name) self.titleLabel.setText( - f'{model_name} is thinking... ' - '(check progress in terminal/console)', color=self.titleColor + f"{model_name} is thinking... (check progress in terminal/console)", + color=self.titleColor, ) # Store undo state before modifying stuff @@ -530,32 +507,32 @@ def autoAssignBud_YeastMate(self): _SizeZ = None if self.isSegm3D: - _SizeZ = posData.SizeZ + _SizeZ = posData.SizeZ win = apps.QDialogModelParams( init_params, segment_params, - model_name, - url=url, + model_name, + url=url, posData=posData, - df_metadata=posData.metadata_df + df_metadata=posData.metadata_df, ) win.exec_() if win.cancel: - self.titleLabel.setText('Segmentation aborted.') + self.titleLabel.setText("Segmentation aborted.") return - use_gpu = win.init_kwargs.get('gpu', False) + use_gpu = win.init_kwargs.get("gpu", False) proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) if not proceed: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') + self.logger.info("Segmentation process cancelled.") + self.titleLabel.setText("Segmentation process cancelled.") return - + self.model_kwargs = win.model_kwargs model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) if model is None: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') + self.logger.info("Segmentation process cancelled.") + self.titleLabel.setText("Segmentation process cancelled.") return try: model.setupLogger(self.logger) @@ -570,7 +547,7 @@ def autoAssignBud_YeastMate(self): self.store_data() self.updateAllImages() - self.titleLabel.setText('Budding event prediction done.', color='g') + self.titleLabel.setText("Budding event prediction done.", color="g") def autoCca_df(self, enforceAll=False): """ @@ -588,50 +565,42 @@ def autoCca_df(self, enforceAll=False): # Skip cca if not the right mode mode = str(self.modeComboBox.currentText()) - if mode.find('Cell cycle') == -1: + if mode.find("Cell cycle") == -1: return notEnoughG1Cells, proceed - # Make sure that this is a visited frame in segmentation tracking mode - if posData.allData_li[posData.frame_i]['labels'] is None: + if posData.allData_li[posData.frame_i]["labels"] is None: proceed = self.warnFrameNeverVisitedSegmMode() return notEnoughG1Cells, proceed - + # Determine if this is the last visited frame for repeating # bud assignment on non manually correct (corrected_on_frame_i>0) buds. # The idea is that the user could have assigned division on a cell # by going previous and we want to check if this cell could be a # "better" mother for those non manually corrected buds - curr_df = posData.allData_li[posData.frame_i]['acdc_df'] - isLastVisitedAgain = self.isLastVisitedAgainCca( - curr_df, enforceAll=enforceAll - ) - + curr_df = posData.allData_li[posData.frame_i]["acdc_df"] + isLastVisitedAgain = self.isLastVisitedAgainCca(curr_df, enforceAll=enforceAll) + frameAlreadyAnnotated = ( - posData.cca_df is not None - and not enforceAll - and not isLastVisitedAgain + posData.cca_df is not None and not enforceAll and not isLastVisitedAgain ) # Use stored cca_df and do not modify it with automatic stuff if frameAlreadyAnnotated: return notEnoughG1Cells, proceed - + # Keep only correctedAssignIDs if requested # For the last visited frame we perform assignment again only on # IDs where we didn't manually correct assignment correctedAssignIDs = set() if isLastVisitedAgain and not enforceAll: try: - correctedAssignIDs = curr_df[ - curr_df['corrected_on_frame_i']>0 - ].index + correctedAssignIDs = curr_df[curr_df["corrected_on_frame_i"] > 0].index except Exception as e: correctedAssignIDs = [] posData.new_IDs = [ - ID for ID in posData.new_IDs - if ID not in correctedAssignIDs + ID for ID in posData.new_IDs if ID not in correctedAssignIDs ] - + # Check if new IDs exist some time in the past found_cca_df_IDs = self.checkCcaPastFramesNewIDs() @@ -643,18 +612,18 @@ def autoCca_df(self, enforceAll=False): return notEnoughG1Cells, proceed # Get previous dataframe - acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] + acdc_df = posData.allData_li[posData.frame_i - 1]["acdc_df"] prev_cca_df = acdc_df[self.cca_df_colnames].copy() if posData.cca_df is None: posData.cca_df = prev_cca_df.copy() else: posData.cca_df = curr_df[self.cca_df_colnames].copy() - + # concatenate new IDs found in past frames (before frame_i-1) if found_cca_df_IDs is not None: cca_df = pd.concat([posData.cca_df, *found_cca_df_IDs]) - unique_idx = ~cca_df.index.duplicated(keep='first') + unique_idx = ~cca_df.index.duplicated(keep="first") posData.cca_df = cca_df[unique_idx] # If there are no new IDs we are done @@ -665,43 +634,39 @@ def autoCca_df(self, enforceAll=False): # Get cells in G1 (exclude dead) and check if there are enough cells in G1 try: - prev_df_G1 = prev_cca_df[prev_cca_df['cell_cycle_stage']=='G1'] - prev_df_G1 = prev_df_G1[~acdc_df.loc[prev_df_G1.index]['is_cell_dead']] + prev_df_G1 = prev_cca_df[prev_cca_df["cell_cycle_stage"] == "G1"] + prev_df_G1 = prev_df_G1[~acdc_df.loc[prev_df_G1.index]["is_cell_dead"]] IDsCellsG1 = set(prev_df_G1.index) except Exception as err: IDsCellsG1 = set() - + if isLastVisitedAgain or enforceAll: # If we are repeating auto cca for last visited frame # then we also add the cells in G1 that appears in current frame - # and we remove the ones that are already in S in current frame + # and we remove the ones that are already in S in current frame # if they were manually corrected (i.e., they cannot be mother). - # Note that potential mother cells must be either appearing in - # current frame or in G1 also at previous frame. - # If we would consider cells that are in G1 at current frame - # but not in previous frame, assigning a bud to it would + # Note that potential mother cells must be either appearing in + # current frame or in G1 also at previous frame. + # If we would consider cells that are in G1 at current frame + # but not in previous frame, assigning a bud to it would # result in no G1 at all for the mother cell. - df_G1 = posData.cca_df[posData.cca_df['cell_cycle_stage']=='G1'] + df_G1 = posData.cca_df[posData.cca_df["cell_cycle_stage"] == "G1"] current_G1_IDs = df_G1.index - new_cell_G1 = [ - ID for ID in current_G1_IDs if ID not in prev_cca_df.index - ] + new_cell_G1 = [ID for ID in current_G1_IDs if ID not in prev_cca_df.index] IDsCellsG1.update(new_cell_G1) cells_S_current = posData.cca_df[ - (posData.cca_df['cell_cycle_stage']=='S') - & (posData.cca_df['corrected_on_frame_i']==posData.frame_i) + (posData.cca_df["cell_cycle_stage"] == "S") + & (posData.cca_df["corrected_on_frame_i"] == posData.frame_i) ].index IDsCellsG1 = IDsCellsG1 - set(cells_S_current) # Remove cells that disappeared IDsCellsG1 = [ID for ID in IDsCellsG1 if ID in posData.IDs] - + numCellsG1 = len(IDsCellsG1) numNewCells = len(posData.new_IDs) if numCellsG1 < numNewCells: - notEnoughG1Cells, proceed = self.handleNoCellsInG1( - numCellsG1, numNewCells - ) + notEnoughG1Cells, proceed = self.handleNoCellsInG1(numCellsG1, numNewCells) return notEnoughG1Cells, proceed # Compute new IDs contours @@ -719,37 +684,37 @@ def autoCca_df(self, enforceAll=False): # Run hungarian (munkres) assignment algorithm row_idx, col_idx = scipy.optimize.linear_sum_assignment(cost) - + # New mother cells newMothIDs = {IDsCellsG1[i] for i in row_idx} - + # Assign buds to mothers for i, j in zip(row_idx, col_idx): mothID = IDsCellsG1[i] budID = posData.new_IDs[j] - + relID = None # If we are repeating assignment for the bud then we also have to - # correct the possibily wrong mother --> it goes back to + # correct the possibily wrong mother --> it goes back to # G1 if it's not a mother that we assign now if budID in posData.cca_df.index: - relID = posData.cca_df.at[budID, 'relative_ID'] + relID = posData.cca_df.at[budID, "relative_ID"] if relID in prev_cca_df.index and relID not in newMothIDs: posData.cca_df.loc[relID] = prev_cca_df.loc[relID] - - posData.cca_df.at[mothID, 'relative_ID'] = budID - posData.cca_df.at[mothID, 'cell_cycle_stage'] = 'S' + + posData.cca_df.at[mothID, "relative_ID"] = budID + posData.cca_df.at[mothID, "cell_cycle_stage"] = "S" bud_cca_dict = base_cca_dict.copy() - bud_cca_dict['cell_cycle_stage'] = 'S' - bud_cca_dict['generation_num'] = 0 - bud_cca_dict['relative_ID'] = mothID - bud_cca_dict['relationship'] = 'bud' - bud_cca_dict['emerg_frame_i'] = posData.frame_i - bud_cca_dict['is_history_known'] = True - bud_cca_dict['corrected_on_frame_i'] = -1 + bud_cca_dict["cell_cycle_stage"] = "S" + bud_cca_dict["generation_num"] = 0 + bud_cca_dict["relative_ID"] = mothID + bud_cca_dict["relationship"] = "bud" + bud_cca_dict["emerg_frame_i"] = posData.frame_i + bud_cca_dict["is_history_known"] = True + bud_cca_dict["corrected_on_frame_i"] = -1 posData.cca_df.loc[budID] = pd.Series(bud_cca_dict) - + # Keep only existing IDs posData.cca_df = posData.cca_df.loc[posData.IDs] @@ -769,28 +734,28 @@ def blinkPairingItem(self): def ccaCheckerStopChecking(self): if not self.ccaCheckerRunning: return - + self.ccaIntegrityCheckerWorker.clearQueue() - + if self.ccaIntegrityCheckerWorker.isChecking: self.ccaIntegrityCheckerWorker.abortChecking = True def ccaCheckerWorkerClosed(self, worker): - self.logger.info('Cell cycle annotations integrity checker stopped.') - self.ccaCheckerRunning = False + self.logger.info("Cell cycle annotations integrity checker stopped.") + self.ccaCheckerRunning = False def ccaCheckerWorkerDone(self): self.setStatusBarLabel(log=False) def ccaIntegrCheckerToggled(self, checked): - self.df_settings.at['is_cca_integrity_checker_activated', 'value'] = ( - int(checked) + self.df_settings.at["is_cca_integrity_checker_activated", "value"] = int( + checked ) self.df_settings.to_csv(self.settings_csv_path) mode = self.modeComboBox.currentText() - if mode != 'Cell cycle analysis': + if mode != "Cell cycle analysis": return - + if checked: self.startCcaIntegrityCheckerWorker() else: @@ -800,17 +765,17 @@ def checkCcaPastFramesNewIDs(self): posData = self.data[self.pos_i] if not posData.new_IDs: return - + found_cca_df_IDs = [] - for frame_i in range(posData.frame_i-2, -1, -1): - acdc_df = posData.allData_li[frame_i]['acdc_df'] + for frame_i in range(posData.frame_i - 2, -1, -1): + acdc_df = posData.allData_li[frame_i]["acdc_df"] cca_df_i = acdc_df[self.cca_df_colnames] intersect_idx = cca_df_i.index.intersection(posData.new_IDs) cca_df_i = cca_df_i.loc[intersect_idx] if cca_df_i.empty: continue found_cca_df_IDs.append(cca_df_i) - + # Remove IDs found in past frames from new_IDs list newIDs = np.array(posData.new_IDs, dtype=np.uint32) mask_index = np.in1d(newIDs, cca_df_i.index) @@ -823,9 +788,9 @@ def checkChangeMotherBudEligible(self, budID, frame_i): result = self._checkBudFutureNoDivision(budID, frame_i) if result is None: return True - + self.warnBudAnnotatedDividedInFuture( - budID, *result, action='change mother cell' + budID, *result, action="change mother cell" ) return False @@ -838,50 +803,50 @@ def checkDivisionCanBeUndone(self, ID, relID): Cell ID of the clicked cell in G1 relID : _type_ Relative ID of the cell that was clicked - + Notes ----- Division annotation can be undone only if `relID` is also in G1 for the entire duration of the correction - """ + """ posData = self.data[self.pos_i] - - ccs_relID = posData.cca_df.at[relID, 'cell_cycle_stage'] - if ccs_relID == 'S': + + ccs_relID = posData.cca_df.at[relID, "cell_cycle_stage"] + if ccs_relID == "S": return posData.frame_i - + # Check future frames - for future_i in range(posData.frame_i+1, posData.SizeT): + for future_i in range(posData.frame_i + 1, posData.SizeT): cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) if cca_df_i is None: # ith frame was not visited yet - break - - ccs_relID = cca_df_i.at[relID, 'cell_cycle_stage'] - if ccs_relID == 'S': + break + + ccs_relID = cca_df_i.at[relID, "cell_cycle_stage"] + if ccs_relID == "S": return future_i - + # Check past frames - for past_i in range(posData.frame_i-1, -1, -1): + for past_i in range(posData.frame_i - 1, -1, -1): cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) if ID not in cca_df_i.index or relID not in cca_df_i.index: # Bud did not exist at frame_i = i break - - ccs = cca_df_i.at[ID, 'cell_cycle_stage'] - if ccs == 'S': + + ccs = cca_df_i.at[ID, "cell_cycle_stage"] + if ccs == "S": break - - ccs_relID = cca_df_i.at[relID, 'cell_cycle_stage'] - if ccs_relID == 'S': - return future_i + + ccs_relID = cca_df_i.at[relID, "cell_cycle_stage"] + if ccs_relID == "S": + return future_i def checkMothEligibility(self, budID, new_mothID): """ Check that the new mother is in G1 for the entire life of the bud and that the G1 duration is > than 1 frame """ - last_cca_frame_i = self.navigateScrollBar.maximum()-1 + last_cca_frame_i = self.navigateScrollBar.maximum() - 1 posData = self.data[self.pos_i] eligible = True @@ -893,19 +858,19 @@ def checkMothEligibility(self, budID, new_mothID): if cca_df_i is None: # ith frame was not visited yet break - + if budID not in cca_df_i.index: # Bud disappeared break - is_still_bud = cca_df_i.at[budID, 'relationship'] == 'bud' + is_still_bud = cca_df_i.at[budID, "relationship"] == "bud" if not is_still_bud: break - ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] - if ccs != 'G1': + ccs = cca_df_i.at[new_mothID, "cell_cycle_stage"] + if ccs != "G1": cancel, apply = self.warnMotherNotEligible( - new_mothID, budID, future_i, 'not_G1_in_the_future' + new_mothID, budID, future_i, "not_G1_in_the_future" ) if apply: self.resetCcaFuture(future_i) @@ -915,11 +880,11 @@ def checkMothEligibility(self, budID, new_mothID): if cancel or (isG1singleFrame and isFutureFrameNotLastAnnot): eligible = False return eligible - + G1_duration_future += 1 # Check past frames - for past_i in range(posData.frame_i-1, -1, -1): + for past_i in range(posData.frame_i - 1, -1, -1): # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) @@ -930,35 +895,35 @@ def checkMothEligibility(self, budID, new_mothID): # Mother not existing because it appeared from outside FOV break - ccs = cca_df_i.at[new_mothID, 'cell_cycle_stage'] - if ccs != 'G1' and is_bud_existing: + ccs = cca_df_i.at[new_mothID, "cell_cycle_stage"] + if ccs != "G1" and is_bud_existing: # Requested mother not in G1 in the past # during the life of the bud (is_bud_existing = True) self.warnMotherNotEligible( - new_mothID, budID, past_i, 'not_G1_in_the_past' + new_mothID, budID, past_i, "not_G1_in_the_past" ) eligible = False return eligible if not is_bud_existing: # Bud stop existing --> check that mother is still in G1 - if ccs != 'G1': + if ccs != "G1": eligible = False self.warnMotherNotEligible( - new_mothID, budID, past_i, 'single_frame_G1_duration' + new_mothID, budID, past_i, "single_frame_G1_duration" ) break - + return eligible def checkMothersExcludedOrDead(self): try: posData = self.data[self.pos_i] buds_df = posData.cca_df[ - (posData.cca_df.relationship == 'bud') + (posData.cca_df.relationship == "bud") & (posData.cca_df.emerg_frame_i == posData.frame_i) ] - acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] + acdc_df_i = posData.allData_li[posData.frame_i]["acdc_df"] moth_df = acdc_df_i.loc[buds_df.relative_ID.to_list()] excluded_df = moth_df[ (moth_df.is_cell_dead > 0) | (moth_df.is_cell_excluded > 0) @@ -974,11 +939,9 @@ def checkMothersExcludedOrDead(self): return proceed except Exception as e: self.logger.info(traceback.format_exc()) - print('-'*100) - self.logger.warning( - 'Checking if mother cell is excluded or dead failed.' - ) - print('^'*100) + print("-" * 100) + self.logger.warning("Checking if mother cell is excluded or dead failed.") + print("^" * 100) return False def checkScellsGone(self): @@ -995,13 +958,13 @@ def checkScellsGone(self): automaticallyDividedIDs = [] mode = str(self.modeComboBox.currentText()) - if mode.find('Cell cycle') == -1: + if mode.find("Cell cycle") == -1: # No cell cycle analysis mode --> do nothing return False, automaticallyDividedIDs posData = self.data[self.pos_i] - if posData.allData_li[posData.frame_i]['labels'] is None: + if posData.allData_li[posData.frame_i]["labels"] is None: # Frame never visited/checked in segm mode --> autoCca_df will raise # a critical message return False, automaticallyDividedIDs @@ -1009,21 +972,21 @@ def checkScellsGone(self): # Check if there are S cells that either only mother or only # bud disappeared and automatically assign division to it # or abort visiting this frame - prev_acdc_df = posData.allData_li[posData.frame_i-1]['acdc_df'] - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_acdc_df = posData.allData_li[posData.frame_i - 1]["acdc_df"] + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] prev_cca_df = prev_acdc_df[self.cca_df_colnames].copy() ScellsIDsGone = [] for ccSeries in prev_cca_df.itertuples(): ID = ccSeries.Index ccs = ccSeries.cell_cycle_stage - if ccs != 'S': + if ccs != "S": continue relID = ccSeries.relative_ID if relID == -1: continue - + # Check is relID is gone while ID stays if relID not in posData.IDs and ID in posData.IDs: ScellsIDsGone.append(relID) @@ -1035,48 +998,42 @@ def checkScellsGone(self): self.highlightNewIDs_ccaFailed(ScellsIDsGone, rp=prev_rp) proceed = self.warnScellsGone(ScellsIDsGone, posData.frame_i) self.clearLostObjContoursItems() - + if not proceed: return True, automaticallyDividedIDs for IDgone in ScellsIDsGone: - relID = prev_cca_df.at[IDgone, 'relative_ID'] + relID = prev_cca_df.at[IDgone, "relative_ID"] self.annotateDisappearedBeforeDivision(relID, IDgone, prev_cca_df) self.annotateDivision( - prev_cca_df, IDgone, relID, frame_i=posData.frame_i-1 + prev_cca_df, IDgone, relID, frame_i=posData.frame_i - 1 ) self.annotateDivisionCurrentFrameRelativeIDgone(relID) automaticallyDividedIDs.append(relID) - - self.store_cca_df(frame_i=posData.frame_i-1, cca_df=prev_cca_df) + + self.store_cca_df(frame_i=posData.frame_i - 1, cca_df=prev_cca_df) return False, automaticallyDividedIDs def checkSwapMothersEligibility(self): posData = self.data[self.pos_i] - + lab2D = self.get_2Dlab(posData.lab) budID = lab2D[self.yClickBud, self.xClickBud] otherMothID = lab2D[self.yClickMoth, self.xClickMoth] - mothID = posData.cca_df.at[budID, 'relative_ID'] - otherBudID = posData.cca_df.at[otherMothID, 'relative_ID'] - + mothID = posData.cca_df.at[budID, "relative_ID"] + otherBudID = posData.cca_df.at[otherMothID, "relative_ID"] + for _budID in (budID, otherBudID): - result = self._checkBudFutureNoDivision( - _budID, posData.frame_i - ) + result = self._checkBudFutureNoDivision(_budID, posData.frame_i) if result is None: continue - + self.warnBudAnnotatedDividedInFuture(_budID, *result) return - - correct_pairings = { - otherBudID: mothID, budID: otherMothID - } - wrong_pairings = { - mothID: budID, otherMothID: otherBudID - } + + correct_pairings = {otherBudID: mothID, budID: otherMothID} + wrong_pairings = {mothID: budID, otherMothID: otherBudID} for correctBudID, correctMothID in correct_pairings.items(): wrongBudID = wrong_pairings[correctMothID] frame_no_G1 = self._checkMothInG1beforeBudEmergence( @@ -1084,12 +1041,12 @@ def checkSwapMothersEligibility(self): ) if frame_no_G1 is None: continue - + self.warnMotherNotAtLeastOneFrameG1( correctBudID, correctMothID, frame_no_G1 ) return - + return budID, otherBudID, otherMothID, mothID def disableCcaIntegrityChecker(self): @@ -1098,7 +1055,7 @@ def disableCcaIntegrityChecker(self): def enqCcaIntegrityChecker(self): if not self.ccaCheckerRunning: return - posData = self.data[self.pos_i] + posData = self.data[self.pos_i] self.ccaIntegrityCheckerWorker.enqueue(posData) def fixCcaDfAfterEdit(self, editTxt): @@ -1110,21 +1067,17 @@ def fixCcaDfAfterEdit(self, editTxt): def fixWillDivide(self, warning_txt, IDs_will_divide_wrong): self.logger.info(warning_txt) - self.logger.info('Fixing `will_divide` information...') - + self.logger.info("Fixing `will_divide` information...") + global_cca_df = self.getConcatCcaDf() - global_cca_df = ( - global_cca_df.reset_index() - .set_index(['Cell_ID', 'generation_num']) - ) - global_cca_df.loc[IDs_will_divide_wrong, 'will_divide'] = 0 - global_cca_df = ( - global_cca_df.reset_index() - .set_index(['frame_i', 'Cell_ID']) + global_cca_df = global_cca_df.reset_index().set_index( + ["Cell_ID", "generation_num"] ) + global_cca_df.loc[IDs_will_divide_wrong, "will_divide"] = 0 + global_cca_df = global_cca_df.reset_index().set_index(["frame_i", "Cell_ID"]) self.storeFromConcatCcaDf(global_cca_df) - def getBaseCca_df(self, with_tree_cols=False): + def getBaseCca_df(self, with_tree_cols=False): posData = self.data[self.pos_i] IDs = [obj.label for obj in posData.rp] cca_df = core.getBaseCca_df(IDs, with_tree_cols=with_tree_cols) @@ -1138,14 +1091,14 @@ def getConcatCcaDf(self): cca_df = self.get_cca_df(frame_i=frame_i, return_df=True) if cca_df is None: break - + cca_dfs.append(cca_df) keys.append(frame_i) - + if not cca_dfs: return - - global_cca_df = pd.concat(cca_dfs, keys=keys, names=['frame_i']) + + global_cca_df = pd.concat(cca_dfs, keys=keys, names=["frame_i"]) return global_cca_df def get_cca_df(self, frame_i=None, return_df=False, debug=False): @@ -1155,18 +1108,18 @@ def get_cca_df(self, frame_i=None, return_df=False, debug=False): posData = self.data[self.pos_i] cca_df = None i = posData.frame_i if frame_i is None else frame_i - df = posData.allData_li[i]['acdc_df'] + df = posData.allData_li[i]["acdc_df"] if df is not None: - if 'cell_cycle_stage' in df.columns: + if "cell_cycle_stage" in df.columns: cca_df = df[self.cca_df_colnames].copy() - + if cca_df is None and self.isSnapshot: cca_df = self.getBaseCca_df() posData.cca_df = cca_df if cca_df is not None: cca_df = cca_df.dropna() - + if return_df: return cca_df else: @@ -1174,18 +1127,18 @@ def get_cca_df(self, frame_i=None, return_df=False, debug=False): def get_last_cca_frame_i(self): posData = self.data[self.pos_i] - + i = 0 # Determine last annotated frame index for i, dict_frame_i in enumerate(posData.allData_li): - df = dict_frame_i['acdc_df'] + df = dict_frame_i["acdc_df"] if df is None: break - elif 'cell_cycle_stage' not in df.columns: + elif "cell_cycle_stage" not in df.columns: break - - last_cca_frame_i = i if i==0 or i+1==len(posData.allData_li) else i-1 - + + last_cca_frame_i = i if i == 0 or i + 1 == len(posData.allData_li) else i - 1 + return last_cca_frame_i def goToFrameNumber(self, frame_n): @@ -1212,7 +1165,7 @@ def handleNoCellsInG1(self, numCellsG1, numNewCells): else: notEnoughG1Cells = True proceed = False - + # Clear new cells annotations self.ccaFailedScatterItem.setData([], []) return notEnoughG1Cells, proceed @@ -1227,12 +1180,10 @@ def highlightNewCellNotEnoughG1cells(self, IDsCellsG1): continue objContours = self.getObjContours(obj) if objContours is not None: - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 + xx = objContours[:, 0] + 0.5 + yy = objContours[:, 1] + 0.5 self.ccaFailedScatterItem.addPoints(xx, yy) - self.textAnnot[0].addObjAnnotation( - obj, 'green', f'{obj.label}?', False - ) + self.textAnnot[0].addObjAnnotation(obj, "green", f"{obj.label}?", False) def highlightNewIDs_ccaFailed(self, IDsWithIssue, rp=None): if rp is None: @@ -1246,50 +1197,53 @@ def highlightNewIDs_ccaFailed(self, IDsWithIssue, rp=None): def initCca(self): posData = self.data[self.pos_i] last_tracked_i = self.get_last_tracked_i() - defaultMode = 'Viewer' + defaultMode = "Viewer" if last_tracked_i == 0: txt = html_utils.paragraph( - 'On this dataset either you never checked that the segmentation ' - 'and tracking are correct or you did not save yet.

    ' + "On this dataset either you never checked that the segmentation " + "and tracking are correct or you did not save yet.

    " 'If you already visited some frames with "Segmentation and Tracking" ' 'mode save data before switching to "Cell cycle analysis mode".

    ' - 'Otherwise you first have to check (and eventually correct) some frames ' + "Otherwise you first have to check (and eventually correct) some frames " 'in "Segmentation and Tracking" mode before proceeding ' - 'with cell cycle analysis.') - msg = widgets.myMessageBox() - msg.critical( - self, 'Tracking was never checked', txt + "with cell cycle analysis." ) + msg = widgets.myMessageBox() + msg.critical(self, "Tracking was never checked", txt) self.modeComboBox.setCurrentText(defaultMode) return proceed = True - + last_cca_frame_i = self.get_last_cca_frame_i() if last_cca_frame_i == 0: # Remove undoable actions from segmentation mode posData.UndoRedoStates[0] = [] self.undoAction.setEnabled(False) self.redoAction.setEnabled(False) - + if posData.frame_i > last_cca_frame_i: # Prompt user to go to last annotated frame msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_cca_frame_i+1}.

    + The last annotated frame is frame {last_cca_frame_i + 1}.

    Do you want to restart cell cycle analysis from frame - {last_cca_frame_i+1}?
    + {last_cca_frame_i + 1}?
    """) _, goToFrameButton, stayButton = msg.warning( - self, 'Go to last annotated frame?', txt, + self, + "Go to last annotated frame?", + txt, buttonsTexts=( - 'Cancel', f'Yes, go to frame {last_cca_frame_i+1}', - 'No, stay on current frame') + "Cancel", + f"Yes, go to frame {last_cca_frame_i + 1}", + "No, stay on current frame", + ), ) if goToFrameButton == msg.clickedButton: self.addMissingIDs_cca_df(posData) self.store_cca_df() - msg = 'Looking good!' + msg = "Looking good!" self.last_cca_frame_i = last_cca_frame_i posData.frame_i = last_cca_frame_i self.titleLabel.setText(msg, color=self.titleColor) @@ -1303,10 +1257,10 @@ def initCca(self): self.store_cca_df() self.initMissingFramesCca(last_cca_frame_i, posData.frame_i) last_cca_frame_i = posData.frame_i - msg = 'Cell cycle analysis initialised!' - self.titleLabel.setText(msg, color='g') + msg = "Cell cycle analysis initialised!" + self.titleLabel.setText(msg, color="g") elif msg.cancel: - msg = 'Cell cycle analysis aborted.' + msg = "Cell cycle analysis aborted." self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) self.modeComboBox.setCurrentText(defaultMode) @@ -1316,26 +1270,28 @@ def initCca(self): # Prompt user to go to last annotated frame msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_cca_frame_i+1}.

    + The last annotated frame is frame {last_cca_frame_i + 1}.

    Do you want to restart cell cycle analysis from frame - {last_cca_frame_i+1}?
    + {last_cca_frame_i + 1}?
    """) yesButton, noButton, _ = msg.question( - self, 'Go to last annotated frame?', txt, - buttonsTexts=('Yes', 'No', 'Cancel') + self, + "Go to last annotated frame?", + txt, + buttonsTexts=("Yes", "No", "Cancel"), ) if msg.cancel: - msg = 'Cell cycle analysis aborted.' + msg = "Cell cycle analysis aborted." self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) self.modeComboBox.setCurrentText(defaultMode) proceed = False return - + self.addMissingIDs_cca_df(posData) if msg.clickedButton == yesButton: self.addMissingIDs_cca_df(posData) - msg = 'Looking good!' + msg = "Looking good!" self.titleLabel.setText(msg, color=self.titleColor) self.last_cca_frame_i = last_cca_frame_i posData.frame_i = last_cca_frame_i @@ -1351,69 +1307,69 @@ def initCca(self): self.last_cca_frame_i = last_cca_frame_i - self.navigateScrollBar.setMaximum(last_cca_frame_i+1) - self.navSpinBox.setMaximum(last_cca_frame_i+1) + self.navigateScrollBar.setMaximum(last_cca_frame_i + 1) + self.navSpinBox.setMaximum(last_cca_frame_i + 1) self.lastTrackedFrameLabel.setText( - f'Last cc annot. frame n. = {last_cca_frame_i+1}' + f"Last cc annot. frame n. = {last_cca_frame_i + 1}" ) - + if posData.cca_df is None: posData.cca_df = self.getBaseCca_df() self.store_cca_df() - msg = 'Cell cycle analysis initialized!' + msg = "Cell cycle analysis initialized!" self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) else: self.get_cca_df() - + self.enqCcaIntegrityChecker() - + return proceed def initCcaIntegrityChecker(self): posData = self.data[self.pos_i] for frame_i, data_frame_i in enumerate(posData.allData_li): - lab = data_frame_i['labels'] + lab = data_frame_i["labels"] if lab is None: break - + cca_df = self.get_cca_df(frame_i, return_df=True) self.store_cca_df_checker(posData, frame_i, cca_df) - + self.enqCcaIntegrityChecker() def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): self.logger.info( - 'Initialising cell cycle annotations of missing past frames...' + "Initialising cell cycle annotations of missing past frames..." ) posData = self.data[self.pos_i] current_frame_i = posData.frame_i - + annotated_cca_dfs = [] - for frame_i in range(last_cca_frame_i+1): - acdc_df = posData.allData_li[frame_i]['acdc_df'] - if 'cell_cycle_stage' in acdc_df.columns: + for frame_i in range(last_cca_frame_i + 1): + acdc_df = posData.allData_li[frame_i]["acdc_df"] + if "cell_cycle_stage" in acdc_df.columns: continue - - acdc_df[self.cca_df_colnames] = '' - + + acdc_df[self.cca_df_colnames] = "" + annotated_cca_dfs = [ - posData.allData_li[i]['acdc_df'][self.cca_df_colnames] - for i in range(last_cca_frame_i+1) + posData.allData_li[i]["acdc_df"][self.cca_df_colnames] + for i in range(last_cca_frame_i + 1) ] - keys = range(last_cca_frame_i+1) - names = ['frame_i', 'Cell_ID'] + keys = range(last_cca_frame_i + 1) + names = ["frame_i", "Cell_ID"] annotated_cca_df = ( pd.concat(annotated_cca_dfs, keys=keys, names=names) .reset_index() - .set_index(['Cell_ID', 'frame_i']) + .set_index(["Cell_ID", "frame_i"]) .sort_index() ) - + last_annotated_cca_df = annotated_cca_df.groupby(level=0).last() cca_df_colnames = self.cca_df_colnames - pbar = tqdm(total=current_frame_i-last_cca_frame_i+1, ncols=100) - for frame_i in range(last_cca_frame_i, current_frame_i+1): + pbar = tqdm(total=current_frame_i - last_cca_frame_i + 1, ncols=100) + for frame_i in range(last_cca_frame_i, current_frame_i + 1): posData.frame_i = frame_i self.get_data() cca_df = self.getBaseCca_df() @@ -1431,21 +1387,21 @@ def initMissingFramesCca(self, last_cca_frame_i, current_frame_i): def isCcaCheckerChecking(self): if not self.ccaCheckerRunning: return False - + return self.ccaIntegrityCheckerWorker.isChecking def isCurrentFrameCcaVisited(self): posData = self.data[self.pos_i] - curr_df = posData.allData_li[posData.frame_i]['acdc_df'] - return curr_df is not None and 'cell_cycle_stage' in curr_df.columns + curr_df = posData.allData_li[posData.frame_i]["acdc_df"] + return curr_df is not None and "cell_cycle_stage" in curr_df.columns def isFrameCcaAnnotated(self): posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] if acdc_df is None: return False - return 'cell_cycle_stage' in acdc_df.columns + return "cell_cycle_stage" in acdc_df.columns def isLastVisitedAgainCca(self, curr_df, enforceAll=False): # Determine if this is the last visited frame for repeating @@ -1456,29 +1412,30 @@ def isLastVisitedAgainCca(self, curr_df, enforceAll=False): posData = self.data[self.pos_i] if curr_df is None: return False - - if 'cell_cycle_stage' not in curr_df.columns: + + if "cell_cycle_stage" not in curr_df.columns: return False - + if enforceAll: return False - + lastVisited = False posData.new_IDs = [ - ID for ID in posData.new_IDs - if curr_df.at[ID, 'is_history_known'] - and curr_df.at[ID, 'cell_cycle_stage'] == 'S' + ID + for ID in posData.new_IDs + if curr_df.at[ID, "is_history_known"] + and curr_df.at[ID, "cell_cycle_stage"] == "S" ] - if posData.frame_i+1 < posData.SizeT: - next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] + if posData.frame_i + 1 < posData.SizeT: + next_df = posData.allData_li[posData.frame_i + 1]["acdc_df"] if next_df is None: lastVisited = True else: - if 'cell_cycle_stage' not in next_df.columns: + if "cell_cycle_stage" not in next_df.columns: lastVisited = True else: lastVisited = True - + return lastVisited def manualCellCycleAnnotation(self, ID): @@ -1507,25 +1464,25 @@ def manualCellCycleAnnotation(self, ID): self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) # Correct current frame - clicked_ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] - relID = posData.cca_df.at[ID, 'relative_ID'] + clicked_ccs = posData.cca_df.at[ID, "cell_cycle_stage"] + relID = posData.cca_df.at[ID, "relative_ID"] if relID not in posData.IDs: return - - if clicked_ccs == 'G1' and posData.frame_i == 0: + + if clicked_ccs == "G1" and posData.frame_i == 0: # We do not allow undoing division annotation on first frame return - if clicked_ccs == 'G1': + if clicked_ccs == "G1": issue_frame_i = self.checkDivisionCanBeUndone(ID, relID) if issue_frame_i is not None: _warnings.warnDivisionAnnotationCannotBeUndone( ID, relID, issue_frame_i, qparent=self ) return - - if clicked_ccs == 'S': + + if clicked_ccs == "S": self.annotateDivision(posData.cca_df, ID, relID) self.store_cca_df() else: @@ -1543,9 +1500,9 @@ def manualCellCycleAnnotation(self, ID): if self.ccaTableWin is not None: zoomIDs = self.getZoomIDs() self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - + # Correct future frames - for future_i in range(posData.frame_i+1, posData.SizeT): + for future_i in range(posData.frame_i + 1, posData.SizeT): cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) if cca_df_i is None: # ith frame was not visited yet @@ -1557,57 +1514,48 @@ def manualCellCycleAnnotation(self, ID): # For some reason ID disappeared from this frame continue - ccs = cca_df_i.at[ID, 'cell_cycle_stage'] - relID = cca_df_i.at[ID, 'relative_ID'] - if clicked_ccs == 'S': - if ccs == 'G1': + ccs = cca_df_i.at[ID, "cell_cycle_stage"] + relID = cca_df_i.at[ID, "relative_ID"] + if clicked_ccs == "S": + if ccs == "G1": # Cell is in G1 in the future again so stop annotating break self.annotateDivision(cca_df_i, ID, relID) - self.store_cca_df( - frame_i=future_i, cca_df=cca_df_i, autosave=False - ) - elif ccs == 'S': + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) + elif ccs == "S": # Cell is in S in the future again so stop undoing (break) # also leave a 1 frame duration G1 to avoid a continuous # S phase self.annotateDivision(cca_df_i, ID, relID) - self.store_cca_df( - frame_i=future_i, cca_df=cca_df_i, autosave=False - ) + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) break else: self.undoDivisionAnnotation(cca_df_i, ID, relID) - self.store_cca_df( - frame_i=future_i, cca_df=cca_df_i, autosave=False - ) - + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) + # Correct past frames - for past_i in range(posData.frame_i-1, -1, -1): + for past_i in range(posData.frame_i - 1, -1, -1): cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) if ID not in cca_df_i.index or relID not in cca_df_i.index: # Bud did not exist at frame_i = i break self.storeUndoRedoCca(past_i, cca_df_i, undoId) - ccs = cca_df_i.at[ID, 'cell_cycle_stage'] - relID = cca_df_i.at[ID, 'relative_ID'] - if ccs == 'S': + ccs = cca_df_i.at[ID, "cell_cycle_stage"] + relID = cca_df_i.at[ID, "relative_ID"] + if ccs == "S": # We correct only those frames in which the ID was in 'G1' break else: store = self.undoDivisionAnnotation(cca_df_i, ID, relID) - self.store_cca_df( - frame_i=past_i, cca_df=cca_df_i, autosave=False - ) - + self.store_cca_df(frame_i=past_i, cca_df=cca_df_i, autosave=False) + self.enqAutosave() def manualEditCca(self, checked=True): posData = self.data[self.pos_i] editCcaWidget = apps.editCcaTableWidget( - posData.cca_df, posData.SizeT, current_frame_i=posData.frame_i, - parent=self + posData.cca_df, posData.SizeT, current_frame_i=posData.frame_i, parent=self ) editCcaWidget.sigApplyChangesFutureFrames.connect( self.applyManualCcaChangesFutureFrames @@ -1645,54 +1593,55 @@ def nearest_point_2Dyx(self, points, all_others): def onMotherNotInG1(self, mothID): txt = html_utils.paragraph( - f'You clicked on ID={mothID} which is NOT in G1

    ' - 'Do you want to proceed with swapping the mother cells?

    ' - 'NOTE: To assign a bud start by clicking on the bud ' - 'and release on a cell in G1' + f"You clicked on ID={mothID} which is NOT in G1

    " + "Do you want to proceed with swapping the mother cells?

    " + "NOTE: To assign a bud start by clicking on the bud " + "and release on a cell in G1" ) msg = widgets.myMessageBox() - swapMothersButton = widgets.reloadPushButton('Swap mother cells') + swapMothersButton = widgets.reloadPushButton("Swap mother cells") _, swapMothersButton = msg.warning( - self, 'Released on a cell NOT in G1', txt, - buttonsTexts=('Cancel', swapMothersButton) + self, + "Released on a cell NOT in G1", + txt, + buttonsTexts=("Cancel", swapMothersButton), ) if msg.cancel: return - + pairings = self.checkSwapMothersEligibility() if pairings is None: - self.logger.info('Swapping mothers is not possible.') + self.logger.info("Swapping mothers is not possible.") return - + self.swapMothers(*pairings) def reInitCca(self): if not self.isSnapshot: txt = html_utils.paragraph( - 'If you decide to continue ALL cell cycle annotations from ' - 'this frame to the end will be erased from current session ' - '(saved data is not touched of course).

    ' - 'To annotate future frames again you will have to revisit them.

    ' - 'Do you want to continue?' + "If you decide to continue ALL cell cycle annotations from " + "this frame to the end will be erased from current session " + "(saved data is not touched of course).

    " + "To annotate future frames again you will have to revisit them.

    " + "Do you want to continue?" ) msg = widgets.myMessageBox() msg.warning( - self, 'Re-initialize annnotations?', txt, - buttonsTexts=('Cancel', 'Yes') + self, "Re-initialize annnotations?", txt, buttonsTexts=("Cancel", "Yes") ) posData = self.data[self.pos_i] if msg.cancel: return - + # Reset all future frames - self.resetCcaFuture(posData.frame_i+1) + self.resetCcaFuture(posData.frame_i + 1) if posData.frame_i == 0: # Reset everything since we are on first frame posData.cca_df = self.getBaseCca_df() self.store_data() self.updateAllImages() - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) + self.navigateScrollBar.setMaximum(posData.frame_i + 1) + self.navSpinBox.setMaximum(posData.frame_i + 1) else: # Store undo state before modifying stuff self.storeUndoRedoStates(False) @@ -1700,52 +1649,53 @@ def reInitCca(self): posData = self.data[self.pos_i] posData.cca_df = self.getBaseCca_df() self.store_data() - self.updateAllImages() + self.updateAllImages() def removeCcaAnnotationsCurrentFrame(self): posData = self.data[self.pos_i] posData.cca_df = None - - posData.allData_li[posData.frame_i].pop('cca_df', None) - posData.allData_li[posData.frame_i].pop('cca_df_checker', None) - - df = posData.allData_li[posData.frame_i]['acdc_df'] + + posData.allData_li[posData.frame_i].pop("cca_df", None) + posData.allData_li[posData.frame_i].pop("cca_df_checker", None) + + df = posData.allData_li[posData.frame_i]["acdc_df"] if df is None: # No more saved info to delete return False - if 'cell_cycle_stage' not in df.columns: + if "cell_cycle_stage" not in df.columns: # No cell cycle info present return False df = df.drop(columns=self.cca_df_colnames) - posData.allData_li[posData.frame_i]['acdc_df'] = df - + posData.allData_li[posData.frame_i]["acdc_df"] = df + return True def repeatAutoCca(self): # Do not allow automatic bud assignment if there are future # frames that already contain anotations posData = self.data[self.pos_i] - next_df = posData.allData_li[posData.frame_i+1]['acdc_df'] + next_df = posData.allData_li[posData.frame_i + 1]["acdc_df"] if next_df is not None: - if 'cell_cycle_stage' in next_df.columns: + if "cell_cycle_stage" in next_df.columns: msg = QMessageBox() warn_cca = msg.critical( - self, 'Future visited frames detected!', - 'Automatic bud assignment CANNOT be performed becasue ' - 'there are future frames that already contain cell cycle ' - 'annotations. The behaviour in this case cannot be predicted.\n\n' - 'We suggest assigning the bud manually OR use the ' + self, + "Future visited frames detected!", + "Automatic bud assignment CANNOT be performed becasue " + "there are future frames that already contain cell cycle " + "annotations. The behaviour in this case cannot be predicted.\n\n" + "We suggest assigning the bud manually OR use the " '"Re-initialize cell cycle annotations" button which properly ' - 're-initialize future frames.', - msg.Ok + "re-initialize future frames.", + msg.Ok, ) return - correctedAssignIDs = ( - posData.cca_df[posData.cca_df['corrected_on_frame_i']>=0].index - ) + correctedAssignIDs = posData.cca_df[ + posData.cca_df["corrected_on_frame_i"] >= 0 + ].index NeverCorrectedAssignIDs = [ ID for ID in posData.new_IDs if ID not in correctedAssignIDs ] @@ -1764,14 +1714,15 @@ def repeatAutoCca(self): msg = QMessageBox() msg.setIcon(msg.Question) msg.setText( - 'Do you want to automatically assign buds to mother cells for ' - 'ALL the new cells in this frame (excluding cells with unknown history) ' - 'OR only the cells where you never clicked on?' + "Do you want to automatically assign buds to mother cells for " + "ALL the new cells in this frame (excluding cells with unknown history) " + "OR only the cells where you never clicked on?" ) msg.setDetailedText( - f'New cells that you never touched:\n\n{NeverCorrectedAssignIDs}') - enforceAllButton = QPushButton('ALL new cells') - b = QPushButton('Only cells that I never corrected assignment') + f"New cells that you never touched:\n\n{NeverCorrectedAssignIDs}" + ) + enforceAllButton = QPushButton("ALL new cells") + b = QPushButton("Only cells that I never corrected assignment") msg.addButton(b, msg.YesRole) msg.addButton(enforceAllButton, msg.NoRole) msg.exec_() @@ -1786,123 +1737,115 @@ def repeatAutoCca(self): def resetCcaFuture(self, from_frame_i): posData = self.data[self.pos_i] - self.last_cca_frame_i = from_frame_i-1 + self.last_cca_frame_i = from_frame_i - 1 self.ccaCheckerStopChecking() - - self.setNavigateScrollBarMaximum() + + self.setNavigateScrollBarMaximum() for i in range(from_frame_i, posData.SizeT): - posData.allData_li[i].pop('cca_df', None) - posData.allData_li[i].pop('cca_df_checker', None) - - df = posData.allData_li[i]['acdc_df'] + posData.allData_li[i].pop("cca_df", None) + posData.allData_li[i].pop("cca_df_checker", None) + + df = posData.allData_li[i]["acdc_df"] if df is None: # No more saved info to delete break - if 'cell_cycle_stage' not in df.columns: + if "cell_cycle_stage" not in df.columns: # No cell cycle info present continue df = df.drop(columns=self.cca_df_colnames) - posData.allData_li[i]['acdc_df'] = df - + posData.allData_li[i]["acdc_df"] = df + if posData.acdc_df is not None: frames = posData.acdc_df.index.get_level_values(0) if from_frame_i in frames: posData.acdc_df = posData.acdc_df.loc[:from_frame_i] - + self.resetWillDivideInfo() def resetFutureCcaColCurrentFrame(self): posData = self.data[self.pos_i] - - cca_df_S_mask = posData.cca_df.cell_cycle_stage == 'S' - posData.cca_df.loc[cca_df_S_mask, 'will_divide'] = 0 - - mothers_mask = ( - (posData.cca_df.relationship == 'mother') - & cca_df_S_mask - ) - bud_mask = posData.cca_df.relationship == 'bud' - - posData.cca_df.loc[mothers_mask, 'daughter_disappears_before_division'] = 0 - posData.cca_df.loc[bud_mask, 'disappears_before_division'] = 0 - + + cca_df_S_mask = posData.cca_df.cell_cycle_stage == "S" + posData.cca_df.loc[cca_df_S_mask, "will_divide"] = 0 + + mothers_mask = (posData.cca_df.relationship == "mother") & cca_df_S_mask + bud_mask = posData.cca_df.relationship == "bud" + + posData.cca_df.loc[mothers_mask, "daughter_disappears_before_division"] = 0 + posData.cca_df.loc[bud_mask, "disappears_before_division"] = 0 + cca_df = self.get_cca_df(frame_i=posData.frame_i, return_df=True) if cca_df is not None: - cca_df_S_mask = cca_df.cell_cycle_stage == 'S' - cca_df.loc[cca_df_S_mask, 'will_divide'] = 0 - - mothers_mask = ( - (cca_df.relationship == 'mother') - & cca_df_S_mask - ) - bud_mask = cca_df.relationship == 'bud' - - cca_df.loc[mothers_mask, 'daughter_disappears_before_division'] = 0 - cca_df.loc[bud_mask, 'disappears_before_division'] = 0 - + cca_df_S_mask = cca_df.cell_cycle_stage == "S" + cca_df.loc[cca_df_S_mask, "will_divide"] = 0 + + mothers_mask = (cca_df.relationship == "mother") & cca_df_S_mask + bud_mask = cca_df.relationship == "bud" + + cca_df.loc[mothers_mask, "daughter_disappears_before_division"] = 0 + cca_df.loc[bud_mask, "disappears_before_division"] = 0 + self.store_data() def resetWillDivideInfo(self): global_cca_df = self.getConcatCcaDf() if global_cca_df is None: return - + global_cca_df = load._fix_will_divide(global_cca_df) self.storeFromConcatCcaDf(global_cca_df) def setCcaIssueContour(self, obj): - objContours = self.getObjContours(obj, all_external=True) + objContours = self.getObjContours(obj, all_external=True) for cont in objContours: - xx = cont[:,0] + 0.5 - yy = cont[:,1] + 0.5 + xx = cont[:, 0] + 0.5 + yy = cont[:, 1] + 0.5 self.ax1_lostObjScatterItem.addPoints(xx, yy) - self.textAnnot[0].addObjAnnotation( - obj, 'lost_object', f'{obj.label}?', False - ) + self.textAnnot[0].addObjAnnotation(obj, "lost_object", f"{obj.label}?", False) def startBlinkingPairingItem(self, budIDs, mothIDs): self.ax1_newMothBudLinesItem.setOpacity(0.2) self.ax1_oldMothBudLinesItem.setOpacity(0.2) - + posData = self.data[self.pos_i] - acdc_df_i = posData.allData_li[posData.frame_i]['acdc_df'] - + acdc_df_i = posData.allData_li[posData.frame_i]["acdc_df"] + # Blink one pairing at the time (the first found) - xc_b = acdc_df_i.loc[budIDs[0], 'x_centroid'] - yc_b = acdc_df_i.loc[budIDs[0], 'y_centroid'] - - xc_m = acdc_df_i.loc[mothIDs[0], 'x_centroid'] - yc_m = acdc_df_i.loc[mothIDs[0], 'y_centroid'] - + xc_b = acdc_df_i.loc[budIDs[0], "x_centroid"] + yc_b = acdc_df_i.loc[budIDs[0], "y_centroid"] + + xc_m = acdc_df_i.loc[mothIDs[0], "x_centroid"] + yc_m = acdc_df_i.loc[mothIDs[0], "y_centroid"] + self.warnPairingItem.setData([xc_b, xc_m], [yc_b, yc_m]) - + self.blinkPairingItemTimer = QTimer() self.blinkPairingItemTimer.flag = True self.blinkPairingItemTimer.timeout.connect(self.blinkPairingItem) self.blinkPairingItemTimer.start(300) def startCcaIntegrityCheckerWorker(self): - if not hasattr(self, 'data'): + if not hasattr(self, "data"): return - + if not self.isDataLoaded: return - + if not self.ccaIntegrCheckerToggle.isChecked(): return - + ccaCheckerThread = QThread() self.ccaCheckerMutex = QMutex() self.ccaCheckerWaitCond = QWaitCondition() - + worker = workers.CcaIntegrityCheckerWorker( self.ccaCheckerMutex, self.ccaCheckerWaitCond ) self.ccaIntegrityCheckerWorker = worker self.ccaCheckerThread = ccaCheckerThread - + worker.moveToThread(ccaCheckerThread) worker.finished.connect(ccaCheckerThread.quit) worker.finished.connect(worker.deleteLater) @@ -1914,20 +1857,20 @@ def startCcaIntegrityCheckerWorker(self): worker.finished.connect(self.ccaCheckerWorkerClosed) worker.sigWarning.connect(self.warnCcaIntegrity) worker.sigFixWillDivide.connect(self.fixWillDivide) - + ccaCheckerThread.started.connect(worker.run) ccaCheckerThread.start() - + self.ccaCheckerRunning = True - + self.initCcaIntegrityChecker() - - self.logger.info('Cell cycle annotations integrity checker started.') + + self.logger.info("Cell cycle annotations integrity checker started.") def stopBlinkingPairItem(self): self.ax1_newMothBudLinesItem.setOpacity(1.0) self.ax1_oldMothBudLinesItem.setOpacity(1.0) - + self.warnPairingItem.setData([], []) try: self.blinkPairingItemTimer.stop() @@ -1947,15 +1890,20 @@ def storeFromConcatCcaDf(self, global_cca_df): cca_df = global_cca_df.loc[frame_i] except KeyError as err: break - + self.store_cca_df(frame_i=frame_i, cca_df=cca_df, autosave=False) - + self.get_cca_df() def store_cca_df( - self, pos_i=None, frame_i=None, cca_df=None, mainThread=True, - autosave=True, store_cca_df_copy=False - ): + self, + pos_i=None, + frame_i=None, + cca_df=None, + mainThread=True, + autosave=True, + store_cca_df_copy=False, + ): pos_i = self.pos_i if pos_i is None else pos_i posData = self.data[pos_i] i = posData.frame_i if frame_i is None else frame_i @@ -1964,8 +1912,8 @@ def store_cca_df( if self.ccaTableWin is not None and mainThread: zoomIDs = self.getZoomIDs() self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) - - acdc_df = posData.allData_li[i]['acdc_df'] + + acdc_df = posData.allData_li[i]["acdc_df"] if acdc_df is None: current_frame_i = None if frame_i is not None and frame_i != posData.frame_i: @@ -1973,27 +1921,27 @@ def store_cca_df( posData.frame_i = frame_i self.get_data() self.store_data() - acdc_df = posData.allData_li[i]['acdc_df'] + acdc_df = posData.allData_li[i]["acdc_df"] if current_frame_i is not None: # Back to current frame posData.frame_i = current_frame_i self.get_data(debug=False) - - if 'cell_cycle_stage' in acdc_df.columns: + + if "cell_cycle_stage" in acdc_df.columns: # Cell cycle info already present --> overwrite with new acdc_df[self.cca_df_colnames] = cca_df[self.cca_df_colnames] - posData.allData_li[i]['acdc_df'] = acdc_df + posData.allData_li[i]["acdc_df"] = acdc_df elif cca_df is not None: - df = acdc_df.drop(cca_df.columns, axis=1, errors='ignore') - df = df.join(cca_df, how='left') - posData.allData_li[i]['acdc_df'] = df - + df = acdc_df.drop(cca_df.columns, axis=1, errors="ignore") + df = df.join(cca_df, how="left") + posData.allData_li[i]["acdc_df"] = df + # Store copy for cca integrity worker self.store_cca_df_checker(posData, i, cca_df) - + if store_cca_df_copy and cca_df is not None: - posData.allData_li[i]['cca_df'] = cca_df.copy() - + posData.allData_li[i]["cca_df"] = cca_df.copy() + if autosave: self.enqAutosave() self.enqCcaIntegrityChecker() @@ -2001,138 +1949,133 @@ def store_cca_df( def store_cca_df_checker(self, posData, frame_i, cca_df): if not self.ccaCheckerRunning: return - + if cca_df is None: return - - posData.allData_li[frame_i]['cca_df_checker'] = cca_df.copy() + + posData.allData_li[frame_i]["cca_df_checker"] = cca_df.copy() def swapMothers(self, budID, otherBudID, otherMothID, mothID): posData = self.data[self.pos_i] - + # Store cca_df for undo action undoId = uuid.uuid4() self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - + self.logger.info( - f'Swapping assignments (requested at frame n. {posData.frame_i+1}):\n' - f' * Bud ID {budID} --> mother ID {otherMothID}\n' - f' * Bud ID {otherBudID} --> mother ID {mothID}' + f"Swapping assignments (requested at frame n. {posData.frame_i + 1}):\n" + f" * Bud ID {budID} --> mother ID {otherMothID}\n" + f" * Bud ID {otherBudID} --> mother ID {mothID}" ) - - correct_pairings = { - otherBudID: mothID, - budID: otherMothID - } - + + correct_pairings = {otherBudID: mothID, budID: otherMothID} + for correct_budID, correct_mothID in correct_pairings.items(): - posData.cca_df.at[correct_budID, 'relative_ID'] = correct_mothID - posData.cca_df.at[correct_mothID, 'relative_ID'] = correct_budID - posData.cca_df.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i - posData.cca_df.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i + posData.cca_df.at[correct_budID, "relative_ID"] = correct_mothID + posData.cca_df.at[correct_mothID, "relative_ID"] = correct_budID + posData.cca_df.at[correct_budID, "corrected_on_frame_i"] = posData.frame_i + posData.cca_df.at[correct_mothID, "corrected_on_frame_i"] = posData.frame_i self.store_cca_df() - + # Correct past frames corrected_budIDs_past = set() - for past_i in range(posData.frame_i-1, -1, -1): + for past_i in range(posData.frame_i - 1, -1, -1): if len(corrected_budIDs_past) == 2: break - + for correct_budID, correct_mothID in correct_pairings.items(): # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) - + if correct_budID in corrected_budIDs_past: continue if correct_budID not in cca_df_i.index: # Bud does not exist anymore in the past corrected_budIDs_past.add(correct_budID) - + if len(corrected_budIDs_past) < 2: self.restoreMotherToBeforeWrongBudWasAssignedToIt( correct_mothID, cca_df_i, past_i ) continue - - cca_df_i.at[correct_budID, 'relative_ID'] = correct_mothID - cca_df_i.at[correct_mothID, 'relative_ID'] = correct_budID - cca_df_i.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i - cca_df_i.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i - + + cca_df_i.at[correct_budID, "relative_ID"] = correct_mothID + cca_df_i.at[correct_mothID, "relative_ID"] = correct_budID + cca_df_i.at[correct_budID, "corrected_on_frame_i"] = posData.frame_i + cca_df_i.at[correct_mothID, "corrected_on_frame_i"] = posData.frame_i + # Set mother cell cycle stage to S in case it is not - if cca_df_i.at[correct_mothID, 'cell_cycle_stage'] == 'G1': - cca_df_i.at[correct_mothID, 'cell_cycle_stage'] = 'S' + if cca_df_i.at[correct_mothID, "cell_cycle_stage"] == "G1": + cca_df_i.at[correct_mothID, "cell_cycle_stage"] = "S" # cca_df_i.at[correct_mothID, 'generation_num'] -= 1 - - self.store_cca_df( - frame_i=past_i, cca_df=cca_df_i, autosave=False - ) - + + self.store_cca_df(frame_i=past_i, cca_df=cca_df_i, autosave=False) + # Correct future frames corrected_budIDs_future = set() - for future_i in range(posData.frame_i+1, posData.SizeT): + for future_i in range(posData.frame_i + 1, posData.SizeT): if len(corrected_budIDs_future) == 2: break - + # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) if cca_df_i is None: # ith frame was not visited yet break - + for correct_budID, correct_mothID in correct_pairings.items(): if correct_budID in corrected_budIDs_future: # Bud already corrected in the future continue - + if correct_budID not in cca_df_i.index: # Bud disappeared in the future corrected_budIDs_future.add(correct_budID) continue - - ccs_bud = cca_df_i.at[correct_budID, 'cell_cycle_stage'] - if ccs_bud == 'G1': - # Bud divided in the future, annotate division between + + ccs_bud = cca_df_i.at[correct_budID, "cell_cycle_stage"] + if ccs_bud == "G1": + # Bud divided in the future, annotate division between # correct mother and wrong bud and then stop correcting if correct_budID not in corrected_budIDs_future: corrected_budIDs_future.add(correct_budID) - - if len(corrected_budIDs_future) < 2: + + if len(corrected_budIDs_future) < 2: self.annotateDivisionFutureFramesSwapMothers( cca_df_i, correct_mothID, future_i ) continue - - cca_df_i.at[correct_budID, 'relative_ID'] = correct_mothID - cca_df_i.at[correct_mothID, 'relative_ID'] = correct_budID - cca_df_i.at[correct_budID, 'corrected_on_frame_i'] = posData.frame_i - cca_df_i.at[correct_mothID, 'corrected_on_frame_i'] = posData.frame_i - + + cca_df_i.at[correct_budID, "relative_ID"] = correct_mothID + cca_df_i.at[correct_mothID, "relative_ID"] = correct_budID + cca_df_i.at[correct_budID, "corrected_on_frame_i"] = posData.frame_i + cca_df_i.at[correct_mothID, "corrected_on_frame_i"] = posData.frame_i + # Set mother cell cycle stage to S in case it is not - if cca_df_i.at[correct_mothID, 'cell_cycle_stage'] == 'G1': - cca_df_i.at[correct_mothID, 'cell_cycle_stage'] = 'S' + if cca_df_i.at[correct_mothID, "cell_cycle_stage"] == "G1": + cca_df_i.at[correct_mothID, "cell_cycle_stage"] = "S" # cca_df_i.at[correct_mothID, 'generation_num'] -= 1 - + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) - + self.updateAllImages() def undoBudMothAssignment(self, ID): posData = self.data[self.pos_i] - relID = posData.cca_df.at[ID, 'relative_ID'] - ccs = posData.cca_df.at[ID, 'cell_cycle_stage'] - if ccs == 'G1': + relID = posData.cca_df.at[ID, "relative_ID"] + ccs = posData.cca_df.at[ID, "cell_cycle_stage"] + if ccs == "G1": return - posData.cca_df.at[ID, 'relative_ID'] = -1 - posData.cca_df.at[ID, 'generation_num'] = 2 - posData.cca_df.at[ID, 'cell_cycle_stage'] = 'G1' - posData.cca_df.at[ID, 'relationship'] = 'mother' + posData.cca_df.at[ID, "relative_ID"] = -1 + posData.cca_df.at[ID, "generation_num"] = 2 + posData.cca_df.at[ID, "cell_cycle_stage"] = "G1" + posData.cca_df.at[ID, "relationship"] = "mother" if relID in posData.cca_df.index: - posData.cca_df.at[relID, 'relative_ID'] = -1 - posData.cca_df.at[relID, 'generation_num'] = 2 - posData.cca_df.at[relID, 'cell_cycle_stage'] = 'G1' - posData.cca_df.at[relID, 'relationship'] = 'mother' + posData.cca_df.at[relID, "relative_ID"] = -1 + posData.cca_df.at[relID, "generation_num"] = 2 + posData.cca_df.at[relID, "cell_cycle_stage"] = "G1" + posData.cca_df.at[relID, "relationship"] = "mother" obj_idx = posData.IDs.index(ID) relObj_idx = posData.IDs.index(relID) @@ -2152,132 +2095,125 @@ def undoDivisionAnnotation(self, cca_df, ID, relID): # Correct as follows: # If G1 then correct to S and -1 on generation number store = False - cca_df.at[ID, 'cell_cycle_stage'] = 'S' - gen_num_clickedID = cca_df.at[ID, 'generation_num'] - cca_df.at[ID, 'generation_num'] -= 1 - cca_df.at[ID, 'division_frame_i'] = -1 - cca_df.at[relID, 'cell_cycle_stage'] = 'S' - gen_num_relID = cca_df.at[relID, 'generation_num'] - cca_df.at[relID, 'generation_num'] -= 1 - cca_df.at[relID, 'division_frame_i'] = -1 + cca_df.at[ID, "cell_cycle_stage"] = "S" + gen_num_clickedID = cca_df.at[ID, "generation_num"] + cca_df.at[ID, "generation_num"] -= 1 + cca_df.at[ID, "division_frame_i"] = -1 + cca_df.at[relID, "cell_cycle_stage"] = "S" + gen_num_relID = cca_df.at[relID, "generation_num"] + cca_df.at[relID, "generation_num"] -= 1 + cca_df.at[relID, "division_frame_i"] = -1 if gen_num_clickedID < gen_num_relID: - cca_df.at[ID, 'relationship'] = 'bud' + cca_df.at[ID, "relationship"] = "bud" else: - cca_df.at[relID, 'relationship'] = 'bud' - cca_df.at[ID, 'will_divide'] = 0 - cca_df.at[relID, 'will_divide'] = 0 + cca_df.at[relID, "relationship"] = "bud" + cca_df.at[ID, "will_divide"] = 0 + cca_df.at[relID, "will_divide"] = 0 store = True return store def unstore_cca_df(self): posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] for col in self.cca_df_colnames: if col not in acdc_df.columns: continue acdc_df.drop(col, axis=1, inplace=True) def updateCcaDfDeletedIDsTimelapse( - self, posData, relIDsOfDelIDs, deletedIDs, undoId, - dropInPast, dropInFuture - ): + self, posData, relIDsOfDelIDs, deletedIDs, undoId, dropInPast, dropInFuture + ): # Get status of the relIDs (of deleted IDs) to restore relIDsCcaStatus = {} for relID in relIDsOfDelIDs: try: - ccs = posData.cca_df.at[relID, 'cell_cycle_stage'] - relationship = posData.cca_df.at[relID, 'relationship'] + ccs = posData.cca_df.at[relID, "cell_cycle_stage"] + relationship = posData.cca_df.at[relID, "relationship"] except Exception as err: continue - + ccaStatus = core.getBaseCca_df([relID]).loc[relID] - if relationship == 'mother' and ccs == 'S': - for past_frame_i in range(posData.frame_i-1, -1, -1): - cca_df_i = self.get_cca_df( - frame_i=past_frame_i, return_df=True - ) - ccs_past = cca_df_i.at[relID, 'cell_cycle_stage'] - if ccs_past == 'G1': + if relationship == "mother" and ccs == "S": + for past_frame_i in range(posData.frame_i - 1, -1, -1): + cca_df_i = self.get_cca_df(frame_i=past_frame_i, return_df=True) + ccs_past = cca_df_i.at[relID, "cell_cycle_stage"] + if ccs_past == "G1": ccaStatus = cca_df_i.loc[relID] break - + posData.cca_df.loc[relID] = ccaStatus self.store_data(autosave=False) relIDsCcaStatus[relID] = ccaStatus - - for fut_frame_i in range(posData.frame_i+1, posData.SizeT): + + for fut_frame_i in range(posData.frame_i + 1, posData.SizeT): cca_df_i = self.get_cca_df(frame_i=fut_frame_i, return_df=True) if cca_df_i is None: # ith frame was not visited yet break - + self.storeUndoRedoCca(fut_frame_i, cca_df_i, undoId) if dropInFuture: - cca_df_i = cca_df_i.drop(deletedIDs, errors='ignore') + cca_df_i = cca_df_i.drop(deletedIDs, errors="ignore") else: for delID in deletedIDs: dataDict = posData.allData_li[fut_frame_i] - delIDexists = dataDict['IDs_idxs'].get(delID, False) + delIDexists = dataDict["IDs_idxs"].get(delID, False) if not delIDexists: continue - + cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID] - + areRelIDsPresent = False for relID in relIDsOfDelIDs: try: - ccs = cca_df_i.at[relID, 'cell_cycle_stage'] - relationship = cca_df_i.at[relID, 'relationship'] + ccs = cca_df_i.at[relID, "cell_cycle_stage"] + relationship = cca_df_i.at[relID, "relationship"] ccaStatus = relIDsCcaStatus[relID] cca_df_i.loc[relID] = ccaStatus areRelIDsPresent = True except Exception as err: continue - + if not areRelIDsPresent: break - - self.store_cca_df( - frame_i=fut_frame_i, cca_df=cca_df_i, autosave=False - ) - + + self.store_cca_df(frame_i=fut_frame_i, cca_df=cca_df_i, autosave=False) + # Correct past frames - for past_frame_i in range(posData.frame_i-1, -1, -1): + for past_frame_i in range(posData.frame_i - 1, -1, -1): cca_df_i = self.get_cca_df(frame_i=past_frame_i, return_df=True) if cca_df_i is None: # ith frame was not visited yet break - + self.storeUndoRedoCca(past_frame_i, cca_df_i, undoId) if dropInPast: - cca_df_i = cca_df_i.drop(deletedIDs, errors='ignore') + cca_df_i = cca_df_i.drop(deletedIDs, errors="ignore") else: for delID in deletedIDs: dataDict = posData.allData_li[past_frame_i] - delIDexists = dataDict['IDs_idxs'].get(delID, False) + delIDexists = dataDict["IDs_idxs"].get(delID, False) if not delIDexists: continue - + cca_df_i.loc[delID] = core.getBaseCca_df([delID]).loc[delID] - + areRelIDsPresent = False for relID in relIDsOfDelIDs: try: - ccs = cca_df_i.at[relID, 'cell_cycle_stage'] - relationship = cca_df_i.at[relID, 'relationship'] + ccs = cca_df_i.at[relID, "cell_cycle_stage"] + relationship = cca_df_i.at[relID, "relationship"] ccaStatus = relIDsCcaStatus[relID] cca_df_i.loc[relID] = ccaStatus areRelIDsPresent = True except Exception as err: continue - + if not areRelIDsPresent: break - - self.store_cca_df( - frame_i=past_frame_i, cca_df=cca_df_i, autosave=False - ) + + self.store_cca_df(frame_i=past_frame_i, cca_df=cca_df_i, autosave=False) def updateIsHistoryKnown(): """ @@ -2299,24 +2235,21 @@ def updateIsHistoryKnown(): pass def update_cca_df_deletedIDs( - self, posData, deletedIDs, dropInPast=True, dropInFuture=True - ): + self, posData, deletedIDs, dropInPast=True, dropInFuture=True + ): if posData.cca_df is None: return - + # Store cca_df for undo action undoId = uuid.uuid4() self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - + try: - relIDs = ( - posData.cca_df.reindex(deletedIDs, fill_value=-1) - ['relative_ID'] - ) + relIDs = posData.cca_df.reindex(deletedIDs, fill_value=-1)["relative_ID"] except KeyError as err: return - - posData.cca_df = posData.cca_df.drop(deletedIDs, errors='ignore') + + posData.cca_df = posData.cca_df.drop(deletedIDs, errors="ignore") if self.isSnapshot: self.update_cca_df_newIDs(posData, relIDs) else: @@ -2329,178 +2262,174 @@ def update_cca_df_newIDs(self, posData, new_IDs): self.addIDBaseCca_df(posData, newID) def update_cca_df_relabelling(self, posData, oldIDs, newIDs): - relIDs = posData.cca_df['relative_ID'] - posData.cca_df['relative_ID'] = relIDs.replace(oldIDs, newIDs) + relIDs = posData.cca_df["relative_ID"] + posData.cca_df["relative_ID"] = relIDs.replace(oldIDs, newIDs) mapper = dict(zip(oldIDs, newIDs)) posData.cca_df = posData.cca_df.rename(index=mapper) def update_cca_df_snapshots(self, editTxt, posData): cca_df = posData.cca_df cca_df_IDs = cca_df.index - if editTxt == 'Delete ID': + if editTxt == "Delete ID": deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, deleted_IDs) - elif editTxt == 'Separate IDs': + elif editTxt == "Separate IDs": new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] self.update_cca_df_newIDs(posData, new_IDs) deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, deleted_IDs) - elif editTxt == 'Edit ID': + elif editTxt == "Edit ID": new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] self.update_cca_df_newIDs(posData, new_IDs) old_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, old_IDs) - elif editTxt == 'Annotate ID as dead': + elif editTxt == "Annotate ID as dead": return - - elif editTxt == 'Deleted non-selected objects': + + elif editTxt == "Deleted non-selected objects": deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, deleted_IDs) - elif editTxt == 'Delete ID with eraser': + elif editTxt == "Delete ID with eraser": deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, deleted_IDs) - elif editTxt == 'Add new ID with brush tool': + elif editTxt == "Add new ID with brush tool": new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] self.update_cca_df_newIDs(posData, new_IDs) - elif editTxt == 'Merge IDs': + elif editTxt == "Merge IDs": deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, deleted_IDs) - elif editTxt == 'Add new ID with curvature tool': + elif editTxt == "Add new ID with curvature tool": new_IDs = [ID for ID in posData.IDs if ID not in cca_df_IDs] self.update_cca_df_newIDs(posData, new_IDs) - elif editTxt == 'Delete IDs using ROI': + elif editTxt == "Delete IDs using ROI": deleted_IDs = [ID for ID in cca_df_IDs if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, deleted_IDs) - elif editTxt == 'Repeat segmentation': + elif editTxt == "Repeat segmentation": posData.cca_df = self.getBaseCca_df() def viewCcaTable(self): posData = self.data[self.pos_i] zoomIDs = self.getZoomIDs() - - df = posData.allData_li[posData.frame_i]['acdc_df'] + + df = posData.allData_li[posData.frame_i]["acdc_df"] current_cca_df = posData.cca_df if zoomIDs is not None: df = df.loc[zoomIDs] current_cca_df = current_cca_df.loc[zoomIDs] - + for column in current_cca_df.columns: header = ( - '================================================\n' - f'CURRENT vs STORED `{column}` column' - f'for frame number {posData.frame_i+1}:\n' + "================================================\n" + f"CURRENT vs STORED `{column}` column" + f"for frame number {posData.frame_i + 1}:\n" ) df_compare = current_cca_df[[column]].copy() - df_compare[f'STORED_{column}'] = df[column] - text = f'{header}{df_compare}' + df_compare[f"STORED_{column}"] = df[column] + text = f"{header}{df_compare}" self.logger.info(text) - - if 'cell_cycle_stage' in df.columns: + + if "cell_cycle_stage" in df.columns: cca_df = df[self.cca_df_colnames] cca_df = cca_df.merge( - current_cca_df, how='outer', left_index=True, right_index=True, - suffixes=('_STORED', '_CURRENT') + current_cca_df, + how="outer", + left_index=True, + right_index=True, + suffixes=("_STORED", "_CURRENT"), ) cca_df = cca_df.reindex(sorted(cca_df.columns), axis=1) num_cols = len(cca_df.columns) - for j in range(0,num_cols,2): - df_j_x = cca_df.iloc[:,j] - df_j_y = cca_df.iloc[:,j+1] - if any(df_j_x!=df_j_y): - self.logger.info('------------------------') - self.logger.info('DIFFERENCES:') - diff_df = cca_df.iloc[:,j:j+2] - diff_mask = diff_df.iloc[:,0]!=diff_df.iloc[:,1] + for j in range(0, num_cols, 2): + df_j_x = cca_df.iloc[:, j] + df_j_y = cca_df.iloc[:, j + 1] + if any(df_j_x != df_j_y): + self.logger.info("------------------------") + self.logger.info("DIFFERENCES:") + diff_df = cca_df.iloc[:, j : j + 2] + diff_mask = diff_df.iloc[:, 0] != diff_df.iloc[:, 1] self.logger.info(diff_df[diff_mask]) else: cca_df = None self.logger.info(cca_df) - self.logger.info('========================') + self.logger.info("========================") if current_cca_df is None: return if current_cca_df.empty: msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Cell cycle annotations\' table is empty.
    ' + "Cell cycle annotations' table is empty.
    " ) - msg.warning(self, 'Table empty', txt) + msg.warning(self, "Table empty", txt) return - - df = posData.add_tree_cols_to_cca_df( - current_cca_df, frame_i=posData.frame_i - ) + + df = posData.add_tree_cols_to_cca_df(current_cca_df, frame_i=posData.frame_i) if self.ccaTableWin is None: self.ccaTableWin = apps.ViewCcaTableWindow(df, parent=self) self.ccaTableWin.show() self.ccaTableWin.setGeometryWindow() - self.ccaTableWin.sigUpdateCcaTable.connect( - self.onSigUpdateCcaTableWindow - ) + self.ccaTableWin.sigUpdateCcaTable.connect(self.onSigUpdateCcaTableWindow) else: self.ccaTableWin.setFocus() self.ccaTableWin.activateWindow() self.ccaTableWin.updateTable(current_cca_df) def warnBudAnnotatedDividedInFuture( - self, budID, motherID, future_division_frame_i, - action='swap mother cells' - ): + self, budID, motherID, future_division_frame_i, action="swap mother cells" + ): posData = self.data[self.pos_i] - + txt = html_utils.paragraph(f""" Bud ID {budID} is annotated as divided from mother ID {motherID} - at frame n. {future_division_frame_i+1},
    + at frame n. {future_division_frame_i + 1},
    therefore it is not possible to {action}.

    We recommend reinitializing cell cycle annotations on any - frame
    between frames number {posData.frame_i+1} and + frame
    between frames number {posData.frame_i + 1} and {future_division_frame_i} before attempting to {action}.

    Thank you for your patience! """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, f'{action} not possible'.title(), txt) + msg.warning(self, f"{action} not possible".title(), txt) return def warnCcaIntegrity(self, txt, category): - self.logger.warning(f'{html_utils.to_plain_text(txt)}') - - if 'disable_all' in self.disabled_cca_warnings: + self.logger.warning(f"{html_utils.to_plain_text(txt)}") + + if "disable_all" in self.disabled_cca_warnings: return - + if category in self.disabled_cca_warnings: return - + if txt in self.disabled_cca_warnings: return - + if self.isWarningCcaIntegrity: # Some other warning is still open --> avoid opening another one return - + self.isWarningCcaIntegrity = True disabled_warning = _warnings.warn_cca_integrity( - txt, category, self, - go_to_frame_callback=self.goToFrameNumber + txt, category, self, go_to_frame_callback=self.goToFrameNumber ) if disabled_warning: self.disabled_cca_warnings.add(disabled_warning) - + self.isWarningCcaIntegrity = False def warnDeadOrExcludedMothers(self, budIDs, mothIDs): self.startBlinkingPairingItem(budIDs, mothIDs) msg = widgets.myMessageBox(wrapText=False) pairings = [ - f'Mother ID {mID} --> bud ID {bID}' - for mID, bID in zip(mothIDs, budIDs) + f"Mother ID {mID} --> bud ID {bID}" for mID, bID in zip(mothIDs, budIDs) ] txt = html_utils.paragraph(f""" The mother cell in the following mother-bud pairings @@ -2509,40 +2438,43 @@ def warnDeadOrExcludedMothers(self, budIDs, mothIDs): {html_utils.to_list(pairings)} """) msg.warning( - self, 'Mother cell is excluded or dead', txt, - buttonsTexts=('Cancel', 'Ok') + self, "Mother cell is excluded or dead", txt, buttonsTexts=("Cancel", "Ok") ) return not msg.cancel def warnEditingWithCca_df( - self, editTxt, return_answer=False, get_answer=False, - get_cancelled=False, update_images=True - ): + self, + editTxt, + return_answer=False, + get_answer=False, + get_cancelled=False, + update_images=True, + ): # Function used to warn that the user is editing in "Segmentation and # Tracking" mode a frame that contains cca annotations. - # Ask whether to remove annotations from all future frames + # Ask whether to remove annotations from all future frames if self.isSnapshot: return True posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] + if acdc_df is None and self.lineage_tree is None: if update_images: self.updateAllImages() return True - + cell_cycle_stage_present = ( - acdc_df is not None and 'cell_cycle_stage' in acdc_df.columns - ) + acdc_df is not None and "cell_cycle_stage" in acdc_df.columns + ) lineage_tree_present = ( - self.lineage_tree is not None or 'parent_ID_tree' in acdc_df.columns + self.lineage_tree is not None or "parent_ID_tree" in acdc_df.columns ) if not cell_cycle_stage_present and not lineage_tree_present: if update_images: self.updateAllImages() return True - + action = self.warnEditingWithAnnotActions.get(editTxt, None) if action is not None and not action.isChecked(): # user has checked that he does not want to be asked again AND he doesnt want to delete @@ -2551,60 +2483,63 @@ def warnEditingWithCca_df( return True msg = widgets.myMessageBox() - warn_type = 'cell cycle annotations' if cell_cycle_stage_present else 'lineage tree annotations' + warn_type = ( + "cell cycle annotations" + if cell_cycle_stage_present + else "lineage tree annotations" + ) txt = html_utils.paragraph( - f'You modified a frame that has {warn_type}.

    ' + f"You modified a frame that has {warn_type}.

    " f'The change "{editTxt}" most likely makes the ' - 'annotations wrong.

    ' - 'If you really want to apply this change we reccommend to remove' - f'ALL {warn_type}
    ' - 'from current frame to the end.

    ' - 'What do you want to do?' + "annotations wrong.

    " + "If you really want to apply this change we reccommend to remove" + f"ALL {warn_type}
    " + "from current frame to the end.

    " + "What do you want to do?" ) if action is not None: - checkBox = QCheckBox('Remember my choice and do not ask again') + checkBox = QCheckBox("Remember my choice and do not ask again") else: checkBox = None - + dropDelIDsNoteText = ( - '' if editTxt.find('Delete') == -1 else ' (drop removed IDs)' + "" if editTxt.find("Delete") == -1 else " (drop removed IDs)" ) _, removeAnnotButton, _ = msg.warning( - self, 'Edited segmentation with annotations!', txt, + self, + "Edited segmentation with annotations!", + txt, buttonsTexts=( - 'Cancel', - 'Remove annotations from future frames (RECOMMENDED)', - f'Do not remove annotations{dropDelIDsNoteText}' - ), widgets=checkBox - ) + "Cancel", + "Remove annotations from future frames (RECOMMENDED)", + f"Do not remove annotations{dropDelIDsNoteText}", + ), + widgets=checkBox, + ) if msg.cancel: if get_cancelled: - return 'cancelled' + return "cancelled" removeAnnotations = False return removeAnnotations - + if action is not None: action.setChecked(not checkBox.isChecked()) action.removeAnnot = msg.clickedButton == removeAnnotButton - + if return_answer: return msg.clickedButton == removeAnnotButton - + if (msg.clickedButton == removeAnnotButton) and cell_cycle_stage_present: self.resetFutureCcaColCurrentFrame() - self.resetCcaFuture(posData.frame_i+1) + self.resetCcaFuture(posData.frame_i + 1) self.updateAllImages() elif (msg.clickedButton == removeAnnotButton) and lineage_tree_present: self.resetLin_tree_future() self.updateAllImages() else: if dropDelIDsNoteText and posData.cca_df is not None: - delIDs = [ - ID for ID in posData.cca_df.index if ID not in posData.IDs - ] - self.update_cca_df_deletedIDs( - posData, delIDs, dropInPast=False - ) + delIDs = [ID for ID in posData.cca_df.index if ID not in posData.IDs] + self.update_cca_df_deletedIDs(posData, delIDs, dropInPast=False) self.addMissingIDs_cca_df(posData) self.updateAllImages() self.store_data() @@ -2617,7 +2552,7 @@ def warnEditingWithCca_df( # self.resetLin_tree_future() # self.resetCcaFuture(posData.frame_i) # self.next_frame() - + if get_answer: return msg.clickedButton == removeAnnotButton else: @@ -2626,20 +2561,21 @@ def warnEditingWithCca_df( def warnFrameNeverVisitedSegmMode(self): msg = widgets.myMessageBox() warn_cca = msg.critical( - self, 'Next frame NEVER visited', + self, + "Next frame NEVER visited", 'Next frame was never visited in "Segmentation and Tracking"' - 'mode.\n You cannot perform cell cycle analysis on frames' - 'where segmentation and/or tracking errors were not' - 'checked/corrected.\n\n' + "mode.\n You cannot perform cell cycle analysis on frames" + "where segmentation and/or tracking errors were not" + "checked/corrected.\n\n" 'Switch to "Segmentation and Tracking" mode ' - 'and check/correct next frame,\n' - 'before attempting cell cycle analysis again', + "and check/correct next frame,\n" + "before attempting cell cycle analysis again", ) return False def warnMotherNotAtLeastOneFrameG1(self, budID, motherID, frame_no_G1): posData = self.data[self.pos_i] - + txt = html_utils.paragraph(f""" Assigning bud ID {budID} to cell ID {motherID} cannot be done because cell ID {motherID} is not in G1 at frame n. @@ -2652,27 +2588,27 @@ def warnMotherNotAtLeastOneFrameG1(self, budID, motherID, frame_no_G1): Thank you for your patience! """) msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, 'Swap mothers not possible', txt) + msg.warning(self, "Swap mothers not possible", txt) return def warnMotherNotEligible(self, new_mothID, budID, i, why): - if why == 'not_G1_in_the_future': + if why == "not_G1_in_the_future": err_msg = html_utils.paragraph(f""" The requested cell in G1 (ID={new_mothID}) - at future frame {i+1} has a bud assigned to it, + at future frame {i + 1} has a bud assigned to it, therefore it cannot be assigned as the mother of bud ID {budID}.

    You can assign a cell as the mother of bud ID {budID} only if this cell is in G1 for the entire life of the bud.

    One possible solution is to click on "cancel", go to - frame {i+1} and assign the bud of cell {new_mothID} + frame {i + 1} and assign the bud of cell {new_mothID} to another cell.\n' A second solution is to assign bud ID {budID} to cell {new_mothID} anyway by clicking "Apply".

    However to ensure correctness of future assignments Cell-ACDC will delete any cell cycle - information from frame {i+1} to the end. Therefore, you + information from frame {i + 1} to the end. Therefore, you will have to visit those frames again.

    The deletion of cell cycle information CANNOT BE UNDONE! @@ -2680,39 +2616,36 @@ def warnMotherNotEligible(self, new_mothID, budID, i, why): Apply assignment or cancel process? """) applyButton = widgets.okPushButton(isDefault=False) - applyButton.setText('Apply and remove future annotations') + applyButton.setText("Apply and remove future annotations") msg = widgets.myMessageBox() _, applyButton = msg.warning( - self, 'Cell not eligible', err_msg, - buttonsTexts=('Cancel', applyButton) + self, "Cell not eligible", err_msg, buttonsTexts=("Cancel", applyButton) ) cancel = msg.cancel apply = msg.clickedButton == applyButton - elif why == 'not_G1_in_the_past': + elif why == "not_G1_in_the_past": err_msg = html_utils.paragraph(f""" The requested cell in G1 - (ID={new_mothID}) at past frame {i+1} + (ID={new_mothID}) at past frame {i + 1} has a bud assigned to it, therefore it cannot be assigned as mother of bud ID {budID}.
    You can assign a cell as the mother of bud ID {budID} only if this cell is in G1 for the entire life of the bud.
    - One possible solution is to first go to frame {i+1} and + One possible solution is to first go to frame {i + 1} and assign the bud of cell {new_mothID} to another cell. """) msg = widgets.myMessageBox() - msg.warning( - self, 'Cell not eligible', err_msg - ) + msg.warning(self, "Cell not eligible", err_msg) cancel = msg.cancel apply = False - elif why == 'single_frame_G1_duration': + elif why == "single_frame_G1_duration": err_msg = html_utils.paragraph(f""" Assigning bud ID {budID} to cell ID {new_mothID} would result in no G1 phase at all between previous cell cycle and - current cell cycle (see frame n. {i+1}).

    + current cell cycle (see frame n. {i + 1}).

    The solution is to annotate division on cell ID {new_mothID} - on any frame before the frame number {i+1}, and then + on any frame before the frame number {i + 1}, and then proceed to correcting the bud assignment.

    This will gurantee a G1 duration for the cell {new_mothID} @@ -2720,9 +2653,7 @@ def warnMotherNotEligible(self, new_mothID, budID, i, why): Thank you for your patience! """) msg = widgets.myMessageBox() - msg.warning( - self, 'Cell not eligible', err_msg - ) + msg.warning(self, "Cell not eligible", err_msg) cancel = msg.cancel apply = False return cancel, apply @@ -2745,8 +2676,10 @@ def warnScellsGone(self, ScellsIDsGone, frame_i): Do you want to continue? """) _, yesButton, noButton = msg.warning( - self, 'Cells in "S/G2/M" disappeared!', text, - buttonsTexts=('Cancel', 'Yes', 'No') + self, + 'Cells in "S/G2/M" disappeared!', + text, + buttonsTexts=("Cancel", "Yes", "No"), ) return msg.clickedButton == yesButton @@ -2758,47 +2691,45 @@ def warnSettingHistoryKnownCellsFirstFrame(self, ID): history status cannot be changed. """) msg = widgets.myMessageBox(wrapText=False) - msg.warning( - self, 'First frame cells', txt - ) + msg.warning(self, "First frame cells", txt) def getStatusKnownHistoryBud(self, ID): posData = self.data[self.pos_i] cca_df_ID = None - for i in range(posData.frame_i-1, -1, -1): + for i in range(posData.frame_i - 1, -1, -1): cca_df_i = self.get_cca_df(frame_i=i, return_df=True) is_cell_existing = is_bud_existing = ID in cca_df_i.index if not is_cell_existing: bud_cca_dict = base_cca_dict.copy() - bud_cca_dict['cell_cycle_stage'] = 'S' - bud_cca_dict['generation_num'] = 0 - bud_cca_dict['relationship'] = 'bud' - bud_cca_dict['emerg_frame_i'] = i+1 - bud_cca_dict['is_history_known'] = True + bud_cca_dict["cell_cycle_stage"] = "S" + bud_cca_dict["generation_num"] = 0 + bud_cca_dict["relationship"] = "bud" + bud_cca_dict["emerg_frame_i"] = i + 1 + bud_cca_dict["is_history_known"] = True cca_df_ID = pd.Series(bud_cca_dict) return cca_df_ID def setHistoryKnowledge(self, ID, cca_df): posData = self.data[self.pos_i] - is_history_known = cca_df.at[ID, 'is_history_known'] + is_history_known = cca_df.at[ID, "is_history_known"] if is_history_known: - cca_df.at[ID, 'is_history_known'] = False - cca_df.at[ID, 'cell_cycle_stage'] = 'G1' - cca_df.at[ID, 'generation_num'] += 2 - cca_df.at[ID, 'emerg_frame_i'] = -1 - cca_df.at[ID, 'relative_ID'] = -1 - cca_df.at[ID, 'relationship'] = 'mother' + cca_df.at[ID, "is_history_known"] = False + cca_df.at[ID, "cell_cycle_stage"] = "G1" + cca_df.at[ID, "generation_num"] += 2 + cca_df.at[ID, "emerg_frame_i"] = -1 + cca_df.at[ID, "relative_ID"] = -1 + cca_df.at[ID, "relationship"] = "mother" else: cca_df.loc[ID] = posData.ccaStatus_whenEmerged[ID] def annotateDivisionFutureFramesSwapMothers( - self, cca_df_at_future_division, mothIDofDisappearedBud, frame_i - ): - """This method is called as part of `guiWin.swapMothers`. - - It annotates cell division and propagates that to future frames to the - mother cell that stops having the correct bud because division between - wrong bud and other wrong mother was annotated in the future. + self, cca_df_at_future_division, mothIDofDisappearedBud, frame_i + ): + """This method is called as part of `guiWin.swapMothers`. + + It annotates cell division and propagates that to future frames to the + mother cell that stops having the correct bud because division between + wrong bud and other wrong mother was annotated in the future. Parameters ---------- @@ -2807,61 +2738,64 @@ def annotateDivisionFutureFramesSwapMothers( mothIDofDisappearedBud : int Mother ID of the disappeared bud frame_i : int - Frame since when the mother ID stops having the correct bud because + Frame since when the mother ID stops having the correct bud because the correct bud was assigned as divided from the wrong mother - """ + """ posData = self.data[self.pos_i] - + relativeIDofMothID = cca_df_at_future_division.at[ - mothIDofDisappearedBud, 'relative_ID' + mothIDofDisappearedBud, "relative_ID" ] if relativeIDofMothID not in cca_df_at_future_division.index: # Also wrong bud ID disappeared return - + relativeIDofMothIDrelationship = cca_df_at_future_division.at[ - relativeIDofMothID, 'relationship' + relativeIDofMothID, "relationship" ] - if relativeIDofMothIDrelationship != 'bud': - # The wrong bud ID is a cell in G1 from future cycle --> + if relativeIDofMothIDrelationship != "bud": + # The wrong bud ID is a cell in G1 from future cycle --> # the actual wrong bud ID disappeared too. return - + wrongBudID = relativeIDofMothID - + self.annotateDivision( - cca_df_at_future_division, mothIDofDisappearedBud, wrongBudID, - frame_i=frame_i + cca_df_at_future_division, + mothIDofDisappearedBud, + wrongBudID, + frame_i=frame_i, + ) + cca_df_at_future_division.at[mothIDofDisappearedBud, "corrected_on_frame_i"] = ( + frame_i ) - cca_df_at_future_division.at[ - mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i self.store_cca_df( frame_i=frame_i, cca_df=cca_df_at_future_division, autosave=False ) - + ccaStatusToRestore = cca_df_at_future_division.loc[mothIDofDisappearedBud] - for future_i in range(frame_i+1, posData.SizeT): + for future_i in range(frame_i + 1, posData.SizeT): # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) if cca_df_i is None: # ith frame was not visited yet break - - ccs = cca_df_i.at[mothIDofDisappearedBud, 'cell_cycle_stage'] - if ccs == 'G1': + + ccs = cca_df_i.at[mothIDofDisappearedBud, "cell_cycle_stage"] + if ccs == "G1": # Mother cell in G1 again, stop correcting break - + cca_df_i.loc[mothIDofDisappearedBud] = ccaStatusToRestore - cca_df_i.at[mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i - - self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) + cca_df_i.at[mothIDofDisappearedBud, "corrected_on_frame_i"] = frame_i + + self.store_cca_df(frame_i=future_i, cca_df=cca_df_i, autosave=False) def getStatus_RelID_BeforeEmergence(self, budID, curr_mothID): posData = self.data[self.pos_i] # Get status of the current mother before it had budID assigned to it cca_status_before_bud_emerg = None - for i in range(posData.frame_i-1, -1, -1): + for i in range(posData.frame_i - 1, -1, -1): # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=i, return_df=True) @@ -2876,46 +2810,44 @@ def getStatus_RelID_BeforeEmergence(self, budID, curr_mothID): # they appeared together from outside of the fov # and they were trated as new IDs bud in S0 bud_cca_dict = base_cca_dict.copy() - bud_cca_dict['cell_cycle_stage'] = 'S' - bud_cca_dict['generation_num'] = 0 - bud_cca_dict['relationship'] = 'bud' - bud_cca_dict['emerg_frame_i'] = i+1 - bud_cca_dict['is_history_known'] = True + bud_cca_dict["cell_cycle_stage"] = "S" + bud_cca_dict["generation_num"] = 0 + bud_cca_dict["relationship"] = "bud" + bud_cca_dict["emerg_frame_i"] = i + 1 + bud_cca_dict["is_history_known"] = True cca_status_before_bud_emerg = pd.Series(bud_cca_dict) return cca_status_before_bud_emerg - + # Mother did not have a status before bud emergence because it was # already paired with bud at first frame --> reinit to default - cca_status_before_bud_emerg = ( - core.getBaseCca_df([curr_mothID]).loc[curr_mothID] - ) + cca_status_before_bud_emerg = core.getBaseCca_df([curr_mothID]).loc[curr_mothID] return cca_status_before_bud_emerg def _checkBudFutureNoDivision(self, budID, start_frame_i): posData = self.data[self.pos_i] - + future_i = start_frame_i for future_i in range(start_frame_i, posData.SizeT): if future_i == 0: continue - + # Get cca_df for ith frame from allData_li cca_df_i = self.get_cca_df(frame_i=future_i, return_df=True) if cca_df_i is None: # ith frame was not visited yet return - + if budID not in cca_df_i.index: # Bud disappears in the future --> fine return - - ccs = cca_df_i.at[budID, 'cell_cycle_stage'] - if ccs == 'G1': - return future_i, cca_df_i.at[budID, 'relative_ID'] + + ccs = cca_df_i.at[budID, "cell_cycle_stage"] + if ccs == "G1": + return future_i, cca_df_i.at[budID, "relative_ID"] def _checkMothInG1beforeBudEmergence( - self, motherID, budID, wrongBudID, start_frame_i - ): + self, motherID, budID, wrongBudID, start_frame_i + ): """Check that mother is in G1 on the frame before bud emergence Parameters @@ -2926,77 +2858,75 @@ def _checkMothInG1beforeBudEmergence( ID of bud start_frame_i : int Frame index from which to start checking in the past - """ + """ for past_i in range(start_frame_i, -1, -1): - cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) + cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) if budID not in cca_df_i.index: - if cca_df_i.at[motherID, 'cell_cycle_stage'] == 'G1': + if cca_df_i.at[motherID, "cell_cycle_stage"] == "G1": return - - budID_prev_cycle = cca_df_i.at[motherID, 'relative_ID'] + + budID_prev_cycle = cca_df_i.at[motherID, "relative_ID"] if budID_prev_cycle != wrongBudID: return past_i + 1 - + break def restoreMotherToBeforeWrongBudWasAssignedToIt( - self, mothIDofDisappearedBud, - cca_df_at_correct_bud_ID_disappearance, - frame_i - ): - """This method is called as part of `guiWin.swapMothers`. + self, mothIDofDisappearedBud, cca_df_at_correct_bud_ID_disappearance, frame_i + ): + """This method is called as part of `guiWin.swapMothers`. Parameters ---------- mothIDofDisappearedBud : int Mother ID of the disappeared bud cca_df_at_correct_bud_ID_disappearance : pd.DataFrame - Cell cycle annotations DataFrame when the correct bud ID stopped + Cell cycle annotations DataFrame when the correct bud ID stopped existing (before emergence) frame_i : int - Frame index when the correct bud ID stopped existing + Frame index when the correct bud ID stopped existing (before emergence) - + Note ---- - It restores the mother cell cycle annotations to the status it had - before the wrong bud was assigned to it. - - We need to do it only if the swapMothers past frames loop is still + It restores the mother cell cycle annotations to the status it had + before the wrong bud was assigned to it. + + We need to do it only if the swapMothers past frames loop is still iterating to correct the other bud. - + We also need to do this only if the wrong bud ID is actually a bud. - - When we swap mothers in the past frames it can be that the correct bud - ID stops existing (before emergence). In this case the correct mother - still has the wrong bud assigned to ID so we need to restore the status - it had before the wrong bud was assigned to it. - - To determine the status we go back until the wrong bud disappear. That - is the frame before the wrong bud was assigned to the mother we want to + + When we swap mothers in the past frames it can be that the correct bud + ID stops existing (before emergence). In this case the correct mother + still has the wrong bud assigned to ID so we need to restore the status + it had before the wrong bud was assigned to it. + + To determine the status we go back until the wrong bud disappear. That + is the frame before the wrong bud was assigned to the mother we want to correct. This is the status we want to restore. - - When we go back in time it could be that the wrong bud never disappears - becuase it is already emerged at frame 0. In this case the status we - want to restore at is the default G1 status at frame 0. - """ + + When we go back in time it could be that the wrong bud never disappears + becuase it is already emerged at frame 0. In this case the status we + want to restore at is the default G1 status at frame 0. + """ relativeIDofMothID = cca_df_at_correct_bud_ID_disappearance.at[ - mothIDofDisappearedBud, 'relative_ID' + mothIDofDisappearedBud, "relative_ID" ] if relativeIDofMothID not in cca_df_at_correct_bud_ID_disappearance.index: # Also wrong bud ID disappeared return - + relativeIDofMothIDrelationship = cca_df_at_correct_bud_ID_disappearance.at[ - relativeIDofMothID, 'relationship' + relativeIDofMothID, "relationship" ] - if relativeIDofMothIDrelationship != 'bud': - # The wrong bud ID is a cell in G1 from previous cycle --> + if relativeIDofMothIDrelationship != "bud": + # The wrong bud ID is a cell in G1 from previous cycle --> # the actual wrong bud ID disappeared too. return - + wrongBudID = relativeIDofMothID - + mothCcaBeforeWrongBudID = base_cca_dict # Search in the past for status of mother before wrong bud emerged for past_i in range(frame_i, -1, -1): @@ -3004,16 +2934,14 @@ def restoreMotherToBeforeWrongBudWasAssignedToIt( if wrongBudID not in cca_df_i.index: mothCcaBeforeWrongBudID = cca_df_i.loc[mothIDofDisappearedBud] break - + # Restore in past frames the correct mother status for past_i in range(frame_i, -1, -1): cca_df_i = self.get_cca_df(frame_i=past_i, return_df=True) if wrongBudID in cca_df_i.index: cca_df_i.loc[mothIDofDisappearedBud] = mothCcaBeforeWrongBudID - cca_df_i.at[mothIDofDisappearedBud, 'corrected_on_frame_i'] = frame_i - self.store_cca_df( - frame_i=past_i, cca_df=cca_df_i, autosave=False - ) + cca_df_i.at[mothIDofDisappearedBud, "corrected_on_frame_i"] = frame_i + self.store_cca_df(frame_i=past_i, cca_df=cca_df_i, autosave=False) else: break @@ -3022,34 +2950,30 @@ def annotateDivisionCurrentFrameRelativeIDgone(self, IDwhoseRelativeIsGone): if posData.cca_df is None: return ID = IDwhoseRelativeIsGone - posData.cca_df.at[ID, 'generation_num'] += 1 - posData.cca_df.at[ID, 'division_frame_i'] = posData.frame_i-1 - posData.cca_df.at[ID, 'relationship'] = 'mother' - - def annotateDisappearedBeforeDivision( - self, relID, IDgone, cca_df, frame_i=None - ): - posData = self.data[self.pos_i] - gen_num = cca_df.at[relID, 'generation_num'] + posData.cca_df.at[ID, "generation_num"] += 1 + posData.cca_df.at[ID, "division_frame_i"] = posData.frame_i - 1 + posData.cca_df.at[ID, "relationship"] = "mother" + + def annotateDisappearedBeforeDivision(self, relID, IDgone, cca_df, frame_i=None): + posData = self.data[self.pos_i] + gen_num = cca_df.at[relID, "generation_num"] if frame_i is None: frame_i = posData.frame_i - - for past_frame_i in range(frame_i-1, -1, -1): + + for past_frame_i in range(frame_i - 1, -1, -1): past_cca_df = self.get_cca_df(frame_i=past_frame_i, return_df=True) if past_cca_df is None: return - + try: - if past_cca_df.at[relID, 'generation_num'] != gen_num: + if past_cca_df.at[relID, "generation_num"] != gen_num: # ID is a mother and the cell cycle is finished here return except Exception as err: # Bud stops existing --> stop process return - - past_cca_df.at[IDgone, 'disappears_before_division'] = 1 - past_cca_df.at[relID, 'daughter_disappears_before_division'] = 1 - - self.store_cca_df( - cca_df=past_cca_df, frame_i=past_frame_i, autosave=False - ) + + past_cca_df.at[IDgone, "disappears_before_division"] = 1 + past_cca_df.at[relID, "daughter_disappears_before_division"] = 1 + + self.store_cca_df(cca_df=past_cca_df, frame_i=past_frame_i, autosave=False) diff --git a/cellacdc/mixins/combine.py b/cellacdc/mixins/combine.py index bfcbfd0a2..4448d4937 100644 --- a/cellacdc/mixins/combine.py +++ b/cellacdc/mixins/combine.py @@ -8,60 +8,68 @@ from natsort import natsorted from qtpy.QtCore import QMutex, QThread, QTimer, QWaitCondition -from cellacdc import apps, core, html_utils, myutils, preprocess, printl, widgets, workers +from cellacdc import ( + apps, + core, + html_utils, + myutils, + preprocess, + printl, + widgets, + workers, +) from .graphics import Graphics from .preprocessing import Preprocessing + class CombineGui: def _setup_vars_combine(self): self.combineWorker = None self.combineDialog = None self.combineSegmViewToggle = None - + def combineDialogSaveCombinedData(self, dialog): # here check if all data has been processed? posData = self.data[self.pos_i] - + try: posData.combinedChannelsDataArray() except TypeError as e: - if 'Not all frames have been processed.' in str(e): + if "Not all frames have been processed." in str(e): msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Not all frames have been processed.
    ' - 'Please process all frames before saving.' + "Not all frames have been processed.
    " + "Please process all frames before saving." ) - msg.warning(self, 'Process all data before saving', txt) + msg.warning(self, "Process all data before saving", txt) return - helpText = ( - """ + helpText = """ The segm/img file will be saved with a different file name.

    Insert a name to append to the end of the new file name. The rest of the name will be the same as the original file base. """ - ) - hintText = 'Insert a name for the combined channels file:' + hintText = "Insert a name for the combined channels file:" basename = posData.basename if self.combineDialog.saveAsSegm(): - ext = '.npz' - hintText = hintText.replace('channels', 'segmentation') - helpText = helpText.replace('channels', 'segmentation') - basename = f'{basename}segm' + ext = ".npz" + hintText = hintText.replace("channels", "segmentation") + helpText = helpText.replace("channels", "segmentation") + basename = f"{basename}segm" else: - ext = '.tif' - + ext = ".tif" + win = apps.filenameDialog( basename=basename, ext=ext, hintText=hintText, - defaultEntry='combined', - helpText=helpText, + defaultEntry="combined", + helpText=helpText, allowEmpty=False, - parent=dialog + parent=dialog, ) win.exec_() if win.cancel: @@ -69,24 +77,25 @@ def combineDialogSaveCombinedData(self, dialog): appendedText = win.entryText if appendedText: - filename = f'{basename}_{appendedText}{ext}' + filename = f"{basename}_{appendedText}{ext}" else: - filename = f'{basename}{ext}' - + filename = f"{basename}{ext}" + self.progressWin = apps.QDialogWorkerProgress( - title='Saving combined channels(s)', + title="Saving combined channels(s)", parent=self, - pbarDesc='Saving combined channels(s)' + pbarDesc="Saving combined channels(s)", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - - self.statusBarLabel.setText('Saving combined channels...') - + + self.statusBarLabel.setText("Saving combined channels...") + self.saveCombinedChannelsWorker = workers.SaveCombinedChannelsWorker( - self.data, filename, + self.data, + filename, ) - + self.saveCombinedChannelsThread = QThread() self.saveCombinedChannelsWorker.moveToThread(self.saveCombinedChannelsThread) self.saveCombinedChannelsWorker.signals.finished.connect( @@ -98,23 +107,19 @@ def combineDialogSaveCombinedData(self, dialog): self.saveCombinedChannelsThread.finished.connect( self.saveCombinedChannelsThread.deleteLater ) - - self.saveCombinedChannelsWorker.signals.critical.connect( - self.workerCritical - ) + + self.saveCombinedChannelsWorker.signals.critical.connect(self.workerCritical) self.saveCombinedChannelsWorker.signals.initProgressBar.connect( self.workerInitProgressbar ) self.saveCombinedChannelsWorker.signals.progressBar.connect( self.workerUpdateProgressbar ) - self.saveCombinedChannelsWorker.signals.progress.connect( - self.workerProgress - ) + self.saveCombinedChannelsWorker.signals.progress.connect(self.workerProgress) self.saveCombinedChannelsWorker.signals.finished.connect( self.saveCombinedChannelsWorkerFinished ) - + self.saveCombinedChannelsThread.started.connect( self.saveCombinedChannelsWorker.run ) @@ -122,155 +127,139 @@ def combineDialogSaveCombinedData(self, dialog): self.saveCombinedChannelsWorker.sigDebugShowImg.connect(self.debugShowImg) self.saveCombinedChannelsThread.start() - + def combineDialogStepsChanged(self): - steps, keep_input_data_type, formula = self.combineDialog.steps(return_keepInputDataType=True) + steps, keep_input_data_type, formula = self.combineDialog.steps( + return_keepInputDataType=True + ) if steps is None: - self.logger.warning('Combine channels recipe not initialized yet.') + self.logger.warning("Combine channels recipe not initialized yet.") return - - self.updateCombineChannelsPreview(steps=steps, keep_input_data_type=keep_input_data_type, formula=formula) + + self.updateCombineChannelsPreview( + steps=steps, keep_input_data_type=keep_input_data_type, formula=formula + ) def updateCombineChannelsPreview(self, *args, **kwargs): - force = kwargs.get('force', False) - + force = kwargs.get("force", False) + if self.combineDialog is None: return - + if not self.combineDialog.isVisible() and not force: return - + if not self.combineDialog.previewCheckbox.isChecked() and not force: return - - if kwargs.get('steps') is None: - steps, keep_input_data_type, formula = self.combineDialog.steps(return_keepInputDataType=True) + + if kwargs.get("steps") is None: + steps, keep_input_data_type, formula = self.combineDialog.steps( + return_keepInputDataType=True + ) else: - steps = kwargs.get('steps') - keep_input_data_type = kwargs.get('keep_input_data_type') - formula = kwargs.get('formula') + steps = kwargs.get("steps") + keep_input_data_type = kwargs.get("keep_input_data_type") + formula = kwargs.get("formula") if steps is None: - self.logger.warning('Combine channels recipe not initialized yet.') + self.logger.warning("Combine channels recipe not initialized yet.") return - - txt = 'Combining...' + + txt = "Combining..." self.logger.info(txt) self.statusBarLabel.setText(txt) - + self.combineEnqueueCurrentImage(steps, keep_input_data_type, formula) - + def viewCombineChannelDataToggled(self, checked): self.img1.setUseCombined(checked) - + if checked: self.combineViewAsSegmSetup() - else: # setimage1 is already called in combineViewAsSegmSetup + else: # setimage1 is already called in combineViewAsSegmSetup self.setImageImg1() if self.viewPreprocDataToggle.isChecked(): - self.viewPreprocDataToggle.toggled.disconnect() + self.viewPreprocDataToggle.toggled.disconnect() self.viewPreprocDataToggle.setChecked(False) - self.viewPreprocDataToggle.toggled.connect( - self.viewPreprocDataToggled - ) - + self.viewPreprocDataToggle.toggled.connect(self.viewPreprocDataToggled) + def setupCombiningChannels(self): posData = self.data[self.pos_i] if self.combineDialog is not None: self.combineDialog.close() - + ordered_channels = [ch for ch in posData.chNames if ch != self.user_ch_name] ordered_channels = natsorted(ordered_channels) ordered_channels = [self.user_ch_name] + ordered_channels - segmentations = [segm for segm in self.existingSegmEndNames] segmentations = natsorted(segmentations) - segmentations = ['current segm.'] + segmentations + segmentations = ["current segm."] + segmentations # also add segm ordered_channels.extend(segmentations) - + self.combineDialog = apps.CombineChannelsSetupDialogGUI( ordered_channels, - isTimelapse=posData.SizeT>1, - isZstack=posData.SizeZ>1, - isMultiPos=len(self.data)>1, + isTimelapse=posData.SizeT > 1, + isZstack=posData.SizeZ > 1, + isMultiPos=len(self.data) > 1, df_metadata=posData.metadata_df, - hideOnClosing=True, + hideOnClosing=True, # addApplyButton=True, - parent=self - ) - self.doPreviewPreprocImage = False #to do - self.combineDialog.sigApplyImage.connect( - self.combineCurrentImage - ) - self.combineDialog.sigApplyZstack.connect( - self.combineZStack - ) - self.combineDialog.sigApplyAllFrames.connect( - self.combineAllFrames - ) - self.combineDialog.sigApplyAllPos.connect( - self.combineAllPos - ) - self.combineDialog.sigPreviewToggled.connect( - self.combinePreviewToggled + parent=self, ) + self.doPreviewPreprocImage = False # to do + self.combineDialog.sigApplyImage.connect(self.combineCurrentImage) + self.combineDialog.sigApplyZstack.connect(self.combineZStack) + self.combineDialog.sigApplyAllFrames.connect(self.combineAllFrames) + self.combineDialog.sigApplyAllPos.connect(self.combineAllPos) + self.combineDialog.sigPreviewToggled.connect(self.combinePreviewToggled) self.combineDialog.sigSaveAsSegmCheckboxToggled.connect( self.combinePreviewViewAsSegmToggled ) - self.combineDialog.sigValuesChanged.connect( - self.combineDialogStepsChanged - ) + self.combineDialog.sigValuesChanged.connect(self.combineDialogStepsChanged) self.combineDialog.sigSavePreprocData.connect( self.combineDialogSaveCombinedData ) - self.combineDialog.sigClose.connect( - self.combineDialogClosed - ) + self.combineDialog.sigClose.connect(self.combineDialogClosed) if self.combineWorker is not None: return - + self.combineThread = QThread() self.combineMutex = QMutex() self.combineWaitCond = QWaitCondition() - + self.combineWorker = workers.CombineChannelsWorkerGUI( - self.combineMutex, self.combineWaitCond, + self.combineMutex, + self.combineWaitCond, logger_func=self.logger.info, # signals=self.signals # what are the singals for gui??? ) - + self.combineWorker.moveToThread(self.combineThread) self.combineWorker.signals.finished.connect(self.combineThread.quit) - self.combineWorker.signals.finished.connect( - self.combineWorker.deleteLater - ) + self.combineWorker.signals.finished.connect(self.combineWorker.deleteLater) self.combineThread.finished.connect(self.combineWorker.deleteLater) self.combineWorker.sigDone.connect(self.combineWorkerDone) - self.combineWorker.sigIsQueueEmpty.connect( - self.combineWorkerIsQueueEmpty - ) + self.combineWorker.sigIsQueueEmpty.connect(self.combineWorkerIsQueueEmpty) self.combineWorker.sigPreviewDone.connect(self.combineWorkerPreviewDone) self.combineWorker.signals.progress.connect(self.workerProgress) self.combineWorker.signals.critical.connect(self.workerCritical) self.combineWorker.signals.finished.connect(self.combineWorkerClosed) - self.combineWorker.sigAskLoadChannels.connect( - self.combineWorkerAskLoadChannels - ) - + self.combineWorker.sigAskLoadChannels.connect(self.combineWorkerAskLoadChannels) + self.combineThread.started.connect(self.combineWorker.run) self.combineThread.start() - - self.logger.info('Combine channels worker started.') - + + self.logger.info("Combine channels worker started.") + def combineDialogClosed(self, window): QTimer.singleShot(200, self._combineDialogClosed) - + def _combineDialogClosed(self): self.combineDialog = None @@ -283,17 +272,19 @@ def combineViewAsSegmSetup(self): if self.combineSegmViewToggle.isChecked(): self.combineSegmViewToggle.setChecked(False) self.combineSegmViewToggle.setCheckable(False) - + if not self.overlayLabelsButton.isChecked() and combineViewAsSegm: self.overlayLabelsButton.blockSignals(True) self.overlayLabelsButton.setChecked(True) - self.overlayLabels_cb(checked=True, selectedLabelsEndnames=['combined segm.']) + self.overlayLabels_cb( + checked=True, selectedLabelsEndnames=["combined segm."] + ) self.overlayLabelsButton.blockSignals(False) - + if combineViewAsSegm: if not self.combineSegmViewToggle.isChecked(): self.combineSegmViewToggle.setCheckable(True) - + # reset view to update the overlay labels self.combineSegmViewToggle.setChecked(False) self.combineSegmViewToggle.setChecked(True) @@ -304,21 +295,22 @@ def combineViewAsSegmSetup(self): def combineChannelsActionTriggered(self): if self.zProjComboBox is not None: curr_proj = self.zProjComboBox.currentText() - if curr_proj != 'single z-slice': - self.zProjComboBox.setCurrentText('single z-slice') - + if curr_proj != "single z-slice": + self.zProjComboBox.setCurrentText("single z-slice") + if self.switchPlaneCombobox is not None: depthAxes = self.switchPlaneCombobox.depthAxes() - if depthAxes != 'z': - self.switchPlaneCombobox.setCurrentText('xy') - + if depthAxes != "z": + self.switchPlaneCombobox.setCurrentText("xy") + if self.combineDialog is None: self.setupCombiningChannels() self.combineDialog.show() self.combineDialog.raise_() self.combineDialog.activateWindow() self.combineDialog.emitSigPreviewToggled() - + + class CombineWorker(CombineGui, Graphics, Preprocessing): def combineEnqueueCurrentImage(self, steps, keep_input_data_type, formula): posData = self.data[self.pos_i] @@ -327,43 +319,43 @@ def combineEnqueueCurrentImage(self, steps, keep_input_data_type, formula): z_slice = self.z_slice_index() else: z_slice = 0 - + key = (self.pos_i, posData.frame_i, z_slice) self.combineWorker.enqueue( self.data, - steps, + steps, key, keep_input_data_type, output_as_segm=self.combineDialog.saveAsSegm(), formula=formula, ) - + def combinePreviewToggled(self, checked): self.viewCombineChannelDataToggle.setChecked(checked) self.updateCombineChannelsPreview() - + def combinePreviewViewAsSegmToggled(self, checked): self.updateCombineChannelsPreview() self.combineViewAsSegmSetup() - + def combineCurrentImage( - self, - steps: List[Dict[str, Any]]=None, - keep_input_data_type:bool=None, - formula: str=None, - ): + self, + steps: List[Dict[str, Any]] = None, + keep_input_data_type: bool = None, + formula: str = None, + ): if steps and keep_input_data_type is None: - raise ValueError('keep_input_data_type must be set if steps is set') - + raise ValueError("keep_input_data_type must be set if steps is set") + if steps is None: steps, keep_input_data_type, formula = self.combineDialog.steps( return_keepInputDataType=True ) - txt = 'Combining current image...' + txt = "Combining current image..." self.logger.info(txt) self.statusBarLabel.setText(txt) - + selected_channel = core.get_selected_channels(steps) self.getChData(requ_ch=selected_channel) @@ -373,46 +365,46 @@ def combineCurrentImage( key = (pos_i, self.data[pos_i].frame_i, z_slice) self.combineWorker.setupJob( - self.data, - steps, + self.data, + steps, keep_input_data_type, key, output_as_segm=self.combineDialog.saveAsSegm(), formula=formula, ) - + self.combineWorker.wakeUp() - + def combineZStack( - self, - steps: List[Dict[str, Any]]=None, - keep_input_data_type:bool=None, - formula: str=None, - ): + self, + steps: List[Dict[str, Any]] = None, + keep_input_data_type: bool = None, + formula: str = None, + ): if self.combineDialog is not None: keep_input_data_type = ( self.combineDialog.keepInputDataTypeToggle.isChecked() ) - + if steps and keep_input_data_type is None: - raise ValueError('keep_input_data_type must be set if steps is set') - + raise ValueError("keep_input_data_type must be set if steps is set") + if steps is None: steps, keep_input_data_type, formula = self.combineDialog.steps( return_keepInputDataType=True ) - txt = 'Combining z-stack...' + txt = "Combining z-stack..." self.statusBarLabel.setText(txt) self.logger.info(txt) - + selected_channel = core.get_selected_channels(steps) self.getChData(requ_ch=selected_channel) posData = self.data[self.pos_i] key = (self.pos_i, posData.frame_i, None) self.combineWorker.setupJob( - self.data, - steps, + self.data, + steps, keep_input_data_type, key, output_as_segm=self.combineDialog.saveAsSegm(), @@ -420,28 +412,31 @@ def combineZStack( ) self.combineWorker.wakeUp() - - def combineAllFrames(self, - steps: List[Dict[str, Any]]=None, - keep_input_data_type:bool=None, - formula: str=None, - ): + + def combineAllFrames( + self, + steps: List[Dict[str, Any]] = None, + keep_input_data_type: bool = None, + formula: str = None, + ): if steps and not keep_input_data_type: - raise ValueError('keep_input_data_type must be set if steps is set') - + raise ValueError("keep_input_data_type must be set if steps is set") + if steps is None: - steps, keep_input_data_type, formula = self.combineDialog.steps(return_keepInputDataType=True) - txt = 'Combining all frames...' + steps, keep_input_data_type, formula = self.combineDialog.steps( + return_keepInputDataType=True + ) + txt = "Combining all frames..." self.logger.info(txt) self.statusBarLabel.setText(txt) - + selected_channel = core.get_selected_channels(steps) self.getChData(requ_ch=selected_channel) key = (self.pos_i, None, None) self.combineWorker.setupJob( - self.data, - steps, + self.data, + steps, keep_input_data_type, key, output_as_segm=self.combineDialog.saveAsSegm(), @@ -449,31 +444,33 @@ def combineAllFrames(self, ) self.combineWorker.wakeUp() - - def combineAllPos(self, - steps: List[Dict[str, Any]]=None, - keep_input_data_type:bool=None, - formula: str=None, - ): + + def combineAllPos( + self, + steps: List[Dict[str, Any]] = None, + keep_input_data_type: bool = None, + formula: str = None, + ): if steps and not keep_input_data_type: - raise ValueError('keep_input_data_type must be set if steps is set') - + raise ValueError("keep_input_data_type must be set if steps is set") + if steps is None: - steps, keep_input_data_type, formula = self.combineDialog.steps(return_keepInputDataType=True) - txt = 'Combining all Positions...' + steps, keep_input_data_type, formula = self.combineDialog.steps( + return_keepInputDataType=True + ) + txt = "Combining all Positions..." self.logger.info(txt) self.statusBarLabel.setText(txt) - + selected_channel = core.get_selected_channels(steps) - + for pos_i in range(len(self.data)): self.getChData(requ_ch=selected_channel, pos_i=pos_i) - key = (None, None, None) self.combineWorker.setupJob( - self.data, - steps, + self.data, + steps, keep_input_data_type, key, output_as_segm=self.combineDialog.saveAsSegm(), @@ -481,14 +478,14 @@ def combineAllPos(self, ) self.combineWorker.wakeUp() - + def stopCombineWorker(self): - self.logger.info('Closing combine worker...') + self.logger.info("Closing combine worker...") try: self.combineWorker.stop() except Exception as err: pass - + def combineWorkerCritical(self, error): self.combineDialog.appliedFinished() self.workerCritical(error) @@ -499,15 +496,13 @@ def combineWorkerIsQueueEmpty(self, isEmpty: bool): else: self.combineDialog.setDisabled(True) self.combineDialog.infoLabel.setText( - 'Computing preview...
    ' - '(Feel free to use Cell-ACDC while waiting)' + "Computing preview...
    " + "(Feel free to use Cell-ACDC while waiting)" ) def combineWorkerPreviewDone( - self, - processed_data: List[np.ndarray], - keys: List[Tuple[int, int, int]] - ): + self, processed_data: List[np.ndarray], keys: List[Tuple[int, int, int]] + ): unique_pos = {key[0] for key in keys} per_pos_data = {pos_i: [] for pos_i in unique_pos} @@ -515,9 +510,9 @@ def combineWorkerPreviewDone( pos_i, frame_i, z_slice = key per_pos_data[pos_i].append((key, img)) - for pos_i in unique_pos: + for pos_i in unique_pos: posData = self.data[pos_i] - if not hasattr(posData, 'combine_img_data'): + if not hasattr(posData, "combine_img_data"): posData.combine_img_data = preprocess.PreprocessedData( image_data=np.zeros(posData.img_data.shape) ) @@ -544,22 +539,26 @@ def combineWorkerPreviewDone( self.data, pos_i, frame_i, z_slice ) else: - raise ValueError('Invalid number of dimensions in img_data.') - + raise ValueError("Invalid number of dimensions in img_data.") + posData = self.data[self.pos_i] curr_pos_i, curr_frame_i, curr_z_slice = ( - self.pos_i,self.data[self.pos_i].frame_i, self.z_slice_index() + self.pos_i, + self.data[self.pos_i].frame_i, + self.z_slice_index(), ) if not self.combineDialog.saveAsSegm(): self.img1.updateMinMaxValuesCombinedData( self.data, curr_pos_i, curr_frame_i, curr_z_slice ) - + self.combineViewAsSegmSetup() - + def combineWorkerAskLoadChannels(self, requ_channels, pos_i): # spit channels and segm to load - segms_to_load, channels_to_load, current_segm = myutils.separate_fluo_segment_channels(requ_channels) + segms_to_load, channels_to_load, current_segm = ( + myutils.separate_fluo_segment_channels(requ_channels) + ) if pos_i is None: pos_i = list(range(len(self.data))) elif not isinstance(pos_i, list): @@ -571,12 +570,10 @@ def combineWorkerAskLoadChannels(self, requ_channels, pos_i): for segm in segms_to_load: self.loadOverlayLabelsData(segm, pos_i=i) self.combineWorker.wake_waitCondLoadFluoChannels() - + def combineWorkerDone( - self, - processed_data: List[np.ndarray], - keys: List[Tuple[int, int, int]] - ): + self, processed_data: List[np.ndarray], keys: List[Tuple[int, int, int]] + ): self.setStatusBarLabel(log=False) self.combineDialog.appliedFinished() @@ -587,9 +584,9 @@ def combineWorkerDone( pos_i, frame_i, z_slice = key per_pos_data[pos_i].append((key, img)) - for pos_i in unique_pos: + for pos_i in unique_pos: posData = self.data[pos_i] - if not hasattr(posData, 'combine_img_data'): + if not hasattr(posData, "combine_img_data"): posData.combine_img_data = preprocess.PreprocessedData( image_data=np.zeros(posData.img_data.shape) ) @@ -602,9 +599,9 @@ def combineWorkerDone( posData.combine_img_data[frame_i][z_slice] = processed_data if not self.combineDialog.saveAsSegm(): self.img1.updateMinMaxValuesCombinedData( - self.data, pos_i, frame_i, z_slice - ) - if not self.combineDialog.saveAsSegm(): + self.data, pos_i, frame_i, z_slice + ) + if not self.combineDialog.saveAsSegm(): self.img1.updateMinMaxValuesCombinedDataProjections( self.data, pos_i, frame_i ) @@ -616,31 +613,31 @@ def combineWorkerDone( self.img1.updateMinMaxValuesCombinedData( self.data, pos_i, frame_i, z_slice ) - + if not self.viewCombineChannelDataToggle.isChecked(): self.viewCombineChannelDataToggle.setChecked(True) else: self.setImageImg1() def combineWorkerClosed(self, worker): - self.logger.info('Combine worker stopped.') - + self.logger.info("Combine worker stopped.") + def saveCombinedChannelsWorkerFinished(self): if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - + self.setStatusBarLabel() - self.logger.info('Combined channels data saved!') - self.titleLabel.setText('Combined channels data saved!', color='w') + self.logger.info("Combined channels data saved!") + self.titleLabel.setText("Combined channels data saved!", color="w") def saveCombineWorkerFinished(self): if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - + self.setStatusBarLabel() - self.logger.info('Combined channels saved!') - self.titleLabel.setText('Combined channels saved!', color='w') \ No newline at end of file + self.logger.info("Combined channels saved!") + self.titleLabel.setText("Combined channels saved!", color="w") diff --git a/cellacdc/mixins/curvature_tools.py b/cellacdc/mixins/curvature_tools.py index 5922d9969..458546109 100644 --- a/cellacdc/mixins/curvature_tools.py +++ b/cellacdc/mixins/curvature_tools.py @@ -10,15 +10,16 @@ from .brush_tools import BrushTools from .undo_redo import UndoRedo + class CurvatureTools(BrushTools, UndoRedo): """Extracted from guiWin.""" def clearCurvItems(self, removeItems=True): try: posData = self.data[self.pos_i] - curvItems = zip(posData.curvPlotItems, - posData.curvAnchorsItems, - posData.curvHoverItems) + curvItems = zip( + posData.curvPlotItems, posData.curvAnchorsItems, posData.curvHoverItems + ) for plotItem, curvAnchors, hoverItem in curvItems: plotItem.setData([], []) curvAnchors.setData([], []) @@ -60,12 +61,12 @@ def curvToolSplineToObj(self, xxA=None, yyA=None, isRightClick=False): if curvToolID <= 0: self.setBrushID() curvToolID = posData.brushID - + lab2D = self.get_2Dlab(posData.lab).copy() newIDMask = np.zeros(lab2D.shape, bool) rr, cc = skimage.draw.polygon(yyS, xxS, shape=lab2D.shape) newIDMask[rr, cc] = True - newIDMask[lab2D!=0] = False + newIDMask[lab2D != 0] = False lab2D[newIDMask] = curvToolID self.set_2Dlab(lab2D) self.currentLab2D = lab2D @@ -80,11 +81,14 @@ def curvTool_cb(self, checked): self.curvPlotItem = pg.PlotDataItem(pen=self.newIDs_cpen) self.curvHoverPlotItem = pg.PlotDataItem(pen=self.oldIDs_cpen) self.curvAnchors = pg.ScatterPlotItem( - symbol='o', size=9, - brush=pg.mkBrush((255,0,0,50)), - pen=pg.mkPen((255,0,0), width=2), - hoverable=True, hoverPen=pg.mkPen((255,0,0), width=3), - hoverBrush=pg.mkBrush((255,0,0)), tip=None + symbol="o", + size=9, + brush=pg.mkBrush((255, 0, 0, 50)), + pen=pg.mkPen((255, 0, 0), width=2), + hoverable=True, + hoverPen=pg.mkPen((255, 0, 0), width=3), + hoverBrush=pg.mkBrush((255, 0, 0)), + tip=None, ) self.ax1.addItem(self.curvAnchors) self.ax1.addItem(self.curvPlotItem) @@ -99,20 +103,20 @@ def curvTool_cb(self, checked): self.clearCurvItems() while self.app.overrideCursor() is not None: self.app.restoreOverrideCursor() - + self.showEditIDwidgets(checked) def drawAutoContour(self, y2, x2): y1, x1 = self.autoCont_y0, self.autoCont_x0 - Dy = abs(y2-y1) - Dx = abs(x2-x1) + Dy = abs(y2 - y1) + Dx = abs(x2 - x1) edge = self.getDisplayedImg1() if Dy != 0 or Dx != 0: # NOTE: numIter takes care of any lag in mouseMoveEvent numIter = int(round(max((Dy, Dx)))) - alfa = np.arctan2(y1-y2, x2-x1) - base = np.pi/4 - alfa_dir = round((base * round(alfa/base))*180/np.pi) + alfa = np.arctan2(y1 - y2, x2 - x1) + base = np.pi / 4 + alfa_dir = round((base * round(alfa / base)) * 180 / np.pi) for _ in range(numIter): y1, x1 = self.autoCont_y0, self.autoCont_x0 yy, xx = self.get_dir_coords(alfa_dir, y1, x1, edge.shape) @@ -142,29 +146,25 @@ def drawAutoContour(self, y2, x2): def getClosedSplineCoords(self): xxS, yyS = self.curvPlotItem.getData() - bbox_area = (xxS.max()-xxS.min())*(yyS.max()-yyS.min()) + bbox_area = (xxS.max() - xxS.min()) * (yyS.max() - yyS.min()) if bbox_area < 26_000: # Using 1000 is fast enough according to profiling - return xxS, yyS - - optimalSpaceSize = self.splineToObjModel.predict( - bbox_area, max_exec_time=150 - ) + return xxS, yyS + + optimalSpaceSize = self.splineToObjModel.predict(bbox_area, max_exec_time=150) if optimalSpaceSize >= 1000: # Using 1000 is fast enough according to model return xxS, yyS - + if optimalSpaceSize < 100: # Do not allow a rough spline optimalSpaceSize = 100 - - # Get spline with optimal space size so that exec time + + # Get spline with optimal space size so that exec time # or skimage.draw.polygon is less than 150 ms xx, yy = self.curvAnchors.getData() resolutionSpace = np.linspace(0, 1, int(optimalSpaceSize)) - xxS, yyS = self.getSpline( - xx, yy, resolutionSpace=resolutionSpace, per=True - ) + xxS, yyS = self.getSpline(xx, yy, resolutionSpace=resolutionSpace, per=True) return xxS, yyS def getPolygonBrush(self, yxc2, Y, X): @@ -174,26 +174,26 @@ def getPolygonBrush(self, yxc2, Y, X): R = self.brushSizeSpinbox.value() r = R - arcsin_den = np.sqrt((x2-x1)**2+(y2-y1)**2) - arctan_den = (x2-x1) - if arcsin_den!=0 and arctan_den!=0: - beta = np.arcsin((R-r)/arcsin_den) - gamma = -np.arctan((y2-y1)/arctan_den) - alpha = gamma-beta - x3 = x1 + r*np.sin(alpha) - y3 = y1 + r*np.cos(alpha) - x4 = x2 + R*np.sin(alpha) - y4 = y2 + R*np.cos(alpha) - - alpha = gamma+beta - x5 = x1 - r*np.sin(alpha) - y5 = y1 - r*np.cos(alpha) - x6 = x2 - R*np.sin(alpha) - y6 = y2 - R*np.cos(alpha) - - rr_poly, cc_poly = skimage.draw.polygon([y3, y4, y6, y5], - [x3, x4, x6, x5], - shape=(Y, X)) + arcsin_den = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + arctan_den = x2 - x1 + if arcsin_den != 0 and arctan_den != 0: + beta = np.arcsin((R - r) / arcsin_den) + gamma = -np.arctan((y2 - y1) / arctan_den) + alpha = gamma - beta + x3 = x1 + r * np.sin(alpha) + y3 = y1 + r * np.cos(alpha) + x4 = x2 + R * np.sin(alpha) + y4 = y2 + R * np.cos(alpha) + + alpha = gamma + beta + x5 = x1 - r * np.sin(alpha) + y5 = y1 - r * np.cos(alpha) + x6 = x2 - R * np.sin(alpha) + y6 = y2 - R * np.cos(alpha) + + rr_poly, cc_poly = skimage.draw.polygon( + [y3, y4, y6, y5], [x3, x4, x6, x5], shape=(Y, X) + ) else: rr_poly, cc_poly = [], [] @@ -216,9 +216,7 @@ def getSpline(self, xx, yy, resolutionSpace=None, per=False, appendFirst=False): k = 2 if len(xx) == 3 else 3 try: - tck, u = scipy.interpolate.splprep( - [xx, yy], s=0, k=k, per=per - ) + tck, u = scipy.interpolate.splprep([xx, yy], s=0, k=k, per=per) xi, yi = scipy.interpolate.splev(resolutionSpace, tck) return xi, yi except (ValueError, TypeError): @@ -227,10 +225,10 @@ def getSpline(self, xx, yy, resolutionSpace=None, per=False, appendFirst=False): def get_dir_coords(self, alfa_dir, yd, xd, shape, connectivity=1): h, w = shape - y_above = yd+1 if yd+1 < h else yd - y_below = yd-1 if yd > 0 else yd - x_right = xd+1 if xd+1 < w else xd - x_left = xd-1 if xd > 0 else xd + y_above = yd + 1 if yd + 1 < h else yd + y_below = yd - 1 if yd > 0 else yd + x_right = xd + 1 if xd + 1 < w else xd + x_left = xd - 1 if xd > 0 else xd if alfa_dir == 0: yy = [y_below, y_below, yd, y_above, y_above] xx = [xd, x_right, x_right, x_right, xd] @@ -268,12 +266,12 @@ def hoverEventDrawSpline(self, event): # If we are hovering the starting point we generate # a closed spline if len(xx) < 2: - return - - if len(hoverAnchors)>0: + return + + if len(hoverAnchors) > 0: xA_hover, yA_hover = hoverAnchors[0].pos() - if xx[0]==xA_hover and yy[0]==yA_hover: - per=True + if xx[0] == xA_hover and yy[0] == yA_hover: + per = True if per: # Append start coords and close spline xx = np.r_[xx, xx[0]] @@ -301,11 +299,11 @@ def smoothAutoContWithSpline(self, n=3): return obj = rp[0] cont = self.getObjContours(obj) - xxC, yyC = cont[:,0], cont[:,1] + xxC, yyC = cont[:, 0], cont[:, 1] xxA, yyA = xxC[::n], yyC[::n] self.xxA_autoCont, self.yyA_autoCont = xxA, yyA xxS, yyS = self.getSpline(xxA, yyA, per=True, appendFirst=True) - if len(xxS)>0: + if len(xxS) > 0: self.curvPlotItem.setData(xxS, yyS) except (TypeError, ValueError): pass diff --git a/cellacdc/mixins/custom_annotations.py b/cellacdc/mixins/custom_annotations.py index 01242c69b..ab0d209d8 100644 --- a/cellacdc/mixins/custom_annotations.py +++ b/cellacdc/mixins/custom_annotations.py @@ -20,26 +20,30 @@ from .annotation_display import AnnotationDisplay from .object_properties import ObjectProperties + class CustomAnnotations(AnnotationDisplay, ObjectProperties): """Extracted from guiWin.""" - def addCustomAnnnotScatterPlot( - self, symbolColor, symbol, toolButton - ): + def addCustomAnnnotScatterPlot(self, symbolColor, symbol, toolButton): # Add scatter plot item symbolColorBrush = [0, 0, 0, 50] symbolColorBrush[:3] = symbolColor.getRgb()[:3] scatterPlotItem = widgets.CustomAnnotationScatterPlotItem() scatterPlotItem.setData( - [], [], symbol=symbol, pxMode=False, - brush=pg.mkBrush(symbolColorBrush), size=15, + [], + [], + symbol=symbol, + pxMode=False, + brush=pg.mkBrush(symbolColorBrush), + size=15, pen=pg.mkPen(width=3, color=symbolColor), - hoverable=True, hoverBrush=pg.mkBrush(symbolColor), - tip=None + hoverable=True, + hoverBrush=pg.mkBrush(symbolColor), + tip=None, ) scatterPlotItem.sigHovered.connect(self.customAnnotHovered) scatterPlotItem.button = toolButton - self.customAnnotDict[toolButton]['scatterPlotItem'] = scatterPlotItem + self.customAnnotDict[toolButton]["scatterPlotItem"] = scatterPlotItem self.ax1.addItem(scatterPlotItem) def addCustomAnnotButtonAllLoadedPos(self): @@ -59,36 +63,51 @@ def addCustomAnnotation(self): self.addAnnotWin.sigDeleteSelecAnnot.connect(self.deleteSelectedAnnot) self.addAnnotWin.exec_() if self.addAnnotWin.cancel: - self.logger.info('Custom annotation process cancelled.') + self.logger.info("Custom annotation process cancelled.") return symbol = self.addAnnotWin.symbol - symbolColor = self.addAnnotWin.state['symbolColor'] + symbolColor = self.addAnnotWin.state["symbolColor"] keySequence = self.addAnnotWin.shortcutWidget.widget.keySequence toolTip = self.addAnnotWin.toolTip - name = self.addAnnotWin.state['name'] - keepActive = self.addAnnotWin.state.get('keepActive', True) - isHideChecked = self.addAnnotWin.state.get('isHideChecked', True) - + name = self.addAnnotWin.state["name"] + keepActive = self.addAnnotWin.state.get("keepActive", True) + isHideChecked = self.addAnnotWin.state.get("isHideChecked", True) + proceed = self.checkNameExists(name) if not proceed: - self.logger.info('Custom annotation process cancelled.') + self.logger.info("Custom annotation process cancelled.") return self.addCustomAnnotationItems( - symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked, self.addAnnotWin.state + symbol, + symbolColor, + keySequence, + toolTip, + name, + keepActive, + isHideChecked, + self.addAnnotWin.state, ) self.saveCustomAnnot() self.doCustomAnnotation(0) def addCustomAnnotationButton( - self, symbol, symbolColor, keySequence, toolTip, annotName, - keepActive, isHideChecked - ): + self, + symbol, + symbolColor, + keySequence, + toolTip, + annotName, + keepActive, + isHideChecked, + ): toolButton = widgets.customAnnotToolButton( - symbol, symbolColor, parent=self, keepToolActive=keepActive, - isHideChecked=isHideChecked + symbol, + symbolColor, + parent=self, + keepToolActive=keepActive, + isHideChecked=isHideChecked, ) toolButton.setCheckable(True) self.checkableQButtonsGroup.addButton(toolButton) @@ -105,23 +124,29 @@ def addCustomAnnotationButton( return toolButton, action def addCustomAnnotationItems( - self, symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked, state - ): + self, + symbol, + symbolColor, + keySequence, + toolTip, + name, + keepActive, + isHideChecked, + state, + ): toolButton, action = self.addCustomAnnotationButton( - symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked + symbol, symbolColor, keySequence, toolTip, name, keepActive, isHideChecked ) self.customAnnotDict[toolButton] = { - 'action': action, - 'state': state, - 'annotatedIDs': [defaultdict(list) for _ in range(len(self.data))] + "action": action, + "state": state, + "annotatedIDs": [defaultdict(list) for _ in range(len(self.data))], } # Save custom annotation to cellacdc/temp/custom_annotations.json state_to_save = state.copy() - state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) + state_to_save["symbolColor"] = tuple(symbolColor.getRgb()) self.savedCustomAnnot[name] = state_to_save for posData in self.data: posData.customAnnot[name] = state_to_save @@ -130,11 +155,11 @@ def addCustomAnnotationItems( self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) customAnnotButton = self.customAnnotDict[toolButton] - allPosAnnotatedIDs = customAnnotButton['annotatedIDs'] + allPosAnnotatedIDs = customAnnotButton["annotatedIDs"] # Add 0s column to acdc_df for pos_i, posData in enumerate(self.data): for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] + acdc_df = data_dict["acdc_df"] if acdc_df is None: continue if name not in acdc_df.columns: @@ -142,64 +167,67 @@ def addCustomAnnotationItems( else: acdc_df[name] = acdc_df[name].astype(int) acdc_df_annot = acdc_df[acdc_df[name] == 1].reset_index() - annot_IDs = acdc_df_annot['Cell_ID'].to_list() + annot_IDs = acdc_df_annot["Cell_ID"].to_list() allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs) - + if posData.acdc_df is not None: if name not in posData.acdc_df.columns: posData.acdc_df[name] = 0 else: posData.acdc_df[name] = posData.acdc_df[name].astype(int) - acdc_df_annot = ( - posData.acdc_df[posData.acdc_df[name] == 1] - .reset_index() - ) - annot_IDs = acdc_df_annot['Cell_ID'].to_list() + acdc_df_annot = posData.acdc_df[ + posData.acdc_df[name] == 1 + ].reset_index() + annot_IDs = acdc_df_annot["Cell_ID"].to_list() allPosAnnotatedIDs[pos_i][frame_i].extend(annot_IDs) def addCustomAnnotationSavedPos(self, pos_i=None): if pos_i is None: pos_i = self.pos_i - + posData = self.data[pos_i] for name, annotState in posData.customAnnot.items(): # Check if button is already present and update only annotated IDs - buttons = [b for b in self.customAnnotDict.keys() if b.name==name] + buttons = [b for b in self.customAnnotDict.keys() if b.name == name] if buttons: toolButton = buttons[0] - allAnnotedIDs = self.customAnnotDict[toolButton]['annotatedIDs'] + allAnnotedIDs = self.customAnnotDict[toolButton]["annotatedIDs"] allAnnotedIDs[pos_i] = posData.customAnnotIDs.get(name, {}) continue try: - symbol = re.findall(r"\'(.+)\'", annotState['symbol'])[0] + symbol = re.findall(r"\'(.+)\'", annotState["symbol"])[0] except Exception as e: self.logger.info(traceback.format_exc()) - symbol = 'o' - - symbolColor = QColor(*annotState['symbolColor']) - shortcut = annotState['shortcut'] + symbol = "o" + + symbolColor = QColor(*annotState["symbolColor"]) + shortcut = annotState["shortcut"] if shortcut is not None: keySequence = widgets.macShortcutToWindows(shortcut) keySequence = widgets.KeySequenceFromText(keySequence) else: keySequence = None toolTip = myutils.getCustomAnnotTooltip(annotState) - keepActive = annotState.get('keepActive', True) - isHideChecked = annotState.get('isHideChecked', True) + keepActive = annotState.get("keepActive", True) + isHideChecked = annotState.get("isHideChecked", True) toolButton, action = self.addCustomAnnotationButton( - symbol, symbolColor, keySequence, toolTip, name, - keepActive, isHideChecked + symbol, + symbolColor, + keySequence, + toolTip, + name, + keepActive, + isHideChecked, ) allPosAnnotIDs = [ - pos.customAnnotIDs.get(name, defaultdict(list)) - for pos in self.data + pos.customAnnotIDs.get(name, defaultdict(list)) for pos in self.data ] self.customAnnotDict[toolButton] = { - 'action': action, - 'state': annotState, - 'annotatedIDs': allPosAnnotIDs + "action": action, + "state": annotState, + "annotatedIDs": allPosAnnotIDs, } self.addCustomAnnnotScatterPlot(symbolColor, symbol, toolButton) @@ -212,35 +240,36 @@ def askCustomAnnotationNameExists(self, name): If you continue, this column will be used to initialize pre-annotated objects.

    Do you want to continue? - """ - ) + """) noButton, yesButton = msg.question( - self, 'Custom annotation name already exists', txt, - buttonsTexts=('No, stop process', 'Yes, use existing column') + self, + "Custom annotation name already exists", + txt, + buttonsTexts=("No, stop process", "Yes, use existing column"), ) return msg.clickedButton == yesButton def checkNameExists(self, name): posData = self.data[self.pos_i] for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] + acdc_df = data_dict["acdc_df"] if acdc_df is None: continue if name in acdc_df.columns: return self.askCustomAnnotationNameExists(name) - + if posData.acdc_df is not None and name in posData.acdc_df.columns: return self.askCustomAnnotationNameExists(name) - + return True def clearCustomAnnot(self): for button in self.customAnnotDict.keys(): - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + scatterPlotItem = self.customAnnotDict[button]["scatterPlotItem"] scatterPlotItem.setData([], []) def clearScatterPlotCustomAnnotButton(self, button): - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + scatterPlotItem = self.customAnnotDict[button]["scatterPlotItem"] scatterPlotItem.setData([], []) def customAnnotButtonToggled(self, checked): @@ -253,25 +282,25 @@ def customAnnotButtonToggled(self, checked): button.toggled.disconnect() self.clearScatterPlotCustomAnnotButton(button) - button.setChecked(False) + button.setChecked(False) button.toggled.connect(self.customAnnotButtonToggled) self.doCustomAnnotation(0) else: self.customAnnotButton = None button = self.sender() clearAnnotation = ( - button.isHideChecked - or not self.viewAllCustomAnnotAction.isChecked() + button.isHideChecked or not self.viewAllCustomAnnotAction.isChecked() ) - if clearAnnotation: + if clearAnnotation: self.clearScatterPlotCustomAnnotButton(button) self.setHighlightID(False) self.resetCursor() def customAnnotHide(self, button): - self.customAnnotDict[button]['state']['isHideChecked'] = button.isHideChecked + self.customAnnotDict[button]["state"]["isHideChecked"] = button.isHideChecked clearAnnot = ( - not button.isChecked() and button.isHideChecked + not button.isChecked() + and button.isHideChecked and not self.viewAllCustomAnnotAction.isChecked() ) if clearAnnot: @@ -292,18 +321,15 @@ def customAnnotHovered(self, scatterPlotItem, points, event): x, y = point.pos().x(), point.pos().y() xdata, ydata = int(x), int(y) ID = self.get_2Dlab(posData.lab)[ydata, xdata] - vb.setToolTip( - f'Annotation name: {scatterPlotItem.button.name}\n' - f'ID = {ID}' - ) + vb.setToolTip(f"Annotation name: {scatterPlotItem.button.name}\nID = {ID}") else: - vb.setToolTip('') + vb.setToolTip("") def customAnnotKeepActive(self, button): - self.customAnnotDict[button]['state']['keepActive'] = button.keepToolActive + self.customAnnotDict[button]["state"]["keepActive"] = button.keepToolActive def customAnnotModify(self, button): - state = self.customAnnotDict[button]['state'] + state = self.customAnnotDict[button]["state"] self.addAnnotWin = apps.customAnnotationDialog( self.savedCustomAnnot, state=state ) @@ -314,36 +340,40 @@ def customAnnotModify(self, button): # Rename column if existing posData = self.data[self.pos_i] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] if acdc_df is not None: - old_name = self.customAnnotDict[button]['state']['name'] - new_name = self.addAnnotWin.state['name'] + old_name = self.customAnnotDict[button]["state"]["name"] + new_name = self.addAnnotWin.state["name"] acdc_df = acdc_df.rename(columns={old_name: new_name}) - posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df + posData.allData_li[posData.frame_i]["acdc_df"] = acdc_df - self.customAnnotDict[button]['state'] = self.addAnnotWin.state + self.customAnnotDict[button]["state"] = self.addAnnotWin.state - name = self.addAnnotWin.state['name'] + name = self.addAnnotWin.state["name"] state_to_save = self.addAnnotWin.state.copy() - symbolColor = self.addAnnotWin.state['symbolColor'] - state_to_save['symbolColor'] = tuple(symbolColor.getRgb()) + symbolColor = self.addAnnotWin.state["symbolColor"] + state_to_save["symbolColor"] = tuple(symbolColor.getRgb()) self.savedCustomAnnot[name] = self.addAnnotWin.state self.saveCustomAnnot() symbol = self.addAnnotWin.symbol - symbolColor = self.customAnnotDict[button]['state']['symbolColor'] + symbolColor = self.customAnnotDict[button]["state"]["symbolColor"] button.setColor(symbolColor) button.update() symbolColorBrush = [0, 0, 0, 50] symbolColorBrush[:3] = symbolColor.getRgb()[:3] - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + scatterPlotItem = self.customAnnotDict[button]["scatterPlotItem"] xx, yy = scatterPlotItem.getData() if xx is None: xx, yy = [], [] scatterPlotItem.setData( - xx, yy, symbol=symbol, pxMode=False, - brush=pg.mkBrush(symbolColorBrush), size=15, - pen=pg.mkPen(width=3, color=symbolColor) + xx, + yy, + symbol=symbol, + pxMode=False, + brush=pg.mkBrush(symbolColorBrush), + size=15, + pen=pg.mkPen(width=3, color=symbolColor), ) def deleteSavedAnnotation(self): @@ -360,13 +390,13 @@ def deleteSelectedAnnot(self, itemsToDelete): def doCustomAnnotation(self, ID): mode = self.modeComboBox.currentText() - if not self.isSnapshot and mode != 'Custom annotations': + if not self.isSnapshot and mode != "Custom annotations": # Do not show annotations if timelapse and mode not annotations return - - if self.switchPlaneCombobox.depthAxes() != 'z': + + if self.switchPlaneCombobox.depthAxes() != "z": return - + # NOTE: pass 0 for ID to not add posData = self.data[self.pos_i] if self.viewAllCustomAnnotAction.isChecked(): @@ -377,56 +407,55 @@ def doCustomAnnotation(self, ID): else: # Annotate if the button is active or isHideChecked is False buttons = [ - b for b in self.customAnnotDict.keys() + b + for b in self.customAnnotDict.keys() if (b.isChecked() or not b.isHideChecked) ] if not buttons: return for button in buttons: - annotatedIDs = ( - self.customAnnotDict[button]['annotatedIDs'][self.pos_i] - ) + annotatedIDs = self.customAnnotDict[button]["annotatedIDs"][self.pos_i] annotIDs_frame_i = annotatedIDs.get(posData.frame_i, []) - state = self.customAnnotDict[button]['state'] - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - + state = self.customAnnotDict[button]["state"] + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] + if button.isChecked() and ID > 0: # Annotate only if existing ID and the button is checked if ID in annotIDs_frame_i: annotIDs_frame_i.remove(ID) - acdc_df.at[ID, state['name']] = 0 + acdc_df.at[ID, state["name"]] = 0 elif ID != 0: annotIDs_frame_i.append(ID) - + annotPerButton = self.customAnnotDict[button] - allAnnotedIDs = annotPerButton['annotatedIDs'] + allAnnotedIDs = annotPerButton["annotatedIDs"] posAnnotedIDs = allAnnotedIDs[self.pos_i] posAnnotedIDs[posData.frame_i] = annotIDs_frame_i - + if acdc_df is None: self.store_data(autosave=False) - acdc_df = posData.allData_li[posData.frame_i]['acdc_df'] - + acdc_df = posData.allData_li[posData.frame_i]["acdc_df"] + xx, yy = [], [] for annotID in annotIDs_frame_i: if annotID not in posData.IDs_idxs: continue - + obj_idx = posData.IDs_idxs[annotID] obj = posData.rp[obj_idx] - acdc_df.at[annotID, state['name']] = 1 + acdc_df.at[annotID, state["name"]] = 1 if not self.isObjVisible(obj.bbox): continue y, x = self.getObjCentroid(obj.centroid) xx.append(x) yy.append(y) - - scatterPlotItem = self.customAnnotDict[button]['scatterPlotItem'] + + scatterPlotItem = self.customAnnotDict[button]["scatterPlotItem"] scatterPlotItem.setData(xx, yy) - posData.allData_li[posData.frame_i]['acdc_df'] = acdc_df - + posData.allData_li[posData.frame_i]["acdc_df"] = acdc_df + # if self.highlightedID != 0: # self.highlightedID = 0 # self.setHighlightID(False) @@ -443,14 +472,16 @@ def loadCustomAnnotations(self): Click on "Add custom annotation" button to start adding new annotations. """) - msg.warning(self, 'No annotations saved', txt) + msg.warning(self, "No annotations saved", txt) return - + self.selectAnnotWin = widgets.QDialogListbox( - 'Load previously used custom annotation(s)', - 'Select annotations to load:', items, - additionalButtons=('Delete selected annnotations', ), - parent=self, multiSelection=True + "Load previously used custom annotation(s)", + "Select annotations to load:", + items, + additionalButtons=("Delete selected annnotations",), + parent=self, + multiSelection=True, ) for button in self.selectAnnotWin._additionalButtons: button.disconnect() @@ -458,64 +489,66 @@ def loadCustomAnnotations(self): self.selectAnnotWin.exec_() if self.selectAnnotWin.cancel: return - + for selectedAnnotName in self.selectAnnotWin.selectedItemsText: selectedAnnot = self.savedCustomAnnot[selectedAnnotName] - symbol = selectedAnnot['symbol'] + symbol = selectedAnnot["symbol"] symbol = re.findall(r"\'(.+)\'", symbol)[0] - symbolColor = selectedAnnot['symbolColor'] + symbolColor = selectedAnnot["symbolColor"] symbolColor = pg.mkColor(symbolColor) - keySequence = widgets.KeySequenceFromText(selectedAnnot['shortcut']) - Type = selectedAnnot['type'] + keySequence = widgets.KeySequenceFromText(selectedAnnot["shortcut"]) + Type = selectedAnnot["type"] toolTip = ( - f'Name: {selectedAnnotName}\n\n' - f'Type: {Type}\n\n' - f'Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n' - f'Description: {selectedAnnot["description"]}\n\n' + f"Name: {selectedAnnotName}\n\n" + f"Type: {Type}\n\n" + f"Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n" + f"Description: {selectedAnnot['description']}\n\n" f'Shortcut: "{keySequence}"' ) - keepActive = selectedAnnot['keepActive'] - isHideChecked = selectedAnnot['isHideChecked'] + keepActive = selectedAnnot["keepActive"] + isHideChecked = selectedAnnot["isHideChecked"] state = { - 'type': Type, - 'name': selectedAnnotName, - 'symbol': selectedAnnot['symbol'], - 'shortcut': selectedAnnot['shortcut'], - 'description': selectedAnnot["description"], - 'keepActive': keepActive, - 'isHideChecked': isHideChecked, - 'symbolColor': symbolColor + "type": Type, + "name": selectedAnnotName, + "symbol": selectedAnnot["symbol"], + "shortcut": selectedAnnot["shortcut"], + "description": selectedAnnot["description"], + "keepActive": keepActive, + "isHideChecked": isHideChecked, + "symbolColor": symbolColor, } self.addCustomAnnotationItems( - symbol, symbolColor, keySequence, toolTip, selectedAnnotName, - keepActive, isHideChecked, state + symbol, + symbolColor, + keySequence, + toolTip, + selectedAnnotName, + keepActive, + isHideChecked, + state, ) for pos_i, posData in enumerate(self.data): posData.customAnnot[selectedAnnotName] = selectedAnnot - + self.saveCustomAnnot() def readSavedCustomAnnot(self): tempAnnot = {} if os.path.exists(custom_annot_path): - self.logger.info('Loading saved custom annotations...') - tempAnnot = load.read_json( - custom_annot_path, logger_func=self.logger.info - ) + self.logger.info("Loading saved custom annotations...") + tempAnnot = load.read_json(custom_annot_path, logger_func=self.logger.info) posData = self.data[self.pos_i] self.savedCustomAnnot = tempAnnot for pos_i, posData in enumerate(self.data): - self.savedCustomAnnot = { - **self.savedCustomAnnot, **posData.customAnnot - } + self.savedCustomAnnot = {**self.savedCustomAnnot, **posData.customAnnot} def reinitCustomAnnot(self): buttons = list(self.customAnnotDict.keys()) for button in buttons: self.clearScatterPlotCustomAnnotButton(button) - action = self.customAnnotDict[button]['action'] + action = self.customAnnotDict[button]["action"] self.annotateToolbar.removeAction(action) self.checkableQButtonsGroup.removeButton(button) self.customAnnotDict.pop(button) @@ -531,19 +564,22 @@ def removeCustomAnnotButton(self, button, askHow=True, save=True): only the annotation button?
    """) _, removeOnlyButton, removeColButton = msg.question( - self, 'Remove only button?', txt, + self, + "Remove only button?", + txt, buttonsTexts=( - 'Cancel', 'Remove only button', - ' Remove also column with annotations ' - ) + "Cancel", + "Remove only button", + " Remove also column with annotations ", + ), ) if msg.cancel: return removeOnlyButton = msg.clickedButton == removeOnlyButton else: removeOnlyButton = True - - name = self.customAnnotDict[button]['state']['name'] + + name = self.customAnnotDict[button]["state"]["name"] # remove annotation from position for posData in self.data: try: @@ -555,23 +591,21 @@ def removeCustomAnnotButton(self, button, askHow=True, save=True): if posData.acdc_df is None: continue - + if removeOnlyButton: continue - posData.acdc_df = posData.acdc_df.drop( - columns=name, errors='ignore' - ) + posData.acdc_df = posData.acdc_df.drop(columns=name, errors="ignore") for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] + acdc_df = data_dict["acdc_df"] if acdc_df is None: continue - acdc_df = acdc_df.drop(columns=name, errors='ignore') - posData.allData_li[frame_i]['acdc_df'] = acdc_df + acdc_df = acdc_df.drop(columns=name, errors="ignore") + posData.allData_li[frame_i]["acdc_df"] = acdc_df self.clearScatterPlotCustomAnnotButton(button) - action = self.customAnnotDict[button]['action'] + action = self.customAnnotDict[button]["action"] self.annotateToolbar.removeAction(action) self.checkableQButtonsGroup.removeButton(button) self.customAnnotDict.pop(button) @@ -580,20 +614,20 @@ def removeCustomAnnotButton(self, button, askHow=True, save=True): self.saveCustomAnnot(only_temp=True) def saveCustomAnnot(self, only_temp=False): - if not hasattr(self, 'savedCustomAnnot'): + if not hasattr(self, "savedCustomAnnot"): return if not self.savedCustomAnnot: return # Save to cell acdc temp path - with open(custom_annot_path, mode='w') as file: + with open(custom_annot_path, mode="w") as file: json.dump(self.savedCustomAnnot, file, indent=2) if only_temp: return - - self.logger.info('Saving custom annotations parameters...') + + self.logger.info("Saving custom annotations parameters...") # Save to pos path for _posData in self.data: _posData.saveCustomAnnotationParams() diff --git a/cellacdc/mixins/data_loading.py b/cellacdc/mixins/data_loading.py index fa99d3827..89a1c62cc 100644 --- a/cellacdc/mixins/data_loading.py +++ b/cellacdc/mixins/data_loading.py @@ -40,6 +40,7 @@ from .layout_controls import LayoutControls + class DataLoading(LayoutControls): """Extracted from guiWin.""" @@ -47,34 +48,31 @@ def _createEmptyData(self): self.MostRecentPath = self.getMostRecentPath() exp_path = QFileDialog.getExistingDirectory( self, - 'Select experiment folder where to create empty data', - self.MostRecentPath + "Select experiment folder where to create empty data", + self.MostRecentPath, ) if not exp_path: return - - pos_path = os.path.join(exp_path, 'Position_1') - images_path = os.path.join(pos_path, 'Images') + + pos_path = os.path.join(exp_path, "Position_1") + images_path = os.path.join(pos_path, "Images") if os.path.exists(images_path): raise FileExistsError(f'The following path already exists "{images_path}"') os.makedirs(images_path, exist_ok=True) - - basename = 'test_empty_' - tif_filename = f'{basename}channel_1.tif' + + basename = "test_empty_" + tif_filename = f"{basename}channel_1.tif" tif_filepath = os.path.join(images_path, tif_filename) - empty_img = np.zeros((256,256), dtype=np.uint8) - empty_img[0,0] = 255 + empty_img = np.zeros((256, 256), dtype=np.uint8) + empty_img[0, 0] = 255 skimage.io.imsave(tif_filepath, empty_img) - - metadata_filename = f'{basename}metadata.csv' + + metadata_filename = f"{basename}metadata.csv" metadata_filepath = os.path.join(images_path, metadata_filename) - df_metadata = pd.DataFrame({ - 'Description': ['basename'], - 'values': [basename] - }) + df_metadata = pd.DataFrame({"Description": ["basename"], "values": [basename]}) df_metadata.to_csv(metadata_filepath, index=False) - + self.isNewFile = True self._openFolder(exp_path=images_path) @@ -96,7 +94,7 @@ def _loadFromExperimentFolder(self, exp_path): images_paths = [] for pos in select_folder.selected_pos: - images_paths.append(os.path.join(exp_path, pos, 'Images')) + images_paths.append(os.path.join(exp_path, pos, "Images")) return images_paths def _openFile(self, file_path=None): @@ -106,91 +104,87 @@ def _openFile(self, file_path=None): if file_path is None: self.MostRecentPath = self.getMostRecentPath() file_path = QFileDialog.getOpenFileName( - self, 'Select image file', self.MostRecentPath, + self, + "Select image file", + self.MostRecentPath, "Image/Video Files (*.png *.tif *.tiff *.jpg *.jpeg *.mov *.avi *.mp4)" - ";;All Files (*)")[0] + ";;All Files (*)", + )[0] if not file_path: return - + filename, ext = os.path.splitext(os.path.basename(file_path)) ext = ext.lower() dirpath = os.path.dirname(file_path) dirname = os.path.basename(dirpath) - filename = filename.rstrip('_') + filename = filename.rstrip("_") channel_name = None do_copy = True - if dirname != 'Images': - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - acdc_folder = f'{timestamp}_acdc' - exp_path = os.path.join(dirpath, acdc_folder, 'Images') + if dirname != "Images": + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + acdc_folder = f"{timestamp}_acdc" + exp_path = os.path.join(dirpath, acdc_folder, "Images") proceed, do_copy = self.warnUserCreationImagesFolder(exp_path, ext) if not proceed: - self.logger.info('Loading image file cancelled.') + self.logger.info("Loading image file cancelled.") return - - proceed, channel_name = self.askUserChannelName( - filename, '.tif' - ) + + proceed, channel_name = self.askUserChannelName(filename, ".tif") if not proceed: - self.logger.info('Loading image file cancelled.') + self.logger.info("Loading image file cancelled.") return - + os.makedirs(exp_path, exist_ok=True) else: exp_path = dirpath if channel_name is not None: # Check if user wants to use the existing channel name - underscore_splits = filename.split('_') + underscore_splits = filename.split("_") if len(underscore_splits) > 1: default_ch_name = underscore_splits[-1] if channel_name == default_ch_name: - filename = '_'.join(underscore_splits[:-1]) - - basename = f'{filename}_' - new_filename = f'{filename}_{channel_name}{ext}' - df_metadata = pd.DataFrame({ - 'Description': ['basename'], - 'values': [basename] - }) - metadata_csv_filename = f'{basename}metadata.csv' - metadata_csv_filepath = os.path.join( - exp_path, metadata_csv_filename + filename = "_".join(underscore_splits[:-1]) + + basename = f"{filename}_" + new_filename = f"{filename}_{channel_name}{ext}" + df_metadata = pd.DataFrame( + {"Description": ["basename"], "values": [basename]} ) + metadata_csv_filename = f"{basename}metadata.csv" + metadata_csv_filepath = os.path.join(exp_path, metadata_csv_filename) df_metadata.to_csv(metadata_csv_filepath, index=False) else: - new_filename = f'{filename}{ext}' - + new_filename = f"{filename}{ext}" + if do_copy: - action_text = 'Copying' + action_text = "Copying" else: - action_text = 'Moving' - - if ext == '.tif' or ext == '.npz': + action_text = "Moving" + + if ext == ".tif" or ext == ".npz": new_filepath = os.path.join(exp_path, new_filename) if not os.path.exists(new_filepath): - self.logger.info(f'{action_text} file to Images folder...') + self.logger.info(f"{action_text} file to Images folder...") if do_copy: shutil.copy2(file_path, new_filepath) else: shutil.move(file_path, new_filepath) self._openFolder(exp_path=exp_path, imageFilePath=new_filepath) else: - self.logger.info(f'{action_text} file to .tif format...') - data = load.loadData(file_path, '', log_func=self.logger.info) + self.logger.info(f"{action_text} file to .tif format...") + data = load.loadData(file_path, "", log_func=self.logger.info) data.loadImgData() img = data.img_data if img.ndim == 3 and (img.shape[-1] == 3 or img.shape[-1] == 4): - self.logger.info('Converting RGB image to grayscale...') + self.logger.info("Converting RGB image to grayscale...") if img.shape[-1] == 3: data.img_data = skimage.color.rgb2gray(data.img_data) else: - data.img_data = cv2.cvtColor( - data.img_data, cv2.COLOR_RGBA2GRAY - ) + data.img_data = cv2.cvtColor(data.img_data, cv2.COLOR_RGBA2GRAY) data.img_data = skimage.img_as_ubyte(data.img_data) new_filename_no_ext, ext = os.path.splitext(new_filename) - tif_filename = f'{new_filename_no_ext}.tif' + tif_filename = f"{new_filename_no_ext}.tif" tif_path = os.path.join(exp_path, tif_filename) if data.img_data.ndim == 3: SizeT = data.img_data.shape[0] @@ -213,9 +207,7 @@ def _openFile(self, file_path=None): myutils.to_tiff(tif_path, data.img_data) self._openFolder(exp_path=exp_path, imageFilePath=tif_path) - def _openFolder( - self, checked=False, exp_path=None, imageFilePath='' - ): + def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): """Main function to load data. Parameters @@ -238,9 +230,9 @@ def _openFolder( self.MostRecentPath = self.getMostRecentPath() exp_path = QFileDialog.getExistingDirectory( self, - 'Select experiment folder containing Position_n folders ' - 'or specific Position_n folder', - self.MostRecentPath + "Select experiment folder containing Position_n folders " + "or specific Position_n folder", + self.MostRecentPath, ) if not exp_path: @@ -261,18 +253,18 @@ def _openFolder( self.ccaTableWin.close() self.exp_path = exp_path - self.logger.info(f'Loading from {self.exp_path}') + self.logger.info(f"Loading from {self.exp_path}") self.addToRecentPaths(exp_path, logger=self.logger) self.addPathToOpenRecentMenu(exp_path) folder_type = myutils.determine_folder_type(exp_path) is_pos_folder, is_images_folder, exp_path = folder_type - self.titleLabel.setText('Loading data...', color=self.titleColor) + self.titleLabel.setText("Loading data...", color=self.titleColor) skip_channels = [] ch_name_selector = prompts.select_channel_name( - which_channel='segm', allow_abort=False + which_channel="segm", allow_abort=False ) user_ch_name = None if not is_pos_folder and not is_images_folder and not imageFilePath: @@ -284,23 +276,23 @@ def _openFolder( elif is_pos_folder and not imageFilePath: pos_foldername = os.path.basename(exp_path) exp_path = os.path.dirname(exp_path) - images_paths = [os.path.join(exp_path, pos_foldername, 'Images')] + images_paths = [os.path.join(exp_path, pos_foldername, "Images")] elif is_images_folder and not imageFilePath: images_paths = [exp_path] pos_path = os.path.dirname(exp_path) exp_path = os.path.dirname(pos_path) - + elif imageFilePath: # images_path = exp_path because called by openFile func filenames = myutils.listdir(exp_path) - ch_names, basenameNotFound = ( - ch_name_selector.get_available_channels(filenames, exp_path) + ch_names, basenameNotFound = ch_name_selector.get_available_channels( + filenames, exp_path ) filename = os.path.basename(imageFilePath) self.ch_names = ch_names user_ch_name = [ - chName for chName in ch_names if filename.find(chName)!=-1 + chName for chName in ch_names if filename.find(chName) != -1 ][0] images_paths = [exp_path] pos_path = os.path.dirname(exp_path) @@ -321,16 +313,14 @@ def _openFolder( self.criticalNoTifFound(images_path) return if len(ch_names) > 1: - CbLabel='Select channel name to load: ' - ch_name_selector.QtPrompt( - self, ch_names, CbLabel=CbLabel - ) + CbLabel = "Select channel name to load: " + ch_name_selector.QtPrompt(self, ch_names, CbLabel=CbLabel) if ch_name_selector.was_aborted: self.openFolderAction.setEnabled(True) return - skip_channels.extend([ - ch for ch in ch_names if ch!=ch_name_selector.channel_name - ]) + skip_channels.extend( + [ch for ch in ch_names if ch != ch_name_selector.channel_name] + ) else: ch_name_selector.channel_name = ch_names[0] ch_name_selector.setUserChannelName() @@ -340,11 +330,14 @@ def _openFolder( ch_name_selector.channel_name = user_ch_name user_ch_file_paths = [] - not_allowed_ends = ['btrack_tracks.h5'] + not_allowed_ends = ["btrack_tracks.h5"] for images_path in self.images_paths: channel_file_path = load.get_filename_from_channel( - images_path, user_ch_name, skip_channels=skip_channels, - not_allowed_ends=not_allowed_ends, logger=self.logger.info + images_path, + user_ch_name, + skip_channels=skip_channels, + not_allowed_ends=not_allowed_ends, + logger=self.logger.info, ) if not channel_file_path: self.criticalImgPathNotFound(images_path) @@ -363,7 +356,7 @@ def _openFolder( self.gui_createOverlayColors() self.gui_createOverlayItems() lastRow = self.bottomLeftLayout.rowCount() - self.bottomLeftLayout.setRowStretch(lastRow+1, 1) + self.bottomLeftLayout.setRowStretch(lastRow + 1, 1) self.num_pos = len(user_ch_file_paths) proceed = self.loadSelectedData(user_ch_file_paths, user_ch_name) @@ -379,11 +372,11 @@ def addToRecentPaths(self, path, logger=None): def askMismatchSegmDataShape(self, posData): msg = widgets.myMessageBox(wrapText=False) - title = 'Segm. data shape mismatch' - f = '3D' if self.isSegm3D else '2D' - f = f'{f} over time' if posData.SizeT > 1 else f - r = '2D' if self.isSegm3D else '3D' - r = f'{r} over time' if posData.SizeT > 1 else r + title = "Segm. data shape mismatch" + f = "3D" if self.isSegm3D else "2D" + f = f"{f} over time" if posData.SizeT > 1 else f + r = "2D" if self.isSegm3D else "3D" + r = f"{r} over time" if posData.SizeT > 1 else r text = html_utils.paragraph(f""" The segmentation masks of the first Position that you loaded is {f},
    @@ -393,27 +386,25 @@ def askMismatchSegmDataShape(self, posData): Do you want to skip loading this position or cancel the process? """) _, skipPosButton = msg.warning( - self, title, text, buttonsTexts=('Cancel', 'Skip this Position') + self, title, text, buttonsTexts=("Cancel", "Skip this Position") ) if skipPosButton == msg.clickedButton: self.loadDataWorker.skipPos = True self.loadDataWorker.waitCond.wakeAll() def askRecoverNotSavedData(self, posData): - last_modified_time_unsaved = 'NEVER' + last_modified_time_unsaved = "NEVER" if os.path.exists(posData.segm_npz_temp_path): recovered_file_path = posData.segm_npz_temp_path if os.path.exists(posData.segm_npz_path): - last_modified_time_unsaved = ( - datetime.fromtimestamp( - os.path.getmtime(posData.segm_npz_path) - ).strftime("%a %d. %b. %y - %H:%M:%S") - ) + last_modified_time_unsaved = datetime.fromtimestamp( + os.path.getmtime(posData.segm_npz_path) + ).strftime("%a %d. %b. %y - %H:%M:%S") else: posData.setTempPaths() if os.path.exists(posData.unsaved_acdc_df_autosave_path): zip_path = posData.unsaved_acdc_df_autosave_path - with zipfile.ZipFile(zip_path, mode='r') as zip: + with zipfile.ZipFile(zip_path, mode="r") as zip: csv_names = natsorted(set(zip.namelist())) iso_key = csv_names[-1][:-4] most_recent_unsaved_acdc_df_datetime = datetime.strptime( @@ -422,45 +413,47 @@ def askRecoverNotSavedData(self, posData): last_modified_time_unsaved = ( most_recent_unsaved_acdc_df_datetime ).strftime("%a %d. %b. %y - %H:%M:%S") - + if os.path.exists(posData.acdc_output_csv_path): acdc_df_mtime = os.path.getmtime(posData.acdc_output_csv_path) timestamp = datetime.fromtimestamp(acdc_df_mtime) - last_modified_time_saved = timestamp.strftime( - "%a %d. %b. %y - %H:%M:%S" - ) + last_modified_time_saved = timestamp.strftime("%a %d. %b. %y - %H:%M:%S") else: - last_modified_time_saved = 'Null' - + last_modified_time_saved = "Null" + msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph(""" Cell-ACDC detected unsaved data.

    Do you want to load and recover the unsaved data or load the data that was last saved by the user? """) - details = (f""" + details = f""" The unsaved data was created on {last_modified_time_unsaved}\n\n The user saved the data last time on {last_modified_time_saved} - """) + """ msg.setDetailedText(details) - loadUnsavedButton = widgets.reloadPushButton('Recover unsaved data') - loadSavedButton = widgets.savePushButton('Load saved data') - infoButton = widgets.infoPushButton('More info...') - loadSafeNpzButton = '' + loadUnsavedButton = widgets.reloadPushButton("Recover unsaved data") + loadSavedButton = widgets.savePushButton("Load saved data") + infoButton = widgets.infoPushButton("More info...") + loadSafeNpzButton = "" if posData.isSafeNpzOverwritePresent(): loadSafeNpzButton = widgets.reloadPushButton( - 'Load .safe.npz file from crash' + "Load .safe.npz file from crash" ) buttons = ( - loadSavedButton, loadUnsavedButton, loadSafeNpzButton, - infoButton + loadSavedButton, + loadUnsavedButton, + loadSafeNpzButton, + infoButton, ) else: buttons = (loadSavedButton, loadUnsavedButton, infoButton) msg.question( - self.progressWin, 'Recover unsaved data?', txt, - buttonsTexts=('Cancel', *buttons), - showDialog=False + self.progressWin, + "Recover unsaved data?", + txt, + buttonsTexts=("Cancel", *buttons), + showDialog=False, ) infoButton.disconnect() infoButton.clicked.connect(partial(self.showInfoAutosave, posData)) @@ -471,7 +464,7 @@ def askRecoverNotSavedData(self, posData): self.loadDataWorker.loadUnsaved = True elif msg.clickedButton == loadSafeNpzButton: self.loadDataWorker.loadSafeOverwriteNpz = True - + self.loadDataWorker.waitCond.wakeAll() def askUserChannelName(self, filename_no_ext, ext): @@ -482,13 +475,13 @@ def askUserChannelName(self, filename_no_ext, ext): """) basename = filename_no_ext - underscore_splits = filename_no_ext.split('_') + underscore_splits = filename_no_ext.split("_") if len(underscore_splits) > 1: channel_name = underscore_splits[-1] - basename = '_'.join(underscore_splits[:-1]) + basename = "_".join(underscore_splits[:-1]) else: - channel_name = 'channel_1' - + channel_name = "channel_1" + txt = html_utils.paragraph(f""" Provide some text (e.g., the channel name) to append at the end of the image file. """) @@ -497,14 +490,14 @@ def askUserChannelName(self, filename_no_ext, ext): ext=ext, hintText=txt, defaultEntry=channel_name, - helpText=help_txt, + helpText=help_txt, allowEmpty=False, parent=self, - title='Provide channel name for image file', + title="Provide channel name for image file", ) win.exec_() if win.cancel: - return False, '' + return False, "" return True, win.entryText @@ -512,12 +505,12 @@ def checkManageVersions(self): posData = self.data[self.pos_i] posData.setTempPaths(createFolder=False) loaded_acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) - + if os.path.exists(posData.recoveryFolderpath()): self.manageVersionsAction.setDisabled(False) self.manageVersionsAction.setToolTip( - f'Load an older version of the `{loaded_acdc_df_filename}` file ' - '(table with annotations and measurements).' + f"Load an older version of the `{loaded_acdc_df_filename}` file " + "(table with annotations and measurements)." ) else: self.manageVersionsAction.setDisabled(True) @@ -526,7 +519,7 @@ def checkMemoryRequirements(self, required_ram): memory = psutil.virtual_memory() total_ram = memory.total available_ram = memory.available - if required_ram/available_ram > 0.3: + if required_ram / available_ram > 0.3: proceed = self.warnMemoryNotSufficient( total_ram, available_ram, required_ram ) @@ -537,31 +530,26 @@ def checkMemoryRequirements(self, required_ram): def criticalFluoChannelNotFound(self, fluo_ch, posData): msg = widgets.myMessageBox(showCentered=False) ls = "\n".join(myutils.listdir(posData.images_path)) - msg.setDetailedText( - f'Files present in the {posData.relPath} folder:\n' - f'{ls}' - ) - title = 'Requested channel data not found!' + msg.setDetailedText(f"Files present in the {posData.relPath} folder:\n{ls}") + title = "Requested channel data not found!" txt = html_utils.paragraph( - f'The folder {posData.pos_path} ' - 'does not contain ' - 'either one of the following files:

    ' - f'{posData.basename}{fluo_ch}.tif
    ' - f'{posData.basename}{fluo_ch}_aligned.npz

    ' - 'Data loading aborted.' + f"The folder {posData.pos_path} " + "does not contain " + "either one of the following files:

    " + f"{posData.basename}{fluo_ch}.tif
    " + f"{posData.basename}{fluo_ch}_aligned.npz

    " + "Data loading aborted." ) msg.addShowInFileManagerButton(posData.images_path) - okButton = msg.warning( - self, title, txt, buttonsTexts=('Ok') - ) + okButton = msg.warning(self, title, txt, buttonsTexts=("Ok")) def criticalImgPathNotFound(self, images_path): self.logger.info( - 'The following folder does not contain valid image files: ' + "The following folder does not contain valid image files: " f'"{images_path}"\n\n' - 'Check that all the positions loaded contain the same channel name. ' - 'Make sure to double check for spelling mistakes or types in the ' - 'channel names.' + "Check that all the positions loaded contain the same channel name. " + "Make sure to double check for spelling mistakes or types in the " + "channel names." ) msg = widgets.myMessageBox() msg.addShowInFileManagerButton(images_path) @@ -572,11 +560,11 @@ def criticalImgPathNotFound(self, images_path): Valid file formats are .h5, .tif, _aligned.h5, _aligned.npz. """) okButton = msg.critical( - self, 'No valid files found!', err_msg, buttonsTexts=('Ok',) + self, "No valid files found!", err_msg, buttonsTexts=("Ok",) ) def criticalInvalidPosFolder(self, exp_path): - href = html_utils.href_tag('here', data_structure_docs_url) + href = html_utils.href_tag("here", data_structure_docs_url) txt = html_utils.paragraph(f""" The selected folder:

    @@ -597,43 +585,37 @@ def criticalInvalidPosFolder(self, exp_path): For more information about the correct folder structure see {href}. """) msg = widgets.myMessageBox(wrapText=False) - helpButton = widgets.helpPushButton('Help...') + helpButton = widgets.helpPushButton("Help...") msg.addButton(helpButton) helpButton.clicked.disconnect() - helpButton.clicked.connect( - partial(myutils.browse_url, data_structure_docs_url) - ) + helpButton.clicked.connect(partial(myutils.browse_url, data_structure_docs_url)) msg.addShowInFileManagerButton(exp_path) - msg.critical( - self, 'Incompatible folder', txt - ) + msg.critical(self, "Incompatible folder", txt) def criticalNoTifFound(self, images_path): - err_title = 'No .tif files found in folder.' + err_title = "No .tif files found in folder." err_msg = html_utils.paragraph( - 'The following folder

    ' - f'{images_path}

    ' - 'does not contain .tif or .h5 files.

    ' + "The following folder

    " + f"{images_path}

    " + "does not contain .tif or .h5 files.

    " 'Only .tif or .h5 files can be loaded with "Open Folder" button.

    ' - 'Try with File --> Open image/video file... ' - 'and directly select the file you want to load.' + "Try with File --> Open image/video file... " + "and directly select the file you want to load." ) msg = widgets.myMessageBox() msg.addShowInFileManagerButton(images_path) msg.critical(self, err_title, err_msg) def getFileExtensions(self, images_path): - alignedFound = any([f.find('_aligned.np')!=-1 - for f in myutils.listdir(images_path)]) + alignedFound = any( + [f.find("_aligned.np") != -1 for f in myutils.listdir(images_path)] + ) if alignedFound: extensions = ( - 'Aligned channels (*npz *npy);; Tif channels(*tiff *tif)' - ';;All Files (*)' + "Aligned channels (*npz *npy);; Tif channels(*tiff *tif);;All Files (*)" ) else: - extensions = ( - 'Tif channels(*tiff *tif);; All Files (*)' - ) + extensions = "Tif channels(*tiff *tif);; All Files (*)" return extensions def getMostRecentPath(self): @@ -641,12 +623,13 @@ def getMostRecentPath(self): def getPathFromChName(self, chName, posData): ls = myutils.listdir(posData.images_path) - endnames = {f[len(posData.basename):]:f for f in ls} - validEnds = ['_aligned.npz', '_aligned.h5', '.h5', '.tif', '.npz'] + endnames = {f[len(posData.basename) :]: f for f in ls} + validEnds = ["_aligned.npz", "_aligned.h5", ".h5", ".tif", ".npz"] for end in validEnds: files = [ - filename for endname, filename in endnames.items() - if endname == f'{chName}{end}' + filename + for endname, filename in endnames.items() + if endname == f"{chName}{end}" ] if files: filename = files[0] @@ -672,47 +655,47 @@ def helpNewFile(self): More info about Position folders in the {href} at the section called "Create required data structure from microscopy file(s)". """) - msg.information( - self, 'Help on Position folders', txt - ) + msg.information(self, "Help on Position folders", txt) def initFluoData(self): if len(self.ch_names) <= 1: return - - if 'ask_load_fluo_at_init' in self.df_settings.index: - if self.df_settings.at['ask_load_fluo_at_init', 'value'] == 'No': - return + + if "ask_load_fluo_at_init" in self.df_settings.index: + if self.df_settings.at["ask_load_fluo_at_init", "value"] == "No": + return msg = widgets.myMessageBox(allowClose=False) txt = ( - 'Do you also want to load fluorescence images?
    ' - 'You can load as many channels as you want.

    ' - 'If you load fluorescence images then the software will ' - 'calculate metrics for each loaded fluorescence channel ' - 'such as min, max, mean, quantiles, etc. ' - 'of each segmented object.

    ' - 'NOTE: You can always load them later from the menu ' - 'File --> Load fluorescence images... or when you set ' - 'measurements from the menu ' - 'Measurements --> Set measurements...' + "Do you also want to load fluorescence images?
    " + "You can load as many channels as you want.

    " + "If you load fluorescence images then the software will " + "calculate metrics for each loaded fluorescence channel " + "such as min, max, mean, quantiles, etc. " + "of each segmented object.

    " + "NOTE: You can always load them later from the menu " + "File --> Load fluorescence images... or when you set " + "measurements from the menu " + "Measurements --> Set measurements..." ) msg.addDoNotShowAgainCheckbox(text="Don't ask again") no, yes = msg.question( - self, 'Load fluorescence images?', html_utils.paragraph(txt), - buttonsTexts=('No', 'Yes') + self, + "Load fluorescence images?", + html_utils.paragraph(txt), + buttonsTexts=("No", "Yes"), ) if msg.doNotShowAgainCheckbox.isChecked(): - self.df_settings.at['ask_load_fluo_at_init', 'value'] = 'No' + self.df_settings.at["ask_load_fluo_at_init", "value"] = "No" self.df_settings.to_csv(self.settings_csv_path) if msg.clickedButton == yes: self.loadFluo_cb(None) self.AutoPilotProfile.storeClickMessageBox( - 'Load fluorescence images?', msg.clickedButton.text() + "Load fluorescence images?", msg.clickedButton.text() ) def loadDataWorkerDataIntegrityCritical(self): - errTitle = 'All loaded positions contains frames over time!' - self.titleLabel.setText(errTitle, color='r') + errTitle = "All loaded positions contains frames over time!" + self.titleLabel.setText(errTitle, color="r") msg = widgets.myMessageBox(parent=self) @@ -721,19 +704,19 @@ def loadDataWorkerDataIntegrityCritical(self): To load data that contains frames over time you have to select only ONE position. """) - msg.setIcon(iconName='SP_MessageBoxCritical') - msg.setWindowTitle('Loaded multiple positions with frames!') + msg.setIcon(iconName="SP_MessageBoxCritical") + msg.setWindowTitle("Loaded multiple positions with frames!") msg.addText(err_msg) - msg.addButton('Ok') + msg.addButton("Ok") msg.show(block=True) def loadDataWorkerDataIntegrityWarning(self, pos_foldername): err_msg = ( 'WARNING: Segmentation mask file ("..._segm.npz") not found. ' - 'You could run segmentation module first.' + "You could run segmentation module first." ) - self.workerProgress(err_msg, 'INFO') - self.titleLabel.setText(err_msg, color='r') + self.workerProgress(err_msg, "INFO") + self.titleLabel.setText(err_msg, color="r") abort = False msg = widgets.myMessageBox(parent=self) warn_msg = html_utils.paragraph(f""" @@ -743,11 +726,11 @@ def loadDataWorkerDataIntegrityWarning(self, pos_foldername): pre-compute the mask with the segmentation module.

    Do you want to continue? """) - msg.setIcon(iconName='SP_MessageBoxWarning') - msg.setWindowTitle('Segmentation file not found') + msg.setIcon(iconName="SP_MessageBoxWarning") + msg.setWindowTitle("Segmentation file not found") msg.addText(warn_msg) - msg.addButton('Ok') - continueWithBlankSegm = msg.addButton(' Cancel ') + msg.addButton("Ok") + continueWithBlankSegm = msg.addButton(" Cancel ") msg.show(block=True) if continueWithBlankSegm == msg.clickedButton: abort = True @@ -755,16 +738,16 @@ def loadDataWorkerDataIntegrityWarning(self, pos_foldername): self.loadDataWaitCond.wakeAll() def loadDataWorkerFinished(self, data): - self.funcDescription = 'loading data worker finished' + self.funcDescription = "loading data worker finished" if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - if data is None or data=='abort': + if data is None or data == "abort": self.loadingDataAborted() return - + if data[0].onlyEditMetadata: self.loadingDataAborted() return @@ -778,22 +761,25 @@ def loadFluo_cb(self, checked=True, fluo_channels=None): if fluo_channels is None: posData = self.data[self.pos_i] ch_names = [ - ch for ch in self.ch_names if ch != self.user_ch_name - and ch not in posData.loadedFluoChannels + ch + for ch in self.ch_names + if ch != self.user_ch_name and ch not in posData.loadedFluoChannels ] if not ch_names: msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'You already loaded ALL channels.

    ' - 'To change the overlaid channel ' - 'right-click on the overlay button.' + "You already loaded ALL channels.

    " + "To change the overlaid channel " + "right-click on the overlay button." ) - msg.information(self, 'All channels are loaded', txt) + msg.information(self, "All channels are loaded", txt) return False selectFluo = widgets.QDialogListbox( - 'Select channel to load', - 'Select channel names to load:\n', - ch_names, multiSelection=True, parent=self + "Select channel to load", + "Select channel names to load:\n", + ch_names, + multiSelection=True, + parent=self, ) selectFluo.exec_() @@ -821,26 +807,27 @@ def loadFluo_cb(self, checked=True, fluo_channels=None): posData.fluo_data_dict[filename] = fluo_data posData.fluo_bkgrData_dict[filename] = bkgrData posData.ol_data_dict[filename] = fluo_data.copy() - - self.overlayButton.setStyleSheet(f'background-color: {GREEN_HEX}') - self.guiTabControl.addChannels([ - posData.user_ch_name, *posData.loadedFluoChannels - ]) + + self.overlayButton.setStyleSheet(f"background-color: {GREEN_HEX}") + self.guiTabControl.addChannels( + [posData.user_ch_name, *posData.loadedFluoChannels] + ) return True def loadNonAlignedFluoChannel(self, fluo_path): posData = self.data[self.pos_i] - if posData.filename.find('aligned') != -1: + if posData.filename.find("aligned") != -1: filename, _ = os.path.splitext(os.path.basename(fluo_path)) - path = f'.../{posData.pos_foldername}/Images/{filename}_aligned.npz' + path = f".../{posData.pos_foldername}/Images/{filename}_aligned.npz" msg = widgets.myMessageBox() msg.critical( - self, 'Aligned fluo channel not found!', - 'Aligned data for fluorescence channel not found!\n\n' - f'You loaded aligned data for the cells channel, therefore ' - 'loading NON-aligned fluorescence data is not allowed.\n\n' + self, + "Aligned fluo channel not found!", + "Aligned data for fluorescence channel not found!\n\n" + f"You loaded aligned data for the cells channel, therefore " + "loading NON-aligned fluorescence data is not allowed.\n\n" 'Run the script "dataPrep.py" to create the following file:\n\n' - f'{path}' + f"{path}", ) return None fluo_data = np.squeeze(skimage.io.imread(fluo_path)) @@ -849,21 +836,23 @@ def loadNonAlignedFluoChannel(self, fluo_path): def loadPosTriggered(self): if not self.isDataLoaded: return - + self.startAutomaticLoadingPos() def loadSelectedData(self, user_ch_file_paths, user_ch_name): data = [] numPos = len(user_ch_file_paths) self.user_ch_file_paths = user_ch_file_paths - - self.logger.info(f'Reading {user_ch_name} channel metadata...') + + self.logger.info(f"Reading {user_ch_name} channel metadata...") # Get information from first loaded position - posData = load.loadData(user_ch_file_paths[0], user_ch_name, log_func=self.logger.info) + posData = load.loadData( + user_ch_file_paths[0], user_ch_name, log_func=self.logger.info + ) posData.getBasenameAndChNames(qparent=self) posData.buildPaths() - if posData.ext != '.h5': + if posData.ext != ".h5": self.lazyLoader.salute = False self.lazyLoader.exit = True self.lazyLoaderWaitCond.wakeAll() @@ -875,30 +864,28 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): _posData = load.loadData(filePath, user_ch_name, log_func=self.logger.info) _posData.getBasenameAndChNames(qparent=self) segm_files = load.get_segm_files(_posData.images_path) - _existingEndnames = load.get_endnames( - _posData.basename, segm_files - ) + _existingEndnames = load.get_endnames(_posData.basename, segm_files) existingSegmEndNames.update(_existingEndnames) - selectedSegmEndName = '' - self.newSegmEndName = '' + selectedSegmEndName = "" + self.newSegmEndName = "" if self.isNewFile or not existingSegmEndNames: self.isNewFile = True # Remove the 'segm_' part to allow filenameDialog to check if # a new file is existing (since we only ask for the part after # 'segm_') existingEndNames = [ - n.replace('segm', '', 1).replace('_', '', 1) + n.replace("segm", "", 1).replace("_", "", 1) for n in existingSegmEndNames ] - if posData.basename.endswith('_'): - basename = f'{posData.basename}segm' + if posData.basename.endswith("_"): + basename = f"{posData.basename}segm" else: - basename = f'{posData.basename}_segm' + basename = f"{posData.basename}_segm" win = apps.filenameDialog( basename=basename, - hintText='Insert a filename for the segmentation file:', - existingNames=existingEndNames + hintText="Insert a filename for the segmentation file:", + existingNames=existingEndNames, ) win.exec_() if win.cancel: @@ -908,8 +895,11 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): else: if len(existingSegmEndNames) > 0: win = apps.SelectSegmFileDialog( - existingSegmEndNames, self.exp_path, parent=self, - addNewFileButton=True, basename=posData.basename + existingSegmEndNames, + self.exp_path, + parent=self, + addNewFileButton=True, + basename=posData.basename, ) win.exec_() if win.cancel: @@ -917,9 +907,7 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): return if win.newSegmEndName is None: selectedSegmEndName = win.selectedItemText - self.AutoPilotProfile.storeSelectedSegmFile( - selectedSegmEndName - ) + self.AutoPilotProfile.storeSelectedSegmFile(selectedSegmEndName) else: self.newSegmEndName = win.newSegmEndName self.isNewFile = True @@ -927,7 +915,7 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): selectedSegmEndName = list(existingSegmEndNames)[0] posData.loadImgData() - + required_ram = posData.getBytesImageData() if required_ram >= 5e8: # Disable autosave for data > 500MB @@ -937,7 +925,7 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): if not proceed: self.loadingDataAborted() return - + posData.loadOtherFiles( load_segm_data=True, load_metadata=True, @@ -949,24 +937,22 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): self.labelBoolSegm = posData.labelBoolSegm posData.labelSegmData() - print('') - self.logger.info( - f'Segmentation filename: {posData.segm_npz_path}' - ) + print("") + self.logger.info(f"Segmentation filename: {posData.segm_npz_path}") proceed = posData.askInputMetadata( self.num_pos, - ask_SizeT=self.num_pos==1, + ask_SizeT=self.num_pos == 1, ask_TimeIncrement=True, ask_PhysicalSizes=True, singlePos=False, - save=True, - warnMultiPos=True + save=True, + warnMultiPos=True, ) if not proceed: self.loadingDataAborted() return - + self.AutoPilotProfile.storeOkAskInputMetadata() if posData.isSegm3D is None: @@ -992,13 +978,11 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): self.createOverlayLabelsItems(existingSegmEndNames) self.disableNonFunctionalButtons() - self.isH5chunk = ( - posData.ext == '.h5' - and (self.loadSizeT != self.SizeT - or self.loadSizeZ != self.SizeZ) + self.isH5chunk = posData.ext == ".h5" and ( + self.loadSizeT != self.SizeT or self.loadSizeZ != self.SizeZ ) - required_ram = posData.checkH5memoryFootprint()*self.loadSizeS + required_ram = posData.checkH5memoryFootprint() * self.loadSizeS if required_ram > 0: proceed = self.checkMemoryRequirements(required_ram) if not proceed: @@ -1011,17 +995,16 @@ def loadSelectedData(self, user_ch_file_paths, user_ch_name): self.isSnapshot = False self.progressWin = apps.QDialogWorkerProgress( - title='Loading data...', parent=self, - pbarDesc=f'Loading "{user_ch_file_paths[0]}"...' + title="Loading data...", + parent=self, + pbarDesc=f'Loading "{user_ch_file_paths[0]}"...', ) self.progressWin.show(self.app) func = partial( - self.startLoadDataWorker, user_ch_file_paths, user_ch_name, - posData + self.startLoadDataWorker, user_ch_file_paths, user_ch_name, posData ) - QTimer.singleShot(150, func) def load_fluo_data(self, fluo_path, isGuiThread=True): @@ -1031,28 +1014,28 @@ def load_fluo_data(self, fluo_path, isGuiThread=True): # Load overlay frames and align if needed filename = os.path.basename(fluo_path) filename_noEXT, ext = os.path.splitext(filename) - if ext == '.npy' or ext == '.npz': + if ext == ".npy" or ext == ".npz": fluo_data = np.load(fluo_path) try: - fluo_data = np.squeeze(fluo_data['arr_0']) + fluo_data = np.squeeze(fluo_data["arr_0"]) except Exception as e: fluo_data = np.squeeze(fluo_data) # Load background data bkgrData_path = os.path.join( - posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' + posData.images_path, f"{filename_noEXT}_bkgrRoiData.npz" ) if os.path.exists(bkgrData_path): bkgrData = np.load(bkgrData_path) - elif ext == '.tif' or ext == '.tiff': - aligned_filename = f'{filename_noEXT}_aligned.npz' + elif ext == ".tif" or ext == ".tiff": + aligned_filename = f"{filename_noEXT}_aligned.npz" aligned_path = os.path.join(posData.images_path, aligned_filename) if os.path.exists(aligned_path): - fluo_data = np.load(aligned_path)['arr_0'] + fluo_data = np.load(aligned_path)["arr_0"] # Load background data bkgrData_path = os.path.join( - posData.images_path, f'{aligned_filename}_bkgrRoiData.npz' + posData.images_path, f"{aligned_filename}_bkgrRoiData.npz" ) if os.path.exists(bkgrData_path): bkgrData = np.load(bkgrData_path) @@ -1063,38 +1046,38 @@ def load_fluo_data(self, fluo_path, isGuiThread=True): # Load background data bkgrData_path = os.path.join( - posData.images_path, f'{filename_noEXT}_bkgrRoiData.npz' + posData.images_path, f"{filename_noEXT}_bkgrRoiData.npz" ) if os.path.exists(bkgrData_path): bkgrData = np.load(bkgrData_path) elif isGuiThread: txt = html_utils.paragraph( - f'File format {ext} is not supported!\n' - 'Choose either .tif or .npz files.' + f"File format {ext} is not supported!\n" + "Choose either .tif or .npz files." ) msg = widgets.myMessageBox() - msg.critical(self, 'File not supported', txt) + msg.critical(self, "File not supported", txt) return None, None return fluo_data, bkgrData def loadingDataAborted(self): self.openFolderAction.setEnabled(True) - self.titleLabel.setText('Loading data aborted.') + self.titleLabel.setText("Loading data aborted.") def loadingDataCompleted(self): self.isDataLoading = True posData = self.data[self.pos_i] - - files_format = '\n'.join([ - f' - {file}' for file in posData.images_folder_files - ]) - sep = '-'*100 + + files_format = "\n".join( + [f" - {file}" for file in posData.images_folder_files] + ) + sep = "-" * 100 self.logger.info( - f'{sep}\nFiles present in the first Position folder loaded:\n\n' - f'{files_format}\n{sep}' + f"{sep}\nFiles present in the first Position folder loaded:\n\n" + f"{files_format}\n{sep}" ) - self.logger.info(f'Basename of the first Position: {posData.basename}') + self.logger.info(f"Basename of the first Position: {posData.basename}") self.secondLevelToolbar.setVisible(True) self.updateImageValueFormatter() self.checkManageVersions() @@ -1104,15 +1087,15 @@ def loadingDataCompleted(self): self.setWindowTitle( f'Cell-ACDC v{self._acdc_version} - GUI - "{posData.exp_path}"' ) - + self.setupPreprocessing() self.setupCombiningChannels() if self.isSegm3D: - self.segmNdimIndicator.setText('3D') + self.segmNdimIndicator.setText("3D") else: - self.segmNdimIndicator.setText('2D') - + self.segmNdimIndicator.setText("2D") + self.segmNdimIndicatorAction.setVisible(True) self.guiTabControl.addChannels([posData.user_ch_name]) @@ -1123,53 +1106,45 @@ def loadingDataCompleted(self): self.init_segmInfo_df() self.connectScrollbars() self.initPosAttr() - - self.logger.info('Pre-computing min and max values of the images...') + + self.logger.info("Pre-computing min and max values of the images...") self.img1.preComputedMinMaxValues(self.data) self.img2.minMaxValuesMapper = self.img1.minMaxValuesMapper - + self.initMetrics() self.initFluoData() self.createChannelNamesActions() self.addActionsLutItemContextMenu(self.imgGrad) - + # Scrollbar for opacity of img1 (when overlaying) - self.img1.alphaScrollbar = self.addAlphaScrollbar( - self.user_ch_name, self.img1 - ) + self.img1.alphaScrollbar = self.addAlphaScrollbar(self.user_ch_name, self.img1) - self.navigateScrollBar.setSliderPosition(posData.frame_i+1) + self.navigateScrollBar.setSliderPosition(posData.frame_i + 1) # Connect events at the end of loading data process self.gui_connectGraphicsEvents() if not self.isEditActionsConnected: self.gui_connectEditActions() self.normalizeToFloatAction.setChecked(True) - + self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) self.setFramesSnapshotMode() if self.isSnapshot: - self.navSizeLabel.setText(f'/{len(self.data)}') + self.navSizeLabel.setText(f"/{len(self.data)}") else: - self.navSizeLabel.setText(f'/{posData.SizeT}') + self.navSizeLabel.setText(f"/{posData.SizeT}") self.enableZstackWidgets(posData.SizeZ > 1) # self.showHighlightZneighCheckbox() - - self.exportToVideoAction.setDisabled( - posData.SizeZ == 1 and posData.SizeT == 1 - ) + + self.exportToVideoAction.setDisabled(posData.SizeZ == 1 and posData.SizeT == 1) self.img1BottomGroupbox.show() - isLabVisible = self.df_settings.at['isLabelsVisible', 'value'] == 'Yes' - isRightImgVisible = ( - self.df_settings.at['isRightImageVisible', 'value'] == 'Yes' - ) - isNextFrameVisible = ( - self.df_settings.at['isNextFrameVisible', 'value'] == 'Yes' - ) + isLabVisible = self.df_settings.at["isLabelsVisible", "value"] == "Yes" + isRightImgVisible = self.df_settings.at["isRightImageVisible", "value"] == "Yes" + isNextFrameVisible = self.df_settings.at["isNextFrameVisible", "value"] == "Yes" isNextFrameActive = ( isNextFrameVisible and self.labelsGrad.showNextFrameAction.isEnabled() ) @@ -1183,18 +1158,16 @@ def loadingDataCompleted(self): self.labelsGrad.showNextFrameAction.setChecked(isNextFrameActive) if isRightImgVisible or isNextFrameActive: self.rightBottomGroupbox.setChecked(True) - - isTwoImagesLayout = ( - isRightImgVisible or isLabVisible or isNextFrameActive - ) + + isTwoImagesLayout = isRightImgVisible or isLabVisible or isNextFrameActive self.setTwoImagesLayout(isTwoImagesLayout) - + self.setBottomLayoutStretch() - + if isNextFrameActive: self.rightBottomGroupbox.show() self.rightBottomGroupbox.setChecked(True) - self.drawNothingCheckboxRight.click() + self.drawNothingCheckboxRight.click() self.readSavedCustomAnnot() self.addCustomAnnotButtonAllLoadedPos() @@ -1212,16 +1185,13 @@ def loadingDataCompleted(self): self.update_rp() self.updateAllImages() if posData.SizeT > 1: - self.rightImageFramesScrollbar.setValueNoSignal(posData.frame_i+2) + self.rightImageFramesScrollbar.setValueNoSignal(posData.frame_i + 2) self.setMetricsFunc() self.gui_createLabelRoiItem() self.gui_createZoomRectItem() - self.titleLabel.setText( - 'Data successfully loaded.', - color=self.titleColor - ) + self.titleLabel.setText("Data successfully loaded.", color=self.titleColor) self.disableNonFunctionalButtons() self.setVisible3DsegmWidgets() @@ -1254,27 +1224,29 @@ def loadingDataCompleted(self): self.isDataLoaded = True self.isDataLoading = False - + self.initImgGradRescaleIntensitiesHowPreference() - + self.rescaleIntensitiesLut(setImage=False) - + self.gui_createAutoSaveWorker() def newFile(self): - self.newSegmEndName = '' + self.newSegmEndName = "" self.isNewFile = True msg = widgets.myMessageBox(parent=self, showCentered=False) - msg.setWindowTitle('File or folder?') - msg.addText(html_utils.paragraph(f""" + msg.setWindowTitle("File or folder?") + msg.addText( + html_utils.paragraph(f""" Do you want to load an image file or Position folder(s)? - """)) - loadPosButton = QPushButton('Load Position folder', msg) + """) + ) + loadPosButton = QPushButton("Load Position folder", msg) loadPosButton.setIcon(QIcon(":folder-open.svg")) - loadFileButton = QPushButton('Load image file', msg) + loadFileButton = QPushButton("Load image file", msg) loadFileButton.setIcon(QIcon(":image.svg")) - helpButton = widgets.helpPushButton('Help...') + helpButton = widgets.helpPushButton("Help...") msg.addButton(helpButton) helpButton.disconnect() helpButton.clicked.connect(self.helpNewFile) @@ -1285,7 +1257,7 @@ def newFile(self): msg.exec_() if msg.cancel: return - + if msg.clickedButton == loadPosButton: self._openFolder() else: @@ -1297,23 +1269,20 @@ def openFile(self, checked=False, file_path=None): self.isNewFile = False self._openFile(file_path=file_path) - def openFolder( - self, checked=False, exp_path=None, imageFilePath='' - ): + def openFolder(self, checked=False, exp_path=None, imageFilePath=""): if exp_path is None: - self.logger.info('Asking to select a folder path...') + self.logger.info("Asking to select a folder path...") else: self.logger.info(f'Opening FOLDER "{exp_path}"...') self.isNewFile = False - if hasattr(self, 'data') and self.titleLabel.text != 'Saved!': + if hasattr(self, "data") and self.titleLabel.text != "Saved!": msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Do you want to save before loading another dataset?' + "Do you want to save before loading another dataset?" ) _, no, yes = msg.question( - self, 'Save?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') + self, "Save?", txt, buttonsTexts=("Cancel", "No", "Yes") ) if msg.clickedButton == yes: func = partial(self._openFolder, exp_path, imageFilePath) @@ -1325,12 +1294,10 @@ def openFolder( else: self.store_data(autosave=False) - self._openFolder( - exp_path=exp_path, imageFilePath=imageFilePath - ) + self._openFolder(exp_path=exp_path, imageFilePath=imageFilePath) def openRecentFile(self, path): - self.logger.info(f'Opening recent folder: {path}') + self.logger.info(f"Opening recent folder: {path}") self.addToRecentPaths(path, logger=self.logger) self.openFolder(exp_path=path) @@ -1341,7 +1308,7 @@ def reload_cb(self): labData = np.load(posData.segm_npz_path) # Keep compatibility with .npy and .npz files try: - lab = labData['arr_0'][posData.frame_i] + lab = labData["arr_0"][posData.frame_i] except Exception as e: lab = labData[posData.frame_i] posData.segm_data[posData.frame_i] = lab.copy() @@ -1351,40 +1318,40 @@ def reload_cb(self): def showInfoAutosave(self, posData): msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = (f""" + txt = f""" Cell-ACDC either detected unsaved data in a previous session and it stored it because the Autosave
    function was active, or it crashed during saving.

    You can toggle Autosave ON and OFF from the menu on the top menubar File --> Autosave. - """) - txt = (f""" + """ + txt = f""" {txt}

    If Cell-ACDC crashed during saving, the segmentation file ending with .new.npz
    is present and you might be able to recover the data from there. - """) - - txt = (f""" + """ + + txt = f""" {txt}

    You can find additional recovered data in the following folder: - """) + """ txt = html_utils.paragraph(txt) msg.information( - self, 'Autosave info', txt, - path_to_browse=posData.recoveryFolderPath, - commands=(posData.recoveryFolderPath,) + self, + "Autosave info", + txt, + path_to_browse=posData.recoveryFolderPath, + commands=(posData.recoveryFolderPath,), ) def startAutomaticLoadingPos(self): self.AutoPilot = autopilot.AutoPilot(self) self.AutoPilot.execLoadPos() - def startLoadDataWorker( - self, user_ch_file_paths, user_ch_name, firstPosData - ): - self.funcDescription = 'loading data' - + def startLoadDataWorker(self, user_ch_file_paths, user_ch_name, firstPosData): + self.funcDescription = "loading data" + self.guiTabControl.propsQGBox.idSB.setValue(0) self.thread = QThread() @@ -1397,24 +1364,14 @@ def startLoadDataWorker( self.loadDataWorker.moveToThread(self.thread) self.loadDataWorker.signals.finished.connect(self.thread.quit) - self.loadDataWorker.signals.finished.connect( - self.loadDataWorker.deleteLater - ) + self.loadDataWorker.signals.finished.connect(self.loadDataWorker.deleteLater) self.thread.finished.connect(self.thread.deleteLater) - self.loadDataWorker.signals.finished.connect( - self.loadDataWorkerFinished - ) + self.loadDataWorker.signals.finished.connect(self.loadDataWorkerFinished) self.loadDataWorker.signals.progress.connect(self.workerProgress) - self.loadDataWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.loadDataWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.loadDataWorker.signals.critical.connect( - self.workerCritical - ) + self.loadDataWorker.signals.initProgressBar.connect(self.workerInitProgressbar) + self.loadDataWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.loadDataWorker.signals.critical.connect(self.workerCritical) self.loadDataWorker.signals.dataIntegrityCritical.connect( self.loadDataWorkerDataIntegrityCritical ) @@ -1427,9 +1384,7 @@ def startLoadDataWorker( self.loadDataWorker.signals.sigWarnMismatchSegmDataShape.connect( self.askMismatchSegmDataShape ) - self.loadDataWorker.signals.sigRecovery.connect( - self.askRecoverNotSavedData - ) + self.loadDataWorker.signals.sigRecovery.connect(self.askRecoverNotSavedData) self.thread.started.connect(self.loadDataWorker.run) self.thread.start() @@ -1437,7 +1392,7 @@ def startLoadDataWorker( def stopAutomaticLoadingPos(self): if self.AutoPilot is None: return - + if self.AutoPilot.timer.isActive(): self.AutoPilot.timer.stop() self.AutoPilot = None @@ -1446,7 +1401,7 @@ def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): total_ram = myutils._bytes_to_GB(total_ram) available_ram = myutils._bytes_to_GB(available_ram) required_ram = myutils._bytes_to_GB(required_ram) - required_perc = round(100*required_ram/available_ram) + required_perc = round(100 * required_ram / available_ram) msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" The total amount of data that you requested to load is about @@ -1461,11 +1416,13 @@ def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): What do you want to do? """) cancelButton, continueButton = msg.warning( - self, 'Memory not sufficient', txt, - buttonsTexts=('Cancel', 'Continue anyway') + self, + "Memory not sufficient", + txt, + buttonsTexts=("Cancel", "Continue anyway"), ) if msg.clickedButton == continueButton: - # Disable autosaving since it would keep a copy of the data and + # Disable autosaving since it would keep a copy of the data and # we cannot afford it with low memory self.autoSaveToggle.setChecked(False) return True @@ -1474,7 +1431,7 @@ def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): def warnUserCreationImagesFolder(self, images_path, ext): msg = widgets.myMessageBox(wrapText=False) - txt = (f""" + txt = f""" Cell-ACDC requires a specific folder structure to load the data.

    Specifically, it requires the image(s) to be located in a folder called Images.

    @@ -1489,24 +1446,22 @@ def warnUserCreationImagesFolder(self, images_path, ext): folder: {images_path}
    - """) - - if ext == '.tif' or ext == '.npz': - txt = f'{txt}How do you want to proceed?' + """ + + if ext == ".tif" or ext == ".npz": + txt = f"{txt}How do you want to proceed?" else: - txt = f'{txt}Do you want to proceed?' + txt = f"{txt}Do you want to proceed?" txt = html_utils.paragraph(txt) - - if ext == '.tif' or ext == '.npz': - copyButton = widgets.copyPushButton( - 'Copy the image into the new folder' - ) - moveButton = widgets.movePushButton( - 'Move the image into the new folder' - ) + + if ext == ".tif" or ext == ".npz": + copyButton = widgets.copyPushButton("Copy the image into the new folder") + moveButton = widgets.movePushButton("Move the image into the new folder") _, copyButton, moveButton = msg.information( - self, 'Creating Images folder', txt, - buttonsTexts=('Cancel', copyButton, moveButton) + self, + "Creating Images folder", + txt, + buttonsTexts=("Cancel", copyButton, moveButton), ) if msg.cancel: return False, None @@ -1515,23 +1470,25 @@ def warnUserCreationImagesFolder(self, images_path, ext): return True, True elif msg.clickedButton == moveButton: return True, False - + else: msg.information( - self, 'Creating Images folder', txt, - buttonsTexts=('Cancel', 'Yes, proceed') + self, + "Creating Images folder", + txt, + buttonsTexts=("Cancel", "Yes, proceed"), ) if msg.cancel: return False, None - + return True, True def workerPermissionError(self, txt, waitCond): msg = widgets.myMessageBox(parent=self) - msg.setIcon(iconName='SP_MessageBoxCritical') - msg.setWindowTitle('Permission denied') + msg.setIcon(iconName="SP_MessageBoxCritical") + msg.setWindowTitle("Permission denied") msg.addText(txt) - msg.addButton(' Ok ') + msg.addButton(" Ok ") msg.exec_() waitCond.wakeAll() @@ -1541,9 +1498,10 @@ def zSliceAbsent(self, filename, posData): chNames = posData.chNames filenamesPresent = posData.segmInfo_df.index.get_level_values(0).unique() chNamesPresent = [ - ch for ch in chNames + ch + for ch in chNames for file in filenamesPresent - if file.endswith(ch) or file.endswith(f'{ch}_aligned') + if file.endswith(ch) or file.endswith(f"{ch}_aligned") ] win = apps.QDialogZsliceAbsent(filename, SizeZ, chNamesPresent) win.exec_() @@ -1552,7 +1510,7 @@ def zSliceAbsent(self, filename, posData): self.waitCond.wakeAll() return if win.useMiddleSlice: - user_ch_name = filename[len(posData.basename):] + user_ch_name = filename[len(posData.basename) :] for _posData in self.data: if _posData is None: continue @@ -1563,13 +1521,11 @@ def zSliceAbsent(self, filename, posData): _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) elif win.useSameAsCh: - user_ch_name = filename[len(posData.basename):] + user_ch_name = filename[len(posData.basename) :] for _posData in self.data: if _posData is None: continue - _, srcFilename = self.getPathFromChName( - win.selectedChannel, _posData - ) + _, srcFilename = self.getPathFromChName(win.selectedChannel, _posData) cellacdc_df = _posData.segmInfo_df.loc[srcFilename].copy() _, dstFilename = self.getPathFromChName(user_ch_name, _posData) if dstFilename is None: @@ -1580,23 +1536,23 @@ def zSliceAbsent(self, filename, posData): for z_info in cellacdc_df.itertuples(): frame_i = z_info.Index zProjHow = z_info.which_z_proj - if zProjHow == 'single z-slice': + if zProjHow == "single z-slice": src_idx = (srcFilename, frame_i) - if _posData.segmInfo_df.at[src_idx, 'resegmented_in_gui']: - col = 'z_slice_used_gui' + if _posData.segmInfo_df.at[src_idx, "resegmented_in_gui"]: + col = "z_slice_used_gui" else: - col = 'z_slice_used_dataPrep' + col = "z_slice_used_dataPrep" z_slice = _posData.segmInfo_df.at[src_idx, col] dst_idx = (dstFilename, frame_i) - dst_df.at[dst_idx, 'z_slice_used_dataPrep'] = z_slice - dst_df.at[dst_idx, 'z_slice_used_gui'] = z_slice + dst_df.at[dst_idx, "z_slice_used_dataPrep"] = z_slice + dst_df.at[dst_idx, "z_slice_used_gui"] = z_slice _posData.segmInfo_df = pd.concat([dst_df, _posData.segmInfo_df]) unique_idx = ~_posData.segmInfo_df.index.duplicated() _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] _posData.segmInfo_df.to_csv(_posData.segmInfo_df_csv_path) elif win.runDataPrep: user_ch_file_paths = [] - user_ch_name = filename[len(self.data[self.pos_i].basename):] + user_ch_name = filename[len(self.data[self.pos_i].basename) :] for _posData in self.data: if _posData is None: continue @@ -1612,20 +1568,17 @@ def zSliceAbsent(self, filename, posData): dataPrepWin = dataPrep.dataPrepWin() dataPrepWin.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - dataPrepWin.titleText = ( - """ + dataPrepWin.titleText = """ Select z-slice (or projection) for each frame/position.
    Once happy, close the window. - """) + """ dataPrepWin.show() dataPrepWin.initLoading() dataPrepWin.SizeT = self.data[0].SizeT dataPrepWin.SizeZ = self.data[0].SizeZ dataPrepWin.metadataAlreadyAsked = True - self.logger.info(f'Loading channel {user_ch_name} data...') - dataPrepWin.loadFiles( - exp_path, user_ch_file_paths, user_ch_name - ) + self.logger.info(f"Loading channel {user_ch_name} data...") + dataPrepWin.loadFiles(exp_path, user_ch_file_paths, user_ch_name) dataPrepWin.startAction.setDisabled(True) dataPrepWin.onlySelectingZslice = True @@ -1640,18 +1593,18 @@ def getConcatAcdcDf(self): keys = [] posData = self.data[self.pos_i] for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None: break - - acdc_df = data_dict['acdc_df'] + + acdc_df = data_dict["acdc_df"] if acdc_df is None: break - + acdc_dfs.append(acdc_df) keys.append(frame_i) - + if not acdc_dfs: return - - return pd.concat(acdc_dfs, keys=keys, names=['frame_i']) + + return pd.concat(acdc_dfs, keys=keys, names=["frame_i"]) diff --git a/cellacdc/mixins/deleted_rois.py b/cellacdc/mixins/deleted_rois.py index 65647ab02..b3a8be256 100644 --- a/cellacdc/mixins/deleted_rois.py +++ b/cellacdc/mixins/deleted_rois.py @@ -15,6 +15,7 @@ from .cell_cycle import CellCycle + class DeletedRois(CellCycle): """Extracted from guiWin.""" @@ -24,10 +25,10 @@ def addDelPolyLineRoi_cb(self, checked): self.uncheckLeftClickButtons(self.addDelPolyLineRoiButton) self.connectLeftClickButtons() if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete IDs using ROI') + self.fixCcaDfAfterEdit("Delete IDs using ROI") self.updateAllImages() else: - self.warnEditingWithCca_df('Delete IDs using ROI') + self.warnEditingWithCca_df("Delete IDs using ROI") else: self.tempSegmentON = False self.ax1_rulerPlotItem.setData([], []) @@ -36,7 +37,7 @@ def addDelPolyLineRoi_cb(self, checked): while self.app.overrideCursor() is not None: self.app.restoreOverrideCursor() - def addDelROI(self, event): + def addDelROI(self, event): roi, key = self.createDelROI() self.addRoiToDelRoiInfo(roi) if not self.labelsGrad.showLabelsImgAction.isChecked(): @@ -47,27 +48,25 @@ def addDelROI(self, event): self.applyDelROIimg1(roi, init=True, ax=1) if self.isSnapshot: - self.fixCcaDfAfterEdit('Delete IDs using ROI') + self.fixCcaDfAfterEdit("Delete IDs using ROI") self.updateAllImages() else: - self.warnEditingWithCca_df( - 'Delete IDs using ROI', get_cancelled=True - ) + self.warnEditingWithCca_df("Delete IDs using ROI", get_cancelled=True) def addExistingDelROIs(self): posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] isAx2hidden = not self.labelsGrad.showLabelsImgAction.isChecked() - for r, roi in enumerate(delROIs_info['rois']): + for r, roi in enumerate(delROIs_info["rois"]): if isinstance(roi, pg.PolyLineROI) or isAx2hidden: # PolyLine ROIs are only on ax1 self.ax1.addDelRoiItem(roi, roi.key) else: # Rect ROI is on ax2 because ax2 is visible - self.ax2.addDelRoiItem(roi, roi.key) - - self.setDelRoiState(roi, delROIs_info['state'][r]) + self.ax2.addDelRoiItem(roi, roi.key) + + self.setDelRoiState(roi, delROIs_info["state"][r]) def addPointsPolyLineRoi(self, closed=False): self.polyLineRoi.setPoints(self.polyLineRoi.points, closed=closed) @@ -81,46 +80,46 @@ def addPointsPolyLineRoi(self, closed=False): def addRoiToDelRoiInfo(self, roi: pg.ROI): posData = self.data[self.pos_i] for i in range(posData.frame_i, posData.SizeT): - delROIs_info = posData.allData_li[i]['delROIs_info'] - delROIs_info['rois'].append(roi) - delROIs_info['state'].append(roi.getState()) - delROIs_info['delMasks'].append(np.zeros_like(self.currentLab2D)) - delROIs_info['delIDsROI'].append(set()) + delROIs_info = posData.allData_li[i]["delROIs_info"] + delROIs_info["rois"].append(roi) + delROIs_info["state"].append(roi.getState()) + delROIs_info["delMasks"].append(np.zeros_like(self.currentLab2D)) + delROIs_info["delIDsROI"].append(set()) def applyDelROIimg1(self, roi, init=False, ax=0): if ax == 0: how = self.drawIDsContComboBox.currentText() else: how = self.getAnnotateHowRightImage() - + if ax == 1 and not self.labelsGrad.showRightImgAction.isChecked(): return - - if init and how.find('contours') == -1: + + if init and how.find("contours") == -1: self.setOverlaySegmMasks(force=True) return posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] try: - idx = delROIs_info['rois'].index(roi) + idx = delROIs_info["rois"].index(roi) except Exception as err: try: ax.removeDelRoiItem(roi) except Exception as err: pass return - delIDs = delROIs_info['delIDsROI'][idx] - delMask = delROIs_info['delMasks'][idx] - if how.find('nothing') != -1: + delIDs = delROIs_info["delIDsROI"][idx] + delMask = delROIs_info["delMasks"][idx] + if how.find("nothing") != -1: return - elif how.find('contours') != -1: + elif how.find("contours") != -1: self.updateContoursImage(ax=ax) - + if not delIDs: return - - if how.find('overlay segm. masks') != -1: + + if how.find("overlay segm. masks") != -1: lab = self.currentLab2D.copy() lab[delMask > 0] = 0 if ax == 0: @@ -128,30 +127,30 @@ def applyDelROIimg1(self, roi, init=False, ax=0): else: self.labelsLayerRightImg.setImage(lab, autoLevels=False) - self.setAllTextAnnotations(labelsToSkip={ID:True for ID in delIDs}) + self.setAllTextAnnotations(labelsToSkip={ID: True for ID in delIDs}) def applyDelROIs(self): - self.logger.info('Applying deletion ROIs (if present)...') - + self.logger.info("Applying deletion ROIs (if present)...") + for posData in self.data: self.current_frame_i = posData.frame_i for frame_i in range(posData.SizeT): - lab = posData.allData_li[frame_i]['labels'] + lab = posData.allData_li[frame_i]["labels"] if lab is None: break - delROIs_info = posData.allData_li[frame_i]['delROIs_info'] - delIDs_rois = delROIs_info['delIDsROI'] + delROIs_info = posData.allData_li[frame_i]["delROIs_info"] + delIDs_rois = delROIs_info["delIDsROI"] if not delIDs_rois: continue for delIDs in delIDs_rois: for delID in delIDs: - lab[lab==delID] = 0 - posData.allData_li[frame_i]['labels'] = lab + lab[lab == delID] = 0 + posData.allData_li[frame_i]["labels"] = lab # Get the rest of the metadata and store data based on the new lab posData.frame_i = frame_i self.get_data() self.store_data(autosave=False) - + # Back to current frame posData.frame_i = self.current_frame_i self.get_data() @@ -162,19 +161,17 @@ def clearLostObjContoursItems(self): self.ax1_lostTrackedScatterItem.setData([], []) self.ax2_lostTrackedScatterItem.setData([], []) - + self.ax2_lostObjImageItem.clear() self.ax2_lostTrackedObjImageItem.clear() - + self.ax1_lostObjImageItem.clear() self.ax1_lostTrackedObjImageItem.clear() def createDelPolyLineRoi(self): Y, X = self.currentLab2D.shape self.polyLineRoi = pg.PolyLineROI( - [], rotatable=False, - removable=True, - pen=pg.mkPen(color='r') + [], rotatable=False, removable=True, pen=pg.mkPen(color="r") ) self.polyLineRoi.handleSize = 7 self.polyLineRoi.points = [] @@ -190,11 +187,12 @@ def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None): Y, X = self.currentLab2D.shape if anchors is None: roi = widgets.DelROI( - [xl, yb], [w, h], + [xl, yb], + [w, h], rotatable=False, removable=True, - pen=pg.mkPen(color='r'), - maxBounds=QRectF(QRect(0,0,X,Y)) + pen=pg.mkPen(color="r"), + maxBounds=QRectF(QRect(0, 0, X, Y)), ) ## handles scaling horizontally around center roi.addScaleHandle([1, 0.5], [0, 0.5]) @@ -214,13 +212,13 @@ def createDelROI(self, xl=None, yb=None, w=32, h=32, anchors=None): roi.sigRegionChanged.connect(self.delROImoving) roi.sigRegionChanged.connect(self.delROIstartedMoving) roi.sigRegionChangeFinished.connect(self.delROImovingFinished) - + key = uuid.uuid4() - + return roi, key def delROImoving(self, roi): - roi.setPen(color=(255,255,0)) + roi.setPen(color=(255, 255, 0)) # First bring back IDs if the ROI moved away self.restoreAnnotDelROI(roi) self.setImageImg2() @@ -228,12 +226,10 @@ def delROImoving(self, roi): self.applyDelROIimg1(roi, ax=1) def delROImovingFinished(self, roi: pg.ROI): - roi.setPen(color='r') + roi.setPen(color="r") self.update_rp() self.updateAllImages() - QTimer.singleShot( - 300, partial(self.updateDelROIinFutureFrames, roi) - ) + QTimer.singleShot(300, partial(self.updateDelROIinFutureFrames, roi)) def delROIstartedMoving(self, roi): self.clearLostObjContoursItems() @@ -242,42 +238,41 @@ def getDelROIlab(self, input_lab_2D=None): posData = self.data[self.pos_i] if self.delRoiLab is None: self.initDelRoiLab() - + out_lab = self.delRoiLab if input_lab_2D is None: out_lab[:] = self.get_2Dlab(posData.lab, force_z=False) else: out_lab[:] = input_lab_2D - + allDelIDs = set() # Iterate rois and delete IDs - for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: - if ( - not self.ax1.isDelRoiItemPresent(roi) - and not self.ax2.isDelRoiItemPresent(roi) - ): - continue + for roi in posData.allData_li[posData.frame_i]["delROIs_info"]["rois"]: + if not self.ax1.isDelRoiItemPresent( + roi + ) and not self.ax2.isDelRoiItemPresent(roi): + continue ROImask = self.getDelRoiMask(roi) - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - idx = delROIs_info['rois'].index(roi) - delObjROImask = delROIs_info['delMasks'][idx] - delIDsROI = delROIs_info['delIDsROI'][idx] + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + idx = delROIs_info["rois"].index(roi) + delObjROImask = delROIs_info["delMasks"][idx] + delIDsROI = delROIs_info["delIDsROI"][idx] delROIlabRp = skimage.measure.regionprops(out_lab) for delObj in delROIlabRp: isDelObj = np.any(ROImask[delObj.slice][delObj.image]) if not isDelObj: continue - + delObjROImask[delObj.slice][delObj.image] = delObj.label out_lab[delObj.slice][delObj.image] = 0 - + delIDsROI.add(delObj.label) allDelIDs.add(delObj.label) # Keep a mask of deleted IDs to bring them back when roi moves - delROIs_info['delMasks'][idx] = delObjROImask - delROIs_info['delIDsROI'][idx] = delIDsROI - + delROIs_info["delMasks"][idx] = delObjROImask + delROIs_info["delIDsROI"][idx] = delIDsROI + # printl( # f't1-t0: {(t1-t0)*1000:.3f} ms,', # f't2-t1: {(t2-t1)*1000:.3f} ms,', @@ -287,7 +282,7 @@ def getDelROIlab(self, input_lab_2D=None): # # f't6-t5: {(t6-t5)*1000:.3f} ms', # sep='\n' # ) - + return allDelIDs, out_lab def getDelRoiMask(self, roi, posData=None, z_slice=None): @@ -301,20 +296,20 @@ def getDelRoiMask(self, roi, posData=None, z_slice=None): x0, y0 = roi.pos().x(), roi.pos().y() for _, point in roi.getLocalHandlePositions(): xr, yr = point.x(), point.y() - r.append(int(yr+y0)) - c.append(int(xr+x0)) + r.append(int(yr + y0)) + c.append(int(xr + x0)) if not r or not c: return ROImask - + if len(r) == 2: rr, cc, val = skimage.draw.line_aa(r[0], c[0], r[1], c[1]) else: rr, cc = skimage.draw.polygon(r, c, shape=self.currentLab2D.shape) - + Y, X = self.currentLab2D.shape - rr = rr[(rr>=0) & (rr=0) & (cc= 0) & (rr < Y)] + cc = cc[(cc >= 0) & (cc < X)] + if self.isSegm3D: ROImask[z_slice, rr, cc] = True else: @@ -330,27 +325,26 @@ def getDelRoiMask(self, roi, posData=None, z_slice=None): ROImask[z_slice, rr, cc] = True else: ROImask[rr, cc] = True - else: + else: x0, y0 = [int(c) for c in roi.pos()] w, h = [int(c) for c in roi.size()] if self.isSegm3D: - ROImask[z_slice, y0:y0+h, x0:x0+w] = True + ROImask[z_slice, y0 : y0 + h, x0 : x0 + w] = True else: - ROImask[y0:y0+h, x0:x0+w] = True + ROImask[y0 : y0 + h, x0 : x0 + w] = True return ROImask def getDelRoisIDs(self): posData = self.data[self.pos_i] if posData.frame_i > 0: - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] + prev_lab = posData.allData_li[posData.frame_i - 1]["labels"] allDelIDs = set() - for roi in posData.allData_li[posData.frame_i]['delROIs_info']['rois']: - if ( - not self.ax1.isDelRoiItemPresent(roi) - and not self.ax2.isDelRoiItemPresent(roi) - ): + for roi in posData.allData_li[posData.frame_i]["delROIs_info"]["rois"]: + if not self.ax1.isDelRoiItemPresent( + roi + ) and not self.ax2.isDelRoiItemPresent(roi): continue - + ROImask = self.getDelRoiMask(roi) delIDs = posData.lab[ROImask] allDelIDs.update(delIDs) @@ -364,8 +358,8 @@ def getStoredDelRoiIDs(self, frame_i=None): if frame_i is None: frame_i = posData.frame_i allDelIDs = set() - delROIs_info = posData.allData_li[frame_i]['delROIs_info'] - delIDs_rois = delROIs_info['delIDsROI'] + delROIs_info = posData.allData_li[frame_i]["delROIs_info"] + delIDs_rois = delROIs_info["delIDsROI"] for delIDs in delIDs_rois: allDelIDs.update(delIDs) return allDelIDs @@ -375,14 +369,14 @@ def initDelRoiLab(self): z_slice = self.z_lab() img = posData.img_data[posData.frame_i] Y, X = img[z_slice].shape[-2:] - + self.delRoiLab = np.zeros((Y, X), dtype=np.uint32) def moveDelRoisToLeft(self): # Move del ROIs to the left image for posData in self.data: - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - for roi in delROIs_info['rois']: + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + for roi in delROIs_info["rois"]: if not self.ax2.isDelRoiItemPresent(roi): continue @@ -391,66 +385,66 @@ def moveDelRoisToLeft(self): def removeAlldelROIsCurrentFrame(self): posData = self.data[self.pos_i] - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - rois = delROIs_info['rois'].copy() + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + rois = delROIs_info["rois"].copy() for roi in rois: self.ax2.removeDelRoiItem(roi) for item in self.ax2.items: if isinstance(item, pg.ROI): self.ax2.removeDelRoiItem(item) - + for item in self.ax1.items: if isinstance(item, pg.ROI) and item != self.labelRoiItem: self.ax1.removeDelRoiItem(item) def removeDelROI(self, event): posData = self.data[self.pos_i] - + for ax in (self.ax1, self.ax2): try: self.ax1.removeDelRoiItem(self.roi_to_del) except Exception as err: pass - - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] - idx = delROIs_info['rois'].index(self.roi_to_del) - delROIs_info['rois'].pop(idx) - delROIs_info['delMasks'].pop(idx) - delROIs_info['delIDsROI'].pop(idx) - delROIs_info['state'].pop(idx) - + + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] + idx = delROIs_info["rois"].index(self.roi_to_del) + delROIs_info["rois"].pop(idx) + delROIs_info["delMasks"].pop(idx) + delROIs_info["delIDsROI"].pop(idx) + delROIs_info["state"].pop(idx) + self.removeDelROIFromFutureFrames(self.roi_to_del) self.updateAllImages() def removeDelROIFromFutureFrames(self, roi_to_del): posData = self.data[self.pos_i] - + # Restore deleted IDs from already visited future frames - current_frame_i = posData.frame_i - for i in range(posData.frame_i+1, posData.SizeT): - if posData.allData_li[i]['labels'] is None: + current_frame_i = posData.frame_i + for i in range(posData.frame_i + 1, posData.SizeT): + if posData.allData_li[i]["labels"] is None: break - - delROIs_info = posData.allData_li[i]['delROIs_info'] + + delROIs_info = posData.allData_li[i]["delROIs_info"] try: - idx = delROIs_info['rois'].index(roi_to_del) + idx = delROIs_info["rois"].index(roi_to_del) except IndexError: continue - + posData.frame_i = i - idx = delROIs_info['rois'].index(roi_to_del) - if delROIs_info['delIDsROI'][idx]: - posData.lab = posData.allData_li[i]['labels'] + idx = delROIs_info["rois"].index(roi_to_del) + if delROIs_info["delIDsROI"][idx]: + posData.lab = posData.allData_li[i]["labels"] self.restoreAnnotDelROI(roi_to_del, enforce=True, draw=False) - posData.allData_li[i]['labels'] = posData.lab + posData.allData_li[i]["labels"] = posData.lab self.get_data() self.store_data(autosave=False) - delROIs_info['rois'].pop(idx) - delROIs_info['delMasks'].pop(idx) - delROIs_info['delIDsROI'].pop(idx) - delROIs_info['state'].pop(idx) - + delROIs_info["rois"].pop(idx) + delROIs_info["delMasks"].pop(idx) + delROIs_info["delIDsROI"].pop(idx) + delROIs_info["state"].pop(idx) + if isinstance(self.roi_to_del, pg.PolyLineROI): # PolyLine ROIs are only on ax1 self.ax1.removeItem(self.roi_to_del) @@ -463,7 +457,7 @@ def removeDelROIFromFutureFrames(self, roi_to_del): # Back to current frame posData.frame_i = current_frame_i - posData.lab = posData.allData_li[posData.frame_i]['labels'] + posData.lab = posData.allData_li[posData.frame_i]["labels"] self.get_data() self.store_data() @@ -472,8 +466,8 @@ def replacePolyLineRoiWithLineRoi(self, roi): (_, point1), (_, point2) = roi.getLocalHandlePositions() xr1, yr1 = point1.x(), point1.y() xr2, yr2 = point2.x(), point2.y() - x1, y1 = xr1+x0, yr1+y0 - x2, y2 = xr2+x0, yr2+x0 + x1, y1 = xr1 + x0, yr1 + y0 + x2, y2 = xr2 + x0, yr2 + x0 lineRoi = pg.LineROI((x1, y1), (x2, y2), width=0.5) lineRoi.handleSize = 7 self.ax1.removeItem(self.polyLineRoi) @@ -487,34 +481,34 @@ def replacePolyLineRoiWithLineRoi(self, roi): def restoreAnnotDelROI(self, roi, enforce=True, draw=True): posData = self.data[self.pos_i] ROImask = self.getDelRoiMask(roi) - delROIs_info = posData.allData_li[posData.frame_i]['delROIs_info'] + delROIs_info = posData.allData_li[posData.frame_i]["delROIs_info"] try: - idx = delROIs_info['rois'].index(roi) + idx = delROIs_info["rois"].index(roi) except Exception as err: - return - - delMask = delROIs_info['delMasks'][idx] - delIDs = delROIs_info['delIDsROI'][idx] + return + + delMask = delROIs_info["delMasks"][idx] + delIDs = delROIs_info["delIDsROI"][idx] overlapROIdelIDs = np.unique(delMask[ROImask]) lab2D = self.get_2Dlab(posData.lab) restoredIDs = set() for ID in delIDs: if ID in overlapROIdelIDs and not enforce: continue - + restoredIDs.add(ID) - - delMaskID = delMask==ID + + delMaskID = delMask == ID self.currentLab2D[delMaskID] = ID lab2D[delMaskID] = ID - + if draw: self.restoreDelROIimg1(delMaskID, ID, ax=0) self.restoreDelROIimg1(delMaskID, ID, ax=1) - + delMask[delMaskID] = 0 - - delROIs_info['delIDsROI'][idx] = delIDs - restoredIDs + + delROIs_info["delIDsROI"][idx] = delIDs - restoredIDs self.set_2Dlab(lab2D) self.update_rp() @@ -524,23 +518,19 @@ def restoreDelROIimg1(self, delMaskID, delID, ax=0): else: how = self.getAnnotateHowRightImage() - if how.find('nothing') != -1: + if how.find("nothing") != -1: return - - if how.find('contours') != -1: + + if how.find("contours") != -1: rp_delmask = skimage.measure.regionprops(delMaskID.astype(np.uint8)) if len(rp_delmask) > 0: obj = rp_delmask[0] - self.addObjContourToContoursImage(obj=obj, ax=ax) - elif how.find('overlay segm. masks') != -1: + self.addObjContourToContoursImage(obj=obj, ax=ax) + elif how.find("overlay segm. masks") != -1: if ax == 0: - self.labelsLayerImg1.setImage( - self.currentLab2D, autoLevels=False - ) + self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) else: - self.labelsLayerRightImg.setImage( - self.currentLab2D, autoLevels=False - ) + self.labelsLayerRightImg.setImage(self.currentLab2D, autoLevels=False) def setDelRoiState(self, roi: pg.ROI, state): roi.sigRegionChanged.disconnect() @@ -552,42 +542,42 @@ def setDelRoiState(self, roi: pg.ROI, state): def updateDelROIinFutureFrames(self, roi: pg.ROI): posData = self.data[self.pos_i] restore_current_frame = False - + roiState = roi.getState() # Restore deleted IDs from already visited future frames - current_frame_i = posData.frame_i - delROIs_info = posData.allData_li[current_frame_i]['delROIs_info'] + current_frame_i = posData.frame_i + delROIs_info = posData.allData_li[current_frame_i]["delROIs_info"] try: - idx = delROIs_info['rois'].index(roi) - delROIs_info['state'][idx] = roiState + idx = delROIs_info["rois"].index(roi) + delROIs_info["state"][idx] = roiState except Exception as err: pass - + self.store_data() - - for i in range(posData.frame_i+1, posData.SizeT): - delROIs_info = posData.allData_li[i]['delROIs_info'] + + for i in range(posData.frame_i + 1, posData.SizeT): + delROIs_info = posData.allData_li[i]["delROIs_info"] try: - idx = delROIs_info['rois'].index(roi) + idx = delROIs_info["rois"].index(roi) except Exception as err: continue - delROIs_info['state'][idx] = roiState - if posData.allData_li[i]['labels'] is None: + delROIs_info["state"][idx] = roiState + if posData.allData_li[i]["labels"] is None: continue - + posData.frame_i = i - posData.lab = posData.allData_li[i]['labels'] + posData.lab = posData.allData_li[i]["labels"] self.restoreAnnotDelROI(roi, enforce=False, draw=False) - posData.allData_li[i]['labels'] = posData.lab + posData.allData_li[i]["labels"] = posData.lab self.get_data() self.store_data(autosave=False) restore_current_frame = True - + if not restore_current_frame: return - + # Back to current frame posData.frame_i = current_frame_i - posData.lab = posData.allData_li[posData.frame_i]['labels'] + posData.lab = posData.allData_li[posData.frame_i]["labels"] self.get_data() self.store_data() diff --git a/cellacdc/mixins/display_decorations.py b/cellacdc/mixins/display_decorations.py index db7fb5221..81e9daa1d 100644 --- a/cellacdc/mixins/display_decorations.py +++ b/cellacdc/mixins/display_decorations.py @@ -19,13 +19,9 @@ def addScaleBar(self, checked): X, Y, posData.PhysicalSizeX, parent=self ) self.scaleBarDialog.show() - self.scaleBar = widgets.ScaleBar( - (Y, X), viewRange, parent=self.ax1 - ) + self.scaleBar = widgets.ScaleBar((Y, X), viewRange, parent=self.ax1) self.scaleBar.sigEditProperties.connect(self.editScaleBarProperties) - self.scaleBar.sigRemove.connect( - self.editScaleBarRemove - ) + self.scaleBar.sigRemove.connect(self.editScaleBarRemove) self.scaleBar.addToAxis(self.ax1) self.scaleBar.draw(**self.scaleBarDialog.kwargs()) self.scaleBarDialog.sigValueChanged.connect(self.updateScaleBar) @@ -47,25 +43,21 @@ def addTimestamp(self, checked): self.timestampDialog = apps.TimestampPropertiesDialog(parent=self) self.timestampDialog.show() self.timestamp = widgets.TimestampItem( - Y, X, viewRange, + Y, + X, + viewRange, secondsPerFrame=posData.TimeIncrement, - start_timedelta=self.timestampStartTimedelta - ) - self.timestamp.sigEditProperties.connect( - self.editTimestampProperties - ) - self.timestamp.sigRemove.connect( - self.editTimestampRemove + start_timedelta=self.timestampStartTimedelta, ) + self.timestamp.sigEditProperties.connect(self.editTimestampProperties) + self.timestamp.sigRemove.connect(self.editTimestampRemove) self.timestamp.addToAxis(self.ax1) - self.timestamp.draw( - posData.frame_i, **self.timestampDialog.kwargs() - ) + self.timestamp.draw(posData.frame_i, **self.timestampDialog.kwargs()) self.timestampDialog.sigValueChanged.connect(self.updateTimestamp) self.timestampDialog.exec_() else: self.timestamp.removeFromAxis(self.ax1) - + self.timestampDialog = None self.imgGrad.addTimestampAction.setChecked(checked) @@ -78,10 +70,10 @@ def ax1ViewRange(self, integers=False): viewRange = self.ax1.viewRange() else: viewRange = self.ax1.viewRange(exportMask) - + if not integers: return viewRange - + xRange, yRange = viewRange xmin = round(xRange[0]) ymin = round(yRange[0]) @@ -94,7 +86,7 @@ def getViewRange(self): xRange, yRange = self.ax1.viewRange() xmin = 0 if xRange[0] < 0 else xRange[0] ymin = 0 if yRange[0] < 0 else yRange[0] - + xmax = X if xRange[1] >= X else xRange[1] ymax = Y if yRange[1] >= Y else yRange[1] return int(ymin), int(ymax), int(xmin), int(xmax) @@ -112,9 +104,7 @@ def editScaleBarRemove(self, timestamp): self.addScaleBarAction.setChecked(False) def editTimestampProperties(self, properties): - self.timestampDialog = apps.TimestampPropertiesDialog( - parent=self, **properties - ) + self.timestampDialog = apps.TimestampPropertiesDialog(parent=self, **properties) self.timestampDialog.sigValueChanged.connect(self.updateTimestamp) self.timestampDialog.show() @@ -122,34 +112,26 @@ def editTimestampRemove(self, timestamp): self.addTimestampAction.setChecked(False) def viewRangeChanged(self, viewBox, viewRange, updateExportImageMask=True): - # self.updateViewRangeExportToImage(viewRange) + # self.updateViewRangeExportToImage(viewRange) self.updateValuesStatusBar() - - if hasattr(self, 'scaleBar'): - isScaleBarMoveWithZoom = ( - self.scaleBar.properties()['move_with_zoom'] - ) + + if hasattr(self, "scaleBar"): + isScaleBarMoveWithZoom = self.scaleBar.properties()["move_with_zoom"] else: isScaleBarMoveWithZoom = False - doMoveScaleBar = ( - self.scaleBarDialog is not None or isScaleBarMoveWithZoom - ) + doMoveScaleBar = self.scaleBarDialog is not None or isScaleBarMoveWithZoom if doMoveScaleBar: self.scaleBar.updatePosViewRangeChanged(viewRange) - - if hasattr(self, 'timestamp'): - isTimestampMoveWithZoom = ( - self.timestamp.properties()['move_with_zoom'] - ) + + if hasattr(self, "timestamp"): + isTimestampMoveWithZoom = self.timestamp.properties()["move_with_zoom"] else: isTimestampMoveWithZoom = False - - doMoveTimestamp = ( - self.timestampDialog is not None or isTimestampMoveWithZoom - ) + + doMoveTimestamp = self.timestampDialog is not None or isTimestampMoveWithZoom if doMoveTimestamp: self.timestamp.updatePosViewRangeChanged(viewRange) - + self._viewRange = viewRange def updateScaleBar(self, scaleBarKwargs): @@ -160,11 +142,11 @@ def updateTimestamp(self, timeStampKwargs): self.timestamp.draw(posData.frame_i, **timeStampKwargs) def updateTimestampFrame(self): - if not hasattr(self, 'timestamp'): + if not hasattr(self, "timestamp"): return - + if not self.addTimestampAction.isChecked(): return - + posData = self.data[self.pos_i] self.timestamp.setText(posData.frame_i) diff --git a/cellacdc/mixins/draw_clear_region.py b/cellacdc/mixins/draw_clear_region.py index 465eab2ae..a4118cf5f 100644 --- a/cellacdc/mixins/draw_clear_region.py +++ b/cellacdc/mixins/draw_clear_region.py @@ -4,6 +4,7 @@ from .undo_redo import UndoRedo + class DrawClearRegion(UndoRedo): """Extracted from guiWin.""" @@ -15,56 +16,49 @@ def drawClearRegion_cb(self, checked): self.connectLeftClickButtons() self.drawClearRegionToolbar.setVisible(checked) - + if not self.isSegm3D: self.drawClearRegionToolbar.setZslicesControlEnabled(False) return - + if not checked: return - - self.drawClearRegionToolbar.setZslicesControlEnabled( - True, SizeZ=posData.SizeZ - ) + + self.drawClearRegionToolbar.setZslicesControlEnabled(True, SizeZ=posData.SizeZ) def clearObjsFreehandRegion(self): - self.logger.info('Clearing objects inside freehand region...') - + self.logger.info("Clearing objects inside freehand region...") + # Store undo state before modifying stuff self.storeUndoRedoStates(False, storeImage=False, storeOnlyZoom=True) - + posData = self.data[self.pos_i] zRange = None if self.isSegm3D: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' + isZslice = zProjHow == "single z-slice" if isZslice: z_slice = self.z_lab() - zRange = self.drawClearRegionToolbar.zRange( - z_slice, posData.SizeZ - ) + zRange = self.drawClearRegionToolbar.zRange(z_slice, posData.SizeZ) else: zRange = (0, posData.SizeZ) - + regionSlice = self.freeRoiItem.slice(zRange=zRange) mask = self.freeRoiItem.mask() - + regionLab = posData.lab[(...,) + regionSlice].copy() - + clearBorders = ( - self.drawClearRegionToolbar - .clearOnlyEnclosedObjsRadioButton.isChecked() + self.drawClearRegionToolbar.clearOnlyEnclosedObjsRadioButton.isChecked() ) if clearBorders: if regionLab.ndim == 2: - regionLab = transformation.clear_objects_not_in_mask( - regionLab, mask - ) + regionLab = transformation.clear_objects_not_in_mask(regionLab, mask) regionRp = skimage.measure.regionprops(regionLab) for obj in regionRp: if np.all(mask[obj.slice][obj.image]): continue - + regionLab[obj.slice][obj.image] = 0 else: for z, regionLab_z in enumerate(regionLab): @@ -73,25 +67,24 @@ def clearObjsFreehandRegion(self): ) else: regionLab[..., ~mask] = 0 - + regionRp = skimage.measure.regionprops(regionLab) clearIDs = [obj.label for obj in regionRp] - + if not clearIDs: if clearBorders: self.logger.warning( - 'None of the objects in the freehand region are ' - 'fully enclosed' + "None of the objects in the freehand region are fully enclosed" ) else: self.logger.warning( - 'None of the objects are touching the freehand region' + "None of the objects are touching the freehand region" ) return - + self.deleteIDmiddleClick(clearIDs, False, False) self.update_cca_df_deletedIDs(posData, clearIDs) - + self.freeRoiItem.clear() - + self.updateAllImages() diff --git a/cellacdc/mixins/exporting.py b/cellacdc/mixins/exporting.py index efd42dd94..ea66f1535 100644 --- a/cellacdc/mixins/exporting.py +++ b/cellacdc/mixins/exporting.py @@ -19,6 +19,7 @@ from .app_shell import AppShell from .frame_navigation import FrameNavigation + class Exporting(AppShell, FrameNavigation): """Extracted from guiWin.""" @@ -29,40 +30,42 @@ def askTimelapseOrZslicesVideo(self): """) msg = widgets.myMessageBox(wrapText=False) _, timelapseButton = msg.question( - self, 'Z-slices or Timelapse video?', txt, - buttonsTexts=('Z-slices', 'Timelapse') + self, + "Z-slices or Timelapse video?", + txt, + buttonsTexts=("Z-slices", "Timelapse"), ) if msg.cancel: - return - + return + return msg.clickedButton == timelapseButton def exportAddScaleBar(self, checked): self.addScaleBarAction.setChecked(checked) def exportFrame(self): - nd = self.exportToVideoPreferences['num_digits'] + nd = self.exportToVideoPreferences["num_digits"] idx = str(self.exportToVideoCurrentNavVarIdx).zfill(nd) - filename = self.exportToVideoPreferences['filename'] - png_filename = f'{idx}_{filename}.png' - pngs_folderpath = self.exportToVideoPreferences['pngs_folderpath'] - + filename = self.exportToVideoPreferences["filename"] + png_filename = f"{idx}_{filename}.png" + pngs_folderpath = self.exportToVideoPreferences["pngs_folderpath"] + png_filepath = os.path.join(pngs_folderpath, png_filename) img_bgr = self.exportToVideoImageExporter.export(png_filepath) self.exportToVideoExporter.add_frame(img_bgr) return True - def exportToImage(self, preferences): - filepath = preferences['filepath'] + def exportToImage(self, preferences): + filepath = preferences["filepath"] self.logger.info(f'Saving image to "{filepath}"...') - - if filepath.endswith('.svg'): + + if filepath.endswith(".svg"): exporter = exporters.SVGExporter(self.ax1) else: - exporter = exporters.ImageExporter(self.ax1, dpi=preferences['dpi']) + exporter = exporters.ImageExporter(self.ax1, dpi=preferences["dpi"]) exporter.export(filepath) - self.logger.info(f'Image saved.') - + self.logger.info(f"Image saved.") + self.setDisabled(False) self.exportMaskImage[:] = 0 self.exportMaskImageItem.setImage(self.exportMaskImage) @@ -70,14 +73,14 @@ def exportToImage(self, preferences): def exportToImageTriggered(self): posData = self.data[self.pos_i] - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = f'{timestamp}_acdc_exported_image' + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{timestamp}_acdc_exported_image" win = apps.ExportToImageParametersDialog( - parent=self, - startFolderpath=posData.pos_path, - startFilename=filename, + parent=self, + startFolderpath=posData.pos_path, + startFilename=filename, startViewRange=self.ax1.viewRange(), - isScaleBarPresent=self.addScaleBarAction.isChecked(), + isScaleBarPresent=self.addScaleBarAction.isChecked(), ) win.sigAddScaleBar.connect(self.exportAddScaleBar) win.sigRangeChanged.connect( @@ -94,19 +97,19 @@ def exportToImageTriggered(self): self.exportMaskImage[:] = 0 self.exportMaskImageItem.setImage(self.exportMaskImage) self.exportToImageWindow = None - self.logger.info('Export to image process cancelled') + self.logger.info("Export to image process cancelled") return isTransparent = self.overlayToolbar.isTransparent() - if not isTransparent: - # SVG export works only with RGBA not with setOpacity + if not isTransparent: + # SVG export works only with RGBA not with setOpacity # --> only true transparency mode can be used self.overlayToolbar.setTransparent(True) - + self.exportToImage(win.selected_preferences) self.exportToImageWindow = None - - if not isTransparent: + + if not isTransparent: self.overlayToolbar.setTransparent(False) def exportToVideoAddTimestamp(self, checked): @@ -116,138 +119,135 @@ def exportToVideoFinished(self, conversion_to_mp4_successful): self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - + # Back to current frame - if self.exportToVideoPreferences['is_timelapse']: + if self.exportToVideoPreferences["is_timelapse"]: posData = self.data[self.pos_i] - posData.frame_i = self.exportToVideoNavVarIdxToRestore + posData.frame_i = self.exportToVideoNavVarIdxToRestore self.get_data() self.store_data() self.updateAllImages() - self.navigateScrollBar.setSliderPosition(posData.frame_i+1) - self.navSpinBox.setValue(posData.frame_i+1) + self.navigateScrollBar.setSliderPosition(posData.frame_i + 1) + self.navSpinBox.setValue(posData.frame_i + 1) else: self.update_z_slice(self.exportToVideoNavVarIdxToRestore) - + self.setDisabled(False) self.isExportingVideo = False - + if not self.isTransparent: - # True transparency mode was activated programmatically + # True transparency mode was activated programmatically # --> restore what the user had before starting to export self.overlayToolbar.setTransparent(False) - + prompts.exportToVideoFinished( - self.exportToVideoPreferences, conversion_to_mp4_successful, - qparent=self + self.exportToVideoPreferences, conversion_to_mp4_successful, qparent=self ) def exportToVideoTriggered(self): posData = self.data[self.pos_i] - + doTimelapseVideo = posData.SizeT > 1 if posData.SizeT > 1 and posData.SizeZ > 1: doTimelapseVideo = self.askTimelapseOrZslicesVideo() - + if doTimelapseVideo is None: - self.logger.info('Export to video process cancelled') + self.logger.info("Export to video process cancelled") return - + channels = [self.user_ch_name, *self.checkedOverlayChannels] - mode = 'timelapse' if doTimelapseVideo else 'z_slices' - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = f'{timestamp}_acdc_exported_{mode}_video' + mode = "timelapse" if doTimelapseVideo else "z_slices" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{timestamp}_acdc_exported_{mode}_video" win = apps.ExportToVideoParametersDialog( channels, - parent=self, - startFolderpath=posData.pos_path, - startFilename=filename, - startFrameNum=posData.frame_i+1, - SizeT=posData.SizeT, + parent=self, + startFolderpath=posData.pos_path, + startFilename=filename, + startFrameNum=posData.frame_i + 1, + SizeT=posData.SizeT, SizeZ=posData.SizeZ, isTimelapseVideo=doTimelapseVideo, - isScaleBarPresent=self.addScaleBarAction.isChecked(), + isScaleBarPresent=self.addScaleBarAction.isChecked(), isTimestampPresent=self.addTimestampAction.isChecked(), - rescaleIntensChannelHowMapper=self.rescaleIntensChannelHowMapper + rescaleIntensChannelHowMapper=self.rescaleIntensChannelHowMapper, ) win.sigAddScaleBar.connect(self.exportAddScaleBar) win.sigAddTimestamp.connect(self.exportToVideoAddTimestamp) win.sigRescaleIntensLut.connect(self.rescaleIntensExportToVideoDialog) win.exec_() if win.cancel: - self.logger.info('Export to video process cancelled') + self.logger.info("Export to video process cancelled") return - + cancel = _warnings.warnExportToVideo(qparent=self) if cancel: - self.logger.info('Export to video process cancelled') + self.logger.info("Export to video process cancelled") return - - self.startExportToVideoWorker(win.selected_preferences) + + self.startExportToVideoWorker(win.selected_preferences) def exportingFramesFinished(self): - if not self.exportToVideoPreferences['save_pngs']: - self.logger.info('Removing PNGs...') + if not self.exportToVideoPreferences["save_pngs"]: + self.logger.info("Removing PNGs...") try: - shutil.rmtree(self.exportToVideoPreferences['pngs_folderpath']) + shutil.rmtree(self.exportToVideoPreferences["pngs_folderpath"]) except Exception as err: pass - - self.logger.info('Saving video...') - + + self.logger.info("Saving video...") + self.exportToVideoExporter.release() - + # Run ffmpeg new process conversion_to_mp4_successful = True - if self.exportToVideoPreferences['filepath'].endswith('.mp4'): + if self.exportToVideoPreferences["filepath"].endswith(".mp4"): try: self.exportToVideoExporter.avi_to_mp4() try: - os.remove(self.exportToVideoPreferences['avi_filepath']) + os.remove(self.exportToVideoPreferences["avi_filepath"]) except Exception as err: pass except Exception as err: self.logger.exception(traceback.format_exc()) - self.logger.info( - 'Conversion to MP4 failed. See traceback above.' - ) + self.logger.info("Conversion to MP4 failed. See traceback above.") conversion_to_mp4_successful = False - self.exportToVideoPreferences['filepath'] = ( + self.exportToVideoPreferences["filepath"] = ( self.exportToVideoExporter._avi_filepath ) - + self.exportToVideoFinished(conversion_to_mp4_successful) def exportingVideoCritical(self): self.setDisabled(False) - + self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - - self.logger.info('Exporting video process failed.') + + self.logger.info("Exporting video process failed.") def getZoomIDs(self, viewRange=None): if viewRange is None: viewRange = self.ax1.viewRange() - + lab = self.currentLab2D Y, X = lab.shape ((xmin, xmax), (ymin, ymax)) = viewRange if xmin <= 0 and ymin <= 0 and xmax >= X and ymax >= Y: posData = self.data[self.pos_i] return None - + xmin = xmin if xmin >= 0 else 0 ymin = ymin if ymin >= 0 else 0 xmax = xmax if xmax < X else X ymax = ymax if ymax < Y else Y - + zoomSlice = ( - slice(round(ymin), round(ymax)), - slice(round(xmin), round(xmax)), + slice(round(ymin), round(ymax)), + slice(round(xmin), round(xmax)), ) - + zoomLab = skimage.segmentation.clear_border(lab[zoomSlice]) zoomRp = skimage.measure.regionprops(zoomLab) zoomIDs = [obj.label for obj in zoomRp] @@ -258,35 +258,35 @@ def initExportMaskImage(self): z_slice = self.z_lab() img = posData.img_data[posData.frame_i] Y, X = img[z_slice].shape[-2:] - + self.exportMaskImage = np.zeros((Y, X, 4), dtype=np.uint8) def onSigUpdateCcaTableWindow(self, *args): if not self.isDataLoaded: return - + if self.ccaTableWin is None: return - + viewRange = self.ax1.viewRange() posData = self.data[self.pos_i] zoomIDs = self.getZoomIDs(viewRange=viewRange) - + self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) def setExportMaskImage(self, viewRange): - if not hasattr(self, 'exportMaskImage'): + if not hasattr(self, "exportMaskImage"): self.initExportMaskImage() else: self.exportMaskImage[:] = 0 - + xRange, yRange = viewRange x0, x1 = map(round, xRange) y0, y1 = map(round, yRange) - + if self.invertBwAction.isChecked(): self.exportMaskImage[:, :, :3] = 255 - + if x0 > 0: self.exportMaskImage[:, :x0, 3] = 255 if x1 < self.exportMaskImage.shape[1]: @@ -295,7 +295,7 @@ def setExportMaskImage(self, viewRange): self.exportMaskImage[:y0, :, 3] = 255 if y1 < self.exportMaskImage.shape[0]: self.exportMaskImage[y1:, :, 3] = 255 - + self.exportMaskImageItem.setImage(self.exportMaskImage) def setViewRangeFromExportToImageDialog(self, viewRange, win=None): @@ -311,76 +311,69 @@ def setViewRangeFromExportToImageDialog(self, viewRange, win=None): def startExportToVideoWorker(self, preferences): self.isExportingVideo = True self.isTransparent = self.overlayToolbar.isTransparent() - if not self.isTransparent: - # SVG export works only with RGBA not with setOpacity + if not self.isTransparent: + # SVG export works only with RGBA not with setOpacity # --> only true transparency mode can be used self.overlayToolbar.setTransparent(True) - + self.setDisabled(True) - + self.progressWin = apps.QDialogWorkerProgress( - title='Exporting to video', parent=self.mainWin, - pbarDesc='Exporting to video...' + title="Exporting to video", + parent=self.mainWin, + pbarDesc="Exporting to video...", ) self.progressWin.show(self.app) - self.exportToVideoStopNavVarNum = preferences['stop_nav_var_num'] + self.exportToVideoStopNavVarNum = preferences["stop_nav_var_num"] self.numFramesExported = 0 self.progressWin.mainPbar.setMaximum( - preferences['stop_nav_var_num'] - - preferences['start_nav_var_num'] + 1 + preferences["stop_nav_var_num"] - preferences["start_nav_var_num"] + 1 ) self.exportToVideoPreferences = preferences - + self.store_data() posData = self.data[self.pos_i] - if self.exportToVideoPreferences['is_timelapse']: - # Go to requested start frame - posData.frame_i = preferences['start_nav_var_num'] - 1 + if self.exportToVideoPreferences["is_timelapse"]: + # Go to requested start frame + posData.frame_i = preferences["start_nav_var_num"] - 1 self.get_data() self.updateAllImages() self.exportToVideoNavVarIdxToRestore = posData.frame_i else: - self.update_z_slice(preferences['start_nav_var_num'] - 1) - self.exportToVideoNavVarIdxToRestore = ( - self.zSliceScrollBar.sliderPosition() - ) - self.exportToVideoCurrentNavVarIdx = ( - preferences['start_nav_var_num'] - 1 - ) - + self.update_z_slice(preferences["start_nav_var_num"] - 1) + self.exportToVideoNavVarIdxToRestore = self.zSliceScrollBar.sliderPosition() + self.exportToVideoCurrentNavVarIdx = preferences["start_nav_var_num"] - 1 + self.exportToVideoImageExporter = exporters.ImageExporter( - self.ax1, - save_pngs=preferences['save_pngs'], - dpi=preferences['dpi'] + self.ax1, save_pngs=preferences["save_pngs"], dpi=preferences["dpi"] ) self.exportToVideoExporter = exporters.VideoExporter( - preferences['avi_filepath'], preferences['fps'] + preferences["avi_filepath"], preferences["fps"] ) - + QTimer.singleShot(200, self.updateAndExportFrame) def updateAndExportFrame(self): didVideoExporterFinish = ( - self.exportToVideoCurrentNavVarIdx - == self.exportToVideoStopNavVarNum + self.exportToVideoCurrentNavVarIdx == self.exportToVideoStopNavVarNum ) if didVideoExporterFinish: self.progressWin.mainPbar.setMaximum(0) self.progressWin.mainPbar.setValue(0) QTimer.singleShot(50, self.exportingFramesFinished) return - + posData = self.data[self.pos_i] - if self.exportToVideoPreferences['is_timelapse']: - self.goToFrameNumber(self.exportToVideoCurrentNavVarIdx+1) + if self.exportToVideoPreferences["is_timelapse"]: + self.goToFrameNumber(self.exportToVideoCurrentNavVarIdx + 1) else: self.update_z_slice(self.exportToVideoCurrentNavVarIdx) - + success = self.exportFrame() if success is None: self.exportingVideoCritical() return - + self.exportToVideoCurrentNavVarIdx += 1 self.progressWin.mainPbar.update(1) @@ -389,33 +382,33 @@ def updateAndExportFrame(self): def updateViewRangeExportToImage(self, viewRange): if self.exportToImageWindow is None: return - + # prevViewRange = self.exportToImageWindow.viewRange() prevViewRange = self._viewRange prevXRange = prevViewRange[0] prevYRange = prevViewRange[1] currXRange = viewRange[0] currYRange = viewRange[1] - + prevX0, prevX1 = prevXRange currX0, currX1 = currXRange prevY0, prevY1 = prevYRange currY0, currY1 = currYRange - + deltaX = currX0 - prevX0 deltaY = currY0 - prevY0 - + winViewRange = self.exportToImageWindow.viewRange() winXRange = winViewRange[0] winYRange = winViewRange[1] winX0, winX1 = winXRange winY0, winY1 = winYRange - + newX0 = winX0 + deltaX newX1 = winX1 + deltaX newY0 = winY0 + deltaY newY1 = winY1 + deltaY - + self.exportToImageWindow.setViewRange( (newX0, newX1), (newY0, newY1), emitSignal=False ) diff --git a/cellacdc/mixins/frame_navigation.py b/cellacdc/mixins/frame_navigation.py index 04aa01fb4..e64ec8943 100644 --- a/cellacdc/mixins/frame_navigation.py +++ b/cellacdc/mixins/frame_navigation.py @@ -21,6 +21,7 @@ from .graphics import Graphics from .label_editing import LabelEditing + class FrameNavigation(Graphics, LabelEditing): """Extracted from guiWin.""" @@ -36,9 +37,9 @@ def PosScrollBarAction(self, action): def PosScrollBarMoved(self, pos_n): if self.navigateScrollBarStartedMoving: - self.store_data() - - self.pos_i = pos_n-1 + self.store_data() + + self.pos_i = pos_n - 1 self.updateFramePosLabel() proceed_cca, never_visited = self.get_data() self.updateAllImages() @@ -47,11 +48,11 @@ def PosScrollBarMoved(self, pos_n): def PosScrollBarReleased(self): self.navigateScrollBarStartedMoving = True - if self.pos_i == self.navigateScrollBar.sliderPosition()-1: + if self.pos_i == self.navigateScrollBar.sliderPosition() - 1: # Slider released without changing value --> do nothing return - - self.pos_i = self.navigateScrollBar.sliderPosition()-1 + + self.pos_i = self.navigateScrollBar.sliderPosition() - 1 self.updateFramePosLabel() self.updatePos() @@ -60,70 +61,73 @@ def _setViewRangeSwitchPlane(self, previousPlane): SizeZ = posData.SizeZ SizeY, SizeX = self.img1.image.shape[:2] currentPlane = self.switchPlaneCombobox.plane() - if previousPlane == 'xy': - if currentPlane == 'zy': + if previousPlane == "xy": + if currentPlane == "zy": self.ax1.setRange(xRange=self.yRangePrev) unusedRange = np.clip(self.xRangePrev, 0, SizeX) - elif currentPlane == 'zx': + elif currentPlane == "zx": self.ax1.setRange(xRange=self.xRangePrev) unusedRange = np.clip(self.yRangePrev, 0, SizeY) - elif previousPlane == 'zy': - if currentPlane == 'xy': + elif previousPlane == "zy": + if currentPlane == "xy": self.ax1.setRange(yRange=self.xRangePrev) unusedRange = np.clip(self.yRangePrev, 0, SizeZ) - elif currentPlane == 'zx': + elif currentPlane == "zx": self.ax1.setRange(yRange=self.yRangePrev) unusedRange = np.clip(self.xRangePrev, 0, SizeY) - elif previousPlane == 'zx': - if currentPlane == 'xy': + elif previousPlane == "zx": + if currentPlane == "xy": self.ax1.setRange(xRange=self.xRangePrev) unusedRange = np.clip(self.yRangePrev, 0, SizeZ) - elif currentPlane == 'zy': + elif currentPlane == "zy": self.ax1.setRange(yRange=self.yRangePrev) unusedRange = np.clip(self.xRangePrev, 0, SizeX) - - sliceValue = round((unusedRange[0] + unusedRange[1])/2) + + sliceValue = round((unusedRange[0] + unusedRange[1]) / 2) self.zSliceScrollBar.setSliderPosition(sliceValue) self.update_z_slice(self.zSliceScrollBar.sliderPosition()) def apply_tools_on_new_frame(self): mode = str(self.modeComboBox.currentText()) - if mode != 'Segmentation and Tracking': + if mode != "Segmentation and Tracking": return posData = self.data[self.pos_i] - if not (posData.last_tracked_i <= posData.frame_i) or posData.frame_i == self.lastFrameRanOnFirstVisitTools: + if ( + not (posData.last_tracked_i <= posData.frame_i) + or posData.frame_i == self.lastFrameRanOnFirstVisitTools + ): return - + self.lastFrameRanOnFirstVisitTools = posData.frame_i for name, checkbox in self.applyToolNewFrameActions.items(): if not checkbox.isChecked(): continue - + tool_button = self.applyToolNewFrameButtons[name] try: - if hasattr(tool_button, 'click'): + if hasattr(tool_button, "click"): tool_button.click() - elif hasattr(tool_button, 'trigger'): + elif hasattr(tool_button, "trigger"): tool_button.trigger() else: - printl( - f"Warning: {name} has no click or trigger method" - ) + printl(f"Warning: {name} has no click or trigger method") except Exception as e: self.logger.info(f"Error applying tool {name}: {e}") def askInitCcaFirstFrame(self): mode = str(self.modeComboBox.currentText()) - if mode != 'Cell cycle analysis': + if mode != "Cell cycle analysis": return True - + posData = self.data[self.pos_i] if posData.frame_i != 0: return True - + editCcaWidget = apps.editCcaTableWidget( - posData.cca_df, posData.SizeT, parent=self, - title='Initialize cell cycle annotations' + posData.cca_df, + posData.SizeT, + parent=self, + title="Initialize cell cycle annotations", ) editCcaWidget.sigApplyChangesFutureFrames.connect( self.applyManualCcaChangesFutureFrames @@ -132,54 +136,53 @@ def askInitCcaFirstFrame(self): if editCcaWidget.cancel: self.resetNavigateFramesScrollbar() return False - + if posData.cca_df is not None: - is_cca_same_as_stored = ( - (posData.cca_df == editCcaWidget.cca_df).all(axis=None) + is_cca_same_as_stored = (posData.cca_df == editCcaWidget.cca_df).all( + axis=None ) if not is_cca_same_as_stored: reinit_cca = self.warnEditingWithCca_df( - 'Re-initialize cell cyle annotations first frame', - return_answer=True + "Re-initialize cell cyle annotations first frame", + return_answer=True, ) if reinit_cca: self.resetCcaFuture(0) - + posData.cca_df = editCcaWidget.cca_df self.store_cca_df() - + return True def askInitLinTreeFirstFrame(self): mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree': + if mode != "Normal division: Lineage tree": return True - + posData = self.data[self.pos_i] if posData.frame_i != 0: return True - + if self.lineage_tree is None: - self.initLinTree() - + self.initLinTree() + return True def checkIfFutureFrameManualAnnotPastFrames(self): if not self.manualAnnotPastButton.isChecked(): return True - + posData = self.data[self.pos_i] - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + frame_to_restore = self.manualAnnotState.get("frame_i_to_restore") if posData.frame_i <= frame_to_restore: return True - + warn_txt = ( - 'WARNING: Cannot navigate to future frames while in ' - 'manual annotation mode.' + "WARNING: Cannot navigate to future frames while in manual annotation mode." ) self.logger.info(warn_txt) self.statusBarLabel.setText(f'

    {warn_txt}

    ') - + return False def connectScrollbars(self): @@ -189,9 +192,9 @@ def connectScrollbars(self): if self.data[0].SizeZ > 1: self.enableZstackWidgets(True) - self.zSliceScrollBar.setMaximum(self.data[0].SizeZ-1) + self.zSliceScrollBar.setMaximum(self.data[0].SizeZ - 1) self.zSliceSpinbox.setMaximum(self.data[0].SizeZ) - self.SizeZlabel.setText(f'/{self.data[0].SizeZ}') + self.SizeZlabel.setText(f"/{self.data[0].SizeZ}") try: self.zSliceScrollBar.actionTriggered.disconnect() self.zSliceScrollBar.sliderReleased.disconnect() @@ -204,42 +207,42 @@ def connectScrollbars(self): self.zSliceScrollBar.actionTriggered.connect( self.zSliceScrollBarActionTriggered ) - self.zSliceScrollBar.sliderReleased.connect( - self.zSliceScrollBarReleased - ) + self.zSliceScrollBar.sliderReleased.connect(self.zSliceScrollBarReleased) self.zProjComboBox.currentTextChanged.connect(self.updateZproj) self.zProjComboBox.activated.connect(self.clearComboBoxFocus) - self.switchPlaneCombobox.sigPlaneChanged.connect( - self.switchViewedPlane - ) + self.switchPlaneCombobox.sigPlaneChanged.connect(self.switchViewedPlane) self.zProjLockViewButton.toggled.connect(self.zProjLockViewToggled) posData = self.data[self.pos_i] if posData.SizeT == 1: - self.t_label.setText('Position n.') + self.t_label.setText("Position n.") self.navigateScrollBar.setMinimum(1) self.navigateScrollBar.setMaximum(len(self.data)) self.navigateScrollBar.setAbsoluteMaximum(len(self.data)) self.navSpinBox.setMaximum(len(self.data)) - self.navigateScrollBar.connectEvents({ - 'sliderMoved': self.PosScrollBarMoved, - 'sliderReleased': self.PosScrollBarReleased, - 'actionTriggered': self.PosScrollBarAction - }) + self.navigateScrollBar.connectEvents( + { + "sliderMoved": self.PosScrollBarMoved, + "sliderReleased": self.PosScrollBarReleased, + "actionTriggered": self.PosScrollBarAction, + } + ) else: self.navigateScrollBar.setMinimum(1) self.navigateScrollBar.setAbsoluteMaximum(posData.SizeT) self.rightImageFramesScrollbar.setMinimum(1) self.rightImageFramesScrollbar.setMaximum(posData.SizeT) if posData.last_tracked_i is not None: - self.navigateScrollBar.setMaximum(posData.last_tracked_i+1) - self.navSpinBox.setMaximum(posData.last_tracked_i+1) - self.t_label.setText('Frame n.') - self.navigateScrollBar.connectEvents({ - 'sliderMoved': self.framesScrollBarMoved, - 'sliderReleased': self.framesScrollBarReleased, - 'actionTriggered': self.framesScrollBarActionTriggered - }) + self.navigateScrollBar.setMaximum(posData.last_tracked_i + 1) + self.navSpinBox.setMaximum(posData.last_tracked_i + 1) + self.t_label.setText("Frame n.") + self.navigateScrollBar.connectEvents( + { + "sliderMoved": self.framesScrollBarMoved, + "sliderReleased": self.framesScrollBarReleased, + "actionTriggered": self.framesScrollBarActionTriggered, + } + ) self.rightImageFramesScrollbar.connectValueChanged( self.rightImageFramesScrollbarValueChanged ) @@ -276,18 +279,18 @@ def framesScrollBarActionTriggered(self, action): def framesScrollBarMoved(self, frame_n): if self.navigateScrollBarStartedMoving: mode = str(self.modeComboBox.currentText()) - if mode != 'Viewer': + if mode != "Viewer": self.store_data(debug=False) - + posData = self.data[self.pos_i] - posData.frame_i = frame_n-1 - if posData.allData_li[posData.frame_i]['labels'] is None: + posData.frame_i = frame_n - 1 + if posData.allData_li[posData.frame_i]["labels"] is None: if posData.frame_i < len(posData.segm_data): posData.lab = posData.segm_data[posData.frame_i] else: posData.lab = np.zeros_like(posData.segm_data[0]) else: - posData.lab = posData.allData_li[posData.frame_i]['labels'] + posData.lab = posData.allData_li[posData.frame_i]["labels"] self.setImageImg1() if self.overlayButton.isChecked(): @@ -296,7 +299,7 @@ def framesScrollBarMoved(self, frame_n): if self.navigateScrollBarStartedMoving: self.clearAllItems() - self.navSpinBox.setValueNoEmit(posData.frame_i+1) + self.navSpinBox.setValueNoEmit(posData.frame_i + 1) if self.labelsGrad.showLabelsImgAction.isChecked(): self.img2.setImage(posData.lab, z=self.z_lab(), autoLevels=False) self.updateLookuptable() @@ -308,16 +311,16 @@ def framesScrollBarMoved(self, frame_n): def framesScrollBarReleased(self, do_store_data=False): posData = self.data[self.pos_i] - if posData.frame_i == self.navigateScrollBar.sliderPosition()-1: + if posData.frame_i == self.navigateScrollBar.sliderPosition() - 1: # Slider released without changing value --> do nothing return - + mode = str(self.modeComboBox.currentText()) - if mode != 'Viewer' and do_store_data: + if mode != "Viewer" and do_store_data: self.store_data(debug=False) - + self.navigateScrollBarStartedMoving = True - posData.frame_i = self.navigateScrollBar.sliderPosition()-1 + posData.frame_i = self.navigateScrollBar.sliderPosition() - 1 self.updateFramePosLabel() proceed_cca, never_visited = self.get_data() self.updateAllImages() @@ -325,7 +328,7 @@ def framesScrollBarReleased(self, do_store_data=False): def goToZsliceSearchedID(self, obj): if not self.isSegm3D: return - + current_z = self.z_lab() nearest_nonzero_z = core.nearest_nonzero_z_idx_from_z_centroid( obj, current_z=current_z @@ -333,7 +336,7 @@ def goToZsliceSearchedID(self, obj): if nearest_nonzero_z == current_z: self.drawPointsLayers(computePointsLayers=True) return - + self.zSliceScrollBar.setSliderPosition(nearest_nonzero_z) self.update_z_slice(nearest_nonzero_z) @@ -341,36 +344,36 @@ def isNavigateActionOnNextFrame(self): posData = self.data[self.pos_i] if posData.SizeT == 1: return False - + ax1_coords = self.getMouseDataCoordsRightImage() if ax1_coords is None: return False - + if not self.labelsGrad.showNextFrameAction.isEnabled(): return False - + if not self.labelsGrad.showNextFrameAction.isChecked(): return - + # Mouse is on right image and next frame action is checked - return True + return True def manualAnnotRestoreLastTrackedFrame(self, last_tracked_i_to_restore): - if self.navigateScrollBar.maximum()-1 <= last_tracked_i_to_restore: + if self.navigateScrollBar.maximum() - 1 <= last_tracked_i_to_restore: return - + posData = self.data[self.pos_i] - for frame_i in range(last_tracked_i_to_restore+1, posData.SizeT): + for frame_i in range(last_tracked_i_to_restore + 1, posData.SizeT): data_frame_i = myutils.get_empty_stored_data_dict() - - data_frame_i['manually_edited_lab'] = ( - posData.allData_li[frame_i]['manually_edited_lab'] - ) - + + data_frame_i["manually_edited_lab"] = posData.allData_li[frame_i][ + "manually_edited_lab" + ] + posData.allData_li[frame_i] = data_frame_i - - self.navigateScrollBar.setMaximum(last_tracked_i_to_restore+1) - self.navSpinBox.setMaximum(last_tracked_i_to_restore+1) + + self.navigateScrollBar.setMaximum(last_tracked_i_to_restore + 1) + self.navSpinBox.setMaximum(last_tracked_i_to_restore + 1) def navigateSpinboxEditingFinished(self): if self.isSnapshot: @@ -389,10 +392,10 @@ def navigateSpinboxValueChanged(self, value): def nextActionTriggered(self): if self.isNavigateActionOnNextFrame(): self.rightImageFramesScrollbar.setValue( - self.rightImageFramesScrollbar.value()+1 + self.rightImageFramesScrollbar.value() + 1 ) return - + stepAddAction = QAbstractSlider.SliderAction.SliderSingleStepAdd if self.zKeptDown or self.zSliceCheckbox.isChecked(): self.zSliceScrollBar.triggerAction(stepAddAction) @@ -402,25 +405,25 @@ def nextActionTriggered(self): def nextFrameImage(self, current_frame_i=None): if not self.labelsGrad.showNextFrameAction.isEnabled(): return - + if not self.labelsGrad.showNextFrameAction.isChecked(): return - + posData = self.data[self.pos_i] if current_frame_i is None: current_frame_i = posData.frame_i - + next_frame_i = current_frame_i + 1 if next_frame_i >= len(posData.img_data): img = posData.img_data[-1] else: img = posData.img_data[next_frame_i] - + if posData.SizeZ > 1: img = self.get_2Dimg_from_3D(img, isLayer0=True) - + # img = self.normalizeIntensities(img) - + return img def next_cb(self): @@ -430,30 +433,30 @@ def next_cb(self): self.next_frame() if self.curvToolButton.isChecked(): self.curvTool_cb(True) - - self.updatePropsWidget('') - def next_frame(self, warn=True): + self.updatePropsWidget("") + + def next_frame(self, warn=True): proceed = self.checkIfFutureFrameManualAnnotPastFrames() if not proceed: return - + proceed = self.askInitCcaFirstFrame() if not proceed: return - + proceed = self.askInitLinTreeFirstFrame() if not proceed: return - + mode = str(self.modeComboBox.currentText()) posData = self.data[self.pos_i] - - if posData.frame_i >= posData.SizeT-1: + + if posData.frame_i >= posData.SizeT - 1: # Store data for current frame - if mode != 'Viewer': + if mode != "Viewer": self.store_data(debug=False) - msg = 'You reached the last segmented frame!' + msg = "You reached the last segmented frame!" self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) return @@ -461,10 +464,10 @@ def next_frame(self, warn=True): proceed = self.warnLostObjects() if not proceed: self.resetNavigateScrollbar() - return + return # Store data for current frame - if mode != 'Viewer': + if mode != "Viewer": self.store_data(debug=False) self.askLineageTreeChanges() @@ -474,34 +477,30 @@ def next_frame(self, warn=True): if not proceed_cca: posData.frame_i -= 1 self.get_data() - self.logger.info( - 'No data for current frame. ' - ) + self.logger.info("No data for current frame. ") return - - if mode == 'Segmentation and Tracking' or self.isSnapshot: + + if mode == "Segmentation and Tracking" or self.isSnapshot: self.addExistingDelROIs() - + self.updatePreprocessPreview() self.updateCombineChannelsPreview() self.postProcessing() - self.tracking(storeUndo=True, wl_update=False) + self.tracking(storeUndo=True, wl_update=False) notEnoughG1Cells, proceed = self.attempt_auto_cca() if notEnoughG1Cells or not proceed: posData.frame_i -= 1 self.get_data() self.setAllTextAnnotations() - self.logger.info( - 'Not enough G1 cells to compute cell cycle annotations.' - ) + self.logger.info("Not enough G1 cells to compute cell cycle annotations.") return - + self.store_zslices_rp() self.resetExpandLabel() self.updateAllImages() self.updateHighlightedAxis() self.updateViewerWindow() - self.updateLastVisitedFrame(last_visited_frame_i=posData.frame_i-1) + self.updateLastVisitedFrame(last_visited_frame_i=posData.frame_i - 1) self.setNavigateScrollBarMaximum() self.updateScrollbars() self.computeSegm() @@ -510,30 +509,30 @@ def next_frame(self, warn=True): self.zoomToCells() self.updateItemsMousePos() self.updateObjectCounts() - + self.apply_tools_on_new_frame() def next_pos(self): self.store_data(debug=True, autosave=False) prev_pos_i = self.pos_i - if self.pos_i < self.num_pos-1: + if self.pos_i < self.num_pos - 1: self.pos_i += 1 self.updateSegmDataAutoSaveWorker() else: - self.logger.info('You reached last position.') + self.logger.info("You reached last position.") self.pos_i = 0 self.updatePos() def onZsliceSpinboxValueChange(self, value): - self.zSliceScrollBar.setSliderPosition(value-1) + self.zSliceScrollBar.setSliderPosition(value - 1) def prevActionTriggered(self): if self.isNavigateActionOnNextFrame(): self.rightImageFramesScrollbar.setValue( - self.rightImageFramesScrollbar.value()-1 + self.rightImageFramesScrollbar.value() - 1 ) return - + stepSubAction = QAbstractSlider.SliderAction.SliderSingleStepSub if self.zKeptDown or self.zSliceCheckbox.isChecked(): self.zSliceScrollBar.triggerAction(stepSubAction) @@ -547,30 +546,30 @@ def prev_cb(self): self.prev_frame() if self.curvToolButton.isChecked(): self.curvTool_cb(True) - - self.updatePropsWidget('') + + self.updatePropsWidget("") def prev_frame(self): - posData = self.data[self.pos_i] + posData = self.data[self.pos_i] if posData.frame_i <= 0: - msg = 'You reached the first frame!' + msg = "You reached the first frame!" self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) return - + # Store data for current frame mode = str(self.modeComboBox.currentText()) - if mode != 'Viewer': + if mode != "Viewer": self.store_data(debug=False) - - self.removeAlldelROIsCurrentFrame() + + self.removeAlldelROIsCurrentFrame() self.askLineageTreeChanges() posData.frame_i -= 1 _, never_visited = self.get_data() - - if mode == 'Segmentation and Tracking' or self.isSnapshot: + + if mode == "Segmentation and Tracking" or self.isSnapshot: self.addExistingDelROIs() - + self.resetExpandLabel() self.updatePreprocessPreview() self.updateCombineChannelsPreview() @@ -593,63 +592,60 @@ def prev_pos(self): self.pos_i -= 1 self.updateSegmDataAutoSaveWorker() else: - self.logger.info('You reached first position.') - self.pos_i = self.num_pos-1 + self.logger.info("You reached first position.") + self.pos_i = self.num_pos - 1 self.updatePos() def reInitLastSegmFrame( - self, checked=True, from_frame_i=None, updateImages=True, - force=False - ): + self, checked=True, from_frame_i=None, updateImages=True, force=False + ): if not force: cancel = self.warnReinitLastSegmFrame() if cancel: - self.logger.info( - 'Re-initialization of last validated frame cancelled.' - ) + self.logger.info("Re-initialization of last validated frame cancelled.") return posData = self.data[self.pos_i] if from_frame_i is None: from_frame_i = posData.frame_i - + self.lastFrameRanOnFirstVisitTools = posData.frame_i - + self.updateLastCheckedFrameWidgets(from_frame_i) posData.last_tracked_i = from_frame_i - self.navigateScrollBar.setMaximum(from_frame_i+1) - self.navSpinBox.setMaximum(from_frame_i+1) + self.navigateScrollBar.setMaximum(from_frame_i + 1) + self.navSpinBox.setMaximum(from_frame_i + 1) # self.navigateScrollBar.setMinimum(1) - + # posData.tracked_lost_centroids[from_frame_i-1] = set() for i in range(from_frame_i, posData.SizeT): - if posData.allData_li[i]['labels'] is None: + if posData.allData_li[i]["labels"] is None: break - - posData.segm_data[i] = posData.allData_li[i]['labels'] + + posData.segm_data[i] = posData.allData_li[i]["labels"] posData.allData_li[i] = myutils.get_empty_stored_data_dict() - + posData.tracked_lost_centroids[i] = set() - posData.acdcTracker2stepsAnnotInfo.pop(i, None) - + posData.acdcTracker2stepsAnnotInfo.pop(i, None) + if posData.acdc_df is not None: frames = posData.acdc_df.index.get_level_values(0) if from_frame_i in frames: posData.acdc_df = posData.acdc_df.loc[:from_frame_i] - + self.removeAlldelROIsCurrentFrame() - + if not updateImages: return - + self.updateAllImages() def resetAcceptedLostIDs(self, from_frame_i=None): posData = self.data[self.pos_i] if from_frame_i is None: from_frame_i = posData.frame_i - - posData.tracked_lost_centroids[from_frame_i-1] = set() + + posData.tracked_lost_centroids[from_frame_i - 1] = set() for i in range(from_frame_i, posData.SizeT): posData.tracked_lost_centroids[i] = set() @@ -657,8 +653,8 @@ def resetNavigateFramesScrollbar(self, frame_i=None): posData = self.data[self.pos_i] if frame_i is None: frame_i = posData.frame_i - - self.navigateScrollBar.setValueNoSignal(frame_i+1) + + self.navigateScrollBar.setValueNoSignal(frame_i + 1) def resetNavigateScrollbar(self): try: @@ -681,7 +677,7 @@ def resetNavigateScrollbar(self): self.navigateScrollBar.sliderMoved.connect(self.framesScrollBarMoved) def rightImageFramesScrollbarValueChanged(self, value): - img = self.nextFrameImage(current_frame_i=value-2) + img = self.nextFrameImage(current_frame_i=value - 2) self.img1.linkedImageItem.frame_i = value self.img1.linkedImageItem.setImage(img) @@ -689,7 +685,7 @@ def setFrameNavigationDisabled(self, disable: bool, why: str): """Disables the frame navigation buttons and scrollbar. This is used when the user is not allowed to navigate through frames Call again to unlock it again. Also sets tooltips to inform the user - + Parameters ---------- disable : bool @@ -697,7 +693,7 @@ def setFrameNavigationDisabled(self, disable: bool, why: str): why : str the reason for disabeling the navigation. """ - + if disable: self.whyNavigateDisabled.add(why) else: @@ -705,7 +701,7 @@ def setFrameNavigationDisabled(self, disable: bool, why: str): self.whyNavigateDisabled.remove(why) except KeyError: pass - + if len(self.whyNavigateDisabled) == 0: disable = False else: @@ -715,61 +711,61 @@ def setFrameNavigationDisabled(self, disable: bool, why: str): self.prevAction.setDisabled(disable) self.nextAction.setDisabled(disable) self.navigateScrollBar.setDisabled(disable) - + # Set appropriate tooltip if not disable: self.navigateScrollBar.setToolTip( - 'NOTE: The maximum frame number that can be visualized with this ' - 'scrollbar\n' - 'is the last visited frame with the selected mode\n' + "NOTE: The maximum frame number that can be visualized with this " + "scrollbar\n" + "is the last visited frame with the selected mode\n" '(see "Mode" selector on the top-right).\n\n' - 'If the scrollbar does not move it means that you never visited\n' - 'any frame with current mode.\n\n' + "If the scrollbar does not move it means that you never visited\n" + "any frame with current mode.\n\n" 'Note that the "Viewer" mode allows you to scroll ALL frames.' ) return - - txt = f'Frame navigation disabled: {self.whyNavigateDisabled}' + + txt = f"Frame navigation disabled: {self.whyNavigateDisabled}" self.logger.info(txt) self.navigateScrollBar.setToolTip(txt) def setNavigateScrollBarMaximum(self): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if mode == 'Segmentation and Tracking': + if mode == "Segmentation and Tracking": if posData.last_tracked_i is not None: if posData.frame_i > posData.last_tracked_i: - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) + self.navigateScrollBar.setMaximum(posData.frame_i + 1) + self.navSpinBox.setMaximum(posData.frame_i + 1) else: - self.navigateScrollBar.setMaximum(posData.last_tracked_i+1) - self.navSpinBox.setMaximum(posData.last_tracked_i+1) + self.navigateScrollBar.setMaximum(posData.last_tracked_i + 1) + self.navSpinBox.setMaximum(posData.last_tracked_i + 1) else: - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) - - self.updateLastCheckedFrameWidgets(self.navSpinBox.maximum()-1) - elif mode == 'Cell cycle analysis': + self.navigateScrollBar.setMaximum(posData.frame_i + 1) + self.navSpinBox.setMaximum(posData.frame_i + 1) + + self.updateLastCheckedFrameWidgets(self.navSpinBox.maximum() - 1) + elif mode == "Cell cycle analysis": if posData.frame_i > self.last_cca_frame_i: - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) + self.navigateScrollBar.setMaximum(posData.frame_i + 1) + self.navSpinBox.setMaximum(posData.frame_i + 1) else: - self.navigateScrollBar.setMaximum(self.last_cca_frame_i+1) - self.navSpinBox.setMaximum(self.last_cca_frame_i+1) + self.navigateScrollBar.setMaximum(self.last_cca_frame_i + 1) + self.navSpinBox.setMaximum(self.last_cca_frame_i + 1) self.lastTrackedFrameLabel.setText( - f'Last cc annot. frame n. = {self.navSpinBox.maximum()}' + f"Last cc annot. frame n. = {self.navSpinBox.maximum()}" ) - elif mode == 'Normal division: Lineage tree': + elif mode == "Normal division: Lineage tree": if self.lineage_tree is None: - self.navigateScrollBar.setMaximum(posData.frame_i+1) - self.navSpinBox.setMaximum(posData.frame_i+1) + self.navigateScrollBar.setMaximum(posData.frame_i + 1) + self.navSpinBox.setMaximum(posData.frame_i + 1) else: if self.lineage_tree.frames_for_dfs: i = max(self.lineage_tree.frames_for_dfs) else: i = 0 - self.navigateScrollBar.setMaximum(i+1) - self.navSpinBox.setMaximum(i+1) + self.navigateScrollBar.setMaximum(i + 1) + self.navSpinBox.setMaximum(i + 1) def setSwitchViewedPlaneDisabled(self, disabled): posData = self.data[self.pos_i] @@ -782,9 +778,7 @@ def setSwitchViewedPlaneDisabled(self, disabled): def setViewRangeSwitchPlane(self, previousPlane): self.autoRange() - QTimer.singleShot( - 100, partial(self._setViewRangeSwitchPlane, previousPlane) - ) + QTimer.singleShot(100, partial(self._setViewRangeSwitchPlane, previousPlane)) def setZprojDisabled(self, disabled, storePrevState=False): self.combineChannelsAction.setDisabled(disabled) @@ -792,19 +786,19 @@ def setZprojDisabled(self, disabled, storePrevState=False): button = self.editToolBar.widgetForAction(action) if button == self.eraserButton: continue - + if button in self.toolsActiveInProj3Dsegm: continue - + try: tooltip = button.toolTip() - prefix = 'WARNING: Disabled due to projection mode\n\n' + prefix = "WARNING: Disabled due to projection mode\n\n" if disabled: if not tooltip.startswith(prefix): button.setToolTip(prefix + tooltip) else: if tooltip.startswith(prefix): - button.setToolTip(tooltip[len(prefix):]) + button.setToolTip(tooltip[len(prefix) :]) except: pass action.setDisabled(disabled) @@ -817,20 +811,20 @@ def switchViewedPlane(self, previousPlane, currentPlane): posData = self.data[self.pos_i] self.xRangePrev, self.yRangePrev = self.ax1.viewRange() self.zSlicePrev = self.zSliceScrollBar.sliderPosition() - - self.zProjComboBox.setCurrentText('single z-slice') + + self.zProjComboBox.setCurrentText("single z-slice") depthAxes = self.switchPlaneCombobox.depthAxes() self.onEscape() self.initDelRoiLab() - if depthAxes != 'z': + if depthAxes != "z": # Disable projections on plane that is not xy - self.zProjComboBox.setCurrentText('single z-slice') + self.zProjComboBox.setCurrentText("single z-slice") self.zProjComboBox.setDisabled(True) - + # Clear annotations self.clearAllItems() self.setHighlightID(False) - + # Disable annotations on a plane that is not yz self.setDrawNothingAnnotations() self.setDisabledAnnotCheckBoxesLeft(True) @@ -848,42 +842,40 @@ def switchViewedPlane(self, previousPlane, currentPlane): if self.overlayButtonPrevState: self.overlayButton.setChecked(self.overlayButtonPrevState) self.updateZsliceScrollbar(posData.frame_i) - + SizeY, SizeX = posData.img_data[posData.frame_i].shape[-2:] - - if depthAxes != 'z' and self.isSnapshot: + + if depthAxes != "z" and self.isSnapshot: # Disable editing when the plane is not xy self.disableEditingViewPlaneNotXY() elif self.isSnapshot: # Re-enable editing in snapshot mode when the plane is xy self.setEnabledSnapshotMode() - - if depthAxes == 'z': + + if depthAxes == "z": maxSliceNum = posData.SizeZ - elif depthAxes == 'y': + elif depthAxes == "y": maxSliceNum = SizeY else: maxSliceNum = SizeX - - maxSliceText = f'/{maxSliceNum}' + + maxSliceText = f"/{maxSliceNum}" self.SizeZlabel.setText(maxSliceText) - self.zSliceCheckbox.setText(f'{depthAxes}-slice') - self.zSliceScrollBar.setMaximum(maxSliceNum-1) + self.zSliceCheckbox.setText(f"{depthAxes}-slice") + self.zSliceScrollBar.setMaximum(maxSliceNum - 1) self.zSliceSpinbox.setMaximum(maxSliceNum) - + self.initContoursImage() self.updateAllImages() - QTimer.singleShot( - 200, partial(self.setViewRangeSwitchPlane, previousPlane) - ) + QTimer.singleShot(200, partial(self.setViewRangeSwitchPlane, previousPlane)) def updateFramePosLabel(self): if self.isSnapshot: posData = self.data[self.pos_i] - self.navSpinBox.setValueNoEmit(self.pos_i+1) + self.navSpinBox.setValueNoEmit(self.pos_i + 1) else: posData = self.data[0] - self.navSpinBox.setValueNoEmit(posData.frame_i+1) + self.navSpinBox.setValueNoEmit(posData.frame_i + 1) def updateItemsMousePos(self): if self.brushButton.isChecked(): @@ -893,7 +885,7 @@ def updateItemsMousePos(self): self.updateEraserCursor(self.xHoverImg, self.yHoverImg) def updateOverlayZproj(self, how): - if how.find('max') != -1 or how == 'same as above': + if how.find("max") != -1 or how == "same as above": self.overlay_z_label.setDisabled(True) self.zSliceOverlay_SB.setDisabled(True) else: @@ -920,7 +912,7 @@ def updatePos(self): self.updateScrollbars() self.updatePreprocessPreview() self.updateCombineChannelsPreview() - self.updateAllImages() + self.updateAllImages() self.computeSegm() self.zoomOut() self.restartZoomAutoPilot() @@ -932,14 +924,14 @@ def updateScrollbars(self): self.updateItemsMousePos() self.updateFramePosLabel() posData = self.data[self.pos_i] - navPos = self.pos_i+1 if self.isSnapshot else posData.frame_i+1 + navPos = self.pos_i + 1 if self.isSnapshot else posData.frame_i + 1 self.navigateScrollBar.setSliderPosition(navPos) if posData.SizeZ > 1: self.updateZsliceScrollbar(posData.frame_i) idx = (posData.filename, posData.frame_i) - self.zSliceScrollBar.setMaximum(posData.SizeZ-1) + self.zSliceScrollBar.setMaximum(posData.SizeZ - 1) self.zSliceSpinbox.setMaximum(posData.SizeZ) - self.SizeZlabel.setText(f'/{posData.SizeZ}') + self.SizeZlabel.setText(f"/{posData.SizeZ}") def updateViewerWindow(self): if self.slideshowWin is None: @@ -956,19 +948,16 @@ def updateViewerWindow(self): self.slideshowWin.update_img() def updateZproj(self, how): - for p, posData in enumerate(self.data[self.pos_i:]): + for p, posData in enumerate(self.data[self.pos_i :]): if self.zProjLockViewButton.isChecked(): - idx = [ - (posData.filename, frame_i) - for frame_i in range(posData.SizeT) - ] + idx = [(posData.filename, frame_i) for frame_i in range(posData.SizeT)] else: idx = [(posData.filename, posData.frame_i)] - posData.segmInfo_df.loc[idx, 'which_z_proj_gui'] = how + posData.segmInfo_df.loc[idx, "which_z_proj_gui"] = how posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) - + posData = self.data[self.pos_i] - if how == 'single z-slice': + if how == "single z-slice": self.zSliceScrollBar.setDisabled(False) self.zSliceSpinbox.setDisabled(False) self.zSliceCheckbox.setDisabled(False) @@ -983,26 +972,21 @@ def updateZproj(self, how): def update_z_slice(self, z): posData = self.data[self.pos_i] - if self.switchPlaneCombobox.depthAxes() == 'z': + if self.switchPlaneCombobox.depthAxes() == "z": if self.zProjLockViewButton.isChecked(): - idx = [ - (posData.filename, frame_i) - for frame_i in range(posData.SizeT) - ] + idx = [(posData.filename, frame_i) for frame_i in range(posData.SizeT)] else: idx = [ - (posData.filename, frame_i) + (posData.filename, frame_i) for frame_i in range(posData.frame_i, posData.SizeT) ] - posData.segmInfo_df.loc[idx, 'z_slice_used_gui'] = z - + posData.segmInfo_df.loc[idx, "z_slice_used_gui"] = z + self.updatePreprocessPreview() self.updateCombineChannelsPreview() self.highlightedID = self.getHighlightedID() self.updateAllImages( - computePointsLayers=False, - computeContours=False, - updateLookuptable=True + computePointsLayers=False, computeContours=False, updateLookuptable=True ) self.updateItemsMousePos() if self.isSegm3D: @@ -1011,46 +995,44 @@ def update_z_slice(self, z): def warnLostObjects(self, do_warn=True): if not do_warn: return True - + if not self.warnLostCellsAction.isChecked(): return True - + mode = str(self.modeComboBox.currentText()) - if not mode == 'Segmentation and Tracking': + if not mode == "Segmentation and Tracking": return True - + posData = self.data[self.pos_i] if not posData.lost_IDs: return True - + frame_i = posData.frame_i try: accepted_lost_IDs = posData.accepted_lost_IDs.get(frame_i, []) - already_accepted_lost = ( - Counter(accepted_lost_IDs) == Counter(posData.lost_IDs) + already_accepted_lost = Counter(accepted_lost_IDs) == Counter( + posData.lost_IDs ) except AttributeError as err: already_accepted_lost = False - + if already_accepted_lost: return True self.nextAction.setDisabled(True) self.prevAction.setDisabled(True) self.navigateScrollBar.setDisabled(True) - + msg = widgets.myMessageBox() warn_msg = html_utils.paragraph( - 'Current frame (compared to previous frame) ' - 'has lost the following cells:

    ' - f'{posData.lost_IDs}

    ' - 'Are you sure you want to continue?
    ' + "Current frame (compared to previous frame) " + "has lost the following cells:

    " + f"{posData.lost_IDs}

    " + "Are you sure you want to continue?
    " ) - checkBox = QCheckBox('Do not show again') + checkBox = QCheckBox("Do not show again") noButton, yesButton = msg.warning( - self, 'Lost cells!', warn_msg, - buttonsTexts=('No', 'Yes'), - widgets=checkBox + self, "Lost cells!", warn_msg, buttonsTexts=("No", "Yes"), widgets=checkBox ) doNotWarnLostCells = not checkBox.isChecked() self.warnLostCellsAction.setChecked(doNotWarnLostCells) @@ -1059,27 +1041,27 @@ def warnLostObjects(self, do_warn=True): self.prevAction.setDisabled(False) self.navigateScrollBar.setDisabled(False) return False - + self.nextAction.setDisabled(False) self.prevAction.setDisabled(False) self.navigateScrollBar.setDisabled(False) - if not hasattr(posData, 'accepted_lost_IDs'): + if not hasattr(posData, "accepted_lost_IDs"): posData.accepted_lost_IDs = {} if frame_i not in posData.accepted_lost_IDs: posData.accepted_lost_IDs[frame_i] = [] - + posData.accepted_lost_IDs[frame_i].extend(posData.lost_IDs) # This section is adding the lost cells to tracked_lost_centroids... TBH I dont know why this wasnt done in the first place - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[posData.frame_i - 1]["IDs_idxs"] accepted_lost_centroids = { - tuple(int(val) for val in prev_rp[prev_IDs_idxs[ID]].centroid) + tuple(int(val) for val in prev_rp[prev_IDs_idxs[ID]].centroid) for ID in posData.lost_IDs } try: - posData.tracked_lost_centroids[frame_i] = ( - posData.tracked_lost_centroids[frame_i] | (accepted_lost_centroids) - ) + posData.tracked_lost_centroids[frame_i] = posData.tracked_lost_centroids[ + frame_i + ] | (accepted_lost_centroids) except KeyError: posData.tracked_lost_centroids[frame_i] = accepted_lost_centroids return True @@ -1094,8 +1076,10 @@ def warnReinitLastSegmFrame(self): {current_frame_n} will be lost! """) msg.warning( - self, 'WARNING: Potential loss of data', txt, - buttonsTexts=('Cancel', 'Yes, I am sure') + self, + "WARNING: Potential loss of data", + txt, + buttonsTexts=("Cancel", "Yes, I am sure"), ) return msg.cancel @@ -1115,20 +1099,21 @@ def zSliceScrollBarActionTriggered(self, action): posData = self.data[self.pos_i] idx = (posData.filename, posData.frame_i) z = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == 'z': - posData.segmInfo_df.at[idx, 'z_slice_used_gui'] = z - self.zSliceSpinbox.setValueNoEmit(z+1) + if self.switchPlaneCombobox.depthAxes() == "z": + posData.segmInfo_df.at[idx, "z_slice_used_gui"] = z + self.zSliceSpinbox.setValueNoEmit(z + 1) img = self._getImageupdateAllImages(None) self.img1.setCurrentZsliceIndex(z) self.img1.setImage( - img, next_frame_image=self.nextFrameImage(), - scrollbar_value=posData.frame_i+2 + img, + next_frame_image=self.nextFrameImage(), + scrollbar_value=posData.frame_i + 2, ) try: self.setOverlayImages() except Exception as err: pass - + if self.labelsGrad.showLabelsImgAction.isChecked(): self.img2.setImage(posData.lab, z=z, autoLevels=False) self.updateViewerWindow() @@ -1137,7 +1122,7 @@ def zSliceScrollBarActionTriggered(self, action): self.setOverlayLabelsItems() self.drawPointsLayers(computePointsLayers=False) self.zSliceScrollBarStartedMoving = False - self.highlightSearchedID(self.highlightedID, force=True) + self.highlightSearchedID(self.highlightedID, force=True) def zSliceScrollBarReleased(self): self.clearTempBrushImage() @@ -1145,9 +1130,9 @@ def zSliceScrollBarReleased(self): self.update_z_slice(self.zSliceScrollBar.sliderPosition()) def storeViewRange(self): - if not hasattr(self, 'isRangeReset'): + if not hasattr(self, "isRangeReset"): return - + if not self.isRangeReset: return self.ax1_viewRange = self.ax1.viewRange() diff --git a/cellacdc/mixins/geometry.py b/cellacdc/mixins/geometry.py index 90cb8e822..a413f5ca0 100644 --- a/cellacdc/mixins/geometry.py +++ b/cellacdc/mixins/geometry.py @@ -7,16 +7,6 @@ from cellacdc import is_mac - - - - - - - - - - class Geometry: """Extracted from guiWin.""" @@ -34,21 +24,19 @@ def isDefaultMiddleClick(self, mouseEvent, modifiers): def isMiddleClick(self, mouseEvent, modifiers): if self.delObjAction is None: return self.isDefaultMiddleClick(mouseEvent, modifiers) - + delObjKeySequence, delObjQtButton = self.delObjAction if delObjKeySequence is None: - # Setting only middle click on mac is allowed, however the + # Setting only middle click on mac is allowed, however the # delObjKeySequence is None and the tool button is never checked isDelObjectActive = True else: isDelObjectActive = self.delObjToolAction.isChecked() - + mouseEventButton = self.changeRightClickToLeftOnMac(mouseEvent) - - middle_click = ( - mouseEventButton == delObjQtButton and isDelObjectActive - ) - + + middle_click = mouseEventButton == delObjQtButton and isDelObjectActive + return middle_click def isPanImageClick(self, mouseEvent, modifiers): @@ -57,21 +45,21 @@ def isPanImageClick(self, mouseEvent, modifiers): def middleClickText(self): if self.delObjAction is None and is_mac: - return 'Command + Left Click' - + return "Command + Left Click" + if self.delObjAction is None: - return 'Middle Click' - + return "Middle Click" + delObjKeySequence, delObjQtButton = self.delObjAction - + if delObjQtButton == Qt.MouseButton.LeftButton: - buttonName = 'Left click' + buttonName = "Left click" elif delObjQtButton == Qt.MouseButton.RightButton: - buttonName = 'Right click' + buttonName = "Right click" else: - buttonName = 'Middle click' - + buttonName = "Middle click" + if delObjKeySequence is None: return buttonName - - return f'{delObjKeySequence.toString()} + {buttonName}' + + return f"{delObjKeySequence.toString()} + {buttonName}" diff --git a/cellacdc/mixins/graphics.py b/cellacdc/mixins/graphics.py index 5b9048660..df95339ed 100644 --- a/cellacdc/mixins/graphics.py +++ b/cellacdc/mixins/graphics.py @@ -34,38 +34,37 @@ from .points_layers import PointsLayers + class Graphics(PointsLayers): """Extracted from guiWin.""" - def _computeAllContours2D( - self, dataDict, obj, z, obj_bbox, include_internal=False - ): + def _computeAllContours2D(self, dataDict, obj, z, obj_bbox, include_internal=False): obj_image = self.getObjImage(obj.image, obj.bbox, z_slice=z) if obj_image is None: return - + all_external = False local = False contours = core.get_obj_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, + obj_image=obj_image, + obj_bbox=obj_bbox, local=local, - all_external=all_external + all_external=all_external, ) key = (obj.label, str(z), all_external, local) - dataDict['contours'][key] = contours - + dataDict["contours"][key] = contours + all_external = True local = False contours = core.get_obj_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, + obj_image=obj_image, + obj_bbox=obj_bbox, local=local, all_external=all_external, - all=include_internal + all=include_internal, ) key = (obj.label, str(z), all_external, local) - dataDict['contours'][key] = contours + dataDict["contours"][key] = contours return dataDict @@ -76,29 +75,27 @@ def _computeAllObjToObjCostPairs(self, posData): for frame_i, dataDict in enumerate(posData.allData_li): if frame_i == 0: continue - - rp = dataDict['regionprops'] + + rp = dataDict["regionprops"] if rp is None: break - - prev_rp = posData.allData_li[frame_i-1]['regionprops'] + + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] dist_matrix = core._compute_all_obj_to_obj_contour_dist_pairs( - dataDict['contours'], rp, - prev_rp=prev_rp, - restrict_search=True + dataDict["contours"], rp, prev_rp=prev_rp, restrict_search=True ) - dataDict['obj_to_obj_dist_cost_matrix_df'] = dist_matrix + dataDict["obj_to_obj_dist_cost_matrix_df"] = dist_matrix self.computeAllObjCostPairsWorker.signals.progressBar.emit(1) self.computeAllObjCostPairsWorker.signals.initProgressBar.emit(0) def _gui_createGraphicsItems(self): for _posData in self.data: - _posData.allData_li = [None]*_posData.SizeT - + _posData.allData_li = [None] * _posData.SizeT + posData = self.data[self.pos_i] allIDs, posData = core.count_objects(posData, self.logger.info) - + self.highLowResAction.setChecked(True) numItems = len(allIDs) if numItems > 1500: @@ -117,23 +114,29 @@ def _gui_createGraphicsItems(self): # Many items requires pxMode active to be fast enough self.pxModeAction.setChecked(True) - self.logger.info(f'Creating graphical items...') + self.logger.info(f"Creating graphical items...") self.ax1_contoursImageItem = pg.ImageItem() - + self.ax1_lostObjImageItem = pg.ImageItem() self.ax2_lostObjImageItem = pg.ImageItem() - + self.ax1_lostTrackedObjImageItem = pg.ImageItem() self.ax2_lostTrackedObjImageItem = pg.ImageItem() - + self.ax1_oldMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, - size=self.mothBudLineWeight, pen=None + symbol="s", + pxMode=False, + brush=self.oldMothBudLineBrush, + size=self.mothBudLineWeight, + pen=None, ) self.ax1_newMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.newMothBudLineBrush, - size=self.mothBudLineWeight, pen=None + symbol="s", + pxMode=False, + brush=self.newMothBudLineBrush, + size=self.mothBudLineWeight, + pen=None, ) self.ax1_lostObjScatterItem = self.gui_getLostObjScatterItem() self.yellowContourScatterItem = self.gui_getLostObjScatterItem() @@ -141,27 +144,32 @@ def _gui_createGraphicsItems(self): self.ax1_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() self.greenContourScatterItem = self.gui_getTrackedLostObjScatterItem() - brush = pg.mkBrush((0,255,0,200)) - pen = pg.mkPen('g', width=1) + brush = pg.mkBrush((0, 255, 0, 200)) + pen = pg.mkPen("g", width=1) self.ccaFailedScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight+1, pen=pen, - brush=brush, pxMode=False, symbol='s' + size=self.contLineWeight + 1, pen=pen, brush=brush, pxMode=False, symbol="s" ) self.ax2_contoursImageItem = pg.ImageItem() self.ax2_oldMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.oldMothBudLineBrush, - size=self.mothBudLineWeight, pen=None + symbol="s", + pxMode=False, + brush=self.oldMothBudLineBrush, + size=self.mothBudLineWeight, + pen=None, ) self.ax2_newMothBudLinesItem = pg.ScatterPlotItem( - symbol='s', pxMode=False, brush=self.newMothBudLineBrush, - size=self.mothBudLineWeight, pen=None + symbol="s", + pxMode=False, + brush=self.newMothBudLineBrush, + size=self.mothBudLineWeight, + pen=None, ) self.ax2_lostObjScatterItem = self.gui_getLostObjScatterItem() self.ax2_lostTrackedScatterItem = self.gui_getTrackedLostObjScatterItem() - - self.gui_createTextAnnotItems(allIDs) # here - self.gui_setTextAnnotColors()# here + + self.gui_createTextAnnotItems(allIDs) # here + self.gui_setTextAnnotColors() # here self.setDisabledAnnotOptions(False) @@ -202,27 +210,27 @@ def _updateMothBudLineColour(self, color): def _updateMothBudLineSize(self, size): self.gui_createMothBudLinePens() - + for act in self.imgGrad.mothBudLineWightActionGroup.actions(): if act == self.sender(): act.setChecked(True) act.toggled.connect(self.mothBudLineWeightToggled) - + self.ax1_oldMothBudLinesItem.setSize(size) self.ax1_newMothBudLinesItem.setSize(size) self.ax2_oldMothBudLinesItem.setSize(size) self.ax2_newMothBudLinesItem.setSize(size) - def addActionsLutItemContextMenu(self, lutItem): - lutItem.gradient.menu.addSection('Visible channels: ') + def addActionsLutItemContextMenu(self, lutItem): + lutItem.gradient.menu.addSection("Visible channels: ") for action in self.overlayContextMenu.actions(): if action.isSeparator(): continue lutItem.gradient.menu.addAction(action) lutItem.gradient.menu.addSeparator() - annotationMenu = lutItem.gradient.menu.addMenu('Annotations settings') - ID_menu = annotationMenu.addMenu('IDs') + annotationMenu = lutItem.gradient.menu.addMenu("Annotations settings") + ID_menu = annotationMenu.addMenu("IDs") self.annotSettingsIDmenu = QActionGroup(annotationMenu) labID_action = QAction("Show label's ID") labID_action.setCheckable(True) @@ -236,7 +244,7 @@ def addActionsLutItemContextMenu(self, lutItem): ID_menu.addAction(labID_action) ID_menu.addAction(treeID_action) - ID_menu = annotationMenu.addMenu('Generation number') + ID_menu = annotationMenu.addMenu("Generation number") self.annotSettingsGenNumMenu = QActionGroup(annotationMenu) gen_num_action = QAction("Show default generation number") gen_num_action.setCheckable(True) @@ -254,8 +262,8 @@ def addAlphaScrollbar(self, channelName, imageItem): alphaScrollBar = widgets.ScrollBar(Qt.Horizontal) imageItem.alphaScrollBar = alphaScrollBar alphaScrollBar.channelName = channelName - - label = QLabel(f'Alpha {channelName}') + + label = QLabel(f"Alpha {channelName}") label.setFont(_font) label.hide() alphaScrollBar.imageItem = imageItem @@ -266,17 +274,14 @@ def addAlphaScrollbar(self, channelName, imageItem): alphaScrollBar.setMaximum(40) alphaScrollBar.setValue(20) alphaScrollBar.setToolTip( - f'Control the alpha value of the overlaid channel {channelName}.\n' - 'alpha=0 results in NO overlay,\n' - 'alpha=1 results in only fluorescence data visible' + f"Control the alpha value of the overlaid channel {channelName}.\n" + "alpha=0 results in NO overlay,\n" + "alpha=1 results in only fluorescence data visible" ) self.bottomLeftLayout.addWidget( - alphaScrollBar.label, self.alphaScrollbarRow, 0, - alignment=Qt.AlignRight - ) - self.bottomLeftLayout.addWidget( - alphaScrollBar, self.alphaScrollbarRow, 1, 1, 2 + alphaScrollBar.label, self.alphaScrollbarRow, 0, alignment=Qt.AlignRight ) + self.bottomLeftLayout.addWidget(alphaScrollBar, self.alphaScrollbarRow, 1, 1, 2) alphaScrollBar.valueChanged.connect( partial(self.setOpacityOverlayLayersItems, scrollbar=alphaScrollBar) @@ -287,9 +292,7 @@ def addAlphaScrollbar(self, channelName, imageItem): def addFluoChNameContextMenuAction(self, ch_name): posData = self.data[self.pos_i] - allTexts = [ - action.text() for action in self.chNamesQActionGroup.actions() - ] + allTexts = [action.text() for action in self.chNamesQActionGroup.actions()] if ch_name not in allTexts: action = QAction(self) action.setText(ch_name) @@ -299,13 +302,12 @@ def addFluoChNameContextMenuAction(self, ch_name): self.fluoDataChNameActions.append(action) def addObjContourToContoursImage( - self, ID=0, obj=None, ax=0, thickness=None, color=None, - force=False - ): + self, ID=0, obj=None, ax=0, thickness=None, color=None, force=False + ): imageItem = self.getContoursImageItem(ax, force=force) if imageItem is None: return - + if obj is None: obj = self.getObjFromID(ID) if obj is None: @@ -316,7 +318,7 @@ def addObjContourToContoursImage( thickness = self.contLineWeight if color is None: color = self.contLineColor - + self.setContoursImage(imageItem, contours, thickness, color) def addOverlayLabelsToggled(self, checked, name=None): @@ -333,11 +335,11 @@ def addOverlayLabelsToggled(self, checked, name=None): def askLabelsToOverlay(self): selectOverlayLabels = widgets.QDialogListbox( - 'Select segmentation to overlay', - 'Select segmentation file to overlay:\n', - natsorted(self.existingSegmEndNames), - multiSelection=True, - parent=self + "Select segmentation to overlay", + "Select segmentation file to overlay:\n", + natsorted(self.existingSegmEndNames), + multiSelection=True, + parent=self, ) selectOverlayLabels.exec_() if selectOverlayLabels.cancel: @@ -348,9 +350,11 @@ def askLabelsToOverlay(self): def askSelectOverlayChannel(self): ch_names = [ch for ch in self.ch_names if ch != self.user_ch_name] selectFluo = widgets.QDialogListbox( - 'Select channel', - 'Select channel names to overlay:\n', - ch_names, multiSelection=True, parent=self + "Select channel", + "Select channel names to overlay:\n", + ch_names, + multiSelection=True, + parent=self, ) selectFluo.exec_() if selectFluo.cancel: @@ -389,7 +393,7 @@ def clearAx1Items(self, onlyHideText=False): self.ax1_lostTrackedScatterItem.setData([], []) self.ccaFailedScatterItem.setData([], []) self.yellowContourScatterItem.setData([], []) - + self.clearPointsLayers() self.clearOverlayLabelsItems() @@ -410,32 +414,30 @@ def clearAx2Items(self, onlyHideText=False): def clearComputedContours(self): for posData in self.data: for frame_i, dataDict in enumerate(posData.allData_li): - dataDict['contours'] = {} + dataDict["contours"] = {} - def clearObjContour( - self, ID=0, obj=None, ax=0, debug=False, updateImage=True - ): + def clearObjContour(self, ID=0, obj=None, ax=0, debug=False, updateImage=True): imageItem = self.getContoursImageItem(ax) if imageItem is None: return if ID > 0: - self.contoursImage[self.currentLab2D==ID] = [0,0,0,0] + self.contoursImage[self.currentLab2D == ID] = [0, 0, 0, 0] else: obj_slice = self.getObjSlice(obj.slice) obj_image = self.getObjImage(obj.image, obj.bbox) - self.contoursImage[obj_slice][obj_image] = [0,0,0,0] - + self.contoursImage[obj_slice][obj_image] = [0, 0, 0, 0] + if not updateImage: return - - imageItem.setImage(self.contoursImage) + + imageItem.setImage(self.contoursImage) def clearOverlayImageItems(self): for items in self.overlayLayersItems.values(): imageItem = items[0] imageItem.clear() - + self.rgbaImg1.clear() def clearOverlayLabelsItems(self): @@ -446,33 +448,36 @@ def clearOverlayLabelsItems(self): contoursItem.clear() def computeAllContours(self): - self.logger.info('Computing all contours...') + self.logger.info("Computing all contours...") posData = self.data[self.pos_i] zz = [None] if self.isSegm3D: zz.extend(range(posData.SizeZ)) - + include_internal = self.showAllContoursToggle.isChecked() for frame_i, dataDict in enumerate(posData.allData_li): - lab = dataDict['labels'] + lab = dataDict["labels"] if lab is None: break - - rp = dataDict['regionprops'] + + rp = dataDict["regionprops"] if rp is None: rp = skimage.measure.regionprops(lab) - - dataDict['contours'] = {} + + dataDict["contours"] = {} for obj in rp: obj_bbox = self.getObjBbox(obj.bbox) for z in zz: if not self.isObjVisible(obj.bbox, z_slice=z): continue - + try: self._computeAllContours2D( - dataDict, obj, z, obj_bbox, - include_internal=include_internal + dataDict, + obj, + z, + obj_bbox, + include_internal=include_internal, ) except Exception as err: # Contours computation fails on weird objects @@ -490,28 +495,25 @@ def computeAllObjCostPairsWorkerFinished(self, output): self.computeAllObjCostPairsWorkerLoop.exit() def computeAllObjToObjCostPairs(self): - desc = ( - 'Computing all object-to-object cost matrices...' - ) + desc = "Computing all object-to-object cost matrices..." self.logger.info(desc) posData = self.data[self.pos_i] - - + self.progressWin = apps.QDialogWorkerProgress( title=desc, parent=self, pbarDesc=desc ) self.progressWin.mainPbar.setMaximum(0) self.progressWin.show(self.app) - + self.computeAllObjCostPairsThread = QThread() self.computeAllObjCostPairsWorker = workers.SimpleWorker( posData, self._computeAllObjToObjCostPairs ) - + self.computeAllObjCostPairsWorker.moveToThread( self.computeAllObjCostPairsThread ) - + self.computeAllObjCostPairsWorker.signals.finished.connect( self.computeAllObjCostPairsThread.quit ) @@ -521,7 +523,7 @@ def computeAllObjToObjCostPairs(self): self.computeAllObjCostPairsThread.finished.connect( self.computeAllObjCostPairsThread.deleteLater ) - + self.computeAllObjCostPairsWorker.signals.critical.connect( self.computeAllObjCostPairsWorkerCritical ) @@ -531,18 +533,16 @@ def computeAllObjToObjCostPairs(self): self.computeAllObjCostPairsWorker.signals.progressBar.connect( self.workerUpdateProgressbar ) - self.computeAllObjCostPairsWorker.signals.progress.connect( - self.workerProgress - ) + self.computeAllObjCostPairsWorker.signals.progress.connect(self.workerProgress) self.computeAllObjCostPairsWorker.signals.finished.connect( self.computeAllObjCostPairsWorkerFinished ) - + self.computeAllObjCostPairsThread.started.connect( self.computeAllObjCostPairsWorker.run ) self.computeAllObjCostPairsThread.start() - + self.computeAllObjCostPairsWorkerLoop = QEventLoop() self.computeAllObjCostPairsWorkerLoop.exec_() @@ -551,20 +551,20 @@ def contLineWeightToggled(self, checked=True): return self.imgGrad.uncheckContLineWeightActions() w = self.sender().lineWeight - self.df_settings.at['contLineWeight', 'value'] = w + self.df_settings.at["contLineWeight", "value"] = w self.df_settings.to_csv(self.settings_csv_path) self._updateContLineThickness() self.updateAllImages() def createChannelNamesActions(self): # LUT histogram channel name context menu actions - self.chNamesQActionGroup = QActionGroup(self) + self.chNamesQActionGroup = QActionGroup(self) self.chNamesQActionGroup.addAction(self.userChNameAction) posData = self.data[self.pos_i] for action in self.fluoDataChNameActions: - self.chNamesQActionGroup.addAction(action) + self.chNamesQActionGroup.addAction(action) action.setChecked(False) - + self.userChNameAction.setChecked(True) for action in self.overlayContextMenu.actions(): @@ -586,29 +586,29 @@ def createOverlayLabelsContextMenu(self, segmEndnames): self.overlayLabelsContextMenu.addSeparator() self.drawModeOverlayLabelsChannels = {} segmEndnames_extended = list(segmEndnames.copy()) - segmEndnames_extended = ['combined segm.'] + segmEndnames_extended + segmEndnames_extended = ["combined segm."] + segmEndnames_extended for segmEndname in segmEndnames_extended: action = QAction(segmEndname, self.overlayLabelsContextMenu) - if segmEndname == 'combined segm.': + if segmEndname == "combined segm.": action.setCheckable(False) self.combineSegmViewToggle = action else: action.setCheckable(True) action.toggled.connect(self.addOverlayLabelsToggled) self.overlayLabelsContextMenu.addAction(action) - + self.overlayLabelsContextMenu.addSeparator() - action = QAction('Edit appearance...', self.overlayLabelsContextMenu) + action = QAction("Edit appearance...", self.overlayLabelsContextMenu) action.triggered.connect(self.editOverlayLabelsAppearance) self.overlayLabelsContextMenu.addAction(action) def createOverlayLabelsItems(self, segmEndnames): selectActionGroup = QActionGroup(self) segmEndnames_extended = list(segmEndnames.copy()) - segmEndnames_extended = ['combined segm.'] + segmEndnames_extended + segmEndnames_extended = ["combined segm."] + segmEndnames_extended for segmEndname in segmEndnames_extended: action = QAction(segmEndname) - if segmEndname == 'combined segm.': + if segmEndname == "combined segm.": action.setCheckable(False) else: action.setCheckable(True) @@ -634,9 +634,14 @@ def createOverlayLabelsItems(self, segmEndnames): r, g, b, a = colors.rgba_str_to_values(color) qcolor = QColor(r, g, b, a) contoursItem.setData( - [], [], symbol='s', pxMode=False, size=self.contLineWeight*2, + [], + [], + symbol="s", + pxMode=False, + size=self.contLineWeight * 2, brush=pg.mkBrush(color=qcolor), - pen=pg.mkPen(width=3, color=qcolor), tip=None + pen=pg.mkPen(width=3, color=qcolor), + tip=None, ) items = (imageItem, contoursItem, gradItem) @@ -654,7 +659,7 @@ def defaultRescaleIntensLutActionToggled(self, action): rescaleIntensAction.setChecked(True) rescaleIntensAction.trigger() break - + for channel, items in self.overlayLayersItems.items(): lutItem = items[1] for rescaleIntensAction in lutItem.rescaleActionGroup.actions(): @@ -662,22 +667,24 @@ def defaultRescaleIntensLutActionToggled(self, action): rescaleIntensAction.setChecked(True) rescaleIntensAction.trigger() break - - self.df_settings.at['default_rescale_intens_how', 'value'] = how + + self.df_settings.at["default_rescale_intens_how", "value"] = how self.df_settings.to_csv(self.settings_csv_path) def drawLostObjContoursImage( - self, imageItem, contours, - thickness=1, - color=(255, 165, 0, 255) # orange - ): + self, + imageItem, + contours, + thickness=1, + color=(255, 165, 0, 255), # orange + ): img = self.lostObjContoursImage cv2.drawContours(img, contours, -1, color, thickness) imageItem.setImage(img) def drawLostTrackedObjContoursImage(self, imageItem, contours): thickness = 1 - color = (0, 255, 0, 255) # green + color = (0, 255, 0, 255) # green img = self.lostTrackedObjContoursImage cv2.drawContours(img, contours, -1, color, thickness) imageItem.setImage(img) @@ -691,16 +698,16 @@ def editOverlayLabelsAppearance(self, *args): win.exec_() if win.cancel: return - - brush = win.properties['brush'] - pen = win.properties['pen'] + + brush = win.properties["brush"] + pen = win.properties["pen"] for items in self.overlayLabelsItems.values(): imageItem, contoursItem, gradItem = items contoursItem.setBrush(brush, update=False) contoursItem.setPen(pen) def enableOverlayWidgets(self, enabled): - posData = self.data[self.pos_i] + posData = self.data[self.pos_i] if enabled: self.overlayColorButton.setDisabled(False) self.editOverlayColorAction.setDisabled(False) @@ -708,13 +715,15 @@ def enableOverlayWidgets(self, enabled): if posData.SizeZ == 1: return - self.zSliceOverlay_SB.setMaximum(posData.SizeZ-1) - if self.zProjOverlay_CB.currentText().find('max') != -1: + self.zSliceOverlay_SB.setMaximum(posData.SizeZ - 1) + if self.zProjOverlay_CB.currentText().find("max") != -1: self.overlay_z_label.setDisabled(True) self.zSliceOverlay_SB.setDisabled(True) else: z = self.zSliceOverlay_SB.sliderPosition() - self.overlay_z_label.setText(f'Overlay z-slice {z+1:02}/{posData.SizeZ}') + self.overlay_z_label.setText( + f"Overlay z-slice {z + 1:02}/{posData.SizeZ}" + ) self.zSliceOverlay_SB.setDisabled(False) self.overlay_z_label.setDisabled(False) self.zSliceOverlay_SB.show() @@ -742,15 +751,15 @@ def extendLabelsLUT(self, lenNewLut): posData = self.data[self.pos_i] # Build a new lut to include IDs > than original len of lut if lenNewLut > len(self.lut): - numNewColors = lenNewLut-len(self.lut) + numNewColors = lenNewLut - len(self.lut) # Index original lut _lut = np.zeros((lenNewLut, 3), np.uint8) - _lut[:len(self.lut)] = self.lut + _lut[: len(self.lut)] = self.lut # Pick random colors and append them at the end to recycle them - randomIdx = np.random.randint(0,len(self.lut),size=numNewColors) + randomIdx = np.random.randint(0, len(self.lut), size=numNewColors) for i, idx in enumerate(randomIdx): rgb = self.lut[idx] - _lut[len(self.lut)+i] = rgb + _lut[len(self.lut) + i] = rgb self.lut = _lut self.initLabelsImageItems() return True @@ -758,23 +767,23 @@ def extendLabelsLUT(self, lenNewLut): def getLabelsImageLut(self): lut = np.zeros((len(self.lut), 4), dtype=np.uint8) - lut[:,-1] = 255 - lut[:,:-1] = self.lut - lut[0] = [0,0,0,0] + lut[:, -1] = 255 + lut[:, :-1] = self.lut + lut[0] = [0, 0, 0, 0] return lut def getNearestLostObjID(self, y, x): if not self.annotLostObjsToggle.isChecked(): return - + posData = self.data[self.pos_i] if not posData.lost_IDs: return - - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] + + prev_lab = posData.allData_li[posData.frame_i - 1]["labels"] if prev_lab is None: return - + # if not hasattr(self, 'lostObjContoursImage'): # self.store_data() # posData.frame_i -= 1 @@ -786,50 +795,54 @@ def getNearestLostObjID(self, y, x): # self.updateLostContoursImage(ax=0) # self.updateLostContoursImage(ax=1) # self.updateLostNewCurrentIDs() - + yy, xx, _ = np.nonzero(self.lostObjContoursImage) lostObjsContourMask = np.zeros(self.currentLab2D.shape, dtype=bool) lostObjsContourMask[yy.astype(int), xx.astype(int)] = True - + # Add accepted lost IDs try: yy, xx, _ = np.nonzero(self.lostTrackedObjContoursImage) lostObjsContourMask[yy.astype(int), xx.astype(int)] = True except Exception as err: pass - + _, y_nearest, x_nearest = core.nearest_nonzero_2D( lostObjsContourMask, y, x, return_coords=True ) nearest_ID = self.get_2Dlab(prev_lab)[y_nearest, x_nearest] - + if nearest_ID == 0: return - + return nearest_ID def getObjContours( - self, obj, all_external=False, local=False, force_calc=True, - include_internal=False - ): + self, + obj, + all_external=False, + local=False, + force_calc=True, + include_internal=False, + ): posData = self.data[self.pos_i] dataDict = posData.allData_li[posData.frame_i] - allContours = dataDict.get('contours') + allContours = dataDict.get("contours") if allContours is not None and not force_calc: z = self.z_lab() key = (obj.label, str(z), all_external, local) contours = allContours.get(key) if contours is not None: return contours - + obj_image = self.getObjImage(obj.image, obj.bbox).astype(np.uint8) obj_bbox = self.getObjBbox(obj.bbox) try: contours = core.get_obj_contours( - obj_image=obj_image, - obj_bbox=obj_bbox, + obj_image=obj_image, + obj_bbox=obj_bbox, local=local, - all_external=all_external + all_external=all_external, ) except Exception as e: if all_external: @@ -837,8 +850,8 @@ def getObjContours( else: contours = None self.logger.warning( - f'Object ID {obj.label} contours drawing failed. ' - f'(bounding box = {obj.bbox})' + f"Object ID {obj.label} contours drawing failed. " + f"(bounding box = {obj.bbox})" ) return contours @@ -849,7 +862,7 @@ def getObjFromID(self, ID): except KeyError as e: # Object already cleared return - + obj = posData.rp[idx] return obj @@ -862,7 +875,7 @@ def getOlImg(self, key, frame_i=None): if posData.SizeZ > 1: zProjHow = self.zProjOverlay_CB.currentText() z = self.zSliceOverlay_SB.sliderPosition() - if zProjHow == 'same as above': + if zProjHow == "same as above": zProjHow = self.zProjComboBox.currentText() z = self.zSliceScrollBar.sliderPosition() reconnect = False @@ -873,17 +886,17 @@ def getOlImg(self, key, frame_i=None): pass self.zSliceOverlay_SB.setSliderPosition(z) if reconnect: - self.zSliceOverlay_SB.valueChanged.connect( - self.updateOverlayZslice - ) - if zProjHow == 'single z-slice': - self.overlay_z_label.setText(f'Overlay z-slice {z+1:02}/{posData.SizeZ}') + self.zSliceOverlay_SB.valueChanged.connect(self.updateOverlayZslice) + if zProjHow == "single z-slice": + self.overlay_z_label.setText( + f"Overlay z-slice {z + 1:02}/{posData.SizeZ}" + ) ol_img = img[z].copy() - elif zProjHow == 'max z-projection': + elif zProjHow == "max z-projection": ol_img = img.max(axis=0) - elif zProjHow == 'mean z-projection': + elif zProjHow == "mean z-projection": ol_img = img.mean(axis=0) - elif zProjHow == 'median z-proj.': + elif zProjHow == "median z-proj.": ol_img = np.median(img, axis=0) else: ol_img = img.copy() @@ -899,16 +912,16 @@ def getOpacitiesFromAlphaScrollbarValues(self): if not _toolbutton.isChecked() or not _toolbutton.isVisible(): continue - alpha_values.append(alphaSB.value()/alphaSB.maximum()) + alpha_values.append(alphaSB.value() / alphaSB.maximum()) activeOverlayImageItems.append(imgItem) - + opacities = colors.hierarchical_weights(alpha_values)[::-1] channel_opacity_mapper = {} for i, imgItem in enumerate(activeOverlayImageItems): - channel_opacity_mapper[imgItem.channelName] = opacities[i+1] - + channel_opacity_mapper[imgItem.channelName] = opacities[i + 1] + channel_opacity_mapper[self.user_ch_name] = opacities[0] - + return channel_opacity_mapper def getOverlayItems(self, channelName, index): @@ -917,14 +930,14 @@ def getOverlayItems(self, channelName, index): imageItem.channelName = channelName lutItem = widgets.myHistogramLUTitem( - parent=self, name='image', axisLabel=channelName + parent=self, name="image", axisLabel=channelName ) imageItem.lutItem = lutItem for action in lutItem.rescaleActionGroup.actions(): if action.text() == self.defaultRescaleIntensHow: action.setChecked(True) break - + lutItem.removeAddScaleBarAction() lutItem.removeAddTimestampAction() lutItem.restoreState(self.df_settings) @@ -936,23 +949,19 @@ def getOverlayItems(self, channelName, index): lutItem.initColor = initColor lutItem.hide() - lutItem.overlayColorButton.sigColorChanging.connect( - self.changeOverlayColor - ) - lutItem.overlayColorButton.sigColorChanged.connect( - self.saveOverlayColor - ) + lutItem.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) + lutItem.overlayColorButton.sigColorChanged.connect(self.saveOverlayColor) lutItem.invertBwAction.toggled.connect(self.setCheckedInvertBW) - lutItem.contoursColorButton.disconnect() + lutItem.contoursColorButton.disconnect() lutItem.contoursColorButton.clicked.connect( self.imgGrad.contoursColorButton.click ) for act in lutItem.contLineWightActionGroup.actions(): act.toggled.connect(self.contLineWeightToggled) - - lutItem.mothBudLineColorButton.disconnect() + + lutItem.mothBudLineColorButton.disconnect() lutItem.mothBudLineColorButton.clicked.connect( self.imgGrad.mothBudLineColorButton.click ) @@ -960,55 +969,45 @@ def getOverlayItems(self, channelName, index): act.toggled.connect(self.mothBudLineWeightToggled) lutItem.textColorButton.disconnect() - lutItem.textColorButton.clicked.connect( - self.editTextIDsColorAction.trigger - ) + lutItem.textColorButton.clicked.connect(self.editTextIDsColorAction.trigger) - lutItem.defaultSettingsAction.triggered.connect( - self.restoreDefaultSettings - ) - lutItem.labelsAlphaSlider.valueChanged.connect( - self.setValueLabelsAlphaSlider - ) + lutItem.defaultSettingsAction.triggered.connect(self.restoreDefaultSettings) + lutItem.labelsAlphaSlider.valueChanged.connect(self.setValueLabelsAlphaSlider) lutItem.sigRescaleIntes.connect( partial(self.rescaleIntensitiesLut, imageItem=imageItem) ) - if f'how_rescale_intensities_{channelName}' in self.df_settings.index: - how = self.df_settings.at[ - f'how_rescale_intensities_{channelName}', 'value' - ] + if f"how_rescale_intensities_{channelName}" in self.df_settings.index: + how = self.df_settings.at[f"how_rescale_intensities_{channelName}", "value"] lutItem.setRescaleIntensitiesHow(how) - - self.rescaleIntensChannelHowMapper[channelName] = ( - 'Rescale each 2D image' - ) + + self.rescaleIntensChannelHowMapper[channelName] = "Rescale each 2D image" self.addActionsLutItemContextMenu(lutItem) alphaScrollBar = self.addAlphaScrollbar(channelName, imageItem) - + toolbutton = widgets.OverlayChannelToolButton( channelName, lutItem, shortcut=str(index) ) toolbutton.action = self.overlayToolbar.addWidget(toolbutton) toolbutton.setVisible(False) - + toolbutton.clicked.connect(self.overlayChannelToolbuttonClicked) - + alphaScrollBar.toolbutton = toolbutton - + return imageItem, lutItem, alphaScrollBar, toolbutton def getOverlayLabelsData(self, segmEndname): posData = self.data[self.pos_i] - + if posData.ol_labels_data is None: - self.loadOverlayLabelsData(segmEndname) + self.loadOverlayLabelsData(segmEndname) elif segmEndname not in posData.ol_labels_data: self.loadOverlayLabelsData(segmEndname) - + comb_seg = False - if 'combined segm.' == segmEndname: + if "combined segm." == segmEndname: comb_seg = True if not self.isSegm3D: zStackImg = self.data[0].SizeZ > 1 @@ -1016,12 +1015,14 @@ def getOverlayLabelsData(self, segmEndname): selected_z_stack = self.zSliceScrollBar.sliderPosition() else: selected_z_stack = 0 - out = posData.ol_labels_data['combined segm.'][posData.frame_i][selected_z_stack] + out = posData.ol_labels_data["combined segm."][posData.frame_i][ + selected_z_stack + ] return out.astype(np.uint32) - + if self.isSegm3D: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' + isZslice = zProjHow == "single z-slice" if isZslice: z = self.zSliceScrollBar.sliderPosition() ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i][z] @@ -1029,7 +1030,9 @@ def getOverlayLabelsData(self, segmEndname): ol_lab = ol_lab.astype(np.uint32) return ol_lab else: - ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i].max(axis=0) + ol_lab = posData.ol_labels_data[segmEndname][posData.frame_i].max( + axis=0 + ) if comb_seg: ol_lab = ol_lab.astype(np.uint32) return ol_lab @@ -1037,7 +1040,7 @@ def getOverlayLabelsData(self, segmEndname): return posData.ol_labels_data[segmEndname][posData.frame_i] def greedyShuffleCmap(self, updateImages=True): - lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) + lut = self.labelsGrad.item.colorMap().getLookupTable(0, 1, 255) greedy_lut = colors.get_greedy_lut(self.currentLab2D, lut) self.lut = greedy_lut self.initLabelsImageItems() @@ -1053,7 +1056,7 @@ def gui_addGraphicsItems(self): equalizeHistPushButton.setCheckable(True) if not self.invertBwAction.isChecked(): equalizeHistPushButton.setStyleSheet( - 'QPushButton {background-color: #282828; color: #F0F0F0;}' + "QPushButton {background-color: #282828; color: #F0F0F0;}" ) self.equalizeHistPushButton = equalizeHistPushButton proxy.setWidget(equalizeHistPushButton) @@ -1061,67 +1064,55 @@ def gui_addGraphicsItems(self): self.equalizeHistPushButton = equalizeHistPushButton # Left image histogram - self.imgGrad = widgets.myHistogramLUTitem(parent=self, name='image') + self.imgGrad = widgets.myHistogramLUTitem(parent=self, name="image") self.imgGrad.restoreState(self.df_settings) self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) for action in self.imgGrad.rescaleActionGroup.actions(): if action.text() == self.defaultRescaleIntensHow: action.setChecked(True) self.rescaleIntensMenu.addAction(action) - + # Colormap gradient widget self.labelsGrad = widgets.labelsGradientWidget(parent=self) try: stateFound = self.labelsGrad.restoreState(self.df_settings) except Exception as e: self.logger.exception(traceback.format_exc()) - print('======================================') + print("======================================") self.logger.info( - 'Failed to restore previously used colormap. ' + "Failed to restore previously used colormap. " 'Using default colormap "viridis"' ) - self.labelsGrad.item.loadPreset('viridis') - + self.labelsGrad.item.loadPreset("viridis") + # Add actions to imgGrad gradient item - self.imgGrad.gradient.menu.addAction( - self.labelsGrad.showLabelsImgAction - ) - self.imgGrad.gradient.menu.addAction( - self.labelsGrad.showRightImgAction - ) - self.imgGrad.gradient.menu.addAction( - self.labelsGrad.showNextFrameAction - ) - + self.imgGrad.gradient.menu.addAction(self.labelsGrad.showLabelsImgAction) + self.imgGrad.gradient.menu.addAction(self.labelsGrad.showRightImgAction) + self.imgGrad.gradient.menu.addAction(self.labelsGrad.showNextFrameAction) + self.imgGrad.gradient.menu.addSeparator() - - self.imgGrad.gradient.menu.addMenu(self.exportMenu) - + + self.imgGrad.gradient.menu.addMenu(self.exportMenu) + # Add actions to view menu self.viewMenu.addAction(self.labelsGrad.showLabelsImgAction) self.viewMenu.addAction(self.labelsGrad.showRightImgAction) - + # Right image histogram self.imgGradRight = widgets.baseHistogramLUTitem( - name='image', parent=self, gradientPosition='left' - ) - self.imgGradRight.gradient.menu.addAction( - self.labelsGrad.showLabelsImgAction + name="image", parent=self, gradientPosition="left" ) - self.imgGradRight.gradient.menu.addAction( - self.labelsGrad.showRightImgAction - ) - self.imgGradRight.gradient.menu.addAction( - self.labelsGrad.showNextFrameAction - ) - + self.imgGradRight.gradient.menu.addAction(self.labelsGrad.showLabelsImgAction) + self.imgGradRight.gradient.menu.addAction(self.labelsGrad.showRightImgAction) + self.imgGradRight.gradient.menu.addAction(self.labelsGrad.showNextFrameAction) + self.imgGrad.setChildLutItem(self.imgGradRight) # Title self.titleLabel = pg.LabelItem( - justify='center', color=self.titleColor, size='14pt' + justify="center", color=self.titleColor, size="14pt" ) - self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) + self.graphLayout.addItem(self.titleLabel, row=0, col=1, colspan=2) def gui_addOverlayLayerItems(self): for items in self.overlayLabelsItems.values(): @@ -1132,7 +1123,7 @@ def gui_addOverlayLayerItems(self): def gui_addTopLayerItems(self): for item in self.topLayerItems: self.ax1.addItem(item) - + for item in self.topLayerItemsRight: self.ax2.addItem(item) @@ -1155,16 +1146,16 @@ def gui_connectGraphicsEvents(self): self.ax1.sigRangeChanged.connect(self.viewRangeChanged) def gui_createContourPens(self): - if 'contLineWeight' in self.df_settings.index: - val = self.df_settings.at['contLineWeight', 'value'] + if "contLineWeight" in self.df_settings.index: + val = self.df_settings.at["contLineWeight", "value"] self.contLineWeight = int(val) else: self.contLineWeight = 1 - if 'contLineColor' in self.df_settings.index: - val = self.df_settings.at['contLineColor', 'value'] + if "contLineColor" in self.df_settings.index: + val = self.df_settings.at["contLineColor", "value"] rgba = colors.rgba_str_to_values(val) self.contLineColor = rgba - self.newIDlineColor = [min(255, v+50) for v in self.contLineColor] + self.newIDlineColor = [min(255, v + 50) for v in self.contLineColor] else: self.contLineColor = (255, 0, 0, 200) self.newIDlineColor = (255, 0, 0, 255) @@ -1184,31 +1175,24 @@ def gui_createContourPens(self): act.setChecked(True) self.imgGrad.contoursColorButton.setColor(self.contLineColor[:3]) - self.imgGrad.contoursColorButton.sigColorChanging.connect( - self.updateContColour - ) - self.imgGrad.contoursColorButton.sigColorChanged.connect( - self.saveContColour - ) + self.imgGrad.contoursColorButton.sigColorChanging.connect(self.updateContColour) + self.imgGrad.contoursColorButton.sigColorChanged.connect(self.saveContColour) for act in self.imgGrad.contLineWightActionGroup.actions(): act.toggled.connect(self.contLineWeightToggled) # Contours pens - self.oldIDs_cpen = pg.mkPen( - color=self.contLineColor, width=self.contLineWeight - ) + self.oldIDs_cpen = pg.mkPen(color=self.contLineColor, width=self.contLineWeight) self.newIDs_cpen = pg.mkPen( - color=self.newIDlineColor, width=self.contLineWeight+1 - ) - self.tempNewIDs_cpen = pg.mkPen( - color='g', width=self.contLineWeight+1 + color=self.newIDlineColor, width=self.contLineWeight + 1 ) + self.tempNewIDs_cpen = pg.mkPen(color="g", width=self.contLineWeight + 1) def gui_createGraphicsItems(self): # Create enough PlotDataItems and LabelItems to draw contours and IDs. self.progressWin = apps.QDialogWorkerProgress( - title='Creating axes items', parent=self, - pbarDesc='Creating axes items (see progress in the terminal)...' + title="Creating axes items", + parent=self, + pbarDesc="Creating axes items (see progress in the terminal)...", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) @@ -1218,34 +1202,36 @@ def gui_createGraphicsItems(self): def gui_createLabelRoiItem(self): Y, X = self.currentLab2D.shape # Label ROI rectangle - pen = pg.mkPen('r', width=3) + pen = pg.mkPen("r", width=3) self.labelRoiItem = widgets.ROI( - (0,0), (0,0), - maxBounds=QRectF(QRect(0,0,X,Y)), + (0, 0), + (0, 0), + maxBounds=QRectF(QRect(0, 0, X, Y)), scaleSnap=True, translateSnap=True, - pen=pen, hoverPen=pen + pen=pen, + hoverPen=pen, ) posData = self.data[self.pos_i] if self.labelRoiZdepthSpinbox.value() == 0: self.labelRoiZdepthSpinbox.setValue(posData.SizeZ) - self.labelRoiZdepthSpinbox.setMaximum(posData.SizeZ+1) + self.labelRoiZdepthSpinbox.setMaximum(posData.SizeZ + 1) def gui_createMothBudLinePens(self): - if 'mothBudLineSize' in self.df_settings.index: - val = self.df_settings.at['mothBudLineSize', 'value'] + if "mothBudLineSize" in self.df_settings.index: + val = self.df_settings.at["mothBudLineSize", "value"] self.mothBudLineWeight = int(val) else: self.mothBudLineWeight = 2 - self.newMothBudlineColor = (255, 0, 0) - if 'mothBudLineColor' in self.df_settings.index: - val = self.df_settings.at['mothBudLineColor', 'value'] + self.newMothBudlineColor = (255, 0, 0) + if "mothBudLineColor" in self.df_settings.index: + val = self.df_settings.at["mothBudLineColor", "value"] rgba = colors.rgba_str_to_values(val) self.mothBudLineColor = rgba[0:3] else: - self.mothBudLineColor = (255,165,0) + self.mothBudLineColor = (255, 165, 0) try: self.imgGrad.mothBudLineColorButton.sigColorChanging.disconnect() @@ -1275,38 +1261,34 @@ def gui_createMothBudLinePens(self): # MOther-bud lines brushes self.NewBudMoth_Pen = pg.mkPen( - color=self.newMothBudlineColor, width=self.mothBudLineWeight+1, - style=Qt.DashLine + color=self.newMothBudlineColor, + width=self.mothBudLineWeight + 1, + style=Qt.DashLine, ) self.OldBudMoth_Pen = pg.mkPen( - color=self.mothBudLineColor, width=self.mothBudLineWeight, - style=Qt.DashLine - ) - - self.redDashLinePen = pg.mkPen( - color='r', width=2, style=Qt.DashLine + color=self.mothBudLineColor, width=self.mothBudLineWeight, style=Qt.DashLine ) + self.redDashLinePen = pg.mkPen(color="r", width=2, style=Qt.DashLine) + self.oldMothBudLineBrush = pg.mkBrush(self.mothBudLineColor) self.newMothBudLineBrush = pg.mkBrush(self.newMothBudlineColor) def gui_createOverlayColors(self): fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] - self.logger.info( - f'Number of TIFF files detected: {len(fluoChannels)}' - ) + self.logger.info(f"Number of TIFF files detected: {len(fluoChannels)}") self.overlayColors = {} for c, ch in enumerate(fluoChannels): - if f'{ch}_rgb' in self.df_settings.index: - rgb_text = self.df_settings.at[f'{ch}_rgb', 'value'] - rgb = tuple([int(val) for val in rgb_text.split('_')]) + if f"{ch}_rgb" in self.df_settings.index: + rgb_text = self.df_settings.at[f"{ch}_rgb", "value"] + rgb = tuple([int(val) for val in rgb_text.split("_")]) self.overlayColors[ch] = rgb else: - if c >= len(self.overlayRGBs) -1: - i = c/len(fluoChannels) + if c >= len(self.overlayRGBs) - 1: + i = c / len(fluoChannels) additional_color_num = c - len(self.overlayRGBs) + 1 rgbs = [ - tuple([round(c*255) for c in self.overlayCmap(i)][:3]) + tuple([round(c * 255) for c in self.overlayCmap(i)][:3]) for _ in range(additional_color_num) ] self.overlayRGBs.extend(rgbs) @@ -1319,77 +1301,71 @@ def gui_createOverlayItems(self): self.user_ch_name, self.imgGrad ) self.baseLayerToolbutton.setChecked(True) - self.baseLayerToolbutton.clicked.connect( - self.overlayChannelToolbuttonClicked - ) - self.allOverlayToolbuttons = { - self.user_ch_name: self.baseLayerToolbutton - } - self.allOverlayToolbuttonsByIdx = { - 0: self.baseLayerToolbutton - } - self.baseLayerToolbutton.action = ( - self.overlayToolbar.addWidget(self.baseLayerToolbutton) + self.baseLayerToolbutton.clicked.connect(self.overlayChannelToolbuttonClicked) + self.allOverlayToolbuttons = {self.user_ch_name: self.baseLayerToolbutton} + self.allOverlayToolbuttonsByIdx = {0: self.baseLayerToolbutton} + self.baseLayerToolbutton.action = self.overlayToolbar.addWidget( + self.baseLayerToolbutton ) self.overlayLayersItems = {} self.overlayToolbarAreChannelsChecked = {} fluoChannels = [ch for ch in self.ch_names if ch != self.user_ch_name] for c, ch in enumerate(fluoChannels): - overlayItems = self.getOverlayItems(ch, c+1) + overlayItems = self.getOverlayItems(ch, c + 1) self.overlayLayersItems[ch] = overlayItems imageItem, lutItem = overlayItems[:2] self.ax1.addItem(imageItem) - self.lutItemsLayout.addItem(lutItem, row=0, col=c+1) + self.lutItemsLayout.addItem(lutItem, row=0, col=c + 1) toolbutton = overlayItems[3] self.allOverlayToolbuttons[ch] = toolbutton - self.allOverlayToolbuttonsByIdx[c+1] = toolbutton + self.allOverlayToolbuttonsByIdx[c + 1] = toolbutton self.overlayToolbuttonsSep = self.overlayToolbar.addSeparator() self.plotsCol = len(self.ch_names) - + self.ax1.addImageItem(self.rgbaImg1) def gui_createPlotItems(self): - if 'textIDsColor' in self.df_settings.index: - rgbString = self.df_settings.at['textIDsColor', 'value'] + if "textIDsColor" in self.df_settings.index: + rgbString = self.df_settings.at["textIDsColor", "value"] r, g, b = colors.rgb_str_to_values(rgbString) self.gui_createTextAnnotColors(r, g, b, custom=True) self.textIDsColorButton.setColor((r, g, b)) else: - self.gui_createTextAnnotColors(0,0,0, custom=False) + self.gui_createTextAnnotColors(0, 0, 0, custom=False) - if 'labels_text_color' in self.df_settings.index: - rgbString = self.df_settings.at['labels_text_color', 'value'] + if "labels_text_color" in self.df_settings.index: + rgbString = self.df_settings.at["labels_text_color", "value"] r, g, b = colors.rgb_str_to_values(rgbString) self.ax2_textColor = (r, g, b) else: self.ax2_textColor = (255, 0, 0) - - self.emptyLab = np.zeros((2,2), dtype=np.uint8) + + self.emptyLab = np.zeros((2, 2), dtype=np.uint8) # Right image item linked to left self.rightImageItem = widgets.ChildImageItem( linkedScrollbar=self.rightImageFramesScrollbar ) - self.imgGradRight.setImageItem(self.rightImageItem) + self.imgGradRight.setImageItem(self.rightImageItem) self.ax2.addItem(self.rightImageItem) - + # Left image self.img1 = widgets.ParentImageItem( linkedImageItem=self.rightImageItem, activatingActions=( self.labelsGrad.showRightImgAction, - self.labelsGrad.showNextFrameAction - ) + self.labelsGrad.showNextFrameAction, + ), ) self.imgGrad.setImageItem(self.img1) self.img1.lutItem = self.imgGrad self.imgGrad.sigRescaleIntes.connect(self.rescaleIntensitiesLut) self.ax1.addBaseImageItem(self.img1) - + # RGBA image for true transparency mode self.rgbaImg1 = pg.ImageItem() - + # self.rgbaImg1.setImage(self.emptyLab) # Right image @@ -1402,12 +1378,12 @@ def gui_createPlotItems(self): self.gui_createContourPens() self.gui_createMothBudLinePens() - self.eraserCirclePen = pg.mkPen(width=1.5, color='r') - + self.eraserCirclePen = pg.mkPen(width=1.5, color="r") + # Temporary line item connecting bud to new mother self.BudMothTempLine = pg.PlotDataItem(pen=self.NewBudMoth_Pen) self.topLayerItems.append(self.BudMothTempLine) - + # Temporary line item connecting objects to merge self.mergeObjsTempLine = widgets.PlotCurveItem(pen=self.redDashLinePen) self.topLayerItems.append(self.mergeObjsTempLine) @@ -1420,25 +1396,25 @@ def gui_createPlotItems(self): self.ax2.addItem(self.labelsLayerRightImg) # Red/green border rect item - self.GreenLinePen = pg.mkPen(color='g', width=2) - self.RedLinePen = pg.mkPen(color='r', width=2) + self.GreenLinePen = pg.mkPen(color="g", width=2) + self.RedLinePen = pg.mkPen(color="r", width=2) self.ax1BorderLine = pg.PlotDataItem() self.topLayerItems.append(self.ax1BorderLine) - self.ax2BorderLine = pg.PlotDataItem(pen=pg.mkPen(color='r', width=2)) + self.ax2BorderLine = pg.PlotDataItem(pen=pg.mkPen(color="r", width=2)) self.topLayerItems.append(self.ax2BorderLine) # Brush/Eraser/Wand.. layer item self.tempLayerRightImage = pg.ImageItem() self.tempLayerImg1 = widgets.ParentImageItem( linkedImageItem=self.tempLayerRightImage, - activatingAction=(self.labelsGrad.showRightImgAction, ) + activatingAction=(self.labelsGrad.showRightImgAction,), ) self.topLayerItems.append(self.tempLayerImg1) self.topLayerItemsRight.append(self.tempLayerRightImage) # Highlighted ID layer items self.highLightIDLayerImg1 = pg.ImageItem() - self.topLayerItems.append(self.highLightIDLayerImg1) + self.topLayerItems.append(self.highLightIDLayerImg1) # Highlighted ID layer items self.highLightIDLayerRightImage = pg.ImageItem() @@ -1448,7 +1424,7 @@ def gui_createPlotItems(self): self.keepIDsTempLayerRight = pg.ImageItem() self.keepIDsTempLayerLeft = widgets.ParentImageItem( linkedImageItem=self.keepIDsTempLayerRight, - activatingAction=self.labelsGrad.showRightImgAction + activatingAction=self.labelsGrad.showRightImgAction, ) self.topLayerItems.append(self.keepIDsTempLayerLeft) self.topLayerItemsRight.append(self.keepIDsTempLayerRight) @@ -1456,42 +1432,65 @@ def gui_createPlotItems(self): # Searched ID contour self.searchedIDitemRight = pg.ScatterPlotItem() self.searchedIDitemRight.setData( - [], [], symbol='s', pxMode=False, size=1, - brush=pg.mkBrush(color=(255,0,0,150)), - pen=pg.mkPen(width=2, color='r'), tip=None + [], + [], + symbol="s", + pxMode=False, + size=1, + brush=pg.mkBrush(color=(255, 0, 0, 150)), + pen=pg.mkPen(width=2, color="r"), + tip=None, ) self.searchedIDitemLeft = pg.ScatterPlotItem() self.searchedIDitemLeft.setData( - [], [], symbol='s', pxMode=False, size=1, - brush=pg.mkBrush(color=(255,0,0,150)), - pen=pg.mkPen(width=2, color='r'), tip=None + [], + [], + symbol="s", + pxMode=False, + size=1, + brush=pg.mkBrush(color=(255, 0, 0, 150)), + pen=pg.mkPen(width=2, color="r"), + tip=None, ) self.topLayerItems.append(self.searchedIDitemLeft) self.topLayerItemsRight.append(self.searchedIDitemRight) - # Brush circle img1 self.ax1_BrushCircle = pg.ScatterPlotItem() self.ax1_BrushCircle.setData( - [], [], symbol='o', pxMode=False, - brush=pg.mkBrush((255,255,255,50)), - pen=pg.mkPen(width=2), tip=None + [], + [], + symbol="o", + pxMode=False, + brush=pg.mkBrush((255, 255, 255, 50)), + pen=pg.mkPen(width=2), + tip=None, ) self.topLayerItems.append(self.ax1_BrushCircle) # Eraser circle img1 self.ax1_EraserCircle = pg.ScatterPlotItem() self.ax1_EraserCircle.setData( - [], [], symbol='o', pxMode=False, - brush=None, pen=self.eraserCirclePen, tip=None + [], + [], + symbol="o", + pxMode=False, + brush=None, + pen=self.eraserCirclePen, + tip=None, ) self.topLayerItems.append(self.ax1_EraserCircle) self.ax1_EraserX = pg.ScatterPlotItem() self.ax1_EraserX.setData( - [], [], symbol='x', pxMode=False, size=3, - brush=pg.mkBrush(color=(255,0,0,50)), - pen=pg.mkPen(width=1, color='r'), tip=None + [], + [], + symbol="x", + pxMode=False, + size=3, + brush=pg.mkBrush(color=(255, 0, 0, 50)), + pen=pg.mkPen(width=1, color="r"), + tip=None, ) self.topLayerItems.append(self.ax1_EraserX) @@ -1499,43 +1498,63 @@ def gui_createPlotItems(self): self.labelRoiCircItemLeft = widgets.LabelRoiCircularItem() self.labelRoiCircItemLeft.cleared = False self.labelRoiCircItemLeft.setData( - [], [], symbol='o', pxMode=False, - brush=pg.mkBrush(color=(255,0,0,0)), - pen=pg.mkPen(color='r', width=2), tip=None + [], + [], + symbol="o", + pxMode=False, + brush=pg.mkBrush(color=(255, 0, 0, 0)), + pen=pg.mkPen(color="r", width=2), + tip=None, ) self.labelRoiCircItemRight = widgets.LabelRoiCircularItem() self.labelRoiCircItemRight.cleared = False self.labelRoiCircItemRight.setData( - [], [], symbol='o', pxMode=False, - brush=pg.mkBrush(color=(255,0,0,0)), - pen=pg.mkPen(color='r', width=2), tip=None + [], + [], + symbol="o", + pxMode=False, + brush=pg.mkBrush(color=(255, 0, 0, 0)), + pen=pg.mkPen(color="r", width=2), + tip=None, ) self.topLayerItems.append(self.labelRoiCircItemLeft) self.topLayerItemsRight.append(self.labelRoiCircItemRight) - + self.ax1_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() self.ax1_binnedIDs_ScatterPlot.setData( - [], [], symbol='t', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=3, color='r'), tip=None + [], + [], + symbol="t", + pxMode=False, + brush=pg.mkBrush((255, 0, 0, 50)), + size=15, + pen=pg.mkPen(width=3, color="r"), + tip=None, ) self.topLayerItems.append(self.ax1_binnedIDs_ScatterPlot) - + self.ax1_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() self.ax1_ripIDs_ScatterPlot.setData( - [], [], symbol='x', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=2, color='r'), tip=None + [], + [], + symbol="x", + pxMode=False, + brush=pg.mkBrush((255, 0, 0, 50)), + size=15, + pen=pg.mkPen(width=2, color="r"), + tip=None, ) self.topLayerItems.append(self.ax1_ripIDs_ScatterPlot) # Ruler plotItem and scatterItem - rulerPen = pg.mkPen(color='r', style=Qt.DashLine, width=2) + rulerPen = pg.mkPen(color="r", style=Qt.DashLine, width=2) self.ax1_rulerPlotItem = widgets.RulerPlotItem(pen=rulerPen) self.ax1_rulerAnchorsItem = pg.ScatterPlotItem( - symbol='o', size=9, - brush=pg.mkBrush((255,0,0,50)), - pen=pg.mkPen((255,0,0), width=2), tip=None + symbol="o", + size=9, + brush=pg.mkBrush((255, 0, 0, 50)), + pen=pg.mkPen((255, 0, 0), width=2), + tip=None, ) self.topLayerItems.append(self.ax1_rulerPlotItem) self.topLayerItems.append(self.ax1_rulerPlotItem.labelItem) @@ -1544,76 +1563,106 @@ def gui_createPlotItems(self): # Start point of polyline roi self.ax1_point_ScatterPlot = pg.ScatterPlotItem() self.ax1_point_ScatterPlot.setData( - [], [], symbol='o', pxMode=False, size=3, - pen=pg.mkPen(width=2, color='r'), - brush=pg.mkBrush((255,0,0,50)), tip=None + [], + [], + symbol="o", + pxMode=False, + size=3, + pen=pg.mkPen(width=2, color="r"), + brush=pg.mkBrush((255, 0, 0, 50)), + tip=None, ) self.topLayerItems.append(self.ax1_point_ScatterPlot) # Experimental: scatter plot to add a point marker self.startPointPolyLineItem = pg.ScatterPlotItem() self.startPointPolyLineItem.setData( - [], [], symbol='o', size=9, - pen=pg.mkPen(width=2, color='r'), - brush=pg.mkBrush((255,0,0,50)), - hoverable=True, hoverBrush=pg.mkBrush((255,0,0,255)), tip=None + [], + [], + symbol="o", + size=9, + pen=pg.mkPen(width=2, color="r"), + brush=pg.mkBrush((255, 0, 0, 50)), + hoverable=True, + hoverBrush=pg.mkBrush((255, 0, 0, 255)), + tip=None, ) self.topLayerItems.append(self.startPointPolyLineItem) # Eraser circle img2 self.ax2_EraserCircle = pg.ScatterPlotItem() self.ax2_EraserCircle.setData( - [], [], symbol='o', pxMode=False, brush=None, - pen=self.eraserCirclePen, tip=None + [], + [], + symbol="o", + pxMode=False, + brush=None, + pen=self.eraserCirclePen, + tip=None, ) self.ax2.addItem(self.ax2_EraserCircle) self.ax2_EraserX = pg.ScatterPlotItem() self.ax2_EraserX.setData( - [], [], symbol='x', pxMode=False, size=3, - brush=pg.mkBrush(color=(255,0,0,50)), - pen=pg.mkPen(width=1.5, color='r') + [], + [], + symbol="x", + pxMode=False, + size=3, + brush=pg.mkBrush(color=(255, 0, 0, 50)), + pen=pg.mkPen(width=1.5, color="r"), ) self.ax2.addItem(self.ax2_EraserX) # Brush circle img2 self.ax2_BrushCirclePen = pg.mkPen(width=2) - self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50)) + self.ax2_BrushCircleBrush = pg.mkBrush((255, 255, 255, 50)) self.ax2_BrushCircle = pg.ScatterPlotItem() self.ax2_BrushCircle.setData( - [], [], symbol='o', pxMode=False, + [], + [], + symbol="o", + pxMode=False, brush=self.ax2_BrushCircleBrush, - pen=self.ax2_BrushCirclePen, tip=None + pen=self.ax2_BrushCirclePen, + tip=None, ) self.ax2.addItem(self.ax2_BrushCircle) # Annotated metadata markers (ScatterPlotItem) self.ax2_binnedIDs_ScatterPlot = widgets.BaseScatterPlotItem() self.ax2_binnedIDs_ScatterPlot.setData( - [], [], symbol='t', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=3, color='r'), tip=None + [], + [], + symbol="t", + pxMode=False, + brush=pg.mkBrush((255, 0, 0, 50)), + size=15, + pen=pg.mkPen(width=3, color="r"), + tip=None, ) self.ax2.addItem(self.ax2_binnedIDs_ScatterPlot) - + self.ax2_ripIDs_ScatterPlot = widgets.BaseScatterPlotItem() self.ax2_ripIDs_ScatterPlot.setData( - [], [], symbol='x', pxMode=False, - brush=pg.mkBrush((255,0,0,50)), size=15, - pen=pg.mkPen(width=2, color='r'), tip=None + [], + [], + symbol="x", + pxMode=False, + brush=pg.mkBrush((255, 0, 0, 50)), + size=15, + pen=pg.mkPen(width=2, color="r"), + tip=None, ) self.ax2.addItem(self.ax2_ripIDs_ScatterPlot) - self.freeRoiItem = widgets.PlotCurveItem( - pen=pg.mkPen(color='r', width=2) - ) + self.freeRoiItem = widgets.PlotCurveItem(pen=pg.mkPen(color="r", width=2)) self.topLayerItems.append(self.freeRoiItem) - + self.warnPairingItem = widgets.PlotCurveItem( - pen=pg.mkPen(color='r', width=5, style=Qt.DashLine), - pxMode=False + pen=pg.mkPen(color="r", width=5, style=Qt.DashLine), pxMode=False ) self.topLayerItems.append(self.warnPairingItem) - + self.exportMaskImageItem = pg.ImageItem() self.ghostContourItemLeft = widgets.GhostContourItem(self.ax1) @@ -1621,25 +1670,25 @@ def gui_createPlotItems(self): self.ghostMaskItemLeft = widgets.GhostMaskItem(self.ax1) self.ghostMaskItemRight = widgets.GhostMaskItem(self.ax2) - + self.manualBackgroundObjItem = widgets.GhostContourItem( - self.ax1, penColor='r', textColor='r' + self.ax1, penColor="r", textColor="r" ) self.manualBackgroundImageItem = pg.ImageItem() def gui_createTextAnnotColors(self, r, g, b, custom=False): if custom: self.objLabelAnnotRgb = (int(r), int(g), int(b)) - self.SphaseAnnotRgb = (int(r*0.9), int(r*0.9), int(b*0.9)) - self.G1phaseAnnotRgba = (int(r*0.8), int(g*0.8), int(b*0.8), 220) + self.SphaseAnnotRgb = (int(r * 0.9), int(r * 0.9), int(b * 0.9)) + self.G1phaseAnnotRgba = (int(r * 0.8), int(g * 0.8), int(b * 0.8), 220) else: - self.objLabelAnnotRgb = (255, 255, 255) # white + self.objLabelAnnotRgb = (255, 255, 255) # white self.SphaseAnnotRgb = (229, 229, 229) self.G1phaseAnnotRgba = (204, 204, 204, 220) - self.dividedAnnotRgb = (245, 188, 1) # orange + self.dividedAnnotRgb = (245, 188, 1) # orange - self.emptyBrush = pg.mkBrush((0,0,0,0)) - self.emptyPen = pg.mkPen((0,0,0,0)) + self.emptyBrush = pg.mkBrush((0, 0, 0, 0)) + self.emptyPen = pg.mkPen((0, 0, 0, 0)) def gui_createTextAnnotItems(self, allIDs): self.textAnnot = {} @@ -1648,21 +1697,21 @@ def gui_createTextAnnotItems(self, allIDs): for ax in range(2): ax_textAnnot = annotate.TextAnnotations() ax_textAnnot.initFonts(self.fontSize) - ax_textAnnot.createItems( - isHighResolution, allIDs, pxMode=pxMode - ) + ax_textAnnot.createItems(isHighResolution, allIDs, pxMode=pxMode) self.textAnnot[ax] = ax_textAnnot def gui_createZoomRectItem(self): Y, X = self.currentLab2D.shape # Label ROI rectangle - pen = pg.mkPen('r', width=3, style=Qt.DashLine) + pen = pg.mkPen("r", width=3, style=Qt.DashLine) self.zoomRectItem = widgets.ZoomROI( - (0,0), (0,0), - maxBounds=QRectF(QRect(0,0,X,Y)), + (0, 0), + (0, 0), + maxBounds=QRectF(QRect(0, 0, X, Y)), scaleSnap=True, translateSnap=True, - pen=pen, hoverPen=pen + pen=pen, + hoverPen=pen, ) def gui_getLostObjScatterItem(self): @@ -1670,8 +1719,7 @@ def gui_getLostObjScatterItem(self): brush = pg.mkBrush((*self.objLostAnnotRgb, 150)) pen = pg.mkPen(self.objLostAnnotRgb, width=1) lostObjScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight+1, pen=pen, - brush=brush, pxMode=False, symbol='s' + size=self.contLineWeight + 1, pen=pen, brush=brush, pxMode=False, symbol="s" ) return lostObjScatterItem @@ -1680,8 +1728,7 @@ def gui_getTrackedLostObjScatterItem(self): brush = pg.mkBrush((*self.objLostTrackedAnnotRgb, 150)) pen = pg.mkPen(self.objLostTrackedAnnotRgb, width=1) lostObjScatterItem = pg.ScatterPlotItem( - size=self.contLineWeight+1, pen=pen, - brush=brush, pxMode=False, symbol='s' + size=self.contLineWeight + 1, pen=pen, brush=brush, pxMode=False, symbol="s" ) return lostObjScatterItem @@ -1698,13 +1745,21 @@ def gui_initImg1BottomWidgets(self): def gui_setTextAnnotColors(self): self.textAnnot[0].setColors( - self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, - self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb + self.objLabelAnnotRgb, + self.dividedAnnotRgb, + self.SphaseAnnotRgb, + self.G1phaseAnnotRgba, + self.objLostAnnotRgb, + self.objLostTrackedAnnotRgb, ) self.textAnnot[1].setColors( - self.objLabelAnnotRgb, self.dividedAnnotRgb, self.SphaseAnnotRgb, - self.G1phaseAnnotRgba, self.objLostAnnotRgb, self.objLostTrackedAnnotRgb + self.objLabelAnnotRgb, + self.dividedAnnotRgb, + self.SphaseAnnotRgb, + self.G1phaseAnnotRgba, + self.objLostAnnotRgb, + self.objLostTrackedAnnotRgb, ) def hideOverlayLabelsItems(self, specific=None): @@ -1721,18 +1776,18 @@ def imgGradLUTfinished_cb(self): ticks = self.imgGrad.gradient.listTicks() self.img1ChannelGradients[self.user_ch_name] = { - 'ticks': [(x, t.color.getRgb()) for t,x in ticks], - 'mode': 'rgb' + "ticks": [(x, t.color.getRgb()) for t, x in ticks], + "mode": "rgb", } - + self.df_settings = self.imgGrad.saveState(self.df_settings) self.df_settings.to_csv(self.settings_csv_path) def initColormapOverlayLayerItem(self, foregrColor, lutItem): if self.invertBwAction.isChecked(): - bkgrColor = (255,255,255,255) + bkgrColor = (255, 255, 255, 255) else: - bkgrColor = (0,0,0,255) + bkgrColor = (0, 0, 0, 255) gradient = colors.get_pg_gradient((bkgrColor, foregrColor)) lutItem.setGradient(gradient) @@ -1773,9 +1828,7 @@ def loadOverlayData(self, ol_channels, addToExisting=False): ol_data = {} for i, ol_ch in enumerate(ol_channels): _, filename = self.getPathFromChName(ol_ch, posData) - ol_data[filename] = ( - posData.ol_data_dict[filename].copy() - ) + ol_data[filename] = posData.ol_data_dict[filename].copy() self.addFluoChNameContextMenuAction(ol_ch) posData.ol_data = ol_data @@ -1788,22 +1841,21 @@ def loadOverlayLabelsData(self, segmEndname, pos_i=None): if posData.ol_labels_data is None: posData.ol_labels_data = {} - if segmEndname == 'combined segm.': - posData.ol_labels_data['combined segm.'] = posData.combine_img_data - return + if segmEndname == "combined segm.": + posData.ol_labels_data["combined segm."] = posData.combine_img_data + return filePath, filename = load.get_path_from_endname( segmEndname, posData.images_path ) self.logger.info(f'Loading "{segmEndname}.npz"...') - labelsData = np.load(filePath)['arr_0'] + labelsData = np.load(filePath)["arr_0"] if posData.SizeT == 1: labelsData = labelsData[np.newaxis] if self.isSegm3D and labelsData.ndim == 3: # 2D segm --> stack to 3D T, Y, X = labelsData.shape - repeat = [labelsData]*posData.SizeZ + repeat = [labelsData] * posData.SizeZ labelsData = np.stack(repeat, axis=1) - posData.ol_labels_data[segmEndname] = labelsData @@ -1812,7 +1864,7 @@ def mothBudLineWeightToggled(self, checked=True): return self.imgGrad.uncheckContLineWeightActions() w = self.sender().lineWeight - self.df_settings.at['mothBudLineSize', 'value'] = w + self.df_settings.at["mothBudLineSize", "value"] = w self.df_settings.to_csv(self.settings_csv_path) self._updateMothBudLineSize(w) self.updateAllImages() @@ -1820,11 +1872,13 @@ def mothBudLineWeightToggled(self, checked=True): def mousePressColorButton(self, event): posData = self.data[self.pos_i] items = list(self.checkedOverlayChannels) - if len(items)>1: + if len(items) > 1: selectFluo = widgets.QDialogListbox( - 'Select image', - 'Select which fluorescence image you want to update the color of\n', - items, multiSelection=False, parent=self + "Select image", + "Select which fluorescence image you want to update the color of\n", + items, + multiSelection=False, + parent=self, ) selectFluo.exec_() keys = selectFluo.selectedItemsText @@ -1845,16 +1899,14 @@ def overlayChannelToggled(self, checked): self.loadOverlayData([channelName], addToExisting=True) else: _, filename = self.getPathFromChName(channelName, posData) - posData.ol_data[filename] = ( - posData.ol_data_dict[filename].copy() - ) - - self.checkedOverlayChannels.add(channelName) + posData.ol_data[filename] = posData.ol_data_dict[filename].copy() + + self.checkedOverlayChannels.add(channelName) else: self.checkedOverlayChannels.remove(channelName) imageItem = self.overlayLayersItems[channelName][0] imageItem.clear() - + self.setOverlayChannelsToolbuttonsChecked() self.setOverlayItemsVisible() self.setRetainSizePolicyLutItems() @@ -1863,29 +1915,29 @@ def overlayChannelToggled(self, checked): def overlayChannelToolbuttonClicked(self, checked=False, toolbutton=None): if toolbutton is None: toolbutton = self.sender() - - n_checked_buttons = ( - sum([b.isChecked() for b in self.allOverlayToolbuttons.values()]) + + n_checked_buttons = sum( + [b.isChecked() for b in self.allOverlayToolbuttons.values()] ) - + channelName = toolbutton.channelName() - + if n_checked_buttons == 0 or self.overlayToolbar.isSingleChannel(): # At least one button must be checked toolbutton.setChecked(True) - + if self.overlayToolbar.isSingleChannel(): - # Exclusive buttons + # Exclusive buttons for channel, otherToolbutton in self.allOverlayToolbuttons.items(): if channel == channelName: continue otherToolbutton.setChecked(False) - - if self.overlayToolbar.isTransparent(): + + if self.overlayToolbar.isTransparent(): self.setOverlayImages() return - + self.setOverlayItemsOpacities() def overlayLabelsDrawModeToggled(self, action): @@ -1901,7 +1953,7 @@ def overlayLabels_cb(self, checked, selectedLabelsEndnames=None): if selectedLabelsEndnames is None: selectedLabelsEndnames = self.askLabelsToOverlay() if selectedLabelsEndnames is None: - self.logger.info('Overlay labels cancelled.') + self.logger.info("Overlay labels cancelled.") self.overlayLabelsButton.setChecked(False) return for selectedEndname in selectedLabelsEndnames: @@ -1919,7 +1971,7 @@ def overlayLabels_cb(self, checked, selectedLabelsEndnames=None): def overlay_cb(self, checked): self.overlayToolbar.setVisible(checked) - + self.UserNormAction, _, _ = self.getCheckNormAction() posData = self.data[self.pos_i] if checked: @@ -1930,8 +1982,8 @@ def overlay_cb(self, checked): self.overlayButton.setChecked(False) self.overlayButton.toggled.connect(self.overlay_cb) return - - success = self.loadOverlayData(selectedChannels) + + success = self.loadOverlayData(selectedChannels) if not success: return False lastChannel = selectedChannels[-1] @@ -1952,27 +2004,26 @@ def overlay_cb(self, checked): self.updateImageValueFormatter() self.enableOverlayWidgets(False) self.clearOverlayImageItems() - - + self.setOverlayItemsVisible() def permanentGreedyCmapToggled(self, checked): if checked: - settings_value = 'yes' + settings_value = "yes" else: self.setLut() self.updateLookuptable() self.initLabelsImageItems() - settings_value = 'no' - + settings_value = "no" + self.updateAllImages() - + if self.isSnapshot: - option_name = 'permanent_greedy_lut_snapshots' + option_name = "permanent_greedy_lut_snapshots" else: - option_name = 'permanent_greedy_lut_timelapse' - - self.df_settings.at[option_name, 'value'] = settings_value + option_name = "permanent_greedy_lut_timelapse" + + self.df_settings.at[option_name, "value"] = settings_value self.df_settings.to_csv(self.settings_csv_path) def removeAllItems(self): @@ -1993,16 +2044,16 @@ def removeAllItems(self): except Exception as e: pass - if hasattr(self, 'contoursImage'): + if hasattr(self, "contoursImage"): self.initContoursImage() def removeOverlayItems(self): self.lutItemsLayout.clear() - + try: for toolbutton in self.allOverlayToolbuttonsByIdx.values(): self.overlayToolbar.removeAction(toolbutton.action) - + self.overlayToolbuttonsSep.removeFromToolbar() except Exception as err: pass @@ -2010,34 +2061,34 @@ def removeOverlayItems(self): def restoreDefaultColors(self): try: color = self.defaultToolBarButtonColor - self.overlayButton.setStyleSheet(f'background-color: {color}') + self.overlayButton.setStyleSheet(f"background-color: {color}") except AttributeError: # traceback.print_exc() pass def restoreDefaultSettings(self): df = self.df_settings - df.at['contLineWeight', 'value'] = 1 - df.at['mothBudLineSize', 'value'] = 1 - df.at['mothBudLineColor', 'value'] = (255, 165, 0, 255) - df.at['contLineColor', 'value'] = (205, 0, 0, 220) + df.at["contLineWeight", "value"] = 1 + df.at["mothBudLineSize", "value"] = 1 + df.at["mothBudLineColor", "value"] = (255, 165, 0, 255) + df.at["contLineColor", "value"] = (205, 0, 0, 220) self._updateContColour((205, 0, 0, 220)) self._updateMothBudLineColour((255, 165, 0, 255)) self._updateMothBudLineSize(1) self._updateContLineThickness() - df.at['overlaySegmMasksAlpha', 'value'] = 0.3 - df.at['img_cmap', 'value'] = 'grey' - self.imgCmap = self.imgGrad.cmaps['grey'] - self.imgCmapName = 'grey' - self.labelsGrad.item.loadPreset('viridis') - df.at['labels_bkgrColor', 'value'] = (25, 25, 25) - - if df.at['is_bw_inverted', 'value'] == 'Yes': + df.at["overlaySegmMasksAlpha", "value"] = 0.3 + df.at["img_cmap", "value"] = "grey" + self.imgCmap = self.imgGrad.cmaps["grey"] + self.imgCmapName = "grey" + self.labelsGrad.item.loadPreset("viridis") + df.at["labels_bkgrColor", "value"] = (25, 25, 25) + + if df.at["is_bw_inverted", "value"] == "Yes": self.invertBw(update=False) - - df = df[~df.index.str.contains('lab_cmap')] + + df = df[~df.index.str.contains("lab_cmap")] df.to_csv(self.settings_csv_path) self.imgGrad.restoreState(df) for items in self.overlayLayersItems.values(): @@ -2052,7 +2103,7 @@ def restoreDefaultSettings(self): def saveBkgrColor(self, button): color = button.color().getRgb()[:3] - self.df_settings.at['labels_bkgrColor', 'value'] = color + self.df_settings.at["labels_bkgrColor", "value"] = color self.df_settings.to_csv(self.settings_csv_path) self.updateAllImages() @@ -2064,32 +2115,32 @@ def saveMothBudLineColour(self, colorButton): def saveOverlayColor(self, button): rgb = button.color().getRgb()[:3] - rgb_text = '_'.join([str(val) for val in rgb]) - self.df_settings.at[f'{button.channel}_rgb', 'value'] = rgb_text + rgb_text = "_".join([str(val) for val in rgb]) + self.df_settings.at[f"{button.channel}_rgb", "value"] = rgb_text self.df_settings.to_csv(self.settings_csv_path) def saveTextIDsColors(self, button): - self.df_settings.at['textIDsColor', 'value'] = self.objLabelAnnotRgb + self.df_settings.at["textIDsColor", "value"] = self.objLabelAnnotRgb self.df_settings.to_csv(self.settings_csv_path) def saveTextLabelsColor(self, button): color = button.color().getRgb()[:3] - self.df_settings.at['labels_text_color', 'value'] = color + self.df_settings.at["labels_text_color", "value"] = color self.df_settings.to_csv(self.settings_csv_path) def segmNdimIndicatorClicked(self): ndimText = self.segmNdimIndicator.text() - if ndimText == '2D': - alternativeNdimText = '3D' - toggleText = 'activate' + if ndimText == "2D": + alternativeNdimText = "3D" + toggleText = "activate" else: - alternativeNdimText = '2D' - toggleText = 'de-activate' + alternativeNdimText = "2D" + toggleText = "de-activate" msg = widgets.myMessageBox(wrapText=False) - important_txt = (""" + important_txt = """ The toggle to activate 3D segmentation is visible only when the Number of z-slices is greater than 1. - """) + """ txt = html_utils.paragraph(f""" This indicator shows that you are working with {ndimText} segmentation masks.

    @@ -2104,12 +2155,14 @@ def segmNdimIndicatorClicked(self): {toggleText} the parameter called Work with 3D segmentation masks (z-stack)
    as indicated in the screenshot below
    . - {html_utils.to_admonition(important_txt, admonition_type='note')} + {html_utils.to_admonition(important_txt, admonition_type="note")}
    """) msg.information( - self, 'Segmentation nmber of dimensions info', txt, - image_paths=':toggle_3D_screenshot.png' + self, + "Segmentation nmber of dimensions info", + txt, + image_paths=":toggle_3D_screenshot.png", ) self.segmNdimIndicator.setChecked(True) @@ -2138,29 +2191,29 @@ def setContoursImage(self, imageItem, contours, thickness, color): imageItem.setImage(self.contoursImage) def setLostObjectContour(self, obj): - allContours = self.getObjContours(obj, all_external=True) + allContours = self.getObjContours(obj, all_external=True) for objContours in allContours: - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - data = [obj.label]*len(xx) + xx = objContours[:, 0] + 0.5 + yy = objContours[:, 1] + 0.5 + data = [obj.label] * len(xx) self.ax1_lostObjScatterItem.addPoints(xx, yy, data=data) self.ax2_lostObjScatterItem.addPoints(xx, yy) def setLut(self, shuffle=True): - self.lut = self.labelsGrad.item.colorMap().getLookupTable(0,1,255) + self.lut = self.labelsGrad.item.colorMap().getLookupTable(0, 1, 255) if shuffle: np.random.shuffle(self.lut) - + # Insert background color - if 'labels_bkgrColor' in self.df_settings.index: - rgbString = self.df_settings.at['labels_bkgrColor', 'value'] + if "labels_bkgrColor" in self.df_settings.index: + rgbString = self.df_settings.at["labels_bkgrColor", "value"] try: r, g, b = rgbString except Exception as e: r, g, b = colors.rgb_str_to_values(rgbString) else: r, g, b = 25, 25, 25 - self.df_settings.at['labels_bkgrColor', 'value'] = (r, g, b) + self.df_settings.at["labels_bkgrColor", "value"] = (r, g, b) self.lut = np.insert(self.lut, 0, [r, g, b], axis=0) @@ -2172,18 +2225,18 @@ def setOpacityOverlayLayersItems(self, value, imageItem=None, scrollbar=None): toolbutton = self.allOverlayToolbuttons[channel] if not toolbutton.isChecked() or not toolbutton.isVisible(): return - + if value is None: value = scrollbar.value() - + if imageItem is None: imageItem = scrollbar.imageItem - alpha = value/scrollbar.maximum() + alpha = value / scrollbar.maximum() elif value > 1: - alpha = value/scrollbar.maximum() + alpha = value / scrollbar.maximum() else: alpha = value - + alpha_values = [] activeOverlayImageItems = [] for items in self.overlayLayersItems.values(): @@ -2194,15 +2247,15 @@ def setOpacityOverlayLayersItems(self, value, imageItem=None, scrollbar=None): elif not _toolbutton.isChecked() or not _toolbutton.isVisible(): continue else: - alpha_values.append(alphaSB.value()/alphaSB.maximum()) - + alpha_values.append(alphaSB.value() / alphaSB.maximum()) + activeOverlayImageItems.append(imgItem) - + opacities = colors.hierarchical_weights(alpha_values)[::-1] - + for i, imgItem in enumerate(activeOverlayImageItems): - imgItem.setOpacity(opacities[i+1]) - + imgItem.setOpacity(opacities[i + 1]) + self.img1.setOpacity(opacities[0], applyToLinked=False) def setOverlayChannelsToolbuttonsChecked(self): @@ -2218,22 +2271,24 @@ def setOverlayColors(self): (255, 255, 0), (252, 72, 254), (49, 222, 134), - (22, 108, 27) + (22, 108, 27), ] - self.overlayCmap = matplotlib.colormaps['hsv'] + self.overlayCmap = matplotlib.colormaps["hsv"] self.overlayRGBs.extend( - [tuple([round(c*255) for c in self.overlayCmap(i)][:3]) - for i in np.linspace(0,1,8)] + [ + tuple([round(c * 255) for c in self.overlayCmap(i)][:3]) + for i in np.linspace(0, 1, 8) + ] ) def setOverlayImages(self, frame_i=None): if not self.overlayButton.isChecked(): return - + posData = self.data[self.pos_i] if posData.ol_data is None: return - + rgba_imgs_info = {} for filename in posData.ol_data: chName = myutils.get_chname_from_basename( @@ -2241,7 +2296,7 @@ def setOverlayImages(self, frame_i=None): ) if chName not in self.checkedOverlayChannels: continue - + items = self.overlayLayersItems[chName] imageItem, lutItem, alphaSB = items[:3] @@ -2251,19 +2306,19 @@ def setOverlayImages(self, frame_i=None): toolbutton = items[3] if not toolbutton.isChecked(): continue - alpha_val = alphaSB.value()/alphaSB.maximum() + alpha_val = alphaSB.value() / alphaSB.maximum() ol_img = skimage.exposure.rescale_intensity( ol_img, out_range=(0.0, 1.0) ) - out_range_min, out_range_max = lutItem.getLevels() + out_range_min, out_range_max = lutItem.getLevels() rgba_imgs_info[chName] = (ol_img, alpha_val, lutItem) else: self.rescaleIntensitiesLut(setImage=False, imageItem=imageItem) imageItem.setImage(ol_img) - + if not self.overlayToolbar.isTransparent(): - return - + return + alpha_values = [] images = [] luts = [] @@ -2271,43 +2326,38 @@ def setOverlayImages(self, frame_i=None): ol_img, alpha_val, lutItem = info alpha_values.append(alpha_val) images.append(ol_img) - luts.append(lutItem.gradient.getLookupTable(256, alpha=255)/255) - + luts.append(lutItem.gradient.getLookupTable(256, alpha=255) / 255) + weights = colors.hierarchical_weights(alpha_values) - + if self.baseLayerToolbutton.isChecked(): image1 = self._getImageupdateAllImages() - image1 = skimage.exposure.rescale_intensity( - image1, out_range=(0.0, 1.0) - ) + image1 = skimage.exposure.rescale_intensity(image1, out_range=(0.0, 1.0)) images.append(image1) - baseLut = ( - self.imgGrad.gradient.getLookupTable(256, alpha=255)/255 - ) + baseLut = self.imgGrad.gradient.getLookupTable(256, alpha=255) / 255 luts.append(baseLut) - + images_rgba = [] for img, lut in zip(images, luts): - rgba = colors.grayscale_apply_lut(img, lut) + rgba = colors.grayscale_apply_lut(img, lut) images_rgba.append(rgba) - - rgba_merge = colors.hierarchical_blend(images_rgba, weights) + + rgba_merge = colors.hierarchical_blend(images_rgba, weights) self.rgbaImg1.setImage(rgba_merge) def setOverlayItemsOpacities(self): - n_checked_buttons = ( - sum([b.isChecked() for b in self.allOverlayToolbuttons.values()]) + n_checked_buttons = sum( + [b.isChecked() for b in self.allOverlayToolbuttons.values()] ) - + isSingleChannel = ( - self.overlayToolbar.isSingleChannel() - or n_checked_buttons == 1 + self.overlayToolbar.isSingleChannel() or n_checked_buttons == 1 ) - + channel_opacity_mapper = self.getOpacitiesFromAlphaScrollbarValues() - + # Set opacity of every layer accordingly - for channel, otherToolbutton in self.allOverlayToolbuttons.items(): + for channel, otherToolbutton in self.allOverlayToolbuttons.items(): if channel == self.user_ch_name: otherImageItem = self.img1 alphaScrollbar = None @@ -2317,24 +2367,24 @@ def setOverlayItemsOpacities(self): otherImageItem = otherItems[0] alphaScrollbar = otherItems[2] # alpha_value = alphaScrollbar.value()/alphaScrollbar.maximum() - + if otherToolbutton.isChecked() and isSingleChannel: op_val = 1.0 elif otherToolbutton.isChecked(): op_val = channel_opacity_mapper[channel] else: op_val = 0.0 - + if op_val == 0: op_val = 0.01 op_val = op_val if op_val < 1.0 else 0.999 - + otherImageItem.setOpacity(op_val, applyToLinked=False) - + if alphaScrollbar is None: continue - + alphaScrollbar.setDisabled(bool(op_val == 0)) def setOverlayItemsVisible(self): @@ -2343,11 +2393,11 @@ def setOverlayItemsVisible(self): lutItem.hide() alphaSB.hide() alphaSB.label.hide() - toolbutton.setVisible(False) - + toolbutton.setVisible(False) + if not self.overlayButton.isChecked(): return - + for channel, items in self.overlayLayersItems.items(): _, lutItem, alphaSB, toolbutton = items[:4] if channel in self.checkedOverlayChannels: @@ -2360,7 +2410,7 @@ def setOverlayLabelsItems(self, specific=None): if not self.overlayLabelsButton.isChecked(): self.hideOverlayLabelsItems(specific=specific) return - + if specific is None: specific = self.drawModeOverlayLabelsChannels.keys() @@ -2370,47 +2420,44 @@ def setOverlayLabelsItems(self, specific=None): items = self.overlayLabelsItems[segmEndname] imageItem, contoursItem, gradItem = items contoursItem.clear() - if drawMode == 'Draw contours': + if drawMode == "Draw contours": for obj in skimage.measure.regionprops(ol_lab): - contours = self.getObjContours( - obj, all_external=True - ) + contours = self.getObjContours(obj, all_external=True) for cont in contours: - contoursItem.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) - elif drawMode == 'Overlay labels': + contoursItem.addPoints(cont[:, 0] + 0.5, cont[:, 1] + 0.5) + elif drawMode == "Overlay labels": imageItem.setImage(ol_lab, autoLevels=False) self.showOverlayLabelsItems(specific=specific) - def setOverlayLabelsItemsVisible(self, checked): + def setOverlayLabelsItemsVisible(self, checked): for _segmEndname, drawMode in self.drawModeOverlayLabelsChannels.items(): items = self.overlayLabelsItems[_segmEndname] gradItem = items[-1] gradItem.hide() - + if checked: segmEndname = self.sender().text() gradItem = self.overlayLabelsItems[segmEndname][-1] gradItem.show() def setOverlaySegmMasks(self, force=False, forceIfNotActive=False): - if not hasattr(self, 'currentLab2D'): + if not hasattr(self, "currentLab2D"): return how = self.drawIDsContComboBox.currentText() - isOverlaySegmLeftActive = how.find('overlay segm. masks') != -1 + isOverlaySegmLeftActive = how.find("overlay segm. masks") != -1 how_ax2 = self.getAnnotateHowRightImage() isOverlaySegmRightActive = ( - how_ax2.find('overlay segm. masks') != -1 + how_ax2.find("overlay segm. masks") != -1 and self.labelsGrad.showRightImgAction.isChecked() ) isOverlaySegmActive = ( - isOverlaySegmLeftActive or isOverlaySegmRightActive - or force + isOverlaySegmLeftActive or isOverlaySegmRightActive or force ) if not isOverlaySegmActive and not forceIfNotActive: - return + return alpha = self.imgGrad.labelsAlphaSlider.value() if alpha == 0: @@ -2420,23 +2467,24 @@ def setOverlaySegmMasks(self, force=False, forceIfNotActive=False): maxID = max(posData.IDs, default=0) if maxID >= len(self.lut): - self.extendLabelsLUT(maxID+10) + self.extendLabelsLUT(maxID + 10) currentLab2D = self.currentLab2D if isOverlaySegmLeftActive: self.labelsLayerImg1.setImage(currentLab2D, autoLevels=False) - if isOverlaySegmRightActive: + if isOverlaySegmRightActive: self.labelsLayerRightImg.setImage(currentLab2D, autoLevels=False) def setOverlaySingleChannel(self, *args, **kwargs): if self.overlayToolbar.isSingleChannel(): self.overlayToolbarAreChannelsChecked = { - channel:toolbutton.isChecked() + channel: toolbutton.isChecked() for channel, toolbutton in self.allOverlayToolbuttons.items() } firstActiveToolbutton = [ - toolbutton for toolbutton in self.allOverlayToolbuttons.values() + toolbutton + for toolbutton in self.allOverlayToolbuttons.values() if toolbutton.isChecked() ][0] firstActiveToolbutton.click() @@ -2444,53 +2492,45 @@ def setOverlaySingleChannel(self, *args, **kwargs): for ch, checked in self.overlayToolbarAreChannelsChecked.items(): toolbutton = self.allOverlayToolbuttons[ch] toolbutton.setChecked(checked) - + self.setOverlayItemsOpacities() def setOverlayTransparency(self, transparent: bool): opacity = float(transparent) opacity = opacity if opacity < 1.0 else 0.999 self.rgbaImg1.setOpacity(opacity) - + if transparent: self.img1.setOpacity(0.001, applyToLinked=False) self.imgGrad.sigLookupTableChanged.connect( self.updateTransparentOverlayRgba ) - self.imgGrad.sigLevelsChanged.connect( - self.updateTransparentOverlayRgba - ) - + self.imgGrad.sigLevelsChanged.connect(self.updateTransparentOverlayRgba) + for channel, items in self.overlayLayersItems.items(): imageItem, lutItem, alphaSB = items[:3] if transparent: alphaSB.valueChanged.disconnect() - alphaSB.valueChanged.connect( - self.updateTransparentOverlayRgba - ) - lutItem.sigLookupTableChanged.connect( - self.updateTransparentOverlayRgba - ) - lutItem.sigLevelsChanged.connect( - self.updateTransparentOverlayRgba - ) + alphaSB.valueChanged.connect(self.updateTransparentOverlayRgba) + lutItem.sigLookupTableChanged.connect(self.updateTransparentOverlayRgba) + lutItem.sigLevelsChanged.connect(self.updateTransparentOverlayRgba) imageItem.setOpacity(0) if not transparent: self.setOverlayItemsOpacities() - + self.setOverlayImages() def setPermanentGreedyCmapPreferences(self): if self.isSnapshot: - option_name = 'permanent_greedy_lut_snapshots' + option_name = "permanent_greedy_lut_snapshots" else: - option_name = 'permanent_greedy_lut_timelapse' + option_name = "permanent_greedy_lut_timelapse" if option_name not in self.df_settings.index: return - - checked = self.df_settings.at[option_name, 'value'] == 'yes' + + checked = self.df_settings.at[option_name, "value"] == "yes" self.labelsGrad.permanentGreedyCmapAction.setChecked(checked) def setRetainSizePolicyLutItems(self): @@ -2504,12 +2544,12 @@ def setRetainSizePolicyLutItems(self): def setTrackedLostObjectContour(self, obj): if self.isExportingVideo: return - - allContours = self.getObjContours(obj, all_external=True) + + allContours = self.getObjContours(obj, all_external=True) for objContours in allContours: - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 - data = [obj.label]*len(xx) + xx = objContours[:, 0] + 0.5 + yy = objContours[:, 1] + 0.5 + data = [obj.label] * len(xx) self.ax1_lostTrackedScatterItem.addPoints(xx, yy, data=data) self.ax2_lostTrackedScatterItem.addPoints(xx, yy) @@ -2535,9 +2575,9 @@ def showOverlayLabelsItems(self, specific=None): for segmEndname in specific: imageItem, contoursItem, gradItem = self.overlayLabelsItems[segmEndname] drawMode = self.drawModeOverlayLabelsChannels[segmEndname] - if drawMode == 'Draw contours': + if drawMode == "Draw contours": contoursItem.setVisible(True) - elif drawMode == 'Overlay labels': + elif drawMode == "Overlay labels": imageItem.setVisible(True) gradItem.setVisible(True) @@ -2562,7 +2602,7 @@ def updateBkgrColor(self, button): def updateContColour(self, colorButton): color = colorButton.color().getRgb() - self.df_settings.at['contLineColor', 'value'] = str(color) + self.df_settings.at["contLineColor", "value"] = str(color) self._updateContColour(color) self.updateAllImages() @@ -2570,20 +2610,20 @@ def updateContoursImage(self, ax, delROIsIDs=None, compute=True): imageItem = self.getContoursImageItem(ax) if imageItem is None: return - - if not hasattr(self, 'contoursImage'): + + if not hasattr(self, "contoursImage"): self.initContoursImage() else: self.contoursImage[:] = 0 - + contours = [] - for obj in skimage.measure.regionprops(self.currentLab2D): + for obj in skimage.measure.regionprops(self.currentLab2D): obj_contours = self.getObjContours( - obj, - all_external=True, + obj, + all_external=True, force_calc=compute, - include_internal=self.showAllContoursToggle.isChecked() - ) + include_internal=self.showAllContoursToggle.isChecked(), + ) contours.extend(obj_contours) thickness = self.contLineWeight @@ -2620,22 +2660,22 @@ def updateLookuptable(self, lenNewLut=None, delIDs=None): try: # lut = self.lut[:lenNewLut].copy() for ID in posData.binnedIDs: - lut[ID] = lut[ID]*0.2 + lut[ID] = lut[ID] * 0.2 for ID in posData.ripIDs: - lut[ID] = lut[ID]*0.2 + lut[ID] = lut[ID] * 0.2 except Exception as e: err_str = traceback.format_exc() - print('='*30) + print("=" * 30) self.logger.info(err_str) - print('='*30) + print("=" * 30) if updateLevels: self.img2.setLevels([0, len(lut)]) - + if self.keepIDsButton.isChecked(): - lut = np.round(lut*0.3).astype(np.uint8) - keptLut = np.round(lut[self.keptObjectsIDs]/0.3).astype(np.uint8) + lut = np.round(lut * 0.3).astype(np.uint8) + keptLut = np.round(lut[self.keptObjectsIDs] / 0.3).astype(np.uint8) lut[self.keptObjectsIDs] = keptLut self.img2.setLookupTable(lut) @@ -2645,37 +2685,39 @@ def updateLostContoursImage(self, ax, draw=True, delROIsIDs=None): imageItem = self.getLostObjImageItem(ax) if imageItem is None: return - - if not hasattr(self, 'lostObjContoursImage'): + + if not hasattr(self, "lostObjContoursImage"): self.initLostObjContoursImage() else: self.lostObjContoursImage[:] = 0 if delROIsIDs is None: delROIsIDs = set() - + posData = self.data[self.pos_i] - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[posData.frame_i - 1]["IDs_idxs"] if posData.whitelist is not None and posData.whitelist.whitelistIDs is not None: - whitelist = posData.whitelist.whitelistIDs[posData.frame_i-1] + whitelist = posData.whitelist.whitelistIDs[posData.frame_i - 1] else: whitelist = None contours = [] for lostID in posData.lost_IDs: - if lostID in delROIsIDs or (whitelist is not None and lostID not in whitelist): + if lostID in delROIsIDs or ( + whitelist is not None and lostID not in whitelist + ): continue - + obj = prev_rp[prev_IDs_idxs[lostID]] if not self.isObjVisible(obj.bbox): continue - + obj_contours = self.getObjContours(obj, all_external=True) - + if ax == 0: self.addLostObjsToLostObjImage(obj, lostID) - + contours.extend(obj_contours) if not draw: @@ -2684,35 +2726,35 @@ def updateLostContoursImage(self, ax, draw=True, delROIsIDs=None): self.drawLostObjContoursImage(imageItem, contours) def updateLostTrackedContoursImage( - self, ax, delROIsIDs=None, tracked_lost_IDs=None - ): + self, ax, delROIsIDs=None, tracked_lost_IDs=None + ): imageItem = self.getLostTrackedObjImageItem(ax) if imageItem is None: return - - if not hasattr(self, 'lostTrackedObjContoursImage'): + + if not hasattr(self, "lostTrackedObjContoursImage"): self.initLostTrackedObjContoursImage() else: self.lostTrackedObjContoursImage[:] = 0 - + if delROIsIDs is None: delROIsIDs = set() - + posData = self.data[self.pos_i] if tracked_lost_IDs is None: tracked_lost_IDs = self.getTrackedLostIDs() - - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] + + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[posData.frame_i - 1]["IDs_idxs"] contours = [] for tracked_lost_ID in tracked_lost_IDs: if tracked_lost_ID in delROIsIDs: continue - + obj = prev_rp[prev_IDs_idxs[tracked_lost_ID]] if not self.isObjVisible(obj.bbox): continue - + obj_contours = self.getObjContours(obj, all_external=True) contours.extend(obj_contours) @@ -2720,7 +2762,7 @@ def updateLostTrackedContoursImage( def updateMothBudLineColour(self, colorButton): color = colorButton.color().getRgb() - self.df_settings.at['mothBudLineColor', 'value'] = str(color) + self.df_settings.at["mothBudLineColor", "value"] = str(color) self._updateMothBudLineColour(color) self.updateAllImages() @@ -2730,7 +2772,7 @@ def updateTextAnnotColor(self, button): for items in self.overlayLayersItems.values(): lutItem = items[1] lutItem.textColorButton.setColor((r, g, b)) - self.gui_createTextAnnotColors(r,g,b, custom=True) + self.gui_createTextAnnotColors(r, g, b, custom=True) self.gui_setTextAnnotColors() self.updateAllImages() diff --git a/cellacdc/mixins/image_controls.py b/cellacdc/mixins/image_controls.py index 95195aeed..8f9b98c14 100644 --- a/cellacdc/mixins/image_controls.py +++ b/cellacdc/mixins/image_controls.py @@ -24,6 +24,7 @@ from .frame_navigation import FrameNavigation + class ImageControls(FrameNavigation): """Extracted from guiWin.""" @@ -39,7 +40,7 @@ def gui_createBottomWidgetsToBottomLayout(self): self.bottomLayout.addWidget(self.img1BottomGroupbox) self.bottomLayout.addStretch(1) self.bottomLayout.addWidget(self.rightBottomGroupbox) - self.bottomLayout.addStretch(1) + self.bottomLayout.addStretch(1) bottomScrollAreaLayout.addLayout(self.bottomLayout) bottomScrollAreaLayout.addStretch(1) @@ -48,18 +49,18 @@ def gui_createBottomWidgetsToBottomLayout(self): bottomScrollArea.setWidgetResizable(True) bottomScrollArea.setWidget(bottomWidget) self.bottomScrollArea = bottomScrollArea - - if 'bottom_sliders_zoom_perc' in self.df_settings.index: - val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value']) + + if "bottom_sliders_zoom_perc" in self.df_settings.index: + val = int(self.df_settings.at["bottom_sliders_zoom_perc", "value"]) zoom_perc = val else: zoom_perc = 100 - self.bottomLayoutContextMenu = QMenu('Bottom layout', self) - zoomMenu = self.bottomLayoutContextMenu.addMenu('Zoom') + self.bottomLayoutContextMenu = QMenu("Bottom layout", self) + zoomMenu = self.bottomLayoutContextMenu.addMenu("Zoom") actions = [] self.bottomLayoutContextMenu.zoomActionGroup = QActionGroup(zoomMenu) for perc in np.arange(50, 151, 10): - action = QAction(f'{perc}%', zoomMenu) + action = QAction(f"{perc}%", zoomMenu) action.setCheckable(True) if perc == zoom_perc: action.setChecked(True) @@ -67,18 +68,15 @@ def gui_createBottomWidgetsToBottomLayout(self): actions.append(action) self.bottomLayoutContextMenu.zoomActionGroup.addAction(action) zoomMenu.addActions(actions) - resetAction = self.bottomLayoutContextMenu.addAction( - 'Reset default height' - ) + resetAction = self.bottomLayoutContextMenu.addAction("Reset default height") resetAction.triggered.connect(self.resizeGui) retainSpaceAction = self.bottomLayoutContextMenu.addAction( - 'Retain space of hidden sliders' + "Retain space of hidden sliders" ) retainSpaceAction.setCheckable(True) - if 'retain_space_hidden_sliders' in self.df_settings.index: + if "retain_space_hidden_sliders" in self.df_settings.index: retainSpaceChecked = ( - self.df_settings.at['retain_space_hidden_sliders', 'value'] - == 'Yes' + self.df_settings.at["retain_space_hidden_sliders", "value"] == "Yes" ) else: retainSpaceChecked = True @@ -91,10 +89,10 @@ def gui_createGraphicsPlots(self): self.graphLayout = pg.GraphicsLayoutWidget() if self.invertBwAction.isChecked(): self.graphLayout.setBackground(graphLayoutBkgrColor) - self.titleColor = 'black' + self.titleColor = "black" else: self.graphLayout.setBackground(darkBkgrColor) - self.titleColor = 'white' + self.titleColor = "white" self.lutItemsLayout = self.graphLayout.addLayout(row=1, col=0) # self.lutItemsLayout.setBorder('w') @@ -103,8 +101,8 @@ def gui_createGraphicsPlots(self): self.ax1 = widgets.MainPlotItem(showWelcomeText=True) self.ax1.invertY(True) self.ax1.setAspectLocked(True) - self.ax1.hideAxis('bottom') - self.ax1.hideAxis('left') + self.ax1.hideAxis("bottom") + self.ax1.hideAxis("left") self.plotsCol = 1 self.graphLayout.addItem(self.ax1, row=1, col=1) @@ -112,8 +110,8 @@ def gui_createGraphicsPlots(self): self.ax2 = widgets.MainPlotItem() self.ax2.setAspectLocked(True) self.ax2.invertY(True) - self.ax2.hideAxis('bottom') - self.ax2.hideAxis('left') + self.ax2.hideAxis("bottom") + self.ax2.hideAxis("left") # self.currentFrameLabelItem = pg.LabelItem( # color=self.titleColor, size='13px' # ) @@ -122,16 +120,16 @@ def gui_createGraphicsPlots(self): def gui_createImg1Widgets(self): # Toggle contours/ID combobox self.drawIDsContComboBoxSegmItems = [ - 'Draw IDs and contours', - 'Draw IDs and overlay segm. masks', - 'Draw only cell cycle info', - 'Draw cell cycle info and contours', - 'Draw cell cycle info and overlay segm. masks', - 'Draw only mother-bud lines', - 'Draw only IDs', - 'Draw only contours', - 'Draw only overlay segm. masks', - 'Draw nothing' + "Draw IDs and contours", + "Draw IDs and overlay segm. masks", + "Draw only cell cycle info", + "Draw cell cycle info and contours", + "Draw cell cycle info and overlay segm. masks", + "Draw only mother-bud lines", + "Draw only IDs", + "Draw only contours", + "Draw only overlay segm. masks", + "Draw nothing", ] self.drawIDsContComboBox = widgets.ComboBox() self.drawIDsContComboBox.setFont(_font) @@ -139,23 +137,28 @@ def gui_createImg1Widgets(self): self.drawIDsContComboBox.setVisible(False) self.annotIDsCheckbox = widgets.CheckBox( - 'IDs', keyPressCallback=self.resetFocus) + "IDs", keyPressCallback=self.resetFocus + ) self.annotCcaInfoCheckbox = widgets.CheckBox( - 'Cell cycle info', keyPressCallback=self.resetFocus) + "Cell cycle info", keyPressCallback=self.resetFocus + ) self.annotNumZslicesCheckbox = widgets.CheckBox( - 'No. z-slices/object', keyPressCallback=self.resetFocus) + "No. z-slices/object", keyPressCallback=self.resetFocus + ) self.annotContourCheckbox = widgets.CheckBox( - 'Contours', keyPressCallback=self.resetFocus) + "Contours", keyPressCallback=self.resetFocus + ) self.annotSegmMasksCheckbox = widgets.CheckBox( - 'Segm. masks', keyPressCallback=self.resetFocus) + "Segm. masks", keyPressCallback=self.resetFocus + ) self.drawMothBudLinesCheckbox = widgets.CheckBox( - 'Only mother-daughter line', keyPressCallback=self.resetFocus + "Only mother-daughter line", keyPressCallback=self.resetFocus ) self.drawNothingCheckbox = widgets.CheckBox( - 'Do not annotate', keyPressCallback=self.resetFocus + "Do not annotate", keyPressCallback=self.resetFocus ) self.annotOptionsWidget = QWidget() @@ -163,7 +166,7 @@ def gui_createImg1Widgets(self): # Show tree info checkbox self.showTreeInfoCheckbox = widgets.CheckBox( - 'Show tree info', keyPressCallback=self.resetFocus + "Show tree info", keyPressCallback=self.resetFocus ) self.showTreeInfoCheckbox.setFont(_font) sp = self.showTreeInfoCheckbox.sizePolicy() @@ -172,23 +175,23 @@ def gui_createImg1Widgets(self): self.showTreeInfoCheckbox.hide() annotOptionsLayout.addWidget(self.showTreeInfoCheckbox) - annotOptionsLayout.addWidget(QLabel(' | ')) + annotOptionsLayout.addWidget(QLabel(" | ")) annotOptionsLayout.addWidget(self.annotIDsCheckbox) annotOptionsLayout.addWidget(self.annotCcaInfoCheckbox) annotOptionsLayout.addWidget(self.drawMothBudLinesCheckbox) annotOptionsLayout.addWidget(self.annotNumZslicesCheckbox) - annotOptionsLayout.addWidget(QLabel(' | ')) + annotOptionsLayout.addWidget(QLabel(" | ")) annotOptionsLayout.addWidget(self.annotContourCheckbox) annotOptionsLayout.addWidget(self.annotSegmMasksCheckbox) - annotOptionsLayout.addWidget(QLabel(' | ')) + annotOptionsLayout.addWidget(QLabel(" | ")) annotOptionsLayout.addWidget(self.drawNothingCheckbox) annotOptionsLayout.addWidget(self.drawIDsContComboBox) self.annotOptionsLayout = annotOptionsLayout # Toggle highlight z+-1 objects combobox self.highlightZneighObjCheckbox = widgets.CheckBox( - 'Highlight objects in neighbouring z-slices', - keyPressCallback=self.resetFocus + "Highlight objects in neighbouring z-slices", + keyPressCallback=self.resetFocus, ) self.highlightZneighObjCheckbox.setFont(_font) self.highlightZneighObjCheckbox.hide() @@ -198,41 +201,46 @@ def gui_createImg1Widgets(self): # Annotations options right image self.annotIDsCheckboxRight = widgets.CheckBox( - 'IDs', keyPressCallback=self.resetFocus) + "IDs", keyPressCallback=self.resetFocus + ) self.annotCcaInfoCheckboxRight = widgets.CheckBox( - 'Cell cycle info', keyPressCallback=self.resetFocus) + "Cell cycle info", keyPressCallback=self.resetFocus + ) self.annotNumZslicesCheckboxRight = widgets.CheckBox( - 'No. z-slices/object', keyPressCallback=self.resetFocus + "No. z-slices/object", keyPressCallback=self.resetFocus ) self.annotContourCheckboxRight = widgets.CheckBox( - 'Contours', keyPressCallback=self.resetFocus) + "Contours", keyPressCallback=self.resetFocus + ) self.annotSegmMasksCheckboxRight = widgets.CheckBox( - 'Segm. masks', keyPressCallback=self.resetFocus) + "Segm. masks", keyPressCallback=self.resetFocus + ) self.drawMothBudLinesCheckboxRight = widgets.CheckBox( - 'Only mother-daughter line', keyPressCallback=self.resetFocus + "Only mother-daughter line", keyPressCallback=self.resetFocus ) self.drawNothingCheckboxRight = widgets.CheckBox( - 'Do not annotate', keyPressCallback=self.resetFocus) + "Do not annotate", keyPressCallback=self.resetFocus + ) self.annotOptionsWidgetRight = QWidget() annotOptionsLayoutRight = QHBoxLayout() - annotOptionsLayoutRight.addWidget(QLabel(' ')) - annotOptionsLayoutRight.addWidget(QLabel(' | ')) + annotOptionsLayoutRight.addWidget(QLabel(" ")) + annotOptionsLayoutRight.addWidget(QLabel(" | ")) annotOptionsLayoutRight.addWidget(self.annotIDsCheckboxRight) annotOptionsLayoutRight.addWidget(self.annotCcaInfoCheckboxRight) annotOptionsLayoutRight.addWidget(self.drawMothBudLinesCheckboxRight) annotOptionsLayoutRight.addWidget(self.annotNumZslicesCheckboxRight) - annotOptionsLayoutRight.addWidget(QLabel(' | ')) + annotOptionsLayoutRight.addWidget(QLabel(" | ")) annotOptionsLayoutRight.addWidget(self.annotContourCheckboxRight) annotOptionsLayoutRight.addWidget(self.annotSegmMasksCheckboxRight) - annotOptionsLayoutRight.addWidget(QLabel(' | ')) + annotOptionsLayoutRight.addWidget(QLabel(" | ")) annotOptionsLayoutRight.addWidget(self.drawNothingCheckboxRight) self.annotOptionsLayoutRight = annotOptionsLayoutRight - + self.annotOptionsWidgetRight.setLayout(annotOptionsLayoutRight) # Frames scrollbar @@ -241,15 +249,15 @@ def gui_createImg1Widgets(self): self.navigateScrollBar.setMinimum(1) self.navigateScrollBar.setMaximum(1) self.navigateScrollBar.setToolTip( - 'NOTE: The maximum frame number that can be visualized with this ' - 'scrollbar\n' - 'is the last visited frame with the selected mode\n' + "NOTE: The maximum frame number that can be visualized with this " + "scrollbar\n" + "is the last visited frame with the selected mode\n" '(see "Mode" selector on the top-right).\n\n' - 'If the scrollbar does not move it means that you never visited\n' - 'any frame with current mode.\n\n' + "If the scrollbar does not move it means that you never visited\n" + "any frame with current mode.\n\n" 'Note that the "Viewer" mode allows you to scroll ALL frames.' ) - t_label = QLabel('frame n. ') + t_label = QLabel("frame n. ") t_label.setFont(_font) self.t_label = t_label @@ -258,36 +266,41 @@ def gui_createImg1Widgets(self): self.zProjComboBox = widgets.ComboBox() self.zProjComboBox.setFont(_font) - self.zProjComboBox.addItems([ - 'single z-slice', - 'max z-projection', - 'mean z-projection', - 'median z-proj.' - ]) + self.zProjComboBox.addItems( + [ + "single z-slice", + "max z-projection", + "mean z-projection", + "median z-proj.", + ] + ) self.zProjLockViewButton = widgets.LockPushButton() self.zProjLockViewButton.setCheckable(True) self.zProjLockViewButton.setToolTip( - 'If active, the selected z-slice view is applied to all frames' + "If active, the selected z-slice view is applied to all frames" ) self.zProjLockViewButton.hide() - + self.switchPlaneCombobox = widgets.SwitchPlaneCombobox() - self.switchPlaneCombobox.setToolTip( - 'Switch viewed plane' - ) + self.switchPlaneCombobox.setToolTip("Switch viewed plane") self.zSliceOverlay_SB = widgets.ScrollBar(Qt.Horizontal) - _z_label = QLabel('Overlay z-slice ') + _z_label = QLabel("Overlay z-slice ") _z_label.setFont(_font) _z_label.setDisabled(True) self.overlay_z_label = _z_label self.zProjOverlay_CB = widgets.ComboBox() self.zProjOverlay_CB.setFont(_font) - self.zProjOverlay_CB.addItems([ - 'single z-slice', 'max z-projection', 'mean z-projection', - 'median z-proj.', 'same as above' - ]) + self.zProjOverlay_CB.addItems( + [ + "single z-slice", + "max z-projection", + "mean z-projection", + "median z-proj.", + "same as above", + ] + ) self.zProjOverlay_CB.setCurrentIndex(4) self.zSliceOverlay_SB.setDisabled(True) @@ -296,8 +309,8 @@ def gui_createImg1Widgets(self): def gui_createLabWidgets(self): bottomRightLayout = QVBoxLayout() self.rightBottomGroupbox = widgets.GroupBox( - 'Annotate right image independent of left image', - keyPressCallback=self.resetFocus + "Annotate right image independent of left image", + keyPressCallback=self.resetFocus, ) self.rightBottomGroupbox.setCheckable(True) self.rightBottomGroupbox.setChecked(False) @@ -314,10 +327,10 @@ def gui_createLabWidgets(self): self.annotOptionsLayoutRight.addWidget(self.annotateRightHowCombobox) self.rightImageFramesScrollbar = widgets.ScrollBarWithNumericControl( - labelText='Frame n. ' + labelText="Frame n. " ) self.rightImageFramesScrollbar.setVisible(False) - + bottomRightLayout.addWidget(self.annotOptionsWidgetRight) bottomRightLayout.addWidget(self.rightImageFramesScrollbar) bottomRightLayout.addStretch(1) @@ -329,7 +342,7 @@ def gui_createLabWidgets(self): def gui_getImg1BottomWidgets(self): bottomLeftLayout = QGridLayout() self.bottomLeftLayout = bottomLeftLayout - container = QGroupBox('Navigate and annotate left image') + container = QGroupBox("Navigate and annotate left image") row = 0 bottomLeftLayout.addWidget(self.annotOptionsWidget, row, 0, 1, 4) @@ -348,40 +361,32 @@ def gui_getImg1BottomWidgets(self): self.navSpinBox = widgets.SpinBox(disableKeyPress=True) self.navSpinBox.setMinimum(1) self.navSpinBox.setMaximum(100) - self.navSizeLabel = QLabel('/ND') + self.navSizeLabel = QLabel("/ND") navWidgetsLayout.addWidget(self.t_label) navWidgetsLayout.addWidget(self.navSpinBox) navWidgetsLayout.addWidget(self.navSizeLabel) - bottomLeftLayout.addLayout( - navWidgetsLayout, row, 0, alignment=Qt.AlignRight - ) - bottomLeftLayout.addWidget(self.navigateScrollBar, row, 1, 1, 2) + bottomLeftLayout.addLayout(navWidgetsLayout, row, 0, alignment=Qt.AlignRight) + bottomLeftLayout.addWidget(self.navigateScrollBar, row, 1, 1, 2) sp = self.navigateScrollBar.sizePolicy() sp.setRetainSizeWhenHidden(True) self.navigateScrollBar.setSizePolicy(sp) self.navSpinBox.connectValueChanged(self.navigateSpinboxValueChanged) - self.navSpinBox.editingFinished.connect( - self.navigateSpinboxEditingFinished - ) - self.navSpinBox.sigUpClicked.connect( - self.navigateSpinboxEditingFinished - ) - self.navSpinBox.sigDownClicked.connect( - self.navigateSpinboxEditingFinished - ) + self.navSpinBox.editingFinished.connect(self.navigateSpinboxEditingFinished) + self.navSpinBox.sigUpClicked.connect(self.navigateSpinboxEditingFinished) + self.navSpinBox.sigDownClicked.connect(self.navigateSpinboxEditingFinished) self.lastTrackedFrameLabel = QLabel() self.lastTrackedFrameLabel.setFont(_font) bottomLeftLayout.addWidget(self.lastTrackedFrameLabel, row, 3) - + row += 1 zSliceCheckboxLayout = QHBoxLayout() - self.zSliceCheckbox = QCheckBox('z-slice') + self.zSliceCheckbox = QCheckBox("z-slice") self.zSliceSpinbox = widgets.SpinBox(disableKeyPress=True) self.zSliceSpinbox.setMinimum(1) - self.SizeZlabel = QLabel('/ND') + self.SizeZlabel = QLabel("/ND") self.zSliceCheckbox.setToolTip( - 'Activate/deactivate control of the z-slices with keyboard arrows.\n\n' + "Activate/deactivate control of the z-slices with keyboard arrows.\n\n" 'SHORTCUT to toggle ON/OFF: "Z" key' ) zSliceCheckboxLayout.addWidget(self.zSliceCheckbox) @@ -408,9 +413,9 @@ def gui_getImg1BottomWidgets(self): row += 1 self.alphaScrollbarRow = row - bottomLeftLayout.setColumnStretch(0,0) - bottomLeftLayout.setColumnStretch(1,3) - bottomLeftLayout.setColumnStretch(2,0) + bottomLeftLayout.setColumnStretch(0, 0) + bottomLeftLayout.setColumnStretch(1, 3) + bottomLeftLayout.setColumnStretch(2, 0) container.setLayout(bottomLeftLayout) return container @@ -439,4 +444,4 @@ def setFocusGraphics(self): def setFocusMain(self): # on macOS with Qt6 setFocus causes crashes. Disabled for now. - return + return diff --git a/cellacdc/mixins/image_display.py b/cellacdc/mixins/image_display.py index 5915e5689..6cfb457d3 100644 --- a/cellacdc/mixins/image_display.py +++ b/cellacdc/mixins/image_display.py @@ -23,56 +23,57 @@ from .display_decorations import DisplayDecorations + class ImageDisplay(DisplayDecorations): """Extracted from guiWin.""" def RGBtoGray(self, R, G, B): # see https://stackoverflow.com/questions/17615963/standard-rgb-to-grayscale-conversion - C_linear = (0.2126*R + 0.7152*G + 0.0722*B)/255 + C_linear = (0.2126 * R + 0.7152 * G + 0.0722 * B) / 255 if C_linear <= 0.0031309: - gray = 12.92*C_linear + gray = 12.92 * C_linear else: - gray = 1.055*(C_linear)**(1/2.4) - 0.055 + gray = 1.055 * (C_linear) ** (1 / 2.4) - 0.055 return gray def _getImageupdateAllImages(self, image=None): if image is not None: return image - + img = self.getImage() return img def activeBrushCircleCursors(self, isHoverImg1): if self.showMirroredCursorAction.isChecked(): return self.ax1_BrushCircle, self.ax2_BrushCircle - + if isHoverImg1: - return self.ax1_BrushCircle, + return (self.ax1_BrushCircle,) else: - return self.ax2_BrushCircle, + return (self.ax2_BrushCircle,) def activeEraserCircleCursors(self, isHoverImg1): if self.showMirroredCursorAction.isChecked(): return self.ax1_EraserCircle, self.ax2_EraserCircle - + if isHoverImg1: - return self.ax1_EraserCircle, + return (self.ax1_EraserCircle,) else: - return self.ax2_EraserCircle, + return (self.ax2_EraserCircle,) def activeEraserXCursors(self, isHoverImg1): if self.showMirroredCursorAction.isChecked(): return self.ax1_EraserX, self.ax2_EraserX - + if isHoverImg1: - return self.ax1_EraserX, + return (self.ax1_EraserX,) else: - return self.ax2_EraserX, + return (self.ax2_EraserX,) def addFontSizeActions(self, menu, slot): fontActionGroup = QActionGroup(self) fontActionGroup.setExclusive(True) - for fontSize in range(4,27): + for fontSize in range(4, 27): action = QAction(self) action.setText(str(fontSize)) action.setCheckable(True) @@ -92,12 +93,12 @@ def changeFontSize(self): fontSize = self.fontSizeSpinBox.value() if fontSize == self.fontSize: return - + self.fontSize = fontSize - self.df_settings.at['fontSize', 'value'] = self.fontSize + self.df_settings.at["fontSize", "value"] = self.fontSize self.df_settings.to_csv(self.settings_csv_path) - + self.setAllIDs() posData = self.data[self.pos_i] for ax in range(2): @@ -109,13 +110,17 @@ def changeFontSize(self): def clearCursors(self): self.ax1_cursor.setData([], []) - self.ax2_cursor.setData([], []) + self.ax2_cursor.setData([], []) self.setHoverToolSymbolData( - [], [], (self.ax2_BrushCircle, self.ax1_BrushCircle), - ) + [], + [], + (self.ax2_BrushCircle, self.ax1_BrushCircle), + ) eraserCursors = ( - self.ax1_EraserCircle, self.ax2_EraserCircle, - self.ax1_EraserX, self.ax2_EraserX + self.ax1_EraserCircle, + self.ax2_EraserCircle, + self.ax1_EraserX, + self.ax2_EraserX, ) self.setHoverToolSymbolData([], [], eraserCursors) @@ -129,14 +134,15 @@ def editImgProperties(self, checked=True): ask_SizeT=True, ask_TimeIncrement=True, ask_PhysicalSizes=True, - save=True, singlePos=True, - askSegm3D=False + save=True, + singlePos=True, + askSegm3D=False, ) - if hasattr(self, 'timestamp'): + if hasattr(self, "timestamp"): self.timestamp.setSecondsPerFrame(posData.TimeIncrement) self.updateTimestampFrame() - - if hasattr(self, 'scaleBar'): + + if hasattr(self, "scaleBar"): self.scaleBar.updatePhysicalLength(posData.PhysicalSizeX) def enableZstackWidgets(self, enabled): @@ -171,7 +177,7 @@ def enableZstackWidgets(self, enabled): self.SizeZlabel.hide() self.switchPlaneCombobox.hide() self.switchPlaneCombobox.setDisabled(True) - + self.imgGrad.rescaleAcrossZstackAction.setDisabled(not enabled) for ch, overlayItems in self.overlayLayersItems.items(): lutItem = overlayItems[1] @@ -179,12 +185,12 @@ def enableZstackWidgets(self, enabled): def equalizeHist(self, checked=True): self.img1.useEqualized = checked - + if not checked: self.updateAllImages() return - self.logger.info('Equalizing image histogram...') + self.logger.info("Equalizing image histogram...") for pos_i, _posData in enumerate(self.data): n_dim_img = _posData.img_data.ndim _posData.equalized_img_data = preprocess.PreprocessedData() @@ -205,12 +211,12 @@ def equalizeHist(self, checked=True): self.img1.updateMinMaxValuesEqualizedData( self.data, pos_i, frame_i, None ) - + self.updateAllImages() def getCheckNormAction(self): normalize = False - how = '' + how = "" for action in self.normalizeQActionGroup.actions(): if action.isChecked(): how = action.text() @@ -221,7 +227,7 @@ def getCheckNormAction(self): def getContoursImageItem(self, ax, force=False): if not self.areContoursRequested(ax) and not force: return - + if ax == 0: return self.ax1_contoursImageItem else: @@ -235,11 +241,9 @@ def getDisplayedZstack(self): return posData.img_data[posData.frame_i] def getDistantGray(self, desiredGray, bkgrGray): - isDesiredSimilarToBkgr = ( - abs(desiredGray-bkgrGray) < 0.3 - ) + isDesiredSimilarToBkgr = abs(desiredGray - bkgrGray) < 0.3 if isDesiredSimilarToBkgr: - return 1-desiredGray + return 1 - desiredGray else: return desiredGray @@ -247,16 +251,16 @@ def getImage(self, frame_i=None, raw=False): posData = self.data[self.pos_i] if frame_i is None: frame_i = posData.frame_i - + if raw: return self.getRawImageLayer0(frame_i) - + if self.viewPreprocDataToggle.isChecked(): try: img = posData.preproc_img_data[frame_i] if posData.SizeZ == 1: return np.array(img) - + self.updateZsliceScrollbar(frame_i) z_slice = self.z_slice_index() img = img[z_slice] @@ -266,19 +270,19 @@ def getImage(self, frame_i=None, raw=False): # 'Pre-processed image not existing --> returning raw image' # ) return self.getRawImageLayer0(frame_i) - + viewCombinedImageData = ( self.viewCombineChannelDataToggle.isChecked() and self.combineDialog is not None and not self.combineDialog.saveAsSegm() ) - + if viewCombinedImageData: try: img = posData.combine_img_data[frame_i] if posData.SizeZ == 1: return np.array(img) - + self.updateZsliceScrollbar(frame_i) z_slice = self.z_slice_index() img = img[z_slice] @@ -288,12 +292,12 @@ def getImage(self, frame_i=None, raw=False): # 'combined image not existing --> returning raw image' # ) return self.getRawImageLayer0(frame_i) - + if self.equalizeHistPushButton.isChecked(): img = posData.equalized_img_data[frame_i] if posData.SizeZ == 1: return np.array(img) - + self.updateZsliceScrollbar(frame_i) z_slice = self.z_slice_index() img = img[z_slice] @@ -321,16 +325,16 @@ def getLostTrackedObjImageItem(self, ax): return self.ax2_lostTrackedObjImageItem def getObjBbox(self, obj_bbox): - if self.isSegm3D and len(obj_bbox)==6: + if self.isSegm3D and len(obj_bbox) == 6: obj_bbox = (obj_bbox[1], obj_bbox[2], obj_bbox[4], obj_bbox[5]) return obj_bbox else: return obj_bbox def getObjImage(self, obj_image, obj_bbox, z_slice=None): - if self.isSegm3D and len(obj_bbox)==6: + if self.isSegm3D and len(obj_bbox) == 6: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' + isZslice = zProjHow == "single z-slice" if not isZslice: # required a projection return obj_image.max(axis=0) @@ -340,7 +344,7 @@ def getObjImage(self, obj_image, obj_bbox, z_slice=None): z_slice = self.z_lab() if isinstance(z_slice, tuple): z_slice = z_slice[-1] - + local_z = z_slice - min_z try: obi_image_2d = obj_image[local_z] @@ -375,7 +379,7 @@ def getObject2DsliceFromZ(self, z, obj): def getPreComputedMinMaxZstack(self, channel: str): if channel != self.user_ch_name: return None - + posData = self.data[self.pos_i] zstack_min, zstack_max = np.inf, 0 for z in range(posData.SizeZ): @@ -383,14 +387,14 @@ def getPreComputedMinMaxZstack(self, channel: str): levels = self.img1.minMaxValuesMapper.get(key) if levels is None: return - + img_min, img_max = levels if img_min < zstack_min: zstack_min = img_min - + if img_max > zstack_max: zstack_max = img_max - + return (zstack_min, zstack_max) def getRawImage(self, frame_i=None, filename=None): @@ -400,7 +404,7 @@ def getRawImage(self, frame_i=None, filename=None): if filename is None: rawImgData = posData.img_data[frame_i] isLayer0 = True - else: + else: rawImgData = posData.ol_data[filename][frame_i] isLayer0 = False if posData.SizeZ > 1: @@ -425,10 +429,10 @@ def getRawImageLayer0(self, frame_i): return img raise ValueError( - 'Raw image for display must be 2D (Y, X) or RGB/A (Y, X, 3 or 4); ' - f'got shape={getattr(img, "shape", None)}, ndim={getattr(img, "ndim", None)} ' - f'for frame_i={frame_i} (metadata SizeT={posData.SizeT}, SizeZ={posData.SizeZ}). ' - 'Check that metadata SizeT/SizeZ matches the loaded array (e.g. squeezed TIFF vs CSV).' + "Raw image for display must be 2D (Y, X) or RGB/A (Y, X, 3 or 4); " + f"got shape={getattr(img, 'shape', None)}, ndim={getattr(img, 'ndim', None)} " + f"for frame_i={frame_i} (metadata SizeT={posData.SizeT}, SizeZ={posData.SizeZ}). " + "Check that metadata SizeT/SizeZ matches the loaded array (e.g. squeezed TIFF vs CSV)." ) def get_2Dimg_from_3D(self, imgData, isLayer0=True, frame_i=None): @@ -438,36 +442,36 @@ def get_2Dimg_from_3D(self, imgData, isLayer0=True, frame_i=None): if frame_i < 0: frame_i = 0 frame_i = posData.frame_i = 0 - + axis_slice = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == 'x': + if self.switchPlaneCombobox.depthAxes() == "x": return imgData[:, :, axis_slice].copy() - elif self.switchPlaneCombobox.depthAxes() == 'y': + elif self.switchPlaneCombobox.depthAxes() == "y": return imgData[:, axis_slice].copy() - + idx = (posData.filename, frame_i) zProjHow_L0 = self.zProjComboBox.currentText() if isLayer0: try: - z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] except ValueError as e: - z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] + z = posData.segmInfo_df.loc[idx, "z_slice_used_gui"].iloc[0] zProjHow = zProjHow_L0 else: z = self.zSliceOverlay_SB.sliderPosition() zProjHow_L1 = self.zProjOverlay_CB.currentText() - if zProjHow_L1 == 'same as above': + if zProjHow_L1 == "same as above": zProjHow = zProjHow_L0 else: zProjHow = zProjHow_L1 - - if zProjHow == 'single z-slice': - img = imgData[z] #.copy() - elif zProjHow == 'max z-projection': + + if zProjHow == "single z-slice": + img = imgData[z] # .copy() + elif zProjHow == "max z-projection": img = imgData.max(axis=0) - elif zProjHow == 'mean z-projection': + elif zProjHow == "mean z-projection": img = imgData.mean(axis=0) - elif zProjHow == 'median z-proj.': + elif zProjHow == "median z-proj.": img = np.median(imgData, axis=0) return img @@ -476,7 +480,7 @@ def get_2Dlab(self, lab, force_z=True): if force_z: return lab[self.z_lab()] zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' + isZslice = zProjHow == "single z-slice" if isZslice: return lab[self.z_lab()] else: @@ -484,7 +488,7 @@ def get_2Dlab(self, lab, force_z=True): else: return lab - def get_2Drp(self, lab=None): + def get_2Drp(self, lab=None): if self.isSegm3D: if lab is None: # self.currentLab2D is defined at self.setImageImg2() @@ -500,27 +504,25 @@ def initContoursImage(self): z_slice = self.z_lab() img = posData.img_data[posData.frame_i] Y, X = img[z_slice].shape[-2:] - + self.contoursImage = np.zeros((Y, X, 4), dtype=np.uint8) def initImgCmap(self): - if not 'img_cmap' in self.df_settings.index: - self.df_settings.at['img_cmap', 'value'] = 'grey' - self.imgCmapName = self.df_settings.at['img_cmap', 'value'] + if not "img_cmap" in self.df_settings.index: + self.df_settings.at["img_cmap", "value"] = "grey" + self.imgCmapName = self.df_settings.at["img_cmap", "value"] self.imgCmap = self.imgGrad.cmaps[self.imgCmapName] - if self.imgCmapName != 'grey': + if self.imgCmapName != "grey": # To ensure mapping to colors we need to normalize image self.normalizeByMaxAction.setChecked(True) def initImgGradRescaleIntensitiesHowPreference(self): posData = self.data[self.pos_i] channelName = posData.user_ch_name - if f'how_rescale_intensities_{channelName}' not in self.df_settings.index: + if f"how_rescale_intensities_{channelName}" not in self.df_settings.index: return - - how = self.df_settings.at[ - f'how_rescale_intensities_{channelName}', 'value' - ] + + how = self.df_settings.at[f"how_rescale_intensities_{channelName}", "value"] self.imgGrad.setRescaleIntensitiesHow(how) def initLostObjContoursImage(self): @@ -528,7 +530,7 @@ def initLostObjContoursImage(self): z_slice = self.z_lab() img = posData.img_data[posData.frame_i] Y, X = img[z_slice].shape[-2:] - + self.lostObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) def initLostTrackedObjContoursImage(self): @@ -536,16 +538,16 @@ def initLostTrackedObjContoursImage(self): z_slice = self.z_lab() img = posData.img_data[posData.frame_i] Y, X = img[z_slice].shape[-2:] - + self.lostTrackedObjContoursImage = np.zeros((Y, X, 4), dtype=np.uint8) def initManualBackgroundImage(self): posData = self.data[self.pos_i] - if hasattr(posData, 'lab'): + if hasattr(posData, "lab"): Y, X = posData.lab.shape[-2:] else: Y, X = posData.img_data.shape[-2:] - if not hasattr(self, 'manualBackgroundTextItems'): + if not hasattr(self, "manualBackgroundTextItems"): self.manualBackgroundTextItems = {} posData.manualBackgroundImage = np.zeros((Y, X, 4), dtype=np.uint8) if posData.manualBackgroundLab is None: @@ -553,21 +555,21 @@ def initManualBackgroundImage(self): def initTextAnnot(self, force=False): posData = self.data[self.pos_i] - if hasattr(posData, 'lab'): + if hasattr(posData, "lab"): Y, X = posData.lab.shape[-2:] else: Y, X = posData.img_data.shape[-2:] self.textAnnot[0].initItem((Y, X)) - self.textAnnot[1].initItem((Y, X)) + self.textAnnot[1].initItem((Y, X)) def invertBw(self, checked, update=True): self.invertBwAlreadyCalledOnce = True - + try: self.labelsGrad.invertBwAction.toggled.disconnect() except Exception as err: pass - + self.labelsGrad.invertBwAction.setChecked(checked) self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) @@ -584,7 +586,7 @@ def invertBw(self, checked, update=True): self.imgGradRight.setInvertedColorMaps(checked) self.imgGradRight.invertCurrentColormap(checked) - if hasattr(self, 'overlayLayersItems'): + if hasattr(self, "overlayLayersItems"): for items in self.overlayLayersItems.values(): lutItem = items[1] lutItem.invertBwAction.toggled.disconnect() @@ -595,67 +597,65 @@ def invertBw(self, checked, update=True): if self.slideshowWin is not None: self.slideshowWin.is_bw_inverted = checked self.slideshowWin.update_img() - self.df_settings.at['is_bw_inverted', 'value'] = 'Yes' if checked else 'No' + self.df_settings.at["is_bw_inverted", "value"] = "Yes" if checked else "No" self.df_settings.to_csv(self.settings_csv_path) if checked: # Light mode - self.equalizeHistPushButton.setStyleSheet('') + self.equalizeHistPushButton.setStyleSheet("") self.graphLayout.setBackground(graphLayoutBkgrColor) - self.ax2_BrushCirclePen = pg.mkPen((150,150,150), width=2) - self.ax2_BrushCircleBrush = pg.mkBrush((200,200,200,150)) - self.titleColor = 'black' + self.ax2_BrushCirclePen = pg.mkPen((150, 150, 150), width=2) + self.ax2_BrushCircleBrush = pg.mkBrush((200, 200, 200, 150)) + self.titleColor = "black" else: # Dark mode self.equalizeHistPushButton.setStyleSheet( - 'QPushButton {background-color: #282828; color: #F0F0F0;}' + "QPushButton {background-color: #282828; color: #F0F0F0;}" ) self.graphLayout.setBackground(darkBkgrColor) self.ax2_BrushCirclePen = pg.mkPen(width=2) - self.ax2_BrushCircleBrush = pg.mkBrush((255,255,255,50)) - self.titleColor = 'white' - - if not hasattr(self, 'textAnnot'): + self.ax2_BrushCircleBrush = pg.mkBrush((255, 255, 255, 50)) + self.titleColor = "white" + + if not hasattr(self, "textAnnot"): return - + self.textAnnot[0].invertBlackAndWhite() self.textAnnot[1].invertBlackAndWhite() - self.objLabelAnnotRgb = tuple( - self.textAnnot[0].item.colors()['label'][:3] - ) + self.objLabelAnnotRgb = tuple(self.textAnnot[0].item.colors()["label"][:3]) self.textIDsColorButton.setColor(self.objLabelAnnotRgb) self.imgGrad.textColorButton.setColor(self.objLabelAnnotRgb) for items in self.overlayLayersItems.values(): lutItem = items[1] lutItem.textColorButton.setColor(self.objLabelAnnotRgb) - + if update: self.updateAllImages() def isObjVisible(self, obj_bbox, debug=False, z_slice=None): if z_slice is None: z_slice = self.z_lab() - + if self.isSegm3D: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' + isZslice = zProjHow == "single z-slice" if not isZslice: # required a projection --> all obj are visible return True - + depthAxes = self.switchPlaneCombobox.depthAxes() - + min_z, min_y, min_x, max_z, max_y, max_x = obj_bbox - if depthAxes == 'z': + if depthAxes == "z": min_val, max_val = min_z, max_z val = z_slice - elif depthAxes == 'y': + elif depthAxes == "y": min_val, max_val = min_y, max_y val = z_slice[-1] else: min_val, max_val = min_x, max_x val = z_slice[-1] - + if val >= min_val and val < max_val: return True else: @@ -671,18 +671,14 @@ def launchSlideshow(self): parent=self, button_toUncheck=self.slideshowButton, linkWindow=posData.SizeT > 1, - enableOverlay=True, - enableMirroredCursor=True - ) - self.slideshowWin.img.minMaxValuesMapper = ( - self.img1.minMaxValuesMapper + enableOverlay=True, + enableMirroredCursor=True, ) + self.slideshowWin.img.minMaxValuesMapper = self.img1.minMaxValuesMapper self.slideshowWin.img.setCurrentPosIndex(self.pos_i) h = self.drawIDsContComboBox.size().height() self.slideshowWin.framesScrollBar.setFixedHeight(h) - self.slideshowWin.overlayButton.setChecked( - self.overlayButton.isChecked() - ) + self.slideshowWin.overlayButton.setChecked(self.overlayButton.isChecked()) self.slideshowWin.sigHoveringImage.connect( self.setMirroredCursorFromSecondWindow ) @@ -691,19 +687,17 @@ def launchSlideshow(self): self.slideshowWin.img.setCurrentZsliceIndex(z_slice) self.slideshowWin.zSliceScrollBar.setSliderPosition(z_slice) self.slideshowWin.z_label.setText( - f'z-slice {z_slice+1:02}/{posData.SizeZ}' + f"z-slice {z_slice + 1:02}/{posData.SizeZ}" ) self.slideshowWin.update_img() - self.slideshowWin.show( - left=self.slideshowWinLeft, top=self.slideshowWinTop - ) + self.slideshowWin.show(left=self.slideshowWinLeft, top=self.slideshowWinTop) else: self.slideshowWin.close() self.slideshowWin = None def normaliseIntensitiesActionTriggered(self, action): how = action.text() - self.df_settings.at['how_normIntensities', 'value'] = how + self.df_settings.at["how_normIntensities", "value"] = how self.df_settings.to_csv(self.settings_csv_path) self.updateAllImages() self.updateImageValueFormatter() @@ -712,32 +706,32 @@ def normalizeIntensities(self, img): action, normalize, how = self.getCheckNormAction() if not normalize: return img - - if how == 'Do not normalize. Display raw image': - img = img - elif how == 'Convert to floating point format with values [0, 1]': + + if how == "Do not normalize. Display raw image": + img = img + elif how == "Convert to floating point format with values [0, 1]": img = myutils.img_to_float(img) # elif how == 'Rescale to 8-bit unsigned integer format with values [0, 255]': # img = skimage.img_as_float(img) # img = (img*255).astype(np.uint8) # return img - elif how == 'Rescale to [0, 1]': + elif how == "Rescale to [0, 1]": img = skimage.img_as_float(img) img = skimage.exposure.rescale_intensity(img) - elif how == 'Normalize by max value': - img = img/np.max(img) + elif how == "Normalize by max value": + img = img / np.max(img) return img def removeAxLimits(self): - self.ax1.vb.state['limits']['xLimits'] = [-1E307, +1E307] - self.ax1.vb.state['limits']['yLimits'] = [-1E307, +1E307] + self.ax1.vb.state["limits"]["xLimits"] = [-1e307, +1e307] + self.ax1.vb.state["limits"]["yLimits"] = [-1e307, +1e307] def rescaleIntensExportToVideoDialog(self, how, channel, setImage=True): if channel == self.user_ch_name: lutItem = self.imgGrad else: lutItem = self.overlayLayersItems[channel][1] - + for action in lutItem.rescaleActionGroup.actions(): if action.text() == how: action.trigger() @@ -745,17 +739,13 @@ def rescaleIntensExportToVideoDialog(self, how, channel, setImage=True): break def rescaleIntensitiesLut( - self, - action: QAction=None, - setImage: bool=True, - imageItem=None - ): + self, action: QAction = None, setImage: bool = True, imageItem=None + ): if not self.isDataLoaded: self.logger.info( - 'WARNING: Data is not loaded. ' - 'Intensities will be rescaled later.' + "WARNING: Data is not loaded. Intensities will be rescaled later." ) - return + return posData = self.data[self.pos_i] if imageItem is None: @@ -766,55 +756,55 @@ def rescaleIntensitiesLut( channel = imageItem.channelName _, filename = self.getPathFromChName(channel, posData) image_data = posData.fluo_data_dict[filename] - + triggeredByUser = True if action is None: triggeredByUser = False action = imageItem.lutItem.rescaleActionGroup.checkedAction() - + how = action.text() - - self.df_settings.at[f'how_rescale_intensities_{channel}', 'value'] = how + + self.df_settings.at[f"how_rescale_intensities_{channel}", "value"] = how self.df_settings.to_csv(self.settings_csv_path) - - if how == 'Rescale each 2D image': + + if how == "Rescale each 2D image": if how == self.rescaleIntensChannelHowMapper[channel]: # No need to update since we have autoscale - return - + return + imageItem.setEnableAutoLevels(True) if setImage: imageItem.setImage(imageItem.image) return - + lutLevelsCh = posData.lutLevels[channel] - - if how == 'Rescale across z-stack': + + if how == "Rescale across z-stack": imageItem.setEnableAutoLevels(False) levels_key = (how, posData.frame_i) levels = lutLevelsCh.get(levels_key) if levels is None: levels = self.getPreComputedMinMaxZstack(channel) - + if levels is None: image_zstack = image_data[posData.frame_i] levels = (image_zstack.min(), image_zstack.max()) lutLevelsCh[levels_key] = levels imageItem.setLevels(levels) - elif how == 'Rescale across time frames': + elif how == "Rescale across time frames": imageItem.setEnableAutoLevels(False) levels_key = (how, None) levels = lutLevelsCh.get(levels_key) if levels is None: levels = (image_data.min(), image_data.max()) - + lutLevelsCh[levels_key] = levels imageItem.setLevels(levels) - elif how == 'Choose custom levels...': + elif how == "Choose custom levels...": autoLevelsEnabledBefore = imageItem.autoLevelsEnabled imageItem.setEnableAutoLevels(False) if triggeredByUser: - current_min, current_max = imageItem.getLevels() + current_min, current_max = imageItem.getLevels() dtype_max = np.iinfo(image_data.dtype).max max_value = image_data.max() min_value = image_data.min() @@ -823,7 +813,7 @@ def rescaleIntensitiesLut( init_max_value=current_max, maximum_max_value=max_value, minimum_min_value=min_value, - parent=self + parent=self, ) win.sigLevelsChanged.connect( partial(self.customLevelsLutChanged, imageItem=imageItem) @@ -831,14 +821,14 @@ def rescaleIntensitiesLut( win.exec_() if win.cancel: imageItem.setEnableAutoLevels(autoLevelsEnabledBefore) - self.logger.info('Custom LUT levels setting cancelled.') + self.logger.info("Custom LUT levels setting cancelled.") self.updateAllImages() return selectedLevels = win.selectedLevels else: selectedLevels = imageItem.getLevels() imageItem.setLevels(selectedLevels) - elif how == 'Do no rescale, display raw image': + elif how == "Do no rescale, display raw image": imageItem.setEnableAutoLevels(False) levels_key = (how, None) levels = lutLevelsCh.get(levels_key) @@ -847,9 +837,9 @@ def rescaleIntensitiesLut( levels = (0, dtype_max) lutLevelsCh[levels_key] = levels imageItem.setLevels(levels) - + self.rescaleIntensChannelHowMapper[channel] = how - + if setImage: imageItem.setImage(imageItem.image) @@ -864,19 +854,16 @@ def resetRange(self): self.isRangeReset = True def resizeGui(self): - self.ax1.vb.state['limits']['xRange'] = [None, None] - self.ax1.vb.state['limits']['yRange'] = [None, None] + self.ax1.vb.state["limits"]["xRange"] = [None, None] + self.ax1.vb.state["limits"]["yRange"] = [None, None] self.autoRange() - if self.ax1.getViewBox().state['limits']['xRange'][0] is not None: + if self.ax1.getViewBox().state["limits"]["xRange"][0] is not None: self.bottomScrollArea._resizeVertical() return (xmin, xmax), (ymin, ymax) = self.ax1.viewRange() - maxYRange = int((ymax-ymin)*1.5) - maxXRange = int((xmax-xmin)*1.5) - self.ax1.setLimits( - maxYRange=maxYRange, - maxXRange=maxXRange - ) + maxYRange = int((ymax - ymin) * 1.5) + maxXRange = int((xmax - xmin) * 1.5) + self.ax1.setLimits(maxYRange=maxYRange, maxXRange=maxXRange) self.bottomScrollArea._resizeVertical() QTimer.singleShot(200, self.autoRange) @@ -964,40 +951,41 @@ def setImageImg1(self, image=None): self.img1.setCurrentFrameIndex(posData.frame_i) if posData.SizeZ > 1: zProjHow = self.zProjComboBox.currentText() - if zProjHow == 'single z-slice': + if zProjHow == "single z-slice": z = self.zSliceScrollBar.sliderPosition() else: z = zProjHow - + self.img1.setCurrentZsliceIndex(z) self.img1.setImage( - img, next_frame_image=self.nextFrameImage(), - scrollbar_value=posData.frame_i+2 + img, + next_frame_image=self.nextFrameImage(), + scrollbar_value=posData.frame_i + 2, ) def setImageImg2(self, updateLookuptable=True, set_image=True): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if mode == 'Segmentation and Tracking' or self.isSnapshot: + if mode == "Segmentation and Tracking" or self.isSnapshot: # self.addExistingDelROIs() allDelIDs, lab2D = self.getDelROIlab() else: lab2D = self.get_2Dlab(posData.lab, force_z=False) allDelIDs = set() - - self.currentLab2D = lab2D + + self.currentLab2D = lab2D if self.labelsGrad.permanentGreedyCmapAction.isChecked() and updateLookuptable: self.greedyShuffleCmap(updateImages=False) - + if self.labelsGrad.showLabelsImgAction.isChecked() and set_image: self.img2.setImage(lab2D, z=self.z_lab(), autoLevels=False) - + if updateLookuptable: self.updateLookuptable(delIDs=allDelIDs) def setLastUserNormAction(self): - how = self.df_settings.at['how_normIntensities', 'value'] + how = self.df_settings.at["how_normIntensities", "value"] for action in self.normalizeQActionGroup.actions(): if action.text() == how: action.setChecked(True) @@ -1028,7 +1016,7 @@ def setTwoImagesLayout(self, isTwoImages): else: self.graphLayout.removeItem(self.titleLabel) self.graphLayout.addItem(self.titleLabel, row=0, col=1) - # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignCenter) + # self.mainLayout.setAlignment(self.bottomLayout, Qt.AlignCenter) self.ax2.hide() oldLink = self.ax2.vb.linkedView(self.ax1.vb.YAxis) try: @@ -1039,13 +1027,13 @@ def setTwoImagesLayout(self, isTwoImages): def set_2Dlab(self, lab2D, lab3D=None): posData = self.data[self.pos_i] - + if lab3D is None: lab3D = posData.lab - + if self.isSegm3D: zProjHow = self.zProjComboBox.currentText() - isZslice = zProjHow == 'single z-slice' + isZslice = zProjHow == "single z-slice" if isZslice: lab3D[self.z_lab()] = lab2D else: @@ -1062,9 +1050,9 @@ def showLabelImageItem(self, checked): self.setTwoImagesLayout(checked) self.setAnnotOptionsRightImageLabelsDisabled(checked) if checked: - self.df_settings.at['isLabelsVisible', 'value'] = 'Yes' - self.df_settings.at['isNextFrameVisible', 'value'] = 'No' - self.df_settings.at['isRightImageVisible', 'value'] = 'No' + self.df_settings.at["isLabelsVisible", "value"] = "Yes" + self.df_settings.at["isNextFrameVisible", "value"] = "No" + self.df_settings.at["isRightImageVisible", "value"] = "No" self.rightBottomGroupbox.show() self.rightBottomGroupbox.setChecked(True) if not self.isDataLoading: @@ -1072,20 +1060,20 @@ def showLabelImageItem(self, checked): else: self.clearAx2Items() self.img2.clear() - self.df_settings.at['isLabelsVisible', 'value'] = 'No' + self.df_settings.at["isLabelsVisible", "value"] = "No" self.rightBottomGroupbox.hide() self.moveDelRoisToLeft() - + self.df_settings.to_csv(self.settings_csv_path) QTimer.singleShot(200, self.resizeGui) self.setBottomLayoutStretch() def showMirroredCursorToggled(self, checked): - value = 'Yes' if checked else 'No' - self.df_settings.at['showMirroredCursor', 'value'] = value + value = "Yes" if checked else "No" + self.df_settings.at["showMirroredCursor", "value"] = value self.df_settings.to_csv(settings_csv_path) - + if not checked: self.clearCursors() @@ -1094,84 +1082,83 @@ def showNextFrameImageItem(self, checked): self.rightImageFramesScrollbar.setDisabled(not checked) self.setTwoImagesLayout(checked) if checked: - self.df_settings.at['isNextFrameVisible', 'value'] = 'Yes' - self.df_settings.at['isRightImageVisible', 'value'] = 'No' - self.df_settings.at['isLabelsVisible', 'value'] = 'No' - self.graphLayout.addItem( - self.imgGradRight, row=1, col=self.plotsCol+2 - ) + self.df_settings.at["isNextFrameVisible", "value"] = "Yes" + self.df_settings.at["isRightImageVisible", "value"] = "No" + self.df_settings.at["isLabelsVisible", "value"] = "No" + self.graphLayout.addItem(self.imgGradRight, row=1, col=self.plotsCol + 2) self.rightBottomGroupbox.show() self.rightBottomGroupbox.setChecked(True) - self.drawNothingCheckboxRight.click() + self.drawNothingCheckboxRight.click() if not self.isDataLoading: self.updateAllImages() else: self.clearAx2Items() self.rightBottomGroupbox.hide() - self.df_settings.at['isNextFrameVisible', 'value'] = 'No' + self.df_settings.at["isNextFrameVisible", "value"] = "No" try: self.graphLayout.removeItem(self.imgGradRight) except Exception: return self.rightImageItem.clear() - + self.df_settings.to_csv(self.settings_csv_path) - + QTimer.singleShot(300, self.resizeGui) - self.setBottomLayoutStretch() + self.setBottomLayoutStretch() def showRightImageItem(self, checked): self.rightImageFramesScrollbar.setVisible(not checked) self.rightImageFramesScrollbar.setDisabled(checked) self.setTwoImagesLayout(checked) if checked: - self.df_settings.at['isRightImageVisible', 'value'] = 'Yes' - self.df_settings.at['isNextFrameVisible', 'value'] = 'No' - self.df_settings.at['isLabelsVisible', 'value'] = 'No' - self.graphLayout.addItem( - self.imgGradRight, row=1, col=self.plotsCol+2 - ) + self.df_settings.at["isRightImageVisible", "value"] = "Yes" + self.df_settings.at["isNextFrameVisible", "value"] = "No" + self.df_settings.at["isLabelsVisible", "value"] = "No" + self.graphLayout.addItem(self.imgGradRight, row=1, col=self.plotsCol + 2) self.rightBottomGroupbox.show() if not self.isDataLoading: self.updateAllImages() else: self.clearAx2Items() self.rightBottomGroupbox.hide() - self.df_settings.at['isRightImageVisible', 'value'] = 'No' + self.df_settings.at["isRightImageVisible", "value"] = "No" try: self.graphLayout.removeItem(self.imgGradRight) except Exception: return self.rightImageItem.clear() - + self.df_settings.to_csv(self.settings_csv_path) - + QTimer.singleShot(300, self.resizeGui) - self.setBottomLayoutStretch() + self.setBottomLayoutStretch() def updateAllImages( - self, image=None, computePointsLayers=True, computeContours=True, - updateLookuptable=True - ): + self, + image=None, + computePointsLayers=True, + computeContours=True, + updateLookuptable=True, + ): self.clearAllItems() posData = self.data[self.pos_i] self.last_pos_i = self.pos_i self.last_frame_i = posData.frame_i - + self.rescaleIntensitiesLut(setImage=False) - self.setImageImg1(image=image) + self.setImageImg1(image=image) self.setImageImg2(updateLookuptable=updateLookuptable) - + self.setOverlayImages() self.setOverlayLabelsItems() self.setOverlaySegmMasks() - + if self.slideshowWin is not None: self.slideshowWin.frame_i = posData.frame_i self.slideshowWin.update_img() @@ -1179,19 +1166,17 @@ def updateAllImages( # self.update_rp() # Annotate ID and draw contours - delROIsIDs = self.setAllTextAnnotations() - self.setAllContoursImages( - delROIsIDs=delROIsIDs, compute=False - ) + delROIsIDs = self.setAllTextAnnotations() + self.setAllContoursImages(delROIsIDs=delROIsIDs, compute=False) mode = self.modeComboBox.currentText() self.drawAllMothBudLines() - if mode == 'Normal division: Lineage tree': + if mode == "Normal division: Lineage tree": self.drawAllLineageTreeLines() - self.highlightLostNew() + self.highlightLostNew() - if self.ccaTableWin is not None: # need to add for lin tree, later + if self.ccaTableWin is not None: # need to add for lin tree, later zoomIDs = self.getZoomIDs() self.ccaTableWin.updateTable(posData.cca_df, IDs=zoomIDs) @@ -1203,10 +1188,10 @@ def updateAllImages( self.drawPointsLayers(computePointsLayers=computePointsLayers) self.setManualBackgroundImage() self.annotateAssignedObjsAcdcTrackerSecondStep() - - self.highlightSearchedID(self.highlightedID, force=True) - self.updateTimestampFrame() - + + self.highlightSearchedID(self.highlightedID, force=True) + self.updateTimestampFrame() + posData.visited = True def updateImageValueFormatter(self): @@ -1214,41 +1199,41 @@ def updateImageValueFormatter(self): dtype = self.img1.image.dtype n_digits = len(str(int(self.img1.image.max()))) self.imgValueFormatter = myutils.get_number_fstring_formatter( - dtype, precision=abs(n_digits-5) + dtype, precision=abs(n_digits - 5) ) rawImgData = self.data[self.pos_i].img_data dtype = rawImgData.dtype n_digits = len(str(int(rawImgData.max()))) self.rawValueFormatter = myutils.get_number_fstring_formatter( - dtype, precision=abs(n_digits-5) + dtype, precision=abs(n_digits - 5) ) def updateLabelsAlpha(self, value): - self.df_settings.at['overlaySegmMasksAlpha', 'value'] = value + self.df_settings.at["overlaySegmMasksAlpha", "value"] = value self.df_settings.to_csv(self.settings_csv_path) if self.keepIDsButton.isChecked(): - value = value/3 + value = value / 3 self.labelsLayerImg1.setOpacity(value) self.labelsLayerRightImg.setOpacity(value) def updateZsliceScrollbar(self, frame_i): posData = self.data[self.pos_i] - if self.switchPlaneCombobox.depthAxes() != 'z': + if self.switchPlaneCombobox.depthAxes() != "z": return - + idx = (posData.filename, frame_i) try: - z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] except ValueError as e: - z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] + z = posData.segmInfo_df.loc[idx, "z_slice_used_gui"].iloc[0] try: - zProjHow = posData.segmInfo_df.at[idx, 'which_z_proj_gui'] + zProjHow = posData.segmInfo_df.at[idx, "which_z_proj_gui"] except ValueError as e: - zProjHow = posData.segmInfo_df.loc[idx, 'which_z_proj_gui'].iloc[0] - + zProjHow = posData.segmInfo_df.loc[idx, "which_z_proj_gui"].iloc[0] + self.zProjComboBox.setCurrentText(zProjHow) - + reconnect = False try: self.zSliceScrollBar.actionTriggered.disconnect() @@ -1261,39 +1246,37 @@ def updateZsliceScrollbar(self, frame_i): self.zSliceScrollBar.actionTriggered.connect( self.zSliceScrollBarActionTriggered ) - self.zSliceScrollBar.sliderReleased.connect( - self.zSliceScrollBarReleased - ) - self.zSliceSpinbox.setValueNoEmit(z+1) + self.zSliceScrollBar.sliderReleased.connect(self.zSliceScrollBarReleased) + self.zSliceSpinbox.setValueNoEmit(z + 1) def zProjLockViewToggled(self, checked): self.updateZproj(self.zProjComboBox.currentText()) def z_lab(self, checkIfProj=False): - if checkIfProj and self.zProjComboBox.currentText() != 'single z-slice': + if checkIfProj and self.zProjComboBox.currentText() != "single z-slice": return - + if not self.isSegm3D: - return - + return + posData = self.data[self.pos_i] idx = self.zSliceScrollBar.sliderPosition() - + # ensure idx doesnt exceed the number of z-slices of the position - idx_z = min(idx, posData.SizeZ-1) - + idx_z = min(idx, posData.SizeZ - 1) + if not self.switchPlaneCombobox.isEnabled(): return idx_z - + depthAxes = self.switchPlaneCombobox.depthAxes() - if depthAxes == 'z': + if depthAxes == "z": return idx_z - elif depthAxes == 'y': - idx_y = min(idx, posData.SizeY-1) + elif depthAxes == "y": + idx_y = min(idx, posData.SizeY - 1) return (slice(None), idx_y) else: - idx_x = min(idx, posData.SizeX-1) + idx_x = min(idx, posData.SizeX - 1) return (slice(None), slice(None), idx_x) def z_slice_index(self): @@ -1301,21 +1284,17 @@ def z_slice_index(self): if posData.SizeZ == 1: return None zProjHow = self.zProjComboBox.currentText() - if zProjHow != 'single z-slice': + if zProjHow != "single z-slice": return zProjHow - + axis_slice = self.zSliceScrollBar.sliderPosition() - if self.switchPlaneCombobox.depthAxes() == 'x': - z_slice = ( - slice(None, None, None), slice(None, None, None), axis_slice - ) - elif self.switchPlaneCombobox.depthAxes() == 'y': - z_slice = ( - slice(None, None, None), axis_slice - ) + if self.switchPlaneCombobox.depthAxes() == "x": + z_slice = (slice(None, None, None), slice(None, None, None), axis_slice) + elif self.switchPlaneCombobox.depthAxes() == "y": + z_slice = (slice(None, None, None), axis_slice) else: z_slice = axis_slice - + return z_slice def zoomOut(self): @@ -1326,17 +1305,17 @@ def zoomToCells(self, enforce=False): return posData = self.data[self.pos_i] - lab_mask = (self.currentLab2D>0).astype(np.uint8) + lab_mask = (self.currentLab2D > 0).astype(np.uint8) rp = skimage.measure.regionprops(lab_mask) if not rp: Y, X = lab_mask.shape - xRange = -0.5, X+0.5 - yRange = -0.5, Y+0.5 + xRange = -0.5, X + 0.5 + yRange = -0.5, Y + 0.5 else: obj = rp[0] min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) - xRange = min_col-10, max_col+10 - yRange = max_row+10, min_row-10 + xRange = min_col - 10, max_col + 10 + yRange = max_row + 10, min_row - 10 self.ax1.setRange(xRange=xRange, yRange=yRange) diff --git a/cellacdc/mixins/label_editing.py b/cellacdc/mixins/label_editing.py index a8d3b3ab5..1d924223c 100644 --- a/cellacdc/mixins/label_editing.py +++ b/cellacdc/mixins/label_editing.py @@ -14,17 +14,18 @@ from .tool_activation import ToolActivation + class LabelEditing(ToolActivation): """Extracted from guiWin.""" def _get_editID_info(self, df): - if 'was_manually_edited' not in df.columns: + if "was_manually_edited" not in df.columns: return [] - - if 'y_centroid' not in df.columns or 'x_centroid' not in df.columns: + + if "y_centroid" not in df.columns or "x_centroid" not in df.columns: df = self.addYXcentroidToDf(df) - - manually_edited_df = df[df['was_manually_edited'] > 0] + + manually_edited_df = df[df["was_manually_edited"] > 0] editID_info = [ (row.y_centroid, row.x_centroid, row.Index) for row in manually_edited_df.itertuples() @@ -34,36 +35,47 @@ def _get_editID_info(self, df): def _update_zslices_rp(self): if not self.isSegm3D: return - + posData = self.data[self.pos_i] posData.zSlicesRp = {} for z, lab2d in enumerate(posData.lab): lab2d_rp = skimage.measure.regionprops(lab2d) - posData.zSlicesRp[z] = {obj.label:obj for obj in lab2d_rp} + posData.zSlicesRp[z] = {obj.label: obj for obj in lab2d_rp} def addYXcentroidToDf(self, df): posData = self.data[self.pos_i] for obj in posData.rp: y_centroid = int(self.getObjCentroid(obj.centroid)[0]) x_centroid = int(self.getObjCentroid(obj.centroid)[1]) - df.at[obj.label, 'y_centroid'] = y_centroid - df.at[obj.label, 'x_centroid'] = x_centroid + df.at[obj.label, "y_centroid"] = y_centroid + df.at[obj.label, "x_centroid"] = x_centroid return df def applyEditID( - self, clickedID, currentIDs, oldIDnewIDMapper, clicked_x, clicked_y, shift=False, doPropagateUnvisited=False - ): + self, + clickedID, + currentIDs, + oldIDnewIDMapper, + clicked_x, + clicked_y, + shift=False, + doPropagateUnvisited=False, + ): posData = self.data[self.pos_i] - + # Ask to propagate change to all future visited frames - key = 'Edit ID' + key = "Edit ID" askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - clickedID, key, doNotShow, - posData.UndoFutFrames_EditID, posData.applyFutFrames_EditID, - applyTrackingB=True + (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( + self.propagateChange( + clickedID, + key, + doNotShow, + posData.UndoFutFrames_EditID, + posData.applyFutFrames_EditID, + applyTrackingB=True, + ) ) if UndoFutFrames is None: @@ -73,7 +85,7 @@ def applyEditID( lab = self.get_2Dlab(posData.lab) else: lab = posData.lab - + # Store undo state before modifying stuff self.storeUndoRedoStates(UndoFutFrames) maxID = max(posData.IDs, default=0) @@ -112,17 +124,17 @@ def applyEditID( if not math.isnan(y) and not math.isnan(y): y, x = int(y), int(x) posData.editID_info.append((y, x, new_ID)) - + self.updateAssignedObjsAcdcTrackerSecondStep(new_ID) - + if shift and self.isSegm3D: self.set_2Dlab(lab) - + # Update rps self.update_rp() # Since we manually changed an ID we don't want to repeat tracking - self.setAllTextAnnotations() + self.setAllTextAnnotations() self.highlightLostNew() # self.checkIDsMultiContour() @@ -130,11 +142,11 @@ def applyEditID( self.updateLookuptable() if self.isSnapshot: - self.fixCcaDfAfterEdit('Edit ID') + self.fixCcaDfAfterEdit("Edit ID") self.updateAllImages() else: - self.warnEditingWithCca_df('Edit ID', update_images=False) - + self.warnEditingWithCca_df("Edit ID", update_images=False) + if not self.editIDbutton.findChild(QAction).isChecked(): self.editIDbutton.setChecked(False) @@ -145,41 +157,37 @@ def applyEditID( posData.UndoFutFrames_EditID = UndoFutFrames posData.applyFutFrames_EditID = applyFutFrames includeUnvisited = ( - posData.includeUnvisitedInfo['Edit ID'] - or doPropagateUnvisited + posData.includeUnvisitedInfo["Edit ID"] or doPropagateUnvisited ) - + if not applyFutFrames and not doPropagateUnvisited: return self.changeIDfutureFrames( - endFrame_i, oldIDnewIDMapper, includeUnvisited, - shift=shift + endFrame_i, oldIDnewIDMapper, includeUnvisited, shift=shift ) def apply_manual_edits_to_lab_if_needed(self, lab): posData = self.data[self.pos_i] data_frame_i = posData.allData_li[posData.frame_i] - edited_lab_dict = data_frame_i['manually_edited_lab']['lab'] + edited_lab_dict = data_frame_i["manually_edited_lab"]["lab"] if not edited_lab_dict: return lab - + # zoom_slice = data_frame_i['manually_edited_lab']['zoom_slice'] for z, lab_edited in edited_lab_dict.items(): if not self.isSegm3D: # lab[zoom_slice] = lab_edited lab = lab_edited break - + lab[z] = lab_edited - + # lab[z, zoom_slice[0], zoom_slice[1]] = zoom_lab - + return lab - def assignNewIDfromClickedID( - self, clickedID: int, event: QGraphicsSceneMouseEvent - ): + def assignNewIDfromClickedID(self, clickedID: int, event: QGraphicsSceneMouseEvent): posData = self.data[self.pos_i] x, y = event.pos().x(), event.pos().y() newID = self.setBrushID(return_val=True) @@ -187,21 +195,20 @@ def assignNewIDfromClickedID( self.applyEditID(clickedID, posData.IDs.copy(), mapper, x, y) def changeIDfutureFrames( - self, endFrame_i, oldIDnewIDMapper, includeUnvisited, - shift=False - ): + self, endFrame_i, oldIDnewIDMapper, includeUnvisited, shift=False + ): posData = self.data[self.pos_i] self.current_frame_i = posData.frame_i - + # Store data for current frame self.store_data() if endFrame_i is None: self.app.restoreOverrideCursor() return - + segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i+1, segmSizeT): - lab = posData.allData_li[i]['labels'] + for i in range(posData.frame_i + 1, segmSizeT): + lab = posData.allData_li[i]["labels"] if lab is None and not includeUnvisited: self.enqAutosave() break @@ -214,7 +221,7 @@ def changeIDfutureFrames( lab = self.get_2Dlab(posData.lab) else: lab = posData.lab - + if self.onlyTracking: self.tracking(enforce=True) elif not posData.IDs: @@ -223,19 +230,19 @@ def changeIDfutureFrames( maxID = max(posData.IDs, default=0) + 1 for old_ID, new_ID in oldIDnewIDMapper: if new_ID in lab: - tempID = maxID + 1 # lab.max() + 1 + tempID = maxID + 1 # lab.max() + 1 lab[lab == old_ID] = tempID lab[lab == new_ID] = old_ID lab[lab == tempID] = new_ID maxID += 1 else: lab[lab == old_ID] = new_ID - + if shift and self.isSegm3D: self.set_2Dlab(lab) - + self.update_rp(draw=False) - self.store_data(autosave=i==endFrame_i) + self.store_data(autosave=i == endFrame_i) elif includeUnvisited: # Unvisited frame (includeUnvisited = True) lab = posData.segm_data[i] @@ -243,7 +250,7 @@ def changeIDfutureFrames( lab = self.get_2Dlab(lab) else: lab = lab - + for old_ID, new_ID in oldIDnewIDMapper: if new_ID in lab: tempID = lab.max() + 1 @@ -252,10 +259,10 @@ def changeIDfutureFrames( lab[lab == tempID] = new_ID else: lab[lab == old_ID] = new_ID - + if shift and self.isSegm3D: posData.segm_data[i][self.z_lab()] = lab - + # Back to current frame posData.frame_i = self.current_frame_i self.get_data() @@ -266,9 +273,7 @@ def delBorderObj(self, checked): self.storeUndoRedoStates(False) posData = self.data[self.pos_i] - posData.lab = skimage.segmentation.clear_border( - posData.lab, buffer_size=1 - ) + posData.lab = skimage.segmentation.clear_border(posData.lab, buffer_size=1) oldIDs = posData.IDs.copy() self.update_rp() removedIDs = [ID for ID in oldIDs if ID not in posData.IDs] @@ -283,11 +288,11 @@ def delNewObj(self, checked): posData = self.data[self.pos_i] frame_i = posData.frame_i - + if frame_i == 0: return - - prev_IDs = posData.allData_li[frame_i-1]['IDs'] + + prev_IDs = posData.allData_li[frame_i - 1]["IDs"] curr_IDs = posData.IDs new_IDs = list(set(curr_IDs) - set(prev_IDs)) @@ -295,17 +300,15 @@ def delNewObj(self, checked): del_mask = np.isin(lab, new_IDs) lab[del_mask] = 0 posData.lab = lab - + self.update_rp() - + if posData.cca_df is not None: posData.cca_df = posData.cca_df.drop(index=new_IDs) self.store_data() self.updateAllImages() - def deleteIDFromLab( - self, lab, delID, frame_i=None, delMask=None, shift=False - ): + def deleteIDFromLab(self, lab, delID, frame_i=None, delMask=None, shift=False): posData = self.data[self.pos_i] frame_i = posData.frame_i if frame_i is None else frame_i @@ -318,26 +321,26 @@ def deleteIDFromLab( rp = skimage.measure.regionprops(lab) IDs_idxs = {obj.label: idx for idx, obj in enumerate(rp)} else: - if frame_i==posData.frame_i: + if frame_i == posData.frame_i: rp = posData.rp IDs_idxs = posData.IDs_idxs else: - rp = posData.allData_li[frame_i]['regionprops'] - IDs_idxs = posData.allData_li[frame_i]['IDs_idxs'] + rp = posData.allData_li[frame_i]["regionprops"] + IDs_idxs = posData.allData_li[frame_i]["IDs_idxs"] if isinstance(delID, int): delID = [delID] - + is_any_id_present = False for _delID in delID: if _delID in IDs_idxs: is_any_id_present = True break - + if not is_any_id_present: return lab, delMask - if delMask is None: + if delMask is None: delMask = np.zeros(lab.shape, dtype=bool) else: delMask[:] = False @@ -349,20 +352,19 @@ def deleteIDFromLab( obj = rp[idx] delMask[obj.slice][obj.image] = True lab[delMask] = 0 - + if shift and self.isSegm3D: self.set_2Dlab(lab, lab3D=lab3D) lab = lab3D if delMask3D is not None: self.set_2Dlab(delMask, lab3D=delMask3D) delMask = delMask3D - + return lab, delMask def deleteIDmiddleClick( - self, delIDs: Iterable, applyFutFrames, includeUnvisited, - shift=False - ): + self, delIDs: Iterable, applyFutFrames, includeUnvisited, shift=False + ): self.clearHighlightedID() posData = self.data[self.pos_i] @@ -374,8 +376,8 @@ def deleteIDmiddleClick( # Store current data before going to future frames self.store_data() segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i+1, segmSizeT): - lab = posData.allData_li[i]['labels'] + for i in range(posData.frame_i + 1, segmSizeT): + lab = posData.allData_li[i]["labels"] if lab is None and not includeUnvisited: self.enqAutosave() break @@ -387,7 +389,7 @@ def deleteIDmiddleClick( ) # Store change - posData.allData_li[i]['labels'] = lab + posData.allData_li[i]["labels"] = lab # Get the rest of the stored metadata based on the new lab posData.frame_i = i self.get_data() @@ -402,45 +404,44 @@ def deleteIDmiddleClick( # Back to current frame if applyFutFrames: posData.frame_i = current_frame_i - self.get_data() + self.get_data() z_slice = None if shift and self.isSegm3D: z_slice = self.z_lab() - - posData.lab, delID_mask = self.deleteIDFromLab( - posData.lab, delIDs, shift=shift - ) + + posData.lab, delID_mask = self.deleteIDFromLab(posData.lab, delIDs, shift=shift) for _delID in delIDs: - self.clearObjContour(ID=_delID, ax=0) - self.clearObjContour(ID=_delID, ax=1) + self.clearObjContour(ID=_delID, ax=0) + self.clearObjContour(ID=_delID, ax=1) if z_slice is None: - self.removeObjectFromRp(_delID) - self.removeStoredContours(_delID, z_slice=z_slice) - + self.removeObjectFromRp(_delID) + self.removeStoredContours(_delID, z_slice=z_slice) + if shift and self.isSegm3D: self.update_rp() self.store_data(autosave=False) - self.whitelistPropagateIDs(IDs_to_remove=delIDs, curr_frame_only=(not applyFutFrames)) + self.whitelistPropagateIDs( + IDs_to_remove=delIDs, curr_frame_only=(not applyFutFrames) + ) return delID_mask - def getClickedID(self, xdata, ydata, text=''): + def getClickedID(self, xdata, ydata, text=""): posData = self.data[self.pos_i] ID = self.get_2Dlab(posData.lab)[ydata, xdata] if ID == 0: - msg = ( - 'You clicked on the background.\n' - f'Enter here the ID {text}' - ) + msg = f"You clicked on the background.\nEnter here the ID {text}" nearest_ID = core.nearest_nonzero_2D( self.get_2Dlab(posData.lab), xdata, ydata ) clickedBkgrID = apps.QLineEditDialog( - title='Clicked on background', - msg=msg, parent=self, allowedValues=posData.IDs, + title="Clicked on background", + msg=msg, + parent=self, + allowedValues=posData.IDs, defaultTxt=str(nearest_ID), - isInteger=True + isInteger=True, ) clickedBkgrID.exec_() if clickedBkgrID.cancel: @@ -450,9 +451,9 @@ def getClickedID(self, xdata, ydata, text=''): return ID def getHoverID(self, xdata, ydata, byPassShiftCheck=False): - if not hasattr(self, 'diskMask'): + if not hasattr(self, "diskMask"): return 0 - + modifiers = QGuiApplication.keyboardModifiers() ctrl = modifiers == Qt.ControlModifier if byPassShiftCheck: @@ -461,7 +462,7 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): shift = modifiers == Qt.ShiftModifier if self.isPowerBrush() and not ctrl: - return 0 + return 0 if not self.autoIDcheckbox.isChecked(): return self.editIDspinbox.value() @@ -476,36 +477,36 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): SizeZ = posData.lab.shape[0] doNotLinkThroughZ = self.brushButton.isChecked() and shift if doNotLinkThroughZ: - if self.brushHoverCenterModeAction.isChecked() or ID>0: + if self.brushHoverCenterModeAction.isChecked() or ID > 0: hoverID = ID else: masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] hoverID = np.bincount(masked_lab).argmax() else: if z > 0: - ID_z_under = posData.lab[z-1, ydata, xdata] - if self.brushHoverCenterModeAction.isChecked() or ID_z_under>0: + ID_z_under = posData.lab[z - 1, ydata, xdata] + if self.brushHoverCenterModeAction.isChecked() or ID_z_under > 0: hoverIDa = ID_z_under else: lab = posData.lab - masked_lab_a = lab[z-1, ymin:ymax, xmin:xmax][diskMask] + masked_lab_a = lab[z - 1, ymin:ymax, xmin:xmax][diskMask] hoverIDa = np.bincount(masked_lab_a).argmax() else: hoverIDa = 0 - if self.brushHoverCenterModeAction.isChecked() or ID>0: + if self.brushHoverCenterModeAction.isChecked() or ID > 0: hoverIDb = lab_2D[ydata, xdata] else: masked_lab_b = lab_2D[ymin:ymax, xmin:xmax][diskMask] hoverIDb = np.bincount(masked_lab_b).argmax() - if z < SizeZ-1: - ID_z_above = posData.lab[z+1, ydata, xdata] - if self.brushHoverCenterModeAction.isChecked() or ID_z_above>0: + if z < SizeZ - 1: + ID_z_above = posData.lab[z + 1, ydata, xdata] + if self.brushHoverCenterModeAction.isChecked() or ID_z_above > 0: hoverIDc = ID_z_above else: lab = posData.lab - masked_lab_c = lab[z+1, ymin:ymax, xmin:xmax][diskMask] + masked_lab_c = lab[z + 1, ymin:ymax, xmin:xmax][diskMask] hoverIDc = np.bincount(masked_lab_c).argmax() else: hoverIDc = 0 @@ -524,12 +525,12 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): if self.brushButton.isChecked() and shift: # Force new ID with brush and Shift hoverID = 0 - elif self.brushHoverCenterModeAction.isChecked() or ID>0: + elif self.brushHoverCenterModeAction.isChecked() or ID > 0: hoverID = ID else: masked_lab = lab_2D[ymin:ymax, xmin:xmax][diskMask] hoverID = np.bincount(masked_lab).argmax() - + self.editIDspinbox.setValue(hoverID) return hoverID @@ -537,7 +538,7 @@ def getHoverID(self, xdata, ydata, byPassShiftCheck=False): def getLastHoveredID(self): if self.xHoverImg is None: return 0 - + xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) ID = self.currentLab2D[ydata, xdata] return ID @@ -545,10 +546,10 @@ def getLastHoveredID(self): def get_zslices_rp(self): if not self.isSegm3D: return - + posData = self.data[self.pos_i] self.store_zslices_rp() - posData.zSlicesRp = posData.allData_li[posData.frame_i]['z_slices_rp'] + posData.zSlicesRp = posData.allData_li[posData.frame_i]["z_slices_rp"] def isPowerBrush(self): color = self.brushButton.palette().button().color().name() @@ -579,59 +580,67 @@ def removeObjectFromRp(self, delID): IDs.append(obj.label) IDs_idxs[obj.label] = idx idx += 1 - + posData.rp = rp posData.IDs = IDs posData.IDs_idxs = IDs_idxs - + if not self.isSegm3D: return - + zSlicesRp = {} for z, zSliceRp in posData.zSlicesRp.items(): if delID in zSliceRp: continue - + zSlicesRp[z] = zSlicesRp - + posData.zSlicesRp = zSlicesRp self.store_zslices_rp(force_update=True) def removeStoredContours(self, delID, frame_i=None, z_slice=None): posData = self.data[self.pos_i] - + if frame_i is None: frame_i = posData.frame_i - + dataDict = posData.allData_li[posData.frame_i] try: newContours = {} - for key, contours in dataDict['contours'].items(): + for key, contours in dataDict["contours"].items(): ID = key[0] if ID == delID: continue - + if z_slice is not None: z_slice_i = key[1] if z_slice_i != z_slice: continue - + newContours[key] = contours - - dataDict['contours'] = newContours + + dataDict["contours"] = newContours except KeyError as err: pass def setHoverToolSymbolColor( - self, xdata, ydata, pen, ScatterItems, button, - brush=None, hoverRGB=None, ID=None, byPassShiftCheck=False - ): + self, + xdata, + ydata, + pen, + ScatterItems, + button, + brush=None, + hoverRGB=None, + ID=None, + byPassShiftCheck=False, + ): modifiers = QGuiApplication.keyboardModifiers() if byPassShiftCheck: shift = False else: shift = modifiers == Qt.ShiftModifier - + posData = self.data[self.pos_i] Y, X = self.get_2Dlab(posData.lab).shape if not myutils.is_in_bounds(xdata, ydata, X, Y): @@ -639,12 +648,10 @@ def setHoverToolSymbolColor( self.isHoverZneighID = False if ID is None: - hoverID = self.getHoverID( - xdata, ydata, byPassShiftCheck=byPassShiftCheck - ) + hoverID = self.getHoverID(xdata, ydata, byPassShiftCheck=byPassShiftCheck) else: hoverID = ID - + if hoverID == 0: for item in ScatterItems: item.setPen(pen) @@ -653,53 +660,59 @@ def setHoverToolSymbolColor( try: rgb = self.lut[hoverID] rgb = rgb if hoverRGB is None else hoverRGB - rgbPen = np.clip(rgb*1.1, 0, 255) + rgbPen = np.clip(rgb * 1.1, 0, 255) for item in ScatterItems: item.setPen(*rgbPen, width=2) item.setBrush(*rgb, 100) except IndexError: pass - + checkChangeID = ( - self.isHoverZneighID and not shift - and self.lastHoverID != hoverID + self.isHoverZneighID and not shift and self.lastHoverID != hoverID ) if checkChangeID: # We are hovering an ID in z+1 or z-1 self.restoreBrushID = hoverID # self.changeBrushID() - + self.lastHoverID = hoverID def store_zslices_rp(self, force_update=False): if not self.isSegm3D: return - - posData = self.data[self.pos_i] + + posData = self.data[self.pos_i] are_zslices_rp_stored = ( - posData.allData_li[posData.frame_i].get('z_slices_rp') is not None + posData.allData_li[posData.frame_i].get("z_slices_rp") is not None ) if force_update or not are_zslices_rp_stored: self._update_zslices_rp() - - posData.allData_li[posData.frame_i]['z_slices_rp'] = posData.zSlicesRp + + posData.allData_li[posData.frame_i]["z_slices_rp"] = posData.zSlicesRp def update_rp( - self, draw=True, debug=False, update_IDs=True, - wl_update=True, wl_track_og_curr=False,wl_update_lab=False - ): + self, + draw=True, + debug=False, + update_IDs=True, + wl_update=True, + wl_track_og_curr=False, + wl_update_lab=False, + ): posData = self.data[self.pos_i] # Update rp for current posData.lab (e.g. after any change) if wl_update: if self.whitelistOriginalIDs is None: - old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() # for whitelist stuff + old_IDs = posData.allData_li[posData.frame_i][ + "IDs" + ].copy() # for whitelist stuff else: old_IDs = self.whitelistOriginalIDs.copy() self.whitelistOriginalIDs = None elif self.whitelistOriginalIDs is None: - self.whitelist_old_IDs = posData.allData_li[posData.frame_i]['IDs'].copy() + self.whitelist_old_IDs = posData.allData_li[posData.frame_i]["IDs"].copy() posData.rp = skimage.measure.regionprops(posData.lab) if update_IDs: @@ -710,7 +723,7 @@ def update_rp( IDs_idxs[obj.label] = idx posData.IDs = IDs posData.IDs_idxs = IDs_idxs - self.update_rp_metadata(draw=draw) + self.update_rp_metadata(draw=draw) self.store_zslices_rp(force_update=True) if not wl_update: @@ -720,16 +733,15 @@ def update_rp( accepted_lost_centroids = self.getTrackedLostIDs() new_IDs = posData.IDs added_IDs = set(new_IDs) - set(old_IDs) - removed_IDs = ( - set(old_IDs) - - set(new_IDs) - - set(accepted_lost_centroids) - ) + removed_IDs = set(old_IDs) - set(new_IDs) - set(accepted_lost_centroids) self.whitelistPropagateIDs( - IDs_to_add=added_IDs, IDs_to_remove=removed_IDs, - curr_frame_only=True, IDs_curr=new_IDs, + IDs_to_add=added_IDs, + IDs_to_remove=removed_IDs, + curr_frame_only=True, + IDs_curr=new_IDs, track_og_curr=wl_track_og_curr, - curr_lab=posData.lab, curr_rp=posData.rp, - update_lab=wl_update_lab + curr_lab=posData.lab, + curr_rp=posData.rp, + update_lab=wl_update_lab, ) diff --git a/cellacdc/mixins/label_roi.py b/cellacdc/mixins/label_roi.py index f79ec063f..8a4d9cdaa 100644 --- a/cellacdc/mixins/label_roi.py +++ b/cellacdc/mixins/label_roi.py @@ -20,6 +20,7 @@ from .brush_tools import BrushTools + class LabelRoi(BrushTools): """Extracted from guiWin.""" @@ -27,17 +28,17 @@ def getLabelRoiImage(self): posData = self.data[self.pos_i] if self.labelRoiTrangeCheckbox.isChecked(): - start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 + start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() - tRangeLen = stop_frame_n-start_frame_i + tRangeLen = stop_frame_n - start_frame_i else: tRangeLen = 1 - + if tRangeLen > 1: tRange = (start_frame_i, stop_frame_n) else: tRange = None - + if self.isSegm3D: if tRangeLen > 1: imgData = posData.img_data @@ -53,26 +54,22 @@ def getLabelRoiImage(self): z0 = self.zSliceScrollBar.sliderPosition() z1 = z0 + 1 else: - if roi_zdepth%2 != 0: - roi_zdepth +=1 - half_zdepth = int(roi_zdepth/2) + if roi_zdepth % 2 != 0: + roi_zdepth += 1 + half_zdepth = int(roi_zdepth / 2) zc = self.zSliceScrollBar.sliderPosition() + 1 - z0 = zc-half_zdepth - z0 = z0 if z0>=0 else 0 - z1 = zc+half_zdepth - z1 = z1 if z1= 0 else 0 + z1 = zc + half_zdepth + z1 = z1 if z1 < posData.SizeZ else posData.SizeZ if self.labelRoiIsRectRadioButton.isChecked(): - labelRoiSlice = self.labelRoiItem.slice( - zRange=(z0,z1), tRange=tRange - ) + labelRoiSlice = self.labelRoiItem.slice(zRange=(z0, z1), tRange=tRange) elif self.labelRoiIsFreeHandRadioButton.isChecked(): - labelRoiSlice = self.freeRoiItem.slice( - zRange=(z0,z1), tRange=tRange - ) + labelRoiSlice = self.freeRoiItem.slice(zRange=(z0, z1), tRange=tRange) elif self.labelRoiIsCircularRadioButton.isChecked(): labelRoiSlice = self.labelRoiCircItemLeft.slice( - zRange=(z0,z1), tRange=tRange + zRange=(z0, z1), tRange=tRange ) else: if self.labelRoiIsRectRadioButton.isChecked(): @@ -93,7 +90,7 @@ def getLabelRoiImage(self): mask = self.labelRoiCircItemLeft.mask() else: mask = None - + if mask is not None: # Copy roiImg otherwise we are replacing minimum inside original image roiImg = roiImg.copy() @@ -127,14 +124,14 @@ def getSecondChannelData(self): fluo_data, bkgrData = self.load_fluo_data(fluo_path) posData.fluo_data_dict[filename] = fluo_data posData.fluo_bkgrData_dict[filename] = bkgrData - + if self.labelRoiTrangeCheckbox.isChecked(): - start_frame_i = self.labelRoiStartFrameNoSpinbox.value()-1 + start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 stop_frame_n = self.labelRoiStopFrameNoSpinbox.value() - tRangeLen = stop_frame_n-start_frame_i + tRangeLen = stop_frame_n - start_frame_i else: tRangeLen = 1 - + if tRangeLen > 1: # fluo_img_data = fluo_data[start_frame_i:stop_frame_n] if self.isSegm3D or posData.SizeZ == 1: @@ -152,7 +149,7 @@ def getSecondChannelData(self): fluo_img_data = fluo_data[posData.frame_i] else: fluo_img_data = fluo_data - + if self.isSegm3D or posData.SizeZ == 1: return fluo_img_data else: @@ -165,13 +162,13 @@ def indexRoiLab(self, roiLab, roiLabSlice, lab, brushID): mask[..., 1:-1, 1:-1] = True roiLab = skimage.segmentation.clear_border(roiLab, mask=mask) - roiLabMask = roiLab>0 - roiLab[roiLabMask] += (brushID-1) + roiLabMask = roiLab > 0 + roiLab[roiLabMask] += brushID - 1 if self.labelRoiReplaceExistingObjectsCheckbox.isChecked(): IDs_touched_by_new_objects = np.unique(lab[roiLabSlice][roiLabMask]) for ID in IDs_touched_by_new_objects: - lab[lab==ID] = 0 - + lab[lab == ID] = 0 + lab[roiLabSlice][roiLabMask] = roiLab[roiLabMask] return lab @@ -181,14 +178,13 @@ def initLabelRoiModel(self): self.initLabelRoiModelDialog = apps.QDialogSelectModel(parent=self) self.initLabelRoiModelDialog.exec_() if self.initLabelRoiModelDialog.cancel: - self.logger.info('Magic labeller aborted.') + self.logger.info("Magic labeller aborted.") self.initLabelRoiModelDialog = None return True self.app.setOverrideCursor(Qt.WaitCursor) model_name = self.initLabelRoiModelDialog.selectedModel self.labelRoiModel = self.repeatSegm( - model_name=model_name, askSegmParams=True, - is_label_roi=True + model_name=model_name, askSegmParams=True, is_label_roi=True ) if self.labelRoiModel is None: self.initLabelRoiModelDialog = None @@ -199,24 +195,23 @@ def initLabelRoiModel(self): def labelRoiCancelled(self): self.labelRoiRunning = False - self.app.restoreOverrideCursor() - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) + self.app.restoreOverrideCursor() + self.labelRoiItem.setPos((0, 0)) + self.labelRoiItem.setSize((0, 0)) self.freeRoiItem.clear() - self.logger.info('Magic labeller process cancelled.') + self.logger.info("Magic labeller process cancelled.") def labelRoiCheckStartStopFrame(self): if not self.labelRoiTrangeCheckbox.isChecked(): return True - + start_n = self.labelRoiStartFrameNoSpinbox.value() stop_n = self.labelRoiStopFrameNoSpinbox.value() if start_n <= stop_n: return True - + self.blinker = qutils.QControlBlink( - self.labelRoiStopFrameNoSpinbox, - qparent=self + self.labelRoiStopFrameNoSpinbox, qparent=self ) self.blinker.start() msg = widgets.myMessageBox() @@ -225,19 +220,21 @@ def labelRoiCheckStartStopFrame(self): What do you want to do? """) msg.warning( - self, 'Stop frame number lower than start', txt, - buttonsTexts=('Cancel', 'Segment only current frame') + self, + "Stop frame number lower than start", + txt, + buttonsTexts=("Cancel", "Segment only current frame"), ) if msg.cancel: return False - + posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) - self.labelRoiStopFrameNoSpinbox.setValue(posData.frame_i+1) + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i + 1) + self.labelRoiStopFrameNoSpinbox.setValue(posData.frame_i + 1) def labelRoiDone(self, roiSegmData, isTimeLapse): self.setDisabled(False) - + posData = self.data[self.pos_i] self.setBrushID() @@ -248,7 +245,7 @@ def labelRoiDone(self, roiSegmData, isTimeLapse): start_frame_i = self.labelRoiStartFrameNoSpinbox.value() - 1 for i, roiLab in enumerate(roiSegmData): frame_i = start_frame_i + i - lab = posData.allData_li[frame_i]['labels'] + lab = posData.allData_li[frame_i]["labels"] store = True if lab is None: if frame_i >= len(posData.segm_data): @@ -260,15 +257,13 @@ def labelRoiDone(self, roiSegmData, isTimeLapse): lab = posData.segm_data[frame_i] store = False roiLabSlice = self.labelRoiSlice[1:] - lab = self.indexRoiLab( - roiLab, roiLabSlice, lab, posData.brushID - ) + lab = self.indexRoiLab(roiLab, roiLabSlice, lab, posData.brushID) if store: posData.frame_i = frame_i - posData.allData_li[frame_i]['labels'] = lab.copy() + posData.allData_li[frame_i]["labels"] = lab.copy() self.get_data() self.store_data(autosave=False) - + # Back to current frame posData.frame_i = current_frame_i self.get_data() @@ -279,26 +274,26 @@ def labelRoiDone(self, roiSegmData, isTimeLapse): ) self.update_rp() - + # Repeat tracking if self.autoIDcheckbox.isChecked(): self.tracking(enforce=True, assign_unique_new_IDs=False) - + self.store_data() self.updateAllImages() - - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) + + self.labelRoiItem.setPos((0, 0)) + self.labelRoiItem.setSize((0, 0)) self.freeRoiItem.clear() - self.logger.info('Magic labeller done!') - self.app.restoreOverrideCursor() + self.logger.info("Magic labeller done!") + self.app.restoreOverrideCursor() - self.labelRoiRunning = False + self.labelRoiRunning = False if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() - self.progressWin = None - + self.progressWin = None + uncheckLabelRoiTRange = ( self.labelRoiTrangeCheckbox.isChecked() and not self.labelRoiTrangeCheckbox.findChild(QAction).isChecked() @@ -308,7 +303,7 @@ def labelRoiDone(self, roiSegmData, isTimeLapse): def labelRoiFromCurrentFrameTriggered(self): posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i + 1) def labelRoiToEndFramesTriggered(self): posData = self.data[self.pos_i] @@ -328,37 +323,36 @@ def labelRoiTrangeCheckboxToggled(self, checked): posData = self.data[self.pos_i] - self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i+1) + self.labelRoiStartFrameNoSpinbox.setValue(posData.frame_i + 1) self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) def labelRoiViewCurrentModel(self): from . import config - ini_path = os.path.join( - settings_folderpath, 'last_params_segm_models.ini' - ) + + ini_path = os.path.join(settings_folderpath, "last_params_segm_models.ini") configPars = config.ConfigParser() configPars.read(ini_path) model_name = self.labelRoiModel.model_name - txt = f'Model: {model_name}' - SECTION = f'{model_name}.init' - txt = f'{txt}

    [Initialization parameters]
    ' + txt = f"Model: {model_name}" + SECTION = f"{model_name}.init" + txt = f"{txt}

    [Initialization parameters]
    " for option in configPars.options(SECTION): value = configPars[SECTION][option] - param_txt = f'{option} = {value}
    ' - txt = f'{txt}{param_txt}' - - SECTION = f'{model_name}.segment' - txt = f'{txt}
    [Segmentation parameters]
    ' + param_txt = f"{option} = {value}
    " + txt = f"{txt}{param_txt}" + + SECTION = f"{model_name}.segment" + txt = f"{txt}
    [Segmentation parameters]
    " for option in configPars.options(SECTION): value = configPars[SECTION][option] - param_txt = f'{option} = {value}
    ' - txt = f'{txt}{param_txt}' - + param_txt = f"{option} = {value}
    " + txt = f"{txt}{param_txt}" + win = apps.ViewTextDialog(txt, parent=self) win.exec_() def labelRoiWorkerFinished(self): - self.logger.info('Magic labeller closed.') + self.logger.info("Magic labeller closed.") worker = self.labelRoiActiveWorkers.pop(-1) def labelRoi_cb(self, checked): @@ -375,8 +369,8 @@ def labelRoi_cb(self, checked): lastActiveWorker = self.labelRoiActiveWorkers[-1] self.labelRoiGarbageWorkers.append(lastActiveWorker) lastActiveWorker.finished.emit() - self.logger.info('Collected garbage w5orker (magic labeller).') - + self.logger.info("Collected garbage w5orker (magic labeller).") + self.labelRoiToolbar.setVisible(True) if self.isSegm3D: self.labelRoiZdepthSpinbox.setDisabled(False) @@ -393,9 +387,7 @@ def labelRoi_cb(self, checked): labelRoiWorker.moveToThread(self.labelRoiThread) labelRoiWorker.finished.connect(self.labelRoiThread.quit) labelRoiWorker.finished.connect(labelRoiWorker.deleteLater) - self.labelRoiThread.finished.connect( - self.labelRoiThread.deleteLater - ) + self.labelRoiThread.finished.connect(self.labelRoiThread.deleteLater) labelRoiWorker.finished.connect(self.labelRoiWorkerFinished) labelRoiWorker.sigLabellingDone.connect(self.labelRoiDone) @@ -412,61 +404,61 @@ def labelRoi_cb(self, checked): # Add the rectROI to ax1 self.ax1.addItem(self.labelRoiItem) elif self.initLabelRoiModelDialog is not None: - # User is using other tools while the dialog is still open - # --> we allow this because it's useful to be able to use + # User is using other tools while the dialog is still open + # --> we allow this because it's useful to be able to use # the ruler or check things --> do nothing pass else: self.labelRoiToolbar.setVisible(False) - + for worker in self.labelRoiActiveWorkers: worker._stop() while self.app.overrideCursor() is not None: self.app.restoreOverrideCursor() - - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) + + self.labelRoiItem.setPos((0, 0)) + self.labelRoiItem.setSize((0, 0)) self.freeRoiItem.clear() self.ax1.removeItem(self.labelRoiItem) self.updateLabelRoiCircularCursor(None, None, False) def loadLabelRoiLastParams(self): - idx = 'labelRoi_checkedRoiType' + idx = "labelRoi_checkedRoiType" if idx in self.df_settings.index: - checkedRoiType = self.df_settings.at[idx, 'value'] + checkedRoiType = self.df_settings.at[idx, "value"] for button in self.labelRoiTypesGroup.buttons(): if button.text() == checkedRoiType: button.setChecked(True) break - - idx = 'labelRoi_circRoiRadius' + + idx = "labelRoi_circRoiRadius" if idx in self.df_settings.index: - circRoiRadius = self.df_settings.at[idx, 'value'] + circRoiRadius = self.df_settings.at[idx, "value"] self.labelRoiCircularRadiusSpinbox.setValue(int(circRoiRadius)) - - idx = 'labelRoi_roiZdepth' + + idx = "labelRoi_roiZdepth" if idx in self.df_settings.index: - roiZdepth = self.df_settings.at[idx, 'value'] + roiZdepth = self.df_settings.at[idx, "value"] self.labelRoiZdepthSpinbox.setValue(int(roiZdepth)) - - idx = 'labelRoi_autoClearBorder' + + idx = "labelRoi_autoClearBorder" if idx in self.df_settings.index: - clearBorder = self.df_settings.at[idx, 'value'] - checked = clearBorder == 'Yes' + clearBorder = self.df_settings.at[idx, "value"] + checked = clearBorder == "Yes" self.labelRoiAutoClearBorderCheckbox.setChecked(checked) - - idx = 'labelRoi_replaceExistingObjects' + + idx = "labelRoi_replaceExistingObjects" if idx in self.df_settings.index: - val = self.df_settings.at[idx, 'value'] - checked = val == 'Yes' + val = self.df_settings.at[idx, "value"] + checked = val == "Yes" self.labelRoiReplaceExistingObjectsCheckbox.setChecked(checked) - + if self.labelRoiIsCircularRadioButton.isChecked(): self.labelRoiCircularRadiusSpinbox.setDisabled(False) def showLabelRoiContextMenu(self, event): menu = QMenu(self.labelRoiButton) - action = QAction('Re-initialize magic labeller model...') + action = QAction("Re-initialize magic labeller model...") action.triggered.connect(self.initLabelRoiModel) menu.addAction(action) menu.exec_(QCursor.pos()) @@ -476,14 +468,13 @@ def storeLabelRoiParams(self, value=None, checked=True): circRoiRadius = self.labelRoiCircularRadiusSpinbox.value() roiZdepth = self.labelRoiZdepthSpinbox.value() autoClearBorder = self.labelRoiAutoClearBorderCheckbox.isChecked() - clearBorder = 'Yes' if autoClearBorder else 'No' - self.df_settings.at['labelRoi_checkedRoiType', 'value'] = checkedRoiType - self.df_settings.at['labelRoi_circRoiRadius', 'value'] = circRoiRadius - self.df_settings.at['labelRoi_roiZdepth', 'value'] = roiZdepth - self.df_settings.at['labelRoi_autoClearBorder', 'value'] = clearBorder - self.df_settings.at['labelRoi_replaceExistingObjects', 'value'] = ( - 'Yes' if self.labelRoiReplaceExistingObjectsCheckbox.isChecked() - else 'No' + clearBorder = "Yes" if autoClearBorder else "No" + self.df_settings.at["labelRoi_checkedRoiType", "value"] = checkedRoiType + self.df_settings.at["labelRoi_circRoiRadius", "value"] = circRoiRadius + self.df_settings.at["labelRoi_roiZdepth", "value"] = roiZdepth + self.df_settings.at["labelRoi_autoClearBorder", "value"] = clearBorder + self.df_settings.at["labelRoi_replaceExistingObjects", "value"] = ( + "Yes" if self.labelRoiReplaceExistingObjectsCheckbox.isChecked() else "No" ) self.df_settings.to_csv(self.settings_csv_path) @@ -500,7 +491,7 @@ def updateLabelRoiCircularCursor(self, x, y, checked): xx, yy = [], [] else: xx, yy = [x], [y] - + if not xx and len(self.labelRoiCircItemLeft.getData()[0]) == 0: return diff --git a/cellacdc/mixins/label_transform_tools.py b/cellacdc/mixins/label_transform_tools.py index 1e1ee1cf5..cd8f165e8 100644 --- a/cellacdc/mixins/label_transform_tools.py +++ b/cellacdc/mixins/label_transform_tools.py @@ -7,6 +7,7 @@ from .brush_tools import BrushTools from .label_editing import LabelEditing + class LabelTransformTools(BrushTools, LabelEditing): """Extracted from guiWin.""" @@ -19,8 +20,7 @@ def expandLabel(self, dilation=True): # Re-initialize label to expand when we hover on a different ID # or we change direction reinitExpandingLab = ( - self.expandingID != self.hoverLabelID - or dilation != self.isDilation + self.expandingID != self.hoverLabelID or dilation != self.isDilation ) ID = self.hoverLabelID @@ -34,32 +34,28 @@ def expandLabel(self, dilation=True): self.isExpandingLabel = True self.expandingID = ID self.expandingLab = np.zeros_like(self.currentLab2D) - self.expandingLab[obj.coords[:,-2], obj.coords[:,-1]] = ID + self.expandingLab[obj.coords[:, -2], obj.coords[:, -1]] = ID self.expandFootprintSize = 1 - prevCoords = (obj.coords[:,-2], obj.coords[:,-1]) - self.currentLab2D[obj.coords[:,-2], obj.coords[:,-1]] = 0 + prevCoords = (obj.coords[:, -2], obj.coords[:, -1]) + self.currentLab2D[obj.coords[:, -2], obj.coords[:, -1]] = 0 lab_2D = self.get_2Dlab(posData.lab) - lab_2D[obj.coords[:,-2], obj.coords[:,-1]] = 0 + lab_2D[obj.coords[:, -2], obj.coords[:, -1]] = 0 footprint = skimage.morphology.disk(self.expandFootprintSize) if dilation: - expandedLab = skimage.morphology.dilation( - self.expandingLab, footprint - ) + expandedLab = skimage.morphology.dilation(self.expandingLab, footprint) self.isDilation = True else: - expandedLab = skimage.morphology.erosion( - self.expandingLab, footprint - ) + expandedLab = skimage.morphology.erosion(self.expandingLab, footprint) self.isDilation = False # Prevent expanding into neighbouring labels - expandedLab[self.currentLab2D>0] = 0 + expandedLab[self.currentLab2D > 0] = 0 # Get coords of the dilated/eroded object expandedObj = skimage.measure.regionprops(expandedLab)[0] - expandedObjCoords = (expandedObj.coords[:,-2], expandedObj.coords[:,-1]) + expandedObjCoords = (expandedObj.coords[:, -2], expandedObj.coords[:, -1]) # Add the dilated/erored object self.currentLab2D[expandedObjCoords] = self.expandingID @@ -67,9 +63,9 @@ def expandLabel(self, dilation=True): self.set_2Dlab(lab_2D) self.currentLab2D = lab_2D - + self.update_rp() - + if self.labelsGrad.showLabelsImgAction.isChecked(): self.img2.setImage(img=self.currentLab2D, autoLevels=False) @@ -91,7 +87,7 @@ def expandLabelCallback(self, checked): self.updateAllImages() def _setTempImgExpandLabelContours(self, prevCoords, ax=0): - self.contoursImage[prevCoords] = [0,0,0,0] + self.contoursImage[prevCoords] = [0, 0, 0, 0] currentLab2Drp = skimage.measure.regionprops(self.currentLab2D) for obj in currentLab2Drp: if obj.label == self.expandingID: @@ -103,16 +99,16 @@ def _setTempImgExpandLabelSegmMasks(self, prevCoords, ax=0): # Remove previous overlaid mask labelsImage = self.getLabelsLayerImage(ax=ax) labelsImage[prevCoords] = 0 - + # Overlay new moved mask labelsImage[prevCoords] = self.expandingID if ax == 0: - self.labelsLayerImg1.setImage( - self.labelsLayerImg1.image, autoLevels=False) + self.labelsLayerImg1.setImage(self.labelsLayerImg1.image, autoLevels=False) else: self.labelsLayerRightImg.setImage( - self.labelsLayerRightImg.image, autoLevels=False) + self.labelsLayerRightImg.image, autoLevels=False + ) def resetExpandLabel(self): self.expandingID = -1 @@ -135,7 +131,7 @@ def startMovingLabel(self, xPos, yPos): self.prevMovePos = (xdata, ydata) movingObj = posData.rp[posData.IDs.index(ID)] self.movingObjCoords = movingObj.coords.copy() - yy, xx = movingObj.coords[:,-2], movingObj.coords[:,-1] + yy, xx = movingObj.coords[:, -2], movingObj.coords[:, -1] self.currentLab2D[yy, xx] = 0 def moveLabel(self, xPos, yPos): @@ -143,43 +139,43 @@ def moveLabel(self, xPos, yPos): lab_2D = self.get_2Dlab(posData.lab) Y, X = lab_2D.shape xdata, ydata = int(xPos), int(yPos) - if xdata<0 or ydata<0 or xdata>=X or ydata>=Y: + if xdata < 0 or ydata < 0 or xdata >= X or ydata >= Y: return self.clearObjContour(ID=self.movingID, ax=0) xStart, yStart = self.prevMovePos - deltaX = xdata-xStart - deltaY = ydata-yStart + deltaX = xdata - xStart + deltaY = ydata - yStart - yy, xx = self.movingObjCoords[:,-2], self.movingObjCoords[:,-1] + yy, xx = self.movingObjCoords[:, -2], self.movingObjCoords[:, -1] if self.isSegm3D: - zz = self.movingObjCoords[:,0] + zz = self.movingObjCoords[:, 0] posData.lab[zz, yy, xx] = 0 else: posData.lab[yy, xx] = 0 - self.movingObjCoords[:,-2] = self.movingObjCoords[:,-2]+deltaY - self.movingObjCoords[:,-1] = self.movingObjCoords[:,-1]+deltaX + self.movingObjCoords[:, -2] = self.movingObjCoords[:, -2] + deltaY + self.movingObjCoords[:, -1] = self.movingObjCoords[:, -1] + deltaX - yy, xx = self.movingObjCoords[:,-2], self.movingObjCoords[:,-1] + yy, xx = self.movingObjCoords[:, -2], self.movingObjCoords[:, -1] - yy[yy<0] = 0 - xx[xx<0] = 0 - yy[yy>=Y] = Y-1 - xx[xx>=X] = X-1 + yy[yy < 0] = 0 + xx[xx < 0] = 0 + yy[yy >= Y] = Y - 1 + xx[xx >= X] = X - 1 if self.isSegm3D: - zz = self.movingObjCoords[:,0] + zz = self.movingObjCoords[:, 0] posData.lab[zz, yy, xx] = self.movingID else: posData.lab[yy, xx] = self.movingID - + self.currentLab2D = self.get_2Dlab(posData.lab) if self.labelsGrad.showLabelsImgAction.isChecked(): self.img2.setImage(self.currentLab2D, autoLevels=False) - + self.setTempImg1MoveLabel() self.prevMovePos = (xdata, ydata) @@ -189,7 +185,7 @@ def setTempImgExpandLabel(self, prevCoords, expandedObjCoords, ax=0): how = self.drawIDsContComboBox.currentText() else: how = self.getAnnotateHowRightImage() - + self._setTempImgExpandLabelContours(prevCoords, ax=ax) def setTempImg1MoveLabel(self, ax=0): @@ -197,27 +193,25 @@ def setTempImg1MoveLabel(self, ax=0): how = self.drawIDsContComboBox.currentText() else: how = self.getAnnotateHowRightImage() - - if how.find('contours') != -1: + + if how.find("contours") != -1: currentLab2Drp = skimage.measure.regionprops(self.currentLab2D) for obj in currentLab2Drp: if obj.label == self.movingID: self.addObjContourToContoursImage(obj=obj, ax=ax) break - elif how.find('overlay segm. masks') != -1: + elif how.find("overlay segm. masks") != -1: if ax == 0: self.labelsLayerImg1.setImage(self.currentLab2D, autoLevels=False) self.highLightIDLayerImg1.image[:] = 0 - mask = self.currentLab2D==self.movingID + mask = self.currentLab2D == self.movingID self.highLightIDLayerImg1.image[mask] = self.movingID highlightedImage = self.highLightIDLayerImg1.image self.highLightIDLayerImg1.setImage(highlightedImage) else: - self.labelsLayerRightImg.setImage( - self.currentLab2D, autoLevels=False - ) + self.labelsLayerRightImg.setImage(self.currentLab2D, autoLevels=False) self.highLightIDLayerRightImage.image[:] = 0 - mask = self.currentLab2D==self.movingID + mask = self.currentLab2D == self.movingID self.highLightIDLayerRightImage.image[mask] = self.movingID highlightedImage = self.highLightIDLayerRightImage.image self.highLightIDLayerRightImage.setImage(highlightedImage) diff --git a/cellacdc/mixins/layout_controls.py b/cellacdc/mixins/layout_controls.py index e7f9113d5..7cefbe3fa 100644 --- a/cellacdc/mixins/layout_controls.py +++ b/cellacdc/mixins/layout_controls.py @@ -28,40 +28,35 @@ from .window_events import WindowEvents from .label_roi import LabelRoi + class LayoutControls(ImageControls, WindowEvents, LabelRoi): """Extracted from guiWin.""" def gui_createControlsToolbar(self): self.controlToolBars = [] self.addToolBarBreak() - + # Edit toolbar modeToolBar = widgets.ToolBar("Mode", self) self.addToolBar(modeToolBar) self.modeComboBox = widgets.ComboBox() self.modeComboBox.addItems(self.modeItems) - self.modeComboBoxLabel = QLabel(' Mode: ') + self.modeComboBoxLabel = QLabel(" Mode: ") self.modeComboBoxLabel.setBuddy(self.modeComboBox) modeToolBar.addWidget(self.modeComboBoxLabel) modeToolBar.addWidget(self.modeComboBox) modeToolBar.setVisible(False) - + self.modeToolBar = modeToolBar - + self.overlayToolbar = widgets.OverlayToolbar(parent=self) self.addToolBar(Qt.TopToolBarArea, self.overlayToolbar) self.overlayToolbar.setVisible(False) - self.overlayToolbar.sigSetTranspacency.connect( - self.setOverlayTransparency - ) - self.overlayToolbar.sigSetSingleChannel.connect( - self.setOverlaySingleChannel - ) - - self.autoPilotZoomToObjToolbar = widgets.ToolBar( - "Auto-zoom to objects", self - ) + self.overlayToolbar.sigSetTranspacency.connect(self.setOverlayTransparency) + self.overlayToolbar.sigSetSingleChannel.connect(self.setOverlaySingleChannel) + + self.autoPilotZoomToObjToolbar = widgets.ToolBar("Auto-zoom to objects", self) self.autoPilotZoomToObjToolbar.setContextMenuPolicy(Qt.PreventContextMenu) self.autoPilotZoomToObjToolbar.setMovable(False) self.addToolBar(Qt.TopToolBarArea, self.autoPilotZoomToObjToolbar) @@ -69,20 +64,16 @@ def gui_createControlsToolbar(self): self.autoPilotZoomToObjToolbar.setVisible(False) self.autoPilotZoomToObjToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.autoPilotZoomToObjToolbar) - + # Highlighted ID or searched ID toolbar - self.highlightIDToolbar = widgets.HighlightedIDToolbar( - parent=self - ) + self.highlightIDToolbar = widgets.HighlightedIDToolbar(parent=self) self.addToolBar(Qt.TopToolBarArea, self.highlightIDToolbar) self.highlightIDToolbar.setVisible(False) self.highlightIDToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.highlightIDToolbar) - - self.highlightIDToolbar.sigIDChanged.connect( - self.setHighlighedIDfromToolbar - ) - + + self.highlightIDToolbar.sigIDChanged.connect(self.setHighlighedIDfromToolbar) + # Widgets toolbar brushEraserToolBar = widgets.ToolBar("Widgets", self) self.addToolBar(Qt.TopToolBarArea, brushEraserToolBar) @@ -90,69 +81,64 @@ def gui_createControlsToolbar(self): self.editIDspinbox = widgets.SpinBox() # self.editIDspinbox.setMaximum(2**32-1) - editIDLabel = QLabel(' ID: ') + editIDLabel = QLabel(" ID: ") self.editIDLabelAction = brushEraserToolBar.addWidget(editIDLabel) - self.editIDspinboxAction = brushEraserToolBar.addWidget( - self.editIDspinbox - ) + self.editIDspinboxAction = brushEraserToolBar.addWidget(self.editIDspinbox) self.editIDLabelAction.setVisible(False) self.editIDspinboxAction.setVisible(False) self.editIDspinboxAction.setDisabled(True) self.editIDLabelAction.setDisabled(True) - brushEraserToolBar.addWidget(QLabel(' ')) - self.autoIDcheckbox = QCheckBox('Auto-ID') + brushEraserToolBar.addWidget(QLabel(" ")) + self.autoIDcheckbox = QCheckBox("Auto-ID") self.autoIDcheckbox.setChecked(True) self.autoIDcheckboxAction = brushEraserToolBar.addWidget(self.autoIDcheckbox) self.autoIDcheckboxAction.setVisible(False) self.brushSizeSpinbox = widgets.SpinBox( - disableKeyPress=True, - allowNegative=False + disableKeyPress=True, allowNegative=False ) self.brushSizeSpinbox.setValue(4) - brushSizeLabel = QLabel(' Size: ') + brushSizeLabel = QLabel(" Size: ") brushSizeLabel.setBuddy(self.brushSizeSpinbox) self.brushSizeLabelAction = brushEraserToolBar.addWidget(brushSizeLabel) self.brushSizeAction = brushEraserToolBar.addWidget(self.brushSizeSpinbox) self.brushSizeLabelAction.setVisible(False) self.brushSizeAction.setVisible(False) - - brushEraserToolBar.addWidget(QLabel(' ')) - self.brushAutoFillCheckbox = QCheckBox('Auto-fill holes') + + brushEraserToolBar.addWidget(QLabel(" ")) + self.brushAutoFillCheckbox = QCheckBox("Auto-fill holes") self.brushAutoFillAction = brushEraserToolBar.addWidget( self.brushAutoFillCheckbox ) self.brushAutoFillAction.setVisible(False) - if 'brushAutoFill' in self.df_settings.index: - checked = self.df_settings.at['brushAutoFill', 'value'] == 'Yes' + if "brushAutoFill" in self.df_settings.index: + checked = self.df_settings.at["brushAutoFill", "value"] == "Yes" self.brushAutoFillCheckbox.setChecked(checked) - - brushEraserToolBar.addWidget(QLabel(' ')) - self.brushAutoHideCheckbox = QCheckBox('Hide objects when hovering') + + brushEraserToolBar.addWidget(QLabel(" ")) + self.brushAutoHideCheckbox = QCheckBox("Hide objects when hovering") self.brushAutoHideAction = brushEraserToolBar.addWidget( self.brushAutoHideCheckbox ) self.brushAutoHideCheckbox.setChecked(True) self.brushAutoHideAction.setVisible(False) - if 'brushAutoHide' in self.df_settings.index: - checked = self.df_settings.at['brushAutoHide', 'value'] == 'Yes' + if "brushAutoHide" in self.df_settings.index: + checked = self.df_settings.at["brushAutoHide", "value"] == "Yes" self.brushAutoHideCheckbox.setChecked(checked) - + brushEraserToolBar.setVisible(False) self.brushEraserToolBar = brushEraserToolBar - self.wandControlsToolbar = widgets.WandControlsToolbar( - parent=self - ) + self.wandControlsToolbar = widgets.WandControlsToolbar(parent=self) - self.addToolBar(Qt.TopToolBarArea , self.wandControlsToolbar) + self.addToolBar(Qt.TopToolBarArea, self.wandControlsToolbar) self.wandControlsToolbar.setVisible(False) self.controlToolBars.append(self.wandControlsToolbar) separatorW = 5 self.labelRoiToolbar = widgets.ToolBar("Magic labeller controls", self) - self.labelRoiToolbar.addWidget(QLabel('ROI n. of z-slices: ')) + self.labelRoiToolbar.addWidget(QLabel("ROI n. of z-slices: ")) self.labelRoiZdepthSpinbox = widgets.SpinBox(disableKeyPress=True) self.labelRoiToolbar.addWidget(self.labelRoiZdepthSpinbox) @@ -161,43 +147,43 @@ def gui_createControlsToolbar(self): self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) self.labelRoiReplaceExistingObjectsCheckbox = QCheckBox( - 'Remove objs. touched by new ones' + "Remove objs. touched by new ones" ) self.labelRoiToolbar.addWidget(self.labelRoiReplaceExistingObjectsCheckbox) self.labelRoiAutoClearBorderCheckbox = QCheckBox( - 'Clear ROI borders before adding new objs.' + "Clear ROI borders before adding new objs." ) self.labelRoiAutoClearBorderCheckbox.setChecked(True) self.labelRoiToolbar.addWidget(self.labelRoiAutoClearBorderCheckbox) - + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) self.labelRoiToolbar.addWidget(widgets.QVLine()) self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) group = QButtonGroup() group.setExclusive(True) - self.labelRoiIsRectRadioButton = QRadioButton('Rect. ROI') + self.labelRoiIsRectRadioButton = QRadioButton("Rect. ROI") self.labelRoiIsRectRadioButton.setChecked(True) - self.labelRoiIsFreeHandRadioButton = QRadioButton('Freehand ROI') - self.labelRoiIsCircularRadioButton = QRadioButton('Circular ROI') + self.labelRoiIsFreeHandRadioButton = QRadioButton("Freehand ROI") + self.labelRoiIsCircularRadioButton = QRadioButton("Circular ROI") group.addButton(self.labelRoiIsRectRadioButton) group.addButton(self.labelRoiIsFreeHandRadioButton) group.addButton(self.labelRoiIsCircularRadioButton) self.labelRoiToolbar.addWidget(self.labelRoiIsRectRadioButton) self.labelRoiToolbar.addWidget(self.labelRoiIsFreeHandRadioButton) self.labelRoiToolbar.addWidget(self.labelRoiIsCircularRadioButton) - self.labelRoiToolbar.addWidget(QLabel(' | Radius (pixel): ')) + self.labelRoiToolbar.addWidget(QLabel(" | Radius (pixel): ")) self.labelRoiCircularRadiusSpinbox = widgets.SpinBox(disableKeyPress=True) self.labelRoiCircularRadiusSpinbox.setMinimum(1) self.labelRoiCircularRadiusSpinbox.setValue(11) self.labelRoiCircularRadiusSpinbox.setDisabled(True) self.labelRoiToolbar.addWidget(self.labelRoiCircularRadiusSpinbox) - + self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) self.labelRoiToolbar.addWidget(widgets.QVLine()) self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=separatorW)) - startFrameLabel = QLabel('Start frame n. ') + startFrameLabel = QLabel("Start frame n. ") startFrameLabel.setDisabled(True) self.labelRoiToolbar.addWidget(startFrameLabel) self.labelRoiStartFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) @@ -208,13 +194,13 @@ def gui_createControlsToolbar(self): self.labelRoiStartFrameNoSpinbox.setDisabled(True) self.labelRoiFromCurrentFrameAction = QAction(self) - self.labelRoiFromCurrentFrameAction.setText('Segment from current frame') + self.labelRoiFromCurrentFrameAction.setText("Segment from current frame") self.labelRoiFromCurrentFrameAction.setIcon(QIcon(":frames_current.svg")) self.labelRoiToolbar.addAction(self.labelRoiFromCurrentFrameAction) self.labelRoiFromCurrentFrameAction.setDisabled(True) self.labelRoiToolbar.addWidget(widgets.QHWidgetSpacer(width=3)) - stopFrameLabel = QLabel(' Stop frame n. ') + stopFrameLabel = QLabel(" Stop frame n. ") stopFrameLabel.setDisabled(True) self.labelRoiToolbar.addWidget(stopFrameLabel) self.labelRoiStopFrameNoSpinbox = widgets.SpinBox(disableKeyPress=True) @@ -225,18 +211,16 @@ def gui_createControlsToolbar(self): self.labelRoiStopFrameNoSpinbox.setDisabled(True) self.labelRoiToEndFramesAction = QAction(self) - self.labelRoiToEndFramesAction.setText('Segment all remaining frames') + self.labelRoiToEndFramesAction.setText("Segment all remaining frames") self.labelRoiToEndFramesAction.setIcon(QIcon(":frames_end.svg")) self.labelRoiToolbar.addAction(self.labelRoiToEndFramesAction) self.labelRoiToEndFramesAction.setDisabled(True) - self.labelRoiTrangeCheckbox = QCheckBox('Segment range of frames') + self.labelRoiTrangeCheckbox = QCheckBox("Segment range of frames") self.labelRoiToolbar.addWidget(self.labelRoiTrangeCheckbox) self.labelRoiViewCurrentModelAction = QAction(self) - self.labelRoiViewCurrentModelAction.setText( - 'View current model\'s parameters' - ) + self.labelRoiViewCurrentModelAction.setText("View current model's parameters") self.labelRoiViewCurrentModelAction.setIcon(QIcon(":view.svg")) self.labelRoiToolbar.addAction(self.labelRoiViewCurrentModelAction) self.labelRoiViewCurrentModelAction.setDisabled(True) @@ -248,9 +232,7 @@ def gui_createControlsToolbar(self): self.loadLabelRoiLastParams() - self.labelRoiTrangeCheckbox.toggled.connect( - self.labelRoiTrangeCheckboxToggled - ) + self.labelRoiTrangeCheckbox.toggled.connect(self.labelRoiTrangeCheckboxToggled) self.labelRoiReplaceExistingObjectsCheckbox.toggled.connect( self.storeLabelRoiParams ) @@ -263,12 +245,8 @@ def gui_createControlsToolbar(self): self.labelRoiCircularRadiusSpinbox.valueChanged.connect( self.storeLabelRoiParams ) - self.labelRoiZdepthSpinbox.valueChanged.connect( - self.storeLabelRoiParams - ) - self.labelRoiAutoClearBorderCheckbox.toggled.connect( - self.storeLabelRoiParams - ) + self.labelRoiZdepthSpinbox.valueChanged.connect(self.storeLabelRoiParams) + self.labelRoiAutoClearBorderCheckbox.toggled.connect(self.storeLabelRoiParams) group.buttonToggled.connect(self.storeLabelRoiParams) self.labelRoiToEndFramesAction.triggered.connect( @@ -287,14 +265,12 @@ def gui_createControlsToolbar(self): self.keepIDsConfirmAction.setToolTip('Apply "keep IDs" selection') self.keepIDsConfirmAction.setDisabled(True) self.keepIDsToolbar.addAction(self.keepIDsConfirmAction) - self.keepIDsToolbar.addWidget(QLabel(' IDs to keep: ')) + self.keepIDsToolbar.addWidget(QLabel(" IDs to keep: ")) instructionsText = ( - ' (Separate IDs by comma. Use a dash to denote a range of IDs)' + " (Separate IDs by comma. Use a dash to denote a range of IDs)" ) instructionsLabel = QLabel(instructionsText) - self.keptIDsLineEdit = widgets.KeepIDsLineEdit( - instructionsLabel, parent=self - ) + self.keptIDsLineEdit = widgets.KeepIDsLineEdit(instructionsLabel, parent=self) self.keepIDsToolbar.addWidget(self.keptIDsLineEdit) self.keepIDsToolbar.addWidget(instructionsLabel) spacer = QWidget() @@ -307,21 +283,21 @@ def gui_createControlsToolbar(self): self.keptIDsLineEdit.sigEnterPressed.connect(self.applyKeepObjects) self.keptIDsLineEdit.sigIDsChanged.connect(self.updateKeepIDs) self.keepIDsConfirmAction.triggered.connect(self.applyKeepObjects) - + # closeToolbarAction = QAction( # QIcon(":cancelButton.svg"), "Close toolbar...", self # ) # closeToolbarAction.triggered.connect(self.closeToolbars) # self.autoPilotZoomToObjToolbar.addAction(closeToolbarAction) - + self.autoPilotZoomToObjToolbar.addWidget(widgets.QVLine()) self.autoPilotZoomToObjToolbar.addWidget( widgets.QHWidgetSpacer(width=separatorW) ) - + spinBox = widgets.SpinBox() spinBox.setMinimum(1) - spinBox.label = QLabel(' Zoom to ID: ') + spinBox.label = QLabel(" Zoom to ID: ") spinBox.labelAction = self.autoPilotZoomToObjToolbar.addWidget(spinBox.label) spinBox.action = self.autoPilotZoomToObjToolbar.addWidget(spinBox) spinBox.editingFinished.connect(self.zoomToObj) @@ -331,48 +307,40 @@ def gui_createControlsToolbar(self): toggle = widgets.Toggle() self.autoPilotZoomToObjToggle = toggle toggle.toggled.connect(self.autoPilotZoomToObjToggled) - toggle.label = QLabel(' Auto-pilot: ') + toggle.label = QLabel(" Auto-pilot: ") tooltip = ( - 'When auto-pilot is active, you can use Up/Down arrows to ' - 'automatically zoom to the next/previous object.\n\n' - 'Alternatively, you can type the ID of the object you want to ' - 'zoom to.' + "When auto-pilot is active, you can use Up/Down arrows to " + "automatically zoom to the next/previous object.\n\n" + "Alternatively, you can type the ID of the object you want to " + "zoom to." ) toggle.label.setToolTip(tooltip) toggle.setToolTip(tooltip) self.autoPilotZoomToObjToolbar.addWidget(toggle.label) self.autoPilotZoomToObjToolbar.addWidget(toggle) - + self.pointsLayersToolbars = [] - + self.pointsLayersToolbar = widgets.PointsLayersToolbar(parent=self) self.pointsLayersToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - - self.pointsLayersToolbar.sigAddPointsLayer.connect( - self.addPointsLayerTriggered - ) - + + self.pointsLayersToolbar.sigAddPointsLayer.connect(self.addPointsLayerTriggered) + self.addToolBar(Qt.TopToolBarArea, self.pointsLayersToolbar) - + self.pointsLayersToolbar.setVisible(False) self.pointsLayersToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.pointsLayersToolbar) - - self.pointsLayersToolbars.append( - self.pointsLayersToolbar - ) + + self.pointsLayersToolbars.append(self.pointsLayersToolbar) self.manualTrackingToolbar = widgets.ManualTrackingToolBar( "Manual tracking controls", self ) self.manualTrackingToolbar.sigIDchanged.connect(self.initGhostObject) self.manualTrackingToolbar.sigDisableGhost.connect(self.clearGhost) - self.manualTrackingToolbar.sigClearGhostContour.connect( - self.clearGhostContour - ) - self.manualTrackingToolbar.sigClearGhostMask.connect( - self.clearGhostMask - ) + self.manualTrackingToolbar.sigClearGhostContour.connect(self.clearGhostContour) + self.manualTrackingToolbar.sigClearGhostMask.connect(self.clearGhostMask) self.manualTrackingToolbar.sigGhostOpacityChanged.connect( self.updateGhostMaskOpacity ) @@ -380,7 +348,7 @@ def gui_createControlsToolbar(self): self.addToolBar(Qt.TopToolBarArea, self.manualTrackingToolbar) self.manualTrackingToolbar.setVisible(False) self.controlToolBars.append(self.manualTrackingToolbar) - + self.manualBackgroundToolbar = widgets.ManualBackgroundToolBar( "Manual background controls", self ) @@ -390,7 +358,7 @@ def gui_createControlsToolbar(self): self.addToolBar(Qt.TopToolBarArea, self.manualBackgroundToolbar) self.manualBackgroundToolbar.setVisible(False) self.controlToolBars.append(self.manualBackgroundToolbar) - + # Copy lost object contour toolbar self.copyLostObjToolbar = widgets.CopyLostObjectToolbar( "Copy lost object controls", self @@ -398,42 +366,42 @@ def gui_createControlsToolbar(self): for name, action in self.copyLostObjToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - self.copyLostObjToolbar.sigCopyAllObjects.connect( - self.copyAllLostObjects - ) - + self.copyLostObjToolbar.sigCopyAllObjects.connect(self.copyAllLostObjects) + self.addToolBar(Qt.TopToolBarArea, self.copyLostObjToolbar) self.copyLostObjToolbar.setVisible(False) # self.controlToolBars.append(self.copyLostObjToolbar) - + # Copy lost object contour toolbar self.drawClearRegionToolbar = widgets.DrawClearRegionToolbar( "Draw freehand region and clear objects controls", self ) - + self.addToolBar(Qt.TopToolBarArea, self.drawClearRegionToolbar) self.drawClearRegionToolbar.setVisible(False) self.controlToolBars.append(self.drawClearRegionToolbar) try: - addNewIDToggleState = self.df_settings.at['addNewIDsWhitelistToggle', 'value'] == 'Yes' + addNewIDToggleState = ( + self.df_settings.at["addNewIDsWhitelistToggle", "value"] == "Yes" + ) except KeyError: addNewIDToggleState = True - + self.whitelistIDsToolbar = widgets.WhitelistIDsToolbar( addNewIDToggleState, self ) for name, action in self.whitelistIDsToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - + self.addToolBar(Qt.TopToolBarArea, self.whitelistIDsToolbar) self.whitelistIDsToolbar.setVisible(False) self.controlToolBars.append(self.whitelistIDsToolbar) - + self.magicPromptsToolbar = widgets.MagicPromptsToolbar(self) for name, action in self.magicPromptsToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - + self.magicPromptsToolbar.sigComputeOnZoom.connect( self.magicPromptsComputeOnZoomTriggered ) @@ -460,21 +428,17 @@ def gui_createControlsToolbar(self): self.magicPromptsToolbar.setVisible(False) self.magicPromptsToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.magicPromptsToolbar) - + self.promptSegmentPointsLayerToolbar = ( widgets.PromptableModelPointsLayerToolbar(parent=self) ) - self.promptSegmentPointsLayerToolbar.setContextMenuPolicy( - Qt.PreventContextMenu - ) - + self.promptSegmentPointsLayerToolbar.setContextMenuPolicy(Qt.PreventContextMenu) + self.addToolBar(Qt.TopToolBarArea, self.promptSegmentPointsLayerToolbar) self.promptSegmentPointsLayerToolbar.setVisible(False) - - self.pointsLayersToolbars.append( - self.promptSegmentPointsLayerToolbar - ) - + + self.pointsLayersToolbars.append(self.promptSegmentPointsLayerToolbar) + # Second level toolbar secondLevelToolbar = widgets.ToolBar("Second level toolbar", self) self.addToolBar(Qt.TopToolBarArea, secondLevelToolbar) @@ -482,11 +446,11 @@ def gui_createControlsToolbar(self): self.delObjToolAction.setIcon(QIcon(":del_obj_click.svg")) self.delObjToolAction.setCheckable(True) self.delObjToolAction.setToolTip( - 'Customisable delete object action\n\n' - 'Go to the `Settings --> Customise keyboard shortcuts...` menu ' - 'on the top menubar\n' - 'to customise the action required to delete ' - 'an object with a click.\n\n' + "Customisable delete object action\n\n" + "Go to the `Settings --> Customise keyboard shortcuts...` menu " + "on the top menubar\n" + "to customise the action required to delete " + "an object with a click.\n\n" 'When working with 3D segmentations, to delete only the z-slice mask, hold "Shift" while clicking.' ) secondLevelToolbar.addAction(self.delObjToolAction) @@ -496,7 +460,7 @@ def gui_createControlsToolbar(self): def gui_createMainLayout(self): mainLayout = QGridLayout() - row, col = 0, 1 # Leave column 1 for the overlay labels gradient editor + row, col = 0, 1 # Leave column 1 for the overlay labels gradient editor mainLayout.addLayout(self.leftSideDocksLayout, row, col, 2, 1) row = 0 @@ -504,22 +468,18 @@ def gui_createMainLayout(self): mainLayout.addWidget(self.graphLayout, row, col, 1, 2) mainLayout.setRowStretch(row, 2) - col = 4 # graphLayout spans two columns + col = 4 # graphLayout spans two columns mainLayout.addWidget(self.labelsGrad, row, col) - col = 5 + col = 5 mainLayout.addLayout(self.rightSideDocksLayout, row, col, 2, 1) col = 2 row += 1 self.resizeBottomLayoutLine = widgets.VerticalResizeHline() mainLayout.addWidget(self.resizeBottomLayoutLine, row, col, 1, 2) - self.resizeBottomLayoutLine.dragged.connect( - self.resizeBottomLayoutLineDragged - ) - self.resizeBottomLayoutLine.clicked.connect( - self.resizeBottomLayoutLineClicked - ) + self.resizeBottomLayoutLine.dragged.connect(self.resizeBottomLayoutLineDragged) + self.resizeBottomLayoutLine.clicked.connect(self.resizeBottomLayoutLineClicked) self.resizeBottomLayoutLine.released.connect( self.resizeBottomLayoutLineReleased ) @@ -543,27 +503,27 @@ def gui_createMainLayout(self): return mainLayout def gui_createRegionPropsDockWidget(self, side=Qt.LeftDockWidgetArea): - self.propsDockWidget = QDockWidget('Cell-ACDC objects', self) + self.propsDockWidget = QDockWidget("Cell-ACDC objects", self) self.guiTabControl = widgets.guiTabControl(self.propsDockWidget) # self.guiTabControl.setFont(_font) self.propsDockWidget.setWidget(self.guiTabControl) self.propsDockWidget.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetFloatable + QDockWidget.DockWidgetFeature.DockWidgetFloatable | QDockWidget.DockWidgetFeature.DockWidgetMovable ) self.propsDockWidget.setAllowedAreas( Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea ) - + self.addDockWidget(side, self.propsDockWidget) self.propsDockWidget.hide() def gui_createStatusBar(self): self.statusbar = self.statusBar() # Permanent widget - self.wcLabel = QLabel('') + self.wcLabel = QLabel("") self.statusbar.addPermanentWidget(self.wcLabel) # self.toggleTerminalButton = widgets.ToggleTerminalButton() @@ -572,17 +532,18 @@ def gui_createStatusBar(self): # self.gui_terminalButtonClicked # ) - self.statusBarLabel = QLabel('') + self.statusBarLabel = QLabel("") self.statusbar.addWidget(self.statusBarLabel) def gui_createTerminalWidget(self): self.terminal = widgets.QLog(logger=self.logger) self.terminal.connect() - self.terminalDock = QDockWidget('Log', self) + self.terminalDock = QDockWidget("Log", self) self.terminalDock.setWidget(self.terminal) self.terminalDock.setFeatures( - QDockWidget.DockWidgetFeature.DockWidgetFloatable | QDockWidget.DockWidgetFeature.DockWidgetMovable + QDockWidget.DockWidgetFeature.DockWidgetFloatable + | QDockWidget.DockWidgetFeature.DockWidgetMovable ) self.terminalDock.setAllowedAreas(Qt.BottomDockWidgetArea) self.addDockWidget(Qt.BottomDockWidgetArea, self.terminalDock) @@ -595,27 +556,27 @@ def gui_populateToolSettingsMenu(self): self.brushHoverCenterModeAction = QAction() self.brushHoverCenterModeAction.setCheckable(True) self.brushHoverCenterModeAction.setText( - 'Use center of the brush/eraser cursor to determine hover ID' + "Use center of the brush/eraser cursor to determine hover ID" ) self.brushHoverCircleModeAction = QAction() self.brushHoverCircleModeAction.setCheckable(True) self.brushHoverCircleModeAction.setText( - 'Use the entire circle of the brush/eraser cursor to determine hover ID' + "Use the entire circle of the brush/eraser cursor to determine hover ID" ) brushHoverModeActionGroup.addAction(self.brushHoverCenterModeAction) brushHoverModeActionGroup.addAction(self.brushHoverCircleModeAction) brushHoverModeMenu = self.settingsMenu.addMenu( - 'Brush/eraser cursor hovering mode' + "Brush/eraser cursor hovering mode" ) brushHoverModeMenu.addAction(self.brushHoverCenterModeAction) brushHoverModeMenu.addAction(self.brushHoverCircleModeAction) - if 'useCenterBrushCursorHoverID' not in self.df_settings.index: - self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' + if "useCenterBrushCursorHoverID" not in self.df_settings.index: + self.df_settings.at["useCenterBrushCursorHoverID", "value"] = "Yes" - useCenterBrushCursorHoverID = self.df_settings.at[ - 'useCenterBrushCursorHoverID', 'value' - ] == 'Yes' + useCenterBrushCursorHoverID = ( + self.df_settings.at["useCenterBrushCursorHoverID", "value"] == "Yes" + ) self.brushHoverCenterModeAction.setChecked(useCenterBrushCursorHoverID) self.brushHoverCircleModeAction.setChecked(not useCenterBrushCursorHoverID) @@ -625,43 +586,43 @@ def gui_populateToolSettingsMenu(self): self.settingsMenu.addSeparator() - keepToolActiveNames = { - 'Segment range of frames': self.labelRoiTrangeCheckbox - } + keepToolActiveNames = {"Segment range of frames": self.labelRoiTrangeCheckbox} for button in self.checkableQButtonsGroup.buttons(): if button.toolTip() == "": toolName = "MISSING" continue else: - toolName = re.findall(r'Name: (.*)', button.toolTip())[0] + toolName = re.findall(r"Name: (.*)", button.toolTip())[0] keepToolActiveNames[toolName] = button - + keepToolActiveNames = dict(natsorted(keepToolActiveNames.items())) - + applyToNewFrameNames = { - 'Segmenting for lost IDs': self.segForLostIDsButton, - 'Delete bordering objects': self.delBorderObjAction.button, - 'Delete newly segmented objects': self.delNewObjAction.button, + "Segmenting for lost IDs": self.segForLostIDsButton, + "Delete bordering objects": self.delBorderObjAction.button, + "Delete newly segmented objects": self.delNewObjAction.button, } - - allToolsList = list(keepToolActiveNames.keys()) + list(applyToNewFrameNames.keys()) + + allToolsList = list(keepToolActiveNames.keys()) + list( + applyToNewFrameNames.keys() + ) allToolsList = natsorted(allToolsList) - + menus = {} - + for toolName in allToolsList: - menuItemText = f'{toolName} tool'.replace(' ', ' ') + menuItemText = f"{toolName} tool".replace(" ", " ") menus[toolName] = self.settingsMenu.addMenu(menuItemText) - + self.keepToolActiveActions = dict() self.applyToolNewFrameActions = dict() self.applyToolNewFrameButtons = dict() all_checked = True - + for toolName, button in keepToolActiveNames.items(): menu = menus[toolName] action = QAction(button) - action.setText('Keep tool active after using it') + action.setText("Keep tool active after using it") action.setCheckable(True) if toolName in self.df_settings.index: action.setChecked(True) @@ -670,32 +631,30 @@ def gui_populateToolSettingsMenu(self): action.toggled.connect(self.keepToolActiveActionToggled) menu.addAction(action) self.keepToolActiveActions[toolName] = action - + for toolName, button in applyToNewFrameNames.items(): menu = menus[toolName] action = QAction(button) - action.setText('Apply when visitng new frame') + action.setText("Apply when visitng new frame") action.setCheckable(True) action.toggled.connect(self.applyToolNewFrameActionToggled) menu.addAction(action) self.applyToolNewFrameActions[toolName] = action self.applyToolNewFrameButtons[toolName] = button - + for toolName in self.applyToolNewFrameActions.keys(): settingString = toolName.strip() - settingString = toolName.replace(' ', '_') - settingString = f'{settingString}_applyNewFrame' + settingString = toolName.replace(" ", "_") + settingString = f"{settingString}_applyNewFrame" if settingString in self.df_settings.index: - val = self.df_settings.at[settingString, 'value'] - if val == 'applyNewFrame': + val = self.df_settings.at[settingString, "value"] + if val == "applyNewFrame": self.applyToolNewFrameActions[toolName].setChecked(True) - + self.settingsMenu.addSeparator() self.keepAllToolsActiveToggle = QAction() - self.keepAllToolsActiveToggle.setText( - 'Keep all tools active after using them' - ) + self.keepAllToolsActiveToggle.setText("Keep all tools active after using them") self.keepAllToolsActiveToggle.setCheckable(True) self.keepAllToolsActiveToggle.setChecked(all_checked) self.keepAllToolsActiveToggle.toggled.connect( @@ -703,17 +662,17 @@ def gui_populateToolSettingsMenu(self): ) self.settingsMenu.addAction(self.keepAllToolsActiveToggle) self.settingsMenu.addSeparator() - + askHowFutureFramesMenu = self.settingsMenu.addMenu( - 'Ask how to propagate changes to future frames' + "Ask how to propagate changes to future frames" ) self.askHowFutureFramesActions = {} askHowFutureFramesActionsKeys = ( - 'Delete ID', - 'Exclude cell from analysis', - 'Annotate cell as dead', - 'Edit ID', - 'Keep ID' + "Delete ID", + "Exclude cell from analysis", + "Annotate cell as dead", + "Edit ID", + "Keep ID", ) for key in askHowFutureFramesActionsKeys: askHowFutureFramesAction = QAction() @@ -723,32 +682,25 @@ def gui_populateToolSettingsMenu(self): askHowFutureFramesAction.setDisabled(True) askHowFutureFramesMenu.addAction(askHowFutureFramesAction) self.askHowFutureFramesActions[key] = askHowFutureFramesAction - - warningsMenu = self.settingsMenu.addMenu('Warnings and pop-ups') + + warningsMenu = self.settingsMenu.addMenu("Warnings and pop-ups") self.warnLostCellsAction = QAction() - self.warnLostCellsAction.setText('Show pop-up warning for lost cells') + self.warnLostCellsAction.setText("Show pop-up warning for lost cells") self.warnLostCellsAction.setCheckable(True) self.warnLostCellsAction.setChecked(True) warningsMenu.addAction(self.warnLostCellsAction) warnEditingWithAnnotTexts = { - 'Delete ID': 'Show warning when deleting ID that has annotations', - 'Separate IDs': 'Show warning when separating IDs that have annotations', - 'Edit ID': 'Show warning when editing ID that has annotations', - 'Annotate ID as dead': - 'Show warning when annotating dead ID that has annotations', - 'Delete ID with eraser': - 'Show warning when erasing ID that has annotations', - 'Add new ID with brush tool': - 'Show warning when adding new ID (brush) that has annotations', - 'Merge IDs': - 'Show warning when merging IDs that have annotations', - 'Add new ID with curvature tool': - 'Show warning when adding new ID (curv. tool) that has annotations', - 'Add new ID with magic-wand': - 'Show warning when adding new ID (magic-wand) that has annotations', - 'Delete IDs using ROI': - 'Show warning when using ROIs to delete IDs that have annotations', + "Delete ID": "Show warning when deleting ID that has annotations", + "Separate IDs": "Show warning when separating IDs that have annotations", + "Edit ID": "Show warning when editing ID that has annotations", + "Annotate ID as dead": "Show warning when annotating dead ID that has annotations", + "Delete ID with eraser": "Show warning when erasing ID that has annotations", + "Add new ID with brush tool": "Show warning when adding new ID (brush) that has annotations", + "Merge IDs": "Show warning when merging IDs that have annotations", + "Add new ID with curvature tool": "Show warning when adding new ID (curv. tool) that has annotations", + "Add new ID with magic-wand": "Show warning when adding new ID (magic-wand) that has annotations", + "Delete IDs using ROI": "Show warning when using ROIs to delete IDs that have annotations", } self.warnEditingWithAnnotActions = {} for key, desc in warnEditingWithAnnotTexts.items(): @@ -765,9 +717,9 @@ def gui_terminalButtonClicked(self, terminalVisible): def retainSpaceSlidersToggled(self, checked): if checked: - self.df_settings.at['retain_space_hidden_sliders', 'value'] = 'Yes' + self.df_settings.at["retain_space_hidden_sliders", "value"] = "Yes" else: - self.df_settings.at['retain_space_hidden_sliders', 'value'] = 'No' + self.df_settings.at["retain_space_hidden_sliders", "value"] = "No" self.df_settings.to_csv(self.settings_csv_path) if not self.zSliceScrollBar.isEnabled(): retainSpaceZ = False @@ -778,44 +730,44 @@ def retainSpaceSlidersToggled(self, checked): myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=retainSpaceZ) myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=retainSpaceZ) myutils.setRetainSizePolicy(self.overlay_z_label, retain=retainSpaceZ) - + QTimer.singleShot(200, self.resizeGui) def useCenterBrushCursorHoverIDtoggled(self, checked): if checked: - self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'Yes' + self.df_settings.at["useCenterBrushCursorHoverID", "value"] = "Yes" else: - self.df_settings.at['useCenterBrushCursorHoverID', 'value'] = 'No' + self.df_settings.at["useCenterBrushCursorHoverID", "value"] = "No" self.df_settings.to_csv(self.settings_csv_path) def zoomBottomLayoutActionTriggered(self, checked): if not checked: return - perc = int(re.findall(r'(\d+)%', self.sender().text())[0]) + perc = int(re.findall(r"(\d+)%", self.sender().text())[0]) if perc != 100: - fontSizeFactor = perc/100 - heightFactor = perc/100 + fontSizeFactor = perc / 100 + heightFactor = perc / 100 self.resizeSlidersArea( fontSizeFactor=fontSizeFactor, heightFactor=heightFactor ) else: self.gui_resetBottomLayoutHeight() - self.df_settings.at['bottom_sliders_zoom_perc', 'value'] = perc + self.df_settings.at["bottom_sliders_zoom_perc", "value"] = perc self.df_settings.to_csv(self.settings_csv_path) QTimer.singleShot(150, self.resizeGui) - def gui_createShowPropsButton(self, side='left'): - self.leftSideDocksLayout = QVBoxLayout() + def gui_createShowPropsButton(self, side="left"): + self.leftSideDocksLayout = QVBoxLayout() self.leftSideDocksLayout.setSpacing(0) - self.leftSideDocksLayout.setContentsMargins(0,0,0,0) - self.rightSideDocksLayout = QVBoxLayout() + self.leftSideDocksLayout.setContentsMargins(0, 0, 0, 0) + self.rightSideDocksLayout = QVBoxLayout() self.rightSideDocksLayout.setSpacing(0) - self.rightSideDocksLayout.setContentsMargins(0,0,0,0) + self.rightSideDocksLayout.setContentsMargins(0, 0, 0, 0) self.showPropsDockButton = widgets.expandCollapseButton() self.showPropsDockButton.setDisabled(True) self.showPropsDockButton.setFocusPolicy(Qt.NoFocus) - self.showPropsDockButton.setToolTip('Show object properties') - if side == 'left': + self.showPropsDockButton.setToolTip("Show object properties") + if side == "left": self.leftSideDocksLayout.addWidget(self.showPropsDockButton) else: self.rightSideDocksLayout.addWidget(self.showPropsDockButton) diff --git a/cellacdc/mixins/lineage_interactions.py b/cellacdc/mixins/lineage_interactions.py index 3a58cadb9..513a2ef17 100644 --- a/cellacdc/mixins/lineage_interactions.py +++ b/cellacdc/mixins/lineage_interactions.py @@ -22,6 +22,7 @@ from .annotation_display import AnnotationDisplay from .tracking import Tracking + class LineageInteractions(AnnotationDisplay, Tracking): """Extracted from guiWin.""" @@ -47,8 +48,8 @@ def annotate_unknown_lineage_action(self, posData, event, ydata, xdata): if point is None: return posData = self.data[self.pos_i] - acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] - acdc_df_frame.at[ID, 'parent_ID_tree'] = -1 + acdc_df_frame = posData.allData_li[posData.frame_i]["acdc_df"] + acdc_df_frame.at[ID, "parent_ID_tree"] = -1 self.drawAllLineageTreeLines() def askLineageTreeChanges(self): @@ -60,35 +61,42 @@ def askLineageTreeChanges(self): """ mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree': + if mode != "Normal division: Lineage tree": return - + if not self.lineage_tree: return - + posData = self.data[self.pos_i] - - if self.original_df_lin_tree_i is not None and self.original_df_lin_tree_i != posData.frame_i: + + if ( + self.original_df_lin_tree_i is not None + and self.original_df_lin_tree_i != posData.frame_i + ): printl("!This should not happen!") self.store_data(autosave=False) og_frame = posData.frame_i posData.frame_i = self.original_df_lin_tree_i self.get_data() - self.logger.info('Lineage tree changes were not propagated, going back to original frame.') + self.logger.info( + "Lineage tree changes were not propagated, going back to original frame." + ) self.askLineageTreeChanges() self.store_data(autosave=False) posData.frame_i = og_frame self.get_data() return - result = self.get_difference_table(return_css_separated=True, return_differece=True) + result = self.get_difference_table( + return_css_separated=True, return_differece=True + ) if result is None: self.original_df_lin_tree = None self.original_df_lin_tree_i = None return css, txt, differences = result - changed_IDs = differences['Cell_ID'].unique() + changed_IDs = differences["Cell_ID"].unique() if posData.frame_i == max(self.lineage_tree.frames_for_dfs): # here we can just propagate the cahnged. This is super fast, since there is no recursion, no children and fast finding of parents @@ -97,44 +105,47 @@ def askLineageTreeChanges(self): self.original_df_lin_tree_i = None return - txt = txt + 'Do you want to keep, propgagte or discard the changes?' - txt = css + html_utils.paragraph('Changes made in this frame
    ' + txt) + txt = txt + "Do you want to keep, propgagte or discard the changes?" + txt = css + html_utils.paragraph("Changes made in this frame
    " + txt) msg = widgets.myMessageBox() - propagate_btn, discard_btn, _ = msg.question(self, - 'Changes in lineage tree', - txt, - buttonsTexts=('Propagate', 'Discard', 'Cancel'),) + propagate_btn, discard_btn, _ = msg.question( + self, + "Changes in lineage tree", + txt, + buttonsTexts=("Propagate", "Discard", "Cancel"), + ) if msg.clickedButton == propagate_btn: self.lineage_tree.propagate(posData.frame_i, relevant_cells=changed_IDs) self.original_df_lin_tree = None self.original_df_lin_tree_i = None - self.logger.info('Lineage tree propagated.') + self.logger.info("Lineage tree propagated.") elif msg.clickedButton == discard_btn: - posData.allData_li[posData.frame_i]['acdc_df'] = self.original_df_lin_tree.copy() + posData.allData_li[posData.frame_i]["acdc_df"] = ( + self.original_df_lin_tree.copy() + ) self.original_df_lin_tree = None self.original_df_lin_tree_i = None - self.logger.info('Lineage tree changes discarded.') - + self.logger.info("Lineage tree changes discarded.") elif msg.cancel: # Go back to current frame msg = widgets.myMessageBox() - txt = html_utils.paragraph(''' + txt = html_utils.paragraph(""" Changes were kept but not propagated! Please make sure to come back and propagate them, otherwise your table might be inconsistent! There is a button for this next to the edit buttons. Please also do not visit new frames! - ''') - msg.warning(self, 'Changes kept but not propagated!', txt) + """) + msg.warning(self, "Changes kept but not propagated!", txt) self.original_df_lin_tree = None self.original_df_lin_tree_i = None - self.logger.info('Lineage tree changes discarded.') + self.logger.info("Lineage tree changes discarded.") def autoLinTree_df(self, enforceAll=False): """Automatically generates a lineage tree dataframe. @@ -165,9 +176,9 @@ def autoLinTree_df(self, enforceAll=False): mode = str(self.modeComboBox.currentText()) # Skip if not the right mode - if mode != 'Normal division: Lineage tree': + if mode != "Normal division: Lineage tree": return notEnoughG1Cells, proceed - + posData = self.data[self.pos_i] frame_i = posData.frame_i @@ -175,16 +186,16 @@ def autoLinTree_df(self, enforceAll=False): return notEnoughG1Cells, proceed # Make sure that this is a visited frame in segmentation tracking mode - if posData.allData_li[frame_i]['labels'] is None: # may need to change this + if posData.allData_li[frame_i]["labels"] is None: # may need to change this proceed = self.warnFrameNeverVisitedSegmMode() return notEnoughG1Cells, proceed - + self.store_data(autosave=False) self.get_data() lab = posData.lab - prev_lab = posData.allData_li[frame_i-1]['labels'] + prev_lab = posData.allData_li[frame_i - 1]["labels"] rp = posData.rp - prev_rp = posData.allData_li[frame_i-1]['regionprops'] + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) self.store_data() @@ -212,17 +223,20 @@ def find_mother_action(self, posData, event, ydata, xdata): if point is None: return posData = self.data[self.pos_i] - acdc_df_frame = posData.allData_li[posData.frame_i]['acdc_df'] + acdc_df_frame = posData.allData_li[posData.frame_i]["acdc_df"] filtered_IDs = self.getDistanceListMissingIDs(point, ID) if len(filtered_IDs) == 0: - self.logger.info('No mother candidates found.') + self.logger.info("No mother candidates found.") return i = self.right_click_i % len(filtered_IDs) i = abs(i) # Ensure i is non-negative new_mother = filtered_IDs[i] - if acdc_df_frame.loc[ID]['parent_ID_tree'] == new_mother and self.original_mother_skipped == False: # if a mother is already present, skip it + if ( + acdc_df_frame.loc[ID]["parent_ID_tree"] == new_mother + and self.original_mother_skipped == False + ): # if a mother is already present, skip it self.right_click_i += 1 self.original_mother_skipped = True @@ -230,7 +244,9 @@ def find_mother_action(self, posData, event, ydata, xdata): i = abs(i) # Ensure i is non-negative new_mother = filtered_IDs[i] - acdc_df_frame.at[ID, 'parent_ID_tree'] = new_mother # update mother in the df, no need to propagate or stuff lile this + acdc_df_frame.at[ID, "parent_ID_tree"] = ( + new_mother # update mother in the df, no need to propagate or stuff lile this + ) # dont need to update alldata_li as acdc_df_frame is just a view self.drawAllLineageTreeLines() @@ -244,13 +260,11 @@ def getDistanceListMissingIDs(self, point, ID): # self.get_data() if ID not in self.distanceListMissingIDs.keys(): - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - relevant_rp = [ - obj for obj in prev_rp if obj.label not in posData.IDs - ] + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + relevant_rp = [obj for obj in prev_rp if obj.label not in posData.IDs] len_relevant_rp = len(relevant_rp) if len_relevant_rp == 0: - self.logger.info('No missing IDs found in previous frame.') + self.logger.info("No missing IDs found in previous frame.") return [] elif len_relevant_rp == 1: self.distanceListMissingIDs[ID] = [relevant_rp[0].label] @@ -268,14 +282,14 @@ def get_difference_table(self, return_css_separated=False, return_differece=Fals return posData = self.data[self.pos_i] - - new_df = posData.allData_li[posData.frame_i]['acdc_df'] + + new_df = posData.allData_li[posData.frame_i]["acdc_df"] original_df = self.original_df_lin_tree.copy() if original_df.equals(new_df): return - - compare_columns = ['parent_ID_tree'] + + compare_columns = ["parent_ID_tree"] new_df = new_df[original_df.columns] new_df = myutils.checked_reset_index_Cell_ID(new_df) @@ -288,10 +302,10 @@ def get_difference_table(self, return_css_separated=False, return_differece=Fals differences = original_df.compare(new_df) if differences.empty: return - + differences = myutils.checked_reset_index_Cell_ID(differences) - - differences = differences['parent_ID_tree'] + + differences = differences["parent_ID_tree"] differences = differences.reset_index() txt = """
    @@ -305,15 +319,15 @@ def get_difference_table(self, return_css_separated=False, return_differece=Fals ID = str(int(diff.Cell_ID)) old_parent = str(int(diff.self)) new_parent = str(int(diff.other)) - - txt += f''' + + txt += f""" - ''' - txt += '
    {ID} {old_parent} {new_parent}
    ' + """ + txt += "" - css = r''' + css = r""" - ''' + """ if return_css_separated and not return_differece: return css, txt elif return_css_separated and return_differece: @@ -350,28 +364,27 @@ def initLinTree(self, force=False): if not force and self.lineage_tree is not None: return - + mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree' and not force: + if mode != "Normal division: Lineage tree" and not force: return posData = self.data[self.pos_i] last_tracked_i = self.get_last_tracked_i() - defaultMode = 'Viewer' + defaultMode = "Viewer" if last_tracked_i == 0: # Display message to the user txt = html_utils.paragraph( - 'On this dataset either you never checked that the segmentation ' - 'and tracking are correct or you did not save yet.

    ' + "On this dataset either you never checked that the segmentation " + "and tracking are correct or you did not save yet.

    " 'If you already visited some frames with "Segmentation and Tracking" ' 'mode save data before switching to "Normal division: Lineage Tree".

    ' - 'Otherwise you first have to check (and eventually correct) some frames ' + "Otherwise you first have to check (and eventually correct) some frames " 'in "Segmentation and Tracking" mode before proceeding ' - 'with lineage tree analysis.') - msg = widgets.myMessageBox() - msg.critical( - self, 'Tracking was never checked', txt + "with lineage tree analysis." ) + msg = widgets.myMessageBox() + msg.critical(self, "Tracking was never checked", txt) self.modeComboBox.setCurrentText(defaultMode) return @@ -379,11 +392,12 @@ def initLinTree(self, force=False): last_lin_tree_frame_i = 0 # Determine last annotated frame index for i, dict_frame_i in enumerate(posData.allData_li): - df = dict_frame_i['acdc_df'] - if (df is None or - 'generation_num_tree' not in df.columns - or df['generation_num_tree'].isin([np.nan, 0]).all() - ): + df = dict_frame_i["acdc_df"] + if ( + df is None + or "generation_num_tree" not in df.columns + or df["generation_num_tree"].isin([np.nan, 0]).all() + ): break else: last_lin_tree_frame_i = i @@ -398,59 +412,65 @@ def initLinTree(self, force=False): # Prompt user to go to last annotated frame msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_lin_tree_frame_i+1}.

    + The last annotated frame is frame {last_lin_tree_frame_i + 1}.

    Do you want to restart lineage tree analysis from frame - {last_lin_tree_frame_i+1}?
    + {last_lin_tree_frame_i + 1}?
    """) _, yesButton, stayButton = msg.warning( - self, 'Go to last annotated frame?', txt, + self, + "Go to last annotated frame?", + txt, buttonsTexts=( - 'Cancel', f'Yes, go to frame {last_lin_tree_frame_i+1}', - 'No, stay on current frame') + "Cancel", + f"Yes, go to frame {last_lin_tree_frame_i + 1}", + "No, stay on current frame", + ), ) if yesButton == msg.clickedButton: - msg = 'Looking good!' + msg = "Looking good!" self.last_lin_tree_frame_i = last_lin_tree_frame_i posData.frame_i = last_lin_tree_frame_i self.titleLabel.setText(msg, color=self.titleColor) self.get_data(lin_tree_init=False) - self.updateAllImages() # i dont think I need to change this - self.updateScrollbars() # i dont think I need to change this + self.updateAllImages() # i dont think I need to change this + self.updateScrollbars() # i dont think I need to change this elif stayButton == msg.clickedButton: - self.initMissingFramesLinTree(posData.frame_i) #!!! + self.initMissingFramesLinTree(posData.frame_i) #!!! last_lin_tree_frame_i = posData.frame_i - msg = 'Lineage tree analysis initialised!' - self.titleLabel.setText(msg, color='g') + msg = "Lineage tree analysis initialised!" + self.titleLabel.setText(msg, color="g") elif msg.cancel: - msg = 'Lineage tree analysis aborted.' + msg = "Lineage tree analysis aborted." self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) self.modeComboBox.setCurrentText(defaultMode) proceed = False return - + elif posData.frame_i < last_lin_tree_frame_i: # Prompt user to go to last annotated frame msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" - The last annotated frame is frame {last_lin_tree_frame_i+1}.

    + The last annotated frame is frame {last_lin_tree_frame_i + 1}.

    Do you want to restart lineage tree analysis from frame - {last_lin_tree_frame_i+1}?
    + {last_lin_tree_frame_i + 1}?
    """) goTo_last_annotated_frame_i = msg.question( - self, 'Go to last annotated frame?', txt, - buttonsTexts=('Yes', 'No', 'Cancel') + self, + "Go to last annotated frame?", + txt, + buttonsTexts=("Yes", "No", "Cancel"), )[0] if goTo_last_annotated_frame_i == msg.clickedButton: - msg = 'Looking good!' + msg = "Looking good!" self.titleLabel.setText(msg, color=self.titleColor) self.last_lin_tree_frame_i = last_lin_tree_frame_i posData.frame_i = last_lin_tree_frame_i self.get_data(lin_tree_init=False) - self.updateAllImages() # i dont think I need to change this - self.updateScrollbars() # i dont think I need to change this + self.updateAllImages() # i dont think I need to change this + self.updateScrollbars() # i dont think I need to change this elif msg.cancel: - msg = 'Lineage tree analysis aborted.' + msg = "Lineage tree analysis aborted." self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) self.modeComboBox.setCurrentText(defaultMode) @@ -461,21 +481,23 @@ def initLinTree(self, force=False): self.last_lin_tree_frame_i = last_lin_tree_frame_i - self.navigateScrollBar.setMaximum(last_lin_tree_frame_i+1) - self.navSpinBox.setMaximum(last_lin_tree_frame_i+1) + self.navigateScrollBar.setMaximum(last_lin_tree_frame_i + 1) + self.navSpinBox.setMaximum(last_lin_tree_frame_i + 1) if self.lineage_tree is None or force: self.store_data(autosave=False) self.get_data(lin_tree_init=False) self.lineage_tree = normal_division_lineage_tree(gui=self) - msg = 'Lineage tree analysis initialized!' + msg = "Lineage tree analysis initialized!" self.logger.info(msg) self.titleLabel.setText(msg, color=self.titleColor) return proceed - def initMissingFramesLinTree(self, current_frame_i): # done Need to add partially missing previous frames and loading + def initMissingFramesLinTree( + self, current_frame_i + ): # done Need to add partially missing previous frames and loading """ When not starting from the first frame, automatically creates lineage tree dfs for all "skipped" frames and initializes the tree if not done so before. @@ -494,7 +516,7 @@ def initMissingFramesLinTree(self, current_frame_i): # done Need to add partiall """ self.logger.info( - 'Initialising lineage tree annotations of missing past frames...' + "Initialising lineage tree annotations of missing past frames..." ) self.store_data(autosave=False) @@ -503,20 +525,26 @@ def initMissingFramesLinTree(self, current_frame_i): # done Need to add partiall posData = self.data[self.pos_i] current_frame_i = posData.frame_i - if not self.lineage_tree: # init lin tree if not done already - self.lineage_tree = normal_division_lineage_tree(gui=self) # here frame_i!=0 + if not self.lineage_tree: # init lin tree if not done already + self.lineage_tree = normal_division_lineage_tree( + gui=self + ) # here frame_i!=0 - missing_frames = list(range(current_frame_i+1)) - present_frames = list(self.lineage_tree.frames_for_dfs) if self.lineage_tree else [] - present_frames = [] if not present_frames else present_frames # deal with None - missing_frames = [frame_i for frame_i in missing_frames if frame_i not in present_frames] + missing_frames = list(range(current_frame_i + 1)) + present_frames = ( + list(self.lineage_tree.frames_for_dfs) if self.lineage_tree else [] + ) + present_frames = [] if not present_frames else present_frames # deal with None + missing_frames = [ + frame_i for frame_i in missing_frames if frame_i not in present_frames + ] missing_frames.sort() for frame_i in missing_frames: - lab = posData.allData_li[frame_i]['labels'] - prev_lab = posData.allData_li[frame_i-1]['labels'] - rp = posData.allData_li[frame_i]['regionprops'] - prev_rp = posData.allData_li[frame_i-1]['regionprops'] + lab = posData.allData_li[frame_i]["labels"] + prev_lab = posData.allData_li[frame_i - 1]["labels"] + rp = posData.allData_li[frame_i]["regionprops"] + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] # i might need to change this if I need support for only partially missing frames... Although I probably never have to care about that though self.lineage_tree.real_time(frame_i, lab, prev_lab, rp=rp, prev_rp=prev_rp) @@ -530,14 +558,16 @@ def propagateLinTreeAction(self, dummy_for_button=None): posData = self.data[self.pos_i] self.lineage_tree.propagate(posData.frame_i) if posData.frame_i == self.original_df_lin_tree_i: - self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() + self.original_df_lin_tree = posData.allData_li[posData.frame_i][ + "acdc_df" + ].copy() - self.logger.info('Lineage tree propagated.') + self.logger.info("Lineage tree propagated.") def repeat_click_and_backup(self, posData, event, ydata, xdata): """ - This function is part of the lin_tree edit functionality. - It handles the back up of the original self.lineage_tree.lineage_list + This function is part of the lin_tree edit functionality. + It handles the back up of the original self.lineage_tree.lineage_list df and the repeated clicking on the same ID to cycle through pssible mothers. Parameters @@ -557,13 +587,17 @@ def repeat_click_and_backup(self, posData, event, ydata, xdata): A tuple containing the point(tuple: (x, y) coords) and ID of clicked cell. """ if self.original_df_lin_tree is None: - self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() + self.original_df_lin_tree = posData.allData_li[posData.frame_i][ + "acdc_df" + ].copy() self.original_df_lin_tree_i = posData.frame_i elif self.original_df_lin_tree_i != posData.frame_i: self.logger.info( - '[WARNING]: !!! Original lineage tree df changed, resetting original_df_lin_tree !!!' + "[WARNING]: !!! Original lineage tree df changed, resetting original_df_lin_tree !!!" ) - self.original_df_lin_tree = posData.allData_li[posData.frame_i]['acdc_df'].copy() + self.original_df_lin_tree = posData.allData_li[posData.frame_i][ + "acdc_df" + ].copy() self.original_df_lin_tree_i = posData.frame_i if not self.right_click_ID: @@ -595,56 +629,60 @@ def resetLin_tree_future(self): for i in range(frame_i, posData.SizeT): if self.lineage_tree is not None: self.lineage_tree.frames_for_dfs.discard(frame_i) - df = posData.allData_li[i]['acdc_df'] + df = posData.allData_li[i]["acdc_df"] # reste lineage tree columns if df is None: continue - df = df.drop(columns=lineage_tree_cols, errors='ignore') - posData.allData_li[i]['acdc_df'] = df + df = df.drop(columns=lineage_tree_cols, errors="ignore") + posData.allData_li[i]["acdc_df"] = df def viewLinTreeInfoAction(self): mode = str(self.modeComboBox.currentText()) - if mode != 'Normal division: Lineage tree': - self.logger.info('This action is only available in the "Normal division: Lineage tree" mode.') + if mode != "Normal division: Lineage tree": + self.logger.info( + 'This action is only available in the "Normal division: Lineage tree" mode.' + ) return - + if not self.lineage_tree: - self.logger.info('No lineage tree found.') + self.logger.info("No lineage tree found.") return - + posData = self.data[self.pos_i] if self.original_df_lin_tree_i != posData.frame_i: # could be that this is not entirley true and self.curr_original_df_i just didnt get set right though! - txt_changes = '
    No changes were made in this frame.

    ' - + txt_changes = "
    No changes were made in this frame.

    " + else: result = self.get_difference_table(return_css_separated=True) if result is None: - txt_changes = 'No changes were made in this frame.' + txt_changes = "No changes were made in this frame." else: css, txt_changes = result - txt_changes = 'Changes made in this frame:' + txt_changes + '

    ' - - cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i) + txt_changes = "Changes made in this frame:" + txt_changes + "

    " + + cells_with_parent, orphan_cells, lost_cells = ( + self.lineage_tree.export_lin_tree_info(posData.frame_i) + ) if orphan_cells == []: - txt_orphan_cells = 'No orphan Cells!' + txt_orphan_cells = "No orphan Cells!" else: - txt_orphan_cells = ', '.join([str(cell) for cell in orphan_cells]) - txt_orphan = f'Orphan cells:
    {txt_orphan_cells}

    ' + txt_orphan_cells = ", ".join([str(cell) for cell in orphan_cells]) + txt_orphan = f"Orphan cells:
    {txt_orphan_cells}

    " lost_cells = list(lost_cells) if lost_cells == []: - txt_lost_cells = 'No lost Cells!' + txt_lost_cells = "No lost Cells!" else: - txt_lost_cells = ', '.join([str(cell) for cell in lost_cells]) - txt_lost = f'Lost cells:
    {txt_lost_cells}

    ' + txt_lost_cells = ", ".join([str(cell) for cell in lost_cells]) + txt_lost = f"Lost cells:
    {txt_lost_cells}

    " if cells_with_parent == []: - table_cells_with_parent = '
    No cells with parents!' + table_cells_with_parent = "
    No cells with parents!" else: table_cells_with_parent = """ @@ -653,15 +691,17 @@ def viewLinTreeInfoAction(self): """ for cell, parent in cells_with_parent: - table_cells_with_parent += f''' + table_cells_with_parent += f""" - ''' - table_cells_with_parent += '
    {parent} {cell}
    ' + """ + table_cells_with_parent += "" - txt_cells_with_parents = f'Cells with parents:{table_cells_with_parent}

    ' + txt_cells_with_parents = ( + f"Cells with parents:{table_cells_with_parent}

    " + ) - css = r''' + css = r""" - ''' + """ - txt = css + html_utils.paragraph(txt_changes + txt_orphan + txt_lost + txt_cells_with_parents) + txt = css + html_utils.paragraph( + txt_changes + txt_orphan + txt_lost + txt_cells_with_parents + ) msg = widgets.myMessageBox() - msg.information(self, - 'lineage tree information', - txt - ) + msg.information(self, "lineage tree information", txt) diff --git a/cellacdc/mixins/magic_prompts.py b/cellacdc/mixins/magic_prompts.py index 54baa0f03..7a3117fbb 100644 --- a/cellacdc/mixins/magic_prompts.py +++ b/cellacdc/mixins/magic_prompts.py @@ -21,13 +21,14 @@ from .graphics import Graphics + class MagicPrompts(Graphics): """Extracted from guiWin.""" def _importInitMagicPromptModel( - self, model_name, posData, win, acdcPromptSegment, toolbar - ): - self.logger.info(f'Initializing promptable model {model_name}...') + self, model_name, posData, win, acdcPromptSegment, toolbar + ): + self.logger.info(f"Initializing promptable model {model_name}...") init_kwargs = win.init_kwargs model = myutils.init_prompt_segm_model( acdcPromptSegment, posData, win.init_kwargs @@ -36,87 +37,83 @@ def _importInitMagicPromptModel( toolbar.model_segment_kwargs = win.model_kwargs toolbar.model_name = model_name toolbar.viewModelParamsAction.setDisabled(False) - + self.magicPromptsToolbar.setInitializedModel( init_kwargs, toolbar.model_segment_kwargs ) - - self.logger.info( - f'Promptable model {model_name} successfully initialised!' - ) + + self.logger.info(f"Promptable model {model_name} successfully initialised!") def getMagicPromptsInputs(self, toolbar): if not self.promptSegmentPointsLayerToolbar.isPointsLayerInit: _warnings.warnPromptSegmentPointsLayerNotInit(qparent=self) return - + if not self.magicPromptsToolbar.viewModelParamsAction.isEnabled(): _warnings.warnPromptSegmentModelNotInit(qparent=self) return - + posData = self.data[self.pos_i] image = self.getDisplayedZstack() df_points = self.promptSegmentPointsLayerToolbar.pointsLayerDf( posData, isSegm3D=self.isSegm3D ) - + self.logger.info( - f'Starting {toolbar.model_name} promptable segmentation with the ' - f'following prompts:\n\n{df_points}' + f"Starting {toolbar.model_name} promptable segmentation with the " + f"following prompts:\n\n{df_points}" ) - + return image, df_points def magicPromptsClearPoints(self, toolbar, only_zoom=False): posData = self.data[self.pos_i] scatterItem = self.promptSegmentPointsLayerToolbar.scatterItem() action = scatterItem.action - + pointsDataPos = action.pointsData.get(self.pos_i) if pointsDataPos is None: return - - framePointsData = action.pointsData[self.pos_i].pop( - posData.frame_i, None - ) + + framePointsData = action.pointsData[self.pos_i].pop(posData.frame_i, None) if framePointsData is None: return - + if not only_zoom: scatterItem.clear() return - + ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange() Y, X = posData.img_data.shape[-2:] - + xmin = int(max(0, xmin)) xmax = int(min(X, xmax)) ymin = int(max(0, ymin)) ymax = int(min(Y, ymax)) - - if 'x' in framePointsData: - newFramePointsData = {'x': [], 'y': [], 'id': []} - xx = framePointsData['x'] - yy = framePointsData['y'] - ids = framePointsData['id'] + + if "x" in framePointsData: + newFramePointsData = {"x": [], "y": [], "id": []} + xx = framePointsData["x"] + yy = framePointsData["y"] + ids = framePointsData["id"] for x, y, point_id in zip(xx, yy, ids): if x < xmin or x >= xmax or y < ymin or y >= ymax: - newFramePointsData['x'].append(x) - newFramePointsData['y'].append(y) - newFramePointsData['id'].append(point_id) + newFramePointsData["x"].append(x) + newFramePointsData["y"].append(y) + newFramePointsData["id"].append(point_id) else: newFramePointsData = {} for z, zSliceFramePointsData in framePointsData.items(): - newFramePointsData[z] = {'x': [], 'y': [], 'id': []} - xx = zSliceFramePointsData['x'] - yy = zSliceFramePointsData['y'] - ids = zSliceFramePointsData['id'] + newFramePointsData[z] = {"x": [], "y": [], "id": []} + xx = zSliceFramePointsData["x"] + yy = zSliceFramePointsData["y"] + ids = zSliceFramePointsData["id"] for x, y, point_id in zip(xx, yy, ids): if x < xmin or x >= xmax or y < ymin or y >= ymax: - newFramePointsData[z]['x'].append(x) - newFramePointsData[z]['y'].append(y) - newFramePointsData[z]['id'].append(point_id) - + newFramePointsData[z]["x"].append(x) + newFramePointsData[z]["y"].append(y) + newFramePointsData[z]["id"].append(point_id) + action.pointsData[self.pos_i][posData.frame_i] = newFramePointsData self.drawPointsLayers() @@ -124,13 +121,12 @@ def magicPromptsComputeOnImageTriggered(self, toolbar): inputs = self.getMagicPromptsInputs(toolbar) if inputs is None: self.logger.info( - '"Computing promptable segmentation on entire image" ' - 'process cancelled.' + '"Computing promptable segmentation on entire image" process cancelled.' ) return image, df_points = inputs - + self.startMagicPromptsWorkerAndWait( image, df_points, toolbar.model, toolbar.model_segment_kwargs ) @@ -142,78 +138,82 @@ def magicPromptsComputeOnZoomTriggered(self, toolbar): '"Computing promptable segmentation on zoom" process cancelled.' ) return - + posData = self.data[self.pos_i] image, df_points = inputs - + ((xmin, xmax), (ymin, ymax)) = self.ax1.viewRange() Y, X = image.shape[-2:] - + xmin = int(max(0, xmin)) xmax = int(min(X, xmax)) ymin = int(max(0, ymin)) ymax = int(min(Y, ymax)) - + self.logger.info( - f'Zoom range: xmin={xmin}, xmax={xmax}, ymin={ymin}, ymax={ymax}' + f"Zoom range: xmin={xmin}, xmax={xmax}, ymin={ymin}, ymax={ymax}" ) - + zoom_slice = (slice(ymin, ymax), slice(xmin, xmax)) - + image = image[..., ymin:ymax, xmin:xmax] image_origin = (0, ymin, xmin) - - df_points = df_points[df_points['y'] >= ymin] - df_points = df_points[df_points['x'] >= xmin] - df_points = df_points[df_points['y'] < ymax] - df_points = df_points[df_points['x'] < xmax] - - df_points['y'] -= ymin - df_points['x'] -= xmin - - df_points = df_points[ df_points['frame_i'] == posData.frame_i] - - self.logger.info( - f'Image origin = {image_origin}\n' - f'Image shape = {image.shape}' - ) - + + df_points = df_points[df_points["y"] >= ymin] + df_points = df_points[df_points["x"] >= xmin] + df_points = df_points[df_points["y"] < ymax] + df_points = df_points[df_points["x"] < xmax] + + df_points["y"] -= ymin + df_points["x"] -= xmin + + df_points = df_points[df_points["frame_i"] == posData.frame_i] + + self.logger.info(f"Image origin = {image_origin}\nImage shape = {image.shape}") + self.startMagicPromptsWorkerAndWait( - image, df_points, toolbar.model, toolbar.model_segment_kwargs, - image_origin=image_origin, zoom_slice=zoom_slice + image, + df_points, + toolbar.model, + toolbar.model_segment_kwargs, + image_origin=image_origin, + zoom_slice=zoom_slice, ) def magicPromptsInitModel( - self, - model_name, - acdcPromptSegment, - init_argspecs, - segment_argspecs, - help_url, - toolbar, - ): + self, + model_name, + acdcPromptSegment, + init_argspecs, + segment_argspecs, + help_url, + toolbar, + ): posData = self.data[self.pos_i] - + out = prompts.init_prompt_model_params( - posData, model_name, init_argspecs, segment_argspecs, - help_url=help_url, qparent=self, init_last_params=True + posData, + model_name, + init_argspecs, + segment_argspecs, + help_url=help_url, + qparent=self, + init_last_params=True, ) - win = out.get('win') + win = out.get("win") if win.cancel: self.logger.info( - f'Initialization of {model_name} promptable model cancelled.' + f"Initialization of {model_name} promptable model cancelled." ) return - + self._importInitMagicPromptModel( model_name, posData, win, acdcPromptSegment, toolbar ) def magicPromptsInterpolateZsliceToggled(self, checked): # See 'self.promptSegmentPointsLayerToolbar.addPointsZslicesInterpolation' - self.promptSegmentPointsLayerToolbar.doAddPointsZslicesInterpolation = ( - checked - ) + self.promptSegmentPointsLayerToolbar.doAddPointsZslicesInterpolation = checked def magicPromptsWorkerCritical(self, error): self.magicPromptsWorkerLoop.exit() @@ -225,100 +225,95 @@ def magicPromptsWorkerFinished(self, output, zoom_slice=None): self.progressWin.close() self.progressWin = None self.magicPromptsWorkerLoop.exit() - + lab_new, lab_union, lab_interesection = output - + posData = self.data[self.pos_i] - + is_zoom = True if zoom_slice is None: zoom_slice = (slice(None), slice(None)) is_zoom = False - - img = ( - posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]] - ) + + img = posData.img_data[posData.frame_i][..., zoom_slice[0], zoom_slice[1]] images = [img, img, img, img] labels_overlays = [ - posData.lab[..., zoom_slice[0], zoom_slice[1]], - lab_new[..., zoom_slice[0], zoom_slice[1]], - lab_union[..., zoom_slice[0], zoom_slice[1]], + posData.lab[..., zoom_slice[0], zoom_slice[1]], + lab_new[..., zoom_slice[0], zoom_slice[1]], + lab_union[..., zoom_slice[0], zoom_slice[1]], lab_interesection[..., zoom_slice[0], zoom_slice[1]], ] labels_overlays_lut = self.getLabelsImageLut() labels_overlays_luts = [ - labels_overlays_lut, - labels_overlays_lut, + labels_overlays_lut, + labels_overlays_lut, labels_overlays_lut, labels_overlays_lut, ] axis_titles = [ - 'Original masks', - 'New masks', - 'Union of original and new masks', - 'Intersection of original and new masks' + "Original masks", + "New masks", + "Union of original and new masks", + "Intersection of original and new masks", ] - + from cellacdc.plot import imshow + promptSegmResultsWindow = imshow( - *images, + *images, labels_overlays=labels_overlays, labels_overlays_luts=labels_overlays_luts, axis_titles=axis_titles, - window_title='Promptable segmentation results', - figure_title='Ctrl+Click to select the result to use', + window_title="Promptable segmentation results", + figure_title="Ctrl+Click to select the result to use", annotate_labels_idxs=[0, 1, 2, 3], - selectable_images=True, + selectable_images=True, max_ncols=2, - lut='gray', - infer_rgb=False + lut="gray", + infer_rgb=False, ) if promptSegmResultsWindow.selected_idx is None: self.logger.info( - 'Selection of the promptable model segmentation ' - 'result cancelled.' + "Selection of the promptable model segmentation result cancelled." ) return - + if promptSegmResultsWindow.selected_idx == 0: self.logger.info( - 'No selection of a promptable model segmentation ' - 'result was made' + "No selection of a promptable model segmentation result was made" ) return # Store undo state before modifying stuff self.storeUndoRedoStates(False) - + results = (None, lab_new, lab_union, lab_interesection) selected_idx = promptSegmResultsWindow.selected_idx zoom_out_lab = results[selected_idx][..., zoom_slice[0], zoom_slice[1]] zoom_out_lab_mask = zoom_out_lab > 0 - - lab = posData.allData_li[posData.frame_i]['labels'] - lab[..., zoom_slice[0], zoom_slice[1]][zoom_out_lab_mask] = ( - zoom_out_lab[zoom_out_lab_mask] - ) - - posData.allData_li[posData.frame_i]['labels'] = lab + + lab = posData.allData_li[posData.frame_i]["labels"] + lab[..., zoom_slice[0], zoom_slice[1]][zoom_out_lab_mask] = zoom_out_lab[ + zoom_out_lab_mask + ] + + posData.allData_li[posData.frame_i]["labels"] = lab self.get_data() self.store_data(autosave=False) self.updateAllImages() def segmWithPromptableModelActionTriggered(self): - self.blinker = qutils.QControlBlink( - self.magicPromptsToolButton, qparent=self - ) + self.blinker = qutils.QControlBlink(self.magicPromptsToolButton, qparent=self) self.blinker.start() def showInstructionsCustomPromptModel(self): modelFilePath = apps.addCustomPromptModelMessages(QParent=self) if modelFilePath is None: - self.logger.info('Adding custom promptable model process stopped.') + self.logger.info("Adding custom promptable model process stopped.") return - + myutils.store_custom_promptable_model_path(modelFilePath) - + msg = widgets.myMessageBox(wrapText=False) info_txt = html_utils.paragraph(f""" Done!

    @@ -326,45 +321,46 @@ def showInstructionsCustomPromptModel(self): Use the Magic prompts button (top toolbar) to use it.

    Have fun! """) - msg.information(self, 'Custom promptable model added', info_txt) + msg.information(self, "Custom promptable model added", info_txt) def startMagicPromptsWorkerAndWait( - self, image, df_points, model, model_segment_kwargs, - image_origin=(0, 0, 0), zoom_slice=None - ): - desc = ( - 'Running promptable segmentation model...' - ) + self, + image, + df_points, + model, + model_segment_kwargs, + image_origin=(0, 0, 0), + zoom_slice=None, + ): + desc = "Running promptable segmentation model..." self.logger.info(desc) posData = self.data[self.pos_i] - + self.progressWin = apps.QDialogWorkerProgress( title=desc, parent=self, pbarDesc=desc ) self.progressWin.mainPbar.setMaximum(0) self.progressWin.show(self.app) - + self.magicPromptsThread = QThread() self.magicPromptsWorker = workers.MagicPromptsWorker( - posData, image, df_points, model, model_segment_kwargs, + posData, + image, + df_points, + model, + model_segment_kwargs, image_origin=image_origin, - global_image=posData.img_data[posData.frame_i] - ) - - self.magicPromptsWorker.moveToThread( - self.magicPromptsThread - ) - - self.magicPromptsWorker.signals.finished.connect( - self.magicPromptsThread.quit + global_image=posData.img_data[posData.frame_i], ) + + self.magicPromptsWorker.moveToThread(self.magicPromptsThread) + + self.magicPromptsWorker.signals.finished.connect(self.magicPromptsThread.quit) self.magicPromptsWorker.signals.finished.connect( self.magicPromptsWorker.deleteLater ) - self.magicPromptsThread.finished.connect( - self.magicPromptsThread.deleteLater - ) - + self.magicPromptsThread.finished.connect(self.magicPromptsThread.deleteLater) + self.magicPromptsWorker.signals.critical.connect( self.magicPromptsWorkerCritical ) @@ -374,49 +370,50 @@ def startMagicPromptsWorkerAndWait( self.magicPromptsWorker.signals.progressBar.connect( self.workerUpdateProgressbar ) - self.magicPromptsWorker.signals.progress.connect( - self.workerProgress - ) + self.magicPromptsWorker.signals.progress.connect(self.workerProgress) self.magicPromptsWorker.signals.finished.connect( partial(self.magicPromptsWorkerFinished, zoom_slice=zoom_slice) ) - - self.magicPromptsThread.started.connect( - self.magicPromptsWorker.run - ) + + self.magicPromptsThread.started.connect(self.magicPromptsWorker.run) self.magicPromptsThread.start() - + self.magicPromptsWorkerLoop = QEventLoop() self.magicPromptsWorkerLoop.exec_() def viewSetMagicPromptModelParams( - self, - model_name, - acdcPromptSegment, - init_argspecs, - segment_argspecs, - help_url, - init_kwargs, - segment_kwargs, - toolbar - ): + self, + model_name, + acdcPromptSegment, + init_argspecs, + segment_argspecs, + help_url, + init_kwargs, + segment_kwargs, + toolbar, + ): posData = self.data[self.pos_i] - + init_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( init_argspecs, init_kwargs ) segment_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( segment_argspecs, segment_kwargs ) - + out = prompts.init_prompt_model_params( - posData, model_name, init_argspecs, segment_argspecs, - help_url=help_url, qparent=self, init_last_params=False + posData, + model_name, + init_argspecs, + segment_argspecs, + help_url=help_url, + qparent=self, + init_last_params=False, ) - win = out.get('win') + win = out.get("win") if win.cancel: return - + if win.model_kwargs != segment_kwargs or win.init_kwargs != init_kwargs: self._importInitMagicPromptModel( model_name, posData, win, acdcPromptSegment, toolbar diff --git a/cellacdc/mixins/main_menu.py b/cellacdc/mixins/main_menu.py index 84518a10e..4ce6c14aa 100644 --- a/cellacdc/mixins/main_menu.py +++ b/cellacdc/mixins/main_menu.py @@ -31,8 +31,8 @@ def gui_createMenuBar(self): fileMenu.addAction(self.saveAsAction) fileMenu.addAction(self.quickSaveAction) fileMenu.addSeparator() - - self.exportMenu = fileMenu.addMenu('Export') + + self.exportMenu = fileMenu.addMenu("Export") self.exportMenu.addAction(self.exportToVideoAction) self.exportMenu.addAction(self.exportToImageAction) fileMenu.addSeparator() @@ -41,7 +41,7 @@ def gui_createMenuBar(self): # Separator self.fileMenu.lastSeparator = fileMenu.addSeparator() fileMenu.addAction(self.exitAction) - + # Edit menu editMenu = menuBar.addMenu("&Edit") editMenu.addSeparator() @@ -70,32 +70,32 @@ def gui_createMenuBar(self): self.defaultRescaleIntensLutMenu ) howTexts = ( - 'Rescale each 2D image', - 'Rescale across z-stack', - 'Rescale across time frames', - 'Do no rescale, display raw image' + "Rescale each 2D image", + "Rescale across z-stack", + "Rescale across time frames", + "Do no rescale, display raw image", ) try: - self.defaultRescaleIntensHow = ( - self.df_settings.at['default_rescale_intens_how', 'value'] - ) + self.defaultRescaleIntensHow = self.df_settings.at[ + "default_rescale_intens_how", "value" + ] except Exception as err: self.defaultRescaleIntensHow = howTexts[0] - + for howText in howTexts: action = QAction(howText, self.defaultRescaleIntensLutMenu) action.setCheckable(True) if howText == self.defaultRescaleIntensHow: action.setChecked(True) - + self.defaultRescaleIntensActionGroup.addAction(action) self.defaultRescaleIntensLutMenu.addAction(action) - + ImageMenu.addAction(self.addScaleBarAction) ImageMenu.addAction(self.addTimestampAction) - - self.rescaleIntensMenu = ImageMenu.addMenu('Rescale intensities (LUT)') - + + self.rescaleIntensMenu = ImageMenu.addMenu("Rescale intensities (LUT)") + ImageMenu.addAction(self.preprocessAction) ImageMenu.addAction(self.combineChannelsAction) ImageMenu.addAction(self.saveLabColormapAction) @@ -108,32 +108,28 @@ def gui_createMenuBar(self): SegmMenu = menuBar.addMenu("&Segment") self.segmentMenu = SegmMenu SegmMenu.addSeparator() - self.segmSingleFrameMenu = SegmMenu.addMenu('Segment displayed frame') + self.segmSingleFrameMenu = SegmMenu.addMenu("Segment displayed frame") for action in self.segmActions: self.segmSingleFrameMenu.addAction(action) self.segmSingleFrameMenu.addSeparator() self.segmSingleFrameMenu.addAction(self.addCustomModelFrameAction) - self.segmVideoMenu = SegmMenu.addMenu('Segment multiple frames') + self.segmVideoMenu = SegmMenu.addMenu("Segment multiple frames") for action in self.segmActionsVideo: self.segmVideoMenu.addAction(action) self.segmVideoMenu.addSeparator() self.segmVideoMenu.addAction(self.addCustomModelVideoAction) - + self.segmWithPromptableModelMenu = SegmMenu.addMenu( - 'Segment with promptable model' - ) - - self.segmWithPromptableModelMenu.addAction( - self.segmWithPromptableModelAction + "Segment with promptable model" ) - + + self.segmWithPromptableModelMenu.addAction(self.segmWithPromptableModelAction) + self.segmWithPromptableModelMenu.addSeparator() - self.segmWithPromptableModelMenu.addAction( - self.addCustomPromptModelAction - ) + self.segmWithPromptableModelMenu.addAction(self.addCustomPromptModelAction) SegmMenu.addAction(self.EditSegForLostIDsSetSettings) SegmMenu.addAction(self.postProcessSegmAction) @@ -146,7 +142,7 @@ def gui_createMenuBar(self): self.trackingMenu = trackingMenu trackingMenu.addSeparator() selectTrackAlgoMenu = trackingMenu.addMenu( - 'Select real-time tracking algorithm' + "Select real-time tracking algorithm" ) for rtTrackerAction in self.trackingAlgosGroup.actions(): selectTrackAlgoMenu.addAction(rtTrackerAction) @@ -156,14 +152,10 @@ def gui_createMenuBar(self): trackingMenu.addAction(self.repeatTrackingMenuAction) trackingMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) - + if self.mainWin is not None: - trackingMenu.addAction( - self.mainWin.applyTrackingFromTableAction - ) - trackingMenu.addAction( - self.mainWin.applyTrackingFromTrackMateXMLAction - ) + trackingMenu.addAction(self.mainWin.applyTrackingFromTableAction) + trackingMenu.addAction(self.mainWin.applyTrackingFromTrackMateXMLAction) # Measurements menu measurementsMenu = menuBar.addMenu("&Measurements") @@ -189,7 +181,7 @@ def gui_createMenuBar(self): self.settingsMenu.addSeparator() # Mode menu (actions added when self.modeComboBox is created) - self.modeMenu = menuBar.addMenu('Mode') + self.modeMenu = menuBar.addMenu("Mode") self.modeMenu.menuAction().setVisible(False) # Help menu diff --git a/cellacdc/mixins/main_toolbar.py b/cellacdc/mixins/main_toolbar.py index a3516b9ae..12dba9747 100644 --- a/cellacdc/mixins/main_toolbar.py +++ b/cellacdc/mixins/main_toolbar.py @@ -12,6 +12,7 @@ from .actions import Actions + class MainToolbar(Actions): """Extracted from guiWin.""" @@ -40,9 +41,7 @@ def gui_createToolBars(self): # fileToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) fileToolBar.setMovable(False) - self.segmNdimIndicatorAction = fileToolBar.addWidget( - self.segmNdimIndicator - ) + self.segmNdimIndicatorAction = fileToolBar.addWidget(self.segmNdimIndicator) self.segmNdimIndicatorAction.setVisible(False) fileToolBar.addAction(self.newAction) fileToolBar.addAction(self.openFolderAction) @@ -65,21 +64,21 @@ def gui_createToolBars(self): # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) self.addToolBar(navigateToolBar) navigateToolBar.addAction(self.findIdAction) - + navigateToolBar.addWidget(self.zoomRectButton) self.slideshowButton = QToolButton(self) self.slideshowButton.setIcon(QIcon(":eye-plus.svg")) self.slideshowButton.setCheckable(True) - self.slideshowButton.setShortcut('Ctrl+W') + self.slideshowButton.setShortcut("Ctrl+W") navigateToolBar.addWidget(self.slideshowButton) - + navigateToolBar.addAction(self.autoPilotButton) - + # navigateToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) navigateToolBar.addAction(self.skipToNewIdAction) - - self.preprocessImageAction = QAction('Preprocess image', self) + + self.preprocessImageAction = QAction("Preprocess image", self) self.preprocessImageAction.setIcon(QIcon(":filter_image.svg")) navigateToolBar.addAction(self.preprocessImageAction) @@ -90,16 +89,14 @@ def gui_createToolBars(self): self.overlayButtonAction = navigateToolBar.addWidget(self.overlayButton) # self.checkableButtons.append(self.overlayButton) # self.checkableQButtonsGroup.addButton(self.overlayButton) - + self.countObjsButton = QToolButton(self) self.countObjsButton.setIcon(QIcon(":count_objects.svg")) self.countObjsButton.setCheckable(True) - self.countObjsButton.setShortcut('Ctrl+Shift+C') - self.countObjsButtonAction = navigateToolBar.addWidget( - self.countObjsButton - ) + self.countObjsButton.setShortcut("Ctrl+Shift+C") + self.countObjsButtonAction = navigateToolBar.addWidget(self.countObjsButton) - self.togglePointsLayerAction = QAction('Activate points layer', self) + self.togglePointsLayerAction = QAction("Activate points layer", self) self.togglePointsLayerAction.setCheckable(True) self.togglePointsLayerAction.setIcon(QIcon(":pointsLayer.svg")) navigateToolBar.addAction(self.togglePointsLayerAction) @@ -123,7 +120,7 @@ def gui_createToolBars(self): # fluorescence image color widget colorsToolBar = widgets.ToolBar("Colors", self) - self.overlayColorButton = pg.ColorButton(self, color=(230,230,230)) + self.overlayColorButton = pg.ColorButton(self, color=(230, 230, 230)) self.overlayColorButton.setDisabled(True) colorsToolBar.addWidget(self.overlayColorButton) @@ -143,21 +140,18 @@ def gui_createToolBars(self): self.assignBudMothButton = QToolButton(self) self.assignBudMothButton.setIcon(QIcon(":assign-motherbud.svg")) self.assignBudMothButton.setCheckable(True) - self.assignBudMothButton.setShortcut('A') + self.assignBudMothButton.setShortcut("A") self.assignBudMothButton.setVisible(False) - self.assignBudMothButton.action = ccaToolBar.addWidget( - self.assignBudMothButton - ) + self.assignBudMothButton.action = ccaToolBar.addWidget(self.assignBudMothButton) self.checkableButtons.append(self.assignBudMothButton) self.checkableQButtonsGroup.addButton(self.assignBudMothButton) self.functionsNotTested3D.append(self.assignBudMothButton) - # Set is_history_known button self.setIsHistoryKnownButton = QToolButton(self) self.setIsHistoryKnownButton.setIcon(QIcon(":history.svg")) self.setIsHistoryKnownButton.setCheckable(True) - self.setIsHistoryKnownButton.setShortcut('U') + self.setIsHistoryKnownButton.setShortcut("U") self.setIsHistoryKnownButton.setVisible(False) self.setIsHistoryKnownButton.action = ccaToolBar.addWidget( self.setIsHistoryKnownButton @@ -165,7 +159,7 @@ def gui_createToolBars(self): self.checkableButtons.append(self.setIsHistoryKnownButton) self.checkableQButtonsGroup.addButton(self.setIsHistoryKnownButton) self.functionsNotTested3D.append(self.setIsHistoryKnownButton) - + ccaToolBar.addAction(self.assignBudMothAutoAction) ccaToolBar.addAction(self.editCcaToolAction) ccaToolBar.addAction(self.reInitCcaAction) @@ -178,9 +172,9 @@ def gui_createToolBars(self): # Edit toolbar editToolBar = widgets.ToolBar("Edit", self) editToolBar.setContextMenuPolicy(Qt.PreventContextMenu) - + self.addToolBar(editToolBar) - + self.manulAnnotToolButtons = set() self.brushButton = QToolButton(self) @@ -190,7 +184,7 @@ def gui_createToolBars(self): self.checkableButtons.append(self.brushButton) self.LeftClickButtons.append(self.brushButton) self.brushButton.keyPressShortcut = Qt.Key_B - self.widgetsWithShortcut['Brush'] = self.brushButton + self.widgetsWithShortcut["Brush"] = self.brushButton self.manulAnnotToolButtons.add(self.brushButton) self.eraserButton = QToolButton(self) @@ -198,7 +192,7 @@ def gui_createToolBars(self): self.eraserButton.setCheckable(True) editToolBar.addWidget(self.eraserButton) self.eraserButton.keyPressShortcut = Qt.Key_X - self.widgetsWithShortcut['Eraser'] = self.eraserButton + self.widgetsWithShortcut["Eraser"] = self.eraserButton self.checkableButtons.append(self.eraserButton) self.LeftClickButtons.append(self.eraserButton) self.manulAnnotToolButtons.add(self.eraserButton) @@ -206,131 +200,117 @@ def gui_createToolBars(self): self.curvToolButton = QToolButton(self) self.curvToolButton.setIcon(QIcon(":curvature-tool.svg")) self.curvToolButton.setCheckable(True) - self.curvToolButton.setShortcut('C') + self.curvToolButton.setShortcut("C") self.curvToolButton.action = editToolBar.addWidget(self.curvToolButton) self.LeftClickButtons.append(self.curvToolButton) # self.functionsNotTested3D.append(self.curvToolButton) - self.widgetsWithShortcut['Curvature tool'] = self.curvToolButton + self.widgetsWithShortcut["Curvature tool"] = self.curvToolButton # self.checkableButtons.append(self.curvToolButton) self.manulAnnotToolButtons.add(self.curvToolButton) self.wandToolButton = QToolButton(self) self.wandToolButton.setIcon(QIcon(":magic_wand.svg")) self.wandToolButton.setCheckable(True) - self.wandToolButton.setShortcut('Ctrl+D') + self.wandToolButton.setShortcut("Ctrl+D") self.wandToolButton.action = editToolBar.addWidget(self.wandToolButton) self.LeftClickButtons.append(self.wandToolButton) self.checkableButtons.append(self.eraserButton) - self.widgetsWithShortcut['Magic wand'] = self.wandToolButton - + self.widgetsWithShortcut["Magic wand"] = self.wandToolButton + self.magicPromptsToolButton = QToolButton(self) self.magicPromptsToolButton.setIcon(QIcon(":magic-prompts.svg")) self.magicPromptsToolButton.setCheckable(True) - self.magicPromptsToolButton.setShortcut('W') + self.magicPromptsToolButton.setShortcut("W") self.magicPromptsToolButton.action = editToolBar.addWidget( self.magicPromptsToolButton ) - self.widgetsWithShortcut['Magic prompts'] = self.magicPromptsToolButton - + self.widgetsWithShortcut["Magic prompts"] = self.magicPromptsToolButton + self.drawClearRegionButton = QToolButton(self) self.drawClearRegionButton.setCheckable(True) self.drawClearRegionButton.setIcon(QIcon(":clear_freehand_region.svg")) - self.widgetsWithShortcut['Clear freehand region'] = ( - self.drawClearRegionButton - ) + self.widgetsWithShortcut["Clear freehand region"] = self.drawClearRegionButton self.toolsActiveInProj3Dsegm.add(self.drawClearRegionButton) - + self.checkableButtons.append(self.drawClearRegionButton) self.LeftClickButtons.append(self.drawClearRegionButton) - - self.drawClearRegionAction = editToolBar.addWidget( - self.drawClearRegionButton - ) - self.widgetsWithShortcut['Annotate mother/daughter pairing'] = ( + self.drawClearRegionAction = editToolBar.addWidget(self.drawClearRegionButton) + + self.widgetsWithShortcut["Annotate mother/daughter pairing"] = ( self.assignBudMothButton ) - self.widgetsWithShortcut['Annotate unknown history'] = ( + self.widgetsWithShortcut["Annotate unknown history"] = ( self.setIsHistoryKnownButton ) - + self.copyLostObjButton = QToolButton(self) self.copyLostObjButton.setIcon(QIcon(":copyContour.svg")) self.copyLostObjButton.setCheckable(True) - self.copyLostObjButton.setShortcut('V') - self.copyLostObjButton.action = editToolBar.addWidget( - self.copyLostObjButton - ) + self.copyLostObjButton.setShortcut("V") + self.copyLostObjButton.action = editToolBar.addWidget(self.copyLostObjButton) self.checkableButtons.append(self.copyLostObjButton) self.checkableQButtonsGroup.addButton(self.copyLostObjButton) - self.widgetsWithShortcut['Copy lost object contour'] = ( - self.copyLostObjButton - ) + self.widgetsWithShortcut["Copy lost object contour"] = self.copyLostObjButton self.functionsNotTested3D.append(self.copyLostObjButton) - + self.labelRoiButton = widgets.rightClickToolButton(parent=self) self.labelRoiButton.setIcon(QIcon(":label_roi.svg")) self.labelRoiButton.setCheckable(True) - self.labelRoiButton.setShortcut('L') + self.labelRoiButton.setShortcut("L") self.labelRoiButton.action = editToolBar.addWidget(self.labelRoiButton) self.LeftClickButtons.append(self.labelRoiButton) self.checkableButtons.append(self.labelRoiButton) self.checkableQButtonsGroup.addButton(self.labelRoiButton) - self.widgetsWithShortcut['Label ROI'] = self.labelRoiButton + self.widgetsWithShortcut["Label ROI"] = self.labelRoiButton # self.functionsNotTested3D.append(self.labelRoiButton) - + self.manualAnnotPastButton = QToolButton(self) self.manualAnnotPastButton.setIcon(QIcon(":lock_id_annotate_future.svg")) self.manualAnnotPastButton.setCheckable(True) - self.manualAnnotPastButton.setShortcut('Y') + self.manualAnnotPastButton.setShortcut("Y") self.manualAnnotPastButton.action = editToolBar.addWidget( self.manualAnnotPastButton ) self.checkableButtons.append(self.manualAnnotPastButton) - self.widgetsWithShortcut['Lock ID and annotate single object'] = ( + self.widgetsWithShortcut["Lock ID and annotate single object"] = ( self.manualAnnotPastButton ) self.functionsNotTested3D.append(self.manualAnnotPastButton) self.manulAnnotToolButtons.add(self.manualAnnotPastButton) - self.segmentToolAction = QAction('Segment with last used model', self) + self.segmentToolAction = QAction("Segment with last used model", self) self.segmentToolAction.setIcon(QIcon(":segment.svg")) - self.segmentToolAction.setShortcut('R') - self.widgetsWithShortcut['Repeat segmentation'] = self.segmentToolAction + self.segmentToolAction.setShortcut("R") + self.widgetsWithShortcut["Repeat segmentation"] = self.segmentToolAction editToolBar.addAction(self.segmentToolAction) self.segForLostIDsButton = QToolButton(self) self.segForLostIDsButton.setIcon(QIcon(":segForLostIDs.svg")) - self.segForLostIDsAction = editToolBar.addWidget( - self.segForLostIDsButton - ) - self.segForLostIDsButton.clicked.connect( - self.segForLostIDsButtonClicked - ) + self.segForLostIDsAction = editToolBar.addWidget(self.segForLostIDsButton) + self.segForLostIDsButton.clicked.connect(self.segForLostIDsButtonClicked) # self.SegForLostIDsButton.setShortcut('U') # self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.SegForLostIDsButton - + self.manualBackgroundButton = QToolButton(self) self.manualBackgroundButton.setIcon(QIcon(":manual_background.svg")) self.manualBackgroundButton.setCheckable(True) - self.manualBackgroundButton.setShortcut('G') + self.manualBackgroundButton.setShortcut("G") self.LeftClickButtons.append(self.manualBackgroundButton) self.checkableButtons.append(self.manualBackgroundButton) self.checkableQButtonsGroup.addButton(self.manualBackgroundButton) - self.widgetsWithShortcut['Manual background'] = self.manualBackgroundButton - - self.manualBackgroundAction = editToolBar.addWidget( - self.manualBackgroundButton - ) - + self.widgetsWithShortcut["Manual background"] = self.manualBackgroundButton + + self.manualBackgroundAction = editToolBar.addWidget(self.manualBackgroundButton) + self.delObjsOutSegmMaskAction = QAction( - QIcon(":del_objs_out_segm.svg"), - 'Select a segmentation file and delete all objects on the background', - self + QIcon(":del_objs_out_segm.svg"), + "Select a segmentation file and delete all objects on the background", + self, ) - self.delObjsOutSegmMaskAction.setShortcut('I') - self.widgetsWithShortcut['Delete all objects outside segm'] = ( + self.delObjsOutSegmMaskAction.setShortcut("I") + self.widgetsWithShortcut["Delete all objects outside segm"] = ( self.delObjsOutSegmMaskAction ) editToolBar.addAction(self.delObjsOutSegmMaskAction) @@ -338,96 +318,98 @@ def gui_createToolBars(self): self.hullContToolButton = QToolButton(self) self.hullContToolButton.setIcon(QIcon(":hull.svg")) self.hullContToolButton.setCheckable(True) - self.hullContToolButton.setShortcut('O') + self.hullContToolButton.setShortcut("O") self.hullContToolButton.action = editToolBar.addWidget(self.hullContToolButton) self.checkableButtons.append(self.hullContToolButton) self.checkableQButtonsGroup.addButton(self.hullContToolButton) self.functionsNotTested3D.append(self.hullContToolButton) - self.widgetsWithShortcut['Hull contour'] = self.hullContToolButton + self.widgetsWithShortcut["Hull contour"] = self.hullContToolButton self.fillHolesToolButton = QToolButton(self) self.fillHolesToolButton.setIcon(QIcon(":fill_holes.svg")) self.fillHolesToolButton.setCheckable(True) - self.fillHolesToolButton.setShortcut('F') + self.fillHolesToolButton.setShortcut("F") self.fillHolesToolButton.action = editToolBar.addWidget( self.fillHolesToolButton ) self.checkableButtons.append(self.fillHolesToolButton) self.checkableQButtonsGroup.addButton(self.fillHolesToolButton) self.functionsNotTested3D.append(self.fillHolesToolButton) - self.widgetsWithShortcut['Fill holes'] = self.fillHolesToolButton + self.widgetsWithShortcut["Fill holes"] = self.fillHolesToolButton self.moveLabelToolButton = QToolButton(self) self.moveLabelToolButton.setIcon(QIcon(":moveLabel.svg")) self.moveLabelToolButton.setCheckable(True) - self.moveLabelToolButton.setShortcut('P') - self.moveLabelToolButton.action = editToolBar.addWidget(self.moveLabelToolButton) + self.moveLabelToolButton.setShortcut("P") + self.moveLabelToolButton.action = editToolBar.addWidget( + self.moveLabelToolButton + ) self.checkableButtons.append(self.moveLabelToolButton) self.checkableQButtonsGroup.addButton(self.moveLabelToolButton) - self.widgetsWithShortcut['Move label'] = self.moveLabelToolButton + self.widgetsWithShortcut["Move label"] = self.moveLabelToolButton self.expandLabelToolButton = QToolButton(self) self.expandLabelToolButton.setIcon(QIcon(":expandLabel.svg")) self.expandLabelToolButton.setCheckable(True) - self.expandLabelToolButton.setShortcut('E') - self.expandLabelToolButton.action = editToolBar.addWidget(self.expandLabelToolButton) + self.expandLabelToolButton.setShortcut("E") + self.expandLabelToolButton.action = editToolBar.addWidget( + self.expandLabelToolButton + ) self.expandLabelToolButton.hide() self.checkableButtons.append(self.expandLabelToolButton) self.LeftClickButtons.append(self.expandLabelToolButton) self.checkableQButtonsGroup.addButton(self.expandLabelToolButton) - self.widgetsWithShortcut['Expand/shrink label'] = self.expandLabelToolButton + self.widgetsWithShortcut["Expand/shrink label"] = self.expandLabelToolButton self.editIDbutton = QToolButton(self) self.editIDbutton.setIcon(QIcon(":edit-id.svg")) self.editIDbutton.setCheckable(True) - self.editIDbutton.setShortcut('N') + self.editIDbutton.setShortcut("N") editToolBar.addWidget(self.editIDbutton) self.checkableButtons.append(self.editIDbutton) self.checkableQButtonsGroup.addButton(self.editIDbutton) - self.widgetsWithShortcut['Edit ID'] = self.editIDbutton + self.widgetsWithShortcut["Edit ID"] = self.editIDbutton self.separateBudButton = QToolButton(self) self.separateBudButton.setIcon(QIcon(":separate-bud.svg")) self.separateBudButton.setCheckable(True) - self.separateBudButton.setShortcut('S') + self.separateBudButton.setShortcut("S") self.separateBudButton.action = editToolBar.addWidget(self.separateBudButton) self.checkableButtons.append(self.separateBudButton) self.checkableQButtonsGroup.addButton(self.separateBudButton) # self.functionsNotTested3D.append(self.separateBudButton) - self.widgetsWithShortcut['Separate objects'] = self.separateBudButton + self.widgetsWithShortcut["Separate objects"] = self.separateBudButton self.mergeIDsButton = QToolButton(self) self.mergeIDsButton.setIcon(QIcon(":merge-IDs.svg")) self.mergeIDsButton.setCheckable(True) - self.mergeIDsButton.setShortcut('M') + self.mergeIDsButton.setShortcut("M") self.mergeIDsButton.action = editToolBar.addWidget(self.mergeIDsButton) self.checkableButtons.append(self.mergeIDsButton) self.checkableQButtonsGroup.addButton(self.mergeIDsButton) # self.functionsNotTested3D.append(self.mergeIDsButton) - self.widgetsWithShortcut['Merge objects'] = self.mergeIDsButton + self.widgetsWithShortcut["Merge objects"] = self.mergeIDsButton self.keepIDsButton = QToolButton(self) self.keepIDsButton.setIcon(QIcon(":keep_objects.svg")) self.keepIDsButton.setCheckable(True) self.keepIDsButton.action = editToolBar.addWidget(self.keepIDsButton) - self.keepIDsButton.setShortcut('K') + self.keepIDsButton.setShortcut("K") self.checkableButtons.append(self.keepIDsButton) self.checkableQButtonsGroup.addButton(self.keepIDsButton) # self.functionsNotTested3D.append(self.keepIDsButton) - self.widgetsWithShortcut['Select objects to keep'] = self.keepIDsButton + self.widgetsWithShortcut["Select objects to keep"] = self.keepIDsButton self.whitelistIDsButton = QToolButton(self) self.whitelistIDsButton.setIcon(QIcon(":whitelist.svg")) self.whitelistIDsButton.setCheckable(True) - self.whitelistIDsButton.action = editToolBar.addWidget( - self.whitelistIDsButton - ) - self.whitelistIDsButton.setShortcut('Ctrl+K') + self.whitelistIDsButton.action = editToolBar.addWidget(self.whitelistIDsButton) + self.whitelistIDsButton.setShortcut("Ctrl+K") self.checkableButtons.append(self.whitelistIDsButton) self.checkableQButtonsGroup.addButton(self.whitelistIDsButton) self.LeftClickButtons.append(self.whitelistIDsButton) # self.functionsNotTested3D.append(self.whitelistIDsButton) - self.widgetsWithShortcut['Select objects to add to a tracking whitelist'] = ( + self.widgetsWithShortcut["Select objects to add to a tracking whitelist"] = ( self.whitelistIDsButton ) @@ -443,37 +425,35 @@ def gui_createToolBars(self): self.manualTrackingButton = QToolButton(self) self.manualTrackingButton.setIcon(QIcon(":manual_tracking.svg")) self.manualTrackingButton.setCheckable(True) - self.manualTrackingButton.setShortcut('T') + self.manualTrackingButton.setShortcut("T") self.checkableQButtonsGroup.addButton(self.manualTrackingButton) self.checkableButtons.append(self.manualTrackingButton) - self.widgetsWithShortcut['Manual tracking'] = self.manualTrackingButton + self.widgetsWithShortcut["Manual tracking"] = self.manualTrackingButton self.ripCellButton = QToolButton(self) self.ripCellButton.setIcon(QIcon(":rip.svg")) self.ripCellButton.setCheckable(True) - self.ripCellButton.setShortcut('D') + self.ripCellButton.setShortcut("D") self.ripCellButton.action = editToolBar.addWidget(self.ripCellButton) self.checkableButtons.append(self.ripCellButton) self.checkableQButtonsGroup.addButton(self.ripCellButton) self.functionsNotTested3D.append(self.ripCellButton) - self.widgetsWithShortcut['Annotate cell as dead'] = self.ripCellButton + self.widgetsWithShortcut["Annotate cell as dead"] = self.ripCellButton editToolBar.addAction(self.addDelRoiAction) # editToolBar.addAction(self.addDelPolyLineRoiAction) - + self.addDelPolyLineRoiAction = editToolBar.addWidget( self.addDelPolyLineRoiButton ) - self.addDelPolyLineRoiAction.roiType = 'polyline' - + self.addDelPolyLineRoiAction.roiType = "polyline" + editToolBar.addAction(self.delBorderObjAction) self.delBorderObjAction.button = editToolBar.widgetForAction( self.delBorderObjAction ) editToolBar.addAction(self.delNewObjAction) - self.delNewObjAction.button = editToolBar.widgetForAction( - self.delNewObjAction - ) + self.delNewObjAction.button = editToolBar.widgetForAction(self.delNewObjAction) self.addDelRoiAction.toolbar = editToolBar self.functionsNotTested3D.append(self.addDelRoiAction) @@ -483,15 +463,13 @@ def gui_createToolBars(self): self.delBorderObjAction.toolbar = editToolBar self.functionsNotTested3D.append(self.delBorderObjAction) - + self.delNewObjAction.toolbar = editToolBar # self.functionsNotTested3D.append(self.delNewObjAction) so id this doesnt work in 3d i dont know anymore editToolBar.addAction(self.repeatTrackingAction) - - self.manualTrackingAction = editToolBar.addWidget( - self.manualTrackingButton - ) + + self.manualTrackingAction = editToolBar.addWidget(self.manualTrackingButton) self.functionsNotTested3D.append(self.repeatTrackingAction) self.functionsNotTested3D.append(self.manualTrackingAction) @@ -504,10 +482,9 @@ def gui_createToolBars(self): self.reinitLastSegmFrameAction.toolbar = editToolBar self.functionsNotTested3D.append(self.reinitLastSegmFrameAction) - self.editLin_TreeBar = widgets.ToolBar("Lin Tree Edit", self) self.editLin_TreeBar.setContextMenuPolicy(Qt.PreventContextMenu) - + self.addToolBar(self.editLin_TreeBar) self.editLin_TreeGroup = QButtonGroup() self.editLin_TreeGroup.setExclusive(True) @@ -517,46 +494,53 @@ def gui_createToolBars(self): self.findNextMotherButton.setCheckable(True) self.editLin_TreeBar.addWidget(self.findNextMotherButton) self.editLin_TreeGroup.addButton(self.findNextMotherButton) - self.findNextMotherButton.setShortcut('F') - self.widgetsWithShortcut['Find next potential mother (lineage tree)'] = self.findNextMotherButton + self.findNextMotherButton.setShortcut("F") + self.widgetsWithShortcut["Find next potential mother (lineage tree)"] = ( + self.findNextMotherButton + ) self.unknownLineageButton = QToolButton(self) self.unknownLineageButton.setIcon(QIcon(":history.svg")) self.unknownLineageButton.setCheckable(True) self.editLin_TreeBar.addWidget(self.unknownLineageButton) self.editLin_TreeGroup.addButton(self.unknownLineageButton) - self.unknownLineageButton.setShortcut('U') - self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.unknownLineageButton + self.unknownLineageButton.setShortcut("U") + self.widgetsWithShortcut["Unknown lineage (lineage tree)"] = ( + self.unknownLineageButton + ) self.noToolLinTreeButton = QToolButton(self) self.noToolLinTreeButton.setIcon(QIcon(":arrow_cursor.svg")) self.noToolLinTreeButton.setCheckable(True) self.editLin_TreeBar.addWidget(self.noToolLinTreeButton) self.editLin_TreeGroup.addButton(self.noToolLinTreeButton) - self.noToolLinTreeButton.setShortcut('N') - self.widgetsWithShortcut['No tool (lineage tree)'] = self.noToolLinTreeButton + self.noToolLinTreeButton.setShortcut("N") + self.widgetsWithShortcut["No tool (lineage tree)"] = self.noToolLinTreeButton self.propagateLinTreeButton = QToolButton(self) self.propagateLinTreeButton.setIcon(QIcon(":compute.svg")) self.editLin_TreeBar.addWidget(self.propagateLinTreeButton) - self.propagateLinTreeButton.setShortcut('P') - self.widgetsWithShortcut['Propagate (lineage tree)'] = self.propagateLinTreeButton + self.propagateLinTreeButton.setShortcut("P") + self.widgetsWithShortcut["Propagate (lineage tree)"] = ( + self.propagateLinTreeButton + ) self.propagateLinTreeButton.clicked.connect(self.propagateLinTreeAction) self.viewLinTreeInfoButton = QToolButton(self) self.viewLinTreeInfoButton.setIcon(QIcon(":addCustomAnnotation.svg")) self.editLin_TreeBar.addWidget(self.viewLinTreeInfoButton) - self.viewLinTreeInfoButton.setShortcut('S') - self.widgetsWithShortcut['View Changes (lineage tree)'] = self.viewLinTreeInfoButton + self.viewLinTreeInfoButton.setShortcut("S") + self.widgetsWithShortcut["View Changes (lineage tree)"] = ( + self.viewLinTreeInfoButton + ) self.viewLinTreeInfoButton.clicked.connect(self.viewLinTreeInfoAction) - modes_available = [ - 'Segmentation and Tracking', - 'Cell cycle analysis', - 'Viewer', - 'Custom annotations', - 'Normal division: Lineage tree' + "Segmentation and Tracking", + "Cell cycle analysis", + "Viewer", + "Custom annotations", + "Normal division: Lineage tree", ] self.modeItems = modes_available @@ -566,7 +550,7 @@ def gui_createToolBars(self): action.setCheckable(True) self.modeActionGroup.addAction(action) self.modeMenu.addAction(action) - if mode == 'Viewer': + if mode == "Viewer": action.setChecked(True) self.editToolBar = editToolBar diff --git a/cellacdc/mixins/measurements.py b/cellacdc/mixins/measurements.py index 2a99ad883..2066c6bf3 100644 --- a/cellacdc/mixins/measurements.py +++ b/cellacdc/mixins/measurements.py @@ -17,7 +17,7 @@ def _setMetrics(self, measurementsWin): for ch in self._measurements_kernel.chNamesToProcess: if ch not in self.notLoadedChNames: continue - + success = self.loadFluo_cb(fluo_channels=[ch]) if not success: continue @@ -36,22 +36,20 @@ def addCustomMetric(self, checked=False): txt = measurements.add_metrics_instructions() metrics_path = measurements.metrics_path msg = widgets.myMessageBox() - msg.addShowInFileManagerButton(metrics_path, 'Show example...') - title = 'Add custom metrics instructions' - msg.information(self, title, txt, buttonsTexts=('Ok',)) + msg.addShowInFileManagerButton(metrics_path, "Show example...") + title = "Add custom metrics instructions" + msg.information(self, title, txt, buttonsTexts=("Ok",)) def initMetricsToSave(self, posData): self._measurements_kernel._init_metrics_to_save(posData) def initMetrics(self): - self.logger.info('Initializing measurements...') + self.logger.info("Initializing measurements...") posData = self.data[self.pos_i] self._measurements_kernel = cli.ComputeMeasurementsKernel( self.logger, self.log_path, False ) - self._measurements_kernel.init_args( - posData.chNames, posData.getSegmEndname() - ) + self._measurements_kernel.init_args(posData.chNames, posData.getSegmEndname()) self._measurements_kernel._init_metrics(posData, self.isSegm3D) def showSetMeasurements(self, checked=False, qparent=None): @@ -64,7 +62,7 @@ def showSetMeasurements(self, checked=False, qparent=None): try: df_favourite_funcs = pd.read_csv(favourite_func_metrics_csv_path) - favourite_funcs = df_favourite_funcs['favourite_func_name'].to_list() + favourite_funcs = df_favourite_funcs["favourite_func_name"].to_list() except Exception as e: favourite_funcs = None @@ -72,10 +70,10 @@ def showSetMeasurements(self, checked=False, qparent=None): allPos_acdc_df_cols = set() for _posData in self.data: for frame_i, data_dict in enumerate(_posData.allData_li): - acdc_df = data_dict['acdc_df'] + acdc_df = data_dict["acdc_df"] if acdc_df is None: continue - + allPos_acdc_df_cols.update(acdc_df.columns) loadedChNames = posData.setLoadedChannelNames(returnList=True) posData.fluo_data_dict.pop(self.user_ch_name, None) @@ -84,14 +82,18 @@ def showSetMeasurements(self, checked=False, qparent=None): notLoadedChNames = [c for c in self.ch_names if c not in loadedChNames] self.notLoadedChNames = notLoadedChNames self.measurementsWin = apps.SetMeasurementsDialog( - loadedChNames, notLoadedChNames, posData.SizeZ > 1, self.isSegm3D, - favourite_funcs=favourite_funcs, + loadedChNames, + notLoadedChNames, + posData.SizeZ > 1, + self.isSegm3D, + favourite_funcs=favourite_funcs, allPos_acdc_df_cols=list(allPos_acdc_df_cols), - acdc_df_path=posData.images_path, posData=posData, + acdc_df_path=posData.images_path, + posData=posData, addCombineMetricCallback=self.addCombineMetric, - allPosData=self.data, - parent=qparent, - state=self.setMeasWinState + allPosData=self.data, + parent=qparent, + state=self.setMeasWinState, ) self.measurementsWin.sigCancel.connect(self.setMeasurementsCancelled) self.measurementsWin.sigClosed.connect(self.setMeasurements) @@ -103,44 +105,42 @@ def setMeasurementsCancelled(self): def setMeasurements(self): posData = self.data[self.pos_i] if self.measurementsWin.delExistingCols: - self.logger.info('Removing existing unchecked measurements...') + self.logger.info("Removing existing unchecked measurements...") delCols = self.measurementsWin.existingUncheckedColnames delRps = self.measurementsWin.existingUncheckedRps - delCols_format = [f' * {colname}' for colname in delCols] - delRps_format = [f' * {colname}' for colname in delRps] + delCols_format = [f" * {colname}" for colname in delCols] + delRps_format = [f" * {colname}" for colname in delRps] delCols_format.extend(delRps_format) - delCols_format = '\n'.join(delCols_format) + delCols_format = "\n".join(delCols_format) self.logger.info(delCols_format) for _posData in self.data: for frame_i, data_dict in enumerate(_posData.allData_li): - acdc_df = data_dict['acdc_df'] + acdc_df = data_dict["acdc_df"] if acdc_df is None: continue - - acdc_df = acdc_df.drop(columns=delCols, errors='ignore') + + acdc_df = acdc_df.drop(columns=delCols, errors="ignore") for col_rp in delRps: - drop_df_rp = acdc_df.filter(regex=fr'{col_rp}.*', axis=1) + drop_df_rp = acdc_df.filter(regex=rf"{col_rp}.*", axis=1) drop_cols_rp = drop_df_rp.columns - acdc_df = acdc_df.drop(columns=drop_cols_rp, errors='ignore') - _posData.allData_li[frame_i]['acdc_df'] = acdc_df + acdc_df = acdc_df.drop(columns=drop_cols_rp, errors="ignore") + _posData.allData_li[frame_i]["acdc_df"] = acdc_df self.setMeasWinState = self.measurementsWin.state() - self.logger.info('Setting measurements...') + self.logger.info("Setting measurements...") self._setMetrics(self.measurementsWin) - self.logger.info('Metrics successfully set.') + self.logger.info("Metrics successfully set.") self.measurementsWin = None def saveCombineMetricsToPosData(self, window): for posData in self.data: equationsDict, isMixedChannels = window.getEquationsDict() for newColName, equation in equationsDict.items(): - posData.addEquationCombineMetrics( - equation, newColName, isMixedChannels - ) + posData.addEquationCombineMetrics(equation, newColName, isMixedChannels) posData.saveCombineMetrics() - + if self.measurementsWin is None: return - + self.measurementsWinState = self.measurementsWin.state() self.measurementsWin.close() self.showSetMeasurements() diff --git a/cellacdc/mixins/mode_controls.py b/cellacdc/mixins/mode_controls.py index 3b10dd0b6..113973d49 100644 --- a/cellacdc/mixins/mode_controls.py +++ b/cellacdc/mixins/mode_controls.py @@ -8,14 +8,15 @@ from .tool_activation import ToolActivation + class ModeControls(ToolActivation): """Extracted from guiWin.""" def blinkModeComboBox(self): if self.flag: - self.modeComboBox.setStyleSheet('background-color: orange') + self.modeComboBox.setStyleSheet("background-color: orange") else: - self.modeComboBox.setStyleSheet('background-color: none') + self.modeComboBox.setStyleSheet("background-color: none") self.flag = not self.flag def changeMode(self, text): @@ -26,28 +27,28 @@ def changeMode(self, text): mode = text prevMode = self.modeComboBox.previousText() self.annotateToolbar.setVisible(False) - if prevMode != 'Viewer': + if prevMode != "Viewer": self.store_data(autosave=True) - + self.copyLostObjButton.setChecked(False) self.stopCcaIntegrityCheckerWorker() self.setAutoSaveSegmentationEnabled(False) self.setAutoSaveAnnotationsEnabled(False) - if prevMode == 'Normal division: Lineage tree': + if prevMode == "Normal division: Lineage tree": self.askLineageTreeChanges() self.lineage_tree = None self.editLin_TreeBar.setVisible(False) self.uncheckAllButtonsFromButtonGroup(self.editLin_TreeGroup) - elif prevMode == 'Cell cycle analysis': + elif prevMode == "Cell cycle analysis": self.setEnabledCcaToolbar(enabled=False) - if mode == 'Segmentation and Tracking': + if mode == "Segmentation and Tracking": self.setAutoSaveSegmentationEnabled(True) self.setSwitchViewedPlaneDisabled(True) self.trackingMenu.setDisabled(False) self.modeToolBar.setVisible(True) - self.lastTrackedFrameLabel.setText('') + self.lastTrackedFrameLabel.setText("") self.initSegmTrackMode() self.setEnabledEditToolbarButton(enabled=True) self.addExistingDelROIs() @@ -60,7 +61,7 @@ def changeMode(self, text): self.store_cca_df() self.restorePrevAnnotOptions() self.whitelistViewOGIDs(False) - elif mode == 'Cell cycle analysis': + elif mode == "Cell cycle analysis": self.setAutoSaveAnnotationsEnabled(True) self.setSwitchViewedPlaneDisabled(True) self.startCcaIntegrityCheckerWorker() @@ -81,7 +82,7 @@ def changeMode(self, text): self.removeAlldelROIsCurrentFrame() self.setAnnotOptionsCcaMode() self.clearGhost() - elif mode == 'Viewer': + elif mode == "Viewer": self.autoSaveTimer.stop() self.setSwitchViewedPlaneDisabled(False) self.modeToolBar.setVisible(True) @@ -95,7 +96,7 @@ def changeMode(self, text): self.navSpinBox.setMaximum(posData.SizeT) self.clearGhost() self.computeAllContours() - elif mode == 'Custom annotations': + elif mode == "Custom annotations": self.setAutoSaveAnnotationsEnabled(True) self.setSwitchViewedPlaneDisabled(True) self.modeToolBar.setVisible(True) @@ -108,14 +109,16 @@ def changeMode(self, text): self.clearGhost() self.doCustomAnnotation(0) self.computeAllContours() - elif mode == 'Snapshot': + elif mode == "Snapshot": self.setAutoSaveAnnotationsEnabled(True) self.setSwitchViewedPlaneDisabled(False) self.reconnectUndoRedo() self.setEnabledSnapshotMode() self.doCustomAnnotation(0) self.clearComputedContours() - elif mode == 'Normal division: Lineage tree': # Mode activation for lineage tree + elif ( + mode == "Normal division: Lineage tree" + ): # Mode activation for lineage tree # self.startLinTreeIntegrityCheckerWorker() # need to replace (postponed) proceed = self.initLinTree() self.setEnabledCcaToolbar(enabled=False) @@ -134,7 +137,7 @@ def changeMode(self, text): self.setAnnotOptionsLin_treeMode() self.clearGhost() self.editLin_TreeBar.setVisible(True) - + self.disableNonFunctionalButtons() def changeModeFromMenu(self, action): @@ -145,7 +148,7 @@ def clearComboBoxFocus(self, mode): self.sender().clearFocus() try: self.timer.stop() - self.modeComboBox.setStyleSheet('background-color: none') + self.modeComboBox.setStyleSheet("background-color: none") except Exception as e: pass @@ -176,12 +179,12 @@ def enableSizeSpinbox(self, enabled): self.brushSizeAction.setVisible(enabled) self.brushAutoFillAction.setVisible(enabled) self.brushAutoHideAction.setVisible(enabled) - self.brushEraserToolBar.setVisible(enabled) + self.brushEraserToolBar.setVisible(enabled) self.disableNonFunctionalButtons() def nonViewerEditMenuOpened(self): mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': + if mode == "Viewer": self.startBlinkingModeCB() def reconnectUndoRedo(self): @@ -191,12 +194,12 @@ def reconnectUndoRedo(self): except Exception as e: pass mode = self.modeComboBox.currentText() - if mode == 'Segmentation and Tracking' or mode == 'Snapshot': + if mode == "Segmentation and Tracking" or mode == "Snapshot": self.undoAction.triggered.connect(self.undo) self.redoAction.triggered.connect(self.redo) - elif mode == 'Cell cycle analysis': + elif mode == "Cell cycle analysis": self.undoAction.triggered.connect(self.UndoCca) - elif mode == 'Custom annotations': + elif mode == "Custom annotations": self.undoAction.triggered.connect(self.undoCustomAnnotation) else: self.undoAction.setDisabled(True) @@ -232,11 +235,11 @@ def setEnabledEditToolbarButton(self, enabled=False): self.autoSegmAction.setEnabled(enabled) self.editToolBar.setVisible(enabled) mode = self.modeComboBox.currentText() - ccaON = mode == 'Cell cycle analysis' + ccaON = mode == "Cell cycle analysis" for action in self.editToolBar.actions(): button = self.editToolBar.widgetForAction(action) # Keep binCellButton active in cca mode - if button==self.binCellButton and not enabled and ccaON: + if button == self.binCellButton and not enabled and ccaON: action.setVisible(True) button.setEnabled(True) else: @@ -267,7 +270,7 @@ def setEnabledSnapshotMode(self): self.segmVideoMenu.setDisabled(True) self.trackingMenu.setDisabled(True) self.modeToolBar.setVisible(False) - + self.relabelSequentialAction.setDisabled(False) self.postProcessSegmAction.setDisabled(False) self.autoSegmAction.setDisabled(False) @@ -281,7 +284,7 @@ def setEnabledSnapshotMode(self): action.setVisible(True) elif action == self.reInitCcaAction: action.setVisible(True) - elif action == self.assignBudMothAutoAction and posData.SizeT==1: + elif action == self.assignBudMothAutoAction and posData.SizeT == 1: action.setVisible(True) for action in self.editToolBar.actions(): button = self.editToolBar.widgetForAction(action) @@ -308,13 +311,13 @@ def setFramesSnapshotMode(self): self.drawIDsContComboBox.currentIndexChanged.disconnect() except Exception as e: pass - + self.imgGrad.rescaleAcrossTimeAction.setDisabled(True) self.repeatTrackingAction.setDisabled(True) self.manualTrackingAction.setDisabled(True) self.logger.info('Setting GUI mode to "Snapshots"...') self.modeComboBox.clear() - self.modeComboBox.addItems(['Snapshot']) + self.modeComboBox.addItems(["Snapshot"]) self.modeComboBox.setDisabled(True) self.modeMenu.menuAction().setVisible(False) self.drawIDsContComboBox.clear() @@ -323,7 +326,7 @@ def setFramesSnapshotMode(self): self.modeToolBar.setVisible(False) self.skipToNewIdAction.setVisible(False) self.skipToNewIdAction.setDisabled(True) - self.modeComboBox.setCurrentText('Snapshot') + self.modeComboBox.setCurrentText("Snapshot") self.annotateToolbar.setVisible(True) self.labelsGrad.showNextFrameAction.setDisabled(True) self.drawIDsContComboBox.currentIndexChanged.connect( @@ -374,12 +377,13 @@ def setFramesSnapshotMode(self): self.modeComboBox.sigTextChanged.connect(self.changeMode) self.modeComboBox.activated.connect(self.clearComboBoxFocus) self.drawIDsContComboBox.currentIndexChanged.connect( - self.drawIDsContComboBox_cb) - self.modeComboBox.setCurrentText('Viewer') + self.drawIDsContComboBox_cb + ) + self.modeComboBox.setCurrentText("Viewer") self.showTreeInfoCheckbox.show() self.manualBackgroundAction.setVisible(False) self.manualBackgroundAction.setDisabled(True) - self.labelsGrad.showNextFrameAction.setDisabled(False) + self.labelsGrad.showNextFrameAction.setDisabled(False) self.manualAnnotPastButton.setDisabled(False) self.manualAnnotPastButton.action.setDisabled(False) self.manualAnnotPastButton.setVisible(True) @@ -392,10 +396,10 @@ def setFramesSnapshotMode(self): self.segForLostIDsAction.setDisabled(False) self.delNewObjAction.setVisible(True) self.delNewObjAction.setDisabled(False) - + for ch, overlayItems in self.overlayLayersItems.items(): lutItem = overlayItems[1] - lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot) + lutItem.rescaleAcrossTimeAction.setDisabled(self.isSnapshot) def startBlinkingModeCB(self): try: @@ -414,16 +418,16 @@ def startBlinkingModeCB(self): def stopBlinkingCB(self): self.timer.stop() - self.modeComboBox.setStyleSheet('background-color: none') + self.modeComboBox.setStyleSheet("background-color: none") def uncheckAllButtonsFromButtonGroup(self, buttonGroup): for button in buttonGroup.buttons(): if not button.isCheckable(): continue - + if not button.isChecked(): continue - + button.setChecked(False) def updateModeMenuAction(self): diff --git a/cellacdc/mixins/object_cleanup.py b/cellacdc/mixins/object_cleanup.py index 11f3f6f08..a906527cb 100644 --- a/cellacdc/mixins/object_cleanup.py +++ b/cellacdc/mixins/object_cleanup.py @@ -9,27 +9,28 @@ from .cell_cycle import CellCycle + class ObjectCleanup(CellCycle): """Extracted from guiWin.""" def delObjsOutSegmMaskActionTriggered(self): posData = self.data[self.pos_i] segm_files = load.get_segm_files(posData.images_path) - existingSegmEndnames = load.get_endnames( - posData.basename, segm_files - ) + existingSegmEndnames = load.get_endnames(posData.basename, segm_files) selectSegmWin = widgets.QDialogListbox( - 'Select segmentation file', - 'Select segmentation file to use as ROI:\n', - existingSegmEndnames, multiSelection=False, parent=self + "Select segmentation file", + "Select segmentation file to use as ROI:\n", + existingSegmEndnames, + multiSelection=False, + parent=self, ) selectSegmWin.exec_() if selectSegmWin.cancel: - self.logger.info('Delete objects process cancelled.') + self.logger.info("Delete objects process cancelled.") return - + selectedSegmEndname = selectSegmWin.selectedItemsText[0] - + self.startDelObjsOutSegmMaskWorker(selectedSegmEndname) def delObjsOutSegmMaskWorkerFinished(self, result): @@ -37,44 +38,43 @@ def delObjsOutSegmMaskWorkerFinished(self, result): worker, cleared_segm_data, delIDs = result if posData.SizeT == 1: cleared_segm_data = cleared_segm_data[np.newaxis] - + self.update_cca_df_deletedIDs(posData, delIDs) - + current_frame_i = posData.frame_i for frame_i, cleared_lab in enumerate(cleared_segm_data): # Store change - posData.allData_li[frame_i]['labels'] = cleared_lab + posData.allData_li[frame_i]["labels"] = cleared_lab # Get the rest of the stored metadata based on the new lab posData.frame_i = frame_i self.get_data() self.store_data(autosave=False) - + # Back to current frame posData.frame_i = current_frame_i self.get_data() - + if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - self.logger.info('Deleting objects outside of ROIs finished.') - self.titleLabel.setText( - 'Deleting objects outside of ROIs finished.', color='w' - ) + self.logger.info("Deleting objects outside of ROIs finished.") + self.titleLabel.setText("Deleting objects outside of ROIs finished.", color="w") self.updateAllImages() def startDelObjsOutSegmMaskWorker(self, selectedSegmEndname): self.store_data(autosave=False) posData = self.data[self.pos_i] segm_data = np.squeeze(self.getStoredSegmData()) - + self.progressWin = apps.QDialogWorkerProgress( - title='Deleting objects outside of ROIs', parent=self, - pbarDesc='Deleting objects outside of ROIs...' + title="Deleting objects outside of ROIs", + parent=self, + pbarDesc="Deleting objects outside of ROIs...", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - + self.thread = QThread() self.worker = workers.DelObjectsOutsideSegmROIWorker( selectedSegmEndname, segm_data, posData.images_path diff --git a/cellacdc/mixins/object_properties.py b/cellacdc/mixins/object_properties.py index 1f3b28565..5606f1dee 100644 --- a/cellacdc/mixins/object_properties.py +++ b/cellacdc/mixins/object_properties.py @@ -11,6 +11,7 @@ from .cell_cycle import CellCycle from .tracking import Tracking + class ObjectProperties(CellCycle, Tracking): """Extracted from guiWin.""" @@ -18,10 +19,10 @@ def _keepObjects(self, keepIDs=None, lab=None, rp=None): posData = self.data[self.pos_i] if lab is None: lab = posData.lab - + if rp is None: rp = posData.rp - + if keepIDs is None: keepIDs = self.keptObjectsIDs @@ -30,7 +31,7 @@ def _keepObjects(self, keepIDs=None, lab=None, rp=None): continue lab[obj.slice][obj.image] = 0 - + return lab def applyKeepObjects(self): @@ -39,7 +40,7 @@ def applyKeepObjects(self): self._keepObjects() self.highlightHoverIDsKeptObj(0, 0, hoverID=0) - + posData = self.data[self.pos_i] self.update_rp() @@ -47,7 +48,7 @@ def applyKeepObjects(self): self.tracking(enforce=True, assign_unique_new_IDs=False) if self.isSnapshot: - self.fixCcaDfAfterEdit('Deleted non-selected objects') + self.fixCcaDfAfterEdit("Deleted non-selected objects") self.updateAllImages() self.keptObjectsIDs = widgets.KeptObjectIDsList( self.keptIDsLineEdit, self.keepIDsConfirmAction @@ -55,13 +56,13 @@ def applyKeepObjects(self): return else: removeAnnot = self.warnEditingWithCca_df( - 'Deleted non-selected objects', get_answer=True + "Deleted non-selected objects", get_answer=True ) if not removeAnnot: - # We can propagate changes only if the user agrees on + # We can propagate changes only if the user agrees on # removing annotations return - + self.current_frame_i = posData.frame_i if posData.frame_i > 0: txt = html_utils.paragraph(""" @@ -69,44 +70,50 @@ def applyKeepObjects(self): """) msg = widgets.myMessageBox(wrapText=False, showCentered=False) _, _, applyToPastButton = msg.question( - self, 'Propagate to past frames?', txt, - buttonsTexts=('Cancel', 'No', 'Yes, apply to past frames') + self, + "Propagate to past frames?", + txt, + buttonsTexts=("Cancel", "No", "Yes, apply to past frames"), ) if msg.cancel: return if msg.clickedButton == applyToPastButton: self.store_data() - self.logger.info('Applying keep objects to past frames...') + self.logger.info("Applying keep objects to past frames...") if not removeAnnot and posData.cca_df is not None: delIDs = [ - ID for ID in posData.cca_df.index - if ID not in posData.IDs + ID for ID in posData.cca_df.index if ID not in posData.IDs ] self.update_cca_df_deletedIDs(posData, delIDs) - + for i in tqdm(range(posData.frame_i), ncols=100): - lab = posData.allData_li[i]['labels'] - rp = posData.allData_li[i]['regionprops'] + lab = posData.allData_li[i]["labels"] + rp = posData.allData_li[i]["regionprops"] keepLab = self._keepObjects(lab=lab, rp=rp) # Store change - posData.allData_li[i]['labels'] = keepLab.copy() + posData.allData_li[i]["labels"] = keepLab.copy() # Get the rest of the stored metadata based on the new lab posData.frame_i = i self.get_data() self.store_data(autosave=False) - + posData.frame_i = self.current_frame_i self.get_data() # Ask to propagate change to all future visited frames - key = 'Keep ID' + key = "Keep ID" askAction = self.askHowFutureFramesActions[key] doNotShow = not askAction.isChecked() - (UndoFutFrames, applyFutFrames, endFrame_i, - doNotShowAgain) = self.propagateChange( - self.keptObjectsIDs, key, doNotShow, - posData.UndoFutFrames_keepID, posData.applyFutFrames_keepID, - force=True, applyTrackingB=True + (UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain) = ( + self.propagateChange( + self.keptObjectsIDs, + key, + doNotShow, + posData.UndoFutFrames_keepID, + posData.applyFutFrames_keepID, + force=True, + applyTrackingB=True, + ) ) if UndoFutFrames is None: @@ -119,34 +126,31 @@ def applyKeepObjects(self): posData.doNotShowAgain_keepID = doNotShowAgain posData.UndoFutFrames_keepID = UndoFutFrames posData.applyFutFrames_keepID = applyFutFrames - includeUnvisited = posData.includeUnvisitedInfo['Keep ID'] + includeUnvisited = posData.includeUnvisitedInfo["Keep ID"] if applyFutFrames: self.store_data() - self.logger.info('Applying to future frames...') - pbar = tqdm(total=posData.SizeT-posData.frame_i-1, ncols=100) + self.logger.info("Applying to future frames...") + pbar = tqdm(total=posData.SizeT - posData.frame_i - 1, ncols=100) segmSizeT = len(posData.segm_data) if not removeAnnot and posData.cca_df is not None: - delIDs = [ - ID for ID in posData.cca_df.index - if ID not in posData.IDs - ] + delIDs = [ID for ID in posData.cca_df.index if ID not in posData.IDs] self.update_cca_df_deletedIDs(posData, delIDs) - - for i in range(posData.frame_i+1, segmSizeT): - lab = posData.allData_li[i]['labels'] + + for i in range(posData.frame_i + 1, segmSizeT): + lab = posData.allData_li[i]["labels"] if lab is None and not includeUnvisited: self.enqAutosave() - pbar.update(posData.SizeT-i) + pbar.update(posData.SizeT - i) break - - rp = posData.allData_li[i]['regionprops'] + + rp = posData.allData_li[i]["regionprops"] if lab is not None: keepLab = self._keepObjects(lab=lab, rp=rp) # Store change - posData.allData_li[i]['labels'] = keepLab.copy() + posData.allData_li[i]["labels"] = keepLab.copy() # Get the rest of the stored metadata based on the new lab posData.frame_i = i self.get_data() @@ -157,10 +161,10 @@ def applyKeepObjects(self): rp = skimage.measure.regionprops(lab) keepLab = self._keepObjects(lab=lab, rp=rp) posData.segm_data[i] = keepLab - + pbar.update() pbar.close() - + # Back to current frame if applyFutFrames: posData.frame_i = self.current_frame_i @@ -172,15 +176,15 @@ def applyKeepObjects(self): def clearHighlightedID(self): self.highlightIDToolbar.setVisible(False) - + try: self.updateLostContoursImage(ax=0, delROIsIDs=None) except Exception as err: pass - + if self.highlightedID == 0: return - + self.highlightedID = 0 self.guiTabControl.highlightCheckbox.setChecked(False) self.guiTabControl.highlightSearchedCheckbox.setChecked(False) @@ -198,25 +202,23 @@ def clearHighlightedText(self): pass def countObjects(self): - self.logger.info('Counting objects...') - + self.logger.info("Counting objects...") + posData = self.data[self.pos_i] if posData.SizeT > 1: return self.countObjectsTimelapse() - + return self.countObjectsSnapshots() def countObjectsCb(self, checked): if self.countObjsWindow is None: categoryCountMapper = self.countObjects() self.countObjsWindow = apps.ObjectCountDialog( - categoryCountMapper=categoryCountMapper, - parent=self, - data=self.data + categoryCountMapper=categoryCountMapper, parent=self, data=self.data ) self.countObjsWindow.sigShowEvent.connect(self.updateObjectCounts) self.countObjsWindow.sigUpdateCounts.connect(self.updateObjectCounts) - + if checked: self.countObjsWindow.show() else: @@ -226,13 +228,13 @@ def countObjectsSnapshots(self): posData = self.data[self.pos_i] if self.countObjsWindow is None: activeCategories = { - 'In current position', - 'In all visited positions (current session)', - 'In all visited positions (previous sessions)', - 'In all loaded positions', + "In current position", + "In all visited positions (current session)", + "In all visited positions (previous sessions)", + "In all loaded positions", } if self.isSegm3D: - activeCategories.add('In current z-slice') + activeCategories.add("In current z-slice") else: activeCategories = self.countObjsWindow.activeCategories() @@ -241,13 +243,13 @@ def countObjectsSnapshots(self): numObjectsVisitedPosPrevious = 0 numObjectsVisitedPosCurrent = 0 numObjectsCurrentZslice = None - if 'In current z-slice' in activeCategories: + if "In current z-slice" in activeCategories: numObjectsCurrentZslice = len( skimage.measure.regionprops(self.currentLab2D) ) - + for pos_i, _posData in enumerate(self.data): - IDs = _posData.allData_li[0]['IDs'] + IDs = _posData.allData_li[0]["IDs"] if os.path.exists(_posData.acdc_output_csv_path): numObjectsVisitedPosPrevious += len(IDs) if IDs: @@ -258,86 +260,79 @@ def countObjectsSnapshots(self): rp = skimage.measure.regionprops(lab) numObjs = len(rp) numObjectsAllPos += numObjs - + if _posData.visited: numObjectsVisitedPosCurrent += numObjs - + allCategoryCountMapper = { - 'In current position': numObjectsCurrentPos, - 'In all visited positions (current session)': - numObjectsVisitedPosCurrent, - 'In all visited positions (previous sessions)': - numObjectsVisitedPosPrevious, - 'In all loaded positions': numObjectsAllPos, + "In current position": numObjectsCurrentPos, + "In all visited positions (current session)": numObjectsVisitedPosCurrent, + "In all visited positions (previous sessions)": numObjectsVisitedPosPrevious, + "In all loaded positions": numObjectsAllPos, } if numObjectsCurrentZslice is not None: - allCategoryCountMapper['In current z-slice'] = ( - numObjectsCurrentZslice - ) - + allCategoryCountMapper["In current z-slice"] = numObjectsCurrentZslice + if self.countObjsWindow is None: - return allCategoryCountMapper - + return allCategoryCountMapper + categoryCountMapper = {} for category in activeCategories: categoryCountMapper[category] = allCategoryCountMapper[category] - + return categoryCountMapper def countObjectsTimelapse(self): if self.countObjsWindow is None: activeCategories = { - 'In current frame', - 'In all visited frames', - 'In entire video', - 'Unique objects in all visited frames', - 'Unique objects in entire video' + "In current frame", + "In all visited frames", + "In entire video", + "Unique objects in all visited frames", + "Unique objects in entire video", } else: activeCategories = self.countObjsWindow.activeCategories() - - posData = self.data[self.pos_i] - allCategoryCountMapper = posData.countObjectsInSegmTimelapse( - activeCategories - ) + + posData = self.data[self.pos_i] + allCategoryCountMapper = posData.countObjectsInSegmTimelapse(activeCategories) if self.countObjsWindow is None: - return allCategoryCountMapper - + return allCategoryCountMapper + categoryCountMapper = {} for category in activeCategories: categoryCountMapper[category] = allCategoryCountMapper[category] - + return categoryCountMapper def getHighlightedID(self): if self.highlightedID > 0: return self.highlightedID - - doHighlight = ( - self.propsDockWidget.isVisible() - and ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() - ) + + doHighlight = self.propsDockWidget.isVisible() and ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() ) if not doHighlight: return 0 - + return self.guiTabControl.propsQGBox.idSB.value() - def get_curr_lab(self, curr_lab: np.ndarray|None = None, frame_i: int|None = None): + def get_curr_lab( + self, curr_lab: np.ndarray | None = None, frame_i: int | None = None + ): """Get the current labels for the position data. Hirarchically checks: 1. If `curr_lab` is provided, use it. 2. If `posData.lab` is not None, use it. 3. If `posData.allData_li[frame_i]['labels']` exists, use it. 4. If `posData.segm_data[frame_i]` exists, use it. - + If frame_i is None, uses the current frame index from `posData`. Parameters ---------- curr_lab : np.ndarray, optional - Current labels for the position data if it should be checked + Current labels for the position data if it should be checked if its not None first, by default None frame_i : int, optional Frame index to use for retrieving labels, by default None @@ -350,37 +345,37 @@ def get_curr_lab(self, curr_lab: np.ndarray|None = None, frame_i: int|None = Non posData = self.data[self.pos_i] if frame_i is None: frame_i = posData.frame_i - + if curr_lab is None and frame_i == posData.frame_i: curr_lab = posData.lab - + if curr_lab is None: try: - curr_lab = posData.allData_li[frame_i]['labels'].copy() + curr_lab = posData.allData_li[frame_i]["labels"].copy() except: pass - + if curr_lab is None: try: curr_lab = posData.segm_data[frame_i].copy() except: pass - + return curr_lab def grayOutHighlightedLabels(self, nonGrayedIDs=None, alpha=None): if nonGrayedIDs is None: nonGrayedIDs = set() - + posData = self.data[self.pos_i] if alpha is None: alpha = self.imgGrad.labelsAlphaSlider.value() - - if not hasattr(self, 'highlightedLab'): + + if not hasattr(self, "highlightedLab"): self.highlightedLab = np.zeros_like(self.currentLab2D) else: self.highlightedLab[:] = 0 - + lut = np.zeros((2, 4), dtype=np.uint8) for _obj in posData.rp: if not self.isObjVisible(_obj.bbox): @@ -390,11 +385,11 @@ def grayOutHighlightedLabels(self, nonGrayedIDs=None, alpha=None): _slice = self.getObjSlice(_obj.slice) _objMask = self.getObjImage(_obj.image, _obj.bbox) self.highlightedLab[_slice][_objMask] = _obj.label - rgb = self.lut[_obj.label].copy() + rgb = self.lut[_obj.label].copy() lut[1, :-1] = rgb # Set alpha to 0.7 - lut[1, -1] = 178 - + lut[1, -1] = 178 + return lut def grayOutOverlaySegm(self, ax=0): @@ -402,11 +397,11 @@ def grayOutOverlaySegm(self, ax=0): how = self.drawIDsContComboBox.currentText() else: how = self.getAnnotateHowRightImage() - - isOverlaySegmActive = how.find('segm. masks') != -1 + + isOverlaySegmActive = how.find("segm. masks") != -1 if not isOverlaySegmActive: return - + grayedLut = self.grayOutHighlightedLabels() def highlightHoverID(self, x, y, hoverID=None): @@ -418,7 +413,7 @@ def highlightHoverID(self, x, y, hoverID=None): if hoverID == 0: return - + posData = self.data[self.pos_i] objIdx = posData.IDs_idxs[hoverID] obj = posData.rp[objIdx] @@ -433,25 +428,25 @@ def highlightHoverIDsKeptObj(self, x, y, hoverID=None): return self.highlightSearchedID(hoverID, greyOthers=False) - + if hoverID == 0 and self.highlightedID == 0: return - + if hoverID == 0 and self.highlightedID != 0: self.clearHighlightedKeepIDs() for ID in self.keptObjectsIDs: self.highlightLabelID(ID) return - + posData = self.data[self.pos_i] try: objIdx = posData.IDs_idxs[hoverID] except KeyError as err: - return - + return + obj = posData.rp[objIdx] self.goToZsliceSearchedID(obj) - + for ID in self.keptObjectsIDs: self.highlightLabelID(ID) @@ -469,18 +464,18 @@ def highlightIDonHoverCheckBoxToggled(self, checked): self.updatePropsWidget(self.highlightedID) self.updateAllImages() - def highlightLabelID(self, ID, ax=0): + def highlightLabelID(self, ID, ax=0): posData = self.data[self.pos_i] try: obj = posData.rp[posData.IDs_idxs[ID]] except KeyError: return - + self.textAnnot[ax].highlightObject(obj) def highlightSearchedID(self, ID, force=False, greyOthers=True): self.highlightIDToolbar.setIDNoSignals(ID) - + if ID == 0: self.highlightIDToolbar.setVisible(False) return @@ -488,20 +483,17 @@ def highlightSearchedID(self, ID, force=False, greyOthers=True): if ID == self.highlightedID and not force: return - doHighlight = ( - self.propsDockWidget.isVisible() - and ( - self.guiTabControl.highlightCheckbox.isChecked() - or self.guiTabControl.highlightSearchedCheckbox.isChecked() - ) + doHighlight = self.propsDockWidget.isVisible() and ( + self.guiTabControl.highlightCheckbox.isChecked() + or self.guiTabControl.highlightSearchedCheckbox.isChecked() ) if doHighlight: self.highlightedID = self.guiTabControl.propsQGBox.idSB.value() ID = self.highlightedID - + if self.highlightedID > 0: self.clearHighlightedText() - + self.searchedIDitemRight.setData([], []) self.searchedIDitemLeft.setData([], []) @@ -513,53 +505,52 @@ def highlightSearchedID(self, ID, force=False, greyOthers=True): objIdx = posData.IDs_idxs.get(ID) if objIdx is None: return - + obj = posData.rp[objIdx] isObjVisible = self.isObjVisible(obj.bbox) if not isObjVisible: return - + if greyOthers: self.textAnnot[0].grayOutAnnotations() self.textAnnot[1].grayOutAnnotations() how_ax1 = self.drawIDsContComboBox.currentText() how_ax2 = self.getAnnotateHowRightImage() - isOverlaySegm_ax1 = how_ax1.find('segm. masks') != -1 - isOverlaySegm_ax2 = how_ax2.find('segm. masks') != -1 + isOverlaySegm_ax1 = how_ax1.find("segm. masks") != -1 + isOverlaySegm_ax2 = how_ax2.find("segm. masks") != -1 alpha = self.imgGrad.labelsAlphaSlider.value() - + if isOverlaySegm_ax1 or isOverlaySegm_ax2: grayedLut = self.grayOutHighlightedLabels( - nonGrayedIDs={obj.label}, - alpha=alpha + nonGrayedIDs={obj.label}, alpha=alpha ) - + cont = None contours = None if isOverlaySegm_ax1: self.highLightIDLayerImg1.setLookupTable(grayedLut) - self.highLightIDLayerImg1.setImage(self.highlightedLab) - self.labelsLayerImg1.setOpacity(alpha/3) + self.highLightIDLayerImg1.setImage(self.highlightedLab) + self.labelsLayerImg1.setOpacity(alpha / 3) else: contours = self.getObjContours(obj, all_external=True) for cont in contours: - self.searchedIDitemLeft.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) - + self.searchedIDitemLeft.addPoints(cont[:, 0] + 0.5, cont[:, 1] + 0.5) + if isOverlaySegm_ax2: self.highLightIDLayerRightImage.setLookupTable(grayedLut) self.highLightIDLayerRightImage.setImage(self.highlightedLab) - self.labelsLayerRightImg.setOpacity(alpha/3) + self.labelsLayerRightImg.setOpacity(alpha / 3) else: if contours is None: contours = self.getObjContours(obj, all_external=True) for cont in contours: - self.searchedIDitemRight.addPoints(cont[:,0]+0.5, cont[:,1]+0.5) + self.searchedIDitemRight.addPoints(cont[:, 0] + 0.5, cont[:, 1] + 0.5) # Gray out all IDs excpet searched one - lut = self.lut.copy() # [:max(posData.IDs)+1] - lut[:ID] = lut[:ID]*0.2 - lut[ID+1:] = lut[ID+1:]*0.2 + lut = self.lut.copy() # [:max(posData.IDs)+1] + lut[:ID] = lut[:ID] * 0.2 + lut[ID + 1 :] = lut[ID + 1 :] * 0.2 self.img2.setLookupTable(lut) # Highlight text @@ -578,13 +569,13 @@ def highlightSearchedIDcheckBoxToggled(self, checked): if obj_idx is None: return obj = posData.rp[objIdx] - self.goToZsliceSearchedID(obj) + self.goToZsliceSearchedID(obj) def initKeepObjLabelsLayers(self): lut = np.zeros((len(self.lut), 4), dtype=np.uint8) - lut[:,:-1] = self.lut - lut[:,-1:] = 255 - lut[0] = [0,0,0,0] + lut[:, :-1] = self.lut + lut[:, -1:] = 255 + lut[0] = [0, 0, 0, 0] self.keepIDsTempLayerLeft.setLevels([0, len(lut)]) self.keepIDsTempLayerLeft.setLookupTable(lut) @@ -593,9 +584,7 @@ def initPixelSizePropsDockWidget(self): PhysicalSizeX = posData.PhysicalSizeX PhysicalSizeY = posData.PhysicalSizeY PhysicalSizeZ = posData.PhysicalSizeZ - self.guiTabControl.initPixelSize( - PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ - ) + self.guiTabControl.initPixelSize(PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) def keepIDs_cb(self, checked): if checked: @@ -605,7 +594,7 @@ def keepIDs_cb(self, checked): self.annotIDsCheckbox.setChecked(True) self.setDrawAnnotComboboxText() self.uncheckLeftClickButtons(None) - self.initKeepObjLabelsLayers() + self.initKeepObjLabelsLayers() self.setAllIDs() else: # restore items to non-grayed out @@ -619,7 +608,7 @@ def keepIDs_cb(self, checked): self.ax2_lostObjImageItem.setOpacity(1.0) self.ax1_lostTrackedObjImageItem.setOpacity(1.0) self.ax2_lostTrackedObjImageItem.setOpacity(1.0) - + self.keepIDsToolbar.setVisible(checked) self.highlightedIDopts = None self.keptObjectsIDs = widgets.KeptObjectIDsList( @@ -632,14 +621,14 @@ def propsWidgetIDvalueChanged(self, ID): if ID == 0: self.updatePropsWidget(int(ID)) return - + propsQGBox = self.guiTabControl.propsQGBox obj_idx = posData.IDs_idxs.get(ID) if obj_idx is None: - s = f'Object ID {int(ID):d} does not exist' + s = f"Object ID {int(ID):d} does not exist" propsQGBox.notExistingIDLabel.setText(s) return - + obj = posData.rp[obj_idx] self.goToZsliceSearchedID(obj) self.updatePropsWidget(int(ID)) @@ -648,7 +637,7 @@ def removeHighlightLabelID(self, IDs=None, ax=0): posData = self.data[self.pos_i] if IDs is None: IDs = posData.IDs - + for ID in IDs: obj = posData.rp[posData.IDs_idxs[ID]] self.textAnnot[ax].removeHighlightObject(obj) @@ -659,14 +648,14 @@ def setAllIDs(self, onlyVisited=False): for frame_i in range(len(posData.segm_data)): if frame_i >= len(posData.allData_li): break - lab = posData.allData_li[frame_i]['labels'] + lab = posData.allData_li[frame_i]["labels"] if lab is None and onlyVisited: break - + if lab is None: rp = skimage.measure.regionprops(posData.segm_data[frame_i]) else: - rp = posData.allData_li[frame_i]['regionprops'] + rp = posData.allData_li[frame_i]["regionprops"] posData.allIDs.update([obj.label for obj in rp]) def setHighlighedIDfromToolbar(self, ID: int): @@ -717,7 +706,7 @@ def updateKeepIDs(self, IDs): if ID not in self.keptObjectsIDs: self.keptObjectsIDs.append(ID, editText=False) self.highlightLabelID(ID) - + # Check if IDs in current keptObjectsIDs are present in IDs from line edit for ID in self.keptObjectsIDs: if ID not in posData.allIDs: @@ -725,7 +714,7 @@ def updateKeepIDs(self, IDs): continue if ID not in IDs: self.keptObjectsIDs.remove(ID, editText=False) - + self.updateTempLayerKeepIDs() if isAnyIDnotExisting: self.keptIDsLineEdit.warnNotExistingID() @@ -735,13 +724,13 @@ def updateKeepIDs(self, IDs): def updateObjectCounts(self): if self.countObjsWindow is None: return - + if not self.countObjsWindow.isVisible(): return - + if not self.countObjsWindow.livePreviewCheckbox.isChecked(): return - + categoryCountMapper = self.countObjects() self.countObjsWindow.updateCounts(categoryCountMapper) @@ -753,18 +742,17 @@ def updatePropsWidget(self, ID, fromHover=False): self.currentPropsID = -1 ID = int(ID) - + update = ( - self.propsDockWidget.isVisible() - and ID != 0 and ID!=self.currentPropsID + self.propsDockWidget.isVisible() and ID != 0 and ID != self.currentPropsID ) if not update: return posData = self.data[self.pos_i] - if not hasattr(posData, 'rp'): - return - + if not hasattr(posData, "rp"): + return + if posData.rp is None: self.update_rp() @@ -780,25 +768,25 @@ def updatePropsWidget(self, ID, fromHover=False): obj_idx = posData.IDs_idxs.get(ID) if obj_idx is None: - s = f'Object ID {int(ID):d} does not exist' + s = f"Object ID {int(ID):d} does not exist" propsQGBox.notExistingIDLabel.setText(s) return - propsQGBox.notExistingIDLabel.setText('') + propsQGBox.notExistingIDLabel.setText("") self.currentPropsID = ID propsQGBox.idSB.setValue(ID) - + doHighlight = ( self.guiTabControl.highlightCheckbox.isChecked() or self.guiTabControl.highlightSearchedCheckbox.isChecked() ) if doHighlight: self.highlightSearchedID(ID) - + obj = posData.rp[obj_idx] if self.isSegm3D: - if self.zProjComboBox.currentText() == 'single z-slice': + if self.zProjComboBox.currentText() == "single z-slice": local_z = self.z_lab() - obj.bbox[0] area_pxl = np.count_nonzero(obj.image[local_z]) else: @@ -812,29 +800,26 @@ def updatePropsWidget(self, ID, fromHover=False): PhysicalSizeX = pixelSizeQGBox.pixelWidthWidget.value() PhysicalSizeY = pixelSizeQGBox.pixelHeightWidget.value() PhysicalSizeZ = pixelSizeQGBox.voxelDepthWidget.value() - - yx_pxl_to_um2 = PhysicalSizeY*PhysicalSizeX - area_um2 = area_pxl*yx_pxl_to_um2 + yx_pxl_to_um2 = PhysicalSizeY * PhysicalSizeX + + area_um2 = area_pxl * yx_pxl_to_um2 propsQGBox.cellAreaUm2DSB.setValue(area_um2) if self.isSegm3D: PhysicalSizeZ = posData.PhysicalSizeZ vol_vox_3D = obj.area - vol_fl_3D = vol_vox_3D*PhysicalSizeZ*PhysicalSizeY*PhysicalSizeX + vol_fl_3D = vol_vox_3D * PhysicalSizeZ * PhysicalSizeY * PhysicalSizeX propsQGBox.cellVolVox3D_SB.setValue(vol_vox_3D) propsQGBox.cellVolFl3D_DSB.setValue(vol_fl_3D) - vol_vox, vol_fl = _calc_rot_vol( - obj, PhysicalSizeY, PhysicalSizeX - ) + vol_vox, vol_fl = _calc_rot_vol(obj, PhysicalSizeY, PhysicalSizeX) propsQGBox.cellVolVoxSB.setValue(int(vol_vox)) propsQGBox.cellVolFlDSB.setValue(vol_fl) - minor_axis_length = max(1, obj.minor_axis_length) - elongation = obj.major_axis_length/minor_axis_length + elongation = obj.major_axis_length / minor_axis_length propsQGBox.elongationDSB.setValue(elongation) solidity = obj.solidity @@ -846,7 +831,7 @@ def updatePropsWidget(self, ID, fromHover=False): intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox selectedChannel = intensMeasurQGBox.channelCombobox.currentText() - + try: _, filename = self.getPathFromChName(selectedChannel, posData) image = posData.ol_data_dict[filename][posData.frame_i] @@ -868,11 +853,11 @@ def updatePropsWidget(self, ID, fromHover=False): funcDesc = intensMeasurQGBox.additionalMeasCombobox.currentText() func = intensMeasurQGBox.additionalMeasCombobox.functions[funcDesc] - if funcDesc == 'Concentration': + if funcDesc == "Concentration": bkgrVal = np.median(img[posData.lab == 0]) amount = func(objData, bkgrVal, obj.area) - value = amount/vol_vox - elif funcDesc == 'Amount': + value = amount / vol_vox + elif funcDesc == "Amount": bkgrVal = np.median(img[posData.lab == 0]) amount = func(objData, bkgrVal, obj.area) value = amount @@ -884,7 +869,7 @@ def updatePropsWidget(self, ID, fromHover=False): def updateTempLayerKeepIDs(self): if not self.keepIDsButton.isChecked(): return - + keptLab = np.zeros_like(self.currentLab2D) posData = self.data[self.pos_i] diff --git a/cellacdc/mixins/object_search.py b/cellacdc/mixins/object_search.py index 6d909ce80..925a1b6d2 100644 --- a/cellacdc/mixins/object_search.py +++ b/cellacdc/mixins/object_search.py @@ -9,21 +9,24 @@ from .frame_navigation import FrameNavigation + class ObjectSearch(FrameNavigation): """Extracted from guiWin.""" def askGoToFrameFoundID(self, searchedID, frame_i_found): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(f""" - Object ID {searchedID} was found at frame n. {frame_i_found+1}.

    - Do you want to go to frame n. {frame_i_found+1}. + Object ID {searchedID} was found at frame n. {frame_i_found + 1}.

    + Do you want to go to frame n. {frame_i_found + 1}. """) noButton, yesButton = msg.information( - self, f'ID {searchedID} found at frame n. {frame_i_found+1}', txt, + self, + f"ID {searchedID} found at frame n. {frame_i_found + 1}", + txt, buttonsTexts=( - 'No, stay on current frame', - f'Yes, go to frame n. {frame_i_found+1}' - ) + "No, stay on current frame", + f"Yes, go to frame n. {frame_i_found + 1}", + ), ) return msg.clickedButton == yesButton @@ -31,10 +34,10 @@ def findID(self, checked=False, ID=None): posData = self.data[self.pos_i] if ID is None: searchIDdialog = apps.FindIDDialog( - title='Search object by ID', - msg='Enter object ID to find and highlight', + title="Search object by ID", + msg="Enter object ID to find and highlight", parent=self, - isInteger=True + isInteger=True, ) searchIDdialog.exec_() if searchIDdialog.cancel: @@ -43,7 +46,7 @@ def findID(self, checked=False, ID=None): searchedID = searchIDdialog.EntryID else: searchedID = ID - + if searchedID in posData.IDs: self.goToObjectID(searchedID) return @@ -51,35 +54,35 @@ def findID(self, checked=False, ID=None): if posData.SizeT == 1: self.warnIDnotFound(searchedID) return - + if searchedID in posData.lost_IDs: self.goToLostObjectID(searchedID) return - + tracked_lost_IDs = self.getTrackedLostIDs() if searchedID in tracked_lost_IDs: self.goToAcceptedLostObjectID(searchedID) return - - self.logger.info(f'Searching ID {searchedID} in other frames...') - + + self.logger.info(f"Searching ID {searchedID} in other frames...") + frame_i_found = self.startSearchIDworker(searchedID) if frame_i_found is None: self.warnIDnotFound(searchedID) return - + self.logger.info( - f'Object ID {searchedID} found at frame n. {frame_i_found+1}.' + f"Object ID {searchedID} found at frame n. {frame_i_found + 1}." ) proceed = self.askGoToFrameFoundID(searchedID, frame_i_found) if not proceed: return - + posData.frame_i = frame_i_found self.get_data() self.updateAllImages() self.updateScrollbars() - + self.goToObjectID(searchedID) def findNextNewIdWorkerFinished(self, next_frame_i): @@ -87,50 +90,48 @@ def findNextNewIdWorkerFinished(self, next_frame_i): self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - - self.navSpinBox.setValue(next_frame_i+1) + + self.navSpinBox.setValue(next_frame_i + 1) self.framesScrollBarReleased() def goToAcceptedLostObjectID(self, acceptedLostID): posData = self.data[self.pos_i] frame_i = posData.frame_i - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[frame_i - 1]["IDs_idxs"] obj = prev_rp[prev_IDs_idxs[acceptedLostID]] self.goToZsliceSearchedID(obj) - + self.updateLostTrackedContoursImage(tracked_lost_IDs=[acceptedLostID]) def goToLostObjectID(self, lostID, color=(255, 165, 0, 255)): posData = self.data[self.pos_i] frame_i = posData.frame_i - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[frame_i - 1]["IDs_idxs"] obj = prev_rp[prev_IDs_idxs[lostID]] self.goToZsliceSearchedID(obj) - + imageItem = self.getLostObjImageItem(0) thickness = 1 - if not hasattr(self, 'lostObjContoursImage'): + if not hasattr(self, "lostObjContoursImage"): self.initLostObjContoursImage() else: - self.lostObjContoursImage[:] = 0 + self.lostObjContoursImage[:] = 0 contours = [] obj_contours = self.getObjContours(obj, all_external=True) contours.extend(obj_contours) - + self.addLostObjsToLostObjImage(obj, lostID) - self.drawLostObjContoursImage( - imageItem, contours, thickness=2, color=color - ) + self.drawLostObjContoursImage(imageItem, contours, thickness=2, color=color) def goToObjectID(self, ID): posData = self.data[self.pos_i] objIdx = posData.IDs_idxs[ID] obj = posData.rp[objIdx] self.goToZsliceSearchedID(obj) - + self.highlightSearchedID(ID) propsQGBox = self.guiTabControl.propsQGBox propsQGBox.idSB.setValue(ID) @@ -143,19 +144,19 @@ def searchIDworkerCallback(self, posData, searchedID): for frame_i in range(len(posData.segm_data)): if frame_i >= len(posData.allData_li): break - lab = posData.allData_li[frame_i]['labels'] + lab = posData.allData_li[frame_i]["labels"] if lab is None: rp = skimage.measure.regionprops(posData.segm_data[frame_i]) IDs = set([obj.label for obj in rp]) else: - IDs = posData.allData_li[frame_i]['IDs'] - + IDs = posData.allData_li[frame_i]["IDs"] + if searchedID in IDs: frame_i_found = frame_i break - + self.searchIDworker.signals.progressBar.emit(1) - + self.searchIDworker.frame_i_found = frame_i_found def searchIDworkerCritical(self, error): @@ -167,17 +168,18 @@ def searchIDworkerFinished(self): self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - + self.searchIDworkerLoop.exit() def skipForwardToNewID(self): self.progressWin = apps.QDialogWorkerProgress( - title='Searching the next frame with a new object', parent=self, - pbarDesc=f'Searching the next frame with a new object...' + title="Searching the next frame with a new object", + parent=self, + pbarDesc=f"Searching the next frame with a new object...", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - + self.startFindNextNewIdWorker() def startFindNextNewIdWorker(self): @@ -185,7 +187,7 @@ def startFindNextNewIdWorker(self): self._thread = QThread() self.findNextNewIdWorker = workers.FindNextNewIdWorker(posData, self) self.findNextNewIdWorker.moveToThread(self._thread) - + self.findNextNewIdWorker.signals.finished.connect(self._thread.quit) self.findNextNewIdWorker.signals.finished.connect( self.findNextNewIdWorker.deleteLater @@ -202,62 +204,45 @@ def startFindNextNewIdWorker(self): self.findNextNewIdWorker.signals.progressBar.connect( self.workerUpdateProgressbar ) - self.findNextNewIdWorker.signals.critical.connect( - self.workerCritical - ) + self.findNextNewIdWorker.signals.critical.connect(self.workerCritical) self._thread.started.connect(self.findNextNewIdWorker.run) self._thread.start() def startSearchIDworker(self, searchedID): posData = self.data[self.pos_i] - - desc = 'Searching ID in all frames...' - + + desc = "Searching ID in all frames..." + self.progressWin = apps.QDialogWorkerProgress( title=desc, parent=self.mainWin, pbarDesc=desc ) self.progressWin.mainPbar.setMaximum(posData.SizeT) self.progressWin.show(self.app) - + self.searchIDthread = QThread() self.searchIDworker = workers.SimpleWorker( - posData, self.searchIDworkerCallback, - func_args=(searchedID, ) + posData, self.searchIDworkerCallback, func_args=(searchedID,) ) self.searchIDworker.frame_i_found = None self.searchIDworker.moveToThread(self.searchIDthread) - - self.searchIDworker.signals.finished.connect( - self.searchIDthread.quit - ) - self.searchIDworker.signals.finished.connect( - self.searchIDworker.deleteLater - ) + + self.searchIDworker.signals.finished.connect(self.searchIDthread.quit) + self.searchIDworker.signals.finished.connect(self.searchIDworker.deleteLater) self.searchIDthread.finished.connect(self.searchIDthread.deleteLater) - - self.searchIDworker.signals.critical.connect( - self.searchIDworkerCritical - ) - self.searchIDworker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.searchIDworker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.searchIDworker.signals.progress.connect( - self.workerProgress - ) - self.searchIDworker.signals.finished.connect( - self.searchIDworkerFinished - ) - + + self.searchIDworker.signals.critical.connect(self.searchIDworkerCritical) + self.searchIDworker.signals.initProgressBar.connect(self.workerInitProgressbar) + self.searchIDworker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.searchIDworker.signals.progress.connect(self.workerProgress) + self.searchIDworker.signals.finished.connect(self.searchIDworkerFinished) + self.searchIDthread.started.connect(self.searchIDworker.run) self.searchIDthread.start() - + self.searchIDworkerLoop = QEventLoop() self.searchIDworkerLoop.exec_() - + return self.searchIDworker.frame_i_found def warnIDnotFound(self, searchedID): @@ -265,4 +250,4 @@ def warnIDnotFound(self, searchedID): txt = html_utils.paragraph(f""" Object ID {searchedID} was not found.

    """) - msg.warning(self, f'ID {searchedID} not found', txt) + msg.warning(self, f"ID {searchedID} not found", txt) diff --git a/cellacdc/mixins/points_layers.py b/cellacdc/mixins/points_layers.py index 568c292b1..39b0143e6 100644 --- a/cellacdc/mixins/points_layers.py +++ b/cellacdc/mixins/points_layers.py @@ -21,6 +21,7 @@ from .brush_tools import BrushTools + class PointsLayers(BrushTools): """Extracted from guiWin.""" @@ -30,10 +31,10 @@ def addClickedPoint(self, action, x, y, id): pointsDataPos = action.pointsData.get(self.pos_i) if pointsDataPos is None: action.pointsData[self.pos_i] = {} - + framePointsData = action.pointsData[self.pos_i].get(posData.frame_i) if action.snapToMax: - radius = round(action.pointSize/2) + radius = round(action.pointSize / 2) rr, cc = skimage.draw.disk((round(y), round(x)), radius) idx_max = (self.img1.image[rr, cc]).argmax() y, x = rr[idx_max], cc[idx_max] @@ -42,31 +43,31 @@ def addClickedPoint(self, action, x, y, id): if posData.SizeZ > 1: zSlice = self.zSliceScrollBar.sliderPosition() action.pointsData[self.pos_i][posData.frame_i] = { - zSlice: {'x': [x], 'y': [y], 'id': [id]} + zSlice: {"x": [x], "y": [y], "id": [id]} } else: action.pointsData[self.pos_i][posData.frame_i] = { - 'x': [x], 'y': [y], 'id': [id] + "x": [x], + "y": [y], + "id": [id], } else: if posData.SizeZ > 1: zSlice = self.zSliceScrollBar.sliderPosition() z_data = framePointsData.get(zSlice) if z_data is None: - framePointsData[zSlice] = {'x': [x], 'y': [y], 'id': [id]} + framePointsData[zSlice] = {"x": [x], "y": [y], "id": [id]} else: - framePointsData[zSlice]['x'].append(x) - framePointsData[zSlice]['y'].append(y) - framePointsData[zSlice]['id'].append(id) - action.pointsData[self.pos_i][posData.frame_i] = ( - framePointsData - ) + framePointsData[zSlice]["x"].append(x) + framePointsData[zSlice]["y"].append(y) + framePointsData[zSlice]["id"].append(id) + action.pointsData[self.pos_i][posData.frame_i] = framePointsData else: pointsDataPos = action.pointsData[self.pos_i] framePointsData = pointsDataPos[posData.frame_i] - framePointsData['x'].append(x) - framePointsData['y'].append(y) - framePointsData['id'].append(id) + framePointsData["x"].append(x) + framePointsData["y"].append(y) + framePointsData["id"].append(id) self.markPointsLayerDirty(action=action) @@ -77,7 +78,7 @@ def addPointsByClickingButtonToggled(self, checked=True, sender=None): action = sender.action action.scatterItem.setVisible(False) return - + self.disconnectLeftClickButtons() self.uncheckLeftClickButtons(sender) self.connectLeftClickButtons() @@ -90,34 +91,39 @@ def addPointsByClickingScatterItemHoverEntered(self, item, points, event): point = points[0] point_id = point.data() toolButton = item.action.button - toolButton.rightClickIDSpinbox.prevId = ( - toolButton.rightClickIDSpinbox.value() - ) + toolButton.rightClickIDSpinbox.prevId = toolButton.rightClickIDSpinbox.value() toolButton.rightClickIDSpinbox.setValue(point_id) def addPointsLayer(self, toolbar=None): proceed = self.checkLoadedTableIds(toolbar) - + if self.addPointsWin.cancel or not proceed: self.addPointsWin = None - self.logger.info('Adding points layer cancelled.') + self.logger.info("Adding points layer cancelled.") return - + if toolbar is None: toolbar = self.pointsLayersToolbar - + symbol = self.addPointsWin.symbol color = self.addPointsWin.color pointSize = self.addPointsWin.pointSize - zRadius = int((self.addPointsWin.zHeight-1)/2) - r,g,b,a = color.getRgb() + zRadius = int((self.addPointsWin.zHeight - 1) / 2) + r, g, b, a = color.getRgb() scatterItem = widgets.PointsScatterPlotItem( - [], [], ax=self.ax1, symbol=symbol, pxMode=False, size=pointSize, - brush=pg.mkBrush(color=(r,g,b,100)), - pen=pg.mkPen(width=2, color=(r,g,b)), - hoverable=True, hoverBrush=pg.mkBrush((r,g,b,200)), - tip=None, show_data_as_tip=True + [], + [], + ax=self.ax1, + symbol=symbol, + pxMode=False, + size=pointSize, + brush=pg.mkBrush(color=(r, g, b, 100)), + pen=pg.mkPen(width=2, color=(r, g, b)), + hoverable=True, + hoverBrush=pg.mkBrush((r, g, b, 200)), + tip=None, + show_data_as_tip=True, ) self.ax1.addItem(scatterItem) @@ -130,26 +136,21 @@ def addPointsLayer(self, toolbar=None): toolButton.toggled.connect(self.pointLayerToolbuttonToggled) toolButton.sigEditAppearance.connect(self.editPointsLayerAppearance) toolButton.sigShowIdsToggled.connect(self.showPointsLayerIdsToggled) - toolButton.sigRemove.connect( - partial(self.removePointsLayer, toolbar=toolbar) - ) - + toolButton.sigRemove.connect(partial(self.removePointsLayer, toolbar=toolbar)) + action = toolbar.addWidget(toolButton) action.state = self.addPointsWin.state() toolButton.action = action - action.brushColor = (r,g,b,100) + action.brushColor = (r, g, b, 100) action.brushColorId0 = ( *colors.hex_to_rgb( - colors.lighten_color( - np.array(action.brushColor)/255, 0.3 - ) - ), 100 - ) - action.penColor = (r,g,b) - action.penColorId0 = colors.lighten_color( - np.array(action.penColor)/255, 0.3 + colors.lighten_color(np.array(action.brushColor) / 255, 0.3) + ), + 100, ) + action.penColor = (r, g, b) + action.penColorId0 = colors.lighten_color(np.array(action.penColor) / 255, 0.3) action.pointSize = pointSize action.zRadius = zRadius action.button = toolButton @@ -164,46 +165,40 @@ def addPointsLayer(self, toolbar=None): action.snapToMax = False action.loadedDfInfo = self.addPointsWin.loadedDfInfo self.setPointsLayerLoadedDfEndanme(action) - - if self.addPointsWin.layerType.startswith('Click to annotate point'): + + if self.addPointsWin.layerType.startswith("Click to annotate point"): action.snapToMax = self.addPointsWin.snapToMaxToggle.isChecked() isLoadedDf = self.addPointsWin.clickEntryIsLoadedDf - self.setupAddPointsByClicking( - toolButton, isLoadedDf, toolbar=toolbar - ) + self.setupAddPointsByClicking(toolButton, isLoadedDf, toolbar=toolbar) if self.addPointsWin.autoPilotToggle.isChecked(): self.autoPilotZoomToObjToggle.setChecked(True) - + weighingChannel = self.addPointsWin.weighingChannel self.loadPointsLayerWeighingData(action, weighingChannel) self.drawPointsLayers() - + if toolbar == self.promptSegmentPointsLayerToolbar: self.promptSegmentPointsLayerToolbar.isPointsLayerInit = True self.magicPromptsToolbar.clearPointsAction.setDisabled(False) self.magicPromptsToolbar.clearPointsActionOnZoom.setDisabled(False) - QTimer.singleShot( - 200, self.magicPromptsToolbar.selectModelAction.trigger - ) - + QTimer.singleShot(200, self.magicPromptsToolbar.selectModelAction.trigger) + self.addPointsWin = None def addPointsLayerTriggered(self, checked=False, toolbar=None): if toolbar is None: toolbar = self.pointsLayersToolbar - + if self.addPointsWin is not None: - self.logger.info( - 'Add points layer window is already open. Cannot add now.' - ) + self.logger.info("Add points layer window is already open. Cannot add now.") return - + onlyMouseClicks = toolbar == self.promptSegmentPointsLayerToolbar posData = self.data[self.pos_i] self.addPointsWin = apps.AddPointsLayerDialog( - channelNames=posData.chNames, - imagesPath=posData.images_path, + channelNames=posData.chNames, + imagesPath=posData.images_path, hideCentroidsSection=onlyMouseClicks, hideWeightedCentroidsSection=onlyMouseClicks, hideFromTableSection=onlyMouseClicks, @@ -211,15 +206,15 @@ def addPointsLayerTriggered(self, checked=False, toolbar=None): hideWithMouseClicksSection=False, parent=self, ) - cmap = matplotlib.colormaps['gist_rainbow'] + cmap = matplotlib.colormaps["gist_rainbow"] i = np.random.default_rng(seed=123).uniform() for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): + if not hasattr(action, "layerTypeIdx"): continue - rgb = [round(c*255) for c in cmap(i)][:3] + rgb = [round(c * 255) for c in cmap(i)][:3] self.addPointsWin.appearanceGroupbox.colorButton.setColor(rgb) break - + self.addPointsWin.sigCriticalReadTable.connect(self.logger.info) self.addPointsWin.sigLoadedTable.connect(self.logLoadedTablePointsLayer) self.addPointsWin.sigClosed.connect( @@ -231,69 +226,66 @@ def addPointsLayerTriggered(self, checked=False, toolbar=None): self.addPointsWin.show() if self.addPointsWin.clickEntryRadiobutton.isChecked(): QTimer.singleShot( - 200, + 200, partial( self.addPointsWin.sigCheckClickEntryTableEndnameExists.emit, - self.addPointsWin.clickEntryTableEndname.text(), - False - ) + self.addPointsWin.clickEntryTableEndname.text(), + False, + ), ) - def askLoadNewerRecoveryClickEntryDfs( - self, tableEndName, newer_recovery_filepaths - ): + def askLoadNewerRecoveryClickEntryDfs(self, tableEndName, newer_recovery_filepaths): if not newer_recovery_filepaths: return False num_tables = len(newer_recovery_filepaths) filepath, recovery_filepath = newer_recovery_filepaths[0] - main_timestamp = datetime.fromtimestamp( - os.path.getmtime(filepath) - ).strftime('%a %d. %b. %y - %H:%M:%S') + main_timestamp = datetime.fromtimestamp(os.path.getmtime(filepath)).strftime( + "%a %d. %b. %y - %H:%M:%S" + ) recovery_timestamp = datetime.fromtimestamp( os.path.getmtime(recovery_filepath) - ).strftime('%a %d. %b. %y - %H:%M:%S') + ).strftime("%a %d. %b. %y - %H:%M:%S") if num_tables == 1: text = html_utils.paragraph( - f'A newer recovery version of {tableEndName}.csv ' - 'was found.

    ' - f'Main table save date: {main_timestamp}
    ' - f'Recovery save date: {recovery_timestamp}

    ' - 'Do you want to load the newer recovery version?' + f"A newer recovery version of {tableEndName}.csv " + "was found.

    " + f"Main table save date: {main_timestamp}
    " + f"Recovery save date: {recovery_timestamp}

    " + "Do you want to load the newer recovery version?" ) else: text = html_utils.paragraph( - f'Newer recovery versions of {tableEndName}.csv ' - f'were found for {num_tables} positions.

    ' - f'Example main table save date: {main_timestamp}
    ' - f'Example recovery save date: {recovery_timestamp}

    ' - 'Do you want to load the newer recovery version where available?' + f"Newer recovery versions of {tableEndName}.csv " + f"were found for {num_tables} positions.

    " + f"Example main table save date: {main_timestamp}
    " + f"Example recovery save date: {recovery_timestamp}

    " + "Do you want to load the newer recovery version where available?" ) msg = widgets.myMessageBox(wrapText=False) _, yesButton, _ = msg.warning( - self.addPointsWin, 'Newer recovery table found', text, - buttonsTexts=('Cancel', 'Yes, load newer recovery', 'No, load main table') + self.addPointsWin, + "Newer recovery table found", + text, + buttonsTexts=("Cancel", "Yes, load newer recovery", "No, load main table"), ) return msg.clickedButton == yesButton def askSaveAddedPoints(self): msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - 'Do you want to save the annotated points?' - ) + txt = html_utils.paragraph("Do you want to save the annotated points?") _, noButton, yesButton = msg.question( - self, 'Save?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') + self, "Save?", txt, buttonsTexts=("Cancel", "No", "Yes") ) if msg.clickedButton != yesButton: return - + for toolbar in self.pointsLayersToolbars: for action in self.pointsLayersToolbar.actions(): try: - if 'Save annotated' in action.text(): + if "Save annotated" in action.text(): action.trigger() except Exception as err: pass @@ -302,50 +294,51 @@ def askSavePointsLayer(self, action): toolButton = action.button tableEndName = toolButton.clickEntryTableEndName saveAction = toolButton.saveAction - + txt = html_utils.paragraph(f""" Do you want to save the points you added (table called {tableEndName}.csv)? - """ - ) + """) msg = widgets.myMessageBox(wrapText=False) _, _, saveButton = msg.question( - self, 'Save points layer?', txt, - buttonsTexts=('Cancel', 'No, do not save', 'Yes, save points') + self, + "Save points layer?", + txt, + buttonsTexts=("Cancel", "No, do not save", "Yes, save points"), ) if msg.clickedButton == saveButton: self.savePointsAddedByClicking(saveAction.saveToolbutton, None) - + return msg.cancel def autoPilotZoomToObjToggled(self, checked): if not checked: self.zoomOut() return - + posData = self.data[self.pos_i] if not posData.IDs: - self.logger.info('There are no objects in current segmentation mask') + self.logger.info("There are no objects in current segmentation mask") return self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) self.zoomToObj(posData.rp[0]) def autoZoomNextObj(self): self.sender().setValue(self.sender().value() - 1) - self.pointsLayerAutoPilot('next') + self.pointsLayerAutoPilot("next") self.setFocusMain() self.setFocusGraphics() def autoZoomPrevObj(self): self.sender().setValue(self.sender().value() + 1) - self.pointsLayerAutoPilot('prev') + self.pointsLayerAutoPilot("prev") self.setFocusMain() self.setFocusGraphics() def buttonAddPointsByClickingActive(self): for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): + if not hasattr(action, "layerTypeIdx"): continue if action.layerTypeIdx == 4 and action.button.isChecked(): return action.button @@ -353,14 +346,14 @@ def buttonAddPointsByClickingActive(self): def checkAskSavePointsLayers(self): for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): + if not hasattr(action, "layerTypeIdx"): continue if action.layerTypeIdx != 4: continue - + scatterItem = action.scatterItem xx, yy = scatterItem.getData() - + if xx is None or len(xx) == 0: toolButton = action.button tableEndName = toolButton.clickEntryTableEndName @@ -369,21 +362,21 @@ def checkAskSavePointsLayers(self): for pos_i, _posData in enumerate(self.data): if pos_i == self.pos_i: continue - + df = _posData.clickEntryPointsDfs.get(tableEndName) if df is None: continue - + are_there_points_to_save = True break - + if not are_there_points_to_save: continue - + cancel = self.askSavePointsLayer(action) if cancel: return cancel - + return False def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading): @@ -393,19 +386,21 @@ def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading): if os.path.exists(filepath): doesTableExists = True break - + if not doesTableExists: return - + if not forceLoading: msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - f'The table {tableEndName}.csv already exists!

    ' - 'Do you want to load it?' + f"The table {tableEndName}.csv already exists!

    " + "Do you want to load it?" ) _, yesButton, _ = msg.warning( - self.addPointsWin, 'Table exists!', txt, - buttonsTexts=('Cancel', 'Yes, load it', 'No, let me enter a new name') + self.addPointsWin, + "Table exists!", + txt, + buttonsTexts=("Cancel", "Yes, load it", "No, let me enter a new name"), ) if msg.clickedButton != yesButton: return @@ -417,23 +412,21 @@ def checkClickEntryTableEndnameExists(self, tableEndName, forceLoading): tableEndName, newer_recovery_filepaths ) - self.loadClickEntryDfs( - tableEndName, loadRecoveryIfNewer=load_recovery_if_newer - ) + self.loadClickEntryDfs(tableEndName, loadRecoveryIfNewer=load_recovery_if_newer) def checkLoadedTableIds(self, toolbar): if toolbar != self.promptSegmentPointsLayerToolbar: return True - + for posData in self.data: for tableEndName, df in posData.clickEntryPointsDfs.items(): - for point_id in df['id'].values: + for point_id in df["id"].values: if point_id in posData.IDs_idxs: proceed = self.warnAddingPointWithExistingId( point_id, table_endname=tableEndName ) return proceed - + return True def clearPointsLayers(self): @@ -448,7 +441,7 @@ def drawPointsLayers(self, computePointsLayers=True): posData = self.data[self.pos_i] for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): + if not hasattr(action, "layerTypeIdx"): continue if action.layerTypeIdx < 2 and computePointsLayers: @@ -456,76 +449,72 @@ def drawPointsLayers(self, computePointsLayers=True): if not action.button.isChecked(): continue - + frames = action.pointsData.get(self.pos_i, set()) if posData.frame_i not in frames: if action.layerTypeIdx != 4: self.logger.info( - f'Frame number {posData.frame_i+1} does not have any ' + f"Frame number {posData.frame_i + 1} does not have any " f'"{action.layerType}" point to display.' ) continue - + framePointsData = action.pointsData[self.pos_i][posData.frame_i] - - if 'x' not in framePointsData: + + if "x" not in framePointsData: # 3D points zProjHow = self.zProjComboBox.currentText() - isZslice = ( - zProjHow == 'single z-slice' and posData.SizeZ > 1 - ) + isZslice = zProjHow == "single z-slice" and posData.SizeZ > 1 if isZslice: xx, yy, ids, data = [], [], [], [] zSlice = self.zSliceScrollBar.sliderPosition() zRadius = action.zRadius - zRange = range(zSlice-zRadius, zSlice+zRadius+1) + zRange = range(zSlice - zRadius, zSlice + zRadius + 1) for z in zRange: z_data = framePointsData.get(z) if z_data is None: continue - xx.extend(z_data['x']) - yy.extend(z_data['y']) - ids.extend(z_data['id']) + xx.extend(z_data["x"]) + yy.extend(z_data["y"]) + ids.extend(z_data["id"]) try: - data.extend(z_data['data']) + data.extend(z_data["data"]) except KeyError as err: # data is needed only for loaded tables - pass + pass else: xx, yy, ids, data = [], [], [], [] # z-projection --> draw all points for z, z_data in framePointsData.items(): - xx.extend(z_data['x']) - yy.extend(z_data['y']) - ids.extend(z_data['id']) + xx.extend(z_data["x"]) + yy.extend(z_data["y"]) + ids.extend(z_data["id"]) try: - data.extend(z_data['data']) + data.extend(z_data["data"]) except KeyError as err: # data is needed only for loaded tables - pass + pass else: # 2D segmentation - xx = framePointsData['x'] - yy = framePointsData['y'] - ids = framePointsData['id'] + xx = framePointsData["x"] + yy = framePointsData["y"] + ids = framePointsData["id"] try: - data = framePointsData['data'] + data = framePointsData["data"] except KeyError as err: # data is needed only for loaded tables - pass - + pass + brushColors = [ - action.brushColor if id != 0 else action.brushColorId0 - for id in ids + action.brushColor if id != 0 else action.brushColorId0 for id in ids ] brushes = [pg.mkBrush(color) for color in brushColors] - + pensColor = [ - action.penColor if id != 0 else action.penColorId0 - for id in ids + action.penColor if id != 0 else action.penColorId0 for id in ids ] pens = [pg.mkPen(color) for color in pensColor] - + if action.layerTypeIdx == 2: # For loaded table show the rest of the table as a tooltip data = data @@ -533,14 +522,12 @@ def drawPointsLayers(self, computePointsLayers=True): else: data = ids show_data_as_tip = False - - xx = np.array(xx) # + 0.5 - yy = np.array(yy) # + 0.5 - + + xx = np.array(xx) # + 0.5 + yy = np.array(yy) # + 0.5 + action.scatterItem.show_data_as_tip = show_data_as_tip - action.scatterItem.setData( - xx, yy, data=data, brush=brushes, pen=pens - ) + action.scatterItem.setData(xx, yy, data=data, brush=brushes, pen=pens) def editPointsLayerAppearance(self, button): win = apps.EditPointsLayerAppearanceDialog(parent=self) @@ -548,22 +535,22 @@ def editPointsLayerAppearance(self, button): win.exec_() if win.cancel: return - + symbol = win.symbol color = win.color pointSize = win.pointSize - zRadius = int((win.zHeight-1)/2) - r,g,b,a = color.getRgb() + zRadius = int((win.zHeight - 1) / 2) + r, g, b, a = color.getRgb() scatterItem = button.action.scatterItem - scatterItem.opts['hoverBrush'] = pg.mkBrush((r,g,b,200)) + scatterItem.opts["hoverBrush"] = pg.mkBrush((r, g, b, 200)) scatterItem.setSymbol(symbol, update=False) - scatterItem.setBrush(pg.mkBrush(color=(r,g,b,100)), update=False) - scatterItem.setPen(pg.mkPen(width=2, color=(r,g,b)), update=False) + scatterItem.setBrush(pg.mkBrush(color=(r, g, b, 100)), update=False) + scatterItem.setPen(pg.mkPen(width=2, color=(r, g, b)), update=False) scatterItem.setSize(pointSize, update=True) - - button.action.brushColor = (r,g,b,100) - button.action.penColor = (r,g,b) + + button.action.brushColor = (r, g, b, 100) + button.action.penColor = (r, g, b) button.action.pointSize = pointSize button.action.zRadius = zRadius @@ -573,51 +560,57 @@ def flushDirtyPointsLayersAutosave(self): if not self.dirtyPointsLayerTableEndNames: return - for tableEndName in tuple(self.dirtyPointsLayerTableEndNames): # avoid runtime error - self.savePointsAddedByClickingFromEndname( - tableEndName, recovery=True - ) + for tableEndName in tuple( + self.dirtyPointsLayerTableEndNames + ): # avoid runtime error + self.savePointsAddedByClickingFromEndname(tableEndName, recovery=True) self.dirtyPointsLayerTableEndNames.clear() def getAddedPointId( - self, isMagicPrompts, addPointsByClickingButton, - right_click, left_click, middle_click - ): + self, + isMagicPrompts, + addPointsByClickingButton, + right_click, + left_click, + middle_click, + ): action = addPointsByClickingButton.action if right_click: id = addPointsByClickingButton.rightClickIDSpinbox.value() elif left_click: - id = addPointsByClickingButton.pointIdSpinbox.value() + id = addPointsByClickingButton.pointIdSpinbox.value() id = self.getClickedPointNewId( - action, id, addPointsByClickingButton.pointIdSpinbox, - isMagicPrompts=isMagicPrompts + action, + id, + addPointsByClickingButton.pointIdSpinbox, + isMagicPrompts=isMagicPrompts, ) if isMagicPrompts: proceed = self.warnAddingPointWithExistingId(id) if not proceed: return - + addPointsByClickingButton.pointIdSpinbox.setValue(id) elif middle_click: id = 0 - + return id def getCentroidsPointsData(self, action): # Centroids (either weighted or not) - # NOTE: if user requested to draw from table we load that in + # NOTE: if user requested to draw from table we load that in # apps.AddPointsLayerDialog.ok_cb() posData = self.data[self.pos_i] action.pointsData[self.pos_i] = {posData.frame_i: {}} - if hasattr(action, 'weighingData'): + if hasattr(action, "weighingData"): lab = posData.lab img = action.weighingData[self.pos_i][posData.frame_i] rp = skimage.measure.regionprops(lab, intensity_image=img) - attr = 'weighted_centroid' + attr = "weighted_centroid" else: rp = posData.rp - attr = 'centroid' + attr = "centroid" for i, obj in enumerate(rp): centroid = getattr(obj, attr) if len(centroid) == 3: @@ -625,25 +618,25 @@ def getCentroidsPointsData(self, action): z_int = round(zc) if z_int not in action.pointsData[self.pos_i][posData.frame_i]: action.pointsData[self.pos_i][posData.frame_i][z_int] = { - 'x': [xc], 'y': [yc], 'id': [obj.label] + "x": [xc], + "y": [yc], + "id": [obj.label], } else: z_data = action.pointsData[self.pos_i][posData.frame_i][z_int] - z_data['x'].append(xc) - z_data['y'].append(yc) - z_data['id'].append(obj.label) + z_data["x"].append(xc) + z_data["y"].append(yc) + z_data["id"].append(obj.label) else: yc, xc = centroid - if 'y' not in action.pointsData[self.pos_i][posData.frame_i]: - action.pointsData[self.pos_i][posData.frame_i]['y'] = [yc] - action.pointsData[self.pos_i][posData.frame_i]['x'] = [xc] - action.pointsData[self.pos_i][posData.frame_i]['id'] = ( - [obj.label] - ) + if "y" not in action.pointsData[self.pos_i][posData.frame_i]: + action.pointsData[self.pos_i][posData.frame_i]["y"] = [yc] + action.pointsData[self.pos_i][posData.frame_i]["x"] = [xc] + action.pointsData[self.pos_i][posData.frame_i]["id"] = [obj.label] else: - action.pointsData[self.pos_i][posData.frame_i]['y'].append(yc) - action.pointsData[self.pos_i][posData.frame_i]['x'].append(xc) - action.pointsData[self.pos_i][posData.frame_i]['id'].append( + action.pointsData[self.pos_i][posData.frame_i]["y"].append(yc) + action.pointsData[self.pos_i][posData.frame_i]["x"].append(xc) + action.pointsData[self.pos_i][posData.frame_i]["id"].append( obj.label ) @@ -656,7 +649,9 @@ def getClickEntryNewerRecoveryFilepaths(self, tableEndName): if not os.path.exists(filepath) or not os.path.exists(recovery_filepath): continue - if os.path.getmtime(recovery_filepath) <= os.path.getmtime(filepath) + 15: # add a 15 second tolerance + if ( + os.path.getmtime(recovery_filepath) <= os.path.getmtime(filepath) + 15 + ): # add a 15 second tolerance continue newer_recovery_filepaths.append((filepath, recovery_filepath)) @@ -664,35 +659,33 @@ def getClickEntryNewerRecoveryFilepaths(self, tableEndName): return newer_recovery_filepaths def getClickEntryTableFilepaths(self, posData, tableEndName): - if posData.basename.endswith('_'): + if posData.basename.endswith("_"): basename = posData.basename else: - basename = f'{posData.basename}_' + basename = f"{posData.basename}_" - csv_filename = f'{basename}{tableEndName}' - if not csv_filename.endswith('.csv'): - csv_filename = f'{csv_filename}.csv' + csv_filename = f"{basename}{tableEndName}" + if not csv_filename.endswith(".csv"): + csv_filename = f"{csv_filename}.csv" filepath = os.path.join(posData.images_path, csv_filename) - recovery_filepath = os.path.join( - posData.images_path, 'recovery', csv_filename - ) + recovery_filepath = os.path.join(posData.images_path, "recovery", csv_filename) return filepath, recovery_filepath def getClickedPointNewId( - self, action, current_id, pointIdSpinbox, isMagicPrompts=False - ): - removed_id = getattr(pointIdSpinbox, 'removedId', None) + self, action, current_id, pointIdSpinbox, isMagicPrompts=False + ): + removed_id = getattr(pointIdSpinbox, "removedId", None) if removed_id is not None: pointIdSpinbox.removedId = None return removed_id - + posData = self.data[self.pos_i] if isMagicPrompts: is_already_new = self.isPointIdAlreadyNew(current_id, action) if is_already_new: return current_id - + new_ID = self.setBrushID(return_val=True) new_id = max(current_id, new_ID) + 1 return new_id @@ -700,18 +693,18 @@ def getClickedPointNewId( pointsDataPos = action.pointsData.get(self.pos_i) if pointsDataPos is None: return 1 - + framePointsData = pointsDataPos.get(posData.frame_i) if framePointsData is None: return 1 if posData.SizeZ > 1: new_id = 1 for z_data in framePointsData.values(): - max_id = max(z_data.get('id', 0), default=0) + 1 + max_id = max(z_data.get("id", 0), default=0) + 1 if max_id > new_id: new_id = max_id else: - new_id = max(framePointsData.get('id', 0), default=0) + 1 + new_id = max(framePointsData.get("id", 0), default=0) + 1 if current_id >= new_id: return current_id return new_id @@ -720,25 +713,25 @@ def isPointIdAlreadyNew(self, point_id, action): posData = self.data[self.pos_i] if point_id in posData.IDs_idxs: return False - + is_ID = point_id in posData.IDs_idxs pointsDataPos = action.pointsData.get(self.pos_i) if pointsDataPos is None: return not is_ID - + framePointsData = pointsDataPos.get(posData.frame_i) if framePointsData is None: return not is_ID - - if 'x' not in framePointsData: + + if "x" not in framePointsData: is_id_already_added = False for z, z_data in framePointsData.items(): - if point_id in z_data['id']: + if point_id in z_data["id"]: is_id_already_added = True break else: - is_id_already_added = point_id in framePointsData['id'] - + is_id_already_added = point_id in framePointsData["id"] + is_already_new = not is_ID and not is_id_already_added return is_already_new @@ -751,13 +744,10 @@ def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): if loadRecoveryIfNewer: recovery_exists = os.path.exists(recovery_filepath) main_exists = os.path.exists(filepath) - if ( - recovery_exists - and ( - not main_exists - or os.path.getmtime(recovery_filepath) - > os.path.getmtime(filepath) + 15 - ) + if recovery_exists and ( + not main_exists + or os.path.getmtime(recovery_filepath) + > os.path.getmtime(filepath) + 15 ): filepath = recovery_filepath elif not main_exists: @@ -768,10 +758,10 @@ def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): self.logger.info(f'Loading points from "{filepath}"...') df = pd.read_csv(filepath) - if 'id' not in df.columns: - df['id'] = range(1, len(df)+1) + if "id" not in df.columns: + df["id"] = range(1, len(df) + 1) posData.clickEntryPointsDfs[tableEndName] = df - + try: self.addPointsWin.loadButton.confirmAction() except Exception as err: @@ -780,7 +770,7 @@ def loadClickEntryDfs(self, tableEndName, loadRecoveryIfNewer=False): def loadPointsLayerWeighingData(self, action, weighingChannel): if not weighingChannel: return - + self.logger.info(f'Loading "{weighingChannel}" weighing data...') action.weighingData = [] for p, posData in enumerate(self.data): @@ -791,10 +781,10 @@ def loadPointsLayerWeighingData(self, action, weighingChannel): path, filename = self.getPathFromChName(weighingChannel, posData) if path is None: - self.criticalFluoChannelNotFound(weighingChannel, posData) + self.criticalFluoChannelNotFound(weighingChannel, posData) action.weighingData = [] return - + if filename in posData.fluo_data_dict: # Weighing data already loaded as additional fluo channel wData = posData.fluo_data_dict[filename] @@ -806,23 +796,17 @@ def loadPointsLayerWeighingData(self, action, weighingChannel): action.weighingData.append(wData) def logLoadedTablePointsLayer(self, df, filename: str): - separator = f'-'*100 + separator = f"-" * 100 header = f'First 10 rows of loaded table - "{filename}":' - footer = f'Number of points: {len(df)}' - text = ( - f'{separator}\n' - f'{header}\n\n' - f'{df.head(10)}\n\n' - f'{footer}\n' - f'{separator}' - ) + footer = f"Number of points: {len(df)}" + text = f"{separator}\n{header}\n\n{df.head(10)}\n\n{footer}\n{separator}" if filename: - text = f'{text}\nFilename: {filename}' + text = f"{text}\nFilename: {filename}" self.logger.info(text) def markPointsLayerDirty(self, tableEndName=None, action=None): if tableEndName is None and action is not None: - tableEndName = getattr(action, 'clickEntryTableEndName', None) + tableEndName = getattr(action, "clickEntryTableEndName", None) if tableEndName is None: addPointsByClickingButton = self.buttonAddPointsByClickingActive() @@ -843,75 +827,77 @@ def pointsLayerAutoPilot(self, direction): posData = self.data[self.pos_i] if not posData.IDs: return - + try: ID_idx = posData.IDs_idxs[ID] - if direction == 'next': + if direction == "next": nextID_idx = ID_idx + 1 else: nextID_idx = ID_idx - 1 obj = posData.rp[nextID_idx] except Exception as e: - self.logger.info( - f'Auto-pilot restarted from first ID' - ) + self.logger.info(f"Auto-pilot restarted from first ID") obj = posData.rp[0] - + self.autoPilotZoomToObjSpinBox.setValue(obj.label) self.zoomToObj(obj) def pointsLayerClicksDfsToData(self, posData, toolbar=None): if toolbar is None: toolbar = self.pointsLayersToolbar - + for action in toolbar.actions()[1:]: - if not hasattr(action, 'button'): + if not hasattr(action, "button"): continue - - if not hasattr(action.button, 'clickEntryTableEndName'): + + if not hasattr(action.button, "clickEntryTableEndName"): continue tableEndName = action.button.clickEntryTableEndName action.pointsData[self.pos_i] = {} if posData.clickEntryPointsDfs.get(tableEndName) is None: continue - + df = posData.clickEntryPointsDfs[tableEndName] - - if posData.SizeZ > 1 and df['z'].isna().any(): + + if posData.SizeZ > 1 and df["z"].isna().any(): self.warnLoadedPointsTableIsNot3D(tableEndName) return - - for frame_i, df_frame in df.groupby('frame_i'): + + for frame_i, df_frame in df.groupby("frame_i"): action.pointsData[self.pos_i][frame_i] = {} if posData.SizeZ > 1: - for z, df_zlice in df_frame.groupby('z'): - xx = df_zlice['x'].to_list() - yy = df_zlice['y'].to_list() - ids = df_zlice['id'].to_list() + for z, df_zlice in df_frame.groupby("z"): + xx = df_zlice["x"].to_list() + yy = df_zlice["y"].to_list() + ids = df_zlice["id"].to_list() action.pointsData[self.pos_i][frame_i][z] = { - 'x': xx, 'y': yy, 'id': ids + "x": xx, + "y": yy, + "id": ids, } else: - xx = df_frame['x'].to_list() - yy = df_frame['y'].to_list() - ids = df_frame['id'].to_list() + xx = df_frame["x"].to_list() + yy = df_frame["y"].to_list() + ids = df_frame["id"].to_list() action.pointsData[self.pos_i][frame_i] = { - 'x': xx, 'y': yy, 'id': ids + "x": xx, + "y": yy, + "id": ids, } def pointsLayerDataToDf(self, posData, getOnlyActive=False, toolbar=None): df = None for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, 'button'): + if not hasattr(action, "button"): continue - if not hasattr(action.button, 'clickEntryTableEndName'): + if not hasattr(action.button, "clickEntryTableEndName"): continue - + tableEndName = action.button.clickEntryTableEndName if getOnlyActive and not action.button.isChecked(): continue - + df = toolbar.fromActionToDataFrame( action, posData, isSegm3D=self.isSegm3D ) @@ -925,27 +911,28 @@ def pointsLayerLoadedDfsToData(self): posData = self.data[self.pos_i] for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, 'loadedDfInfo'): + if not hasattr(action, "loadedDfInfo"): continue - + if action.loadedDfInfo is None: continue - - endname = action.loadedDfInfo.get('endname') + + endname = action.loadedDfInfo.get("endname") if endname is None: continue - - filename = f'{posData.basename}{endname}' + + filename = f"{posData.basename}{endname}" filepath = os.path.join(posData.images_path, filename) if not os.path.exists(filepath): action.pointsData[self.pos_i] = {} - - df = load.load_df_points_layer(filepath) - action.pointsData[self.pos_i] = ( - load.loaded_df_to_points_data( - df, action.loadedDfInfo['t'], action.loadedDfInfo['z'], - action.loadedDfInfo['y'], action.loadedDfInfo['x'] - ) + + df = load.load_df_points_layer(filepath) + action.pointsData[self.pos_i] = load.loaded_df_to_points_data( + df, + action.loadedDfInfo["t"], + action.loadedDfInfo["z"], + action.loadedDfInfo["y"], + action.loadedDfInfo["x"], ) self.logLoadedTablePointsLayer(df, filename=filename) @@ -953,7 +940,7 @@ def pointsLayerToggled(self, checked): if not checked: for action in self.pointsLayersToolbar.actions(): try: - if 'Save annotated' in action.text(): + if "Save annotated" in action.text(): self.askSaveAddedPoints() break except Exception as err: @@ -977,36 +964,37 @@ def removeClickedPoints(self, action, points): framePointsData = action.pointsData[self.pos_i][posData.frame_i] if posData.SizeZ > 1: zProjHow = self.zProjComboBox.currentText() - if zProjHow != 'single z-slice': + if zProjHow != "single z-slice": _warnings.warnCannotAddRemovePointsProjection() return zSlice = self.zSliceScrollBar.sliderPosition() else: zSlice = None - + removed_ids = [] for point in points: pos = point.pos() x, y = pos.x(), pos.y() if zSlice is not None: zSliceRad = action.zRadius - sliceFramePointsData = [framePointsData[z] for z in range( - zSlice-zSliceRad, zSlice+zSliceRad+1 - ) if z in framePointsData.keys()] + sliceFramePointsData = [ + framePointsData[z] + for z in range(zSlice - zSliceRad, zSlice + zSliceRad + 1) + if z in framePointsData.keys() + ] else: sliceFramePointsData = [framePointsData] - for sliceFramePointsData in sliceFramePointsData: - if point.data() in sliceFramePointsData['id']: - sliceFramePointsData['x'].remove(x) - sliceFramePointsData['y'].remove(y) - sliceFramePointsData['id'].remove(point.data()) + if point.data() in sliceFramePointsData["id"]: + sliceFramePointsData["x"].remove(x) + sliceFramePointsData["y"].remove(y) + sliceFramePointsData["id"].remove(point.data()) removed_ids.append(point.data()) if removed_ids: self.markPointsLayerDirty(action=action) - + return removed_ids def removePointsLayer(self, button, toolbar=None): @@ -1017,7 +1005,7 @@ def removePointsLayer(self, button, toolbar=None): toolbar.removeAction(button.action) for action in button.actions: toolbar.removeAction(action) - + if toolbar == self.promptSegmentPointsLayerToolbar: self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False @@ -1027,24 +1015,22 @@ def resizeRangeWelcomeText(self): deltaY = yRange[1] - yRange[0] self.ax1.setXRange(0, deltaX) self.ax1.setYRange(0, deltaY) - self.ax1.setLimits( - xMin=0, xMax=deltaX, yMin=0, yMax=deltaY - ) + self.ax1.setLimits(xMin=0, xMax=deltaX, yMin=0, yMax=deltaY) def restartZoomAutoPilot(self): if not self.autoPilotZoomToObjToggle.isChecked(): return - + posData = self.data[self.pos_i] if not posData.IDs: return - + self.autoPilotZoomToObjSpinBox.setValue(posData.IDs[0]) self.zoomToObj(posData.rp[0]) def restorePrevPointIdRightClick(self, addPointsByClickingButton): - # Try to restore the id that was there before hovering - # because the hovering was required only to delete the + # Try to restore the id that was there before hovering + # because the hovering was required only to delete the # point try: prevId = addPointsByClickingButton.rightClickIDSpinbox.prevId @@ -1056,22 +1042,22 @@ def savePointsAddedByClicking(self, button, event): sender = button.action toolButton = sender.toolButton tableEndName = toolButton.clickEntryTableEndName - - self.logger.info(f'Saving _{tableEndName}.csv table...') - + + self.logger.info(f"Saving _{tableEndName}.csv table...") + self.savePointsAddedByClickingFromEndname(tableEndName) - - self.logger.info(f'{tableEndName}.csv saved!') - self.titleLabel.setText(f'{tableEndName}.csv saved!', color='g') + + self.logger.info(f"{tableEndName}.csv saved!") + self.titleLabel.setText(f"{tableEndName}.csv saved!", color="g") def savePointsAddedByClickingFromEndname(self, tableEndName, recovery=False): self.pointsLayerDataToDf(self.data[self.pos_i]) for posData in self.data: - if not posData.basename.endswith('_'): - basename = f'{posData.basename}_' + if not posData.basename.endswith("_"): + basename = f"{posData.basename}_" else: basename = posData.basename - tableFilename = f'{basename}{tableEndName}.csv' + tableFilename = f"{basename}{tableEndName}.csv" if recovery: tableFilepath = os.path.join( posData.recoveryFolderpath(), tableFilename @@ -1081,7 +1067,7 @@ def savePointsAddedByClickingFromEndname(self, tableEndName, recovery=False): df = posData.clickEntryPointsDfs.get(tableEndName) if df is None: continue - df = df.sort_values(['frame_i', 'Cell_ID']) + df = df.sort_values(["frame_i", "Cell_ID"]) df.to_csv(tableFilepath, index=False) def setHoverCircleAddPoint(self, x, y): @@ -1090,32 +1076,31 @@ def setHoverCircleAddPoint(self, x, y): return action = addPointsByClickingButton.action self.setHoverToolSymbolData( - [x], [y], (self.ax1_BrushCircle,), - size=action.pointSize + [x], [y], (self.ax1_BrushCircle,), size=action.pointSize ) def setPointsLayerLoadedDfEndanme(self, action): if action.loadedDfInfo is None: return - + posData = self.data[self.pos_i] - images_path = posData.images_path.replace('\\', '/') - + images_path = posData.images_path.replace("\\", "/") + df_folderpath = os.path.dirname( - action.loadedDfInfo['filepath'].replace('\\', '/') + action.loadedDfInfo["filepath"].replace("\\", "/") ) - + if images_path != df_folderpath: return - - df_filename = os.path.basename(action.loadedDfInfo['filepath']) - + + df_filename = os.path.basename(action.loadedDfInfo["filepath"]) + if not df_filename.startswith(posData.basename): return - - endname = df_filename[len(posData.basename):] - action.loadedDfInfo['endname'] = endname - + + endname = df_filename[len(posData.basename) :] + action.loadedDfInfo["endname"] = endname + action.button.setToolTip(endname) def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): @@ -1124,56 +1109,52 @@ def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): tableEndName = self.addPointsWin.clickEntryTableEndnameText if isLoadedDf is not None: posData = self.data[self.pos_i] - tableEndName = tableEndName[len(posData.basename):] + tableEndName = tableEndName[len(posData.basename) :] self.loadClickEntryDfs(tableEndName) - + toolButton.toolbar = toolbar toolButton.clickEntryTableEndName = tableEndName self.checkableQButtonsGroup.addButton(toolButton) toolButton.toggled.connect(self.addPointsByClickingButtonToggled) self.addPointsByClickingButtonToggled(sender=toolButton) - + toolButton.setToolTip(tableEndName) - + pointIdSpinbox = widgets.SpinBox() pointIdSpinbox.setMinimum(0) pointIdSpinbox.setValue(1) - pointIdSpinbox.label = QLabel(' Left-click ID: ') + pointIdSpinbox.label = QLabel(" Left-click ID: ") pointIdSpinbox.labelAction = toolbar.addWidget(pointIdSpinbox.label) if toolbar == self.promptSegmentPointsLayerToolbar: newID = self.setBrushID(return_val=True) pointIdSpinbox.setValue(newID) pointIdSpinbox.setReadOnly(True) pointIdSpinbox.setToolTip( - 'The ids added with left-click cannot be manually edited. ' - 'They are always a new, non-existing id.' - ) - + "The ids added with left-click cannot be manually edited. " + "They are always a new, non-existing id." + ) + toolButton.actions.append(pointIdSpinbox.labelAction) pointIdSpinbox.action = toolbar.addWidget(pointIdSpinbox) toolButton.actions.append(pointIdSpinbox.action) pointIdSpinbox.toolButton = toolButton toolButton.pointIdSpinbox = pointIdSpinbox - + rightClickIDSpinbox = widgets.SpinBox() pointIdSpinbox.setLinkedValueWidget(rightClickIDSpinbox) rightClickIDSpinbox.setMaximumWidth(pointIdSpinbox.sizeHint().width()) rightClickIDSpinbox.setValue(pointIdSpinbox.value()) rightClickIDSpinbox.setMinimum(0) - rightClickIDSpinbox.label = QLabel(' | Right-click ID: ') - rightClickIDSpinbox.labelAction = toolbar.addWidget( - rightClickIDSpinbox.label - ) + rightClickIDSpinbox.label = QLabel(" | Right-click ID: ") + rightClickIDSpinbox.labelAction = toolbar.addWidget(rightClickIDSpinbox.label) toolButton.actions.append(rightClickIDSpinbox.labelAction) rightClickIDSpinbox.action = toolbar.addWidget(rightClickIDSpinbox) toolButton.actions.append(rightClickIDSpinbox.action) rightClickIDSpinbox.toolButton = toolButton toolButton.rightClickIDSpinbox = rightClickIDSpinbox - - saveToolbutton = widgets.SavePointsLayerButton( - tableEndName, parent=self - ) + + saveToolbutton = widgets.SavePointsLayerButton(tableEndName, parent=self) saveToolbutton.sigRenameTableAction.connect( self.updatePointsLayerClickEntryTableEndname ) @@ -1184,23 +1165,21 @@ def setupAddPointsByClicking(self, toolButton, isLoadedDf, toolbar): saveAction.toolButton = toolButton toolButton.saveAction = saveAction toolButton.saveToolbutton = saveToolbutton - + toolButton.actions.append(saveAction) - + vlineAction = toolbar.addWidget(widgets.QVLine()) - spacerAction = toolbar.addWidget( - widgets.QHWidgetSpacer(width=5) - ) - + spacerAction = toolbar.addWidget(widgets.QHWidgetSpacer(width=5)) + toolButton.actions.append(vlineAction) toolButton.actions.append(spacerAction) - + action = toolButton.action scatterItem = action.scatterItem scatterItem.sigHoverEntered.connect( self.addPointsByClickingScatterItemHoverEntered ) - + self.pointsLayerClicksDfsToData(posData, toolbar=toolbar) def showPointsLayerIdsToggled(self, button, checked): @@ -1208,14 +1187,14 @@ def showPointsLayerIdsToggled(self, button, checked): self.drawPointsLayers() def storeUndoAddPoint(self, action): - if not hasattr(self, 'undoAddPointQueueMapper'): + if not hasattr(self, "undoAddPointQueueMapper"): self.undoAddPointQueueMapper = defaultdict(list) posData = self.data[self.pos_i] pointsDataPos = action.pointsData.get(self.pos_i) if pointsDataPos is None: return - + state = deepcopy(pointsDataPos) self.undoAddPointQueueMapper[action].append(state) self.undoAction.setEnabled(True) @@ -1224,35 +1203,33 @@ def undoAddPoint(self, action): undoAddPointQueue = self.undoAddPointQueueMapper.get(action) if undoAddPointQueue is None: return False - + if len(undoAddPointQueue) == 0: return False - + posData = self.data[self.pos_i] state = undoAddPointQueue.pop(-1) action.pointsData[self.pos_i] = state self.markPointsLayerDirty(action=action) - + self.drawPointsLayers(computePointsLayers=False) - + if len(self.undoAddPointQueueMapper[action]) == 0: - self.undoAction.setEnabled(True) - + self.undoAction.setEnabled(True) + return True - def updatePointsLayerClickEntryTableEndname( - self, saveToolbutton, table_endname - ): + def updatePointsLayerClickEntryTableEndname(self, saveToolbutton, table_endname): saveAction = saveToolbutton.action toolButton = saveAction.toolButton toolButton.clickEntryTableEndName = table_endname - + self.logger.info( f'Done. Click entry table endname updated to "{table_endname}"' ) def zoomToObj(self, obj=None): - if not hasattr(self, 'data'): + if not hasattr(self, "data"): return posData = self.data[self.pos_i] if obj is None: @@ -1261,16 +1238,14 @@ def zoomToObj(self, obj=None): ID_idx = posData.IDs_idxs[ID] obj = obj = posData.rp[ID_idx] except Exception as e: - self.logger.warning( - f'ID {ID} does not exist (add points by clicking)' - ) - + self.logger.warning(f"ID {ID} does not exist (add points by clicking)") + if obj is None: return - - self.goToZsliceSearchedID(obj) + + self.goToZsliceSearchedID(obj) min_row, min_col, max_row, max_col = self.getObjBbox(obj.bbox) - xRange = min_col-5, max_col+5 - yRange = max_row+5, min_row-5 + xRange = min_col - 5, max_col + 5 + yRange = max_row + 5, min_row - 5 self.ax1.setRange(xRange=xRange, yRange=yRange) diff --git a/cellacdc/mixins/preprocessing.py b/cellacdc/mixins/preprocessing.py index f341dca6e..b4c0f5644 100644 --- a/cellacdc/mixins/preprocessing.py +++ b/cellacdc/mixins/preprocessing.py @@ -12,6 +12,7 @@ from .session import Session + class Preprocessing(Session): """Extracted from guiWin.""" @@ -22,12 +23,14 @@ def askGet2Dor3Dimage(self): """) msg = widgets.myMessageBox(wrapText=False) _, use3Dbutton, use2Dbutton = msg.question( - self, '3D denoising?', txt, - buttonsTexts=('Cancel', 'Denoise 3D z-stack', 'Denoise 2D image') + self, + "3D denoising?", + txt, + buttonsTexts=("Cancel", "Denoise 3D z-stack", "Denoise 2D image"), ) if msg.cancel: - return - + return + if msg.clickedButton == use3Dbutton: posData = self.data[self.pos_i] zslice = self.zSliceScrollBar.sliderPosition() @@ -57,83 +60,75 @@ def getChData(self, requ_ch=None, pos_i=None): self.loadFluo_cb(fluo_channels=missing_channels) def preprocWorkerClosed(self, worker): - self.logger.info('Pre-processing worker stopped.') + self.logger.info("Pre-processing worker stopped.") def preprocWorkerCritical(self, error): self.preprocessDialog.appliedFinished() self.workerCritical(error) def preprocWorkerDone( - self, - processed_data: np.ndarray, - how: str, - ): + self, + processed_data: np.ndarray, + how: str, + ): self.setStatusBarLabel(log=False) self.preprocessDialog.appliedFinished() - + posData = self.data[self.pos_i] - if not hasattr(posData, 'preproc_img_data'): + if not hasattr(posData, "preproc_img_data"): posData.preproc_img_data = preprocess.PreprocessedData() - if how == 'current_image': + if how == "current_image": if posData.SizeZ > 1: z_slice = self.z_slice_index() - posData.preproc_img_data[posData.frame_i][z_slice] = ( - processed_data - ) + posData.preproc_img_data[posData.frame_i][z_slice] = processed_data else: posData.preproc_img_data[posData.frame_i] = processed_data z_slice = 0 self.img1.updateMinMaxValuesPreprocessedData( self.data, self.pos_i, posData.frame_i, z_slice ) - elif how == 'z_stack': + elif how == "z_stack": for z_slice, processed_img in enumerate(processed_data): - posData.preproc_img_data[posData.frame_i][z_slice] = ( - processed_img - ) + posData.preproc_img_data[posData.frame_i][z_slice] = processed_img self.img1.updateMinMaxValuesPreprocessedData( self.data, self.pos_i, posData.frame_i, z_slice ) self.img1.updateMinMaxValuesPreprocessedProjections( self.data, self.pos_i, posData.frame_i ) - elif how == 'all_frames': + elif how == "all_frames": for frame_i, processed_frame in enumerate(processed_data): if processed_frame.ndim == 2: processed_frame = (processed_frame,) - + for z_slice, processed_img in enumerate(processed_frame): - posData.preproc_img_data[frame_i][z_slice] = ( - processed_img - ) + posData.preproc_img_data[frame_i][z_slice] = processed_img self.img1.updateMinMaxValuesPreprocessedData( self.data, self.pos_i, frame_i, z_slice ) self.img1.updateMinMaxValuesPreprocessedProjections( self.data, self.pos_i, frame_i ) - elif how == 'all_pos': - for pos_i, processed_pos_data in enumerate(processed_data): + elif how == "all_pos": + for pos_i, processed_pos_data in enumerate(processed_data): if processed_pos_data.ndim == 2: processed_pos_data = (processed_pos_data,) posData = self.data[pos_i] - if not hasattr(posData, 'preproc_img_data'): + if not hasattr(posData, "preproc_img_data"): posData.preproc_img_data = preprocess.PreprocessedData() for z_slice, processed_img in enumerate(processed_pos_data): - posData.preproc_img_data[0][z_slice] = ( - processed_img - ) + posData.preproc_img_data[0][z_slice] = processed_img self.img1.updateMinMaxValuesPreprocessedData( self.data, pos_i, 0, z_slice ) - + if posData.SizeZ > 1: self.img1.updateMinMaxValuesPreprocessedProjections( self.data, pos_i, frame_i ) - + if not self.viewPreprocDataToggle.isChecked(): self.viewPreprocDataToggle.setChecked(True) else: @@ -145,26 +140,23 @@ def preprocWorkerIsQueueEmpty(self, isEmpty: bool): else: self.preprocessDialog.setDisabled(True) self.preprocessDialog.infoLabel.setText( - 'Computing preview...
    ' - '(Feel free to use Cell-ACDC while waiting)' + "Computing preview...
    " + "(Feel free to use Cell-ACDC while waiting)" ) def preprocWorkerPreviewDone( - self, processed_data: np.ndarray, - key: Tuple[int, int, Union[int, str]] - ): + self, processed_data: np.ndarray, key: Tuple[int, int, Union[int, str]] + ): pos_i, frame_i, z_slice = key posData = self.data[pos_i] - if not hasattr(posData, 'preproc_img_data'): + if not hasattr(posData, "preproc_img_data"): posData.preproc_img_data = preprocess.PreprocessedData( image_data=np.zeros(posData.img_data.shape) ) - + posData.preproc_img_data[frame_i][z_slice] = processed_data - self.img1.updateMinMaxValuesPreprocessedData( - self.data, pos_i, frame_i, z_slice - ) - + self.img1.updateMinMaxValuesPreprocessedData(self.data, pos_i, frame_i, z_slice) + self.setImageImg1() def preprocessActionTriggered(self): @@ -174,152 +166,121 @@ def preprocessActionTriggered(self): self.preprocessDialog.emitSigPreviewToggled() def preprocessAllFrames(self, recipe: List[Dict[str, Any]]): - txt = 'Pre-processing all frames...' + txt = "Pre-processing all frames..." self.logger.info(txt) self.statusBarLabel.setText(txt) - + posData = self.data[self.pos_i] func = core.preprocess_video_from_recipe image_data = posData.img_data - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'all_frames' - ) + self.preprocWorker.setupJob(func, image_data, recipe, "all_frames") self.preprocWorker.wakeUp() def preprocessAllPos(self, recipe: List[Dict[str, Any]]): - txt = 'Pre-processing all Positions...' + txt = "Pre-processing all Positions..." self.logger.info(txt) self.statusBarLabel.setText(txt) - + func = core.preprocess_multi_pos_from_recipe recipe = core.validate_multidimensional_recipe( recipe, apply_to_all_frames=False ) image_data = [posData.img_data[0] for posData in self.data] - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'all_pos' - ) - + self.preprocWorker.setupJob(func, image_data, recipe, "all_pos") + self.preprocWorker.wakeUp() def preprocessCurrentImage(self, recipe: List[Dict[str, Any]], *args): - txt = 'Pre-processing current image...' + txt = "Pre-processing current image..." self.logger.info(txt) self.statusBarLabel.setText(txt) - + func = core.preprocess_image_from_recipe recipe = core.validate_multidimensional_recipe(recipe) - + image_data = self.getImage(raw=True) - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'current_image' - ) - + self.preprocWorker.setupJob(func, image_data, recipe, "current_image") + self.preprocWorker.wakeUp() - def preprocessDialogRecipeChanged(self, recipe):# why does this need the recepie as an arg + def preprocessDialogRecipeChanged( + self, recipe + ): # why does this need the recepie as an arg recipe = self.preprocessDialog.recipe() if recipe is None: - self.logger.warning('Pre-processing recipe not initialized yet.') + self.logger.warning("Pre-processing recipe not initialized yet.") return - + self.updatePreprocessPreview(recipe=recipe) def preprocessDialogSavePreprocessedData(self, dialog): posData = self.data[self.pos_i] - + try: posData.preprocessedDataArray() except TypeError as e: - if 'Not all frames have been processed.' in str(e): + if "Not all frames have been processed." in str(e): msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Not all frames have been processed.
    ' - 'Please process all frames before saving.' + "Not all frames have been processed.
    " + "Please process all frames before saving." ) - msg.warning(self, 'Process all data before saving', txt) + msg.warning(self, "Process all data before saving", txt) return - - helpText = ( - """ + helpText = """ The preprocessed image file will be saved with a different file name.

    Insert a name to append to the end of the new file name. The rest of the name will be the same as the original file. """ - ) - - + win = apps.filenameDialog( - basename=f'{posData.basename}{self.user_ch_name}', + basename=f"{posData.basename}{self.user_ch_name}", ext=".tif", - hintText='Insert a name for the preprocessed image file:', - defaultEntry='preprocessed', - helpText=helpText, + hintText="Insert a name for the preprocessed image file:", + defaultEntry="preprocessed", + helpText=helpText, allowEmpty=False, - parent=dialog + parent=dialog, ) win.exec_() if win.cancel: return appendedText = win.entryText - + self.progressWin = apps.QDialogWorkerProgress( - title='Saving pre-processed image(s)', + title="Saving pre-processed image(s)", parent=self, - pbarDesc='Saving pre-processed image(s)' + pbarDesc="Saving pre-processed image(s)", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - - self.statusBarLabel.setText('Saving pre-processed data...') - + + self.statusBarLabel.setText("Saving pre-processed data...") + self.savePreprocWorker = workers.SaveProcessedDataWorker( self.data, appendedText, ext=".tif" ) - + self.savePreprocThread = QThread() self.savePreprocWorker.moveToThread(self.savePreprocThread) - self.savePreprocWorker.signals.finished.connect( - self.savePreprocThread.quit - ) + self.savePreprocWorker.signals.finished.connect(self.savePreprocThread.quit) self.savePreprocWorker.signals.finished.connect( self.savePreprocWorker.deleteLater ) - self.savePreprocThread.finished.connect( - self.savePreprocThread.deleteLater - ) - - self.savePreprocWorker.signals.critical.connect( - self.workerCritical - ) + self.savePreprocThread.finished.connect(self.savePreprocThread.deleteLater) + + self.savePreprocWorker.signals.critical.connect(self.workerCritical) self.savePreprocWorker.signals.initProgressBar.connect( self.workerInitProgressbar ) - self.savePreprocWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.savePreprocWorker.signals.progress.connect( - self.workerProgress - ) - self.savePreprocWorker.signals.finished.connect( - self.savePreprocWorkerFinished - ) - - self.savePreprocThread.started.connect( - self.savePreprocWorker.run - ) + self.savePreprocWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.savePreprocWorker.signals.progress.connect(self.workerProgress) + self.savePreprocWorker.signals.finished.connect(self.savePreprocWorkerFinished) + + self.savePreprocThread.started.connect(self.savePreprocWorker.run) self.savePreprocThread.start() def preprocessEnqueueCurrentImage(self, recipe): @@ -330,132 +291,108 @@ def preprocessEnqueueCurrentImage(self, recipe): z_slice = self.z_slice_index() else: z_slice = 0 - + recipe = core.validate_multidimensional_recipe(recipe) - + key = (self.pos_i, posData.frame_i, z_slice) - self.preprocWorker.enqueue( - func, - image_data, - recipe, - key - ) + self.preprocWorker.enqueue(func, image_data, recipe, key) def preprocessPreviewToggled(self, checked): self.viewPreprocDataToggle.setChecked(checked) self.updatePreprocessPreview() def preprocessZStack(self, recipe: List[Dict[str, Any]], *args): - txt = 'Pre-processing z-stack...' + txt = "Pre-processing z-stack..." self.statusBarLabel.setText(txt) self.logger.info(txt) - + posData = self.data[self.pos_i] func = core.preprocess_zstack_from_recipe recipe = core.validate_multidimensional_recipe( recipe, apply_to_all_frames=False ) image_data = posData.img_data[posData.frame_i] - self.preprocWorker.setupJob( - func, - image_data, - recipe, - 'z_stack' - ) - + self.preprocWorker.setupJob(func, image_data, recipe, "z_stack") + self.preprocWorker.wakeUp() def setupPreprocessing(self): posData = self.data[self.pos_i] if self.preprocessDialog is not None: self.preprocessDialog.close() - + self.preprocessDialog = apps.PreProcessRecipeDialog( - isTimelapse=posData.SizeT>1, - isZstack=posData.SizeZ>1, - isMultiPos=len(self.data)>1, + isTimelapse=posData.SizeT > 1, + isZstack=posData.SizeZ > 1, + isMultiPos=len(self.data) > 1, df_metadata=posData.metadata_df, - hideOnClosing=True, + hideOnClosing=True, addApplyButton=True, - parent=self + parent=self, ) self.doPreviewPreprocImage = False - self.preprocessDialog.sigApplyImage.connect( - self.preprocessCurrentImage - ) - self.preprocessDialog.sigApplyZstack.connect( - self.preprocessZStack - ) - self.preprocessDialog.sigApplyAllFrames.connect( - self.preprocessAllFrames - ) - self.preprocessDialog.sigApplyAllPos.connect( - self.preprocessAllPos - ) - self.preprocessDialog.sigPreviewToggled.connect( - self.preprocessPreviewToggled - ) + self.preprocessDialog.sigApplyImage.connect(self.preprocessCurrentImage) + self.preprocessDialog.sigApplyZstack.connect(self.preprocessZStack) + self.preprocessDialog.sigApplyAllFrames.connect(self.preprocessAllFrames) + self.preprocessDialog.sigApplyAllPos.connect(self.preprocessAllPos) + self.preprocessDialog.sigPreviewToggled.connect(self.preprocessPreviewToggled) self.preprocessDialog.sigValuesChanged.connect( self.preprocessDialogRecipeChanged ) self.preprocessDialog.sigSavePreprocData.connect( self.preprocessDialogSavePreprocessedData ) - + if self.preprocWorker is not None: return - + self.preprocThread = QThread() self.preprocMutex = QMutex() self.preprocWaitCond = QWaitCondition() - + self.preprocWorker = workers.CustomPreprocessWorkerGUI( self.preprocMutex, self.preprocWaitCond ) - + self.preprocWorker.moveToThread(self.preprocThread) self.preprocWorker.signals.finished.connect(self.preprocThread.quit) - self.preprocWorker.signals.finished.connect( - self.preprocWorker.deleteLater - ) + self.preprocWorker.signals.finished.connect(self.preprocWorker.deleteLater) self.preprocThread.finished.connect(self.preprocThread.deleteLater) self.preprocWorker.sigDone.connect(self.preprocWorkerDone) - self.preprocWorker.sigIsQueueEmpty.connect( - self.preprocWorkerIsQueueEmpty - ) + self.preprocWorker.sigIsQueueEmpty.connect(self.preprocWorkerIsQueueEmpty) self.preprocWorker.sigPreviewDone.connect(self.preprocWorkerPreviewDone) self.preprocWorker.signals.progress.connect(self.workerProgress) self.preprocWorker.signals.critical.connect(self.workerCritical) self.preprocWorker.signals.finished.connect(self.preprocWorkerClosed) - + self.preprocThread.started.connect(self.preprocWorker.run) self.preprocThread.start() - - self.logger.info('Pre-processing worker started.') + + self.logger.info("Pre-processing worker started.") def updatePreprocessPreview(self, *args, **kwargs): - force = kwargs.get('force', False) - + force = kwargs.get("force", False) + if not self.preprocessDialog.isVisible() and not force: return - + if not self.preprocessDialog.previewCheckbox.isChecked() and not force: return - - if kwargs.get('recipe') is None: + + if kwargs.get("recipe") is None: recipe = self.preprocessDialog.recipe() else: - recipe = kwargs.get('recipe') + recipe = kwargs.get("recipe") if recipe is None: - self.logger.warning('Pre-processing recipe not initialized yet.') + self.logger.warning("Pre-processing recipe not initialized yet.") return - - txt = 'Pre-processing current image...' + + txt = "Pre-processing current image..." self.logger.info(txt) self.statusBarLabel.setText(txt) - + self.preprocessEnqueueCurrentImage(recipe) def viewPreprocDataToggled(self, checked): diff --git a/cellacdc/mixins/quick_settings.py b/cellacdc/mixins/quick_settings.py index 84a6a87d8..db95f18c1 100644 --- a/cellacdc/mixins/quick_settings.py +++ b/cellacdc/mixins/quick_settings.py @@ -9,145 +9,137 @@ from .actions import Actions + class QuickSettings(Actions): """Extracted from guiWin.""" def gui_createQuickSettingsWidgets(self): self.quickSettingsLayout = QVBoxLayout() self.quickSettingsGroupbox = widgets.GroupBox() - self.quickSettingsGroupbox.setTitle('Quick settings') + self.quickSettingsGroupbox.setTitle("Quick settings") layout = QFormLayout() - layout.setFieldGrowthPolicy( - QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint - ) + layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.FieldsStayAtSizeHint) layout.setFormAlignment(Qt.AlignRight | Qt.AlignVCenter) - + self.viewPreprocDataToggle = widgets.Toggle() viewPreprocDataToggleTooltip = ( - 'View pre-processed data. See menu `Image --> Pre-processing...`\n' - 'on the top menubar.' + "View pre-processed data. See menu `Image --> Pre-processing...`\n" + "on the top menubar." ) self.viewPreprocDataToggle.setChecked(False) self.viewPreprocDataToggle.setToolTip(viewPreprocDataToggleTooltip) - viewPreprocDataToggleLabel = QLabel('View pre-processed image') + viewPreprocDataToggleLabel = QLabel("View pre-processed image") viewPreprocDataToggleLabel.setToolTip(viewPreprocDataToggleTooltip) layout.addRow(viewPreprocDataToggleLabel, self.viewPreprocDataToggle) self.viewCombineChannelDataToggle = widgets.Toggle() viewCombineChannelDataToggleTooltip = ( - 'View combined channel. See menu `Image --> combing channels...`\n' - 'on the top menubar.' + "View combined channel. See menu `Image --> combing channels...`\n" + "on the top menubar." ) self.viewCombineChannelDataToggle.setChecked(False) self.viewCombineChannelDataToggle.setToolTip( viewCombineChannelDataToggleTooltip ) - viewCombineChannelDataToggleLabel = QLabel('View combined channels') + viewCombineChannelDataToggleLabel = QLabel("View combined channels") viewCombineChannelDataToggleLabel.setToolTip( viewCombineChannelDataToggleTooltip ) layout.addRow( - viewCombineChannelDataToggleLabel, - self.viewCombineChannelDataToggle + viewCombineChannelDataToggleLabel, self.viewCombineChannelDataToggle ) self.autoSaveToggle = widgets.Toggle() autoSaveTooltip = ( - 'Automatically store a copy of the segmentation data ' - 'in the `.recovery` folder after every edit.' + "Automatically store a copy of the segmentation data " + "in the `.recovery` folder after every edit." ) self.autoSaveToggle.setChecked(True) self.autoSaveToggle.setToolTip(autoSaveTooltip) - autoSaveLabel = QLabel('Autosave segmentation') + autoSaveLabel = QLabel("Autosave segmentation") autoSaveLabel.setToolTip(autoSaveTooltip) layout.addRow(autoSaveLabel, self.autoSaveToggle) - + self.autoSaveAnnotToggle = widgets.Toggle() autoSaveAnnotTooltip = ( - 'Automatically store a copy of the annotations (acdc_output CSV file) ' - 'in the `.recovery` folder after every edit.' + "Automatically store a copy of the annotations (acdc_output CSV file) " + "in the `.recovery` folder after every edit." ) self.autoSaveAnnotToggle.setChecked(True) self.autoSaveAnnotToggle.setToolTip(autoSaveAnnotTooltip) - autoSaveAnnotLabel = QLabel('Autosave annotations') + autoSaveAnnotLabel = QLabel("Autosave annotations") autoSaveAnnotLabel.setToolTip(autoSaveAnnotTooltip) layout.addRow(autoSaveAnnotLabel, self.autoSaveAnnotToggle) - + self.autoSaveIntervalEditButton = widgets.editPushButton( flat=True, hoverable=True ) - self.autoSaveIntervalLabel = QLabel('Autosave interval') + self.autoSaveIntervalLabel = QLabel("Autosave interval") self.autoSaveIntervalSetTooltip() - layout.addRow( - self.autoSaveIntervalLabel, self.autoSaveIntervalEditButton - ) - + layout.addRow(self.autoSaveIntervalLabel, self.autoSaveIntervalEditButton) + self.autoSaveIntervalDialog = apps.AutoSaveIntervalDialog(parent=self) self.autoSaveIntervalDialog.setValues(*self.autoSaveIntevalValueUnit) - + self.ccaIntegrCheckerToggle = widgets.Toggle() ccaIntegrCheckerToggleTooltip = ( - 'Toggle background cell cycle annotations integrity checker ON/OFF' + "Toggle background cell cycle annotations integrity checker ON/OFF" ) self.ccaIntegrCheckerToggle.setChecked(False) self.ccaIntegrCheckerToggle.setToolTip(ccaIntegrCheckerToggleTooltip) - label = QLabel('Cc annot. checker') + label = QLabel("Cc annot. checker") label.setToolTip(ccaIntegrCheckerToggleTooltip) layout.addRow(label, self.ccaIntegrCheckerToggle) - if 'is_cca_integrity_checker_activated' in self.df_settings.index: - idx = 'is_cca_integrity_checker_activated' - val = int(self.df_settings.at[idx, 'value']) + if "is_cca_integrity_checker_activated" in self.df_settings.index: + idx = "is_cca_integrity_checker_activated" + val = int(self.df_settings.at[idx, "value"]) self.ccaIntegrCheckerToggle.setChecked(not val) - + self.annotLostObjsToggle = widgets.Toggle() - annotLostObjsToggleTooltip = ( - 'Toggle annotation of lost objects mode ON/OFF' - ) + annotLostObjsToggleTooltip = "Toggle annotation of lost objects mode ON/OFF" self.annotLostObjsToggle.setChecked(True) self.annotLostObjsToggle.setToolTip(annotLostObjsToggleTooltip) - label = QLabel('Annot. lost objects') + label = QLabel("Annot. lost objects") label.setToolTip(annotLostObjsToggleTooltip) layout.addRow(label, self.annotLostObjsToggle) self.realTimeTrackingToggle = widgets.Toggle() self.realTimeTrackingToggle.setChecked(True) self.realTimeTrackingToggle.setDisabled(True) - label = QLabel('Real-time tracking') + label = QLabel("Real-time tracking") label.setDisabled(True) self.realTimeTrackingToggle.label = label layout.addRow(label, self.realTimeTrackingToggle) - + self.showAllContoursToggle = widgets.Toggle() showAllContoursTooltip = ( - 'If active, all contours will be displayed, including inner contours' - '(e.g. holes and sub-objects)' + "If active, all contours will be displayed, including inner contours" + "(e.g. holes and sub-objects)" ) self.showAllContoursToggle.setToolTip(showAllContoursTooltip) - showAllContourLabel = QLabel('Show all contours') + showAllContourLabel = QLabel("Show all contours") showAllContourLabel.setToolTip(showAllContoursTooltip) layout.addRow(showAllContourLabel, self.showAllContoursToggle) - self.showAllContoursToggle.toggled.connect( - self.showAllContoursToggled - ) + self.showAllContoursToggle.toggled.connect(self.showAllContoursToggled) # Font size self.fontSizeSpinBox = widgets.SpinBox() self.fontSizeSpinBox.setMinimum(1) self.fontSizeSpinBox.setMaximum(99) - layout.addRow('Font size', self.fontSizeSpinBox) - savedFontSize = str(self.df_settings.at['fontSize', 'value']) - if savedFontSize.find('pt') != -1: + layout.addRow("Font size", self.fontSizeSpinBox) + savedFontSize = str(self.df_settings.at["fontSize", "value"]) + if savedFontSize.find("pt") != -1: savedFontSize = savedFontSize[:-2] self.fontSize = int(savedFontSize) - if 'pxMode' not in self.df_settings.index: - # Users before introduction of pxMode had pxMode=False, but now + if "pxMode" not in self.df_settings.index: + # Users before introduction of pxMode had pxMode=False, but now # the new default is True. This requires larger font size. - self.fontSize = 2*self.fontSize - self.df_settings.at['pxMode', 'value'] = 1 + self.fontSize = 2 * self.fontSize + self.df_settings.at["pxMode", "value"] = 1 self.df_settings.to_csv(settings_csv_path) self.fontSizeSpinBox.setValue(self.fontSize) - self.fontSizeSpinBox.editingFinished.connect(self.changeFontSize) + self.fontSizeSpinBox.editingFinished.connect(self.changeFontSize) self.fontSizeSpinBox.sigUpClicked.connect(self.changeFontSize) self.fontSizeSpinBox.sigDownClicked.connect(self.changeFontSize) diff --git a/cellacdc/mixins/saving.py b/cellacdc/mixins/saving.py index d712957d9..515ec23fb 100644 --- a/cellacdc/mixins/saving.py +++ b/cellacdc/mixins/saving.py @@ -25,18 +25,17 @@ from .app_shell import AppShell + class Saving(AppShell): """Extracted from guiWin.""" def _enqueueAutoSave(self): - if not self.statusBarLabel.text().endswith('Autosaving...'): - self.statusBarLabel.setText( - f'{self.statusBarLabel.text()} | Autosaving...' - ) - - timestamp = datetime.now().strftime(r'%H:%M:%S.%f')[:-3] - self.logger.info(f'Autosaving... - {timestamp}') - + if not self.statusBarLabel.text().endswith("Autosaving..."): + self.statusBarLabel.setText(f"{self.statusBarLabel.text()} | Autosaving...") + + timestamp = datetime.now().strftime(r"%H:%M:%S.%f")[:-3] + self.logger.info(f"Autosaving... - {timestamp}") + posData = self.data[self.pos_i] worker, thread = self.autoSaveActiveWorkers[-1] worker.enqueue(posData) @@ -54,41 +53,37 @@ def _waitCloseAutoSaveWorker(self): def askConcatenate(self): if self.mainWin is None: return - + if self._isQuickSave: return - - if 'showAskConcatenate' not in self.df_settings.index: - self.df_settings.at['showAskConcatenate', 'value'] = 'Yes' - - showAskConcatenate = ( - self.df_settings.at['showAskConcatenate', 'value'] == 'Yes' - ) + + if "showAskConcatenate" not in self.df_settings.index: + self.df_settings.at["showAskConcatenate", "value"] = "Yes" + + showAskConcatenate = self.df_settings.at["showAskConcatenate", "value"] == "Yes" if not showAskConcatenate: return - + txt = html_utils.paragraph(f""" Do you want to concatenate the `acdc_output.csv` tables from multiple Positions into one single CSV file?
    """) - doNotShowAgainCheckbox = QCheckBox('Do not show again') + doNotShowAgainCheckbox = QCheckBox("Do not show again") msg = widgets.myMessageBox(wrapText=False) noButton, yesButton = msg.question( - self, 'Concatenate tables?', txt, - buttonsTexts=('No', 'Yes'), - widgets=doNotShowAgainCheckbox - ) - showAskConcatenate = ( - 'No' if doNotShowAgainCheckbox.isChecked() else 'Yes' - ) - self.df_settings.at['showAskConcatenate', 'value'] = ( - showAskConcatenate - ) + self, + "Concatenate tables?", + txt, + buttonsTexts=("No", "Yes"), + widgets=doNotShowAgainCheckbox, + ) + showAskConcatenate = "No" if doNotShowAgainCheckbox.isChecked() else "Yes" + self.df_settings.at["showAskConcatenate", "value"] = showAskConcatenate self.df_settings.to_csv(settings_csv_path) - + if not msg.clickedButton == yesButton: return - + txt = html_utils.paragraph(f""" To concatenate the `acdc_output.csv` tables from multiple Positions and multiple experiments
    @@ -96,7 +91,7 @@ def askConcatenate(self): Utilities --> Concatenate --> Concatenate acdc output tables from multiple Positions and experiments.... """) msg = widgets.myMessageBox(wrapText=False) - msg.information(self, 'How to concatenate tables', txt) + msg.information(self, "How to concatenate tables", txt) def askPosToSave(self): return self.askSelectPos() @@ -109,46 +104,50 @@ def askSaveLastVisitedCcaMode(self, isQuickSave=False): self.save_until_frame_i = 0 if self.isSnapshot: return True - + for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None: frame_i -= 1 break - + self.save_until_frame_i = frame_i self.save_cca_until_frame_i = frame_i self.last_tracked_i = frame_i - + if isQuickSave: return True - - last_cca_frame_i = self.navigateScrollBar.maximum()-1 + + last_cca_frame_i = self.navigateScrollBar.maximum() - 1 # Ask to save last visited frame or not txt = html_utils.paragraph(f""" You annotated the cell cycle stages up - until frame number {last_cca_frame_i+1}.

    + until frame number {last_cca_frame_i + 1}.

    Enter up to which frame number you want to save the cell cycle annotations: """) lastFrameDialog = apps.QLineEditDialog( - title='Last annoated frame number to save', - defaultTxt=str(last_cca_frame_i+1), - msg=txt, parent=self, allowedValues=(1, last_cca_frame_i+1), - warnLastFrame=True, isInteger=True, stretchEntry=False, - lastVisitedFrame=last_cca_frame_i+1, + title="Last annoated frame number to save", + defaultTxt=str(last_cca_frame_i + 1), + msg=txt, + parent=self, + allowedValues=(1, last_cca_frame_i + 1), + warnLastFrame=True, + isInteger=True, + stretchEntry=False, + lastVisitedFrame=last_cca_frame_i + 1, ) lastFrameDialog.exec_() if lastFrameDialog.cancel: return False last_save_cca_frame_i = lastFrameDialog.enteredValue - 1 - + if last_save_cca_frame_i < last_cca_frame_i: self.resetCcaFuture(last_cca_frame_i) - + self.save_cca_until_frame_i = last_save_cca_frame_i - + return True def askSaveLastVisitedSegmMode(self, isQuickSave=False): @@ -162,7 +161,7 @@ def askSaveLastVisitedSegmMode(self, isQuickSave=False): return True for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None: frame_i -= 1 break @@ -176,14 +175,19 @@ def askSaveLastVisitedSegmMode(self, isQuickSave=False): # Ask to save last visited frame or not txt = html_utils.paragraph(f""" You visualised and corrected segmentation and tracking data up - until frame number {frame_i+1}.

    + until frame number {frame_i + 1}.

    Enter up to which frame number you want to save data: """) lastFrameDialog = apps.QLineEditDialog( - title='Last frame number to save', defaultTxt=str(frame_i+1), - msg=txt, parent=self, allowedValues=(1, posData.SizeT), - warnLastFrame=True, isInteger=True, stretchEntry=False, - lastVisitedFrame=frame_i+1, + title="Last frame number to save", + defaultTxt=str(frame_i + 1), + msg=txt, + parent=self, + allowedValues=(1, posData.SizeT), + warnLastFrame=True, + isInteger=True, + stretchEntry=False, + lastVisitedFrame=frame_i + 1, ) lastFrameDialog.exec_() if lastFrameDialog.cancel: @@ -193,27 +197,27 @@ def askSaveLastVisitedSegmMode(self, isQuickSave=False): self.save_cca_until_frame_i = self.save_until_frame_i if self.save_until_frame_i > frame_i: self.logger.info( - f'Storing frames {frame_i+1}-{self.save_until_frame_i+1}...' + f"Storing frames {frame_i + 1}-{self.save_until_frame_i + 1}..." ) current_frame_i = posData.frame_i # User is requesting to save past the last visited frame --> # store data as if they were visited - for i in range(frame_i+1, self.save_until_frame_i+1): + for i in range(frame_i + 1, self.save_until_frame_i + 1): posData.frame_i = i self.get_data() self.store_data(autosave=False) - + # Go back to current frame posData.frame_i = current_frame_i self.get_data() last_tracked_i = self.save_until_frame_i - + self.last_tracked_i = last_tracked_i return True def askSaveMetrics(self): txt = html_utils.paragraph( - """ + """ Do you also want to save the measurements (e.g., cell volume, mean, amount etc.)?

    @@ -225,20 +229,21 @@ def askSaveMetrics(self): NOTE: Saving metrics might be slow, we recommend doing it only when you need it.
    - """) - msg = widgets.myMessageBox( - parent=self, resizeButtons=False, wrapText=False + """ ) - setMeasurementsButton = widgets.setPushButton('Set measurements...') + msg = widgets.myMessageBox(parent=self, resizeButtons=False, wrapText=False) + setMeasurementsButton = widgets.setPushButton("Set measurements...") _, yesButton, noButton, _ = msg.question( - self, 'Save measurements?', txt, - buttonsTexts=('Cancel', 'Yes', 'No', setMeasurementsButton), - showDialog=False + self, + "Save measurements?", + txt, + buttonsTexts=("Cancel", "Yes", "No", setMeasurementsButton), + showDialog=False, ) setMeasurementsButton.disconnect() setMeasurementsButton.clicked.connect( partial( - self.showSetMeasurements, + self.showSetMeasurements, qparent=msg, ) ) @@ -249,21 +254,20 @@ def askSaveMetrics(self): def askSaveOnClosing(self, event): if not self.saveAction.isEnabled(): return True - if self.titleLabel.text == 'Saved!': + if self.titleLabel.text == "Saved!": return True if not self.isDataLoaded: return True - + msg = widgets.myMessageBox() - txt = html_utils.paragraph('Do you want to save before closing?') + txt = html_utils.paragraph("Do you want to save before closing?") _, noButton, yesButton = msg.question( - self, 'Save?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') + self, "Save?", txt, buttonsTexts=("Cancel", "No", "Yes") ) if msg.cancel: event.ignore() return False - + if msg.clickedButton == yesButton: self.closeGUI = True QTimer.singleShot(100, self.saveAction.trigger) @@ -278,7 +282,7 @@ def askSaveOriginalSegm(self, isQuickSave=False): posData = self.data[self.pos_i] if not posData.whitelist: return "", True, True - + help_txt = html_utils.paragraph(f""" You have whitelisted IDs in the current position.
    Do you want to save the not whitelisted segmentation data
    @@ -291,9 +295,7 @@ def askSaveOriginalSegm(self, isQuickSave=False): """) found_files = load.get_segm_files(posData.images_path) - existingEndnames = load.get_endnames( - posData.basename, found_files - ) + existingEndnames = load.get_endnames(posData.basename, found_files) segmFilename = os.path.basename(posData.segm_npz_path) segmFilename = f"{segmFilename[:-4]}_not_whitelisted" @@ -302,11 +304,11 @@ def askSaveOriginalSegm(self, isQuickSave=False): hintText=txt, defaultEntry=segmFilename, existingNames=existingEndnames, - helpText=help_txt, + helpText=help_txt, allowEmpty=False, parent=self, - title='Save not whitelisted segmentation data', - addDoNotSaveButton=True + title="Save not whitelisted segmentation data", + addDoNotSaveButton=True, ) win.exec_() if win.cancel: @@ -315,10 +317,10 @@ def askSaveOriginalSegm(self, isQuickSave=False): return "", True, True return win.entryText, True, False - def askSelectPos(self, action='to save'): + def askSelectPos(self, action="to save"): last_pos = 1 for p, posData in enumerate(self.data): - acdc_df = posData.allData_li[0]['acdc_df'] + acdc_df = posData.allData_li[0]["acdc_df"] if acdc_df is None: last_pos = p break @@ -327,30 +329,33 @@ def askSelectPos(self, action='to save'): items = [posData.pos_foldername for posData in self.data] selectPosWin = widgets.QDialogListbox( - f'Select Positions {action}', f'Select Positions {action}:\n', - items, multiSelection=True, parent=self, - preSelectedItems=items[:last_pos] + f"Select Positions {action}", + f"Select Positions {action}:\n", + items, + multiSelection=True, + parent=self, + preSelectedItems=items[:last_pos], ) selectPosWin.exec_() if selectPosWin.cancel: return - + return selectPosWin.selectedItemsText def autoSaveAnnotToggled(self, checked): if not self.autoSaveActiveWorkers: self.gui_createAutoSaveWorker() - + if not self.autoSaveActiveWorkers: return - + worker, thread = self.autoSaveActiveWorkers[-1] - + mode = self.modeComboBox.currentText() - if mode != 'Viewer': + if mode != "Viewer": # No reason to save in viewer mode checked = False - + worker.isAutoSaveAnnotON = checked def autoSaveClose(self): @@ -365,58 +370,54 @@ def autoSaveIntervalEdit(self): def autoSaveIntervalSetTooltip(self): value, unit = self.autoSaveIntevalValueUnit autoSaveIntervalEditTooltip = ( - 'Change autosave interval to every N frames or minutes\n\n' - f'Current autosave interval: {value} {unit}' + "Change autosave interval to every N frames or minutes\n\n" + f"Current autosave interval: {value} {unit}" ) self.autoSaveIntervalLabel.setToolTip(autoSaveIntervalEditTooltip) self.autoSaveIntervalEditButton.setToolTip(autoSaveIntervalEditTooltip) def autoSaveIntervalValueChanged( - self, value: float, unit: Literal['minutes', 'frames'] - ): + self, value: float, unit: Literal["minutes", "frames"] + ): self.autoSaveIntevalValueUnit = (value, unit) self.autoSaveTimer.stop() - - self.df_settings.at['autoSaveIntevalValue', 'value'] = str(value) - self.df_settings.at['autoSaveIntervalUnit', 'value'] = unit + + self.df_settings.at["autoSaveIntevalValue", "value"] = str(value) + self.df_settings.at["autoSaveIntervalUnit", "value"] = unit self.df_settings.to_csv(settings_csv_path) - - self.logger.info( - f'Autosave interval changed to: {value} {unit}' - ) + + self.logger.info(f"Autosave interval changed to: {value} {unit}") self.autoSaveIntervalSetTooltip() - - if unit == 'frames': + + if unit == "frames": self.startAutoSaveEveryNframesTimer() def autoSaveTimerCountFrames(self): - if not hasattr(self, 'data'): - # This happes when the self.autoSaveTimer times out after + if not hasattr(self, "data"): + # This happes when the self.autoSaveTimer times out after # the GUI has been closed --> we simply ignore it return - + posData = self.data[self.pos_i] - autoSaveIntevalValue, autoSaveIntervalUnit = ( - self.autoSaveIntevalValueUnit - ) + autoSaveIntevalValue, autoSaveIntervalUnit = self.autoSaveIntevalValueUnit isTimeToAutoSave = ( abs(posData.frame_i - self.autoSaveTimeStartFrameIdx) >= autoSaveIntevalValue ) if not isTimeToAutoSave: return - + self.autoSaveTimeStartFrameIdx = posData.frame_i self.flushDirtyPointsLayersAutosave() self._enqueueAutoSave() def autoSaveTimerTimedOut(self): - if not hasattr(self, 'data'): - # This happes when the self.autoSaveTimer times out after + if not hasattr(self, "data"): + # This happes when the self.autoSaveTimer times out after # the GUI has been closed --> we simply ignore it self.autoSaveTimer.stop() return - + self.autoSaveTimer.stop() self.flushDirtyPointsLayersAutosave() self._enqueueAutoSave() @@ -424,24 +425,22 @@ def autoSaveTimerTimedOut(self): def autoSaveToggled(self, checked): if not self.autoSaveActiveWorkers: self.gui_createAutoSaveWorker() - + if not self.autoSaveActiveWorkers: return - + worker, thread = self.autoSaveActiveWorkers[-1] - + mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': - # Autosaving segmentation makes sense only in + if mode != "Segmentation and Tracking": + # Autosaving segmentation makes sense only in # "Segmentation and Tracking" mode checked = False - + worker.isAutoSaveON = checked def cancelSavingInitialisation(self): - self.titleLabel.setText( - 'Saving data process cancelled.', color=self.titleColor - ) + self.titleLabel.setText("Saving data process cancelled.", color=self.titleColor) self.closeGUI = False def checkMissingCca(self): @@ -450,103 +449,98 @@ def checkMissingCca(self): doNotShowAgain = False if not self.doNotShowAgainMissingCca: return proceed, ignore, doNotShowAgain - + missing_cca_items = [] for posData in self.data: for frame_i, data_dict in enumerate(posData.allData_li): - acdc_df = data_dict['acdc_df'] + acdc_df = data_dict["acdc_df"] if acdc_df is None: continue - - if 'cell_cycle_stage' not in acdc_df.columns: + + if "cell_cycle_stage" not in acdc_df.columns: continue - + cca_df = acdc_df[cca_df_colnames] if cca_df.isnull().values.any(): i = frame_i if not self.isSnapshot else None missing_cca_items.append((cca_df, posData, i)) - + if not missing_cca_items: return proceed, ignore, doNotShowAgain - + proceed = False - ignore, doNotShowAgain =_warnings.warnMissingCca( + ignore, doNotShowAgain = _warnings.warnMissingCca( missing_cca_items, qparent=self ) - + if doNotShowAgain: - self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'Yes' + self.df_settings.at["doNotShowAgainMissingCca", "value"] = "Yes" self.df_settings.to_csv(self.settings_csv_path) - + return proceed, ignore, doNotShowAgain def computeVolumeRegionprop(self): - if 'cell_vol_vox' not in self._measurements_kernel.sizeMetricsToSave: + if "cell_vol_vox" not in self._measurements_kernel.sizeMetricsToSave: return # We compute the cell volume in the main thread because calling # skimage.transform.rotate in a separate thread causes crashes # with segmentation fault on macOS. I don't know why yet. - self.logger.info('Computing cell volume...') + self.logger.info("Computing cell volume...") end_i = self.save_until_frame_i pos_iter = tqdm(self.data, ncols=100) for p, posData in enumerate(pos_iter): if self.posToSave is not None: if posData.pos_foldername not in self.posToSave: continue - + PhysicalSizeY = posData.PhysicalSizeY PhysicalSizeX = posData.PhysicalSizeX frame_iter = tqdm( - posData.allData_li[:end_i+1], ncols=100, position=1, leave=False + posData.allData_li[: end_i + 1], ncols=100, position=1, leave=False ) for frame_i, data_dict in enumerate(frame_iter): - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None: break - rp = data_dict['regionprops'] + rp = data_dict["regionprops"] obj_iter = tqdm(rp, ncols=100, position=2, leave=False) for i, obj in enumerate(obj_iter): - vol_vox, vol_fl = _calc_rot_vol( - obj, PhysicalSizeY, PhysicalSizeX - ) + vol_vox, vol_fl = _calc_rot_vol(obj, PhysicalSizeY, PhysicalSizeX) obj.vol_vox = vol_vox obj.vol_fl = vol_fl - posData.allData_li[frame_i]['regionprops'] = rp + posData.allData_li[frame_i]["regionprops"] = rp def enqAutosave(self): mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': - if self.statusBarLabel.text().endswith('Autosaving...'): + if mode == "Viewer": + if self.statusBarLabel.text().endswith("Autosaving..."): self.statusBarLabel.setText( - self.statusBarLabel.text().replace(' | Autosaving...', '') + self.statusBarLabel.text().replace(" | Autosaving...", "") ) - return - + return + if not self.autoSaveActiveWorkers: self.gui_createAutoSaveWorker() - + if not self.autoSaveActiveWorkers: return - + if self.autoSaveTimer.isActive(): return - + self._enqueueAutoSave() - autoSaveIntevalValue, autoSaveIntervalUnit = ( - self.autoSaveIntevalValueUnit - ) + autoSaveIntevalValue, autoSaveIntervalUnit = self.autoSaveIntevalValueUnit if autoSaveIntevalValue == 0: return - + try: self.autoSaveTimer.timeout.disconnect() except Exception as err: pass - - - if autoSaveIntervalUnit == 'minutes': - autosave_interval_ms = round(autoSaveIntevalValue*60*1000) + + if autoSaveIntervalUnit == "minutes": + autosave_interval_ms = round(autoSaveIntevalValue * 60 * 1000) self.autoSaveTimer.timeout.connect(self.autoSaveTimerTimedOut) self.autoSaveTimer.start(autosave_interval_ms) else: @@ -563,11 +557,11 @@ def manageVersions(self): undoId = uuid.uuid4() if posData.cca_df is not None: self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - + selectedTime = selectVersion.selectedTimestamp - self.modeComboBox.setCurrentText('Viewer') - self.logger.info(f'Loading file from {selectedTime}...') + self.modeComboBox.setCurrentText("Viewer") + self.logger.info(f"Loading file from {selectedTime}...") acdc_df = load.read_acdc_df_from_archive( selectVersion.archiveFilePath, selectVersion.selectedKey @@ -576,32 +570,32 @@ def manageVersions(self): frames = acdc_df.index.get_level_values(0) last_visited_frame_i = frames.max() current_frame_i = posData.frame_i - pbar = tqdm(total=last_visited_frame_i+1, ncols=100) - for frame_i in range(last_visited_frame_i+1): + pbar = tqdm(total=last_visited_frame_i + 1, ncols=100) + for frame_i in range(last_visited_frame_i + 1): posData.frame_i = frame_i self.get_data() if posData.cca_df is not None: self.storeUndoRedoCca(posData.frame_i, posData.cca_df, undoId) - if posData.allData_li[frame_i]['labels'] is None: + if posData.allData_li[frame_i]["labels"] is None: pbar.update() continue - + if frame_i not in frames: acdc_df_i = pd.DataFrame(columns=acdc_df.columns) - acdc_df_i.drop(self.cca_df_colnames, axis=1, errors='ignore') - acdc_df_i.index.name = 'Cell_ID' + acdc_df_i.drop(self.cca_df_colnames, axis=1, errors="ignore") + acdc_df_i.index.name = "Cell_ID" else: - acdc_df_i = acdc_df.loc[frame_i].dropna(axis=1, how='all') - - posData.allData_li[frame_i]['acdc_df'] = acdc_df_i + acdc_df_i = acdc_df.loc[frame_i].dropna(axis=1, how="all") + + posData.allData_li[frame_i]["acdc_df"] = acdc_df_i pbar.update() pbar.close() - + # Back to current frame posData.frame_i = current_frame_i self.get_data(debug=False) self.updateAllImages() - self.logger.info('Annotations correctly recovered.') + self.logger.info("Annotations correctly recovered.") def quickSave(self): self.saveData(isQuickSave=True) @@ -615,22 +609,19 @@ def saveAsData(self, checked=True): existingFilenames = set() for _posData in self.data: segm_files = load.get_segm_files(_posData.images_path) - _existingEndnames = load.get_endnames( - _posData.basename, segm_files + _existingEndnames = load.get_endnames(_posData.basename, segm_files) + existingFilenames.update( + [f"{_posData.basename}{endname}.npz" for endname in _existingEndnames] ) - existingFilenames.update([ - f'{_posData.basename}{endname}.npz' - for endname in _existingEndnames - ]) posData = self.data[self.pos_i] - if posData.basename.endswith('_'): - basename = f'{posData.basename}segm' + if posData.basename.endswith("_"): + basename = f"{posData.basename}segm" else: - basename = f'{posData.basename}_segm' + basename = f"{posData.basename}_segm" win = apps.filenameDialog( basename=basename, - hintText='Insert a filename for the segmentation file:
    ', - existingNames=existingFilenames + hintText="Insert a filename for the segmentation file:
    ", + existingNames=existingFilenames, ) win.exec_() if win.cancel: @@ -654,8 +645,8 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): # Wait autosave worker to finish for worker, thread in self.autoSaveActiveWorkers: - self.logger.info('Stopping autosaving process...') - self.statusBarLabel.setText('Stopping autosaving process...') + self.logger.info("Stopping autosaving process...") + self.statusBarLabel.setText("Stopping autosaving process...") worker.stop() self.waitAutoSaveWorkerTimer = QTimer() self.waitAutoSaveWorkerTimer.timeout.connect( @@ -666,8 +657,7 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.waitAutoSaveWorkerLoop.exec_() self.titleLabel.setText( - 'Saving data... (check progress in the terminal)', - color=self.titleColor + "Saving data... (check progress in the terminal)", color=self.titleColor ) # Check channel name correspondence to warn @@ -690,8 +680,8 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.cancelSavingInitialisation() self.setDisabled(False, keepDisabled=False) self.activateWindow() - return - + return + self.save_metrics = False if not isQuickSave: self.save_metrics, cancel = self.askSaveMetrics() @@ -713,12 +703,12 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): if isQuickSave: # Quick save only current pos self.posToSave = {self.data[self.pos_i].pos_foldername} - + if self.isSnapshot: self.store_data(mainThread=False) mode = self.modeComboBox.currentText() - if mode == 'Cell cycle analysis': + if mode == "Cell cycle analysis": proceed = self.askSaveLastVisitedCcaMode(isQuickSave=isQuickSave) if not proceed: self.cancelSavingInitialisation() @@ -732,28 +722,30 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.setDisabled(False, keepDisabled=False) self.activateWindow() return True - - append_name_og_whitelist, proceed, do_not_save_og_whitelist = self.askSaveOriginalSegm(isQuickSave=isQuickSave) + + append_name_og_whitelist, proceed, do_not_save_og_whitelist = ( + self.askSaveOriginalSegm(isQuickSave=isQuickSave) + ) if not proceed: self.cancelSavingInitialisation() self.setDisabled(False, keepDisabled=False) self.activateWindow() return True - if self.save_metrics or mode == 'Cell cycle analysis': + if self.save_metrics or mode == "Cell cycle analysis": self.computeVolumeRegionprop() infoTxt = html_utils.paragraph( - f'Saving {self.exp_path}...
    ', font_size='14px' + f"Saving {self.exp_path}...
    ", font_size="14px" ) self.saveWin = apps.QDialogPbar( - parent=self, title='Saving data', infoTxt=infoTxt + parent=self, title="Saving data", infoTxt=infoTxt ) self.saveWin.setFont(_font) # if not self.save_metrics: self.saveWin.metricsQPbar.hide() - self.saveWin.progressLabel.setText('Preparing data...') + self.saveWin.progressLabel.setText("Preparing data...") self.saveWin.show() # Set up separate thread for saving and show progress bar widget @@ -781,16 +773,12 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.worker.progressBar.connect(self.saveDataUpdatePbar) # self.worker.metricsPbarProgress.connect(self.saveDataUpdateMetricsPbar) self.worker.critical.connect(self.saveDataWorkerCritical) - self.worker.customMetricsCritical.connect( - self.saveDataCustomMetricsCritical - ) + self.worker.customMetricsCritical.connect(self.saveDataCustomMetricsCritical) self.worker.sigCombinedMetricsMissingColumn.connect( self.saveDataCombinedMetricsMissingColumn ) self.worker.addMetricsCritical.connect(self.saveDataAddMetricsCritical) - self.worker.regionPropsCritical.connect( - self.saveDataRegionPropsCritical - ) + self.worker.regionPropsCritical.connect(self.saveDataRegionPropsCritical) self.worker.criticalPermissionError.connect(self.saveDataPermissionError) self.worker.askZsliceAbsent.connect(self.zSliceAbsent) self.worker.sigDebug.connect(self._workerDebug) @@ -798,43 +786,43 @@ def saveData(self, checked=False, finishedCallback=None, isQuickSave=False): self.thread.started.connect(self.worker.run) self.thread.start() - + return False def saveDataAddMetricsCritical(self, traceback_format, error_message): self.setDisabled(False, keepDisabled=False) self.activateWindow() - self.logger.info('') - _hl = '====================================' - self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') + self.logger.info("") + _hl = "====================================" + self.logger.info(f"{_hl}\n{traceback_format}\n{_hl}") self.worker.addMetricsErrors[error_message] = traceback_format def saveDataCombinedMetricsMissingColumn(self, error_msg, func_name): self.setDisabled(False, keepDisabled=False) self.activateWindow() - self.logger.info('') - warning = f'[WARNING]: {error_msg}. Metric {func_name} was skipped.' - _hl = '====================================' - self.logger.info(f'{_hl}\n{warning}\n{_hl}') + self.logger.info("") + warning = f"[WARNING]: {error_msg}. Metric {func_name} was skipped." + _hl = "====================================" + self.logger.info(f"{_hl}\n{warning}\n{_hl}") self.worker.customMetricsErrors[func_name] = warning def saveDataCustomMetricsCritical(self, traceback_format, func_name): self.setDisabled(False, keepDisabled=False) self.activateWindow() - self.logger.info('') - _hl = '====================================' - self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') + self.logger.info("") + _hl = "====================================" + self.logger.info(f"{_hl}\n{traceback_format}\n{_hl}") self.worker.customMetricsErrors[func_name] = traceback_format def saveDataFinished(self): self.setDisabled(False, keepDisabled=False) self.activateWindow() if self.saveWin.aborted or self.worker.abort: - self.titleLabel.setText('Saving process cancelled.', color='r') + self.titleLabel.setText("Saving process cancelled.", color="r") elif self._isQuickSave: - self.titleLabel.setText('Saved segmentation file and annotations') + self.titleLabel.setText("Saved segmentation file and annotations") else: - self.titleLabel.setText('Saved!') + self.titleLabel.setText("Saved!") self.saveWin.workerFinished = True self.saveWin.close() @@ -843,31 +831,30 @@ def saveDataFinished(self): self.updateSegmDataAutoSaveWorker() if self.worker.addMetricsErrors: - self.warnErrorsAddMetrics() + self.warnErrorsAddMetrics() if self.worker.regionPropsErrors: self.warnErrorsRegionProps() if self.worker.customMetricsErrors: self.warnErrorsCustomMetrics() - + self.checkManageVersions() - + self.askConcatenate() - + if self.closeGUI: salute_string = myutils.get_salute_string() msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Data saved!. The GUI will now close.

    ' - f'{salute_string}' + f"Data saved!. The GUI will now close.

    {salute_string}" ) - msg.information(self, 'Data saved', txt) + msg.information(self, "Data saved", txt) self.close() def saveDataPermissionError(self, err_msg): self.setDisabled(False, keepDisabled=False) self.activateWindow() msg = QMessageBox() - msg.critical(self, 'Permission denied', err_msg, msg.Ok) + msg.critical(self, "Permission denied", err_msg, msg.Ok) self.waitCond.wakeAll() def saveDataProgress(self, text): @@ -877,35 +864,33 @@ def saveDataProgress(self, text): def saveDataRegionPropsCritical(self, traceback_format, error_message): self.setDisabled(False, keepDisabled=False) self.activateWindow() - self.logger.info('') - _hl = '====================================' - self.logger.info(f'{_hl}\n{traceback_format}\n{_hl}') + self.logger.info("") + _hl = "====================================" + self.logger.info(f"{_hl}\n{traceback_format}\n{_hl}") self.worker.regionPropsErrors[error_message] = traceback_format def saveDataUpdateMetricsPbar(self, max, step): if max > 0: self.saveWin.metricsQPbar.setMaximum(max) self.saveWin.metricsQPbar.setValue(0) - self.saveWin.metricsQPbar.setValue( - self.saveWin.metricsQPbar.value()+step - ) + self.saveWin.metricsQPbar.setValue(self.saveWin.metricsQPbar.value() + step) def saveDataUpdatePbar(self, step, max=-1, exec_time=0.0): if max >= 0: self.saveWin.QPbar.setMaximum(max) else: - self.saveWin.QPbar.setValue(self.saveWin.QPbar.value()+step) - steps_left = self.saveWin.QPbar.maximum()-self.saveWin.QPbar.value() - seconds = round(exec_time*steps_left) + self.saveWin.QPbar.setValue(self.saveWin.QPbar.value() + step) + steps_left = self.saveWin.QPbar.maximum() - self.saveWin.QPbar.value() + seconds = round(exec_time * steps_left) ETA = myutils.seconds_to_ETA(seconds) - self.saveWin.ETA_label.setText(f'ETA: {ETA}') + self.saveWin.ETA_label.setText(f"ETA: {ETA}") def saveMetricsCritical(self, traceback_format): - print('\n====================================') + print("\n====================================") self.logger.exception(traceback_format) - print('====================================\n') - self.logger.info('Warning: calculating metrics failed see above...') - print('------------------------------') + print("====================================\n") + self.logger.info("Warning: calculating metrics failed see above...") + print("------------------------------") msg = widgets.myMessageBox(wrapText=False) err_msg = html_utils.paragraph(f""" @@ -918,9 +903,9 @@ def saveMetricsCritical(self, traceback_format): Please restart Cell-ACDC, we apologise for any inconvenience.

    """) - msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...') + msg.addShowInFileManagerButton(self.logs_path, txt="Show log file...") msg.setDetailedText(traceback_format, visible=True) - msg.critical(self, 'Critical error while saving metrics', err_msg) + msg.critical(self, "Critical error while saving metrics", err_msg) self.is_error_state = True self.waitCond.wakeAll() @@ -928,9 +913,9 @@ def saveMetricsCritical(self, traceback_format): def setAutoSaveAnnotationsEnabled(self, enabled): if not self.autoSaveActiveWorkers: return - + worker, thread = self.autoSaveActiveWorkers[-1] - + if enabled: worker.isAutoSaveAnnotON = self.autoSaveToggle.isChecked() else: @@ -939,9 +924,9 @@ def setAutoSaveAnnotationsEnabled(self, enabled): def setAutoSaveSegmentationEnabled(self, enabled): if not self.autoSaveActiveWorkers: return - + worker, thread = self.autoSaveActiveWorkers[-1] - + if enabled: worker.isAutoSaveON = self.autoSaveToggle.isChecked() else: @@ -950,9 +935,7 @@ def setAutoSaveSegmentationEnabled(self, enabled): def startAutoSaveEveryNframesTimer(self): posData = self.data[self.pos_i] self.autoSaveTimeStartFrameIdx = posData.frame_i - self.autoSaveTimer.timeout.connect( - self.autoSaveTimerCountFrames - ) + self.autoSaveTimer.timeout.connect(self.autoSaveTimerCountFrames) self.autoSaveTimer.start(500) def turnOffAutoSaveWorker(self): @@ -971,8 +954,8 @@ def waitAutoSaveWorker(self, worker): self.setStatusBarLabel(log=False) def warnDifferentSegmChannel( - self, loaded_channel, segm_channel_hyperparams, segmEndName - ): + self, loaded_channel, segm_channel_hyperparams, segmEndName + ): txt = html_utils.paragraph(f""" You loaded the segmentation file ending with _{segmEndName}.npz which corresponds to the channel @@ -985,28 +968,36 @@ def warnDifferentSegmChannel( """) msg = widgets.myMessageBox(showCentered=False, wrapText=False) msg.warning( - self, 'WARNING: Potential for data loss', txt, - buttonsTexts=('Cancel', 'Yes') + self, + "WARNING: Potential for data loss", + txt, + buttonsTexts=("Cancel", "Yes"), ) return msg.cancel def warnErrorsAddMetrics(self): win = apps.ComputeMetricsErrorsDialog( - self.worker.addMetricsErrors, self.logs_path, - log_type='standard_metrics', parent=self + self.worker.addMetricsErrors, + self.logs_path, + log_type="standard_metrics", + parent=self, ) win.exec_() def warnErrorsCustomMetrics(self): win = apps.ComputeMetricsErrorsDialog( - self.worker.customMetricsErrors, self.logs_path, - log_type='custom_metrics', parent=self + self.worker.customMetricsErrors, + self.logs_path, + log_type="custom_metrics", + parent=self, ) win.exec_() def warnErrorsRegionProps(self): win = apps.ComputeMetricsErrorsDialog( - self.worker.regionPropsErrors, self.logs_path, - log_type='region_props', parent=self + self.worker.regionPropsErrors, + self.logs_path, + log_type="region_props", + parent=self, ) win.exec_() diff --git a/cellacdc/mixins/seg_for_lost_ids.py b/cellacdc/mixins/seg_for_lost_ids.py index e3582442b..ee6433f78 100644 --- a/cellacdc/mixins/seg_for_lost_ids.py +++ b/cellacdc/mixins/seg_for_lost_ids.py @@ -12,86 +12,98 @@ from .segmentation import Segmentation from .frame_navigation import FrameNavigation + class SegForLostIds(Segmentation, FrameNavigation): """Extracted from guiWin.""" def SegForLostIDsSetSettings(self): try: - prev_model = str(self.df_settings.at['SegForLostIDsModel', 'value']) + prev_model = str(self.df_settings.at["SegForLostIDsModel", "value"]) except KeyError: prev_model = None win = apps.QDialogSelectModel(parent=self, customFirst=prev_model) win.exec_() if win.cancel: - self.logger.info('Seg for lost IDs cancelled.') + self.logger.info("Seg for lost IDs cancelled.") return base_model_name = win.selectedModel if base_model_name: - self.df_settings.at['SegForLostIDsModel', 'value'] = base_model_name + self.df_settings.at["SegForLostIDsModel", "value"] = base_model_name self.df_settings.to_csv(self.settings_csv_path) - model_name = 'local_seg' + model_name = "local_seg" idx = self.modelNames.index(model_name) acdcSegment = self.acdcSegment_li[idx] try: if acdcSegment is None or base_model_name != self.local_seg_base_model_name: - self.logger.info(f'Importing {base_model_name}...') + self.logger.info(f"Importing {base_model_name}...") acdcSegment = myutils.import_segment_module(base_model_name) self.acdcSegment_li[idx] = acdcSegment self.local_seg_base_model_name = base_model_name except (IndexError, ImportError, KeyError) as e: - self.logger.error(f'Error importing {base_model_name}: {e}') + self.logger.error(f"Error importing {base_model_name}: {e}") return - - extra_params = ['overlap_threshold', - 'padding', - 'size_perc_diff', - 'distance_filler_growth', - 'max_iterations', - 'allow_only_tracked_cells'] + + extra_params = [ + "overlap_threshold", + "padding", + "size_perc_diff", + "distance_filler_growth", + "max_iterations", + "allow_only_tracked_cells", + ] extra_types = [float, float, float, float, int, bool] - extra_defaults = [0.5, 0.8, 0.3, 1., 2, False] + extra_defaults = [0.5, 0.8, 0.3, 1.0, 2, False] - extra_desc = ['Overlap threshold with other already segemented cells over which newly segmented cells are discarded', - 'Padding of the box used for new segmentation around the segmentation from the previous frame', - 'Relative size difference acceptable compared to previous frames', - """Cells which are already segmented are filled with random noise sampled from background + extra_desc = [ + "Overlap threshold with other already segemented cells over which newly segmented cells are discarded", + "Padding of the box used for new segmentation around the segmentation from the previous frame", + "Relative size difference acceptable compared to previous frames", + """Cells which are already segmented are filled with random noise sampled from background to ensure that they don't get segmented again. This parameter controls the additional padding around the already segmented cells.""", - """The algorithm will try and segment the maximum amount + """The algorithm will try and segment the maximum amount of cells in the image by running the model several times and filling new found cells with background noise. How many of these iterations should be run?""", - "If no new cell IDs should be permitted (based on real time tracking)"] + "If no new cell IDs should be permitted (based on real time tracking)", + ] extra_ArgSpec = [] for i, param in enumerate(extra_params): - param = ArgSpec(name=param, - default=extra_defaults[i], - type=extra_types[i], - desc=extra_desc[i], - docstring='') + param = ArgSpec( + name=param, + default=extra_defaults[i], + type=extra_types[i], + desc=extra_desc[i], + docstring="", + ) extra_ArgSpec.append(param) init_params, segment_params = myutils.getModelArgSpec(acdcSegment) - segment_params = [arg for arg in segment_params if arg[0] != 'diameter'] - - extraParamsTitle = 'Settings for local segmentation' + segment_params = [arg for arg in segment_params if arg[0] != "diameter"] + + extraParamsTitle = "Settings for local segmentation" win = self.initSegmModelParams( - base_model_name, acdcSegment, init_params, segment_params, - extraParams=extra_ArgSpec, extraParamsTitle=extraParamsTitle, - initLastParams=True, ini_filename='segmentation_for_lostIDs.ini', + base_model_name, + acdcSegment, + init_params, + segment_params, + extraParams=extra_ArgSpec, + extraParamsTitle=extraParamsTitle, + initLastParams=True, + ini_filename="segmentation_for_lostIDs.ini", ) if win is None: - self.logger.info('Segmentation for lost IDs cancelled.') + self.logger.info("Segmentation for lost IDs cancelled.") return init_kwargs_new = {} @@ -107,17 +119,18 @@ def SegForLostIDsSetSettings(self): args_new[key] = val self.SegForLostIDsSettings = { - 'win': win, - 'init_kwargs_new': init_kwargs_new, - 'args_new': args_new, - 'base_model_name': base_model_name, + "win": win, + "init_kwargs_new": init_kwargs_new, + "args_new": args_new, + "base_model_name": base_model_name, } def SegForLostIDsWorkerAskInstallGPU(self, model_name, use_gpu): result = myutils.check_gpu_available(model_name, use_gpu, qparent=self) self.SegForLostIDsWorker.gpu_go = result dont_force_cpu = myutils.check_gpu_available( - model_name, use_gpu, do_not_warn=True) + model_name, use_gpu, do_not_warn=True + ) self.SegForLostIDsWorker.dont_force_cpu = dont_force_cpu self.SegForLostIDsWaitCond.wakeAll() @@ -129,7 +142,7 @@ def SegForLostIDsWorkerFinished(self): self.updateAllImages() self.update_rp() self.store_data(autosave=True) - self.setFrameNavigationDisabled(disable=False, why='Segmentation for lost IDs') + self.setFrameNavigationDisabled(disable=False, why="Segmentation for lost IDs") if self.progressWin is not None: self.progressWin.workerFinished = True @@ -137,7 +150,7 @@ def SegForLostIDsWorkerFinished(self): self.progressWin = None def onSegForLostInit(self): - self.logger.info('Settings for segmentation for lost IDs not set.') + self.logger.info("Settings for segmentation for lost IDs not set.") self.SegForLostIDsSetSettings() self.SegForLostIDsWaitCond.wakeAll() @@ -146,48 +159,80 @@ def onSigGetData(self, waitcond, debug=False): waitcond.wakeAll() def onSigStoreData( - self, waitcond, pos_i=None, enforce=True, debug=False, - mainThread=True, autosave=True, store_cca_df_copy=False - ): - self.store_data(pos_i=pos_i, enforce=enforce, debug=debug, mainThread=mainThread, - autosave=autosave, store_cca_df_copy=store_cca_df_copy) + self, + waitcond, + pos_i=None, + enforce=True, + debug=False, + mainThread=True, + autosave=True, + store_cca_df_copy=False, + ): + self.store_data( + pos_i=pos_i, + enforce=enforce, + debug=debug, + mainThread=mainThread, + autosave=autosave, + store_cca_df_copy=store_cca_df_copy, + ) waitcond.wakeAll() def onSigStoreDataSegForLostIDsWorker(self, autosave): - self.onSigStoreData( - self.SegForLostIDsWaitCond, autosave=autosave) + self.onSigStoreData(self.SegForLostIDsWaitCond, autosave=autosave) - def onSigTrackManuallyAddedObjectSegForLostIDsWorker(self, added_IDs, isNewID, wl_update, wl_track_og_curr): - self.trackManuallyAddedObject(added_IDs, isNewID, wl_update=wl_update, wl_track_og_curr=wl_track_og_curr) + def onSigTrackManuallyAddedObjectSegForLostIDsWorker( + self, added_IDs, isNewID, wl_update, wl_track_og_curr + ): + self.trackManuallyAddedObject( + added_IDs, isNewID, wl_update=wl_update, wl_track_og_curr=wl_track_og_curr + ) self.SegForLostIDsWaitCond.wakeAll() - def onSigUpdateRP(self, waitcond, draw=True, debug=False, update_IDs=True, - wl_update=True, wl_track_og_curr=False): - self.update_rp(draw=draw, debug=debug, update_IDs=update_IDs, - wl_update=wl_update, wl_track_og_curr=wl_track_og_curr) + def onSigUpdateRP( + self, + waitcond, + draw=True, + debug=False, + update_IDs=True, + wl_update=True, + wl_track_og_curr=False, + ): + self.update_rp( + draw=draw, + debug=debug, + update_IDs=update_IDs, + wl_update=wl_update, + wl_track_og_curr=wl_track_og_curr, + ) waitcond.wakeAll() def onSigUpdateRPSegForLostIDsWorker(self, wl_update, wl_track_og_curr): - self.onSigUpdateRP(self.SegForLostIDsWaitCond, - wl_update=wl_update, - wl_track_og_curr=wl_track_og_curr) + self.onSigUpdateRP( + self.SegForLostIDsWaitCond, + wl_update=wl_update, + wl_track_og_curr=wl_track_og_curr, + ) def segForLostIDsButtonClicked(self): - self.setFrameNavigationDisabled(disable=True, why='Segmentation for lost IDs') + self.setFrameNavigationDisabled(disable=True, why="Segmentation for lost IDs") posData = self.data[self.pos_i] if posData.frame_i == 0: - self.logger.info('Segmentation for lost IDs not available on first frame.') - self.setFrameNavigationDisabled(disable=False, why='Segmentation for lost IDs') + self.logger.info("Segmentation for lost IDs not available on first frame.") + self.setFrameNavigationDisabled( + disable=False, why="Segmentation for lost IDs" + ) return self.storeUndoRedoStates(False) self.progressWin = apps.QDialogWorkerProgress( - title='Segmenting for lost IDs', parent=self, - pbarDesc=f'Segmenting for lost IDs...' + title="Segmenting for lost IDs", + parent=self, + pbarDesc=f"Segmenting for lost IDs...", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) - + self.startSegForLostIDsWorker() def showImageDebug(self, img): @@ -208,35 +253,47 @@ def startSegForLostIDsWorker(self): self.SegForLostIDsWorker.sigAskInstallModel.connect( self.SegForLostIDsWorkerAskInstallModel ) - self.SegForLostIDsWorker.sigshowImageDebug.connect( - self.showImageDebug - ) - + self.SegForLostIDsWorker.sigshowImageDebug.connect(self.showImageDebug) + self.SegForLostIDsWorker.sigSegForLostIDsWorkerAskInstallGPU.connect( self.SegForLostIDsWorkerAskInstallGPU ) - self.SegForLostIDsWorker.sigStoreData.connect(self.onSigStoreDataSegForLostIDsWorker) - self.SegForLostIDsWorker.sigUpdateRP.connect(self.onSigUpdateRPSegForLostIDsWorker) + self.SegForLostIDsWorker.sigStoreData.connect( + self.onSigStoreDataSegForLostIDsWorker + ) + self.SegForLostIDsWorker.sigUpdateRP.connect( + self.onSigUpdateRPSegForLostIDsWorker + ) # self.SegForLostIDsWorker.sigGetData.connect(self.onSigGetDataSegForLostIDsWorker) # self.SegForLostIDsWorker.sigGet2Dlab.connect(self.onSigGet2DlabSegForLostIDsWorker) # self.SegForLostIDsWorker.sigGetTrackedLostIDs.connect(self.onSigGetTrackedSegForLostIDsWorker) # self.SegForLostIDsWorker.sigGetBrushID.connect(self.onSigGetBrushIDSegForLostIDsWorker) - self.SegForLostIDsWorker.sigTrackManuallyAddedObject.connect(self.onSigTrackManuallyAddedObjectSegForLostIDsWorker) + self.SegForLostIDsWorker.sigTrackManuallyAddedObject.connect( + self.onSigTrackManuallyAddedObjectSegForLostIDsWorker + ) # Move the worker to the thread self.SegForLostIDsWorker.moveToThread(self._thread) # Manage thread lifecycle self.SegForLostIDsWorker.signals.finished.connect(self._thread.quit) - self.SegForLostIDsWorker.signals.finished.connect(self.SegForLostIDsWorker.deleteLater) + self.SegForLostIDsWorker.signals.finished.connect( + self.SegForLostIDsWorker.deleteLater + ) self._thread.finished.connect(self._thread.deleteLater) # Connect other worker signals to the appropriate slots - self.SegForLostIDsWorker.signals.finished.connect(self.SegForLostIDsWorkerFinished) + self.SegForLostIDsWorker.signals.finished.connect( + self.SegForLostIDsWorkerFinished + ) self.SegForLostIDsWorker.signals.progress.connect(self.workerProgress) - self.SegForLostIDsWorker.signals.initProgressBar.connect(self.workerInitProgressbar) - self.SegForLostIDsWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.SegForLostIDsWorker.signals.initProgressBar.connect( + self.workerInitProgressbar + ) + self.SegForLostIDsWorker.signals.progressBar.connect( + self.workerUpdateProgressbar + ) self.SegForLostIDsWorker.signals.critical.connect(self.workerCritical) # Start the thread and worker diff --git a/cellacdc/mixins/segmentation.py b/cellacdc/mixins/segmentation.py index b9568bac9..73c08645f 100644 --- a/cellacdc/mixins/segmentation.py +++ b/cellacdc/mixins/segmentation.py @@ -21,6 +21,7 @@ from .tool_activation import ToolActivation + class Segmentation(ToolActivation): """Extracted from guiWin.""" @@ -30,11 +31,11 @@ def autoSegm_cb(self, checked): # Ask which model models = myutils.get_list_of_models() win = widgets.QDialogListbox( - 'Select model', - 'Select model to use for segmentation: ', + "Select model", + "Select model to use for segmentation: ", models, multiSelection=False, - parent=self + parent=self, ) win.exec_() if win.cancel: @@ -65,30 +66,29 @@ def checkIfAutoSegm(self): for lab in posData.segm_data: if not np.any(lab): ask = True - txt = 'frames' + txt = "frames" break else: if not np.any(posData.segm_data): ask = True - txt = 'positions' + txt = "positions" break if not ask: return questionTxt = html_utils.paragraph( - f'Some or all loaded {txt} contain empty segmentation masks.

    ' - 'Do you want to activate automatic segmentation* ' - f'when visiting these {txt}?

    ' - '* Automatic segmentation can always be turned ON/OFF from the menu
    ' - ' Edit --> Segmentation --> Enable automatic segmentation

    ' - f'NOTE: you can automatically segment all {txt} using the
    ' - ' segmentation module.' + f"Some or all loaded {txt} contain empty segmentation masks.

    " + "Do you want to activate automatic segmentation* " + f"when visiting these {txt}?

    " + "* Automatic segmentation can always be turned ON/OFF from the menu
    " + " Edit --> Segmentation --> Enable automatic segmentation

    " + f"NOTE: you can automatically segment all {txt} using the
    " + " segmentation module." ) msg = widgets.myMessageBox(wrapText=False) noButton, yesButton = msg.question( - self, 'Automatic segmentation?', questionTxt, - buttonsTexts=('No', 'Yes') + self, "Automatic segmentation?", questionTxt, buttonsTexts=("No", "Yes") ) if msg.clickedButton == yesButton: self.autoSegmAction.setChecked(True) @@ -99,7 +99,7 @@ def checkIfAutoSegm(self): def computeSegm(self, force=False): posData = self.data[self.pos_i] mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer' or mode == 'Cell cycle analysis': + if mode == "Viewer" or mode == "Cell cycle analysis": return if np.any(posData.lab) and not force: @@ -118,45 +118,58 @@ def debugSegmWorker(self, to_debug): self.segmWorkerWaitCond.wakeAll() def initSegmModelParams( - self, model_name, acdcSegment, init_params, segment_params, - is_label_roi=False, initLastParams=False, - extraParams=None, extraParamsTitle=None,ini_filename=None - - ): - posData = self.data[self.pos_i] + self, + model_name, + acdcSegment, + init_params, + segment_params, + is_label_roi=False, + initLastParams=False, + extraParams=None, + extraParamsTitle=None, + ini_filename=None, + ): + posData = self.data[self.pos_i] try: url = acdcSegment.url_help() except AttributeError: url = None - - text_if_cancelled = 'Segmentation process cancelled.' + + text_if_cancelled = "Segmentation process cancelled." out = prompts.init_segm_model_params( - posData, model_name, init_params, segment_params, - help_url=url, qparent=self, init_last_params=initLastParams, - check_sam_embeddings=not is_label_roi, is_gui_caller=True, - extraParams=extraParams,extraParamsTitle=extraParamsTitle, + posData, + model_name, + init_params, + segment_params, + help_url=url, + qparent=self, + init_last_params=initLastParams, + check_sam_embeddings=not is_label_roi, + is_gui_caller=True, + extraParams=extraParams, + extraParamsTitle=extraParamsTitle, ini_filename=ini_filename, ) - if out.get('load_sam_embeddings', False): - self.logger.info('Loading Segment Anything image embeddings...') + if out.get("load_sam_embeddings", False): + self.logger.info("Loading Segment Anything image embeddings...") for _posData in self.data: _posData.loadSamEmbeddings(logger_func=None) - text_if_cancelled = 'SAM embeddings loaded.' - - win = out.get('win') + text_if_cancelled = "SAM embeddings loaded." + + win = out.get("win") if win is None: self.logger.info(text_if_cancelled) self.titleLabel.setText(text_if_cancelled) return - + if win.cancel: self.logger.info(text_if_cancelled) self.titleLabel.setText(text_if_cancelled) return - - if model_name != 'thresholding': + + if model_name != "thresholding": self.model_kwargs = win.model_kwargs - + return win def init_segmInfo_df(self): @@ -174,12 +187,8 @@ def postProcessSegm(self, checked): SizeZ = None if checked: posData = self.data[self.pos_i] - self.postProcessSegmWin = apps.PostProcessSegmDialog( - posData, mainWin=self - ) - self.postProcessSegmWin.sigClosed.connect( - self.postProcessSegmWinClosed - ) + self.postProcessSegmWin = apps.PostProcessSegmDialog(posData, mainWin=self) + self.postProcessSegmWin.sigClosed.connect(self.postProcessSegmWinClosed) self.postProcessSegmWin.sigValueChanged.connect( self.postProcessSegmValueChanged ) @@ -196,27 +205,30 @@ def postProcessSegm(self, checked): self.postProcessSegmWin = None def postProcessSegmApplyToAllFutureFrames( - self, postProcessKwargs, - customPostProcessGroupedFeatures, - customPostProcessFeatures - ): + self, + postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures, + ): proceed = self.warnEditingWithCca_df( - 'post-processing segmentation', update_images=False + "post-processing segmentation", update_images=False ) if not proceed: - self.logger.info('Post-processing segmentation cancelled.') + self.logger.info("Post-processing segmentation cancelled.") return self.progressWin = apps.QDialogWorkerProgress( - title='Post-processing segmentation', parent=self, - pbarDesc=f'Post-processing segmentation masks...' + title="Post-processing segmentation", + parent=self, + pbarDesc=f"Post-processing segmentation masks...", ) self.progressWin.show(self.app) self.progressWin.mainPbar.setMaximum(0) self.startPostProcessSegmWorker( - postProcessKwargs, customPostProcessGroupedFeatures, - customPostProcessFeatures + postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures, ) def postProcessSegmEditingFinished(self): @@ -228,19 +240,19 @@ def postProcessSegmValueChanged(self, lab, delObjs: dict): for delObj in delObjs.values(): self.clearObjContour(obj=delObj, ax=0) self.clearObjContour(obj=delObj, ax=1) - + posData = self.data[self.pos_i] - + labelsToSkip = {} for ID in posData.IDs: if ID in delObjs: labelsToSkip[ID] = True continue - + restoreObj = self.postProcessSegmWin.origObjs[ID] self.addObjContourToContoursImage(obj=restoreObj, ax=0) self.addObjContourToContoursImage(obj=restoreObj, ax=1) - + # self.setAllTextAnnotations(labelsToSkip=labelsToSkip) posData.lab = lab @@ -262,34 +274,32 @@ def postProcessSegmWorkerFinished(self): self.progressWin = None self.get_data() self.updateAllImages() - self.titleLabel.setText('Post-processing segmentation done!', color='w') - self.logger.info('Post-processing segmentation done!') + self.titleLabel.setText("Post-processing segmentation done!", color="w") + self.logger.info("Post-processing segmentation done!") def postProcessing(self): if self.postProcessSegmWin is None: return - + self.postProcessSegmWin.setPosData() posData = self.data[self.pos_i] lab, delIDs = self.postProcessSegmWin.apply() - if posData.allData_li[posData.frame_i]['labels'] is None: + if posData.allData_li[posData.frame_i]["labels"] is None: posData.lab = lab.copy() self.update_rp() else: - posData.allData_li[posData.frame_i]['labels'] = lab + posData.allData_li[posData.frame_i]["labels"] = lab self.get_data() def reinitStoredSegmModels(self): - self.models = [None]*len(self.models) + self.models = [None] * len(self.models) - def repeatSegm( - self, model_name='', askSegmParams=False, is_label_roi=False - ): - if model_name == 'thresholding': + def repeatSegm(self, model_name="", askSegmParams=False, is_label_roi=False): + if model_name == "thresholding": # thresholding model is stored as 'Automatic thresholding' # at line of code `models.append('Automatic thresholding')` - model_name = 'Automatic thresholding' - + model_name = "Automatic thresholding" + idx = self.modelNames.index(model_name) # Ask segm parameters if not already set # and not called by segmSingleFrameMenu (askSegmParams=False) @@ -302,17 +312,17 @@ def repeatSegm( # Store undo state before modifying stuff self.storeUndoRedoStates(False) - if model_name == 'Automatic thresholding': - # Automatic thresholding is the name of the models as stored + if model_name == "Automatic thresholding": + # Automatic thresholding is the name of the models as stored # in self.modelNames, but the actual model is called thresholding # (see cellacdc/models/thresholding) - model_name = 'thresholding' + model_name = "thresholding" posData = self.data[self.pos_i] # Check if model needs to be imported acdcSegment = self.acdcSegment_li[idx] if acdcSegment is None: - self.logger.info(f'Importing {model_name}...') + self.logger.info(f"Importing {model_name}...") acdcSegment = myutils.import_segment_module(model_name) self.acdcSegment_li[idx] = acdcSegment @@ -330,10 +340,10 @@ def repeatSegm( url = acdcSegment.url_help() except AttributeError: url = None - + self.preproc_recipe = None initLastParams = True - if model_name == 'thresholding': + if model_name == "thresholding": win = apps.QDialogAutomaticThresholding( parent=self, isSegm3D=self.isSegm3D ) @@ -341,56 +351,57 @@ def repeatSegm( if win.cancel: return self.model_kwargs = win.segment_kwargs - thresh_method = self.model_kwargs['threshold_method'] - gauss_sigma = self.model_kwargs['gauss_sigma'] + thresh_method = self.model_kwargs["threshold_method"] + gauss_sigma = self.model_kwargs["gauss_sigma"] segment_params = myutils.insertModelArgSpec( - segment_params, 'threshold_method', thresh_method + segment_params, "threshold_method", thresh_method ) segment_params = myutils.insertModelArgSpec( - segment_params, 'gauss_sigma', gauss_sigma + segment_params, "gauss_sigma", gauss_sigma ) initLastParams = False - + win = self.initSegmModelParams( - model_name, acdcSegment, init_params, segment_params, - is_label_roi=is_label_roi, - initLastParams=initLastParams + model_name, + acdcSegment, + init_params, + segment_params, + is_label_roi=is_label_roi, + initLastParams=initLastParams, ) if win is None: return - + self.standardPostProcessKwargs = win.standardPostProcessKwargs self.customPostProcessFeatures = win.customPostProcessFeatures - self.customPostProcessGroupedFeatures = ( - win.customPostProcessGroupedFeatures - ) + self.customPostProcessGroupedFeatures = win.customPostProcessGroupedFeatures self.applyPostProcessing = win.applyPostProcessing self.secondChannelName = win.secondChannelName self.preproc_recipe = win.preproc_recipe - + myutils.log_segm_params( - model_name, win.init_kwargs, win.model_kwargs, - logger_func=self.logger.info, - preproc_recipe=win.preproc_recipe, - apply_post_process=self.applyPostProcessing, - standard_postprocess_kwargs=self.standardPostProcessKwargs, - custom_postprocess_features=self.customPostProcessFeatures + model_name, + win.init_kwargs, + win.model_kwargs, + logger_func=self.logger.info, + preproc_recipe=win.preproc_recipe, + apply_post_process=self.applyPostProcessing, + standard_postprocess_kwargs=self.standardPostProcessKwargs, + custom_postprocess_features=self.customPostProcessFeatures, ) - use_gpu = win.init_kwargs.get('gpu', False) + use_gpu = win.init_kwargs.get("gpu", False) proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) if not proceed: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') + self.logger.info("Segmentation process cancelled.") + self.titleLabel.setText("Segmentation process cancelled.") return - - model = myutils.init_segm_model( - acdcSegment, posData, win.init_kwargs - ) + + model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) if model is None: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') - return + self.logger.info("Segmentation process cancelled.") + self.titleLabel.setText("Segmentation process cancelled.") + return try: model.setupLogger(self.logger) except Exception as e: @@ -399,98 +410,101 @@ def repeatSegm( model.model_name = model_name else: model = self.models[idx] - + if is_label_roi: return model self.titleLabel.setText( - f'Segmenting with {model_name}... ' - '(check progress in terminal/console)', color=self.titleColor + f"Segmenting with {model_name}... (check progress in terminal/console)", + color=self.titleColor, ) - - post_process_params = { - 'applied_postprocessing': self.applyPostProcessing - } + + post_process_params = {"applied_postprocessing": self.applyPostProcessing} post_process_params = { - **post_process_params, + **post_process_params, **self.standardPostProcessKwargs, - **self.customPostProcessFeatures + **self.customPostProcessFeatures, } if askSegmParams: posData.saveSegmHyperparams( - model_name, win.init_kwargs, win.model_kwargs, + model_name, + win.init_kwargs, + win.model_kwargs, post_process_params=post_process_params, - preproc_recipe=self.preproc_recipe + preproc_recipe=self.preproc_recipe, ) if self.askRepeatSegment3D: self.segment3D = False if self.isSegm3D and self.askRepeatSegment3D: msg = widgets.myMessageBox(showCentered=False) - msg.addDoNotShowAgainCheckbox(text='Do not ask again') + msg.addDoNotShowAgainCheckbox(text="Do not ask again") txt = html_utils.paragraph( - 'Do you want to segment the entire z-stack or only the ' - 'current z-slice?' + "Do you want to segment the entire z-stack or only the " + "current z-slice?" ) _, segment3DButton, _ = msg.question( - self, '3D segmentation?', txt, - buttonsTexts=( - 'Cancel', 'Segment 3D z-stack', 'Segment 2D z-slice' - ) + self, + "3D segmentation?", + txt, + buttonsTexts=("Cancel", "Segment 3D z-stack", "Segment 2D z-slice"), ) if msg.cancel: - self.titleLabel.setText('Segmentation process aborted.') - self.logger.info('Segmentation process aborted.') + self.titleLabel.setText("Segmentation process aborted.") + self.logger.info("Segmentation process aborted.") return self.segment3D = msg.clickedButton == segment3DButton if msg.doNotShowAgainCheckbox.isChecked(): self.askRepeatSegment3D = False - + if self.askZrangeSegm3D: self.z_range = None if self.isSegm3D and self.segment3D and self.askZrangeSegm3D: idx = (posData.filename, posData.frame_i) try: - orignal_z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + orignal_z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] except ValueError as e: - orignal_z = posData.segmInfo_df.loc[idx, 'z_slice_used_gui'].iloc[0] + orignal_z = posData.segmInfo_df.loc[idx, "z_slice_used_gui"].iloc[0] selectZtool = apps.QCropZtool( - posData.SizeZ, parent=self, cropButtonText='Ok', - addDoNotShowAgain=True, title='Select z-slice range to segment' + posData.SizeZ, + parent=self, + cropButtonText="Ok", + addDoNotShowAgain=True, + title="Select z-slice range to segment", ) selectZtool.sigZvalueChanged.connect(self.selectZtoolZvalueChanged) selectZtool.sigCrop.connect(selectZtool.close) selectZtool.exec_() self.update_z_slice(orignal_z) if selectZtool.cancel: - self.titleLabel.setText('Segmentation process aborted.') - self.logger.info('Segmentation process aborted.') + self.titleLabel.setText("Segmentation process aborted.") + self.logger.info("Segmentation process aborted.") return startZ = selectZtool.lowerZscrollbar.value() stopZ = selectZtool.upperZscrollbar.value() self.z_range = (startZ, stopZ) if selectZtool.doNotShowAgainCheckbox.isChecked(): self.askZrangeSegm3D = False - + secondChannelData = None if self.secondChannelName is not None: secondChannelData = self.getSecondChannelData() - + self.titleLabel.setText( - f'{model_name} is thinking... ' - '(check progress in terminal/console)', color=self.titleColor + f"{model_name} is thinking... (check progress in terminal/console)", + color=self.titleColor, ) self.model = model - + self.segmWorkerMutex = QMutex() self.segmWorkerWaitCond = QWaitCondition() self.thread = QThread() self.worker = workers.segmWorker( - self, + self, secondChannelData=secondChannelData, - mutex=self.segmWorkerMutex, - waitCond=self.segmWorkerWaitCond + mutex=self.segmWorkerMutex, + waitCond=self.segmWorkerWaitCond, ) self.worker.z_range = self.z_range self.worker.moveToThread(self.thread) @@ -508,27 +522,27 @@ def repeatSegm( self.thread.start() def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): - if model_name == 'thresholding': + if model_name == "thresholding": # thresholding model is stored as 'Automatic thresholding' # at line of code `models.append('Automatic thresholding')` - model_name = 'Automatic thresholding' + model_name = "Automatic thresholding" idx = self.modelNames.index(model_name) self.downloadWin = apps.downloadModel(model_name, parent=self) self.downloadWin.download() - if model_name == 'Automatic thresholding': - # Automatic thresholding is the name of the models as stored + if model_name == "Automatic thresholding": + # Automatic thresholding is the name of the models as stored # in self.modelNames, but the actual model is called thresholding # (see cellacdc/models/thresholding) - model_name = 'thresholding' + model_name = "thresholding" posData = self.data[self.pos_i] # Check if model needs to be imported acdcSegment = self.acdcSegment_li[idx] if acdcSegment is None: - self.logger.info(f'Importing {model_name}...') + self.logger.info(f"Importing {model_name}...") acdcSegment = myutils.import_segment_module(model_name) self.acdcSegment_li[idx] = acdcSegment @@ -539,15 +553,15 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): url = acdcSegment.url_help() except AttributeError: url = None - - if model_name == 'thresholding': + + if model_name == "thresholding": autoThreshWin = apps.QDialogAutomaticThresholding( parent=self, isSegm3D=self.isSegm3D ) autoThreshWin.exec_() if autoThreshWin.cancel: return - + win = self.initSegmModelParams( model_name, acdcSegment, init_params, segment_params ) @@ -556,58 +570,57 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): self.standardPostProcessKwargs = win.standardPostProcessKwargs self.customPostProcessFeatures = win.customPostProcessFeatures - self.customPostProcessGroupedFeatures = ( - win.customPostProcessGroupedFeatures - ) + self.customPostProcessGroupedFeatures = win.customPostProcessGroupedFeatures self.applyPostProcessing = win.applyPostProcessing self.preproc_recipe = win.preproc_recipe - + myutils.log_segm_params( - model_name, win.init_kwargs, win.model_kwargs, - logger_func=self.logger.info, - preproc_recipe=win.preproc_recipe, - apply_post_process=self.applyPostProcessing, - standard_postprocess_kwargs=self.standardPostProcessKwargs, - custom_postprocess_features=self.customPostProcessFeatures + model_name, + win.init_kwargs, + win.model_kwargs, + logger_func=self.logger.info, + preproc_recipe=win.preproc_recipe, + apply_post_process=self.applyPostProcessing, + standard_postprocess_kwargs=self.standardPostProcessKwargs, + custom_postprocess_features=self.customPostProcessFeatures, ) - + secondChannelData = None if win.secondChannelName is not None: secondChannelData = self.getSecondChannelData() - use_gpu = win.init_kwargs.get('gpu', False) + use_gpu = win.init_kwargs.get("gpu", False) proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) if not proceed: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') + self.logger.info("Segmentation process cancelled.") + self.titleLabel.setText("Segmentation process cancelled.") return model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) if model is None: - self.logger.info('Segmentation process cancelled.') - self.titleLabel.setText('Segmentation process cancelled.') + self.logger.info("Segmentation process cancelled.") + self.titleLabel.setText("Segmentation process cancelled.") return try: model.setupLogger(self.logger) except Exception as e: pass - + self.extendSegmDataIfNeeded(stopFrameNum) - self.reInitLastSegmFrame( - from_frame_i=startFrameNum-1, updateImages=False - ) + self.reInitLastSegmFrame(from_frame_i=startFrameNum - 1, updateImages=False) self.titleLabel.setText( - f'{model_name} is thinking... ' - '(check progress in terminal/console)', color=self.titleColor + f"{model_name} is thinking... (check progress in terminal/console)", + color=self.titleColor, ) self.progressWin = apps.QDialogWorkerProgress( - title='Segmenting video', parent=self, - pbarDesc=f'Segmenting from frame n. {startFrameNum} to {stopFrameNum}...' + title="Segmenting video", + parent=self, + pbarDesc=f"Segmenting from frame n. {startFrameNum} to {stopFrameNum}...", ) self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stopFrameNum-startFrameNum) + self.progressWin.mainPbar.setMaximum(stopFrameNum - startFrameNum) self.thread = QThread() self.worker = workers.segmVideoWorker( @@ -636,7 +649,7 @@ def resetCursor(self): def segmFrameCallback(self, action): if action == self.addCustomModelFrameAction: return - + idx = self.segmActions.index(action) model_name = self.modelNames[idx] self.repeatSegm(model_name=model_name, askSegmParams=True) @@ -647,11 +660,11 @@ def segmVideoCallback(self, action): posData = self.data[self.pos_i] win = apps.startStopFramesDialog( - posData.SizeT, currentFrameNum=posData.frame_i+1 + posData.SizeT, currentFrameNum=posData.frame_i + 1 ) win.exec_() if win.cancel: - self.logger.info('Segmentation on multiple frames aborted.') + self.logger.info("Segmentation on multiple frames aborted.") return idx = self.segmActionsVideo.index(action) @@ -669,18 +682,18 @@ def segmVideoWorkerFinished(self, exec_time): self.tracking(enforce=True) self.updateAllImages() - txt = f'Done. Segmentation computed in {exec_time:.3f} s' - self.logger.info('-----------------') + txt = f"Done. Segmentation computed in {exec_time:.3f} s" + self.logger.info("-----------------") self.logger.info(txt) - self.logger.info('=================') - self.titleLabel.setText(txt, color='g') + self.logger.info("=================") + self.titleLabel.setText(txt, color="g") def segmWorkerFinished(self, lab, exec_time): posData = self.data[self.pos_i] - if posData.segmInfo_df is not None and posData.SizeZ>1: + if posData.segmInfo_df is not None and posData.SizeZ > 1: idx = (posData.filename, posData.frame_i) - posData.segmInfo_df.at[idx, 'resegmented_in_gui'] = True + posData.segmInfo_df.at[idx, "resegmented_in_gui"] = True if lab.ndim == 2 and self.isSegm3D: self.set_2Dlab(lab) @@ -688,23 +701,23 @@ def segmWorkerFinished(self, lab, exec_time): posData.lab = lab.copy() self.activateAnnotations() - + self.update_rp(wl_update=False) - self.tracking(enforce=True, against_next=posData.frame_i==0) - + self.tracking(enforce=True, against_next=posData.frame_i == 0) + if self.isSnapshot: - self.fixCcaDfAfterEdit('Repeat segmentation') + self.fixCcaDfAfterEdit("Repeat segmentation") self.updateAllImages() else: - self.warnEditingWithCca_df('Repeat segmentation') + self.warnEditingWithCca_df("Repeat segmentation") - txt = f'Done. Segmentation computed in {exec_time:.3f} s' - self.logger.info('-----------------') + txt = f"Done. Segmentation computed in {exec_time:.3f} s" + self.logger.info("-----------------") self.logger.info(txt) - self.logger.info('=================') - self.titleLabel.setText(txt, color='g') + self.logger.info("=================") + self.titleLabel.setText(txt, color="g") self.checkIfAutoSegm() - + QTimer.singleShot(200, self.resizeGui) def segmentToolActionTriggered(self): @@ -712,14 +725,12 @@ def segmentToolActionTriggered(self): win = apps.QDialogSelectModel(parent=self) win.exec_() if win.cancel: - self.logger.info('Repeat segmentation cancelled.') + self.logger.info("Repeat segmentation cancelled.") return model_name = win.selectedModel - self.repeatSegm( - model_name=model_name, askSegmParams=True - ) + self.repeatSegm(model_name=model_name, askSegmParams=True) else: - self.repeatSegm(model_name=self.segmModelName) + self.repeatSegm(model_name=self.segmModelName) def selectZtoolZvalueChanged(self, whichZ, z): self.update_z_slice(z) @@ -727,9 +738,9 @@ def selectZtoolZvalueChanged(self, whichZ, z): def showInstructionsCustomModel(self): modelFilePath = apps.addCustomModelMessages(self) if modelFilePath is None: - self.logger.info('Adding custom model process stopped.') + self.logger.info("Adding custom model process stopped.") return - + myutils.store_custom_model_path(modelFilePath) modelName = os.path.basename(os.path.dirname(modelFilePath)) customModelAction = QAction(modelName) diff --git a/cellacdc/mixins/session.py b/cellacdc/mixins/session.py index 2019fc233..fd98da6fa 100644 --- a/cellacdc/mixins/session.py +++ b/cellacdc/mixins/session.py @@ -20,115 +20,122 @@ from .worker import Worker + class Session(Worker): """Extracted from guiWin.""" - def _get_data_unvisited(self, posData, debug=False, lin_tree_init=True,): + def _get_data_unvisited( + self, + posData, + debug=False, + lin_tree_init=True, + ): posData.editID_info = [] proceed_cca = True never_visited = True - if str(self.modeComboBox.currentText()) == 'Cell cycle analysis': + if str(self.modeComboBox.currentText()) == "Cell cycle analysis": # Warn that we are visiting a frame that was never segm-checked # on cell cycle analysis mode msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Segmentation and Tracking was never checked from ' - f'frame {posData.frame_i+1} onwards.

    ' - 'To ensure correct cell cell cycle analysis you have to ' - 'first visit the frames after ' - f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' + "Segmentation and Tracking was never checked from " + f"frame {posData.frame_i + 1} onwards.

    " + "To ensure correct cell cell cycle analysis you have to " + "first visit the frames after " + f'{posData.frame_i + 1} with "Segmentation and Tracking" mode.' ) warn_cca = msg.critical( - self, 'Never checked segmentation on requested frame', txt + self, "Never checked segmentation on requested frame", txt ) proceed_cca = False return proceed_cca, never_visited - elif str(self.modeComboBox.currentText()) == 'Normal division: Lineage tree': + elif str(self.modeComboBox.currentText()) == "Normal division: Lineage tree": # Warn that we are visiting a frame that was never segm-checked # on cell cycle analysis mode msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Segmentation and Tracking was never checked from ' - f'frame {posData.frame_i+1} onwards.

    ' - 'To ensure correct lineage tree analysis you have to ' - 'first visit the frames after ' - f'{posData.frame_i+1} with "Segmentation and Tracking" mode.' + "Segmentation and Tracking was never checked from " + f"frame {posData.frame_i + 1} onwards.

    " + "To ensure correct lineage tree analysis you have to " + "first visit the frames after " + f'{posData.frame_i + 1} with "Segmentation and Tracking" mode.' ) - warn_cca = msg.critical(#??? - self, 'Never checked segmentation on requested frame', txt + warn_cca = msg.critical( # ??? + self, "Never checked segmentation on requested frame", txt ) proceed_cca = False return proceed_cca, never_visited - + # Requested frame was never visited before. Load from HDD labels = self.get_labels() - posData.lab = self.apply_manual_edits_to_lab_if_needed( - labels - ) + posData.lab = self.apply_manual_edits_to_lab_if_needed(labels) posData.rp = skimage.measure.regionprops(posData.lab) self.setManualBackgroundLab() - + if posData.acdc_df is not None: frames = posData.acdc_df.index.get_level_values(0) if posData.frame_i in frames: # Since there was already segmentation metadata from # previous closed session add it to current metadata df = posData.acdc_df.loc[posData.frame_i].copy() - binnedIDs_df = df[df['is_cell_excluded']>0] + binnedIDs_df = df[df["is_cell_excluded"] > 0] binnedIDs = set(binnedIDs_df.index).union(posData.binnedIDs) posData.binnedIDs = binnedIDs - ripIDs_df = df[df['is_cell_dead']>0] + ripIDs_df = df[df["is_cell_dead"] > 0] ripIDs = set(ripIDs_df.index).union(posData.ripIDs) posData.ripIDs = ripIDs posData.editID_info.extend(self._get_editID_info(df)) # Load cca df into current metadata - if 'cell_cycle_stage' in df.columns: + if "cell_cycle_stage" in df.columns: cca_cols = df.columns.intersection(self.cca_df_colnames) cca_df = df[cca_cols].dropna() if cca_df.empty: - df = df.drop( - columns=self.cca_df_colnames, errors='ignore' - ) + df = df.drop(columns=self.cca_df_colnames, errors="ignore") else: df = df.loc[cca_df.index] cols = self.cca_df_int_cols - df[cols] = df[cols].astype('Int64') - + df[cols] = df[cols].astype("Int64") + i = posData.frame_i - posData.allData_li[i]['acdc_df'] = df.copy() - + posData.allData_li[i]["acdc_df"] = df.copy() + if self.lineage_tree is None and lin_tree_init: self.initLinTree() - + self.get_cca_df() - + return proceed_cca, never_visited - def _get_data_visited(self, posData, debug=False, lin_tree_init=True,): + def _get_data_visited( + self, + posData, + debug=False, + lin_tree_init=True, + ): # Requested frame was already visited. Load from RAM. never_visited = False posData.lab = self.get_labels(from_store=True) posData.rp = skimage.measure.regionprops(posData.lab) - df = posData.allData_li[posData.frame_i]['acdc_df'] + df = posData.allData_li[posData.frame_i]["acdc_df"] if df is None: posData.binnedIDs = set() posData.ripIDs = set() posData.editID_info = [] else: try: - binnedIDs_df = df[df['is_cell_excluded']>0] + binnedIDs_df = df[df["is_cell_excluded"] > 0] except Exception as err: df = myutils.fix_acdc_df_dtypes(df) - binnedIDs_df = df[df['is_cell_excluded']>0] + binnedIDs_df = df[df["is_cell_excluded"] > 0] posData.binnedIDs = set(binnedIDs_df.index) - ripIDs_df = df[df['is_cell_dead']>0] + ripIDs_df = df[df["is_cell_dead"] > 0] posData.ripIDs = set(ripIDs_df.index) posData.editID_info = self._get_editID_info(df) self.setManualBackgroundLab(load_from_store=True, debug=debug) if self.lineage_tree is None and lin_tree_init: self.initLinTree() - + self.get_cca_df(debug=debug) return True, never_visited @@ -140,7 +147,7 @@ def addPathToOpenRecentMenu(self, path): else: action = QAction(path, self) action.triggered.connect(partial(self.openRecentFile, path)) - + try: firstAction = self.openRecentMenu.actions()[0] self.openRecentMenu.insertAction(firstAction, action) @@ -151,7 +158,7 @@ def getStoredSegmData(self): posData = self.data[self.pos_i] segm_data = [] for data_frame_i in posData.allData_li: - lab = data_frame_i['labels'] + lab = data_frame_i["labels"] if lab is None: break segm_data.append(lab) @@ -163,7 +170,7 @@ def get_data(self, debug=False, lin_tree_init=True): never_visited = False if posData.frame_i > 2: # Remove undo states from 4 frames back to avoid memory issues - posData.UndoRedoStates[posData.frame_i-4] = [] + posData.UndoRedoStates[posData.frame_i - 4] = [] # Check if current frame contains undo states (not empty list) if posData.UndoRedoStates[posData.frame_i]: self.undoAction.setDisabled(False) @@ -173,9 +180,10 @@ def get_data(self, debug=False, lin_tree_init=True): self.undoAction.setDisabled(True) self.UndoCount = 0 # If stored labels is None then it is the first time we visit this frame - if posData.allData_li[posData.frame_i]['labels'] is None: - proceed_cca, never_visited = self._get_data_unvisited( - posData, lin_tree_init=lin_tree_init, + if posData.allData_li[posData.frame_i]["labels"] is None: + proceed_cca, never_visited = self._get_data_unvisited( + posData, + lin_tree_init=lin_tree_init, ) if not proceed_cca: return proceed_cca, never_visited @@ -183,35 +191,31 @@ def get_data(self, debug=False, lin_tree_init=True): proceed_cca, never_visited = self._get_data_visited( posData, lin_tree_init=lin_tree_init, debug=debug ) - + self.update_rp_metadata(draw=False) posData.IDs = [obj.label for obj in posData.rp] posData.IDs_idxs = { - ID:i for ID, i in zip(posData.IDs, range(len(posData.IDs))) + ID: i for ID, i in zip(posData.IDs, range(len(posData.IDs))) } self.get_zslices_rp() self.pointsLayerDfsToData(posData) return proceed_cca, never_visited def get_labels( - self, - from_store=False, - frame_i=None, - return_existing=False, - return_copy=True - ): + self, from_store=False, frame_i=None, return_existing=False, return_copy=True + ): """Get the labels array. - + Parameters ---------- from_store : bool, optional - If True load the labels array from the stored posData.allData_li, + If True load the labels array from the stored posData.allData_li, i.e., from RAM. Default is False frame_i : int, optional If None, use the current frame index. Default is None return_existing : bool, optional - If True, the second return element will be a boolean that - is True if the labels array was found stored in `posData.allData_li`. + If True, the second return element will be a boolean that + is True if the labels array was found stored in `posData.allData_li`. Default is False return_copy : bool, optional If True returns a copy of the labels array @@ -219,31 +223,31 @@ def get_labels( Returns ------- numpy.ndarray or tuple of (numpy.ndarray, bool) - The first element is the labels array requested. If `return_existing` - is True then this method also returns a second boolean element that + The first element is the labels array requested. If `return_existing` + is True then this method also returns a second boolean element that is True if the labels array was found in in `posData.allData_li`. - + Note ---- - - If `from_store` is True then this method will try to get the stored - labels array. If any error occurs then the returned labels are the + + If `from_store` is True then this method will try to get the stored + labels array. If any error occurs then the returned labels are the saved ones in the segmentation file (i.e., from hard drive). - - """ + + """ posData = self.data[self.pos_i] if frame_i is None: frame_i = posData.frame_i - + existing = True if from_store: try: - labels = posData.allData_li[frame_i]['labels'] + labels = posData.allData_li[frame_i]["labels"] if labels is None: from_store = False except Exception as err: from_store = False - + if not from_store: try: labels = posData.segm_data[frame_i] @@ -256,10 +260,10 @@ def get_labels( shape = (posData.SizeY, posData.SizeX) labels = np.zeros(shape, dtype=np.uint32) return_copy = False - + if return_copy: labels = labels.copy() - + if return_existing: return labels, existing else: @@ -298,15 +302,17 @@ def initPosAttr(self): posData.doNotShowAgain_keepID = False posData.UndoFutFrames_keepID = False posData.applyFutFrames_keepID = False - + posData.doNotShowAgainAssignNewID = False posData.UndoFutFramesAssignNewID = False posData.applyFutFramesAssignNewID = False posData.includeUnvisitedInfo = { - 'Delete ID': False, 'Edit ID': False, 'Keep ID': False + "Delete ID": False, + "Edit ID": False, + "Keep ID": False, } - + posData.loadTrackedLostCentroids() posData.acdcTracker2stepsAnnotInfo = {} @@ -324,17 +330,15 @@ def initPosAttr(self): posData.ol_data_dict = {} posData.ol_data = None - posData.ol_labels_data = None - + posData.ol_labels_data = None + missing_frames = posData.SizeT - len(posData.allData_li) if missing_frames > 0: posData.allData_li.extend([None] * missing_frames) for i in range(posData.SizeT): if posData.allData_li[i] is None: - posData.allData_li[i] = ( - myutils.get_empty_stored_data_dict() - ) - + posData.allData_li[i] = myutils.get_empty_stored_data_dict() + posData.lutLevels = {channel: {} for channel in self.ch_names} posData.ccaStatus_whenEmerged = {} @@ -345,7 +349,7 @@ def initPosAttr(self): posData.ripIDs = set() posData.cca_df = None if posData.last_tracked_i is not None: - last_tracked_num = posData.last_tracked_i+1 + last_tracked_num = posData.last_tracked_i + 1 # Load previous session data # Keep track of which ROIs have already been added # in previous frame @@ -358,30 +362,30 @@ def initPosAttr(self): ) # Ask whether to resume from last frame - if last_tracked_num>1: + if last_tracked_num > 1: msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Cell-ACDC detected a previous session ended ' - f'at frame {last_tracked_num}.

    ' - f'Do you want to resume from frame ' - f'{last_tracked_num}?' + "Cell-ACDC detected a previous session ended " + f"at frame {last_tracked_num}.

    " + f"Do you want to resume from frame " + f"{last_tracked_num}?" ) noButton, yesButton = msg.question( - self, 'Start from last session?', txt, - buttonsTexts=(' No ', 'Yes') + self, + "Start from last session?", + txt, + buttonsTexts=(" No ", "Yes"), ) self.AutoPilotProfile.storeClickMessageBox( - 'Start from last session?', msg.clickedButton.text() + "Start from last session?", msg.clickedButton.text() ) if msg.clickedButton == yesButton: posData.frame_i = posData.last_tracked_i self.lastFrameRanOnFirstVisitTools = posData.frame_i else: posData.frame_i = 0 - - posData.img_data_min_max = ( - posData.img_data.min(), posData.img_data.max() - ) + + posData.img_data_min_max = (posData.img_data.min(), posData.img_data.max()) # Back to first position self.pos_i = 0 @@ -398,73 +402,71 @@ def initPosAttr(self): def loadLastSessionSettings(self): self.settings_csv_path = settings_csv_path if os.path.exists(settings_csv_path): - self.df_settings = pd.read_csv( - settings_csv_path, index_col='setting' - ) - if 'is_bw_inverted' not in self.df_settings.index: - self.df_settings.at['is_bw_inverted', 'value'] = 'No' + self.df_settings = pd.read_csv(settings_csv_path, index_col="setting") + if "is_bw_inverted" not in self.df_settings.index: + self.df_settings.at["is_bw_inverted", "value"] = "No" else: - self.df_settings.loc['is_bw_inverted'] = ( - self.df_settings.loc['is_bw_inverted'].astype(str) - ) - if 'fontSize' not in self.df_settings.index: - self.df_settings.at['fontSize', 'value'] = 12 - if 'overlayColor' not in self.df_settings.index: - self.df_settings.at['overlayColor', 'value'] = '255-255-0' - if 'how_normIntensities' not in self.df_settings.index: - raw = 'Do not normalize. Display raw image' - self.df_settings.at['how_normIntensities', 'value'] = raw + self.df_settings.loc["is_bw_inverted"] = self.df_settings.loc[ + "is_bw_inverted" + ].astype(str) + if "fontSize" not in self.df_settings.index: + self.df_settings.at["fontSize", "value"] = 12 + if "overlayColor" not in self.df_settings.index: + self.df_settings.at["overlayColor", "value"] = "255-255-0" + if "how_normIntensities" not in self.df_settings.index: + raw = "Do not normalize. Display raw image" + self.df_settings.at["how_normIntensities", "value"] = raw else: - idx = ['is_bw_inverted', 'fontSize', 'overlayColor', 'how_normIntensities'] - values = ['No', 12, '255-255-0', 'raw'] - self.df_settings = pd.DataFrame({ - 'setting': idx,'value': values} - ).set_index('setting') - - if 'isLabelsVisible' not in self.df_settings.index: - self.df_settings.at['isLabelsVisible', 'value'] = 'No' - - if 'isNextFrameVisible' not in self.df_settings.index: - self.df_settings.at['isNextFrameVisible', 'value'] = 'No' - - if 'isRightImageVisible' not in self.df_settings.index: - self.df_settings.at['isRightImageVisible', 'value'] = 'Yes' - - if 'manual_separate_draw_mode' not in self.df_settings.index: - col = 'manual_separate_draw_mode' - self.df_settings.at[col, 'value'] = 'threepoints_arc' - - if 'colorScheme' in self.df_settings.index: - col = 'colorScheme' - self._colorScheme = self.df_settings.at[col, 'value'] + idx = ["is_bw_inverted", "fontSize", "overlayColor", "how_normIntensities"] + values = ["No", 12, "255-255-0", "raw"] + self.df_settings = pd.DataFrame( + {"setting": idx, "value": values} + ).set_index("setting") + + if "isLabelsVisible" not in self.df_settings.index: + self.df_settings.at["isLabelsVisible", "value"] = "No" + + if "isNextFrameVisible" not in self.df_settings.index: + self.df_settings.at["isNextFrameVisible", "value"] = "No" + + if "isRightImageVisible" not in self.df_settings.index: + self.df_settings.at["isRightImageVisible", "value"] = "Yes" + + if "manual_separate_draw_mode" not in self.df_settings.index: + col = "manual_separate_draw_mode" + self.df_settings.at[col, "value"] = "threepoints_arc" + + if "colorScheme" in self.df_settings.index: + col = "colorScheme" + self._colorScheme = self.df_settings.at[col, "value"] else: - self._colorScheme = 'light' - + self._colorScheme = "light" + self.doNotShowAgainMissingCca = False - if 'doNotShowAgainMissingCca' not in self.df_settings.index: - self.df_settings.at['doNotShowAgainMissingCca', 'value'] = 'No' + if "doNotShowAgainMissingCca" not in self.df_settings.index: + self.df_settings.at["doNotShowAgainMissingCca", "value"] = "No" else: - val = self.df_settings.at['doNotShowAgainMissingCca', 'value'] - self.doNotShowAgainMissingCca = val=='Yes' + val = self.df_settings.at["doNotShowAgainMissingCca", "value"] + self.doNotShowAgainMissingCca = val == "Yes" def reInitGui(self): cancel = self.checkAskSavePointsLayers() if cancel: return False - + if self.overlayToolbar.isTransparent(): self.overlayToolbar.setTransparent(False) - + self.secondLevelToolbar.setVisible(False) - + self.gui_createLazyLoader() try: self.navSpinBox.valueChanged.disconnect() except Exception as e: pass - - try: + + try: self.scaleBar.removeFromAxis(self.ax1) except Exception as e: pass @@ -483,7 +485,7 @@ def reInitGui(self): self.showPropsDockButton.setDisabled(True) self.removeOverlayItems() self.lutItemsLayout.addItem(self.imgGrad, row=0, col=0) - + self.reinitWidgetsPos() self.removeAllItems() self.reinitCustomAnnot() @@ -495,30 +497,30 @@ def reInitGui(self): self.reinitStoredSegmModels() self.removeAxLimits() self.curvToolButton.setChecked(False) - + self.wandControlsToolbar.setVisible(False) self.wandToolButton.setChecked(False) self.segmNdimIndicatorAction.setVisible(False) - + self.navigateToolBar.hide() self.ccaToolBar.hide() self.editToolBar.hide() self.brushEraserToolBar.hide() self.modeToolBar.hide() - self.modeComboBox.setCurrentText('Viewer') - + self.modeComboBox.setCurrentText("Viewer") + alpha = self.imgGrad.labelsAlphaSlider.value() self.labelsLayerImg1.setOpacity(alpha) self.labelsLayerRightImg.setOpacity(alpha) - self.lastTrackedFrameLabel.setText('') - + self.lastTrackedFrameLabel.setText("") + self.promptSegmentPointsLayerToolbar.isPointsLayerInit = False - + for action in self.askHowFutureFramesActions.values(): action.setChecked(True) action.setDisabled(True) - + return True def readRecentPaths(self, recent_paths_path=None): @@ -527,19 +529,19 @@ def readRecentPaths(self, recent_paths_path=None): # Step 1. Read recent Paths if recent_paths_path is None: - recent_paths_path = recentPaths_path - + recent_paths_path = recentPaths_path + if os.path.exists(recent_paths_path): - df = pd.read_csv(recent_paths_path, index_col='index') - df['path'] = df['path'].str.replace('\\', '/') - df = df.drop_duplicates(subset=['path']) + df = pd.read_csv(recent_paths_path, index_col="index") + df["path"] = df["path"].str.replace("\\", "/") + df = df.drop_duplicates(subset=["path"]) df.to_csv(recent_paths_path) - if 'opened_last_on' in df.columns: - df = df.sort_values('opened_last_on', ascending=False) - recentPaths = df['path'].to_list() + if "opened_last_on" in df.columns: + df = df.sort_values("opened_last_on", ascending=False) + recentPaths = df["path"].to_list() else: recentPaths = [] - + # Step 2. Dynamically create the actions actions = [] for path in recentPaths: @@ -556,9 +558,14 @@ def reinitWidgetsPos(self): pass def store_data( - self, pos_i=None, enforce=True, debug=False, mainThread=True, - autosave=True, store_cca_df_copy=False - ): + self, + pos_i=None, + enforce=True, + debug=False, + mainThread=True, + autosave=True, + store_cca_df_copy=False, + ): pos_i = self.pos_i if pos_i is None else pos_i posData = self.data[pos_i] if posData.frame_i < 0: @@ -569,38 +576,32 @@ def store_data( mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer' and not enforce: + if mode == "Viewer" and not enforce: return # if not mainThread: # self.lin_tree_ask_changes() - + allData_li = posData.allData_li[posData.frame_i] - allData_li['regionprops'] = posData.rp.copy() - allData_li['labels'] = posData.lab.copy() - allData_li['IDs'] = posData.IDs.copy() - allData_li['manualBackgroundLab'] = ( - posData.manualBackgroundLab - ) - allData_li['IDs_idxs'] = ( - posData.IDs_idxs.copy() - ) + allData_li["regionprops"] = posData.rp.copy() + allData_li["labels"] = posData.lab.copy() + allData_li["IDs"] = posData.IDs.copy() + allData_li["manualBackgroundLab"] = posData.manualBackgroundLab + allData_li["IDs_idxs"] = posData.IDs_idxs.copy() if self.manualAnnotPastButton.isChecked(): - self.store_manual_annot_data( - posData=posData, data_frame_i=allData_li - ) - + self.store_manual_annot_data(posData=posData, data_frame_i=allData_li) + self.store_zslices_rp() # Store dynamic metadata - is_cell_dead_li = [False]*len(posData.rp) - is_cell_excluded_li = [False]*len(posData.rp) - IDs = [0]*len(posData.rp) - xx_centroid = [0]*len(posData.rp) - yy_centroid = [0]*len(posData.rp) + is_cell_dead_li = [False] * len(posData.rp) + is_cell_excluded_li = [False] * len(posData.rp) + IDs = [0] * len(posData.rp) + xx_centroid = [0] * len(posData.rp) + yy_centroid = [0] * len(posData.rp) if self.isSegm3D: - zz_centroid = [0]*len(posData.rp) - areManuallyEdited = [0]*len(posData.rp) + zz_centroid = [0] * len(posData.rp) + areManuallyEdited = [0] * len(posData.rp) editedNewIDs = [vals[2] for vals in posData.editID_info] for i, obj in enumerate(posData.rp): is_cell_dead_li[i] = obj.dead @@ -618,60 +619,58 @@ def store_data( posData.STOREDmaxID = max(IDs, default=0) - acdc_df = allData_li['acdc_df'] + acdc_df = allData_li["acdc_df"] if acdc_df is None: - allData_li['acdc_df'] = pd.DataFrame( + allData_li["acdc_df"] = pd.DataFrame( { - 'Cell_ID': IDs, - 'is_cell_dead': is_cell_dead_li, - 'is_cell_excluded': is_cell_excluded_li, - 'x_centroid': xx_centroid, - 'y_centroid': yy_centroid, - 'was_manually_edited': areManuallyEdited + "Cell_ID": IDs, + "is_cell_dead": is_cell_dead_li, + "is_cell_excluded": is_cell_excluded_li, + "x_centroid": xx_centroid, + "y_centroid": yy_centroid, + "was_manually_edited": areManuallyEdited, } - ).set_index('Cell_ID') - + ).set_index("Cell_ID") + if self.isSegm3D: - allData_li['acdc_df']['z_centroid'] = ( - zz_centroid - ) + allData_li["acdc_df"]["z_centroid"] = zz_centroid else: # Filter or add IDs that were not stored yet - acdc_df = acdc_df.drop(columns=['time_seconds'], errors='ignore') + acdc_df = acdc_df.drop(columns=["time_seconds"], errors="ignore") acdc_df = acdc_df.reindex(IDs, fill_value=0) - acdc_df['is_cell_dead'] = is_cell_dead_li - acdc_df['is_cell_excluded'] = is_cell_excluded_li - acdc_df['x_centroid'] = xx_centroid - acdc_df['y_centroid'] = yy_centroid + acdc_df["is_cell_dead"] = is_cell_dead_li + acdc_df["is_cell_excluded"] = is_cell_excluded_li + acdc_df["x_centroid"] = xx_centroid + acdc_df["y_centroid"] = yy_centroid if self.isSegm3D: - acdc_df['z_centroid'] = zz_centroid - acdc_df['was_manually_edited'] = areManuallyEdited - allData_li['acdc_df'] = acdc_df + acdc_df["z_centroid"] = zz_centroid + acdc_df["was_manually_edited"] = areManuallyEdited + allData_li["acdc_df"] = acdc_df if mainThread: self.pointsLayerDataToDf(posData) - + self.store_cca_df( - pos_i=pos_i, mainThread=mainThread, autosave=autosave, - store_cca_df_copy=store_cca_df_copy + pos_i=pos_i, + mainThread=mainThread, + autosave=autosave, + store_cca_df_copy=store_cca_df_copy, ) - def store_manual_annot_data( - self, posData=None, data_frame_i=None - ): + def store_manual_annot_data(self, posData=None, data_frame_i=None): if posData is None: posData = self.data[self.pos_i] - + if data_frame_i is None: data_frame_i = posData.allData_li[posData.frame_i] - + if not self.isSegm3D: lab = [posData.lab] else: lab = posData.lab - + for z, lab_2D in enumerate(lab): - data_frame_i['manually_edited_lab']['lab'][z] = lab_2D + data_frame_i["manually_edited_lab"]["lab"][z] = lab_2D def unstore_data(self): posData = self.data[self.pos_i] @@ -681,16 +680,16 @@ def updateLastVisitedFrame(self, last_visited_frame_i=None): if last_visited_frame_i is None: posData = self.data[self.pos_i] last_visited_frame_i = posData.frame_i - + mode = str(self.modeComboBox.currentText()) - if mode == 'Viewer': + if mode == "Viewer": return - elif mode == 'Segmentation and Tracking': + elif mode == "Segmentation and Tracking": posData = self.data[self.pos_i] if posData.last_tracked_i >= last_visited_frame_i: return posData.last_tracked_i = last_visited_frame_i - elif mode == 'Cell cycle analysis': + elif mode == "Cell cycle analysis": if self.last_cca_frame_i >= last_visited_frame_i: return self.last_cca_frame_i = last_visited_frame_i diff --git a/cellacdc/mixins/status_hover.py b/cellacdc/mixins/status_hover.py index bbf9c1437..74ab6947a 100644 --- a/cellacdc/mixins/status_hover.py +++ b/cellacdc/mixins/status_hover.py @@ -9,6 +9,7 @@ from .image_display import ImageDisplay + class StatusHover(ImageDisplay): """Extracted from guiWin.""" @@ -16,21 +17,21 @@ def _addOverlayHoverValuesFormatted(self, txt, xdata, ydata): posData = self.data[self.pos_i] if posData.ol_data is None: return txt - + for filename in posData.ol_data: chName = myutils.get_chname_from_basename( filename, posData.basename, remove_ext=False ) if chName not in self.checkedOverlayChannels: continue - + raw_overlay_img = self.getRawImage(filename=filename) raw_overlay_value = raw_overlay_img[ydata, xdata] # raw_overlay_max_value = raw_overlay_img.max() - raw_txt = self._channelHoverValues('Raw', chName, raw_overlay_value) + raw_txt = self._channelHoverValues("Raw", chName, raw_overlay_value) - txt = f'{txt} | {raw_txt}' + txt = f"{txt} | {raw_txt}" return txt def _addRulerMeasurementText(self, txt): @@ -39,28 +40,24 @@ def _addRulerMeasurementText(self, txt): if xx is None: return txt - lenPxl = math.sqrt((xx[0]-xx[1])**2 + (yy[0]-yy[1])**2) + lenPxl = math.sqrt((xx[0] - xx[1]) ** 2 + (yy[0] - yy[1]) ** 2) depthAxes = self.switchPlaneCombobox.depthAxes() - if depthAxes != 'z': + if depthAxes != "z": pxlToUm = posData.PhysicalSizeZ else: pxlToUm = posData.PhysicalSizeX - - length_txt = ( - f'length = {int(lenPxl)} pxl ({lenPxl*pxlToUm:.2f} μm)' - ) - txt = f'{txt} | Measurement: {length_txt}' + + length_txt = f"length = {int(lenPxl)} pxl ({lenPxl * pxlToUm:.2f} μm)" + txt = f"{txt} | Measurement: {length_txt}" return txt def _channelHoverValues(self, descr, channel, value, ff=None): if ff is None: n_digits = len(str(int(value))) ff = myutils.get_number_fstring_formatter( - type(value), precision=abs(n_digits-5) + type(value), precision=abs(n_digits - 5) ) - txt = ( - f'{descr} {channel}: value={value:{ff}}' - ) + txt = f"{descr} {channel}: value={value:{ff}}" return txt def getActiveToolButton(self): @@ -74,29 +71,29 @@ def updateValuesStatusBar(self): H = round(yb - yt) txt = self.wcLabel.text() pattern = ( - r'W=.*?, H=.*? \| ' - r'x_left=.*?, y_top=.*? \| ' - r'x_right=.*?, y_bottom=.*? \| ' + r"W=.*?, H=.*? \| " + r"x_left=.*?, y_top=.*? \| " + r"x_right=.*?, y_bottom=.*? \| " ) replacing = ( - f'W={W:d}, H={H:d} | ' - f'x_left={xl:d}, y_top={yt:d} | ' - f'x_right={xr:d}, y_bottom={yb:d} | ' + f"W={W:d}, H={H:d} | " + f"x_left={xl:d}, y_top={yt:d} | " + f"x_right={xr:d}, y_bottom={yb:d} | " ) txt = re.sub(pattern, replacing, txt) self.wcLabel.setText(txt) - def hoverValuesFormatted(self, xdata, ydata, activeToolButton, is_ax0): + def hoverValuesFormatted(self, xdata, ydata, activeToolButton, is_ax0): (xl, xr), (yt, yb) = self.ax1ViewRange(integers=True) W = round(xr - xl) H = round(yb - yt) ax_idx = 0 if is_ax0 else 1 txt = ( - f'x={xdata:d}, y={ydata:d} | ' - f'W={W:d}, H={H:d} | ' - f'x_left={xl:d}, y_top={yt:d} | ' - f'x_right={xr:d}, y_bottom={yb:d} | ' - f'(ax{ax_idx})' + f"x={xdata:d}, y={ydata:d} | " + f"W={W:d}, H={H:d} | " + f"x_left={xl:d}, y_top={yt:d} | " + f"x_right={xr:d}, y_bottom={yb:d} | " + f"(ax{ax_idx})" ) if activeToolButton == self.rulerButton: txt = self._addRulerMeasurementText(txt) @@ -111,21 +108,20 @@ def hoverValuesFormatted(self, xdata, ydata, activeToolButton, is_ax0): # raw_max_value = raw_img.max() ch = self.user_ch_name - raw_txt = self._channelHoverValues('Raw', ch, raw_value) + raw_txt = self._channelHoverValues("Raw", ch, raw_value) - txt = f'{txt} | {raw_txt}' + txt = f"{txt} | {raw_txt}" txt = self._addOverlayHoverValuesFormatted(txt, xdata, ydata) - + ID = self.currentLab2D[ydata, xdata] maxID = max(posData.IDs, default=0) num_obj = len(posData.IDs) lab_txt = ( - f'Objects: ID={ID}, max ID={maxID}, ' - f'num. of objects={num_obj}' + f"Objects: ID={ID}, max ID={maxID}, num. of objects={num_obj}" ) - txt = f'{txt} | {lab_txt}' + txt = f"{txt} | {lab_txt}" txt = self._addRulerMeasurementText(txt) return txt @@ -133,14 +129,14 @@ def hoverValuesFormatted(self, xdata, ydata, activeToolButton, is_ax0): def setStatusBarLabel(self, log=True): self.statusbar.clearMessage() posData = self.data[self.pos_i] - segmentedChannelname = posData.filename[len(posData.basename):] + segmentedChannelname = posData.filename[len(posData.basename) :] segmFilename = os.path.basename(posData.segm_npz_path) - segmEndName = segmFilename[len(posData.basename):] + segmEndName = segmFilename[len(posData.basename) :] txt = ( - f'{posData.pos_foldername} || ' - f'Basename: {posData.basename} || ' - f'Segmented channel: {segmentedChannelname} || ' - f'Segmentation file name: {segmEndName}' + f"{posData.pos_foldername} || " + f"Basename: {posData.basename} || " + f"Segmented channel: {segmentedChannelname} || " + f"Segmentation file name: {segmEndName}" ) mode = str(self.modeComboBox.currentText()) if log: @@ -149,6 +145,6 @@ def setStatusBarLabel(self, log=True): def getRulerLengthText(self): text = self.wcLabel.text() - lengthText = re.findall(r'length = (.*)\)', text)[0] - lengthText = lengthText.replace('pxl', 'pixels') - return f'{lengthText})' + lengthText = re.findall(r"length = (.*)\)", text)[0] + lengthText = lengthText.replace("pxl", "pixels") + return f"{lengthText})" diff --git a/cellacdc/mixins/tool_activation.py b/cellacdc/mixins/tool_activation.py index cb1394e02..d0c3f3f1a 100644 --- a/cellacdc/mixins/tool_activation.py +++ b/cellacdc/mixins/tool_activation.py @@ -10,6 +10,7 @@ from .session import Session + class ToolActivation(Session): """Extracted from guiWin.""" @@ -27,14 +28,18 @@ def _copyAllLostObjects_navigateToFrame(self, frame_i): self.lostObjContoursImage[:] = 0 self.lostObjImage[:] = 0 - prev_rp = posData.allData_li[frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[frame_i-1]['IDs_idxs'] # need to change this when merging with opt. + prev_rp = posData.allData_li[frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[frame_i - 1][ + "IDs_idxs" + ] # need to change this when merging with opt. for lostID in posData.lost_IDs: obj = prev_rp[prev_IDs_idxs[lostID]] self.addLostObjsToLostObjImage(obj, lostID, force=True) def _copyAllLostObjects_refreshRp(self): - self.update_rp(draw=False, wl_update=False) # need to change this when merging with opt. + self.update_rp( + draw=False, wl_update=False + ) # need to change this when merging with opt. def _copyAllLostObjects_returnToFrame(self, frame_i): posData = self.data[self.pos_i] @@ -46,7 +51,7 @@ def addLostObjsToLostObjImage(self, lostObj, lostID, force=False): if not force: if not self.copyLostObjButton.isChecked(): return - + obj_slice = self.getObjSlice(lostObj.slice) obj_image = self.getObjImage(lostObj.image, lostObj.bbox) self.lostObjImage[obj_slice][obj_image] = lostID @@ -57,18 +62,16 @@ def annotLostObjsToggled(self, checked): self.updateAllImages() def clearTempBrushImage(self, forceClearLinked=True): - if not hasattr(self, 'tempLayerImg1'): + if not hasattr(self, "tempLayerImg1"): return - - self.tempLayerImg1.setImage( - self.emptyLab, force_set_linked=forceClearLinked - ) - + + self.tempLayerImg1.setImage(self.emptyLab, force_set_linked=forceClearLinked) + try: self.brushContourImage[:] = 0 except Exception as err: pass - + try: self.brushImage[:] = 0 except Exception as err: @@ -93,13 +96,11 @@ def connectLeftClickButtons(self): def connectLeftClickButtonsPointsLayersToolbar(self): for toolbar in self.pointsLayersToolbars: for action in toolbar.actions()[1:]: - if not hasattr(action, 'layerTypeIdx'): + if not hasattr(action, "layerTypeIdx"): continue if action.layerTypeIdx != 4: continue - action.button.toggled.connect( - self.addPointsByClickingButtonToggled - ) + action.button.toggled.connect(self.addPointsByClickingButtonToggled) def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): if not self.copyLostObjButton.isChecked(): @@ -107,12 +108,12 @@ def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): posData = self.data[self.pos_i] - desc = 'Copying all lost objects...' + desc = "Copying all lost objects..." self.progressWin = apps.QDialogWorkerProgress( title=desc, parent=self.mainWin, pbarDesc=desc ) - self.progressWin.mainPbar.setMaximum(for_future_frame_n+1) + self.progressWin.mainPbar.setMaximum(for_future_frame_n + 1) self.progressWin.show(self.app) self.copyAllLostObjectsThread = QThread() @@ -123,24 +124,18 @@ def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): self.copyAllLostObjectsWorker.moveToThread(self.copyAllLostObjectsThread) self.copyAllLostObjectsWorker.navigateToFrame.connect( - self._copyAllLostObjects_navigateToFrame, - Qt.BlockingQueuedConnection + self._copyAllLostObjects_navigateToFrame, Qt.BlockingQueuedConnection ) self.copyAllLostObjectsWorker.returnToFrame.connect( - self._copyAllLostObjects_returnToFrame, - Qt.BlockingQueuedConnection + self._copyAllLostObjects_returnToFrame, Qt.BlockingQueuedConnection ) self.copyAllLostObjectsWorker.copyLostObjectMask.connect( - self.copyLostObjectMask, - Qt.BlockingQueuedConnection + self.copyLostObjectMask, Qt.BlockingQueuedConnection ) self.copyAllLostObjectsWorker.refreshRp.connect( - self._copyAllLostObjects_refreshRp, - Qt.BlockingQueuedConnection - ) - self.copyAllLostObjectsWorker.progressBar.connect( - self.workerUpdateProgressbar + self._copyAllLostObjects_refreshRp, Qt.BlockingQueuedConnection ) + self.copyAllLostObjectsWorker.progressBar.connect(self.workerUpdateProgressbar) self.copyAllLostObjectsWorker.critical.connect( self.copyAllLostObjectsWorkerCritical ) @@ -157,9 +152,7 @@ def copyAllLostObjects(self, for_future_frame_n, max_overlap_perc): self.copyAllLostObjectsWorkerFinished ) - self.copyAllLostObjectsThread.started.connect( - self.copyAllLostObjectsWorker.run - ) + self.copyAllLostObjectsThread.started.connect(self.copyAllLostObjectsWorker.run) self.copyAllLostObjectsThread.start() self.copyAllLostObjectsWorkerLoop = QEventLoop() @@ -175,17 +168,16 @@ def copyAllLostObjectsWorkerFinished(self, output): self.progressWin.close() self.progressWin = None - if output.get('doReinitLastSegmFrame', False): + if output.get("doReinitLastSegmFrame", False): self.reInitLastSegmFrame( - from_frame_i=output.get('last_visited_frame_i'), + from_frame_i=output.get("last_visited_frame_i"), updateImages=False, - force=True + force=True, ) - if output.get('overlap_warning', False): + if output.get("overlap_warning", False): self.blinker = qutils.QControlBlink( - self.copyLostObjToolbar.maxOverlapNumberControl, - qparent=self.mainWin + self.copyLostObjToolbar.maxOverlapNumberControl, qparent=self.mainWin ) self.blinker.start() @@ -196,11 +188,11 @@ def copyAllLostObjectsWorkerFinished(self, output): def copyLostObjContour_cb(self, checked): self.copyLostObjToolbar.setVisible(checked) - + self.ax1_lostObjScatterItem.hoverLostID = 0 if not checked: return - + self.lostObjImage = np.zeros_like(self.currentLab2D) self.updateLostContoursImage(0) @@ -214,19 +206,19 @@ def copyLostObjectMask(self, ID: int): def disableNonFunctionalButtons(self): if not self.isSegm3D: - return + return for item in self.functionsNotTested3D: - if hasattr(item, 'action'): + if hasattr(item, "action"): toolButton = item action = toolButton.action toolButton.setDisabled(True) - elif hasattr(item, 'toolbar'): + elif hasattr(item, "toolbar"): toolbar = item.toolbar action = item toolButton = toolbar.widgetForAction(action) - toolButton.setDisabled(True) - else: + toolButton.setDisabled(True) + else: action = item action.setDisabled(True) @@ -242,21 +234,19 @@ def getPrevFrameIDs(self, current_frame_i=None): posData = self.data[self.pos_i] if current_frame_i is None: current_frame_i = posData.frame_i - + if current_frame_i is None: return [] - + prev_frame_i = current_frame_i - 1 - prevIDs = posData.allData_li[prev_frame_i]['IDs'] - + prevIDs = posData.allData_li[prev_frame_i]["IDs"] + if prevIDs: return prevIDs - + # IDs in previous frame were not stored --> load prev lab from HDD prev_lab = self.get_labels( - from_store=False, - frame_i=prev_frame_i, - return_copy=False + from_store=False, frame_i=prev_frame_i, return_copy=False ) rp = skimage.measure.regionprops(prev_lab) prevIDs = [obj.label for obj in rp] @@ -276,9 +266,9 @@ def hideItemsHoverBrush(self, xy=None, ID=None, force=False): if not self.brushAutoHideCheckbox.isChecked() and not force: return - + posData = self.data[self.pos_i] - size = self.brushSizeSpinbox.value()*2 + size = self.brushSizeSpinbox.value() * 2 if xy is not None: ID = self.get_2Dlab(posData.lab)[ydata, xdata] @@ -288,13 +278,13 @@ def hideItemsHoverBrush(self, xy=None, ID=None, force=False): if self.ax1_lostTrackedScatterItem.isVisible(): self.ax1_lostTrackedScatterItem.setVisible(False) - + if self.ax2_lostObjScatterItem.isVisible(): self.ax2_lostObjScatterItem.setVisible(False) if self.ax2_lostTrackedScatterItem.isVisible(): self.ax2_lostTrackedScatterItem.setVisible(False) - + # Restore ID previously hovered if ID != self.ax1BrushHoverID and not self.isMouseDragImg1: try: @@ -315,13 +305,13 @@ def highlightHoverLostObj(self, modifiers, event): noModifier = modifiers == Qt.NoModifier if not noModifier: return - + if not self.copyLostObjButton.isChecked(): return - + if event.isExit(): return - + posData = self.data[self.pos_i] x, y = event.pos() xdata, ydata = int(x), int(y) @@ -329,42 +319,42 @@ def highlightHoverLostObj(self, modifiers, event): hoverLostID = self.lostObjImage[ydata, xdata] except IndexError: return - - self.ax1_lostObjScatterItem.hoverLostID = hoverLostID + + self.ax1_lostObjScatterItem.hoverLostID = hoverLostID if hoverLostID == 0: - self.ax1_lostObjScatterItem.setSize(self.contLineWeight+1) + self.ax1_lostObjScatterItem.setSize(self.contLineWeight + 1) self.ax1_lostObjScatterItem.setData([], []) else: - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - prev_IDs_idxs = posData.allData_li[posData.frame_i-1]['IDs_idxs'] + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + prev_IDs_idxs = posData.allData_li[posData.frame_i - 1]["IDs_idxs"] lostObj = prev_rp[prev_IDs_idxs[hoverLostID]] obj_contours = self.getObjContours(lostObj, all_external=True) for cont in obj_contours: - xx = cont[:,0] - yy = cont[:,1] + xx = cont[:, 0] + yy = cont[:, 1] self.ax1_lostObjScatterItem.addPoints(xx, yy) - self.ax1_lostObjScatterItem.setSize(self.contLineWeight+2) + self.ax1_lostObjScatterItem.setSize(self.contLineWeight + 2) def highlightLostNew(self): - if self.modeComboBox.currentText() == 'Viewer': + if self.modeComboBox.currentText() == "Viewer": return - + posData = self.data[self.pos_i] delROIsIDs = self.getDelRoisIDs() - + # self.setAllContoursImages(delROIsIDs=delROIsIDs) if posData.frame_i == 0: - return + return if not self.annotLostObjsToggle.isChecked(): return - - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] - + + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] + if prev_rp is None: return - self.setAllLostObjContoursImage(delROIsIDs=delROIsIDs) + self.setAllLostObjContoursImage(delROIsIDs=delROIsIDs) self.setAllLostTrackedObjContoursImage(delROIsIDs=delROIsIDs) def highlightManualAnnotMode(self, viewBox, viewRange): @@ -391,28 +381,27 @@ def manualAnnotPast_cb(self, checked): if checked: for _ in range(3): self.onEscape( - buttonsToNotUncheck=[self.manualAnnotPastButton], - doAutoRange=False + buttonsToNotUncheck=[self.manualAnnotPastButton], doAutoRange=False ) self.brushButton.setChecked(True) self.store_data() self.manualAnnotState = { - 'editID': self.editIDspinbox.value(), - 'isAutoID': self.autoIDcheckbox.isChecked(), - 'doWarnLostObj': self.warnLostCellsAction.isChecked(), + "editID": self.editIDspinbox.value(), + "isAutoID": self.autoIDcheckbox.isChecked(), + "doWarnLostObj": self.warnLostCellsAction.isChecked(), } self.autoIDcheckbox.setChecked(False) self.warnLostCellsAction.setChecked(False) hoverID = self.getLastHoveredID() if hoverID == 0: win = apps.QLineEditDialog( - title='Not hovering any ID', - msg='You are not hovering on any ID.\n' - 'Enter the ID that you want to lock.', - parent=self, + title="Not hovering any ID", + msg="You are not hovering on any ID.\n" + "Enter the ID that you want to lock.", + parent=self, isInteger=True, - defaultTxt=self.setBrushID(return_val=True) + defaultTxt=self.setBrushID(return_val=True), ) win.exec_() if win.cancel: @@ -420,44 +409,42 @@ def manualAnnotPast_cb(self, checked): return hoverID = win.EntryID self.logger.info( - 'Setting manual annotation for ID = ' - f'{hoverID}, at frame n. {posData.frame_i+1}' + "Setting manual annotation for ID = " + f"{hoverID}, at frame n. {posData.frame_i + 1}" ) self.editIDspinbox.setValue(hoverID) try: obj_idx = posData.IDs_idxs[hoverID] obj = posData.rp[obj_idx] - radius = 0.9 * obj.minor_axis_length / 2 # math.sqrt(obj.area/math.pi)*0.9 + radius = ( + 0.9 * obj.minor_axis_length / 2 + ) # math.sqrt(obj.area/math.pi)*0.9 self.brushSizeSpinbox.setValue(round(radius)) except Exception as err: pass - - self.manualAnnotState['frame_i_to_restore'] = posData.frame_i - self.manualAnnotState['last_tracked_i'] = ( - self.navigateScrollBar.maximum()-1 + + self.manualAnnotState["frame_i_to_restore"] = posData.frame_i + self.manualAnnotState["last_tracked_i"] = ( + self.navigateScrollBar.maximum() - 1 ) self.ax1.sigRangeChanged.connect(self.highlightManualAnnotMode) - self.ax1.setHighlighted(True, color='green') + self.ax1.setHighlighted(True, color="green") else: - self.setStatusBarLabel() - self.autoIDcheckbox.setChecked(self.manualAnnotState['isAutoID']) - self.editIDspinbox.setValue(self.manualAnnotState['editID']) - self.warnLostCellsAction.setChecked( - self.manualAnnotState['doWarnLostObj'] - ) - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + self.setStatusBarLabel() + self.autoIDcheckbox.setChecked(self.manualAnnotState["isAutoID"]) + self.editIDspinbox.setValue(self.manualAnnotState["editID"]) + self.warnLostCellsAction.setChecked(self.manualAnnotState["doWarnLostObj"]) + frame_to_restore = self.manualAnnotState.get("frame_i_to_restore") if frame_to_restore is None: - return - + return + self.store_data() self.store_manual_annot_data() - - last_tracked_i_to_restore = self.manualAnnotState['last_tracked_i'] + + last_tracked_i_to_restore = self.manualAnnotState["last_tracked_i"] self.manualAnnotRestoreLastTrackedFrame(last_tracked_i_to_restore) - - self.logger.info( - f'Restoring view to frame n. {posData.frame_i+1}...' - ) + + self.logger.info(f"Restoring view to frame n. {posData.frame_i + 1}...") posData.frame_i = frame_to_restore self.get_data() self.updateAllImages() @@ -465,18 +452,18 @@ def manualAnnotPast_cb(self, checked): self.ax1.sigRangeChanged.disconnect() self.ax1.setHighlighted(False) QTimer.singleShot(150, self.autoRange) - + self.setManualAnnotModeEnabledTools(checked) def onEscape( - self, - isTypingIDFunctionChecked=False, - buttonsToNotUncheck=None, - doAutoRange=True - ): + self, + isTypingIDFunctionChecked=False, + buttonsToNotUncheck=None, + doAutoRange=True, + ): if buttonsToNotUncheck is None: buttonsToNotUncheck = set() - + if self.keepIDsButton.isChecked() and self.keptObjectsIDs: self.keptObjectsIDs = widgets.KeptObjectIDsList( self.keptIDsLineEdit, self.keepIDsConfirmAction @@ -490,25 +477,25 @@ def onEscape( self.typingEditID = False QTimer.singleShot(300, self.autoRange) return - + if isTypingIDFunctionChecked and self.typingEditID: self.typingEditID = False QTimer.singleShot(300, self.autoRange) return - + if self.labelRoiButton.isChecked() and self.isMouseDragImg1: self.isMouseDragImg1 = False - self.labelRoiItem.setPos((0,0)) - self.labelRoiItem.setSize((0,0)) + self.labelRoiItem.setPos((0, 0)) + self.labelRoiItem.setSize((0, 0)) self.freeRoiItem.clear() QTimer.singleShot(300, self.autoRange) return - + if self.zoomRectButton.isChecked(): self.zoomRectCancelled() QTimer.singleShot(300, self.autoRange) return - + self.setUncheckedAllButtons(buttonsToNotUncheck=buttonsToNotUncheck) self.setUncheckedAllCustomAnnotButtons() self.setUncheckedPointsLayers() @@ -520,7 +507,7 @@ def onEscape( self.polyLineRoi.clearPoints() except Exception as e: pass - + if doAutoRange: QTimer.singleShot(11, self.autoRange) @@ -531,7 +518,7 @@ def restoreHoverObjBrush(self): obj = posData.rp[obj_idx] if not self.isObjVisible(obj.bbox): return - + self.addObjContourToContoursImage(obj=obj, ax=0) self.addObjContourToContoursImage(obj=obj, ax=1) @@ -542,19 +529,15 @@ def setLostNewOldPrevIDs(self): posData.new_IDs = [] posData.old_IDs = [] # posData.multiContIDs = set() - self.titleLabel.setText('Looking good!', color=self.titleColor) + self.titleLabel.setText("Looking good!", color=self.titleColor) return [] - + # elif self.modeComboBox.currentText() == 'Viewer': # pass - + out = self.updateLostNewCurrentIDs() - lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs = ( - out - ) - self.setTitleText( - lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs - ) + lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs = out + self.setTitleText(lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs) return curr_delRoiIDs def setManualAnnotModeEnabledTools(self, enabled): @@ -562,20 +545,20 @@ def setManualAnnotModeEnabledTools(self, enabled): toolButton = self.editToolBar.widgetForAction(action) if toolButton in self.manulAnnotToolButtons: continue - - toolButton.setDisabled(enabled) - action.setDisabled(enabled) + + toolButton.setDisabled(enabled) + action.setDisabled(enabled) def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): if not IDs: return htmlTxt_li, htmlTxtFull_li - + if isinstance(IDs, set): IDs = list(IDs) trim_IDs = myutils.get_trimmed_list(IDs) - txt = f'{pretxt}: {trim_IDs}' - txt_full = f'{pretxt}:
    {IDs}' + txt = f"{pretxt}: {trim_IDs}" + txt_full = f"{pretxt}:
    {IDs}" txt = f'{txt}' txt_full = f'{txt_full}' @@ -585,21 +568,17 @@ def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): return htmlTxt_li, htmlTxtFull_li - def setTitleText( - self, lost_IDs=None, new_IDs=None, IDs_with_holes=None, - tracked_lost_IDs=None - ): + def setTitleText( + self, lost_IDs=None, new_IDs=None, IDs_with_holes=None, tracked_lost_IDs=None + ): if self.manualAnnotPastButton.isChecked(): lockedID = self.editIDspinbox.value() - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') - txt = ( - f'Locked ID {lockedID} ' - f'since frame n. {frame_to_restore+1}' - ) + frame_to_restore = self.manualAnnotState.get("frame_i_to_restore") + txt = f"Locked ID {lockedID} since frame n. {frame_to_restore + 1}" htmlTxt = f'{txt}' self.titleLabel.setText(htmlTxt) return - + mode = self.modeComboBox.currentText() try: posData = self.data[self.pos_i] @@ -607,7 +586,7 @@ def setTitleText( prev_segmented = True except IndexError: prev_segmented = False - + if prev_segmented: htmlTxt_li = [] htmlTxtFull_li = [] @@ -616,42 +595,42 @@ def setTitleText( self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxt) return - - if mode != 'Normal division: Lineage tree': + + if mode != "Normal division: Lineage tree": htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'IDs lost', 'orange', lost_IDs + htmlTxt_li, htmlTxtFull_li, "IDs lost", "orange", lost_IDs ) htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'New IDs', 'red', new_IDs + htmlTxt_li, htmlTxtFull_li, "New IDs", "red", new_IDs ) htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'Acc. IDs lost', 'green', - tracked_lost_IDs + htmlTxt_li, htmlTxtFull_li, "Acc. IDs lost", "green", tracked_lost_IDs ) for i, htmlTxtFull in enumerate(htmlTxtFull_li): - htmlTxtFull_li[i] = htmlTxtFull.replace('Acc.', 'Accepted') + htmlTxtFull_li[i] = htmlTxtFull.replace("Acc.", "Accepted") htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'IDs with holes', 'red', - IDs_with_holes + htmlTxt_li, htmlTxtFull_li, "IDs with holes", "red", IDs_with_holes ) else: try: - cells_with_parent, orphan_cells, lost_cells = self.lineage_tree.export_lin_tree_info(posData.frame_i) + cells_with_parent, orphan_cells, lost_cells = ( + self.lineage_tree.export_lin_tree_info(posData.frame_i) + ) except IndexError or KeyError: - title = 'Processing lineage tree...' + title = "Processing lineage tree..." htmlTxt = f'{title}' self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxt) return except AttributeError: - title = 'Lineage tree still initializing...' + title = "Lineage tree still initializing..." htmlTxt = f'{title}' self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxt) return - + parent_cell_txt_raw = [] if cells_with_parent: # aggregate same parents @@ -661,30 +640,32 @@ def setTitleText( parent_cell_groups[parent] = [] parent_cell_groups[parent].append(cell) for parent, daughters in parent_cell_groups.items(): - cells_str = ','.join([str(daughter) for daughter in daughters]) - parent_cell_txt_raw.append(f'({parent}>{cells_str})') + cells_str = ",".join([str(daughter) for daughter in daughters]) + parent_cell_txt_raw.append(f"({parent}>{cells_str})") htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'New w/out mother', 'red', - orphan_cells + htmlTxt_li, htmlTxtFull_li, "New w/out mother", "red", orphan_cells ) htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'Lost', 'yellow', lost_cells + htmlTxt_li, htmlTxtFull_li, "Lost", "yellow", lost_cells ) htmlTxt_li, htmlTxtFull_li = self.setTitleFormatter( - htmlTxt_li, htmlTxtFull_li, 'Parent > Cell', 'green', - parent_cell_txt_raw + htmlTxt_li, + htmlTxtFull_li, + "Parent > Cell", + "green", + parent_cell_txt_raw, ) if not htmlTxt_li: - title = 'Looking good' + title = "Looking good" htmlTxt = f'{title}' self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxt) return - htmlTxt = ', '.join(htmlTxt_li) - htmlTxtFull = '
    '.join(htmlTxtFull_li) + htmlTxt = ", ".join(htmlTxt_li) + htmlTxtFull = "
    ".join(htmlTxtFull_li) self.titleLabel.setText(htmlTxt) self.titleLabel.setToolTip(htmlTxtFull) @@ -693,7 +674,7 @@ def setUncheckedAllButtons(self, buttonsToNotUncheck=None): self.clickedOnBud = False if buttonsToNotUncheck is None: buttonsToNotUncheck = set() - + try: self.BudMothTempLine.setData([], []) except Exception as e: @@ -702,7 +683,7 @@ def setUncheckedAllButtons(self, buttonsToNotUncheck=None): if button in buttonsToNotUncheck: continue button.setChecked(False) - + if self.countObjsButton not in buttonsToNotUncheck: self.countObjsButton.setChecked(False) self.splineHoverON = False @@ -722,7 +703,7 @@ def uncheckLeftClickButtons(self, sender): for button in self.LeftClickButtons: if button != sender: button.setChecked(False) - + if button != self.labelRoiButton: # self.labelRoiButton is disconnected so we manually call uncheck self.labelRoi_cb(False) @@ -735,8 +716,8 @@ def uncheckLeftClickButtons(self, sender): continue except: pass - toolbar.setVisible(False) - + toolbar.setVisible(False) + self.enableSizeSpinbox(False) if sender is not None: self.keepIDsButton.setChecked(False) @@ -758,57 +739,59 @@ def updateBrushCursor(self, x, y, isHoverImg1=True): if not (xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y): return - size = self.brushSizeSpinbox.value()*2 + size = self.brushSizeSpinbox.value() * 2 self.setHoverToolSymbolData( - [x], [y], self.activeBrushCircleCursors(isHoverImg1), - size=size + [x], [y], self.activeBrushCircleCursors(isHoverImg1), size=size ) self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, + xdata, + ydata, + self.ax2_BrushCirclePen, self.activeBrushCircleCursors(isHoverImg1), - self.brushButton, brush=self.ax2_BrushCircleBrush + self.brushButton, + brush=self.ax2_BrushCircleBrush, ) def updateHighlightedAxis(self): if not self.manualAnnotPastButton.isChecked(): return - - frame_to_restore = self.manualAnnotState.get('frame_i_to_restore') + + frame_to_restore = self.manualAnnotState.get("frame_i_to_restore") posData = self.data[self.pos_i] if posData.frame_i == frame_to_restore: - color = 'green' + color = "green" elif posData.frame_i < frame_to_restore: - color = 'gold' + color = "gold" else: - color = 'red' - + color = "red" + self.ax1.setHighlightingRectItemsColor(color) def updateLostNewCurrentIDs(self): posData = self.data[self.pos_i] - - prev_IDs = self.getPrevFrameIDs() + + prev_IDs = self.getPrevFrameIDs() tracked_lost_IDs = self.getTrackedLostIDs() curr_IDs = posData.IDs curr_delRoiIDs = self.getStoredDelRoiIDs() - prev_delRoiIDs = self.getStoredDelRoiIDs(frame_i=posData.frame_i-1) + prev_delRoiIDs = self.getStoredDelRoiIDs(frame_i=posData.frame_i - 1) lost_IDs = [ - ID for ID in prev_IDs if ID not in curr_IDs - and ID not in prev_delRoiIDs and ID not in tracked_lost_IDs + ID + for ID in prev_IDs + if ID not in curr_IDs + and ID not in prev_delRoiIDs + and ID not in tracked_lost_IDs ] new_IDs = [ - ID for ID in curr_IDs if ID not in prev_IDs - and ID not in curr_delRoiIDs + ID for ID in curr_IDs if ID not in prev_IDs and ID not in curr_delRoiIDs ] IDs_with_holes = [] posData.lost_IDs = lost_IDs posData.new_IDs = new_IDs posData.old_IDs = prev_IDs posData.IDs = curr_IDs - - out = ( - lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs - ) + + out = (lost_IDs, new_IDs, IDs_with_holes, tracked_lost_IDs, curr_delRoiIDs) return out def wand_cb(self, checked): diff --git a/cellacdc/mixins/tracking.py b/cellacdc/mixins/tracking.py index 6b366a8c2..817c4aeb0 100644 --- a/cellacdc/mixins/tracking.py +++ b/cellacdc/mixins/tracking.py @@ -22,17 +22,18 @@ from .undo_redo import UndoRedo + class Tracking(UndoRedo): """Extracted from guiWin.""" def _drawGhostContour(self, x, y): if self.ghostObject is None: return - + ID = self.ghostObject.label yc, xc = self.ghostObject.local_centroid - Dx = x-xc - Dy = y-yc + Dx = x - xc + Dy = y - yc xx = self.ghostObject.xx_contour + Dx yy = self.ghostObject.yy_contour + Dy self.ghostContourItemLeft.setData( @@ -45,14 +46,14 @@ def _drawGhostContour(self, x, y): def _drawGhostMask(self, x, y): if self.ghostObject is None: return - + self.clearGhostMask() ID = self.ghostObject.label h, w = self.ghostObject.image.shape[-2:] yc, xc = self.ghostObject.local_centroid - Dx = int(x-xc) - Dy = int(y-yc) - bbox = ((Dy, Dy+h), (Dx, Dx+w)) + Dx = int(x - xc) + Dy = int(y - yc) + bbox = ((Dy, Dy + h), (Dx, Dx + w)) Y, X = self.currentLab2D.shape slices = myutils.get_slices_local_into_global_arr(bbox, (Y, X)) @@ -73,11 +74,11 @@ def _drawGhostMask(self, x, y): def _drawManualBackgroundObjContour(self, x, y): if self.manualBackgroundObj is None: return - + ID = self.manualBackgroundObj.label yc, xc = self.manualBackgroundObj.local_centroid - Dx = x-xc - Dy = y-yc + Dx = x - xc + Dy = y - yc xx = self.manualBackgroundObj.xx_contour + Dx yy = self.manualBackgroundObj.yy_contour + Dy self.manualBackgroundObjItem.setData( @@ -90,43 +91,41 @@ def addManualBackgroundItems(self): def addManualBackgroundObject(self, x, y): posData = self.data[self.pos_i] - - if not hasattr(self, 'manualBackgroundObj'): + + if not hasattr(self, "manualBackgroundObj"): self.initManualBackgroundObject() - + Y, X = self.currentLab2D.shape ymin, xmin, ymax, xmax = self.manualBackgroundObj.bbox - width, height = xmax-xmin, ymax-ymin + width, height = xmax - xmin, ymax - ymin yc, xc = self.manualBackgroundObj.local_centroid - xstart, ystart = round(x-xc), round(y-yc) + xstart, ystart = round(x - xc), round(y - yc) xstart = xstart if xstart >= 0 else 0 ystart = ystart if ystart >= 0 else 0 - - xend = xstart+width - yend = ystart+height + + xend = xstart + width + yend = ystart + height xend = xend if xend <= X else X yend = yend if yend <= Y else Y - - width = xend-xstart - height = yend-ystart - + + width = xend - xstart + height = yend - ystart + obj_image = self.manualBackgroundObj.image[:height, :width] obj_slice = (slice(ystart, yend), slice(xstart, xend)) ID = self.manualBackgroundObj.label self.clearManualBackgroundObject(ID) posData.manualBackgroundLab[obj_slice][obj_image] = ID - + if ID in self.manualBackgroundTextItems: self.manualBackgroundTextItems[ID].setPos(x, y) return - - textItem = pg.TextItem( - text=str(ID), color='r', anchor=(0.5, 0.5) - ) + + textItem = pg.TextItem(text=str(ID), color="r", anchor=(0.5, 0.5)) textItem.setFont(font_13px) textItem.setPos(x, y) self.manualBackgroundTextItems[ID] = textItem - + self.ax1.addItem(textItem) def addManualTrackingItems(self): @@ -147,23 +146,23 @@ def annotateAssignedObjsAcdcTrackerSecondStep(self): annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) if annotInfo is None: return - + new_objs_1st_step, lost_objs_1st_step = annotInfo for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): - allContours = self.getObjContours(lostObj, all_external=True) + allContours = self.getObjContours(lostObj, all_external=True) for objContours in allContours: isObjVisible = self.isObjVisible(newObj.bbox) if not isObjVisible: continue - xx = objContours[:,0] + 0.5 - yy = objContours[:,1] + 0.5 + xx = objContours[:, 0] + 0.5 + yy = objContours[:, 1] + 0.5 self.yellowContourScatterItem.addPoints(xx, yy) - + y1, x1 = self.getObjCentroid(lostObj.centroid) y2, x2 = self.getObjCentroid(newObj.centroid) xx, yy = core.get_line(y1, x1, y2, x2, dashed=False) self.ax1_oldMothBudLinesItem.addPoints(xx, yy) - + posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None def clearAssignedObjsSecondStep(self): @@ -186,33 +185,33 @@ def clearGhostMask(self): def clearManualBackgroundAnnotations(self): try: for textItem in self.manualBackgroundTextItems.values(): - textItem.setText('') + textItem.setText("") except Exception as error: pass def clearManualBackgroundObject(self, ID): posData = self.data[self.pos_i] - mask = posData.manualBackgroundLab==ID + mask = posData.manualBackgroundLab == ID posData.manualBackgroundImage[mask, :] = 0 posData.manualBackgroundLab[mask] = 0 def doSkipTracking(self, against_next: bool, enforce: bool): if self.isSnapshot: return True - + mode = str(self.modeComboBox.currentText()) - if mode != 'Segmentation and Tracking': + if mode != "Segmentation and Tracking": return True - + if self.UserEnforced_DisabledTracking: return True - + if not self.realTimeTrackingToggle.isChecked(): return True - + posData = self.data[self.pos_i] if against_next: - reference_lab = posData.allData_li[posData.frame_i+1]['labels'] + reference_lab = posData.allData_li[posData.frame_i + 1]["labels"] if reference_lab is None: # Next frame never visited --> cannot track against next return True @@ -220,36 +219,36 @@ def doSkipTracking(self, against_next: bool, enforce: bool): if posData.frame_i == posData.SizeT - 1: # Last frame --> cannot track against next return True - + else: # check that we are not on the last frame if posData.frame_i == 0: return True - + if enforce or self.UserEnforced_Tracking: # Enforce even if not last visited frame return False - + is_first_time_on_next_frame = self.isFirstTimeOnNextFrame() skip_tracking = not is_first_time_on_next_frame - + return skip_tracking def drawManualBackgroundObj(self, x, y): if x is None or y is None: self.clearGhost() return - + self._drawManualBackgroundObjContour(x, y) def drawManualTrackingGhost(self, x, y): if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): return - + if x is None or y is None: self.clearGhost() return - + if self.manualTrackingToolbar.ghostContourRadiobutton.isChecked(): self._drawGhostContour(x, y) else: @@ -259,7 +258,7 @@ def enableSmartTrack(self, checked): posData = self.data[self.pos_i] # Disable tracking for already visited frames - if posData.allData_li[posData.frame_i]['labels'] is not None: + if posData.allData_li[posData.frame_i]["labels"] is not None: trackingEnabled = True else: trackingEnabled = False @@ -278,7 +277,7 @@ def enableSmartTrack(self, checked): def getLastTrackedFrame(self, posData): last_tracked_i = 0 for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None: frame_i -= 1 break @@ -293,17 +292,17 @@ def getTrackedLostIDs(self, prev_lab=None, IDs_in_frames=None, frame_i=None): if self.isExportingVideo: posData.trackedLostIDs = trackedLostIDs return trackedLostIDs - + retrackedLostcent = set() if frame_i is None: frame_i = posData.frame_i - + if prev_lab is None: prev_lab = self.get_labels( - from_store=True, - frame_i=posData.frame_i-1, + from_store=True, + frame_i=posData.frame_i - 1, return_existing=False, - return_copy=False + return_copy=False, ) if IDs_in_frames is None: @@ -340,22 +339,22 @@ def get_last_tracked_i(self): posData = self.data[self.pos_i] last_tracked_i = 0 for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None and frame_i == 0: last_tracked_i = 0 break elif lab is None: - last_tracked_i = frame_i-1 + last_tracked_i = frame_i - 1 break else: - last_tracked_i = posData.segmSizeT-1 + last_tracked_i = posData.segmSizeT - 1 return last_tracked_i def handleAdditionalInfoRealTimeTracker(self, prev_rp, *args): - if self._rtTrackerName == 'CellACDC_normal_division': + if self._rtTrackerName == "CellACDC_normal_division": tracked_lost_IDs = args[0] self.setTrackedLostCentroids(prev_rp, tracked_lost_IDs) - elif self._rtTrackerName == 'CellACDC_2steps': + elif self._rtTrackerName == "CellACDC_2steps": if args[0] is None: return posData = self.data[self.pos_i] @@ -363,31 +362,31 @@ def handleAdditionalInfoRealTimeTracker(self, prev_rp, *args): def initGhostObject(self, ID=None): mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': + if mode != "Segmentation and Tracking": self.ghostObject = None return - + if not self.manualTrackingButton.isChecked(): self.ghostObject = None return - + if not self.manualTrackingToolbar.showGhostCheckbox.isChecked(): self.ghostObject = None return - + if ID is None: ID = self.manualTrackingToolbar.spinboxID.value() - + posData = self.data[self.pos_i] if posData.frame_i == 0: self.ghostObject = None return - - prevFrameRp = posData.allData_li[posData.frame_i-1]['regionprops'] + + prevFrameRp = posData.allData_li[posData.frame_i - 1]["regionprops"] if prevFrameRp is None: self.ghostObject = None return - + for obj in prevFrameRp: if obj.label != ID: continue @@ -396,18 +395,16 @@ def initGhostObject(self, ID=None): else: self.ghostObject = None self.manualTrackingToolbar.showWarning( - f'The ID {ID} does not exist in previous frame ' - '--> starting a new track.' + f"The ID {ID} does not exist in previous frame " + "--> starting a new track." ) return - + self.manualTrackingToolbar.clearInfoText() - self.ghostObject.contour = self.getObjContours( - self.ghostObject, local=True - ) - self.ghostObject.xx_contour = self.ghostObject.contour[:,0] - self.ghostObject.yy_contour = self.ghostObject.contour[:,1] + self.ghostObject.contour = self.getObjContours(self.ghostObject, local=True) + self.ghostObject.xx_contour = self.ghostObject.contour[:, 0] + self.ghostObject.yy_contour = self.ghostObject.contour[:, 1] self.ghostMaskItemLeft.initLookupTable(self.lut[ID]) self.ghostMaskItemRight.initLookupTable(self.lut[ID]) @@ -416,28 +413,26 @@ def initManualBackgroundObject(self, ID=None): if not self.manualBackgroundButton.isChecked(): self.manualBackgroundObj = None return - + if ID is None: ID = self.manualBackgroundToolbar.spinboxID.value() - + posData = self.data[self.pos_i] if ID not in posData.IDs: self.manualBackgroundObj = None - self.manualBackgroundToolbar.showWarning( - f'The ID {ID} does not exist' - ) + self.manualBackgroundToolbar.showWarning(f"The ID {ID} does not exist") self.manualBackgroundObjItem.clear() return - + ID_idx = posData.IDs_idxs[ID] self.manualBackgroundObj = posData.rp[ID_idx] - + self.manualBackgroundToolbar.clearInfoText() self.manualBackgroundObj.contour = self.getObjContours( self.manualBackgroundObj, local=True ) - xx_contour = self.manualBackgroundObj.contour[:,0] - yy_contour = self.manualBackgroundObj.contour[:,1] + xx_contour = self.manualBackgroundObj.contour[:, 0] + yy_contour = self.manualBackgroundObj.contour[:, 1] self.manualBackgroundObj.xx_contour = xx_contour self.manualBackgroundObj.yy_contour = yy_contour @@ -445,59 +440,61 @@ def initRealTimeTracker(self, force=False): for rtTrackerAction in self.trackingAlgosGroup.actions(): if rtTrackerAction.isChecked(): break - + aliases = myutils.aliases_real_time_trackers(reverse=True) - + rtTracker = rtTrackerAction.text() rtTracker_txt = rtTracker if rtTracker in aliases: rtTracker = aliases[rtTracker] - - if rtTracker == 'Cell-ACDC': + + if rtTracker == "Cell-ACDC": return - if rtTracker == 'YeaZ': + if rtTracker == "YeaZ": return - + if self.isRealTimeTrackerInitialized and not force: return - - self.logger.info(f'Initializing {rtTracker_txt} tracker...') + + self.logger.info(f"Initializing {rtTracker_txt} tracker...") self._rtTrackerName = rtTracker posData = self.data[self.pos_i] realTimeTracker, track_frame_params = myutils.init_tracker( posData, rtTracker, qparent=self, realTime=True ) if realTimeTracker is None: - self.logger.info(f'{rtTracker} tracker initialization cancelled.') + self.logger.info(f"{rtTracker} tracker initialization cancelled.") return - + self.realTimeTracker = realTimeTracker self.track_frame_params = track_frame_params - self.logger.info(f'{rtTracker} tracker successfully initialized.') - if 'image_channel_name' in self.track_frame_params: + self.logger.info(f"{rtTracker} tracker successfully initialized.") + if "image_channel_name" in self.track_frame_params: # Remove the channel name since it was already loaded in init_tracker - del self.track_frame_params['image_channel_name'] + del self.track_frame_params["image_channel_name"] def initSegmTrackMode(self): posData = self.data[self.pos_i] last_tracked_i = self.get_last_tracked_i() - + if posData.frame_i > last_tracked_i: # Prompt user to go to last tracked frame msg = widgets.myMessageBox() txt = html_utils.paragraph( f'The last visited frame in "Segmentation and Tracking mode" ' - f'is frame {last_tracked_i+1}.\n\n' - f'We recommend to resume from that frame.

    ' - 'How do you want to proceed?' + f"is frame {last_tracked_i + 1}.\n\n" + f"We recommend to resume from that frame.

    " + "How do you want to proceed?" ) goToButton, stayButton = msg.warning( - self, 'Go to last visited frame?', txt, + self, + "Go to last visited frame?", + txt, buttonsTexts=( - f'Resume from frame {last_tracked_i+1} (RECOMMENDED)', - f'Stay on current frame {posData.frame_i+1}' - ) + f"Resume from frame {last_tracked_i + 1} (RECOMMENDED)", + f"Stay on current frame {posData.frame_i + 1}", + ), ) if msg.clickedButton == goToButton: posData.frame_i = last_tracked_i @@ -510,19 +507,19 @@ def initSegmTrackMode(self): current_frame_i = posData.frame_i self.lastFrameRanOnFirstVisitTools = posData.frame_i self.logger.info( - f'Storing data up until frame n. {current_frame_i+1}...' + f"Storing data up until frame n. {current_frame_i + 1}..." ) - pbar = tqdm(total=current_frame_i+1, ncols=100) + pbar = tqdm(total=current_frame_i + 1, ncols=100) for i in range(current_frame_i): posData.frame_i = i self.get_data() - self.store_data(autosave=i==current_frame_i-1) + self.store_data(autosave=i == current_frame_i - 1) pbar.update() pbar.close() posData.frame_i = current_frame_i self.get_data() - + self.highlightLostNew() self.updateLastCheckedFrameWidgets(last_tracked_i) @@ -531,31 +528,32 @@ def initSegmTrackMode(self): def isFirstTimeOnNextFrame(self): posData = self.data[self.pos_i] - posData.last_tracked_i = self.navigateScrollBar.maximum()-1 + posData.last_tracked_i = self.navigateScrollBar.maximum() - 1 return posData.frame_i > posData.last_tracked_i def keepOnlyNewIDAssignedObjsSecondStep(self, trackedID): posData = self.data[self.pos_i] annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) - + if annotInfo is None: - return - + return + new_objs_1st_step, lost_objs_1st_step = annotInfo correct_new_objs, correct_lost_objs = [], [] for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): newObj_ID = posData.lab[newObj.slice][newObj.image][0] if newObj_ID != trackedID: continue - + correct_new_objs.append(newObj) correct_lost_objs.append(lostObj) - + if not correct_new_objs: posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None else: posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( - correct_new_objs, correct_lost_objs + correct_new_objs, + correct_lost_objs, ) def manualBackground_cb(self, checked): @@ -589,9 +587,7 @@ def manualTracking_cb(self, checked): self.UserEnforced_DisabledTracking_previousStatus = ( self.UserEnforced_DisabledTracking ) - self.UserEnforced_Tracking_previousStatus = ( - self.UserEnforced_Tracking - ) + self.UserEnforced_Tracking_previousStatus = self.UserEnforced_Tracking self.UserEnforced_DisabledTracking = True self.UserEnforced_Tracking = False @@ -604,9 +600,7 @@ def manualTracking_cb(self, checked): self.UserEnforced_DisabledTracking = ( self.UserEnforced_DisabledTracking_previousStatus ) - self.UserEnforced_Tracking = ( - self.UserEnforced_Tracking_previousStatus - ) + self.UserEnforced_Tracking = self.UserEnforced_Tracking_previousStatus self.removeManualTrackingItems() self.clearGhost() @@ -621,7 +615,7 @@ def manuallyEditTracking(self, tracked_lab, allIDs): infoToRemove.append((y, x, new_ID)) continue if new_ID in allIDs: - tempID = maxID+1 + tempID = maxID + 1 tracked_lab[tracked_lab == old_ID] = tempID tracked_lab[tracked_lab == new_ID] = old_ID tracked_lab[tracked_lab == tempID] = new_ID @@ -659,8 +653,7 @@ def realTimeTrackingClicked(self, checked): """) msg = widgets.myMessageBox(showCentered=False, wrapText=False) yesButton, noButton = msg.question( - self, 'Keep tracking always active?', txt, - buttonsTexts=('Yes', 'No') + self, "Keep tracking always active?", txt, buttonsTexts=("Yes", "No") ) if msg.clickedButton == yesButton: self.repeatTracking() @@ -673,7 +666,7 @@ def removeManualBackgroundItems(self): self.manualBackgroundObjItem.removeFromPlotItem() self.ax1.removeItem(self.manualBackgroundImageItem) - def removeManualTrackingItems(self): + def removeManualTrackingItems(self): self.ghostContourItemLeft.removeFromPlotItem() self.ghostContourItemRight.removeFromPlotItem() @@ -686,13 +679,12 @@ def repeatTracking(self): self.tracking(enforce=True, DoManualEdit=False) if posData.editID_info: editedIDsInfo = { - posData.lab[y,x]:newID + posData.lab[y, x]: newID for y, x, newID in posData.editID_info - if posData.lab[y,x] != newID + if posData.lab[y, x] != newID } editedIDsInfoItems = [ - f'ID {oldID} --> {newID}' - for oldID, newID in editedIDsInfo.items() + f"ID {oldID} --> {newID}" for oldID, newID in editedIDsInfo.items() ] editIDul = html_utils.to_list(editedIDsInfoItems) msg = widgets.myMessageBox() @@ -702,16 +694,14 @@ def repeatTracking(self):

    Do you want to keep these edits or ignore them? """) - keepManualEditButton = widgets.okPushButton( - 'Keep manually edited IDs' - ) - ignoreButton = widgets.noPushButton( - 'Ignore manually edited IDs' - ) + keepManualEditButton = widgets.okPushButton("Keep manually edited IDs") + ignoreButton = widgets.noPushButton("Ignore manually edited IDs") msg.question( - self, 'Repeat tracking mode', txt, - buttonsTexts=(keepManualEditButton, ignoreButton), - detailsText=editIDul + self, + "Repeat tracking mode", + txt, + buttonsTexts=(keepManualEditButton, ignoreButton), + detailsText=editIDul, ) if msg.cancel: return @@ -727,89 +717,84 @@ def repeatTracking(self): posData.editID_info = [] if np.any(posData.lab != prev_lab): if self.isSnapshot: - self.fixCcaDfAfterEdit('Repeat tracking') + self.fixCcaDfAfterEdit("Repeat tracking") self.updateAllImages() else: - self.warnEditingWithCca_df('Repeat tracking') + self.warnEditingWithCca_df("Repeat tracking") else: self.updateAllImages() def repeatTrackingVideo(self, checked=False): posData = self.data[self.pos_i] win = widgets.selectTrackerGUI( - posData.SizeT, currentFrameNo=posData.frame_i+1 + posData.SizeT, currentFrameNo=posData.frame_i + 1 ) win.exec_() if win.cancel: - self.logger.info('Tracking aborted.') + self.logger.info("Tracking aborted.") return trackerName = win.selectedItemsText[0] start_n = win.startFrame stop_n = win.stopFrame video_to_track = posData.segm_data - for frame_i in range(start_n-1, stop_n): + for frame_i in range(start_n - 1, stop_n): data_dict = posData.allData_li[frame_i] - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None: break video_to_track[frame_i] = lab - video_to_track = video_to_track[start_n-1:stop_n] - - self.logger.info(f'Importing {trackerName} tracker...') + video_to_track = video_to_track[start_n - 1 : stop_n] + + self.logger.info(f"Importing {trackerName} tracker...") self.tracker, self.track_params, init_params = myutils.init_tracker( posData, trackerName, qparent=self, return_init_params=True ) if self.track_params is None: - self.logger.info('Tracking aborted.') + self.logger.info("Tracking aborted.") return - - warningText = myutils.validate_tracker_input( - self.tracker, video_to_track - ) + + warningText = myutils.validate_tracker_input(self.tracker, video_to_track) if warningText is not None: self.logger.info(warningText) self.warnTrackerInputNotValid(trackerName, warningText) - return - - if 'image_channel_name' in self.track_params: + return + + if "image_channel_name" in self.track_params: # Remove the channel name since it was already loaded in init_tracker - del self.track_params['image_channel_name'] - + del self.track_params["image_channel_name"] + track_params_log = { - key: value for key, value in self.track_params.items() - if key != 'image' + key: value for key, value in self.track_params.items() if key != "image" } self.logger.info( - 'Tracking parameters:\n\n' - f'Initialization parameters: {init_params}\n' - f'Track parameters: {track_params_log}' + "Tracking parameters:\n\n" + f"Initialization parameters: {init_params}\n" + f"Track parameters: {track_params_log}" ) last_cca_i = self.get_last_cca_frame_i() - if start_n-2 <= last_cca_i and start_n>1: - proceed = self.warnRepeatTrackingVideoWithAnnotations( - last_cca_i, start_n - ) + if start_n - 2 <= last_cca_i and start_n > 1: + proceed = self.warnRepeatTrackingVideoWithAnnotations(last_cca_i, start_n) if not proceed: - self.logger.info('Tracking aborted.') + self.logger.info("Tracking aborted.") return - - self.logger.info(f'Removing annotations from frame n. {start_n}.') - self.resetCcaFuture(start_n-1) + + self.logger.info(f"Removing annotations from frame n. {start_n}.") + self.resetCcaFuture(start_n - 1) self.start_n = start_n self.stop_n = stop_n - - info_txt = f'Tracking from frame n. {start_n} to {stop_n}...' + + info_txt = f"Tracking from frame n. {start_n} to {stop_n}..." self.logger.info(info_txt) self.progressWin = apps.QDialogWorkerProgress( - title='Tracking', parent=self, pbarDesc=info_txt + title="Tracking", parent=self, pbarDesc=info_txt ) self.progressWin.show(self.app) - self.progressWin.mainPbar.setMaximum(stop_n-start_n) + self.progressWin.mainPbar.setMaximum(stop_n - start_n) self.startTrackingWorker(posData, video_to_track) def resetManualBackgroundItems(self): @@ -822,7 +807,7 @@ def resetManualBackgroundSpinboxID(self): if not self.manualBackgroundButton.isChecked(): self.manualBackgroundObj = None return - + posData = self.data[self.pos_i] minID = min(posData.IDs, default=0) self.manualBackgroundToolbar.spinboxID.setValue(minID) @@ -839,11 +824,11 @@ def separateByLabelling(self, lab, rp, maxID=None): for obj in rp: lab_obj = skimage.measure.label(obj.image) rp_lab_obj = skimage.measure.regionprops(lab_obj) - if len(rp_lab_obj)<=1: + if len(rp_lab_obj) <= 1: continue lab_obj += maxID - _slice = obj.slice # self.getObjSlice(obj.slice) - _objMask = obj.image # self.getObjImage(obj.image) + _slice = obj.slice # self.getObjSlice(obj.slice) + _objMask = obj.image # self.getObjImage(obj.image) lab[_slice][_objMask] = lab_obj[_objMask] setRp = True maxID += 1 @@ -862,21 +847,21 @@ def setManualBackgrounNextID(self): def setManualBackgroundImage(self): if not self.manualBackgroundButton.isChecked(): return - + posData = self.data[self.pos_i] - if not hasattr(posData, 'manualBackgroundImage'): + if not hasattr(posData, "manualBackgroundImage"): self.initManualBackgroundImage() - + contours = [] - for obj in skimage.measure.regionprops(posData.manualBackgroundLab): - obj_contours = self.getObjContours(obj, all_external=True) + for obj in skimage.measure.regionprops(posData.manualBackgroundLab): + obj_contours = self.getObjContours(obj, all_external=True) contours.extend(obj_contours) textItem = self.manualBackgroundTextItems[obj.label] - textItem.setText(f'{obj.label}') + textItem.setText(f"{obj.label}") self.ax1.addItem(textItem) yc, xc = obj.centroid textItem.setPos(xc, yc) - + cv2.drawContours( posData.manualBackgroundImage, contours, -1, (255, 0, 0, 200), 1 ) @@ -886,15 +871,15 @@ def setManualBackgroundLab(self, load_from_store=False, debug=True): posData = self.data[self.pos_i] if posData.manualBackgroundLab is None: self.initManualBackgroundImage() - + for obj in skimage.measure.regionprops(posData.manualBackgroundLab): - textItem = pg.TextItem(text='', color='r', anchor=(0.5, 0.5)) + textItem = pg.TextItem(text="", color="r", anchor=(0.5, 0.5)) if obj.label in self.manualBackgroundTextItems: continue self.manualBackgroundTextItems[obj.label] = textItem def setTrackedLostCentroids(self, prev_rp, tracked_lost_IDs): - """Store centroids of those IDs the tracker decided is fine to lose + """Store centroids of those IDs the tracker decided is fine to lose (e.g., upon standard cell division the ID of the mother is fine) Parameters @@ -904,43 +889,52 @@ def setTrackedLostCentroids(self, prev_rp, tracked_lost_IDs): tracked_lost_IDs : iterable List-like container of the IDs that is fine to lose from previous frame to current frame - + Note ---- - This function stores the centroids because the user could change IDs + This function stores the centroids because the user could change IDs in multiple ways. Storing centroids is more robust. - """ + """ posData = self.data[self.pos_i] frame_i = posData.frame_i - + for obj in prev_rp: if obj.label not in tracked_lost_IDs: continue - + int_centroid = tuple([int(val) for val in obj.centroid]) try: posData.tracked_lost_centroids[frame_i].add(int_centroid) except KeyError: - posData.tracked_lost_centroids[frame_i] = {int_centroid} + posData.tracked_lost_centroids[frame_i] = {int_centroid} def trackFrame( - self, prev_lab, prev_rp, curr_lab, curr_rp, curr_IDs, - assign_unique_new_IDs=True, IDs=None, unique_ID=None - ): + self, + prev_lab, + prev_rp, + curr_lab, + curr_rp, + curr_IDs, + assign_unique_new_IDs=True, + IDs=None, + unique_ID=None, + ): if self.trackWithAcdcAction.isChecked(): tracked_result = CellACDC_tracker.track_frame( - prev_lab, prev_rp, curr_lab, curr_rp, + prev_lab, + prev_rp, + curr_lab, + curr_rp, IDs_curr_untracked=curr_IDs, setBrushID_func=self.setBrushID, posData=self.data[self.pos_i], - assign_unique_new_IDs=assign_unique_new_IDs, + assign_unique_new_IDs=assign_unique_new_IDs, IDs=IDs, - unique_ID=unique_ID + unique_ID=unique_ID, ) elif self.trackWithYeazAction.isChecked(): tracked_result = self.tracking_yeaz.correspondence( - prev_lab, curr_lab, use_modified_yeaz=True, - use_scipy=True + prev_lab, curr_lab, use_modified_yeaz=True, use_scipy=True ) else: tracked_result = self.trackFrameCustomTracker( @@ -953,47 +947,48 @@ def trackFrame( self.handleAdditionalInfoRealTimeTracker(prev_rp, tracked_lost_IDs) else: tracked_lab = tracked_result - + return tracked_lab - def trackFrameCustomTracker( - self, prev_lab, currentLab, IDs=None, unique_ID=None - ): + def trackFrameCustomTracker(self, prev_lab, currentLab, IDs=None, unique_ID=None): if unique_ID is None: unique_ID = self.setBrushID() try: tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, + prev_lab, + currentLab, unique_ID=unique_ID, IDs=IDs, **self.track_frame_params, ) except TypeError as err: - if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: + if str(err).find("an unexpected keyword argument 'unique_ID'") != -1: try: tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, IDs=IDs, - **self.track_frame_params + prev_lab, currentLab, IDs=IDs, **self.track_frame_params ) except TypeError as err: - if str(err).find('an unexpected keyword argument \'IDs\'') != -1: + if str(err).find("an unexpected keyword argument 'IDs'") != -1: tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - **self.track_frame_params) + prev_lab, currentLab, **self.track_frame_params + ) else: raise err - elif str(err).find('an unexpected keyword argument \'IDs\'') != -1: + elif str(err).find("an unexpected keyword argument 'IDs'") != -1: try: tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, + prev_lab, + currentLab, unique_ID=unique_ID, - **self.track_frame_params + **self.track_frame_params, ) except TypeError as err: - if str(err).find('an unexpected keyword argument \'unique_ID\'') != -1: + if ( + str(err).find("an unexpected keyword argument 'unique_ID'") + != -1 + ): tracked_result = self.realTimeTracker.track_frame( - prev_lab, currentLab, - **self.track_frame_params + prev_lab, currentLab, **self.track_frame_params ) else: raise err @@ -1002,9 +997,12 @@ def trackFrameCustomTracker( return tracked_result def trackManuallyAddedObject( - self, added_IDs: List[int] | int | Set[int], isNewID: bool, - wl_update:bool=True, wl_track_og_curr:bool=False - ): + self, + added_IDs: List[int] | int | Set[int], + isNewID: bool, + wl_update: bool = True, + wl_track_og_curr: bool = False, + ): """Track object added manually on frame that was already visited. Parameters @@ -1013,42 +1011,41 @@ def trackManuallyAddedObject( ID or IDs of the object added manually isNewID : bool If True, the added object is new - + Notes ----- - This method tracks the new added object against the previous frame - labels. If the ID determined by tracking is different from `added_ID` - (meaning that tracking thinks the new ID should be changed to the - tracked ID) and the tracked ID is not already existing (which would - otherwise causing merging) we assign the tracked ID to the object with - `added_ID`. - - If instead the tracked ID is the same as `added_ID` we are dealing - with a truly new object. In this case we want to try tracking it against - the next frame (since the next frame was already validated). - As before, we assign the tracked ID (against the next frame) only if - not already existing in current frame (to avoid merging). - """ + This method tracks the new added object against the previous frame + labels. If the ID determined by tracking is different from `added_ID` + (meaning that tracking thinks the new ID should be changed to the + tracked ID) and the tracked ID is not already existing (which would + otherwise causing merging) we assign the tracked ID to the object with + `added_ID`. + + If instead the tracked ID is the same as `added_ID` we are dealing + with a truly new object. In this case we want to try tracking it against + the next frame (since the next frame was already validated). + As before, we assign the tracked ID (against the next frame) only if + not already existing in current frame (to avoid merging). + """ if self.isSnapshot: - return - + return + if not isNewID: return if isinstance(added_IDs, int): added_IDs = [added_IDs] - + posData = self.data[self.pos_i] tracked_lab = self.tracking( - enforce=True, assign_unique_new_IDs=False, return_lab=True, - IDs=added_IDs + enforce=True, assign_unique_new_IDs=False, return_lab=True, IDs=added_IDs ) self.clearAssignedObjsSecondStep() if tracked_lab is None: return - + # Track only new object - prevIDs = posData.allData_li[posData.frame_i-1]['IDs'] + prevIDs = posData.allData_li[posData.frame_i - 1]["IDs"] # mask = np.zeros(posData.lab.shape, dtype=bool) update_rp = False @@ -1064,15 +1061,14 @@ def trackManuallyAddedObject( trackedID = tracked_lab[mask][0] except IndexError as err: # added_ID is not present - continue - + continue + isTrackedIDalreadyPresentAndNotNew = ( - posData.IDs_idxs.get(trackedID) is not None - and added_ID != trackedID + posData.IDs_idxs.get(trackedID) is not None and added_ID != trackedID ) if isTrackedIDalreadyPresentAndNotNew: continue - + isTrackedIDinPrevIDs = trackedID in prevIDs if isTrackedIDinPrevIDs: posData.lab[mask] = trackedID @@ -1083,43 +1079,47 @@ def trackManuallyAddedObject( self.clearAssignedObjsSecondStep() continue posData.lab[mask] = trackedID - + self.keepOnlyNewIDAssignedObjsSecondStep(trackedID) update_rp = True - + if update_rp: self.update_rp(wl_update=wl_update) def trackNewIDtoNewIDsFutureFrame(self, newID, newIDmask): posData = self.data[self.pos_i] try: - nextLab = posData.allData_li[posData.frame_i+1]['labels'] + nextLab = posData.allData_li[posData.frame_i + 1]["labels"] except IndexError: # This is last frame --> there are no future frames return - + if nextLab is None: return - + newID_lab = np.zeros_like(posData.lab) newID_lab[newIDmask] = newID newLab_rp = [posData.rp[posData.IDs_idxs[newID]]] - newLab_IDs = [newID] - nextRp = posData.allData_li[posData.frame_i+1]['regionprops'] - + newLab_IDs = [newID] + nextRp = posData.allData_li[posData.frame_i + 1]["regionprops"] + tracked_lab = self.trackFrame( - nextLab, nextRp, newID_lab, newLab_rp, newLab_IDs, - assign_unique_new_IDs=False + nextLab, + nextRp, + newID_lab, + newLab_rp, + newLab_IDs, + assign_unique_new_IDs=False, ) - trackedID = tracked_lab[newID_lab>0][0] + trackedID = tracked_lab[newID_lab > 0][0] if trackedID == newID: # Object does not exist in future frame --> do not track return - + if posData.IDs_idxs.get(trackedID) is not None: # Tracked ID already exists --> do not track to avoid merging return - + return trackedID def trackSubsetIDs(self, subsetIDs: Iterable[int]): @@ -1130,12 +1130,16 @@ def trackSubsetIDs(self, subsetIDs: Iterable[int]): subsetLab = np.zeros_like(posData.lab) for subsetID in subsetIDs: subsetLab[posData.lab == subsetID] = subsetID - - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + + prev_lab = posData.allData_li[posData.frame_i - 1]["labels"] + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] tracked_lab = self.trackFrame( - prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, - assign_unique_new_IDs=True + prev_lab, + prev_rp, + posData.lab, + posData.rp, + posData.IDs, + assign_unique_new_IDs=True, ) doUpdateRp = False for subsetID in subsetIDs: @@ -1143,37 +1147,44 @@ def trackSubsetIDs(self, subsetIDs: Iterable[int]): trackedID = tracked_lab[subsetIDmask][0] if trackedID == subsetID: continue - + is_manually_edited = False for y, x, new_ID in posData.editID_info: if new_ID == subsetID: # Do not track because it was manually edited break - + posData.lab[subsetIDmask] = tracked_lab[subsetIDmask] doUpdateRp = True - + if not doUpdateRp: return - + self.update_rp() def tracking( - self, enforce=False, DoManualEdit=True, - storeUndo=False, prev_lab=None, prev_rp=None, - return_lab=False, assign_unique_new_IDs=True, - separateByLabel=True, wl_update=True, - IDs=None, against_next=False, - ): + self, + enforce=False, + DoManualEdit=True, + storeUndo=False, + prev_lab=None, + prev_rp=None, + return_lab=False, + assign_unique_new_IDs=True, + separateByLabel=True, + wl_update=True, + IDs=None, + against_next=False, + ): posData = self.data[self.pos_i] - + if self.doSkipTracking(against_next, enforce): self.setLostNewOldPrevIDs() return - + """Tracking starts here""" staturBarLabelText = self.statusBarLabel.text() - self.statusBarLabel.setText('Tracking...') + self.statusBarLabel.setText("Tracking...") if storeUndo: # Store undo state before modifying stuff @@ -1186,29 +1197,36 @@ def tracking( posData.lab, rp=posData.rp, max_ID=maxID ) if setRp: - self.update_rp(wl_update=wl_update, ) + self.update_rp( + wl_update=wl_update, + ) if prev_lab is None: if not against_next: - prev_lab = posData.allData_li[posData.frame_i-1]['labels'] + prev_lab = posData.allData_li[posData.frame_i - 1]["labels"] else: - prev_lab = posData.allData_li[posData.frame_i+1]['labels'] + prev_lab = posData.allData_li[posData.frame_i + 1]["labels"] if prev_rp is None: if not against_next: - prev_rp = posData.allData_li[posData.frame_i-1]['regionprops'] + prev_rp = posData.allData_li[posData.frame_i - 1]["regionprops"] else: - prev_rp = posData.allData_li[posData.frame_i+1]['regionprops'] - + prev_rp = posData.allData_li[posData.frame_i + 1]["regionprops"] + unique_ID = None if posData.frame_i < self.get_last_tracked_i(): unique_ID = self.setBrushID(return_val=True) - + tracked_lab = self.trackFrame( - prev_lab, prev_rp, posData.lab, posData.rp, posData.IDs, - assign_unique_new_IDs=assign_unique_new_IDs, IDs=IDs, - unique_ID=unique_ID + prev_lab, + prev_rp, + posData.lab, + posData.rp, + posData.IDs, + assign_unique_new_IDs=assign_unique_new_IDs, + IDs=IDs, + unique_ID=unique_ID, ) - + if DoManualEdit: # Correct tracking with manually changed IDs rp = skimage.measure.regionprops(tracked_lab) @@ -1216,41 +1234,42 @@ def tracking( self.manuallyEditTracking(tracked_lab, IDs) if return_lab: - QTimer.singleShot(50, partial( - self.statusBarLabel.setText, staturBarLabelText - )) + QTimer.singleShot( + 50, partial(self.statusBarLabel.setText, staturBarLabelText) + ) return tracked_lab - + # Update labels, regionprops and determine new and lost IDs posData.lab = tracked_lab - self.update_rp(wl_update=wl_update, ) + self.update_rp( + wl_update=wl_update, + ) self.setAllTextAnnotations() - QTimer.singleShot(50, partial( - self.statusBarLabel.setText, staturBarLabelText - )) + QTimer.singleShot(50, partial(self.statusBarLabel.setText, staturBarLabelText)) def updateAssignedObjsAcdcTrackerSecondStep(self, newID): posData = self.data[self.pos_i] annotInfo = posData.acdcTracker2stepsAnnotInfo.get(posData.frame_i) if annotInfo is None: return - + new_objs_1st_step, lost_objs_1st_step = annotInfo correct_new_objs, correct_lost_objs = [], [] for lostObj, newObj in zip(lost_objs_1st_step, new_objs_1st_step): newObj_ID = posData.lab[newObj.slice][newObj.image][0] if newObj_ID == newID: - # The ID of the new object tracked with 2nd step was + # The ID of the new object tracked with 2nd step was # manually edit --> do not annotate its linking to lost obj anymore continue correct_new_objs.append(newObj) correct_lost_objs.append(lostObj) - + if not correct_new_objs: posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = None else: posData.acdcTracker2stepsAnnotInfo[posData.frame_i] = ( - correct_new_objs, correct_lost_objs + correct_new_objs, + correct_lost_objs, ) self.annotateAssignedObjsAcdcTrackerSecondStep() @@ -1259,35 +1278,37 @@ def updateGhostMaskOpacity(self, alpha_percentage=None): alpha_percentage = ( self.manualTrackingToolbar.ghostMaskOpacitySpinbox.value() ) - alpha = alpha_percentage/100 + alpha = alpha_percentage / 100 self.ghostMaskItemLeft.setOpacity(alpha) self.ghostMaskItemRight.setOpacity(alpha) def updateLastCheckedFrameWidgets(self, last_tracked_i): - self.navigateScrollBar.setMaximum(last_tracked_i+1) - self.navSpinBox.setMaximum(last_tracked_i+1) + self.navigateScrollBar.setMaximum(last_tracked_i + 1) + self.navSpinBox.setMaximum(last_tracked_i + 1) self.lastTrackedFrameLabel.setText( - f'Last checked frame n. = {last_tracked_i+1}' + f"Last checked frame n. = {last_tracked_i + 1}" ) def warnRepeatTrackingVideoOnVisitedFrames(self, last_tracked_i, start_n): msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'You are repeating tracking on frames that have already ' - 'been visited/tracked before.

    ' - 'This will very likely make the annotations wrong.

    ' - 'If you really want to repeat tracking on the frames before ' - f'{last_tracked_i+1} the annotations from frame ' - f'{start_n} to frame {last_tracked_i+1} ' - 'will be removed.

    ' - 'Do you want to continue?' + "You are repeating tracking on frames that have already " + "been visited/tracked before.

    " + "This will very likely make the annotations wrong.

    " + "If you really want to repeat tracking on the frames before " + f"{last_tracked_i + 1} the annotations from frame " + f"{start_n} to frame {last_tracked_i + 1} " + "will be removed.

    " + "Do you want to continue?" ) noButton, yesButton = msg.warning( - self, 'Repating tracking with annotations!', txt, + self, + "Repating tracking with annotations!", + txt, buttonsTexts=( - ' No, stop tracking and keep annotations.', - ' Yes, repeat tracking and DELETE annotations.' - ) + " No, stop tracking and keep annotations.", + " Yes, repeat tracking and DELETE annotations.", + ), ) if msg.cancel: return False @@ -1300,21 +1321,23 @@ def warnRepeatTrackingVideoOnVisitedFrames(self, last_tracked_i, start_n): def warnRepeatTrackingVideoWithAnnotations(self, last_tracked_i, start_n): msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'You are repeating tracking on frames that have cell cycle ' - 'annotations.

    ' - 'This will very likely make the annotations wrong.

    ' - 'If you really want to repeat tracking on the frames before ' - f'{last_tracked_i+1} the annotations from frame ' - f'{start_n} to frame {last_tracked_i+1} ' - 'will be removed.

    ' - 'Do you want to continue?' + "You are repeating tracking on frames that have cell cycle " + "annotations.

    " + "This will very likely make the annotations wrong.

    " + "If you really want to repeat tracking on the frames before " + f"{last_tracked_i + 1} the annotations from frame " + f"{start_n} to frame {last_tracked_i + 1} " + "will be removed.

    " + "Do you want to continue?" ) noButton, yesButton = msg.warning( - self, 'Repating tracking with annotations!', txt, + self, + "Repating tracking with annotations!", + txt, buttonsTexts=( - ' No, stop tracking and keep annotations.', - ' Yes, repeat tracking and DELETE annotations.' - ) + " No, stop tracking and keep annotations.", + " Yes, repeat tracking and DELETE annotations.", + ), ) if msg.cancel: return False @@ -1326,9 +1349,9 @@ def warnRepeatTrackingVideoWithAnnotations(self, last_tracked_i, start_n): def warnTrackerInputNotValid(self, trackerName, warningText): msg = widgets.myMessageBox(wrapText=False) - txt = warningText.replace('\n', '
    ') + txt = warningText.replace("\n", "
    ") txt = html_utils.paragraph( - f'{txt}

    ' - 'Tracking process will be cancelled. Thank you for your patience!' + f"{txt}

    " + "Tracking process will be cancelled. Thank you for your patience!" ) - msg.warning(self, 'Invalid input for tracker', txt) + msg.warning(self, "Invalid input for tracker", txt) diff --git a/cellacdc/mixins/undo_redo.py b/cellacdc/mixins/undo_redo.py index c25c9b1df..16e2abcde 100644 --- a/cellacdc/mixins/undo_redo.py +++ b/cellacdc/mixins/undo_redo.py @@ -11,6 +11,7 @@ from .label_editing import LabelEditing + class UndoRedo(LabelEditing): """Extracted from guiWin.""" @@ -23,12 +24,11 @@ def UndoCca(self): self.addCcaState(posData.frame_i, posData.cca_df, undoId) storeState = True - # Get previously stored state self.UndoCount += 1 currentCcaStates = posData.UndoRedoCcaStates[posData.frame_i] prevCcaState = currentCcaStates[self.UndoCount] - posData.cca_df = prevCcaState['cca_df'] + posData.cca_df = prevCcaState["cca_df"] self.store_cca_df() self.updateAllImages() @@ -39,7 +39,7 @@ def UndoCca(self): # Undo all past and future frames that has a last status inserted # when modyfing current frame - prevStateId = prevCcaState['id'] + prevStateId = prevCcaState["id"] for frame_i in range(0, posData.SizeT): if storeState: cca_df_i = self.get_cca_df(frame_i=frame_i, return_df=True) @@ -54,21 +54,21 @@ def UndoCca(self): continue CcaState_i = CcaStates_i[self.UndoCount] - id_i = CcaState_i['id'] + id_i = CcaState_i["id"] if id_i != prevStateId: # The id of the state in frame_i is different from current frame continue - cca_df_i = CcaState_i['cca_df'] + cca_df_i = CcaState_i["cca_df"] self.store_cca_df(frame_i=frame_i, cca_df=cca_df_i, autosave=False) - + self.resetWillDivideInfo() self.enqAutosave() def addCcaState(self, frame_i, cca_df, undoId): posData = self.data[self.pos_i] posData.UndoRedoCcaStates[frame_i].insert( - 0, {'id': undoId, 'cca_df': cca_df.copy()} + 0, {"id": undoId, "cca_df": cca_df.copy()} ) def addCurrentState(self, storeImage=False, storeOnlyZoom=False): @@ -85,8 +85,7 @@ def addCurrentState(self, storeImage=False, storeOnlyZoom=False): if storeOnlyZoom: labels, crop_slice = transformation.crop_2D( - self.currentLab2D, self.ax1.viewRange(), tolerance=10, - return_copy=False + self.currentLab2D, self.ax1.viewRange(), tolerance=10, return_copy=False ) if self.isSegm3D: z = self.z_lab(checkIfProj=True) @@ -103,16 +102,16 @@ def addCurrentState(self, storeImage=False, storeOnlyZoom=False): else: labels = posData.lab.copy() crop_slice = None - + state = { - 'image': image, - 'labels': labels, - 'editID_info': posData.editID_info.copy(), - 'binnedIDs': posData.binnedIDs.copy(), - 'keptObejctsIDs': self.keptObjectsIDs.copy(), - 'ripIDs': posData.ripIDs.copy(), - 'cca_df': cca_df, - 'crop_slice': crop_slice + "image": image, + "labels": labels, + "editID_info": posData.editID_info.copy(), + "binnedIDs": posData.binnedIDs.copy(), + "keptObejctsIDs": self.keptObjectsIDs.copy(), + "ripIDs": posData.ripIDs.copy(), + "cca_df": cca_df, + "crop_slice": crop_slice, } posData.UndoRedoStates[posData.frame_i].insert(0, state) @@ -122,8 +121,7 @@ def askPropagateChangePast(self, change_txt): """) msg = widgets.myMessageBox(wrapText=False) yesButton, _ = msg.question( - self, 'Propagate change to past frames', txt, - buttonsTexts=('Yes', 'No') + self, "Propagate change to past frames", txt, buttonsTexts=("Yes", "No") ) return msg.clickedButton == yesButton @@ -134,7 +132,7 @@ def clearUndoQueue(self): self.undoAction.setEnabled(False) posData.UndoRedoStates = [[] for _ in range(posData.SizeT)] posData.UndoRedoCcaStates = [[] for _ in range(posData.SizeT)] - if hasattr(self, 'undoAddPointQueueMapper'): + if hasattr(self, "undoAddPointQueueMapper"): self.undoAddPointQueueMapper = defaultdict(list) def getCurrentState(self): @@ -142,36 +140,42 @@ def getCurrentState(self): i = posData.frame_i c = self.UndoCount state = posData.UndoRedoStates[i][c] - if state['image'] is None: + if state["image"] is None: image_left = None else: - image_left = state['image'].copy() - - crop_slice = state['crop_slice'] + image_left = state["image"].copy() + + crop_slice = state["crop_slice"] if crop_slice is None: - posData.lab = state['labels'].copy() + posData.lab = state["labels"].copy() elif self.isSegm3D: z_slice, slice_y, slice_x = crop_slice - posData.lab[..., z_slice, slice_y, slice_x] = state['labels'].copy() + posData.lab[..., z_slice, slice_y, slice_x] = state["labels"].copy() else: slice_y, slice_x = crop_slice - posData.lab[..., slice_y, slice_x] = state['labels'].copy() - - posData.editID_info = state['editID_info'].copy() - posData.binnedIDs = state['binnedIDs'].copy() - posData.ripIDs = state['ripIDs'].copy() - self.keptObjectsIDs = state['keptObejctsIDs'].copy() - cca_df = state['cca_df'] + posData.lab[..., slice_y, slice_x] = state["labels"].copy() + + posData.editID_info = state["editID_info"].copy() + posData.binnedIDs = state["binnedIDs"].copy() + posData.ripIDs = state["ripIDs"].copy() + self.keptObjectsIDs = state["keptObejctsIDs"].copy() + cca_df = state["cca_df"] if cca_df is not None: - posData.cca_df = state['cca_df'].copy() + posData.cca_df = state["cca_df"].copy() else: posData.cca_df = None return image_left def propagateChange( - self, modID, modTxt, doNotShow, UndoFutFrames, - applyFutFrames, applyTrackingB=False, force=False - ): + self, + modID, + modTxt, + doNotShow, + UndoFutFrames, + applyFutFrames, + applyTrackingB=False, + force=False, + ): """ This function determines whether there are already visited future frames that contains "modID". If so, it triggers a pop-up asking the user @@ -179,7 +183,7 @@ def propagateChange( """ posData = self.data[self.pos_i] # Do not check the future for the last frame - if posData.frame_i+1 == posData.SizeT: + if posData.frame_i + 1 == posData.SizeT: # No future frames to propagate the change to return False, False, None, doNotShow @@ -189,8 +193,8 @@ def propagateChange( # frames has an ID affected by the change last_tracked_i_found = False segmSizeT = len(posData.segm_data) - for i in range(posData.frame_i+1, segmSizeT): - if posData.allData_li[i]['labels'] is None: + for i in range(posData.frame_i + 1, segmSizeT): + if posData.allData_li[i]["labels"] is None: if not last_tracked_i_found: # We set last tracked frame at -1 first None found last_tracked_i = i - 1 @@ -201,11 +205,11 @@ def propagateChange( else: lab = posData.segm_data[i] else: - lab = posData.allData_li[i]['labels'] - + lab = posData.allData_li[i]["labels"] + if modID in lab: areFutureIDs_affected.append(True) - + if not last_tracked_i_found: # All frames have been visited in segm&track mode last_tracked_i = posData.SizeT - 1 @@ -221,18 +225,22 @@ def propagateChange( # Ask what to do unless the user has previously checked doNotShowAgain if doNotShow: endFrame_i = last_tracked_i - if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': - self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) + if applyFutFrames and not UndoFutFrames and modTxt == "Edit ID": + self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i + 1)) return UndoFutFrames, applyFutFrames, endFrame_i, doNotShow else: addApplyAllButton = ( - modTxt == 'Delete ID' or modTxt == 'Edit ID' - or modTxt == 'Assign new ID' + modTxt == "Delete ID" + or modTxt == "Edit ID" + or modTxt == "Assign new ID" ) ffa = apps.FutureFramesAction_QDialog( - posData.frame_i+1, last_tracked_i, modTxt, - applyTrackingB=applyTrackingB, parent=self, - addApplyAllButton=addApplyAllButton + posData.frame_i + 1, + last_tracked_i, + modTxt, + applyTrackingB=applyTrackingB, + parent=self, + addApplyAllButton=addApplyAllButton, ) ffa.exec_() decision = ffa.decision @@ -243,41 +251,41 @@ def propagateChange( endFrame_i = ffa.endFrame_i doNotShowAgain = ffa.doNotShowCheckbox.isChecked() askAction = self.askHowFutureFramesActions[modTxt] - askAction.setChecked( not doNotShowAgain) + askAction.setChecked(not doNotShowAgain) askAction.setDisabled(False) self.onlyTracking = False - if decision == 'apply_and_reinit': + if decision == "apply_and_reinit": UndoFutFrames = True applyFutFrames = False - elif decision == 'apply_and_NOTreinit': + elif decision == "apply_and_NOTreinit": UndoFutFrames = False applyFutFrames = False - elif decision == 'apply_to_all_visited': + elif decision == "apply_to_all_visited": UndoFutFrames = False applyFutFrames = True - elif decision == 'only_tracking': + elif decision == "only_tracking": UndoFutFrames = False applyFutFrames = True self.onlyTracking = True - elif decision == 'apply_to_all': + elif decision == "apply_to_all": UndoFutFrames = False applyFutFrames = True posData.includeUnvisitedInfo[modTxt] = True - if applyFutFrames and not UndoFutFrames and modTxt == 'Edit ID': - self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i+1)) + if applyFutFrames and not UndoFutFrames and modTxt == "Edit ID": + self.whitelistSyncIDsOG(frame_is=range(posData.frame_i, endFrame_i + 1)) return UndoFutFrames, applyFutFrames, endFrame_i, doNotShowAgain def propagateMergeObjsPast(self, IDs_to_merge): self.store_data(autosave=False) posData = self.data[self.pos_i] current_frame_i = posData.frame_i - for past_frame_i in range(posData.frame_i-1, -1, -1): + for past_frame_i in range(posData.frame_i - 1, -1, -1): posData.frame_i = past_frame_i self.get_data() - - IDs = posData.allData_li[past_frame_i]['IDs'] + + IDs = posData.allData_li[past_frame_i]["IDs"] stop_loop = False for ID in IDs_to_merge: if ID not in IDs: @@ -286,14 +294,14 @@ def propagateMergeObjsPast(self, IDs_to_merge): if ID == 0: continue - posData.lab[posData.lab==ID] = self.firstID + posData.lab[posData.lab == ID] = self.firstID self.update_rp() - + self.store_data(autosave=False) - + if stop_loop: break - + posData.frame_i = current_frame_i self.get_data() @@ -341,15 +349,13 @@ def storeUndoRedoCca(self, frame_i, cca_df, undoId): if len(posData.UndoRedoCcaStates[frame_i]) > 10: posData.UndoRedoCcaStates[frame_i].pop(-1) - def storeUndoRedoStates( - self, UndoFutFrames, storeImage=False, storeOnlyZoom=False - ): + def storeUndoRedoStates(self, UndoFutFrames, storeImage=False, storeOnlyZoom=False): posData = self.data[self.pos_i] if UndoFutFrames: # Since we modified current frame all future frames that were already # visited are not valid anymore. Undo changes there self.reInitLastSegmFrame(updateImages=False) - + # Keep only 5 Undo/Redo states if len(posData.UndoRedoStates[posData.frame_i]) > 5: posData.UndoRedoStates[posData.frame_i].pop(-1) @@ -358,9 +364,7 @@ def storeUndoRedoStates( # NOTE: index 0 is most recent state before doing last change self.UndoCount = 0 self.undoAction.setEnabled(True) - self.addCurrentState( - storeImage=storeImage, storeOnlyZoom=storeOnlyZoom - ) + self.addCurrentState(storeImage=storeImage, storeOnlyZoom=storeOnlyZoom) def undo(self): addPointsByClickingButton = self.buttonAddPointsByClickingActive() @@ -368,14 +372,14 @@ def undo(self): done = self.undoAddPoint(addPointsByClickingButton.action) if done: return - + if self.UndoCount == 0: # Store current state to enable redoing it self.addCurrentState() - + posData = self.data[self.pos_i] # Get previously stored state - if self.UndoCount < len(posData.UndoRedoStates[posData.frame_i])-1: + if self.UndoCount < len(posData.UndoRedoStates[posData.frame_i]) - 1: self.UndoCount += 1 # Since we have undone then it is possible to redo self.redoAction.setEnabled(True) @@ -386,10 +390,10 @@ def undo(self): self.updateAllImages(image=image_left) self.store_data() - if not self.UndoCount < len(posData.UndoRedoStates[posData.frame_i])-1: + if not self.UndoCount < len(posData.UndoRedoStates[posData.frame_i]) - 1: # We have undone all available states self.undoAction.setEnabled(False) - + if self.whitelistIDsButton.isChecked(): self.whitelistHighlightIDs() diff --git a/cellacdc/mixins/whitelist.py b/cellacdc/mixins/whitelist.py index 52497ed03..99ea2b236 100644 --- a/cellacdc/mixins/whitelist.py +++ b/cellacdc/mixins/whitelist.py @@ -23,24 +23,25 @@ class WhitelistGui: - """A class to manage the whitelist GUI elements. - """ - def whitelistCheckOriginalLabels(self, warning:bool=True, - frame_i:int=None): - """Warns the user that there are no original labels labels are present + """A class to manage the whitelist GUI elements.""" + + def whitelistCheckOriginalLabels(self, warning: bool = True, frame_i: int = None): + """Warns the user that there are no original labels labels are present for the frame""" posData = self.data[self.pos_i] if posData.whitelist is None: return False - + if frame_i is None: frame_i = posData.frame_i - + if posData.whitelist.originalLabsIDs is None: return False - if (frame_i >= len(posData.whitelist.originalLabsIDs) or - posData.whitelist.originalLabsIDs[frame_i] is None): + if ( + frame_i >= len(posData.whitelist.originalLabsIDs) + or posData.whitelist.originalLabsIDs[frame_i] is None + ): txt = """ No original labels are present for the current frame, this action cannot be performed.""" @@ -48,9 +49,11 @@ def whitelistCheckOriginalLabels(self, warning:bool=True, if not warning: return False msg = widgets.myMessageBox.warning( - self, 'No original labels', txt, + self, + "No original labels", + txt, ) - + return False else: return True @@ -65,7 +68,7 @@ def whitelistTrackOGagainstPreviousFrame_cb(self, signal_slot=None): if not self.whitelistCheckOriginalLabels(): return old_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] - prev_cell_IDs = posData.allData_li[frame_i-1]['IDs'] + prev_cell_IDs = posData.allData_li[frame_i - 1]["IDs"] self.whitelistTrackOGCurr(against_prev=True) new_cell_IDs = posData.whitelist.originalLabsIDs[frame_i] @@ -73,12 +76,12 @@ def whitelistTrackOGagainstPreviousFrame_cb(self, signal_slot=None): new_IDs = new_IDs & set(prev_cell_IDs) self.whitelistUpdateLab( - track_og_curr=False, IDs_to_add=new_IDs, + track_og_curr=False, + IDs_to_add=new_IDs, ) def whitelistLoadOGLabs_cb(self): - """Generates a dialog to load the original (not whitelisted) labels - """ + """Generates a dialog to load the original (not whitelisted) labels""" posData = self.data[self.pos_i] curr_seg_path = posData.segm_npz_path @@ -86,33 +89,36 @@ def whitelistLoadOGLabs_cb(self): custom_first = f"{segmFilename[:-4]}_not_whitelisted.npz" images_path = posData.images_path existingEndnames = [ - files for files in os.listdir(images_path) if files.endswith('.npz') + files for files in os.listdir(images_path) if files.endswith(".npz") ] if custom_first not in existingEndnames: custom_first = None infoText = html_utils.paragraph( - 'Select the segmentation file containing the original labels ' + "Select the segmentation file containing the original labels " 'of the objects. Pleae note that the current saved "original" ' - 'labels will be replaced with the new ones, but the filtered ' - 'labels will be kept.' + "labels will be replaced with the new ones, but the filtered " + "labels will be kept." ) win = apps.SelectSegmFileDialog( - existingEndnames, images_path, parent=self, - basename=posData.basename, infoText=infoText, - custom_first=custom_first + existingEndnames, + images_path, + parent=self, + basename=posData.basename, + infoText=infoText, + custom_first=custom_first, ) win.exec_() if win.cancel: - self.logger.info('Loading original labels canceled.') + self.logger.info("Loading original labels canceled.") return selected = win.selectedItemText - self.logger.info(f'Loading original labels from {selected}...') + self.logger.info(f"Loading original labels from {selected}...") self.whitelistLoadOGLabs(selected) @disableWindow - def whitelistLoadOGLabs(self, selected:str): + def whitelistLoadOGLabs(self, selected: str): """Loads the original labels from the selected files Parameters @@ -125,12 +131,12 @@ def whitelistLoadOGLabs(self, selected:str): selected_path = os.path.join(images_path, selected) posData.whitelist.loadOGLabs(selected_path) - + self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) @exception_handler @disableWindow - def whitelistViewOGIDs(self, checked:bool): + def whitelistViewOGIDs(self, checked: bool): """Switch between selected and original labels. Uses self.viewOriginalLabels to see what has to be done. @@ -141,29 +147,29 @@ def whitelistViewOGIDs(self, checked:bool): """ switch_to_og = checked and not self.viewOriginalLabels switch_to_seg = not checked and self.viewOriginalLabels - + if not switch_to_og and not switch_to_seg: return posData = self.data[self.pos_i] if posData.whitelist is None: return - + if posData.whitelist._debug: - printl('whitelistViewOGIDs', checked) - + printl("whitelistViewOGIDs", checked) + frame_i = posData.frame_i if frame_i > 0: - frames_range = [frame_i-1, frame_i] + frames_range = [frame_i - 1, frame_i] else: frames_range = [frame_i] self.store_data(autosave=False) - + if not self.whitelistCheckOriginalLabels(): return if switch_to_og: - self.setFrameNavigationDisabled(True, why='Viewing original labels') + self.setFrameNavigationDisabled(True, why="Viewing original labels") self.viewOriginalLabels = True for i in frames_range: @@ -174,15 +180,21 @@ def whitelistViewOGIDs(self, checked:bool): IDs = posData.IDs og_frame = posData.whitelist.originalLabs[i].copy() - IDs_to_uppdate = posData.whitelist.whitelistIDs[i] & posData.whitelist.originalLabsIDs[i] + IDs_to_uppdate = ( + posData.whitelist.whitelistIDs[i] + & posData.whitelist.originalLabsIDs[i] + ) if IDs_to_uppdate: mask = np.isin(og_frame, list(IDs_to_uppdate)) og_frame[mask] = 0 mask = np.isin(posData.lab, list(IDs_to_uppdate)) og_frame[mask] = posData.lab[mask] - - IDs_to_add = posData.whitelist.whitelistIDs[i] - posData.whitelist.originalLabsIDs[i] + + IDs_to_add = ( + posData.whitelist.whitelistIDs[i] + - posData.whitelist.originalLabsIDs[i] + ) if IDs_to_add: mask = np.isin(posData.lab, list(IDs_to_add)) og_frame[mask] = posData.lab[mask] @@ -192,15 +204,19 @@ def whitelistViewOGIDs(self, checked:bool): self.store_data(autosave=False) if frame_i > 0: - missing_IDs = set(posData.IDs) - set(posData.allData_li[frame_i-1]['IDs']) - self.trackManuallyAddedObject(missing_IDs,isNewID=True, wl_update=False) + missing_IDs = set(posData.IDs) - set( + posData.allData_li[frame_i - 1]["IDs"] + ) + self.trackManuallyAddedObject( + missing_IDs, isNewID=True, wl_update=False + ) self.setAllTextAnnotations() self.updateAllImages() elif switch_to_seg: self.viewOriginalLabels = False - self.setFrameNavigationDisabled(False, why='Viewing original labels') + self.setFrameNavigationDisabled(False, why="Viewing original labels") for i in frames_range: posData.frame_i = i @@ -217,7 +233,7 @@ def whitelistViewOGIDs(self, checked:bool): # self.whitelistTrackCurrOG() self.update_rp(wl_update=False) self.store_data(autosave=False) - self.whitelistUpdateLab(frame_i=i) #has update_rp and store data + self.whitelistUpdateLab(frame_i=i) # has update_rp and store data self.setAllTextAnnotations() self.updateAllImages() @@ -226,7 +242,7 @@ def whitelistSetViewOGIDsToggle(self, checked: bool): This also updates the self.viewOriginalLabels variable. !!! Doesn't change the actually displayed labels, use self.whitelistViewOGIDs to do that.!!! - + Parameters ---------- checked : bool @@ -248,9 +264,9 @@ def whitelistAddNewIDsToggled(self, checked: bool): """ self.addNewIDsWhitelistToggle = checked if checked: - self.df_settings.at['addNewIDsWhitelistToggle', 'value'] = 'Yes' + self.df_settings.at["addNewIDsWhitelistToggle", "value"] = "Yes" else: - self.df_settings.at['addNewIDsWhitelistToggle', 'value'] = 'No' + self.df_settings.at["addNewIDsWhitelistToggle", "value"] = "No" self.df_settings.to_csv(self.settings_csv_path) if checked: self.whitelistAddNewIDs(ignore_not_first_time=True) @@ -258,9 +274,9 @@ def whitelistAddNewIDsToggled(self, checked: bool): self.updateAllImages() self.whitelistIDsUpdateText() - def whitelistAddNewIDs(self, ignore_not_first_time:bool=False): + def whitelistAddNewIDs(self, ignore_not_first_time: bool = False): """Function which adds new IDs to the whitelist, based on the original labels. - It will check if the frame is visited the first time, unless + It will check if the frame is visited the first time, unless ignore_not_first_time is True. It does nothing if self.addNewIDsWhitelistToggle is False. !!!Careful, does not change the lab, just the whitelist!!! @@ -268,16 +284,16 @@ def whitelistAddNewIDs(self, ignore_not_first_time:bool=False): Parameters ---------- ignore_not_first_time : bool, optional - Weather it should be checked if the frame is visited + Weather it should be checked if the frame is visited the first time, by default False """ - mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': + mode = self.modeComboBox.currentText() + if mode != "Segmentation and Tracking": return - + if not self.addNewIDsWhitelistToggle: return - + posData = self.data[self.pos_i] if posData.whitelist is None: return @@ -285,32 +301,35 @@ def whitelistAddNewIDs(self, ignore_not_first_time:bool=False): debug = posData.whitelist._debug if debug: - printl('whitelistAddNewIDs') + printl("whitelistAddNewIDs") posData = self.data[self.pos_i] frame_i = posData.frame_i - + if self.get_last_tracked_i() > frame_i and not ignore_not_first_time: return - + if frame_i == 0: return - if self.whitelistAddNewIDsFrame is not None and frame_i == self.whitelistAddNewIDsFrame: + if ( + self.whitelistAddNewIDsFrame is not None + and frame_i == self.whitelistAddNewIDsFrame + ): return - + self.whitelistAddNewIDsFrame = frame_i curr_lab = self.get_curr_lab() - posData.whitelist.addNewIDs(frame_i=frame_i, - allData_li=posData.allData_li, - IDs_curr=posData.IDs, - curr_lab=curr_lab) - + posData.whitelist.addNewIDs( + frame_i=frame_i, + allData_li=posData.allData_li, + IDs_curr=posData.IDs, + curr_lab=curr_lab, + ) - def whitelistIDsAccepted(self, - whitelistIDs: Set[int] | List[int]): + def whitelistIDsAccepted(self, whitelistIDs: Set[int] | List[int]): """Function which is called when the user accepts a whitelist. Also initializes the whitelist if it is not already initialized. (Aka not loaded) @@ -324,8 +343,8 @@ def whitelistIDsAccepted(self, self.whitelistIDsToolbar.viewOGToggle.setCheckable(True) self.whitelistSetViewOGIDsToggle(False) - self.setFrameNavigationDisabled(False, why='Viewing original labels') - + self.setFrameNavigationDisabled(False, why="Viewing original labels") + self.store_data(autosave=False) posData = self.data[self.pos_i] @@ -334,14 +353,14 @@ def whitelistIDsAccepted(self, posData.whitelist = Whitelist( total_frames=posData.SizeT, ) - + if posData.whitelist._debug: - printl('whitelistIDsAccepted', whitelistIDs) + printl("whitelistIDsAccepted", whitelistIDs) whitelistIDs = set(whitelistIDs) IDs_curr = set(posData.IDs) - + posData.whitelist.IDsAccepted( whitelistIDs, segm_data=posData.segm_data, @@ -349,12 +368,11 @@ def whitelistIDsAccepted(self, allData_li=posData.allData_li, IDs_curr=IDs_curr, curr_lab=posData.lab, - ) - - # self.whitelistPropagateIDs(new_whitelist=whitelistIDs, - # try_create_new_whitelists=True, - # only_future_frames=True, + + # self.whitelistPropagateIDs(new_whitelist=whitelistIDs, + # try_create_new_whitelists=True, + # only_future_frames=True, # force_not_dynamic_update=True, # update_lab=True # ) @@ -363,18 +381,21 @@ def whitelistIDsAccepted(self, self.whitelistIDsUpdateText() self.keepIDsTempLayerLeft.clear() - def whitelistUpdateLab(self, frame_i: int=None, - track_og_curr=False, new_frame:bool=False, - IDs_to_add:List[int] | Set[int]=None, - IDs_to_remove:List[int]|Set[int]=None, - ): + def whitelistUpdateLab( + self, + frame_i: int = None, + track_og_curr=False, + new_frame: bool = False, + IDs_to_add: List[int] | Set[int] = None, + IDs_to_remove: List[int] | Set[int] = None, + ): # this should also work for 3D i think... """Updates the displayed lab based on the whitelist. Parameters ---------- frame_i : int, optional - frame which should be updated. If not provided, + frame which should be updated. If not provided, uses posData.frame_i, by default None track_og_curr : bool, optional if True, will track the original current IDs, by default False @@ -390,22 +411,22 @@ def whitelistUpdateLab(self, frame_i: int=None, if benchmark: ts = [time.perf_counter()] titles = [ - '', - 'store_data', - 'whitelistSetViewOGIDsToggle', - 'get_data', - 'get what to add/remove', - 'track_og_curr', - 'get current lab', - 'add/remove IDs', - 'store data', - 'update images', - ] - + "", + "store_data", + "whitelistSetViewOGIDsToggle", + "get_data", + "get what to add/remove", + "track_og_curr", + "get current lab", + "add/remove IDs", + "store data", + "update images", + ] + mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': + if mode != "Segmentation and Tracking": return - + posData = self.data[self.pos_i] if posData.whitelist is None: return @@ -417,23 +438,24 @@ def whitelistUpdateLab(self, frame_i: int=None, og_frame_i = posData.frame_i posData.frame_i = frame_i # getting data is handles later in the code - + debug = posData.whitelist._debug if debug: - printl('whitelistUpdateLab', frame_i, og_frame_i) + printl("whitelistUpdateLab", frame_i, og_frame_i) from . import debugutils + debugutils.print_call_stack() if benchmark: ts.append(time.perf_counter()) - self.whitelistSetViewOGIDsToggle(False) ### + self.whitelistSetViewOGIDsToggle(False) ### if benchmark: ts.append(time.perf_counter()) - + if self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): - og_lab = posData.whitelist.originalLabs[frame_i] ### + og_lab = posData.whitelist.originalLabs[frame_i] ### else: og_lab = None if benchmark: @@ -456,50 +478,48 @@ def whitelistUpdateLab(self, frame_i: int=None, if benchmark: ts.append(time.perf_counter()) - + ### - if not missing_IDs and not to_be_removed_IDs: # nothing to do + if not missing_IDs and not to_be_removed_IDs: # nothing to do if og_frame_i != frame_i: posData.frame_i = og_frame_i if got_data and og_frame_i != frame_i: self.get_data() if benchmark: - print('No IDs to add/remove') + print("No IDs to add/remove") ts.append(time.perf_counter()) - indx = titles.index('track_og_curr') - titles[indx + 1] = 'store_data' + indx = titles.index("track_og_curr") + titles[indx + 1] = "store_data" time_taken = time.perf_counter() - ts[0] - print(f'\nTotal time for whitelistUpdateLab: {time_taken:.2f}s') + print(f"\nTotal time for whitelistUpdateLab: {time_taken:.2f}s") for i in range(1, len(ts)): - time_taken = ts[i] - ts[i-1] - print(f'Time taken for {titles[i]}: {time_taken:.2f}s') - print('') + time_taken = ts[i] - ts[i - 1] + print(f"Time taken for {titles[i]}: {time_taken:.2f}s") + print("") return - + if not got_data and og_frame_i != frame_i: self.get_data() got_data = True - + if benchmark: ts.append(time.perf_counter()) ### if missing_IDs and track_og_curr and not new_frame: - self.whitelistTrackOGCurr(frame_i=frame_i, - lab = posData.lab, - rp = posData.rp) - + self.whitelistTrackOGCurr(frame_i=frame_i, lab=posData.lab, rp=posData.rp) + missing_IDs = np.array(missing_IDs, dtype=np.int32) to_be_removed_IDs = np.array(to_be_removed_IDs, dtype=np.int32) if debug: printl(missing_IDs, to_be_removed_IDs) - curr_lab = posData.lab # or curr_lab = posData.lab??? + curr_lab = posData.lab # or curr_lab = posData.lab??? # convert values to int if they are not already if curr_lab is None: try: - curr_lab = posData.allData_li[frame_i]['labels'].copy() + curr_lab = posData.allData_li[frame_i]["labels"].copy() except: pass if curr_lab is None: @@ -508,22 +528,24 @@ def whitelistUpdateLab(self, frame_i: int=None, except: pass if curr_lab is None: - printl('No current lab?') + printl("No current lab?") curr_lab = np.zeros_like(posData.segm_data[0]) curr_lab = curr_lab.astype(np.int32) if benchmark: ts.append(time.perf_counter()) if missing_IDs.size > 0 and og_lab is not None: - mask = np.isin(og_lab, missing_IDs) # add missing_IDs + mask = np.isin(og_lab, missing_IDs) # add missing_IDs curr_lab[mask] = og_lab[mask] if to_be_removed_IDs.size > 0: - curr_lab[np.isin(curr_lab, to_be_removed_IDs)] = 0 # remove to_be_removed_IDs + curr_lab[np.isin(curr_lab, to_be_removed_IDs)] = ( + 0 # remove to_be_removed_IDs + ) if benchmark: ts.append(time.perf_counter()) - + posData.lab = curr_lab self.update_rp(wl_update=False) @@ -534,54 +556,56 @@ def whitelistUpdateLab(self, frame_i: int=None, if og_frame_i != frame_i: posData.frame_i = og_frame_i self.get_data() - + self.updateAllImages() self.setAllTextAnnotations() if benchmark: ts.append(time.perf_counter()) time_taken = time.perf_counter() - ts[0] - print(f'\nTotal time for whitelistUpdateLab: {time_taken:.2f}s') + print(f"\nTotal time for whitelistUpdateLab: {time_taken:.2f}s") for i in range(1, len(ts)): - time_taken = ts[i] - ts[i-1] - print(f'Time taken for {titles[i]}: {time_taken:.2f}s') - print('') + time_taken = ts[i] - ts[i - 1] + print(f"Time taken for {titles[i]}: {time_taken:.2f}s") + print("") def whitelistIDsUpdateText(self): - """Updates the text. Carefull, triggers whitelistLineEdit.textChanged! - """ + """Updates the text. Carefull, triggers whitelistLineEdit.textChanged!""" mode = self.modeComboBox.currentText() - if mode != 'Segmentation and Tracking': + if mode != "Segmentation and Tracking": return posData = self.data[self.pos_i] if posData.whitelist is None: return - + if posData.whitelist._debug: - printl('whitelistIDsUpdateText') - + printl("whitelistIDsUpdateText") + frame_i = posData.frame_i whitelist = posData.whitelist.get(frame_i=frame_i) self.whitelistIDsToolbar.whitelistLineEdit.setText(whitelist) - def whitelistTrackOGCurr(self, frame_i:int=None, - against_prev:bool=False, - lab:np.ndarray=None, - rp:list=None, - IDs: Set[int] | List[int] =None): - """Track the original labels in relation to the current (whitelisted) + def whitelistTrackOGCurr( + self, + frame_i: int = None, + against_prev: bool = False, + lab: np.ndarray = None, + rp: list = None, + IDs: Set[int] | List[int] = None, + ): + """Track the original labels in relation to the current (whitelisted) labels. Parameters Parameters ---------- frame_i : int, optional - frame_i to be tracked, posData.frame_i if not provided, + frame_i to be tracked, posData.frame_i if not provided, by default None against_prev : bool, optional - if the original frame should be tracked against frame_i-1. + if the original frame should be tracked against frame_i-1. Cannot be used with rp or lab, by default False lab : np.ndarray, optional lab to be tracked against, by default None @@ -604,24 +628,26 @@ def whitelistTrackOGCurr(self, frame_i:int=None, if debug: from . import debugutils + debugutils.print_call_stack(depth=2) - printl('whitelistTrackOGCurr', against_prev) + printl("whitelistTrackOGCurr", against_prev) if against_prev and (rp is not None or lab is not None): - raise ValueError('Cannot provide both rp and lab when tracking' - ' against previous frame.' - 'Instead only provide rp and lab, and dont set against_prev.') + raise ValueError( + "Cannot provide both rp and lab when tracking" + " against previous frame." + "Instead only provide rp and lab, and dont set against_prev." + ) if frame_i is None: frame_i = posData.frame_i if against_prev and frame_i == 0: return - - if not self.whitelistCheckOriginalLabels(warning=False, - frame_i=frame_i): + + if not self.whitelistCheckOriginalLabels(warning=False, frame_i=frame_i): if debug: - printl('No original labels, cannot track.') + printl("No original labels, cannot track.") return og_frame_i = posData.frame_i @@ -629,14 +655,14 @@ def whitelistTrackOGCurr(self, frame_i:int=None, if lab is not None and not rp: rp = skimage.measure.regionprops(lab) - + changed_frame = False if lab is None: if debug: - printl('No lab and no rp provided.') + printl("No lab and no rp provided.") if against_prev: - rp = posData.allData_li[frame_i-1]['regionprops'] - lab = posData.allData_li[frame_i-1]['labels'] + rp = posData.allData_li[frame_i - 1]["regionprops"] + lab = posData.allData_li[frame_i - 1]["labels"] else: if frame_i != og_frame_i: self.store_data(autosave=False) @@ -649,39 +675,44 @@ def whitelistTrackOGCurr(self, frame_i:int=None, og_rp = skimage.measure.regionprops(og_lab) # lab = lab.copy() - denom_overlap_matrix = 'union' if not against_prev else 'area_prev' + denom_overlap_matrix = "union" if not against_prev else "area_prev" og_lab = CellACDC_tracker.track_frame( - lab, rp, og_lab, og_rp, - denom_overlap_matrix=denom_overlap_matrix, - posData = posData, - setBrushID_func=self.setBrushID, - IDs=IDs, - # assign_unique_new_IDs=False, + lab, + rp, + og_lab, + og_rp, + denom_overlap_matrix=denom_overlap_matrix, + posData=posData, + setBrushID_func=self.setBrushID, + IDs=IDs, + # assign_unique_new_IDs=False, ) posData.whitelist.originalLabs[frame_i] = og_lab - posData.whitelist.originalLabsIDs[frame_i] = {obj.label for obj in skimage.measure.regionprops(og_lab)} + posData.whitelist.originalLabsIDs[frame_i] = { + obj.label for obj in skimage.measure.regionprops(og_lab) + } if changed_frame: posData.frame_i = og_frame_i self.get_data() - def whitelistTrackCurrOG(self, frame_i:int=None, against_prev:bool=False): + def whitelistTrackCurrOG(self, frame_i: int = None, against_prev: bool = False): """Track the current (whitelisted) labels in relation to the original labels. Parameters ---------- frame_i : int, optional frame_i to be tracked, posData.frame_i if not provided, by default None against_prev : bool, optional - if the original frame should be tracked against frame_i-1. + if the original frame should be tracked against frame_i-1. """ posData = self.data[self.pos_i] if posData.whitelist is None: return if posData.whitelist._debug: - printl('whitelistTrackCurrOG', frame_i, against_prev) + printl("whitelistTrackCurrOG", frame_i, against_prev) if frame_i is None: frame_i = posData.frame_i @@ -694,30 +725,34 @@ def whitelistTrackCurrOG(self, frame_i:int=None, against_prev:bool=False): self.store_data(autosave=False) posData.frame_i = frame_i self.get_data() - + lab = posData.lab rp = posData.rp - - if not self.whitelistCheckOriginalLabels(warning=False, - frame_i=frame_i if not against_prev else frame_i-1): + + if not self.whitelistCheckOriginalLabels( + warning=False, frame_i=frame_i if not against_prev else frame_i - 1 + ): if posData.whitelist._debug: - printl('No original labels, cannot track.') + printl("No original labels, cannot track.") return if against_prev: - og_lab = posData.whitelist.originalLabs[frame_i-1] + og_lab = posData.whitelist.originalLabs[frame_i - 1] else: og_lab = posData.whitelist.originalLabs[frame_i] og_rp = skimage.measure.regionprops(og_lab) - denom_overlap_matrix = 'union' if not against_prev else 'area_prev' + denom_overlap_matrix = "union" if not against_prev else "area_prev" lab = CellACDC_tracker.track_frame( - og_lab, og_rp, lab, rp, + og_lab, + og_rp, + lab, + rp, denom_overlap_matrix=denom_overlap_matrix, - posData = posData, - setBrushID_func=self.setBrushID + posData=posData, + setBrushID_func=self.setBrushID, ) posData.lab = lab @@ -729,9 +764,11 @@ def whitelistTrackCurrOG(self, frame_i:int=None, against_prev:bool=False): posData.frame_i = og_frame self.get_data() - def whitelistSyncIDsOG(self, - frame_is: List[int]=None, - against_prev: bool=False,): + def whitelistSyncIDsOG( + self, + frame_is: List[int] = None, + against_prev: bool = False, + ): """Interates over the frames and calls whitelistTrackOGCurr for each frame. Parameters @@ -739,7 +776,7 @@ def whitelistSyncIDsOG(self, frame_is : List[int], optional list of frame_i, if None goes through all, by default None against_prev : bool, optional - if the original frame should be tracked against frame_i-1. + if the original frame should be tracked against frame_i-1. """ posData = self.data[self.pos_i] if frame_is is None: @@ -748,7 +785,7 @@ def whitelistSyncIDsOG(self, for frame_i in frame_is: self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=against_prev) - def whitelistInitNewFrames(self, frame_i:int=None, force:bool=False): + def whitelistInitNewFrames(self, frame_i: int = None, force: bool = False): """Initialize the whitelist for a new frame. The class whitelist keeps track of the init frames and doesnt try to init them again, unless forced. Does not init the class! @@ -774,53 +811,55 @@ def whitelistInitNewFrames(self, frame_i:int=None, force:bool=False): if frame_i is None: frame_i = posData.frame_i - + if posData.whitelist._debug: - printl('whitelistInitNewFrames', frame_i, force) + printl("whitelistInitNewFrames", frame_i, force) if frame_i not in posData.whitelist.initialized_i: self.whitelistTrackOGCurr(frame_i=frame_i, against_prev=True) new_frame, update_frames = posData.whitelist.initNewFrames( - frame_i=frame_i, force=force) + frame_i=frame_i, force=force + ) self.whitelistAddNewIDs() - return new_frame, update_frames + return new_frame, update_frames # @exec_time - def whitelistPropagateIDs(self, - new_whitelist: Set[int] | List[int] = None, - IDs_to_add: Set[int] = None, - IDs_to_remove: Set[int] = None, - frame_i: int = None, - try_create_new_whitelists: bool = False, - curr_frame_only: bool = False, - force_not_dynamic_update: bool = False, - only_future_frames: bool = True, - allow_only_current_IDs: bool = False, - track_og_curr: bool = True, - IDs_curr: Set[int] | List[int] = None, - index_lab_combo: Tuple[int, np.ndarray] = None, - curr_rp: list = None, - curr_lab: np.ndarray = None, - store_data: bool = True, - update_lab: bool = False, - ): + def whitelistPropagateIDs( + self, + new_whitelist: Set[int] | List[int] = None, + IDs_to_add: Set[int] = None, + IDs_to_remove: Set[int] = None, + frame_i: int = None, + try_create_new_whitelists: bool = False, + curr_frame_only: bool = False, + force_not_dynamic_update: bool = False, + only_future_frames: bool = True, + allow_only_current_IDs: bool = False, + track_og_curr: bool = True, + IDs_curr: Set[int] | List[int] = None, + index_lab_combo: Tuple[int, np.ndarray] = None, + curr_rp: list = None, + curr_lab: np.ndarray = None, + store_data: bool = True, + update_lab: bool = False, + ): """ Propagates whitelist IDs across frames in the dataset. (Doesnt update labs) Should also be called when viewing a new frame! This function updates whitelist. If curr_frame_only is True, it only updates the - whitelist of the current frame. If the frame changes, this function should be called + whitelist of the current frame. If the frame changes, this function should be called again to update the whitelist for the new frame (without this argument). It should also handle cases were this is not done, but this is less safe. Then, all the additions and removals are propagated to the other frames. - If force_not_dynamic_update is True, the function will propagate the entire whitelist to + If force_not_dynamic_update is True, the function will propagate the entire whitelist to frames, and not only the IDs which were added or removed. Hierarchy of arguments for current_IDs: 1. IDs_curr (if provided) - (2. index_lab_combo (if provided) (is also passed to not current frame only + (2. index_lab_combo (if provided) (is also passed to not current frame only propagation if that propagation is necessary, and used when the frame_i matches)) 3. curr_rp (if provided) 4. curr_lab (if provided) @@ -829,46 +868,46 @@ def whitelistPropagateIDs(self, Parameters ---------- new_whitelist : Set[int] | List[int], optional - A new set of whitelist IDs to replace the current whitelist. Cannot be + A new set of whitelist IDs to replace the current whitelist. Cannot be used together with `IDs_to_add` or `IDs_to_remove`, by default None. IDs_to_add : Set[int], optional A set of IDs to add to the current whitelist, by default None. IDs_to_remove : Set[int], optional A set of IDs to remove from the current whitelist, by default None. frame_i : int, optional - The frame index for the propagation. + The frame index for the propagation. If None, uses posData.frame_i, by default None. try_create_new_whitelists : bool, optional - If True, creates new whitelist entries for frames that do not already + If True, creates new whitelist entries for frames that do not already have them. Should only be necessary when its initialized, by default False. curr_frame_only : bool, optional - If True, only updates the whitelist for the current frame. + If True, only updates the whitelist for the current frame. (See description of function), by default False. force_not_dynamic_update : bool, optional - If True, disables dynamic updates to the whitelist. + If True, disables dynamic updates to the whitelist. (See description of function), by default False. only_future_frames : bool, optional If True, propagates changes only to future frames, by default True. allow_only_current_IDs : bool, optional - If True, only allows IDs that are present in the current frame + If True, only allows IDs that are present in the current frame to be added to the whitelist, by default True. track_og_curr : bool, optional If True, tracks the original labels in relation to the current (whitelisted) labels. This is done by calling whitelistTrackOGCurr. - If its a new frame, this is done in whitelistInitNewFrames against the + If its a new frame, this is done in whitelistInitNewFrames against the previous frame, by default True. IDs_curr : Set[int] | List[int], optional - A set of IDs for the current frame, if None, + A set of IDs for the current frame, if None, will be calculated from other stuff (see description), by default None. index_lab_combo : Tuple[int, np.ndarray], optional - Combination of frame_i and current frame, + Combination of frame_i and current frame, Used to get IDs_curr (see description), when the frame_i matches - (is also passed to not current frame only - propagation if that propagation is necessary, + (is also passed to not current frame only + propagation if that propagation is necessary, and used when the frame_i matches), by default None. curr_rp : list, optional - Region properties for the current frame. For IDs_curr. (see description), + Region properties for the current frame. For IDs_curr. (see description), by default None. curr_lab : np.ndarray, optional Labels for the current frame for IDs_curr. (see description), @@ -902,12 +941,12 @@ def whitelistPropagateIDs(self, This would also propagate the changes to all other frames. """ - #doesnt update the frame displayed, only wl - try: # safety XD + # doesnt update the frame displayed, only wl + try: # safety XD IDs_curr = IDs_curr.copy() except AttributeError: pass - + IDs_curr = set(IDs_curr) if IDs_curr is not None else None posData = self.data[self.pos_i] @@ -915,8 +954,9 @@ def whitelistPropagateIDs(self, debug = posData.whitelist._debug if posData.whitelist is not None else False if debug: - printl('Propagating IDs...') + printl("Propagating IDs...") from . import debugutils + debugutils.print_call_stack() printl(new_whitelist, IDs_to_add, IDs_to_remove) @@ -961,11 +1001,15 @@ def whitelistPropagateIDs(self, self.store_data(autosave=False) for frame_i, IDs_to_add, IDs_to_remove, new_frame in update_frames: - self.whitelistUpdateLab(frame_i=frame_i, track_og_curr=track_og_curr, - new_frame=new_frame, IDs_to_add=IDs_to_add, - IDs_to_remove=IDs_to_remove, ) + self.whitelistUpdateLab( + frame_i=frame_i, + track_og_curr=track_og_curr, + new_frame=new_frame, + IDs_to_add=IDs_to_add, + IDs_to_remove=IDs_to_remove, + ) - def whitelistIDs_cb(self, checked:bool): + def whitelistIDs_cb(self, checked: bool): """Callback for when the whitelist IDs button is checked or unchecked. Initialises the pointlayer and the whitelist IDs toolbar if checked. @@ -979,7 +1023,7 @@ def whitelistIDs_cb(self, checked:bool): self.disconnectLeftClickButtons() self.uncheckLeftClickButtons(self.whitelistIDsButton) self.connectLeftClickButtons() - + self.whitelistIDsToolbar.setVisible(checked) self.whitelistHighlightIDs(checked) self.whitelistIDsUpdateText() @@ -989,7 +1033,7 @@ def whitelistIDs_cb(self, checked:bool): self.setLostNewOldPrevIDs() self.updateAllImages() - def whitelistHighlightIDs(self, checked:bool=True): + def whitelistHighlightIDs(self, checked: bool = True): """Highlights the IDs in the current frame based on the whitelist. Parameters @@ -1000,30 +1044,29 @@ def whitelistHighlightIDs(self, checked:bool=True): if not checked: self.removeHighlightLabelID() return - + posData = self.data[self.pos_i] if posData.whitelist is None: - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() # not updated, only use in this context + if not hasattr(self, "tempWhitelistIDs"): + self.tempWhitelistIDs = set() # not updated, only use in this context current_whitelist = self.tempWhitelistIDs else: current_whitelist = self.tempWhitelistIDs else: - current_whitelist = posData.whitelist.get( - frame_i=posData.frame_i) - + current_whitelist = posData.whitelist.get(frame_i=posData.frame_i) + for ID in current_whitelist: self.highlightLabelID(ID) - - def whitelistIDsChanged(self, - whitelistIDs: Set[int] | List[int], - debug: bool=False): - """Callback for when the whitelist IDs are changed. + + def whitelistIDsChanged( + self, whitelistIDs: Set[int] | List[int], debug: bool = False + ): + """Callback for when the whitelist IDs are changed. This is called when the user changed the IDs in the whitelist IDs toolbar - (or when its programmatically changed, but if its not + (or when its programmatically changed, but if its not visible it should return instantly) - Will update the temp layer and also complain when IDs + Will update the temp layer and also complain when IDs are not valid/present in the current lab Parameters @@ -1035,28 +1078,30 @@ def whitelistIDsChanged(self, """ if not self.whitelistIDsButton.isChecked(): return - + posData = self.data[self.pos_i] if posData.whitelist: debug = posData.whitelist._debug if debug: - printl('whitelistIDsChanged', whitelistIDs) + printl("whitelistIDsChanged", whitelistIDs) if posData.whitelist is None: wl_init = False - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() # not updated, only use in this context + if not hasattr(self, "tempWhitelistIDs"): + self.tempWhitelistIDs = set() # not updated, only use in this context current_whitelist = self.tempWhitelistIDs else: current_whitelist = self.tempWhitelistIDs else: wl_init = True - current_whitelist = posData.whitelist.get( - frame_i=posData.frame_i) + current_whitelist = posData.whitelist.get(frame_i=posData.frame_i) current_whitelist_copy = current_whitelist.copy() - if not hasattr(posData, 'originalLabsIDs') or posData.whitelist.originalLabsIDs is None: + if ( + not hasattr(posData, "originalLabsIDs") + or posData.whitelist.originalLabsIDs is None + ): possible_IDs = posData.IDs.copy() else: if not self.whitelistCheckOriginalLabels(warning=False): @@ -1095,13 +1140,12 @@ def whitelistIDsChanged(self, # @exec_time def whitelistUpdateTempLayer(self): - """Updates the temp layer with the current whitelist IDs. - """ + """Updates the temp layer with the current whitelist IDs.""" if not self.whitelistIDsButton.isChecked(): self.keepIDsTempLayerLeft.clear() return - if not hasattr(self, 'keptLab'): + if not hasattr(self, "keptLab"): self.keptLab = np.zeros_like(self.currentLab2D) keptLab = self.keptLab else: @@ -1110,8 +1154,8 @@ def whitelistUpdateTempLayer(self): posData = self.data[self.pos_i] if posData.whitelist is None: - if not hasattr(self, 'tempWhitelistIDs'): - self.tempWhitelistIDs = set() # not updated, only use in this context + if not hasattr(self, "tempWhitelistIDs"): + self.tempWhitelistIDs = set() # not updated, only use in this context current_whitelist = self.tempWhitelistIDs else: current_whitelist = self.tempWhitelistIDs @@ -1130,4 +1174,4 @@ def whitelistUpdateTempLayer(self): keptLab[_slice][_objMask] = obj.label - self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) \ No newline at end of file + self.keepIDsTempLayerLeft.setImage(keptLab, autoLevels=False) diff --git a/cellacdc/mixins/window_events.py b/cellacdc/mixins/window_events.py index e7dadb7e2..1f1a0ae69 100644 --- a/cellacdc/mixins/window_events.py +++ b/cellacdc/mixins/window_events.py @@ -29,6 +29,7 @@ from .app_shell import AppShell from .frame_navigation import FrameNavigation + class WindowEvents(AppShell, FrameNavigation): """Extracted from guiWin.""" @@ -38,7 +39,7 @@ def _resizeLeaveSpaceTerminalBelow(self): top = geometry.top() width = geometry.width() height = geometry.height() - self.setGeometry(left, top+10, width, height-200) + self.setGeometry(left, top + 10, width, height - 200) def _resizeSlidersArea(self): self.navigateScrollBar.setFixedHeight(self.newHeight) @@ -59,10 +60,10 @@ def _resizeSlidersArea(self): except: pass checkBoxStyleSheet = ( - 'QCheckBox::indicator {' - f'width: {self.newCheckBoxesHeight}px;' - f'height: {self.newCheckBoxesHeight}px' - '}' + "QCheckBox::indicator {" + f"width: {self.newCheckBoxesHeight}px;" + f"height: {self.newCheckBoxesHeight}px" + "}" ) for i in range(self.annotOptionsLayout.count()): widget = self.annotOptionsLayout.itemAt(i).widget() @@ -85,10 +86,7 @@ def askCloseAllWindows(self): If you proceed, the other windows will be closed too.
    """) msg = widgets.myMessageBox(wrapText=False) - msg.warning( - self, 'Open windows', txt, - buttonsTexts=('Cancel', 'Ok, close now') - ) + msg.warning(self, "Open windows", txt, buttonsTexts=("Cancel", "Ok, close now")) return msg.cancel def changeEvent(self, event): @@ -101,23 +99,23 @@ def changeRightClickToLeftOnMac(self, mouseEvent): button = mouseEvent.button() if not is_mac: return button - + delObjKeySequence, delObjQtButton = self.delObjAction if delObjKeySequence is None: return button - - if not delObjKeySequence.toString() == 'Control': + + if not delObjKeySequence.toString() == "Control": return button - + if button != Qt.MouseButton.RightButton: return button - + if delObjQtButton == Qt.MouseButton.LeftButton: - # On mac, pressing "Control" and clicking with left button changes - # it to a right click button --> here, left click is required for + # On mac, pressing "Control" and clicking with left button changes + # it to a right click button --> here, left click is required for # delete object --> force return of left click return Qt.MouseButton.LeftButton - + return button def checkOverlayToolbuttonClicked(self, event): @@ -129,13 +127,13 @@ def checkOverlayToolbuttonClicked(self, event): success = True except Exception as e: # printl(traceback.format_exc()) - success = False + success = False return success def checkSetDelObjActionActive(self, event): if self.delObjAction is None and self.is_win: return - + if self.delObjAction is None: # On mac we check for Key_Control if event.key() == Qt.Key_Control: @@ -143,7 +141,7 @@ def checkSetDelObjActionActive(self, event): return delObjKeySequence, delObjQtButton = self.delObjAction - keySequenceText = widgets.QKeyEventToString(event).rstrip('+') + keySequenceText = widgets.QKeyEventToString(event).rstrip("+") if delObjKeySequence is None: # self.delObjToolAction.setChecked(True) @@ -155,11 +153,11 @@ def checkSetDelObjActionActive(self, event): keySequenceText = widgets.macShortcutToWindows(keySequenceText) # printl( - # delObjKeySequence.toString(), - # keySequenceText, + # delObjKeySequence.toString(), + # keySequenceText, # delObjKeySequenceText # ) - + if keySequenceText == delObjKeySequenceText: self.delObjToolAction.setChecked(True) @@ -168,34 +166,34 @@ def checkTriggerKeyPressShortcuts(self, event: QKeyEvent): isEraserKey = event.key() == self.eraserButton.keyPressShortcut if isBrushKey or isEraserKey: return isBrushKey, isEraserKey - + modifierText = widgets.modifierKeyToText(event.modifiers()) for widget in self.widgetsWithShortcut.values(): - if not hasattr(widget, 'keyPressShortcut'): + if not hasattr(widget, "keyPressShortcut"): continue - + if event.key() == widget.keyPressShortcut: if widget.isCheckable(): widget.setChecked(True) else: - widget.trigger() + widget.trigger() continue - + shortcutText = widget.keyPressShortcut.toString() try: - mod, key = shortcutText.split('+') + mod, key = shortcutText.split("+") if modifierText == mod and event.key() == QKeySequence(key): widget.trigger() - + except Exception as e: pass - + return isBrushKey, isEraserKey def clearMemory(self): - if not hasattr(self, 'data'): + if not hasattr(self, "data"): return - self.logger.info('Clearing memory...') + self.logger.info("Clearing memory...") for posData in self.data: try: del posData.img_data @@ -225,10 +223,10 @@ def closeEvent(self, event): if cancel: event.ignore() return - + self.onEscape() self.saveWindowGeometry() - + if self.newWindows: cancel = self.askCloseAllWindows() if cancel: @@ -242,18 +240,19 @@ def closeEvent(self, event): self.slideshowWin.close() if self.ccaTableWin is not None: self.ccaTableWin.close() - + proceed = self.askSaveOnClosing(event) if not proceed: event.ignore() return self.autoSaveClose() - + if self.autoSaveActiveWorkers: progressWin = apps.QDialogWorkerProgress( - title='Closing autosaving worker', parent=self, - pbarDesc='Closing autosaving worker...' + title="Closing autosaving worker", + parent=self, + pbarDesc="Closing autosaving worker...", ) progressWin.show(self.app) progressWin.mainPbar.setMaximum(0) @@ -263,34 +262,34 @@ def closeEvent(self, event): self.waitCloseAutoSaveWorkerLoop.exec_() progressWin.workerFinished = True progressWin.close() - + self.stopPreprocWorker() self.stopCombineWorker() self.stopCcaIntegrityCheckerWorker() - + # Close the inifinte loop of the thread if self.lazyLoader is not None: self.lazyLoader.exit = True self.lazyLoaderWaitCond.wakeAll() self.waitReadH5cond.wakeAll() - + if self.storeStateWorker is not None: # Close storeStateWorker self.storeStateWorker._stop() while self.storeStateWorker.isFinished: time.sleep(0.05) - + # Block main thread while separate threads closes time.sleep(0.1) self.clearMemory() - self.logger.info('Closing GUI logger...') + self.logger.info("Closing GUI logger...") self.logger.close() - + if self.lazyLoader is None: self.sigClosed.emit(self) - + gc.collect() def doubleKeySpacebarTimerCallback(self): @@ -313,7 +312,7 @@ def doubleKeyTimerCallBack(self): if isBrushChecked and self.uncheck: self.Button.setChecked(False) c = self.defaultToolBarButtonColor - self.Button.setStyleSheet(f'background-color: {c}') + self.Button.setStyleSheet(f"background-color: {c}") def doubleRightClickTimerCallBack(self): if self.isDoubleRightClick: @@ -321,16 +320,16 @@ def doubleRightClickTimerCallBack(self): return self.doubleRightClickTimeElapsed = True self.countRightClicks = 0 - + # Time to double right click on img1 expired --> single right-click - self.gui_imgGradShowContextMenu(*self._img1_click_xy) + self.gui_imgGradShowContextMenu(*self._img1_click_xy) def dragEnterEvent(self, event): file_path = event.mimeData().urls()[0].toLocalFile() if os.path.isdir(file_path): exp_path = file_path basename = os.path.basename(file_path) - if basename.find('Position_')!=-1 or basename=='Images': + if basename.find("Position_") != -1 or basename == "Images": event.acceptProposedAction() else: event.ignore() @@ -360,8 +359,8 @@ def enterEvent(self, event): mainWinTop = mainWinGeometry.top() mainWinWidth = mainWinGeometry.width() mainWinHeight = mainWinGeometry.height() - mainWinRight = mainWinLeft+mainWinWidth - mainWinBottom = mainWinTop+mainWinHeight + mainWinRight = mainWinLeft + mainWinWidth + mainWinBottom = mainWinTop + mainWinHeight slideshowWinGeometry = self.slideshowWin.geometry() slideshowWinLeft = slideshowWinGeometry.left() @@ -370,15 +369,14 @@ def enterEvent(self, event): slideshowWinHeight = slideshowWinGeometry.height() # Determine if overlap - overlap = ( - (slideshowWinTop < mainWinBottom) and - (slideshowWinLeft < mainWinRight) + overlap = (slideshowWinTop < mainWinBottom) and ( + slideshowWinLeft < mainWinRight ) autoActivate = ( - self.isDataLoaded and not - overlap and not - posData.disableAutoActivateViewerWindow + self.isDataLoaded + and not overlap + and not posData.disableAutoActivateViewerWindow ) if autoActivate: @@ -394,70 +392,67 @@ def gui_createCursors(self): pixmap = QPixmap(":addDelPolyLineRoi_cursor.svg") self.polyLineRoiCursor = QCursor(pixmap, 16, 16) - + pixmap = QPixmap(":cross_cursor.svg") self.addPointsCursor = QCursor(pixmap, 16, 16) def keyDownCallback( - self, isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive - ): + self, isBrushActive, isWandActive, isExpandLabelActive, isLabelRoiCircActive + ): isAutoPilotActive = ( self.autoPilotZoomToObjToggle.isChecked() and self.autoPilotZoomToObjToolbar.isVisible() ) if isBrushActive: brushSize = self.brushSizeSpinbox.value() - self.brushSizeSpinbox.setValue(brushSize-1) + self.brushSizeSpinbox.setValue(brushSize - 1) elif isWandActive: wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() - self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance-1) + self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance - 1) elif isExpandLabelActive: self.expandLabel(dilation=False) self.expandFootprintSize += 1 elif isLabelRoiCircActive: val = self.labelRoiCircularRadiusSpinbox.value() - self.labelRoiCircularRadiusSpinbox.setValue(val-1) + self.labelRoiCircularRadiusSpinbox.setValue(val - 1) elif isAutoPilotActive: - self.pointsLayerAutoPilot('prev') + self.pointsLayerAutoPilot("prev") elif self.isNavigateActionOnNextFrame(): posData = self.data[self.pos_i] - self.rightImageFramesScrollbar.setValue(posData.frame_i+2) + self.rightImageFramesScrollbar.setValue(posData.frame_i + 2) else: self.zSliceScrollBar.triggerAction( QAbstractSlider.SliderAction.SliderSingleStepSub ) def keyPressCheckSetSpinboxValue(self, event, spinbox): - """Check if the key pressed is a digit and set the spinbox value + """Check if the key pressed is a digit and set the spinbox value accordingly.""" try: n = int(event.text()) if self.typingEditID: - value = int(f'{spinbox.value()}{n}') + value = int(f"{spinbox.value()}{n}") else: value = n self.typingEditID = True spinbox.setValue(value) - + try: spinbox.timer.stop() except Exception as err: pass - + spinbox.timer = QTimer(spinbox) - spinbox.timer.timeout.connect( - self.editingSpinboxValueTimerCallback - ) + spinbox.timer.timeout.connect(self.editingSpinboxValueTimerCallback) spinbox.timer.start(2000) spinbox.timer.setSingleShot(True) success = True except Exception as e: # printl(traceback.format_exc()) - success = False + success = False return success - def keyPressEvent(self, ev): + def keyPressEvent(self, ev): ctrl = ev.modifiers() == Qt.ControlModifier if ctrl and ev.key() == Qt.Key_D: self.resizeLeaveSpaceTerminalBelow() @@ -466,6 +461,7 @@ def keyPressEvent(self, ev): if ev.key() == Qt.Key_Q and self.debug: try: from . import _q_debug + _q_debug.q_debug(self) except Exception as err: printl(traceback.format_exc()) @@ -474,7 +470,7 @@ def keyPressEvent(self, ev): if not self.isDataLoaded: self.logger.warning( - 'Data not loaded yet. Key pressing events are not connected.' + "Data not loaded yet. Key pressing events are not connected." ) return @@ -482,102 +478,103 @@ def keyPressEvent(self, ev): if not ctrl: self.wasCtrlPressedFirstTime = True self.onCtrlPressedFirstTime() - + if ev.key() == Qt.Key_PageDown: self.onKeyPageDown() - + if ev.key() == Qt.Key_PageUp: self.onKeyPageUp() - + if ev.key() == Qt.Key_Home: self.onKeyHome() - + if ev.key() == Qt.Key_End: self.onKeyEnd() - + modifiers = ev.modifiers() isAltModifier = modifiers == Qt.AltModifier isCtrlModifier = modifiers == Qt.ControlModifier isShiftModifier = modifiers == Qt.ShiftModifier - + self.checkSetDelObjActionActive(ev) - + self.isZmodifier = ( - ev.key()== Qt.Key_Z and not isAltModifier - and not isCtrlModifier and not isShiftModifier + ev.key() == Qt.Key_Z + and not isAltModifier + and not isCtrlModifier + and not isShiftModifier ) if isShiftModifier: if self.brushButton.isChecked(): # Force default brush symbol with shift down self.setHoverToolSymbolColor( - 1, 1, self.ax2_BrushCirclePen, + 1, + 1, + self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush, - ID=0 + self.brushButton, + brush=self.ax2_BrushCircleBrush, + ID=0, ) if self.isSegm3D: - self.changeBrushID() - + self.changeBrushID() + isAnyModifier = isAltModifier or isCtrlModifier or isShiftModifier if not isAnyModifier and self.overlayButton.isChecked(): isButtonClicked = self.checkOverlayToolbuttonClicked(ev) if isButtonClicked: - return - - isBrushActive = ( - self.brushButton.isChecked() or self.eraserButton.isChecked() - ) + return + + isBrushActive = self.brushButton.isChecked() or self.eraserButton.isChecked() isManualTrackingActive = self.manualTrackingButton.isChecked() isManualBackgroundActive = self.manualBackgroundButton.isChecked() isTypingIDFunctionChecked = False if self.brushButton.isChecked() and not self.autoIDcheckbox.isChecked(): success = self.keyPressCheckSetSpinboxValue(ev, self.editIDspinbox) isTypingIDFunctionChecked = True - + if isManualTrackingActive: isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( ev, self.manualTrackingToolbar.spinboxID ) - + elif isManualBackgroundActive: isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( ev, self.manualBackgroundToolbar.spinboxID ) - + addPointsByClickingButton = self.buttonAddPointsByClickingActive() if ( - addPointsByClickingButton is not None - and addPointsByClickingButton.toolbar.isVisible() - ): + addPointsByClickingButton is not None + and addPointsByClickingButton.toolbar.isVisible() + ): isTypingIDFunctionChecked = self.keyPressCheckSetSpinboxValue( ev, addPointsByClickingButton.rightClickIDSpinbox ) - + isBrushKey, isEraserKey = self.checkTriggerKeyPressShortcuts(ev) isExpandLabelActive = self.expandLabelToolButton.isChecked() isWandActive = self.wandToolButton.isChecked() isLabelRoiCircActive = ( - self.labelRoiButton.isChecked() + self.labelRoiButton.isChecked() and self.labelRoiIsCircularRadioButton.isChecked() ) how = self.drawIDsContComboBox.currentText() - isOverlaySegm = how.find('overlay segm. masks') != -1 - if ev.key()==Qt.Key_Up and not isCtrlModifier: + isOverlaySegm = how.find("overlay segm. masks") != -1 + if ev.key() == Qt.Key_Up and not isCtrlModifier: self.keyUpCallback( - isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive + isBrushActive, isWandActive, isExpandLabelActive, isLabelRoiCircActive ) - elif ev.key()==Qt.Key_Down and not isCtrlModifier: + elif ev.key() == Qt.Key_Down and not isCtrlModifier: self.keyDownCallback( - isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive + isBrushActive, isWandActive, isExpandLabelActive, isLabelRoiCircActive ) elif ev.key() == Qt.Key_Enter or ev.key() == Qt.Key_Return: if isTypingIDFunctionChecked: self.typingEditID = False elif self.keepIDsButton.isChecked(): self.keepIDsConfirmAction.trigger() - elif ev.key() == Qt.Key_Escape: + elif ev.key() == Qt.Key_Escape: self.onEscape(isTypingIDFunctionChecked=isTypingIDFunctionChecked) elif isAltModifier: isCursorSizeAll = self.app.overrideCursor() == Qt.SizeAllCursor @@ -587,13 +584,13 @@ def keyPressEvent(self, ev): elif isCtrlModifier and isOverlaySegm: if ev.key() == Qt.Key_Up: val = self.imgGrad.labelsAlphaSlider.value() - delta = 5/self.imgGrad.labelsAlphaSlider.maximum() - val = val+delta + delta = 5 / self.imgGrad.labelsAlphaSlider.maximum() + val = val + delta self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) elif ev.key() == Qt.Key_Down: val = self.imgGrad.labelsAlphaSlider.value() - delta = 5/self.imgGrad.labelsAlphaSlider.maximum() - val = val-delta + delta = 5 / self.imgGrad.labelsAlphaSlider.maximum() + val = val - delta self.imgGrad.labelsAlphaSlider.setValue(val, emitSignal=True) elif ev.key() == self.zoomOutKeyValue: self.zoomToCells(enforce=True) @@ -627,7 +624,7 @@ def keyPressEvent(self, ev): if not self.Button.isVisible(): return - + if self.countKeyPress == 0: # If first time clicking B activate brush and start timer # to catch double press of B @@ -648,21 +645,26 @@ def keyPressEvent(self, ev): c = self.defaultToolBarButtonColor else: c = self.doublePressKeyButtonColor - self.Button.setStyleSheet(f'background-color: {c}') + self.Button.setStyleSheet(f"background-color: {c}") self.countKeyPress = 0 if self.xHoverImg is not None: xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) if isBrushKey: self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, + xdata, + ydata, + self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush + self.brushButton, + brush=self.ax2_BrushCircleBrush, ) elif isEraserKey: self.setHoverToolSymbolColor( - xdata, ydata, self.eraserCirclePen, + xdata, + ydata, + self.eraserCirclePen, (self.ax2_EraserCircle, self.ax1_EraserCircle), - self.eraserButton + self.eraserButton, ) def keyReleaseEvent(self, ev): @@ -672,7 +674,7 @@ def keyReleaseEvent(self, ev): self.onCtrlReleased() elif ev.key() == Qt.Key_Shift: self.onShiftReleased() - + canRepeat = ( ev.key() == Qt.Key_Left or ev.key() == Qt.Key_Right @@ -681,13 +683,13 @@ def keyReleaseEvent(self, ev): or ev.key() == Qt.Key_Control or ev.key() == Qt.Key_Backspace or self.delObjToolAction.isChecked() - ) - + ) + if canRepeat and ev.isAutoRepeat(): return - + self.delObjToolAction.setChecked(False) - + if ev.isAutoRepeat() and not ev.key() == Qt.Key_Z: if self.warnKeyPressedMsg is not None: return @@ -700,7 +702,7 @@ def keyReleaseEvent(self, ev): It confuses me :)

    Thanks! """) - self.warnKeyPressedMsg.warning(self, 'Release the key, please', txt) + self.warnKeyPressedMsg.warning(self, "Release the key, please", txt) self.warnKeyPressedMsg = None elif ev.isAutoRepeat() and ev.key() == Qt.Key_Z and self.isZmodifier: self.zKeptDown = True @@ -712,27 +714,26 @@ def keyReleaseEvent(self, ev): self.zKeptDown = False def keyUpCallback( - self, isBrushActive, isWandActive, isExpandLabelActive, - isLabelRoiCircActive - ): + self, isBrushActive, isWandActive, isExpandLabelActive, isLabelRoiCircActive + ): isAutoPilotActive = ( self.autoPilotZoomToObjToggle.isChecked() and self.autoPilotZoomToObjToolbar.isVisible() ) if isBrushActive: brushSize = self.brushSizeSpinbox.value() - self.brushSizeSpinbox.setValue(brushSize+1) + self.brushSizeSpinbox.setValue(brushSize + 1) elif isWandActive: wandTolerance = self.wandControlsToolbar.toleranceSpinbox.value() - self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance+1) + self.wandControlsToolbar.toleranceSpinbox.setValue(wandTolerance + 1) elif isExpandLabelActive: self.expandLabel(dilation=True) self.expandFootprintSize += 1 elif isLabelRoiCircActive: val = self.labelRoiCircularRadiusSpinbox.value() - self.labelRoiCircularRadiusSpinbox.setValue(val+1) + self.labelRoiCircularRadiusSpinbox.setValue(val + 1) elif isAutoPilotActive: - self.pointsLayerAutoPilot('next') + self.pointsLayerAutoPilot("next") else: self.zSliceScrollBar.triggerAction( QAbstractSlider.SliderAction.SliderSingleStepAdd @@ -746,8 +747,8 @@ def leaveEvent(self, event): mainWinTop = mainWinGeometry.top() mainWinWidth = mainWinGeometry.width() mainWinHeight = mainWinGeometry.height() - mainWinRight = mainWinLeft+mainWinWidth - mainWinBottom = mainWinTop+mainWinHeight + mainWinRight = mainWinLeft + mainWinWidth + mainWinBottom = mainWinTop + mainWinHeight slideshowWinGeometry = self.slideshowWin.geometry() slideshowWinLeft = slideshowWinGeometry.left() @@ -756,15 +757,14 @@ def leaveEvent(self, event): slideshowWinHeight = slideshowWinGeometry.height() # Determine if overlap - overlap = ( - (slideshowWinTop < mainWinBottom) and - (slideshowWinLeft < mainWinRight) + overlap = (slideshowWinTop < mainWinBottom) and ( + slideshowWinLeft < mainWinRight ) autoActivate = ( - self.isDataLoaded and not - overlap and not - posData.disableAutoActivateViewerWindow + self.isDataLoaded + and not overlap + and not posData.disableAutoActivateViewerWindow ) if autoActivate: @@ -774,7 +774,7 @@ def leaveEvent(self, event): def mousePressEvent(self, event) -> None: if event.button() == Qt.MouseButton.RightButton: pos = self.resizeBottomLayoutLine.mapFromGlobal(event.globalPos()) - if pos.y()>=0: + if pos.y() >= 0: self.gui_raiseBottomLayoutContextMenu(event) return super().mousePressEvent(event) @@ -794,7 +794,7 @@ def onKeyPageDown(self): and self.autoPilotZoomToObjToolbar.isVisible() ) if isAutoPilotActive: - self.pointsLayerAutoPilot('prev') + self.pointsLayerAutoPilot("prev") elif self.zSliceScrollBar.isVisible(): self.zSliceScrollBar.triggerAction( QAbstractSlider.SliderAction.SliderSingleStepAdd @@ -806,7 +806,7 @@ def onKeyPageUp(self): and self.autoPilotZoomToObjToolbar.isVisible() ) if isAutoPilotActive: - self.pointsLayerAutoPilot('next') + self.pointsLayerAutoPilot("next") elif self.zSliceScrollBar.isVisible(): self.zSliceScrollBar.triggerAction( QAbstractSlider.SliderAction.SliderSingleStepAdd @@ -817,8 +817,8 @@ def onShiftReleased(self): self.updateBrushCursorOnShiftRelease() def readSettings(self): - settings = QSettings('schmollerlab', 'acdc_gui') - if settings.value('geometry') is not None: + settings = QSettings("schmollerlab", "acdc_gui") + if settings.value("geometry") is not None: self.restoreGeometry(settings.value("geometry")) def resizeBottomLayoutLineClicked(self, event): @@ -834,7 +834,7 @@ def resizeBottomLayoutLineReleased(self): QTimer.singleShot(100, self.autoRange) def resizeEvent(self, event): - if hasattr(self, 'ax1'): + if hasattr(self, "ax1"): self.ax1.autoRange() def resizeLeaveSpaceTerminalBelow(self): @@ -847,13 +847,13 @@ def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): self.newCheckBoxesHeight = self.checkBoxesHeight self.newHeight = self.h else: - self.newHeight = round(self.h*heightFactor) - self.newCheckBoxesHeight = round(self.checkBoxesHeight*heightFactor) - + self.newHeight = round(self.h * heightFactor) + self.newCheckBoxesHeight = round(self.checkBoxesHeight * heightFactor) + if fontSizeFactor is None: newFontSize = self.fontPixelSize else: - newFontSize = round(self.fontPixelSize*fontSizeFactor) + newFontSize = round(self.fontPixelSize * fontSizeFactor) newFont = QFont() newFont.setPixelSize(newFontSize) _font = newFont @@ -890,7 +890,7 @@ def resizeSlidersArea(self, fontSizeFactor=None, heightFactor=None): QTimer.singleShot(100, self._resizeSlidersArea) def saveWindowGeometry(self): - settings = QSettings('schmollerlab', 'acdc_gui') + settings = QSettings("schmollerlab", "acdc_gui") settings.setValue("geometry", self.saveGeometry()) def show(self): @@ -907,22 +907,20 @@ def show(self): self.h = self.navSpinBox.size().height() fontSizeFactor = None heightFactor = None - if 'bottom_sliders_zoom_perc' in self.df_settings.index: - val = int(self.df_settings.at['bottom_sliders_zoom_perc', 'value']) + if "bottom_sliders_zoom_perc" in self.df_settings.index: + val = int(self.df_settings.at["bottom_sliders_zoom_perc", "value"]) if val != 100: - fontSizeFactor = val/100 - heightFactor = val/100 + fontSizeFactor = val / 100 + heightFactor = val / 100 self.defaultWidgetHeightBottomLayout = self.h self.checkBoxesHeight = 14 self.fontPixelSize = 11 self.defaultBottomLayoutHeight = self.img1BottomGroupbox.height() - + self.bottomLayout.setStretch(0, 0) self.bottomLayout.addSpacing(self.quickSettingsGroupbox.width()) - self.resizeSlidersArea( - fontSizeFactor=fontSizeFactor, heightFactor=heightFactor - ) + self.resizeSlidersArea(fontSizeFactor=fontSizeFactor, heightFactor=heightFactor) self.bottomScrollArea.hide() self.gui_initImg1BottomWidgets() @@ -933,11 +931,9 @@ def show(self): self.showPropsDockButton.setMaximumWidth(15) self.showPropsDockButton.setMaximumHeight(120) - + for toolbar in self.controlToolBars: - toolbar.setMinimumHeight( - self.secondLevelToolbar.sizeHint().height() - ) + toolbar.setMinimumHeight(self.secondLevelToolbar.sizeHint().height()) self.graphLayout.setFocus() @@ -950,7 +946,7 @@ def showEvent(self, event): self.activateWindow() def stopPreprocWorker(self): - self.logger.info('Closing pre-processing worker...') + self.logger.info("Closing pre-processing worker...") try: self.preprocWorker.stop() except Exception as err: @@ -959,18 +955,21 @@ def stopPreprocWorker(self): def storeDefaultAndCustomColors(self): c = self.overlayButton.palette().button().color().name() self.defaultToolBarButtonColor = c - self.doublePressKeyButtonColor = '#fa693b' + self.doublePressKeyButtonColor = "#fa693b" def super_show(self): super().show() - def updateBrushCursorOnShiftRelease(self): + def updateBrushCursorOnShiftRelease(self): xdata, ydata = int(self.xHoverImg), int(self.yHoverImg) self.setHoverToolSymbolColor( - xdata, ydata, self.ax2_BrushCirclePen, + xdata, + ydata, + self.ax2_BrushCirclePen, (self.ax2_BrushCircle, self.ax1_BrushCircle), - self.brushButton, brush=self.ax2_BrushCircleBrush, - byPassShiftCheck=True + self.brushButton, + brush=self.ax2_BrushCircleBrush, + byPassShiftCheck=True, ) if self.isSegm3D: self.changeBrushID() diff --git a/cellacdc/mixins/worker.py b/cellacdc/mixins/worker.py index bac0b0c55..010a8b226 100644 --- a/cellacdc/mixins/worker.py +++ b/cellacdc/mixins/worker.py @@ -13,12 +13,13 @@ from .status_hover import StatusHover + class Worker(StatusHover): """Extracted from guiWin.""" def autoSaveWorkerClosed(self, worker): if self.autoSaveActiveWorkers: - self.logger.info('Autosaving worker closed.') + self.logger.info("Autosaving worker closed.") try: self.autoSaveActiveWorkers.remove(worker) except Exception as e: @@ -44,7 +45,7 @@ def ccaIntegrityWorkerCritical(self, error): raise error except Exception as err: self.logger.exception(traceback.format_exc()) - + href = f'GitHub page' txt = html_utils.paragraph(f""" Unfortunately the experimental feature @@ -59,19 +60,21 @@ def ccaIntegrityWorkerCritical(self, error): """) msg = widgets.myMessageBox(wrapText=False) msg.warning( - self, 'Experimental feature error', txt, + self, + "Experimental feature error", + txt, commands=(self.log_path,), - path_to_browse=self.logs_path + path_to_browse=self.logs_path, ) self.disableCcaIntegrityChecker() - def gui_createAutoSaveWorker(self): - if not hasattr(self, 'data'): + def gui_createAutoSaveWorker(self): + if not hasattr(self, "data"): return - + if not self.isDataLoaded: - return - + return + if self.autoSaveActiveWorkers: garbage = self.autoSaveActiveWorkers[-1] self.autoSaveGarbageWorkers.append(garbage) @@ -97,16 +100,14 @@ def gui_createAutoSaveWorker(self): autoSaveWorker.sigDone.connect(self.autoSaveWorkerDone) autoSaveWorker.progress.connect(self.workerProgress) autoSaveWorker.finished.connect(self.autoSaveWorkerClosed) - autoSaveWorker.sigAutoSaveCannotProceed.connect( - self.turnOffAutoSaveWorker - ) - + autoSaveWorker.sigAutoSaveCannotProceed.connect(self.turnOffAutoSaveWorker) + autoSaveThread.started.connect(autoSaveWorker.run) autoSaveThread.start() self.autoSaveActiveWorkers.append((autoSaveWorker, autoSaveThread)) - self.logger.info('Autosaving worker started.') + self.logger.info("Autosaving worker started.") def gui_createLazyLoader(self): if not self.lazyLoader is None: @@ -118,8 +119,10 @@ def gui_createLazyLoader(self): self.waitReadH5cond = QWaitCondition() self.readH5mutex = QMutex() self.lazyLoader = workers.LazyLoader( - self.lazyLoaderMutex, self.lazyLoaderWaitCond, - self.waitReadH5cond, self.readH5mutex + self.lazyLoaderMutex, + self.lazyLoaderWaitCond, + self.waitReadH5cond, + self.readH5mutex, ) self.lazyLoader.moveToThread(self.lazyLoaderThread) self.lazyLoader.wait = True @@ -156,11 +159,11 @@ def gui_createStoreStateWorker(self): self.storeStateWorker.sigDone.connect(self.storeStateWorkerDone) self.storeStateWorker.progress.connect(self.workerProgress) self.storeStateWorker.finished.connect(self.storeStateWorkerClosed) - + self.storeStateThread.started.connect(self.storeStateWorker.run) self.storeStateThread.start() - self.logger.info('Store state worker started.') + self.logger.info("Store state worker started.") def lazyLoaderCritical(self, error): if self.progressWin is not None: @@ -171,7 +174,7 @@ def lazyLoaderCritical(self, error): raise error def lazyLoaderFinished(self): - self.logger.info('Load chunk data worker done.') + self.logger.info("Load chunk data worker done.") if self.lazyLoader.updateImgOnFinished: self.updateAllImages() @@ -182,18 +185,16 @@ def lazyLoaderFinished(self): def lazyLoaderWorkerClosed(self): if self.lazyLoader.salute: - self.logger.info('Cell-ACDC GUI closed.') + self.logger.info("Cell-ACDC GUI closed.") self.sigClosed.emit(self) - + self.lazyLoader = None def loadingNewChunk(self, chunk_range): coord0_chunk, coord1_chunk = chunk_range - desc = ( - f'Loading new window, range = ({coord0_chunk}, {coord1_chunk})...' - ) + desc = f"Loading new window, range = ({coord0_chunk}, {coord1_chunk})..." self.progressWin = apps.QDialogWorkerProgress( - title='Loading data...', parent=self, pbarDesc=desc + title="Loading data...", parent=self, pbarDesc=desc ) self.progressWin.mainPbar.setMaximum(0) self.progressWin.show(self.app) @@ -202,9 +203,7 @@ def relabelWorkerFinished(self): self.updateAllImages() def saveDataWorkerCritical(self, error): - self.logger.warning( - 'Saving process stopped because of critical error.' - ) + self.logger.warning("Saving process stopped because of critical error.") self.saveWin.aborted = True self.worker.finished.emit() self.workerCritical(error) @@ -214,21 +213,25 @@ def savePreprocWorkerFinished(self): self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - + self.setStatusBarLabel() - self.logger.info('Pre-processed data saved!') - self.titleLabel.setText('Pre-processed data saved!', color='w') + self.logger.info("Pre-processed data saved!") + self.titleLabel.setText("Pre-processed data saved!", color="w") def startPostProcessSegmWorker( - self, postProcessKwargs, customPostProcessGroupedFeatures, - customPostProcessFeatures - ): + self, + postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures, + ): self.thread = QThread() self.postProcessWorker = workers.PostProcessSegmWorker( - postProcessKwargs, customPostProcessGroupedFeatures, - customPostProcessFeatures, self + postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures, + self, ) - + self.postProcessWorker.moveToThread(self.thread) self.postProcessWorker.signals.finished.connect(self.thread.quit) self.postProcessWorker.signals.finished.connect( @@ -243,12 +246,8 @@ def startPostProcessSegmWorker( self.postProcessWorker.signals.initProgressBar.connect( self.workerInitProgressbar ) - self.postProcessWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.postProcessWorker.signals.critical.connect( - self.workerCritical - ) + self.postProcessWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.postProcessWorker.signals.critical.connect(self.workerCritical) self.thread.started.connect(self.postProcessWorker.run) self.thread.start() @@ -273,9 +272,7 @@ def startRelabellingWorker(self, posFoldernames): def startTrackingWorker(self, posData, video_to_track): self.thread = QThread() - self.trackingWorker = workers.trackingWorker( - posData, self, video_to_track - ) + self.trackingWorker = workers.trackingWorker(posData, self, video_to_track) self.trackingWorker.moveToThread(self.thread) self.trackingWorker.finished.connect(self.thread.quit) self.trackingWorker.finished.connect(self.trackingWorker.deleteLater) @@ -283,15 +280,9 @@ def startTrackingWorker(self, posData, video_to_track): # Custom signals self.trackingWorker.signals.progress = self.trackingWorker.progress - self.trackingWorker.signals.progressBar.connect( - self.workerUpdateProgressbar - ) - self.trackingWorker.signals.initProgressBar.connect( - self.workerInitProgressbar - ) - self.trackingWorker.signals.sigInitInnerPbar.connect( - self.workerInitInnerPbar - ) + self.trackingWorker.signals.progressBar.connect(self.workerUpdateProgressbar) + self.trackingWorker.signals.initProgressBar.connect(self.workerInitProgressbar) + self.trackingWorker.signals.sigInitInnerPbar.connect(self.workerInitInnerPbar) self.trackingWorker.progress.connect(self.workerProgress) self.trackingWorker.critical.connect(self.workerCritical) self.trackingWorker.finished.connect(self.trackingWorkerFinished) @@ -302,7 +293,7 @@ def startTrackingWorker(self, posData, video_to_track): self.thread.start() def storeStateWorkerClosed(self): - self.logger.info('Store state worker started.') + self.logger.info("Store state worker started.") def storeStateWorkerDone(self): if self.storeStateWorker.callbackOnDone is not None: @@ -314,34 +305,36 @@ def trackingWorkerFinished(self): self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - self.logger.info('Worker process ended.') + self.logger.info("Worker process ended.") askDisableRealTimeTracking = ( self.trackingWorker.trackingOnNeverVisitedFrames and self.realTimeTrackingToggle.isChecked() ) if askDisableRealTimeTracking: msg = widgets.myMessageBox() - title = 'Disable real-time tracking?' + title = "Disable real-time tracking?" txt = ( - 'You perfomed tracking on frames that you have ' - 'never visited.

    ' - 'Cell-ACDC default behaviour is to track them again when you ' - 'will visit them.

    ' - 'However, you can overwrite this behaviour and explicitly ' - 'disable tracking for all of the frames you already tracked.

    ' - 'NOTE: you can reactivate real-time tracking by clicking on the ' + "You perfomed tracking on frames that you have " + "never visited.

    " + "Cell-ACDC default behaviour is to track them again when you " + "will visit them.

    " + "However, you can overwrite this behaviour and explicitly " + "disable tracking for all of the frames you already tracked.

    " + "NOTE: you can reactivate real-time tracking by clicking on the " '"Reset last segmented frame" button on the top toolbar.

    ' - 'What do you want me to do?' + "What do you want me to do?" ) _, disableTrackingButton = msg.information( - self, title, html_utils.paragraph(txt), + self, + title, + html_utils.paragraph(txt), buttonsTexts=( - 'Keep real-time tracking active (recommended)', - 'Disable real-time tracking' - ) + "Keep real-time tracking active (recommended)", + "Disable real-time tracking", + ), ) if msg.clickedButton == disableTrackingButton: - self.logger.info('Disabling real time tracking...') + self.logger.info("Disabling real time tracking...") self.realTimeTrackingToggle.setChecked(False) # posData = self.data[self.pos_i] # current_frame_i = posData.frame_i @@ -357,7 +350,7 @@ def trackingWorkerFinished(self): # self.get_data() posData = self.data[self.pos_i] self.updateAllImages() - self.titleLabel.setText('Done', color='w') + self.titleLabel.setText("Done", color="w") def workerCritical(self, out: Tuple[QObject, Exception]): self.setDisabled(False) @@ -382,6 +375,7 @@ def workerCritical(self, out: Tuple[QObject, Exception]): def workerDebug(self, item): tracked_video, worker = item from cellacdc.plot import imshow + imshow(tracked_video) worker.waitCond.wakeAll() @@ -390,9 +384,9 @@ def workerFinished(self): self.progressWin.workerFinished = True self.progressWin.close() self.progressWin = None - self.logger.info('Worker process ended.') + self.logger.info("Worker process ended.") self.updateAllImages() - self.titleLabel.setText('Done', color='w') + self.titleLabel.setText("Done", color="w") def workerInitInnerPbar(self, totalIter): self.progressWin.innerPbar.setValue(0) @@ -409,7 +403,7 @@ def workerInitProgressbar(self, totalIter): def workerLog(self, text): self.logger.info(text) - def workerProgress(self, text, loggerLevel='INFO'): # used in cca and lin tree + def workerProgress(self, text, loggerLevel="INFO"): # used in cca and lin tree if self.progressWin is not None: self.progressWin.logConsole.append(text) self.logger.log(getattr(logging, loggerLevel), text) diff --git a/cellacdc/myutils.py b/cellacdc/myutils.py index fd43207ae..871f81da3 100644 --- a/cellacdc/myutils.py +++ b/cellacdc/myutils.py @@ -42,7 +42,7 @@ import skimage.measure from . import GUI_INSTALLED, KNOWN_EXTENSIONS, is_conda_env - + from . import core, load from . import html_utils, is_linux, is_win, is_mac, issues_url, is_mac_arm64 from . import cellacdc_path, printl, acdc_fiji_path, logs_path, acdc_ffmpeg_path @@ -60,81 +60,86 @@ if GUI_INSTALLED: from qtpy.QtWidgets import QMessageBox from qtpy.QtCore import Signal, QObject, QCoreApplication - + from . import widgets, apps from . import config -ArgSpec = namedtuple('ArgSpec', ['name', 'default', 'type', 'desc', 'docstring']) +ArgSpec = namedtuple("ArgSpec", ["name", "default", "type", "desc", "docstring"]) + def get_module_name(script_file_path): parts = pathlib.Path(script_file_path).parts - parts = list(parts[parts.index('cellacdc')+1:]) + parts = list(parts[parts.index("cellacdc") + 1 :]) parts[-1] = os.path.splitext(parts[-1])[0] - module = '.'.join(parts) + module = ".".join(parts) return module + def get_pos_status_acdc(pos_path): - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") ls = listdir(images_path) for file in ls: - if file.endswith('acdc_output.csv'): + if file.endswith("acdc_output.csv"): acdc_df_path = os.path.join(images_path, file) break else: - return '' - + return "" + acdc_df = pd.read_csv(acdc_df_path) - last_tracked_i = acdc_df['frame_i'].max() + last_tracked_i = acdc_df["frame_i"].max() last_cca_i = 0 - if 'cell_cycle_stage' in acdc_df.columns: - cca_df = acdc_df[['frame_i', 'cell_cycle_stage']].dropna() - last_cca_i = cca_df['frame_i'].max() + if "cell_cycle_stage" in acdc_df.columns: + cca_df = acdc_df[["frame_i", "cell_cycle_stage"]].dropna() + last_cca_i = cca_df["frame_i"].max() if last_cca_i > 0: return ( - f' (last tracked frame = {last_tracked_i+1}, ' - f'last annotated frame = {last_cca_i+1})' + f" (last tracked frame = {last_tracked_i + 1}, " + f"last annotated frame = {last_cca_i + 1})" ) else: - return f' (last tracked frame = {last_tracked_i+1})' + return f" (last tracked frame = {last_tracked_i + 1})" + def get_pos_status_spotmax(pos_path): - spotmax_out_path = os.path.join(pos_path, 'spotMAX_output') - is_smax_out_present = 'Yes' if os.path.exists(spotmax_out_path) else 'No' + spotmax_out_path = os.path.join(pos_path, "spotMAX_output") + is_smax_out_present = "Yes" if os.path.exists(spotmax_out_path) else "No" if os.path.exists(spotmax_out_path): - return ' (SpotMAX output exists)' + return " (SpotMAX output exists)" else: - return '' + return "" + -def get_pos_status( - pos_path, - caller: Literal['Cell-ACDC', 'SpotMAX']='Cell-ACDC' - ): - if caller == 'Cell-ACDC': +def get_pos_status(pos_path, caller: Literal["Cell-ACDC", "SpotMAX"] = "Cell-ACDC"): + if caller == "Cell-ACDC": return get_pos_status_acdc(pos_path) - - if caller == 'SpotMAX': + + if caller == "SpotMAX": return get_pos_status_spotmax(pos_path) - + + def get_gdrive_path(): if is_win: - return os.path.join(f'G:{os.sep}', 'My Drive') + return os.path.join(f"G:{os.sep}", "My Drive") elif is_mac: return os.path.join( - '/Users/francesco.padovani/Library/CloudStorage/' - 'GoogleDrive-padovaf@tcd.ie/My Drive' + "/Users/francesco.padovani/Library/CloudStorage/" + "GoogleDrive-padovaf@tcd.ie/My Drive" ) + def get_acdc_data_path(): Cell_ACDC_path = os.path.dirname(cellacdc_path) - return os.path.join(Cell_ACDC_path, 'data') + return os.path.join(Cell_ACDC_path, "data") + def get_open_filemaneger_os_string(): if is_win: - return 'Show in Explorer...' + return "Show in Explorer..." elif is_mac: - return 'Reveal in Finder...' + return "Reveal in Finder..." elif is_linux: - return 'Show in File Manager...' + return "Show in File Manager..." + def filterCommonStart(images_path): startNameLen = 6 @@ -146,49 +151,54 @@ def filterCommonStart(images_path): commonStartFilenames = [f for f in ls if f.startswith(mostCommonStart)] return commonStartFilenames + def get_salute_string(): time_now = datetime.datetime.now().time() - time_end_morning = datetime.time(12,00,00) - time_end_lunch = datetime.time(13,00,00) - time_end_afternoon = datetime.time(15,00,00) - time_end_evening = datetime.time(20,00,00) - time_end_night = datetime.time(4,00,00) + time_end_morning = datetime.time(12, 00, 00) + time_end_lunch = datetime.time(13, 00, 00) + time_end_afternoon = datetime.time(15, 00, 00) + time_end_evening = datetime.time(20, 00, 00) + time_end_night = datetime.time(4, 00, 00) if time_now >= time_end_night and time_now < time_end_morning: - return 'Have a good day!' + return "Have a good day!" elif time_now >= time_end_morning and time_now < time_end_lunch: - return 'Enjoy your lunch!' + return "Enjoy your lunch!" elif time_now >= time_end_lunch and time_now < time_end_afternoon: - return 'Have a good afternoon!' + return "Have a good afternoon!" elif time_now >= time_end_afternoon and time_now < time_end_evening: - return 'Have a good evening!' + return "Have a good evening!" else: - return 'Have a good night!' + return "Have a good night!" + def remove_known_extension(name): for ext in KNOWN_EXTENSIONS: if name.endswith(ext): - return name[:-len(ext)], ext + return name[: -len(ext)], ext + + return name, "" + - return name, '' - def getCustomAnnotTooltip(annotState): toolTip = ( - f'Name: {annotState["name"]}\n\n' - f'Type: {annotState["type"]}\n\n' - f'Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n' - f'Description: {annotState["description"]}\n\n' + f"Name: {annotState['name']}\n\n" + f"Type: {annotState['type']}\n\n" + f"Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n" + f"Description: {annotState['description']}\n\n" f'SHORTCUT: "{annotState["shortcut"]}"' ) return toolTip + def trim_path(path, depth=3, start_with_dots=True): path_li = os.path.abspath(path).split(os.sep) - rel_path = f'{f"{os.sep}".join(path_li[-depth:])}' + rel_path = f"{f'{os.sep}'.join(path_li[-depth:])}" if start_with_dots: - return f'...{os.sep}{rel_path}' + return f"...{os.sep}{rel_path}" else: return rel_path + def get_add_custom_prompt_model_instructions(): init_sh = html_utils.init_sh segment_sh = html_utils.segment_sh @@ -211,8 +221,9 @@ def get_add_custom_prompt_model_instructions(): """) return text + def get_add_custom_model_instructions(): - user_manual_url = 'https://github.com/SchmollerLab/Cell_ACDC/blob/main/UserManual/Cell-ACDC_User_Manual.pdf' + user_manual_url = "https://github.com/SchmollerLab/Cell_ACDC/blob/main/UserManual/Cell-ACDC_User_Manual.pdf" href_user_manual = f'user manual' href = f'here' class_sh = html_utils.class_sh @@ -258,39 +269,43 @@ def get_add_custom_model_instructions(): """) return s + def is_iterable(item): - try: - iter(item) - return True - except TypeError as e: - return False + try: + iter(item) + return True + except TypeError as e: + return False + class utilClass: pass + def get_trimmed_list(li: list, max_num_digits=10): if len(li) == 0: - return '[]' - + return "[]" + tom_num_digits = sum([len(str(val)) for val in li]) - + if tom_num_digits == 0: return f"[{', '.join(map(str, li))}]" - - avg_num_digits = tom_num_digits/len(li) - max_num_vals = int(round(max_num_digits/avg_num_digits)) + + avg_num_digits = tom_num_digits / len(li) + max_num_vals = int(round(max_num_digits / avg_num_digits)) if tom_num_digits > max_num_digits: front_vals = ceil(max_num_vals / 2) back_vals = max_num_vals // 2 - + if front_vals + back_vals >= len(li): return f"[{', '.join(map(str, li))}]" - li = li[:front_vals] + ['...'] + li[len(li) - back_vals:] + li = li[:front_vals] + ["..."] + li[len(li) - back_vals :] return f"[{', '.join(map(str, li))}]" + def get_trimmed_dict(di: dict, max_num_digits=10): di_str = di.copy() total_num_digits = sum([len(str(key)) + len(str(val)) for key, val in di.items()]) @@ -303,47 +318,47 @@ def get_trimmed_dict(di: dict, max_num_digits=10): di_str[keys[max_num_vals]] = "..." return f"[{', '.join([f'{key} -> {val}' for key, val in di_str.items()])}]" + def checked_reset_index(df): if df.index.names is None or df.index.names == [None]: return df.reset_index(drop=True) else: return df.reset_index() + def checked_reset_index_Cell_ID(df): - if df.index.names == ['Cell_ID']: + if df.index.names == ["Cell_ID"]: return df df = checked_reset_index(df) - return df.set_index('Cell_ID') + return df.set_index("Cell_ID") def _bytes_to_MB(size_bytes): factor = pow(2, -20) - size_MB = round(size_bytes*factor) + size_MB = round(size_bytes * factor) return size_MB + def _bytes_to_GB(size_bytes): factor = pow(2, -30) - size_GB = round(size_bytes*factor, 2) + size_GB = round(size_bytes * factor, 2) return size_GB + def getMemoryFootprint(files_list): - required_memory = sum([ - 48 if file.endswith('.h5') else os.path.getsize(file) - for file in files_list - ]) + required_memory = sum( + [48 if file.endswith(".h5") else os.path.getsize(file) for file in files_list] + ) return required_memory + def get_logs_path(): return logs_path + class Logger(logging.Logger): - def __init__( - self, - module='base', - name='cellacdc-logger', - level=logging.DEBUG - ): - super().__init__(f'{name}-{module}', level=level) + def __init__(self, module="base", name="cellacdc-logger", level=logging.DEBUG): + super().__init__(f"{name}-{module}", level=level) self._stdout = sys.stdout self._stderr = StdErr(logger=self) sys.stderr = self._stderr @@ -353,11 +368,11 @@ def __init__( 30: "WARNING", 20: "INFO", 10: "DEBUG", - 0: "NOTSET" + 0: "NOTSET", } - + def write(self, text, log_to_file=True, write_to_stdout=True): - """Capture print statements, print to terminal and log text to + """Capture print statements, print to terminal and log text to the open log file Parameters @@ -366,19 +381,19 @@ def write(self, text, log_to_file=True, write_to_stdout=True): Text to log log_to_file : bool, optional If True, call `info` method with `text`. Default is True - """ - if write_to_stdout: + """ + if write_to_stdout: self._stdout.write(text) - + if not log_to_file: return - - if text == '\n': + + if text == "\n": return - + if not text: - return - + return + self.debug(text) def close(self): @@ -387,131 +402,133 @@ def close(self): self.removeHandler(handler) sys.stdout = self._stdout self._stderr.close() - + def __del__(self): sys.stdout = self._stdout self._stderr.close() - + def info(self, text, *args, **kwargs): super().info(text, *args, **kwargs) try: - self.write(f'{text}\n', log_to_file=False) + self.write(f"{text}\n", log_to_file=False) except TypeError: # Sometimes the logger is patched (e.g., by spotiflow), which - # triggers the TypeError because the patching function does not have + # triggers the TypeError because the patching function does not have # log_to_file argument - self.write(f'{text}\n') - + self.write(f"{text}\n") + def warning(self, text, *args, **kwargs): super().warning(text, *args, **kwargs) try: - self.write(f'[WARNING]: {text}\n', log_to_file=False) + self.write(f"[WARNING]: {text}\n", log_to_file=False) except TypeError: # Sometimes the logger is patched (e.g., by spotiflow), which - # triggers the TypeError because the patching function does not have + # triggers the TypeError because the patching function does not have # log_to_file argument - self.write(f'[WARNING]: {text}\n') - + self.write(f"[WARNING]: {text}\n") + def error(self, text, *args, write_traceback=True, **kwargs): super().error(text, *args, **kwargs) self.write(traceback.format_exc()) try: - self.write(f'[ERROR]: {text}\n', log_to_file=False) + self.write(f"[ERROR]: {text}\n", log_to_file=False) except TypeError: # Sometimes the logger is patched (e.g., by spotiflow), which - # triggers the TypeError because the patching function does not have + # triggers the TypeError because the patching function does not have # log_to_file argument - self.write(f'[ERROR]: {text}\n') - + self.write(f"[ERROR]: {text}\n") + def plain(self, text, write_to_stdout=False): orig_formatters = [handler.formatter for handler in self.handlers] for handler in self.handlers: - handler.setFormatter(logging.Formatter('%(message)s')) + handler.setFormatter(logging.Formatter("%(message)s")) self.write(text, write_to_stdout=write_to_stdout) for handler in self.handlers: handler.setFormatter(orig_formatters.pop(0)) - + def critical(self, text, *args, **kwargs): super().critical(text, *args, **kwargs) try: - self.write(f'[CRITICAL]: {text}\n', log_to_file=False) + self.write(f"[CRITICAL]: {text}\n", log_to_file=False) except TypeError: # Sometimes the logger is patched (e.g., by spotiflow), which - # triggers the TypeError because the patching function does not have + # triggers the TypeError because the patching function does not have # log_to_file argument - self.write(f'[CRITICAL]: {text}\n') - + self.write(f"[CRITICAL]: {text}\n") + def exception(self, text, *args, write_traceback=True, **kwargs): super().exception(text, *args, **kwargs) self.write(traceback.format_exc()) try: - self.write(f'[ERROR]: {text}\n', log_to_file=False) + self.write(f"[ERROR]: {text}\n", log_to_file=False) except TypeError: # Sometimes the logger is patched (e.g., by spotiflow), which - # triggers the TypeError because the patching function does not have + # triggers the TypeError because the patching function does not have # log_to_file argument - self.write(f'[ERROR]: {text}\n') - + self.write(f"[ERROR]: {text}\n") + def log(self, level, text): if not isinstance(level, int): - printl(level, text, type(level), type(text), sep='\n') + printl(level, text, type(level), type(text), sep="\n") super().log(level, text) - levelName = self._levelToName.get(level, 'INFO') + levelName = self._levelToName.get(level, "INFO") getattr(self, levelName.lower())(text) - + def flush(self): self._stdout.flush() + class StdErr: - def __init__(self, logger: Logger=None): + def __init__(self, logger: Logger = None): self._sys_stderr = sys.stderr self._err_msg_line_buffer = [] self._logger = logger - - def write(self, text: str): - if text.startswith('Traceback'): - print('-'*100) - + + def write(self, text: str): + if text.startswith("Traceback"): + print("-" * 100) + self._sys_stderr.write(text) - + if not text: return - + self._err_msg_line_buffer.append(text) - if not text.endswith('\n'): + if not text.endswith("\n"): return - + # If the line ends with a newline, flush the buffer - err_line = ''.join(self._err_msg_line_buffer) + err_line = "".join(self._err_msg_line_buffer) if self._logger is not None: self._logger.plain(err_line, write_to_stdout=False) else: print(err_line) - + self._err_msg_line_buffer = [] - + def flush(self): self._sys_stderr.flush() - + def close(self): """Close the StdErr stream""" sys.stderr = self._sys_stderr + def delete_older_log_files(logs_path): if not os.path.exists(logs_path): return - + log_files = os.listdir(logs_path) for log_file in log_files: - if not log_file.endswith('.log'): + if not log_file.endswith(".log"): continue - + log_filepath = os.path.join(logs_path, log_file) try: mtime = os.path.getmtime(log_filepath) except Exception as err: continue - + mdatetime = datetime.datetime.fromtimestamp(mtime) days = (datetime.datetime.now() - mdatetime).days if days < 7: @@ -522,120 +539,126 @@ def delete_older_log_files(logs_path): except Exception as err: continue + def get_info_version_text(is_cli=False, cli_formatted_text=True): version = read_version() - release_date = get_date_from_version(version, package='cellacdc') + release_date = get_date_from_version(version, package="cellacdc") py_ver = sys.version_info env_folderpath = sys.prefix - python_version = f'{py_ver.major}.{py_ver.minor}.{py_ver.micro}' + python_version = f"{py_ver.major}.{py_ver.minor}.{py_ver.micro}" info_txts = [ - f'Version {version}', - f'Released on: {release_date}', + f"Version {version}", + f"Released on: {release_date}", f'Installed in "{cellacdc_path}"', f'Environment folder: "{env_folderpath}"', f'User profile folder: "{user_profile_path}"', f'Settings folder: "{settings_folderpath}"', - f'Python {python_version}', - f'Platform: {platform.platform()}', - f'System: {platform.system()}', + f"Python {python_version}", + f"Platform: {platform.platform()}", + f"System: {platform.system()}", ] if is_linux: try: distro_name = get_linux_distribution_name() except Exception as err: - distro_name = 'Undetermined' - - info_txts.append(f'Linux distribution: {distro_name}') - + distro_name = "Undetermined" + + info_txts.append(f"Linux distribution: {distro_name}") + if GUI_INSTALLED and not is_cli: info_txts.append(f'Icons from: "{qrc_resources_path}"') try: from qtpy import QtCore - info_txts.append(f'Qt {QtCore.__version__}') + + info_txts.append(f"Qt {QtCore.__version__}") except Exception as err: - info_txts.append('Qt: Not installed') - + info_txts.append("Qt: Not installed") + try: branch_name = get_git_branch_name() info_txts.append(f'Git branch: "{branch_name}"') except Exception as err: pass - - info_txts.append(f'Working directory: {os.getcwd()}') - + + info_txts.append(f"Working directory: {os.getcwd()}") + if not cli_formatted_text: return info_txts - - info_txts = [f' - {txt}' for txt in info_txts] - + + info_txts = [f" - {txt}" for txt in info_txts] + max_len = max([len(txt) for txt in info_txts]) + 2 - + formatted_info_txts = [] for txt in info_txts: - horiz_spacing = ' '*(max_len - len(txt)) - txt = f'{txt}{horiz_spacing}|' + horiz_spacing = " " * (max_len - len(txt)) + txt = f"{txt}{horiz_spacing}|" formatted_info_txts.append(txt) - - formatted_info_txts.insert(0, 'Cell-ACDC info:\n') - formatted_info_txts.insert(0, '='*max_len) - formatted_info_txts.append('='*max_len) - info_txt = '\n'.join(formatted_info_txts) - + + formatted_info_txts.insert(0, "Cell-ACDC info:\n") + formatted_info_txts.insert(0, "=" * max_len) + formatted_info_txts.append("=" * max_len) + info_txt = "\n".join(formatted_info_txts) + try: from spotmax.utils import get_info_version_text as smax_info + smax_info_txt = smax_info(include_platform=False, is_cli=is_cli) - info_txt += '\n\n' + smax_info_txt + info_txt += "\n\n" + smax_info_txt except ImportError: pass - + return info_txt + def _log_system_info(logger, log_path, is_cli=False, also_spotmax=False): logger.info(f'Initialized log file "{log_path}"') - + info_txt = get_info_version_text(is_cli=is_cli) - + logger.info(info_txt) - + if not also_spotmax: return - + from spotmax.utils import get_info_version_text as smax_info + smax_info_txt = smax_info(include_platform=False) logger.info(smax_info_txt) -def setupLogger(module='base', logs_path=None, caller='Cell-ACDC'): + +def setupLogger(module="base", logs_path=None, caller="Cell-ACDC"): if logs_path is None: logs_path = get_logs_path() - + logger = Logger(module=module) sys.stdout = logger - + delete_older_log_files(logs_path) if not os.path.exists(logs_path): os.mkdir(logs_path) - date_time = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S') + date_time = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") id = uuid4() - log_filename = f'{date_time}_{module}_{id}_stdout.log' + log_filename = f"{date_time}_{module}_{id}_stdout.log" log_path = os.path.join(logs_path, log_filename) - output_file_handler = logging.FileHandler(log_path, mode='w') + output_file_handler = logging.FileHandler(log_path, mode="w") # Format your logs (optional) formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s:\n' - '------------------------\n' - '%(message)s\n' - '------------------------\n', - datefmt='%d-%m-%Y, %H:%M:%S' + "%(asctime)s - %(name)s - %(levelname)s:\n" + "------------------------\n" + "%(message)s\n" + "------------------------\n", + datefmt="%d-%m-%Y, %H:%M:%S", ) output_file_handler.setFormatter(formatter) logger.addHandler(output_file_handler) - - _log_system_info(logger, log_path, also_spotmax=caller!='Cell-ACDC') - + + _log_system_info(logger, log_path, also_spotmax=caller != "Cell-ACDC") + # if module == 'gui' and GUI_INSTALLED: # qt_handler = widgets.QtHandler() # qt_handler.setFormatter(logging.Formatter("%(message)s")) @@ -643,6 +666,7 @@ def setupLogger(module='base', logs_path=None, caller='Cell-ACDC'): return logger, logs_path, log_path, log_filename + def get_pos_foldernames(exp_path, check_if_is_sub_folder=False): if not check_if_is_sub_folder: ls = listdir(exp_path) @@ -664,45 +688,48 @@ def get_pos_foldernames(exp_path, check_if_is_sub_folder=False): return get_pos_foldernames(exp_path) return pos_foldernames + def get_images_folderpath(folderpath): if os.path.isfile(folderpath): folderpath = os.path.dirname(folderpath) - if folderpath.endswith('Images'): + if folderpath.endswith("Images"): return folderpath - - images_folderpath = os.path.join(folderpath, 'Images') + + images_folderpath = os.path.join(folderpath, "Images") if os.path.exists(images_folderpath): return images_folderpath - - return '' - + + return "" + + def getMostRecentPath(): if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - if 'opened_last_on' in df.columns: - df = df.sort_values('opened_last_on', ascending=False) - MostRecentPath = '' - for path in df['path']: + df = pd.read_csv(recentPaths_path, index_col="index") + if "opened_last_on" in df.columns: + df = df.sort_values("opened_last_on", ascending=False) + MostRecentPath = "" + for path in df["path"]: if os.path.exists(path): MostRecentPath = path break else: - MostRecentPath = '' + MostRecentPath = "" return MostRecentPath + def addToRecentPaths(exp_path, logger=None): if not os.path.exists(exp_path): return - exp_path = exp_path.replace('\\', '/') + exp_path = exp_path.replace("\\", "/") if os.path.exists(recentPaths_path): try: - df = pd.read_csv(recentPaths_path, index_col='index') - recentPaths = df['path'].to_list() - if 'opened_last_on' in df.columns: - openedOn = df['opened_last_on'].to_list() + df = pd.read_csv(recentPaths_path, index_col="index") + recentPaths = df["path"].to_list() + if "opened_last_on" in df.columns: + openedOn = df["opened_last_on"].to_list() else: - openedOn = [np.nan]*len(recentPaths) + openedOn = [np.nan] * len(recentPaths) if exp_path in recentPaths: pop_idx = recentPaths.index(exp_path) recentPaths.pop(pop_idx) @@ -719,263 +746,294 @@ def addToRecentPaths(exp_path, logger=None): else: recentPaths = [exp_path] openedOn = [datetime.datetime.now()] - df = pd.DataFrame({ - 'path': recentPaths, - 'opened_last_on': pd.Series(openedOn, dtype='datetime64[ns]')} + df = pd.DataFrame( + { + "path": recentPaths, + "opened_last_on": pd.Series(openedOn, dtype="datetime64[ns]"), + } ) - df.index.name = 'index' + df.index.name = "index" df.to_csv(recentPaths_path) + def checkDataIntegrity(filenames, parent_path, parentQWidget=None): if not filenames: msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - 'Cell-ACDC could not find any files in the folder ' - f'{parent_path}.

    ' - 'Please make sure that the folder contains at least one image file.

    ' - 'Thank you for your patience!' - ) - msg.warning(parentQWidget, 'Selected folder is emppty', txt) - raise FileNotFoundError( - f'No files found in the folder {parent_path}. ' + "Cell-ACDC could not find any files in the folder " + f"{parent_path}.

    " + "Please make sure that the folder contains at least one image file.

    " + "Thank you for your patience!" ) - + msg.warning(parentQWidget, "Selected folder is emppty", txt) + raise FileNotFoundError(f"No files found in the folder {parent_path}. ") + char = filenames[0][:2] startWithSameChar = all([f.startswith(char) for f in filenames]) if not startWithSameChar: msg = widgets.myMessageBox() txt = html_utils.paragraph( - 'Cell-ACDC detected files inside the folder ' - 'that do not start with the same, common basename.

    ' - 'To ensure correct loading of the data, the folder where ' - 'the file(s) is/are should either contain a single image file or' - 'only files that start with the same, common basename.

    ' - 'For example the following filenames:

    ' - 'F014_s01_phase_contr.tif
    ' - 'F014_s01_mCitrine.tif

    ' - 'are named correctly since they all start with the ' + "Cell-ACDC detected files inside the folder " + "that do not start with the same, common basename.

    " + "To ensure correct loading of the data, the folder where " + "the file(s) is/are should either contain a single image file or" + "only files that start with the same, common basename.

    " + "For example the following filenames:

    " + "F014_s01_phase_contr.tif
    " + "F014_s01_mCitrine.tif

    " + "are named correctly since they all start with the " 'the common basename "F014_s01_". After the common basename you ' 'can write whatever text you want. In the example above, "phase_contr" ' 'and "mCitrine" are the channel names.

    ' - 'Data loading may still be successfull, so Cell-ACDC will ' - 'still try to load data now.
    ' + "Data loading may still be successfull, so Cell-ACDC will " + "still try to load data now.
    " ) - filesFormat = [f' - {file}' for file in filenames] + filesFormat = [f" - {file}" for file in filenames] filesFormat = "\n".join(filesFormat) - detailsText = ( - f'Files present in the folder {parent_path}:\n\n' - f'{filesFormat}' - ) - msg.addShowInFileManagerButton(parent_path, txt='Open folder...') + detailsText = f"Files present in the folder {parent_path}:\n\n{filesFormat}" + msg.addShowInFileManagerButton(parent_path, txt="Open folder...") msg.warning( - parentQWidget, 'Data structure compromised', txt, - detailsText=detailsText, buttonsTexts=('Cancel', 'Ok') + parentQWidget, + "Data structure compromised", + txt, + detailsText=detailsText, + buttonsTexts=("Cancel", "Ok"), ) if msg.cancel: - raise TypeError( - 'Process aborted by the user.' - ) + raise TypeError("Process aborted by the user.") return False return True + def get_cca_colname_desc(): desc = { - 'Cell ID': ( - 'ID of the segmented cell. All of the other columns ' - 'are properties of this ID.' + "Cell ID": ( + "ID of the segmented cell. All of the other columns " + "are properties of this ID." ), - 'Cell cycle stage': ( - 'G1 if the cell does NOT have a bud. S/G2/M if it does.' + "Cell cycle stage": ("G1 if the cell does NOT have a bud. S/G2/M if it does."), + "Relative ID": ( + "ID of the bud related to the Cell ID (row). For cells in G1 write the " + "bud ID it had in the previous cycle." ), - 'Relative ID': ( - 'ID of the bud related to the Cell ID (row). For cells in G1 write the ' - 'bud ID it had in the previous cycle.' + "Generation number": ( + "Number of times the cell divided from a bud. For cells in the first " + "frame write any number greater than 1." ), - 'Generation number': ( - 'Number of times the cell divided from a bud. For cells in the first ' - 'frame write any number greater than 1.' + "Relationship": ( + "Relationship of the current Cell ID (row). " + "Either mother or bud. An object is a bud if " + "it didn't divide from the mother yet. All other instances " + "(e.g., cell in G1) are still labelled as mother." ), - 'Relationship': ( - 'Relationship of the current Cell ID (row). ' - 'Either mother or bud. An object is a bud if ' - 'it didn\'t divide from the mother yet. All other instances ' - '(e.g., cell in G1) are still labelled as mother.' + "Emerging frame num.": ( + "Frame number at which the object emerged/appeared in the scene." ), - 'Emerging frame num.': ( - 'Frame number at which the object emerged/appeared in the scene.' + "Division frame num.": ( + "Frame number at which the bud separated from the mother." ), - 'Division frame num.': ( - 'Frame number at which the bud separated from the mother.' + "Is history known?": ( + "Cells that are already present in the first frame or appears " + "from outside of the field of view, have some information missing. " + "For example, for cells in the first frame we do not know how many " + "times it budded and divided in the past. " + "In these cases Is history known? is True." ), - 'Is history known?': ( - 'Cells that are already present in the first frame or appears ' - 'from outside of the field of view, have some information missing. ' - 'For example, for cells in the first frame we do not know how many ' - 'times it budded and divided in the past. ' - 'In these cases Is history known? is True.' - ) } return desc + def testQcoreApp(): print(QCoreApplication.instance()) + def store_custom_model_path(model_file_path): - model_file_path = model_file_path.replace('\\', '/') + model_file_path = model_file_path.replace("\\", "/") model_name = os.path.basename(os.path.dirname(model_file_path)) cp = config.ConfigParser() if os.path.exists(models_list_file_path): cp.read(models_list_file_path) if model_name not in cp: cp[model_name] = {} - cp[model_name]['path'] = model_file_path - with open(models_list_file_path, 'w') as configFile: + cp[model_name]["path"] = model_file_path + with open(models_list_file_path, "w") as configFile: cp.write(configFile) + def store_custom_promptable_model_path(promptable_model_file_path): - model_file_path = promptable_model_file_path.replace('\\', '/') + model_file_path = promptable_model_file_path.replace("\\", "/") model_name = os.path.basename(os.path.dirname(model_file_path)) cp = config.ConfigParser() if os.path.exists(promptable_models_list_file_path): cp.read(promptable_models_list_file_path) if model_name not in cp: cp[model_name] = {} - cp[model_name]['path'] = model_file_path - with open(promptable_models_list_file_path, 'w') as configFile: + cp[model_name]["path"] = model_file_path + with open(promptable_models_list_file_path, "w") as configFile: cp.write(configFile) + def check_git_installed(parent=None): try: - subprocess.check_call(['git', '--version'], shell=True) + subprocess.check_call(["git", "--version"], shell=True) return True except Exception as e: - print('='*20) + print("=" * 20) traceback.print_exc() - print('='*20) - git_url = 'https://git-scm.com/book/en/v2/Getting-Started-Installing-Git' + print("=" * 20) + git_url = "https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" In order to install javabridge you first need to install Git (it was not found).

    Close Cell-ACDC and follow the instructions - {html_utils.tag('here', f'a href="{git_url}"')}.

    + {html_utils.tag("here", f'a href="{git_url}"')}.

    NOTE: After installing Git you might need to restart the terminal. """) - msg.warning( - parent, 'Git not installed', txt - ) + msg.warning(parent, "Git not installed", txt) return False + def browse_url(url): import webbrowser + webbrowser.open(url) + def browse_docs(): browse_url(urls.docs_homepage) + def install_java(): try: - subprocess.check_call(['javac', '-version'], shell=True) + subprocess.check_call(["javac", "-version"], shell=True) return False except Exception as e: from . import widgets + win = widgets.installJavaDialog() win.exec_() return win.clickedButton == win.cancelButton + def install_javabridge(force_compile=False, attempt_uninstall_first=False): if attempt_uninstall_first: try: subprocess.check_call( - [sys.executable, '-m', 'pip', 'uninstall', '-y', 'javabridge'] + [sys.executable, "-m", "pip", "uninstall", "-y", "javabridge"] ) except Exception as e: pass - if sys.platform.startswith('win'): + if sys.platform.startswith("win"): if force_compile: subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', '-U', - 'git+https://github.com/SchmollerLab/python-javabridge-acdc'] + [ + sys.executable, + "-m", + "pip", + "install", + "-U", + "git+https://github.com/SchmollerLab/python-javabridge-acdc", + ] ) else: subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', '-U', - 'git+https://github.com/SchmollerLab/python-javabridge-windows'] + [ + sys.executable, + "-m", + "pip", + "install", + "-U", + "git+https://github.com/SchmollerLab/python-javabridge-windows", + ] ) elif is_mac: subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', '-U', - 'git+https://github.com/SchmollerLab/python-javabridge-acdc'] + [ + sys.executable, + "-m", + "pip", + "install", + "-U", + "git+https://github.com/SchmollerLab/python-javabridge-acdc", + ] ) elif is_linux: subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', '-U', - 'git+https://github.com/LeeKamentsky/python-javabridge.git@master'] + [ + sys.executable, + "-m", + "pip", + "install", + "-U", + "git+https://github.com/LeeKamentsky/python-javabridge.git@master", + ] ) -def is_in_bounds(x,y,X,Y): + +def is_in_bounds(x, y, X, Y): in_bounds = x >= 0 and x < X and y >= 0 and y < Y return in_bounds + def read_version(logger=None, return_success=False): cellacdc_parent_path = os.path.dirname(cellacdc_path) cellacdc_parent_folder = os.path.basename(cellacdc_parent_path) - if cellacdc_parent_folder == 'site-packages': + if cellacdc_parent_folder == "site-packages": from . import __version__ + version = __version__ success = True else: try: from setuptools_scm import get_version - version = get_version(root='..', relative_to=__file__) + + version = get_version(root="..", relative_to=__file__) success = True except Exception as e: if logger is None: logger = print - logger('*'*40) + logger("*" * 40) logger(traceback.format_exc()) - logger('-'*40) + logger("-" * 40) logger( - '[WARNING]: Cell-ACDC could not determine the current version. ' - 'Returning the version determined at installation time. ' - 'See details above.' + "[WARNING]: Cell-ACDC could not determine the current version. " + "Returning the version determined at installation time. " + "See details above." ) - logger('='*40) + logger("=" * 40) try: from . import _version + version = _version.version success = False except Exception as e: - version = 'ND' + version = "ND" success = False - + if return_success: return version, success else: return version -def get_date_from_version(version: str, package='cellacdc', debug=False): + +def get_date_from_version(version: str, package="cellacdc", debug=False): try: - response = requests.get( - f'https://pypi.org/pypi/{package}/json', - timeout=2 - ) + response = requests.get(f"https://pypi.org/pypi/{package}/json", timeout=2) res_json = response.json() - pypi_releases_json = res_json['releases'] + pypi_releases_json = res_json["releases"] version_json = pypi_releases_json[version][0] - upload_time = version_json['upload_time_iso_8601'] - date = datetime.datetime.strptime( - upload_time, r'%Y-%m-%dT%H:%M:%S.%fZ' - ) - date_str = date.strftime(r'%A %d %B %Y at %H:%M') + upload_time = version_json["upload_time_iso_8601"] + date = datetime.datetime.strptime(upload_time, r"%Y-%m-%dT%H:%M:%S.%fZ") + date_str = date.strftime(r"%A %d %B %Y at %H:%M") return date_str except Exception as err: if debug: traceback.print_exc() - + try: - # Locate the direct_url.json file for the package + # Locate the direct_url.json file for the package # installed with pip git+ dist = importlib.metadata.distribution(package) dist_info_dir = dist._path # internal path to .dist-info @@ -992,53 +1050,52 @@ def get_date_from_version(version: str, package='cellacdc', debug=False): owner, repo = parts.split("/", 1) # Query GitHub API for commit date - api_url = ( - f"https://api.github.com/repos/{owner}/{repo}/commits/{commit_id}" - ) + api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{commit_id}" response = requests.get(api_url) response.raise_for_status() commit_data = response.json() date_utc = commit_data["commit"]["committer"]["date"] - + date_str = format_commit_date_utc(date_utc) - + return date_str except Exception as err: if debug: traceback.print_exc() - + try: - if package == 'cellacdc': + if package == "cellacdc": pkg_path = cellacdc_path - elif package == 'spotmax': + elif package == "spotmax": from spotmax import spotmax_path + pkg_path = spotmax_path - commit_hash = re.findall(r'\+g([A-Za-z0-9]+)(\.d)?', version)[0][0] + commit_hash = re.findall(r"\+g([A-Za-z0-9]+)(\.d)?", version)[0][0] git_path = os.path.dirname(pkg_path) - command = f'git -C {git_path} show {commit_hash}' + command = f"git -C {git_path} show {commit_hash}" commit_log = _subprocess_run_command( - command, shell=False, callback='check_output' + command, shell=False, callback="check_output" ) - commit_log = commit_log.decode() - date_log = re.findall(r'Date:(.*) \+', commit_log)[0].strip() - date = datetime.datetime.strptime(date_log, r'%a %b %d %H:%M:%S %Y') - date_str = date.strftime(r'%A %d %B %Y at %H:%M') + commit_log = commit_log.decode() + date_log = re.findall(r"Date:(.*) \+", commit_log)[0].strip() + date = datetime.datetime.strptime(date_log, r"%a %b %d %H:%M:%S %Y") + date_str = date.strftime(r"%A %d %B %Y at %H:%M") return date_str except Exception as err: if debug: traceback.print_exc() - - return 'ND' + + return "ND" + def get_git_branch_name(): - command = 'git rev-parse --abbrev-ref HEAD' - output = _subprocess_run_command( - command, shell=False, callback='check_output' - ) + command = "git rev-parse --abbrev-ref HEAD" + output = _subprocess_run_command(command, shell=False, callback="check_output") branch_name = output.decode().strip() return branch_name + def showInExplorer(path): if is_mac: os.system(f'open "{path}"') @@ -1047,27 +1104,31 @@ def showInExplorer(path): else: os.startfile(path) + def exec_time(func): @wraps(func) def inner_function(self, *args, **kwargs): t0 = time.perf_counter() - if func.__code__.co_argcount==1 and func.__defaults__ is None: + if func.__code__.co_argcount == 1 and func.__defaults__ is None: result = func(self) - elif func.__code__.co_argcount>1 and func.__defaults__ is None: + elif func.__code__.co_argcount > 1 and func.__defaults__ is None: result = func(self, *args) else: result = func(self, *args, **kwargs) t1 = time.perf_counter() - s = f'{func.__name__} execution time = {(t1-t0)*1000:.3f} ms' + s = f"{func.__name__} execution time = {(t1 - t0) * 1000:.3f} ms" printl(s, is_decorator=True) return result + return inner_function + def setRetainSizePolicy(widget, retain=True): sp = widget.sizePolicy() sp.setRetainSizeWhenHidden(retain) widget.setSizePolicy(sp) + def getAcdcDfSegmPaths(images_path): ls = listdir(images_path) basename = getBasename(ls) @@ -1075,31 +1136,32 @@ def getAcdcDfSegmPaths(images_path): for file in ls: filePath = os.path.join(images_path, file) fileName, ext = os.path.splitext(file) - endName = fileName[len(basename):] - if endName.find('acdc_output') != -1 and ext=='.csv': - info_name = endName.replace('acdc_output', '') + endName = fileName[len(basename) :] + if endName.find("acdc_output") != -1 and ext == ".csv": + info_name = endName.replace("acdc_output", "") paths.setdefault(info_name, {}) - paths[info_name]['acdc_df_path'] = filePath - paths[info_name]['acdc_df_filename'] = fileName - elif endName.find('segm') != -1 and ext=='.npz': - info_name = endName.replace('segm', '') + paths[info_name]["acdc_df_path"] = filePath + paths[info_name]["acdc_df_filename"] = fileName + elif endName.find("segm") != -1 and ext == ".npz": + info_name = endName.replace("segm", "") paths.setdefault(info_name, {}) - paths[info_name]['segm_path'] = filePath - paths[info_name]['segm_filename'] = fileName + paths[info_name]["segm_path"] = filePath + paths[info_name]["segm_filename"] = fileName return paths + def getChannelFilePath(images_path, chName): - file = '' - alignedFilePath = '' - tifFilePath = '' - h5FilePath = '' + file = "" + alignedFilePath = "" + tifFilePath = "" + h5FilePath = "" for file in listdir(images_path): filePath = os.path.join(images_path, file) - if file.endswith(f'{chName}_aligned.npz'): + if file.endswith(f"{chName}_aligned.npz"): alignedFilePath = filePath - elif file.endswith(f'{chName}.tif'): + elif file.endswith(f"{chName}.tif"): tifFilePath = filePath - elif file.endswith(f'{chName}.h5'): + elif file.endswith(f"{chName}.h5"): h5FilePath = filePath if alignedFilePath: return alignedFilePath @@ -1108,27 +1170,30 @@ def getChannelFilePath(images_path, chName): elif tifFilePath: return tifFilePath else: - return '' + return "" + def get_number_fstring_formatter(dtype, precision=4): if np.issubdtype(dtype, np.integer): - return 'd' + return "d" else: - return f'.{precision}f' + return f".{precision}f" + def get_chname_from_basename(filename, basename, remove_ext=True): if remove_ext: filename, ext = os.path.splitext(filename) - chName = filename[len(basename):] - aligned_idx = chName.find('_aligned') + chName = filename[len(basename) :] + aligned_idx = chName.find("_aligned") if aligned_idx != -1: chName = chName[:aligned_idx] return chName + def getBaseAcdcDf(rp): - zeros_list = [0]*len(rp) - nones_list = [None]*len(rp) - minus1_list = [-1]*len(rp) + zeros_list = [0] * len(rp) + nones_list = [None] * len(rp) + minus1_list = [-1] * len(rp) IDs = [] xx_centroid = [] yy_centroid = [] @@ -1141,336 +1206,340 @@ def getBaseAcdcDf(rp): if len(obj.centroid) == 3: zc = obj.centroid[0] zz_centroid.append(zc) - + df = pd.DataFrame( { - 'Cell_ID': IDs, - 'is_cell_dead': zeros_list, - 'is_cell_excluded': zeros_list, - 'x_centroid': xx_centroid, - 'y_centroid': yy_centroid, - 'was_manually_edited': minus1_list + "Cell_ID": IDs, + "is_cell_dead": zeros_list, + "is_cell_excluded": zeros_list, + "x_centroid": xx_centroid, + "y_centroid": yy_centroid, + "was_manually_edited": minus1_list, } - ).set_index('Cell_ID') + ).set_index("Cell_ID") if zz_centroid: - df['z_centroid'] = zz_centroid - + df["z_centroid"] = zz_centroid + return df + def getBasenameAndChNames(images_path, useExt=None): _tempPosData = utilClass() _tempPosData.images_path = images_path load.loadData.getBasenameAndChNames(_tempPosData, useExt=useExt) return _tempPosData.basename, _tempPosData.chNames + def getBasename(files): basename = files[0] for file in files: # Determine the basename based on intersection of all files _, ext = os.path.splitext(file) sm = difflib.SequenceMatcher(None, file, basename) - i, j, k = sm.find_longest_match( - 0, len(file), 0, len(basename) - ) - basename = file[i:i+k] + i, j, k = sm.find_longest_match(0, len(file), 0, len(basename)) + basename = file[i : i + k] return basename + def findalliter(patter, string): """Function used to return all re.findall objects in string""" - m_test = re.findall(r'(\d+)_(.+)', string) + m_test = re.findall(r"(\d+)_(.+)", string) m_iter = [m_test] while m_test: - m_test = re.findall(r'(\d+)_(.+)', m_test[0][1]) + m_test = re.findall(r"(\d+)_(.+)", m_test[0][1]) m_iter.append(m_test) return m_iter + def clipSelemMask(mask, shape, Yc, Xc, copy=True): if copy: mask = mask.copy() - + Y, X = shape h, w = mask.shape # Bottom, Left, Top, Right global coordinates of mask - Y0, X0, Y1, X1 = Yc-(h/2), Xc-(w/2), Yc+(h/2), Xc+(w/2) - mask_limits = [floor(Y0)+1, floor(X0)+1, floor(Y1)+1, floor(X1)+1] - - if Y0>=0 and X0>=0 and Y1<=Y and X1<=X: + Y0, X0, Y1, X1 = Yc - (h / 2), Xc - (w / 2), Yc + (h / 2), Xc + (w / 2) + mask_limits = [floor(Y0) + 1, floor(X0) + 1, floor(Y1) + 1, floor(X1) + 1] + + if Y0 >= 0 and X0 >= 0 and Y1 <= Y and X1 <= X: # Mask is withing shape boundaries, no need to clip ystart, xstart, yend, xend = mask_limits mask_slice = slice(ystart, yend), slice(xstart, xend) return mask, mask_slice - if Y0<0: + if Y0 < 0: # Mask is exceeding at the bottom ystart = floor(abs(Y0)) mask_limits[0] = 0 mask = mask[ystart:] - if X0<0: + if X0 < 0: # Mask is exceeding at the left xstart = floor(abs(X0)) mask_limits[1] = 0 mask = mask[:, xstart:] - if Y1>Y: + if Y1 > Y: # Mask is exceeding at the top yend = ceil(abs(Y1)) - Y mask_limits[2] = Y mask = mask[:-yend] - if X1>X: + if X1 > X: # Mask is exceeding at the right xend = ceil(abs(X1)) - X mask_limits[3] = X mask = mask[:, :-xend] - + ystart, xstart, yend, xend = mask_limits mask_slice = slice(ystart, yend), slice(xstart, xend) return mask, mask_slice def listdir(path) -> List[str]: - return natsorted([ - f for f in os.listdir(path) - if not f.startswith('.') - and not f == 'desktop.ini' - and not f == 'recovery' - and not f.endswith('.new.npz') - ]) - -def setDefaultValueArgSpecsFromKwargs( - params: List[ArgSpec], - kwargs: Dict[str, object] - ): + return natsorted( + [ + f + for f in os.listdir(path) + if not f.startswith(".") + and not f == "desktop.ini" + and not f == "recovery" + and not f.endswith(".new.npz") + ] + ) + + +def setDefaultValueArgSpecsFromKwargs(params: List[ArgSpec], kwargs: Dict[str, object]): new_params = [] for param in params: new_value = kwargs.get(param.name) if new_value is None: new_params.append(param) continue - + new_param = ArgSpec( - name=param.name, - default=new_value, - type=param.type, + name=param.name, + default=new_value, + type=param.type, desc=param.desc, - docstring=param.docstring + docstring=param.docstring, ) new_params.append(new_param) return new_params + def insertModelArgSpec( - params, param_name, param_value, param_type=None, desc='', - docstring='' - ): + params, param_name, param_value, param_type=None, desc="", docstring="" +): updated_params = [] for param in params: if param.name == param_name: if param_type is None: param_type = param.type new_param = ArgSpec( - name=param_name, default=param_value, type=param_type, - desc=desc, docstring=docstring + name=param_name, + default=param_value, + type=param_type, + desc=desc, + docstring=docstring, ) updated_params.append(new_param) else: updated_params.append(param) return updated_params -def get_function_argspec(function, args_to_skip={'logger_func',}): + +def get_function_argspec( + function, + args_to_skip={ + "logger_func", + }, +): argspecs = inspect.getfullargspec(function) kwargs_type_hints = typing.get_type_hints(function) docstring = function.__doc__ params = params_to_ArgSpec( - argspecs, kwargs_type_hints, docstring, - args_to_skip=args_to_skip + argspecs, kwargs_type_hints, docstring, args_to_skip=args_to_skip ) return params + def getModelArgSpec(acdcSegment): init_ArgSpec = inspect.getfullargspec(acdcSegment.Model.__init__) init_kwargs_type_hints = typing.get_type_hints(acdcSegment.Model.__init__) init_doc = acdcSegment.Model.__init__.__doc__ - init_params = params_to_ArgSpec( - init_ArgSpec, init_kwargs_type_hints, init_doc - ) + init_params = params_to_ArgSpec(init_ArgSpec, init_kwargs_type_hints, init_doc) init_params = add_segm_data_param(init_params, init_ArgSpec) - + segment_ArgSpec = inspect.getfullargspec(acdcSegment.Model.segment) segment_kwargs_type_hints = typing.get_type_hints(acdcSegment.Model.segment) try: - segment_ArgSpec.args.remove('frame_i') + segment_ArgSpec.args.remove("frame_i") except Exception as e: pass - + segment_doc = acdcSegment.Model.segment.__doc__ segment_params = params_to_ArgSpec( - segment_ArgSpec, segment_kwargs_type_hints, segment_doc, + segment_ArgSpec, + segment_kwargs_type_hints, + segment_doc, ) - + return init_params, segment_params + def _get_doc_stop_idx(docstring, start_idx, next_param_name=None, debug=False): if debug: - import pdb; pdb.set_trace() - + import pdb + + pdb.set_trace() + if next_param_name is not None: - doc_stop_idx = docstring.find(f'{next_param_name} : ') + doc_stop_idx = docstring.find(f"{next_param_name} : ") if doc_stop_idx > 1: return doc_stop_idx - + docstring_from_start = docstring[start_idx:] - next_param_searched = re.search(r'\w+ : ', docstring_from_start) + next_param_searched = re.search(r"\w+ : ", docstring_from_start) if next_param_searched is not None: return next_param_searched.start(0) + start_idx - - doc_stop_idx = docstring.find('Returns') + + doc_stop_idx = docstring.find("Returns") if doc_stop_idx > 1: return doc_stop_idx - - doc_stop_idx = docstring.find('Notes') + + doc_stop_idx = docstring.find("Notes") if doc_stop_idx > 1: - return doc_stop_idx - + return doc_stop_idx + return -1 + def parse_model_param_doc(name, next_param_name=None, docstring=None): if not docstring: - return '' - + return "" + try: # Extract parameter description from 'param : ...' - start_text = f'{name} : ' + start_text = f"{name} : " if docstring.find(start_text) == -1: # Parameter not present in docstring - return '' - + return "" + doc_start_idx = docstring.find(start_text) + len(start_text) - + doc_stop_idx = _get_doc_stop_idx( docstring, doc_start_idx, next_param_name=next_param_name ) if doc_stop_idx == -1: doc_stop_idx = len(docstring) - + param_doc = docstring[doc_start_idx:doc_stop_idx] - + # Start at first end of line - param_doc = param_doc[param_doc.find('\n')+1:] - + param_doc = param_doc[param_doc.find("\n") + 1 :] + # Replace multiples spaces with single space - param_doc = re.sub(' +', ' ', param_doc) - + param_doc = re.sub(" +", " ", param_doc) + # Remove trailing spaces param_doc = param_doc.strip() except Exception as err: - param_doc = '' - - param_doc = param_doc.replace(', optional', '') - + param_doc = "" + + param_doc = param_doc.replace(", optional", "") + return param_doc + def add_segm_data_param(init_params, init_argspecs): if init_argspecs.defaults is None: num_kwargs = 0 else: num_kwargs = len(init_argspecs.defaults) - + # Segm model requires segm data --> add it to params num_args = len(init_argspecs.args) - num_kwargs if num_args == 1: # Args is only self --> segm data not needed return init_params - + desc = ( -'This model requires an additional segmentation file as input.\n\n' -'Please, select which segmentation file to provide to the model.' + "This model requires an additional segmentation file as input.\n\n" + "Please, select which segmentation file to provide to the model." ) - + segm_data_argspec = ArgSpec( - name='Auxiliary segmentation file', - default='', - type=str, + name="Auxiliary segmentation file", + default="", + type=str, desc=desc, - docstring=None + docstring=None, ) - + init_params.insert(0, segm_data_argspec) return init_params -def params_to_ArgSpec( - fullargspecs, type_hints, docstring, args_to_skip=None - ): + +def params_to_ArgSpec(fullargspecs, type_hints, docstring, args_to_skip=None): params = [] - + if fullargspecs.defaults is None: return params - + if args_to_skip is None: args_to_skip = set() - + num_params = len(fullargspecs.args) ip = num_params - len(fullargspecs.defaults) if ip < 0: return params - + for arg, default in zip(fullargspecs.args[ip:], fullargspecs.defaults): if arg in args_to_skip: continue - + if arg in type_hints: _type = type_hints[arg] else: _type = type(default) - + next_param_name = None - if ip+1 < num_params: - next_param_name = fullargspecs.args[ip+1] - + if ip + 1 < num_params: + next_param_name = fullargspecs.args[ip + 1] + param_doc = parse_model_param_doc( - arg, - next_param_name=next_param_name, - docstring=docstring + arg, next_param_name=next_param_name, docstring=docstring ) param = ArgSpec( - name=arg, - default=default, - type=_type, - desc=param_doc, - docstring=docstring + name=arg, default=default, type=_type, desc=param_doc, docstring=docstring ) params.append(param) ip += 1 return params -def getClassArgSpecs(classModule, runMethodName='run'): + +def getClassArgSpecs(classModule, runMethodName="run"): init_ArgSpec = inspect.getfullargspec(classModule.__init__) - init_kwargs_type_hints = typing.get_type_hints( - classModule.__init__ - ) + init_kwargs_type_hints = typing.get_type_hints(classModule.__init__) init_doc = classModule.__init__.__doc__ - init_params = params_to_ArgSpec( - init_ArgSpec, init_kwargs_type_hints, init_doc - ) - + init_params = params_to_ArgSpec(init_ArgSpec, init_kwargs_type_hints, init_doc) + run_ArgSpec = inspect.getfullargspec(getattr(classModule, runMethodName)) - run_kwargs_type_hints = typing.get_type_hints( - getattr(classModule, runMethodName) - ) + run_kwargs_type_hints = typing.get_type_hints(getattr(classModule, runMethodName)) run_doc = getattr(classModule, runMethodName).__doc__ run_params = params_to_ArgSpec( - run_ArgSpec, run_kwargs_type_hints, run_doc, - args_to_skip={'signals', 'export_to'} + run_ArgSpec, + run_kwargs_type_hints, + run_doc, + args_to_skip={"signals", "export_to"}, ) return init_params, run_params + def getTrackerArgSpec(trackerModule, realTime=False): init_ArgSpec = inspect.getfullargspec(trackerModule.tracker.__init__) - init_kwargs_type_hints = typing.get_type_hints( - trackerModule.tracker.__init__ - ) + init_kwargs_type_hints = typing.get_type_hints(trackerModule.tracker.__init__) init_doc = trackerModule.tracker.__init__.__doc__ - init_params = params_to_ArgSpec( - init_ArgSpec, init_kwargs_type_hints, init_doc - ) + init_params = params_to_ArgSpec(init_ArgSpec, init_kwargs_type_hints, init_doc) if realTime: track_ArgSpec = inspect.getfullargspec(trackerModule.tracker.track_frame) track_kwargs_type_hints = typing.get_type_hints( @@ -1479,55 +1548,61 @@ def getTrackerArgSpec(trackerModule, realTime=False): track_doc = trackerModule.tracker.track_frame.__doc__ else: track_ArgSpec = inspect.getfullargspec(trackerModule.tracker.track) - track_kwargs_type_hints = typing.get_type_hints( - trackerModule.tracker.track - ) + track_kwargs_type_hints = typing.get_type_hints(trackerModule.tracker.track) track_doc = trackerModule.tracker.track.__doc__ track_params = params_to_ArgSpec( - track_ArgSpec, track_kwargs_type_hints, track_doc, - args_to_skip={'signals', 'export_to'} + track_ArgSpec, + track_kwargs_type_hints, + track_doc, + args_to_skip={"signals", "export_to"}, ) return init_params, track_params + def isIntensityImgRequiredForTracker(trackerModule): track_ArgSpec = inspect.getfullargspec(trackerModule.tracker.track) num_args = len(track_ArgSpec.args) - len(track_ArgSpec.defaults) - # If the number of args is 3 then we have `self, labels, image` as args - # which means the tracker requires the image - return num_args == 3 + # If the number of args is 3 then we have `self, labels, image` as args + # which means the tracker requires the image + return num_args == 3 + def getDefault_SegmInfo_df(posData, filename): - mid_slice = int(posData.SizeZ/2) - df = pd.DataFrame({ - 'filename': [filename]*posData.SizeT, - 'frame_i': range(posData.SizeT), - 'z_slice_used_dataPrep': [mid_slice]*posData.SizeT, - 'which_z_proj': ['single z-slice']*posData.SizeT, - 'z_slice_used_gui': [mid_slice]*posData.SizeT, - 'which_z_proj_gui': ['single z-slice']*posData.SizeT, - 'resegmented_in_gui': [False]*posData.SizeT, - 'is_from_dataPrep': [False]*posData.SizeT - }).set_index(['filename', 'frame_i']) + mid_slice = int(posData.SizeZ / 2) + df = pd.DataFrame( + { + "filename": [filename] * posData.SizeT, + "frame_i": range(posData.SizeT), + "z_slice_used_dataPrep": [mid_slice] * posData.SizeT, + "which_z_proj": ["single z-slice"] * posData.SizeT, + "z_slice_used_gui": [mid_slice] * posData.SizeT, + "which_z_proj_gui": ["single z-slice"] * posData.SizeT, + "resegmented_in_gui": [False] * posData.SizeT, + "is_from_dataPrep": [False] * posData.SizeT, + } + ).set_index(["filename", "frame_i"]) return df + def get_examples_path(which): - if which == 'time_lapse_2D': - foldername = 'TimeLapse_2D' - url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/KgJQtsQKZJnWZjL/download/TimeLapse_2D.zip' + if which == "time_lapse_2D": + foldername = "TimeLapse_2D" + url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/KgJQtsQKZJnWZjL/download/TimeLapse_2D.zip" file_size = 45143552 - elif which == 'snapshots_3D': - foldername = 'Multi_3D_zStack_Analysed' - url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/3RNjGiPwKcdnGtj/download/Yeast_Analysed_multi3D_zStacks.zip' + elif which == "snapshots_3D": + foldername = "Multi_3D_zStack_Analysed" + url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/3RNjGiPwKcdnGtj/download/Yeast_Analysed_multi3D_zStacks.zip" file_size = 124822528 else: - return '' - - examples_path = os.path.join(user_profile_path, 'acdc-examples') + return "" + + examples_path = os.path.join(user_profile_path, "acdc-examples") example_path = os.path.join(examples_path, foldername) return examples_path, example_path, url, file_size -def download_examples(which='time_lapse_2D', progress=None): + +def download_examples(which="time_lapse_2D", progress=None): examples_path, example_path, url, file_size = get_examples_path(which) if os.path.exists(example_path): if progress is not None: @@ -1535,17 +1610,14 @@ def download_examples(which='time_lapse_2D', progress=None): progress.emit(0, 0) return example_path - zip_dst = os.path.join(examples_path, 'example_temp.zip') + zip_dst = os.path.join(examples_path, "example_temp.zip") if not os.path.exists(examples_path): os.makedirs(examples_path, exist_ok=True) - print(f'Downloading example to {example_path}') + print(f"Downloading example to {example_path}") - download_url( - url, zip_dst, verbose=False, file_size=file_size, - progress=progress - ) + download_url(url, zip_dst, verbose=False, file_size=file_size, progress=progress) exctract_to = examples_path extract_zip(zip_dst, exctract_to) @@ -1555,105 +1627,112 @@ def download_examples(which='time_lapse_2D', progress=None): # Remove downloaded zip archive os.remove(zip_dst) - print('Example downloaded successfully') + print("Example downloaded successfully") return example_path + def get_acdc_java_path(): - acdc_java_path = os.path.join(user_profile_path, 'acdc-java') - dot_acdc_java_path = os.path.join(user_profile_path, '.acdc-java') + acdc_java_path = os.path.join(user_profile_path, "acdc-java") + dot_acdc_java_path = os.path.join(user_profile_path, ".acdc-java") return acdc_java_path, dot_acdc_java_path + def get_java_url(): - is_linux = sys.platform.startswith('linux') - is_mac = sys.platform == 'darwin' + is_linux = sys.platform.startswith("linux") + is_mac = sys.platform == "darwin" is_win = sys.platform.startswith("win") - is_win64 = (is_win and (os.environ["PROCESSOR_ARCHITECTURE"] == "AMD64")) + is_win64 = is_win and (os.environ["PROCESSOR_ARCHITECTURE"] == "AMD64") # https://drive.google.com/drive/u/0/folders/1MxhySsxB1aBrqb31QmLfVpq8z1vDyLbo if is_win64: - os_foldername = 'win64' - unzipped_foldername = 'java_portable_windows-0.1' + os_foldername = "win64" + unzipped_foldername = "java_portable_windows-0.1" file_size = 214798150 # url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/eMyirTw8qG2wJMt/download/java_portable_windows-0.1.zip' - url = 'https://github.com/SchmollerLab/java_portable_windows/archive/refs/tags/v0.1.zip' + url = "https://github.com/SchmollerLab/java_portable_windows/archive/refs/tags/v0.1.zip" elif is_mac: - os_foldername = 'macOS' - unzipped_foldername = 'java_portable_macos-0.1' - url = 'https://github.com/SchmollerLab/java_portable_macos/archive/refs/tags/v0.1.zip' + os_foldername = "macOS" + unzipped_foldername = "java_portable_macos-0.1" + url = "https://github.com/SchmollerLab/java_portable_macos/archive/refs/tags/v0.1.zip" # url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/SjZb8aommXgrECq/download/java_portable_macos-0.1.zip' file_size = 108478751 elif is_linux: - os_foldername = 'linux' - unzipped_foldername = 'java_portable_linux-0.1' - url = 'https://github.com/SchmollerLab/java_portable_linux/archive/refs/tags/v0.1.zip' + os_foldername = "linux" + unzipped_foldername = "java_portable_linux-0.1" + url = "https://github.com/SchmollerLab/java_portable_linux/archive/refs/tags/v0.1.zip" # url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/HjeQagixE2cjbZL/download/java_portable_linux-0.1.zip' file_size = 92520706 return url, file_size, os_foldername, unzipped_foldername + def _jdk_exists(jre_path): # If jre_path exists and it's windows search for ~/acdc-java/win64/jdk # or ~/.acdc-java/win64/jdk. If not Windows return jre_path if not jre_path: - return '' + return "" os_acdc_java_path = os.path.dirname(jre_path) os_foldername = os.path.basename(os_acdc_java_path) - if not os_foldername.startswith('win'): + if not os_foldername.startswith("win"): return jre_path if os.path.exists(os_acdc_java_path): for folder in os.listdir(os_acdc_java_path): - if not folder.startswith('jdk'): + if not folder.startswith("jdk"): continue - dir_path = os.path.join(os_acdc_java_path, folder) + dir_path = os.path.join(os_acdc_java_path, folder) for file in os.listdir(dir_path): - if file == 'bin': + if file == "bin": return dir_path - return '' + return "" + def get_package_version(import_pkg_name): import importlib.metadata - version = importlib.metadata.version(import_pkg_name) + + version = importlib.metadata.version(import_pkg_name) return version + def check_upgrade_javabridge(): try: - version = get_package_version('javabridge') + version = get_package_version("javabridge") except Exception as e: return - patch = int(version.split('.')[2]) + patch = int(version.split(".")[2]) if patch > 18: return install_javabridge() + def _java_exists(os_foldername): acdc_java_path, dot_acdc_java_path = get_acdc_java_path() os_acdc_java_path = os.path.join(acdc_java_path, os_foldername) if os.path.exists(os_acdc_java_path): for folder in os.listdir(os_acdc_java_path): - if not folder.startswith('jre'): + if not folder.startswith("jre"): continue - dir_path = os.path.join(os_acdc_java_path, folder) + dir_path = os.path.join(os_acdc_java_path, folder) for file in os.listdir(dir_path): - if file == 'bin': + if file == "bin": return dir_path # Some users still has the old .acdc folder --> check os_dot_acdc_java_path = os.path.join(dot_acdc_java_path, os_foldername) if os.path.exists(os_dot_acdc_java_path): for folder in os.listdir(os_dot_acdc_java_path): - if not folder.startswith('jre'): + if not folder.startswith("jre"): continue - dir_path = os.path.join(os_dot_acdc_java_path, folder) + dir_path = os.path.join(os_dot_acdc_java_path, folder) for file in os.listdir(dir_path): - if file == 'bin': + if file == "bin": return dir_path - return '' + return "" # Check if the user unzipped the javabridge_portable folder and not its content os_acdc_java_path = os.path.join(acdc_java_path, os_foldername) if os.path.exists(os_acdc_java_path): for folder in os.listdir(os_acdc_java_path): - dir_path = os.path.join(os_acdc_java_path, folder) - if folder.startswith('java_portable') and os.path.isdir(dir_path): + dir_path = os.path.join(os_acdc_java_path, folder) + if folder.startswith("java_portable") and os.path.isdir(dir_path): # Move files one level up unzipped_path = os.path.join(os_acdc_java_path, folder) for name in os.listdir(unzipped_path): @@ -1666,19 +1745,20 @@ def _java_exists(os_foldername): pass # Check if what we moved one level up was actually java for folder in os.listdir(os_acdc_java_path): - if not folder.startswith('jre'): + if not folder.startswith("jre"): continue - dir_path = os.path.join(os_acdc_java_path, folder) + dir_path = os.path.join(os_acdc_java_path, folder) for file in os.listdir(dir_path): - if file == 'bin': + if file == "bin": return dir_path - return '' + return "" + def download_java(): url, file_size, os_foldername, unzipped_foldername = get_java_url() jre_path = _java_exists(os_foldername) jdk_path = _jdk_exists(jre_path) - if os_foldername.startswith('win') and jre_path and jdk_path: + if os_foldername.startswith("win") and jre_path and jdk_path: return jre_path, jdk_path, url if jre_path: @@ -1687,18 +1767,18 @@ def download_java(): acdc_java_path, _ = get_acdc_java_path() os_acdc_java_path = os.path.join(acdc_java_path, os_foldername) - temp_zip = os.path.join(os_acdc_java_path, 'acdc_java_temp.zip') + temp_zip = os.path.join(os_acdc_java_path, "acdc_java_temp.zip") if not os.path.exists(os_acdc_java_path): os.makedirs(os_acdc_java_path, exist_ok=True) try: - download_url(url, temp_zip, file_size=file_size, desc='Java') + download_url(url, temp_zip, file_size=file_size, desc="Java") extract_zip(temp_zip, os_acdc_java_path) except Exception as e: - print('=======================') + print("=======================") traceback.print_exc() - print('=======================') + print("=======================") finally: os.remove(temp_zip) @@ -1717,17 +1797,18 @@ def download_java(): jdk_path = _jdk_exists(jre_path) return jre_path, jdk_path, url + def get_model_path(model_name, create_temp_dir=True): - if model_name == 'Automatic thresholding': - model_name == 'thresholding' - - model_info_path = os.path.join(models_path, model_name, 'model') - + if model_name == "Automatic thresholding": + model_name == "thresholding" + + model_info_path = os.path.join(models_path, model_name, "model") + if os.path.exists(model_info_path): for file in listdir(model_info_path): - if file != 'weights_location_path.txt': + if file != "weights_location_path.txt": continue - with open(os.path.join(model_info_path, file), 'r') as txt: + with open(os.path.join(model_info_path, file), "r") as txt: model_path = txt.read() model_path = os.path.expanduser(model_path) if not os.path.exists(model_path): @@ -1740,109 +1821,107 @@ def get_model_path(model_name, create_temp_dir=True): os.makedirs(model_info_path, exist_ok=True) model_path = _write_model_location_to_txt(model_name) - model_path = migrate_to_new_user_profile_path(model_path) - + model_path = migrate_to_new_user_profile_path(model_path) + if not os.path.exists(model_path): os.makedirs(model_path, exist_ok=True) if not create_temp_dir: - return '', model_path + return "", model_path exists = check_model_exists(model_path, model_name) if exists: - return '', model_path + return "", model_path temp_zip_path = _create_temp_dir() return temp_zip_path, model_path + def check_model_exists(model_path, model_name): try: import cellacdc + m = model_name.lower() - weights_filenames = getattr(cellacdc, f'{m}_weights_filenames') + weights_filenames = getattr(cellacdc, f"{m}_weights_filenames") files_present = listdir(model_path) return all([f in files_present for f in weights_filenames]) except Exception as e: return True - + + def _create_temp_dir(): temp_model_path = tempfile.mkdtemp() - temp_zip_path = os.path.join(temp_model_path, 'model_temp.zip') + temp_zip_path = os.path.join(temp_model_path, "model_temp.zip") return temp_zip_path + def _model_url(model_name, return_alternative=False): - if model_name == 'YeaZ': - url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/8PMePcwJXmaMMS6/download/YeaZ_weights.zip' - alternative_url = 'https://zenodo.org/record/6125825/files/YeaZ_weights.zip?download=1' + if model_name == "YeaZ": + url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/8PMePcwJXmaMMS6/download/YeaZ_weights.zip" + alternative_url = ( + "https://zenodo.org/record/6125825/files/YeaZ_weights.zip?download=1" + ) file_size = 693685011 - elif model_name == 'YeastMate': - url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/pMT8pAmMkNtN8BP/download/yeastmate_weights.zip' - alternative_url = 'https://zenodo.org/record/6140067/files/yeastmate_weights.zip?download=1' + elif model_name == "YeastMate": + url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/pMT8pAmMkNtN8BP/download/yeastmate_weights.zip" + alternative_url = ( + "https://zenodo.org/record/6140067/files/yeastmate_weights.zip?download=1" + ) file_size = 164911104 - elif model_name == 'segment_anything': + elif model_name == "segment_anything": url = [ - 'https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth', - 'https://dl.fbaipublicfiles.com/segment_anything/sam_vit_l_0b3195.pth', - 'https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth' + "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth", + "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_l_0b3195.pth", + "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth", ] file_size = [2564550879, 1249524736, 375042383] - alternative_url = '' - elif model_name == 'YeaZ_v2': + alternative_url = "" + elif model_name == "YeaZ_v2": url = [ - 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/5PARckkcJcN9D3S/download/weights_budding_BF_multilab_0_1', - 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/CTHq4HN3adyFbnE/download/weights_budding_PhC_multilab_0_1', - 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/QTtBJycYnLQZsHQ/download/weights_fission_multilab_0_2' + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/5PARckkcJcN9D3S/download/weights_budding_BF_multilab_0_1", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/CTHq4HN3adyFbnE/download/weights_budding_PhC_multilab_0_1", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/QTtBJycYnLQZsHQ/download/weights_fission_multilab_0_2", ] file_size = [124142981, 124143031, 124144759] - alternative_url = 'https://github.com/rahi-lab/YeaZ-GUI#installation' - elif model_name == 'DeepSea': + alternative_url = "https://github.com/rahi-lab/YeaZ-GUI#installation" + elif model_name == "DeepSea": url = [ - 'https://github.com/abzargar/DeepSea/raw/master/deepsea/trained_models/segmentation.pth', - 'https://github.com/abzargar/DeepSea/raw/master/deepsea/trained_models/tracker.pth' + "https://github.com/abzargar/DeepSea/raw/master/deepsea/trained_models/segmentation.pth", + "https://github.com/abzargar/DeepSea/raw/master/deepsea/trained_models/tracker.pth", ] file_size = [7988969, 8637439] - alternative_url = '' - elif model_name == 'TAPIR': - url = [ - 'https://storage.googleapis.com/dm-tapnet/tapir_checkpoint.npy' - ] + alternative_url = "" + elif model_name == "TAPIR": + url = ["https://storage.googleapis.com/dm-tapnet/tapir_checkpoint.npy"] file_size = [124408122] - alternative_url = '' - elif model_name == 'Cellpose_germlineNuclei': + alternative_url = "" + elif model_name == "Cellpose_germlineNuclei": url = [ - 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/AXG6fFfD8o5GZ83/download/cellpose_germlineNuclei_2023' + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/AXG6fFfD8o5GZ83/download/cellpose_germlineNuclei_2023" ] file_size = [26570752] - alternative_url = '' - elif model_name == 'omnipose': + alternative_url = "" + elif model_name == "omnipose": url = [ - 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/DynLkocWRbQfyRp/download/bact_fluor_cptorch_0' - 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/2248Eoyozp3Ezj2/download/bact_fluor_omnitorch_0', - 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/GiacDfXGerxE7PT/download/bact_phase_omnitorch_0', - 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/DDq8s3CgnG2Yw6H/download/cyto2_omnitorch_0', - 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/MM5meM2J5HbWqXR/download/plant_cptorch_0', - 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/aap7znrWq5sE6JQ/download/plant_omnitorch_0', - 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/w5M46x9qr8zLHZH/download/size_cyto2_omnitorch_0.npy' + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/DynLkocWRbQfyRp/download/bact_fluor_cptorch_0" + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/2248Eoyozp3Ezj2/download/bact_fluor_omnitorch_0", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/GiacDfXGerxE7PT/download/bact_phase_omnitorch_0", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/DDq8s3CgnG2Yw6H/download/cyto2_omnitorch_0", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/MM5meM2J5HbWqXR/download/plant_cptorch_0", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/aap7znrWq5sE6JQ/download/plant_omnitorch_0", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/w5M46x9qr8zLHZH/download/size_cyto2_omnitorch_0.npy", ] - file_size = [ - 26558464, - 26558464, - 26558464, - 26558464, - 26558464, - 75071488, - 4096 - ] - alternative_url = '' - elif model_name == 'sam2': + file_size = [26558464, 26558464, 26558464, 26558464, 26558464, 75071488, 4096] + alternative_url = "" + elif model_name == "sam2": url = [ - 'https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_tiny.pt', - 'https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_small.pt', - 'https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_base_plus.pt', - 'https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_large.pt' + "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_tiny.pt", + "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_small.pt", + "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_base_plus.pt", + "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_large.pt", ] file_size = [155233385, 184211977, 319128965, 910600801] - alternative_url = '' + alternative_url = "" else: return if return_alternative: @@ -1850,82 +1929,72 @@ def _model_url(model_name, return_alternative=False): else: return url, file_size + def _download_segment_anything_models(): - urls, file_sizes = _model_url('segment_anything') + urls, file_sizes = _model_url("segment_anything") temp_model_path = tempfile.mkdtemp() - _, final_model_path = ( - get_model_path('segment_anything', create_temp_dir=False) - ) + _, final_model_path = get_model_path("segment_anything", create_temp_dir=False) for url, file_size in zip(urls, file_sizes): - filename = url.split('/')[-1] + filename = url.split("/")[-1] final_dst = os.path.join(final_model_path, filename) if os.path.exists(final_dst): continue temp_dst = os.path.join(temp_model_path, filename) download_url( - url, temp_dst, file_size=file_size, desc='segment_anything', - verbose=False + url, temp_dst, file_size=file_size, desc="segment_anything", verbose=False ) shutil.move(temp_dst, final_dst) + def _download_sam2_models(): - urls, file_sizes = _model_url('sam2') + urls, file_sizes = _model_url("sam2") temp_model_path = tempfile.mkdtemp() - _, final_model_path = ( - get_model_path('sam2', create_temp_dir=False) - ) + _, final_model_path = get_model_path("sam2", create_temp_dir=False) for url, file_size in zip(urls, file_sizes): - filename = url.split('/')[-1] + filename = url.split("/")[-1] final_dst = os.path.join(final_model_path, filename) if os.path.exists(final_dst): continue temp_dst = os.path.join(temp_model_path, filename) - download_url( - url, temp_dst, file_size=file_size, desc='sam2', - verbose=False - ) + download_url(url, temp_dst, file_size=file_size, desc="sam2", verbose=False) shutil.move(temp_dst, final_dst) + def _download_deepsea_models(): - urls, file_sizes = _model_url('DeepSea') + urls, file_sizes = _model_url("DeepSea") temp_model_path = tempfile.mkdtemp() - _, final_model_path = ( - get_model_path('deepsea', create_temp_dir=False) - ) + _, final_model_path = get_model_path("deepsea", create_temp_dir=False) for url, file_size in zip(urls, file_sizes): - filename = url.split('/')[-1] + filename = url.split("/")[-1] final_dst = os.path.join(final_model_path, filename) - if os.path.exists(final_dst): + if os.path.exists(final_dst): continue temp_dst = os.path.join(temp_model_path, filename) - download_url( - url, temp_dst, file_size=file_size, desc='deepsea', - verbose=False - ) - + download_url(url, temp_dst, file_size=file_size, desc="deepsea", verbose=False) + shutil.move(temp_dst, final_dst) + def download_manual(): - manual_folder_path = os.path.join(user_profile_path, 'acdc-manual') + manual_folder_path = os.path.join(user_profile_path, "acdc-manual") if not os.path.exists(manual_folder_path): os.makedirs(manual_folder_path, exist_ok=True) - manual_file_path = os.path.join(user_profile_path, 'Cell-ACDC_User_Manual.pdf') + manual_file_path = os.path.join(user_profile_path, "Cell-ACDC_User_Manual.pdf") if not os.path.exists(manual_file_path): - url = 'https://github.com/SchmollerLab/Cell_ACDC/raw/main/UserManual/Cell-ACDC_User_Manual.pdf' + url = "https://github.com/SchmollerLab/Cell_ACDC/raw/main/UserManual/Cell-ACDC_User_Manual.pdf" download_url(url, manual_file_path, file_size=1727470) return manual_file_path -def download_bioformats_jar( - qparent=None, logger_info=print, logger_exception=print - ): + +def download_bioformats_jar(qparent=None, logger_info=print, logger_exception=print): dst_filepath = os.path.join( - cellacdc_path, 'bioformats', 'jars', 'bioformats_package.jar' + cellacdc_path, "bioformats", "jars", "bioformats_package.jar" ) if os.path.exists(dst_filepath): return True, dst_filepath @@ -1933,9 +2002,7 @@ def download_bioformats_jar( success = False for url in urls_to_try: try: - logger_info( - f'Downloading `bioformats_package.jar`...' - ) + logger_info(f"Downloading `bioformats_package.jar`...") download_url(url, dst_filepath, file_size=43233280) success = True break @@ -1944,47 +2011,47 @@ def download_bioformats_jar( traceback_str = traceback.format_exc() logger_exception(traceback_str) continue - + if success: return True, dst_filepath _warnings.warn_download_bioformats_jar_failed(dst_filepath, qparent=qparent) raise ModuleNotFoundError( - 'Bioformats package jar could not be downloaded. Please, ' - f'download it from here {urls.bioformats_download_page} and ' + "Bioformats package jar could not be downloaded. Please, " + f"download it from here {urls.bioformats_download_page} and " f'place it in the following path "{dst_filepath}". ' - 'Thank you for your patience!' + "Thank you for your patience!" ) return False, dst_filepath - + def showUserManual(): manual_file_path = download_manual() showInExplorer(manual_file_path) + def get_confirm_token(response): for key, value in response.cookies.items(): - if key.startswith('download_warning'): + if key.startswith("download_warning"): return value return None -def download_url( - url, dst, desc='', file_size=None, verbose=True, progress=None - ): + +def download_url(url, dst, desc="", file_size=None, verbose=True, progress=None): import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - + CHUNK_SIZE = 32768 if verbose: - print(f'Downloading {desc} to: {os.path.dirname(dst)}') + print(f"Downloading {desc} to: {os.path.dirname(dst)}") response = requests.get(url, stream=True, timeout=20, verify=False) if file_size is not None and progress is not None: progress.emit(file_size, -1) pbar = tqdm( - total=file_size, unit='B', unit_scale=True, - unit_divisor=1024, ncols=100 + total=file_size, unit="B", unit_scale=True, unit_divisor=1024, ncols=100 ) - with open(dst, 'wb') as f: + with open(dst, "wb") as f: for chunk in response.iter_content(CHUNK_SIZE): # if chunk: f.write(chunk) @@ -1993,23 +2060,22 @@ def download_url( progress.emit(-1, len(chunk)) pbar.close() + def save_response_content( - response, destination, file_size=None, - model_name='cellpose', progress=None - ): - print(f'Downloading {model_name} to: {os.path.dirname(destination)}') + response, destination, file_size=None, model_name="cellpose", progress=None +): + print(f"Downloading {model_name} to: {os.path.dirname(destination)}") CHUNK_SIZE = 32768 # Download to a temp folder in user path - temp_folder = pathlib.Path.home().joinpath('.acdc_temp') + temp_folder = pathlib.Path.home().joinpath(".acdc_temp") if not os.path.exists(temp_folder): os.mkdir(temp_folder) temp_dst = os.path.join(temp_folder, os.path.basename(destination)) if file_size is not None and progress is not None: progress.emit(file_size, -1) pbar = tqdm( - total=file_size, unit='B', unit_scale=True, - unit_divisor=1024, ncols=100 + total=file_size, unit="B", unit_scale=True, unit_divisor=1024, ncols=100 ) with open(temp_dst, "wb") as f: for chunk in response.iter_content(CHUNK_SIZE): @@ -2027,38 +2093,44 @@ def save_response_content( shutil.move(temp_dst, destination) shutil.rmtree(temp_folder) + def extract_zip(zip_path, extract_to_path, verbose=True): if verbose: - print(f'Extracting to {extract_to_path}...') - with zipfile.ZipFile(zip_path, 'r') as zip_ref: + print(f"Extracting to {extract_to_path}...") + with zipfile.ZipFile(zip_path, "r") as zip_ref: zip_ref.extractall(extract_to_path) + def check_v123_model_path(model_name): # Cell-ACDC v1.2.3 saved the weights inside the package, # while from v1.2.4 we save them on user folder. If we find the # weights in the package we move them to user folder without downloading # new ones. - v123_model_path = os.path.join(models_path, model_name, 'model') + v123_model_path = os.path.join(models_path, model_name, "model") exists = check_model_exists(v123_model_path, model_name) if exists: return v123_model_path else: - return '' + return "" + def is_old_user_profile_path(path_to_check: os.PathLike): from . import user_data_dir + user_data_folderpath = user_data_dir() user_profile_path_txt = os.path.join( - user_data_folderpath, 'acdc_user_profile_location.txt' + user_data_folderpath, "acdc_user_profile_location.txt" ) if os.path.exists(user_profile_path_txt): return False - + from . import user_home_path - user_home_path = user_home_path.replace('\\', '/') - path_to_check = path_to_check.replace('\\', '/') + + user_home_path = user_home_path.replace("\\", "/") + path_to_check = path_to_check.replace("\\", "/") return user_home_path == path_to_check + def migrate_to_new_user_profile_path(path_to_migrate: os.PathLike): parent_dir = os.path.dirname(path_to_migrate) if not is_old_user_profile_path(parent_dir): @@ -2066,20 +2138,20 @@ def migrate_to_new_user_profile_path(path_to_migrate: os.PathLike): folder = os.path.basename(path_to_migrate) return os.path.join(user_profile_path, folder) + def _write_model_location_to_txt(model_name): - model_info_path = os.path.join(models_path, model_name, 'model') - model_path = os.path.join(user_profile_path, f'acdc-{model_name}') - file = 'weights_location_path.txt' - with open(os.path.join(model_info_path, file), 'w') as txt: + model_info_path = os.path.join(models_path, model_name, "model") + model_path = os.path.join(user_profile_path, f"acdc-{model_name}") + file = "weights_location_path.txt" + with open(os.path.join(model_info_path, file), "w") as txt: txt.write(model_path) return os.path.expanduser(model_path) + def determine_folder_type(folder_path): is_pos_folder = is_pos_folderpath(folder_path) - is_images_folder = folder_path.endswith('Images') and listdir(folder_path) - contains_images_folder = os.path.exists( - os.path.join(folder_path, 'Images') - ) + is_images_folder = folder_path.endswith("Images") and listdir(folder_path) + contains_images_folder = os.path.exists(os.path.join(folder_path, "Images")) contains_pos_folders = len(get_pos_foldernames(folder_path)) > 0 if contains_pos_folders: is_pos_folder = False @@ -2087,63 +2159,64 @@ def determine_folder_type(folder_path): elif contains_images_folder and not is_pos_folder: # Folder created by loading an image is_images_folder = True - folder_path = os.path.join(folder_path, 'Images') - + folder_path = os.path.join(folder_path, "Images") + return is_pos_folder, is_images_folder, folder_path + def download_model(model_name): - if model_name == 'segment_anything': + if model_name == "segment_anything": try: _download_segment_anything_models() return True except Exception as e: traceback.print_exc() return False - elif model_name == 'sam2': + elif model_name == "sam2": try: _download_sam2_models() return True except Exception as e: traceback.print_exc() return False - elif model_name == 'DeepSea': + elif model_name == "DeepSea": try: _download_deepsea_models() return True except Exception as e: traceback.print_exc() return False - elif model_name == 'TAPIR': + elif model_name == "TAPIR": try: _download_tapir_model() return True except Exception as e: traceback.print_exc() return False - elif model_name == 'YeaZ_v2': + elif model_name == "YeaZ_v2": try: _download_yeaz_models() return True except Exception as e: traceback.print_exc() return False - elif model_name == 'Cellpose_germlineNuclei': + elif model_name == "Cellpose_germlineNuclei": try: _download_cellpose_germlineNuclei_model() return True except Exception as e: traceback.print_exc() return False - elif model_name == 'omnipose': + elif model_name == "omnipose": try: _download_omnipose_models() return True except Exception as err: return False - elif model_name != 'YeastMate' and model_name != 'YeaZ': + elif model_name != "YeastMate" and model_name != "YeaZ": # We manage only YeastMate and YeaZ return True - + try: # Check if model exists temp_zip_path, model_path = get_model_path(model_name) @@ -2154,8 +2227,8 @@ def download_model(model_name): # Check if user has model in the old v1.2.3 location v123_model_path = check_v123_model_path(model_name) if v123_model_path: - print(f'Weights files found in {v123_model_path}') - print(f'--> moving to new location: {model_path}...') + print(f"Weights files found in {v123_model_path}") + print(f"--> moving to new location: {model_path}...") for file in listdir(v123_model_path): src = os.path.join(v123_model_path, file) dst = os.path.join(model_path, file) @@ -2165,27 +2238,26 @@ def download_model(model_name): # Download model from url to tempDir/model_temp.zip temp_dir = os.path.dirname(temp_zip_path) url, file_size = _model_url(model_name) - print(f'Downloading {model_name} to {model_path}') + print(f"Downloading {model_name} to {model_path}") download_url( - url, temp_zip_path, file_size=file_size, desc=model_name, - verbose=False + url, temp_zip_path, file_size=file_size, desc=model_name, verbose=False ) # Extract zip file inside temp dir - print(f'Extracting model...') + print(f"Extracting model...") extract_zip(temp_zip_path, temp_dir, verbose=False) # Move unzipped files to ~/acdc-{model_name} folder - print(f'Moving files from temporary folder to {model_path}...') + print(f"Moving files from temporary folder to {model_path}...") for file in listdir(temp_dir): - if file.endswith('.zip'): + if file.endswith(".zip"): continue src = os.path.join(temp_dir, file) dst = os.path.join(model_path, file) shutil.move(src, dst) # Remove temp directory - print(f'Removing temporary folder...') + print(f"Removing temporary folder...") shutil.rmtree(temp_dir) return True @@ -2193,126 +2265,122 @@ def download_model(model_name): traceback.print_exc() return False + # def get_tiff_metadata( # image_arr, -# SizeT=None, -# SizeZ=None, +# SizeT=None, +# SizeZ=None, # PhysicalSizeZ=None, -# PhysicalSizeX=None, +# PhysicalSizeX=None, # PhysicalSizeY=None, # TimeIncrement=None # ): # SizeY, SizeX = image_arr.shape[-2:] # Type = str(image_arr.dtype) - + # metadata = { # 'SizeX': SizeX, # 'SizeY': SizeY, # 'Type': Type # } - + # axes = 'YX' # if SizeZ is not None and SizeZ > 1: # axes = f'Z{axes}' # metadata['SizeZ'] = SizeZ - + # if SizeT is not None and SizeT > 1: # axes = f'T{axes}' # metadata['SizeT'] = SizeT - + # metadata['axes'] = axes - + # if PhysicalSizeX is not None: # metadata['PhysicalSizeX'] = PhysicalSizeX - + # if PhysicalSizeY is not None: # metadata['PhysicalSizeY'] = PhysicalSizeY - + # if PhysicalSizeZ is not None: # metadata['PhysicalSizeZ'] = PhysicalSizeZ - + # if TimeIncrement is not None: # metadata['TimeIncrement'] = TimeIncrement - + # return metadata + def get_tiff_metadata( - image_arr, - SizeT=None, - SizeZ=None, - PhysicalSizeZ=None, - PhysicalSizeX=None, - PhysicalSizeY=None, - TimeIncrement=None - ): + image_arr, + SizeT=None, + SizeZ=None, + PhysicalSizeZ=None, + PhysicalSizeX=None, + PhysicalSizeY=None, + TimeIncrement=None, +): SizeY, SizeX = image_arr.shape[-2:] Type = str(image_arr.dtype) - - metadata = { - 'Pixels': { - 'SizeX': SizeX, - 'SizeY': SizeY, - 'Type': Type - } - } - - axes = 'YX' + + metadata = {"Pixels": {"SizeX": SizeX, "SizeY": SizeY, "Type": Type}} + + axes = "YX" if SizeZ is not None and SizeZ > 1: - axes = f'Z{axes}' - metadata['Pixels']['SizeZ'] = SizeZ - + axes = f"Z{axes}" + metadata["Pixels"]["SizeZ"] = SizeZ + if SizeT is not None and SizeT > 1: - axes = f'T{axes}' - metadata['Pixels']['SizeT'] = SizeT - - metadata['axes'] = axes - + axes = f"T{axes}" + metadata["Pixels"]["SizeT"] = SizeT + + metadata["axes"] = axes + if PhysicalSizeX is not None: - metadata['Pixels']['PhysicalSizeX'] = PhysicalSizeX - + metadata["Pixels"]["PhysicalSizeX"] = PhysicalSizeX + if PhysicalSizeY is not None: - metadata['Pixels']['PhysicalSizeY'] = PhysicalSizeY - + metadata["Pixels"]["PhysicalSizeY"] = PhysicalSizeY + if PhysicalSizeZ is not None: - metadata['Pixels']['PhysicalSizeZ'] = PhysicalSizeZ - + metadata["Pixels"]["PhysicalSizeZ"] = PhysicalSizeZ + if TimeIncrement is not None: - metadata['Pixels']['TimeIncrement'] = TimeIncrement - + metadata["Pixels"]["TimeIncrement"] = TimeIncrement + return metadata + def to_tiff( - new_path, data, - SizeT=None, - SizeZ=None, - PhysicalSizeZ=None, - PhysicalSizeX=None, - PhysicalSizeY=None, - TimeIncrement=None - ): - valid_dtypes = ( - np.uint8, np.uint16, np.float32 - ) + new_path, + data, + SizeT=None, + SizeZ=None, + PhysicalSizeZ=None, + PhysicalSizeX=None, + PhysicalSizeY=None, + TimeIncrement=None, +): + valid_dtypes = (np.uint8, np.uint16, np.float32) is_valid_dtype = False for valid_dtype in valid_dtypes: if np.issubdtype(data.dtype, valid_dtype): is_valid_dtype = True break - + if not is_valid_dtype: data = data.astype(np.float32) - + metadata = get_tiff_metadata( data, - SizeT=SizeT, - SizeZ=SizeZ, + SizeT=SizeT, + SizeZ=SizeZ, PhysicalSizeZ=PhysicalSizeZ, - PhysicalSizeX=PhysicalSizeX, + PhysicalSizeX=PhysicalSizeX, PhysicalSizeY=PhysicalSizeY, - TimeIncrement=TimeIncrement + TimeIncrement=TimeIncrement, ) - - # # Potential alternative + + # # Potential alternative # hyperstack = tifffile.memmap( # new_path, # shape=img.shape, @@ -2322,14 +2390,13 @@ def to_tiff( # ) # hyperstack[:] = img # hyperstack.flush() - + try: - tifffile.imwrite( - new_path, data, metadata=metadata, imagej=True - ) + tifffile.imwrite(new_path, data, metadata=metadata, imagej=True) except Exception as err: tifffile.imwrite(new_path, data) + def from_lab_to_obj_coords(lab): rp = skimage.measure.regionprops(lab) dfs = [] @@ -2339,14 +2406,15 @@ def from_lab_to_obj_coords(lab): obj_coords = obj.coords ndim = obj_coords.shape[1] if ndim == 3: - columns = ['z', 'y', 'x'] + columns = ["z", "y", "x"] else: - columns = ['y', 'x'] + columns = ["y", "x"] df_obj = pd.DataFrame(data=obj_coords, columns=columns) dfs.append(df_obj) - df = pd.concat(dfs, keys = keys, names=['Cell_ID', 'idx']).droplevel('idx') + df = pd.concat(dfs, keys=keys, names=["Cell_ID", "idx"]).droplevel("idx") return df + def lab2d_to_rois(ImagejRoi, lab2D, ndigits, t=None, z=None): rp = skimage.measure.regionprops(lab2D) rois = [] @@ -2355,35 +2423,34 @@ def lab2d_to_rois(ImagejRoi, lab2D, ndigits, t=None, z=None): yc, xc = obj.centroid x_str = str((int(xc))).zfill(ndigits) y_str = str((int(yc))).zfill(ndigits) - name = f'{x_str}-{y_str}' + name = f"{x_str}-{y_str}" if z is not None: z_str = str(z).zfill(ndigits) - name = f'{z_str}-{name}' - + name = f"{z_str}-{name}" + if t is not None: t_str = str(t).zfill(ndigits) - name = f'{t_str}-{name}' - - name = f'id={obj.label}-{name}' - - roi = ImagejRoi.frompoints( - cont, name=name, t=t, z=z, index=obj.label - ) + name = f"{t_str}-{name}" + + name = f"id={obj.label}-{name}" + + roi = ImagejRoi.frompoints(cont, name=name, t=t, z=z, index=obj.label) rois.append(roi) return rois + def from_lab_to_imagej_rois(lab, ImagejRoi, t=0, SizeT=1, max_ID=None): if max_ID is None: max_ID = lab.max() - + if SizeT == 1: t = None - + SizeY, SizeX = lab.shape[-2:] ndigitsT = len(str(SizeT)) ndigitsY = len(str(SizeY)) ndigitsX = len(str(SizeX)) - + if lab.ndim == 3: rois = [] SizeZ = len(lab) @@ -2397,228 +2464,240 @@ def from_lab_to_imagej_rois(lab, ImagejRoi, t=0, SizeT=1, max_ID=None): rois = lab2d_to_rois(ImagejRoi, lab, ndigits, t=t) return rois + def from_imagej_rois_to_segm_data( - TZYX_shape, ID_to_roi_mapper, rescale_rois_sizes, - repeat_2d_rois_zslices_range - ): + TZYX_shape, ID_to_roi_mapper, rescale_rois_sizes, repeat_2d_rois_zslices_range +): SizeT, SizeZ, SizeY, SizeX = TZYX_shape segm_data = np.zeros(TZYX_shape, dtype=np.uint32) for ID, roi in ID_to_roi_mapper.items(): name = roi.name - name_parts = name.split('-') + name_parts = name.split("-") zz = [0] if len(name_parts) == 2 and SizeZ > 1: # 2D roi in 3D segm data --> place 2D roi on each z-slice zz = range(*repeat_2d_rois_zslices_range) - + elif len(name_parts) > 2 and SizeZ > 1: # 2D roi from a 3D roi --> place at requested z-slice zz = [int(name_parts[-3])] - - tt = [0]*len(zz) + + tt = [0] * len(zz) if SizeT > 1: - tt = [roi.t_position]*len(zz) - + tt = [roi.t_position] * len(zz) + y0, x0 = roi.top, roi.left contours = roi.integer_coordinates + (x0, y0) xx = contours[:, 0] yy = contours[:, 1] - if rescale_rois_sizes is not None: - rescale_z = rescale_rois_sizes['Z'] - rescale_y = rescale_rois_sizes['Y'] - rescale_x = rescale_rois_sizes['X'] - - factor_z = rescale_z[1]/rescale_z[0] - factor_y = rescale_y[1]/rescale_y[0] - factor_x = rescale_x[1]/rescale_x[0] - - xx = np.clip(np.round(xx * factor_x).astype(int), 0, SizeX-1) - yy = np.clip(np.round(yy * factor_y).astype(int), 0, SizeY-1) - + if rescale_rois_sizes is not None: + rescale_z = rescale_rois_sizes["Z"] + rescale_y = rescale_rois_sizes["Y"] + rescale_x = rescale_rois_sizes["X"] + + factor_z = rescale_z[1] / rescale_z[0] + factor_y = rescale_y[1] / rescale_y[0] + factor_x = rescale_x[1] / rescale_x[0] + + xx = np.clip(np.round(xx * factor_x).astype(int), 0, SizeX - 1) + yy = np.clip(np.round(yy * factor_y).astype(int), 0, SizeY - 1) + for t, z in zip(tt, zz): if rescale_rois_sizes is not None: - z = round(z*factor_z) - z = z if z=0 else 0 - + z = round(z * factor_z) + z = z if z < SizeZ else SizeZ + z = z if z >= 0 else 0 + rr, cc = skimage.draw.polygon(yy, xx) segm_data[t, z, rr, cc] = ID - + return np.squeeze(segm_data) - + + def aliases_real_time_trackers(reverse=False): """ Returns a dictionary with aliases for real-time trackers. """ aliases = { - 'CellACDC_normal_division': 'Cell-ACDC symmetric division', - 'CellACDC_2steps' : 'Cell-ACDC 2 steps', - } - + "CellACDC_normal_division": "Cell-ACDC symmetric division", + "CellACDC_2steps": "Cell-ACDC 2 steps", + } + if reverse: aliases = {v: k for k, v in aliases.items()} - + return aliases - + + def get_list_of_real_time_trackers(): trackers = get_list_of_trackers() rt_trackers = [] aliases = aliases_real_time_trackers() for tracker in trackers: - if tracker == 'CellACDC': + if tracker == "CellACDC": continue - if tracker == 'YeaZ': + if tracker == "YeaZ": continue - tracker_filename = f'{tracker}_tracker.py' + tracker_filename = f"{tracker}_tracker.py" tracker_path = os.path.join( - cellacdc_path, 'trackers', tracker, tracker_filename + cellacdc_path, "trackers", tracker, tracker_filename ) try: with open(tracker_path) as file: txt = file.read() - if txt.find('def track_frame') != -1: + if txt.find("def track_frame") != -1: rt_trackers.append(tracker) except Exception as e: continue - + for i, tracker in enumerate(rt_trackers): if tracker in aliases: rt_trackers[i] = aliases[tracker] return natsorted(rt_trackers, key=str.casefold) + def get_list_of_trackers(): - trackers_path = os.path.join(cellacdc_path, 'trackers') + trackers_path = os.path.join(cellacdc_path, "trackers") trackers = [] for name in listdir(trackers_path): _path = os.path.join(trackers_path, name) - tracker_script_path = os.path.join(_path, f'{name}_tracker.py') + tracker_script_path = os.path.join(_path, f"{name}_tracker.py") is_valid_tracker = ( - os.path.isdir(_path) and os.path.exists(tracker_script_path) - and not name.endswith('__') + os.path.isdir(_path) + and os.path.exists(tracker_script_path) + and not name.endswith("__") ) - if name.startswith('_'): + if name.startswith("_"): continue - + if is_valid_tracker: trackers.append(name) return natsorted(trackers, key=str.casefold) + def get_list_of_models(): models = set() for name in listdir(models_path): _path = os.path.join(models_path, name) if not os.path.exists(_path): continue - + if not os.path.isdir(_path): continue - - if name.endswith('__'): + + if name.endswith("__"): continue - - if name.startswith('_'): + + if name.startswith("_"): continue - - if name == 'skip_segmentation': + + if name == "skip_segmentation": continue - - if not os.path.exists(os.path.join(_path, 'acdcSegment.py')): + + if not os.path.exists(os.path.join(_path, "acdcSegment.py")): continue - - if name == 'thresholding': - name = 'Automatic thresholding' - + + if name == "thresholding": + name = "Automatic thresholding" + models.add(name) - + if not os.path.exists(models_list_file_path): return natsorted(list(models), key=str.casefold) - + cp = config.ConfigParser() cp.read(models_list_file_path) models.update(cp.sections()) return natsorted(list(models), key=str.casefold) + def get_list_of_promptable_models(): models = set() for name in listdir(promptable_models_path): _path = os.path.join(promptable_models_path, name) if not os.path.exists(_path): continue - + if not os.path.isdir(_path): continue - - if name.endswith('__'): + + if name.endswith("__"): continue - - if not os.path.exists(os.path.join(_path, 'acdcPromptSegment.py')): + + if not os.path.exists(os.path.join(_path, "acdcPromptSegment.py")): continue - + models.add(name) - + if not os.path.exists(promptable_models_list_file_path): return natsorted(list(models), key=str.casefold) - + cp = config.ConfigParser() cp.read(promptable_models_list_file_path) models.update(cp.sections()) return natsorted(list(models), key=str.casefold) + def seconds_to_ETA(seconds): seconds = round(seconds) ETA = datetime.timedelta(seconds=seconds) - ETA_split = str(ETA).split(':') + ETA_split = str(ETA).split(":") if seconds < 0: - ETA = '00h:00m:00s' + ETA = "00h:00m:00s" elif seconds >= 86400: - days, hhmmss = str(ETA).split(',') - h, m, s = hhmmss.split(':') - ETA = f'{days}, {int(h):02}h:{int(m):02}m:{int(s):02}s' + days, hhmmss = str(ETA).split(",") + h, m, s = hhmmss.split(":") + ETA = f"{days}, {int(h):02}h:{int(m):02}m:{int(s):02}s" else: - h, m, s = str(ETA).split(':') - ETA = f'{int(h):02}h:{int(m):02}m:{int(s):02}s' + h, m, s = str(ETA).split(":") + ETA = f"{int(h):02}h:{int(m):02}m:{int(s):02}s" return ETA + def to_uint8(img): if img.dtype == np.uint8: return img - img = np.round(img_to_float(img)*255).astype(np.uint8) + img = np.round(img_to_float(img) * 255).astype(np.uint8) return img + def to_uint16(img): if img.dtype == np.uint16: return img - img = np.round(img_to_float(img)*65535).astype(np.uint16) + img = np.round(img_to_float(img) * 65535).astype(np.uint16) return img + def elided_text(text, max_len=50, elid_idx=None): if len(text) <= max_len: return text if elid_idx is None: - elid_idx = int(max_len/2) + elid_idx = int(max_len / 2) if elid_idx >= max_len: elid_idx = max_len - 1 idx1 = elid_idx idx2 = elid_idx - max_len - text = f'{text[:idx1]}...{text[idx2:]}' + text = f"{text[:idx1]}...{text[idx2:]}" return text -def to_relative_path(path, levels=3, prefix='...'): - path = path.replace('\\', '/') - parts = path.split('/') + +def to_relative_path(path, levels=3, prefix="..."): + path = path.replace("\\", "/") + parts = path.split("/") if levels >= len(parts): return path parts = parts[-levels:] - rel_path = '/'.join(parts) - rel_path.replace('/', os.sep) + rel_path = "/".join(parts) + rel_path.replace("/", os.sep) if prefix: - rel_path = f'{prefix}{os.sep}{rel_path}' + rel_path = f"{prefix}{os.sep}{rel_path}" return rel_path + def img_to_float(img, force_dtype=None, force_missing_dtype=None, warn=True): input_img_dtype = img.dtype value = img[(0,) * img.ndim] @@ -2634,114 +2713,123 @@ def img_to_float(img, force_dtype=None, force_missing_dtype=None, warn=True): img = img.astype(float) if force_dtype is not None: dtype_max = np.iinfo(force_dtype).max - img = img/dtype_max + img = img / dtype_max elif input_img_dtype == np.uint8: # Input image is 8-bit - img = img/uint8_max + img = img / uint8_max elif input_img_dtype == np.uint16: # Input image is 16-bit - img = img/uint16_max + img = img / uint16_max elif input_img_dtype == np.uint32: # Input image is 32-bit - img = img/uint32_max + img = img / uint32_max elif force_missing_dtype is not None: img = img.astype(force_dtype) elif img_max <= uint8_max: # Input image is probably 8-bit if warn: - _warnings.warn_image_overflow_dtype(input_img_dtype, img_max, '8-bit') - img = img/uint8_max + _warnings.warn_image_overflow_dtype(input_img_dtype, img_max, "8-bit") + img = img / uint8_max elif img_max <= uint16_max: # Input image is probably 16-bit if warn: - _warnings.warn_image_overflow_dtype(input_img_dtype, img_max, '16-bit') - img = img/uint16_max + _warnings.warn_image_overflow_dtype(input_img_dtype, img_max, "16-bit") + img = img / uint16_max elif img_max <= uint32_max: # Input image is probably 32-bit if warn: - _warnings.warn_image_overflow_dtype(input_img_dtype, img_max, '32-bit') - img = img/uint32_max + _warnings.warn_image_overflow_dtype(input_img_dtype, img_max, "32-bit") + img = img / uint32_max else: # Input image is a non-supported data type raise TypeError( - f'The maximum value in the image is {img_max} which is greater than the ' - f'maximum value supported of {uint32_max} (32-bit). ' - 'Please consider converting your images to 32-bit or 16-bit first.' + f"The maximum value in the image is {img_max} which is greater than the " + f"maximum value supported of {uint32_max} (32-bit). " + "Please consider converting your images to 32-bit or 16-bit first." ) return img + def float_img_to_dtype(img, dtype): if img.dtype == dtype: return img - + img_max = img.max() if img_max > 1.0: raise TypeError( - 'Images of float data type with values greater than 1.0 cannot ' - f'be safely casted to {dtype}. ' - f'The max value of the input image is {img_max:.3f}' + "Images of float data type with values greater than 1.0 cannot " + f"be safely casted to {dtype}. " + f"The max value of the input image is {img_max:.3f}" ) - + img_min = img.min() if img_min < -1.0: raise TypeError( - 'Images of float data type with values smaller than -1.0 cannot ' - f'be safely casted to {dtype}.' - f'The minumum value of the input image is {img_min:.3f}' + "Images of float data type with values smaller than -1.0 cannot " + f"be safely casted to {dtype}." + f"The minumum value of the input image is {img_min:.3f}" ) if dtype == np.uint8: return skimage.img_as_ubyte(img) - + if dtype == np.uint16: return skimage.img_as_uint(img) - + if dtype == np.float32: return img.astype(np.float32) - + if dtype == np.float64: return img.astype(np.float64) - + raise TypeError( - f'Invalid output data type `{dtype}`. ' - 'Valid output data types are `np.uint8` and `np.uint16`' + f"Invalid output data type `{dtype}`. " + "Valid output data types are `np.uint8` and `np.uint16`" ) + def convert_to_dtype(data: np.ndarray, dtype): if data.dtype == dtype: return data - val = data[tuple([0]*data.ndim)] + val = data[tuple([0] * data.ndim)] if isinstance(val, (np.floating, float)): data = float_img_to_dtype(data, dtype) elif dtype == np.uint8: - data = np.round(img_to_float(data)*255).astype(np.uint8) + data = np.round(img_to_float(data) * 255).astype(np.uint8) elif dtype == np.uint16: - data = np.round(img_to_float(data)*65535).astype(np.uint16) + data = np.round(img_to_float(data) * 65535).astype(np.uint16) else: raise TypeError( - f'Invalid output data type `{dtype}`. ' - 'Valid data types are floating-point format, `np.uint8` ' - 'and `np.uint16`' + f"Invalid output data type `{dtype}`. " + "Valid data types are floating-point format, `np.uint8` " + "and `np.uint16`" ) return data + def _install_homebrew_command(): return '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + def _brew_install_java_command(): - return 'brew install --cask homebrew/cask-versions/adoptopenjdk8' + return "brew install --cask homebrew/cask-versions/adoptopenjdk8" + def _brew_install_hdf5(): - return 'brew install hdf5' + return "brew install hdf5" + def _apt_update_command(): - return 'sudo apt-get update' + return "sudo apt-get update" + def _apt_gcc_command(): - return 'sudo apt install python-dev gcc' + return "sudo apt install python-dev gcc" + def _apt_install_java_command(): - return 'sudo apt-get install openjdk-8-jdk' + return "sudo apt-get install openjdk-8-jdk" + def _java_instructions_linux(): s1 = html_utils.paragraph(""" @@ -2750,15 +2838,15 @@ def _java_instructions_linux(): """) s2 = html_utils.paragraph(f""" - {_apt_gcc_command().replace(' ', ' ')} + {_apt_gcc_command().replace(" ", " ")} """) s3 = html_utils.paragraph(f""" - {_apt_update_command().replace(' ', ' ')} + {_apt_update_command().replace(" ", " ")} """) s4 = html_utils.paragraph(f""" - {_apt_install_java_command().replace(' ', ' ')} + {_apt_install_java_command().replace(" ", " ")} """) s5 = html_utils.paragraph(""" @@ -2770,6 +2858,7 @@ def _java_instructions_linux(): """) return s1, s2, s3, s4 + def _java_instructions_macOS(): s1 = html_utils.paragraph(""" Run the following commands
    @@ -2781,7 +2870,7 @@ def _java_instructions_macOS(): """) s3 = html_utils.paragraph(f""" - {_brew_install_java_command().replace(' ', ' ')} + {_brew_install_java_command().replace(" ", " ")} """) s4 = html_utils.paragraph(""" @@ -2798,11 +2887,14 @@ def _java_instructions_macOS(): """) return s1, s2, s3, s4 + def jdk_windows_url(): - return 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/R62Ktcda6jWea2s' + return "https://hmgubox2.helmholtz-muenchen.de/index.php/s/R62Ktcda6jWea2s" + def cpp_windows_url(): - return 'https://visualstudio.microsoft.com/visual-cpp-build-tools/' + return "https://visualstudio.microsoft.com/visual-cpp-build-tools/" + def _java_instructions_windows(): jdk_url = f'"{jdk_windows_url()}"' @@ -2830,6 +2922,7 @@ def _java_instructions_windows(): """) return s1, s2, s3 + def install_javabridge_instructions_text(): if is_win: return _java_instructions_windows() @@ -2838,6 +2931,7 @@ def install_javabridge_instructions_text(): elif is_linux: return _java_instructions_linux() + def install_javabridge_help(parent=None): msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" @@ -2854,19 +2948,20 @@ def install_javabridge_help(parent=None): Alternatively, you can cancel the process and try later. """) msg.setIcon() - msg.setWindowTitle('Installing javabridge') + msg.setWindowTitle("Installing javabridge") msg.addText(txt) - msg.addButton(' Ok ') - cancel = msg.addButton(' Cancel ') + msg.addButton(" Ok ") + cancel = msg.addButton(" Cancel ") msg.exec_() return msg.clickedButton == cancel + def check_napari_plugin(plugin_name, module_name, parent=None): try: import_module(module_name) except ModuleNotFoundError as e: - url = 'https://napari.org/stable/plugins/find_and_install_plugin.html#find-and-install-plugins' - href = html_utils.href_tag('this guide', url) + url = "https://napari.org/stable/plugins/find_and_install_plugin.html#find-and-install-plugins" + href = html_utils.href_tag("this guide", url) txt = html_utils.paragraph(f""" To correctly use this napari utility you need to install the plugin called {plugin_name}.

    @@ -2877,30 +2972,37 @@ def check_napari_plugin(plugin_name, module_name, parent=None): {plugin_name} becasue it is NOT A SEARCH BOX. """) msg = widgets.myMessageBox() - msg.critical(parent, f'Napari plugin required', txt) + msg.critical(parent, f"Napari plugin required", txt) raise e + def _install_pip_package( - pkg_name: str, - logger: Callable = print, - install_dependencies: bool = True, - force_binary: bool = True, - pref_binary: bool = True, - ) -> None: - command = [sys.executable, '-m', 'pip', 'install', pkg_name,] + pkg_name: str, + logger: Callable = print, + install_dependencies: bool = True, + force_binary: bool = True, + pref_binary: bool = True, +) -> None: + command = [ + sys.executable, + "-m", + "pip", + "install", + pkg_name, + ] if force_binary: - command.append('--only-binary=:all:') + command.append("--only-binary=:all:") elif pref_binary: - command.append('--prefer-binary') + command.append("--prefer-binary") if not install_dependencies: - command.append('--no-deps') + command.append("--no-deps") try: - subprocess.check_call( - command - ) + subprocess.check_call(command) except subprocess.CalledProcessError as e: if "--only-binary=:all:" in str(e): - logger(f"Error: {pkg_name} does not have a binary distribution available, trying preferred binary.") + logger( + f"Error: {pkg_name} does not have a binary distribution available, trying preferred binary." + ) _install_pip_package( pkg_name=pkg_name, logger=logger, @@ -2909,9 +3011,11 @@ def _install_pip_package( pref_binary=True, ) elif "--prefer-binary" in str(e): - logger(f"Error: {pkg_name} does not have a preferred binary distribution available, trying source.") - command.remove('--prefer-binary') - command.append('--no-binary=:all:') + logger( + f"Error: {pkg_name} does not have a preferred binary distribution available, trying source." + ) + command.remove("--prefer-binary") + command.append("--no-binary=:all:") _install_pip_package( pkg_name=pkg_name, logger=logger, @@ -2924,71 +3028,77 @@ def _install_pip_package( not being available for your platform or python version.""") raise e + def uninstall_pip_package(pkg_name): - subprocess.check_call( - [sys.executable, '-m', 'pip', 'uninstall', '-y', pkg_name] - ) + subprocess.check_call([sys.executable, "-m", "pip", "uninstall", "-y", pkg_name]) + def uninstall_omnipose_acdc(): - """Uninstall omnipose-acdc if present. Since v1.5.0 it is not needed. - """ + """Uninstall omnipose-acdc if present. Since v1.5.0 it is not needed.""" import json + pip_list_output = subprocess.check_output( - [sys.executable, '-m', 'pip', 'list', '--format', 'json'] + [sys.executable, "-m", "pip", "list", "--format", "json"] ) installed_packages = json.loads(pip_list_output) pkgs_to_uninstall = [] for package_info in installed_packages: - if package_info['name'] == 'omnipose-acdc': - pkgs_to_uninstall.append('omnipose-acdc') - elif package_info['name'] == 'cellpose-omni-acdc': - pkgs_to_uninstall.append('cellpose-omni-acdc') + if package_info["name"] == "omnipose-acdc": + pkgs_to_uninstall.append("omnipose-acdc") + elif package_info["name"] == "cellpose-omni-acdc": + pkgs_to_uninstall.append("cellpose-omni-acdc") for pkg_to_uninstall in pkgs_to_uninstall: uninstall_pip_package(pkg_to_uninstall) -def get_cellpose_major_version(errors='raise'): + +def get_cellpose_major_version(errors="raise"): major_installed = None try: - installed_version = get_package_version('cellpose') - major_installed = int(installed_version.split('.')[0]) + installed_version = get_package_version("cellpose") + major_installed = int(installed_version.split(".")[0]) except Exception as err: - if errors == 'raise': + if errors == "raise": raise err - + return major_installed + def check_cellpose_version(version: str): if isinstance(version, int): - version = f'{version}.0' + version = f"{version}.0" - major_requested = int(version.split('.')[0]) + major_requested = int(version.split(".")[0]) cancel = False try: - installed_version = get_package_version('cellpose') - major_installed = int(installed_version.split('.')[0]) + installed_version = get_package_version("cellpose") + major_installed = int(installed_version.split(".")[0]) is_version_correct = major_installed == major_requested if not is_version_correct: cancel = _warnings.warn_installing_different_cellpose_version( version, installed_version ) if not is_second_version_greater( - min_target_versions_cp[str(major_requested)], - installed_version + min_target_versions_cp[str(major_requested)], installed_version ): is_version_correct = False except Exception as err: is_version_correct = False - + if cancel: - raise ModuleNotFoundError('Cellpose installation cancelled by the user.') + raise ModuleNotFoundError("Cellpose installation cancelled by the user.") return is_version_correct + def purge_module(module_name): - to_delete = [mod for mod in sys.modules if mod == module_name or mod.startswith(module_name + '.')] + to_delete = [ + mod + for mod in sys.modules + if mod == module_name or mod.startswith(module_name + ".") + ] for mod in to_delete: del sys.modules[mod] - + importlib.invalidate_caches() importlib.import_module(module_name) if module_name in sys.modules: @@ -2996,9 +3106,10 @@ def purge_module(module_name): else: raise ModuleNotFoundError(f"Module '{module_name}' not found in sys.modules.") + def is_second_version_greater( - target_version: str, - current_version: str, + target_version: str, + current_version: str, ): """ Compares two model versions and returns True if the current version is @@ -3006,235 +3117,234 @@ def is_second_version_greater( """ target_version = packaging_version.parse(target_version) current_version = packaging_version.parse(current_version) - + return current_version >= target_version -def is_pkg_version_within_range( - package_version: str, min_version='', max_version='' - ): + +def is_pkg_version_within_range(package_version: str, min_version="", max_version=""): package_version_number = packaging_version.parse(package_version) is_greater_than_min = True if min_version: min_version_number = packaging_version.parse(min_version) is_greater_than_min = package_version_number >= min_version_number - + is_less_than_max = True if max_version: max_version_number = packaging_version.parse(max_version) is_less_than_max = package_version_number <= max_version_number - + return is_greater_than_min and is_less_than_max - + def check_install_cellpose( - version: Literal['2.0', '3.0', '4.0', 'any'] = '2.0', - version_to_install_if_missing: Literal['2.0', '3.0', '4.0'] = '4.0' - ): + version: Literal["2.0", "3.0", "4.0", "any"] = "2.0", + version_to_install_if_missing: Literal["2.0", "3.0", "4.0"] = "4.0", +): if isinstance(version, int): - version = f'{version}.0' - + version = f"{version}.0" + check_install_torch() - if version == 'any': + if version == "any": try: from cellpose import models + return except Exception as err: - version = version_to_install_if_missing # after this the version will for sure be a valid format and not 'any' - + version = version_to_install_if_missing # after this the version will for sure be a valid format and not 'any' + is_version_correct = check_cellpose_version(version) if is_version_correct: return - - major_version = int(version.split('.')[0]) - next_version = major_version+1 + major_version = int(version.split(".")[0]) + + next_version = major_version + 1 min_version = min_target_versions_cp[str(major_version)] - + check_install_package( - 'cellpose', - max_version=f'{next_version}.0', + "cellpose", + max_version=f"{next_version}.0", min_version=min_version, include_lower_version=True, ) - purge_module('cellpose') + purge_module("cellpose") + def check_install_baby(): check_install_package( - 'TensorFlow', - pypi_name='tensorflow', - import_pkg_name='tensorflow', - max_version='2.14' + "TensorFlow", + pypi_name="tensorflow", + import_pkg_name="tensorflow", + max_version="2.14", ) - check_install_package('baby', pypi_name='baby-seg', import_pkg_name='baby') + check_install_package("baby", pypi_name="baby-seg", import_pkg_name="baby") + def check_install_nnInteractive(): - check_install_package('huggingface-hub') + check_install_package("huggingface-hub") check_install_torch() - check_install_package('nnInteractive') - - purge_module('nnInteractive') + check_install_package("nnInteractive") + + purge_module("nnInteractive") importlib.invalidate_caches() import nnInteractive + importlib.reload(nnInteractive) + def check_install_microsam(): - check_install_package( - 'micro-sam', - pypi_name='micro_sam', - installer='conda' - ) + check_install_package("micro-sam", pypi_name="micro_sam", installer="conda") + def check_install_yeaz(): check_install_torch() - check_install_package('yeaz') + check_install_package("yeaz") + def check_install_segment_anything(): check_install_torch() - check_install_package('segment_anything') + check_install_package("segment_anything") + def check_install_sam2(): check_install_torch() - check_install_package('sam2') + check_install_package("sam2") def check_install_cellsam(): check_install_torch() check_install_package( - 'cellSAM', - pypi_name='git+https://github.com/vanvalenlab/cellSAM.git', - import_pkg_name='cellSAM', + "cellSAM", + pypi_name="git+https://github.com/vanvalenlab/cellSAM.git", + import_pkg_name="cellSAM", note=( - 'CellSAM requires a DeepCell access token to download models.\n' - 'Set the DEEPCELL_ACCESS_TOKEN environment variable before use.\n' - 'Get your token at: https://deepcell.org' - ) + "CellSAM requires a DeepCell access token to download models.\n" + "Set the DEEPCELL_ACCESS_TOKEN environment variable before use.\n" + "Get your token at: https://deepcell.org" + ), ) + def is_gui_running(): if not GUI_INSTALLED: return False - + return QCoreApplication.instance() is not None -def check_pkg_version(import_pkg_name, min_version, include_lower_version, raise_err=True): + +def check_pkg_version( + import_pkg_name, min_version, include_lower_version, raise_err=True +): is_version_correct = False try: installed_version = get_package_version(import_pkg_name) if include_lower_version: - is_version_correct = ( - packaging_version.parse(installed_version) - >= packaging_version.parse(min_version) - ) - else: - is_version_correct = ( - packaging_version.parse(installed_version) - > packaging_version.parse(min_version) - ) + is_version_correct = packaging_version.parse( + installed_version + ) >= packaging_version.parse(min_version) + else: + is_version_correct = packaging_version.parse( + installed_version + ) > packaging_version.parse(min_version) except Exception as err: is_version_correct = False - + if raise_err and not is_version_correct: - raise ModuleNotFoundError( - f'{import_pkg_name}>{min_version} not installed.' - ) + raise ModuleNotFoundError(f"{import_pkg_name}>{min_version} not installed.") else: return is_version_correct + def check_pkg_exact_version(import_pkg_name, version: str, raise_err=True): is_version_correct = False try: installed_version = get_package_version(import_pkg_name) - is_version_correct = ( - packaging_version.parse(installed_version) - == packaging_version.parse(version) - ) + is_version_correct = packaging_version.parse( + installed_version + ) == packaging_version.parse(version) except Exception as err: is_version_correct = False - + if raise_err and not is_version_correct: - raise ModuleNotFoundError( - f'{import_pkg_name}=={version} not installed.' - ) + raise ModuleNotFoundError(f"{import_pkg_name}=={version} not installed.") else: return is_version_correct + def check_pkg_max_version( - import_pkg_name, max_version, include_higher_version, raise_err=True - ): + import_pkg_name, max_version, include_higher_version, raise_err=True +): is_version_correct = False try: from packaging import version - installed_version = get_package_version(import_pkg_name) + + installed_version = get_package_version(import_pkg_name) if include_higher_version: - is_version_correct = ( - packaging_version.parse(installed_version) - <= packaging_version.parse(max_version) - ) + is_version_correct = packaging_version.parse( + installed_version + ) <= packaging_version.parse(max_version) else: - is_version_correct = ( - packaging_version.parse(installed_version) - < packaging_version.parse(max_version) - ) + is_version_correct = packaging_version.parse( + installed_version + ) < packaging_version.parse(max_version) except Exception as err: is_version_correct = False - + if raise_err and not is_version_correct: - raise ModuleNotFoundError( - f'{import_pkg_name}<={max_version} not installed.' - ) + raise ModuleNotFoundError(f"{import_pkg_name}<={max_version} not installed.") else: return is_version_correct -def install_package_conda(conda_pkg_name, channel='conda-forge'): + +def install_package_conda(conda_pkg_name, channel="conda-forge"): if not is_conda_env(): - raise EnvironmentError( - 'Cell-ACDC is not running in a `conda` environment.' - ) + raise EnvironmentError("Cell-ACDC is not running in a `conda` environment.") conda_prefix, pip_prefix = get_pip_conda_prefix() conda_prefix = re.sub( - r'(-c\sconda-forge\s?|--channel=conda-forge\s?)', f'-c {channel} ', - conda_prefix + r"(-c\sconda-forge\s?|--channel=conda-forge\s?)", f"-c {channel} ", conda_prefix ) - command = f'{conda_prefix} -y {conda_pkg_name}' + command = f"{conda_prefix} -y {conda_pkg_name}" _subprocess_run_command(command) -def _subprocess_run_command(command, shell=True, callback='check_call'): + +def _subprocess_run_command(command, shell=True, callback="check_call"): func = getattr(subprocess, callback) try: out = func(command, shell=shell) except Exception as err: print( - f'[WARNING]: Command `{command}` failed. ' - f'Trying with `{command.split()}`...' + f"[WARNING]: Command `{command}` failed. Trying with `{command.split()}`..." ) out = func(command.split(), shell=shell) - + return out + def check_install_omnipose(): try: - import_module('omnipose') + import_module("omnipose") return except ModuleNotFoundError: pass - + try: - check_install_package('omnipose', pypi_name='omnipose_acdc') + check_install_package("omnipose", pypi_name="omnipose_acdc") except Exception as err: - install_package_conda('mahotas') - _install_pip_package('omnipose-acdc') + install_package_conda("mahotas") + _install_pip_package("omnipose-acdc") + def _run_command(command: str | list[str], shell=False): if not isinstance(command, (str, list)): raise TypeError( - f'Command must be a string or a list of strings, not {type(command)}' + f"Command must be a string or a list of strings, not {type(command)}" ) - + command_str = None if isinstance(command, str): args_list = [command] @@ -3243,30 +3353,32 @@ def _run_command(command: str | list[str], shell=False): args_list = command if len(command) == 1: command_str = command[0] - + try: subprocess.check_call(args_list, shell=shell) return except Exception as err: pass - + if command_str is None: return - + try: subprocess.check_call(command_str, shell=shell) return except Exception as err: pass - + try: from . import acdc_regex + args = acdc_regex.RE_SPLIT_SPACES_IGNORE_QUOTES.split(command_str)[1::2] subprocess.check_call(args, shell=shell) return except Exception as err: pass + def _warn_dll_torch(qparent=None): msg = widgets.myMessageBox() txt = html_utils.paragraph(""" @@ -3280,70 +3392,75 @@ def _warn_dll_torch(qparent=None): DLL conflicts. """) msg.information( - qparent, 'Please restart Cell-ACDC', txt, - buttonsTexts=('Ok, I will save my data and restart Cell-ACDC'), + qparent, + "Please restart Cell-ACDC", + txt, + buttonsTexts=("Ok, I will save my data and restart Cell-ACDC"), ) -def check_install_torch(is_cli=False, caller_name='Cell-ACDC', qparent=None): + +def check_install_torch(is_cli=False, caller_name="Cell-ACDC", qparent=None): try: import torch import torchvision + return except OSError as err: - if 'dll' in str(err): + if "dll" in str(err): _warn_dll_torch(qparent=qparent) raise err else: traceback.print_exc() except Exception as err: traceback.print_exc() - + if is_cli: - _install_pytorch_cli(caller_name=caller_name) + _install_pytorch_cli(caller_name=caller_name) return - + win = apps.InstallPyTorchDialog(parent=qparent, caller_name=caller_name) win.exec_() if win.cancel: - _warnings.log_pytorch_not_installed() + _warnings.log_pytorch_not_installed() return - + command = win.command print(f'Running command: "{command}"') _run_command(command) - + try: import torch except OSError as e: - if 'dll' in str(e): + if "dll" in str(e): _warn_dll_torch(qparent=qparent) raise e - - purge_module('torch') + + purge_module("torch") + def check_install_package( - pkg_name: str, - import_pkg_name: str='', - pypi_name='', - note='', - parent=None, - raise_on_cancel=True, - logger_func=print, - is_cli=False, - caller_name='Cell-ACDC', - force_upgrade=False, - upgrade=False, - min_version='', - max_version='', - exact_version='', - install_dependencies=True, - return_outcome=False, - installer: Literal['pip', 'conda']='pip', - include_higher_version: bool = False, - include_lower_version: bool = False - ): - """Try to import a package. If import fails, ask user to install it + pkg_name: str, + import_pkg_name: str = "", + pypi_name="", + note="", + parent=None, + raise_on_cancel=True, + logger_func=print, + is_cli=False, + caller_name="Cell-ACDC", + force_upgrade=False, + upgrade=False, + min_version="", + max_version="", + exact_version="", + install_dependencies=True, + return_outcome=False, + installer: Literal["pip", "conda"] = "pip", + include_higher_version: bool = False, + include_lower_version: bool = False, +): + """Try to import a package. If import fails, ask user to install it automatically. Parameters @@ -3365,7 +3482,7 @@ def check_install_package( logger_func : callable, optional Function used to log text. Default is print is_cli : bool, optional - If True, message will be displayed in the terminal. + If True, message will be displayed in the terminal. If False, message will be displayed in a Qt message box. Default is False caller_name : str, optional @@ -3373,26 +3490,26 @@ def check_install_package( force_upgrade : bool, optional If True, we force the upgrade even if package is installed. upgrade : bool, optional - If True, pip will upgrade the package. This value is True if + If True, pip will upgrade the package. This value is True if `force_upgrade` is True. Without min_version and max_version it will never upgrade or downgrade the package. min_version : str, optional - If not empty it must be a valid version `major[.minor][.patch]` where - minor and patch are optional. If the installed package is older the - upgrade will be forced. + If not empty it must be a valid version `major[.minor][.patch]` where + minor and patch are optional. If the installed package is older the + upgrade will be forced. max_version : str, optional - If not empty it must be a valid version `major[.minor][.patch]` where - minor and patch are optional. If the installed package is newer the - upgrade will be forced. + If not empty it must be a valid version `major[.minor][.patch]` where + minor and patch are optional. If the installed package is newer the + upgrade will be forced. exact_version : str, optional - If not empty, install this exact version. It must be a valid + If not empty, install this exact version. It must be a valid `major[.minor][.patch]`. install_dependencies : bool, optional If False, the `--no-deps` flag will be added to the pip command. return_outcome : bool, optional If True, returns 1 on successfull action installer : str, optional - Package manager to use to install the package. Either 'pip' or 'conda'. + Package manager to use to install the package. Either 'pip' or 'conda'. Default is 'pip' include_higher_version : bool, optional If True, if the higher version is installed, it will not be downgraded. @@ -3400,7 +3517,7 @@ def check_install_package( include_lower_version : bool, optional If True, if the lower version is installed, it will not be upgraded. Default is False - + Raises ------ ModuleNotFoundError @@ -3408,17 +3525,18 @@ def check_install_package( """ if not import_pkg_name: import_pkg_name = pkg_name - + if not is_gui_running(): - is_cli=True - - try: # check_pkg_version and check_pkg_max_version - import_pkg_name = import_pkg_name.replace('-', '_') + is_cli = True + + try: # check_pkg_version and check_pkg_max_version + import_pkg_name = import_pkg_name.replace("-", "_") import_module(import_pkg_name) if force_upgrade: upgrade = True raise ModuleNotFoundError( - f'User requested to forcefully upgrade the package "{pkg_name}"') + f'User requested to forcefully upgrade the package "{pkg_name}"' + ) if exact_version: check_pkg_exact_version(import_pkg_name, exact_version) if min_version: @@ -3427,52 +3545,50 @@ def check_install_package( check_pkg_max_version(import_pkg_name, max_version, include_higher_version) except ModuleNotFoundError: proceed = _install_package_msg( - pkg_name, - note=note, - parent=parent, + pkg_name, + note=note, + parent=parent, upgrade=upgrade, - is_cli=is_cli, - caller_name=caller_name, + is_cli=is_cli, + caller_name=caller_name, logger_func=logger_func, - pkg_command=pypi_name, - max_version=max_version, + pkg_command=pypi_name, + max_version=max_version, min_version=min_version, exact_version=exact_version, installer=installer, include_higher_version=include_higher_version, - include_lower_version=include_lower_version + include_lower_version=include_lower_version, ) if pypi_name: pkg_name = pypi_name if not proceed: if raise_on_cancel: - raise ModuleNotFoundError( - f'User aborted {pkg_name} installation' - ) + raise ModuleNotFoundError(f"User aborted {pkg_name} installation") else: return traceback.format_exc() try: - if pkg_name == 'tensorflow': - _install_tensorflow( - max_version=max_version, min_version=min_version - ) - elif pkg_name == 'deepsea': + if pkg_name == "tensorflow": + _install_tensorflow(max_version=max_version, min_version=min_version) + elif pkg_name == "deepsea": _install_deepsea() - elif pkg_name == 'segment_anything': + elif pkg_name == "segment_anything": _install_segment_anything() - elif pkg_name == 'sam2': + elif pkg_name == "sam2": _install_sam2() else: pkg_command = _get_pkg_command_pip_install( - pkg_name, + pkg_name, exact_version=exact_version, - max_version=max_version, + max_version=max_version, min_version=min_version, including_higher_version=include_higher_version, including_lower_version=include_lower_version, ) - if installer == 'pip': - _install_pip_package(pkg_command, install_dependencies=install_dependencies) + if installer == "pip": + _install_pip_package( + pkg_command, install_dependencies=install_dependencies + ) else: install_package_conda(pkg_command) except Exception as e: @@ -3483,65 +3599,64 @@ def check_install_package( if return_outcome: return True + def check_install_custom_dependencies(custom_install_requires, *args, **kwargs): """Used to install a package with custom dependencies, usefull if they have random pinned versions for their dependencies. - + For *args and **kwargs see `myutils.check_install_package`. Parameters ---------- custom_install_requires : list - list of dependencies. Check either requirements.txt, setup.py, + list of dependencies. Check either requirements.txt, setup.py, setup.cfg, pyproject.toml, or any other file that lists the dependencies. - For formatting of the dependencies with min max version, + For formatting of the dependencies with min max version, use _get_pkg_command_pip_install. """ - kwargs['install_dependencies'] = False - kwargs['return_outcome'] = True + kwargs["install_dependencies"] = False + kwargs["return_outcome"] = True success = check_install_package(*args, **kwargs) if not success: return for pkg_name in custom_install_requires: _install_pip_package(pkg_name) + def get_chained_attr(_object, _name): - for attr in _name.split('.'): + for attr in _name.split("."): _object = getattr(_object, attr) return _object + def check_matplotlib_version(qparent=None): - mpl_version = get_package_version('matplotlib') - mpl_version_digits = mpl_version.split('.') + mpl_version = get_package_version("matplotlib") + mpl_version_digits = mpl_version.split(".") mpl_major = int(mpl_version_digits[0]) mpl_minor = int(mpl_version_digits[1]) - is_less_than_3_5 = ( - mpl_major < 3 or (mpl_major >= 3 and mpl_minor < 5) - ) + is_less_than_3_5 = mpl_major < 3 or (mpl_major >= 3 and mpl_minor < 5) if not is_less_than_3_5: - return - - proceed = _install_package_msg('matplotlib', parent=qparent, upgrade=True) + return + + proceed = _install_package_msg("matplotlib", parent=qparent, upgrade=True) if not proceed: - raise ModuleNotFoundError( - f'User aborted "matplotlib" installation' - ) + raise ModuleNotFoundError(f'User aborted "matplotlib" installation') import subprocess + try: subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', '-U', 'matplotlib'] + [sys.executable, "-m", "pip", "install", "-U", "matplotlib"] ) except Exception as e: printl(traceback.format_exc()) - _inform_install_package_failed( - 'matplotlib', parent=qparent, do_exit=False - ) - + _inform_install_package_failed("matplotlib", parent=qparent, do_exit=False) + + def _inform_install_package_failed(pkg_name, parent=None, do_exit=True): conda_prefix, pip_prefix = get_pip_conda_prefix() - install_command = f'{pip_prefix} --upgrade {pkg_name}' + install_command = f"{pip_prefix} --upgrade {pkg_name}" txt = html_utils.paragraph(f""" Unfortunately, installation of {pkg_name} returned an error.

    Try restarting Cell-ACDC. If it doesn't work, @@ -3551,93 +3666,113 @@ def _inform_install_package_failed(pkg_name, parent=None, do_exit=True): Thank you for your patience. """) msg = widgets.myMessageBox() - msg.critical(parent, f'{pkg_name} installation failed', txt) - print('*'*50) + msg.critical(parent, f"{pkg_name} installation failed", txt) + print("*" * 50) print( f'[ERROR]: Installation of "{pkg_name}" failed. ' - f'Please, close Cell-ACDC and run the command ' - f'{pip_prefix} --upgrade {pkg_name}`' + f"Please, close Cell-ACDC and run the command " + f"{pip_prefix} --upgrade {pkg_name}`" ) - print('^'*50) + print("^" * 50) + def download_fiji(logger_func=print): url = None if is_mac: - url = 'https://downloads.micron.ox.ac.uk/fiji_update/mirrors/fiji-latest/fiji-macosx.zip' + url = "https://downloads.micron.ox.ac.uk/fiji_update/mirrors/fiji-latest/fiji-macosx.zip" file_size = 474_525_405 - + if url is None: return if os.path.exists(get_fiji_exec_folderpath()): return - + os.makedirs(acdc_fiji_path) - + temp_dir = tempfile.mkdtemp() - zip_dst = os.path.join(temp_dir, 'fiji-macosx.zip') + zip_dst = os.path.join(temp_dir, "fiji-macosx.zip") logger_func(f'Downloading Fiji to "{acdc_fiji_path}"...') - download_url( - url, zip_dst, verbose=False, file_size=file_size - ) + download_url(url, zip_dst, verbose=False, file_size=file_size) extract_zip(zip_dst, acdc_fiji_path) - + return acdc_fiji_path + def _install_package_msg( - pkg_name, note='', parent=None, upgrade=False, caller_name='Cell-ACDC', - is_cli=False, pkg_command='', logger_func=print, - exact_version='', max_version='', min_version='', - installer: Literal['pip', 'conda']='pip', - include_higher_version: bool = False, - include_lower_version: bool = False - ): + pkg_name, + note="", + parent=None, + upgrade=False, + caller_name="Cell-ACDC", + is_cli=False, + pkg_command="", + logger_func=print, + exact_version="", + max_version="", + min_version="", + installer: Literal["pip", "conda"] = "pip", + include_higher_version: bool = False, + include_lower_version: bool = False, +): if is_cli: proceed = _install_package_cli_msg( - pkg_name, note=note, upgrade=upgrade, caller_name=caller_name, - pkg_command=pkg_command, + pkg_name, + note=note, + upgrade=upgrade, + caller_name=caller_name, + pkg_command=pkg_command, exact_version=exact_version, - max_version=max_version, - min_version=min_version, logger_func=logger_func, + max_version=max_version, + min_version=min_version, + logger_func=logger_func, installer=installer, include_higher_version=include_higher_version, - include_lower_version=include_lower_version + include_lower_version=include_lower_version, ) else: proceed = _install_package_gui_msg( - pkg_name, note=note, parent=parent, upgrade=upgrade, - caller_name=caller_name, pkg_command=pkg_command, + pkg_name, + note=note, + parent=parent, + upgrade=upgrade, + caller_name=caller_name, + pkg_command=pkg_command, exact_version=exact_version, - max_version=max_version, min_version=min_version, - logger_func=logger_func, installer=installer, + max_version=max_version, + min_version=min_version, + logger_func=logger_func, + installer=installer, including_higher_version=include_higher_version, - including_lower_version=include_lower_version + including_lower_version=include_lower_version, ) return proceed + def get_cli_multi_choice_question(question, choices): - choices_format = [f'{i+1}) {choice}.' for i, choice in enumerate(choices)] - choices_format = ' '.join(choices_format) - choices_opts = '/'.join([str(i) for i in range(1, len(choices)+1)]) - text = f'{question} {choices_format} q) Quit. ({choices_opts})?: ' + choices_format = [f"{i + 1}) {choice}." for i, choice in enumerate(choices)] + choices_format = " ".join(choices_format) + choices_opts = "/".join([str(i) for i in range(1, len(choices) + 1)]) + text = f"{question} {choices_format} q) Quit. ({choices_opts})?: " return text -def _install_pytorch_cli( - caller_name='Cell-ACDC', action='install', logger_func=print - ): - separator = '-'*60 + +def _install_pytorch_cli(caller_name="Cell-ACDC", action="install", logger_func=print): + separator = "-" * 60 txt = ( - f'{separator}\n{caller_name} needs to {action} PyTorch\n\n' - 'You can choose to install it now or stop the process and install it ' - 'later. To install it correctly, we need to know your preferences.\n' + f"{separator}\n{caller_name} needs to {action} PyTorch\n\n" + "You can choose to install it now or stop the process and install it " + "later. To install it correctly, we need to know your preferences.\n" ) logger_func(txt) questions = { - 'Choose your OS:': ('Windows', 'Mac', 'Linux'), - 'Package manager:': ('Pip'), - 'Compute platform:': ( - 'CPU', 'CUDA 11.8 (NVIDIA GPU)', 'CUDA 12.1 (NVIDIA GPU)' - ) + "Choose your OS:": ("Windows", "Mac", "Linux"), + "Package manager:": ("Pip"), + "Compute platform:": ( + "CPU", + "CUDA 11.8 (NVIDIA GPU)", + "CUDA 12.1 (NVIDIA GPU)", + ), } selected_command = get_pytorch_command() selected_preferences = [] @@ -3645,49 +3780,46 @@ def _install_pytorch_cli( input_txt = get_cli_multi_choice_question(question, choices) while True: answer = input(input_txt) - if answer.lower() == 'q': - exit('Execution stopped by the user.') - + if answer.lower() == "q": + exit("Execution stopped by the user.") + try: idx = int(answer) - 1 if idx >= len(choices): - raise TypeError('Not a valid answer') + raise TypeError("Not a valid answer") except Exception as err: - print('-'*100) + print("-" * 100) logger_func( f'"{answer}" is not a valid answer.' 'Choose one of the options or "q" to quit.' ) - print('^'*100) + print("^" * 100) continue - + preference = choices[idx] selected_command = selected_command[preference] selected_preferences.append(preference) - print('') + print("") break - - print('-'*100) - selected_preferences = ', '.join(selected_preferences) - logger_func(f'Selected preferences: {selected_preferences}') - print('-'*100) - logger_func(f'Command:\n\n{selected_command}\n') + + print("-" * 100) + selected_preferences = ", ".join(selected_preferences) + logger_func(f"Selected preferences: {selected_preferences}") + print("-" * 100) + logger_func(f"Command:\n\n{selected_command}\n") while True: - answer = input('Do you want to run the command now ([y]/n)?: ') - if answer.lower() == 'n': - exit('Execution stopped by the user.') - - if answer.lower() == 'y' or not answer: + answer = input("Do you want to run the command now ([y]/n)?: ") + if answer.lower() == "n": + exit("Execution stopped by the user.") + + if answer.lower() == "y" or not answer: break - - print('-'*100) - print( - f'"{answer}" is not a valid answer. ' - 'Choose "y" for yes or "n" for no.' - ) - print('^'*100) - - if selected_command.startswith('conda'): + + print("-" * 100) + print(f'"{answer}" is not a valid answer. Choose "y" for yes or "n" for no.') + print("^" * 100) + + if selected_command.startswith("conda"): try: subprocess.check_call([selected_command], shell=True) except Exception as err: @@ -3703,18 +3835,19 @@ def _install_pytorch_cli( cmd_list = [cmd.lstrip(".") for cmd in cmd_list] subprocess.check_call([sys.executable, *cmd_list], shell=True) + def _get_pkg_command_pip_install( - pkg_command, - exact_version='', - max_version='', - min_version='', - including_lower_version=False, - including_higher_version=False - ): + pkg_command, + exact_version="", + max_version="", + min_version="", + including_lower_version=False, + including_higher_version=False, +): if exact_version: - pkg_command = f'{pkg_command}=={exact_version}' + pkg_command = f"{pkg_command}=={exact_version}" return pkg_command - + if including_higher_version: sign_max = "<=" else: @@ -3724,102 +3857,121 @@ def _get_pkg_command_pip_install( else: sign_min = ">" if min_version: - pkg_command = f'{pkg_command}{sign_min}{min_version}' + pkg_command = f"{pkg_command}{sign_min}{min_version}" if max_version: - pkg_command = f'{pkg_command},' - + pkg_command = f"{pkg_command}," + if max_version: - pkg_command = f'{pkg_command}{sign_max}{max_version}' - + pkg_command = f"{pkg_command}{sign_max}{max_version}" + return pkg_command + def _install_package_cli_msg( - pkg_name, note='', upgrade=False, caller_name='Cell-ACDC', - logger_func=print, pkg_command='', exact_version='', max_version='', - min_version='', installer: Literal['pip', 'conda']='pip', - include_lower_version=False, - include_higher_version=False - ): + pkg_name, + note="", + upgrade=False, + caller_name="Cell-ACDC", + logger_func=print, + pkg_command="", + exact_version="", + max_version="", + min_version="", + installer: Literal["pip", "conda"] = "pip", + include_lower_version=False, + include_higher_version=False, +): if not pkg_command: pkg_command = pkg_name - + pkg_command = _get_pkg_command_pip_install( - pkg_command, exact_version=exact_version, - max_version=max_version, min_version=min_version, + pkg_command, + exact_version=exact_version, + max_version=max_version, + min_version=min_version, including_lower_version=include_lower_version, - including_higher_version=include_higher_version + including_higher_version=include_higher_version, ) - + if upgrade: - action = 'upgrade' + action = "upgrade" else: - action = 'install' - + action = "install" + conda_prefix, pip_prefix = get_pip_conda_prefix() - if installer == 'pip': - install_command = f'{pip_prefix} --upgrade {pkg_command}' - elif installer == 'conda': - install_command = f'{conda_prefix} {pkg_command}' - - separator = '-'*60 + if installer == "pip": + install_command = f"{pip_prefix} --upgrade {pkg_command}" + elif installer == "conda": + install_command = f"{conda_prefix} {pkg_command}" + + separator = "-" * 60 txt = ( - f'{separator}\n{caller_name} needs to {action} {pkg_name}\n\n' - 'You can choose to install it now or stop the process and install it ' - 'later with the following command:\n\n' - f'{install_command}\n' + f"{separator}\n{caller_name} needs to {action} {pkg_name}\n\n" + "You can choose to install it now or stop the process and install it " + "later with the following command:\n\n" + f"{install_command}\n" ) logger_func(txt) - - - + while True: answer = try_input_install_package(pkg_name, install_command) - if not answer or answer.lower() == 'y': + if not answer or answer.lower() == "y": return True - - if answer.lower() == 'n': + + if answer.lower() == "n": return False - + logger_func( f'{answer} is not a valid answer. Valid answers are "y" for Yes and ' '"n" for No.' ) - + + def _install_package_gui_msg( - pkg_name, note='', parent=None, upgrade=False, caller_name='Cell-ACDC', - pkg_command='', logger_func=None, exact_version='', - max_version='', min_version='', - including_lower_version=False, including_higher_version=False, - installer: Literal['pip', 'conda']='pip' - ): + pkg_name, + note="", + parent=None, + upgrade=False, + caller_name="Cell-ACDC", + pkg_command="", + logger_func=None, + exact_version="", + max_version="", + min_version="", + including_lower_version=False, + including_higher_version=False, + installer: Literal["pip", "conda"] = "pip", +): msg = widgets.myMessageBox(parent=parent) if upgrade: - install_text = 'upgrade' + install_text = "upgrade" else: - install_text = 'install' - if pkg_name == 'BayesianTracker': - pkg_name = 'btrack' - + install_text = "install" + if pkg_name == "BayesianTracker": + pkg_name = "btrack" + if not pkg_command: pkg_command = pkg_name - + pkg_command = _get_pkg_command_pip_install( - pkg_command, exact_version=exact_version, - max_version=max_version, min_version=min_version, + pkg_command, + exact_version=exact_version, + max_version=max_version, + min_version=min_version, including_lower_version=including_lower_version, - including_higher_version=including_higher_version + including_higher_version=including_higher_version, ) - + conda_prefix, pip_prefix = get_pip_conda_prefix() - if installer == 'pip': - command = f'{pip_prefix} --upgrade {pkg_command}' - elif installer == 'conda': - command = f'{conda_prefix} {pkg_command}' - - command_html = command.lower().replace('<', '<').replace('>', '>') - + if installer == "pip": + command = f"{pip_prefix} --upgrade {pkg_command}" + elif installer == "conda": + command = f"{conda_prefix} {pkg_command}" + + command_html = command.lower().replace("<", "<").replace(">", ">") + txt = html_utils.paragraph(f""" {caller_name} is going to download and {install_text} {pkg_name}.

    @@ -3832,172 +3984,189 @@ def _install_package_gui_msg( command: """) if note: - txt = f'{txt}{note}' + txt = f"{txt}{note}" _, okButton = msg.information( - parent, f'Install {pkg_name}', txt, - buttonsTexts=('Cancel', 'Ok'), - commands=(command_html,) + parent, + f"Install {pkg_name}", + txt, + buttonsTexts=("Cancel", "Ok"), + commands=(command_html,), ) return msg.clickedButton == okButton -def _install_tensorflow(max_version='', min_version=''): + +def _install_tensorflow(max_version="", min_version=""): cpu = platform.processor() pkg_command = _get_pkg_command_pip_install( - 'tensorflow', - max_version=max_version, - min_version=min_version + "tensorflow", max_version=max_version, min_version=min_version ) conda_prefix, pip_prefix = get_pip_conda_prefix() - if is_mac and cpu == 'arm': + if is_mac and cpu == "arm": args = [f'{conda_prefix} "{pkg_command}"'] shell = True else: - args = [sys.executable, '-m', 'pip', 'install', '-U', pkg_command] + args = [sys.executable, "-m", "pip", "install", "-U", pkg_command] shell = False subprocess.check_call(args, shell=shell) - + # purge numpy - purge_module('numpy') + purge_module("numpy") + def _install_segment_anything(): args = [ - sys.executable, '-m', 'pip', 'install', - '-U', '--use-pep517', - 'git+https://github.com/facebookresearch/segment-anything.git' + sys.executable, + "-m", + "pip", + "install", + "-U", + "--use-pep517", + "git+https://github.com/facebookresearch/segment-anything.git", ] subprocess.check_call(args) + def _install_sam2(): args = [ - sys.executable, '-m', 'pip', 'install', - '-U', '--use-pep517', - 'git+https://github.com/facebookresearch/sam2.git' + sys.executable, + "-m", + "pip", + "install", + "-U", + "--use-pep517", + "git+https://github.com/facebookresearch/sam2.git", ] subprocess.check_call(args) + def _install_deepsea(): - subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', 'deepsea'] - ) + subprocess.check_call([sys.executable, "-m", "pip", "install", "deepsea"]) + def import_tracker_module(tracker_name): - module_name = f'cellacdc.trackers.{tracker_name}.{tracker_name}_tracker' + module_name = f"cellacdc.trackers.{tracker_name}.{tracker_name}_tracker" tracker_module = import_module(module_name) return tracker_module -def download_ffmpeg(): + +def download_ffmpeg(): ffmpeg_folderpath = acdc_ffmpeg_path if is_win: - url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/rXioWZpwjwn9JTT/download/windows_ffmpeg-7.0-full_build.zip' + url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/rXioWZpwjwn9JTT/download/windows_ffmpeg-7.0-full_build.zip" file_size = 173477888 - ffmep_exec_path = os.path.join(ffmpeg_folderpath, 'bin', 'ffmpeg.exe') + ffmep_exec_path = os.path.join(ffmpeg_folderpath, "bin", "ffmpeg.exe") elif is_mac: - url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/We7rcTLzqAP4zf7/download/mac_ffmpeg.zip' + url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/We7rcTLzqAP4zf7/download/mac_ffmpeg.zip" file_size = 25288704 - ffmep_exec_path = os.path.join(ffmpeg_folderpath, 'ffmpeg') + ffmep_exec_path = os.path.join(ffmpeg_folderpath, "ffmpeg") elif is_linux: - ffmep_exec_path = '' + ffmep_exec_path = "" return ffmep_exec_path - + if os.path.exists(ffmep_exec_path): - return ffmep_exec_path.replace('\\', os.sep).replace('/', os.sep) - - print('Downloading FFMPEG...') + return ffmep_exec_path.replace("\\", os.sep).replace("/", os.sep) + + print("Downloading FFMPEG...") temp_dir = tempfile.mkdtemp() - temp_zip_path = os.path.join(temp_dir, 'acdc-ffmpeg.zip') - + temp_zip_path = os.path.join(temp_dir, "acdc-ffmpeg.zip") + download_url( - url, temp_zip_path, verbose=True, file_size=file_size, + url, + temp_zip_path, + verbose=True, + file_size=file_size, ) extract_zip(temp_zip_path, ffmpeg_folderpath) - - return ffmep_exec_path.replace('\\', os.sep).replace('/', os.sep) + + return ffmep_exec_path.replace("\\", os.sep).replace("/", os.sep) + def get_fiji_binary_filepath_mac(fiji_app_filepath): if not is_mac: - return '' + return "" fiji_binary_path = os.path.join( - fiji_app_filepath, 'Contents', 'MacOS', 'ImageJ-macosx' + fiji_app_filepath, "Contents", "MacOS", "ImageJ-macosx" ) if os.path.exists(fiji_binary_path): return fiji_binary_path - + fiji_binary_path = os.path.join( - fiji_app_filepath, 'Contents', 'MacOS', 'fiji-macos' + fiji_app_filepath, "Contents", "MacOS", "fiji-macos" ) if os.path.exists(fiji_binary_path): return fiji_binary_path - return '' + return "" + def get_fiji_exec_folderpath() -> str: if not is_mac: - return '' - + return "" + from cellacdc import fiji_location_filepath - + if os.path.exists(fiji_location_filepath): - with open(fiji_location_filepath, 'r') as txt: + with open(fiji_location_filepath, "r") as txt: fiji_app_filepath = txt.read() return get_fiji_binary_filepath_mac(fiji_app_filepath) - if os.path.exists('/Applications/Fiji.app'): - return get_fiji_binary_filepath_mac('/Applications/Fiji.app') - - acdc_fiji_app_path = os.path.join(acdc_fiji_path, 'Fiji.app') + if os.path.exists("/Applications/Fiji.app"): + return get_fiji_binary_filepath_mac("/Applications/Fiji.app") + + acdc_fiji_app_path = os.path.join(acdc_fiji_path, "Fiji.app") acdc_fiji_binary_path = get_fiji_binary_filepath_mac(acdc_fiji_app_path) - + return acdc_fiji_binary_path + def get_fiji_base_command(): command = None if is_mac: command = get_fiji_exec_folderpath() - + return command - + + def _init_fiji_cli(): if is_win: return True - + fiji_app_folderpath = get_fiji_exec_folderpath() - args_add_to_path = [f'chmod 755 {fiji_app_folderpath}'] + args_add_to_path = [f"chmod 755 {fiji_app_folderpath}"] try: subprocess.check_call(args_add_to_path, shell=True) return True except Exception as e: - printl(f'Error occurred while setting permissions: {e}') + printl(f"Error occurred while setting permissions: {e}") return False + def test_fiji_base_command(logger_func=print): base_command = get_fiji_base_command() if base_command is None: - logger_func('[WARNING]: Fiji is not present.') + logger_func("[WARNING]: Fiji is not present.") return False - - command = f'{base_command} --headless' - return run_fiji_command(command=command, logger_func=logger_func) + + command = f"{base_command} --headless" + return run_fiji_command(command=command, logger_func=logger_func) + def run_fiji_command(command=None, logger_func=print): if command is None: - command = f'{get_fiji_base_command()} --headless' - + command = f"{get_fiji_base_command()} --headless" + init_success = _init_fiji_cli() if not init_success: return False - separator = '-'*100 + separator = "-" * 100 commands = (command, command.split()) for args in commands: - logger_func( - f'{separator}\n' - f'Trying Fiji command: "{args}"...\n' - f'{separator}\n' - ) + logger_func(f'{separator}\nTrying Fiji command: "{args}"...\n{separator}\n') try: subprocess.check_call(args, shell=True) return True @@ -4005,29 +4174,29 @@ def run_fiji_command(command=None, logger_func=print): continue return False + def import_promptable_segment_module(model_name): try: acdcPromptSegment = import_module( - f'cellacdc.segmenters_promptable.{model_name}.acdcPromptSegment' + f"cellacdc.segmenters_promptable.{model_name}.acdcPromptSegment" ) except ModuleNotFoundError as e: # Check if custom model cp = config.ConfigParser() cp.read(promptable_models_list_file_path) - model_path = cp[model_name]['path'] - spec = importlib.util.spec_from_file_location( - 'acdcPromptSegment', model_path - ) + model_path = cp[model_name]["path"] + spec = importlib.util.spec_from_file_location("acdcPromptSegment", model_path) acdcPromptSegment = importlib.util.module_from_spec(spec) - sys.modules['acdcPromptSegment'] = acdcPromptSegment + sys.modules["acdcPromptSegment"] = acdcPromptSegment spec.loader.exec_module(acdcPromptSegment) return acdcPromptSegment + def init_tracker( - posData, trackerName, realTime=False, qparent=None, - return_init_params=False - ): + posData, trackerName, realTime=False, qparent=None, return_init_params=False +): from . import apps + downloadWin = apps.downloadModel(trackerName, parent=qparent) downloadWin.download() @@ -4035,30 +4204,32 @@ def init_tracker( init_params = {} track_params = {} paramsWin = None - if trackerName == 'BayesianTracker': + if trackerName == "BayesianTracker": Y, X = posData.img_data_shape[-2:] if posData.isSegm3D: labShape = (posData.SizeZ, Y, X) else: labShape = (1, Y, X) paramsWin = apps.BayesianTrackerParamsWin( - labShape, parent=qparent, channels=posData.chNames, - currentChannelName=posData.user_ch_name + labShape, + parent=qparent, + channels=posData.chNames, + currentChannelName=posData.user_ch_name, ) paramsWin.exec_() if not paramsWin.cancel: init_params = paramsWin.params - track_params['export_to'] = posData.get_btrack_export_path() + track_params["export_to"] = posData.get_btrack_export_path() if paramsWin.intensityImageChannel is not None: chName = paramsWin.intensityImageChannel - track_params['image'] = posData.loadChannelData(chName) - track_params['image_channel_name'] = chName - elif trackerName == 'CellACDC': + track_params["image"] = posData.loadChannelData(chName) + track_params["image_channel_name"] = chName + elif trackerName == "CellACDC": paramsWin = apps.CellACDCTrackerParamsWin(parent=qparent) paramsWin.exec_() if not paramsWin.cancel: init_params = paramsWin.params - elif trackerName == 'delta': + elif trackerName == "delta": paramsWin = apps.DeltaTrackerParamsWin(posData=posData, parent=qparent) paramsWin.exec_() if not paramsWin.cancel: @@ -4067,9 +4238,7 @@ def init_tracker( init_argspecs, track_argspecs = getTrackerArgSpec( trackerModule, realTime=realTime ) - intensityImgRequiredForTracker = isIntensityImgRequiredForTracker( - trackerModule - ) + intensityImgRequiredForTracker = isIntensityImgRequiredForTracker(trackerModule) if init_argspecs or track_argspecs: try: url = trackerModule.url_help() @@ -4087,61 +4256,69 @@ def init_tracker( df_metadata = posData.metadata_df except Exception as e: df_metadata = None - + if not intensityImgRequiredForTracker: currentChannelName = None - + paramsWin = apps.QDialogModelParams( - init_argspecs, track_argspecs, trackerName, url=url, - channels=channels, is_tracker=True, + init_argspecs, + track_argspecs, + trackerName, + url=url, + channels=channels, + is_tracker=True, currentChannelName=currentChannelName, - df_metadata=df_metadata, posData=posData + df_metadata=df_metadata, + posData=posData, ) if not intensityImgRequiredForTracker and channels is not None: paramsWin.channelCombobox.setDisabled(True) - + paramsWin.exec_() if not paramsWin.cancel: init_params = paramsWin.init_kwargs track_params = paramsWin.model_kwargs - if paramsWin.inputChannelName != 'None': + if paramsWin.inputChannelName != "None": chName = paramsWin.inputChannelName - track_params['image'] = posData.loadChannelData(chName) - track_params['image_channel_name'] = chName - if 'export_to_extension' in track_params: - ext = track_params['export_to_extension'] - track_params['export_to'] = posData.get_tracker_export_path( + track_params["image"] = posData.loadChannelData(chName) + track_params["image_channel_name"] = chName + if "export_to_extension" in track_params: + ext = track_params["export_to_extension"] + track_params["export_to"] = posData.get_tracker_export_path( trackerName, ext ) if paramsWin is not None and paramsWin.cancel: - tracker = None, + tracker = (None,) track_params = None init_params = None else: tracker = trackerModule.tracker(**init_params) - + if return_init_params: return tracker, track_params, init_params else: return tracker, track_params + def import_segment_module(model_name): try: - acdcSegment = import_module(f'cellacdc.segmenters.{model_name}.acdcSegment') + acdcSegment = import_module(f"cellacdc.segmenters.{model_name}.acdcSegment") except ModuleNotFoundError as e: # Check if custom model cp = config.ConfigParser() cp.read(models_list_file_path) - model_path = cp[model_name]['path'] - spec = importlib.util.spec_from_file_location('acdcSegment', model_path) + model_path = cp[model_name]["path"] + spec = importlib.util.spec_from_file_location("acdcSegment", model_path) acdcSegment = importlib.util.module_from_spec(spec) - sys.modules['acdcSegment'] = acdcSegment + sys.modules["acdcSegment"] = acdcSegment spec.loader.exec_module(acdcSegment) return acdcSegment + def get_pip_conda_prefix(list_return=False): from .config import parser_args + try: cp = parser_args if cp["install_details"] is not None: @@ -4149,7 +4326,7 @@ def get_pip_conda_prefix(list_return=False): install_details = cp["install_details"] venv_path = install_details["venv_path"] conda_path = install_details["conda_path"] - if ' ' not in conda_path: + if " " not in conda_path: conda_path = conda_path.strip('"').strip("'") else: no_cli_install = False @@ -4158,20 +4335,28 @@ def get_pip_conda_prefix(list_return=False): pass if no_cli_install: - conda_prefix = f'{conda_path} install -y -p {venv_path} -c conda-forge' + conda_prefix = f"{conda_path} install -y -p {venv_path} -c conda-forge" exec_path = sys.executable - if ' ' in exec_path: + if " " in exec_path: exec_path = f'"{exec_path}"' pip_prefix = f"{exec_path} -m pip install" else: - conda_prefix = 'conda install -y -c conda-forge' - pip_prefix = 'pip install' - - pip_list = [sys.executable, '-m', 'pip', 'install'] + conda_prefix = "conda install -y -c conda-forge" + pip_prefix = "pip install" + + pip_list = [sys.executable, "-m", "pip", "install"] if no_cli_install: - conda_list = [conda_path.strip('"').strip("'"), 'install', '-y', '-p', venv_path.strip('"').strip("'"), '-c', 'conda-forge'] + conda_list = [ + conda_path.strip('"').strip("'"), + "install", + "-y", + "-p", + venv_path.strip('"').strip("'"), + "-c", + "conda-forge", + ] else: - conda_list = ['conda', 'install', '-y', '-c', 'conda-forge'] + conda_list = ["conda", "install", "-y", "-c", "conda-forge"] if list_return: return conda_list, pip_list else: @@ -4179,25 +4364,22 @@ def get_pip_conda_prefix(list_return=False): def _warn_install_gpu(model_name, ask_installs, qparent=None): - + cellpose_cuda_url = ( - r'https://github.com/mouseland/cellpose#gpu-version-cuda-on-windows-or-linux' - ) - torch_cuda_url = ( - r'https://pytorch.org/get-started/locally/' - ) - direct_ml_url = ( - r'https://microsoft.github.io/DirectML/' + r"https://github.com/mouseland/cellpose#gpu-version-cuda-on-windows-or-linux" ) + torch_cuda_url = r"https://pytorch.org/get-started/locally/" + direct_ml_url = r"https://microsoft.github.io/DirectML/" torch_directml_url = ( - r'https://learn.microsoft.com/en-us/windows/ai/directml/pytorch-windows' - ) - + r"https://learn.microsoft.com/en-us/windows/ai/directml/pytorch-windows" + ) - cellpose_href = f'{html_utils.href_tag("here", cellpose_cuda_url)}' - torch_href = f'{html_utils.href_tag("here", torch_cuda_url)}' - direct_ml_href = f'{html_utils.href_tag("direct_ml_DirectMLref", direct_ml_url)}' - torch_directml_href = f'{html_utils.href_tag("directml pytorch", torch_directml_url)}' + cellpose_href = f"{html_utils.href_tag('here', cellpose_cuda_url)}" + torch_href = f"{html_utils.href_tag('here', torch_cuda_url)}" + direct_ml_href = f"{html_utils.href_tag('direct_ml_DirectMLref', direct_ml_url)}" + torch_directml_href = ( + f"{html_utils.href_tag('directml pytorch', torch_directml_url)}" + ) conda_prefix, pip_prefix = get_pip_conda_prefix() @@ -4208,9 +4390,9 @@ def _warn_install_gpu(model_name, ask_installs, qparent=None): We recomment using CUDA over DirectML, but if you are using a Windows machine with an AMD GPU, you can use DirectML.
    """) - txt_cuda_title = html_utils.paragraph(f"CUDA", font_size='18px') + txt_cuda_title = html_utils.paragraph(f"CUDA", font_size="18px") - pip_prefix = pip_prefix.replace('install -y', 'uninstall') + pip_prefix = pip_prefix.replace("install -y", "uninstall") txt_cuda = html_utils.paragraph(f""" Check out these instructions {cellpose_href}, and {torch_href}.
    First, uninstall the CPU version of PyTorch with the following command: @@ -4220,7 +4402,7 @@ def _warn_install_gpu(model_name, ask_installs, qparent=None): {pip_prefix} torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
    """) - + add_info = html_utils.to_admonition( f""" Pleae use the following table to find the correct link for the command. @@ -4246,61 +4428,64 @@ def _warn_install_gpu(model_name, ask_installs, qparent=None): """, - "info" + "info", ) - - txt_cuda = f'{txt_cuda}{add_info}' - - txt_directML_title = html_utils.paragraph(f"DirectML", font_size='18px') + + txt_cuda = f"{txt_cuda}{add_info}" + + txt_directML_title = html_utils.paragraph(f"DirectML", font_size="18px") txt_directML = html_utils.paragraph(f""" Check out {direct_ml_href}, and {torch_directml_href} for more info.
    Only supported on Windows 10/11 with Python 3.8-3.12.
    Click the Install DirectML button to install DirectML.

    """) - + txt_end = html_utils.paragraph(f""" How do you want to proceed? """) - stopButton = widgets.cancelPushButton('Stop the process') - directMLButton = widgets.okPushButton('Install DirectML') - proceedButton = widgets.okPushButton('Proceed without GPU') + stopButton = widgets.cancelPushButton("Stop the process") + directMLButton = widgets.okPushButton("Install DirectML") + proceedButton = widgets.okPushButton("Proceed without GPU") buttons = [stopButton] - if 'cuda' in ask_installs: - txt = f'{txt}{txt_cuda_title}{txt_cuda}' - if 'directML' in ask_installs: - txt = f'{txt}{txt_directML_title}{txt_directML}' + if "cuda" in ask_installs: + txt = f"{txt}{txt_cuda_title}{txt_cuda}" + if "directML" in ask_installs: + txt = f"{txt}{txt_directML_title}{txt_directML}" buttons.append(directMLButton) - txt = f'{txt}{txt_end}' + txt = f"{txt}{txt_end}" buttons.append(proceedButton) msg.warning( - qparent, 'PyTorch GPU version not installed', txt, + qparent, + "PyTorch GPU version not installed", + txt, buttonsTexts=buttons, ) if msg.cancel: return False, False - + if msg.clickedButton == directMLButton: py_ver = sys.version_info if is_win and py_ver.major == 3 and py_ver.minor < 13: success = check_install_package( - pkg_name = 'torch-directml', - import_pkg_name = 'torch_directml', - pypi_name = 'torch-directml', + pkg_name="torch-directml", + import_pkg_name="torch_directml", + pypi_name="torch-directml", return_outcome=True, ) - purge_module('torch') + purge_module("torch") return success, True else: msg = widgets.myMessageBox() msg.warning( - qparent, 'DirectML not supported', - 'DirectML is only supported on Python 3.8-3.12 and Windows 10/11', + qparent, + "DirectML not supported", + "DirectML is only supported on Python 3.8-3.12 and Windows 10/11", ) return False, False @@ -4310,83 +4495,88 @@ def _warn_install_gpu(model_name, ask_installs, qparent=None): if msg.clickedButton == proceedButton: return True, False + def check_gpu_requested_segm_model(init_kwargs): - gpu = init_kwargs.get('gpu', False) + gpu = init_kwargs.get("gpu", False) if gpu: return True - - device_type = init_kwargs.get('device_type', 'cpu') - return device_type == 'gpu' or device_type == '' + + device_type = init_kwargs.get("device_type", "cpu") + return device_type == "gpu" or device_type == "" + def check_gpu_available( - model_name, use_gpu, - do_not_warn=False, - qparent=None, - cuda=False, - directML=False, - return_available_gpu_type=False - ): + model_name, + use_gpu, + do_not_warn=False, + qparent=None, + cuda=False, + directML=False, + return_available_gpu_type=False, +): if not use_gpu: if return_available_gpu_type: return True, [] else: return True - + ask_for_cuda = False if cuda: try: import torch + if not torch.cuda.is_available(): ask_for_cuda = True if not torch.cuda.device_count() > 0: ask_for_cuda = True except ModuleNotFoundError: ask_for_cuda = True - + ask_for_directML = False if directML: if is_win: try: import torch_directml + if not torch_directml.is_available(): ask_for_directML = True except ModuleNotFoundError: ask_for_directML = True - + frameworks = _available_frameworks(model_name) - ask_installs = set() if not ask_for_cuda else {'cuda'} - ask_installs.update( - {'directML'} if ask_for_directML else set() - ) + ask_installs = set() if not ask_for_cuda else {"cuda"} + ask_installs.update({"directML"} if ask_for_directML else set()) framework_available = False available_frameworks_list = [] for framework, model_compatible in frameworks.items(): if not model_compatible: continue - if framework == 'cuda': + if framework == "cuda": import torch + if not torch.cuda.is_available(): - ask_installs.add('cuda') + ask_installs.add("cuda") elif not torch.cuda.device_count() > 0: - ask_installs.add('cuda') + ask_installs.add("cuda") else: framework_available = True - available_frameworks_list.append('cuda') - elif framework == 'directML': + available_frameworks_list.append("cuda") + elif framework == "directML": if is_win: try: import torch_directml + if not torch_directml.is_available(): - ask_installs.add('directML') + ask_installs.add("directML") else: framework_available = True - available_frameworks_list.append('directML') + available_frameworks_list.append("directML") except ModuleNotFoundError: - ask_installs.add('directML') + ask_installs.add("directML") elif is_mac_arm64: framework_available = True break - + if framework_available and not ask_for_cuda and not ask_for_directML: if return_available_gpu_type: return True, available_frameworks_list @@ -4398,11 +4588,13 @@ def check_gpu_available( return False, available_frameworks_list else: return False - - proceed, directML_installed = _warn_install_gpu(model_name, ask_installs, qparent=qparent) + + proceed, directML_installed = _warn_install_gpu( + model_name, ask_installs, qparent=qparent + ) if return_available_gpu_type: if directML_installed: - available_frameworks_list.append('directML') + available_frameworks_list.append("directML") return proceed, available_frameworks_list else: return proceed @@ -4410,44 +4602,46 @@ def check_gpu_available( def _available_frameworks(model_name): frameworks = { - - "cuda":( - model_name.lower().find('cellpose') != -1 - or model_name.lower().find('omnipose') != -1 - or model_name.lower().find('deepsea') != -1 - or model_name.lower().find('segment_anything') != -1 - or model_name.lower().find('sam2') != -1 - or model_name.lower().find('yeaz') != -1 - or model_name.lower().find('yeaz_v2') != -1 - ), - "directML":( - model_name.lower().find('cellpose_v4') != -1 - or model_name.lower().find('cellpose_v3') != -1# has its own way to check - - ) + "cuda": ( + model_name.lower().find("cellpose") != -1 + or model_name.lower().find("omnipose") != -1 + or model_name.lower().find("deepsea") != -1 + or model_name.lower().find("segment_anything") != -1 + or model_name.lower().find("sam2") != -1 + or model_name.lower().find("yeaz") != -1 + or model_name.lower().find("yeaz_v2") != -1 + ), + "directML": ( + model_name.lower().find("cellpose_v4") != -1 + or model_name.lower().find("cellpose_v3") != -1 # has its own way to check + ), } return frameworks + def find_missing_integers(lst, max_range=None): if max_range is not None: - max_range = lst[-1]+1 + max_range = lst[-1] + 1 return [x for x in range(lst[0], max_range) if x not in lst] -def synthetic_image_geneator(size=(512,512), f_x=1, f_y=1): + +def synthetic_image_geneator(size=(512, 512), f_x=1, f_y=1): Y, X = size x = np.linspace(0, 10, Y) y = np.linspace(0, 10, X) xx, yy = np.meshgrid(x, y) - img = np.sin(f_x*xx)*np.cos(f_y*yy) + img = np.sin(f_x * xx) * np.cos(f_y * yy) return img + def get_show_in_file_manager_text(): if is_mac: - return 'Reveal in Finder' + return "Reveal in Finder" elif is_linux: - return 'Show in File Manager' + return "Show in File Manager" elif is_win: - return 'Show in File Explorer' + return "Show in File Explorer" + def get_slices_local_into_global_arr(bbox_coords, global_shape): slice_global_to_local = [] @@ -4460,146 +4654,146 @@ def get_slices_local_into_global_arr(bbox_coords, global_shape): if _max > _D: _max_crop = _D - _max _max = _D - + slice_global_to_local.append(slice(_min, _max)) slice_crop_local.append(slice(_min_crop, _max_crop)) - + return tuple(slice_global_to_local), tuple(slice_crop_local) + def get_pip_install_cellacdc_version_command(version=None): conda_prefix, pip_prefix = get_pip_conda_prefix() if version is None: version = read_version() - commit_hash_idx = version.find('+g') - is_dev_version = commit_hash_idx > 0 + commit_hash_idx = version.find("+g") + is_dev_version = commit_hash_idx > 0 if is_dev_version: - commit_hash = version[commit_hash_idx+2:].split('.')[0] + commit_hash = version[commit_hash_idx + 2 :].split(".")[0] command = f'{pip_prefix} --upgrade "git+{github_home_url}.git@{commit_hash}"' command_github = None else: - command = f'{pip_prefix} --upgrade cellacdc=={version}' + command = f"{pip_prefix} --upgrade cellacdc=={version}" command_github = f'{pip_prefix} --upgrade "git+{urls.github_url}@{version}"' - return command, command_github + return command, command_github + def get_git_pull_checkout_cellacdc_version_commands(version=None): if version is None: version = read_version() - commit_hash_idx = version.find('+g') - is_dev_version = commit_hash_idx > 0 + commit_hash_idx = version.find("+g") + is_dev_version = commit_hash_idx > 0 if not is_dev_version: return [] - commit_hash = version[commit_hash_idx+2:].split('.')[0] + commit_hash = version[commit_hash_idx + 2 :].split(".")[0] commands = ( f'cd "{os.path.dirname(cellacdc_path)}"', - 'git pull', - f'git checkout {commit_hash}' + "git pull", + f"git checkout {commit_hash}", ) return commands + def check_install_tapir(): check_install_package( - 'tapnet', pypi_name='git+https://github.com/ElpadoCan/TAPIR.git' + "tapnet", pypi_name="git+https://github.com/ElpadoCan/TAPIR.git" ) + def _download_tapir_model(): - urls, file_sizes = _model_url('TAPIR') + urls, file_sizes = _model_url("TAPIR") temp_model_path = tempfile.mkdtemp() - _, final_model_path = ( - get_model_path('TAPIR', create_temp_dir=False) - ) + _, final_model_path = get_model_path("TAPIR", create_temp_dir=False) for url, file_size in zip(urls, file_sizes): - filename = url.split('/')[-1] + filename = url.split("/")[-1] final_dst = os.path.join(final_model_path, filename) - if os.path.exists(final_dst): + if os.path.exists(final_dst): continue temp_dst = os.path.join(temp_model_path, filename) - download_url( - url, temp_dst, file_size=file_size, desc='TAPIR', - verbose=False - ) - + download_url(url, temp_dst, file_size=file_size, desc="TAPIR", verbose=False) + shutil.move(temp_dst, final_dst) + def _download_yeaz_models(): - urls, file_sizes = _model_url('YeaZ_v2') + urls, file_sizes = _model_url("YeaZ_v2") temp_model_path = tempfile.mkdtemp() - _, final_model_path = ( - get_model_path('YeaZ_v2', create_temp_dir=False) - ) + _, final_model_path = get_model_path("YeaZ_v2", create_temp_dir=False) for url, file_size in zip(urls, file_sizes): - filename = url.split('/')[-1] + filename = url.split("/")[-1] final_dst = os.path.join(final_model_path, filename) - if os.path.exists(final_dst): + if os.path.exists(final_dst): continue temp_dst = os.path.join(temp_model_path, filename) - download_url( - url, temp_dst, file_size=file_size, desc='YeaZ_v2', - verbose=False - ) - + download_url(url, temp_dst, file_size=file_size, desc="YeaZ_v2", verbose=False) + shutil.move(temp_dst, final_dst) + def _download_cellpose_germlineNuclei_model(): - urls, file_sizes = _model_url('Cellpose_germlineNuclei') + urls, file_sizes = _model_url("Cellpose_germlineNuclei") temp_model_path = tempfile.mkdtemp() - _, final_model_path = ( - get_model_path('Cellpose_germlineNuclei', create_temp_dir=False) + _, final_model_path = get_model_path( + "Cellpose_germlineNuclei", create_temp_dir=False ) for url, file_size in zip(urls, file_sizes): - filename = url.split('/')[-1] + filename = url.split("/")[-1] final_dst = os.path.join(final_model_path, filename) - if os.path.exists(final_dst): + if os.path.exists(final_dst): continue temp_dst = os.path.join(temp_model_path, filename) download_url( - url, temp_dst, file_size=file_size, desc='Cellpose_germlineNuclei', - verbose=False + url, + temp_dst, + file_size=file_size, + desc="Cellpose_germlineNuclei", + verbose=False, ) - + shutil.move(temp_dst, final_dst) + def _download_omnipose_models(): - urls, file_sizes = _model_url('omnipose') + urls, file_sizes = _model_url("omnipose") temp_model_path = tempfile.mkdtemp() - final_model_path = os.path.expanduser(r'~\.cellpose\models') + final_model_path = os.path.expanduser(r"~\.cellpose\models") for url, file_size in zip(urls, file_sizes): - filename = url.split('/')[-1] + filename = url.split("/")[-1] final_dst = os.path.join(final_model_path, filename) - if os.path.exists(final_dst): + if os.path.exists(final_dst): continue temp_dst = os.path.join(temp_model_path, filename) - download_url( - url, temp_dst, file_size=file_size, desc='omnipose', - verbose=False - ) - + download_url(url, temp_dst, file_size=file_size, desc="omnipose", verbose=False) + shutil.move(temp_dst, final_dst) + def format_cca_manual_changes(changes: dict): - txt = '' + txt = "" for ID, changes_ID in changes.items(): - txt = f'{txt}* ID {ID}:\n' + txt = f"{txt}* ID {ID}:\n" for col, (old_val, new_val) in changes_ID.items(): - txt = f'{txt} - {col}: {old_val} --> {new_val}\n' - txt = f'{txt}--------------------------------\n\n' + txt = f"{txt} - {col}: {old_val} --> {new_val}\n" + txt = f"{txt}--------------------------------\n\n" return txt + def init_prompt_segm_model(acdcPromptSegment, posData, init_kwargs): model = acdcPromptSegment.Model(**init_kwargs) return model - + + def init_segm_model(acdcSegment, posData, init_kwargs): - segm_endname = init_kwargs.pop('segm_endname', 'None') - if segm_endname != 'None': + segm_endname = init_kwargs.pop("segm_endname", "None") + if segm_endname != "None": load_segm = True - if not hasattr(posData, 'segm_data'): + if not hasattr(posData, "segm_data"): load_segm = True - elif posData.segm_npz_path.endswith(f'{segm_endname}.npz'): + elif posData.segm_npz_path.endswith(f"{segm_endname}.npz"): load_segm = False if not load_segm: segm_data = np.squeeze(posData.segm_data) @@ -4608,60 +4802,60 @@ def init_segm_model(acdcSegment, posData, init_kwargs): segm_endname, posData.images_path ) printl(f'Loading segmentation data from "{segm_filepath}"...') - segm_data = np.load(segm_filepath)['arr_0'] + segm_data = np.load(segm_filepath)["arr_0"] else: segm_data = None # Initialize input_points_df for models promptable with points - input_points_filepath = init_kwargs.pop('input_points_path', '') + input_points_filepath = init_kwargs.pop("input_points_path", "") if input_points_filepath: - input_points_df = init_input_points_df( - posData, input_points_filepath - ) - init_kwargs['input_points_df'] = input_points_df - + input_points_df = init_input_points_df(posData, input_points_filepath) + init_kwargs["input_points_df"] = input_points_df + try: # Models introduced before 1.3.2 do not have the segm_data as input kwargs = inspect.getfullargspec(acdcSegment.Model.__init__).args - if 'is_rgb' not in kwargs and 'is_rgb' in init_kwargs: - del init_kwargs['is_rgb'] + if "is_rgb" not in kwargs and "is_rgb" in init_kwargs: + del init_kwargs["is_rgb"] model = acdcSegment.Model(**init_kwargs) - except Exception as e: model = acdcSegment.Model(segm_data, **init_kwargs) - - if hasattr(model, 'init_successful'): + + if hasattr(model, "init_successful"): if not model.init_successful: return None return model + def _parse_bool_str(value): if isinstance(value, bool): return value - - if value == 'True': + + if value == "True": return True - elif value == 'False': + elif value == "False": return False + def check_install_trackastra(): check_install_package( - 'Trackastra', - import_pkg_name='trackastra', - pypi_name='trackastra' + "Trackastra", import_pkg_name="trackastra", pypi_name="trackastra" ) + def get_torch_device(gpu=False): import torch + if torch.cuda.is_available() and gpu: - device = torch.device('cuda') + device = torch.device("cuda") elif torch.backends.mps.is_available(): - device = torch.device('mps') + device = torch.device("mps") else: - device = torch.device('cpu') + device = torch.device("cpu") return device + def parse_model_params(model_argspecs, model_params): parsed_model_params = {} for row, argspec in enumerate(model_argspecs): @@ -4677,29 +4871,31 @@ def parse_model_params(model_argspecs, model_params): parsed_model_params[argspec.name] = value return parsed_model_params + # def init_cellpose_denoise_model(): # from . import apps - + # from cellacdc.models.cellpose_v3._denoise import ( # CellposeDenoiseModel, url_help # ) # init_argspecs, run_argspecs = getClassArgSpecs(CellposeDenoiseModel) # url = url_help() - + # paramsWin = apps.QDialogModelParams( -# init_argspecs, run_argspecs, 'Cellpose 3.0', +# init_argspecs, run_argspecs, 'Cellpose 3.0', # url=url, is_tracker=True, action_type='denoising' # ) # paramsWin.exec_() # if paramsWin.cancel: # return - + # init_params = paramsWin.init_kwargs # run_params = paramsWin.model_kwargs # denoise_model = CellposeDenoiseModel(**init_params) # return denoise_model, init_params, run_params + def init_input_points_df(posData, input_points_filepath): input_points_df = None if os.path.exists(input_points_filepath): @@ -4711,46 +4907,48 @@ def init_input_points_df(posData, input_points_filepath): filepath = os.path.join(posData.images_path, file) input_points_df = pd.read_csv(filepath) break - + if input_points_df is None: raise FileNotFoundError( f'Could not find input points table from file "input_points_filepath" ' - 'Perhaps, you forgot to save the table?' + "Perhaps, you forgot to save the table?" ) - - for col in ('x', 'y', 'id'): + + for col in ("x", "y", "id"): if col not in input_points_df.columns: raise KeyError( - f'Input points table is missing colum {col}. It must have ' - 'the colums (x, y, id)' + f"Input points table is missing colum {col}. It must have " + "the colums (x, y, id)" ) - + return input_points_df + def are_acdc_dfs_equal(df_left, df_right): if df_left.shape != df_right.shape: return False - + try: for col in df_left.columns: if col not in df_right.columns: return False - + try: eq_mask = np.isclose(df_left[col], df_right[col], equal_nan=True) except Exception as err: # Data type is string eq_mask = df_left[col] == df_right[col] - - nan_mask = ((df_left[col].isna()) & (df_right[col].isna())) + + nan_mask = (df_left[col].isna()) & (df_right[col].isna()) equality_mask = (eq_mask) | (nan_mask) if not equality_mask.all(): return False except Exception as err: return False - + return True + def is_pos_folderpath(folderpath): """Determine if a path is a valid Cell-ACDC Position folder @@ -4763,7 +4961,7 @@ def is_pos_folderpath(folderpath): ------- bool True if the path is a valid Cell-ACDC Position folder, False otherwise - + Notes ----- A valid Cell-ACDC Position folder must: @@ -4771,76 +4969,83 @@ def is_pos_folderpath(folderpath): - Be a directory - Contain an 'Images' subdirectory - The 'Images' subdirectory must not be empty - """ + """ foldername = os.path.basename(folderpath) is_valid_pos_folder = ( - re.search(r'^Position_(\d+)$', foldername) is not None + re.search(r"^Position_(\d+)$", foldername) is not None and os.path.isdir(folderpath) - and os.path.exists(os.path.join(folderpath, 'Images')) - and listdir(os.path.join(folderpath, 'Images')) + and os.path.exists(os.path.join(folderpath, "Images")) + and listdir(os.path.join(folderpath, "Images")) ) return is_valid_pos_folder + def log_segm_params( - model_name, init_params, segm_params, logger_func=print, - preproc_recipe=None, apply_post_process=False, - standard_postprocess_kwargs=None, custom_postprocess_features=None - ): + model_name, + init_params, + segm_params, + logger_func=print, + preproc_recipe=None, + apply_post_process=False, + standard_postprocess_kwargs=None, + custom_postprocess_features=None, +): init_params_format = [ - f' * {option} = {value}' for option, value in init_params.items() + f" * {option} = {value}" for option, value in init_params.items() ] - init_params_format = '\n'.join(init_params_format) - + init_params_format = "\n".join(init_params_format) + segm_params_format = [ - f' * {option} = {value}' for option, value in segm_params.items() + f" * {option} = {value}" for option, value in segm_params.items() ] - segm_params_format = '\n'.join(segm_params_format) - + segm_params_format = "\n".join(segm_params_format) + preproc_recipe_format = None if preproc_recipe is not None: preproc_recipe_format = [] for s, step in enumerate(preproc_recipe): - preproc_recipe_format.append(f' * Step {s+1}') - method = step['method'] - preproc_recipe_format.append(f' - Method: {method}') - for option, value in step['kwargs'].items(): - preproc_recipe_format.append(f' - {option}: {value}') - preproc_recipe_format = '\n'.join(preproc_recipe_format) - + preproc_recipe_format.append(f" * Step {s + 1}") + method = step["method"] + preproc_recipe_format.append(f" - Method: {method}") + for option, value in step["kwargs"].items(): + preproc_recipe_format.append(f" - {option}: {value}") + preproc_recipe_format = "\n".join(preproc_recipe_format) + standard_postproc_format = None if apply_post_process and standard_postprocess_kwargs is not None: standard_postproc_format = [ - f' * {option} = {value}' + f" * {option} = {value}" for option, value in standard_postprocess_kwargs.items() ] - standard_postproc_format = '\n'.join(standard_postproc_format) - + standard_postproc_format = "\n".join(standard_postproc_format) + custom_postproc_format = None if apply_post_process and custom_postprocess_features is not None: custom_postproc_format = [ - f' * {feature} = ({low}, {high})' + f" * {feature} = ({low}, {high})" for feature, (low, high) in custom_postprocess_features.items() ] - custom_postproc_format = '\n'.join(custom_postproc_format) - - separator = '-'*100 + custom_postproc_format = "\n".join(custom_postproc_format) + + separator = "-" * 100 params_format = ( - f'{separator}\n' - f'Model name: {model_name}\n\n' - 'Preprocessing recipe:\n\n' - f'{preproc_recipe_format}\n\n' - 'Initialization parameters:\n\n' - f'{init_params_format}\n\n' - 'Segmentation parameters:\n\n' - f'{segm_params_format}\n\n' - 'Post-processing:\n\n' - f'{standard_postproc_format}\n\n' - 'Custom post-processing:\n\n' - f'{custom_postproc_format}\n' - f'{separator}' + f"{separator}\n" + f"Model name: {model_name}\n\n" + "Preprocessing recipe:\n\n" + f"{preproc_recipe_format}\n\n" + "Initialization parameters:\n\n" + f"{init_params_format}\n\n" + "Segmentation parameters:\n\n" + f"{segm_params_format}\n\n" + "Post-processing:\n\n" + f"{standard_postproc_format}\n\n" + "Custom post-processing:\n\n" + f"{custom_postproc_format}\n" + f"{separator}" ) logger_func(params_format) + def pairwise(iterable): # pairwise('ABCDEFG') → AB BC CD DE EF FG iterator = iter(iterable) @@ -4849,122 +5054,123 @@ def pairwise(iterable): yield a, b a = b + def append_text_filename(filename: str, text_to_append: str): filename_noext, ext = os.path.splitext(filename) - filename_out = f'{filename_noext}{text_to_append}{ext}' + filename_out = f"{filename_noext}{text_to_append}{ext}" return filename_out + def validate_images_path(input_path: os.PathLike, create_dirs_tree=False): - is_images_path = input_path.endswith('Images') + is_images_path = input_path.endswith("Images") parent_dir = os.path.dirname(input_path) parent_foldername = os.path.basename(parent_dir) - is_pos_folder = ( - re.search(r'^Position_(\d+)$', parent_foldername) is not None - and os.path.isdir(parent_dir) - ) + is_pos_folder = re.search( + r"^Position_(\d+)$", parent_foldername + ) is not None and os.path.isdir(parent_dir) if not is_pos_folder: existing_pos_foldernames = get_pos_foldernames(input_path) pos_n = len(existing_pos_foldernames) + 1 - pos_folderpath = os.path.join(input_path, f'Position_{pos_n}') - images_path = os.path.join(pos_folderpath, 'Images') + pos_folderpath = os.path.join(input_path, f"Position_{pos_n}") + images_path = os.path.join(pos_folderpath, "Images") elif is_images_path: pos_folderpath = input_path - images_path = os.path.join(pos_folderpath, 'Images') + images_path = os.path.join(pos_folderpath, "Images") else: images_path = input_path - + if create_dirs_tree: os.makedirs(images_path, exist_ok=True) - + return images_path + def fix_acdc_df_dtypes(acdc_df): - acdc_df['is_cell_excluded'] = acdc_df['is_cell_excluded'].astype(bool) + acdc_df["is_cell_excluded"] = acdc_df["is_cell_excluded"].astype(bool) return acdc_df + def _relabel_cca_dfs_and_segm_data( - cca_dfs, - IDs_mapper, - asymm_tracked_segm, - progressbar=True, - ): + cca_dfs, + IDs_mapper, + asymm_tracked_segm, + progressbar=True, +): # Rename Cell_ID index according to asymmetric cell div convention if progressbar: pbar = tqdm( - desc='Applying asymmetric division', - total=len(IDs_mapper), ncols=100 + desc="Applying asymmetric division", total=len(IDs_mapper), ncols=100 ) for key, (root_ID, parent_ID) in IDs_mapper.items(): div_frame_i, daughter_ID = key for frame_i in range(div_frame_i, len(asymm_tracked_segm)): - - lab = asymm_tracked_segm[frame_i] rp = skimage.measure.regionprops(lab) rp_mapper = {obj.label: obj for obj in rp} obj_daught = rp_mapper.get(daughter_ID) mother_ID = root_ID if rp_mapper.get(root_ID) is None else parent_ID - - cca_dfs[frame_i].rename( - index={daughter_ID: mother_ID}, inplace=True - ) - + + cca_dfs[frame_i].rename(index={daughter_ID: mother_ID}, inplace=True) + if obj_daught is None: continue - + lab[obj_daught.slice][obj_daught.image] = mother_ID - + if progressbar: pbar.update() - + if progressbar: pbar.close() - + + def df_ctc_to_acdc_df( - df_ctc, tracked_segm, cell_division_mode='Normal', return_list=False, - progressbar=True - ): + df_ctc, + tracked_segm, + cell_division_mode="Normal", + return_list=False, + progressbar=True, +): """Convert Cell Tracking Challenge DataFrame with annotated division to Cell-ACDC cell cycle annotations DataFrame. Parameters ---------- df_ctc : pd.DataFrame - DataFrame with {'label', 't1', 't2', 'parent'} columns where + DataFrame with {'label', 't1', 't2', 'parent'} columns where 't1' is the frame index of cell division. tracked_segm : (T, Y, X) array of ints Array of tracked segmentation labels. cell_division_mode : {'Normal', 'Asymmetric'}, optional - Type of cell division. `Normal` is the standard cell division, - where the mother cell divides into two daughter cells. For the - tracking, that means the two daughter cells get a new, unique ID - each. - - `Asymmetric` means that the mother cell grows one daughter - cell that eventually divides from the mother (e.g., budding yeast). - For the tracking, this means that the mother cell ID keeps + Type of cell division. `Normal` is the standard cell division, + where the mother cell divides into two daughter cells. For the + tracking, that means the two daughter cells get a new, unique ID + each. + + `Asymmetric` means that the mother cell grows one daughter + cell that eventually divides from the mother (e.g., budding yeast). + For the tracking, this means that the mother cell ID keeps existing after division and the daughter cell gets a new, unique ID. - - If `Asymmetric`, the third returned element is the segmentation data - with the asymmetric Cell IDs. + + If `Asymmetric`, the third returned element is the segmentation data + with the asymmetric Cell IDs. return_list : bool, optional - If `True`, the second returned element is the list of created dataframes, + If `True`, the second returned element is the list of created dataframes, one per frame. Default is False progressbar : bool, optional If `True`, displays a tqdm progressbar. Default is True - """ + """ cca_dfs = [] keys = [] - df_ctc = df_ctc.set_index(['t1', 'parent']) - - if cell_division_mode == 'Asymmetric': + df_ctc = df_ctc.set_index(["t1", "parent"]) + + if cell_division_mode == "Asymmetric": asymm_tracked_segm = tracked_segm.copy() - + asymmetric_IDs_rename_mapper = {} if progressbar: pbar = tqdm( - desc='Converting to Cell-ACDC format', - total=len(tracked_segm), ncols=100 + desc="Converting to Cell-ACDC format", total=len(tracked_segm), ncols=100 ) for frame_i, lab in enumerate(tracked_segm): rp = skimage.measure.regionprops(lab) @@ -4976,12 +5182,12 @@ def df_ctc_to_acdc_df( if progressbar: pbar.update() continue - + # Copy annotations from previous frames - prev_cca_df = cca_dfs[frame_i-1] + prev_cca_df = cca_dfs[frame_i - 1] old_IDs = cca_df.index.intersection(prev_cca_df.index) cca_df.loc[old_IDs] = prev_cca_df.loc[old_IDs] - + try: df_ctc_i = df_ctc.loc[frame_i] except KeyError as err: @@ -4990,43 +5196,43 @@ def df_ctc_to_acdc_df( if progressbar: pbar.update() continue - + for parent_ID, df_ctc_i_pID in df_ctc_i.groupby(level=0): - daughter_IDs = df_ctc_i_pID['label'].to_list() - + daughter_IDs = df_ctc_i_pID["label"].to_list() + if parent_ID == 0: continue - - cca_df.loc[daughter_IDs, 'parent_ID_tree'] = parent_ID - cca_df.loc[daughter_IDs, 'emerg_frame_i'] = frame_i - cca_df.loc[daughter_IDs, 'division_frame_i'] = frame_i - - root_ID = prev_cca_df.at[parent_ID, 'root_ID_tree'] + + cca_df.loc[daughter_IDs, "parent_ID_tree"] = parent_ID + cca_df.loc[daughter_IDs, "emerg_frame_i"] = frame_i + cca_df.loc[daughter_IDs, "division_frame_i"] = frame_i + + root_ID = prev_cca_df.at[parent_ID, "root_ID_tree"] if root_ID == -1: root_ID = parent_ID - cca_df.loc[daughter_IDs, 'root_ID_tree'] = root_ID - - cca_df.loc[daughter_IDs[0], 'sister_ID_tree'] = daughter_IDs[1] - cca_df.loc[daughter_IDs[1], 'sister_ID_tree'] = daughter_IDs[0] - - prev_gen_num = prev_cca_df.loc[parent_ID, 'generation_num_tree'] - cca_df.loc[daughter_IDs, 'generation_num_tree'] = prev_gen_num + 1 - - # Annotate division from df_ctc_i into - if cell_division_mode == 'Asymmetric': + cca_df.loc[daughter_IDs, "root_ID_tree"] = root_ID + + cca_df.loc[daughter_IDs[0], "sister_ID_tree"] = daughter_IDs[1] + cca_df.loc[daughter_IDs[1], "sister_ID_tree"] = daughter_IDs[0] + + prev_gen_num = prev_cca_df.loc[parent_ID, "generation_num_tree"] + cca_df.loc[daughter_IDs, "generation_num_tree"] = prev_gen_num + 1 + + # Annotate division from df_ctc_i into + if cell_division_mode == "Asymmetric": # Recycle the root_ID and assign it to one of the daughters replaced_daught_ID = daughter_IDs[1] key = (frame_i, replaced_daught_ID) - asymmetric_IDs_rename_mapper[key] = (root_ID, parent_ID) - + asymmetric_IDs_rename_mapper[key] = (root_ID, parent_ID) + cca_dfs.append(cca_df) - + if progressbar: pbar.update() - + if progressbar: pbar.close() - + if asymmetric_IDs_rename_mapper: _relabel_cca_dfs_and_segm_data( cca_dfs, @@ -5034,26 +5240,26 @@ def df_ctc_to_acdc_df( asymm_tracked_segm, progressbar=True, ) - - cca_df = pd.concat(cca_dfs, keys=keys, names=['frame_i']) - + + cca_df = pd.concat(cca_dfs, keys=keys, names=["frame_i"]) + out = [cca_df, None, None] - + if return_list: out[1] = cca_dfs - - if cell_division_mode == 'Asymmetric': + + if cell_division_mode == "Asymmetric": out[2] = asymm_tracked_segm - + return out + def check_install_instanseg(): check_install_package( - pkg_name='InstanSeg', - import_pkg_name='instanseg', - pypi_name='instanseg-torch' + pkg_name="InstanSeg", import_pkg_name="instanseg", pypi_name="instanseg-torch" ) + def validate_tracker_input(tracker, segm_video_to_track): try: warning_text = tracker.validate_input(segm_video_to_track) @@ -5062,82 +5268,84 @@ def validate_tracker_input(tracker, segm_video_to_track): printl(traceback.format_exc()) pass return + + def format_IDs(IDs): if isinstance(IDs, str): - raise ValueError('IDs must not be a string') + raise ValueError("IDs must not be a string") IDsRange = [] - text = '' + text = "" sorted_vals = sorted(IDs) for i, e in enumerate(sorted_vals): e = int(e) # Get previous and next value (if possible) if i > 0: - prevVal = sorted_vals[i-1] + prevVal = sorted_vals[i - 1] else: prevVal = -1 - if i < len(sorted_vals)-1: - nextVal = sorted_vals[i+1] + if i < len(sorted_vals) - 1: + nextVal = sorted_vals[i + 1] else: nextVal = -1 - if e-prevVal == 1 or nextVal-e == 1: + if e - prevVal == 1 or nextVal - e == 1: if not IDsRange: - if nextVal-e == 1 and e-prevVal != 1: + if nextVal - e == 1 and e - prevVal != 1: # Current value is the first value of a new range IDsRange = [e] else: # Current value is the second element of a new range IDsRange = [prevVal, e] else: - if e-prevVal == 1: + if e - prevVal == 1: # Current value is part of an ongoing range IDsRange.append(e) else: - # Current value is the first element of a new range - # --> create range text and this element will + # Current value is the first element of a new range + # --> create range text and this element will # be added to the new range at the next iter start, stop = IDsRange[0], IDsRange[-1] - if stop-start > 1: - sep = '-' + if stop - start > 1: + sep = "-" else: - sep = ',' - text = f'{text},{start}{sep}{stop}' + sep = "," + text = f"{text},{start}{sep}{stop}" IDsRange = [] else: # Current value doesn't belong to a range if IDsRange: # There was a range not added to text --> add it now start, stop = IDsRange[0], IDsRange[-1] - if stop-start > 1: - sep = '-' + if stop - start > 1: + sep = "-" else: - sep = ',' - text = f'{text},{start}{sep}{stop}' - - text = f'{text},{e}' + sep = "," + text = f"{text},{start}{sep}{stop}" + + text = f"{text},{e}" IDsRange = [] if IDsRange: # Last range was not added --> add it now start, stop = IDsRange[0], IDsRange[-1] - text = f'{text},{start}-{stop}' + text = f"{text},{start}-{stop}" text = text[1:] return text + def get_empty_stored_data_dict(): return { - 'regionprops': None, - 'labels': None, - 'acdc_df': None, - 'delROIs_info': { - 'rois': [], 'delMasks': [], 'delIDsROI': [], 'state': [] - }, - 'IDs': [], - 'manually_edited_lab': {'lab': {}, 'zoom_slice': None} - } + "regionprops": None, + "labels": None, + "acdc_df": None, + "delROIs_info": {"rois": [], "delMasks": [], "delIDsROI": [], "state": []}, + "IDs": [], + "manually_edited_lab": {"lab": {}, "zoom_slice": None}, + } + def iterate_along_axes(arr, axes, arr_ndim=None): if arr_ndim is None: @@ -5145,19 +5353,20 @@ def iterate_along_axes(arr, axes, arr_ndim=None): axes = list(axes) front_axes = axes + [i for i in range(arr_ndim) if i not in axes] arr_moved = np.moveaxis(arr, front_axes, range(arr_ndim)) - iter_shape = arr_moved.shape[:len(axes)] + iter_shape = arr_moved.shape[: len(axes)] for idx in np.ndindex(iter_shape): # Build the index for the original array full_idx = [slice(None)] * arr_ndim for axis, i in zip(axes, idx): full_idx[axis] = i yield tuple(full_idx) - + + def get_input_output_mapper( - input_shape: Tuple[int], - iterate_axes: Tuple[int], - output_shape: Tuple[int], - output_axes: Tuple[int], + input_shape: Tuple[int], + iterate_axes: Tuple[int], + output_shape: Tuple[int], + output_axes: Tuple[int], ) -> List[Tuple[Tuple[int, ...], Tuple[int, ...]]]: """Creates list of tuples with the input and output indices @@ -5197,19 +5406,21 @@ def get_input_output_mapper( return mapper + def translateStrNone(*args): args = list(args) for i, arg in enumerate(args): if isinstance(arg, str): - if arg.lower() == 'none': + if arg.lower() == "none": args[i] = None - elif arg.lower() == 'true': + elif arg.lower() == "true": args[i] = True - elif arg.lower() == 'false': + elif arg.lower() == "false": args[i] = False - + return args + def get_pytorch_command(): """Get the command to install pytorch CPU or CUDA @@ -5217,121 +5428,131 @@ def get_pytorch_command(): ------- dict Dictionary mapping OS to commands for installing PyTorch - + Notes ----- - As of Oct 2024, the `pytorch` channel on Anaconda was deprecated. + As of Oct 2024, the `pytorch` channel on Anaconda was deprecated. See here https://github.com/pytorch/pytorch/issues/138506 """ conda_prefix, pip_prefix = get_pip_conda_prefix() pytorch_commands = { - 'Windows': { + "Windows": { # 'Conda': { # 'CPU': f'{conda_prefix} pytorch torchvision cpuonly -c conda-forge', # 'CUDA 11.8 (NVIDIA GPU)': f'{conda_prefix} pytorch torchvision pytorch-cuda=11.8 -c conda-forge -c nvidia', # 'CUDA 12.1 (NVIDIA GPU)': f'{conda_prefix} pytorch torchvision pytorch-cuda=12.1 -c conda-forge -c nvidia' # }, - 'Pip': { - 'CPU': f'{pip_prefix} torch torchvision', - 'CUDA 11.8 (NVIDIA GPU)': f'{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cu118', - 'CUDA 12.1 (NVIDIA GPU)': f'{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cu121' + "Pip": { + "CPU": f"{pip_prefix} torch torchvision", + "CUDA 11.8 (NVIDIA GPU)": f"{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cu118", + "CUDA 12.1 (NVIDIA GPU)": f"{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cu121", } }, - 'Mac': { + "Mac": { # 'Conda': { # 'CPU': f'{conda_prefix} pytorch torchvision cpuonly -c conda-forge', # 'CUDA 11.8 (NVIDIA GPU)': '[WARNING]: CUDA is not available on MacOS', # 'CUDA 12.1 (NVIDIA GPU)': '[WARNING]: CUDA is not available on MacOS' # }, - 'Pip': { - 'CPU': f'{pip_prefix} torch torchvision', - 'CUDA 11.8 (NVIDIA GPU)': '[WARNING]: CUDA is not available on MacOS', - 'CUDA 12.1 (NVIDIA GPU)': '[WARNING]: CUDA is not available on MacOS' + "Pip": { + "CPU": f"{pip_prefix} torch torchvision", + "CUDA 11.8 (NVIDIA GPU)": "[WARNING]: CUDA is not available on MacOS", + "CUDA 12.1 (NVIDIA GPU)": "[WARNING]: CUDA is not available on MacOS", } }, - 'Linux': { + "Linux": { # 'Conda': { # 'CPU': f'{conda_prefix} pytorch torchvision cpuonly -c conda-forge', # 'CUDA 11.8 (NVIDIA GPU)': f'{conda_prefix} pytorch torchvision pytorch-cuda=11.8 -c conda-forge -c nvidia', # 'CUDA 12.1 (NVIDIA GPU)': f'{conda_prefix} pytorch torchvision pytorch-cuda=12.1 -c conda-forge -c nvidia' # }, - 'Pip': { - 'CPU': f'{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cpu', - 'CUDA 11.8 (NVIDIA GPU)': f'{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cu118', - 'CUDA 12.1 (NVIDIA GPU)': f'{pip_prefix} torch torchvision' + "Pip": { + "CPU": f"{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cpu", + "CUDA 11.8 (NVIDIA GPU)": f"{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cu118", + "CUDA 12.1 (NVIDIA GPU)": f"{pip_prefix} torch torchvision", } - } + }, } return pytorch_commands + def get_package_info(package_name): try: - result = subprocess.run([ - sys.executable, '-m', 'pip', 'show', package_name - ], capture_output=True, text=True, check=True) - + result = subprocess.run( + [sys.executable, "-m", "pip", "show", package_name], + capture_output=True, + text=True, + check=True, + ) + info = {} - for line in result.stdout.split('\n'): - if ':' in line: - key, value = line.split(':', 1) + for line in result.stdout.split("\n"): + if ":" in line: + key, value = line.split(":", 1) info[key.strip()] = value.strip() - + # Check if it's editable by looking at the location - location = info.get('Location', '') - editable_location = info.get('Editable project location', '') - + location = info.get("Location", "") + editable_location = info.get("Editable project location", "") + return { - 'installed': True, - 'editable': bool(editable_location), - 'location': location, - 'editable_location': editable_location + "installed": True, + "editable": bool(editable_location), + "location": location, + "editable_location": editable_location, } - + except subprocess.CalledProcessError: - return {'installed': False, 'editable': False} + return {"installed": False, "editable": False} + # Usage def update_package(parent, package_name): package_info = get_package_info(package_name) - if not package_info['installed']: + if not package_info["installed"]: printl(f"Package {package_name} is not installed.") return False - editable = package_info.get('editable', False) + editable = package_info.get("editable", False) if editable: return update_editable_package(parent, package_name, package_info) else: return update_not_editable_package(package_name, package_info) + def update_editable_package(parent, package_name, package_info): - repo_location = package_info.get('editable_location', '') - + repo_location = package_info.get("editable_location", "") + if not repo_location or not os.path.exists(repo_location): print(f"Repository location not found for {package_name}") return False return _update_repo_with_git_command(package_name, repo_location) + def _update_repo_with_git_command(package_name, repo_location): """Update repository using git command""" try: - print(f"Updating {package_name} repository at {repo_location} using git command...") - + print( + f"Updating {package_name} repository at {repo_location} using git command..." + ) + # Change to repository directory original_cwd = os.getcwd() os.chdir(repo_location) - + stashed_changes = False # check if there is a portable git from .config import parser_args + try: cp = parser_args if cp["install_details"] is not None: no_cli_install = True install_details = cp["install_details"] - target_dir = install_details.get('target_dir', '') + target_dir = install_details.get("target_dir", "") target_dir = target_dir.strip().strip('"').strip("'") target_dir = os.path.abspath(target_dir) else: @@ -5341,110 +5562,119 @@ def _update_repo_with_git_command(package_name, repo_location): pass if is_win and no_cli_install: - git_loc = os.path.join(target_dir, - "portable_git", - "cmd", - "git.exe") + git_loc = os.path.join(target_dir, "portable_git", "cmd", "git.exe") if not os.path.exists(git_loc): print(f"Portable git not found at {git_loc}. Using system git.") - git_loc = 'git' + git_loc = "git" else: - git_loc = 'git' - + git_loc = "git" + # Check if git is available if not shutil.which(git_loc): - print(f"Git command not found. Please install git to update {package_name}.") + print( + f"Git command not found. Please install git to update {package_name}." + ) return False - + try: # Check for uncommitted changes - branch_result = subprocess.run([git_loc, 'branch', '--show-current'], - capture_output=True, text=True, check=True) + branch_result = subprocess.run( + [git_loc, "branch", "--show-current"], + capture_output=True, + text=True, + check=True, + ) current_branch = branch_result.stdout.strip() print(f"Current branch: {current_branch}") - result = subprocess.run([git_loc, 'status', '--porcelain'], - capture_output=True, text=True, check=True) + result = subprocess.run( + [git_loc, "status", "--porcelain"], + capture_output=True, + text=True, + check=True, + ) if result.stdout.strip(): print(f"Repository {package_name} has uncommitted changes") print("Stashing changes before update...") - subprocess.run([git_loc, 'stash'], check=True) + subprocess.run([git_loc, "stash"], check=True) stashed_changes = True - + # Pull changes - subprocess.run([git_loc, 'pull'], check=True) + subprocess.run([git_loc, "pull"], check=True) print(f"Successfully updated {package_name}") - + # Pop stashed changes if any were stashed if stashed_changes: try: - subprocess.run([git_loc, 'stash', 'pop'], check=True) + subprocess.run([git_loc, "stash", "pop"], check=True) print("Restored stashed changes") except subprocess.CalledProcessError as pop_error: print(f"Warning: Could not restore stashed changes: {pop_error}") - + return True - + except subprocess.CalledProcessError as e: print(f"Git command failed for {package_name}: {e}") return False finally: os.chdir(original_cwd) - + except Exception as e: print(f"Error updating {package_name} with git command: {e}") return False + def update_not_editable_package(package_name, package_info): """Update a non-editable package using pip""" try: _, pip_list = get_pip_conda_prefix(list_return=True) command = pip_list + ["--upgrade ", package_name] - + print(f"Updating {package_name} using pip...") result = subprocess.run(command, shell=True, capture_output=True, text=True) - + if result.returncode == 0: print(f"Successfully updated {package_name}") return True else: print(f"Failed to update {package_name}: {result.stderr}") return False - + except Exception as e: print(f"Error updating {package_name}: {e}") return False + def try_kwargs(func, *args, **kwargs): """ Attempt to call a function with the provided arguments and keyword arguments. - - If the function raises a TypeError due to unexpected keyword arguments, - those arguments are dynamically removed, and the function is retried. - This process continues until the function succeeds or no keyword arguments + + If the function raises a TypeError due to unexpected keyword arguments, + those arguments are dynamically removed, and the function is retried. + This process continues until the function succeeds or no keyword arguments remain, in which case the exception is re-raised. - + Args: func (Callable): The function to call. *args: Positional arguments to pass to the function. **kwargs: Keyword arguments to pass to the function. - + Returns: Tuple[Any, List[str]]: A tuple containing: - The result of the function call (or None if it fails). - A list of keyword arguments that were removed. - + Raises: - ValueError: If a keyword argument mentioned in the error message + ValueError: If a keyword argument mentioned in the error message is not found in the provided kwargs. - TypeError: If the function fails with a TypeError after all keyword + TypeError: If the function fails with a TypeError after all keyword arguments have been removed. """ - + kwargs = kwargs.copy() # Create a copy to avoid modifying the original removed_kwargs = [] - pattern = r"unexpected keyword argument ['\"](\w+)['\"]" + pattern = r"unexpected keyword argument ['\"](\w+)['\"]" while True: try: return func(*args, **kwargs), removed_kwargs @@ -5461,11 +5691,12 @@ def try_kwargs(func, *args, **kwargs): ) else: raise e - + if len(kwargs) == 0: print(f"Function {func.__name__} failed with TypeError: {e}") raise e - + + def get_obj_by_label(rp, target_label): """ Returns the object with the specified label from the given list of objects. @@ -5487,6 +5718,7 @@ def get_obj_by_label(rp, target_label): return obj return None + def find_distances_ID(rps, point=None, ID=None): """ Calculate the distances between a given point and the centroids of a list of regionprops. @@ -5519,21 +5751,24 @@ def find_distances_ID(rps, point=None, ID=None): try: point = [rp.centroid for rp in rps if rp.label == ID][0] except IndexError: - raise ValueError(f'ID {ID} not found in regionprops (list of cells).') + raise ValueError(f"ID {ID} not found in regionprops (list of cells).") elif ID is None and point is None: - raise ValueError('Either ID or point must be provided.') + raise ValueError("Either ID or point must be provided.") elif ID is not None and point is not None: - raise ValueError('Only one of ID or point must be provided.') - - point = point[::-1] # rp are in (y, x) format (or (z, y, x) for 3D data) so I need to reverse order + raise ValueError("Only one of ID or point must be provided.") + + point = point[ + ::-1 + ] # rp are in (y, x) format (or (z, y, x) for 3D data) so I need to reverse order point = np.array([point]) centroids = np.array([rp.centroid for rp in rps]) diff = point[:, np.newaxis] - centroids dist_matrix = np.linalg.norm(diff, axis=2) return dist_matrix + def sort_IDs_dist(rps, point=None, ID=None): """Sorts the IDs of regionprops based on their distances to a given point. @@ -5542,11 +5777,11 @@ def sort_IDs_dist(rps, point=None, ID=None): rps : list A list of regionprops objects representing cells. point : tuple, optional - The coordinates of the point to calculate distances from. + The coordinates of the point to calculate distances from. If not provided, it will be calculated based on the given ID. ID : int, optional - The ID of the regionprops object to calculate distances from. - If this and point are both provided, or neither, an error will be + The ID of the regionprops object to calculate distances from. + If this and point are both provided, or neither, an error will be raised. Returns @@ -5568,30 +5803,30 @@ def sort_IDs_dist(rps, point=None, ID=None): try: point = [rp.centroid for rp in rps if rp.label == ID][0] except IndexError: - raise ValueError(f'ID {ID} not found in regionprops (list of cells).') + raise ValueError(f"ID {ID} not found in regionprops (list of cells).") elif ID is None and point is None: - raise ValueError('Either ID or point must be provided.') + raise ValueError("Either ID or point must be provided.") elif ID is not None and point is not None: - raise ValueError('Only one of ID or point must be provided.') - + raise ValueError("Only one of ID or point must be provided.") IDs = [rp.label for rp in rps] if len(IDs) == 0: return [] elif len(IDs) == 1: return IDs - dist_matrix = find_distances_ID(rps, point=point) + dist_matrix = find_distances_ID(rps, point=point) dist_matrix = np.squeeze(dist_matrix) sorted_ids = sorted(zip(dist_matrix, IDs)) sorted_ids = [ID for _, ID in sorted_ids] return sorted_ids + def safe_get_or_call(obj, path: str): """Safely get nested attributes or call methods with literal args from a string path.""" - expr = ast.parse(path, mode='eval').body + expr = ast.parse(path, mode="eval").body def _eval(node, current_obj): if isinstance(node, ast.Attribute): @@ -5609,18 +5844,21 @@ def _eval(node, current_obj): return _eval(expr, obj) + def format_commit_date_utc(utc_str): # Parse the UTC date string (ISO 8601 format) dt = datetime.datetime.fromisoformat(utc_str.replace("Z", "+00:00")) - + # Convert to your local time zone (optional) local_dt = dt.astimezone() # removes UTC offset if local - + # Format nicely return local_dt.strftime(r"%A %d %B %Y at %H:%M") + def get_linux_distribution_name(): import csv + RELEASE_DATA = {} with open("/etc/os-release") as f: reader = csv.reader(f, delimiter="=") @@ -5635,72 +5873,73 @@ def get_linux_distribution_name(): if version_split[0] == major_version: # Just major version shown, replace it with the full version RELEASE_DATA["VERSION"] = " ".join([DEBIAN_VERSION] + version_split[1:]) - - name_version = f'{RELEASE_DATA["NAME"]} {RELEASE_DATA["VERSION"]}' - + + name_version = f"{RELEASE_DATA['NAME']} {RELEASE_DATA['VERSION']}" + return name_version + def reset_settings(): question = ( - 'Do you want to reset Cell-ACDC settings' - '- type "h" for help - (y/[n]/h)? ' + 'Do you want to reset Cell-ACDC settings- type "h" for help - (y/[n]/h)? ' ) info_txt = ( - 'If you reset Cell-ACDC settings, the folder below will be deleted.\n\n' - 'This means deeleting things like custom shortcuts, recent paths, last ' - 'selections, and GUI preferences.\n\n' + "If you reset Cell-ACDC settings, the folder below will be deleted.\n\n" + "This means deeleting things like custom shortcuts, recent paths, last " + "selections, and GUI preferences.\n\n" f'Settings folder path: "{settings_folderpath}"' ) - answer = 'y' + answer = "y" while True: - try: - answer = input(f'\n{question}') + try: + answer = input(f"\n{question}") except Exception as err: break - - if answer == 'n': - print('*'*100) - return 'Resetting Cell-ACDC settings cancelled.' - - if answer == 'y': + + if answer == "n": + print("*" * 100) + return "Resetting Cell-ACDC settings cancelled." + + if answer == "y": break - - if answer == 'h': - print('-'*100) - print(f'\n{info_txt}') - print('='*100) - + + if answer == "h": + print("-" * 100) + print(f"\n{info_txt}") + print("=" * 100) + print( f'"{answer}" is not a valid answer. ' 'Type "y" for "yes", "n" for "no", or "h" for help.' ) - + try: os.remove(settings_folderpath) - print('*'*100) + print("*" * 100) out_txt = ( - 'Cell-ACDC settings have been reset.\n\n' - 'The following folder was deleted:\n\n' - f'{settings_folderpath}' + "Cell-ACDC settings have been reset.\n\n" + "The following folder was deleted:\n\n" + f"{settings_folderpath}" ) except Exception as err: traceback.print_exc() - print('*'*100) + print("*" * 100) out_txt = ( - '**ERROR** occured when trying to remove the settings folder.\n\n' - 'To reset Cell-ACDC settings, please remove this folder:\n\n' - f'{settings_folderpath}\n' + "**ERROR** occured when trying to remove the settings folder.\n\n" + "To reset Cell-ACDC settings, please remove this folder:\n\n" + f"{settings_folderpath}\n" ) return out_txt - + + def separate_fluo_segment_channels(channels): segms_to_load = [] channels_to_load = [] current_segm = False for ch in channels: - if ch == 'current segm.': + if ch == "current segm.": current_segm = True - elif 'segm' in ch: + elif "segm" in ch: segms_to_load.append(ch) else: channels_to_load.append(ch) diff --git a/cellacdc/napari_utils/arboretum.py b/cellacdc/napari_utils/arboretum.py index e23de9edf..0780bf0aa 100644 --- a/cellacdc/napari_utils/arboretum.py +++ b/cellacdc/napari_utils/arboretum.py @@ -8,15 +8,12 @@ from qtpy.QtCore import QTimer, Signal + class NapariArboretumDialog(base.MainThreadSinglePosUtilBase): - def __init__( - self, posPath, app, title: str, infoText: str, parent=None - ): - + def __init__(self, posPath, app, title: str, infoText: str, parent=None): + module = myutils.get_module_name(__file__) - super().__init__( - app, title, module, infoText, parent - ) + super().__init__(app, title, module, infoText, parent) self.sigClose.connect(self.close) @@ -25,95 +22,98 @@ def __init__( @exception_handler def launchNapariArboretum(self, posPath): - images_path = os.path.join(posPath, 'Images') + images_path = os.path.join(posPath, "Images") ls = myutils.listdir(images_path) image_files = [ - file for file in ls - if file.endswith('.tif') - or file.endswith('aligned.npz') - or file.endswith('.h5') + file + for file in ls + if file.endswith(".tif") + or file.endswith("aligned.npz") + or file.endswith(".h5") ] selectImageFile = widgets.QDialogListbox( - 'Select image file', - 'Select which image file to load\n', - image_files, multiSelection=False, parent=self + "Select image file", + "Select which image file to load\n", + image_files, + multiSelection=False, + parent=self, ) selectImageFile.exec_() if selectImageFile.cancel: - self.logger.info('napari-arboretum utility aborted.') + self.logger.info("napari-arboretum utility aborted.") return imageFile = selectImageFile.selectedItemsText[0] - self.logger.info(f'Loading image file {imageFile}...') - + self.logger.info(f"Loading image file {imageFile}...") + imagePath = os.path.join(images_path, imageFile) - posData = load.loadData(imagePath, '') + posData = load.loadData(imagePath, "") posData.getBasenameAndChNames() posData.loadImgData() segm_files = load.get_segm_files(posData.images_path) - existingEndnames = load.get_endnames( - posData.basename, segm_files - ) + existingEndnames = load.get_endnames(posData.basename, segm_files) if len(existingEndnames) > 1: win = apps.SelectSegmFileDialog( - existingEndnames, images_path, parent=self, - basename=posData.basename + existingEndnames, images_path, parent=self, basename=posData.basename ) win.exec_() if win.cancel: - self.logger.info('napari-arboretum utility aborted.') + self.logger.info("napari-arboretum utility aborted.") return selectedSegmEndName = win.selectedItemText else: selectedSegmEndName = existingEndnames[0] - self.logger.info(f'Loading segmentation file ending with {selectedSegmEndName}...') + self.logger.info( + f"Loading segmentation file ending with {selectedSegmEndName}..." + ) posData.loadOtherFiles( load_segm_data=True, load_acdc_df=True, - end_filename_segm=selectedSegmEndName + end_filename_segm=selectedSegmEndName, ) - self.logger.info('Importing napari...') + self.logger.info("Importing napari...") import napari - self.logger.info('Building arboretum lineage tree...') + self.logger.info("Building arboretum lineage tree...") acdc_df = posData.acdc_df.reset_index() tree = core.LineageTree(acdc_df, logging_func=self.logger.info) tracks_data, graph, properties = tree.to_arboretum() props = natsorted(acdc_df.columns.to_list()) selectProps = widgets.QDialogListbox( - 'Select measurements', - 'Select measurements to add as properties in napari viewer

    ' - 'Ctrl+Click to select multiple items
    ' - 'Shift+Click to select a range of items
    ', - props, multiSelection=True, parent=self + "Select measurements", + "Select measurements to add as properties in napari viewer

    " + "Ctrl+Click to select multiple items
    " + "Shift+Click to select a range of items
    ", + props, + multiSelection=True, + parent=self, ) selectProps.exec_() if selectProps.cancel: - self.logger.info('napari-arboretum utility aborted.') + self.logger.info("napari-arboretum utility aborted.") return - + for col in selectProps.selectedItemsText: try: properties[col] = acdc_df[col] except Exception as e: pass - self.logger.info('Launching napari viewer...') + self.logger.info("Launching napari viewer...") viewer = napari.Viewer() viewer.add_image(posData.img_data, name=imageFile) viewer.add_labels(posData.segm_data, name=selectedSegmEndName) - acdc_df_endname = selectedSegmEndName.replace('segm', 'acdc_tracks') + acdc_df_endname = selectedSegmEndName.replace("segm", "acdc_tracks") viewer.add_tracks( - tracks_data, graph=graph, name=acdc_df_endname, - properties=properties + tracks_data, graph=graph, name=acdc_df_endname, properties=properties ) viewer.window.add_plugin_dock_widget( plugin_name="napari-arboretum", widget_name="Arboretum" @@ -121,7 +121,5 @@ def launchNapariArboretum(self, posPath): napari.run(max_loop_level=2) - self.logger.info('napari viewer closed.') + self.logger.info("napari viewer closed.") self.close() - - diff --git a/cellacdc/path.py b/cellacdc/path.py index beca21849..3bf3e98bb 100644 --- a/cellacdc/path.py +++ b/cellacdc/path.py @@ -11,74 +11,78 @@ from . import printl from . import myutils + def listdir(path): - return natsorted([ - f for f in os.listdir(path) - if not f.startswith('.') - and not f == 'desktop.ini' - and not f == 'recovery' - ]) - -def newfilepath(file_path, appended_text: str=None): + return natsorted( + [ + f + for f in os.listdir(path) + if not f.startswith(".") and not f == "desktop.ini" and not f == "recovery" + ] + ) + + +def newfilepath(file_path, appended_text: str = None): if appended_text is None: - appended_text='' - + appended_text = "" + if not os.path.exists(file_path): return file_path, appended_text - + folder_path = os.path.dirname(file_path) filename = os.path.basename(file_path) filename, ext = os.path.splitext(filename) if appended_text: - if appended_text.startswith('_'): - appended_text = appended_text.lstrip('_') + if appended_text.startswith("_"): + appended_text = appended_text.lstrip("_") if appended_text: - new_filename = f'{filename}_{appended_text}{ext}' + new_filename = f"{filename}_{appended_text}{ext}" new_filepath = os.path.join(folder_path, new_filename) if not os.path.exists(new_filepath): return new_filepath, appended_text - + i = 0 while True: if appended_text: - new_filename = f'{filename}_{appended_text}_{i+1}{ext}' + new_filename = f"{filename}_{appended_text}_{i + 1}{ext}" else: - new_filename = f'{filename}_{i+1}{ext}' + new_filename = f"{filename}_{i + 1}{ext}" new_filepath = os.path.join(folder_path, new_filename) if not os.path.exists(new_filepath): - return new_filepath, f'{appended_text}_{i+1}' + return new_filepath, f"{appended_text}_{i + 1}" i += 1 + def show_in_file_manager(path): if is_mac: - args = ['open', fr'{path}'] + args = ["open", rf"{path}"] elif is_linux: - args = ['xdg-open', fr'{path}'] + args = ["xdg-open", rf"{path}"] else: if os.path.isfile(path): - args = ['explorer', '/select,', os.path.realpath(path)] + args = ["explorer", "/select,", os.path.realpath(path)] else: - args = ['explorer', os.path.realpath(path)] + args = ["explorer", os.path.realpath(path)] subprocess.run(args) + def copy_or_move_tree( - src: os.PathLike, dst: os.PathLike, copy=False, - sigInitPbar=None, sigUpdatePbar=None - ): + src: os.PathLike, dst: os.PathLike, copy=False, sigInitPbar=None, sigUpdatePbar=None +): if sigInitPbar is not None: sigInitPbar.emit(0) - + files_failed_move = {} files_info = {} for root, dirs, files in os.walk(src): for file in files: - rel_path = os.path.relpath(root, src).replace('\\', '/') + rel_path = os.path.relpath(root, src).replace("\\", "/") src_filepath = os.path.join(root, file) - dst_filepath = os.path.join(dst, *rel_path.split('/'), file) + dst_filepath = os.path.join(dst, *rel_path.split("/"), file) files_info[src_filepath] = dst_filepath - + if sigInitPbar is not None: sigInitPbar.emit(len(files_info)) for src_filepath, dst_filepath in files_info.items(): @@ -95,25 +99,27 @@ def copy_or_move_tree( sigUpdatePbar.emit(1) return files_failed_move + def get_posfolderpaths_walk(folderpath): pos_folderpaths = defaultdict(set) for root, dirs, files in os.walk(folderpath): - if not root.endswith('Images'): + if not root.endswith("Images"): continue - + pos_folderpath = os.path.dirname(root) if not myutils.is_pos_folderpath(pos_folderpath): continue - - exp_path = os.path.dirname(pos_folderpath).replace('\\', '/') + + exp_path = os.path.dirname(pos_folderpath).replace("\\", "/") pos_foldername = os.path.basename(pos_folderpath) pos_folderpaths[exp_path].add(pos_foldername) - + for exp_path in pos_folderpaths.keys(): pos_folderpaths[exp_path] = natsorted(pos_folderpaths[exp_path]) - + return pos_folderpaths + def get_exp_path_pos_foldernames_mapper(paths): mapper = defaultdict(lambda: defaultdict(list)) @@ -125,7 +131,7 @@ def get_exp_path_pos_foldernames_mapper(paths): folder_type = myutils.determine_folder_type(path) is_pos_folder, is_images_folder, _ = folder_type - + if filename is not None and not is_images_folder: continue @@ -139,16 +145,15 @@ def get_exp_path_pos_foldernames_mapper(paths): else: path_mapper = get_posfolderpaths_walk(path) for exp_path, pos_foldernames in path_mapper.items(): - mapper[exp_path]['pos_foldernames'].extend(pos_foldernames) + mapper[exp_path]["pos_foldernames"].extend(pos_foldernames) continue - + exp_path = os.path.dirname(pos_folderpath) pos_foldername = os.path.basename(pos_folderpath) - key = exp_path.replace('\\', '/') - mapper[key]['pos_foldernames'].append(pos_foldername) + key = exp_path.replace("\\", "/") + mapper[key]["pos_foldernames"].append(pos_foldername) if filename is not None: - mapper[key]['filenames'].append(filename) - - return mapper + mapper[key]["filenames"].append(filename) + return mapper diff --git a/cellacdc/plot.py b/cellacdc/plot.py index 3d33b3c23..874579136 100644 --- a/cellacdc/plot.py +++ b/cellacdc/plot.py @@ -27,44 +27,45 @@ from . import _core, error_below, error_close from . import _run, core, myutils + def matplotlib_cmap_to_lut( - cmap: Union[Iterable, matplotlib.colors.Colormap, str], - n_colors: int=256 - ): + cmap: Union[Iterable, matplotlib.colors.Colormap, str], n_colors: int = 256 +): if isinstance(cmap, str): cmap = plt.get_cmap(cmap) - - rgbs = [cmap(i) for i in np.linspace(0,1,n_colors)] - lut = (np.array(rgbs)*255).astype(np.uint8) + + rgbs = [cmap(i) for i in np.linspace(0, 1, n_colors)] + lut = (np.array(rgbs) * 255).astype(np.uint8) return lut + def imshow( - *images: Union[np.ndarray, dict], - labels_overlays: np.ndarray | List[np.ndarray]=None, - labels_overlays_luts: np.ndarray | List[np.ndarray]=None, - points_coords: np.ndarray=None, - points_coords_df: pd.DataFrame | List[pd.DataFrame]=None, - points_groups: List[str]=None, - points_data: Union[np.ndarray, pd.DataFrame, pd.Series]=None, - hide_axes: bool=True, - lut: Union[Iterable, matplotlib.colors.Colormap, str]=None, - autoLevels: bool=True, - autoLevelsOnScroll: bool=False, - block: bool=True, - showMaximised=False, - max_ncols=4, - axis_titles: Union[Iterable, None]=None, - parent=None, - window_title='Cell-ACDC image viewer', - figure_title='', - color_scheme=None, - link_scrollbars=True, - annotate_labels_idxs: List[int]=None, - show_duplicated_cursor=True, - selectable_images=False, - infer_rgb=True, - print_call_stack: bool=False - ): + *images: Union[np.ndarray, dict], + labels_overlays: np.ndarray | List[np.ndarray] = None, + labels_overlays_luts: np.ndarray | List[np.ndarray] = None, + points_coords: np.ndarray = None, + points_coords_df: pd.DataFrame | List[pd.DataFrame] = None, + points_groups: List[str] = None, + points_data: Union[np.ndarray, pd.DataFrame, pd.Series] = None, + hide_axes: bool = True, + lut: Union[Iterable, matplotlib.colors.Colormap, str] = None, + autoLevels: bool = True, + autoLevelsOnScroll: bool = False, + block: bool = True, + showMaximised=False, + max_ncols=4, + axis_titles: Union[Iterable, None] = None, + parent=None, + window_title="Cell-ACDC image viewer", + figure_title="", + color_scheme=None, + link_scrollbars=True, + annotate_labels_idxs: List[int] = None, + show_duplicated_cursor=True, + selectable_images=False, + infer_rgb=True, + print_call_stack: bool = False, +): if print_call_stack: myutils.print_call_stack() @@ -77,26 +78,27 @@ def imshow( axis_titles.append(title) if color_scheme is None: from ._palettes import get_color_scheme + color_scheme = get_color_scheme() - + if lut is None: - lut = matplotlib_cmap_to_lut('viridis') + lut = matplotlib_cmap_to_lut("viridis") if isinstance(lut, str): lut = matplotlib_cmap_to_lut(lut) if isinstance(lut, np.ndarray): - luts = [lut]*len(images) + luts = [lut] * len(images) else: luts = lut - + if luts is not None: for l in range(len(luts)): if not isinstance(luts[l], str): continue - + luts[l] = matplotlib_cmap_to_lut(luts[l]) - + casted_images = [] for image in images: if image.dtype == bool: @@ -105,7 +107,7 @@ def imshow( app = _run._setup_app() win = widgets.ImShow( - parent=parent, + parent=parent, link_scrollbars=link_scrollbars, infer_rgb=infer_rgb, figure_title=figure_title, @@ -117,23 +119,23 @@ def imshow( win.setupMainLayout() win.setupStatusBar() win.setupGraphicLayout( - *casted_images, - hide_axes=hide_axes, + *casted_images, + hide_axes=hide_axes, max_ncols=max_ncols, - color_scheme=color_scheme + color_scheme=color_scheme, ) if axis_titles is not None: win.setupTitles(*axis_titles) win.showImages( - *casted_images, + *casted_images, labels_overlays=labels_overlays, labels_overlays_luts=labels_overlays_luts, - luts=luts, - autoLevels=autoLevels, - autoLevelsOnScroll=autoLevelsOnScroll + luts=luts, + autoLevels=autoLevels, + autoLevelsOnScroll=autoLevelsOnScroll, ) if points_coords_df is not None: - win.drawPointsFromDf(points_coords_df, points_groups=points_groups) + win.drawPointsFromDf(points_coords_df, points_groups=points_groups) if points_coords is not None: points_coords = np.round(points_coords).astype(int) win.drawPoints(points_coords) @@ -142,32 +144,33 @@ def imshow( if show_duplicated_cursor: win.setupDuplicatedCursors() win.annotateObjectIDs( - annotate_labels_idxs=annotate_labels_idxs, + annotate_labels_idxs=annotate_labels_idxs, init=True, ) win.run(block=block, showMaximised=showMaximised, screenToWindowRatio=0.8) return win + def _add_colorbar_axes( - ax: plt.Axes, im: matplotlib.image.AxesImage, size='5%', pad=0.07, - label='' - ): + ax: plt.Axes, im: matplotlib.image.AxesImage, size="5%", pad=0.07, label="" +): divider = make_axes_locatable(ax) cax = divider.append_axes("right", size="5%", pad=0.05) cbar = plt.colorbar(im, cax=cax) if label: cbar.set_label(label) + def _raise_non_unique_groups(grouping, dfs, groups_xx): groups_with_duplicates = {} for d, df in enumerate(dfs): if df.index.is_unique: continue group_xx = groups_xx[d] - group_with_duplicates = df.columns[0].split(';;')[1].replace('-', ', ') - duplicated_xx = group_xx[df.index.duplicated(keep='first')] + group_with_duplicates = df.columns[0].split(";;")[1].replace("-", ", ") + duplicated_xx = group_xx[df.index.duplicated(keep="first")] groups_with_duplicates[group_with_duplicates] = duplicated_xx - + duplicates = [] for group_values, duplicated_xx in groups_with_duplicates.items(): xx_name = duplicated_xx.name @@ -177,27 +180,34 @@ def _raise_non_unique_groups(grouping, dfs, groups_xx): ) duplicates.append(duplicates_str) - duplicates = '\n'.join(duplicates) + duplicates = "\n".join(duplicates) traceback.print_exc() print(error_below) - grouping_str = f'{grouping}'.strip('()').strip(',') + grouping_str = f"{grouping}".strip("()").strip(",") print(f'The groups determined by "{grouping_str}" are not unique:\n') - print(f'{duplicates}') + print(f"{duplicates}") print(error_close) exit() + def raise_missing_arg(argument_name): traceback.print_exc() print(error_below) - print(f'The argument `{argument_name}` is required.') + print(f"The argument `{argument_name}` is required.") print(error_close) exit() + def _get_groups_data( - df: pd.DataFrame, x: str, z: str, grouping: str, bin_size: int=None, - normalize_x: bool=False, zeroize_x: bool=False - ): + df: pd.DataFrame, + x: str, + z: str, + grouping: str, + bin_size: int = None, + normalize_x: bool = False, + zeroize_x: bool = False, +): grouped = df.groupby(list(grouping)) dfs = [] groups_xx = [] @@ -205,45 +215,43 @@ def _get_groups_data( max_n_decimals = None min_norm_bin_size = None if normalize_x: - min_dx = min([ - group_df[x].diff().abs().min() for _, group_df in grouped - ]) + min_dx = min([group_df[x].diff().abs().min() for _, group_df in grouped]) max_n_decimals = 0 min_norm_bin_size = np.inf for name, group_df in grouped: groups_xx.append(group_df[x]) if zeroize_x: - group_xx = group_df[x]-group_df[x].min() + group_xx = group_df[x] - group_df[x].min() else: group_xx = group_df[x] if len(grouping) == 1: name_str = str(name) else: - name_str = '-'.join([str(n) for n in name]) - group_cols = {col:f'{col};;{name_str}' for col in group_df.columns} + name_str = "-".join([str(n) for n in name]) + group_cols = {col: f"{col};;{name_str}" for col in group_df.columns} group_df = group_df.rename(columns=group_cols) - if normalize_x: + if normalize_x: max_xx = group_xx.max() - norm_dx = min_dx/max_xx + norm_dx = min_dx / max_xx min_dx_rounded = _core.round_to_significant(norm_dx, 2) - n_decimals = len(str(min_dx_rounded).split('.')[1]) + n_decimals = len(str(min_dx_rounded).split(".")[1]) if n_decimals > max_n_decimals: max_n_decimals = n_decimals - norm_xx = (group_xx/max_xx).round(n_decimals) - norm_xx_perc = norm_xx*100 + norm_xx = (group_xx / max_xx).round(n_decimals) + norm_xx_perc = norm_xx * 100 if bin_size is not None: - norm_bin_size = (bin_size/max_xx).round(n_decimals)*100 + norm_bin_size = (bin_size / max_xx).round(n_decimals) * 100 if norm_bin_size < min_norm_bin_size: min_norm_bin_size = norm_bin_size - group_df['x'] = norm_xx_perc + group_df["x"] = norm_xx_perc else: - group_df['x'] = group_xx - col_name = f'{z};;{name_str}' - dfs.append(group_df[[col_name, 'x']].dropna().set_index('x')) - - yticks_labels.append(f'{name}'.strip('()')) - + group_df["x"] = group_xx + col_name = f"{z};;{name_str}" + dfs.append(group_df[[col_name, "x"]].dropna().set_index("x")) + + yticks_labels.append(f"{name}".strip("()")) + try: df_data = pd.concat(dfs, names=[x], axis=1).sort_index() except pd.errors.InvalidIndexError as err: @@ -259,124 +267,127 @@ def _get_groups_data( n_decimals = max_n_decimals - 2 order_of_magnitude = 10**n_decimals df_data = df_data.reset_index() - df_data['x_int'] = (df_data['x']*order_of_magnitude).astype(int) - df_data = df_data.set_index('x_int').drop(columns='x') - bin_size = int(bin_size*order_of_magnitude) + df_data["x_int"] = (df_data["x"] * order_of_magnitude).astype(int) + df_data = df_data.set_index("x_int").drop(columns="x") + bin_size = int(bin_size * order_of_magnitude) df_data.index = pd.to_datetime(df_data.index.astype(int)) - rs = f'{bin_size}ns' - df_data = df_data.resample(rs, label='right').mean() - df_data.index = df_data.index.astype(np.int64)/order_of_magnitude + rs = f"{bin_size}ns" + df_data = df_data.resample(rs, label="right").mean() + df_data.index = df_data.index.astype(np.int64) / order_of_magnitude data = df_data.fillna(0).values.T xx = df_data.index return data, xx, yticks_labels + def _check_df_data_args(**kwargs): for arg_name, arg_value in kwargs.items(): - if arg_value: + if arg_value: continue if arg_value is not None: continue raise_missing_arg(arg_name) + def _raise_group_label_depth_too_deep(group_label_depth, n_levels): traceback.print_exc() print(error_below) print( - f'The `group_label_depth = {group_label_depth}` is too high, ' - f'there are only {n_levels} levels.' + f"The `group_label_depth = {group_label_depth}` is too high, " + f"there are only {n_levels} levels." ) print(error_close) exit() -def _get_heatmap_yticks( - nrows, group_height, yticks_labels, group_label_depth - ): - yticks = np.arange(0,nrows*group_height, group_height) - 0.5 + +def _get_heatmap_yticks(nrows, group_height, yticks_labels, group_label_depth): + yticks = np.arange(0, nrows * group_height, group_height) - 0.5 # yticks = yticks + group_height/2 - 0.5 if group_label_depth is not None: - df_ticks = pd.DataFrame({ - 'yticks': yticks, - 'yticks_labels': yticks_labels - }).set_index('yticks').astype(str) - df_ticks = df_ticks['yticks_labels'].str.split(',', expand=True) + df_ticks = ( + pd.DataFrame({"yticks": yticks, "yticks_labels": yticks_labels}) + .set_index("yticks") + .astype(str) + ) + df_ticks = df_ticks["yticks_labels"].str.split(",", expand=True) if group_label_depth > len(df_ticks.columns): n_levels = len(df_ticks.columns) _raise_group_label_depth_too_deep(group_label_depth, n_levels) df_ticks = df_ticks[list(range(group_label_depth))] - df_ticks['yticks_labels'] = df_ticks.agg(','.join, axis=1) - df_ticks = df_ticks.reset_index().set_index('yticks_labels') - yticks_first = df_ticks[~df_ticks.index.duplicated(keep='first')] - yticks_last = df_ticks[~df_ticks.index.duplicated(keep='last')] - yticks_start = yticks_first['yticks'] - yticks_end = yticks_last['yticks'] - yticks_center = yticks_start + (yticks_end-yticks_start)/2 + df_ticks["yticks_labels"] = df_ticks.agg(",".join, axis=1) + df_ticks = df_ticks.reset_index().set_index("yticks_labels") + yticks_first = df_ticks[~df_ticks.index.duplicated(keep="first")] + yticks_last = df_ticks[~df_ticks.index.duplicated(keep="last")] + yticks_start = yticks_first["yticks"] + yticks_end = yticks_last["yticks"] + yticks_center = yticks_start + (yticks_end - yticks_start) / 2 yticks_center = yticks_center return yticks_start, yticks_end, yticks_center + def _raise_convert_time_how(convert_time_how): print(error_below) - conversion_methods = [ - f' * {how}' for how in _core.time_units_converters.keys() - ] - conversion_methods = '\n'.join(conversion_methods) - print( - f'"{convert_time_how}" is not a valid `convert_time_how` value.\n' - ) - print( - f'Valid methods are:\n\n{conversion_methods}' - ) + conversion_methods = [f" * {how}" for how in _core.time_units_converters.keys()] + conversion_methods = "\n".join(conversion_methods) + print(f'"{convert_time_how}" is not a valid `convert_time_how` value.\n') + print(f"Valid methods are:\n\n{conversion_methods}") print(error_close) exit() + def _get_heatmap_xticks( - xx, x_unit_width, num_xticks, convert_time_how, - num_decimals_xticks_labels, x_label_loc='right', - add_x_0_label=True, x_labels=None - ): + xx, + x_unit_width, + num_xticks, + convert_time_how, + num_decimals_xticks_labels, + x_label_loc="right", + add_x_0_label=True, + x_labels=None, +): series_xindex = pd.Series(xx).repeat(x_unit_width) - if x_label_loc == 'right': + if x_label_loc == "right": series_xindex.index = series_xindex.index + 1 - elif x_label_loc == 'center': + elif x_label_loc == "center": series_xindex.index = series_xindex.index + 0.5 - elif x_label_loc == 'left': + elif x_label_loc == "left": pass - + if x_labels is not None: - series_xticks = ( - series_xindex[series_xindex.isin(x_labels)] - .drop_duplicates(keep='first') + series_xticks = series_xindex[series_xindex.isin(x_labels)].drop_duplicates( + keep="first" ) else: - resampling_step = round(len(series_xindex)/(num_xticks)) + resampling_step = round(len(series_xindex) / (num_xticks)) series_xticks = series_xindex.iloc[::resampling_step] - + xticks = series_xticks.index.to_list() xticks_labels = series_xticks.values.astype(int) - + if add_x_0_label and xticks[0] != 0: xticks = [0, *xticks] xticks_labels = np.zeros(len(xticks), dtype=int) xticks_labels[1:] = series_xticks - + if convert_time_how is None: return xticks, xticks_labels - - from_unit, to_unit = convert_time_how.split('->') + + from_unit, to_unit = convert_time_how.split("->") xticks_labels = _core.convert_time_units(xticks_labels, from_unit, to_unit) if xticks_labels is None: _raise_convert_time_how(convert_time_how) - + if num_decimals_xticks_labels is None: return xticks, xticks_labels - + xticks_labels = xticks_labels.round(num_decimals_xticks_labels) - + return xticks, xticks_labels + def _check_x_dtype(df, x, force_x_to_int): if force_x_to_int: return @@ -385,46 +396,47 @@ def _check_x_dtype(df, x, force_x_to_int): return print(error_below) print( - f'The `x` column must be of data type integer. ' - 'Pass `force_x_to_int=True` if you want to force conversion to ' - 'integers.' + f"The `x` column must be of data type integer. " + "Pass `force_x_to_int=True` if you want to force conversion to " + "integers." ) print(error_close) exit() + def heatmap( - data: Union[pd.DataFrame, np.ndarray], - x: str='', - z: str='', - y_grouping: Union[str, List[str]]='', - sort_groups: bool=True, - normalize_x: bool=False, - zeroize_x: bool=False, - x_bin_size: int=None, - x_label_loc: str='right', - x_labels: np.ndarray=None, - add_x_0_label: bool=False, - convert_time_how: str=None, - xlabel: str=None, - num_decimals_xticks_labels: int=None, - force_x_to_int: bool=False, - z_min: Union[int, float]=None, - z_max: Union[int, float]=None, - stretch_height_factor: float=None, - stretch_width_factor: float=None, - group_label_depth: int=1, - num_xticks: int=6, - colormap: Union[str, matplotlib.colors.Colormap]='viridis', - missing_values_color=None, - colorbar_pad: float= 0.07, - colorbar_size: float=0.05, - colorbar_label: str='', - ax: plt.Axes=None, - fig: plt.Figure=None, - backend: str='matplotlib', - block: bool=False, - imshow_kwargs: dict=None - ): + data: Union[pd.DataFrame, np.ndarray], + x: str = "", + z: str = "", + y_grouping: Union[str, List[str]] = "", + sort_groups: bool = True, + normalize_x: bool = False, + zeroize_x: bool = False, + x_bin_size: int = None, + x_label_loc: str = "right", + x_labels: np.ndarray = None, + add_x_0_label: bool = False, + convert_time_how: str = None, + xlabel: str = None, + num_decimals_xticks_labels: int = None, + force_x_to_int: bool = False, + z_min: Union[int, float] = None, + z_max: Union[int, float] = None, + stretch_height_factor: float = None, + stretch_width_factor: float = None, + group_label_depth: int = 1, + num_xticks: int = 6, + colormap: Union[str, matplotlib.colors.Colormap] = "viridis", + missing_values_color=None, + colorbar_pad: float = 0.07, + colorbar_size: float = 0.05, + colorbar_label: str = "", + ax: plt.Axes = None, + fig: plt.Figure = None, + backend: str = "matplotlib", + block: bool = False, + imshow_kwargs: dict = None, +): """Generate heatmap plot from data Parameters @@ -434,10 +446,10 @@ def heatmap( x : str, optional Name of the column used for the x-axis. Default is '' z : str, optional - Name of the column used for the z-axis, i.e., the values that + Name of the column used for the z-axis, i.e., the values that determine the color of each pixel. Default is '' y_grouping : Union[str, List[str]], optional - Column or list of columns that identifies a single row in the + Column or list of columns that identifies a single row in the heatmap. Default is '' sort_groups : bool, optional _description_. Default is True @@ -498,11 +510,11 @@ def heatmap( ------- _type_ _description_ - """ - + """ + if ax is None: fig, ax = plt.subplots() - + if imshow_kwargs is None: imshow_kwargs = {} @@ -518,33 +530,38 @@ def heatmap( if sort_groups: data = data.sort_values(list(y_cols)) data, xx, yticks_labels = _get_groups_data( - data, x, z, grouping=y_cols, normalize_x=normalize_x, - bin_size=x_bin_size, zeroize_x=zeroize_x + data, + x, + z, + grouping=y_cols, + normalize_x=normalize_x, + bin_size=x_bin_size, + zeroize_x=zeroize_x, ) else: - x = 'x' if not x else x - y_grouping = 'groups' if not y_grouping else y_grouping - z = 'x' if not z else z + x = "x" if not x else x + y_grouping = "groups" if not y_grouping else y_grouping + z = "x" if not z else z xx = np.arange(data.shape[-1]) if z_min is None: z_min = np.nanmin(data) - + if z_max is None: z_max = np.nanmax(data) Y, X = data.shape - group_height = round(X/Y) + group_height = round(X / Y) if stretch_height_factor is not None: - group_height = round(group_height*stretch_height_factor) - + group_height = round(group_height * stretch_height_factor) + Y, X = data.shape - x_unit_width = round(Y/X) + x_unit_width = round(Y / X) if stretch_width_factor is not None: x_unit_width = round(stretch_width_factor) - - group_height = group_height if group_height>1 else 1 - x_unit_width = x_unit_width if x_unit_width>1 else 1 + + group_height = group_height if group_height > 1 else 1 + x_unit_width = x_unit_width if x_unit_width > 1 else 1 yticks_start, yticks_end, yticks_center = _get_heatmap_yticks( len(data), group_height, yticks_labels, group_label_depth @@ -553,34 +570,39 @@ def heatmap( yticks = yticks_start.values xticks, xticks_labels = _get_heatmap_xticks( - xx, x_unit_width, num_xticks, convert_time_how, - num_decimals_xticks_labels, x_label_loc=x_label_loc, - add_x_0_label=add_x_0_label, x_labels=x_labels + xx, + x_unit_width, + num_xticks, + convert_time_how, + num_decimals_xticks_labels, + x_label_loc=x_label_loc, + add_x_0_label=add_x_0_label, + x_labels=x_labels, ) if group_height > 1: - data = np.repeat(data, [group_height]*len(data), axis=0) - + data = np.repeat(data, [group_height] * len(data), axis=0) + if x_unit_width > 1: ncols = data.shape[-1] - data = np.repeat(data, [x_unit_width]*ncols, axis=1) - xticks = [xtick*x_unit_width for xtick in xticks] - + data = np.repeat(data, [x_unit_width] * ncols, axis=1) + xticks = [xtick * x_unit_width for xtick in xticks] + if missing_values_color is not None: if isinstance(colormap, str): colormap = plt.get_cmap(colormap) bkgr_color = matplotlib.colors.to_rgba(missing_values_color) - colors = colormap(np.linspace(0,1,256)) + colors = colormap(np.linspace(0, 1, 256)) colors[0] = bkgr_color colormap = matplotlib.colors.ListedColormap(colors) if xlabel is None: xlabel = x - # Make sure to label the side of the pixel + # Make sure to label the side of the pixel xticks = np.array(xticks) - xticks = (xticks + (xticks-x_unit_width))/2 + xticks = (xticks + (xticks - x_unit_width)) / 2 xticks -= 0.5 im = ax.imshow(data, cmap=colormap, vmin=z_min, vmax=z_max, **imshow_kwargs) @@ -588,57 +610,53 @@ def heatmap( ax.set_xticks(xticks, labels=xticks_labels) ax.set_ylabel(y_grouping) ax.set_yticks(yticks, labels=yticks_labels) - - _size_perc = f'{int(colorbar_size*100)}%' - _add_colorbar_axes( - ax, im, size=_size_perc, pad=colorbar_pad, label=colorbar_label - ) - + + _size_perc = f"{int(colorbar_size * 100)}%" + _add_colorbar_axes(ax, im, size=_size_perc, pad=colorbar_pad, label=colorbar_label) + if block: plt.show() else: return fig, ax, im + def _binned_mean_stats(x, y, bins, bins_min_count): x = np.array(x).astype(float) y = np.array(y).astype(float) - bin_counts, _, _ = scipy.stats.binned_statistic( - x, y, statistic='count', bins=bins - ) + bin_counts, _, _ = scipy.stats.binned_statistic(x, y, statistic="count", bins=bins) bin_means, bin_edges, _ = scipy.stats.binned_statistic( x, y, bins=bins, statistic=np.nanmean ) - bin_std, _, _ = scipy.stats.binned_statistic( - x, y, statistic=np.nanstd, bins=bins - ) - bin_width = (bin_edges[1] - bin_edges[0]) - bin_centers = bin_edges[1:] - bin_width/2 + bin_std, _, _ = scipy.stats.binned_statistic(x, y, statistic=np.nanstd, bins=bins) + bin_width = bin_edges[1] - bin_edges[0] + bin_centers = bin_edges[1:] - bin_width / 2 if bins_min_count > 1: bin_centers = bin_centers[bin_counts > bins_min_count] bin_means = bin_means[bin_counts > bins_min_count] bin_std = bin_std[bin_counts > bins_min_count] bin_counts = bin_counts[bin_counts > bins_min_count] - std_err = bin_std/np.sqrt(bin_counts) + std_err = bin_std / np.sqrt(bin_counts) return bin_centers, bin_means, bin_std, std_err + def binned_means_plot( - x: Union[str, Iterable] = None, - y: Union[str, Iterable] = None, - bins: Union[int, Iterable] = 10, - bins_min_count: int = 1, - data: pd.DataFrame = None, - ci_plot: Literal['errorbar', 'fill_between']='errorbar', - scatter: bool = True, - line_plot = True, - use_std_err: bool = True, - color = None, - label = None, - scatter_kws = None, - errorbar_kws = None, - fill_between_kws = None, - ax: plt.Axes = None, - scatter_colors = None - ): + x: Union[str, Iterable] = None, + y: Union[str, Iterable] = None, + bins: Union[int, Iterable] = 10, + bins_min_count: int = 1, + data: pd.DataFrame = None, + ci_plot: Literal["errorbar", "fill_between"] = "errorbar", + scatter: bool = True, + line_plot=True, + use_std_err: bool = True, + color=None, + label=None, + scatter_kws=None, + errorbar_kws=None, + fill_between_kws=None, + ax: plt.Axes = None, + scatter_colors=None, +): if ax is None: fig, ax = plt.subplots(1) @@ -655,16 +673,16 @@ def binned_means_plot( if color is None: color = sns.color_palette(n_colors=1)[0] - + if scatter_kws is None: - scatter_kws = {'alpha': 0.3} - - if 'alpha' not in scatter_kws: - scatter_kws['alpha'] = 0.3 - + scatter_kws = {"alpha": 0.3} + + if "alpha" not in scatter_kws: + scatter_kws["alpha"] = 0.3 + if label is None: - label = '' - + label = "" + if scatter_colors is None: scatter_colors = color @@ -672,34 +690,34 @@ def binned_means_plot( if scatter: ax.scatter(x, y, color=scatter_colors, **scatter_kws) yerr = std_err if use_std_err else std - - if ci_plot == 'errorbar': + + if ci_plot == "errorbar": if errorbar_kws is None: - errorbar_kws = {'capsize': 3, 'lw': 2} - + errorbar_kws = {"capsize": 3, "lw": 2} + if not line_plot: - fmt = '.' + fmt = "." else: - fmt = '' - + fmt = "" + ax.errorbar( xe, ye, yerr=yerr, fmt=fmt, color=color, label=label, **errorbar_kws ) - elif ci_plot == 'fill_between': + elif ci_plot == "fill_between": if fill_between_kws is None: - fill_between_kws = {'alpha': 0.3} - + fill_between_kws = {"alpha": 0.3} + if line_plot: ax.plot(xe, ye, color=color, label=label) - label = '' - + label = "" + ax.fill_between( - xe, ye-yerr, ye+yerr, color=color, label=label, **fill_between_kws + xe, ye - yerr, ye + yerr, color=color, label=label, **fill_between_kws ) - return ax + def text_to_pg_scatter_symbol(text: str, font=None, return_scale=False): if font is None: font = QtGui.QFont() @@ -708,27 +726,28 @@ def text_to_pg_scatter_symbol(text: str, font=None, return_scale=False): symbol = QtGui.QPainterPath() symbol.addText(0, 0, font, text) br = symbol.boundingRect() - scale = min(1. / br.width(), 1. / br.height()) + scale = min(1.0 / br.width(), 1.0 / br.height()) tr = QtGui.QTransform() tr.scale(scale, scale) - tr.translate(-br.x() - br.width()/2., -br.y() - br.height()/2.) + tr.translate(-br.x() - br.width() / 2.0, -br.y() - br.height() / 2.0) symbol = tr.map(symbol) if return_scale: return symbol, scale else: return symbol + def get_symbol_sizes(scales: dict, symbols: dict, size: int): scales_arr = np.array([scales[text] for text in symbols.keys()]) - normalized_scales = scales_arr/scales_arr.max() - sizes = np.round(size/normalized_scales).astype(int) - sizes = {text:scale for text, scale in zip(symbols.keys(), sizes)} + normalized_scales = scales_arr / scales_arr.max() + sizes = np.round(size / normalized_scales).astype(int) + sizes = {text: scale for text, scale in zip(symbols.keys(), sizes)} return sizes + def texts_to_pg_scatter_symbols( - texts: Union[str, list], font=None, progress=True, - return_scales=False - ): + texts: Union[str, list], font=None, progress=True, return_scales=False +): if font is None: font = QtGui.QFont() font.setPixelSize(11) @@ -736,7 +755,7 @@ def texts_to_pg_scatter_symbols( texts = [texts] if progress: - pbar = tqdm(total=len(texts)*2, ncols=100) + pbar = tqdm(total=len(texts) * 2, ncols=100) symbols = {} scales = {} @@ -744,102 +763,108 @@ def texts_to_pg_scatter_symbols( symbol = QtGui.QPainterPath() symbol.addText(0, 0, font, text) br = symbol.boundingRect() - scale = min(1. / br.width(), 1. / br.height()) + scale = min(1.0 / br.width(), 1.0 / br.height()) if progress: pbar.update() tr = QtGui.QTransform() tr.scale(scale, scale) - tr.translate(-br.x() - br.width()*0.5, -br.y() - br.height()*0.5) + tr.translate(-br.x() - br.width() * 0.5, -br.y() - br.height() * 0.5) symbols[text] = tr.map(symbol) scales[text] = scale if progress: pbar.update() - + if progress: pbar.close() - + if return_scales: return symbols, scales else: return symbols + def plt_contours( - ax, lab=None, rp=None, plot_kwargs=None, only_IDs=None, - clear_borders=True, obj_contours_kwargs=None - ): + ax, + lab=None, + rp=None, + plot_kwargs=None, + only_IDs=None, + clear_borders=True, + obj_contours_kwargs=None, +): if rp is None: rp = skimage.measure.regionprops(lab) if plot_kwargs is None: plot_kwargs = {} - + if obj_contours_kwargs is None: obj_contours_kwargs = {} - + for obj in rp: if only_IDs is not None and obj.label not in only_IDs: continue - + contours = core.get_obj_contours(obj, **obj_contours_kwargs) if not isinstance(contours, list): - contours = [contours] - + contours = [contours] + for contour in contours: xx = contour[:, 0] yy = contour[:, 1] if clear_borders: - valid_mask = np.logical_and(xx>0.5, yy>0.5) + valid_mask = np.logical_and(xx > 0.5, yy > 0.5) xx = xx[valid_mask] yy = yy[valid_mask] - + ax.plot(xx, yy, **plot_kwargs) + def plt_moth_bud_lines( - ax, cca_df, lab=None, rp=None, plot_kwargs=None, - only_moth_IDs=None - ): + ax, cca_df, lab=None, rp=None, plot_kwargs=None, only_moth_IDs=None +): if rp is None: rp = skimage.measure.regionprops(lab) if plot_kwargs is None: plot_kwargs = {} - - rp_mapper = {obj.label:obj for obj in rp} - + + rp_mapper = {obj.label: obj for obj in rp} + for obj in rp: - ccs = cca_df.at[obj.label, 'cell_cycle_stage'] - if ccs == 'G1': + ccs = cca_df.at[obj.label, "cell_cycle_stage"] + if ccs == "G1": continue - - status = cca_df.at[obj.label, 'relationship'] - if status == 'mother': + + status = cca_df.at[obj.label, "relationship"] + if status == "mother": continue - - mothID = cca_df.at[obj.label, 'relative_ID'] + + mothID = cca_df.at[obj.label, "relative_ID"] if only_moth_IDs is not None and mothID not in only_moth_IDs: continue - + moth_obj = rp_mapper[mothID] - + y1, x1 = obj.centroid y2, x2 = moth_obj.centroid - + ax.plot([x1, x2], [y1, y2], **plot_kwargs) -if __name__ == '__main__': + +if __name__ == "__main__": x = np.arange(0, 1000).astype(float) - y = 2*x+10 + y = 2 * x + 10 noise = np.random.normal(0, 100, size=1000) y += noise - data = pd.DataFrame({'x': x, 'y': y}) + data = pd.DataFrame({"x": x, "y": y}) nbins = 10 bins_min_count = 10 binned_means_plot( - x='x', y='y', data=data, nbins=nbins, bins_min_count=bins_min_count + x="x", y="y", data=data, nbins=nbins, bins_min_count=bins_min_count ) - + plt.show() - \ No newline at end of file diff --git a/cellacdc/preprocess.py b/cellacdc/preprocess.py index 60d006140..3a4e7fbca 100644 --- a/cellacdc/preprocess.py +++ b/cellacdc/preprocess.py @@ -1,20 +1,21 @@ """ -This module contains the functions that can be used as pre-processing steps -before segmentation. +This module contains the functions that can be used as pre-processing steps +before segmentation. -These functions are automatically added to `apps.QDialogModelParams` and they -can be selected in the pre-processing recipe. +These functions are automatically added to `apps.QDialogModelParams` and they +can be selected in the pre-processing recipe. -Every function must have a single argument for the image, while all -other parameters must be keyword arguments. +Every function must have a single argument for the image, while all +other parameters must be keyword arguments. -Functions that should not be used as pre-processing steps must start with `_`. -The list of functions is generated in the module `cellacdc.config` +Functions that should not be used as pre-processing steps must start with `_`. +The list of functions is generated in the module `cellacdc.config` (see PREPROCESS_MAPPER variable). -IMPORTANT: Do not import functions otherwise they will be added as possible +IMPORTANT: Do not import functions otherwise they will be added as possible step (for example do not do `from skimage.util import img_as_ubyte`). """ + from typing import Hashable, Union, Optional, Tuple from tqdm import tqdm @@ -25,6 +26,7 @@ try: import cupyx.scipy.ndimage import cupy as cp + CUPY_INSTALLED = True except Exception as e: CUPY_INSTALLED = False @@ -40,13 +42,11 @@ SQRT_2 = math.sqrt(2) + def remove_hot_pixels( - image, - logger_func=print, - progress=True, - apply_to_all_zslices=True - ): - """Apply a morphological opening operation to remove isolated bright + image, logger_func=print, progress=True, apply_to_all_zslices=True +): + """Apply a morphological opening operation to remove isolated bright pixels. Parameters @@ -62,7 +62,7 @@ def remove_hot_pixels( ------- (Y, X) or (Z, Y, X) numpy.ndarray Filtered image - """ + """ is_3D = image.ndim == 3 if is_3D: if progress: @@ -78,13 +78,14 @@ def remove_hot_pixels( filtered = skimage.morphology.opening(image) return filtered + def gaussian_filter( - image, - sigma: _types.Vector=0.75, - use_gpu=False, - logger_func=print, - apply_to_all_zslices=True - ): + image, + sigma: _types.Vector = 0.75, + use_gpu=False, + logger_func=print, + apply_to_all_zslices=True, +): """Multi-dimensional Gaussian filter Parameters @@ -92,11 +93,11 @@ def gaussian_filter( image : numpy.ndarray Input image (grayscale or color) to filter. sigma : types.Vector - Standard deviation for Gaussian kernel. The standard deviations of the - Gaussian filter are given for each axis as a sequence, or as a single + Standard deviation for Gaussian kernel. The standard deviations of the + Gaussian filter are given for each axis as a sequence, or as a single number, in which case it is equal for all axes. use_gpu : bool, optional - If True, uses `cupy` instead of `skimage.filters.gaussian`. + If True, uses `cupy` instead of `skimage.filters.gaussian`. Default is False logger_func : callable, optional Function used to log information. Default is print @@ -109,48 +110,45 @@ def gaussian_filter( See also -------- Wikipedia link: `Gaussian blur `_ - """ + """ try: if len(sigma) > 1 and sigma[0] == 0: return image except Exception as err: pass - + try: if sigma == 0: return image except Exception as err: pass - + try: if len(sigma) == 0: sigma = sigma[0] except Exception as err: pass - + if CUPY_INSTALLED and use_gpu: try: image = cp.array(image, dtype=float) filtered = cupyx.scipy.ndimage.gaussian_filter(image, sigma) filtered = cp.asnumpy(filtered) except Exception as err: - logger_func('*'*100) + logger_func("*" * 100) logger_func(err) logger_func( - '[WARNING]: GPU acceleration of the gaussian filter failed. ' - f'Using CPU...{error_up_str}' + "[WARNING]: GPU acceleration of the gaussian filter failed. " + f"Using CPU...{error_up_str}" ) filtered = skimage.filters.gaussian(image, sigma=sigma) else: filtered = skimage.filters.gaussian(image, sigma=sigma) return filtered -def ridge_filter( - image, - sigmas: _types.Vector=(1.0, 2.0), - apply_to_all_zslices=True - ): - """Filter used to enhance network-like structures (Sato filter). More info + +def ridge_filter(image, sigmas: _types.Vector = (1.0, 2.0), apply_to_all_zslices=True): + """Filter used to enhance network-like structures (Sato filter). More info here https://scikit-image.org/docs/stable/auto_examples/edges/plot_ridge_filter.html Parameters @@ -164,20 +162,21 @@ def ridge_filter( ------- (Y, X) or (Z, Y, X) numpy.ndarray Filtered image - """ + """ input_shape = image.shape filtered = skimage.filters.sato( np.squeeze(image), sigmas=sigmas, black_ridges=False ).reshape(input_shape) return filtered + def spot_detector_filter( - image, - spots_zyx_radii_pxl: _types.Vector=(3, 5, 5), - use_gpu=False, - logger_func=print, - apply_to_all_zslices=True - ): + image, + spots_zyx_radii_pxl: _types.Vector = (3, 5, 5), + use_gpu=False, + logger_func=print, + apply_to_all_zslices=True, +): """Spot detection using Difference of Gaussians filter. Parameters @@ -185,10 +184,10 @@ def spot_detector_filter( image : (Y, X) or (Z, Y, X) numpy.ndarray Input image spots_zyx_radii_pxl : sequence of floats, one for each dimension, optional - Expected size of the spots in pixels. One size for each dimension in + Expected size of the spots in pixels. One size for each dimension in `image`. Default is (3, 5, 5) use_gpu : bool, optional - If `True` uses GPU if `cupy` is installed and a CUDA-compatible GPU + If `True` uses GPU if `cupy` is installed and a CUDA-compatible GPU is available . Default is False logger_func : callable, optional Function used to log additional information on progress. Default is print @@ -202,45 +201,42 @@ def spot_detector_filter( ------ TypeError Error raised when on of the input sigmas is zero. - """ + """ spots_zyx_radii_pxl = np.array(spots_zyx_radii_pxl) if image.ndim == 2 and len(spots_zyx_radii_pxl) == 3: spots_zyx_radii_pxl = spots_zyx_radii_pxl[1:] - - sigma1 = spots_zyx_radii_pxl/(1+SQRT_2) - + + sigma1 = spots_zyx_radii_pxl / (1 + SQRT_2) + if 0 in sigma1: raise TypeError( - f'Sharpening filter input sigmas cannot be 0. `zyx_sigma1 = {sigma1}`' + f"Sharpening filter input sigmas cannot be 0. `zyx_sigma1 = {sigma1}`" ) - - blurred1 = gaussian_filter( - image, sigma1, use_gpu=use_gpu, logger_func=logger_func - ) - - sigma2 = SQRT_2*sigma1 - blurred2 = gaussian_filter( - image, sigma2, use_gpu=use_gpu, logger_func=logger_func - ) - + + blurred1 = gaussian_filter(image, sigma1, use_gpu=use_gpu, logger_func=logger_func) + + sigma2 = SQRT_2 * sigma1 + blurred2 = gaussian_filter(image, sigma2, use_gpu=use_gpu, logger_func=logger_func) + sharpened = blurred1 - blurred2 - + out_range = (image.min(), image.max()) - in_range = 'image' + in_range = "image" sharp_rescaled = skimage.exposure.rescale_intensity( sharpened, in_range=in_range, out_range=out_range ) - + return sharp_rescaled + def correct_illumination( - image, - block_size=45, - # rescale_illumination=True, - approximate_object_diameter=15, - # background_threshold=0.3, - apply_gaussian_filter=True - ): + image, + block_size=45, + # rescale_illumination=True, + approximate_object_diameter=15, + # background_threshold=0.3, + apply_gaussian_filter=True, +): """ Correct illumination of an image. Based on CellProfiler's illumination correction. @@ -291,11 +287,9 @@ def correct_illumination( return corrected_image -def enhance_speckles(img, - radius=15, - apply_to_all_zslices=False - ): - """Enhance speckles in an image using white_tophat. Based on + +def enhance_speckles(img, radius=15, apply_to_all_zslices=False): + """Enhance speckles in an image using white_tophat. Based on EnhanceOrSuppressFeatures from Cell profiler with 'Feature type: Speckles' Parameters @@ -303,7 +297,7 @@ def enhance_speckles(img, image : np.ndarray 2D image to enhance radius : int, optional - Radius to use for the enhancer. Will suppress objects smaller than this + Radius to use for the enhancer. Will suppress objects smaller than this radius. Default is 15 Returns @@ -318,18 +312,19 @@ def enhance_speckles(img, output_image = skimage.morphology.white_tophat(img, footprint=footprint) return output_image + def fucci_filter( - image, - correct_illumination_toggle=False, - do_basicpy_background_correction=True, - enhance_speckles_toggle=True, - block_size=120, - # rescale_illumination=False, - approximate_object_diameter=25, - # background_threshold=0.3, - apply_gaussian_filter=True, - speckle_radius=25 - ): + image, + correct_illumination_toggle=False, + do_basicpy_background_correction=True, + enhance_speckles_toggle=True, + block_size=120, + # rescale_illumination=False, + approximate_object_diameter=25, + # background_threshold=0.3, + apply_gaussian_filter=True, + speckle_radius=25, +): """Basic filter pipeline proposed for Fucci images. If you want custom pipelines and more in depth control, create your own recipe using the GUI or segmentation and tracking modules. @@ -339,13 +334,13 @@ def fucci_filter( image : (Y, X) numpy.ndarray 2D image to correct correct_illumination_toggle : bool, optional - If illumination should be corrected. + If illumination should be corrected. Default is True do_basicpy_background_correction : bool, optional - If BaSiC background correction should be applied. + If BaSiC background correction should be applied. Default is False enhance_speckles_toggle : bool, optional - If speckles should be enhanced. + If speckles should be enhanced. Default is True block_size : int, optional Block size for which to calculate the background illumination. @@ -354,16 +349,16 @@ def fucci_filter( # if illumination should be rescaled with skimage.exposure.rescale_intensity range=(0, 1). # Default is True approximate_object_diameter : int, optional - Approximate object diameter for gaussian_filter. + Approximate object diameter for gaussian_filter. Default is 25 # background_threshold : float, optional - # Threshold to be used to determine the background. + # Threshold to be used to determine the background. # Default is 0.3 apply_gaussian_filter : bool, optional - If gaussian_filter should be applied to the illumination_function. + If gaussian_filter should be applied to the illumination_function. Default is True speckle_radius : int, optional - Radius to use for the enhancer. Will suppress objects smaller than this + Radius to use for the enhancer. Will suppress objects smaller than this radius. Default is 25 Returns @@ -373,14 +368,14 @@ def fucci_filter( """ if do_basicpy_background_correction: image = basicpy_background_correction( - image, + image, apply_to_all_frames=False, apply_to_all_zslices=False, ) if correct_illumination_toggle: image = correct_illumination( - image, - block_size=block_size, + image, + block_size=block_size, # rescale_illumination=rescale_illumination, approximate_object_diameter=approximate_object_diameter, # background_threshold=background_threshold, @@ -388,136 +383,132 @@ def fucci_filter( ) if enhance_speckles_toggle: image = enhance_speckles(image, radius=speckle_radius) - + return image + def dummy_filter( - image: np.ndarray, - apply_to_all_zslices=False, - apply_to_all_frames=False - ): + image: np.ndarray, apply_to_all_zslices=False, apply_to_all_frames=False +): printl(image.shape) return image + class VolumeImageData: def __init__(self): self._data = {} - def __setitem__( - self, - z_slice: int, - image: np.ndarray - ): + def __setitem__(self, z_slice: int, image: np.ndarray): if not isinstance(z_slice, (int, str)): raise TypeError( - f'{z_slice} is not not a valid index. ' - f'It must be an integer or a string and not {type(z_slice)}' + f"{z_slice} is not not a valid index. " + f"It must be an integer or a string and not {type(z_slice)}" ) - + if image.ndim != 2: raise TypeError( - 'Only 2D images can be assigned to a specifc z-slice index.' + "Only 2D images can be assigned to a specifc z-slice index." ) - + self._data[z_slice] = image - - def __getitem__( - self, z_slice: Union[int, Tuple[Union[int, slice]], None] - ): + + def __getitem__(self, z_slice: Union[int, Tuple[Union[int, slice]], None]): if isinstance(z_slice, int): return self._data[z_slice] - + arr = self._build_arr() return arr[z_slice] - + def __array__(self) -> np.ndarray: return self._build_arr() - + def __repr__(self): return str(self._data) - + def _build_arr(self): if not self._data: return - + img = self._data[0] SizeZ = len(self._data) arr = np.zeros((SizeZ, *img.shape), dtype=img.dtype) for z_slice, img in self._data.items(): arr[z_slice] = img return np.squeeze(arr) - + def max(self, axis=None): arr = self._build_arr() if arr is None: return - + return arr.max(axis=axis) def min(self, axis=None): arr = self._build_arr() if arr is None: return - + return arr.min(axis=axis) - + def mean(self, axis=None): arr = self._build_arr() if arr is None: return - + return arr.mean(axis=axis) - + + class PreprocessedData: def __init__(self, image_data=None): self._data = {} if image_data is not None: self._init_data(image_data) - + def _init_data(self, image_data): for frame_i, img in enumerate(image_data): self[frame_i] = img - + def __getitem__(self, frame_i: int): if frame_i not in self._data: self._data[frame_i] = VolumeImageData() - + return self._data[frame_i] - + def __setitem__(self, frame_i: int, image: np.ndarray): if not isinstance(frame_i, int): raise TypeError( - f'{frame_i} is not not a valid index. ' - f'It must be an integer and not {type(frame_i)}' + f"{frame_i} is not not a valid index. " + f"It must be an integer and not {type(frame_i)}" ) - + if frame_i not in self._data: self._data[frame_i] = VolumeImageData() - + if image.ndim == 2: self._data[frame_i][0] = image else: for z_slice, img in enumerate(image): self._data[frame_i][z_slice] = img - + def __repr__(self): return str(self._data) - + def get(self, frame_i: int, default_value=None): try: return self._data[frame_i] except KeyError: return default_value + def rescale_intensities( - image: np.array, - out_range_low: float=0.0, - out_range_high: float=1.0, - in_range_how: _types.RescaleIntensitiesInRangeHow='percentage', - in_range_low: float=0.0, - in_range_high: float=1.0, - apply_to_all_zslices=True, - ): + image: np.array, + out_range_low: float = 0.0, + out_range_high: float = 1.0, + in_range_how: _types.RescaleIntensitiesInRangeHow = "percentage", + in_range_low: float = 0.0, + in_range_high: float = 1.0, + apply_to_all_zslices=True, +): """Rescale the intensities of an image to a given range. Parameters @@ -529,19 +520,19 @@ def rescale_intensities( out_range_high : float, optional Max value of the output image. Default is 1.0 in_range_low : float, optional - Min value of the output image. See `in_range_how` for more details. + Min value of the output image. See `in_range_how` for more details. Default is 0.0 in_range_high : float, optional - Max value of the output image. See `in_range_how` for more details. + Max value of the output image. See `in_range_how` for more details. Default is 1.0 in_range_how : {'percentage', 'image', 'absolute'}, optional - If `percentage`, the image is first rescaled to (0, 1) using the - minimum and maximum value of the input image. This allows to specify - the input range as a percentage of the image intensity range. - If `image`, the input range is the minimum and maximum value of the - input image. - If `absolute`, the input range is specified by `in_range_low` and - `in_range_high` in absolute values (same scale as the input image). + If `percentage`, the image is first rescaled to (0, 1) using the + minimum and maximum value of the input image. This allows to specify + the input range as a percentage of the image intensity range. + If `image`, the input range is the minimum and maximum value of the + input image. + If `absolute`, the input range is specified by `in_range_low` and + `in_range_high` in absolute values (same scale as the input image). Default is 'percentage'. apply_to_all_zslices : bool, optional Scale intensities across multi-dimensional images. Default is True @@ -552,33 +543,36 @@ def rescale_intensities( The rescaled image """ out_range = (out_range_low, out_range_high) - if in_range_how == 'image': - in_range = 'image' - elif in_range_how == 'percentage': + if in_range_how == "image": + in_range = "image" + elif in_range_how == "percentage": image = skimage.exposure.rescale_intensity( - image, in_range='image', out_range=(0, 1) + image, in_range="image", out_range=(0, 1) ) - in_range = (in_range_low, in_range_high) # which now will be in (0, 1) - elif in_range_how == 'absolute': + in_range = (in_range_low, in_range_high) # which now will be in (0, 1) + elif in_range_how == "absolute": in_range = (in_range_low, in_range_high) - + rescaled = skimage.exposure.rescale_intensity( image, in_range=in_range, out_range=out_range ) return rescaled + def _init_dummy_filter(**kwargs): """ - This function runs automatically as part of the preprocessing recipe if - the user selects the 'dummy_filter' step. The 'dummy_filter' is available - only in debug mode. Initialization functions run in the main GUI thread - and they can be used to set up the related function, for example to + This function runs automatically as part of the preprocessing recipe if + the user selects the 'dummy_filter' step. The 'dummy_filter' is available + only in debug mode. Initialization functions run in the main GUI thread + and they can be used to set up the related function, for example to prompt the user that a package needs to be installed. """ pass + def _init_basicpy_background_correction(**kwargs): from . import myutils + custom_install_requires = [ "hyperactive>=4.4.0", "jax>=0.3.10,<0.4.23", @@ -587,62 +581,63 @@ def _init_basicpy_background_correction(**kwargs): "pooch", "pydantic>=2.7.0,<3.0.0", "scikit-image", - "scipy", # this will theoretically have the wrong version of scipy in the end - ] - + "scipy", # this will theoretically have the wrong version of scipy in the end + ] + myutils.check_install_custom_dependencies( - custom_install_requires, 'basicpy', parent=kwargs.get('parent') + custom_install_requires, "basicpy", parent=kwargs.get("parent") ) + def basicpy_background_correction( - images, - apply_to_all_frames=False, - apply_to_all_zslices=False, - smoothness_flatfield=1.0, - get_darkfield=True, - smoothness_darkfield=1.0, - sparse_cost_darkfield=0.01, - # baseline=None, - # darkfield=None, - fitting_mode: _types.BaSiCpyFittingModes="ladmap", - epsilon=0.1, - # flatfield=None, - autosegment=False, - autosegment_margin=10, - max_iterations=500, - max_reweight_iterations=10, - max_reweight_iterations_baseline=5, - # max_workers=2, - rho=1.5, - mu_coef=12.5, - max_mu_coef=10000000.0, - optimization_tol=0.001, - optimization_tol_diff=0.01, - resize_mode: _types.BaSiCpyResizeModes="jax", - resize_params: _types.NotGUIParam=None, - reweighting_tol=0.01, - sort_intensity=False, - working_size=128, - timelapse: _types.BaSiCpyTimelapse="True", - parent: _types.NotGUIParam=None - ): + images, + apply_to_all_frames=False, + apply_to_all_zslices=False, + smoothness_flatfield=1.0, + get_darkfield=True, + smoothness_darkfield=1.0, + sparse_cost_darkfield=0.01, + # baseline=None, + # darkfield=None, + fitting_mode: _types.BaSiCpyFittingModes = "ladmap", + epsilon=0.1, + # flatfield=None, + autosegment=False, + autosegment_margin=10, + max_iterations=500, + max_reweight_iterations=10, + max_reweight_iterations_baseline=5, + # max_workers=2, + rho=1.5, + mu_coef=12.5, + max_mu_coef=10000000.0, + optimization_tol=0.001, + optimization_tol_diff=0.01, + resize_mode: _types.BaSiCpyResizeModes = "jax", + resize_params: _types.NotGUIParam = None, + reweighting_tol=0.01, + sort_intensity=False, + working_size=128, + timelapse: _types.BaSiCpyTimelapse = "True", + parent: _types.NotGUIParam = None, +): """ A function for fitting and applying BaSiC illumination correction profiles. Parameters ---------- images : (T, Z, Y, X) numpy.ndarray - Image. Make sure to set have (T, Z, Y, X) dimensions, + Image. Make sure to set have (T, Z, Y, X) dimensions, or missing dimensions - in accordance with the `apply_to_all_frames` and + in accordance with the `apply_to_all_frames` and `apply_to_all_zslices` parameters. apply_to_all_frames : bool, default=True - Whether to apply the correction to all frames. - If set to falce, assumes that the image has + Whether to apply the correction to all frames. + If set to falce, assumes that the image has no T dimension, so either (Z, Y, X) or (Y, X). apply_to_all_zslices : bool, default=True - Whether to apply the correction to all Z slices. - If set to falce, assumes that the image has + Whether to apply the correction to all Z slices. + If set to falce, assumes that the image has no Z dimension, so either (T, Y, X) or (Y, X). smoothness_flatfield : float, default=1.0 Weight of the flatfield term in the Lagrangian. @@ -687,7 +682,7 @@ def basicpy_background_correction( optimization_tol_diff : float, default=0.01 Optimization tolerance for update difference. resize_mode : str, default="jax" - Resize mode for downsampling images. Must be one of + Resize mode for downsampling images. Must be one of ['jax', 'skimage', 'skimage_dask']. resize_params : dict, default={} Parameters for the resize function. @@ -739,7 +734,7 @@ def basicpy_background_correction( images = transformation.correct_img_dimension( images, input_dims=input_dims, output_dims=output_dims ) - + from basicpy import BaSiC basic = BaSiC( @@ -767,16 +762,13 @@ def basicpy_background_correction( resize_params=resize_params, reweighting_tol=reweighting_tol, sort_intensity=sort_intensity, - working_size=working_size - ) + working_size=working_size, + ) print("Fitting BaSiC model, may take a while...") basic.fit(images) - images = basic.transform( - images, - timelapse=timelapse - ) - + images = basic.transform(images, timelapse=timelapse) + images = images.squeeze() images = np.array(images) - return images \ No newline at end of file + return images diff --git a/cellacdc/prompts.py b/cellacdc/prompts.py index 86e20a3cc..4f1228dcd 100755 --- a/cellacdc/prompts.py +++ b/cellacdc/prompts.py @@ -13,7 +13,11 @@ if GUI_INSTALLED: from qtpy.QtWidgets import ( - QApplication, QPushButton, QHBoxLayout, QLabel, QSizePolicy + QApplication, + QPushButton, + QHBoxLayout, + QLabel, + QSizePolicy, ) from qtpy.QtCore import Qt from qtpy.QtGui import QFont @@ -22,6 +26,7 @@ from . import myutils, printl, html_utils, load from . import settings_folderpath + class select_channel_name: def __init__(self, which_channel=None, allow_abort=True): self.is_first_call = True @@ -31,30 +36,28 @@ def __init__(self, which_channel=None, allow_abort=True): self.allow_abort = allow_abort def _get_available_channels_from_metadata( - self, metadata_csv_path, filenames, channelExt - ): + self, metadata_csv_path, filenames, channelExt + ): df = pd.read_csv(metadata_csv_path) basename = None channel_names = None - if 'Description' not in df.columns: + if "Description" not in df.columns: return [] - channelNamesMask = df.Description.str.contains(r'channel_\d+_name') - channelNames = df[channelNamesMask]['values'].to_list() + channelNamesMask = df.Description.str.contains(r"channel_\d+_name") + channelNames = df[channelNamesMask]["values"].to_list() try: - basename = df.set_index('Description').at['basename', 'values'] + basename = df.set_index("Description").at["basename", "values"] except Exception as e: basename = None if channelNames: - # There are channel names in metadata --> check that they + # There are channel names in metadata --> check that they # are still existing as files channel_names = channelNames.copy() for chName in channelNames: chSaved = [] for file in filenames: - patterns = ( - f'{chName}.tif', f'{chName}_aligned.npz' - ) + patterns = (f"{chName}.tif", f"{chName}_aligned.npz") ends = [p for p in patterns if file.endswith(p)] if ends: pattern = ends[0] @@ -77,36 +80,40 @@ def _get_available_channels_from_metadata( if channel_names is None or basename is None: return [] - + # Add additional channels existing as file but not in metadata.csv for file in filenames: ends = [ - ext for ext in channelExt if (file.endswith(ext) - and not file.endswith('btrack_tracks.h5')) - and not file.endswith('edited.h5') + ext + for ext in channelExt + if (file.endswith(ext) and not file.endswith("btrack_tracks.h5")) + and not file.endswith("edited.h5") ] if ends: - endName = file[len(basename):] - chName = endName.replace(ends[0], '') + endName = file[len(basename) :] + chName = endName.replace(ends[0], "") if chName not in channel_names: channel_names.append(chName) - + channel_names = natsorted(channel_names) - + return channel_names - + def get_available_channels( - self, filenames, images_path, useExt=None, - channelExt=('.tif', '_aligned.npz'), - validEndnames=('aligned.npz', 'acdc_output.csv', 'segm.npz') - ): + self, + filenames, + images_path, + useExt=None, + channelExt=(".tif", "_aligned.npz"), + validEndnames=("aligned.npz", "acdc_output.csv", "segm.npz"), + ): # First check if metadata.csv already has the channel names metadata_csv_path = None for file in myutils.listdir(images_path): - if file.endswith('metadata.csv'): + if file.endswith("metadata.csv"): metadata_csv_path = os.path.join(images_path, file) break - + chNames_found = False channel_names = set() basename = None @@ -116,7 +123,7 @@ def get_available_channels( ) if channel_names: return channel_names, False - + # Find basename as intersection of filenames channel_names = set() self.basenameNotFound = False @@ -130,14 +137,13 @@ def get_available_channels( validFile = False if useExt is None: validFile = True - elif ext in useExt and not file.endswith('btrack_tracks.h5'): + elif ext in useExt and not file.endswith("btrack_tracks.h5"): validFile = True elif any([file.endswith(end) for end in validEndnames]): validFile = True else: - validFile = ( - (file.find('_acdc_output_') != -1 and ext == '.csv') - or (file.find('_segm_') != -1 and ext == '.npz') + validFile = (file.find("_acdc_output_") != -1 and ext == ".csv") or ( + file.find("_segm_") != -1 and ext == ".npz" ) if not validFile: continue @@ -145,29 +151,29 @@ def get_available_channels( i, j, k = sm.find_longest_match(0, len(file), 0, len(basename)) if i > 0: continue - basename = file[i:i+k] + basename = file[i : i + k] self.basename = basename - + basenameNotFound = [False] for file in filenames: - if file.endswith('edited.h5'): + if file.endswith("edited.h5"): continue - - if file.endswith('btrack_tracks.h5'): + + if file.endswith("btrack_tracks.h5"): continue - + filename, ext = os.path.splitext(file) validImageFile = False if ext in channelExt: validImageFile = True - elif file.endswith('aligned.npz'): + elif file.endswith("aligned.npz"): validImageFile = True - filename = filename[:-len('_aligned')] - + filename = filename[: -len("_aligned")] + if not validImageFile: continue - - channel_name = filename.split(basename)[-1] + + channel_name = filename.split(basename)[-1] channel_names.add(channel_name) if channel_name == filename: # Warn that an intersection could not be found @@ -176,26 +182,29 @@ def get_available_channels( if any(basenameNotFound): self.basenameNotFound = True filenameNOext, _ = os.path.splitext(basename) - self.basename = f'{filenameNOext}_' + self.basename = f"{filenameNOext}_" if self.which_channel is not None: # Search for "phase" and put that channel first on the list - if self.which_channel == 'segm': - is_phase_contr_li = [c.lower().find('phase')!=-1 - for c in channel_names] + if self.which_channel == "segm": + is_phase_contr_li = [ + c.lower().find("phase") != -1 for c in channel_names + ] if any(is_phase_contr_li): idx = is_phase_contr_li.index(True) channel_names[0], channel_names[idx] = ( - channel_names[idx], channel_names[0]) - + channel_names[idx], + channel_names[0], + ) + channel_names = natsorted(channel_names) - + return channel_names, any(basenameNotFound) def _load_last_selection(self): last_sel_channel = None ch = self.which_channel if self.which_channel is not None: - txt_path = os.path.join(settings_folderpath, f'{ch}_last_sel.txt') + txt_path = os.path.join(settings_folderpath, f"{ch}_last_sel.txt") if os.path.exists(txt_path): with open(txt_path) as txt: last_sel_channel = txt.read() @@ -206,20 +215,21 @@ def _save_last_selection(self, selection): if self.which_channel is not None: if not os.path.exists(settings_folderpath): os.mkdir(settings_folderpath) - txt_path = os.path.join(settings_folderpath, f'{ch}_last_sel.txt') - with open(txt_path, 'w') as txt: + txt_path = os.path.join(settings_folderpath, f"{ch}_last_sel.txt") + with open(txt_path, "w") as txt: txt.write(selection) - + def askChannelName(self, filenames, images_path, ask, ch_names): from . import apps + if not ask: return ch_names filename = self.basename possibleChannelNames = [] - splits = [split for split in filename.split('_') if split] + splits = [split for split in filename.split("_") if split] possibleChannelNames = [] - for i in range(len(splits)-1): - possibleChanneName = '_'.join(splits[i+1:]) + for i in range(len(splits) - 1): + possibleChanneName = "_".join(splits[i + 1 :]) possibleChannelNames.append(possibleChanneName) possibleChannelNames = possibleChannelNames[::-1] @@ -230,8 +240,12 @@ def askChannelName(self, filenames, images_path, ask, ch_names): Filename: {filename} """) win = apps.QDialogCombobox( - 'Select channel name', possibleChannelNames, txt, - CbLabel='Select channel name: ', parent=None, centeredCombobox=True + "Select channel name", + possibleChannelNames, + txt, + CbLabel="Select channel name: ", + parent=None, + centeredCombobox=True, ) win.exec_() if win.cancel: @@ -244,24 +258,29 @@ def askChannelName(self, filenames, images_path, ask, ch_names): df_metadata, metadata_csv_path = load.get_posData_metadata( images_path, basename ) - df_metadata.at['channel_0_name', 'values'] = channel_name + df_metadata.at["channel_0_name", "values"] = channel_name df_metadata.to_csv(metadata_csv_path) ch_names, _ = self.get_available_channels(filenames, images_path) return ch_names - - def QtPrompt(self, parent, channel_names, informativeText='', - CbLabel='Select channel name: '): + def QtPrompt( + self, + parent, + channel_names, + informativeText="", + CbLabel="Select channel name: ", + ): from . import apps + font = QFont() font.setPixelSize(13) win = apps.QDialogCombobox( - 'Select channel name', + "Select channel name", channel_names, informativeText, CbLabel=CbLabel, parent=parent, - defaultChannelName=self.last_sel_channel + defaultChannelName=self.last_sel_channel, ) win.setFont(font) win.exec_() @@ -274,7 +293,7 @@ def QtPrompt(self, parent, channel_names, informativeText='', def setUserChannelName(self): if self.basenameNotFound: reverse_ch_name = self.channel_name[::-1] - idx = reverse_ch_name.find('_') + idx = reverse_ch_name.find("_") if idx != -1: self.user_ch_name = self.channel_name[-idx:] else: @@ -288,57 +307,63 @@ def _test(self, name=None, index=None, mode=None): def _abort(self): self.was_aborted = True if self.allow_abort: - exit('Execution aborted by the user') + exit("Execution aborted by the user") + def exportToImageFinished(filepath, qparent=None): from cellacdc import widgets - - txt = 'Exporting to image done!' - txt = f'{txt}

    Files were saved here:' - + + txt = "Exporting to image done!" + txt = f"{txt}

    Files were saved here:" + txt = html_utils.paragraph(txt) msg = widgets.myMessageBox(wrapText=False) msg.information( - qparent, 'Exporting image finished', txt, - commands=(filepath,), - path_to_browse=os.path.dirname(filepath) + qparent, + "Exporting image finished", + txt, + commands=(filepath,), + path_to_browse=os.path.dirname(filepath), ) -def exportToVideoFinished( - preferences, conversion_to_mp4_successful, qparent=None - ): + +def exportToVideoFinished(preferences, conversion_to_mp4_successful, qparent=None): from cellacdc import widgets - - txt = 'Exporting to video finished!' - - msg_type = 'information' + + txt = "Exporting to video finished!" + + msg_type = "information" if not conversion_to_mp4_successful: from . import urls - github_href = html_utils.href_tag('GitHub page', urls.issues_url) - msg_type = 'warning' + + github_href = html_utils.href_tag("GitHub page", urls.issues_url) + msg_type = "warning" txt = ( - f'{txt}

    ' - 'WARNING: Conversion to MP4 failed. ' - 'Video file was saved as AVI instead. ' - f'Feel free to report the issue on our {github_href}' + f"{txt}

    " + "WARNING: Conversion to MP4 failed. " + "Video file was saved as AVI instead. " + f"Feel free to report the issue on our {github_href}" ) - - txt = f'{txt}

    Files were saved here:' - + + txt = f"{txt}

    Files were saved here:" + txt = html_utils.paragraph(txt) - - - folderpath = os.path.dirname(preferences['filepath']) - commands = [preferences['filepath']] - if preferences['save_pngs']: - commands.append(preferences['pngs_folderpath']) - + + folderpath = os.path.dirname(preferences["filepath"]) + commands = [preferences["filepath"]] + if preferences["save_pngs"]: + commands.append(preferences["pngs_folderpath"]) + msg = widgets.myMessageBox(wrapText=False) getattr(msg, msg_type)( - qparent, 'Exporting video finished', txt, - commands=commands, path_to_browse=folderpath + qparent, + "Exporting video finished", + txt, + commands=commands, + path_to_browse=folderpath, ) + def askSamSaveEmbeddings(qparent=None): txt = html_utils.paragraph(""" Segment Anything Model generates image embeddings that you @@ -347,24 +372,31 @@ def askSamSaveEmbeddings(qparent=None): prompts).

    Do you want to save the image embeddings? """) - saveOnlyButton = widgets.BedPushButton('Save only embeddings') - saveButton = widgets.BedPlusLabelPushButton('Save also embeddings') - saveOnlyButton = widgets.BedPushButton('Save only embeddings') + saveOnlyButton = widgets.BedPushButton("Save only embeddings") + saveButton = widgets.BedPlusLabelPushButton("Save also embeddings") + saveOnlyButton = widgets.BedPushButton("Save only embeddings") msg = widgets.myMessageBox(wrapText=False) _, saveOnlyButton, saveButton, _ = msg.question( - qparent, 'Save SAM Image Embeddings?', txt, + qparent, + "Save SAM Image Embeddings?", + txt, buttonsTexts=( - 'Cancel', saveOnlyButton, saveButton, - widgets.NoBedPushButton('Do not save embeddings') - ) + "Cancel", + saveOnlyButton, + saveButton, + widgets.NoBedPushButton("Do not save embeddings"), + ), ) sam_only_embeddings = msg.clickedButton == saveOnlyButton sam_also_embeddings = msg.clickedButton == saveButton return sam_only_embeddings, sam_also_embeddings, msg.cancel + def askSamLoadEmbeddings( - sam_embeddings_path, qparent=None, is_gui_caller=False, - ): + sam_embeddings_path, + qparent=None, + is_gui_caller=False, +): txt = html_utils.paragraph(""" Cell-ACDC detected previously saved Segment Anything image embeddings (see file path below).

    @@ -372,18 +404,20 @@ def askSamLoadEmbeddings( Do you want to load the image embeddings? """) msg = widgets.myMessageBox(wrapText=False) - loadButton = widgets.BedPlusLabelPushButton('Load embeddings and segment') - doNotLoadButton = widgets.NoBedPushButton('Do not load embeddings') + loadButton = widgets.BedPlusLabelPushButton("Load embeddings and segment") + doNotLoadButton = widgets.NoBedPushButton("Do not load embeddings") buttons = (loadButton, doNotLoadButton) if is_gui_caller: - loadOnlyEmbedButton = widgets.BedPushButton('Only load embeddings') + loadOnlyEmbedButton = widgets.BedPushButton("Only load embeddings") buttons = (loadOnlyEmbedButton, *buttons) - + msg.question( - qparent, 'Load SAM Image Embeddings?', txt, - buttonsTexts=('Cancel', *buttons), - commands=(sam_embeddings_path,), - path_to_browse=os.path.dirname(sam_embeddings_path) + qparent, + "Load SAM Image Embeddings?", + txt, + buttonsTexts=("Cancel", *buttons), + commands=(sam_embeddings_path,), + path_to_browse=os.path.dirname(sam_embeddings_path), ) loadEmbed = msg.clickedButton == loadButton onlyLoadEmbed = False @@ -391,77 +425,86 @@ def askSamLoadEmbeddings( onlyLoadEmbed = msg.clickedButton == loadOnlyEmbedButton return loadEmbed, onlyLoadEmbed, msg.cancel + def init_prompt_model_params( - posData, model_name, init_params, segment_params, - qparent=None, help_url=None, init_last_params=False, - ini_filename=None - ): + posData, + model_name, + init_params, + segment_params, + qparent=None, + help_url=None, + init_last_params=False, + ini_filename=None, +): out = {} - + segm_files = load.get_segm_files(posData.images_path) - existingSegmEndnames = load.get_endnames( - posData.basename, segm_files - ) + existingSegmEndnames = load.get_endnames(posData.basename, segm_files) win = apps.QDialogModelParams( init_params, segment_params, - model_name, + model_name, parent=qparent, - url=help_url, - initLastParams=init_last_params, + url=help_url, + initLastParams=init_last_params, posData=posData, segmFileEndnames=existingSegmEndnames, df_metadata=posData.metadata_df, addPreProcessParams=False, addPostProcessParams=False, ini_filename=ini_filename, - add_additional_segm_params=False + add_additional_segm_params=False, ) win.setChannelNames(posData.chNames) - out['win'] = win + out["win"] = win win.exec_() return out + def init_segm_model_params( - posData, model_name, init_params, segment_params, - qparent=None, help_url=None, init_last_params=False, - check_sam_embeddings=True, is_gui_caller=False, - extraParams=None, extraParamsTitle=None, - ini_filename=None, add_additional_segm_params=False - ): + posData, + model_name, + init_params, + segment_params, + qparent=None, + help_url=None, + init_last_params=False, + check_sam_embeddings=True, + is_gui_caller=False, + extraParams=None, + extraParamsTitle=None, + ini_filename=None, + add_additional_segm_params=False, +): out = {} - - is_sam_model = ( - model_name in ('segment_anything', 'sam2') and check_sam_embeddings - ) - + + is_sam_model = model_name in ("segment_anything", "sam2") and check_sam_embeddings + # If SAM with prompts and embeddings were prev saved, asks to load them load_sam_embed = False only_load_sam_embed = False sam_embeddings_exist = os.path.exists(posData.sam_embeddings_path) - sam_embeddings_loaded = hasattr(posData, 'sam_embeddings') + sam_embeddings_loaded = hasattr(posData, "sam_embeddings") if is_sam_model and sam_embeddings_exist and not sam_embeddings_loaded: load_sam_embed, only_load_sam_embed, cancel = askSamLoadEmbeddings( - posData.sam_embeddings_path, qparent=qparent, - is_gui_caller=is_gui_caller + posData.sam_embeddings_path, qparent=qparent, is_gui_caller=is_gui_caller ) if cancel: return out - - out['load_sam_embeddings'] = only_load_sam_embed or load_sam_embed + + out["load_sam_embeddings"] = only_load_sam_embed or load_sam_embed if only_load_sam_embed: return out - + segm_files = load.get_segm_files(posData.images_path) - existingSegmEndnames = load.get_endnames( - posData.basename, segm_files - ) + existingSegmEndnames = load.get_endnames(posData.basename, segm_files) win = apps.QDialogModelParams( init_params, segment_params, - model_name, parent=qparent, - url=help_url, - initLastParams=init_last_params, + model_name, + parent=qparent, + url=help_url, + initLastParams=init_last_params, posData=posData, segmFileEndnames=existingSegmEndnames, df_metadata=posData.metadata_df, @@ -469,35 +512,32 @@ def init_segm_model_params( extraParams=extraParams, extraParamsTitle=extraParamsTitle, ini_filename=ini_filename, - add_additional_segm_params=add_additional_segm_params + add_additional_segm_params=add_additional_segm_params, ) win.setChannelNames(posData.chNames) - out['win'] = win + out["win"] = win win.exec_() if win.cancel: return out - + if load_sam_embed: - win.model_kwargs['use_loaded_embeddings'] = True + win.model_kwargs["use_loaded_embeddings"] = True posData.loadSamEmbeddings() - + ask_sam_embeddings = ( - model_name in ('segment_anything', 'sam2') + model_name in ("segment_anything", "sam2") and not load_sam_embed and check_sam_embeddings ) # If SAM and embeddings were not laoded, asks to save them if ask_sam_embeddings: - sam_only_embeddings, sam_also_embeddings, cancel = ( - askSamSaveEmbeddings(qparent=qparent) + sam_only_embeddings, sam_also_embeddings, cancel = askSamSaveEmbeddings( + qparent=qparent ) if cancel: return out - win.model_kwargs['only_embeddings'] = sam_only_embeddings - win.model_kwargs['save_embeddings'] = ( - sam_only_embeddings or sam_also_embeddings - ) - + win.model_kwargs["only_embeddings"] = sam_only_embeddings + win.model_kwargs["save_embeddings"] = sam_only_embeddings or sam_also_embeddings + return out - \ No newline at end of file diff --git a/cellacdc/qrc_resources_dark.py b/cellacdc/qrc_resources_dark.py index 4a7518461..f15931e41 100644 --- a/cellacdc/qrc_resources_dark.py +++ b/cellacdc/qrc_resources_dark.py @@ -338070,7 +338070,7 @@ \x00\x00\x01\x97\x4b\x92\x55\xaa\ " -qt_version = [int(v) for v in QtCore.qVersion().split('.')] +qt_version = [int(v) for v in QtCore.qVersion().split(".")] if qt_version < [5, 8, 0]: rcc_version = 1 qt_resource_struct = qt_resource_struct_v1 @@ -338078,10 +338078,17 @@ rcc_version = 2 qt_resource_struct = qt_resource_struct_v2 + def qInitResources(): - QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qRegisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) + def qCleanupResources(): - QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qUnregisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) + qInitResources() diff --git a/cellacdc/qrc_resources_light.py b/cellacdc/qrc_resources_light.py index 4a977f553..21d48ba99 100644 --- a/cellacdc/qrc_resources_light.py +++ b/cellacdc/qrc_resources_light.py @@ -337032,7 +337032,7 @@ \x00\x00\x01\x97\x4b\x92\x55\xaa\ " -qt_version = [int(v) for v in QtCore.qVersion().split('.')] +qt_version = [int(v) for v in QtCore.qVersion().split(".")] if qt_version < [5, 8, 0]: rcc_version = 1 qt_resource_struct = qt_resource_struct_v1 @@ -337040,10 +337040,17 @@ rcc_version = 2 qt_resource_struct = qt_resource_struct_v2 + def qInitResources(): - QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qRegisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) + def qCleanupResources(): - QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qUnregisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) + qInitResources() diff --git a/cellacdc/qutils.py b/cellacdc/qutils.py index 721eef9d0..352fd66a3 100644 --- a/cellacdc/qutils.py +++ b/cellacdc/qutils.py @@ -1,13 +1,10 @@ -from qtpy.QtCore import ( - Qt, QTimer, QEventLoop -) +from qtpy.QtCore import Qt, QTimer, QEventLoop from qtpy.QtWidgets import QWidget import functools + class QWhileLoop: - def __init__( - self, loop_callback, period=100, max_duration=None - ): + def __init__(self, loop_callback, period=100, max_duration=None): self._loop_callback = loop_callback self._period = period self._max_duration = max_duration @@ -22,20 +19,21 @@ def exec_(self): self.max_duration_timer.timeout.connect(self.stop) self.max_duration_timer.start(self._max_duration) self.loop.exec_() - + def stop(self): self.timer.stop() if self._max_duration is not None: self.max_duration_timer.stop() self.loop.exit() + class QControlBlink: def __init__(self, QWidgetToBlink: QWidget, duration_ms=2000, qparent=None) -> None: self.duration_ms = duration_ms self._widget = QWidgetToBlink self.qparent = qparent self.blinkON = False - + def start(self): self.timer = QTimer(self.qparent) self.timer.timeout.connect(self.timerCallback) @@ -44,17 +42,18 @@ def start(self): self.stopTimer = QTimer(self.qparent) self.stopTimer.timeout.connect(self.stop) self.stopTimer.start(self.duration_ms) - + def timerCallback(self): if self.blinkON: - self._widget.setStyleSheet('background-color: orange') + self._widget.setStyleSheet("background-color: orange") else: - self._widget.setStyleSheet('background-color: none') + self._widget.setStyleSheet("background-color: none") self.blinkON = not self.blinkON def stop(self): self.timer.stop() - self._widget.setStyleSheet('background-color: none') + self._widget.setStyleSheet("background-color: none") + def hide_and_delete_layout(layout): # Hide all widgets in the layout @@ -64,20 +63,23 @@ def hide_and_delete_layout(layout): widget.hide() layout.removeWidget(widget) widget.setParent(None) - + # Delete the layout layout.deleteLater() + def delete_widget(widget): widget.hide() widget.setParent(None) widget.deleteLater() + def replace_certain_vals(getVal, replace_val, by_val): """ Decorator: If the return value of getVal equals replace_val (type-cast to value's type), return by_val instead. Otherwise, return the original value. """ + @functools.wraps(getVal) def wrapper(*args, **kwargs): value = getVal(*args, **kwargs) @@ -88,13 +90,16 @@ def wrapper(*args, **kwargs): if value == target_val: return by_val return value + return wrapper + def set_value_no_signals(widget, value): was_blocked = widget.blockSignals(True) widget.setValue(value) widget.blockSignals(was_blocked) + def set_exclusive_valueSetter(widget, valueSetter, value): was_blocked = widget.blockSignals(True) try: @@ -103,6 +108,7 @@ def set_exclusive_valueSetter(widget, valueSetter, value): valueSetter(value) widget.blockSignals(was_blocked) + def hardDelete(item, setPosData=True): try: item.setParent(None) @@ -118,9 +124,10 @@ def hardDelete(item, setPosData=True): except AttributeError: pass item = None - + + def insert_row(layout, insert_at, new_widget, col=0, dont_shift_other_cols=False): -# Shift all widgets down by one row from insert_at onwards + # Shift all widgets down by one row from insert_at onwards for row in range(layout.rowCount() - 1, insert_at - 1, -1): for loc_col in range(layout.columnCount()): if loc_col != col and dont_shift_other_cols: @@ -129,4 +136,4 @@ def insert_row(layout, insert_at, new_widget, col=0, dont_shift_other_cols=False if item is not None: layout.removeItem(item) layout.addItem(item, row + 1, loc_col) - layout.addWidget(new_widget, insert_at, col) \ No newline at end of file + layout.addWidget(new_widget, insert_at, col) diff --git a/cellacdc/record.py b/cellacdc/record.py index 094ad654a..e97d84f1e 100644 --- a/cellacdc/record.py +++ b/cellacdc/record.py @@ -13,6 +13,7 @@ from .. import user_data_folderpath from .. import workers + class ScreenRecorderFrame(QFrame): def __init__(self, app, parent=None): super().__init__(parent) @@ -21,14 +22,14 @@ def __init__(self, app, parent=None): # Border tolerance to trigger resizing self.px = 10 self.app = app - + def mousePressEvent(self, event): x, y = event.pos().x(), event.pos().y() # x00, y00 = self._parent.x0-self.px, self._parent.y0-self.px - x01, y01 = self._parent.x0+self.px, self._parent.y0+self.px - x10, y10 = self._parent.x1-self.px, self._parent.y1-self.px + x01, y01 = self._parent.x0 + self.px, self._parent.y0 + self.px + x10, y10 = self._parent.x1 - self.px, self._parent.y1 - self.px # x11, y11 = self._parent.x1+self.px, self._parent.y1+self.px - if yy01 and xx01: + if y < y10 and y > y01 and x < x10 and x > x01: # Cursor click inside rectangle self.app.setOverrideCursor(Qt.ClosedHandCursor) self.xc, self.yc = x, y @@ -40,56 +41,55 @@ def mouseMoveEvent(self, event): return x, y = event.pos().x(), event.pos().y() - x00, y00 = self._parent.x0-self.px, self._parent.y0-self.px - x01, y01 = self._parent.x0+self.px, self._parent.y0+self.px - x10, y10 = self._parent.x1-self.px, self._parent.y1-self.px - x11, y11 = self._parent.x1+self.px, self._parent.y1+self.px - if yy01 and xx01: + x00, y00 = self._parent.x0 - self.px, self._parent.y0 - self.px + x01, y01 = self._parent.x0 + self.px, self._parent.y0 + self.px + x10, y10 = self._parent.x1 - self.px, self._parent.y1 - self.px + x11, y11 = self._parent.x1 + self.px, self._parent.y1 + self.px + if y < y10 and y > y01 and x < x10 and x > x01: # Cursor inside rectangle self.app.setOverrideCursor(Qt.OpenHandCursor) - elif yy00 and xx00: + elif y < y11 and y > y00 and x < x11 and x > x00: # Cursor on border --> determine if ver, hor or diags - if xy10: + self.corner = "topLeft" + elif x < x01 and y > y10: # Bottom left corner self.app.setOverrideCursor(Qt.SizeBDiagCursor) - self.corner = 'bottomLeft' - elif x>x10 and y x10 and y < y01: # Top right corner self.app.setOverrideCursor(Qt.SizeBDiagCursor) - self.corner = 'topRight' - elif x>x10 and y>y10: + self.corner = "topRight" + elif x > x10 and y > y10: # Bottom right corner self.app.setOverrideCursor(Qt.SizeFDiagCursor) - self.corner = 'bottomRight' - elif xx10: + self.corner = "bottomRight" + elif x < x01 or x > x10: # Left or right side self.app.setOverrideCursor(Qt.SizeHorCursor) - if x xmax: x = xmax - + if y < xmin: y = ymin elif y > ymax: y = ymax - - return x, y - + + return x, y + def mouseMoveEvent(self, event): x, y = event.pos().x(), event.pos().y() x, y = self.boundXYtoScreen(x, y) if self.app.overrideCursor() == Qt.SizeFDiagCursor: - if self.frame.corner == 'topLeft': + if self.frame.corner == "topLeft": self.x0, self.y0 = x, y self.update() else: @@ -201,7 +202,7 @@ def mouseMoveEvent(self, event): self.x1, self.y1 = x, y self.update() elif self.app.overrideCursor() == Qt.SizeBDiagCursor: - if self.frame.corner == 'bottomLeft': + if self.frame.corner == "bottomLeft": self.x0, self.y1 = x, y self.update() else: @@ -209,23 +210,23 @@ def mouseMoveEvent(self, event): self.x1, self.y0 = x, y self.update() elif self.app.overrideCursor() == Qt.SizeHorCursor: - if self.frame.corner == 'left': + if self.frame.corner == "left": self.x0 = x self.update() else: self.x1 = x self.update() elif self.app.overrideCursor() == Qt.SizeVerCursor: - if self.frame.corner == 'top': + if self.frame.corner == "top": self.y0 = y self.update() else: self.y1 = y self.update() elif self.app.overrideCursor() == Qt.ClosedHandCursor: - deltax, deltay = x-self.frame.xc, y-self.frame.yc - self.x0, self.y0 = self.x0+deltax, self.y0+deltay - self.x1, self.y1 = self.x1+deltax, self.y1+deltay + deltax, deltay = x - self.frame.xc, y - self.frame.yc + self.x0, self.y0 = self.x0 + deltax, self.y0 + deltay + self.x1, self.y1 = self.x1 + deltax, self.y1 + deltay self.frame.xc, self.frame.yc = x, y self.update() @@ -237,9 +238,7 @@ def mouseReleaseEvent(self, event): def startRecorder(self): self.thread = QThread() - self.screenGrabWorker = workers.ScreenRecorderWorker( - self, user_data_folderpath - ) + self.screenGrabWorker = workers.ScreenRecorderWorker(self, user_data_folderpath) self.screenGrabWorker.moveToThread(self.thread) self.screenGrabWorker.finished.connect(self.thread.quit) @@ -248,7 +247,7 @@ def startRecorder(self): self.thread.started.connect(self.screenGrabWorker.run) self.thread.start() - print('Recording started...') + print("Recording started...") def keyPressEvent(self, event): if event.key() == Qt.Key_Escape: diff --git a/cellacdc/resources/to_dark_mode_svg.py b/cellacdc/resources/to_dark_mode_svg.py index 896cc6145..418995d25 100644 --- a/cellacdc/resources/to_dark_mode_svg.py +++ b/cellacdc/resources/to_dark_mode_svg.py @@ -4,8 +4,8 @@ from tqdm import tqdm LIGHT_TO_DARK_MAPPER = { - '#666666': '#9a9a9a', - '#4d4d4d': '#f0f0f0', + "#666666": "#9a9a9a", + "#4d4d4d": "#f0f0f0", # '#d9d9d9': '#4d4d4d', } @@ -13,50 +13,55 @@ # Read resources_light.qrc file and extract SVG relative paths resources_folderpath = os.path.dirname(os.path.abspath(__file__)) cellacdc_path = os.path.dirname(resources_folderpath) -resources_filepath = os.path.join(cellacdc_path, 'resources_light.qrc') +resources_filepath = os.path.join(cellacdc_path, "resources_light.qrc") -qrc_resources_light_path = os.path.join(cellacdc_path, 'qrc_resources_light.py') -qrc_resources_dark_path = os.path.join(cellacdc_path, 'qrc_resources_dark.py') -qrc_resources_path = os.path.join(cellacdc_path, 'qrc_resources.py') +qrc_resources_light_path = os.path.join(cellacdc_path, "qrc_resources_light.py") +qrc_resources_dark_path = os.path.join(cellacdc_path, "qrc_resources_dark.py") +qrc_resources_path = os.path.join(cellacdc_path, "qrc_resources.py") if os.path.exists(qrc_resources_light_path): os.rename(qrc_resources_path, qrc_resources_dark_path) os.rename(qrc_resources_light_path, qrc_resources_path) - -with open(resources_filepath, 'r') as resources_file: + +with open(resources_filepath, "r") as resources_file: resources_txt = resources_file.read() resources_dark_txt = resources_txt svg_relpaths = re.findall(r'(.+)', resources_txt) -# Iterate SVGs and replace colors +# Iterate SVGs and replace colors for svg_relpath in tqdm(svg_relpaths, ncols=100): - svg_relpath_parts = svg_relpath.split('/') + svg_relpath_parts = svg_relpath.split("/") svg_abspath = os.path.join(cellacdc_path, *svg_relpath_parts) svg_folderpath = os.path.dirname(svg_abspath) - if 'icons' not in svg_relpath_parts: + if "icons" not in svg_relpath_parts: # Skip SVGs outside of the icons folder continue - + # Read svg files and replace colors - with open(svg_abspath, 'r', encoding="utf8") as svg_file: + with open(svg_abspath, "r", encoding="utf8") as svg_file: svg_text = svg_file.read() for light_hex, dark_hex in LIGHT_TO_DARK_MAPPER.items(): svg_text_dark = svg_text.replace(light_hex, dark_hex) - + # Save additional _dark.svg and replace them in resources_txt - svg_dark_abspath = svg_abspath.replace('.svg', '_dark.svg') - with open(svg_dark_abspath, 'w', encoding="utf8") as svg_file: + svg_dark_abspath = svg_abspath.replace(".svg", "_dark.svg") + with open(svg_dark_abspath, "w", encoding="utf8") as svg_file: svg_file.write(svg_text_dark) - svg_relpath_dark = svg_relpath.replace('.svg', '_dark.svg') + svg_relpath_dark = svg_relpath.replace(".svg", "_dark.svg") resources_txt = resources_txt.replace(svg_relpath, svg_relpath_dark) # Save a new resouces_dark.qrc file -with open(qrc_resources_dark_path, 'w') as resources_file: +with open(qrc_resources_dark_path, "w") as resources_file: resources_file.write(resources_txt) # Compule new qrc_resources.py dark -print('Compiling the Qt resource file...') -qrc_resources_dark_filepath = os.path.join(cellacdc_path, 'qrc_resources_dark.py') -commands = ['pyrcc5', f"{qrc_resources_dark_path}", '-o', f"{qrc_resources_dark_filepath}"] -subprocess.run(commands, check=True) \ No newline at end of file +print("Compiling the Qt resource file...") +qrc_resources_dark_filepath = os.path.join(cellacdc_path, "qrc_resources_dark.py") +commands = [ + "pyrcc5", + f"{qrc_resources_dark_path}", + "-o", + f"{qrc_resources_dark_filepath}", +] +subprocess.run(commands, check=True) diff --git a/cellacdc/scripts/correct_shift_X.py b/cellacdc/scripts/correct_shift_X.py index 3d31db1dd..1b4ccfaa0 100644 --- a/cellacdc/scripts/correct_shift_X.py +++ b/cellacdc/scripts/correct_shift_X.py @@ -19,124 +19,152 @@ import json - PREVIEW_Z_STACK = None PREVIEW_Z = None NEW_PATH_SUF = None INCLUDE_PATTERN_TIF_SEARCH = None + def load_constants(): - print('Loading constants...') + print("Loading constants...") global PREVIEW_Z_STACK global PREVIEW_Z global INCLUDE_PATTERN_TIF_SEARCH - with open('regex.txt', 'r') as input_file: + with open("regex.txt", "r") as input_file: regex_file = input_file.read() for line in regex_file.splitlines(): - if re.search('x_INCLUDE_PATTERN_TIF_SEARCH', line): - line = line.split(':', 1)[1].strip().lstrip().rstrip(',') + if re.search("x_INCLUDE_PATTERN_TIF_SEARCH", line): + line = line.split(":", 1)[1].strip().lstrip().rstrip(",") INCLUDE_PATTERN_TIF_SEARCH = line - with open('config.json', 'r') as input_file: + with open("config.json", "r") as input_file: config = json.load(input_file) - PREVIEW_Z_STACK = config['correct_shift_x']['PREVIEW_Z_STACK'] - PREVIEW_Z = config['correct_shift_x']['PREVIEW_Z'] - NEW_PATH_SUF = config['correct_shift_x']['NEW_PATH_SUF'] + PREVIEW_Z_STACK = config["correct_shift_x"]["PREVIEW_Z_STACK"] + PREVIEW_Z = config["correct_shift_x"]["PREVIEW_Z"] + NEW_PATH_SUF = config["correct_shift_x"]["NEW_PATH_SUF"] return NEW_PATH_SUF + def correct_constant_shift_X_img(img, shift): for i, row in enumerate(img[::2]): - l = i*2 + l = i * 2 img[l] = np.roll(row, shift) return img + def correct_constant_shift_X(z_stack, shift): for z, img in enumerate(z_stack): img = correct_constant_shift_X_img(img, shift) z_stack[z] = img return z_stack + def find_other_tif(file_path): folder_path = os.path.dirname(file_path) file_list = os.listdir(folder_path) - tif_files = [filename for filename in file_list if filename.lower().endswith('.tif')] + tif_files = [ + filename for filename in file_list if filename.lower().endswith(".tif") + ] return tif_files + def finding_shift(tif_data, shift, NEW_PATH_SUF): eval_img = (tif_data[PREVIEW_Z_STACK][PREVIEW_Z]).copy() - eval_img = correct_constant_shift_X_img(eval_img, shift) + eval_img = correct_constant_shift_X_img(eval_img, shift) imshow(tif_data[PREVIEW_Z_STACK][PREVIEW_Z], eval_img) while True: - answer = input('Do you want to proceed with the shift or change it ([y]/n/"number"/help)? ') - if answer.lower() == 'n': + answer = input( + 'Do you want to proceed with the shift or change it ([y]/n/"number"/help)? ' + ) + if answer.lower() == "n": exit() elif answer.isdigit(): shift = int(answer) shift = finding_shift(tif_data, shift, NEW_PATH_SUF) return shift - elif answer.lstrip('-').isdigit(): + elif answer.lstrip("-").isdigit(): shift = int(answer) shift = finding_shift(tif_data, shift, NEW_PATH_SUF) return shift - elif answer.lower() == 'help': - print('Change the shown image by changing PREVIEW_Z_STACK and PREVIEW_Z in the beginning of the code. \nChange the ending of the new file name by changing NEW_PATH_SUF in the code. \nCurrent z stack and z displayed: ' + str(PREVIEW_Z_STACK) + ' ' +str(PREVIEW_Z) + '\nCurrent ending: ' + NEW_PATH_SUF) + elif answer.lower() == "help": + print( + "Change the shown image by changing PREVIEW_Z_STACK and PREVIEW_Z in the beginning of the code. \nChange the ending of the new file name by changing NEW_PATH_SUF in the code. \nCurrent z stack and z displayed: " + + str(PREVIEW_Z_STACK) + + " " + + str(PREVIEW_Z) + + "\nCurrent ending: " + + NEW_PATH_SUF + ) finding_shift(tif_data, shift, NEW_PATH_SUF) return shift elif not answer: return shift - elif answer.lower() == 'y': + elif answer.lower() == "y": return shift else: - print('The input is not an integer') - + print("The input is not an integer") + def shiftingstuff_main(shift, tif_data, tif_path, NEW_PATH_SUF): corrected_data = tif_data.copy() for frame_i, img in enumerate(tif_data): corrected_data[frame_i] = correct_constant_shift_X(img.copy(), shift) - new_path = tif_path.replace('.tif', NEW_PATH_SUF + '.tif' ) + new_path = tif_path.replace(".tif", NEW_PATH_SUF + ".tif") skimage.io.imsave(new_path, corrected_data, check_contrast=False) del corrected_data del tif_data return + def shiftingstuff_other(tif_name, shift, tif_path, scan_other, NEW_PATH_SUF): if scan_other == True: - tif_path = os.path.join(os.path.dirname(tif_path), tif_name) + tif_path = os.path.join(os.path.dirname(tif_path), tif_name) tif_data = load.imread(tif_path) shiftingstuff_main(shift, tif_data, tif_path, NEW_PATH_SUF) del tif_data return + def sequential(NEW_PATH_SUF): parser = argparse.ArgumentParser() - parser.add_argument('tif_path', help='Path to the tif-file') - parser.add_argument('shift', help='Amount of shift') + parser.add_argument("tif_path", help="Path to the tif-file") + parser.add_argument("shift", help="Amount of shift") args = parser.parse_args() tif_path = args.tif_path shift = int(args.shift) - print('Path: \n' + tif_path) - print('Original Shift: ' + str(shift)) + print("Path: \n" + tif_path) + print("Original Shift: " + str(shift)) tif_data = load.imread(tif_path) - print('Please close the window after inspecting if the shift value is right in order to proceed.') + print( + "Please close the window after inspecting if the shift value is right in order to proceed." + ) shift = finding_shift(tif_data, shift, NEW_PATH_SUF) - print('Shift used: ' +str(shift)) + print("Shift used: " + str(shift)) - tif_files = find_other_tif(tif_path) - tif_names = [tif_file for tif_file in tif_files if re.match(INCLUDE_PATTERN_TIF_SEARCH, tif_file)] - print('New tif file(s) found:\n' + "\n".join(tif_names)) + tif_files = find_other_tif(tif_path) + tif_names = [ + tif_file + for tif_file in tif_files + if re.match(INCLUDE_PATTERN_TIF_SEARCH, tif_file) + ] + print("New tif file(s) found:\n" + "\n".join(tif_names)) while True: - answer = input('Do you want to shift the other .tif files in the folder too? ([y]/n/help)') - if answer.lower() == 'n': + answer = input( + "Do you want to shift the other .tif files in the folder too? ([y]/n/help)" + ) + if answer.lower() == "n": scan_other = False break - elif answer.lower() == 'help': - print('You can change the regex pattern in the beginning of the code (EXCLUDE_PATTERN_TIF_SEARCH). \nIf you dont know regex, ask Chat_GPT to generate one for you by giving it examples of file names and then asking it to generate a regex code which excludes the files you want to exclude. \nCurrent expression is: ' + INCLUDE_PATTERN_TIF_SEARCH) + elif answer.lower() == "help": + print( + "You can change the regex pattern in the beginning of the code (EXCLUDE_PATTERN_TIF_SEARCH). \nIf you dont know regex, ask Chat_GPT to generate one for you by giving it examples of file names and then asking it to generate a regex code which excludes the files you want to exclude. \nCurrent expression is: " + + INCLUDE_PATTERN_TIF_SEARCH + ) exit() else: scan_other = True @@ -149,8 +177,15 @@ def sequential(NEW_PATH_SUF): shift, tif_data, tif_names, scan_other, tif_path = sequential(NEW_PATH_SUF) with concurrent.futures.ProcessPoolExecutor() as executor: futures = [] - futures = [executor.submit(shiftingstuff_other, tif_name, shift, tif_path, scan_other, NEW_PATH_SUF) for tif_name in tif_names] - futures.append(executor.submit(shiftingstuff_main, shift, tif_data, tif_path, NEW_PATH_SUF)) + futures = [ + executor.submit( + shiftingstuff_other, tif_name, shift, tif_path, scan_other, NEW_PATH_SUF + ) + for tif_name in tif_names + ] + futures.append( + executor.submit(shiftingstuff_main, shift, tif_data, tif_path, NEW_PATH_SUF) + ) results = [future.result() for future in futures] - print('Done!') - exit() \ No newline at end of file + print("Done!") + exit() diff --git a/cellacdc/scripts/correct_shift_X_multi.py b/cellacdc/scripts/correct_shift_X_multi.py index 519ec78ca..728551a36 100644 --- a/cellacdc/scripts/correct_shift_X_multi.py +++ b/cellacdc/scripts/correct_shift_X_multi.py @@ -11,13 +11,17 @@ from cellacdc.plot import imshow -#Change this if your data structure is different:# +# Change this if your data structure is different:# def finding_base_tif_files_path(root_path): print(INCLUDE_PATTERN_TIF_BASESEARCH) - base_tif_files_paths =[] + base_tif_files_paths = [] tif_files_paths = [] folder_list = os.listdir(root_path) - folder_list = [os.path.join(root_path, folder_name, 'Images') for folder_name in folder_list if folder_name.lower().startswith(FOLDER_FILTER.lower())] + folder_list = [ + os.path.join(root_path, folder_name, "Images") + for folder_name in folder_list + if folder_name.lower().startswith(FOLDER_FILTER.lower()) + ] for folder_name in folder_list: folder_cont = os.listdir(folder_name) for file_name in folder_cont: @@ -25,94 +29,116 @@ def finding_base_tif_files_path(root_path): base_tif_files_paths.append(os.path.join(folder_name, file_name)) tif_files_paths.append(folder_name) return base_tif_files_paths, tif_files_paths + + ################################################## + def load_constants(): - print('Loading constants...') + print("Loading constants...") global PREVIEW_Z_STACK global PREVIEW_Z global FOLDER_FILTER global INCLUDE_PATTERN_TIF_SEARCH global INCLUDE_PATTERN_TIF_BASESEARCH global PRESET_SHIFT - with open('regex.txt', 'r') as input_file: + with open("regex.txt", "r") as input_file: regex_file = input_file.read() for line in regex_file.splitlines(): - if re.search('x_mult_INCLUDE_PATTERN_TIF_SEARCH', line): - line = line.split(':', 1)[1].strip().lstrip().rstrip(',') + if re.search("x_mult_INCLUDE_PATTERN_TIF_SEARCH", line): + line = line.split(":", 1)[1].strip().lstrip().rstrip(",") INCLUDE_PATTERN_TIF_SEARCH = line - elif re.search('x_mult_INCLUDE_PATTERN_TIF_BASESEARCH', line): - line = line.split(':', 1)[1].strip().lstrip().rstrip(',') + elif re.search("x_mult_INCLUDE_PATTERN_TIF_BASESEARCH", line): + line = line.split(":", 1)[1].strip().lstrip().rstrip(",") INCLUDE_PATTERN_TIF_BASESEARCH = line - with open('config.json', 'r') as input_file: + with open("config.json", "r") as input_file: config = json.load(input_file) - PREVIEW_Z_STACK = config['correct_shift_x_multi']['PREVIEW_Z_STACK'] - PREVIEW_Z = config['correct_shift_x_multi']['PREVIEW_Z'] - NEW_PATH_SUF = config['correct_shift_x_multi']['NEW_PATH_SUF'] - FOLDER_FILTER = config['correct_shift_x_multi']['FOLDER_FILTER'] - PRESET_SHIFT = config['correct_shift_x_multi']['PRESET_SHIFT'] - return NEW_PATH_SUF #IDK WHY THIS CAN'T BE GLOBAL(ID DOESNT WORK LIKE THE OTHERS? WHY?) + PREVIEW_Z_STACK = config["correct_shift_x_multi"]["PREVIEW_Z_STACK"] + PREVIEW_Z = config["correct_shift_x_multi"]["PREVIEW_Z"] + NEW_PATH_SUF = config["correct_shift_x_multi"]["NEW_PATH_SUF"] + FOLDER_FILTER = config["correct_shift_x_multi"]["FOLDER_FILTER"] + PRESET_SHIFT = config["correct_shift_x_multi"]["PRESET_SHIFT"] + return NEW_PATH_SUF # IDK WHY THIS CAN'T BE GLOBAL(ID DOESNT WORK LIKE THE OTHERS? WHY?) + + # #Ok it is bc it is used in concurrent.futures. Wellp I guess I'll just return it then + def correct_constant_shift_X_img(img, shift): for i, row in enumerate(img[::2]): - l = i*2 + l = i * 2 img[l] = np.roll(row, shift) return img + def correct_constant_shift_X(z_stack, shift): - for z, img in enumerate(z_stack): + for z, img in enumerate(z_stack): for i, row in enumerate(img[::2]): - l = i*2 + l = i * 2 z_stack[z, l] = np.roll(row, shift) return z_stack + def find_other_tif(file_path): folder_path = os.path.dirname(file_path) file_list = os.listdir(folder_path) - file_list = [filename for filename in file_list if filename.lower().endswith('.tif')] + file_list = [ + filename for filename in file_list if filename.lower().endswith(".tif") + ] return file_list + def finding_shift(tif_data, shift, NEW_PATH_SUF): eval_img = (tif_data[PREVIEW_Z_STACK][PREVIEW_Z]).copy() eval_img = correct_constant_shift_X_img(eval_img, shift) imshow(tif_data[PREVIEW_Z_STACK][PREVIEW_Z], eval_img) while True: - answer = input('Do you want to proceed with the shift or change it?([y]/n/"number"/help)') - if answer.lower() == 'n': + answer = input( + 'Do you want to proceed with the shift or change it?([y]/n/"number"/help)' + ) + if answer.lower() == "n": exit() elif answer.isdigit(): shift = int(answer) shift = finding_shift(tif_data, shift, NEW_PATH_SUF) return shift - elif answer.lstrip('-').isdigit(): + elif answer.lstrip("-").isdigit(): shift = int(answer) shift = finding_shift(tif_data, shift, NEW_PATH_SUF) return shift - elif answer.lower() == 'help': - print('Change the shown image by changing PREVIEW_Z_STACK and PREVIEW_Z in the beginning of the code. \nChange the ending of the new file name by changing NEW_PATH_SUF in the code. \nCurrent z stack and z displayed: ' + str(PREVIEW_Z_STACK) + ' ' +str(PREVIEW_Z) + '\nCurrent ending: ' + NEW_PATH_SUF) + elif answer.lower() == "help": + print( + "Change the shown image by changing PREVIEW_Z_STACK and PREVIEW_Z in the beginning of the code. \nChange the ending of the new file name by changing NEW_PATH_SUF in the code. \nCurrent z stack and z displayed: " + + str(PREVIEW_Z_STACK) + + " " + + str(PREVIEW_Z) + + "\nCurrent ending: " + + NEW_PATH_SUF + ) finding_shift(tif_data, shift, NEW_PATH_SUF) return shift elif not answer: return shift - elif answer.lower() == 'y': + elif answer.lower() == "y": return shift else: - print('The input is not an integer') + print("The input is not an integer") + def shiftingstuff_main(shift, tif_data, tif_path, NEW_PATH_SUF): corrected_data = tif_data.copy() for frame_i, img in enumerate(tif_data): corrected_data[frame_i] = correct_constant_shift_X(img.copy(), shift) - new_path = tif_path.replace('.tif', NEW_PATH_SUF + '.tif') + new_path = tif_path.replace(".tif", NEW_PATH_SUF + ".tif") skimage.io.imsave(new_path, corrected_data, check_contrast=False) print("Saved under:\n" + str(new_path)) del tif_data del corrected_data return + def shiftingstuff_other(shifttif, NEW_PATH_SUF): if shifttif[0] != 0: tif_data = load.imread(shifttif[1]) @@ -120,25 +146,34 @@ def shiftingstuff_other(shifttif, NEW_PATH_SUF): del tif_data return + def sequential(NEW_PATH_SUF): parser = argparse.ArgumentParser() - parser.add_argument('root_path', help='Path to the folder containing all the folders with the positions') + parser.add_argument( + "root_path", + help="Path to the folder containing all the folders with the positions", + ) args = parser.parse_args() root_path = args.root_path base_file_paths, other_files_paths = finding_base_tif_files_path(root_path) - print('Path: \n' + root_path) - print('Base files found:\n' + "\n".join(base_file_paths)) + print("Path: \n" + root_path) + print("Base files found:\n" + "\n".join(base_file_paths)) if base_file_paths == []: - print('No files found!') + print("No files found!") exit() while True: - answer = input('Do you want to shift the other .tif files in the folders too? ([y]/n/help)') - if answer.lower() == 'n': + answer = input( + "Do you want to shift the other .tif files in the folders too? ([y]/n/help)" + ) + if answer.lower() == "n": scan_other = False break - elif answer.lower() == 'help': - print('You can change the regex pattern in the beginning of the code (EXCLUDE_PATTERN_TIF_SEARCH). \nIf you dont know regex, ask Chat_GPT to generate one for you by giving it examples of file names and then asking it to generate a regex code which excludes the files you want to exclude. \nCurrent expression is: ' + INCLUDE_PATTERN_TIF_SEARCH) + elif answer.lower() == "help": + print( + "You can change the regex pattern in the beginning of the code (EXCLUDE_PATTERN_TIF_SEARCH). \nIf you dont know regex, ask Chat_GPT to generate one for you by giving it examples of file names and then asking it to generate a regex code which excludes the files you want to exclude. \nCurrent expression is: " + + INCLUDE_PATTERN_TIF_SEARCH + ) exit() else: scan_other = True @@ -148,28 +183,43 @@ def sequential(NEW_PATH_SUF): for i, tif_path in enumerate(base_file_paths): shift = PRESET_SHIFT tif_data = load.imread(tif_path) - print('You are looking at:\n' + str(tif_path) + '\nPlease close the window after inspecting if the shift value is right in order to proceed.') + print( + "You are looking at:\n" + + str(tif_path) + + "\nPlease close the window after inspecting if the shift value is right in order to proceed." + ) shift = finding_shift(tif_data, shift, NEW_PATH_SUF) tif_files_master.append([shift, tif_path]) del tif_data if scan_other == True: other_tif_files = [] - other_tif_files = find_other_tif(tif_path) - other_tif_files = [tif_file for tif_file in other_tif_files if re.match(INCLUDE_PATTERN_TIF_SEARCH, tif_file)] - other_tif_files = [os.path.join(other_files_paths[i], tif_file) for tif_file in other_tif_files] + other_tif_files = find_other_tif(tif_path) + other_tif_files = [ + tif_file + for tif_file in other_tif_files + if re.match(INCLUDE_PATTERN_TIF_SEARCH, tif_file) + ] + other_tif_files = [ + os.path.join(other_files_paths[i], tif_file) + for tif_file in other_tif_files + ] for other_tif_file in other_tif_files: tif_files_master.append([shift, other_tif_file]) return tif_files_master + if __name__ == "__main__": NEW_PATH_SUF = load_constants() tif_files_master = sequential(NEW_PATH_SUF) - print('\nFiles with shift:\n') + print("\nFiles with shift:\n") for sub_list in tif_files_master: - print('Shift: ' + str(sub_list[0]) + '\nPath:' + str(sub_list[1]) + '\n') + print("Shift: " + str(sub_list[0]) + "\nPath:" + str(sub_list[1]) + "\n") with concurrent.futures.ProcessPoolExecutor() as executor: futures = [] - futures = [executor.submit(shiftingstuff_other, shifttif, NEW_PATH_SUF) for shifttif in tif_files_master] + futures = [ + executor.submit(shiftingstuff_other, shifttif, NEW_PATH_SUF) + for shifttif in tif_files_master + ] results = [future.result() for future in futures] - print('Done!') - exit() \ No newline at end of file + print("Done!") + exit() diff --git a/cellacdc/scripts/correct_shift_X_single.py b/cellacdc/scripts/correct_shift_X_single.py index 81b7d6096..d5463a8bb 100644 --- a/cellacdc/scripts/correct_shift_X_single.py +++ b/cellacdc/scripts/correct_shift_X_single.py @@ -26,124 +26,155 @@ NEW_PATH_SUF = None INCLUDE_PATTERN_TIF_SEARCH = None + def load_constants(): - print('Loading constants...') + print("Loading constants...") global PREVIEW_Z global INCLUDE_PATTERN_TIF_SEARCH - with open('regex.txt', 'r') as input_file: + with open("regex.txt", "r") as input_file: regex_file = input_file.read() for line in regex_file.splitlines(): - if re.search('x_INCLUDE_PATTERN_TIF_SEARCH', line): - line = line.split(':', 1)[1].strip().lstrip().rstrip(',') + if re.search("x_INCLUDE_PATTERN_TIF_SEARCH", line): + line = line.split(":", 1)[1].strip().lstrip().rstrip(",") INCLUDE_PATTERN_TIF_SEARCH = line - with open('config.json', 'r') as input_file: + with open("config.json", "r") as input_file: config = json.load(input_file) - PREVIEW_Z = config['correct_shift_x_single']['PREVIEW_Z'] - NEW_PATH_SUF = config['correct_shift_x_single']['NEW_PATH_SUF'] + PREVIEW_Z = config["correct_shift_x_single"]["PREVIEW_Z"] + NEW_PATH_SUF = config["correct_shift_x_single"]["NEW_PATH_SUF"] return NEW_PATH_SUF + def correct_constant_shift_X_img(img, shift): for i, row in enumerate(img[::2]): - l = i*2 + l = i * 2 img[l] = np.roll(row, shift) return img + def correct_constant_shift_X(z_stack, shift): for z, img in enumerate(z_stack): img = correct_constant_shift_X_img(img, shift) z_stack[z] = img return z_stack + def find_other_tif(file_path): folder_path = os.path.dirname(file_path) file_list = os.listdir(folder_path) - tif_files = [filename for filename in file_list if filename.lower().endswith('.tif')] + tif_files = [ + filename for filename in file_list if filename.lower().endswith(".tif") + ] return tif_files + def finding_shift(tif_data, shift, start_frame, NEW_PATH_SUF): eval_img = (tif_data[start_frame][PREVIEW_Z]).copy() - eval_img = correct_constant_shift_X_img(eval_img, shift) + eval_img = correct_constant_shift_X_img(eval_img, shift) imshow(tif_data[start_frame][PREVIEW_Z], eval_img) while True: - answer = input('Do you want to proceed with the shift or change it ([y]/n/"number"/help)? ') - if answer.lower() == 'n': + answer = input( + 'Do you want to proceed with the shift or change it ([y]/n/"number"/help)? ' + ) + if answer.lower() == "n": exit() elif answer.isdigit(): shift = int(answer) shift = finding_shift(tif_data, shift, start_frame, NEW_PATH_SUF) return shift - elif answer.lstrip('-').isdigit(): + elif answer.lstrip("-").isdigit(): shift = int(answer) shift = finding_shift(tif_data, shift, start_frame, NEW_PATH_SUF) return shift - elif answer.lower() == 'help': - print('Change the shown image by changing PREVIEW_Z in the beginning of the code. \nChange the ending of the new file name by changing NEW_PATH_SUF in the code. \nCurrent z stack and z displayed: ' + str(PREVIEW_Z) + '\nCurrent ending: ' + NEW_PATH_SUF) + elif answer.lower() == "help": + print( + "Change the shown image by changing PREVIEW_Z in the beginning of the code. \nChange the ending of the new file name by changing NEW_PATH_SUF in the code. \nCurrent z stack and z displayed: " + + str(PREVIEW_Z) + + "\nCurrent ending: " + + NEW_PATH_SUF + ) finding_shift(tif_data, shift, start_frame, NEW_PATH_SUF) return shift elif not answer: return shift - elif answer.lower() == 'y': + elif answer.lower() == "y": return shift else: - print('The input is not an integer') - + print("The input is not an integer") + def shiftingstuff_main(shift, tif_data, tif_path, start_frame, end_frame, NEW_PATH_SUF): corrected_data = tif_data.copy() for frame_i, img in islice(enumerate(tif_data), start_frame, end_frame): corrected_data[frame_i] = correct_constant_shift_X(img.copy(), shift) - new_path = tif_path.replace('.tif', NEW_PATH_SUF + '.tif' ) + new_path = tif_path.replace(".tif", NEW_PATH_SUF + ".tif") skimage.io.imsave(new_path, corrected_data, check_contrast=False) del corrected_data del tif_data return -def shiftingstuff_other(tif_name, shift, tif_path, scan_other, start_frame, end_frame, NEW_PATH_SUF): + +def shiftingstuff_other( + tif_name, shift, tif_path, scan_other, start_frame, end_frame, NEW_PATH_SUF +): if scan_other == True: tif_path = os.path.join(os.path.dirname(tif_path), tif_name) tif_data = load.imread(tif_path) - shiftingstuff_main(shift, tif_data, tif_path, start_frame, end_frame, NEW_PATH_SUF) + shiftingstuff_main( + shift, tif_data, tif_path, start_frame, end_frame, NEW_PATH_SUF + ) del tif_data return + def sequential(NEW_PATH_SUF): parser = argparse.ArgumentParser() - parser.add_argument('tif_path', help='Path to the tif-file') - parser.add_argument('shift', help='Amount of shift') - parser.add_argument('frame_start', help='Start of frames which should be shifted') - parser.add_argument('frame_end', help='End of frames which should be shifted') + parser.add_argument("tif_path", help="Path to the tif-file") + parser.add_argument("shift", help="Amount of shift") + parser.add_argument("frame_start", help="Start of frames which should be shifted") + parser.add_argument("frame_end", help="End of frames which should be shifted") args = parser.parse_args() tif_path = args.tif_path shift = int(args.shift) start_frame = int(args.frame_start) end_frame = int(args.frame_end) - print('Path: \n' + tif_path) - print('Original Shift: ' + str(shift)) - print('Start from frame: ' + str(start_frame)) - print('End on frame: ' + str(end_frame)) + print("Path: \n" + tif_path) + print("Original Shift: " + str(shift)) + print("Start from frame: " + str(start_frame)) + print("End on frame: " + str(end_frame)) tif_data = load.imread(tif_path) start_frame -= 1 - print('Please close the window after inspecting if the shift value is right in order to proceed.') + print( + "Please close the window after inspecting if the shift value is right in order to proceed." + ) shift = finding_shift(tif_data, shift, start_frame, NEW_PATH_SUF) - print('Shift used: ' +str(shift)) + print("Shift used: " + str(shift)) - tif_files = find_other_tif(tif_path) - tif_names = [tif_file for tif_file in tif_files if re.match(INCLUDE_PATTERN_TIF_SEARCH, tif_file)] - print('New tif file(s) found:\n' + "\n".join(tif_names)) + tif_files = find_other_tif(tif_path) + tif_names = [ + tif_file + for tif_file in tif_files + if re.match(INCLUDE_PATTERN_TIF_SEARCH, tif_file) + ] + print("New tif file(s) found:\n" + "\n".join(tif_names)) while True: - answer = input('Do you want to shift the other .tif files in the folder too? ([y]/n/help)') - if answer.lower() == 'n': + answer = input( + "Do you want to shift the other .tif files in the folder too? ([y]/n/help)" + ) + if answer.lower() == "n": scan_other = False break - elif answer.lower() == 'help': - print('You can change the regex pattern in the beginning of the code (INCLUDE_PATTERN_TIF_SEARCH). \nIf you dont know regex, ask Chat_GPT to generate one for you by giving it examples of file names and then asking it to generate a regex code which excludes the files you want to exclude. \nCurrent expression is: ' + INCLUDE_PATTERN_TIF_SEARCH) + elif answer.lower() == "help": + print( + "You can change the regex pattern in the beginning of the code (INCLUDE_PATTERN_TIF_SEARCH). \nIf you dont know regex, ask Chat_GPT to generate one for you by giving it examples of file names and then asking it to generate a regex code which excludes the files you want to exclude. \nCurrent expression is: " + + INCLUDE_PATTERN_TIF_SEARCH + ) exit() else: scan_other = True @@ -153,11 +184,35 @@ def sequential(NEW_PATH_SUF): if __name__ == "__main__": NEW_PATH_SUF = load_constants() - shift, tif_data, tif_names, scan_other, tif_path, start_frame, end_frame = sequential(NEW_PATH_SUF) + shift, tif_data, tif_names, scan_other, tif_path, start_frame, end_frame = ( + sequential(NEW_PATH_SUF) + ) with concurrent.futures.ProcessPoolExecutor() as executor: futures = [] - futures = [executor.submit(shiftingstuff_other, tif_name, shift, tif_path, scan_other, start_frame, end_frame, NEW_PATH_SUF) for tif_name in tif_names] - futures.append(executor.submit(shiftingstuff_main, shift, tif_data, tif_path, start_frame, end_frame, NEW_PATH_SUF)) + futures = [ + executor.submit( + shiftingstuff_other, + tif_name, + shift, + tif_path, + scan_other, + start_frame, + end_frame, + NEW_PATH_SUF, + ) + for tif_name in tif_names + ] + futures.append( + executor.submit( + shiftingstuff_main, + shift, + tif_data, + tif_path, + start_frame, + end_frame, + NEW_PATH_SUF, + ) + ) results = [future.result() for future in futures] - print('Done!') - exit() \ No newline at end of file + print("Done!") + exit() diff --git a/cellacdc/scripts/pngtotif.py b/cellacdc/scripts/pngtotif.py index cb956dfd4..af6828e17 100644 --- a/cellacdc/scripts/pngtotif.py +++ b/cellacdc/scripts/pngtotif.py @@ -1,9 +1,10 @@ from PIL import Image import os + def convert_png_to_tif(input_folder, output_tif): images = [] - + # Get a list of all PNG files in the input folder png_files = [file for file in os.listdir(input_folder) if file.endswith(".png")] @@ -21,7 +22,12 @@ def convert_png_to_tif(input_folder, output_tif): print(f"Conversion completed. TIFF file saved at {output_tif}") -input_folder = r"C:\Users\SchmollerLab\Documents\Timon\DeepSea_data\test\set_22_MESC\images" -output_tif = r"C:\Users\SchmollerLab\Documents\Timon\DeepSea_data\test\set_22_MESC\images.tiff" + +input_folder = ( + r"C:\Users\SchmollerLab\Documents\Timon\DeepSea_data\test\set_22_MESC\images" +) +output_tif = ( + r"C:\Users\SchmollerLab\Documents\Timon\DeepSea_data\test\set_22_MESC\images.tiff" +) convert_png_to_tif(input_folder, output_tif) diff --git a/cellacdc/scripts/split_segm_mask_yeast.py b/cellacdc/scripts/split_segm_mask_yeast.py index db1ac7d1b..33c2f667a 100644 --- a/cellacdc/scripts/split_segm_mask_yeast.py +++ b/cellacdc/scripts/split_segm_mask_yeast.py @@ -13,13 +13,15 @@ DEBUG = False + def ask_select_folder(): selected_path = qtpy.compat.getexistingdirectory( - caption='Select experiment folder to analyse', - basedir=myutils.getMostRecentPath() + caption="Select experiment folder to analyse", + basedir=myutils.getMostRecentPath(), ) return selected_path + def get_exp_path_pos_foldernames(selected_path): folder_type = myutils.determine_folder_type(selected_path) is_pos_folder, is_images_folder, exp_path = folder_type @@ -33,150 +35,142 @@ def get_exp_path_pos_foldernames(selected_path): else: exp_path = selected_path pos_foldernames = myutils.get_pos_foldernames(exp_path) - + return exp_path, pos_foldernames + def select_segm_masks(exp_path, pos_foldernames): - infoText = 'Select which segmentation file OF THE CELLS:' + infoText = "Select which segmentation file OF THE CELLS:" existingEndNames = load.get_segm_endnames_from_exp_path( exp_path, pos_foldernames=pos_foldernames ) win = apps.SelectSegmFileDialog( - existingEndNames, exp_path, - infoText=infoText, - fileType='segmentation' + existingEndNames, exp_path, infoText=infoText, fileType="segmentation" ) win.exec_() if win.cancel: return - + cells_segm_endname = win.selectedItemText - - infoText = 'Select segmentation files to SPLIT:' + + infoText = "Select segmentation files to SPLIT:" existingEndNames.discard(cells_segm_endname) win = apps.SelectSegmFileDialog( - existingEndNames, exp_path, - infoText=infoText, - fileType='segmentation', - allowMultipleSelection=True + existingEndNames, + exp_path, + infoText=infoText, + fileType="segmentation", + allowMultipleSelection=True, ) win.exec_() if win.cancel: return - + list_segm_endnames_to_split = win.selectedItemTexts return cells_segm_endname, list_segm_endnames_to_split + def run(): - app, splashScreen = _setup_app(splashscreen=True) + app, splashScreen = _setup_app(splashscreen=True) splashScreen.close() - + selected_path = ask_select_folder() if not selected_path: - exit('Execution cancelled') - + exit("Execution cancelled") + myutils.addToRecentPaths(selected_path) exp_path, pos_foldernames = get_exp_path_pos_foldernames(selected_path) - + if len(pos_foldernames) > 1: selectPosWin = widgets.QDialogListbox( - 'Select Positions to analyse', - 'Select Positions to analyse:\n', - pos_foldernames, - multiSelection=True, - parent=None + "Select Positions to analyse", + "Select Positions to analyse:\n", + pos_foldernames, + multiSelection=True, + parent=None, ) selectPosWin.exec_() if selectPosWin.cancel: - print('Execution stopped by the user') + print("Execution stopped by the user") return - + pos_foldernames = selectPosWin.selectedItemsText - + selected_segm_endnames = select_segm_masks(exp_path, pos_foldernames) if selected_segm_endnames is None: - exit('Execution cancelled') - + exit("Execution cancelled") + cells_segm_endname, list_segm_endnames_to_split = selected_segm_endnames - + list_segm_endnames_to_split_str = [ - f' {val}' for val in list_segm_endnames_to_split + f" {val}" for val in list_segm_endnames_to_split ] - list_segm_endnames_to_split_str = '\n'.join(list_segm_endnames_to_split_str) - print('='*100) + list_segm_endnames_to_split_str = "\n".join(list_segm_endnames_to_split_str) + print("=" * 100) print( - f' - Cells segmentation endname: {cells_segm_endname}', - f' - Segmentation files to split:', - f'{list_segm_endnames_to_split_str}', - sep='\n' + f" - Cells segmentation endname: {cells_segm_endname}", + f" - Segmentation files to split:", + f"{list_segm_endnames_to_split_str}", + sep="\n", ) - - acdc_df_endname = cells_segm_endname.replace('segm', 'acdc_output') - if not acdc_df_endname.endswith('.csv'): - acdc_df_endname = f'{acdc_df_endname}.csv' - - print(f' - Cell cycle annotations file: {acdc_df_endname}') + + acdc_df_endname = cells_segm_endname.replace("segm", "acdc_output") + if not acdc_df_endname.endswith(".csv"): + acdc_df_endname = f"{acdc_df_endname}.csv" + + print(f" - Cell cycle annotations file: {acdc_df_endname}") pbar = tqdm(total=len(pos_foldernames), ncols=100) for pos in pos_foldernames: - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") cells_segm_data = load.load_segm_file( images_path, end_name_segm_file=cells_segm_endname ) - + acdc_df = load.load_acdc_df_file( images_path, end_name_acdc_df_file=acdc_df_endname ) if acdc_df is None: - files_format = '\n'.join([ - f' - {file}' for file in os.listdir(images_path) - ]) - print('', '='*100, sep='\n') - print( - f'Files present in "{images_path}":\n\n{files_format}' + files_format = "\n".join( + [f" - {file}" for file in os.listdir(images_path)] ) + print("", "=" * 100, sep="\n") + print(f'Files present in "{images_path}":\n\n{files_format}') print( f'\n[WARNING]: Cell cycle annotations file "{acdc_df_endname}" ' - 'not found in the following folder. Skipping it.\n\n' - f'{images_path}' + "not found in the following folder. Skipping it.\n\n" + f"{images_path}" ) - print('='*100) + print("=" * 100) continue - + pbar.set_description(pos) for segm_endname in list_segm_endnames_to_split: segm_data_to_split, segm_data_to_split_fp = load.load_segm_file( - images_path, end_name_segm_file=segm_endname, - return_path=True + images_path, end_name_segm_file=segm_endname, return_path=True ) out = core.split_segm_masks_mother_bud_line( - cells_segm_data, segm_data_to_split, acdc_df, - debug=DEBUG + cells_segm_data, segm_data_to_split, acdc_df, debug=DEBUG ) split_segm_close, split_segm_away = out - + segm_data_to_split_fn = os.path.basename(segm_data_to_split_fp) - + split_close_filename = segm_data_to_split_fn.replace( - segm_endname, f'{segm_endname}_split_close.npz' - ).replace('.npz.npz', '.npz') - split_close_filepath = os.path.join( - images_path, split_close_filename - ) - + segm_endname, f"{segm_endname}_split_close.npz" + ).replace(".npz.npz", ".npz") + split_close_filepath = os.path.join(images_path, split_close_filename) + io.savez_compressed(split_close_filepath, split_segm_close) - - + split_away_filename = segm_data_to_split_fn.replace( - segm_endname, f'{segm_endname}_split_away.npz' - ).replace('.npz.npz', '.npz') - split_away_filepath = os.path.join( - images_path, split_away_filename - ) + segm_endname, f"{segm_endname}_split_away.npz" + ).replace(".npz.npz", ".npz") + split_away_filepath = os.path.join(images_path, split_away_filename) io.savez_compressed(split_away_filepath, split_segm_away) pbar.update() - + pbar.close() - -if __name__ == '__main__': - run() \ No newline at end of file + +if __name__ == "__main__": + run() diff --git a/cellacdc/segm.py b/cellacdc/segm.py index c75c2589d..d93ab4f02 100755 --- a/cellacdc/segm.py +++ b/cellacdc/segm.py @@ -19,13 +19,28 @@ from tqdm import tqdm from qtpy.QtWidgets import ( - QApplication, QMainWindow, QFileDialog, - QVBoxLayout, QPushButton, QLabel, QProgressBar, QHBoxLayout, - QStyleFactory, QWidget, QMessageBox, QTextEdit + QApplication, + QMainWindow, + QFileDialog, + QVBoxLayout, + QPushButton, + QLabel, + QProgressBar, + QHBoxLayout, + QStyleFactory, + QWidget, + QMessageBox, + QTextEdit, ) from qtpy.QtCore import ( - Qt, QEventLoop, Signal, QObject, QMutex, QWaitCondition, QThread, - QTimer + Qt, + QEventLoop, + Signal, + QObject, + QMutex, + QWaitCondition, + QThread, + QTimer, ) from qtpy import QtGui import qtpy.compat @@ -39,21 +54,24 @@ from . import config from . import urls -if os.name == 'nt': +if os.name == "nt": try: # Set taskbar icon in windows import ctypes - myappid = 'schmollerlab.cellacdc.pyqt.v1' # arbitrary string + + myappid = "schmollerlab.cellacdc.pyqt.v1" # arbitrary string ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) except: pass + class QTerminal(QTextEdit): def write(self, message): - message = message.replace('\r ', '') + message = message.replace("\r ", "") if message: self.setText(message) + class SegmWorkerSignals(QObject): finished = Signal(object) progress = Signal(str) @@ -66,6 +84,7 @@ class SegmWorkerSignals(QObject): debug = Signal(object) critical = Signal(object) + import os import time @@ -74,28 +93,25 @@ class SegmWorkerSignals(QObject): from cellacdc import load, core, features, cli + class SegmWorker(QObject): - def __init__( - self, img_path, mainWin, stop_frame_n - ): + def __init__(self, img_path, mainWin, stop_frame_n): QObject.__init__(self) self.signals = SegmWorkerSignals() self.img_path = img_path self.stop_frame_n = stop_frame_n self.mainWin = mainWin self.init_kernel(mainWin) - + def init_kernel(self, mainWin): use_ROI = not mainWin.ROIdeactivatedByUser - self.kernel = cli.SegmKernel( - mainWin.logger, mainWin.log_path, is_cli=False - ) + self.kernel = cli.SegmKernel(mainWin.logger, mainWin.log_path, is_cli=False) self.kernel.init_args( - mainWin.user_ch_name, + mainWin.user_ch_name, mainWin.endFilenameSegm, - mainWin.model_name, + mainWin.model_name, mainWin.do_tracking, - mainWin.applyPostProcessing, + mainWin.applyPostProcessing, mainWin.save, mainWin.image_chName_tracker, mainWin.standardPostProcessKwargs, @@ -107,7 +123,7 @@ def init_kernel(self, mainWin): mainWin.use3DdataFor2Dsegm, mainWin.model_kwargs, mainWin.track_params, - mainWin.SizeT, + mainWin.SizeT, mainWin.SizeZ, model=mainWin.model, tracker=mainWin.tracker, @@ -115,24 +131,21 @@ def init_kernel(self, mainWin): signals=self.signals, logger_func=self.signals.progress.emit, innerPbar_available=mainWin.innerPbar_available, - is_segment3DT_available=mainWin.is_segment3DT_available, - preproc_recipe=mainWin.preproc_recipe, + is_segment3DT_available=mainWin.is_segment3DT_available, + preproc_recipe=mainWin.preproc_recipe, reduce_memory_usage=mainWin.reduce_memory_usage, use_freehand_ROI=mainWin.useFreeHandROI, ) - + def run_kernels(self): - self.kernel.run( - self.img_path, - self.stop_frame_n - ) + self.kernel.run(self.img_path, self.stop_frame_n) if self.mainWin._measurements_kernel is None: return - - segm_endname = self.kernel.segm_endname.replace('.npz', '') + + segm_endname = self.kernel.segm_endname.replace(".npz", "") self.mainWin._measurements_kernel.run( - img_path=self.img_path, - stop_frame_n=self.stop_frame_n, + img_path=self.img_path, + stop_frame_n=self.stop_frame_n, end_filename_segm=segm_endname, ) @@ -140,14 +153,19 @@ def run_kernels(self): def run(self): self.run_kernels() self.signals.finished.emit(self) - + + class segmWin(QMainWindow): sigClosed = Signal() - + def __init__( - self, parent=None, allowExit=False, buttonToRestore=None, - mainWin=None, version=None - ): + self, + parent=None, + allowExit=False, + buttonToRestore=None, + mainWin=None, + version=None, + ): super().__init__(parent) self.allowExit = allowExit @@ -155,23 +173,21 @@ def __init__( self.mainWin = mainWin if mainWin is not None: self.app = mainWin.app - + self._version = version - logger, logs_path, log_path, log_filename = myutils.setupLogger( - module='segm' - ) + logger, logs_path, log_path, log_filename = myutils.setupLogger(module="segm") self.logger = logger self.log_path = log_path self.log_filename = log_filename self.logs_path = logs_path if self._version is not None: - logger.info(f'Initializing Segmentation module v{self._version}...') + logger.info(f"Initializing Segmentation module v{self._version}...") else: - logger.info(f'Initializing Segmentation module...') + logger.info(f"Initializing Segmentation module...") - self.setWindowTitle(f'Cell-ACDC v{self._version} - Segment') + self.setWindowTitle(f"Cell-ACDC v{self._version} - Segment") self.setWindowIcon(QtGui.QIcon(":icon.ico")) mainContainer = QWidget() @@ -212,7 +228,7 @@ def __init__( self.progressLabel = widgets.Label(self, force_html=True) self.mainLayout.addWidget(self.progressLabel) - abortButton = widgets.cancelPushButton('Stop processs') + abortButton = widgets.cancelPushButton("Stop processs") abortButton.clicked.connect(self.close) buttonsLayout.addStretch(1) buttonsLayout.addWidget(abortButton) @@ -224,25 +240,25 @@ def __init__( def getMostRecentPath(self): if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - if 'opened_last_on' in df.columns: - df = df.sort_values('opened_last_on', ascending=False) - self.MostRecentPath = df.iloc[0]['path'] + df = pd.read_csv(recentPaths_path, index_col="index") + if "opened_last_on" in df.columns: + df = df.sort_values("opened_last_on", ascending=False) + self.MostRecentPath = df.iloc[0]["path"] if not isinstance(self.MostRecentPath, str): - self.MostRecentPath = '' + self.MostRecentPath = "" else: - self.MostRecentPath = '' + self.MostRecentPath = "" def addToRecentPaths(self, exp_path): if not os.path.exists(exp_path): return if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - recentPaths = df['path'].to_list() - if 'opened_last_on' in df.columns: - openedOn = df['opened_last_on'].to_list() + df = pd.read_csv(recentPaths_path, index_col="index") + recentPaths = df["path"].to_list() + if "opened_last_on" in df.columns: + openedOn = df["opened_last_on"].to_list() else: - openedOn = [np.nan]*len(recentPaths) + openedOn = [np.nan] * len(recentPaths) if exp_path in recentPaths: pop_idx = recentPaths.index(exp_path) recentPaths.pop(pop_idx) @@ -256,10 +272,13 @@ def addToRecentPaths(self, exp_path): else: recentPaths = [exp_path] openedOn = [datetime.datetime.now()] - df = pd.DataFrame({'path': recentPaths, - 'opened_last_on': pd.Series(openedOn, - dtype='datetime64[ns]')}) - df.index.name = 'index' + df = pd.DataFrame( + { + "path": recentPaths, + "opened_last_on": pd.Series(openedOn, dtype="datetime64[ns]"), + } + ) + df.index.name = "index" df.to_csv(recentPaths_path) def addPbar(self, add_inner=False): @@ -267,7 +286,7 @@ def addPbar(self, add_inner=False): QPbar = widgets.ProgressBar(self) pBarLayout.addWidget(QPbar) ETA_label = QLabel() - ETA_label.setText('ETA: NDh:NDm:NDs') + ETA_label.setText("ETA: NDh:NDm:NDs") pBarLayout.addWidget(ETA_label) if add_inner: self.innerQPbar = QPbar @@ -281,7 +300,7 @@ def addPbar(self, add_inner=False): screen = self.screen() screenHeight = screen.size().height() screenWidth = screen.size().width() - self.resize(int(screenWidth*0.5), int(screenHeight*0.6)) + self.resize(int(screenWidth * 0.5), int(screenHeight * 0.6)) def askHowToHandleROI(self): if len(self.posData.dataPrepFreeRoiPoints) > 0: @@ -293,42 +312,44 @@ def askHowToHandleROI(self): """) msg = widgets.myMessageBox(wrapText=False) _, noButton, yesButton = msg.question( - self, 'Use the free-hand ROI?', txt, - buttonsTexts = ( - 'Cancel', - 'No, segment the entire image', - 'Yes, use the free-hand ROI' - ) + self, + "Use the free-hand ROI?", + txt, + buttonsTexts=( + "Cancel", + "No, segment the entire image", + "Yes, use the free-hand ROI", + ), ) return False, False, msg.clickedButton == yesButton - - idx_slice = pd.IndexSlice[:, 'cropped'] + + idx_slice = pd.IndexSlice[:, "cropped"] df_ROI = self.posData.dataPrep_ROIcoords if df_ROI is None: - href = html_utils.href_tag('here', urls.dataprep_docs) + href = html_utils.href_tag("here", urls.dataprep_docs) txt = html_utils.paragraph(f""" Do you want to segment only a rectangluar sub-region (ROI) of the image?

    If yes, Cell-ACDC will launch the Data-prep module later.

    See {href} for more details on how to use the Data-prep module. """) - elif int(df_ROI.loc[idx_slice, 'value'].iloc[0]) > 0: + elif int(df_ROI.loc[idx_slice, "value"].iloc[0]) > 0: # Data is cropped, do not ask to segment a roi return False, False, False else: - xl_slice = pd.IndexSlice[:, 'x_left'] - xr_slice = pd.IndexSlice[:, 'x_right'] - yt_slice = pd.IndexSlice[:, 'y_top'] - yb_slice = pd.IndexSlice[:, 'y_bottom'] + xl_slice = pd.IndexSlice[:, "x_left"] + xr_slice = pd.IndexSlice[:, "x_right"] + yt_slice = pd.IndexSlice[:, "y_top"] + yb_slice = pd.IndexSlice[:, "y_bottom"] SizeY, SizeX = self.posData.img_data.shape[-2:] - x0 = int(df_ROI.loc[xl_slice, 'value'].iloc[0]) - x1 = int(df_ROI.loc[xr_slice, 'value'].iloc[0]) - y0 = int(df_ROI.loc[yt_slice, 'value'].iloc[0]) - y1 = int(df_ROI.loc[yb_slice, 'value'].iloc[0]) - if x0 == 0 and y0 == 0 and y1==SizeY and y1 == SizeX: + x0 = int(df_ROI.loc[xl_slice, "value"].iloc[0]) + x1 = int(df_ROI.loc[xr_slice, "value"].iloc[0]) + y0 = int(df_ROI.loc[yt_slice, "value"].iloc[0]) + y1 = int(df_ROI.loc[yb_slice, "value"].iloc[0]) + if x0 == 0 and y0 == 0 and y1 == SizeY and y1 == SizeX: # ROI is present but with same shape as image --> ignore return False, False, False - + note = html_utils.to_admonition(""" If you need to modify the existing ROI, cancel the process now and launch Data-prep again. @@ -340,67 +361,61 @@ def askHowToHandleROI(self): {note} """) msg = widgets.myMessageBox(showCentered=False, wrapText=False) - _, yesButton, noButton = msg.question(self, 'ROI?', txt, - buttonsTexts = ('Cancel','Yes','No') + _, yesButton, noButton = msg.question( + self, "ROI?", txt, buttonsTexts=("Cancel", "Yes", "No") ) return msg.cancel, msg.clickedButton == yesButton, False - + def main(self): selectFoldersWin = apps.SelectFoldersToAnalyse( - parent=self, - instructionsText= - 'Select experiment folders to analyse using ' - 'the same set of parameters', - askSelectPosFolders=True + parent=self, + instructionsText="Select experiment folders to analyse using " + "the same set of parameters", + askSelectPosFolders=True, ) selectFoldersWin.exec_() if selectFoldersWin.cancel: self.processStopped() return - - expToPosFoldersMapper = ( - selectFoldersWin.selectedExpFolderToPosFoldernamesMapper - ) + + expToPosFoldersMapper = selectFoldersWin.selectedExpFolderToPosFoldernamesMapper font = QtGui.QFont() font.setPixelSize(13) self.setWindowTitle( - f'Cell-ACDC v{self._version} - Segmentation and Tracking workflow' + f"Cell-ACDC v{self._version} - Segmentation and Tracking workflow" ) self.addPbar() self.addlogTerminal() - self.log('Loading data...') - self.progressLabel.setText('Loading data...') + self.log("Loading data...") + self.progressLabel.setText("Loading data...") ch_name_selector = prompts.select_channel_name( - which_channel='segm', allow_abort=True + which_channel="segm", allow_abort=True ) images_paths = [] for exp_path, pos_foldernames in expToPosFoldersMapper.items(): for pos_foldername in pos_foldernames: - images_path = os.path.join( - exp_path, pos_foldername, 'Images' - ) + images_path = os.path.join(exp_path, pos_foldername, "Images") images_paths.append(images_path) user_ch_file_paths = [] for images_path in images_paths: - print('') - self.log(f'Processing {images_path}') + print("") + self.log(f"Processing {images_path}") filenames = myutils.listdir(images_path) if not filenames: self.criticalImagesFolderEmpty(images_path) self.close() return if ch_name_selector.is_first_call: - ch_names, warn = ( - ch_name_selector.get_available_channels( - filenames, images_path - )) + ch_names, warn = ch_name_selector.get_available_channels( + filenames, images_path + ) if not ch_names: self.criticalNoTifFound(images_path) self.close() @@ -420,30 +435,34 @@ def main(self): tif_found = False dataPrep_fn = None for filename in filenames: - if filename.find(f'{user_ch_name}_aligned.npz') != -1: + if filename.find(f"{user_ch_name}_aligned.npz") != -1: img_path = os.path.join(images_path, filename) - idx = filename.find('_aligned.npz') + idx = filename.find("_aligned.npz") dataPrep_fn = filename[:idx] aligned_npz_found = True - elif filename.find(f'{user_ch_name}.tif') != -1: + elif filename.find(f"{user_ch_name}.tif") != -1: img_path = os.path.join(images_path, filename) tif_found = True if not aligned_npz_found and not tif_found: - print('') - print('-------------------------------------------------------') - self.log(f'The folder {images_path}\n does not contain the file ' - f'{user_ch_name}_aligned.npz\n or the file {user_ch_name}.tif. ' - 'Skipping it.') - print('-------------------------------------------------------') - print('') + print("") + print("-------------------------------------------------------") + self.log( + f"The folder {images_path}\n does not contain the file " + f"{user_ch_name}_aligned.npz\n or the file {user_ch_name}.tif. " + "Skipping it." + ) + print("-------------------------------------------------------") + print("") elif not aligned_npz_found and tif_found: - print('') - print('-------------------------------------------------------') - self.log(f'The folder {images_path}\n does not contain the file ' - f'{user_ch_name}_aligned.npz. Segmenting .tif data.') - print('-------------------------------------------------------') - print('') + print("") + print("-------------------------------------------------------") + self.log( + f"The folder {images_path}\n does not contain the file " + f"{user_ch_name}_aligned.npz. Segmenting .tif data." + ) + print("-------------------------------------------------------") + print("") user_ch_file_paths.append(img_path) elif aligned_npz_found: user_ch_file_paths.append(img_path) @@ -468,7 +487,7 @@ def main(self): load_last_tracked_i=False, load_metadata=True, load_dataprep_free_roi=True, - load_customCombineMetrics=True + load_customCombineMetrics=True, ) proceed = self.posData.askInputMetadata( self.numPos, @@ -476,7 +495,7 @@ def main(self): ask_TimeIncrement=False, ask_PhysicalSizes=False, save=True, - forceEnableAskSegm3D=True + forceEnableAskSegm3D=True, ) # Store metadata for all other positions loaded for other_img_path in user_ch_file_paths[1:]: @@ -489,53 +508,51 @@ def main(self): ) self._posData.isSegm3D = self.posData.isSegm3D try: - _SizeT = int(self._posData.metadata_df.at['SizeT', 'values']) + _SizeT = int(self._posData.metadata_df.at["SizeT", "values"]) if _SizeT == self.posData.SizeT: continue - - self._posData.metadata_df.at['SizeT', 'values'] = self.posData.SizeT + + self._posData.metadata_df.at["SizeT", "values"] = self.posData.SizeT self._posData.SizeT = self.posData.SizeT except Exception as err: self._posData.SizeT = self.posData.SizeT - + self._posData.saveMetadata() - + self.isSegm3D = self.posData.isSegm3D self.SizeT = self.posData.SizeT self.SizeZ = self.posData.SizeZ if not proceed: self.processStopped() return - + # Ask which model win = apps.QDialogSelectModel( - parent=self, addSkipSegmButton=self.posData.SizeT>1 + parent=self, addSkipSegmButton=self.posData.SizeT > 1 ) win.exec_() if win.cancel: self.processStopped() return - + model_name = win.selectedModel - if model_name == 'thresholding': - win = apps.QDialogAutomaticThresholding( - parent=self, isSegm3D=self.isSegm3D - ) + if model_name == "thresholding": + win = apps.QDialogAutomaticThresholding(parent=self, isSegm3D=self.isSegm3D) win.exec_() if win.cancel: self.processStopped() return self.model_kwargs = win.segment_kwargs - self.log(f'Downloading {model_name} (if needed)...') + self.log(f"Downloading {model_name} (if needed)...") self.downloadWin = apps.downloadModel(model_name, parent=self) self.downloadWin.download() - - self.log(f'Importing {model_name}...') + + self.log(f"Importing {model_name}...") self.model_name = model_name acdcSegment = myutils.import_segment_module(model_name) - self.acdcSegment = acdcSegment + self.acdcSegment = acdcSegment # Read all models parameters init_params, segment_params = myutils.getModelArgSpec(self.acdcSegment) @@ -545,71 +562,76 @@ def main(self): url = acdcSegment.url_help() except AttributeError: url = None - + out = prompts.init_segm_model_params( - self.posData, model_name, init_params, segment_params, - help_url=url, qparent=self, init_last_params=False, - add_additional_segm_params=True + self.posData, + model_name, + init_params, + segment_params, + help_url=url, + qparent=self, + init_last_params=False, + add_additional_segm_params=True, ) - win = out.get('win') + win = out.get("win") if win.cancel: self.processStopped() return - - if model_name != 'thresholding': + + if model_name != "thresholding": self.model_kwargs = win.model_kwargs self.standardPostProcessKwargs = win.standardPostProcessKwargs self.customPostProcessFeatures = win.customPostProcessFeatures - self.customPostProcessGroupedFeatures = ( - win.customPostProcessGroupedFeatures - ) + self.customPostProcessGroupedFeatures = win.customPostProcessGroupedFeatures self.applyPostProcessing = win.applyPostProcessing self.secondChannelName = win.secondChannelName - + myutils.log_segm_params( - model_name, win.init_kwargs, win.model_kwargs, - logger_func=self.logger.info, - preproc_recipe=win.preproc_recipe, - apply_post_process=self.applyPostProcessing, - standard_postprocess_kwargs=self.standardPostProcessKwargs, - custom_postprocess_features=self.customPostProcessFeatures + model_name, + win.init_kwargs, + win.model_kwargs, + logger_func=self.logger.info, + preproc_recipe=win.preproc_recipe, + apply_post_process=self.applyPostProcessing, + standard_postprocess_kwargs=self.standardPostProcessKwargs, + custom_postprocess_features=self.customPostProcessFeatures, ) init_kwargs = win.init_kwargs self.init_model_kwargs = init_kwargs self.preproc_recipe = win.preproc_recipe self.reduce_memory_usage = win.reduceMemoryUsage - + if self.secondChannelName is not None: - init_kwargs['is_rgb'] = True - + init_kwargs["is_rgb"] = True + self.model = myutils.init_segm_model(acdcSegment, self.posData, init_kwargs) if self.model is None: - self.logger.info('Segmentation model was not initialized correctly!') + self.logger.info("Segmentation model was not initialized correctly!") self.processStopped() return try: self.model.setupLogger(self.logger) except Exception as e: pass - + self.predictCcaState_model = None self.is_segment3DT_available = False - if self.posData.SizeT>1 and not self.isSegm3D: + if self.posData.SizeT > 1 and not self.isSegm3D: self.is_segment3DT_available = any( - [name=='segment3DT' for name in dir(acdcSegment.Model)] + [name == "segment3DT" for name in dir(acdcSegment.Model)] ) self.innerPbar_available = False - if len(user_ch_file_paths)>1 and self.posData.SizeT>1: + if len(user_ch_file_paths) > 1 and self.posData.SizeT > 1: self.addPbar(add_inner=True) self.innerPbar_available = True - + # Check if there are segmentation already computed self.selectedSegmFile = None - self.endFilenameSegm = 'segm.npz' + self.endFilenameSegm = "segm.npz" self.isNewSegmFile = False askNewName = True isMultiSegm = False @@ -619,55 +641,55 @@ def main(self): if len(segm_files) > 0: isMultiSegm = True break - - sam_only_embeddings = self.model_kwargs.get('only_embeddings', False) + + sam_only_embeddings = self.model_kwargs.get("only_embeddings", False) self.save = not sam_only_embeddings if isMultiSegm and not sam_only_embeddings: askNewName = self.askMultipleSegm( - segm_files, isTimelapse=self.posData.SizeT>1 + segm_files, isTimelapse=self.posData.SizeT > 1 ) if askNewName is None: self.save = False self.processStopped() return - + if self.selectedSegmFile is not None: - self.endFilenameSegm = self.selectedSegmFile[len(self.posData.basename):] - + self.endFilenameSegm = self.selectedSegmFile[len(self.posData.basename) :] + if askNewName and self.save: self.isNewSegmFile = True win = apps.filenameDialog( - basename=f'{self.posData.basename}segm', - hintText='Insert a filename for the segmentation file:
    ', - existingNames=segm_files + basename=f"{self.posData.basename}segm", + hintText="Insert a filename for the segmentation file:
    ", + existingNames=segm_files, ) win.exec_() if win.cancel: self.processStopped() return if win.entryText: - self.endFilenameSegm = f'segm_{win.entryText}.npz' + self.endFilenameSegm = f"segm_{win.entryText}.npz" else: - self.endFilenameSegm = f'segm.npz' + self.endFilenameSegm = f"segm.npz" # Save hyperparams + post_process_params = {"applied_postprocessing": self.applyPostProcessing} post_process_params = { - 'applied_postprocessing': self.applyPostProcessing - } - post_process_params = { - **post_process_params, + **post_process_params, **self.standardPostProcessKwargs, - **self.customPostProcessFeatures + **self.customPostProcessFeatures, } - + for other_img_path in user_ch_file_paths: self._posData = load.loadData(other_img_path, user_ch_name, QParent=self) self._posData.getBasenameAndChNames(qparent=self) self._posData.buildPaths() self._posData.saveSegmHyperparams( - model_name, self.init_model_kwargs, self.model_kwargs, - post_process_params=post_process_params, - preproc_recipe=self.preproc_recipe + model_name, + self.init_model_kwargs, + self.model_kwargs, + post_process_params=post_process_params, + preproc_recipe=self.preproc_recipe, ) # Ask ROI @@ -701,16 +723,16 @@ def main(self): if self._posData.segmInfo_df is None: isSegmInfoPresent = False break - + self.use3DdataFor2Dsegm = False if self.posData.SizeZ > 1 and not self.isSegm3D: cancel, use3DdataFor2Dsegm = self.askHowToHandle2DsegmOn3Ddata() if cancel: self.processStopped() return - + self.use3DdataFor2Dsegm = use3DdataFor2Dsegm - + segm2D_never_visualized_dataPrep = ( not self.isSegm3D and self.posData.SizeZ > 1 @@ -739,21 +761,19 @@ def main(self): ) dataPrepWin.show() if selectROI: - dataPrepWin.titleText = ( - """ + dataPrepWin.titleText = """ If you need to crop press the green tick button,
    otherwise you can close the window. """ - ) else: - print('') + print("") self.log( - f'WARNING: The image data in {img_path} is 3D but ' - f'_segmInfo.csv file not found. Launching dataPrep.py...' + f"WARNING: The image data in {img_path} is 3D but " + f"_segmInfo.csv file not found. Launching dataPrep.py..." ) self.logTerminal.setText( - f'The image data in {img_path} is 3D but ' - f'_segmInfo.csv file not found. Launching dataPrep.py...' + f"The image data in {img_path} is 3D but " + f"_segmInfo.csv file not found. Launching dataPrep.py..." ) msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" @@ -766,15 +786,13 @@ def main(self): or projection for each Position or frame
    . """) msg.warning( - self, '3D z-stacks info missing', txt, - buttonsTexts=('Cancel', 'Ok') + self, "3D z-stacks info missing", txt, buttonsTexts=("Cancel", "Ok") ) if msg.cancel: self.processStopped() return - dataPrepWin.titleText = ( - """ + dataPrepWin.titleText = """ Select z-slice (or projection) for each frame/position.
    Then, if you want to segment the entire field of view, close the window.
    @@ -782,11 +800,9 @@ def main(self): press the "Start" button, draw the ROI
    and confirm with the green tick button. """ - ) autoStart = False dataPrepWin.initLoading() - dataPrepWin.loadFiles( - exp_path, user_ch_file_paths, user_ch_name) + dataPrepWin.loadFiles(exp_path, user_ch_file_paths, user_ch_name) if self.posData.SizeZ == 1: dataPrepWin.prepData(None) loop = QEventLoop(self) @@ -794,10 +810,7 @@ def main(self): loop.exec_() # If data was aligned then we make sure to load it here - user_ch_file_paths = load.get_user_ch_paths( - images_paths, - user_ch_name - ) + user_ch_file_paths = load.get_user_ch_paths(images_paths, user_ch_name) img_path = user_ch_file_paths[0] self.posData = load.loadData(img_path, user_ch_name, QParent=self) @@ -813,33 +826,36 @@ def main(self): load_dataPrep_ROIcoords=True, load_bkgr_data=False, load_last_tracked_i=False, - load_metadata=True + load_metadata=True, ) self.posData.isSegm3D = self.isSegm3D - elif self.posData.SizeZ > 1 and not self.isSegm3D and not self.use3DdataFor2Dsegm: + elif ( + self.posData.SizeZ > 1 and not self.isSegm3D and not self.use3DdataFor2Dsegm + ): df = self.posData.segmInfo_df.loc[self.posData.filename] - zz = df['z_slice_used_dataPrep'].to_list() + zz = df["z_slice_used_dataPrep"].to_list() isROIactive = False - if self.posData.dataPrep_ROIcoords is not None and not self.ROIdeactivatedByUser: + if ( + self.posData.dataPrep_ROIcoords is not None + and not self.ROIdeactivatedByUser + ): df_roi = self.posData.dataPrep_ROIcoords.loc[0] - isROIactive = df_roi.at['cropped', 'value'] == 0 - x0, x1, y0, y1 = df_roi['value'][:4] + isROIactive = df_roi.at["cropped", "value"] == 0 + x0, x1, y0, y1 = df_roi["value"][:4] df_roi = self.posData.dataPrep_ROIcoords.loc[0] - isROIactive = df_roi.at['cropped', 'value'] == 0 - x0, x1, y0, y1 = df_roi['value'][:4] + isROIactive = df_roi.at["cropped", "value"] == 0 + x0, x1, y0, y1 = df_roi["value"][:4] self.image_chName_tracker = None self.do_tracking = False self.tracker = None self.track_params = {} self.tracker_init_params = {} - self.trackerName = '' + self.trackerName = "" self.stopFrames = [1 for _ in range(len(user_ch_file_paths))] if self.posData.SizeT > 1: - win = apps.askStopFrameSegm( - user_ch_file_paths, user_ch_name, parent=self - ) + win = apps.askStopFrameSegm(user_ch_file_paths, user_ch_name, parent=self) win.setFont(font) win.exec_() if win.cancel: @@ -850,15 +866,17 @@ def main(self): # Ask whether to track the frames trackers = myutils.get_list_of_trackers() - txt = html_utils.paragraph(''' + txt = html_utils.paragraph(""" Do you want to track the objects?

    If yes, select the tracker to use

    - ''') + """) win = widgets.QDialogListbox( - 'Track objects?', txt, - trackers, additionalButtons=['Do not track'], + "Track objects?", + txt, + trackers, + additionalButtons=["Do not track"], multiSelection=False, - parent=self + parent=self, ) win.exec_() if win.cancel: @@ -868,14 +886,14 @@ def main(self): self.image_chName_tracker = None if win.clickedButton in win._additionalButtons: self.do_tracking = False - trackerName = '' + trackerName = "" self.trackerName = trackerName else: self.do_tracking = True trackerName = win.selectedItemsText[0] self.trackerName = trackerName init_tracker_output = myutils.init_tracker( - self.posData, trackerName, return_init_params=True, qparent=self + self.posData, trackerName, return_init_params=True, qparent=self ) self.tracker, self.track_params, self.tracker_init_params = ( init_tracker_output @@ -883,24 +901,21 @@ def main(self): if self.track_params is None: self.processStopped() return - - if 'image_channel_name' in self.track_params: - # Store the channel name for the tracker for loading it + + if "image_channel_name" in self.track_params: + # Store the channel name for the tracker for loading it # in case of multiple pos self.image_chName_tracker = self.track_params.pop( - 'image_channel_name' + "image_channel_name" ) - self.progressLabel.setText('Starting main worker...') + self.progressLabel.setText("Starting main worker...") max = 0 for i, imgPath in enumerate(user_ch_file_paths): self._posData = load.loadData(imgPath, user_ch_name) self._posData.getBasenameAndChNames(qparent=self) - self._posData.loadOtherFiles( - load_segm_data=False, - load_metadata=True - ) + self._posData.loadOtherFiles(load_segm_data=False, load_metadata=True) if self.posData.SizeT > 1: max += self.stopFrames[i] else: @@ -912,7 +927,7 @@ def main(self): if self.innerPbar_available: self.QPbar.setMaximum(len(user_ch_file_paths)) else: - self.QPbar.setMaximum(max*2) + self.QPbar.setMaximum(max * 2) self.exec_time_per_iter = 0 self.exec_time_per_frame = 0 @@ -923,172 +938,164 @@ def main(self): self.exp_path = exp_path self.user_ch_file_paths = user_ch_file_paths self.user_ch_name = user_ch_name - + proceed, measurements_kernel = self.askSaveMeasurements() if not proceed: - self.logger.info('Segmentation process interrupted.') + self.logger.info("Segmentation process interrupted.") self.close() return self._measurements_kernel = measurements_kernel - + proceed = self.askRunNowOrSaveConfigFile() if not proceed: - self.logger.info('Segmentation process interrupted.') + self.logger.info("Segmentation process interrupted.") self.close() return - + t0 = time.perf_counter() for pos_idx, img_path in enumerate(self.user_ch_file_paths): stop_frame_n = self.stopFrames[pos_idx] - segmWorker, segmThread = self.startSegmWorker( - img_path, stop_frame_n - ) + segmWorker, segmThread = self.startSegmWorker(img_path, stop_frame_n) self.waitSegmWorker(segmWorker) if segmWorker.is_error: break - + t1 = time.perf_counter() - - self.processFinished(t1-t0) + + self.processFinished(t1 - t0) def criticalImagesFolderEmpty(self, images_path): - err_title = 'The images folder is empty' + err_title = "The images folder is empty" err_msg = html_utils.paragraph( - 'The following folder

    ' - f'{images_path}

    ' - 'is empty.

    ' + "The following folder

    " + f"{images_path}

    " + "is empty.

    " ) msg = widgets.myMessageBox() msg.addShowInFileManagerButton(images_path) msg.critical(self, err_title, err_msg) - + def criticalNoTifFound(self, images_path): - err_title = 'No .tif files found in folder.' + err_title = "No .tif files found in folder." err_msg = html_utils.paragraph( - 'The following folder

    ' - f'{images_path}

    ' - 'does not contain .tif or .h5 files.

    ' + "The following folder

    " + f"{images_path}

    " + "does not contain .tif or .h5 files.

    " 'Only .tif or .h5 files can be loaded with "Open Folder" button.

    ' - 'Try with File --> Open image/video file... ' - 'and directly select the file you want to load.' + "Try with File --> Open image/video file... " + "and directly select the file you want to load." ) msg = widgets.myMessageBox() msg.addShowInFileManagerButton(images_path) msg.critical(self, err_title, err_msg) - + def waitSegmWorker(self, worker): worker.loop = QEventLoop(self) worker.loop.exec_() - + def _saveConfigurationFile(self, filepath): init_args = { - 'user_ch_name': self.user_ch_name, - 'segm_endname': self.endFilenameSegm, - 'model_name': self.model_name, - 'tracker_name': self.trackerName, - 'do_tracking': self.do_tracking, - 'do_postprocess': self.applyPostProcessing, - 'do_save': self.save, - 'image_channel_tracker': self.image_chName_tracker, - 'isSegm3D': self.isSegm3D, - 'use_ROI': not self.ROIdeactivatedByUser, - 'second_channel_name': self.secondChannelName, - 'use3DdataFor2Dsegm': self.use3DdataFor2Dsegm, - 'reduce_memory_usage': self.reduce_memory_usage, - } - metadata_params = { - 'SizeT': self.SizeT, - 'SizeZ': self.SizeZ + "user_ch_name": self.user_ch_name, + "segm_endname": self.endFilenameSegm, + "model_name": self.model_name, + "tracker_name": self.trackerName, + "do_tracking": self.do_tracking, + "do_postprocess": self.applyPostProcessing, + "do_save": self.save, + "image_channel_tracker": self.image_chName_tracker, + "isSegm3D": self.isSegm3D, + "use_ROI": not self.ROIdeactivatedByUser, + "second_channel_name": self.secondChannelName, + "use3DdataFor2Dsegm": self.use3DdataFor2Dsegm, + "reduce_memory_usage": self.reduce_memory_usage, } + metadata_params = {"SizeT": self.SizeT, "SizeZ": self.SizeZ} track_params = { - key:value for key, value in self.track_params.items() - if key != 'image' + key: value for key, value in self.track_params.items() if key != "image" } ini_items = { - 'workflow': {'type': 'segmentation and/or tracking'}, - 'initialization': init_args, - 'metadata': metadata_params, - 'init_segmentation_model_params': self.init_model_kwargs, - 'segmentation_model_params': self.model_kwargs, - 'init_tracker_params': self.tracker_init_params, - 'tracker_params': track_params, - 'standard_postprocess_features': self.standardPostProcessKwargs, - 'custom_postprocess_features': self.customPostProcessFeatures, + "workflow": {"type": "segmentation and/or tracking"}, + "initialization": init_args, + "metadata": metadata_params, + "init_segmentation_model_params": self.init_model_kwargs, + "segmentation_model_params": self.model_kwargs, + "init_tracker_params": self.tracker_init_params, + "tracker_params": track_params, + "standard_postprocess_features": self.standardPostProcessKwargs, + "custom_postprocess_features": self.customPostProcessFeatures, } - preprocessing_items = config.preprocess_recipe_to_ini_items( - self.preproc_recipe - ) + preprocessing_items = config.preprocess_recipe_to_ini_items(self.preproc_recipe) ini_items = {**ini_items, **preprocessing_items} - + grouped_features = self.customPostProcessGroupedFeatures for category, metrics_names in grouped_features.items(): category_params = {} if isinstance(metrics_names, dict): for channel, channel_metrics in metrics_names.items(): - values = '\n'.join(channel_metrics) - values = f'\n{values}' + values = "\n".join(channel_metrics) + values = f"\n{values}" category_params[channel] = values else: - values = '\n'.join(metrics_names) - values = f'\n{values}' - category_params['names'] = values - ini_items[f'postprocess_features.{category}'] = category_params + values = "\n".join(metrics_names) + values = f"\n{values}" + category_params["names"] = values + ini_items[f"postprocess_features.{category}"] = category_params if self._measurements_kernel is not None: - ini_items['measurements'] = ( + ini_items["measurements"] = ( self._measurements_kernel.to_workflow_config_params() ) - + load.save_workflow_to_config( filepath, ini_items, self.user_ch_file_paths, self.stopFrames ) - + self.logger.info(f'Segmentation workflow saved to "{filepath}"') - + txt = html_utils.paragraph( - 'Segmentation workflow successfully saved to the following location:

    ' - f'{filepath}

    ' - 'You can run the segmentation workflow with the following command:' + "Segmentation workflow successfully saved to the following location:

    " + f"{filepath}

    " + "You can run the segmentation workflow with the following command:" ) command = f'acdc -p "{filepath}"' msg = widgets.myMessageBox(wrapText=False) msg.information( - self, 'Workflow save', txt, + self, + "Workflow save", + txt, commands=(command,), - path_to_browse=os.path.dirname(filepath) + path_to_browse=os.path.dirname(filepath), ) - + def saveWorkflowToConfigFile(self): - timestamp = datetime.datetime.now().strftime( - r'%Y-%m-%d_%H-%M' - ) + timestamp = datetime.datetime.now().strftime(r"%Y-%m-%d_%H-%M") win = apps.filenameDialog( - parent=self, - ext='.ini', - title='Insert filename for configuration file', - hintText='Insert filename for the configuration file', - allowEmpty=False, - defaultEntry=f'{timestamp}_acdc_segm_track_workflow' + parent=self, + ext=".ini", + title="Insert filename for configuration file", + hintText="Insert filename for the configuration file", + allowEmpty=False, + defaultEntry=f"{timestamp}_acdc_segm_track_workflow", ) win.exec_() if win.cancel: return False - + config_filename = win.filename mostRecentPath = myutils.getMostRecentPath() folder_path = apps.get_existing_directory( allow_images_path=False, - parent=self, - caption='Select folder where to save configuration file', + parent=self, + caption="Select folder where to save configuration file", basedir=mostRecentPath, # options=QFileDialog.DontUseNativeDialog ) if not folder_path: return False - + config_filepath = os.path.join(folder_path, config_filename) self._saveConfigurationFile(config_filepath) - + def showHelpSaveMeasurements(self, parent=None): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(f""" @@ -1099,73 +1106,72 @@ def showHelpSaveMeasurements(self, parent=None): If you plan to visualize and correct segmentation results, and you need the measurements, you will anyway need to compute
    and save them after correcting the segmentations. - """ - ) - msg.information(parent, 'Help - Save measurements', txt) - + """) + msg.information(parent, "Help - Save measurements", txt) + def askSaveMeasurements(self): measurements_kernel = None - + if not self.save: return True, measurements_kernel - - acdcOutputEndname = ( - self.endFilenameSegm.replace('segm', 'acdc_output') - .replace('.npz', '.csv') + + acdcOutputEndname = self.endFilenameSegm.replace("segm", "acdc_output").replace( + ".npz", ".csv" ) txt = html_utils.paragraph(f""" Do you also want to save measurements in the {acdcOutputEndname} table after segmentation? """) msg = widgets.myMessageBox(wrapText=False) - saveButton = widgets.savePushButton('Yes, save measurements') - noSaveButton = widgets.noPushButton('Do not save measurements') - helpButton = widgets.helpPushButton('Help...') + saveButton = widgets.savePushButton("Yes, save measurements") + noSaveButton = widgets.noPushButton("Do not save measurements") + helpButton = widgets.helpPushButton("Help...") msg.question( - self, 'Save measurements?', txt, - buttonsTexts=( - 'Cancel', helpButton, noSaveButton, saveButton - ), - showDialog=False + self, + "Save measurements?", + txt, + buttonsTexts=("Cancel", helpButton, noSaveButton, saveButton), + showDialog=False, ) helpButton.clicked.disconnect() - helpButton.clicked.connect( - partial(self.showHelpSaveMeasurements, parent=msg) - ) + helpButton.clicked.connect(partial(self.showHelpSaveMeasurements, parent=msg)) msg.exec_() if msg.cancel: return False, measurements_kernel - + if not msg.clickedButton == saveButton: return True, measurements_kernel - - self.logger.info('Setting up measurements...') - - segmEndname = self.endFilenameSegm.replace('.npz', '') + + self.logger.info("Setting up measurements...") + + segmEndname = self.endFilenameSegm.replace(".npz", "") images_path = os.path.dirname(self.user_ch_file_paths[0]) pos_path = os.path.dirname(images_path) exp_path = os.path.dirname(pos_path) pos_foldernames = [ - os.path.basename(os.path.dirname(os.path.dirname(img_path))) + os.path.basename(os.path.dirname(os.path.dirname(img_path))) for img_path in self.user_ch_file_paths ] selectedExpPaths = {exp_path: pos_foldernames} - + from .utils import compute as utilsCompute + self.calcMeasUtility = utilsCompute.computeMeasurmentsUtilWin( - selectedExpPaths, self.app, segmEndname=segmEndname, - parent=self, doRunComputation=False + selectedExpPaths, + self.app, + segmEndname=segmEndname, + parent=self, + doRunComputation=False, ) self.calcMeasUtility.runWorker( - showProgress=False, - stopFrameNumber=self.stopFrames + showProgress=False, stopFrameNumber=self.stopFrames ) self.waitCalcMeasUtility() - + measurements_kernel = self.calcMeasUtility.worker.kernel - + return not self.calcMeasUtility.cancel, measurements_kernel - + def waitCalcMeasUtility(self): self.waitCalcMeasUtilityTimer = QTimer(self) self.waitCalcMeasUtilityTimer.timeout.connect( @@ -1179,7 +1185,7 @@ def checkCalcMeasUtilityFinished(self, calcMeasUtility): if calcMeasUtility.isWorkerFinished: self.waitCalcMeasUtilityLoop.exit() self.waitCalcMeasUtilityTimer.stop() - + def askRunNowOrSaveConfigFile(self): txt = html_utils.paragraph(""" Do you want to run the segmentation process now
    @@ -1190,24 +1196,24 @@ def askRunNowOrSaveConfigFile(self): (i.e., headless).
    """) msg = widgets.myMessageBox(wrapText=False) - saveButton = widgets.savePushButton('Save and run later') - runNowButton = widgets.playPushButton('Run now') + saveButton = widgets.savePushButton("Save and run later") + runNowButton = widgets.playPushButton("Run now") _, saveButton, runNowButton = msg.question( - self, 'Run workflow now?', txt, - buttonsTexts=( - 'Cancel', saveButton, runNowButton - ) + self, + "Run workflow now?", + txt, + buttonsTexts=("Cancel", saveButton, runNowButton), ) if msg.cancel: return False - + if msg.clickedButton == saveButton: saved = self.saveWorkflowToConfigFile() if not saved: return False - + return msg.clickedButton == runNowButton - + def askMultipleSegm(self, segm_files, isTimelapse=True): txt = html_utils.paragraph(""" At least one of the loaded positions already contains a @@ -1216,25 +1222,22 @@ def askMultipleSegm(self, segm_files, isTimelapse=True): NOTE: you will be able to choose a stop frame later.
    """) msg = widgets.myMessageBox(resizeButtons=False) - msg.setWindowTitle('Multiple segmentation files') + msg.setWindowTitle("Multiple segmentation files") msg.addText(txt) if len(segm_files) > 1: - overWriteText = 'Select segm. file to overwrite...' + overWriteText = "Select segm. file to overwrite..." else: - overWriteText = 'Overwrite existing segmentation file' + overWriteText = "Overwrite existing segmentation file" overWriteButton = widgets.savePushButton(overWriteText) - doNotSaveButton = widgets.noPushButton('Do not save') - newButton = widgets.newFilePushButton('Save as...') + doNotSaveButton = widgets.noPushButton("Do not save") + newButton = widgets.newFilePushButton("Save as...") msg.addCancelButton(connect=True) msg.addButton(overWriteButton) msg.addButton(newButton) msg.addButton(doNotSaveButton) - if len(segm_files)>1: + if len(segm_files) > 1: overWriteButton.clicked.disconnect() - func = partial( - self.selectSegmFile, segm_files, True, msg, - overWriteButton - ) + func = partial(self.selectSegmFile, segm_files, True, msg, overWriteButton) overWriteButton.clicked.connect(func) else: self.selectedSegmFile = segm_files[0] @@ -1253,29 +1256,25 @@ def askMultipleSegm(self, segm_files, isTimelapse=True): return askNewName def askHowToHandle2DsegmOn3Ddata(self): - txt = html_utils.paragraph( - 'How do you want to handle 3D data?' - ) - use3DButton = widgets.threeDPushButton( - 'Pass all z-slices to the model' - ) + txt = html_utils.paragraph("How do you want to handle 3D data?") + use3DButton = widgets.threeDPushButton("Pass all z-slices to the model") convertTo2DButton = widgets.twoDPushButton( - 'Use or select z-slices or projection from Data prep' - ) - buttons = ( - 'Cancel', use3DButton, convertTo2DButton + "Use or select z-slices or projection from Data prep" ) + buttons = ("Cancel", use3DButton, convertTo2DButton) msg = widgets.myMessageBox(wrapText=False) - msg.question(self, 'How to handle 3D data', txt, buttonsTexts=buttons) - + msg.question(self, "How to handle 3D data", txt, buttonsTexts=buttons) + return msg.cancel, msg.clickedButton == use3DButton - + def selectSegmFile(self, segm_files, isOverwrite, msg, button): - action = 'overwrite' if isOverwrite else 'concatenate to' + action = "overwrite" if isOverwrite else "concatenate to" selectSegmFileWin = widgets.QDialogListbox( - 'Select segmentation file', - f'Select segmentation file to {action}:\n', - segm_files, multiSelection=False, parent=msg + "Select segmentation file", + f"Select segmentation file to {action}:\n", + segm_files, + multiSelection=False, + parent=msg, ) selectSegmFileWin.exec_() if selectSegmFileWin.cancel: @@ -1287,12 +1286,12 @@ def selectSegmFile(self, segm_files, isOverwrite, msg, button): button.clicked.disconnect() button.clicked.connect(msg.buttonCallBack) button.click() - + def log(self, text): self.logger.info(text) try: self.logTerminal.append(text) - self.logTerminal.append('-'*30) + self.logTerminal.append("-" * 30) maxScrollbar = self.logTerminal.verticalScrollBar().maximum() self.logTerminal.verticalScrollBar().setValue(maxScrollbar) except AttributeError: @@ -1312,7 +1311,7 @@ def reset_innerQPbar(self, num_frames): def create_tqdm_pbar(self, num_frames): self.tqdm_pbar = tqdm( - total=num_frames, unit=' frames', ncols=75, file=self.logTerminal + total=num_frames, unit=" frames", ncols=75, file=self.logTerminal ) def update_tqdm_pbar(self, step): @@ -1322,22 +1321,23 @@ def close_tqdm(self): self.tqdm_pbar.close() def setPredictBuddingModel(self): - self.downloadYeastMate = apps.downloadModel('YeastMate', parent=self) + self.downloadYeastMate = apps.downloadModel("YeastMate", parent=self) self.downloadYeastMate.download() import models.YeastMate.acdcSegment as yeastmate + self.predictCcaState_model = yeastmate.Model() def startSegmWorker(self, img_path, stop_frame_n): thread = QThread() - + worker = SegmWorker(img_path, self, stop_frame_n) worker.is_error = False - + worker.moveToThread(thread) worker.signals.finished.connect(thread.quit) worker.signals.finished.connect(worker.deleteLater) thread.finished.connect(thread.deleteLater) - + worker.signals.finished.connect(self.segmWorkerFinished) worker.signals.progress.connect(self.segmWorkerProgress) worker.signals.progressBar.connect(self.segmWorkerProgressBar) @@ -1347,12 +1347,12 @@ def startSegmWorker(self, img_path, stop_frame_n): worker.signals.progress_tqdm.connect(self.update_tqdm_pbar) worker.signals.signal_close_tqdm.connect(self.close_tqdm) worker.signals.critical.connect(self.workerCritical) - + thread.started.connect(worker.run) thread.start() - + return worker, thread - + @exception_handler def workerCritical(self, out: Tuple[QObject, Exception]): worker, error = out @@ -1364,73 +1364,67 @@ def debugSegmWorker(self, lab): apps.imshow_tk(lab) def segmWorkerProgress(self, text): - print('-----------------------------------------') + print("-----------------------------------------") self.logger.info(text) self.progressLabel.setText(text) def segmWorkerProgressBar(self, step): - self.QPbar.setValue(self.QPbar.value()+step) - steps_left = self.QPbar.maximum()-self.QPbar.value() + self.QPbar.setValue(self.QPbar.value() + step) + steps_left = self.QPbar.maximum() - self.QPbar.value() # Update ETA every two calls of this function - if steps_left%2 == 0: + if steps_left % 2 == 0: t = time.time() self.exec_time_per_iter = t - self.time_last_pbar_update - groups_2steps_left = steps_left/2 - seconds = round(self.exec_time_per_iter*groups_2steps_left) + groups_2steps_left = steps_left / 2 + seconds = round(self.exec_time_per_iter * groups_2steps_left) ETA = myutils.seconds_to_ETA(seconds) - self.ETA_label.setText(f'ETA: {ETA}') + self.ETA_label.setText(f"ETA: {ETA}") self.exec_time_per_iter = 0 self.time_last_pbar_update = t def segmWorkerInnerProgressBar(self, step): - self.innerQPbar.setValue(self.innerQPbar.value()+step) + self.innerQPbar.setValue(self.innerQPbar.value() + step) t = time.time() self.exec_time_per_frame = t - self.time_last_innerPbar_update - steps_left = self.QPbar.maximum()-self.QPbar.value() - seconds = round(self.exec_time_per_frame*steps_left) + steps_left = self.QPbar.maximum() - self.QPbar.value() + seconds = round(self.exec_time_per_frame * steps_left) ETA = myutils.seconds_to_ETA(seconds) - self.innerETA_label.setText(f'ETA: {ETA}') + self.innerETA_label.setText(f"ETA: {ETA}") self.exec_time_per_frame = 0 self.time_last_innerPbar_update = t # Estimate total ETA current_numFrames = self.QPbar.maximum() - tot_seconds = round(self.exec_time_per_frame*current_numFrames) + tot_seconds = round(self.exec_time_per_frame * current_numFrames) numPos = self.QPbar.maximum() - allPos_seconds = tot_seconds*numPos - tot_seconds_left = allPos_seconds-tot_seconds + allPos_seconds = tot_seconds * numPos + tot_seconds_left = allPos_seconds - tot_seconds ETA = myutils.seconds_to_ETA(round(tot_seconds_left)) - total_ETA = self.ETA_label.setText(f'ETA: {ETA}') + total_ETA = self.ETA_label.setText(f"ETA: {ETA}") - def segmWorkerFinished(self, worker): + def segmWorkerFinished(self, worker): worker.loop.exit() - + def processFinished(self, total_exec_time): - short_txt = 'Segmentation process finished!' + short_txt = "Segmentation process finished!" exec_time = round(total_exec_time) delta = datetime.timedelta(seconds=exec_time) - exec_time_delta = str(delta).split(',')[-1].strip() - h, m, s = str(exec_time_delta).split(':') - exec_time_delta = f'{int(h):02}h:{int(m):02}m:{int(s):02}s' + exec_time_delta = str(delta).split(",")[-1].strip() + h, m, s = str(exec_time_delta).split(":") + exec_time_delta = f"{int(h):02}h:{int(m):02}m:{int(s):02}s" items = ( - f'Total execution time: {exec_time_delta}
    ', - f'Selected folder: {self.exp_path}' - ) - txt = ( - 'Segmentation task ended.' - f'{html_utils.to_list(items)}' - ) - steps_left = self.QPbar.maximum()-self.QPbar.value() - self.QPbar.setValue(self.QPbar.value()+steps_left) - - txt = html_utils.paragraph( - f'{txt}
    {myutils.get_salute_string()}' + f"Total execution time: {exec_time_delta}
    ", + f"Selected folder: {self.exp_path}", ) + txt = f"Segmentation task ended.{html_utils.to_list(items)}" + steps_left = self.QPbar.maximum() - self.QPbar.value() + self.QPbar.setValue(self.QPbar.value() + steps_left) + + txt = html_utils.paragraph(f"{txt}
    {myutils.get_salute_string()}") self.progressLabel.setText(short_txt) msg = widgets.myMessageBox(self, wrapText=False) msg.information( - self, 'Segmentation task ended.', txt, - path_to_browse=self.exp_path + self, "Segmentation task ended.", txt, path_to_browse=self.exp_path ) try: del self.posData @@ -1453,7 +1447,7 @@ def processStopped(self): msg = widgets.myMessageBox(showCentered=False) closeAnswer = msg.warning( - self, 'Execution cancelled', 'Segmentation task cancelled.' + self, "Execution cancelled", "Segmentation task cancelled." ) try: del self.posData @@ -1468,9 +1462,7 @@ def processStopped(self): except AttributeError: pass self.close() - - def warnSegmWorkerStillRunning(self): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph(""" @@ -1479,38 +1471,36 @@ def warnSegmWorkerStillRunning(self): Are you sure you want to continue? """) noButton, yesButton = msg.warning( - self, 'Process still running', txt, - buttonsTexts=( - 'No, wait for the process to end', - 'Yes, close Cell-ACDC' - ) + self, + "Process still running", + txt, + buttonsTexts=("No, wait for the process to end", "Yes, close Cell-ACDC"), ) if msg.cancel: return False return msg.clickedButton == yesButton def closeEvent(self, event): - print('') - self.log('Closing segmentation module...') + print("") + self.log("Closing segmentation module...") if self.buttonToRestore is not None: button, color, text = self.buttonToRestore button.setText(text) - button.setStyleSheet( - f'QPushButton {{background-color: {color};}}') + button.setStyleSheet(f"QPushButton {{background-color: {color};}}") self.mainWin.setWindowState(Qt.WindowNoState) self.mainWin.setWindowState(Qt.WindowActive) self.mainWin.raise_() - - self.log('Closing segmentation module logger...') + + self.log("Closing segmentation module logger...") handlers = self.logger.handlers[:] for handler in handlers: handler.close() self.logger.removeHandler(handler) - + try: self.model.closeLogger() except Exception as e: pass - - self.log('Segmentation module closed.') + + self.log("Segmentation module closed.") self.sigClosed.emit() diff --git a/cellacdc/segm_utils.py b/cellacdc/segm_utils.py index 790e59719..b1129d3df 100644 --- a/cellacdc/segm_utils.py +++ b/cellacdc/segm_utils.py @@ -7,9 +7,9 @@ import inspect +import os # for dbug +import json # for dbug -import os # for dbug -import json # for dbug def find_overlap(lab_1, lab_2): """ @@ -38,12 +38,14 @@ def find_overlap(lab_1, lab_2): return ID_overlap + def get_obj_from_rps(rps, ID): for obj in rps: if obj.label == ID: return obj return None + def get_box_coords(rps, prev_lab_shape, ID, padding): """ Calculate the coordinates of a bounding box around a given ID in a labeled image, @@ -73,15 +75,16 @@ def get_box_coords(rps, prev_lab_shape, ID, padding): return box_x_min, box_x_max, box_y_min, box_y_max + def find_overlapping_bboxs(IDs, bboxs, order=1): """ Finds and merges overlapping bounding boxes by considering chained overlaps. - + Parameters: - IDs: List of IDs corresponding to the bounding boxes. - bboxs: List of bounding boxes (x_min, x_max, y_min, y_max). - order: Number of times to perform the merging process. - + Returns: - new_bboxs: List of merged bounding boxes. """ @@ -92,16 +95,12 @@ def boxes_overlap(bbox1, bbox2): x_min2, x_max2, y_min2, y_max2 = bbox2 # Check if there's no overlap - if (x_max1 <= x_min2 or - x_max2 <= x_min1 or - y_max1 <= y_min2 or - y_max2 <= y_min1 - ): + if x_max1 <= x_min2 or x_max2 <= x_min1 or y_max1 <= y_min2 or y_max2 <= y_min1: return False else: return True - - IDs = [[ID] for ID in IDs] + + IDs = [[ID] for ID in IDs] for _ in range(order): merged = [False] * len(bboxs) # Keep track of whether a box has been merged @@ -115,7 +114,7 @@ def boxes_overlap(bbox1, bbox2): # Start with the current bbox as the base for merging current_merged_bbox = bbox merged[i] = True # Mark this box as merged - IDs_merged = IDs[i] # Keep track of the IDs that have been merged + IDs_merged = IDs[i] # Keep track of the IDs that have been merged # Try to merge it with all other boxes for j, other_bbox in enumerate(bboxs): @@ -131,17 +130,18 @@ def boxes_overlap(bbox1, bbox2): min(x_min1, x_min2), max(x_max1, x_max2), min(y_min1, y_min2), - max(y_max1, y_max2) + max(y_max1, y_max2), ) merged[j] = True # Mark the other box as merged - IDs_merged.extend(IDs[j]) # Add the IDs of the other box to the merged IDs + IDs_merged.extend( + IDs[j] + ) # Add the IDs of the other box to the merged IDs # Add the merged bbox to the new list new_bboxs.append(current_merged_bbox) new_IDs.append(IDs_merged) - # If no changes occur, break the loop early if len(new_bboxs) == len(bboxs): break @@ -152,6 +152,7 @@ def boxes_overlap(bbox1, bbox2): return IDs, bboxs + # def fast_border_touching_labels(label_img): # # Get unique labels from the four borders # border_labels = np.r_[ @@ -163,13 +164,23 @@ def boxes_overlap(bbox1, bbox2): # # Use np.unique once on the combined array # return np.unique(border_labels[border_labels != 0]) -def single_cell_seg(model, prev_lab, curr_lab, curr_img, IDs, new_unique_ID, - win, posData, distance_filler_growth=1, - overlap_threshold=0.5, padding=0.4, - export_bbox_for_training=False, - ): + +def single_cell_seg( + model, + prev_lab, + curr_lab, + curr_img, + IDs, + new_unique_ID, + win, + posData, + distance_filler_growth=1, + overlap_threshold=0.5, + padding=0.4, + export_bbox_for_training=False, +): """ - Function to segment single cells in the current frame using the previous frame segmentation as a reference. + Function to segment single cells in the current frame using the previous frame segmentation as a reference. IDs is from the previous frame segmentation, and the current frame should have already been tracked so the IDs match! Args: model: eval function used to segment the cells @@ -206,10 +217,12 @@ def single_cell_seg(model, prev_lab, curr_lab, curr_img, IDs, new_unique_ID, bboxs = [get_box_coords(prev_rp, prev_lab_shape, ID, padding) for ID in IDs] IDs_bboxs, bboxs = find_overlapping_bboxs(IDs, bboxs) - + assigned_IDs = [] - uses_diameter = inspect.signature(model.segment).parameters.get('diameter', None) is not None + uses_diameter = ( + inspect.signature(model.segment).parameters.get("diameter", None) is not None + ) for IDs, bbox in zip(IDs_bboxs, bboxs): box_x_min, box_x_max, box_y_min, box_y_max = bbox @@ -220,12 +233,16 @@ def single_cell_seg(model, prev_lab, curr_lab, curr_img, IDs, new_unique_ID, IDs = np.array(IDs) box_curr_lab_other_IDs[np.isin(box_curr_lab_other_IDs, IDs)] = 0 - box_curr_lab_other_IDs_grown = skimage.segmentation.expand_labels(box_curr_lab_other_IDs, distance=distance_filler_growth) + box_curr_lab_other_IDs_grown = skimage.segmentation.expand_labels( + box_curr_lab_other_IDs, distance=distance_filler_growth + ) # Fill other IDs with random samples from the background indices_to_fill = np.where(box_curr_lab_other_IDs_grown != 0) - box_background = box_curr_img[box_curr_lab_other_IDs_grown==0] - random_samples = np.random.choice(box_background, size=indices_to_fill[0].shape, replace=True) + box_background = box_curr_img[box_curr_lab_other_IDs_grown == 0] + random_samples = np.random.choice( + box_background, size=indices_to_fill[0].shape, replace=True + ) box_curr_img[indices_to_fill] = random_samples # Run model, give it the diameter of cell if possible @@ -234,36 +251,42 @@ def single_cell_seg(model, prev_lab, curr_lab, curr_img, IDs, new_unique_ID, for ID in IDs: obj = get_obj_from_rps(prev_rp, ID) diameters.append(obj.axis_major_length) - + if len(diameters) == 0: diameter = None else: diameter = np.mean(diameters) - model_kwargs['diameter'] = diameter - + model_kwargs["diameter"] = diameter + box_model_lab = segm_model_segment( - model, box_curr_img, model_kwargs, + model, + box_curr_img, + model_kwargs, preproc_recipe=preproc_recipe, posData=posData, ) if export_bbox_for_training: - bboxs_for_debug.append([IDs, bbox, box_model_lab.copy(), box_curr_lab.copy()]) + bboxs_for_debug.append( + [IDs, bbox, box_model_lab.copy(), box_curr_lab.copy()] + ) - # Post-processing + # Post-processing if applyPostProcessing: box_model_lab = post_process_segm( box_model_lab, **standardPostProcessKwargs ) if customPostProcessFeatures: box_model_lab = custom_post_process_segm( - posData, - customPostProcessGroupedFeatures, - box_model_lab, box_curr_img, posData.frame_i, - posData.filename, - posData.user_ch_name, - customPostProcessFeatures + posData, + customPostProcessGroupedFeatures, + box_model_lab, + box_curr_img, + posData.frame_i, + posData.filename, + posData.user_ch_name, + customPostProcessFeatures, ) ### maybe add roi extension if cells are deleted... @@ -275,7 +298,7 @@ def single_cell_seg(model, prev_lab, curr_lab, curr_img, IDs, new_unique_ID, for ID, overlap_perc in overlap: if overlap_perc > overlap_threshold: box_model_lab[box_model_lab == ID] = 0 - + rp_model_lab = skimage.measure.regionprops(box_model_lab) for obj in rp_model_lab: box_curr_lab_other_IDs[box_model_lab == obj.label] = new_unique_ID @@ -283,7 +306,9 @@ def single_cell_seg(model, prev_lab, curr_lab, curr_img, IDs, new_unique_ID, new_unique_ID += 1 positive_mask = box_curr_lab_other_IDs > 0 - curr_lab[box_x_min:box_x_max, box_y_min:box_y_max][positive_mask] = box_curr_lab_other_IDs[positive_mask] + curr_lab[box_x_min:box_x_max, box_y_min:box_y_max][positive_mask] = ( + box_curr_lab_other_IDs[positive_mask] + ) if export_bbox_for_training: bboxs_for_debug[-1].append(box_curr_lab_other_IDs.copy()) @@ -291,13 +316,20 @@ def single_cell_seg(model, prev_lab, curr_lab, curr_img, IDs, new_unique_ID, if export_bbox_for_training: frame_i = posData.frame_i - os.makedirs(os.path.join(posData.images_path, ".train_box_data", posData.filename), exist_ok=True) + os.makedirs( + os.path.join(posData.images_path, ".train_box_data", posData.filename), + exist_ok=True, + ) - npz_filepath = os.path.join(posData.images_path, ".train_box_data", posData.filename) - json_filepath = os.path.join(posData.images_path, ".train_box_data", posData.filename, 'info.json') + npz_filepath = os.path.join( + posData.images_path, ".train_box_data", posData.filename + ) + json_filepath = os.path.join( + posData.images_path, ".train_box_data", posData.filename, "info.json" + ) try: - with open(json_filepath, 'r') as f: + with open(json_filepath, "r") as f: loaded_dict = json.load(f) except FileNotFoundError: loaded_dict = {} @@ -311,14 +343,21 @@ def single_cell_seg(model, prev_lab, curr_lab, curr_img, IDs, new_unique_ID, end_i = start_i + len(bboxs_for_debug) for i in range(start_i, end_i): - IDs, bbox, box_model_lab, box_prev_lab, box_final_lab = bboxs_for_debug[i - start_i] + IDs, bbox, box_model_lab, box_prev_lab, box_final_lab = bboxs_for_debug[ + i - start_i + ] npz_path = os.path.join(npz_filepath, f"{frame_i}_{i}.npz") - io.savez_compressed(npz_path, box_model_lab=box_model_lab, box_prev_lab=box_prev_lab, box_final_lab=box_final_lab) + io.savez_compressed( + npz_path, + box_model_lab=box_model_lab, + box_prev_lab=box_prev_lab, + box_final_lab=box_final_lab, + ) bboxs_info.append([IDs, bbox, npz_path]) - + loaded_dict[frame_i] = bboxs_info - with open(json_filepath, 'w') as f: + with open(json_filepath, "w") as f: json.dump(loaded_dict, f, indent=4) - return curr_lab, assigned_IDs, IDs_bboxs, bboxs \ No newline at end of file + return curr_lab, assigned_IDs, IDs_bboxs, bboxs diff --git a/cellacdc/segmentation.py b/cellacdc/segmentation.py index 9bfba56ad..4dfafa578 100644 --- a/cellacdc/segmentation.py +++ b/cellacdc/segmentation.py @@ -5,21 +5,17 @@ import cv2 -def _find_contours_2D( - image, bbox_lower_coords=(0, 0), all=False, closed=True - ): + +def _find_contours_2D(image, bbox_lower_coords=(0, 0), all=False, closed=True): mode = cv2.RETR_CCOMP if all else cv2.RETR_EXTERNAL contours, _ = cv2.findContours(image, mode, cv2.CHAIN_APPROX_NONE) - + if all: all_contours = [ - np.squeeze(contour, axis=1)+bbox_lower_coords - for contour in contours + np.squeeze(contour, axis=1) + bbox_lower_coords for contour in contours ] if closed: - all_contours = [ - np.vstack((contour, contour[0])) for contour in contours - ] + all_contours = [np.vstack((contour, contour[0])) for contour in contours] return all_contours else: contour = np.squeeze(contours[0], axis=1) @@ -28,17 +24,21 @@ def _find_contours_2D( contour = contour + bbox_lower_coords return contour + def find_obj_contour( - obj: skimage.measure._regionprops.RegionProperties, all=False, - local=False, do_z_max_proj=False, closed=True - ): + obj: skimage.measure._regionprops.RegionProperties, + all=False, + local=False, + do_z_max_proj=False, + closed=True, +): is3D = obj.image.ndim == 3 bbox_y_idx = 1 if is3D else 0 if local: - bbox_lower_coords=(0, 0) + bbox_lower_coords = (0, 0) else: - min_y, min_x = obj.bbox[bbox_y_idx:bbox_y_idx+2] + min_y, min_x = obj.bbox[bbox_y_idx : bbox_y_idx + 2] bbox_lower_coords = (min_x, min_y) if is3D and do_z_max_proj: @@ -47,23 +47,18 @@ def find_obj_contour( else: obj_image = obj.image.astype(np.uint8) - kwargs = { - 'bbox_lower_coords': bbox_lower_coords, - 'all':all, 'closed': closed - } + kwargs = {"bbox_lower_coords": bbox_lower_coords, "all": all, "closed": closed} if is3D: - contours = [ - _find_contours_2D(image_z, **kwargs) for image_z in obj_image - ] + contours = [_find_contours_2D(image_z, **kwargs) for image_z in obj_image] else: contours = _find_contours_2D(obj_image, **kwargs) return contours + def find_contours( - label_img, connectivity=1, mode='thick', background=0, - return_coords=False, **kwargs - ): - """Return bool array where boundaries between labeled regions are True. + label_img, connectivity=1, mode="thick", background=0, return_coords=False, **kwargs +): + """Return bool array where boundaries between labeled regions are True. If `return_coords` is True then return also a list of objects' contours coordinates. @@ -92,7 +87,7 @@ def find_contours( marked. - subpixel: return a doubled image, with pixels *between* the original pixels marked as boundary where appropriate., - + By default 'thick' background : int, optional For modes 'inner' and 'outer', a definition of a background @@ -102,8 +97,8 @@ def find_contours( If ``True``, also return a list of objects' contours coordinates, by default False kwargs : dict, optional - Additional arguments passed `acdctools.segmentation.find_obj_contour` - function. This function uses the opencv find contours function + Additional arguments passed `acdctools.segmentation.find_obj_contour` + function. This function uses the opencv find contours function `cv2.findContours`. Used only if `mode='inner'`. Returns @@ -115,25 +110,25 @@ def find_contours( inserted in between all other pairs of pixels). contours_coords: list of ndarray A list of ndarrays with shape (N, n) where `n` is the number of - dimensions of `label_img` and `N` is the number of points in each - object's contour. The list contains one ndarray per object in - `label_img`. - The ordering of columns follows the numpy's order of dimensions - convention, e.g., for 2-D, the first and second column are the - y and x coordinates, respectively. + dimensions of `label_img` and `N` is the number of points in each + object's contour. The list contains one ndarray per object in + `label_img`. + The ordering of columns follows the numpy's order of dimensions + convention, e.g., for 2-D, the first and second column are the + y and x coordinates, respectively. Only provided if `return_coords` is True. - """ + """ boundaries = skimage.segmentation.find_boundaries( label_img, connectivity=connectivity, mode=mode, background=background ) if not return_coords: return boundaries - + is2D = label_img.ndim == 2 rp = skimage.measure.regionprops(label_img) contours_coords = [] for obj in rp: - if mode == 'inner' and is2D: + if mode == "inner" and is2D: pass else: pass diff --git a/cellacdc/segmenters/BABY/__init__.py b/cellacdc/segmenters/BABY/__init__.py index 04cce55ec..02f9914df 100644 --- a/cellacdc/segmenters/BABY/__init__.py +++ b/cellacdc/segmenters/BABY/__init__.py @@ -1,2 +1,2 @@ # Installation of BABY is taken care of in the tracker implementation -from cellacdc.trackers.BABY import BABY_MODELS \ No newline at end of file +from cellacdc.trackers.BABY import BABY_MODELS diff --git a/cellacdc/segmenters/BABY/acdcSegment.py b/cellacdc/segmenters/BABY/acdcSegment.py index 1d6706d78..abea25034 100644 --- a/cellacdc/segmenters/BABY/acdcSegment.py +++ b/cellacdc/segmenters/BABY/acdcSegment.py @@ -7,29 +7,28 @@ from cellacdc.trackers import BABY from cellacdc.trackers.BABY import BABY_tracker + class AvailableModels: values = BABY.BABY_MODELS + class Model: def __init__( - self, - model_name: AvailableModels='yeast-alcatras-brightfield-sCMOS-60x-5z', - ): + self, + model_name: AvailableModels = "yeast-alcatras-brightfield-sCMOS-60x-5z", + ): self.tracker = BABY_tracker.tracker(model_name) - + def segment( - self, image, - refine_outlines=True, - swap_YX_axes_to_XY=True, - PhysicalSizeX=1.0 - ): + self, image, refine_outlines=True, swap_YX_axes_to_XY=True, PhysicalSizeX=1.0 + ): Y, X = image.shape[-2:] lab = np.zeros((Y, X), dtype=np.uint32) - + image = self.tracker._preprocess(image, swap_YX_axes_to_XY) - + result_generator = self.tracker.crawler.baby_brain.segment( - image[None, ...], + image[None, ...], pixel_size=PhysicalSizeX, overlap_size=48, yield_edgemasks=False, @@ -38,23 +37,20 @@ def segment( yield_volumes=False, refine_outlines=refine_outlines, yield_rescaling=False, - keep_bb_pixel_size=False + keep_bb_pixel_size=False, ) - + for result in result_generator: - masks = result['masks'] + masks = result["masks"] areas_mapper = { - m: (np.count_nonzero(mask), mask) - for m, mask in enumerate(masks) + m: (np.count_nonzero(mask), mask) for m, mask in enumerate(masks) } areas_mapper = dict( - sorted(areas_mapper.items(), - key=lambda item: item[1][0], - reverse=True) + sorted(areas_mapper.items(), key=lambda item: item[1][0], reverse=True) ) for i, (_, mask) in areas_mapper.items(): if swap_YX_axes_to_XY: mask = np.swapaxes(mask, 0, 1) - lab[mask] = i+1 - + lab[mask] = i + 1 + return lab diff --git a/cellacdc/segmenters/Cellpose_germlineNuclei/__init__.py b/cellacdc/segmenters/Cellpose_germlineNuclei/__init__.py index e1e15a14e..6a2be49ea 100644 --- a/cellacdc/segmenters/Cellpose_germlineNuclei/__init__.py +++ b/cellacdc/segmenters/Cellpose_germlineNuclei/__init__.py @@ -1,3 +1,3 @@ from cellacdc import myutils -myutils.check_install_cellpose() \ No newline at end of file +myutils.check_install_cellpose() diff --git a/cellacdc/segmenters/Cellpose_germlineNuclei/acdcSegment.py b/cellacdc/segmenters/Cellpose_germlineNuclei/acdcSegment.py index d5c5fb6cc..5331a6fd5 100644 --- a/cellacdc/segmenters/Cellpose_germlineNuclei/acdcSegment.py +++ b/cellacdc/segmenters/Cellpose_germlineNuclei/acdcSegment.py @@ -14,95 +14,90 @@ from cellacdc import user_profile_path default_model_path = os.path.join( - user_profile_path, - 'acdc-Cellpose_germlineNuclei', - 'cellpose_germlineNuclei_2023' + user_profile_path, "acdc-Cellpose_germlineNuclei", "cellpose_germlineNuclei_2023" ) + class Model: - def __init__( - self, - model_path: os.PathLike=default_model_path, - gpu=False - ): + def __init__(self, model_path: os.PathLike = default_model_path, gpu=False): self.model = models.CellposeModel( gpu=gpu, diam_mean=30, pretrained_model=model_path ) - + def setupLogger(self, logger): models.models_logger = logger - - def setLoggerPropagation(self, propagate:bool): + + def setLoggerPropagation(self, propagate: bool): models.models_logger.propagate = propagate - - def setLoggerLevel(self, level:str): + + def setLoggerLevel(self, level: str): import logging - if level == 'error': + + if level == "error": models.models_logger.setLevel(logging.ERROR) - - + def closeLogger(self): handlers = models.models_logger.handlers[:] for handler in handlers: handler.close() models.models_logger.removeHandler(handler) - + def _eval(self, image, **kwargs): return self.model.eval(image.astype(np.float32), **kwargs)[0] - + def _initialize_image(self, image): # See cellpose.gui.io._initialize_images if image.ndim > 3: # make tiff Z x channels x W x H - if image.shape[0]<4: + if image.shape[0] < 4: # tiff is channels x Z x W x H - image = np.transpose(image, (1,0,2,3)) - elif image.shape[-1]<4: + image = np.transpose(image, (1, 0, 2, 3)) + elif image.shape[-1] < 4: # tiff is Z x W x H x channels - image = np.transpose(image, (0,3,1,2)) + image = np.transpose(image, (0, 3, 1, 2)) # fill in with blank channels to make 3 channels if image.shape[1] < 3: shape = image.shape - shape_to_concat = (shape[0], 3-shape[1], shape[2], shape[3]) + shape_to_concat = (shape[0], 3 - shape[1], shape[2], shape[3]) to_concat = np.zeros(shape_to_concat, dtype=np.uint8) image = np.concatenate((image, to_concat), axis=1) - image = np.transpose(image, (0,2,3,1)) - elif image.ndim==3: + image = np.transpose(image, (0, 2, 3, 1)) + elif image.ndim == 3: if image.shape[0] < 5: - image = np.transpose(image, (1,2,0)) + image = np.transpose(image, (1, 2, 0)) if image.shape[-1] < 3: shape = image.shape - #if parent.autochannelbtn.isChecked(): + # if parent.autochannelbtn.isChecked(): # image = normalize99(image) * 255 - shape_to_concat = (shape[0], shape[1], 3-shape[2]) - to_concat = np.zeros(shape_to_concat,dtype=type(image[0,0,0])) + shape_to_concat = (shape[0], shape[1], 3 - shape[2]) + to_concat = np.zeros(shape_to_concat, dtype=type(image[0, 0, 0])) image = np.concatenate((image, to_concat), axis=-1) - image = image[np.newaxis,...] - elif image.shape[-1]<5 and image.shape[-1]>2: - image = image[:,:,:3] - #if parent.autochannelbtn.isChecked(): + image = image[np.newaxis, ...] + elif image.shape[-1] < 5 and image.shape[-1] > 2: + image = image[:, :, :3] + # if parent.autochannelbtn.isChecked(): # image = normalize99(image) * 255 - image = image[np.newaxis,...] + image = image[np.newaxis, ...] else: - image = image[np.newaxis,...] - + image = image[np.newaxis, ...] + if image.ndim < 4: - image = image[:,:,:,np.newaxis] + image = image[:, :, :, np.newaxis] return image - - + def segment( - self, image, - diameter_um=3.5, - blurfactor=2.50, - PhysicalSizeZ = 1.0001, - PhysicalSizeY = 1.0001, - PhysicalSizeX = 1.0001, - cellprob_threshold=0.0, - clean_borders=False - ): - """ Cellpose model for C. elegans germline nuclei. This model works on a single channel only. - + self, + image, + diameter_um=3.5, + blurfactor=2.50, + PhysicalSizeZ=1.0001, + PhysicalSizeY=1.0001, + PhysicalSizeX=1.0001, + cellprob_threshold=0.0, + clean_borders=False, + ): + """Cellpose model for C. elegans germline nuclei. This model works on a single channel only. + Parameters ---------- diameter_um : float @@ -119,42 +114,37 @@ def segment( cellprob_threshold for cellpose. clean_borders : bool Remove masks that touch the top or bottom slice in z, or that are closer than 2 pixels to the edges in x or y. - + Returns ----- np.ndarray Instance segmentation array with the same shape as the input image. """ - # Preprocess image # image = image/image.max() # image = skimage.filters.gaussian(image, sigma=1) # image = skimage.exposure.equalize_adapthist(image) zspacing = PhysicalSizeZ xysize = np.mean([PhysicalSizeX, PhysicalSizeY]) - + isRGB = image.shape[-1] == 3 or image.shape[-1] == 4 if isRGB: raise TypeError( "This model was trained for 1 channel only. Please specify a single channel (DNA or synaptonemal complex/axis staining). " ) - - isZstack = (image.ndim==3 and not isRGB) or (image.ndim==4) - - anisotropy = math.ceil(abs(zspacing/xysize)) - pxScale=xysize*30/diameter_um - - do_3D = True - - #if stitch_threshold > 0: - # do_3D = False + isZstack = (image.ndim == 3 and not isRGB) or (image.ndim == 4) + anisotropy = math.ceil(abs(zspacing / xysize)) + pxScale = xysize * 30 / diameter_um - channels = [0,0] + do_3D = True + # if stitch_threshold > 0: + # do_3D = False + channels = [0, 0] # Run cellpose eval if not isZstack: @@ -162,39 +152,73 @@ def segment( "This script is for 3D data (at least 5 slices) only. If needed, please modify the script to segment 2D data." ) else: - img_scaled=np.zeros((image.shape[0],round(image.shape[1]*pxScale),round(image.shape[2]*pxScale))) - img_blur=np.zeros((img_scaled.shape)) - image[image==0] = np.quantile(image[image>0],0.01) + img_scaled = np.zeros( + ( + image.shape[0], + round(image.shape[1] * pxScale), + round(image.shape[2] * pxScale), + ) + ) + img_blur = np.zeros((img_scaled.shape)) + image[image == 0] = np.quantile(image[image > 0], 0.01) if pxScale > 1: for i in range(image.shape[0]): - img_scaled[i,:,:] = scipy.ndimage.zoom(image[i,:,:],pxScale, order=3) - img_blur[i,:,:]=scipy.ndimage.gaussian_filter(img_scaled[i,:,:],blurfactor) - - else: + img_scaled[i, :, :] = scipy.ndimage.zoom( + image[i, :, :], pxScale, order=3 + ) + img_blur[i, :, :] = scipy.ndimage.gaussian_filter( + img_scaled[i, :, :], blurfactor + ) + + else: for i in range(image.shape[0]): - img_scaled[i,:,:] = scipy.ndimage.zoom(image[i,:,:],pxScale, order=3) - img_blur[i,:,:]=scipy.ndimage.gaussian_filter(img_scaled[i,:,:],blurfactor) + img_scaled[i, :, :] = scipy.ndimage.zoom( + image[i, :, :], pxScale, order=3 + ) + img_blur[i, :, :] = scipy.ndimage.gaussian_filter( + img_scaled[i, :, :], blurfactor + ) img_blur = self._initialize_image(img_blur) - labels_scaled, flows_blur, styles_blur = self.model.eval(img_blur.astype(np.uint16), - diameter=30, - channels=channels, do_3D=True, - anisotropy=anisotropy, - batch_size=3, - cellprob_threshold=cellprob_threshold) - - labels=np.zeros(image.shape,dtype=labels_scaled.dtype) + labels_scaled, flows_blur, styles_blur = self.model.eval( + img_blur.astype(np.uint16), + diameter=30, + channels=channels, + do_3D=True, + anisotropy=anisotropy, + batch_size=3, + cellprob_threshold=cellprob_threshold, + ) + + labels = np.zeros(image.shape, dtype=labels_scaled.dtype) for i in range(image.shape[0]): - labels[i,:,:]=scipy.ndimage.zoom(labels_scaled[i,:,:],(image.shape[1]/labels_scaled.shape[1],image.shape[2]/labels_scaled.shape[2]),order=0) + labels[i, :, :] = scipy.ndimage.zoom( + labels_scaled[i, :, :], + ( + image.shape[1] / labels_scaled.shape[1], + image.shape[2] / labels_scaled.shape[2], + ), + order=0, + ) if clean_borders: - idx = np.unique(np.concatenate([np.unique(labels[-1,:,:][labels[-1,:,:]>0]),np.unique(labels[0,:,:][labels[0,:,:]>0]), - np.unique(labels[:,0:2,:][labels[:,0:2,:]>0]),np.unique(labels[:,-3:-1,:][labels[:,-3:-1,:]>0]), - np.unique(labels[:,:,0:2][labels[:,:,0:2]>0]),np.unique(labels[:,:,-3:-1][labels[:,:,-3:-1]>0]),])) - - labels[np.isin(labels,idx)] = 0 + idx = np.unique( + np.concatenate( + [ + np.unique(labels[-1, :, :][labels[-1, :, :] > 0]), + np.unique(labels[0, :, :][labels[0, :, :] > 0]), + np.unique(labels[:, 0:2, :][labels[:, 0:2, :] > 0]), + np.unique(labels[:, -3:-1, :][labels[:, -3:-1, :] > 0]), + np.unique(labels[:, :, 0:2][labels[:, :, 0:2] > 0]), + np.unique(labels[:, :, -3:-1][labels[:, :, -3:-1] > 0]), + ] + ) + ) + + labels[np.isin(labels, idx)] = 0 return labels + def url_help(): - return 'https://cellpose.readthedocs.io/en/latest/api.html' + return "https://cellpose.readthedocs.io/en/latest/api.html" diff --git a/cellacdc/segmenters/DeepSea/__init__.py b/cellacdc/segmenters/DeepSea/__init__.py index 45929f3be..66748c7b2 100644 --- a/cellacdc/segmenters/DeepSea/__init__.py +++ b/cellacdc/segmenters/DeepSea/__init__.py @@ -6,59 +6,61 @@ from cellacdc import myutils myutils.check_install_torch() -myutils.check_install_package('deepsea') -myutils.check_install_package('munkres') +myutils.check_install_package("deepsea") +myutils.check_install_package("munkres") import torch import torchvision.transforms as transforms from PIL import Image -_, deepsea_segmenters_path = myutils.get_model_path('deepsea', create_temp_dir=False) +_, deepsea_segmenters_path = myutils.get_model_path("deepsea", create_temp_dir=False) -image_size = [383,512] +image_size = [383, 512] image_means = [0.5] image_stds = [0.5] + def _get_segm_transforms(): - return transforms.Compose([ - transforms.ToPILImage(), - transforms.Resize(image_size), - transforms.ToTensor(), - transforms.Normalize(mean=image_means, std=image_stds) - ]) - -def _init_model( - checkpoint_filename, DeepSeaClass, gpu=False - ): - # Initialize torch device + return transforms.Compose( + [ + transforms.ToPILImage(), + transforms.Resize(image_size), + transforms.ToTensor(), + transforms.Normalize(mean=image_means, std=image_stds), + ] + ) + + +def _init_model(checkpoint_filename, DeepSeaClass, gpu=False): + # Initialize torch device if gpu: from cellacdc import is_mac import platform + cpu = platform.processor() - if is_mac and cpu == 'arm': - device = 'cpu' + if is_mac and cpu == "arm": + device = "cpu" else: - device = 'cuda' + device = "cuda" else: - device = 'cpu' - + device = "cpu" + torch_device = torch.device(device) # Initialize checkpoint checkpoint_path = os.path.join(deepsea_segmenters_path, checkpoint_filename) checkpoint = torch.load(checkpoint_path, map_location=torch_device) - model = DeepSeaClass( - n_channels=1, n_classes=2, bilinear=True - ) + model = DeepSeaClass(n_channels=1, n_classes=2, bilinear=True) model.load_state_dict(checkpoint) model = model.to(torch_device) return torch_device, checkpoint, model + def _resize_img(img: Union[Image.Image, np.ndarray], device, transforms): tensor_img = transforms(img).to(device=device, dtype=torch.float32) - resized_img = tensor_img.cpu().numpy()[0,:,:] + resized_img = tensor_img.cpu().numpy()[0, :, :] img_min = np.min(resized_img) img_max = np.max(resized_img) img_range = img_max - img_min diff --git a/cellacdc/segmenters/DeepSea/acdcSegment.py b/cellacdc/segmenters/DeepSea/acdcSegment.py index a8bdf40a8..0ff05f9ae 100644 --- a/cellacdc/segmenters/DeepSea/acdcSegment.py +++ b/cellacdc/segmenters/DeepSea/acdcSegment.py @@ -22,19 +22,20 @@ torch.cuda.manual_seed(SEED) torch.backends.cudnn.deterministic = True + class Model: def __init__(self, gpu=False): torch_device, checkpoint, model = _init_model( - 'segmentation.pth', DeepSeaSegmentation, gpu=gpu + "segmentation.pth", DeepSeaSegmentation, gpu=gpu ) self.torch_device = torch_device self._transforms = _get_segm_transforms() self._checkpoint = checkpoint self.model = model - + def segment(self, image: np.ndarray): is_rgb_image = image.shape[-1] == 3 or image.shape[-1] == 4 - is_z_stack = (image.ndim==3 and not is_rgb_image) or (image.ndim==4) + is_z_stack = (image.ndim == 3 and not is_rgb_image) or (image.ndim == 4) labels = np.zeros(image.shape, dtype=np.uint32) if is_rgb_image: labels = np.zeros(image.shape[:-1], dtype=np.uint32) @@ -49,24 +50,23 @@ def segment(self, image: np.ndarray): else: labels = self._segment_2D_image(image, (Y, X)) return labels - + def _segment_2D_image(self, img: np.ndarray, grayscale_img_shape): try: img = (255 * ((img - img.min()) / img.ptp())).astype(np.uint8) except AttributeError as e: img = (255 * ((img - img.min()) / np.ptp(img))).astype(np.uint8) - tensor_img = ( - self._transforms(img) - .to(device=self.torch_device, dtype=torch.float32) + tensor_img = self._transforms(img).to( + device=self.torch_device, dtype=torch.float32 ) _eval = self.model.eval() mask_pred, edge_pred = _eval(tensor_img.unsqueeze(0)) - mask_pred = transforms.Resize( - grayscale_img_shape, antialias=True - ).forward(mask_pred) + mask_pred = transforms.Resize(grayscale_img_shape, antialias=True).forward( + mask_pred + ) mask_pred = mask_pred.argmax(dim=1).cpu().numpy()[0, :, :] mask_bool = mask_pred > 0 lab = skimage.measure.label(np.squeeze(mask_bool)) - + return lab diff --git a/cellacdc/segmenters/InstanSeg/__init__.py b/cellacdc/segmenters/InstanSeg/__init__.py index 39ed82738..f3245d987 100644 --- a/cellacdc/segmenters/InstanSeg/__init__.py +++ b/cellacdc/segmenters/InstanSeg/__init__.py @@ -2,8 +2,4 @@ myutils.check_install_instanseg() -INSTANSEG_MODELS = ( - 'fluorescence_nuclei_and_cells', - 'brightfield_nuclei' -) - +INSTANSEG_MODELS = ("fluorescence_nuclei_and_cells", "brightfield_nuclei") diff --git a/cellacdc/segmenters/InstanSeg/acdcSegment.py b/cellacdc/segmenters/InstanSeg/acdcSegment.py index 661c32cee..569ce1ef2 100644 --- a/cellacdc/segmenters/InstanSeg/acdcSegment.py +++ b/cellacdc/segmenters/InstanSeg/acdcSegment.py @@ -7,85 +7,80 @@ from . import INSTANSEG_MODELS + class AvailabelModels: values = INSTANSEG_MODELS + class AvailableDevices: - values = ( - 'Auto', 'GPU', 'CPU' - ) + values = ("Auto", "GPU", "CPU") + class VerbosityValues: - values = ( - 'Silent', 'Normal', 'Verbose' - ) + values = ("Silent", "Normal", "Verbose") + class ChannelOrder: - values = ( - 'First channel', 'Second channel' - ) + values = ("First channel", "Second channel") + class Model: def __init__( - self, - model_type: AvailabelModels='fluorescence_nuclei_and_cells', - custom_model_type: str='', - device: AvailableDevices='Auto', - verbosity: VerbosityValues='1' - ) -> None: + self, + model_type: AvailabelModels = "fluorescence_nuclei_and_cells", + custom_model_type: str = "", + device: AvailableDevices = "Auto", + verbosity: VerbosityValues = "1", + ) -> None: if custom_model_type: model_type = custom_model_type - - if device == 'Auto': + + if device == "Auto": device = myutils.get_torch_device(gpu=True) - elif device == 'CPU': - device = 'cpu' - elif device == 'GPU': + elif device == "CPU": + device = "cpu" + elif device == "GPU": device = myutils.get_torch_device(gpu=True) - - self.model = InstanSeg( - model_type, - device=device, - verbosity=verbosity - ) + + self.model = InstanSeg(model_type, device=device, verbosity=verbosity) def preprocess(self, image, rescale_intensities, warn=True): if rescale_intensities: image_min = image - image.min() - image_float = image_min/image_min.max() + image_float = image_min / image_min.max() else: image_float = myutils.img_to_float(image, warn=warn) - - return (image_float*255).astype(np.uint8) - + + return (image_float * 255).astype(np.uint8) + def segment( - self, - image, - second_channel_image: SecondChannelImage=None, - return_masks_for_channel: ChannelOrder='First channel', - PhysicalSizeX: float=1.0, - do_not_resize_to_pixel_size: bool=False, - rescale_intensities: bool=False - ): + self, + image, + second_channel_image: SecondChannelImage = None, + return_masks_for_channel: ChannelOrder = "First channel", + PhysicalSizeX: float = 1.0, + do_not_resize_to_pixel_size: bool = False, + rescale_intensities: bool = False, + ): if do_not_resize_to_pixel_size: PhysicalSizeX = None - + image_in = image if second_channel_image is not None: image_in = self.second_ch_img_to_stack(image, second_channel_image) - + image_in = self.preprocess(image_in, rescale_intensities) - + if image_in.shape[-1] > 2: image_in = image_in[..., np.newaxis] - + is_zstack = image_in.ndim == 4 - + if isinstance(return_masks_for_channel, int): masks_index = return_masks_for_channel else: - masks_index = 0 if return_masks_for_channel == 'First channel' else 1 - + masks_index = 0 if return_masks_for_channel == "First channel" else 1 + if is_zstack: lab = np.zeros((image_in.shape[:3]), dtype=np.uint32) for z, img in enumerate(image_in): @@ -93,26 +88,22 @@ def segment( img, PhysicalSizeX, masks_index=masks_index ) else: - lab = self._segment_2D_img( - image_in, PhysicalSizeX, masks_index=masks_index - ) - + lab = self._segment_2D_img(image_in, PhysicalSizeX, masks_index=masks_index) + return lab - + def _segment_2D_img(self, image, PhysicalSizeX, masks_index=0): - labeled_output, image_tensor = self.model.eval_small_image( - image, PhysicalSizeX - ) + labeled_output, image_tensor = self.model.eval_small_image(image, PhysicalSizeX) labels = labeled_output[0].cpu().detach().numpy() lab = labels[masks_index].astype(np.uint32) return lab - + def second_ch_img_to_stack(self, image, second_image): img_stack = np.zeros((*image.shape, 2)) img_stack[..., 0] = image img_stack[..., 1] = second_image return img_stack - - + + def url_help(): - return 'https://github.com/instanseg/instanseg' \ No newline at end of file + return "https://github.com/instanseg/instanseg" diff --git a/cellacdc/segmenters/StarDist/__init__.py b/cellacdc/segmenters/StarDist/__init__.py index 14bd152b0..967aa9367 100755 --- a/cellacdc/segmenters/StarDist/__init__.py +++ b/cellacdc/segmenters/StarDist/__init__.py @@ -4,9 +4,9 @@ from cellacdc import myutils -note = '' -if sys.platform == 'darwin': - note = (""" +note = "" +if sys.platform == "darwin": + note = """

    NOTE for M1 mac users: if you are on MacOS with an Apple Silicon processor cancel this operation and follow the @@ -15,23 +15,24 @@ here.

    - """) -myutils.check_install_package('tensorflow', note=note) -myutils.check_install_package('numpy', max_version='2.0.0') -myutils.check_install_package('stardist') + """ +myutils.check_install_package("tensorflow", note=note) +myutils.check_install_package("numpy", max_version="2.0.0") +myutils.check_install_package("stardist") import sys import tensorflow import h5py + if sys.version_info.minor < 9: # Tensorflow > 2.3 has the requirement h5py~=3.1.0, # but stardist 0.7.3 with python<3.9 requires h5py<3 # see issue here https://github.com/stardist/stardist/issues/180 - tf_version = tensorflow.__version__.split('.') + tf_version = tensorflow.__version__.split(".") tf_major, tf_minor = [int(v) for v in tf_version][:2] - h5py_version = h5py.__version__.split('.') + h5py_version = h5py.__version__.split(".") h5py_major = int(h5py_version[0]) if tf_major > 1 and tf_minor > 2 and h5py_major >= 3: subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', '--upgrade', 'h5py==2.10.0'] + [sys.executable, "-m", "pip", "install", "--upgrade", "h5py==2.10.0"] ) diff --git a/cellacdc/segmenters/StarDist/acdcSegment.py b/cellacdc/segmenters/StarDist/acdcSegment.py index 4058789f6..38811c73d 100755 --- a/cellacdc/segmenters/StarDist/acdcSegment.py +++ b/cellacdc/segmenters/StarDist/acdcSegment.py @@ -6,31 +6,31 @@ from cellacdc import models + class AvailableModels: values = models.STARDIST_MODELS + class Model: def __init__( - self, - model_name: AvailableModels='2D_versatile_fluo', - load_stardist_3D=False - ): + self, model_name: AvailableModels = "2D_versatile_fluo", load_stardist_3D=False + ): """_summary_ Parameters ---------- model_name : str, optional - Name of the pre-trained model to load. - - Available models are '2D_versatile_fluo', '2D_versatile_he', and + Name of the pre-trained model to load. + + Available models are '2D_versatile_fluo', '2D_versatile_he', and '2D_paper_dsb2018'. - + Default is '2D_versatile_fluo' - """ - + """ + stardist_default_models = models.STARDIST_MODELS stardist_path = os.path.dirname(os.path.abspath(__file__)) - T_cell_path = os.path.join(stardist_path, 'model', 'T_cell') + T_cell_path = os.path.join(stardist_path, "model", "T_cell") model_class = StarDist3D if load_stardist_3D else StarDist2D if not os.path.exists(T_cell_path): model_name = stardist_default_models[0] @@ -40,39 +40,35 @@ def __init__( else: script_path = os.path.abspath(__file__) stardist_path = os.path.dirname(script_path) - model_path = os.path.join(stardist_path, 'model') - self.model = model_class( - None, name=model_name, basedir=model_path - ) + model_path = os.path.join(stardist_path, "model") + self.model = model_class(None, name=model_name, basedir=model_path) self.load_stardist_3D = load_stardist_3D - def segment( - self, image, prob_thresh=0.0, nms_thresh=0.0, - segment_3D_volume=False - ): + def segment(self, image, prob_thresh=0.0, nms_thresh=0.0, segment_3D_volume=False): # Check on image shape is2D = image.ndim == 2 is3D = image.ndim == 3 calling_stardist3D_on_2D_data = ( (is3D and self.load_stardist_3D and not segment_3D_volume) - or is2D and self.load_stardist_3D + or is2D + and self.load_stardist_3D ) calling_stardist2D_on_3D_data = ( is3D and not self.load_stardist_3D and segment_3D_volume ) if calling_stardist3D_on_2D_data: - print('') - print('='*30) + print("") + print("=" * 30) raise ValueError( - 'StarDist3D cannot segment 2D image data. If you are trying to ' + "StarDist3D cannot segment 2D image data. If you are trying to " 'segment z-slices one by one you need to click "True" at the ' '"Segment 3D Volume" entry.' ) elif calling_stardist2D_on_3D_data: - print('') - print('='*30) + print("") + print("=" * 30) raise ValueError( - 'StarDist2D cannot segment 3D image data. If you are trying to ' + "StarDist2D cannot segment 3D image data. If you are trying to " 'segment z-slices one by one you need to click "False" at the ' '"Segment 3D Volume" entry.' ) @@ -84,16 +80,12 @@ def segment( labels = np.zeros(image.shape, dtype=np.uint32) for i, _img in enumerate(image): lab, _ = self.model.predict_instances( - normalize(_img), - prob_thresh=prob_thresh, - nms_thresh=nms_thresh + normalize(_img), prob_thresh=prob_thresh, nms_thresh=nms_thresh ) labels[i] = lab - labels = skimage.measure.label(labels>0) + labels = skimage.measure.label(labels > 0) else: labels, _ = self.model.predict_instances( - normalize(image), - prob_thresh=prob_thresh, - nms_thresh=nms_thresh + normalize(image), prob_thresh=prob_thresh, nms_thresh=nms_thresh ) return labels.astype(np.uint32) diff --git a/cellacdc/segmenters/YeaZ/__init__.py b/cellacdc/segmenters/YeaZ/__init__.py index f212dc81a..c284d64cd 100755 --- a/cellacdc/segmenters/YeaZ/__init__.py +++ b/cellacdc/segmenters/YeaZ/__init__.py @@ -1,3 +1,3 @@ from cellacdc import myutils -myutils.check_install_package('tensorflow', max_version='2.17') +myutils.check_install_package("tensorflow", max_version="2.17") diff --git a/cellacdc/segmenters/YeaZ/acdcSegment.py b/cellacdc/segmenters/YeaZ/acdcSegment.py index d5d0154f1..c1b38b0ec 100755 --- a/cellacdc/segmenters/YeaZ/acdcSegment.py +++ b/cellacdc/segmenters/YeaZ/acdcSegment.py @@ -17,6 +17,7 @@ from cellacdc import myutils from cellacdc import user_profile_path + class progressCallback(keras.callbacks.Callback): def __init__(self, signals): self.signals = signals @@ -34,27 +35,25 @@ def on_predict_batch_end(self, batch, logs=None): else: self.signals[0].progressBar.emit(1) + class Model: def __init__(self, is_phase_contrast=True): # Initialize model - self.model = model.unet( - pretrained_weights=None, - input_size=(None,None,1) - ) + self.model = model.unet(pretrained_weights=None, input_size=(None, None, 1)) # Get the path where the weights are saved. # We suggest saving the weights files into a 'model' subfolder - model_path = os.path.join(str(user_profile_path), f'acdc-YeaZ') + model_path = os.path.join(str(user_profile_path), f"acdc-YeaZ") if is_phase_contrast: - weights_fn = 'unet_weights_batchsize_25_Nepochs_100_SJR0_10.hdf5' + weights_fn = "unet_weights_batchsize_25_Nepochs_100_SJR0_10.hdf5" else: - weights_fn = 'weights_budding_BF_multilab_0_1.hdf5' + weights_fn = "weights_budding_BF_multilab_0_1.hdf5" weights_path = os.path.join(model_path, weights_fn) if not os.path.exists(model_path): - raise FileNotFoundError(f'Weights file not found in {model_path}') + raise FileNotFoundError(f"Weights file not found in {model_path}") self.model.load_weights(weights_path) @@ -71,17 +70,16 @@ def yeaz_preprocess(self, image, tqdm_pbar=None, warn=True): def predict3DT(self, timelapse3D): # pad with zeros such that is divisible by 16 (nrow, ncol) = timelapse3D[0].shape - row_add = 16-nrow%16 - col_add = 16-ncol%16 + row_add = 16 - nrow % 16 + col_add = 16 - ncol % 16 pad_info = ((0, 0), (0, row_add), (0, col_add)) - padded = np.pad(timelapse3D, pad_info, 'constant') + padded = np.pad(timelapse3D, pad_info, "constant") x = padded[:, :, :, np.newaxis] prediction = self.model.predict(x, batch_size=1, verbose=1) prediction = prediction[:, 0:-row_add, 0:-col_add, 0] return prediction - def segment2D(self, image, thresh_val=0.0, min_distance=10): # Preprocess image image = self.yeaz_preprocess(image) @@ -91,13 +89,13 @@ def segment2D(self, image, thresh_val=0.0, min_distance=10): # pad with zeros such that is divisible by 16 (nrow, ncol) = image.shape - row_add = 16-nrow%16 - col_add = 16-ncol%16 + row_add = 16 - nrow % 16 + col_add = 16 - ncol % 16 pad_info = ((0, row_add), (0, col_add)) - padded = np.pad(image, pad_info, 'constant') - x = padded[np.newaxis,:,:,np.newaxis] + padded = np.pad(image, pad_info, "constant") + x = padded[np.newaxis, :, :, np.newaxis] - prediction = self.model.predict(x, batch_size=1, verbose=1)[0,:,:,0] + prediction = self.model.predict(x, batch_size=1, verbose=1)[0, :, :, 0] # remove padding with 0s prediction = prediction[0:-row_add, 0:-col_add] @@ -115,26 +113,26 @@ def segment(self, image, thresh_val=0.0, min_distance=10): img, thresh_val=thresh_val, min_distance=min_distance ) labels[z] = lab - labels = skimage.measure.label(labels>0) + labels = skimage.measure.label(labels > 0) else: labels = self.segment2D( image, thresh_val=thresh_val, min_distance=min_distance ) return labels - def segment3DT( - self, timelapse3D, thresh_val=0.0, min_distance=10, signals=None - ): + def segment3DT(self, timelapse3D, thresh_val=0.0, min_distance=10, signals=None): sig_progress_tqdm = None if signals is not None: - signals[0].progress.emit(f'Preprocessing images...') + signals[0].progress.emit(f"Preprocessing images...") signals[0].create_tqdm.emit(len(timelapse3D)) sig_progress_tqdm = signals[0].progress_tqdm - timelapse3D = np.array([ - self.yeaz_preprocess(image, tqdm_pbar=sig_progress_tqdm, warn=i==0) - for i, image in enumerate(timelapse3D) - ]) + timelapse3D = np.array( + [ + self.yeaz_preprocess(image, tqdm_pbar=sig_progress_tqdm, warn=i == 0) + for i, image in enumerate(timelapse3D) + ] + ) if signals is not None: signals[0].signal_close_tqdm.emit() @@ -144,15 +142,15 @@ def segment3DT( # pad with zeros such that is divisible by 16 (nrow, ncol) = timelapse3D[0].shape - row_add = 16-nrow%16 - col_add = 16-ncol%16 + row_add = 16 - nrow % 16 + col_add = 16 - ncol % 16 pad_info = ((0, 0), (0, row_add), (0, col_add)) - padded = np.pad(timelapse3D, pad_info, 'constant') + padded = np.pad(timelapse3D, pad_info, "constant") x = padded[:, :, :, np.newaxis] if signals is not None: - signals[0].progress.emit(f'Predicting (the future) with YeaZ...') + signals[0].progress.emit(f"Predicting (the future) with YeaZ...") callbacks = None if signals is not None: @@ -160,10 +158,10 @@ def segment3DT( prediction = self.model.predict( x, batch_size=1, verbose=1, callbacks=callbacks - )[:,:,:,0] + )[:, :, :, 0] if signals is not None: - signals[0].progress.emit(f'Labelling objects with YeaZ...') + signals[0].progress.emit(f"Labelling objects with YeaZ...") # remove padding with 0s prediction = prediction[:, 0:-row_add, 0:-col_add] @@ -180,5 +178,6 @@ def segment3DT( signals[0].signal_close_tqdm.emit() return lab_timelapse + def url_help(): - return 'https://github.com/rahi-lab/YeaZ-GUI' \ No newline at end of file + return "https://github.com/rahi-lab/YeaZ-GUI" diff --git a/cellacdc/segmenters/YeaZ/unet/LaunchBatchPrediction.py b/cellacdc/segmenters/YeaZ/unet/LaunchBatchPrediction.py index 21539a693..c4be0d213 100755 --- a/cellacdc/segmenters/YeaZ/unet/LaunchBatchPrediction.py +++ b/cellacdc/segmenters/YeaZ/unet/LaunchBatchPrediction.py @@ -3,68 +3,78 @@ Created on Tue Nov 19 17:38:58 2019 """ -from qtpy.QtWidgets import (QDialog, QDialogButtonBox, QLineEdit, QFormLayout, - QLabel, QListWidget, QAbstractItemView, QCheckBox, - QButtonGroup, QRadioButton) +from qtpy.QtWidgets import ( + QDialog, + QDialogButtonBox, + QLineEdit, + QFormLayout, + QLabel, + QListWidget, + QAbstractItemView, + QCheckBox, + QButtonGroup, + QRadioButton, +) from qtpy import QtGui class CustomDialog(QDialog): def __init__(self, *args, **kwargs): super(CustomDialog, self).__init__(*args, **kwargs) - - app, = args + + (app,) = args maxtimeindex = app.reader.sizet - + self.setWindowTitle("Launch NN") - self.setGeometry(100,100, 500,200) - + self.setGeometry(100, 100, 500, 200) + self.entry1 = QLineEdit() - self.entry1.setValidator(QtGui.QIntValidator(0,int(maxtimeindex-1))) + self.entry1.setValidator(QtGui.QIntValidator(0, int(maxtimeindex - 1))) self.entry2 = QLineEdit() - self.entry2.setValidator(QtGui.QIntValidator(0,int(maxtimeindex-1))) - + self.entry2.setValidator(QtGui.QIntValidator(0, int(maxtimeindex - 1))) + # FOV dialog self.listfov = QListWidget() self.listfov.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) for f in range(0, app.reader.Npos): - self.listfov.addItem('Field of View {}'.format(f+1)) + self.listfov.addItem("Field of View {}".format(f + 1)) + + self.labeltime = QLabel( + "Enter range of frames ({}-{}) to segment".format(0, app.reader.sizet - 1) + ) - self.labeltime = QLabel("Enter range of frames ({}-{}) to segment".format(0, app.reader.sizet-1)) - self.entry_threshold = QLineEdit() self.entry_threshold.setValidator(QtGui.QDoubleValidator()) - self.entry_threshold.setText('0.5') - + self.entry_threshold.setText("0.5") + self.entry_segmentation = QLineEdit() self.entry_segmentation.setValidator(QtGui.QIntValidator()) - self.entry_segmentation.setText('5') - + self.entry_segmentation.setText("5") + flo = QFormLayout() flo.addWidget(self.labeltime) - flo.addRow('Start from frame:', self.entry1) - flo.addRow('End at frame:', self.entry2) - flo.addRow('Select field(s) of view:', self.listfov) - flo.addRow('Threshold value:', self.entry_threshold) - flo.addRow('Min. distance between seeds:', self.entry_segmentation) - + flo.addRow("Start from frame:", self.entry1) + flo.addRow("End at frame:", self.entry2) + flo.addRow("Select field(s) of view:", self.listfov) + flo.addRow("Threshold value:", self.entry_threshold) + flo.addRow("Min. distance between seeds:", self.entry_segmentation) + self.radiobuttons = QButtonGroup() - self.buttonBF = QRadioButton('Images are bright-field') - self.buttonPC = QRadioButton('Images are phase contrast') + self.buttonBF = QRadioButton("Images are bright-field") + self.buttonPC = QRadioButton("Images are phase contrast") self.buttonPC.setChecked(True) self.radiobuttons.addButton(self.buttonBF, id=0) self.radiobuttons.addButton(self.buttonPC, id=1) flo.addWidget(self.buttonBF) flo.addWidget(self.buttonPC) - - QBtn = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel - + + QBtn = ( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + self.buttonBox = QDialogButtonBox(QBtn) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) flo.addWidget(self.buttonBox) self.setLayout(flo) - - - diff --git a/cellacdc/segmenters/YeaZ/unet/hungarian.py b/cellacdc/segmenters/YeaZ/unet/hungarian.py index 302729eda..d23e6f26d 100755 --- a/cellacdc/segmenters/YeaZ/unet/hungarian.py +++ b/cellacdc/segmenters/YeaZ/unet/hungarian.py @@ -9,16 +9,16 @@ def correspondence(prev, curr): """ Corrects correspondence between previous and current mask, returns current mask with corrected cell values. New cells are given the unique identifier - starting at max(prev)+1. - + starting at max(prev)+1. + This is done by embedding every cell into a feature space consisting of - the center of mass and the area. The pairwise euclidean distance is - calculated between the cells of the previous and current frame. This is + the center of mass and the area. The pairwise euclidean distance is + calculated between the cells of the previous and current frame. This is then used as a cost for the bipartite matching problem which is in turn solved by the Hungarian algorithm as implemented in the munkres package. """ newcell = np.max(prev) + 1 - + hu_dict = hungarian_align(prev, curr) new = curr.copy() for key, val in hu_dict.items(): @@ -26,66 +26,68 @@ def correspondence(prev, curr): if val == -1: val = newcell newcell += 1 - - new[curr==key] = val - + + new[curr == key] = val + return new def hungarian_align(m1, m2): """ - Aligns the cells using the hungarian algorithm using the euclidean distance as - cost. - Returns dictionary of cells in m2 to cells in m1. If a cell is new, the dictionary + Aligns the cells using the hungarian algorithm using the euclidean distance as + cost. + Returns dictionary of cells in m2 to cells in m1. If a cell is new, the dictionary value is -1. """ dist, ix1, ix2 = cell_distance(m1, m2) - - # If dist couldn't be calculated, return dictionary from cells to themselves + + # If dist couldn't be calculated, return dictionary from cells to themselves if dist is None: unique_m2 = np.unique(m2) return dict(zip(unique_m2, unique_m2)) - + solver = Munkres() indexes = solver.compute(make_square(dist)) - + # Create dictionary of cell indicies d = dict([(ix2.get(i2, -1), ix1.get(i1, -1)) for i1, i2 in indexes]) - d.pop(-1, None) + d.pop(-1, None) return d def cell_to_features(im, c, nsamples=None, time=None): """Embeds cell c in image im into feature space""" - coord = np.argwhere(im==c) + coord = np.argwhere(im == c) area = coord.shape[0] - + if nsamples is not None: samples = np.random.choice(area, min(nsamples, area), replace=False) - sampled = coord[samples,:] + sampled = coord[samples, :] else: sampled = coord - + com = sampled.mean(axis=0) - - return {'cell': c, - 'time': time, - 'sqrtarea': np.sqrt(area), - 'area': area, - 'com_x': com[0], - 'com_y': com[1]} - - + + return { + "cell": c, + "time": time, + "sqrtarea": np.sqrt(area), + "area": area, + "com_x": com[0], + "com_y": com[1], + } + + def cell_distance(m1, m2, weight_com=3): """ Gives distance matrix between cells in first and second frame, by embedding all cells into the feature space. Currently uses center of mass and area - as features, with center of mass weighted with factor weight_com (to + as features, with center of mass weighted with factor weight_com (to make it more important). """ # Modify to compute use more computed features - #cols = ['com_x', 'com_y', 'roundness', 'sqrtarea'] - cols = ['com_x', 'com_y', 'area'] + # cols = ['com_x', 'com_y', 'roundness', 'sqrtarea'] + cols = ["com_x", "com_y", "area"] def get_features(m, t): cells = list(np.unique(m)) @@ -93,29 +95,28 @@ def get_features(m, t): cells.remove(0) features = [cell_to_features(m, c, time=t) for c in cells] return pd.DataFrame(features), dict(enumerate(cells)) - + # Create df, rescale feat1, ix_to_cell1 = get_features(m1, 1) feat2, ix_to_cell2 = get_features(m2, 2) - + # Check if one of matrices doesn't contain cells - if len(feat1)==0 or len(feat2)==0: + if len(feat1) == 0 or len(feat2) == 0: return None, None, None - + df = pd.concat((feat1, feat2)) df[cols] = scale(df[cols]) - + # give more importance to center of mass - df[['com_x', 'com_y']] = df[['com_x', 'com_y']] * weight_com + df[["com_x", "com_y"]] = df[["com_x", "com_y"]] * weight_com # pairwise euclidean dist dist = euclidean_distances( - df.loc[df['time']==1][cols], - df.loc[df['time']==2][cols] + df.loc[df["time"] == 1][cols], df.loc[df["time"] == 2][cols] ) return dist, ix_to_cell1, ix_to_cell2 - - + + def zero_pad(m, shape): """Pads matrix with zeros to be of desired shape""" out = np.zeros(shape) @@ -126,12 +127,10 @@ def zero_pad(m, shape): def make_square(m): """Turns matrix into square matrix, as required by Munkres algorithm""" - r,c = m.shape - if r==c: + r, c = m.shape + if r == c: return m - elif r>c: - return zero_pad(m, (r,r)) + elif r > c: + return zero_pad(m, (r, r)) else: - return zero_pad(m, (c,c)) - - + return zero_pad(m, (c, c)) diff --git a/cellacdc/segmenters/YeaZ/unet/model.py b/cellacdc/segmenters/YeaZ/unet/model.py index 587de0035..feb40151f 100755 --- a/cellacdc/segmenters/YeaZ/unet/model.py +++ b/cellacdc/segmenters/YeaZ/unet/model.py @@ -1,19 +1,28 @@ """ Source of the code: https://github.com/zhixuhao/unet """ + # Turn off GPU access so can train and use the YeaZ-GUI import os + os.environ["CUDA_VISIBLE_DEVICES"] = "-1" # Import tensorflow differently depending on version from tensorflow import __version__ as tf_version + tf_version_old = int(tf_version[0]) <= 1 from tensorflow.keras.models import Model -from tensorflow.keras.layers import (Input, Conv2D, MaxPooling2D, Dropout, - concatenate, UpSampling2D) +from tensorflow.keras.layers import ( + Input, + Conv2D, + MaxPooling2D, + Dropout, + concatenate, + UpSampling2D, +) from tensorflow.keras.optimizers import Adam -#from tensorflow.keras.callbacks import ModelCheckpoint, LearningRateScheduler +# from tensorflow.keras.callbacks import ModelCheckpoint, LearningRateScheduler if tf_version_old: from tensorflow import ConfigProto @@ -28,53 +37,104 @@ config.gpu_options.allow_growth = True session = InteractiveSession(config=config) -def unet(pretrained_weights = None,input_size = (256,256,1)): + +def unet(pretrained_weights=None, input_size=(256, 256, 1)): inputs = Input(input_size) - conv1 = Conv2D(64, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(inputs) - conv1 = Conv2D(64, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv1) + conv1 = Conv2D( + 64, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(inputs) + conv1 = Conv2D( + 64, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(conv1) pool1 = MaxPooling2D(pool_size=(2, 2))(conv1) - conv2 = Conv2D(128, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(pool1) - conv2 = Conv2D(128, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv2) + conv2 = Conv2D( + 128, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(pool1) + conv2 = Conv2D( + 128, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(conv2) pool2 = MaxPooling2D(pool_size=(2, 2))(conv2) - conv3 = Conv2D(256, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(pool2) - conv3 = Conv2D(256, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv3) + conv3 = Conv2D( + 256, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(pool2) + conv3 = Conv2D( + 256, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(conv3) pool3 = MaxPooling2D(pool_size=(2, 2))(conv3) - conv4 = Conv2D(512, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(pool3) - conv4 = Conv2D(512, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv4) + conv4 = Conv2D( + 512, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(pool3) + conv4 = Conv2D( + 512, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(conv4) drop4 = Dropout(0.5)(conv4) pool4 = MaxPooling2D(pool_size=(2, 2))(drop4) - conv5 = Conv2D(1024, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(pool4) - conv5 = Conv2D(1024, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv5) + conv5 = Conv2D( + 1024, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(pool4) + conv5 = Conv2D( + 1024, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(conv5) drop5 = Dropout(0.5)(conv5) - up6 = Conv2D(512, 2, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(UpSampling2D(size = (2,2))(drop5)) - merge6 = concatenate([drop4,up6], axis = 3) - conv6 = Conv2D(512, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(merge6) - conv6 = Conv2D(512, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv6) - - up7 = Conv2D(256, 2, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(UpSampling2D(size = (2,2))(conv6)) - merge7 = concatenate([conv3,up7], axis = 3) - conv7 = Conv2D(256, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(merge7) - conv7 = Conv2D(256, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv7) - - up8 = Conv2D(128, 2, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(UpSampling2D(size = (2,2))(conv7)) - merge8 = concatenate([conv2,up8], axis = 3) - conv8 = Conv2D(128, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(merge8) - conv8 = Conv2D(128, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv8) - - up9 = Conv2D(64, 2, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(UpSampling2D(size = (2,2))(conv8)) - merge9 = concatenate([conv1,up9], axis = 3) - conv9 = Conv2D(64, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(merge9) - conv9 = Conv2D(64, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv9) - conv9 = Conv2D(2, 3, activation = 'relu', padding = 'same', kernel_initializer = 'he_normal')(conv9) - conv10 = Conv2D(1, 1, activation = 'sigmoid')(conv9) - - model = Model(inputs = inputs, outputs = conv10) - - model.compile(optimizer = Adam(learning_rate = 1e-4), loss = 'binary_crossentropy', metrics = ['accuracy']) - - if(pretrained_weights): + up6 = Conv2D( + 512, 2, activation="relu", padding="same", kernel_initializer="he_normal" + )(UpSampling2D(size=(2, 2))(drop5)) + merge6 = concatenate([drop4, up6], axis=3) + conv6 = Conv2D( + 512, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(merge6) + conv6 = Conv2D( + 512, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(conv6) + + up7 = Conv2D( + 256, 2, activation="relu", padding="same", kernel_initializer="he_normal" + )(UpSampling2D(size=(2, 2))(conv6)) + merge7 = concatenate([conv3, up7], axis=3) + conv7 = Conv2D( + 256, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(merge7) + conv7 = Conv2D( + 256, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(conv7) + + up8 = Conv2D( + 128, 2, activation="relu", padding="same", kernel_initializer="he_normal" + )(UpSampling2D(size=(2, 2))(conv7)) + merge8 = concatenate([conv2, up8], axis=3) + conv8 = Conv2D( + 128, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(merge8) + conv8 = Conv2D( + 128, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(conv8) + + up9 = Conv2D( + 64, 2, activation="relu", padding="same", kernel_initializer="he_normal" + )(UpSampling2D(size=(2, 2))(conv8)) + merge9 = concatenate([conv1, up9], axis=3) + conv9 = Conv2D( + 64, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(merge9) + conv9 = Conv2D( + 64, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(conv9) + conv9 = Conv2D( + 2, 3, activation="relu", padding="same", kernel_initializer="he_normal" + )(conv9) + conv10 = Conv2D(1, 1, activation="sigmoid")(conv9) + + model = Model(inputs=inputs, outputs=conv10) + + model.compile( + optimizer=Adam(learning_rate=1e-4), + loss="binary_crossentropy", + metrics=["accuracy"], + ) + + if pretrained_weights: model.load_weights(pretrained_weights) return model diff --git a/cellacdc/segmenters/YeaZ/unet/neural_network.py b/cellacdc/segmenters/YeaZ/unet/neural_network.py index 6169fcdba..2fd55819a 100755 --- a/cellacdc/segmenters/YeaZ/unet/neural_network.py +++ b/cellacdc/segmenters/YeaZ/unet/neural_network.py @@ -1,9 +1,9 @@ - # -*- coding: utf-8 -*- """ Created on Sat Dec 21 18:54:10 2019 """ + import os import sys import numpy as np @@ -13,17 +13,19 @@ from .model import unet + def determine_path_weights(): script_dirname = os.path.dirname(os.path.realpath(__file__)) main_path = os.path.dirname(os.path.dirname(os.path.dirname(script_dirname))) - model_path = os.path.join(main_path, 'models', 'YeaZ_model') + model_path = os.path.join(main_path, "models", "YeaZ_model") - if getattr(sys, 'frozen', False): - path_weights = os.path.join(sys._MEIPASS, 'unet/') + if getattr(sys, "frozen", False): + path_weights = os.path.join(sys._MEIPASS, "unet/") else: path_weights = model_path return path_weights + def create_directory_if_not_exists(path): """ Create in the file system a new directory if it doesn't exist yet. @@ -62,33 +64,28 @@ def prediction(im, is_pc, path_weights): """ # pad with zeros such that is divisible by 16 (nrow, ncol) = im.shape - row_add = 16-nrow%16 - col_add = 16-ncol%16 - padded = np.pad(im, ((0, row_add), (0, col_add)), 'constant') + row_add = 16 - nrow % 16 + col_add = 16 - ncol % 16 + padded = np.pad(im, ((0, row_add), (0, col_add)), "constant") # WHOLE CELL PREDICTION - model = unet(pretrained_weights = None, - input_size = (None,None,1)) + model = unet(pretrained_weights=None, input_size=(None, None, 1)) if is_pc: path = os.path.join( - path_weights, - 'unet_weights_batchsize_25_Nepochs_100_SJR0_10.hdf5' + path_weights, "unet_weights_batchsize_25_Nepochs_100_SJR0_10.hdf5" ) else: - path = os.path.join( - path_weights, - 'weights_budding_BF_multilab_0_1.hdf5' - ) + path = os.path.join(path_weights, "weights_budding_BF_multilab_0_1.hdf5") if not os.path.exists(path): - raise ValueError(f'Weights file not found in {path}') + raise ValueError(f"Weights file not found in {path}") model.load_weights(path) - results = model.predict(padded[np.newaxis,:,:,np.newaxis], batch_size=1) + results = model.predict(padded[np.newaxis, :, :, np.newaxis], batch_size=1) - res = results[0,:,:,0] + res = results[0, :, :, 0] return res[:nrow, :ncol] @@ -106,23 +103,27 @@ def batch_prediction(im_stack, is_pc, path_weights, batch_size=1): col_add = 16 - ncol % 16 im_stack_padded = [] for im in im_stack: - padded = np.pad(im, ((0, row_add), (0, col_add)), mode='constant') + padded = np.pad(im, ((0, row_add), (0, col_add)), mode="constant") im_stack_padded.append(padded) im_stack_padded = np.array(im_stack_padded) # WHOLE CELL PREDICTION - model = unet(pretrained_weights=None, - input_size=(None, None, 1)) + model = unet(pretrained_weights=None, input_size=(None, None, 1)) if is_pc: - path = os.path.join(path_weights, 'unet_weights_batchsize_25_Nepochs_100_SJR0_10.hdf5') + path = os.path.join( + path_weights, "unet_weights_batchsize_25_Nepochs_100_SJR0_10.hdf5" + ) else: - path = os.path.join(path_weights, 'unet_weights_BF_batchsize_25_Nepochs_100_SJR_0_1.hdf5') + path = os.path.join( + path_weights, "unet_weights_BF_batchsize_25_Nepochs_100_SJR_0_1.hdf5" + ) if not os.path.exists(path): raise ValueError( - 'Weights file not found! Download them from the link ' - f'below and place them into {path_weights}.\n' - 'Link: https://drive.google.com/file/d/1CO7uF-werl9y8s3Fel0cVjRHCdXRf2Ly/view?usp=sharing') + "Weights file not found! Download them from the link " + f"below and place them into {path_weights}.\n" + "Link: https://drive.google.com/file/d/1CO7uF-werl9y8s3Fel0cVjRHCdXRf2Ly/view?usp=sharing" + ) model.load_weights(path) diff --git a/cellacdc/segmenters/YeaZ/unet/segment.py b/cellacdc/segmenters/YeaZ/unet/segment.py index 0c2d037f4..689d430f9 100755 --- a/cellacdc/segmenters/YeaZ/unet/segment.py +++ b/cellacdc/segmenters/YeaZ/unet/segment.py @@ -31,9 +31,9 @@ def segment(th, pred, min_distance=10, topology=None, merge=True, q=0.75): m[tuple(peak_idx.T)] = True # Uncomment to start with cross for every pixel instead of single pixel - m_lab = label(m) #comment this - #m_dil = dilation(m) - #m_lab = label(m_dil) + m_lab = label(m) # comment this + # m_dil = dilation(m) + # m_lab = label(m_dil) wsh = watershed(topology, m_lab, mask=th, connectivity=2) if merge: merged = cell_merge(wsh, pred, q) @@ -41,6 +41,7 @@ def segment(th, pred, min_distance=10, topology=None, merge=True, q=0.75): merged = wsh return correct_artefacts(merged) + def segment_stack(th, pred, min_distance=10, topology=None, signals=None): """ source: YeaZ @@ -62,9 +63,9 @@ def correct_artefacts(wsh): by another cell. Those are removed here. """ unique, count = np.unique(wsh, return_counts=True) - to_remove = unique[count<=3] + to_remove = unique[count <= 3] for rem in to_remove: - rem_im = wsh==rem + rem_im = wsh == rem rem_cont = dilation(rem_im) & ~rem_im vals, val_counts = np.unique(wsh[rem_cont], return_counts=True) replace_val = vals[np.argmax(val_counts)] @@ -78,51 +79,52 @@ def cell_merge(wsh, pred, q=0.75): Procedure that merges cells if the border between them is predicted to be cell pixels. """ - wshshape=wsh.shape + wshshape = wsh.shape # masks for the original cells - objs = np.zeros((wsh.max()+1,wshshape[0],wshshape[1]), dtype=bool) + objs = np.zeros((wsh.max() + 1, wshshape[0], wshshape[1]), dtype=bool) # masks for dilated cells - dil_objs = np.zeros((wsh.max()+1,wshshape[0],wshshape[1]), dtype=bool) + dil_objs = np.zeros((wsh.max() + 1, wshshape[0], wshshape[1]), dtype=bool) # bounding box coordinates - obj_coords = np.zeros((wsh.max()+1,4)) + obj_coords = np.zeros((wsh.max() + 1, 4)) # cleaned watershed, output of function - wshclean = np.zeros((wshshape[0],wshshape[1])) + wshclean = np.zeros((wshshape[0], wshshape[1])) # kernel to dilate objects - kernel = np.ones((3,3), dtype=bool) + kernel = np.ones((3, 3), dtype=bool) for obj1 in range(wsh.max()): # create masks and dilated masks for obj - objs[obj1,:,:] = wsh==(obj1+1) - dil_objs[obj1,:,:] = dilation(objs[obj1,:,:], kernel) + objs[obj1, :, :] = wsh == (obj1 + 1) + dil_objs[obj1, :, :] = dilation(objs[obj1, :, :], kernel) # bounding box - obj_coords[obj1,:] = get_bounding_box(dil_objs[obj1,:,:]) + obj_coords[obj1, :] = get_bounding_box(dil_objs[obj1, :, :]) - objcounter = 0 # counter for new watershed objects + objcounter = 0 # counter for new watershed objects for obj1 in range(wsh.max()): - dil1 = dil_objs[obj1,:,:] + dil1 = dil_objs[obj1, :, :] # check if mask has been deleted if np.sum(dil1) == 0: continue objcounter = objcounter + 1 - orig1 = objs[obj1,:,:] + orig1 = objs[obj1, :, :] - for obj2 in range(obj1+1,wsh.max()): - dil2 = dil_objs[obj2,:,:] + for obj2 in range(obj1 + 1, wsh.max()): + dil2 = dil_objs[obj2, :, :] # only check border if bounding box overlaps, and second mask # is not yet deleted - if (do_box_overlap(obj_coords[obj1,:], obj_coords[obj2,:]) - and np.sum(dil2) > 0): - + if ( + do_box_overlap(obj_coords[obj1, :], obj_coords[obj2, :]) + and np.sum(dil2) > 0 + ): border = dil1 * dil2 border_pred = pred[border] @@ -137,13 +139,13 @@ def cell_merge(wsh, pred, q=0.75): top_border_area = len(top_border_pred) # merge cells - if top_border_height / top_border_area > .99: - orig1 = np.logical_or(orig1, objs[obj2,:,:]) - dil_objs[obj1,:,:] = np.logical_or(dil1, dil2) - dil_objs[obj2,:,:] = np.zeros((wshshape[0], wshshape[1])) - obj_coords[obj1,:] = get_bounding_box(dil_objs[obj1,:,:]) + if top_border_height / top_border_area > 0.99: + orig1 = np.logical_or(orig1, objs[obj2, :, :]) + dil_objs[obj1, :, :] = np.logical_or(dil1, dil2) + dil_objs[obj2, :, :] = np.zeros((wshshape[0], wshshape[1])) + obj_coords[obj1, :] = get_bounding_box(dil_objs[obj1, :, :]) - wshclean = wshclean + orig1*objcounter + wshclean = wshclean + orig1 * objcounter return wshclean @@ -152,15 +154,22 @@ def do_box_overlap(coord1, coord2): """Checks if boxes, determined by their coordinates, overlap. Safety margin of 2 pixels""" return ( - (coord1[0] - 2 < coord2[0] and coord1[1] + 2 > coord2[0] - or coord2[0] - 2 < coord1[0] and coord2[1] + 2 > coord1[0]) - and (coord1[2] - 2 < coord2[2] and coord1[3] + 2 > coord2[2] - or coord2[2] - 2 < coord1[2] and coord2[3] + 2 > coord1[2])) + coord1[0] - 2 < coord2[0] + and coord1[1] + 2 > coord2[0] + or coord2[0] - 2 < coord1[0] + and coord2[1] + 2 > coord1[0] + ) and ( + coord1[2] - 2 < coord2[2] + and coord1[3] + 2 > coord2[2] + or coord2[2] - 2 < coord1[2] + and coord2[3] + 2 > coord1[2] + ) def get_bounding_box(im): """Returns bounding box of object in boolean image""" coords = np.where(im) - return np.array([np.min(coords[0]), np.max(coords[0]), - np.min(coords[1]), np.max(coords[1])]) + return np.array( + [np.min(coords[0]), np.max(coords[0]), np.min(coords[1]), np.max(coords[1])] + ) diff --git a/cellacdc/segmenters/YeaZ/unet/tracking.py b/cellacdc/segmenters/YeaZ/unet/tracking.py index fa3ce2176..aca6ca05f 100755 --- a/cellacdc/segmenters/YeaZ/unet/tracking.py +++ b/cellacdc/segmenters/YeaZ/unet/tracking.py @@ -15,6 +15,7 @@ except ModuleNotFoundError as e: pass + def correspondence(prev, curr, use_scipy=True, use_modified_yeaz=True): """ source: YeaZ modified by Cell-ACDC developers @@ -41,10 +42,11 @@ def correspondence(prev, curr, use_scipy=True, use_modified_yeaz=True): val = newcell newcell += 1 - new[curr==key] = val + new[curr == key] = val return new + def scipy_align(m1, m2, acdc_yeaz=True): """ source: YeaZ modified by Cell-ACDC @@ -67,6 +69,7 @@ def scipy_align(m1, m2, acdc_yeaz=True): d.pop(-1, None) return d + def correspondence_stack(stack, signals=None): """ source: YeaZ @@ -77,15 +80,16 @@ def correspondence_stack(stack, signals=None): corrected_stack[0] = stack[0] for idx in range(len(stack)): try: - curr = stack[idx+1] + curr = stack[idx + 1] prev = corrected_stack[idx] except IndexError: continue - corrected_stack[idx+1] = correspondence(prev, curr) + corrected_stack[idx + 1] = correspondence(prev, curr) if signals is not None: signals.progressBar.emit(1) return corrected_stack + def hungarian_align(m1, m2, acdc_yeaz=True): """ source: YeaZ @@ -109,50 +113,54 @@ def hungarian_align(m1, m2, acdc_yeaz=True): d.pop(-1, None) return d + def cell_to_features(im, c, nsamples=None, time=None): """ source: YeaZ Embeds cell c in image im into feature space """ - coord = np.argwhere(im==c) + coord = np.argwhere(im == c) area = coord.shape[0] if nsamples is not None: samples = np.random.choice(area, min(nsamples, area), replace=False) - sampled = coord[samples,:] + sampled = coord[samples, :] else: sampled = coord com = sampled.mean(axis=0) - return {'cell': c, - 'time': time, - 'sqrtarea': np.sqrt(area), - 'area': area, - 'com_x': com[0], - 'com_y': com[1]} + return { + "cell": c, + "time": time, + "sqrtarea": np.sqrt(area), + "area": area, + "com_x": com[0], + "com_y": com[1], + } + def get_features_acdc(m, t): rp = regionprops(m) features = { - 'cell': [], - 'time': [], - 'sqrtarea': [], - 'area': [], - 'com_x': [], - 'com_y': [] + "cell": [], + "time": [], + "sqrtarea": [], + "area": [], + "com_x": [], + "com_y": [], } for obj in rp: area = obj.area y, x = obj.centroid - features['cell'].append(obj.label) - features['time'].append(t) - features['sqrtarea'].append(sqrt(area)) - features['area'].append(area) - features['com_x'].append(y) - features['com_y'].append(x) + features["cell"].append(obj.label) + features["time"].append(t) + features["sqrtarea"].append(sqrt(area)) + features["area"].append(area) + features["com_x"].append(y) + features["com_y"].append(x) df = pd.DataFrame(features) - return df, dict(enumerate(features['cell'])) + return df, dict(enumerate(features["cell"])) def get_features(m, t): @@ -162,6 +170,7 @@ def get_features(m, t): features = [cell_to_features(m, c, time=t) for c in cells] return pd.DataFrame(features), dict(enumerate(cells)) + def cell_distance(m1, m2, weight_com=3, acdc_yeaz=True): """ source: YeaZ @@ -171,8 +180,8 @@ def cell_distance(m1, m2, weight_com=3, acdc_yeaz=True): make it more important). """ # Modify to compute use more computed features - #cols = ['com_x', 'com_y', 'roundness', 'sqrtarea'] - cols = ['com_x', 'com_y', 'area'] + # cols = ['com_x', 'com_y', 'roundness', 'sqrtarea'] + cols = ["com_x", "com_y", "area"] get_features_func = get_features_acdc if acdc_yeaz else get_features @@ -183,19 +192,18 @@ def cell_distance(m1, m2, weight_com=3, acdc_yeaz=True): # feat1_acdc, ix_to_cell1_acdc = get_features_acdc(m1, 1) # Check if one of matrices doesn't contain cells - if len(feat1)==0 or len(feat2)==0: + if len(feat1) == 0 or len(feat2) == 0: return None, None, None df = pd.concat((feat1, feat2)) df[cols] = scale(df[cols]) # give more importance to center of mass - df[['com_x', 'com_y']] = df[['com_x', 'com_y']] * weight_com + df[["com_x", "com_y"]] = df[["com_x", "com_y"]] * weight_com # pairwise euclidean dist dist = euclidean_distances( - df.loc[df['time']==1][cols], - df.loc[df['time']==2][cols] + df.loc[df["time"] == 1][cols], df.loc[df["time"] == 2][cols] ) return dist, ix_to_cell1, ix_to_cell2 @@ -216,10 +224,10 @@ def make_square(m): source: YeaZ Turns matrix into square matrix, as required by Munkres algorithm """ - r,c = m.shape - if r==c: + r, c = m.shape + if r == c: return m - elif r>c: - return zero_pad(m, (r,r)) + elif r > c: + return zero_pad(m, (r, r)) else: - return zero_pad(m, (c,c)) + return zero_pad(m, (c, c)) diff --git a/cellacdc/segmenters/YeaZ_v2/__init__.py b/cellacdc/segmenters/YeaZ_v2/__init__.py index bb3071951..01af56e4b 100644 --- a/cellacdc/segmenters/YeaZ_v2/__init__.py +++ b/cellacdc/segmenters/YeaZ_v2/__init__.py @@ -4,55 +4,47 @@ myutils.check_install_yeaz() -custom_weights_json_filename = 'custom_weights_name_filepath.json' +custom_weights_json_filename = "custom_weights_name_filepath.json" + def add_model_filepath(name: str, filepath: os.PathLike): - _, model_folderpath = myutils.get_model_path( - 'YeaZ_v2', create_temp_dir=False - ) + _, model_folderpath = myutils.get_model_path("YeaZ_v2", create_temp_dir=False) custom_weights_json_file = os.path.join( model_folderpath, custom_weights_json_filename ) custom_weights_mapper = {} if os.path.exists(custom_weights_json_file): custom_weights_mapper = load.read_json( - custom_weights_json_file, - desc='YeaZ_v2 custom weights filepath info' + custom_weights_json_file, desc="YeaZ_v2 custom weights filepath info" ) - + custom_weights_mapper[name] = filepath load.write_json(custom_weights_mapper, custom_weights_json_file) - + + def load_models_filepath(): - values = [ - 'Phase contrast', - 'Bright-field', - 'Fission yeast' - ] + values = ["Phase contrast", "Bright-field", "Fission yeast"] mapper = { - 'Phase contrast': 'weights_budding_PhC_multilab_0_1', - 'Bright-field': 'weights_budding_BF_multilab_0_1', - 'Fission yeast': 'weights_fission_multilab_0_2' + "Phase contrast": "weights_budding_PhC_multilab_0_1", + "Bright-field": "weights_budding_BF_multilab_0_1", + "Fission yeast": "weights_fission_multilab_0_2", } - _, model_folderpath = myutils.get_model_path( - 'YeaZ_v2', create_temp_dir=False - ) + _, model_folderpath = myutils.get_model_path("YeaZ_v2", create_temp_dir=False) mapper = { - name: os.path.join(model_folderpath, filename) + name: os.path.join(model_folderpath, filename) for name, filename in mapper.items() } - + custom_weights_json_file = os.path.join( model_folderpath, custom_weights_json_filename ) if not os.path.exists(custom_weights_json_file): return values, mapper - + custom_weights_mapper = load.read_json( - custom_weights_json_file, - desc='YeaZ_v2 custom weights filepath info' + custom_weights_json_file, desc="YeaZ_v2 custom weights filepath info" ) values.extend(custom_weights_mapper.keys()) mapper = {**mapper, **custom_weights_mapper} - - return values, mapper \ No newline at end of file + + return values, mapper diff --git a/cellacdc/segmenters/YeaZ_v2/acdcSegment.py b/cellacdc/segmenters/YeaZ_v2/acdcSegment.py index 35848d64d..754280024 100644 --- a/cellacdc/segmenters/YeaZ_v2/acdcSegment.py +++ b/cellacdc/segmenters/YeaZ_v2/acdcSegment.py @@ -16,57 +16,62 @@ from . import load_models_filepath -class ModelType: + +class ModelType: isWidget = True def __init__(self): from cellacdc import widgets + self.widget = widgets.YeazV2SelectModelNameCombobox( - custom_select_item_text='Select custom weights file...' + custom_select_item_text="Select custom weights file..." ) + class Model: - def __init__(self, model_type: ModelType='Phase contrast'): + def __init__(self, model_type: ModelType = "Phase contrast"): # Initialize model models_name, models_name_filepath_mapper = load_models_filepath() weights_filepath = models_name_filepath_mapper[model_type] - + self.model = UNet() self.model.load_state_dict(torch.load(weights_filepath)) - + if torch.cuda.is_available(): - device = torch.device('cuda') + device = torch.device("cuda") self._is_gpu = True elif torch.backends.mps.is_available(): - device = torch.device('mps') + device = torch.device("mps") self._is_gpu = True else: - device = torch.device('cpu') + device = torch.device("cpu") self._is_gpu = False - + self.device = device self.model = self.model.to(device) - + def _segment_img_3D(self, image, thresh_val=0.0, min_distance=10): # Preprocess image - image = np.array([ - self._preprocess_image(img, warn=i==0).astype(np.float32) - for i, img in enumerate(image) - ]) - + image = np.array( + [ + self._preprocess_image(img, warn=i == 0).astype(np.float32) + for i, img in enumerate(image) + ] + ) + # pad with zeros such that is divisible by 16 (nrow, ncol) = image.shape[-2:] - row_add = 16-nrow%16 - col_add = 16-ncol%16 + row_add = 16 - nrow % 16 + col_add = 16 - ncol % 16 pad_width = ((0, 0), (0, row_add), (0, col_add)) padded = np.pad(image, pad_width) - + padded = torch.from_numpy(padded) if self._is_gpu: padded = padded.to(self.device) - + self.model.eval() - + with torch.no_grad(): # Convert input tensor to PyTorch tensor input_tensor = padded.unsqueeze(1).float() @@ -75,7 +80,7 @@ def _segment_img_3D(self, image, thresh_val=0.0, min_distance=10): # Convert output tensor to NumPy array output_array = output_tensor.cpu().detach().numpy() result = output_array[:, 0, :, :] - + if self._is_gpu: try: gc.collect() @@ -83,37 +88,35 @@ def _segment_img_3D(self, image, thresh_val=0.0, min_distance=10): except Exception as e: pass prediction = result[:, :nrow, :ncol] - + if thresh_val == 0: thresh_val = None - + labels = np.zeros(prediction.shape, dtype=np.uint32) for i, pred in enumerate(prediction): thresh = nn.threshold(pred, th=thresh_val) - lab = yeaz_segment.segment( - thresh, pred, min_distance=min_distance - ) + lab = yeaz_segment.segment(thresh, pred, min_distance=min_distance) labels[i] = lab.astype(np.uint32) return labels - + def _segment_img_2D(self, image, thresh_val=0.0, min_distance=10, warn=True): # Preprocess image image = self._preprocess_image(image, warn=warn).astype(np.float32) - + # pad with zeros such that is divisible by 16 (nrow, ncol) = image.shape - row_add = 16-nrow%16 - col_add = 16-ncol%16 + row_add = 16 - nrow % 16 + col_add = 16 - ncol % 16 pad_width = ((0, row_add), (0, col_add)) padded = np.pad(image, pad_width) - + padded = torch.from_numpy(padded) if self._is_gpu: padded = padded.to(self.device) - + self.model.eval() - + with torch.no_grad(): # Convert input tensor to PyTorch tensor input_tensor = padded.unsqueeze(0).unsqueeze(0).float() @@ -122,7 +125,7 @@ def _segment_img_2D(self, image, thresh_val=0.0, min_distance=10, warn=True): # Convert output tensor to NumPy array output_array = output_tensor.cpu().detach().numpy() result = output_array[0, 0, :, :] - + if self._is_gpu: try: gc.collect() @@ -130,17 +133,15 @@ def _segment_img_2D(self, image, thresh_val=0.0, min_distance=10, warn=True): except Exception as e: pass prediction = result[:nrow, :ncol] - + if thresh_val == 0: thresh_val = None - + thresholded = nn.threshold(prediction, th=thresh_val) - lab = yeaz_segment.segment( - thresholded, prediction, min_distance=min_distance - ) - + lab = yeaz_segment.segment(thresholded, prediction, min_distance=min_distance) + return lab.astype(np.uint32) - + def _preprocess_image(self, image, tqdm_pbar=None, warn=True): image = myutils.img_to_float(image, warn=warn) image = skimage.exposure.equalize_adapthist(image) @@ -150,19 +151,18 @@ def _preprocess_image(self, image, tqdm_pbar=None, warn=True): # def segment3DT( # self, timelapse3D, thresh_val=0.0, min_distance=10, signals=None - # ): + # ): # lab = self._segment_img_3D( # timelapse3D, thresh_val=thresh_val, min_distance=min_distance # ) # return lab - + def segment(self, image, thresh_val=0.0, min_distance=10): if image.ndim == 3: labels = np.zeros(image.shape, dtype=np.uint32) for z, img in enumerate(image): lab = self._segment_img_2D( - img, thresh_val=thresh_val, min_distance=min_distance, - warn=z==0 + img, thresh_val=thresh_val, min_distance=min_distance, warn=z == 0 ) labels[z] = lab else: @@ -171,5 +171,6 @@ def segment(self, image, thresh_val=0.0, min_distance=10): ) return labels + def url_help(): - return 'https://github.com/rahi-lab/YeaZ-GUI' \ No newline at end of file + return "https://github.com/rahi-lab/YeaZ-GUI" diff --git a/cellacdc/segmenters/YeastMate/__init__.py b/cellacdc/segmenters/YeastMate/__init__.py index 138c1f61c..282f9918d 100755 --- a/cellacdc/segmenters/YeastMate/__init__.py +++ b/cellacdc/segmenters/YeastMate/__init__.py @@ -13,23 +13,24 @@ if QCoreApplication.instance() is None: app = QApplication(sys.argv) - win = warnVisualCppRequired(pkg_name='YeastMate') + win = warnVisualCppRequired(pkg_name="YeastMate") win.exec_() if win.cancel: - raise ModuleNotFoundError( - 'User cancelled Visual C++ installation' - ) + raise ModuleNotFoundError("User cancelled Visual C++ installation") - subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', 'Cython'] - ) + subprocess.check_call([sys.executable, "-m", "pip", "install", "Cython"]) # subprocess.check_call( # [sys.executable, '-m', 'pip', 'install', # 'git+https://github.com/philferriere/cocoapi.git#subdirectory=PythonAPI'] # ) subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', - 'git+https://github.com/facebookresearch/detectron2.git@v0.5'] + [ + sys.executable, + "-m", + "pip", + "install", + "git+https://github.com/facebookresearch/detectron2.git@v0.5", + ] ) try: @@ -42,24 +43,28 @@ app = QApplication(sys.argv) from cellacdc import myutils - cancel = myutils._install_package_msg('YeastMate') + + cancel = myutils._install_package_msg("YeastMate") if cancel: - raise ModuleNotFoundError( - 'User aborted YeastMate installation' - ) + raise ModuleNotFoundError("User aborted YeastMate installation") subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', - 'git+https://github.com/hoerlteam/YeastMate.git'] + [ + sys.executable, + "-m", + "pip", + "install", + "git+https://github.com/hoerlteam/YeastMate.git", + ] ) # YeastMate installs opencv-python which is not functional with PyQt5 on macOS. # Uninstall it, and reinstall opencv-python-headless subprocess.check_call( - [sys.executable, '-m', 'pip', 'uninstall', '-y', 'opencv-python'] + [sys.executable, "-m", "pip", "uninstall", "-y", "opencv-python"] ) subprocess.check_call( - [sys.executable, '-m', 'pip', 'uninstall', '-y', 'opencv-python-headless'] + [sys.executable, "-m", "pip", "uninstall", "-y", "opencv-python-headless"] ) subprocess.check_call( - [sys.executable, '-m', 'pip', 'install', 'opencv-python-headless'] + [sys.executable, "-m", "pip", "install", "opencv-python-headless"] ) diff --git a/cellacdc/segmenters/YeastMate/acdcSegment.py b/cellacdc/segmenters/YeastMate/acdcSegment.py index ec026c81c..41415c524 100755 --- a/cellacdc/segmenters/YeastMate/acdcSegment.py +++ b/cellacdc/segmenters/YeastMate/acdcSegment.py @@ -15,32 +15,31 @@ from cellacdc.core import getBaseCca_df from cellacdc import user_profile_path + class Model: def __init__(self): - model_path = os.path.join(str(user_profile_path), f'acdc-YeastMate') - yaml_path = os.path.join(model_path, 'yeastmate.yaml') - weights_path = os.path.join(model_path, 'yeastmate_weights.pth') + model_path = os.path.join(str(user_profile_path), f"acdc-YeastMate") + yaml_path = os.path.join(model_path, "yeastmate.yaml") + weights_path = os.path.join(model_path, "yeastmate_weights.pth") - self.model = YeastMatePredictor( - yaml_path, - weights_path - ) + self.model = YeastMatePredictor(yaml_path, weights_path) def segment( - self, image, - score_threshold_0=0.9, - score_thresholds_1=0.75, - score_thresholds_2=0.75, - pixel_size=110, - reference_pixel_size=110, - lower_quantile=1.5, - upper_quantile=98.5 - ): + self, + image, + score_threshold_0=0.9, + score_thresholds_1=0.75, + score_thresholds_2=0.75, + pixel_size=110, + reference_pixel_size=110, + lower_quantile=1.5, + upper_quantile=98.5, + ): score_thresholds = { - 0: score_threshold_0, + 0: score_threshold_0, 1: score_thresholds_1, - 2: score_thresholds_2 + 2: score_thresholds_2, } detections, lab = self.model.inference( @@ -49,7 +48,7 @@ def segment( pixel_size=pixel_size, reference_pixel_size=reference_pixel_size, lower_quantile=lower_quantile, - upper_quantile=upper_quantile + upper_quantile=upper_quantile, ) return lab @@ -68,15 +67,15 @@ def predictCcaState(self, image, precomputed_lab): if info is None: continue - obj_class = info.get('class') + obj_class = info.get("class") if len(obj_class) < 2: continue - is_budding = float(obj_class[1])>2 + is_budding = float(obj_class[1]) > 2 if not is_budding: continue - links = info.get('links') + links = info.get("links") if not links: continue @@ -85,7 +84,7 @@ def predictCcaState(self, image, precomputed_lab): if mother_bud_info is None: continue - mother_bud_ids = mother_bud_info.get('links') + mother_bud_ids = mother_bud_info.get("links") if mother_bud_ids is None: continue @@ -110,14 +109,15 @@ def predictCcaState(self, image, precomputed_lab): if budID not in cca_df.index: continue - cca_df.at[mothID, 'relative_ID'] = budID - cca_df.at[mothID, 'cell_cycle_stage'] = 'S' + cca_df.at[mothID, "relative_ID"] = budID + cca_df.at[mothID, "cell_cycle_stage"] = "S" - cca_df.at[budID, 'relative_ID'] = mothID - cca_df.at[budID, 'cell_cycle_stage'] = 'S' - cca_df.at[budID, 'relationship'] = 'bud' - cca_df.at[budID, 'generation_num'] = 0 + cca_df.at[budID, "relative_ID"] = mothID + cca_df.at[budID, "cell_cycle_stage"] = "S" + cca_df.at[budID, "relationship"] = "bud" + cca_df.at[budID, "generation_num"] = 0 return cca_df + def url_help(): - return 'https://github.com/hoerlteam/YeastMate/blob/main/examples/python_detection.ipynb' + return "https://github.com/hoerlteam/YeastMate/blob/main/examples/python_detection.ipynb" diff --git a/cellacdc/segmenters/__init__.py b/cellacdc/segmenters/__init__.py index b9c59c7b0..be133a6e6 100755 --- a/cellacdc/segmenters/__init__.py +++ b/cellacdc/segmenters/__init__.py @@ -1,5 +1 @@ -STARDIST_MODELS = [ - '2D_versatile_fluo', - '2D_versatile_he', - '2D_paper_dsb2018' -] \ No newline at end of file +STARDIST_MODELS = ["2D_versatile_fluo", "2D_versatile_he", "2D_paper_dsb2018"] diff --git a/cellacdc/segmenters/_cellpose_base/__init__.py b/cellacdc/segmenters/_cellpose_base/__init__.py index 59bc26e30..cea3daa92 100644 --- a/cellacdc/segmenters/_cellpose_base/__init__.py +++ b/cellacdc/segmenters/_cellpose_base/__init__.py @@ -1,5 +1,5 @@ min_target_versions_cp = { - '2': '2.3.2', - '3': '3.1.1.2', - '4': '4.0.6', -} \ No newline at end of file + "2": "2.3.2", + "3": "3.1.1.2", + "4": "4.0.6", +} diff --git a/cellacdc/segmenters/_cellpose_base/_directML.py b/cellacdc/segmenters/_cellpose_base/_directML.py index b71e55027..246cd8845 100644 --- a/cellacdc/segmenters/_cellpose_base/_directML.py +++ b/cellacdc/segmenters/_cellpose_base/_directML.py @@ -2,19 +2,21 @@ from cellacdc.myutils import check_install_package import sys -def init_directML(): + +def init_directML(): success = True try: import torch_directml except ImportError: py_ver = sys.version_info - #check windows + # check windows from cellacdc import is_win + if is_win and py_ver.major == 3 and py_ver.minor < 13: success = check_install_package( - pkg_name = 'torch-directml', - import_pkg_name = 'torch_directml', - pypi_name = 'torch-directml', + pkg_name="torch-directml", + import_pkg_name="torch_directml", + pypi_name="torch-directml", return_outcome=True, ) else: @@ -28,10 +30,11 @@ def init_directML(): success = False return success + def setup_custom_device(model, device): """ Forces the model to use a custom device (e.g., DirectML) for inference. - This is a workaround, and could be handled better in the future. + This is a workaround, and could be handled better in the future. (Ideally when all parameters are set initially) Args: @@ -41,25 +44,25 @@ def setup_custom_device(model, device): Returns: model (cellpose.CellposeModel): Cellpose model with custom device set. """ - if hasattr(model, 'model'): + if hasattr(model, "model"): model = model.model - + model.gpu = True model.device = device model.mkldnn = False - if hasattr(model, 'net'): + if hasattr(model, "net"): model.net.to(device) model.net.mkldnn = False - if hasattr(model, 'cp'): + if hasattr(model, "cp"): model.cp.gpu = True model.cp.device = device model.cp.mkldnn = False - if hasattr(model.cp, 'net'): + if hasattr(model.cp, "net"): model.cp.net.to(device) model.cp.net.mkldnn = False - if hasattr(model, 'sz'): + if hasattr(model, "sz"): model.sz.device = device - + return model @@ -69,14 +72,13 @@ def setup_directML(acdc_cp_model): Args: model (cellpose.CellposeModel|cellpse.Cellpos): Cellpose model. Should work for v2, v3 and custom. - + Returns: model (cellpose.CellposeModel|cellpse.Cellpos): Cellpose model with DirectML set as the device. """ - print( - 'Using DirectML GPU for Cellpose model inference' - ) + print("Using DirectML GPU for Cellpose model inference") import torch_directml + directml_device = torch_directml.device() acdc_cp_model = setup_custom_device(acdc_cp_model, directml_device) - return acdc_cp_model \ No newline at end of file + return acdc_cp_model diff --git a/cellacdc/segmenters/_cellpose_base/acdcSegment.py b/cellacdc/segmenters/_cellpose_base/acdcSegment.py index abb3a8e36..3d6040894 100644 --- a/cellacdc/segmenters/_cellpose_base/acdcSegment.py +++ b/cellacdc/segmenters/_cellpose_base/acdcSegment.py @@ -8,28 +8,31 @@ import inspect + class BackboneOptions: """Options for cellpose backbone""" - values = ['default', "transformer"] + + values = ["default", "transformer"] + class GPUDirectMLGPUCPU: """Options for DirectML GPU acceleration""" - values = ['cpu', 'gpu','directml_gpu'] + + values = ["cpu", "gpu", "directml_gpu"] def cpu_gpu_directml_gpu( - input_string: str, - ): - """Translate input string to cpu, gpu or directml_gpu. - """ + input_string: str, +): + """Translate input string to cpu, gpu or directml_gpu.""" directml_gpu = False gpu = False input_string = input_string.lower() - if input_string == 'cpu': + if input_string == "cpu": pass - elif input_string == 'gpu': + elif input_string == "gpu": gpu = True - elif input_string == 'directml_gpu': + elif input_string == "directml_gpu": directml_gpu = True else: raise ValueError( @@ -38,12 +41,15 @@ def cpu_gpu_directml_gpu( ) return directml_gpu, gpu + class DealWithSecondChannelOptions: """Options available for dealing with second channel""" - values = ['together','separately', 'ignore'] + + values = ["together", "separately", "ignore"] + def check_deal_with_second_channel( - input_string: DealWithSecondChannelOptions, is_rgb: bool + input_string: DealWithSecondChannelOptions, is_rgb: bool ): if input_string not in DealWithSecondChannelOptions.values: raise ValueError( @@ -51,16 +57,16 @@ def check_deal_with_second_channel( f"Expected one of {DealWithSecondChannelOptions.values}." ) input_string = input_string.lower() - seperatly= False + seperatly = False together = False ignore = False if not is_rgb: pass - elif input_string == 'separately': + elif input_string == "separately": seperatly = True - elif input_string == 'together': + elif input_string == "together": together = True - elif input_string == 'ignore': + elif input_string == "ignore": ignore = True else: raise ValueError( @@ -69,29 +75,28 @@ def check_deal_with_second_channel( ) return seperatly, together, ignore + class Model: def __init__( - self, - ): - """Initialize cellpose base model class, which is used in the cellpose versions - """ + self, + ): + """Initialize cellpose base model class, which is used in the cellpose versions""" self.initConstants() - - + def check_model_path_model_type(self, model_path, model_type): - if model_path == 'None' or not model_path: + if model_path == "None" or not model_path: model_path = None - - if model_type == 'None' or not model_type: + + if model_type == "None" or not model_type: model_type = None - + if model_path is not None and model_type is not None: raise TypeError( "You cannot set both `model_type` and `model_path`. " "Please set only one of them." ) - + if model_path is None and model_type is None: raise TypeError( "You must set either `model_type` or `model_path`. " @@ -104,33 +109,35 @@ def initConstants(self, is_rgb=False): self.img_ndim = None self.z_axis = None self.channel_axis = None - self.cp_version = myutils.get_cellpose_major_version() + self.cp_version = myutils.get_cellpose_major_version() self._sizemodelnotfound = True self.batch_size = None self.printed_model_params = False - + def setupLogger(self, logger): from cellpose import models + models.models_logger = logger - + def closeLogger(self): from cellpose import models + handlers = models.models_logger.handlers[:] for handler in handlers: handler.close() models.models_logger.removeHandler(handler) - + def _eval(self, image, **kwargs): if self.batch_size is not None: - kwargs['batch_size'] = self.batch_size + kwargs["batch_size"] = self.batch_size if self.cp_version == 4: - del kwargs['channels'] - kwargs['channel_axis'] = self.channel_axis - kwargs['z_axis'] = self.z_axis + del kwargs["channels"] + kwargs["channel_axis"] = self.channel_axis + kwargs["z_axis"] = self.z_axis if self.cp_version == 3: kwargs["channel_axis"] = self.channel_axis kwargs["z_axis"] = self.z_axis - + if not self.printed_model_params: if isinstance(image, list): sample_img = image[0] @@ -143,16 +150,12 @@ def _eval(self, image, **kwargs): print(f"Running model on image shape: {shape}, kwargs: {kwargs}") if self.is_rgb: for i, subarr in enumerate(np.moveaxis(sample_img, -3, 0)): - print(f"Channel {i+1} min: {subarr.min()}, max: {subarr.max()}") + print(f"Channel {i + 1} min: {subarr.min()}, max: {subarr.max()}") else: print(f"Image min: {sample_img.min()}, max: {sample_img.max()}") self.printed_model_params = True - - out, removed_kwargs = myutils.try_kwargs( - self.model.eval, - image, - **kwargs - ) + + out, removed_kwargs = myutils.try_kwargs(self.model.eval, image, **kwargs) segm = out[0] if removed_kwargs: print( @@ -170,74 +173,75 @@ def _eval(self, image, **kwargs): "but was removed from eval method." ) return segm - + def second_ch_img_to_stack(self, first_ch_data, second_ch_data): # The 'cyto' model can work with a second channel (e.g., nucleus). # However, it needs to be encoded into one of the RGB channels - # Here we put the first channel in the 'red' channel and the + # Here we put the first channel in the 'red' channel and the # second channel in the 'green' channel. We then pass # `channels = [1,2]` to the segment method rgb_stack = np.zeros((*first_ch_data.shape, 3), dtype=first_ch_data.dtype) - - R_slice = [slice(None)]*(rgb_stack.ndim) + + R_slice = [slice(None)] * (rgb_stack.ndim) R_slice[-1] = 0 R_slice = tuple(R_slice) rgb_stack[R_slice] = first_ch_data - G_slice = [slice(None)]*(rgb_stack.ndim) + G_slice = [slice(None)] * (rgb_stack.ndim) G_slice[-1] = 1 G_slice = tuple(G_slice) rgb_stack[G_slice] = second_ch_data - + self.is_rgb = True return rgb_stack - + def get_zStack_rgb(self, image): if self.img_shape is None: self.img_shape = image.shape if self.img_ndim is None: self.img_ndim = len(self.img_shape) - self.is_rgb = (self.img_shape[-1] == 3 or self.img_shape[-1] == 4) if not self.is_rgb else self.is_rgb + self.is_rgb = ( + (self.img_shape[-1] == 3 or self.img_shape[-1] == 4) + if not self.is_rgb + else self.is_rgb + ) remaining_dims = self.img_ndim if self.is_rgb: remaining_dims -= 1 if self.timelapse: remaining_dims -= 1 - self.isZstack = ( - remaining_dims == 3 - ) + self.isZstack = remaining_dims == 3 return self.isZstack, self.is_rgb - + def get_eval_kwargs( - self, image, - diameter=0.0, - flow_threshold=0.4, - # cellprob_threshold=0.0, - stitch_threshold=0.0, - # min_size=15, - anisotropy=0.0, - # normalize=True, - # resample=True, - segment_3D_volume=False, - # max_size_fraction=0.4, - # flow3D_smooth=0, - # tile_overlap=0.1, - **kwargs - ): - """Get evaluation kwargs for the model.eval method, accurate for v2. - """ + self, + image, + diameter=0.0, + flow_threshold=0.4, + # cellprob_threshold=0.0, + stitch_threshold=0.0, + # min_size=15, + anisotropy=0.0, + # normalize=True, + # resample=True, + segment_3D_volume=False, + # max_size_fraction=0.4, + # flow3D_smooth=0, + # tile_overlap=0.1, + **kwargs, + ): + """Get evaluation kwargs for the model.eval method, accurate for v2.""" if diameter == 0.0 and self._sizemodelnotfound: raise TypeError( - 'Diameter is 0.0 but size model is not found. ' - 'Please set diameter to a non-zero value.' + "Diameter is 0.0 but size model is not found. " + "Please set diameter to a non-zero value." ) - if self.img_shape is None: self.img_shape = image.shape @@ -249,9 +253,9 @@ def get_eval_kwargs( if anisotropy == 0.0 and segment_3D_volume: if not self.printed_model_params: print( - 'Anisotropy is 0.0 but segment_3D_volume is True. ' - 'Please set anisotropy to a non-zero value.' \ - 'For now set to 1.0, assuming isotropic data.' + "Anisotropy is 0.0 but segment_3D_volume is True. " + "Please set anisotropy to a non-zero value." + "For now set to 1.0, assuming isotropic data." ) anisotropy = 1.0 @@ -259,62 +263,58 @@ def get_eval_kwargs( if not self.printed_model_params: print( """Anisotropy is set to 1.0 (assuming isotropic data), - since data is not a z-stack""") + since data is not a z-stack""" + ) anisotropy = 1.0 - + do_3D = segment_3D_volume if not isZstack: stitch_threshold = 0.0 segment_3D_volume = False do_3D = False - + if stitch_threshold > 0: if not self.printed_model_params: - print( - 'Using stiching mode instead of trying to segment 3D volume.' - ) + print("Using stiching mode instead of trying to segment 3D volume.") do_3D = False - + if isZstack and flow_threshold > 0: if not self.printed_model_params: print( - 'Flow threshold is not used for 3D segmentation. ' - 'Setting it to 0.0.' + "Flow threshold is not used for 3D segmentation. Setting it to 0.0." ) flow_threshold = 0.0 - - if flow_threshold==0.0: + + if flow_threshold == 0.0: flow_threshold = None - channels = [0,0] if not is_rgb else [1,2] + channels = [0, 0] if not is_rgb else [1, 2] eval_kwargs = { - 'channels': channels, - 'diameter': diameter, - 'flow_threshold': flow_threshold, + "channels": channels, + "diameter": diameter, + "flow_threshold": flow_threshold, #'cellprob_threshold': cellprob_threshold, - 'stitch_threshold': stitch_threshold, + "stitch_threshold": stitch_threshold, # 'min_size': min_size, # 'normalize': normalize, - 'do_3D': do_3D, - 'anisotropy': anisotropy, + "do_3D": do_3D, + "anisotropy": anisotropy, # 'resample': resample, # 'max_size_fraction': max_size_fraction, # 'flow3D_smooth': flow3D_smooth, # 'tile_overlap': tile_overlap } - if not segment_3D_volume and isZstack and stitch_threshold>0: + if not segment_3D_volume and isZstack and stitch_threshold > 0: raise TypeError( "`stitch_threshold` must be 0 when segmenting slice-by-slice. " "Alternatively, set `segment_3D_volume = True`." ) - + return eval_kwargs, isZstack - def eval_loop( - self, images, segment_3D_volume, init_imgs=True, **eval_kwargs - ): + def eval_loop(self, images, segment_3D_volume, init_imgs=True, **eval_kwargs): """No support for time lapse. This is handles in self._segment3DT_eval Parameters @@ -331,111 +331,123 @@ def eval_loop( Returns ------- np.ndarray - Segmentation masks array. If `segment_3D_volume` is True, - the shape is (Z, Y, X) or (T, Z, Y, X). If `segment_3D_volume` + Segmentation masks array. If `segment_3D_volume` is True, + the shape is (Z, Y, X) or (T, Z, Y, X). If `segment_3D_volume` is False, the shape is (Y, X) or (T, Y, X). """ if self.img_shape is None: self.img_shape = images.shape - if not segment_3D_volume and self.isZstack: # segment on a per slice basis + if not segment_3D_volume and self.isZstack: # segment on a per slice basis if init_imgs: images, z_axis, channel_axis = _initialize_image( - images, self.is_rgb, iter_axis_zstack=0, + images, + self.is_rgb, + iter_axis_zstack=0, isZstack=self.isZstack, ) else: z_axis = self.z_axis channel_axis = self.channel_axis - self.z_axis = None # since we are segmenting slice-by-slice - self.channel_axis = channel_axis - 1 if channel_axis is not None else None # since we iterate over z-axis + self.z_axis = None # since we are segmenting slice-by-slice + self.channel_axis = ( + channel_axis - 1 if channel_axis is not None else None + ) # since we iterate over z-axis if self.channel_axis is None: labels = np.zeros(images.shape, dtype=np.uint32) else: - shape = images.shape[:channel_axis] + images.shape[channel_axis+1:] + shape = images.shape[:channel_axis] + images.shape[channel_axis + 1 :] labels = np.zeros(shape, dtype=np.uint32) for i, z_img in enumerate(images): lab = self._eval(z_img, **eval_kwargs) labels[i] = lab - labels = skimage.measure.label(labels>0) + labels = skimage.measure.label(labels > 0) else: if init_imgs: - images, z_axis, channel_axis = _initialize_image(images, self.is_rgb, - isZstack=self.isZstack, - ) + images, z_axis, channel_axis = _initialize_image( + images, + self.is_rgb, + isZstack=self.isZstack, + ) self.z_axis = z_axis self.channel_axis = channel_axis else: z_axis = self.z_axis channel_axis = self.channel_axis - + labels = self._eval(images, **eval_kwargs) - + return labels - - def segment3DT_eval( - self, images, eval_kwargs, init_imgs=True, **kwargs - ): - if not kwargs['segment_3D_volume'] and self.isZstack: + + def segment3DT_eval(self, images, eval_kwargs, init_imgs=True, **kwargs): + if not kwargs["segment_3D_volume"] and self.isZstack: if init_imgs: - images, z_axis, channel_axis = _initialize_image(images, self.is_rgb, - iter_axis_time=0, - iter_axis_zstack=1, - timelapse=True, - isZstack=self.isZstack, - ) + images, z_axis, channel_axis = _initialize_image( + images, + self.is_rgb, + iter_axis_time=0, + iter_axis_zstack=1, + timelapse=True, + isZstack=self.isZstack, + ) else: z_axis = self.z_axis channel_axis = self.channel_axis - - self.z_axis = z_axis - 2 if z_axis is not None else None # video doesnt count as dim. iterate over time + + self.z_axis = ( + z_axis - 2 if z_axis is not None else None + ) # video doesnt count as dim. iterate over time self.channel_axis = channel_axis - 2 if channel_axis is not None else None - # Passing entire 4D video and segmenting slice-by-slice is + # Passing entire 4D video and segmenting slice-by-slice is # not possible --> iterate each frame and run normal segment if self.channel_axis is None: labels = np.zeros(images.shape, dtype=np.uint32) else: - shape = images.shape[:channel_axis] + images.shape[channel_axis+1:] + shape = images.shape[:channel_axis] + images.shape[channel_axis + 1 :] labels = np.zeros(shape, dtype=np.uint32) for i, img_t in enumerate(images): lab = self.eval_loop( - img_t, segment_3D_volume=False, - init_imgs=False, - **eval_kwargs + img_t, segment_3D_volume=False, init_imgs=False, **eval_kwargs ) labels[i] = lab else: - eval_kwargs['channels'] = [eval_kwargs['channels']]*len(images) + eval_kwargs["channels"] = [eval_kwargs["channels"]] * len(images) if init_imgs: - images, z_axis, channel_axis = _initialize_image(images, self.is_rgb, - iter_axis_time=0, - timelapse=True, - isZstack=self.isZstack, - ) + images, z_axis, channel_axis = _initialize_image( + images, + self.is_rgb, + iter_axis_time=0, + timelapse=True, + isZstack=self.isZstack, + ) else: z_axis = self.z_axis channel_axis = self.channel_axis - self.z_axis = z_axis - 1 if z_axis is not None else None # video doesnt count as dim + self.z_axis = ( + z_axis - 1 if z_axis is not None else None + ) # video doesnt count as dim self.channel_axis = channel_axis - 1 if channel_axis is not None else None - images = [image.astype(np.float32) for image in images] # convert to list + images = [image.astype(np.float32) for image in images] # convert to list labels = np.array(self._eval(images, **eval_kwargs)) return labels - -def _initialize_image(image:np.ndarray, - is_rgb:bool, - # single_img_shape:Tuple[int], - # single_img_ndim:int, - iter_axis_time:int=None, - iter_axis_zstack:int=None, - target_shape:Tuple[int]=None, - timelapse:bool=False, - isZstack:bool=False, - target_axis_iter:Tuple[int]=None, - add_rgb:bool=False, - ): + + +def _initialize_image( + image: np.ndarray, + is_rgb: bool, + # single_img_shape:Tuple[int], + # single_img_ndim:int, + iter_axis_time: int = None, + iter_axis_zstack: int = None, + target_shape: Tuple[int] = None, + timelapse: bool = False, + isZstack: bool = False, + target_axis_iter: Tuple[int] = None, + add_rgb: bool = False, +): """Tries to initialize image for cellpose. You will have to specify the target shape and the axis to iterate over. Target order of dimensions is (Z x nchan x Y x X) or (T x Z x nchan x Y x X) @@ -482,23 +494,39 @@ def _initialize_image(image:np.ndarray, f"Image is {len(true_img_shape)}D with shape {true_img_shape}. " "It was expected to have 4D shape (T x Z x Y x X x nchan)" ) - + z_axis = 1 if add_rgb: - target_shape = (true_img_shape[0], true_img_shape[1], 3, true_img_shape[-2], true_img_shape[-1]) + target_shape = ( + true_img_shape[0], + true_img_shape[1], + 3, + true_img_shape[-2], + true_img_shape[-1], + ) channel_axis = 2 elif is_rgb: - target_shape = (true_img_shape[0], true_img_shape[1], 3, true_img_shape[-3], true_img_shape[-2]) + target_shape = ( + true_img_shape[0], + true_img_shape[1], + 3, + true_img_shape[-3], + true_img_shape[-2], + ) channel_axis = 2 else: - target_shape = (true_img_shape[0], true_img_shape[1], true_img_shape[-2], true_img_shape[-1]) + target_shape = ( + true_img_shape[0], + true_img_shape[1], + true_img_shape[-2], + true_img_shape[-1], + ) channel_axis = None - if iter_axis_time is not None and iter_axis_zstack is not None: iter_axis = [iter_axis_time, iter_axis_zstack] target_axis_iter = [0, 1] - elif iter_axis_time is not None and iter_axis_zstack is None: + elif iter_axis_time is not None and iter_axis_zstack is None: iter_axis = [iter_axis_time] target_axis_iter = [0] elif iter_axis_time is None and iter_axis_zstack is not None: @@ -507,7 +535,7 @@ def _initialize_image(image:np.ndarray, else: iter_axis = None target_axis_iter = None - + elif timelapse and not isZstack: z_axis = None if len(true_img_shape) < 3 or (is_rgb and len(true_img_shape) < 4): @@ -516,10 +544,20 @@ def _initialize_image(image:np.ndarray, "It was expected to have 3D shape (T x Y x X x nchan)" ) if add_rgb: - target_shape = (true_img_shape[0], 3, true_img_shape[-2], true_img_shape[-1]) + target_shape = ( + true_img_shape[0], + 3, + true_img_shape[-2], + true_img_shape[-1], + ) channel_axis = 1 elif is_rgb: - target_shape = (true_img_shape[0], 3, true_img_shape[-3], true_img_shape[-2]) + target_shape = ( + true_img_shape[0], + 3, + true_img_shape[-3], + true_img_shape[-2], + ) channel_axis = 1 else: target_shape = (true_img_shape[0], true_img_shape[-2], true_img_shape[-1]) @@ -531,7 +569,7 @@ def _initialize_image(image:np.ndarray, else: iter_axis = None target_axis_iter = None - + elif not timelapse and isZstack: z_axis = 0 if len(true_img_shape) < 3 or (is_rgb and len(true_img_shape) < 4): @@ -540,10 +578,20 @@ def _initialize_image(image:np.ndarray, "It was expected to have 3D shape (Z x Y x X x nchan)" ) if add_rgb: - target_shape = (true_img_shape[0], 3, true_img_shape[-2], true_img_shape[-1]) + target_shape = ( + true_img_shape[0], + 3, + true_img_shape[-2], + true_img_shape[-1], + ) channel_axis = 1 elif is_rgb: - target_shape = (true_img_shape[0], 3, true_img_shape[-3], true_img_shape[-2]) + target_shape = ( + true_img_shape[0], + 3, + true_img_shape[-3], + true_img_shape[-2], + ) channel_axis = 1 else: target_shape = (true_img_shape[0], true_img_shape[-2], true_img_shape[-1]) @@ -555,7 +603,7 @@ def _initialize_image(image:np.ndarray, else: iter_axis = None target_axis_iter = None - + elif not timelapse and not isZstack: z_axis = None @@ -569,7 +617,7 @@ def _initialize_image(image:np.ndarray, channel_axis = 0 elif is_rgb: target_shape = (3, true_img_shape[-3], true_img_shape[-2]) - channel_axis = 0 + channel_axis = 0 else: target_shape = (true_img_shape[-2], true_img_shape[-1]) channel_axis = None @@ -580,18 +628,19 @@ def _initialize_image(image:np.ndarray, single_img_from_iter_axis = image[tuple(idx)] else: single_img_from_iter_axis = image - + if single_img_from_iter_axis is not None: single_img_shape = single_img_from_iter_axis.shape single_img_ndim = len(single_img_shape) else: single_img_shape = true_img_shape single_img_ndim = len(single_img_shape) - + single_img_isZstack = isZstack if iter_axis_zstack is None else False single_img_timelapse = timelapse if iter_axis_time is None else False - + from cellacdc._core import _initialize_single_image + image = core.apply_func_to_imgs( image, _initialize_single_image, @@ -604,32 +653,33 @@ def _initialize_image(image:np.ndarray, img_shape=single_img_shape, img_ndim=single_img_ndim, timelapse=single_img_timelapse, - add_rgb=add_rgb, ) return image, z_axis, channel_axis + def check_directml_gpu_gpu(model_name, directml_gpu, gpu, ask_install=True): if ask_install: proceed, available_frameworks_list = myutils.check_gpu_available( - model_name, - use_gpu=(gpu or directml_gpu), - cuda=gpu, - return_available_gpu_type=True + model_name, + use_gpu=(gpu or directml_gpu), + cuda=gpu, + return_available_gpu_type=True, ) else: proceed = True - available_frameworks_list = ['cuda', 'directML'] + available_frameworks_list = ["cuda", "directML"] - if 'cuda' not in available_frameworks_list: + if "cuda" not in available_frameworks_list: gpu = False - if 'directML' not in available_frameworks_list: + if "directML" not in available_frameworks_list: directml_gpu = False if not proceed: return directml_gpu, gpu, proceed if directml_gpu: from cellacdc.segmenters._cellpose_base._directML import init_directML + directml_gpu = init_directML() if directml_gpu and gpu: @@ -640,20 +690,24 @@ def check_directml_gpu_gpu(model_name, directml_gpu, gpu, ask_install=True): """ ) gpu = False - + return directml_gpu, gpu, proceed + def setup_gpu_direct_ml(self, directml_gpu, gpu, device): if directml_gpu: from cellacdc.segmenters._cellpose_base._directML import setup_directML + setup_directML(self) from cellacdc.core import fix_sparse_directML + fix_sparse_directML() - - if gpu: # sometimes gpu is not properly set up ^^ + + if gpu: # sometimes gpu is not properly set up ^^ from cellacdc.segmenters._cellpose_base._directML import setup_custom_device + if device is None: device = 0 try: @@ -662,27 +716,28 @@ def setup_gpu_direct_ml(self, directml_gpu, gpu, device): pass if isinstance(device, int): - device = torch.device(f'cuda:{device}') + device = torch.device(f"cuda:{device}") elif isinstance(device, str): device = torch.device(device) - + setup_custom_device(self, device) + def _get_normalize_params( - image, - normalize=False, - rescale_intensity_low_val_perc=0.0, - rescale_intensity_high_val_perc=100.0, - # sharpen=0, - low_percentile=1.0, - high_percentile=99.0, - norm3D=False, - cp_version=4, - tile_norm_blocksize=0, - ): + image, + normalize=False, + rescale_intensity_low_val_perc=0.0, + rescale_intensity_high_val_perc=100.0, + # sharpen=0, + low_percentile=1.0, + high_percentile=99.0, + norm3D=False, + cp_version=4, + tile_norm_blocksize=0, +): if not normalize: return False - + rescale_intensity_low_val_perc = float(rescale_intensity_low_val_perc) rescale_intensity_high_val_perc = float(rescale_intensity_high_val_perc) low_percentile = float(low_percentile) @@ -690,26 +745,26 @@ def _get_normalize_params( normalize_kwargs = {} do_rescale = ( - rescale_intensity_low_val_perc != 0 - or rescale_intensity_high_val_perc != 100.0 + rescale_intensity_low_val_perc != 0 or rescale_intensity_high_val_perc != 100.0 ) if not do_rescale: - normalize_kwargs['lowhigh'] = None + normalize_kwargs["lowhigh"] = None else: - low = image*rescale_intensity_low_val_perc/100 - high = image*rescale_intensity_high_val_perc/100 - normalize_kwargs['lowhigh'] = (low, high) - + low = image * rescale_intensity_low_val_perc / 100 + high = image * rescale_intensity_high_val_perc / 100 + normalize_kwargs["lowhigh"] = (low, high) + # normalize_kwargs['sharpen'] = sharpen - normalize_kwargs['percentile'] = (low_percentile, high_percentile) - normalize_kwargs['norm3D'] = norm3D + normalize_kwargs["percentile"] = (low_percentile, high_percentile) + normalize_kwargs["norm3D"] = norm3D if cp_version == 4: - normalize_kwargs['tile_norm_blocksize'] = tile_norm_blocksize + normalize_kwargs["tile_norm_blocksize"] = tile_norm_blocksize elif cp_version == 3: - normalize_kwargs['tile_norm'] = tile_norm_blocksize - + normalize_kwargs["tile_norm"] = tile_norm_blocksize + return normalize_kwargs + def url_help(): - return 'https://cellpose.readthedocs.io/en/latest/api.html' \ No newline at end of file + return "https://cellpose.readthedocs.io/en/latest/api.html" diff --git a/cellacdc/segmenters/cellpose_v2/__init__.py b/cellacdc/segmenters/cellpose_v2/__init__.py index e99ca1b01..6abf1af8c 100644 --- a/cellacdc/segmenters/cellpose_v2/__init__.py +++ b/cellacdc/segmenters/cellpose_v2/__init__.py @@ -1,9 +1,12 @@ import cellacdc.myutils as myutils + myutils.check_install_cellpose(2) + class AvailableModelsv2: from cellpose.models import MODEL_NAMES + values = MODEL_NAMES - - is_exclusive_with = ['model_path'] - default_exclusive = 'Using custom model' \ No newline at end of file + + is_exclusive_with = ["model_path"] + default_exclusive = "Using custom model" diff --git a/cellacdc/segmenters/cellpose_v2/acdcSegment.py b/cellacdc/segmenters/cellpose_v2/acdcSegment.py index 76661af12..3003ca11e 100644 --- a/cellacdc/segmenters/cellpose_v2/acdcSegment.py +++ b/cellacdc/segmenters/cellpose_v2/acdcSegment.py @@ -4,24 +4,24 @@ from cellacdc import myutils from . import AvailableModelsv2 + class Model(CellposeBaseModel): def __new__(cls, *args, **kwargs): myutils.check_install_cellpose(2) return super().__new__(cls) - + def __init__( - self, - model_type: AvailableModelsv2='cyto', - model_path: os.PathLike='', - net_avg:bool=False, - gpu:bool=False, - device:torch.device|int='None', - custom_concatenation:bool=False, - custom_style_on:bool=True, - custom_residual_on:bool=True, - custom_diam_mean:float=30.0, - - ): + self, + model_type: AvailableModelsv2 = "cyto", + model_path: os.PathLike = "", + net_avg: bool = False, + gpu: bool = False, + device: torch.device | int = "None", + custom_concatenation: bool = False, + custom_style_on: bool = True, + custom_residual_on: bool = True, + custom_diam_mean: float = 30.0, + ): """Initialize cellpose 2 model Parameters @@ -35,16 +35,16 @@ def __init__( `model_type`. If you want to use a custom model, set this to the path of the model file. Default is None. gpu : bool, optional - If True and PyTorch for your GPU is correctly installed, - denoising and segmentation processes will run on the GPU. + If True and PyTorch for your GPU is correctly installed, + denoising and segmentation processes will run on the GPU. Default is False directml_gpu : bool, optional If True, will attempt to use DirectML for GPU acceleration. Only for v3 and v4. v2 loads the model later, which causes problems. Dont want to edit cellpose code too much... device : torch.device or int or None If not None, this is the device used for running the model - (torch.device('cuda') or torch.device('cpu')). - It overrides `gpu`, recommended if you want to use a specific GPU + (torch.device('cuda') or torch.device('cpu')). + It overrides `gpu`, recommended if you want to use a specific GPU (e.g. torch.device('cuda:1'). Default is None custom_concatenation : bool, optional Only effects custom trained models. See cellpose v2 for more info. @@ -61,20 +61,24 @@ def __init__( """ self.init_successful = False self.initConstants() - model_type, model_path, device = myutils.translateStrNone(model_type, model_path, device) - + model_type, model_path, device = myutils.translateStrNone( + model_type, model_path, device + ) + self.check_model_path_model_type( - model_type=model_type, - model_path=model_path, + model_type=model_type, + model_path=model_path, ) - - print(f'Initializing Cellpose v2...') + + print(f"Initializing Cellpose v2...") from cellpose import models + if model_type: try: self.model = models.Cellpose( - gpu=gpu, net_avg=net_avg, + gpu=gpu, + net_avg=net_avg, model_type=model_type, device=device, ) @@ -82,13 +86,17 @@ def __init__( except FileNotFoundError: self._sizemodelnotfound = True self.model = models.CellposeModel( - gpu=gpu, net_avg=net_avg, model_type=model_type, + gpu=gpu, + net_avg=net_avg, + model_type=model_type, device=device, ) elif model_path is not None: self._sizemodelnotfound = True self.model = models.CellposeModel( - gpu=gpu, net_avg=net_avg, device=device, + gpu=gpu, + net_avg=net_avg, + device=device, pretrained_model=model_path, concatenation=custom_concatenation, style_on=custom_style_on, @@ -96,40 +104,40 @@ def __init__( diam_mean=custom_diam_mean, ) self.init_successful = True - + def _get_eval_kwargs_v2( - self, - cellprob_threshold:float=0.0, - min_size:int=15, - normalize:bool=True, - resample:bool=True, - invert:bool=False, - original_kwargs:dict=None, + self, + cellprob_threshold: float = 0.0, + min_size: int = 15, + normalize: bool = True, + resample: bool = True, + invert: bool = False, + original_kwargs: dict = None, ): additional_kwargs = { - 'cellprob_threshold': cellprob_threshold, - 'min_size': min_size, - 'normalize': normalize, - 'resample': resample, - 'invert': invert, + "cellprob_threshold": cellprob_threshold, + "min_size": min_size, + "normalize": normalize, + "resample": resample, + "invert": invert, } original_kwargs.update(additional_kwargs) return original_kwargs - def segment( - self, image, - diameter:float=0.0, - flow_threshold:float=0.4, - cellprob_threshold:float=0.0, - stitch_threshold:float=0.0, - min_size:int=15, - normalize:bool=True, - resample:bool=True, - segment_3D_volume:bool=False, - anisotropy:float=0.0, - invert:bool=False, - ): + self, + image, + diameter: float = 0.0, + flow_threshold: float = 0.4, + cellprob_threshold: float = 0.0, + stitch_threshold: float = 0.0, + min_size: int = 15, + normalize: bool = True, + resample: bool = True, + segment_3D_volume: bool = False, + anisotropy: float = 0.0, + invert: bool = False, + ): """Segment image using cellpose eval Parameters @@ -137,38 +145,38 @@ def segment( image : (Y, X) or (Z, Y, X) numpy.ndarray Input image. Either 2D or 3D z-stack. diameter : float, optional - Average diameter (in pixels) of the obejcts of interest. + Average diameter (in pixels) of the obejcts of interest. Default is 0.0 flow_threshold : float, optional - Flow error threshold (all cells with errors below threshold are + Flow error threshold (all cells with errors below threshold are kept) (not used for 3D). Default is 0.4 cellprob_threshold : float, optional - All pixels with value above threshold will be part of an object. + All pixels with value above threshold will be part of an object. Decrease this value to find more and larger masks. Default is 0.0 stitch_threshold : float, optional - If `stitch_threshold` is greater than 0.0 and `segment_3D_volume` - is True, masks are stitched in 3D to return volume segmentation. + If `stitch_threshold` is greater than 0.0 and `segment_3D_volume` + is True, masks are stitched in 3D to return volume segmentation. Default is 0.0 min_size : int, optional - Minimum number of pixels per mask, you can turn off this filter + Minimum number of pixels per mask, you can turn off this filter with `min_size = -1`. Default is 15 anisotropy : float, optional - For 3D segmentation, optional rescaling factor (e.g. set to 2.0 if + For 3D segmentation, optional rescaling factor (e.g. set to 2.0 if Z is sampled half as dense as X or Y). Default is 0.0 normalize : bool, optional - If True, normalize image using the other parameters. + If True, normalize image using the other parameters. Default is True resample : bool, optional - Run dynamics at original image size (will be slower but create + Run dynamics at original image size (will be slower but create more accurate boundaries). Default is True segment_3D_volume : bool, optional - If True and input `image` is a 3D z-stack the entire z-stack - is passed to cellpose model. If False, Cell-ACDC will force one - z-slice at the time. Best results with 3D data are obtained by - passing the entire z-stack, but with a `stitch_threshold` greater - than 0 (e.g., 0.4). This way cellpose will internally segment - slice-by-slice and it will merge the resulting z-slice masks - belonging to the same object. + If True and input `image` is a 3D z-stack the entire z-stack + is passed to cellpose model. If False, Cell-ACDC will force one + z-slice at the time. Best results with 3D data are obtained by + passing the entire z-stack, but with a `stitch_threshold` greater + than 0 (e.g., 0.4). This way cellpose will internally segment + slice-by-slice and it will merge the resulting z-slice masks + belonging to the same object. Default is False invert : bool, optional If True, invert image pixel intensity before running network. @@ -183,7 +191,7 @@ def segment( ------ TypeError `stitch_threshold` must be 0 when segmenting slice-by-slice. - """ + """ self.timelapse = False self.img_shape = image.shape self.img_ndim = len(self.img_shape) @@ -198,7 +206,7 @@ def segment( anisotropy=anisotropy, # normalize=normalize, # resample=resample, - segment_3D_volume=segment_3D_volume + segment_3D_volume=segment_3D_volume, ) eval_kwargs = self._get_eval_kwargs_v2( @@ -207,20 +215,18 @@ def segment( normalize=normalize, resample=resample, invert=invert, - original_kwargs=eval_kwargs + original_kwargs=eval_kwargs, ) labels = self.eval_loop( - image, - segment_3D_volume=segment_3D_volume, - **eval_kwargs + image, segment_3D_volume=segment_3D_volume, **eval_kwargs ) self.img_shape = None self.img_ndim = None return labels - + def segment3DT(self, video_data, signals=None, **kwargs): self.timelapse = True self.img_shape = video_data[0].shape @@ -228,20 +234,16 @@ def segment3DT(self, video_data, signals=None, **kwargs): eval_kwargs, self.isZstack = self.get_eval_kwargs(video_data[0], **kwargs) eval_kwargs = self._get_eval_kwargs_v2( - cellprob_threshold=kwargs['cellprob_threshold'], - min_size=kwargs['min_size'], - normalize=kwargs['normalize'], - resample=kwargs['resample'], - invert=kwargs['invert'], - original_kwargs=eval_kwargs - ) - - - labels = self.segment3DT_eval( - video_data, eval_kwargs, **kwargs + cellprob_threshold=kwargs["cellprob_threshold"], + min_size=kwargs["min_size"], + normalize=kwargs["normalize"], + resample=kwargs["resample"], + invert=kwargs["invert"], + original_kwargs=eval_kwargs, ) + labels = self.segment3DT_eval(video_data, eval_kwargs, **kwargs) self.img_shape = None self.img_ndim = None - return labels \ No newline at end of file + return labels diff --git a/cellacdc/segmenters/cellpose_v3/__init__.py b/cellacdc/segmenters/cellpose_v3/__init__.py index cca6ea45e..93806ca14 100644 --- a/cellacdc/segmenters/cellpose_v3/__init__.py +++ b/cellacdc/segmenters/cellpose_v3/__init__.py @@ -1,16 +1,21 @@ import cellacdc.myutils as myutils + myutils.check_install_cellpose(3) + class AvailableModelsv3: from cellpose.models import MODEL_NAMES + values = MODEL_NAMES - - is_exclusive_with = ['model_path'] - default_exclusive = 'Using custom model' + + is_exclusive_with = ["model_path"] + default_exclusive = "Using custom model" + class AvailableModelsv3Denoise: from cellpose.denoise import MODEL_NAMES + values = MODEL_NAMES - - is_exclusive_with = ['denoise_model_path'] - default_exclusive = 'Using custom denoise model' \ No newline at end of file + + is_exclusive_with = ["denoise_model_path"] + default_exclusive = "Using custom denoise model" diff --git a/cellacdc/segmenters/cellpose_v3/_denoise.py b/cellacdc/segmenters/cellpose_v3/_denoise.py index 975df573d..fc11cd3ca 100644 --- a/cellacdc/segmenters/cellpose_v3/_denoise.py +++ b/cellacdc/segmenters/cellpose_v3/_denoise.py @@ -7,29 +7,36 @@ import os from cellacdc import myutils -from cellacdc.segmenters._cellpose_base.acdcSegment import (_initialize_image, GPUDirectMLGPUCPU, - cpu_gpu_directml_gpu, check_directml_gpu_gpu, - setup_gpu_direct_ml, _get_normalize_params, - DealWithSecondChannelOptions, check_deal_with_second_channel) +from cellacdc.segmenters._cellpose_base.acdcSegment import ( + _initialize_image, + GPUDirectMLGPUCPU, + cpu_gpu_directml_gpu, + check_directml_gpu_gpu, + setup_gpu_direct_ml, + _get_normalize_params, + DealWithSecondChannelOptions, + check_deal_with_second_channel, +) import torch from _types import NotGUIParam import itertools + class CellposeDenoiseModel(DenoiseModel): def __init__( - self, - device_type: GPUDirectMLGPUCPU='cpu', - device: torch.device | int | None = None, - batch_size: int = 8, - denoise_model: AvailableModelsv3Denoise='denoise_cyto3', - deal_with_second_channel: DealWithSecondChannelOptions = 'together', - denoise_model_path: os.PathLike='', - diam_mean: float = 30.0, - denoise_nchan: int = 1, - is_rgb: NotGUIParam = False, - ask_install_gpu: NotGUIParam = True, - ): + self, + device_type: GPUDirectMLGPUCPU = "cpu", + device: torch.device | int | None = None, + batch_size: int = 8, + denoise_model: AvailableModelsv3Denoise = "denoise_cyto3", + deal_with_second_channel: DealWithSecondChannelOptions = "together", + denoise_model_path: os.PathLike = "", + diam_mean: float = 30.0, + denoise_nchan: int = 1, + is_rgb: NotGUIParam = False, + ask_install_gpu: NotGUIParam = True, + ): """Initialize cellpose 3.0 denoising model Parameters @@ -41,8 +48,8 @@ def __init__( - 'directml': Use DirectML for running the model on GPU. device : torch.device or int or None If not None, this is the device used for running the model - (torch.device('cuda') or torch.device('cpu')). - It overrides `gpu`, recommended if you want to use a specific GPU + (torch.device('cuda') or torch.device('cpu')). + It overrides `gpu`, recommended if you want to use a specific GPU (e.g. torch.device('cuda:1'). Default is None batch_size : int, optional Batch size for running the model on GPU. Reduce to decrease memory usage, but it will slow down the processing. @@ -73,7 +80,7 @@ def __init__( Path to a custom cellpose denoise model file. diam_mean : float, optional Mean diameter of objects in the image for denoising during training. - If using a pretrained model, it is recommended to leave it as 0.0, + If using a pretrained model, it is recommended to leave it as 0.0, which will use the default diameter 30 denoise_nchan : int, optional Number of channels in the denoised image. Default is 1. @@ -89,75 +96,82 @@ def __init__( self.printed_model_params = False - self.is_rgb = is_rgb - self.denoise_second_channel_separately, self.denoise_second_channel_together, self.ignore_second_channel = check_deal_with_second_channel( - deal_with_second_channel, is_rgb) - + ( + self.denoise_second_channel_separately, + self.denoise_second_channel_together, + self.ignore_second_channel, + ) = check_deal_with_second_channel(deal_with_second_channel, is_rgb) + self.batch_size = batch_size - denoise_model, denoise_model_path, device = myutils.translateStrNone(denoise_model, denoise_model_path, device) - directml_gpu, gpu = cpu_gpu_directml_gpu( + denoise_model, denoise_model_path, device = myutils.translateStrNone( + denoise_model, denoise_model_path, device + ) + directml_gpu, gpu = cpu_gpu_directml_gpu( input_string=device_type, ) - self.nstr = denoise_model.split('_')[-1] if denoise_model else None + self.nstr = denoise_model.split("_")[-1] if denoise_model else None - directml_gpu, gpu, proceed= check_directml_gpu_gpu( - 'cellpose_v3', directml_gpu, gpu, ask_install=ask_install_gpu + directml_gpu, gpu, proceed = check_directml_gpu_gpu( + "cellpose_v3", directml_gpu, gpu, ask_install=ask_install_gpu ) - + if not proceed: return - if denoise_model_path and denoise_model: + if denoise_model_path and denoise_model: raise ValueError( "You can only specify one of 'denoise_model_path' or 'denoise_model'." ) if diam_mean == 0.0: diam_mean = 30 - + if diam_mean != 30 and denoise_model: printl( f"[WARNING] It is recommended not to set 'denoise_diameter' for pretrained models!" ) - super().__init__(gpu=gpu, pretrained_model=denoise_model_path, diam_mean=diam_mean, chan2=self.denoise_second_channel_together, - nchan=denoise_nchan, device=device, model_type=denoise_model) - - setup_gpu_direct_ml( - self, - directml_gpu, - gpu, device) + super().__init__( + gpu=gpu, + pretrained_model=denoise_model_path, + diam_mean=diam_mean, + chan2=self.denoise_second_channel_together, + nchan=denoise_nchan, + device=device, + model_type=denoise_model, + ) + + setup_gpu_direct_ml(self, directml_gpu, gpu, device) - def run( - self, - image: np.ndarray, - diameter:float=0.0, - do_3D:bool=True, - invert:bool=False, - normalize:bool=True, - rescale_intensity_low_val_perc:float=0.0, - rescale_intensity_high_val_perc:float=100.0, - # sharpen:float=0, - low_percentile:float=1.0, - high_percentile:float=99.0, - norm3D:bool=False, - rescale:float=1.0, - tile_overlap:float=0.1, - isZstack:NotGUIParam=False, - timelapse:NotGUIParam=False, - init_image:NotGUIParam=True, - bsize:int=224, - normalize_dict:NotGUIParam=None, - ): + self, + image: np.ndarray, + diameter: float = 0.0, + do_3D: bool = True, + invert: bool = False, + normalize: bool = True, + rescale_intensity_low_val_perc: float = 0.0, + rescale_intensity_high_val_perc: float = 100.0, + # sharpen:float=0, + low_percentile: float = 1.0, + high_percentile: float = 99.0, + norm3D: bool = False, + rescale: float = 1.0, + tile_overlap: float = 0.1, + isZstack: NotGUIParam = False, + timelapse: NotGUIParam = False, + init_image: NotGUIParam = True, + bsize: int = 224, + normalize_dict: NotGUIParam = None, + ): """Run cellpose 3.0 denoise model Parameters ---------- image : numpy.ndarray - (Y, X) or (Z, Y, X) or (C, Y, X) (Z, C, Y, X). If timelapse, the left most dim is expected to be time + (Y, X) or (Z, Y, X) or (C, Y, X) (Z, C, Y, X). If timelapse, the left most dim is expected to be time diameter : float, optional Diameter of expected objects. If 0.0, cellpose will not try to estimate it (as opposed to the segmentation model) Will use 30 for everything except nuclei, which will use 17.0. @@ -170,20 +184,20 @@ def run( normalize : bool, optional If True, normalize image using the other parameters. Default is True rescale_intensity_low_val_perc : float, optional - Rescale intensities so that this is the minimum value in the image. + Rescale intensities so that this is the minimum value in the image. Default is 0.0 rescale_intensity_high_val_perc : float, optional - Rescale intensities so that this is the maximum value in the image. + Rescale intensities so that this is the maximum value in the image. Default is 100.0 # sharpen : int, optional - # Sharpen image with high pass filter, recommended to be 1/4-1/8 + # Sharpen image with high pass filter, recommended to be 1/4-1/8 # diameter of cells in pixels. Default is 0. low_percentile : float, optional Lower percentile for normalizing image. Default is 1.0 high_percentile : float, optional Higher percentile for normalizing image. Default is 99.0 norm3D : bool, optional - Compute normalization across entire z-stack rather than + Compute normalization across entire z-stack rather than plane-by-plane in stitching mode. Default is False rescale : float, optional Rescale image intensities to this value. Defaults to 1.0. Unless edge cases, should left to default None. @@ -205,92 +219,94 @@ def run( rescale = None if diameter == 0: - diameter = 30.0 if self.nstr != 'nuclei' else 17.0 - + diameter = 30.0 if self.nstr != "nuclei" else 17.0 + is_rgb = self.is_rgb if normalize_dict is None: normalize_params = _get_normalize_params( image, - normalize=normalize, - rescale_intensity_low_val_perc=rescale_intensity_low_val_perc, - rescale_intensity_high_val_perc=rescale_intensity_high_val_perc, + normalize=normalize, + rescale_intensity_low_val_perc=rescale_intensity_low_val_perc, + rescale_intensity_high_val_perc=rescale_intensity_high_val_perc, # sharpen=sharpen, - low_percentile=low_percentile, + low_percentile=low_percentile, high_percentile=high_percentile, - norm3D=norm3D + norm3D=norm3D, ) else: normalize_params = normalize_dict - - normalize_dict['invert'] = invert + + normalize_dict["invert"] = invert eval_kwargs = { - 'diameter': diameter, - 'normalize': normalize_params, - 'rescale': rescale, - 'tile_overlap': tile_overlap, - 'do_3D': do_3D, - 'bsize': bsize, + "diameter": diameter, + "normalize": normalize_params, + "rescale": rescale, + "tile_overlap": tile_overlap, + "do_3D": do_3D, + "bsize": bsize, } if self.batch_size is not None: - eval_kwargs['batch_size'] = self.batch_size - + eval_kwargs["batch_size"] = self.batch_size + self.isZstack = isZstack self.denoise_slices_separately = not do_3D and isZstack if self.denoise_second_channel_together: - eval_kwargs['channels'] = self.cellpose_rgb_channel - elif self.denoise_second_channel_separately or self.ignore_second_channel or not self.is_rgb: - eval_kwargs['channels'] = self.cellpose_greyscale_channel + eval_kwargs["channels"] = self.cellpose_rgb_channel + elif ( + self.denoise_second_channel_separately + or self.ignore_second_channel + or not self.is_rgb + ): + eval_kwargs["channels"] = self.cellpose_greyscale_channel else: - raise ValueError( - f"Invalid channels configuration for denoising!" - ) + raise ValueError(f"Invalid channels configuration for denoising!") iter_axis_zstack = None if not self.denoise_slices_separately else 0 - iter_axis_zstack = iter_axis_zstack + 1 if (timelapse and iter_axis_zstack is not None) else iter_axis_zstack + iter_axis_zstack = ( + iter_axis_zstack + 1 + if (timelapse and iter_axis_zstack is not None) + else iter_axis_zstack + ) if init_image: image, z_axis, channel_axis = _initialize_image( - image, - isZstack=isZstack, - is_rgb=is_rgb, + image, + isZstack=isZstack, + is_rgb=is_rgb, timelapse=timelapse, iter_axis_zstack=iter_axis_zstack, iter_axis_time=0 if timelapse else None, add_rgb=False, ) denoised_img = np.zeros_like(image) - # add proper iterations, check wtf is going on wuit + # add proper iterations, check wtf is going on wuit # (Z x nchan x Y x X) - is_model_given_3D = (isZstack and not self.denoise_slices_separately) - eval_kwargs['z_axis'] = 0 if is_model_given_3D else None - eval_kwargs['channel_axis'] = 1 if is_model_given_3D else 0 - if self.denoise_second_channel_separately or not is_rgb or self.ignore_second_channel: - eval_kwargs['channel_axis'] = None + is_model_given_3D = isZstack and not self.denoise_slices_separately + eval_kwargs["z_axis"] = 0 if is_model_given_3D else None + eval_kwargs["channel_axis"] = 1 if is_model_given_3D else 0 + if ( + self.denoise_second_channel_separately + or not is_rgb + or self.ignore_second_channel + ): + eval_kwargs["channel_axis"] = None if timelapse: - pbartime = tqdm( - total=len(image), ncols=100, desc='Denoising time-lapse: ' - ) + pbartime = tqdm(total=len(image), ncols=100, desc="Denoising time-lapse: ") else: - pbartime = tqdm( - total=1, ncols=100, desc='Denoising image: ' - ) + pbartime = tqdm(total=1, ncols=100, desc="Denoising image: ") if timelapse: for t, img in enumerate(image): - denoised_img = self._eval_image( - img, eval_kwargs, denoised_img, t=t - ) + denoised_img = self._eval_image(img, eval_kwargs, denoised_img, t=t) pbartime.update(1) else: - denoised_img = self._eval_image( - image, eval_kwargs, denoised_img - ) + denoised_img = self._eval_image(image, eval_kwargs, denoised_img) pbartime.update(1) - + pbartime.close() return denoised_img - + def _eval_image(self, image, eval_kwargs, entire_denoised_img, t=None): if t is not None: denoised_img = entire_denoised_img[t] @@ -298,63 +314,94 @@ def _eval_image(self, image, eval_kwargs, entire_denoised_img, t=None): denoised_img = entire_denoised_img # for NOT timelapse images helper funciton if self.denoise_slices_separately: - if self.denoise_second_channel_separately: # dont need to move channel axis in output since I iterate over it and put it back correctly - for z, c in tqdm(itertools.product(range(len(image)), self.first_second_channel), # only denoise channels which are requested - desc=f'Denoising z-slicesand channels', ncols=100): + if self.denoise_second_channel_separately: # dont need to move channel axis in output since I iterate over it and put it back correctly + for z, c in tqdm( + itertools.product( + range(len(image)), self.first_second_channel + ), # only denoise channels which are requested + desc=f"Denoising z-slicesand channels", + ncols=100, + ): img = image[z][c] img = self._acdc_eval(img, eval_kwargs) - img = np.squeeze(img) # remove channel axis if it was added + img = np.squeeze(img) # remove channel axis if it was added denoised_img[z, c] = img - elif self.ignore_second_channel: # dont need to move channel axis in output since I iterate over it and put it back correctly - for z, img_z in tqdm(enumerate(image), desc=f'Denoising z-slices: ', ncols=100): + elif self.ignore_second_channel: # dont need to move channel axis in output since I iterate over it and put it back correctly + for z, img_z in tqdm( + enumerate(image), desc=f"Denoising z-slices: ", ncols=100 + ): img = img_z[self.first_second_channel[0]] img = self._acdc_eval(img, eval_kwargs) - img = np.squeeze(img) # remove channel axis if it was added + img = np.squeeze(img) # remove channel axis if it was added denoised_img[z, self.first_second_channel[0]] = img # copy second channel as it is - denoised_img[:, self.first_second_channel[1]] = image[:, self.first_second_channel[1]] - else: # model either gets single gray or RGB image slices, channels was set correctly before - for z, img_z in tqdm(enumerate(image), desc=f'Denoising z-slices: ', ncols=100): - img = self._acdc_eval(img_z, eval_kwargs) # oputputs rgb last... + denoised_img[:, self.first_second_channel[1]] = image[ + :, self.first_second_channel[1] + ] + else: # model either gets single gray or RGB image slices, channels was set correctly before + for z, img_z in tqdm( + enumerate(image), desc=f"Denoising z-slices: ", ncols=100 + ): + img = self._acdc_eval(img_z, eval_kwargs) # oputputs rgb last... if not self.is_rgb: - img = np.squeeze(img) # remove channel axis if it was added + img = np.squeeze(img) # remove channel axis if it was added else: - img = np.moveaxis(img, -1, 0) # move channel axis to the front - img = self._add_rgb_channels(img, isZstack=False) # add rgb channels if needed + img = np.moveaxis(img, -1, 0) # move channel axis to the front + img = self._add_rgb_channels( + img, isZstack=False + ) # add rgb channels if needed denoised_img[z] = img else: - if self.denoise_second_channel_separately: # dont need to move channel axis in output since I iterate over it and put it back correctly + if self.denoise_second_channel_separately: # dont need to move channel axis in output since I iterate over it and put it back correctly if self.isZstack: - image = np.moveaxis(image, 1, 0) # move channel axis to the front + image = np.moveaxis(image, 1, 0) # move channel axis to the front denoised_img = np.moveaxis(denoised_img, 1, 0) - for c in tqdm(self.first_second_channel, desc=f'Denoising channels: ', ncols=100): + for c in tqdm( + self.first_second_channel, desc=f"Denoising channels: ", ncols=100 + ): img = self._acdc_eval(image[c], eval_kwargs) - denoised_img[c] = np.squeeze(img) # remove channel axis if it was added + denoised_img[c] = np.squeeze( + img + ) # remove channel axis if it was added if self.isZstack: denoised_img = np.moveaxis(denoised_img, 0, 1) - elif self.ignore_second_channel: # dont need to move channel axis in output since I iterate over it and put it back correctly + elif self.ignore_second_channel: # dont need to move channel axis in output since I iterate over it and put it back correctly if self.isZstack: - image = np.moveaxis(image, 1, 0) # move channel axis to the front + image = np.moveaxis(image, 1, 0) # move channel axis to the front denoised_img = np.moveaxis(denoised_img, 1, 0) img = self._acdc_eval(image[self.first_second_channel[0]], eval_kwargs) - img = np.squeeze(img) # remove channel axis if it was added - denoised_img[self.first_second_channel[0]] = img # remove channel axis if it was added + img = np.squeeze(img) # remove channel axis if it was added + denoised_img[self.first_second_channel[0]] = ( + img # remove channel axis if it was added + ) # copy second channel as it is - denoised_img[self.first_second_channel[1]] = image[self.first_second_channel[1]] + denoised_img[self.first_second_channel[1]] = image[ + self.first_second_channel[1] + ] if self.isZstack: - denoised_img = np.moveaxis(denoised_img, 0, 1) # move channel axis to the back + denoised_img = np.moveaxis( + denoised_img, 0, 1 + ) # move channel axis to the back else: - denoised_img = self._acdc_eval(image, eval_kwargs) # pass entire iamge, with or without channels. Channels param set before + denoised_img = self._acdc_eval( + image, eval_kwargs + ) # pass entire iamge, with or without channels. Channels param set before if self.is_rgb: if self.isZstack: - denoised_img = np.moveaxis(denoised_img, -1, 1) # move channel axis to the front after z + denoised_img = np.moveaxis( + denoised_img, -1, 1 + ) # move channel axis to the front after z else: - denoised_img = np.moveaxis(denoised_img, -1, 0) # move channel axis to the front + denoised_img = np.moveaxis( + denoised_img, -1, 0 + ) # move channel axis to the front else: - denoised_img = np.squeeze(denoised_img) # remove channel axis if it was added - + denoised_img = np.squeeze( + denoised_img + ) # remove channel axis if it was added + # make sure that no channel is lost and if true, add it back # should not be needed, as entire_denoised_img has right shape denoised_img = self._add_rgb_channels(denoised_img) @@ -365,7 +412,7 @@ def _eval_image(self, image, eval_kwargs, entire_denoised_img, t=None): return entire_denoised_img - def _add_rgb_channels(self, denoised_img:np.ndarray, isZstack=None): + def _add_rgb_channels(self, denoised_img: np.ndarray, isZstack=None): if not self.is_rgb: return denoised_img @@ -376,25 +423,34 @@ def _add_rgb_channels(self, denoised_img:np.ndarray, isZstack=None): if isZstack: channels = denoised_image_shape[1] if channels < 3: - shape_to_concat = (denoised_image_shape[0], 3 - channels, denoised_image_shape[2], denoised_image_shape[3]) + shape_to_concat = ( + denoised_image_shape[0], + 3 - channels, + denoised_image_shape[2], + denoised_image_shape[3], + ) # put it at position 0 denoised_img = np.concatenate( [denoised_img, np.zeros(shape_to_concat, dtype=denoised_img.dtype)], - axis=1 + axis=1, ) else: channels = denoised_image_shape[0] if channels < 3: - shape_to_concat = (3 - channels, denoised_image_shape[1], denoised_image_shape[2]) + shape_to_concat = ( + 3 - channels, + denoised_image_shape[1], + denoised_image_shape[2], + ) # put it at position 0 denoised_img = np.concatenate( [denoised_img, np.zeros(shape_to_concat, dtype=denoised_img.dtype)], - axis=0 + axis=0, ) return denoised_img def _acdc_eval(self, image, eval_kwargs): - if not self.printed_model_params: + if not self.printed_model_params: if isinstance(image, list): shape = image[0].shape shape = f"{len(image)} images of shape {shape}" @@ -404,12 +460,12 @@ def _acdc_eval(self, image, eval_kwargs): print(f"Running denoise on image shape: {shape}, kwargs: {eval_kwargs}") if self.denoise_second_channel_together: for i, subarr in enumerate(np.moveaxis(image, -3, 0)): - print(f"Channel {i+1} min: {subarr.min()}, max: {subarr.max()}") + print(f"Channel {i + 1} min: {subarr.min()}, max: {subarr.max()}") else: print(f"Image min: {image.min()}, max: {image.max()}") self.printed_model_params = True return self.eval(image, **eval_kwargs) - - + + def url_help(): - return 'https://www.biorxiv.org/content/10.1101/2024.02.10.579780v1' + return "https://www.biorxiv.org/content/10.1101/2024.02.10.579780v1" diff --git a/cellacdc/segmenters/cellpose_v3/acdcSegment.py b/cellacdc/segmenters/cellpose_v3/acdcSegment.py index 4881782dc..6578ddddb 100644 --- a/cellacdc/segmenters/cellpose_v3/acdcSegment.py +++ b/cellacdc/segmenters/cellpose_v3/acdcSegment.py @@ -2,8 +2,15 @@ from cellacdc import myutils, printl from cellacdc.segmenters._cellpose_base.acdcSegment import Model as CellposeBaseModel -from cellacdc.segmenters._cellpose_base.acdcSegment import (BackboneOptions, GPUDirectMLGPUCPU, cpu_gpu_directml_gpu, - check_directml_gpu_gpu, setup_gpu_direct_ml, _get_normalize_params, DealWithSecondChannelOptions) +from cellacdc.segmenters._cellpose_base.acdcSegment import ( + BackboneOptions, + GPUDirectMLGPUCPU, + cpu_gpu_directml_gpu, + check_directml_gpu_gpu, + setup_gpu_direct_ml, + _get_normalize_params, + DealWithSecondChannelOptions, +) import torch from . import AvailableModelsv3, AvailableModelsv3Denoise @@ -13,27 +20,28 @@ import numpy as np + class Model(CellposeBaseModel): def __new__(cls, *args, **kwargs): myutils.check_install_cellpose(3) return super().__new__(cls) def __init__( - self, - model_type:AvailableModelsv3='cyto3', - model_path: os.PathLike='', - device_type: GPUDirectMLGPUCPU='cpu', - device: torch.device | int | None = None, - batch_size:int=8, - denoise_before_segmentation:bool=False, - denoise_model: AvailableModelsv3Denoise='denoise_cyto3', - denoise_second_channel: DealWithSecondChannelOptions = 'together', - denoise_model_path: os.PathLike='', - denoise_diameter:float=0.0, - denoise_nchan: int = 1, - backbone: BackboneOptions='default', - is_rgb: NotGUIParam = False, # whether the input image will be rgb - ): + self, + model_type: AvailableModelsv3 = "cyto3", + model_path: os.PathLike = "", + device_type: GPUDirectMLGPUCPU = "cpu", + device: torch.device | int | None = None, + batch_size: int = 8, + denoise_before_segmentation: bool = False, + denoise_model: AvailableModelsv3Denoise = "denoise_cyto3", + denoise_second_channel: DealWithSecondChannelOptions = "together", + denoise_model_path: os.PathLike = "", + denoise_diameter: float = 0.0, + denoise_nchan: int = 1, + backbone: BackboneOptions = "default", + is_rgb: NotGUIParam = False, # whether the input image will be rgb + ): """Initialize cellpose 3 model Parameters @@ -53,8 +61,8 @@ def __init__( - 'directml': Use DirectML for running the model on GPU. device : torch.device or int or None If not None, this is the device used for running the model - (torch.device('cuda') or torch.device('cpu')). - It overrides `gpu`, recommended if you want to use a specific GPU + (torch.device('cuda') or torch.device('cpu')). + It overrides `gpu`, recommended if you want to use a specific GPU (e.g. torch.device('cuda:1'). Default is None batch_size : int, optional Batch size for running the model on GPU. Reduce to decrease memory usage, but it will slow down the processing. @@ -87,7 +95,7 @@ def __init__( denoise_diameter : float, optional Mean diameter of objects in the image for denoising (during training?). If left at 0, will pass 30, the cellpose default value. - It is not clear what exactly this parameter does from cellpose documentation, + It is not clear what exactly this parameter does from cellpose documentation, please don't touch it if you dont know too! denoise_nchan : int, optional Number of channels in the denoised image. Default is 1. @@ -104,29 +112,34 @@ def __init__( ) model_type, model_path, device, denoise_model, denoise_model_path = out self.check_model_path_model_type( - model_type=model_type, - model_path=model_path, + model_type=model_type, + model_path=model_path, ) - directml_gpu, gpu = cpu_gpu_directml_gpu( + directml_gpu, gpu = cpu_gpu_directml_gpu( input_string=device_type, ) directml_gpu, gpu, proceed = check_directml_gpu_gpu( - 'cellpose_v3', directml_gpu=directml_gpu, gpu=gpu, + "cellpose_v3", + directml_gpu=directml_gpu, + gpu=gpu, ) if not proceed: return if denoise_before_segmentation and denoise_model: - denoise_model_type = denoise_model.split('_')[-1] if denoise_model else None + denoise_model_type = denoise_model.split("_")[-1] if denoise_model else None if denoise_model_type != model_type: - printl(f'[WARNING] denoise model type {denoise_model_type} does not match ') - - print(f'Initializing Cellpose v3...') + printl( + f"[WARNING] denoise model type {denoise_model_type} does not match " + ) + + print(f"Initializing Cellpose v3...") import cellpose + if model_type: try: self.model = cellpose.models.Cellpose( @@ -135,17 +148,17 @@ def __init__( model_type=model_type, backbone=backbone, ) - self._sizemodelnotfound = False + self._sizemodelnotfound = False except FileNotFoundError: - printl(f'Size model for {model_type} not found.') + printl(f"Size model for {model_type} not found.") self._sizemodelnotfound = True self.model = cellpose.models.CellposeModel( gpu=gpu, device=device, model_type=model_type, backbone=backbone, - ) + ) elif model_path is not None: self._sizemodelnotfound = True self.model = cellpose.models.CellposeModel( @@ -158,133 +171,131 @@ def __init__( self.denoiseModel = None if denoise_before_segmentation: from cellacdc.segmenters.cellpose_v3 import _denoise + self.denoiseModel = _denoise.CellposeDenoiseModel( device_type=device_type, - device=device, denoise_model=denoise_model, + device=device, + denoise_model=denoise_model, denoise_model_path=denoise_model_path, diam_mean=denoise_diameter, - deal_with_second_channel=denoise_second_channel, + deal_with_second_channel=denoise_second_channel, denoise_nchan=denoise_nchan, - batch_size=batch_size, + batch_size=batch_size, is_rgb=self.is_rgb, ask_install_gpu=False, # don't ask to install cellpose if not installed ) - - setup_gpu_direct_ml( - self, - directml_gpu, - gpu, device) - + + setup_gpu_direct_ml(self, directml_gpu, gpu, device) + self.init_successful = True def _get_eval_kwargs_v3( - self, - eval_kwargs: dict, - **kwargs: dict, + self, + eval_kwargs: dict, + **kwargs: dict, ): eval_kwargs_3 = { - 'cellprob_threshold': kwargs['cellprob_threshold'], - 'min_size': kwargs['min_size'], - 'resample': kwargs['resample'], - 'max_size_fraction': kwargs['max_size_fraction'], - 'flow3D_smooth': kwargs['flow3D_smooth'], - 'tile_overlap': kwargs['tile_overlap'], - 'invert': kwargs['invert'], - + "cellprob_threshold": kwargs["cellprob_threshold"], + "min_size": kwargs["min_size"], + "resample": kwargs["resample"], + "max_size_fraction": kwargs["max_size_fraction"], + "flow3D_smooth": kwargs["flow3D_smooth"], + "tile_overlap": kwargs["tile_overlap"], + "invert": kwargs["invert"], } eval_kwargs.update(eval_kwargs_3) return eval_kwargs - def segment( # 2D, 2D x stacks. 2D over time is in segment3DT, 4D is not supported - self, - image, - diameter:float=0.0, - flow_threshold:float=0.4, - cellprob_threshold:float=0.0, - resample:bool=True, - min_size:int=15, - max_size_fraction:float=0.4, - segment_3D_volume:bool=False, - stitch_threshold:float=0.0, - flow3D_smooth:float=0, - anisotropy:float=0.0, - tile_overlap:float=0.1, - invert:bool=False, - normalize:bool=True, - rescale_intensity_low_val_perc:float=0.0, - rescale_intensity_high_val_perc:float=100.0, - # sharpen:int=0, - low_percentile:float=1.0, - high_percentile:float=99.0, - norm3D:bool=False, - tile_norm_blocksize: int=0, - denoise_rescale:float=1.0, - init_imgs:NotGUIParam=True, - bsize:int=224, - ): + def segment( # 2D, 2D x stacks. 2D over time is in segment3DT, 4D is not supported + self, + image, + diameter: float = 0.0, + flow_threshold: float = 0.4, + cellprob_threshold: float = 0.0, + resample: bool = True, + min_size: int = 15, + max_size_fraction: float = 0.4, + segment_3D_volume: bool = False, + stitch_threshold: float = 0.0, + flow3D_smooth: float = 0, + anisotropy: float = 0.0, + tile_overlap: float = 0.1, + invert: bool = False, + normalize: bool = True, + rescale_intensity_low_val_perc: float = 0.0, + rescale_intensity_high_val_perc: float = 100.0, + # sharpen:int=0, + low_percentile: float = 1.0, + high_percentile: float = 99.0, + norm3D: bool = False, + tile_norm_blocksize: int = 0, + denoise_rescale: float = 1.0, + init_imgs: NotGUIParam = True, + bsize: int = 224, + ): """Run cellpose 3.0 denoising + segmentation model Parameters ---------- image : (Y, X) or (Z, Y, X) numpy.ndarray - 2D or 3D image (z-stack). + 2D or 3D image (z-stack). diameter : float, optional - Diameter of expected objects. If 0.0, it uses 30.0 for "one-click" + Diameter of expected objects. If 0.0, it uses 30.0 for "one-click" and 17.0 for "nuclei". Default is 0.0 flow_threshold : float, optional - Flow error threshold (all cells with errors below threshold are + Flow error threshold (all cells with errors below threshold are kept) (not used for 3D). Default is 0.4 cellprob_threshold : float, optional - All pixels with value above threshold will be part of an object. + All pixels with value above threshold will be part of an object. Decrease this value to find more and larger masks. Default is 0.0 resample : bool, optional - Run dynamics at original image size (will be slower but create + Run dynamics at original image size (will be slower but create more accurate boundaries). Default is True min_size : int, optional - Minimum number of pixels per mask, you can turn off this filter + Minimum number of pixels per mask, you can turn off this filter with `min_size = -1`. Default is 15 max_size_fraction : float, optional Masks larger than this fraction of total image size are removed. Default is 0.4. segment_3D_volume : bool, optional - If True and input `image` is a 3D z-stack the entire z-stack - is passed to cellpose model. If False, Cell-ACDC will force one - z-slice at the time. Best results with cellpose and 3D data are - obtained by passing the entire z-stack, but with a - `stitch_threshold` greater than 0 (e.g., 0.4). This way cellpose - will internally segment slice-by-slice and it will merge the - resulting z-slice masks belonging to the same object. + If True and input `image` is a 3D z-stack the entire z-stack + is passed to cellpose model. If False, Cell-ACDC will force one + z-slice at the time. Best results with cellpose and 3D data are + obtained by passing the entire z-stack, but with a + `stitch_threshold` greater than 0 (e.g., 0.4). This way cellpose + will internally segment slice-by-slice and it will merge the + resulting z-slice masks belonging to the same object. Default is False stitch_threshold : float, optional - If `stitch_threshold` is greater than 0.0 and `segment_3D_volume` - is True, masks are stitched in 3D to return volume segmentation. + If `stitch_threshold` is greater than 0.0 and `segment_3D_volume` + is True, masks are stitched in 3D to return volume segmentation. Default is 0.0 anisotropy : float, optional - For 3D segmentation, optional rescaling factor (e.g. set to 2.0 if + For 3D segmentation, optional rescaling factor (e.g. set to 2.0 if Z is sampled half as dense as X or Y). Default is 0.0 tile_overlap : float, optional Fraction of overlap of tiles when computing flows. Defaults to 0.1. invert : bool, optional Invert image pixel intensity before running network. Default is False. normalize : bool, optional - If True, normalize image using the other parameters. + If True, normalize image using the other parameters. Default is True rescale_intensity_low_val_perc : float, optional - Rescale intensities so that this is the minimum value in the image. + Rescale intensities so that this is the minimum value in the image. Default is 0.0 rescale_intensity_high_val_perc : float, optional - Rescale intensities so that this is the maximum value in the image. + Rescale intensities so that this is the maximum value in the image. Default is 100.0 # sharpen : int, optional - # Sharpen image with high pass filter, recommended to be 1/4-1/8 + # Sharpen image with high pass filter, recommended to be 1/4-1/8 # diameter of cells in pixels. Default is 0. low_percentile : float, optional Lower percentile for normalizing image. Default is 1.0 high_percentile : float, optional Higher percentile for normalizing image. Default is 99.0 norm3D : bool, optional - Compute normalization across entire z-stack rather than + Compute normalization across entire z-stack rather than plane-by-plane in stitching mode. Default is False tile_norm_blocksize : int, optional Size of the tiles for normalization. Default is 0, which means no tiling. @@ -294,7 +305,7 @@ def segment( # 2D, 2D x stacks. 2D over time is in segment3DT, 4D is not support Default is 224. Don't change it unless you know what you are doing, please! - """ + """ self.timelapse = False image @@ -334,30 +345,35 @@ def segment( # 2D, 2D x stacks. 2D over time is in segment3DT, 4D is not support high_percentile=high_percentile, norm3D=norm3D, normalize=normalize, - tile_norm_blocksize=tile_norm_blocksize - ) + tile_norm_blocksize=tile_norm_blocksize, + ) if self.denoiseModel is not None: self.isZstack, self.is_rgb = self.get_zStack_rgb( - image,) + image, + ) if init_imgs: if not segment_3D_volume and self.isZstack: - image, z_axis, channel_axis = _initialize_image(image, self.is_rgb, - iter_axis_zstack=0, - isZstack=self.isZstack, - ) - self.channel_axis = channel_axis # changing the axis for cellpose is handled in the eval loop + image, z_axis, channel_axis = _initialize_image( + image, + self.is_rgb, + iter_axis_zstack=0, + isZstack=self.isZstack, + ) + self.channel_axis = channel_axis # changing the axis for cellpose is handled in the eval loop self.z_axis = z_axis else: - image, z_axis, channel_axis = _initialize_image(image, self.is_rgb, - isZstack=self.isZstack, - ) + image, z_axis, channel_axis = _initialize_image( + image, + self.is_rgb, + isZstack=self.isZstack, + ) self.z_axis = z_axis self.channel_axis = channel_axis image = self.denoiseModel.run( image, diameter=diameter, - do_3D=eval_kwargs['do_3D'], + do_3D=eval_kwargs["do_3D"], normalize_dict=norm_kwargs, tile_overlap=tile_overlap, timelapse=False, @@ -368,8 +384,9 @@ def segment( # 2D, 2D x stacks. 2D over time is in segment3DT, 4D is not support invert=invert, ) - eval_kwargs['normalize'] = norm_kwargs if self.denoiseModel is None else True # if denoise model was used, just normalise the image with default parameters - + eval_kwargs["normalize"] = ( + norm_kwargs if self.denoiseModel is None else True + ) # if denoise model was used, just normalise the image with default parameters self.img_shape = image.shape self.img_ndim = len(self.img_shape) @@ -379,77 +396,85 @@ def segment( # 2D, 2D x stacks. 2D over time is in segment3DT, 4D is not support image, segment_3D_volume=segment_3D_volume, init_imgs=init_imgs_eval_loop, - **eval_kwargs + **eval_kwargs, ) self.img_shape = None self.img_ndim = None return labels - - def segment3DT(self, video_data, signals=None, init_imgs=True, **kwargs): # just 2D over time + + def segment3DT( + self, video_data, signals=None, init_imgs=True, **kwargs + ): # just 2D over time self.timelapse = True eval_kwargs, self.isZstack = self.get_eval_kwargs(video_data[0], **kwargs) eval_kwargs = self._get_eval_kwargs_v3( eval_kwargs=eval_kwargs, - cellprob_threshold=kwargs['cellprob_threshold'], - min_size=kwargs['min_size'], - resample=kwargs['resample'], - max_size_fraction=kwargs['max_size_fraction'], - flow3D_smooth=kwargs['flow3D_smooth'], - tile_overlap=kwargs['tile_overlap'], - invert=kwargs['invert'], + cellprob_threshold=kwargs["cellprob_threshold"], + min_size=kwargs["min_size"], + resample=kwargs["resample"], + max_size_fraction=kwargs["max_size_fraction"], + flow3D_smooth=kwargs["flow3D_smooth"], + tile_overlap=kwargs["tile_overlap"], + invert=kwargs["invert"], ) norm_kwargs = _get_normalize_params( image=video_data, - normalize=kwargs['normalize'], - rescale_intensity_low_val_perc=kwargs['rescale_intensity_low_val_perc'], - rescale_intensity_high_val_perc=kwargs['rescale_intensity_high_val_perc'], + normalize=kwargs["normalize"], + rescale_intensity_low_val_perc=kwargs["rescale_intensity_low_val_perc"], + rescale_intensity_high_val_perc=kwargs["rescale_intensity_high_val_perc"], # sharpen=kwargs['sharpen'], - low_percentile=kwargs['low_percentile'], - high_percentile=kwargs['high_percentile'], - norm3D=kwargs['norm3D'], + low_percentile=kwargs["low_percentile"], + high_percentile=kwargs["high_percentile"], + norm3D=kwargs["norm3D"], ) if self.denoiseModel is not None: if init_imgs: - if not kwargs['segment_3D_volume'] and self.isZstack: - video_data, z_axis, channel_axis = _initialize_image(video_data, self.is_rgb, - iter_axis_time=0, - iter_axis_zstack=1, - timelapse=True, - isZstack=self.isZstack, - ) - self.z_axis = z_axis # changing of axis is handled in the eval loop - self.channel_axis = channel_axis + if not kwargs["segment_3D_volume"] and self.isZstack: + video_data, z_axis, channel_axis = _initialize_image( + video_data, + self.is_rgb, + iter_axis_time=0, + iter_axis_zstack=1, + timelapse=True, + isZstack=self.isZstack, + ) + self.z_axis = z_axis # changing of axis is handled in the eval loop + self.channel_axis = channel_axis else: - video_data, z_axis, channel_axis = _initialize_image(video_data, self.is_rgb, - iter_axis_time=0, - timelapse=True, - isZstack=self.isZstack, - ) - self.z_axis = z_axis # changing of axis is handled in the eval loop + video_data, z_axis, channel_axis = _initialize_image( + video_data, + self.is_rgb, + iter_axis_time=0, + timelapse=True, + isZstack=self.isZstack, + ) + self.z_axis = z_axis # changing of axis is handled in the eval loop self.channel_axis = channel_axis - + video_data = self.denoiseModel.run( video_data, - diameter=eval_kwargs['diameter'], - do_3D=eval_kwargs['do_3D'], + diameter=eval_kwargs["diameter"], + do_3D=eval_kwargs["do_3D"], normalize_dict=norm_kwargs, - tile_overlap=kwargs['tile_overlap'], + tile_overlap=kwargs["tile_overlap"], timelapse=True, - bsize=kwargs['bsize'], + bsize=kwargs["bsize"], isZstack=self.isZstack, init_image=False, # Denoise model does not need init_imgs, already done - rescale=kwargs['denoise_rescale'], - ) - + rescale=kwargs["denoise_rescale"], + ) + self.img_shape = video_data[0].shape self.img_ndim = len(self.img_shape) - eval_kwargs['normalize'] = norm_kwargs if self.denoiseModel is None else True # if denoise model was used, just normalise the image with default parameters + eval_kwargs["normalize"] = ( + norm_kwargs if self.denoiseModel is None else True + ) # if denoise model was used, just normalise the image with default parameters init_imgs_segment3DT_eval = init_imgs if self.denoiseModel is None else False labels = self.segment3DT_eval( @@ -460,5 +485,6 @@ def segment3DT(self, video_data, signals=None, init_imgs=True, **kwargs): # just self.img_ndim = None return labels + def url_help(): - return 'https://cellpose.readthedocs.io/en/latest/api.html' + return "https://cellpose.readthedocs.io/en/latest/api.html" diff --git a/cellacdc/segmenters/cellpose_v4/__init__.py b/cellacdc/segmenters/cellpose_v4/__init__.py index 4a53003ca..b0904b5bf 100644 --- a/cellacdc/segmenters/cellpose_v4/__init__.py +++ b/cellacdc/segmenters/cellpose_v4/__init__.py @@ -1,9 +1,12 @@ import cellacdc.myutils as myutils + myutils.check_install_cellpose(4) + class AvailableModelsv4: from cellpose.models import MODEL_NAMES + values = MODEL_NAMES - - is_exclusive_with = ['model_path'] - default_exclusive = 'Using custom model' \ No newline at end of file + + is_exclusive_with = ["model_path"] + default_exclusive = "Using custom model" diff --git a/cellacdc/segmenters/cellpose_v4/acdcSegment.py b/cellacdc/segmenters/cellpose_v4/acdcSegment.py index 191afb6ed..7e8a6e607 100644 --- a/cellacdc/segmenters/cellpose_v4/acdcSegment.py +++ b/cellacdc/segmenters/cellpose_v4/acdcSegment.py @@ -1,28 +1,30 @@ import os from cellacdc import myutils, printl import torch -from cellacdc.segmenters._cellpose_base.acdcSegment import (Model as CellposeBaseModel, - GPUDirectMLGPUCPU, - cpu_gpu_directml_gpu, - check_directml_gpu_gpu, - setup_gpu_direct_ml, - _get_normalize_params) +from cellacdc.segmenters._cellpose_base.acdcSegment import ( + Model as CellposeBaseModel, + GPUDirectMLGPUCPU, + cpu_gpu_directml_gpu, + check_directml_gpu_gpu, + setup_gpu_direct_ml, + _get_normalize_params, +) from . import AvailableModelsv4 + class Model(CellposeBaseModel): def __new__(cls, *args, **kwargs): myutils.check_install_cellpose(4) return super().__new__(cls) - + def __init__( - self, - model_type: AvailableModelsv4='cpsam', - model_path: os.PathLike='', - device_type: GPUDirectMLGPUCPU='cpu', - device:torch.device|int='None', - batch_size:int=8, - - ): + self, + model_type: AvailableModelsv4 = "cpsam", + model_path: os.PathLike = "", + device_type: GPUDirectMLGPUCPU = "cpu", + device: torch.device | int = "None", + batch_size: int = 8, + ): """Initialize Cellpose 4 (Cellpose-SAM) model Parameters @@ -42,114 +44,113 @@ def __init__( - 'directml': Use DirectML for running the model on GPU. device : torch.device or int or None If not None, this is the device used for running the model - (torch.device('cuda') or torch.device('cpu')). - It overrides `gpu`, recommended if you want to use a specific GPU + (torch.device('cuda') or torch.device('cpu')). + It overrides `gpu`, recommended if you want to use a specific GPU (e.g. torch.device('cuda:1'). Default is None batch_size : int, optional Batch size for running the model on GPU. Reduce to decrease memory usage, but it will slow down the processing. Default is 8. - """ + """ self.init_successful = False self.initConstants() self.batch_size = batch_size - model_type, model_path, device = myutils.translateStrNone(model_type, model_path, device) + model_type, model_path, device = myutils.translateStrNone( + model_type, model_path, device + ) self.check_model_path_model_type( - model_type=model_type, - model_path=model_path, + model_type=model_type, + model_path=model_path, ) - directml_gpu, gpu = cpu_gpu_directml_gpu( + directml_gpu, gpu = cpu_gpu_directml_gpu( input_string=device_type, ) directml_gpu, gpu, proceed = check_directml_gpu_gpu( - 'cellpose_v4', directml_gpu=directml_gpu, gpu=gpu, + "cellpose_v4", + directml_gpu=directml_gpu, + gpu=gpu, ) if not proceed: return model_path = model_path or model_type - + major_version = myutils.get_cellpose_major_version() - print(f'Initializing Cellpose v{major_version}...') + print(f"Initializing Cellpose v{major_version}...") from cellpose import models + self.model = models.CellposeModel( gpu=gpu, device=device, pretrained_model=model_path, - ) - - setup_gpu_direct_ml( - self, - directml_gpu, - gpu, device) - + ) + + setup_gpu_direct_ml(self, directml_gpu, gpu, device) + self.init_successful = True - + def _get_eval_kwargs_v4( - self, - max_size_fraction:float=0.4, - invert:bool=False, - flow3D_smooth:int=0, - niter:int=0, - augment:bool=False, - tile_overlap:float=0.1, - bsize:int=224, - # interp:bool=True, - min_size:int=15, - cellprob_threshold:float=0.0, - prev_kwargs:dict=None, - **kwargs - ): + self, + max_size_fraction: float = 0.4, + invert: bool = False, + flow3D_smooth: int = 0, + niter: int = 0, + augment: bool = False, + tile_overlap: float = 0.1, + bsize: int = 224, + # interp:bool=True, + min_size: int = 15, + cellprob_threshold: float = 0.0, + prev_kwargs: dict = None, + **kwargs, + ): if niter == 0: niter = None prev_kwargs = self._filter_kwargs(**prev_kwargs) - + additional_kwargs = { - 'max_size_fraction': max_size_fraction, - 'invert': invert, - 'flow3D_smooth': flow3D_smooth, - 'niter': niter, - 'augment': augment, - 'tile_overlap': tile_overlap, - 'bsize': bsize, - 'min_size': min_size, - 'cellprob_threshold': cellprob_threshold, + "max_size_fraction": max_size_fraction, + "invert": invert, + "flow3D_smooth": flow3D_smooth, + "niter": niter, + "augment": augment, + "tile_overlap": tile_overlap, + "bsize": bsize, + "min_size": min_size, + "cellprob_threshold": cellprob_threshold, # 'interp': interp } prev_kwargs.update(additional_kwargs) - + return prev_kwargs - def _filter_kwargs( - self, - **kwargs - ): + def _filter_kwargs(self, **kwargs): kwarg_key_list = [ - 'channels', - 'diameter', - 'flow_threshold', - 'stitch_threshold', - 'do_3D', - 'anisotropy', + "channels", + "diameter", + "flow_threshold", + "stitch_threshold", + "do_3D", + "anisotropy", ] for key in list(kwargs.keys()): if key not in kwarg_key_list: del kwargs[key] - + for key in kwarg_key_list: if key not in kwargs: raise KeyError( f"Key '{key}' not found in kwargs. " "Please provide all required keys." ) - + return kwargs # def _filter_kwargs( @@ -169,101 +170,101 @@ def _filter_kwargs( # for key in list(kwargs.keys()): # if key not in kwarg_key_list: # del kwargs[key] - + # for key in kwarg_key_list: # if key not in kwargs: # raise KeyError( # f"Key '{key}' not found in kwargs. " # "Please provide all required keys." # ) - + # return kwargs - + def segment( - self, image, - diameter:float=0.0, - flow_threshold:float=0.4, - cellprob_threshold:float=0.0, - min_size:int=15, - max_size_fraction:float=0.4, - invert:bool=False, - segment_3D_volume:bool=False, - stitch_threshold:float=0.0, - flow3D_smooth:float=0, - anisotropy:float=0.0, - tile_overlap:float=0.1, - normalize:bool=True, - rescale_intensity_low_val_perc:float=0.0, - rescale_intensity_high_val_perc:float=100.0, - # sharpen:int=0, - low_percentile:float=1.0, - high_percentile:float=99.0, - norm3D:bool=False, - tile_norm_blocksize: int=0, - niter:int=0, - augment:bool=False, - bsize:int=256, - # interp:bool=True, - - ): + self, + image, + diameter: float = 0.0, + flow_threshold: float = 0.4, + cellprob_threshold: float = 0.0, + min_size: int = 15, + max_size_fraction: float = 0.4, + invert: bool = False, + segment_3D_volume: bool = False, + stitch_threshold: float = 0.0, + flow3D_smooth: float = 0, + anisotropy: float = 0.0, + tile_overlap: float = 0.1, + normalize: bool = True, + rescale_intensity_low_val_perc: float = 0.0, + rescale_intensity_high_val_perc: float = 100.0, + # sharpen:int=0, + low_percentile: float = 1.0, + high_percentile: float = 99.0, + norm3D: bool = False, + tile_norm_blocksize: int = 0, + niter: int = 0, + augment: bool = False, + bsize: int = 256, + # interp:bool=True, + ): """Segment an image using Cellpose (see details in v2) Parameters ---------- image : (Y, X) or (Z, Y, X) numpy.ndarray - 2D or 3D image (z-stack). + 2D or 3D image (z-stack). diameter : float, optional - Diameter of expected objects. If 0.0, it uses 30.0 for "one-click" + Diameter of expected objects. If 0.0, it uses 30.0 for "one-click" and 17.0 for "nuclei". Default is 0.0 flow_threshold : float, optional - Flow error threshold (all cells with errors below threshold are + Flow error threshold (all cells with errors below threshold are kept) (not used for 3D). Default is 0.4 cellprob_threshold : float, optional - All pixels with value above threshold will be part of an object. + All pixels with value above threshold will be part of an object. Decrease this value to find more and larger masks. Default is 0.0 min_size : int, optional - Minimum number of pixels per mask, you can turn off this filter + Minimum number of pixels per mask, you can turn off this filter with `min_size = -1`. Default is 15 max_size_fraction : float, optional Masks larger than this fraction of total image size are removed. Default is 0.4. invert : bool, optional Invert image pixel intensity before running network. Default is False. segment_3D_volume : bool, optional - If True and input `image` is a 3D z-stack the entire z-stack - is passed to cellpose model. If False, Cell-ACDC will force one - z-slice at the time. Best results with cellpose and 3D data are - obtained by passing the entire z-stack, but with a - `stitch_threshold` greater than 0 (e.g., 0.4). This way cellpose - will internally segment slice-by-slice and it will merge the - resulting z-slice masks belonging to the same object. + If True and input `image` is a 3D z-stack the entire z-stack + is passed to cellpose model. If False, Cell-ACDC will force one + z-slice at the time. Best results with cellpose and 3D data are + obtained by passing the entire z-stack, but with a + `stitch_threshold` greater than 0 (e.g., 0.4). This way cellpose + will internally segment slice-by-slice and it will merge the + resulting z-slice masks belonging to the same object. Default is False stitch_threshold : float, optional - If `stitch_threshold` is greater than 0.0 and `segment_3D_volume` - is True, masks are stitched in 3D to return volume segmentation. + If `stitch_threshold` is greater than 0.0 and `segment_3D_volume` + is True, masks are stitched in 3D to return volume segmentation. Default is 0.0 anisotropy : float, optional - For 3D segmentation, optional rescaling factor (e.g. set to 2.0 if + For 3D segmentation, optional rescaling factor (e.g. set to 2.0 if Z is sampled half as dense as X or Y). Default is 0.0 tile_overlap : float, optional Fraction of overlap of tiles when computing flows. Defaults to 0.1. normalize : bool, optional - If True, normalize image using the other parameters. + If True, normalize image using the other parameters. Default is True rescale_intensity_low_val_perc : float, optional - Rescale intensities so that this is the minimum value in the image. + Rescale intensities so that this is the minimum value in the image. Default is 0.0 rescale_intensity_high_val_perc : float, optional - Rescale intensities so that this is the maximum value in the image. + Rescale intensities so that this is the maximum value in the image. Default is 100.0 # sharpen : int, optional - # Sharpen image with high pass filter, recommended to be 1/4-1/8 + # Sharpen image with high pass filter, recommended to be 1/4-1/8 # diameter of cells in pixels. Default is 0. low_percentile : float, optional Lower percentile for normalizing image. Default is 1.0 high_percentile : float, optional Higher percentile for normalizing image. Default is 99.0 norm3D : bool, optional - Compute normalization across entire z-stack rather than + Compute normalization across entire z-stack rather than plane-by-plane in stitching mode. Default is False tile_norm_blocksize : int, optional Size of the tiles for normalization. Default is 0, which means no tiling. @@ -276,7 +277,7 @@ def segment( bsize : int, optional Block size for tiles, recommended to keep at 224 (as in training). Default is 224. - """ + """ self.timelapse = False self.img_shape = image.shape self.img_ndim = len(self.img_shape) @@ -307,7 +308,7 @@ def segment( # interp=interp, min_size=min_size, cellprob_threshold=cellprob_threshold, - prev_kwargs=eval_kwargs + prev_kwargs=eval_kwargs, ) norm_kwargs = _get_normalize_params( @@ -319,15 +320,13 @@ def segment( high_percentile=high_percentile, norm3D=norm3D, normalize=normalize, - tile_norm_blocksize=tile_norm_blocksize - ) - - eval_kwargs['normalize'] = norm_kwargs - - labs = self.eval_loop( - image, segment_3D_volume, **eval_kwargs + tile_norm_blocksize=tile_norm_blocksize, ) + eval_kwargs["normalize"] = norm_kwargs + + labs = self.eval_loop(image, segment_3D_volume, **eval_kwargs) + self.img_shape = None self.img_ndim = None @@ -337,26 +336,19 @@ def segment3DT(self, video_data, signals=None, **kwargs): self.timelapse = True self.img_shape = video_data[0].shape self.img_ndim = len(self.img_shape) - + image = video_data[0] - eval_kwargs, self.isZstack = self.get_eval_kwargs( - image, - **kwargs - ) + eval_kwargs, self.isZstack = self.get_eval_kwargs(image, **kwargs) - eval_kwargs = self._get_eval_kwargs_v4( - **kwargs, - prev_kwargs=eval_kwargs - ) + eval_kwargs = self._get_eval_kwargs_v4(**kwargs, prev_kwargs=eval_kwargs) - labels = self.segment3DT_eval( - video_data, eval_kwargs, **kwargs - ) + labels = self.segment3DT_eval(video_data, eval_kwargs, **kwargs) self.img_shape = None self.img_ndim = None - return labels + return labels + def url_help(): - return 'https://cellpose.readthedocs.io/en/latest/api.html' + return "https://cellpose.readthedocs.io/en/latest/api.html" diff --git a/cellacdc/segmenters/cellsam/__init__.py b/cellacdc/segmenters/cellsam/__init__.py index 3faed5aad..3dedb4691 100644 --- a/cellacdc/segmenters/cellsam/__init__.py +++ b/cellacdc/segmenters/cellsam/__init__.py @@ -8,6 +8,6 @@ # cellsam_general: trained on datasets from the original publication # cellsam_extra: incorporates additional datasets beyond the paper model_types = { - 'General': 'cellsam_general', - 'Extra': 'cellsam_extra', + "General": "cellsam_general", + "Extra": "cellsam_extra", } diff --git a/cellacdc/segmenters/cellsam/acdcSegment.py b/cellacdc/segmenters/cellsam/acdcSegment.py index a024b9dc2..555bdb788 100644 --- a/cellacdc/segmenters/cellsam/acdcSegment.py +++ b/cellacdc/segmenters/cellsam/acdcSegment.py @@ -27,21 +27,21 @@ class Boolean: class Model: def __init__( - self, - model_type: AvailableModels='General', - model_path: os.PathLike='', - bbox_threshold: float=0.4, - low_contrast_enhancement: bool=False, - use_wsi: bool=True, - gauge_cell_size: bool=False, - block_size: int=400, - overlap: int=56, - iou_depth: int=56, - iou_threshold: float=0.5, - postprocess: bool=False, - remove_boundaries: bool=False, - gpu: bool=True - ): + self, + model_type: AvailableModels = "General", + model_path: os.PathLike = "", + bbox_threshold: float = 0.4, + low_contrast_enhancement: bool = False, + use_wsi: bool = True, + gauge_cell_size: bool = False, + block_size: int = 400, + overlap: int = 56, + iou_depth: int = 56, + iou_threshold: float = 0.5, + postprocess: bool = False, + remove_boundaries: bool = False, + gpu: bool = True, + ): """Initialization of CellSAM Model within Cell-ACDC CellSAM is a foundation model for cell segmentation that achieves @@ -100,9 +100,9 @@ def __init__( Whether to use GPU for inference (if available). Default is True """ if gpu and torch.cuda.is_available(): - self.device = 'cuda' + self.device = "cuda" else: - self.device = 'cpu' + self.device = "cpu" self.bbox_threshold = bbox_threshold self.low_contrast_enhancement = low_contrast_enhancement @@ -118,7 +118,7 @@ def __init__( model_path = myutils.translateStrNone(model_path)[0] if model_path: - print(f'Loading CellSAM model from {model_path}...') + print(f"Loading CellSAM model from {model_path}...") self.model = get_local_model(model_path) else: model_name = model_types[model_type] @@ -127,7 +127,7 @@ def __init__( self.model = get_model(model=model_name) except Exception as e: error_msg = str(e).lower() - if 'token' in error_msg or 'auth' in error_msg or '401' in error_msg: + if "token" in error_msg or "auth" in error_msg or "401" in error_msg: raise RuntimeError( f"Failed to download CellSAM model: {e}\n\n" "Hint: CellSAM requires a DeepCell access token. " @@ -139,15 +139,15 @@ def __init__( self.model = self.model.to(self.device) self.model.bbox_threshold = bbox_threshold - print(f'CellSAM model loaded successfully on {self.device}') + print(f"CellSAM model loaded successfully on {self.device}") def segment( - self, - image: np.ndarray, - frame_i: int=0, - automatic_removal_of_background: Boolean=False, - posData: NotParam=None - ) -> np.ndarray: + self, + image: np.ndarray, + frame_i: int = 0, + automatic_removal_of_background: Boolean = False, + posData: NotParam = None, + ) -> np.ndarray: """Segment image using CellSAM Parameters @@ -223,23 +223,28 @@ def _segment_2D_image(self, image: np.ndarray) -> np.ndarray: if self.use_wsi: # Use WSI pipeline for large images or dense cell populations import dask.array as da + img_normalized = normalize_image(img.astype(np.float32)) if self.low_contrast_enhancement: from cellSAM.utils import enhance_low_contrast + img_normalized = enhance_low_contrast(img_normalized) inp = da.from_array(img_normalized, chunks=256) if self.gauge_cell_size: from cellSAM.cellsam_pipeline import use_cellsize_gaging + labels = use_cellsize_gaging( - inp, self.model, self.device, + inp, + self.model, + self.device, block_size=self.block_size, overlap=self.overlap, iou_depth=self.iou_depth, iou_threshold=self.iou_threshold, - bbox_threshold=self.bbox_threshold + bbox_threshold=self.bbox_threshold, ) else: labels = segment_wsi( @@ -251,7 +256,7 @@ def _segment_2D_image(self, image: np.ndarray) -> np.ndarray: normalize=True, model=self.model, device=self.device, - bbox_threshold=self.bbox_threshold + bbox_threshold=self.bbox_threshold, ).compute() else: # Direct segmentation for smaller images @@ -262,7 +267,7 @@ def _segment_2D_image(self, image: np.ndarray) -> np.ndarray: postprocess=self.postprocess, remove_boundaries=self.remove_boundaries, bbox_threshold=self.bbox_threshold, - device=self.device + device=self.device, ) return labels.astype(np.uint32) @@ -304,7 +309,7 @@ def _prepare_image(self, image: np.ndarray) -> np.ndarray: else: # Pad with zeros img = np.zeros((*image.shape[:-1], 3), dtype=image.dtype) - img[..., :image.shape[-1]] = image + img[..., : image.shape[-1]] = image else: raise ValueError(f"Unexpected image shape: {image.shape}") @@ -350,4 +355,4 @@ def _remove_background_from_labels(self, labels: np.ndarray) -> np.ndarray: def url_help(): - return 'https://github.com/vanvalenlab/cellSAM' + return "https://github.com/vanvalenlab/cellSAM" diff --git a/cellacdc/segmenters/delta/__init__.py b/cellacdc/segmenters/delta/__init__.py index cd7539ea5..dd1955b17 100644 --- a/cellacdc/segmenters/delta/__init__.py +++ b/cellacdc/segmenters/delta/__init__.py @@ -6,4 +6,4 @@ from cellacdc import myutils -myutils.check_install_package('delta', pypi_name='delta2') +myutils.check_install_package("delta", pypi_name="delta2") diff --git a/cellacdc/segmenters/delta/acdcSegment.py b/cellacdc/segmenters/delta/acdcSegment.py index 1cec89787..fd6bd09e0 100644 --- a/cellacdc/segmenters/delta/acdcSegment.py +++ b/cellacdc/segmenters/delta/acdcSegment.py @@ -19,9 +19,7 @@ class Model: - - def __init__(self, - model_type='2D or mothermachine'): + def __init__(self, model_type="2D or mothermachine"): """ Configures data, initializes model, loads weights for model. @@ -50,18 +48,21 @@ def __init__(self, except ValueError: # Downloads model weights and configuration files for 2D and mothermachine - download_assets(load_models=True, - load_sets=False, - load_evals=False, - config_level='local') - - def delta_preprocess(self, - image, - target_size: Tuple[int, int] = (256, 32), - order: int = 1, - rangescale: bool = True, - crop: bool = False, - ): + download_assets( + load_models=True, + load_sets=False, + load_evals=False, + config_level="local", + ) + + def delta_preprocess( + self, + image, + target_size: Tuple[int, int] = (256, 32), + order: int = 1, + rangescale: bool = True, + crop: bool = False, + ): """ Takes image and reformat it @@ -104,7 +105,7 @@ def delta_preprocess(self, for j in range(2) ] img = np.zeros((fill_shape[0], fill_shape[1])) - img[0: i.shape[0], 0: i.shape[1]] = i + img[0 : i.shape[0], 0 : i.shape[1]] = i if rangescale: if np.ptp(img) != 0: @@ -136,26 +137,23 @@ def segment(self, image): original_shape = image.shape if image.ndim != 2: - raise ValueError( - f"""Delta only works with 2 dimensional images.""" - ) + raise ValueError(f"""Delta only works with 2 dimensional images.""") # 2D: Cut into overlapping windows - img = self.delta_preprocess(image=image, - target_size=self.target_size, - crop=True) + img = self.delta_preprocess( + image=image, target_size=self.target_size, crop=True + ) # Process image to use for delta - image = self.delta_preprocess(image=image, - target_size=self.target_size, - crop=cfg.crop_windows) + image = self.delta_preprocess( + image=image, target_size=self.target_size, crop=cfg.crop_windows + ) # Change Dimensions to 4D numpy array image = np.reshape(image, (1,) + image.shape + (1,)) # mother machine: Don't crop images into windows if not cfg.crop_windows: - # Predictions: results = self.model.predict(image, verbose=1)[0, :, :, 0] @@ -190,5 +188,6 @@ def segment(self, image): return lab.astype(np.uint32) + def url_help(): - return 'https://gitlab.com/dunloplab/delta' \ No newline at end of file + return "https://gitlab.com/dunloplab/delta" diff --git a/cellacdc/segmenters/omnipose/__init__.py b/cellacdc/segmenters/omnipose/__init__.py index b21279481..22a77bbab 100644 --- a/cellacdc/segmenters/omnipose/__init__.py +++ b/cellacdc/segmenters/omnipose/__init__.py @@ -4,4 +4,4 @@ from cellacdc import myutils -myutils.check_install_omnipose() \ No newline at end of file +myutils.check_install_omnipose() diff --git a/cellacdc/segmenters/omnipose/acdcSegment.py b/cellacdc/segmenters/omnipose/acdcSegment.py index 36f1fbdc3..a84e7e652 100644 --- a/cellacdc/segmenters/omnipose/acdcSegment.py +++ b/cellacdc/segmenters/omnipose/acdcSegment.py @@ -12,88 +12,85 @@ from omnipose.core import OMNI_MODELS + class AvailableModels: values = OMNI_MODELS + class Model: def __init__( - self, - model_type: AvailableModels='bact_phase_omni', - net_avg=False, - gpu=False - ): + self, model_type: AvailableModels = "bact_phase_omni", net_avg=False, gpu=False + ): if model_type not in OMNI_MODELS: err_msg = ( - f'"{model_type}" not available. ' - f'Available models are {OMNI_MODELS}' + f'"{model_type}" not available. Available models are {OMNI_MODELS}' ) raise NameError(err_msg) - self.model = models.Cellpose( - gpu=gpu, net_avg=net_avg, model_type=model_type - ) - + self.model = models.Cellpose(gpu=gpu, net_avg=net_avg, model_type=model_type) + def _eval(self, image, **kwargs): - kwargs['omni'] = True + kwargs["omni"] = True return self.model.eval(image.astype(np.float32), **kwargs)[0] - + def _initialize_image(self, image): # See cellpose.io._initialize_images if image.ndim == 2: - image = image[np.newaxis,...] - - img_min = image.min() + image = image[np.newaxis, ...] + + img_min = image.min() img_max = image.max() image = image.astype(np.float32) image -= img_min if img_max > img_min + 1e-3: - image /= (img_max - img_min) + image /= img_max - img_min image *= 255 if image.ndim < 4: - image = image[:,:,:,np.newaxis] + image = image[:, :, :, np.newaxis] return image - + def segment( - self, image, - diameter=0.0, - flow_threshold=0.4, - cellprob_threshold=0.0, - stitch_threshold=0.0, - min_size=15, - anisotropy=0.0, - normalize=True, - resample=True, - segment_3D_volume=False - ): + self, + image, + diameter=0.0, + flow_threshold=0.4, + cellprob_threshold=0.0, + stitch_threshold=0.0, + min_size=15, + anisotropy=0.0, + normalize=True, + resample=True, + segment_3D_volume=False, + ): # Preprocess image # image = image/image.max() # image = skimage.filters.gaussian(image, sigma=1) # image = skimage.exposure.equalize_adapthist(image) if anisotropy == 0 or image.ndim == 2: anisotropy = None - + do_3D = segment_3D_volume if image.ndim == 2: stitch_threshold = 0.0 segment_3D_volume = False do_3D = False - + if stitch_threshold > 0: do_3D = False - - if flow_threshold==0.0 or image.ndim==3: + + if flow_threshold == 0.0 or image.ndim == 3: flow_threshold = None eval_kwargs = { - 'channels': [0,0], - 'diameter': diameter, - 'flow_threshold': flow_threshold, - 'cellprob_threshold': cellprob_threshold, - 'stitch_threshold': stitch_threshold, - 'min_size': min_size, - 'normalize': normalize, - 'do_3D': do_3D, - 'anisotropy': anisotropy, - 'resample': resample + "channels": [0, 0], + "diameter": diameter, + "flow_threshold": flow_threshold, + "cellprob_threshold": cellprob_threshold, + "stitch_threshold": stitch_threshold, + "min_size": min_size, + "normalize": normalize, + "do_3D": do_3D, + "anisotropy": anisotropy, + "resample": resample, } # Run cellpose eval @@ -103,11 +100,12 @@ def segment( _img = self._initialize_image(_img) lab = self._eval(_img, **eval_kwargs) labels[i] = lab - labels = skimage.measure.label(labels>0) + labels = skimage.measure.label(labels > 0) else: - image = self._initialize_image(image) + image = self._initialize_image(image) labels = self._eval(image, **eval_kwargs) return labels + def url_help(): - return 'https://omnipose.readthedocs.io/' + return "https://omnipose.readthedocs.io/" diff --git a/cellacdc/segmenters/omnipose_custom/__init__.py b/cellacdc/segmenters/omnipose_custom/__init__.py index 7ff29a02c..bb4a8ceef 100644 --- a/cellacdc/segmenters/omnipose_custom/__init__.py +++ b/cellacdc/segmenters/omnipose_custom/__init__.py @@ -4,4 +4,4 @@ from cellacdc import myutils -myutils.check_install_package('omnipose_acdc') +myutils.check_install_package("omnipose_acdc") diff --git a/cellacdc/segmenters/omnipose_custom/acdcSegment.py b/cellacdc/segmenters/omnipose_custom/acdcSegment.py index 7150d7b76..8948c604e 100644 --- a/cellacdc/segmenters/omnipose_custom/acdcSegment.py +++ b/cellacdc/segmenters/omnipose_custom/acdcSegment.py @@ -13,25 +13,27 @@ from omnipose.core import OMNI_MODELS from cellacdc import printl + class Model: - def __init__(self, model_path: os.PathLike = '', net_avg=False, gpu=False): + def __init__(self, model_path: os.PathLike = "", net_avg=False, gpu=False): self.acdcCellpose = cp_omni.Model() self.acdcCellpose.model = models.CellposeModel( gpu=gpu, net_avg=net_avg, pretrained_model=model_path ) def segment( - self, image, - diameter=0.0, - flow_threshold=0.4, - cellprob_threshold=0.0, - stitch_threshold=0.0, - min_size=15, - anisotropy=0.0, - normalize=True, - resample=True, - segment_3D_volume=False - ): + self, + image, + diameter=0.0, + flow_threshold=0.4, + cellprob_threshold=0.0, + stitch_threshold=0.0, + min_size=15, + anisotropy=0.0, + normalize=True, + resample=True, + segment_3D_volume=False, + ): labels = self.acdcCellpose.segment( image, diameter=diameter, @@ -42,9 +44,10 @@ def segment( anisotropy=anisotropy, normalize=normalize, resample=resample, - segment_3D_volume=segment_3D_volume + segment_3D_volume=segment_3D_volume, ) return labels + def url_help(): - return 'https://omnipose.readthedocs.io/' + return "https://omnipose.readthedocs.io/" diff --git a/cellacdc/segmenters/pomBseen/__init__.py b/cellacdc/segmenters/pomBseen/__init__.py index 03dbe438a..148dd7e30 100644 --- a/cellacdc/segmenters/pomBseen/__init__.py +++ b/cellacdc/segmenters/pomBseen/__init__.py @@ -1,3 +1,3 @@ from cellacdc import myutils -myutils.check_install_package('pombseen', pypi_name='pomBseen') +myutils.check_install_package("pombseen", pypi_name="pomBseen") diff --git a/cellacdc/segmenters/pomBseen/acdcSegment.py b/cellacdc/segmenters/pomBseen/acdcSegment.py index 186699441..da482f418 100644 --- a/cellacdc/segmenters/pomBseen/acdcSegment.py +++ b/cellacdc/segmenters/pomBseen/acdcSegment.py @@ -1,32 +1,34 @@ from pombseen.main import pomBseg + class Model: def __init__(self): pass + def segment( - self, - image, - offset = -2.5, - connectivity_remove_small_objects_inverse_bw = 1, - connectivity_label = 1, - connectivity_remove_small_objects_binarize = 1, - sharpen_image = False, - radius=1.0, - amount=1.0, - block_size = 15, - min_pix_inverse_bw = 600, - min_pix_inverted_inverse_bw = 600, - min_pix_thresh_binarize = 600, - footprint = 'default', - clear_border_buffer = 2, - clear_border_max_pix = 1200, - convex_filter_slope = 12.8571, - convex_filter_intercept = 12.5, - min_size = 500, - max_size = 100000, - apply_convex_hull = False, - ): - """Segment the input `image` and returns a labelled array with the same + self, + image, + offset=-2.5, + connectivity_remove_small_objects_inverse_bw=1, + connectivity_label=1, + connectivity_remove_small_objects_binarize=1, + sharpen_image=False, + radius=1.0, + amount=1.0, + block_size=15, + min_pix_inverse_bw=600, + min_pix_inverted_inverse_bw=600, + min_pix_thresh_binarize=600, + footprint="default", + clear_border_buffer=2, + clear_border_max_pix=1200, + convex_filter_slope=12.8571, + convex_filter_intercept=12.5, + min_size=500, + max_size=100000, + apply_convex_hull=False, + ): + """Segment the input `image` and returns a labelled array with the same shape as input image (i.e., instance segmentation). Parameters @@ -76,34 +78,35 @@ def segment( ------- _type_ _description_ - """ - if footprint == 'default': + """ + if footprint == "default": footprint = None - + # Make sure block_size is odd if block_size % 2 == 0: block_size += 1 - - segmented_img = pomBseg(image, - sharpen_image, - radius, - amount, - block_size, - offset, - footprint, - min_pix_inverse_bw, - min_pix_inverted_inverse_bw, - min_pix_thresh_binarize, - connectivity_remove_small_objects_inverse_bw, - connectivity_label, - connectivity_remove_small_objects_binarize, - clear_border_buffer, - clear_border_max_pix, - convex_filter_slope, - convex_filter_intercept, - min_size, - max_size, - apply_convex_hull, + + segmented_img = pomBseg( + image, + sharpen_image, + radius, + amount, + block_size, + offset, + footprint, + min_pix_inverse_bw, + min_pix_inverted_inverse_bw, + min_pix_thresh_binarize, + connectivity_remove_small_objects_inverse_bw, + connectivity_label, + connectivity_remove_small_objects_binarize, + clear_border_buffer, + clear_border_max_pix, + convex_filter_slope, + convex_filter_intercept, + min_size, + max_size, + apply_convex_hull, ) - return segmented_img \ No newline at end of file + return segmented_img diff --git a/cellacdc/segmenters/pomBseen_nuclear/__init__.py b/cellacdc/segmenters/pomBseen_nuclear/__init__.py index 03dbe438a..148dd7e30 100644 --- a/cellacdc/segmenters/pomBseen_nuclear/__init__.py +++ b/cellacdc/segmenters/pomBseen_nuclear/__init__.py @@ -1,3 +1,3 @@ from cellacdc import myutils -myutils.check_install_package('pombseen', pypi_name='pomBseen') +myutils.check_install_package("pombseen", pypi_name="pomBseen") diff --git a/cellacdc/segmenters/pomBseen_nuclear/acdcSegment.py b/cellacdc/segmenters/pomBseen_nuclear/acdcSegment.py index a7f9ce9e0..413fd8223 100644 --- a/cellacdc/segmenters/pomBseen_nuclear/acdcSegment.py +++ b/cellacdc/segmenters/pomBseen_nuclear/acdcSegment.py @@ -1,20 +1,21 @@ from pombseen.main import pomBsegNuc + class Model: def __init__(self, segm_data): self.segm_data = segm_data def segment( - self, - image, - connectivity = 1, - offset = 0, - min_size=5, - max_size=100000, - max_nuclei = 2, - rel_size_max = 0.3 - ): - """Segment the input `image` and returns a labelled array with the same + self, + image, + connectivity=1, + offset=0, + min_size=5, + max_size=100000, + max_nuclei=2, + rel_size_max=0.3, + ): + """Segment the input `image` and returns a labelled array with the same shape as input image (i.e., instance segmentation). Parameters @@ -36,11 +37,17 @@ def segment( ------- _type_ Segmented image - """ + """ segmented_img = pomBsegNuc( - image, self.segm_data, connectivity, offset, min_size, max_size, - max_nuclei, rel_size_max + image, + self.segm_data, + connectivity, + offset, + min_size, + max_size, + max_nuclei, + rel_size_max, ) - return segmented_img \ No newline at end of file + return segmented_img diff --git a/cellacdc/segmenters/sam2/__init__.py b/cellacdc/segmenters/sam2/__init__.py index 4760da4c5..8990fdebd 100644 --- a/cellacdc/segmenters/sam2/__init__.py +++ b/cellacdc/segmenters/sam2/__init__.py @@ -8,13 +8,13 @@ # Get SAM2 models path # Using the same pattern as segment_anything -_, sam_segmenters_path = myutils.get_model_path('sam2', create_temp_dir=False) +_, sam_segmenters_path = myutils.get_model_path("sam2", create_temp_dir=False) # SAM2 model configurations # Format: 'Display Name': ('config_file', 'checkpoint_filename') model_types = { - 'Large': ('configs/sam2.1/sam2.1_hiera_l.yaml', 'sam2.1_hiera_large.pt'), - 'Base Plus': ('configs/sam2.1/sam2.1_hiera_b+.yaml', 'sam2.1_hiera_base_plus.pt'), - 'Small': ('configs/sam2.1/sam2.1_hiera_s.yaml', 'sam2.1_hiera_small.pt'), - 'Tiny': ('configs/sam2.1/sam2.1_hiera_t.yaml', 'sam2.1_hiera_tiny.pt'), + "Large": ("configs/sam2.1/sam2.1_hiera_l.yaml", "sam2.1_hiera_large.pt"), + "Base Plus": ("configs/sam2.1/sam2.1_hiera_b+.yaml", "sam2.1_hiera_base_plus.pt"), + "Small": ("configs/sam2.1/sam2.1_hiera_s.yaml", "sam2.1_hiera_small.pt"), + "Tiny": ("configs/sam2.1/sam2.1_hiera_t.yaml", "sam2.1_hiera_tiny.pt"), } diff --git a/cellacdc/segmenters/sam2/acdcSegment.py b/cellacdc/segmenters/sam2/acdcSegment.py index b05a264f4..92ba5bca9 100644 --- a/cellacdc/segmenters/sam2/acdcSegment.py +++ b/cellacdc/segmenters/sam2/acdcSegment.py @@ -16,35 +16,41 @@ from cellacdc import myutils, widgets, printl + class AvailableModels: values = list(model_types.keys()) + class DataFrame: not_a_param = True + class NotParam: not_a_param = True + class Boolean: not_a_param = True + class Integer: not_a_param = True + class Model: def __init__( - self, - model_type: AvailableModels='Large', - input_points_path: widgets.CsvFilePathControl='', - input_points_df: DataFrame='None', - points_per_side=32, - pred_iou_thresh=0.8, - stability_score_thresh=0.95, - crop_n_layers=0, - crop_n_points_downscale_factor=1, - min_mask_region_area=0, - gpu=True - ): + self, + model_type: AvailableModels = "Large", + input_points_path: widgets.CsvFilePathControl = "", + input_points_df: DataFrame = "None", + points_per_side=32, + pred_iou_thresh=0.8, + stability_score_thresh=0.95, + crop_n_layers=0, + crop_n_points_downscale_factor=1, + min_mask_region_area=0, + gpu=True, + ): """Initialization of Segment Anything Model 2 within Cell-ACDC Parameters @@ -130,34 +136,34 @@ def __init__( """ if gpu: from cellacdc import is_mac_arm64 + if is_mac_arm64: - device = 'cpu' + device = "cpu" else: - device = 'cuda' + device = "cuda" else: - device = 'cpu' + device = "cpu" - if isinstance(input_points_df, str) and input_points_df=='None': + if isinstance(input_points_df, str) and input_points_df == "None": input_points_df = None - load_points_df = ( - input_points_path - and input_points_df is None - ) + load_points_df = input_points_path and input_points_df is None if load_points_df: input_points_df = pd.read_csv(input_points_path) if input_points_df is not None: - if 'z' in input_points_df.columns: - input_points_df = input_points_df.sort_values(['z', 'id']) + if "z" in input_points_df.columns: + input_points_df = input_points_df.sort_values(["z", "id"]) else: - input_points_df = input_points_df.sort_values('id') + input_points_df = input_points_df.sort_values("id") self._input_points_df = input_points_df config_file, sam_checkpoint = model_types[model_type] sam_checkpoint = os.path.join(sam_segmenters_path, sam_checkpoint) - sam = build_sam2(config_file=config_file, ckpt_path=sam_checkpoint, device=device) + sam = build_sam2( + config_file=config_file, ckpt_path=sam_checkpoint, device=device + ) if input_points_df is None: self.model = SAM2AutomaticMaskGenerator( @@ -175,18 +181,17 @@ def __init__( self._embedded_img = None def segment( - self, - image: np.ndarray, - frame_i: int, - automatic_removal_of_background: bool=True, - input_points_df: DataFrame='None', - posData: NotParam=None, - save_embeddings: Boolean=False, - only_embeddings: Boolean=False, - use_loaded_embeddings: Boolean=False, - start_z_slice: Integer=0 - ) -> np.ndarray: - + self, + image: np.ndarray, + frame_i: int, + automatic_removal_of_background: bool = True, + input_points_df: DataFrame = "None", + posData: NotParam = None, + save_embeddings: Boolean = False, + only_embeddings: Boolean = False, + use_loaded_embeddings: Boolean = False, + start_z_slice: Integer = 0, + ) -> np.ndarray: """Segment image using SAM2 image : ([Z], Y, X, [C]) numpy.ndarray @@ -260,7 +265,7 @@ def segment( self._input_points_df = input_points_df is_rgb_image = image.shape[-1] == 3 or image.shape[-1] == 4 - is_z_stack = (image.ndim==3 and not is_rgb_image) or (image.ndim==4) + is_z_stack = (image.ndim == 3 and not is_rgb_image) or (image.ndim == 4) if is_rgb_image: labels = np.zeros(image.shape[:-1], dtype=np.uint32) else: @@ -268,15 +273,13 @@ def segment( if self._input_points_df is None: df_points = None - elif 'frame_i' in self._input_points_df.columns: - mask = self._input_points_df['frame_i'] == frame_i + elif "frame_i" in self._input_points_df.columns: + mask = self._input_points_df["frame_i"] == frame_i df_points = self._input_points_df[mask] else: df_points = self._input_points_df - input_points, input_labels = self._get_input_points( - is_z_stack, df_points - ) + input_points, input_labels = self._get_input_points(is_z_stack, df_points) if is_z_stack: for z, img in enumerate(image): input_points_z = None @@ -287,40 +290,42 @@ def segment( embeddings_init = False if use_loaded_embeddings: embeddings_init = self._get_img_embeddings( - posData, frame_i=frame_i, z=z+start_z_slice + posData, frame_i=frame_i, z=z + start_z_slice ) if only_embeddings: self._init_embeddings(img) else: lab_2D = self._segment_2D_image( - img, input_points_z, input_labels_z, - embeddings_already_init=embeddings_init + img, + input_points_z, + input_labels_z, + embeddings_already_init=embeddings_init, ) labels[z] = lab_2D if save_embeddings or only_embeddings: posData.storeSamEmbeddings( - self, frame_i=frame_i, z=z+start_z_slice + self, frame_i=frame_i, z=z + start_z_slice ) if automatic_removal_of_background and input_points is None: # For z-stacks, remove background after 3D relabeling labels = self._remove_background_from_labels(labels) - - labels = skimage.measure.label(labels>0).astype(np.uint32) + + labels = skimage.measure.label(labels > 0).astype(np.uint32) else: embeddings_init = False if use_loaded_embeddings: - embeddings_init = self._get_img_embeddings( - posData, frame_i=frame_i - ) + embeddings_init = self._get_img_embeddings(posData, frame_i=frame_i) if only_embeddings: self._init_embeddings(image) else: labels = self._segment_2D_image( - image, input_points, input_labels, + image, + input_points, + input_labels, embeddings_already_init=embeddings_init, - automatic_removal_of_background=automatic_removal_of_background + automatic_removal_of_background=automatic_removal_of_background, ) if save_embeddings or only_embeddings: @@ -345,25 +350,21 @@ def _get_input_points(self, is_z_stack, df_points): if is_z_stack: input_points = defaultdict(dict) input_labels = defaultdict(dict) - neg_input_points_df = ( - df_points[df_points['id'] == 0] - .set_index('z') - ) - for (z, id), sub_df in df_points.groupby(['z', 'id']): + neg_input_points_df = df_points[df_points["id"] == 0].set_index("z") + for (z, id), sub_df in df_points.groupby(["z", "id"]): if id == 0: continue # Concatenate negative points - points_data_z = sub_df[['x', 'y']].to_numpy() + points_data_z = sub_df[["x", "y"]].to_numpy() points_labels_z = np.ones(len(sub_df), dtype=int) # 1 = positive try: - neg_points_data_z = ( - neg_input_points_df.loc[z][['x', 'y']].to_numpy()) - points_data_z = np.row_stack(( - neg_points_data_z, points_data_z - )) + neg_points_data_z = neg_input_points_df.loc[z][ + ["x", "y"] + ].to_numpy() + points_data_z = np.row_stack((neg_points_data_z, points_data_z)) points_labels_z = np.concatenate( - ([0]*len(neg_points_data_z), points_labels_z) + ([0] * len(neg_points_data_z), points_labels_z) ) except IndexError: pass @@ -373,23 +374,19 @@ def _get_input_points(self, is_z_stack, df_points): else: input_points = {} input_labels = {} - neg_input_points_df = ( - df_points[df_points['id'] == 0] - ) - neg_input_points_data = neg_input_points_df[['x', 'y']].to_numpy() - for id, df_id in df_points.groupby('id'): + neg_input_points_df = df_points[df_points["id"] == 0] + neg_input_points_data = neg_input_points_df[["x", "y"]].to_numpy() + for id, df_id in df_points.groupby("id"): if id == 0: continue - points_data_id = df_id[['x', 'y']].to_numpy() - points_data_id = np.row_stack(( - neg_input_points_data, points_data_id - )) + points_data_id = df_id[["x", "y"]].to_numpy() + points_data_id = np.row_stack((neg_input_points_data, points_data_id)) # Use 1 for positive labels (not actual IDs) - SAM expects binary 0/1 points_labels_id = np.ones(len(df_id), dtype=int) points_labels_id = np.concatenate( - ([0]*len(neg_input_points_data), points_labels_id) + ([0] * len(neg_input_points_data), points_labels_id) ) input_points[id] = points_data_id input_labels[id] = points_labels_id @@ -407,7 +404,7 @@ def _init_embeddings(self, img_rgb): except Exception as err: init_embeddings = True - if hasattr(self.model, 'predictor'): + if hasattr(self.model, "predictor"): predictor = self.model.predictor else: predictor = self.model @@ -417,12 +414,13 @@ def _init_embeddings(self, img_rgb): self._embedded_img = img_rgb def _segment_2D_image( - self, image: np.ndarray, - input_points: np.ndarray, - input_labels: np.ndarray, - embeddings_already_init: bool=False, - automatic_removal_of_background: bool=False - ) -> np.ndarray: + self, + image: np.ndarray, + input_points: np.ndarray, + input_labels: np.ndarray, + embeddings_already_init: bool = False, + automatic_removal_of_background: bool = False, + ) -> np.ndarray: img = myutils.to_uint8(image) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) @@ -439,10 +437,10 @@ def _segment_2D_image( masks = [m for i, m in enumerate(masks) if i != bg_idx] # Sort by area descending so smaller masks overwrite larger ones - masks = sorted(masks, key=lambda m: m['area'], reverse=True) + masks = sorted(masks, key=lambda m: m["area"], reverse=True) for id, mask in enumerate(masks): - obj_image = mask['segmentation'] - labels[obj_image] = id+1 + obj_image = mask["segmentation"] + labels[obj_image] = id + 1 return labels @@ -456,7 +454,7 @@ def _segment_2D_image( for id, point_coords in input_points.items(): point_labels = input_labels[id] - multimask_output = len(point_coords)==1 + multimask_output = len(point_coords) == 1 masks, scores, logits = self.model.predict( point_coords=point_coords, point_labels=point_labels, @@ -470,9 +468,7 @@ def _segment_2D_image( labels[mask] = id return labels - def _find_background_mask_index( - self, masks: list, shape: tuple - ) -> int | None: + def _find_background_mask_index(self, masks: list, shape: tuple) -> int | None: """Find the mask with the most pixels touching the image border.""" if not masks: return None @@ -484,7 +480,7 @@ def _find_background_mask_index( max_border_pixels = 0 bg_idx = None for i, mask in enumerate(masks): - segmentation = mask['segmentation'] + segmentation = mask["segmentation"] border_pixels = np.sum(segmentation & border_mask) if border_pixels > max_border_pixels: max_border_pixels = border_pixels @@ -505,4 +501,4 @@ def _remove_background_from_labels(self, labels: np.ndarray) -> np.ndarray: def url_help(): - return 'https://github.com/facebookresearch/segment-anything-2' + return "https://github.com/facebookresearch/segment-anything-2" diff --git a/cellacdc/segmenters/segment_anything/__init__.py b/cellacdc/segmenters/segment_anything/__init__.py index 26d42f299..c62634229 100644 --- a/cellacdc/segmenters/segment_anything/__init__.py +++ b/cellacdc/segmenters/segment_anything/__init__.py @@ -5,10 +5,12 @@ import os from cellacdc import segment_anything_weights_filenames -_, sam_segmenters_path = myutils.get_model_path('segment_anything', create_temp_dir=False) +_, sam_segmenters_path = myutils.get_model_path( + "segment_anything", create_temp_dir=False +) model_types = { - 'Large': ('default', segment_anything_weights_filenames[0]), - 'Medium': ('vit_l', segment_anything_weights_filenames[1]), - 'Small': ('vit_b', segment_anything_weights_filenames[2]) -} \ No newline at end of file + "Large": ("default", segment_anything_weights_filenames[0]), + "Medium": ("vit_l", segment_anything_weights_filenames[1]), + "Small": ("vit_b", segment_anything_weights_filenames[2]), +} diff --git a/cellacdc/segmenters/segment_anything/acdcSegment.py b/cellacdc/segmenters/segment_anything/acdcSegment.py index 9c965b5fd..40c4c645c 100644 --- a/cellacdc/segmenters/segment_anything/acdcSegment.py +++ b/cellacdc/segmenters/segment_anything/acdcSegment.py @@ -12,157 +12,159 @@ from . import model_types, sam_segmenters_path -from segment_anything import ( - sam_model_registry, SamAutomaticMaskGenerator, SamPredictor -) +from segment_anything import sam_model_registry, SamAutomaticMaskGenerator, SamPredictor from cellacdc import myutils, widgets, printl + class AvailableModels: values = list(model_types.keys()) + class DataFrame: not_a_param = True + class NotParam: not_a_param = True + class Boolean: not_a_param = True + class Integer: not_a_param = True + class Model: def __init__( - self, - model_type: AvailableModels='Large', - input_points_path: widgets.CsvFilePathControl='', - input_points_df: DataFrame='None', - points_per_side=32, - pred_iou_thresh=0.88, - stability_score_thresh=0.95, - crop_n_layers=0, - crop_n_points_downscale_factor=2, - min_mask_region_area=1, - gpu=False - ): + self, + model_type: AvailableModels = "Large", + input_points_path: widgets.CsvFilePathControl = "", + input_points_df: DataFrame = "None", + points_per_side=32, + pred_iou_thresh=0.88, + stability_score_thresh=0.95, + crop_n_layers=0, + crop_n_points_downscale_factor=2, + min_mask_region_area=1, + gpu=False, + ): """Initialization of Segment Anything Model within Cell-ACDC Parameters ---------- points_per_side : int or None, optional - The number of points to be sampled along one side of the image. - The total number of points is points_per_side**2. - If None, 'point_grids' must provide explicit point sampling. - Ignored if `input_points_path` is not empty or `input_points_df` is not + The number of points to be sampled along one side of the image. + The total number of points is points_per_side**2. + If None, 'point_grids' must provide explicit point sampling. + Ignored if `input_points_path` is not empty or `input_points_df` is not 'None'. Default is 32 - pred_iou_thresh : float, optional - A filtering threshold in [0,1], using the model's predicted mask - quality. - Ignored if `input_points_path` is not empty or `input_points_df` is not - 'None'. Default is pred_iou_thresh + pred_iou_thresh : float, optional + A filtering threshold in [0,1], using the model's predicted mask + quality. + Ignored if `input_points_path` is not empty or `input_points_df` is not + 'None'. Default is pred_iou_thresh stability_score_thresh : float, optional - A filtering threshold in [0,1], using the stability of the mask - under changes to the cutoff used to binarize the model's mask - predictions. - Ignored if `input_points_path` is not empty or `input_points_df` is not + A filtering threshold in [0,1], using the stability of the mask + under changes to the cutoff used to binarize the model's mask + predictions. + Ignored if `input_points_path` is not empty or `input_points_df` is not 'None'. Default is 0.95 - crop_n_layers : int - If >0, mask prediction will be run again on crops of the image. - Sets the number of layers to run, where each layer has 2**i_layer + crop_n_layers : int + If >0, mask prediction will be run again on crops of the image. + Sets the number of layers to run, where each layer has 2**i_layer number of image crops. - Ignored if `input_points_path` is not empty or `input_points_df` is not + Ignored if `input_points_path` is not empty or `input_points_df` is not 'None'. Default is 0 crop_n_points_downscale_factor : int, optional - The number of points-per-side sampled in layer n is scaled down by + The number of points-per-side sampled in layer n is scaled down by crop_n_points_downscale_factor**n. - Ignored if `input_points_path` is not empty or `input_points_df` is not + Ignored if `input_points_path` is not empty or `input_points_df` is not 'None'. Default is 2 min_mask_region_area: int, optional - If >0, postprocessing will be applied mto remove disconnected - regions and holes in masks with area smaller than - min_mask_region_area. - Ignored if `input_points_path` is not empty or `input_points_df` is not + If >0, postprocessing will be applied mto remove disconnected + regions and holes in masks with area smaller than + min_mask_region_area. + Ignored if `input_points_path` is not empty or `input_points_df` is not 'None'. Default is 1 input_points_path : str, optional - If not empty, this is the path to the CSV file with the coordinates - of the input points for SAM. It must contain the columns - ('x', 'y', 'id') with an optional 'z' column for segmentation of 3D - z-stack data (slice-by-slice) and a 'frame_i' columns for - time-lapse data. - - Note that `id = 0` will be used for the negative points, i.e. those + If not empty, this is the path to the CSV file with the coordinates + of the input points for SAM. It must contain the columns + ('x', 'y', 'id') with an optional 'z' column for segmentation of 3D + z-stack data (slice-by-slice) and a 'frame_i' columns for + time-lapse data. + + Note that `id = 0` will be used for the negative points, i.e. those objects (like the background) that should not be segmented. - - In the Cell-ACDC GUI (module 3) you can click to add points and - save them to a file whose path or endname can be provided for the - `input_points_path`. To do so, click on the "Add points layer" - button on the top toolbar and choose "Add points with mouse clicks". - - To add a new point for a new object click with the mouse left - button. To add points to the same object click with the right - button. The 'id' of the point will be visible next to the point - symbol. To delete a point click on the point. - - To add negative points click with the middle button (Cmd+click on - macOS) or enter 0 in the "Point id" numeric control (top toolbar) - and then right-click to add points with the current id. - + + In the Cell-ACDC GUI (module 3) you can click to add points and + save them to a file whose path or endname can be provided for the + `input_points_path`. To do so, click on the "Add points layer" + button on the top toolbar and choose "Add points with mouse clicks". + + To add a new point for a new object click with the mouse left + button. To add points to the same object click with the right + button. The 'id' of the point will be visible next to the point + symbol. To delete a point click on the point. + + To add negative points click with the middle button (Cmd+click on + macOS) or enter 0 in the "Point id" numeric control (top toolbar) + and then right-click to add points with the current id. + To load the coordinates from a CSV file click on the browse button. - - If empty string and `inputs_points_df` is 'None', SAM will run + + If empty string and `inputs_points_df` is 'None', SAM will run in automatic mode on the entire image. Default is None - + input_points_df : pd.DataFrame or 'None', optional - If not 'None', this is a pandas DataFrame (a table) with the - coordinates of the input points for SAM. - - It must contain the columns ('x', 'y', 'id') with an optional - 'z' column for segmentation of 3D z-stack data (slice-by-slice) and - a 'frame_i' columns for time-lapse data. Note that `id = 0` will - be used for the negative points, i.e. those objects (like the + If not 'None', this is a pandas DataFrame (a table) with the + coordinates of the input points for SAM. + + It must contain the columns ('x', 'y', 'id') with an optional + 'z' column for segmentation of 3D z-stack data (slice-by-slice) and + a 'frame_i' columns for time-lapse data. Note that `id = 0` will + be used for the negative points, i.e. those objects (like the background) that should not be segmented. - - If not 'None', `input_points_path` will be ignored and this will be used - instead. - - If 'None' and `input_points_path` is empty, SAM will run - in automatic mode on the entire image. Default is 'None' - """ + + If not 'None', `input_points_path` will be ignored and this will be used + instead. + + If 'None' and `input_points_path` is empty, SAM will run + in automatic mode on the entire image. Default is 'None' + """ if gpu: from cellacdc import is_mac_arm64 + if is_mac_arm64: - device = 'cpu' + device = "cpu" else: - device = 'cuda' + device = "cuda" else: - device = 'cpu' - - if isinstance(input_points_df, str) and input_points_df=='None': + device = "cpu" + + if isinstance(input_points_df, str) and input_points_df == "None": input_points_df = None - - load_points_df = ( - input_points_path - and input_points_df is None - ) + + load_points_df = input_points_path and input_points_df is None if load_points_df: input_points_df = pd.read_csv(input_points_path) - + if input_points_df is not None: - if 'z' in input_points_df.columns: - input_points_df = input_points_df.sort_values(['z', 'id']) + if "z" in input_points_df.columns: + input_points_df = input_points_df.sort_values(["z", "id"]) else: - input_points_df = input_points_df.sort_values('id') - + input_points_df = input_points_df.sort_values("id") + self._input_points_df = input_points_df - + model_type, sam_checkpoint = model_types[model_type] sam_checkpoint = os.path.join(sam_segmenters_path, sam_checkpoint) - sam = sam_model_registry[model_type](checkpoint=sam_checkpoint) + sam = sam_model_registry[model_type](checkpoint=sam_checkpoint) sam.to(device=device) if input_points_df is None: self.model = SamAutomaticMaskGenerator( - sam, + sam, points_per_side=points_per_side, pred_iou_thresh=pred_iou_thresh, stability_score_thresh=stability_score_thresh, @@ -172,115 +174,112 @@ def __init__( ) else: self.model = SamPredictor(sam) - + self._embedded_img = None - + def segment( - self, - image: np.ndarray, - frame_i: int, - automatic_removal_of_background: bool=True, - input_points_df: DataFrame='None', - posData: NotParam=None, - save_embeddings: Boolean=False, - only_embeddings: Boolean=False, - use_loaded_embeddings: Boolean=False, - start_z_slice: Integer=0 - ) -> np.ndarray: - + self, + image: np.ndarray, + frame_i: int, + automatic_removal_of_background: bool = True, + input_points_df: DataFrame = "None", + posData: NotParam = None, + save_embeddings: Boolean = False, + only_embeddings: Boolean = False, + use_loaded_embeddings: Boolean = False, + start_z_slice: Integer = 0, + ) -> np.ndarray: """_summary_ image : ([Z], Y, X, [C]) numpy.ndarray - Input image. It can be grayscale 2D (Y, X), or 3D (Z, Y, X) for - z-stack data, or it can have additional dimension C for the RGB + Input image. It can be grayscale 2D (Y, X), or 3D (Z, Y, X) for + z-stack data, or it can have additional dimension C for the RGB channels. - + frame_i : int - Frame index (starting from 0). Used to get the input points from - `input_points_df` with timelapse data. Ignored if the + Frame index (starting from 0). Used to get the input points from + `input_points_df` with timelapse data. Ignored if the `input_points_df` does not have the 'frame_i' column. - + automatic_removal_of_background : bool, optional - If True, the background object will be removed. The background - object is defined as the largest object touching the borders of the - image. Used only with automatic generator without input prompts, - i.e., `input_points_path` is empty and `input_points_df` is equal + If True, the background object will be removed. The background + object is defined as the largest object touching the borders of the + image. Used only with automatic generator without input prompts, + i.e., `input_points_path` is empty and `input_points_df` is equal to 'None'. - + input_points_df : pd.DataFrame or 'None', optional - If not 'None', this is a pandas DataFrame (a table) with the - coordinates of the input points for SAM. - - It must contain the columns ('x', 'y', 'id') with an optional - 'z' column for segmentation of 3D z-stack data (slice-by-slice) and - a 'frame_i' columns for time-lapse data. Note that `id = 0` will - be used for the negative points, i.e. those objects (like the + If not 'None', this is a pandas DataFrame (a table) with the + coordinates of the input points for SAM. + + It must contain the columns ('x', 'y', 'id') with an optional + 'z' column for segmentation of 3D z-stack data (slice-by-slice) and + a 'frame_i' columns for time-lapse data. Note that `id = 0` will + be used for the negative points, i.e. those objects (like the background) that should not be segmented. - - If not 'None', and there is already an `input_points_df` from the - `__init__` (initialization of the model) method it will be + + If not 'None', and there is already an `input_points_df` from the + `__init__` (initialization of the model) method it will be overwritten with the new table. - + posData : load.loadData or None, optional - This is not a parameter configurable through the GUI. Cell-ACDC - will pass the class of the loaded data from the specific Position. - This is the used internally to add image embeddings if + This is not a parameter configurable through the GUI. Cell-ACDC + will pass the class of the loaded data from the specific Position. + This is the used internally to add image embeddings if `save_embeddings` is True. - + save_embeddings : bool, optional - This is not a parameter configurable through the GUI. If `posData` - is not None, the image embeddings will be stored in the dictionary - `posData.sam_embeddings`. This dictionary can be later used to + This is not a parameter configurable through the GUI. If `posData` + is not None, the image embeddings will be stored in the dictionary + `posData.sam_embeddings`. This dictionary can be later used to save the embeddings to disk. - + only_embeddings : bool, optional - This is not a parameter configurable through the GUI. If `True`, - The labels masks will not be generated and the model will only - be used to generate the image embeddings stored in + This is not a parameter configurable through the GUI. If `True`, + The labels masks will not be generated and the model will only + be used to generate the image embeddings stored in `posData.sam_embeddings`. - - use_loaded_embeddings : bool, optional - This is not a parameter configurable through the GUI. If `posData` - is not None, the image embeddings will be loaded from the dictionary + + use_loaded_embeddings : bool, optional + This is not a parameter configurable through the GUI. If `posData` + is not None, the image embeddings will be loaded from the dictionary `posData.sam_embeddings`. - + start_z_slice : int, optional - This is not a parameter configurable through the GUI. Cell-ACDC - will pass the correct start z-slice to store embeddings at the + This is not a parameter configurable through the GUI. Cell-ACDC + will pass the correct start z-slice to store embeddings at the right z-slice. - + Returns ------- ([Z], Y, X) numpy.ndarray of ints - Output labelled masks with the same shape as input image but without - the channel dimension. Every pixel belonging to the same object + Output labelled masks with the same shape as input image but without + the channel dimension. Every pixel belonging to the same object will have the same integer ID. ID = 0 is for the background. - """ - + """ + if isinstance(input_points_df, pd.DataFrame): self._input_points_df = input_points_df - + is_rgb_image = image.shape[-1] == 3 or image.shape[-1] == 4 - is_z_stack = (image.ndim==3 and not is_rgb_image) or (image.ndim==4) + is_z_stack = (image.ndim == 3 and not is_rgb_image) or (image.ndim == 4) if is_rgb_image: labels = np.zeros(image.shape[:-1], dtype=np.uint32) else: labels = np.zeros(image.shape, dtype=np.uint32) - + if self._input_points_df is None: df_points = None - elif 'frame_i' in self._input_points_df.columns: - mask = self._input_points_df['frame_i'] == frame_i + elif "frame_i" in self._input_points_df.columns: + mask = self._input_points_df["frame_i"] == frame_i df_points = self._input_points_df[mask] else: df_points = self._input_points_df - + auto_remove_bkgr = automatic_removal_of_background - input_points, input_labels = self._get_input_points( - is_z_stack, df_points - ) - if is_z_stack: - pbar_z = tqdm(total=len(image), ncols=100, desc='z-slice') + input_points, input_labels = self._get_input_points(is_z_stack, df_points) + if is_z_stack: + pbar_z = tqdm(total=len(image), ncols=100, desc="z-slice") for z, img in enumerate(image): input_points_z = None input_labels_z = None @@ -288,148 +287,143 @@ def segment( input_points_z = input_points.get(z, None) if input_points_z is not None: input_labels_z = input_labels.get(z, []) - + embeddings_init = False if use_loaded_embeddings: embeddings_init = self._get_img_embeddings( - posData, frame_i=frame_i, z=z+start_z_slice + posData, frame_i=frame_i, z=z + start_z_slice ) - + if only_embeddings: self._init_embeddings(img) else: lab_2D = self._segment_2D_image( - img, input_points_z, input_labels_z, + img, + input_points_z, + input_labels_z, embeddings_already_init=embeddings_init, ) labels[z] = lab_2D if save_embeddings or only_embeddings: posData.storeSamEmbeddings( - self, frame_i=frame_i, z=z+start_z_slice + self, frame_i=frame_i, z=z + start_z_slice ) - + pbar_z.update() pbar_z.close() - + if automatic_removal_of_background and input_points is None: # For z-stacks, remove background after 3D relabeling labels = self._remove_background_from_labels(labels) - - labels = skimage.measure.label(labels>0).astype(np.uint32) + + labels = skimage.measure.label(labels > 0).astype(np.uint32) else: embeddings_init = False if use_loaded_embeddings: - embeddings_init = self._get_img_embeddings( - posData, frame_i=frame_i - ) + embeddings_init = self._get_img_embeddings(posData, frame_i=frame_i) if only_embeddings: self._init_embeddings(image) else: labels = self._segment_2D_image( - image, input_points, input_labels, + image, + input_points, + input_labels, embeddings_already_init=embeddings_init, - automatic_removal_of_background=auto_remove_bkgr + automatic_removal_of_background=auto_remove_bkgr, ) if save_embeddings or only_embeddings: posData.storeSamEmbeddings(self, frame_i=frame_i) - + return labels def _get_img_embeddings(self, posData, frame_i=0, z=0): img_embeddings = posData.getSamEmbeddings(frame_i=frame_i, z=z) if img_embeddings is None: return False - + for key, value in img_embeddings.items(): setattr(self, key, value) - + return True - + def _get_input_points(self, is_z_stack, df_points): if df_points is None: return None, None - + if is_z_stack: input_points = defaultdict(dict) input_labels = defaultdict(dict) - neg_input_points_df = ( - df_points[df_points['id'] == 0] - .set_index('z') - ) - for (z, id), sub_df in df_points.groupby(['z', 'id']): + neg_input_points_df = df_points[df_points["id"] == 0].set_index("z") + for (z, id), sub_df in df_points.groupby(["z", "id"]): if id == 0: continue - + # Concatenate negative points - points_data_z = sub_df[['x', 'y']].to_numpy() + points_data_z = sub_df[["x", "y"]].to_numpy() points_labels_z = np.ones(len(sub_df), dtype=int) # 1 = positive try: - neg_points_data_z = ( - neg_input_points_df.loc[z][['x', 'y']].to_numpy()) - points_data_z = np.row_stack(( - neg_points_data_z, points_data_z - )) + neg_points_data_z = neg_input_points_df.loc[z][ + ["x", "y"] + ].to_numpy() + points_data_z = np.row_stack((neg_points_data_z, points_data_z)) points_labels_z = np.concatenate( - ([0]*len(neg_points_data_z), points_labels_z) + ([0] * len(neg_points_data_z), points_labels_z) ) except IndexError: pass - + input_points[z][id] = points_data_z input_labels[z][id] = points_labels_z else: input_points = {} input_labels = {} - neg_input_points_df = ( - df_points[df_points['id'] == 0] - ) - neg_input_points_data = neg_input_points_df[['x', 'y']].to_numpy() - for id, df_id in df_points.groupby('id'): + neg_input_points_df = df_points[df_points["id"] == 0] + neg_input_points_data = neg_input_points_df[["x", "y"]].to_numpy() + for id, df_id in df_points.groupby("id"): if id == 0: continue - - points_data_id = df_id[['x', 'y']].to_numpy() - points_data_id = np.row_stack(( - neg_input_points_data, points_data_id - )) + + points_data_id = df_id[["x", "y"]].to_numpy() + points_data_id = np.row_stack((neg_input_points_data, points_data_id)) # Use 1 for positive labels (not actual IDs) - SAM expects binary 0/1 points_labels_id = np.ones(len(df_id), dtype=int) points_labels_id = np.concatenate( - ([0]*len(neg_input_points_data), points_labels_id) + ([0] * len(neg_input_points_data), points_labels_id) ) input_points[id] = points_data_id input_labels[id] = points_labels_id - + return input_points, input_labels - - def _init_embeddings(self, img_rgb): + + def _init_embeddings(self, img_rgb): if img_rgb.ndim == 2: img_rgb = myutils.to_uint8(img_rgb) img_rgb = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2RGB) - - # Create embeddings only if new image + + # Create embeddings only if new image try: init_embeddings = not np.allclose(img_rgb, self._embedded_img) except Exception as err: init_embeddings = True - - if hasattr(self.model, 'predictor'): + + if hasattr(self.model, "predictor"): predictor = self.model.predictor else: predictor = self.model - - if init_embeddings: + + if init_embeddings: predictor.set_image(img_rgb) self._embedded_img = img_rgb - + def _segment_2D_image( - self, image: np.ndarray, - input_points: dict[int, np.ndarray], - input_labels: dict[int, np.ndarray], - embeddings_already_init: bool=False, - automatic_removal_of_background: bool=False - ) -> np.ndarray: + self, + image: np.ndarray, + input_points: dict[int, np.ndarray], + input_labels: dict[int, np.ndarray], + embeddings_already_init: bool = False, + automatic_removal_of_background: bool = False, + ) -> np.ndarray: img = myutils.to_uint8(image) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) @@ -446,24 +440,24 @@ def _segment_2D_image( masks = [m for i, m in enumerate(masks) if i != bg_idx] # Sort by area descending so smaller masks overwrite larger ones - masks = sorted(masks, key=lambda m: m['area'], reverse=True) + masks = sorted(masks, key=lambda m: m["area"], reverse=True) for id, mask in enumerate(masks): - obj_image = mask['segmentation'] - labels[obj_image] = id+1 + obj_image = mask["segmentation"] + labels[obj_image] = id + 1 return labels - + # No input points --> return empty labels if len(input_points) == 0: return labels - + # SAM with input points if not embeddings_already_init: self._init_embeddings(img) - + for id, point_coords in input_points.items(): point_labels = input_labels[id] - multimask_output = len(point_coords)==1 + multimask_output = len(point_coords) == 1 masks, scores, logits = self.model.predict( point_coords=point_coords, point_labels=point_labels, @@ -477,9 +471,7 @@ def _segment_2D_image( labels[mask] = id return labels - def _find_background_mask_index( - self, masks: list, shape: tuple - ) -> int | None: + def _find_background_mask_index(self, masks: list, shape: tuple) -> int | None: """Find the mask with the most pixels touching the image border.""" if not masks: return None @@ -491,7 +483,7 @@ def _find_background_mask_index( max_border_pixels = 0 bg_idx = None for i, mask in enumerate(masks): - segmentation = mask['segmentation'] + segmentation = mask["segmentation"] border_pixels = np.sum(segmentation & border_mask) if border_pixels > max_border_pixels: max_border_pixels = border_pixels @@ -512,4 +504,4 @@ def _remove_background_from_labels(self, labels: np.ndarray) -> np.ndarray: def url_help(): - return 'https://github.com/facebookresearch/segment-anything' + return "https://github.com/facebookresearch/segment-anything" diff --git a/cellacdc/segmenters/skip_segmentation/acdcSegment.py b/cellacdc/segmenters/skip_segmentation/acdcSegment.py index aed82e3f1..7679d63c1 100644 --- a/cellacdc/segmenters/skip_segmentation/acdcSegment.py +++ b/cellacdc/segmenters/skip_segmentation/acdcSegment.py @@ -1,14 +1,13 @@ class Model: def __init__(self, segm_data): self.segm_data = segm_data - def segment( - self, - image, - frame_i, - skip_segmentation = True, - ): + self, + image, + frame_i, + skip_segmentation=True, + ): """Skips the segmentation step and instead uses the provided segmentation data. Parameters @@ -21,6 +20,6 @@ def segment( ------- _type_ Segmented image (same as segm_data) - """ - - return self.segm_data[frame_i] \ No newline at end of file + """ + + return self.segm_data[frame_i] diff --git a/cellacdc/segmenters/thresholding/acdcSegment.py b/cellacdc/segmenters/thresholding/acdcSegment.py index 3ee0f1ca3..c4d3d1612 100644 --- a/cellacdc/segmenters/thresholding/acdcSegment.py +++ b/cellacdc/segmenters/thresholding/acdcSegment.py @@ -4,10 +4,11 @@ from cellacdc import printl + class Model: def __init__(self): pass - + def _preprocess(self, img, gauss_sigma): if gauss_sigma > 0: filtered = skimage.filters.gaussian(img, sigma=gauss_sigma) @@ -18,12 +19,14 @@ def _preprocess(self, img, gauss_sigma): def _apply_threshold(self, img, threshold_method): thresh_val = getattr(skimage.filters, threshold_method)(img) return img > thresh_val - + def segment( - self, image, gauss_sigma=1.0, - threshold_method='threshold_otsu', - segment_3D_volume=False - ): + self, + image, + gauss_sigma=1.0, + threshold_method="threshold_otsu", + segment_3D_volume=False, + ): is3D = image.ndim > 2 if is3D and not segment_3D_volume: # Segment slice-by-slice @@ -35,7 +38,7 @@ def segment( else: filtered = self._preprocess(image, gauss_sigma) thresh = self._apply_threshold(filtered, threshold_method) - + labels = skimage.measure.label(thresh) - return labels \ No newline at end of file + return labels diff --git a/cellacdc/segmenters_promptable/micro-sam/__init__.py b/cellacdc/segmenters_promptable/micro-sam/__init__.py index 40ce20431..42233bf05 100644 --- a/cellacdc/segmenters_promptable/micro-sam/__init__.py +++ b/cellacdc/segmenters_promptable/micro-sam/__init__.py @@ -1,3 +1,3 @@ import cellacdc.myutils as myutils -myutils.check_install_microsam() \ No newline at end of file +myutils.check_install_microsam() diff --git a/cellacdc/segmenters_promptable/nnInteractive/acdcPromptSegment.py b/cellacdc/segmenters_promptable/nnInteractive/acdcPromptSegment.py index 7800d867a..398a5e37f 100644 --- a/cellacdc/segmenters_promptable/nnInteractive/acdcPromptSegment.py +++ b/cellacdc/segmenters_promptable/nnInteractive/acdcPromptSegment.py @@ -13,22 +13,25 @@ from huggingface_hub import snapshot_download + class AvailableModels: - values = ['nnInteractive_v1.0'] + values = ["nnInteractive_v1.0"] + class GPUorCPU: - values = ['gpu', 'cpu'] + values = ["gpu", "cpu"] + class Model: def __init__( - self, - model_name: AvailableModels = 'nnInteractive_v1.0', - run_on: GPUorCPU = 'cpu', - device: torch.device | int ='None', - verbose: bool = False, - torch_number_of_threads: int = os.cpu_count(), - **kwargs - ): + self, + model_name: AvailableModels = "nnInteractive_v1.0", + run_on: GPUorCPU = "cpu", + device: torch.device | int = "None", + verbose: bool = False, + torch_number_of_threads: int = os.cpu_count(), + **kwargs, + ): """_summary_ Parameters @@ -39,26 +42,26 @@ def __init__( Whether to run on CPU or first GPU available. Default is 'cpu' device : torch.device or int or None If not None, this is the device used for running the model - (torch.device('cuda') or torch.device('cpu')). - It overrides `run_on`, recommended if you want to use a specific GPU + (torch.device('cuda') or torch.device('cpu')). + It overrides `run_on`, recommended if you want to use a specific GPU (e.g. torch.device('cuda:1'). Default is None verbose : bool, optional - If True, more information will be displayed in the terminal. + If True, more information will be displayed in the terminal. Default is False torch_number_of_threads : int, optional - Number of CPU threads to use for the computation. + Number of CPU threads to use for the computation. Default is `os.cpu_count()`, i.e., the maximum available CPU cores. - """ + """ from nnInteractive.inference.inference_session import ( - nnInteractiveInferenceSession + nnInteractiveInferenceSession, ) - - if device == 'None': + + if device == "None": device = None - + if device is None: - device = myutils.get_torch_device(gpu=run_on == 'gpu') - + device = myutils.get_torch_device(gpu=run_on == "gpu") + self.model = nnInteractiveInferenceSession( device=device, # Set inference device use_torch_compile=False, # Experimental: Not tested yet @@ -67,55 +70,55 @@ def __init__( do_autozoom=True, # Enables AutoZoom for better patching use_pinned_memory=True, # Optimizes GPU memory transfers ) - - download_dir = os.path.join(user_profile_path, 'acdc-nnInteractive') + + download_dir = os.path.join(user_profile_path, "acdc-nnInteractive") os.makedirs(download_dir, exist_ok=True) - + download_path = snapshot_download( - repo_id='nnInteractive/nnInteractive', + repo_id="nnInteractive/nnInteractive", allow_patterns=[f"{model_name}/*"], - local_dir=download_dir + local_dir=download_dir, ) - + model_path = os.path.join(download_dir, model_name) - + self.model.initialize_from_trained_model_folder(model_path) - + self.prompt_ids_image_mapper = {} self.prompts = defaultdict(list) self.negative_prompts = defaultdict(list) - - def _validate_prompt(self, prompt, prompt_type='point'): - if prompt_type == 'point': + + def _validate_prompt(self, prompt, prompt_type="point"): + if prompt_type == "point": prompt = tuple(prompt) if len(prompt) != 3: raise ValueError( "Point prompt must be a sequence of 3 coordinates (z, y, x)." ) - + def _validate_image(self, image): if image is None: return - + if image.ndim == 3: return - + raise ValueError( "Only 3D images are supported by nnInteractive. " "Please provide a 3D image with (Z, Y, X) dimensions." ) - + def add_prompt( - self, - prompt, - prompt_id: int, - *args, - image=None, - image_origin=(0, 0, 0), - parent_obj_id=0, - prompt_type='point', - **kwargs - ): + self, + prompt, + prompt_id: int, + *args, + image=None, + image_origin=(0, 0, 0), + parent_obj_id=0, + prompt_type="point", + **kwargs, + ): """Add prompt to model Parameters @@ -124,97 +127,83 @@ def add_prompt( Prompt to add. If 'point', this should be a sequence of 3 coordinates (z, y, x). prompt_id : int - Unique identifier for the prompt. If 0, then it will be treated as a + Unique identifier for the prompt. If 0, then it will be treated as a negative prompt (i.e., the background). image : np.ndarray, optional - Image to which the prompt is associated. If None, the prompt will + Image to which the prompt is associated. If None, the prompt will be associated to the entire image passed to the `segment` method. image_origin : tuple of (z0, y0, x0) coordinates, optional - Origin of the image in the global image coordinate system. This - is useful when you want to pass a crop of the image to the model, - but still have the result inserted into the global image by + Origin of the image in the global image coordinate system. This + is useful when you want to pass a crop of the image to the model, + but still have the result inserted into the global image by the `segment` method. Default is (0, 0, 0). parent_obj_id : int, optional - The ID of the parent object. If not 0, this will be used to assign - negative prompts only to the parent object. + The ID of the parent object. If not 0, this will be used to assign + negative prompts only to the parent object. prompt_type : {'point'}, optional The type of prompt to add. Default is 'point'. - """ + """ self._validate_prompt(prompt, prompt_type=prompt_type) self._validate_image(image) - + if prompt_id not in self.prompt_ids_image_mapper and prompt_id != 0: self.prompt_ids_image_mapper[prompt_id] = (image, image_origin) - + if prompt_id != 0: - self.prompts[prompt_id].append( - (prompt, prompt_type) - ) + self.prompts[prompt_id].append((prompt, prompt_type)) elif parent_obj_id != 0: # Negative prompt for a specific parent object - self.negative_prompts[parent_obj_id].append( - (prompt, prompt_type) - ) + self.negative_prompts[parent_obj_id].append((prompt, prompt_type)) else: # Negative prompt for the background self.negative_prompts[0].append((prompt, prompt_type)) - + def _add_object_prompts(self, prompt_id, is_negative=False): prompts = self.prompts[prompt_id] for prompt, prompt_type in prompts: - if prompt_type == 'point': + if prompt_type == "point": # nnInteractive requires (x, y, z) order point_prompt = tuple(prompt[::-1]) self.model.add_point_interaction( point_prompt, include_interaction=not is_negative, - run_prediction=True + run_prediction=True, ) else: raise ValueError(f"Unsupported prompt type: {prompt_type}") - + def _add_object_specific_negative_prompts(self, prompt_id): obj_negative_prompts = self.negative_prompts[prompt_id] for prompt, prompt_type in obj_negative_prompts: - if prompt_type == 'point': + if prompt_type == "point": # nnInteractive requires (x, y, z) order point_prompt = tuple(prompt[::-1]) self.model.add_point_interaction( - point_prompt, - include_interaction=False, - run_prediction=True + point_prompt, include_interaction=False, run_prediction=True ) else: raise ValueError(f"Unsupported prompt type: {prompt_type}") - + def _add_global_negative_prompts(self): global_negative_prompts = self.negative_prompts[0] for prompt, prompt_type in global_negative_prompts: - if prompt_type == 'point': + if prompt_type == "point": # nnInteractive requires (x, y, z) order point_prompt = tuple(prompt[::-1]) self.model.add_point_interaction( - point_prompt, - include_interaction=False, - run_prediction=True + point_prompt, include_interaction=False, run_prediction=True ) else: raise ValueError(f"Unsupported prompt type: {prompt_type}") - + def _add_other_objects_prompts_as_negative(self, current_prompt_id): for prompt_id, prompts in self.prompts.items(): if prompt_id == current_prompt_id: continue - + self._add_object_prompts(prompt_id, is_negative=True) - - def segment( - self, - image, - treat_other_objects_as_background=True, - *args, - **kwargs - ): + + def segment(self, image, treat_other_objects_as_background=True, *args, **kwargs): """Run the segmentation model on the image using the prompts added Parameters @@ -222,15 +211,15 @@ def segment( image : (Z, Y, X) np.ndarray 3D z-stack image to segment. treat_other_objects_as_background : bool, optional - If True, when segmenting an object, the prompts added + If True, when segmenting an object, the prompts added for all the other objects are treated as negative prompts for the current object. Default is True Returns ------- (Z, Y, X) np.ndarray - Labelled array with the segmentation masks of the objects. - Smaller objects are added on top to prevent larger + Labelled array with the segmentation masks of the objects. + Smaller objects are added on top to prevent larger objects from removing smaller ones. Raises @@ -240,58 +229,57 @@ def segment( """ self._validate_image(image) - lab = np.zeros(image.shape, dtype=np.uint32) + lab = np.zeros(image.shape, dtype=np.uint32) for prompt_id, value in self.prompt_ids_image_mapper.items(): prompt_image, image_origin = value - + if prompt_image is None: prompt_image = image - + # Re-order axis from (z, y, x) to (x, y, z) for the model prompt_image = np.moveaxis(prompt_image, (0, 1, 2), (2, 1, 0)) - + prompt_image = prompt_image[np.newaxis] self.model.set_image(prompt_image) - - target_tensor = torch.zeros( - prompt_image.shape[1:], dtype=torch.uint8 - ) + + target_tensor = torch.zeros(prompt_image.shape[1:], dtype=torch.uint8) self.model.set_target_buffer(target_tensor) - + self._add_object_prompts(prompt_id, is_negative=False) self._add_object_specific_negative_prompts(prompt_id) self._add_global_negative_prompts() - + if treat_other_objects_as_background: # Add the other objects prompts as negative self._add_other_objects_prompts_as_negative(prompt_id) - + # self.model._predict() - + result_tensor = target_tensor.clone() - + # Convert to numpy array and re-order axis back to (z, y, x) result_mask = np.moveaxis( result_tensor.numpy(), (2, 1, 0), (0, 1, 2) ).astype(bool) - + # Insert the result into the global label array z0, y0, x0 = image_origin d, h, w = result_mask.shape z1, y1, x1 = z0 + d, y0 + h, x0 + w - + obj_slice = (slice(z0, z1), slice(y0, y1), slice(x0, x1)) lab[obj_slice][result_mask] = prompt_id - + self.model.reset_interactions() - + lab = build_combined_mask(lab) - + self.prompt_ids_image_mapper = {} self.prompts = defaultdict(list) self.negative_prompts = defaultdict(list) - + return lab + def url_help(): - return 'https://github.com/MIC-DKFZ/nnInteractive' \ No newline at end of file + return "https://github.com/MIC-DKFZ/nnInteractive" diff --git a/cellacdc/segmenters_promptable/sam2/acdcPromptSegment.py b/cellacdc/segmenters_promptable/sam2/acdcPromptSegment.py index 29d2245c1..3b75cfe28 100644 --- a/cellacdc/segmenters_promptable/sam2/acdcPromptSegment.py +++ b/cellacdc/segmenters_promptable/sam2/acdcPromptSegment.py @@ -44,7 +44,9 @@ def __init__(self, model_type: AvailableModels = "Large", gpu: bool = True): config_file, sam_checkpoint = model_types[model_type] sam_checkpoint = os.path.join(sam_segmenters_path, sam_checkpoint) - sam = build_sam2(config_file=config_file, ckpt_path=sam_checkpoint, device=device) + sam = build_sam2( + config_file=config_file, ckpt_path=sam_checkpoint, device=device + ) self.model = SAM2ImagePredictor(sam) self._embedded_img = None @@ -167,7 +169,10 @@ def segment( else: lab_out = np.zeros(image.shape, dtype=np.uint32) - for prompt_id, (prompt_image, image_origin) in self.prompt_ids_image_mapper.items(): + for prompt_id, ( + prompt_image, + image_origin, + ) in self.prompt_ids_image_mapper.items(): if prompt_id == 0: continue @@ -178,12 +183,9 @@ def segment( prompt_id, treat_other_objects_as_background ) - is_prompt_rgb = ( - prompt_image.ndim >= 3 and prompt_image.shape[-1] in (3, 4) - ) - is_prompt_z_stack = ( - (prompt_image.ndim == 3 and not is_prompt_rgb) - or (prompt_image.ndim == 4) + is_prompt_rgb = prompt_image.ndim >= 3 and prompt_image.shape[-1] in (3, 4) + is_prompt_z_stack = (prompt_image.ndim == 3 and not is_prompt_rgb) or ( + prompt_image.ndim == 4 ) if is_prompt_rgb: @@ -218,7 +220,9 @@ def segment( ) mask_idx = np.argmax(scores) if multimask_output else 0 if multimask_output: - log_mask_selection(prompt_id, masks, scores, mask_idx, z_slice=z) + log_mask_selection( + prompt_id, masks, scores, mask_idx, z_slice=z + ) mask = masks[mask_idx].astype(bool) obj_mask[z][mask] = True else: diff --git a/cellacdc/segmenters_promptable/segment_anything/acdcPromptSegment.py b/cellacdc/segmenters_promptable/segment_anything/acdcPromptSegment.py index b4421cc1e..437e1fa2c 100644 --- a/cellacdc/segmenters_promptable/segment_anything/acdcPromptSegment.py +++ b/cellacdc/segmenters_promptable/segment_anything/acdcPromptSegment.py @@ -167,7 +167,10 @@ def segment( else: lab_out = np.zeros(image.shape, dtype=np.uint32) - for prompt_id, (prompt_image, image_origin) in self.prompt_ids_image_mapper.items(): + for prompt_id, ( + prompt_image, + image_origin, + ) in self.prompt_ids_image_mapper.items(): if prompt_id == 0: continue @@ -178,12 +181,9 @@ def segment( prompt_id, treat_other_objects_as_background ) - is_prompt_rgb = ( - prompt_image.ndim >= 3 and prompt_image.shape[-1] in (3, 4) - ) - is_prompt_z_stack = ( - (prompt_image.ndim == 3 and not is_prompt_rgb) - or (prompt_image.ndim == 4) + is_prompt_rgb = prompt_image.ndim >= 3 and prompt_image.shape[-1] in (3, 4) + is_prompt_z_stack = (prompt_image.ndim == 3 and not is_prompt_rgb) or ( + prompt_image.ndim == 4 ) if is_prompt_rgb: @@ -218,7 +218,9 @@ def segment( ) mask_idx = np.argmax(scores) if multimask_output else 0 if multimask_output: - log_mask_selection(prompt_id, masks, scores, mask_idx, z_slice=z) + log_mask_selection( + prompt_id, masks, scores, mask_idx, z_slice=z + ) mask = masks[mask_idx].astype(bool) obj_mask[z][mask] = True else: diff --git a/cellacdc/segmenters_promptable/utils.py b/cellacdc/segmenters_promptable/utils.py index ef23a3830..89afdd2eb 100644 --- a/cellacdc/segmenters_promptable/utils.py +++ b/cellacdc/segmenters_promptable/utils.py @@ -28,11 +28,7 @@ def build_combined_mask(model_out): return combined -def _apply_overlap_rule( - lab_old, - lab_new, - mode: Literal['union', 'intersection'] - ): +def _apply_overlap_rule(lab_old, lab_new, mode: Literal["union", "intersection"]): """ Apply overlap rules between old and new label masks. @@ -72,13 +68,13 @@ def _apply_overlap_rule( p_only = np.logical_and(p_mask, ~q_mask) # Old only q_only = np.logical_and(q_mask, ~p_mask) # New only - if mode == 'union': + if mode == "union": # p OR q → all become p result[p_and_q] = p result[p_only] = p result[q_only] = p - elif mode == 'intersection': + elif mode == "intersection": # Only p AND q → p; p XOR q → 0 result[p_and_q] = p # p_only and q_only become 0 (already 0 in result) @@ -87,7 +83,7 @@ def _apply_overlap_rule( non_overlapping_new_ids = new_ids - overlapping_new_ids for q in non_overlapping_new_ids: q_mask = lab_new == q - if mode == 'union': + if mode == "union": result[q_mask] = q # In 'intersection' mode, non-overlapping new IDs are not added @@ -95,10 +91,10 @@ def _apply_overlap_rule( def insert_model_output_into_labels( - lab, - model_out, - edited_IDs: int | List[int] = 0, - ): + lab, + model_out, + edited_IDs: int | List[int] = 0, +): """ Combine model output with existing labels using three strategies. @@ -122,7 +118,7 @@ def insert_model_output_into_labels( lab_new = build_combined_mask(model_out) # Apply overlap rules for union and intersection - lab_union = _apply_overlap_rule(lab, lab_new, mode='union') - lab_intersection = _apply_overlap_rule(lab, lab_new, mode='intersection') + lab_union = _apply_overlap_rule(lab, lab_new, mode="union") + lab_intersection = _apply_overlap_rule(lab, lab_new, mode="intersection") return lab_new, lab_union, lab_intersection diff --git a/cellacdc/syntax.py b/cellacdc/syntax.py index 09205a00a..718c56d41 100644 --- a/cellacdc/syntax.py +++ b/cellacdc/syntax.py @@ -4,17 +4,17 @@ from qtpy import QtCore, QtGui, QtWidgets -def format(color, style=''): - """Return a QTextCharFormat with the given attributes. - """ + +def format(color, style=""): + """Return a QTextCharFormat with the given attributes.""" _color = QtGui.QColor() _color.setNamedColor(color) _format = QtGui.QTextCharFormat() _format.setForeground(_color) - if 'bold' in style: + if "bold" in style: _format.setFontWeight(QtGui.QFont.Weight.Bold) - if 'italic' in style: + if "italic" in style: _format.setFontItalic(True) return _format @@ -22,97 +22,145 @@ def format(color, style=''): # Syntax styles that can be shared by all languages STYLES = { - 'keyword': format('red'), - 'operator': format('red'), - 'brace': format('darkGray'), - 'defclass': format('darkMagenta'), - 'string': format('green'), - 'string2': format('darkMagenta'), - 'comment': format('darkBlu', 'italic'), - 'self': format('black', 'italic'), - 'numbers': format('brown'), + "keyword": format("red"), + "operator": format("red"), + "brace": format("darkGray"), + "defclass": format("darkMagenta"), + "string": format("green"), + "string2": format("darkMagenta"), + "comment": format("darkBlu", "italic"), + "self": format("black", "italic"), + "numbers": format("brown"), } -class PythonHighlighter (QtGui.QSyntaxHighlighter): - """Syntax highlighter for the Python language. - """ +class PythonHighlighter(QtGui.QSyntaxHighlighter): + """Syntax highlighter for the Python language.""" + # Python keywords keywords = [ - 'and', 'assert', 'break', 'class', 'continue', 'def', - 'del', 'elif', 'else', 'except', 'exec', 'finally', - 'for', 'from', 'global', 'if', 'import', 'in', - 'is', 'lambda', 'not', 'or', 'pass', 'print', - 'raise', 'return', 'try', 'while', 'yield', - 'None', 'True', 'False', + "and", + "assert", + "break", + "class", + "continue", + "def", + "del", + "elif", + "else", + "except", + "exec", + "finally", + "for", + "from", + "global", + "if", + "import", + "in", + "is", + "lambda", + "not", + "or", + "pass", + "print", + "raise", + "return", + "try", + "while", + "yield", + "None", + "True", + "False", ] # Python operators operators = [ - '=', + "=", # Comparison - '==', '!=', '<', '<=', '>', '>=', + "==", + "!=", + "<", + "<=", + ">", + ">=", # Arithmetic - '\+', '-', '\*', '/', '//', '\%', '\*\*', + "\+", + "-", + "\*", + "/", + "//", + "\%", + "\*\*", # In-place - '\+=', '-=', '\*=', '/=', '\%=', + "\+=", + "-=", + "\*=", + "/=", + "\%=", # Bitwise - '\^', '\|', '\&', '\~', '>>', '<<', + "\^", + "\|", + "\&", + "\~", + ">>", + "<<", ] # Python braces braces = [ - '\{', '\}', '\(', '\)', '\[', '\]', + "\{", + "\}", + "\(", + "\)", + "\[", + "\]", ] def __init__(self, parent: QtGui.QTextDocument) -> None: super().__init__(parent) # Multi-line strings (expression, flag, style) - self.tri_single = (QtCore.QRegularExpression("'''"), 1, STYLES['string2']) - self.tri_double = (QtCore.QRegularExpression('"""'), 2, STYLES['string2']) + self.tri_single = (QtCore.QRegularExpression("'''"), 1, STYLES["string2"]) + self.tri_double = (QtCore.QRegularExpression('"""'), 2, STYLES["string2"]) rules = [] # Keyword, operator, and brace rules - rules += [(r'\b%s\b' % w, 0, STYLES['keyword']) - for w in PythonHighlighter.keywords] - rules += [(r'%s' % o, 0, STYLES['operator']) - for o in PythonHighlighter.operators] - rules += [(r'%s' % b, 0, STYLES['brace']) - for b in PythonHighlighter.braces] + rules += [ + (r"\b%s\b" % w, 0, STYLES["keyword"]) for w in PythonHighlighter.keywords + ] + rules += [ + (r"%s" % o, 0, STYLES["operator"]) for o in PythonHighlighter.operators + ] + rules += [(r"%s" % b, 0, STYLES["brace"]) for b in PythonHighlighter.braces] # All other rules rules += [ # 'self' - (r'\bself\b', 0, STYLES['self']), - + (r"\bself\b", 0, STYLES["self"]), # 'def' followed by an identifier - (r'\bdef\b\s*(\w+)', 1, STYLES['defclass']), + (r"\bdef\b\s*(\w+)", 1, STYLES["defclass"]), # 'class' followed by an identifier - (r'\bclass\b\s*(\w+)', 1, STYLES['defclass']), - + (r"\bclass\b\s*(\w+)", 1, STYLES["defclass"]), # Numeric literals - (r'\b[+-]?[0-9]+[lL]?\b', 0, STYLES['numbers']), - (r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b', 0, STYLES['numbers']), - (r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b', 0, STYLES['numbers']), - + (r"\b[+-]?[0-9]+[lL]?\b", 0, STYLES["numbers"]), + (r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", 0, STYLES["numbers"]), + (r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b", 0, STYLES["numbers"]), # Double-quoted string, possibly containing escape sequences - (r'"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES['string']), + (r'"[^"\\]*(\\.[^"\\]*)*"', 0, STYLES["string"]), # Single-quoted string, possibly containing escape sequences - (r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES['string']), - + (r"'[^'\\]*(\\.[^'\\]*)*'", 0, STYLES["string"]), # From '#' until a newline - (r'#[^\n]*', 0, STYLES['comment']), + (r"#[^\n]*", 0, STYLES["comment"]), ] # Build a QRegularExpression for each pattern - self.rules = [(QtCore.QRegularExpression(pat), index, fmt) - for (pat, index, fmt) in rules] + self.rules = [ + (QtCore.QRegularExpression(pat), index, fmt) for (pat, index, fmt) in rules + ] def highlightBlock(self, text): - """Apply syntax highlighting to the given block of text. - """ + """Apply syntax highlighting to the given block of text.""" self.tripleQuoutesWithinStrings = [] # Do other syntax formatting for expression, nth, format in self.rules: @@ -121,7 +169,10 @@ def highlightBlock(self, text): # if there is a string we check # if there are some triple quotes within the string # they will be ignored if they are matched again - if expression.pattern() in [r'"[^"\\]*(\\.[^"\\]*)*"', r"'[^'\\]*(\\.[^'\\]*)*'"]: + if expression.pattern() in [ + r'"[^"\\]*(\\.[^"\\]*)*"', + r"'[^'\\]*(\\.[^'\\]*)*'", + ]: innerIndex = self.tri_single[0].indexIn(text, index + 1) if innerIndex == -1: innerIndex = self.tri_double[0].indexIn(text, index + 1) @@ -191,4 +242,4 @@ def match_multiline(self, text, delimiter, in_state, style): if self.currentBlockState() == in_state: return True else: - return False \ No newline at end of file + return False diff --git a/cellacdc/test_segm_model.py b/cellacdc/test_segm_model.py index 6cc950975..cbbefc21d 100755 --- a/cellacdc/test_segm_model.py +++ b/cellacdc/test_segm_model.py @@ -16,7 +16,8 @@ try: import pytest - pytest.skip('skipping this test since it is gui based', allow_module_level=True) + + pytest.skip("skipping this test since it is gui based", allow_module_level=True) except Exception as e: pass @@ -29,40 +30,39 @@ # test_data = data.BABYtestData() # test_data = data.YeastMitoSnapshotData() -app, splashScreen = _setup_app(splashscreen=True) +app, splashScreen = _setup_app(splashscreen=True) splashScreen.close() initialWindow = apps.TestSegmModelInitalDialog() initialWindow.exec_() if initialWindow.cancel: - print('Execution cancelled.') + print("Execution cancelled.") exit() start_frame_idx = initialWindow.start_frame_n if start_frame_idx is not None: start_frame_idx -= 1 - -stop_frame_n = initialWindow.stop_frame_n + +stop_frame_n = initialWindow.stop_frame_n start_z_slice_idx = initialWindow.start_z_slice_n if start_z_slice_idx is not None: start_z_slice_idx -= 1 - + stop_z_slice_n = initialWindow.stop_z_slice_n is_timelapse = initialWindow.is_timelapse if test_data is None: tif_filepath, _ = qtpy.compat.getopenfilename( - basedir=myutils.getMostRecentPath(), - filters=('Images (*.tif)') + basedir=myutils.getMostRecentPath(), filters=("Images (*.tif)") ) if not tif_filepath: - exit('Execution cancelled.') - + exit("Execution cancelled.") + images_path = os.path.dirname(tif_filepath) basename = os.path.commonprefix(myutils.listdir(images_path)) filename, ext = os.path.splitext(os.path.basename(tif_filepath)) - channel = filename[len(basename):] + channel = filename[len(basename) :] posData = load.loadData(tif_filepath, channel) posData.loadImgData() image_data = posData.img_data @@ -71,37 +71,35 @@ posData = test_data.posData() image_data = test_data.image_data() images_path = test_data.images_path - + posData.loadOtherFiles(load_segm_data=False, load_metadata=True) posData.buildPaths() if is_timelapse: - img = image_data[ - start_frame_idx:stop_frame_n, - start_z_slice_idx:stop_z_slice_n - ] + img = image_data[start_frame_idx:stop_frame_n, start_z_slice_idx:stop_z_slice_n] else: img = image_data[start_z_slice_idx:stop_z_slice_n] from cellacdc.plot import imshow + imshow(img) cellacdc_path = os.path.dirname(os.path.abspath(__file__)) models = myutils.get_list_of_models() win = widgets.QDialogListbox( - 'Select model', - 'Select model to use for segmentation: ', + "Select model", + "Select model to use for segmentation: ", models, - multiSelection=False + multiSelection=False, ) win.exec_() if win.cancel: - sys.exit('Execution aborted') + sys.exit("Execution aborted") model_name = win.selectedItemsText[0] -if model_name == 'Automatic thresholding': - model_name = 'thresholding' +if model_name == "Automatic thresholding": + model_name = "thresholding" # Check if model needs to be downloaded downloadWin = apps.downloadModel(model_name, parent=None) downloadWin.download() @@ -119,46 +117,54 @@ url = None out = prompts.init_segm_model_params( - posData, model_name, init_params, segment_params, - help_url=url, qparent=None, init_last_params=True + posData, + model_name, + init_params, + segment_params, + help_url=url, + qparent=None, + init_last_params=True, ) -win = out.get('win') +win = out.get("win") if win.cancel: - exit('Execution cancelled.') + exit("Execution cancelled.") # Initialize model segm_data = None init_kwargs = win.init_kwargs -segm_endname = init_kwargs.pop('segm_endname', None) +segm_endname = init_kwargs.pop("segm_endname", None) if segm_endname is not None: segm_filepath, _ = load.get_path_from_endname(segm_endname, images_path) - segm_data = np.load(segm_filepath)['arr_0'] + segm_data = np.load(segm_filepath)["arr_0"] model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) if model is None: - sys.exit('Segmentation model was not initialized correctly!') -is_segment3DT_available = any( - [name=='segment3DT' for name in dir(model)] -) + sys.exit("Segmentation model was not initialized correctly!") +is_segment3DT_available = any([name == "segment3DT" for name in dir(model)]) if img.ndim == 3 and (img.shape[-1] == 3 or img.shape[-1] == 4): img = skimage.color.rgb2gray(img) -print('Input image shape: ', img.shape) -print('Segmentation process started...') +print("Input image shape: ", img.shape) +print("Segmentation process started...") lab = core.segm_model_segment( - model, img, win.model_kwargs, frame_i=start_frame_idx, - preproc_recipe=win.preproc_recipe, posData=posData, + model, + img, + win.model_kwargs, + frame_i=start_frame_idx, + preproc_recipe=win.preproc_recipe, + posData=posData, is_timelapse_model_and_data=is_segment3DT_available and is_timelapse, ) from cellacdc.plot import imshow + imshow( - img, - lab, + img, + lab, window_title=f'Result of segmenting with "{model_name}" model', - axis_titles=['Input image', 'Segmentation result'], + axis_titles=["Input image", "Segmentation result"], annotate_labels_idxs=[1], ) diff --git a/cellacdc/test_tracker.py b/cellacdc/test_tracker.py index 2edc18be1..8b2dd9711 100644 --- a/cellacdc/test_tracker.py +++ b/cellacdc/test_tracker.py @@ -10,7 +10,8 @@ try: import pytest - pytest.skip('skipping this test since it is gui based', allow_module_level=True) + + pytest.skip("skipping this test since it is gui based", allow_module_level=True) except Exception as e: pass @@ -18,12 +19,12 @@ from cellacdc._run import _setup_app # Ask which model to use --> Test if new model is visible -app, splashScreen = _setup_app(splashscreen=True) +app, splashScreen = _setup_app(splashscreen=True) splashScreen.close() -channel_name = 'SiR_Hoechst' -end_filename_segm = 'segm' # 'segm_test' -START_FRAME = 0 +channel_name = "SiR_Hoechst" +end_filename_segm = "segm" # 'segm_test' +START_FRAME = 0 STOP_FRAME = 10 # PLOT_FRAME = 499 SAVE = False @@ -39,16 +40,15 @@ if test_data is None: tif_filepath, _ = qtpy.compat.getopenfilename( - basedir=myutils.getMostRecentPath(), - filters=('Images (*.tif)') + basedir=myutils.getMostRecentPath(), filters=("Images (*.tif)") ) if not tif_filepath: - exit('Execution cancelled.') - + exit("Execution cancelled.") + images_path = os.path.dirname(tif_filepath) basename = os.path.commonprefix(myutils.listdir(images_path)) filename, ext = os.path.splitext(os.path.basename(tif_filepath)) - channel = filename[len(basename):] + channel = filename[len(basename) :] posData = load.loadData(tif_filepath, channel) else: posData = test_data.posData() @@ -56,26 +56,24 @@ posData.loadImgData() posData.loadOtherFiles( - load_segm_data=True, - load_metadata=True, - end_filename_segm=end_filename_segm + load_segm_data=True, load_metadata=True, end_filename_segm=end_filename_segm ) -lab_stack = posData.segm_data[START_FRAME:STOP_FRAME+1] +lab_stack = posData.segm_data[START_FRAME : STOP_FRAME + 1] -imshow(lab_stack, axis_titles=['Before tracking'], annotate_labels_idxs=[0]) +imshow(lab_stack, axis_titles=["Before tracking"], annotate_labels_idxs=[0]) trackers = myutils.get_list_of_trackers() -txt = html_utils.paragraph(''' +txt = html_utils.paragraph(""" Select the tracker to use

    -''') +""") win = widgets.QDialogListbox( - 'Select tracker', txt, trackers, multiSelection=False, parent=None + "Select tracker", txt, trackers, multiSelection=False, parent=None ) win.exec_() if win.cancel: - sys.exit('Execution aborted') + sys.exit("Execution aborted") trackerName = win.selectedItemsText[0] @@ -84,13 +82,13 @@ posData, trackerName, qparent=None, realTime=REAL_TIME_TRACKER ) if track_params is None: - exit('Execution aborted') + exit("Execution aborted") print(posData.segm_data.shape) -lab_stack = posData.segm_data[START_FRAME:STOP_FRAME+1] +lab_stack = posData.segm_data[START_FRAME : STOP_FRAME + 1] if SCRUMBLE_IDs: # Scrumble IDs last frame - + last_lab = lab_stack[-1] last_rp = skimage.measure.regionprops(lab_stack[-1]) IDs = [obj.label for obj in last_rp] @@ -106,53 +104,52 @@ obj_to_del = last_rp[random_idx] last_lab[obj_to_del.slice][obj_to_del.image] = 0 -print(f'Tracking data with shape {lab_stack.shape}') +print(f"Tracking data with shape {lab_stack.shape}") trackerInputImage = None -if 'image' in track_params: - trackerInputImage = track_params.pop('image')[START_FRAME:STOP_FRAME+1] +if "image" in track_params: + trackerInputImage = track_params.pop("image")[START_FRAME : STOP_FRAME + 1] -if 'image_channel_name' in track_params: - # Store the channel name for the tracker for loading it +if "image_channel_name" in track_params: + # Store the channel name for the tracker for loading it # in case of multiple pos - track_params.pop('image_channel_name') + track_params.pop("image_channel_name") tracked_stack = core.tracker_track( - lab_stack, tracker, track_params, - intensity_img=trackerInputImage, - logger_func=print + lab_stack, tracker, track_params, intensity_img=trackerInputImage, logger_func=print ) -if hasattr(posData, 'acdc_output_csv_path'): +if hasattr(posData, "acdc_output_csv_path"): posData.fromTrackerToAcdcDf(tracker, tracked_stack, save=True) first_untracked_lab = lab_stack[0] uniqueID = max(np.max(lab_stack), np.max(tracked_stack)) + 1 retracked_video = transformation.retrack_based_on_untracked_first_frame( - tracked_stack.copy(), first_untracked_lab, uniqueID=uniqueID + tracked_stack.copy(), first_untracked_lab, uniqueID=uniqueID ) if SAVE: try: io.savez_compressed( - posData.segm_npz_path.replace('segm', 'segm_tracked'), - tracked_stack + posData.segm_npz_path.replace("segm", "segm_tracked"), tracked_stack ) except Exception as e: - import pdb; pdb.set_trace() + import pdb + + pdb.set_trace() imshow( - posData.loadChannelData(''), + posData.loadChannelData(""), tracked_stack, retracked_video, lab_stack, axis_titles=[ - 'Intensity channel', - 'After tracking', - 'After re-tracking first frame', - 'Before tracking' + "Intensity channel", + "After tracking", + "After re-tracking first frame", + "Before tracking", ], - annotate_labels_idxs=[1, 2, 3] + annotate_labels_idxs=[1, 2, 3], ) diff --git a/cellacdc/trackers/BABY/BABY_tracker.py b/cellacdc/trackers/BABY/BABY_tracker.py index 813f13203..6fa39f464 100644 --- a/cellacdc/trackers/BABY/BABY_tracker.py +++ b/cellacdc/trackers/BABY/BABY_tracker.py @@ -11,105 +11,99 @@ from ..CellACDC import CellACDC_tracker + class AvailableModels: values = BABY.BABY_MODELS + class tracker: def __init__( - self, - model_type: AvailableModels='yeast-alcatras-brightfield-sCMOS-60x-5z', - ): + self, + model_type: AvailableModels = "yeast-alcatras-brightfield-sCMOS-60x-5z", + ): brain = modelsets.get(model_type) self.crawler = BabyCrawler(brain) - + def _preprocess(self, image, swap_YX_axes_to_XY): if image.ndim == 2: image = image[np.newaxis] - image = myutils.to_uint16(image) - - # BABY requires z-slices as last dimension while Cell-ACDC takes + + # BABY requires z-slices as last dimension while Cell-ACDC takes # Z, Y, X input if swap_YX_axes_to_XY: dst_axes = (2, 1, 0) else: - dst_axes = (1, 2, 0) - + dst_axes = (1, 2, 0) + image = image.transpose(dst_axes) - + return image - + def iterate_result_series(self, result_series, swap_YX_axes_to_XY): for frame_i, result in enumerate(result_series): - contour_masks = result[0]['edgemasks'] - IDs = result[0]['cell_label'] + contour_masks = result[0]["edgemasks"] + IDs = result[0]["cell_label"] for ID, contour_mask in zip(IDs, contour_masks): - mask = scipy.ndimage.binary_fill_holes(contour_mask) + mask = scipy.ndimage.binary_fill_holes(contour_mask) if swap_YX_axes_to_XY: - mask = np.swapaxes(mask, 0, 1) + mask = np.swapaxes(mask, 0, 1) yield frame_i, mask, ID - - def track_baby_segm_data( - self, segm_data, result_series, swap_YX_axes_to_XY - ): + + def track_baby_segm_data(self, segm_data, result_series, swap_YX_axes_to_XY): tracked_data = np.zeros_like(segm_data) - result_generator = self.iterate_result_series( - result_series, swap_YX_axes_to_XY - ) - for frame_i, mask, ID in result_generator: + result_generator = self.iterate_result_series(result_series, swap_YX_axes_to_XY) + for frame_i, mask, ID in result_generator: tracked_data[frame_i][mask] = ID return tracked_data - - def track_external_segm_data( - self, segm_data, result_series, swap_YX_axes_to_XY - ): - result_generator = self.iterate_result_series( - result_series, swap_YX_axes_to_XY - ) + + def track_external_segm_data(self, segm_data, result_series, swap_YX_axes_to_XY): + result_generator = self.iterate_result_series(result_series, swap_YX_axes_to_XY) old_IDs_tracks = {} tracked_IDs_tracks = {} - for frame_i, mask, track_ID in result_generator: + for frame_i, mask, track_ID in result_generator: oldID = segm_data[frame_i][mask][0] if oldID == 0: continue - + if frame_i not in old_IDs_tracks: old_IDs_tracks[frame_i] = [oldID] tracked_IDs_tracks[frame_i] = [track_ID] else: old_IDs_tracks[frame_i].append(oldID) tracked_IDs_tracks[frame_i].append(track_ID) - + tracked_data = segm_data.copy() for frame_i in old_IDs_tracks.keys(): tracked_IDs = tracked_IDs_tracks[frame_i] old_IDs = old_IDs_tracks[frame_i] - + lab = self.segm_video[frame_i] rp = skimage.measure.regionprops(lab) IDs_curr_untracked = [obj.label for obj in rp] - - uniqueID = max((max(tracked_IDs), max(IDs_curr_untracked)))+1 + + uniqueID = max((max(tracked_IDs), max(IDs_curr_untracked))) + 1 tracked_lab = CellACDC_tracker.indexAssignment( - old_IDs, tracked_IDs, IDs_curr_untracked, - lab.copy(), rp, uniqueID + old_IDs, tracked_IDs, IDs_curr_untracked, lab.copy(), rp, uniqueID ) tracked_data[frame_i] = tracked_lab - + return tracked_data - + def track( - self, segm_data, intensity_data, - resegment_data=True, - swap_YX_axes_to_XY=True, - refine_outlines=True, - assign_mothers=True, - with_edgemasks=True, - with_volumes=True, - parallel=False, - signals=None - ): + self, + segm_data, + intensity_data, + resegment_data=True, + swap_YX_axes_to_XY=True, + refine_outlines=True, + assign_mothers=True, + with_edgemasks=True, + with_volumes=True, + parallel=False, + signals=None, + ): """_summary_ Parameters @@ -119,33 +113,33 @@ def track( intensity_data : (T, Y, X) or (T, Z, Y, X) Input intensity data resegment_data : bool, optional - If True, BABY will ignore the input `segm_data` and perform - segmentation de novo. - If False, BABY will only track the input `segm_data`. + If True, BABY will ignore the input `segm_data` and perform + segmentation de novo. + If False, BABY will only track the input `segm_data`. Default is True Returns ------- np.ndarray with the same shape as `segm_data` Tracked data - """ + """ image_series = [ self._preprocess(image, swap_YX_axes_to_XY) for image in intensity_data ] - + result_series = [] for image in image_series: result = self.crawler.step( - image[None, ...], + image[None, ...], refine_outlines=refine_outlines, assign_mothers=assign_mothers, with_edgemasks=with_edgemasks, with_volumes=with_volumes, - parallel=parallel + parallel=parallel, ) result_series.append(result) self.updateGuiProgressBar(signals) - + if resegment_data: tracked_data = self.track_baby_segm_data( segm_data, result_series, swap_YX_axes_to_XY @@ -154,20 +148,18 @@ def track( tracked_data = self.track_external_segm_data( segm_data, result_series, swap_YX_axes_to_XY ) - + return tracked_data def updateGuiProgressBar(self, signals): if signals is None: return - - if hasattr(signals, 'innerPbar_available'): + + if hasattr(signals, "innerPbar_available"): if signals.innerPbar_available: # Use inner pbar of the GUI widget (top pbar is for positions) signals.innerProgressBar.emit(1) return - if hasattr(signals, 'progressBar'): + if hasattr(signals, "progressBar"): signals.progressBar.emit(1) - - diff --git a/cellacdc/trackers/BABY/__init__.py b/cellacdc/trackers/BABY/__init__.py index 6e5b1f9bb..546662798 100644 --- a/cellacdc/trackers/BABY/__init__.py +++ b/cellacdc/trackers/BABY/__init__.py @@ -3,6 +3,7 @@ myutils.check_install_baby() from baby import modelsets + meta = modelsets.meta() -BABY_MODELS = list(meta.keys()) \ No newline at end of file +BABY_MODELS = list(meta.keys()) diff --git a/cellacdc/trackers/BayesianTracker/BayesianTracker_tracker.py b/cellacdc/trackers/BayesianTracker/BayesianTracker_tracker.py index 7b55abcca..304e7b71d 100755 --- a/cellacdc/trackers/BayesianTracker/BayesianTracker_tracker.py +++ b/cellacdc/trackers/BayesianTracker/BayesianTracker_tracker.py @@ -15,20 +15,25 @@ from tqdm import tqdm + class tracker: def __init__(self, **params): self.params = params def track( - self, segm_video, image, signals=None, - export_to: os.PathLike=None, verbose=False - ): - FEATURES = self.params['features'] + self, + segm_video, + image, + signals=None, + export_to: os.PathLike = None, + verbose=False, + ): + FEATURES = self.params["features"] if segm_video.ndim == 3: # btrack requires 4D data. Add extra dimension for 3D data segm_video = segm_video[:, np.newaxis, :, :] - + if image is not None: if image.ndim == 3: image = image[:, np.newaxis, :, :] @@ -44,19 +49,18 @@ def track( ) if signals is not None: - signals.progress.emit('Running BayesianTracker...') + signals.progress.emit("Running BayesianTracker...") # initialise a tracker session using a context manager with btrack.BayesianTracker() as tracker: - # configure the tracker using a config file - tracker.configure_from_file(self.params['model_path']) - tracker.verbose = self.params['verbose'] - update_method = self.params['update_method'] + tracker.configure_from_file(self.params["model_path"]) + tracker.verbose = self.params["verbose"] + update_method = self.params["update_method"] - if update_method == 'APPROXIMATE': + if update_method == "APPROXIMATE": tracker.update_method = getattr(BayesianUpdates, update_method) - tracker.max_search_radius = self.params['max_search_radius'] + tracker.max_search_radius = self.params["max_search_radius"] # add features if FEATURES: @@ -66,13 +70,13 @@ def track( tracker.append(obj_from_arr) # set the volume - tracker.volume=self.params['volume'] + tracker.volume = self.params["volume"] # track them (in interactive mode) - tracker.track(step_size=self.params['step_size']) + tracker.track(step_size=self.params["step_size"]) # generate hypotheses and run the global optimizer - if self.params['optimize']: + if self.params["optimize"]: tracker.optimize() # save tracks @@ -88,11 +92,9 @@ def track( ) return tracked_video - def _from_tracks_to_labels( - self, tracks, segm_video, signals=None, verbose=False - ): + def _from_tracks_to_labels(self, tracks, segm_video, signals=None, verbose=False): if signals is not None: - signals.progress.emit('Applying BayesianTracker tracks...') + signals.progress.emit("Applying BayesianTracker tracks...") # Label the segm_video according to tracks tracked_video = np.zeros_like(segm_video) @@ -107,12 +109,12 @@ def _from_tracks_to_labels( tracked_IDs = [] for track in tracks: track_dict = track.to_dict() - if frame_i not in track_dict['t']: + if frame_i not in track_dict["t"]: continue - df = pd.DataFrame(track.to_dict()).set_index('t').loc[frame_i] + df = pd.DataFrame(track.to_dict()).set_index("t").loc[frame_i] - yc, xc = df['y'], df['x'] + yc, xc = df["y"], df["x"] try: old_ID = lab[int(yc), int(xc)] except Exception as e: @@ -122,35 +124,34 @@ def _from_tracks_to_labels( continue old_IDs.append(old_ID) - tracked_IDs.append(df['ID']) + tracked_IDs.append(df["ID"]) if not tracked_IDs: # No cells tracked continue - uniqueID = max((max(tracked_IDs), max(IDs_curr_untracked)))+1 + uniqueID = max((max(tracked_IDs), max(IDs_curr_untracked))) + 1 if verbose: - print('-------------------------') - print(f'Tracking frame n. {frame_i+1}') + print("-------------------------") + print(f"Tracking frame n. {frame_i + 1}") for old_ID, tracked_ID in zip(old_IDs, tracked_IDs): - print(f'Tracking ID {old_ID} --> {tracked_ID}') - print('-------------------------') + print(f"Tracking ID {old_ID} --> {tracked_ID}") + print("-------------------------") tracked_lab = CellACDC_tracker.indexAssignment( - old_IDs, tracked_IDs, IDs_curr_untracked, - lab.copy(), rp, uniqueID + old_IDs, tracked_IDs, IDs_curr_untracked, lab.copy(), rp, uniqueID ) tracked_video[frame_i] = tracked_lab self.updateGuiProgressBar(signals) return tracked_video - + def updateGuiProgressBar(self, signals): if signals is None: return - - if hasattr(signals, 'innerPbar_available'): + + if hasattr(signals, "innerPbar_available"): if signals.innerPbar_available: # Use inner pbar of the GUI widget (top pbar is for positions) signals.innerProgressBar.emit(1) diff --git a/cellacdc/trackers/BayesianTracker/__init__.py b/cellacdc/trackers/BayesianTracker/__init__.py index 8576fda0a..b56a0721d 100755 --- a/cellacdc/trackers/BayesianTracker/__init__.py +++ b/cellacdc/trackers/BayesianTracker/__init__.py @@ -2,9 +2,10 @@ try: import btrack - from cellacdc.myutils import get_package_version - version = get_package_version('btrack') - minor = version.split('.')[1] + from cellacdc.myutils import get_package_version + + version = get_package_version("btrack") + minor = version.split(".")[1] if int(minor) < 5: UPGRADE_BTRACK = True except Exception as e: @@ -13,8 +14,8 @@ from cellacdc import myutils myutils.check_install_package( - 'Bayesian Tracker', - import_pkg_name='btrack', - pypi_name='btrack', - force_upgrade=UPGRADE_BTRACK + "Bayesian Tracker", + import_pkg_name="btrack", + pypi_name="btrack", + force_upgrade=UPGRADE_BTRACK, ) diff --git a/cellacdc/trackers/CellACDC/CellACDC_tracker.py b/cellacdc/trackers/CellACDC/CellACDC_tracker.py index 07cfe841b..9e9b0c98d 100755 --- a/cellacdc/trackers/CellACDC/CellACDC_tracker.py +++ b/cellacdc/trackers/CellACDC/CellACDC_tracker.py @@ -11,8 +11,16 @@ DEBUG = False -def calc_Io_matrix(lab, prev_lab, rp, prev_rp, IDs_curr_untracked=None, - denom:str='area_prev', IDs=None): + +def calc_Io_matrix( + lab, + prev_lab, + rp, + prev_rp, + IDs_curr_untracked=None, + denom: str = "area_prev", + IDs=None, +): # maybe its faster to calculate IoU not via mask but via area1 / (area1 + area2 - intersection) IDs_prev = [] if IDs_curr_untracked is None: @@ -31,12 +39,10 @@ def calc_Io_matrix(lab, prev_lab, rp, prev_rp, IDs_curr_untracked=None, # prev_lab, prev_rp = lab.copy, rp.copy() # lab, rp = prev_lab_temp, prev_rp_temp - if not denom in ['area_prev', 'union']: - raise ValueError( - "Invalid denom value. Use 'area_prev' or 'union'." - ) + if not denom in ["area_prev", "union"]: + raise ValueError("Invalid denom value. Use 'area_prev' or 'union'.") - # prev_label_positions = {ID_prev: np.where(prev_lab == ID_prev)[0] for ID_prev in set(prev_lab) if ID_prev != 0} + # prev_label_positions = {ID_prev: np.where(prev_lab == ID_prev)[0] for ID_prev in set(prev_lab) if ID_prev != 0} # if denom == 'union': # temp_lab = np.zeros(lab.shape, dtype=bool) for j, obj_prev in enumerate(prev_rp): @@ -45,9 +51,9 @@ def calc_Io_matrix(lab, prev_lab, rp, prev_rp, IDs_curr_untracked=None, # if IDs is not None and ID_prev not in IDs: # continue - if denom == 'area_prev': # or denom == 'area_curr': + if denom == "area_prev": # or denom == 'area_curr': denom_val = obj_prev.area - + # Get intersecting IDs between current and object in previous frame intersect_IDs, intersects = np.unique( lab[obj_prev.slice][obj_prev.image], return_counts=True @@ -59,7 +65,7 @@ def calc_Io_matrix(lab, prev_lab, rp, prev_rp, IDs_curr_untracked=None, if I == 0: continue - if denom == 'union': + if denom == "union": obj_curr = rp_mapper[intersect_ID] # temp_lab[obj_prev.slice][obj_prev.image] = True # temp_lab[obj_curr.slice][obj_curr.image] = True @@ -68,16 +74,23 @@ def calc_Io_matrix(lab, prev_lab, rp, prev_rp, IDs_curr_untracked=None, denom_val = obj_prev.area + obj_curr.area - I if denom_val == 0: continue - - idx = idx_mapper[intersect_ID] - IoA = I/denom_val + + idx = idx_mapper[intersect_ID] + IoA = I / denom_val IoA_matrix[idx, j] = IoA return IoA_matrix, IDs_curr_untracked, IDs_prev + def assign( - IoA_matrix, IDs_curr_untracked, IDs_prev, IoA_thresh=0.4, - aggr_track=None, IoA_thresh_aggr=0.4, daughters_list=None, - IDs=None): + IoA_matrix, + IDs_curr_untracked, + IDs_prev, + IoA_thresh=0.4, + aggr_track=None, + IoA_thresh_aggr=0.4, + daughters_list=None, + IDs=None, +): # Determine max IoA between IDs and assign tracked ID if IoA >= IoA_thresh if IoA_matrix.size == 0: return [], [] @@ -88,7 +101,7 @@ def assign( old_IDs = [] if DEBUG: - printl(f'IDs in previous frame: {IDs_prev}') + printl(f"IDs in previous frame: {IDs_prev}") for i, j in enumerate(max_IoA_col_idx): if daughters_list is not None: @@ -102,94 +115,94 @@ def assign( IoA_thresh_temp = IoA_thresh else: IoA_thresh_temp = IoA_thresh - max_IoU = IoA_matrix[i,j] + max_IoU = IoA_matrix[i, j] count = counts_dict[j] if max_IoU >= IoA_thresh_temp: tracked_ID = IDs_prev[j] if count == 1: old_ID = IDs_curr_untracked[i] elif count > 1: - old_ID_idx = IoA_matrix[:,j].argmax() + old_ID_idx = IoA_matrix[:, j].argmax() old_ID = IDs_curr_untracked[old_ID_idx] tracked_IDs.append(tracked_ID) old_IDs.append(old_ID) return old_IDs, tracked_IDs + def log_debugging(what, **kwargs): if not DEBUG: return - - if what == 'start': - printl('----------------START INDEX ASSIGNMENT----------------') + + if what == "start": + printl("----------------START INDEX ASSIGNMENT----------------") printl( - f'Current IDs: {kwargs["IDs_curr_untracked"]}\n' - f'Previous IDs: {kwargs["old_IDs"]}' - ) - if what == 'assign_unique': - assign_unique_new_IDs = kwargs['assign_unique_new_IDs'] - txt = ( - f'Assign new IDs uniquely = {assign_unique_new_IDs}' + f"Current IDs: {kwargs['IDs_curr_untracked']}\n" + f"Previous IDs: {kwargs['old_IDs']}" ) + if what == "assign_unique": + assign_unique_new_IDs = kwargs["assign_unique_new_IDs"] + txt = f"Assign new IDs uniquely = {assign_unique_new_IDs}" printl(txt) - elif what == 'new_untracked_and_assign_unique': - new_untracked_IDs = kwargs['new_untracked_IDs'] - new_tracked_IDs = kwargs['new_tracked_IDs'] - IDs_curr_untracked = kwargs['IDs_curr_untracked'] - old_IDs = kwargs['old_IDs'] + elif what == "new_untracked_and_assign_unique": + new_untracked_IDs = kwargs["new_untracked_IDs"] + new_tracked_IDs = kwargs["new_tracked_IDs"] + IDs_curr_untracked = kwargs["IDs_curr_untracked"] + old_IDs = kwargs["old_IDs"] txt = ( - f'Current IDs: {IDs_curr_untracked}\n' - f'Previous IDs: {old_IDs}\n' - f'New objects that get a new big ID: {new_untracked_IDs}\n' - f'New unique IDs for the new objects: {new_tracked_IDs}' + f"Current IDs: {IDs_curr_untracked}\n" + f"Previous IDs: {old_IDs}\n" + f"New objects that get a new big ID: {new_untracked_IDs}\n" + f"New unique IDs for the new objects: {new_tracked_IDs}" ) printl(txt) - txt = '' + txt = "" for _ID, replacingID in zip(new_untracked_IDs, new_tracked_IDs): - txt = f'{txt}{_ID} --> {replacingID}\n' + txt = f"{txt}{_ID} --> {replacingID}\n" printl(txt) - elif what == 'new_untracked_and_tracked': - new_untracked_IDs = kwargs['new_untracked_IDs'] - new_tracked_IDs = kwargs['new_tracked_IDs'] - new_IDs_in_trackedIDs = kwargs['new_IDs_in_trackedIDs'] - old_IDs = kwargs['old_IDs'] + elif what == "new_untracked_and_tracked": + new_untracked_IDs = kwargs["new_untracked_IDs"] + new_tracked_IDs = kwargs["new_tracked_IDs"] + new_IDs_in_trackedIDs = kwargs["new_IDs_in_trackedIDs"] + old_IDs = kwargs["old_IDs"] txt = ( - f'New tracked IDs that already exists: {new_IDs_in_trackedIDs}\n' - f'Previous IDs: {old_IDs}\n' - f'New objects that get a new big ID: {new_untracked_IDs}\n' - f'New unique IDs for the new objects: {new_tracked_IDs}' + f"New tracked IDs that already exists: {new_IDs_in_trackedIDs}\n" + f"Previous IDs: {old_IDs}\n" + f"New objects that get a new big ID: {new_untracked_IDs}\n" + f"New unique IDs for the new objects: {new_tracked_IDs}" ) printl(txt) - txt = '' + txt = "" for _ID, replacingID in zip(new_IDs_in_trackedIDs, new_tracked_IDs): - txt = f'{txt}{_ID} --> {replacingID}\n' + txt = f"{txt}{_ID} --> {replacingID}\n" printl(txt) - elif what == 'tracked': - old_IDs = kwargs['old_IDs'] - tracked_IDs = kwargs['tracked_IDs'] + elif what == "tracked": + old_IDs = kwargs["old_IDs"] + tracked_IDs = kwargs["tracked_IDs"] txt = ( - f'Old IDs to be tracked: {old_IDs}\n' - f'New IDs replacing old IDs: {tracked_IDs}' + f"Old IDs to be tracked: {old_IDs}\n" + f"New IDs replacing old IDs: {tracked_IDs}" ) printl(txt) - txt = '' + txt = "" for _ID, replacingID in zip(old_IDs, tracked_IDs): - txt = f'{txt}{_ID} --> {replacingID}\n' + txt = f"{txt}{_ID} --> {replacingID}\n" printl(txt) + def indexAssignment( - old_IDs: List[int], - tracked_IDs: List[int], - IDs_curr_untracked: List[int], - lab: 'np.ndarray[int]', - rp: 'regionprops', - uniqueID: int, - remove_untracked=False, - assign_unique_new_IDs=True, - return_assignments=False, - IDs=None - ): - """Replace `old_IDs` in `lab` with `tracked_IDs` while making sure to + old_IDs: List[int], + tracked_IDs: List[int], + IDs_curr_untracked: List[int], + lab: "np.ndarray[int]", + rp: "regionprops", + uniqueID: int, + remove_untracked=False, + assign_unique_new_IDs=True, + return_assignments=False, + IDs=None, +): + """Replace `old_IDs` in `lab` with `tracked_IDs` while making sure to avoid merging IDs. Parameters @@ -205,17 +218,17 @@ def indexAssignment( rp : list of skimage.measure._regionprops.RegionProperties List of RegionProperties of the objects in `lab` uniqueID : int - Starting unique ID that is going to replace those objects whose ID is + Starting unique ID that is going to replace those objects whose ID is not tracked but they might require a new (unique) one to avoid merging. remove_untracked : bool, optional - If True, those objects that were not tracked will be removed. + If True, those objects that were not tracked will be removed. Default is False assign_unique_new_IDs : bool, optional - If True, uses `uniqueID` to replace the ID of the untracked objects. + If True, uses `uniqueID` to replace the ID of the untracked objects. Default is True return_assignments : bool, optional - If True, returns a dictionary where the keys are the untracked - IDs and the values are the unique IDs that replaced untracked IDs. + If True, returns a dictionary where the keys are the untracked + IDs and the values are the unique IDs that replaced untracked IDs. Default is False IDs : list of ints, optional IDs to be used for the calculation of the IoA matrix. If None, @@ -224,97 +237,97 @@ def indexAssignment( Returns ------- tracked_lab : (Y, X) or (Z, Y, X) array of ints - Segmentation masks with IDs replaced according to input tracking + Segmentation masks with IDs replaced according to input tracking information. assignments: dict Returned only if `return_assignments` is True. - """ - log_debugging( - 'start', - IDs_curr_untracked=IDs_curr_untracked, - old_IDs=old_IDs - ) - + """ + log_debugging("start", IDs_curr_untracked=IDs_curr_untracked, old_IDs=old_IDs) + # Replace untracked IDs with tracked IDs and new IDs with increasing num new_untracked_IDs = [ID for ID in IDs_curr_untracked if ID not in old_IDs] tracked_lab = lab assignments = {} - log_debugging( - 'assign_unique', - assign_unique_new_IDs=assign_unique_new_IDs - ) + log_debugging("assign_unique", assign_unique_new_IDs=assign_unique_new_IDs) if new_untracked_IDs and assign_unique_new_IDs: # Relabel new untracked IDs (i.e., new cells) unique IDs if remove_untracked: - new_tracked_IDs = [0]*len(new_untracked_IDs) + new_tracked_IDs = [0] * len(new_untracked_IDs) else: - new_tracked_IDs = [ - uniqueID+i for i in range(len(new_untracked_IDs)) - ] - core.lab_replace_values( - tracked_lab, rp, new_untracked_IDs, new_tracked_IDs - ) + new_tracked_IDs = [uniqueID + i for i in range(len(new_untracked_IDs))] + core.lab_replace_values(tracked_lab, rp, new_untracked_IDs, new_tracked_IDs) assignments.update(dict(zip(new_untracked_IDs, new_tracked_IDs))) log_debugging( - 'new_untracked_and_assign_unique', + "new_untracked_and_assign_unique", IDs_curr_untracked=IDs_curr_untracked, old_IDs=old_IDs, new_untracked_IDs=new_untracked_IDs, - new_tracked_IDs=new_tracked_IDs + new_tracked_IDs=new_tracked_IDs, ) elif new_untracked_IDs and tracked_IDs: # If we don't replace unique new IDs we check that tracked IDs are # not already existing to avoid duplicates - new_IDs_in_trackedIDs = [ - ID for ID in new_untracked_IDs if ID in tracked_IDs - ] - new_tracked_IDs = [ - uniqueID+i for i in range(len(new_IDs_in_trackedIDs)) - ] - core.lab_replace_values( - tracked_lab, rp, new_IDs_in_trackedIDs, new_tracked_IDs - ) + new_IDs_in_trackedIDs = [ID for ID in new_untracked_IDs if ID in tracked_IDs] + new_tracked_IDs = [uniqueID + i for i in range(len(new_IDs_in_trackedIDs))] + core.lab_replace_values(tracked_lab, rp, new_IDs_in_trackedIDs, new_tracked_IDs) assignments.update(dict(zip(new_IDs_in_trackedIDs, new_tracked_IDs))) log_debugging( - 'new_untracked_and_tracked', + "new_untracked_and_tracked", new_IDs_in_trackedIDs=new_IDs_in_trackedIDs, old_IDs=old_IDs, new_untracked_IDs=new_untracked_IDs, - new_tracked_IDs=new_tracked_IDs + new_tracked_IDs=new_tracked_IDs, ) if tracked_IDs: - core.lab_replace_values( - tracked_lab, rp, old_IDs, tracked_IDs, in_place=True - ) + core.lab_replace_values(tracked_lab, rp, old_IDs, tracked_IDs, in_place=True) assignments.update(dict(zip(old_IDs, tracked_IDs))) log_debugging( - 'tracked', + "tracked", tracked_IDs=tracked_IDs, old_IDs=old_IDs, ) if not return_assignments: return tracked_lab - else: + else: return tracked_lab, assignments + def track_frame( - prev_lab, prev_rp, lab, rp, IDs_curr_untracked=None, - unique_ID=None, setBrushID_func=None, posData=None, - assign_unique_new_IDs=True, IoA_thresh=0.4, debug=False, - return_all=False, aggr_track=None, IoA_matrix=None, - IoA_thresh_aggr=None, IDs_prev=None, return_prev_IDs=False, - mother_daughters=None, denom_overlap_matrix = 'area_prev', - IDs=None - ): + prev_lab, + prev_rp, + lab, + rp, + IDs_curr_untracked=None, + unique_ID=None, + setBrushID_func=None, + posData=None, + assign_unique_new_IDs=True, + IoA_thresh=0.4, + debug=False, + return_all=False, + aggr_track=None, + IoA_matrix=None, + IoA_thresh_aggr=None, + IDs_prev=None, + return_prev_IDs=False, + mother_daughters=None, + denom_overlap_matrix="area_prev", + IDs=None, +): if not np.any(lab): # Skip empty frames return lab if IoA_matrix is None: IoA_matrix, IDs_curr_untracked, IDs_prev = calc_Io_matrix( - lab, prev_lab, rp, prev_rp, IDs_curr_untracked=IDs_curr_untracked, - denom=denom_overlap_matrix, IDs=IDs + lab, + prev_lab, + rp, + prev_rp, + IDs_curr_untracked=IDs_curr_untracked, + denom=denom_overlap_matrix, + IDs=IDs, ) daughters_list = [] @@ -323,63 +336,79 @@ def track_frame( daughters_list.extend(daughters) old_IDs, tracked_IDs = assign( - IoA_matrix, IDs_curr_untracked, IDs_prev, - IoA_thresh=IoA_thresh, aggr_track=aggr_track, - IoA_thresh_aggr=IoA_thresh_aggr, daughters_list=daughters_list, + IoA_matrix, + IDs_curr_untracked, + IDs_prev, + IoA_thresh=IoA_thresh, + aggr_track=aggr_track, + IoA_thresh_aggr=IoA_thresh_aggr, + daughters_list=daughters_list, ) - + if posData is None and unique_ID is None: - unique_ID = max( - (max(IDs_prev, default=0), max(IDs_curr_untracked, default=0)) - ) + 1 + unique_ID = ( + max((max(IDs_prev, default=0), max(IDs_curr_untracked, default=0))) + 1 + ) elif unique_ID is None: # Compute starting unique ID setBrushID_func(useCurrentLab=True) - unique_ID = posData.brushID+1 + unique_ID = posData.brushID + 1 if not return_all: tracked_lab = indexAssignment( - old_IDs, tracked_IDs, IDs_curr_untracked, - lab.copy(), rp, unique_ID, + old_IDs, + tracked_IDs, + IDs_curr_untracked, + lab.copy(), + rp, + unique_ID, assign_unique_new_IDs=assign_unique_new_IDs, ) else: tracked_lab, assignments = indexAssignment( - old_IDs, tracked_IDs, IDs_curr_untracked, - lab.copy(), rp, unique_ID, - assign_unique_new_IDs=assign_unique_new_IDs, + old_IDs, + tracked_IDs, + IDs_curr_untracked, + lab.copy(), + rp, + unique_ID, + assign_unique_new_IDs=assign_unique_new_IDs, return_assignments=return_all, ) # old_new_ids = dict(zip(old_IDs, tracked_IDs)) # for now not used, but could be useful in the future - + if return_all: - return tracked_lab, IoA_matrix, assignments, tracked_IDs # remove tracked_IDs and change code in CellACDC_tracker.py if causing problems + return ( + tracked_lab, + IoA_matrix, + assignments, + tracked_IDs, + ) # remove tracked_IDs and change code in CellACDC_tracker.py if causing problems else: return tracked_lab + class tracker: def __init__(self, **params): self.params = params - def track(self, segm_video, signals=None, export_to: os.PathLike=None): + def track(self, segm_video, signals=None, export_to: os.PathLike = None): tracked_video = np.zeros_like(segm_video) - pbar = tqdm(total=len(segm_video), desc='Tracking', ncols=100) + pbar = tqdm(total=len(segm_video), desc="Tracking", ncols=100) for frame_i, lab in enumerate(segm_video): if frame_i == 0: tracked_video[frame_i] = lab pbar.update() continue - prev_lab = tracked_video[frame_i-1] + prev_lab = tracked_video[frame_i - 1] prev_rp = regionprops(prev_lab) rp = regionprops(lab.copy()) - IoA_thresh = self.params.get('IoA_thresh', 0.4) - tracked_lab = track_frame( - prev_lab, prev_rp, lab, rp, IoA_thresh=IoA_thresh - ) + IoA_thresh = self.params.get("IoA_thresh", 0.4) + tracked_lab = track_frame(prev_lab, prev_rp, lab, rp, IoA_thresh=IoA_thresh) tracked_video[frame_i] = tracked_lab self.updateGuiProgressBar(signals) @@ -387,19 +416,19 @@ def track(self, segm_video, signals=None, export_to: os.PathLike=None): pbar.close() # tracked_video = relabel_sequential(tracked_video)[0] return tracked_video - + def updateGuiProgressBar(self, signals): if signals is None: return - - if hasattr(signals, 'innerPbar_available'): + + if hasattr(signals, "innerPbar_available"): if signals.innerPbar_available: # Use inner pbar of the GUI widget (top pbar is for positions) signals.innerProgressBar.emit(1) return - if hasattr(signals, 'progressBar'): + if hasattr(signals, "progressBar"): signals.progressBar.emit(1) def save_output(self): - pass \ No newline at end of file + pass diff --git a/cellacdc/trackers/CellACDC_2steps/CellACDC_2steps_tracker.py b/cellacdc/trackers/CellACDC_2steps/CellACDC_2steps_tracker.py index 4bef3ec46..1cfacfb68 100644 --- a/cellacdc/trackers/CellACDC_2steps/CellACDC_2steps_tracker.py +++ b/cellacdc/trackers/CellACDC_2steps/CellACDC_2steps_tracker.py @@ -14,53 +14,57 @@ from ..CellACDC import CellACDC_tracker + class SearchRangeUnits: - values = ['pixels', 'micrometre'] + values = ["pixels", "micrometre"] + class Integer: not_a_param = True + class tracker: def __init__( - self, - annotate_objects_tracked_second_step=True, - PhysicalSizeX=1.0, - PhysicalSizeY=1.0, - PhysicalSizeZ=1.0, - ): + self, + annotate_objects_tracked_second_step=True, + PhysicalSizeX=1.0, + PhysicalSizeY=1.0, + PhysicalSizeZ=1.0, + ): """Initialize Cell-ACDC two steps tracker Parameters ---------- annotate_objects_tracked_second_step : bool, optional - If True, Cell-ACDC will draw a line on the GUI between the objects - in previous frame that were lost in current frame according to the - first step (based on overlap) and the objects in current frame that - were matched according to the second step (based on search range). + If True, Cell-ACDC will draw a line on the GUI between the objects + in previous frame that were lost in current frame according to the + first step (based on overlap) and the objects in current frame that + were matched according to the second step (based on search range). Default is True PhysicalSizeX : float, optional - Pixel size in the x-direction in 'micrometre/pixel'. This will be + Pixel size in the x-direction in 'micrometre/pixel'. This will be ignored if `search_range_unit` is `pixels`. Default is 1.0 PhysicalSizeY : float, optional - Pixel size in the y-direction in 'micrometre/pixel'. This will be + Pixel size in the y-direction in 'micrometre/pixel'. This will be ignored if `search_range_unit` is `pixels`. Default is 1.0. PhysicalSizeZ : float, optional - Pixel size in the z-direction in 'micrometre/pixel'. This will be - ignored if `search_range_unit` is `pixels`. Default is 1.0. - """ + Pixel size in the z-direction in 'micrometre/pixel'. This will be + ignored if `search_range_unit` is `pixels`. Default is 1.0. + """ self._annot_obj_2nd_step = annotate_objects_tracked_second_step self._pixel_yx_size = (PhysicalSizeY, PhysicalSizeX) self._voxel_zyx_size = (PhysicalSizeZ, PhysicalSizeY, PhysicalSizeX) - + def track( - self, segm_video, - overlap_threshold=0.4, - search_range_unit: SearchRangeUnits='pixels', - lost_IDs_search_range=10, - signals: cellacdc.workers.signals=None, - export_to_extension='.csv', - export_to: os.PathLike=None, - ): + self, + segm_video, + overlap_threshold=0.4, + search_range_unit: SearchRangeUnits = "pixels", + lost_IDs_search_range=10, + signals: cellacdc.workers.signals = None, + export_to_extension=".csv", + export_to: os.PathLike = None, + ): """Track the objects in `segm_video`. Parameters @@ -68,56 +72,59 @@ def track( segm_video : (T, Y, X) or (T, Z, Y, X) array of ints Input segmentation masks to track. overlap_threshold : float, optional - Minimum overlap between objects of two consecutive frames to - consider the object as not new. The overlap is calculated as the - ratio between the intersection between current object and objects - in previous frame and are of the objects in previous frame. - All new objects will undergo a second step of matching based on + Minimum overlap between objects of two consecutive frames to + consider the object as not new. The overlap is calculated as the + ratio between the intersection between current object and objects + in previous frame and are of the objects in previous frame. + All new objects will undergo a second step of matching based on the `lost_IDs_search_range`. Default is 0.4 search_range_unit : {'pixels', 'micrometre'}, optional - Physical unit of the parameter `lost_IDs_search_range`. If - `micrometre`, distances will be converted using the pixel sizes. - See the parameters `PixelSizeX`, `PixelSizeY`, and `PixelSizeZ`. + Physical unit of the parameter `lost_IDs_search_range`. If + `micrometre`, distances will be converted using the pixel sizes. + See the parameters `PixelSizeX`, `PixelSizeY`, and `PixelSizeZ`. Default is 'pixels' lost_IDs_search_range : int, optional - Maximum distance that a new object (according to `overlap_threshold`) - can travel between two consecutive frames to be considered as - potential candidate to match to a lost object. The unit is + Maximum distance that a new object (according to `overlap_threshold`) + can travel between two consecutive frames to be considered as + potential candidate to match to a lost object. The unit is either `pixels` or `micrometre` (see `search_range_unit` parameter). Default is 10 signals : cellacdc.workers.signals, optional - Class with `qtpy.Signal` attributes used to display progress on the + Class with `qtpy.Signal` attributes used to display progress on the GUI (text and progressbars). Default is None export_to_extension : str, optional - Extension of the optional table that will be saved in the tracking + Extension of the optional table that will be saved in the tracking process. Default is '.csv' export_to : os.PathLike, optional Path of the table to export. Default is None - """ + """ tracked_video = np.copy(segm_video) for frame_i, lab in enumerate(segm_video): if frame_i == 0: continue - prev_frame_lab = tracked_video[frame_i-1] + prev_frame_lab = tracked_video[frame_i - 1] tracked_lab, _ = self.track_frame( - prev_frame_lab, lab, + prev_frame_lab, + lab, search_range_unit=search_range_unit, - overlap_threshold=overlap_threshold, - lost_IDs_search_range=lost_IDs_search_range + overlap_threshold=overlap_threshold, + lost_IDs_search_range=lost_IDs_search_range, ) tracked_video[frame_i] = tracked_lab self.updateGuiProgressBar(signals) return tracked_video - + def track_frame( - self, prev_frame_lab, current_frame_lab, - overlap_threshold=0.4, - search_range_unit: SearchRangeUnits='pixels', - lost_IDs_search_range=10, - unique_ID: Integer=None - ): - """Track two consecutive frames in two steps. First step based on - `overlap_threshold` and second step tracks only lost objects to new + self, + prev_frame_lab, + current_frame_lab, + overlap_threshold=0.4, + search_range_unit: SearchRangeUnits = "pixels", + lost_IDs_search_range=10, + unique_ID: Integer = None, + ): + """Track two consecutive frames in two steps. First step based on + `overlap_threshold` and second step tracks only lost objects to new objects detemined at first step. Parameters @@ -127,90 +134,90 @@ def track_frame( current_frame_lab : (Y, X) or (Z, Y, X) array of ints Segmentation masks of the current frame. overlap_threshold : float, optional - Minimum overlap between objects of two consecutive frames to - consider the object as not new. The overlap is calculated as the - ratio between the intersection between current object and objects - in previous frame and are of the objects in previous frame. - All new objects will undergo a second step of matching based on + Minimum overlap between objects of two consecutive frames to + consider the object as not new. The overlap is calculated as the + ratio between the intersection between current object and objects + in previous frame and are of the objects in previous frame. + All new objects will undergo a second step of matching based on the `lost_IDs_search_range`. Default is 0.4 search_range_unit : {'pixels', 'micrometre'}, optional - Physical unit of the parameter `lost_IDs_search_range`. If - `micrometre`, distances will be converted using the pixel sizes. - See the parameters `PixelSizeX`, `PixelSizeY`, and `PixelSizeZ`. + Physical unit of the parameter `lost_IDs_search_range`. If + `micrometre`, distances will be converted using the pixel sizes. + See the parameters `PixelSizeX`, `PixelSizeY`, and `PixelSizeZ`. Default is 'pixels' lost_IDs_search_range : int, optional - Maximum distance that a new object (according to `overlap_threshold`) - can travel between two consecutive frames to be considered as - potential candidate to match to a lost object. The unit is - either `pixels` or `micrometre`and it is set in the + Maximum distance that a new object (according to `overlap_threshold`) + can travel between two consecutive frames to be considered as + potential candidate to match to a lost object. The unit is + either `pixels` or `micrometre`and it is set in the `search_range_unit` parameter. Default is 10 unique_ID : int, optional If not None, uses this as starting ID for all the untracked objects. If None, this will be calculated based on the two input frames. - """ + """ to_track_tracked_objs_2nd_step = None - + prev_rp = skimage.measure.regionprops(prev_frame_lab) curr_rp = skimage.measure.regionprops(current_frame_lab) - + tracked_lab_1st_step = CellACDC_tracker.track_frame( - prev_frame_lab, - prev_rp, - current_frame_lab, - curr_rp, - IoA_thresh=overlap_threshold, - return_prev_IDs=False, - unique_ID=unique_ID + prev_frame_lab, + prev_rp, + current_frame_lab, + curr_rp, + IoA_thresh=overlap_threshold, + return_prev_IDs=False, + unique_ID=unique_ID, ) - + prev_rp_mapper = {obj.label: obj for obj in prev_rp} - + tracked_rp_1st_step = skimage.measure.regionprops(tracked_lab_1st_step) - tracked_rp_1st_step_mapper = { - obj.label: obj for obj in tracked_rp_1st_step - } - + tracked_rp_1st_step_mapper = {obj.label: obj for obj in tracked_rp_1st_step} + lost_rp_mapper = { - obj.label: obj for obj in prev_rp + obj.label: obj + for obj in prev_rp if tracked_rp_1st_step_mapper.get(obj.label) is None } - + if not lost_rp_mapper: return tracked_lab_1st_step, to_track_tracked_objs_2nd_step - + new_rp_mapper = { - obj.label: obj for obj in tracked_rp_1st_step + obj.label: obj + for obj in tracked_rp_1st_step if prev_rp_mapper.get(obj.label) is None } - + if not new_rp_mapper: return tracked_lab_1st_step, to_track_tracked_objs_2nd_step - + ndim = current_frame_lab.ndim lost_IDs_coords = np.zeros((len(lost_rp_mapper), ndim)) lost_IDs_idx_to_obj_mapper = {} for lost_idx, lost_obj in enumerate(lost_rp_mapper.values()): lost_IDs_coords[lost_idx] = lost_obj.centroid lost_IDs_idx_to_obj_mapper[lost_idx] = lost_obj - + new_IDs_coords = np.zeros((len(new_rp_mapper), ndim)) new_IDs_idx_to_obj_mapper = {} for new_idx, new_obj in enumerate(new_rp_mapper.values()): new_IDs_coords[new_idx] = new_obj.centroid new_IDs_idx_to_obj_mapper[new_idx] = new_obj - - if search_range_unit == 'micrometre': + + if search_range_unit == "micrometre": if ndim == 3: scaling = self._voxel_zyx_size else: scaling = self._pixel_yx_size lost_IDs_coords /= scaling new_IDs_coords /= scaling - + diff = lost_IDs_coords[:, np.newaxis] - new_IDs_coords # dist_matrix[i, j] = euclidean_dist(lost_IDs_coords[i], new_IDs_coords[j]) dist_matrix = np.linalg.norm(diff, axis=2) - + assignments = scipy.optimize.linear_sum_assignment(dist_matrix) IDs_to_track = [] tracked_IDs_2nd_step = [] @@ -221,42 +228,36 @@ def track_frame( dist = dist_matrix[i, j] if dist > lost_IDs_search_range: continue - + IDs_to_track.append(new_IDs_idx_to_obj_mapper[j].label) tracked_IDs_2nd_step.append(lost_IDs_idx_to_obj_mapper[i].label) if self._annot_obj_2nd_step: objs_to_track.append(new_IDs_idx_to_obj_mapper[j]) tracked_objs_2nd_step.append(lost_IDs_idx_to_obj_mapper[i]) - + if not IDs_to_track: return tracked_lab_1st_step, to_track_tracked_objs_2nd_step - + tracked_lab_2nd_step = cellacdc.core.lab_replace_values( - tracked_lab_1st_step, + tracked_lab_1st_step, tracked_rp_1st_step, - IDs_to_track, - tracked_IDs_2nd_step + IDs_to_track, + tracked_IDs_2nd_step, ) - + if self._annot_obj_2nd_step: - to_track_tracked_objs_2nd_step = ( - objs_to_track, tracked_objs_2nd_step - ) - + to_track_tracked_objs_2nd_step = (objs_to_track, tracked_objs_2nd_step) + return tracked_lab_2nd_step, to_track_tracked_objs_2nd_step - + def updateGuiProgressBar(self, signals): if signals is None: return - - if hasattr(signals, 'innerPbar_available'): + + if hasattr(signals, "innerPbar_available"): if signals.innerPbar_available: # Use inner pbar of the GUI widget (top pbar is for positions) signals.innerProgressBar.emit(1) return signals.progressBar.emit(1) - - - - \ No newline at end of file diff --git a/cellacdc/trackers/CellACDC_normal_division/CellACDC_normal_division_tracker.py b/cellacdc/trackers/CellACDC_normal_division/CellACDC_normal_division_tracker.py index 8e47215a9..19c962611 100644 --- a/cellacdc/trackers/CellACDC_normal_division/CellACDC_normal_division_tracker.py +++ b/cellacdc/trackers/CellACDC_normal_division/CellACDC_normal_division_tracker.py @@ -24,14 +24,15 @@ # Returns: # - pandas.DataFrame: The filtered DataFrame containing only the specified columns. # """ -# lin_tree_cols = {'generation_num_tree', 'root_ID_tree', -# 'sister_ID_tree', 'parent_ID_tree', -# 'parent_ID_tree', 'emerg_frame_i', +# lin_tree_cols = {'generation_num_tree', 'root_ID_tree', +# 'sister_ID_tree', 'parent_ID_tree', +# 'parent_ID_tree', 'emerg_frame_i', # 'division_frame_i', 'is_history_known'} # sis_cols = {col for col in df.columns if col.startswith('sister_ID_tree')} # lin_tree_cols = lin_tree_cols | sis_cols # return df[list(lin_tree_cols)] + def reorg_sister_cells_for_export(lineage_tree_frame): """ Reorganizes the daughter cells in the lineage tree frame for export. @@ -46,20 +47,23 @@ def reorg_sister_cells_for_export(lineage_tree_frame): """ if lineage_tree_frame.empty: return lineage_tree_frame - - old_sister_columns = {col for col in lineage_tree_frame.columns if col.startswith('sister_ID_tree')} - sister_columns = lineage_tree_frame['sister_ID_tree'].apply(pd.Series) + old_sister_columns = { + col for col in lineage_tree_frame.columns if col.startswith("sister_ID_tree") + } + + sister_columns = lineage_tree_frame["sister_ID_tree"].apply(pd.Series) max_daughter = sister_columns.shape[1] - new_columns = [f'sister_ID_tree_{i}' for i in range(max_daughter)] + new_columns = [f"sister_ID_tree_{i}" for i in range(max_daughter)] lineage_tree_frame = lineage_tree_frame.drop(columns=old_sister_columns) lineage_tree_frame[new_columns] = sister_columns - lineage_tree_frame['sister_ID_tree'] = sister_columns[0] + lineage_tree_frame["sister_ID_tree"] = sister_columns[0] return lineage_tree_frame + # def reorg_sister_cells_inner_func(row): # """ # Reorganizes the sister cells in a row of a DataFrame. Used as an inner function for apply. @@ -71,7 +75,7 @@ def reorg_sister_cells_for_export(lineage_tree_frame): # """ # values = [int(i) for i in row if i not in {0, -1} and not np.isnan(i)] or [-1] -# values = list(set(values)) +# values = list(set(values)) # return values @@ -99,7 +103,10 @@ def reorg_sister_cells_for_export(lineage_tree_frame): # df = checked_reset_index_Cell_ID(df) # return df -def mother_daughter_assign(IoA_matrix, IoA_thresh_daughter, min_daughter, max_daughter, IoA_thresh_instant=None): + +def mother_daughter_assign( + IoA_matrix, IoA_thresh_daughter, min_daughter, max_daughter, IoA_thresh_instant=None +): """ Identifies cells that have not undergone division based on the input IoA matrix. @@ -116,7 +123,7 @@ def mother_daughter_assign(IoA_matrix, IoA_thresh_daughter, min_daughter, max_da """ mother_daughters = [] aggr_track = [] - daughter_range = range(min_daughter, max_daughter+1, 1) + daughter_range = range(min_daughter, max_daughter + 1, 1) instant_accept = [] IoA_thresholded = IoA_matrix >= IoA_thresh_daughter @@ -131,7 +138,7 @@ def mother_daughter_assign(IoA_matrix, IoA_thresh_daughter, min_daughter, max_da if IoA_instant_accept[:, j].any(): instant_accept.append(j) continue - + high_IoA_indices = np.where(IoA_thresholded[:, j])[0] if not high_IoA_indices.size: @@ -146,13 +153,15 @@ def mother_daughter_assign(IoA_matrix, IoA_thresh_daughter, min_daughter, max_da for daughter in daughters: high_IoA_greater_1 = np.count_nonzero(IoA_thresholded[daughter]) > 1 if high_IoA_greater_1: - should_remove_idx.append(True) + should_remove_idx.append(True) break else: should_remove_idx.append(False) - + # printl(f'length of mother_daughters: {len(mother_daughters), len(should_remove_idx)}') - mother_daughters = [mother_daughters[i] for i, remove in enumerate(should_remove_idx) if not remove] + mother_daughters = [ + mother_daughters[i] for i, remove in enumerate(should_remove_idx) if not remove + ] # daughters_li = [] # for _, daughters in mother_daughters: @@ -160,6 +169,7 @@ def mother_daughter_assign(IoA_matrix, IoA_thresh_daughter, min_daughter, max_da return aggr_track, mother_daughters + def added_lineage_tree_to_cca_df(added_lineage_tree): """ Converts the added lineage tree into a DataFrame with specific columns. @@ -187,20 +197,25 @@ def added_lineage_tree_to_cca_df(added_lineage_tree): return pd.DataFrame() # Use zip to unpack columns efficiently - emerg_frame_i, cell_id, parent_id, gen_num, root_id, sister_ids = zip(*added_lineage_tree) - cca_df = pd.DataFrame({ - 'Cell_ID': cell_id, - 'emerg_frame_i': emerg_frame_i, - 'division_frame_i': emerg_frame_i, - 'generation_num_tree': gen_num, - 'parent_ID_tree': parent_id, - 'root_ID_tree': root_id, - 'sister_ID_tree': sister_ids, - }) - cca_df['is_history_known'] = (cca_df['parent_ID_tree'] != -1).astype(int) - cca_df = cca_df.set_index('Cell_ID') + emerg_frame_i, cell_id, parent_id, gen_num, root_id, sister_ids = zip( + *added_lineage_tree + ) + cca_df = pd.DataFrame( + { + "Cell_ID": cell_id, + "emerg_frame_i": emerg_frame_i, + "division_frame_i": emerg_frame_i, + "generation_num_tree": gen_num, + "parent_ID_tree": parent_id, + "root_ID_tree": root_id, + "sister_ID_tree": sister_ids, + } + ) + cca_df["is_history_known"] = (cca_df["parent_ID_tree"] != -1).astype(int) + cca_df = cca_df.set_index("Cell_ID") return cca_df + def filter_current_IDs(df, current_IDs): """ Filters for current IDs. @@ -215,6 +230,7 @@ def filter_current_IDs(df, current_IDs): df = checked_reset_index_Cell_ID(df) return df[df.index.isin(current_IDs)] + def IoA_index_daughter_to_ID(daughters, assignments, IDs_curr_untracked): """ Converts a list of daughter indices (IoA Matrix) to their corresponding IDs. @@ -231,7 +247,7 @@ def IoA_index_daughter_to_ID(daughters, assignments, IDs_curr_untracked): if daughters is None: return - + daughter_IDs = [] for daughter in daughters: if assignments: @@ -241,13 +257,14 @@ def IoA_index_daughter_to_ID(daughters, assignments, IDs_curr_untracked): return daughter_IDs + # def update_fam_dynamically(families, fixed_df, Cell_IDs_fixed=None): # if Cell_IDs_fixed is None: # Cell_IDs_fixed = fixed_df.index # for idx, family in enumerate(families): # # Keep only cellinfos where cell_id is in Cell_IDs_fixed # families[idx] = [cellinfo for cellinfo in family if cellinfo[0] not in Cell_IDs_fixed] - + # families = [family for family in families if family] # Remove empty families # handled_cells = set() # for family in families: @@ -260,14 +277,15 @@ def IoA_index_daughter_to_ID(daughters, assignments, IDs_curr_untracked): # # Update the family with the generation number and root ID # family.append((relevant_cell, relevant_cells.loc[relevant_cell, 'generation_num_tree'])) # handled_cells.update(relevant_cells.index) - + # for cell_id in Cell_IDs_fixed: # if cell_id not in handled_cells: # # If the cell is not handled, create a new family for it # families.append([(cell_id, fixed_df.loc[cell_id, 'generation_num_tree'])]) - + # return families + class normal_division_tracker: """ A class that tracks cell divisions in a video sequence. The tracker uses the Intersection over Area (IoA) metric to track cells and identify daughter cells. @@ -293,13 +311,15 @@ class normal_division_tracker: - track_frame(self, frame_i, lab=None, prev_lab=None, rp=None, prev_rp=None): Tracks a single frame in the video sequence. """ - def __init__(self, - segm_video, - IoA_thresh_daughter, - min_daughter, - max_daughter, - IoA_thresh, - IoA_thresh_aggressive): + def __init__( + self, + segm_video, + IoA_thresh_daughter, + min_daughter, + max_daughter, + IoA_thresh, + IoA_thresh_aggressive, + ): """ Initializes the normal_division_tracker object. @@ -322,8 +342,16 @@ def __init__(self, self.tracked_video = np.zeros_like(segm_video) self.tracked_video[0] = segm_video[0] - def track_frame(self, frame_i, lab=None, prev_lab=None, rp=None, prev_rp=None, - IDs=None, unique_ID=None): + def track_frame( + self, + frame_i, + lab=None, + prev_lab=None, + rp=None, + prev_rp=None, + IDs=None, + unique_ID=None, + ): """ Tracks a single frame in the video sequence. @@ -339,7 +367,7 @@ def track_frame(self, frame_i, lab=None, prev_lab=None, rp=None, prev_rp=None, lab = self.segm_video[frame_i] if prev_lab is None: - prev_lab = self.tracked_video[frame_i-1] + prev_lab = self.tracked_video[frame_i - 1] if rp is None: self.rp = regionprops(lab.copy()) @@ -349,36 +377,39 @@ def track_frame(self, frame_i, lab=None, prev_lab=None, rp=None, prev_rp=None, if prev_rp is None: prev_rp = regionprops(prev_lab.copy()) - IoA_matrix, self.IDs_curr_untracked, self.IDs_prev = calc_Io_matrix(lab, - prev_lab, - self.rp, - prev_rp, - IDs=IDs, - ) - self.aggr_track, self.mother_daughters = mother_daughter_assign(IoA_matrix, - IoA_thresh_daughter=self.IoA_thresh_daughter, - min_daughter=self.min_daughter, - max_daughter=self.max_daughter, - IoA_thresh_instant=self.IoA_thresh - ) - self.tracked_lab, IoA_matrix, self.assignments, _ = track_frame_base(prev_lab, - prev_rp, - lab, - self.rp, - IoA_thresh=self.IoA_thresh, - IoA_matrix=IoA_matrix, - aggr_track=self.aggr_track, - IoA_thresh_aggr=self.IoA_thresh_aggressive, - IDs_curr_untracked=self.IDs_curr_untracked, - IDs_prev=self.IDs_prev, - return_all=True, - mother_daughters=self.mother_daughters, - unique_ID=unique_ID - ) - + IoA_matrix, self.IDs_curr_untracked, self.IDs_prev = calc_Io_matrix( + lab, + prev_lab, + self.rp, + prev_rp, + IDs=IDs, + ) + self.aggr_track, self.mother_daughters = mother_daughter_assign( + IoA_matrix, + IoA_thresh_daughter=self.IoA_thresh_daughter, + min_daughter=self.min_daughter, + max_daughter=self.max_daughter, + IoA_thresh_instant=self.IoA_thresh, + ) + self.tracked_lab, IoA_matrix, self.assignments, _ = track_frame_base( + prev_lab, + prev_rp, + lab, + self.rp, + IoA_thresh=self.IoA_thresh, + IoA_matrix=IoA_matrix, + aggr_track=self.aggr_track, + IoA_thresh_aggr=self.IoA_thresh_aggressive, + IDs_curr_untracked=self.IDs_curr_untracked, + IDs_prev=self.IDs_prev, + return_all=True, + mother_daughters=self.mother_daughters, + unique_ID=unique_ID, + ) self.tracked_video[frame_i] = self.tracked_lab + class normal_division_lineage_tree: """ Class for tracking and managing cell lineage trees during normal cell division across multiple frames. @@ -423,10 +454,18 @@ class normal_division_lineage_tree: export_lin_tree_info(frame_i) Return information about new, orphan, and lost cells between two consecutive frames. - """ + """ - def __init__(self, lab=None, first_df=None, frame_i=0, max_daughter=2, min_daughter=2, IoA_thresh_daughter=0.25, - gui=None): + def __init__( + self, + lab=None, + first_df=None, + frame_i=0, + max_daughter=2, + min_daughter=2, + IoA_thresh_daughter=0.25, + gui=None, + ): """ Initialize the lineage tree for normal cell divisions. @@ -449,30 +488,35 @@ def __init__(self, lab=None, first_df=None, frame_i=0, max_daughter=2, min_daugh self.gui = gui self.max_daughters_added = 0 self.gui_mode = True if gui is not None else False - self.mother_daughters = [] # just for the dict_curr_frame stuff... + self.mother_daughters = [] # just for the dict_curr_frame stuff... self.frames_for_dfs = set([frame_i]) - self.need_update_gen_df = False # this is only when using the quick option in update_gen_df_from_df + self.need_update_gen_df = ( + False # this is only when using the quick option in update_gen_df_from_df + ) self.first_frame_i_for_ID = dict() self.ID_frame_i_lookup = {} - - if self.gui_mode: # part of loading for gui + + if self.gui_mode: # part of loading for gui posData = self.gui.data[self.gui.pos_i] for i, data in enumerate(posData.allData_li): - if 'generation_num_tree' in data['acdc_df'].columns and data['acdc_df']['generation_num_tree'].notna().all(): + if ( + "generation_num_tree" in data["acdc_df"].columns + and data["acdc_df"]["generation_num_tree"].notna().all() + ): self.frames_for_dfs.add(i) - + self.init_lineage_tree(lab, first_df, frame_i) - + def _get_first_frame_i_for_ID(self, ID): if self.gui_mode: posData = self.gui.data[self.gui.pos_i] if ID in self.first_frame_i_for_ID: frame_i = self.first_frame_i_for_ID[ID] - if ID in posData.allData_li[frame_i]['acdc_df'].index: + if ID in posData.allData_li[frame_i]["acdc_df"].index: return frame_i for i, data in enumerate(posData.allData_li): - if ID in data['acdc_df'].index: + if ID in data["acdc_df"].index: self.first_frame_i_for_ID[ID] = i return i else: @@ -484,40 +528,44 @@ def _get_first_frame_i_for_ID(self, ID): if ID in df.index: self.first_frame_i_for_ID[ID] = i return i - - def _get_extra_daughter_cols(self,num_daughters=None): + + def _get_extra_daughter_cols(self, num_daughters=None): if num_daughters is not None and self.max_daughters_added < num_daughters: missing_i = range(self.max_daughters_added, num_daughters) - missing_cols = [f'sister_ID_tree_{i}' for i in missing_i] + missing_cols = [f"sister_ID_tree_{i}" for i in missing_i] self.max_daughters_added = num_daughters if self.gui_mode: posData = self.gui.data[self.gui.pos_i] for frame_i in self.frames_for_dfs: - df = posData.allData_li[frame_i]['acdc_df'] - missing_cols_loc = [col for col in missing_cols if col not in df.columns] + df = posData.allData_li[frame_i]["acdc_df"] + missing_cols_loc = [ + col for col in missing_cols if col not in df.columns + ] df[missing_cols_loc] = -1 else: for df in self.lineage_list: - missing_cols_loc = [col for col in missing_cols if col not in df.columns] + missing_cols_loc = [ + col for col in missing_cols if col not in df.columns + ] df[missing_cols_loc] = -1 - - return [f'sister_ID_tree_{i}' for i in range(self.max_daughters_added)] - + + return [f"sister_ID_tree_{i}" for i in range(self.max_daughters_added)] + def _get_df_from_frame_i(self, frame_i): if self.gui_mode: posData = self.gui.data[self.gui.pos_i] - return posData.allData_li[frame_i]['acdc_df'] + return posData.allData_li[frame_i]["acdc_df"] else: return self.lineage_list[frame_i] - + def _get_row_from_ID(self, ID, start_search_frame_i=None): if ID in self.ID_frame_i_lookup: frame_i = self.ID_frame_i_lookup[ID] if self.gui_mode: posData = self.gui.data[self.gui.pos_i] try: - df = posData.allData_li[frame_i]['acdc_df'] + df = posData.allData_li[frame_i]["acdc_df"] row = df.loc[ID] return row except: @@ -532,15 +580,15 @@ def _get_row_from_ID(self, ID, start_search_frame_i=None): if self.gui_mode: posData = self.gui.data[self.gui.pos_i] if start_search_frame_i is not None: - df = posData.allData_li[start_search_frame_i]['acdc_df'] + df = posData.allData_li[start_search_frame_i]["acdc_df"] if ID in df.index: row = df.loc[ID] self.ID_frame_i_lookup[ID] = start_search_frame_i return row - + for i, data in enumerate(posData.allData_li): - if ID in data['acdc_df'].index: - df = data['acdc_df'] + if ID in data["acdc_df"].index: + df = data["acdc_df"] row = df.loc[ID] self.ID_frame_i_lookup[ID] = i return row @@ -551,14 +599,14 @@ def _get_row_from_ID(self, ID, start_search_frame_i=None): row = df.loc[ID] self.ID_frame_i_lookup[ID] = start_search_frame_i return row - + for i, df in enumerate(self.lineage_list): if ID in df.index: row = df.loc[ID] self.ID_frame_i_lookup[ID] = i return row - raise ValueError(f'ID {ID} not found in any frame.') + raise ValueError(f"ID {ID} not found in any frame.") def init_lineage_tree(self, lab=None, first_df=None, frame_i=None): """ @@ -571,57 +619,69 @@ def init_lineage_tree(self, lab=None, first_df=None, frame_i=None): Raises: ValueError: If both lab and first_df are provided. """ - print('Initializing lineage tree...') + print("Initializing lineage tree...") if lab is not None and lab.any() and first_df: - raise ValueError('Only one of lab and first_df can be provided.') - + raise ValueError("Only one of lab and first_df can be provided.") + if frame_i is None: frame_i = 0 - + if self.gui_mode: cca_df = self._get_df_from_frame_i(frame_i) - if 'parent_ID_tree' in cca_df.columns: + if "parent_ID_tree" in cca_df.columns: return - cca_df['emerg_frame_i'] = cca_df['division_frame_i'] = frame_i - cca_df['generation_num_tree'] = 1 - cca_df['parent_ID_tree'] = -1 - cca_df['is_history_known'] = (cca_df['parent_ID_tree'] != -1).astype(int) - cca_df['root_ID_tree'] = cca_df.index - cca_df['sister_ID_tree'] = -1 + cca_df["emerg_frame_i"] = cca_df["division_frame_i"] = frame_i + cca_df["generation_num_tree"] = 1 + cca_df["parent_ID_tree"] = -1 + cca_df["is_history_known"] = (cca_df["parent_ID_tree"] != -1).astype(int) + cca_df["root_ID_tree"] = cca_df.index + cca_df["sister_ID_tree"] = -1 cca_df[self._get_extra_daughter_cols()] = -1 - + return - - if lab is not None: + if lab is not None: rp = regionprops(lab) labels = [obj.label for obj in rp] - cca_df = pd.DataFrame({ - 'Cell_ID': labels, - }) - cca_df = cca_df.set_index('Cell_ID') + cca_df = pd.DataFrame( + { + "Cell_ID": labels, + } + ) + cca_df = cca_df.set_index("Cell_ID") # check if the cca_df already has the lineage columns - cca_df['emerg_frame_i'] = cca_df['division_frame_i'] = frame_i - cca_df['generation_num_tree'] = 1 - cca_df['parent_ID_tree'] = -1 - cca_df['is_history_known'] = (cca_df['parent_ID_tree'] != -1).astype(int) - cca_df['root_ID_tree'] = cca_df.index - - cca_df['sister_ID_tree'] = [[-1] * (self.max_daughter-1) for _ in range(len(cca_df))] + cca_df["emerg_frame_i"] = cca_df["division_frame_i"] = frame_i + cca_df["generation_num_tree"] = 1 + cca_df["parent_ID_tree"] = -1 + cca_df["is_history_known"] = (cca_df["parent_ID_tree"] != -1).astype(int) + cca_df["root_ID_tree"] = cca_df.index + + cca_df["sister_ID_tree"] = [ + [-1] * (self.max_daughter - 1) for _ in range(len(cca_df)) + ] cca_df = checked_reset_index_Cell_ID(cca_df) self.lineage_list = [cca_df] - elif first_df is not None and not first_df.empty: if self.gui_mode: # not yet implemented - raise NotImplementedError('Initializing lineage tree with a DataFrame is not yet implemented in GUI mode.') + raise NotImplementedError( + "Initializing lineage tree with a DataFrame is not yet implemented in GUI mode." + ) first_df = checked_reset_index_Cell_ID(first_df) self.lineage_list = [first_df] - - def add_new_frame(self, frame_i, mother_daughters, IDs_prev, - IDs_curr_untracked, assignments, curr_IDs, new_IDs): + + def add_new_frame( + self, + frame_i, + mother_daughters, + IDs_prev, + IDs_curr_untracked, + assignments, + curr_IDs, + new_IDs, + ): """ Add a new frame to the lineage tree, updating families and tracking new and divided cells. @@ -641,12 +701,14 @@ def add_new_frame(self, frame_i, mother_daughters, IDs_prev, added_lineage_tree = [] else: posData = self.gui.data[self.gui.pos_i] - cca_df = posData.allData_li[frame_i]['acdc_df'] + cca_df = posData.allData_li[frame_i]["acdc_df"] daughter_dict = {} daughter_set = set() for mother, daughters in mother_daughters: - daughter_IDs = IoA_index_daughter_to_ID(daughters, assignments, IDs_curr_untracked) + daughter_IDs = IoA_index_daughter_to_ID( + daughters, assignments, IDs_curr_untracked + ) daughter_dict[mother] = daughter_IDs daughter_set.update(set(daughter_IDs)) @@ -654,19 +716,21 @@ def add_new_frame(self, frame_i, mother_daughters, IDs_prev, if not self.gui_mode: for ID in new_unknown_IDs: - added_lineage_tree.append((frame_i, ID, -1, 1, ID, [-1] * (self.max_daughter-1))) + added_lineage_tree.append( + (frame_i, ID, -1, 1, ID, [-1] * (self.max_daughter - 1)) + ) else: relevant_rows = cca_df.index.isin(new_unknown_IDs) - cca_df.loc[relevant_rows, 'generation_num_tree'] = 1 - cca_df.loc[relevant_rows, 'parent_ID_tree'] = -1 - cca_df.loc[relevant_rows, 'emerg_frame_i'] = frame_i - cca_df.loc[relevant_rows, 'division_frame_i'] = frame_i - cca_df.loc[relevant_rows, 'sister_ID_tree'] = -1 - cca_df.loc[relevant_rows, 'root_ID_tree'] = cca_df.index[relevant_rows] + cca_df.loc[relevant_rows, "generation_num_tree"] = 1 + cca_df.loc[relevant_rows, "parent_ID_tree"] = -1 + cca_df.loc[relevant_rows, "emerg_frame_i"] = frame_i + cca_df.loc[relevant_rows, "division_frame_i"] = frame_i + cca_df.loc[relevant_rows, "sister_ID_tree"] = -1 + cca_df.loc[relevant_rows, "root_ID_tree"] = cca_df.index[relevant_rows] cca_df.loc[relevant_rows, self._get_extra_daughter_cols()] = -1 - cca_df.loc[relevant_rows, 'is_history_known'] = False - + cca_df.loc[relevant_rows, "is_history_known"] = False + for mother, _ in mother_daughters: mother_ID = IDs_prev[mother] daughter_IDs = daughter_dict[mother] @@ -675,19 +739,37 @@ def add_new_frame(self, frame_i, mother_daughters, IDs_prev, for daughter_ID in daughter_IDs: daughter_IDs_copy = daughter_IDs.copy() daughter_IDs_copy.remove(daughter_ID) - daughter_IDs_copy = daughter_IDs_copy + [-1] * (self.max_daughters_added - len(daughter_IDs_copy)) + daughter_IDs_copy = daughter_IDs_copy + [-1] * ( + self.max_daughters_added - len(daughter_IDs_copy) + ) if not self.gui_mode: - added_lineage_tree.append((frame_i, daughter_ID, mother_ID, mother_row['generation_num_tree'] + 1, - mother_ID, daughter_IDs_copy)) + added_lineage_tree.append( + ( + frame_i, + daughter_ID, + mother_ID, + mother_row["generation_num_tree"] + 1, + mother_ID, + daughter_IDs_copy, + ) + ) else: - cca_df.loc[daughter_ID, 'generation_num_tree'] = mother_row['generation_num_tree'] + 1 - cca_df.loc[daughter_ID, 'parent_ID_tree'] = mother_ID - cca_df.loc[daughter_ID, 'emerg_frame_i'] = frame_i - cca_df.loc[daughter_ID, 'division_frame_i'] = frame_i - cca_df.loc[daughter_ID, 'sister_ID_tree'] = daughter_IDs_copy[0] # here we dont need to consider the possibility that the sister is already gone, as its the first frame where the daughters appeared - cca_df.loc[daughter_ID, 'root_ID_tree'] = mother_row['root_ID_tree'] - cca_df.loc[daughter_ID, 'is_history_known'] = True - for i, extra_col in enumerate(self._get_extra_daughter_cols(num_daughters=len(daughter_IDs_copy))): + cca_df.loc[daughter_ID, "generation_num_tree"] = ( + mother_row["generation_num_tree"] + 1 + ) + cca_df.loc[daughter_ID, "parent_ID_tree"] = mother_ID + cca_df.loc[daughter_ID, "emerg_frame_i"] = frame_i + cca_df.loc[daughter_ID, "division_frame_i"] = frame_i + cca_df.loc[daughter_ID, "sister_ID_tree"] = daughter_IDs_copy[ + 0 + ] # here we dont need to consider the possibility that the sister is already gone, as its the first frame where the daughters appeared + cca_df.loc[daughter_ID, "root_ID_tree"] = mother_row["root_ID_tree"] + cca_df.loc[daughter_ID, "is_history_known"] = True + for i, extra_col in enumerate( + self._get_extra_daughter_cols( + num_daughters=len(daughter_IDs_copy) + ) + ): cca_df.loc[daughter_ID, extra_col] = daughter_IDs_copy[i] # copy over old lineage info @@ -701,18 +783,27 @@ def add_new_frame(self, frame_i, mother_daughters, IDs_prev, except IndexError: len_lineage_list = len(self.lineage_list) if frame_i >= len_lineage_list: - self.lineage_list.extend([pd.DataFrame()] * (frame_i + 1 - len_lineage_list)) + self.lineage_list.extend( + [pd.DataFrame()] * (frame_i + 1 - len_lineage_list) + ) self.lineage_list[frame_i] = cca_df else: - prev_df = self._get_df_from_frame_i(frame_i-1) + prev_df = self._get_df_from_frame_i(frame_i - 1) same_IDs = prev_df.index.intersection(cca_df.index) - columns = ['generation_num_tree', 'parent_ID_tree', - 'emerg_frame_i', 'division_frame_i', - 'root_ID_tree', 'sister_ID_tree', 'is_history_known'] - cca_df.loc[same_IDs, columns] = prev_df.loc[same_IDs, - columns].values - cca_df.loc[same_IDs, self._get_extra_daughter_cols()] = prev_df.loc[same_IDs, self._get_extra_daughter_cols()].values + columns = [ + "generation_num_tree", + "parent_ID_tree", + "emerg_frame_i", + "division_frame_i", + "root_ID_tree", + "sister_ID_tree", + "is_history_known", + ] + cca_df.loc[same_IDs, columns] = prev_df.loc[same_IDs, columns].values + cca_df.loc[same_IDs, self._get_extra_daughter_cols()] = prev_df.loc[ + same_IDs, self._get_extra_daughter_cols() + ].values self.frames_for_dfs.add(frame_i) def real_time(self, frame_i, lab, prev_lab, rp=None, prev_rp=None): @@ -735,13 +826,16 @@ def real_time(self, frame_i, lab, prev_lab, rp=None, prev_rp=None): if prev_rp is None: prev_rp = regionprops(prev_lab) - IoA_matrix, self.IDs_curr_untracked, self.IDs_prev = calc_Io_matrix(lab, prev_lab, rp, prev_rp) - - _, self.mother_daughters = mother_daughter_assign(IoA_matrix, - IoA_thresh_daughter=self.IoA_thresh_daughter, - min_daughter=self.min_daughter, - max_daughter=self.max_daughter - ) + IoA_matrix, self.IDs_curr_untracked, self.IDs_prev = calc_Io_matrix( + lab, prev_lab, rp, prev_rp + ) + + _, self.mother_daughters = mother_daughter_assign( + IoA_matrix, + IoA_thresh_daughter=self.IoA_thresh_daughter, + min_daughter=self.min_daughter, + max_daughter=self.max_daughter, + ) # filter mothers which are actually tracked/present (could be after user correction in the GUI) filtered_mother_daughters = [] for mother, daughters in self.mother_daughters: @@ -749,12 +843,20 @@ def real_time(self, frame_i, lab, prev_lab, rp=None, prev_rp=None): if mother_ID not in self._get_df_from_frame_i(frame_i).index: filtered_mother_daughters.append((mother, daughters)) self.mother_daughters = filtered_mother_daughters - + curr_IDs = set(self.IDs_curr_untracked) prev_IDs = {obj.label for obj in prev_rp} new_IDs = curr_IDs - prev_IDs self.frames_for_dfs.add(frame_i) - self.add_new_frame(frame_i, self.mother_daughters, self.IDs_prev, self.IDs_curr_untracked, None, curr_IDs, new_IDs) + self.add_new_frame( + frame_i, + self.mother_daughters, + self.IDs_prev, + self.IDs_curr_untracked, + None, + curr_IDs, + new_IDs, + ) def update_df_li_locally(self, df, frame_i): """ @@ -772,79 +874,98 @@ def update_df_li_locally(self, df, frame_i): # we first need to correct generation_num_tree, root_ID_tree, sister_ID_tree if not self.gui_mode: - df = checked_reset_index(df) + df = checked_reset_index(df) corrected_rows = [] for _, Cell_info in df.iterrows(): - if Cell_info['parent_ID_tree'] == -1: - Cell_info['generation_num_tree'] = 1 - Cell_info['root_ID_tree'] = Cell_info['Cell_ID'] - Cell_info['sister_ID_tree'] = [-1] - Cell_info['is_history_known'] = False + if Cell_info["parent_ID_tree"] == -1: + Cell_info["generation_num_tree"] = 1 + Cell_info["root_ID_tree"] = Cell_info["Cell_ID"] + Cell_info["sister_ID_tree"] = [-1] + Cell_info["is_history_known"] = False corrected_rows.append(Cell_info) continue - Cell_info['is_history_known'] = True + Cell_info["is_history_known"] = True - parent_ID = Cell_info['parent_ID_tree'] + parent_ID = Cell_info["parent_ID_tree"] parent_line = self._get_row_from_ID(parent_ID) - Cell_info['generation_num_tree'] = int(parent_line['generation_num_tree']) + 1 - Cell_info['root_ID_tree'] = parent_line['root_ID_tree'] + Cell_info["generation_num_tree"] = ( + int(parent_line["generation_num_tree"]) + 1 + ) + Cell_info["root_ID_tree"] = parent_line["root_ID_tree"] - first_frame_i = self._get_first_frame_i_for_ID(Cell_info['Cell_ID']) + first_frame_i = self._get_first_frame_i_for_ID(Cell_info["Cell_ID"]) df_sisters = self._get_df_from_frame_i(first_frame_i) - sisters = set(df_sisters.loc[df_sisters['parent_ID_tree'] == parent_ID, 'Cell_ID']) - sisters.discard(Cell_info['Cell_ID']) - Cell_info['sister_ID_tree'] = list(sisters) if sisters else [-1] + sisters = set( + df_sisters.loc[df_sisters["parent_ID_tree"] == parent_ID, "Cell_ID"] + ) + sisters.discard(Cell_info["Cell_ID"]) + Cell_info["sister_ID_tree"] = list(sisters) if sisters else [-1] corrected_rows.append(Cell_info) - corrected_df = pd.DataFrame(corrected_rows).set_index('Cell_ID') + corrected_df = pd.DataFrame(corrected_rows).set_index("Cell_ID") self.lineage_list[frame_i] = corrected_df else: posData = self.gui.data[self.gui.pos_i] - df_data = posData.allData_li[frame_i]['acdc_df'] + df_data = posData.allData_li[frame_i]["acdc_df"] df = checked_reset_index_Cell_ID(df) if set(df.index) != set(df_data.index): - raise ValueError('In GUI mode, the DataFrame index must be Cell_ID for lineage updates to work.') - + raise ValueError( + "In GUI mode, the DataFrame index must be Cell_ID for lineage updates to work." + ) + for ID, Cell_info in df.iterrows(): cell_row = df_data.loc[ID] - if Cell_info['parent_ID_tree'] == -1: - df.loc[ID, ['generation_num_tree', 'root_ID_tree', - 'sister_ID_tree', 'is_history_known', - 'parent_ID_tree']] = [1, ID, -1, False, -1] + if Cell_info["parent_ID_tree"] == -1: + df.loc[ + ID, + [ + "generation_num_tree", + "root_ID_tree", + "sister_ID_tree", + "is_history_known", + "parent_ID_tree", + ], + ] = [1, ID, -1, False, -1] df.loc[ID, self._get_extra_daughter_cols()] = -1 continue - cell_row['is_history_known'] = True + cell_row["is_history_known"] = True - parent_ID = Cell_info['parent_ID_tree'] + parent_ID = Cell_info["parent_ID_tree"] parent_line = self._get_row_from_ID(parent_ID) - cell_row['generation_num_tree'] = int(parent_line['generation_num_tree']) + 1 - cell_row['root_ID_tree'] = parent_line['root_ID_tree'] + cell_row["generation_num_tree"] = ( + int(parent_line["generation_num_tree"]) + 1 + ) + cell_row["root_ID_tree"] = parent_line["root_ID_tree"] first_frame_i = self._get_first_frame_i_for_ID(ID) df_sisters = self._get_df_from_frame_i(first_frame_i) - - sisters = set(df_sisters.loc[df_sisters['parent_ID_tree'] == parent_ID].index) + + sisters = set( + df_sisters.loc[df_sisters["parent_ID_tree"] == parent_ID].index + ) sisters.discard(ID) sisters = list(sisters) - cell_row['sister_ID_tree'] = sisters[0] if sisters else -1 + cell_row["sister_ID_tree"] = sisters[0] if sisters else -1 sisters = sisters + [-1] * (self.max_daughters_added - len(sisters)) cols = self._get_extra_daughter_cols(num_daughters=len(sisters)) for col in cols: if col not in cell_row.index: cell_row[col] = -1 - cell_row[self._get_extra_daughter_cols(num_daughters=len(sisters))] = sisters - + cell_row[self._get_extra_daughter_cols(num_daughters=len(sisters))] = ( + sisters + ) df_data.loc[ID] = cell_row + # This will probably be made obsolete by the gui_mode version - # def insert_lineage_df(self, lineage_df, frame_i, update_fams=True, - # consider_children=True, raw_input=False, propagate=True, + # def insert_lineage_df(self, lineage_df, frame_i, update_fams=True, + # consider_children=True, raw_input=False, propagate=True, # relevant_cells=None): # """ # Insert or replace a lineage DataFrame at a given frame index, optionally updating families and propagating changes. @@ -893,7 +1014,6 @@ def update_df_li_locally(self, df, frame_i): # else: # self.lineage_list = out - # elif frame_i > len_lineage_list: # printl(f'WARNING: Frame_i {frame_i} was inserted. The lineage list was only {len(self.lineage_list)} frames long, so the last known lineage tree was copy pasted up to frame_i {frame_i}') @@ -916,9 +1036,14 @@ def update_df_li_locally(self, df, frame_i): # self.lineage_list, self.families = out # else: # self.lineage_list = out - - def _update_consistency(self, fixed_frame_i=None, fixed_df=None, - Cell_IDs_fixed=None, consider_children=True): + + def _update_consistency( + self, + fixed_frame_i=None, + fixed_df=None, + Cell_IDs_fixed=None, + consider_children=True, + ): """ Updates the consistency of lineage information across a list of DataFrames representing cell tracking over time. @@ -946,66 +1071,115 @@ def _update_consistency(self, fixed_frame_i=None, fixed_df=None, - The function maintains a lookup dictionary and a list of fixed DataFrames to efficiently propagate updates. - Sister IDs are stored as sets, excluding the cell's own ID; if a cell has no sisters, the value is set to {-1}. """ - columns_to_replace = ['generation_num_tree', - 'root_ID_tree', - 'sister_ID_tree', - 'parent_ID_tree'] - + columns_to_replace = [ + "generation_num_tree", + "root_ID_tree", + "sister_ID_tree", + "parent_ID_tree", + ] + if fixed_df is not None: fixed_df = checked_reset_index_Cell_ID(fixed_df) elif fixed_frame_i is not None: if self.gui_mode: posData = self.gui.data[self.gui.pos_i] - fixed_df = checked_reset_index_Cell_ID(posData.allData_li[fixed_frame_i]['acdc_df']) + fixed_df = checked_reset_index_Cell_ID( + posData.allData_li[fixed_frame_i]["acdc_df"] + ) else: fixed_df = checked_reset_index_Cell_ID(self.lineage_list[fixed_frame_i]) else: - raise ValueError('Either fixed_frame_i or fixed_df must be provided.') + raise ValueError("Either fixed_frame_i or fixed_df must be provided.") - if Cell_IDs_fixed is not None: # if we have a list of Cell_IDs to consider + if Cell_IDs_fixed is not None: # if we have a list of Cell_IDs to consider fixed_df = fixed_df[fixed_df.index.isin(Cell_IDs_fixed)] - else: + else: Cell_IDs_fixed = fixed_df.index fixed_dfs = [fixed_df] fixed_dfs_lookup = {fixed_df.index[i]: 0 for i in range(len(fixed_df))} - Cell_IDs_fixed = set(Cell_IDs_fixed) # we convert to a set for faster lookups - - df_li = self.lineage_list if not self.gui_mode else [posData.allData_li[i]['acdc_df'] for i in range(len(posData.allData_li))] + Cell_IDs_fixed = set(Cell_IDs_fixed) # we convert to a set for faster lookups + + df_li = ( + self.lineage_list + if not self.gui_mode + else [ + posData.allData_li[i]["acdc_df"] for i in range(len(posData.allData_li)) + ] + ) for frame_df in df_li: - if 'generation_num_tree' not in frame_df.columns or (not frame_df['generation_num_tree'].notna().any()): + if "generation_num_tree" not in frame_df.columns or ( + not frame_df["generation_num_tree"].notna().any() + ): continue frame_df = checked_reset_index_Cell_ID(frame_df) if consider_children: - children = frame_df[frame_df['parent_ID_tree'].isin(Cell_IDs_fixed)] + children = frame_df[frame_df["parent_ID_tree"].isin(Cell_IDs_fixed)] if not children.empty: - for parent_ID, children in children.groupby('parent_ID_tree'): - parent_cell_loc = fixed_dfs_lookup[parent_ID] # we get the parent cell from the lookup dictionary + for parent_ID, children in children.groupby("parent_ID_tree"): + parent_cell_loc = fixed_dfs_lookup[ + parent_ID + ] # we get the parent cell from the lookup dictionary parent_line = fixed_dfs[parent_cell_loc].loc[parent_ID] - children['root_ID_tree'] = parent_line['root_ID_tree'] - children['generation_num_tree'] = parent_line['generation_num_tree'] + 1 - first_frame_i = self._get_first_frame_i_for_ID(children.index[0]) + children["root_ID_tree"] = parent_line["root_ID_tree"] + children["generation_num_tree"] = ( + parent_line["generation_num_tree"] + 1 + ) + first_frame_i = self._get_first_frame_i_for_ID( + children.index[0] + ) df_sisters = self._get_df_from_frame_i(first_frame_i) if self.gui_mode: - sisters = set(df_sisters.loc[df_sisters['parent_ID_tree'] == parent_ID].index) + sisters = set( + df_sisters.loc[ + df_sisters["parent_ID_tree"] == parent_ID + ].index + ) for child in children.index: - child_sisters = [s for s in sisters if s != child] if len(sisters) > 1 else [-1] - child_sisters = child_sisters + [-1] * (self.max_daughters_added - len(child_sisters)) - children.loc[child, 'sister_ID_tree'] = child_sisters[0] if child_sisters else -1 - children.loc[child, self._get_extra_daughter_cols(num_daughters=len(child_sisters))] = child_sisters + child_sisters = ( + [s for s in sisters if s != child] + if len(sisters) > 1 + else [-1] + ) + child_sisters = child_sisters + [-1] * ( + self.max_daughters_added - len(child_sisters) + ) + children.loc[child, "sister_ID_tree"] = ( + child_sisters[0] if child_sisters else -1 + ) + children.loc[ + child, + self._get_extra_daughter_cols( + num_daughters=len(child_sisters) + ), + ] = child_sisters else: - sisters = set(df_sisters.loc[df_sisters['parent_ID_tree'] == parent_ID, 'Cell_ID']) - children['sister_ID_tree'] = [ - [s for s in sisters if s != cell_id] if len(sisters) > 1 else [-1] + sisters = set( + df_sisters.loc[ + df_sisters["parent_ID_tree"] == parent_ID, "Cell_ID" + ] + ) + children["sister_ID_tree"] = [ + [s for s in sisters if s != cell_id] + if len(sisters) > 1 + else [-1] for cell_id in children.index ] - Cell_IDs_fixed = Cell_IDs_fixed.union(children.index) # we add the children IDs to the set of Cell_IDs_fixed - fixed_dfs.append(children) # we append the children to the fixed_dfs list - indx = len(fixed_dfs) - 1 # we get the index of the children in the fixed_dfs list - fixed_dfs_lookup.update({children.index[i]: indx for i in range(len(children))}) # we update the lookup dictionary with the children + Cell_IDs_fixed = Cell_IDs_fixed.union( + children.index + ) # we add the children IDs to the set of Cell_IDs_fixed + fixed_dfs.append( + children + ) # we append the children to the fixed_dfs list + indx = ( + len(fixed_dfs) - 1 + ) # we get the index of the children in the fixed_dfs list + fixed_dfs_lookup.update( + {children.index[i]: indx for i in range(len(children))} + ) # we update the lookup dictionary with the children relevant_cells_mask = frame_df.index.isin(Cell_IDs_fixed) if not relevant_cells_mask.any(): @@ -1021,7 +1195,9 @@ def _update_consistency(self, fixed_frame_i=None, fixed_df=None, # Find the intersection of indices common_idx = frame_df.index.intersection(fixed_df.index) if not common_idx.empty: - frame_df.loc[common_idx, columns_to_replace] = fixed_df.loc[common_idx, columns_to_replace] + frame_df.loc[common_idx, columns_to_replace] = fixed_df.loc[ + common_idx, columns_to_replace + ] def propagate(self, frame_i, relevant_cells=None): """ @@ -1036,12 +1212,13 @@ def propagate(self, frame_i, relevant_cells=None): """ if self.gui_mode: posData = self.gui.data[self.gui.pos_i] - lineage_df = posData.allData_li[frame_i]['acdc_df'] + lineage_df = posData.allData_li[frame_i]["acdc_df"] else: lineage_df = self.lineage_list[frame_i] self.update_df_li_locally(lineage_df, frame_i) - self._update_consistency(fixed_frame_i=frame_i, - consider_children=True, Cell_IDs_fixed=relevant_cells) + self._update_consistency( + fixed_frame_i=frame_i, consider_children=True, Cell_IDs_fixed=relevant_cells + ) # This will probably be made obsolete by the gui_mode version # def load_lineage_df_list(self, df_li): @@ -1066,11 +1243,11 @@ def propagate(self, frame_i, relevant_cells=None): # for i, df in enumerate(df_li): # if df is None: # continue - + # if 'generation_num_tree' not in df.columns: # continue - # mask = (df['generation_num_tree'].isnull() | + # mask = (df['generation_num_tree'].isnull() | # df["generation_num_tree"].isna()) # if mask.any() or df["generation_num_tree"].empty: @@ -1083,7 +1260,7 @@ def propagate(self, frame_i, relevant_cells=None): # self.frames_for_dfs.add(i) # df_li_new.append(df) - # df_filter = df.index.isin(added_IDs) + # df_filter = df.index.isin(added_IDs) # for root_ID, group in df[df_filter].groupby('root_ID_tree'): # if root_ID not in families_root_IDs: # family = list(zip(group.index, group['generation_num_tree'])) @@ -1093,9 +1270,9 @@ def propagate(self, frame_i, relevant_cells=None): # # If the root_ID is already in families, we just update the family with the new cells # family_index = families_root_IDs.index(root_ID) # families[family_index].extend(zip(group.index, group['generation_num_tree'])) - + # added_IDs.update(group.index) - + # if df_li_new: # self.lineage_list = df_li_new @@ -1113,7 +1290,7 @@ def export_df(self, frame_i): df = self.lineage_list[frame_i].copy() if df.empty: - print(f'Warning: No dataframe for frame {frame_i} found.') + print(f"Warning: No dataframe for frame {frame_i} found.") df = reorg_sister_cells_for_export(df) @@ -1128,7 +1305,7 @@ def export_df(self, frame_i): df = df.drop(columns="frame_i") return df - + def export_lin_tree_info(self, frame_i): """ Return information about new, orphan, and lost cells between two consecutive frames. @@ -1144,17 +1321,17 @@ def export_lin_tree_info(self, frame_i): """ if frame_i == 0: return [], [], [] - + if not self.gui_mode: df_curr = self.lineage_list[frame_i] df_curr = checked_reset_index_Cell_ID(df_curr) - df_prev = self.lineage_list[frame_i-1] + df_prev = self.lineage_list[frame_i - 1] df_prev = checked_reset_index_Cell_ID(df_prev) - + else: posData = self.gui.data[self.gui.pos_i] - df_curr = posData.allData_li[frame_i]['acdc_df'] - df_prev = posData.allData_li[frame_i-1]['acdc_df'] + df_curr = posData.allData_li[frame_i]["acdc_df"] + df_prev = posData.allData_li[frame_i - 1]["acdc_df"] new_cells = set(df_curr.index) - set(df_prev.index) lost_cells = set(df_prev.index) - set(df_curr.index) @@ -1166,9 +1343,9 @@ def export_lin_tree_info(self, frame_i): for cell in new_cells: cell_row = df_curr.loc[cell] try: - mother = cell_row['parent_ID_tree'] + mother = cell_row["parent_ID_tree"] except KeyError: - mother = -1 # check for nan mother + mother = -1 # check for nan mother if mother == -1 or pd.isna(mother): orphan_cells.append(cell) else: @@ -1179,11 +1356,13 @@ def export_lin_tree_info(self, frame_i): lost_cells = [int(cell) for cell in lost_cells] cells_with_parent.sort(key=lambda x: x[1]) # Sort by mother ID - cells_with_parent = [(int(cell), int(mother)) for cell, mother in cells_with_parent] + cells_with_parent = [ + (int(cell), int(mother)) for cell, mother in cells_with_parent + ] orphan_cells = [int(cell) for cell in orphan_cells] return cells_with_parent, orphan_cells, lost_cells - + class tracker: """ @@ -1199,23 +1378,25 @@ class tracker: - updateGuiProgressBar(): Updates the GUI progress bar. (Used for GUI communication) - save_output(): Signals to the rest of the programme that the lineage tree should be saved. (Used for module 2) """ + def __init__(self): """ Initializes the CellACDC_normal_division_tracker object. """ pass - def track(self, - segm_video, - IoA_thresh:float = 0.8, - IoA_thresh_daughter:float = 0.25, - IoA_thresh_aggressive:float = 0.5, - min_daughter:int = 2, - max_daughter:int = 2, - record_lineage:bool = True, - return_tracked_lost_centroids:bool = True, - signals = None, - ): + def track( + self, + segm_video, + IoA_thresh: float = 0.8, + IoA_thresh_daughter: float = 0.25, + IoA_thresh_aggressive: float = 0.5, + min_daughter: int = 2, + max_daughter: int = 2, + record_lineage: bool = True, + return_tracked_lost_centroids: bool = True, + signals=None, + ): """ Tracks the segmented video frames and returns the tracked video. (Used for module 2) @@ -1228,15 +1409,17 @@ def track(self, - min_daughter (int, optional): Minimum number of daughter cells. Used for determining if a cell has divided. Defaults to 2. - max_daughter (int, optional): Maximum number of daughter cells. Used for determining if a cell has divided. Defaults to 2. - record_lineage (bool, optional): Flag to record and save lineage. Defaults to True. - + Returns: - list: Tracked video frames. """ if not record_lineage and return_tracked_lost_centroids: - print('return_tracked_lost_centroids is set to True if record_lineage is True.') + print( + "return_tracked_lost_centroids is set to True if record_lineage is True." + ) record_lineage = True - - pbar = tqdm(total=len(segm_video), desc='Tracking', ncols=100) + + pbar = tqdm(total=len(segm_video), desc="Tracking", ncols=100) if return_tracked_lost_centroids: self.tracked_lost_centroids = { @@ -1246,14 +1429,19 @@ def track(self, for frame_i, lab in enumerate(segm_video): if frame_i == 0: tracker = normal_division_tracker( - segm_video, IoA_thresh_daughter, min_daughter, - max_daughter, IoA_thresh, IoA_thresh_aggressive + segm_video, + IoA_thresh_daughter, + min_daughter, + max_daughter, + IoA_thresh, + IoA_thresh_aggressive, ) if record_lineage or return_tracked_lost_centroids: tree = normal_division_lineage_tree( - lab=lab, max_daughter=max_daughter, - min_daughter=min_daughter, - IoA_thresh_daughter=IoA_thresh_daughter + lab=lab, + max_daughter=max_daughter, + min_daughter=min_daughter, + IoA_thresh_daughter=IoA_thresh_daughter, ) pbar.update() rp = regionprops(segm_video[0]) @@ -1275,13 +1463,18 @@ def track(self, new_IDs = curr_IDs - prev_IDs if record_lineage or return_tracked_lost_centroids: tree.add_new_frame( - frame_i, mother_daughters, IDs_prev, IDs_curr_untracked, - assignments, curr_IDs, new_IDs + frame_i, + mother_daughters, + IDs_prev, + IDs_curr_untracked, + assignments, + curr_IDs, + new_IDs, ) tracked_lost_centroids_loc = [] for mother, _ in mother_daughters: mother_ID = IDs_prev[mother] - + found = False for obj in prev_rp: if obj.label == mother_ID: @@ -1291,12 +1484,15 @@ def track(self, if not found: labels = [obj.label for obj in rp] printl(mother, mother_ID, IDs_curr_untracked, labels) - raise ValueError('Something went wrong with the tracked lost centroids.') - + raise ValueError( + "Something went wrong with the tracked lost centroids." + ) if len(mother_daughters) != len(tracked_lost_centroids_loc): - raise ValueError('Something went wrong with the tracked lost centroids.') - + raise ValueError( + "Something went wrong with the tracked lost centroids." + ) + self.tracked_lost_centroids[frame_i] = tracked_lost_centroids_loc prev_IDs = curr_IDs.copy() @@ -1313,22 +1509,22 @@ def track(self, self.cca_dfs_auto = cca_li # here we would also save make sure to save self.tracked_lost_centroids, but since we already assigned it correctly from the get go we dont need to do that - tracked_video = tracker.tracked_video pbar.close() return tracked_video - def track_frame(self, - previous_frame_labels, - current_frame_labels, - IDs : NotGUIParam =None, - IoA_thresh: float = 0.8, - IoA_thresh_daughter:float = 0.25, - IoA_thresh_aggressive:float = 0.5, - min_daughter:int = 2, - max_daughter:int = 2, - unique_ID: NotGUIParam =None, - ): + def track_frame( + self, + previous_frame_labels, + current_frame_labels, + IDs: NotGUIParam = None, + IoA_thresh: float = 0.8, + IoA_thresh_daughter: float = 0.25, + IoA_thresh_aggressive: float = 0.5, + min_daughter: int = 2, + max_daughter: int = 2, + unique_ID: NotGUIParam = None, + ): """ Tracks cell division in a single frame. (This is used for real time tracking in the GUI) @@ -1351,7 +1547,14 @@ def track_frame(self, return current_frame_labels segm_video = [previous_frame_labels, current_frame_labels] - tracker = normal_division_tracker(segm_video, IoA_thresh_daughter, min_daughter, max_daughter, IoA_thresh, IoA_thresh_aggressive) + tracker = normal_division_tracker( + segm_video, + IoA_thresh_daughter, + min_daughter, + max_daughter, + IoA_thresh, + IoA_thresh_aggressive, + ) tracker.track_frame(1, IDs=IDs, unique_ID=unique_ID) tracked_video = tracker.tracked_video @@ -1374,7 +1577,7 @@ def updateGuiProgressBar(self, signals): if signals is None: return - if hasattr(signals, 'innerPbar_available'): + if hasattr(signals, "innerPbar_available"): if signals.innerPbar_available: # Use inner pbar of the GUI widget (top pbar is for positions) signals.innerProgressBar.emit(1) @@ -1392,4 +1595,4 @@ def save_output(self): Returns: - None """ - pass \ No newline at end of file + pass diff --git a/cellacdc/trackers/DeepSea/DeepSea_tracker.py b/cellacdc/trackers/DeepSea/DeepSea_tracker.py index fa378c994..6ec30d2fc 100644 --- a/cellacdc/trackers/DeepSea/DeepSea_tracker.py +++ b/cellacdc/trackers/DeepSea/DeepSea_tracker.py @@ -29,25 +29,28 @@ torch.cuda.manual_seed(SEED) torch.backends.cudnn.deterministic = True + class tracker: def __init__(self, gpu=False): torch_device, checkpoint, model = _init_model( - 'tracker.pth', DeepSeaTracker, gpu=gpu + "tracker.pth", DeepSeaTracker, gpu=gpu ) self.torch_device = torch_device self._transforms = _get_tracker_transforms() self._segm_transforms = _get_segm_transforms() self._checkpoint = checkpoint self.model = model - + def _resize_lab(self, lab, output_shape, rp): _lab_obj_to_resize = np.zeros(lab.shape, dtype=np.float16) lab_resized = np.zeros(output_shape, dtype=np.uint32) for obj in rp: _lab_obj_to_resize[obj.slice][obj.image] = 1.0 _lab_obj_resized = resize( - _lab_obj_to_resize, output_shape, anti_aliasing=True, - preserve_range=True + _lab_obj_to_resize, + output_shape, + anti_aliasing=True, + preserve_range=True, ).round() lab_resized[_lab_obj_resized == 1.0] = obj.label _lab_obj_to_resize[:] = 0.0 @@ -61,22 +64,19 @@ def _relabel_sequential(self, segm_video): return relabelled_video def track( - self, segm_video, image, min_size=10, annotate_lineage_tree=True, - signals=None - ): + self, segm_video, image, min_size=10, annotate_lineage_tree=True, signals=None + ): self.signals = signals segm_video = self._relabel_sequential(segm_video) labels_list = [] resize_img_list = [] pbar = tqdm(total=len(segm_video), ncols=100) if signals is not None: - signals.progress.emit('Resizing objects...') + signals.progress.emit("Resizing objects...") for img, lab in zip(image, segm_video): img = (255 * ((img - img.min()) / img.ptp())).astype(np.uint8) rp = regionprops(lab) - resized_img = _resize_img( - img, self.torch_device, self._segm_transforms - ) + resized_img = _resize_img(img, self.torch_device, self._segm_transforms) resized_lab = self._resize_lab( lab, output_shape=tuple(segm_image_size), rp=rp ) @@ -85,55 +85,67 @@ def track( pbar.update() pbar.close() if signals is not None: - signals.progress.emit('Tracking...') + signals.progress.emit("Tracking...") result = track_cells( - labels_list, resize_img_list, self.model, self.torch_device, - transforms=self._transforms, min_size=min_size + labels_list, + resize_img_list, + self.model, + self.torch_device, + transforms=self._transforms, + min_size=min_size, ) tracked_labels, tracked_centroids, tracked_imgs = result - + labels_to_IDs_mapper = self._get_labels_to_IDs_mapper(tracked_labels) - + if annotate_lineage_tree: self.cca_dfs = self._annotate_lineage_tree( tracked_labels, labels_to_IDs_mapper ) tracked_video = self._replace_tracked_IDs( - labels_list, tracked_labels, tracked_centroids, - labels_to_IDs_mapper, segm_video + labels_list, + tracked_labels, + tracked_centroids, + labels_to_IDs_mapper, + segm_video, ) return tracked_video def _annotate_lineage_tree(self, tracked_labels, labels_to_IDs_mapper): if self.signals is not None: - self.signals.progress.emit('Annotating lineage trees...') + self.signals.progress.emit("Annotating lineage trees...") from cellacdc.core import annotate_lineage_tree_from_labels + cca_dfs = annotate_lineage_tree_from_labels( tracked_labels, labels_to_IDs_mapper - ) + ) return cca_dfs def _get_labels_to_IDs_mapper(self, tracked_labels): if self.signals is not None: - self.signals.progress.emit('Mapping labels to IDs...') + self.signals.progress.emit("Mapping labels to IDs...") labels_to_IDs_mapper = get_labels_to_IDs_mapper(tracked_labels) return labels_to_IDs_mapper def _replace_tracked_IDs( - self, resized_labels_list, tracked_labels, tracked_centroids, - labels_to_IDs_mapper, segm_video - ): + self, + resized_labels_list, + tracked_labels, + tracked_centroids, + labels_to_IDs_mapper, + segm_video, + ): if self.signals is not None: - self.signals.progress.emit('Applying tracking information...') - + self.signals.progress.emit("Applying tracking information...") + _zip = zip(tracked_labels, tracked_centroids) IDs_prev = [] tracked_video = np.zeros_like(segm_video) for frame_i, track_info_frame in enumerate(_zip): tracked_frame_labels, tracked_frame_centroids = track_info_frame tracked_frame_IDs = [ - int(labels_to_IDs_mapper[label].split('_')[0]) + int(labels_to_IDs_mapper[label].split("_")[0]) for label in tracked_frame_labels ] lab = resized_labels_list[frame_i] @@ -141,16 +153,19 @@ def _replace_tracked_IDs( untracked_lab = segm_video[frame_i] rp = regionprops(lab) IDs_curr_untracked = [obj.label for obj in rp] - uniqueID = max( - max(IDs_prev, default=0), - max(IDs_curr_untracked, default=0), - max(tracked_frame_IDs, default=0) - ) + 1 + uniqueID = ( + max( + max(IDs_prev, default=0), + max(IDs_curr_untracked, default=0), + max(tracked_frame_IDs, default=0), + ) + + 1 + ) IDs_to_replace = { - lab[tuple(centr)]:idx + lab[tuple(centr)]: idx for idx, centr in enumerate(tracked_frame_centroids) } - IDs_prev = [] + IDs_prev = [] for obj in rp: idx_ID_to_replace = IDs_to_replace.get(obj.label) if idx_ID_to_replace is None: @@ -160,22 +175,20 @@ def _replace_tracked_IDs( newID = tracked_frame_IDs[idx_ID_to_replace] tracked_lab[untracked_lab == obj.label] = newID IDs_prev.append(newID) - + tracked_video[frame_i] = tracked_lab self.updateGuiProgressBar(self.signals) return tracked_video - + def updateGuiProgressBar(self, signals): if signals is None: return - - if hasattr(signals, 'innerPbar_available'): + + if hasattr(signals, "innerPbar_available"): if signals.innerPbar_available: # Use inner pbar of the GUI widget (top pbar is for positions) signals.innerProgressBar.emit(1) return signals.progressBar.emit(1) - - \ No newline at end of file diff --git a/cellacdc/trackers/DeepSea/__init__.py b/cellacdc/trackers/DeepSea/__init__.py index 3fc285b4f..5e9d882c8 100644 --- a/cellacdc/trackers/DeepSea/__init__.py +++ b/cellacdc/trackers/DeepSea/__init__.py @@ -4,14 +4,17 @@ import torchvision.transforms as transforms -image_size = [128,128] +image_size = [128, 128] image_means = [0.5] image_stds = [0.5] + def _get_tracker_transforms(): - return tracker_transforms.Compose([ - tracker_transforms.Resize(image_size), - tracker_transforms.Grayscale(num_output_channels=1), - tracker_transforms.ToTensor(), - tracker_transforms.Normalize(mean=image_means, std=image_stds) - ]) \ No newline at end of file + return tracker_transforms.Compose( + [ + tracker_transforms.Resize(image_size), + tracker_transforms.Grayscale(num_output_channels=1), + tracker_transforms.ToTensor(), + tracker_transforms.Normalize(mean=image_means, std=image_stds), + ] + ) diff --git a/cellacdc/trackers/TAPIR/TAPIR_tracker.py b/cellacdc/trackers/TAPIR/TAPIR_tracker.py index 567b7c6c0..ccfa70e06 100644 --- a/cellacdc/trackers/TAPIR/TAPIR_tracker.py +++ b/cellacdc/trackers/TAPIR/TAPIR_tracker.py @@ -21,43 +21,50 @@ from . import TAPIR_CHECKPOINT_PATH from .tracking import build_model, inference + class SizesToResize: values = np.arange(256, 1025, 128) + class TrackingInputs: - values = ['Intensity image', 'Segmented objects'] + values = ["Intensity image", "Segmented objects"] + class PointsToTrack: - values = ['Centroids', 'Contours'] + values = ["Centroids", "Contours"] + class tracker: - def __init__( - self, model_checkpoint_path: os.PathLike=TAPIR_CHECKPOINT_PATH - ): + def __init__(self, model_checkpoint_path: os.PathLike = TAPIR_CHECKPOINT_PATH): ckpt_state = np.load(model_checkpoint_path, allow_pickle=True).item() - params, state = ckpt_state['params'], ckpt_state['state'] + params, state = ckpt_state["params"], ckpt_state["state"] model = hk.transform_with_state(build_model) model_apply = jax.jit(model.apply) self.params = params self.state = state self.model_apply = model_apply - + def track( - self, segm_video, video_grayscale, - resize_to_square_with_size: SizesToResize=256, - max_distance=5, save_napari_tracks=False, - use_visibile_information=True, export_to=None, - signals=None, export_to_extension='.csv', - tracking_input: TrackingInputs='Intensity image', - which_points_to_track: PointsToTrack='Centroids', - number_of_points_per_object: int=8 - ): - + self, + segm_video, + video_grayscale, + resize_to_square_with_size: SizesToResize = 256, + max_distance=5, + save_napari_tracks=False, + use_visibile_information=True, + export_to=None, + signals=None, + export_to_extension=".csv", + tracking_input: TrackingInputs = "Intensity image", + which_points_to_track: PointsToTrack = "Centroids", + number_of_points_per_object: int = 8, + ): + if video_grayscale.ndim == 4: ndim = video_grayscale.ndim - msg = f'TAPIR can only track 2D frames over time. Input image is {ndim}D' + msg = f"TAPIR can only track 2D frames over time. Input image is {ndim}D" raise TypeError(msg) - + self._use_visibile_information = use_visibile_information self._which_points_to_track = which_points_to_track self.segm_video = segm_video @@ -68,27 +75,28 @@ def track( frames = skimage.transform.resize( video_grayscale, (num_frames, new_size, new_size) ) - frames = frames/frames.max() - self.resize_ratio_height = height/new_size - self.resize_ratio_width = width/new_size - + frames = frames / frames.max() + self.resize_ratio_height = height / new_size + self.resize_ratio_width = width / new_size + resized_segm_video = np.array( [resize_lab(lab, (new_size, new_size)) for lab in segm_video] ) - + # We track from last frame backwards reversed_resized_frames = frames[::-1] reversed_resized_segm = resized_segm_video[::-1] - + self.reversed_resized_segm = reversed_resized_segm - + frames_rgb = self._get_frames_to_track( - reversed_resized_frames, reversed_resized_segm, - tracking_input + reversed_resized_frames, reversed_resized_segm, tracking_input ) query_points, tracks_start_frames = self._initialize_query_points( - reversed_resized_segm, tracking_input, which_points_to_track, - number_of_points_per_object + reversed_resized_segm, + tracking_input, + which_points_to_track, + number_of_points_per_object, ) self.tracks_start_frames = tracks_start_frames @@ -96,26 +104,24 @@ def track( # plt.imshow(frames_rgb[0]) # plt.plot(query_points[:,2], query_points[:,1], 'r.') # plt.show() - + self.reversed_tracks, self.reversed_visibles = inference( - frames_rgb, query_points, self.model_apply, self.params, - self.state + frames_rgb, query_points, self.model_apply, self.params, self.state ) - + tracked_video = self._apply_tracks() - + if save_napari_tracks: self._save_napari_tracks(export_to) - + if export_to is not None: self._save_tracks(export_to) return tracked_video def _get_frames_to_track( - self, reversed_resized_frames, reversed_resized_segm, - tracking_input - ): - if tracking_input == 'Segmented objects': + self, reversed_resized_frames, reversed_resized_segm, tracking_input + ): + if tracking_input == "Segmented objects": frames = np.zeros(reversed_resized_segm.shape, dtype=np.float32) for frame_i, lab in enumerate(reversed_resized_segm): rp = skimage.measure.regionprops(lab) @@ -125,19 +131,19 @@ def _get_frames_to_track( frames[frame_i][obj.slice][obj.image] = obj_edt[obj.image] else: frames = reversed_resized_frames - frames_rgb = (skimage.color.gray2rgb(frames)*255).astype(np.uint8) + frames_rgb = (skimage.color.gray2rgb(frames) * 255).astype(np.uint8) return frames_rgb - + def _save_napari_tracks(self, export_to): - print('Saving napari tracks...') + print("Saving napari tracks...") napari_tracks = self.to_napari_tracks() if export_to is None: - napari_tracks_path = 'tapir_napari_tracks.csv' + napari_tracks_path = "tapir_napari_tracks.csv" else: - napari_tracks_path = export_to.replace('.csv', '_napari.csv') - df = pd.DataFrame(data=napari_tracks, columns=['ID', 'T', 'Y', 'X']) + napari_tracks_path = export_to.replace(".csv", "_napari.csv") + df = pd.DataFrame(data=napari_tracks, columns=["ID", "T", "Y", "X"]) df.to_csv(napari_tracks_path, index=False) - + def _build_tracks_table(self): tracks = self.reversed_tracks[:, ::-1] visibles = self.reversed_visibles[:, ::-1] @@ -150,9 +156,9 @@ def _build_tracks_table(self): segm_IDs = [] for tr, track in enumerate(tqdm(tracks, ncols=100)): track_ID = self._get_track_ID(resized_segm, track) - for frame_i, (x, y) in enumerate(track): - yc = y*self.resize_ratio_height - xc = x*self.resize_ratio_width + for frame_i, (x, y) in enumerate(track): + yc = y * self.resize_ratio_height + xc = x * self.resize_ratio_width visible = visibles[tr, frame_i] track_IDs.append(track_ID) frames.append(frame_i) @@ -163,22 +169,28 @@ def _build_tracks_table(self): resized_segm[frame_i], y, x, max_dist=self.max_dist ) segm_IDs.append(segm_ID) - df = pd.DataFrame({ - 'frame_i': frames, - 'track_ID': segm_IDs, - 'segm_ID': track_IDs, - 'y_point': yy, - 'x_point': xx, - 'visible': visibles_li - }).set_index(['frame_i', 'track_ID']).sort_index() + df = ( + pd.DataFrame( + { + "frame_i": frames, + "track_ID": segm_IDs, + "segm_ID": track_IDs, + "y_point": yy, + "x_point": xx, + "visible": visibles_li, + } + ) + .set_index(["frame_i", "track_ID"]) + .sort_index() + ) return df - + def _save_tracks(self, export_to): - print('Saving tracks...') + print("Saving tracks...") self.df_tracks.to_csv(export_to) def to_napari_tracks(self, use_centroids=False): - print('Building napari tracks data...') + print("Building napari tracks data...") napari_tracks = [] num_frames = len(self.reversed_resized_segm) Y, X = self.reversed_resized_segm.shape[-2:] @@ -190,16 +202,27 @@ def to_napari_tracks(self, use_centroids=False): if not visible and self._use_visibile_information: continue self._append_napari_point( - napari_tracks, y, x, num_frames, reversed_frame_i, - track_ID, use_centroids=use_centroids + napari_tracks, + y, + x, + num_frames, + reversed_frame_i, + track_ID, + use_centroids=use_centroids, ) napari_tracks = np.array(napari_tracks) return napari_tracks def _append_napari_point( - self, napari_tracks, y, x, num_frames, - reversed_frame_i, track_ID, use_centroids=False - ): + self, + napari_tracks, + y, + x, + num_frames, + reversed_frame_i, + track_ID, + use_centroids=False, + ): frame_i = num_frames - reversed_frame_i - 1 if use_centroids: lab = self.segm_video[frame_i] @@ -210,41 +233,41 @@ def _append_napari_point( napari_tracks.append((track_ID, frame_i, yc, xc)) break else: - yc = y*self.resize_ratio_height - xc = x*self.resize_ratio_width + yc = y * self.resize_ratio_height + xc = x * self.resize_ratio_width napari_tracks.append((track_ID, frame_i, yc, xc)) - + def _get_track_ID(self, resized_segm, track, max_dist=None): Y, X = resized_segm.shape[-2:] x, y = track[-1] # frame_i = self.tracks_start_frames[(round(y), round(x))] - # I still don't know how to get the start frame of each track - # because TAPIR returns a float even for the initialized query + # I still don't know how to get the start frame of each track + # because TAPIR returns a float even for the initialized query # point of each track frame_i = -1 y_int, x_int = round(y), round(x) - y_int = max(0, min(y_int, Y-1)) - x_int = max(0, min(x_int, X-1)) + y_int = max(0, min(y_int, Y - 1)) + x_int = max(0, min(x_int, X - 1)) track_ID = resized_segm[frame_i, y_int, x_int] return track_ID - + def _apply_tracks(self): - print('Applying tracks data...') - - self.df_tracks = self._build_tracks_table() - self.df_tracks = self.df_tracks[self.df_tracks.visible>0] - + print("Applying tracks data...") + + self.df_tracks = self._build_tracks_table() + self.df_tracks = self.df_tracks[self.df_tracks.visible > 0] + # Iterate tracks and determine tracked IDs old_IDs_tracks = {} tracked_IDs_tracks = {} - for (frame_i, track_ID), df in self.df_tracks.groupby(level=(0,1)): + for (frame_i, track_ID), df in self.df_tracks.groupby(level=(0, 1)): if track_ID == 0: continue - - oldID = df['segm_ID'].mode().iloc[0] + + oldID = df["segm_ID"].mode().iloc[0] if oldID == 0: continue - + if frame_i not in old_IDs_tracks: old_IDs_tracks[frame_i] = [oldID] tracked_IDs_tracks[frame_i] = [track_ID] @@ -256,41 +279,43 @@ def _apply_tracks(self): for frame_i in old_IDs_tracks.keys(): tracked_IDs = tracked_IDs_tracks[frame_i] old_IDs = old_IDs_tracks[frame_i] - + lab = self.segm_video[frame_i] rp = skimage.measure.regionprops(lab) IDs_curr_untracked = [obj.label for obj in rp] - - uniqueID = max((max(tracked_IDs), max(IDs_curr_untracked)))+1 + + uniqueID = max((max(tracked_IDs), max(IDs_curr_untracked))) + 1 tracked_lab = CellACDC_tracker.indexAssignment( - old_IDs, tracked_IDs, IDs_curr_untracked, - lab.copy(), rp, uniqueID + old_IDs, tracked_IDs, IDs_curr_untracked, lab.copy(), rp, uniqueID ) tracked_video[frame_i] = tracked_lab return tracked_video - + def _initialize_query_points( - self, reversed_resized_segm, tracking_input, - which_points_to_track, number_of_points_per_object - ): + self, + reversed_resized_segm, + tracking_input, + which_points_to_track, + number_of_points_per_object, + ): first_lab = reversed_resized_segm[0] first_lab_rp = skimage.measure.regionprops(first_lab) num_objs = len(first_lab_rp) tracks_start_frames = {} - if which_points_to_track == 'Centroids': - query_points = np.zeros((num_objs, 3), dtype=int) + if which_points_to_track == "Centroids": + query_points = np.zeros((num_objs, 3), dtype=int) else: - all_contours = [] + all_contours = [] for o, obj in enumerate(first_lab_rp): - if which_points_to_track == 'Centroids': - if tracking_input == 'Segmented objects': + if which_points_to_track == "Centroids": + if tracking_input == "Segmented objects": # Track the center of the edt of the object # since edt is also the input image obj_edt = distance_transform_edt(obj.image) argmax = np.argmax(obj_edt) yc_loc, xc_loc = np.unravel_index(argmax, obj_edt.shape) ymin, xmin, _, _ = obj.bbox - yc, xc = yc_loc+ymin, xc_loc+xmin + yc, xc = yc_loc + ymin, xc_loc + xmin else: # Track the centroid of the object yc, xc = obj.centroid @@ -306,14 +331,15 @@ def _initialize_query_points( all_contours.append(contours) for x, y in contours: tracks_start_frames[(y, x)] = 0 - if which_points_to_track == 'Contours': + if which_points_to_track == "Contours": all_contours = np.concatenate(all_contours) nrows = len(all_contours) - query_points = np.zeros((nrows, 3), dtype=int) - query_points[:, 2] = all_contours[:,0] - query_points[:, 1] = all_contours[:,1] - + query_points = np.zeros((nrows, 3), dtype=int) + query_points[:, 2] = all_contours[:, 0] + query_points[:, 1] = all_contours[:, 1] + return query_points, tracks_start_frames + def url_help(): - return 'https://deepmind-tapir.github.io/' \ No newline at end of file + return "https://deepmind-tapir.github.io/" diff --git a/cellacdc/trackers/TAPIR/__init__.py b/cellacdc/trackers/TAPIR/__init__.py index 526c70b8b..9c1d97afd 100644 --- a/cellacdc/trackers/TAPIR/__init__.py +++ b/cellacdc/trackers/TAPIR/__init__.py @@ -3,5 +3,5 @@ from cellacdc import myutils myutils.check_install_tapir() -_, model_path = myutils.get_model_path('TAPIR', create_temp_dir=False) -TAPIR_CHECKPOINT_PATH = os.path.join(model_path, 'tapir_checkpoint.npy') \ No newline at end of file +_, model_path = myutils.get_model_path("TAPIR", create_temp_dir=False) +TAPIR_CHECKPOINT_PATH = os.path.join(model_path, "tapir_checkpoint.npy") diff --git a/cellacdc/trackers/TAPIR/tracking.py b/cellacdc/trackers/TAPIR/tracking.py index a54c62977..6706a81e3 100644 --- a/cellacdc/trackers/TAPIR/tracking.py +++ b/cellacdc/trackers/TAPIR/tracking.py @@ -4,6 +4,7 @@ from tapnet import tapir_model + def build_model(frames, query_points): """Compute point tracks and occlusions given frames and query points.""" model = tapir_model.TAPIR() @@ -15,6 +16,7 @@ def build_model(frames, query_points): ) return outputs + def preprocess_frames(frames): """Preprocess frames to model inputs. @@ -40,9 +42,12 @@ def postprocess_occlusions(occlusions, expected_dist): visibles: [num_points, num_frames], bool """ # visibles = occlusions < 0 - visibles = (1 - jax.nn.sigmoid(occlusions)) * (1 - jax.nn.sigmoid(expected_dist)) > 0.5 + visibles = (1 - jax.nn.sigmoid(occlusions)) * ( + 1 - jax.nn.sigmoid(expected_dist) + ) > 0.5 return visibles + def inference(frames, query_points, model_apply, params, state): """Inference on one video. @@ -65,9 +70,11 @@ def inference(frames, query_points, model_apply, params, state): outputs, _ = model_apply(params, state, rng, frames, query_points) outputs = tree.map_structure(lambda x: np.array(x[0]), outputs) tracks, occlusions, expected_dist = ( - outputs['tracks'], outputs['occlusion'], outputs['expected_dist'] + outputs["tracks"], + outputs["occlusion"], + outputs["expected_dist"], ) # Binarize occlusions visibles = postprocess_occlusions(occlusions, expected_dist) - return tracks, visibles \ No newline at end of file + return tracks, visibles diff --git a/cellacdc/trackers/Trackastra/Trackastra_tracker.py b/cellacdc/trackers/Trackastra/Trackastra_tracker.py index cc6de1d9c..9838531fa 100644 --- a/cellacdc/trackers/Trackastra/Trackastra_tracker.py +++ b/cellacdc/trackers/Trackastra/Trackastra_tracker.py @@ -1,4 +1,3 @@ - import os from trackastra.model import Trackastra @@ -8,22 +7,26 @@ from . import get_pretrained_model_names + class AvailableModels: values = get_pretrained_model_names() + class AvailableLinkingModes: - values = ['greedy', 'greedy_nodiv', 'ilp'] + values = ["greedy", "greedy_nodiv", "ilp"] + class AvailableCellDivisionModes: - values = ['Normal', 'Asymmetric'] + values = ["Normal", "Asymmetric"] + class tracker: def __init__( - self, - pretrained_model_name: AvailableModels='general_2d', - model_folder_path: _types.FolderPath='', - gpu=False - ) -> None: + self, + pretrained_model_name: AvailableModels = "general_2d", + model_folder_path: _types.FolderPath = "", + gpu=False, + ) -> None: """Initialize tracker Parameters @@ -31,29 +34,29 @@ def __init__( pretrained_model_name : AvailableModels, optional Pre-trained model name. Default is 'general_2d' model_folder_path : os.PathLike, optional - Path to the folder containing `config.yaml` file from + Path to the folder containing `config.yaml` file from custom training. Default is '' gpu : bool, optional - If `True`, attempts to try to use the GPU for inference. + If `True`, attempts to try to use the GPU for inference. Default is False - """ + """ device = myutils.get_torch_device() if model_folder_path: - self.model = Trackastra.from_folder( - model_folder_path, device=str(device) - ) + self.model = Trackastra.from_folder(model_folder_path, device=str(device)) else: self.model = Trackastra.from_pretrained( pretrained_model_name, device=str(device) ) - + def track( - self, segm_video, video_grayscale, - linking_mode: AvailableLinkingModes='greedy', - prevent_deleting_objects: bool=True, - cell_division_mode: AvailableCellDivisionModes='Normal', - record_lineage=True - ): + self, + segm_video, + video_grayscale, + linking_mode: AvailableLinkingModes = "greedy", + prevent_deleting_objects: bool = True, + cell_division_mode: AvailableCellDivisionModes = "Normal", + record_lineage=True, + ): """Track the objects in `segm_video` Parameters @@ -63,33 +66,31 @@ def track( video_grayscale : (T, Y, X) np.ndarray Input intensity images over time. linking_mode : {'greedy', 'greedy_nodiv', 'ilp'}, optional - Strategy used to link the predicted associations. Note that + Strategy used to link the predicted associations. Note that 'ilp' requires the package `motile`. Default is 'greedy' prevent_deleting_objects : bool, optional - If `True`, prevent Trackastra from removing untracked objects or - merging them with other objects. Note that these added objects + If `True`, prevent Trackastra from removing untracked objects or + merging them with other objects. Note that these added objects will not be tracked. Default is `True`. cell_division_mode : {'Normal', 'Asymmetric'}, optional - Type of cell division. `Normal` is the standard cell division, - where the mother cell divides into two daughter cells. For the - tracking, that means the two daughter cells get a new, unique ID - each. Note that division is not detected if + Type of cell division. `Normal` is the standard cell division, + where the mother cell divides into two daughter cells. For the + tracking, that means the two daughter cells get a new, unique ID + each. Note that division is not detected if `linking_mode == greedy_nodiv`. - - `Asymmetric` means that the mother cell grows one daughter - cell that eventually divides from the mother (e.g., budding yeast). - For the tracking, this means that the mother cell ID keeps - existing after division and the daughter cell gets a new, unique ID. + + `Asymmetric` means that the mother cell grows one daughter + cell that eventually divides from the mother (e.g., budding yeast). + For the tracking, this means that the mother cell ID keeps + existing after division and the daughter cell gets a new, unique ID. record_lineage : bool, optional - If `True`, store a list of cell lineage annotaions (Cell-ACDC format) - in the `self.cca_dfs` list (one DataFrame with index `Cell_ID` per - frame). When used through Cell-ACDC, this list will be saved - to the acdc_output CSV file. - """ - out = self.model.track( - video_grayscale, segm_video, mode=linking_mode - ) - + If `True`, store a list of cell lineage annotaions (Cell-ACDC format) + in the `self.cca_dfs` list (one DataFrame with index `Cell_ID` per + frame). When used through Cell-ACDC, this list will be saved + to the acdc_output CSV file. + """ + out = self.model.track(video_grayscale, segm_video, mode=linking_mode) + try: df_ctc, tracked_video = graph_to_ctc(out, segm_video) except Exception as e: @@ -99,58 +100,60 @@ def track( except Exception as e2: graph = out[1] df_ctc, tracked_video = graph_to_ctc(graph, segm_video) - if prevent_deleting_objects: - tracked_video = core.insert_missing_objects( - tracked_video, segm_video - ) - - if linking_mode == 'greedy_nodiv': + tracked_video = core.insert_missing_objects(tracked_video, segm_video) + + if linking_mode == "greedy_nodiv": return tracked_video - + acdc_df, cca_dfs, asym_segm_tracked = myutils.df_ctc_to_acdc_df( - df_ctc, tracked_video, cell_division_mode=cell_division_mode, - return_list=True, progressbar=True + df_ctc, + tracked_video, + cell_division_mode=cell_division_mode, + return_list=True, + progressbar=True, ) - - if cell_division_mode == 'Asymmetric': + + if cell_division_mode == "Asymmetric": return asym_segm_tracked - + if record_lineage: self.cca_dfs = cca_dfs - + return tracked_video def validate_input(self, segm_video, progress=True): import skimage.measure + warning_text = None if progress: from tqdm import tqdm + pbar = tqdm( - total=len(segm_video), desc='Validating input', unit='frame', - ncols=100 + total=len(segm_video), desc="Validating input", unit="frame", ncols=100 ) - + empty_frames = [] for frame_i, lab in enumerate(segm_video): rp = skimage.measure.regionprops(lab) if len(rp) == 0: - empty_frames.append(frame_i+1) - + empty_frames.append(frame_i + 1) + if progress: pbar.update(1) - + if empty_frames: warning_text = ( - 'Trackastra requires that each frame has at least one object.\n\n' - f'The following frame numbers have no objects:\n\n{empty_frames}' + "Trackastra requires that each frame has at least one object.\n\n" + f"The following frame numbers have no objects:\n\n{empty_frames}" ) - + if progress: pbar.close() - + return warning_text + def url_help(): - return 'https://github.com/weigertlab/trackastra' \ No newline at end of file + return "https://github.com/weigertlab/trackastra" diff --git a/cellacdc/trackers/Trackastra/__init__.py b/cellacdc/trackers/Trackastra/__init__.py index 159d3595b..f35699472 100644 --- a/cellacdc/trackers/Trackastra/__init__.py +++ b/cellacdc/trackers/Trackastra/__init__.py @@ -9,11 +9,12 @@ trackastra_folderpath = os.path.dirname(os.path.abspath(trackastra.__file__)) pretraned_json_filepath = os.path.join( - trackastra_folderpath, 'model', 'pretrained.json' + trackastra_folderpath, "model", "pretrained.json" ) + def get_pretrained_model_names(): - with open(pretraned_json_filepath, encoding='utf-8') as file: + with open(pretraned_json_filepath, encoding="utf-8") as file: json_data = json.load(file) - - return list(json_data.keys()) \ No newline at end of file + + return list(json_data.keys()) diff --git a/cellacdc/trackers/YeaZ/YeaZ_tracker.py b/cellacdc/trackers/YeaZ/YeaZ_tracker.py index dc703c1e5..976fd0c5d 100755 --- a/cellacdc/trackers/YeaZ/YeaZ_tracker.py +++ b/cellacdc/trackers/YeaZ/YeaZ_tracker.py @@ -3,6 +3,7 @@ from . import tracking + class tracker: def __init__(self): pass diff --git a/cellacdc/trackers/YeaZ/tracking.py b/cellacdc/trackers/YeaZ/tracking.py index 7785aadf9..91a6c1976 100755 --- a/cellacdc/trackers/YeaZ/tracking.py +++ b/cellacdc/trackers/YeaZ/tracking.py @@ -18,6 +18,7 @@ except ModuleNotFoundError as e: pass + def correspondence(prev, curr, use_scipy=True, use_modified_yeaz=True): """ source: YeaZ modified by Cell-ACDC developers @@ -37,19 +38,16 @@ def correspondence(prev, curr, use_scipy=True, use_modified_yeaz=True): IDs_curr_untracked = [obj.label for obj in regionprops(curr)] IDs_prev = [obj.label for obj in regionprops(prev)] if IDs_prev or IDs_curr_untracked: - uniqueID = max( - max(IDs_prev, default=0), - max(IDs_curr_untracked, default=0) - ) + 1 + uniqueID = max(max(IDs_prev, default=0), max(IDs_curr_untracked, default=0)) + 1 else: uniqueID = 1 tracked_lab = CellACDC_tracker.indexAssignment( - old_IDs, tracked_IDs, IDs_curr_untracked, - curr.copy(), rp, uniqueID + old_IDs, tracked_IDs, IDs_curr_untracked, curr.copy(), rp, uniqueID ) return tracked_lab + def scipy_align(m1, m2, acdc_yeaz=True): """ source: YeaZ modified by Cell-ACDC @@ -72,11 +70,12 @@ def scipy_align(m1, m2, acdc_yeaz=True): d.pop(-1, None) return d + def updateGuiProgressBar(signals): if signals is None: return - - if hasattr(signals, 'innerPbar_available'): + + if hasattr(signals, "innerPbar_available"): if signals.innerPbar_available: # Use inner pbar of the GUI widget (top pbar is for positions) signals.innerProgressBar.emit(1) @@ -84,6 +83,7 @@ def updateGuiProgressBar(signals): signals.progressBar.emit(1) + def correspondence_stack(stack, signals=None): """ source: YeaZ @@ -94,15 +94,16 @@ def correspondence_stack(stack, signals=None): tracked_stack[0] = stack[0] for idx in tqdm(range(len(stack)), ncols=100): try: - curr = stack[idx+1] + curr = stack[idx + 1] prev = tracked_stack[idx] except IndexError: continue - tracked_stack[idx+1] = correspondence(prev, curr) + tracked_stack[idx + 1] = correspondence(prev, curr) updateGuiProgressBar(signals) # tracked_stack = relabel_sequential(tracked_stack)[0] return tracked_stack + def hungarian_align(m1, m2, acdc_yeaz=True): """ source: YeaZ @@ -126,50 +127,54 @@ def hungarian_align(m1, m2, acdc_yeaz=True): d.pop(-1, None) return d + def cell_to_features(im, c, nsamples=None, time=None): """ source: YeaZ Embeds cell c in image im into feature space """ - coord = np.argwhere(im==c) + coord = np.argwhere(im == c) area = coord.shape[0] if nsamples is not None: samples = np.random.choice(area, min(nsamples, area), replace=False) - sampled = coord[samples,:] + sampled = coord[samples, :] else: sampled = coord com = sampled.mean(axis=0) - return {'cell': c, - 'time': time, - 'sqrtarea': np.sqrt(area), - 'area': area, - 'com_x': com[0], - 'com_y': com[1]} + return { + "cell": c, + "time": time, + "sqrtarea": np.sqrt(area), + "area": area, + "com_x": com[0], + "com_y": com[1], + } + def get_features_acdc(m, t): rp = regionprops(m) features = { - 'cell': [], - 'time': [], - 'sqrtarea': [], - 'area': [], - 'com_x': [], - 'com_y': [] + "cell": [], + "time": [], + "sqrtarea": [], + "area": [], + "com_x": [], + "com_y": [], } for obj in rp: area = obj.area y, x = obj.centroid - features['cell'].append(obj.label) - features['time'].append(t) - features['sqrtarea'].append(sqrt(area)) - features['area'].append(area) - features['com_x'].append(y) - features['com_y'].append(x) + features["cell"].append(obj.label) + features["time"].append(t) + features["sqrtarea"].append(sqrt(area)) + features["area"].append(area) + features["com_x"].append(y) + features["com_y"].append(x) df = pd.DataFrame(features) - return df, dict(enumerate(features['cell'])) + return df, dict(enumerate(features["cell"])) def get_features(m, t): @@ -179,6 +184,7 @@ def get_features(m, t): features = [cell_to_features(m, c, time=t) for c in cells] return pd.DataFrame(features), dict(enumerate(cells)) + def cell_distance(m1, m2, weight_com=3, acdc_yeaz=True): """ source: YeaZ @@ -188,8 +194,8 @@ def cell_distance(m1, m2, weight_com=3, acdc_yeaz=True): make it more important). """ # Modify to compute use more computed features - #cols = ['com_x', 'com_y', 'roundness', 'sqrtarea'] - cols = ['com_x', 'com_y', 'area'] + # cols = ['com_x', 'com_y', 'roundness', 'sqrtarea'] + cols = ["com_x", "com_y", "area"] get_features_func = get_features_acdc if acdc_yeaz else get_features @@ -200,19 +206,18 @@ def cell_distance(m1, m2, weight_com=3, acdc_yeaz=True): # feat1_acdc, ix_to_cell1_acdc = get_features_acdc(m1, 1) # Check if one of matrices doesn't contain cells - if len(feat1)==0 or len(feat2)==0: + if len(feat1) == 0 or len(feat2) == 0: return None, None, None df = pd.concat((feat1, feat2)) df[cols] = scale(df[cols]) # give more importance to center of mass - df[['com_x', 'com_y']] = df[['com_x', 'com_y']] * weight_com + df[["com_x", "com_y"]] = df[["com_x", "com_y"]] * weight_com # pairwise euclidean dist dist = euclidean_distances( - df.loc[df['time']==1][cols], - df.loc[df['time']==2][cols] + df.loc[df["time"] == 1][cols], df.loc[df["time"] == 2][cols] ) return dist, ix_to_cell1, ix_to_cell2 @@ -233,10 +238,10 @@ def make_square(m): source: YeaZ Turns matrix into square matrix, as required by Munkres algorithm """ - r,c = m.shape - if r==c: + r, c = m.shape + if r == c: return m - elif r>c: - return zero_pad(m, (r,r)) + elif r > c: + return zero_pad(m, (r, r)) else: - return zero_pad(m, (c,c)) + return zero_pad(m, (c, c)) diff --git a/cellacdc/trackers/delta/__init__.py b/cellacdc/trackers/delta/__init__.py index 9dde410d6..f6ae895b6 100644 --- a/cellacdc/trackers/delta/__init__.py +++ b/cellacdc/trackers/delta/__init__.py @@ -4,4 +4,4 @@ @author: jroberts / jamesr787 """ -from cellacdc.segmenters import delta \ No newline at end of file +from cellacdc.segmenters import delta diff --git a/cellacdc/trackers/delta/delta_tracker.py b/cellacdc/trackers/delta/delta_tracker.py index e1ddfc9b8..0c6f8f85f 100644 --- a/cellacdc/trackers/delta/delta_tracker.py +++ b/cellacdc/trackers/delta/delta_tracker.py @@ -19,15 +19,9 @@ class FakeReader: - - def __init__(self, - x, - y, - channels, - timepoints, - filename, - original_video, - starting_frame): + def __init__( + self, x, y, channels, timepoints, filename, original_video, starting_frame + ): """ Initialize experiment reader @@ -61,14 +55,15 @@ def __init__(self, self.original_video = original_video self.starting_frame = starting_frame - def getframes(self, - squeeze_dimensions: bool = True, - resize: Tuple[int, int] = None, - rescale: Tuple[float, float] = None, - globalrescale: Tuple[float, float] = None, - rotate: float = None, - **kwargs - ): + def getframes( + self, + squeeze_dimensions: bool = True, + resize: Tuple[int, int] = None, + rescale: Tuple[float, float] = None, + globalrescale: Tuple[float, float] = None, + rotate: float = None, + **kwargs, + ): """ Get frames from experiment. @@ -102,9 +97,7 @@ def getframes(self, dt: Union[str, type] = self.dtype if rescale is None else np.float32 if resize is None: - output = np.empty( - [self.timepoints, self.y, self.x], dtype=dt - ) + output = np.empty([self.timepoints, self.y, self.x], dtype=dt) else: output = np.empty( [self.timepoints, resize[0], resize[1]], @@ -113,7 +106,6 @@ def getframes(self, # Load images: for f in range(self.timepoints): - idx = f + self.starting_frame frame = self.original_video[idx].astype(np.uint16) @@ -138,7 +130,6 @@ def getframes(self, class tracker: - def __init__(self, **params): """ Initializes Tracker @@ -163,8 +154,7 @@ def __init__(self, **params): self.params = params - def __read_tiff(self, - path): + def __read_tiff(self, path): """ Reads multipage tiff to numpy array. @@ -186,8 +176,7 @@ def __read_tiff(self, images.append(np.array(img)) return np.array(images) - def __load_model_and_presets(self, - model_type): + def __load_model_and_presets(self, model_type): """ Loads Presets for 2D or mothermachine, initializes model for tracking and loads model weights. @@ -222,18 +211,20 @@ def __load_model_and_presets(self, except ValueError: # Downloads model weights and configuration files for 2D and mothermachine - download_assets(load_models=True, - load_sets=False, - load_evals=False, - config_level='local') - - if self.params['single mothermachine chamber'] and model_type == 'mothermachine': - self.models.pop('rois') - - def track(self, - segm_video, - signals=None, - export_to: os.PathLike=None): + download_assets( + load_models=True, + load_sets=False, + load_evals=False, + config_level="local", + ) + + if ( + self.params["single mothermachine chamber"] + and model_type == "mothermachine" + ): + self.models.pop("rois") + + def track(self, segm_video, signals=None, export_to: os.PathLike = None): """ Tracks Cells @@ -249,13 +240,13 @@ def track(self, """ # Loads Presets and Initializes Model - self.__load_model_and_presets(model_type=self.params['model_type']) + self.__load_model_and_presets(model_type=self.params["model_type"]) # Original Shape original_shape = segm_video[0].shape # Get original video and original image size - original_video = self.__read_tiff(self.params['original_images_path']) + original_video = self.__read_tiff(self.params["original_images_path"]) reference = utils.rangescale(original_video[0], (0, 1)) # Preprocess Segmentation Video @@ -266,14 +257,18 @@ def track(self, img = cv2.resize(img, cfg.target_size_seg[::-1]) img_sm = (img > 0.5).astype(np.uint8) if cfg.crop_windows: - img_sm = img_sm[: original_shape[0], : original_shape[1]].astype(np.uint8) + img_sm = img_sm[: original_shape[0], : original_shape[1]].astype( + np.uint8 + ) seg_stack.append(img_sm) segm_video = seg_stack # Preprocess Original Video box = utils.CroppingBox( - xtl=0, ytl=0, - xbr=reference.shape[1], ybr=reference.shape[0], + xtl=0, + ytl=0, + xbr=reference.shape[1], + ybr=reference.shape[0], ) img_stack = [] if len(original_video) != len(segm_video): @@ -283,30 +278,35 @@ def track(self, for frame in range(len(segm_video)): idx = frame + starting_frame # Crop and scale: - i = utils.rangescale(utils.cropbox(original_video[idx], box), rescale=(0, 1)) + i = utils.rangescale( + utils.cropbox(original_video[idx], box), rescale=(0, 1) + ) # Append i as is to input images stack: img_stack.append(i) # Get Save Path (File Name is same as Original Images + .format) - savepath = self.params['original_images_path'] - filename = savepath.replace('.tif', '') + savepath = self.params["original_images_path"] + filename = savepath.replace(".tif", "") # Init reader - xpreader = FakeReader(x=original_shape[1], - y=original_shape[0], - channels=0, - timepoints=len(segm_video), - filename=savepath, - original_video=original_video, - starting_frame=starting_frame - ) + xpreader = FakeReader( + x=original_shape[1], + y=original_shape[0], + channels=0, + timepoints=len(segm_video), + filename=savepath, + original_video=original_video, + starting_frame=starting_frame, + ) # Init Position - xp = pipeline.Position(position_nb=0, - reader=xpreader, - models=self.models, - drift_correction=False, - crop_windows=cfg.crop_windows) + xp = pipeline.Position( + position_nb=0, + reader=xpreader, + models=self.models, + drift_correction=False, + crop_windows=cfg.crop_windows, + ) # Preprocess xp.preprocess(rotation_correction=False) @@ -325,13 +325,14 @@ def track(self, tracked_video = np.array(xp.rois[0].label_stack, dtype=np.uint8) # Save Results - if self.params['legacy']: + if self.params["legacy"]: xp.legacysave(filename + ".mat") - if self.params['pickle']: + if self.params["pickle"]: import pickle + with open(filename + ".pkl", "wb") as file: pickle.dump(self, file) - if self.params['movie']: + if self.params["movie"]: movie = xp.results_movie(frames=list(range(len(segm_video)))) utils.vidwrite(movie, filename + ".mp4", verbose=False) diff --git a/cellacdc/trackers/trackpy/__init__.py b/cellacdc/trackers/trackpy/__init__.py index 4051611f2..58286025c 100644 --- a/cellacdc/trackers/trackpy/__init__.py +++ b/cellacdc/trackers/trackpy/__init__.py @@ -1,3 +1,3 @@ from cellacdc import myutils -myutils.check_install_package('trackpy') \ No newline at end of file +myutils.check_install_package("trackpy") diff --git a/cellacdc/trackers/trackpy/trackpy_tracker.py b/cellacdc/trackers/trackpy/trackpy_tracker.py index 8912130cb..7aecfc9b6 100644 --- a/cellacdc/trackers/trackpy/trackpy_tracker.py +++ b/cellacdc/trackers/trackpy/trackpy_tracker.py @@ -15,14 +15,18 @@ DEBUG = False + class SearchRangeUnits: - values = ['micrometre', 'pixels'] + values = ["micrometre", "pixels"] + class NeighborStrategies: - values = ['KDTree', 'BTree'] + values = ["KDTree", "BTree"] + class LinkStrategies: - values = ['recursive', 'nonrecursive', 'numba', 'hybrid', 'drop', 'auto'] + values = ["recursive", "nonrecursive", "numba", "hybrid", "drop", "auto"] + class tracker: def __init__(self) -> None: @@ -36,73 +40,73 @@ def _set_frame_features(self, lab, frame_i, tp_df): zc = None else: zc, yc, xc = obj.centroid - tp_df['x'].append(xc) - tp_df['y'].append(yc) + tp_df["x"].append(xc) + tp_df["y"].append(yc) if zc is not None: - tp_df['z'].append(zc) - tp_df['frame'].append(frame_i) - tp_df['ID'].append(obj.label) + tp_df["z"].append(zc) + tp_df["frame"].append(frame_i) + tp_df["ID"].append(obj.label) def _get_pos_columns( - self, tp_df, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ, - search_range_unit - ): - is_3D = 'z' in tp_df.columns - if search_range_unit == 'pixels': + self, tp_df, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ, search_range_unit + ): + is_3D = "z" in tp_df.columns + if search_range_unit == "pixels": if is_3D: - return ['x', 'y', 'z'] + return ["x", "y", "z"] else: - return ['x', 'y'] - + return ["x", "y"] + pos_columns = [] if is_3D: - tp_df['z_um'] = tp_df['z'] * PhysicalSizeZ - pos_columns.append('z_um') - - tp_df['x_um'] = tp_df['x'] * PhysicalSizeX - tp_df['y_um'] = tp_df['y'] * PhysicalSizeY - pos_columns = ['x_um', 'y_um', *pos_columns] - return pos_columns - + tp_df["z_um"] = tp_df["z"] * PhysicalSizeZ + pos_columns.append("z_um") + + tp_df["x_um"] = tp_df["x"] * PhysicalSizeX + tp_df["y_um"] = tp_df["y"] * PhysicalSizeY + pos_columns = ["x_um", "y_um", *pos_columns] + return pos_columns + def track( - self, segm_video, - search_range_unit: SearchRangeUnits='micrometre', - search_range=10.0, - memory=0, - adaptive_stop: float=1.0, - adaptive_step=0.95, - dynamic_predictor=False, - neighbor_strategy: NeighborStrategies='KDTree', - link_strategy: LinkStrategies='recursive', - signals=None, - export_to=None, - PhysicalSizeX=1.0, - PhysicalSizeY=1.0, - PhysicalSizeZ=1.0, - export_to_extension='.csv' - ): + self, + segm_video, + search_range_unit: SearchRangeUnits = "micrometre", + search_range=10.0, + memory=0, + adaptive_stop: float = 1.0, + adaptive_step=0.95, + dynamic_predictor=False, + neighbor_strategy: NeighborStrategies = "KDTree", + link_strategy: LinkStrategies = "recursive", + signals=None, + export_to=None, + PhysicalSizeX=1.0, + PhysicalSizeY=1.0, + PhysicalSizeZ=1.0, + export_to_extension=".csv", + ): """_summary_ Parameters ---------- search_range_unit : {'micrometres', 'pixels'}, default 'micrometres' - Physical unit of the `search_range`. If 'pixels', PhysicalSizes will - be ignored. + Physical unit of the `search_range`. If 'pixels', PhysicalSizes will + be ignored. search_range : float, optional - Radius of the circle centerd at the object at previous frame where - to search for the object at current frame. - - This is equivalent to the maximum distance the object is allowed - to travel between frames to be considered the same object. - - The unit is pixels for isotropic data (typically 2D over time) and + Radius of the circle centerd at the object at previous frame where + to search for the object at current frame. + + This is equivalent to the maximum distance the object is allowed + to travel between frames to be considered the same object. + + The unit is pixels for isotropic data (typically 2D over time) and in micrometers for anisotropic data (typically 3D over time). - + Default is 10.0. adaptive_stop : float, default 1.0 - If not None, when encountering an oversize subnet, retry by - progressively reducing search_range until the subnet is solvable. - If search_range becomes less or equal than `adaptive_stop`, give up + If not None, when encountering an oversize subnet, retry by + progressively reducing search_range until the subnet is solvable. + If search_range becomes less or equal than `adaptive_stop`, give up and raise a `SubnetOversizeException`. adaptive_step : float, default 0.95 Reduce search_range by multiplying it by this factor. @@ -117,16 +121,16 @@ def track( ------- (T, Y, X) or (T, Z, Y, X) np.array of ints Tracked segmentation masks with the same shape as input `segm_video`. - """ + """ # Handle string input for adaptive_stop if isinstance(adaptive_stop, str): - if adaptive_stop == 'None': + if adaptive_stop == "None": adaptive_stop = None else: adaptive_stop = float(adaptive_stop) - + self.setProgressBarMaximum(signals, len(segm_video)) - + # Build tp DataFrame --> https://soft-matter.github.io/trackpy/v0.5.0/generated/trackpy.link.html#trackpy.link tp_df = defaultdict(list) pbar = tqdm(total=len(segm_video), ncols=100) @@ -135,33 +139,33 @@ def track( pbar.update() self.updateGuiProgressBar(signals) pbar.close() - + tp_df = pd.DataFrame(tp_df) pos_columns = self._get_pos_columns( - tp_df, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ, - search_range_unit + tp_df, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ, search_range_unit ) # Run tracker if dynamic_predictor: predictor = tp.predict.NearestVelocityPredict() else: predictor = tp - + tp_out_df = predictor.link_df( - tp_df, search_range, + tp_df, + search_range, memory=int(memory), - adaptive_stop=adaptive_stop, + adaptive_stop=adaptive_stop, adaptive_step=adaptive_step, neighbor_strategy=neighbor_strategy, link_strategy=link_strategy, - pos_columns=pos_columns - ).set_index('frame') - + pos_columns=pos_columns, + ).set_index("frame") + if export_to is not None: tp_out_df.to_csv(export_to) - - tp_out_df['particle'] += 1 # trackpy starts from 0 with tracked ids - + + tp_out_df["particle"] += 1 # trackpy starts from 0 with tracked ids + # Generate tracked video data tracked_video = np.zeros_like(segm_video) for frame_i, lab in enumerate(segm_video): @@ -177,66 +181,66 @@ def track( IDs_curr_untracked = [obj.label for obj in rp] if DEBUG: - printl(f'Current untracked IDs: {IDs_curr_untracked}') + printl(f"Current untracked IDs: {IDs_curr_untracked}") if not IDs_curr_untracked: # No cells segmented continue - + try: - tracked_IDs = tp_out_df_frame['particle'].astype(int).to_list() - old_IDs = tp_out_df_frame['ID'].astype(int).to_list() + tracked_IDs = tp_out_df_frame["particle"].astype(int).to_list() + old_IDs = tp_out_df_frame["ID"].astype(int).to_list() except AttributeError: # Single cell - tracked_IDs = [int(tp_out_df_frame['particle'])] - old_IDs = [int(tp_out_df_frame['ID'])] - + tracked_IDs = [int(tp_out_df_frame["particle"])] + old_IDs = [int(tp_out_df_frame["ID"])] + if not tracked_IDs: # No cells tracked continue - uniqueID = max((max(tracked_IDs), max(IDs_curr_untracked)))+1 + uniqueID = max((max(tracked_IDs), max(IDs_curr_untracked))) + 1 if DEBUG: - print('-------------------------') - print(f'Tracking frame n. {frame_i+1}') + print("-------------------------") + print(f"Tracking frame n. {frame_i + 1}") for old_ID, tracked_ID in zip(old_IDs, tracked_IDs): - print(f'Tracking ID {old_ID} --> {tracked_ID}') - print('-------------------------') - + print(f"Tracking ID {old_ID} --> {tracked_ID}") + print("-------------------------") + tracked_lab = CellACDC_tracker.indexAssignment( - old_IDs, tracked_IDs, IDs_curr_untracked, - lab.copy(), rp, uniqueID + old_IDs, tracked_IDs, IDs_curr_untracked, lab.copy(), rp, uniqueID ) tracked_video[frame_i] = tracked_lab self.updateGuiProgressBar(signals) - + return tracked_video - + def setProgressBarMaximum(self, signals, num_frames): if signals is None: return - - if hasattr(signals, 'innerPbar_available'): + + if hasattr(signals, "innerPbar_available"): if signals.innerPbar_available: # Use inner pbar of the GUI widget (top pbar is for positions) - signals.sigInitInnerPbar.emit(num_frames*2) + signals.sigInitInnerPbar.emit(num_frames * 2) return - - signals.initProgressBar.emit(num_frames*2) - + + signals.initProgressBar.emit(num_frames * 2) + def updateGuiProgressBar(self, signals): if signals is None: return - - if hasattr(signals, 'innerPbar_available'): + + if hasattr(signals, "innerPbar_available"): if signals.innerPbar_available: # Use inner pbar of the GUI widget (top pbar is for positions) signals.innerProgressBar.emit(1) return signals.progressBar.emit(1) - + + def url_help(): - return 'https://soft-matter.github.io/trackpy/v0.5.0/generated/trackpy.link.html#trackpy.link' \ No newline at end of file + return "https://soft-matter.github.io/trackpy/v0.5.0/generated/trackpy.link.html#trackpy.link" diff --git a/cellacdc/transformation.py b/cellacdc/transformation.py index 113978ba4..4dccc0974 100644 --- a/cellacdc/transformation.py +++ b/cellacdc/transformation.py @@ -1,4 +1,4 @@ -import xml.etree.ElementTree as ET +import xml.etree.ElementTree as ET import math import pandas as pd @@ -13,6 +13,7 @@ from typing import List + def resize_lab(lab, output_shape, rp=None): if rp is None: rp = skimage.measure.regionprops(lab) @@ -21,13 +22,13 @@ def resize_lab(lab, output_shape, rp=None): for obj in rp: _lab_obj_to_resize[obj.slice][obj.image] = 1.0 _lab_obj_resized = resize( - _lab_obj_to_resize, output_shape, anti_aliasing=True, - preserve_range=True + _lab_obj_to_resize, output_shape, anti_aliasing=True, preserve_range=True ).round() lab_resized[_lab_obj_resized == 1.0] = obj.label _lab_obj_to_resize[:] = 0.0 return lab_resized + def crop_2D(img, xy_range, tolerance=0, return_copy=True): (xmin, xmax), (ymin, ymax) = xy_range Y, X = img.shape @@ -39,7 +40,7 @@ def crop_2D(img, xy_range, tolerance=0, return_copy=True): xmax = X if xmax > X else round(xmax) ymin = 0 if ymin < 0 else round(ymin) ymax = Y if ymax > Y else round(ymax) - crop_shape = (ymax-ymin, xmax-xmin) + crop_shape = (ymax - ymin, xmax - xmin) crop_slice = (slice(ymin, ymax, None), slice(xmin, xmax, None)) if return_copy: cropped = np.zeros(crop_shape, dtype=img.dtype) @@ -48,17 +49,19 @@ def crop_2D(img, xy_range, tolerance=0, return_copy=True): cropped = img[crop_slice] return cropped, crop_slice + def del_objs_outside_segm_roi(segm_roi, segm): - del_IDs = np.unique(segm[segm_roi==0]) + del_IDs = np.unique(segm[segm_roi == 0]) cleared_segm = segm.copy() clearedIDs = [] for del_ID in del_IDs: if del_ID == 0: continue - cleared_segm[segm==del_ID] = 0 + cleared_segm[segm == del_ID] = 0 clearedIDs.append(del_ID) return cleared_segm, clearedIDs + def trackmate_xml_to_df(xml_file): IDs = [] xx = [] @@ -69,28 +72,23 @@ def trackmate_xml_to_df(xml_file): Tracks = tree.getroot() for i, particle in enumerate(Tracks): - ID = i+1 + ID = i + 1 for t, detection in enumerate(particle): attrib = detection.attrib IDs.append(ID) - xx.append(attrib['x']) - yy.append(attrib['y']) - zz.append(attrib['z']) - frame_idxs.append(attrib['t']) - - df = pd.DataFrame({ - 'frame_i': frame_idxs, - 'ID': IDs, - 'x': xx, - 'y': yy, - 'z': zz - }) + xx.append(attrib["x"]) + yy.append(attrib["y"]) + zz.append(attrib["z"]) + frame_idxs.append(attrib["t"]) + + df = pd.DataFrame({"frame_i": frame_idxs, "ID": IDs, "x": xx, "y": yy, "z": zz}) return df + def retrack_based_on_untracked_first_frame( - tracked_video, first_untracked_lab, uniqueID=None - ): - """Re-tack the objects in the first frame of `tracked_video` to have the + tracked_video, first_untracked_lab, uniqueID=None +): + """Re-tack the objects in the first frame of `tracked_video` to have the same IDs as in `first_untracked_lab` Parameters @@ -98,30 +96,30 @@ def retrack_based_on_untracked_first_frame( tracked_video : (T, Y, X) or (T, Z, Y, X) of ints Array with the segmentation instances of the tracked objects first_untracked_lab : (Y, X) or (Z, Y, X) of ints - Array with the segmentation instances of the objects in the first + Array with the segmentation instances of the objects in the first frame before they were tracked uniqueID : int, optional - If not None, it will be used as first of the unique IDs. - If None, this will be initialized to the maximum in `tracked_video`. + If not None, it will be used as first of the unique IDs. + If None, this will be initialized to the maximum in `tracked_video`. Default is None. Returns ------- (T, Y, X) or (T, Z, Y, X) of ints - Tracked video where the objects in the first frame has the same IDs as - in `first_untracked_lab`. - + Tracked video where the objects in the first frame has the same IDs as + in `first_untracked_lab`. + Notes ----- - The idea of this function is to ensure that objects in the first frame - before and after tracking have the same IDs. This is needed to ensure - continuity of obejct IDs when tracking portions of the video in - different batches. - """ - + The idea of this function is to ensure that objects in the first frame + before and after tracking have the same IDs. This is needed to ensure + continuity of obejct IDs when tracking portions of the video in + different batches. + """ + first_tracked_lab = tracked_video[0] first_tracked_rp = skimage.measure.regionprops(first_tracked_lab) - + tracked_to_untracked_mapper = {} for obj in first_tracked_rp: untracked_ID = first_untracked_lab[obj.slice][obj.image][0] @@ -131,18 +129,16 @@ def retrack_based_on_untracked_first_frame( if not tracked_to_untracked_mapper: return tracked_video - + first_untracked_rp = skimage.measure.regionprops(first_untracked_lab) first_untracked_IDs = [obj.label for obj in first_untracked_rp] - + if uniqueID is None: uniqueID = np.max(tracked_video) + 1 - uniqueIDs = np.arange(uniqueID, uniqueID+len(first_untracked_IDs)) + uniqueIDs = np.arange(uniqueID, uniqueID + len(first_untracked_IDs)) + + untracked_to_unique_mapper = dict(zip(first_untracked_IDs, uniqueIDs)) - untracked_to_unique_mapper = ( - dict(zip(first_untracked_IDs, uniqueIDs)) - ) - pbar = tqdm(total=len(tracked_video), ncols=100) for frame_i, tracked_lab in enumerate(tracked_video): rp_tracked = skimage.measure.regionprops(tracked_lab) @@ -151,39 +147,39 @@ def retrack_based_on_untracked_first_frame( if new_unique_ID is None: # Untracked ID not present in tracked labels continue - + untracked_ID = tracked_to_untracked_mapper.get(obj_tracked.label) if untracked_ID is None: # No need to make ID unique because it will not change later continue - - # Replace untracked ID with a unique ID to prevent merging when later - # we will replace tracked IDs of first frame to their corresponding + + # Replace untracked ID with a unique ID to prevent merging when later + # we will replace tracked IDs of first frame to their corresponding # untracked ID - tracked_video[tracked_video==obj_tracked.label] = new_unique_ID + tracked_video[tracked_video == obj_tracked.label] = new_unique_ID - # Update tracked to untracked mapper because now tracked_video + # Update tracked to untracked mapper because now tracked_video # changed and we would not find the same ID later tracked_to_untracked_mapper[new_unique_ID] = ( tracked_to_untracked_mapper.pop(obj_tracked.label) ) - + pbar.update() pbar.close() - + uniqueID = np.max(tracked_video) + 1 - + untracked_to_unique_mapper = {} pbar = tqdm(total=len(tracked_video), ncols=100) for frame_i, tracked_lab in enumerate(tracked_video): rp_tracked = skimage.measure.regionprops(tracked_lab) - rp_tracked_dict = {obj.label:obj for obj in rp_tracked} + rp_tracked_dict = {obj.label: obj for obj in rp_tracked} for obj_tracked in rp_tracked: untracked_ID = tracked_to_untracked_mapper.get(obj_tracked.label) if untracked_ID is None: # Untracked ID not present in tracked labels continue - + untracked_obj = rp_tracked_dict.get(untracked_ID) if untracked_obj is not None: new_unique_ID = untracked_to_unique_mapper.get(untracked_ID) @@ -191,23 +187,20 @@ def retrack_based_on_untracked_first_frame( new_unique_ID = uniqueID untracked_to_unique_mapper[untracked_ID] = new_unique_ID uniqueID += 1 - + # Make sure to change existing IDs to unique lab = tracked_video[frame_i] - lab[untracked_obj.slice][untracked_obj.image] = ( - new_unique_ID - ) - - # Replace tracked ID of first frame to the untracked ID of the - # reference - tracked_video[frame_i][obj_tracked.slice][obj_tracked.image] = ( - untracked_ID - ) + lab[untracked_obj.slice][untracked_obj.image] = new_unique_ID + + # Replace tracked ID of first frame to the untracked ID of the + # reference + tracked_video[frame_i][obj_tracked.slice][obj_tracked.image] = untracked_ID pbar.update() pbar.close() - + return tracked_video + def remove_padding_2D(arr, val=0, return_crop_slice=False): crop_slice = [] for a, ax in enumerate((1, 0)): @@ -216,27 +209,28 @@ def remove_padding_2D(arr, val=0, return_crop_slice=False): pad_ax_mask = np.isnan(pad_ax) else: pad_ax_mask = pad_ax == val - + pad_ax_left = 0 for i, val in enumerate(pad_ax_mask): if not val: pad_ax_left = i - break - + break + pad_ax_right = arr.shape[a] for j, val in enumerate(pad_ax_mask[::-1]): if not val: pad_ax_right -= j - break - + break + crop_slice.append(slice(pad_ax_left, pad_ax_right)) - + crop_slice = tuple(crop_slice) if return_crop_slice: return arr[crop_slice], crop_slice - + return arr[tuple(crop_slice)] + def crop_outer_padding(arr, value=0, copy=False): if isinstance(value, (int, float)): if arr.ndim > 2: @@ -251,7 +245,7 @@ def crop_outer_padding(arr, value=0, copy=False): # which rows/cols are entirely padding? row_is_pad = np.all(padding_pixel, axis=1) col_is_pad = np.all(padding_pixel, axis=0) - + # build mask padding_mask = np.zeros_like(padding_pixel) @@ -262,57 +256,53 @@ def crop_outer_padding(arr, value=0, copy=False): is_top_padded = True except ValueError: is_top_padded = False - + try: bottom = len(row_is_pad) - np.argmax(~row_is_pad[::-1]) padding_mask[bottom:, :] = True is_bottom_padded = True except ValueError: is_bottom_padded = False - + try: left = np.argmax(~col_is_pad) padding_mask[:, :left] = True is_left_padded = True except ValueError: is_left_padded = False - + try: right = len(col_is_pad) - np.argmax(~col_is_pad[::-1]) padding_mask[:, right:] = True is_right_padded = True except ValueError: is_right_padded = False - - is_padded = ( - is_top_padded or is_bottom_padded or - is_left_padded or is_right_padded - ) - + + is_padded = is_top_padded or is_bottom_padded or is_left_padded or is_right_padded + if not is_padded: return arr.copy() if copy else arr - + # Crop using regionprops - padding_mask_rp = skimage.measure.regionprops( - skimage.measure.label(~padding_mask) - ) + padding_mask_rp = skimage.measure.regionprops(skimage.measure.label(~padding_mask)) if not padding_mask_rp: return arr.copy() if copy else arr - + padding_mask_obj = padding_mask_rp[0] top, left, bottom, right = padding_mask_obj.bbox - + # Crop cropped_arr = arr[top:bottom, left:right] - + if copy: cropped_arr = cropped_arr.copy() - + return cropped_arr + def snap_xy_to_closest_angle(x0, y0, x1, y1, angle_factor=15): # Snap to closest angle divisible by angle_factor degrees - angle = math.degrees(math.atan2(y1-y0, x1-x0)) + angle = math.degrees(math.atan2(y1 - y0, x1 - x0)) snap_angle = math.radians(core.closest_n_divisible_by_m(angle, angle_factor)) dist = math.dist((x0, y0), (x1, y1)) dx = dist * math.cos(snap_angle) @@ -320,6 +310,7 @@ def snap_xy_to_closest_angle(x0, y0, x1, y1, angle_factor=15): x1, y1 = x0 + dx, y0 + dy return x1, y1 + def correct_img_dimension(image, input_dims: List[str], output_dims: List[str]): """Resort and expand the image to the correct dimensions (output_dims). @@ -343,7 +334,7 @@ def correct_img_dimension(image, input_dims: List[str], output_dims: List[str]): if input_dims == output_dims: return image - + if image.ndim != len(input_dims): raise ValueError( f"Image has {image.ndim} dimensions but expected {len(input_dims)}" @@ -352,16 +343,17 @@ def correct_img_dimension(image, input_dims: List[str], output_dims: List[str]): missing_dims = set(output_dims) - set(input_dims) input_dims = list(input_dims) output_dims = list(output_dims) - + for missing_dim in missing_dims: image = np.expand_dims(image, axis=output_dims.index(missing_dim)) input_dims.insert(output_dims.index(missing_dim), missing_dim) - + dim_map = [input_dims.index(dim) for dim in output_dims] image = np.transpose(image, dim_map) - + return image + def clear_objects_not_in_mask(lab, mask): """Clear objects in lab that are not fully contained in mask. @@ -386,5 +378,5 @@ def clear_objects_not_in_mask(lab, mask): if np.all(mask[obj.slice][obj.image]): continue lab_cleared[obj.slice][obj.image] = 0 - - return lab_cleared \ No newline at end of file + + return lab_cleared diff --git a/cellacdc/urls.py b/cellacdc/urls.py index 094b1cc4d..bcd00a75c 100644 --- a/cellacdc/urls.py +++ b/cellacdc/urls.py @@ -1,29 +1,29 @@ -contribute_url = 'https://github.com/SchmollerLab/Cell_ACDC/blob/main/cellacdc/docs/source/contributing.rst' +contribute_url = "https://github.com/SchmollerLab/Cell_ACDC/blob/main/cellacdc/docs/source/contributing.rst" -github_url = 'https://github.com/SchmollerLab/Cell_ACDC' +github_url = "https://github.com/SchmollerLab/Cell_ACDC" -issues_url = 'https://github.com/SchmollerLab/Cell_ACDC/issues' +issues_url = "https://github.com/SchmollerLab/Cell_ACDC/issues" -forum_url = 'https://github.com/SchmollerLab/Cell_ACDC/discussions' +forum_url = "https://github.com/SchmollerLab/Cell_ACDC/discussions" -resources_url = 'https://github.com/SchmollerLab/Cell_ACDC#resources' +resources_url = "https://github.com/SchmollerLab/Cell_ACDC#resources" -my_contact_url = 'https://www.helmholtz-munich.de/ife/about-us/people/staff-detail/ma/8873/Dr.-Padovani/index.html' +my_contact_url = "https://www.helmholtz-munich.de/ife/about-us/people/staff-detail/ma/8873/Dr.-Padovani/index.html" -user_manual_url = 'https://github.com/SchmollerLab/Cell_ACDC/blob/main/UserManual/Cell-ACDC_User_Manual.pdf' +user_manual_url = "https://github.com/SchmollerLab/Cell_ACDC/blob/main/UserManual/Cell-ACDC_User_Manual.pdf" -cite_url = 'https://bmcbiol.biomedcentral.com/articles/10.1186/s12915-022-01372-6' +cite_url = "https://bmcbiol.biomedcentral.com/articles/10.1186/s12915-022-01372-6" -dataprep_docs = 'https://cell-acdc.readthedocs.io/en/latest/getting-started.html#preparing-data-for-further-analysis-data-prep' +dataprep_docs = "https://cell-acdc.readthedocs.io/en/latest/getting-started.html#preparing-data-for-further-analysis-data-prep" -docs_homepage = 'https://cell-acdc.readthedocs.io/en/latest' +docs_homepage = "https://cell-acdc.readthedocs.io/en/latest" -bioformats_jar_home_url = 'https://downloads.openmicroscopy.org/bio-formats/7.2.0/artifacts/bioformats_package.jar' +bioformats_jar_home_url = "https://downloads.openmicroscopy.org/bio-formats/7.2.0/artifacts/bioformats_package.jar" -bioformats_jar_hmgu_url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/NnGCP7nGKHz9Tds/download/bioformats_package.jar' +bioformats_jar_hmgu_url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/NnGCP7nGKHz9Tds/download/bioformats_package.jar" -bioformats_download_page = 'https://www.openmicroscopy.org/bio-formats/downloads/' +bioformats_download_page = "https://www.openmicroscopy.org/bio-formats/downloads/" -install_pytorch = 'https://pytorch.org/get-started/locally/' +install_pytorch = "https://pytorch.org/get-started/locally/" -fiji_downloads = 'https://imagej.net/software/fiji/downloads' \ No newline at end of file +fiji_downloads = "https://imagej.net/software/fiji/downloads" diff --git a/cellacdc/utils/acdcToSymDiv.py b/cellacdc/utils/acdcToSymDiv.py index 24f514f2f..551244569 100644 --- a/cellacdc/utils/acdcToSymDiv.py +++ b/cellacdc/utils/acdcToSymDiv.py @@ -7,24 +7,20 @@ from tqdm import tqdm from qtpy.QtCore import Signal, QThread -from qtpy.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, QStyle -) +from qtpy.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QStyle + +from .. import widgets, apps, workers, html_utils, myutils, gui, load, printl -from .. import ( - widgets, apps, workers, html_utils, myutils, - gui, load, printl -) class AcdcToSymDivUtil(QDialog): def __init__(self, expPaths, app, parent=None): super().__init__(parent) - self.setWindowTitle('Utility to add symmetric division table') + self.setWindowTitle("Utility to add symmetric division table") self.parent = parent logger, logs_path, log_path, log_filename = myutils.setupLogger( - module='utils.AcdcToSymDiv' + module="utils.AcdcToSymDiv" ) self.logger = logger self.log_path = log_path @@ -41,11 +37,11 @@ def __init__(self, expPaths, app, parent=None): infoLayout = QHBoxLayout() infoTxt = html_utils.paragraph( - 'Computing lineage tree table for symmetrically dividing cells...' + "Computing lineage tree table for symmetrically dividing cells..." ) iconLabel = QLabel(self) - standardIcon = getattr(QStyle, 'SP_MessageBoxInformation') + standardIcon = getattr(QStyle, "SP_MessageBoxInformation") icon = self.style().standardIcon(standardIcon) pixmap = icon.pixmap(60, 60) iconLabel.setPixmap(pixmap) @@ -54,7 +50,7 @@ def __init__(self, expPaths, app, parent=None): infoLayout.addWidget(QLabel(infoTxt)) buttonsLayout = QHBoxLayout() - cancelButton = widgets.cancelPushButton('Cancel') + cancelButton = widgets.cancelPushButton("Cancel") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -72,8 +68,9 @@ def showEvent(self, event): def runWorker(self): self.progressWin = apps.QDialogWorkerProgress( - title='Building lineage tree table', parent=self, - pbarDesc='Building lineage tree table...' + title="Building lineage tree table", + parent=self, + pbarDesc="Building lineage tree table...", ) self.progressWin.sigClosed.connect(self.progressWinClosed) self.progressWin.show(self.app) @@ -103,31 +100,31 @@ def workerInitProgressbar(self, totalIter): if totalIter == 1: totalIter = 0 self.progressWin.mainPbar.setMaximum(totalIter) - + def workerUpdateProgressbar(self, step): self.progressWin.mainPbar.update(step) - + def workerUpdatePbarDesc(self, desc): self.progressWin.progressLabel.setText(desc) - + def warnPermissionError(self, traceback_str, path): err_msg = html_utils.paragraph( - 'The file below is open in another app ' - '(Excel maybe?).

    ' - f'{path}

    ' + "The file below is open in another app " + "(Excel maybe?).

    " + f"{path}

    " 'Close file and then press "Ok".' ) msg = widgets.myMessageBox(wrapText=False) msg.setDetailedText(traceback_str) - msg.warning(self, 'Permission error', err_msg) + msg.warning(self, "Permission error", err_msg) self.worker.waitCond.wakeAll() - + def selectSegmFileLoadData(self, exp_path, pos_foldernames): # Get end name of every existing segmentation file existingSegmEndNames = set() for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") basename, chNames = myutils.getBasenameAndChNames(images_path) # Use first found channel, it doesn't matter for metrics for chName in chNames: @@ -142,9 +139,7 @@ def selectSegmFileLoadData(self, exp_path, pos_foldernames): _posData = load.loadData(filePath, chName) _posData.getBasenameAndChNames() segm_files = load.get_segm_files(_posData.images_path) - _existingEndnames = load.get_endnames( - _posData.basename, segm_files - ) + _existingEndnames = load.get_endnames(_posData.basename, segm_files) existingSegmEndNames.update(_existingEndnames) if len(existingSegmEndNames) == 1: @@ -152,26 +147,24 @@ def selectSegmFileLoadData(self, exp_path, pos_foldernames): self.worker.waitCond.wakeAll() return - win = apps.SelectSegmFileDialog( - existingSegmEndNames, exp_path, parent=self - ) + win = apps.SelectSegmFileDialog(existingSegmEndNames, exp_path, parent=self) win.exec_() self.endFilenameSegm = win.selectedItemText self.worker.abort = win.cancel self.worker.waitCond.wakeAll() - + def addRegionPropsErrors(self, traceback_format, error_message): - self.logger.info('') - print('====================================') + self.logger.info("") + print("====================================") self.logger.info(traceback_format) - print('====================================') + print("====================================") self.worker.regionPropsErrors[error_message] = traceback_format - + def addCombinedMetricsError(self, traceback_format, func_name): - self.logger.info('') - print('====================================') + self.logger.info("") + print("====================================") self.logger.info(traceback_format) - print('====================================') + print("====================================") self.worker.customMetricsErrors[func_name] = traceback_format def skipEvent(self, dummy): @@ -189,17 +182,16 @@ def abortCallback(self): self.worker.abort = True else: self.close() - + def warnMissingAnnot(self, missingAnnotErrors): win = apps.ComputeMetricsErrorsDialog( - missingAnnotErrors, self.logs_path, log_type='missing_annot', - parent=self + missingAnnotErrors, self.logs_path, log_type="missing_annot", parent=self ) win.exec_() - + def warnErrors(self, errors): win = apps.ComputeMetricsErrorsDialog( - errors, self.logs_path, log_type='generic', parent=self + errors, self.logs_path, log_type="generic", parent=self ) win.exec_() @@ -208,39 +200,39 @@ def workerCritical(self, error): raise error except: traceback_str = traceback.format_exc() - print('='*20) + print("=" * 20) self.worker.logger.log(traceback_str) - print('='*20) + print("=" * 20) def workerFinished(self, worker): if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() - + if worker.abort: - txt = 'Adding lineage tree table ABORTED.' + txt = "Adding lineage tree table ABORTED." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.warning(self, 'Process aborted', html_utils.paragraph(txt)) + msg.warning(self, "Process aborted", html_utils.paragraph(txt)) elif worker.errors or worker.missingAnnotErrors: if worker.errors: self.warnErrors(worker.errors) else: self.warnMissingAnnot(worker.missingAnnotErrors) - txt = 'Adding lineage tree table completed WITH ERRORS.' + txt = "Adding lineage tree table completed WITH ERRORS." msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.warning(self, 'Process warning', html_utils.paragraph(txt)) + msg.warning(self, "Process warning", html_utils.paragraph(txt)) else: - txt = 'Adding lineage tree table completed.' + txt = "Adding lineage tree table completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) self.worker = None self.progressWin = None self.close() - def workerProgress(self, text, loggerLevel='INFO'): + def workerProgress(self, text, loggerLevel="INFO"): if self.progressWin is not None: self.progressWin.logConsole.append(text) self.logger.log(getattr(logging, loggerLevel), text) diff --git a/cellacdc/utils/align.py b/cellacdc/utils/align.py index 5938367a6..70aafa2f3 100755 --- a/cellacdc/utils/align.py +++ b/cellacdc/utils/align.py @@ -6,28 +6,33 @@ from .base import NewThreadMultipleExpBaseUtil + class alignWin(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None - ): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths - + def runWorker(self): self.worker = workers.AlignWorker(self) self.worker.sigAskUseSavedShifts.connect(self.askUseSavedShifts) self.worker.sigAskSelectChannel.connect(self.askSelectChannel) self.worker.sigAborted.connect(self.workerAborted) super().runWorker(self.worker) - + def showEvent(self, event): self.runWorker() - + def askUseSavedShifts(self, exp_path, basename): txt = html_utils.paragraph(f""" Some or all the Positions in this experiment folder

    @@ -37,34 +42,39 @@ def askUseSavedShifts(self, exp_path, basename): """) msg = widgets.myMessageBox(wrapText=False, showCentered=False) _, useShiftsButton, ignoreShiftsButton, revertButton = msg.question( - self, 'Select how saved shifts', txt, + self, + "Select how saved shifts", + txt, buttonsTexts=( - 'Cancel', 'Apply alignment from saved shifts', - 'Ignore saved shifts and compute alignment', - 'Revert alignment using saved shifts' - ) + "Cancel", + "Apply alignment from saved shifts", + "Ignore saved shifts and compute alignment", + "Revert alignment using saved shifts", + ), ) if msg.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() - + self.worker.revertedAlignEndname = None if msg.clickedButton == useShiftsButton: - savedShiftsHow = 'use_saved_shifts' + savedShiftsHow = "use_saved_shifts" elif msg.clickedButton == ignoreShiftsButton: - savedShiftsHow = 'ignore_saved_shifts' + savedShiftsHow = "ignore_saved_shifts" elif msg.clickedButton == revertButton: - savedShiftsHow = 'rever_alignment' + savedShiftsHow = "rever_alignment" txt = html_utils.paragraph(f""" How do you want to save the image file with reverted alignment? """) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - overWriteButton = widgets.savePushButton('Overwrite existing file') - saveAsButton = widgets.newFilePushButton('Save as new file...') + overWriteButton = widgets.savePushButton("Overwrite existing file") + saveAsButton = widgets.newFilePushButton("Save as new file...") _, overWriteButton, saveAsButton = msg.question( - self, 'Select how saved shifts', txt, - buttonsTexts=('Cancel', overWriteButton, saveAsButton), - showDialog=False + self, + "Select how saved shifts", + txt, + buttonsTexts=("Cancel", overWriteButton, saveAsButton), + showDialog=False, ) saveAsButton.clicked.disconnect() saveAsCallback = partial(self.askAppendedName, basename, msg) @@ -73,16 +83,19 @@ def askUseSavedShifts(self, exp_path, basename): if msg.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() - + self.worker.savedShiftsHow = savedShiftsHow self.worker.waitCond.wakeAll() - + def askAppendedName(self, basename, parent): win = apps.filenameDialog( - ext='.tif', title='Reverted alignment data filename', - hintText='Insert a text to append to the filename', - parent=self, basename=basename, allowEmpty=False + ext=".tif", + title="Reverted alignment data filename", + hintText="Insert a text to append to the filename", + parent=self, + basename=basename, + allowEmpty=False, ) win.exec_() if win.cancel: @@ -90,33 +103,36 @@ def askAppendedName(self, basename, parent): self.worker.revertedAlignEndname = win.entryText parent.cancel = False parent.close() - + def askSelectChannel(self, channels): selectChannelWin = apps.QDialogCombobox( - 'Select channel', channels, 'Select reference channel for the aligment', - CbLabel='Select channel: ', parent=self + "Select channel", + channels, + "Select reference channel for the aligment", + CbLabel="Select channel: ", + parent=self, ) selectChannelWin.exec_() if selectChannelWin.cancel: self.worker.abort = True - self.worker.waitCond.wakeAll() - + self.worker.waitCond.wakeAll() + self.worker.chName = selectChannelWin.selectedItemText self.worker.waitCond.wakeAll() def workerAborted(self): self.workerFinished(None, aborted=True) - + def workerFinished(self, worker, aborted=False): if aborted: - txt = 'Aligning frames process CANCELLED.' + txt = "Aligning frames process CANCELLED." else: - txt = 'Aligning frames process completed.' + txt = "Aligning frames process completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) if aborted: - msg.warning(self, 'Process completed', html_utils.paragraph(txt)) + msg.warning(self, "Process completed", html_utils.paragraph(txt)) else: - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) super().workerFinished(worker) - self.close() \ No newline at end of file + self.close() diff --git a/cellacdc/utils/applyTrackFromTable.py b/cellacdc/utils/applyTrackFromTable.py index 47997efa8..eba0b55bb 100644 --- a/cellacdc/utils/applyTrackFromTable.py +++ b/cellacdc/utils/applyTrackFromTable.py @@ -9,39 +9,39 @@ from qtpy.QtWidgets import QFileDialog + class ApplyTrackingInfoFromTableUtil(base.MainThreadSinglePosUtilBase): def __init__( - self, app, title: str, infoText: str, parent=None, - callbackOnFinished=None - ): + self, app, title: str, infoText: str, parent=None, callbackOnFinished=None + ): module = myutils.get_module_name(__file__) - super().__init__( - app, title, module, infoText, parent - ) + super().__init__(app, title, module, infoText, parent) self.sigClose.connect(self.close) self.callbackOnFinished = callbackOnFinished @exception_handler def run(self, posPath): - self.logger.info('Reading exisiting segmentation file names...') + self.logger.info("Reading exisiting segmentation file names...") endFilenameSegm = self.selectSegmFileLoadData(posPath) if not endFilenameSegm: return False - + msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph( 'After clicking "Ok" you will be asked to select the table ' - 'file (.csv) containing the tracking information.' + "file
    (.csv) containing the tracking information." ) - msg.information(self, 'Instructions', txt) + msg.information(self, "Instructions", txt) if msg.cancel: return False - + csvPath = QFileDialog.getOpenFileName( - self, 'Select table with tracking info', posPath, - "CSV files (*.csv);;All Files (*)" + self, + "Select table with tracking info", + posPath, + "CSV files (*.csv);;All Files (*)", )[0] if not csvPath: return False @@ -55,32 +55,32 @@ def run(self, posPath): win.exec_() if win.cancel: return False - + columnsInfo = { - 'frameIndexCol': win.frameIndexCol, - 'trackIDsCol': win.trackedIDsCol, - 'maskIDsCol': win.maskIDsCol, - 'xCentroidCol': win.xCentroidCol, - 'yCentroidCol': win.yCentroidCol, - 'parentIDcol': win.parentIDcol, - 'isFirstFrameOne': win.isFirstFrameOne, - 'deleteUntrackedIDs': win.deleteUntrackedIDs + "frameIndexCol": win.frameIndexCol, + "trackIDsCol": win.trackedIDsCol, + "maskIDsCol": win.maskIDsCol, + "xCentroidCol": win.xCentroidCol, + "yCentroidCol": win.yCentroidCol, + "parentIDcol": win.parentIDcol, + "isFirstFrameOne": win.isFirstFrameOne, + "deleteUntrackedIDs": win.deleteUntrackedIDs, } - - imagesPath = os.path.join(posPath, 'Images') + + imagesPath = os.path.join(posPath, "Images") segmFilename = [ - f for f in myutils.listdir(imagesPath) - if f.endswith(f'{endFilenameSegm}.npz') + f + for f in myutils.listdir(imagesPath) + if f.endswith(f"{endFilenameSegm}.npz") ][0] basename = os.path.splitext(segmFilename)[0] - overWriteButton = widgets.savePushButton( - 'Overwrite existing segmentation file' - ) + overWriteButton = widgets.savePushButton("Overwrite existing segmentation file") win = apps.filenameDialog( - basename=f'{basename}_', - hintText='Insert a filename for the tracked masks file:', - allowEmpty=False, defaultEntry='tracked', - additionalButtons=(overWriteButton, ) + basename=f"{basename}_", + hintText="Insert a filename for the tracked masks file:", + allowEmpty=False, + defaultEntry="tracked", + additionalButtons=(overWriteButton,), ) overWriteButton.clicked.connect(partial(self.overWriteClicked, win)) win.exec_() @@ -94,9 +94,8 @@ def run(self, posPath): self.worker.signals.finished.connect(self.callbackOnFinished) self.runWorker(self.worker) return True - + def overWriteClicked(self, win): win.cancel = False - win.filename = '' + win.filename = "" win.close() - diff --git a/cellacdc/utils/applyTrackFromTrackMateXML.py b/cellacdc/utils/applyTrackFromTrackMateXML.py index c1b96fd6a..30f758de2 100644 --- a/cellacdc/utils/applyTrackFromTrackMateXML.py +++ b/cellacdc/utils/applyTrackFromTrackMateXML.py @@ -10,48 +10,48 @@ from qtpy.QtWidgets import QFileDialog + class ApplyTrackingInfoFromTrackMateUtil(base.MainThreadSinglePosUtilBase): def __init__( - self, app, title: str, infoText: str, parent=None, - callbackOnFinished=None - ): + self, app, title: str, infoText: str, parent=None, callbackOnFinished=None + ): module = myutils.get_module_name(__file__) - super().__init__( - app, title, module, infoText, parent - ) + super().__init__(app, title, module, infoText, parent) self.sigClose.connect(self.close) self.callbackOnFinished = callbackOnFinished @exception_handler def run(self, posPath): - self.logger.info('Reading exisiting segmentation file names...') + self.logger.info("Reading exisiting segmentation file names...") endFilenameSegm = self.selectSegmFileLoadData(posPath) if not endFilenameSegm: return False - + msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph( 'After clicking "Ok" you will be asked to select the XML ' - 'file (.csv) containing the tracking information.' + "file (.csv) containing the tracking information." ) - msg.information(self, 'Instructions', txt) + msg.information(self, "Instructions", txt) if msg.cancel: return False - + xmlPath = QFileDialog.getOpenFileName( - self, 'Select table with tracking info', posPath, - "XML files (*.xml);;All Files (*)" + self, + "Select table with tracking info", + posPath, + "XML files (*.xml);;All Files (*)", )[0] if not xmlPath: return False xmlName = os.path.basename(xmlPath) self.logger.info(f'Parsing XML file "{xmlName}"...') - + df = transformation.trackmate_xml_to_df(xmlPath) - csvName = xmlName.replace('.xml', '.csv') + csvName = xmlName.replace(".xml", ".csv") csvPath = load.save_df_to_csv_temp_path(df, csvName, index=False) deleteUntrackedIDs, proceed = self.askDeleteUntrackedIDs() @@ -61,32 +61,32 @@ def run(self, posPath): # win.exec_() # if win.cancel: # return False - + columnsInfo = { - 'frameIndexCol': 'frame_i', - 'trackIDsCol': 'ID', - 'maskIDsCol': 'None', - 'xCentroidCol': 'x', - 'yCentroidCol': 'y', - 'parentIDcol': 'None', - 'isFirstFrameOne': False, - 'deleteUntrackedIDs': deleteUntrackedIDs + "frameIndexCol": "frame_i", + "trackIDsCol": "ID", + "maskIDsCol": "None", + "xCentroidCol": "x", + "yCentroidCol": "y", + "parentIDcol": "None", + "isFirstFrameOne": False, + "deleteUntrackedIDs": deleteUntrackedIDs, } - - imagesPath = os.path.join(posPath, 'Images') + + imagesPath = os.path.join(posPath, "Images") segmFilename = [ - f for f in myutils.listdir(imagesPath) - if f.endswith(f'{endFilenameSegm}.npz') + f + for f in myutils.listdir(imagesPath) + if f.endswith(f"{endFilenameSegm}.npz") ][0] basename = os.path.splitext(segmFilename)[0] - overWriteButton = widgets.savePushButton( - 'Overwrite existing segmentation file' - ) + overWriteButton = widgets.savePushButton("Overwrite existing segmentation file") win = apps.filenameDialog( - basename=f'{basename}_', - hintText='Insert a filename for the tracked masks file:', - allowEmpty=False, defaultEntry='tracked', - additionalButtons=(overWriteButton, ) + basename=f"{basename}_", + hintText="Insert a filename for the tracked masks file:", + allowEmpty=False, + defaultEntry="tracked", + additionalButtons=(overWriteButton,), ) overWriteButton.clicked.connect(partial(self.overWriteClicked, win)) win.exec_() @@ -100,20 +100,19 @@ def run(self, posPath): self.worker.signals.finished.connect(self.callbackOnFinished) self.runWorker(self.worker) return True - + def overWriteClicked(self, win): win.cancel = False - win.filename = '' + win.filename = "" win.close() - + def askDeleteUntrackedIDs(self): msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - 'Do you want to remove objects that were not tracked?' + "Do you want to remove objects that were not tracked?" ) _, yesButton, noButton = msg.question( - self, 'Delete untracked objects?', txt, - buttonsTexts=('Cancel', 'No', 'Yes') + self, "Delete untracked objects?", txt, buttonsTexts=("Cancel", "No", "Yes") ) if msg.cancel: return False, False diff --git a/cellacdc/utils/base.py b/cellacdc/utils/base.py index 263c9a7aa..2962c79ca 100644 --- a/cellacdc/utils/base.py +++ b/cellacdc/utils/base.py @@ -4,9 +4,7 @@ from natsort import natsorted from qtpy.QtCore import Qt, QThread, QSize -from qtpy.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel -) +from qtpy.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel from qtpy import QtGui from .. import exception_handler, myutils, html_utils, workers, widgets @@ -23,40 +21,57 @@ from qtpy.QtCore import Signal, QThread from qtpy.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, QStyle, QApplication + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QStyle, + QApplication, ) from .. import ( - widgets, apps, workers, html_utils, myutils, - gui, load, printl, exception_handler + widgets, + apps, + workers, + html_utils, + myutils, + gui, + load, + printl, + exception_handler, ) + def log_init_util(logger, expPaths: dict, util_title, util_module): exp_paths_str = pprint.pformat(expPaths, indent=1) - + logger.info(f'Utility title: "{util_title}"') logger.info(f'Utility module: "{util_module}"') - logger.info(f'Selected experiments:\n{exp_paths_str}') - - + logger.info(f"Selected experiments:\n{exp_paths_str}") + + class NewThreadMultipleExpBaseUtil(QDialog): def __init__( - self, expPaths, app: QApplication, title: str, module: str, - infoText: str, progressDialogueTitle: str, parent=None - ): + self, + expPaths, + app: QApplication, + title: str, + module: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): super().__init__(parent) self.setWindowTitle(title) self._title = title self._parent = parent - self.progressDialogueTitle = progressDialogueTitle + self.progressDialogueTitle = progressDialogueTitle + + logger, logs_path, log_path, log_filename = myutils.setupLogger(module=module) - logger, logs_path, log_path, log_filename = myutils.setupLogger( - module=module - ) - log_init_util(logger, expPaths, title, module) - + self.logger = logger self.log_path = log_path self.log_filename = log_filename @@ -75,7 +90,7 @@ def __init__( infoTxt = html_utils.paragraph(infoText) iconLabel = QLabel(self) - standardIcon = getattr(QStyle, 'SP_MessageBoxInformation') + standardIcon = getattr(QStyle, "SP_MessageBoxInformation") icon = self.style().standardIcon(standardIcon) pixmap = icon.pixmap(60, 60) iconLabel.setPixmap(pixmap) @@ -84,7 +99,7 @@ def __init__( infoLayout.addWidget(QLabel(infoTxt)) buttonsLayout = QHBoxLayout() - cancelButton = widgets.cancelPushButton('Cancel') + cancelButton = widgets.cancelPushButton("Cancel") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -99,8 +114,9 @@ def __init__( def runWorker(self, worker): self.progressWin = apps.QDialogWorkerProgress( - title=self.progressDialogueTitle, parent=self, - pbarDesc=f'{self.progressDialogueTitle}...' + title=self.progressDialogueTitle, + parent=self, + pbarDesc=f"{self.progressDialogueTitle}...", ) self.progressWin.sigClosed.connect(self.progressWinClosed) self.progressWin.show(self.app) @@ -115,33 +131,25 @@ def runWorker(self, worker): self.worker.signals.progress.connect(self.workerProgress) self.worker.signals.critical.connect(self.workerCritical) - self.worker.signals.sigSelectSegmFiles.connect( - self.selectSegmFileLoadData - ) + self.worker.signals.sigSelectSegmFiles.connect(self.selectSegmFileLoadData) self.worker.signals.sigSelectFilesWithText.connect( self.selectFileFromFilesWithText ) self.worker.signals.sigSelectAcdcOutputFiles.connect( self.selectAcdcOutputTables - ) - self.worker.signals.sigSelectSpotmaxRun.connect( - self.selectSpotmaxRun - ) - self.worker.signals.sigSelectFile.connect( - self.selectFile - ) + ) + self.worker.signals.sigSelectSpotmaxRun.connect(self.selectSpotmaxRun) + self.worker.signals.sigSelectFile.connect(self.selectFile) self.worker.signals.sigPermissionError.connect(self.warnPermissionError) self.worker.signals.initProgressBar.connect(self.workerInitProgressbar) self.worker.signals.sigInitInnerPbar.connect(self.workerInitInnerPbar) self.worker.signals.progressBar.connect(self.workerUpdateProgressbar) - self.worker.signals.sigUpdateInnerPbar.connect( - self.workerUpdateInnerPbar - ) + self.worker.signals.sigUpdateInnerPbar.connect(self.workerUpdateInnerPbar) self.worker.signals.sigUpdatePbarDesc.connect(self.workerUpdatePbarDesc) self.thread.started.connect(self.worker.run) self.thread.start() - + def workerInitInnerPbar(self, totalIter): if totalIter <= 1: self.progressWin.innerPbar.hide() @@ -155,36 +163,35 @@ def workerInitProgressbar(self, totalIter): if totalIter == 1: totalIter = 0 self.progressWin.mainPbar.setMaximum(totalIter) - + def workerUpdateInnerPbar(self, step): self.progressWin.innerPbar.update(step) - + def workerUpdateProgressbar(self, step): self.progressWin.mainPbar.update(step) - + def workerUpdatePbarDesc(self, desc): self.progressWin.progressLabel.setText(desc) - + def warnPermissionError(self, traceback_str, path): err_msg = html_utils.paragraph( - 'The file below is open in another app ' - '(Excel maybe?).

    ' - f'{path}

    ' + "The file below is open in another app " + "(Excel maybe?).

    " + f"{path}

    " 'Close file and then press "Ok".' ) msg = widgets.myMessageBox(wrapText=False) msg.setDetailedText(traceback_str) - msg.warning(self, 'Permission error', err_msg) + msg.warning(self, "Permission error", err_msg) self.worker.waitCond.wakeAll() - + def selectAcdcOutputTables( - self, exp_path, pos_foldernames, infoText, allowSingleSelection, - multiSelection - ): + self, exp_path, pos_foldernames, infoText, allowSingleSelection, multiSelection + ): existingAcdcOutputEndnames = set() for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") basename, chNames = myutils.getBasenameAndChNames(images_path) # Use first found channel, it doesn't matter for basename for chName in chNames: @@ -205,7 +212,7 @@ def selectAcdcOutputTables( _posData.basename, acdc_output_files ) existingAcdcOutputEndnames.update(acdc_output_endnames) - + self.existingAcdcOutputEndnames = list(existingAcdcOutputEndnames) if len(self.existingAcdcOutputEndnames) == 1: @@ -213,35 +220,37 @@ def selectAcdcOutputTables( self.selectedAcdcOutputEndnames = self.existingAcdcOutputEndnames self.worker.waitCond.wakeAll() return - + if multiSelection: selectWindow = apps.OrderableListWidgetDialog( - self.existingAcdcOutputEndnames, - title='Select acdc_output files', - infoTxt=( - 'Select acdc_output tables and choose a table number (optional)

    ' - 'Ctrl+Click to select multiple items
    ' - 'Shift+Click to select a range of items
    ' - ), - helpText=( - 'The table number is useful to ensure that you can load the ' - 'same exact equations you used in a previous sessions.

    ' - 'Cell-ACDC will automatically save the equations you enter. ' - 'They will be saved in a file ending with ' - '_equations_appended_name.ini
    and each table will ' - 'be numbered with the number you enter now.

    ' - 'When you reopen the equations dialogue you can select to load ' - 'equations from a saved .ini file, however,
    only the equations that ' - 'used the table ending with the same name you select now
    ' - 'AND same number can be loaded
    .' + self.existingAcdcOutputEndnames, + title="Select acdc_output files", + infoTxt=( + "Select acdc_output tables and choose a table number (optional)

    " + "Ctrl+Click to select multiple items
    " + "Shift+Click to select a range of items
    " + ), + helpText=( + "The table number is useful to ensure that you can load the " + "same exact equations you used in a previous sessions.

    " + "Cell-ACDC will automatically save the equations you enter. " + "They will be saved in a file ending with " + "_equations_appended_name.ini
    and each table will " + "be numbered with the number you enter now.

    " + "When you reopen the equations dialogue you can select to load " + "equations from a saved .ini file, however,
    only the equations that " + "used the table ending with the same name you select now
    " + "AND same number can be loaded
    ." + ), ) - ) else: selectWindow = widgets.QDialogListbox( - 'Select acdc_output files', - f'Select acdc_output files{infoText}\n', - self.existingAcdcOutputEndnames, multiSelection=multiSelection, - parent=self, allowSingleSelection=allowSingleSelection + "Select acdc_output files", + f"Select acdc_output files{infoText}\n", + self.existingAcdcOutputEndnames, + multiSelection=multiSelection, + parent=self, + allowSingleSelection=allowSingleSelection, ) selectWindow.exec_() self.worker.abort = selectWindow.cancel @@ -249,37 +258,42 @@ def selectAcdcOutputTables( self.worker.waitCond.wakeAll() def selectSpotmaxRun( - self, exp_path, pos_foldernames, all_runs, infoText, - allowSingleSelection, multiSelection - ): - items = natsorted([f'{run}_...{desc}' for run, desc in all_runs]) + self, + exp_path, + pos_foldernames, + all_runs, + infoText, + allowSingleSelection, + multiSelection, + ): + items = natsorted([f"{run}_...{desc}" for run, desc in all_runs]) if len(items) == 1: self.selectedSpotmaxRuns = items self.worker.waitCond.wakeAll() return - + selectWindow = widgets.QDialogListbox( - 'Select spotmax run(s)', - f'Select one or more spotmax runs{infoText}\n', - items, multiSelection=multiSelection, - parent=self, allowSingleSelection=allowSingleSelection + "Select spotmax run(s)", + f"Select one or more spotmax runs{infoText}\n", + items, + multiSelection=multiSelection, + parent=self, + allowSingleSelection=allowSingleSelection, ) selectWindow.exec_() if selectWindow.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() return - + self.selectedSpotmaxRuns = selectWindow.selectedItemsText self.worker.waitCond.wakeAll() - + def selectFile(self, start_dir, caption, filters): from qtpy.compat import getopenfilename + filepath = getopenfilename( - parent=self, - caption=caption, - basedir=start_dir, - filters=filters + parent=self, caption=caption, basedir=start_dir, filters=filters )[0] if not filepath: self.worker.abort = True @@ -288,15 +302,13 @@ def selectFile(self, start_dir, caption, filters): self.selectedFilepath = filepath self.worker.waitCond.wakeAll() - - def _selectFileFromFilesWithText( - self, exp_path, pos_foldernames, with_text, ext - ): + + def _selectFileFromFilesWithText(self, exp_path, pos_foldernames, with_text, ext): # Get end name of every existing segmentation file existingEndNames = set() for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") basename, chNames = myutils.getBasenameAndChNames(images_path) # Use first found channel, it doesn't matter for metrics for chName in chNames: @@ -310,65 +322,62 @@ def _selectFileFromFilesWithText( ) _posData = load.loadData(filePath, chName) _posData.getBasenameAndChNames() - if with_text == 'segm': + if with_text == "segm": found_files = load.get_segm_files(_posData.images_path) else: found_files = load.get_files_with( _posData.images_path, with_text, ext=ext ) - _existingEndnames = load.get_endnames( - _posData.basename, found_files - ) + _existingEndnames = load.get_endnames(_posData.basename, found_files) existingEndNames.update(_existingEndnames) if len(existingEndNames) == 1: return existingEndNames, list(existingEndNames)[0], False - if hasattr(self, 'infoText'): + if hasattr(self, "infoText"): infoText = self.infoText else: infoText = None - if with_text == 'segm': - fileType = 'segmentation' - elif with_text == 'imagej_rois': - fileType = 'ImageJ ROIs' + if with_text == "segm": + fileType = "segmentation" + elif with_text == "imagej_rois": + fileType = "ImageJ ROIs" else: - fileType = with_text.split('_') - + fileType = with_text.split("_") + win = apps.SelectSegmFileDialog( - existingEndNames, exp_path, parent=self, infoText=infoText, - fileType=fileType + existingEndNames, + exp_path, + parent=self, + infoText=infoText, + fileType=fileType, ) win.exec_() return existingEndNames, win.selectedItemText, win.cancel - - def selectFileFromFilesWithText( - self, exp_path, pos_foldernames, with_text, ext - ): - + + def selectFileFromFilesWithText(self, exp_path, pos_foldernames, with_text, ext): + out = self._selectFileFromFilesWithText( exp_path, pos_foldernames, with_text, ext ) existingEndNamesWithText, endFilenameWithText, cancel = out - + self.existingEndNamesWithText = list(existingEndNamesWithText) - self.endFilenameWithText = endFilenameWithText + self.endFilenameWithText = endFilenameWithText self.worker.abort = cancel self.worker.waitCond.wakeAll() - + def selectSegmFileLoadData(self, exp_path, pos_foldernames): - out = self._selectFileFromFilesWithText( - exp_path, pos_foldernames, 'segm', None - ) + out = self._selectFileFromFilesWithText(exp_path, pos_foldernames, "segm", None) existingSegmEndNames, endFilenameSegm, cancel = out - + self.existingSegmEndNames = list(existingSegmEndNames) self.endFilenameSegm = endFilenameSegm - + self.worker.abort = cancel self.worker.waitCond.wakeAll() - + # # Get end name of every existing segmentation file # existingSegmEndNames = set() # for p, pos in enumerate(pos_foldernames): @@ -441,17 +450,17 @@ def workerCritical(self, error): worker = None raise error except: - print('='*20) - if hasattr(self, 'worker'): + print("=" * 20) + if hasattr(self, "worker"): self.worker.logger.log(traceback.format_exc()) - elif worker is not None and hasattr(worker, 'logger'): + elif worker is not None and hasattr(worker, "logger"): worker.logger.log(traceback.format_exc()) - elif hasattr(self, 'logger'): + elif hasattr(self, "logger"): self.logger.log(traceback.format_exc()) else: print(traceback.format_exc()) - print('='*20) - result = _critical_exception_gui(self, f'{self._title} utility') + print("=" * 20) + result = _critical_exception_gui(self, f"{self._title} utility") # mutex and workerFinished handeling try: worker.workerAborted() @@ -484,38 +493,36 @@ def workerFinished(self, worker): self.worker = None self.progressWin = None - def workerProgress(self, text, loggerLevel='INFO'): + def workerProgress(self, text, loggerLevel="INFO"): if self.progressWin is not None: self.progressWin.logConsole.append(text) self.logger.log(getattr(logging, loggerLevel), text) - + def closeEvent(self, event): - self.logger.info('Closing logger...') + self.logger.info("Closing logger...") handlers = self.logger.handlers[:] for handler in handlers: handler.close() self.logger.removeHandler(handler) + class MainThreadSinglePosUtilBase(QDialog): sigClose = Signal() def __init__( - self, app: QApplication, title: str, module: str, infoText: str, - parent=None - ): + self, app: QApplication, title: str, module: str, infoText: str, parent=None + ): super().__init__(parent) self.setWindowTitle(title) - self.progressDialogueTitle = title + self.progressDialogueTitle = title self._parent = parent - logger, logs_path, log_path, log_filename = myutils.setupLogger( - module=module - ) + logger, logs_path, log_path, log_filename = myutils.setupLogger(module=module) logger.info(f'Utility title: "{title}"') logger.info(f'Utility module: "{module}"') - + self.logger = logger self.log_path = log_path self.log_filename = log_filename @@ -532,7 +539,7 @@ def __init__( infoTxt = html_utils.paragraph(infoText) iconLabel = QLabel(self) - standardIcon = getattr(QStyle, 'SP_MessageBoxInformation') + standardIcon = getattr(QStyle, "SP_MessageBoxInformation") icon = self.style().standardIcon(standardIcon) pixmap = icon.pixmap(60, 60) iconLabel.setPixmap(pixmap) @@ -541,7 +548,7 @@ def __init__( infoLayout.addWidget(QLabel(infoTxt)) buttonsLayout = QHBoxLayout() - cancelButton = widgets.cancelPushButton('Close') + cancelButton = widgets.cancelPushButton("Close") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -555,21 +562,22 @@ def __init__( self.worker = None self.setLayout(mainLayout) - + def closeClicked(self): self.sigClose.emit() - + def closeEvent(self, event): - self.logger.info('Closing logger...') + self.logger.info("Closing logger...") handlers = self.logger.handlers[:] for handler in handlers: handler.close() self.logger.removeHandler(handler) - + def runWorker(self, worker): self.progressWin = apps.QDialogWorkerProgress( - title=self.progressDialogueTitle, parent=self, - pbarDesc=f'{self.progressDialogueTitle}...' + title=self.progressDialogueTitle, + parent=self, + pbarDesc=f"{self.progressDialogueTitle}...", ) self.progressWin.sigClosed.connect(self.progressWinClosed) self.progressWin.show(self.app) @@ -583,18 +591,16 @@ def runWorker(self, worker): self.thread.finished.connect(self.thread.deleteLater) self.worker.signals.progress.connect(self.workerProgress) - self.worker.signals.critical.connect(self.workerCritical) + self.worker.signals.critical.connect(self.workerCritical) self.worker.signals.initProgressBar.connect(self.workerInitProgressbar) self.worker.signals.sigInitInnerPbar.connect(self.workerInitInnerPbar) self.worker.signals.progressBar.connect(self.workerUpdateProgressbar) - self.worker.signals.sigUpdateInnerPbar.connect( - self.workerUpdateInnerPbar - ) + self.worker.signals.sigUpdateInnerPbar.connect(self.workerUpdateInnerPbar) self.worker.signals.sigUpdatePbarDesc.connect(self.workerUpdatePbarDesc) self.thread.started.connect(self.worker.run) self.thread.start() - + def workerCritical(self, error): if self.progressWin is not None: self.progressWin.workerFinished = True @@ -603,9 +609,9 @@ def workerCritical(self, error): raise error except: self.traceback_str = traceback.format_exc() - print('='*20) + print("=" * 20) self.worker.logger.log(self.traceback_str) - print('='*20) + print("=" * 20) def workerFinished(self, worker): if self.progressWin is not None: @@ -615,11 +621,11 @@ def workerFinished(self, worker): self.worker = None self.progressWin = None - def workerProgress(self, text, loggerLevel='INFO'): + def workerProgress(self, text, loggerLevel="INFO"): if self.progressWin is not None: self.progressWin.logConsole.append(text) self.logger.log(getattr(logging, loggerLevel), text) - + def workerInitInnerPbar(self, totalIter): if totalIter <= 1: self.progressWin.innerPbar.hide() @@ -633,18 +639,18 @@ def workerInitProgressbar(self, totalIter): if totalIter == 1: totalIter = 0 self.progressWin.mainPbar.setMaximum(totalIter) - + def workerUpdateInnerPbar(self, step): self.progressWin.innerPbar.update(step) - + def workerUpdateProgressbar(self, step): self.progressWin.mainPbar.update(step) - + def workerUpdatePbarDesc(self, desc): self.progressWin.progressLabel.setText(desc) - + def progressWinClosed(self, aborted): self.abort = aborted if aborted and self.worker is not None: self.worker.abort = True - self.close() \ No newline at end of file + self.close() diff --git a/cellacdc/utils/combineChannels.py b/cellacdc/utils/combineChannels.py index cac68e310..419fb7846 100644 --- a/cellacdc/utils/combineChannels.py +++ b/cellacdc/utils/combineChannels.py @@ -7,15 +7,20 @@ from .base import NewThreadMultipleExpBaseUtil + class CombineChannelsUtil(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None - ): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths @@ -25,111 +30,103 @@ def runWorker(self): self.worker.sigAskSetup.connect(self.askSetup) self.worker.sigAborted.connect(self.workerAborted) super().runWorker(self.worker) - + def askSetup(self, expPaths): self.images_paths = [] chNames = {} for j, (exp_path, pos_foldernames) in enumerate(expPaths.items()): for i, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") self.images_paths.append(images_path) - basename, chNames_loc = myutils.getBasenameAndChNames( - images_path - ) + basename, chNames_loc = myutils.getBasenameAndChNames(images_path) segm_files = load.get_segm_files(images_path) - segm_endnames = load.get_endnames( - basename, segm_files - ) + segm_endnames = load.get_endnames(basename, segm_files) if i == 0 and j == 0: chNames = set(chNames_loc) chNames.update(segm_endnames) continue - + chNames_loc = set(chNames_loc) chNames_loc.update(segm_endnames) chNames = chNames.intersection(chNames_loc) chNames = sorted(set(chNames)) - + self.worker.basename = basename df_metadata = load.load_metadata_df(images_path) - + win = apps.CombineChannelsSetupDialogUtil( - chNames, - df_metadata=df_metadata, - parent=self + chNames, df_metadata=df_metadata, parent=self ) win.exec_() - + if win.cancel: self.worker.abort = win.cancel self.worker.waitCond.wakeAll() - return - + return + self.worker.keepInputDataType = win.keepInputDataType self.worker.selectedSteps = win.selectedSteps self.worker.nThreads = win.nThreadsSpinBox.value() self.worker.formula = win.formulaEditWidget.text() self.worker.saveAsSegm = win.saveAsSegm() self.worker.waitCond.wakeAll() - + def showEvent(self, event): self.runWorker() - + def getBasenameExtAndExtensionOutputImage(self): saveAsSegm = self.worker.saveAsSegm if saveAsSegm: - basename_ext = 'segm' - ext = '.npz' + basename_ext = "segm" + ext = ".npz" return basename_ext, ext else: - basename_ext = '' - ext = '.tif' + basename_ext = "" + ext = ".tif" return basename_ext, ext - + def askAppendName(self, basename): basename_ext, ext = self.getBasenameExtAndExtensionOutputImage() saveAsSegm = self.worker.saveAsSegm - helpText = ( - f""" + helpText = f""" The {"combined channels" if not saveAsSegm else "combined segmentation"} file will be saved with a different file name.

    Insert a name to append to the end of the new file name. The rest of the name will be the same as the original file base. """ - ) win = apps.filenameDialog( - basename=f'{basename}{basename_ext}', + basename=f"{basename}{basename_ext}", ext=ext, - hintText=f'Insert a name for the {"combined channels" if not saveAsSegm else "combined segmentation"} file:', - defaultEntry='combined', + hintText=f"Insert a name for the {'combined channels' if not saveAsSegm else 'combined segmentation'} file:", + defaultEntry="combined", helpText=helpText, allowEmpty=False, - parent=self + parent=self, ) win.exec_() if win.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() return - + self.worker.appendedName = win.entryText self.worker.waitCond.wakeAll() - + def workerAborted(self): self.workerFinished(None, aborted=True) - + def workerFinished(self, worker, aborted=False): if aborted: - txt = 'Channel combination aborted.' + txt = "Channel combination aborted." else: - txt = 'Channel combination completed.' + txt = "Channel combination completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) if aborted: - msg.warning(self, 'Process completed', html_utils.paragraph(txt)) + msg.warning(self, "Process completed", html_utils.paragraph(txt)) else: - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) super().workerFinished(worker) - self.close() \ No newline at end of file + self.close() diff --git a/cellacdc/utils/compute.py b/cellacdc/utils/compute.py index 8273a6a3a..26533e04e 100755 --- a/cellacdc/utils/compute.py +++ b/cellacdc/utils/compute.py @@ -9,39 +9,43 @@ from tqdm import tqdm from qtpy.QtCore import Signal, QThread -from qtpy.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, QStyle -) +from qtpy.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QStyle from .base import NewThreadMultipleExpBaseUtil from .. import ( - widgets, apps, workers, html_utils, myutils, - gui, cca_functions, load, printl + widgets, + apps, + workers, + html_utils, + myutils, + gui, + cca_functions, + load, + printl, ) from .. import cellacdc_path, settings_folderpath favourite_func_metrics_csv_path = os.path.join( - settings_folderpath, 'favourite_func_metrics.csv' + settings_folderpath, "favourite_func_metrics.csv" ) + class computeMeasurmentsUtilWin(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, parent=None, segmEndname='', - doRunComputation=True - ): - title = 'Compute measurements utility' - infoText = 'Computing measurements routine running...' - progressDialogueTitle = 'Computing measurements' + self, expPaths, app, parent=None, segmEndname="", doRunComputation=True + ): + title = "Compute measurements utility" + infoText = "Computing measurements routine running..." + progressDialogueTitle = "Computing measurements" module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.parent = parent - + self.cancel = False self.endFilenameSegm = segmEndname @@ -56,15 +60,16 @@ def runWorker(self, showProgress=True, stopFrameNumber=None): self.gui.logger = self.logger self.progressWin = apps.QDialogWorkerProgress( - title='Computing measurements', parent=self, - pbarDesc='Computing measurements...' + title="Computing measurements", + parent=self, + pbarDesc="Computing measurements...", ) self.progressWin.sigClosed.connect(self.progressWinClosed) self.progressWin.show(self.app) if not showProgress: self.progressWin.hide() - + self.thread = QThread() self.worker = workers.ComputeMetricsWorker(self) self.worker.moveToThread(self.thread) @@ -77,23 +82,17 @@ def runWorker(self, showProgress=True, stopFrameNumber=None): self.worker.signals.progress.connect(self.workerProgress) self.worker.signals.critical.connect(self.workerCritical) if not self.endFilenameSegm: - self.worker.signals.sigSelectSegmFiles.connect( - self.selectSegmFileLoadData - ) + self.worker.signals.sigSelectSegmFiles.connect(self.selectSegmFileLoadData) else: - self.worker.signals.sigSelectSegmFiles.connect( - self.wakeUpWorkerThread - ) + self.worker.signals.sigSelectSegmFiles.connect(self.wakeUpWorkerThread) self.worker.signals.sigInitAddMetrics.connect(self.initAddMetricsWorker) self.worker.signals.sigPermissionError.connect(self.warnPermissionError) self.worker.signals.initProgressBar.connect(self.workerInitProgressbar) self.worker.signals.progressBar.connect(self.workerUpdateProgressbar) self.worker.signals.sigUpdatePbarDesc.connect(self.workerUpdatePbarDesc) self.worker.signals.sigComputeVolume.connect(self.computeVolumeRegionprop) - self.worker.signals.sigAskRunNow.connect( - self.askRunNowOrSaveToConfig - ) - + self.worker.signals.sigAskRunNow.connect(self.askRunNowOrSaveToConfig) + if stopFrameNumber is None: self.worker.signals.sigAskStopFrame.connect(self.workerAskStopFrame) else: @@ -104,10 +103,10 @@ def runWorker(self, showProgress=True, stopFrameNumber=None): self.thread.started.connect(self.worker.run) self.thread.start() - + def askRunNowOrSaveToConfig(self, worker): self.worker.savedToWorkflow = False - + txt = html_utils.paragraph(""" Do you want to compute the measurements now
    or save the workflow to a configuration file and run it @@ -117,42 +116,40 @@ def askRunNowOrSaveToConfig(self, worker): (i.e., headless).
    """) msg = widgets.myMessageBox(wrapText=False) - saveButton = widgets.savePushButton('Save and run later') - runNowButton = widgets.playPushButton('Run now') + saveButton = widgets.savePushButton("Save and run later") + runNowButton = widgets.playPushButton("Run now") _, saveButton, runNowButton = msg.question( - self, 'Run workflow now?', txt, - buttonsTexts=( - 'Cancel', saveButton, runNowButton - ) + self, + "Run workflow now?", + txt, + buttonsTexts=("Cancel", saveButton, runNowButton), ) if not msg.clickedButton == saveButton: self.worker.abort = msg.cancel self.worker.waitCond.wakeAll() return - - timestamp = datetime.datetime.now().strftime( - r'%Y-%m-%d_%H-%M' - ) + + timestamp = datetime.datetime.now().strftime(r"%Y-%m-%d_%H-%M") win = apps.filenameDialog( - parent=self, - ext='.ini', - title='Insert filename for configuration file', - hintText='Insert filename for the configuration file', - allowEmpty=False, - defaultEntry=f'{timestamp}_acdc_measurements_workflow' + parent=self, + ext=".ini", + title="Insert filename for configuration file", + hintText="Insert filename for the configuration file", + allowEmpty=False, + defaultEntry=f"{timestamp}_acdc_measurements_workflow", ) win.exec_() if win.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() return - + config_filename = win.filename mostRecentPath = myutils.getMostRecentPath() folder_path = apps.get_existing_directory( allow_images_path=False, - parent=self.progressWin, - caption='Select folder where to save configuration file', + parent=self.progressWin, + caption="Select folder where to save configuration file", basedir=mostRecentPath, # options=QFileDialog.DontUseNativeDialog ) @@ -160,46 +157,44 @@ def askRunNowOrSaveToConfig(self, worker): self.worker.abort = True self.worker.waitCond.wakeAll() return - + config_filepath = os.path.join(folder_path, config_filename) kernel = self.worker.kernel self.saveConfigurationFile(config_filepath, kernel) - + self.worker.savedToWorkflow = True self.worker.waitCond.wakeAll() - + def saveConfigurationFile(self, config_filepath, kernel): - ini_items = {'workflow': {'type': 'measurements'}} - ini_items['measurements'] = kernel.to_workflow_config_params() - paths = [] + ini_items = {"workflow": {"type": "measurements"}} + ini_items["measurements"] = kernel.to_workflow_config_params() + paths = [] stopFrames = [] for pathInfo in self.worker.allPosDataInputs: - images_path = os.path.dirname(pathInfo['file_path']) + images_path = os.path.dirname(pathInfo["file_path"]) paths.append(images_path) - stopFrames.append(pathInfo['stopFrameNum']) - + stopFrames.append(pathInfo["stopFrameNum"]) + load.save_workflow_to_config( - config_filepath, - ini_items, - paths, - stopFrames, - type='measure' + config_filepath, ini_items, paths, stopFrames, type="measure" ) self.worker.kernel.setup_done = True - + txt = html_utils.paragraph( - 'Compute measurements workflow successfully saved to the following location:

    ' - f'{config_filepath}

    ' - 'You can run the workflow with the following command:' + "Compute measurements workflow successfully saved to the following location:

    " + f"{config_filepath}

    " + "You can run the workflow with the following command:" ) command = f'acdc -p "{config_filepath}"' msg = widgets.myMessageBox(wrapText=False) msg.information( - self, 'Workflow save', txt, + self, + "Workflow save", + txt, commands=(command,), - path_to_browse=os.path.dirname(config_filepath) + path_to_browse=os.path.dirname(config_filepath), ) - + def setStopFrame(self, posDatas, stopFrameNumber=1): for p, posData in enumerate(posDatas): if isinstance(stopFrameNumber, int): @@ -209,29 +204,30 @@ def setStopFrame(self, posDatas, stopFrameNumber=1): posData.stopFrameNum = stop_frame_n self.worker.waitCond.wakeAll() - + def wakeUpWorkerThread(self, *args, **kwargs): self.worker.waitCond.wakeAll() - - def warnErrors( - self, standardMetricsErrors, customMetricsErrors, regionPropsErrors - ): + + def warnErrors(self, standardMetricsErrors, customMetricsErrors, regionPropsErrors): if standardMetricsErrors: win = apps.ComputeMetricsErrorsDialog( - standardMetricsErrors, self.logs_path, - log_type='standard_metrics', parent=self + standardMetricsErrors, + self.logs_path, + log_type="standard_metrics", + parent=self, ) win.exec_() if regionPropsErrors: win = apps.ComputeMetricsErrorsDialog( - regionPropsErrors, self.logs_path, - log_type='region_props', parent=self + regionPropsErrors, self.logs_path, log_type="region_props", parent=self ) win.exec_() if customMetricsErrors: win = apps.ComputeMetricsErrorsDialog( - customMetricsErrors, self.logs_path, - log_type='custom_metrics', parent=self + customMetricsErrors, + self.logs_path, + log_type="custom_metrics", + parent=self, ) win.exec_() self.worker.waitCond.wakeAll() @@ -256,14 +252,14 @@ def workerUpdatePbarDesc(self, desc): def warnPermissionError(self, traceback_str, path): err_msg = html_utils.paragraph( - 'The file below is open in another app ' - '(Excel maybe?).

    ' - f'{path}

    ' + "The file below is open in another app " + "(Excel maybe?).

    " + f"{path}

    " 'Close file and then press "Ok".' ) msg = widgets.myMessageBox(wrapText=False) msg.setDetailedText(traceback_str) - msg.warning(self, 'Permission error', err_msg) + msg.warning(self, "Permission error", err_msg) self.worker.waitCond.wakeAll() def selectSegmFileLoadData(self, exp_path, pos_foldernames): @@ -271,7 +267,7 @@ def selectSegmFileLoadData(self, exp_path, pos_foldernames): existingSegmEndNames = set() for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") basename, chNames = myutils.getBasenameAndChNames(images_path) # Use first found channel, it doesn't matter for metrics for chName in chNames: @@ -286,9 +282,7 @@ def selectSegmFileLoadData(self, exp_path, pos_foldernames): _posData = load.loadData(filePath, chName) _posData.getBasenameAndChNames() segm_files = load.get_segm_files(_posData.images_path) - _existingEndnames = load.get_endnames( - _posData.basename, segm_files - ) + _existingEndnames = load.get_endnames(_posData.basename, segm_files) existingSegmEndNames.update(_existingEndnames) if len(existingSegmEndNames) == 1: @@ -296,23 +290,24 @@ def selectSegmFileLoadData(self, exp_path, pos_foldernames): self.worker.waitCond.wakeAll() return - win = apps.SelectSegmFileDialog( - existingSegmEndNames, exp_path, parent=self - ) + win = apps.SelectSegmFileDialog(existingSegmEndNames, exp_path, parent=self) win.exec_() self.endFilenameSegm = win.selectedItemText self.worker.abort = win.cancel self.worker.waitCond.wakeAll() - + def addCombineMetric(self): isZstack = self.posData.SizeZ > 1 self.combineMetricWindow = apps.combineMetricsEquationDialog( - self.posData.chNames, isZstack, self.posData.isSegm3D, - parent=self.measurementsWin, closeOnOk=False + self.posData.chNames, + isZstack, + self.posData.isSegm3D, + parent=self.measurementsWin, + closeOnOk=False, ) self.combineMetricWindow.sigOk.connect(self.saveCombineMetricsToPosData) self.combineMetricWindow.show() - + def saveCombineMetricsToPosData(self, window): for p, _posData in enumerate(self.allPosData): equationsDict, isMixedChannels = window.getEquationsDict() @@ -321,7 +316,7 @@ def saveCombineMetricsToPosData(self, window): equation, newColName, isMixedChannels ) _posData.saveCombineMetrics() - + self.combineMetricWindow.close() self.measurementsWinState = self.measurementsWin.state() self.measurementsWin.restart() @@ -332,20 +327,20 @@ def initAddMetricsWorker(self, posData, allPosDataInputs): # Set measurements try: df_favourite_funcs = pd.read_csv(favourite_func_metrics_csv_path) - favourite_funcs = df_favourite_funcs['favourite_func_name'].to_list() + favourite_funcs = df_favourite_funcs["favourite_func_name"].to_list() except Exception as e: favourite_funcs = None self.posData = posData self.allPosDataInputs = allPosDataInputs - if not hasattr(self, 'allPosData'): + if not hasattr(self, "allPosData"): self.allPosData = [] for p, posDataInputs in enumerate(self.allPosDataInputs): - combineMetricsConfig = posDataInputs['combineMetricsConfig'] - combineMetricsPath = posDataInputs['combineMetricsPath'] + combineMetricsConfig = posDataInputs["combineMetricsConfig"] + combineMetricsPath = posDataInputs["combineMetricsPath"] - # Here we build a placeholder loadData class but we get what is + # Here we build a placeholder loadData class but we get what is # needed to save custom combine metrics from posDataInputs _posData = load.loadData( self.posData.imgPath, self.posData.user_ch_name @@ -355,15 +350,19 @@ def initAddMetricsWorker(self, posData, allPosDataInputs): self.allPosData.append(_posData) self.measurementsWin = apps.SetMeasurementsDialog( - posData.chNames, [], posData.SizeZ > 1, posData.isSegm3D, - favourite_funcs=favourite_funcs, posData=posData, + posData.chNames, + [], + posData.SizeZ > 1, + posData.isSegm3D, + favourite_funcs=favourite_funcs, + posData=posData, addCombineMetricCallback=self.addCombineMetric, - allPosData=self.allPosData + allPosData=self.allPosData, ) self.measurementsWin.sigClosed.connect(self.askSaveObjectsCount) self.measurementsWin.sigCancel.connect(self.abortWorkerMeasurementsWin) self.measurementsWin.show() - + def abortWorkerMeasurementsWin(self): self.worker.abort = self.measurementsWin.cancel self.worker.waitCond.wakeAll() @@ -378,22 +377,22 @@ def askSaveObjectsCount(self): ending with acdc_objects_count. """) noButton, yesButton = msg.question( - self, 'Save objects count?', txt, - buttonsTexts=('No', 'Yes, save objects count') + self, + "Save objects count?", + txt, + buttonsTexts=("No", "Yes, save objects count"), ) if msg.clickedButton == yesButton: self.worker.kernel.set_save_objects_count_table(True) - + self.startSaveDataWorker() - + def startSaveDataWorker(self): - self.worker.kernel.init_args( - self.posData.chNames, self.endFilenameSegm - ) + self.worker.kernel.init_args(self.posData.chNames, self.endFilenameSegm) self.worker.kernel.set_metrics_from_set_measurements_dialog( self.measurementsWin ) - + if not self.doRunComputation: self.worker.setup_done = True self.worker.abort = True @@ -403,7 +402,7 @@ def startSaveDataWorker(self): self.gui.mutex = self.worker.mutex self.gui.waitCond = self.worker.waitCond self.gui.saveWin = self.progressWin - + self.gui.saveDataWorker = workers.saveDataWorker(self.gui) self.gui.saveDataWorker.criticalPermissionError.connect(self.skipEvent) @@ -411,44 +410,42 @@ def startSaveDataWorker(self): self.gui.saveDataWorker.customMetricsCritical.connect( self.addCombinedMetricsError ) - self.gui.saveDataWorker.regionPropsCritical.connect( - self.addRegionPropsErrors - ) + self.gui.saveDataWorker.regionPropsCritical.connect(self.addRegionPropsErrors) self.gui.worker = self.gui.saveDataWorker self.worker.waitCond.wakeAll() - + def addRegionPropsErrors(self, traceback_format, error_message): - self.logger.info('') - print('====================================') + self.logger.info("") + print("====================================") self.logger.info(traceback_format) - print('====================================') + print("====================================") self.worker.regionPropsErrors[error_message] = traceback_format - + def addCombinedMetricsError(self, traceback_format, func_name): - self.logger.info('') - print('====================================') + self.logger.info("") + print("====================================") self.logger.info(traceback_format) - print('====================================') + print("====================================") self.worker.customMetricsErrors[func_name] = traceback_format def skipEvent(self, dummy): self.worker.waitCond.wakeAll() def computeVolumeRegionprop(self, end_frame_i, posData): - if 'cell_vol_vox' not in self.worker.kernel.sizeMetricsToSave: + if "cell_vol_vox" not in self.worker.kernel.sizeMetricsToSave: self.worker.waitCond.wakeAll() return # We compute the cell volume in the main thread because calling # skimage.transform.rotate in a separate thread causes crashes # with segmentation fault on macOS. I don't know why yet. - self.logger.info('Computing cell volume...') + self.logger.info("Computing cell volume...") PhysicalSizeY = posData.PhysicalSizeY PhysicalSizeX = posData.PhysicalSizeX - iterable = enumerate(tqdm(posData.allData_li[:end_frame_i+1], ncols=100)) + iterable = enumerate(tqdm(posData.allData_li[: end_frame_i + 1], ncols=100)) for frame_i, data_dict in iterable: - lab = data_dict['labels'] - rp = data_dict['regionprops'] + lab = data_dict["labels"] + rp = data_dict["regionprops"] obj_iter = tqdm(rp, ncols=100, position=1, leave=False) for i, obj in enumerate(obj_iter): vol_vox, vol_fl = cca_functions._calc_rot_vol( @@ -456,7 +453,7 @@ def computeVolumeRegionprop(self, end_frame_i, posData): ) obj.vol_vox = vol_vox obj.vol_fl = vol_fl - posData.allData_li[frame_i]['regionprops'] = rp + posData.allData_li[frame_i]["regionprops"] = rp self.worker.waitCond.wakeAll() def progressWinClosed(self, aborted): @@ -476,29 +473,29 @@ def workerFinished(self, worker): if self.progressWin is not None: self.progressWin.workerFinished = True self.progressWin.close() - + if worker.setup_done: - txt = 'Measurements set up completed.' + txt = "Measurements set up completed." self.logger.info(txt) elif worker.abort: - txt = 'Computing measurements cancelled.' + txt = "Computing measurements cancelled." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.warning(self, 'Process cancelled', html_utils.paragraph(txt)) - + msg.warning(self, "Process cancelled", html_utils.paragraph(txt)) + else: - txt = 'Computing measurements completed.' + txt = "Computing measurements completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) self.isWorkerFinished = True self.progressWin = None self.close() - def workerProgress(self, text, loggerLevel='INFO'): + def workerProgress(self, text, loggerLevel="INFO"): if self.progressWin is not None: self.progressWin.logConsole.append(text) - if loggerLevel.upper() == 'EXCEPTION': - loggerLevel = 'ERROR' + if loggerLevel.upper() == "EXCEPTION": + loggerLevel = "ERROR" self.logger.log(getattr(logging, loggerLevel.upper()), text) diff --git a/cellacdc/utils/computeMultiChannel.py b/cellacdc/utils/computeMultiChannel.py index 7765a4ed6..f0801cb0b 100644 --- a/cellacdc/utils/computeMultiChannel.py +++ b/cellacdc/utils/computeMultiChannel.py @@ -4,18 +4,23 @@ from .base import NewThreadMultipleExpBaseUtil + class ComputeMetricsMultiChannel(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None - ): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths - + def runWorker(self): self.worker = workers.ComputeMetricsMultiChannelWorker(self) self.worker.sigAskAppendName.connect(self.askAppendName) @@ -25,14 +30,17 @@ def runWorker(self): self.worker.sigHowCombineMetrics.connect(self.showHowCombineMetrics) self.worker.sigAborted.connect(self.workerAborted) super().runWorker(self.worker) - + def showEvent(self, event): self.runWorker() - + def showHowCombineMetrics( - self, imagesPath, selectedAcdcOutputEndnames, - existingAcdcOutputEndnames, allChNames - ): + self, + imagesPath, + selectedAcdcOutputEndnames, + existingAcdcOutputEndnames, + allChNames, + ): self.imagesPath = imagesPath self.existingAcdcOutputEndnames = existingAcdcOutputEndnames acdcDfsDict = {} @@ -44,12 +52,8 @@ def showHowCombineMetrics( self.combineWindow = apps.CombineMetricsMultiDfsSummaryDialog( acdcDfsDict, allChNames, parent=self ) - self.combineWindow.setLogger( - self.logger, self.logs_path, self.log_path - ) - self.combineWindow.sigLoadAdditionalAcdcDf.connect( - self.loadAdditionalAcdcDf - ) + self.combineWindow.setLogger(self.logger, self.logs_path, self.log_path) + self.combineWindow.sigLoadAdditionalAcdcDf.connect(self.loadAdditionalAcdcDf) self.combineWindow.exec_() if self.combineWindow.cancel: self.worker.abort = True @@ -59,19 +63,21 @@ def showHowCombineMetrics( self.worker.equations = self.combineWindow.equations self.worker.acdcDfs = self.combineWindow.acdcDfs self.worker.waitCond.wakeAll() - + def loadAdditionalAcdcDf(self): selectWindow = widgets.QDialogListbox( - 'Select acdc_output files', - f'Select acdc_output files to load\n', - self.existingAcdcOutputEndnames, multiSelection=True, - parent=self, allowSingleSelection=True + "Select acdc_output files", + f"Select acdc_output files to load\n", + self.existingAcdcOutputEndnames, + multiSelection=True, + parent=self, + allowSingleSelection=True, ) selectWindow.exec_() if selectWindow.cancel or not selectWindow.selectedItemsText: - self.logger.info('Loading additional tables cancelled.') + self.logger.info("Loading additional tables cancelled.") return - + acdcDfsDict = {} for end in selectWindow.selectedItemsText: filePath, _ = load.get_path_from_endname(end, self.imagesPath) @@ -87,76 +93,72 @@ def criticalNotEnoughSegmFiles(self, exp_path): """) msg = widgets.myMessageBox(wrapText=False, showCentered=False) msg.addShowInFileManagerButton(exp_path) - msg.critical( - self, 'Not enough segmentation files!', text - ) + msg.critical(self, "Not enough segmentation files!", text) self.worker.abort = True self.worker.waitCond.wakeAll() - + def askAppendName(self, basename, existingEndnames, selectedEndnames): - helpText = ( - """ + helpText = """ The CSV table file with the combined measurements will be saved with a different file name.

    Insert a name to append to the end of the new name. The rest of the name will have the same basename as all other files. """ - ) - channels = [end.replace('acdc_output_', '') for end in selectedEndnames] - channels = [end.replace('acdc_output', '') for end in channels] - channels = [end if end else 'refCh' for end in channels] + channels = [end.replace("acdc_output_", "") for end in selectedEndnames] + channels = [end.replace("acdc_output", "") for end in channels] + channels = [end if end else "refCh" for end in channels] defaultEntry = f"{'_'.join(channels)}_combined_metrics" win = apps.filenameDialog( basename=basename, - hintText='Insert a name for the new, table file:', - existingNames=existingEndnames, - helpText=helpText, + hintText="Insert a name for the new, table file:", + existingNames=existingEndnames, + helpText=helpText, allowEmpty=False, - ext='.csv', + ext=".csv", defaultEntry=defaultEntry, - resizeOnShow=True + resizeOnShow=True, ) win.exec_() if win.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() return - + self.worker.appendedName = win.entryText self.worker.waitCond.wakeAll() - + def workerAborted(self): self.workerFinished(None, aborted=True) - + def workerCritical(self, error): super().workerCritical(error) self.worker.errors[error] = self.traceback_str - + def warnErrors(self, errors): win = apps.ComputeMetricsErrorsDialog( - errors, self.logs_path, log_type='generic', parent=self + errors, self.logs_path, log_type="generic", parent=self ) win.exec_() - + def workerFinished(self, worker, aborted=False): if aborted: - txt = 'Combining multiple channels measurements aborted.' + txt = "Combining multiple channels measurements aborted." isWarning = True elif worker.errors: - txt = 'Combining multiple channels measurements completed WITH ERRORS.' + txt = "Combining multiple channels measurements completed WITH ERRORS." self.warnErrors(worker.errors) isWarning = True else: txt = html_utils.paragraph( - 'Combining multiple channels measurements completed.

    ' - 'Results were saved in the respective Position folder(s).' + "Combining multiple channels measurements completed.

    " + "Results were saved in the respective Position folder(s)." ) isWarning = False self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) if isWarning: - msg.warning(self, 'Process completed', html_utils.paragraph(txt)) + msg.warning(self, "Process completed", html_utils.paragraph(txt)) else: - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) super().workerFinished(worker) - self.close() \ No newline at end of file + self.close() diff --git a/cellacdc/utils/concat.py b/cellacdc/utils/concat.py index f4d4898ea..c68f08984 100755 --- a/cellacdc/utils/concat.py +++ b/cellacdc/utils/concat.py @@ -7,25 +7,30 @@ from .base import NewThreadMultipleExpBaseUtil + class ConcatWin(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None - ): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths - if title.find('spotMAX') != -1: + if title.find("spotMAX") != -1: self.worker_func = workers.ConcatSpotmaxDfsWorker - self._infoText = 'spotMAX' + self._infoText = "spotMAX" else: self.worker_func = workers.ConcatAcdcDfsWorker - self._infoText = 'acdc_output' - - def runWorker(self, format='CSV'): + self._infoText = "acdc_output" + + def runWorker(self, format="CSV"): self.worker = self.worker_func(self, format=format) self.worker.sigAskFolder.connect(self.askFolderWhereToSaveAllExp) self.worker.sigAborted.connect(self.workerAborted) @@ -33,33 +38,36 @@ def runWorker(self, format='CSV'): self.worker.sigSetMeasurements.connect(self.askSetMeasurements) self.worker.signals.sigAskCopyCca.connect(self.askCopyCcaFromAcdcOutput) super().runWorker(self.worker) - + def askCopyCcaFromAcdcOutput(self, images_path): acdc_output_tables = [] for file in myutils.listdir(images_path): - if not file.endswith('.csv'): + if not file.endswith(".csv"): continue - - idx = file.find('acdc_output') + + idx = file.find("acdc_output") if idx == -1: continue - + acdc_output_tables.append(file[idx:]) - + if not acdc_output_tables: self.worker.waitCond.wakeAll() return - + txt = html_utils.paragraph( - 'Do you want to copy cell cycle annotations
    ' - 'from one of the tables below?

    ' - 'If yes, please select from which table you want to copy from:' + "Do you want to copy cell cycle annotations
    " + "from one of the tables below?

    " + "If yes, please select from which table you want to copy from:" ) - noButton = widgets.noPushButton('No, do not copy') + noButton = widgets.noPushButton("No, do not copy") selectTableNameWin = widgets.QDialogListbox( - 'Copy cell cycle annotations?', txt, - acdc_output_tables, multiSelection=False, parent=self, - additionalButtons=(noButton,) + "Copy cell cycle annotations?", + txt, + acdc_output_tables, + multiSelection=False, + parent=self, + additionalButtons=(noButton,), ) noButton.clicked.connect(selectTableNameWin.ok_cb) selectTableNameWin.exec_() @@ -72,74 +80,74 @@ def askCopyCcaFromAcdcOutput(self, images_path): if selectTableNameWin.clickedButton == noButton: self.worker.waitCond.wakeAll() return - - self.worker.setAcdcOutputEndname(selectedTableNames[0]) + + self.worker.setAcdcOutputEndname(selectedTableNames[0]) self.worker.waitCond.wakeAll() - + def askSetMeasurements(self, kwargs): - loadedChNames = kwargs['loadedChNames'] - notLoadedChNames = kwargs['notLoadedChNames'] - isZstack = kwargs['isZstack'] - isSegm3D = kwargs['isSegm3D'] + loadedChNames = kwargs["loadedChNames"] + notLoadedChNames = kwargs["notLoadedChNames"] + isZstack = kwargs["isZstack"] + isSegm3D = kwargs["isSegm3D"] self.setMeasurementsWin = apps.SetMeasurementsDialog( - loadedChNames, notLoadedChNames, isZstack, isSegm3D, - is_concat=True, parent=self - ) - existing_colnames = kwargs['existing_colnames'] - self.setMeasurementsWin.addNonMeasurementColumns( - existing_colnames - ) - self.setMeasurementsWin.setDisabledNotExistingMeasurements( - existing_colnames + loadedChNames, + notLoadedChNames, + isZstack, + isSegm3D, + is_concat=True, + parent=self, ) + existing_colnames = kwargs["existing_colnames"] + self.setMeasurementsWin.addNonMeasurementColumns(existing_colnames) + self.setMeasurementsWin.setDisabledNotExistingMeasurements(existing_colnames) self.setMeasurementsWin.sigClosed.connect(self.setMeasurements) self.setMeasurementsWin.sigCancel.connect(self.setMeasurementsCancelled) self.setMeasurementsWin.show() - + def setMeasurements(self): selectedColumns = [] - if hasattr(self.setMeasurementsWin, 'nonMeasurementsGroupbox'): + if hasattr(self.setMeasurementsWin, "nonMeasurementsGroupbox"): if self.setMeasurementsWin.nonMeasurementsGroupbox.isChecked(): groupbox = self.setMeasurementsWin.nonMeasurementsGroupbox - for checkBox in groupbox.checkBoxes: + for checkBox in groupbox.checkBoxes: if not checkBox.isEnabled(): continue if not checkBox.isChecked(): continue colname = checkBox.text() - selectedColumns.append(colname) - + selectedColumns.append(colname) + for chNameGroupbox in self.setMeasurementsWin.chNameGroupboxes: chName = chNameGroupbox.chName if not chNameGroupbox.isChecked(): # Skip entire channel continue - + for checkBox in chNameGroupbox.checkBoxes: if not checkBox.isEnabled(): continue - + if not checkBox.isChecked(): continue colname = checkBox.text() selectedColumns.append(colname) - + if self.setMeasurementsWin.sizeMetricsQGBox.isChecked(): for checkBox in self.setMeasurementsWin.sizeMetricsQGBox.checkBoxes: if not checkBox.isEnabled(): continue - + if not checkBox.isChecked(): continue colname = checkBox.text() selectedColumns.append(colname) - + selectedPropsNames = [] if self.setMeasurementsWin.regionPropsQGBox.isChecked(): for checkBox in self.setMeasurementsWin.regionPropsQGBox.checkBoxes: if not checkBox.isEnabled(): continue - + if not checkBox.isChecked(): continue colname = checkBox.text() @@ -148,7 +156,7 @@ def setMeasurements(self): self.setMeasurementsWin.existing_colnames, selectedPropsNames ) selectedColumns.extend(selectedRpCols) - + checkMixedChannel = ( self.setMeasurementsWin.mixedChannelsCombineMetricsQGBox is not None and self.setMeasurementsWin.mixedChannelsCombineMetricsQGBox.isChecked() @@ -159,87 +167,84 @@ def setMeasurements(self): for checkBox in checkBoxes: if not checkBox.isEnabled(): continue - + if not checkBox.isChecked(): continue colname = checkBox.text() selectedColumns.append(colname) - + self.worker.selectedColumns = selectedColumns self.worker.abort = False self.worker.waitCond.wakeAll() - + def setMeasurementsCancelled(self): self.worker.abort = True self.worker.waitCond.wakeAll() - + def showEvent(self, event): - formats = ( - 'CSV (Comma Separated Values)', - 'XLS (Excel)' - ) + formats = ("CSV (Comma Separated Values)", "XLS (Excel)") selectFormatWin = widgets.QDialogListbox( - 'Select output file format', - 'Select format of the output file\n', - formats, multiSelection=False, parent=self + "Select output file format", + "Select format of the output file\n", + formats, + multiSelection=False, + parent=self, ) selectFormatWin.exec_() if selectFormatWin.cancel: return - - if selectFormatWin.selectedItemsText[0].startswith('CSV'): - self._ext = '.csv' + + if selectFormatWin.selectedItemsText[0].startswith("CSV"): + self._ext = ".csv" else: - self._ext = '.xlsx' + self._ext = ".xlsx" myutils.check_install_package( - 'OpenPyXL', - import_pkg_name='openpyxl', - pypi_name='XlsxWriter' + "OpenPyXL", import_pkg_name="openpyxl", pypi_name="XlsxWriter" ) self.runWorker(format=selectFormatWin.selectedItemsText[0]) - + def askAppendName(self, basename, existingEndnames): win = apps.filenameDialog( basename=basename, - hintText='Insert a name for the concatenated table file:', - existingNames=existingEndnames, + hintText="Insert a name for the concatenated table file:", + existingNames=existingEndnames, allowEmpty=True, - ext=self._ext + ext=self._ext, ) win.exec_() if win.cancel: - self.worker.abort = True - else: + self.worker.abort = True + else: self.worker.concat_df_filename = win.filename self.worker.waitCond.wakeAll() - + def askFolderWhereToSaveAllExp(self, allExp_filename): - txt = (""" + txt = """ After clicking "Ok" you will be asked to select a folder where you want to save the file
    with the concatenated tables from the multiple experiments selected
    - """) + """ if allExp_filename: - txt = f'{txt}(the filename will be {allExp_filename})' - + txt = f"{txt}(the filename will be {allExp_filename})" + txt = html_utils.paragraph(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.information(self, 'Select folder', txt) + msg.information(self, "Select folder", txt) if msg.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() - - + mostRecentPath = myutils.getMostRecentPath() save_to_dir = QFileDialog.getExistingDirectory( - self, f'Select folder where to save multiple experiments table', - mostRecentPath + self, + f"Select folder where to save multiple experiments table", + mostRecentPath, ) if not save_to_dir: self.worker.abort = True self.worker.waitCond.wakeAll() - + self.worker.allExpSaveFolder = save_to_dir self.worker.waitCond.wakeAll() @@ -247,17 +252,17 @@ def askFolderWhereToSaveAllExp(self, allExp_filename): def workerAborted(self): self.worker.signals.finished.emit(self) self.workerFinished(self.worker, aborted=True) - + def workerFinished(self, worker, aborted=False): if aborted: - txt = f'Concatenating {self._infoText} tables aborted.' + txt = f"Concatenating {self._infoText} tables aborted." else: - txt = f'Concatenating {self._infoText} tables completed.' + txt = f"Concatenating {self._infoText} tables completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) if aborted: - msg.warning(self, 'Process completed', html_utils.paragraph(txt)) + msg.warning(self, "Process completed", html_utils.paragraph(txt)) else: - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) super().workerFinished(worker) - self.close() \ No newline at end of file + self.close() diff --git a/cellacdc/utils/convert.py b/cellacdc/utils/convert.py index 987d03487..877134385 100755 --- a/cellacdc/utils/convert.py +++ b/cellacdc/utils/convert.py @@ -15,13 +15,19 @@ import skimage.color from qtpy.QtWidgets import ( - QApplication, QMainWindow, QFileDialog, - QVBoxLayout, QPushButton, QLabel, QStyleFactory, - QWidget, QMessageBox, QDialog, QHBoxLayout -) -from qtpy.QtCore import ( - Qt, QEventLoop, QSize, QThread, Signal, QObject + QApplication, + QMainWindow, + QFileDialog, + QVBoxLayout, + QPushButton, + QLabel, + QStyleFactory, + QWidget, + QMessageBox, + QDialog, + QHBoxLayout, ) +from qtpy.QtCore import Qt, QEventLoop, QSize, QThread, Signal, QObject from qtpy import QtGui script_path = os.path.dirname(os.path.realpath(__file__)) @@ -35,21 +41,28 @@ from .. import cellacdc_path, recentPaths_path, settings_folderpath from .. import io -if os.name == 'nt': +if os.name == "nt": try: # Set taskbar icon in windows import ctypes - myappid = 'schmollerlab.cellacdc.pyqt.v1' # arbitrary string + + myappid = "schmollerlab.cellacdc.pyqt.v1" # arbitrary string ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) except Exception as e: pass + class convertFileFormatWin(QMainWindow): def __init__( - self, parent=None, allowExit=False, - actionToEnable=None, mainWin=None, - from_='npz', to='npy', info='' - ): + self, + parent=None, + allowExit=False, + actionToEnable=None, + mainWin=None, + from_="npz", + to="npy", + info="", + ): self.from_ = from_ self.to = to self.info = info @@ -68,16 +81,16 @@ def __init__( mainLayout = QVBoxLayout() titleText = html_utils.paragraph( - f'
    Converting .{from_} to .{to} routine running...', - font_size='14px' + f"
    Converting .{from_} to .{to} routine running...", + font_size="14px", ) titleLabel = QLabel(titleText) mainLayout.addWidget(titleLabel) infoTxt = ( - 'Follow the instructions in the pop-up windows.
    ' - 'Note that pop-ups might be minimized or behind other open windows.

    ' - 'Progess is displayed in the terminal/console.' + "Follow the instructions in the pop-up windows.
    " + "Note that pop-ups might be minimized or behind other open windows.

    " + "Progess is displayed in the terminal/console." ) informativeLabel = QLabel(html_utils.paragraph(infoTxt)) @@ -86,7 +99,7 @@ def __init__( informativeLabel.setAlignment(Qt.AlignLeft) mainLayout.addWidget(informativeLabel) - abortButton = QPushButton('Stop processs') + abortButton = QPushButton("Stop processs") abortButton.clicked.connect(self.close) mainLayout.addWidget(abortButton) @@ -95,23 +108,26 @@ def __init__( def getMostRecentPath(self): if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - if 'opened_last_on' in df.columns: - df = df.sort_values('opened_last_on', ascending=False) - self.MostRecentPath = df.iloc[0]['path'] + df = pd.read_csv(recentPaths_path, index_col="index") + if "opened_last_on" in df.columns: + df = df.sort_values("opened_last_on", ascending=False) + self.MostRecentPath = df.iloc[0]["path"] if not isinstance(self.MostRecentPath, str): - self.MostRecentPath = '' + self.MostRecentPath = "" else: - self.MostRecentPath = '' + self.MostRecentPath = "" def main(self): self.getMostRecentPath() exp_path = QFileDialog.getExistingDirectory( - self, 'Select experiment folder containing Position_n folders ' - 'or specific Position_n folder', self.MostRecentPath) + self, + "Select experiment folder containing Position_n folders " + "or specific Position_n folder", + self.MostRecentPath, + ) self.addToRecentPaths(exp_path) - if exp_path == '': + if exp_path == "": abort = self.doAbort() if abort: self.close() @@ -124,22 +140,20 @@ def main(self): folder_type = myutils.determine_folder_type(exp_path) is_pos_folder, is_images_folder, exp_path = folder_type - print('Loading data...') + print("Loading data...") if not is_pos_folder and not is_images_folder: select_folder = load.select_exp_folder() values = select_folder.get_values_segmGUI(exp_path) if not values: txt = html_utils.paragraph( - 'The selected folder:

    ' - f'{exp_path}

    ' - 'is not a valid folder. ' - 'Select a folder that contains the Position_n folders' + "The selected folder:

    " + f"{exp_path}

    " + "is not a valid folder. " + "Select a folder that contains the Position_n folders" ) msg = widgets.myMessageBox() - msg.critical( - self, 'Incompatible folder', txt - ) + msg.critical(self, "Incompatible folder", txt) self.close() return @@ -154,19 +168,20 @@ def main(self): else: pos_foldernames = values - images_paths = [os.path.join(exp_path, pos, 'Images') - for pos in pos_foldernames] + images_paths = [ + os.path.join(exp_path, pos, "Images") for pos in pos_foldernames + ] elif is_pos_folder: pos_foldername = os.path.basename(exp_path) exp_path = os.path.dirname(exp_path) - images_paths = [f'{exp_path}/{pos_foldername}/Images'] + images_paths = [f"{exp_path}/{pos_foldername}/Images"] elif is_images_folder: images_paths = [exp_path] proceed, selectedFilenames = self.selectFiles( - images_paths[0], filterExt=[f'{self.from_}'] + images_paths[0], filterExt=[f"{self.from_}"] ) if not proceed: abort = self.doAbort() @@ -183,40 +198,45 @@ def main(self): self.close() return - print(f'Converting .{self.from_} to .{self.to} started...') + print(f"Converting .{self.from_} to .{self.to} started...") if len(images_paths) > 1: - _endswith = selectedFilenames[0][len(basename):] + _endswith = selectedFilenames[0][len(basename) :] if not _endswith: - self.criticalNoCommonBasename( - selectedFilenames, images_paths[0] - ) + self.criticalNoCommonBasename(selectedFilenames, images_paths[0]) self.close() return for pos_i, images_path in enumerate(tqdm(images_paths, ncols=100)): ls = myutils.listdir(images_path) - _basename = self.getBasename( - images_path, selectedFilenames - ) + _basename = self.getBasename(images_path, selectedFilenames) for file in ls: if file.endswith(_endswith): proceed = self.convert( - images_path, file, appendedTxt, _basename, - from_=self.from_, to=self.to, prompt=False + images_path, + file, + appendedTxt, + _basename, + from_=self.from_, + to=self.to, + prompt=False, ) if not proceed: self.close() return else: proceed = self.convert( - images_paths[0], selectedFilenames[0], appendedTxt, basename, - from_=self.from_, to=self.to + images_paths[0], + selectedFilenames[0], + appendedTxt, + basename, + from_=self.from_, + to=self.to, ) - + self.success = True self.close() if self.allowExit: - exit('Done.') + exit("Done.") def getBasename(self, images_path, selectedFilenames): commonStartFilenames = myutils.filterCommonStart(images_path) @@ -229,56 +249,62 @@ def getBasename(self, images_path, selectedFilenames): else: basename = selector.basename - if basename.endswith('_'): - if self.info.startswith('_'): - basename = f'{basename}{self.info[1:]}' + if basename.endswith("_"): + if self.info.startswith("_"): + basename = f"{basename}{self.info[1:]}" else: - basename = f'{basename}{self.info}' + basename = f"{basename}{self.info}" else: - basename = f'{basename}_{self.info}' + basename = f"{basename}_{self.info}" return basename def convert( - self, images_path, filename, appendedTxt, basename, - from_='npz', to='npy', prompt=True - ): + self, + images_path, + filename, + appendedTxt, + basename, + from_="npz", + to="npy", + prompt=True, + ): filePath = os.path.join(images_path, filename) - if self.from_ == 'npz': - data = np.load(filePath)['arr_0'] - elif self.from_ == 'npy': + if self.from_ == "npz": + data = np.load(filePath)["arr_0"] + elif self.from_ == "npy": data = np.load(filePath) - elif self.from_ == 'tif': + elif self.from_ == "tif": data = load.imread(filePath) - elif self.from_ == 'h5': + elif self.from_ == "h5": data = load.h5dump_to_arr(filePath) - if self.info.find('segm') != -1: + if self.info.find("segm") != -1: data = data.astype(np.uint32) filename, ext = os.path.splitext(filename) if appendedTxt: - if basename.endswith('_'): + if basename.endswith("_"): basename = basename[:-1] - newFilename = f'{basename}_{appendedTxt}.{self.to}' + newFilename = f"{basename}_{appendedTxt}.{self.to}" else: - newFilename = f'{basename}.{self.to}' + newFilename = f"{basename}.{self.to}" newPath = os.path.join(images_path, newFilename) if os.path.exists(newPath): newPath = self.warnFileExisting(newPath) if not newPath: return False - if self.to == 'npy': + if self.to == "npy": np.save(newPath, data) - elif self.to == 'tif': + elif self.to == "tif": myutils.to_tiff(newPath, data) - elif self.to == 'npz': + elif self.to == "npz": io.savez_compressed(newPath, data) - print('') - print('-'*30) + print("") + print("-" * 30) print(f'File "{filePath}" saved to "{newPath}"') - print('-'*30) + print("-" * 30) if prompt: self.conversionDone(filePath, newPath) return True - + def warnFileExisting(self, newFilePath): msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph(f""" @@ -288,77 +314,82 @@ def warnFileExisting(self, newFilePath): """) msg.addShowInFileManagerButton(newFilePath) _, overwriteButton, renameButton = msg.warning( - self, 'File existing', txt, - buttonsTexts=('Cancel', 'Overwrite existing', 'Rename new file') + self, + "File existing", + txt, + buttonsTexts=("Cancel", "Overwrite existing", "Rename new file"), ) if msg.cancel: - return '' - + return "" + if msg.clickedButton == overwriteButton: return newFilePath - + if msg.clickedButton == renameButton: folderName = os.path.dirname(newFilePath) filename, ext = os.path.splitext(os.path.basename(newFilePath)) win = apps.filenameDialog( - basename=filename, ext=ext, allowEmpty=False, - hintText='Insert a filename for the new file:
    ' + basename=filename, + ext=ext, + allowEmpty=False, + hintText="Insert a filename for the new file:
    ", ) win.exec_() if win.cancel: - return '' + return "" newFilePath = os.path.join(folderName, win.filename) return newFilePath - def conversionDone(self, src, dst): msg = widgets.myMessageBox() msg.setWidth(700) parent_path = os.path.dirname(dst) txt = ( - 'Done!

    ' - f'The file below was converted to .{self.to}, and saved' + "Done!

    " + f"The file below was converted to .{self.to}, and saved" ) msg.addShowInFileManagerButton(parent_path) msg.information( - self, 'Conversion done!', html_utils.paragraph(txt), - path_to_browse=parent_path, - commands=(src, dst) + self, + "Conversion done!", + html_utils.paragraph(txt), + path_to_browse=parent_path, + commands=(src, dst), ) def askTxtAppend(self, basename): hintText = html_utils.paragraph( - 'OPTIONAL: write here an additional text to append ' - 'to the filename' + "OPTIONAL: write here an additional text to append to the filename" ) - if basename.endswith('_'): + if basename.endswith("_"): basename = basename[:-1] win = apps.filenameDialog( - ext=self.to, title='New filename', - hintText=hintText, parent=self, basename=basename + ext=self.to, + title="New filename", + hintText=hintText, + parent=self, + basename=basename, ) win.exec_() if win.cancel: - win.entryText = '' + win.entryText = "" return win.cancel, win.entryText def criticalNoCommonBasename(self, filenames, parent_path): msg = widgets.myMessageBox() txt = html_utils.paragraph( - f'The file name {filenames[0]}
    ' - 'does not follow Cell-ACDC naming convention.

    ' - 'The name must have the same common basename ' - 'as all the other files inside the ' - 'Position_n/Images folder.

    ' - 'For example, if in the Images folder you have two files called ' - 'ASY015_SCD_phase_contr.tif and ' - 'ASY015_SCD_mCitrine.tif then the common basename ' - 'is ASY015_SCD_ and the file that you are tring to ' - 'convert should start with the same common basename.' - ) - msg.critical( - self, 'Name of selected file not compatible', txt + f"The file name {filenames[0]}
    " + "does not follow Cell-ACDC naming convention.

    " + "The name must have the same common basename " + "as all the other files inside the " + "Position_n/Images folder.

    " + "For example, if in the Images folder you have two files called " + "ASY015_SCD_phase_contr.tif and " + "ASY015_SCD_mCitrine.tif then the common basename " + "is ASY015_SCD_ and the file that you are tring to " + "convert should start with the same common basename." ) + msg.critical(self, "Name of selected file not compatible", txt) def selectFiles(self, images_path, filterExt=None): files = myutils.listdir(images_path) @@ -373,12 +404,14 @@ def selectFiles(self, images_path, filterExt=None): items = files selectFilesWidget = widgets.QDialogListbox( - 'Select files', - f'Select the .{self.from_} files you want to convert to ' - f'.{self.to}\n\n' - 'NOTE: if you selected multiple Position folders I will try \n' - 'to convert all selected files in each Position folder', - items, multiSelection=False, parent=self + "Select files", + f"Select the .{self.from_} files you want to convert to " + f".{self.to}\n\n" + "NOTE: if you selected multiple Position folders I will try \n" + "to convert all selected files in each Position folder", + items, + multiSelection=False, + parent=self, ) selectFilesWidget.exec_() @@ -395,12 +428,12 @@ def addToRecentPaths(self, exp_path): if not os.path.exists(exp_path): return if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - recentPaths = df['path'].to_list() - if 'opened_last_on' in df.columns: - openedOn = df['opened_last_on'].to_list() + df = pd.read_csv(recentPaths_path, index_col="index") + recentPaths = df["path"].to_list() + if "opened_last_on" in df.columns: + openedOn = df["opened_last_on"].to_list() else: - openedOn = [np.nan]*len(recentPaths) + openedOn = [np.nan] * len(recentPaths) if exp_path in recentPaths: pop_idx = recentPaths.index(exp_path) recentPaths.pop(pop_idx) @@ -414,17 +447,20 @@ def addToRecentPaths(self, exp_path): else: recentPaths = [exp_path] openedOn = [datetime.datetime.now()] - df = pd.DataFrame({'path': recentPaths, - 'opened_last_on': pd.Series(openedOn, - dtype='datetime64[ns]')}) - df.index.name = 'index' + df = pd.DataFrame( + { + "path": recentPaths, + "opened_last_on": pd.Series(openedOn, dtype="datetime64[ns]"), + } + ) + df.index.name = "index" df.to_csv(recentPaths_path) def doAbort(self): if self.allowExit: - exit('Execution aborted by the user') + exit("Execution aborted by the user") else: - print('Conversion task aborted by the user.') + print("Conversion task aborted by the user.") return True def closeEvent(self, event): @@ -433,20 +469,21 @@ def closeEvent(self, event): txt = html_utils.paragraph(""" Conversion process aborted. """) - msg.warning(self, 'Process aborted', txt) - + msg.warning(self, "Process aborted", txt) + if self.actionToEnable is not None: self.actionToEnable.setDisabled(False) self.mainWin.setWindowState(Qt.WindowNoState) self.mainWin.setWindowState(Qt.WindowActive) self.mainWin.raise_() + class ImagesToPositions(QDialog): def __init__(self, parent=None) -> None: super().__init__(parent) - + logger, logs_path, log_path, log_filename = myutils.setupLogger( - module='converter' + module="converter" ) self.logger = logger @@ -454,20 +491,19 @@ def __init__(self, parent=None) -> None: self.log_filename = log_filename self.logs_path = logs_path - self.logger.info('Initializing converter...') + self.logger.info("Initializing converter...") self.cancel = True - self.setWindowTitle('Cell-ACDC converter') - self.funcDescription = 'Cell-ACDC converter' + self.setWindowTitle("Cell-ACDC converter") + self.funcDescription = "Cell-ACDC converter" instructions = [ - 'Put all the images into one folder' - 'Press start button', - 'Select folder containing the images', - 'Select where to save the Position folders', - 'Insert a text to append at the end of each image (e.g., the channel name)', - 'Wait that process ends' + "Put all the images into one folderPress start button", + "Select folder containing the images", + "Select where to save the Position folders", + "Insert a text to append at the end of each image (e.g., the channel name)", + "Wait that process ends", ] txt = html_utils.paragraph(f""" @@ -482,7 +518,7 @@ def __init__(self, parent=None) -> None: layout = QVBoxLayout() textLayout = QHBoxLayout() - pixmap = QtGui.QIcon(":cog_play.svg").pixmap(QSize(64,64)) + pixmap = QtGui.QIcon(":cog_play.svg").pixmap(QSize(64, 64)) iconLabel = QLabel() iconLabel.setPixmap(pixmap) @@ -492,9 +528,9 @@ def __init__(self, parent=None) -> None: textLayout.addStretch(1) buttonsLayout = QHBoxLayout() - stopButton = widgets.stopPushButton('Stop process') - startButton = widgets.playPushButton(' Start ') - cancelButton = widgets.cancelPushButton('Close') + stopButton = widgets.stopPushButton("Stop process") + startButton = widgets.playPushButton(" Start ") + cancelButton = widgets.cancelPushButton("Close") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -524,51 +560,53 @@ def __init__(self, parent=None) -> None: cancelButton.clicked.connect(self.close) startButton.clicked.connect(self.start) stopButton.clicked.connect(self.stop) - + def showEvent(self, event: QtGui.QShowEvent) -> None: self.startButton.setFixedWidth(self.stopButton.width()) self.stopButton.hide() return super().showEvent(event) - @exception_handler + @exception_handler def start(self): self.startButton.hide() self.stopButton.show() MostRecentPath = myutils.getMostRecentPath() folderPath = QFileDialog.getExistingDirectory( - self, 'Select folder containing images', MostRecentPath + self, "Select folder containing images", MostRecentPath ) if not folderPath: - self.logger.info('No path selected. Process stopped.') + self.logger.info("No path selected. Process stopped.") self.stop() return - + tagertFolderPath = QFileDialog.getExistingDirectory( - self, 'Select where to save Position folders', folderPath + self, "Select where to save Position folders", folderPath ) if not tagertFolderPath: - self.logger.info('Target path not selected. Process stopped.') + self.logger.info("Target path not selected. Process stopped.") self.stop() return - + myutils.addToRecentPaths(tagertFolderPath, logger=self.logger) textToAppendInstructions = html_utils.paragraph( - 'Insert a name to append at the end of each new .tif file.' - '

    ' - 'This name is required because Cell-ACDC needs to load files
    ' - 'that ends with the same common name.

    ' + "Insert a name to append at the end of each new .tif file." + "

    " + "This name is required because Cell-ACDC needs to load files
    " + "that ends with the same common name.

    " 'Typically, you can use this for the channel name, e.g., "GFP".' ) win = apps.filenameDialog( - ext='.tif', title='Insert text to append', + ext=".tif", + title="Insert text to append", hintText=textToAppendInstructions, - parent=self, allowEmpty=False + parent=self, + allowEmpty=False, ) win.exec_() if win.cancel: - self.logger.info('Process cancelled at insert text.') + self.logger.info("Process cancelled at insert text.") self.stop() return @@ -589,37 +627,37 @@ def start(self): self.thread.started.connect(self.worker.run) self.thread.start() - + def stop(self): self.startButton.show() self.stopButton.hide() - if hasattr(self, 'worker'): + if hasattr(self, "worker"): self.worker.abort = True - + @exception_handler def workerInitProgressBar(self, maximum): self.progressBar.setValue(0) self.progressBar.setMaximum(maximum) - + @exception_handler def workerUpdateProgressBar(self): self.progressBar.update(1) - + @exception_handler def workerProgress(self, txt): self.logger.info(txt) self.logConsole.append(txt) - + @exception_handler def workerProgressBar(self, txt): self.logger.info(txt) self.logConsole.write(txt) - + @exception_handler def workerCritical(self, error): raise error - + @exception_handler def workerFinished(self): self.startButton.show() @@ -628,13 +666,14 @@ def workerFinished(self): if self.worker.abort: msg = widgets.myMessageBox() msg.warning( - self, 'Conversion process stopped', - html_utils.paragraph('Conversion process stopped!') + self, + "Conversion process stopped", + html_utils.paragraph("Conversion process stopped!"), ) else: msg = widgets.myMessageBox() msg.information( - self, 'Conversion completed', - html_utils.paragraph('Conversion process completed!') + self, + "Conversion completed", + html_utils.paragraph("Conversion process completed!"), ) - diff --git a/cellacdc/utils/countObjects.py b/cellacdc/utils/countObjects.py index 92ff1326a..e80df7431 100644 --- a/cellacdc/utils/countObjects.py +++ b/cellacdc/utils/countObjects.py @@ -2,29 +2,34 @@ from .base import NewThreadMultipleExpBaseUtil + class CountObjectsInsegm(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None - ): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths - + def runWorker(self): self.worker = workers.CountObjectsInSegm(self) self.worker.sigAborted.connect(self.workerAborted) super().runWorker(self.worker) - + def showEvent(self, event): self.runWorker() - + def workerAborted(self): self.workerFinished(None, aborted=True) - + def workerFinished(self, worker, aborted=False): if aborted: txt = f'"{self._title}" process cancelled.' @@ -33,8 +38,8 @@ def workerFinished(self, worker, aborted=False): self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) if aborted: - msg.warning(self, 'Process completed', html_utils.paragraph(txt)) + msg.warning(self, "Process completed", html_utils.paragraph(txt)) else: - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) super().workerFinished(worker) - self.close() \ No newline at end of file + self.close() diff --git a/cellacdc/utils/createConnected3Dsegm.py b/cellacdc/utils/createConnected3Dsegm.py index 1cdde0ffd..864b4f656 100644 --- a/cellacdc/utils/createConnected3Dsegm.py +++ b/cellacdc/utils/createConnected3Dsegm.py @@ -2,66 +2,69 @@ from .base import NewThreadMultipleExpBaseUtil + class CreateConnected3Dsegm(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None - ): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths - + def runWorker(self): self.worker = workers.CreateConnected3Dsegm(self) self.worker.sigAskAppendName.connect(self.askAppendName) self.worker.sigAborted.connect(self.workerAborted) super().runWorker(self.worker) - + def showEvent(self, event): self.runWorker() - + def askAppendName(self, basename, existingEndnames): - helpText = ( - """ + helpText = """ The new 3D segmentation file will be saved with a different file name.

    Insert a name to append to the end of the new name. The rest of the name will be the same as the original file. """ - ) win = apps.filenameDialog( basename=basename, - hintText='Insert a name for the new 3D segmentation file:', - existingNames=existingEndnames, - helpText=helpText, + hintText="Insert a name for the new 3D segmentation file:", + existingNames=existingEndnames, + helpText=helpText, allowEmpty=False, - parent=self + parent=self, ) win.exec_() if win.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() return - + self.worker.appendedName = win.entryText self.worker.waitCond.wakeAll() - + def workerAborted(self): self.workerFinished(None, aborted=True) - + def workerFinished(self, worker, aborted=False): if aborted: - txt = '3D segmentation mask creation process aborted.' + txt = "3D segmentation mask creation process aborted." else: - txt = '3D segmentation mask creation process completed.' + txt = "3D segmentation mask creation process completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) if aborted: - msg.warning(self, 'Process completed', html_utils.paragraph(txt)) + msg.warning(self, "Process completed", html_utils.paragraph(txt)) else: - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) super().workerFinished(worker) - self.close() \ No newline at end of file + self.close() diff --git a/cellacdc/utils/customPreprocess.py b/cellacdc/utils/customPreprocess.py index 24780ad3d..572d4071e 100644 --- a/cellacdc/utils/customPreprocess.py +++ b/cellacdc/utils/customPreprocess.py @@ -6,98 +6,99 @@ from .base import NewThreadMultipleExpBaseUtil + class CustomPreprocessUtil(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None - ): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths - + def runWorker(self): self.worker = workers.CustomPreprocessWorkerUtil(self) self.worker.sigAskAppendName.connect(self.askAppendName) self.worker.sigAskSetupRecipe.connect(self.askSetupRecipe) self.worker.sigAborted.connect(self.workerAborted) super().runWorker(self.worker) - + def askSetupRecipe(self, exp_path, pos_foldernames): channel_names = set() df_metadata = None for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") basename, chNames = myutils.getBasenameAndChNames(images_path) channel_names.update(chNames) if df_metadata is not None: continue - + self.worker.basename = basename df_metadata = load.load_metadata_df(images_path) - + win = apps.PreProcessRecipeDialogUtil( - channel_names, - df_metadata=df_metadata, - parent=self + channel_names, df_metadata=df_metadata, parent=self ) win.exec_() - + if win.cancel: self.worker.abort = win.cancel self.worker.waitCond.wakeAll() - return - + return + self.worker.selectedChannels = win.selectedChannels self.worker.recipe = win.selectedRecipe self.worker.waitCond.wakeAll() - + def showEvent(self, event): self.runWorker() - + def askAppendName(self, basename): - helpText = ( - """ + helpText = """ The preprocessed image file will be saved with a different file name.

    Insert a name to append to the end of the new file name. The rest of the name will be the same as the original file. """ - ) win = apps.filenameDialog( basename=basename, - ext='.tif', - hintText='Insert a name for the preprocessed image file:', - defaultEntry='preprocessed', - helpText=helpText, + ext=".tif", + hintText="Insert a name for the preprocessed image file:", + defaultEntry="preprocessed", + helpText=helpText, allowEmpty=False, - parent=self + parent=self, ) win.exec_() if win.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() return - + self.worker.appendedName = win.entryText self.worker.waitCond.wakeAll() - + def workerAborted(self): self.workerFinished(None, aborted=True) - + def workerFinished(self, worker, aborted=False): if aborted: - txt = 'Custom pre-processing aborted.' + txt = "Custom pre-processing aborted." else: - txt = 'Custom pre-processing completed.' + txt = "Custom pre-processing completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) if aborted: - msg.warning(self, 'Process completed', html_utils.paragraph(txt)) + msg.warning(self, "Process completed", html_utils.paragraph(txt)) else: - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) super().workerFinished(worker) - self.close() \ No newline at end of file + self.close() diff --git a/cellacdc/utils/fillHolesInSegm.py b/cellacdc/utils/fillHolesInSegm.py index 40564e708..36f7ff516 100644 --- a/cellacdc/utils/fillHolesInSegm.py +++ b/cellacdc/utils/fillHolesInSegm.py @@ -3,52 +3,60 @@ from .base import NewThreadMultipleExpBaseUtil import os + class fillHolesInSegm(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None - ): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths - + def runWorker(self): self.worker = workers.FillHolesInSegWorker(self) self.worker.sigAskAppendName.connect(self.askAppendName) self.worker.sigAborted.connect(self.workerAborted) self.worker.sigSelectSegmFiles.connect(self.askInputSegm) super().runWorker(self.worker) - + def showEvent(self, event): self.runWorker() - + def workerAborted(self): self.workerFinished(None, aborted=True) - + def workerFinished(self, worker, aborted=False): if aborted: - txt = 'Filling holes in segmentation mask process aborted.' + txt = "Filling holes in segmentation mask process aborted." else: - txt = 'Filling holes in segmentation mask process completed.' + txt = "Filling holes in segmentation mask process completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) if aborted: - msg.warning(self, 'Process completed', html_utils.paragraph(txt)) + msg.warning(self, "Process completed", html_utils.paragraph(txt)) else: - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) super().workerFinished(worker) self.close() - + def askInputSegm(self, exp_path, pos_foldernames): existingSegmEndNames = load.get_segm_endnames_from_exp_path( - exp_path, pos_foldernames=pos_foldernames - ) + exp_path, pos_foldernames=pos_foldernames + ) win = apps.SelectSegmFileDialog( - existingSegmEndNames, exp_path, parent=self, allowMultipleSelection=True, - infoText=f"Select the segmentation files for folder {exp_path}." + existingSegmEndNames, + exp_path, + parent=self, + allowMultipleSelection=True, + infoText=f"Select the segmentation files for folder {exp_path}.", ) win.exec_() if win.cancel: @@ -58,28 +66,27 @@ def askInputSegm(self, exp_path, pos_foldernames): return self.worker.endFilenameSegmTemp = win.selectedItemTexts self.worker.waitCond.wakeAll() - + def askAppendName(self, basename): - helpText = ( - """ + helpText = """ The new segmentation file can be saved with a different file name.

    Insert a name if the old segmentation should not be overwritten. """ - ) win = apps.filenameDialog( - hintText='Insert a name extension if the old file should not be overwritten.', - helpText=helpText, basename=basename, - allowEmpty=True + hintText="Insert a name extension if the old file should not be overwritten.", + helpText=helpText, + basename=basename, + allowEmpty=True, ) win.exec_() if win.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() return - + if win.entryText is None: self.worker.appendedName = "" else: self.worker.appendedName = win.entryText - self.worker.waitCond.wakeAll() \ No newline at end of file + self.worker.waitCond.wakeAll() diff --git a/cellacdc/utils/filterObjFromCoordsTable.py b/cellacdc/utils/filterObjFromCoordsTable.py index bf576ac18..70575fd03 100644 --- a/cellacdc/utils/filterObjFromCoordsTable.py +++ b/cellacdc/utils/filterObjFromCoordsTable.py @@ -2,79 +2,81 @@ from .base import NewThreadMultipleExpBaseUtil + class FilterObjsFromCoordsTable(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None - ): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths - + def runWorker(self): self.worker = workers.FilterObjsFromCoordsTable(self) self.worker.sigAskAppendName.connect(self.askAppendName) self.worker.sigSetColumnsNames.connect(self.setColumnsNames) self.worker.sigAborted.connect(self.workerAborted) super().runWorker(self.worker) - + def showEvent(self, event): self.runWorker() - + def askAppendName(self, basename, existingEndnames): - helpText = ( - """ + helpText = """ You can choose to save a new file for the filtered segmentation or overwrite the existing one. """ - ) win = apps.filenameDialog( basename=basename, - hintText='Insert a name for the filtered segmentation file:', - existingNames=existingEndnames, - helpText=helpText, + hintText="Insert a name for the filtered segmentation file:", + existingNames=existingEndnames, + helpText=helpText, allowEmpty=False, - parent=self + parent=self, ) win.exec_() if win.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() return - + self.worker.appendedName = win.entryText self.worker.waitCond.wakeAll() - + def setColumnsNames(self, columns, categories, optionalCategories): win = apps.SetColumnNamesDialog( - columns, categories, optionalCategories=optionalCategories, - parent=self + columns, categories, optionalCategories=optionalCategories, parent=self ) win.exec_() if win.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() - return - + return + self.selectedColumnsPerCategory = win.selectedColumns self.worker.waitCond.wakeAll() - + def workerAborted(self): self.workerFinished(None, aborted=True) - + def workerFinished(self, worker, aborted=False): if aborted: - txt = 'Filter segmented objects from coordinates table process aborted.' + txt = "Filter segmented objects from coordinates table process aborted." else: - txt = 'Filter segmented objects from coordinates table process completed.' + txt = "Filter segmented objects from coordinates table process completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) if aborted: - msg.warning(self, 'Process completed', html_utils.paragraph(txt)) + msg.warning(self, "Process completed", html_utils.paragraph(txt)) else: - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) super().workerFinished(worker) - self.close() \ No newline at end of file + self.close() diff --git a/cellacdc/utils/fromImageJroiToSegm.py b/cellacdc/utils/fromImageJroiToSegm.py index 04f69492a..5017d2ca8 100644 --- a/cellacdc/utils/fromImageJroiToSegm.py +++ b/cellacdc/utils/fromImageJroiToSegm.py @@ -3,49 +3,56 @@ from .base import NewThreadMultipleExpBaseUtil + class fromImageJRoiToSegmUtil(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.qparent = parent self.expPaths = expPaths - + def runWorker(self): self.worker = workers.FromImajeJroiToSegmNpzWorker(self) self.worker.sigSelectRoisProps.connect(self.selectRoisProps) super().runWorker(self.worker) - + def selectRoisProps(self, roi_filepath, TZYX_shape, is_multi_pos): win = apps.ImageJRoisToSegmManager( - roi_filepath, TZYX_shape, + roi_filepath, + TZYX_shape, addUseSamePropsForNextPosButton=is_multi_pos, - parent=self.qparent + parent=self.qparent, ) win.exec_() self.worker.abort = win.cancel if win.cancel: self.worker.waitCond.wakeAll() return - + self.worker.IDsToRoisMapper = win.IDsToRoisMapper self.worker.rescaleRoisSizes = win.rescaleSizes self.worker.repeatRoisZslicesRange = win.repeatRoisZslicesRange self.worker.useSamePropsForNextPos = win.useSamePropsForNextPos self.worker.areAllRoisSelected = win.areAllRoisSelected self.worker.waitCond.wakeAll() - + def showEvent(self, event): self.runWorker() - + def workerFinished(self, worker): super().workerFinished(worker) - txt = 'Converting from ImageJ ROIs to Cell-ACDC segmentation file(s) completed.' + txt = "Converting from ImageJ ROIs to Cell-ACDC segmentation file(s) completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.information(self, 'Process completed', html_utils.paragraph(txt)) - self.close() \ No newline at end of file + msg.information(self, "Process completed", html_utils.paragraph(txt)) + self.close() diff --git a/cellacdc/utils/fucciPreprocess.py b/cellacdc/utils/fucciPreprocess.py index 1724ebe6d..84d54d46d 100644 --- a/cellacdc/utils/fucciPreprocess.py +++ b/cellacdc/utils/fucciPreprocess.py @@ -6,112 +6,111 @@ from .base import NewThreadMultipleExpBaseUtil + class FucciPreprocessUtil(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None - ): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths - + def runWorker(self): self.worker = workers.FucciPreprocessWorker(self) self.worker.sigAskAppendName.connect(self.askAppendName) self.worker.sigAskParams.connect(self.askSelectParams) self.worker.sigAborted.connect(self.workerAborted) super().runWorker(self.worker) - + def askSelectParams(self, exp_path, pos_foldernames): channel_names = set() df_metadata = None for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") basename, chNames = myutils.getBasenameAndChNames(images_path) channel_names.update(chNames) if df_metadata is not None: continue - + self.worker.basename = basename df_metadata = load.load_metadata_df(images_path) - + if len(channel_names) < 2: - txt = ( - 'At least two channels are needed to run the FUCCI ' - 'pre-processing.' - ) + txt = "At least two channels are needed to run the FUCCI pre-processing." self.logger.error(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.critical(self, 'Error', html_utils.paragraph(txt)) + msg.critical(self, "Error", html_utils.paragraph(txt)) self.worker.abort = True self.worker.waitCond.wakeAll() return - + win = apps.FucciPreprocessDialog( - channel_names, - df_metadata=df_metadata, - parent=self + channel_names, df_metadata=df_metadata, parent=self ) win.exec_() - + self.worker.firstChannelName = win.firstChannelName self.worker.secondChannelName = win.secondChannelName fucciFilterKwargs = win.function_kwargs self.worker.fucciFilterKwargs = fucciFilterKwargs - - if fucciFilterKwargs['do_basicpy_background_correction']: + + if fucciFilterKwargs["do_basicpy_background_correction"]: from cellacdc import preprocess + preprocess._init_basicpy_background_correction(parent=self) - + self.worker.abort = win.cancel self.worker.waitCond.wakeAll() - + def showEvent(self, event): self.runWorker() - + def askAppendName(self, basename): - helpText = ( - """ + helpText = """ The combined and preprocessed image file will be saved with a different file name.

    Insert a name to append to the end of the new name. The rest of the name will be the same as the original file. """ - ) win = apps.filenameDialog( basename=basename, - hintText='Insert a name for the new combined channels file:', - defaultEntry='fucci_combined', - helpText=helpText, + hintText="Insert a name for the new combined channels file:", + defaultEntry="fucci_combined", + helpText=helpText, allowEmpty=False, - parent=self + parent=self, ) win.exec_() if win.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() return - + self.worker.appendedName = win.entryText self.worker.waitCond.wakeAll() - + def workerAborted(self): self.workerFinished(None, aborted=True) - + def workerFinished(self, worker, aborted=False): if aborted: - txt = 'FUCCI pre-processing aborted.' + txt = "FUCCI pre-processing aborted." else: - txt = 'FUCCI pre-processing completed.' + txt = "FUCCI pre-processing completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) if aborted: - msg.warning(self, 'Process completed', html_utils.paragraph(txt)) + msg.warning(self, "Process completed", html_utils.paragraph(txt)) else: - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) super().workerFinished(worker) - self.close() \ No newline at end of file + self.close() diff --git a/cellacdc/utils/generateMothBudTotalTable.py b/cellacdc/utils/generateMothBudTotalTable.py index bcee83e43..4cd472ad3 100644 --- a/cellacdc/utils/generateMothBudTotalTable.py +++ b/cellacdc/utils/generateMothBudTotalTable.py @@ -9,63 +9,60 @@ from qtpy.QtWidgets import QFileDialog + class GenerateMothBudTotalUtil(base.MainThreadSinglePosUtilBase): def __init__( - self, app, title: str, infoText: str, parent=None, - callbackOnFinished=None - ): + self, app, title: str, infoText: str, parent=None, callbackOnFinished=None + ): module = myutils.get_module_name(__file__) - super().__init__( - app, title, module, infoText, parent - ) + super().__init__(app, title, module, infoText, parent) self.sigClose.connect(self.close) self.callbackOnFinished = callbackOnFinished @exception_handler - def run(self): + def run(self): msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph( 'After clicking "Ok" you will be asked to select the input table ' - 'file (.csv) containing pedigree information.' + "file (.csv) containing pedigree information." ) - msg.information(self, 'Instructions', txt) + msg.information(self, "Instructions", txt) if msg.cancel: return False - + import qtpy.compat + input_csv_filepath = qtpy.compat.getopenfilename( - parent=self, - caption='Select CSV file to load', - filters='CSV (*.csv);;All Files (*)', - basedir=myutils.getMostRecentPath() + parent=self, + caption="Select CSV file to load", + filters="CSV (*.csv);;All Files (*)", + basedir=myutils.getMostRecentPath(), )[0] if input_csv_filepath is None or not input_csv_filepath: return False myutils.addToRecentPaths(os.path.dirname(input_csv_filepath)) - + self.logger.info(f'Reading column names in table "{input_csv_filepath}"...') df = pd.read_csv(input_csv_filepath, nrows=2) - win = apps.GenerateMotherBudTotalTableSelectColumnsDialog( - df, parent=self - ) + win = apps.GenerateMotherBudTotalTableSelectColumnsDialog(df, parent=self) win.exec_() if win.cancel: return False - + selected_options = win.selected_options - + csv_filename = os.path.basename(input_csv_filepath) csv_filename_noext, ext = os.path.splitext(csv_filename) win = apps.filenameDialog( - ext='.csv', - basename=f'{csv_filename_noext}_', - hintText='Insert a filename for the output table file:', - allowEmpty=False, - defaultEntry='mother_bud_total', + ext=".csv", + basename=f"{csv_filename_noext}_", + hintText="Insert a filename for the output table file:", + allowEmpty=False, + defaultEntry="mother_bud_total", ) win.exec_() if win.cancel: @@ -75,7 +72,7 @@ def run(self): out_csv_filepath = os.path.join( os.path.dirname(input_csv_filepath), out_csv_filename ) - + self.worker = workers.GenerateMotherBudTotalTableWorker( self, input_csv_filepath, selected_options, out_csv_filepath ) @@ -83,9 +80,8 @@ def run(self): self.worker.signals.finished.connect(self.callbackOnFinished) self.runWorker(self.worker) return True - + def overWriteClicked(self, win): win.cancel = False - win.filename = '' + win.filename = "" win.close() - diff --git a/cellacdc/utils/rename.py b/cellacdc/utils/rename.py index 7801ad696..dcd0a26d8 100755 --- a/cellacdc/utils/rename.py +++ b/cellacdc/utils/rename.py @@ -13,9 +13,15 @@ from tqdm import tqdm from qtpy.QtWidgets import ( - QApplication, QMainWindow, QFileDialog, - QVBoxLayout, QPushButton, QLabel, QStyleFactory, - QWidget, QMessageBox + QApplication, + QMainWindow, + QFileDialog, + QVBoxLayout, + QPushButton, + QLabel, + QStyleFactory, + QWidget, + QMessageBox, ) from qtpy.QtCore import Qt, QEventLoop from qtpy import QtGui @@ -28,20 +34,19 @@ from .. import prompts, load, myutils, apps, html_utils, widgets from .. import recentPaths_path, cellacdc_path, settings_folderpath -if os.name == 'nt': +if os.name == "nt": try: # Set taskbar icon in windows import ctypes - myappid = 'schmollerlab.cellacdc.pyqt.v1' # arbitrary string + + myappid = "schmollerlab.cellacdc.pyqt.v1" # arbitrary string ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) except Exception as e: pass + class renameFilesWin(QMainWindow): - def __init__( - self, parent=None, allowExit=False, - actionToEnable=None, mainWin=None - ): + def __init__(self, parent=None, allowExit=False, actionToEnable=None, mainWin=None): self.allowExit = allowExit self.processFinished = False self.actionToEnable = actionToEnable @@ -56,21 +61,21 @@ def __init__( mainLayout = QVBoxLayout() titleText = html_utils.paragraph( - '
    Renaming files utility', font_size='14px' + "
    Renaming files utility", font_size="14px" ) titleLabel = QLabel(titleText) mainLayout.addWidget(titleLabel) infoTxt = ( - 'Follow the instructions in the pop-up windows.
    ' - 'Note that pop-ups might be minimized or behind other open windows.

    ' - 'Progess is displayed in the terminal/console.' + "Follow the instructions in the pop-up windows.
    " + "Note that pop-ups might be minimized or behind other open windows.

    " + "Progess is displayed in the terminal/console." ) informativeLabel = QLabel(html_utils.paragraph(infoTxt)) mainLayout.addWidget(informativeLabel) - abortButton = QPushButton('Stop processs') + abortButton = QPushButton("Stop processs") abortButton.clicked.connect(self.close) mainLayout.addWidget(abortButton) @@ -79,55 +84,53 @@ def __init__( def getMostRecentPath(self): if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - if 'opened_last_on' in df.columns: - df = df.sort_values('opened_last_on', ascending=False) - self.MostRecentPath = df.iloc[0]['path'] + df = pd.read_csv(recentPaths_path, index_col="index") + if "opened_last_on" in df.columns: + df = df.sort_values("opened_last_on", ascending=False) + self.MostRecentPath = df.iloc[0]["path"] if not isinstance(self.MostRecentPath, str): - self.MostRecentPath = '' + self.MostRecentPath = "" else: - self.MostRecentPath = '' + self.MostRecentPath = "" def main(self): self.getMostRecentPath() exp_path = QFileDialog.getExistingDirectory( - self, 'Select experiment folder containing Position_n folders ' - 'or specific Position_n folder', self.MostRecentPath) + self, + "Select experiment folder containing Position_n folders " + "or specific Position_n folder", + self.MostRecentPath, + ) self.addToRecentPaths(exp_path) - if exp_path == '': + if exp_path == "": abort = self.doAbort() if abort: self.close() return - self.setWindowTitle( - f'Cell-ACDC - Renaming files - "{exp_path}"' - ) + self.setWindowTitle(f'Cell-ACDC - Renaming files - "{exp_path}"') folder_type = myutils.determine_folder_type(exp_path) is_pos_folder, is_images_folder, exp_path = folder_type - print('Loading data...') + print("Loading data...") if not is_pos_folder and not is_images_folder: select_folder = load.select_exp_folder() values = select_folder.get_values_segmGUI(exp_path) if not values: txt = ( - 'The selected folder:\n\n ' - f'{exp_path}\n\n' - 'is not a valid folder. ' - 'Select a folder that contains the Position_n folders' + "The selected folder:\n\n " + f"{exp_path}\n\n" + "is not a valid folder. " + "Select a folder that contains the Position_n folders" ) msg = QMessageBox() - msg.critical( - self, 'Incompatible folder', txt, msg.Ok - ) + msg.critical(self, "Incompatible folder", txt, msg.Ok) self.close() return - select_folder.QtPrompt(self, values, allow_cancel=False, show=True) if select_folder.cancel: abort = self.doAbort() @@ -135,15 +138,15 @@ def main(self): self.close() return - pos_foldernames = select_folder.selected_pos - images_paths = [os.path.join(exp_path, pos, 'Images') - for pos in pos_foldernames] + images_paths = [ + os.path.join(exp_path, pos, "Images") for pos in pos_foldernames + ] elif is_pos_folder: pos_foldername = os.path.basename(exp_path) exp_path = os.path.dirname(exp_path) - images_paths = [f'{exp_path}/{pos_foldername}/Images'] + images_paths = [f"{exp_path}/{pos_foldername}/Images"] elif is_images_folder: images_paths = [exp_path] @@ -155,7 +158,6 @@ def main(self): self.close() return - abort, appendedTxt = self.askTxtAppend(selectedFilenames[0]) if abort: abort = self.doAbort() @@ -168,16 +170,14 @@ def main(self): ch_name_selector = prompts.select_channel_name() ls = myutils.listdir(images_paths[0]) all_channelNames, abort = ch_name_selector.get_available_channels( - ls, images_paths[0], useExt=None + ls, images_paths[0], useExt=None ) if abort: - self.criticalNoCommonBasename( - selectedFilenames, images_paths[0] - ) + self.criticalNoCommonBasename(selectedFilenames, images_paths[0]) self.close() return _endswith_li = [ - f[len(ch_name_selector.basename):] for f in selectedFilenames + f[len(ch_name_selector.basename) :] for f in selectedFilenames ] for images_path in tqdm(images_paths, ncols=100): ls = myutils.listdir(images_path) @@ -185,35 +185,28 @@ def main(self): ls, images_path, useExt=None ) if skip: - print('') - print('-------------------------------------') - print( - f'{images_path} data structure compromised!' - 'Skipping it.' - ) - print('-------------------------------------') + print("") + print("-------------------------------------") + print(f"{images_path} data structure compromised!Skipping it.") + print("-------------------------------------") for _endswith in _endswith_li: for file in ls: if file.endswith(_endswith): - self._rename( - file, images_path, appendedTxt - ) + self._rename(file, images_path, appendedTxt) else: self._rename(selectedFilenames[0], images_paths[0], appendedTxt) msg = widgets.myMessageBox() - txt = html_utils.paragraph( - 'Renaming process completed.

    ' - ) - msg.information(self, 'Renaming process completed', txt) + txt = html_utils.paragraph("Renaming process completed.

    ") + msg.information(self, "Renaming process completed", txt) self.close() if self.allowExit: - exit('Done.') + exit("Done.") def _rename(self, file, parent_path, appendedTxt): filename, ext = os.path.splitext(file) - new_file = f'{filename}_{appendedTxt}{ext}' + new_file = f"{filename}_{appendedTxt}{ext}" src_filepath = os.path.join(parent_path, file) new_filepath = os.path.join(parent_path, new_file) os.rename(src_filepath, new_filepath) @@ -221,21 +214,18 @@ def _rename(self, file, parent_path, appendedTxt): def save(self, alignedData, filePath, appendedTxt, first_call=True): dir = os.path.dirname(filePath) filename, ext = os.path.splitext(os.path.basename(filePath)) - path = os.path.join(dir, f'{filename}_{appendedTxt}{ext}') + path = os.path.join(dir, f"{filename}_{appendedTxt}{ext}") def askTxtAppend(self, filename): font = QtGui.QFont() font.setPixelSize(13) - self.win = apps.QDialogAppendTextFilename( - filename, '', parent=self, font=font - ) + self.win = apps.QDialogAppendTextFilename(filename, "", parent=self, font=font) self.win.exec_() return self.win.cancel, self.win.LE.text() def criticalNoCommonBasename(self, filenames, parent_path): myutils.checkDataIntegrity(filenames, parent_path, parentQWidget=self) - def selectFiles(self, images_path, filterExt=None): files = myutils.listdir(images_path) if filterExt is not None: @@ -249,9 +239,11 @@ def selectFiles(self, images_path, filterExt=None): items = files selectFilesWidget = widgets.QDialogListbox( - 'Select files', - 'Select the files you want to rename', - items, multiSelection=True, parent=self + "Select files", + "Select the files you want to rename", + items, + multiSelection=True, + parent=self, ) selectFilesWidget.exec_() @@ -268,12 +260,12 @@ def addToRecentPaths(self, exp_path): if not os.path.exists(exp_path): return if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col='index') - recentPaths = df['path'].to_list() - if 'opened_last_on' in df.columns: - openedOn = df['opened_last_on'].to_list() + df = pd.read_csv(recentPaths_path, index_col="index") + recentPaths = df["path"].to_list() + if "opened_last_on" in df.columns: + openedOn = df["opened_last_on"].to_list() else: - openedOn = [np.nan]*len(recentPaths) + openedOn = [np.nan] * len(recentPaths) if exp_path in recentPaths: pop_idx = recentPaths.index(exp_path) recentPaths.pop(pop_idx) @@ -287,17 +279,20 @@ def addToRecentPaths(self, exp_path): else: recentPaths = [exp_path] openedOn = [datetime.datetime.now()] - df = pd.DataFrame({'path': recentPaths, - 'opened_last_on': pd.Series(openedOn, - dtype='datetime64[ns]')}) - df.index.name = 'index' + df = pd.DataFrame( + { + "path": recentPaths, + "opened_last_on": pd.Series(openedOn, dtype="datetime64[ns]"), + } + ) + df.index.name = "index" df.to_csv(recentPaths_path) def doAbort(self): if self.allowExit: - exit('Execution aborted by the user') + exit("Execution aborted by the user") else: - print('Conversion task aborted by the user.') + print("Conversion task aborted by the user.") return True def closeEvent(self, event): diff --git a/cellacdc/utils/repeat.py b/cellacdc/utils/repeat.py index 25383a9bf..644c3dd85 100644 --- a/cellacdc/utils/repeat.py +++ b/cellacdc/utils/repeat.py @@ -5,40 +5,44 @@ from qtpy.QtCore import Qt, QThread, QSize from qtpy.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, QFileDialog, QListWidgetItem + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QFileDialog, + QListWidgetItem, ) from qtpy import QtGui from .. import exception_handler from .. import myutils, html_utils, workers, widgets, load, apps + class repeatDataPrepWindow(QDialog): def __init__(self, parent=None) -> None: super().__init__(parent) - name = 'repeat data prep' - - logger, logs_path, log_path, log_filename = myutils.setupLogger( - module=name - ) + name = "repeat data prep" + + logger, logs_path, log_path, log_filename = myutils.setupLogger(module=name) self.logger = logger self.log_path = log_path self.log_filename = log_filename self.logs_path = logs_path - self.logger.info(f'Initializing {name}...') + self.logger.info(f"Initializing {name}...") self.cancel = True - self.setWindowTitle(f'Cell-ACDC {name}') - self.funcDescription = f'Cell-ACDC {name}' + self.setWindowTitle(f"Cell-ACDC {name}") + self.funcDescription = f"Cell-ACDC {name}" instructions = [ - 'Press start button', - 'Select experiment folder or specific Position folder', - 'Select which channels or un-prepped .tif file to apply data prep to', - 'Wait until process ends' + "Press start button", + "Select experiment folder or specific Position folder", + "Select which channels or un-prepped .tif file to apply data prep to", + "Wait until process ends", ] txt = html_utils.paragraph(f""" @@ -53,7 +57,7 @@ def __init__(self, parent=None) -> None: layout = QVBoxLayout() textLayout = QHBoxLayout() - pixmap = QtGui.QIcon(":cog_play.svg").pixmap(QSize(64,64)) + pixmap = QtGui.QIcon(":cog_play.svg").pixmap(QSize(64, 64)) iconLabel = QLabel() iconLabel.setPixmap(pixmap) @@ -63,9 +67,9 @@ def __init__(self, parent=None) -> None: textLayout.addStretch(1) buttonsLayout = QHBoxLayout() - stopButton = widgets.stopPushButton('Stop process') - startButton = widgets.playPushButton(' Start ') - cancelButton = widgets.cancelPushButton('Close') + stopButton = widgets.stopPushButton("Stop process") + startButton = widgets.playPushButton(" Start ") + cancelButton = widgets.cancelPushButton("Close") buttonsLayout.addStretch(1) buttonsLayout.addWidget(cancelButton) @@ -94,29 +98,28 @@ def __init__(self, parent=None) -> None: cancelButton.clicked.connect(self.close) startButton.clicked.connect(self.start) stopButton.clicked.connect(self.stop) - + def showEvent(self, event: QtGui.QShowEvent) -> None: self.startButton.setFixedWidth(self.stopButton.width()) self.stopButton.hide() return super().showEvent(event) - @exception_handler + @exception_handler def start(self): self.startButton.hide() self.stopButton.show() MostRecentPath = myutils.getMostRecentPath() exp_path = QFileDialog.getExistingDirectory( - self, 'Select experiment folder or specific Position folder', - MostRecentPath + self, "Select experiment folder or specific Position folder", MostRecentPath ) if not exp_path: - self.logger.info('No path selected. Process stopped.') + self.logger.info("No path selected. Process stopped.") self.stop() return - + myutils.addToRecentPaths(exp_path, logger=self.logger) - + folder_type = myutils.determine_folder_type(exp_path) is_pos_folder, is_images_folder, exp_path = folder_type @@ -137,13 +140,15 @@ def start(self): return if len(values) > 1: select_folder.QtPrompt( - self, values, allow_cancel=False, toggleMulti=True, - CbLabel="Select Position folder(s) to process:" + self, + values, + allow_cancel=False, + toggleMulti=True, + CbLabel="Select Position folder(s) to process:", ) if select_folder.cancel: self.logger.info( - 'Process aborted by the user ' - '(cancelled at Postion selection)' + "Process aborted by the user (cancelled at Postion selection)" ) self.stop() return @@ -152,13 +157,13 @@ def start(self): posFoldernames = select_folder.pos_foldernames self.workerProgress(f'Selected folder: "{exp_path}"') - self.workerProgress(' ') - posListFormat = '\n'.join(posFoldernames) - self.workerProgress(f'Selected Positions:\n{posListFormat}') - self.workerProgress(' ') + self.workerProgress(" ") + posListFormat = "\n".join(posFoldernames) + self.workerProgress(f"Selected Positions:\n{posListFormat}") + self.workerProgress(" ") self.workerInitProgressBar(len(posFoldernames)) - + self.thread = QThread() self.worker = workers.reapplyDataPrepWorker(exp_path, posFoldernames) @@ -177,45 +182,52 @@ def start(self): self.thread.started.connect(self.worker.run) self.thread.start() - + def selectChannels(self, ch_name_selector, ch_names, imagesPath, basename): if basename is not None: self.ch_names = ch_names self.basename = basename self.imagesPath = imagesPath browseButton = widgets.browseFileButton( - 'Select .tif file to add and prep', start_dir=imagesPath, - title='Select .tif file to add and prep', ext={'TIFF files': '.tif'} + "Select .tif file to add and prep", + start_dir=imagesPath, + title="Select .tif file to add and prep", + ext={"TIFF files": ".tif"}, ) browseButton.sigPathSelected.connect(self.selectTifFileToAdd) additionalButtons = (browseButton,) else: additionalButtons = [] self.selectChannelWindow = widgets.QDialogListbox( - 'Select channel', - 'Select channel names to process:\n', - ch_names, multiSelection=True, parent=self, - additionalButtons=additionalButtons + "Select channel", + "Select channel names to process:\n", + ch_names, + multiSelection=True, + parent=self, + additionalButtons=additionalButtons, ) self.selectChannelWindow.exec_() if self.selectChannelWindow.cancel: self.worker.abort = True self.worker.selectedChannels = self.selectChannelWindow.selectedItemsText self.worker.waitCond.wakeAll() - + def selectTifFileToAdd(self, tif_file_path): tif_filename = os.path.splitext(os.path.basename(tif_file_path))[0] win = apps.filenameDialog( - ext='.tif', basename=self.basename, - title='Insert a name for new channel', - hintText='Insert a name for the new channel', - allowEmpty=False, defaultEntry=tif_filename, - existingNames=self.ch_names, parent=self.selectChannelWindow + ext=".tif", + basename=self.basename, + title="Insert a name for new channel", + hintText="Insert a name for the new channel", + allowEmpty=False, + defaultEntry=tif_filename, + existingNames=self.ch_names, + parent=self.selectChannelWindow, ) win.exec_() if win.cancel: return - + newTifFilePath = os.path.join(self.imagesPath, win.filename) try: self.logger.info(f'Copying and renaming "{tif_filename}.tif" file...') @@ -230,7 +242,7 @@ def selectTifFileToAdd(self, tif_file_path): self.selectChannelWindow.listBox.addItem(newItem) self.selectChannelWindow.listBox.clearSelection() newItem.setSelected(True) - + def warnCopyTifFileFailed(self, tif_file_path, newTifFilePath, error): tifFilename = os.path.basename(tif_file_path) msg = widgets.myMessageBox(showCentered=False, wrapText=False) @@ -242,65 +254,62 @@ def warnCopyTifFileFailed(self, tif_file_path, newTifFilePath, error): Copy to: {newTifFilePath} """) msg.setDetailedText(error) - msg.critical(self.selectChannelWindow, 'Copy .tif file failed', txt) - + msg.critical(self.selectChannelWindow, "Copy .tif file failed", txt) + def criticalNotValidFolder(self, path: os.PathLike): txt = html_utils.paragraph( - 'The selected folder:

    ' - f'{path}

    ' - 'is not a valid folder. ' - 'Select a folder that contains the Position_n folders' + "The selected folder:

    " + f"{path}

    " + "is not a valid folder. " + "Select a folder that contains the Position_n folders" ) msg = widgets.myMessageBox() msg.addShowInFileManagerButton(path) - msg.critical( - self, 'Incompatible folder', txt, - buttonsTexts=('Ok',) - ) - + msg.critical(self, "Incompatible folder", txt, buttonsTexts=("Ok",)) + def criticalNoChannelsFound(self, images_path): - err_title = 'Channel names not found' + err_title = "Channel names not found" err_msg = html_utils.paragraph( - 'The following folder

    ' - '{images_path}

    ' - 'does not valid channel files.
    ' + "The following folder

    " + "{images_path}

    " + "does not valid channel files.
    " ) msg = widgets.myMessageBox() msg.addShowInFileManagerButton(images_path) msg.critical(self, err_title, err_msg) self.logger.info(err_title) self.stop() - + def stop(self): self.startButton.show() self.stopButton.hide() - if hasattr(self, 'worker'): + if hasattr(self, "worker"): self.worker.abort = True - + @exception_handler def workerInitProgressBar(self, maximum): self.progressBar.setValue(0) self.progressBar.setMaximum(maximum) - + @exception_handler def workerUpdateProgressBar(self): self.progressBar.update(1) - + @exception_handler def workerProgress(self, txt): self.logger.info(txt) self.logConsole.append(txt) - + @exception_handler def workerProgressBar(self, txt): self.logger.info(txt) self.logConsole.write(txt) - + @exception_handler def workerCritical(self, error): raise error - + @exception_handler def workerFinished(self): self.startButton.show() @@ -309,12 +318,14 @@ def workerFinished(self): if self.worker.abort: msg = widgets.myMessageBox() msg.warning( - self, 'Process stopped', - html_utils.paragraph('Data prep process stopped!') + self, + "Process stopped", + html_utils.paragraph("Data prep process stopped!"), ) else: msg = widgets.myMessageBox() msg.information( - self, 'Process completed', - html_utils.paragraph('Data prep process completed!') - ) \ No newline at end of file + self, + "Process completed", + html_utils.paragraph("Data prep process completed!"), + ) diff --git a/cellacdc/utils/resize/__init__.py b/cellacdc/utils/resize/__init__.py index d456c9f4f..e404fd3f5 100644 --- a/cellacdc/utils/resize/__init__.py +++ b/cellacdc/utils/resize/__init__.py @@ -11,6 +11,7 @@ from ... import load, myutils, io + def process_frame(imgs, images_indx, factor, is_segm): T, Z = images_indx if not is_segm: @@ -19,6 +20,7 @@ def process_frame(imgs, images_indx, factor, is_segm): img_resized = ndimage.zoom(imgs[T, Z], factor, order=0) return images_indx, img_resized + def process_frames(imgs, factor, is_segm=False): results = [] @@ -27,18 +29,20 @@ def process_frames(imgs, factor, is_segm=False): images_indxs = list(itertools.product(range(T), range(Z))) images = None with ThreadPoolExecutor() as executor: - futures = [executor.submit(process_frame, imgs, images_indx, factor, is_segm) for images_indx in images_indxs] + futures = [ + executor.submit(process_frame, imgs, images_indx, factor, is_segm) + for images_indx in images_indxs + ] for future in futures: results.append(future.result()) - if not results: raise TypeError("No images to process (or this funciton has a funky error)") - + images_indx, img_resized = results[0] Y, X = img_resized.shape images = np.zeros((T, Z, Y, X), dtype=img_resized.dtype) - + for result in results: images_indx, img_resized = result @@ -47,6 +51,7 @@ def process_frames(imgs, factor, is_segm=False): return images + def load_images(images_path_in, file_path): path = os.path.join(images_path_in, file_path) @@ -64,15 +69,16 @@ def load_images(images_path_in, file_path): return imgs -def save_images(images, filename_in, images_path_out, text_to_append=''): + +def save_images(images, filename_in, images_path_out, text_to_append=""): if images is None: print("No images to save.") return - + images = np.squeeze(images) filename_in_noext, ext = os.path.splitext(filename_in) - filename_out = f'{filename_in_noext}{text_to_append}{ext}' - + filename_out = f"{filename_in_noext}{text_to_append}{ext}" + images_path_out_file = os.path.join(images_path_out, filename_out) if images_path_out_file.endswith(".tif"): @@ -81,26 +87,26 @@ def save_images(images, filename_in, images_path_out, text_to_append=''): io.savez_compressed(images_path_out_file, images) print(f"Sampling completed. File saved in:") - print(f"{images_path_out_file}\n") + print(f"{images_path_out_file}\n") + -def resize_imgs(images_path_in, factor, images_path_out=None, text_to_append=''): +def resize_imgs(images_path_in, factor, images_path_out=None, text_to_append=""): if images_path_out is None: images_path_out = images_path_in - + list_dir = myutils.listdir(images_path_in) - + # Get a list of all PNG files in the input folder images_files = [ - file for file in list_dir if ( - file.endswith(".tif") - or file.endswith('aligned.npz') - ) + file + for file in list_dir + if (file.endswith(".tif") or file.endswith("aligned.npz")) ] if not images_files: print("No image files found in the specified folder.") return - + for filename in images_files: print(f"Processing {filename}...") @@ -109,32 +115,32 @@ def resize_imgs(images_path_in, factor, images_path_out=None, text_to_append='') images = process_frames(images, factor) save_images( - images, filename, images_path_out=images_path_out, - text_to_append=text_to_append + images, + filename, + images_path_out=images_path_out, + text_to_append=text_to_append, ) -def edit_subs_bkgrROIs( - images_path_in, factor, images_path_out=None, text_to_append='' - ): + +def edit_subs_bkgrROIs(images_path_in, factor, images_path_out=None, text_to_append=""): if images_path_out is None: images_path_out = images_path_in - + list_dir = myutils.listdir(images_path_in) bkgrROIs_jsons = [file for file in list_dir if file.endswith("bkgrROIs.json")] bkgrROIs_npzs = [file for file in list_dir if file.endswith("bkgrROIs.npz")] - + # Is this fine to interpolate bkgrROIs_npzs or do I get the same issues as # with the segmentaion masks?" - if not bkgrROIs_jsons and not bkgrROIs_npzs: return for bkgrROIs_json_file in bkgrROIs_jsons: print(f"Processing {bkgrROIs_json_file}...") bkgrROIs_json_file_path = os.path.join(images_path_in, bkgrROIs_json_file) - with open(bkgrROIs_json_file_path, 'r') as file: + with open(bkgrROIs_json_file_path, "r") as file: data = json.load(file) data_scaled = [] @@ -152,53 +158,51 @@ def edit_subs_bkgrROIs( data_part[key] = value_scaled data_scaled.append(data_part) - + bkgrROIs_json_file_out = myutils.append_text_filename( bkgrROIs_json_file, text_to_append ) - images_path_out_file = os.path.join( - images_path_out, bkgrROIs_json_file_out - ) + images_path_out_file = os.path.join(images_path_out, bkgrROIs_json_file_out) - with open(images_path_out_file, 'w') as file: + with open(images_path_out_file, "w") as file: json.dump(data_scaled, file) - print(f'bkgrROIs.json files edited and saved in:') - print(f'{images_path_out_file}\n') + print(f"bkgrROIs.json files edited and saved in:") + print(f"{images_path_out_file}\n") for bkgrROIs_npz_file in bkgrROIs_npzs: - print('WARNING: Not tested yet') + print("WARNING: Not tested yet") print(f"Processing {bkgrROIs_npz_file}...") - + images = load_images(images_path_in, bkgrROIs_npz_file) images = process_frames(images, factor) save_images( - images, bkgrROIs_npz_file, - images_path_out=images_path_out, - text_to_append=text_to_append + images, + bkgrROIs_npz_file, + images_path_out=images_path_out, + text_to_append=text_to_append, ) -def edit_acdc_csvs( - images_path_in, factor, images_path_out=None, text_to_append='' - ): + +def edit_acdc_csvs(images_path_in, factor, images_path_out=None, text_to_append=""): if images_path_out is None: images_path_out = images_path_in - + columns_for_scaling = ["x_centroid", "y_centroid"] acdc_csvs = load.get_acdc_output_files(images_path_in) if not acdc_csvs: return - + for acdc_csv_file in acdc_csvs: print(f"Processing {acdc_csv_file}...") acdc_csv_file_path = os.path.join(images_path_in, acdc_csv_file) if not os.path.exists(acdc_csv_file_path): continue - + try: acdc_df = pd.read_csv(acdc_csv_file_path) except PermissionError as e: @@ -208,26 +212,21 @@ def edit_acdc_csvs( for column in columns_for_scaling: acdc_df[column] = (acdc_df[column] * factor).astype(int) - acdc_csv_file_out = myutils.append_text_filename( - acdc_csv_file, text_to_append - ) + acdc_csv_file_out = myutils.append_text_filename(acdc_csv_file, text_to_append) images_path_out_file = os.path.join(images_path_out, acdc_csv_file_out) acdc_df.to_csv(images_path_out_file, index=False) print(f"Modified CSV saved to:") print(f"{images_path_out_file}\n") -def edit_metadata( - images_path_in, factor, images_path_out=None, text_to_append='' - ): + +def edit_metadata(images_path_in, factor, images_path_out=None, text_to_append=""): if images_path_out is None: images_path_out = images_path_in - + list_dir = myutils.listdir(images_path_in) data_to_scale_int = ["SizeX", "SizeY"] data_to_scale_float = ["PhysicalSizeY", "PhysicalSizeX"] - metadata_files = [ - file for file in list_dir if file.endswith("metadata.csv") - ] + metadata_files = [file for file in list_dir if file.endswith("metadata.csv")] if not metadata_files: return @@ -235,7 +234,7 @@ def edit_metadata( for metadata_file in metadata_files: print(f"Processing {metadata_file}...") metadata_file_path = os.path.join(images_path_in, metadata_file) - with open(metadata_file_path, 'r') as file: + with open(metadata_file_path, "r") as file: metadata = file.read() new_metadata = "" @@ -249,27 +248,25 @@ def edit_metadata( new_metadata += ",".join(entries) + "\n" - metadata_file_out = myutils.append_text_filename( - metadata_file, text_to_append - ) + metadata_file_out = myutils.append_text_filename(metadata_file, text_to_append) images_path_out_file = os.path.join(images_path_out, metadata_file_out) - with open(images_path_out_file, 'w') as file: + with open(images_path_out_file, "w") as file: file.write(new_metadata) print(f"Metadata edited and saved in:") print(f"{images_path_out_file}\n") + def edit_lost_centroids( - images_path_in, factor, images_path_out=None, text_to_append='' - ): + images_path_in, factor, images_path_out=None, text_to_append="" +): if images_path_out is None: images_path_out = images_path_in - + list_dir = myutils.listdir(images_path_in) - + lost_centroids_jsons = [ - file for file in list_dir - if file.endswith("tracked_lost_centroids.json") + file for file in list_dir if file.endswith("tracked_lost_centroids.json") ] if not lost_centroids_jsons: @@ -277,10 +274,10 @@ def edit_lost_centroids( for lost_centroids_json in lost_centroids_jsons: print(f"Processing {lost_centroids_json}...") - + lost_centroids_json_path = os.path.join(images_path_in, lost_centroids_json) - with open(lost_centroids_json_path, 'r') as file: + with open(lost_centroids_json_path, "r") as file: lost_centroids = json.load(file) for frame_i, frame in lost_centroids.items(): @@ -296,21 +293,18 @@ def edit_lost_centroids( lost_centroids_json_out = myutils.append_text_filename( lost_centroids_json, text_to_append ) - images_path_out_file = os.path.join( - images_path_out, lost_centroids_json_out - ) - with open(images_path_out_file, 'w') as file: + images_path_out_file = os.path.join(images_path_out, lost_centroids_json_out) + with open(images_path_out_file, "w") as file: json.dump(lost_centroids, file, indent=4) - + print(f"Lost centroids edited and saved in:") print(f"{images_path_out_file}\n") -def resize_segms( - images_path_in, factor, images_path_out=None, text_to_append='' - ): + +def resize_segms(images_path_in, factor, images_path_out=None, text_to_append=""): if images_path_out is None: images_path_out = images_path_in - + segm_npzs = load.get_segm_files(images_path_in) if not segm_npzs: @@ -318,26 +312,32 @@ def resize_segms( for segm_npz_file in segm_npzs: print(f"Processing {segm_npz_file}...") - + images = load_images(images_path_in, segm_npz_file) images = process_frames(images, factor, is_segm=True) save_images( - images, segm_npz_file, images_path_out=images_path_out, - text_to_append=text_to_append + images, + segm_npz_file, + images_path_out=images_path_out, + text_to_append=text_to_append, ) + def copy_aux_files(images_path_in, images_path_out=None): if images_path_out is None: images_path_out = images_path_in list_dir = myutils.listdir(images_path_in) files_endings = [ - "_last_tracked_i.txt", "_combine_metrics.ini", "_segm_hyperparams.ini" + "_last_tracked_i.txt", + "_combine_metrics.ini", + "_segm_hyperparams.ini", ] aux_files = [ - file for file in list_dir + file + for file in list_dir if any(file.endswith(ending) for ending in files_endings) ] for aux_file in aux_files: @@ -350,31 +350,42 @@ def copy_aux_files(images_path_in, images_path_out=None): print(f"File {aux_file} copied to") print(f"{images_path_out}\n") -def run( - images_path_in, factor, images_path_out=None, text_to_append='' - ): + +def run(images_path_in, factor, images_path_out=None, text_to_append=""): resize_imgs( - images_path_in, factor, text_to_append=text_to_append, - images_path_out=images_path_out + images_path_in, + factor, + text_to_append=text_to_append, + images_path_out=images_path_out, ) edit_subs_bkgrROIs( - images_path_in, factor, text_to_append=text_to_append, - images_path_out=images_path_out + images_path_in, + factor, + text_to_append=text_to_append, + images_path_out=images_path_out, ) copy_aux_files(images_path_in, images_path_out=images_path_out) resize_segms( - images_path_in, factor, text_to_append=text_to_append, - images_path_out=images_path_out + images_path_in, + factor, + text_to_append=text_to_append, + images_path_out=images_path_out, ) edit_acdc_csvs( - images_path_in, factor, text_to_append=text_to_append, - images_path_out=images_path_out + images_path_in, + factor, + text_to_append=text_to_append, + images_path_out=images_path_out, ) edit_metadata( - images_path_in, factor, text_to_append=text_to_append, - images_path_out=images_path_out + images_path_in, + factor, + text_to_append=text_to_append, + images_path_out=images_path_out, ) edit_lost_centroids( - images_path_in, factor, text_to_append=text_to_append, - images_path_out=images_path_out - ) \ No newline at end of file + images_path_in, + factor, + text_to_append=text_to_append, + images_path_out=images_path_out, + ) diff --git a/cellacdc/utils/resize/util.py b/cellacdc/utils/resize/util.py index 9199f0d1a..7f57539d6 100644 --- a/cellacdc/utils/resize/util.py +++ b/cellacdc/utils/resize/util.py @@ -3,23 +3,29 @@ from ..base import NewThreadMultipleExpBaseUtil + class ResizePositionsUtil(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths self._parent = parent - + def runWorker(self): self.worker = workers.ResizeUtilWorker(self) self.worker.sigSetResizeProps.connect(self.setResizeProps) super().runWorker(self.worker) - + def setResizeProps(self, input_path): win = apps.ResizeUtilProps(input_path=input_path, parent=self._parent) win.exec_() @@ -27,19 +33,19 @@ def setResizeProps(self, input_path): if win.cancel: self.worker.waitCond.wakeAll() return - + self.worker.resizeFactor = win.resizeFactor self.worker.textToAppend = win.textToAppend self.worker.expFolderpathOut = win.expFolderpathOut self.worker.waitCond.wakeAll() - + def showEvent(self, event): self.runWorker() - + def workerFinished(self, worker): super().workerFinished(worker) - txt = 'Resizing data process completed.' + txt = "Resizing data process completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.information(self, 'Process completed', html_utils.paragraph(txt)) - self.close() \ No newline at end of file + msg.information(self, "Process completed", html_utils.paragraph(txt)) + self.close() diff --git a/cellacdc/utils/stack2Dinto3Dsegm.py b/cellacdc/utils/stack2Dinto3Dsegm.py index c6c0e6b64..a0433654a 100644 --- a/cellacdc/utils/stack2Dinto3Dsegm.py +++ b/cellacdc/utils/stack2Dinto3Dsegm.py @@ -2,66 +2,70 @@ from .base import NewThreadMultipleExpBaseUtil + class Stack2DsegmTo3Dsegm(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, SizeZ: int, parent=None - ): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + SizeZ: int, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths self._SizeZ = SizeZ - + def runWorker(self): self.worker = workers.Stack2DsegmTo3Dsegm(self, self._SizeZ) self.worker.sigAskAppendName.connect(self.askAppendName) self.worker.sigAborted.connect(self.workerAborted) super().runWorker(self.worker) - + def showEvent(self, event): self.runWorker() - + def askAppendName(self, basename, existingEndnames): - helpText = ( - """ + helpText = """ The new 3D segmentation file will be saved with a different file name.

    Insert a name to append to the end of the new name. The rest of the name will be the same as the original file. """ - ) win = apps.filenameDialog( basename=basename, - hintText='Insert a name for the new 3D segmentation file:', - existingNames=existingEndnames, - helpText=helpText, - allowEmpty=False + hintText="Insert a name for the new 3D segmentation file:", + existingNames=existingEndnames, + helpText=helpText, + allowEmpty=False, ) win.exec_() if win.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() return - + self.worker.appendedName = win.entryText self.worker.waitCond.wakeAll() - + def workerAborted(self): self.workerFinished(None, aborted=True) - + def workerFinished(self, worker, aborted=False): if aborted: - txt = '3D segmentation mask creation process aborted.' + txt = "3D segmentation mask creation process aborted." else: - txt = '3D segmentation mask creation process completed.' + txt = "3D segmentation mask creation process completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) if aborted: - msg.warning(self, 'Process completed', html_utils.paragraph(txt)) + msg.warning(self, "Process completed", html_utils.paragraph(txt)) else: - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) super().workerFinished(worker) - self.close() \ No newline at end of file + self.close() diff --git a/cellacdc/utils/toImageJroi.py b/cellacdc/utils/toImageJroi.py index 7d3724796..404f5a2bb 100644 --- a/cellacdc/utils/toImageJroi.py +++ b/cellacdc/utils/toImageJroi.py @@ -2,28 +2,34 @@ from .base import NewThreadMultipleExpBaseUtil + class toImageRoiUtil(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths - + def runWorker(self): self.worker = workers.ToImajeJroiWorker(self) super().runWorker(self.worker) - + def showEvent(self, event): self.runWorker() - + def workerFinished(self, worker): super().workerFinished(worker) - txt = 'Converting to ImageJ ROIs completed.' + txt = "Converting to ImageJ ROIs completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.information(self, 'Process completed', html_utils.paragraph(txt)) - self.close() \ No newline at end of file + msg.information(self, "Process completed", html_utils.paragraph(txt)) + self.close() diff --git a/cellacdc/utils/toObjCoords.py b/cellacdc/utils/toObjCoords.py index 7d8cfa59a..ff6f396c1 100644 --- a/cellacdc/utils/toObjCoords.py +++ b/cellacdc/utils/toObjCoords.py @@ -2,28 +2,34 @@ from .base import NewThreadMultipleExpBaseUtil + class toObjCoordsUtil(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, parent=None): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths - + def runWorker(self): self.worker = workers.ToObjCoordsWorker(self) super().runWorker(self.worker) - + def showEvent(self, event): self.runWorker() - + def workerFinished(self, worker): super().workerFinished(worker) - txt = 'Converting to object coordinates completed.' + txt = "Converting to object coordinates completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.information(self, 'Process completed', html_utils.paragraph(txt)) - self.close() \ No newline at end of file + msg.information(self, "Process completed", html_utils.paragraph(txt)) + self.close() diff --git a/cellacdc/utils/trackSubCellObjects.py b/cellacdc/utils/trackSubCellObjects.py index 87d25326f..ee8e2bf00 100644 --- a/cellacdc/utils/trackSubCellObjects.py +++ b/cellacdc/utils/trackSubCellObjects.py @@ -2,24 +2,29 @@ from .base import NewThreadMultipleExpBaseUtil + class TrackSubCellFeatures(NewThreadMultipleExpBaseUtil): def __init__( - self, expPaths, app, title: str, infoText: str, - progressDialogueTitle: str, trackSubCellObjParams: dict, - parent=None - ): + self, + expPaths, + app, + title: str, + infoText: str, + progressDialogueTitle: str, + trackSubCellObjParams: dict, + parent=None, + ): module = myutils.get_module_name(__file__) super().__init__( - expPaths, app, title, module, infoText, progressDialogueTitle, - parent=parent + expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) self.expPaths = expPaths - self.trackingMode = trackSubCellObjParams['how'] - self.IoAthresh = trackSubCellObjParams['IoA'] - self.relabelSubObjLab = trackSubCellObjParams['relabelSubObjLab'] - self.createThirdSegm = trackSubCellObjParams['createThirdSegm'] - self.thirdSegmAppendedText = trackSubCellObjParams['thirdSegmAppendedText'] - + self.trackingMode = trackSubCellObjParams["how"] + self.IoAthresh = trackSubCellObjParams["IoA"] + self.relabelSubObjLab = trackSubCellObjParams["relabelSubObjLab"] + self.createThirdSegm = trackSubCellObjParams["createThirdSegm"] + self.thirdSegmAppendedText = trackSubCellObjParams["thirdSegmAppendedText"] + def runWorker(self): self.worker = workers.TrackSubCellObjectsWorker(self) self.worker.sigAskAppendName.connect(self.askAppendName) @@ -28,10 +33,10 @@ def runWorker(self): ) self.worker.sigAborted.connect(self.workerAborted) super().runWorker(self.worker) - + def showEvent(self, event): self.runWorker() - + def criticalNotEnoughSegmFiles(self, exp_path): text = html_utils.paragraph(f""" The following experiment folder

    @@ -44,50 +49,46 @@ def criticalNotEnoughSegmFiles(self, exp_path): """) msg = widgets.myMessageBox(wrapText=False, showCentered=False) msg.addShowInFileManagerButton(exp_path) - msg.critical( - self, 'Not enough segmentation files!', text - ) + msg.critical(self, "Not enough segmentation files!", text) self.worker.abort = True self.worker.waitCond.wakeAll() - + def askAppendName(self, basename, existingEndnames): - helpText = ( - """ + helpText = """ The segmentation file containing the tracked sub-cellular objects will be saved with a different file name.

    Insert a name to append to the end of the new name. The rest of the name will be the same as the original file. """ - ) win = apps.filenameDialog( basename=basename, - hintText='Insert a name for the new, tracked segmentation file:', - existingNames=existingEndnames, - helpText=helpText, - allowEmpty=False + hintText="Insert a name for the new, tracked segmentation file:", + existingNames=existingEndnames, + helpText=helpText, + allowEmpty=False, ) win.exec_() if win.cancel: self.worker.abort = True self.worker.waitCond.wakeAll() return - + self.worker.appendedName = win.entryText self.worker.waitCond.wakeAll() - + def workerAborted(self): self.workerFinished(None, aborted=True) - + def workerFinished(self, worker, aborted=False): if aborted: - txt = 'Tracking sub-cellular objects aborted.' + txt = "Tracking sub-cellular objects aborted." else: - txt = 'Tracking sub-cellular objects completed.' + txt = "Tracking sub-cellular objects completed." self.logger.info(txt) msg = widgets.myMessageBox(wrapText=False, showCentered=False) if aborted: - msg.warning(self, 'Process completed', html_utils.paragraph(txt)) + msg.warning(self, "Process completed", html_utils.paragraph(txt)) else: - msg.information(self, 'Process completed', html_utils.paragraph(txt)) + msg.information(self, "Process completed", html_utils.paragraph(txt)) super().workerFinished(worker) - self.close() \ No newline at end of file + self.close() diff --git a/cellacdc/whitelist.py b/cellacdc/whitelist.py index f0cee0fef..87a63712c 100644 --- a/cellacdc/whitelist.py +++ b/cellacdc/whitelist.py @@ -7,22 +7,23 @@ import time from . import ( - html_utils, - apps, - widgets, - exception_handler, - disableWindow, + html_utils, + apps, + widgets, + exception_handler, + disableWindow, gui_utils, - exec_time + exec_time, ) from .trackers.CellACDC import CellACDC_tracker + class Whitelist: - """A class to manage the whitelist of IDs for a video. - """ + """A class to manage the whitelist of IDs for a video.""" + def __init__(self, total_frames: int | list | set, debug=False): """Initializes the whitelist with the total number of frames. - The whitelist is a dictionary with the frame index + The whitelist is a dictionary with the frame index as the key and a set of IDs as the value. Also the original not whitelisted labs are stored in the originalLabs variable. @@ -50,7 +51,7 @@ def __init__(self, total_frames: int | list | set, debug=False): self.initialized_i = set() self.new_centroids = None - def __getitem__(self, index:int): + def __getitem__(self, index: int): """Gets a whitelist for a given index. Parameters @@ -65,7 +66,7 @@ def __getitem__(self, index:int): """ return self.get(index) - def __setitem__(self, index:int, value:set): + def __setitem__(self, index: int, value: set): """Sets a whitelist for a given index. Parameters @@ -77,8 +78,8 @@ def __setitem__(self, index:int, value:set): """ self.whitelistIDs[index] = set(value) - def loadOGLabs(self, selected_path:str=None, og_data:np.ndarray=None): - """Loads the original labels from a .npz file, + def loadOGLabs(self, selected_path: str = None, og_data: np.ndarray = None): + """Loads the original labels from a .npz file, or from the provided og_data. Parameters @@ -93,9 +94,12 @@ def loadOGLabs(self, selected_path:str=None, og_data:np.ndarray=None): og_data = og_data[og_data.files[0]] self.originalLabs = og_data - self.originalLabsIDs = [{obj.label for obj in skimage.measure.regionprops(frame)} for frame in og_data] - - def saveOGLabs(self, save_path:str): + self.originalLabsIDs = [ + {obj.label for obj in skimage.measure.regionprops(frame)} + for frame in og_data + ] + + def saveOGLabs(self, save_path: str): """Saves the original labels to a .npz file. Parameters @@ -103,25 +107,27 @@ def saveOGLabs(self, save_path:str): save_path : str desired save path for the original labels """ - # original_frames = np.array(list(self.originalLabs.values())) - # the above is not necessary anymore, - #since I changed the originalLabs to be a np.ndarray + # original_frames = np.array(list(self.originalLabs.values())) + # the above is not necessary anymore, + # since I changed the originalLabs to be a np.ndarray np.savez_compressed(save_path, self.originalLabs) - - def load(self, whitelist_path:str, - new_centroids_path:str, - segm_data:np.ndarray, - allData_li:list=None, - ): + + def load( + self, + whitelist_path: str, + new_centroids_path: str, + segm_data: np.ndarray, + allData_li: list = None, + ): """Loads the whitelist from a json file. If the file does not exist, it initializes the whitelist to None. - If the file exists, it loads the whitelist and initializes + If the file exists, it loads the whitelist and initializes the originalLabs variable. Parameters ---------- whitelist_path : str - path to the whitelist json file (should be in accordance to the + path to the whitelist json file (should be in accordance to the one provided in save) segm_data : np.ndarray segmentation data for the video @@ -137,15 +143,17 @@ def load(self, whitelist_path:str, if not os.path.exists(whitelist_path): self.whitelistIDs = None return False - + any_whitelist_added = False - with open(whitelist_path, 'r') as json_file: + with open(whitelist_path, "r") as json_file: whitelist = json.load(json_file) wl_processed = dict() for key, val in whitelist.items(): if val is None: wl_processed[int(key)] = None - elif val == "None": # if the string "none" is present in the json file, it will be converted to None + elif ( + val == "None" + ): # if the string "none" is present in the json file, it will be converted to None wl_processed[int(key)] = None else: wl_processed[int(key)] = set(val) @@ -156,37 +164,43 @@ def load(self, whitelist_path:str, else: self.whitelistIDs = None return False - - self.makeOriginalLabsAndIDs(segm_data, allData_li - ) - + + self.makeOriginalLabsAndIDs(segm_data, allData_li) + self.load_centroids(new_centroids_path=new_centroids_path) return True - - def load_centroids(self, new_centroids_path:str): + + def load_centroids(self, new_centroids_path: str): if os.path.exists(new_centroids_path): - with open(new_centroids_path, 'r') as json_file: + with open(new_centroids_path, "r") as json_file: self.new_centroids = json.load(json_file) - - self.new_centroids = list(self.new_centroids) if isinstance(self.new_centroids, list) else self.new_centroids + + self.new_centroids = ( + list(self.new_centroids) + if isinstance(self.new_centroids, list) + else self.new_centroids + ) for i, val in enumerate(self.new_centroids): if isinstance(val, str) and val.lower() == "none": self.new_centroids[i] = {} elif val is None: self.new_centroids[i] = {} - else: # convert to integers - self.new_centroids[i] = {tuple(map(int, centroid)) for centroid in val} + else: # convert to integers + self.new_centroids[i] = { + tuple(map(int, centroid)) for centroid in val + } else: - printl('No new centroids file found, initializing new centroids.') + printl("No new centroids file found, initializing new centroids.") self.create_new_centroids() - - def create_new_centroids(self, - curr_rp=None, - frame_i:int=None, - ): + + def create_new_centroids( + self, + curr_rp=None, + frame_i: int = None, + ): """ Creates self.new_centroids based on the input data. - + Parameters ---------- @@ -202,38 +216,37 @@ def create_new_centroids(self, """ if self.new_centroids is not None: return - + if frame_i is None and curr_rp is not None: - raise ValueError( - 'If curr_rp is provided, frame_i must also be provided.' - ) - + raise ValueError("If curr_rp is provided, frame_i must also be provided.") + self.new_centroids = [] for i in self.total_frames: if i == 0: self.new_centroids.append({}) continue - - all_there = (self.originalLabsIDs[i] is not None and - self.originalLabsIDs[i-1] is not None) + + all_there = ( + self.originalLabsIDs[i] is not None + and self.originalLabsIDs[i - 1] is not None + ) if all_there is False: self.new_centroids.append({}) continue - - new_IDs = self.originalLabsIDs[i] - self.originalLabsIDs[i-1] - + + new_IDs = self.originalLabsIDs[i] - self.originalLabsIDs[i - 1] + rp = None - if frame_i==i and curr_rp is not None: + if frame_i == i and curr_rp is not None: rp = curr_rp else: rp = skimage.measure.regionprops(self.originalLabs[i]) - self.new_centroids.append({ - tuple(map(int, obj.centroid)) for obj in rp if obj.label in new_IDs - }) - + self.new_centroids.append( + {tuple(map(int, obj.centroid)) for obj in rp if obj.label in new_IDs} + ) - def save(self, whitelist_path:str, new_centroids_path:str): + def save(self, whitelist_path: str, new_centroids_path: str): """Saves the whitelist to a json file. If the whitelist is None, it will not be saved. Make sure that the path is in accordance to the one provided in load. @@ -241,7 +254,7 @@ def save(self, whitelist_path:str, new_centroids_path:str): Parameters ---------- whitelist_path : str - path to the whitelist json file (should be in accordance to the + path to the whitelist json file (should be in accordance to the one provided in load) """ if not self.whitelistIDs: @@ -252,20 +265,20 @@ def save(self, whitelist_path:str, new_centroids_path:str): wl_copy[key] = "None" else: wl_copy[key] = list(val) - json.dump(wl_copy, open(whitelist_path, 'w+'), indent=4) - + json.dump(wl_copy, open(whitelist_path, "w+"), indent=4) + for i, val in enumerate(self.new_centroids): if val is None: self.new_centroids[i] = "None" else: self.new_centroids[i] = list(val) - with open(new_centroids_path, 'w+') as json_file: + with open(new_centroids_path, "w+") as json_file: json.dump(self.new_centroids, json_file, indent=4) - def checkOriginalLabels(self, frame_i:int): + def checkOriginalLabels(self, frame_i: int): """Checks if there are no original labels for the current frame. - + Parameters ---------- frame_i : int @@ -275,22 +288,28 @@ def checkOriginalLabels(self, frame_i:int): bool True if there are original labels, False otherwise. """ - if len(self.originalLabsIDs) <= frame_i or self.originalLabsIDs is None or self.originalLabsIDs[frame_i] is None: + if ( + len(self.originalLabsIDs) <= frame_i + or self.originalLabsIDs is None + or self.originalLabsIDs[frame_i] is None + ): return False return True - def addNewIDs(self, frame_i:int, - allData_li: list, - IDs_curr: List[int] | Set[int]=None, - index_lab_combo: Tuple[int, np.ndarray]=None, - curr_rp: list=None, - curr_lab: np.ndarray=None, - # per_frame_IDs=None, - # labs=None - ): + def addNewIDs( + self, + frame_i: int, + allData_li: list, + IDs_curr: List[int] | Set[int] = None, + index_lab_combo: Tuple[int, np.ndarray] = None, + curr_rp: list = None, + curr_lab: np.ndarray = None, + # per_frame_IDs=None, + # labs=None + ): """Adds new IDs to the whitelist for a given frame based on the - original labels. The IDs are added to the whitelist for the + original labels. The IDs are added to the whitelist for the current frame. Also propagates. @@ -302,57 +321,62 @@ def addNewIDs(self, frame_i:int, passed to self.propagateIDs(), see rest of ACDC: posData. allData_li IDs_curr : list | set, optional - Currently present IDs, passed to self.propagateIDs(). by default + Currently present IDs, passed to self.propagateIDs(). by default None index_lab_combo: Tuple[int, np.ndarray]=None, - Combination of frame_i and current frame, + Combination of frame_i and current frame, passed to self.propagateIDs(), by default None curr_rp : list, optional - Region properties for the current frame, passed to + Region properties for the current frame, passed to self.propagateIDs(). by default None curr_lab : np.ndarray, optional - Labels for the current frame, passed to self.propagateIDs(). + Labels for the current frame, passed to self.propagateIDs(). by default None """ - - for i in [frame_i, frame_i-1]: + + for i in [frame_i, frame_i - 1]: if not self.checkOriginalLabels(i): return - + if curr_lab is None: - curr_lab = allData_li[frame_i]['labels'] - + curr_lab = allData_li[frame_i]["labels"] + new_centroids = self.new_centroids[frame_i] if not new_centroids: return - - new_IDs = {gui_utils.nearest_ID_to_centroid(curr_lab, *new_centroid) for new_centroid in new_centroids} - - self.propagateIDs(IDs_to_add=new_IDs, - curr_frame_only=False, - frame_i=frame_i, - allData_li=allData_li, - IDs_curr=IDs_curr, - index_lab_combo=index_lab_combo, - allow_only_current_IDs=False, - curr_rp=curr_rp, - curr_lab=curr_lab, - # per_frame_IDs=per_frame_IDs, - # labs=labs - ) - - def IDsAccepted(self, - whitelistIDs: Set[int] | List[int], - frame_i: int, - allData_li: list, - segm_data: np.ndarray, - curr_lab: np.ndarray=None, - index_lab_combo: Tuple[int, np.ndarray]=None, - IDs_curr: Set[int] | List[int]=None, - curr_rp: list=None, - # labs=None - ): - """Called if the user accepted IDs. + + new_IDs = { + gui_utils.nearest_ID_to_centroid(curr_lab, *new_centroid) + for new_centroid in new_centroids + } + + self.propagateIDs( + IDs_to_add=new_IDs, + curr_frame_only=False, + frame_i=frame_i, + allData_li=allData_li, + IDs_curr=IDs_curr, + index_lab_combo=index_lab_combo, + allow_only_current_IDs=False, + curr_rp=curr_rp, + curr_lab=curr_lab, + # per_frame_IDs=per_frame_IDs, + # labs=labs + ) + + def IDsAccepted( + self, + whitelistIDs: Set[int] | List[int], + frame_i: int, + allData_li: list, + segm_data: np.ndarray, + curr_lab: np.ndarray = None, + index_lab_combo: Tuple[int, np.ndarray] = None, + IDs_curr: Set[int] | List[int] = None, + curr_rp: list = None, + # labs=None + ): + """Called if the user accepted IDs. This can also be called if one wants forced propagation of IDs. Parameters @@ -366,31 +390,29 @@ def IDsAccepted(self, segm_data : np.ndarray The segmentation data for the video. Fallback to when allData_li is not provided. curr_lab : np.ndarray, optional - Labels for the current frame. Use instead of allData_li/segm_data + Labels for the current frame. Use instead of allData_li/segm_data for current frame_i Also passed to self.propagateIDs(), by default None index_lab_combo : Tuple[int, np.ndarray], optional - Combination of frame_i and current frame, + Combination of frame_i and current frame, passed to self.propagateIDs(), by default None IDs_curr : list | set, optional Currently present IDs, passed to self.propagateIDs(), by default None curr_rp : list, optional Region properties for the current frame, passed to self.propagateIDs(), by default None """ - + # if allData_li is None and labs is None: # raise ValueError('Either allData_li or curr_labs must be provided') # elif allData_li is not None and labs is not None: # raise ValueError('Either allData_li or curr_labs must be provided, not both') if self.whitelistIDs is None: - self.whitelistIDs = { - i: None for i in self.total_frames - } + self.whitelistIDs = {i: None for i in self.total_frames} if IDs_curr: if self._debug: - printl('Using IDs_curr') + printl("Using IDs_curr") try: IDs_curr = IDs_curr.copy() except AttributeError: @@ -399,49 +421,56 @@ def IDsAccepted(self, elif index_lab_combo and index_lab_combo[0] == frame_i: lab = index_lab_combo[1] if self._debug: - printl('Using index_lab_combo') + printl("Using index_lab_combo") IDs_curr = {obj.label for obj in skimage.measure.regionprops(lab)} elif curr_rp is not None: IDs_curr = {obj.label for obj in curr_rp} if self._debug: - printl('Using rp') + printl("Using rp") elif curr_lab is not None: lab = curr_lab if self._debug: - printl('Using curr_lab') + printl("Using curr_lab") IDs_curr = {obj.label for obj in skimage.measure.regionprops(lab)} else: - IDs_curr = allData_li[frame_i]['IDs'] + IDs_curr = allData_li[frame_i]["IDs"] if self._debug: - printl('Using allData_li') - - IDs_curr = set(IDs_curr) + printl("Using allData_li") + IDs_curr = set(IDs_curr) - self.makeOriginalLabsAndIDs(segm_data, allData_li=allData_li, - frame_i=frame_i, curr_lab=curr_lab, - IDs_curr=IDs_curr, - ) + self.makeOriginalLabsAndIDs( + segm_data, + allData_li=allData_li, + frame_i=frame_i, + curr_lab=curr_lab, + IDs_curr=IDs_curr, + ) self.create_new_centroids() whitelistIDs = set(whitelistIDs) - self.propagateIDs(frame_i, - allData_li, - new_whitelist=whitelistIDs, - try_create_new_whitelists=True, - force_not_dynamic_update=True, - index_lab_combo=index_lab_combo, - IDs_curr=IDs_curr, - curr_rp=curr_rp, - curr_lab=curr_lab, - # labs=labs, - ) - - def makeOriginalLabsAndIDs(self, segm_data: np.ndarray, - allData_li: list=None, frame_i: int=None, - curr_lab: np.ndarray=None, - IDs_curr: set | list=None,): - """ Initializes the originalLabs and originalLabsIDs variables. + self.propagateIDs( + frame_i, + allData_li, + new_whitelist=whitelistIDs, + try_create_new_whitelists=True, + force_not_dynamic_update=True, + index_lab_combo=index_lab_combo, + IDs_curr=IDs_curr, + curr_rp=curr_rp, + curr_lab=curr_lab, + # labs=labs, + ) + + def makeOriginalLabsAndIDs( + self, + segm_data: np.ndarray, + allData_li: list = None, + frame_i: int = None, + curr_lab: np.ndarray = None, + IDs_curr: set | list = None, + ): + """Initializes the originalLabs and originalLabsIDs variables. Parameters ---------- @@ -461,41 +490,44 @@ def makeOriginalLabsAndIDs(self, segm_data: np.ndarray, if IDs_curr is not None or curr_lab is not None: if IDs_curr is None or curr_lab is None or frame_i is None: raise ValueError( - 'If IDs_curr, curr_lab or frame_i are provided, all must be provided.' + "If IDs_curr, curr_lab or frame_i are provided, all must be provided." ) - + self.originalLabs = np.copy(segm_data) self.originalLabsIDs = [None] * len(self.total_frames) - + if IDs_curr is not None: self.originalLabsIDs[frame_i] = IDs_curr - + if allData_li is not None: for i in range(len(allData_li)): - if i == frame_i and IDs_curr is not None: # already set + if i == frame_i and IDs_curr is not None: # already set continue lab = None try: - lab = allData_li[i]['labels'] + lab = allData_li[i]["labels"] except: pass if lab is not None: self.originalLabs[i] = lab.copy() - + for i in range(len(segm_data)): IDs = None if IDs_curr is not None and i == frame_i: IDs = set(IDs_curr) elif allData_li is not None: try: - IDs = set(allData_li[i]['IDs']) + IDs = set(allData_li[i]["IDs"]) except KeyError: pass if IDs is None: - IDs = {obj.label for obj in skimage.measure.regionprops(self.originalLabs[i])} + IDs = { + obj.label + for obj in skimage.measure.regionprops(self.originalLabs[i]) + } self.originalLabsIDs[i] = IDs - - def get(self,frame_i:int,try_create_new_whitelists:bool=False): + + def get(self, frame_i: int, try_create_new_whitelists: bool = False): """Gets the whitelist for a given frame index. If the whitelist is not initialized, and try_create_new_whitelists is True, it will create a new whitelist empty for that frame. @@ -515,7 +547,7 @@ def get(self,frame_i:int,try_create_new_whitelists:bool=False): """ try: - old_whitelistIDs =self.whitelistIDs[frame_i] + old_whitelistIDs = self.whitelistIDs[frame_i] except Exception as e: if not try_create_new_whitelists: raise e @@ -525,21 +557,22 @@ def get(self,frame_i:int,try_create_new_whitelists:bool=False): old_whitelistIDs = set() else: raise e - + if old_whitelistIDs is None: old_whitelistIDs = set() else: old_whitelistIDs = set(old_whitelistIDs) - + return old_whitelistIDs - def initNewFrames(self, - frame_i: int, - force: bool = False, - ): + def initNewFrames( + self, + frame_i: int, + force: bool = False, + ): """Initialize the whitelists for all new frame. All frames up to and including frame_i will be initialized. - Unless forced, it will only initialize the whitelist if the frame is not + Unless forced, it will only initialize the whitelist if the frame is not already initialized, (tracked with self.initialized_i). Parameters @@ -547,7 +580,7 @@ def initNewFrames(self, frame_i : int The frame index for where the initialization should be done. force : bool, optional - If the frame_i (only this frame_i in that case) + If the frame_i (only this frame_i in that case) should be reinit, by default False Returns @@ -555,10 +588,10 @@ def initNewFrames(self, bool True if a new frame was initialized, False if not. """ - - missing_frames = set(range(frame_i+1)) - self.initialized_i + + missing_frames = set(range(frame_i + 1)) - self.initialized_i update_frames = [] - + if self._debug: printl(missing_frames, self.initialized_i, frame_i) @@ -575,10 +608,10 @@ def initNewFrames(self, if i == 0: prev_wl = set() else: - prev_wl = self.whitelistIDs[i-1] + prev_wl = self.whitelistIDs[i - 1] if prev_wl is None: prev_wl = set() - + if not self.checkOriginalLabels(i): available_IDs = set() else: @@ -591,46 +624,50 @@ def initNewFrames(self, self.whitelistIDs[i] = new_wl else: self.whitelistIDs[i] = set() - + self.initialized_i.add(i) update_frames.append((i, None, None, True)) if self._debug: - printl('Whitelist IDs new frame (without adding new IDs):', self.whitelistIDs[frame_i]) + printl( + "Whitelist IDs new frame (without adding new IDs):", + self.whitelistIDs[frame_i], + ) return new_frame, update_frames - - def propagateIDs(self, - frame_i: int, - allData_li: list, - new_whitelist: Set[int] | List[int] = None, - IDs_to_add: Set[int] = None, - IDs_to_remove: Set[int] = None, - try_create_new_whitelists: bool = False, - curr_frame_only: bool = False, - force_not_dynamic_update: bool = False, - only_future_frames: bool = True, - allow_only_current_IDs: bool = True, - IDs_curr: Set[int] | List[int] = None, - index_lab_combo: Tuple[int, np.ndarray] = None, - curr_rp: list = None, - curr_lab: np.ndarray = None, - update_frames: list = None, - ): + + def propagateIDs( + self, + frame_i: int, + allData_li: list, + new_whitelist: Set[int] | List[int] = None, + IDs_to_add: Set[int] = None, + IDs_to_remove: Set[int] = None, + try_create_new_whitelists: bool = False, + curr_frame_only: bool = False, + force_not_dynamic_update: bool = False, + only_future_frames: bool = True, + allow_only_current_IDs: bool = True, + IDs_curr: Set[int] | List[int] = None, + index_lab_combo: Tuple[int, np.ndarray] = None, + curr_rp: list = None, + curr_lab: np.ndarray = None, + update_frames: list = None, + ): """ Propagates whitelist IDs across frames in the dataset. (Doesn't update labs) Should also be called when viewing a new frame! This function updates whitelist. If curr_frame_only is True, it only updates the - whitelist of the current frame. If the frame changes, this function should be called + whitelist of the current frame. If the frame changes, this function should be called again to update the whitelist for the new frame (without this argument). It should also handle cases were this is not done, but this is less safe. Then, all the additions and removals are propagated to the other frames. - If force_not_dynamic_update is True, the function will propagate the entire whitelist to + If force_not_dynamic_update is True, the function will propagate the entire whitelist to frames, and not only the IDs which were added or removed. Hierarchy of arguments for current_IDs: 1. IDs_curr (if provided) - (2. index_lab_combo (if provided) (is also passed to not current frame only + (2. index_lab_combo (if provided) (is also passed to not current frame only propagation if that propagation is necessary, and used when the frame_i matches)) 3. curr_rp (if provided) 4. curr_lab (if provided) @@ -641,43 +678,43 @@ def propagateIDs(self, frame_i : int The frame index for the propagation. allData_li : list - See rest of ACDC. posData.allData_li. + See rest of ACDC. posData.allData_li. Used to get the IDs for the current frame. Especially for when propagating after curr_frame_only was changed. Strictly speaking could be substituted with the correct index_lab_combo if necessary in the future. new_whitelist : Set[int] | List[int], optional - A new set of whitelist IDs to replace the current whitelist. Cannot be + A new set of whitelist IDs to replace the current whitelist. Cannot be used together with `IDs_to_add` or `IDs_to_remove`, by default None. IDs_to_add : Set[int], optional A set of IDs to add to the current whitelist, by default None. IDs_to_remove : Set[int], optional A set of IDs to remove from the current whitelist, by default None. try_create_new_whitelists : bool, optional - If True, creates new whitelist entries for frames that do not already + If True, creates new whitelist entries for frames that do not already have them. Should only be necessary when its initialized, by default False. curr_frame_only : bool, optional - If True, only updates the whitelist for the current frame. + If True, only updates the whitelist for the current frame. (See description of function), by default False. force_not_dynamic_update : bool, optional - If True, disables dynamic updates to the whitelist. + If True, disables dynamic updates to the whitelist. (See description of function), by default False. only_future_frames : bool, optional If True, propagates changes only to future frames, by default True. allow_only_current_IDs : bool, optional - If True, only allows IDs that are present in the current frame + If True, only allows IDs that are present in the current frame to be added to the whitelist, by default True. IDs_curr : Set[int] | List[int], optional - A set of IDs for the current frame, if None, + A set of IDs for the current frame, if None, will be calculated from other stuff (see description), by default None. index_lab_combo : Tuple[int, np.ndarray], optional - Combination of frame_i and current frame, + Combination of frame_i and current frame, Used to get IDs_curr (see description), when the frame_i matches - (is also passed to not current frame only - propagation if that propagation is necessary, + (is also passed to not current frame only + propagation if that propagation is necessary, and used when the frame_i matches), by default None. curr_rp : list, optional - Region properties for the current frame. For IDs_curr. (see description), + Region properties for the current frame. For IDs_curr. (see description), by default None. curr_lab : np.ndarray, optional Labels for the current frame for IDs_curr. (see description), @@ -709,7 +746,7 @@ def propagateIDs(self, This would also propagate the changes to all other frames. """ - #doesn't update the frame displayed, only wl + # doesn't update the frame displayed, only wl # if allData_li is not None and per_frame_IDs is not None: # raise ValueError('Cannot provide both allData_li and per_frame_IDs') @@ -717,50 +754,50 @@ def propagateIDs(self, # raise ValueError('Either allData_li or per_frame_IDs or labs must be provided') # elif not allData_li and not per_frame_IDs: # per_frame_IDs = [set() for _ in labs] - + if not update_frames: update_frames = [] if self._debug: - printl('Propagating IDs...') + printl("Propagating IDs...") myutils.print_call_stack() printl(new_whitelist, IDs_to_add, IDs_to_remove) # if labs is None and not allData_li and not IDs_curr: # raise ValueError('Either labs or allData_li or IDs_curr/must be provided') # elif labs is not None and allData_li: - # raise ValueError('Cannot provide both labs and allData_li') - # elif + # raise ValueError('Cannot provide both labs and allData_li') + # elif if IDs_curr: if self._debug: - printl('Using IDs_curr') + printl("Using IDs_curr") try: IDs_curr = IDs_curr.copy() except AttributeError: pass IDs_curr = set(IDs_curr) - + elif index_lab_combo and index_lab_combo[0] == frame_i: lab = index_lab_combo[1] if self._debug: - printl('Using index_lab_combo') + printl("Using index_lab_combo") IDs_curr = {obj.label for obj in skimage.measure.regionprops(lab)} elif curr_rp is not None: IDs_curr = {obj.label for obj in curr_rp} if self._debug: - printl('Using rp') + printl("Using rp") elif curr_lab is not None: lab = curr_lab if self._debug: - printl('Using curr_lab') + printl("Using curr_lab") IDs_curr = {obj.label for obj in skimage.measure.regionprops(lab)} else: - IDs_curr = allData_li[frame_i]['IDs'] + IDs_curr = allData_li[frame_i]["IDs"] if self._debug: - printl('Using allData_li') - + printl("Using allData_li") + IDs_curr = set(IDs_curr) - + # else: # lab = labs[frame_i] # if self._debug: @@ -779,30 +816,39 @@ def propagateIDs(self, self.whitelistOriginalIDs = self.whitelistIDs[frame_i].copy() elif self.whitelistOriginalFrame_i != frame_i: if self._debug: - printl('Frame changed, whitelist was not propagated, propagating...') - new_update_frames = self.propagateIDs(self.whitelistOriginalFrame_i, - allData_li, - index_lab_combo=index_lab_combo, - update_frames=update_frames) + printl( + "Frame changed, whitelist was not propagated, propagating..." + ) + new_update_frames = self.propagateIDs( + self.whitelistOriginalFrame_i, + allData_li, + index_lab_combo=index_lab_combo, + update_frames=update_frames, + ) update_frames.extend(new_update_frames) else: if self.whitelistOriginalFrame_i is not None: if self.whitelistOriginalFrame_i != frame_i: if self._debug: - printl('Frame changed, whitelist was not propagated, propagating...') - new_update_frames = self.propagateIDs(self.whitelistOriginalFrame_i, - allData_li, - index_lab_combo=index_lab_combo, - update_frames=update_frames - ) + printl( + "Frame changed, whitelist was not propagated, propagating..." + ) + new_update_frames = self.propagateIDs( + self.whitelistOriginalFrame_i, + allData_li, + index_lab_combo=index_lab_combo, + update_frames=update_frames, + ) update_frames.extend(new_update_frames) else: propagate_after_curr_frame_only_flag = True self.whitelistOriginalFrame_i = None - + # see what the situation is with adding/removing IDs if new_whitelist and (IDs_to_add is not None or IDs_to_remove is not None): - raise ValueError('Cannot provide both new_whitelist and IDs_to_add or IDs_to_remove') + raise ValueError( + "Cannot provide both new_whitelist and IDs_to_add or IDs_to_remove" + ) # figure out what old wl supposed to be... if force_not_dynamic_update: @@ -810,14 +856,14 @@ def propagateIDs(self, elif propagate_after_curr_frame_only_flag: old_whitelist = self.whitelistOriginalIDs else: - old_whitelist = self.get(frame_i,try_create_new_whitelists) + old_whitelist = self.get(frame_i, try_create_new_whitelists) # construct new_whitelist if new_whitelist is not None: new_whitelist = set(new_whitelist) - else: # updated later if IDs_to_add or IDs_to_remove are provided - new_whitelist = self.get(frame_i,try_create_new_whitelists) - + else: # updated later if IDs_to_add or IDs_to_remove are provided + new_whitelist = self.get(frame_i, try_create_new_whitelists) + if IDs_to_add is not None or IDs_to_remove is not None: if IDs_to_add is None: IDs_to_add = set() @@ -848,7 +894,7 @@ def propagateIDs(self, if self._debug: printl(IDs_to_add, IDs_to_remove) - + prop_to_frame_i = last_frame_i if curr_frame_only: @@ -872,7 +918,7 @@ def propagateIDs(self, if frame_i == i: IDs_curr_loc = IDs_curr else: - IDs_curr_loc = set(allData_li[i]['IDs']) + IDs_curr_loc = set(allData_li[i]["IDs"]) new_whitelist = self.get(i, try_create_new_whitelists).copy() old_whitelist = new_whitelist.copy() @@ -880,19 +926,20 @@ def propagateIDs(self, removed_IDs = [] if IDs_to_add: # intersection with... all possible IDs ...plus all old_whitelistIDs - new_whitelist = IDs_to_add.intersection(IDs_curr_loc.union(IDs_og)) | old_whitelist + new_whitelist = ( + IDs_to_add.intersection(IDs_curr_loc.union(IDs_og)) | old_whitelist + ) # IDs_curr.union(IDs_og) are all possible IDs, IDs_to_add.intersection(IDs_curr.union(IDs_og)) is for finding all possible IDs which want ot be propagated added_IDs = new_whitelist - old_whitelist if IDs_to_remove: new_whitelist = new_whitelist - IDs_to_remove removed_IDs = old_whitelist - new_whitelist - + self.whitelistIDs[i] = new_whitelist if added_IDs or removed_IDs: - update_frames.append((i,added_IDs, removed_IDs, False)) + update_frames.append((i, added_IDs, removed_IDs, False)) if self._debug: printl(self.whitelistIDs[frame_i]) - + return update_frames - diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index 5b03fa52c..93a72eddd 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -26,37 +26,118 @@ from matplotlib.backends.backend_agg import FigureCanvasAgg from qtpy.QtCore import ( - Signal, QTimer, Qt, QPoint, QUrl, Property, - QPropertyAnimation, QEasingCurve, QLocale, - QSize, QRect, QPointF, QRect, QPoint, QEasingCurve, QRegularExpression, - QEvent, QEventLoop, QPropertyAnimation, QObject, - QItemSelectionModel, QAbstractListModel, QModelIndex, - QByteArray, QDataStream, QMimeData, QAbstractItemModel, - QIODevice, QItemSelection, PYQT6, QRectF + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, ) from qtpy.QtGui import ( - QFont, QPalette, QColor, QPen, QKeyEvent, QBrush, QPainter, - QRegularExpressionValidator, QIcon, QPixmap, QKeySequence, QLinearGradient, - QShowEvent, QDesktopServices, QFontMetrics, QGuiApplication, QLinearGradient, - QImage, QCursor, QPicture + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, ) from qtpy.QtWidgets import ( - QTextEdit, QLabel, QProgressBar, QHBoxLayout, QToolButton, QCheckBox, - QApplication, QWidget, QVBoxLayout, QMainWindow, QTreeWidgetItemIterator, - QLineEdit, QSlider, QSpinBox, QGridLayout, QRadioButton, - QScrollArea, QSizePolicy, QComboBox, QPushButton, QScrollBar, - QGroupBox, QAbstractSlider, QDoubleSpinBox, QWidgetAction, - QAction, QTabWidget, QAbstractSpinBox, QToolBar, QStyleOptionSpinBox, - QStyle, QDialog, QSpacerItem, QFrame, QMenu, QActionGroup, - QListWidget, QPlainTextEdit, QFileDialog, QListView, QAbstractItemView, - QTreeWidget, QTreeWidgetItem, QListWidgetItem, QLayout, QStylePainter, - QGraphicsBlurEffect, QGraphicsProxyWidget, QGraphicsObject, - QButtonGroup, QStyleOptionSlider + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, ) import qtpy.compat import pyqtgraph as pg -pg.setConfigOption('imageAxisOrder', 'row-major') + +pg.setConfigOption("imageAxisOrder", "row-major") from . import myutils, measurements, is_mac, is_win, html_utils, is_linux from . import printl, settings_folderpath @@ -87,108 +168,106 @@ font = QFont() font.setPixelSize(12) -custom_cmaps_filepath = os.path.join(settings_folderpath, 'custom_colormaps.ini') +custom_cmaps_filepath = os.path.join(settings_folderpath, "custom_colormaps.ini") + +str_to_operator_mapper = {"+": operator.add, "-": operator.sub} -str_to_operator_mapper = { - "+": operator.add, - "-": operator.sub -} +sign_int_mapper = {"+": 1, "-": -1} -sign_int_mapper = { - '+': 1, '-': -1 -} def removeHSVcmaps(): hsv_cmaps = [] for g, grad in pg.graphicsItems.GradientEditorItem.Gradients.items(): - if grad['mode'] == 'hsv': + if grad["mode"] == "hsv": hsv_cmaps.append(g) for g in hsv_cmaps: del pg.graphicsItems.GradientEditorItem.Gradients[g] + def renamePgCmaps(): Gradients = pg.graphicsItems.GradientEditorItem.Gradients try: - Gradients['hot'] = Gradients.pop('thermal') + Gradients["hot"] = Gradients.pop("thermal") except KeyError: pass try: - Gradients.pop('greyclip') + Gradients.pop("greyclip") except KeyError: pass + def _tab20gradient(): - cmap = plt.get_cmap('tab20') - ticks = [ - (t, tuple([int(v*255) for v in cmap(t)])) for t in np.linspace(0,1,20) - ] - gradient = {'ticks': ticks, 'mode': 'rgb'} + cmap = plt.get_cmap("tab20") + ticks = [(t, tuple([int(v * 255) for v in cmap(t)])) for t in np.linspace(0, 1, 20)] + gradient = {"ticks": ticks, "mode": "rgb"} return gradient + def _tab10gradient(): - cmap = plt.get_cmap('tab10') - ticks = [ - (t, tuple([int(v*255) for v in cmap(t)])) for t in np.linspace(0,1,20) - ] - gradient = {'ticks': ticks, 'mode': 'rgb'} + cmap = plt.get_cmap("tab10") + ticks = [(t, tuple([int(v * 255) for v in cmap(t)])) for t in np.linspace(0, 1, 20)] + gradient = {"ticks": ticks, "mode": "rgb"} return gradient -def getCustomGradients(name='image'): + +def getCustomGradients(name="image"): CustomGradients = {} if not os.path.exists(custom_cmaps_filepath): return CustomGradients - + cp = config.ConfigParser() cp.read(custom_cmaps_filepath) for section in cp.sections(): - if not section.startswith(f'{name}'): + if not section.startswith(f"{name}"): continue - - cmap_name = section[len(f'{name}.'):] - CustomGradients[cmap_name] = {'ticks': [], 'mode': 'rgb'} + + cmap_name = section[len(f"{name}.") :] + CustomGradients[cmap_name] = {"ticks": [], "mode": "rgb"} for option in cp.options(section): value = cp[section][option] - pos, *rgb = value.split(',') + pos, *rgb = value.split(",") rgb = tuple([int(c) for c in rgb]) pos = float(pos) - CustomGradients[cmap_name]['ticks'].append((pos, rgb)) + CustomGradients[cmap_name]["ticks"].append((pos, rgb)) return CustomGradients + def addGradients(): Gradients = pg.graphicsItems.GradientEditorItem.Gradients - Gradients['cividis'] = { - 'ticks': [ + Gradients["cividis"] = { + "ticks": [ (0.0, (0, 34, 78, 255)), (0.25, (66, 78, 108, 255)), (0.5, (124, 123, 120, 255)), (0.75, (187, 173, 108, 255)), - (1.0, (254, 232, 56, 255))], - 'mode': 'rgb' + (1.0, (254, 232, 56, 255)), + ], + "mode": "rgb", } - Gradients['cool'] = { - 'ticks': [ - (0.0, (0, 255, 255, 255)), - (1.0, (255, 0, 255, 255))], - 'mode': 'rgb' + Gradients["cool"] = { + "ticks": [(0.0, (0, 255, 255, 255)), (1.0, (255, 0, 255, 255))], + "mode": "rgb", } - Gradients['sunset'] = { - 'ticks': [ + Gradients["sunset"] = { + "ticks": [ (0.0, (71, 118, 148, 255)), (0.4, (222, 213, 141, 255)), (0.8, (229, 184, 155, 255)), - (1.0, (240, 127, 97, 255))], - 'mode': 'rgb' + (1.0, (240, 127, 97, 255)), + ], + "mode": "rgb", } - Gradients['tab20'] = _tab20gradient() - Gradients['tab10'] = _tab10gradient() + Gradients["tab20"] = _tab20gradient() + Gradients["tab10"] = _tab10gradient() cmaps = {} for name, gradient in Gradients.items(): - ticks = gradient['ticks'] - colors = [tuple([v/255 for v in tick[1]]) for tick in ticks] + ticks = gradient["ticks"] + colors = [tuple([v / 255 for v in tick[1]]) for tick in ticks] cmaps[name] = LinearSegmentedColormap.from_list(name, colors, N=256) return cmaps, Gradients -nonInvertibleCmaps = ['cool', 'sunset', 'bipolar'] + +nonInvertibleCmaps = ["cool", "sunset", "bipolar"] renamePgCmaps() removeHSVcmaps() @@ -196,28 +275,29 @@ def addGradients(): GradientsLabels = Gradients.copy() GradientsImage = Gradients.copy() + class XStream(QObject): _stdout = None _stderr = None messageWritten = Signal(str) - - def flush( self ): + + def flush(self): pass - - def fileno( self ): + + def fileno(self): return -1 - + def write(self, msg): if not self.signalsBlocked(): self.messageWritten.emit(msg) - + @staticmethod def stdout(): if not XStream._stdout: XStream._stdout = XStream() sys.stdout = XStream._stdout return XStream._stdout - + @staticmethod def stderr(): if not XStream._stderr: @@ -225,14 +305,16 @@ def stderr(): sys.stderr = XStream._stderr return XStream._stderr + class QtHandler(logging.Handler): def __init__(self): super().__init__() - + def emit(self, record): record = self.format(record) - if record: - XStream.stdout().write('%s\n'%record) + if record: + XStream.stdout().write("%s\n" % record) + class QLog(QPlainTextEdit): sigClose = Signal() @@ -245,7 +327,7 @@ def __init__(self, *args, logger=None): def connect(self): XStream.stdout().messageWritten.connect(self.writeStdOutput) # XStream.stderr().messageWritten.connect(self.writeStdErr) - + def writeStdOutput(self, text: str) -> None: super().insertPlainText(text) self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) @@ -255,20 +337,20 @@ def writeStdErr(self, text: str) -> None: self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) if self.logger is not None: self.logger.exception(text) - + def insertPlainText(self, text: str) -> None: - super().insertPlainText(f'{text}\n') + super().insertPlainText(f"{text}\n") self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) - + def closeEvent(self, event) -> None: super().closeEvent(event) self.sigClose.emit() + class PushButton(QPushButton): def __init__( - self, *args, icon=None, alignIconLeft=False, - flat=False, hoverable=False - ): + self, *args, icon=None, alignIconLeft=False, flat=False, hoverable=False + ): super().__init__(*args) if icon is not None: self.setIcon(icon) @@ -278,354 +360,394 @@ def __init__( self.setFlat(True) if hoverable: self.installEventFilter(self) - + def setRetainSizeWhenHidden(self, retainSize): sp = self.sizePolicy() sp.setRetainSizeWhenHidden(retainSize) self.setSizePolicy(sp) - + def eventFilter(self, object, event): if event.type() == QEvent.Type.HoverEnter: self.setFlat(False) elif event.type() == QEvent.Type.HoverLeave: self.setFlat(True) return False - + def show(self): text = self.text() if not self.alignIconLeft: super().show() - return + return self._text = text - self.setStyleSheet('text-align:left;') + self.setStyleSheet("text-align:left;") self.setLayout(QGridLayout()) textLabel = QLabel(self._text) textLabel.setAlignment(Qt.AlignRight | Qt.AlignVCenter) textLabel.setAttribute(Qt.WA_TransparentForMouseEvents, True) self._layout().addWidget(textLabel) super().show() - + def confirmAction(self): self.baseIcon = self.icon() - self.setIcon(QIcon(':greenTick.svg')) + self.setIcon(QIcon(":greenTick.svg")) QTimer.singleShot(2000, self.resetButton) - + def resetButton(self): self.setIcon(self.baseIcon) - + def setText(self, text): if self._text is None: super().setText(text) else: super().setText(self._text) + class LoadPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':fork_lift.svg')) + self.setIcon(QIcon(":fork_lift.svg")) + class mergePushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':merge-IDs.svg')) + self.setIcon(QIcon(":merge-IDs.svg")) + class okPushButton(PushButton): def __init__(self, *args, isDefault=True, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':yesGray.svg')) + self.setIcon(QIcon(":yesGray.svg")) if isDefault: self.setDefault(True) # QShortcut(Qt.Key_Return, self, self.click) # QShortcut(Qt.Key_Enter, self, self.click) + class MagnifyingGlassPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':magnGlass.svg')) - + self.setIcon(QIcon(":magnGlass.svg")) + + class MagnifyingGlassAllPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':magnGlass_all.svg')) + self.setIcon(QIcon(":magnGlass_all.svg")) + class AssignNewIDButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':assign_new_id.svg')) + self.setIcon(QIcon(":assign_new_id.svg")) + class LockPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':lock.svg')) + self.setIcon(QIcon(":lock.svg")) self.toggled.connect(self.onToggled) - + def onToggled(self, checked): if not self.isCheckable(): return - + if checked: - self.setIcon(QIcon(':lock_closed.svg')) + self.setIcon(QIcon(":lock_closed.svg")) else: - self.setIcon(QIcon(':lock_open.svg')) - + self.setIcon(QIcon(":lock_open.svg")) + def setCheckable(self, checkable: bool): super().setCheckable(checkable) if checkable: - self.setIcon(QIcon(':lock_open.svg')) + self.setIcon(QIcon(":lock_open.svg")) else: - self.setIcon(QIcon(':lock.svg')) + self.setIcon(QIcon(":lock.svg")) class SkipPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':skip_arrow.svg')) + self.setIcon(QIcon(":skip_arrow.svg")) + class BedPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':bed.svg')) + self.setIcon(QIcon(":bed.svg")) + class BedPlusLabelPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':bed_plus_label.svg')) + self.setIcon(QIcon(":bed_plus_label.svg")) iconH = self.iconSize().height() - iconW = int(iconH*2.5) + iconW = int(iconH * 2.5) self.setIconSize(QSize(iconW, iconH)) + class NoBedPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':no_bed.svg')) + self.setIcon(QIcon(":no_bed.svg")) + class NavigatePushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':navigate.svg')) + self.setIcon(QIcon(":navigate.svg")) + class SwitchPlaneButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':switch_2d_plane.svg')) - self._planes = ('xy', 'zy', 'zx') + self.setIcon(QIcon(":switch_2d_plane.svg")) + self._planes = ("xy", "zy", "zx") self._idx = 0 - + def switchPlane(self): self._idx += 1 - + def setPlane(self, plane): self._idx = self._planes.index(plane) - + def plane(self): return self._planes[self._idx % 3] def depthAxes(self): plane = self.plane() - for axes in 'xyz': + for axes in "xyz": if axes not in plane: return axes + class zoomPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':zoom_out.svg')) - + self.setIcon(QIcon(":zoom_out.svg")) + def setIconZoomOut(self): - self.setIcon(QIcon(':zoom_out.svg')) - + self.setIcon(QIcon(":zoom_out.svg")) + def setIconZoomIn(self): - self.setIcon(QIcon(':zoom_in.svg')) + self.setIcon(QIcon(":zoom_in.svg")) + class WarningButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':warning.svg')) + self.setIcon(QIcon(":warning.svg")) + class reloadPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':reload.svg')) + self.setIcon(QIcon(":reload.svg")) + class savePushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':file-save.svg')) + self.setIcon(QIcon(":file-save.svg")) + class autoPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':cog_play.svg')) + self.setIcon(QIcon(":cog_play.svg")) + class newFilePushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':file-new.svg')) + self.setIcon(QIcon(":file-new.svg")) + class helpPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':help.svg')) + self.setIcon(QIcon(":help.svg")) + class viewPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':eye.svg')) + self.setIcon(QIcon(":eye.svg")) + class infoPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':info.svg')) + self.setIcon(QIcon(":info.svg")) + class threeDPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':3d.svg')) + self.setIcon(QIcon(":3d.svg")) + class twoDPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':2d.svg')) + self.setIcon(QIcon(":2d.svg")) + class addPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':add.svg')) + self.setIcon(QIcon(":add.svg")) + class futurePushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':arrow_future.svg')) + self.setIcon(QIcon(":arrow_future.svg")) + class FutureAllPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':arrow_future_all.svg')) + self.setIcon(QIcon(":arrow_future_all.svg")) + class currentPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':arrow_current.svg')) + self.setIcon(QIcon(":arrow_current.svg")) + class arrowUpPushButton(PushButton): def __init__(self, *args, **kwargs): - alignIconLeft = kwargs.get('alignIconLeft', False) + alignIconLeft = kwargs.get("alignIconLeft", False) super().__init__( - *args, icon=QIcon(':arrow-up.svg'), alignIconLeft=alignIconLeft + *args, icon=QIcon(":arrow-up.svg"), alignIconLeft=alignIconLeft ) + class arrowDownPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':arrow-down.svg')) + self.setIcon(QIcon(":arrow-down.svg")) + class selectAllPushButton(PushButton): sigClicked = Signal(object, bool) - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._status = 'deselect' - self.setIcon(QIcon(':deselect_all.svg')) - self.setText('Deselect all') + self._status = "deselect" + self.setIcon(QIcon(":deselect_all.svg")) + self.setText("Deselect all") self.clicked.connect(self.onClicked) self.setMinimumWidth(self.sizeHint().width()) - + def setChecked(self, checked): if checked: - self._status == 'deselect' + self._status == "deselect" else: - self._status == 'select' + self._status == "select" self.click() - + def onClicked(self): - if self._status == 'select': - icon_fn = ':deselect_all.svg' - self._status = 'deselect' + if self._status == "select": + icon_fn = ":deselect_all.svg" + self._status = "deselect" checked = True - text = 'Deselect all' + text = "Deselect all" else: - icon_fn = ':select_all.svg' - text = 'Select all' - self._status = 'select' + icon_fn = ":select_all.svg" + text = "Select all" + self._status = "select" checked = False self.setIcon(QIcon(icon_fn)) self.setText(text) self.sigClicked.emit(self, checked) + class subtractPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':subtract.svg')) + self.setIcon(QIcon(":subtract.svg")) + class continuePushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':continue.svg')) + self.setIcon(QIcon(":continue.svg")) + class calcPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':calc.svg')) + self.setIcon(QIcon(":calc.svg")) + class playPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':play.svg')) + self.setIcon(QIcon(":play.svg")) + class stopPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':stop.svg')) + self.setIcon(QIcon(":stop.svg")) + class copyPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':edit-copy.svg')) + self.setIcon(QIcon(":edit-copy.svg")) self.clicked.connect(self.onClicked) self._text_to_copy = None - + def setTextToCopy(self, text): self._text_to_copy = text - + def onClicked(self): self._original_text = self.text() if self._text_to_copy is not None: cb = QApplication.clipboard() cb.clear(mode=cb.Clipboard) cb.setText(self._text_to_copy, mode=cb.Clipboard) - - super().setText('Copied!') - self.setIcon(QIcon(':greenTick.svg')) + + super().setText("Copied!") + self.setIcon(QIcon(":greenTick.svg")) QTimer.singleShot(2000, self.resetButton) - + def resetButton(self): self.setText(self._original_text) - self.setIcon(QIcon(':edit-copy.svg')) + self.setIcon(QIcon(":edit-copy.svg")) + class OpenFilePushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':folder-open.svg')) + self.setIcon(QIcon(":folder-open.svg")) + class movePushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':folder-move.svg')) + self.setIcon(QIcon(":folder-move.svg")) + class DownloadPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':download.svg')) + self.setIcon(QIcon(":download.svg")) + class showInFileManagerButton(PushButton): def __init__(self, *args, setDefaultText=False, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':drawer.svg')) + self.setIcon(QIcon(":drawer.svg")) self._path_to_browse = None if setDefaultText: self.setDefaultText() - + def setDefaultText(self): self._text = myutils.get_show_in_file_manager_text() self.setText(self._text) @@ -633,36 +755,37 @@ def setDefaultText(self): def setPathToBrowse(self, path: os.PathLike): self._path_to_browse = path self.clicked.connect(partial(myutils.showInExplorer, path)) - - - + + class OpenUrlButton(PushButton): def __init__(self, url, *args, **kwargs): self._url = url super().__init__(*args, **kwargs) - self.setIcon(QIcon(':browser.svg')) + self.setIcon(QIcon(":browser.svg")) self.clicked.connect(self.openUrl) - + def openUrl(self): QDesktopServices.openUrl(QUrl(self._url)) + class LessThanPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':less_than.svg')) - flat = kwargs.get('flat') + self.setIcon(QIcon(":less_than.svg")) + flat = kwargs.get("flat") if flat is not None: self.setFlat(True) + class showDetailsButton(PushButton): sigToggled = Signal(bool) - - def __init__(self, *args, txt='Show details...', parent=None): + + def __init__(self, *args, txt="Show details...", parent=None): super().__init__(txt, parent) # self.setText(txt) self.txt = txt - self.checkedIcon = QIcon(':hideUp.svg') - self.uncheckedIcon = QIcon(':showDown.svg') + self.checkedIcon = QIcon(":hideUp.svg") + self.uncheckedIcon = QIcon(":showDown.svg") self.setIcon(self.uncheckedIcon) self.toggled.connect(self.onClicked) self.setCheckable(True) @@ -671,110 +794,125 @@ def __init__(self, *args, txt='Show details...', parent=None): def onClicked(self, checked): if checked: - self.setText(self.txt.replace('Show', 'Hide')) + self.setText(self.txt.replace("Show", "Hide")) self.setIcon(self.checkedIcon) else: self.setText(self.txt) self.setIcon(self.uncheckedIcon) - + self.sigToggled.emit(checked) + class cancelPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':cancelButton.svg')) + self.setIcon(QIcon(":cancelButton.svg")) + class setPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':cog.svg')) + self.setIcon(QIcon(":cog.svg")) + class TrainPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':train.svg')) + self.setIcon(QIcon(":train.svg")) + class noPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':no.svg')) + self.setIcon(QIcon(":no.svg")) + class editPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':edit-id.svg')) + self.setIcon(QIcon(":edit-id.svg")) + class delPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':bin.svg')) + self.setIcon(QIcon(":bin.svg")) + class eraserPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':eraser.svg')) + self.setIcon(QIcon(":eraser.svg")) + class CrossCursorPointButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':cross_cursor.svg')) + self.setIcon(QIcon(":cross_cursor.svg")) + class TestPushButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':test.svg')) + self.setIcon(QIcon(":test.svg")) + class browseFileButton(PushButton): sigPathSelected = Signal(str) def __init__( - self, *args, ext=None, title='Select file', start_dir='', - openFolder=False, **kwargs - ): + self, + *args, + ext=None, + title="Select file", + start_dir="", + openFolder=False, + **kwargs, + ): """PushButton with sigPathSelected Signal to select file or folder Parameters ---------- ext : dict or None, optional - If not None, this is a dictionary of - {'FILE NAME': ['.ext1', '.ext2', ...]}. - For example, to allow only selection of CSV files, - pass {'CSV': ['.csv']}. - + If not None, this is a dictionary of + {'FILE NAME': ['.ext1', '.ext2', ...]}. + For example, to allow only selection of CSV files, + pass {'CSV': ['.csv']}. + Note that the 'FILE NAME' is arbitrary. Default is None title : str, optional Title of the File Manager window. Default is 'Select file' start_dir : str, optional - Directory where the File Manager window will initially be open. + Directory where the File Manager window will initially be open. Default is '' openFolder : bool, optional - If True, allows for selection of folders instead of files. + If True, allows for selection of folders instead of files. Default is False - """ + """ super().__init__(*args, **kwargs) - self.setIcon(QIcon(':folder-open.svg')) + self.setIcon(QIcon(":folder-open.svg")) self.clicked.connect(self.browse) - + self._title = title self._start_dir = start_dir self._openFolder = openFolder - self._file_types = 'All Files (*)' + self._file_types = "All Files (*)" if ext is not None: s_li = [] for name, extensions in ext.items(): - _s = '' + _s = "" if isinstance(extensions, str): extensions = [extensions] for ext in extensions: - _s = f'{_s}*{ext} ' - s_li.append(f'{name} {_s.strip()}') + _s = f"{_s}*{ext} " + s_li.append(f"{name} {_s.strip()}") - self._file_types = ';;'.join(s_li) - self._file_types = f'{self._file_types};;All Files (*)' + self._file_types = ";;".join(s_li) + self._file_types = f"{self._file_types};;All Files (*)" def setStartPath(self, start_path): self._start_dir = start_path - + def browse(self): if self._openFolder: fileDialog = QFileDialog.getExistingDirectory @@ -788,30 +926,31 @@ def browse(self): if file_path: self.sigPathSelected.emit(file_path) + def getPushButton(buttonText, qparent=None): isCancelButton = ( - buttonText.lower().find('cancel') != -1 - or buttonText.lower().find('abort') != -1 + buttonText.lower().find("cancel") != -1 + or buttonText.lower().find("abort") != -1 ) isYesButton = ( - buttonText.lower().find('yes') != -1 - or buttonText.lower().find('ok') != -1 - or buttonText.lower().find('continue') != -1 - or buttonText.lower().find('recommended') != -1 + buttonText.lower().find("yes") != -1 + or buttonText.lower().find("ok") != -1 + or buttonText.lower().find("continue") != -1 + or buttonText.lower().find("recommended") != -1 ) - isSettingsButton = buttonText.lower().find('set') != -1 + isSettingsButton = buttonText.lower().find("set") != -1 isNoButton = ( - buttonText.replace(' ', '').lower() == 'no' - or buttonText.lower().find('Do not ') != -1 - or buttonText.lower().find('no, ') != -1 + buttonText.replace(" ", "").lower() == "no" + or buttonText.lower().find("Do not ") != -1 + or buttonText.lower().find("no, ") != -1 ) - isDelButton = buttonText.lower().find('delete') != -1 - isAddButton = buttonText.lower().find('add ') != -1 - is3Dbutton = buttonText.find(' 3D ') != -1 - is2Dbutton = buttonText.find(' 2D ') != -1 - isSaveButton = buttonText.lower().find('overwrite') != -1 - isNewFileButton = buttonText.lower().find('rename') != -1 - isTryAgainButton = buttonText.lower().find('try again') != -1 + isDelButton = buttonText.lower().find("delete") != -1 + isAddButton = buttonText.lower().find("add ") != -1 + is3Dbutton = buttonText.find(" 3D ") != -1 + is2Dbutton = buttonText.find(" 2D ") != -1 + isSaveButton = buttonText.lower().find("overwrite") != -1 + isNewFileButton = buttonText.lower().find("rename") != -1 + isTryAgainButton = buttonText.lower().find("try again") != -1 if isCancelButton: button = cancelPushButton(buttonText, qparent) @@ -841,9 +980,10 @@ def getPushButton(buttonText, qparent=None): button = reloadPushButton(buttonText, qparent) else: button = QPushButton(buttonText, qparent) - + return button, isCancelButton + def CustomGradientMenuAction(gradient: QLinearGradient, name: str, parent): pixmap = QPixmap(100, 15) painter = QPainter(pixmap) @@ -869,94 +1009,97 @@ def CustomGradientMenuAction(gradient: QLinearGradient, name: str, parent): delButton.action = action return action + class ContourItem(pg.PlotCurveItem): def __init__(self, *args, **kargs): super().__init__(*args, **kargs) self._prevData = None - + def clear(self): try: self.setData([], []) except AttributeError as e: pass - + def tempClear(self): try: self._prevData = [d.copy() for d in self.getData()] self.clear() except Exception as e: pass - + def restore(self): if self._prevData is not None: if self._prevData[0] is not None: self.setData(*self._prevData) + class BaseScatterPlotItem(pg.ScatterPlotItem): def __init__(self, *args, **kargs): super().__init__(*args, **kargs) - + def tempClear(self): try: self._prevData = [d.copy() for d in self.getData()] self.setData([], []) except Exception as e: pass - + def restore(self): if self._prevData is not None: if self._prevData[0] is not None: self.setData(*self._prevData) + class VerticalSpacerEmptyWidget(QWidget): def __init__(self, parent=None, height=5) -> None: super().__init__(parent) - self.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum - ) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) self.setFixedHeight(height) + class CustomAnnotationScatterPlotItem(BaseScatterPlotItem): def __init__(self, *args, **kargs): super().__init__(*args, **kargs) -class ElidingLineEdit(QLineEdit): + +class ElidingLineEdit(QLineEdit): def __init__(self, parent=None, minWidth=None): super().__init__(parent) - self._text = '' + self._text = "" self._minWidth = minWidth if minWidth is not None: self.setMinimumWidth(minWidth) - + self.textEdited.connect(self.setText) self.installEventFilter(self) self._elide = True - + def setText(self, text: str, width=None, elide=True) -> None: if width is None: width = self._minWidth - + if width is None: try: - textToPrevRatio = len(text)/len(self.text()) - width = round(self.width()*textToPrevRatio) + textToPrevRatio = len(text) / len(self.text()) + width = round(self.width() * textToPrevRatio) except ZeroDivisionError: width = self.width() if width > self.width(): width = self.width() - + self._text = text if not elide or not self._elide: super().setText(text) return - + fm = QFontMetrics(self.font()) elidedText = fm.elidedText(text, Qt.ElideLeft, width) - + super().setText(elidedText) self.setToolTip(text) - + def text(self): return self._text @@ -964,14 +1107,14 @@ def resizeEvent(self, event): newWidth = event.size().width() self.setText(self._text, width=newWidth) event.accept() - - def eventFilter(self, a0: 'QObject', a1: 'QEvent') -> bool: + + def eventFilter(self, a0: "QObject", a1: "QEvent") -> bool: isFocusIn = a1.type() == QEvent.Type.FocusIn if isFocusIn and (self.isReadOnly() or not self.isEnabled()): self.clearFocus() return True return super().eventFilter(a0, a1) - + def focusInEvent(self, event): super().focusInEvent(event) self._elide = False @@ -983,15 +1126,17 @@ def focusOutEvent(self, event): super().focusOutEvent(event) self.setText(self._text) + class ValidLineEdit(QLineEdit): def __init__(self, parent=None): super().__init__(parent) - + def setInvalidStyleSheet(self): self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) - + def setValidStyleSheet(self): - self.setStyleSheet('') + self.setStyleSheet("") + class KeepIDsLineEdit(ValidLineEdit): sigIDsChanged = Signal(list) @@ -1001,7 +1146,7 @@ class KeepIDsLineEdit(ValidLineEdit): def __init__(self, instructionsLabel, parent=None): super().__init__(parent) - self.validPattern = '^[0-9-, ]+$' + self.validPattern = "^[0-9-, ]+$" regExpr = QRegularExpression(self.validPattern) self.setValidator(QRegularExpressionValidator(regExpr)) @@ -1010,51 +1155,52 @@ def __init__(self, instructionsLabel, parent=None): self.instructionsText = instructionsLabel.text() self._label = instructionsLabel - + def keyPressEvent(self, event) -> None: super().keyPressEvent(event) - if event.text() == ',': + if event.text() == ",": self.sigSort.emit() elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: self.sigEnterPressed.emit() - + def onTextChanged(self, text): IDs = [] - rangesMatch = re.findall(r'(\d+-\d+)', text) + rangesMatch = re.findall(r"(\d+-\d+)", text) if rangesMatch: for rangeText in rangesMatch: - start, stop = rangeText.split('-') + start, stop = rangeText.split("-") start, stop = int(start), int(stop) - IDs.extend(range(start, stop+1)) - text = re.sub(r'(\d+)-(\d+)', '', text) - IDsMatch = re.findall(r'(\d+)', text) + IDs.extend(range(start, stop + 1)) + text = re.sub(r"(\d+)-(\d+)", "", text) + IDsMatch = re.findall(r"(\d+)", text) if IDsMatch: for ID in IDsMatch: IDs.append(int(ID)) self.IDs = sorted(list(set(IDs))) self.sigIDsChanged.emit(self.IDs) - + def onEditingFinished(self): self.sigSort.emit() - + def warnNotExistingID(self): self.setInvalidStyleSheet() self._label.setText( - ' Some of the IDs are not existing --> they will be IGNORED' + " Some of the IDs are not existing --> they will be IGNORED" ) - self._label.setStyleSheet('color: red') + self._label.setStyleSheet("color: red") def setInstructionsText(self): self.setValidStyleSheet() self._label.setText(self.instructionsText) - self._label.setStyleSheet('') + self._label.setStyleSheet("") + class ScrollBar(QScrollBar): def __init__(self, *args): super().__init__(*args) self.installEventFilter(self) self.setContextMenuPolicy(Qt.NoContextMenu) - + def eventFilter(self, object, event) -> bool: if event.type() == QEvent.Type.Wheel: return True @@ -1066,11 +1212,13 @@ def eventFilter(self, object, event) -> bool: return event.button() == Qt.MouseButton.RightButton return False + class _ReorderableListModel(QAbstractListModel): - ''' + """ ReorderableListModel is a list model which implements reordering of its items via drag-n-drop - ''' + """ + dragDropFinished = Signal() def __init__(self, items, parent=None): @@ -1080,10 +1228,10 @@ def __init__(self, items, parent=None): self.pendingRemoveRowsAfterDrop = False def rowForItem(self, text): - ''' + """ rowForItem method returns the row corresponding to the passed in item or None if no such item exists in the model - ''' + """ try: row = self.nodes.index(text) except ValueError: @@ -1122,8 +1270,12 @@ def supportedDropActions(self): def flags(self, index): if not index.isValid(): return Qt.ItemIsEnabled - return Qt.ItemIsEnabled | Qt.ItemIsSelectable | \ - Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled + return ( + Qt.ItemIsEnabled + | Qt.ItemIsSelectable + | Qt.ItemIsDragEnabled + | Qt.ItemIsDropEnabled + ) def insertRows(self, row, count, index): if index.isValid(): @@ -1133,7 +1285,7 @@ def insertRows(self, row, count, index): # inserting 'count' empty rows starting at 'row' self.beginInsertRows(QModelIndex(), row, row + count - 1) for i in range(0, count): - self.nodes.insert(row + i, '') + self.nodes.insert(row + i, "") self.endInsertRows() return True @@ -1149,10 +1301,10 @@ def removeRows(self, row, count, index): self.endRemoveRows() if self.pendingRemoveRowsAfterDrop: - ''' + """ If we got here, it means this call to removeRows is the automatic 'cleanup' action after drag-n-drop performed by Qt - ''' + """ self.pendingRemoveRowsAfterDrop = False self.dragDropFinished.emit() @@ -1168,7 +1320,7 @@ def setData(self, index, value, role): return True def mimeTypes(self): - return ['application/vnd.treeviewdragdrop.list'] + return ["application/vnd.treeviewdragdrop.list"] def mimeData(self, indexes): mimedata = QMimeData() @@ -1177,14 +1329,14 @@ def mimeData(self, indexes): for index in indexes: if index.isValid(): text = self.data(index, 0) - stream << QByteArray(text.encode('utf-8')) - mimedata.setData('application/vnd.treeviewdragdrop.list', encoded_data) + stream << QByteArray(text.encode("utf-8")) + mimedata.setData("application/vnd.treeviewdragdrop.list", encoded_data) return mimedata def dropMimeData(self, data, action, row, column, parent): if action == Qt.IgnoreAction: return True - if not data.hasFormat('application/vnd.treeviewdragdrop.list'): + if not data.hasFormat("application/vnd.treeviewdragdrop.list"): return False if column > 0: return False @@ -1199,7 +1351,7 @@ def dropMimeData(self, data, action, row, column, parent): else: return False - encoded_data = data.data('application/vnd.treeviewdragdrop.list') + encoded_data = data.data("application/vnd.treeviewdragdrop.list") stream = QDataStream(encoded_data, QIODevice.ReadOnly) new_items = [] @@ -1207,13 +1359,13 @@ def dropMimeData(self, data, action, row, column, parent): while not stream.atEnd(): text = QByteArray() stream >> text - text = bytes(text).decode('utf-8') + text = bytes(text).decode("utf-8") index = self.nodes.index(text) new_items.append((text, index)) rows += 1 self.lastDroppedItems = [] - for (text, index) in new_items: + for text, index in new_items: target_row = row if index < row: target_row += 1 @@ -1226,6 +1378,7 @@ def dropMimeData(self, data, action, row, column, parent): self.pendingRemoveRowsAfterDrop = True return True + class _SelectionModel(QItemSelectionModel): def __init__(self, parent=None, isSingleSelection=False): QItemSelectionModel.__init__(self, parent) @@ -1243,8 +1396,8 @@ def onModelItemsReordered(self): self.clearSelection() flags = ( - QItemSelectionModel.SelectionFlag.ClearAndSelect - | QItemSelectionModel.SelectionFlag.Rows + QItemSelectionModel.SelectionFlag.ClearAndSelect + | QItemSelectionModel.SelectionFlag.Rows | QItemSelectionModel.SelectionFlag.Current ) self.select(new_selection, flags) @@ -1252,10 +1405,9 @@ def onModelItemsReordered(self): if not self.isSingleSelection: self.reset() + class ReorderableListView(QListView): - def __init__( - self, items=None, parent=None, isSingleSelection=False - ) -> None: + def __init__(self, items=None, parent=None, isSingleSelection=False) -> None: super().__init__(parent) if items is None: items = [] @@ -1263,14 +1415,12 @@ def __init__( self.isSingleSelection = isSingleSelection self._model = _ReorderableListModel(items) self._selectionModel = _SelectionModel(self._model) - self._model.dragDropFinished.connect( - self._selectionModel.onModelItemsReordered - ) + self._model.dragDropFinished.connect(self._selectionModel.onModelItemsReordered) self.setModel(self._model) self.setSelectionModel(self._selectionModel) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setDragDropOverwriteMode(False) - styleSheet = (f""" + styleSheet = f""" QListView {{ selection-background-color: rgba(200, 200, 200, 0.30); selection-color: black; @@ -1282,35 +1432,43 @@ def __init__( QListView::item:hover {{ background-color: rgba(200, 200, 200, 0.30); }} - """) + """ self.setStyleSheet(styleSheet) - + def setItems(self, items): self._model.nodes = items - + def items(self): return self._model.nodes - + # def mouseReleaseEvent(self, e: QMouseEvent) -> None: # super().mouseReleaseEvent(e) # self._selectionModel.reset() + class QDialogListbox(QDialog): sigSelectionConfirmed = Signal(list) def __init__( - self, title, text, items, cancelText='Cancel', - multiSelection=True, parent=None, - additionalButtons=(), includeSelectionHelp=False, - allowSingleSelection=True, preSelectedItems=None, - allowEmptySelection=True - ): + self, + title, + text, + items, + cancelText="Cancel", + multiSelection=True, + parent=None, + additionalButtons=(), + includeSelectionHelp=False, + allowSingleSelection=True, + preSelectedItems=None, + allowEmptySelection=True, + ): self.cancel = True items = list(items) - + super().__init__(parent) self.setWindowTitle(title) - + if preSelectedItems is None: if items: preSelectedItems = (items[0],) @@ -1345,28 +1503,26 @@ def __init__( listBox = listWidget() listBox.setFont(_font) - listBox.addItems(items) + listBox.addItems(items) if multiSelection: - listBox.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection) + listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) else: - listBox.setSelectionMode( - QAbstractItemView.SelectionMode.SingleSelection) + listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) listBox.setCurrentRow(0) for i in range(listBox.count()): item = listBox.item(i) item.setSelected(item.text() in preSelectedItems) - + self.listBox = listBox if not multiSelection: listBox.itemDoubleClicked.connect(self.ok_cb) topLayout.addWidget(listBox) - if cancelText.lower().find('cancel') != -1: + if cancelText.lower().find("cancel") != -1: cancelButton = cancelPushButton(cancelText) else: cancelButton = QPushButton(cancelText) - okButton = okPushButton(' Ok ') + okButton = okPushButton(" Ok ") bottomLayout.addStretch(1) bottomLayout.addWidget(cancelButton) @@ -1403,49 +1559,50 @@ def __init__( listBox.item(i).isSelected() for i in range(listBox.count()) ] self.setFont(font) - + def keyPressEvent(self, event) -> None: mod = event.modifiers() if mod == Qt.ShiftModifier or mod == Qt.ControlModifier: - self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.listBox.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) elif event.key() == Qt.Key_Escape: self.listBox.clearSelection() event.ignore() return super().keyPressEvent(event) - + def onItemSelectionChanged(self): if not self.listBox.selectedItems(): - self.areItemsSelected = [ - False for i in range(self.listBox.count()) - ] - + self.areItemsSelected = [False for i in range(self.listBox.count())] + def onItemClicked(self, item): mod = QGuiApplication.keyboardModifiers() if mod == Qt.ShiftModifier or mod == Qt.ControlModifier: - self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.listBox.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) return - + self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) itemIdx = self.listBox.row(item) wasSelected = self.areItemsSelected[itemIdx] if wasSelected: item.setSelected(False) - + self.areItemsSelected = [ - self.listBox.item(i).isSelected() - for i in range(self.listBox.count()) + self.listBox.item(i).isSelected() for i in range(self.listBox.count()) ] # self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # else: # selectedItems.append(item) - + # self.listBox.clearSelection() # for i in range(self.listBox.count()): # item = self.listBox.item(i).setSelected(True) - + # print(self.listBox.selectedItems()) - + def setSelectedItems(self, itemsTexts): for i in range(self.listBox.count()): item = self.listBox.item(i) @@ -1456,12 +1613,12 @@ def setSelectedItems(self, itemsTexts): def warnSelectionEmpty(self): msg = myMessageBox(wrapText=False, showCentered=False) txt = html_utils.paragraph( - 'You need to select at least one item!.

    ' - 'Use Ctrl+Click to select multiple items
    ' - 'or Shift+Click to select a range of items' + "You need to select at least one item!.

    " + "Use Ctrl+Click to select multiple items
    " + "or Shift+Click to select a range of items" ) - msg.warning(self, 'Selection cannot be empty!', txt) - + msg.warning(self, "Selection cannot be empty!", txt) + def ok_cb(self, checked=False): self.clickedButton = self.sender() self.cancel = False @@ -1470,17 +1627,17 @@ def ok_cb(self, checked=False): if not self.allowSingleSelection and len(self.selectedItemsText) < 2: msg = myMessageBox(wrapText=False, showCentered=False) txt = html_utils.paragraph( - 'You need to select two or more items.

    ' - 'Use Ctrl+Click to select multiple items
    , or
    ' - 'Shift+Click to select a range of items' + "You need to select two or more items.

    " + "Use Ctrl+Click to select multiple items
    , or
    " + "Shift+Click to select a range of items" ) - msg.warning(self, 'Select two or more items', txt) + msg.warning(self, "Select two or more items", txt) return - + if not self.allowEmptySelection and not self.selectedItemsText: self.warnSelectionEmpty() return - + self.sigSelectionConfirmed.emit(self.selectedItemsText) self.close() @@ -1505,7 +1662,7 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() @@ -1517,26 +1674,25 @@ def __init__(self, parent=None, centered=True) -> None: self.lineEdit().setReadOnly(True) infoTxt = html_utils.paragraph( - 'Select Positions to save

    ' - 'Ctrl+Click to select multiple items
    ' - 'Shift+Click to select a range of items
    ', - center=True + "Select Positions to save

    " + "Ctrl+Click to select multiple items
    " + "Shift+Click to select a range of items
    ", + center=True, ) self.listW = QDialogListbox( - 'Select Positions to save', infoTxt, - [], multiSelection=True, parent=self + "Select Positions to save", infoTxt, [], multiSelection=True, parent=self ) self.listW.listBox.itemClicked.connect(self.listItemClicked) self.listW.sigSelectionConfirmed.connect(self.updateCombobox) - self.centered = centered + self.centered = centered def listItemClicked(self, item): - if item.text().find('All') == -1: + if item.text().find("All") == -1: return - + for i in range(self.listW.listBox.count()): _item = self.listW.listBox.item(i) _item.setSelected(True) @@ -1544,11 +1700,11 @@ def listItemClicked(self, item): def clear(self) -> None: self.listW.listBox.clear() return super().clear() - + def setItems(self, items): self.clear() self.addItems(items) - + def addItems(self, items): super().addItems(items) self.listW.listBox.addItems(items) @@ -1556,11 +1712,9 @@ def addItems(self, items): self.listItemClicked(self.listW.listBox.currentItem()) if self.centered: self.centerItems() - + def updateCombobox(self, selectedItemsText): - isAllItem = [ - i for i, t in enumerate(selectedItemsText) if t.find('All') != -1 - ] + isAllItem = [i for i, t in enumerate(selectedItemsText) if t.find("All") != -1] if len(selectedItemsText) == 1: self.setCurrentText(selectedItemsText[0]) elif isAllItem: @@ -1568,30 +1722,33 @@ def updateCombobox(self, selectedItemsText): self.setCurrentText(selectedItemsText[idx]) else: super().clear() - super().addItems(['Custom selection']) - + super().addItems(["Custom selection"]) + def centerItems(self, idx=None): self.lineEdit().setAlignment(Qt.AlignCenter) - + def selectedItems(self): return self.listW.listBox.selectedItems() - + def selectedItemsText(self): return [item.text() for item in self.selectedItems()] - + def showPopup(self) -> None: self.listW.show() + class filePathControl(QFrame): sigValueChanged = Signal(str) - + def __init__( - self, parent=None, browseFolder=False, - fileManagerTitle='Select file', - validExtensions=None, - startFolder='', - elide=False - ): + self, + parent=None, + browseFolder=False, + fileManagerTitle="Select file", + validExtensions=None, + startFolder="", + elide=False, + ): super().__init__(parent) layout = QHBoxLayout() @@ -1599,10 +1756,12 @@ def __init__( self.le = ElidingLineEdit() else: self.le = QLineEdit() - + self.browseButton = browseFileButton( - openFolder=browseFolder, title=fileManagerTitle, - ext=validExtensions, start_dir=startFolder + openFolder=browseFolder, + title=fileManagerTitle, + ext=validExtensions, + start_dir=startFolder, ) layout.addWidget(self.le) @@ -1611,7 +1770,7 @@ def __init__( self.le.editingFinished.connect(self.setTextTooltip) self.browseButton.sigPathSelected.connect(self.setText) - + self.setFrameStyle(QFrame.Shape.StyledPanel) def setText(self, text): @@ -1622,69 +1781,72 @@ def setText(self, text): def setTextTooltip(self): self.le.setToolTip(self.le.text()) self.sigValueChanged.emit(self.le.text()) - + def path(self): return self.le.text() - + def showEvent(self, a0: QShowEvent) -> None: self.le.setFixedHeight(self.browseButton.height()) return super().showEvent(a0) + class FolderPathControl(filePathControl): def __init__(self, **kwargs): - super().__init__( - browseFolder=True, - fileManagerTitle='Select folder', - **kwargs - ) + super().__init__(browseFolder=True, fileManagerTitle="Select folder", **kwargs) + class CsvFilePathControl(filePathControl): def __init__(self, **kwargs): super().__init__( browseFolder=False, - fileManagerTitle='Select a CSV file', - validExtensions={'CSV files': ['.csv', '.CSV']}, - **kwargs + fileManagerTitle="Select a CSV file", + validExtensions={"CSV files": [".csv", ".CSV"]}, + **kwargs, ) + class QHWidgetSpacer(QWidget): def __init__(self, width=10, parent=None) -> None: super().__init__(parent) self.setFixedWidth(width) + class QVWidgetSpacer(QWidget): def __init__(self, height=10, parent=None) -> None: super().__init__(parent) self.setFixedHeight(height) + class QHLine(QFrame): - def __init__(self, shadow='Sunken', parent=None, color=None): + def __init__(self, shadow="Sunken", parent=None, color=None): super().__init__(parent) self.setFrameShape(QFrame.Shape.HLine) self.setFrameShadow(getattr(QFrame, shadow)) if color is not None: self.setColor(color) - + def setColor(self, color): qcolor = pg.mkColor(color) pal = self.palette() pal.setColor(QPalette.ColorRole.WindowText, qcolor) self.setPalette(pal) + class QVLine(QFrame): - def __init__(self, shadow='Plain', parent=None, color=None): + def __init__(self, shadow="Plain", parent=None, color=None): super().__init__(parent) self.setFrameShape(QFrame.Shape.VLine) self.setFrameShadow(getattr(QFrame.Shadow, shadow)) if color is not None: self.setColor(color) - + def setColor(self, color): qcolor = pg.mkColor(color) pal = self.palette() pal.setColor(QPalette.ColorRole.WindowText, qcolor) self.setPalette(pal) + class VerticalResizeHline(QFrame): dragged = Signal(object) clicked = Signal(object) @@ -1699,21 +1861,21 @@ def __init__(self): self.isMousePressed = False self._height = 4 self.setMinimumHeight(self._height) - + def mousePressEvent(self, event) -> None: self.isMousePressed = True self.clicked.emit(event) return super().mousePressEvent(event) - + def mouseMoveEvent(self, event) -> None: self.dragged.emit(event) return super().mouseMoveEvent(event) - + def mouseReleaseEvent(self, event) -> None: self.isMousePressed = False self.released.emit(event) return super().mouseReleaseEvent(event) - + def eventFilter(self, object, event): if event.type() == QEvent.Type.Enter: self.setLineWidth(0) @@ -1721,18 +1883,19 @@ def eventFilter(self, object, event): pal = self.palette() pal.setColor(QPalette.ColorRole.WindowText, QColor(BASE_COLOR)) self.setPalette(pal) - # self.setStyleSheet('background-color: #4d4d4d') + # self.setStyleSheet('background-color: #4d4d4d') elif event.type() == QEvent.Type.Leave: self.setMidLineWidth(0) self.setLineWidth(1) return False + class GroupBox(QGroupBox): def __init__(self, *args, keyPressCallback=None): super().__init__(*args) self.keyPressCallback = None self.setFocusPolicy(Qt.NoFocus) - + def keyPressEvent(self, event) -> None: event.ignore() if self.keyPressCallback is None: @@ -1740,12 +1903,13 @@ def keyPressEvent(self, event) -> None: self.keyPressCallback() + class CheckBox(QCheckBox): def __init__(self, *args, keyPressCallback=None): super().__init__(*args) self.keyPressCallback = None self.setFocusPolicy(Qt.NoFocus) - + def keyPressEvent(self, event) -> None: event.ignore() if self.keyPressCallback is None: @@ -1753,13 +1917,13 @@ def keyPressEvent(self, event) -> None: self.keyPressCallback() + class ScrollArea(QScrollArea): sigLeaveEvent = Signal() def __init__( - self, parent=None, resizeVerticalOnShow=False, - dropArrowKeyEvents=False - ) -> None: + self, parent=None, resizeVerticalOnShow=False, dropArrowKeyEvents=False + ) -> None: super().__init__(parent) self.setWidgetResizable(True) self.setFrameStyle(QFrame.Shape.NoFrame) @@ -1768,7 +1932,7 @@ def __init__( self.resizeVerticalOnShow = resizeVerticalOnShow self.isOnlyVertical = False self.dropArrowKeyEvents = dropArrowKeyEvents - + def setVerticalLayout(self, layout, widget=None): if widget is None: self.containerWidget = QWidget() @@ -1782,31 +1946,31 @@ def setVerticalLayout(self, layout, widget=None): self.containerWidget.installEventFilter(self) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.isOnlyVertical = True - + def setWidget(self, widget): self.containerWidget = widget super().setWidget(widget) - + def _resizeHorizontal(self): self.setMinimumWidth( self.containerWidget.minimumSizeHint().width() + self.verticalScrollBar().width() ) - + def minimumWidthNoScrollbar(self) -> int: - width = ( + width = ( self.containerWidget.minimumSizeHint().width() + self.verticalScrollBar().width() ) return width - + def minimumHeightNoScrollbar(self) -> int: height = ( self.containerWidget.minimumSizeHint().height() + self.horizontalScrollBar().height() ) return height - + def _resizeVertical(self): height = ( self.containerWidget.minimumSizeHint().height() @@ -1824,7 +1988,7 @@ def eventFilter(self, object, event: QEvent): if object != self.containerWidget: return False - + isResize = event.type() == QEvent.Type.Resize isShow = event.type() == QEvent.Type.Show if isResize and self.isOnlyVertical: @@ -1833,6 +1997,7 @@ def eventFilter(self, object, event: QEvent): self._resizeVertical() return False + class QClickableLabel(QLabel): clicked = Signal(object) @@ -1840,7 +2005,7 @@ def __init__(self, parent=None): self._parent = parent super().__init__(parent) self._checkableItem = None - + def setCheckableItem(self, widget): self._checkableItem = widget @@ -1853,6 +2018,7 @@ def mousePressEvent(self, event): def setChecked(self, checked): self._checkableItem.setChecked(checked) + class QCenteredComboBox(QComboBox): def __init__(self, parent=None) -> None: super().__init__(parent) @@ -1865,11 +2031,11 @@ def __init__(self, parent=None) -> None: self.currentIndexChanged.connect(self.centerItems) self._isPopupVisibile = False - + def centerItems(self, idx): for i in range(self.count()): self.setItemData(i, Qt.AlignCenter, Qt.TextAlignmentRole) - + def eventFilter(self, lineEdit, event): # Reimplement show popup on click if event.type() == QEvent.Type.MouseButtonPress and self.isEnabled(): @@ -1882,26 +2048,28 @@ def eventFilter(self, lineEdit, event): return True return False + class AlphaNumericComboBox(QCenteredComboBox): def __init__(self, parent=None) -> None: super().__init__(parent=parent) - + def addItems(self, items): self._dtype = type(items[0]) super().addItems([str(item) for item in items]) - + def setCurrentValue(self, value): super().setCurrentText(str(value)) - + def currentValue(self): return self._dtype(super().currentText()) + class statusBarPermanentLabel(QWidget): def __init__(self, parent=None): super().__init__(parent) - self.rightLabel = QLabel('') - self.leftLabel = QLabel('') + self.rightLabel = QLabel("") + self.leftLabel = QLabel("") layout = QHBoxLayout() layout.addWidget(self.leftLabel) @@ -1910,118 +2078,115 @@ def __init__(self, parent=None): self.setLayout(layout) + class listWidget(QListWidget): def __init__( - self, - *args, - isMultipleSelection=False, - minimizeHeight=False, - **kwargs - ): + self, *args, isMultipleSelection=False, minimizeHeight=False, **kwargs + ): super().__init__(*args, **kwargs) self.itemHeight = None self.setStyleSheet(LISTWIDGET_STYLESHEET) self.setFont(font) if isMultipleSelection: - self.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.minimizeHeight = minimizeHeight - + def setSelectedAll(self, selected): for i in range(self.count()): self.item(i).setSelected(selected) - + def setSelectedItems(self, itemsText): for i in range(self.count()): item = self.item(i) item.setSelected(item.text() in itemsText) - + def addItems(self, labels) -> None: - super().addItems(labels) + super().addItems(labels) if self.itemHeight is not None: self.setItemHeight() - + if self.minimizeHeight: itemHeight = self.sizeHintForRow(0) - self.setMaximumHeight(itemHeight * self.count() + itemHeight*2) - + self.setMaximumHeight(itemHeight * self.count() + itemHeight * 2) + def addItem(self, text): super().addItem(text) if self.itemHeight is None: return self.setItemHeight() - + def setItemHeight(self, height=40): self.itemHeight = height for i in range(self.count()): item = self.item(i) item.setSizeHint(QSize(0, height)) - + def selectedItemsText(self): return [item.text() for item in self.selectedItems()] + class OrderableListWidget(QWidget): sigEnterEvent = Signal(object) - + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self._labels = [] - + def setParentItem(self, item): self._item = item - + def setLabelsColor(self, selected): if selected: - stylesheet = 'color : black' + stylesheet = "color : black" else: - stylesheet = '' - + stylesheet = "" + for label in self._labels: label.setStyleSheet(stylesheet) - + def enterEvent(self, event): super().enterEvent(event) self.setLabelsColor(True) self.sigEnterEvent.emit(self._item) - + # def leaveEvent(self, event): # super().leaveEvent(event) # self.setLabelsColor(self._item.isSelected()) # printl('leave', self._item.isSelected()) - + def addLabel(self, label): self._labels.append(label) + class OrderableList(listWidget): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.setMouseTracking(True) self.itemEntered.connect(self.onItemEntered) - + def onItemEntered(self, enteredItem): enteredRow = self.row(enteredItem) for i in range(self.count()): item = self.item(i) item._container.setLabelsColor(i == enteredRow or item.isSelected()) - + def leaveEvent(self, event): super().leaveEvent(event) for i in range(self.count()): item = self.item(i) item._container.setLabelsColor(item.isSelected()) - + def addItems(self, items): self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) nr_items = len(items) - nn = [str(n) for n in range(1, nr_items+1)] + nn = [str(n) for n in range(1, nr_items + 1)] for i, item in enumerate(items): itemW = QListWidgetItem() itemContainer = OrderableListWidget() itemContainer.setParentItem(itemW) itemText = QLabel(item) - tableNrLabel = QLabel('| Table nr.') + tableNrLabel = QLabel("| Table nr.") itemContainer.addLabel(tableNrLabel) itemContainer.addLabel(itemText) itemLayout = QHBoxLayout() @@ -2043,34 +2208,34 @@ def addItems(self, items): itemNumberWidget._currentNr = 1 itemNumberWidget.row = i itemContainer.sigEnterEvent.connect(self.onItemEntered) - + self.itemSelectionChanged.connect(self.onItemSelectionChanged) - + def keyPressEvent(self, event) -> None: if event.key() == Qt.Key_Escape: self.clearSelection() event.ignore() return super().keyPressEvent(event) - + def updateNr(self): for i in range(self.count()): item = self.item(i) item._currentNr = int(item._nrWidget.currentText()) - + def onItemSelectionChanged(self): for i in range(self.count()): item = self.item(i) item._container.setLabelsColor(item.isSelected()) item._nrWidget.setDisabled(not item.isSelected()) - if item._nrWidget.currentText() != '1': - item._nrWidget.setCurrentText('1') + if item._nrWidget.currentText() != "1": + item._nrWidget.setCurrentText("1") item._currentNr = 1 - + for i, item in enumerate(self.selectedItems()): - item._nrWidget.setCurrentText(f'{i+1}') - item._currentNr = i+1 - + item._nrWidget.setCurrentText(f"{i + 1}") + item._currentNr = i + 1 + def onTextActivated(self, text): changedNr = self.sender()._currentNr for item in self.selectedItems(): @@ -2078,7 +2243,7 @@ def onTextActivated(self, text): if self.sender().row == row: changedNr = item._currentNr continue - + for item in self.selectedItems(): row = self.row(item) if self.sender().row == row: @@ -2087,22 +2252,22 @@ def onTextActivated(self, text): if nr == int(text): item._nrWidget.setCurrentText(str(changedNr)) break - + self.updateNr() - + class TreeWidget(QTreeWidget): def __init__(self, *args, multiSelection=False): - super().__init__(*args) + super().__init__(*args) self.setStyleSheet(TREEWIDGET_STYLESHEET) self.setFont(font) if multiSelection: self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.itemClicked.connect(self.selectAllChildren) - + self.isCtrlDown = False self.isShiftDown = False - + def keyPressEvent(self, ev): if ev.key() == Qt.Key_Escape: self.clearSelection() @@ -2116,11 +2281,11 @@ def keyReleaseEvent(self, ev): self.isCtrlDown = False elif ev.key() == Qt.Key_Shift: self.isShiftDown = False - + def onFocusChanged(self): self.isCtrlDown = False self.isShiftDown = False - + def selectAllChildren(self, item_or_label): label = None if isinstance(item_or_label, QLabel): @@ -2152,23 +2317,25 @@ def selectAllChildren(self, item_or_label): for i in range(item.childCount()): item.child(i).setSelected(True) + class CancelOkButtonsLayout(QHBoxLayout): def __init__(self, *args, additionalButtons=None): super().__init__(*args) - self.cancelButton = cancelPushButton('Cancel') - self.okButton = okPushButton(' Ok ') + self.cancelButton = cancelPushButton("Cancel") + self.okButton = okPushButton(" Ok ") self.addStretch(1) self.addWidget(self.cancelButton) self.addSpacing(20) - + if additionalButtons is not None: for button in additionalButtons: self.addWidget(button) - + self.addWidget(self.okButton) + class TreeWidgetItem(QTreeWidgetItem): def __init__(self, *args, columnColors=None): super().__init__(*args) @@ -2178,17 +2345,19 @@ def __init__(self, *args, columnColors=None): if color is None: continue self.setBackground(c, QBrush(color)) - + + class FilterObject(QObject): sigFilteredEvent = Signal(object, object) def __init__(self) -> None: super().__init__() - + def eventFilter(self, object, event): self.sigFilteredEvent.emit(object, event) return super().eventFilter(object, event) + class readOnlyQList(QTextEdit): def __init__(self, parent=None): super().__init__(parent) @@ -2198,9 +2367,10 @@ def __init__(self, parent=None): def addItems(self, items): self.items.extend(items) items = [str(item) for item in self.items] - columnList = html_utils.paragraph('
    '.join(items)) + columnList = html_utils.paragraph("
    ".join(items)) self.setText(columnList) + class pgScatterSymbolsCombobox(QComboBox): def __init__(self, parent=None): super().__init__(parent) @@ -2222,7 +2392,7 @@ def __init__(self, parent=None): "'arrow_right'", "'arrow_down'", "'arrow_left'", - "'crosshair'" + "'crosshair'", ] self.addItems(symbols) @@ -2230,12 +2400,12 @@ def __init__(self, parent=None): class alphaNumericLineEdit(QLineEdit): sigInvalidCharacterPressed = Signal(str) sigInvalidCharactersEntered = Signal(object) - - def __init__(self, parent=None, additionalChars='', onlyWarn=False): + + def __init__(self, parent=None, additionalChars="", onlyWarn=False): super().__init__(parent) - self.validPattern = fr'^[a-zA-Z0-9{additionalChars}_\-]+$' - self.invalidPattern = fr'[^a-zA-Z0-9{additionalChars}_\-]' - + self.validPattern = rf"^[a-zA-Z0-9{additionalChars}_\-]+$" + self.invalidPattern = rf"[^a-zA-Z0-9{additionalChars}_\-]" + if not onlyWarn: regExp = QRegularExpression(self.validPattern) self.setValidator(QRegularExpressionValidator(regExp)) @@ -2246,12 +2416,12 @@ def emitInvalidCharactersEntered(self, text): invalidCharacters = self.invalidCharacters() if not invalidCharacters: return - + self.sigInvalidCharactersEntered.emit(set(invalidCharacters)) - + def invalidCharacters(self): - return re.findall(fr'{self.invalidPattern}', self.text()) - + return re.findall(rf"{self.invalidPattern}", self.text()) + def keyPressEvent(self, event: QKeyEvent): if not event.text(): return super().keyPressEvent(event) @@ -2265,55 +2435,58 @@ def keyPressEvent(self, event: QKeyEvent): if not event.text().isprintable(): return super().keyPressEvent(event) - + super().keyPressEvent(event) - + if event.text() in self.text(): return - + self.sigInvalidCharacterPressed.emit(event.text()) + class NumericCommaLineEdit(QLineEdit): def __init__(self, parent=None): super().__init__(parent) - self.validPattern = r'^[0-9,\.]+$' + self.validPattern = r"^[0-9,\.]+$" regExp = QRegularExpression(self.validPattern) self.setValidator(QRegularExpressionValidator(regExp)) - + def values(self): try: - vals = [float(c) for c in self.text().split(',')] + vals = [float(c) for c in self.text().split(",")] except Exception as e: vals = [] return vals + class mySpinBox(QSpinBox): sigTabEvent = Signal(object, object) def __init__(self, *args) -> None: super().__init__(*args) - + def event(self, event): - if event.type()==QEvent.Type.KeyPress and event.key() == Qt.Key_Tab: + if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key_Tab: self.sigTabEvent.emit(event, self) return True return super().event(event) + class KeptObjectIDsList(list): def __init__(self, lineEdit, confirmSelectionAction, *args): self.lineEdit = lineEdit - self.lineEdit.setText('') + self.lineEdit.setText("") self.confirmSelectionAction = confirmSelectionAction confirmSelectionAction.setDisabled(True) super().__init__(*args) - + def setText(self): - text = myutils.format_IDs(self) - + text = myutils.format_IDs(self) + self.lineEdit.setText(text) - + def append(self, element, editText=True): super().append(element) if editText: @@ -2328,37 +2501,38 @@ def remove(self, element, editText=True): if not self: self.confirmSelectionAction.setEnabled(False) + class ScatterPlotItem(pg.ScatterPlotItem): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.updateBrushAndPen(**kwargs) - + def updateBrushAndPen(self, **kwargs): - brush = kwargs.get('brush') + brush = kwargs.get("brush") if brush is not None: self._itemBrush = brush - pen = kwargs.get('pen') + pen = kwargs.get("pen") if pen is not None: self._itemPen = pen - + def setData(self, *args, **kwargs): super().setData(*args, **kwargs) - self.updateBrushAndPen(**kwargs) - + self.updateBrushAndPen(**kwargs) + def itemBrush(self): return self._itemBrush - + def itemPen(self): return self._itemPen - + def removePoint(self, index): newData = np.delete(self.data, index) # Update the index of current points for i in range(index, len(newData)): - spotItem = newData[i]['item'] + spotItem = newData[i]["item"] spotItem._index = i - newData[i]['item'] = spotItem - + newData[i]["item"] = spotItem + self.data = newData self.prepareGeometryChange() self.informViewBoundsChanged() @@ -2366,7 +2540,7 @@ def removePoint(self, index): self.invalidate() self.updateSpots(newData) self.sigPlotChanged.emit(self) - + def coordsToNumpy(self, includeData=False, rounded=True, decimals=None): points = self.points() nrows = len(points) @@ -2386,7 +2560,7 @@ def coordsToNumpy(self, includeData=False, rounded=True, decimals=None): data_arr = np.zeros((nrows, ncols)) for j, data_j in enumerate(data): data_arr[p, j] = data_j - + coords_arr[p, 0] = y coords_arr[p, 1] = x if not includeData: @@ -2403,52 +2577,57 @@ def coordsToNumpy(self, includeData=False, rounded=True, decimals=None): out_arr = out_arr.astype(int) return out_arr + class myLabelItem(pg.LabelItem): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._prevText = '' + self._prevText = "" def setText(self, text, **args): self.text = text opts = self.opts for k in args: opts[k] = args[k] - - if 'size' in self.opts: - size = self.opts['size'] - if size == '0pt' or size == '0px': - self.opts['size'] = '1pt' - super().setText('', size='1pt') + + if "size" in self.opts: + size = self.opts["size"] + if size == "0pt" or size == "0px": + self.opts["size"] = "1pt" + super().setText("", size="1pt") return optlist = [] - color = self.opts['color'] + color = self.opts["color"] if color is None: - color = pg.getConfigOption('foreground') + color = pg.getConfigOption("foreground") color = pg.functions.mkColor(color) - optlist.append('color: ' + color.name(QColor.NameFormat.HexArgb)) - if 'size' in opts: - size = opts['size'] + optlist.append("color: " + color.name(QColor.NameFormat.HexArgb)) + if "size" in opts: + size = opts["size"] if not isinstance(size, str): - size = f'{size}px' - optlist.append('font-size: ' + size) - if 'bold' in opts and opts['bold'] in [True, False]: - optlist.append('font-weight: ' + {True:'bold', False:'normal'}[opts['bold']]) - if 'italic' in opts and opts['italic'] in [True, False]: - optlist.append('font-style: ' + {True:'italic', False:'normal'}[opts['italic']]) - full = "%s" % ('; '.join(optlist), text) - #print full + size = f"{size}px" + optlist.append("font-size: " + size) + if "bold" in opts and opts["bold"] in [True, False]: + optlist.append( + "font-weight: " + {True: "bold", False: "normal"}[opts["bold"]] + ) + if "italic" in opts and opts["italic"] in [True, False]: + optlist.append( + "font-style: " + {True: "italic", False: "normal"}[opts["italic"]] + ) + full = "%s" % ("; ".join(optlist), text) + # print full self.item.setHtml(full) self.updateMin() self.resizeEvent(None) self.updateGeometry() - + def tempClearText(self): if self.text: self._prevText = self.text - self.setText('') - + self.setText("") + def restoreText(self): if self._prevText: self.setText(self._prevText) @@ -2456,10 +2635,15 @@ def restoreText(self): class myMessageBox(_base_widgets.QBaseDialog): def __init__( - self, parent=None, showCentered=True, wrapText=True, - scrollableText=False, enlargeWidthFactor=0, - resizeButtons=True, allowClose=True - ): + self, + parent=None, + showCentered=True, + wrapText=True, + scrollableText=False, + enlargeWidthFactor=0, + resizeButtons=True, + allowClose=True, + ): super().__init__(parent) self.wrapText = wrapText @@ -2498,10 +2682,10 @@ def __init__( self._w = None self.textLayout = QVBoxLayout() - + self._layout.setColumnStretch(1, 1) self.setLayout(self._layout) - + self.setFont(font) def mousePressEvent(self, event): @@ -2510,7 +2694,7 @@ def mousePressEvent(self, event): Qt.TextBrowserInteraction | Qt.TextSelectableByKeyboard ) - def setIcon(self, iconName='SP_MessageBoxInformation'): + def setIcon(self, iconName="SP_MessageBoxInformation"): label = QLabel(self) standardIcon = getattr(QStyle, iconName) @@ -2526,25 +2710,25 @@ def addImage(self, image_path): label.setPixmap(pixmap) self._layout.addWidget(label, self.currentRow, 1) self.currentRow += 1 - + def addShowInFileManagerButton(self, path, txt=None): if txt is None: - txt = 'Reveal in Finder...' if is_mac else 'Show in Explorer...' + txt = "Reveal in Finder..." if is_mac else "Show in Explorer..." self.showInFileManagButton = showInFileManagerButton(txt) self.buttonsLayout.addWidget(self.showInFileManagButton) func = partial(myutils.showInExplorer, path) self.showInFileManagButton.clicked.connect(func) - - def addBrowseUrlButton(self, url, button_text=''): + + def addBrowseUrlButton(self, url, button_text=""): self.openUrlButton = OpenUrlButton(url, button_text) self.buttonsLayout.addWidget(self.openUrlButton) def addCancelButton(self, button=None, connect=False): if button is None: - self.cancelButton = cancelPushButton('Cancel') + self.cancelButton = cancelPushButton("Cancel") else: self.cancelButton = button - self.cancelButton.setIcon(QIcon(':cancelButton.svg')) + self.cancelButton.setIcon(QIcon(":cancelButton.svg")) self.buttonsLayout.insertWidget(0, self.cancelButton) self.buttonsLayout.insertSpacing(1, 20) @@ -2553,21 +2737,21 @@ def addCancelButton(self, button=None, connect=False): def splitLatexBlocks(self, text): texts = re.split(r"(.+?)", text) - return texts - + return texts + def splitCopiableBlocks(self, texts: Sequence[str] | str): if isinstance(texts, str): texts = (texts,) - + texts_out = [] for text in texts: texts_out.extend(re.split(r"(.+?)", text)) - return texts_out + return texts_out def addText(self, text): texts = self.splitLatexBlocks(text) texts = self.splitCopiableBlocks(texts) - + labelsWidget = LabelsWidget(texts, wrapText=self.wrapText) self.labelsWidgets.append(labelsWidget) self.labels.extend(labelsWidget.labels) @@ -2579,35 +2763,34 @@ def addText(self, text): textWidget = labelsWidget self.textLayout.addWidget(textWidget) - + if self.textWidget is None: self.textWidget = QWidget() self.textWidget.setLayout(self.textLayout) self._layout.addWidget(self.textWidget, self.currentRow, 1) self.textRow = self.currentRow self.currentRow += 1 - + return labelsWidget - + def addCopiableCommand(self, command): copiableCommandWidget = CopiableCommandWidget(command) screenWidth = self.screen().size().width() - maxWidth = int(0.75*screenWidth) + maxWidth = int(0.75 * screenWidth) sizeHint = copiableCommandWidget.sizeHint() width = sizeHint.width() if width > maxWidth: copiableCommandWidget = addWidgetToScrollArea( - copiableCommandWidget, - resizeMinHeightNoVerticalScrollbar=True + copiableCommandWidget, resizeMinHeightNoVerticalScrollbar=True ) self._layout.addWidget(copiableCommandWidget, self.currentRow, 1) self.currentRow += 1 - + def copyToClipboard(self): cb = QApplication.clipboard() cb.clear(mode=cb.Clipboard) cb.setText(self.sender()._command, mode=cb.Clipboard) - print('Command copied!') + print("Command copied!") def addButton(self, buttonText): if not isinstance(buttonText, str): @@ -2617,7 +2800,7 @@ def addButton(self, buttonText): button.clicked.connect(self.buttonCallBack) self.buttons.append(button) return button - + button, isCancelButton = getPushButton(buttonText, qparent=self) if not isCancelButton: self.buttonsLayout.addWidget(button) @@ -2626,7 +2809,7 @@ def addButton(self, buttonText): self.buttons.append(button) return button - def addDoNotShowAgainCheckbox(self, text='Do not show again'): + def addDoNotShowAgainCheckbox(self, text="Do not show again"): self.doNotShowAgainCheckbox = QCheckBox(text) def addWidget(self, widget): @@ -2644,7 +2827,7 @@ def setWidth(self, w): def show(self, block=False): self.endOfScrollableRow = self.currentRow - + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) # spacer spacer = QSpacerItem(10, 10) @@ -2663,15 +2846,14 @@ def show(self, block=False): self.doNotShowAgainCheckbox, self.currentRow, 1, 1, 2 ) self.currentRow += 1 - + # spacer self._layout.addItem(QSpacerItem(10, 10), self.currentRow, 1) self.currentRow += 1 - + # buttons self._layout.addLayout( - self.buttonsLayout, self.currentRow, 0, 1, 2, - alignment=Qt.AlignRight + self.buttonsLayout, self.currentRow, 0, 1, 2, alignment=Qt.AlignRight ) # Details @@ -2679,24 +2861,22 @@ def show(self, block=False): # spacer self.currentRow += 1 self._layout.addItem(QSpacerItem(20, 20), self.currentRow, 1) - + # detailsTextWidget self.currentRow += 1 - self._layout.addWidget( - self.detailsTextWidget, self.currentRow, 0, 1, 2 - ) + self._layout.addWidget(self.detailsTextWidget, self.currentRow, 0, 1, 2) # spacer self.currentRow += 1 spacer = QSpacerItem(10, 10) self._layout.addItem(spacer, self.currentRow, 1) self._layout.setRowStretch(self.currentRow, 0) - + screenHeight = self.screen().size().height() dialogHeight = self.sizeHint().height() dialogWidth = self.sizeHint().width() screenWidth = self.screen().size().width() - + # Check if scrollbar is needed if dialogHeight > screenHeight and self.textWidget is not None: textScrollArea = ScrollArea() @@ -2708,20 +2888,20 @@ def show(self, block=False): desiredWidth = dialogWidth + desiredDeltaWidth if desiredWidth < screenWidth: self._w = desiredWidth - + self._layout.removeWidget(self.textWidget) self._layout.addWidget(textScrollArea, self.textRow, 1) - + super().show() QTimer.singleShot(5, self._resize) - + self.alreadyShown = True if block: self._block() def setDetailedText(self, text, visible=False, wrap=True): - text = text.replace('\n', '
    ') + text = text.replace("\n", "
    ") self.detailsTextWidget = QTextEdit(text) self.detailsTextWidget.setReadOnly(True) if not wrap: @@ -2735,7 +2915,7 @@ def setDetailedText(self, text, visible=False, wrap=True): def _showDetails(self, checked): if checked: self.origHeight = self.height() - self.resize(self.width(), self.height()+300) + self.resize(self.width(), self.height() + 300) self.detailsTextWidget.show() else: self.detailsTextWidget.hide() @@ -2769,7 +2949,7 @@ def _resize(self): self.resize(350, self.height()) if self.enlargeWidthFactor > 0: - self.resize(int(self.width()*self.enlargeWidthFactor), self.height()) + self.resize(int(self.width() * self.enlargeWidthFactor), self.height()) if self.visibleDetails: self.detailsButton.click() @@ -2781,8 +2961,8 @@ def _resize(self): screenLeft = screen.geometry().x() screenTop = screen.geometry().y() w, h = self.width(), self.height() - left = int(screenLeft + screenWidth/2 - w/2) - top = int(screenTop + screenHeight/2 - h/2) + left = int(screenLeft + screenWidth / 2 - w / 2) + top = int(screenTop + screenHeight / 2 - h / 2) if top < screenTop: top = screenTop if left < screenLeft: @@ -2797,25 +2977,25 @@ def _resize(self): screen = self.screen() screenWidth = screen.size().width() screenHeight = screen.size().height() - + # Check Force wrap Text for labelWidget in self.labelsWidgets: textWidth = labelWidget.width() - if not textWidth > screenWidth-10: + if not textWidth > screenWidth - 10: continue - factor = np.ceil(textWidth/screenWidth) - lineLength = int(labelWidget.nCharsLongestLine/factor) + factor = np.ceil(textWidth / screenWidth) + lineLength = int(labelWidget.nCharsLongestLine / factor) for label in labelWidget.labels: if isinstance(label, CopiableCommandWidget): continue - + text = label.text() chunks = textwrap.wrap(text, lineLength) - text = '
    '.join(chunks) + text = "
    ".join(chunks) label.setText(text) - + QTimer.singleShot(100, self._resizeWrappedText) - + if self.widgets: return @@ -2835,13 +3015,13 @@ def _resizeWrappedText(self): self.resize(screenWidth, self.height()) screenLeft = self.screen().geometry().left() self.move(screenLeft, self.geometry().top()) - + def _resizeHeight(self): try: # Resize until a "Unable to set geometry" warning is captured # by copnfig.warningHandler._resizeWarningHandler or # # height doesn't change anymore - self.resize(self.width(), self.height()-1) + self.resize(self.width(), self.height() - 1) if self.height() == self._h or self.resizeCallsCount > 100: self.timer.stop() return @@ -2853,13 +3033,23 @@ def _resizeHeight(self): self.timer.stop() def _template( - self, parent, title, message, detailsText=None, - buttonsTexts=None, layouts=None, widgets=None, - commands=None, path_to_browse=None, browse_button_text=None, - url_to_open=None, open_url_button_text='Open url', - image_paths=None, wrapDetails=True, - add_do_not_show_again_checkbox=False - ): + self, + parent, + title, + message, + detailsText=None, + buttonsTexts=None, + layouts=None, + widgets=None, + commands=None, + path_to_browse=None, + browse_button_text=None, + url_to_open=None, + open_url_button_text="Open url", + image_paths=None, + wrapDetails=True, + add_do_not_show_again_checkbox=False, + ): if parent is not None: self.setParent(parent) self.setWindowTitle(title) @@ -2869,13 +3059,13 @@ def _template( commands = (commands,) for command in commands: self.addCopiableCommand(command) - + if image_paths is not None: if isinstance(image_paths, str): image_paths = (image_paths,) for image_path in image_paths: self.addImage(image_path) - + if layouts is not None: if myutils.is_iterable(layouts): for layout in layouts: @@ -2893,18 +3083,14 @@ def _template( self.addWidget(widgets) if path_to_browse is not None: - self.addShowInFileManagerButton( - path_to_browse, txt=browse_button_text - ) - + self.addShowInFileManagerButton(path_to_browse, txt=browse_button_text) + if url_to_open is not None: - self.addBrowseUrlButton( - url_to_open, button_text=open_url_button_text - ) - + self.addBrowseUrlButton(url_to_open, button_text=open_url_button_text) + buttons = [] if buttonsTexts is None: - okButton = self.addButton(' Ok ') + okButton = self.addButton(" Ok ") buttons.append(okButton) elif isinstance(buttonsTexts, str): button = self.addButton(buttonsTexts) @@ -2913,38 +3099,38 @@ def _template( for buttonText in buttonsTexts: button = self.addButton(buttonText) buttons.append(button) - + if detailsText is not None: self.setDetailedText(detailsText, visible=True, wrap=wrapDetails) - + if add_do_not_show_again_checkbox: self.addDoNotShowAgainCheckbox() - + return buttons def critical(self, *args, showDialog=True, **kwargs): - self.setIcon(iconName='SP_MessageBoxCritical') + self.setIcon(iconName="SP_MessageBoxCritical") buttons = self._template(*args, **kwargs) if showDialog: self.exec_() return buttons def information(self, *args, showDialog=True, **kwargs): - self.setIcon(iconName='SP_MessageBoxInformation') + self.setIcon(iconName="SP_MessageBoxInformation") buttons = self._template(*args, **kwargs) if showDialog: self.exec_() return buttons def warning(self, *args, showDialog=True, **kwargs): - self.setIcon(iconName='SP_MessageBoxWarning') + self.setIcon(iconName="SP_MessageBoxWarning") buttons = self._template(*args, **kwargs) if showDialog: self.exec_() return buttons def question(self, *args, showDialog=True, **kwargs): - self.setIcon(iconName='SP_MessageBoxQuestion') + self.setIcon(iconName="SP_MessageBoxQuestion") buttons = self._template(*args, **kwargs) if showDialog: self.exec_() @@ -2956,7 +3142,7 @@ def _block(self): def exec_(self): self.show(block=True) - + def clickButtonFromText(self, buttonText): for button in self.buttons: if button.text() == buttonText: @@ -2976,20 +3162,18 @@ def closeEvent(self, event): return super().closeEvent(event) + class FormLayout(QGridLayout): def __init__(self): QGridLayout.__init__(self) def addFormWidget( - self, formWidget, - leftLabelAlignment=Qt.AlignRight, - align=None, - row=0 - ): + self, formWidget, leftLabelAlignment=Qt.AlignRight, align=None, row=0 + ): for col, item in enumerate(formWidget.items): - if col==0: + if col == 0: alignment = leftLabelAlignment - elif col==2: + elif col == 2: alignment = Qt.AlignLeft else: alignment = align @@ -3001,64 +3185,68 @@ def addFormWidget( except TypeError: self.addLayout(item, row, col) + def macShortcutToWindows(shortcut: str): if shortcut is None: return - - s = (shortcut - .replace('Control', 'Meta') - .replace('Option', 'Alt') - .replace('Command', 'Ctrl') + + s = ( + shortcut.replace("Control", "Meta") + .replace("Option", "Alt") + .replace("Command", "Ctrl") ) return s + def windowsShortcutToMac(shortcut: str): if shortcut is None: return - + if not is_mac: return shortcut - - s = (shortcut - .replace('Meta', 'Control') - .replace('Alt', 'Option') - .replace('Ctrl', 'Command') + + s = ( + shortcut.replace("Meta", "Control") + .replace("Alt", "Option") + .replace("Ctrl", "Command") ) return s + class ToolBarSeparator: - def __init__(self, width=5, toolbar: QToolBar=None): + def __init__(self, width=5, toolbar: QToolBar = None): self._parts = ( - QHWidgetSpacer(width=width), + QHWidgetSpacer(width=width), QVLine(), - QHWidgetSpacer(width=width) + QHWidgetSpacer(width=width), ) self._actions = [] self._toolbar = None if toolbar is not None: self.addToToolbar(toolbar) - + def addToToolbar(self, toolbar): self._toolbar = toolbar for part in self._parts: action = toolbar.addWidget(part) self._actions.append(action) - + def removeFromToolbar(self): if self._toolbar is None: return - + for action in self._actions: self._toolbar.removeAction(action) + class ToolBar(QToolBar): def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) - + self.widgetsWithShortcut = {} - - for child in self.children(): - if child.objectName() == 'qt_toolbar_ext_button': + + for child in self.children(): + if child.objectName() == "qt_toolbar_ext_button": self.extendButton = child self.extendButton.setIcon(QIcon(":expand.svg")) break @@ -3066,49 +3254,50 @@ def __init__(self, *args, **kwargs) -> None: def addSeparator(self, width=5): separator = ToolBarSeparator(width=width, toolbar=self) return separator - + def removeSeparator(self, separator): separator.removeFromToolbar() - - def addSpinBox(self, label=''): + + def addSpinBox(self, label=""): spinbox = SpinBox(disableKeyPress=True) if label: spinbox.label = QLabel(label) spinbox.labelAction = self.addWidget(spinbox.label) - + spinbox.action = self.addWidget(spinbox) return spinbox - - def addButton(self, icon_str: str, text='', checkable=False): + + def addButton(self, icon_str: str, text="", checkable=False): action = QAction(QIcon(icon_str), text, self) action.setCheckable(checkable) self.addAction(action) return action - def addComboBox(self, items=None, label=''): + def addComboBox(self, items=None, label=""): combobox = ComboBox() - + if items is not None: combobox.addItems(items) - + if label: combobox.label = QLabel(label) combobox.labelAction = self.addWidget(combobox.label) - + combobox.action = self.addWidget(combobox) return combobox - def addLabel(self, text=''): + def addLabel(self, text=""): label = QLabel(text) label.action = self.addWidget(label) return label - - def addCheckBox(self, text='', checked=False): + + def addCheckBox(self, text="", checked=False): checkbox = QCheckBox(text) checkbox.setChecked(checked) checkbox.action = self.addWidget(checkbox) return checkbox - + + class ManualTrackingToolBar(ToolBar): sigIDchanged = Signal(int) sigDisableGhost = Signal() @@ -3118,22 +3307,22 @@ class ManualTrackingToolBar(ToolBar): def __init__(self, *args) -> None: super().__init__(*args) - self.spinboxID = self.addSpinBox(label='ID to track: ') + self.spinboxID = self.addSpinBox(label="ID to track: ") self.spinboxID.setMinimum(1) self.addSeparator() - self.showGhostCheckbox = QCheckBox('Show ghost object') + self.showGhostCheckbox = QCheckBox("Show ghost object") self.showGhostCheckbox.setChecked(True) self.addWidget(self.showGhostCheckbox) - self.ghostContourRadiobutton = QRadioButton('Contour') - self.ghostMaskRadiobutton = QRadioButton('Mask ; ') + self.ghostContourRadiobutton = QRadioButton("Contour") + self.ghostMaskRadiobutton = QRadioButton("Mask ; ") self.ghostMaskRadiobutton.setChecked(True) self.addWidget(self.ghostContourRadiobutton) self.addWidget(self.ghostMaskRadiobutton) - self.ghostMaskOpacitySpinbox = self.addSpinBox('Mask opacity: ') + self.ghostMaskOpacitySpinbox = self.addSpinBox("Mask opacity: ") self.ghostMaskOpacitySpinbox.setMaximum(100) self.ghostMaskOpacitySpinbox.setValue(30) @@ -3143,29 +3332,27 @@ def __init__(self, *args) -> None: ) self.spinboxID.valueChanged.connect(self.IDchanged) - self.ghostMaskOpacitySpinbox.valueChanged.connect( - self.ghostOpacityValueChanged - ) + self.ghostMaskOpacitySpinbox.valueChanged.connect(self.ghostOpacityValueChanged) self.addSeparator() - self.infoLabel = QLabel('') + self.infoLabel = QLabel("") self.addWidget(self.infoLabel) - + def showInfo(self, text): - text = html_utils.paragraph(text, font_color='black') + text = html_utils.paragraph(text, font_color="black") self.infoLabel.setText(text) def showWarning(self, text): - text = html_utils.paragraph(f'WARNING: {text}', font_color='red') + text = html_utils.paragraph(f"WARNING: {text}", font_color="red") self.infoLabel.setText(text) - + def clearInfoText(self): - self.infoLabel.setText('') - + self.infoLabel.setText("") + def IDchanged(self, value): self.sigIDchanged.emit(value) - + def showGhostCheckboxToggled(self, checked): disabled = not checked self.ghostContourRadiobutton.setDisabled(disabled) @@ -3174,149 +3361,146 @@ def showGhostCheckboxToggled(self, checked): self.ghostMaskOpacitySpinbox.label.setDisabled(disabled) if disabled: self.sigDisableGhost.emit() - + def ghostContourRadiobuttonToggled(self, checked): self.ghostMaskOpacitySpinbox.setDisabled(checked) self.ghostMaskOpacitySpinbox.label.setDisabled(checked) if checked: - self.sigClearGhostMask.emit() + self.sigClearGhostMask.emit() else: self.sigClearGhostContour.emit() - + def ghostOpacityValueChanged(self, value): self.sigGhostOpacityChanged.emit(value) + class CopyLostObjectToolbar(ToolBar): sigCopyAllObjects = Signal(int, int) - + def __init__(self, *args) -> None: super().__init__(*args) - - action = self.addButton(':copyContour_all.svg') + + action = self.addButton(":copyContour_all.svg") # action.setShortcut('Alt+C') - action.keyPressShortcut = KeySequenceFromText('Alt+C') - action.setToolTip( - 'Copy all lost objects\n\n' - 'Shortcut: Alt+C' - ) - self.widgetsWithShortcut['Copy all lost objects'] = action - + action.keyPressShortcut = KeySequenceFromText("Alt+C") + action.setToolTip("Copy all lost objects\n\nShortcut: Alt+C") + self.widgetsWithShortcut["Copy all lost objects"] = action + action.triggered.connect(self.emitSigCopyAllObjects) - + self.addSeparator() - + self.maxOverlapNumberControl = self.addSpinBox( - label='Maximum overlap to accept lost object [%]: ' + label="Maximum overlap to accept lost object [%]: " ) self.maxOverlapNumberControl.setMinimum(0) self.maxOverlapNumberControl.setValue(10) tooltip = ( - 'Maximum overlap to accept lost object [%]\n\n' - 'If the overlap between the lost object and an object already ' - 'existing is greater than this value,\n' - 'the lost object will not be added.' + "Maximum overlap to accept lost object [%]\n\n" + "If the overlap between the lost object and an object already " + "existing is greater than this value,\n" + "the lost object will not be added." ) self.maxOverlapNumberControl.setToolTip(tooltip) self.maxOverlapNumberControl.label.setToolTip(tooltip) - + self.addSeparator() - + self.untilFrameNumberControl = self.addSpinBox( - label='Copy lost object(s) for the next number of frames: ' + label="Copy lost object(s) for the next number of frames: " ) self.untilFrameNumberControl.setMinimum(0) self.untilFrameNumberControl.setValue(0) def emitSigCopyAllObjects(self): self.sigCopyAllObjects.emit( - self.untilFrameNumberControl.value(), - self.maxOverlapNumberControl.value() + self.untilFrameNumberControl.value(), self.maxOverlapNumberControl.value() ) + class DrawClearRegionToolbar(ToolBar): def __init__(self, *args) -> None: super().__init__(*args) - + group = QButtonGroup() group.setExclusive(True) - self.clearTouchingObjsRadioButton = QRadioButton( - 'Clear all touching objects' - ) + self.clearTouchingObjsRadioButton = QRadioButton("Clear all touching objects") self.clearOnlyEnclosedObjsRadioButton = QRadioButton( - 'Clear only fully enclosed objects' + "Clear only fully enclosed objects" ) self.clearOnlyEnclosedObjsRadioButton.setChecked(True) group.addButton(self.clearTouchingObjsRadioButton) group.addButton(self.clearOnlyEnclosedObjsRadioButton) - + self.addWidget(self.clearTouchingObjsRadioButton) self.addWidget(self.clearOnlyEnclosedObjsRadioButton) - + self.addSeparator() - + self.numZslicesUpSpinbox = self.addSpinBox( - label='Num. of z-slices to clear upwards: ' + label="Num. of z-slices to clear upwards: " ) self.numZslicesUpSpinbox.setMinimum(0) self.numZslicesUpSpinbox.setValue(0) - + self.numZslicesDownSpinbox = self.addSpinBox( - label='Num. of z-slices to clear downwards: ' + label="Num. of z-slices to clear downwards: " ) self.numZslicesDownSpinbox.setMinimum(0) self.numZslicesDownSpinbox.setValue(0) - + def setZslicesControlEnabled(self, enabled, SizeZ=None): self.numZslicesUpSpinbox.labelAction.setVisible(enabled) self.numZslicesUpSpinbox.action.setVisible(enabled) - + self.numZslicesDownSpinbox.labelAction.setVisible(enabled) self.numZslicesDownSpinbox.action.setVisible(enabled) - + if SizeZ is None: return - + self.numZslicesUpSpinbox.setMaximum(SizeZ) self.numZslicesDownSpinbox.setMaximum(SizeZ) - + def zRange(self, z_slice, SizeZ): if z_slice is None: zRange = (0, SizeZ) return zRange - + numZslicesUp = self.numZslicesUpSpinbox.value() numZslicesDown = self.numZslicesDownSpinbox.value() - + zmin = z_slice - numZslicesDown zmax = z_slice + numZslicesDown + 1 - + zmin = zmin if zmin >= 0 else 0 zmax = zmax if zmax <= SizeZ else SizeZ - + return (zmin, zmax) + class ManualBackgroundToolBar(ToolBar): sigIDchanged = Signal(int) def __init__(self, *args) -> None: super().__init__(*args) - self.spinboxID = self.addSpinBox(label='Set background of ID ') + self.spinboxID = self.addSpinBox(label="Set background of ID ") self.spinboxID.setMinimum(1) self.spinboxID.valueChanged.connect(self.IDchanged) - - self.infoLabel = QLabel('') + + self.infoLabel = QLabel("") self.addWidget(self.infoLabel) - + def IDchanged(self, value): self.sigIDchanged.emit(value) - + def showWarning(self, text): - text = html_utils.paragraph(f'WARNING: {text}', font_color='red') + text = html_utils.paragraph(f"WARNING: {text}", font_color="red") self.infoLabel.setText(text) - + def clearInfoText(self): - self.infoLabel.setText('') - + self.infoLabel.setText("") + class rightClickToolButton(QToolButton): sigRightClick = Signal(object) @@ -3332,44 +3516,45 @@ def mousePressEvent(self, event): elif event.button() == Qt.MouseButton.RightButton: self.sigRightClick.emit(event) + class SavePointsLayerButton(rightClickToolButton): sigRenameTableAction = Signal(object, str) - + def __init__(self, table_endname, parent=None): super().__init__(parent=parent) - self.setIcon(QIcon(':file-save.svg')) - + self.setIcon(QIcon(":file-save.svg")) + self.table_endname = table_endname - + self.setToolTip( "Save annotated points in the CSV file ending " f"with '{self.table_endname}.csv'" ) - + self.sigRightClick.connect(self.showContextMenu) - + def showContextMenu(self, event): contextMenu = QMenu(self) contextMenu.addSeparator() - - renameAction = QAction('Rename points layer table') + + renameAction = QAction("Rename points layer table") renameAction.triggered.connect(self.renameTable) contextMenu.addAction(renameAction) - + contextMenu.exec(event.globalPos()) - + def renameTable(self): win = apps.filenameDialog( parent=self, - title='Rename points layer table file', + title="Rename points layer table file", allowEmpty=False, defaultEntry=self.table_endname, - ext='.csv', + ext=".csv", ) win.exec_() if win.cancel: return - + self.table_endname = win.entryText self.setToolTip( "Save annotated points in the CSV file ending " @@ -3377,8 +3562,9 @@ def renameTable(self): ) self.sigRenameTableAction.emit(self, self.table_endname) + class ToolButtonCustomColor(rightClickToolButton): - def __init__(self, symbol, color='r', parent=None): + def __init__(self, symbol, color="r", parent=None): super().__init__(parent=parent) if not isinstance(color, QColor): color = pg.mkColor(color) @@ -3389,19 +3575,19 @@ def setColor(self, color): self.penColor = color self.brushColor = [0, 0, 0, 100] self.brushColor[:3] = color.getRgb()[:3] - + def updateSymbol(self, symbol, update=True): self.symbol = symbol if not update: return self.update() - + def updateColor(self, color, update=True): self.setColor(color) if not update: return self.update() - + def updateIcon(self, symbol, color): self.updateSymbol(symbol) self.updateColor(color) @@ -3412,8 +3598,8 @@ def paintEvent(self, event): p = QPainter(self) w, h = self.width(), self.height() sf = 0.6 - p.scale(w*sf, h*sf) - p.translate(0.5/sf, 0.5/sf) + p.scale(w * sf, h * sf) + p.translate(0.5 / sf, 0.5 / sf) symbol = pg.graphicsItems.ScatterPlotItem.Symbols[self.symbol] pen = pg.mkPen(color=self.penColor, width=2) brush = pg.mkBrush(color=self.brushColor) @@ -3427,82 +3613,83 @@ def paintEvent(self, event): finally: p.end() + class GradientToolButton(rightClickToolButton): def __init__(self, colors=((255, 0, 0),), parent=None): super().__init__(parent=parent) self._qcolors = [pg.mkColor(c) for c in colors] if len(self._qcolors) < 2: self._qcolors.append(self._qcolors[0]) - + def paintEvent(self, event): super().paintEvent(event) painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) - + pen = pg.mkPen(color=self._qcolors[-1], width=2) pad = 7 - + rect = self.rect().adjusted(pad, pad, -pad, -pad) # A little padding # Gradient: bottom to top - gradient = QLinearGradient( - QPointF(rect.bottomLeft()), QPointF(rect.topLeft()) - ) - - # Set color stops evenly distributed + gradient = QLinearGradient(QPointF(rect.bottomLeft()), QPointF(rect.topLeft())) + + # Set color stops evenly distributed num_colors = len(self._qcolors) for i, color in enumerate(self._qcolors): gradient.setColorAt(i / (num_colors - 1), color) - + if not self.isChecked(): painter.setOpacity(0.4) painter.setBrush(gradient) painter.setPen(pen) painter.drawRect(rect) - + painter.end() + class PointsLayerToolButton(ToolButtonCustomColor): sigEditAppearance = Signal(object) sigShowIdsToggled = Signal(object, bool) sigRemove = Signal(object) - def __init__(self, symbol, color='r', parent=None): + def __init__(self, symbol, color="r", parent=None): super().__init__(symbol, color=color, parent=parent) self.sigRightClick.connect(self.showContextMenu) - + def showContextMenu(self, event): contextMenu = QMenu(self) contextMenu.addSeparator() - editAction = QAction('Edit points appearance...') + editAction = QAction("Edit points appearance...") editAction.triggered.connect(self.editAppearance) contextMenu.addAction(editAction) - - removeAction = QAction('Remove points') + + removeAction = QAction("Remove points") removeAction.triggered.connect(self.emitRemove) contextMenu.addAction(removeAction) - - showIdsAction = QAction('Show point ids') + + showIdsAction = QAction("Show point ids") showIdsAction.setCheckable(True) showIdsAction.setChecked(True) contextMenu.addAction(showIdsAction) showIdsAction.toggled.connect(self.emitShowIdsToggled) contextMenu.exec(event.globalPos()) - + def emitRemove(self): self.sigRemove.emit(self) - + def emitShowIdsToggled(self, checked): self.sigShowIdsToggled.emit(self, checked) - + def editAppearance(self): self.sigEditAppearance.emit(self) + class customAnnotToolButton(ToolButtonCustomColor): sigRemoveAction = Signal(object) sigKeepActiveAction = Signal(object) @@ -3510,9 +3697,8 @@ class customAnnotToolButton(ToolButtonCustomColor): sigHideAction = Signal(object) def __init__( - self, symbol, color, keepToolActive=True, parent=None, - isHideChecked=True - ): + self, symbol, color, keepToolActive=True, parent=None, isHideChecked=True + ): super().__init__(symbol, color=color, parent=parent) self.symbol = symbol self.keepToolActive = keepToolActive @@ -3523,21 +3709,21 @@ def showContextMenu(self, event): contextMenu = QMenu(self) contextMenu.addSeparator() - removeAction = QAction('Remove annotation') + removeAction = QAction("Remove annotation") removeAction.triggered.connect(self.removeAction) contextMenu.addAction(removeAction) - editAction = QAction('Modify annotation parameters...') + editAction = QAction("Modify annotation parameters...") editAction.triggered.connect(self.modifyAction) contextMenu.addAction(editAction) - hideAction = QAction('Hide annotations') + hideAction = QAction("Hide annotations") hideAction.setCheckable(True) hideAction.setChecked(self.isHideChecked) hideAction.triggered.connect(self.hideAction) contextMenu.addAction(hideAction) - keepActiveAction = QAction('Keep tool active after using it') + keepActiveAction = QAction("Keep tool active after using it") keepActiveAction.setCheckable(True) keepActiveAction.setChecked(self.keepToolActive) keepActiveAction.triggered.connect(self.keepToolActiveActionToggled) @@ -3559,13 +3745,14 @@ def hideAction(self, checked): self.isHideChecked = checked self.sigHideAction.emit(self) + class LabelRoiCircularItem(pg.ScatterPlotItem): def __init__(self, *args, **kargs): super().__init__(*args, **kargs) - + def setImageShape(self, shape): self._shape = shape - + def slice(self, zRange=None, tRange=None): self.mask() if zRange is None: @@ -3573,33 +3760,34 @@ def slice(self, zRange=None, tRange=None): else: zmin, zmax = zRange _slice = (slice(zmin, zmax), *self._slice) - + if tRange is not None: tmin, tmax = tRange _slice = (slice(tmin, tmax), *_slice) - + return _slice def mask(self): shape = self._shape - radius = int(self.opts['size']/2) + radius = int(self.opts["size"] / 2) mask = skimage.morphology.disk(radius, dtype=bool) xx, yy = self.getData() Yc, Xc = yy[0], xx[0] mask, self._slice = myutils.clipSelemMask(mask, shape, Yc, Xc, copy=False) return mask + class Toggle(QCheckBox): def __init__( - self, - label_text='', - initial=None, - width=80, - bg_color='#b3b3b3', - circle_color='#ffffff', - active_color='#26dd66',# '#005ce6', - animation_curve=QEasingCurve.Type.InOutQuad - ): + self, + label_text="", + initial=None, + width=80, + bg_color="#b3b3b3", + circle_color="#ffffff", + active_color="#26dd66", # '#005ce6', + animation_curve=QEasingCurve.Type.InOutQuad, + ): QCheckBox.__init__(self) # self.setFixedSize(width, 28) @@ -3614,8 +3802,8 @@ def __init__( self._disabled_bg_color = colors.lighten_color(bg_color, amount=0.5) self._circle_margin = 4 - self._circle_position = int(self._circle_margin/2) - self.animation = QPropertyAnimation(self, b'circle_position', self) + self._circle_position = int(self._circle_margin / 2) + self.animation = QPropertyAnimation(self, b"circle_position", self) self.animation.setEasingCurve(animation_curve) self.animation.setDuration(200) @@ -3644,10 +3832,10 @@ def setChecked(self, state): self._isChecked = state if self.isVisible(): self.requestedState = None - QCheckBox.setChecked(self, state>0) + QCheckBox.setChecked(self, state > 0) else: self.requestedState = state - + def isChecked(self): if self.isVisible(): return super().isChecked() @@ -3655,15 +3843,15 @@ def isChecked(self): return self._isChecked def circlePos(self, state: bool): - start = int(self._circle_margin/2) + start = int(self._circle_margin / 2) if state: if self.isVisible(): height, width = self.height(), self.width() else: sizeHint = self.sizeHint() height, width = sizeHint.height(), sizeHint.width() - circle_diameter = height-self._circle_margin - pos = width-start-circle_diameter + circle_diameter = height - self._circle_margin + pos = width - start - circle_diameter else: pos = start return pos @@ -3688,22 +3876,19 @@ def hitButton(self, pos: QPoint): def setDisabled(self, disable): QCheckBox.setDisabled(self, disable) - if hasattr(self, 'label'): + if hasattr(self, "label"): self.label.setDisabled(disable) self.update() def paintEvent(self, e): circle_color = ( - self._circle_color if self.isEnabled() - else self._disabled_circle_color + self._circle_color if self.isEnabled() else self._disabled_circle_color ) active_color = ( - self._active_color if self.isEnabled() - else self._disabled_active_color + self._active_color if self.isEnabled() else self._disabled_active_color ) unchecked_color = ( - self._bg_color if self.isEnabled() - else self._disabled_bg_color + self._bg_color if self.isEnabled() else self._disabled_bg_color ) # set painter @@ -3719,77 +3904,73 @@ def paintEvent(self, e): if not self.isChecked(): # Draw background p.setBrush(QColor(unchecked_color)) - half_h = int(self.height()/2) - p.drawRoundedRect( - 0, 0, rect.width(), self.height(), half_h, half_h - ) + half_h = int(self.height() / 2) + p.drawRoundedRect(0, 0, rect.width(), self.height(), half_h, half_h) # Draw circle p.setBrush(QColor(circle_color)) p.drawEllipse( - int(self._circle_position), int(self._circle_margin/2), - self.height()-self._circle_margin, - self.height()-self._circle_margin + int(self._circle_position), + int(self._circle_margin / 2), + self.height() - self._circle_margin, + self.height() - self._circle_margin, ) else: # Draw background p.setBrush(QColor(active_color)) - half_h = int(self.height()/2) - p.drawRoundedRect( - 0, 0, rect.width(), self.height(), half_h, half_h - ) + half_h = int(self.height() / 2) + p.drawRoundedRect(0, 0, rect.width(), self.height(), half_h, half_h) # Draw circle p.setBrush(QColor(circle_color)) p.drawEllipse( - int(self._circle_position), int(self._circle_margin/2), - self.height()-self._circle_margin, - self.height()-self._circle_margin + int(self._circle_position), + int(self._circle_margin / 2), + self.height() - self._circle_margin, + self.height() - self._circle_margin, ) p.end() + def QKeyEventToString(event: QKeyEvent, notAllowedModifier=None): - isAltKey = event.key()==Qt.Key_Alt - isCtrlKey = event.key()==Qt.Key_Control - isShiftKey = event.key()==Qt.Key_Shift + isAltKey = event.key() == Qt.Key_Alt + isCtrlKey = event.key() == Qt.Key_Control + isShiftKey = event.key() == Qt.Key_Shift isModifierKey = isAltKey or isCtrlKey or isShiftKey - + modifiers = event.modifiers() - isNotAllowedMod = ( - notAllowedModifier is not None and modifiers == notAllowedModifier - ) + isNotAllowedMod = notAllowedModifier is not None and modifiers == notAllowedModifier if isNotAllowedMod: - return - + return + modifers_value = modifiers.value if PYQT6 else modifiers if isModifierKey: keySequenceText = KeySequenceFromText(modifers_value).toString() else: keySequenceText = QKeySequence(modifers_value | event.key()).toString() - - keySequenceText = keySequenceText.encode('ascii', 'ignore').decode('utf-8') - + + keySequenceText = keySequenceText.encode("ascii", "ignore").decode("utf-8") + return keySequenceText + class ShortcutLineEdit(QLineEdit): - def __init__( - self, parent=None, allowModifiers=False, notAllowedModifier=None - ): + def __init__(self, parent=None, allowModifiers=False, notAllowedModifier=None): self.keySequence = None super().__init__(parent) self._allowModifiers = allowModifiers self._notAllowedModifier = notAllowedModifier self.setAlignment(Qt.AlignCenter) - + def text(self): text = macShortcutToWindows(super().text()) - + return text - + def setText(self, text): text = windowsShortcutToMac(text) - + super().setText(text) if not text: self.keySequence = None @@ -3801,7 +3982,7 @@ def setText(self, text): def keyPressEvent(self, event: QKeyEvent): if event.key() == Qt.Key_Backspace or event.key() == Qt.Key_Delete: - self.setText('') + self.setText("") return keySequenceText = QKeyEventToString( @@ -3809,14 +3990,14 @@ def keyPressEvent(self, event: QKeyEvent): ) self.setText(keySequenceText) self.key = event.key() - + def keyReleaseEvent(self, event: QKeyEvent) -> None: - if self.text().endswith('+'): + if self.text().endswith("+"): if not self._allowModifiers: - self.setText('') + self.setText("") else: - self.setText(self.text().rstrip('+').strip()) - + self.setText(self.text().rstrip("+").strip()) + class selectStartStopFrames(QGroupBox): def __init__(self, SizeT, currentFrameNum=0, parent=None): @@ -3826,7 +4007,7 @@ def __init__(self, SizeT, currentFrameNum=0, parent=None): self.startFrame_SB = QSpinBox() self.startFrame_SB.setAlignment(Qt.AlignCenter) self.startFrame_SB.setMinimum(1) - self.startFrame_SB.setMaximum(SizeT-1) + self.startFrame_SB.setMaximum(SizeT - 1) self.startFrame_SB.setValue(currentFrameNum) self.stopFrame_SB = QSpinBox() @@ -3835,10 +4016,10 @@ def __init__(self, SizeT, currentFrameNum=0, parent=None): self.stopFrame_SB.setMaximum(SizeT) self.stopFrame_SB.setValue(SizeT) - selectFramesLayout.addWidget(QLabel('Start frame n.'), 0, 0) + selectFramesLayout.addWidget(QLabel("Start frame n."), 0, 0) selectFramesLayout.addWidget(self.startFrame_SB, 1, 0) - selectFramesLayout.addWidget(QLabel('Stop frame n.'), 0, 1) + selectFramesLayout.addWidget(QLabel("Stop frame n."), 0, 1) selectFramesLayout.addWidget(self.stopFrame_SB, 1, 1) self.warningLabel = QLabel() @@ -3858,34 +4039,34 @@ def _checkRange(self): start = self.startFrame_SB.value() stop = self.stopFrame_SB.value() if stop <= start: - self.warningLabel.setText( - 'stop frame smaller than start frame' - ) + self.warningLabel.setText("stop frame smaller than start frame") else: - self.warningLabel.setText('') + self.warningLabel.setText("") + class formWidget(QWidget): sigApplyButtonClicked = Signal(object) sigComputeButtonClicked = Signal(object) def __init__( - self, widget, - initialVal=None, - stretchWidget=True, - widgetAlignment=None, - labelTextLeft='', - labelTextRight='', - font=None, - addInfoButton=False, - addApplyButton=False, - addComputeButton=False, - addActivateCheckbox=False, - key='', - infoTxt='', - valueGetterName='value', - toolTip='', - parent=None - ): + self, + widget, + initialVal=None, + stretchWidget=True, + widgetAlignment=None, + labelTextLeft="", + labelTextRight="", + font=None, + addInfoButton=False, + addApplyButton=False, + addComputeButton=False, + addActivateCheckbox=False, + key="", + infoTxt="", + valueGetterName="value", + toolTip="", + parent=None, + ): QWidget.__init__(self, parent) self.widget = widget self.key = key @@ -3915,10 +4096,10 @@ def __init__( if not stretchWidget: widgetLayout = QHBoxLayout() - if widgetAlignment != 'left': + if widgetAlignment != "left": widgetLayout.addStretch(1) widgetLayout.addWidget(widget) - if widgetAlignment != 'right': + if widgetAlignment != "right": widgetLayout.addStretch(1) self.items.append(widgetLayout) else: @@ -3928,7 +4109,7 @@ def __init__( self.labelRight.setText(labelTextRight) self.labelRight.setFont(font) self.items.append(self.labelRight) - + if toolTip: self.labelLeft.setToolTip(toolTip) self.widget.setToolTip(toolTip) @@ -3939,9 +4120,7 @@ def __init__( infoButton.setCursor(Qt.WhatsThisCursor) infoButton.setIcon(QIcon(":info.svg")) if labelTextLeft: - infoButton.setToolTip( - f'Info about "{self.labelLeft.text()}" parameter' - ) + infoButton.setToolTip(f'Info about "{self.labelLeft.text()}" parameter') else: infoButton.setToolTip( f'Info about "{self.labelRight.text()}" measurement' @@ -3955,7 +4134,7 @@ def __init__( applyButton.setCursor(Qt.PointingHandCursor) applyButton.setCheckable(True) applyButton.setIcon(QIcon(":apply.svg")) - applyButton.setToolTip(f'Apply this step and visualize results') + applyButton.setToolTip(f"Apply this step and visualize results") applyButton.clicked.connect(self.applyButtonClicked) self.items.append(applyButton) @@ -3963,13 +4142,13 @@ def __init__( computeButton = QPushButton(self) computeButton.setCursor(Qt.BusyCursor) computeButton.setIcon(QIcon(":compute.svg")) - computeButton.setToolTip(f'Compute this step and visualize results') + computeButton.setToolTip(f"Compute this step and visualize results") computeButton.clicked.connect(self.computeButtonClicked) self.items.append(computeButton) - + self.activateCheckbox = None if addActivateCheckbox: - self.activateCheckbox = QCheckBox('Activate') + self.activateCheckbox = QCheckBox("Activate") self.activateCheckbox.setChecked(False) self.widget.setDisabled(True) self.activateCheckbox.toggled.connect(self.setWidgetEnabled) @@ -3977,17 +4156,17 @@ def __init__( self.labelLeft.clicked.connect(self.tryChecking) self.labelRight.clicked.connect(self.tryChecking) - + def setWidgetEnabled(self, checked): self.widget.setDisabled(not checked) - + def value(self): if self.activateCheckbox is None: return getattr(self.widget, self.valueGetterName)() - + if not self.activateCheckbox.isChecked(): return - + return getattr(self.widget, self.valueGetterName)() def tryChecking(self, label): @@ -4005,11 +4184,11 @@ def computeButtonClicked(self): def showInfo(self): msg = myMessageBox() msg.setIcon() - msg.setWindowTitle(f'{self.labelLeft.text()} info') + msg.setWindowTitle(f"{self.labelLeft.text()} info") msg.addText(self.infoTxt) - msg.addButton(' Ok ') + msg.addButton(" Ok ") msg.exec_() - + def setDisabled(self, disabled: bool) -> None: for item in self.items: try: @@ -4017,31 +4196,32 @@ def setDisabled(self, disabled: bool) -> None: except Exception as err: pass + class ToggleTerminalButton(PushButton): sigClicked = Signal(bool) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setIcon(QIcon(':terminal_up.svg')) - self.setFixedSize(34,18) + self.setIcon(QIcon(":terminal_up.svg")) + self.setFixedSize(34, 18) self.setIconSize(QSize(30, 14)) self.setFlat(True) self.terminalVisible = False self.clicked.connect(self.mouseClick) - + def mouseClick(self): if self.terminalVisible: - self.setIcon(QIcon(':terminal_up.svg')) + self.setIcon(QIcon(":terminal_up.svg")) self.terminalVisible = False else: - self.setIcon(QIcon(':terminal_down.svg')) + self.setIcon(QIcon(":terminal_down.svg")) self.terminalVisible = True self.sigClicked.emit(self.terminalVisible) - + def showEvent(self, a0) -> None: self.idlePalette = self.palette() return super().showEvent(a0) - + def enterEvent(self, event) -> None: self.setFlat(False) # pal = self.palette() @@ -4050,18 +4230,20 @@ def enterEvent(self, event) -> None: # self.setPalette(pal) self.update() return super().enterEvent(event) - + def leaveEvent(self, event) -> None: self.setFlat(True) # self.setPalette(self.idlePalette) self.update() return super().leaveEvent(event) + class CenteredDoubleSpinbox(QDoubleSpinBox): def __init__(self, parent=None): super().__init__(parent=parent) self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31-1) + self.setMaximum(2**31 - 1) + class readOnlyDoubleSpinbox(QDoubleSpinBox): def __init__(self, parent=None): @@ -4069,29 +4251,31 @@ def __init__(self, parent=None): self.setReadOnly(True) self.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31-1) + self.setMaximum(2**31 - 1) # self.setStyleSheet('background-color: rgba(240, 240, 240, 200);') + class readOnlySpinbox(QSpinBox): def __init__(self, parent=None): super().__init__(parent=parent) self.setReadOnly(True) self.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31-1) + self.setMaximum(2**31 - 1) # self.setStyleSheet('background-color: rgba(240, 240, 240, 200);') + class DoubleSpinBox(QDoubleSpinBox): sigValueChanged = Signal(int) def __init__(self, parent=None, disableKeyPress=False): super().__init__(parent=parent) self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31-1) - self.setMinimum(-2**31) + self.setMaximum(2**31 - 1) + self.setMinimum(-(2**31)) self._valueChangedFunction = None self.disableKeyPress = disableKeyPress - + def keyPressEvent(self, event) -> None: isBackSpaceKey = event.key() == Qt.Key_Backspace isDeleteKey = event.key() == Qt.Key_Delete @@ -4106,37 +4290,33 @@ def keyPressEvent(self, event) -> None: self.clearFocus() else: super().keyPressEvent(event) - + def textFromValue(self, value: float) -> str: text = super().textFromValue(value) - return text.replace(QLocale().decimalPoint(), '.') + return text.replace(QLocale().decimalPoint(), ".") def valueFromText(self, text: str) -> float: - text = text.replace('.', QLocale().decimalPoint()) + text = text.replace(".", QLocale().decimalPoint()) return super().valueFromText(text) + class SpinBox(QSpinBox): sigValueChanged = Signal(int) sigUpClicked = Signal() sigDownClicked = Signal() - def __init__( - self, - parent=None, - disableKeyPress=False, - allowNegative=True - ): + def __init__(self, parent=None, disableKeyPress=False, allowNegative=True): super().__init__(parent=parent) self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31-1) + self.setMaximum(2**31 - 1) if allowNegative: - self.setMinimum(-2**31) + self.setMinimum(-(2**31)) else: self.setMinimum(0) self._valueChangedFunction = None self.disableKeyPress = disableKeyPress self._linkedWidget = None - + def mousePressEvent(self, event) -> None: super().mousePressEvent(event) opt = QStyleOptionSpinBox() @@ -4154,7 +4334,7 @@ def mousePressEvent(self, event) -> None: # self.editingFinished.emit() # super().focusOutEvent(event) # printl('emitted') - + def keyPressEvent(self, event) -> None: isBackSpaceKey = event.key() == Qt.Key_Backspace isDeleteKey = event.key() == Qt.Key_Delete @@ -4169,34 +4349,35 @@ def keyPressEvent(self, event) -> None: self.clearFocus() else: super().keyPressEvent(event) - + def connectValueChanged(self, function): self._valueChangedFunction = function self.valueChanged.connect(function) - + def setValue(self, value, setLinkedWidget=True): super().setValue(int(value)) if self._linkedWidget is not None and setLinkedWidget: self._linkedWidget.setValue(value) - + def setValueNoEmit(self, value): if self._valueChangedFunction is None: self.setValue(value) return try: self.valueChanged.disconnect() - except TypeError as e: # this fails if its not cennected yet + except TypeError as e: # this fails if its not cennected yet pass - + self.setValue(value) self.valueChanged.connect(self._valueChangedFunction) - + def wheelEvent(self, event): event.ignore() - + def setLinkedValueWidget(self, widget): self._linkedWidget = widget + class ReadOnlyLineEdit(QLineEdit): def __init__(self, parent=None): super().__init__(parent=parent) @@ -4205,28 +4386,35 @@ def __init__(self, parent=None): # 'background-color: rgba(240, 240, 240, 200);' # ) self.installEventFilter(self) - - def eventFilter(self, a0: 'QObject', a1: 'QEvent') -> bool: + + def eventFilter(self, a0: "QObject", a1: "QEvent") -> bool: if a1.type() == QEvent.Type.FocusIn: return True return super().eventFilter(a0, a1) def setValue(self, value): self.setText(str(value)) - + def value(self, casting_func: callable = None): text = self.text() if casting_func is not None: return casting_func(text) return text + class FloatLineEdit(QLineEdit): valueChanged = Signal(float) def __init__( - self, *args, notAllowed=None, allowNegative=True, initial=None, - readOnly=False, decimals=6, warningValues=None - ): + self, + *args, + notAllowed=None, + allowNegative=True, + initial=None, + readOnly=False, + decimals=6, + warningValues=None, + ): QLineEdit.__init__(self, *args) if readOnly: self.setReadOnly(readOnly) @@ -4236,7 +4424,7 @@ def __init__( self._minimum = -np.inf self._decimals = decimals - self.isNumericRegExp = rf'^{float_regex(allow_negative=allowNegative)}$' + self.isNumericRegExp = rf"^{float_regex(allow_negative=allowNegative)}$" regExp = QRegularExpression(self.isNumericRegExp) self.setValidator(QRegularExpressionValidator(regExp)) self.setAlignment(Qt.AlignCenter) @@ -4246,12 +4434,12 @@ def __init__( self.setFont(font) self.textChanged.connect(self.emitValueChanged) - + if initial is not None: self.setValue(initial) else: - self.setValue(0) - + self.setValue(0) + def setDecimals(self, decimals): self._decimals = 6 @@ -4261,7 +4449,7 @@ def castMinMax(self, value: int): if value < self._minimum: value = self._minimum return value - + def setValue(self, value: float): value = self.castMinMax(value) self.setText(str(round(value, self._decimals))) @@ -4276,13 +4464,13 @@ def value(self): val = 0.0 else: val = 0.0 - + return self.castMinMax(val) - + def setMaximum(self, maximum): self._maximum = maximum self.setValue(self.value()) - + def setMinimum(self, minimum): self._minimum = minimum self.setValue(self.value()) @@ -4293,23 +4481,23 @@ def emitValueChanged(self, text): if self.warningValues is not None and val in self.warningValues: self.setStyleSheet(LINEEDIT_WARNING_STYLESHEET) reset_stylesheet = False - + if self.notAllowed is not None and val in self.notAllowed: self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) reset_stylesheet = False else: self.valueChanged.emit(self.value()) - + if reset_stylesheet: - self.setStyleSheet('') + self.setStyleSheet("") + class IntLineEdit(QLineEdit): valueChanged = Signal(float) def __init__( - self, *args, notAllowed=None, allowNegative=True, initial=None, - readOnly=False - ): + self, *args, notAllowed=None, allowNegative=True, initial=None, readOnly=False + ): QLineEdit.__init__(self, *args) self.notAllowed = notAllowed if readOnly: @@ -4317,10 +4505,10 @@ def __init__( self._maximum = np.inf self._minimum = -np.inf - - self._regExp = r'\d+' + + self._regExp = r"\d+" if allowNegative: - self._regExp = r'-?\d+' + self._regExp = r"-?\d+" regExp = QRegularExpression(self._regExp) self.setValidator(QRegularExpressionValidator(regExp)) @@ -4331,16 +4519,16 @@ def __init__( self.setFont(font) self.textChanged.connect(self.emitValueChanged) - + if initial is not None: self.setValue(initial) else: - self.setValue(0) - + self.setValue(0) + def setMaximum(self, maximum): self._maximum = maximum self.setValue(self.value()) - + def setMinimum(self, minimum): self._minimum = minimum self.setValue(self.value()) @@ -4351,7 +4539,7 @@ def castMinMax(self, value: int): if value < self._minimum: value = self._minimum return value - + def setValue(self, value: int): value = self.castMinMax(value) self.setText(str(value)) @@ -4366,27 +4554,26 @@ def value(self): val = 0 else: val = 0 - + return self.castMinMax(val) def emitValueChanged(self, text): if not text: return - + val = self.value() self.setValue(val) if self.notAllowed is not None and val in self.notAllowed: self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) else: - self.setStyleSheet('') + self.setStyleSheet("") self.valueChanged.emit(self.value()) + class CheckboxesGroupBox(QGroupBox): - def __init__( - self, texts, title='', checkable=False, parent=None - ): + def __init__(self, texts, title="", checkable=False, parent=None): super().__init__(parent) - + self.setTitle(title) self.setCheckable(checkable) layout = QVBoxLayout() @@ -4394,45 +4581,53 @@ def __init__( scrollLayout = QVBoxLayout() container = QWidget() scrollarea = QScrollArea() - + self.checkBoxes = [] for text in texts: checkbox = QCheckBox(text) checkbox.setChecked(True) scrollLayout.addWidget(checkbox) self.checkBoxes.append(checkbox) - + container.setLayout(scrollLayout) scrollarea.setWidget(container) layout.addWidget(scrollarea) - + buttonsLayout = QHBoxLayout() selectAllButton = selectAllPushButton() selectAllButton.sigClicked.connect(self.checkAll) buttonsLayout.addStretch(1) buttonsLayout.addWidget(selectAllButton) layout.addLayout(buttonsLayout) - + self.setLayout(layout) - + def checkAll(self, button, checked): for checkBox in self.checkBoxes: checkBox.setChecked(checked) - + + class _metricsQGBox(QGroupBox): sigDelClicked = Signal(str, object) def __init__( - self, desc_dict, title, favourite_funcs=None, isZstack=False, - equations=None, addDelButton=False, delButtonMetricsDesc=None, - parent=None, addCalcForEachZsliceToggle=False - ): + self, + desc_dict, + title, + favourite_funcs=None, + isZstack=False, + equations=None, + addDelButton=False, + delButtonMetricsDesc=None, + parent=None, + addCalcForEachZsliceToggle=False, + ): QGroupBox.__init__(self, parent) - + highlightRgba = _palettes._highlight_rgba() r, g, b, a = highlightRgba - self._highlightStylesheetColor = f'rgb({r}, {g}, {b})' - + self._highlightStylesheetColor = f"rgb({r}, {g}, {b})" + self._parent = parent self.scrollArea = QScrollArea() self.scrollAreaWidget = QWidget() @@ -4461,10 +4656,10 @@ def __init__( checkBox.equation = equations[metric_colname] except Exception as e: pass - + if addDelButton or metric_colname in delButtonMetricsDesc: delButton = delPushButton() - delButton.setToolTip('Delete custom combined measurement') + delButton.setToolTip("Delete custom combined measurement") delButton.colname = metric_colname delButton.checkbox = checkBox delButton.clicked.connect(self.onDelClicked) @@ -4478,8 +4673,8 @@ def __init__( infoButton.clicked.connect(self.showInfo) rowLayout.addWidget(infoButton) - rowLayout.addWidget(checkBox) - rowLayout.addStretch(1) + rowLayout.addWidget(checkBox) + rowLayout.addStretch(1) inner_layout.addLayout(rowLayout) @@ -4487,48 +4682,43 @@ def __init__( self.scrollArea.setWidget(self.scrollAreaWidget) layout.addWidget(self.scrollArea) - buttonsLayout = QHBoxLayout() - + buttonsLayout = QHBoxLayout() + buttonsLayout.addStretch(1) - + self.selectAllButton = selectAllPushButton() self.selectAllButton.sigClicked.connect(self.checkAll) - + buttonsLayout.addWidget(self.selectAllButton) if favourite_funcs is not None: - self.loadFavouritesButton = reloadPushButton( - ' Load last selection... ' - ) + self.loadFavouritesButton = reloadPushButton(" Load last selection... ") self.loadFavouritesButton.clicked.connect(self.checkFavouriteFuncs) # self.checkFavouriteFuncs() buttonsLayout.addWidget(self.loadFavouritesButton) layout.addLayout(buttonsLayout) - + self.calcForEachZsliceToggle = None if addCalcForEachZsliceToggle: buttonsLayout = QHBoxLayout() self.calcForEachZsliceToggle = Toggle() tooltip = ( - 'Calculate `cell_area` for each z-slice.\n\n' - 'The measurements will be saved in the column with name\n' - 'ending with `_zsliceN` where N is the z-slice number\n' - '(starting from 0).' - ) - calcForEachZsliceLabel = QClickableLabel( - 'Calculate for each z-slice' + "Calculate `cell_area` for each z-slice.\n\n" + "The measurements will be saved in the column with name\n" + "ending with `_zsliceN` where N is the z-slice number\n" + "(starting from 0)." ) + calcForEachZsliceLabel = QClickableLabel("Calculate for each z-slice") calcForEachZsliceLabel.setToolTip(tooltip) self.calcForEachZsliceToggle.setToolTip(tooltip) buttonsLayout.addWidget(self.calcForEachZsliceToggle) buttonsLayout.addWidget(calcForEachZsliceLabel) - buttonsLayout.addStretch(1) + buttonsLayout.addStretch(1) layout.addLayout(buttonsLayout) calcForEachZsliceLabel.clicked.connect( partial( - self.toggleCalcForEachZslice, - toggle=self.calcForEachZsliceToggle + self.toggleCalcForEachZslice, toggle=self.calcForEachZsliceToggle ) ) @@ -4540,42 +4730,42 @@ def __init__( self.setFont(_font) self.toggled.connect(self.toggled_cb) - + def toggleCalcForEachZslice(self, label, toggle=None): if toggle is None: toggle = self.calcForEachZsliceToggle - + toggle.setChecked(not toggle.isChecked()) - + def isCalcForEachZsliceRequested(self): if self.calcForEachZsliceToggle is None: return False - + return self.calcForEachZsliceToggle.isChecked() - + def highlightCheckboxesFromSearchText(self, text): for checkbox in self.checkBoxes: if not text: highlighted = False else: highlighted = checkbox.text().lower().find(text.lower()) != -1 - + self.setCheckboxHighlighted(highlighted, checkbox) - + def setCheckboxHighlighted(self, highlighted, checkbox): if highlighted: checkbox.setStyleSheet( - f'background: {self._highlightStylesheetColor}; color: black' + f"background: {self._highlightStylesheetColor}; color: black" ) self.scrollArea.ensureWidgetVisible(checkbox) else: - checkbox.setStyleSheet('') - + checkbox.setStyleSheet("") + def onDelClicked(self): button = self.sender() button.checkbox.setChecked(False) self.sigDelClicked.emit(button.colname, button._layout) - + def toggled_cb(self, checked): for checkbox in self.checkBoxes: if not checked: @@ -4612,9 +4802,9 @@ def showInfo(self, checked=False): msg = myMessageBox() msg.setWidth(600) msg.setIcon() - msg.setWindowTitle(f'{self.sender().colname} info') + msg.setWindowTitle(f"{self.sender().colname} info") msg.addText(info_txt) - msg.addButton(' Ok ') + msg.addButton(" Ok ") msg.exec_() def show(self): @@ -4623,14 +4813,20 @@ def show(self): sw = self.scrollArea.verticalScrollBar().sizeHint().width() self.minWidth = fw + sw + class channelMetricsQGBox(QGroupBox): sigDelClicked = Signal(str, object) sigCheckboxToggled = Signal(object) def __init__( - self, isZstack, chName, isSegm3D, is_concat=False, - posData=None, favourite_funcs=None - ): + self, + isZstack, + chName, + isSegm3D, + is_concat=False, + posData=None, + favourite_funcs=None, + ): QGroupBox.__init__(self) self.doNotWarn = False @@ -4642,21 +4838,27 @@ def __init__( layout = QVBoxLayout() metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc( - isZstack, chName, isSegm3D=isSegm3D, - isManualBackgrPresent=isManualBackgrPresent + isZstack, + chName, + isSegm3D=isSegm3D, + isManualBackgrPresent=isManualBackgrPresent, ) metricsQGBox = _metricsQGBox( - metrics_desc, 'Standard measurements', - favourite_funcs=favourite_funcs, - parent=self, isZstack=isZstack + metrics_desc, + "Standard measurements", + favourite_funcs=favourite_funcs, + parent=self, + isZstack=isZstack, ) self.metricsQGBox = metricsQGBox - + bkgrValsQGBox = _metricsQGBox( - bkgr_val_desc, 'Background values', - favourite_funcs=favourite_funcs, - parent=self, isZstack=isZstack + bkgr_val_desc, + "Background values", + favourite_funcs=favourite_funcs, + parent=self, + isZstack=isZstack, ) self.bkgrValsQGBox = bkgrValsQGBox @@ -4670,7 +4872,7 @@ def __init__( for checkbox in metricsQGBox.checkBoxes: checkbox.toggled.connect(self.standardMetricToggled) self.standardMetricToggled(checkbox.isChecked(), checkbox=checkbox) - + for bkgrCheckbox in bkgrValsQGBox.checkBoxes: bkgrCheckbox.toggled.connect(self.backgroundMetricToggled) @@ -4678,17 +4880,17 @@ def __init__( layout.addWidget(bkgrValsQGBox) items = measurements.custom_metrics_desc( - isZstack, chName, posData=posData, isSegm3D=isSegm3D, - return_combine=True + isZstack, chName, posData=posData, isSegm3D=isSegm3D, return_combine=True ) custom_metrics_desc, combine_metrics_desc = items if custom_metrics_desc: customMetricsQGBox = _metricsQGBox( - custom_metrics_desc, 'Custom measurements', + custom_metrics_desc, + "Custom measurements", delButtonMetricsDesc=combine_metrics_desc, favourite_funcs=favourite_funcs, - isZstack=isZstack + isZstack=isZstack, ) layout.addWidget(customMetricsQGBox) self.checkBoxes.extend(customMetricsQGBox.checkBoxes) @@ -4700,44 +4902,40 @@ def __init__( buttonsLayout = QHBoxLayout() self.calcForEachZsliceToggle = Toggle() tooltip = ( - 'Calculate the selected measurements for each z-slice.\n\n' - 'The measurements will be saved in the column with name\n' - 'ending with `_zsliceN` where N is the z-slice number\n' - '(starting from 0).' - ) - calcForEachZsliceLabel = QClickableLabel( - 'Calculate for each z-slice' + "Calculate the selected measurements for each z-slice.\n\n" + "The measurements will be saved in the column with name\n" + "ending with `_zsliceN` where N is the z-slice number\n" + "(starting from 0)." ) + calcForEachZsliceLabel = QClickableLabel("Calculate for each z-slice") calcForEachZsliceLabel.setToolTip(tooltip) self.calcForEachZsliceToggle.setToolTip(tooltip) buttonsLayout.addWidget(self.calcForEachZsliceToggle) - buttonsLayout.addWidget(calcForEachZsliceLabel) - buttonsLayout.addStretch(1) + buttonsLayout.addWidget(calcForEachZsliceLabel) + buttonsLayout.addStretch(1) layout.addLayout(buttonsLayout) calcForEachZsliceLabel.clicked.connect( partial( - self.toggleCalcForEachZslice, - toggle=self.calcForEachZsliceToggle + self.toggleCalcForEachZslice, toggle=self.calcForEachZsliceToggle ) ) - - - self.setTitle(f'{chName} metrics') + + self.setTitle(f"{chName} metrics") self.setCheckable(True) self.setLayout(layout) - + def toggleCalcForEachZslice(self, label, toggle=None): if toggle is None: toggle = self.calcForEachZsliceToggle - + toggle.setChecked(not toggle.isChecked()) - + def isCalcForEachZsliceRequested(self): if self.calcForEachZsliceToggle is None: return False - + return self.calcForEachZsliceToggle.isChecked() - + def uncheckAndDisableDataPrepIfPosNotPrepped(self, posData): # Uncheck and disable dataprep metrics if pos is not prepped if posData is None: @@ -4747,12 +4945,12 @@ def uncheckAndDisableDataPrepIfPosNotPrepped(self, posData): return for checkbox in self.checkBoxes: - if checkbox.text().find('dataPrep') == -1: + if checkbox.text().find("dataPrep") == -1: continue checkbox.setChecked(False) checkbox.isDataPrepDisabled = True - + def _warnDataPrepCannotBeChecked(self): if self.doNotWarn: return @@ -4766,15 +4964,15 @@ def _warnDataPrepCannotBeChecked(self): Thank you for you patience! """) msg = myMessageBox(showCentered=False) - msg.warning(self, 'Metric cannot be saved', txt) + msg.warning(self, "Metric cannot be saved", txt) def standardMetricToggled(self, checked, checkbox=None): - """Method called when a check-box is toggled. It performs the following + """Method called when a check-box is toggled. It performs the following actions: - 1. If the user try to check a data prep measurement, such as - dataPrep_amount, and this cannot be saved (checkbox has the attr + 1. If the user try to check a data prep measurement, such as + dataPrep_amount, and this cannot be saved (checkbox has the attr `isDataPrepDisabled`) then it warns and explains why it cannot be saved - 2. Make sure that background value median is checked if the user + 2. Make sure that background value median is checked if the user requires amount or concentration metric. 3. Do not allow unchecking background value median and explain why. @@ -4783,16 +4981,16 @@ def standardMetricToggled(self, checked, checkbox=None): checked : bool State of the checkbox toggled checkbox : QtWidgets.QCheckBox, optional - The checkbox that has been toggled. Default is None. If None + The checkbox that has been toggled. Default is None. If None use `self.sender()` - """ + """ if self.is_concat: return - + if checkbox is None: checkbox = self.sender() - if hasattr(checkbox, 'isDataPrepDisabled'): + if hasattr(checkbox, "isDataPrepDisabled"): # Warn that user cannot check data prep metrics and uncheck it if not checkbox.isChecked(): return @@ -4801,10 +4999,10 @@ def standardMetricToggled(self, checked, checkbox=None): return self.sigCheckboxToggled.emit(checkbox) - if checkbox.text().find('amount_') == -1: + if checkbox.text().find("amount_") == -1: return - pattern = r'amount_([A-Za-z]+)(_?[A-Za-z0-9]*)' - repl = r'\g<1>_bkgrVal_median\g<2>' + pattern = r"amount_([A-Za-z]+)(_?[A-Za-z0-9]*)" + repl = r"\g<1>_bkgrVal_median\g<2>" bkgrValMetric = s1 = re.sub(pattern, repl, checkbox.text()) for bkgrCheckbox in self.groupboxes[1].checkBoxes: if bkgrCheckbox.text() == bkgrValMetric: @@ -4812,17 +5010,17 @@ def standardMetricToggled(self, checked, checkbox=None): else: # Make sure to not check for similarly named custom metrics return - + if checked: bkgrCheckbox.setChecked(True) bkgrCheckbox.isRequired = True else: bkgrCheckbox.setDisabled(False) bkgrCheckbox.isRequired = False - + def backgroundMetricToggled(self, checked): """Method called when a checkbox of a background metric is toggled. - Check if the background value is required and explain why it cannot be + Check if the background value is required and explain why it cannot be unchecked. Parameters @@ -4832,20 +5030,20 @@ def backgroundMetricToggled(self, checked): """ if self.is_concat: return - + checkbox = self.sender() - if not hasattr(checkbox, 'isRequired'): + if not hasattr(checkbox, "isRequired"): return - + if not checkbox.isRequired: return - + if checkbox.isChecked(): return - + if self.doNotWarn: return - + checkbox.setChecked(True) txt = html_utils.paragraph(""" This background value cannot be unchecked because it is required @@ -4855,80 +5053,80 @@ def backgroundMetricToggled(self, checked): Thank you for you patience! """) msg = myMessageBox(showCentered=False) - msg.warning(self, 'Background value required', txt) - + msg.warning(self, "Background value required", txt) + def onDelClicked(self, colname_to_del, hlayout): self.sigDelClicked.emit(colname_to_del, hlayout) - + def checkFavouriteFuncs(self): self.doNotWarn = True for groupbox in self.groupboxes: groupbox.checkFavouriteFuncs() self.doNotWarn = False + class PixelSizeGroupbox(QGroupBox): sigValueChanged = Signal(float, float, float) sigReset = Signal() - + def __init__(self, parent=None): - super().__init__('Pixel size', parent) - + super().__init__("Pixel size", parent) + mainLayout = QGridLayout() - + row = 0 - label = QLabel('Pixel width (μm): ') + label = QLabel("Pixel width (μm): ") self.pixelWidthWidget = FloatLineEdit(initial=1.0) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.pixelWidthWidget, row, 1) - + row += 1 - label = QLabel('Pixel height (μm): ') + label = QLabel("Pixel height (μm): ") self.pixelHeightWidget = FloatLineEdit(initial=1.0) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.pixelHeightWidget, row, 1) - + row += 1 - label = QLabel('Voxel depth (μm): ') + label = QLabel("Voxel depth (μm): ") self.voxelDepthWidget = FloatLineEdit(initial=1.0) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.voxelDepthWidget, row, 1) - + row += 1 - resetButton = reloadPushButton('Reset') - mainLayout.addWidget( - resetButton, row, 1, alignment=Qt.AlignRight - ) - + resetButton = reloadPushButton("Reset") + mainLayout.addWidget(resetButton, row, 1, alignment=Qt.AlignRight) + row += 1 mainLayout.addWidget(QHLine(), row, 0, 1, 2) - + mainLayout.setColumnStretch(0, 0) mainLayout.setColumnStretch(1, 1) self.setLayout(mainLayout) - + self.pixelWidthWidget.valueChanged.connect(self.emitValueChanged) self.pixelHeightWidget.valueChanged.connect(self.emitValueChanged) self.voxelDepthWidget.valueChanged.connect(self.emitValueChanged) resetButton.clicked.connect(self.emitReset) - + def emitReset(self): self.sigReset.emit() - + def emitValueChanged(self, value): PhysicalSizeX = self.pixelWidthWidget.value() PhysicalSizeY = self.pixelHeightWidget.value() PhysicalSizeZ = self.voxelDepthWidget.value() self.sigValueChanged.emit(PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) + class objPropsQGBox(QGroupBox): def __init__(self, parent=None): - QGroupBox.__init__(self, 'Properties', parent) + QGroupBox.__init__(self, "Properties", parent) mainLayout = QGridLayout() row = 0 - label = QLabel('Object ID: ') + label = QLabel("Object ID: ") self.idSB = IntLineEdit() mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.idSB, row, 1) @@ -4938,21 +5136,19 @@ def __init__(self, parent=None): row += 1 self.notExistingIDLabel = QLabel() - self.notExistingIDLabel.setStyleSheet( - 'font-size:11px; color: rgb(255, 0, 0);' - ) + self.notExistingIDLabel.setStyleSheet("font-size:11px; color: rgb(255, 0, 0);") mainLayout.addWidget( self.notExistingIDLabel, row, 0, 1, 2, alignment=Qt.AlignCenter ) row += 1 - label = QLabel('Area (pixel): ') + label = QLabel("Area (pixel): ") self.cellAreaPxlSB = IntLineEdit(readOnly=True) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.cellAreaPxlSB, row, 1) row += 1 - label = QLabel('Area (µm2): ') + label = QLabel("Area (µm2): ") self.cellAreaUm2DSB = FloatLineEdit(readOnly=True) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.cellAreaUm2DSB, row, 1) @@ -4961,26 +5157,26 @@ def __init__(self, parent=None): mainLayout.addWidget(QHLine(), row, 0, 1, 2) row += 1 - label = QLabel('Rotational volume (voxel): ') + label = QLabel("Rotational volume (voxel): ") self.cellVolVoxSB = IntLineEdit(readOnly=True) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.cellVolVoxSB, row, 1) row += 1 - label = QLabel('3D volume (voxel): ') + label = QLabel("3D volume (voxel): ") self.cellVolVox3D_SB = IntLineEdit(readOnly=True) self.cellVolVox3D_SB.label = label mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.cellVolVox3D_SB, row, 1) row += 1 - label = QLabel('Rotational volume (fl): ') + label = QLabel("Rotational volume (fl): ") self.cellVolFlDSB = FloatLineEdit(readOnly=True) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.cellVolFlDSB, row, 1) row += 1 - label = QLabel('3D volume (fl): ') + label = QLabel("3D volume (fl): ") self.cellVolFl3D_DSB = FloatLineEdit(readOnly=True) self.cellVolFl3D_DSB.label = label mainLayout.addWidget(label, row, 0) @@ -4990,14 +5186,14 @@ def __init__(self, parent=None): mainLayout.addWidget(QHLine(), row, 0, 1, 2) row += 1 - label = QLabel('Solidity: ') + label = QLabel("Solidity: ") self.solidityDSB = FloatLineEdit(readOnly=True) self.solidityDSB.setMaximum(1) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.solidityDSB, row, 1) row += 1 - label = QLabel('Elongation: ') + label = QLabel("Elongation: ") self.elongationDSB = FloatLineEdit(readOnly=True) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.elongationDSB, row, 1) @@ -5015,48 +5211,49 @@ def __init__(self, parent=None): row += 1 mainLayout.addWidget(QHLine(), row, 0, 1, 2) - + mainLayout.setColumnStretch(0, 0) mainLayout.setColumnStretch(1, 1) self.setLayout(mainLayout) + class objIntesityMeasurQGBox(QGroupBox): def __init__(self, parent=None): - QGroupBox.__init__(self, 'Intensity measurements', parent) + QGroupBox.__init__(self, "Intensity measurements", parent) mainLayout = QGridLayout() row = 0 - label = QLabel('Raw intensity measurements') + label = QLabel("Raw intensity measurements") row += 1 - label = QLabel('Channel: ') + label = QLabel("Channel: ") self.channelCombobox = QComboBox() - self.channelCombobox.addItem('placeholderlong') + self.channelCombobox.addItem("placeholderlong") mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.channelCombobox, row, 1) row += 1 - label = QLabel('Minimum: ') + label = QLabel("Minimum: ") self.minimumDSB = FloatLineEdit(readOnly=True) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.minimumDSB, row, 1) row += 1 - label = QLabel('Maximum: ') + label = QLabel("Maximum: ") self.maximumDSB = FloatLineEdit(readOnly=True) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.maximumDSB, row, 1) row += 1 - label = QLabel('Mean: ') + label = QLabel("Mean: ") self.meanDSB = FloatLineEdit(readOnly=True) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.meanDSB, row, 1) row += 1 - label = QLabel('Median: ') + label = QLabel("Median: ") self.medianDSB = FloatLineEdit(readOnly=True) mainLayout.addWidget(label, row, 0) mainLayout.addWidget(self.medianDSB, row, 1) @@ -5065,18 +5262,18 @@ def __init__(self, parent=None): metricsDesc = measurements._get_metrics_names() metricsFunc, _ = measurements.standard_metrics_func() items = list(set([metricsDesc[key] for key in metricsFunc.keys()])) - items.append('Concentration') + items.append("Concentration") items.sort() nameFuncDict = {} for name, desc in metricsDesc.items(): - if name.find('_dataPrepBkgr')!=-1 or name.find('_manualBkgr')!=-1: - # Skip dataPrepBkgr and manualBkgr since in the dock widget + if name.find("_dataPrepBkgr") != -1 or name.find("_manualBkgr") != -1: + # Skip dataPrepBkgr and manualBkgr since in the dock widget # we display only autoBkgr metrics continue - if name.startswith('concentration_'): - # We use amount function because dividing by volume is taken + if name.startswith("concentration_"): + # We use amount function because dividing by volume is taken # care in the GUI - name = 'amount_autoBkgr' + name = "amount_autoBkgr" nameFuncDict[desc] = metricsFunc[name] funcionCombobox = QComboBox() @@ -5093,12 +5290,13 @@ def addChannels(self, channels): self.channelCombobox.clear() self.channelCombobox.addItems(channels) + class guiTabControl(QTabWidget): def __init__(self, *args): super().__init__(args[0]) self._defaultPixelSize = None - + self.propsTab = QScrollArea(self) container = QWidget() @@ -5108,66 +5306,66 @@ def __init__(self, *args): self.propsQGBox = objPropsQGBox(parent=self.propsTab) self.intensMeasurQGBox = objIntesityMeasurQGBox(parent=self.propsTab) - self.highlightCheckbox = QCheckBox('Highlight objects on mouse hover') + self.highlightCheckbox = QCheckBox("Highlight objects on mouse hover") self.highlightCheckbox.setChecked(False) - - self.highlightSearchedCheckbox = QCheckBox('Highlight searched object') + + self.highlightSearchedCheckbox = QCheckBox("Highlight searched object") self.highlightSearchedCheckbox.setChecked(True) highlightLayout = QHBoxLayout() highlightLayout.addWidget(self.highlightCheckbox) highlightLayout.addStretch(1) - highlightLayout.addWidget(QLabel('|')) + highlightLayout.addWidget(QLabel("|")) highlightLayout.addStretch(1) highlightLayout.addWidget(self.highlightSearchedCheckbox) - + layout.addLayout(highlightLayout) layout.addWidget(self.pixelSizeQGBox) layout.addWidget(self.propsQGBox) - layout.addWidget(self.intensMeasurQGBox) - layout.addStretch(1) + layout.addWidget(self.intensMeasurQGBox) + layout.addStretch(1) container.setLayout(layout) self.propsTab.setWidgetResizable(True) self.propsTab.setWidget(container) - self.addTab(self.propsTab, 'Measurements') - + self.addTab(self.propsTab, "Measurements") + self.pixelSizeQGBox.sigValueChanged.connect(self.pixelSizeChanged) self.pixelSizeQGBox.sigReset.connect(self.resetPixelSize) - + def addChannels(self, channels): self.intensMeasurQGBox.addChannels(channels) - + def resetPixelSize(self): if self._defaultPixelSize is None: return - + self.initPixelSize(*self._defaultPixelSize) - + def initPixelSize(self, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ): self.pixelSizeQGBox.pixelWidthWidget.setValue(PhysicalSizeX) self.pixelSizeQGBox.pixelHeightWidget.setValue(PhysicalSizeY) self.pixelSizeQGBox.voxelDepthWidget.setValue(PhysicalSizeZ) self._defaultPixelSize = (PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) - + def pixelSizeChanged(self, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ): propsQGBox = self.propsQGBox - yx_pxl_to_um2 = PhysicalSizeY*PhysicalSizeX - vox_rot_to_fl = float(PhysicalSizeY)*pow(float(PhysicalSizeX), 2) - vox_3D_to_fl = PhysicalSizeZ*PhysicalSizeY*PhysicalSizeX - + yx_pxl_to_um2 = PhysicalSizeY * PhysicalSizeX + vox_rot_to_fl = float(PhysicalSizeY) * pow(float(PhysicalSizeX), 2) + vox_3D_to_fl = PhysicalSizeZ * PhysicalSizeY * PhysicalSizeX + area_pxl = propsQGBox.cellAreaPxlSB.value() - area_um2 = area_pxl*yx_pxl_to_um2 + area_um2 = area_pxl * yx_pxl_to_um2 propsQGBox.cellAreaUm2DSB.setValue(area_um2) - + vol_rot_vox = propsQGBox.cellVolVoxSB.value() - vol_rot_fl = vol_rot_vox*vox_rot_to_fl + vol_rot_fl = vol_rot_vox * vox_rot_to_fl propsQGBox.cellVolFlDSB.setValue(vol_rot_fl) - + vol_3D_vox = propsQGBox.cellVolVox3D_SB.value() - vol_3D_fl = vol_3D_vox*vox_3D_to_fl + vol_3D_fl = vol_3D_vox * vox_3D_to_fl propsQGBox.cellVolFl3D_DSB.setValue(vol_3D_fl) - + class expandCollapseButton(PushButton): sigClicked = Signal() @@ -5185,12 +5383,12 @@ def buttonClicked(self, checked=False): self.setIcon(QIcon(":collapse.svg")) self.isExpand = False if self.text(): - self.setText(self.text().replace('Hide', 'Show')) + self.setText(self.text().replace("Hide", "Show")) else: self.setIcon(QIcon(":expand.svg")) self.isExpand = True if self.text(): - self.setText(self.text().replace('Show', 'Hide')) + self.setText(self.text().replace("Show", "Hide")) self.sigClicked.emit() def eventFilter(self, object, event): @@ -5200,74 +5398,80 @@ def eventFilter(self, object, event): self.setFlat(True) return False + class view_visualcpp_screenshot(QDialog): def __init__(self, parent=None): super().__init__(parent) layout = QHBoxLayout() - self.setWindowTitle('Visual Studio Builld Tools installation') + self.setWindowTitle("Visual Studio Builld Tools installation") - pixmap = QPixmap(':visualcpp.png') + pixmap = QPixmap(":visualcpp.png") label = QLabel() label.setPixmap(pixmap) layout.addWidget(label) self.setLayout(layout) + class PolyLineROI(pg.PolyLineROI): def __init__(self, positions, closed=False, pos=None, **args): super().__init__(positions, closed, pos, **args) + class BaseGradientEditorItemImage(pg.GradientEditorItem): def __init__(self, *args, **kargs): super().__init__(*args, **kargs) - + def restoreState(self, state): pg.graphicsItems.GradientEditorItem.Gradients = GradientsImage return super().restoreState(state) + class MouseCursor(QWidget): def __init__(self, parent=None) -> None: super().__init__(parent) self._x = None self._y = None self.setMouseTracking(True) - + def mouseMoveEvent(self, event) -> None: self.move(event.pos()) self.update() return super().mouseMoveEvent(event) - + # def drawAtPos(self, x, y): # self._x = x # self._y = y # self.update() - + def paintEvent(self, event) -> None: p = QPainter(self) # p.setPen(QPen(QColor(0,0,0))) # p.setBrush(QBrush(QColor(70,70,70,200))) - p.drawLine(0,0,200,0) + p.drawLine(0, 0, 200, 0) p.end() + class BaseGradientEditorItemLabels(pg.GradientEditorItem): def __init__(self, *args, **kargs): super().__init__(*args, **kargs) - + def restoreState(self, state): pg.graphicsItems.GradientEditorItem.Gradients = GradientsLabels return super().restoreState(state) + class baseHistogramLUTitem(pg.HistogramLUTItem): sigAddColormap = Signal(object, str) sigRescaleIntes = Signal(object) - def __init__(self, name='image', axisLabel='', parent=None, **kwargs): + def __init__(self, name="image", axisLabel="", parent=None, **kwargs): pg.GradientEditorItem = BaseGradientEditorItemLabels super().__init__(**kwargs) - self.labelStyle = {'color': '#ffffff', 'font-size': '11px'} + self.labelStyle = {"color": "#ffffff", "font-size": "11px"} if axisLabel: self.setAxisLabel(axisLabel) @@ -5276,84 +5480,74 @@ def __init__(self, name='image', axisLabel='', parent=None, **kwargs): self._parent = parent self.name = name - self.gradient.colorDialog.setWindowFlags( - Qt.Dialog | Qt.WindowStaysOnTopHint - ) + self.gradient.colorDialog.setWindowFlags(Qt.Dialog | Qt.WindowStaysOnTopHint) self.gradient.colorDialog.accepted.disconnect() self.gradient.colorDialog.accepted.connect(self.tickColorAccepted) self.isInverted = False - self.lastGradientName = 'grey' - self.lastGradient = Gradients['grey'] + self.lastGradientName = "grey" + self.lastGradient = Gradients["grey"] for action in self.gradient.menu.actions(): - if action.text() == 'HSV': + if action.text() == "HSV": HSV_action = action - elif action.text() == 'RGB': + elif action.text() == "RGB": RGB_ation = action self.gradient.menu.removeAction(HSV_action) self.gradient.menu.removeAction(RGB_ation) - + # Rescale intensities (LUT) - rescaleIntensMenu = self.gradient.menu.addMenu( - 'Rescale intensities (LUT)' - ) + rescaleIntensMenu = self.gradient.menu.addMenu("Rescale intensities (LUT)") rescaleActionGroup = QActionGroup(self) rescaleActionGroup.setExclusive(True) - + self.rescaleEach2DimgAction = QAction( - 'Rescale each 2D image', rescaleIntensMenu + "Rescale each 2D image", rescaleIntensMenu ) self.rescaleEach2DimgAction.setCheckable(True) self.rescaleEach2DimgAction.setChecked(True) rescaleActionGroup.addAction(self.rescaleEach2DimgAction) rescaleIntensMenu.addAction(self.rescaleEach2DimgAction) - + self.rescaleAcrossZstackAction = QAction( - 'Rescale across z-stack', rescaleIntensMenu + "Rescale across z-stack", rescaleIntensMenu ) self.rescaleAcrossZstackAction.setCheckable(True) self.rescaleAcrossZstackAction.setChecked(False) rescaleActionGroup.addAction(self.rescaleAcrossZstackAction) rescaleIntensMenu.addAction(self.rescaleAcrossZstackAction) - + self.rescaleAcrossTimeAction = QAction( - 'Rescale across time frames', rescaleIntensMenu + "Rescale across time frames", rescaleIntensMenu ) self.rescaleAcrossTimeAction.setCheckable(True) self.rescaleAcrossTimeAction.setChecked(False) rescaleActionGroup.addAction(self.rescaleAcrossTimeAction) rescaleIntensMenu.addAction(self.rescaleAcrossTimeAction) - - self.customRescaleAction = QAction( - 'Choose custom levels...', rescaleIntensMenu - ) + + self.customRescaleAction = QAction("Choose custom levels...", rescaleIntensMenu) self.customRescaleAction.setCheckable(True) rescaleActionGroup.addAction(self.customRescaleAction) rescaleIntensMenu.addAction(self.customRescaleAction) - + self.doNotRescaleAction = QAction( - 'Do no rescale, display raw image', rescaleIntensMenu + "Do no rescale, display raw image", rescaleIntensMenu ) self.doNotRescaleAction.setCheckable(True) rescaleActionGroup.addAction(self.doNotRescaleAction) rescaleIntensMenu.addAction(self.doNotRescaleAction) - + self.rescaleActionGroup = rescaleActionGroup rescaleActionGroup.triggered.connect(self.rescaleActionTriggered) # Add custom colormap action - self.customCmapsMenu = self.gradient.menu.addMenu('Custom colormaps') + self.customCmapsMenu = self.gradient.menu.addMenu("Custom colormaps") self.customCmapsMenu.aboutToShow.connect(self.onShowCustomCmapsMenu) self.customCmapsMenu.triggered.connect(self.customCmapsMenuTriggered) - - self.saveColormapAction = QAction( - 'Save current colormap...', self - ) + + self.saveColormapAction = QAction("Save current colormap...", self) self.gradient.menu.addAction(self.saveColormapAction) - self.saveColormapAction.triggered.connect( - self.saveColormap - ) + self.saveColormapAction.triggered.connect(self.saveColormap) self.addCustomGradients() @@ -5364,38 +5558,38 @@ def __init__(self, name='image', axisLabel='', parent=None, **kwargs): # hide histogram tool self.vb.hide() - + # Disable moving the axis up and down self.axis.unlinkFromView() # Disable histogram default context Menu event self.vb.raiseContextMenu = lambda x: None - + def rescaleActionTriggered(self, action): self.sigRescaleIntes.emit(action) - + def onShowCustomCmapsMenu(self): self.customCmapsMenu.show() - + def customCmapsMenuTriggered(self, action): cmap = action.cmap self.gradient.colorMapMenuClicked(cmap) self.gradient.showTicks(True) - + def setAxisLabel(self, text): self.labelText = text self.axis.setLabel(text, **self.labelStyle) - + def updateAxisLabel(self): text = self.axis.label.toPlainText() if not text: return self.setAxisLabel(text) - + def setGradient(self, gradient): self.gradient.restoreState(gradient) self.lastGradient = gradient - + def colormapClicked(self, checked=False, name=None): name = self.sender().name self.lastGradientName = name @@ -5407,43 +5601,42 @@ def colormapClicked(self, checked=False, name=None): def sortTicks(self, ticks): sortedTicks = sorted(ticks, key=operator.itemgetter(0)) return sortedTicks - + def getInvertedGradients(self): invertedGradients = {} for name, gradient in Gradients.items(): - ticks = gradient['ticks'] + ticks = gradient["ticks"] sortedTicks = self.sortTicks(ticks) if name in nonInvertibleCmaps: invertedColors = sortedTicks else: invertedColors = [ - (t[0], ti[1]) - for t, ti in zip(sortedTicks, sortedTicks[::-1]) + (t[0], ti[1]) for t, ti in zip(sortedTicks, sortedTicks[::-1]) ] invertedGradient = {} - invertedGradient['ticks'] = invertedColors - invertedGradient['mode'] = gradient['mode'] + invertedGradient["ticks"] = invertedColors + invertedGradient["mode"] = gradient["mode"] invertedGradients[name] = invertedGradient return invertedGradients - + def addInvertedColorMaps(self): self.invertedGradients = self.getInvertedGradients() for action in self.gradient.menu.actions(): - if not hasattr(action, 'name'): + if not hasattr(action, "name"): continue - + if action.name not in self.cmaps: continue - + action.triggered.disconnect() action.triggered.connect(self.colormapClicked) px = QPixmap(100, 15) p = QPainter(px) invertedGradient = self.invertedGradients[action.name] - qtGradient = QLinearGradient(QPointF(0,0), QPointF(100,0)) - ticks = self.sortTicks(invertedGradient['ticks']) - qtGradient.setStops([(x, QColor(*color)) for x,color in ticks]) + qtGradient = QLinearGradient(QPointF(0, 0), QPointF(100, 0)) + ticks = self.sortTicks(invertedGradient["ticks"]) + qtGradient.setStops([(x, QColor(*color)) for x, color in ticks]) brush = QBrush(qtGradient) p.fillRect(QRect(0, 0, 100, 15), brush) p.end() @@ -5453,21 +5646,21 @@ def addInvertedColorMaps(self): rectLabelWidget.setPixmap(px) hbox.addWidget(rectLabelWidget) rectLabelWidget.hide() - + def setInvertedColorMaps(self, inverted): if inverted: showIdx = 2 hideIdx = 1 - self.labelStyle['color'] = '#000000' + self.labelStyle["color"] = "#000000" else: showIdx = 1 hideIdx = 2 - self.labelStyle['color'] = '#ffffff' - + self.labelStyle["color"] = "#ffffff" + for action in self.gradient.menu.actions(): - if not hasattr(action, 'name'): + if not hasattr(action, "name"): continue - + if action.name not in self.cmaps: continue @@ -5477,25 +5670,24 @@ def setInvertedColorMaps(self, inverted): showCmapRect = hbox.itemAt(showIdx).widget() hideCmapRect.hide() showCmapRect.show() - + self.updateAxisLabel() self.isInverted = inverted - + def invertGradient(self, gradient): - ticks = gradient['ticks'] + ticks = gradient["ticks"] sortedTicks = self.sortTicks(ticks) invertedColors = [ - (t[0], ti[1]) - for t, ti in zip(sortedTicks, sortedTicks[::-1]) + (t[0], ti[1]) for t, ti in zip(sortedTicks, sortedTicks[::-1]) ] invertedGradient = {} - invertedGradient['ticks'] = invertedColors - invertedGradient['mode'] = gradient['mode'] + invertedGradient["ticks"] = invertedColors + invertedGradient["mode"] = gradient["mode"] return invertedGradient - + def invertCurrentColormap(self, inverted, debug=False): self.setGradient(self.invertGradient(self.lastGradient)) - + def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): self.originalLength = self.gradient.length self.gradient.length = 100 @@ -5505,25 +5697,25 @@ def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): action = CustomGradientMenuAction(gradient, gradient_name, self.gradient) # action.triggered.connect(self.gradient.contextMenuClicked) action.delButton.clicked.connect(self.removeCustomGradient) - action.cmap = colors.pg_ticks_to_colormap(gradient_ticks['ticks']) + action.cmap = colors.pg_ticks_to_colormap(gradient_ticks["ticks"]) # self.gradient.menu.insertAction(self.saveColormapAction, action) self.customCmapsMenu.addAction(action) self.gradient.length = self.originalLength GradientsImage[gradient_name] = gradient_ticks - + def removeCustomGradient(self): button = self.sender() action = button.action self.customCmapsMenu.removeAction(action) cp = config.ConfigParser() cp.read(custom_cmaps_filepath) - cp.remove_section(f'image.{action.name}') - with open(custom_cmaps_filepath, mode='w') as file: + cp.remove_section(f"image.{action.name}") + with open(custom_cmaps_filepath, mode="w") as file: cp.write(file) - + def addCustomGradients(self): try: - CustomGradients = getCustomGradients(name='image') + CustomGradients = getCustomGradients(name="image") if not CustomGradients: return for gradient_name, gradient_ticks in CustomGradients.items(): @@ -5531,76 +5723,107 @@ def addCustomGradients(self): except Exception as e: printl(traceback.format_exc()) pass - + def _askNameColormap(self): - inputWin = apps.QInput(parent=self._parent, title='Colormap name') - inputWin.askText('Insert a name for the colormap: ', allowEmpty=False) + inputWin = apps.QInput(parent=self._parent, title="Colormap name") + inputWin.askText("Insert a name for the colormap: ", allowEmpty=False) if inputWin.cancel: return cmapName = inputWin.answer return cmapName - + def saveColormap(self): cmapName = self._askNameColormap() if cmapName is None: return - + cp = config.ConfigParser() if os.path.exists(custom_cmaps_filepath): cp.read(custom_cmaps_filepath) - - SECTION = f'{self.name}.{cmapName}' + + SECTION = f"{self.name}.{cmapName}" cp[SECTION] = {} # gradient_ticks = [] state = self.gradient.saveState() for key, value in state.items(): - if key != 'ticks': + if key != "ticks": continue for t, tick in enumerate(value): pos, rgb = tick # gradient_ticks.append((pos, rgb)) - rgb = ','.join([str(c) for c in rgb]) - val = f'{pos},{rgb}' - cp[SECTION][f'tick_{t}_pos_rgb'] = val - - with open(custom_cmaps_filepath, mode='w') as file: + rgb = ",".join([str(c) for c in rgb]) + val = f"{pos},{rgb}" + cp[SECTION][f"tick_{t}_pos_rgb"] = val + + with open(custom_cmaps_filepath, mode="w") as file: cp.write(file) - + self.addCustomGradient(cmapName, state, restore=False) - + def tickColorAccepted(self): self.gradient.currentColorAccepted() # self.sigTickColorAccepted.emit(self.gradient.colorDialog.color().getRgb()) - + def setRescaleIntensitiesHow(self, how): for action in self.rescaleActionGroup.actions(): if action.text() == how: action.setChecked(True) return + class ROI(pg.ROI): def __init__( - self, pos, size=pg.Point(1, 1), angle=0, invertible=False, - maxBounds=None, snapSize=1, scaleSnap=False, translateSnap=False, - rotateSnap=False, parent=None, pen=None, hoverPen=None, - handlePen=None, handleHoverPen=None, movable=True, rotatable=True, - resizable=True, removable=False, aspectLocked=False - ): + self, + pos, + size=pg.Point(1, 1), + angle=0, + invertible=False, + maxBounds=None, + snapSize=1, + scaleSnap=False, + translateSnap=False, + rotateSnap=False, + parent=None, + pen=None, + hoverPen=None, + handlePen=None, + handleHoverPen=None, + movable=True, + rotatable=True, + resizable=True, + removable=False, + aspectLocked=False, + ): super().__init__( - pos, size, angle, invertible, maxBounds, snapSize, scaleSnap, - translateSnap, rotateSnap, parent, pen, hoverPen, handlePen, - handleHoverPen, movable, rotatable, resizable, removable, - aspectLocked + pos, + size, + angle, + invertible, + maxBounds, + snapSize, + scaleSnap, + translateSnap, + rotateSnap, + parent, + pen, + hoverPen, + handlePen, + handleHoverPen, + movable, + rotatable, + resizable, + removable, + aspectLocked, ) - + def slice(self, zRange=None, tRange=None): x0, y0 = [int(round(c)) for c in self.pos()] w, h = [int(round(c)) for c in self.size()] - xmin, xmax = x0, x0+w + xmin, xmax = x0, x0 + w if xmin > xmax: xmin, xmax = xmax, xmin - ymin, ymax = y0, y0+h + ymin, ymax = y0, y0 + h if ymin > ymax: ymin, ymax = ymax, ymin if zRange is not None: @@ -5616,43 +5839,46 @@ def slice(self, zRange=None, tRange=None): def bbox(self): x0, y0 = [int(round(c)) for c in self.pos()] w, h = [int(round(c)) for c in self.size()] - xmin, xmax = x0, x0+w + xmin, xmax = x0, x0 + w if xmin > xmax: xmin, xmax = xmax, xmin - ymin, ymax = y0, y0+h + ymin, ymax = y0, y0 + h if ymin > ymax: ymin, ymax = ymax, ymin - + return ymin, xmin, ymax, xmax + class ZoomROI(ROI): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + self.viewRangesQueue = deque() - + def getLastRange(self): xRange, yRange = self.viewRangesQueue.pop() return xRange, yRange - + def storeLastRange(self, xRange, yRange): self.viewRangesQueue.append((xRange, yRange)) + class DelROI(pg.ROI): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + def clearPoints(self): """ Remove all handles and segments. """ while len(self.handles) > 0: - self.removeHandle(self.handles[0]['item']) + self.removeHandle(self.handles[0]["item"]) -class PlotCurveItem(pg.PlotCurveItem): + +class PlotCurveItem(pg.PlotCurveItem): def __init__(self, *args, **kargs): super().__init__(*args, **kargs) - + def addPoint(self, x, y, **kargs): _xx, _yy = self.getData() if _xx is None or len(_xx) == 0: @@ -5662,36 +5888,35 @@ def addPoint(self, x, y, **kargs): if _xx[-1] == x and _yy[-1] == y: # Do not append same point return - + # Pre-allocate array and insert data (faster than append) - xx = np.zeros(len(_xx)+1, dtype=_xx.dtype) + xx = np.zeros(len(_xx) + 1, dtype=_xx.dtype) xx[:-1] = _xx xx[-1] = x - yy = np.zeros(len(_yy)+1, dtype=_xx.dtype) + yy = np.zeros(len(_yy) + 1, dtype=_xx.dtype) yy[:-1] = _yy yy[-1] = y self.setData(xx, yy, **kargs) - + def clear(self): try: self.setData([], []) except Exception as e: pass super().clear() - - + def closeCurve(self): _xx, _yy = self.getData() self.addPoint(_xx[0], _yy[0]) - + def mask(self): ymin, xmin, ymax, xmax = self.bbox() - _mask = np.zeros((ymax-ymin+1, xmax-xmin+1), dtype=bool) + _mask = np.zeros((ymax - ymin + 1, xmax - xmin + 1), dtype=bool) local_xx, local_yy = self.getLocalData() rr, cc = skimage.draw.polygon(local_yy, local_xx) _mask[rr, cc] = True return _mask - + def getLocalData(self): _xx, _yy = self.getData() return _xx - _xx.min(), _yy - _yy.min() @@ -5700,25 +5925,26 @@ def slice(self, zRange=None, tRange=None): ymin, xmin, ymax, xmax = self.bbox() if zRange is not None: zmin, zmax = zRange - _slice = (slice(zmin, zmax), slice(ymin, ymax+1), slice(xmin, xmax+1)) + _slice = (slice(zmin, zmax), slice(ymin, ymax + 1), slice(xmin, xmax + 1)) else: - _slice = (slice(ymin, ymax+1), slice(xmin, xmax+1)) + _slice = (slice(ymin, ymax + 1), slice(xmin, xmax + 1)) if tRange is not None: tmin, tmax = tRange _slice = (slice(tmin, tmax), *_slice) return _slice - + def bbox(self): _xx, _yy = self.getData() return _yy.min(), _xx.min(), _yy.max(), _xx.max() + class ToggleVisibilityButton(PushButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setFlat(True) # self.setCheckable(True) self._state = False - self.setIcon(QIcon(':unchecked.svg')) + self.setIcon(QIcon(":unchecked.svg")) self.clicked.connect(self.onClicked) self.setStyleSheet(""" QPushButton::pressed { @@ -5726,13 +5952,14 @@ def __init__(self, *args, **kwargs): border-style: none; } """) - + def onClicked(self): self._state = not self._state if self._state: - self.setIcon(QIcon(':eye-checked.svg')) + self.setIcon(QIcon(":eye-checked.svg")) else: - self.setIcon(QIcon(':unchecked.svg')) + self.setIcon(QIcon(":unchecked.svg")) + class ToggleVisibilityCheckBox(QCheckBox): def __init__(self, *args, pixelSize=24): @@ -5740,10 +5967,10 @@ def __init__(self, *args, pixelSize=24): self._pixelSize = pixelSize self.onToggled(False) self.toggled.connect(self.onToggled) - + def setPixelSize(self, pixelSize): self._pixelSize = pixelSize - + def onToggled(self, checked): if checked: self.setStyleSheet(f""" @@ -5779,48 +6006,45 @@ class myHistogramLUTitem(baseHistogramLUTitem): sigAddTimestamp = Signal(bool) def __init__( - self, parent=None, name='image', axisLabel='', isViewer=False, - **kwargs - ): - super().__init__( - parent=parent, name=name, axisLabel=axisLabel, **kwargs - ) + self, parent=None, name="image", axisLabel="", isViewer=False, **kwargs + ): + super().__init__(parent=parent, name=name, axisLabel=axisLabel, **kwargs) self.name = name self._parent = parent - + self.childLutItem = None self.isViewer = isViewer if isViewer: # In the viewer we don't allow additional settings from the menu return - + # Add scale bar action - self.addScaleBarAction = QAction('Add scale bar', self) + self.addScaleBarAction = QAction("Add scale bar", self) self.addScaleBarAction.setCheckable(True) self.addScaleBarAction.triggered.connect(self.emitAddScaleBar) self.gradient.menu.addAction(self.addScaleBarAction) - + # Add timestamp action - self.addTimestampAction = QAction('Add timestamp', self) + self.addTimestampAction = QAction("Add timestamp", self) self.addTimestampAction.setCheckable(True) self.addTimestampAction.triggered.connect(self.emitAddTimestamp) self.gradient.menu.addAction(self.addTimestampAction) # Invert bw action - self.invertBwAction = QAction('Invert black/white', self) + self.invertBwAction = QAction("Invert black/white", self) self.invertBwAction.setCheckable(True) self.gradient.menu.addAction(self.invertBwAction) # Font size menu action - self.fontSizeMenu = QMenu('Text font size') - self.gradient.menu.addMenu(self.fontSizeMenu) + self.fontSizeMenu = QMenu("Text font size") + self.gradient.menu.addMenu(self.fontSizeMenu) # Text color button hbox = QHBoxLayout() - hbox.addWidget(QLabel('Text color: ')) - self.textColorButton = myColorButton(color=(255,255,255)) + hbox.addWidget(QLabel("Text color: ")) + self.textColorButton = myColorButton(color=(255, 255, 255)) hbox.addStretch(1) hbox.addWidget(self.textColorButton) widget = QWidget() @@ -5831,7 +6055,7 @@ def __init__( self.gradient.menu.addAction(act) # Contours line weight - contLineWeightMenu = QMenu('Contours line weight', self.gradient.menu) + contLineWeightMenu = QMenu("Contours line weight", self.gradient.menu) self.contLineWightActionGroup = QActionGroup(self) self.contLineWightActionGroup.setExclusionPolicy( QActionGroup.ExclusionPolicy.Exclusive @@ -5848,8 +6072,8 @@ def __init__( # Contours color button hbox = QHBoxLayout() - hbox.addWidget(QLabel('Contours color: ')) - self.contoursColorButton = myColorButton(color=(25,25,25)) + hbox.addWidget(QLabel("Contours color: ")) + self.contoursColorButton = myColorButton(color=(25, 25, 25)) hbox.addStretch(1) hbox.addWidget(self.contoursColorButton) widget = QWidget() @@ -5860,7 +6084,7 @@ def __init__( self.gradient.menu.addAction(act) # Mother-bud line weight - mothBudLineWeightMenu = QMenu('Mother-bud line weight', self.gradient.menu) + mothBudLineWeightMenu = QMenu("Mother-bud line weight", self.gradient.menu) self.mothBudLineWightActionGroup = QActionGroup(self) self.mothBudLineWightActionGroup.setExclusionPolicy( QActionGroup.ExclusionPolicy.Exclusive @@ -5877,8 +6101,8 @@ def __init__( # Mother-bud line color hbox = QHBoxLayout() - hbox.addWidget(QLabel('Mother-bud line color: ')) - self.mothBudLineColorButton = myColorButton(color=(255,0,0)) + hbox.addWidget(QLabel("Mother-bud line color: ")) + self.mothBudLineColorButton = myColorButton(color=(255, 0, 0)) hbox.addStretch(1) hbox.addWidget(self.mothBudLineColorButton) widget = QWidget() @@ -5889,20 +6113,19 @@ def __init__( self.gradient.menu.addAction(act) self.labelsAlphaMenu = self.gradient.menu.addMenu( - 'Segm. masks overlay alpha...' + "Segm. masks overlay alpha..." ) # self.labelsAlphaMenu.setDisabled(True) hbox = QHBoxLayout() self.labelsAlphaSlider = sliderWithSpinBox( - title='Alpha', title_loc='in_line', isFloat=True, - normalize=True + title="Alpha", title_loc="in_line", isFloat=True, normalize=True ) self.labelsAlphaSlider.setMaximum(100) self.labelsAlphaSlider.setSingleStep(0.05) self.labelsAlphaSlider.setValue(0.3) hbox.addWidget(self.labelsAlphaSlider) - shortCutText = 'Command+Up/Down' if is_mac else 'Ctrl+Up/Down' - hbox.addWidget(QLabel(f'({shortCutText})')) + shortCutText = "Command+Up/Down" if is_mac else "Ctrl+Up/Down" + hbox.addWidget(QLabel(f"({shortCutText})")) widget = QWidget() widget.setLayout(hbox) act = QWidgetAction(self) @@ -5911,7 +6134,7 @@ def __init__( self.labelsAlphaMenu.addAction(act) # Default settings - self.defaultSettingsAction = QAction('Restore default settings...', self) + self.defaultSettingsAction = QAction("Restore default settings...", self) self.gradient.menu.addAction(self.defaultSettingsAction) self.filterObject = FilterObject() @@ -5919,32 +6142,30 @@ def __init__( self.gradient.menu.installEventFilter(self.filterObject) self.highlightedAction = None self.lastHoveredAction = None - + def setChildLutItem(self, childLutItem): self.childLutItem = childLutItem - + def removeAddScaleBarAction(self): self.gradient.menu.removeAction(self.addScaleBarAction) - + def removeAddTimestampAction(self): self.gradient.menu.removeAction(self.addTimestampAction) - + def emitAddScaleBar(self): self.sigAddScaleBar.emit(self.addScaleBarAction.isChecked()) - + def emitAddTimestamp(self): self.sigAddTimestamp.emit(self.addTimestampAction.isChecked()) - + def gradientChanged(self): super().gradientChanged() self.sigGradientChanged.emit(self) - + def gradientMenuEventFilter(self, object, event): if event.type() == QEvent.Type.MouseMove: hoveredAction = self.gradient.menu.actionAt(event.pos()) - isActionEntered = ( - hoveredAction != self.lastHoveredAction - ) + isActionEntered = hoveredAction != self.lastHoveredAction if isActionEntered: if isinstance(hoveredAction, highlightableQWidgetAction): # print('Entered a custom action') @@ -5952,21 +6173,19 @@ def gradientMenuEventFilter(self, object, event): isActionLeft = ( self.highlightedAction is not None and self.highlightedAction != hoveredAction - ) + ) if isActionLeft: - if isinstance( - self.highlightedAction, highlightableQWidgetAction - ): + if isinstance(self.highlightedAction, highlightableQWidgetAction): # print('Left a custom action') pass self.highlightedAction = hoveredAction self.lastHoveredAction = hoveredAction - + def addOverlayColorButton(self, rgbColor, channelName): # Overlay color button hbox = QHBoxLayout() - hbox.addWidget(QLabel('Overlay color: ')) + hbox.addWidget(QLabel("Overlay color: ")) self.overlayColorButton = myColorButton(color=rgbColor) self.overlayColorButton.channel = channelName hbox.addStretch(1) @@ -5995,104 +6214,104 @@ def uncheckMothBudLineLineWeightActions(self): act.setChecked(False) def restoreState(self, df): - if 'textIDsColor' in df.index: - rgbString = df.at['textIDsColor', 'value'] + if "textIDsColor" in df.index: + rgbString = df.at["textIDsColor", "value"] r, g, b = colors.rgb_str_to_values(rgbString) self.textColorButton.setColor((r, g, b)) - if 'contLineColor' in df.index: - rgba_str = df.at['contLineColor', 'value'] + if "contLineColor" in df.index: + rgba_str = df.at["contLineColor", "value"] rgb = colors.rgba_str_to_values(rgba_str)[:3] self.contoursColorButton.setColor(rgb) - - if 'contLineWeight' in df.index: - w = df.at['contLineWeight', 'value'] + + if "contLineWeight" in df.index: + w = df.at["contLineWeight", "value"] w = int(w) for action in self.contLineWightActionGroup.actions(): if action.lineWeight == w: action.setChecked(True) break - - if 'mothBudLineWeight' in df.index: - w = df.at['mothBudLineWeight', 'value'] + + if "mothBudLineWeight" in df.index: + w = df.at["mothBudLineWeight", "value"] w = int(w) for action in self.mothBudLineWightActionGroup.actions(): if action.lineWeight == w: action.setChecked(True) break - if 'overlaySegmMasksAlpha' in df.index: - alpha = df.at['overlaySegmMasksAlpha', 'value'] + if "overlaySegmMasksAlpha" in df.index: + alpha = df.at["overlaySegmMasksAlpha", "value"] self.labelsAlphaSlider.setValue(float(alpha)) - - if 'mothBudLineColor' in df.index: - rgba_str = df.at['mothBudLineColor', 'value'] + + if "mothBudLineColor" in df.index: + rgba_str = df.at["mothBudLineColor", "value"] rgb = colors.rgba_str_to_values(rgba_str)[:3] self.mothBudLineColorButton.setColor(rgb) - - checked = df.at['is_bw_inverted', 'value'] == 'Yes' + + checked = df.at["is_bw_inverted", "value"] == "Yes" self.invertBwAction.setChecked(checked) self.restoreColormap(df) - + def saveState(self, df): # remove previous state - df = df[~df.index.str.contains('img_cmap')].copy() + df = df[~df.index.str.contains("img_cmap")].copy() state = self.gradient.saveState() for key, value in state.items(): - if key == 'ticks': + if key == "ticks": for t, tick in enumerate(value): pos, rgb = tick - df.at[f'img_cmap_tick{t}_rgb', 'value'] = rgb - df.at[f'img_cmap_tick{t}_pos', 'value'] = pos + df.at[f"img_cmap_tick{t}_rgb", "value"] = rgb + df.at[f"img_cmap_tick{t}_pos", "value"] = pos else: if isinstance(value, bool): - value = 'Yes' if value else 'No' - df.at[f'img_cmap_{key}', 'value'] = value + value = "Yes" if value else "No" + df.at[f"img_cmap_{key}", "value"] = value return df - + def restoreColormap(self, df): - state = {'mode': 'rgb', 'ticksVisible': True, 'ticks': []} + state = {"mode": "rgb", "ticksVisible": True, "ticks": []} ticks_pos = {} ticks_rgb = {} stateFound = False for setting, value in df.itertuples(): - idx = setting.find('img_cmap_') + idx = setting.find("img_cmap_") if idx == -1: continue stateFound = True - m = re.findall(r'tick(\d+)_(\w+)', setting) + m = re.findall(r"tick(\d+)_(\w+)", setting) if m: tick_idx, tick_type = m[0] - if tick_type == 'pos': + if tick_type == "pos": ticks_pos[int(tick_idx)] = float(value) - elif tick_type == 'rgb': + elif tick_type == "rgb": ticks_rgb[int(tick_idx)] = colors.rgba_str_to_values(value) else: key = setting[9:] - if value == 'Yes': + if value == "Yes": value = True - elif value == 'No': + elif value == "No": value = False state[key] = value if stateFound: - ticks = [(0, 0)]*len(ticks_pos) + ticks = [(0, 0)] * len(ticks_pos) for idx, val in ticks_pos.items(): pos = val rgb = ticks_rgb[idx] ticks[idx] = (pos, rgb) - state['ticks'] = ticks + state["ticks"] = ticks self.gradient.restoreState(state) def regionChanged(self): super().regionChanged() if self.childLutItem is None: return - + imageItem = self.imageItem() try: mn, mx = imageItem.quickMinMax(targetSize=65536) @@ -6101,11 +6320,11 @@ def regionChanged(self): mn = 0 mx = 255 except AttributeError as err: - mn, mx = self.getLevels() - + mn, mx = self.getLevels() + self.childLutItem.setLevels(min=mn, max=mx) - - + + class labelledQScrollbar(ScrollBar): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -6118,7 +6337,7 @@ def updateLabel(self): if self._label is not None: position = self.sliderPosition() s = self._label.text() - s = re.sub(r'(\d+)/(\d+)', fr'{position+1:02}/\2', s) + s = re.sub(r"(\d+)/(\d+)", rf"{position + 1:02}/\2", s) self._label.setText(s) def setSliderPosition(self, position): @@ -6129,6 +6348,7 @@ def setValue(self, value): QScrollBar.setValue(self, value) self.updateLabel() + class navigateQScrollBar(ScrollBar): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -6154,7 +6374,7 @@ def mousePressEvent(self, event): if self._disableCustomPressEvent: return - + def setValueNoSignal(self, value): for signal_name, slot in self.signal_slot_mapper.items(): signal = getattr(self, signal_name) @@ -6162,10 +6382,10 @@ def setValueNoSignal(self, value): signal.disconnect() except Exception as e: pass - + self.setSliderPosition(value) self.connectEvents(self.signal_slot_mapper) - + def connectEvents(self, signal_slot_mapper: dict): self.signal_slot_mapper = signal_slot_mapper for signal_name, slot in signal_slot_mapper.items(): @@ -6176,6 +6396,7 @@ def connectEvents(self, signal_slot_mapper: dict): pass signal.connect(slot) + class linkedQScrollbar(ScrollBar): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -6198,20 +6419,21 @@ def setMaximum(self, max): if self._linkedScrollBar is not None: self._linkedScrollBar.setMaximum(max) + class myColorButton(pg.ColorButton): - def __init__(self, parent=None, color=(128,128,128), padding=5): + def __init__(self, parent=None, color=(128, 128, 128), padding=5): super().__init__(parent=parent, color=color) if isinstance(padding, (int, float)): - self.padding = (padding, padding, -padding, -padding) + self.padding = (padding, padding, -padding, -padding) else: self.padding = padding self._c = 225 self._hoverDeltaC = 30 self._alpha = 100 - self._bkgrColor = QColor(self._c, self._c, self._c, self._alpha) - self._borderColor = QColor(171, 171, 171) - self._rectBorderPen = QPen(QBrush(QColor(0,0,0)), 0.3) - + self._bkgrColor = QColor(self._c, self._c, self._c, self._alpha) + self._borderColor = QColor(171, 171, 171) + self._rectBorderPen = QPen(QBrush(QColor(0, 0, 0)), 0.3) + def paintEvent(self, event): # QPushButton.paintEvent(self, ev) p = QStylePainter(self) @@ -6223,7 +6445,7 @@ def paintEvent(self, event): # p.fillRect(self.rect(), self._bkgrColor) rect = self.rect().adjusted(*self.padding) ## draw white base, then texture for indicating transparency, then actual color - p.setBrush(pg.mkBrush('w')) + p.setBrush(pg.mkBrush("w")) p.drawRect(rect) p.setBrush(QBrush(Qt.BrushStyle.DiagCrossPattern)) p.drawRect(rect) @@ -6231,68 +6453,69 @@ def paintEvent(self, event): p.setBrush(pg.mkBrush(self._color)) p.drawRect(rect) p.end() - + def enterEvent(self, event): c = self._c + self._hoverDeltaC - self._bkgrColor = QColor(c, c, c, self._alpha) + self._bkgrColor = QColor(c, c, c, self._alpha) self.update() - + def leaveEvent(self, event): c = self._c - self._bkgrColor = QColor(c, c, c, self._alpha) + self._bkgrColor = QColor(c, c, c, self._alpha) self.update() + class highlightableQWidgetAction(QWidgetAction): def __init__(self, parent) -> None: super().__init__(parent) + class overlayLabelsGradientWidget(pg.GradientWidget): def __init__( - self, imageItem, selectActionGroup, segmEndname, - parent=None, orientation='right' - ): + self, + imageItem, + selectActionGroup, + segmEndname, + parent=None, + orientation="right", + ): pg.GradientWidget.__init__(self, parent=parent, orientation=orientation) self.imageItem = imageItem self.selectActionGroup = selectActionGroup for action in self.menu.actions(): - if action.text() == 'HSV': + if action.text() == "HSV": HSV_action = action - elif action.text() == 'RGB': + elif action.text() == "RGB": RGB_ation = action self.menu.removeAction(HSV_action) self.menu.removeAction(RGB_ation) # Shuffle colors action - self.shuffleCmapAction = QAction( - 'Randomly shuffle colormap (Shift+S)', self - ) + self.shuffleCmapAction = QAction("Randomly shuffle colormap (Shift+S)", self) self.menu.addAction(self.shuffleCmapAction) # Drawing mode - drawModeMenu = QMenu('Drawing mode', self) + drawModeMenu = QMenu("Drawing mode", self) self.drawModeActionGroup = QActionGroup(self) - contoursDrawModeAction = QAction('Draw contours', drawModeMenu) + contoursDrawModeAction = QAction("Draw contours", drawModeMenu) contoursDrawModeAction.setCheckable(True) contoursDrawModeAction.setChecked(True) contoursDrawModeAction.segmEndname = segmEndname self.drawModeActionGroup.addAction(contoursDrawModeAction) drawModeMenu.addAction(contoursDrawModeAction) - olDrawModeAction = QAction('Overlay labels', drawModeMenu) + olDrawModeAction = QAction("Overlay labels", drawModeMenu) olDrawModeAction.setCheckable(True) olDrawModeAction.segmEndname = segmEndname self.drawModeActionGroup.addAction(olDrawModeAction) drawModeMenu.addAction(olDrawModeAction) self.menu.addMenu(drawModeMenu) - self.labelsAlphaMenu = self.menu.addMenu( - 'Overlay labels alpha...' - ) + self.labelsAlphaMenu = self.menu.addMenu("Overlay labels alpha...") hbox = QHBoxLayout() self.labelsAlphaSlider = sliderWithSpinBox( - title='Alpha', title_loc='in_line', isFloat=True, - normalize=True + title="Alpha", title_loc="in_line", isFloat=True, normalize=True ) self.labelsAlphaSlider.setMaximum(100) self.labelsAlphaSlider.setSingleStep(0.05) @@ -6306,11 +6529,11 @@ def __init__( self.labelsAlphaMenu.addAction(act) self.menu.addSeparator() - self.menu.addSection('Select segm. file to adjust:') + self.menu.addSection("Select segm. file to adjust:") for action in selectActionGroup.actions(): self.menu.addAction(action) - - self.item.loadPreset('viridis') + + self.item.loadPreset("viridis") self.updateImageLut(None) self.updateImageOpacity(0.3) @@ -6318,68 +6541,65 @@ def __init__( self.sigGradientChangeFinished.connect(self.updateImageLut) self.labelsAlphaSlider.valueChanged.connect(self.updateImageOpacity) self.shuffleCmapAction.triggered.connect(self.shuffleCmap) - + def shuffleCmap(self): lut = self.imageItem.lut np.random.shuffle(lut) - lut[0] = [0,0,0,0] + lut[0] = [0, 0, 0, 0] self.imageItem.setLookupTable(lut) self.imageItem.update() - + def updateImageLut(self, gradientItem): lut = np.zeros((255, 4), dtype=np.uint8) - lut[:,-1] = 255 - lut[:,:-1] = self.item.colorMap().getLookupTable(0,1,255) + lut[:, -1] = 255 + lut[:, :-1] = self.item.colorMap().getLookupTable(0, 1, 255) np.random.shuffle(lut) - lut[0] = [0,0,0,0] + lut[0] = [0, 0, 0, 0] self.imageItem.setLookupTable(lut) self.imageItem.setLevels([0, 255]) - + def updateImageOpacity(self, value): self.imageItem.setOpacity(value) + class labelsGradientWidget(pg.GradientWidget): sigShowRightImgToggled = Signal(bool) sigShowLabelsImgToggled = Signal(bool) sigShowNextFrameToggled = Signal(bool) - def __init__( self, *args, parent=None, orientation='right', **kargs): + def __init__(self, *args, parent=None, orientation="right", **kargs): pg.GradientEditorItem = BaseGradientEditorItemLabels - + pg.GradientWidget.__init__( self, *args, parent=parent, orientation=orientation, **kargs ) self._parent = parent - self.name = 'labels' + self.name = "labels" for action in self.menu.actions(): - if action.text() == 'HSV': + if action.text() == "HSV": HSV_action = action - elif action.text() == 'RGB': + elif action.text() == "RGB": RGB_ation = action self.menu.removeAction(HSV_action) self.menu.removeAction(RGB_ation) # Add custom colormap action - self.customCmapsMenu = self.menu.addMenu('Custom colormaps') + self.customCmapsMenu = self.menu.addMenu("Custom colormaps") self.customCmapsMenu.aboutToShow.connect(self.onShowCustomCmapsMenu) self.customCmapsMenu.triggered.connect(self.customCmapsMenuTriggered) - - self.saveColormapAction = QAction( - 'Save current colormap...', self - ) + + self.saveColormapAction = QAction("Save current colormap...", self) self.menu.addAction(self.saveColormapAction) - self.saveColormapAction.triggered.connect( - self.saveColormap - ) + self.saveColormapAction.triggered.connect(self.saveColormap) self.addCustomGradients() # Background color button hbox = QHBoxLayout() - hbox.addWidget(QLabel('Background color: ')) - self.colorButton = myColorButton(color=(25,25,25)) + hbox.addWidget(QLabel("Background color: ")) + self.colorButton = myColorButton(color=(25, 25, 25)) hbox.addStretch(1) hbox.addWidget(self.colorButton) widget = QWidget() @@ -6390,13 +6610,13 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): self.menu.addAction(act) # Font size menu action - self.fontSizeMenu = QMenu('Text font size', self) - self.menu.addMenu(self.fontSizeMenu) + self.fontSizeMenu = QMenu("Text font size", self) + self.menu.addMenu(self.fontSizeMenu) # IDs color button hbox = QHBoxLayout() - hbox.addWidget(QLabel('Text color: ')) - self.textColorButton = myColorButton(color=(25,25,25)) + hbox.addWidget(QLabel("Text color: ")) + self.textColorButton = myColorButton(color=(25, 25, 25)) hbox.addStretch(1) hbox.addWidget(self.textColorButton) widget = QWidget() @@ -6404,48 +6624,44 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): act = highlightableQWidgetAction(self) act.setDefaultWidget(widget) act.triggered.connect(self.textColorButton.click) - self.menu.addAction(act) - self.menu.addSeparator() + self.menu.addAction(act) + self.menu.addSeparator() # Shuffle colors action - self.shuffleCmapAction = QAction( - 'Randomly shuffle colormap (Shift+S)', self - ) + self.shuffleCmapAction = QAction("Randomly shuffle colormap (Shift+S)", self) self.menu.addAction(self.shuffleCmapAction) self.greedyShuffleCmapAction = QAction( - 'Greedily shuffle colormap (Alt+Shift+S)', self + "Greedily shuffle colormap (Alt+Shift+S)", self ) self.menu.addAction(self.greedyShuffleCmapAction) - - self.permanentGreedyCmapAction = QAction( - 'Always use greedy colormap', self - ) + + self.permanentGreedyCmapAction = QAction("Always use greedy colormap", self) self.permanentGreedyCmapAction.setCheckable(True) self.menu.addAction(self.permanentGreedyCmapAction) # Invert bw action - self.invertBwAction = QAction('Invert black/white', self) + self.invertBwAction = QAction("Invert black/white", self) self.invertBwAction.setCheckable(True) self.menu.addAction(self.invertBwAction) # Show labels action - self.showLabelsImgAction = QAction('Show segmentation image', self) + self.showLabelsImgAction = QAction("Show segmentation image", self) self.showLabelsImgAction.setCheckable(True) self.menu.addAction(self.showLabelsImgAction) # Show right image action - self.showRightImgAction = QAction('Show duplicated left image', self) + self.showRightImgAction = QAction("Show duplicated left image", self) self.showRightImgAction.setCheckable(True) self.menu.addAction(self.showRightImgAction) - + # Show next frame action - self.showNextFrameAction = QAction('Show next frame', self) + self.showNextFrameAction = QAction("Show next frame", self) self.showNextFrameAction.setCheckable(True) self.menu.addAction(self.showNextFrameAction) # Default settings - self.defaultSettingsAction = QAction('Restore default settings...', self) + self.defaultSettingsAction = QAction("Restore default settings...", self) self.menu.addAction(self.defaultSettingsAction) self.menu.addSeparator() @@ -6453,15 +6669,15 @@ def __init__( self, *args, parent=None, orientation='right', **kargs): self.showRightImgAction.toggled.connect(self.showRightImageToggled) self.showLabelsImgAction.toggled.connect(self.showLabelsImageToggled) self.showNextFrameAction.toggled.connect(self.showNextFrameToggled) - + def onShowCustomCmapsMenu(self): self.customCmapsMenu.show() - + def customCmapsMenuTriggered(self, action): cmap = action.cmap self.item.colorMapMenuClicked(cmap) self.item.showTicks(True) - + def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): currentState = self.item.saveState() self.originalLength = self.item.length @@ -6472,26 +6688,26 @@ def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): action = CustomGradientMenuAction(gradient, gradient_name, self.item) # action.triggered.connect(self.item.contextMenuClicked) action.delButton.clicked.connect(self.removeCustomGradient) - action.cmap = colors.pg_ticks_to_colormap(gradient_ticks['ticks']) + action.cmap = colors.pg_ticks_to_colormap(gradient_ticks["ticks"]) # self.item.menu.insertAction(self.saveColormapAction, action) self.customCmapsMenu.addAction(action) self.item.length = self.originalLength self.item.restoreState(currentState) GradientsLabels[gradient_name] = gradient_ticks - + def removeCustomGradient(self): button = self.sender() action = button.action self.customCmapsMenu.removeAction(action) cp = config.ConfigParser() cp.read(custom_cmaps_filepath) - cp.remove_section(f'labels.{action.name}') - with open(custom_cmaps_filepath, mode='w') as file: + cp.remove_section(f"labels.{action.name}") + with open(custom_cmaps_filepath, mode="w") as file: cp.write(file) - + def addCustomGradients(self): try: - CustomGradients = getCustomGradients(name='labels') + CustomGradients = getCustomGradients(name="labels") if not CustomGradients: return for gradient_name, gradient_ticks in CustomGradients.items(): @@ -6499,46 +6715,45 @@ def addCustomGradients(self): except Exception as e: printl(traceback.format_exc()) pass - + def _askNameColormap(self): - inputWin = apps.QInput(parent=self._parent, title='Colormap name') - inputWin.askText('Insert a name for the colormap: ', allowEmpty=False) + inputWin = apps.QInput(parent=self._parent, title="Colormap name") + inputWin.askText("Insert a name for the colormap: ", allowEmpty=False) if inputWin.cancel: return cmapName = inputWin.answer return cmapName - + def saveColormap(self): cmapName = self._askNameColormap() if cmapName is None: return - + cp = config.ConfigParser() if os.path.exists(custom_cmaps_filepath): cp.read(custom_cmaps_filepath) - - SECTION = f'{self.name}.{cmapName}' + + SECTION = f"{self.name}.{cmapName}" cp[SECTION] = {} state = self.item.saveState() for key, value in state.items(): - if key != 'ticks': + if key != "ticks": continue for t, tick in enumerate(value): pos, rgb = tick - rgb = ','.join([str(c) for c in rgb]) - val = f'{pos},{rgb}' - cp[SECTION][f'tick_{t}_pos_rgb'] = val - - with open(custom_cmaps_filepath, mode='w') as file: + rgb = ",".join([str(c) for c in rgb]) + val = f"{pos},{rgb}" + cp[SECTION][f"tick_{t}_pos_rgb"] = val + + with open(custom_cmaps_filepath, mode="w") as file: cp.write(file) - + self.addCustomGradient(cmapName, state, restore=False) - + def isRightImageVisible(self): return ( - self.showLabelsImgAction.isChecked() - or self.showNextFrameAction.isChecked() + self.showLabelsImgAction.isChecked() or self.showNextFrameAction.isChecked() ) def showRightImageToggled(self, checked): @@ -6549,7 +6764,7 @@ def showRightImageToggled(self, checked): self.sigShowLabelsImgToggled.emit(False) self.sigShowNextFrameToggled.emit(checked) self.sigShowRightImgToggled.emit(checked) - + def showLabelsImageToggled(self, checked): if checked and self.isRightImageVisible(): # Hide the right image before showing labels image @@ -6558,7 +6773,7 @@ def showLabelsImageToggled(self, checked): self.sigShowRightImgToggled.emit(False) self.sigShowNextFrameToggled.emit(False) self.sigShowLabelsImgToggled.emit(checked) - + def showNextFrameToggled(self, checked): if checked and self.isRightImageVisible(): # Hide the right image before showing labels image @@ -6570,77 +6785,77 @@ def showNextFrameToggled(self, checked): def saveState(self, df): # remove previous state - df = df[~df.index.str.contains('lab_cmap')].copy() + df = df[~df.index.str.contains("lab_cmap")].copy() state = self.item.saveState() for key, value in state.items(): - if key == 'ticks': + if key == "ticks": for t, tick in enumerate(value): pos, rgb = tick - df.at[f'lab_cmap_tick{t}_rgb', 'value'] = rgb - df.at[f'lab_cmap_tick{t}_pos', 'value'] = pos + df.at[f"lab_cmap_tick{t}_rgb", "value"] = rgb + df.at[f"lab_cmap_tick{t}_pos", "value"] = pos else: if isinstance(value, bool): - value = 'Yes' if value else 'No' - df.at[f'lab_cmap_{key}', 'value'] = value + value = "Yes" if value else "No" + df.at[f"lab_cmap_{key}", "value"] = value return df def restoreState(self, df, loadCmap=True): # Insert background color - if 'labels_bkgrColor' in df.index: - rgbString = df.at['labels_bkgrColor', 'value'] + if "labels_bkgrColor" in df.index: + rgbString = df.at["labels_bkgrColor", "value"] r, g, b = colors.rgb_str_to_values(rgbString) self.colorButton.setColor((r, g, b)) - if 'labels_text_color' in df.index: - rgbString = df.at['labels_text_color', 'value'] + if "labels_text_color" in df.index: + rgbString = df.at["labels_text_color", "value"] r, g, b = colors.rgb_str_to_values(rgbString) self.textColorButton.setColor((r, g, b)) else: self.textColorButton.setColor((255, 0, 0)) - checked = df.at['is_bw_inverted', 'value'] == 'Yes' + checked = df.at["is_bw_inverted", "value"] == "Yes" self.invertBwAction.setChecked(checked) if not loadCmap: return - state = {'mode': 'rgb', 'ticksVisible': True, 'ticks': []} + state = {"mode": "rgb", "ticksVisible": True, "ticks": []} ticks_pos = {} ticks_rgb = {} stateFound = False for setting, value in df.itertuples(): - idx = setting.find('lab_cmap_') + idx = setting.find("lab_cmap_") if idx == -1: continue stateFound = True - m = re.findall(r'tick(\d+)_(\w+)', setting) + m = re.findall(r"tick(\d+)_(\w+)", setting) if m: tick_idx, tick_type = m[0] - if tick_type == 'pos': + if tick_type == "pos": ticks_pos[int(tick_idx)] = float(value) - elif tick_type == 'rgb': + elif tick_type == "rgb": ticks_rgb[int(tick_idx)] = colors.rgba_str_to_values(value) else: key = setting[9:] - if value == 'Yes': + if value == "Yes": value = True - elif value == 'No': + elif value == "No": value = False state[key] = value if stateFound: - ticks = [(0, 0)]*len(ticks_pos) + ticks = [(0, 0)] * len(ticks_pos) for idx, val in ticks_pos.items(): pos = val rgb = ticks_rgb[idx] ticks[idx] = (pos, rgb) - state['ticks'] = ticks + state["ticks"] = ticks self.item.restoreState(state) else: - self.item.loadPreset('viridis') + self.item.loadPreset("viridis") return stateFound @@ -6651,6 +6866,7 @@ def showMenu(self, ev): except AttributeError: self.menu.popup(ev.screenPos()) + class QLogConsole(QTextEdit): def __init__(self, parent=None): super().__init__(parent) @@ -6661,55 +6877,53 @@ def __init__(self, parent=None): def write(self, message): # Method required by tqdm pbar - message = message.replace('\r ', '') + message = message.replace("\r ", "") if message: self.apppendText(message) - + def append(self, text: str) -> None: super().append(text) self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) - + def insertPlainText(self, text: str) -> None: super().append(text) self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) + class ProgressBar(QProgressBar): def __init__(self, parent=None): super().__init__(parent) palette = self.palette() + palette.setColor(QPalette.ColorRole.Highlight, PROGRESSBAR_QCOLOR) palette.setColor( - QPalette.ColorRole.Highlight, - PROGRESSBAR_QCOLOR - ) - palette.setColor( - QPalette.ColorRole.HighlightedText, - PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR + QPalette.ColorRole.HighlightedText, PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR ) self.setPalette(palette) + class ProgressBarWithETA(ProgressBar): def __init__(self, parent=None): self.parent = parent super().__init__(parent=parent) - self.ETA_label = QLabel('NDh:NDm:NDs') + self.ETA_label = QLabel("NDh:NDm:NDs") def update(self, step: int): - self.setValue(self.value()+step) + self.setValue(self.value() + step) t = time.perf_counter() - if not hasattr(self, 'last_time_update'): + if not hasattr(self, "last_time_update"): self.last_time_update = t self.mean_value_duration = None return - seconds_per_value = (t - self.last_time_update)/step + seconds_per_value = (t - self.last_time_update) / step value_left = self.maximum() - self.value() if self.mean_value_duration is None: self.mean_value_duration = seconds_per_value else: self.mean_value_duration = ( - self.mean_value_duration*(self.value()-1) + seconds_per_value - )/self.value() + self.mean_value_duration * (self.value() - 1) + seconds_per_value + ) / self.value() - seconds_left = self.mean_value_duration*value_left + seconds_left = self.mean_value_duration * value_left ETA = myutils.seconds_to_ETA(seconds_left) self.ETA_label.setText(ETA) self.last_time_update = t @@ -6723,97 +6937,105 @@ def hide(self): QProgressBar.hide(self) self.ETA_label.hide() + class NoneWidget: def __init__(self): pass - + def value(self): return None - + def setValue(self, value): return + class MainPlotItem(pg.PlotItem): def __init__( - self, parent=None, name=None, labels=None, title=None, - viewBox=None, axisItems=None, enableMenu=True, - showWelcomeText=False, **kargs - ): + self, + parent=None, + name=None, + labels=None, + title=None, + viewBox=None, + axisItems=None, + enableMenu=True, + showWelcomeText=False, + **kargs, + ): super().__init__( - parent, name, labels, title, viewBox, axisItems, enableMenu, - **kargs + parent, name, labels, title, viewBox, axisItems, enableMenu, **kargs ) # Overwrite zoom out button behaviour to disable autoRange after # clicking it. - # If autorange is enabled, it is called everytime the brush or eraser + # If autorange is enabled, it is called everytime the brush or eraser # scatter plot items touches the border causing flickering self.disableAutoRange() - self.autoBtn.mode = 'manual' + self.autoBtn.mode = "manual" if showWelcomeText: self.infoTextItem = pg.TextItem() self.addItem(self.infoTextItem) - html_filepath = os.path.join(html_path, 'gui_welcome.html') + html_filepath = os.path.join(html_path, "gui_welcome.html") with open(html_filepath) as html_file: htmlText = html_file.read() self.infoTextItem.setHtml(htmlText) - self.infoTextItem.setPos(0,0) - + self.infoTextItem.setPos(0, 0) + self.delRoiItems = {} self.highlightingRectItems = None self._baseImageItem = None self._imageItems = [] self.highlightingRectItemsColor = None - + def addHighlightingRectItems(self, color=None): self.highlightingRectItems = { - 'left': RectItem(QRectF()), - 'right': RectItem(QRectF()), - 'top': RectItem(QRectF()), - 'bottom': RectItem(QRectF()) + "left": RectItem(QRectF()), + "right": RectItem(QRectF()), + "top": RectItem(QRectF()), + "bottom": RectItem(QRectF()), } for rect in self.highlightingRectItems.values(): self.addItem(rect) - + if color is None: return - + self.setHighlightingRectItemsColor(color) - + def setHighlightingRectItemsColor(self, color): if color == self.highlightingRectItemsColor: return - + for item in self.highlightingRectItems.values(): item.setColor(color) - + self.highlightingRectItemsColor = color - + def addBaseImageItem(self, baseImageItem): self._baseImageItem = baseImageItem self._imageItems.append(baseImageItem) self.addItem(baseImageItem) - + def addImageItem(self, imageItem): self._imageItems.append(imageItem) self.addItem(imageItem) - + def setHighlighted(self, highlighted, color=None): if color is None: color = self.highlightingRectItemsColor - + if color is None: - color = 'green' - + color = "green" + if self.highlightingRectItems is None: self.addHighlightingRectItems(color=color) - + if not highlighted: for rect in self.highlightingRectItems.values(): rect.setQRect(QRectF()) return - + self.setHighlightingRectItemsColor(color) - + ((xmin, xmax), (ymin, ymax)) = self.viewRange() xmin = xmin if xmin >= 0 else 0 ymin = ymin if ymin >= 0 else 0 @@ -6821,56 +7043,56 @@ def setHighlighted(self, highlighted, color=None): Y, X = self._baseImageItem.image.shape[:2] xmax = min(xmax, X) ymax = min(ymax, Y) - + w = xmax - xmin h = ymax - ymin - + bs = round(((w + h) / 2) * 0.02) if bs < 1: bs = 1 - + x0 = xmin x1 = xmin + bs x2 = xmax - bs x3 = xmax - + y0 = ymin y1 = ymin + bs y2 = ymax - bs y3 = ymax - - self.highlightingRectItems['left'].setRect(x0, y0, bs, y3-y0) - self.highlightingRectItems['top'].setRect(x1, y0, x3-x1, bs) - self.highlightingRectItems['right'].setRect(x2, y1, bs, y3-y1) - self.highlightingRectItems['bottom'].setRect(x1, y2, x2-x1, bs) + + self.highlightingRectItems["left"].setRect(x0, y0, bs, y3 - y0) + self.highlightingRectItems["top"].setRect(x1, y0, x3 - x1, bs) + self.highlightingRectItems["right"].setRect(x2, y1, bs, y3 - y1) + self.highlightingRectItems["bottom"].setRect(x1, y2, x2 - x1, bs) self.update() - + def clear(self): - super().clear() - + super().clear() + self.delRoiItems = {} self.highlightingRectItems = None self._baseImageItem = None self._imageItems = [] self.highlightingRectItemsColor = None - + try: self.removeItem(self.infoTextItem) except Exception as e: pass - + def autoBtnClicked(self): self.vb.autoRange() self.autoBtn.hide() - + def addDelRoiItem(self, roiItem, key): if self.isDelRoiItemPresent(roiItem): return - + self.delRoiItems[key] = roiItem roiItem.key = key self.addItem(roiItem) - + def removeDelRoiItem(self, roiItem): key = roiItem.key self.delRoiItems.pop(key, None) @@ -6878,67 +7100,66 @@ def removeDelRoiItem(self, roiItem): self.removeItem(roiItem) except Exception as err: return - + def isDelRoiItemPresent(self, roiItem): try: key = roiItem.key except AttributeError as e: return False - + try: roi = self.delRoiItems[key] except Exception as err: return False - + return True - + def viewRange(self, mask_img=None): if mask_img is None: return super().viewRange() - - mask_rp = skimage.measure.regionprops( - skimage.measure.label(mask_img) - ) + + mask_rp = skimage.measure.regionprops(skimage.measure.label(mask_img)) if not mask_rp: return super().viewRange() - + mask_obj = mask_rp[0] ymin, xmin, ymax, xmax = mask_obj.bbox return (xmin, xmax), (ymin, ymax) - + + class sliderWithSpinBox(QWidget): sigValueChange = Signal(object) valueChanged = Signal(object) editingFinished = Signal() - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs): super().__init__(*args) layout = QGridLayout() - title = kwargs.get('title') + title = kwargs.get("title") row = 0 col = 0 if title is not None: titleLabel = QLabel(self) titleLabel.setText(title) - loc = kwargs.get('title_loc', 'top') - if loc == 'top': + loc = kwargs.get("title_loc", "top") + if loc == "top": layout.addWidget(titleLabel, 0, col, alignment=Qt.AlignLeft) - elif loc=='in_line': + elif loc == "in_line": row = -1 col = 1 layout.addWidget(titleLabel, 0, 0, alignment=Qt.AlignLeft) layout.setColumnStretch(0, 0) self._normalize = False - normalize = kwargs.get('normalize') + normalize = kwargs.get("normalize") if normalize is not None and normalize: self._normalize = True self._isFloat = True self._isFloat = False - isFloat = kwargs.get('isFloat') + isFloat = kwargs.get("isFloat") if isFloat is not None and isFloat: self._isFloat = True @@ -6949,16 +7170,16 @@ def __init__(self, *args, **kwargs): else: self.spinBox = SpinBox(self) self.spinBox.setAlignment(Qt.AlignCenter) - self.spinBox.setMaximum(2**31-1) + self.spinBox.setMaximum(2**31 - 1) - maximum_on_label = kwargs.get('maximum_on_label') - spinbox_loc = kwargs.get('spinbox_loc', 'right') - if spinbox_loc == 'right': - spinbox_col = col+1 + maximum_on_label = kwargs.get("maximum_on_label") + spinbox_loc = kwargs.get("spinbox_loc", "right") + if spinbox_loc == "right": + spinbox_col = col + 1 slider_col = col if maximum_on_label is not None: maximum_on_label_col = spinbox_col + 1 - elif spinbox_loc == 'left': + elif spinbox_loc == "left": spinbox_col = col slider_col = col + 1 if maximum_on_label is not None: @@ -6967,33 +7188,32 @@ def __init__(self, *args, **kwargs): if maximum_on_label is not None: self.labelMaximum = QLabel() - layout.addWidget(self.labelMaximum, row+1, maximum_on_label_col) - layout.addWidget(self.slider, row+1, slider_col) - layout.addWidget(self.spinBox, row+1, spinbox_col) - + layout.addWidget(self.labelMaximum, row + 1, maximum_on_label_col) + layout.addWidget(self.slider, row + 1, slider_col) + layout.addWidget(self.spinBox, row + 1, spinbox_col) + if title is not None: layout.setRowStretch(0, 1) - layout.setRowStretch(row+1, 1) + layout.setRowStretch(row + 1, 1) layout.setColumnStretch(slider_col, 6) layout.setColumnStretch(spinbox_col, 1) self._layout = layout - self.lastCol = col+1 + self.lastCol = col + 1 self.sliderCol = slider_col self.slider.valueChanged.connect(self.sliderValueChanged) self.slider.sliderReleased.connect(self.onEditingFinished) self.spinBox.valueChanged.connect(self.spinboxValueChanged) self.spinBox.editingFinished.connect(self.onEditingFinished) - + layout.setContentsMargins(5, 0, 5, 0) - + self.setLayout(layout) - if maximum_on_label is not None: self.setMaximum(maximum_on_label) - self.labelMaximum.setText(f'/{maximum_on_label}') + self.labelMaximum.setText(f"/{maximum_on_label}") def onEditingFinished(self): self.editingFinished.emit() @@ -7007,7 +7227,7 @@ def minimum(self): def setValue(self, value, emitSignal=False): valueInt = value if self._normalize: - valueInt = int(value*self.slider.maximum()) + valueInt = int(value * self.slider.maximum()) elif self._isFloat: valueInt = int(value) @@ -7053,7 +7273,7 @@ def setTickInterval(self, interval): def sliderValueChanged(self, val): self.spinBox.valueChanged.disconnect() if self._normalize: - valF = val/self.slider.maximum() + valF = val / self.slider.maximum() self.spinBox.setValue(valF) else: self.spinBox.setValue(val) @@ -7063,7 +7283,7 @@ def sliderValueChanged(self, val): def spinboxValueChanged(self, val): if self._normalize: - val = int(val*self.slider.maximum()) + val = int(val * self.slider.maximum()) elif self._isFloat: val = int(val) @@ -7075,15 +7295,14 @@ def spinboxValueChanged(self, val): def value(self): return self.spinBox.value() - + def setDisabled(self, disabled) -> None: self.slider.setDisabled(disabled) self.spinBox.setDisabled(disabled) + class BaseImageItem(pg.ImageItem): - def __init__( - self, image=None, **kargs - ): + def __init__(self, image=None, **kargs): self.minMaxValuesMapper = None self.minMaxValuesMapperPreproc = None self.minMaxValuesMapperCombined = None @@ -7095,100 +7314,98 @@ def __init__( self.useEqualized = False self.useCombined = False self._isRgba = False - + super().__init__(image, **kargs) self.autoLevelsEnabled = None - + def isRgba(self): return self._isRgba - + def setEnableAutoLevels(self, enabled: bool): self.autoLevelsEnabled = enabled - - def setImage( - self, image=None, autoLevels=None, **kargs - ): + + def setImage(self, image=None, autoLevels=None, **kargs): if autoLevels is None: autoLevels = self.autoLevelsEnabled - + if image is not None and image.ndim == 3 and image.shape[2] in (3, 4): self._isRgba = True - + super().setImage(image, autoLevels=autoLevels, **kargs) - - def preComputedMinMaxValues(self, data: List['load.loadData']): + + def preComputedMinMaxValues(self, data: List["load.loadData"]): self.minMaxValuesMapper = {} for pos_i, posData in enumerate(data): img_data = posData.img_data - requires_time_dim = ( - posData.img_data.ndim == 2 - or (posData.img_data.ndim == 3 and posData.SizeZ > 1) + requires_time_dim = posData.img_data.ndim == 2 or ( + posData.img_data.ndim == 3 and posData.SizeZ > 1 ) if requires_time_dim: img_data = (img_data,) - + for frame_i, image in enumerate(img_data): if image.ndim == 3: self._updateMinMaxValuesProjections( image, pos_i, frame_i, self.minMaxValuesMapper ) - + if image.ndim == 2: image = (image,) - + for z, img in enumerate(image): self.minMaxValuesMapper[(pos_i, frame_i, z)] = ( - np.nanmin(img), np.nanmax(img) + np.nanmin(img), + np.nanmax(img), ) - + def updateMinMaxValuesEqualizedData( - self, - data: List['load.loadData'], - pos_i: int, - frame_i: int, - z_slice: Union[int, str], - ): + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + z_slice: Union[int, str], + ): if self.minMaxValuesMapperEqualized is None: self.minMaxValuesMapperEqualized = {} posData = data[pos_i] img = posData.equalized_img_data[frame_i][z_slice] key = (pos_i, frame_i, z_slice) - self.minMaxValuesMapperEqualized[key] = (np.nanmin(img), np.nanmax(img)) - + self.minMaxValuesMapperEqualized[key] = (np.nanmin(img), np.nanmax(img)) + def updateMinMaxValuesEqualizedDataProjections( - self, - data: List['load.loadData'], - pos_i: int, - frame_i: int, - ): + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + ): posData = data[pos_i] eq_zstack = posData.equalized_img_data[frame_i] - + self._updateMinMaxValuesProjections( eq_zstack, pos_i, frame_i, self.minMaxValuesMapperEqualized ) - + def _updateMinMaxValuesProjections(self, zstack, pos_i, frame_i, mapper): max_proj = zstack.max(axis=0) - key = (pos_i, frame_i, 'max z-projection') + key = (pos_i, frame_i, "max z-projection") mapper[key] = np.nanmin(max_proj), np.nanmax(max_proj) - + mean_proj = zstack.mean(axis=0) - key = (pos_i, frame_i, 'mean z-projection') + key = (pos_i, frame_i, "mean z-projection") mapper[key] = np.nanmin(mean_proj), np.nanmax(mean_proj) - + median_proj = np.median(zstack, axis=0) - key = (pos_i, frame_i, 'median z-proj.') + key = (pos_i, frame_i, "median z-proj.") mapper[key] = np.nanmin(median_proj), np.nanmax(median_proj) - + def updateMinMaxValuesPreprocessedData( - self, - data: List['load.loadData'], - pos_i: int, - frame_i: int, - z_slice: Union[int, str], - ): + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + z_slice: Union[int, str], + ): if self.minMaxValuesMapperPreproc is None: self.minMaxValuesMapperPreproc = {} @@ -7198,59 +7415,59 @@ def updateMinMaxValuesPreprocessedData( self.minMaxValuesMapperPreproc[key] = (np.nanmin(img), np.nanmax(img)) def updateMinMaxValuesPreprocessedProjections( - self, - data: List['load.loadData'], - pos_i: int, - frame_i: int, - ): + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + ): posData = data[pos_i] zstack = posData.preproc_img_data[frame_i] - + self._updateMinMaxValuesProjections( zstack, pos_i, frame_i, self.minMaxValuesMapperPreproc ) - + def updateMinMaxValuesCombinedData( - self, - data: List['load.loadData'], - pos_i: int, - frame_i: int, - z_slice: Union[int, str], - ): + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + z_slice: Union[int, str], + ): if self.minMaxValuesMapperCombined is None: self.minMaxValuesMapperCombined = {} - + posData = data[pos_i] img = posData.combine_img_data[frame_i][z_slice] key = (pos_i, frame_i, z_slice) self.minMaxValuesMapperCombined[key] = (np.nanmin(img), np.nanmax(img)) - + def updateMinMaxValuesCombinedDataProjections( - self, - data: List['load.loadData'], - pos_i: int, - frame_i: int, - ): + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + ): posData = data[pos_i] zstack = posData.combine_img_data[frame_i] - + self._updateMinMaxValuesProjections( zstack, pos_i, frame_i, self.minMaxValuesMapperCombined ) - + def setCurrentPosIndex(self, pos_i: int): self.pos_i = pos_i - + def setCurrentFrameIndex(self, frame_i: int): self.frame_i = frame_i - + def setCurrentZsliceIndex(self, z: int): self.z = z - + def quickMinMax(self, targetSize=1e6): if self.isRgba(): return super().quickMinMax(targetSize=targetSize) - + if self.usePreprocessed and self.minMaxValuesMapperPreproc is not None: minMaxValuesMapper = self.minMaxValuesMapperPreproc elif self.useCombined and self.minMaxValuesMapperCombined is not None: @@ -7259,258 +7476,262 @@ def quickMinMax(self, targetSize=1e6): minMaxValuesMapper = self.minMaxValuesMapperEqualized else: minMaxValuesMapper = self.minMaxValuesMapper - + if minMaxValuesMapper is None: return super().quickMinMax(targetSize=targetSize) - + try: key = (self.pos_i, self.frame_i, self.z) levels = minMaxValuesMapper[key] return levels except Exception as err: pass - + try: key = (self.pos_i, self.frame_i, self.z) levels = self.minMaxValuesMapper[key] return levels except Exception as err: return super().quickMinMax(targetSize=targetSize) - + def setOpacity(self, value, **kwargs): if value == 0: value = 0.001 - + if value == 1: value = 0.999 - + super().setOpacity(value) - + class BaseLabelsImageItem(pg.ImageItem): - def __init__( - self, image=None, **kargs - ): + def __init__(self, image=None, **kargs): super().__init__(image, **kargs) - + def setImage(self, image=None, **kwargs): if image is None: return - autoLevels = kwargs.get('autoLevels') + autoLevels = kwargs.get("autoLevels") if autoLevels is None: - kwargs['autoLevels'] = False + kwargs["autoLevels"] = False super().setImage(image, **kwargs) + class OverlayImageItem(pg.ImageItem): - def __init__( - self, image=None, **kargs - ): + def __init__(self, image=None, **kargs): super().__init__(image, **kargs) self.autoLevelsEnabled = None - + def setEnableAutoLevels(self, enabled: bool): self.autoLevelsEnabled = enabled - - def setImage( - self, image=None, autoLevels=None, **kargs - ): + + def setImage(self, image=None, autoLevels=None, **kargs): if autoLevels is None: autoLevels = self.autoLevelsEnabled - + super().setImage(image, autoLevels=autoLevels, **kargs) - + def setOpacity(self, value, **kwargs): if value == 0: value = 0.001 - + if value == 1: value = 0.999 - + super().setOpacity(value) + class ParentImageItem(BaseImageItem): def __init__( - self, image=None, linkedImageItem=None, activatingActions=None, - debug=False, **kargs - ): + self, + image=None, + linkedImageItem=None, + activatingActions=None, + debug=False, + **kargs, + ): super().__init__(image, **kargs) self.linkedImageItem = linkedImageItem self.activatingActions = activatingActions self.debug = debug self._forceDoNotUpdateLinked = False self.autoLevelsEnabled = None - + def clear(self): if self.linkedImageItem is not None: self.linkedImageItem.clear() return super().clear() - + def isLinkedImageItemActive(self): if self._forceDoNotUpdateLinked: return False - + if self.linkedImageItem is None: return False - + if self.activatingActions is None: return False - + for action in self.activatingActions: if action.isChecked(): return True - + return False - + def setEnableAutoLevels(self, enabled: bool): self.autoLevelsEnabled = enabled - + def setUsePreprocessed(self, usePreprocessed): self.usePreprocessed = usePreprocessed if self.linkedImageItem is None: return - + self.linkedImageItem.usePreprocessed = usePreprocessed - + def setUseCombined(self, useCombined): self.useCombined = useCombined if self.linkedImageItem is None: return - + self.linkedImageItem.useCombined = useCombined - + def preComputedMinMaxValues(self, *args, **kwargs): super().preComputedMinMaxValues(*args, **kwargs) if self.linkedImageItem is None: return - + self.linkedImageItem.minMaxValuesMapper = self.minMaxValuesMapper - + def updateMinMaxValuesPreprocessedData(self, *args, **kwargs): super().updateMinMaxValuesPreprocessedData(*args, **kwargs) - + if self.linkedImageItem is None: return - + self.linkedImageItem.minMaxValuesMapper = self.minMaxValuesMapper - + def updateMinMaxValuesCombinedData(self, *args, **kwargs): super().updateMinMaxValuesCombinedData(*args, **kwargs) - + if self.linkedImageItem is None: return - + self.linkedImageItem.minMaxValuesMapperCombined = ( self.minMaxValuesMapperCombined ) - + def updateMinMaxValuesCombinedDataProjections(self, *args, **kwargs): super().updateMinMaxValuesCombinedDataProjections(*args, **kwargs) - + if self.linkedImageItem is None: return - + self.linkedImageItem.minMaxValuesMapperCombined = ( self.minMaxValuesMapperCombined ) - + def updateMinMaxValuesEqualizedDataProjections(self, *args, **kwargs): super().updateMinMaxValuesEqualizedDataProjections(*args, **kwargs) - + if self.linkedImageItem is None: return - + self.linkedImageItem.minMaxValuesMapperEqualized = ( self.minMaxValuesMapperEqualized ) - + def updateMinMaxValuesEqualizedData(self, *args, **kwargs): super().updateMinMaxValuesEqualizedData(*args, **kwargs) - + if self.linkedImageItem is None: return - + self.linkedImageItem.minMaxValuesMapperEqualized = ( self.minMaxValuesMapperEqualized ) - + def setCurrentPosIndex(self, *args, **kwargs): super().setCurrentPosIndex(*args, **kwargs) - + if self.linkedImageItem is None: return - + self.linkedImageItem.pos_i = self.pos_i - + def setCurrentFrameIndex(self, *args, **kwargs): super().setCurrentFrameIndex(*args, **kwargs) - + if self.linkedImageItem is None: return - + self.linkedImageItem.frame_i = self.frame_i + 1 - + def setCurrentZsliceIndex(self, *args, **kwargs): super().setCurrentZsliceIndex(*args, **kwargs) - + if self.linkedImageItem is None: return - + self.linkedImageItem.z = self.z - + def setImage( - self, image=None, autoLevels=None, next_frame_image=None, - scrollbar_value=None, force_set_linked=False, **kargs - ): + self, + image=None, + autoLevels=None, + next_frame_image=None, + scrollbar_value=None, + force_set_linked=False, + **kargs, + ): if autoLevels is None: autoLevels = self.autoLevelsEnabled - + super().setImage(image, autoLevels=autoLevels, **kargs) - + if self.linkedImageItem is None: return - + if not self.isLinkedImageItemActive() and not force_set_linked: return - + if next_frame_image is not None: self.linkedImageItem.setImage( - next_frame_image, - scrollbar_value=scrollbar_value, - autoLevels=autoLevels + next_frame_image, scrollbar_value=scrollbar_value, autoLevels=autoLevels ) elif image is not None: self.linkedImageItem.setImage(image) - + def updateImage(self, *args, **kargs): if self.isLinkedImageItemActive(): self.linkedImageItem.image = self.image self.linkedImageItem.updateImage(*args, **kargs) return super().updateImage(*args, **kargs) - + def setOpacity(self, value, applyToLinked=True): super().setOpacity(value) if not applyToLinked: return - + if self.linkedImageItem is None: return - + self.linkedImageItem.setOpacity(value) - + def setLookupTable(self, lut): super().setLookupTable(lut) # if self.linkedImageItem is not None: # self.linkedImageItem.setLookupTable(lut) + class ChildImageItem(BaseImageItem): def __init__(self, *args, linkedScrollbar=None, **kwargs): BaseImageItem.__init__(self, *args, **kwargs) self.linkedScrollbar = linkedScrollbar - + def setImage(self, img=None, z=None, scrollbar_value=None, **kargs): - autoLevels = kargs.get('autoLevels') + autoLevels = kargs.get("autoLevels") if autoLevels is None: - kargs['autoLevels'] = False + kargs["autoLevels"] = False if img is None: BaseImageItem.setImage(self, img, **kargs) @@ -7520,26 +7741,27 @@ def setImage(self, img=None, z=None, scrollbar_value=None, **kargs): BaseImageItem.setImage(self, img[z], **kargs) else: BaseImageItem.setImage(self, img, **kargs) - + if self.linkedScrollbar is None: return - + if not self.linkedScrollbar.isEnabled(): return - + if scrollbar_value is None: return - + self.linkedScrollbar.setValueNoSignal(scrollbar_value) + class labImageItem(pg.ImageItem): def __init__(self, *args, **kwargs): pg.ImageItem.__init__(self, *args, **kwargs) def setImage(self, img=None, z=None, **kargs): - autoLevels = kargs.get('autoLevels') + autoLevels = kargs.get("autoLevels") if autoLevels is None: - kargs['autoLevels'] = False + kargs["autoLevels"] = False if img is None: pg.ImageItem.setImage(self, img, **kargs) @@ -7549,142 +7771,142 @@ def setImage(self, img=None, z=None, **kargs): pg.ImageItem.setImage(self, img[z], **kargs) else: pg.ImageItem.setImage(self, img, **kargs) - - + class PostProcessSegmSlider(sliderWithSpinBox): def __init__(self, *args, label=None, **kwargs): super().__init__(*args, **kwargs) self.label = label - self.checkbox = QCheckBox('Disable') - self._layout.addWidget(self.checkbox, self.sliderCol, self.lastCol+1) + self.checkbox = QCheckBox("Disable") + self._layout.addWidget(self.checkbox, self.sliderCol, self.lastCol + 1) self.checkbox.toggled.connect(self.onCheckBoxToggled) self.valueChanged.connect(self.checkExpandRange) - + def onCheckBoxToggled(self, checked: bool) -> None: super().setDisabled(checked) if self.label is not None: self.label.setDisabled(checked) self.onValueChanged(None) self.onEditingFinished() - + def onValueChanged(self, value): self.valueChanged.emit(value) - + def checkExpandRange(self, value): if value == self.maximum(): range = int(self.maximum() - self.minimum()) - half_range = int(range/2) + half_range = int(range / 2) newMinimum = self.minimum() + half_range newMaximum = self.maximum() + half_range self.setMaximum(newMaximum) self.setMinimum(newMinimum) elif value == self.minimum(): range = int(self.maximum() - self.minimum()) - half_range = int(range/2) + half_range = int(range / 2) newMinimum = self.minimum() - half_range newMaximum = self.maximum() - half_range self.setMaximum(newMaximum) self.setMinimum(newMinimum) - + def onEditingFinished(self): self.editingFinished.emit() - + def value(self): if self.checkbox.isChecked(): return None else: return super().value() + class GhostContourItem(pg.PlotDataItem): def __init__( - self, ParentPlotItem, penColor=(245, 184, 0, 100), - textColor=(245, 184, 0) - ): + self, ParentPlotItem, penColor=(245, 184, 0, 100), textColor=(245, 184, 0) + ): super().__init__() # Yellow pen self.setPen(pg.mkPen(width=2, color=penColor)) self.label = myLabelItem() - self.label.setAttr('bold', True) - self.label.setAttr('color', textColor) + self.label.setAttr("bold", True) + self.label.setAttr("color", textColor) self._ParentPlotItem = ParentPlotItem - + def addToPlotItem(self): self._ParentPlotItem.addItem(self) self._ParentPlotItem.addItem(self.label) - + def removeFromPlotItem(self): self._ParentPlotItem.removeItem(self.label) self._ParentPlotItem.removeItem(self) - + def setData( - self, xx=None, yy=None, fontSize=11, ID=0, - y_cursor=None, x_cursor=None - ): + self, xx=None, yy=None, fontSize=11, ID=0, y_cursor=None, x_cursor=None + ): if xx is None: xx = [] if yy is None: yy = [] super().setData(xx, yy) - if not hasattr(self, 'label'): + if not hasattr(self, "label"): return if ID == 0: - self.label.setText('') + self.label.setText("") else: - self.label.setText(f'{ID}', size=fontSize) + self.label.setText(f"{ID}", size=fontSize) w, h = self.label.itemRect().width(), self.label.itemRect().height() - self.label.setPos(x_cursor, y_cursor-h) - + self.label.setPos(x_cursor, y_cursor - h) + def clear(self): self.setData([], []) + class GhostMaskItem(pg.ImageItem): def __init__(self, ParentPlotItem): super().__init__() self.label = myLabelItem() - self.label.setAttr('bold', True) - self.label.setAttr('color', (245, 184, 0)) + self.label.setAttr("bold", True) + self.label.setAttr("color", (245, 184, 0)) self._ParentPlotItem = ParentPlotItem - + def initImage(self, imgShape): image = np.zeros(imgShape, dtype=np.uint32) self.setImage(image) - + def initLookupTable(self, rgbaColor): lut = np.zeros((2, 4), dtype=np.uint8) - lut[1,-1] = 255 - lut[1,:-1] = rgbaColor + lut[1, -1] = 255 + lut[1, :-1] = rgbaColor self.setLookupTable(lut) - + def addToPlotItem(self): self._ParentPlotItem.addItem(self) self._ParentPlotItem.addItem(self.label) - + def removeFromPlotItem(self): self._ParentPlotItem.removeItem(self.label) self._ParentPlotItem.removeItem(self) - + def updateGhostImage(self, ID=0, y_cursor=None, x_cursor=None, fontSize=None): self.setImage(self.image) if ID == 0: - self.label.setText('') + self.label.setText("") return - - self.label.setText(f'{ID}', size=fontSize) + + self.label.setText(f"{ID}", size=fontSize) w, h = self.label.itemRect().width(), self.label.itemRect().height() - self.label.item.setPos(x_cursor, y_cursor-h) - + self.label.item.setPos(x_cursor, y_cursor - h) + def clear(self): - if hasattr(self, 'label'): - self.label.setText('') + if hasattr(self, "label"): + self.label.setText("") if self.image is None: return self.image[:] = 0 self.setImage(self.image) + class PostProcessSegmSpinbox(QWidget): valueChanged = Signal(int) editingFinished = Signal() @@ -7704,41 +7926,41 @@ def __init__(self, *args, isFloat=False, label=None, **kwargs): self.spinBox.editingFinished.connect(self.onEditingFinished) layout.addWidget(self.spinBox) - self.checkbox = QCheckBox('Disable') + self.checkbox = QCheckBox("Disable") layout.addWidget(self.checkbox) - layout.setStretch(0,1) - layout.setStretch(1,0) + layout.setStretch(0, 1) + layout.setStretch(1, 0) self.label = label self.checkbox.toggled.connect(self.onCheckBoxToggled) - + layout.setContentsMargins(5, 0, 5, 0) - + self.setLayout(layout) - + def onCheckBoxToggled(self, checked: bool) -> None: self.spinBox.setDisabled(checked) if self.label is not None: self.label.setDisabled(checked) self.onValueChanged(None) self.onEditingFinished() - + def onValueChanged(self, value): self.valueChanged.emit(value) - + def onEditingFinished(self): self.editingFinished.emit() def maximum(self): return self.spinBox.maximum() - + def setValue(self, value): self.spinBox.setValue(value) - + def sizeHint(self): return self.spinBox.sizeHint() - + def setMaximum(self, max): self.spinBox.setMaximum(max) @@ -7750,22 +7972,23 @@ def setMinimum(self, min): def setSingleStep(self, step): self.spinBox.setSingleStep(step) - + def setDecimals(self, decimals): self.spinBox.setDecimals(decimals) - + def value(self): if self.checkbox.isChecked(): return None else: return self.spinBox.value() + class CopiableCommandWidget(QGroupBox): - def __init__(self, command='', parent=None, font_size='13px'): + def __init__(self, command="", parent=None, font_size="13px"): super().__init__(parent) - + layout = QHBoxLayout() - + label = QLabel(self) self.label = label self._font_size = font_size @@ -7774,49 +7997,47 @@ def __init__(self, command='', parent=None, font_size='13px'): Qt.TextBrowserInteraction | Qt.TextSelectableByKeyboard ) layout.addWidget(label) - layout.addWidget(QVLine(shadow='Plain', color='#4d4d4d')) - copyButton = copyPushButton('Copy', flat=True, hoverable=True) + layout.addWidget(QVLine(shadow="Plain", color="#4d4d4d")) + copyButton = copyPushButton("Copy", flat=True, hoverable=True) copyButton.clicked.connect(self.copyToClipboard) layout.addWidget(copyButton) layout.addStretch(1) - - self.setLayout(layout) - + + self.setLayout(layout) + def setWordWrap(self, wordWrap): self.label.setWordWrap(wordWrap) - + def copyToClipboard(self): cb = QApplication.clipboard() cb.clear(mode=cb.Clipboard) cb.setText(self._command, mode=cb.Clipboard) - print('Command copied!') - + print("Command copied!") + def setCommand(self, command, font_size=None): if font_size is None: font_size = self._font_size - + self._command = command - txt = html_utils.paragraph( - f'{command}', font_size=font_size - ) + txt = html_utils.paragraph(f"{command}", font_size=font_size) self.label.setText(txt) - + def command(self): return self._command - + def text(self): return self.label.text() - + def setTextInteractionFlags(self, flags): self.label.setTextInteractionFlags(flags) - + + def PostProcessSegmWidget( - minimum, maximum, value, useSliders, isFloat=False, normalize=False, - label=None - ): + minimum, maximum, value, useSliders, isFloat=False, normalize=False, label=None +): if useSliders: if normalize: - maximum = int(maximum*100) + maximum = int(maximum * 100) widget = PostProcessSegmSlider( normalize=normalize, isFloat=isFloat, label=label ) @@ -7827,16 +8048,17 @@ def PostProcessSegmWidget( widget.setValue(value) return widget + # class Spinner(QLabel): # def __init__(self, size=150, parent=None): # super().__init__(parent) # # layout = QHBoxLayout() - + # # self._label = QLabel() # self.setAlignment(Qt.AlignCenter) # # self._label.setText('Ciao') # self._pixmap = QPixmap(':spinner.svg') - + # self._pixmapSize = size + size%2 # self._halfPixmapSize = int(self._pixmapSize/2) # printl(self._pixmapSize, self._halfPixmapSize) @@ -7844,11 +8066,11 @@ def PostProcessSegmWidget( # # self.setFixedSize(160, 160) # self._angle = 0 - + # blurEffect = QGraphicsBlurEffect() # blurEffect.setBlurRadius(1.4) # self.setGraphicsEffect(blurEffect) - + # # layout.addWidget(self._label) # # self.setLayout(layout) @@ -7882,28 +8104,29 @@ def PostProcessSegmWidget( # painter.drawPixmap(x, y, self._pixmap.scaled(self._pixmapSize, self._pixmapSize)) # painter.end() + class LoadingCircleAnimation(QLabel): def __init__(self, size=32, motionBlur=False, parent=None): super().__init__(parent) # layout = QHBoxLayout() - + # self._label = QLabel() self.setAlignment(Qt.AlignCenter) - self._size = size + size%2 - self._radius = int(self._size/2) + self._size = size + size % 2 + self._radius = int(self._size / 2) self.setFixedSize(self._size, self._size) - self._dotDiameter = int(self._size*0.15) - self._dotDiameter = self._dotDiameter + self._dotDiameter%2 - self._dotRadius = int(self._dotDiameter/2) - + self._dotDiameter = int(self._size * 0.15) + self._dotDiameter = self._dotDiameter + self._dotDiameter % 2 + self._dotRadius = int(self._dotDiameter / 2) + self._rgb = _palettes.getPainterColor()[:3] self._index = 0 - + self.setBrushesAndAngles() - + if motionBlur: blurEffect = QGraphicsBlurEffect() - blurRadius = self._size*0.02 + blurRadius = self._size * 0.02 if blurRadius < 1: blurRadius = 1 blurEffect.setBlurRadius(blurRadius) @@ -7915,16 +8138,16 @@ def __init__(self, size=32, motionBlur=False, parent=None): self.animation.setLoopCount(-1) self.animation.setDuration(1200) self.animation.start() - + self.update() - + def setVisible(self, visible): if visible: self.animation.start() else: self.animation.stop() super().setVisible(visible) - + def setBrushesAndAngles(self): self._brushes = [] self._pens = [] @@ -7953,12 +8176,13 @@ def paintEvent(self, event): angle = self._angles[i] painter.setBrush(self._brushes[idx]) painter.setPen(self._pens[idx]) - x = (self._radius-self._dotRadius)*math.cos(angle*math.pi/180) - y = (self._radius-self._dotRadius)*math.sin(angle*math.pi/180) + x = (self._radius - self._dotRadius) * math.cos(angle * math.pi / 180) + y = (self._radius - self._dotRadius) * math.sin(angle * math.pi / 180) painter.drawEllipse(QPointF(x, y), self._dotRadius, self._dotRadius) - + painter.end() + class QBaseWindow(QMainWindow): def __init__(self, parent=None): super().__init__(parent) @@ -7974,28 +8198,32 @@ def show(self, block=False): self.loop.exec_() def closeEvent(self, event): - if hasattr(self, 'loop'): + if hasattr(self, "loop"): self.loop.exit() - + def keyPressEvent(self, event) -> None: if event.key() == Qt.Key_Escape: event.ignore() return - + super().keyPressEvent(event) + class ScrollBarWithNumericControl(QWidget): sigValueChanged = Signal(int) sigMaxProjToggled = Signal(bool, object) - + def __init__( - self, orientation=Qt.Horizontal, add_max_proj_button=False, - parent=None, labelText='' - ) -> None: + self, + orientation=Qt.Horizontal, + add_max_proj_button=False, + parent=None, + labelText="", + ) -> None: super().__init__(parent) - + self._slot = None - + layout = QHBoxLayout() self.scrollbar = QScrollBar(orientation, self) self.spinbox = QSpinBox(self) @@ -8007,23 +8235,23 @@ def __init__( idx += 1 layout.addWidget(self.spinbox) - layout.setStretch(idx,0) + layout.setStretch(idx, 0) idx += 1 - + layout.addWidget(self.maxLabel) - layout.setStretch(idx,0) + layout.setStretch(idx, 0) idx += 1 - + layout.addWidget(self.scrollbar) - layout.setStretch(idx,1) + layout.setStretch(idx, 1) idx += 1 - + if add_max_proj_button: - self.maxProjCheckbox = QCheckBox('MAX') + self.maxProjCheckbox = QCheckBox("MAX") self.scrollbar.maxProjCheckbox = self.maxProjCheckbox layout.addWidget(self.maxProjCheckbox) - layout.setStretch(idx,0) - + layout.setStretch(idx, 0) + layout.setContentsMargins(5, 0, 5, 0) self.setLayout(layout) @@ -8033,115 +8261,120 @@ def __init__( if add_max_proj_button: self.maxProjCheckbox.toggled.connect(self.maxProjToggled) - + def connectValueChanged(self, slot): self.sigValueChanged.connect(slot) self._slot = slot - + def setValueNoSignal(self, value): if self._slot is None: return self.sigValueChanged.disconnect() self.setValue(value) self.sigValueChanged.connect(self._slot) - + def maxProjToggled(self, checked): self.scrollbar.setDisabled(checked) self.sigMaxProjToggled.emit(checked, self) - + def showEvent(self, event) -> None: super().showEvent(event) self.scrollbar.setMinimumHeight(self.spinbox.height()) - + def setMaximum(self, maximum): - self.maxLabel.setText(f'/{maximum}') + self.maxLabel.setText(f"/{maximum}") self.scrollbar.setMaximum(maximum) self.spinbox.setMaximum(maximum) - + def setMinimum(self, minumum): self.scrollbar.setMinimum(minumum) self.spinbox.setMinimum(minumum) - + def spinboxValueChanged(self, value): self.scrollbar.setValue(value) - + def scrollbarValueChanged(self, value): self.spinbox.setValue(value) self.sigValueChanged.emit(value) - + def setValue(self, value): self.scrollbar.setValue(value) - + def value(self): return self.scrollbar.value() - + def maximum(self): return self.scrollbar.maximum() + class ImShowPlotItem(pg.PlotItem): def __init__( - self, parent=None, name=None, labels=None, title=None, - viewBox=None, axisItems=None, enableMenu=True, **kargs - ): + self, + parent=None, + name=None, + labels=None, + title=None, + viewBox=None, + axisItems=None, + enableMenu=True, + **kargs, + ): super().__init__( - parent, name, labels, title, viewBox, axisItems, enableMenu, - **kargs + parent, name, labels, title, viewBox, axisItems, enableMenu, **kargs ) # Overwrite zoom out button behaviour to disable autoRange after # clicking it. - # If autorange is enabled, it is called everytime the brush or eraser + # If autorange is enabled, it is called everytime the brush or eraser # scatter plot items touches the border causing flickering self.disableAutoRange() - self.autoBtn.mode = 'manual' + self.autoBtn.mode = "manual" self.invertY(True) self.setAspectLocked(True) - self.addImageItem(kargs.get('imageItem')) - + self.addImageItem(kargs.get("imageItem")) + self._selected = False self.selectingRects = [] - + def setSelectableTitle(self, title: QGraphicsProxyWidget, **kwargs): self.layout.removeItem(self.titleLabel) self.layout.addItem(title, 0, 1, alignment=Qt.AlignCenter) - + def isSelected(self): return self._selected - + def setSelected( - self, selected: bool, - xlim=(-np.inf, np.inf), - ylim=(-np.inf, np.inf) - ): + self, selected: bool, xlim=(-np.inf, np.inf), ylim=(-np.inf, np.inf) + ): if selected == self._selected: return - + if selected: ((xmin, xmax), (ymin, ymax)) = self.viewRange() ylim_min, ylim_max = ylim xlim_min, xlim_max = xlim - + xmin = max(xlim_min, xmin) xmax = min(xlim_max, xmax) ymin = max(ylim_min, ymin) ymax = min(ylim_max, ymax) - + w = xmax - xmin h = ymax - ymin - + bs = round(((w + h) / 2) * 0.02) if bs < 1: bs = 1 - + rect_left = RectItem(QRectF(xmin, ymin, bs, h)) - rect_top = RectItem(QRectF(xmin+bs, ymin, w-bs-bs, bs)) - rect_right = RectItem(QRectF(xmax-bs, ymin, bs, h)) - rect_bottom = RectItem(QRectF(xmin+bs, ymax-bs, w-bs-bs, bs)) + rect_top = RectItem(QRectF(xmin + bs, ymin, w - bs - bs, bs)) + rect_right = RectItem(QRectF(xmax - bs, ymin, bs, h)) + rect_bottom = RectItem(QRectF(xmin + bs, ymax - bs, w - bs - bs, bs)) self.selectingRects.append(rect_left) self.selectingRects.append(rect_top) self.selectingRects.append(rect_right) self.selectingRects.append(rect_bottom) - + self.addItem(rect_left) self.addItem(rect_top) self.addItem(rect_right) @@ -8150,31 +8383,31 @@ def setSelected( for rect in self.selectingRects: self.removeItem(rect) self.selectingRects = [] - + self._selected = selected - + def addImageItem(self, imageItem): self.imageItem = imageItem if imageItem is None: return - + self.setupContextMenu() self.addItem(imageItem) - + def setupContextMenu(self): - shuffleCmapAction = QAction('Shuffle colormap', self.vb.menu) + shuffleCmapAction = QAction("Shuffle colormap", self.vb.menu) shuffleCmapAction.triggered.connect(self.shuffleColormap) self.vb.menu.addAction(shuffleCmapAction) - - self.resetCmapAction = QAction('Reset colormap', self.vb.menu) + + self.resetCmapAction = QAction("Reset colormap", self.vb.menu) self.resetCmapAction.triggered.connect(self.resetColormap) self.vb.menu.addAction(self.resetCmapAction) self.resetCmapAction.setDisabled(True) - + def shuffleColormap(self): N = self.imageItem._numLevels - colors = self.imageItem.lut/255 - cmap = LinearSegmentedColormap.from_list('shuffled', colors, N=N) + colors = self.imageItem.lut / 255 + cmap = LinearSegmentedColormap.from_list("shuffled", colors, N=N) lut = plot.matplotlib_cmap_to_lut(cmap, n_colors=N) if not self.resetCmapAction.isEnabled(): self._defaultLut = lut.copy() @@ -8184,17 +8417,18 @@ def shuffleColormap(self): self.imageItem.setLookupTable(lut) self.imageItem.update() self.resetCmapAction.setDisabled(False) - + def resetColormap(self): self.imageItem.setLookupTable(self._defaultLut) - + def autoBtnClicked(self): self.autoRange() - + def autoRange(self): self.vb.autoRange() self.autoBtn.hide() + class _ImShowImageItem(pg.ImageItem): sigDataHover = Signal(str) sigHoverEvent = Signal(object, object) @@ -8205,87 +8439,84 @@ def __init__(self, idx) -> None: self._idx = idx self._cursors = [] self._autoLevels = True - + def _getHoverImageValue(self, xdata, ydata): try: value = self.image[ydata, xdata] return value except Exception as err: return - + def setAutoLevels(self, autoLevels): self._autoLevels = autoLevels - + def mousePressEvent(self, event): self.sigMousePressEvent.emit(self, event) super().mousePressEvent(event) - + def setOtherImagesCursors(self, cursors): self._cursors = cursors - + def clearCursors(self): for p, cursor in enumerate(self._cursors): if p == self._idx: continue - + cursor.setData([], []) - + def setImage(self, *args, **kwargs): - if 'autoLevels' not in kwargs: - kwargs['autoLevels'] = self._autoLevels - - super().setImage(*args, **kwargs) + if "autoLevels" not in kwargs: + kwargs["autoLevels"] = self._autoLevels + + super().setImage(*args, **kwargs) if not args: return - - if not kwargs['autoLevels']: + + if not kwargs["autoLevels"]: return - + image = args[0] self._imageMax = image.max() self._imageMin = image.min() self._numLevels = self._imageMax - self._imageMin - + def hoverEvent(self, event): self.sigHoverEvent.emit(self, event) - + if event.isExit(): self.clearCursors() - self.sigDataHover.emit('') + self.sigDataHover.emit("") return - + x, y = event.pos() xdata, ydata = int(x), int(y) value = self._getHoverImageValue(xdata, ydata) if value is None: self.clearCursors() - self.sigDataHover.emit('') + self.sigDataHover.emit("") return - + try: - self.sigDataHover.emit( - f'x={xdata}, y={ydata}, {value = :.4f}' - ) + self.sigDataHover.emit(f"x={xdata}, y={ydata}, {value = :.4f}") except Exception as e: - self.sigDataHover.emit( - f'x={xdata}, y={ydata}, {[val for val in value]}' - ) - + self.sigDataHover.emit(f"x={xdata}, y={ydata}, {[val for val in value]}") + for p, cursor in enumerate(self._cursors): if p == self._idx: continue - + cursor.setData([x], [y]) + class ImShow(QBaseWindow): def __init__( - self, - parent=None, - link_scrollbars=True, - infer_rgb=True, - figure_title='', - selectable_images=False - ): + self, + parent=None, + link_scrollbars=True, + infer_rgb=True, + figure_title="", + selectable_images=False, + ): super().__init__(parent=parent) self._linkedScrollbars = link_scrollbars self._infer_rgb = infer_rgb @@ -8296,8 +8527,8 @@ def __init__( self._autoLevels = True self.textItems = [] - self.group_to_idx_mapper = {'': 0} - + self.group_to_idx_mapper = {"": 0} + def _getGraphicsScrollbar(self, idx, image, imageItem, maximum): proxy = QGraphicsProxyWidget(imageItem) scrollbar = ScrollBarWithNumericControl( @@ -8312,17 +8543,17 @@ def _getGraphicsScrollbar(self, idx, image, imageItem, maximum): proxy.setWidget(scrollbar) proxy.scrollbar = scrollbar return proxy - + def OnScrollbarValueChanged(self, value): scrollbar = self.sender() imageItem = scrollbar.imageItem img = self._get2Dimg(imageItem, scrollbar.image) - imageItem.setImage(img) # , autoLevels=self._autoLevels) - + imageItem.setImage(img) # , autoLevels=self._autoLevels) + overlayLab = self._get2DlabOverlay(imageItem) if overlayLab is not None: imageItem.labImageItem.setImage(overlayLab, autoLevels=False) - + self.setPointsVisible(imageItem) self.updateIDs() @@ -8331,7 +8562,7 @@ def OnScrollbarValueChanged(self, value): return if len(self.ImageItems) == 1: return - + self._linkedScrollbars = False try: idx = scrollbar.idx @@ -8348,7 +8579,7 @@ def OnScrollbarValueChanged(self, value): pass finally: self._linkedScrollbars = True - + def _get2Dimg(self, imageItem, image): for scrollbar in imageItem.ScrollBars: if scrollbar.maxProjCheckbox.isChecked(): @@ -8356,40 +8587,40 @@ def _get2Dimg(self, imageItem, image): else: image = image[scrollbar.value()] return image - + def _get2DlabOverlay(self, imageItem): try: lab = imageItem.lab except Exception as err: - return - + return + for scrollbar in imageItem.ScrollBars: if scrollbar.maxProjCheckbox.isChecked(): lab = lab.max(axis=0) else: lab = lab[scrollbar.value()] - + return lab - + def isObjVisible(self, obj, imageItem): if len(obj.centroid) == 2: return True - + z_scrollbar = imageItem.ScrollBars[-1] if z_scrollbar.maxProjCheckbox.isChecked(): return True - + z_slice = z_scrollbar.value() min_z, min_y, min_x, max_z, max_y, max_x = obj.bbox if z_slice >= min_z and z_slice < max_z: return True return False - + def onMaxProjToggled(self, checked, scrollbar): imageItem = scrollbar.imageItem img = self._get2Dimg(imageItem, scrollbar.image) - imageItem.setImage(img) # , autoLevels=self._autoLevels) + imageItem.setImage(img) # , autoLevels=self._autoLevels) overlayLab = self._get2DlabOverlay(imageItem) if overlayLab is not None: imageItem.labImageItem.setImage(overlayLab, autoLevels=False) @@ -8398,7 +8629,7 @@ def onMaxProjToggled(self, checked, scrollbar): return if len(self.ImageItems) == 1: return - + self._linkedScrollbars = False try: idx = scrollbar.idx @@ -8415,71 +8646,69 @@ def onMaxProjToggled(self, checked, scrollbar): pass finally: self._linkedScrollbars = True - + self.updateIDs() def setPointsVisible(self, imageItem): - if not hasattr(imageItem, 'pointsItems'): + if not hasattr(imageItem, "pointsItems"): return - + first_coord = imageItem.ScrollBars[0].value() isMaxProj = imageItem.ScrollBars[0].maxProjCheckbox.isChecked() for pointsItems in imageItem.pointsItems.values(): for p, plotItem in enumerate(pointsItems): plotItem.setVisible((isMaxProj) or (p == first_coord)) - + def setupStatusBar(self): self.statusbar = self.statusBar() self.wcLabel = QLabel(f"") self.statusbar.addPermanentWidget(self.wcLabel) - + def setupMainLayout(self): self._layout = QHBoxLayout() self._container = QWidget() self._container.setLayout(self._layout) self.setCentralWidget(self._container) - + def setupGraphicLayout( - self, *images, hide_axes=True, max_ncols=4, color_scheme='light' - ): + self, *images, hide_axes=True, max_ncols=4, color_scheme="light" + ): self.graphicLayout = pg.GraphicsLayoutWidget() self._colorScheme = color_scheme # Set a light background - if color_scheme == 'light': + if color_scheme == "light": self.graphicLayout.setBackground((235, 235, 235)) else: self.graphicLayout.setBackground((30, 30, 30)) - ncells = max_ncols * ceil(len(images)/max_ncols) + ncells = max_ncols * ceil(len(images) / max_ncols) nrows = ncells // max_ncols nrows = nrows if nrows > 0 else 1 ncols = max_ncols if len(images) > max_ncols else len(images) - - if color_scheme == 'light': - color = 'black' + + if color_scheme == "light": + color = "black" else: - color = 'white' - - self.titleLabel = pg.LabelItem( - justify='center', color=color, size='14pt' - ) + color = "white" + + self.titleLabel = pg.LabelItem(justify="center", color=color, size="14pt") self.titleLabel.setText(self._figure_title) self.graphicLayout.addItem(self.titleLabel, row=0, col=0, colspan=ncols) start_row = 1 - + # Check if additional rows are needed for the scrollbars max_ndim = max([image.ndim for image in images]) if max_ndim > 4: - raise TypeError('One or more of the images have more than 4 dimensions.') + raise TypeError("One or more of the images have more than 4 dimensions.") if max_ndim == 4: - rows_range = range(0, (nrows-1)*3+1, 3) + rows_range = range(0, (nrows - 1) * 3 + 1, 3) elif max_ndim == 3: - rows_range = range(0, (nrows-1)*2+1, 2) + rows_range = range(0, (nrows - 1) * 2 + 1, 2) else: rows_range = range(nrows) - + self.PlotItems = [] self.ImageItems = [] self.ScrollBars = [] @@ -8493,8 +8722,8 @@ def setupGraphicLayout( break plotItem = ImShowPlotItem() if hide_axes: - plotItem.hideAxis('bottom') - plotItem.hideAxis('left') + plotItem.hideAxis("bottom") + plotItem.hideAxis("left") self.graphicLayout.addItem(plotItem, row=row, col=col) plotItem.loc = (row, col) self.PlotItems.append(plotItem) @@ -8502,91 +8731,80 @@ def setupGraphicLayout( imageItem = _ImShowImageItem(i) plotItem.addImageItem(imageItem) imageItem.plot = plotItem - imageItem.sigHoverEvent.connect( - self.onImageItemHoverEvent - ) - imageItem.sigMousePressEvent.connect( - self.onImageItemMousePressEvent - ) + imageItem.sigHoverEvent.connect(self.onImageItemHoverEvent) + imageItem.sigMousePressEvent.connect(self.onImageItemMousePressEvent) self.ImageItems.append(imageItem) imageItem.gridPos = (row, col) imageItem.ScrollBars = [] - + is_rgb = image.shape[-1] == 3 and self._infer_rgb is_rgba = image.shape[-1] == 4 and self._infer_rgb - does_not_require_scrollbars = ( - image.ndim == 2 - or (image.ndim == 3 and (is_rgb or is_rgba)) + does_not_require_scrollbars = image.ndim == 2 or ( + image.ndim == 3 and (is_rgb or is_rgba) ) if does_not_require_scrollbars: i += 1 continue - + idx_image = 3 if (is_rgb or is_rgba) else 2 - for s in range(image.ndim-idx_image): - maximum = image.shape[s]-1 + for s in range(image.ndim - idx_image): + maximum = image.shape[s] - 1 scrollbarProxy = self._getGraphicsScrollbar( s, image, imageItem, maximum ) - self.graphicLayout.addItem( - scrollbarProxy, row=row+s+1, col=col - ) + self.graphicLayout.addItem(scrollbarProxy, row=row + s + 1, col=col) imageItem.ScrollBars.append(scrollbarProxy.scrollbar) i += 1 - + self._layout.addWidget(self.graphicLayout) - + def onImageItemMousePressEvent(self, imageItem, event): if not self._selectable_images: return - + plotItem = imageItem.plot if not plotItem.isSelected(): return - + self.selected_idx = self.PlotItems.index(plotItem) event.ignore() self.close() - + def onImageItemHoverEvent(self, imageItem, event): if not self._selectable_images: return - + modifiers = QGuiApplication.keyboardModifiers() isCtrl = modifiers == Qt.ControlModifier plotItem = imageItem.plot Y, X = imageItem.image.shape[:2] - plotItem.setSelected( - isCtrl and not event.isExit(), - xlim=(0, X), - ylim=(0, Y) - ) - + plotItem.setSelected(isCtrl and not event.isExit(), xlim=(0, X), ylim=(0, Y)) + def movePlotItem(self, title): combobox = self.sender() plotItem = combobox.plotItem row, col = plotItem.loc - + otherPlotItemIdx = combobox.titles.index(title) otherPlotItem = self.PlotItems[otherPlotItemIdx] other_row, other_col = otherPlotItem.loc - + self.graphicLayout.removeItem(plotItem) self.graphicLayout.removeItem(otherPlotItem) self.graphicLayout.addItem(otherPlotItem, row=row, col=col) self.graphicLayout.addItem(plotItem, row=other_row, col=other_col) - + combobox.blockSignals(True) combobox.setCurrentText(combobox.default_text) combobox.blockSignals(False) - + plotItemIdx = combobox.titles.index(combobox.default_text) - + otherPlotItem.loc = (row, col) plotItem.loc = (other_row, other_col) - - def setupTitles(self, *titles): + + def setupTitles(self, *titles): for plotItem, title in zip(self.PlotItems, titles): combobox = ComboBox() combobox.default_text = title @@ -8599,28 +8817,29 @@ def setupTitles(self, *titles): combobox.plotItem = plotItem plotItem.setSelectableTitle(comboboxGraphicsItem) combobox.currentTextChanged.connect(self.movePlotItem) - + # color = 'k' if self._colorScheme == 'light' else 'w' # for plotItem, title in zip(self.PlotItems, titles): # plotItem.setSelectableTitle(title, color=color) - + def updateStatusBarLabel(self, text): self.wcLabel.setText(text) - + def autoRange(self): for plot in self.PlotItems: plot.autoRange() - + def showImages( - self, *images, - labels_overlays: np.ndarray | List[np.ndarray]=None, - luts=None, - labels_overlays_luts=None, - autoLevels=True, - autoLevelsOnScroll=False - ): + self, + *images, + labels_overlays: np.ndarray | List[np.ndarray] = None, + luts=None, + labels_overlays_luts=None, + autoLevels=True, + autoLevelsOnScroll=False, + ): from .plot import matplotlib_cmap_to_lut - + images = [np.squeeze(img) for img in images] self.luts = luts self._autoLevels = autoLevels @@ -8628,34 +8847,34 @@ def showImages( for image in images: if image.ndim > 5 or image.ndim < 2: raise TypeError( - f'Input image has {image.ndim} dimensions. ' - 'Only 2-D, 3-D, and 4-D images are supported' + f"Input image has {image.ndim} dimensions. " + "Only 2-D, 3-D, and 4-D images are supported" ) - + if isinstance(labels_overlays, np.ndarray): labels_overlays = [labels_overlays] - + if isinstance(labels_overlays_luts, np.ndarray): labels_overlays_luts = [labels_overlays_luts] - + if ( - labels_overlays_luts is not None - and labels_overlays is not None - and (len(labels_overlays_luts) != len(labels_overlays)) - ): + labels_overlays_luts is not None + and labels_overlays is not None + and (len(labels_overlays_luts) != len(labels_overlays)) + ): raise TypeError( - f'Number of lables_overlays_luts is {len(labels_overlays_luts)}, ' - f'while number of labels_overaly is {len(labels_overlays)}. ' - 'Pass `None` if you want to use default lut for the labels_overlays.' + f"Number of lables_overlays_luts is {len(labels_overlays_luts)}, " + f"while number of labels_overaly is {len(labels_overlays)}. " + "Pass `None` if you want to use default lut for the labels_overlays." ) - + if labels_overlays is not None and (len(labels_overlays) != len(images)): raise TypeError( - f'Number of images is {len(images)}, ' - f'while number of labels_overaly is {len(labels_overlays)}. ' - 'Pass `None` if you do not need overlaid labeles.' + f"Number of images is {len(images)}, " + f"while number of labels_overaly is {len(labels_overlays)}. " + "Pass `None` if you do not need overlaid labeles." ) - + for i, (image, imageItem) in enumerate(zip(images, self.ImageItems)): if luts is not None: _autoLevels = autoLevels @@ -8665,18 +8884,17 @@ def showImages( else: _autoLevels = True if lut is None: - lut = matplotlib_cmap_to_lut('viridis') + lut = matplotlib_cmap_to_lut("viridis") imageItem.setLookupTable(lut) else: _autoLevels = True - + is_rgb = image.shape[-1] == 3 and self._infer_rgb is_rgba = image.shape[-1] == 4 and self._infer_rgb - does_not_require_scrollbars = ( - image.ndim == 2 - or (image.ndim == 3 and (is_rgb or is_rgba)) + does_not_require_scrollbars = image.ndim == 2 or ( + image.ndim == 3 and (is_rgb or is_rgba) ) - + if does_not_require_scrollbars: imageItem.setAutoLevels(_autoLevels) imageItem.setImage(image) @@ -8685,44 +8903,42 @@ def showImages( imageItem.setAutoLevels(False) imageItem.setLevels([image.min(), image.max()]) for scrollbar in imageItem.ScrollBars: - scrollbar.setValue(int(scrollbar.maximum()/2)) - + scrollbar.setValue(int(scrollbar.maximum() / 2)) + imageItem.sigDataHover.connect(self.updateStatusBarLabel) - + if labels_overlays is None: continue - - lab_overlay = labels_overlays[i] + + lab_overlay = labels_overlays[i] if lab_overlay is None: continue - + if lab_overlay.shape != image.shape: raise TypeError( - f'`lab_overlay` at index {i} has shape ' - f'{lab_overlay.shape} which is different ' - f'from image shape {image.shape}. ' - 'The image and the `lab_overlay` must ' - 'have the same shape.' + f"`lab_overlay` at index {i} has shape " + f"{lab_overlay.shape} which is different " + f"from image shape {image.shape}. " + "The image and the `lab_overlay` must " + "have the same shape." ) - + plot = imageItem.plot labImageItem = pg.ImageItem() labImageItem.setOpacity(0.4) plot.addImageItem(labImageItem) - + if labels_overlays_luts is not None: labels_overlays_lut = labels_overlays_luts[i] else: - labels_overlays_lut = self._getDefaultLabelsOverlayLut( - lab_overlay - ) - + labels_overlays_lut = self._getDefaultLabelsOverlayLut(lab_overlay) + labImageItem.setLookupTable(labels_overlays_lut) labImageItem.setLevels([0, len(labels_overlays_lut)]) - + imageItem.lab = lab_overlay imageItem.labImageItem = labImageItem - + overlayLab = self._get2DlabOverlay(imageItem) labImageItem.setImage(overlayLab, autoLevels=False) @@ -8732,129 +8948,130 @@ def showImages( shame_shape_plots = [] for unique_shape in unique_shapes: plots = [ - self.PlotItems[i] for i, shape in enumerate(all_shapes) - if shape==unique_shape + self.PlotItems[i] + for i, shape in enumerate(all_shapes) + if shape == unique_shape ] shame_shape_plots.append(plots) - + for plots in shame_shape_plots: for plot in plots: plot.vb.setYLink(plots[0].vb) plot.vb.setXLink(plots[0].vb) - + def _getDefaultLabelsOverlayLut(self, lab_overlay): IDs = [obj.label for obj in skimage.measure.regionprops(lab_overlay)] n_objs = len(IDs) - lut = np.zeros((n_objs+1, 4), dtype=np.uint8) - rgbas = colors.plt_colormap_to_pg_lut('tab20', ncolors=n_objs) + lut = np.zeros((n_objs + 1, 4), dtype=np.uint8) + rgbas = colors.plt_colormap_to_pg_lut("tab20", ncolors=n_objs) np.random.shuffle(rgbas) lut[1:] = rgbas return lut - + def _createPointsScatterItem(self, xx, yy, group, colors=None, data=None): if colors is None: - cmap = matplotlib.colormaps['jet_r'] + cmap = matplotlib.colormaps["jet_r"] idx = self.group_to_idx_mapper[group] - r, g, b = [round(c*255) for c in cmap(idx)][:3] - brush = pg.mkBrush(color=(r,g,b,100)) - pen = pg.mkPen(width=2, color=(r,g,b)) - hoverBrush = pg.mkBrush((r,g,b,200)) + r, g, b = [round(c * 255) for c in cmap(idx)][:3] + brush = pg.mkBrush(color=(r, g, b, 100)) + pen = pg.mkPen(width=2, color=(r, g, b)) + hoverBrush = pg.mkBrush((r, g, b, 200)) else: brush = [] pen = [] hoverBrush = None for color in colors: rgb = matplotlib.colors.to_rgb(color) - rgb = [round(c*255) for c in rgb] - _brush = pg.mkBrush(color=(*rgb,100)) + rgb = [round(c * 255) for c in rgb] + _brush = pg.mkBrush(color=(*rgb, 100)) _pen = pg.mkPen(width=2, color=rgb) brush.append(_brush) pen.append(_pen) - + item = pg.ScatterPlotItem( - xx, yy, symbol='o', pxMode=False, size=3, - brush=brush, pen=pen, - hoverable=True, hoverBrush=hoverBrush, - data=data - ) + xx, + yy, + symbol="o", + pxMode=False, + size=3, + brush=brush, + pen=pen, + hoverable=True, + hoverBrush=hoverBrush, + data=data, + ) return item def drawPointsFromDf( - self, - points_df: pd.DataFrame | List[pd.DataFrame], - points_groups=None - ): + self, points_df: pd.DataFrame | List[pd.DataFrame], points_groups=None + ): if not isinstance(points_df, (list, tuple)): - points_df = [points_df]*len(self.PlotItems) - + points_df = [points_df] * len(self.PlotItems) + for p, df in enumerate(points_df): if isinstance(points_groups, str): points_groups = [points_groups] - + if points_groups is None: - grouped = [('', df)] - groups = [''] + grouped = [("", df)] + groups = [""] else: grouped = df.groupby(points_groups) groups = grouped.groups.keys() - + idxs_space = np.linspace(0, 1, len(groups)) self.group_to_idx_mapper = dict(zip(groups, idxs_space)) for group, df in grouped: - yy = df['y'].values - xx = df['x'].values + yy = df["y"].values + xx = df["x"].values points_coords = np.column_stack((yy, xx)) - if 'z' in df.columns: - zz = df['z'].values + if "z" in df.columns: + zz = df["z"].values points_coords = np.column_stack((zz, points_coords)) if len(group) == 1: group = group[0] - + colors = None - if 'color' in df.columns: - colors = df['color'].values - + if "color" in df.columns: + colors = df["color"].values + data = None - if 'data' in df.columns: - data = df['data'].values - + if "data" in df.columns: + data = df["data"].values + self.drawPoints( - points_coords, - colors=colors, - group=group, - idx=p, - data=data + points_coords, colors=colors, group=group, idx=p, data=data ) - + def drawPoints( - self, - points_coords: np.ndarray, - group='', - idx=None, - colors=None, - data=None, - ): + self, + points_coords: np.ndarray, + group="", + idx=None, + colors=None, + data=None, + ): offset = 0.5 if np.issubdtype(points_coords.dtype, np.integer) else 0 n_dim = points_coords.shape[1] - + if idx is not None: PlotItems = [self.PlotItems[idx]] ImageItems = [self.ImageItems[idx]] else: PlotItems = self.PlotItems ImageItems = self.ImageItems - + if n_dim == 2: if data is None: data = group - - zz = [0]*len(points_coords) + + zz = [0] * len(points_coords) self.points_coords = np.column_stack((zz, points_coords)) for p, plotItem in enumerate(PlotItems): imageItem = ImageItems[p] xx = points_coords[:, 1] + offset - yy = points_coords[:, 0] + offset + yy = points_coords[:, 0] + offset pointsItem = self._createPointsScatterItem( xx, yy, group, data=data, colors=colors ) @@ -8867,22 +9084,22 @@ def drawPoints( imageItem = ImageItems[p] imageItem.pointsItems = defaultdict(list) scrollbar = imageItem.ScrollBars[0] - for first_coord in range(scrollbar.maximum()+1): - coords_idx = np.nonzero(points_coords[:,0] == first_coord) + for first_coord in range(scrollbar.maximum() + 1): + coords_idx = np.nonzero(points_coords[:, 0] == first_coord) coords = points_coords[coords_idx] if colors is None: - _colors = None + _colors = None else: _colors = np.asarray(colors)[coords_idx] if len(_colors) == 0: _colors = None - + _data = group if data is not None: _data = data[coords_idx] if len(_data) == 0: _data = group - + xx = coords[:, 2] + offset yy = coords[:, 1] + offset pointsItem = self._createPointsScatterItem( @@ -8893,26 +9110,32 @@ def drawPoints( pointsItem.setVisible(False) imageItem.pointsItems[group].append(pointsItem) self.setPointsVisible(imageItem) - + def setupDuplicatedCursors(self): self.cursors = [] for p, plotItem in enumerate(self.PlotItems): cursor = pg.ScatterPlotItem( - symbol='+', pxMode=True, pen=pg.mkPen('k', width=1), - brush=pg.mkBrush('w'), size=16, tip=None + symbol="+", + pxMode=True, + pen=pg.mkPen("k", width=1), + brush=pg.mkBrush("w"), + size=16, + tip=None, ) self.cursors.append(cursor) plotItem.addItem(cursor) - + for imageItem in self.ImageItems: imageItem.setOtherImagesCursors(self.cursors) - + def setPointsData(self, points_data): - points_df = pd.DataFrame({ - 'z': self.points_coords[:, 0], - 'y': self.points_coords[:, 1], - 'x': self.points_coords[:, 2] - }) + points_df = pd.DataFrame( + { + "z": self.points_coords[:, 0], + "y": self.points_coords[:, 1], + "x": self.points_coords[:, 2], + } + ) if isinstance(points_data, pd.Series): points_df[points_data.name] = points_data.values elif isinstance(points_data, pd.DataFrame): @@ -8923,33 +9146,33 @@ def setPointsData(self, points_data): else: points_data = points_data.T for i, values in enumerate(points_data): - points_df[f'col_{i}'] = values + points_df[f"col_{i}"] = values + + self.points_df = points_df.set_index(["z", "y", "x"]).sort_index() - self.points_df = points_df.set_index(['z', 'y', 'x']).sort_index() - for p, plotItem in enumerate(self.PlotItems): imageItem = self.ImageItems[p] for pointsItems in imageItem.pointsItems.values(): for pointsItem in pointsItems: pointsItem.sigClicked.connect(self.pointsClicked) - + def pointsClicked(self, item, points, event): point = points[0] x, y = point.pos() coords = (item.z, int(y), int(x)) point_data = self.points_df.loc[[coords]] - now = datetime.datetime.now().strftime('%H:%M:%S') - print('*'*60) - print(f'Point clicked at {now}. Data:') - print('-'*60) + now = datetime.datetime.now().strftime("%H:%M:%S") + print("*" * 60) + print(f"Point clicked at {now}. Data:") + print("-" * 60) print(point_data) - print('') - print('*'*60) + print("") + print("*" * 60) def annotateObjectIDs(self, annotate_labels_idxs=None, init=False): if init: self.annotate_labels_idxs = annotate_labels_idxs - self.textItems = [{} for _ in self.PlotItems] + self.textItems = [{} for _ in self.PlotItems] if self.annotate_labels_idxs is None: return for i, plotItem in enumerate(self.PlotItems): @@ -8965,39 +9188,35 @@ def annotateObjectIDs(self, annotate_labels_idxs=None, init=False): lab = imageItem.labImageItem.image except Exception as err: lab = imageItem.image - + rp = skimage.measure.regionprops(lab) for obj in rp: textItem = plotTextItems.get(obj.label) yc, xc = obj.centroid[-2:] if textItem is None: - textItem = pg.TextItem( - text='', anchor=(0.5,0.5), color='r' - ) + textItem = pg.TextItem(text="", anchor=(0.5, 0.5), color="r") plotItem.addItem(textItem) plotTextItems[obj.label] = textItem if self.isObjVisible(obj, imageItem): text = str(obj.label) else: - text = '' - + text = "" + textItem.setText(text) textItem.setPos(xc, yc) - + # plotItem.enableAutoRange() def clearLabels(self): for textItems in self.textItems: for textItem in textItems.values(): - textItem.setText('') + textItem.setText("") def updateIDs(self): self.clearLabels() try: - self.annotateObjectIDs( - annotate_labels_idxs=self.annotate_labels_idxs - ) + self.annotateObjectIDs(annotate_labels_idxs=self.annotate_labels_idxs) except Exception as err: pass @@ -9008,43 +9227,44 @@ def show(self, block=False, screenToWindowRatio=None): screenGeometry = self.screen().geometry() screenWidth = screenGeometry.width() screenHeight = screenGeometry.height() - finalWidth = int(screenToWindowRatio*screenWidth) - finalHeight = int(screenToWindowRatio*screenHeight) + finalWidth = int(screenToWindowRatio * screenWidth) + finalHeight = int(screenToWindowRatio * screenHeight) screenTop = screenGeometry.top() screenLeft = screenGeometry.left() - xc, yc = screenLeft + screenWidth/2, screenTop + screenHeight/2 - winLeft = int(xc - finalWidth/2) - winTop = int(yc - finalHeight/2) + xc, yc = screenLeft + screenWidth / 2, screenTop + screenHeight / 2 + winLeft = int(xc - finalWidth / 2) + winTop = int(yc - finalHeight / 2) self.setGeometry(winLeft, winTop, finalWidth, finalHeight) - + def run(self, block=False, showMaximised=False, screenToWindowRatio=None): if showMaximised: self.showMaximized() else: self.show(screenToWindowRatio=screenToWindowRatio) QTimer.singleShot(100, self.autoRange) - + if block: self.exec_() - + def resizeEvent(self, event) -> None: - self.PlotItems[0].autoRange() + self.PlotItems[0].autoRange() return super().resizeEvent(event) + class FeatureSelectorButton(QPushButton): - def __init__(self, text, parent=None, alignment=''): + def __init__(self, text, parent=None, alignment=""): super().__init__(text, parent=parent) self._isFeatureSet = False self._alignment = alignment self.setCursor(Qt.PointingHandCursor) - + def setFeatureText(self, text): self.setText(text) self.setFlat(True) self._isFeatureSet = True if self._alignment: - self.setStyleSheet(f'text-align:{self._alignment};') - + self.setStyleSheet(f"text-align:{self._alignment};") + def enterEvent(self, event) -> None: if self._isFeatureSet: self.setFlat(False) @@ -9060,54 +9280,57 @@ def setSizeLongestText(self, longestText): currentText = self.text() self.setText(longestText) w, h = self.sizeHint().width(), self.sizeHint().height() - self.setMinimumWidth(w+10) + self.setMinimumWidth(w + 10) # self.setMinimumHeight(h+5) self.setText(currentText) + class CheckableSpinBoxWidgets: def __init__(self, isFloat=True): if isFloat: self.spinbox = FloatLineEdit() else: self.spinbox = SpinBox() - self.checkbox = QCheckBox('Activate') + self.checkbox = QCheckBox("Activate") self.spinbox.setEnabled(False) self.checkbox.toggled.connect(self.spinbox.setEnabled) - + def value(self): if not self.checkbox.isChecked(): return return self.spinbox.value() + class Label(QLabel): def __init__(self, parent=None, force_html=False): super().__init__(parent) self._force_html = force_html - + def setText(self, text): if self._force_html: text = html_utils.paragraph(text) super().setText(text) - - -class LabelItem(pg.LabelItem): + + +class LabelItem(pg.LabelItem): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + def bbox(self): xl, yl = self.pos().x(), self.pos().y() wl, hl = self.itemRect().width(), self.itemRect().height() - return yl, xl, yl+hl, xl+wl - + return yl, xl, yl + hl, xl + wl + def setBold(self, bold=True): self.origPos = self.pos() self.setText(self.text, bold=bold) self.setPos(self.origPos) -class ScaleBar(QGraphicsObject): + +class ScaleBar(QGraphicsObject): sigEditProperties = Signal(object) sigRemove = Signal(object) - + def __init__(self, imageShape, viewRange, parent=None): super().__init__(parent) self.SizeY, self.SizeX = imageShape @@ -9120,259 +9343,255 @@ def __init__(self, imageShape, viewRange, parent=None): self._parent = parent self.clicked = False self.createContextMenu() - + def updateViewRange(self, viewRange): xRange, yRange = viewRange x0, x1 = xRange y0, y1 = yRange if x0 < 0: x0 = 0 - + if x1 > self.SizeX: x1 = self.SizeX - + if y0 < 0: y0 = 0 - + if y1 > self.SizeY: y1 = self.SizeY - + self.xmax = x1 self.xmin = x0 - + self.ymax = y1 self.ymin = y0 - + def createContextMenu(self): self.contextMenu = QMenu() - action = QAction('Edit properties...', self.contextMenu) + action = QAction("Edit properties...", self.contextMenu) action.triggered.connect(self.emitEditProperties) self.contextMenu.addSeparator() - action = QAction('Remove', self.contextMenu) + action = QAction("Remove", self.contextMenu) action.triggered.connect(self.emitRemove) self.contextMenu.addAction(action) - + def emitEditProperties(self): self.setHighlighted(False) self.sigEditProperties.emit(self.properties()) - + def emitRemove(self): self.sigRemove.emit(self) - + def isHighlighted(self): return self._highlighted - + def setHighlighted(self, highlighted): if self._highlighted and highlighted: return - + if not self._highlighted and not highlighted: return - + pen = self.highlightPen if highlighted else self.pen self.labelItem.setBold(bold=highlighted) self.plotItem.setPen(pen) - + self._highlighted = highlighted - + def showContextMenu(self, x, y): self.contextMenu.popup(QPoint(int(x), int(y))) - + def properties(self): properties = { - 'thickness': self._thickness, - 'length_pixel': self._length, - 'length_unit': self._length_unit, - 'is_text_visible': self._is_text_visible, - 'color': self._color, - 'loc': self._loc, - 'font_size': float(self._font_size[:-2]), - 'unit': self._unit, - 'num_decimals': self._num_decimals, - 'move_with_zoom': self._move_with_zoom, + "thickness": self._thickness, + "length_pixel": self._length, + "length_unit": self._length_unit, + "is_text_visible": self._is_text_visible, + "color": self._color, + "loc": self._loc, + "font_size": float(self._font_size[:-2]), + "unit": self._unit, + "num_decimals": self._num_decimals, + "move_with_zoom": self._move_with_zoom, } return properties - + def move(self, xm, ym): - self._loc = 'Custom' - + self._loc = "Custom" + Dy = ym - self.yc Dx = xm - self.xc - + x0 = self.x0c + Dx x1 = x0 + self._length y0 = y1 = self.y0c + Dy self.plotItem.setData([x0, x1], [y0, y1]) self.setTextPos() - + def paint(self, painter, option, widget): pass - + def boundingRect(self): ymin, xmin, ymax, xmax = self.bbox() - return QRectF(xmin, ymin, xmax-xmin, ymax-ymin) - + return QRectF(xmin, ymin, xmax - xmin, ymax - ymin) + def setLocationProperty(self, loc: str): - self._loc = loc - + self._loc = loc + def setMoveWithZoomProperty(self, move_with_zoom): self._move_with_zoom = move_with_zoom - + def setProperties( - self, - length_pixel, - length_unit, - thickness=3, - color='w', - is_text_visible=True, - loc='top-left', - font_size=12, - unit='', - num_decimals=0, - move_with_zoom=False - ): + self, + length_pixel, + length_unit, + thickness=3, + color="w", + is_text_visible=True, + loc="top-left", + font_size=12, + unit="", + num_decimals=0, + move_with_zoom=False, + ): self._loc = loc self._color = color self._length = length_pixel self._length_unit = length_unit self._is_text_visible = is_text_visible - self._font_size = f'{font_size}px' + self._font_size = f"{font_size}px" self._unit = unit self._num_decimals = num_decimals self._move_with_zoom = move_with_zoom self._thickness = thickness self.pen = pg.mkPen(width=thickness, color=color, cosmetic=False) - self.highlightPen = pg.mkPen( - width=thickness+2, color=color, cosmetic=False - ) + self.highlightPen = pg.mkPen(width=thickness + 2, color=color, cosmetic=False) self.pen.setCapStyle(Qt.PenCapStyle.FlatCap) self.highlightPen.setCapStyle(Qt.PenCapStyle.FlatCap) self.plotItem.setPen(self.pen) - + def updatePhysicalLength(self, PhysicalSizeX): length_unit = self._length_unit unit = self._unit - length_um = _core.convert_length(length_unit, unit, 'μm') - length_pixel = length_um/PhysicalSizeX + length_um = _core.convert_length(length_unit, unit, "μm") + length_pixel = length_um / PhysicalSizeX self._length = length_pixel self.update() - + def addToAxis(self, ax): ax.addItem(self.plotItem) ax.addItem(self.labelItem) - + def setText(self): if self._is_text_visible: number = round(self._length_unit, self._num_decimals) if self._num_decimals == 0: number = int(number) - text = f'{number} {self._unit}' + text = f"{number} {self._unit}" else: - text = '' - self.labelItem.setText( - text, color=self._color, size=self._font_size - ) - + text = "" + self.labelItem.setText(text, color=self._color, size=self._font_size) + def setTextPos(self): xx, yy = self.plotItem.getData() x0 = xx[0] y0 = yy[0] - xc = x0 + self._length/2 + xc = x0 + self._length / 2 wl = self.labelItem.itemRect().width() hl = self.labelItem.itemRect().height() - xl = xc-wl/2 - yt = y0-hl + xl = xc - wl / 2 + yt = y0 - hl self.labelItem.setPos(xl, yt) - + def updatePosViewRangeChanged(self, viewRange): - if self._loc == 'custom': + if self._loc == "custom": xx, yy = self.plotItem.getData() x0p = xx[0] y0p = yy[0] - xcp = x0p + self._length/2 + xcp = x0p + self._length / 2 hl = self.labelItem.itemRect().height() - ycp = y0p - hl/2 + ycp = y0p - hl / 2 x0 = self.xmin y0 = self.ymin x_range = self.xmax - x0 y_range = self.ymax - y0 - Dx_perc = (xcp - x0)/x_range - Dy_perc = (ycp - y0)/y_range - + Dx_perc = (xcp - x0) / x_range + Dy_perc = (ycp - y0) / y_range + self.updateViewRange(viewRange) - + X0 = self.xmin Y0 = self.ymin - + X_range = self.xmax - X0 Y_range = self.ymax - Y0 - - Xcp = X0 + (Dx_perc*X_range) - Ycp = Y0 + (Dy_perc*Y_range) - X0p = Xcp - (self._length/2) - Y0p = Ycp + (hl/2) - + + Xcp = X0 + (Dx_perc * X_range) + Ycp = Y0 + (Dy_perc * Y_range) + X0p = Xcp - (self._length / 2) + Y0p = Ycp + (hl / 2) + X1p = X0p + self._length Y1p = Y0p - + self.plotItem.setData([X0p, X1p], [Y0p, Y1p]) else: self.updateViewRange(viewRange) self.update() - + def getStartXCoordFromLoc(self, loc): - if loc == 'custom': + if loc == "custom": xx, yy = self.plotItem.getData() x0 = xx[0] return x0 - + self.setText() wl = self.labelItem.itemRect().width() - if loc.find('left') != -1: + if loc.find("left") != -1: x0 = self._x_pad + self.xmin - xc = x0 + self._length/2 - xl = xc-wl/2 + xc = x0 + self._length / 2 + xl = xc - wl / 2 if xl < x0: # Text is larger than line --> move line to the right - x0 = self._x_pad + abs(xl-self._x_pad) + x0 = self._x_pad + abs(xl - self._x_pad) else: x0 = self.xmax - self._length - self._x_pad - xc = x0 + self._length/2 - x1 = x0 + self._length - xr = xc+wl/2 + xc = x0 + self._length / 2 + x1 = x0 + self._length + xr = xc + wl / 2 if xr > x1: # Text is larger than line --> move line to the left delta_overshoot = xr - x1 x0 = x0 - delta_overshoot - return x0 - + return x0 + def getStartYCoordFromLoc(self, loc): - if loc == 'custom': + if loc == "custom": xx, yy = self.plotItem.getData() y0 = yy[0] return y0 - + self.setText() textHeight = self.labelItem.itemRect().height() - if loc.find('top') != -1: + if loc.find("top") != -1: return textHeight + self._y_pad + self.ymin else: return self.ymax - self._y_pad - self._thickness - + def update(self): - x0 = self.getStartXCoordFromLoc(self._loc) # + self._thickness/2 + x0 = self.getStartXCoordFromLoc(self._loc) # + self._thickness/2 y0 = self.getStartYCoordFromLoc(self._loc) - - x1 = x0 + self._length # - self._thickness/2 + + x1 = x0 + self._length # - self._thickness/2 self.plotItem.setData([x0, x1], [y0, y0]) - + self.setText() self.setTextPos() - + def draw(self, length_pixel, length_unit, **kwargs): self.setProperties(length_pixel, length_unit, **kwargs) self.update() - + def bbox(self): y_line_min, x_line_min, y_line_max, x_line_max = self.plotItem.bbox() y_lab_min, x_lab_min, y_lab_max, x_lab_max = self.labelItem.bbox() @@ -9381,99 +9600,107 @@ def bbox(self): ymax = max(y_line_max, y_lab_max) xmax = max(x_line_max, x_lab_max) return ymin, xmin, ymax, xmax - + def mousePressed(self, x, y): self.clicked = True self.xc, self.yc = x, y xx, yy = self.plotItem.getData() self.x0c = xx[0] self.y0c = yy[0] - + def removeFromAxis(self, ax): ax.removeItem(self.labelItem) ax.removeItem(self.plotItem) + class ComboBox(QComboBox): sigTextChanged = Signal(str) - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._previousText = None self._valueChanged = False self.currentTextChanged.connect(self.emitTextChanged) self.installEventFilter(self) - + def eventFilter(self, object, event) -> bool: if object == self and event.type() == QEvent.Type.Wheel: # Forward event to parent so QScrollArea can scroll QApplication.sendEvent(self.parent(), event) return True # Consume for the combo itself - + return super().eventFilter(object, event) - + def text(self): return self.currentText() - + def emitTextChanged(self, text): self._valueChanged = True self.sigTextChanged.emit(text) - + def mousePressEvent(self, event): self._previousText = self.currentText() super().mousePressEvent(event) - + def previousText(self): return self._previousText def addItems(self, items): super().addItems(items) self._previousText = items[0] - + def itemsText(self): return [self.itemText(i) for i in range(self.count())] - + def setCurrentIndex(self, idx): itemsText = self.itemsText() currentText = itemsText[idx] self._valueChanged = currentText != self._previousText self._previousText = self.currentText() super().setCurrentIndex(idx) - + def setCurrentText(self, text): currentText = text self._valueChanged = currentText != self._previousText self._previousText = self.currentText() super().setCurrentText(text) + class SetMeasurementsGroupBox(QGroupBox): def __init__( - self, title, itemsText, checkable=True, itemsInfo=None, - lastSelection=None, itemsInfoUrls=None, parent=None - ): + self, + title, + itemsText, + checkable=True, + itemsInfo=None, + lastSelection=None, + itemsInfoUrls=None, + parent=None, + ): super().__init__(parent) - + if itemsInfo is None: itemsInfo = {} - + if itemsInfo is None: itemsInfoUrls = {} - + highlightRgba = _palettes._highlight_rgba() r, g, b, a = highlightRgba - self._highlightStylesheetColor = f'rgb({r}, {g}, {b})' - + self._highlightStylesheetColor = f"rgb({r}, {g}, {b})" + self.setTitle(title) self.setCheckable(checkable) - + mainLayout = QVBoxLayout() - + scrollArea = QScrollArea() scrollArea.setWidgetResizable(True) scrollAreaLayout = QVBoxLayout() scrollAreaWidget = QWidget() self.scrollAreaWidget = scrollAreaWidget self.scrollAreaLayout = scrollAreaLayout - + self.checkboxes = {} for text in itemsText: rowLayout = QHBoxLayout() @@ -9483,67 +9710,65 @@ def __init__( infoButton = infoPushButton() infoButton.setCursor(Qt.WhatsThisCursor) rowLayout.addWidget(infoButton) - + if infoText is not None: infoButton.itemText = text infoButton.infoText = infoText infoButton.clicked.connect(self.showInfo) - + if infoUrl is not None: infoButton.itemText = text infoButton.infoUrl = infoUrl infoButton.clicked.connect(self.openInfoUrl) - + checkbox = QCheckBox(text) checkbox.setParent(self.scrollAreaWidget) checkbox.setChecked(True) rowLayout.addWidget(checkbox) - rowLayout.addStretch(1) - + rowLayout.addStretch(1) + self.checkboxes[text] = checkbox - + scrollAreaLayout.addLayout(rowLayout) scrollAreaLayout.addStretch(1) - + scrollAreaWidget.setLayout(scrollAreaLayout) scrollArea.setWidget(scrollAreaWidget) self.scrollArea = scrollArea - + buttonsLayout = QHBoxLayout() self.selectAllButton = selectAllPushButton() self.selectAllButton.sigClicked.connect(self.setCheckedAll) - + buttonsLayout.addStretch(1) buttonsLayout.addWidget(self.selectAllButton) self.buttonsLayout = buttonsLayout - + if lastSelection is not None: self.lastSelection = lastSelection - self.loadLastSelButton = reloadPushButton( - ' Load last selection... ' - ) + self.loadLastSelButton = reloadPushButton(" Load last selection... ") self.loadLastSelButton.clicked.connect(self.loadLastSelection) buttonsLayout.addWidget(self.loadLastSelButton) - + mainLayout.addWidget(scrollArea) mainLayout.addSpacing(10) mainLayout.addLayout(buttonsLayout) - + self.setLayout(mainLayout) - + def openInfoUrl(self): url = self.sender().infoUrl QDesktopServices.openUrl(QUrl(url)) # import webbrowser # url = self.sender().infoUrl # webbrowser.open(url) - + def getWidthNoScrollBarNeeded(self): width = ( self.scrollArea.verticalScrollBar().sizeHint().width() # self.scrollAreaLayout.contentsRect().width() - + self.scrollAreaWidget.sizeHint().width() + + self.scrollAreaWidget.sizeHint().width() + 30 ) buttonsWidth = 0 @@ -9554,26 +9779,26 @@ def getWidthNoScrollBarNeeded(self): buttonsWidth += widget.sizeHint().width() + 16 largerWidth = max(width, buttonsWidth) return largerWidth - + def resizeWidthNoScrollBarNeeded(self): width = self.getWidthNoScrollBarNeeded() self.setMinimumWidth(width) # self.setFixedWidth(width) - + def loadLastSelection(self): for text, checkbox in self.checkboxes.items(): checked = self.lastSelection.get(text, False) checkbox.setChecked(checked) - + def showInfo(self): infoText = self.sender().infoText itemText = self.sender().itemText - - title = f'{itemText} description' + + title = f"{itemText} description" msg = myMessageBox() - msg.setWidth(int(self.screen().size().width()/2)) + msg.setWidth(int(self.screen().size().width() / 2)) msg.information(self, title, infoText) - + def setCheckedAll(self, button, checked): for checkbox in self.checkboxes.values(): checkbox.setChecked(checked) @@ -9584,135 +9809,139 @@ def highlightCheckboxesFromSearchText(self, text): highlighted = False else: highlighted = checkbox.text().lower().find(text.lower()) != -1 - + self.setCheckboxHighlighted(highlighted, checkbox) - + def setCheckboxHighlighted(self, highlighted, checkbox): if highlighted: checkbox.setStyleSheet( - f'background: {self._highlightStylesheetColor}; color: black' + f"background: {self._highlightStylesheetColor}; color: black" ) self.scrollArea.ensureWidgetVisible(checkbox) else: - checkbox.setStyleSheet('') - + checkbox.setStyleSheet("") + + class SearchLineEdit(QLineEdit): def __init__(self, parent=None): super().__init__(parent) - + self.initSearch() self.setFocusPolicy(Qt.ClickFocus) - + def focusInEvent(self, event) -> None: super().focusInEvent(event) - if super().text() == 'Search...': - self.setText('') - self.setStyleSheet('') - + if super().text() == "Search...": + self.setText("") + self.setStyleSheet("") + def focusOutEvent(self, event) -> None: super().focusOutEvent(event) if not super().text(): self.initSearch() - + def initSearch(self): - self.setText('Search...') - self.setStyleSheet('color: rgb(150, 150, 150)') + self.setText("Search...") + self.setStyleSheet("color: rgb(150, 150, 150)") self.clearFocus() - + def text(self): - if super().text() == 'Search...': - return '' + if super().text() == "Search...": + return "" return super().text() + class ToolButtonTextIcon(rightClickToolButton): - def __init__(self, text='', parent=None): + def __init__(self, text="", parent=None): super().__init__(parent=parent) self._text = text self._penColor = _palettes.text_pen_color() - + def setText(self, text): self._text = text self.update() - + def text(self): return self._text - + def paintEvent(self, event): QToolButton.paintEvent(self, event) p = QPainter(self) - + pen = pg.mkPen(color=self._penColor, width=2) p.setPen(pen) - + w, h = self.width(), self.height() sf = 0.7 - rect_w = w*sf - rect_h = h*sf - x = (w-rect_w)/2 - y = (h-rect_h)/2 - rect = QRectF(x, y, rect_w, rect_h) - + rect_w = w * sf + rect_h = h * sf + x = (w - rect_w) / 2 + y = (h - rect_h) / 2 + rect = QRectF(x, y, rect_w, rect_h) + font = p.font() font.setBold(True) - font.setPixelSize(int(h/len(self._text))) + font.setPixelSize(int(h / len(self._text))) p.setFont(font) - + p.drawText(rect, Qt.AlignCenter, self._text) p.end() + class RulerPlotItem(pg.PlotDataItem): def __init__(self, *args, **kwargs): self.labelItem = pg.LabelItem() super().__init__(*args, **kwargs) - - def setData(self, *args, lengthText='', **kwargs): + + def setData(self, *args, lengthText="", **kwargs): super().setData(*args, **kwargs) - self.labelItem.setText('') + self.labelItem.setText("") if not lengthText: return self.setLengthText(lengthText) - + def setLengthText(self, lengthText): xx, yy = self.getData() x0, x1 = sorted(xx) y0, y1 = sorted(yy) - xc = round(x0 + (x1-x0)/2) - yc = round(y0 + (y1-y0)/2) - self.labelItem.setText(lengthText, size='11px', color='r') + xc = round(x0 + (x1 - x0) / 2) + yc = round(y0 + (y1 - y0) / 2) + self.labelItem.setText(lengthText, size="11px", color="r") # xc = x0 + self._length/2 wl = self.labelItem.itemRect().width() hl = self.labelItem.itemRect().height() - xl = xc-wl/2 - yt = y0-hl + xl = xc - wl / 2 + yt = y0 - hl self.labelItem.setPos(xl, yt) + class VectorLineEdit(QLineEdit): valueChanged = Signal(object) valueChangeFinished = Signal(object) - + def __init__(self, parent=None, initial=None): super().__init__(parent) - + self._minimum = -np.inf - + float_re = float_regex() - vector_regex = fr'\(?\[?{float_re}(,\s?{float_re})+\)?\]?' - regex = fr'^{vector_regex}$|^{float_re}$' + vector_regex = rf"\(?\[?{float_re}(,\s?{float_re})+\)?\]?" + regex = rf"^{vector_regex}$|^{float_re}$" self.validRegex = regex - + regExp = QRegularExpression(regex) self.setValidator(QRegularExpressionValidator(regExp)) self.setAlignment(Qt.AlignCenter) - + self.textChanged.connect(self.emitValueChanged) self.editingFinished.connect(self.emitValueChangeFinished) if initial is None: - self.setText('0.0') - + self.setText("0.0") + font = QFont() font.setPixelSize(11) self.setFont(font) - + def emitValueChangeFinished(self): value = self.value() self.textChanged.disconnect() @@ -9720,9 +9949,9 @@ def emitValueChangeFinished(self): self.setValue(value) self.textChanged.connect(self.emitValueChanged) self.editingFinished.connect(self.emitValueChangeFinished) - + self.emitValueChanged(self.text(), signal=self.valueChangeFinished) - + def emitValueChanged(self, text, signal=None): m = re.match(self.validRegex, text) if m is None: @@ -9731,30 +9960,30 @@ def emitValueChanged(self, text, signal=None): if signal is None: signal = self.valueChanged - - self.setStyleSheet('') + + self.setStyleSheet("") signal.emit(self.value()) - + def increaseValue(self, step): value = self.value() if isinstance(value, (float, int)): value += step else: - value = [val+step for val in value] - value = str(value).lstrip('[').rstrip(']') + value = [val + step for val in value] + value = str(value).lstrip("[").rstrip("]") self.setValue(value) self.emitValueChangeFinished() - + def decreaseValue(self, step): value = self.value() if isinstance(value, (float, int)): value -= step else: - value = [val-step for val in value] - value = str(value).lstrip('[').rstrip(']') + value = [val - step for val in value] + value = str(value).lstrip("[").rstrip("]") self.setText(value) self.emitValueChangeFinished() - + def setValue(self, value): if isinstance(value, (float, int)): if value < self._minimum: @@ -9765,74 +9994,72 @@ def setValue(self, value): if val < self._minimum: val = self._minimum clipped.append(val) - value = str(clipped).lstrip('[').rstrip(']') + value = str(clipped).lstrip("[").rstrip("]") self.setText(value) - + def setText(self, text): super().setText(str(text)) - + def clipValue(self, val: float): if val < self._minimum: val = self._minimum return val - + def value(self): m = re.match(self.validRegex, self.text()) if m is None: return 0.0 - - try: + + try: value = self.clipValue(float(self.text())) return value except Exception as e: text = self.text() - text = text.replace('(', '') - text = text.replace(')', '') - text = text.replace('[', '') - text = text.replace(']', '') - values = text.split(',') + text = text.replace("(", "") + text = text.replace(")", "") + text = text.replace("[", "") + text = text.replace("]", "") + values = text.split(",") return [self.clipValue(float(value)) for value in values] - + def setMinimum(self, minimum): self._minimum = float(minimum) + class LatexLabel(QLabel): def __init__(self, latexText, parent=None): super().__init__(parent) - - latexText = latexText.replace('', '$') - if not latexText.startswith('$'): - latexText = f'${latexText}' - - if not latexText.endswith('$'): - latexText = f'{latexText}$' - - latexText = latexText.replace('
    ', '\n') - + + latexText = latexText.replace("", "$") + if not latexText.startswith("$"): + latexText = f"${latexText}" + + if not latexText.endswith("$"): + latexText = f"{latexText}$" + + latexText = latexText.replace("
    ", "\n") + pixmap = self.mathTex_to_QPixmap(latexText) self.setPixmap(pixmap) - + def mathTex_to_QPixmap(self, mathTex): - #---- set up a mpl figure instance ---- + # ---- set up a mpl figure instance ---- fig = matplotlib.figure.Figure() - fig.patch.set_facecolor('none') + fig.patch.set_facecolor("none") fig.set_canvas(FigureCanvasAgg(fig)) renderer = fig.canvas.get_renderer() - #---- plot the mathTex expression ---- + # ---- plot the mathTex expression ---- ax = fig.add_axes([0, 0, 1, 1]) - ax.axis('off') - ax.patch.set_facecolor('none') + ax.axis("off") + ax.patch.set_facecolor("none") t = ax.text( - 0, 0, mathTex, - ha='left', va='bottom', - fontsize=13, - color=TEXT_COLOR + 0, 0, mathTex, ha="left", va="bottom", fontsize=13, color=TEXT_COLOR ) - #---- fit figure size to text artist ---- + # ---- fit figure size to text artist ---- fwidth, fheight = fig.get_size_inches() fig_bbox = fig.get_window_extent(renderer) @@ -9844,47 +10071,42 @@ def mathTex_to_QPixmap(self, mathTex): fig.set_size_inches(tight_fwidth, tight_fheight) - #---- convert mpl figure to QPixmap ---- + # ---- convert mpl figure to QPixmap ---- buf, size = fig.canvas.print_to_buffer() - qimage = QImage.rgbSwapped(QImage( - buf, size[0], size[1], QImage.Format_ARGB32) - ) + qimage = QImage.rgbSwapped(QImage(buf, size[0], size[1], QImage.Format_ARGB32)) qpixmap = QPixmap(qimage) return qpixmap - + class LabelsWidget(QWidget): def __init__(self, texts, wrapText=False, parent=None): super().__init__(parent=parent) - + layout = QVBoxLayout() - + texts = self.fixParagraphTags(texts) - + self.textLengths = [] self.labels = [] for t, text in enumerate(texts): if not text: continue - if text.startswith(''): + if text.startswith(""): layout.addSpacing(10) label = LatexLabel(text) layout.addWidget(label, alignment=Qt.AlignCenter) try: # Add spacing only if next text is not a formula - nextText = texts[t+1] - if not nextText.startswith(''): + nextText = texts[t + 1] + if not nextText.startswith(""): layout.addSpacing(10) except IndexError: layout.addSpacing(10) - elif text.startswith(''): - text = ( - text.removeprefix('') - .removeprefix('') - ) + elif text.startswith(""): + text = text.removeprefix("").removeprefix("") label = CopiableCommandWidget(command=text, parent=self) layout.addWidget(label) else: @@ -9894,124 +10116,123 @@ def __init__(self, texts, wrapText=False, parent=None): layout.addWidget(label) if wrapText: self.textLengths.append(1) - self.textLengths.extend( - [len(line) for line in text.split('
    ')] - ) - + self.textLengths.extend([len(line) for line in text.split("
    ")]) + self.labels.append(label) - + self.nCharsLongestLine = max(self.textLengths, default=1) - + layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) - + def setWordWrap(self, wordWrap): for label in self.labels: label.setWordWrap(wordWrap) - + def fixParagraphTags(self, texts): firstText = texts[0] - if firstText.find('

    ', firstText) if searched is None: openTag = '

    ' else: openTag = searched.group() - - not_allowed = {' ', '\n'} - + + not_allowed = {" ", "\n"} + fixedTexts = [] for text in texts: - if text.startswith('') or text.startswith(''): + if text.startswith("") or text.startswith(""): fixedTexts.append(text) continue - + if set(text) <= not_allowed: # Ignore texts that are made of only \n and spaces continue - - if text.find('

    ') == -1: - text = rf'{text}<\p>' - + + if text.find("

    ") == -1: + text = rf"{text}<\p>" + if text.find(openTag) == -1: - text = f'{openTag}{text}' - - text = text.replace('\n', '') - + text = f"{openTag}{text}" + + text = text.replace("\n", "") + fixedTexts.append(text) return fixedTexts + class SwitchPlaneCombobox(QComboBox): sigPlaneChanged = Signal(str, str) - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.addItems(['xy', 'zy', 'zx']) - self._previousPlane = 'xy' + self.addItems(["xy", "zy", "zx"]) + self._previousPlane = "xy" self.currentTextChanged.connect(self.emitPlaneChanged) - + def emitPlaneChanged(self, plane): self.sigPlaneChanged.emit(self._previousPlane, plane) self._previousPlane = plane - + def setPlane(self, plane): self.setCurrentText(plane) - + def setCurrentText(self, text): self._previousPlane = self.plane() super().setCurrentText(text) - + def plane(self): return self.currentText() def depthAxes(self): plane = self.plane() - for axes in 'xyz': + for axes in "xyz": if axes not in plane: return axes + class SamInputPointsWidget(QWidget): sigValueChanged = Signal(str) - + def __init__(self, parent=None): super().__init__(parent) - + _layout = QHBoxLayout() - + self.lineEntry = ElidingLineEdit(parent=self) self.lineEntry.setAlignment(Qt.AlignCenter) self.lineEntry.editingFinished.connect(self.emitValueChanged) - + self.editButton = editPushButton() self.browseButton = browseFileButton( - ext={'CSV': '.csv'}, - start_dir=myutils.getMostRecentPath() + ext={"CSV": ".csv"}, start_dir=myutils.getMostRecentPath() ) - + _layout.addWidget(self.lineEntry) _layout.addWidget(self.editButton) _layout.addWidget(self.browseButton) - + _layout.setStretch(0, 1) _layout.setStretch(1, 0) _layout.setStretch(1, 0) - + self.browseButton.sigPathSelected.connect(self.browseCsvFiles) self.editButton.clicked.connect(self.showInfoEditPoints) - + _layout.setContentsMargins(0, 0, 0, 0) self.setLayout(_layout) - + def emitValueChanged(self, text): self.sigValueChanged.emit(text) - + def showInfoEditPoints(self): note = html_utils.to_note( - 'When adding points with the mouse left button you will create a ' - 'new object for each point. To add multiple points for the same ' - 'object click the right button.' + "When adding points with the mouse left button you will create a " + "new object for each point. To add multiple points for the same " + "object click the right button." ) txt = html_utils.paragraph(f""" To add input points for Segment Anything open the GUI (module 3), @@ -10023,8 +10244,8 @@ def showInfoEditPoints(self):
    {note} """) msg = myMessageBox(wrapText=False) - msg.information(self, 'Info edit points', txt) - + msg.information(self, "Info edit points", txt) + def criticalMissingColumn(self, filepath, missing_col): txt = html_utils.paragraph(f""" [ERROR]: The selected table does not contain the column @@ -10033,74 +10254,73 @@ def criticalMissingColumn(self, filepath, missing_col): with an additional z column for 3D z-stacks data. """) msg = myMessageBox(wrapText=False) - msg.critical(self, 'Invalid table', txt) - + msg.critical(self, "Invalid table", txt) + def setValue(self, value: str): self.lineEntry.setText(value) - + def value(self): return self.lineEntry.text() - + def cast_dtype(self, value) -> str: return str(value) - + def browseCsvFiles(self, filepath): - # Check if metadata.csv file exists with basename and set only the + # Check if metadata.csv file exists with basename and set only the # endname of the file df_points = pd.read_csv(filepath) - for col in ('x', 'y', 'id'): + for col in ("x", "y", "id"): if col not in df_points.columns: self.criticalMissingColumn(filepath, col) return - + # Check if basename is present in metadata folderpath = os.path.dirname(filepath) basename = None for file in myutils.listdir(folderpath): - if file.endswith('metadata.csv'): + if file.endswith("metadata.csv"): metadata_csv_path = os.path.join(folderpath, file) - df = pd.read_csv(metadata_csv_path, index_col='Description') + df = pd.read_csv(metadata_csv_path, index_col="Description") try: - basename = df.at['basename', 'values'] + basename = df.at["basename", "values"] except Exception as e: basename = None break - + # Check if file is inside images folder and get basename - is_images_folder = folderpath.endswith('Images') + is_images_folder = folderpath.endswith("Images") if is_images_folder: images_path = folderpath img_filepath = None for file in myutils.listdir(images_path): - if file.endswith('.tif'): + if file.endswith(".tif"): img_filepath = os.path.join(images_path, file) break - - if file.endswith('aligned.npz'): + + if file.endswith("aligned.npz"): img_filepath = os.path.join(images_path, file) break - + if img_filepath is not None: - posData = load.loadData(img_filepath, '', QParent=self) + posData = load.loadData(img_filepath, "", QParent=self) posData.getBasenameAndChNames() filename = os.path.basename(filepath) if filename.startswith(posData.basename): basename = posData.basename - + if basename is None: self.lineEntry.setText(filepath) else: filename = os.path.basename(filepath) - endname = filename[len(basename):] + endname = filename[len(basename) :] self.lineEntry.setText(endname) + class PointsScatterPlotItem(pg.ScatterPlotItem): sigHoverEntered = Signal(object, object, object) - + def __init__(self, *args, ax=None, show_data_as_tip=False, **kwargs): - self.textItem = annotate.TextAnnotationsScatterItem( - size=12, anchor=(1.0, 1.0) - ) + self.textItem = annotate.TextAnnotationsScatterItem(size=12, anchor=(1.0, 1.0)) self.textItem.createSymbols( [str(int_id) for int_id in range(200)], includeBold=False ) @@ -10114,108 +10334,106 @@ def __init__(self, *args, ax=None, show_data_as_tip=False, **kwargs): self.ax = ax self.sigHovered.connect(self.onHover) self.lastHoveredPoint = None - + def onHover(self, item, points, event): if len(points) == 0: vb = self.getViewBox() - vb.setToolTip('') + vb.setToolTip("") return - + if self.lastHoveredPoint != points[0]: self.sigHoverEntered.emit(item, points, event) self.lastHoveredPoint = points[0] - - if not self.opts['hoverable']: + + if not self.opts["hoverable"]: return - + if not self.show_data_as_tip: return - + tip_li = [str(point.data()) for point in points] - tip = '\n\n'.join(tip_li) - + tip = "\n\n".join(tip_li) + vb = self.getViewBox() vb.setToolTip(tip) - - + def setData(self, *args, **kwargs): self.clearTextItems() super().setData(*args, **kwargs) - data = kwargs.get('data') + data = kwargs.get("data") if data is None: return - + if len(data) == 0: return - + first_point_data = data[0] if not isinstance(first_point_data, (int, str)): return - + if not self.drawIds: return - + if self.show_data_as_tip: return - - color = self.opts['brush'].color() - self.textItem.setColors({'id': color.getRgb()}) - size = self.opts['size'] - radius = size/2 + + color = self.opts["brush"].color() + self.textItem.setColors({"id": color.getRgb()}) + size = self.opts["size"] + radius = size / 2 # xx, yy = args # for x, y, point_data in zip(xx, yy, data): for point in self.points(): text = str(point.data()) if not text: continue - + x, y = point.pos().x(), point.pos().y() - xt, yt = x+radius-0.5, y-radius+0.5 + xt, yt = x + radius - 0.5, y - radius + 0.5 opts = { - 'text': text, - 'bold': False, - 'color_name': 'id', + "text": text, + "bold": False, + "color_name": "id", } - data = self.textItem.addObjAnnot( - (xt, yt), anchor=(-0.3, 1.3), **opts - ) - self.textItem.appendData(data, opts['text']) - + data = self.textItem.addObjAnnot((xt, yt), anchor=(-0.3, 1.3), **opts) + self.textItem.appendData(data, opts["text"]) + self.textItem.draw() - # hexColor = color.name() - # htmlText = html_utils.span( - # text, color=hexColor, font_size='13pt', bold=True - # ) - - # textItem = self._textItems.get((x, y)) - # if textItem is None: - # textItem = pg.TextItem(html=htmlText, anchor=(0, 1)) - # textItem.setParentItem(self) - # self._textItems[(x, y)] = textItem - # self.ax.addItem(textItem) - # else: - # textItem.setHtml(htmlText) - # textItem.setPos(x+radius-0.5, y-radius+0.5) - + # hexColor = color.name() + # htmlText = html_utils.span( + # text, color=hexColor, font_size='13pt', bold=True + # ) + + # textItem = self._textItems.get((x, y)) + # if textItem is None: + # textItem = pg.TextItem(html=htmlText, anchor=(0, 1)) + # textItem.setParentItem(self) + # self._textItems[(x, y)] = textItem + # self.ax.addItem(textItem) + # else: + # textItem.setHtml(htmlText) + # textItem.setPos(x+radius-0.5, y-radius+0.5) + def clearTextItems(self): self.textItem.clearData() # for textItem in self._textItems.values(): # textItem.setText('') - + def clear(self): super().clear() self.clearTextItems() - + def setVisible(self, visible): super().setVisible(visible) self.textItem.setVisible(visible) + class installJavaDialog(myMessageBox): def __init__(self, parent=None): super().__init__(parent) - self.setWindowTitle('Install Java') - self.setIcon('SP_MessageBoxWarning') + self.setWindowTitle("Install Java") + self.setIcon("SP_MessageBoxWarning") txt_macOS = html_utils.paragraph(""" Your system doesn't have the Java Development Kit @@ -10239,19 +10457,19 @@ def __init__(self, parent=None): """) if not is_win: - self.instructionsButton = self.addButton('Show intructions...') + self.instructionsButton = self.addButton("Show intructions...") self.instructionsButton.setCheckable(True) self.instructionsButton.disconnect() self.instructionsButton.clicked.connect(self.showInstructions) - installButton = self.addButton('Install') + installButton = self.addButton("Install") installButton.disconnect() installButton.clicked.connect(self.installJava) txt = txt_macOS else: - okButton = self.addButton('Ok') + okButton = self.addButton("Ok") txt = txt_windows - self.cancelButton = self.addButton('Cancel') + self.cancelButton = self.addButton("Cancel") label = self.addText(txt) label.setWordWrap(False) @@ -10265,24 +10483,24 @@ def addInstructionsWindows(self): for t, text in enumerate(myutils.install_javabridge_instructions_text()): label = QLabel() label.setText(text) - if (t == 1 or t == 2): + if t == 1 or t == 2: label.setOpenExternalLinks(True) label.setTextInteractionFlags(Qt.TextBrowserInteraction) code_layout = QHBoxLayout() code_layout.addWidget(label) copyButton = QToolButton() copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - copyButton.setIcon(QIcon(':edit-copy.svg')) - copyButton.setText('Copy link') - if t==1: + copyButton.setIcon(QIcon(":edit-copy.svg")) + copyButton.setText("Copy link") + if t == 1: copyButton.textToCopy = myutils.jdk_windows_url() code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) else: copyButton.textToCopy = myutils.cpp_windows_url() screenshotButton = QToolButton() screenshotButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - screenshotButton.setIcon(QIcon(':cog.svg')) - screenshotButton.setText('See screenshot') + screenshotButton.setIcon(QIcon(":cog.svg")) + screenshotButton.setText("See screenshot") code_layout.addWidget(screenshotButton, alignment=Qt.AlignLeft) code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) screenshotButton.clicked.connect(self.viewScreenshot) @@ -10293,7 +10511,6 @@ def addInstructionsWindows(self): else: _layout.addWidget(label) - _container.setLayout(_layout) self.scrollArea.setWidget(_container) self.currentRow += 1 @@ -10317,15 +10534,15 @@ def addInstructionsMacOS(self): label = QLabel() label.setText(text) # label.setWordWrap(True) - if (t == 1 or t == 2): + if t == 1 or t == 2: label.setWordWrap(True) code_layout = QHBoxLayout() code_layout.addWidget(label) copyButton = QToolButton() copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - copyButton.setIcon(QIcon(':edit-copy.svg')) - copyButton.setText('Copy') - if t==1: + copyButton.setIcon(QIcon(":edit-copy.svg")) + copyButton.setText("Copy") + if t == 1: copyButton.textToCopy = myutils._install_homebrew_command() else: copyButton.textToCopy = myutils._brew_install_java_command() @@ -10357,19 +10574,19 @@ def addInstructionsLinux(self): label = QLabel() label.setText(text) # label.setWordWrap(True) - if (t == 1 or t == 2 or t==3): + if t == 1 or t == 2 or t == 3: label.setWordWrap(True) code_layout = QHBoxLayout() code_layout.addWidget(label) copyButton = QToolButton() copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - copyButton.setIcon(QIcon(':edit-copy.svg')) - copyButton.setText('Copy') - if t==1: + copyButton.setIcon(QIcon(":edit-copy.svg")) + copyButton.setText("Copy") + if t == 1: copyButton.textToCopy = myutils._apt_update_command() - elif t==2: + elif t == 2: copyButton.textToCopy = myutils._apt_install_java_command() - elif t==3: + elif t == 3: copyButton.textToCopy = myutils._apt_gcc_command() copyButton.clicked.connect(self.copyToClipboard) code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) @@ -10395,62 +10612,65 @@ def copyToClipboard(self): cb = QApplication.clipboard() cb.clear(mode=cb.Clipboard) cb.setText(self.sender().textToCopy, mode=cb.Clipboard) - print('Command copied!') + print("Command copied!") def showInstructions(self, checked): if checked: - self.instructionsButton.setText('Hide instructions') + self.instructionsButton.setText("Hide instructions") self.origHeight = self.height() - self.resize(self.width(), self.height()+300) + self.resize(self.width(), self.height() + 300) self.scrollArea.show() else: - self.instructionsButton.setText('Show instructions...') + self.instructionsButton.setText("Show instructions...") self.scrollArea.hide() func = partial(self.resize, self.width(), self.origHeight) QTimer.singleShot(50, func) def installJava(self): import subprocess + try: if is_mac: try: - subprocess.check_call(['brew', 'update']) + subprocess.check_call(["brew", "update"]) except Exception as e: subprocess.run( myutils._install_homebrew_command(), - check=True, text=True, shell=True + check=True, + text=True, + shell=True, ) subprocess.run( myutils._brew_install_java_command(), - check=True, text=True, shell=True + check=True, + text=True, + shell=True, ) elif is_linux: subprocess.run( - myutils._apt_gcc_command()(), - check=True, text=True, shell=True + myutils._apt_gcc_command()(), check=True, text=True, shell=True ) subprocess.run( - myutils._apt_update_command()(), - check=True, text=True, shell=True + myutils._apt_update_command()(), check=True, text=True, shell=True ) subprocess.run( myutils._apt_install_java_command()(), - check=True, text=True, shell=True + check=True, + text=True, + shell=True, ) self.close() except Exception as e: - print('=======================') + print("=======================") traceback.print_exc() - print('=======================') + print("=======================") msg = myMessageBox(wrapText=False) err_msg = html_utils.paragraph(""" Automatic installation of Java failed.

    Please, try manually by following the instructions provided below (click on "Show instructions..." button). Thanks """) - msg.critical( - self, 'Java installation failed', err_msg - ) + msg.critical(self, "Java installation failed", err_msg) def show(self, block=False): super().show(block=False) @@ -10463,23 +10683,25 @@ def show(self, block=False): self.addInstructionsLinux() self.move(self.pos().x(), 20) if is_win: - self.resize(self.width(), self.height()+200) + self.resize(self.width(), self.height() + 200) if block: self._block() def exec_(self): self.show(block=True) + class selectTrackerGUI(QDialogListbox): - def __init__( - self, SizeT, currentFrameNo=1, parent=None - ): + def __init__(self, SizeT, currentFrameNo=1, parent=None): trackers = myutils.get_list_of_trackers() super().__init__( - 'Select tracker', 'Select one of the following trackers', - trackers, multiSelection=False, parent=parent + "Select tracker", + "Select one of the following trackers", + trackers, + multiSelection=False, + parent=parent, ) - self.setWindowTitle('Select tracker') + self.setWindowTitle("Select tracker") self.selectFramesGroupbox = selectStartStopFrames( SizeT, currentFrameNum=currentFrameNo, parent=parent @@ -10495,11 +10717,12 @@ def ok_cb(self, event): self.stopFrame = self.selectFramesGroupbox.stopFrame_SB.value() super().ok_cb(event) + def addWidgetToScrollArea( - widget, - resizeMinWidthNoHorizontalScrollbar=False, - resizeMinHeightNoVerticalScrollbar=False - ): + widget, + resizeMinWidthNoHorizontalScrollbar=False, + resizeMinHeightNoVerticalScrollbar=False, +): container = QWidget() layout = QVBoxLayout() layout.addWidget(widget) @@ -10508,61 +10731,67 @@ def addWidgetToScrollArea( scrollArea = QScrollArea() scrollArea.setWidgetResizable(True) scrollArea.setWidget(container) - + if resizeMinWidthNoHorizontalScrollbar: scrollArea.setMinimumWidth( container.sizeHint().width() + scrollArea.verticalScrollBar().sizeHint().width() ) - + if resizeMinHeightNoVerticalScrollbar: scrollArea.setMinimumHeight( container.sizeHint().height() + scrollArea.horizontalScrollBar().sizeHint().height() ) - + return scrollArea + class CheckableAction(QAction): clicked = Signal(bool) - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + self.setCheckable(True) self.toggled.connect(self.emitClicked) - + def emitClicked(self, checked): self.clicked.emit(checked) - + def setChecked(self, checked): self.toggled.disconnect() super().setChecked(checked) self.toggled.connect(self.emitClicked) + class OddSpinBox(SpinBox): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - + self.setSingleStep(2) self.editingFinished.connect(self.roundToOdd) - + def roundToOdd(self): if self.value() % 2 == 1: return - - self.setValue(self.value()+1) + + self.setValue(self.value() + 1) + class TimestampItem(LabelItem): sigEditProperties = Signal(object) sigRemove = Signal(object) - + def __init__( - self, SizeY, SizeX, viewRange, - secondsPerFrame=1, - parent=None, - start_timedelta=None - ): + self, + SizeY, + SizeX, + viewRange, + secondsPerFrame=1, + parent=None, + start_timedelta=None, + ): self._secondsPerFrame = secondsPerFrame self._x_pad = 3 self._y_pad = 2 @@ -10578,165 +10807,164 @@ def __init__( super().__init__(self) self.updateViewRange(viewRange) self.createContextMenu() - + def setSecondsPerFrame(self, secondsPerFrame): self._secondsPerFrame = secondsPerFrame - + def getBboxViewRange(self, viewRange): xRange, yRange = viewRange x0, x1 = xRange y0, y1 = yRange if x0 < 0: x0 = 0 - + if x1 > self.SizeX: x1 = self.SizeX - + if y0 < 0: y0 = 0 - + if y1 > self.SizeY: y1 = self.SizeY - + return x0, y0, x1, y1 - + def updateViewRange(self, viewRange): x0, y0, x1, y1 = self.getBboxViewRange(viewRange) - + self.xmax = x1 self.xmin = x0 - + self.ymax = y1 self.ymin = y0 - + def createContextMenu(self): self.contextMenu = QMenu() - action = QAction('Edit properties...', self.contextMenu) + action = QAction("Edit properties...", self.contextMenu) action.triggered.connect(self.emitEditProperties) self.contextMenu.addSeparator() - action = QAction('Remove', self.contextMenu) + action = QAction("Remove", self.contextMenu) action.triggered.connect(self.emitRemove) self.contextMenu.addAction(action) - + def emitRemove(self): self.sigRemove.emit(self) - + def mousePressed(self, x, y): self.clicked = True - + def emitEditProperties(self): self.setHighlighted(False) self.sigEditProperties.emit(self.properties()) - + def isHighlighted(self): return self._highlighted - + def setHighlighted(self, highlighted): if self._highlighted and highlighted: return - + if not self._highlighted and not highlighted: return - + super().setText(self.text, bold=highlighted) - + self._highlighted = highlighted - + def showContextMenu(self, x, y): self.contextMenu.popup(QPoint(int(x), int(y))) - + def setLocationProperty(self, loc: str): - self._loc = loc - + self._loc = loc + def properties(self): properties = { - 'color': self._color, - 'loc': self._loc, - 'font_size': int(self._font_size[:-2]), - 'start_timedelta': self._start_timedelta, - 'move_with_zoom': self._move_with_zoom, + "color": self._color, + "loc": self._loc, + "font_size": int(self._font_size[:-2]), + "start_timedelta": self._start_timedelta, + "move_with_zoom": self._move_with_zoom, } return properties def draw(self, frame_i, **kwargs): self.setProperties(**kwargs) self.update(frame_i) - + def update(self, frame_i): self.setPosFromLoc() self.setText(frame_i) - + def setMoveWithZoomProperty(self, move_with_zoom): self._move_with_zoom = move_with_zoom - + def updatePosViewRangeChanged(self, viewRange): - if self._loc == 'custom': + if self._loc == "custom": textHeight = self.itemRect().height() textWidth = self.itemRect().width() x0p = self.pos().x() y0p = self.pos().y() - xcp = x0p + textWidth/2 - ycp = y0p + textHeight/2 + xcp = x0p + textWidth / 2 + ycp = y0p + textHeight / 2 x0 = self.xmin y0 = self.ymin x_range = self.xmax - x0 y_range = self.ymax - y0 - Dx_perc = (xcp - x0)/x_range - Dy_perc = (ycp - y0)/y_range - + Dx_perc = (xcp - x0) / x_range + Dy_perc = (ycp - y0) / y_range + self.updateViewRange(viewRange) - + X0 = self.xmin Y0 = self.ymin - + X_range = self.xmax - X0 Y_range = self.ymax - Y0 - - Xcp = X0 + (Dx_perc*X_range) - Ycp = Y0 + (Dy_perc*Y_range) - X0p = Xcp - (textWidth/2) - Y0p = Ycp - (textHeight/2) - + + Xcp = X0 + (Dx_perc * X_range) + Ycp = Y0 + (Dy_perc * Y_range) + X0p = Xcp - (textWidth / 2) + Y0p = Ycp - (textHeight / 2) + y_pos_max = self.ymax - textHeight - self._y_pad if Y0p > y_pos_max: Y0p = y_pos_max - + x_pos_max = self.xmax - textWidth - self._x_pad if X0p > x_pos_max: X0p = x_pos_max - + self.setPos(X0p, Y0p) else: self.updateViewRange(viewRange) self.setPosFromLoc() - - + def setPosFromLoc(self): textHeight = self.itemRect().height() textWidth = self.itemRect().width() - if self._loc == 'custom': + if self._loc == "custom": return - - if self._loc.find('top') != -1: + + if self._loc.find("top") != -1: y0 = self._y_pad + self.ymin else: y0 = self.ymax - textHeight - self._y_pad - - if self._loc.find('left') != -1: + + if self._loc.find("left") != -1: x0 = self._x_pad + self.xmin else: x0 = self.xmax - textWidth - self._x_pad self.setPos(x0, y0) - + def setProperties( - self, - color=(255, 255, 255), - font_size='13px', - loc='top-left', - start_timedelta=None, - move_with_zoom=False - ): + self, + color=(255, 255, 255), + font_size="13px", + loc="top-left", + start_timedelta=None, + move_with_zoom=False, + ): if start_timedelta is not None: self._start_timedelta = start_timedelta self._color = color @@ -10750,319 +10978,316 @@ def move(self, xm, ym): x0 = self.x0c + Dx y0 = self.y0c + Dy self.setPos(x0, y0) - + def mousePressed(self, x, y): self.clicked = True self.xc, self.yc = x, y self.x0c = self.pos().x() self.y0c = self.pos().y() - + def setText(self, frame_i): if not isinstance(frame_i, int): return - - seconds = frame_i*self._secondsPerFrame + + seconds = frame_i * self._secondsPerFrame timedelta = datetime.timedelta(seconds=round(seconds)) - - diff_seconds = ( - timedelta.total_seconds() - + self._start_timedelta.total_seconds() - ) + + diff_seconds = timedelta.total_seconds() + self._start_timedelta.total_seconds() if diff_seconds >= 0: timedelta = datetime.timedelta(seconds=round(diff_seconds)) text = str(timedelta) else: abs_diff = abs( - timedelta.total_seconds() - + self._start_timedelta.total_seconds() + timedelta.total_seconds() + self._start_timedelta.total_seconds() ) abs_timedelta = datetime.timedelta(seconds=round(abs_diff)) - text = f'-{abs_timedelta}' - + text = f"-{abs_timedelta}" + # printl(timedelta) - super().setText( - text, color=self._color, size=self._font_size - ) - + super().setText(text, color=self._color, size=self._font_size) + def addToAxis(self, ax): ax.addItem(self) - + def removeFromAxis(self, ax): ax.removeItem(self) + class FontSizeWidget(QWidget): sigTextChanged = Signal(str) - - def __init__(self, parent=None, unit='px', initalVal=12): + + def __init__(self, parent=None, unit="px", initalVal=12): super().__init__(parent) - + layout = QHBoxLayout() - - self.spinbox = SpinBox() - self.spinbox.setValue(initalVal) + + self.spinbox = SpinBox() + self.spinbox.setValue(initalVal) layout.addWidget(self.spinbox) - + self.unitLabel = QLabel(unit) layout.addWidget(self.unitLabel) - + layout.setContentsMargins(0, 0, 0, 0) layout.setStretch(0, 1) layout.setStretch(1, 0) - + self.setLayout(layout) - + self.spinbox.valueChanged.connect(self.emitTextChanged) - + def emitTextChanged(self, value): self.sigTextChanged.emit(self.text()) - + def setValue(self, value): if isinstance(value, str): - value = int(value.replace(self.unitLabel.text(), '').strip()) + value = int(value.replace(self.unitLabel.text(), "").strip()) self.spinbox.setValue(value) - + def setText(self, text): - value = int(text.replace(self.unitLabel.text(), '').strip()) + value = int(text.replace(self.unitLabel.text(), "").strip()) self.setValue(value) - + def text(self): - return f'{self.spinbox.value()}{self.unitLabel.text()}' - + return f"{self.spinbox.value()}{self.unitLabel.text()}" + def value(self): return self.spinbox.value() + class RangeSelector(QWidget): sigRangeChanged = Signal(object, object) sigLowValueChanged = Signal(object) sigHighValueChanged = Signal(object) sigRangeManuallyChanged = Signal(object, object) - + def __init__(self, parent=None, integers=False, ordered=True): super().__init__(parent) - + self._integers = integers self._ordered = ordered - + layout = QHBoxLayout() - + if integers: self.lowSpinbox = SpinBox() self.highSpinbox = SpinBox() else: self.lowSpinbox = DoubleSpinBox() self.highSpinbox = DoubleSpinBox() - + layout.addWidget(self.lowSpinbox) layout.addWidget(self.highSpinbox) - + layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) - + self.lowSpinbox.valueChanged.connect(self.lowValueChanged) self.highSpinbox.valueChanged.connect(self.highValueChanged) - + self.lowSpinbox.editingFinished.connect(self.lowValueEditingFinished) self.highSpinbox.editingFinished.connect(self.highValueEditingFinished) - - def lowValueEditingFinished(self): - self.sigRangeManuallyChanged.emit(*self.range()) + + def lowValueEditingFinished(self): + self.sigRangeManuallyChanged.emit(*self.range()) self.emitRangeChanged() - - def highValueEditingFinished(self): - self.sigRangeManuallyChanged.emit(*self.range()) + + def highValueEditingFinished(self): + self.sigRangeManuallyChanged.emit(*self.range()) self.emitRangeChanged() - - def lowValueChanged(self, value): + + def lowValueChanged(self, value): self.emitRangeChanged() self.sigLowValueChanged.emit(value) - + def highValueChanged(self, value): self.emitRangeChanged() self.sigHighValueChanged.emit(value) - + def emitRangeChanged(self): self.sigRangeChanged.emit(*self.range()) - + def setRangeNoEmit(self, lowValue, highValue, decimals=3): self.lowSpinbox.valueChanged.disconnect() self.highSpinbox.valueChanged.disconnect() - + self.setRange(round(lowValue, 3), round(highValue, 3)) - + self.lowSpinbox.valueChanged.connect(self.lowValueChanged) self.highSpinbox.valueChanged.connect(self.highValueChanged) - + def setRange(self, lowValue, highValue): # if lowValue > highValue and self._ordered: # highValue = lowValue + 1 - + if self._integers: lowValue = round(lowValue) highValue = round(highValue) - + self.lowSpinbox.setValue(lowValue) self.highSpinbox.setValue(highValue) - + def range(self): return self.lowSpinbox.value(), self.highSpinbox.value() + class LineEdit(QLineEdit): def __init__(self, parent=None): super().__init__(parent) self.setAlignment(Qt.AlignCenter) - + def value(self): return self.text() def setValue(self, value): self.setText(str(value)) + class PreProcessingSelector(QComboBox): sigValuesChanged = Signal(dict, int) - + def __init__(self, parent=None): super().__init__(parent) self._parent = parent - + self.addItems(PREPROCESS_MAPPER.keys()) self.methodToDefaultValuesMapper = {} self.step_n = -1 self.setParamsWindow = None def htmlInfo(self): - href = html_utils.href_tag('GitHub page', urls.issues_url) - docstring = PREPROCESS_MAPPER[self.currentText()]['docstring'] + href = html_utils.href_tag("GitHub page", urls.issues_url) + docstring = PREPROCESS_MAPPER[self.currentText()]["docstring"] if docstring is None: - text = 'This function is not documented, yet. Sorry :(' + text = "This function is not documented, yet. Sorry :(" else: text = html_utils.rst_docstring_to_html(docstring) text = ( - f'{text}

    ' - f'Feel free to submit an issue on our {href} if you ' - 'need help with this filter.' + f"{text}

    " + f"Feel free to submit an issue on our {href} if you " + "need help with this filter." ) return text - + def setParams(self, method: str, kwargToValueMapper: Dict[str, str]): self.methodToDefaultValuesMapper[method] = kwargToValueMapper - + def askSetParams(self, df_metadata=None, addApplyButton=False): method = self.currentText() - function = PREPROCESS_MAPPER[method]['function'] + function = PREPROCESS_MAPPER[method]["function"] params_argspecs = myutils.get_function_argspec( - function, - args_to_skip={ - 'logger_func', - 'apply_to_all_zslices', - 'apply_to_all_frames' - } + function, + args_to_skip={"logger_func", "apply_to_all_zslices", "apply_to_all_frames"}, ) default_values = self.methodToDefaultValuesMapper.get(method, {}) for kwarg, value in default_values.items(): for p, param_argspec in enumerate(params_argspecs): if param_argspec.name != kwarg: continue - - if hasattr(param_argspec.type, 'cast_dtype'): + + if hasattr(param_argspec.type, "cast_dtype"): cls = param_argspec.type value = cls.cast_dtype(value) else: value = param_argspec.type(value) - + if value == param_argspec.default: continue param_argspec = param_argspec._replace(default=value) params_argspecs[p] = param_argspec - + if self.setParamsWindow is not None: self.setParamsWindow.raise_() self.setParamsWindow.activateWindow() return - + self.setParamsWindow = apps.FunctionParamsDialog( - params_argspecs, + params_argspecs, df_metadata=df_metadata, function_name=method, addApplyButton=addApplyButton, - parent=self._parent + parent=self._parent, ) self.setParamsWindow.sigValuesChanged.connect(self.emitValuesChanged) self.setParamsWindow.emitValuesChanged() self.setParamsWindow.exec_() if self.setParamsWindow.cancel: return - + self.setParams(method, self.setParamsWindow.function_kwargs) - + function_kwargs = self.setParamsWindow.function_kwargs self.setParamsWindow = None - + return function_kwargs def emitValuesChanged(self, functionKwargs: dict): self.sigValuesChanged.emit(functionKwargs, self.step_n) - + + class RescaleImageJroisGroupbox(QGroupBox): def __init__(self, TZYX_out_shape, parent=None): super().__init__(parent) - - self.setTitle('Rescale ROIs') + + self.setTitle("Rescale ROIs") self.setCheckable(True) - + gridLayout = QGridLayout() - - dims = ('Z', 'Y', 'X') + + dims = ("Z", "Y", "X") self.widgets = {} for row, SizeD in enumerate(TZYX_out_shape[1:]): if SizeD == 1: continue - + dim = dims[row] inputSpinbox = SpinBox() inputSpinbox.setMinimum(1) inputSpinbox.setValue(SizeD) - + outZwidget = QLineEdit() outZwidget.setReadOnly(True) outZwidget.setAlignment(Qt.AlignCenter) # outZwidget.setValue(SizeD) outZwidget.setText(str(SizeD)) - row0 = row*2 - row1 = row0+1 - gridLayout.addWidget(QLabel(f'{dim}-dimension: '), row1, 0) - - gridLayout.addWidget(QLabel('Input size'), row0, 1) + row0 = row * 2 + row1 = row0 + 1 + gridLayout.addWidget(QLabel(f"{dim}-dimension: "), row1, 0) + + gridLayout.addWidget(QLabel("Input size"), row0, 1) gridLayout.addWidget(inputSpinbox, row1, 1) - - gridLayout.addWidget(QLabel('Output size'), row0, 2) + + gridLayout.addWidget(QLabel("Output size"), row0, 2) gridLayout.addWidget(outZwidget, row1, 2) - + self.widgets[dim] = (inputSpinbox, SizeD) - + self.setLayout(gridLayout) - + def inputOutputSizes(self): if not self.isChecked(): return - + sizes = { dim: (spinbox.value(), int(SizeD)) for dim, (spinbox, SizeD) in self.widgets.items() } return sizes + class WhitelistLineEdit(KeepIDsLineEdit): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def setText(self, IDs): if not isinstance(IDs, set) and not isinstance(IDs, list): - raise TypeError('IDs must be a set or list') - + raise TypeError("IDs must be a set or list") + formatted_text = myutils.format_IDs(IDs) super().setText(formatted_text) + class WhitelistIDsToolbar(ToolBar): sigWhitelistChanged = Signal(list) sigViewOGIDs = Signal(bool) @@ -11070,73 +11295,68 @@ class WhitelistIDsToolbar(ToolBar): sigAddNewIDs = Signal(bool) sigLoadOGLabs = Signal() sigTrackOGagainstPreviousFrame = Signal(bool) - + def __init__(self, addNewIDToggleState, *args) -> None: super().__init__(*args) - whitelistLineEditLabel = QLabel('Whitelist IDs: ') + whitelistLineEditLabel = QLabel("Whitelist IDs: ") self.addWidget(whitelistLineEditLabel) - - self.whitelistLineEdit = WhitelistLineEdit( - whitelistLineEditLabel, parent=self - ) + + self.whitelistLineEdit = WhitelistLineEdit(whitelistLineEditLabel, parent=self) self.whitelistLineEdit.sigEnterPressed.connect(self.accept) self.whitelistLineEdit.sigIDsChanged.connect(self.emitWhitelistChanged) self.addWidget(self.whitelistLineEdit) # accept button - self.acceptButton = self.addButton(':greenTick.svg') + self.acceptButton = self.addButton(":greenTick.svg") self.acceptButton.triggered.connect(self.accept) # add a view OG toggle - self.viewOGToggle = self.addButton(':eye.svg', checkable=True) + self.viewOGToggle = self.addButton(":eye.svg", checkable=True) viewOGTooltip = ( - 'View the non-whitelisted segmentation mask.\n\n' - 'You can activate this to add new IDs to the whitelist,\n' - 'correct tracking errors, etc.' + "View the non-whitelisted segmentation mask.\n\n" + "You can activate this to add new IDs to the whitelist,\n" + "correct tracking errors, etc." ) self.viewOGToggle.setChecked(True) self.viewOGToggle.setToolTip(viewOGTooltip) - self.viewOGToggle.setShortcut('Shift+K') - key = 'View the non-whitelisted segmentation mask' + self.viewOGToggle.setShortcut("Shift+K") + key = "View the non-whitelisted segmentation mask" self.widgetsWithShortcut[key] = self.viewOGToggle - + self.viewOGToggle.toggled.connect(self.emitViewOGIDs) self.emitViewOGIDs(True) # add a Toggle to add new IDs - self.addNewIDToggle = QCheckBox( - 'Automatically add new IDs to whitelist' - ) + self.addNewIDToggle = QCheckBox("Automatically add new IDs to whitelist") self.addNewIDToggle.setChecked(addNewIDToggleState) self.addWidget(self.addNewIDToggle) self.addNewIDToggle.toggled.connect(self.emitAddNewIDs) self.emitAddNewIDs(addNewIDToggleState) - + self.addSeparator() # add a button to load og df - self.loadOGButton = self.addButton(':open_file.svg') + self.loadOGButton = self.addButton(":open_file.svg") self.loadOGButton.triggered.connect(self.sigLoadOGLabs.emit) self.loadOGButton.setToolTip( - 'Select which segmentation mask file to load ' - 'as the non-whitelisted masks' + "Select which segmentation mask file to load as the non-whitelisted masks" ) - self.TrackOGagainstPreviousFrameButton = self.addButton(':segment.svg') + self.TrackOGagainstPreviousFrameButton = self.addButton(":segment.svg") self.TrackOGagainstPreviousFrameButton.triggered.connect( self.sigTrackOGagainstPreviousFrame.emit ) self.TrackOGagainstPreviousFrameButton.setToolTip( - 'Track the non-whitelisted segmentation masks against the previous frame and copy over successfull tacks' + "Track the non-whitelisted segmentation masks against the previous frame and copy over successfull tacks" ) self.addSeparator() # add an info button - self.infoButton = self.addButton(':info.svg') + self.infoButton = self.addButton(":info.svg") self.infoButton.triggered.connect(self.showInfo) - + # add a spacer to the toolbar spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) @@ -11147,18 +11367,18 @@ def emitWhitelistChanged(self, whitelist): def emitViewOGIDs(self, checked): self.sigViewOGIDs.emit(checked) - + def accept(self): try: whitelist = self.whitelistLineEdit.IDs except AttributeError as e: if "has no attribute 'IDs'" in str(e): whitelist = list() - self.viewOGToggle.toggled.disconnect() + self.viewOGToggle.toggled.disconnect() self.viewOGToggle.setChecked(False) self.viewOGToggle.toggled.connect(self.emitViewOGIDs) self.sigWhitelistAccepted.emit(whitelist) - + def emitAddNewIDs(self, checked): self.sigAddNewIDs.emit(checked) @@ -11184,9 +11404,9 @@ def showInfo(self): non-whitelisted file
    by clicking on the "Load file" button to restart from where you left last time. - """ - ) - msg.information(self, 'White list IDs', txt) + """) + msg.information(self, "White list IDs", txt) + class MagicPromptsToolbar(ToolBar): sigPromptTypeChanged = Signal(object, str) @@ -11194,111 +11414,85 @@ class MagicPromptsToolbar(ToolBar): sigComputeOnImage = Signal(object) sigClearPoints = Signal(object) sigClearPointsOnZmom = Signal(object) - sigInitSelectedModel = Signal( - str, object, list, list, str, object - ) - sigViewModelParams = Signal( - str, object, list, list, str, object, object, object - ) + sigInitSelectedModel = Signal(str, object, list, list, str, object) + sigViewModelParams = Signal(str, object, list, list, str, object, object, object) sigInterpolateZslice = Signal(bool) - + def __init__(self, parent=None): super().__init__(parent) - + self._parent = parent - - prompt_types = ( - 'Points', - ) - - self.selectModelAction = self.addButton(':select-list.svg') - self.selectModelAction.setToolTip( - 'Select the promptable model to use' - ) - - self.viewModelParamsAction = self.addButton(':view.svg') + + prompt_types = ("Points",) + + self.selectModelAction = self.addButton(":select-list.svg") + self.selectModelAction.setToolTip("Select the promptable model to use") + + self.viewModelParamsAction = self.addButton(":view.svg") self.viewModelParamsAction.setToolTip( - 'View the currently selected model parameters' + "View the currently selected model parameters" ) self.viewModelParamsAction.setDisabled(True) - + self.addSeparator() - + self.promptTypeCombobox = self.addComboBox( - prompt_types, label='Prompt type: ', + prompt_types, + label="Prompt type: ", ) - + self.addSeparator() - + self.interpolateZslicesCheckbox = self.addCheckBox( - 'Interpolate points on missing z-slices', checked=False + "Interpolate points on missing z-slices", checked=False ) self.interpolateZslicesCheckbox.setToolTip( - 'If checked, when working with 3D segmentation masks, you can ' - 'add points on some z-slices only and the points on the missing ' - 'z-slices will be determined by linear interpolation.\n\n' - 'This is useful when working with 2D models that segments ' - 'each z-slice independently.\n\n' - 'NOTE: The points will be added only when running the model and ' - 'removed afterwards.' + "If checked, when working with 3D segmentation masks, you can " + "add points on some z-slices only and the points on the missing " + "z-slices will be determined by linear interpolation.\n\n" + "This is useful when working with 2D models that segments " + "each z-slice independently.\n\n" + "NOTE: The points will be added only when running the model and " + "removed afterwards." ) - + self.addSeparator() - - self.computeOnZoomAction = self.addButton(':compute-zoom.svg') + + self.computeOnZoomAction = self.addButton(":compute-zoom.svg") self.computeOnZoomAction.setToolTip( - 'Compute the segmentation on the zoomed area of the image ' - '(faster)' - ) - - self.computeAction = self.addButton(':compute.svg') - self.computeAction.setToolTip( - 'Compute the segmentation on the whole image' - ) - - self.clearPointsAction = self.addButton(':clear-points.svg') - self.clearPointsAction.setToolTip( - 'Clear all points' + "Compute the segmentation on the zoomed area of the image (faster)" ) + + self.computeAction = self.addButton(":compute.svg") + self.computeAction.setToolTip("Compute the segmentation on the whole image") + + self.clearPointsAction = self.addButton(":clear-points.svg") + self.clearPointsAction.setToolTip("Clear all points") self.clearPointsAction.setDisabled(True) - - self.clearPointsActionOnZoom = self.addButton(':clear-points-zoom.svg') + + self.clearPointsActionOnZoom = self.addButton(":clear-points-zoom.svg") self.clearPointsActionOnZoom.setToolTip( - 'Clear all points on the zoomed area of the image' + "Clear all points on the zoomed area of the image" ) self.clearPointsActionOnZoom.setDisabled(True) - + self.addSeparator() - - self.infoAction = self.addButton(':info.svg') - self.infoAction.setToolTip( - 'Show instructions how to use promptable models' - ) - + + self.infoAction = self.addButton(":info.svg") + self.infoAction.setToolTip("Show instructions how to use promptable models") + self.addSeparator() - + self.infoAction.triggered.connect(self.showHelp) self.selectModelAction.triggered.connect(self.selectModel) self.viewModelParamsAction.triggered.connect(self.viewModelParams) - self.promptTypeCombobox.sigTextChanged.connect( - self.emitPromptTypeChanged - ) - self.computeOnZoomAction.triggered.connect( - self.emitSigComputeOnZoom - ) - self.computeAction.triggered.connect( - self.emitSigComputeOnImage - ) - self.clearPointsAction.triggered.connect( - self.emitSigClearPoints - ) - self.clearPointsActionOnZoom.triggered.connect( - self.emitSigClearPointsOnZoom - ) - self.interpolateZslicesCheckbox.toggled.connect( - self.sigInterpolateZslice.emit - ) - + self.promptTypeCombobox.sigTextChanged.connect(self.emitPromptTypeChanged) + self.computeOnZoomAction.triggered.connect(self.emitSigComputeOnZoom) + self.computeAction.triggered.connect(self.emitSigComputeOnImage) + self.clearPointsAction.triggered.connect(self.emitSigClearPoints) + self.clearPointsActionOnZoom.triggered.connect(self.emitSigClearPointsOnZoom) + self.interpolateZslicesCheckbox.toggled.connect(self.sigInterpolateZslice.emit) + def showHelp(self): msg = myMessageBox(wrapText=False) txt = html_utils.paragraph(""" @@ -11343,66 +11537,62 @@ def showHelp(self): Note that you can also save the points by clicking on the "Save points" button to load them later and start from where you left.

    - """) - msg.information( - self, 'Promptable models help', txt - ) - + """) + msg.information(self, "Promptable models help", txt) + def emitSigClearPoints(self): self.sigClearPoints.emit(self) - + def emitSigClearPointsOnZoom(self): self.sigClearPointsOnZmom.emit(self) - + def emitSigComputeOnZoom(self): self.sigComputeOnZoom.emit(self) - + def emitSigComputeOnImage(self): self.sigComputeOnImage.emit(self) - + def selectModel(self): win = apps.SelectPromptableModelDialog(parent=self._parent) win.exec_() if win.cancel: - print('Promptable model selection cancelled') + print("Promptable model selection cancelled") return - + model_name = win.model_name - print(f'Importing promptable model {model_name}...') + print(f"Importing promptable model {model_name}...") # Download model weights, consistent with gui.py downloadWin = apps.downloadModel(model_name, parent=self._parent) downloadWin.download() acdcPromptSegment = myutils.import_promptable_segment_module(model_name) - init_argspecs, segment_argspecs = myutils.getModelArgSpec( - acdcPromptSegment - ) - + init_argspecs, segment_argspecs = myutils.getModelArgSpec(acdcPromptSegment) + try: help_url = acdcPromptSegment.url_help() except AttributeError: help_url = None - + self._model_name = model_name self._acdcPromptSegment = acdcPromptSegment self._init_argspecs = init_argspecs self._segment_argspecs = segment_argspecs self._help_url = help_url - + self.sigInitSelectedModel.emit( - model_name, + model_name, acdcPromptSegment, - init_argspecs, + init_argspecs, segment_argspecs, help_url, - self + self, ) - + def setInitializedModel(self, init_kwargs, segment_kwargs): self._init_kwargs = init_kwargs self._segment_kwargs = segment_kwargs - + def viewModelParams(self): self.sigViewModelParams.emit( self._model_name, @@ -11412,60 +11602,63 @@ def viewModelParams(self): self._help_url, self._init_kwargs, self._segment_kwargs, - self + self, ) - + def emitPromptTypeChanged(self, text): self.sigPromptTypeChanged.emit(self, text) + class KeySequenceFromText(QKeySequence): def __init__(self, text: str): if isinstance(text, str): text = macShortcutToWindows(text) super().__init__(text) self._text = text - + def toString(self): if isinstance(self._text, str): return windowsShortcutToMac(self._text) else: return windowsShortcutToMac(super().toString()) - + + def modifierKeyToText(modifierKey: int): if modifierKey == Qt.ControlModifier: - return 'Ctrl' + return "Ctrl" elif modifierKey == Qt.AltModifier: - return 'Alt' + return "Alt" elif modifierKey == Qt.ShiftModifier: - return 'Shift' + return "Shift" elif modifierKey == Qt.MetaModifier: - return 'Meta' + return "Meta" else: - return '' + return "" + class TimeWidget(QGroupBox): sigValueChanged = Signal(object) - - def __init__(self, parent=None, orientation='vertical'): + + def __init__(self, parent=None, orientation="vertical"): super().__init__(parent) - + mainLayout = QHBoxLayout() - - if orientation == 'vertical': + + if orientation == "vertical": spinboxesLayout = QVBoxLayout() - elif orientation == 'horizontal': + elif orientation == "horizontal": spinboxesLayout = QHBoxLayout() else: raise ValueError('orientation must be "vertical" or "horizontal"') - + self.signCombobox = QComboBox() - self.signCombobox.addItems(('+', '-')) + self.signCombobox.addItems(("+", "-")) self.signCombobox.currentTextChanged.connect(self.emitValueChanged) - + mainLayout.addWidget(self.signCombobox) - + self.spinboxesMapper = {} - units = ('days', 'hours', 'minutes', 'seconds') + units = ("days", "hours", "minutes", "seconds") for unit in units: layout = QHBoxLayout() spinbox = SpinBox() @@ -11476,79 +11669,71 @@ def __init__(self, parent=None, orientation='vertical'): spinbox.valueChanged.connect(self.emitValueChanged) self.spinboxesMapper[unit] = spinbox spinboxesLayout.addLayout(layout) - + mainLayout.addLayout(spinboxesLayout) - + self.setLayout(mainLayout) mainLayout.setContentsMargins(5, 5, 5, 5) - + def values(self): values = {} for unit, spinbox in self.spinboxesMapper.items(): values[unit] = spinbox.value() - + signText = self.signCombobox.currentText() return values, sign_int_mapper[signText] - + def setValuesFromTimedelta(self, timedelta): total_seconds = timedelta.total_seconds() sign = 1 if total_seconds > 0 else -1 days = timedelta.days hours, remainder = divmod(timedelta.seconds, 3600) minutes, seconds = divmod(remainder, 60) - - values = { - 'days': days, - 'hours': hours, - 'minutes': minutes, - 'seconds': seconds - } - + + values = {"days": days, "hours": hours, "minutes": minutes, "seconds": seconds} + self.setValues(values, sign=sign) - + def timedelta(self): values, sign = self.values() - return datetime.timedelta(**values)*sign - + return datetime.timedelta(**values) * sign + def setValues(self, values: dict[str, int | float], sign=1): - signText = '+' if sign > 0 else '-' + signText = "+" if sign > 0 else "-" self.signCombobox.setCurrentText(signText) for unit, value in values.items(): spinbox = self.spinboxesMapper[unit] spinbox.setValue(value) - + def emitValueChanged(self, value): self.sigValueChanged.emit(self.values()) + class PointsLayersToolbar(ToolBar): sigAddPointsLayer = Signal() - - def __init__(self, name='Points layers', parent=None): - + + def __init__(self, name="Points layers", parent=None): + super().__init__(name, parent) - + self.guiWin = parent - + self.setContextMenuPolicy(Qt.PreventContextMenu) - - self.addPointsLayerAction = self.addButton(':addPointsLayer.svg') - + + self.addPointsLayerAction = self.addButton(":addPointsLayer.svg") + self.addSeparator() - - self.pointsLayersLabel = self.addLabel('Points layers: ') - - self.addPointsLayerAction.triggered.connect( - self.emitAddPointsLayer - ) + + self.pointsLayersLabel = self.addLabel("Points layers: ") + + self.addPointsLayerAction.triggered.connect(self.emitAddPointsLayer) self.doAddPointsZslicesInterpolation = False - + def emitAddPointsLayer(self): self.sigAddPointsLayer.emit() - + def fromActionToDataFrame(self, action, posData, isSegm3D=False): - df = pd.DataFrame( - columns=['frame_i', 'Cell_ID', 'z', 'y', 'x', 'id'] - ) + df = pd.DataFrame(columns=["frame_i", "Cell_ID", "z", "y", "x", "id"]) frames_vals = [] IDs = [] zz = [] @@ -11557,15 +11742,15 @@ def fromActionToDataFrame(self, action, posData, isSegm3D=False): ids = [] pos_i = self.guiWin.pos_i if pos_i not in action.pointsData: - printl('No points data for position', pos_i) # should really not happen, but its not a disaster if it does + printl( + "No points data for position", pos_i + ) # should really not happen, but its not a disaster if it does return df pointsDataPos = action.pointsData[pos_i] for frame_i, framePointsData in pointsDataPos.items(): if posData.SizeZ > 1: for z, zSlicePointsData in framePointsData.items(): - yyxx = zip( - zSlicePointsData['y'], zSlicePointsData['x'] - ) + yyxx = zip(zSlicePointsData["y"], zSlicePointsData["x"]) for y, x in yyxx: if isSegm3D: ID = posData.lab[int(z), int(y), int(x)] @@ -11576,108 +11761,101 @@ def fromActionToDataFrame(self, action, posData, isSegm3D=False): zz.append(z) yy.append(y) xx.append(x) - ids.extend(zSlicePointsData['id']) + ids.extend(zSlicePointsData["id"]) else: - yyxx = zip(framePointsData['y'], framePointsData['x']) + yyxx = zip(framePointsData["y"], framePointsData["x"]) for y, x in yyxx: ID = posData.lab[int(y), int(x)] frames_vals.append(frame_i) IDs.append(ID) yy.append(y) xx.append(x) - ids.extend(framePointsData['id']) - df['frame_i'] = frames_vals - df['Cell_ID'] = IDs - df['y'] = yy - df['x'] = xx - df['id'] = ids + ids.extend(framePointsData["id"]) + df["frame_i"] = frames_vals + df["Cell_ID"] = IDs + df["y"] = yy + df["x"] = xx + df["id"] = ids if zz: - df['z'] = zz - + df["z"] = zz + df = self.addPointsZslicesInterpolation(df, posData.lab, isSegm3D) - + return df def addPointsZslicesInterpolation( - self, - df: pd.DataFrame, - lab: np.ndarray, - isSegm3D: bool - ): + self, df: pd.DataFrame, lab: np.ndarray, isSegm3D: bool + ): if not self.doAddPointsZslicesInterpolation: return df - + if not isSegm3D: return df - - if 'z' not in df.columns: + + if "z" not in df.columns: return df - + df_new_rows = [] - for (frame_i, point_id), df_id in df.groupby(['frame_i', 'id']): - xx = df_id['x'].values - yy = df_id['y'].values - zz = df_id['z'].values - + for (frame_i, point_id), df_id in df.groupby(["frame_i", "id"]): + xx = df_id["x"].values + yy = df_id["y"].values + zz = df_id["z"].values + p0, d = core.linear_fit_3d(xx, yy, zz) - + new_row_df = df_id.iloc[[0]].copy() - + z0, z1 = int(np.min(zz)), int(np.max(zz)) - for z in range(z0, z1+1): + for z in range(z0, z1 + 1): if z in zz: continue - + t_int = (z - p0[2]) / d[2] x_new, y_new, z_new = p0 + t_int * d - new_row_df['z'] = round(z_new) - new_row_df['y'] = round(y_new) - new_row_df['x'] = round(x_new) - - Cell_ID = lab[ - int(round(z_new)), - int(round(y_new)), - int(round(x_new)) - ] - new_row_df['Cell_ID'] = Cell_ID - + new_row_df["z"] = round(z_new) + new_row_df["y"] = round(y_new) + new_row_df["x"] = round(x_new) + + Cell_ID = lab[int(round(z_new)), int(round(y_new)), int(round(x_new))] + new_row_df["Cell_ID"] = Cell_ID + df_new_rows.append(new_row_df.copy()) - + if not df_new_rows: return df - + df_new = pd.concat(df_new_rows, ignore_index=True) df = pd.concat([df, df_new], ignore_index=True) - df = df.sort_values(by=['frame_i', 'id', 'z']).reset_index(drop=True) - + df = df.sort_values(by=["frame_i", "id", "z"]).reset_index(drop=True) + return df + class PromptableModelPointsLayerToolbar(PointsLayersToolbar): - def __init__(self, name='Promptable model points layers', parent=None): + def __init__(self, name="Promptable model points layers", parent=None): super().__init__(name, parent=parent) - + self.isPointsLayerInit = False - + self.addPointsLayerAction.setDisabled(True) self.addPointsLayerAction.setVisible(False) - + def pointsLayerDf(self, posData, isSegm3D=False): for action in self.actions()[1:]: - if not hasattr(action, 'button'): + if not hasattr(action, "button"): continue - - df = self.fromActionToDataFrame( - action, posData, isSegm3D=isSegm3D - ) + + df = self.fromActionToDataFrame(action, posData, isSegm3D=isSegm3D) return df - + def scatterItem(self): for action in self.actions()[1:]: - if not hasattr(action, 'button'): + if not hasattr(action, "button"): continue - + return action.scatterItem + class RectItem(pg.GraphicsObject): def __init__(self, rect, pen=None, brush=(255, 0, 0, 100), parent=None): super().__init__(parent) @@ -11688,22 +11866,22 @@ def __init__(self, rect, pen=None, brush=(255, 0, 0, 100), parent=None): self._generate_picture() def setColor(self, color): - rgba = matplotlib.colors.to_rgba(color, alpha=100/255) - rgba = [round(c*255) for c in rgba] + rgba = matplotlib.colors.to_rgba(color, alpha=100 / 255) + rgba = [round(c * 255) for c in rgba] self._brush = pg.mkBrush(rgba) self._generate_picture() self.update() - + def setRect(self, x, y, width, height): self._rect = QRectF(x, y, width, height) self._generate_picture() self.update() - + def setQRect(self, qrect): self._rect = qrect self._generate_picture() self.update() - + @property def rect(self): return self._rect @@ -11721,6 +11899,7 @@ def paint(self, painter, option, widget=None): def boundingRect(self): return QRectF(self.picture.boundingRect()) + def get_min_width_for_no_scrollbar(list_widget: QListWidget) -> int: """ Calculate the minimum width needed for the QListWidget @@ -11738,137 +11917,126 @@ def get_min_width_for_no_scrollbar(list_widget: QListWidget) -> int: padding = 30 # Adjust as needed (depends on style and icons) return max_width + padding + class OverlayToolbar(ToolBar): sigSetTranspacency = Signal(bool) sigSetSingleChannel = Signal(bool) - - def __init__(self, name='Overlay tools', parent=None): - + + def __init__(self, name="Overlay tools", parent=None): + super().__init__(name, parent) - + self.guiWin = parent - + self.setContextMenuPolicy(Qt.PreventContextMenu) - + self.addSeparator() - + self.transparencyCheckbox = self.addCheckBox( - text='True transparency (RGBA composite)' + text="True transparency (RGBA composite)" ) - + self.transparencyCheckbox.setToolTip( - 'Activate to achieve true pixel-wise transparency where ' - 'the pixel intensity is 0 or set to 0 using the ' - 'LUT sliders on the left of the images.\n\n' - 'Since it is significantly slower, we recommended to activate this ' - 'only if you need to export images for figures.' + "Activate to achieve true pixel-wise transparency where " + "the pixel intensity is 0 or set to 0 using the " + "LUT sliders on the left of the images.\n\n" + "Since it is significantly slower, we recommended to activate this " + "only if you need to export images for figures." ) - + self.addSeparator() - - self.singleChannelCheckbox = self.addCheckBox( - text='Single channel' - ) - + + self.singleChannelCheckbox = self.addCheckBox(text="Single channel") + self.singleChannelCheckbox.setToolTip( - 'When single channel mode is activated, selecting a channel ' - 'will display only that channel in the overlay.' + "When single channel mode is activated, selecting a channel " + "will display only that channel in the overlay." ) - + self.transparencyCheckbox.toggled.connect(self.sigSetTranspacency.emit) - self.singleChannelCheckbox.toggled.connect( - self.sigSetSingleChannel.emit - ) - + self.singleChannelCheckbox.toggled.connect(self.sigSetSingleChannel.emit) + def setTransparent(self, transparent: bool): self.transparencyCheckbox.setChecked(transparent) - + def isTransparent(self): return self.transparencyCheckbox.isChecked() - + def isSingleChannel(self): return self.singleChannelCheckbox.isChecked() + class OverlayChannelToolButton(GradientToolButton): def __init__( - self, - channel_name: str, - lut_item: myHistogramLUTitem, - shortcut='0', - parent=None, - ): - super().__init__( - colors=lut_item.gradient.getLookupTable(256), - parent=parent - ) + self, + channel_name: str, + lut_item: myHistogramLUTitem, + shortcut="0", + parent=None, + ): + super().__init__(colors=lut_item.gradient.getLookupTable(256), parent=parent) self._channel_name = channel_name - + lut_item.sigGradientChanged.connect(self.updateColors) - - self.setToolTip( - f'Show/hide "{channel_name}" channel\n\n' - f'Shortcut: {shortcut}' - ) - + + self.setToolTip(f'Show/hide "{channel_name}" channel\n\nShortcut: {shortcut}') + self.setCheckable(True) - + def channelName(self): return self._channel_name - + def updateColors(self, lut_item): colors = lut_item.gradient.getLookupTable(256) self._qcolors = [pg.mkColor(c) for c in colors] self.update() - + def setVisible(self, visible: bool): super().setVisible(visible) - if not hasattr(self, 'action'): + if not hasattr(self, "action"): return - + self.action.setVisible(visible) + class YeazV2SelectModelNameCombobox(ComboBox): sigValueChanged = Signal(str) - + def __init__( - self, *args, - custom_select_item_text='Select custom weights file...', - **kwargs - ): + self, *args, custom_select_item_text="Select custom weights file...", **kwargs + ): super().__init__(*args, **kwargs) self._csi_text = custom_select_item_text self.sigTextChanged.connect(self.onTextChanged) self.initItems() - + def initItems(self): from cellacdc.segmenters.YeaZ_v2 import load_models_filepath + models_name, models_name_filepath_mapper = load_models_filepath() self.addItems(models_name) - + def onTextChanged(self, text): if text != self._csi_text: return - + start_dir = myutils.getMostRecentPath() model_filepath = qtpy.compat.getopenfilename( - parent=self, - caption='Select YeaZ weights file', - filters='All Files (*)', - basedir=start_dir + parent=self, + caption="Select YeaZ weights file", + filters="All Files (*)", + basedir=start_dir, )[0] if not model_filepath: self.setCurrentIndex(0) return - + msg = html_utils.paragraph(f""" Insert a name for the following YeaZ model:

    {model_filepath}
    """) modelNameWindow = apps.QLineEditDialog( - title='Insert a name for the model', - msg=msg, - allowEmpty=False, - parent=self + title="Insert a name for the model", msg=msg, allowEmpty=False, parent=self ) modelNameWindow.exec_() if modelNameWindow.cancel: @@ -11876,23 +12044,24 @@ def onTextChanged(self, text): return model_name = modelNameWindow.enteredValue - + from cellacdc.segmenters.YeaZ_v2 import add_model_filepath + add_model_filepath(model_name, model_filepath) - + self.addItem(model_name) self.setCurrentText(model_name) - + print( - 'YeaZ_v2 model added!\n\n' - f' * Name: {model_name}\n' - f' * File path: {model_filepath}\n' + "YeaZ_v2 model added!\n\n" + f" * Name: {model_name}\n" + f" * File path: {model_filepath}\n" ) - + def addItem(self, item): idx = self.count() - 1 self.insertItem(idx, item) - + def addItems(self, items): super().clear() super().addItems(items) @@ -11901,148 +12070,140 @@ def addItems(self, items): font = self.font() font.setItalic(True) self.setItemData(idx, font, Qt.FontRole) - + def setValue(self, value: str): self.setCurrentText(value) - + def value(self, *args): return self.currentText() class HighlightedIDToolbar(ToolBar): sigIDChanged = Signal(int) - - def __init__(self, name='Highlighted ID', parent=None): - + + def __init__(self, name="Highlighted ID", parent=None): + super().__init__(name, parent) - - self.spinbox = self.addSpinBox('Highlighted ID: ') + + self.spinbox = self.addSpinBox("Highlighted ID: ") self.spinbox.valueChanged.connect(self.emitSigIDChanged) - + self.addSeparator() - + def emitSigIDChanged(self, *args, **kwargs): self.sigIDChanged.emit(self.spinbox.value()) - + def setIDNoSignals(self, ID: int): self.spinbox.blockSignals(True) self.spinbox.setValue(ID) self.spinbox.blockSignals(False) - - + + class AutoSaveIntervalWidget(QWidget): sigValueChanged = Signal(float, str) - + def __init__(self, parent=None): super().__init__(parent) - + layout = QHBoxLayout() - - autoSaveIntervalTooltip = ( - 'Autosave every minutes or frames specified here.' - ) - + + autoSaveIntervalTooltip = "Autosave every minutes or frames specified here." + self.setToolTip(autoSaveIntervalTooltip) - + self.spinbox = DoubleSpinBox() self.spinbox.setMinimum(0) self.spinbox.setValue(2) self.spinbox.setDecimals(2) self.spinbox.setSingleStep(1.0) - + layout.addWidget(self.spinbox) - + self.unitCombobox = ComboBox() - self.unitCombobox.addItems(['minutes', 'frames']) + self.unitCombobox.addItems(["minutes", "frames"]) layout.addWidget(self.unitCombobox) - + layout.setStretch(0, 1) layout.setStretch(1, 0) layout.setContentsMargins(5, 0, 5, 0) - + self.setLayout(layout) - + self.spinbox.sigValueChanged.connect(self.emitSigValueChanged) self.unitCombobox.sigTextChanged.connect(self.emitSigValueChanged) - + def emitSigValueChanged(self, *args, **kwargs): - self.sigValueChanged.emit( - self.spinbox.value(), - self.unitCombobox.currentText() - ) + self.sigValueChanged.emit(self.spinbox.value(), self.unitCombobox.currentText()) + class CheckableWidget(QWidget): - def __init__(self, widget, valueGetterName='value', parent=None): + def __init__(self, widget, valueGetterName="value", parent=None): super().__init__(parent) - + self.widget = widget self.valueGetterName = valueGetterName - + widget.setDisabled(True) - + layout = QHBoxLayout() - + layout.addWidget(widget) - - self.checkbox = QCheckBox('Activate') + + self.checkbox = QCheckBox("Activate") self.checkbox.toggled.connect(self.setWidgetEnabled) - + layout.addSpacing(5) layout.addWidget(self.checkbox) - + layout.setContentsMargins(5, 0, 5, 0) - self.setLayout(layout) - + def setWidgetEnabled(self, checked): self.widget.setDisabled(not checked) - + def value(self): if not self.checkbox.isChecked(): return - + return getattr(self.widget, self.valueGetterName)() - - -class WandControlsToolbar(ToolBar): - def __init__(self, name='Magic wand controls', parent=None): + + +class WandControlsToolbar(ToolBar): + def __init__(self, name="Magic wand controls", parent=None): super().__init__(name, parent) - - self.toleranceSpinbox = self.addSpinBox('Tolerance [%]: ') + + self.toleranceSpinbox = self.addSpinBox("Tolerance [%]: ") self.toleranceSpinbox.setMinimum(0) self.toleranceSpinbox.setMaximum(100) self.toleranceSpinbox.setValue(5) self.toleranceSpinbox.setToolTip( - 'The tolerance is calculated as a percentage of the minimum-maximum ' - 'pixel values range of the loaded dataset.\n\n' - 'If tolerance is greater than 0, the pixels adjacent to the added ' - 'pixels with value within +- tolerance will be considered part of ' - 'the object.' + "The tolerance is calculated as a percentage of the minimum-maximum " + "pixel values range of the loaded dataset.\n\n" + "If tolerance is greater than 0, the pixels adjacent to the added " + "pixels with value within +- tolerance will be considered part of " + "the object." ) - self.addLabel(r'% of min-max intensity range ') - + self.addLabel(r"% of min-max intensity range ") + self.addSeparator() - - self.autoFillHolesCheckbox = self.addCheckBox( - 'Auto-fill holes' - ) - + + self.autoFillHolesCheckbox = self.addCheckBox("Auto-fill holes") + self.addSeparator() - - self.useConvexHullCheckbox = self.addCheckBox( - 'Use convex hull mask' - ) - + + self.useConvexHullCheckbox = self.addCheckBox("Use convex hull mask") + self.addSeparator() - + + class warnVisualCppRequired(myMessageBox): - def __init__(self, pkg_name='javabridge', parent=None): + def __init__(self, pkg_name="javabridge", parent=None): super().__init__(parent) self.screenShotWin = None - self.setIcon(iconName='SP_MessageBoxWarning') - self.setWindowTitle(f'Installation of {pkg_name} info') + self.setIcon(iconName="SP_MessageBoxWarning") + self.setWindowTitle(f"Installation of {pkg_name} info") txt = html_utils.paragraph(f""" Installation of {pkg_name} on Windows requires Microsoft Visual C++ 14.0 or higher.

    @@ -12058,23 +12219,21 @@ def __init__(self, pkg_name='javabridge', parent=None): make sure to select "Desktop development with C++". Click "See the screenshot" for more details. """) - seeScreenshotButton = QPushButton('See screenshot...') - okButton = okPushButton('Ok') - okButton = self.addButton('Ok') + seeScreenshotButton = QPushButton("See screenshot...") + okButton = okPushButton("Ok") + okButton = self.addButton("Ok") okButton.disconnect() okButton.clicked.connect(self.ok_cb) self.addButton(seeScreenshotButton) seeScreenshotButton.disconnect() - seeScreenshotButton.clicked.connect( - self.viewScreenshot - ) + seeScreenshotButton.clicked.connect(self.viewScreenshot) self.addCancelButton(connect=True) self.addText(txt) def ok_cb(self): self.cancel = False self.close() - + def viewScreenshot(self, checked=False): self.screenShotWin = view_visualcpp_screenshot(self) self.screenShotWin.show() @@ -12082,5 +12241,5 @@ def viewScreenshot(self, checked=False): def closeEvent(self, event): if self.screenShotWin is not None: self.screenShotWin.close() - - return super().closeEvent(event) \ No newline at end of file + + return super().closeEvent(event) diff --git a/cellacdc/workers.py b/cellacdc/workers.py index 30f47dc6e..35c298607 100755 --- a/cellacdc/workers.py +++ b/cellacdc/workers.py @@ -24,16 +24,11 @@ from tqdm import tqdm -from qtpy.QtCore import ( - Signal, QObject, QMutex, QWaitCondition -) +from qtpy.QtCore import Signal, QObject, QMutex, QWaitCondition from cellacdc import html_utils -from . import ( - load, myutils, core, prompts, printl, config, - segm_re_pattern, io -) +from . import load, myutils, core, prompts, printl, config, segm_re_pattern, io from . import transformation, measurements, cca_functions from .path import copy_or_move_tree from . import features, plot @@ -46,6 +41,7 @@ DEBUG = False + def worker_exception_handler(func): @wraps(func) def run(self): @@ -57,31 +53,33 @@ def run(self): self.dataQ.clear() except Exception as err: pass - - # Some workers have both self.critical and self.signals.critical - # errors but only one of them is connected --> emit both just + + # Some workers have both self.critical and self.signals.critical + # errors but only one of them is connected --> emit both just # in case try: self.critical.emit((self, error)) except Exception as err: self.signals.critical.emit((self, error)) - + try: self.signals.critical.emit((self, error)) except Exception as err: self.critical.emit((self, error)) - + try: self.mutex.unlock() except Exception as err: pass + return run + class workerLogger: def __init__(self, sigProcess): self.sigProcess = sigProcess - - def log(self, message, level='INFO'): + + def log(self, message, level="INFO"): try: self.sigProcess.emit(str(message), level) except Exception as err: @@ -94,15 +92,16 @@ def log(self, message, level='INFO'): printl(err) finally: pass - + def info(self, message): - self.log(message, level='INFO') + self.log(message, level="INFO") def warning(self, message): - self.log(message, level='WARNING') - + self.log(message, level="WARNING") + def exception(self, message): - self.log(message, level='EXCEPTION') + self.log(message, level="EXCEPTION") + class signals(QObject): progress = Signal(str, object) @@ -139,6 +138,7 @@ class signals(QObject): sigSelectFilesWithText = Signal(str, object, str, object) sigAskRunNow = Signal(object) + class AutoPilotWorker(QObject): finished = Signal() critical = Signal(object) @@ -152,17 +152,18 @@ def __init__(self, guiWin): self.guiWin = guiWin self.app = guiWin.app # self.timer = timer - + def timerCallback(self): pass - + def stop(self): self.sigStopTimer.emit() - self.finished.emit() - + self.finished.emit() + def run(self): self.sigStarted.emit() + class FindNextNewIdWorker(QObject): def __init__(self, posData, guiWin): QObject.__init__(self) @@ -170,32 +171,33 @@ def __init__(self, posData, guiWin): self.logger = workerLogger(self.signals.progress) self.posData = posData self.guiWin = guiWin - + @worker_exception_handler def run(self): prev_IDs = None next_frame_i = -1 for frame_i, data_dict in enumerate(self.posData.allData_li): - lab = data_dict['labels'] - rp = data_dict['regionprops'] - IDs = data_dict['IDs'] + lab = data_dict["labels"] + rp = data_dict["regionprops"] + IDs = data_dict["IDs"] if lab is None: lab = self.posData.segm_data[frame_i] rp = skimage.measure.regionprops(lab) IDs = [obj.label for obj in rp] - + if prev_IDs is None: prev_IDs = IDs continue - + newIDs = [ID for ID in IDs if ID not in prev_IDs] if newIDs: next_frame_i = frame_i - break + break prev_IDs = IDs - + self.signals.finished.emit(next_frame_i) + class SegForLostIDsWorker(QObject): sigAskInit = Signal() sigAskInstallModel = Signal(str) @@ -223,19 +225,19 @@ def emitSigAskInit(self): self.sigAskInit.emit() self.waitCond.wait(self.mutex) self.mutex.unlock() - + def emitSigShowImageDebug(self, img): # self.mutex.lock() self.sigshowImageDebug.emit(img) # self.waitCond.wait(self.mutex) # self.mutex.unlock() - + def emitSigStoreData(self, autosave): self.mutex.lock() self.sigStoreData.emit(autosave) self.waitCond.wait(self.mutex) self.mutex.unlock() - + def emitSigUpdateRP(self, wl_track_og_curr, wl_update): self.mutex.lock() self.sigUpdateRP.emit(wl_track_og_curr, wl_update) @@ -253,32 +255,31 @@ def emitSigAskInstallModel(self, model_name): self.sigAskInstallModel.emit(model_name) self.waitCond.wait(self.mutex) self.mutex.unlock() - + def emitSigAskInstallGPU(self, base_model_name, use_gpu): self.mutex.lock() - self.sigSegForLostIDsWorkerAskInstallGPU.emit(base_model_name, - use_gpu) + self.sigSegForLostIDsWorkerAskInstallGPU.emit(base_model_name, use_gpu) self.waitCond.wait(self.mutex) self.mutex.unlock() - + # def emitGet2Dlab(self): # self.mutex.lock() # self.sigGet2Dlab.emit() # self.waitCond.wait(self.mutex) # self.mutex.unlock() - + # def emitGetTrackedLostIDs(self): # self.mutex.lock() # self.sigGetTrackedLostIDs.emit() # self.waitCond.wait(self.mutex) # self.mutex.unlock() - + # def emitGetBrushID(self): # self.mutex.lock() # self.sigGetBrushID.emit() # self.waitCond.wait(self.mutex) # self.mutex.unlock() - + def emitTrackManuallyAddedObject(self, IDs, isLost, wl_update, wl_track_og_curr): self.mutex.lock() self.sigTrackManuallyAddedObject.emit(IDs, isLost, wl_update, wl_track_og_curr) @@ -292,62 +293,67 @@ def run(self): if not self.guiWin.SegForLostIDsSettings: self.emitSigAskInit() - + if not self.guiWin.SegForLostIDsSettings: self.signals.finished.emit(self) return - self.logger.info('Segmentation for lost IDs started.') - model_name = 'local_seg' - base_model_name = self.guiWin.SegForLostIDsSettings['base_model_name'] + self.logger.info("Segmentation for lost IDs started.") + model_name = "local_seg" + base_model_name = self.guiWin.SegForLostIDsSettings["base_model_name"] idx = self.guiWin.modelNames.index(model_name) acdcSegment = self.guiWin.acdcSegment_li[idx] - - init_kwargs = self.guiWin.SegForLostIDsSettings['win'].init_kwargs - - use_gpu = init_kwargs.get('device_type', 'cpu') != 'cpu' - use_gpu = use_gpu or init_kwargs.get('use_gpu', False) - + + init_kwargs = self.guiWin.SegForLostIDsSettings["win"].init_kwargs + + use_gpu = init_kwargs.get("device_type", "cpu") != "cpu" + use_gpu = use_gpu or init_kwargs.get("use_gpu", False) + self.emitSigAskInstallGPU(base_model_name, use_gpu) - + if not self.gpu_go: self.signals.finished.emit(self) return - - if not self.dont_force_cpu: - if 'device' in init_kwargs: - init_kwargs['device'] = 'cpu' - if 'use_gpu' in init_kwargs: - init_kwargs['use_gpu'] = False - if acdcSegment is None or base_model_name != self.guiWin.local_seg_base_model_name: + if not self.dont_force_cpu: + if "device" in init_kwargs: + init_kwargs["device"] = "cpu" + if "use_gpu" in init_kwargs: + init_kwargs["use_gpu"] = False + + if ( + acdcSegment is None + or base_model_name != self.guiWin.local_seg_base_model_name + ): try: - self.logger.info(f'Importing {base_model_name}...') + self.logger.info(f"Importing {base_model_name}...") self.emitSigAskInstallModel(base_model_name) acdcSegment = myutils.import_segment_module(base_model_name) self.guiWin.acdcSegment_li[idx] = acdcSegment self.guiWin.local_seg_base_model_name = base_model_name except (IndexError, ImportError, KeyError) as e: self.logger.warning( - f'Cannot import {base_model_name} model. ' - 'Please install it first.' + f"Cannot import {base_model_name} model. Please install it first." ) self.signals.critical.emit( - (self, f'Cannot import {base_model_name} model. ' - 'Please install it first.') + ( + self, + f"Cannot import {base_model_name} model. " + "Please install it first.", + ) ) self.signals.finished.emit(self) return - win = self.guiWin.SegForLostIDsSettings['win'] - init_kwargs_new = self.guiWin.SegForLostIDsSettings['init_kwargs_new'] - args_new = self.guiWin.SegForLostIDsSettings['args_new'] + win = self.guiWin.SegForLostIDsSettings["win"] + init_kwargs_new = self.guiWin.SegForLostIDsSettings["init_kwargs_new"] + args_new = self.guiWin.SegForLostIDsSettings["args_new"] model = myutils.init_segm_model(acdcSegment, posData, init_kwargs_new) if model is None: - self.logger.info('Segmentation model was not initialized correctly!') + self.logger.info("Segmentation model was not initialized correctly!") self.signals.critical.emit( - (self, 'Segmentation model was not initialized correctly!') + (self, "Segmentation model was not initialized correctly!") ) self.signals.finished.emit(self) return @@ -364,13 +370,15 @@ def run(self): bboxs_list = [] curr_img = self.guiWin.getDisplayedImg1() - prev_lab = self.guiWin.get_2Dlab(posData.allData_li[frame_i-1]['labels']) - prev_IDs = set(posData.allData_li[frame_i-1]['IDs']) + prev_lab = self.guiWin.get_2Dlab(posData.allData_li[frame_i - 1]["labels"]) + prev_IDs = set(posData.allData_li[frame_i - 1]["IDs"]) # should probably not paly so much with posData.lab, instead handle stuff myself - self.signals.initProgressBar.emit(2 * args_new['max_iterations']) - new_labs = np.zeros([args_new['max_iterations'], *posData.lab.shape], dtype=np.uint32) - for i in range(args_new['max_iterations']): + self.signals.initProgressBar.emit(2 * args_new["max_iterations"]) + new_labs = np.zeros( + [args_new["max_iterations"], *posData.lab.shape], dtype=np.uint32 + ) + for i in range(args_new["max_iterations"]): curr_lab = self.guiWin.get_2Dlab(posData.lab) tracked_lost_IDs = self.guiWin.getTrackedLostIDs() new_unique_ID = self.guiWin.setBrushID(useCurrentLab=True, return_val=True) @@ -380,15 +388,20 @@ def run(self): assigned_IDs_prev = assigned_IDs.copy() out = segm_utils.single_cell_seg( - model, prev_lab, curr_lab, curr_img, - missing_IDs, new_unique_ID, - win, posData, - distance_filler_growth=args_new['distance_filler_growth'], - overlap_threshold=args_new['overlap_threshold'], - padding=args_new['padding'], + model, + prev_lab, + curr_lab, + curr_img, + missing_IDs, + new_unique_ID, + win, + posData, + distance_filler_growth=args_new["distance_filler_growth"], + overlap_threshold=args_new["overlap_threshold"], + padding=args_new["padding"], ) new_lab, assigned_IDs, IDs_bboxs, bboxs = out - + IDs_bboxs_list.append(IDs_bboxs) bboxs_list.append(bboxs) posData.lab = new_lab @@ -397,7 +410,7 @@ def run(self): self.emitTrackManuallyAddedObject(newly_assigned_IDs, True, False, False) new_labs[i] = posData.lab.copy() self.signals.progressBar.emit(1) - + if self._debug: originals = [] models = [] @@ -412,19 +425,26 @@ def run(self): models.append(posData.lab.copy()) for IDs, bbox in zip(IDs_bboxs, bboxs): - - box_x_min, box_x_max, box_y_min, box_y_max = bbox - original_bbox_lab = original_lab[box_x_min:box_x_max, box_y_min:box_y_max] - original_bbox_lab_cleared_borders = skimage.segmentation.clear_border(original_bbox_lab) + box_x_min, box_x_max, box_y_min, box_y_max = bbox + original_bbox_lab = original_lab[ + box_x_min:box_x_max, box_y_min:box_y_max + ] + original_bbox_lab_cleared_borders = skimage.segmentation.clear_border( + original_bbox_lab + ) box_model_lab = model_lab[box_x_min:box_x_max, box_y_min:box_y_max] # original_bbox_lab[np.isin(original_bbox_lab, IDs)] = 0 should be a given. If not seg for lost IDs this recommended - box_model_lab = skimage.segmentation.clear_border(box_model_lab, buffer_size=1) + box_model_lab = skimage.segmentation.clear_border( + box_model_lab, buffer_size=1 + ) rp_model_lab = skimage.measure.regionprops(box_model_lab) rp_original_lab = skimage.measure.regionprops(original_bbox_lab) - rp_original_lab_cleared = skimage.measure.regionprops(original_bbox_lab_cleared_borders) + rp_original_lab_cleared = skimage.measure.regionprops( + original_bbox_lab_cleared_borders + ) original_IDs = [obj.label for obj in rp_original_lab] areas = [obj.area for obj in rp_original_lab_cleared] @@ -432,28 +452,40 @@ def run(self): area_mean = np.mean(areas) else: area_mean = global_area_mean - if args_new['allow_only_tracked_cells']: - filtered_IDs = [obj.label for obj in rp_model_lab - if obj.area > (1 - args_new['size_perc_diff']) * area_mean - and obj.area < (1 + args_new['size_perc_diff']) * area_mean - and obj.label not in original_IDs - and obj.label in missing_IDs_global] + if args_new["allow_only_tracked_cells"]: + filtered_IDs = [ + obj.label + for obj in rp_model_lab + if obj.area > (1 - args_new["size_perc_diff"]) * area_mean + and obj.area < (1 + args_new["size_perc_diff"]) * area_mean + and obj.label not in original_IDs + and obj.label in missing_IDs_global + ] else: - filtered_IDs = [obj.label for obj in rp_model_lab - if obj.area > (1 - args_new['size_perc_diff']) * area_mean - and obj.area < (1 + args_new['size_perc_diff']) * area_mean - and obj.label not in original_IDs] - + filtered_IDs = [ + obj.label + for obj in rp_model_lab + if obj.area > (1 - args_new["size_perc_diff"]) * area_mean + and obj.area < (1 + args_new["size_perc_diff"]) * area_mean + and obj.label not in original_IDs + ] + if self._debug or DEBUG: - filtered_sizes = [(obj.label, obj.area) for obj in rp_model_lab if obj.label in filtered_IDs] + filtered_sizes = [ + (obj.label, obj.area) + for obj in rp_model_lab + if obj.label in filtered_IDs + ] self.logger.info(f"Filtered sizes: {filtered_sizes}") for label in filtered_IDs: - original_bbox_lab[box_model_lab == label] = label # here the stuff should be tracked, so we keep the ID! - + original_bbox_lab[box_model_lab == label] = ( + label # here the stuff should be tracked, so we keep the ID! + ) + # original_lab[box_x_min:box_x_max, box_y_min:box_y_max] = original_bbox_lab - + self.signals.progressBar.emit(1) - + posData.lab = original_lab # if self._debug: @@ -465,14 +497,15 @@ def run(self): self.emitSigUpdateRP(wl_track_og_curr=True, wl_update=True) self.emitSigStoreData(autosave=True) - self.logger.info('Segmentation for lost IDs done.') - + self.logger.info("Segmentation for lost IDs done.") + self.signals.finished.emit(self) + class AlignDataWorker(QObject): sigWarnTifAligned = Signal(object, object, object) sigAskAlignSegmData = Signal() - + def __init__(self, posData, dataPrepWin, mutex, waitCond): QObject.__init__(self) self.signals = signals() @@ -483,75 +516,75 @@ def __init__(self, posData, dataPrepWin, mutex, waitCond): self.waitCond = waitCond self.doNotAlignSegmData = False self.doAbort = False - + def set_attr(self, align, user_ch_name): self.align = align self.user_ch_name = user_ch_name - + def pause(self): self.mutex.lock() self.waitCond.wait(self.mutex) self.mutex.unlock() - + def restart(self): self.waitCond.wakeAll() - + def emitWarnTifAligned(self, numFramesWith0s, tif, posData): self.sigWarnTifAligned.emit(numFramesWith0s, tif, posData) self.pause() - + def emitSigAskAlignSegmData(self): self.sigAskAlignSegmData.emit() self.pause() - + def _align_data(self): _zip = zip(self.posData.tif_paths, self.posData.npz_paths) aligned = False self.posData.all_npz_paths = [ - tif.replace('.tif', '_aligned.npz') for tif in self.posData.tif_paths + tif.replace(".tif", "_aligned.npz") for tif in self.posData.tif_paths ] for i, (tif, npz) in enumerate(_zip): doAlign = npz is None or self.posData.loaded_shifts is None filename_tif = os.path.basename(tif) - user_ch_filename = f'{self.posData.basename}{self.user_ch_name}.tif' + user_ch_filename = f"{self.posData.basename}{self.user_ch_name}.tif" if not doAlign: - _npz = f'{os.path.splitext(tif)[0]}_aligned.npz' + _npz = f"{os.path.splitext(tif)[0]}_aligned.npz" if os.path.exists(_npz): self.posData.all_npz_paths[i] = _npz continue - + if filename_tif != user_ch_filename: continue - + if not self.align: continue # Align based on user_ch_name aligned = True - self.logger.log(f'Aligning: {tif}') + self.logger.log(f"Aligning: {tif}") tif_data = load.imread(tif) numFramesWith0s = self.dataPrepWin.detectTifAlignment( tif_data, self.posData ) if self.align: - self.emitWarnTifAligned( - numFramesWith0s, tif, self.posData - ) + self.emitWarnTifAligned(numFramesWith0s, tif, self.posData) if self.doAbort: return # Alignment routine - if self.posData.SizeZ>1: + if self.posData.SizeZ > 1: align_func = core.align_frames_3D df = self.posData.segmInfo_df.loc[self.posData.filename] - zz = df['z_slice_used_dataPrep'].to_list() - if not self.posData.filename.endswith('aligned') and self.align: + zz = df["z_slice_used_dataPrep"].to_list() + if not self.posData.filename.endswith("aligned") and self.align: # Add aligned channel to segmInfo df_aligned = self.posData.segmInfo_df.rename( - index={self.posData.filename: f'{self.posData.filename}_aligned'} + index={ + self.posData.filename: f"{self.posData.filename}_aligned" + } ) self.posData.segmInfo_df = pd.concat( [self.posData.segmInfo_df, df_aligned] @@ -560,30 +593,30 @@ def _align_data(self): else: align_func = core.align_frames_2D zz = None - + if self.align: self.signals.initProgressBar.emit(len(tif_data)) aligned_frames, shifts = align_func( - tif_data, slices=zz, user_shifts=self.posData.loaded_shifts, - sigPyqt=self.signals.progressBar + tif_data, + slices=zz, + user_shifts=self.posData.loaded_shifts, + sigPyqt=self.signals.progressBar, ) self.posData.loaded_shifts = shifts else: aligned_frames = tif_data - + if self.align: self.signals.initProgressBar.emit(0) - _npz = f'{os.path.splitext(tif)[0]}_aligned.npz' - self.logger.log(f'Storing temporary file: {_npz}') + _npz = f"{os.path.splitext(tif)[0]}_aligned.npz" + self.logger.log(f"Storing temporary file: {_npz}") temp_npz = self.dataPrepWin.getTempfilePath(_npz) io.savez_compressed(temp_npz, aligned_frames) self.dataPrepWin.storeTempFileMove(temp_npz, _npz) - np.save( - self.posData.align_shifts_path, self.posData.loaded_shifts - ) + np.save(self.posData.align_shifts_path, self.posData.loaded_shifts) self.posData.all_npz_paths[i] = _npz - self.logger.log(f'Storing temporary file: {tif}') + self.logger.log(f"Storing temporary file: {tif}") temp_tif = self.dataPrepWin.getTempfilePath(tif) myutils.to_tiff(temp_tif, aligned_frames) self.dataPrepWin.storeTempFileMove(temp_tif, tif) @@ -595,10 +628,10 @@ def _align_data(self): if not doAlign: continue - - if tif.endswith(f'{self.user_ch_name}.tif'): + + if tif.endswith(f"{self.user_ch_name}.tif"): continue - + if not self.align: continue @@ -607,72 +640,74 @@ def _align_data(self): break if self.align: - self.logger.log(f'Aligning: {tif}') + self.logger.log(f"Aligning: {tif}") tif_data = load.imread(tif) # Alignment routine - if self.posData.SizeZ>1: + if self.posData.SizeZ > 1: align_func = core.align_frames_3D df = self.posData.segmInfo_df.loc[self.posData.filename] - zz = df['z_slice_used_dataPrep'].to_list() + zz = df["z_slice_used_dataPrep"].to_list() else: align_func = core.align_frames_2D zz = None if self.align: self.signals.initProgressBar.emit(len(tif_data)) aligned_frames, shifts = align_func( - tif_data, slices=zz, user_shifts=self.posData.loaded_shifts, - sigPyqt=self.signals.progressBar + tif_data, + slices=zz, + user_shifts=self.posData.loaded_shifts, + sigPyqt=self.signals.progressBar, ) else: aligned_frames = tif_data - - _npz = f'{os.path.splitext(tif)[0]}_aligned.npz' - + + _npz = f"{os.path.splitext(tif)[0]}_aligned.npz" + if self.align: self.signals.initProgressBar.emit(0) - self.logger.log(f'Saving: {_npz}') + self.logger.log(f"Saving: {_npz}") temp_npz = self.dataPrepWin.getTempfilePath(_npz) io.savez_compressed(temp_npz, aligned_frames) self.dataPrepWin.storeTempFileMove(temp_npz, _npz) self.posData.all_npz_paths[i] = _npz - self.logger.log(f'Saving: {tif}') + self.logger.log(f"Saving: {tif}") temp_tif = self.dataPrepWin.getTempfilePath(tif) myutils.to_tiff(temp_tif, aligned_frames) self.dataPrepWin.storeTempFileMove(temp_tif, tif) if not aligned: return - + if not self.posData.segmFound: return - + # Align segmentation data accordingly self.segmAligned = False if self.posData.loaded_shifts is None or not self.align: return - + self.emitSigAskAlignSegmData() if self.doNotAlignSegmData: return - + self.dataPrepWin.segmAligned = True - self.logger.log(f'Aligning: {self.posData.segm_npz_path}') + self.logger.log(f"Aligning: {self.posData.segm_npz_path}") self.posData.segm_data, shifts = core.align_frames_2D( - self.posData.segm_data, slices=None, - user_shifts=self.posData.loaded_shifts + self.posData.segm_data, slices=None, user_shifts=self.posData.loaded_shifts ) - self.logger.log(f'Saving: {self.posData.segm_npz_path}') + self.logger.log(f"Saving: {self.posData.segm_npz_path}") temp_npz = self.dataPrepWin.getTempfilePath(self.posData.segm_npz_path) io.savez_compressed(temp_npz, self.posData.segm_data) self.dataPrepWin.storeTempFileMove(temp_npz, self.posData.segm_npz_path) @worker_exception_handler - def run(self): - self._align_data() + def run(self): + self._align_data() self.signals.finished.emit(self) + class LabelRoiWorker(QObject): finished = Signal() critical = Signal(object) @@ -688,62 +723,64 @@ def __init__(self, Gui): self.waitCond = Gui.labelRoiWaitCond self.exit = False self.started = False - + def pause(self): - self.logger.log('Draw box around object to start magic labeller.') + self.logger.log("Draw box around object to start magic labeller.") self.mutex.lock() self.waitCond.wait(self.mutex) self.mutex.unlock() - + def start(self, roiImg, posData, roiSecondChannel=None, isTimelapse=False): self.posData = posData self.isTimelapse = isTimelapse self.imageData = roiImg self.roiSecondChannel = roiSecondChannel self.restart() - + def restart(self, log=True): if log: - self.logger.log('Magic labeller started...') + self.logger.log("Magic labeller started...") self.started = True self.waitCond.wakeAll() - + def _stop(self): - self.logger.log('Magic labeller backend process done. Closing it...') + self.logger.log("Magic labeller backend process done. Closing it...") self.exit = True self.waitCond.wakeAll() - + def _segment_image(self, img, secondChannelImg): if secondChannelImg is not None: - img = self.Gui.labelRoiModel.second_ch_img_to_stack( - img, secondChannelImg - ) - + img = self.Gui.labelRoiModel.second_ch_img_to_stack(img, secondChannelImg) + lab = core.segm_model_segment( - self.Gui.labelRoiModel, img, self.Gui.model_kwargs, - preproc_recipe=self.Gui.preproc_recipe, - posData=self.posData + self.Gui.labelRoiModel, + img, + self.Gui.model_kwargs, + preproc_recipe=self.Gui.preproc_recipe, + posData=self.posData, ) if self.Gui.applyPostProcessing: - lab = core.post_process_segm( - lab, **self.Gui.standardPostProcessKwargs - ) + lab = core.post_process_segm(lab, **self.Gui.standardPostProcessKwargs) if self.Gui.customPostProcessFeatures: lab = features.custom_post_process_segm( - self.posData, self.Gui.customPostProcessGroupedFeatures, - lab, img, self.posData.frame_i, self.posData.filename, - self.posData.user_ch_name, - self.Gui.customPostProcessFeatures + self.posData, + self.Gui.customPostProcessGroupedFeatures, + lab, + img, + self.posData.frame_i, + self.posData.filename, + self.posData.user_ch_name, + self.Gui.customPostProcessFeatures, ) return lab - + @worker_exception_handler def run(self): while not self.exit: if self.exit: break elif self.started: - self.logger.log('Magic labeller is doing its magic...') + self.logger.log("Magic labeller is doing its magic...") if self.isTimelapse: segmData = np.zeros(self.imageData.shape, dtype=np.uint32) for frame_i, img in enumerate(self.imageData): @@ -758,12 +795,13 @@ def run(self): img = self.imageData secondChannelImg = self.roiSecondChannel segmData = self._segment_image(img, secondChannelImg) - + self.sigLabellingDone.emit(segmData, self.isTimelapse) self.started = False self.pause() self.finished.emit() + class StoreGuiStateWorker(QObject): finished = Signal(object) sigDone = Signal() @@ -777,24 +815,24 @@ def __init__(self, mutex, waitCond): self.isFinished = False self.q = queue.Queue() self.logger = workerLogger(self.progress) - + def pause(self): self.mutex.lock() self.waitCond.wait(self.mutex) self.mutex.unlock() - + def enqueue(self, posData, img1): self.q.put((posData, img1)) self.waitCond.wakeAll() - + def _stop(self): self.exit = True self.waitCond.wakeAll() - + def run(self): while True: if self.exit: - self.logger.log('Closing store state worker...') + self.logger.log("Closing store state worker...") break elif not self.q.empty(): posData, img1 = self.q.get() @@ -805,12 +843,12 @@ def run(self): cca_df = None state = { - 'image': img1.copy(), - 'labels': posData.storedLab.copy(), - 'editID_info': posData.editID_info.copy(), - 'binnedIDs': posData.binnedIDs.copy(), - 'ripIDs': posData.ripIDs.copy(), - 'cca_df': cca_df + "image": img1.copy(), + "labels": posData.storedLab.copy(), + "editID_info": posData.editID_info.copy(), + "binnedIDs": posData.binnedIDs.copy(), + "ripIDs": posData.ripIDs.copy(), + "cca_df": cca_df, } posData.UndoRedoStates[posData.frame_i].insert(0, state) if self.q.empty(): @@ -818,10 +856,11 @@ def run(self): self.sigDone.emit() else: self.pause() - + self.isFinished = True self.finished.emit(self) + class AutoSaveWorker(QObject): finished = Signal(object) sigDone = Signal() @@ -846,65 +885,64 @@ def __init__(self, mutex, waitCond, savedSegmData): self.isAutoSaveON = False self.isAutoSaveAnnotON = True self.debug = False - + def pause(self): if self.debug: - self.logger.log('Autosaving is idle.') + self.logger.log("Autosaving is idle.") self.mutex.lock() self.isPaused = True self.waitCond.wait(self.mutex) self.mutex.unlock() self.isPaused = False - + def enqueue(self, posData): # First stop previously saving data if self.isSaving: self.stopSaving = True self._enqueue(posData) - + def _enqueue(self, posData): if self.debug: - self.logger.log('Enqueing posData autosave...') + self.logger.log("Enqueing posData autosave...") self.dataQ.append(posData) if len(self.dataQ) == 1: # Wake up worker upon inserting first element self.stopSaving = False self.waitCond.wakeAll() - + def _stop(self): self.exit = True self.waitCond.wakeAll() - + def stop(self): self.stopSaving = True while not len(self.dataQ) == 0: data = self.dataQ.pop() del data self._stop() - - def cancelSaving(self): - ... - + + def cancelSaving(self): ... + @worker_exception_handler def run(self): while True: if self.exit: - self.logger.log('Closing autosaving worker...') + self.logger.log("Closing autosaving worker...") break elif not len(self.dataQ) == 0: if self.debug: - self.logger.log('Autosaving...') + self.logger.log("Autosaving...") data = self.dataQ.pop() self.isSaving = True try: self.saveData(data) except Exception as e: error = traceback.format_exc() - print('*'*40) + print("*" * 40) self.logger.log(error) - print('='*40) + print("=" * 40) self.isSaving = False - + if len(self.dataQ) == 0: self.sigDone.emit() else: @@ -912,12 +950,12 @@ def run(self): self.isFinished = True self.finished.emit(self) if self.debug: - self.logger.log('Autosave finished signal emitted') - + self.logger.log("Autosave finished signal emitted") + def getLastTrackedFrame(self, posData): last_tracked_i = 0 for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None: frame_i -= 1 break @@ -925,48 +963,48 @@ def getLastTrackedFrame(self, posData): return frame_i else: return last_tracked_i - + def saveData(self, posData): if self.debug: - self.logger.log('Started autosaving...') - + self.logger.log("Started autosaving...") + if not self.isAutoSaveON and not self.isAutoSaveAnnotON: return - + try: posData.setTempPaths() except Exception as e: self.logger.log( - '[WARNING]: Cell-ACDC cannot create the recovery folder for ' - 'the autosaving process. Autosaving will be turned off.' + "[WARNING]: Cell-ACDC cannot create the recovery folder for " + "the autosaving process. Autosaving will be turned off." ) self.sigAutoSaveCannotProceed.emit() return segm_npz_path = posData.segm_npz_temp_path end_i = self.getLastTrackedFrame(posData) - + saved_segm_data = None if self.isAutoSaveON: if end_i < len(posData.segm_data): saved_segm_data = posData.segm_data else: frame_shape = posData.segm_data.shape[1:] - segm_shape = (end_i+1, *frame_shape) + segm_shape = (end_i + 1, *frame_shape) saved_segm_data = np.zeros(segm_shape, dtype=np.uint32) - + keys = [] acdc_df_li = [] - - for frame_i, data_dict in enumerate(posData.allData_li[:end_i+1]): + + for frame_i, data_dict in enumerate(posData.allData_li[: end_i + 1]): if self.stopSaving: break - + # Build saved_segm_data - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None: break - + if self.isAutoSaveON and saved_segm_data is not None: if posData.SizeT > 1: saved_segm_data[frame_i] = lab @@ -974,55 +1012,54 @@ def saveData(self, posData): saved_segm_data = lab if self.isAutoSaveAnnotON: - acdc_df = data_dict['acdc_df'] - + acdc_df = data_dict["acdc_df"] + if acdc_df is None: continue if not np.any(lab): continue - + if self.isAutoSaveAnnotON: acdc_df = load.pd_bool_and_float_to_int_to_str( acdc_df, inplace=False, colsToCastInt=[] ) - + acdc_df_li.append(acdc_df) - key = (frame_i, posData.TimeIncrement*frame_i) + key = (frame_i, posData.TimeIncrement * frame_i) keys.append(key) if self.stopSaving: break - - if not self.stopSaving: + + if not self.stopSaving: if self.isAutoSaveON: segm_data = np.squeeze(saved_segm_data) self._saveSegm(segm_npz_path, segm_data) - + if acdc_df_li: all_frames_acdc_df = pd.concat( - acdc_df_li, keys=keys, - names=['frame_i', 'time_seconds', 'Cell_ID'] + acdc_df_li, keys=keys, names=["frame_i", "time_seconds", "Cell_ID"] ) self._save_acdc_df(all_frames_acdc_df, posData) if self.debug: - self.logger.log(f'Autosaving done.') - self.logger.log(f'Stopped autosaving {self.stopSaving}.') + self.logger.log(f"Autosaving done.") + self.logger.log(f"Stopped autosaving {self.stopSaving}.") self.stopSaving = False - + def _saveSegm(self, recovery_path, data): try: equalToSavedSegm = np.all(self.savedSegmData == data) except Exception as err: return - + if equalToSavedSegm: return else: io.savez_compressed(recovery_path, np.squeeze(data)) - + def _save_acdc_df(self, recovery_acdc_df: pd.DataFrame, posData): recovery_folderpath = posData.recoveryFolderpath() if not os.path.exists(posData.acdc_output_csv_path): @@ -1030,15 +1067,13 @@ def _save_acdc_df(self, recovery_acdc_df: pd.DataFrame, posData): return saved_acdc_df_path = posData.acdc_output_csv_path - saved_acdc_df = ( - pd.read_csv(saved_acdc_df_path, dtype=load.acdc_df_str_cols) - .set_index(['frame_i', 'Cell_ID']) - ) - - recovery_acdc_df = ( - recovery_acdc_df.reset_index(allow_duplicates=True) - .set_index(['frame_i', 'Cell_ID']) - ) + saved_acdc_df = pd.read_csv( + saved_acdc_df_path, dtype=load.acdc_df_str_cols + ).set_index(["frame_i", "Cell_ID"]) + + recovery_acdc_df = recovery_acdc_df.reset_index( + allow_duplicates=True + ).set_index(["frame_i", "Cell_ID"]) recovery_acdc_df = recovery_acdc_df.loc[ :, ~recovery_acdc_df.columns.duplicated() ] @@ -1048,10 +1083,10 @@ def _save_acdc_df(self, recovery_acdc_df: pd.DataFrame, posData): df_left = recovery_acdc_df existing_cols = df_left.columns.intersection(saved_acdc_df.columns) df_right = saved_acdc_df.drop(columns=existing_cols) - recovery_acdc_df = df_left.join(df_right, how='left') + recovery_acdc_df = df_left.join(df_right, how="left") except Exception as error: - self.logger.log(f'[WARNING]: {error}') - + self.logger.log(f"[WARNING]: {error}") + # Check if last saved acdc_df is equal last_unsaved_csv_path = load.get_last_stored_unsaved_acdc_df_filepath( recovery_folderpath @@ -1060,30 +1095,31 @@ def _save_acdc_df(self, recovery_acdc_df: pd.DataFrame, posData): reference_acdc_df = saved_acdc_df else: try: - reference_acdc_df = ( - pd.read_csv(last_unsaved_csv_path, dtype=load.acdc_df_str_cols) - .set_index(['frame_i', 'Cell_ID']) - ) + reference_acdc_df = pd.read_csv( + last_unsaved_csv_path, dtype=load.acdc_df_str_cols + ).set_index(["frame_i", "Cell_ID"]) except Exception as e: - self.logger.log(f'[WARNING]: {e}') + self.logger.log(f"[WARNING]: {e}") reference_acdc_df = saved_acdc_df - + if myutils.are_acdc_dfs_equal(recovery_acdc_df, reference_acdc_df): return - + load.store_unsaved_acdc_df(recovery_folderpath, recovery_acdc_df) + class segmWorker(QObject): finished = Signal(np.ndarray, float) debug = Signal(object) critical = Signal(object) def __init__( - self, mainWin, - secondChannelData=None, - mutex: QWaitCondition=None, - waitCond: QMutex=None - ): + self, + mainWin, + secondChannelData=None, + mutex: QWaitCondition = None, + waitCond: QMutex = None, + ): QObject.__init__(self) self.mainWin = mainWin self.logger = self.mainWin.logger @@ -1095,12 +1131,12 @@ def __init__( def emitDebug(self, to_debug): if self.mutex is None: return - + self.mutex.lock() self.debug.emit(to_debug) self.waitCond.wait(self.mutex) self.mutex.unlock() - + @worker_exception_handler def run(self): t0 = time.perf_counter() @@ -1108,33 +1144,32 @@ def run(self): img = self.mainWin.getDisplayedZstack() if self.z_range is not None: startZ, stopZ = self.z_range - img = img[startZ:stopZ+1] + img = img[startZ : stopZ + 1] else: img = self.mainWin.getDisplayedImg1() - + posData = self.mainWin.data[self.mainWin.pos_i] lab = np.zeros_like(posData.segm_data[0]) - + # self.emitDebug((img, self.secondChannelData)) - + if self.secondChannelData is not None: - img = self.mainWin.model.second_ch_img_to_stack( - img, self.secondChannelData - ) + img = self.mainWin.model.second_ch_img_to_stack(img, self.secondChannelData) start_z_slice = 0 if self.z_range is not None: start_z_slice, _ = self.z_range elif not self.mainWin.segment3D and posData.isSegm3D: idx = (posData.filename, posData.frame_i) - start_z_slice = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] - + start_z_slice = posData.segmInfo_df.at[idx, "z_slice_used_gui"] + _lab = core.segm_model_segment( - self.mainWin.model, img, - self.mainWin.model_kwargs, - frame_i=posData.frame_i, - posData=posData, - start_z_slice=start_z_slice + self.mainWin.model, + img, + self.mainWin.model_kwargs, + frame_i=posData.frame_i, + posData=posData, + start_z_slice=start_z_slice, ) posData.saveSamEmbeddings(logger_func=self.logger.info) @@ -1144,28 +1179,34 @@ def run(self): ) if self.mainWin.customPostProcessFeatures: _lab = features.custom_post_process_segm( - posData, self.mainWin.customPostProcessGroupedFeatures, - _lab, img, posData.frame_i, posData.filename, - posData.user_ch_name, self.mainWin.customPostProcessFeatures + posData, + self.mainWin.customPostProcessGroupedFeatures, + _lab, + img, + posData.frame_i, + posData.filename, + posData.user_ch_name, + self.mainWin.customPostProcessFeatures, ) - + if self.z_range is not None: # 3D segmentation of a z-slices subset startZ, stopZ = self.z_range - lab[startZ:stopZ+1] = _lab + lab[startZ : stopZ + 1] = _lab elif not self.mainWin.segment3D and posData.isSegm3D: # 3D segmentation but segmented current z-slice idx = (posData.filename, posData.frame_i) - z = posData.segmInfo_df.at[idx, 'z_slice_used_gui'] + z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] lab[z] = _lab else: # Either whole z-stack or 2D segmentation lab = _lab - + t1 = time.perf_counter() - exec_time = t1-t0 + exec_time = t1 - t0 self.finished.emit(lab, exec_time) + class segmVideoWorker(QObject): finished = Signal(float) debug = Signal(object) @@ -1195,21 +1236,23 @@ def _check_extend_segm_data(self, segm_data, stop_frame_num): return segm_data extended_shape = (stop_frame_num, *segm_data.shape[1:]) extended_segm_data = np.zeros(extended_shape, dtype=segm_data.dtype) - extended_segm_data[:len(segm_data)] = segm_data + extended_segm_data[: len(segm_data)] = segm_data if len(extended_shape) == 4: return extended_segm_data if self.posData.SizeZ == 1: return extended_segm_data else: num_added_frames = len(extended_segm_data) - len(segm_data) - half_z = int(self.posData.SizeZ/2) + half_z = int(self.posData.SizeZ / 2) # 2D segm on 3D over time data --> fix segmInfo - segmInfo_extended = pd.DataFrame({ - 'filename': [self.posData.filename]*num_added_frames, - 'frame_i': list(range(len(segm_data), len(extended_segm_data))), - 'z_slice_used_gui': [half_z]*num_added_frames, - 'which_z_proj_gui': ['single z-slice']*num_added_frames - }).set_index(['filename', 'frame_i']) + segmInfo_extended = pd.DataFrame( + { + "filename": [self.posData.filename] * num_added_frames, + "frame_i": list(range(len(segm_data), len(extended_segm_data))), + "z_slice_used_gui": [half_z] * num_added_frames, + "which_z_proj_gui": ["single z-slice"] * num_added_frames, + } + ).set_index(["filename", "frame_i"]) segmInfo_df = pd.concat([self.posData.segmInfo_df, segmInfo_extended]) self.posData.segmInfo_df = segmInfo_df self.posData.segmInfo_df.to_csv(self.posData.segmInfo_df_csv_path) @@ -1221,49 +1264,51 @@ def run(self): self.posData.segm_data = self._check_extend_segm_data( self.posData.segm_data, self.stopFrameNum ) - img_data = self.posData.img_data[self.startFrameNum-1:self.stopFrameNum] + img_data = self.posData.img_data[self.startFrameNum - 1 : self.stopFrameNum] is4D = img_data.ndim == 4 is2D_segm = self.posData.segm_data.ndim == 3 if is4D and is2D_segm: filename = self.posData.filename - zz = self.posData.segmInfo_df.loc[filename, 'z_slice_used_gui'] + zz = self.posData.segmInfo_df.loc[filename, "z_slice_used_gui"] else: zz = None for i, img in enumerate(img_data): - frame_i = i+self.startFrameNum-1 + frame_i = i + self.startFrameNum - 1 if self.secondChannelData is not None: - img = self.model.second_ch_img_to_stack( - img, self.secondChannelData - ) + img = self.model.second_ch_img_to_stack(img, self.secondChannelData) if zz is not None: z_slice = zz.loc[frame_i] img = img[z_slice] - + lab = core.segm_model_segment( - self.model, img, self.model_kwargs, frame_i=frame_i, - preproc_recipe=self.preproc_recipe, - posData=self.posData + self.model, + img, + self.model_kwargs, + frame_i=frame_i, + preproc_recipe=self.preproc_recipe, + posData=self.posData, ) self.posData.saveSamEmbeddings(logger_func=self.logger.log) if self.applyPostProcessing: - lab = core.post_process_segm( - lab, **self.standardPostProcessKwargs - ) + lab = core.post_process_segm(lab, **self.standardPostProcessKwargs) if self.customPostProcessFeatures: lab = features.custom_post_process_segm( - self.posData, - self.customPostProcessGroupedFeatures, - lab, img, self.posData.frame_i, - self.posData.filename, - self.posData.user_ch_name, - self.customPostProcessFeatures + self.posData, + self.customPostProcessGroupedFeatures, + lab, + img, + self.posData.frame_i, + self.posData.filename, + self.posData.user_ch_name, + self.customPostProcessFeatures, ) self.posData.segm_data[frame_i] = lab self.progressBar.emit(1) t1 = time.perf_counter() - exec_time = t1-t0 + exec_time = t1 - t0 self.finished.emit(exec_time) + class ComputeMetricsWorker(QObject): progressBar = Signal(int, int, float) @@ -1289,7 +1334,7 @@ def emitSelectSegmFiles(self, exp_path, pos_foldernames): @worker_exception_handler def run(self): - np.seterr(invalid='ignore') + np.seterr(invalid="ignore") debugging = False expPaths = self.mainWin.expPaths tot_exp = len(expPaths) @@ -1301,7 +1346,7 @@ def run(self): tot_pos = len(pos_foldernames) self.allPosDataInputs = [] posDatas = [] - self.logger.log('-'*30) + self.logger.log("-" * 30) expFoldername = os.path.basename(exp_path) if i == 0: @@ -1316,17 +1361,17 @@ def run(self): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") basename, chNames = myutils.getBasenameAndChNames( - images_path, useExt=('.tif', '.h5') + images_path, useExt=(".tif", ".h5") ) - self.signals.sigUpdatePbarDesc.emit(f'Loading {pos_path}...') + self.signals.sigUpdatePbarDesc.emit(f"Loading {pos_path}...") # Use first found channel, it doesn't matter for metrics chName = chNames[0] @@ -1334,7 +1379,7 @@ def run(self): # Load data posData = load.loadData(file_path, chName) - posData.getBasenameAndChNames(useExt=('.tif', '.h5')) + posData.getBasenameAndChNames(useExt=(".tif", ".h5")) posData.buildPaths() posData.loadOtherFiles( @@ -1342,17 +1387,19 @@ def run(self): load_acdc_df=True, load_metadata=True, loadSegmInfo=True, - load_customCombineMetrics=True + load_customCombineMetrics=True, ) posDatas.append(posData) - self.allPosDataInputs.append({ - 'file_path': file_path, - 'chName': chName, - 'combineMetricsConfig': posData.combineMetricsConfig, - 'combineMetricsPath': posData.custom_combine_metrics_path - }) + self.allPosDataInputs.append( + { + "file_path": file_path, + "chName": chName, + "combineMetricsConfig": posData.combineMetricsConfig, + "combineMetricsPath": posData.custom_combine_metrics_path, + } + ) if any([posData.SizeT > 1 for posData in posDatas]): self.mutex.lock() @@ -1363,30 +1410,28 @@ def run(self): self.signals.finished.emit(self) return for p, posData in enumerate(posDatas): - self.allPosDataInputs[p]['stopFrameNum'] = ( - posData.stopFrameNum - ) + self.allPosDataInputs[p]["stopFrameNum"] = posData.stopFrameNum else: for p, posData in enumerate(posDatas): - self.allPosDataInputs[p]['stopFrameNum'] = 1 - + self.allPosDataInputs[p]["stopFrameNum"] = 1 + self.kernel = cli.ComputeMeasurementsKernel( - self.logger, - self.mainWin.log_path, + self.logger, + self.mainWin.log_path, False, ) - + # Iterate pos and calculate metrics numPos = len(self.allPosDataInputs) for p, posDataInputs in enumerate(self.allPosDataInputs): - self.logger.log('='*40) - file_path = posDataInputs['file_path'] - chName = posDataInputs['chName'] - stopFrameNum = posDataInputs['stopFrameNum'] - + self.logger.log("=" * 40) + file_path = posDataInputs["file_path"] + chName = posDataInputs["chName"] + stopFrameNum = posDataInputs["stopFrameNum"] + self.kernel.run( - img_path=file_path, - stop_frame_n=stopFrameNum, + img_path=file_path, + stop_frame_n=stopFrameNum, end_filename_segm=self.mainWin.endFilenameSegm, computeMetricsWorker=self, do_init_metrics=p == 0, @@ -1394,44 +1439,38 @@ def run(self): if self.kernel.setup_done: return - + if self.abort: self.signals.finished.emit(self) return - self.logger.log('*'*30) + self.logger.log("*" * 30) self.mutex.lock() self.signals.sigErrorsReport.emit( - self.standardMetricsErrors, + self.standardMetricsErrors, self.customMetricsErrors, - self.regionPropsErrors + self.regionPropsErrors, ) self.waitCond.wait(self.mutex) self.mutex.unlock() self.signals.finished.emit(self) - + def emitSigComputeVolume(self, posData, stop_frame_n): # Recreate allData_li attribute of the gui posData.allData_li = [] for frame_i, lab in enumerate(posData.segm_data[:stop_frame_n]): - data_dict = { - 'labels': lab, - 'regionprops': skimage.measure.regionprops(lab) - } + data_dict = {"labels": lab, "regionprops": skimage.measure.regionprops(lab)} posData.allData_li.append(data_dict) self.mutex.lock() - self.signals.sigComputeVolume.emit( - stop_frame_n, posData - ) + self.signals.sigComputeVolume.emit(stop_frame_n, posData) self.waitCond.wait(self.mutex) self.mutex.unlock() def emitSigPermissionErrorAndSave( - self, posData, traceback_str, all_frames_acdc_df, - custom_annot_columns - ): + self, posData, traceback_str, all_frames_acdc_df, custom_annot_columns + ): self.mutex.lock() self.signals.sigPermissionError.emit( traceback_str, posData.acdc_output_csv_path @@ -1439,28 +1478,27 @@ def emitSigPermissionErrorAndSave( self.waitCond.wait(self.mutex) self.mutex.unlock() load.save_acdc_df_file( - all_frames_acdc_df, posData.acdc_output_csv_path, - custom_annot_columns=custom_annot_columns + all_frames_acdc_df, + posData.acdc_output_csv_path, + custom_annot_columns=custom_annot_columns, ) - + def emitSigInitMetricsDialog(self, posData): self.mainWin.gui.data = [posData] self.mainWin.gui.pos_i = 0 self.mainWin.gui.isSegm3D = posData.getIsSegm3D() self.mutex.lock() - self.signals.sigInitAddMetrics.emit( - posData, self.allPosDataInputs - ) + self.signals.sigInitAddMetrics.emit(posData, self.allPosDataInputs) self.waitCond.wait(self.mutex) self.mutex.unlock() - + def emitSigAskRunNow(self): self.mutex.lock() self.signals.sigAskRunNow.emit(self) self.waitCond.wait(self.mutex) self.mutex.unlock() - - + + class loadDataWorker(QObject): def __init__(self, mainWin, user_ch_file_paths, user_ch_name, firstPosData): QObject.__init__(self) @@ -1486,15 +1524,14 @@ def checkSelectedDataShape(self, posData, numPos): skipPos = False abort = False emitWarning = ( - not posData.segmFound and posData.SizeT > 1 - and not self.mainWin.isNewFile + not posData.segmFound and posData.SizeT > 1 and not self.mainWin.isNewFile ) if emitWarning: self.signals.dataIntegrityWarning.emit(posData.pos_foldername) self.pause() abort = self.abort return skipPos, abort - + def warnMismatchSegmDataShape(self, posData): self.skipPos = False self.mutex.lock() @@ -1519,7 +1556,7 @@ def run(self): posData = load.loadData(file_path, user_ch_name) loadSegm = True - self.logger.log(f'Loading {posData.relPath}...') + self.logger.log(f"Loading {posData.relPath}...") posData.loadSizeS = self.mainWin.loadSizeS posData.loadSizeT = self.mainWin.loadSizeT @@ -1563,10 +1600,10 @@ def run(self): posData.segmFound = segmFound posData.addYXcentroidColsIfMissing(show_progress=True) - + isPosSegm3D = posData.getIsSegm3D() isMismatch = ( - isPosSegm3D != self.mainWin.isSegm3D + isPosSegm3D != self.mainWin.isSegm3D and isPosSegm3D is not None and not self.mainWin.isNewFile ) @@ -1575,17 +1612,17 @@ def run(self): if skipPos: self.logger.log( f'Skipping "{posData.relPath}" because segmentation ' - 'data shape different from first Position loaded.' + "data shape different from first Position loaded." ) continue else: - data = 'abort' + data = "abort" break self.logger.log( - 'Loaded paths:\n' - f'Segmentation file name: {os.path.basename(posData.segm_npz_path)}\n' - f'ACDC output file name {os.path.basename(posData.acdc_output_csv_path)}' + "Loaded paths:\n" + f"Segmentation file name: {os.path.basename(posData.segm_npz_path)}\n" + f"ACDC output file name {os.path.basename(posData.acdc_output_csv_path)}" ) posData.SizeT = self.mainWin.SizeT @@ -1600,10 +1637,12 @@ def run(self): posData.PhysicalSizeX = self.mainWin.PhysicalSizeX posData.isSegm3D = self.mainWin.isSegm3D posData.saveMetadata( - signals=self.signals, mutex=self.mutex, waitCond=self.waitCond, - additionalMetadata=self.firstPosData._additionalMetadataValues + signals=self.signals, + mutex=self.mutex, + waitCond=self.waitCond, + additionalMetadata=self.firstPosData._additionalMetadataValues, ) - if hasattr(posData, 'img_data_shape'): + if hasattr(posData, "img_data_shape"): SizeY, SizeX = posData.img_data_shape[-2:] if posData.SizeZ > 1 and posData.img_data.ndim < 3: @@ -1614,9 +1653,7 @@ def run(self): except FileNotFoundError: pass - posData.setBlankSegmData( - posData.SizeT, posData.SizeZ, SizeY, SizeX - ) + posData.setBlankSegmData(posData.SizeT, posData.SizeZ, SizeY, SizeX) if not self.firstPosData.onlyEditMetadata: skipPos, abort = self.checkSelectedDataShape(posData, numPos) else: @@ -1625,9 +1662,9 @@ def run(self): if skipPos: continue elif abort: - data = 'abort' + data = "abort" break - + posData.setTempPaths(createFolder=False) isRecoveredDataPresent = ( os.path.exists(posData.segm_npz_temp_path) @@ -1642,43 +1679,43 @@ def run(self): self.mutex.unlock() self.recoveryAsked = True if self.abort: - data = 'abort' + data = "abort" break if self.loadUnsaved: - self.logger.log('Loading unsaved data...') + self.logger.log("Loading unsaved data...") if os.path.exists(posData.segm_npz_temp_path): segm_npz_path = posData.segm_npz_temp_path - posData.segm_data = np.load(segm_npz_path)['arr_0'] + posData.segm_data = np.load(segm_npz_path)["arr_0"] segm_filename = os.path.basename(segm_npz_path) posData.segm_npz_path = os.path.join( posData.images_path, segm_filename ) - + posData.loadMostRecentUnsavedAcdcDf() elif self.loadSafeOverwriteNpz: - self.logger.log('Loading safe npz overwrite...') + self.logger.log("Loading safe npz overwrite...") segm_safe_npz_path = posData.getSafeNpzOverwritePath() - posData.segm_data = np.load(segm_safe_npz_path)['arr_0'] + posData.segm_data = np.load(segm_safe_npz_path)["arr_0"] # Allow single 2D/3D image if posData.SizeT == 1: posData.img_data = posData.img_data[np.newaxis] posData.segm_data = posData.segm_data[np.newaxis] - if hasattr(posData, 'img_data_shape'): + if hasattr(posData, "img_data_shape"): img_shape = posData.img_data_shape - img_shape = 'Not Loaded' - if hasattr(posData, 'img_data_shape'): + img_shape = "Not Loaded" + if hasattr(posData, "img_data_shape"): datasetShape = posData.img_data.shape else: - datasetShape = 'Not Loaded' + datasetShape = "Not Loaded" if posData.segm_data is not None: posData.segmSizeT = len(posData.segm_data) SizeT = posData.SizeT SizeZ = posData.SizeZ - self.logger.log(f'Full dataset shape = {img_shape}') - self.logger.log(f'Loaded dataset shape = {datasetShape}') - self.logger.log(f'Number of frames = {SizeT}') - self.logger.log(f'Number of z-slices per frame = {SizeZ}') + self.logger.log(f"Full dataset shape = {img_shape}") + self.logger.log(f"Loaded dataset shape = {datasetShape}") + self.logger.log(f"Number of frames = {SizeT}") + self.logger.log(f"Number of z-slices per frame = {SizeZ}") data.append(posData) self.signals.progressBar.emit(1) @@ -1688,6 +1725,7 @@ def run(self): self.signals.finished.emit(data) + class trackingWorker(QObject): finished = Signal() critical = Signal(object) @@ -1704,11 +1742,11 @@ def __init__(self, posData, mainWin, video_to_track): self.tracker = self.mainWin.tracker self.track_params = self.mainWin.track_params self.video_to_track = video_to_track - + def _get_first_untracked_lab(self): start_frame_i = self.mainWin.start_n - 1 frameData = self.posData.allData_li[start_frame_i] - lab = frameData['labels'] + lab = frameData["labels"] if lab is not None: return lab else: @@ -1721,15 +1759,15 @@ def _relabel_first_frame_labels(self, tracked_video): max_tracked_video = tracked_video.max() overall_max = max(max_allIDs, max_tracked_video) uniqueID = overall_max + 1 - + tracked_video = transformation.retrack_based_on_untracked_first_frame( - tracked_video, first_untracked_lab, uniqueID=uniqueID + tracked_video, first_untracked_lab, uniqueID=uniqueID ) return tracked_video def _setProgressBarIndefiniteWait(self): try: - if hasattr(self.signals, 'innerPbar_available'): + if hasattr(self.signals, "innerPbar_available"): if self.signals.innerPbar_available: # Use inner pbar of the GUI widget (top pbar is for positions) self.signals.sigInitInnerPbar.emit(1) @@ -1738,75 +1776,73 @@ def _setProgressBarIndefiniteWait(self): self.signals.initProgressBar.emit(1) except Exception as err: pass - + @worker_exception_handler def run(self): - self.mutex.lock() - self.progress.emit( - 'Tracking process started (more details in the terminal)...') - + self.mutex.lock() + self.progress.emit("Tracking process started (more details in the terminal)...") + trackerInputImage = None - self.track_params['signals'] = self.signals - if 'image' in self.track_params: - trackerInputImage = self.track_params.pop('image') - start_frame_i = self.mainWin.start_n-1 + self.track_params["signals"] = self.signals + if "image" in self.track_params: + trackerInputImage = self.track_params.pop("image") + start_frame_i = self.mainWin.start_n - 1 stop_frame_n = self.mainWin.stop_n trackerInputImage = trackerInputImage[start_frame_i:stop_frame_n] - + tracked_video = core.tracker_track( - self.video_to_track, self.tracker, self.track_params, + self.video_to_track, + self.tracker, + self.track_params, intensity_img=trackerInputImage, - logger_func=self.progress.emit + logger_func=self.progress.emit, ) - + self._setProgressBarIndefiniteWait() - + # self.debug.emit((tracked_video, self)) # self.waitCond.wait(self.mutex) - - self.progress.emit('Re-tracking first frame to ensure continuity...') + + self.progress.emit("Re-tracking first frame to ensure continuity...") # Relabel first frame objects back to IDs they had before tracking # (to ensure continuity with past untracked frames) tracked_video = self._relabel_first_frame_labels(tracked_video) - - print('') - self.progress.emit('Generating annotations...') + + print("") + self.progress.emit("Generating annotations...") acdc_df = self.posData.fromTrackerToAcdcDf( - self.tracker, tracked_video, start_frame_i=self.mainWin.start_n-1 + self.tracker, tracked_video, start_frame_i=self.mainWin.start_n - 1 ) # Store new tracked video current_frame_i = self.posData.frame_i self.trackingOnNeverVisitedFrames = False - print('') - self.progress.emit('Storing tracked video...') + print("") + self.progress.emit("Storing tracked video...") pbar = tqdm(total=len(tracked_video), ncols=100) for rel_frame_i, lab in enumerate(tracked_video): frame_i = rel_frame_i + self.mainWin.start_n - 1 if acdc_df is not None: - cca_cols = acdc_df.columns.intersection( - cca_df_colnames_with_tree - ) + cca_cols = acdc_df.columns.intersection(cca_df_colnames_with_tree) # Store cca_df if it is an output of the tracker cca_df = acdc_df.loc[frame_i][cca_cols] self.mainWin.store_cca_df( - frame_i=frame_i, cca_df=cca_df, mainThread=False, - autosave=False + frame_i=frame_i, cca_df=cca_df, mainThread=False, autosave=False ) - if self.posData.allData_li[frame_i]['labels'] is None: + if self.posData.allData_li[frame_i]["labels"] is None: # repeating tracking on a never visited frame # --> modify only raw data and ask later what to do self.posData.segm_data[frame_i] = lab self.trackingOnNeverVisitedFrames = True else: # Get the rest of the stored metadata based on the new lab - self.posData.allData_li[frame_i]['labels'] = lab + self.posData.allData_li[frame_i]["labels"] = lab self.posData.frame_i = frame_i self.mainWin.get_data() self.mainWin.store_data(autosave=False) - + pbar.update() pbar.close() @@ -1817,6 +1853,7 @@ def run(self): self.mutex.unlock() self.finished.emit() + class reapplyDataPrepWorker(QObject): finished = Signal() debug = Signal(object) @@ -1834,19 +1871,19 @@ def __init__(self, expPath, posFoldernames): self.abort = False self.mutex = QMutex() self.waitCond = QWaitCondition() - + def raiseSegmInfoNotFound(self, path): raise FileNotFoundError( - 'The following file is required for the alignment of 4D data ' + "The following file is required for the alignment of 4D data " f'but it was not found: "{path}"' ) - + def saveBkgrData(self, imageData, posData, isAligned=False): bkgrROI_data = {} for r, roi in enumerate(posData.bkgrROIs): xl, yt = [int(round(c)) for c in roi.pos()] w, h = [int(round(c)) for c in roi.size()] - if not yt+h>yt or not xl+w>xl: + if not yt + h > yt or not xl + w > xl: # Prevent 0 height or 0 width roi continue is4D = posData.SizeT > 1 and posData.SizeZ > 1 @@ -1854,42 +1891,42 @@ def saveBkgrData(self, imageData, posData, isAligned=False): is3Dt = posData.SizeT > 1 and posData.SizeZ == 1 is2D = posData.SizeT == 1 and posData.SizeZ == 1 if is4D: - bkgr_data = imageData[:, :, yt:yt+h, xl:xl+w] + bkgr_data = imageData[:, :, yt : yt + h, xl : xl + w] elif is3Dz or is3Dt: - bkgr_data = imageData[:, yt:yt+h, xl:xl+w] + bkgr_data = imageData[:, yt : yt + h, xl : xl + w] elif is2D: - bkgr_data = imageData[yt:yt+h, xl:xl+w] - bkgrROI_data[f'roi{r}_data'] = bkgr_data + bkgr_data = imageData[yt : yt + h, xl : xl + w] + bkgrROI_data[f"roi{r}_data"] = bkgr_data if not bkgrROI_data: return if isAligned: - bkgr_data_fn = f'{posData.filename}_aligned_bkgrRoiData.npz' + bkgr_data_fn = f"{posData.filename}_aligned_bkgrRoiData.npz" else: - bkgr_data_fn = f'{posData.filename}_bkgrRoiData.npz' + bkgr_data_fn = f"{posData.filename}_bkgrRoiData.npz" bkgr_data_path = os.path.join(posData.images_path, bkgr_data_fn) - self.progress.emit('Saving background data to:') + self.progress.emit("Saving background data to:") self.progress.emit(bkgr_data_path) io.savez_compressed(bkgr_data_path, **bkgrROI_data) def run(self): ch_name_selector = prompts.select_channel_name( - which_channel='segm', allow_abort=False + which_channel="segm", allow_abort=False ) for p, pos in enumerate(self.posFoldernames): if self.abort: break - - self.progress.emit(f'Processing {pos}...') - + + self.progress.emit(f"Processing {pos}...") + posPath = os.path.join(self.expPath, pos) - imagesPath = os.path.join(posPath, 'Images') + imagesPath = os.path.join(posPath, "Images") ls = myutils.listdir(imagesPath) if p == 0: - ch_names, basenameNotFound = ( - ch_name_selector.get_available_channels(ls, imagesPath) + ch_names, basenameNotFound = ch_name_selector.get_available_channels( + ls, imagesPath ) if not ch_names: self.sigCriticalNoChannels.emit(imagesPath) @@ -1908,11 +1945,9 @@ def run(self): self.mutex.unlock() if self.abort: break - - self.progress.emit( - f'Selected channels: {self.selectedChannels}' - ) - + + self.progress.emit(f"Selected channels: {self.selectedChannels}") + for chName in self.selectedChannels: filePath = load.get_filename_from_channel(imagesPath, chName) posData = load.loadData(filePath, chName) @@ -1920,12 +1955,12 @@ def run(self): posData.buildPaths() posData.loadImgData() posData.loadOtherFiles( - load_segm_data=False, + load_segm_data=False, getTifPath=True, load_metadata=True, load_shifts=True, load_dataPrep_ROIcoords=True, - loadBkgrROIs=True + loadBkgrROIs=True, ) imageData = posData.img_data @@ -1934,27 +1969,27 @@ def run(self): isAligned = False # Align if posData.loaded_shifts is not None: - self.progress.emit('Aligning frames...') + self.progress.emit("Aligning frames...") shifts = posData.loaded_shifts if imageData.ndim == 4: align_func = core.align_frames_3D else: - align_func = core.align_frames_2D + align_func = core.align_frames_2D imageData, _ = align_func(imageData, user_shifts=shifts) prepped = True isAligned = True - + # Crop and save background if posData.dataPrep_ROIcoords is not None: df = posData.dataPrep_ROIcoords - isCropped = int(df.at['cropped', 'value']) == 1 + isCropped = int(df.at["cropped", "value"]) == 1 if isCropped: self.saveBkgrData(imageData, posData, isAligned) - self.progress.emit('Cropping...') - x0 = int(df.at['x_left', 'value']) - y0 = int(df.at['y_top', 'value']) - x1 = int(df.at['x_right', 'value']) - y1 = int(df.at['y_bottom', 'value']) + self.progress.emit("Cropping...") + x0 = int(df.at["x_left", "value"]) + y0 = int(df.at["y_top", "value"]) + x1 = int(df.at["x_right", "value"]) + y1 = int(df.at["y_bottom", "value"]) if imageData.ndim == 4: imageData = imageData[:, :, y0:y1, x0:x1] elif imageData.ndim == 3: @@ -1966,22 +2001,21 @@ def run(self): filename = os.path.basename(posData.dataPrepBkgrROis_path) self.progress.emit( f'WARNING: the file "{filename}" was not found. ' - 'I cannot crop the data.' + "I cannot crop the data." ) - - if prepped: - self.progress.emit('Saving prepped data...') + + if prepped: + self.progress.emit("Saving prepped data...") io.savez_compressed(posData.align_npz_path, imageData) - if hasattr(posData, 'tif_path'): - myutils.to_tiff( - posData.tif_path, imageData - ) + if hasattr(posData, "tif_path"): + myutils.to_tiff(posData.tif_path, imageData) self.updatePbar.emit() if self.abort: break self.finished.emit() + class LazyLoader(QObject): sigLoadingFinished = Signal() @@ -2018,19 +2052,13 @@ def pause(self): def run(self): while True: if self.exit: - self.signals.progress.emit( - 'Closing lazy loader...', 'INFO' - ) + self.signals.progress.emit("Closing lazy loader...", "INFO") break elif self.wait: - self.signals.progress.emit( - 'Lazy loader paused.', 'INFO' - ) + self.signals.progress.emit("Lazy loader paused.", "INFO") self.pause() else: - self.signals.progress.emit( - 'Lazy loader resumed.', 'INFO' - ) + self.signals.progress.emit("Lazy loader resumed.", "INFO") self.posData.loadChannelDataChunk( self.current_idx, axis=self.axis, worker=self ) @@ -2054,12 +2082,12 @@ def __init__(self, folderPath, targetFolderPath, appendText): self.folderPath = folderPath self.targetFolderPath = targetFolderPath self.appendText = appendText - + @worker_exception_handler def run(self): self.progress.emit(f'Selected folder: "{self.folderPath}"') self.progress.emit(f'Target folder: "{self.targetFolderPath}"') - self.progress.emit(' ') + self.progress.emit(" ") ls = myutils.listdir(self.folderPath) numFiles = len(ls) self.initPbar.emit(numFiles) @@ -2070,48 +2098,47 @@ def run(self): for file in ls: if self.abort: break - + filePath = os.path.join(self.folderPath, file) if os.path.isdir(filePath): # Skip directories self.updatePbar.emit() continue - - self.progress.emit(f'Loading file: {file}') + + self.progress.emit(f"Loading file: {file}") filename, ext = os.path.splitext(file) s0p = str(pos).zfill(numPosDigits) try: data = load.imread(filePath) if data.ndim == 3 and (data.shape[-1] == 3 or data.shape[-1] == 4): - self.progress.emit('Converting RGB image to grayscale...') + self.progress.emit("Converting RGB image to grayscale...") data = skimage.color.rgb2gray(data) data = skimage.img_as_ubyte(data) - - posName = f'Position_{pos}' + + posName = f"Position_{pos}" posPath = os.path.join(self.targetFolderPath, posName) - imagesPath = os.path.join(posPath, 'Images') + imagesPath = os.path.join(posPath, "Images") if not os.path.exists(imagesPath): os.makedirs(imagesPath, exist_ok=True) - newFilename = f's{s0p}_{filename}_{self.appendText}.tif' - relPath = os.path.join(posName, 'Images', newFilename) + newFilename = f"s{s0p}_{filename}_{self.appendText}.tif" + relPath = os.path.join(posName, "Images", newFilename) tifFilePath = os.path.join(imagesPath, newFilename) - self.progress.emit(f'Saving to file: ...{os.sep}{relPath}') - myutils.to_tiff( - tifFilePath, data - ) + self.progress.emit(f"Saving to file: ...{os.sep}{relPath}") + myutils.to_tiff(tifFilePath, data) pos += 1 except Exception as e: self.progress.emit( - f'WARNING: {file} is not a valid image file. Skipping it.' + f"WARNING: {file} is not a valid image file. Skipping it." ) - - self.progress.emit(' ') + + self.progress.emit(" ") self.updatePbar.emit() if self.abort: break self.finished.emit() + class BaseWorkerUtil(QObject): progressBar = Signal(int, int, float) @@ -2131,10 +2158,8 @@ def emitSelectSegmFiles(self, exp_path, pos_foldernames): self.waitCond.wait(self.mutex) self.mutex.unlock() return self.abort - - def emitSelectFilesWithText( - self, exp_path, pos_foldernames, with_text, ext=None - ): + + def emitSelectFilesWithText(self, exp_path, pos_foldernames, with_text, ext=None): self.mutex.lock() self.signals.sigSelectFilesWithText.emit( exp_path, pos_foldernames, with_text, ext @@ -2142,40 +2167,53 @@ def emitSelectFilesWithText( self.waitCond.wait(self.mutex) self.mutex.unlock() return self.abort - - def emitSelectFile(self, start_dir, caption='', filters='All files (*.)'): + + def emitSelectFile(self, start_dir, caption="", filters="All files (*.)"): self.mutex.lock() self.signals.sigSelectFile.emit(start_dir, caption, filters) self.waitCond.wait(self.mutex) self.mutex.unlock() return self.abort - + def emitSelectAcdcOutputFiles( - self, exp_path, pos_foldernames, infoText='', - allowSingleSelection=False, multiSelection=True - ): + self, + exp_path, + pos_foldernames, + infoText="", + allowSingleSelection=False, + multiSelection=True, + ): self.mutex.lock() self.signals.sigSelectAcdcOutputFiles.emit( - exp_path, pos_foldernames, infoText, allowSingleSelection, - multiSelection + exp_path, pos_foldernames, infoText, allowSingleSelection, multiSelection ) self.waitCond.wait(self.mutex) self.mutex.unlock() return self.abort def emitSelectSpotmaxRun( - self, exp_path, pos_foldernames, all_runs, infoText='', - allowSingleSelection=True, multiSelection=True - ): + self, + exp_path, + pos_foldernames, + all_runs, + infoText="", + allowSingleSelection=True, + multiSelection=True, + ): self.mutex.lock() self.signals.sigSelectSpotmaxRun.emit( - exp_path, pos_foldernames, all_runs, infoText, allowSingleSelection, - multiSelection + exp_path, + pos_foldernames, + all_runs, + infoText, + allowSingleSelection, + multiSelection, ) self.waitCond.wait(self.mutex) self.mutex.unlock() return self.abort + class DataPrepSaveBkgrDataWorker(QObject): def __init__(self, posData, dataPrepWin): QObject.__init__(self) @@ -2183,12 +2221,13 @@ def __init__(self, posData, dataPrepWin): self.logger = workerLogger(self.signals.progress) self.posData = posData self.dataPrepWin = dataPrepWin - + @worker_exception_handler def run(self): self.dataPrepWin.saveBkgrData(self.posData) self.signals.finished.emit(self) + class DataPrepCropWorker(QObject): def __init__(self, posData, dataPrepWin, dstPath): QObject.__init__(self) @@ -2197,7 +2236,7 @@ def __init__(self, posData, dataPrepWin, dstPath): self.posData = posData self.dataPrepWin = dataPrepWin self.dstPath = dstPath - + @worker_exception_handler def run(self): self.dataPrepWin.saveSingleCrop( @@ -2205,6 +2244,7 @@ def run(self): ) self.signals.finished.emit(self) + class TrackSubCellObjectsWorker(BaseWorkerUtil): sigAskAppendName = Signal(str, list) sigCriticalNotEnoughSegmFiles = Signal(str) @@ -2212,15 +2252,15 @@ class TrackSubCellObjectsWorker(BaseWorkerUtil): def __init__(self, mainWin): super().__init__(mainWin) - if mainWin.trackingMode.find('Delete both') != -1: - self.trackingMode = 'delete_both' - elif mainWin.trackingMode.find('Delete sub-cellular') != -1: - self.trackingMode = 'delete_sub' - elif mainWin.trackingMode.find('Delete cells') != -1: - self.trackingMode = 'delete_cells' - elif mainWin.trackingMode.find('Only track') != -1: - self.trackingMode = 'only_track' - + if mainWin.trackingMode.find("Delete both") != -1: + self.trackingMode = "delete_both" + elif mainWin.trackingMode.find("Delete sub-cellular") != -1: + self.trackingMode = "delete_sub" + elif mainWin.trackingMode.find("Delete cells") != -1: + self.trackingMode = "delete_cells" + elif mainWin.trackingMode.find("Only track") != -1: + self.trackingMode = "only_track" + self.relabelSubObjLab = mainWin.relabelSubObjLab self.IoAthresh = mainWin.IoAthresh self.createThirdSegm = mainWin.createThirdSegm @@ -2236,13 +2276,13 @@ def run(self): self.errors = {} tot_pos = len(pos_foldernames) - red_text = html_utils.span('OF THE CELLs') - self.mainWin.infoText = f'Select segmentation file {red_text}' + red_text = html_utils.span("OF THE CELLs") + self.mainWin.infoText = f"Select segmentation file {red_text}" abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) if abort: self.sigAborted.emit() return - + # Critical --> there are not enough segm files if len(self.mainWin.existingSegmEndNames) < 2: self.mutex.lock() @@ -2253,16 +2293,14 @@ def run(self): return self.cellsSegmEndFilename = self.mainWin.endFilenameSegm - - red_text = html_utils.span('OF THE SUB-CELLULAR OBJECTS') - self.mainWin.infoText = ( - f'Select segmentation file {red_text}' - ) + + red_text = html_utils.span("OF THE SUB-CELLULAR OBJECTS") + self.mainWin.infoText = f"Select segmentation file {red_text}" abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) if abort: self.sigAborted.emit() return - + # Ask appendend name self.mutex.lock() self.sigAskAppendName.emit( @@ -2282,21 +2320,22 @@ def run(self): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") endFilenameSegm = self.mainWin.endFilenameSegm ls = myutils.listdir(images_path) file_path = [ - os.path.join(images_path, f) for f in ls - if f.endswith(f'{endFilenameSegm}.npz') + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") ][0] - - posData = load.loadData(file_path, '') - self.signals.sigUpdatePbarDesc.emit(f'Processing {posData.pos_path}') + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") posData.getBasenameAndChNames() posData.buildPaths() @@ -2305,20 +2344,22 @@ def run(self): load_segm_data=True, load_acdc_df=True, load_metadata=True, - end_filename_segm=endFilenameSegm + end_filename_segm=endFilenameSegm, ) # Load cells segmentation file segmDataCells, segmCellsPath = load.load_segm_file( - images_path, end_name_segm_file=self.cellsSegmEndFilename, - return_path=True + images_path, + end_name_segm_file=self.cellsSegmEndFilename, + return_path=True, ) acdc_df_cells_endname = self.cellsSegmEndFilename.replace( - '_segm', '_acdc_output' + "_segm", "_acdc_output" ) acdc_df_cell, acdc_df_cells_path = load.load_acdc_df_file( - images_path, end_name_acdc_df_file=acdc_df_cells_endname, - return_path=True + images_path, + end_name_acdc_df_file=acdc_df_cells_endname, + return_path=True, ) if posData.SizeT > 1: @@ -2327,35 +2368,42 @@ def run(self): posData.segm_data = posData.segm_data[:numFrames] else: numFrames = 1 - - self.signals.sigInitInnerPbar.emit(numFrames*2) - - self.logger.log('Tracking sub-cellular objects...') + + self.signals.sigInitInnerPbar.emit(numFrames * 2) + + self.logger.log("Tracking sub-cellular objects...") tracked = core.track_sub_cell_objects( - segmDataCells, posData.segm_data, self.IoAthresh, - how=self.trackingMode, SizeT=numFrames, + segmDataCells, + posData.segm_data, + self.IoAthresh, + how=self.trackingMode, + SizeT=numFrames, sigProgress=self.signals.sigUpdateInnerPbar, - relabel_sub_obj_lab=self.relabelSubObjLab + relabel_sub_obj_lab=self.relabelSubObjLab, ) - (trackedSubSegmData, trackedCellsSegmData, numSubObjPerCell, - replacedSubIds) = tracked - - self.logger.log('Saving tracked segmentation files...') + ( + trackedSubSegmData, + trackedCellsSegmData, + numSubObjPerCell, + replacedSubIds, + ) = tracked + + self.logger.log("Saving tracked segmentation files...") subSegmFilename, ext = os.path.splitext(posData.segm_npz_path) - trackedSubPath = f'{subSegmFilename}_{appendedName}.npz' + trackedSubPath = f"{subSegmFilename}_{appendedName}.npz" io.savez_compressed(trackedSubPath, trackedSubSegmData) posData.saveIsSegm3Dmetadata(trackedSubPath) if trackedCellsSegmData is not None: cellsSegmFilename, ext = os.path.splitext(segmCellsPath) - trackedCellsPath = f'{cellsSegmFilename}_{appendedName}.npz' + trackedCellsPath = f"{cellsSegmFilename}_{appendedName}.npz" io.savez_compressed(trackedCellsPath, trackedCellsSegmData) - + if self.createThirdSegm: self.logger.log( - f'Generating segmentation from ' + f"Generating segmentation from " f'"{self.cellsSegmEndFilename} - {appendedName}" ' - 'difference...' + "difference..." ) if trackedCellsSegmData is not None: parentSegmData = trackedCellsSegmData @@ -2364,63 +2412,65 @@ def run(self): diffSegmData = parentSegmData.copy() diffSegmData[trackedSubSegmData != 0] = 0 - self.logger.log('Saving difference segmentation file...') + self.logger.log("Saving difference segmentation file...") diffSegmPath = ( - f'{subSegmFilename}_{appendedName}' - f'_{self.thirdSegmAppendedText}.npz' + f"{subSegmFilename}_{appendedName}" + f"_{self.thirdSegmAppendedText}.npz" ) io.savez_compressed(diffSegmPath, diffSegmData) posData.saveIsSegm3Dmetadata(diffSegmPath) del diffSegmData - + if self.relabelSubObjLab: # When we relabel the sub-cell objs acdc_df is not valid anymore # because IDs could be different posData.acdc_df = None - - self.logger.log('Generating acdc_output tables...') - # Update or create acdc_df for sub-cellular objects + + self.logger.log("Generating acdc_output tables...") + # Update or create acdc_df for sub-cellular objects acdc_dfs_tracked = core.track_sub_cell_objects_acdc_df( - trackedSubSegmData, posData.acdc_df, - replacedSubIds, numSubObjPerCell, + trackedSubSegmData, + posData.acdc_df, + replacedSubIds, + numSubObjPerCell, tracked_cells_segm_data=trackedCellsSegmData, - cells_acdc_df=acdc_df_cell, SizeT=posData.SizeT, - sigProgress=self.signals.sigUpdateInnerPbar + cells_acdc_df=acdc_df_cell, + SizeT=posData.SizeT, + sigProgress=self.signals.sigUpdateInnerPbar, ) subTrackedAcdcDf, trackedAcdcDf = acdc_dfs_tracked - self.logger.log('Saving acdc_output tables...') - subAcdcDfFilename, _ = os.path.splitext( - posData.acdc_output_csv_path - ) - subTrackedAcdcDfPath = f'{subAcdcDfFilename}_{appendedName}.csv' + self.logger.log("Saving acdc_output tables...") + subAcdcDfFilename, _ = os.path.splitext(posData.acdc_output_csv_path) + subTrackedAcdcDfPath = f"{subAcdcDfFilename}_{appendedName}.csv" subTrackedAcdcDf.to_csv(subTrackedAcdcDfPath) if trackedAcdcDf is not None: basen = posData.basename cellsSegmFilename = os.path.basename(segmCellsPath) cellsSegmFilename, ext = os.path.splitext(cellsSegmFilename) - cellsSegmEndname = cellsSegmFilename[len(basen):] + cellsSegmEndname = cellsSegmFilename[len(basen) :] trackedAcdcDfEndname = cellsSegmEndname.replace( - 'segm', 'acdc_output' + "segm", "acdc_output" + ) + trackedAcdcDfFilename = f"{basen}{trackedAcdcDfEndname}" + trackedAcdcDfFilename = ( + f"{trackedAcdcDfFilename}_{appendedName}.csv" ) - trackedAcdcDfFilename = f'{basen}{trackedAcdcDfEndname}' - trackedAcdcDfFilename = f'{trackedAcdcDfFilename}_{appendedName}.csv' trackedAcdcDfPath = os.path.join( posData.images_path, trackedAcdcDfFilename ) trackedAcdcDf.to_csv(trackedAcdcDfPath) - + if self.createThirdSegm: if posData.SizeT == 1: parentSegmData = parentSegmData[np.newaxis] - subAcdcDfFilename = ( - subSegmFilename.replace('.npz', '.csv') - .replace('segm', 'acdc_output') - ) + subAcdcDfFilename = subSegmFilename.replace( + ".npz", ".csv" + ).replace("segm", "acdc_output") diffAcdcDfPath = ( - f'{subAcdcDfFilename}_{appendedName}' - f'_{self.thirdSegmAppendedText}.csv' + f"{subAcdcDfFilename}_{appendedName}" + f"_{self.thirdSegmAppendedText}.csv" ) third_segm_acdc_df = ( core.track_sub_cell_objects_third_segm_acdc_df( @@ -2433,14 +2483,15 @@ def run(self): self.signals.finished.emit(self) + class PostProcessSegmWorker(QObject): def __init__( - self, - postProcessKwargs, - customPostProcessGroupedFeatures, - customPostProcessFeatures, - mainWin - ): + self, + postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures, + mainWin, + ): super().__init__() self.signals = signals() self.logger = workerLogger(self.signals.progress) @@ -2448,7 +2499,7 @@ def __init__( self.customPostProcessGroupedFeatures = customPostProcessGroupedFeatures self.customPostProcessFeatures = customPostProcessFeatures self.mainWin = mainWin - + @worker_exception_handler def run(self): mainWin = self.mainWin @@ -2459,11 +2510,11 @@ def run(self): else: current_frame_i = posData.frame_i self.signals.initProgressBar.emit(posData.SizeT - current_frame_i) - - self.logger.log('Post-process segmentation process started.') + + self.logger.log("Post-process segmentation process started.") self._run() self.signals.finished.emit(None) - + def _run(self): kwargs = self.kwargs mainWin = self.mainWin @@ -2475,7 +2526,7 @@ def _run(self): for i, data_dict in enumerate(data_li): frame_i = current_frame_i + i visited = True - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None: visited = False try: @@ -2484,23 +2535,23 @@ def _run(self): return image = posData.img_data[frame_i] - + processed_lab = core.post_process_segm( lab, return_delIDs=False, **kwargs ) if self.customPostProcessFeatures: processed_lab = features.custom_post_process_segm( - posData, - self.customPostProcessGroupedFeatures, - processed_lab, - image, - posData.frame_i, - posData.filename, - posData.user_ch_name, - self.customPostProcessFeatures + posData, + self.customPostProcessGroupedFeatures, + processed_lab, + image, + posData.frame_i, + posData.filename, + posData.user_ch_name, + self.customPostProcessFeatures, ) if visited: - posData.allData_li[frame_i]['labels'] = processed_lab + posData.allData_li[frame_i]["labels"] = processed_lab # Get the rest of the stored metadata based on the new lab posData.frame_i = frame_i mainWin.get_data() @@ -2509,9 +2560,10 @@ def _run(self): posData.segm_data[frame_i] = lab self.signals.progressBar.emit(1) - + posData.frame_i = current_frame_i + class CreateConnected3Dsegm(BaseWorkerUtil): sigAskAppendName = Signal(str, list) sigAborted = Signal() @@ -2521,10 +2573,10 @@ def __init__(self, mainWin): def criticalSegmIsNot3D(self): raise TypeError( - 'Input segmentation masks are not 3D. You can use this utility ' - 'only on 3D z-stack data or 4D z-stack over time data.' + "Input segmentation masks are not 3D. You can use this utility " + "only on 3D z-stack data or 4D z-stack over time data." ) - + @worker_exception_handler def run(self): debugging = False @@ -2535,12 +2587,12 @@ def run(self): self.errors = {} tot_pos = len(pos_foldernames) - self.mainWin.infoText = f'Select 3D segmentation file to connect' + self.mainWin.infoText = f"Select 3D segmentation file to connect" abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) if abort: self.sigAborted.emit() return - + # Ask appendend name self.mutex.lock() self.sigAskAppendName.emit( @@ -2560,22 +2612,22 @@ def run(self): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") endFilenameSegm = self.mainWin.endFilenameSegm ls = myutils.listdir(images_path) file_path = [ - os.path.join(images_path, f) for f in ls - if f.endswith(f'{endFilenameSegm}.npz') + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") ][0] - - posData = load.loadData(file_path, '') - self.signals.sigUpdatePbarDesc.emit( - f'Processing {posData.pos_path}') + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") posData.getBasenameAndChNames() posData.buildPaths() @@ -2584,115 +2636,122 @@ def run(self): load_segm_data=True, load_acdc_df=True, load_metadata=True, - end_filename_segm=endFilenameSegm + end_filename_segm=endFilenameSegm, ) if posData.segm_data.ndim == 3: posData.segm_data = posData.segm_data[np.newaxis] - - self.logger.log('Connecting 3D objects...') - + + self.logger.log("Connecting 3D objects...") + numFrames = len(posData.segm_data) self.signals.sigInitInnerPbar.emit(numFrames) connectedSegmData = np.zeros_like(posData.segm_data) for frame_i, lab in enumerate(posData.segm_data): if lab.ndim != 3: self.criticalSegmIsNot3D() - + connected_lab = core.connect_3Dlab_zboundaries(lab) connectedSegmData[frame_i] = connected_lab self.signals.sigUpdateInnerPbar.emit(1) - self.logger.log('Saving connected 3D segmentation file...') + self.logger.log("Saving connected 3D segmentation file...") segmFilename, ext = os.path.splitext(posData.segm_npz_path) - newSegmFilepath = f'{segmFilename}_{appendedName}.npz' + newSegmFilepath = f"{segmFilename}_{appendedName}.npz" connectedSegmData = np.squeeze(connectedSegmData) io.savez_compressed(newSegmFilepath, connectedSegmData) - + self.signals.progressBar.emit(1) self.signals.finished.emit(self) + class ApplyTrackInfoWorker(BaseWorkerUtil): def __init__( - self, parentWin, endFilenameSegm, trackInfoCsvPath, - trackedSegmFilename, trackColsInfo, posPath - ): + self, + parentWin, + endFilenameSegm, + trackInfoCsvPath, + trackedSegmFilename, + trackColsInfo, + posPath, + ): super().__init__(parentWin) self.endFilenameSegm = endFilenameSegm self.trackInfoCsvPath = trackInfoCsvPath self.trackedSegmFilename = trackedSegmFilename self.trackColsInfo = trackColsInfo self.posPath = posPath - + @worker_exception_handler def run(self): - self.logger.log('Loading segmentation file...') + self.logger.log("Loading segmentation file...") self.signals.initProgressBar.emit(0) - imagesPath = os.path.join(self.posPath, 'Images') + imagesPath = os.path.join(self.posPath, "Images") segmFilename = [ - f for f in myutils.listdir(imagesPath) - if f.endswith(f'{self.endFilenameSegm}.npz') + f + for f in myutils.listdir(imagesPath) + if f.endswith(f"{self.endFilenameSegm}.npz") ][0] segmFilePath = os.path.join(imagesPath, segmFilename) - segmData = np.load(segmFilePath)['arr_0'] + segmData = np.load(segmFilePath)["arr_0"] - self.logger.log('Loading table containing tracking info...') + self.logger.log("Loading table containing tracking info...") df = pd.read_csv(self.trackInfoCsvPath) - frameIndexCol = self.trackColsInfo['frameIndexCol'] + frameIndexCol = self.trackColsInfo["frameIndexCol"] - parentIDcol = self.trackColsInfo['parentIDcol'] + parentIDcol = self.trackColsInfo["parentIDcol"] pbarMax = len(df[frameIndexCol].unique()) self.signals.initProgressBar.emit(pbarMax) # Apply tracking info result = core.apply_tracking_from_table( - segmData, self.trackColsInfo, df, signal=self.signals.progressBar, - logger=self.logger.log, pbarMax=pbarMax + segmData, + self.trackColsInfo, + df, + signal=self.signals.progressBar, + logger=self.logger.log, + pbarMax=pbarMax, ) trackedData, trackedIDsMapper, deleteIDsMapper = result if self.trackedSegmFilename: - trackedSegmFilepath = os.path.join( - imagesPath, self.trackedSegmFilename - ) + trackedSegmFilepath = os.path.join(imagesPath, self.trackedSegmFilename) else: trackedSegmFilepath = os.path.join(segmFilePath) - + self.signals.initProgressBar.emit(0) - self.logger.log('Saving tracked segmentation file...') + self.logger.log("Saving tracked segmentation file...") io.savez_compressed(trackedSegmFilepath, trackedData) - mapperPath = os.path.splitext(trackedSegmFilepath)[0] - mapperJsonPath = f'{mapperPath}_deletedIDs_mapper.json' + mapperJsonPath = f"{mapperPath}_deletedIDs_mapper.json" mapperJsonName = os.path.basename(mapperJsonPath) - self.logger.log(f'Saving deleted IDs to {mapperJsonName}...') - with open(mapperJsonPath, 'w') as file: + self.logger.log(f"Saving deleted IDs to {mapperJsonName}...") + with open(mapperJsonPath, "w") as file: file.write(json.dumps(deleteIDsMapper)) mapperPath = os.path.splitext(trackedSegmFilepath)[0] - mapperJsonPath = f'{mapperPath}_replacedIDs_mapper.json' + mapperJsonPath = f"{mapperPath}_replacedIDs_mapper.json" mapperJsonName = os.path.basename(mapperJsonPath) - self.logger.log(f'Saving IDs replacements to {mapperJsonName}...') - with open(mapperJsonPath, 'w') as file: + self.logger.log(f"Saving IDs replacements to {mapperJsonName}...") + with open(mapperJsonPath, "w") as file: file.write(json.dumps(trackedIDsMapper)) - self.logger.log('Generating acdc_output table...') + self.logger.log("Generating acdc_output table...") acdc_df = None if not self.trackedSegmFilename: # Fix existing acdc_df - acdcEndname = self.endFilenameSegm.replace('_segm', '_acdc_output') + acdcEndname = self.endFilenameSegm.replace("_segm", "_acdc_output") acdcFilename = [ - f for f in myutils.listdir(imagesPath) - if f.endswith(f'{acdcEndname}.csv') + f + for f in myutils.listdir(imagesPath) + if f.endswith(f"{acdcEndname}.csv") ] if acdcFilename: acdcFilePath = os.path.join(imagesPath, acdcFilename[0]) - acdc_df = pd.read_csv( - acdcFilePath, index_col=['frame_i', 'Cell_ID'] - ) + acdc_df = pd.read_csv(acdcFilePath, index_col=["frame_i", "Cell_ID"]) if acdc_df is not None: acdc_df = core.apply_trackedIDs_mapper_to_acdc_df( @@ -2701,39 +2760,45 @@ def run(self): else: acdc_dfs = [] keys = [] - for frame_i, lab in enumerate(trackedData): + for frame_i, lab in enumerate(trackedData): rp = skimage.measure.regionprops(lab) acdc_df_frame_i = myutils.getBaseAcdcDf(rp) acdc_dfs.append(acdc_df_frame_i) keys.append(frame_i) - - acdc_df = pd.concat(acdc_dfs, keys=keys, names=['frame_i', 'Cell_ID']) + + acdc_df = pd.concat(acdc_dfs, keys=keys, names=["frame_i", "Cell_ID"]) segmFilename = os.path.basename(trackedSegmFilepath) - acdcFilename = re.sub(segm_re_pattern, '_acdc_output', segmFilename) + acdcFilename = re.sub(segm_re_pattern, "_acdc_output", segmFilename) acdcFilePath = os.path.join(imagesPath, acdcFilename) - + self.signals.initProgressBar.emit(pbarMax) - parentIDcol = self.trackColsInfo['parentIDcol'] - trackIDsCol = self.trackColsInfo['trackIDsCol'] - if parentIDcol != 'None': + parentIDcol = self.trackColsInfo["parentIDcol"] + trackIDsCol = self.trackColsInfo["trackIDsCol"] + if parentIDcol != "None": self.logger.log(f'Adding lineage info from "{parentIDcol}" column...') acdc_df = core.add_cca_info_from_parentID_col( - df, acdc_df, frameIndexCol, trackIDsCol, parentIDcol, - len(segmData), signal=self.signals.progressBar, - maskID_colname=self.trackColsInfo['maskIDsCol'], - x_colname=self.trackColsInfo['xCentroidCol'], - y_colname=self.trackColsInfo['yCentroidCol'] - ) - - self.logger.log('Saving acdc_output table...') + df, + acdc_df, + frameIndexCol, + trackIDsCol, + parentIDcol, + len(segmData), + signal=self.signals.progressBar, + maskID_colname=self.trackColsInfo["maskIDsCol"], + x_colname=self.trackColsInfo["xCentroidCol"], + y_colname=self.trackColsInfo["yCentroidCol"], + ) + + self.logger.log("Saving acdc_output table...") acdc_df.to_csv(acdcFilePath) self.signals.finished.emit(self) + class RestructMultiPosWorker(BaseWorkerUtil): sigSaveTiff = Signal(str, object, object) - def __init__(self, rootFolderPath, dstFolderPath, action='copy'): + def __init__(self, rootFolderPath, dstFolderPath, action="copy"): super().__init__(None) self.rootFolderPath = rootFolderPath self.dstFolderPath = dstFolderPath @@ -2744,8 +2809,11 @@ def __init__(self, rootFolderPath, dstFolderPath, action='copy'): @worker_exception_handler def run(self): load._restructure_multi_files_multi_pos( - self.rootFolderPath, self.dstFolderPath, signals=self.signals, - logger=self.logger.log, action=self.action + self.rootFolderPath, + self.dstFolderPath, + signals=self.signals, + logger=self.logger.log, + action=self.action, ) self.signals.finished.emit(self) @@ -2754,9 +2822,15 @@ class RestructMultiTimepointsWorker(BaseWorkerUtil): sigSaveTiff = Signal(str, object, object) def __init__( - self, allChannels, frame_name_pattern, basename, validFilenames, - rootFolderPath, dstFolderPath, segmFolderPath='' - ): + self, + allChannels, + frame_name_pattern, + basename, + validFilenames, + rootFolderPath, + dstFolderPath, + segmFolderPath="", + ): super().__init__(None) self.allChannels = allChannels self.frame_name_pattern = frame_name_pattern @@ -2776,12 +2850,12 @@ def run(self): dstFolderPath = self.dstFolderPath segmFolderPath = self.segmFolderPath filesInfo = {} - self.signals.initProgressBar.emit(len(self.validFilenames)+1) + self.signals.initProgressBar.emit(len(self.validFilenames) + 1) for file in self.validFilenames: try: # Determine which channel is this file for ch in allChannels: - m = re.findall(rf'(.*)_{ch}{frame_name_pattern}', file) + m = re.findall(rf"(.*)_{ch}{frame_name_pattern}", file) if m: break else: @@ -2800,17 +2874,17 @@ def run(self): self.logger.log(traceback.format_exc()) self.logger.log( f'WARNING: File "{file}" does not contain valid pattern. ' - 'Skipping it.' + "Skipping it." ) continue - + self.signals.progressBar.emit(1) df_metadata = None partial_basename = self.basename allPosDataInfo = [] for p, (posName, channelInfo) in enumerate(filesInfo.items()): - self.logger.log(f'='*40) + self.logger.log(f"=" * 40) self.logger.log(f'Processing position "{posName}"...') for _, filesList in channelInfo.items(): @@ -2824,42 +2898,39 @@ def run(self): continue else: self.logger.log( - f'WARNING: No valid image files found for position {posName}' + f"WARNING: No valid image files found for position {posName}" ) continue # Get basename if partial_basename: - basename = f'{partial_basename}_{posName}_' + basename = f"{partial_basename}_{posName}_" else: - basename = f'{posName}_' + basename = f"{posName}_" # Get SizeT from first file SizeT = len(filesList) - - # Save metadata.csv - df_metadata = pd.DataFrame({ - 'SizeT': SizeT, - 'basename': basename - }, index=['values']) + + # Save metadata.csv + df_metadata = pd.DataFrame( + {"SizeT": SizeT, "basename": basename}, index=["values"] + ) # Iterate channels for c, (channelName, filesList) in enumerate(channelInfo.items()): - self.logger.log( - f' Processing channel "{channelName}"...' - ) + self.logger.log(f' Processing channel "{channelName}"...') # Sort by frame number - sortedFilesList = sorted(filesList, key=lambda t:t[1]) + sortedFilesList = sorted(filesList, key=lambda t: t[1]) - df_metadata[f'channel_{c}_name'] = [channelName] + df_metadata[f"channel_{c}_name"] = [channelName] - imagesPath = os.path.join(dstFolderPath, f'Position_{p+1}', 'Images') + imagesPath = os.path.join(dstFolderPath, f"Position_{p + 1}", "Images") if not os.path.exists(imagesPath): os.makedirs(imagesPath, exist_ok=True) # Iterate frames videoData = None - srcSegmPaths = ['']*SizeT + srcSegmPaths = [""] * SizeT frameNumbers = [] for frame_i, fileInfo in enumerate(sortedFilesList): file, _ = fileInfo @@ -2879,64 +2950,65 @@ def run(self): self.logger.log(traceback.format_exc()) continue - if segmFolderPath and c==0: + if segmFolderPath and c == 0: srcSegmFilePath = os.path.join(segmFolderPath, file) srcSegmPaths[frame_i] = srcSegmFilePath SizeZ = 1 if img.ndim == 3: SizeZ = len(img) - - df_metadata['SizeZ'] = [SizeZ] + + df_metadata["SizeZ"] = [SizeZ] self.signals.progressBar.emit(1) - + if videoData is None: self.logger.log( - f'WARNING: No valid image files found for position ' + f"WARNING: No valid image files found for position " f'"{posName}", channel "{channelName}"' ) continue else: - imgFileName = f'{basename}{channelName}.tif' + imgFileName = f"{basename}{channelName}.tif" dstImgFilePath = os.path.join(imagesPath, imgFileName) - dstSegmFileName = f'{basename}segm_{channelName}.npz' + dstSegmFileName = f"{basename}segm_{channelName}.npz" dstSegmPath = os.path.join(imagesPath, dstSegmFileName) imgDataInfo = { - 'path': dstImgFilePath, 'SizeT': SizeT, 'SizeZ': SizeZ, - 'data': videoData, 'frameNumbers': frameNumbers, - 'dst_segm_path': dstSegmPath, - 'src_segm_paths': srcSegmPaths + "path": dstImgFilePath, + "SizeT": SizeT, + "SizeZ": SizeZ, + "data": videoData, + "frameNumbers": frameNumbers, + "dst_segm_path": dstSegmPath, + "src_segm_paths": srcSegmPaths, } allPosDataInfo.append(imgDataInfo) if df_metadata is not None: - metadata_csv_path = os.path.join( - imagesPath, f'{basename}metadata.csv' - ) + metadata_csv_path = os.path.join(imagesPath, f"{basename}metadata.csv") df_metadata = df_metadata.T - df_metadata.index.name = 'Description' + df_metadata.index.name = "Description" df_metadata.to_csv(metadata_csv_path) - self.logger.log(f'*'*40) - + self.logger.log(f"*" * 40) + if not allPosDataInfo: self.signals.finished.emit(self) return - + self.signals.initProgressBar.emit(len(allPosDataInfo)) - self.logger.log('Saving image files...') - maxSizeT = max([d['SizeT'] for d in allPosDataInfo]) - minFrameNumber = min([d['frameNumbers'][0] for d in allPosDataInfo]) + self.logger.log("Saving image files...") + maxSizeT = max([d["SizeT"] for d in allPosDataInfo]) + minFrameNumber = min([d["frameNumbers"][0] for d in allPosDataInfo]) # Pad missing frames in video files according to frame number for p, imgDataInfo in enumerate(allPosDataInfo): - SizeT = imgDataInfo['SizeT'] - SizeZ = imgDataInfo['SizeZ'] - dstImgFilePath = imgDataInfo['path'] - videoData = imgDataInfo['data'] - frameNumbers = imgDataInfo['frameNumbers'] + SizeT = imgDataInfo["SizeT"] + SizeZ = imgDataInfo["SizeZ"] + dstImgFilePath = imgDataInfo["path"] + videoData = imgDataInfo["data"] + frameNumbers = imgDataInfo["frameNumbers"] paddedShape = (maxSizeT, *videoData.shape[1:]) - imgDataInfo['paddedShape'] = paddedShape + imgDataInfo["paddedShape"] = paddedShape dtype = videoData.dtype paddedVideoData = np.zeros(paddedShape, dtype=dtype) for n, img in zip(frameNumbers, videoData): @@ -2944,30 +3016,30 @@ def run(self): paddedVideoData[frame_i] = img del videoData - imgDataInfo['data'] = None + imgDataInfo["data"] = None - self.mutex.lock() + self.mutex.lock() self.sigSaveTiff.emit(dstImgFilePath, paddedVideoData, self.waitCond) self.waitCond.wait(self.mutex) - self.mutex.unlock() + self.mutex.unlock() - self.signals.progressBar.emit(1) + self.signals.progressBar.emit(1) if not segmFolderPath: self.signals.finished.emit(self) return self.signals.initProgressBar.emit(len(allPosDataInfo)) - self.logger.log('Saving segmentation files...') + self.logger.log("Saving segmentation files...") for p, imgDataInfo in enumerate(allPosDataInfo): - SizeT = imgDataInfo['SizeT'] - frameNumbers = imgDataInfo['frameNumbers'] - SizeT = imgDataInfo['SizeT'] - SizeZ = imgDataInfo['SizeZ'] - frameNumbers = imgDataInfo['frameNumbers'] - paddedShape = imgDataInfo['paddedShape'] + SizeT = imgDataInfo["SizeT"] + frameNumbers = imgDataInfo["frameNumbers"] + SizeT = imgDataInfo["SizeT"] + SizeZ = imgDataInfo["SizeZ"] + frameNumbers = imgDataInfo["frameNumbers"] + paddedShape = imgDataInfo["paddedShape"] segmData = np.zeros(paddedShape, dtype=np.uint32) - for n, segmFilePath in zip(frameNumbers, imgDataInfo['src_segm_paths']): + for n, segmFilePath in zip(frameNumbers, imgDataInfo["src_segm_paths"]): frame_i = n - minFrameNumber try: lab = load.imread(segmFilePath).astype(np.uint32) @@ -2975,15 +3047,16 @@ def run(self): except Exception as e: self.logger.log(traceback.format_exc()) self.logger.log( - 'WARNING: The following segmentation file does not ' + "WARNING: The following segmentation file does not " f'exist, saving empty masks: "{srcSegmFilePath}"' - ) + ) - io.savez_compressed(imgDataInfo['dst_segm_path'], segmData) - del segmData + io.savez_compressed(imgDataInfo["dst_segm_path"], segmData) + del segmData self.signals.finished.emit(self) + class ComputeMetricsMultiChannelWorker(BaseWorkerUtil): sigAskAppendName = Signal(str, list, list) sigCriticalNotEnoughSegmFiles = Signal(str) @@ -2992,43 +3065,50 @@ class ComputeMetricsMultiChannelWorker(BaseWorkerUtil): def __init__(self, mainWin): super().__init__(mainWin) - + def emitHowCombineMetrics( - self, imagesPath, selectedAcdcOutputEndnames, - existingAcdcOutputEndnames, allChNames - ): + self, + imagesPath, + selectedAcdcOutputEndnames, + existingAcdcOutputEndnames, + allChNames, + ): self.mutex.lock() self.sigHowCombineMetrics.emit( - imagesPath, selectedAcdcOutputEndnames, - existingAcdcOutputEndnames, allChNames + imagesPath, + selectedAcdcOutputEndnames, + existingAcdcOutputEndnames, + allChNames, ) self.waitCond.wait(self.mutex) self.mutex.unlock() return self.abort - + def loadAcdcDfs(self, imagesPath, selectedAcdcOutputEndnames): for end in selectedAcdcOutputEndnames: filePath, _ = load.get_path_from_endname(end, imagesPath) acdc_df = pd.read_csv(filePath) yield acdc_df - + def run_iter_exp(self, exp_path, pos_foldernames, i, tot_exp): tot_pos = len(pos_foldernames) - + abort = self.emitSelectAcdcOutputFiles( - exp_path, pos_foldernames, infoText=' to combine', - allowSingleSelection=False + exp_path, + pos_foldernames, + infoText=" to combine", + allowSingleSelection=False, ) if abort: self.sigAborted.emit() return - + # Ask appendend name self.mutex.lock() self.sigAskAppendName.emit( - f'{self.mainWin.basename_pos1}acdc_output', + f"{self.mainWin.basename_pos1}acdc_output", self.mainWin.existingAcdcOutputEndnames, - self.mainWin.selectedAcdcOutputEndnames + self.mainWin.selectedAcdcOutputEndnames, ) self.waitCond.wait(self.mutex) self.mutex.unlock() @@ -3047,49 +3127,48 @@ def run_iter_exp(self, exp_path, pos_foldernames, i, tot_exp): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, {pos} ({p + 1}/{tot_pos})" ) - imagesPath = os.path.join(exp_path, pos, 'Images') + imagesPath = os.path.join(exp_path, pos, "Images") basename, chNames = myutils.getBasenameAndChNames( - imagesPath, useExt=('.tif', '.h5') + imagesPath, useExt=(".tif", ".h5") ) if p == 0: abort = self.emitHowCombineMetrics( - imagesPath, selectedAcdcOutputEndnames, - existingAcdcOutputEndnames, chNames + imagesPath, + selectedAcdcOutputEndnames, + existingAcdcOutputEndnames, + chNames, ) if abort: self.sigAborted.emit() return acdcDfs = self.acdcDfs.values() - # Update selected acdc_dfs since the user could have + # Update selected acdc_dfs since the user could have # loaded additional ones inside the emitHowCombineMetrics # dialog selectedAcdcOutputEndnames = self.acdcDfs.keys() else: - acdcDfs = self.loadAcdcDfs( - imagesPath, selectedAcdcOutputEndnames - ) + acdcDfs = self.loadAcdcDfs(imagesPath, selectedAcdcOutputEndnames) dfs = [] for i, acdc_df in enumerate(acdcDfs): - dfs.append(acdc_df.add_suffix(f'_table{i+1}')) + dfs.append(acdc_df.add_suffix(f"_table{i + 1}")) combined_df = pd.concat(dfs, axis=1) newAcdcDf = pd.DataFrame(index=combined_df.index) for newColname, equation in self.equations.items(): newAcdcDf[newColname] = combined_df.eval(equation) - + newAcdcDfPath = os.path.join( - imagesPath, f'{basename}acdc_output_{appendedName}.csv' + imagesPath, f"{basename}acdc_output_{appendedName}.csv" ) newAcdcDf.to_csv(newAcdcDfPath) equationsIniPath = os.path.join( - imagesPath, f'{basename}equations_{appendedName}.ini' + imagesPath, f"{basename}equations_{appendedName}.ini" ) equationsConfig = config.ConfigParser() if os.path.exists(equationsIniPath): @@ -3097,26 +3176,26 @@ def run_iter_exp(self, exp_path, pos_foldernames, i, tot_exp): equationsConfig = self.addEquationsToConfigPars( equationsConfig, selectedAcdcOutputEndnames, self.equations ) - with open(equationsIniPath, 'w') as configfile: + with open(equationsIniPath, "w") as configfile: equationsConfig.write(configfile) self.signals.progressBar.emit(1) - + return True - + def addEquationsToConfigPars(self, cp, selectedAcdcOutputEndnames, equations): section = [ - f'df{i+1}:{end}' for i, end in enumerate(selectedAcdcOutputEndnames) + f"df{i + 1}:{end}" for i, end in enumerate(selectedAcdcOutputEndnames) ] - section = ';'.join(section) + section = ";".join(section) if section not in cp: cp[section] = {} - + for metricName, expression in equations.items(): cp[section][metricName] = expression - + return cp - + @worker_exception_handler def run(self): debugging = False @@ -3136,25 +3215,26 @@ def run(self): self.signals.finished.emit(self) + class ConcatAcdcDfsWorker(BaseWorkerUtil): sigAborted = Signal() sigAskFolder = Signal(str) sigSetMeasurements = Signal(object) sigAskAppendName = Signal(str, list) - def __init__(self, mainWin, format='CSV'): + def __init__(self, mainWin, format="CSV"): super().__init__(mainWin) - if format.startswith('CSV'): - self._to_format = 'to_csv' - elif format.startswith('XLS'): - self._to_format = 'to_excel' - + if format.startswith("CSV"): + self._to_format = "to_csv" + elif format.startswith("XLS"): + self._to_format = "to_excel" + def emitSetMeasurements(self, kwargs): self.mutex.lock() self.sigSetMeasurements.emit(kwargs) self.waitCond.wait(self.mutex) self.mutex.unlock() - + def emitAskAppendName(self, allPos_acdc_df_basename): # Ask appendend name self.mutex.lock() @@ -3167,7 +3247,7 @@ def run(self): debugging = False expPaths = self.mainWin.expPaths tot_exp = len(expPaths) - + self.signals.initProgressBar.emit(0) acdc_dfs_allexp = [] acdc_objs_count_dfs_allexp = {} @@ -3175,11 +3255,14 @@ def run(self): for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): self.errors = {} tot_pos = len(pos_foldernames) - + if i == 0: abort = self.emitSelectAcdcOutputFiles( - exp_path, pos_foldernames, infoText=' to combine', - allowSingleSelection=True, multiSelection=False + exp_path, + pos_foldernames, + infoText=" to combine", + allowSingleSelection=True, + multiSelection=False, ) if abort: self.sigAborted.emit() @@ -3187,7 +3270,7 @@ def run(self): selectedAcdcOutputEndname = self.mainWin.selectedAcdcOutputEndnames[0] selectedAcdcObjsCountEndname = selectedAcdcOutputEndname.replace( - 'acdc_output', 'acdc_objects_count' + "acdc_output", "acdc_objects_count" ) self.signals.initProgressBar.emit(len(pos_foldernames)) @@ -3200,30 +3283,28 @@ def run(self): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") ls = myutils.listdir(images_path) acdc_output_file = [ - f for f in ls - if f.endswith(f'{selectedAcdcOutputEndname}.csv') + f for f in ls if f.endswith(f"{selectedAcdcOutputEndname}.csv") ] if not acdc_output_file: self.logger.log( - f'{pos} does not contain any ' - f'{selectedAcdcOutputEndname}.csv file. ' - 'Skipping it.' + f"{pos} does not contain any " + f"{selectedAcdcOutputEndname}.csv file. " + "Skipping it." ) self.signals.progressBar.emit(1) continue - + acdc_objs_count_file = [ - f for f in ls - if f.endswith(f'{selectedAcdcObjsCountEndname}.csv') + f for f in ls if f.endswith(f"{selectedAcdcObjsCountEndname}.csv") ] if acdc_objs_count_file: df_count_filepath = os.path.join( @@ -3231,9 +3312,9 @@ def run(self): ) df_count = pd.read_csv(df_count_filepath) acdc_objs_count_dfs[pos] = df_count - + acdc_df_filepath = os.path.join(images_path, acdc_output_file[0]) - acdc_df = pd.read_csv(acdc_df_filepath).set_index('Cell_ID') + acdc_df = pd.read_csv(acdc_df_filepath).set_index("Cell_ID") acdc_dfs.append(acdc_df) keys.append(pos) @@ -3241,98 +3322,91 @@ def run(self): self.signals.initProgressBar.emit(0) acdc_df_allpos = pd.concat( - acdc_dfs, keys=keys, names=['Position_n', 'Cell_ID'] + acdc_dfs, keys=keys, names=["Position_n", "Cell_ID"] ) - acdc_df_allpos['experiment_folderpath'] = exp_path - + acdc_df_allpos["experiment_folderpath"] = exp_path + basename, chNames = myutils.getBasenameAndChNames( - images_path, useExt=('.tif', '.h5') + images_path, useExt=(".tif", ".h5") ) df_metadata = load.load_metadata_df(images_path) - SizeZ = df_metadata.at['SizeZ', 'values'] + SizeZ = df_metadata.at["SizeZ", "values"] SizeZ = int(float(SizeZ)) existing_colnames = acdc_df_allpos.columns - isSegm3D = any([col.endswith('3D') for col in existing_colnames]) - + isSegm3D = any([col.endswith("3D") for col in existing_colnames]) + if i == 0: kwargs = { - 'loadedChNames': chNames, - 'notLoadedChNames': [], - 'isZstack': SizeZ > 1, - 'isSegm3D': isSegm3D, - 'existing_colnames': existing_colnames + "loadedChNames": chNames, + "notLoadedChNames": [], + "isZstack": SizeZ > 1, + "isSegm3D": isSegm3D, + "existing_colnames": existing_colnames, } self.emitSetMeasurements(kwargs) if self.abort: self.sigAborted.emit() return - + selected_cols = [ - col for col in self.selectedColumns - if col in acdc_df_allpos.columns + col for col in self.selectedColumns if col in acdc_df_allpos.columns ] acdc_df_allpos = acdc_df_allpos[selected_cols] acdc_dfs_allexp.append(acdc_df_allpos) exp_name = os.path.basename(exp_path) keys_exp.append((exp_path, exp_name)) - allpos_dir = os.path.join(exp_path, 'AllPos_acdc_output') + allpos_dir = os.path.join(exp_path, "AllPos_acdc_output") if not os.path.exists(allpos_dir): os.mkdir(allpos_dir) - - allPos_acdc_df_basename = f'AllPos_{selectedAcdcOutputEndname}' + + allPos_acdc_df_basename = f"AllPos_{selectedAcdcOutputEndname}" if i == 0: self.emitAskAppendName(allPos_acdc_df_basename) if self.abort: self.sigAborted.emit() return - - acdc_objs_count_df_allpos_filename = ( - self.concat_df_filename.replace( - 'acdc_output', 'acdc_objects_count' - ) - ) - - acdc_dfs_allpos_filepath = os.path.join( - allpos_dir, self.concat_df_filename + + acdc_objs_count_df_allpos_filename = self.concat_df_filename.replace( + "acdc_output", "acdc_objects_count" ) + acdc_dfs_allpos_filepath = os.path.join(allpos_dir, self.concat_df_filename) + self.logger.log( - 'Saving all positions concatenated file to ' + "Saving all positions concatenated file to " f'"{acdc_dfs_allpos_filepath}"' ) to_format_func = getattr(acdc_df_allpos, self._to_format) to_format_func(acdc_dfs_allpos_filepath) self.acdc_dfs_allpos_filepath = acdc_dfs_allpos_filepath - + if not acdc_objs_count_dfs: continue - + acdc_objs_count_df_allpos = pd.concat( - acdc_objs_count_dfs, names=['Position_n'] + acdc_objs_count_dfs, names=["Position_n"] ) - acdc_objs_count_df_allpos['experiment_folderpath'] = exp_path - + acdc_objs_count_df_allpos["experiment_folderpath"] = exp_path + acdc_objs_count_df_allpos_filepath = os.path.join( allpos_dir, acdc_objs_count_df_allpos_filename ) - + self.logger.log( - 'Saving all positions objects count file to ' + "Saving all positions objects count file to " f'"{acdc_objs_count_df_allpos_filepath}"' ) to_format_func = getattr(acdc_objs_count_df_allpos, self._to_format) to_format_func(acdc_objs_count_df_allpos_filepath) - - acdc_objs_count_dfs_allexp[(exp_path, exp_name)] = ( - acdc_objs_count_df_allpos - ) - + + acdc_objs_count_dfs_allexp[(exp_path, exp_name)] = acdc_objs_count_df_allpos + if len(keys_exp) <= 1: self.signals.finished.emit(self) return - - allExp_filename = f'multiExp_{self.concat_df_filename}' + + allExp_filename = f"multiExp_{self.concat_df_filename}" self.mutex.lock() self.sigAskFolder.emit(allExp_filename) self.waitCond.wait(self.mutex) @@ -3340,34 +3414,31 @@ def run(self): if self.abort: self.sigAborted.emit() return - + acdc_df_allexp = pd.concat( - acdc_dfs_allexp, keys=keys_exp, - names=['experiment_folderpath', 'experiment_foldername'] - ) - acdc_dfs_allexp_filepath = os.path.join( - self.allExpSaveFolder, allExp_filename + acdc_dfs_allexp, + keys=keys_exp, + names=["experiment_folderpath", "experiment_foldername"], ) + acdc_dfs_allexp_filepath = os.path.join(self.allExpSaveFolder, allExp_filename) self.logger.log( - 'Saving multiple experiments concatenated file to ' + "Saving multiple experiments concatenated file to " f'"{acdc_dfs_allexp_filepath}"' ) to_format_func = getattr(acdc_df_allexp, self._to_format) to_format_func(acdc_dfs_allexp_filepath) - + if acdc_objs_count_dfs_allexp: - allexp_count_df_filename = ( - f'multiExp_{acdc_objs_count_df_allpos_filename}' - ) + allexp_count_df_filename = f"multiExp_{acdc_objs_count_df_allpos_filename}" acdc_objs_count_df_allexp = pd.concat( acdc_objs_count_dfs_allexp, - names=['experiment_folderpath', 'experiment_foldername'] + names=["experiment_folderpath", "experiment_foldername"], ) acdc_objs_count_df_allexp_filepath = os.path.join( self.allExpSaveFolder, allexp_count_df_filename ) self.logger.log( - 'Saving multiple experiments concatenated file to ' + "Saving multiple experiments concatenated file to " f'"{acdc_objs_count_df_allexp_filepath}"' ) to_format_func = getattr(acdc_objs_count_df_allexp, self._to_format) @@ -3375,22 +3446,24 @@ def run(self): self.signals.finished.emit(self) + class FromImajeJroiToSegmNpzWorker(BaseWorkerUtil): sigSelectRoisProps = Signal(str, object, bool) - + def __init__(self, mainWin): super().__init__(mainWin) - + def emitSelectRoisProps(self, roi_filepath, TZYX_shape, is_multi_pos): self.mutex.lock() self.sigSelectRoisProps.emit(roi_filepath, TZYX_shape, is_multi_pos) self.waitCond.wait(self.mutex) self.mutex.unlock() return self.abort - + @worker_exception_handler def run(self): import roifile + expPaths = self.mainWin.expPaths tot_exp = len(expPaths) self.signals.initProgressBar.emit(0) @@ -3399,12 +3472,12 @@ def run(self): tot_pos = len(pos_foldernames) abort = self.emitSelectFilesWithText( - exp_path, pos_foldernames, 'imagej_rois', ext='.zip' + exp_path, pos_foldernames, "imagej_rois", ext=".zip" ) if abort: self.signals.finished.emit(self) return - + self.askRoiPreferences = True for p, pos in enumerate(pos_foldernames): if self.abort: @@ -3412,31 +3485,32 @@ def run(self): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") endFilenameRoi = self.mainWin.endFilenameWithText ls = myutils.listdir(images_path) rois_filepaths = [ - os.path.join(images_path, f) for f in ls - if f.endswith(f'{endFilenameRoi}.zip') + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameRoi}.zip") ] - + if not rois_filepaths: self.logger.log( - '[WARNING]: The following Position folder does not ' - f'contain any file ending with {endFilenameRoi}. ' + "[WARNING]: The following Position folder does not " + f"contain any file ending with {endFilenameRoi}. " f'Skipping it. "{os.path.join(exp_path, pos)}")' ) continue - + rois_filepath = rois_filepaths[0] - + if self.askRoiPreferences: is_multi_pos = len(pos_foldernames) > 1 - self.logger.log('Loading image data to get image shape...') + self.logger.log("Loading image data to get image shape...") TZYX_shape = load.get_tzyx_shape(images_path) abort = self.emitSelectRoisProps( rois_filepath, TZYX_shape, is_multi_pos @@ -3444,41 +3518,40 @@ def run(self): if abort: self.signals.finished.emit(self) return - + self.askRoiPreferences = not self.useSamePropsForNextPos elif self.areAllRoisSelected: rois = roifile.roiread(rois_filepath) - self.IDsToRoisMapper = {i+i: roi for roi in enumerate(rois)} + self.IDsToRoisMapper = {i + i: roi for roi in enumerate(rois)} else: # Use same ID of previous position rois = roifile.roiread(rois_filepath) - IDsToRoisMapper = {i+i: roi for i, roi in enumerate(rois)} + IDsToRoisMapper = {i + i: roi for i, roi in enumerate(rois)} self.IDsToRoisMapper = { - ID: IDsToRoisMapper[ID] - for ID in self.IDsToRoisMapper.keys() + ID: IDsToRoisMapper[ID] for ID in self.IDsToRoisMapper.keys() } - - self.logger.log('Generating segm mask from ROIs...') + + self.logger.log("Generating segm mask from ROIs...") segm_data = myutils.from_imagej_rois_to_segm_data( - TZYX_shape, self.IDsToRoisMapper, self.rescaleRoisSizes, - self.repeatRoisZslicesRange + TZYX_shape, + self.IDsToRoisMapper, + self.rescaleRoisSizes, + self.repeatRoisZslicesRange, ) - - - segm_filepath = (rois_filepath - .replace('imagej_rois', 'segm') - .replace('.zip', '.npz') + + segm_filepath = rois_filepath.replace("imagej_rois", "segm").replace( + ".zip", ".npz" ) self.logger.log(f'Saving segm mask to "{segm_filepath}"...') io.savez_compressed(segm_filepath, segm_data) - + self.signals.finished.emit(self) - - + + class ToImajeJroiWorker(BaseWorkerUtil): def __init__(self, mainWin): super().__init__(mainWin) - + @worker_exception_handler def run(self): from roifile import ImagejRoi, roiwrite @@ -3495,39 +3568,40 @@ def run(self): if abort: self.signals.finished.emit(self) return - + for p, pos in enumerate(pos_foldernames): if self.abort: self.signals.finished.emit(self) return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") endFilenameSegm = self.mainWin.endFilenameSegm ls = myutils.listdir(images_path) - + files_path = [ - os.path.join(images_path, f) for f in ls - if f.endswith(f'{endFilenameSegm}.npz') + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") ] - + if not files_path: self.logger.log( - '[WARNING]: The following Position folder does not ' - f'contain any file ending with {endFilenameSegm}. ' + "[WARNING]: The following Position folder does not " + f"contain any file ending with {endFilenameSegm}. " f'Skipping it. "{os.path.join(exp_path, pos)}")' ) continue - + file_path = files_path[0] - - posData = load.loadData(file_path, '') - self.signals.sigUpdatePbarDesc.emit(f'Processing {posData.pos_path}') + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") posData.getBasenameAndChNames() posData.buildPaths() @@ -3535,25 +3609,22 @@ def run(self): posData.loadOtherFiles( load_segm_data=True, load_metadata=True, - end_filename_segm=endFilenameSegm + end_filename_segm=endFilenameSegm, ) - + if posData.SizeT > 1: rois = [] max_ID = posData.segm_data.max() for t, lab in enumerate(posData.segm_data): rois_t = myutils.from_lab_to_imagej_rois( - lab, ImagejRoi, t=t, SizeT=posData.SizeT, - max_ID=max_ID + lab, ImagejRoi, t=t, SizeT=posData.SizeT, max_ID=max_ID ) rois.extend(rois_t) else: - rois = myutils.from_lab_to_imagej_rois( - posData.segm_data, ImagejRoi - ) + rois = myutils.from_lab_to_imagej_rois(posData.segm_data, ImagejRoi) - roi_filepath = posData.segm_npz_path.replace('.npz', '.zip') - roi_filepath = roi_filepath.replace('_segm', '_imagej_rois') + roi_filepath = posData.segm_npz_path.replace(".npz", ".zip") + roi_filepath = roi_filepath.replace("_segm", "_imagej_rois") try: os.remove(roi_filepath) @@ -3561,7 +3632,7 @@ def run(self): pass roiwrite(roi_filepath, rois) - + self.signals.finished.emit(self) @@ -3596,7 +3667,7 @@ def run(self): tot_pos = len(pos_foldernames) self.allPosDataInputs = [] posDatas = [] - self.logger.log('-'*30) + self.logger.log("-" * 30) expFoldername = os.path.basename(exp_path) abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) @@ -3611,17 +3682,17 @@ def run(self): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") basename, chNames = myutils.getBasenameAndChNames( - images_path, useExt=('.tif', '.h5') + images_path, useExt=(".tif", ".h5") ) - self.signals.sigUpdatePbarDesc.emit(f'Loading {pos_path}...') + self.signals.sigUpdatePbarDesc.emit(f"Loading {pos_path}...") # Use first found channel, it doesn't matter for metrics for chName in chNames: @@ -3636,71 +3707,67 @@ def run(self): # Load data posData = load.loadData(file_path, chName) - posData.getBasenameAndChNames(useExt=('.tif', '.h5')) + posData.getBasenameAndChNames(useExt=(".tif", ".h5")) posData.loadOtherFiles( load_segm_data=False, load_acdc_df=True, load_metadata=True, - loadSegmInfo=True + loadSegmInfo=True, ) posDatas.append(posData) - self.allPosDataInputs.append({ - 'file_path': file_path, - 'chName': chName - }) - + self.allPosDataInputs.append({"file_path": file_path, "chName": chName}) + # Iterate pos and calculate metrics numPos = len(self.allPosDataInputs) for p, posDataInputs in enumerate(self.allPosDataInputs): - file_path = posDataInputs['file_path'] - chName = posDataInputs['chName'] + file_path = posDataInputs["file_path"] + chName = posDataInputs["chName"] posData = load.loadData(file_path, chName) - self.signals.sigUpdatePbarDesc.emit(f'Processing {posData.pos_path}') + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") - posData.getBasenameAndChNames(useExt=('.tif', '.h5')) + posData.getBasenameAndChNames(useExt=(".tif", ".h5")) posData.buildPaths() posData.loadImgData() posData.loadOtherFiles( load_segm_data=False, load_acdc_df=True, - end_filename_segm=self.mainWin.endFilenameSegm + end_filename_segm=self.mainWin.endFilenameSegm, ) if not posData.acdc_df_found: relPath = ( - f'...{os.sep}{expFoldername}' - f'{os.sep}{posData.pos_foldername}' + f"...{os.sep}{expFoldername}{os.sep}{posData.pos_foldername}" ) self.logger.log( f'WARNING: Skipping "{relPath}" ' - f'because acdc_output.csv file was not found.' + f"because acdc_output.csv file was not found." ) self.missingAnnotErrors[relPath] = ( f'
    FileNotFoundError: the Positon "{relPath}" ' - 'does not have the acdc_output.csv file.
    ') - + "does not have the acdc_output.csv file.
    " + ) + continue - + acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) self.logger.log( - 'Loaded path:\n' - f'ACDC output file name: "{acdc_df_filename}"' + f'Loaded path:\nACDC output file name: "{acdc_df_filename}"' ) - self.logger.log('Building tree...') + self.logger.log("Building tree...") try: tree = core.LineageTree(posData.acdc_df) error = tree.build() if isinstance(error, KeyError): self.logger.log(str(error)) - + self.logger.log( - 'WARNING: Annotations missing in ' + "WARNING: Annotations missing in " f'"{posData.acdc_output_csv_path}"' ) self.missingAnnotErrors[acdc_df_filename] = str(error) @@ -3712,7 +3779,7 @@ def run(self): traceback_format = traceback.format_exc() self.logger.log(traceback_format) self.errors[error] = traceback_format - + try: posData.acdc_df.to_csv(posData.acdc_output_csv_path) except PermissionError: @@ -3724,11 +3791,12 @@ def run(self): self.waitCond.wait(self.mutex) self.mutex.unlock() posData.acdc_df.to_csv(posData.acdc_output_csv_path) - + self.signals.progressBar.emit(1) - + self.signals.finished.emit(self) + class AlignWorker(BaseWorkerUtil): sigAborted = Signal() sigAskUseSavedShifts = Signal(str, str) @@ -3736,14 +3804,14 @@ class AlignWorker(BaseWorkerUtil): def __init__(self, mainWin): super().__init__(mainWin) - + def emitAskUseSavedShifts(self, expPath, basename): self.mutex.lock() self.sigAskUseSavedShifts.emit(expPath, basename) self.waitCond.wait(self.mutex) self.mutex.unlock() return self.abort - + def emitAskSelectChannel(self, channels): self.mutex.lock() self.sigAskSelectChannel.emit(channels) @@ -3762,21 +3830,21 @@ def run(self): shiftsFound = False for pos in pos_foldernames: - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") ls = myutils.listdir(images_path) for file in ls: - if file.endswith('align_shift.npy'): + if file.endswith("align_shift.npy"): shiftsFound = True basename, chNames = myutils.getBasenameAndChNames( - images_path, useExt=('.tif', '.h5') + images_path, useExt=(".tif", ".h5") ) break if shiftsFound: break - + savedShiftsHow = None if shiftsFound: - basename_ch0 = f'{basename}{chNames[0]}_' + basename_ch0 = f"{basename}{chNames[0]}_" abort = self.emitAskUseSavedShifts(exp_path, basename_ch0) if abort: self.sigAborted.emit() @@ -3790,163 +3858,164 @@ def run(self): self.sigAborted.emit() return - self.logger.log('*'*40) + self.logger.log("*" * 40) self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, 'Images') + images_path = os.path.join(pos_path, "Images") basename, chNames = myutils.getBasenameAndChNames( - images_path, useExt=('.tif', '.h5') + images_path, useExt=(".tif", ".h5") ) - self.signals.sigUpdatePbarDesc.emit(f'Loading {pos_path}...') + self.signals.sigUpdatePbarDesc.emit(f"Loading {pos_path}...") if p == 0: - self.logger.log(f'Asking to select reference channel...') + self.logger.log(f"Asking to select reference channel...") abort = self.emitAskSelectChannel(chNames) if abort: self.sigAborted.emit() return chName = self.chName - + file_path = myutils.getChannelFilePath(images_path, chName) # Load data posData = load.loadData(file_path, chName) - posData.getBasenameAndChNames(useExt=('.tif', '.h5')) + posData.getBasenameAndChNames(useExt=(".tif", ".h5")) posData.buildPaths() posData.loadImgData() posData.loadOtherFiles( - load_segm_data=False, - load_shifts=True, - loadSegmInfo=True + load_segm_data=False, load_shifts=True, loadSegmInfo=True ) if posData.img_data.ndim == 4: align_func = core.align_frames_3D if posData.segmInfo_df is None: raise FileNotFoundError( - 'To align 4D data you need to select which z-slice ' - 'you want to use for alignment. Please run the module ' - '`1. Launch data prep module...` before aligning the ' - 'frames. (z-slice info MISSING from position ' + "To align 4D data you need to select which z-slice " + "you want to use for alignment. Please run the module " + "`1. Launch data prep module...` before aligning the " + "frames. (z-slice info MISSING from position " f'"{posData.relPath}")' ) df = posData.segmInfo_df.loc[posData.filename] - zz = df['z_slice_used_dataPrep'].to_list() + zz = df["z_slice_used_dataPrep"].to_list() elif posData.img_data.ndim == 3: align_func = core.align_frames_2D zz = None - + useSavedShifts = ( - savedShiftsHow == 'use_saved_shifts' + savedShiftsHow == "use_saved_shifts" and posData.loaded_shifts is not None ) if useSavedShifts: user_shifts = posData.loaded_shifts else: user_shifts = None - - if savedShiftsHow == 'rever_alignment': + + if savedShiftsHow == "rever_alignment": if posData.loaded_shifts is None: self.logger.log( f'WARNING: Cannot revert alignment in "{posData.relPath}" ' - 'since it is missing previously computed shifts. ' - 'Skipping this positon.' + "since it is missing previously computed shifts. " + "Skipping this positon." ) continue - + # Revert alignment and save selected channel for chName in chNames: - self.logger.log( - f'Reverting alignment on "{chName}"...' - ) + self.logger.log(f'Reverting alignment on "{chName}"...') if chName == posData.user_ch_name: data = posData.img_data else: - file_path = myutils.getChannelFilePath( - images_path, chName - ) + file_path = myutils.getChannelFilePath(images_path, chName) data = load.load_image_file(file_path) - - self.signals.sigInitInnerPbar.emit(len(data)-1) + + self.signals.sigInitInnerPbar.emit(len(data) - 1) revertedData = core.revert_alignment( - posData.loaded_shifts, data, - sigPyqt=self.signals.sigUpdateInnerPbar - ) - self.logger.log( - f'Saving "{chName}"...' + posData.loaded_shifts, + data, + sigPyqt=self.signals.sigUpdateInnerPbar, ) + self.logger.log(f'Saving "{chName}"...') self.signals.sigInitInnerPbar.emit(0) self.saveAlignedData( - revertedData, images_path, posData.basename, - chName, self.revertedAlignEndname, - ext=posData.ext + revertedData, + images_path, + posData.basename, + chName, + self.revertedAlignEndname, + ext=posData.ext, ) del revertedData, data else: for chName in chNames: - self.logger.log( - f'Aligning "{chName}"...' - ) + self.logger.log(f'Aligning "{chName}"...') if chName == posData.user_ch_name: data = posData.img_data else: - file_path = myutils.getChannelFilePath( - images_path, chName - ) + file_path = myutils.getChannelFilePath(images_path, chName) data = load.load_image_file(file_path) - self.signals.sigInitInnerPbar.emit(len(data)-1) - + self.signals.sigInitInnerPbar.emit(len(data) - 1) + alignedImgData, shifts = align_func( - data, slices=zz, user_shifts=user_shifts, - sigPyqt=self.signals.sigUpdateInnerPbar + data, + slices=zz, + user_shifts=user_shifts, + sigPyqt=self.signals.sigUpdateInnerPbar, ) self.logger.log(f'Saving "{chName}"...') np.save(posData.align_shifts_path, shifts) - + self.signals.sigInitInnerPbar.emit(0) self.saveAlignedData( - alignedImgData, images_path, posData.basename, - chName, '', ext=posData.non_aligned_ext + alignedImgData, + images_path, + posData.basename, + chName, + "", + ext=posData.non_aligned_ext, ) self.saveAlignedData( - alignedImgData, images_path, posData.basename, - chName, 'aligned', ext='.npz' + alignedImgData, + images_path, + posData.basename, + chName, + "aligned", + ext=".npz", ) del alignedImgData, data - + self.signals.finished.emit(self) - - def saveAlignedData( - self, data, imagesPath, basename, chName, endname, ext='.tif' - ): + + def saveAlignedData(self, data, imagesPath, basename, chName, endname, ext=".tif"): if endname: - newFilename = f'{basename}{chName}_{endname}{ext}' + newFilename = f"{basename}{chName}_{endname}{ext}" else: - newFilename = f'{basename}{chName}{ext}' - + newFilename = f"{basename}{chName}{ext}" + filePath = os.path.join(imagesPath, newFilename) - if ext == '.tif': + if ext == ".tif": SizeT = data.shape[0] SizeZ = 1 if data.ndim == 4: SizeZ = data.shape[1] myutils.to_tiff(filePath, data) - elif ext == '.npz': + elif ext == ".npz": io.savez_compressed(filePath, data) - elif ext == '.h5': + elif ext == ".h5": load.save_to_h5(filePath, data) + class ToObjCoordsWorker(BaseWorkerUtil): def __init__(self, mainWin): super().__init__(mainWin) - + @worker_exception_handler def run(self): debugging = False @@ -3961,28 +4030,29 @@ def run(self): if abort: self.signals.finished.emit(self) return - + for p, pos in enumerate(pos_foldernames): if self.abort: self.signals.finished.emit(self) return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") endFilenameSegm = self.mainWin.endFilenameSegm ls = myutils.listdir(images_path) file_path = [ - os.path.join(images_path, f) for f in ls - if f.endswith(f'{endFilenameSegm}.npz') + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") ][0] - - posData = load.loadData(file_path, '') - self.signals.sigUpdatePbarDesc.emit(f'Processing {posData.pos_path}') + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") posData.getBasenameAndChNames() posData.buildPaths() @@ -3990,12 +4060,12 @@ def run(self): posData.loadOtherFiles( load_segm_data=True, load_metadata=True, - end_filename_segm=endFilenameSegm + end_filename_segm=endFilenameSegm, ) if posData.SizeT == 1: posData.segm_data = posData.segm_data[np.newaxis] - + dfs = [] n_frames = len(posData.segm_data) self.signals.initProgressBar.emit(n_frames) @@ -4003,17 +4073,18 @@ def run(self): df_coords_i = myutils.from_lab_to_obj_coords(lab) dfs.append(df_coords_i) self.signals.progressBar.emit(1) - df_filepath = posData.segm_npz_path.replace('.npz', '.csv') - df_filepath = df_filepath.replace('_segm', '_objects_coordinates') + df_filepath = posData.segm_npz_path.replace(".npz", ".csv") + df_filepath = df_filepath.replace("_segm", "_objects_coordinates") keys = list(range(len(posData.segm_data))) - df = pd.concat(dfs, keys=keys, names=['frame_i']) - + df = pd.concat(dfs, keys=keys, names=["frame_i"]) + self.signals.initProgressBar.emit(0) df.to_csv(df_filepath) - + self.signals.finished.emit(self) + class Stack2DsegmTo3Dsegm(BaseWorkerUtil): sigAskAppendName = Signal(str, list) sigAborted = Signal() @@ -4032,12 +4103,12 @@ def run(self): self.errors = {} tot_pos = len(pos_foldernames) - self.mainWin.infoText = f'Select 2D segmentation file to stack' + self.mainWin.infoText = f"Select 2D segmentation file to stack" abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) if abort: self.sigAborted.emit() return - + # Ask appendend name self.mutex.lock() self.sigAskAppendName.emit( @@ -4057,21 +4128,22 @@ def run(self): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") endFilenameSegm = self.mainWin.endFilenameSegm ls = myutils.listdir(images_path) file_path = [ - os.path.join(images_path, f) for f in ls - if f.endswith(f'{endFilenameSegm}.npz') + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") ][0] - - posData = load.loadData(file_path, '') - self.signals.sigUpdatePbarDesc.emit(f'Processing {posData.pos_path}') + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") posData.getBasenameAndChNames() posData.buildPaths() @@ -4080,13 +4152,13 @@ def run(self): load_segm_data=True, load_acdc_df=True, load_metadata=True, - end_filename_segm=endFilenameSegm + end_filename_segm=endFilenameSegm, ) if posData.segm_data.ndim == 2: posData.segm_data = posData.segm_data[np.newaxis] - - self.logger.log('Stacking 2D into 3D objects...') - + + self.logger.log("Stacking 2D into 3D objects...") + numFrames = len(posData.segm_data) self.signals.sigInitInnerPbar.emit(numFrames) T, Y, X = posData.segm_data.shape @@ -4098,16 +4170,17 @@ def run(self): self.signals.sigUpdateInnerPbar.emit(1) - self.logger.log('Saving stacked 3D segmentation file...') + self.logger.log("Saving stacked 3D segmentation file...") segmFilename, ext = os.path.splitext(posData.segm_npz_path) - newSegmFilepath = f'{segmFilename}_{appendedName}.npz' + newSegmFilepath = f"{segmFilename}_{appendedName}.npz" segmData2D = np.squeeze(segmData2D) io.savez_compressed(newSegmFilepath, segmData2D) - + self.signals.progressBar.emit(1) self.signals.finished.emit(self) + class MigrateUserProfileWorker(QObject): finished = Signal(object) critical = Signal(object) @@ -4120,62 +4193,66 @@ def __init__(self, src_path, dst_path, acdc_folders): self.src_path = src_path self.dst_path = dst_path self.acdc_folders = acdc_folders - + @worker_exception_handler def run(self): import shutil from . import models_path self.progress.emit( - 'Migrating user profile data from ' + "Migrating user profile data from " f'"{self.src_path}" to "{self.dst_path}"...' ) acdc_folders = self.acdc_folders - self.signals.initProgressBar.emit(2*len(acdc_folders)) + self.signals.initProgressBar.emit(2 * len(acdc_folders)) dst_folder = os.path.basename(self.dst_path) folders_to_remove = [] for acdc_folder in acdc_folders: if acdc_folder == dst_folder: - # Skip the destination folder that would be picked up if the + # Skip the destination folder that would be picked up if the # user called it with acdc at the start of the name self.signals.progressBar.emit(2) continue src = os.path.join(self.src_path, acdc_folder) dst = os.path.join(self.dst_path, acdc_folder) - self.progress.emit(f'Copying {src} to {dst}...') + self.progress.emit(f"Copying {src} to {dst}...") files_failed_move = copy_or_move_tree( - src, dst, copy=False, - sigInitPbar=self.signals.sigInitInnerPbar, - sigUpdatePbar=self.signals.sigUpdateInnerPbar + src, + dst, + copy=False, + sigInitPbar=self.signals.sigInitInnerPbar, + sigUpdatePbar=self.signals.sigUpdateInnerPbar, ) folders_to_remove.append(src) self.signals.progressBar.emit(1) - + for to_remove in folders_to_remove: try: self.progress.emit(f'Removing "{to_remove}"...') shutil.rmtree(to_remove) except Exception as err: self.progress.emit( - '--------------------------------------------------------\n' + "--------------------------------------------------------\n" f'[WARNING]: Removal of the folder "{to_remove}" failed. ' - 'Please remove manually.\n' - '--------------------------------------------------------' + "Please remove manually.\n" + "--------------------------------------------------------" ) finally: self.signals.progressBar.emit(1) - + # Update model's paths - load.migrate_models_paths(self.dst_path) - + load.migrate_models_paths(self.dst_path) + # Store user profile data folder path from . import user_profile_path_txt + os.makedirs(os.path.dirname(user_profile_path_txt), exist_ok=True) - with open(user_profile_path_txt, 'w') as txt: + with open(user_profile_path_txt, "w") as txt: txt.write(self.dst_path) - + self.finished.emit(self) + class DelObjectsOutsideSegmROIWorker(QObject): finished = Signal(object) critical = Signal(object) @@ -4183,17 +4260,17 @@ class DelObjectsOutsideSegmROIWorker(QObject): debug = Signal(object) def __init__( - self, - segm_roi_endname: os.PathLike, - segm_data: np.ndarray, - images_path: os.PathLike - ): + self, + segm_roi_endname: os.PathLike, + segm_data: np.ndarray, + images_path: os.PathLike, + ): QObject.__init__(self) self.signals = signals() self.segm_roi_endname = segm_roi_endname self.segm_data = segm_data self.images_path = images_path - + @worker_exception_handler def run(self): segm_roi_endname = self.segm_roi_endname @@ -4202,34 +4279,35 @@ def run(self): ) self.progress.emit(f'Loading segmentation file "{segm_roi_filepath}"...') segm_roi_data = load.load_image_file(segm_roi_filepath) - - self.progress.emit(f'Deleting objects outside of selected ROIs...') + + self.progress.emit(f"Deleting objects outside of selected ROIs...") cleared_segm_data, delIDs = transformation.del_objs_outside_segm_roi( segm_roi_data, self.segm_data ) - + self.finished.emit((self, cleared_segm_data, delIDs)) + class ConcatSpotmaxDfsWorker(BaseWorkerUtil): sigAborted = Signal() sigAskFolder = Signal(str) sigSetMeasurements = Signal(object) sigAskAppendName = Signal(str, list) - def __init__(self, mainWin, format='CSV'): + def __init__(self, mainWin, format="CSV"): super().__init__(mainWin) - if format.startswith('CSV'): - self._final_ext = '.csv' - elif format.startswith('XLS'): - self._final_ext = '.xlsx' + if format.startswith("CSV"): + self._final_ext = ".csv" + elif format.startswith("XLS"): + self._final_ext = ".xlsx" self.acdcOutputEndname = None - + def emitSetMeasurements(self, kwargs): self.mutex.lock() self.sigSetMeasurements.emit(kwargs) self.waitCond.wait(self.mutex) self.mutex.unlock() - + def emitAskAppendName(self, allPos_spotmax_df_basename): # Ask appendend name self.mutex.lock() @@ -4242,71 +4320,71 @@ def emitAskCopyCca(self, images_path): self.signals.sigAskCopyCca.emit(images_path) self.waitCond.wait(self.mutex) self.mutex.unlock() - + def setAcdcOutputEndname(self, acdcOutputEndname): self.acdcOutputEndname = acdcOutputEndname - + def getAcdcDf(self, images_path): if self.acdcOutputEndname is None: return - + for file in myutils.listdir(images_path): if not file.endswith(self.acdcOutputEndname): continue - + filepath = os.path.join(images_path, file) - acdc_df = pd.read_csv(filepath, index_col=['frame_i', 'Cell_ID']) + acdc_df = pd.read_csv(filepath, index_col=["frame_i", "Cell_ID"]) return acdc_df - + def copyCcaColsFromAcdcDf(self, df, acdc_df, debug=False): if acdc_df is None: return df - + if debug: printl(acdc_df.columns.to_list(), pretty=True) - + idx = df.index.intersection(acdc_df.index) for col in cca_df_colnames: if col not in acdc_df.columns: continue - + if col not in self.selectedColumns: continue - + df.loc[idx, col] = acdc_df.loc[idx, col] - + for col in lineage_tree_cols: if col not in acdc_df.columns: continue - + if col not in self.selectedColumns: continue - + df.loc[idx, col] = acdc_df.loc[idx, col] - + for col in default_annot_df.keys(): if col not in acdc_df.columns: continue - + if col not in self.selectedColumns: continue - + df.loc[idx, col] = acdc_df.loc[idx, col] - + for col in self.selectedColumns: if col not in acdc_df.columns: continue - + df.loc[idx, col] = acdc_df.loc[idx, col] - - if debug and col == 'cell_vol_fl': + + if debug and col == "cell_vol_fl": printl(df[[col]]) - + return df - + def emitAskFolderWhereToSaveMultiExp(self): self.mutex.lock() - self.sigAskFolder.emit('') + self.sigAskFolder.emit("") self.waitCond.wait(self.mutex) self.mutex.unlock() if self.abort: @@ -4314,7 +4392,7 @@ def emitAskFolderWhereToSaveMultiExp(self): return return self.allExpSaveFolder - + def askSelectMeasurements(self, exp_path, posFoldernames): acdc_dfs = [] keys = [] @@ -4323,50 +4401,50 @@ def askSelectMeasurements(self, exp_path, posFoldernames): self.sigAborted.emit() return False - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") acdc_df = self.getAcdcDf(images_path) if acdc_df is None: continue - + acdc_dfs.append(acdc_df) keys.append(pos) - + if not acdc_dfs: return True - + acdc_df_allpos = pd.concat( - acdc_dfs, keys=keys, names=['Position_n', 'frame_i', 'Cell_ID'] + acdc_dfs, keys=keys, names=["Position_n", "frame_i", "Cell_ID"] ) - acdc_df_allpos['experiment_folderpath'] = exp_path + acdc_df_allpos["experiment_folderpath"] = exp_path basename, chNames = myutils.getBasenameAndChNames( - images_path, useExt=('.tif', '.h5') + images_path, useExt=(".tif", ".h5") ) df_metadata = load.load_metadata_df(images_path) - SizeZ = df_metadata.at['SizeZ', 'values'] + SizeZ = df_metadata.at["SizeZ", "values"] SizeZ = int(float(SizeZ)) existing_colnames = acdc_df_allpos.columns - isSegm3D = any([col.endswith('3D') for col in existing_colnames]) - + isSegm3D = any([col.endswith("3D") for col in existing_colnames]) + kwargs = { - 'loadedChNames': chNames, - 'notLoadedChNames': [], - 'isZstack': SizeZ > 1, - 'isSegm3D': isSegm3D, - 'existing_colnames': existing_colnames + "loadedChNames": chNames, + "notLoadedChNames": [], + "isZstack": SizeZ > 1, + "isSegm3D": isSegm3D, + "existing_colnames": existing_colnames, } self.emitSetMeasurements(kwargs) if self.abort: self.sigAborted.emit() return False - + return True - + @worker_exception_handler def run(self): from spotmax import DFs_FILENAMES, DF_REF_CH_FILENAME from spotmax.utils import get_runs_num_and_desc import spotmax.io - + self.selectedColumns = None debugging = False expPaths = self.mainWin.expPaths @@ -4380,29 +4458,29 @@ def run(self): for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): self.errors = {} tot_pos = len(pos_foldernames) - - all_runs = get_runs_num_and_desc( - exp_path, pos_foldernames=pos_foldernames - ) + + all_runs = get_runs_num_and_desc(exp_path, pos_foldernames=pos_foldernames) if not all_runs: self.logger.log( - '[WARNING] The following experiment does not contain ' + "[WARNING] The following experiment does not contain " f'valid spotMAX output files. Skipping it. "{exp_path}"' ) continue - + if not runNumberAlreadyAsked: abort = self.emitSelectSpotmaxRun( - exp_path, pos_foldernames, all_runs, - infoText=' to combine', - allowSingleSelection=True, - multiSelection=False + exp_path, + pos_foldernames, + all_runs, + infoText=" to combine", + allowSingleSelection=True, + multiSelection=False, ) if abort: self.sigAborted.emit() return runNumberAlreadyAsked = True - + selectedSpotmaxRuns = self.mainWin.selectedSpotmaxRuns self.signals.initProgressBar.emit(len(pos_foldernames)) @@ -4416,273 +4494,274 @@ def run(self): if self.abort: self.sigAborted.emit() return - + pos_path = os.path.join(exp_path, pos) - spotmax_output_path = os.path.join(pos_path, 'spotMAX_output') - + spotmax_output_path = os.path.join(pos_path, "spotMAX_output") + if not os.path.exists(spotmax_output_path): self.logger.log( - '[WARNING] The following Position folder does not contain ' + "[WARNING] The following Position folder does not contain " f'valid spotMAX output files. Skipping it. "{pos_path}"' ) continue - - images_path = os.path.join(exp_path, pos, 'Images') - + + images_path = os.path.join(exp_path, pos, "Images") + if not copyFromCcaAlreadyAsked: self.emitAskCopyCca(images_path) if self.abort: self.sigAborted.emit() return - + self.askSelectMeasurements(exp_path, pos_foldernames) if self.abort: - return - copyFromCcaAlreadyAsked = True - + return + copyFromCcaAlreadyAsked = True + acdc_df = self.getAcdcDf(images_path) - + self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - for run_desc in selectedSpotmaxRuns: - run, desc = run_desc.split('_...') - ini_filename = f'{run}_analysis_parameters{desc}.ini' - ini_filepath = os.path.join( - spotmax_output_path, ini_filename - ) + run, desc = run_desc.split("_...") + ini_filename = f"{run}_analysis_parameters{desc}.ini" + ini_filepath = os.path.join(spotmax_output_path, ini_filename) if not os.path.exists(ini_filepath): self.logger.log( - '[WARNING] The following Position folder does not contain ' - f'the spotMAX output file for run number {run}. ' + "[WARNING] The following Position folder does not contain " + f"the spotMAX output file for run number {run}. " f'Skipping it. "{pos_path}"' ) continue - + pos_ini_filepaths[(run, desc)] = ini_filepath for _, pattern_filename in DFs_FILENAMES.items(): - run_filename = pattern_filename.replace('*rn*', run) - run_filename = run_filename.replace('*desc*', desc) - aggr_filename = f'{run_filename}_aggregated.csv' - aggr_filepath = os.path.join( - spotmax_output_path, aggr_filename - ) + run_filename = pattern_filename.replace("*rn*", run) + run_filename = run_filename.replace("*desc*", desc) + aggr_filename = f"{run_filename}_aggregated.csv" + aggr_filepath = os.path.join(spotmax_output_path, aggr_filename) if not os.path.exists(aggr_filepath): - continue - - df_spots_filename = f'{run_filename}.h5' + continue + + df_spots_filename = f"{run_filename}.h5" spots_filepath = os.path.join( spotmax_output_path, df_spots_filename ) - ext_spots = '.h5' + ext_spots = ".h5" if not os.path.exists(spots_filepath): - df_spots_filename = f'{run_filename}.csv' + df_spots_filename = f"{run_filename}.csv" spots_filepath = os.path.join( spotmax_output_path, df_spots_filename ) - ext_spots = '.csv' - + ext_spots = ".csv" + if not os.path.exists(spots_filepath): continue - + analysis_step = re.findall( - r'\*rn\*(.*)\*desc\*', pattern_filename + r"\*rn\*(.*)\*desc\*", pattern_filename )[0] key = (run, analysis_step, desc, ext_spots) try: - df_spots = spotmax.io.load_spots_table( - spotmax_output_path, df_spots_filename - ).reset_index().set_index(['frame_i', 'Cell_ID']) + df_spots = ( + spotmax.io.load_spots_table( + spotmax_output_path, df_spots_filename + ) + .reset_index() + .set_index(["frame_i", "Cell_ID"]) + ) df_spots = self.copyCcaColsFromAcdcDf( df_spots, acdc_df, debug=False ) - df_spots = ( - df_spots.reset_index() - .set_index(['frame_i', 'Cell_ID', 'spot_id']) + df_spots = df_spots.reset_index().set_index( + ["frame_i", "Cell_ID", "spot_id"] ) dfs_spots[key].append(df_spots) except Exception as err: - self.logger.log(str(err), level='ERROR') + self.logger.log(str(err), level="ERROR") self.logger.log( - 'WARNING: Error when reading single-spots ' - 'tables (possibly because there are no spots). ' - 'Skipping this Position.', - level='WARNING' + "WARNING: Error when reading single-spots " + "tables (possibly because there are no spots). " + "Skipping this Position.", + level="WARNING", ) pass - + df_aggregated = pd.read_csv( - aggr_filepath, index_col=['frame_i', 'Cell_ID'] + aggr_filepath, index_col=["frame_i", "Cell_ID"] ) df_aggregated = self.copyCcaColsFromAcdcDf( df_aggregated, acdc_df ) dfs_aggr[key].append(df_aggregated) pos_runs[key].append(pos) - + ref_ch_id_text = re.findall( - r'\*rn\*(.*)\*desc\*', DF_REF_CH_FILENAME + r"\*rn\*(.*)\*desc\*", DF_REF_CH_FILENAME )[0] - ref_ch_filename = ( - DF_REF_CH_FILENAME.replace('*rn*', run) - ) - ref_ch_filename = ( - ref_ch_filename.replace('*desc*', desc) - ) - ref_ch_filepath = os.path.join( - spotmax_output_path, ref_ch_filename - ) + ref_ch_filename = DF_REF_CH_FILENAME.replace("*rn*", run) + ref_ch_filename = ref_ch_filename.replace("*desc*", desc) + ref_ch_filepath = os.path.join(spotmax_output_path, ref_ch_filename) if not os.path.exists(ref_ch_filepath): continue - + df_ref_ch = pd.read_csv( - ref_ch_filepath, index_col=['frame_i', 'Cell_ID'] + ref_ch_filepath, index_col=["frame_i", "Cell_ID"] ) df_ref_ch = self.copyCcaColsFromAcdcDf(df_ref_ch, acdc_df) ref_ch_key = (run, ref_ch_id_text, desc) dfs_ref_ch[ref_ch_key].append(df_ref_ch) pos_runs_ref_ch[ref_ch_key].append(pos) - self.signals.progressBar.emit(1) - + self.signals.progressBar.emit(1) + self.signals.initProgressBar.emit(0) - - self.logger.log('Saving concantenated files...') - - allpos_folderpath = os.path.join(exp_path, 'spotMAX_multipos_output') + + self.logger.log("Saving concantenated files...") + + allpos_folderpath = os.path.join(exp_path, "spotMAX_multipos_output") os.makedirs(allpos_folderpath, exist_ok=True) - + exp_name = os.path.basename(exp_path) for key, dfs in dfs_spots.items(): pos_keys = pos_runs[key] run, analysis_step, desc, ext_spots = key - - if ext_spots == '.csv': + + if ext_spots == ".csv": ext_spots = self._final_ext - filename = f'multipos_{run}{analysis_step}{desc}{ext_spots}' + filename = f"multipos_{run}{analysis_step}{desc}{ext_spots}" all_exp_key = filename df_spots_concat = spotmax.io.save_concat_dfs( - dfs, pos_keys, allpos_folderpath, filename, ext_spots, - names=['Position_n'], return_concat_df=True - ) - df_spots_concat['experiment_foldername'] = exp_name - df_spots_concat['experiment_folderpath'] = exp_path - spotmax_dfs_spots_allexp[all_exp_key]['dfs'].append( - df_spots_concat - ) - spotmax_dfs_spots_allexp[all_exp_key]['keys'].append( - exp_path + dfs, + pos_keys, + allpos_folderpath, + filename, + ext_spots, + names=["Position_n"], + return_concat_df=True, ) + df_spots_concat["experiment_foldername"] = exp_name + df_spots_concat["experiment_folderpath"] = exp_path + spotmax_dfs_spots_allexp[all_exp_key]["dfs"].append(df_spots_concat) + spotmax_dfs_spots_allexp[all_exp_key]["keys"].append(exp_path) ini_filepath = pos_ini_filepaths[(run, desc)] ini_filename = os.path.basename(ini_filepath) dst_ini_filepath = os.path.join(allpos_folderpath, ini_filename) if not os.path.exists(dst_ini_filepath): shutil.copy2(ini_filepath, dst_ini_filepath) - - spotmax_dfs_spots_allexp[all_exp_key]['ini_filepath'].append( + + spotmax_dfs_spots_allexp[all_exp_key]["ini_filepath"].append( dst_ini_filepath ) - + for key, dfs in dfs_aggr.items(): pos_keys = pos_runs[key] run, analysis_step, desc, _ = key filename = ( - f'multipos_{run}{analysis_step}{desc}' - f'_aggregated{self._final_ext}' + f"multipos_{run}{analysis_step}{desc}_aggregated{self._final_ext}" ) all_exp_aggr_key = filename df_aggr_concat = spotmax.io.save_concat_dfs( - dfs, pos_keys, allpos_folderpath, filename, self._final_ext, - names=['Position_n'], return_concat_df=True + dfs, + pos_keys, + allpos_folderpath, + filename, + self._final_ext, + names=["Position_n"], + return_concat_df=True, ) - spotmax_dfs_aggr_allexp[all_exp_aggr_key]['dfs'].append( - df_aggr_concat - ) - spotmax_dfs_aggr_allexp[all_exp_aggr_key]['keys'].append( + spotmax_dfs_aggr_allexp[all_exp_aggr_key]["dfs"].append(df_aggr_concat) + spotmax_dfs_aggr_allexp[all_exp_aggr_key]["keys"].append( (exp_path, exp_name) ) - + for key, dfs in dfs_ref_ch.items(): run, ref_ch_id_text, desc = key pos_keys = pos_runs_ref_ch[key] - filename = ( - f'multipos_{run}{ref_ch_id_text}{desc}{self._final_ext}' - ) + filename = f"multipos_{run}{ref_ch_id_text}{desc}{self._final_ext}" all_exp_ref_ch_key = filename df_ref_ch_concat = spotmax.io.save_concat_dfs( - dfs, pos_keys, allpos_folderpath, filename, self._final_ext, - names=['Position_n'], return_concat_df=True + dfs, + pos_keys, + allpos_folderpath, + filename, + self._final_ext, + names=["Position_n"], + return_concat_df=True, ) - ref_ch_dfs_allexp[all_exp_ref_ch_key]['dfs'].append( - df_ref_ch_concat - ) - ref_ch_dfs_allexp[all_exp_ref_ch_key]['keys'].append( + ref_ch_dfs_allexp[all_exp_ref_ch_key]["dfs"].append(df_ref_ch_concat) + ref_ch_dfs_allexp[all_exp_ref_ch_key]["keys"].append( (exp_path, exp_name) ) - - multiexp_dst_folderpath = '' + + multiexp_dst_folderpath = "" if len(expPaths) == 1: self.signals.finished.emit(self) return - + multiexp_dst_folderpath = self.emitAskFolderWhereToSaveMultiExp() printl(multiexp_dst_folderpath) if multiexp_dst_folderpath is None: return - + self.logger.log( f'Saving multi-experiment files to "{multiexp_dst_folderpath}"...' ) - names = ['experiment_folderpath', 'experiment_foldername'] + names = ["experiment_folderpath", "experiment_foldername"] for filename, items in spotmax_dfs_spots_allexp.items(): - keys = items['keys'] - dfs = items['dfs'] - multiexp_filename = f'multiexp_{filename}' + keys = items["keys"] + dfs = items["dfs"] + multiexp_filename = f"multiexp_{filename}" extension = os.path.splitext(filename)[-1] spotmax.io.save_concat_dfs( - dfs, keys, multiexp_dst_folderpath, - multiexp_filename, + dfs, + keys, + multiexp_dst_folderpath, + multiexp_filename, extension, - names=['experiment_folderpath'] + names=["experiment_folderpath"], ) - ini_filepath = items['ini_filepath'][0] + ini_filepath = items["ini_filepath"][0] ini_filename = os.path.basename(ini_filepath) - dst_ini_filepath = os.path.join( - multiexp_dst_folderpath, ini_filename - ) + dst_ini_filepath = os.path.join(multiexp_dst_folderpath, ini_filename) if not os.path.exists(dst_ini_filepath): shutil.copy2(ini_filepath, dst_ini_filepath) - + for filename, items in spotmax_dfs_aggr_allexp.items(): - keys = items['keys'] - dfs = items['dfs'] + keys = items["keys"] + dfs = items["dfs"] printl(keys, pretty=True) - multiexp_filename = f'multiexp_{filename}' + multiexp_filename = f"multiexp_{filename}" extension = os.path.splitext(filename)[-1] spotmax.io.save_concat_dfs( - dfs, keys, multiexp_dst_folderpath, - multiexp_filename, + dfs, + keys, + multiexp_dst_folderpath, + multiexp_filename, extension, - names=names + names=names, ) - + for filename, items in ref_ch_dfs_allexp.items(): - keys = items['keys'] - dfs = items['dfs'] - multiexp_filename = f'multiexp_{filename}' + keys = items["keys"] + dfs = items["dfs"] + multiexp_filename = f"multiexp_{filename}" extension = os.path.splitext(filename)[-1] spotmax.io.save_concat_dfs( - dfs, keys, multiexp_dst_folderpath, - multiexp_filename, + dfs, + keys, + multiexp_dst_folderpath, + multiexp_filename, extension, - names=names + names=names, ) - + self.signals.finished.emit(self) + class FilterObjsFromCoordsTable(BaseWorkerUtil): sigAskAppendName = Signal(str, list) sigAborted = Signal() @@ -4696,77 +4775,71 @@ def emitSetColumnsNames(self, columns, categories, optionalCategories): self.sigSetColumnsNames.emit(columns, categories, optionalCategories) self.waitCond.wait(self.mutex) self.mutex.unlock() - return self.abort - + return self.abort + def getColumnsCategories( - self, df_coords, exp_path, pos_foldernames, endFilenameSegm - ): + self, df_coords, exp_path, pos_foldernames, endFilenameSegm + ): columns = df_coords.columns.to_list() - categories = ['X coord. column', 'Y coord. column'] + categories = ["X coord. column", "Y coord. column"] optionalCategories = [] - - images_path = os.path.join(exp_path, pos_foldernames[0], 'Images') + + images_path = os.path.join(exp_path, pos_foldernames[0], "Images") metadata_df = load.load_metadata_df(images_path) - SizeT = float(metadata_df.at['SizeT', 'values']) - SizeZ = float(metadata_df.at['SizeZ', 'values']) - - segmData = load.load_segm_file( - images_path, end_name_segm_file=endFilenameSegm - ) - + SizeT = float(metadata_df.at["SizeT", "values"]) + SizeZ = float(metadata_df.at["SizeZ", "values"]) + + segmData = load.load_segm_file(images_path, end_name_segm_file=endFilenameSegm) + if segmData.ndim == 4: - categories.append('Z coord. column') - categories.append('Frame index column') + categories.append("Z coord. column") + categories.append("Frame index column") elif segmData.ndim == 3: if SizeZ > 1 and SizeT == 1: # 3D z-stack data - categories.append('Z coord. column') + categories.append("Z coord. column") else: - optionalCategories.append('Z coord. column') - + optionalCategories.append("Z coord. column") + if SizeT > 1: # 3D time-lapse - categories.append('Frame index column') + categories.append("Frame index column") else: - optionalCategories.append('Frame index column') + optionalCategories.append("Frame index column") else: - optionalCategories.append('Z coord. column') - optionalCategories.append('Frame index column') - + optionalCategories.append("Z coord. column") + optionalCategories.append("Frame index column") + if len(pos_foldernames) > 1: - categories.append('Position_n') + categories.append("Position_n") else: - optionalCategories.append('Position_n') - + optionalCategories.append("Position_n") + return columns, categories, optionalCategories - + def getDfCoords( - self, df_coords, selectedColumnsPerCategory, pos_foldername, frame_i - ): - pos_col = selectedColumnsPerCategory.get('Position_n', 'None') - frame_i_col = selectedColumnsPerCategory.get( - 'Frame index column', 'None' - ) - x_col = selectedColumnsPerCategory['X coord. column'] - y_col = selectedColumnsPerCategory['Y coord. column'] - if pos_col != 'None': + self, df_coords, selectedColumnsPerCategory, pos_foldername, frame_i + ): + pos_col = selectedColumnsPerCategory.get("Position_n", "None") + frame_i_col = selectedColumnsPerCategory.get("Frame index column", "None") + x_col = selectedColumnsPerCategory["X coord. column"] + y_col = selectedColumnsPerCategory["Y coord. column"] + if pos_col != "None": df_coords = df_coords[df_coords[pos_col] == pos_foldername] - if frame_i_col != 'None': + if frame_i_col != "None": df_coords = df_coords[df_coords[frame_i_col] == frame_i] - + xy_cols = [x_col, y_col] - + df_out = pd.DataFrame( - index=df_coords.index, - data=df_coords[xy_cols].values, - columns=['x', 'y'] + index=df_coords.index, data=df_coords[xy_cols].values, columns=["x", "y"] ) - z_col = selectedColumnsPerCategory.get('Z coord. column', 'None') - if z_col != 'None': - df_out['z'] = df_coords[z_col] - + z_col = selectedColumnsPerCategory.get("Z coord. column", "None") + if z_col != "None": + df_out["z"] = df_coords[z_col] + return df_out - + @worker_exception_handler def run(self): debugging = False @@ -4777,46 +4850,42 @@ def run(self): self.errors = {} tot_pos = len(pos_foldernames) - self.mainWin.infoText = f'Select segmentation file to filter' + self.mainWin.infoText = f"Select segmentation file to filter" abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) if abort: self.sigAborted.emit() return endFilenameSegm = self.mainWin.endFilenameSegm - - self.logger.log('Asking to select the CSV table file...') - + + self.logger.log("Asking to select the CSV table file...") + abort = self.emitSelectFile( - exp_path, 'Select CSV table file with coordinates to filter', - 'CSV (*.csv)' + exp_path, + "Select CSV table file with coordinates to filter", + "CSV (*.csv)", ) if abort: self.sigAborted.emit() return - - self.logger.log( - f'Loading table file `{self.mainWin.selectedFilepath}`..' - ) + + self.logger.log(f"Loading table file `{self.mainWin.selectedFilepath}`..") df_coords = pd.read_csv(self.mainWin.selectedFilepath) - + columns, categories, optionalCategories = self.getColumnsCategories( df_coords, exp_path, pos_foldernames, endFilenameSegm - ) - - abort = self.emitSetColumnsNames( - columns, categories, optionalCategories ) + + abort = self.emitSetColumnsNames(columns, categories, optionalCategories) if abort: self.sigAborted.emit() return - + selectedColumnsPerCategory = self.mainWin.selectedColumnsPerCategory - + # Ask appendend name self.mutex.lock() self.sigAskAppendName.emit( - self.mainWin.endFilenameSegm, - self.mainWin.existingSegmEndNames + self.mainWin.endFilenameSegm, self.mainWin.existingSegmEndNames ) self.waitCond.wait(self.mutex) self.mutex.unlock() @@ -4832,20 +4901,21 @@ def run(self): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") ls = myutils.listdir(images_path) file_path = [ - os.path.join(images_path, f) for f in ls - if f.endswith(f'{endFilenameSegm}.npz') + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") ][0] - - posData = load.loadData(file_path, '') - self.signals.sigUpdatePbarDesc.emit(f'Processing {posData.pos_path}') + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") posData.getBasenameAndChNames() posData.buildPaths() @@ -4854,13 +4924,13 @@ def run(self): load_segm_data=True, load_acdc_df=True, load_metadata=True, - end_filename_segm=endFilenameSegm + end_filename_segm=endFilenameSegm, ) if posData.SizeT == 1: posData.segm_data = posData.segm_data[np.newaxis] - - self.logger.log('Filtering objects...') - + + self.logger.log("Filtering objects...") + numFrames = len(posData.segm_data) self.signals.sigInitInnerPbar.emit(numFrames) filteredSegmData = np.zeros_like(posData.segm_data) @@ -4873,7 +4943,7 @@ def run(self): self.signals.sigUpdateInnerPbar.emit(num_frames_missing) filteredSegmData = filteredSegmData[:frame_i] break - + filtered_lab = core.filter_segm_objs_from_table_coords( lab, df_coords_frame_i ) @@ -4881,16 +4951,17 @@ def run(self): self.signals.sigUpdateInnerPbar.emit(1) - self.logger.log('Saving filtered segmentation file...') + self.logger.log("Saving filtered segmentation file...") segmFilename, ext = os.path.splitext(posData.segm_npz_path) - newSegmFilepath = f'{segmFilename}_{appendedName}.npz' + newSegmFilepath = f"{segmFilename}_{appendedName}.npz" filteredSegmData = np.squeeze(filteredSegmData) io.savez_compressed(newSegmFilepath, filteredSegmData) - + self.signals.progressBar.emit(1) self.signals.finished.emit(self) + class ScreenRecorderWorker(QObject): sigGrabScreen = Signal() finished = Signal() @@ -4899,19 +4970,20 @@ def __init__(self, screenRecorderWin, folder_path): QObject.__init__(self) self.screenRecorderWin = screenRecorderWin self.folder_path = folder_path - + def run(self): for i in range(4): - fn = f'shot_{i:03}.jpg' + fn = f"shot_{i:03}.jpg" grab_path = os.path.join(self.folder_path, fn) screen = self.screenRecorderWin.screen() screenshot = screen.grabWindow(self.screenRecorderWin.winId()) - screenshot.save(grab_path, 'jpg') + screenshot.save(grab_path, "jpg") print(grab_path) time.sleep(0.2) self.finished.emit() + class CcaIntegrityCheckerWorker(QObject): finished = Signal(object) critical = Signal(object) @@ -4919,7 +4991,7 @@ class CcaIntegrityCheckerWorker(QObject): sigDone = Signal() sigWarning = Signal(str, str) sigFixWillDivide = Signal(str, list) - + def __init__(self, mutex, waitCond): QObject.__init__(self) self.logger = workerLogger(self.progress) @@ -4932,136 +5004,128 @@ def __init__(self, mutex, waitCond): self.isPaused = False self.debug = False self.dataQ = deque(maxlen=10) - + def pause(self): if self.debug: - self.logger.log('Cell cycle annotations checker is idle.') + self.logger.log("Cell cycle annotations checker is idle.") self.mutex.lock() self.isPaused = True self.waitCond.wait(self.mutex) self.mutex.unlock() self.isPaused = False - + def enqueue(self, posData): # First stop previous checking if self.isChecking: self.abortChecking = True self._enqueue(posData) - + def _enqueue(self, posData): if self.debug: - self.logger.log('Enqueing posData...') + self.logger.log("Enqueing posData...") self.dataQ.append(posData) if len(self.dataQ) == 1: # Wake worker upon inserting first element self.abortChecking = False self.waitCond.wakeAll() - + def clearQueue(self): self.dataQ.clear() - + def _stop(self): self.exit = True self.waitCond.wakeAll() - + def abort(self): self.abortChecking = True while not len(self.dataQ) == 0: data = self.dataQ.pop() del data self._stop() - + def _check_equality_num_mothers_buds_in_S(self, checker, frame_i): num_moth_S, num_buds = checker.get_num_mothers_and_buds_in_S() - + if num_moth_S == num_buds: return True - - category = 'number of buds different from number of mothers in S phase' + + category = "number of buds different from number of mothers in S phase" ul_items = [ - f'Number of buds = {num_buds}', - f'Number of mothers in S phase = {num_moth_S}' + f"Number of buds = {num_buds}", + f"Number of mothers in S phase = {num_moth_S}", ] txt = html_utils.paragraph( - f'At frame n. {frame_i+1} the number of buds and number of ' - 'mother cells in S phase are different!' - f'{html_utils.to_list(ul_items)}' + f"At frame n. {frame_i + 1} the number of buds and number of " + "mother cells in S phase are different!" + f"{html_utils.to_list(ul_items)}" ) self.sigWarning.emit(txt, category) return False - + def _check_mothers_multiple_buds(self, checker, frame_i): - mother_IDs_with_multiple_buds = ( - checker.get_mother_IDs_with_multiple_buds() - ) + mother_IDs_with_multiple_buds = checker.get_mother_IDs_with_multiple_buds() if len(mother_IDs_with_multiple_buds) == 0: return True - category = 'mother cells with multiple buds' + category = "mother cells with multiple buds" txt = html_utils.paragraph( - f'At frame n. {frame_i+1} ' - 'the following mother cells have multiple buds assigned to it' - f'

    {mother_IDs_with_multiple_buds}' + f"At frame n. {frame_i + 1} " + "the following mother cells have multiple buds assigned to it" + f"

    {mother_IDs_with_multiple_buds}" ) self.sigWarning.emit(txt, category) return False - + def _check_cells_without_G1(self, checker, global_cca_df): - IDs_cycles_without_G1 = ( - checker.get_IDs_cycles_without_G1(global_cca_df) - ) + IDs_cycles_without_G1 = checker.get_IDs_cycles_without_G1(global_cca_df) if len(IDs_cycles_without_G1) == 0: return True - category = 'cell cycles without G1' + category = "cell cycles without G1" txt = html_utils.paragraph( - 'Cell-ACDC requires that every cell cycle has at least ' - 'one frame in G1.
    ' - 'The following pairs of (ID, generation number) ' - 'do not satisfy this condition:

    ' - f'{IDs_cycles_without_G1}' + "Cell-ACDC requires that every cell cycle has at least " + "one frame in G1.
    " + "The following pairs of (ID, generation number) " + "do not satisfy this condition:

    " + f"{IDs_cycles_without_G1}" ) self.sigWarning.emit(txt, category) return False - + def _check_will_divide_is_true(self, checker, global_cca_df): - # NOTE: unfortunately this function performs pandas manipulations - # that are either not thread-safe or in any case are freezing the + # NOTE: unfortunately this function performs pandas manipulations + # that are either not thread-safe or in any case are freezing the # GUI. For now we don't run this until we find a solution return True - - IDs_will_divide_wrong = ( - checker.get_IDs_gen_num_will_divide_wrong(global_cca_df) - ) + + IDs_will_divide_wrong = checker.get_IDs_gen_num_will_divide_wrong(global_cca_df) if len(IDs_will_divide_wrong) == 0: return True txt = html_utils.paragraph( - 'Cell-ACDC found that `will_divide` is annotated as True on the ' - 'following (ID, generation number) cell
    ' - 'despite the fact that division is still not annotated on ' - 'these cells

    :' - f'{IDs_will_divide_wrong}' + "Cell-ACDC found that `will_divide` is annotated as True on the " + "following (ID, generation number) cell
    " + "despite the fact that division is still not annotated on " + "these cells

    :" + f"{IDs_will_divide_wrong}" ) self.sigFixWillDivide.emit(txt, IDs_will_divide_wrong) return False - + def _check_buds_gen_num_zero(self, checker, frame_i): - bud_IDs_gen_num_nonzero = ( - checker.get_bud_IDs_gen_num_nonzero() - ) + bud_IDs_gen_num_nonzero = checker.get_bud_IDs_gen_num_nonzero() if len(bud_IDs_gen_num_nonzero) == 0: return True - category = 'buds whose generation number is not zero' + category = "buds whose generation number is not zero" txt = html_utils.paragraph( - f'At frame n. {frame_i+1} ' - 'the following bud IDs have generation number different from 0:' - f'

    {bud_IDs_gen_num_nonzero}' + f"At frame n. {frame_i + 1} " + "the following bud IDs have generation number different from 0:" + f"

    {bud_IDs_gen_num_nonzero}" ) self.sigWarning.emit(txt, category) return False - + def _check_mothers_gen_num_greater_one(self, checker, frame_i): moth_IDs_gen_num_non_greater_one = ( checker.get_moth_IDs_gen_num_non_greater_one() @@ -5069,104 +5133,100 @@ def _check_mothers_gen_num_greater_one(self, checker, frame_i): if len(moth_IDs_gen_num_non_greater_one) == 0: return True - category = 'mothers whose generation number is < 1' + category = "mothers whose generation number is < 1" txt = html_utils.paragraph( - f'At frame n. {frame_i+1} ' - 'the following mother cells have generation number < 1:' - f'

    {moth_IDs_gen_num_non_greater_one}' + f"At frame n. {frame_i + 1} " + "the following mother cells have generation number < 1:" + f"

    {moth_IDs_gen_num_non_greater_one}" ) self.sigWarning.emit(txt, category) return False - + def _check_buds_G1(self, checker, frame_i): - buds_G1 = ( - checker.get_buds_G1() - ) + buds_G1 = checker.get_buds_G1() if len(buds_G1) == 0: return True - category = 'buds in G1' + category = "buds in G1" txt = html_utils.paragraph( - f'At frame n. {frame_i+1} ' - 'the following bud IDs are in G1 (buds must be in S):' - f'

    {buds_G1}' + f"At frame n. {frame_i + 1} " + "the following bud IDs are in G1 (buds must be in S):" + f"

    {buds_G1}" ) self.sigWarning.emit(txt, category) return False - + def _check_cell_S_rel_ID_zero(self, checker, frame_i): - cell_S_rel_ID_zero = ( - checker.get_cell_S_rel_ID_zero() - ) + cell_S_rel_ID_zero = checker.get_cell_S_rel_ID_zero() if len(cell_S_rel_ID_zero) == 0: return True - category = 'buds in G1' + category = "buds in G1" txt = html_utils.paragraph( - f'At frame n. {frame_i+1} ' - 'the following cell IDs in S phase do not have ' - 'relative_ID > 0:' - f'

    {cell_S_rel_ID_zero}' + f"At frame n. {frame_i + 1} " + "the following cell IDs in S phase do not have " + "relative_ID > 0:" + f"

    {cell_S_rel_ID_zero}" ) self.sigWarning.emit(txt, category) return False - + def _check_ID_rel_ID_mismatches(self, checker, frame_i): ID_rel_ID_mismatches = checker.get_ID_rel_ID_mismatches() if len(ID_rel_ID_mismatches) == 0: return True items = [ - f'Cell ID {ID} has relative ID = {relID}, ' - f'while cell ID {relID} has relative ID = {relID_of_relID}' + f"Cell ID {ID} has relative ID = {relID}, " + f"while cell ID {relID} has relative ID = {relID_of_relID}" for ID, relID, relID_of_relID in ID_rel_ID_mismatches ] - category = '`ID-relative_ID` mismatches' + category = "`ID-relative_ID` mismatches" txt = html_utils.paragraph( - f'At frame n. {frame_i+1} ' - 'there are the following `ID-relative_ID` mismatches:' - f'{html_utils.to_list(items)}' + f"At frame n. {frame_i + 1} " + "there are the following `ID-relative_ID` mismatches:" + f"{html_utils.to_list(items)}" ) self.sigWarning.emit(txt, category) return False - + def _check_lonely_cells_in_S(self, checker, frame_i): lonely_cells_in_S = checker.get_lonely_cells_in_S() if len(lonely_cells_in_S) == 0: return True - category = 'Lovely cells in S phase' + category = "Lovely cells in S phase" txt = html_utils.paragraph( - f'At frame n. {frame_i+1} ' - 'the following cell IDs are in `S` phase but their `relative_ID` ' - f'does not exist:

    ' - f'{lonely_cells_in_S}' + f"At frame n. {frame_i + 1} " + "the following cell IDs are in `S` phase but their `relative_ID` " + f"does not exist:

    " + f"{lonely_cells_in_S}" ) self.sigWarning.emit(txt, category) return False - + def _get_cca_df_copy(self, acdc_df): try: cca_df = pd.DataFrame( data=acdc_df[cca_df_colnames].values, columns=cca_df_colnames, - index=acdc_df.index + index=acdc_df.index, ) return cca_df except KeyError as error: - return - - def check(self, posData): + return + + def check(self, posData): self.isChecking = True checkpoints = ( - '_check_lonely_cells_in_S', - '_check_equality_num_mothers_buds_in_S', - '_check_mothers_multiple_buds', - '_check_buds_gen_num_zero', - '_check_mothers_gen_num_greater_one', - '_check_buds_G1', - '_check_cell_S_rel_ID_zero', - '_check_ID_rel_ID_mismatches' + "_check_lonely_cells_in_S", + "_check_equality_num_mothers_buds_in_S", + "_check_mothers_multiple_buds", + "_check_buds_gen_num_zero", + "_check_mothers_gen_num_greater_one", + "_check_buds_G1", + "_check_cell_S_rel_ID_zero", + "_check_ID_rel_ID_mismatches", ) cca_dfs = [] keys = [] @@ -5175,58 +5235,58 @@ def check(self, posData): if self.abortChecking: check_integrity_globally = False break - - lab = data_dict['labels'] + + lab = data_dict["labels"] if lab is None: break - - cca_df = data_dict.get('cca_df_checker') + + cca_df = data_dict.get("cca_df_checker") if cca_df is None: # There are no annotations at frame_i --> stop break - - IDs = data_dict['IDs'] + + IDs = data_dict["IDs"] checker = core.CcaIntegrityChecker(cca_df, lab, IDs) - + for checkpoint in checkpoints: proceed = getattr(self, checkpoint)(checker, frame_i) if not proceed: break - + if not proceed: check_integrity_globally = False break - + cca_dfs.append(cca_df) keys.append(frame_i) - - if check_integrity_globally and len(cca_dfs)>1: + + if check_integrity_globally and len(cca_dfs) > 1: global_checkpoints = [ - '_check_cells_without_G1', + "_check_cells_without_G1", # '_check_will_divide_is_true' ] # Check integrity globally - global_cca_df = pd.concat(cca_dfs, keys=keys, names=['frame_i']) + global_cca_df = pd.concat(cca_dfs, keys=keys, names=["frame_i"]) for checkpoint in global_checkpoints: proceed = getattr(self, checkpoint)(checker, global_cca_df) if not proceed: break - + self.abortChecking = False self.isChecking = False time.sleep(1) - + @worker_exception_handler def run(self): while True: if self.exit: - self.logger.log('Closing cell cycle integrity checker worker...') + self.logger.log("Closing cell cycle integrity checker worker...") break elif not len(self.dataQ) == 0: if self.debug: self.logger.log( - 'Checking integrity of cell cycle annotations ' - f'({len(self.dataQ)})...' + "Checking integrity of cell cycle annotations " + f"({len(self.dataQ)})..." ) data = self.dataQ.pop() self.check(data) @@ -5236,72 +5296,75 @@ def run(self): self.pause() self.isFinished = True self.finished.emit(self) - + + class ApplyImageFilterWorker(QObject): finished = Signal(object) critical = Signal(object) progress = Signal(str) - + def __init__(self, filter_func, input_data): QObject.__init__(self) self.filter_func = filter_func self.input_data = input_data - + @worker_exception_handler def run(self): - self.progress.emit('Filtering image...') + self.progress.emit("Filtering image...") filtered_data = self.filter_func(self.input_data) self.finished.emit(filtered_data) + class MoveTempFilesWorker(QObject): def __init__(self, temp_files_to_move: Dict[os.PathLike, os.PathLike]): QObject.__init__(self) self.signals = signals() self.logger = workerLogger(self.signals.progress) self.temp_files_to_move = temp_files_to_move - + @worker_exception_handler def run(self): for src, dst in self.temp_files_to_move.items(): - self.logger.log(f'Saving channel data to: {dst}...') + self.logger.log(f"Saving channel data to: {dst}...") shutil.move(src, dst) tempDir = os.path.dirname(src) shutil.rmtree(tempDir) self.signals.progressBar.emit(1) self.signals.finished.emit(self) + class ResizeUtilWorker(BaseWorkerUtil): sigSetResizeProps = Signal(str) - + def emitSetResizeProps(self, input_path): self.mutex.lock() self.sigSetResizeProps.emit(input_path) self.waitCond.wait(self.mutex) self.mutex.unlock() return self.abort - + def __init__(self, mainWin): super().__init__(mainWin) - + def validateOutputPath(self, path): if path is None: return - + images_path = myutils.validate_images_path(path, create_dirs_tree=True) return images_path - + @worker_exception_handler def run(self): expPaths = self.mainWin.expPaths tot_exp = len(expPaths) - + self.signals.initProgressBar.emit(0) for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): abort = self.emitSetResizeProps(exp_path) if abort: self.signals.finished.emit(self) return - + tot_pos = len(pos_foldernames) for p, pos in enumerate(pos_foldernames): if self.abort: @@ -5309,25 +5372,26 @@ def run(self): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - images_path = os.path.join(exp_path, pos, 'Images') - - + images_path = os.path.join(exp_path, pos, "Images") + rf = self.resizeFactor text_to_append = self.textToAppend images_path_out = self.validateOutputPath(self.expFolderpathOut) if images_path_out is None: images_path_out = images_path resize.run( - images_path, rf, - text_to_append=text_to_append, - images_path_out=images_path_out - ) - + images_path, + rf, + text_to_append=text_to_append, + images_path_out=images_path_out, + ) + self.signals.finished.emit(self) + class FucciPreprocessWorker(BaseWorkerUtil): sigAskAppendName = Signal(str) sigAskParams = Signal(object, object) @@ -5335,32 +5399,32 @@ class FucciPreprocessWorker(BaseWorkerUtil): def __init__(self, mainWin): super().__init__(mainWin) - + def emitAskParams(self, exp_path, pos_foldernames): self.mutex.lock() self.sigAskParams.emit(exp_path, pos_foldernames) self.waitCond.wait(self.mutex) self.mutex.unlock() return self.abort - + def applyPipeline(self, first_ch_data, second_ch_data, filter_kwargs): processed_data = np.zeros(first_ch_data.shape, dtype=np.uint8) pbar = tqdm(total=len(processed_data), ncols=100) with concurrent.futures.ThreadPoolExecutor() as executor: iterable = enumerate(zip(first_ch_data, second_ch_data)) - func = partial( - core.fucci_pipeline_executor_map, **filter_kwargs - ) + func = partial(core.fucci_pipeline_executor_map, **filter_kwargs) result = executor.map(func, iterable) for frame_i, processed_img in result: - processed_img = skimage.exposure.rescale_intensity(processed_img, out_range=(0, 255)) + processed_img = skimage.exposure.rescale_intensity( + processed_img, out_range=(0, 255) + ) processed_img = processed_img.astype(np.uint8) processed_data[frame_i] = processed_img pbar.update() pbar.close() - + return processed_data - + @worker_exception_handler def run(self): debugging = False @@ -5371,14 +5435,14 @@ def run(self): self.errors = {} tot_pos = len(pos_foldernames) - self.mainWin.infoText = f'Setup parameters' - + self.mainWin.infoText = f"Setup parameters" + if i == 0: abort = self.emitAskParams(exp_path, pos_foldernames) if abort: self.sigAborted.emit() return - + # Ask appendend name self.mutex.lock() self.sigAskAppendName.emit(self.basename) @@ -5396,75 +5460,67 @@ def run(self): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - images_path = os.path.join(exp_path, pos, 'Images') - - self.logger.log( - f'Loading {self.firstChannelName} channel data...' - ) + images_path = os.path.join(exp_path, pos, "Images") + + self.logger.log(f"Loading {self.firstChannelName} channel data...") first_ch_filepath = load.get_filename_from_channel( images_path, self.firstChannelName ) first_ch_data = load.load_image_file(first_ch_filepath) - - self.logger.log( - f'Loading {self.secondChannelName} channel data...' - ) + + self.logger.log(f"Loading {self.secondChannelName} channel data...") second_ch_filepath = load.get_filename_from_channel( images_path, self.secondChannelName ) second_ch_data = load.load_image_file(second_ch_filepath) - - self.logger.log( - 'Applying FUCCI pre-processing pipeline...\n' - ) + + self.logger.log("Applying FUCCI pre-processing pipeline...\n") processed_data = self.applyPipeline( first_ch_data, second_ch_data, self.fucciFilterKwargs ) - + basename, chNames = myutils.getBasenameAndChNames(images_path) _, ext = os.path.splitext(first_ch_filepath) - processed_filename = f'{basename}{appendedName}{ext}' - processed_filepath = os.path.join( - images_path, processed_filename - ) + processed_filename = f"{basename}{appendedName}{ext}" + processed_filepath = os.path.join(images_path, processed_filename) self.logger.log( f'Saving pre-processed images to "{processed_filepath}"...' ) io.save_image_data(processed_filepath, processed_data) - + self.signals.progressBar.emit(1) self.signals.finished.emit(self) + class SimpleWorker(QObject): def __init__(self, posData, func, func_args=None, func_kwargs=None): QObject.__init__(self) self.posData = posData self.signals = signals() self.output = {} - + if func_args is None: func_args = [] - + if func_kwargs is None: func_kwargs = {} - + self.func = func self.func_args = func_args self.func_kwargs = func_kwargs self.posData = posData - + @worker_exception_handler def run(self): - self.result = self.func( - self.posData, *self.func_args, **self.func_kwargs - ) + self.result = self.func(self.posData, *self.func_args, **self.func_kwargs) self.signals.finished.emit(self.output) + class CopyAllLostObjectsWorker(QObject): navigateToFrame = Signal(int) returnToFrame = Signal(int) @@ -5516,7 +5572,7 @@ def run(self): self.progressBar.emit(1) if self.for_future_frame_n == 0: - output['overlap_warning'] = overlap_warning + output["overlap_warning"] = overlap_warning self.finished.emit(output) return @@ -5524,19 +5580,20 @@ def run(self): self.returnToFrame.emit(current_frame_i) if last_visited_frame_i < last_copied_frame_i: - output['doReinitLastSegmFrame'] = True - output['last_visited_frame_i'] = last_visited_frame_i + output["doReinitLastSegmFrame"] = True + output["last_visited_frame_i"] = last_visited_frame_i - output['overlap_warning'] = overlap_warning + output["overlap_warning"] = overlap_warning self.finished.emit(output) + class SaveProcessedDataWorker(QObject): def __init__( - self, - allPosData: Iterable['load.loadData'], - appended_text_filename: str, - ext: str = None - ): + self, + allPosData: Iterable["load.loadData"], + appended_text_filename: str, + ext: str = None, + ): QObject.__init__(self) self.allPosData = allPosData self.signals = signals() @@ -5550,53 +5607,48 @@ def run(self): for posData in self.allPosData: ext_loc = self.ext if self.ext is not None else posData.ext processed_filename = ( - f'{posData.basename}{posData.user_ch_name}_' - f'{self.appended_text_filename}{ext_loc}' - ) - processed_filepath = os.path.join( - posData.images_path, processed_filename + f"{posData.basename}{posData.user_ch_name}_" + f"{self.appended_text_filename}{ext_loc}" ) - self.logger.log(f'Saving {processed_filepath}...') + processed_filepath = os.path.join(posData.images_path, processed_filename) + self.logger.log(f"Saving {processed_filepath}...") processed_data = posData.preprocessedDataArray() if processed_data is None: self.logger.log( - f'[WARNING]: {posData.pos_foldername} does not have ' - 'preprocessed data. Skipping it.' + f"[WARNING]: {posData.pos_foldername} does not have " + "preprocessed data. Skipping it." ) continue - + io.save_image_data(processed_filepath, processed_data) - + self.signals.finished.emit(self) + class SaveCombinedChannelsWorker(QObject): sigDebugShowImg = Signal(object) + def __init__( - self, - allPosData: Iterable['load.loadData'], - filename: str, - debug: bool = False - ): + self, allPosData: Iterable["load.loadData"], filename: str, debug: bool = False + ): QObject.__init__(self) self.allPosData = allPosData self.signals = signals() self.logger = workerLogger(self.signals.progress) self.filename = filename self.debug = debug - + @worker_exception_handler def run(self): self.signals.initProgressBar.emit(0) for posData in self.allPosData: - processed_filepath = os.path.join( - posData.images_path, self.filename - ) - self.logger.log(f'Saving {processed_filepath}...') + processed_filepath = os.path.join(posData.images_path, self.filename) + self.logger.log(f"Saving {processed_filepath}...") processed_data = posData.combinedChannelsDataArray() if processed_data is None: self.logger.log( - f'[WARNING]: {posData.pos_foldername} does not have ' - 'combined channels data. Skipping it.' + f"[WARNING]: {posData.pos_foldername} does not have " + "combined channels data. Skipping it." ) continue if self.debug: @@ -5606,16 +5658,17 @@ def run(self): printl(processed_data.max()) printl(processed_filepath) self.sigDebugShowImg.emit(processed_data) - # cellacdc.plot.imshow(processed_data) + # cellacdc.plot.imshow(processed_data) io.save_image_data(processed_filepath, processed_data) - + self.signals.finished.emit(self) + class CustomPreprocessWorkerGUI(QObject): sigDone = Signal(object, str) sigPreviewDone = Signal(object, tuple) sigIsQueueEmpty = Signal(bool) - + def __init__(self, mutex, waitCond): QObject.__init__(self) self.signals = signals() @@ -5626,87 +5679,78 @@ def __init__(self, mutex, waitCond): self.exit = False self.wait = True self._abort = False - + def enqueue( - self, - func: Callable, - image: np.ndarray, - recipe: Dict[str, Any], - key: Tuple[int, int, Union[int, str]] - ): + self, + func: Callable, + image: np.ndarray, + recipe: Dict[str, Any], + key: Tuple[int, int, Union[int, str]], + ): self.dataQ.append((func, image, recipe, key)) if len(self.dataQ) == 1: self.sigIsQueueEmpty.emit(False) # Wake up worker upon inserting first element self.wakeUp() - + def wakeUp(self): self.wait = False self.waitCond.wakeAll() - + def pause(self): self.wait = True self.mutex.lock() self.waitCond.wait(self.mutex) self.mutex.unlock() - + def abort(self): self._abort = True - + def stop(self): self.abort() self.exit = True self.waitCond.wakeAll() self.signals.finished.emit(self) - + def setupJob( - self, - func: Callable, - image_data: np.ndarray, - recipe: Dict[str, Any], - how: str - ): + self, func: Callable, image_data: np.ndarray, recipe: Dict[str, Any], how: str + ): self._func = func self._image_data = image_data self._recipe = recipe self._how = how - + def runJob(self, image=None, recipe=None): if image is None: image = self._image_data.copy() if recipe is None: recipe = self._recipe - + return self.applyRecipe(self._func, image, recipe) - + def applyRecipe( - self, - func: Callable, - image: np.ndarray, - recipe: List[Dict[str, Any]] - ): + self, func: Callable, image: np.ndarray, recipe: List[Dict[str, Any]] + ): preprocessed_data = func(image, recipe) - keep_input_data_type = recipe[0].get('keep_input_data_type', True) + keep_input_data_type = recipe[0].get("keep_input_data_type", True) if not keep_input_data_type: return preprocessed_data try: - preprocessed_data = myutils.convert_to_dtype( - preprocessed_data, image.dtype - ) + preprocessed_data = myutils.convert_to_dtype(preprocessed_data, image.dtype) except Exception as err: preprocessed_data = preprocessed_data.astype(image.dtype) return preprocessed_data - + @worker_exception_handler def run(self): while True: if self.exit: - self.logger.log('Closing pre-processing worker...') + self.logger.log("Closing pre-processing worker...") break elif self.wait: - self.logger.log('Pre-processing worker paused.') + self.logger.log("Pre-processing worker paused.") self.pause() elif len(self.dataQ) > 0: func, image, recipe, key = self.dataQ.pop() @@ -5716,20 +5760,26 @@ def run(self): self.wait = True self.sigIsQueueEmpty.emit(True) else: - self.logger.log('Pre-processing worker resumed.') + self.logger.log("Pre-processing worker resumed.") processed_data = self.runJob() self.sigDone.emit(processed_data, self._how) self.wait = True self.signals.finished.emit(self) + class CombineChannelsWorkerGUI(CustomPreprocessWorkerGUI): sigDone = Signal(object, list) sigPreviewDone = Signal(object, list) sigAskLoadChannels = Signal(set, object) - def __init__(self, mutex, waitCond, logger_func: Callable,): -# signals_parent=None): + def __init__( + self, + mutex, + waitCond, + logger_func: Callable, + ): + # signals_parent=None): super().__init__(mutex, waitCond) self.waitCondLoadFluoChannels = QWaitCondition() @@ -5741,29 +5791,31 @@ def __init__(self, mutex, waitCond, logger_func: Callable,): # self.signals = signals_parent def enqueue( - self, - data, - steps: Dict[str, Any], - key: Tuple[int, int, Union[int, str]], - keep_input_data_type: bool, - output_as_segm: bool, - formula: str, - ): - self.dataQ.append((data, steps, key, keep_input_data_type,output_as_segm, formula)) + self, + data, + steps: Dict[str, Any], + key: Tuple[int, int, Union[int, str]], + keep_input_data_type: bool, + output_as_segm: bool, + formula: str, + ): + self.dataQ.append( + (data, steps, key, keep_input_data_type, output_as_segm, formula) + ) if len(self.dataQ) == 1: self.sigIsQueueEmpty.emit(False) # Wake up worker upon inserting first element self.wakeUp() def setupJob( - self, - data: Dict[str, np.ndarray], - steps: Dict[str, Any], - keep_input_data_type: bool, - key: Tuple[Union[int, None], Union[int, None], Union[int, None]], - output_as_segm: bool, - formula: str, - ): + self, + data: Dict[str, np.ndarray], + steps: Dict[str, Any], + keep_input_data_type: bool, + key: Tuple[Union[int, None], Union[int, None], Union[int, None]], + output_as_segm: bool, + formula: str, + ): self._key = key self._steps = steps self._data = data @@ -5771,8 +5823,15 @@ def setupJob( self._output_as_segm = output_as_segm self._formula = formula - def runJob(self, data=None, steps=None, keep_input_data_type=None, key=None, - output_as_segm=None, formula=None): + def runJob( + self, + data=None, + steps=None, + keep_input_data_type=None, + key=None, + output_as_segm=None, + formula=None, + ): if data is None: data = self._data if steps is None: @@ -5789,17 +5848,19 @@ def runJob(self, data=None, steps=None, keep_input_data_type=None, key=None, if not steps and formula is None: return - return self.applySteps(data, steps, keep_input_data_type, key, output_as_segm, formula=formula) - + return self.applySteps( + data, steps, keep_input_data_type, key, output_as_segm, formula=formula + ) + def applySteps( - self, - data: Dict[str, np.ndarray], - steps: List[Dict[str, Any]], - keep_input_data_type: bool, - key: Tuple[Union[int, None], Union[int, None], Union[int, None]], - output_as_segm: bool, - formula: str, - ): + self, + data: Dict[str, np.ndarray], + steps: List[Dict[str, Any]], + keep_input_data_type: bool, + key: Tuple[Union[int, None], Union[int, None], Union[int, None]], + output_as_segm: bool, + formula: str, + ): new_keys = [] key = list(key) @@ -5816,7 +5877,7 @@ def applySteps( new_keys_per_pos.append(list(range(frames))) else: new_keys_per_pos.append([key[1]]) - + if key[2] is None: z_slices = data[pos_i].SizeZ if not z_slices: @@ -5827,7 +5888,7 @@ def applySteps( new_keys_per_pos = list(itertools.product(*new_keys_per_pos)) new_keys.extend(new_keys_per_pos) - + output_imgs, out_keys = core.combine_channels_multithread_return_imgs( steps=steps, data=data, @@ -5837,14 +5898,13 @@ def applySteps( signals=self.signals, output_as_segm=output_as_segm, formula=formula, - ) return output_imgs, out_keys def requiredChannels(self, steps=None, pos_i=None): if steps is None: steps = self._steps - + required_channels = core.get_selected_channels(steps) if pos_i is None: pos_i = self._key[0] @@ -5855,25 +5915,31 @@ def requiredChannels(self, steps=None, pos_i=None): def run(self): while True: if self.exit: - self.logger.log('Closing combining channels worker...') + self.logger.log("Closing combining channels worker...") break elif self.wait: - self.logger.log('Combining channels worker paused.') + self.logger.log("Combining channels worker paused.") self.pause() elif len(self.dataQ) > 0: - data, steps, key, keep_input_data_type, output_as_segm, formula = self.dataQ.pop() + data, steps, key, keep_input_data_type, output_as_segm, formula = ( + self.dataQ.pop() + ) requ_steps, pos_i = self.requiredChannels(steps, key[0]) self.emitsigAskLoadChannels(requ_steps, pos_i) output_imgs, out_keys = self.applySteps( - data, steps, keep_input_data_type, key, - output_as_segm=output_as_segm, formula=formula + data, + steps, + keep_input_data_type, + key, + output_as_segm=output_as_segm, + formula=formula, ) self.sigPreviewDone.emit(output_imgs, out_keys) if len(self.dataQ) == 0: self.wait = True self.sigIsQueueEmpty.emit(True) else: - self.logger.log('Combining channels worker resumed.') + self.logger.log("Combining channels worker resumed.") requ_steps, pos_i = self.requiredChannels() self.emitsigAskLoadChannels(requ_steps, pos_i) output_imgs, out_keys = self.runJob() @@ -5881,7 +5947,7 @@ def run(self): self.wait = True self.signals.finished.emit(self) - + def emitsigAskLoadChannels(self, requChannels, pos_i): self.mutex.lock() self.sigAskLoadChannels.emit(requChannels, pos_i) @@ -5893,7 +5959,8 @@ def wake_waitCondLoadFluoChannels(self): self.mutex.lock() self.waitCondLoadFluoChannels.wakeAll() self.mutex.unlock() - + + class CustomPreprocessWorkerUtil(BaseWorkerUtil): sigAskAppendName = Signal(str) sigAskSetupRecipe = Signal(object, object) @@ -5901,25 +5968,25 @@ class CustomPreprocessWorkerUtil(BaseWorkerUtil): def __init__(self, mainWin): super().__init__(mainWin) - + def emitAskSetupRecipe(self, exp_path, pos_foldernames): self.mutex.lock() self.sigAskSetupRecipe.emit(exp_path, pos_foldernames) self.waitCond.wait(self.mutex) self.mutex.unlock() return self.abort - + def applyPipeline( - self, - images_path: os.PathLike, - channel_names: Iterable[str], - recipe: List[Dict[str, Any]], - appended_text_filename: str - ): - posData = None + self, + images_path: os.PathLike, + channel_names: Iterable[str], + recipe: List[Dict[str, Any]], + appended_text_filename: str, + ): + posData = None preprocessed_data = {} for channel in channel_names: - self.logger.log(f'Loading {channel} channel data...') + self.logger.log(f"Loading {channel} channel data...") ch_filepath = load.get_filename_from_channel(images_path, channel) ch_image_data = load.load_image_file(ch_filepath) if posData is None: @@ -5936,8 +6003,8 @@ def applyPipeline( preprocessed_ch_data = core.preprocess_image_from_recipe_multithread( ch_image_data, recipe ) - - keep_input_data_type = recipe[0].get('keep_input_data_type', True) + + keep_input_data_type = recipe[0].get("keep_input_data_type", True) if keep_input_data_type: preprocessed_ch_data = myutils.convert_to_dtype( preprocessed_ch_data, ch_image_data.dtype @@ -5945,13 +6012,11 @@ def applyPipeline( _, ext = os.path.splitext(ch_filepath) basename = posData.basename - processed_filename = ( - f'{basename}{channel}_{appended_text_filename}{ext}' - ) + processed_filename = f"{basename}{channel}_{appended_text_filename}{ext}" preprocessed_data[processed_filename] = preprocessed_ch_data - + return preprocessed_data - + @worker_exception_handler def run(self): debugging = False @@ -5962,24 +6027,24 @@ def run(self): self.errors = {} tot_pos = len(pos_foldernames) - self.mainWin.infoText = 'Setup recipe' - + self.mainWin.infoText = "Setup recipe" + if i == 0: abort = self.emitAskSetupRecipe(exp_path, pos_foldernames) if abort: self.sigAborted.emit() return - + # Ask append name self.mutex.lock() - basename = f'{self.basename}{self.selectedChannels[0]}_' + basename = f"{self.basename}{self.selectedChannels[0]}_" self.sigAskAppendName.emit(basename) self.waitCond.wait(self.mutex) self.mutex.unlock() if self.abort: self.sigAborted.emit() return - + appendedName = self.appendedName self.signals.initProgressBar.emit(len(pos_foldernames)) for p, pos in enumerate(pos_foldernames): @@ -5988,33 +6053,28 @@ def run(self): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - images_path = os.path.join(exp_path, pos, 'Images') - self.logger.log( - 'Applying custom pre-processing recipe...\n' - ) + images_path = os.path.join(exp_path, pos, "Images") + self.logger.log("Applying custom pre-processing recipe...\n") processed_data = self.applyPipeline( - images_path, self.selectedChannels, - self.recipe, appendedName + images_path, self.selectedChannels, self.recipe, appendedName ) - + for filename, preprocessed_ch_data in processed_data.items(): preprocessed_filepath = os.path.join(images_path, filename) self.logger.log( - f'Saving pre-processed images to ' - f'"{preprocessed_filepath}"...' + f'Saving pre-processed images to "{preprocessed_filepath}"...' ) - - io.save_image_data( - preprocessed_filepath, preprocessed_ch_data - ) + + io.save_image_data(preprocessed_filepath, preprocessed_ch_data) self.signals.progressBar.emit(1) self.signals.finished.emit(self) + class CombineChannelsWorkerUtil(BaseWorkerUtil): sigAskAppendName = Signal(str) sigAskSetup = Signal(object) @@ -6022,41 +6082,39 @@ class CombineChannelsWorkerUtil(BaseWorkerUtil): def __init__(self, mainWin, mutex=None, waitCond=None): super().__init__(mainWin) - + def emitAskSetup(self, expPaths): self.mutex.lock() self.sigAskSetup.emit(expPaths) self.waitCond.wait(self.mutex) self.mutex.unlock() return self.abort - + def applyPipeline( - self, - image_paths: os.PathLike, - steps: Dict[str, Dict[str, Any]], - appended_text_filename: str, - keep_input_data_type: bool, - n_threads: int = None, - formula: str = None, - ): + self, + image_paths: os.PathLike, + steps: Dict[str, Dict[str, Any]], + appended_text_filename: str, + keep_input_data_type: bool, + n_threads: int = None, + formula: str = None, + ): save_filepaths = [] images_path_to_process = [] if self.saveAsSegm: - out_ext = '.npz' - basename_ext = 'segm_' + out_ext = ".npz" + basename_ext = "segm_" else: - out_ext = '.tif' - basename_ext = '' + out_ext = ".tif" + basename_ext = "" for images_path in image_paths: basename, channels = myutils.getBasenameAndChNames(images_path) - - savename = ( - f'{basename}{basename_ext}{appended_text_filename}{out_ext}' - ) + + savename = f"{basename}{basename_ext}{appended_text_filename}{out_ext}" images_path_to_process.append(images_path) save_filepaths.append(os.path.join(images_path, savename)) - + core.combine_channels_multithread( steps=steps, images_paths=images_path_to_process, @@ -6068,7 +6126,7 @@ def applyPipeline( output_as_segm=self.saveAsSegm, formula=formula, ) - + @worker_exception_handler def run(self): @@ -6079,10 +6137,10 @@ def run(self): if abort: self.sigAborted.emit() return - + # Ask append name self.mutex.lock() - basename = f'{self.basename}' + basename = f"{self.basename}" self.sigAskAppendName.emit(basename) self.waitCond.wait(self.mutex) self.mutex.unlock() @@ -6094,14 +6152,16 @@ def run(self): selectedSteps = self.selectedSteps - self.logger.log('Applying pipeline...') - self.logger.log('Selected steps:') + self.logger.log("Applying pipeline...") + self.logger.log("Selected steps:") for step in selectedSteps.values(): self.logger.log(step) - + image_paths = [] for exp_path, pos_foldernames in expPaths.items(): - image_paths += [os.path.join(exp_path, pos, 'Images') for pos in pos_foldernames] + image_paths += [ + os.path.join(exp_path, pos, "Images") for pos in pos_foldernames + ] self.signals.initProgressBar.emit(len(pos_foldernames)) formula = self.formula @@ -6116,6 +6176,7 @@ def run(self): self.signals.finished.emit(self) + class saveDataWorker(QObject): finished = Signal() progress = Signal(str) @@ -6141,29 +6202,28 @@ def __init__(self, mainWin): self.addMetricsErrors = {} self.regionPropsErrors = {} self.abort = False - + def checkAbort(self): if self.saveWin.aborted: self.finished.emit() return True return False - + def saveManualBackgroundData(self, posData, frame_i): data_dict = posData.allData_li[frame_i] - if 'manualBackgroundLab' not in data_dict: + if "manualBackgroundLab" not in data_dict: return - - manualBackgrData = data_dict['manualBackgroundLab'] + + manualBackgrData = data_dict["manualBackgroundLab"] posData.saveManualBackgroundData(manualBackgrData) - + def emitSigPermissionErrorAndSave( - self, all_frames_acdc_df, acdc_output_csv_path, - custom_annot_columns - ): + self, all_frames_acdc_df, acdc_output_csv_path, custom_annot_columns + ): err_msg = ( - 'The below file is open in another app ' - '(Excel maybe?).\n\n' - f'{acdc_output_csv_path}\n\n' + "The below file is open in another app " + "(Excel maybe?).\n\n" + f"{acdc_output_csv_path}\n\n" 'Close file and then press "Ok".' ) self.mutex.lock() @@ -6173,11 +6233,12 @@ def emitSigPermissionErrorAndSave( # Save segmentation metadata load.save_acdc_df_file( - all_frames_acdc_df, acdc_output_csv_path, - custom_annot_columns=custom_annot_columns, - last_cca_frame_i=self.mainWin.save_cca_until_frame_i + all_frames_acdc_df, + acdc_output_csv_path, + custom_annot_columns=custom_annot_columns, + last_cca_frame_i=self.mainWin.save_cca_until_frame_i, ) - + def _emitSigDebug(self, stuff_to_debug): self.mutex.lock() self.sigDebug.emit(stuff_to_debug) @@ -6189,41 +6250,43 @@ def emitUpdateProgressBar(self): exec_time = t - self.time_last_pbar_update self.progressBar.emit(1, -1, exec_time) self.time_last_pbar_update = t - + def saveAcdcDf(self, posData: load.loadData, end_i): acdc_dfs_li = [] keys = [] - self.progress.emit(f'Saving annotations for {posData.relPath}...') - for frame_i, data_dict in enumerate(posData.allData_li[:end_i+1]): + self.progress.emit(f"Saving annotations for {posData.relPath}...") + for frame_i, data_dict in enumerate(posData.allData_li[: end_i + 1]): if self.saveWin.aborted: self.finished.emit() return # Build saved_segm_data - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None: break - - acdc_df = posData.allData_li[frame_i]['acdc_df'] + + acdc_df = posData.allData_li[frame_i]["acdc_df"] if acdc_df is None: continue - + acdc_dfs_li.append(acdc_df) - keys.append((frame_i, posData.TimeIncrement*frame_i)) - + keys.append((frame_i, posData.TimeIncrement * frame_i)) + if not acdc_dfs_li: return - + self.mainWin._measurements_kernel._concat_and_save_acdc_df( - acdc_dfs_li, keys, posData, self.mainWin.save_metrics, - saveDataWorker=self, - last_cca_frame_i=self.mainWin.save_cca_until_frame_i + acdc_dfs_li, + keys, + posData, + self.mainWin.save_metrics, + saveDataWorker=self, + last_cca_frame_i=self.mainWin.save_cca_until_frame_i, ) - + def saveSegmData(self, posData, end_i, saved_segm_data): - self.progress.emit(f'Saving segmentation data for {posData.relPath}...') - - + self.progress.emit(f"Saving segmentation data for {posData.relPath}...") + # extend saved_segm_data if needed if posData.SizeT > 1: missing_frames_number = end_i + 1 - len(saved_segm_data) @@ -6233,41 +6296,38 @@ def saveSegmData(self, posData, end_i, saved_segm_data): saved_segm_data, np.zeros( (missing_frames_number, *saved_segm_data.shape[1:]), - dtype=saved_segm_data.dtype - ) + dtype=saved_segm_data.dtype, + ), ), ) - - for frame_i, data_dict in enumerate(posData.allData_li[:end_i+1]): + for frame_i, data_dict in enumerate(posData.allData_li[: end_i + 1]): if self.saveWin.aborted: self.finished.emit() return # Build saved_segm_data - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None: break - + posData.lab = lab if posData.SizeT > 1: saved_segm_data[frame_i] = lab else: saved_segm_data = lab - if 'manualBackgroundLab' in data_dict: - manualBackgrData = data_dict['manualBackgroundLab'] - posData.saveManualBackgroundData(manualBackgrData) - + if "manualBackgroundLab" in data_dict: + manualBackgrData = data_dict["manualBackgroundLab"] + posData.saveManualBackgroundData(manualBackgrData) + # Save segmentation file - io.savez_compressed( - posData.segm_npz_path, np.squeeze(saved_segm_data) - ) + io.savez_compressed(posData.segm_npz_path, np.squeeze(saved_segm_data)) posData.segm_data = saved_segm_data # Allow single 2D/3D image if posData.SizeT == 1: posData.segm_data = posData.segm_data[np.newaxis] - + try: os.remove(posData.segm_npz_temp_path) except Exception as e: @@ -6289,16 +6349,16 @@ def run(self): if self.saveWin.aborted: self.finished.emit() return - + if posToSave is not None: if posData.pos_foldername not in posToSave: - self.progress.emit(f'Skipping {posData.relPath}') + self.progress.emit(f"Skipping {posData.relPath}") continue - + last_tracked_i_path = posData.last_tracked_i_path - end_i = self.mainWin.save_until_frame_i + end_i = self.mainWin.save_until_frame_i self.saveSegmData(posData, end_i, posData.segm_data) - + posData.saveCustomAnnotationParams() current_frame_i = posData.frame_i @@ -6314,7 +6374,7 @@ def run(self): last_tracked_i = 0 if p == 0: - self.progressBar.emit(0, numPosToSave*(last_tracked_i+1), 0) + self.progressBar.emit(0, numPosToSave * (last_tracked_i + 1), 0) acdc_output_csv_path = posData.acdc_output_csv_path delROIs_info_path = posData.delROIs_info_path @@ -6322,47 +6382,46 @@ def run(self): # Add segmented channel data for calc metrics if requested add_user_channel_data = True for chName in self.mainWin._measurements_kernel.chNamesToSkip: - skipUserChannel = ( - posData.filename.endswith(chName) - or posData.filename.endswith(f'{chName}_aligned') - ) + skipUserChannel = posData.filename.endswith( + chName + ) or posData.filename.endswith(f"{chName}_aligned") if skipUserChannel: add_user_channel_data = False if add_user_channel_data and not self.isQuickSave: posData.fluo_data_dict[posData.filename] = posData.img_data - + if not self.isQuickSave: posData.fluo_bkgrData_dict[posData.filename] = posData.bkgrData - + posData.setLoadedChannelNames() - + if not self.isQuickSave: self.mainWin.initMetricsToSave(posData) self.mainWin._measurements_kernel.run( - posData=posData, - stop_frame_n=end_i+1, + posData=posData, + stop_frame_n=end_i + 1, saveDataWorker=self, save_metrics=self.mainWin.save_metrics, - last_cca_frame_i=self.mainWin.save_cca_until_frame_i + last_cca_frame_i=self.mainWin.save_cca_until_frame_i, ) else: self.saveAcdcDf(posData, end_i) - - self.progress.emit(f'Saving {posData.relPath}') + + self.progress.emit(f"Saving {posData.relPath}") if not self.do_not_save_og_whitelist: og_save_path = os.path.join( - posData.images_path, self.append_name_og_whitelist + posData.images_path, self.append_name_og_whitelist ) posData.whitelist.saveOGLabs(og_save_path) - + if posData.whitelist: whitelistIDs_path = posData.segm_npz_path.replace( - '.npz', '_whitelistIDs.json' + ".npz", "_whitelistIDs.json" ) new_centroids_path = posData.segm_npz_path.replace( - '.npz', '_new_centroids.json' + ".npz", "_new_centroids.json" ) posData.whitelist.save( whitelistIDs_path, new_centroids_path=new_centroids_path @@ -6373,9 +6432,9 @@ def run(self): posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) except PermissionError: err_msg = ( - 'The below file is open in another app ' - '(Excel maybe?).\n\n' - f'{posData.segmInfo_df_csv_path}\n\n' + "The below file is open in another app " + "(Excel maybe?).\n\n" + f"{posData.segmInfo_df_csv_path}\n\n" 'Close file and then press "Ok".' ) self.mutex.lock() @@ -6390,7 +6449,7 @@ def run(self): posData.fluo_bkgrData_dict.pop(posData.filename) if posData.SizeT > 1: - self.progress.emit('Almost done...') + self.progress.emit("Almost done...") self.progressBar.emit(0, 0, 0) if self.isQuickSave: @@ -6398,8 +6457,8 @@ def run(self): posData.frame_i = current_frame_i self.mainWin.get_data() continue - - with open(last_tracked_i_path, 'w+') as txt: + + with open(last_tracked_i_path, "w+") as txt: txt.write(str(end_i)) # Save combined metrics equations @@ -6413,21 +6472,20 @@ def run(self): posData.frame_i = current_frame_i self.mainWin.get_data() - if mode == 'Segmentation and Tracking' or mode == 'Viewer': - self.progress.emit( - f'Saved data until frame number {end_i+1}' - ) - elif mode == 'Cell cycle analysis': + if mode == "Segmentation and Tracking" or mode == "Viewer": + self.progress.emit(f"Saved data until frame number {end_i + 1}") + elif mode == "Cell cycle analysis": self.progress.emit( - 'Saved cell cycle annotations until frame ' - f'number {self.mainWin.last_cca_frame_i+1}' + "Saved cell cycle annotations until frame " + f"number {self.mainWin.last_cca_frame_i + 1}" ) # self.progressBar.emit(1) if self.mainWin.isSnapshot: - self.progress.emit(f'Saved all {p+1} Positions!') - + self.progress.emit(f"Saved all {p + 1} Positions!") + self.finished.emit() - + + class relabelSequentialWorker(QObject): finished = Signal() critical = Signal(object) @@ -6445,29 +6503,29 @@ def __init__(self, mainWin, posFoldernames): def progressNewIDs(self, oldIDs, newIDs): li = list(zip(oldIDs, newIDs)) - s = '\n'.join([str(pair).replace(',', ' -->') for pair in li]) - s = f'IDs relabelled as follows:\n{s}' + s = "\n".join([str(pair).replace(",", " -->") for pair in li]) + s = f"IDs relabelled as follows:\n{s}" self.progress.emit(s) @worker_exception_handler def run(self): self.mutex.lock() - self.progress.emit('Relabelling process started...') + self.progress.emit("Relabelling process started...") mainWin = self.mainWin current_pos_i = mainWin.pos_i - + for p, posData in enumerate(self.data): if posData.pos_foldername not in self.posFoldernames: continue - + mainWin.pos_i = p current_lab = mainWin.get_2Dlab(posData.lab).copy() current_frame_i = posData.frame_i segm_data = [] for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict['labels'] + lab = data_dict["labels"] if lab is None: break segm_data.append(lab) @@ -6479,14 +6537,13 @@ def run(self): segm_data = np.array(segm_data) segm_data, oldIDs, newIDs = core.relabel_sequential( - segm_data, is_timelapse=posData.SizeT>1 + segm_data, is_timelapse=posData.SizeT > 1 ) self.progressNewIDs(oldIDs, newIDs) self.sigRemoveItemsGUI.emit(np.max(segm_data)) self.progress.emit( - 'Updating stored data and cell cycle annotations ' - '(if present)...' + "Updating stored data and cell cycle annotations (if present)..." ) mainWin.updateAnnotatedIDs(oldIDs, newIDs, logger=self.progress.emit) @@ -6497,9 +6554,7 @@ def run(self): posData.lab = lab mainWin.get_cca_df() if posData.cca_df is not None: - mainWin.update_cca_df_relabelling( - posData, oldIDs, newIDs - ) + mainWin.update_cca_df_relabelling(posData, oldIDs, newIDs) mainWin.update_rp(draw=False) mainWin.store_data(mainThread=False) @@ -6510,15 +6565,22 @@ def run(self): mainWin.get_data() self.mutex.unlock() - self.finished.emit() + self.finished.emit() + class MagicPromptsWorker(QObject): def __init__( - self, posData, image, df_points, model, model_segment_kwargs, - image_origin=(0, 0, 0), global_image=None - ): + self, + posData, + image, + df_points, + model, + model_segment_kwargs, + image_origin=(0, 0, 0), + global_image=None, + ): QObject.__init__(self) - + self.signals = signals() self.posData = posData self.image = image @@ -6530,51 +6592,45 @@ def __init__( self.image_origin = image_origin self.model = model self.model_segment_kwargs = model_segment_kwargs - + @worker_exception_handler def run(self): from cellacdc.segmenters_promptable import utils - + for row in self.df_points.itertuples(): prompt_id = row.id point = (row.z, row.y, row.x) - print(f'Adding point prompt {point} with id = {prompt_id}...') + print(f"Adding point prompt {point} with id = {prompt_id}...") parent_obj_id = row.Cell_ID if row.Cell_ID == prompt_id else 0 self.model.add_prompt( - prompt=point, - prompt_id=prompt_id, + prompt=point, + prompt_id=prompt_id, parent_obj_id=parent_obj_id, - image=self.image, - image_origin=self.image_origin, - prompt_type='point' + image=self.image, + image_origin=self.image_origin, + prompt_type="point", ) - + lab_out = self.model.segment( - self.global_image, - lab=self.posData.lab, - **self.model_segment_kwargs + self.global_image, lab=self.posData.lab, **self.model_segment_kwargs ) - edited_IDs = self.df_points['Cell_ID'].unique() + edited_IDs = self.df_points["Cell_ID"].unique() - lab_new, lab_union, lab_interesection = ( - utils.insert_model_output_into_labels( - self.posData.lab, - lab_out, - edited_IDs=edited_IDs - ) + lab_new, lab_union, lab_interesection = utils.insert_model_output_into_labels( + self.posData.lab, lab_out, edited_IDs=edited_IDs ) - + self.signals.finished.emit((lab_new, lab_union, lab_interesection)) + class FillHolesInSegWorker(BaseWorkerUtil): sigAskAppendName = Signal(str) sigAborted = Signal() sigSelectSegmFiles = Signal(str, list) - def __init__(self, mainWin): super().__init__(mainWin) - + def emitSelectSegmFiles(self, exp_path, pos_foldernames): self.mutex.lock() self.sigSelectSegmFiles.emit(exp_path, pos_foldernames) @@ -6601,14 +6657,12 @@ def run(self): self.sigAborted.emit() return for pos_folder in pos_foldernames: - imgs_path = os.path.join(exp_path, - pos_folder, - "Images") + imgs_path = os.path.join(exp_path, pos_folder, "Images") lab_paths_dict[imgs_path] = self.endFilenameSegmTemp tot_segm_files += len(self.endFilenameSegmTemp) unique_segm_files.update(self.endFilenameSegmTemp) - self.logger.info('Filling holes in segmentation masks...') + self.logger.info("Filling holes in segmentation masks...") abort = self.emitAskAppendName("/".join(unique_segm_files)) if abort: self.sigAborted.emit() @@ -6628,55 +6682,54 @@ def run(self): elif segm_data_ndim == 4: segm_data = segm_data else: - raise NotImplementedError( - "This ndim is not supported!" - ) + raise NotImplementedError("This ndim is not supported!") for i, stack in enumerate(segm_data): for j, lab in enumerate(stack): segm_data[i, j] = core.fill_holes_in_segmentation(lab) - - segm_data_save_path = (segm_data_path - .replace(segm_file_name, - f"{segm_file_name}{self.appendedName}")) + + segm_data_save_path = segm_data_path.replace( + segm_file_name, f"{segm_file_name}{self.appendedName}" + ) io.savez_compressed(segm_data_save_path, segm_data) self.signals.progressBar.emit(1) self.signals.finished.emit(self) + class GenerateMotherBudTotalTableWorker(BaseWorkerUtil): def __init__( - self, parentWin, input_csv_filepath, selected_options, - out_csv_filepath - ): + self, parentWin, input_csv_filepath, selected_options, out_csv_filepath + ): super().__init__(parentWin) self.input_csv_filepath = input_csv_filepath self.selected_options = selected_options self.out_csv_filepath = out_csv_filepath - + @worker_exception_handler def run(self): - self.logger.log(f'Loading table "{self.input_csv_filepath}"...') + self.logger.log(f'Loading table "{self.input_csv_filepath}"...') self.signals.initProgressBar.emit(0) - + input_df = pd.read_csv(self.input_csv_filepath) - - self.logger.log('Generating output table...') + + self.logger.log("Generating output table...") out_df = cca_functions.generate_mother_bud_total_df( input_df, **self.selected_options ) - - self.logger.log(f'Saving output table to "{self.out_csv_filepath}"...') - + + self.logger.log(f'Saving output table to "{self.out_csv_filepath}"...') + out_df.to_csv(self.out_csv_filepath) - + self.signals.finished.emit(self) - + + class CountObjectsInSegm(BaseWorkerUtil): sigAskAppendName = Signal(str, list) sigAborted = Signal() def __init__(self, mainWin): super().__init__(mainWin) - + @worker_exception_handler def run(self): debugging = False @@ -6687,7 +6740,7 @@ def run(self): self.errors = {} tot_pos = len(pos_foldernames) - self.mainWin.infoText = f'Select segmentation file to count' + self.mainWin.infoText = f"Select segmentation file to count" abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) if abort: self.sigAborted.emit() @@ -6700,22 +6753,22 @@ def run(self): return self.logger.log( - f'Processing experiment n. {i+1}/{tot_exp}, ' - f'{pos} ({p+1}/{tot_pos})' + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" ) - images_path = os.path.join(exp_path, pos, 'Images') + images_path = os.path.join(exp_path, pos, "Images") endFilenameSegm = self.mainWin.endFilenameSegm ls = myutils.listdir(images_path) file_path = [ - os.path.join(images_path, f) for f in ls - if f.endswith(f'{endFilenameSegm}.npz') + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") ][0] - - posData = load.loadData(file_path, '') - self.signals.sigUpdatePbarDesc.emit( - f'Processing {posData.pos_path}') + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") posData.getBasenameAndChNames() posData.buildPaths() @@ -6724,22 +6777,22 @@ def run(self): load_segm_data=True, load_acdc_df=False, load_metadata=True, - end_filename_segm=endFilenameSegm + end_filename_segm=endFilenameSegm, ) if posData.segm_data.ndim == 3: posData.segm_data = posData.segm_data[np.newaxis] - - self.logger.log('Counting objects...') - + + self.logger.log("Counting objects...") + countMapper = posData.countObjectsInSegm() - countMapper.pop('In current frame', None) + countMapper.pop("In current frame", None) df_count_endname = posData.saveObjCounts(countMapper) - + self.logger.log( - 'Saved object counts table to file ending with: ' + "Saved object counts table to file ending with: " f'"{df_count_endname}"' ) - + self.signals.progressBar.emit(1) self.signals.finished.emit(self) diff --git a/notebooks/acdc_paper_plots.ipynb b/notebooks/acdc_paper_plots.ipynb index 645f633ae..596ef911e 100755 --- a/notebooks/acdc_paper_plots.ipynb +++ b/notebooks/acdc_paper_plots.ipynb @@ -44,11 +44,13 @@ "\n", "cwd_path = os.getcwd()\n", "Cell_ACDC_path = os.path.dirname(cwd_path)\n", - "data_dir = os.path.join('..', 'tables', 'paper_plot_data')\n", - "plot_data3a_path = os.path.join(data_dir, 'plot_data3a.csv')\n", - "plot_data3d_path = os.path.join(data_dir, 'p38_AB_AllPos_BF_manual_cell_vol_VS_nucl_vol.csv')\n", - "plot_data4d_left_path = os.path.join(data_dir, 'plot_data4c.csv')\n", - "plot_data4d_right_path = os.path.join(data_dir, 'plot_data4d.csv')\n", + "data_dir = os.path.join(\"..\", \"tables\", \"paper_plot_data\")\n", + "plot_data3a_path = os.path.join(data_dir, \"plot_data3a.csv\")\n", + "plot_data3d_path = os.path.join(\n", + " data_dir, \"p38_AB_AllPos_BF_manual_cell_vol_VS_nucl_vol.csv\"\n", + ")\n", + "plot_data4d_left_path = os.path.join(data_dir, \"plot_data4c.csv\")\n", + "plot_data4d_right_path = os.path.join(data_dir, \"plot_data4d.csv\")\n", "os.path.exists(plot_data3d_path)" ] }, @@ -168,23 +170,23 @@ ], "source": [ "# Discard cells that are larger than 2.5*mean cell_vol_fl\n", - "col = 'cell_vol_fl'\n", + "col = \"cell_vol_fl\"\n", "\n", "df_3d = pd.read_csv(plot_data3d_path)\n", "max_vol_3d = df_3d[col].mean() * 2.5\n", - "df_3d['discard'] = 0\n", - "df_3d.loc[df_3d[col] >= max_vol_3d, 'discard'] = 1\n", + "df_3d[\"discard\"] = 0\n", + "df_3d.loc[df_3d[col] >= max_vol_3d, \"discard\"] = 1\n", "\n", "df_4d = pd.read_csv(plot_data4d_left_path)\n", "max_vol_4d = df_4d[col].mean() * 2.5\n", - "df_4d['discard'] = 0\n", - "df_4d.loc[df_4d[col] >= max_vol_4d, 'discard'] = 1\n", + "df_4d[\"discard\"] = 0\n", + "df_4d.loc[df_4d[col] >= max_vol_4d, \"discard\"] = 1\n", "\n", "df_4d_box = pd.read_csv(plot_data4d_right_path)\n", - "df_4d_box['discard'] = 0\n", - "df_4d_box.loc[df_4d_box[col] >= max_vol_4d, 'discard'] = 1\n", + "df_4d_box[\"discard\"] = 0\n", + "df_4d_box.loc[df_4d_box[col] >= max_vol_4d, \"discard\"] = 1\n", "\n", - "df_4d_box.sort_values(col)[[col, 'discard']]" + "df_4d_box.sort_values(col)[[col, \"discard\"]]" ] }, { @@ -250,78 +252,88 @@ "plot_data3b = pd.read_csv(plot_data3d_path)\n", "\n", "# Drop discarded\n", - "plot_data3b = plot_data3b[plot_data3b['discard'] == 0]\n", + "plot_data3b = plot_data3b[plot_data3b[\"discard\"] == 0]\n", "\n", - "sns.set_theme(context='talk', font_scale=1.6)\n", + "sns.set_theme(context=\"talk\", font_scale=1.6)\n", "sns.set_style(\"whitegrid\", {\"grid.color\": \".95\"})\n", - "fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(20,10))\n", + "fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(20, 10))\n", "sns.regplot(\n", - " data=plot_data3a[plot_data3a.relationship_cellpose=='mother'],\n", - " x='cell_vol_fl_yeaz',\n", - " y='cell_vol_fl_cellpose',\n", - " ax = ax[0],\n", - " color = sns.color_palette()[0]\n", + " data=plot_data3a[plot_data3a.relationship_cellpose == \"mother\"],\n", + " x=\"cell_vol_fl_yeaz\",\n", + " y=\"cell_vol_fl_cellpose\",\n", + " ax=ax[0],\n", + " color=sns.color_palette()[0],\n", ")\n", "sns.regplot(\n", - " data=plot_data3a[plot_data3a.relationship_cellpose=='bud'],\n", - " x='cell_vol_fl_yeaz',\n", - " y='cell_vol_fl_cellpose',\n", - " ax = ax[0],\n", - " color = sns.color_palette()[1]\n", + " data=plot_data3a[plot_data3a.relationship_cellpose == \"bud\"],\n", + " x=\"cell_vol_fl_yeaz\",\n", + " y=\"cell_vol_fl_cellpose\",\n", + " ax=ax[0],\n", + " color=sns.color_palette()[1],\n", ")\n", - "labels = [\n", - " 'Mother cells',\n", - " 'Buds'\n", - "]\n", + "labels = [\"Mother cells\", \"Buds\"]\n", "handles = [\n", - " mlines.Line2D([], [], color=sns.color_palette()[0], marker='o', linestyle='None',\n", - " markersize=10),\n", - " mlines.Line2D([], [], color=sns.color_palette()[1], marker='o', linestyle='None',\n", - " markersize=10)\n", + " mlines.Line2D(\n", + " [],\n", + " [],\n", + " color=sns.color_palette()[0],\n", + " marker=\"o\",\n", + " linestyle=\"None\",\n", + " markersize=10,\n", + " ),\n", + " mlines.Line2D(\n", + " [],\n", + " [],\n", + " color=sns.color_palette()[1],\n", + " marker=\"o\",\n", + " linestyle=\"None\",\n", + " markersize=10,\n", + " ),\n", "]\n", - "ax[0].legend(\n", - " handles=handles,\n", - " labels=labels, \n", - " loc='upper left',\n", - " framealpha=0.5\n", - ")\n", - "scatter_plot_max = max(plot_data3a.cell_vol_fl_yeaz.max(), plot_data3a.cell_vol_fl_cellpose.max())\n", - "ax[0].set_xlabel('Cell vol. (phase contrast + YeaZ) [fL]')\n", - "ax[0].set_ylabel('Cell vol. ($\\it{ACT1pr}$ signal + Cellpose) [fL]')\n", - "#ax[0].set_title('A', fontsize=50, loc='left', pad=30, x=-0.175)\n", - "ax[0].set_ylim(0, int(scatter_plot_max)+10)\n", - "ax[0].set_xlim(0, int(scatter_plot_max)+10)\n", - "ax[0].set_xticks(np.arange(0, scatter_plot_max+10, 20))\n", - "ax[0].set_yticks(np.arange(0, scatter_plot_max+10, 20))\n", + "ax[0].legend(handles=handles, labels=labels, loc=\"upper left\", framealpha=0.5)\n", + "scatter_plot_max = max(\n", + " plot_data3a.cell_vol_fl_yeaz.max(), plot_data3a.cell_vol_fl_cellpose.max()\n", + ")\n", + "ax[0].set_xlabel(\"Cell vol. (phase contrast + YeaZ) [fL]\")\n", + "ax[0].set_ylabel(\"Cell vol. ($\\it{ACT1pr}$ signal + Cellpose) [fL]\")\n", + "# ax[0].set_title('A', fontsize=50, loc='left', pad=30, x=-0.175)\n", + "ax[0].set_ylim(0, int(scatter_plot_max) + 10)\n", + "ax[0].set_xlim(0, int(scatter_plot_max) + 10)\n", + "ax[0].set_xticks(np.arange(0, scatter_plot_max + 10, 20))\n", + "ax[0].set_yticks(np.arange(0, scatter_plot_max + 10, 20))\n", "\n", "sns.regplot(\n", " data=plot_data3b,\n", - " x='nucleus_vol_fl',\n", - " y='cell_vol_fl',\n", + " x=\"nucleus_vol_fl\",\n", + " y=\"cell_vol_fl\",\n", " robust=False,\n", - " ax = ax[1],\n", - " color = sns.color_palette()[2]\n", + " ax=ax[1],\n", + " color=sns.color_palette()[2],\n", ")\n", "\n", "scatter_plot_max = plot_data3b.nucleus_vol_fl.max()\n", - "ax[1].set_ylabel('HSCs vol. (Bright-field + YeaZ) [fL]')\n", - "ax[1].set_xlabel('HSCs nucl. vol. (DAPI signal + StarDist) [fL]')\n", - "ax[1].set_xticks(np.arange(0, scatter_plot_max+10, 100))\n", - "ax[1].set_yticks(np.arange(0, plot_data3b.cell_vol_fl.max()+10, 100))\n", + "ax[1].set_ylabel(\"HSCs vol. (Bright-field + YeaZ) [fL]\")\n", + "ax[1].set_xlabel(\"HSCs nucl. vol. (DAPI signal + StarDist) [fL]\")\n", + "ax[1].set_xticks(np.arange(0, scatter_plot_max + 10, 100))\n", + "ax[1].set_yticks(np.arange(0, plot_data3b.cell_vol_fl.max() + 10, 100))\n", "\n", "\n", "plt.tight_layout()\n", - "plt.savefig('../figures/new_fig3/fig3_final.svg')\n", - "#plt.savefig('../figures/new_fig3/fig3.png', dpi=300)\n", + "plt.savefig(\"../figures/new_fig3/fig3_final.svg\")\n", + "# plt.savefig('../figures/new_fig3/fig3.png', dpi=300)\n", "plt.show()\n", "\n", - "print(f'Sample size Fig. 3A: {len(plot_data3a)//2}')\n", - "pearson_r, p_value = scipy.stats.pearsonr(plot_data3a.cell_vol_fl_yeaz, plot_data3a.cell_vol_fl_cellpose)\n", - "print(f'Pearson Correlation and p-value for non-correlation 3A: {pearson_r, p_value}')\n", + "print(f\"Sample size Fig. 3A: {len(plot_data3a) // 2}\")\n", + "pearson_r, p_value = scipy.stats.pearsonr(\n", + " plot_data3a.cell_vol_fl_yeaz, plot_data3a.cell_vol_fl_cellpose\n", + ")\n", + "print(f\"Pearson Correlation and p-value for non-correlation 3A: {pearson_r, p_value}\")\n", "\n", - "print(f'Sample size Fig. 3B: {len(plot_data3b)}')\n", - "pearson_r, p_value = scipy.stats.pearsonr(plot_data3b.nucleus_vol_fl, plot_data3b.cell_vol_fl)\n", - "print(f'Pearson Correlation and p-value for non-correlation 3A: {pearson_r, p_value}')" + "print(f\"Sample size Fig. 3B: {len(plot_data3b)}\")\n", + "pearson_r, p_value = scipy.stats.pearsonr(\n", + " plot_data3b.nucleus_vol_fl, plot_data3b.cell_vol_fl\n", + ")\n", + "print(f\"Pearson Correlation and p-value for non-correlation 3A: {pearson_r, p_value}\")" ] }, { @@ -401,209 +413,305 @@ ], "source": [ "# load data from csv\n", - "plot_data4a = pd.read_csv(os.path.join(data_dir, 'plot_data4a.csv'))\n", - "plot_data4b = pd.read_csv(os.path.join(data_dir, 'plot_data4b.csv'))\n", + "plot_data4a = pd.read_csv(os.path.join(data_dir, \"plot_data4a.csv\"))\n", + "plot_data4b = pd.read_csv(os.path.join(data_dir, \"plot_data4b.csv\"))\n", "plot_data4c = pd.read_csv(plot_data4d_left_path)\n", "plot_data4d = pd.read_csv(plot_data4d_right_path)\n", "\n", "# Drop discarded\n", - "plot_data4c = plot_data4c[plot_data4c['discard'] == 0]\n", - "plot_data4d = plot_data4d[plot_data4d['discard'] == 0]\n", + "plot_data4c = plot_data4c[plot_data4c[\"discard\"] == 0]\n", + "plot_data4d = plot_data4d[plot_data4d[\"discard\"] == 0]\n", "\n", - "sns.set_theme(context='talk', font_scale=1.725)\n", + "sns.set_theme(context=\"talk\", font_scale=1.725)\n", "sns.set_style(\"whitegrid\", {\"grid.color\": \".95\"})\n", - "fig, axs = plt.subplots(nrows=2, ncols=2, figsize=(24,20))\n", + "fig, axs = plt.subplots(nrows=2, ncols=2, figsize=(24, 20))\n", "\n", "# subplot 1\n", "sns.scatterplot(\n", " data=plot_data4a,\n", - " x='cell_vol_fl',\n", - " y='FITC_concentration',\n", - " ax = axs[0,0],\n", - " color=sns.color_palette('pastel')[2],\n", + " x=\"cell_vol_fl\",\n", + " y=\"FITC_concentration\",\n", + " ax=axs[0, 0],\n", + " color=sns.color_palette(\"pastel\")[2],\n", " s=11,\n", - " legend=False\n", - " #scatter_kws={'s':10},\n", - " #x_bins=20\n", - " #hue='size_category'\n", + " legend=False,\n", + " # scatter_kws={'s':10},\n", + " # x_bins=20\n", + " # hue='size_category'\n", ")\n", "nbins = 12\n", "bins_min_count = 10\n", - "xe, ye, std = cca_functions.binned_mean_stats(plot_data4a.cell_vol_fl, plot_data4a.FITC_concentration, nbins, bins_min_count)\n", - "axs[0,0].errorbar(xe, ye, yerr=std, capsize=6, lw=3, c=sns.color_palette()[2])\n", - "axs[0,0].set_xlabel('Cell volume [fL]')\n", - "axs[0,0].set_ylabel('mTOR activity [a.u.]')\n", - "axs[0,0].ticklabel_format(axis='y', style='sci', scilimits=(0,0), useMathText=True)\n", - "axs[0,0].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", - "#lower_y_border, upper_y_border = plot_data4b.FITC_concentration.min()-10, plot_data4b.FITC_concentration.max()+10\n", - "lower_y_border, upper_y_border = -200, plot_data4a.FITC_concentration.max()+10\n", + "xe, ye, std = cca_functions.binned_mean_stats(\n", + " plot_data4a.cell_vol_fl, plot_data4a.FITC_concentration, nbins, bins_min_count\n", + ")\n", + "axs[0, 0].errorbar(xe, ye, yerr=std, capsize=6, lw=3, c=sns.color_palette()[2])\n", + "axs[0, 0].set_xlabel(\"Cell volume [fL]\")\n", + "axs[0, 0].set_ylabel(\"mTOR activity [a.u.]\")\n", + "axs[0, 0].ticklabel_format(axis=\"y\", style=\"sci\", scilimits=(0, 0), useMathText=True)\n", + "axs[0, 0].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", + "# lower_y_border, upper_y_border = plot_data4b.FITC_concentration.min()-10, plot_data4b.FITC_concentration.max()+10\n", + "lower_y_border, upper_y_border = -200, plot_data4a.FITC_concentration.max() + 10\n", "height = upper_y_border - lower_y_border\n", "# configure borders for \"size blocks\"\n", "xs_borders = 0, np.percentile(plot_data4a.cell_vol_fl, 15)\n", - "m_borders = np.percentile(plot_data4a.cell_vol_fl, 35), np.percentile(plot_data4a.cell_vol_fl, 65)\n", - "xl_borders = np.percentile(plot_data4a.cell_vol_fl, 85), np.percentile(plot_data4a.cell_vol_fl, 85)*2 + 20\n", - "xs_width = xs_borders[1]-xs_borders[0]\n", - "m_width = m_borders[1]-m_borders[0]\n", - "xl_width = xl_borders[1]-xl_borders[0]\n", + "m_borders = (\n", + " np.percentile(plot_data4a.cell_vol_fl, 35),\n", + " np.percentile(plot_data4a.cell_vol_fl, 65),\n", + ")\n", + "xl_borders = (\n", + " np.percentile(plot_data4a.cell_vol_fl, 85),\n", + " np.percentile(plot_data4a.cell_vol_fl, 85) * 2 + 20,\n", + ")\n", + "xs_width = xs_borders[1] - xs_borders[0]\n", + "m_width = m_borders[1] - m_borders[0]\n", + "xl_width = xl_borders[1] - xl_borders[0]\n", "# add gray rectangles for size categories\n", - "axs[0,0].add_patch(\n", - " patches.Rectangle((xs_borders[0], lower_y_border), xs_width, height, color='black', alpha=0.1)\n", + "axs[0, 0].add_patch(\n", + " patches.Rectangle(\n", + " (xs_borders[0], lower_y_border), xs_width, height, color=\"black\", alpha=0.1\n", + " )\n", ")\n", - "axs[0,0].text(0.5*sum(xs_borders)-10, upper_y_border-(upper_y_border//10), 'XS', fontdict={'fontsize':30})\n", - "axs[0,0].add_patch(\n", - " patches.Rectangle((m_borders[0], lower_y_border), m_width, height, color='black', alpha=0.1)\n", + "axs[0, 0].text(\n", + " 0.5 * sum(xs_borders) - 10,\n", + " upper_y_border - (upper_y_border // 10),\n", + " \"XS\",\n", + " fontdict={\"fontsize\": 30},\n", ")\n", - "axs[0,0].text(0.5*sum(m_borders)-10, upper_y_border-(upper_y_border//10), 'M', fontdict={'fontsize':30})\n", - "axs[0,0].add_patch(\n", - " patches.Rectangle((xl_borders[0], lower_y_border), xl_width, height, color='black', alpha=0.1)\n", + "axs[0, 0].add_patch(\n", + " patches.Rectangle(\n", + " (m_borders[0], lower_y_border), m_width, height, color=\"black\", alpha=0.1\n", + " )\n", + ")\n", + "axs[0, 0].text(\n", + " 0.5 * sum(m_borders) - 10,\n", + " upper_y_border - (upper_y_border // 10),\n", + " \"M\",\n", + " fontdict={\"fontsize\": 30},\n", + ")\n", + "axs[0, 0].add_patch(\n", + " patches.Rectangle(\n", + " (xl_borders[0], lower_y_border), xl_width, height, color=\"black\", alpha=0.1\n", + " )\n", + ")\n", + "axs[0, 0].text(\n", + " 0.5 * sum(xl_borders) - 10,\n", + " upper_y_border - (upper_y_border // 10),\n", + " \"XL\",\n", + " fontdict={\"fontsize\": 30},\n", ")\n", - "axs[0,0].text(0.5*sum(xl_borders)-10, upper_y_border-(upper_y_border//10), 'XL', fontdict={'fontsize':30})\n", "# set x and y limits manually\n", - "axs[0,0].set_xlim(0, xl_borders[1])\n", - "axs[0,0].set_ylim(lower_y_border, upper_y_border)\n", - "#axs[1].set_title('B', fontsize=40, loc='left', pad=10)\n", - "#axs[1].set_yscale('log')\n", + "axs[0, 0].set_xlim(0, xl_borders[1])\n", + "axs[0, 0].set_ylim(lower_y_border, upper_y_border)\n", + "# axs[1].set_title('B', fontsize=40, loc='left', pad=10)\n", + "# axs[1].set_yscale('log')\n", "\n", "# subplot 2\n", "sns.boxplot(\n", " data=plot_data4b,\n", - " x='size_category',\n", - " y='FITC_concentration',\n", + " x=\"size_category\",\n", + " y=\"FITC_concentration\",\n", " order=[\"Control\", \"All\", \"XS\", \"M\", \"XL\"],\n", - " palette=['lightgray', sns.color_palette()[2]]+ [sns.color_palette('pastel')[2]]*3,\n", - " ax=axs[0,1],\n", - " #size=1\n", - " #inner='quartile'\n", - ")\n", - "axs[0,1].set_xlabel('Size category')\n", - "axs[0,1].set_ylabel('mTOR activity [a.u.]')\n", - "#axs[0,1].set_title('C', fontsize=40, loc='left', pad=10)\n", - "axs[0,1].set_ylim(lower_y_border, upper_y_border)\n", - "axs[0,1].ticklabel_format(axis='y', style='sci', scilimits=(0,0), useMathText=True)\n", - "axs[0,1].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", - "#axs[0,1].set_yscale('log')\n", - "\n", - "\n", - "print(f'Sample size Fig. 4A&B: {len(plot_data4a)}')\n", - "print(f'Sample size control Fig. 4B: {len(plot_data4b[plot_data4b.size_category==\"Control\"])}')\n", - "print(f'Sample size XS: {len(plot_data4b[plot_data4b.size_category==\"XS\"])}')\n", - "print(f'Sample size M: {len(plot_data4b[plot_data4b.size_category==\"M\"])}')\n", - "print(f'Sample size XL: {len(plot_data4b[plot_data4b.size_category==\"XL\"])}')\n", + " palette=[\"lightgray\", sns.color_palette()[2]]\n", + " + [sns.color_palette(\"pastel\")[2]] * 3,\n", + " ax=axs[0, 1],\n", + " # size=1\n", + " # inner='quartile'\n", + ")\n", + "axs[0, 1].set_xlabel(\"Size category\")\n", + "axs[0, 1].set_ylabel(\"mTOR activity [a.u.]\")\n", + "# axs[0,1].set_title('C', fontsize=40, loc='left', pad=10)\n", + "axs[0, 1].set_ylim(lower_y_border, upper_y_border)\n", + "axs[0, 1].ticklabel_format(axis=\"y\", style=\"sci\", scilimits=(0, 0), useMathText=True)\n", + "axs[0, 1].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", + "# axs[0,1].set_yscale('log')\n", "\n", + "\n", + "print(f\"Sample size Fig. 4A&B: {len(plot_data4a)}\")\n", "print(\n", - " f'ommitted {len(plot_data4a[plot_data4a.FITC_concentration>upper_y_border])} cells with FITC concentration '\n", - " f'higher than {upper_y_border}'\n", - " )\n", + " f\"Sample size control Fig. 4B: {len(plot_data4b[plot_data4b.size_category == 'Control'])}\"\n", + ")\n", + "print(f\"Sample size XS: {len(plot_data4b[plot_data4b.size_category == 'XS'])}\")\n", + "print(f\"Sample size M: {len(plot_data4b[plot_data4b.size_category == 'M'])}\")\n", + "print(f\"Sample size XL: {len(plot_data4b[plot_data4b.size_category == 'XL'])}\")\n", "\n", - "print(f'**Effect sizes FITC amount per volume:**')\n", - "print(f'Effect size (cohen) All vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4b, \"All\", \"Control\",val_column=\"FITC_concentration\"), 2)}')\n", - "print(f'Effect size (cohen) XS vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4b, \"XS\", \"Control\",val_column=\"FITC_concentration\"), 2)}')\n", - "print(f'Effect size (cohen) M vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4b, \"M\", \"Control\",val_column=\"FITC_concentration\"), 2)}')\n", - "print(f'Effect size (cohen) XL vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4b, \"XL\", \"Control\",val_column=\"FITC_concentration\"), 2)}')\n", + "print(\n", + " f\"ommitted {len(plot_data4a[plot_data4a.FITC_concentration > upper_y_border])} cells with FITC concentration \"\n", + " f\"higher than {upper_y_border}\"\n", + ")\n", "\n", - "print(f'Effect size (glass) All vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4b, \"All\", \"Control\",val_column=\"FITC_concentration\"), 2)}')\n", - "print(f'Effect size (glass) XS vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4b, \"XS\", \"Control\",val_column=\"FITC_concentration\"), 2)}')\n", - "print(f'Effect size (glass) M vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4b, \"M\", \"Control\",val_column=\"FITC_concentration\"), 2)}')\n", - "print(f'Effect size (glass) XL vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4b, \"XL\", \"Control\",val_column=\"FITC_concentration\"), 2)}')\n", + "print(f\"**Effect sizes FITC amount per volume:**\")\n", + "print(\n", + " f\"Effect size (cohen) All vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4b, 'All', 'Control', val_column='FITC_concentration'), 2)}\"\n", + ")\n", + "print(\n", + " f\"Effect size (cohen) XS vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4b, 'XS', 'Control', val_column='FITC_concentration'), 2)}\"\n", + ")\n", + "print(\n", + " f\"Effect size (cohen) M vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4b, 'M', 'Control', val_column='FITC_concentration'), 2)}\"\n", + ")\n", + "print(\n", + " f\"Effect size (cohen) XL vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4b, 'XL', 'Control', val_column='FITC_concentration'), 2)}\"\n", + ")\n", + "\n", + "print(\n", + " f\"Effect size (glass) All vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4b, 'All', 'Control', val_column='FITC_concentration'), 2)}\"\n", + ")\n", + "print(\n", + " f\"Effect size (glass) XS vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4b, 'XS', 'Control', val_column='FITC_concentration'), 2)}\"\n", + ")\n", + "print(\n", + " f\"Effect size (glass) M vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4b, 'M', 'Control', val_column='FITC_concentration'), 2)}\"\n", + ")\n", + "print(\n", + " f\"Effect size (glass) XL vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4b, 'XL', 'Control', val_column='FITC_concentration'), 2)}\"\n", + ")\n", "\n", "\n", "# subplot 3\n", "sns.scatterplot(\n", " data=plot_data4c,\n", - " x='cell_vol_fl',\n", - " y='Pp38_concentration',\n", - " ax = axs[1,0],\n", - " color=sns.color_palette('pastel')[0],\n", + " x=\"cell_vol_fl\",\n", + " y=\"Pp38_concentration\",\n", + " ax=axs[1, 0],\n", + " color=sns.color_palette(\"pastel\")[0],\n", " s=11,\n", - " legend=False\n", - " #scatter_kws={'s':10},\n", - " #x_bins=20\n", - " #hue='size_category'\n", + " legend=False,\n", + " # scatter_kws={'s':10},\n", + " # x_bins=20\n", + " # hue='size_category'\n", ")\n", "nbins = 8\n", "bins_min_count = 10\n", - "xe, ye, std = cca_functions.binned_mean_stats(plot_data4c.cell_vol_fl, plot_data4c.Pp38_concentration, nbins, bins_min_count)\n", - "axs[1,0].errorbar(xe, ye, yerr=std, capsize=6, lw=3, c=sns.color_palette()[0])\n", - "axs[1,0].set_xlabel('Nuclear volume (DAPI) [fL]')\n", - "axs[1,0].set_ylabel('p38 activity [a.u.]')\n", - "#lower_y_border, upper_y_border = plot_data3b.FITC_concentration.min()-10, plot_data3b.FITC_concentration.max()+10\n", - "lower_y_border, upper_y_border = -200, plot_data4c.Pp38_concentration.max()+10\n", + "xe, ye, std = cca_functions.binned_mean_stats(\n", + " plot_data4c.cell_vol_fl, plot_data4c.Pp38_concentration, nbins, bins_min_count\n", + ")\n", + "axs[1, 0].errorbar(xe, ye, yerr=std, capsize=6, lw=3, c=sns.color_palette()[0])\n", + "axs[1, 0].set_xlabel(\"Nuclear volume (DAPI) [fL]\")\n", + "axs[1, 0].set_ylabel(\"p38 activity [a.u.]\")\n", + "# lower_y_border, upper_y_border = plot_data3b.FITC_concentration.min()-10, plot_data3b.FITC_concentration.max()+10\n", + "lower_y_border, upper_y_border = -200, plot_data4c.Pp38_concentration.max() + 10\n", "height = upper_y_border - lower_y_border\n", "# configure borders for \"size blocks\"\n", "xs_borders = 0, np.percentile(plot_data4c.cell_vol_fl, 15)\n", - "m_borders = np.percentile(plot_data4c.cell_vol_fl, 35), np.percentile(plot_data4c.cell_vol_fl, 65)\n", - "xl_borders = np.percentile(plot_data4c.cell_vol_fl, 85), np.max(plot_data4c.cell_vol_fl) + 20\n", - "xs_width = xs_borders[1]-xs_borders[0]\n", - "m_width = m_borders[1]-m_borders[0]\n", - "xl_width = xl_borders[1]-xl_borders[0]\n", + "m_borders = (\n", + " np.percentile(plot_data4c.cell_vol_fl, 35),\n", + " np.percentile(plot_data4c.cell_vol_fl, 65),\n", + ")\n", + "xl_borders = (\n", + " np.percentile(plot_data4c.cell_vol_fl, 85),\n", + " np.max(plot_data4c.cell_vol_fl) + 20,\n", + ")\n", + "xs_width = xs_borders[1] - xs_borders[0]\n", + "m_width = m_borders[1] - m_borders[0]\n", + "xl_width = xl_borders[1] - xl_borders[0]\n", "# add gray rectangles for size categories\n", - "axs[1,0].add_patch(\n", - " patches.Rectangle((xs_borders[0], lower_y_border), xs_width, height, color='black', alpha=0.1)\n", + "axs[1, 0].add_patch(\n", + " patches.Rectangle(\n", + " (xs_borders[0], lower_y_border), xs_width, height, color=\"black\", alpha=0.1\n", + " )\n", ")\n", - "axs[1,0].text(0.5*sum(xs_borders)-9, upper_y_border-(upper_y_border//10), 'XS', fontdict={'fontsize':30})\n", - "axs[1,0].add_patch(\n", - " patches.Rectangle((m_borders[0], lower_y_border), m_width, height, color='black', alpha=0.1)\n", + "axs[1, 0].text(\n", + " 0.5 * sum(xs_borders) - 9,\n", + " upper_y_border - (upper_y_border // 10),\n", + " \"XS\",\n", + " fontdict={\"fontsize\": 30},\n", ")\n", - "axs[1,0].text(0.5*sum(m_borders)-6, upper_y_border-(upper_y_border//10), 'M', fontdict={'fontsize':30})\n", - "axs[1,0].add_patch(\n", - " patches.Rectangle((xl_borders[0], lower_y_border), xl_width, height, color='black', alpha=0.1)\n", + "axs[1, 0].add_patch(\n", + " patches.Rectangle(\n", + " (m_borders[0], lower_y_border), m_width, height, color=\"black\", alpha=0.1\n", + " )\n", + ")\n", + "axs[1, 0].text(\n", + " 0.5 * sum(m_borders) - 6,\n", + " upper_y_border - (upper_y_border // 10),\n", + " \"M\",\n", + " fontdict={\"fontsize\": 30},\n", + ")\n", + "axs[1, 0].add_patch(\n", + " patches.Rectangle(\n", + " (xl_borders[0], lower_y_border), xl_width, height, color=\"black\", alpha=0.1\n", + " )\n", + ")\n", + "axs[1, 0].text(\n", + " 0.5 * sum(xl_borders) - 5,\n", + " upper_y_border - (upper_y_border // 10),\n", + " \"XL\",\n", + " fontdict={\"fontsize\": 30},\n", ")\n", - "axs[1,0].text(0.5*sum(xl_borders)-5, upper_y_border-(upper_y_border//10), 'XL', fontdict={'fontsize':30})\n", "# set x and y limits manually\n", - "axs[1,0].set_xlim(0, xl_borders[1])\n", - "axs[1,0].set_ylim(lower_y_border, upper_y_border)\n", - "axs[1,0].ticklabel_format(axis='y', style='sci', scilimits=(0,0), useMathText=True)\n", - "axs[1,0].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", - "#axs[1].set_title('B', fontsize=40, loc='left', pad=10)\n", - "#axs[1].set_yscale('log')\n", + "axs[1, 0].set_xlim(0, xl_borders[1])\n", + "axs[1, 0].set_ylim(lower_y_border, upper_y_border)\n", + "axs[1, 0].ticklabel_format(axis=\"y\", style=\"sci\", scilimits=(0, 0), useMathText=True)\n", + "axs[1, 0].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", + "# axs[1].set_title('B', fontsize=40, loc='left', pad=10)\n", + "# axs[1].set_yscale('log')\n", "\n", "# subplot 4\n", "sns.boxplot(\n", " data=plot_data4d,\n", - " x='size_category',\n", - " y='Pp38_concentration',\n", + " x=\"size_category\",\n", + " y=\"Pp38_concentration\",\n", " order=[\"Control\", \"All\", \"XS\", \"M\", \"XL\"],\n", - " palette=['lightgray', sns.color_palette()[0]]+ [sns.color_palette('pastel')[0]]*3,\n", - " ax=axs[1,1],\n", - " #size=1\n", - " #inner='quartile'\n", - ")\n", - "axs[1,1].set_xlabel('Size category')\n", - "axs[1,1].set_ylabel('p38 activity [a.u.]')\n", - "#axs[1,1].set_title('C', fontsize=40, loc='left', pad=10)\n", - "axs[1,1].set_ylim(lower_y_border, upper_y_border)\n", - "axs[1,1].ticklabel_format(axis='y', style='sci', scilimits=(0,0), useMathText=True)\n", - "axs[1,1].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", - "#axs[1,1].set_yscale('log')\n", + " palette=[\"lightgray\", sns.color_palette()[0]]\n", + " + [sns.color_palette(\"pastel\")[0]] * 3,\n", + " ax=axs[1, 1],\n", + " # size=1\n", + " # inner='quartile'\n", + ")\n", + "axs[1, 1].set_xlabel(\"Size category\")\n", + "axs[1, 1].set_ylabel(\"p38 activity [a.u.]\")\n", + "# axs[1,1].set_title('C', fontsize=40, loc='left', pad=10)\n", + "axs[1, 1].set_ylim(lower_y_border, upper_y_border)\n", + "axs[1, 1].ticklabel_format(axis=\"y\", style=\"sci\", scilimits=(0, 0), useMathText=True)\n", + "axs[1, 1].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", + "# axs[1,1].set_yscale('log')\n", "\n", "plt.tight_layout()\n", "\n", - "plt.savefig('../figures/new_fig4/combined_fig4_v4.svg')\n", - "plt.savefig('../figures/new_fig4/combined_fig4_v4.png', dpi=300)\n", + "plt.savefig(\"../figures/new_fig4/combined_fig4_v4.svg\")\n", + "plt.savefig(\"../figures/new_fig4/combined_fig4_v4.png\", dpi=300)\n", "\n", "plt.show()\n", "\n", "\n", - "print(f'Sample size Fig. 4C&D: {len(plot_data4c)}')\n", - "print(f'Sample size control Fig. 4D: {len(plot_data4d[plot_data4d.size_category==\"Control\"])}')\n", - "print(f'Sample size XS: {len(plot_data4d[plot_data4d.size_category==\"XS\"])}')\n", - "print(f'Sample size M: {len(plot_data4d[plot_data4d.size_category==\"M\"])}')\n", - "print(f'Sample size XL: {len(plot_data4d[plot_data4d.size_category==\"XL\"])}')\n", + "print(f\"Sample size Fig. 4C&D: {len(plot_data4c)}\")\n", + "print(\n", + " f\"Sample size control Fig. 4D: {len(plot_data4d[plot_data4d.size_category == 'Control'])}\"\n", + ")\n", + "print(f\"Sample size XS: {len(plot_data4d[plot_data4d.size_category == 'XS'])}\")\n", + "print(f\"Sample size M: {len(plot_data4d[plot_data4d.size_category == 'M'])}\")\n", + "print(f\"Sample size XL: {len(plot_data4d[plot_data4d.size_category == 'XL'])}\")\n", + "\n", + "print(\n", + " f\"ommitted {len(plot_data4c[plot_data4c.Pp38_concentration > upper_y_border])} cells with Pp38 concentration \"\n", + " f\"higher than {upper_y_border}\"\n", + ")\n", "\n", "print(\n", - " f'ommitted {len(plot_data4c[plot_data4c.Pp38_concentration>upper_y_border])} cells with Pp38 concentration '\n", - " f'higher than {upper_y_border}'\n", - " )\n", - "\n", - "print(f'Effect size (cohen) All vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4d, \"All\", \"Control\"), 2)}')\n", - "print(f'Effect size (cohen) XS vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4d, \"XS\", \"Control\"), 2)}')\n", - "print(f'Effect size (cohen) M vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4d, \"M\", \"Control\"), 2)}')\n", - "print(f'Effect size (cohen) XL vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4d, \"XL\", \"Control\"), 2)}')\n", - "\n", - "print(f'Effect size (glass) All vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4d, \"All\", \"Control\"), 2)}')\n", - "print(f'Effect size (glass) XS vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4d, \"XS\", \"Control\"), 2)}')\n", - "print(f'Effect size (glass) M vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4d, \"M\", \"Control\"), 2)}')\n", - "print(f'Effect size (glass) XL vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4d, \"XL\", \"Control\"), 2)}')" + " f\"Effect size (cohen) All vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4d, 'All', 'Control'), 2)}\"\n", + ")\n", + "print(\n", + " f\"Effect size (cohen) XS vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4d, 'XS', 'Control'), 2)}\"\n", + ")\n", + "print(\n", + " f\"Effect size (cohen) M vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4d, 'M', 'Control'), 2)}\"\n", + ")\n", + "print(\n", + " f\"Effect size (cohen) XL vs. Control: {round(cca_functions.calculate_effect_size_cohen(plot_data4d, 'XL', 'Control'), 2)}\"\n", + ")\n", + "\n", + "print(\n", + " f\"Effect size (glass) All vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4d, 'All', 'Control'), 2)}\"\n", + ")\n", + "print(\n", + " f\"Effect size (glass) XS vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4d, 'XS', 'Control'), 2)}\"\n", + ")\n", + "print(\n", + " f\"Effect size (glass) M vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4d, 'M', 'Control'), 2)}\"\n", + ")\n", + "print(\n", + " f\"Effect size (glass) XL vs. Control: {round(cca_functions.calculate_effect_size_glass(plot_data4d, 'XL', 'Control'), 2)}\"\n", + ")" ] }, { @@ -661,219 +769,224 @@ } ], "source": [ - "plot_data5a = pd.read_csv(os.path.join(data_dir, 'plot_data5a_v2.csv'))\n", - "plot_data5a_melted = pd.read_csv(os.path.join(data_dir, 'plot_data5a_melted_v2.csv'))\n", - "plot_data5b = pd.read_csv(os.path.join(data_dir, 'plot_data5b_v2.csv'))\n", - "plot_data5c = pd.read_csv(os.path.join(data_dir, 'plot_data5c.csv'))\n", - "sns.set_theme(context='talk', font_scale=1.6)\n", + "plot_data5a = pd.read_csv(os.path.join(data_dir, \"plot_data5a_v2.csv\"))\n", + "plot_data5a_melted = pd.read_csv(os.path.join(data_dir, \"plot_data5a_melted_v2.csv\"))\n", + "plot_data5b = pd.read_csv(os.path.join(data_dir, \"plot_data5b_v2.csv\"))\n", + "plot_data5c = pd.read_csv(os.path.join(data_dir, \"plot_data5c.csv\"))\n", + "sns.set_theme(context=\"talk\", font_scale=1.6)\n", "sns.set_style(\"whitegrid\", {\"grid.color\": \".95\"})\n", - "fig, axs = plt.subplots(nrows=2, ncols=2, figsize=(20,20))#, sharey='row')\n", + "fig, axs = plt.subplots(nrows=2, ncols=2, figsize=(20, 20)) # , sharey='row')\n", "\n", - "shared_y_max = plot_data5b.relevant_amount.max()+0.2e5\n", + "shared_y_max = plot_data5b.relevant_amount.max() + 0.2e5\n", "split_by_gen = True\n", "\n", "# subplot 1\n", "if split_by_gen:\n", - " style='Generation'\n", + " style = \"Generation\"\n", "else:\n", - " style=None\n", + " style = None\n", "sns.lineplot(\n", - " data=plot_data5a_melted[plot_data5a_melted.centered_time_in_minutes>=0].sort_values('Generation', ascending=False),\n", - " x=\"centered_time_in_minutes\", \n", + " data=plot_data5a_melted[\n", + " plot_data5a_melted.centered_time_in_minutes >= 0\n", + " ].sort_values(\"Generation\", ascending=False),\n", + " x=\"centered_time_in_minutes\",\n", " y=\"value\",\n", - " hue='Method of calculation',\n", - " palette=[sns.color_palette('dark')[0],sns.color_palette('dark')[1]],\n", + " hue=\"Method of calculation\",\n", + " palette=[sns.color_palette(\"dark\")[0], sns.color_palette(\"dark\")[1]],\n", " style=style,\n", " ci=95,\n", - " ax=axs[0,0],\n", - " legend=False\n", + " ax=axs[0, 0],\n", + " legend=False,\n", ")\n", "sns.lineplot(\n", " data=plot_data5a_melted[\n", - " (plot_data5a_melted.centered_time_in_minutes<=0) &\n", - " (plot_data5a_melted['Method of calculation'] == \"Combined signal\")\n", - " ].sort_values('Generation', ascending=False),\n", - " x=\"centered_time_in_minutes\", \n", + " (plot_data5a_melted.centered_time_in_minutes <= 0)\n", + " & (plot_data5a_melted[\"Method of calculation\"] == \"Combined signal\")\n", + " ].sort_values(\"Generation\", ascending=False),\n", + " x=\"centered_time_in_minutes\",\n", " y=\"value\",\n", - " hue='Method of calculation',\n", - " palette=[sns.color_palette('pastel')[1]],\n", + " hue=\"Method of calculation\",\n", + " palette=[sns.color_palette(\"pastel\")[1]],\n", " style=style,\n", " ci=95,\n", - " ax=axs[0,0],\n", - " legend=False\n", + " ax=axs[0, 0],\n", + " legend=False,\n", ")\n", "\n", - "axs[0,0].axvline(x=0, color='red')#, label='Time of Bud Emergence')\n", - "axs[0,0].text(\n", - " 0.7, 1.5e5, \"Time of \\nbud emerg.\", horizontalalignment='left', \n", - " size='medium', color='red', weight='normal'\n", + "axs[0, 0].axvline(x=0, color=\"red\") # , label='Time of Bud Emergence')\n", + "axs[0, 0].text(\n", + " 0.7,\n", + " 1.5e5,\n", + " \"Time of \\nbud emerg.\",\n", + " horizontalalignment=\"left\",\n", + " size=\"medium\",\n", + " color=\"red\",\n", + " weight=\"normal\",\n", ")\n", "# custom legend\n", - "labels = [\n", - " 'Combined signal',\n", - " 'Bud signal',\n", - " 'Division 1',\n", - " 'Divisions 2+'\n", - "]\n", + "labels = [\"Combined signal\", \"Bud signal\", \"Division 1\", \"Divisions 2+\"]\n", "handles = [\n", - " mpatches.Patch(color=sns.color_palette('pastel')[1]),\n", - " mpatches.Patch(color=sns.color_palette('dark')[0]),\n", - " mlines.Line2D([], [], color='gray', linestyle='-'),\n", - " mlines.Line2D([], [], color='gray', linestyle='--')\n", + " mpatches.Patch(color=sns.color_palette(\"pastel\")[1]),\n", + " mpatches.Patch(color=sns.color_palette(\"dark\")[0]),\n", + " mlines.Line2D([], [], color=\"gray\", linestyle=\"-\"),\n", + " mlines.Line2D([], [], color=\"gray\", linestyle=\"--\"),\n", "]\n", "handles2 = [\n", - " mpatches.Patch(color=sns.color_palette('dark')[1]),\n", - " mpatches.Patch(color='white'),\n", - " mpatches.Patch(color='white'),\n", - " mpatches.Patch(color='white'),\n", + " mpatches.Patch(color=sns.color_palette(\"dark\")[1]),\n", + " mpatches.Patch(color=\"white\"),\n", + " mpatches.Patch(color=\"white\"),\n", + " mpatches.Patch(color=\"white\"),\n", "]\n", - "axs[0,0].legend(\n", - " handles=handles+handles2,\n", + "axs[0, 0].legend(\n", + " handles=handles + handles2,\n", " ncol=2,\n", - " labels=['']*4+labels,\n", + " labels=[\"\"] * 4 + labels,\n", " columnspacing=-0.5,\n", - " loc='upper left',\n", - " bbox_to_anchor = (-.01,1),\n", + " loc=\"upper left\",\n", + " bbox_to_anchor=(-0.01, 1),\n", " framealpha=0.5,\n", - " handlelength=1\n", - ")\n", - "#plt.setp(axs[0,0].get_legend().get_title(), fontsize='20') \n", - "axs[0,0].set_ylabel(\"Amount of Htb1-mCitrine [a.u.]\")\n", - "axs[0,0].set_xlabel(\"Time since bud emergence [minutes]\")\n", - "#axs[0,0].set_title('A', fontsize=30, loc='left', pad=10)\n", - "axs[0,0].ticklabel_format(axis='y', style='sci', scilimits=(0,0), useMathText=True)\n", - "axs[0,0].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", - "axs[0,0].set_ylim(-0.2e5, shared_y_max)\n", - "axs[0,0].set_xlim(\n", + " handlelength=1,\n", + ")\n", + "# plt.setp(axs[0,0].get_legend().get_title(), fontsize='20')\n", + "axs[0, 0].set_ylabel(\"Amount of Htb1-mCitrine [a.u.]\")\n", + "axs[0, 0].set_xlabel(\"Time since bud emergence [minutes]\")\n", + "# axs[0,0].set_title('A', fontsize=30, loc='left', pad=10)\n", + "axs[0, 0].ticklabel_format(axis=\"y\", style=\"sci\", scilimits=(0, 0), useMathText=True)\n", + "axs[0, 0].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", + "axs[0, 0].set_ylim(-0.2e5, shared_y_max)\n", + "axs[0, 0].set_xlim(\n", " plot_data5a_melted.centered_time_in_minutes.min(),\n", - " plot_data5a_melted.centered_time_in_minutes.max()\n", + " plot_data5a_melted.centered_time_in_minutes.max(),\n", ")\n", - "#axs[0,0].legend().get_texts()[0].set_text(matplotlib.text.Text(text='test', fontweight=1000))\n", + "# axs[0,0].legend().get_texts()[0].set_text(matplotlib.text.Text(text='test', fontweight=1000))\n", "\n", - "#subplot 2\n", + "# subplot 2\n", "# Initialize the figure\n", "custom_colors = [\n", - " sns.color_palette('dark')[1],\n", - " sns.color_palette('pastel')[1],\n", + " sns.color_palette(\"dark\")[1],\n", + " sns.color_palette(\"pastel\")[1],\n", " sns.color_palette()[4],\n", - " sns.color_palette()[2]\n", + " sns.color_palette()[2],\n", "]\n", "sns.scatterplot(\n", - " x=\"relevant_volume\", \n", - " y=\"relevant_amount\", \n", - " data=plot_data5b[plot_data5b.generation_num==1].sort_values(\n", - " 'Kind of Measurement new', ascending=False\n", + " x=\"relevant_volume\",\n", + " y=\"relevant_amount\",\n", + " data=plot_data5b[plot_data5b.generation_num == 1].sort_values(\n", + " \"Kind of Measurement new\", ascending=False\n", " ),\n", " palette=custom_colors,\n", " hue=\"Kind of Measurement new\",\n", - " marker='x',\n", - " ax=axs[0,1]\n", + " marker=\"x\",\n", + " ax=axs[0, 1],\n", ")\n", "\n", "sns.scatterplot(\n", - " x=\"relevant_volume\", \n", - " y=\"relevant_amount\", \n", - " data=plot_data5b[plot_data5b.generation_num>1].sort_values(\n", - " 'Kind of Measurement new', ascending=False\n", + " x=\"relevant_volume\",\n", + " y=\"relevant_amount\",\n", + " data=plot_data5b[plot_data5b.generation_num > 1].sort_values(\n", + " \"Kind of Measurement new\", ascending=False\n", " ),\n", " palette=custom_colors,\n", " hue=\"Kind of Measurement new\",\n", " legend=False,\n", - " marker='o',\n", - " ax=axs[0,1]\n", + " marker=\"o\",\n", + " ax=axs[0, 1],\n", ")\n", "measurements = [\n", - " 'Mother+bud at cytokinesis',\n", - " 'At G1-entry',\n", - " 'AF control, m+b at cytokinesis',\n", - " 'AF control at G1-entry'\n", + " \"Mother+bud at cytokinesis\",\n", + " \"At G1-entry\",\n", + " \"AF control, m+b at cytokinesis\",\n", + " \"AF control at G1-entry\",\n", "]\n", "\n", "# add regplots in for loop\n", - "print(pd.unique(plot_data5b['Kind of Measurement new']))\n", + "print(pd.unique(plot_data5b[\"Kind of Measurement new\"]))\n", "for idx, measure in enumerate(measurements):\n", " sns.regplot(\n", - " x=\"relevant_volume\", \n", - " y=\"relevant_amount\", \n", - " data=plot_data5b[plot_data5b['Kind of Measurement new']==measure],\n", + " x=\"relevant_volume\",\n", + " y=\"relevant_amount\",\n", + " data=plot_data5b[plot_data5b[\"Kind of Measurement new\"] == measure],\n", " color=custom_colors[idx],\n", " scatter=False,\n", - " ax=axs[0,1]\n", + " ax=axs[0, 1],\n", " )\n", - "labels = [\n", - " 'Division 1',\n", - " 'Divisions 2+'\n", - "]\n", + "labels = [\"Division 1\", \"Divisions 2+\"]\n", "handles = [\n", - " mpatches.Patch(color=sns.color_palette('dark')[1]),\n", - " mpatches.Patch(color=sns.color_palette('pastel')[1]),\n", + " mpatches.Patch(color=sns.color_palette(\"dark\")[1]),\n", + " mpatches.Patch(color=sns.color_palette(\"pastel\")[1]),\n", " mpatches.Patch(color=sns.color_palette()[2]),\n", " mpatches.Patch(color=sns.color_palette()[4]),\n", - " mlines.Line2D([], [], color='gray', marker='x', linestyle='None',\n", - " markersize=10),\n", - " mlines.Line2D([], [], color='gray', marker='o', linestyle='None',\n", - " markersize=10)\n", + " mlines.Line2D([], [], color=\"gray\", marker=\"x\", linestyle=\"None\", markersize=10),\n", + " mlines.Line2D([], [], color=\"gray\", marker=\"o\", linestyle=\"None\", markersize=10),\n", "]\n", - "axs[0,1].legend(\n", + "axs[0, 1].legend(\n", " handles=handles,\n", - " labels=measurements+labels, \n", - " loc='lower right',\n", - " #bbox_to_anchor = (1,0),\n", + " labels=measurements + labels,\n", + " loc=\"lower right\",\n", + " # bbox_to_anchor = (1,0),\n", " framealpha=0.5,\n", - " handlelength=0.75\n", + " handlelength=0.75,\n", ")\n", - "axs[0,1].set_ylabel(\"Amount of Htb1-mCitrine [a.u.]\")\n", - "axs[0,1].set_xlabel('Cell volume at G1-entry / before cytokinesis [fL]')\n", + "axs[0, 1].set_ylabel(\"Amount of Htb1-mCitrine [a.u.]\")\n", + "axs[0, 1].set_xlabel(\"Cell volume at G1-entry / before cytokinesis [fL]\")\n", "# format y-axis\n", - "axs[0,1].ticklabel_format(axis='y', style='sci', scilimits=(0,0), useMathText=True)\n", - "axs[0,1].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", + "axs[0, 1].ticklabel_format(axis=\"y\", style=\"sci\", scilimits=(0, 0), useMathText=True)\n", + "axs[0, 1].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", "# format x-axis\n", - "axs[0,1].set_xlim(0, plot_data5b.relevant_volume.max()+2)\n", - "axs[0,1].set_ylim(-0.2e5, shared_y_max)\n", - "#axs[0,1].set_title('B', fontsize=30, loc='left', pad=10)\n", + "axs[0, 1].set_xlim(0, plot_data5b.relevant_volume.max() + 2)\n", + "axs[0, 1].set_ylim(-0.2e5, shared_y_max)\n", + "# axs[0,1].set_title('B', fontsize=30, loc='left', pad=10)\n", "\n", "sns.boxplot(\n", " data=plot_data5c,\n", - " x='x_label',\n", - " y='mCitrine_corrected_concentration',\n", - " palette='vlag',\n", + " x=\"x_label\",\n", + " y=\"mCitrine_corrected_concentration\",\n", + " palette=\"vlag\",\n", " fliersize=0,\n", - " ax=axs[1,0]\n", + " ax=axs[1, 0],\n", ")\n", "\n", - "#add stripplot on top\n", + "# add stripplot on top\n", "sns.stripplot(\n", " data=plot_data5c,\n", - " x='x_label',\n", - " y='mCitrine_corrected_concentration',\n", + " x=\"x_label\",\n", + " y=\"mCitrine_corrected_concentration\",\n", " color=\".3\",\n", - " ax=axs[1,0]\n", + " ax=axs[1, 0],\n", ")\n", "\n", "# switch to scientific number format on y-Axis and move text\n", - "axs[1,0].ticklabel_format(axis='y', style='sci', scilimits=(0,0), useMathText=True)\n", - "axs[1,0].get_yaxis().get_offset_text().set_position((-0.07,0))\n", + "axs[1, 0].ticklabel_format(axis=\"y\", style=\"sci\", scilimits=(0, 0), useMathText=True)\n", + "axs[1, 0].get_yaxis().get_offset_text().set_position((-0.07, 0))\n", "\n", "# Rename axes and set title\n", - "axs[1,0].set_ylabel(\"Htb1-mCitrine amount per volume\\nin mother cell at division [a.u.]\")#, fontsize=20)\n", - "axs[1,0].set_xlabel(\"Division\")\n", - "#axs[1,0].set_title(f\"Concentration by Generation (n={len(plot_data5c)})\", fontsize=25) # changed this from 30 to 25 compared to 5B\n", - "axs[1,0].set_ylim(0, plot_data5c.mCitrine_corrected_concentration.max()+0.1e4)\n", + "axs[1, 0].set_ylabel(\n", + " \"Htb1-mCitrine amount per volume\\nin mother cell at division [a.u.]\"\n", + ") # , fontsize=20)\n", + "axs[1, 0].set_xlabel(\"Division\")\n", + "# axs[1,0].set_title(f\"Concentration by Generation (n={len(plot_data5c)})\", fontsize=25) # changed this from 30 to 25 compared to 5B\n", + "axs[1, 0].set_ylim(0, plot_data5c.mCitrine_corrected_concentration.max() + 0.1e4)\n", "\n", "plt.tight_layout()\n", "\n", - "plt.savefig(os.path.join('..', 'figures', 'new_fig5', 'combined_fig5_v4.png'), dpi=300)\n", - "plt.savefig(os.path.join('..', 'figures', 'new_fig5', 'combined_fig5_v4.svg'))\n", + "plt.savefig(os.path.join(\"..\", \"figures\", \"new_fig5\", \"combined_fig5_v4.png\"), dpi=300)\n", + "plt.savefig(os.path.join(\"..\", \"figures\", \"new_fig5\", \"combined_fig5_v4.svg\"))\n", "\n", "plt.show()\n", - "sample_size5a = len(plot_data5a[['position', 'Cell_ID', 'file', 'generation_num']].drop_duplicates())\n", + "sample_size5a = len(\n", + " plot_data5a[[\"position\", \"Cell_ID\", \"file\", \"generation_num\"]].drop_duplicates()\n", + ")\n", "sample_size5b = len(plot_data5b)\n", - "print(f'Fig 5A sample size: {sample_size5a}')\n", - "print(f'Fig 5A sample sizes by generation: {plot_data5a_melted.Generation.unique()}')\n", - "print(f\"Fig 5B sample size: {int(sample_size5b/2)}\")\n", - "print(f'Fig 5B sample size flu-control: {len(plot_data5b[plot_data5b.selection_subset==1])//2}')\n", - "print(f'Fig 5B sample size tagged strain: {len(plot_data5b[plot_data5b.selection_subset==0])//2}')\n", - "print(f'Fig 5C sample size: {len(plot_data5c)}')" + "print(f\"Fig 5A sample size: {sample_size5a}\")\n", + "print(f\"Fig 5A sample sizes by generation: {plot_data5a_melted.Generation.unique()}\")\n", + "print(f\"Fig 5B sample size: {int(sample_size5b / 2)}\")\n", + "print(\n", + " f\"Fig 5B sample size flu-control: {len(plot_data5b[plot_data5b.selection_subset == 1]) // 2}\"\n", + ")\n", + "print(\n", + " f\"Fig 5B sample size tagged strain: {len(plot_data5b[plot_data5b.selection_subset == 0]) // 2}\"\n", + ")\n", + "print(f\"Fig 5C sample size: {len(plot_data5c)}\")" ] }, { @@ -883,7 +996,7 @@ "metadata": {}, "outputs": [], "source": [ - "plot_data5a_melted[plot_data5a_melted.centered_time_in_minutes<=0]" + "plot_data5a_melted[plot_data5a_melted.centered_time_in_minutes <= 0]" ] }, { @@ -921,13 +1034,17 @@ }, "outputs": [], "source": [ - "phase_contr_yeaz_data = pd.read_csv(os.path.join(data_dir, 'SegmPhaseContr_YeaZ_AllPos_acdc_output.csv'))\n", - "cellpose_act1_data = pd.read_csv(os.path.join(data_dir, 'SegmACT1_Cellpose_AllPos_acdc_output.csv'))\n", + "phase_contr_yeaz_data = pd.read_csv(\n", + " os.path.join(data_dir, \"SegmPhaseContr_YeaZ_AllPos_acdc_output.csv\")\n", + ")\n", + "cellpose_act1_data = pd.read_csv(\n", + " os.path.join(data_dir, \"SegmACT1_Cellpose_AllPos_acdc_output.csv\")\n", + ")\n", "merged_vol_data = pd.merge(\n", " phase_contr_yeaz_data,\n", " cellpose_act1_data,\n", - " on=['Position_n', 'Cell_ID'],\n", - " suffixes=('_yeaz', '_cellpose')\n", + " on=[\"Position_n\", \"Cell_ID\"],\n", + " suffixes=(\"_yeaz\", \"_cellpose\"),\n", ")" ] }, @@ -944,37 +1061,46 @@ }, "outputs": [], "source": [ - "plt.figure(figsize=(10,10))\n", + "plt.figure(figsize=(10, 10))\n", "fig = sns.lmplot(\n", " data=merged_vol_data,\n", - " x='cell_vol_fl_yeaz',\n", - " y='cell_vol_fl_cellpose',\n", - " hue='relationship_cellpose',\n", + " x=\"cell_vol_fl_yeaz\",\n", + " y=\"cell_vol_fl_cellpose\",\n", + " hue=\"relationship_cellpose\",\n", " height=7.5,\n", - " legend=False\n", + " legend=False,\n", ")\n", "ax = plt.gca()\n", - "labels = [\n", - " 'Mother cells',\n", - " 'Buds & daughter cells'\n", - "]\n", + "labels = [\"Mother cells\", \"Buds & daughter cells\"]\n", "handles = [\n", - " mlines.Line2D([], [], color=sns.color_palette()[0], marker='o', linestyle='None',\n", - " markersize=10),\n", - " mlines.Line2D([], [], color=sns.color_palette()[1], marker='o', linestyle='None',\n", - " markersize=10)\n", + " mlines.Line2D(\n", + " [],\n", + " [],\n", + " color=sns.color_palette()[0],\n", + " marker=\"o\",\n", + " linestyle=\"None\",\n", + " markersize=10,\n", + " ),\n", + " mlines.Line2D(\n", + " [],\n", + " [],\n", + " color=sns.color_palette()[1],\n", + " marker=\"o\",\n", + " linestyle=\"None\",\n", + " markersize=10,\n", + " ),\n", "]\n", "ax.legend(\n", " handles=handles,\n", - " labels=labels, \n", - " loc='center right',\n", - " bbox_to_anchor = (1,0.2),\n", - " framealpha=0.5\n", + " labels=labels,\n", + " loc=\"center right\",\n", + " bbox_to_anchor=(1, 0.2),\n", + " framealpha=0.5,\n", ")\n", - "ax.set_xlabel('Cell Volume Phase Contrast + YeaZ [fL]')\n", - "ax.set_ylabel('Cell Volume Act1 signal + cellpose [fL]')\n", + "ax.set_xlabel(\"Cell Volume Phase Contrast + YeaZ [fL]\")\n", + "ax.set_ylabel(\"Cell Volume Act1 signal + cellpose [fL]\")\n", "plt.show()\n", - "#merged_vol_data.to_csv(os.path.join(data_dir, 'plot_data3a.csv'), index=False)" + "# merged_vol_data.to_csv(os.path.join(data_dir, 'plot_data3a.csv'), index=False)" ] }, { @@ -1011,12 +1137,20 @@ }, "outputs": [], "source": [ - "stem_data = pd.read_csv(os.path.join(data_dir, 'p38_AB_AllPos_acdc_output.csv'))\n", + "stem_data = pd.read_csv(os.path.join(data_dir, \"p38_AB_AllPos_acdc_output.csv\"))\n", "# configure borders for \"size blocks\"\n", "xs_borders = 0, np.percentile(stem_data.cell_vol_fl, 15)\n", - "m_borders = np.percentile(stem_data.cell_vol_fl, 35), np.percentile(stem_data.cell_vol_fl, 65)\n", - "xl_borders = np.percentile(stem_data.cell_vol_fl, 85), np.max(stem_data.cell_vol_fl) + 20\n", - "stem_data['Pp38_concentration'] = stem_data['Pp38_amount_autoBkgr_zSlice'] / stem_data['cell_vol_fl']" + "m_borders = (\n", + " np.percentile(stem_data.cell_vol_fl, 35),\n", + " np.percentile(stem_data.cell_vol_fl, 65),\n", + ")\n", + "xl_borders = (\n", + " np.percentile(stem_data.cell_vol_fl, 85),\n", + " np.max(stem_data.cell_vol_fl) + 20,\n", + ")\n", + "stem_data[\"Pp38_concentration\"] = (\n", + " stem_data[\"Pp38_amount_autoBkgr_zSlice\"] / stem_data[\"cell_vol_fl\"]\n", + ")" ] }, { @@ -1043,33 +1177,46 @@ }, "outputs": [], "source": [ - "plt.subplots(figsize=(10,10))\n", - "sns.set_theme(context='talk', style='darkgrid')\n", + "plt.subplots(figsize=(10, 10))\n", + "sns.set_theme(context=\"talk\", style=\"darkgrid\")\n", "ax = sns.scatterplot(\n", " data=stem_data,\n", - " x='cell_vol_fl',\n", - " y='Pp38_concentration',\n", - " #hue='size_category'\n", + " x=\"cell_vol_fl\",\n", + " y=\"Pp38_concentration\",\n", + " # hue='size_category'\n", + ")\n", + "ax.set_xlabel(\"HSC Nuclear Volume [fL]\")\n", + "ax.set_ylabel(\"Mean intensity Pp38 [a.u.]\")\n", + "lower_y_border, upper_y_border = (\n", + " stem_data.Pp38_concentration.min() - 10,\n", + " stem_data.Pp38_concentration.max() + 10,\n", ")\n", - "ax.set_xlabel('HSC Nuclear Volume [fL]')\n", - "ax.set_ylabel('Mean intensity Pp38 [a.u.]')\n", - "lower_y_border, upper_y_border = stem_data.Pp38_concentration.min()-10, stem_data.Pp38_concentration.max()+10\n", "height = upper_y_border - lower_y_border\n", - "xs_width = xs_borders[1]-xs_borders[0]\n", - "m_width = m_borders[1]-m_borders[0]\n", - "xl_width = xl_borders[1]-xl_borders[0]\n", + "xs_width = xs_borders[1] - xs_borders[0]\n", + "m_width = m_borders[1] - m_borders[0]\n", + "xl_width = xl_borders[1] - xl_borders[0]\n", "ax.add_patch(\n", - " patches.Rectangle((xs_borders[0], lower_y_border), xs_width, height, color='black', alpha=0.2)\n", + " patches.Rectangle(\n", + " (xs_borders[0], lower_y_border), xs_width, height, color=\"black\", alpha=0.2\n", + " )\n", + ")\n", + "plt.text(\n", + " 0.5 * sum(xs_borders) - 20, upper_y_border - 50, \"XS\", fontdict={\"fontsize\": 30}\n", ")\n", - "plt.text(0.5*sum(xs_borders)-20, upper_y_border-50, 'XS', fontdict={'fontsize':30})\n", "ax.add_patch(\n", - " patches.Rectangle((m_borders[0], lower_y_border), m_width, height, color='black', alpha=0.2)\n", + " patches.Rectangle(\n", + " (m_borders[0], lower_y_border), m_width, height, color=\"black\", alpha=0.2\n", + " )\n", ")\n", - "plt.text(0.5*sum(m_borders)-20, upper_y_border-50, 'M', fontdict={'fontsize':30})\n", + "plt.text(0.5 * sum(m_borders) - 20, upper_y_border - 50, \"M\", fontdict={\"fontsize\": 30})\n", "ax.add_patch(\n", - " patches.Rectangle((xl_borders[0], lower_y_border), xl_width, height, color='black', alpha=0.2)\n", + " patches.Rectangle(\n", + " (xl_borders[0], lower_y_border), xl_width, height, color=\"black\", alpha=0.2\n", + " )\n", + ")\n", + "plt.text(\n", + " 0.5 * sum(xl_borders) - 20, upper_y_border - 50, \"XL\", fontdict={\"fontsize\": 30}\n", ")\n", - "plt.text(0.5*sum(xl_borders)-20, upper_y_border-50, 'XL', fontdict={'fontsize':30})\n", "\"\"\"\n", "plt.savefig(\n", " '../figures/stemcell_scatter_v1.pdf',\n", @@ -1077,7 +1224,7 @@ ")\n", "\"\"\"\n", "plt.show()\n", - "#stem_data.to_csv(os.path.join(data_dir, 'plot_data4c.csv'), index=False)" + "# stem_data.to_csv(os.path.join(data_dir, 'plot_data4c.csv'), index=False)" ] }, { @@ -1104,22 +1251,33 @@ }, "outputs": [], "source": [ - "stem_bkgr_data = pd.read_csv(os.path.join(data_dir, 'p38_control_AllPos_acdc_output.csv'))\n", - "stem_bkgr_data['Pp38_concentration'] = stem_bkgr_data['Pp38_amount_autoBkgr_zSlice'] / stem_bkgr_data['cell_vol_fl']\n", + "stem_bkgr_data = pd.read_csv(\n", + " os.path.join(data_dir, \"p38_control_AllPos_acdc_output.csv\")\n", + ")\n", + "stem_bkgr_data[\"Pp38_concentration\"] = (\n", + " stem_bkgr_data[\"Pp38_amount_autoBkgr_zSlice\"] / stem_bkgr_data[\"cell_vol_fl\"]\n", + ")\n", + "\n", + "\n", "def generate_size_str(x):\n", - " if x>=0 and x<=xs_borders[1]:\n", - " return 'XS'\n", - " elif x>=m_borders[0] and x<=m_borders[1]:\n", - " return 'M'\n", - " elif x>=xl_borders[0]:\n", - " return 'XL'\n", + " if x >= 0 and x <= xs_borders[1]:\n", + " return \"XS\"\n", + " elif x >= m_borders[0] and x <= m_borders[1]:\n", + " return \"M\"\n", + " elif x >= xl_borders[0]:\n", + " return \"XL\"\n", " else:\n", - " return 'rest'\n", + " return \"rest\"\n", + "\n", + "\n", "all_data = stem_data.copy()\n", - "all_data['size_category'] = 'All'\n", - "stem_data['size_category'] = stem_data.cell_vol_fl.apply(generate_size_str)\n", - "stem_bkgr_data['size_category'] = 'Control'\n", - "box_data = pd.concat([all_data, stem_data[stem_data.size_category!='rest'], stem_bkgr_data], ignore_index=True)" + "all_data[\"size_category\"] = \"All\"\n", + "stem_data[\"size_category\"] = stem_data.cell_vol_fl.apply(generate_size_str)\n", + "stem_bkgr_data[\"size_category\"] = \"Control\"\n", + "box_data = pd.concat(\n", + " [all_data, stem_data[stem_data.size_category != \"rest\"], stem_bkgr_data],\n", + " ignore_index=True,\n", + ")" ] }, { @@ -1135,18 +1293,18 @@ }, "outputs": [], "source": [ - "sns.set_theme(context='talk', style='darkgrid')\n", - "plt.figure(figsize=(10,10))\n", + "sns.set_theme(context=\"talk\", style=\"darkgrid\")\n", + "plt.figure(figsize=(10, 10))\n", "ax = sns.boxplot(\n", " data=box_data,\n", - " x='size_category',\n", - " y='Pp38_concentration',\n", + " x=\"size_category\",\n", + " y=\"Pp38_concentration\",\n", " order=[\"Control\", \"All\", \"XS\", \"M\", \"XL\"],\n", - " color=sns.color_palette()[0]\n", - " #inner='quartile'\n", + " color=sns.color_palette()[0],\n", + " # inner='quartile'\n", ")\n", - "ax.set_xlabel('Size Category')\n", - "ax.set_ylabel('Mean intensity Pp38 [a.u.]')\n", + "ax.set_xlabel(\"Size Category\")\n", + "ax.set_ylabel(\"Mean intensity Pp38 [a.u.]\")\n", "\"\"\"\n", "plt.savefig(\n", " '../figures/stemcell_violin_v1.pdf',\n", @@ -1154,7 +1312,7 @@ ")\n", "\"\"\"\n", "plt.show()\n", - "#box_data.to_csv(os.path.join(data_dir, 'plot_data4d.csv'), index=False)" + "# box_data.to_csv(os.path.join(data_dir, 'plot_data4d.csv'), index=False)" ] }, { @@ -1181,16 +1339,26 @@ }, "outputs": [], "source": [ - "stem_data = pd.read_csv(os.path.join(data_dir, 'stemcell_data.csv'))\n", + "stem_data = pd.read_csv(os.path.join(data_dir, \"stemcell_data.csv\"))\n", "# configure borders for \"size blocks\"\n", "xs_borders = 0, np.percentile(stem_data.cell_vol_fl, 15)\n", - "m_borders = np.percentile(stem_data.cell_vol_fl, 35), np.percentile(stem_data.cell_vol_fl, 65)\n", - "xl_borders = np.percentile(stem_data.cell_vol_fl, 85), np.percentile(stem_data.cell_vol_fl, 85) * 2 + 20\n", + "m_borders = (\n", + " np.percentile(stem_data.cell_vol_fl, 35),\n", + " np.percentile(stem_data.cell_vol_fl, 65),\n", + ")\n", + "xl_borders = (\n", + " np.percentile(stem_data.cell_vol_fl, 85),\n", + " np.percentile(stem_data.cell_vol_fl, 85) * 2 + 20,\n", + ")\n", "# In Fig. 3B very small cells are assumed to be imaging fragments, very large cells missed Segmentation errors\n", - "min_vol, max_vol = 0, xl_borders[0]*2\n", - "stem_selection_indices = np.logical_and(stem_data.cell_vol_fl>min_vol, stem_data.cell_vol_fl min_vol, stem_data.cell_vol_fl < max_vol\n", + ")\n", "stem_data = stem_data[stem_selection_indices]\n", - "stem_data['FITC_concentration'] = stem_data['FITC_amount_autoBkgr_zSlice'] / stem_data['cell_vol_fl']" + "stem_data[\"FITC_concentration\"] = (\n", + " stem_data[\"FITC_amount_autoBkgr_zSlice\"] / stem_data[\"cell_vol_fl\"]\n", + ")" ] }, { @@ -1217,35 +1385,48 @@ }, "outputs": [], "source": [ - "plt.subplots(figsize=(10,10))\n", - "sns.set_theme(context='talk', style='darkgrid')\n", + "plt.subplots(figsize=(10, 10))\n", + "sns.set_theme(context=\"talk\", style=\"darkgrid\")\n", "ax = sns.scatterplot(\n", " data=stem_data,\n", - " x='cell_vol_fl',\n", - " y='FITC_concentration',\n", - " color=sns.color_palette()[2]\n", - " #hue='size_category'\n", - ")\n", - "ax.set_xlabel('HSC Volume [fL]')\n", - "ax.set_ylabel('Mean intensity FITC [a.u.]')\n", - "lower_y_border, upper_y_border = stem_data.FITC_concentration.min()-10, stem_data.FITC_concentration.max()+10\n", + " x=\"cell_vol_fl\",\n", + " y=\"FITC_concentration\",\n", + " color=sns.color_palette()[2],\n", + " # hue='size_category'\n", + ")\n", + "ax.set_xlabel(\"HSC Volume [fL]\")\n", + "ax.set_ylabel(\"Mean intensity FITC [a.u.]\")\n", + "lower_y_border, upper_y_border = (\n", + " stem_data.FITC_concentration.min() - 10,\n", + " stem_data.FITC_concentration.max() + 10,\n", + ")\n", "height = upper_y_border - lower_y_border\n", - "xs_width = xs_borders[1]-xs_borders[0]\n", - "m_width = m_borders[1]-m_borders[0]\n", - "xl_width = xl_borders[1]-xl_borders[0]\n", + "xs_width = xs_borders[1] - xs_borders[0]\n", + "m_width = m_borders[1] - m_borders[0]\n", + "xl_width = xl_borders[1] - xl_borders[0]\n", "ax.add_patch(\n", - " patches.Rectangle((xs_borders[0], lower_y_border), xs_width, height, color='black', alpha=0.2)\n", + " patches.Rectangle(\n", + " (xs_borders[0], lower_y_border), xs_width, height, color=\"black\", alpha=0.2\n", + " )\n", + ")\n", + "plt.text(\n", + " 0.5 * sum(xs_borders) - 20, upper_y_border - 50, \"XS\", fontdict={\"fontsize\": 30}\n", ")\n", - "plt.text(0.5*sum(xs_borders)-20, upper_y_border-50, 'XS', fontdict={'fontsize':30})\n", "ax.add_patch(\n", - " patches.Rectangle((m_borders[0], lower_y_border), m_width, height, color='black', alpha=0.2)\n", + " patches.Rectangle(\n", + " (m_borders[0], lower_y_border), m_width, height, color=\"black\", alpha=0.2\n", + " )\n", ")\n", - "plt.text(0.5*sum(m_borders)-20, upper_y_border-50, 'M', fontdict={'fontsize':30})\n", + "plt.text(0.5 * sum(m_borders) - 20, upper_y_border - 50, \"M\", fontdict={\"fontsize\": 30})\n", "ax.add_patch(\n", - " patches.Rectangle((xl_borders[0], lower_y_border), xl_width, height, color='black', alpha=0.2)\n", + " patches.Rectangle(\n", + " (xl_borders[0], lower_y_border), xl_width, height, color=\"black\", alpha=0.2\n", + " )\n", ")\n", "ax.set_xlim(0, xl_borders[1])\n", - "plt.text(0.5*sum(xl_borders)-20, upper_y_border-50, 'XL', fontdict={'fontsize':30})\n", + "plt.text(\n", + " 0.5 * sum(xl_borders) - 20, upper_y_border - 50, \"XL\", fontdict={\"fontsize\": 30}\n", + ")\n", "\"\"\"\n", "plt.savefig(\n", " '../figures/stemcell_scatter_v1.pdf',\n", @@ -1253,7 +1434,7 @@ ")\n", "\"\"\"\n", "plt.show()\n", - "#stem_data.to_csv(os.path.join(data_dir, 'plot_data4a.csv'), index=False)" + "# stem_data.to_csv(os.path.join(data_dir, 'plot_data4a.csv'), index=False)" ] }, { @@ -1280,22 +1461,31 @@ }, "outputs": [], "source": [ - "stem_bkgr_data = pd.read_csv(os.path.join(data_dir, 'stemcell_bkgr_data.csv'))\n", - "stem_bkgr_data['FITC_concentration'] = stem_bkgr_data['FITC_amount_autoBkgr_zSlice'] / stem_bkgr_data['cell_vol_fl']\n", + "stem_bkgr_data = pd.read_csv(os.path.join(data_dir, \"stemcell_bkgr_data.csv\"))\n", + "stem_bkgr_data[\"FITC_concentration\"] = (\n", + " stem_bkgr_data[\"FITC_amount_autoBkgr_zSlice\"] / stem_bkgr_data[\"cell_vol_fl\"]\n", + ")\n", + "\n", + "\n", "def generate_size_str(x):\n", - " if x>=0 and x<=xs_borders[1]:\n", - " return 'XS'\n", - " elif x>=m_borders[0] and x<=m_borders[1]:\n", - " return 'M'\n", - " elif x>=xl_borders[0]:\n", - " return 'XL'\n", + " if x >= 0 and x <= xs_borders[1]:\n", + " return \"XS\"\n", + " elif x >= m_borders[0] and x <= m_borders[1]:\n", + " return \"M\"\n", + " elif x >= xl_borders[0]:\n", + " return \"XL\"\n", " else:\n", - " return 'rest'\n", + " return \"rest\"\n", + "\n", + "\n", "all_data = stem_data.copy()\n", - "all_data['size_category'] = 'All'\n", - "stem_data['size_category'] = stem_data.cell_vol_fl.apply(generate_size_str)\n", - "stem_bkgr_data['size_category'] = 'Control'\n", - "box_data = pd.concat([all_data, stem_data[stem_data.size_category!='rest'], stem_bkgr_data], ignore_index=True)" + "all_data[\"size_category\"] = \"All\"\n", + "stem_data[\"size_category\"] = stem_data.cell_vol_fl.apply(generate_size_str)\n", + "stem_bkgr_data[\"size_category\"] = \"Control\"\n", + "box_data = pd.concat(\n", + " [all_data, stem_data[stem_data.size_category != \"rest\"], stem_bkgr_data],\n", + " ignore_index=True,\n", + ")" ] }, { @@ -1311,18 +1501,18 @@ }, "outputs": [], "source": [ - "sns.set_theme(context='talk', style='darkgrid')\n", - "plt.figure(figsize=(10,10))\n", + "sns.set_theme(context=\"talk\", style=\"darkgrid\")\n", + "plt.figure(figsize=(10, 10))\n", "ax = sns.boxplot(\n", " data=box_data,\n", - " x='size_category',\n", - " y='FITC_concentration',\n", + " x=\"size_category\",\n", + " y=\"FITC_concentration\",\n", " order=[\"Control\", \"All\", \"XS\", \"M\", \"XL\"],\n", - " color=sns.color_palette()[2]\n", - " #inner='quartile'\n", + " color=sns.color_palette()[2],\n", + " # inner='quartile'\n", ")\n", - "ax.set_xlabel('Size Category')\n", - "ax.set_ylabel('Mean intensity FITC [a.u.]')\n", + "ax.set_xlabel(\"Size Category\")\n", + "ax.set_ylabel(\"Mean intensity FITC [a.u.]\")\n", "\"\"\"\n", "plt.savefig(\n", " '../figures/stemcell_violin_v1.pdf',\n", @@ -1330,7 +1520,7 @@ ")\n", "\"\"\"\n", "plt.show()\n", - "#box_data.to_csv(os.path.join(data_dir, 'plot_data4b.csv'), index=False)" + "# box_data.to_csv(os.path.join(data_dir, 'plot_data4b.csv'), index=False)" ] }, { @@ -1369,19 +1559,22 @@ "source": [ "data_dirs, positions = (\n", " [\n", - " '../data/acdc_test_data/TimeLapse_2D/MIA_KC_htb1_mCitrine_labeled',\n", - " '../data/acdc_test_data/TimeLapse_2D/MIA_KC_htb1_mCitrine_flu_control_labeled'\n", + " \"../data/acdc_test_data/TimeLapse_2D/MIA_KC_htb1_mCitrine_labeled\",\n", + " \"../data/acdc_test_data/TimeLapse_2D/MIA_KC_htb1_mCitrine_flu_control_labeled\",\n", " ],\n", " [\n", - " ['Position_2', 'Position_3', 'Position_4', 'Position_5', 'Position_8'],\n", - " ['Position_1', 'Position_3']\n", - " ]\n", + " [\"Position_2\", \"Position_3\", \"Position_4\", \"Position_5\", \"Position_8\"],\n", + " [\"Position_1\", \"Position_3\"],\n", + " ],\n", ")\n", "file_names = [os.path.split(path)[-1] for path in data_dirs]\n", - "image_folders = [[os.path.join(data_dir, pos_str, 'Images') for pos_str in pos_list] for pos_list, data_dir in zip(positions, data_dirs)]\n", + "image_folders = [\n", + " [os.path.join(data_dir, pos_str, \"Images\") for pos_str in pos_list]\n", + " for pos_list, data_dir in zip(positions, data_dirs)\n", + "]\n", "# determine available channels based on first(!) position.\n", "# Warn user if one or more of the channels are not available for some positions\n", - "first_pos_dir = os.path.join(data_dirs[0], positions[0][0], 'Images')\n", + "first_pos_dir = os.path.join(data_dirs[0], positions[0][0], \"Images\")\n", "first_pos_files = myutils.listdir(first_pos_dir)\n", "channels, warn = cca_functions.find_available_channels(first_pos_files, first_pos_dir)" ] @@ -1401,13 +1594,9 @@ "outputs": [], "source": [ "overall_df, is_timelapse_data, is_zstack_data = cca_functions.calculate_downstream_data(\n", - " file_names,\n", - " image_folders,\n", - " positions,\n", - " channels, \n", - " force_recalculation=False\n", + " file_names, image_folders, positions, channels, force_recalculation=False\n", ")\n", - "#overall_df.to_csv(os.path.join(data_dir, 'raw_downstream_data_fig4_v2.csv'), index=False)" + "# overall_df.to_csv(os.path.join(data_dir, 'raw_downstream_data_fig4_v2.csv'), index=False)" ] }, { @@ -1433,8 +1622,8 @@ }, "outputs": [], "source": [ - "data_dir = os.path.join('..', 'data', 'paper_plot_data')\n", - "overall_df = pd.read_csv(os.path.join(data_dir, 'raw_downstream_data_fig5_v2.csv'))" + "data_dir = os.path.join(\"..\", \"data\", \"paper_plot_data\")\n", + "overall_df = pd.read_csv(os.path.join(data_dir, \"raw_downstream_data_fig5_v2.csv\"))" ] }, { @@ -1463,17 +1652,24 @@ "overall_df_with_rel = cca_functions.calculate_relatives_data(overall_df, channels)\n", "# If working with timelapse data build dataframe grouped by phases\n", "group_cols = [\n", - " 'Cell_ID', 'generation_num', 'cell_cycle_stage', 'relationship', 'position', 'file', \n", - " 'max_frame_pos', 'selection_subset', 'max_t'\n", + " \"Cell_ID\",\n", + " \"generation_num\",\n", + " \"cell_cycle_stage\",\n", + " \"relationship\",\n", + " \"position\",\n", + " \"file\",\n", + " \"max_frame_pos\",\n", + " \"selection_subset\",\n", + " \"max_t\",\n", "]\n", "# calculate data grouped by phase only in the case, that timelapse data is available\n", "if is_timelapse_data:\n", - " phase_grouped = cca_functions.calculate_per_phase_quantities(overall_df_with_rel, group_cols, channels)\n", + " phase_grouped = cca_functions.calculate_per_phase_quantities(\n", + " overall_df_with_rel, group_cols, channels\n", + " )\n", " # append phase-grouped data to overall_df\n", " overall_df_with_rel = overall_df_with_rel.merge(\n", - " phase_grouped,\n", - " how='left',\n", - " on=group_cols\n", + " phase_grouped, how=\"left\", on=group_cols\n", " )" ] }, @@ -1518,7 +1714,7 @@ "# some configurations\n", "# frame interval of video\n", "frame_interval_minutes = 3\n", - "# quantiles of complete cell cycles (wrt phase lengths) to exclude from analysis \n", + "# quantiles of complete cell cycles (wrt phase lengths) to exclude from analysis\n", "# (not used, keep this for potential later use)\n", "down_q, upper_q = 0, 1\n", "# minimum number of cell cycles contributing to the mean+CI curve:\n", @@ -1530,104 +1726,131 @@ "\n", "# select needed cols from overall_df_with_rel to not end up with too many columns\n", "needed_cols = [\n", - " 'selection_subset', 'position', 'Cell_ID', 'cell_cycle_stage', 'generation_num', 'frame_i',\n", - " 'mCitrine_corrected_amount', 'mCitrine_corrected_amount_rel', \n", - " 'file', 'relationship', 'relative_ID', 'phase_length', 'phase_begin', 'gui_mCitrine_amount_autoBkgr'\n", + " \"selection_subset\",\n", + " \"position\",\n", + " \"Cell_ID\",\n", + " \"cell_cycle_stage\",\n", + " \"generation_num\",\n", + " \"frame_i\",\n", + " \"mCitrine_corrected_amount\",\n", + " \"mCitrine_corrected_amount_rel\",\n", + " \"file\",\n", + " \"relationship\",\n", + " \"relative_ID\",\n", + " \"phase_length\",\n", + " \"phase_begin\",\n", + " \"gui_mCitrine_amount_autoBkgr\",\n", "]\n", - "filter_idx = np.logical_and(overall_df_with_rel['complete_cycle'] == 1, overall_df_with_rel.selection_subset==0)\n", + "filter_idx = np.logical_and(\n", + " overall_df_with_rel[\"complete_cycle\"] == 1,\n", + " overall_df_with_rel.selection_subset == 0,\n", + ")\n", "plot_data5a = overall_df_with_rel.loc[filter_idx, needed_cols].copy()\n", "# calculate the time the cell already spent in the current frame at the current timepoint\n", - "plot_data5a['frames_in_phase'] = plot_data5a['frame_i'] - plot_data5a['phase_begin'] + 1\n", - "# calculate the time to the next (for G1 cells) and from the last (for S cells) G1/S transition \n", - "plot_data5a['centered_frames_in_phase'] = plot_data5a.apply(\n", - " lambda x: x.loc['frames_in_phase'] if\\\n", - " x.loc['cell_cycle_stage']=='S' else\\\n", - " x.loc['frames_in_phase']-1-x.loc['phase_length'],\n", - " axis=1\n", + "plot_data5a[\"frames_in_phase\"] = plot_data5a[\"frame_i\"] - plot_data5a[\"phase_begin\"] + 1\n", + "# calculate the time to the next (for G1 cells) and from the last (for S cells) G1/S transition\n", + "plot_data5a[\"centered_frames_in_phase\"] = plot_data5a.apply(\n", + " lambda x: (\n", + " x.loc[\"frames_in_phase\"]\n", + " if x.loc[\"cell_cycle_stage\"] == \"S\"\n", + " else x.loc[\"frames_in_phase\"] - 1 - x.loc[\"phase_length\"]\n", + " ),\n", + " axis=1,\n", ")\n", "# calculate combined signal and the \"Pool, Phase ID\" for the legend\n", - "# plot_data5a at this point only contains relationship==mother, \n", + "# plot_data5a at this point only contains relationship==mother,\n", "# as generation_num==0 and relationship==bud are filtered out (incomplete cycle, cycles start with G1)\n", - "plot_data5a['Combined signal'] = plot_data5a.apply(\n", - " lambda x: x.loc['mCitrine_corrected_amount']+x.loc['mCitrine_corrected_amount_rel'] if\\\n", - " x.loc['cell_cycle_stage']=='S' and x.loc['relationship'] == 'mother' else\\\n", - " x.loc['mCitrine_corrected_amount'],\n", - " axis=1\n", + "plot_data5a[\"Combined signal\"] = plot_data5a.apply(\n", + " lambda x: (\n", + " x.loc[\"mCitrine_corrected_amount\"] + x.loc[\"mCitrine_corrected_amount_rel\"]\n", + " if x.loc[\"cell_cycle_stage\"] == \"S\" and x.loc[\"relationship\"] == \"mother\"\n", + " else x.loc[\"mCitrine_corrected_amount\"]\n", + " ),\n", + " axis=1,\n", ")\n", - "plot_data5a['Bud signal'] = plot_data5a.apply(\n", - " lambda x: x.loc['mCitrine_corrected_amount_rel'] if\\\n", - " x.loc['cell_cycle_stage']=='S' and x.loc['relationship'] == 'mother' else 0,\n", - " axis=1\n", + "plot_data5a[\"Bud signal\"] = plot_data5a.apply(\n", + " lambda x: (\n", + " x.loc[\"mCitrine_corrected_amount_rel\"]\n", + " if x.loc[\"cell_cycle_stage\"] == \"S\" and x.loc[\"relationship\"] == \"mother\"\n", + " else 0\n", + " ),\n", + " axis=1,\n", ")\n", "# scale data if needed\n", "if scale_data:\n", - " maximum = max(\n", - " plot_data5a['Combined signal'].max(), \n", - " plot_data5a['Bud signal'].max()\n", - " )\n", - " plot_data5a['Combined signal'] /= maximum\n", - " plot_data5a['Bud signal'] /= maximum\n", + " maximum = max(plot_data5a[\"Combined signal\"].max(), plot_data5a[\"Bud signal\"].max())\n", + " plot_data5a[\"Combined signal\"] /= maximum\n", + " plot_data5a[\"Bud signal\"] /= maximum\n", "# calculate min and max centered times per generation to eliminate up to a percentile\n", "# (not used, as upper_q and lower_q are set to 100/0 respectively)\n", - "plot_data5a['min_centered_frames'] = plot_data5a.groupby(\n", - " ['position', 'file', 'Cell_ID', 'generation_num']\n", - ")['centered_frames_in_phase'].transform(\n", - " 'min'\n", - ")\n", - "plot_data5a['max_centered_frames'] = plot_data5a.groupby(\n", - " ['position', 'file', 'Cell_ID', 'generation_num']\n", - ")['centered_frames_in_phase'].transform(\n", - " 'max'\n", - ")\n", - "min_and_max = plot_data5a.groupby(\n", - " ['Cell_ID', 'generation_num', 'position', 'file']\n", - ").agg(\n", - " min_centered = ('min_centered_frames', 'first'),\n", - " max_centered = ('max_centered_frames', 'first')\n", - ").reset_index()\n", - "min_val, max_val = np.quantile(\n", - " min_and_max.min_centered, down_q\n", - ") * frame_interval_minutes, np.quantile(\n", - " min_and_max.max_centered, upper_q\n", - ") * frame_interval_minutes\n", + "plot_data5a[\"min_centered_frames\"] = plot_data5a.groupby(\n", + " [\"position\", \"file\", \"Cell_ID\", \"generation_num\"]\n", + ")[\"centered_frames_in_phase\"].transform(\"min\")\n", + "plot_data5a[\"max_centered_frames\"] = plot_data5a.groupby(\n", + " [\"position\", \"file\", \"Cell_ID\", \"generation_num\"]\n", + ")[\"centered_frames_in_phase\"].transform(\"max\")\n", + "min_and_max = (\n", + " plot_data5a.groupby([\"Cell_ID\", \"generation_num\", \"position\", \"file\"])\n", + " .agg(\n", + " min_centered=(\"min_centered_frames\", \"first\"),\n", + " max_centered=(\"max_centered_frames\", \"first\"),\n", + " )\n", + " .reset_index()\n", + ")\n", + "min_val, max_val = (\n", + " np.quantile(min_and_max.min_centered, down_q) * frame_interval_minutes,\n", + " np.quantile(min_and_max.max_centered, upper_q) * frame_interval_minutes,\n", + ")\n", "# perform selection (won't change anything if upper and lower are 100 and 0 respectively)\n", "selection_indices = np.logical_and(\n", - " plot_data5a.min_centered_frames*frame_interval_minutes>=min_val, \n", - " plot_data5a.max_centered_frames*frame_interval_minutes<=max_val\n", + " plot_data5a.min_centered_frames * frame_interval_minutes >= min_val,\n", + " plot_data5a.max_centered_frames * frame_interval_minutes <= max_val,\n", ")\n", "plot_data5a = plot_data5a[selection_indices]\n", "\n", "# calculate centered time in minutes\n", - "plot_data5a['centered_time_in_minutes'] = plot_data5a.centered_frames_in_phase * frame_interval_minutes\n", + "plot_data5a[\"centered_time_in_minutes\"] = (\n", + " plot_data5a.centered_frames_in_phase * frame_interval_minutes\n", + ")\n", "\n", "# group dataframe to calculate sample sizes per generation\n", - "standard_grouped = plot_data5a.groupby(\n", - " ['position', 'file', 'Cell_ID', 'generation_num']\n", - ").agg('count').reset_index()\n", - "plot_data5a['Generation'] = plot_data5a.apply(\n", - " lambda x: f'1st ($n_1$={len(standard_grouped[standard_grouped.generation_num==1])})' if\\\n", - " x.loc['generation_num']==1 else f'2+ ($n_2$={len(standard_grouped[standard_grouped.generation_num>1])})',\n", - " axis=1\n", + "standard_grouped = (\n", + " plot_data5a.groupby([\"position\", \"file\", \"Cell_ID\", \"generation_num\"])\n", + " .agg(\"count\")\n", + " .reset_index()\n", + ")\n", + "plot_data5a[\"Generation\"] = plot_data5a.apply(\n", + " lambda x: (\n", + " f\"1st ($n_1$={len(standard_grouped[standard_grouped.generation_num == 1])})\"\n", + " if x.loc[\"generation_num\"] == 1\n", + " else f\"2+ ($n_2$={len(standard_grouped[standard_grouped.generation_num > 1])})\"\n", + " ),\n", + " axis=1,\n", ")\n", "if split_by_gen:\n", - " g_cols = ['centered_frames_in_phase', 'Generation']\n", + " g_cols = [\"centered_frames_in_phase\", \"Generation\"]\n", "else:\n", - " g_cols = 'centered_frames_in_phase'\n", - "plot_data5a['contributing_ccs_at_time'] = plot_data5a.groupby(g_cols).transform('count')['selection_subset']\n", + " g_cols = \"centered_frames_in_phase\"\n", + "plot_data5a[\"contributing_ccs_at_time\"] = plot_data5a.groupby(g_cols).transform(\n", + " \"count\"\n", + ")[\"selection_subset\"]\n", "plot_data5a = plot_data5a[plot_data5a.contributing_ccs_at_time >= min_no_of_ccs]\n", "\n", "# finally prepare data for plot (use melt for multiple lines)\n", "sample_size_5a = len(standard_grouped)\n", - "avg_cell_cycle_length = round(standard_grouped.loc[:,'centered_time_in_minutes'].mean())*frame_interval_minutes\n", - "cols_to_plot = ['Bud signal', 'Combined signal']\n", + "avg_cell_cycle_length = (\n", + " round(standard_grouped.loc[:, \"centered_time_in_minutes\"].mean())\n", + " * frame_interval_minutes\n", + ")\n", + "cols_to_plot = [\"Bud signal\", \"Combined signal\"]\n", "index_cols = [col for col in plot_data5a.columns if col not in cols_to_plot]\n", "plot_data5a_melted = pd.melt(\n", - " plot_data5a, index_cols, var_name='Method of calculation'\n", - ").sort_values('Method of calculation')\n", - "data_dir = os.path.join('..', 'data', 'paper_plot_data')\n", + " plot_data5a, index_cols, var_name=\"Method of calculation\"\n", + ").sort_values(\"Method of calculation\")\n", + "data_dir = os.path.join(\"..\", \"data\", \"paper_plot_data\")\n", "# save preprocessed data for Fig. 5A\n", - "#plot_data5a_melted.to_csv(os.path.join(data_dir, 'plot_data5a_melted_v2.csv'), index=False)\n", - "#plot_data5a.to_csv(os.path.join(data_dir, 'plot_data5a_v2.csv'), index=False)" + "# plot_data5a_melted.to_csv(os.path.join(data_dir, 'plot_data5a_melted_v2.csv'), index=False)\n", + "# plot_data5a.to_csv(os.path.join(data_dir, 'plot_data5a_v2.csv'), index=False)" ] }, { @@ -1656,33 +1879,42 @@ "sns.set_theme(style=\"darkgrid\", font_scale=1.6)\n", "f, ax = plt.subplots(figsize=(15, 12))\n", "if split_by_gen:\n", - " style='Generation'\n", + " style = \"Generation\"\n", "else:\n", - " style=None\n", + " style = None\n", "ax = sns.lineplot(\n", - " data=plot_data5a_melted,#.sort_values('Pool, Phase'),\n", - " x=\"centered_time_in_minutes\", \n", + " data=plot_data5a_melted, # .sort_values('Pool, Phase'),\n", + " x=\"centered_time_in_minutes\",\n", " y=\"value\",\n", - " hue='Method of calculation',\n", + " hue=\"Method of calculation\",\n", " style=style,\n", - " #style='position',\n", - " ci=95\n", + " # style='position',\n", + " ci=95,\n", ")\n", - "ax.axvline(x=0, color='red')#, label='Time of Bud Emergence')\n", + "ax.axvline(x=0, color=\"red\") # , label='Time of Bud Emergence')\n", "ax.text(\n", - " 0.5, 100000, \"Time of \\nBud Emergence\", horizontalalignment='left', \n", - " size='medium', color='red', weight='normal'\n", + " 0.5,\n", + " 100000,\n", + " \"Time of \\nBud Emergence\",\n", + " horizontalalignment=\"left\",\n", + " size=\"medium\",\n", + " color=\"red\",\n", + " weight=\"normal\",\n", ")\n", "ax.legend(\n", - " #title=f'Avg CC Length: {avg_cell_cycle_length} min, n = {sample_size_5a}', \n", + " # title=f'Avg CC Length: {avg_cell_cycle_length} min, n = {sample_size_5a}',\n", " fancybox=True,\n", " labelspacing=0.5,\n", " handlelength=1.5,\n", - " loc = 'upper left'\n", + " loc=\"upper left\",\n", + ")\n", + "ax.set_ylabel(\n", + " \"Total amount of Htb1-mCitrine corrected by background [a.u.]\", fontsize=20\n", ")\n", - "ax.set_ylabel(\"Total amount of Htb1-mCitrine corrected by background [a.u.]\", fontsize=20)\n", "ax.set_xlabel(\"Time in phase relative to G1/S transition [minutes]\", fontsize=20)\n", - "ax.set_title(\"Corrected Htb1-mCitrine Amount during Cell Cycle Progression\", fontsize=30)\n", + "ax.set_title(\n", + " \"Corrected Htb1-mCitrine Amount during Cell Cycle Progression\", fontsize=30\n", + ")\n", "plt.tight_layout()\n", "\"\"\"\n", "plt.savefig(os.path.join('..', 'figures', 'new_fig5', 'mCitrine_over_time_by_gen_v6.svg'))\n", @@ -1713,14 +1945,22 @@ }, "outputs": [], "source": [ - "# obtain table where one cell cycle is represented by one row: \n", + "# obtain table where one cell cycle is represented by one row:\n", "# first set of columns (like phase_length, growth...) for G1, second set of cols for S\n", "needed_cols = [\n", - " 'Cell_ID', 'generation_num', 'position', 'file', 'cell_cycle_stage', 'selection_subset', \n", - " 'phase_volume_at_beginning', 'phase_volume_at_end', 'phase_mCitrine_amount_at_beginning',\n", - " 'phase_mCitrine_combined_amount_at_end', 'phase_combined_volume_at_end'\n", + " \"Cell_ID\",\n", + " \"generation_num\",\n", + " \"position\",\n", + " \"file\",\n", + " \"cell_cycle_stage\",\n", + " \"selection_subset\",\n", + " \"phase_volume_at_beginning\",\n", + " \"phase_volume_at_end\",\n", + " \"phase_mCitrine_amount_at_beginning\",\n", + " \"phase_mCitrine_combined_amount_at_end\",\n", + " \"phase_combined_volume_at_end\",\n", "]\n", - "plot_data5b = phase_grouped.loc[phase_grouped.all_complete==1, needed_cols]\n", + "plot_data5b = phase_grouped.loc[phase_grouped.all_complete == 1, needed_cols]\n", "scale_data = False" ] }, @@ -1736,47 +1976,54 @@ }, "outputs": [], "source": [ - "plot_data5b['relevant_volume'] = plot_data5b.apply(\n", - " lambda x: x.loc['phase_volume_at_beginning'] if\\\n", - " x.loc['cell_cycle_stage']=='G1' else\\\n", - " x.loc['phase_combined_volume_at_end'],\n", - " axis=1\n", - ")\n", - "plot_data5b['relevant_amount'] = plot_data5b.apply(\n", - " lambda x: x.loc['phase_mCitrine_amount_at_beginning'] if\\\n", - " x.loc['cell_cycle_stage']=='G1' else\\\n", - " x.loc['phase_mCitrine_combined_amount_at_end'],\n", - " axis=1\n", - ")\n", - "# defining a function to generate entries for the figure legend \n", + "plot_data5b[\"relevant_volume\"] = plot_data5b.apply(\n", + " lambda x: (\n", + " x.loc[\"phase_volume_at_beginning\"]\n", + " if x.loc[\"cell_cycle_stage\"] == \"G1\"\n", + " else x.loc[\"phase_combined_volume_at_end\"]\n", + " ),\n", + " axis=1,\n", + ")\n", + "plot_data5b[\"relevant_amount\"] = plot_data5b.apply(\n", + " lambda x: (\n", + " x.loc[\"phase_mCitrine_amount_at_beginning\"]\n", + " if x.loc[\"cell_cycle_stage\"] == \"G1\"\n", + " else x.loc[\"phase_mCitrine_combined_amount_at_end\"]\n", + " ),\n", + " axis=1,\n", + ")\n", + "\n", + "\n", + "# defining a function to generate entries for the figure legend\n", "# (assuming that selection_subset>0 is the autofluorescence control of the experiment)\n", "def calc_legend_entry(x):\n", - " if x.loc['selection_subset'] == 0:\n", - " if x.loc['cell_cycle_stage']=='G1':\n", - " return 'At G1-entry'\n", + " if x.loc[\"selection_subset\"] == 0:\n", + " if x.loc[\"cell_cycle_stage\"] == \"G1\":\n", + " return \"At G1-entry\"\n", " else:\n", - " return 'Mother+bud at cytokinesis'\n", + " return \"Mother+bud at cytokinesis\"\n", " else:\n", - " if x.loc['cell_cycle_stage']=='G1':\n", - " return 'AF control at G1-entry'\n", + " if x.loc[\"cell_cycle_stage\"] == \"G1\":\n", + " return \"AF control at G1-entry\"\n", " else:\n", - " return 'AF control, m+b at cytokinesis'\n", - " \n", - "plot_data5b['Kind of Measurement new'] = plot_data5b.apply(\n", - " calc_legend_entry,\n", - " axis=1\n", - ")\n", - "plot_data5b['Generation'] = plot_data5b.apply(\n", - " lambda x: f'1st ($n_1$={int(len(plot_data5b[plot_data5b.generation_num==1])/2)})' if\\\n", - " x.loc['generation_num']==1 else f'2+ ($n_2$={int(len(plot_data5b[plot_data5b.generation_num>1])/2)})',\n", - " axis=1\n", + " return \"AF control, m+b at cytokinesis\"\n", + "\n", + "\n", + "plot_data5b[\"Kind of Measurement new\"] = plot_data5b.apply(calc_legend_entry, axis=1)\n", + "plot_data5b[\"Generation\"] = plot_data5b.apply(\n", + " lambda x: (\n", + " f\"1st ($n_1$={int(len(plot_data5b[plot_data5b.generation_num == 1]) / 2)})\"\n", + " if x.loc[\"generation_num\"] == 1\n", + " else f\"2+ ($n_2$={int(len(plot_data5b[plot_data5b.generation_num > 1]) / 2)})\"\n", + " ),\n", + " axis=1,\n", ")\n", "if scale_data:\n", - " maximum = plot_data5b['relevant_amount'].max()\n", - " plot_data5b['relevant_amount'] /= maximum\n", + " maximum = plot_data5b[\"relevant_amount\"].max()\n", + " plot_data5b[\"relevant_amount\"] /= maximum\n", "sample_size_5b = len(plot_data5b)\n", - "data_dir = os.path.join('..', 'data', 'paper_plot_data')\n", - "#plot_data5b.to_csv(os.path.join(data_dir, 'plot_data5b_v2.csv'), index=False)" + "data_dir = os.path.join(\"..\", \"data\", \"paper_plot_data\")\n", + "# plot_data5b.to_csv(os.path.join(data_dir, 'plot_data5b_v2.csv'), index=False)" ] }, { @@ -1801,93 +2048,98 @@ }, "outputs": [], "source": [ - "#plot_data5b = plot_data5b[plot_data5b.selection_subset==1]\n", + "# plot_data5b = plot_data5b[plot_data5b.selection_subset==1]\n", "sns.set_theme(style=\"whitegrid\", font_scale=1.6)\n", "# Initialize the figure\n", "sns.lmplot(\n", - " x=\"relevant_volume\", \n", - " y=\"relevant_amount\", \n", - " data=plot_data5b.sort_values(\n", - " 'Kind of Measurement new', ascending=False\n", - " ),\n", + " x=\"relevant_volume\",\n", + " y=\"relevant_amount\",\n", + " data=plot_data5b.sort_values(\"Kind of Measurement new\", ascending=False),\n", " hue=\"Kind of Measurement new\",\n", " legend=False,\n", - " #style=\"generation_num\",\n", - " #row=\"selection_subset\",\n", - " #sharex=False,\n", + " # style=\"generation_num\",\n", + " # row=\"selection_subset\",\n", + " # sharex=False,\n", " height=10,\n", " aspect=1.1,\n", - " scatter=False\n", + " scatter=False,\n", ")\n", "\n", "sns.scatterplot(\n", - " x=\"relevant_volume\", \n", - " y=\"relevant_amount\", \n", - " data=plot_data5b[plot_data5b.generation_num==1].sort_values(\n", - " 'Kind of Measurement new', ascending=False\n", + " x=\"relevant_volume\",\n", + " y=\"relevant_amount\",\n", + " data=plot_data5b[plot_data5b.generation_num == 1].sort_values(\n", + " \"Kind of Measurement new\", ascending=False\n", " ),\n", " hue=\"Kind of Measurement new\",\n", " legend=False,\n", - " marker='x'\n", + " marker=\"x\",\n", ")\n", "\n", "sns.scatterplot(\n", - " x=\"relevant_volume\", \n", - " y=\"relevant_amount\", \n", - " data=plot_data5b[plot_data5b.generation_num>1].sort_values(\n", - " 'Kind of Measurement new', ascending=False\n", + " x=\"relevant_volume\",\n", + " y=\"relevant_amount\",\n", + " data=plot_data5b[plot_data5b.generation_num > 1].sort_values(\n", + " \"Kind of Measurement new\", ascending=False\n", " ),\n", " hue=\"Kind of Measurement new\",\n", " legend=False,\n", - " marker='o'\n", + " marker=\"o\",\n", ")\n", "\n", - "#g._legend.set_title('Kind of Measurement')\n", + "# g._legend.set_title('Kind of Measurement')\n", "ax = plt.gca()\n", - "#ax.set(yscale=\"log2\")\n", - "#ax.set_yscale('log', basey=2)\n", - "#ax.set_xscale('log', basex=10)\n", + "# ax.set(yscale=\"log2\")\n", + "# ax.set_yscale('log', basey=2)\n", + "# ax.set_xscale('log', basex=10)\n", "labels = [\n", - " 'Single cell at G1-entry',\n", - " 'Mother&bud at cytokinesis',\n", - " 'Af control, single cell at G1-entry',\n", - " 'Af control, combined mother&bud at cytokinesis',\n", - " 'Generation 1',\n", - " 'Generation 2+'\n", + " \"Single cell at G1-entry\",\n", + " \"Mother&bud at cytokinesis\",\n", + " \"Af control, single cell at G1-entry\",\n", + " \"Af control, combined mother&bud at cytokinesis\",\n", + " \"Generation 1\",\n", + " \"Generation 2+\",\n", "]\n", "handles = [\n", " mpatches.Patch(color=sns.color_palette()[0]),\n", " mpatches.Patch(color=sns.color_palette()[1]),\n", " mpatches.Patch(color=sns.color_palette()[2]),\n", " mpatches.Patch(color=sns.color_palette()[3]),\n", - " mlines.Line2D([], [], color='gray', marker='x', linestyle='None',\n", - " markersize=10),\n", - " mlines.Line2D([], [], color='gray', marker='o', linestyle='None',\n", - " markersize=10)\n", + " mlines.Line2D([], [], color=\"gray\", marker=\"x\", linestyle=\"None\", markersize=10),\n", + " mlines.Line2D([], [], color=\"gray\", marker=\"o\", linestyle=\"None\", markersize=10),\n", "]\n", "ax.legend(\n", " handles=handles,\n", - " labels=labels, \n", - " loc='center right',\n", - " bbox_to_anchor = (1,0.2),\n", - " framealpha=0.5\n", + " labels=labels,\n", + " loc=\"center right\",\n", + " bbox_to_anchor=(1, 0.2),\n", + " framealpha=0.5,\n", ")\n", "ax.set_ylabel(\"Amount of Htb1-mCitrine in Cell(s) [a.u.]\", fontsize=20)\n", - "ax.set_xlabel(\"Volume at G1-entry / Combined Volume Before Cytokinesis [fL]\", fontsize=20)\n", - "ax.set_title(f\"Volume at G1-entry vs Htb1-mCitrine Amount (n={int(sample_size_5b/2)})\", fontsize=30)\n", + "ax.set_xlabel(\n", + " \"Volume at G1-entry / Combined Volume Before Cytokinesis [fL]\", fontsize=20\n", + ")\n", + "ax.set_title(\n", + " f\"Volume at G1-entry vs Htb1-mCitrine Amount (n={int(sample_size_5b / 2)})\",\n", + " fontsize=30,\n", + ")\n", "# format y-axis\n", - "plt.ticklabel_format(axis='y', style='sci', scilimits=(0,0), useMathText=True)\n", - "ax.get_yaxis().get_offset_text().set_position((-0.05,0))\n", + "plt.ticklabel_format(axis=\"y\", style=\"sci\", scilimits=(0, 0), useMathText=True)\n", + "ax.get_yaxis().get_offset_text().set_position((-0.05, 0))\n", "# format x-axis\n", - "ax.set_xlim(0, plot_data5b.relevant_volume.max()+20)\n", + "ax.set_xlim(0, plot_data5b.relevant_volume.max() + 20)\n", "plt.tight_layout()\n", "\"\"\"\n", "plt.savefig(os.path.join('..', 'figures', 'new_fig5', 'mCitrine_at_birth_and_cytokinesis_v6.png'), dpi=300)\n", "plt.savefig(os.path.join('..', 'figures', 'new_fig5', 'mCitrine_at_birth_and_cytokinesis_v6.svg'))\n", "\"\"\"\n", "plt.show()\n", - "print(f'sample size flu-control: {len(plot_data5b[plot_data5b.selection_subset==1])//2}')\n", - "print(f'sample size tagged strain: {len(plot_data5b[plot_data5b.selection_subset==0])//2}')" + "print(\n", + " f\"sample size flu-control: {len(plot_data5b[plot_data5b.selection_subset == 1]) // 2}\"\n", + ")\n", + "print(\n", + " f\"sample size tagged strain: {len(plot_data5b[plot_data5b.selection_subset == 0]) // 2}\"\n", + ")" ] }, { @@ -1909,21 +2161,21 @@ "source": [ "# will show up at x=1 --> later mother cells at their own birth\n", "mothers_at_birth = overall_df_with_rel[\n", - " (overall_df_with_rel.generation_num==1) & \n", - " (overall_df_with_rel.cell_cycle_stage=='G1') & \n", - " (overall_df_with_rel.frame_i==overall_df_with_rel.phase_begin) & \n", - " (overall_df_with_rel.is_history_known) &\n", - " (overall_df_with_rel.file=='MIA_KC_htb1_mCitrine_labeled') &\n", - " (~overall_df_with_rel.is_cell_excluded)\n", + " (overall_df_with_rel.generation_num == 1)\n", + " & (overall_df_with_rel.cell_cycle_stage == \"G1\")\n", + " & (overall_df_with_rel.frame_i == overall_df_with_rel.phase_begin)\n", + " & (overall_df_with_rel.is_history_known)\n", + " & (overall_df_with_rel.file == \"MIA_KC_htb1_mCitrine_labeled\")\n", + " & (~overall_df_with_rel.is_cell_excluded)\n", "]\n", "# will show up at x>1 --> mother cells now dividing from their own daughter cell the first (gen=2), second (gen=3),... time\n", "mothers_at_division = overall_df_with_rel[\n", - " (overall_df_with_rel.generation_num>1) & \n", - " (overall_df_with_rel.cell_cycle_stage=='G1') & \n", - " (overall_df_with_rel.frame_i==overall_df_with_rel.division_frame_i) & \n", - " (overall_df_with_rel.is_history_known) &\n", - " (overall_df_with_rel.file=='MIA_KC_htb1_mCitrine_labeled') &\n", - " (~overall_df_with_rel.is_cell_excluded)\n", + " (overall_df_with_rel.generation_num > 1)\n", + " & (overall_df_with_rel.cell_cycle_stage == \"G1\")\n", + " & (overall_df_with_rel.frame_i == overall_df_with_rel.division_frame_i)\n", + " & (overall_df_with_rel.is_history_known)\n", + " & (overall_df_with_rel.file == \"MIA_KC_htb1_mCitrine_labeled\")\n", + " & (~overall_df_with_rel.is_cell_excluded)\n", "]" ] }, @@ -1934,13 +2186,27 @@ "metadata": {}, "outputs": [], "source": [ - "mothers_df = pd.concat([mothers_at_division,mothers_at_birth], ignore_index=True)\n", - "mothers_df['pos_cell_id'] = mothers_df.apply(lambda x: f'cell_{x.loc[\"Cell_ID\"]}_{x.loc[\"position\"]}', axis=1)\n", + "mothers_df = pd.concat([mothers_at_division, mothers_at_birth], ignore_index=True)\n", + "mothers_df[\"pos_cell_id\"] = mothers_df.apply(\n", + " lambda x: f\"cell_{x.loc['Cell_ID']}_{x.loc['position']}\", axis=1\n", + ")\n", "# calculate number of cells per generation\n", "gen_counter = Counter(mothers_df.generation_num)\n", - "mothers_df['x_label'] = mothers_df.generation_num.apply(lambda x: f'{int(x)} (n={gen_counter[x]})')\n", - "mothers_df = mothers_df[['frame_i', 'Cell_ID', 'file', 'position', 'x_label', 'mCitrine_corrected_amount', 'mCitrine_corrected_concentration']].sort_values('x_label')\n", - "#mothers_df.to_csv(os.path.join(data_dir, 'plot_data5c.csv'), index=False)" + "mothers_df[\"x_label\"] = mothers_df.generation_num.apply(\n", + " lambda x: f\"{int(x)} (n={gen_counter[x]})\"\n", + ")\n", + "mothers_df = mothers_df[\n", + " [\n", + " \"frame_i\",\n", + " \"Cell_ID\",\n", + " \"file\",\n", + " \"position\",\n", + " \"x_label\",\n", + " \"mCitrine_corrected_amount\",\n", + " \"mCitrine_corrected_concentration\",\n", + " ]\n", + "].sort_values(\"x_label\")\n", + "# mothers_df.to_csv(os.path.join(data_dir, 'plot_data5c.csv'), index=False)" ] }, { @@ -1961,39 +2227,43 @@ "outputs": [], "source": [ "sns.set_theme(style=\"whitegrid\", font_scale=1.6)\n", - "fig, ax = plt.subplots(figsize=(10,10))\n", + "fig, ax = plt.subplots(figsize=(10, 10))\n", "sns.boxplot(\n", " data=mothers_df,\n", - " x='x_label',\n", - " y='mCitrine_corrected_concentration',\n", - " palette='vlag',\n", + " x=\"x_label\",\n", + " y=\"mCitrine_corrected_concentration\",\n", + " palette=\"vlag\",\n", " fliersize=0,\n", - " ax=ax\n", + " ax=ax,\n", ")\n", "\n", - "#add stripplot on top\n", + "# add stripplot on top\n", "sns.stripplot(\n", " data=mothers_df,\n", - " x='x_label',\n", - " y='mCitrine_corrected_concentration',\n", + " x=\"x_label\",\n", + " y=\"mCitrine_corrected_concentration\",\n", " color=\".3\",\n", - " ax=ax\n", + " ax=ax,\n", ")\n", "\n", "# switch to scientific number format on y-Axis and move text\n", - "ax.ticklabel_format(axis='y', style='sci', scilimits=(0,0), useMathText=True)\n", - "ax.get_yaxis().get_offset_text().set_position((-0.05,0))\n", + "ax.ticklabel_format(axis=\"y\", style=\"sci\", scilimits=(0, 0), useMathText=True)\n", + "ax.get_yaxis().get_offset_text().set_position((-0.05, 0))\n", "\n", "# Rename axes and set title\n", - "ax.set_ylabel(\"Htb1-mCitrine amount per volume in mother cell at division [a.u.]\", fontsize=20)\n", + "ax.set_ylabel(\n", + " \"Htb1-mCitrine amount per volume in mother cell at division [a.u.]\", fontsize=20\n", + ")\n", "ax.set_xlabel(\"Generation\", fontsize=20)\n", - "ax.set_title(f\"Amount per Volume by Generation (n={len(mothers_df)})\", fontsize=25) # changed this from 30 to 25 compared to 5B\n", - "ax.set_ylim(0, mothers_df.mCitrine_corrected_concentration.max()+0.1e4)\n", + "ax.set_title(\n", + " f\"Amount per Volume by Generation (n={len(mothers_df)})\", fontsize=25\n", + ") # changed this from 30 to 25 compared to 5B\n", + "ax.set_ylim(0, mothers_df.mCitrine_corrected_concentration.max() + 0.1e4)\n", "\n", "# save and show\n", "plt.tight_layout()\n", - "#plt.savefig('../figures/generation_plot_v5.svg')#, dpi=300)\n", - "#plt.savefig('../figures/generation_plot_v5.png', dpi=300)\n", + "# plt.savefig('../figures/generation_plot_v5.svg')#, dpi=300)\n", + "# plt.savefig('../figures/generation_plot_v5.png', dpi=300)\n", "plt.show()" ] }, @@ -2014,19 +2284,23 @@ "metadata": {}, "outputs": [], "source": [ - "outliers = mothers_df.loc[mothers_df['mCitrine_corrected_concentration'] > 1e4][['Cell_ID', 'frame_i', 'file', 'position']]\n", - "data_path = f'../data/acdc_test_data/TimeLapse_2D/MIA_KC_htb1_mCitrine_labeled'\n", + "outliers = mothers_df.loc[mothers_df[\"mCitrine_corrected_concentration\"] > 1e4][\n", + " [\"Cell_ID\", \"frame_i\", \"file\", \"position\"]\n", + "]\n", + "data_path = f\"../data/acdc_test_data/TimeLapse_2D/MIA_KC_htb1_mCitrine_labeled\"\n", "for idx, line in outliers.iterrows():\n", - " print(line)# if 'is' in str(v)])\n", - " pos_dir = f'{data_path}/{line[\"position\"]}/Images'\n", - " channel_data, seg_mask, cc_data, metadata, cc_props = cca_functions._load_files(pos_dir, ['phase_contr'])\n", - " plt.figure(figsize=(12,5))\n", + " print(line) # if 'is' in str(v)])\n", + " pos_dir = f\"{data_path}/{line['position']}/Images\"\n", + " channel_data, seg_mask, cc_data, metadata, cc_props = cca_functions._load_files(\n", + " pos_dir, [\"phase_contr\"]\n", + " )\n", + " plt.figure(figsize=(12, 5))\n", " plt.subplot(121)\n", - " plt.title('Phase Contrast')\n", + " plt.title(\"Phase Contrast\")\n", " plt.imshow(channel_data[line[\"frame_i\"]])\n", " plt.subplot(122)\n", - " plt.title('Outlier_cell')\n", - " plt.imshow(seg_mask[line[\"frame_i\"]]==line[\"Cell_ID\"])\n", + " plt.title(\"Outlier_cell\")\n", + " plt.imshow(seg_mask[line[\"frame_i\"]] == line[\"Cell_ID\"])\n", " plt.show()" ] }, diff --git a/notebooks/cell_cycle_analysis.ipynb b/notebooks/cell_cycle_analysis.ipynb index ff0c11706..d4ab46d45 100755 --- a/notebooks/cell_cycle_analysis.ipynb +++ b/notebooks/cell_cycle_analysis.ipynb @@ -20,13 +20,15 @@ "import glob\n", "import numpy as np\n", "import pandas as pd\n", + "\n", "pd.set_option(\"display.max_columns\", 200)\n", "pd.set_option(\"display.max_rows\", 50)\n", - "pd.set_option('display.max_colwidth', 150)\n", + "pd.set_option(\"display.max_colwidth\", 150)\n", "import matplotlib.pyplot as plt\n", "import matplotlib.patches as mpatches\n", "import matplotlib.lines as mlines\n", "import seaborn as sns\n", + "\n", "sns.set_theme()\n", "try:\n", " from cellacdc import cca_functions\n", @@ -34,7 +36,7 @@ "except FileNotFoundError:\n", " # Check if user has developer version --> add the Cell_ACDC/cellacdc\n", " # folder to path and import from thre\n", - " sys.path.insert(0, '../cellacdc/')\n", + " sys.path.insert(0, \"../cellacdc/\")\n", " from cellacdc import cca_functions\n", " from cellacdc import myutils" ] @@ -83,12 +85,17 @@ "source": [ "data_dirs, positions, app = cca_functions.configuration_dialog()\n", "file_names = [os.path.split(path)[-1] for path in data_dirs]\n", - "image_folders = [[os.path.join(data_dir, pos_str, 'Images') for pos_str in pos_list] for pos_list, data_dir in zip(positions, data_dirs)]\n", + "image_folders = [\n", + " [os.path.join(data_dir, pos_str, \"Images\") for pos_str in pos_list]\n", + " for pos_list, data_dir in zip(positions, data_dirs)\n", + "]\n", "# determine available channels based on first(!) position.\n", "# Warn user if one or more of the channels are not available for some positions\n", - "first_pos_dir = os.path.join(data_dirs[0], positions[0][0], 'Images')\n", + "first_pos_dir = os.path.join(data_dirs[0], positions[0][0], \"Images\")\n", "first_pos_files = myutils.listdir(first_pos_dir)\n", - "channels, basename = cca_functions.find_available_channels(first_pos_files, first_pos_dir)\n", + "channels, basename = cca_functions.find_available_channels(\n", + " first_pos_files, first_pos_dir\n", + ")\n", "segm_endname = cca_functions.get_segm_endname(first_pos_dir, basename)" ] }, @@ -143,9 +150,9 @@ " file_names,\n", " image_folders,\n", " positions,\n", - " channels, \n", + " channels,\n", " segm_endname,\n", - " force_recalculation=False\n", + " force_recalculation=False,\n", ")\n", "\"\"\"\n", "overall_df = cca_functions.load_acdc_output_only(\n", @@ -181,24 +188,38 @@ "outputs": [], "source": [ "# if cell cycle annotations were performed in ACDC, extend the dataframe by a join on each cells relative cell\n", - "if 'cell_cycle_stage' in overall_df.columns:\n", + "if \"cell_cycle_stage\" in overall_df.columns:\n", " overall_df_with_rel = cca_functions.calculate_relatives_data(overall_df, channels)\n", "# If working with timelapse data build dataframe grouped by phases\n", "group_cols = [\n", - " 'Cell_ID', 'generation_num', 'cell_cycle_stage', 'relationship', 'position', 'file', \n", - " 'max_frame_pos', 'selection_subset', 'max_t'\n", + " \"Cell_ID\",\n", + " \"generation_num\",\n", + " \"cell_cycle_stage\",\n", + " \"relationship\",\n", + " \"position\",\n", + " \"file\",\n", + " \"max_frame_pos\",\n", + " \"selection_subset\",\n", + " \"max_t\",\n", "]\n", "# calculate data grouped by phase only in the case, that timelapse data is available\n", - "if is_timelapse_data and 'max_t' in overall_df_with_rel.columns:\n", - " phase_grouped = cca_functions.calculate_per_phase_quantities(overall_df_with_rel, group_cols, channels)\n", + "if is_timelapse_data and \"max_t\" in overall_df_with_rel.columns:\n", + " phase_grouped = cca_functions.calculate_per_phase_quantities(\n", + " overall_df_with_rel, group_cols, channels\n", + " )\n", " # append phase-grouped data to overall_df_with_rel\n", " overall_df_with_rel = overall_df_with_rel.merge(\n", - " phase_grouped,\n", - " how='left',\n", - " on=group_cols\n", + " phase_grouped, how=\"left\", on=group_cols\n", " )\n", - " overall_df_with_rel['time_in_phase'] = overall_df_with_rel['frame_i'] - overall_df_with_rel['phase_begin'] + 1\n", - " overall_df_with_rel['time_in_cell_cycle'] = overall_df_with_rel.groupby(['Cell_ID', 'generation_num', 'position', 'file'])['frame_i'].transform('cumcount') + 1" + " overall_df_with_rel[\"time_in_phase\"] = (\n", + " overall_df_with_rel[\"frame_i\"] - overall_df_with_rel[\"phase_begin\"] + 1\n", + " )\n", + " overall_df_with_rel[\"time_in_cell_cycle\"] = (\n", + " overall_df_with_rel.groupby([\"Cell_ID\", \"generation_num\", \"position\", \"file\"])[\n", + " \"frame_i\"\n", + " ].transform(\"cumcount\")\n", + " + 1\n", + " )" ] }, { @@ -258,30 +279,26 @@ "metadata": {}, "outputs": [], "source": [ - "\n", "symm_plot_data = overall_df.copy()\n", - "grouping_cols = [\n", - " 'Cell_ID', \n", - " 'Cell_ID_tree', \n", - " 'parent_ID_tree', \n", - " 'file', \n", - " 'position'\n", - "]\n", - "symm_plot_data['rolling_avg_area'] = (\n", - " symm_plot_data.groupby(grouping_cols)['cell_area_pxl']\n", - " .transform(lambda x: x.rolling(window=3, center=True).mean())\n", - ")\n", - "symm_plot_data = symm_plot_data.sort_values('frame_i')\n", - "cc_frames = symm_plot_data.groupby(grouping_cols).agg(\n", - " root_ID = ('root_ID_tree', lambda x: x.iloc[0]),\n", - " birth_frame = ('frame_i', min),\n", - " cc_end_frame = ('frame_i', max),\n", - " birth_area = ('cell_area_pxl', lambda x: x.iloc[0]),\n", - " mean_area_first_5 = ('cell_area_pxl', lambda x: x.iloc[:5].mean()),\n", - " mean_area_first_10 = ('cell_area_pxl', lambda x: x.iloc[:10].mean()),\n", - " avg_area_first3 = ('rolling_avg_area', lambda x: try_find_entry(x, 1)),\n", - " avg_area_9to11 = ('rolling_avg_area', lambda x: try_find_entry(x, 9))\n", - ").reset_index()\n" + "grouping_cols = [\"Cell_ID\", \"Cell_ID_tree\", \"parent_ID_tree\", \"file\", \"position\"]\n", + "symm_plot_data[\"rolling_avg_area\"] = symm_plot_data.groupby(grouping_cols)[\n", + " \"cell_area_pxl\"\n", + "].transform(lambda x: x.rolling(window=3, center=True).mean())\n", + "symm_plot_data = symm_plot_data.sort_values(\"frame_i\")\n", + "cc_frames = (\n", + " symm_plot_data.groupby(grouping_cols)\n", + " .agg(\n", + " root_ID=(\"root_ID_tree\", lambda x: x.iloc[0]),\n", + " birth_frame=(\"frame_i\", min),\n", + " cc_end_frame=(\"frame_i\", max),\n", + " birth_area=(\"cell_area_pxl\", lambda x: x.iloc[0]),\n", + " mean_area_first_5=(\"cell_area_pxl\", lambda x: x.iloc[:5].mean()),\n", + " mean_area_first_10=(\"cell_area_pxl\", lambda x: x.iloc[:10].mean()),\n", + " avg_area_first3=(\"rolling_avg_area\", lambda x: try_find_entry(x, 1)),\n", + " avg_area_9to11=(\"rolling_avg_area\", lambda x: try_find_entry(x, 9)),\n", + " )\n", + " .reset_index()\n", + ")" ] }, { @@ -291,20 +308,26 @@ "metadata": {}, "outputs": [], "source": [ - "cc_frames['cc_length'] = cc_frames['cc_end_frame'] - cc_frames['birth_frame']\n", + "cc_frames[\"cc_length\"] = cc_frames[\"cc_end_frame\"] - cc_frames[\"birth_frame\"]\n", "# filter for birth_frame>0 (birth of cells present at beginning of experiment cannot be observed)\n", - "cc_frames = cc_frames.loc[cc_frames['birth_frame']>0]\n", + "cc_frames = cc_frames.loc[cc_frames[\"birth_frame\"] > 0]\n", "# calculate last frames per experiment and join this information with data\n", - "last_frames_per_experiment = overall_df.groupby(['file', 'position']).agg(\n", - " last_frame = ('frame_i', max)\n", - ").reset_index()\n", - "cc_frames = pd.merge(cc_frames, last_frames_per_experiment, how='left', on=['position', 'file'])\n", + "last_frames_per_experiment = (\n", + " overall_df.groupby([\"file\", \"position\"])\n", + " .agg(last_frame=(\"frame_i\", max))\n", + " .reset_index()\n", + ")\n", + "cc_frames = pd.merge(\n", + " cc_frames, last_frames_per_experiment, how=\"left\", on=[\"position\", \"file\"]\n", + ")\n", "# filter out rows where cell cycle \"ends\" on last frame (could just be bc of end of experiment)\n", - "cc_frames = cc_frames[cc_frames.last_frame!=cc_frames.cc_end_frame]\n", + "cc_frames = cc_frames[cc_frames.last_frame != cc_frames.cc_end_frame]\n", "# filter out rows with cell cycle length 0 (those are rows representing non-observable S phases)\n", - "cc_frames = cc_frames[cc_frames.cc_length>0]\n", + "cc_frames = cc_frames[cc_frames.cc_length > 0]\n", "# calculate growth at beginning of cell cycle by subtracting sliding avg of first 3 frames from sliding avg within frames 9 to 11\n", - "cc_frames['growth_in_first_10_frames'] = cc_frames.avg_area_9to11 - cc_frames.avg_area_first3" + "cc_frames[\"growth_in_first_10_frames\"] = (\n", + " cc_frames.avg_area_9to11 - cc_frames.avg_area_first3\n", + ")" ] }, { @@ -333,15 +356,11 @@ "metadata": {}, "outputs": [], "source": [ - "plt.figure(figsize=(8,8))\n", - "plt.title('Correlation of cell size at birth and cell cycle length')\n", - "sns.regplot(\n", - " data=cc_frames,\n", - " x = 'birth_area',\n", - " y = 'cc_length'\n", - ")\n", - "plt.ylabel('Length of cell cycle [frames]')\n", - "plt.xlabel('Area of cell at birth [pixels]')\n", + "plt.figure(figsize=(8, 8))\n", + "plt.title(\"Correlation of cell size at birth and cell cycle length\")\n", + "sns.regplot(data=cc_frames, x=\"birth_area\", y=\"cc_length\")\n", + "plt.ylabel(\"Length of cell cycle [frames]\")\n", + "plt.xlabel(\"Area of cell at birth [pixels]\")\n", "plt.show()" ] }, @@ -352,15 +371,11 @@ "metadata": {}, "outputs": [], "source": [ - "plt.figure(figsize=(8,8))\n", - "plt.title('Correlation of cell size at birth and growth in the first 10 frames')\n", - "sns.regplot(\n", - " data=cc_frames,\n", - " x = 'birth_area',\n", - " y = 'growth_in_first_10_frames'\n", - ")\n", - "plt.ylabel('Change of area during 10 first frames [pixels]')\n", - "plt.xlabel('Area of cell at birth [pixels]')\n", + "plt.figure(figsize=(8, 8))\n", + "plt.title(\"Correlation of cell size at birth and growth in the first 10 frames\")\n", + "sns.regplot(data=cc_frames, x=\"birth_area\", y=\"growth_in_first_10_frames\")\n", + "plt.ylabel(\"Change of area during 10 first frames [pixels]\")\n", + "plt.xlabel(\"Area of cell at birth [pixels]\")\n", "plt.show()" ] }, @@ -380,14 +395,10 @@ "metadata": {}, "outputs": [], "source": [ - "plt.figure(figsize=(8,8))\n", - "plt.title('Distribution of cell cycle lengths')\n", - "sns.histplot(\n", - " data=cc_frames,\n", - " x='cc_length',\n", - " bins=10\n", - " )\n", - "plt.xlabel('Length of cell cycle [frames]')\n", + "plt.figure(figsize=(8, 8))\n", + "plt.title(\"Distribution of cell cycle lengths\")\n", + "sns.histplot(data=cc_frames, x=\"cc_length\", bins=10)\n", + "plt.xlabel(\"Length of cell cycle [frames]\")\n", "plt.show()" ] }, @@ -429,8 +440,10 @@ } ], "source": [ - "complete_cc_data = overall_df_with_rel[overall_df_with_rel.complete_cycle==1]\n", - "cc_lengths = complete_cc_data.groupby(['Cell_ID', 'generation_num', 'file', 'position'])['time_in_cell_cycle'].max()\n", + "complete_cc_data = overall_df_with_rel[overall_df_with_rel.complete_cycle == 1]\n", + "cc_lengths = complete_cc_data.groupby(\n", + " [\"Cell_ID\", \"generation_num\", \"file\", \"position\"]\n", + ")[\"time_in_cell_cycle\"].max()\n", "sns.histplot(cc_lengths)\n", "plt.show()" ] @@ -457,22 +470,35 @@ "outputs": [], "source": [ "# set this to match with channel of interest\n", - "ch_name = 'mCitrine'\n", + "ch_name = \"mCitrine\"\n", "# filter for relevant rows (first gen G1 cells)\n", "plot_data7 = overall_df_with_rel[\n", - " (overall_df_with_rel.cell_cycle_stage=='G1') &\n", - " (overall_df_with_rel.generation_num==1) &\n", - " (overall_df_with_rel.is_history_known) &\n", - " (overall_df_with_rel.complete_phase)\n", + " (overall_df_with_rel.cell_cycle_stage == \"G1\")\n", + " & (overall_df_with_rel.generation_num == 1)\n", + " & (overall_df_with_rel.is_history_known)\n", + " & (overall_df_with_rel.complete_phase)\n", "]\n", "# select columns of interest for the plot\n", "plot_data7 = plot_data7[\n", - " ['file', 'position', 'frame_i', 'Cell_ID', 'phase_begin', 'generation_num', f'{ch_name}_corrected_concentration']\n", + " [\n", + " \"file\",\n", + " \"position\",\n", + " \"frame_i\",\n", + " \"Cell_ID\",\n", + " \"phase_begin\",\n", + " \"generation_num\",\n", + " f\"{ch_name}_corrected_concentration\",\n", + " ]\n", "]\n", "# calculate \"time in phase\" column for x-axis\n", - "plot_data7['time_in_phase'] = plot_data7['frame_i'] - plot_data7['phase_begin']\n", + "plot_data7[\"time_in_phase\"] = plot_data7[\"frame_i\"] - plot_data7[\"phase_begin\"]\n", "# calculate a unique cell id accross files by just appending file, pos & cell id\n", - "plot_data7['f_pos_cell_id'] = plot_data7.apply(lambda x: f'{x[\"file\"]}_{x[\"position\"]}_Cell_{x[\"Cell_ID\"]}_Gen_{int(x[\"generation_num\"])}', axis=1)" + "plot_data7[\"f_pos_cell_id\"] = plot_data7.apply(\n", + " lambda x: (\n", + " f\"{x['file']}_{x['position']}_Cell_{x['Cell_ID']}_Gen_{int(x['generation_num'])}\"\n", + " ),\n", + " axis=1,\n", + ")" ] }, { @@ -494,47 +520,47 @@ ], "source": [ "# Generate figures (aggregated, single traces, combined\n", - "sns.set_theme(context='talk', font_scale=1.15)\n", + "sns.set_theme(context=\"talk\", font_scale=1.15)\n", "sns.set_style(\"whitegrid\", {\"grid.color\": \".95\"})\n", - "fig, axs = plt.subplots(ncols=3, figsize=(30,10), sharey=False)\n", + "fig, axs = plt.subplots(ncols=3, figsize=(30, 10), sharey=False)\n", "sns.lineplot(\n", " data=plot_data7,\n", - " x=\"time_in_phase\", \n", - " y=f'{ch_name}_corrected_concentration',\n", + " x=\"time_in_phase\",\n", + " y=f\"{ch_name}_corrected_concentration\",\n", " ci=95,\n", - " ax=axs[0]\n", + " ax=axs[0],\n", ")\n", "sns.lineplot(\n", " data=plot_data7,\n", - " x=\"time_in_phase\", \n", - " y=f'{ch_name}_corrected_concentration',\n", + " x=\"time_in_phase\",\n", + " y=f\"{ch_name}_corrected_concentration\",\n", " estimator=None,\n", - " units='f_pos_cell_id',\n", + " units=\"f_pos_cell_id\",\n", " ax=axs[1],\n", " lw=0.5,\n", - " #alpha=0.5\n", + " # alpha=0.5\n", ")\n", "sns.lineplot(\n", " data=plot_data7,\n", - " x=\"time_in_phase\", \n", - " y=f'{ch_name}_corrected_concentration',\n", + " x=\"time_in_phase\",\n", + " y=f\"{ch_name}_corrected_concentration\",\n", " ci=95,\n", - " ax=axs[2]\n", + " ax=axs[2],\n", ")\n", "sns.lineplot(\n", " data=plot_data7,\n", - " x=\"time_in_phase\", \n", - " y=f'{ch_name}_corrected_concentration',\n", + " x=\"time_in_phase\",\n", + " y=f\"{ch_name}_corrected_concentration\",\n", " estimator=None,\n", - " units='f_pos_cell_id',\n", + " units=\"f_pos_cell_id\",\n", " ax=axs[2],\n", " lw=0.5,\n", - " #alpha=0.5\n", + " # alpha=0.5\n", ")\n", "axs[0].set_ylabel(f\"Amount per Volume of {ch_name} [a.u.]\")\n", "axs[1].set_ylabel(f\"Amount per Volume of {ch_name} [a.u.]\")\n", "axs[2].set_ylabel(f\"Amount per Volume of {ch_name} [a.u.]\")\n", - "#plt.savefig('../figures/firstgen_g1_concentration.png', dpi=300)\n", + "# plt.savefig('../figures/firstgen_g1_concentration.png', dpi=300)\n", "plt.show()" ] }, @@ -563,17 +589,24 @@ }, "outputs": [], "source": [ - "# obtain table where one cell cycle is represented by one row: \n", + "# obtain table where one cell cycle is represented by one row:\n", "# first set of columns (like phase_length, growth...) for G1, second set of cols for S\n", - "complete_cc_data = phase_grouped[phase_grouped.all_complete==1]\n", - "s_data = complete_cc_data[complete_cc_data.cell_cycle_stage==\"S\"]\n", - "g1_data = complete_cc_data[complete_cc_data.cell_cycle_stage==\"G1\"]\n", + "complete_cc_data = phase_grouped[phase_grouped.all_complete == 1]\n", + "s_data = complete_cc_data[complete_cc_data.cell_cycle_stage == \"S\"]\n", + "g1_data = complete_cc_data[complete_cc_data.cell_cycle_stage == \"G1\"]\n", "plot_data2 = g1_data.merge(\n", - " s_data, on=['Cell_ID', 'generation_num', 'position'], how='inner', suffixes=('_g1','_s')\n", + " s_data,\n", + " on=[\"Cell_ID\", \"generation_num\", \"position\"],\n", + " how=\"inner\",\n", + " suffixes=(\"_g1\", \"_s\"),\n", + ")\n", + "plot_data2 = plot_data2[plot_data2.generation_num == 1]\n", + "plot_data2[\"combined_motherbud_growth\"] = (\n", + " plot_data2[\"phase_area_growth_s\"] + plot_data2[\"phase_daughter_area_growth_s\"]\n", ")\n", - "plot_data2 = plot_data2[plot_data2.generation_num==1]\n", - "plot_data2['combined_motherbud_growth'] = plot_data2['phase_area_growth_s'] + plot_data2['phase_daughter_area_growth_s']\n", - "plot_data2['combined_motherbud_vol_growth'] = plot_data2['phase_volume_growth_s'] + plot_data2['phase_daughter_volume_growth_s']" + "plot_data2[\"combined_motherbud_vol_growth\"] = (\n", + " plot_data2[\"phase_volume_growth_s\"] + plot_data2[\"phase_daughter_volume_growth_s\"]\n", + ")" ] }, { @@ -591,9 +624,14 @@ "source": [ "sns.set_theme(style=\"darkgrid\", font_scale=2)\n", "# Initialize the figure\n", - "g = sns.lmplot(x=\"phase_volume_growth_g1\", y=\"combined_motherbud_vol_growth\", data=plot_data2,\n", - " hue=\"selection_subset_g1\", height=10)\n", - "g._legend.set_title('Position Pool')\n", + "g = sns.lmplot(\n", + " x=\"phase_volume_growth_g1\",\n", + " y=\"combined_motherbud_vol_growth\",\n", + " data=plot_data2,\n", + " hue=\"selection_subset_g1\",\n", + " height=10,\n", + ")\n", + "g._legend.set_title(\"Position Pool\")\n", "ax = plt.gca()\n", "ax.set_ylabel(\"Combined Mother+Bud S growth [fL]\", fontsize=20)\n", "ax.set_xlabel(\"G1 growth [fL]\", fontsize=20)\n", @@ -627,17 +665,22 @@ }, "outputs": [], "source": [ - "# obtain table where one cell cycle is represented by one row: \n", + "# obtain table where one cell cycle is represented by one row:\n", "# first set of columns (like phase_length, growth...) for G1, second set of cols for S\n", - "plot_data3 = phase_grouped[phase_grouped.cell_cycle_stage==\"G1\"]\n", - "plot_data3 = plot_data3[plot_data3.complete_phase==1]\n", - "plot_data3 = plot_data3[plot_data3.generation_num==1]\n", + "plot_data3 = phase_grouped[phase_grouped.cell_cycle_stage == \"G1\"]\n", + "plot_data3 = plot_data3[plot_data3.complete_phase == 1]\n", + "plot_data3 = plot_data3[plot_data3.generation_num == 1]\n", "\n", "sns.set_theme(style=\"darkgrid\", font_scale=2)\n", "# Initialize the figure\n", - "g = sns.lmplot(x=\"phase_volume_at_beginning\", y=\"phase_length\", data=plot_data3,\n", - " hue=\"selection_subset\", height=10)\n", - "g._legend.set_title('Position Pool')\n", + "g = sns.lmplot(\n", + " x=\"phase_volume_at_beginning\",\n", + " y=\"phase_length\",\n", + " data=plot_data3,\n", + " hue=\"selection_subset\",\n", + " height=10,\n", + ")\n", + "g._legend.set_title(\"Position Pool\")\n", "ax = plt.gca()\n", "ax.set_ylabel(\"Duration of first G1 phase [no of frames]\", fontsize=20)\n", "ax.set_xlabel(\"Volume at birth (first cytokinesis) [fL]\", fontsize=20)\n", @@ -671,23 +714,30 @@ "outputs": [], "source": [ "# set channel name here:\n", - "ch_name = 'mCitrine'\n", - "# obtain table where one cell cycle is represented by one row: \n", + "ch_name = \"mCitrine\"\n", + "# obtain table where one cell cycle is represented by one row:\n", "# first set of columns (like phase_length, growth...) for G1, second set of cols for S\n", - "plot_data4 = phase_grouped[phase_grouped.cell_cycle_stage==\"G1\"]\n", - "plot_data4 = plot_data4[plot_data4.complete_phase==1]\n", - "plot_data4 = plot_data4[plot_data4.generation_num==1]\n", + "plot_data4 = phase_grouped[phase_grouped.cell_cycle_stage == \"G1\"]\n", + "plot_data4 = plot_data4[plot_data4.complete_phase == 1]\n", + "plot_data4 = plot_data4[plot_data4.generation_num == 1]\n", "\n", "sns.set_theme(style=\"darkgrid\", font_scale=2)\n", "# Initialize the figure\n", - "g = sns.lmplot(x=\"phase_volume_at_beginning\", y=f\"phase_{ch_name}_concentration_at_beginning\", data=plot_data4,\n", - " hue=\"selection_subset\", height=10, )\n", - "g._legend.set_title('Position Pool')\n", + "g = sns.lmplot(\n", + " x=\"phase_volume_at_beginning\",\n", + " y=f\"phase_{ch_name}_concentration_at_beginning\",\n", + " data=plot_data4,\n", + " hue=\"selection_subset\",\n", + " height=10,\n", + ")\n", + "g._legend.set_title(\"Position Pool\")\n", "g.set(yscale=\"log\")\n", "ax = plt.gca()\n", "ax.set_ylabel(\"mCitrine signal amount per volume in cell [a.u.]\", fontsize=20)\n", "ax.set_xlabel(\"Volume at birth (first cytokinesis) [fL]\", fontsize=20)\n", - "ax.set_title(\"Volume at birth vs mCitrine signal amount per volume (1st generation)\", fontsize=30)\n", + "ax.set_title(\n", + " \"Volume at birth vs mCitrine signal amount per volume (1st generation)\", fontsize=30\n", + ")\n", "plt.show()" ] }, @@ -716,19 +766,26 @@ }, "outputs": [], "source": [ - "# obtain table where one cell cycle is represented by one row: \n", + "# obtain table where one cell cycle is represented by one row:\n", "# first set of columns (like phase_length, growth...) for G1, second set of cols for S\n", - "complete_cc_data = phase_grouped[phase_grouped.all_complete==1]\n", - "s_data = complete_cc_data[complete_cc_data.cell_cycle_stage==\"S\"]\n", - "g1_data = complete_cc_data[complete_cc_data.cell_cycle_stage==\"G1\"]\n", - "plot_data1 = g1_data.merge(s_data, on=['Cell_ID', 'generation_num', 'position', 'file'], how='inner')\n", - "plot_data1 = plot_data1[plot_data1.generation_num==1]\n", + "complete_cc_data = phase_grouped[phase_grouped.all_complete == 1]\n", + "s_data = complete_cc_data[complete_cc_data.cell_cycle_stage == \"S\"]\n", + "g1_data = complete_cc_data[complete_cc_data.cell_cycle_stage == \"G1\"]\n", + "plot_data1 = g1_data.merge(\n", + " s_data, on=[\"Cell_ID\", \"generation_num\", \"position\", \"file\"], how=\"inner\"\n", + ")\n", + "plot_data1 = plot_data1[plot_data1.generation_num == 1]\n", "\n", "sns.set_theme(style=\"darkgrid\", font_scale=2)\n", "# Initialize the figure\n", - "g = sns.lmplot(x=\"phase_length_x\", y=\"phase_length_y\", data=plot_data1,\n", - " hue=\"selection_subset_x\", height=10)\n", - "g._legend.set_title('Position Pool')\n", + "g = sns.lmplot(\n", + " x=\"phase_length_x\",\n", + " y=\"phase_length_y\",\n", + " data=plot_data1,\n", + " hue=\"selection_subset_x\",\n", + " height=10,\n", + ")\n", + "g._legend.set_title(\"Position Pool\")\n", "ax = plt.gca()\n", "ax.set_ylabel(\"S duration same cycle [frames]\", fontsize=20)\n", "ax.set_xlabel(\"G1 duration [frames]\", fontsize=20)\n", @@ -773,36 +830,29 @@ "sns.set_theme(style=\"ticks\", font_scale=2)\n", "\n", "# Initialize the figure\n", - "plt.figure(figsize=(10,10))\n", + "plt.figure(figsize=(10, 10))\n", "sns.histplot(\n", - " x='cell_vol_fl', \n", - " data=overall_df,\n", - " hue='relationship',\n", - " bins=20,\n", - " legend=False\n", + " x=\"cell_vol_fl\", data=overall_df, hue=\"relationship\", bins=20, legend=False\n", ")\n", "ax = plt.gca()\n", - "labels = [\n", - " 'Mother cells',\n", - " 'Buds'\n", - "]\n", + "labels = [\"Mother cells\", \"Buds\"]\n", "handles = [\n", - " mpatches.Patch(color=sns.color_palette('pastel')[0]),\n", - " mpatches.Patch(color=sns.color_palette('pastel')[1])\n", + " mpatches.Patch(color=sns.color_palette(\"pastel\")[0]),\n", + " mpatches.Patch(color=sns.color_palette(\"pastel\")[1]),\n", "]\n", "ax.legend(\n", " handles=handles,\n", - " labels=labels, \n", - " loc='upper right',\n", - " #bbox_to_anchor = (1,0.2),\n", - " framealpha=0.5\n", + " labels=labels,\n", + " loc=\"upper right\",\n", + " # bbox_to_anchor = (1,0.2),\n", + " framealpha=0.5,\n", ")\n", "\n", "# Tweak the visual presentation\n", "ax = plt.gca()\n", "ax.set_xlabel(\"Cell volume [fL]\", fontsize=20)\n", "ax.set_title(f\"Volume distribution, n: {overall_df.shape[0]}\", fontsize=30)\n", - "#sns.despine(trim=True, left=True)\n", + "# sns.despine(trim=True, left=True)\n", "plt.show()" ] }, @@ -830,20 +880,25 @@ "outputs": [], "source": [ "# set channel name here:\n", - "ch_name = 'act1'\n", + "ch_name = \"act1\"\n", "sns.set_theme(style=\"darkgrid\", font_scale=2)\n", "# Initialize the figure\n", "g = sns.lmplot(\n", - " x=\"act1_amount_autoBkgr_meanProj\", \n", - " y=\"act1_amount_autoBkgr_maxProj\", \n", + " x=\"act1_amount_autoBkgr_meanProj\",\n", + " y=\"act1_amount_autoBkgr_maxProj\",\n", " data=overall_df,\n", - " hue=\"relationship\", \n", + " hue=\"relationship\",\n", " # hue='selection_subset', # try this if you selected multiple position pools\n", - " height=10)\n", - "g._legend.set_title('Cell type')\n", + " height=10,\n", + ")\n", + "g._legend.set_title(\"Cell type\")\n", "ax = plt.gca()\n", - "ax.set_ylabel(f\"{ch_name} signal amount (max projection of z-slices) [a.u.]\", fontsize=20)\n", - "ax.set_xlabel(f\"{ch_name} signal amount (mean projection of z-slices) [a.u.]\", fontsize=20)\n", + "ax.set_ylabel(\n", + " f\"{ch_name} signal amount (max projection of z-slices) [a.u.]\", fontsize=20\n", + ")\n", + "ax.set_xlabel(\n", + " f\"{ch_name} signal amount (mean projection of z-slices) [a.u.]\", fontsize=20\n", + ")\n", "ax.set_title(\"Comparing mean projection with max projection\", fontsize=30)\n", "plt.show()" ] @@ -872,18 +927,18 @@ "outputs": [], "source": [ "# set channel name here:\n", - "ch_name = 'act1'\n", + "ch_name = \"act1\"\n", "sns.set_theme(style=\"darkgrid\", font_scale=2)\n", "# Initialize the figure\n", "g = sns.lmplot(\n", - " x=\"cell_vol_fl\", \n", - " y=f\"{ch_name}_amount_autoBkgr_meanProj\", \n", + " x=\"cell_vol_fl\",\n", + " y=f\"{ch_name}_amount_autoBkgr_meanProj\",\n", " data=overall_df,\n", " hue=\"relationship\",\n", " # hue='selection_subset', # try this if you selected multiple position pools\n", - " height=10\n", + " height=10,\n", ")\n", - "g._legend.set_title('Position Pool')\n", + "g._legend.set_title(\"Position Pool\")\n", "ax = plt.gca()\n", "ax.set_ylabel(f\"{ch_name} signal amount in cell [a.u.]\", fontsize=20)\n", "ax.set_xlabel(\"Cell volume [fL]\", fontsize=20)\n", @@ -915,17 +970,17 @@ "outputs": [], "source": [ "# set channel name here:\n", - "ch_name = 'act1'\n", + "ch_name = \"act1\"\n", "sns.set_theme(style=\"darkgrid\", font_scale=2)\n", "# Initialize the figure\n", "g = sns.lmplot(\n", - " x=\"cell_vol_fl\", \n", - " y=f\"{ch_name}_mean_meanProj\", \n", + " x=\"cell_vol_fl\",\n", + " y=f\"{ch_name}_mean_meanProj\",\n", " data=overall_df,\n", - " hue=\"relationship\", \n", - " height=10, \n", + " hue=\"relationship\",\n", + " height=10,\n", ")\n", - "g._legend.set_title('Cell type')\n", + "g._legend.set_title(\"Cell type\")\n", "ax = plt.gca()\n", "ax.set_ylabel(f\"{ch_name} mean signal strength in cell [a.u.]\", fontsize=20)\n", "ax.set_xlabel(\"Cell volume [fL]\", fontsize=20)\n", @@ -975,7 +1030,7 @@ "# some configurations\n", "# frame interval of video\n", "frame_interval_minutes = 3\n", - "# quantiles of complete cell cycles (wrt phase lengths) to exclude from analysis \n", + "# quantiles of complete cell cycles (wrt phase lengths) to exclude from analysis\n", "# (not used, keep this for potential later use)\n", "down_q, upper_q = 0, 1\n", "# minimum number of cell cycles contributing to the mean+CI curve:\n", @@ -985,7 +1040,7 @@ "# wether to scale to 0/1 or not\n", "scale_data = False\n", "# name of channel the signal of which should be plotted\n", - "ch_name = 'mCitrine'" + "ch_name = \"mCitrine\"" ] }, { @@ -1003,104 +1058,133 @@ "source": [ "# select needed cols from overall_df_with_rel to not end up with too many columns\n", "needed_cols = [\n", - " 'selection_subset', 'position', 'Cell_ID', 'cell_cycle_stage', 'generation_num', 'frame_i',\n", - " f'{ch_name}_corrected_amount', f'{ch_name}_corrected_amount_rel', \n", - " 'file', 'relationship', 'relative_ID', 'phase_length', 'phase_begin', f'gui_{ch_name}_amount_autoBkgr'\n", + " \"selection_subset\",\n", + " \"position\",\n", + " \"Cell_ID\",\n", + " \"cell_cycle_stage\",\n", + " \"generation_num\",\n", + " \"frame_i\",\n", + " f\"{ch_name}_corrected_amount\",\n", + " f\"{ch_name}_corrected_amount_rel\",\n", + " \"file\",\n", + " \"relationship\",\n", + " \"relative_ID\",\n", + " \"phase_length\",\n", + " \"phase_begin\",\n", + " f\"gui_{ch_name}_amount_autoBkgr\",\n", "]\n", - "filter_idx = np.logical_and(overall_df_with_rel['complete_cycle'] == 1, overall_df_with_rel.selection_subset==0)\n", + "filter_idx = np.logical_and(\n", + " overall_df_with_rel[\"complete_cycle\"] == 1,\n", + " overall_df_with_rel.selection_subset == 0,\n", + ")\n", "plot_data4a = overall_df_with_rel.loc[filter_idx, needed_cols].copy()\n", "# calculate the time the cell already spent in the current frame at the current timepoint\n", - "plot_data4a['frames_in_phase'] = plot_data4a['frame_i'] - plot_data4a['phase_begin'] + 1\n", - "# calculate the time to the next (for G1 cells) and from the last (for S cells) G1/S transition \n", - "plot_data4a['centered_frames_in_phase'] = plot_data4a.apply(\n", - " lambda x: x.loc['frames_in_phase'] if\\\n", - " x.loc['cell_cycle_stage']=='S' else\\\n", - " x.loc['frames_in_phase']-1-x.loc['phase_length'],\n", - " axis=1\n", + "plot_data4a[\"frames_in_phase\"] = plot_data4a[\"frame_i\"] - plot_data4a[\"phase_begin\"] + 1\n", + "# calculate the time to the next (for G1 cells) and from the last (for S cells) G1/S transition\n", + "plot_data4a[\"centered_frames_in_phase\"] = plot_data4a.apply(\n", + " lambda x: (\n", + " x.loc[\"frames_in_phase\"]\n", + " if x.loc[\"cell_cycle_stage\"] == \"S\"\n", + " else x.loc[\"frames_in_phase\"] - 1 - x.loc[\"phase_length\"]\n", + " ),\n", + " axis=1,\n", ")\n", "# calculate combined signal and the \"Pool, Phase ID\" for the legend\n", - "# plot_data4a at this point only contains relationship==mother, \n", + "# plot_data4a at this point only contains relationship==mother,\n", "# as generation_num==0 and relationship==bud are filtered out (incomplete cycle, cycles start with G1)\n", - "plot_data4a['Combined signal m&b'] = plot_data4a.apply(\n", - " lambda x: x.loc[f'{ch_name}_corrected_amount']+x.loc[f'{ch_name}_corrected_amount_rel'] if\\\n", - " x.loc['cell_cycle_stage']=='S' and x.loc['relationship'] == 'mother' else\\\n", - " x.loc[f'{ch_name}_corrected_amount'],\n", - " axis=1\n", + "plot_data4a[\"Combined signal m&b\"] = plot_data4a.apply(\n", + " lambda x: (\n", + " x.loc[f\"{ch_name}_corrected_amount\"] + x.loc[f\"{ch_name}_corrected_amount_rel\"]\n", + " if x.loc[\"cell_cycle_stage\"] == \"S\" and x.loc[\"relationship\"] == \"mother\"\n", + " else x.loc[f\"{ch_name}_corrected_amount\"]\n", + " ),\n", + " axis=1,\n", ")\n", - "plot_data4a['Bud signal'] = plot_data4a.apply(\n", - " lambda x: x.loc[f'{ch_name}_corrected_amount_rel'] if\\\n", - " x.loc['cell_cycle_stage']=='S' and x.loc['relationship'] == 'mother' else 0,\n", - " axis=1\n", + "plot_data4a[\"Bud signal\"] = plot_data4a.apply(\n", + " lambda x: (\n", + " x.loc[f\"{ch_name}_corrected_amount_rel\"]\n", + " if x.loc[\"cell_cycle_stage\"] == \"S\" and x.loc[\"relationship\"] == \"mother\"\n", + " else 0\n", + " ),\n", + " axis=1,\n", ")\n", "# scale data if needed\n", "if scale_data:\n", " maximum = max(\n", - " plot_data4a['Combined signal m&b'].max(), \n", - " plot_data4a['Bud signal'].max()\n", + " plot_data4a[\"Combined signal m&b\"].max(), plot_data4a[\"Bud signal\"].max()\n", " )\n", - " plot_data4a['Combined signal m&b'] /= maximum\n", - " plot_data4a['Bud signal'] /= maximum\n", + " plot_data4a[\"Combined signal m&b\"] /= maximum\n", + " plot_data4a[\"Bud signal\"] /= maximum\n", "# calculate min and max centered times per generation to eliminate up to a percentile\n", "# (not used, as upper_q and lower_q are set to 100/0 respectively)\n", - "plot_data4a['min_centered_frames'] = plot_data4a.groupby(\n", - " ['position', 'file', 'Cell_ID', 'generation_num']\n", - ")['centered_frames_in_phase'].transform(\n", - " 'min'\n", + "plot_data4a[\"min_centered_frames\"] = plot_data4a.groupby(\n", + " [\"position\", \"file\", \"Cell_ID\", \"generation_num\"]\n", + ")[\"centered_frames_in_phase\"].transform(\"min\")\n", + "plot_data4a[\"max_centered_frames\"] = plot_data4a.groupby(\n", + " [\"position\", \"file\", \"Cell_ID\", \"generation_num\"]\n", + ")[\"centered_frames_in_phase\"].transform(\"max\")\n", + "min_and_max = (\n", + " plot_data4a.groupby([\"Cell_ID\", \"generation_num\", \"position\", \"file\"])\n", + " .agg(\n", + " min_centered=(\"min_centered_frames\", \"first\"),\n", + " max_centered=(\"max_centered_frames\", \"first\"),\n", + " )\n", + " .reset_index()\n", ")\n", - "plot_data4a['max_centered_frames'] = plot_data4a.groupby(\n", - " ['position', 'file', 'Cell_ID', 'generation_num']\n", - ")['centered_frames_in_phase'].transform(\n", - " 'max'\n", + "min_val, max_val = (\n", + " np.quantile(min_and_max.min_centered, down_q) * frame_interval_minutes,\n", + " np.quantile(min_and_max.max_centered, upper_q) * frame_interval_minutes,\n", ")\n", - "min_and_max = plot_data4a.groupby(\n", - " ['Cell_ID', 'generation_num', 'position', 'file']\n", - ").agg(\n", - " min_centered = ('min_centered_frames', 'first'),\n", - " max_centered = ('max_centered_frames', 'first')\n", - ").reset_index()\n", - "min_val, max_val = np.quantile(\n", - " min_and_max.min_centered, down_q\n", - ") * frame_interval_minutes, np.quantile(\n", - " min_and_max.max_centered, upper_q\n", - ") * frame_interval_minutes\n", "# perform selection (won't change anything if upper and lower are 100 and 0 respectively)\n", "selection_indices = np.logical_and(\n", - " plot_data4a.min_centered_frames*frame_interval_minutes>=min_val, \n", - " plot_data4a.max_centered_frames*frame_interval_minutes<=max_val\n", + " plot_data4a.min_centered_frames * frame_interval_minutes >= min_val,\n", + " plot_data4a.max_centered_frames * frame_interval_minutes <= max_val,\n", ")\n", "plot_data4a = plot_data4a[selection_indices]\n", "\n", "# calculate centered time in minutes\n", - "plot_data4a['centered_time_in_minutes'] = plot_data4a.centered_frames_in_phase * frame_interval_minutes\n", + "plot_data4a[\"centered_time_in_minutes\"] = (\n", + " plot_data4a.centered_frames_in_phase * frame_interval_minutes\n", + ")\n", "\n", "# group dataframe to calculate sample sizes per generation\n", - "standard_grouped = plot_data4a.groupby(\n", - " ['position', 'file', 'Cell_ID', 'generation_num']\n", - ").agg('count').reset_index()\n", - "plot_data4a['Generation'] = plot_data4a.apply(\n", - " lambda x: f'1st ($n_1$={len(standard_grouped[standard_grouped.generation_num==1])})' if\\\n", - " x.loc['generation_num']==1 else f'2+ ($n_2$={len(standard_grouped[standard_grouped.generation_num>1])})',\n", - " axis=1\n", + "standard_grouped = (\n", + " plot_data4a.groupby([\"position\", \"file\", \"Cell_ID\", \"generation_num\"])\n", + " .agg(\"count\")\n", + " .reset_index()\n", + ")\n", + "plot_data4a[\"Generation\"] = plot_data4a.apply(\n", + " lambda x: (\n", + " f\"1st ($n_1$={len(standard_grouped[standard_grouped.generation_num == 1])})\"\n", + " if x.loc[\"generation_num\"] == 1\n", + " else f\"2+ ($n_2$={len(standard_grouped[standard_grouped.generation_num > 1])})\"\n", + " ),\n", + " axis=1,\n", ")\n", "if split_by_gen:\n", - " g_cols = ['centered_frames_in_phase', 'Generation']\n", + " g_cols = [\"centered_frames_in_phase\", \"Generation\"]\n", "else:\n", - " g_cols = 'centered_frames_in_phase'\n", - "plot_data4a['contributing_ccs_at_time'] = plot_data4a.groupby(g_cols).transform('count')['selection_subset']\n", + " g_cols = \"centered_frames_in_phase\"\n", + "plot_data4a[\"contributing_ccs_at_time\"] = plot_data4a.groupby(g_cols).transform(\n", + " \"count\"\n", + ")[\"selection_subset\"]\n", "plot_data4a = plot_data4a[plot_data4a.contributing_ccs_at_time >= min_no_of_ccs]\n", "\n", "# finally prepare data for plot (use melt for multiple lines)\n", "sample_size_4a = len(standard_grouped)\n", - "avg_cell_cycle_length = round(standard_grouped.loc[:,'centered_time_in_minutes'].mean())*frame_interval_minutes\n", - "cols_to_plot = ['Bud signal', 'Combined signal m&b']\n", + "avg_cell_cycle_length = (\n", + " round(standard_grouped.loc[:, \"centered_time_in_minutes\"].mean())\n", + " * frame_interval_minutes\n", + ")\n", + "cols_to_plot = [\"Bud signal\", \"Combined signal m&b\"]\n", "index_cols = [col for col in plot_data4a.columns if col not in cols_to_plot]\n", "plot_data4a_melted = pd.melt(\n", - " plot_data4a, index_cols, var_name='Method of calculation'\n", - ").sort_values('Method of calculation')\n", - "data_dir = os.path.join('..', 'data', 'paper_plot_data')\n", + " plot_data4a, index_cols, var_name=\"Method of calculation\"\n", + ").sort_values(\"Method of calculation\")\n", + "data_dir = os.path.join(\"..\", \"data\", \"paper_plot_data\")\n", "# save preprocessed data for Fig. 4A\n", - "#plot_data4a_melted.to_csv(os.path.join(data_dir, 'plot_data4a_melted.csv'), index=False)\n", - "#plot_data4a.to_csv(os.path.join(data_dir, 'plot_data4a.csv'), index=False)" + "# plot_data4a_melted.to_csv(os.path.join(data_dir, 'plot_data4a_melted.csv'), index=False)\n", + "# plot_data4a.to_csv(os.path.join(data_dir, 'plot_data4a.csv'), index=False)" ] }, { @@ -1120,29 +1204,34 @@ "sns.set_theme(style=\"darkgrid\", font_scale=1.6)\n", "f, ax = plt.subplots(figsize=(15, 12))\n", "if split_by_gen:\n", - " style='Generation'\n", + " style = \"Generation\"\n", "else:\n", - " style=None\n", + " style = None\n", "ax = sns.lineplot(\n", - " data=plot_data6_melted,#.sort_values('Pool, Phase'),\n", - " x=\"centered_time_in_minutes\", \n", + " data=plot_data6_melted, # .sort_values('Pool, Phase'),\n", + " x=\"centered_time_in_minutes\",\n", " y=\"value\",\n", - " hue='Method of Calculation',\n", - " #hue='position',\n", + " hue=\"Method of Calculation\",\n", + " # hue='position',\n", " style=style,\n", - " ci=95\n", + " ci=95,\n", ")\n", - "ax.axvline(x=0, color='red')#, label='Time of Bud Emergence')\n", + "ax.axvline(x=0, color=\"red\") # , label='Time of Bud Emergence')\n", "ax.text(\n", - " 0.5, 0.21, \"Time of \\nBud Emergence\", horizontalalignment='left', \n", - " size='medium', color='red', weight='normal'\n", + " 0.5,\n", + " 0.21,\n", + " \"Time of \\nBud Emergence\",\n", + " horizontalalignment=\"left\",\n", + " size=\"medium\",\n", + " color=\"red\",\n", + " weight=\"normal\",\n", ")\n", "ax.legend(\n", - " title=f'Avg CC Length: {avg_cell_cycle_length} min, n = {sample_size}', \n", + " title=f\"Avg CC Length: {avg_cell_cycle_length} min, n = {sample_size}\",\n", " fancybox=True,\n", " labelspacing=0.5,\n", " handlelength=1.5,\n", - " loc = 'upper left'\n", + " loc=\"upper left\",\n", ")\n", "ax.set_ylabel(\"Total amount of Signal corrected by background [a.u.]\", fontsize=20)\n", "ax.set_xlabel(\"Time in phase relative to G1/S transition [minutes]\", fontsize=20)\n", @@ -1179,14 +1268,22 @@ "outputs": [], "source": [ "# configure channel the signal of which should be plotted\n", - "ch_name = 'mCitrine'\n", + "ch_name = \"mCitrine\"\n", "# first set of columns (like phase_length, growth...) for G1, second set of cols for S\n", "needed_cols = [\n", - " 'Cell_ID', 'generation_num', 'position', 'file', 'cell_cycle_stage', 'selection_subset', \n", - " 'phase_volume_at_beginning', 'phase_volume_at_end', f'phase_{ch_name}_amount_at_beginning',\n", - " f'phase_{ch_name}_combined_amount_at_end','phase_combined_volume_at_end'\n", + " \"Cell_ID\",\n", + " \"generation_num\",\n", + " \"position\",\n", + " \"file\",\n", + " \"cell_cycle_stage\",\n", + " \"selection_subset\",\n", + " \"phase_volume_at_beginning\",\n", + " \"phase_volume_at_end\",\n", + " f\"phase_{ch_name}_amount_at_beginning\",\n", + " f\"phase_{ch_name}_combined_amount_at_end\",\n", + " \"phase_combined_volume_at_end\",\n", "]\n", - "plot_data4 = phase_grouped.loc[phase_grouped.complete_cycle==1, needed_cols]\n", + "plot_data4 = phase_grouped.loc[phase_grouped.complete_cycle == 1, needed_cols]\n", "scale_data = False" ] }, @@ -1203,51 +1300,60 @@ }, "outputs": [], "source": [ - "plot_data4['relevant_volume'] = plot_data4.apply(\n", - " lambda x: x.loc['phase_volume_at_beginning'] if\\\n", - " x.loc['cell_cycle_stage']=='G1' else\\\n", - " x.loc['phase_combined_volume_at_end'],\n", - " axis=1\n", + "plot_data4[\"relevant_volume\"] = plot_data4.apply(\n", + " lambda x: (\n", + " x.loc[\"phase_volume_at_beginning\"]\n", + " if x.loc[\"cell_cycle_stage\"] == \"G1\"\n", + " else x.loc[\"phase_combined_volume_at_end\"]\n", + " ),\n", + " axis=1,\n", ")\n", - "plot_data4['relevant_amount'] = plot_data4.apply(\n", - " lambda x: x.loc[f'phase_{ch_name}_amount_at_beginning'] if\\\n", - " x.loc['cell_cycle_stage']=='G1' else\\\n", - " x.loc[f'phase_{ch_name}_combined_amount_at_end'],\n", - " axis=1\n", + "plot_data4[\"relevant_amount\"] = plot_data4.apply(\n", + " lambda x: (\n", + " x.loc[f\"phase_{ch_name}_amount_at_beginning\"]\n", + " if x.loc[\"cell_cycle_stage\"] == \"G1\"\n", + " else x.loc[f\"phase_{ch_name}_combined_amount_at_end\"]\n", + " ),\n", + " axis=1,\n", ")\n", - "# defining a function to generate entries for the figure legend \n", + "\n", + "\n", + "# defining a function to generate entries for the figure legend\n", "# (assuming that selection_subset>0 is the autofluorescence control of the experiment)\n", "def calc_legend_entry(x):\n", - " if x.loc['selection_subset'] == 0:\n", - " if x.loc['cell_cycle_stage']=='G1':\n", - " return 'Single cell at birth'\n", + " if x.loc[\"selection_subset\"] == 0:\n", + " if x.loc[\"cell_cycle_stage\"] == \"G1\":\n", + " return \"Single cell at birth\"\n", " else:\n", - " return 'Combined mother&bud at cytokinesis'\n", + " return \"Combined mother&bud at cytokinesis\"\n", " else:\n", - " if x.loc['cell_cycle_stage']=='G1':\n", - " return 'Af control, single cell at birth'\n", + " if x.loc[\"cell_cycle_stage\"] == \"G1\":\n", + " return \"Af control, single cell at birth\"\n", " else:\n", - " return 'Af control, combined mother&bud at cytokinesis'\n", - " \n", - "plot_data4['Kind of Measurement'] = plot_data4.apply(\n", - " lambda x: 'Single Cell in G1 (Frame after Cytokinesis)' if\\\n", - " x.loc['cell_cycle_stage']=='G1' else\\\n", - " 'Combined Mother & Bud in S (Frame before Cytokinesis)',\n", - " axis=1\n", - ")\n", - "plot_data4['Kind of Measurement new'] = plot_data4.apply(\n", - " calc_legend_entry,\n", - " axis=1\n", + " return \"Af control, combined mother&bud at cytokinesis\"\n", + "\n", + "\n", + "plot_data4[\"Kind of Measurement\"] = plot_data4.apply(\n", + " lambda x: (\n", + " \"Single Cell in G1 (Frame after Cytokinesis)\"\n", + " if x.loc[\"cell_cycle_stage\"] == \"G1\"\n", + " else \"Combined Mother & Bud in S (Frame before Cytokinesis)\"\n", + " ),\n", + " axis=1,\n", ")\n", - "plot_data4['Generation'] = plot_data4.apply(\n", - " lambda x: f'1st ($n_1$={int(len(plot_data4[plot_data4.generation_num==1])/2)})' if\\\n", - " x.loc['generation_num']==1 else f'2+ ($n_2$={int(len(plot_data4[plot_data4.generation_num>1])/2)})',\n", - " axis=1\n", + "plot_data4[\"Kind of Measurement new\"] = plot_data4.apply(calc_legend_entry, axis=1)\n", + "plot_data4[\"Generation\"] = plot_data4.apply(\n", + " lambda x: (\n", + " f\"1st ($n_1$={int(len(plot_data4[plot_data4.generation_num == 1]) / 2)})\"\n", + " if x.loc[\"generation_num\"] == 1\n", + " else f\"2+ ($n_2$={int(len(plot_data4[plot_data4.generation_num > 1]) / 2)})\"\n", + " ),\n", + " axis=1,\n", ")\n", "if scale_data:\n", - " maximum = plot_data4['relevant_amount'].max()\n", - " plot_data4['relevant_amount'] /= maximum\n", - "sample_size = len(plot_data4)\n" + " maximum = plot_data4[\"relevant_amount\"].max()\n", + " plot_data4[\"relevant_amount\"] /= maximum\n", + "sample_size = len(plot_data4)" ] }, { @@ -1263,79 +1369,81 @@ }, "outputs": [], "source": [ - "#plot_data4 = plot_data4[plot_data4.selection_subset==1]\n", + "# plot_data4 = plot_data4[plot_data4.selection_subset==1]\n", "sns.set_theme(style=\"darkgrid\", font_scale=1.6)\n", "# create lmplot. Don't scatter and ommit legend to customize scatterplot and legend\n", "sns.lmplot(\n", - " x=\"relevant_volume\", \n", - " y=\"relevant_amount\", \n", - " data=plot_data4.sort_values(\n", - " 'Kind of Measurement new', ascending=False\n", - " ),\n", + " x=\"relevant_volume\",\n", + " y=\"relevant_amount\",\n", + " data=plot_data4.sort_values(\"Kind of Measurement new\", ascending=False),\n", " hue=\"Kind of Measurement new\",\n", " legend=False,\n", " height=10,\n", " aspect=1.1,\n", - " scatter=False\n", + " scatter=False,\n", ")\n", "sns.scatterplot(\n", - " x=\"relevant_volume\", \n", - " y=\"relevant_amount\", \n", - " data=plot_data4[plot_data4.generation_num==1].sort_values(\n", - " 'Kind of Measurement new', ascending=False\n", + " x=\"relevant_volume\",\n", + " y=\"relevant_amount\",\n", + " data=plot_data4[plot_data4.generation_num == 1].sort_values(\n", + " \"Kind of Measurement new\", ascending=False\n", " ),\n", " hue=\"Kind of Measurement new\",\n", " legend=False,\n", - " marker='x'\n", + " marker=\"x\",\n", ")\n", "sns.scatterplot(\n", - " x=\"relevant_volume\", \n", - " y=\"relevant_amount\", \n", - " data=plot_data4[plot_data4.generation_num>1].sort_values(\n", - " 'Kind of Measurement new', ascending=False\n", + " x=\"relevant_volume\",\n", + " y=\"relevant_amount\",\n", + " data=plot_data4[plot_data4.generation_num > 1].sort_values(\n", + " \"Kind of Measurement new\", ascending=False\n", " ),\n", " hue=\"Kind of Measurement new\",\n", " legend=False,\n", - " marker='o'\n", + " marker=\"o\",\n", ")\n", "ax = plt.gca()\n", "labels = [\n", - " 'Single cell at birth',\n", - " 'Combined mother&bud at cytokinesis',\n", - " 'Af control, single cell at birth',\n", - " 'Af control, combined mother&bud at cytokinesis',\n", - " 'Generation 1',\n", - " 'Generation 2+'\n", + " \"Single cell at birth\",\n", + " \"Combined mother&bud at cytokinesis\",\n", + " \"Af control, single cell at birth\",\n", + " \"Af control, combined mother&bud at cytokinesis\",\n", + " \"Generation 1\",\n", + " \"Generation 2+\",\n", "]\n", "handles = [\n", " mpatches.Patch(color=sns.color_palette()[0]),\n", " mpatches.Patch(color=sns.color_palette()[1]),\n", " mpatches.Patch(color=sns.color_palette()[2]),\n", " mpatches.Patch(color=sns.color_palette()[3]),\n", - " mlines.Line2D([], [], color='gray', marker='x', linestyle='None',\n", - " markersize=10),\n", - " mlines.Line2D([], [], color='gray', marker='o', linestyle='None',\n", - " markersize=10)\n", + " mlines.Line2D([], [], color=\"gray\", marker=\"x\", linestyle=\"None\", markersize=10),\n", + " mlines.Line2D([], [], color=\"gray\", marker=\"o\", linestyle=\"None\", markersize=10),\n", "]\n", "ax.legend(\n", " handles=handles,\n", - " labels=labels, \n", - " loc='center right',\n", - " bbox_to_anchor = (1,0.2),\n", - " framealpha=0.5\n", + " labels=labels,\n", + " loc=\"center right\",\n", + " bbox_to_anchor=(1, 0.2),\n", + " framealpha=0.5,\n", ")\n", "ax.set_ylabel(\"Amount of Signal in Cell(s) [a.u.]\", fontsize=20)\n", "ax.set_xlabel(\"Volume at Birth / Combined Volume Before Cytokinesis [fL]\", fontsize=20)\n", - "ax.set_title(f\"Volume at birth vs Signal Amount (n={int(sample_size/2)})\", fontsize=30)\n", + "ax.set_title(\n", + " f\"Volume at birth vs Signal Amount (n={int(sample_size / 2)})\", fontsize=30\n", + ")\n", "# format y-axis\n", - "plt.ticklabel_format(axis='y', style='sci', scilimits=(0,0), useMathText=True)\n", - "ax.get_yaxis().get_offset_text().set_position((-0.05,0))\n", + "plt.ticklabel_format(axis=\"y\", style=\"sci\", scilimits=(0, 0), useMathText=True)\n", + "ax.get_yaxis().get_offset_text().set_position((-0.05, 0))\n", "# format x-axis\n", - "ax.set_xlim(0, plot_data4.relevant_volume.max()+20)\n", + "ax.set_xlim(0, plot_data4.relevant_volume.max() + 20)\n", "plt.tight_layout()\n", "plt.show()\n", - "print(f'sample size flu-control: {len(plot_data4[plot_data4.selection_subset==1])//2}')\n", - "print(f'sample size tagged strain: {len(plot_data4[plot_data4.selection_subset==0])//2}')" + "print(\n", + " f\"sample size flu-control: {len(plot_data4[plot_data4.selection_subset == 1]) // 2}\"\n", + ")\n", + "print(\n", + " f\"sample size tagged strain: {len(plot_data4[plot_data4.selection_subset == 0]) // 2}\"\n", + ")" ] } ], diff --git a/notebooks/workshop_analyses.ipynb b/notebooks/workshop_analyses.ipynb index 3fdcc94a5..a6661f65b 100644 --- a/notebooks/workshop_analyses.ipynb +++ b/notebooks/workshop_analyses.ipynb @@ -12,11 +12,13 @@ "import numpy as np\n", "import pandas as pd\n", "from scipy.spatial import distance_matrix\n", + "\n", "pd.set_option(\"display.max_columns\", 200)\n", "pd.set_option(\"display.max_rows\", 50)\n", - "pd.set_option('display.max_colwidth', 150)\n", + "pd.set_option(\"display.max_colwidth\", 150)\n", "import matplotlib.pyplot as plt\n", "import seaborn as sns\n", + "\n", "sns.set_theme()\n", "try:\n", " from cellacdc import cca_functions\n", @@ -24,7 +26,7 @@ "except FileNotFoundError:\n", " # Check if user has developer version --> add the Cell_ACDC/cellacdc\n", " # folder to path and import from there\n", - " sys.path.insert(0, '../cellacdc/')\n", + " sys.path.insert(0, \"../cellacdc/\")\n", " from cellacdc import cca_functions\n", " from cellacdc import myutils" ] @@ -73,14 +75,28 @@ "source": [ "data_dirs, positions, app = cca_functions.configuration_dialog()\n", "file_names = [os.path.split(path)[-1] for path in data_dirs]\n", - "image_folders = [[os.path.join(data_dir, pos_str, 'Images') for pos_str in pos_list] for pos_list, data_dir in zip(positions, data_dirs)]\n", + "image_folders = [\n", + " [os.path.join(data_dir, pos_str, \"Images\") for pos_str in pos_list]\n", + " for pos_list, data_dir in zip(positions, data_dirs)\n", + "]\n", "# determine available channels based on first(!) position.\n", "# Warn user if one or more of the channels are not available for some positions\n", - "first_pos_dirs = [os.path.join(data_dir, positions[0][0], 'Images') for data_dir in data_dirs]\n", + "first_pos_dirs = [\n", + " os.path.join(data_dir, positions[0][0], \"Images\") for data_dir in data_dirs\n", + "]\n", "first_pos_files = [myutils.listdir(first_pos_dir) for first_pos_dir in first_pos_dirs]\n", - "channels = [cca_functions.find_available_channels(fpf, fpd)[0] for fpf, fpd in zip(first_pos_files, first_pos_dirs)]\n", - "basenames = [cca_functions.find_available_channels(fpf, fpd)[1] for fpf, fpd in zip(first_pos_files, first_pos_dirs)]\n", - "segm_endnames = [cca_functions.get_segm_endname(fpd, bn) for fpd, bn in zip(first_pos_dirs, basenames)]\n" + "channels = [\n", + " cca_functions.find_available_channels(fpf, fpd)[0]\n", + " for fpf, fpd in zip(first_pos_files, first_pos_dirs)\n", + "]\n", + "basenames = [\n", + " cca_functions.find_available_channels(fpf, fpd)[1]\n", + " for fpf, fpd in zip(first_pos_files, first_pos_dirs)\n", + "]\n", + "segm_endnames = [\n", + " cca_functions.get_segm_endname(fpd, bn)\n", + " for fpd, bn in zip(first_pos_dirs, basenames)\n", + "]" ] }, { @@ -116,12 +132,9 @@ "outputs": [], "source": [ "overall_df = cca_functions.load_acdc_output_only(\n", - " file_names,\n", - " image_folders,\n", - " positions,\n", - " segm_endnames\n", + " file_names, image_folders, positions, segm_endnames\n", ")\n", - "is_timelapse_data = True # Maybe not needed" + "is_timelapse_data = True # Maybe not needed" ] }, { @@ -148,24 +161,38 @@ "outputs": [], "source": [ "# if cell cycle annotations were performed in ACDC, extend the dataframe by a join on each cells relative cell\n", - "if 'cell_cycle_stage' in overall_df.columns:\n", + "if \"cell_cycle_stage\" in overall_df.columns:\n", " overall_df_with_rel = cca_functions.calculate_relatives_data(overall_df, channels)\n", " # If working with timelapse data build dataframe grouped by phases\n", " group_cols = [\n", - " 'Cell_ID', 'generation_num', 'cell_cycle_stage', 'relationship', 'position', 'file', \n", - " 'max_frame_pos', 'selection_subset', 'max_t'\n", + " \"Cell_ID\",\n", + " \"generation_num\",\n", + " \"cell_cycle_stage\",\n", + " \"relationship\",\n", + " \"position\",\n", + " \"file\",\n", + " \"max_frame_pos\",\n", + " \"selection_subset\",\n", + " \"max_t\",\n", " ]\n", " # calculate data grouped by phase only in the case, that timelapse data is available\n", - " if is_timelapse_data and 'max_t' in overall_df_with_rel.columns:\n", - " phase_grouped = cca_functions.calculate_per_phase_quantities(overall_df_with_rel, group_cols, channels)\n", + " if is_timelapse_data and \"max_t\" in overall_df_with_rel.columns:\n", + " phase_grouped = cca_functions.calculate_per_phase_quantities(\n", + " overall_df_with_rel, group_cols, channels\n", + " )\n", " # append phase-grouped data to overall_df_with_rel\n", " overall_df_with_rel = overall_df_with_rel.merge(\n", - " phase_grouped,\n", - " how='left',\n", - " on=group_cols\n", + " phase_grouped, how=\"left\", on=group_cols\n", + " )\n", + " overall_df_with_rel[\"time_in_phase\"] = (\n", + " overall_df_with_rel[\"frame_i\"] - overall_df_with_rel[\"phase_begin\"] + 1\n", " )\n", - " overall_df_with_rel['time_in_phase'] = overall_df_with_rel['frame_i'] - overall_df_with_rel['phase_begin'] + 1\n", - " overall_df_with_rel['time_in_cell_cycle'] = overall_df_with_rel.groupby(['Cell_ID', 'generation_num', 'position', 'file'])['frame_i'].transform('cumcount') + 1" + " overall_df_with_rel[\"time_in_cell_cycle\"] = (\n", + " overall_df_with_rel.groupby(\n", + " [\"Cell_ID\", \"generation_num\", \"position\", \"file\"]\n", + " )[\"frame_i\"].transform(\"cumcount\")\n", + " + 1\n", + " )" ] }, { @@ -207,22 +234,40 @@ "outputs": [], "source": [ "fig, axs = plt.subplots(1, 3, figsize=(15, 5))\n", - "sns.lineplot(data=overall_df, x='frame_i', y='cell_area_um2', hue='selection_subset', ci='sd', ax=axs[0])\n", "sns.lineplot(\n", - " data=overall_df.groupby(['frame_i', 'selection_subset']).size().reset_index(drop=False), \n", - " x='frame_i', \n", - " y=0, \n", - " hue='selection_subset', \n", - " ci='sd', \n", - " ax=axs[1]\n", - " )\n", - "track_lengths = overall_df.groupby(\n", - " ['selection_subset', 'Cell_ID']\n", - " )['frame_i'].apply(lambda x: x.max() - x.min()).reset_index(drop=False)\n", - "sns.histplot(data=track_lengths, x='frame_i', kde=True, ax=axs[2], hue='selection_subset', multiple='dodge')\n", - "axs[0].set_title('Mean cell area over time')\n", - "axs[1].set_title('Number of cells over time')\n", - "axs[2].set_title('Track length distribution')" + " data=overall_df,\n", + " x=\"frame_i\",\n", + " y=\"cell_area_um2\",\n", + " hue=\"selection_subset\",\n", + " ci=\"sd\",\n", + " ax=axs[0],\n", + ")\n", + "sns.lineplot(\n", + " data=overall_df.groupby([\"frame_i\", \"selection_subset\"])\n", + " .size()\n", + " .reset_index(drop=False),\n", + " x=\"frame_i\",\n", + " y=0,\n", + " hue=\"selection_subset\",\n", + " ci=\"sd\",\n", + " ax=axs[1],\n", + ")\n", + "track_lengths = (\n", + " overall_df.groupby([\"selection_subset\", \"Cell_ID\"])[\"frame_i\"]\n", + " .apply(lambda x: x.max() - x.min())\n", + " .reset_index(drop=False)\n", + ")\n", + "sns.histplot(\n", + " data=track_lengths,\n", + " x=\"frame_i\",\n", + " kde=True,\n", + " ax=axs[2],\n", + " hue=\"selection_subset\",\n", + " multiple=\"dodge\",\n", + ")\n", + "axs[0].set_title(\"Mean cell area over time\")\n", + "axs[1].set_title(\"Number of cells over time\")\n", + "axs[2].set_title(\"Track length distribution\")" ] }, { @@ -236,10 +281,11 @@ { "cell_type": "code", "execution_count": null, + "id": "7fb27b941602401d91542211134fc71a", "metadata": {}, "outputs": [], "source": [ - "plot_data = overall_df.loc[overall_df['selection_subset'] == 0]" + "plot_data = overall_df.loc[overall_df[\"selection_subset\"] == 0]" ] }, { @@ -260,27 +306,27 @@ "plt.figure(figsize=(18, 6))\n", "# First Panel: Number of Cells per Frame\n", "plt.subplot(1, 3, 1)\n", - "plot_data.groupby('frame_i').size().plot(kind='line')\n", - "plt.xlabel('Frame')\n", - "plt.ylabel('Number of Cells')\n", - "plt.title('Number of Cells per Frame')\n", + "plot_data.groupby(\"frame_i\").size().plot(kind=\"line\")\n", + "plt.xlabel(\"Frame\")\n", + "plt.ylabel(\"Number of Cells\")\n", + "plt.title(\"Number of Cells per Frame\")\n", "\n", "# Second Panel: Mean Cell Volume over Time\n", "plt.subplot(1, 3, 2)\n", - "sns.lineplot(data=plot_data, x='frame_i', y='cell_area_um2', ci='sd')\n", - "plt.xlabel('Frame')\n", - "plt.ylabel('Mean Cell area (µm²)')\n", - "plt.title('Mean Cell area over Time')\n", + "sns.lineplot(data=plot_data, x=\"frame_i\", y=\"cell_area_um2\", ci=\"sd\")\n", + "plt.xlabel(\"Frame\")\n", + "plt.ylabel(\"Mean Cell area (µm²)\")\n", + "plt.title(\"Mean Cell area over Time\")\n", "\n", "# Third Panel: Total Area of All Cells over Time\n", "plt.subplot(1, 3, 3)\n", - "plot_data.groupby('frame_i')['cell_area_um2'].sum().plot(kind='line')\n", - "plt.xlabel('Frame')\n", - "plt.ylabel('Total Cell area (µm²)')\n", - "plt.title('Total Cell area over Time')\n", + "plot_data.groupby(\"frame_i\")[\"cell_area_um2\"].sum().plot(kind=\"line\")\n", + "plt.xlabel(\"Frame\")\n", + "plt.ylabel(\"Total Cell area (µm²)\")\n", + "plt.title(\"Total Cell area over Time\")\n", "\n", "plt.tight_layout()\n", - "plt.show()\n" + "plt.show()" ] }, { @@ -299,28 +345,36 @@ "outputs": [], "source": [ "# Filter the DataFrame for the first frame\n", - "first_frame_df = plot_data[plot_data['frame_i'] == 0]\n", + "first_frame_df = plot_data[plot_data[\"frame_i\"] == 0]\n", "\n", "# Filter the DataFrame for the last frame\n", - "last_frame_df = plot_data[plot_data['frame_i'] == plot_data['frame_i'].max()]\n", + "last_frame_df = plot_data[plot_data[\"frame_i\"] == plot_data[\"frame_i\"].max()]\n", "# Calculate the total number of cells in each frame\n", "first_frame_total_cells = len(first_frame_df)\n", "last_frame_total_cells = len(last_frame_df)\n", "\n", "# Plot the volume distributions\n", "plt.figure(figsize=(10, 6))\n", - "sns.histplot(data=first_frame_df, x='cell_area_um2', kde=True, label='First Frame', stat='density')\n", - "sns.histplot(data=last_frame_df, x='cell_area_um2', kde=True, label='Last Frame', stat='density')\n", - "plt.xlabel('Cell Area (µm²)')\n", - "plt.ylabel('Density')\n", - "plt.title('Relative Volume Distribution of Cells')\n", + "sns.histplot(\n", + " data=first_frame_df,\n", + " x=\"cell_area_um2\",\n", + " kde=True,\n", + " label=\"First Frame\",\n", + " stat=\"density\",\n", + ")\n", + "sns.histplot(\n", + " data=last_frame_df, x=\"cell_area_um2\", kde=True, label=\"Last Frame\", stat=\"density\"\n", + ")\n", + "plt.xlabel(\"Cell Area (µm²)\")\n", + "plt.ylabel(\"Density\")\n", + "plt.title(\"Relative Volume Distribution of Cells\")\n", "plt.legend()\n", "\n", "# Add text annotations for the relative counts\n", - "print(f'Cell count first frame: {first_frame_total_cells}')\n", - "print(f'Cell count last frame: {last_frame_total_cells}')\n", + "print(f\"Cell count first frame: {first_frame_total_cells}\")\n", + "print(f\"Cell count last frame: {last_frame_total_cells}\")\n", "\n", - "plt.show()\n" + "plt.show()" ] }, { @@ -339,15 +393,17 @@ "outputs": [], "source": [ "# Calculate track lengths\n", - "track_lengths = plot_data.groupby('Cell_ID')['frame_i'].apply(lambda x: x.max() - x.min())\n", + "track_lengths = plot_data.groupby(\"Cell_ID\")[\"frame_i\"].apply(\n", + " lambda x: x.max() - x.min()\n", + ")\n", "\n", "# Plot track length distribution\n", "plt.figure(figsize=(10, 6))\n", "sns.histplot(data=track_lengths, kde=True)\n", - "plt.xlabel('Track Length')\n", - "plt.ylabel('Count')\n", - "plt.title('Distribution of Track Lengths')\n", - "plt.show()\n" + "plt.xlabel(\"Track Length\")\n", + "plt.ylabel(\"Count\")\n", + "plt.title(\"Distribution of Track Lengths\")\n", + "plt.show()" ] }, { @@ -365,31 +421,30 @@ "metadata": {}, "outputs": [], "source": [ - "filtered_df = plot_data[plot_data['Cell_ID'].map(track_lengths) > 20]\n", + "filtered_df = plot_data[plot_data[\"Cell_ID\"].map(track_lengths) > 20]\n", "plt.figure(figsize=(21, 7))\n", "# First Panel: Volume over time lineplot\n", "plt.subplot(1, 2, 1)\n", - "for cell_id, cell_data in filtered_df.groupby('Cell_ID'):\n", - " plt.plot(cell_data['frame_i'], cell_data['cell_area_um2'], label=f'Cell {cell_id}')\n", - "plt.xlabel('Frame')\n", - "plt.ylabel('Cell Area (µm²)')\n", - "plt.title('Volume over Time')\n", + "for cell_id, cell_data in filtered_df.groupby(\"Cell_ID\"):\n", + " plt.plot(cell_data[\"frame_i\"], cell_data[\"cell_area_um2\"], label=f\"Cell {cell_id}\")\n", + "plt.xlabel(\"Frame\")\n", + "plt.ylabel(\"Cell Area (µm²)\")\n", + "plt.title(\"Volume over Time\")\n", "plt.legend().set_visible(False) # Hide the legend\n", "\n", "# Second Panel: Traces of all cells\n", "plt.subplot(1, 2, 2)\n", - "for cell_id, cell_data in filtered_df.groupby('Cell_ID'):\n", - " plt.plot(cell_data['centroid-1'], cell_data['centroid-0'], label=f'Cell {cell_id}')\n", - "plt.xlabel('X-coordinate')\n", - "plt.title('Traces of Cells')\n", + "for cell_id, cell_data in filtered_df.groupby(\"Cell_ID\"):\n", + " plt.plot(cell_data[\"centroid-1\"], cell_data[\"centroid-0\"], label=f\"Cell {cell_id}\")\n", + "plt.xlabel(\"X-coordinate\")\n", + "plt.title(\"Traces of Cells\")\n", "plt.legend().set_visible(False) # Hide the legend\n", - "maxCentroidAll = filtered_df[['centroid-0', 'centroid-1']].max().max()\n", - "plt.xlim(0, maxCentroidAll+50)\n", - "plt.ylim(0, maxCentroidAll+50)\n", + "maxCentroidAll = filtered_df[[\"centroid-0\", \"centroid-1\"]].max().max()\n", + "plt.xlim(0, maxCentroidAll + 50)\n", + "plt.ylim(0, maxCentroidAll + 50)\n", "\n", "plt.tight_layout()\n", - "plt.show()\n", - "\n" + "plt.show()" ] }, { @@ -411,8 +466,8 @@ " \"\"\"\n", " Calculate the frame-by-frame distance of a centroid series\n", " \"\"\"\n", - " xSeries = centroid_series['centroid-1']\n", - " ySeries = centroid_series['centroid-0']\n", + " xSeries = centroid_series[\"centroid-1\"]\n", + " ySeries = centroid_series[\"centroid-0\"]\n", " # Calculate the distance between each frame\n", " dists = np.sqrt((xSeries.diff() ** 2) + (ySeries.diff() ** 2))\n", " return dists" @@ -425,28 +480,39 @@ "metadata": {}, "outputs": [], "source": [ - "\n", "# Left panel: Total traveled distance vs. mean volume\n", "plt.figure(figsize=(14, 7))\n", "plt.subplot(1, 2, 1)\n", - "for cell_id, cell_data in filtered_df.groupby('Cell_ID'):\n", - " plt.scatter(cell_data['cell_area_um2'].mean(), np.max(distance_matrix(cell_data[['centroid-0', 'centroid-1']], cell_data[['centroid-0', 'centroid-1']])))\n", + "for cell_id, cell_data in filtered_df.groupby(\"Cell_ID\"):\n", + " plt.scatter(\n", + " cell_data[\"cell_area_um2\"].mean(),\n", + " np.max(\n", + " distance_matrix(\n", + " cell_data[[\"centroid-0\", \"centroid-1\"]],\n", + " cell_data[[\"centroid-0\", \"centroid-1\"]],\n", + " )\n", + " ),\n", + " )\n", "\n", - "plt.xlabel('Mean Area [µm²]')\n", - "plt.ylabel('Total Traveled Distance')\n", - "plt.title('Total Traveled Distance vs. Mean Area')\n", + "plt.xlabel(\"Mean Area [µm²]\")\n", + "plt.ylabel(\"Total Traveled Distance\")\n", + "plt.title(\"Total Traveled Distance vs. Mean Area\")\n", "\n", "# Right panel: Frame-by-frame distance vs. frame-by-frame growth\n", "plt.subplot(1, 2, 2)\n", - "for cell_id, cell_data in filtered_df.groupby('Cell_ID'):\n", - " plt.scatter(frame_by_frame_dist(cell_data[['centroid-0', 'centroid-1']])[1:], np.diff(cell_data['cell_area_um2']), alpha=0.4)\n", + "for cell_id, cell_data in filtered_df.groupby(\"Cell_ID\"):\n", + " plt.scatter(\n", + " frame_by_frame_dist(cell_data[[\"centroid-0\", \"centroid-1\"]])[1:],\n", + " np.diff(cell_data[\"cell_area_um2\"]),\n", + " alpha=0.4,\n", + " )\n", "\n", - "plt.xlabel('Frame-by-Frame Distance')\n", - "plt.ylabel('Frame-by-Frame Growth [Area in µm²]')\n", - "plt.title('Frame-by-Frame Distance vs. Frame-by-Frame Growth')\n", + "plt.xlabel(\"Frame-by-Frame Distance\")\n", + "plt.ylabel(\"Frame-by-Frame Growth [Area in µm²]\")\n", + "plt.title(\"Frame-by-Frame Distance vs. Frame-by-Frame Growth\")\n", "\n", "plt.tight_layout()\n", - "plt.show()\n" + "plt.show()" ] }, { diff --git a/tests/prompt_segm/test_sam.py b/tests/prompt_segm/test_sam.py index 70b07c9d5..102af06c4 100644 --- a/tests/prompt_segm/test_sam.py +++ b/tests/prompt_segm/test_sam.py @@ -70,7 +70,9 @@ def test_promptable_segmentation_with_ground_truth_centroids(self, test_data): plots_dir = Path(__file__).parent.parent / "_plots" / "prompt_segm" / "sam" save_segmentation_overlay( - labels, frame, frame_index, + labels, + frame, + frame_index, plots_dir / f"test_promptable_sam_frame_{frame_index:04d}.png", prompt_points=centroids, ) diff --git a/tests/prompt_segm/test_sam2.py b/tests/prompt_segm/test_sam2.py index 97ba4dc9e..2ff5826b1 100644 --- a/tests/prompt_segm/test_sam2.py +++ b/tests/prompt_segm/test_sam2.py @@ -70,7 +70,9 @@ def test_promptable_segmentation_with_ground_truth_centroids(self, test_data): plots_dir = Path(__file__).parent.parent / "_plots" / "prompt_segm" / "sam2" save_segmentation_overlay( - labels, frame, frame_index, + labels, + frame, + frame_index, plots_dir / f"test_promptable_sam2_frame_{frame_index:04d}.png", prompt_points=centroids, ) diff --git a/tests/segm/test_cellsam.py b/tests/segm/test_cellsam.py index 17ce7da96..3db69391f 100644 --- a/tests/segm/test_cellsam.py +++ b/tests/segm/test_cellsam.py @@ -59,6 +59,8 @@ def test_automatic_segmentation_sampled_frames(self, test_frames, posData): validate_labels(labels, frame.shape) print_segmentation_results(labels, frame, frame_i) save_segmentation_overlay( - labels, frame, frame_i, + labels, + frame, + frame_i, plots_dir / f"test_cellsam_segmentation_frame_{frame_i:04d}.png", ) diff --git a/tests/segm/test_sam.py b/tests/segm/test_sam.py index 40eb1507d..886693dd0 100644 --- a/tests/segm/test_sam.py +++ b/tests/segm/test_sam.py @@ -67,6 +67,8 @@ def test_automatic_segmentation_sampled_frames(self, test_frames, posData): validate_labels(labels, frame.shape) print_segmentation_results(labels, frame, frame_i) save_segmentation_overlay( - labels, frame, frame_i, + labels, + frame, + frame_i, plots_dir / f"test_sam_segmentation_frame_{frame_i:04d}.png", ) diff --git a/tests/segm/test_sam2.py b/tests/segm/test_sam2.py index 78ecfc3e2..25cf3a0d3 100644 --- a/tests/segm/test_sam2.py +++ b/tests/segm/test_sam2.py @@ -67,6 +67,8 @@ def test_automatic_segmentation_sampled_frames(self, test_frames, posData): validate_labels(labels, frame.shape) print_segmentation_results(labels, frame, frame_i) save_segmentation_overlay( - labels, frame, frame_i, + labels, + frame, + frame_i, plots_dir / f"test_sam2_segmentation_frame_{frame_i:04d}.png", ) diff --git a/tests/test_import_cellacdc.py b/tests/test_import_cellacdc.py index dc77a8623..5a87736de 100755 --- a/tests/test_import_cellacdc.py +++ b/tests/test_import_cellacdc.py @@ -7,6 +7,7 @@ from cellacdc import segm from cellacdc import dataPrep + def test_placeholder(): # Add test test_placeholder because we are only testing import pass diff --git a/tests/utils/segmentation.py b/tests/utils/segmentation.py index 0bdee1ca4..e34735a5a 100644 --- a/tests/utils/segmentation.py +++ b/tests/utils/segmentation.py @@ -95,18 +95,14 @@ def validate_labels(labels: np.ndarray, expected_shape: tuple): If validation fails. """ assert labels is not None, "Segmentation returned None" - assert isinstance(labels, np.ndarray), ( - f"Expected numpy array, got {type(labels)}" - ) + assert isinstance(labels, np.ndarray), f"Expected numpy array, got {type(labels)}" assert labels.shape == expected_shape, ( f"Shape mismatch: {labels.shape} != {expected_shape}" ) assert np.issubdtype(labels.dtype, np.integer), ( f"Expected integer dtype, got {labels.dtype}" ) - assert labels.min() >= 0, ( - f"Labels should be non-negative, got min={labels.min()}" - ) + assert labels.min() >= 0, f"Labels should be non-negative, got min={labels.min()}" def print_segmentation_results(labels: np.ndarray, frame: np.ndarray, frame_i: int): @@ -178,20 +174,27 @@ def save_segmentation_overlay( closest_idx = np.argmin(distances) y, x = coords[closest_idx] ax.text( - x, y, str(region.label), - color="white", fontsize=8, fontweight="bold", - ha="center", va="center", - path_effects=[ - patheffects.withStroke(linewidth=2, foreground="black") - ], + x, + y, + str(region.label), + color="white", + fontsize=8, + fontweight="bold", + ha="center", + va="center", + path_effects=[patheffects.withStroke(linewidth=2, foreground="black")], ) # Plot prompt points if provided if prompt_points: for label_id, y, x in prompt_points: ax.plot( - x, y, 'x', - color='red', markersize=8, markeredgewidth=2, + x, + y, + "x", + color="red", + markersize=8, + markeredgewidth=2, ) ax.set_title(f"Frame {frame_i} ({num_objects} objects)") @@ -235,6 +238,7 @@ def ensure_sam(): sys.path.insert(0, str(candidate)) import pytest + pytest.importorskip("segment_anything") @@ -253,18 +257,21 @@ def ensure_sam2(): sys.path.insert(0, str(candidate)) import pytest + pytest.importorskip("sam2") def ensure_cellsam(): """Ensure cellSAM is importable.""" import pytest + pytest.importorskip("cellSAM") def get_test_posdata(): """Get posData for the standard test dataset.""" from cellacdc import data + return data.MIA_KC_htb1_mCitrine().posData() @@ -277,6 +284,7 @@ def get_test_dataset(): Dataset object with access to images, segmentation, and metadata. """ from cellacdc import data + return data.MIA_KC_htb1_mCitrine() From a9e52f501089870119a84238241e82a7e0002a52 Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 12:17:11 +0200 Subject: [PATCH 13/21] Add composable GUI variants for viewer and segmentation workflows. Introduce basic, visualization, and segmentation entry points with curated mixin bundles, optional action wiring for slim windows, and mixin import fixes so each variant starts cleanly. Co-authored-by: Cursor --- cellacdc/gui_basic.py | 100 +++++ cellacdc/gui_bundles.py | 48 +++ cellacdc/gui_runtime.py | 46 +++ cellacdc/gui_segmentation.py | 248 ++++++++++++ cellacdc/gui_visualization.py | 235 +++++++++++ cellacdc/mixins/actions.py | 611 +++++++++++++++++++---------- cellacdc/mixins/app_shell.py | 3 +- cellacdc/mixins/image_controls.py | 5 +- cellacdc/mixins/label_roi.py | 2 +- cellacdc/mixins/layout_controls.py | 139 +++---- cellacdc/mixins/main_menu.py | 6 +- cellacdc/mixins/main_toolbar.py | 12 +- cellacdc/mixins/session.py | 1 + cellacdc/mixins/tool_activation.py | 3 +- pyproject.toml | 3 + 15 files changed, 1163 insertions(+), 299 deletions(-) create mode 100644 cellacdc/gui_basic.py create mode 100644 cellacdc/gui_bundles.py create mode 100644 cellacdc/gui_runtime.py create mode 100644 cellacdc/gui_segmentation.py create mode 100644 cellacdc/gui_visualization.py diff --git a/cellacdc/gui_basic.py b/cellacdc/gui_basic.py new file mode 100644 index 000000000..6fc55926f --- /dev/null +++ b/cellacdc/gui_basic.py @@ -0,0 +1,100 @@ +"""Minimal composable GUI — foundation for feature-specific variants.""" + +from __future__ import annotations + +import os +import sys + +from qtpy.QtCore import Qt +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import QLabel, QMainWindow, QVBoxLayout, QWidget + +from . import myutils +from .gui_bundles import BASIC_GUI_ROOTS +from .gui_runtime import bootstrap_qt, run_event_loop +from .mixins import AppShell +from .myutils import setupLogger + + +class BasicGuiWin(QMainWindow, AppShell): + """Small GUI built from the basic mixin bundle.""" + + def __init__(self, app, parent=None, version=None): + super().__init__(parent) + self.app = app + self._version = version + self._appName = "Cell-ACDC Basic" + self.mainWin = None + self.launcherSlot = None + self.buttonToRestore = None + self.closeGUI = False + self._acdc_version = myutils.read_version() + self.newWindows = [] + + from .config import parser_args + + self.debug = parser_args["debug"] + + def run(self, module="acdc_gui_basic", logs_path=None): + QMainWindow.setWindowIcon(self, QIcon(":icon.ico")) + QMainWindow.setWindowTitle(self, "Cell-ACDC Basic") + + logger, logs_path, log_path, log_filename = setupLogger( + module=module, logs_path=logs_path, caller="Cell-ACDC" + ) + self.module = module + self.logger = logger + self.log_path = log_path + self.log_filename = log_filename + self.logs_path = logs_path + self.logger.info("Initializing basic GUI") + + self.loadLastSessionSettings() + self.is_error_state = False + self.pos_i = 0 + self.isDataLoaded = False + + self._build_minimal_chrome() + self.show() + self.logger.info("Basic GUI ready") + + def _build_minimal_chrome(self): + from qtpy.QtGui import QAction + + file_menu = self.menuBar().addMenu("&File") + exit_action = QAction("E&xit", self) + exit_action.setShortcut("Ctrl+Q") + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + help_menu = self.menuBar().addMenu("&Help") + about_action = QAction("&About", self) + about_action.triggered.connect(self.showAbout) + help_menu.addAction(about_action) + + central = QWidget() + layout = QVBoxLayout(central) + layout.addWidget( + QLabel( + "Basic GUI\n\n" + f"Mixin bundle: {', '.join(BASIC_GUI_ROOTS)}\n" + "Next: add segmentation and data-loading mixins here." + ), + alignment=Qt.AlignCenter, + ) + self.setCentralWidget(central) + + status = self.statusBar() + status.showMessage("Ready") + + +def main(): + app, _splash = bootstrap_qt() + version = myutils.read_version() + win = BasicGuiWin(app, version=version) + win.run() + run_event_loop(app) + + +if __name__ == "__main__": + main() diff --git a/cellacdc/gui_bundles.py b/cellacdc/gui_bundles.py new file mode 100644 index 000000000..737e81e6a --- /dev/null +++ b/cellacdc/gui_bundles.py @@ -0,0 +1,48 @@ +"""Mixin bundles for composable GUI variants. + +Each bundle lists the mixins declared directly on the window class. Upstream +parents are inherited automatically through the mixin dependency graph. +""" + +from __future__ import annotations + +# Minimal shell: logging, session settings, window chrome helpers. +BASIC_GUI_ROOTS: tuple[str, ...] = ("AppShell",) + +# Load data and visualize images / labels (no annotation canvas stack). +VISUALIZATION_GUI_ROOTS: tuple[str, ...] = ( + "DataLoading", + "MainMenu", + "MainToolbar", + "Saving", + "Measurements", + "ObjectSearch", + "Exporting", + "Preprocessing", + "AnnotationDisplay", + "QuickSettings", + "UndoRedo", + "CombineGui", +) + +# Segmentation and annotation (no lineage tree, custom annotations, or measurements). +SEGMENTATION_GUI_ROOTS: tuple[str, ...] = ( + "WhitelistGui", + "DataLoading", + "CanvasRightImage", + "CanvasHover", + "MagicPrompts", + "ObjectSearch", + "SegForLostIds", + "Exporting", + "CombineWorker", + "CurvatureTools", + "DrawClearRegion", + "LabelTransformTools", + "DeletedRois", + "Saving", + "MainToolbar", + "QuickSettings", + "MainMenu", + "AnnotationDisplay", +) diff --git a/cellacdc/gui_runtime.py b/cellacdc/gui_runtime.py new file mode 100644 index 000000000..e5388eb2d --- /dev/null +++ b/cellacdc/gui_runtime.py @@ -0,0 +1,46 @@ +"""Shared Qt bootstrap for composable GUI variants.""" + +from __future__ import annotations + +import os +import sys + + +def bootstrap_qt(*, splashscreen: bool = False): + from cellacdc._run import _setup_app, _setup_gui_libraries, _setup_numpy + + requires_exit = _setup_gui_libraries(exit_at_end=False) + _setup_numpy() + if requires_exit: + from cellacdc._run import _exit_on_setup + + _exit_on_setup() + + from qtpy import QtCore, QtWidgets + + try: + QtWidgets.QApplication.setAttribute( + QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + except Exception: + pass + + import pyqtgraph as pg + + pg.setConfigOption("imageAxisOrder", "row-major") + + if os.name == "nt": + try: + import ctypes + + myappid = "schmollerlab.cellacdc.pyqt.v1" + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + except Exception: + pass + + app, splash = _setup_app(splashscreen=splashscreen) + return app, splash + + +def run_event_loop(app): + sys.exit(app.exec_()) diff --git a/cellacdc/gui_segmentation.py b/cellacdc/gui_segmentation.py new file mode 100644 index 000000000..a4fdb1284 --- /dev/null +++ b/cellacdc/gui_segmentation.py @@ -0,0 +1,248 @@ +"""Segmentation-focused GUI — annotate and segment without full tracking/CCA stack.""" + +from __future__ import annotations + +import sys + +import numpy as np +from qtpy.QtCore import QTimer, Qt, Signal +from qtpy.QtWidgets import QButtonGroup, QMainWindow, QWidget + +from . import autopilot, myutils +from .gui_bundles import SEGMENTATION_GUI_ROOTS +from .gui_runtime import bootstrap_qt, run_event_loop +from .mixins import ( + AnnotationDisplay, + CanvasHover, + CanvasRightImage, + CombineWorker, + CurvatureTools, + DataLoading, + DeletedRois, + DrawClearRegion, + Exporting, + LabelTransformTools, + MagicPrompts, + MainMenu, + MainToolbar, + ObjectSearch, + QuickSettings, + Saving, + SegForLostIds, + WhitelistGui, +) +from .myutils import setupLogger + +np.seterr(invalid="ignore") + + +class SegmentationGuiWin( + QMainWindow, + WhitelistGui, + DataLoading, + CanvasRightImage, + CanvasHover, + MagicPrompts, + ObjectSearch, + SegForLostIds, + Exporting, + CombineWorker, + CurvatureTools, + DrawClearRegion, + LabelTransformTools, + DeletedRois, + Saving, + MainToolbar, + QuickSettings, + MainMenu, + AnnotationDisplay, +): + """Segmentation GUI: load data, draw/edit labels, run segmenters.""" + + sigClosed = Signal(object) + sigExportFrame = Signal() + + def __init__( + self, + app, + parent=None, + buttonToRestore=None, + mainWin=None, + version=None, + launcherSlot=None, + ): + super().__init__(parent) + + self._version = version + + from .trackers.YeaZ import tracking as tracking_yeaz + + self.tracking_yeaz = tracking_yeaz + + from .config import parser_args + + self.debug = parser_args["debug"] + + self.buttonToRestore = buttonToRestore + self.launcherSlot = launcherSlot + self.mainWin = mainWin + self.app = app + self.closeGUI = False + self._acdc_version = myutils.read_version() + self.setAcceptDrops(True) + self._appName = "Cell-ACDC Segmentation" + + self.lineage_tree = None + self.already_synced_lin_tree = set() + self.right_click_ID = None + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + + def run(self, module="acdc_gui_segm", logs_path=None): + from qtpy.QtGui import QIcon + + QMainWindow.setWindowIcon(self, QIcon(":icon.ico")) + QMainWindow.setWindowTitle(self, "Cell-ACDC Segmentation") + + self.is_win = sys.platform.startswith("win") + if self.is_win: + self.openFolderText = "Show in Explorer..." + else: + self.openFolderText = "Reveal in Finder..." + + self.is_error_state = False + logger, logs_path, log_path, log_filename = setupLogger( + module=module, logs_path=logs_path, caller="Cell-ACDC" + ) + if self._version is not None: + logger.info(f"Initializing segmentation GUI v{self._version}") + else: + logger.info("Initializing segmentation GUI...") + + self.module = module + self.logger = logger + self.log_path = log_path + self.log_filename = log_filename + self.logs_path = logs_path + + self.initProfileModels() + self.loadLastSessionSettings() + + self.newWindows = [] + self.progressWin = None + self.slideshowWin = None + self.ccaTableWin = None + self.exportToImageWindow = None + self.customAnnotButton = None + self.ccaCheckerRunning = False + self.isDataLoaded = False + self.highlightedID = 0 + self.hoverLabelID = 0 + self.expandingID = -1 + self.count = 0 + self.isDilation = True + self.flag = True + self.currentPropsID = 0 + self.isSegm3D = False + self.newSegmEndName = "" + self.closeGUI = False + self.warnKeyPressedMsg = None + self.img1ChannelGradients = {} + self.AutoPilotProfile = autopilot.AutoPilotProfile() + self.storeStateWorker = None + self.AutoPilot = None + self.widgetsWithShortcut = {} + self.invertBwAlreadyCalledOnce = False + self.zoomOutKeyValue = Qt.Key_H + self.preprocWorker = None + self.preprocessDialog = None + self.viewOriginalLabels = True + self.keepDisabled = False + self.whitelistAddNewIDsFrame = None + self.whitelistOriginalIDs = None + self.whyNavigateDisabled = set() + self.autoSaveTimer = QTimer() + self.dirtyPointsLayerTableEndNames = set() + + self._setup_vars_combine() + if "autoSaveIntevalValue" not in self.df_settings.index: + autoSaveIntevalValue = 2 + autoSaveIntervalUnit = "minutes" + else: + autoSaveIntevalValue = float( + self.df_settings.at["autoSaveIntevalValue", "value"] + ) + autoSaveIntervalUnit = str( + self.df_settings.at["autoSaveIntervalUnit", "value"] + ) + + self.autoSaveIntevalValueUnit = (autoSaveIntevalValue, autoSaveIntervalUnit) + + self.checkableButtons = [] + self.LeftClickButtons = [] + self.toolsActiveInProj3Dsegm = set() + self.customAnnotDict = {} + self.functionsNotTested3D = [] + self.isSnapshot = False + self.debugFlag = False + self.pos_i = 0 + self.save_until_frame_i = 0 + self.countKeyPress = 0 + self.countRightClicks = 0 + self.xHoverImg, self.yHoverImg = None, None + self.lastFrameRanOnFirstVisitTools = 0 + + self.checkableQButtonsGroup = QButtonGroup(self) + self.checkableQButtonsGroup.setExclusive(False) + self.lazyLoader = None + + self.gui_createCursors() + self.gui_createActions() + self.gui_createMenuBar() + self.gui_createToolBars() + self.gui_createControlsToolbar() + self.gui_createShowPropsButton() + self.gui_createRegionPropsDockWidget() + self.gui_createQuickSettingsWidgets() + self.setTooltips() + self.gui_populateToolSettingsMenu() + + self.autoSaveGarbageWorkers = [] + self.autoSaveActiveWorkers = [] + + self.gui_connectActions() + self.gui_createStatusBar() + + self.gui_createGraphicsPlots() + self.gui_addGraphicsItems() + self.gui_createImg1Widgets() + self.gui_createLabWidgets() + self.gui_createBottomWidgetsToBottomLayout() + + mainContainer = QWidget() + self.setCentralWidget(mainContainer) + mainLayout = self.gui_createMainLayout() + self.mainLayout = mainLayout + mainContainer.setLayout(mainLayout) + + self.isEditActionsConnected = False + self.readRecentPaths() + self.initShortcuts() + self.show() + QTimer.singleShot(100, self.resizeRangeWelcomeText) + + self.logger.info( + f"Segmentation GUI ready (bundle: {', '.join(SEGMENTATION_GUI_ROOTS)})." + ) + + +def main(): + app, _splash = bootstrap_qt() + version = myutils.read_version() + win = SegmentationGuiWin(app, version=version) + win.run() + run_event_loop(app) + + +if __name__ == "__main__": + main() diff --git a/cellacdc/gui_visualization.py b/cellacdc/gui_visualization.py new file mode 100644 index 000000000..862f67a78 --- /dev/null +++ b/cellacdc/gui_visualization.py @@ -0,0 +1,235 @@ +"""Data visualization GUI — load datasets and inspect images/labels.""" + +from __future__ import annotations + +import os +import sys + +import numpy as np +from qtpy.QtCore import QTimer, Qt, Signal +from qtpy.QtWidgets import QButtonGroup, QMainWindow, QWidget + +from . import autopilot, myutils, settings_folderpath +from .gui_bundles import VISUALIZATION_GUI_ROOTS +from .gui_runtime import bootstrap_qt, run_event_loop +from .mixins import ( + AnnotationDisplay, + CombineGui, + DataLoading, + Exporting, + MainMenu, + MainToolbar, + Measurements, + ObjectSearch, + Preprocessing, + QuickSettings, + Saving, + UndoRedo, +) +from .myutils import setupLogger + +np.seterr(invalid="ignore") + + +class VisualizationGuiWin( + QMainWindow, + DataLoading, + MainMenu, + MainToolbar, + Saving, + Measurements, + ObjectSearch, + Exporting, + Preprocessing, + AnnotationDisplay, + QuickSettings, + UndoRedo, + CombineGui, +): + """Viewer-focused GUI: open data and visualize frames.""" + + sigClosed = Signal(object) + sigExportFrame = Signal() + + def __init__( + self, + app, + parent=None, + buttonToRestore=None, + mainWin=None, + version=None, + launcherSlot=None, + ): + super().__init__(parent) + + self._version = version + + from .trackers.YeaZ import tracking as tracking_yeaz + + self.tracking_yeaz = tracking_yeaz + + from .config import parser_args + + self.debug = parser_args["debug"] + + self.buttonToRestore = buttonToRestore + self.launcherSlot = launcherSlot + self.mainWin = mainWin + self.app = app + self.closeGUI = False + self._acdc_version = myutils.read_version() + self.setAcceptDrops(True) + self._appName = "Cell-ACDC Viewer" + + self.lineage_tree = None + self.already_synced_lin_tree = set() + self.right_click_ID = None + self.original_df_lin_tree = None + self.original_df_lin_tree_i = None + + def run(self, module="acdc_gui_viz", logs_path=None): + from qtpy.QtGui import QIcon + + QMainWindow.setWindowIcon(self, QIcon(":icon.ico")) + QMainWindow.setWindowTitle(self, "Cell-ACDC Viewer") + + self.is_win = sys.platform.startswith("win") + if self.is_win: + self.openFolderText = "Show in Explorer..." + else: + self.openFolderText = "Reveal in Finder..." + + self.is_error_state = False + logger, logs_path, log_path, log_filename = setupLogger( + module=module, logs_path=logs_path, caller="Cell-ACDC" + ) + if self._version is not None: + logger.info(f"Initializing viewer GUI v{self._version}") + else: + logger.info("Initializing viewer GUI...") + + self.module = module + self.logger = logger + self.log_path = log_path + self.log_filename = log_filename + self.logs_path = logs_path + + self.initProfileModels() + self.loadLastSessionSettings() + + self.newWindows = [] + self.progressWin = None + self.slideshowWin = None + self.ccaTableWin = None + self.exportToImageWindow = None + self.customAnnotButton = None + self.ccaCheckerRunning = False + self.isDataLoaded = False + self.highlightedID = 0 + self.hoverLabelID = 0 + self.expandingID = -1 + self.count = 0 + self.isDilation = True + self.flag = True + self.currentPropsID = 0 + self.isSegm3D = False + self.newSegmEndName = "" + self.closeGUI = False + self.warnKeyPressedMsg = None + self.img1ChannelGradients = {} + self.AutoPilotProfile = autopilot.AutoPilotProfile() + self.storeStateWorker = None + self.AutoPilot = None + self.widgetsWithShortcut = {} + self.invertBwAlreadyCalledOnce = False + self.zoomOutKeyValue = Qt.Key_H + self.preprocWorker = None + self.preprocessDialog = None + self.viewOriginalLabels = True + self.keepDisabled = False + self.whitelistAddNewIDsFrame = None + self.whitelistOriginalIDs = None + self.whyNavigateDisabled = set() + self.autoSaveTimer = QTimer() + self.dirtyPointsLayerTableEndNames = set() + + if "autoSaveIntevalValue" not in self.df_settings.index: + autoSaveIntevalValue = 2 + autoSaveIntervalUnit = "minutes" + else: + autoSaveIntevalValue = float( + self.df_settings.at["autoSaveIntevalValue", "value"] + ) + autoSaveIntervalUnit = str( + self.df_settings.at["autoSaveIntervalUnit", "value"] + ) + + self.autoSaveIntevalValueUnit = (autoSaveIntevalValue, autoSaveIntervalUnit) + + self.checkableButtons = [] + self.LeftClickButtons = [] + self.toolsActiveInProj3Dsegm = set() + self.customAnnotDict = {} + self.functionsNotTested3D = [] + self.isSnapshot = False + self.debugFlag = False + self.pos_i = 0 + self.save_until_frame_i = 0 + self.countKeyPress = 0 + self.countRightClicks = 0 + self.xHoverImg, self.yHoverImg = None, None + self.lastFrameRanOnFirstVisitTools = 0 + + self.checkableQButtonsGroup = QButtonGroup(self) + self.checkableQButtonsGroup.setExclusive(False) + self.lazyLoader = None + + self.gui_createCursors() + self.gui_createActions() + self.gui_createMenuBar() + self.gui_createToolBars() + self.gui_createControlsToolbar() + self.gui_createQuickSettingsWidgets() + self.gui_createShowPropsButton() + self.gui_createRegionPropsDockWidget() + self.setTooltips() + + self.autoSaveGarbageWorkers = [] + self.autoSaveActiveWorkers = [] + + self.gui_connectActions() + self.gui_createStatusBar() + + self.gui_createGraphicsPlots() + self.gui_addGraphicsItems() + self.gui_createImg1Widgets() + self.gui_createLabWidgets() + self.gui_createBottomWidgetsToBottomLayout() + + mainContainer = QWidget() + self.setCentralWidget(mainContainer) + mainLayout = self.gui_createMainLayout() + self.mainLayout = mainLayout + mainContainer.setLayout(mainLayout) + + self.isEditActionsConnected = False + self.readRecentPaths() + self.initShortcuts() + self.show() + QTimer.singleShot(100, self.resizeRangeWelcomeText) + + self.logger.info( + f"Viewer GUI ready (bundle: {', '.join(VISUALIZATION_GUI_ROOTS)})." + ) + + +def main(): + app, _splash = bootstrap_qt() + version = myutils.read_version() + win = VisualizationGuiWin(app, version=version) + win.run() + run_event_loop(app) + + +if __name__ == "__main__": + main() diff --git a/cellacdc/mixins/actions.py b/cellacdc/mixins/actions.py index e0716fa29..0aa7c8f2d 100644 --- a/cellacdc/mixins/actions.py +++ b/cellacdc/mixins/actions.py @@ -9,7 +9,7 @@ from qtpy.QtGui import QIcon, QKeySequence from qtpy.QtWidgets import QAction, QActionGroup, QToolButton -from cellacdc import apps, is_mac, settings_folderpath, widgets +from cellacdc import apps, is_mac, myutils, settings_folderpath, widgets shortcut_filepath = os.path.join(settings_folderpath, "shortcuts.ini") @@ -19,6 +19,11 @@ class Actions(ImageDisplay): """Extracted from guiWin.""" + def _connect_method_if_present(self, signal, method_name): + method = getattr(self, method_name, None) + if method is not None: + signal.connect(method) + def editShortcuts_cb(self): if is_mac: delObjKeySequenceText = "Ctrl" @@ -60,7 +65,9 @@ def editShortcuts_cb(self): def gui_connectActions(self): # Connect File actions if self.debug: - self.createEmptyDataAction.triggered.connect(self._createEmptyData) + self._connect_method_if_present( + self.createEmptyDataAction.triggered, "_createEmptyData" + ) self.segmNdimIndicator.clicked.connect(self.segmNdimIndicatorClicked) self.newWindowAction.triggered.connect(self.openNewWindow) self.newAction.triggered.connect(self.newFile) @@ -73,16 +80,21 @@ def gui_connectActions(self): self.exportToImageAction.triggered.connect(self.exportToImageTriggered) self.quickSaveAction.triggered.connect(self.quickSave) self.viewPreprocDataToggle.toggled.connect(self.viewPreprocDataToggled) - self.viewCombineChannelDataToggle.toggled.connect( - self.viewCombineChannelDataToggled - ) + if hasattr(self, "viewCombineChannelDataToggle"): + self._connect_method_if_present( + self.viewCombineChannelDataToggle.toggled, + "viewCombineChannelDataToggled", + ) self.autoSaveToggle.toggled.connect(self.autoSaveToggled) self.autoSaveAnnotToggle.toggled.connect(self.autoSaveAnnotToggled) self.autoSaveIntervalDialog.sigValueChanged.connect( self.autoSaveIntervalValueChanged ) self.autoSaveIntervalEditButton.clicked.connect(self.autoSaveIntervalEdit) - self.ccaIntegrCheckerToggle.toggled.connect(self.ccaIntegrCheckerToggled) + if hasattr(self, "ccaIntegrCheckerToggle"): + self._connect_method_if_present( + self.ccaIntegrCheckerToggle.toggled, "ccaIntegrCheckerToggled" + ) self.annotLostObjsToggle.toggled.connect(self.annotLostObjsToggled) self.highLowResAction.clicked.connect(self.highLowResToggled) self.showInExplorerAction.triggered.connect(self.showInExplorer_cb) @@ -111,25 +123,37 @@ def gui_connectActions(self): # self.openRecentMenu.aboutToShow.connect(self.populateOpenRecent) self.checkableQButtonsGroup.buttonClicked.connect(self.uncheckQButton) - self.showPropsDockButton.sigClicked.connect(self.showPropsDockWidget) + self._connect_method_if_present( + self.showPropsDockButton.sigClicked, "showPropsDockWidget" + ) - self.loadCustomAnnotationsAction.triggered.connect(self.loadCustomAnnotations) - self.addCustomAnnotationAction.triggered.connect(self.addCustomAnnotation) - self.viewAllCustomAnnotAction.toggled.connect(self.viewAllCustomAnnot) - self.addCustomModelVideoAction.triggered.connect( - self.showInstructionsCustomModel + self._connect_method_if_present( + self.loadCustomAnnotationsAction.triggered, "loadCustomAnnotations" ) - self.addCustomModelFrameAction.triggered.connect( - self.showInstructionsCustomModel + self._connect_method_if_present( + self.addCustomAnnotationAction.triggered, "addCustomAnnotation" ) - self.addCustomModelFrameAction.callback = self.segmFrameCallback - self.addCustomModelVideoAction.callback = self.segmVideoCallback - - self.addCustomPromptModelAction.triggered.connect( - self.showInstructionsCustomPromptModel + self._connect_method_if_present( + self.viewAllCustomAnnotAction.toggled, "viewAllCustomAnnot" ) - self.segmWithPromptableModelAction.triggered.connect( - self.segmWithPromptableModelActionTriggered + self._connect_method_if_present( + self.addCustomModelVideoAction.triggered, "showInstructionsCustomModel" + ) + self._connect_method_if_present( + self.addCustomModelFrameAction.triggered, "showInstructionsCustomModel" + ) + if hasattr(self, "segmFrameCallback"): + self.addCustomModelFrameAction.callback = self.segmFrameCallback + if hasattr(self, "segmVideoCallback"): + self.addCustomModelVideoAction.callback = self.segmVideoCallback + + self._connect_method_if_present( + self.addCustomPromptModelAction.triggered, + "showInstructionsCustomPromptModel", + ) + self._connect_method_if_present( + self.segmWithPromptableModelAction.triggered, + "segmWithPromptableModelActionTriggered", ) def gui_connectEditActions(self): @@ -138,245 +162,416 @@ def gui_connectEditActions(self): self.loadFluoAction.setEnabled(True) self.isEditActionsConnected = True - self.preprocessImageAction.triggered.connect(self.preprocessAction.trigger) - self.combineChannelsAction.triggered.connect( - self.combineChannelsActionTriggered - ) - - self.overlayButton.toggled.connect(self.overlay_cb) - self.countObjsButton.toggled.connect(self.countObjectsCb) - self.togglePointsLayerAction.toggled.connect(self.pointsLayerToggled) - self.overlayLabelsButton.toggled.connect(self.overlayLabels_cb) - self.overlayButton.sigRightClick.connect(self.showOverlayContextMenu) - self.labelRoiButton.sigRightClick.connect(self.showLabelRoiContextMenu) - self.overlayLabelsButton.sigRightClick.connect( - self.showOverlayLabelsContextMenu - ) - self.rulerButton.toggled.connect(self.ruler_cb) - self.loadFluoAction.triggered.connect(self.loadFluo_cb) - self.loadPosAction.triggered.connect(self.loadPosTriggered) - # self.reloadAction.triggered.connect(self.reload_cb) - self.findIdAction.triggered.connect(self.findID) - self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) - self.autoPilotButton.toggled.connect(self.autoPilotToggled) - self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID) - self.slideshowButton.toggled.connect(self.launchSlideshow) - - self.copyLostObjButton.toggled.connect(self.copyLostObjContour_cb) - self.manualAnnotPastButton.toggled.connect(self.manualAnnotPast_cb) - - self.segmSingleFrameMenu.triggered.connect(self.segmFrameCallback) - self.segmVideoMenu.triggered.connect(self.segmVideoCallback) - - self.postProcessSegmAction.toggled.connect(self.postProcessSegm) - self.autoSegmAction.toggled.connect(self.autoSegm_cb) - self.realTimeTrackingToggle.clicked.connect(self.realTimeTrackingClicked) - self.repeatTrackingAction.triggered.connect(self.repeatTracking) - self.manualTrackingButton.toggled.connect(self.manualTracking_cb) - self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) - self.repeatTrackingMenuAction.triggered.connect(self.repeatTracking) - self.repeatTrackingVideoAction.triggered.connect(self.repeatTrackingVideo) - for rtTrackerAction in self.trackingAlgosGroup.actions(): - rtTrackerAction.toggled.connect(self.rtTrackerActionToggled) - self.editRtTrackerParamsAction.triggered.connect(self.initRealTimeTracker) - self.delObjsOutSegmMaskAction.triggered.connect( - self.delObjsOutSegmMaskActionTriggered + if hasattr(self, "preprocessImageAction") and hasattr(self, "preprocessAction"): + self.preprocessImageAction.triggered.connect(self.preprocessAction.trigger) + self._connect_method_if_present( + self.combineChannelsAction.triggered, "combineChannelsActionTriggered" ) - self.mergeIDsButton.toggled.connect(self.mergeObjs_cb) - self.brushButton.toggled.connect(self.Brush_cb) - self.eraserButton.toggled.connect(self.Eraser_cb) - self.curvToolButton.toggled.connect(self.curvTool_cb) - self.wandToolButton.toggled.connect(self.wand_cb) - self.labelRoiButton.toggled.connect(self.labelRoi_cb) - self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) - self.drawClearRegionButton.toggled.connect(self.drawClearRegion_cb) - self.reInitCcaAction.triggered.connect(self.reInitCca) - self.moveLabelToolButton.toggled.connect(self.moveLabelButtonToggled) - self.editCcaToolAction.triggered.connect( - self.manualEditCcaToolbarActionTriggered - ) - self.assignBudMothAutoAction.triggered.connect(self.autoAssignBud_YeastMate) - self.keepIDsButton.toggled.connect(self.keepIDs_cb) - self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) + self._connect_method_if_present(self.overlayButton.toggled, "overlay_cb") + self._connect_method_if_present(self.countObjsButton.toggled, "countObjectsCb") + self._connect_method_if_present( + self.togglePointsLayerAction.toggled, "pointsLayerToggled" + ) + self._connect_method_if_present( + self.overlayLabelsButton.toggled, "overlayLabels_cb" + ) + self._connect_method_if_present( + self.overlayButton.sigRightClick, "showOverlayContextMenu" + ) + self._connect_method_if_present( + self.labelRoiButton.sigRightClick, "showLabelRoiContextMenu" + ) + self._connect_method_if_present( + self.overlayLabelsButton.sigRightClick, "showOverlayLabelsContextMenu" + ) + self._connect_method_if_present(self.rulerButton.toggled, "ruler_cb") + self._connect_method_if_present(self.loadFluoAction.triggered, "loadFluo_cb") + self._connect_method_if_present(self.loadPosAction.triggered, "loadPosTriggered") + self._connect_method_if_present(self.findIdAction.triggered, "findID") + self._connect_method_if_present( + self.zoomRectButton.toggled, "zoomRectActionToggled" + ) + self._connect_method_if_present( + self.autoPilotButton.toggled, "autoPilotToggled" + ) + self._connect_method_if_present( + self.skipToNewIdAction.triggered, "skipForwardToNewID" + ) + self._connect_method_if_present( + self.slideshowButton.toggled, "launchSlideshow" + ) - self.whitelistIDsToolbar.sigWhitelistChanged.connect(self.whitelistIDsChanged) + self._connect_method_if_present( + self.copyLostObjButton.toggled, "copyLostObjContour_cb" + ) + self._connect_method_if_present( + self.manualAnnotPastButton.toggled, "manualAnnotPast_cb" + ) - self.whitelistIDsToolbar.sigWhitelistAccepted.connect(self.whitelistIDsAccepted) + self._connect_method_if_present( + self.segmSingleFrameMenu.triggered, "segmFrameCallback" + ) + self._connect_method_if_present( + self.segmVideoMenu.triggered, "segmVideoCallback" + ) - self.whitelistIDsToolbar.sigViewOGIDs.connect(self.whitelistViewOGIDs) + self._connect_method_if_present( + self.postProcessSegmAction.toggled, "postProcessSegm" + ) + self._connect_method_if_present(self.autoSegmAction.toggled, "autoSegm_cb") + self._connect_method_if_present( + self.realTimeTrackingToggle.clicked, "realTimeTrackingClicked" + ) + self._connect_method_if_present( + self.repeatTrackingAction.triggered, "repeatTracking" + ) + self._connect_method_if_present( + self.manualTrackingButton.toggled, "manualTracking_cb" + ) + self._connect_method_if_present( + self.manualBackgroundButton.toggled, "manualBackground_cb" + ) + self._connect_method_if_present( + self.repeatTrackingMenuAction.triggered, "repeatTracking" + ) + self._connect_method_if_present( + self.repeatTrackingVideoAction.triggered, "repeatTrackingVideo" + ) + for rtTrackerAction in self.trackingAlgosGroup.actions(): + self._connect_method_if_present( + rtTrackerAction.toggled, "rtTrackerActionToggled" + ) + self._connect_method_if_present( + self.editRtTrackerParamsAction.triggered, "initRealTimeTracker" + ) + self._connect_method_if_present( + self.delObjsOutSegmMaskAction.triggered, "delObjsOutSegmMaskActionTriggered" + ) + self._connect_method_if_present(self.mergeIDsButton.toggled, "mergeObjs_cb") + self._connect_method_if_present(self.brushButton.toggled, "Brush_cb") + self._connect_method_if_present(self.eraserButton.toggled, "Eraser_cb") + self._connect_method_if_present(self.curvToolButton.toggled, "curvTool_cb") + self._connect_method_if_present(self.wandToolButton.toggled, "wand_cb") + self._connect_method_if_present(self.labelRoiButton.toggled, "labelRoi_cb") + self._connect_method_if_present( + self.magicPromptsToolButton.toggled, "magicPrompts_cb" + ) + self._connect_method_if_present( + self.drawClearRegionButton.toggled, "drawClearRegion_cb" + ) + self._connect_method_if_present(self.reInitCcaAction.triggered, "reInitCca") + self._connect_method_if_present( + self.moveLabelToolButton.toggled, "moveLabelButtonToggled" + ) + self._connect_method_if_present( + self.editCcaToolAction.triggered, "manualEditCcaToolbarActionTriggered" + ) + self._connect_method_if_present( + self.assignBudMothAutoAction.triggered, "autoAssignBud_YeastMate" + ) + self._connect_method_if_present(self.keepIDsButton.toggled, "keepIDs_cb") - self.whitelistIDsToolbar.sigAddNewIDs.connect(self.whitelistAddNewIDsToggled) + self._connect_method_if_present( + self.whitelistIDsButton.toggled, "whitelistIDs_cb" + ) - self.whitelistIDsToolbar.sigLoadOGLabs.connect(self.whitelistLoadOGLabs_cb) + if hasattr(self, "whitelistIDsToolbar"): + self._connect_method_if_present( + self.whitelistIDsToolbar.sigWhitelistChanged, "whitelistIDsChanged" + ) + self._connect_method_if_present( + self.whitelistIDsToolbar.sigWhitelistAccepted, "whitelistIDsAccepted" + ) + self._connect_method_if_present( + self.whitelistIDsToolbar.sigViewOGIDs, "whitelistViewOGIDs" + ) + self._connect_method_if_present( + self.whitelistIDsToolbar.sigAddNewIDs, "whitelistAddNewIDsToggled" + ) + self._connect_method_if_present( + self.whitelistIDsToolbar.sigLoadOGLabs, "whitelistLoadOGLabs_cb" + ) + self._connect_method_if_present( + self.whitelistIDsToolbar.sigTrackOGagainstPreviousFrame, + "whitelistTrackOGagainstPreviousFrame_cb", + ) - self.whitelistIDsToolbar.sigTrackOGagainstPreviousFrame.connect( - self.whitelistTrackOGagainstPreviousFrame_cb + self._connect_method_if_present( + self.expandLabelToolButton.toggled, "expandLabelCallback" ) - self.expandLabelToolButton.toggled.connect(self.expandLabelCallback) - - self.reinitLastSegmFrameAction.triggered.connect(self.reInitLastSegmFrame) + self._connect_method_if_present( + self.reinitLastSegmFrameAction.triggered, "reInitLastSegmFrame" + ) - self.defaultRescaleIntensActionGroup.triggered.connect( - self.defaultRescaleIntensLutActionToggled + self._connect_method_if_present( + self.defaultRescaleIntensActionGroup.triggered, + "defaultRescaleIntensLutActionToggled", ) - # self.repeatAutoCcaAction.triggered.connect(self.repeatAutoCca) - self.manuallyEditCcaAction.triggered.connect(self.manualEditCca) - self.addScaleBarAction.toggled.connect(self.addScaleBar) - self.addTimestampAction.toggled.connect(self.addTimestamp) - self.saveLabColormapAction.triggered.connect(self.saveLabelsColormap) + self._connect_method_if_present( + self.manuallyEditCcaAction.triggered, "manualEditCca" + ) + self._connect_method_if_present(self.addScaleBarAction.toggled, "addScaleBar") + self._connect_method_if_present( + self.addTimestampAction.toggled, "addTimestamp" + ) + self._connect_method_if_present( + self.saveLabColormapAction.triggered, "saveLabelsColormap" + ) - self.enableSmartTrackAction.toggled.connect(self.enableSmartTrack) - # Brush/Eraser size action - self.brushSizeSpinbox.valueChanged.connect(self.brushSize_cb) - self.autoIDcheckbox.toggled.connect(self.autoIDtoggled) - # Mode - self.modeActionGroup.triggered.connect(self.changeModeFromMenu) - self.modeComboBox.sigTextChanged.connect(self.changeMode) - self.modeComboBox.activated.connect(self.clearComboBoxFocus) - self.equalizeHistPushButton.toggled.connect(self.equalizeHist) + self._connect_method_if_present( + self.enableSmartTrackAction.toggled, "enableSmartTrack" + ) + self._connect_method_if_present( + self.brushSizeSpinbox.valueChanged, "brushSize_cb" + ) + self._connect_method_if_present(self.autoIDcheckbox.toggled, "autoIDtoggled") + self._connect_method_if_present( + self.modeActionGroup.triggered, "changeModeFromMenu" + ) + self._connect_method_if_present( + self.modeComboBox.sigTextChanged, "changeMode" + ) + self._connect_method_if_present( + self.modeComboBox.activated, "clearComboBoxFocus" + ) + self._connect_method_if_present( + self.equalizeHistPushButton.toggled, "equalizeHist" + ) - self.editOverlayColorAction.triggered.connect(self.toggleOverlayColorButton) - self.editTextIDsColorAction.triggered.connect(self.toggleTextIDsColorButton) - self.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) - self.overlayColorButton.sigColorChanged.connect(self.saveOverlayColor) - self.textIDsColorButton.sigColorChanging.connect(self.updateTextAnnotColor) - self.textIDsColorButton.sigColorChanged.connect(self.saveTextIDsColors) + self._connect_method_if_present( + self.editOverlayColorAction.triggered, "toggleOverlayColorButton" + ) + self._connect_method_if_present( + self.editTextIDsColorAction.triggered, "toggleTextIDsColorButton" + ) + self._connect_method_if_present( + self.overlayColorButton.sigColorChanging, "changeOverlayColor" + ) + self._connect_method_if_present( + self.overlayColorButton.sigColorChanged, "saveOverlayColor" + ) + self._connect_method_if_present( + self.textIDsColorButton.sigColorChanging, "updateTextAnnotColor" + ) + self._connect_method_if_present( + self.textIDsColorButton.sigColorChanged, "saveTextIDsColors" + ) - self.setMeasurementsAction.triggered.connect(self.showSetMeasurements) - self.addCustomMetricAction.triggered.connect(self.addCustomMetric) - self.addCombineMetricAction.triggered.connect(self.addCombineMetric) + self._connect_method_if_present( + self.setMeasurementsAction.triggered, "showSetMeasurements" + ) + self._connect_method_if_present( + self.addCustomMetricAction.triggered, "addCustomMetric" + ) + self._connect_method_if_present( + self.addCombineMetricAction.triggered, "addCombineMetric" + ) - self.labelsGrad.colorButton.sigColorChanging.connect(self.updateBkgrColor) - self.labelsGrad.colorButton.sigColorChanged.connect(self.saveBkgrColor) - self.labelsGrad.sigGradientChangeFinished.connect(self.updateLabelsCmap) - self.labelsGrad.sigGradientChanged.connect(self.ticksCmapMoved) - self.labelsGrad.textColorButton.sigColorChanging.connect( - self.updateTextLabelsColor + self._connect_method_if_present( + self.labelsGrad.colorButton.sigColorChanging, "updateBkgrColor" ) - self.labelsGrad.textColorButton.sigColorChanged.connect( - self.saveTextLabelsColor + self._connect_method_if_present( + self.labelsGrad.colorButton.sigColorChanged, "saveBkgrColor" + ) + self._connect_method_if_present( + self.labelsGrad.sigGradientChangeFinished, "updateLabelsCmap" + ) + self._connect_method_if_present( + self.labelsGrad.sigGradientChanged, "ticksCmapMoved" + ) + self._connect_method_if_present( + self.labelsGrad.textColorButton.sigColorChanging, "updateTextLabelsColor" + ) + self._connect_method_if_present( + self.labelsGrad.textColorButton.sigColorChanged, "saveTextLabelsColor" ) - # self.addFontSizeActions( - # self.labelsGrad.fontSizeMenu, self.setFontSizeActionChecked - # ) - self.labelsGrad.shuffleCmapAction.triggered.connect(self.shuffle_cmap) - self.labelsGrad.greedyShuffleCmapAction.triggered.connect( - self.greedyShuffleCmap + self._connect_method_if_present( + self.labelsGrad.shuffleCmapAction.triggered, "shuffle_cmap" + ) + self._connect_method_if_present( + self.labelsGrad.greedyShuffleCmapAction.triggered, "greedyShuffleCmap" + ) + self._connect_method_if_present( + self.labelsGrad.permanentGreedyCmapAction.toggled, + "permanentGreedyCmapToggled", + ) + self._connect_method_if_present( + self.shuffleCmapAction.triggered, "shuffle_cmap" + ) + self._connect_method_if_present( + self.greedyShuffleCmapAction.triggered, "greedyShuffleCmap" ) - self.labelsGrad.permanentGreedyCmapAction.toggled.connect( - self.permanentGreedyCmapToggled + self._connect_method_if_present( + self.labelsGrad.invertBwAction.toggled, "setCheckedInvertBW" + ) + self._connect_method_if_present( + self.labelsGrad.sigShowLabelsImgToggled, "showLabelImageItem" + ) + self._connect_method_if_present( + self.labelsGrad.sigShowRightImgToggled, "showRightImageItem" + ) + self._connect_method_if_present( + self.labelsGrad.sigShowNextFrameToggled, "showNextFrameImageItem" ) - self.shuffleCmapAction.triggered.connect(self.shuffle_cmap) - self.greedyShuffleCmapAction.triggered.connect(self.greedyShuffleCmap) - self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) - self.labelsGrad.sigShowLabelsImgToggled.connect(self.showLabelImageItem) - self.labelsGrad.sigShowRightImgToggled.connect(self.showRightImageItem) - self.labelsGrad.sigShowNextFrameToggled.connect(self.showNextFrameImageItem) - self.labelsGrad.defaultSettingsAction.triggered.connect( - self.restoreDefaultSettings + self._connect_method_if_present( + self.labelsGrad.defaultSettingsAction.triggered, "restoreDefaultSettings" ) - # self.addFontSizeActions( - # self.imgGrad.fontSizeMenu, self.setFontSizeActionChecked - # ) - self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + self._connect_method_if_present( + self.imgGrad.invertBwAction.toggled, "setCheckedInvertBW" + ) self.imgGrad.textColorButton.disconnect() - self.imgGrad.textColorButton.clicked.connect( - self.editTextIDsColorAction.trigger + if hasattr(self, "editTextIDsColorAction"): + self.imgGrad.textColorButton.clicked.connect( + self.editTextIDsColorAction.trigger + ) + self._connect_method_if_present( + self.imgGrad.labelsAlphaSlider.valueChanged, "updateLabelsAlpha" ) - self.imgGrad.labelsAlphaSlider.valueChanged.connect(self.updateLabelsAlpha) - self.imgGrad.defaultSettingsAction.triggered.connect( - self.restoreDefaultSettings + self._connect_method_if_present( + self.imgGrad.defaultSettingsAction.triggered, "restoreDefaultSettings" ) - # Drawing mode - self.drawIDsContComboBox.currentIndexChanged.connect( - self.drawIDsContComboBox_cb + self._connect_method_if_present( + self.drawIDsContComboBox.currentIndexChanged, "drawIDsContComboBox_cb" + ) + self._connect_method_if_present( + self.drawIDsContComboBox.activated, "clearComboBoxFocus" ) - self.drawIDsContComboBox.activated.connect(self.clearComboBoxFocus) - self.annotateRightHowCombobox.currentIndexChanged.connect( - self.annotateRightHowCombobox_cb + self._connect_method_if_present( + self.annotateRightHowCombobox.currentIndexChanged, + "annotateRightHowCombobox_cb", + ) + self._connect_method_if_present( + self.annotateRightHowCombobox.activated, "clearComboBoxFocus" ) - self.annotateRightHowCombobox.activated.connect(self.clearComboBoxFocus) - self.showTreeInfoCheckbox.toggled.connect(self.setAnnotInfoMode) + self._connect_method_if_present( + self.showTreeInfoCheckbox.toggled, "setAnnotInfoMode" + ) - # Left - self.annotIDsCheckbox.clicked.connect(self.annotOptionClicked) - self.annotCcaInfoCheckbox.clicked.connect(self.annotOptionClicked) - self.annotContourCheckbox.clicked.connect(self.annotOptionClicked) - self.annotSegmMasksCheckbox.clicked.connect(self.annotOptionClicked) - self.drawMothBudLinesCheckbox.clicked.connect(self.annotOptionClicked) - self.drawNothingCheckbox.clicked.connect(self.annotOptionClicked) - self.annotNumZslicesCheckbox.clicked.connect(self.annotOptionClicked) + self._connect_method_if_present( + self.annotIDsCheckbox.clicked, "annotOptionClicked" + ) + self._connect_method_if_present( + self.annotCcaInfoCheckbox.clicked, "annotOptionClicked" + ) + self._connect_method_if_present( + self.annotContourCheckbox.clicked, "annotOptionClicked" + ) + self._connect_method_if_present( + self.annotSegmMasksCheckbox.clicked, "annotOptionClicked" + ) + self._connect_method_if_present( + self.drawMothBudLinesCheckbox.clicked, "annotOptionClicked" + ) + self._connect_method_if_present( + self.drawNothingCheckbox.clicked, "annotOptionClicked" + ) + self._connect_method_if_present( + self.annotNumZslicesCheckbox.clicked, "annotOptionClicked" + ) - # Right - self.annotIDsCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.annotCcaInfoCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.annotContourCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.annotSegmMasksCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.drawMothBudLinesCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.drawNothingCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self.annotNumZslicesCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self._connect_method_if_present( + self.annotIDsCheckboxRight.clicked, "annotOptionClickedRight" + ) + self._connect_method_if_present( + self.annotCcaInfoCheckboxRight.clicked, "annotOptionClickedRight" + ) + self._connect_method_if_present( + self.annotContourCheckboxRight.clicked, "annotOptionClickedRight" + ) + self._connect_method_if_present( + self.annotSegmMasksCheckboxRight.clicked, "annotOptionClickedRight" + ) + self._connect_method_if_present( + self.drawMothBudLinesCheckboxRight.clicked, "annotOptionClickedRight" + ) + self._connect_method_if_present( + self.drawNothingCheckboxRight.clicked, "annotOptionClickedRight" + ) + self._connect_method_if_present( + self.annotNumZslicesCheckboxRight.clicked, "annotOptionClickedRight" + ) - self.segmentToolAction.triggered.connect(self.segmentToolActionTriggered) + self._connect_method_if_present( + self.segmentToolAction.triggered, "segmentToolActionTriggered" + ) - self.addDelRoiAction.triggered.connect(self.addDelROI) - self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) - self.delBorderObjAction.triggered.connect(self.delBorderObj) - self.delNewObjAction.triggered.connect(self.delNewObj) + self._connect_method_if_present(self.addDelRoiAction.triggered, "addDelROI") + self._connect_method_if_present( + self.addDelPolyLineRoiButton.toggled, "addDelPolyLineRoi_cb" + ) + self._connect_method_if_present( + self.delBorderObjAction.triggered, "delBorderObj" + ) + self._connect_method_if_present(self.delNewObjAction.triggered, "delNewObj") - self.brushAutoFillCheckbox.toggled.connect(self.brushAutoFillToggled) - self.brushAutoHideCheckbox.toggled.connect(self.brushAutoHideToggled) + self._connect_method_if_present( + self.brushAutoFillCheckbox.toggled, "brushAutoFillToggled" + ) + self._connect_method_if_present( + self.brushAutoHideCheckbox.toggled, "brushAutoHideToggled" + ) self.imgGrad.sigAddScaleBar.connect(self.addScaleBarAction.setChecked) self.imgGrad.sigAddTimestamp.connect(self.addTimestampAction.setChecked) - self.imgGrad.gradient.sigGradientChangeFinished.connect( - self.imgGradLUTfinished_cb + self._connect_method_if_present( + self.imgGrad.gradient.sigGradientChangeFinished, "imgGradLUTfinished_cb" ) - # self.normalizeQActionGroup.triggered.connect( - # self.normaliseIntensitiesActionTriggered - # ) - self.imgPropertiesAction.triggered.connect(self.editImgProperties) + self._connect_method_if_present( + self.imgPropertiesAction.triggered, "editImgProperties" + ) - self.relabelSequentialAction.triggered.connect(self.relabelSequentialCallback) + self._connect_method_if_present( + self.relabelSequentialAction.triggered, "relabelSequentialCallback" + ) - self.zoomToObjsAction.triggered.connect(self.zoomToObjsActionCallback) - self.zoomOutAction.triggered.connect(self.zoomOut) - self.preprocessAction.triggered.connect(self.preprocessActionTriggered) - self.combineChannelsAction.triggered.connect( - self.combineChannelsActionTriggered + self._connect_method_if_present( + self.zoomToObjsAction.triggered, "zoomToObjsActionCallback" + ) + self._connect_method_if_present(self.zoomOutAction.triggered, "zoomOut") + self._connect_method_if_present( + self.preprocessAction.triggered, "preprocessActionTriggered" + ) + self._connect_method_if_present( + self.combineChannelsAction.triggered, "combineChannelsActionTriggered" ) - self.viewCcaTableAction.triggered.connect(self.viewCcaTable) + self._connect_method_if_present( + self.viewCcaTableAction.triggered, "viewCcaTable" + ) - self.guiTabControl.propsQGBox.idSB.valueChanged.connect( - self.propsWidgetIDvalueChanged + self._connect_method_if_present( + self.guiTabControl.propsQGBox.idSB.valueChanged, "propsWidgetIDvalueChanged" ) - self.guiTabControl.highlightCheckbox.toggled.connect( - self.highlightIDonHoverCheckBoxToggled + self._connect_method_if_present( + self.guiTabControl.highlightCheckbox.toggled, + "highlightIDonHoverCheckBoxToggled", ) - self.guiTabControl.highlightSearchedCheckbox.toggled.connect( - self.highlightSearchedIDcheckBoxToggled + self._connect_method_if_present( + self.guiTabControl.highlightSearchedCheckbox.toggled, + "highlightSearchedIDcheckBoxToggled", ) intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox - intensMeasurQGBox.additionalMeasCombobox.currentTextChanged.connect( - self.updatePropsWidget + self._connect_method_if_present( + intensMeasurQGBox.additionalMeasCombobox.currentTextChanged, + "updatePropsWidget", ) - intensMeasurQGBox.channelCombobox.currentTextChanged.connect( - self.updatePropsWidget + self._connect_method_if_present( + intensMeasurQGBox.channelCombobox.currentTextChanged, "updatePropsWidget" ) propsQGBox = self.guiTabControl.propsQGBox - propsQGBox.additionalPropsCombobox.currentTextChanged.connect( - self.updatePropsWidget + self._connect_method_if_present( + propsQGBox.additionalPropsCombobox.currentTextChanged, "updatePropsWidget" ) def gui_createActions(self): @@ -504,8 +699,8 @@ def gui_createActions(self): self.EditSegForLostIDsSetSettings = QAction( "Edit settings for Segmenting lost IDs...", self ) - self.EditSegForLostIDsSetSettings.triggered.connect( - self.SegForLostIDsSetSettings + self._connect_method_if_present( + self.EditSegForLostIDsSetSettings.triggered, "SegForLostIDsSetSettings" ) self.repeatTrackingAction = QAction( @@ -764,7 +959,7 @@ def gui_updateSwitchColorSchemeActionText(self): self.toggleColorSchemeAction.setText(txt) def initShortcuts(self): - from . import config + from cellacdc import config cp = config.ConfigParser() if os.path.exists(shortcut_filepath): @@ -835,7 +1030,7 @@ def setShortcuts(self, shortcuts: dict, save=True): if not save: return - from . import config + from cellacdc import config cp = config.ConfigParser() if os.path.exists(shortcut_filepath): diff --git a/cellacdc/mixins/app_shell.py b/cellacdc/mixins/app_shell.py index 7850c6442..11bf8a166 100644 --- a/cellacdc/mixins/app_shell.py +++ b/cellacdc/mixins/app_shell.py @@ -14,6 +14,7 @@ base_cca_dict, cca_df_colnames, html_utils, + load, settings_csv_path, widgets, ) @@ -192,7 +193,7 @@ def initGlobalAttr(self): def initProfileModels(self): self.logger.info("Initiliazing profilers...") - from ._profile.spline_to_obj import model + from cellacdc._profile.spline_to_obj import model self.splineToObjModel = model.Model() diff --git a/cellacdc/mixins/image_controls.py b/cellacdc/mixins/image_controls.py index 8f9b98c14..e94d4c4cd 100644 --- a/cellacdc/mixins/image_controls.py +++ b/cellacdc/mixins/image_controls.py @@ -17,7 +17,10 @@ QWidget, ) -from cellacdc import widgets +import pyqtgraph as pg +import numpy as np + +from cellacdc import darkBkgrColor, graphLayoutBkgrColor, widgets _font = QFont() _font.setPixelSize(11) diff --git a/cellacdc/mixins/label_roi.py b/cellacdc/mixins/label_roi.py index 8a4d9cdaa..8ec3c5f33 100644 --- a/cellacdc/mixins/label_roi.py +++ b/cellacdc/mixins/label_roi.py @@ -327,7 +327,7 @@ def labelRoiTrangeCheckboxToggled(self, checked): self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) def labelRoiViewCurrentModel(self): - from . import config + from cellacdc import config ini_path = os.path.join(settings_folderpath, "last_params_segm_models.ini") configPars = config.ConfigParser() diff --git a/cellacdc/mixins/layout_controls.py b/cellacdc/mixins/layout_controls.py index 7cefbe3fa..250aed448 100644 --- a/cellacdc/mixins/layout_controls.py +++ b/cellacdc/mixins/layout_controls.py @@ -18,6 +18,7 @@ QLabel, QRadioButton, QSizePolicy, + QVBoxLayout, QWidget, ) @@ -29,6 +30,13 @@ from .label_roi import LabelRoi +def _connect_method_if_present(host, signal, method_name): + method = getattr(host, method_name, None) + if method is not None: + signal.connect(method) + + + class LayoutControls(ImageControls, WindowEvents, LabelRoi): """Extracted from guiWin.""" @@ -53,8 +61,8 @@ def gui_createControlsToolbar(self): self.overlayToolbar = widgets.OverlayToolbar(parent=self) self.addToolBar(Qt.TopToolBarArea, self.overlayToolbar) self.overlayToolbar.setVisible(False) - self.overlayToolbar.sigSetTranspacency.connect(self.setOverlayTransparency) - self.overlayToolbar.sigSetSingleChannel.connect(self.setOverlaySingleChannel) + _connect_method_if_present(self, self.overlayToolbar.sigSetTranspacency, "setOverlayTransparency") + _connect_method_if_present(self, self.overlayToolbar.sigSetSingleChannel, "setOverlaySingleChannel") self.autoPilotZoomToObjToolbar = widgets.ToolBar("Auto-zoom to objects", self) self.autoPilotZoomToObjToolbar.setContextMenuPolicy(Qt.PreventContextMenu) @@ -72,7 +80,7 @@ def gui_createControlsToolbar(self): self.highlightIDToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.highlightIDToolbar) - self.highlightIDToolbar.sigIDChanged.connect(self.setHighlighedIDfromToolbar) + _connect_method_if_present(self, self.highlightIDToolbar.sigIDChanged, "setHighlighedIDfromToolbar") # Widgets toolbar brushEraserToolBar = widgets.ToolBar("Widgets", self) @@ -232,32 +240,18 @@ def gui_createControlsToolbar(self): self.loadLabelRoiLastParams() - self.labelRoiTrangeCheckbox.toggled.connect(self.labelRoiTrangeCheckboxToggled) - self.labelRoiReplaceExistingObjectsCheckbox.toggled.connect( - self.storeLabelRoiParams - ) - self.labelRoiIsCircularRadioButton.toggled.connect( - self.labelRoiIsCircularRadioButtonToggled - ) - self.labelRoiCircularRadiusSpinbox.valueChanged.connect( - self.updateLabelRoiCircularSize - ) - self.labelRoiCircularRadiusSpinbox.valueChanged.connect( - self.storeLabelRoiParams - ) - self.labelRoiZdepthSpinbox.valueChanged.connect(self.storeLabelRoiParams) - self.labelRoiAutoClearBorderCheckbox.toggled.connect(self.storeLabelRoiParams) - group.buttonToggled.connect(self.storeLabelRoiParams) + _connect_method_if_present(self, self.labelRoiTrangeCheckbox.toggled, "labelRoiTrangeCheckboxToggled") + _connect_method_if_present(self, self.labelRoiReplaceExistingObjectsCheckbox.toggled, "storeLabelRoiParams") + _connect_method_if_present(self, self.labelRoiIsCircularRadioButton.toggled, "labelRoiIsCircularRadioButtonToggled") + _connect_method_if_present(self, self.labelRoiCircularRadiusSpinbox.valueChanged, "updateLabelRoiCircularSize") + _connect_method_if_present(self, self.labelRoiCircularRadiusSpinbox.valueChanged, "storeLabelRoiParams") + _connect_method_if_present(self, self.labelRoiZdepthSpinbox.valueChanged, "storeLabelRoiParams") + _connect_method_if_present(self, self.labelRoiAutoClearBorderCheckbox.toggled, "storeLabelRoiParams") + _connect_method_if_present(self, group.buttonToggled, "storeLabelRoiParams") - self.labelRoiToEndFramesAction.triggered.connect( - self.labelRoiToEndFramesTriggered - ) - self.labelRoiFromCurrentFrameAction.triggered.connect( - self.labelRoiFromCurrentFrameTriggered - ) - self.labelRoiViewCurrentModelAction.triggered.connect( - self.labelRoiViewCurrentModel - ) + _connect_method_if_present(self, self.labelRoiToEndFramesAction.triggered, "labelRoiToEndFramesTriggered") + _connect_method_if_present(self, self.labelRoiFromCurrentFrameAction.triggered, "labelRoiFromCurrentFrameTriggered") + _connect_method_if_present(self, self.labelRoiViewCurrentModelAction.triggered, "labelRoiViewCurrentModel") self.keepIDsToolbar = widgets.ToolBar("Keep IDs controls", self) self.keepIDsConfirmAction = QAction() @@ -280,14 +274,14 @@ def gui_createControlsToolbar(self): self.keepIDsToolbar.setVisible(False) self.controlToolBars.append(self.keepIDsToolbar) - self.keptIDsLineEdit.sigEnterPressed.connect(self.applyKeepObjects) - self.keptIDsLineEdit.sigIDsChanged.connect(self.updateKeepIDs) - self.keepIDsConfirmAction.triggered.connect(self.applyKeepObjects) + _connect_method_if_present(self, self.keptIDsLineEdit.sigEnterPressed, "applyKeepObjects") + _connect_method_if_present(self, self.keptIDsLineEdit.sigIDsChanged, "updateKeepIDs") + _connect_method_if_present(self, self.keepIDsConfirmAction.triggered, "applyKeepObjects") # closeToolbarAction = QAction( # QIcon(":cancelButton.svg"), "Close toolbar...", self # ) - # closeToolbarAction.triggered.connect(self.closeToolbars) + # _connect_method_if_present(self, closeToolbarAction.triggered, "closeToolbars") # self.autoPilotZoomToObjToolbar.addAction(closeToolbarAction) self.autoPilotZoomToObjToolbar.addWidget(widgets.QVLine()) @@ -300,13 +294,13 @@ def gui_createControlsToolbar(self): spinBox.label = QLabel(" Zoom to ID: ") spinBox.labelAction = self.autoPilotZoomToObjToolbar.addWidget(spinBox.label) spinBox.action = self.autoPilotZoomToObjToolbar.addWidget(spinBox) - spinBox.editingFinished.connect(self.zoomToObj) - spinBox.sigUpClicked.connect(self.autoZoomNextObj) - spinBox.sigDownClicked.connect(self.autoZoomPrevObj) + _connect_method_if_present(self, spinBox.editingFinished, "zoomToObj") + _connect_method_if_present(self, spinBox.sigUpClicked, "autoZoomNextObj") + _connect_method_if_present(self, spinBox.sigDownClicked, "autoZoomPrevObj") self.autoPilotZoomToObjSpinBox = spinBox toggle = widgets.Toggle() self.autoPilotZoomToObjToggle = toggle - toggle.toggled.connect(self.autoPilotZoomToObjToggled) + _connect_method_if_present(self, toggle.toggled, "autoPilotZoomToObjToggled") toggle.label = QLabel(" Auto-pilot: ") tooltip = ( "When auto-pilot is active, you can use Up/Down arrows to " @@ -324,7 +318,7 @@ def gui_createControlsToolbar(self): self.pointsLayersToolbar = widgets.PointsLayersToolbar(parent=self) self.pointsLayersToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - self.pointsLayersToolbar.sigAddPointsLayer.connect(self.addPointsLayerTriggered) + _connect_method_if_present(self, self.pointsLayersToolbar.sigAddPointsLayer, "addPointsLayerTriggered") self.addToolBar(Qt.TopToolBarArea, self.pointsLayersToolbar) @@ -337,13 +331,11 @@ def gui_createControlsToolbar(self): self.manualTrackingToolbar = widgets.ManualTrackingToolBar( "Manual tracking controls", self ) - self.manualTrackingToolbar.sigIDchanged.connect(self.initGhostObject) - self.manualTrackingToolbar.sigDisableGhost.connect(self.clearGhost) - self.manualTrackingToolbar.sigClearGhostContour.connect(self.clearGhostContour) - self.manualTrackingToolbar.sigClearGhostMask.connect(self.clearGhostMask) - self.manualTrackingToolbar.sigGhostOpacityChanged.connect( - self.updateGhostMaskOpacity - ) + _connect_method_if_present(self, self.manualTrackingToolbar.sigIDchanged, "initGhostObject") + _connect_method_if_present(self, self.manualTrackingToolbar.sigDisableGhost, "clearGhost") + _connect_method_if_present(self, self.manualTrackingToolbar.sigClearGhostContour, "clearGhostContour") + _connect_method_if_present(self, self.manualTrackingToolbar.sigClearGhostMask, "clearGhostMask") + _connect_method_if_present(self, self.manualTrackingToolbar.sigGhostOpacityChanged, "updateGhostMaskOpacity") self.addToolBar(Qt.TopToolBarArea, self.manualTrackingToolbar) self.manualTrackingToolbar.setVisible(False) @@ -352,9 +344,7 @@ def gui_createControlsToolbar(self): self.manualBackgroundToolbar = widgets.ManualBackgroundToolBar( "Manual background controls", self ) - self.manualBackgroundToolbar.sigIDchanged.connect( - self.initManualBackgroundObject - ) + _connect_method_if_present(self, self.manualBackgroundToolbar.sigIDchanged, "initManualBackgroundObject") self.addToolBar(Qt.TopToolBarArea, self.manualBackgroundToolbar) self.manualBackgroundToolbar.setVisible(False) self.controlToolBars.append(self.manualBackgroundToolbar) @@ -366,7 +356,7 @@ def gui_createControlsToolbar(self): for name, action in self.copyLostObjToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - self.copyLostObjToolbar.sigCopyAllObjects.connect(self.copyAllLostObjects) + _connect_method_if_present(self, self.copyLostObjToolbar.sigCopyAllObjects, "copyAllLostObjects") self.addToolBar(Qt.TopToolBarArea, self.copyLostObjToolbar) self.copyLostObjToolbar.setVisible(False) @@ -402,27 +392,18 @@ def gui_createControlsToolbar(self): for name, action in self.magicPromptsToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - self.magicPromptsToolbar.sigComputeOnZoom.connect( - self.magicPromptsComputeOnZoomTriggered - ) - self.magicPromptsToolbar.sigComputeOnImage.connect( - self.magicPromptsComputeOnImageTriggered - ) - self.magicPromptsToolbar.sigInitSelectedModel.connect( - self.magicPromptsInitModel - ) - self.magicPromptsToolbar.sigViewModelParams.connect( - self.viewSetMagicPromptModelParams - ) - self.magicPromptsToolbar.sigClearPoints.connect( - partial(self.magicPromptsClearPoints, only_zoom=False) - ) - self.magicPromptsToolbar.sigClearPointsOnZmom.connect( - partial(self.magicPromptsClearPoints, only_zoom=True) - ) - self.magicPromptsToolbar.sigInterpolateZslice.connect( - self.magicPromptsInterpolateZsliceToggled - ) + _connect_method_if_present(self, self.magicPromptsToolbar.sigComputeOnZoom, "magicPromptsComputeOnZoomTriggered") + _connect_method_if_present(self, self.magicPromptsToolbar.sigComputeOnImage, "magicPromptsComputeOnImageTriggered") + _connect_method_if_present(self, self.magicPromptsToolbar.sigInitSelectedModel, "magicPromptsInitModel") + _connect_method_if_present(self, self.magicPromptsToolbar.sigViewModelParams, "viewSetMagicPromptModelParams") + if hasattr(self, "magicPromptsClearPoints"): + self.magicPromptsToolbar.sigClearPoints.connect( + partial(self.magicPromptsClearPoints, only_zoom=False) + ) + self.magicPromptsToolbar.sigClearPointsOnZmom.connect( + partial(self.magicPromptsClearPoints, only_zoom=True) + ) + _connect_method_if_present(self, self.magicPromptsToolbar.sigInterpolateZslice, "magicPromptsInterpolateZsliceToggled") self.addToolBar(Qt.TopToolBarArea, self.magicPromptsToolbar) self.magicPromptsToolbar.setVisible(False) @@ -478,11 +459,9 @@ def gui_createMainLayout(self): row += 1 self.resizeBottomLayoutLine = widgets.VerticalResizeHline() mainLayout.addWidget(self.resizeBottomLayoutLine, row, col, 1, 2) - self.resizeBottomLayoutLine.dragged.connect(self.resizeBottomLayoutLineDragged) - self.resizeBottomLayoutLine.clicked.connect(self.resizeBottomLayoutLineClicked) - self.resizeBottomLayoutLine.released.connect( - self.resizeBottomLayoutLineReleased - ) + _connect_method_if_present(self, self.resizeBottomLayoutLine.dragged, "resizeBottomLayoutLineDragged") + _connect_method_if_present(self, self.resizeBottomLayoutLine.clicked, "resizeBottomLayoutLineClicked") + _connect_method_if_present(self, self.resizeBottomLayoutLine.released, "resizeBottomLayoutLineReleased") # row += 1 # mainLayout.addItem(QSpacerItem(5,5), row+1, col, 1, 2) @@ -580,9 +559,7 @@ def gui_populateToolSettingsMenu(self): self.brushHoverCenterModeAction.setChecked(useCenterBrushCursorHoverID) self.brushHoverCircleModeAction.setChecked(not useCenterBrushCursorHoverID) - self.brushHoverCenterModeAction.toggled.connect( - self.useCenterBrushCursorHoverIDtoggled - ) + _connect_method_if_present(self, self.brushHoverCenterModeAction.toggled, "useCenterBrushCursorHoverIDtoggled") self.settingsMenu.addSeparator() @@ -628,7 +605,7 @@ def gui_populateToolSettingsMenu(self): action.setChecked(True) else: all_checked = False - action.toggled.connect(self.keepToolActiveActionToggled) + _connect_method_if_present(self, action.toggled, "keepToolActiveActionToggled") menu.addAction(action) self.keepToolActiveActions[toolName] = action @@ -637,7 +614,7 @@ def gui_populateToolSettingsMenu(self): action = QAction(button) action.setText("Apply when visitng new frame") action.setCheckable(True) - action.toggled.connect(self.applyToolNewFrameActionToggled) + _connect_method_if_present(self, action.toggled, "applyToolNewFrameActionToggled") menu.addAction(action) self.applyToolNewFrameActions[toolName] = action self.applyToolNewFrameButtons[toolName] = button @@ -657,9 +634,7 @@ def gui_populateToolSettingsMenu(self): self.keepAllToolsActiveToggle.setText("Keep all tools active after using them") self.keepAllToolsActiveToggle.setCheckable(True) self.keepAllToolsActiveToggle.setChecked(all_checked) - self.keepAllToolsActiveToggle.toggled.connect( - self.keepAllToolsActiveActionToggled - ) + _connect_method_if_present(self, self.keepAllToolsActiveToggle.toggled, "keepAllToolsActiveActionToggled") self.settingsMenu.addAction(self.keepAllToolsActiveToggle) self.settingsMenu.addSeparator() diff --git a/cellacdc/mixins/main_menu.py b/cellacdc/mixins/main_menu.py index 4ce6c14aa..15014f529 100644 --- a/cellacdc/mixins/main_menu.py +++ b/cellacdc/mixins/main_menu.py @@ -135,7 +135,8 @@ def gui_createMenuBar(self): SegmMenu.addAction(self.postProcessSegmAction) SegmMenu.addAction(self.autoSegmAction) SegmMenu.addAction(self.relabelSequentialAction) - SegmMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) + if hasattr(self, "nonViewerEditMenuOpened"): + SegmMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) # Tracking menu trackingMenu = menuBar.addMenu("&Tracking") @@ -151,7 +152,8 @@ def gui_createMenuBar(self): trackingMenu.addAction(self.repeatTrackingVideoAction) trackingMenu.addAction(self.repeatTrackingMenuAction) - trackingMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) + if hasattr(self, "nonViewerEditMenuOpened"): + trackingMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) if self.mainWin is not None: trackingMenu.addAction(self.mainWin.applyTrackingFromTableAction) diff --git a/cellacdc/mixins/main_toolbar.py b/cellacdc/mixins/main_toolbar.py index 12dba9747..02b6dad06 100644 --- a/cellacdc/mixins/main_toolbar.py +++ b/cellacdc/mixins/main_toolbar.py @@ -288,7 +288,9 @@ def gui_createToolBars(self): self.segForLostIDsButton = QToolButton(self) self.segForLostIDsButton.setIcon(QIcon(":segForLostIDs.svg")) self.segForLostIDsAction = editToolBar.addWidget(self.segForLostIDsButton) - self.segForLostIDsButton.clicked.connect(self.segForLostIDsButtonClicked) + self._connect_method_if_present( + self.segForLostIDsButton.clicked, "segForLostIDsButtonClicked" + ) # self.SegForLostIDsButton.setShortcut('U') # self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.SegForLostIDsButton @@ -524,7 +526,9 @@ def gui_createToolBars(self): self.widgetsWithShortcut["Propagate (lineage tree)"] = ( self.propagateLinTreeButton ) - self.propagateLinTreeButton.clicked.connect(self.propagateLinTreeAction) + self._connect_method_if_present( + self.propagateLinTreeButton.clicked, "propagateLinTreeAction" + ) self.viewLinTreeInfoButton = QToolButton(self) self.viewLinTreeInfoButton.setIcon(QIcon(":addCustomAnnotation.svg")) @@ -533,7 +537,9 @@ def gui_createToolBars(self): self.widgetsWithShortcut["View Changes (lineage tree)"] = ( self.viewLinTreeInfoButton ) - self.viewLinTreeInfoButton.clicked.connect(self.viewLinTreeInfoAction) + self._connect_method_if_present( + self.viewLinTreeInfoButton.clicked, "viewLinTreeInfoAction" + ) modes_available = [ "Segmentation and Tracking", diff --git a/cellacdc/mixins/session.py b/cellacdc/mixins/session.py index fd98da6fa..9e7aded2f 100644 --- a/cellacdc/mixins/session.py +++ b/cellacdc/mixins/session.py @@ -6,6 +6,7 @@ from functools import partial import numpy as np +import pandas as pd import skimage.measure from qtpy.QtWidgets import QAction diff --git a/cellacdc/mixins/tool_activation.py b/cellacdc/mixins/tool_activation.py index d0c3f3f1a..1552a259a 100644 --- a/cellacdc/mixins/tool_activation.py +++ b/cellacdc/mixins/tool_activation.py @@ -502,7 +502,8 @@ def onEscape( self.clearTempBrushImage() self.isMouseDragImg1 = False self.typingEditID = False - self.clearHighlightedID() + if hasattr(self, "clearHighlightedID"): + self.clearHighlightedID() try: self.polyLineRoi.clearPoints() except Exception as e: diff --git a/pyproject.toml b/pyproject.toml index e96d7a741..421d080b4 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,9 @@ dev = [ cellacdc = "cellacdc.__main__:run" acdc = "cellacdc.__main__:run" Cell-ACDC = "cellacdc.__main__:run" +gui = "cellacdc.gui_visualization:main" +gui-basic = "cellacdc.gui_basic:main" +gui-segmentation = "cellacdc.gui_segmentation:main" [tool.setuptools] include-package-data = true From 8514bd5d76b0ac265486c46ce6639e339f7fc9eb Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 15:03:35 +0200 Subject: [PATCH 14/21] Revert "Add composable GUI variants for viewer and segmentation workflows." This reverts commit a9e52f501089870119a84238241e82a7e0002a52. --- cellacdc/gui_basic.py | 100 ----- cellacdc/gui_bundles.py | 48 --- cellacdc/gui_runtime.py | 46 --- cellacdc/gui_segmentation.py | 248 ------------ cellacdc/gui_visualization.py | 235 ----------- cellacdc/mixins/actions.py | 611 ++++++++++------------------- cellacdc/mixins/app_shell.py | 3 +- cellacdc/mixins/image_controls.py | 5 +- cellacdc/mixins/label_roi.py | 2 +- cellacdc/mixins/layout_controls.py | 139 ++++--- cellacdc/mixins/main_menu.py | 6 +- cellacdc/mixins/main_toolbar.py | 12 +- cellacdc/mixins/session.py | 1 - cellacdc/mixins/tool_activation.py | 3 +- pyproject.toml | 3 - 15 files changed, 299 insertions(+), 1163 deletions(-) delete mode 100644 cellacdc/gui_basic.py delete mode 100644 cellacdc/gui_bundles.py delete mode 100644 cellacdc/gui_runtime.py delete mode 100644 cellacdc/gui_segmentation.py delete mode 100644 cellacdc/gui_visualization.py diff --git a/cellacdc/gui_basic.py b/cellacdc/gui_basic.py deleted file mode 100644 index 6fc55926f..000000000 --- a/cellacdc/gui_basic.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Minimal composable GUI — foundation for feature-specific variants.""" - -from __future__ import annotations - -import os -import sys - -from qtpy.QtCore import Qt -from qtpy.QtGui import QIcon -from qtpy.QtWidgets import QLabel, QMainWindow, QVBoxLayout, QWidget - -from . import myutils -from .gui_bundles import BASIC_GUI_ROOTS -from .gui_runtime import bootstrap_qt, run_event_loop -from .mixins import AppShell -from .myutils import setupLogger - - -class BasicGuiWin(QMainWindow, AppShell): - """Small GUI built from the basic mixin bundle.""" - - def __init__(self, app, parent=None, version=None): - super().__init__(parent) - self.app = app - self._version = version - self._appName = "Cell-ACDC Basic" - self.mainWin = None - self.launcherSlot = None - self.buttonToRestore = None - self.closeGUI = False - self._acdc_version = myutils.read_version() - self.newWindows = [] - - from .config import parser_args - - self.debug = parser_args["debug"] - - def run(self, module="acdc_gui_basic", logs_path=None): - QMainWindow.setWindowIcon(self, QIcon(":icon.ico")) - QMainWindow.setWindowTitle(self, "Cell-ACDC Basic") - - logger, logs_path, log_path, log_filename = setupLogger( - module=module, logs_path=logs_path, caller="Cell-ACDC" - ) - self.module = module - self.logger = logger - self.log_path = log_path - self.log_filename = log_filename - self.logs_path = logs_path - self.logger.info("Initializing basic GUI") - - self.loadLastSessionSettings() - self.is_error_state = False - self.pos_i = 0 - self.isDataLoaded = False - - self._build_minimal_chrome() - self.show() - self.logger.info("Basic GUI ready") - - def _build_minimal_chrome(self): - from qtpy.QtGui import QAction - - file_menu = self.menuBar().addMenu("&File") - exit_action = QAction("E&xit", self) - exit_action.setShortcut("Ctrl+Q") - exit_action.triggered.connect(self.close) - file_menu.addAction(exit_action) - - help_menu = self.menuBar().addMenu("&Help") - about_action = QAction("&About", self) - about_action.triggered.connect(self.showAbout) - help_menu.addAction(about_action) - - central = QWidget() - layout = QVBoxLayout(central) - layout.addWidget( - QLabel( - "Basic GUI\n\n" - f"Mixin bundle: {', '.join(BASIC_GUI_ROOTS)}\n" - "Next: add segmentation and data-loading mixins here." - ), - alignment=Qt.AlignCenter, - ) - self.setCentralWidget(central) - - status = self.statusBar() - status.showMessage("Ready") - - -def main(): - app, _splash = bootstrap_qt() - version = myutils.read_version() - win = BasicGuiWin(app, version=version) - win.run() - run_event_loop(app) - - -if __name__ == "__main__": - main() diff --git a/cellacdc/gui_bundles.py b/cellacdc/gui_bundles.py deleted file mode 100644 index 737e81e6a..000000000 --- a/cellacdc/gui_bundles.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Mixin bundles for composable GUI variants. - -Each bundle lists the mixins declared directly on the window class. Upstream -parents are inherited automatically through the mixin dependency graph. -""" - -from __future__ import annotations - -# Minimal shell: logging, session settings, window chrome helpers. -BASIC_GUI_ROOTS: tuple[str, ...] = ("AppShell",) - -# Load data and visualize images / labels (no annotation canvas stack). -VISUALIZATION_GUI_ROOTS: tuple[str, ...] = ( - "DataLoading", - "MainMenu", - "MainToolbar", - "Saving", - "Measurements", - "ObjectSearch", - "Exporting", - "Preprocessing", - "AnnotationDisplay", - "QuickSettings", - "UndoRedo", - "CombineGui", -) - -# Segmentation and annotation (no lineage tree, custom annotations, or measurements). -SEGMENTATION_GUI_ROOTS: tuple[str, ...] = ( - "WhitelistGui", - "DataLoading", - "CanvasRightImage", - "CanvasHover", - "MagicPrompts", - "ObjectSearch", - "SegForLostIds", - "Exporting", - "CombineWorker", - "CurvatureTools", - "DrawClearRegion", - "LabelTransformTools", - "DeletedRois", - "Saving", - "MainToolbar", - "QuickSettings", - "MainMenu", - "AnnotationDisplay", -) diff --git a/cellacdc/gui_runtime.py b/cellacdc/gui_runtime.py deleted file mode 100644 index e5388eb2d..000000000 --- a/cellacdc/gui_runtime.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Shared Qt bootstrap for composable GUI variants.""" - -from __future__ import annotations - -import os -import sys - - -def bootstrap_qt(*, splashscreen: bool = False): - from cellacdc._run import _setup_app, _setup_gui_libraries, _setup_numpy - - requires_exit = _setup_gui_libraries(exit_at_end=False) - _setup_numpy() - if requires_exit: - from cellacdc._run import _exit_on_setup - - _exit_on_setup() - - from qtpy import QtCore, QtWidgets - - try: - QtWidgets.QApplication.setAttribute( - QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough - ) - except Exception: - pass - - import pyqtgraph as pg - - pg.setConfigOption("imageAxisOrder", "row-major") - - if os.name == "nt": - try: - import ctypes - - myappid = "schmollerlab.cellacdc.pyqt.v1" - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) - except Exception: - pass - - app, splash = _setup_app(splashscreen=splashscreen) - return app, splash - - -def run_event_loop(app): - sys.exit(app.exec_()) diff --git a/cellacdc/gui_segmentation.py b/cellacdc/gui_segmentation.py deleted file mode 100644 index a4fdb1284..000000000 --- a/cellacdc/gui_segmentation.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Segmentation-focused GUI — annotate and segment without full tracking/CCA stack.""" - -from __future__ import annotations - -import sys - -import numpy as np -from qtpy.QtCore import QTimer, Qt, Signal -from qtpy.QtWidgets import QButtonGroup, QMainWindow, QWidget - -from . import autopilot, myutils -from .gui_bundles import SEGMENTATION_GUI_ROOTS -from .gui_runtime import bootstrap_qt, run_event_loop -from .mixins import ( - AnnotationDisplay, - CanvasHover, - CanvasRightImage, - CombineWorker, - CurvatureTools, - DataLoading, - DeletedRois, - DrawClearRegion, - Exporting, - LabelTransformTools, - MagicPrompts, - MainMenu, - MainToolbar, - ObjectSearch, - QuickSettings, - Saving, - SegForLostIds, - WhitelistGui, -) -from .myutils import setupLogger - -np.seterr(invalid="ignore") - - -class SegmentationGuiWin( - QMainWindow, - WhitelistGui, - DataLoading, - CanvasRightImage, - CanvasHover, - MagicPrompts, - ObjectSearch, - SegForLostIds, - Exporting, - CombineWorker, - CurvatureTools, - DrawClearRegion, - LabelTransformTools, - DeletedRois, - Saving, - MainToolbar, - QuickSettings, - MainMenu, - AnnotationDisplay, -): - """Segmentation GUI: load data, draw/edit labels, run segmenters.""" - - sigClosed = Signal(object) - sigExportFrame = Signal() - - def __init__( - self, - app, - parent=None, - buttonToRestore=None, - mainWin=None, - version=None, - launcherSlot=None, - ): - super().__init__(parent) - - self._version = version - - from .trackers.YeaZ import tracking as tracking_yeaz - - self.tracking_yeaz = tracking_yeaz - - from .config import parser_args - - self.debug = parser_args["debug"] - - self.buttonToRestore = buttonToRestore - self.launcherSlot = launcherSlot - self.mainWin = mainWin - self.app = app - self.closeGUI = False - self._acdc_version = myutils.read_version() - self.setAcceptDrops(True) - self._appName = "Cell-ACDC Segmentation" - - self.lineage_tree = None - self.already_synced_lin_tree = set() - self.right_click_ID = None - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - - def run(self, module="acdc_gui_segm", logs_path=None): - from qtpy.QtGui import QIcon - - QMainWindow.setWindowIcon(self, QIcon(":icon.ico")) - QMainWindow.setWindowTitle(self, "Cell-ACDC Segmentation") - - self.is_win = sys.platform.startswith("win") - if self.is_win: - self.openFolderText = "Show in Explorer..." - else: - self.openFolderText = "Reveal in Finder..." - - self.is_error_state = False - logger, logs_path, log_path, log_filename = setupLogger( - module=module, logs_path=logs_path, caller="Cell-ACDC" - ) - if self._version is not None: - logger.info(f"Initializing segmentation GUI v{self._version}") - else: - logger.info("Initializing segmentation GUI...") - - self.module = module - self.logger = logger - self.log_path = log_path - self.log_filename = log_filename - self.logs_path = logs_path - - self.initProfileModels() - self.loadLastSessionSettings() - - self.newWindows = [] - self.progressWin = None - self.slideshowWin = None - self.ccaTableWin = None - self.exportToImageWindow = None - self.customAnnotButton = None - self.ccaCheckerRunning = False - self.isDataLoaded = False - self.highlightedID = 0 - self.hoverLabelID = 0 - self.expandingID = -1 - self.count = 0 - self.isDilation = True - self.flag = True - self.currentPropsID = 0 - self.isSegm3D = False - self.newSegmEndName = "" - self.closeGUI = False - self.warnKeyPressedMsg = None - self.img1ChannelGradients = {} - self.AutoPilotProfile = autopilot.AutoPilotProfile() - self.storeStateWorker = None - self.AutoPilot = None - self.widgetsWithShortcut = {} - self.invertBwAlreadyCalledOnce = False - self.zoomOutKeyValue = Qt.Key_H - self.preprocWorker = None - self.preprocessDialog = None - self.viewOriginalLabels = True - self.keepDisabled = False - self.whitelistAddNewIDsFrame = None - self.whitelistOriginalIDs = None - self.whyNavigateDisabled = set() - self.autoSaveTimer = QTimer() - self.dirtyPointsLayerTableEndNames = set() - - self._setup_vars_combine() - if "autoSaveIntevalValue" not in self.df_settings.index: - autoSaveIntevalValue = 2 - autoSaveIntervalUnit = "minutes" - else: - autoSaveIntevalValue = float( - self.df_settings.at["autoSaveIntevalValue", "value"] - ) - autoSaveIntervalUnit = str( - self.df_settings.at["autoSaveIntervalUnit", "value"] - ) - - self.autoSaveIntevalValueUnit = (autoSaveIntevalValue, autoSaveIntervalUnit) - - self.checkableButtons = [] - self.LeftClickButtons = [] - self.toolsActiveInProj3Dsegm = set() - self.customAnnotDict = {} - self.functionsNotTested3D = [] - self.isSnapshot = False - self.debugFlag = False - self.pos_i = 0 - self.save_until_frame_i = 0 - self.countKeyPress = 0 - self.countRightClicks = 0 - self.xHoverImg, self.yHoverImg = None, None - self.lastFrameRanOnFirstVisitTools = 0 - - self.checkableQButtonsGroup = QButtonGroup(self) - self.checkableQButtonsGroup.setExclusive(False) - self.lazyLoader = None - - self.gui_createCursors() - self.gui_createActions() - self.gui_createMenuBar() - self.gui_createToolBars() - self.gui_createControlsToolbar() - self.gui_createShowPropsButton() - self.gui_createRegionPropsDockWidget() - self.gui_createQuickSettingsWidgets() - self.setTooltips() - self.gui_populateToolSettingsMenu() - - self.autoSaveGarbageWorkers = [] - self.autoSaveActiveWorkers = [] - - self.gui_connectActions() - self.gui_createStatusBar() - - self.gui_createGraphicsPlots() - self.gui_addGraphicsItems() - self.gui_createImg1Widgets() - self.gui_createLabWidgets() - self.gui_createBottomWidgetsToBottomLayout() - - mainContainer = QWidget() - self.setCentralWidget(mainContainer) - mainLayout = self.gui_createMainLayout() - self.mainLayout = mainLayout - mainContainer.setLayout(mainLayout) - - self.isEditActionsConnected = False - self.readRecentPaths() - self.initShortcuts() - self.show() - QTimer.singleShot(100, self.resizeRangeWelcomeText) - - self.logger.info( - f"Segmentation GUI ready (bundle: {', '.join(SEGMENTATION_GUI_ROOTS)})." - ) - - -def main(): - app, _splash = bootstrap_qt() - version = myutils.read_version() - win = SegmentationGuiWin(app, version=version) - win.run() - run_event_loop(app) - - -if __name__ == "__main__": - main() diff --git a/cellacdc/gui_visualization.py b/cellacdc/gui_visualization.py deleted file mode 100644 index 862f67a78..000000000 --- a/cellacdc/gui_visualization.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Data visualization GUI — load datasets and inspect images/labels.""" - -from __future__ import annotations - -import os -import sys - -import numpy as np -from qtpy.QtCore import QTimer, Qt, Signal -from qtpy.QtWidgets import QButtonGroup, QMainWindow, QWidget - -from . import autopilot, myutils, settings_folderpath -from .gui_bundles import VISUALIZATION_GUI_ROOTS -from .gui_runtime import bootstrap_qt, run_event_loop -from .mixins import ( - AnnotationDisplay, - CombineGui, - DataLoading, - Exporting, - MainMenu, - MainToolbar, - Measurements, - ObjectSearch, - Preprocessing, - QuickSettings, - Saving, - UndoRedo, -) -from .myutils import setupLogger - -np.seterr(invalid="ignore") - - -class VisualizationGuiWin( - QMainWindow, - DataLoading, - MainMenu, - MainToolbar, - Saving, - Measurements, - ObjectSearch, - Exporting, - Preprocessing, - AnnotationDisplay, - QuickSettings, - UndoRedo, - CombineGui, -): - """Viewer-focused GUI: open data and visualize frames.""" - - sigClosed = Signal(object) - sigExportFrame = Signal() - - def __init__( - self, - app, - parent=None, - buttonToRestore=None, - mainWin=None, - version=None, - launcherSlot=None, - ): - super().__init__(parent) - - self._version = version - - from .trackers.YeaZ import tracking as tracking_yeaz - - self.tracking_yeaz = tracking_yeaz - - from .config import parser_args - - self.debug = parser_args["debug"] - - self.buttonToRestore = buttonToRestore - self.launcherSlot = launcherSlot - self.mainWin = mainWin - self.app = app - self.closeGUI = False - self._acdc_version = myutils.read_version() - self.setAcceptDrops(True) - self._appName = "Cell-ACDC Viewer" - - self.lineage_tree = None - self.already_synced_lin_tree = set() - self.right_click_ID = None - self.original_df_lin_tree = None - self.original_df_lin_tree_i = None - - def run(self, module="acdc_gui_viz", logs_path=None): - from qtpy.QtGui import QIcon - - QMainWindow.setWindowIcon(self, QIcon(":icon.ico")) - QMainWindow.setWindowTitle(self, "Cell-ACDC Viewer") - - self.is_win = sys.platform.startswith("win") - if self.is_win: - self.openFolderText = "Show in Explorer..." - else: - self.openFolderText = "Reveal in Finder..." - - self.is_error_state = False - logger, logs_path, log_path, log_filename = setupLogger( - module=module, logs_path=logs_path, caller="Cell-ACDC" - ) - if self._version is not None: - logger.info(f"Initializing viewer GUI v{self._version}") - else: - logger.info("Initializing viewer GUI...") - - self.module = module - self.logger = logger - self.log_path = log_path - self.log_filename = log_filename - self.logs_path = logs_path - - self.initProfileModels() - self.loadLastSessionSettings() - - self.newWindows = [] - self.progressWin = None - self.slideshowWin = None - self.ccaTableWin = None - self.exportToImageWindow = None - self.customAnnotButton = None - self.ccaCheckerRunning = False - self.isDataLoaded = False - self.highlightedID = 0 - self.hoverLabelID = 0 - self.expandingID = -1 - self.count = 0 - self.isDilation = True - self.flag = True - self.currentPropsID = 0 - self.isSegm3D = False - self.newSegmEndName = "" - self.closeGUI = False - self.warnKeyPressedMsg = None - self.img1ChannelGradients = {} - self.AutoPilotProfile = autopilot.AutoPilotProfile() - self.storeStateWorker = None - self.AutoPilot = None - self.widgetsWithShortcut = {} - self.invertBwAlreadyCalledOnce = False - self.zoomOutKeyValue = Qt.Key_H - self.preprocWorker = None - self.preprocessDialog = None - self.viewOriginalLabels = True - self.keepDisabled = False - self.whitelistAddNewIDsFrame = None - self.whitelistOriginalIDs = None - self.whyNavigateDisabled = set() - self.autoSaveTimer = QTimer() - self.dirtyPointsLayerTableEndNames = set() - - if "autoSaveIntevalValue" not in self.df_settings.index: - autoSaveIntevalValue = 2 - autoSaveIntervalUnit = "minutes" - else: - autoSaveIntevalValue = float( - self.df_settings.at["autoSaveIntevalValue", "value"] - ) - autoSaveIntervalUnit = str( - self.df_settings.at["autoSaveIntervalUnit", "value"] - ) - - self.autoSaveIntevalValueUnit = (autoSaveIntevalValue, autoSaveIntervalUnit) - - self.checkableButtons = [] - self.LeftClickButtons = [] - self.toolsActiveInProj3Dsegm = set() - self.customAnnotDict = {} - self.functionsNotTested3D = [] - self.isSnapshot = False - self.debugFlag = False - self.pos_i = 0 - self.save_until_frame_i = 0 - self.countKeyPress = 0 - self.countRightClicks = 0 - self.xHoverImg, self.yHoverImg = None, None - self.lastFrameRanOnFirstVisitTools = 0 - - self.checkableQButtonsGroup = QButtonGroup(self) - self.checkableQButtonsGroup.setExclusive(False) - self.lazyLoader = None - - self.gui_createCursors() - self.gui_createActions() - self.gui_createMenuBar() - self.gui_createToolBars() - self.gui_createControlsToolbar() - self.gui_createQuickSettingsWidgets() - self.gui_createShowPropsButton() - self.gui_createRegionPropsDockWidget() - self.setTooltips() - - self.autoSaveGarbageWorkers = [] - self.autoSaveActiveWorkers = [] - - self.gui_connectActions() - self.gui_createStatusBar() - - self.gui_createGraphicsPlots() - self.gui_addGraphicsItems() - self.gui_createImg1Widgets() - self.gui_createLabWidgets() - self.gui_createBottomWidgetsToBottomLayout() - - mainContainer = QWidget() - self.setCentralWidget(mainContainer) - mainLayout = self.gui_createMainLayout() - self.mainLayout = mainLayout - mainContainer.setLayout(mainLayout) - - self.isEditActionsConnected = False - self.readRecentPaths() - self.initShortcuts() - self.show() - QTimer.singleShot(100, self.resizeRangeWelcomeText) - - self.logger.info( - f"Viewer GUI ready (bundle: {', '.join(VISUALIZATION_GUI_ROOTS)})." - ) - - -def main(): - app, _splash = bootstrap_qt() - version = myutils.read_version() - win = VisualizationGuiWin(app, version=version) - win.run() - run_event_loop(app) - - -if __name__ == "__main__": - main() diff --git a/cellacdc/mixins/actions.py b/cellacdc/mixins/actions.py index 0aa7c8f2d..e0716fa29 100644 --- a/cellacdc/mixins/actions.py +++ b/cellacdc/mixins/actions.py @@ -9,7 +9,7 @@ from qtpy.QtGui import QIcon, QKeySequence from qtpy.QtWidgets import QAction, QActionGroup, QToolButton -from cellacdc import apps, is_mac, myutils, settings_folderpath, widgets +from cellacdc import apps, is_mac, settings_folderpath, widgets shortcut_filepath = os.path.join(settings_folderpath, "shortcuts.ini") @@ -19,11 +19,6 @@ class Actions(ImageDisplay): """Extracted from guiWin.""" - def _connect_method_if_present(self, signal, method_name): - method = getattr(self, method_name, None) - if method is not None: - signal.connect(method) - def editShortcuts_cb(self): if is_mac: delObjKeySequenceText = "Ctrl" @@ -65,9 +60,7 @@ def editShortcuts_cb(self): def gui_connectActions(self): # Connect File actions if self.debug: - self._connect_method_if_present( - self.createEmptyDataAction.triggered, "_createEmptyData" - ) + self.createEmptyDataAction.triggered.connect(self._createEmptyData) self.segmNdimIndicator.clicked.connect(self.segmNdimIndicatorClicked) self.newWindowAction.triggered.connect(self.openNewWindow) self.newAction.triggered.connect(self.newFile) @@ -80,21 +73,16 @@ def gui_connectActions(self): self.exportToImageAction.triggered.connect(self.exportToImageTriggered) self.quickSaveAction.triggered.connect(self.quickSave) self.viewPreprocDataToggle.toggled.connect(self.viewPreprocDataToggled) - if hasattr(self, "viewCombineChannelDataToggle"): - self._connect_method_if_present( - self.viewCombineChannelDataToggle.toggled, - "viewCombineChannelDataToggled", - ) + self.viewCombineChannelDataToggle.toggled.connect( + self.viewCombineChannelDataToggled + ) self.autoSaveToggle.toggled.connect(self.autoSaveToggled) self.autoSaveAnnotToggle.toggled.connect(self.autoSaveAnnotToggled) self.autoSaveIntervalDialog.sigValueChanged.connect( self.autoSaveIntervalValueChanged ) self.autoSaveIntervalEditButton.clicked.connect(self.autoSaveIntervalEdit) - if hasattr(self, "ccaIntegrCheckerToggle"): - self._connect_method_if_present( - self.ccaIntegrCheckerToggle.toggled, "ccaIntegrCheckerToggled" - ) + self.ccaIntegrCheckerToggle.toggled.connect(self.ccaIntegrCheckerToggled) self.annotLostObjsToggle.toggled.connect(self.annotLostObjsToggled) self.highLowResAction.clicked.connect(self.highLowResToggled) self.showInExplorerAction.triggered.connect(self.showInExplorer_cb) @@ -123,37 +111,25 @@ def gui_connectActions(self): # self.openRecentMenu.aboutToShow.connect(self.populateOpenRecent) self.checkableQButtonsGroup.buttonClicked.connect(self.uncheckQButton) - self._connect_method_if_present( - self.showPropsDockButton.sigClicked, "showPropsDockWidget" - ) + self.showPropsDockButton.sigClicked.connect(self.showPropsDockWidget) - self._connect_method_if_present( - self.loadCustomAnnotationsAction.triggered, "loadCustomAnnotations" - ) - self._connect_method_if_present( - self.addCustomAnnotationAction.triggered, "addCustomAnnotation" + self.loadCustomAnnotationsAction.triggered.connect(self.loadCustomAnnotations) + self.addCustomAnnotationAction.triggered.connect(self.addCustomAnnotation) + self.viewAllCustomAnnotAction.toggled.connect(self.viewAllCustomAnnot) + self.addCustomModelVideoAction.triggered.connect( + self.showInstructionsCustomModel ) - self._connect_method_if_present( - self.viewAllCustomAnnotAction.toggled, "viewAllCustomAnnot" + self.addCustomModelFrameAction.triggered.connect( + self.showInstructionsCustomModel ) - self._connect_method_if_present( - self.addCustomModelVideoAction.triggered, "showInstructionsCustomModel" - ) - self._connect_method_if_present( - self.addCustomModelFrameAction.triggered, "showInstructionsCustomModel" - ) - if hasattr(self, "segmFrameCallback"): - self.addCustomModelFrameAction.callback = self.segmFrameCallback - if hasattr(self, "segmVideoCallback"): - self.addCustomModelVideoAction.callback = self.segmVideoCallback - - self._connect_method_if_present( - self.addCustomPromptModelAction.triggered, - "showInstructionsCustomPromptModel", + self.addCustomModelFrameAction.callback = self.segmFrameCallback + self.addCustomModelVideoAction.callback = self.segmVideoCallback + + self.addCustomPromptModelAction.triggered.connect( + self.showInstructionsCustomPromptModel ) - self._connect_method_if_present( - self.segmWithPromptableModelAction.triggered, - "segmWithPromptableModelActionTriggered", + self.segmWithPromptableModelAction.triggered.connect( + self.segmWithPromptableModelActionTriggered ) def gui_connectEditActions(self): @@ -162,416 +138,245 @@ def gui_connectEditActions(self): self.loadFluoAction.setEnabled(True) self.isEditActionsConnected = True - if hasattr(self, "preprocessImageAction") and hasattr(self, "preprocessAction"): - self.preprocessImageAction.triggered.connect(self.preprocessAction.trigger) - self._connect_method_if_present( - self.combineChannelsAction.triggered, "combineChannelsActionTriggered" - ) - - self._connect_method_if_present(self.overlayButton.toggled, "overlay_cb") - self._connect_method_if_present(self.countObjsButton.toggled, "countObjectsCb") - self._connect_method_if_present( - self.togglePointsLayerAction.toggled, "pointsLayerToggled" - ) - self._connect_method_if_present( - self.overlayLabelsButton.toggled, "overlayLabels_cb" - ) - self._connect_method_if_present( - self.overlayButton.sigRightClick, "showOverlayContextMenu" - ) - self._connect_method_if_present( - self.labelRoiButton.sigRightClick, "showLabelRoiContextMenu" - ) - self._connect_method_if_present( - self.overlayLabelsButton.sigRightClick, "showOverlayLabelsContextMenu" - ) - self._connect_method_if_present(self.rulerButton.toggled, "ruler_cb") - self._connect_method_if_present(self.loadFluoAction.triggered, "loadFluo_cb") - self._connect_method_if_present(self.loadPosAction.triggered, "loadPosTriggered") - self._connect_method_if_present(self.findIdAction.triggered, "findID") - self._connect_method_if_present( - self.zoomRectButton.toggled, "zoomRectActionToggled" - ) - self._connect_method_if_present( - self.autoPilotButton.toggled, "autoPilotToggled" - ) - self._connect_method_if_present( - self.skipToNewIdAction.triggered, "skipForwardToNewID" + self.preprocessImageAction.triggered.connect(self.preprocessAction.trigger) + self.combineChannelsAction.triggered.connect( + self.combineChannelsActionTriggered + ) + + self.overlayButton.toggled.connect(self.overlay_cb) + self.countObjsButton.toggled.connect(self.countObjectsCb) + self.togglePointsLayerAction.toggled.connect(self.pointsLayerToggled) + self.overlayLabelsButton.toggled.connect(self.overlayLabels_cb) + self.overlayButton.sigRightClick.connect(self.showOverlayContextMenu) + self.labelRoiButton.sigRightClick.connect(self.showLabelRoiContextMenu) + self.overlayLabelsButton.sigRightClick.connect( + self.showOverlayLabelsContextMenu + ) + self.rulerButton.toggled.connect(self.ruler_cb) + self.loadFluoAction.triggered.connect(self.loadFluo_cb) + self.loadPosAction.triggered.connect(self.loadPosTriggered) + # self.reloadAction.triggered.connect(self.reload_cb) + self.findIdAction.triggered.connect(self.findID) + self.zoomRectButton.toggled.connect(self.zoomRectActionToggled) + self.autoPilotButton.toggled.connect(self.autoPilotToggled) + self.skipToNewIdAction.triggered.connect(self.skipForwardToNewID) + self.slideshowButton.toggled.connect(self.launchSlideshow) + + self.copyLostObjButton.toggled.connect(self.copyLostObjContour_cb) + self.manualAnnotPastButton.toggled.connect(self.manualAnnotPast_cb) + + self.segmSingleFrameMenu.triggered.connect(self.segmFrameCallback) + self.segmVideoMenu.triggered.connect(self.segmVideoCallback) + + self.postProcessSegmAction.toggled.connect(self.postProcessSegm) + self.autoSegmAction.toggled.connect(self.autoSegm_cb) + self.realTimeTrackingToggle.clicked.connect(self.realTimeTrackingClicked) + self.repeatTrackingAction.triggered.connect(self.repeatTracking) + self.manualTrackingButton.toggled.connect(self.manualTracking_cb) + self.manualBackgroundButton.toggled.connect(self.manualBackground_cb) + self.repeatTrackingMenuAction.triggered.connect(self.repeatTracking) + self.repeatTrackingVideoAction.triggered.connect(self.repeatTrackingVideo) + for rtTrackerAction in self.trackingAlgosGroup.actions(): + rtTrackerAction.toggled.connect(self.rtTrackerActionToggled) + self.editRtTrackerParamsAction.triggered.connect(self.initRealTimeTracker) + self.delObjsOutSegmMaskAction.triggered.connect( + self.delObjsOutSegmMaskActionTriggered ) - self._connect_method_if_present( - self.slideshowButton.toggled, "launchSlideshow" + self.mergeIDsButton.toggled.connect(self.mergeObjs_cb) + self.brushButton.toggled.connect(self.Brush_cb) + self.eraserButton.toggled.connect(self.Eraser_cb) + self.curvToolButton.toggled.connect(self.curvTool_cb) + self.wandToolButton.toggled.connect(self.wand_cb) + self.labelRoiButton.toggled.connect(self.labelRoi_cb) + self.magicPromptsToolButton.toggled.connect(self.magicPrompts_cb) + self.drawClearRegionButton.toggled.connect(self.drawClearRegion_cb) + self.reInitCcaAction.triggered.connect(self.reInitCca) + self.moveLabelToolButton.toggled.connect(self.moveLabelButtonToggled) + self.editCcaToolAction.triggered.connect( + self.manualEditCcaToolbarActionTriggered ) + self.assignBudMothAutoAction.triggered.connect(self.autoAssignBud_YeastMate) + self.keepIDsButton.toggled.connect(self.keepIDs_cb) - self._connect_method_if_present( - self.copyLostObjButton.toggled, "copyLostObjContour_cb" - ) - self._connect_method_if_present( - self.manualAnnotPastButton.toggled, "manualAnnotPast_cb" - ) + self.whitelistIDsButton.toggled.connect(self.whitelistIDs_cb) - self._connect_method_if_present( - self.segmSingleFrameMenu.triggered, "segmFrameCallback" - ) - self._connect_method_if_present( - self.segmVideoMenu.triggered, "segmVideoCallback" - ) + self.whitelistIDsToolbar.sigWhitelistChanged.connect(self.whitelistIDsChanged) - self._connect_method_if_present( - self.postProcessSegmAction.toggled, "postProcessSegm" - ) - self._connect_method_if_present(self.autoSegmAction.toggled, "autoSegm_cb") - self._connect_method_if_present( - self.realTimeTrackingToggle.clicked, "realTimeTrackingClicked" - ) - self._connect_method_if_present( - self.repeatTrackingAction.triggered, "repeatTracking" - ) - self._connect_method_if_present( - self.manualTrackingButton.toggled, "manualTracking_cb" - ) - self._connect_method_if_present( - self.manualBackgroundButton.toggled, "manualBackground_cb" - ) - self._connect_method_if_present( - self.repeatTrackingMenuAction.triggered, "repeatTracking" - ) - self._connect_method_if_present( - self.repeatTrackingVideoAction.triggered, "repeatTrackingVideo" - ) - for rtTrackerAction in self.trackingAlgosGroup.actions(): - self._connect_method_if_present( - rtTrackerAction.toggled, "rtTrackerActionToggled" - ) - self._connect_method_if_present( - self.editRtTrackerParamsAction.triggered, "initRealTimeTracker" - ) - self._connect_method_if_present( - self.delObjsOutSegmMaskAction.triggered, "delObjsOutSegmMaskActionTriggered" - ) - self._connect_method_if_present(self.mergeIDsButton.toggled, "mergeObjs_cb") - self._connect_method_if_present(self.brushButton.toggled, "Brush_cb") - self._connect_method_if_present(self.eraserButton.toggled, "Eraser_cb") - self._connect_method_if_present(self.curvToolButton.toggled, "curvTool_cb") - self._connect_method_if_present(self.wandToolButton.toggled, "wand_cb") - self._connect_method_if_present(self.labelRoiButton.toggled, "labelRoi_cb") - self._connect_method_if_present( - self.magicPromptsToolButton.toggled, "magicPrompts_cb" - ) - self._connect_method_if_present( - self.drawClearRegionButton.toggled, "drawClearRegion_cb" - ) - self._connect_method_if_present(self.reInitCcaAction.triggered, "reInitCca") - self._connect_method_if_present( - self.moveLabelToolButton.toggled, "moveLabelButtonToggled" - ) - self._connect_method_if_present( - self.editCcaToolAction.triggered, "manualEditCcaToolbarActionTriggered" - ) - self._connect_method_if_present( - self.assignBudMothAutoAction.triggered, "autoAssignBud_YeastMate" - ) - self._connect_method_if_present(self.keepIDsButton.toggled, "keepIDs_cb") + self.whitelistIDsToolbar.sigWhitelistAccepted.connect(self.whitelistIDsAccepted) - self._connect_method_if_present( - self.whitelistIDsButton.toggled, "whitelistIDs_cb" - ) + self.whitelistIDsToolbar.sigViewOGIDs.connect(self.whitelistViewOGIDs) - if hasattr(self, "whitelistIDsToolbar"): - self._connect_method_if_present( - self.whitelistIDsToolbar.sigWhitelistChanged, "whitelistIDsChanged" - ) - self._connect_method_if_present( - self.whitelistIDsToolbar.sigWhitelistAccepted, "whitelistIDsAccepted" - ) - self._connect_method_if_present( - self.whitelistIDsToolbar.sigViewOGIDs, "whitelistViewOGIDs" - ) - self._connect_method_if_present( - self.whitelistIDsToolbar.sigAddNewIDs, "whitelistAddNewIDsToggled" - ) - self._connect_method_if_present( - self.whitelistIDsToolbar.sigLoadOGLabs, "whitelistLoadOGLabs_cb" - ) - self._connect_method_if_present( - self.whitelistIDsToolbar.sigTrackOGagainstPreviousFrame, - "whitelistTrackOGagainstPreviousFrame_cb", - ) + self.whitelistIDsToolbar.sigAddNewIDs.connect(self.whitelistAddNewIDsToggled) - self._connect_method_if_present( - self.expandLabelToolButton.toggled, "expandLabelCallback" - ) + self.whitelistIDsToolbar.sigLoadOGLabs.connect(self.whitelistLoadOGLabs_cb) - self._connect_method_if_present( - self.reinitLastSegmFrameAction.triggered, "reInitLastSegmFrame" + self.whitelistIDsToolbar.sigTrackOGagainstPreviousFrame.connect( + self.whitelistTrackOGagainstPreviousFrame_cb ) - self._connect_method_if_present( - self.defaultRescaleIntensActionGroup.triggered, - "defaultRescaleIntensLutActionToggled", - ) + self.expandLabelToolButton.toggled.connect(self.expandLabelCallback) - self._connect_method_if_present( - self.manuallyEditCcaAction.triggered, "manualEditCca" - ) - self._connect_method_if_present(self.addScaleBarAction.toggled, "addScaleBar") - self._connect_method_if_present( - self.addTimestampAction.toggled, "addTimestamp" - ) - self._connect_method_if_present( - self.saveLabColormapAction.triggered, "saveLabelsColormap" - ) + self.reinitLastSegmFrameAction.triggered.connect(self.reInitLastSegmFrame) - self._connect_method_if_present( - self.enableSmartTrackAction.toggled, "enableSmartTrack" - ) - self._connect_method_if_present( - self.brushSizeSpinbox.valueChanged, "brushSize_cb" - ) - self._connect_method_if_present(self.autoIDcheckbox.toggled, "autoIDtoggled") - self._connect_method_if_present( - self.modeActionGroup.triggered, "changeModeFromMenu" - ) - self._connect_method_if_present( - self.modeComboBox.sigTextChanged, "changeMode" - ) - self._connect_method_if_present( - self.modeComboBox.activated, "clearComboBoxFocus" - ) - self._connect_method_if_present( - self.equalizeHistPushButton.toggled, "equalizeHist" + self.defaultRescaleIntensActionGroup.triggered.connect( + self.defaultRescaleIntensLutActionToggled ) - self._connect_method_if_present( - self.editOverlayColorAction.triggered, "toggleOverlayColorButton" - ) - self._connect_method_if_present( - self.editTextIDsColorAction.triggered, "toggleTextIDsColorButton" - ) - self._connect_method_if_present( - self.overlayColorButton.sigColorChanging, "changeOverlayColor" - ) - self._connect_method_if_present( - self.overlayColorButton.sigColorChanged, "saveOverlayColor" - ) - self._connect_method_if_present( - self.textIDsColorButton.sigColorChanging, "updateTextAnnotColor" - ) - self._connect_method_if_present( - self.textIDsColorButton.sigColorChanged, "saveTextIDsColors" - ) + # self.repeatAutoCcaAction.triggered.connect(self.repeatAutoCca) + self.manuallyEditCcaAction.triggered.connect(self.manualEditCca) + self.addScaleBarAction.toggled.connect(self.addScaleBar) + self.addTimestampAction.toggled.connect(self.addTimestamp) + self.saveLabColormapAction.triggered.connect(self.saveLabelsColormap) - self._connect_method_if_present( - self.setMeasurementsAction.triggered, "showSetMeasurements" - ) - self._connect_method_if_present( - self.addCustomMetricAction.triggered, "addCustomMetric" - ) - self._connect_method_if_present( - self.addCombineMetricAction.triggered, "addCombineMetric" - ) + self.enableSmartTrackAction.toggled.connect(self.enableSmartTrack) + # Brush/Eraser size action + self.brushSizeSpinbox.valueChanged.connect(self.brushSize_cb) + self.autoIDcheckbox.toggled.connect(self.autoIDtoggled) + # Mode + self.modeActionGroup.triggered.connect(self.changeModeFromMenu) + self.modeComboBox.sigTextChanged.connect(self.changeMode) + self.modeComboBox.activated.connect(self.clearComboBoxFocus) + self.equalizeHistPushButton.toggled.connect(self.equalizeHist) - self._connect_method_if_present( - self.labelsGrad.colorButton.sigColorChanging, "updateBkgrColor" - ) - self._connect_method_if_present( - self.labelsGrad.colorButton.sigColorChanged, "saveBkgrColor" - ) - self._connect_method_if_present( - self.labelsGrad.sigGradientChangeFinished, "updateLabelsCmap" - ) - self._connect_method_if_present( - self.labelsGrad.sigGradientChanged, "ticksCmapMoved" - ) - self._connect_method_if_present( - self.labelsGrad.textColorButton.sigColorChanging, "updateTextLabelsColor" - ) - self._connect_method_if_present( - self.labelsGrad.textColorButton.sigColorChanged, "saveTextLabelsColor" - ) + self.editOverlayColorAction.triggered.connect(self.toggleOverlayColorButton) + self.editTextIDsColorAction.triggered.connect(self.toggleTextIDsColorButton) + self.overlayColorButton.sigColorChanging.connect(self.changeOverlayColor) + self.overlayColorButton.sigColorChanged.connect(self.saveOverlayColor) + self.textIDsColorButton.sigColorChanging.connect(self.updateTextAnnotColor) + self.textIDsColorButton.sigColorChanged.connect(self.saveTextIDsColors) - self._connect_method_if_present( - self.labelsGrad.shuffleCmapAction.triggered, "shuffle_cmap" - ) - self._connect_method_if_present( - self.labelsGrad.greedyShuffleCmapAction.triggered, "greedyShuffleCmap" - ) - self._connect_method_if_present( - self.labelsGrad.permanentGreedyCmapAction.toggled, - "permanentGreedyCmapToggled", - ) - self._connect_method_if_present( - self.shuffleCmapAction.triggered, "shuffle_cmap" - ) - self._connect_method_if_present( - self.greedyShuffleCmapAction.triggered, "greedyShuffleCmap" - ) - self._connect_method_if_present( - self.labelsGrad.invertBwAction.toggled, "setCheckedInvertBW" + self.setMeasurementsAction.triggered.connect(self.showSetMeasurements) + self.addCustomMetricAction.triggered.connect(self.addCustomMetric) + self.addCombineMetricAction.triggered.connect(self.addCombineMetric) + + self.labelsGrad.colorButton.sigColorChanging.connect(self.updateBkgrColor) + self.labelsGrad.colorButton.sigColorChanged.connect(self.saveBkgrColor) + self.labelsGrad.sigGradientChangeFinished.connect(self.updateLabelsCmap) + self.labelsGrad.sigGradientChanged.connect(self.ticksCmapMoved) + self.labelsGrad.textColorButton.sigColorChanging.connect( + self.updateTextLabelsColor ) - self._connect_method_if_present( - self.labelsGrad.sigShowLabelsImgToggled, "showLabelImageItem" + self.labelsGrad.textColorButton.sigColorChanged.connect( + self.saveTextLabelsColor ) - self._connect_method_if_present( - self.labelsGrad.sigShowRightImgToggled, "showRightImageItem" + # self.addFontSizeActions( + # self.labelsGrad.fontSizeMenu, self.setFontSizeActionChecked + # ) + + self.labelsGrad.shuffleCmapAction.triggered.connect(self.shuffle_cmap) + self.labelsGrad.greedyShuffleCmapAction.triggered.connect( + self.greedyShuffleCmap ) - self._connect_method_if_present( - self.labelsGrad.sigShowNextFrameToggled, "showNextFrameImageItem" + self.labelsGrad.permanentGreedyCmapAction.toggled.connect( + self.permanentGreedyCmapToggled ) + self.shuffleCmapAction.triggered.connect(self.shuffle_cmap) + self.greedyShuffleCmapAction.triggered.connect(self.greedyShuffleCmap) + self.labelsGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) + self.labelsGrad.sigShowLabelsImgToggled.connect(self.showLabelImageItem) + self.labelsGrad.sigShowRightImgToggled.connect(self.showRightImageItem) + self.labelsGrad.sigShowNextFrameToggled.connect(self.showNextFrameImageItem) - self._connect_method_if_present( - self.labelsGrad.defaultSettingsAction.triggered, "restoreDefaultSettings" + self.labelsGrad.defaultSettingsAction.triggered.connect( + self.restoreDefaultSettings ) - self._connect_method_if_present( - self.imgGrad.invertBwAction.toggled, "setCheckedInvertBW" - ) + # self.addFontSizeActions( + # self.imgGrad.fontSizeMenu, self.setFontSizeActionChecked + # ) + self.imgGrad.invertBwAction.toggled.connect(self.setCheckedInvertBW) self.imgGrad.textColorButton.disconnect() - if hasattr(self, "editTextIDsColorAction"): - self.imgGrad.textColorButton.clicked.connect( - self.editTextIDsColorAction.trigger - ) - self._connect_method_if_present( - self.imgGrad.labelsAlphaSlider.valueChanged, "updateLabelsAlpha" + self.imgGrad.textColorButton.clicked.connect( + self.editTextIDsColorAction.trigger ) - self._connect_method_if_present( - self.imgGrad.defaultSettingsAction.triggered, "restoreDefaultSettings" + self.imgGrad.labelsAlphaSlider.valueChanged.connect(self.updateLabelsAlpha) + self.imgGrad.defaultSettingsAction.triggered.connect( + self.restoreDefaultSettings ) - self._connect_method_if_present( - self.drawIDsContComboBox.currentIndexChanged, "drawIDsContComboBox_cb" - ) - self._connect_method_if_present( - self.drawIDsContComboBox.activated, "clearComboBoxFocus" + # Drawing mode + self.drawIDsContComboBox.currentIndexChanged.connect( + self.drawIDsContComboBox_cb ) + self.drawIDsContComboBox.activated.connect(self.clearComboBoxFocus) - self._connect_method_if_present( - self.annotateRightHowCombobox.currentIndexChanged, - "annotateRightHowCombobox_cb", - ) - self._connect_method_if_present( - self.annotateRightHowCombobox.activated, "clearComboBoxFocus" + self.annotateRightHowCombobox.currentIndexChanged.connect( + self.annotateRightHowCombobox_cb ) + self.annotateRightHowCombobox.activated.connect(self.clearComboBoxFocus) - self._connect_method_if_present( - self.showTreeInfoCheckbox.toggled, "setAnnotInfoMode" - ) + self.showTreeInfoCheckbox.toggled.connect(self.setAnnotInfoMode) - self._connect_method_if_present( - self.annotIDsCheckbox.clicked, "annotOptionClicked" - ) - self._connect_method_if_present( - self.annotCcaInfoCheckbox.clicked, "annotOptionClicked" - ) - self._connect_method_if_present( - self.annotContourCheckbox.clicked, "annotOptionClicked" - ) - self._connect_method_if_present( - self.annotSegmMasksCheckbox.clicked, "annotOptionClicked" - ) - self._connect_method_if_present( - self.drawMothBudLinesCheckbox.clicked, "annotOptionClicked" - ) - self._connect_method_if_present( - self.drawNothingCheckbox.clicked, "annotOptionClicked" - ) - self._connect_method_if_present( - self.annotNumZslicesCheckbox.clicked, "annotOptionClicked" - ) + # Left + self.annotIDsCheckbox.clicked.connect(self.annotOptionClicked) + self.annotCcaInfoCheckbox.clicked.connect(self.annotOptionClicked) + self.annotContourCheckbox.clicked.connect(self.annotOptionClicked) + self.annotSegmMasksCheckbox.clicked.connect(self.annotOptionClicked) + self.drawMothBudLinesCheckbox.clicked.connect(self.annotOptionClicked) + self.drawNothingCheckbox.clicked.connect(self.annotOptionClicked) + self.annotNumZslicesCheckbox.clicked.connect(self.annotOptionClicked) - self._connect_method_if_present( - self.annotIDsCheckboxRight.clicked, "annotOptionClickedRight" - ) - self._connect_method_if_present( - self.annotCcaInfoCheckboxRight.clicked, "annotOptionClickedRight" - ) - self._connect_method_if_present( - self.annotContourCheckboxRight.clicked, "annotOptionClickedRight" - ) - self._connect_method_if_present( - self.annotSegmMasksCheckboxRight.clicked, "annotOptionClickedRight" - ) - self._connect_method_if_present( - self.drawMothBudLinesCheckboxRight.clicked, "annotOptionClickedRight" - ) - self._connect_method_if_present( - self.drawNothingCheckboxRight.clicked, "annotOptionClickedRight" - ) - self._connect_method_if_present( - self.annotNumZslicesCheckboxRight.clicked, "annotOptionClickedRight" - ) + # Right + self.annotIDsCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.annotCcaInfoCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.annotContourCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.annotSegmMasksCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.drawMothBudLinesCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.drawNothingCheckboxRight.clicked.connect(self.annotOptionClickedRight) + self.annotNumZslicesCheckboxRight.clicked.connect(self.annotOptionClickedRight) - self._connect_method_if_present( - self.segmentToolAction.triggered, "segmentToolActionTriggered" - ) + self.segmentToolAction.triggered.connect(self.segmentToolActionTriggered) - self._connect_method_if_present(self.addDelRoiAction.triggered, "addDelROI") - self._connect_method_if_present( - self.addDelPolyLineRoiButton.toggled, "addDelPolyLineRoi_cb" - ) - self._connect_method_if_present( - self.delBorderObjAction.triggered, "delBorderObj" - ) - self._connect_method_if_present(self.delNewObjAction.triggered, "delNewObj") + self.addDelRoiAction.triggered.connect(self.addDelROI) + self.addDelPolyLineRoiButton.toggled.connect(self.addDelPolyLineRoi_cb) + self.delBorderObjAction.triggered.connect(self.delBorderObj) + self.delNewObjAction.triggered.connect(self.delNewObj) - self._connect_method_if_present( - self.brushAutoFillCheckbox.toggled, "brushAutoFillToggled" - ) - self._connect_method_if_present( - self.brushAutoHideCheckbox.toggled, "brushAutoHideToggled" - ) + self.brushAutoFillCheckbox.toggled.connect(self.brushAutoFillToggled) + self.brushAutoHideCheckbox.toggled.connect(self.brushAutoHideToggled) self.imgGrad.sigAddScaleBar.connect(self.addScaleBarAction.setChecked) self.imgGrad.sigAddTimestamp.connect(self.addTimestampAction.setChecked) - self._connect_method_if_present( - self.imgGrad.gradient.sigGradientChangeFinished, "imgGradLUTfinished_cb" + self.imgGrad.gradient.sigGradientChangeFinished.connect( + self.imgGradLUTfinished_cb ) - self._connect_method_if_present( - self.imgPropertiesAction.triggered, "editImgProperties" - ) + # self.normalizeQActionGroup.triggered.connect( + # self.normaliseIntensitiesActionTriggered + # ) + self.imgPropertiesAction.triggered.connect(self.editImgProperties) - self._connect_method_if_present( - self.relabelSequentialAction.triggered, "relabelSequentialCallback" - ) + self.relabelSequentialAction.triggered.connect(self.relabelSequentialCallback) - self._connect_method_if_present( - self.zoomToObjsAction.triggered, "zoomToObjsActionCallback" - ) - self._connect_method_if_present(self.zoomOutAction.triggered, "zoomOut") - self._connect_method_if_present( - self.preprocessAction.triggered, "preprocessActionTriggered" - ) - self._connect_method_if_present( - self.combineChannelsAction.triggered, "combineChannelsActionTriggered" + self.zoomToObjsAction.triggered.connect(self.zoomToObjsActionCallback) + self.zoomOutAction.triggered.connect(self.zoomOut) + self.preprocessAction.triggered.connect(self.preprocessActionTriggered) + self.combineChannelsAction.triggered.connect( + self.combineChannelsActionTriggered ) - self._connect_method_if_present( - self.viewCcaTableAction.triggered, "viewCcaTable" - ) + self.viewCcaTableAction.triggered.connect(self.viewCcaTable) - self._connect_method_if_present( - self.guiTabControl.propsQGBox.idSB.valueChanged, "propsWidgetIDvalueChanged" + self.guiTabControl.propsQGBox.idSB.valueChanged.connect( + self.propsWidgetIDvalueChanged ) - self._connect_method_if_present( - self.guiTabControl.highlightCheckbox.toggled, - "highlightIDonHoverCheckBoxToggled", + self.guiTabControl.highlightCheckbox.toggled.connect( + self.highlightIDonHoverCheckBoxToggled ) - self._connect_method_if_present( - self.guiTabControl.highlightSearchedCheckbox.toggled, - "highlightSearchedIDcheckBoxToggled", + self.guiTabControl.highlightSearchedCheckbox.toggled.connect( + self.highlightSearchedIDcheckBoxToggled ) intensMeasurQGBox = self.guiTabControl.intensMeasurQGBox - self._connect_method_if_present( - intensMeasurQGBox.additionalMeasCombobox.currentTextChanged, - "updatePropsWidget", + intensMeasurQGBox.additionalMeasCombobox.currentTextChanged.connect( + self.updatePropsWidget ) - self._connect_method_if_present( - intensMeasurQGBox.channelCombobox.currentTextChanged, "updatePropsWidget" + intensMeasurQGBox.channelCombobox.currentTextChanged.connect( + self.updatePropsWidget ) propsQGBox = self.guiTabControl.propsQGBox - self._connect_method_if_present( - propsQGBox.additionalPropsCombobox.currentTextChanged, "updatePropsWidget" + propsQGBox.additionalPropsCombobox.currentTextChanged.connect( + self.updatePropsWidget ) def gui_createActions(self): @@ -699,8 +504,8 @@ def gui_createActions(self): self.EditSegForLostIDsSetSettings = QAction( "Edit settings for Segmenting lost IDs...", self ) - self._connect_method_if_present( - self.EditSegForLostIDsSetSettings.triggered, "SegForLostIDsSetSettings" + self.EditSegForLostIDsSetSettings.triggered.connect( + self.SegForLostIDsSetSettings ) self.repeatTrackingAction = QAction( @@ -959,7 +764,7 @@ def gui_updateSwitchColorSchemeActionText(self): self.toggleColorSchemeAction.setText(txt) def initShortcuts(self): - from cellacdc import config + from . import config cp = config.ConfigParser() if os.path.exists(shortcut_filepath): @@ -1030,7 +835,7 @@ def setShortcuts(self, shortcuts: dict, save=True): if not save: return - from cellacdc import config + from . import config cp = config.ConfigParser() if os.path.exists(shortcut_filepath): diff --git a/cellacdc/mixins/app_shell.py b/cellacdc/mixins/app_shell.py index 11bf8a166..7850c6442 100644 --- a/cellacdc/mixins/app_shell.py +++ b/cellacdc/mixins/app_shell.py @@ -14,7 +14,6 @@ base_cca_dict, cca_df_colnames, html_utils, - load, settings_csv_path, widgets, ) @@ -193,7 +192,7 @@ def initGlobalAttr(self): def initProfileModels(self): self.logger.info("Initiliazing profilers...") - from cellacdc._profile.spline_to_obj import model + from ._profile.spline_to_obj import model self.splineToObjModel = model.Model() diff --git a/cellacdc/mixins/image_controls.py b/cellacdc/mixins/image_controls.py index e94d4c4cd..8f9b98c14 100644 --- a/cellacdc/mixins/image_controls.py +++ b/cellacdc/mixins/image_controls.py @@ -17,10 +17,7 @@ QWidget, ) -import pyqtgraph as pg -import numpy as np - -from cellacdc import darkBkgrColor, graphLayoutBkgrColor, widgets +from cellacdc import widgets _font = QFont() _font.setPixelSize(11) diff --git a/cellacdc/mixins/label_roi.py b/cellacdc/mixins/label_roi.py index 8ec3c5f33..8a4d9cdaa 100644 --- a/cellacdc/mixins/label_roi.py +++ b/cellacdc/mixins/label_roi.py @@ -327,7 +327,7 @@ def labelRoiTrangeCheckboxToggled(self, checked): self.labelRoiStopFrameNoSpinbox.setValue(posData.SizeT) def labelRoiViewCurrentModel(self): - from cellacdc import config + from . import config ini_path = os.path.join(settings_folderpath, "last_params_segm_models.ini") configPars = config.ConfigParser() diff --git a/cellacdc/mixins/layout_controls.py b/cellacdc/mixins/layout_controls.py index 250aed448..7cefbe3fa 100644 --- a/cellacdc/mixins/layout_controls.py +++ b/cellacdc/mixins/layout_controls.py @@ -18,7 +18,6 @@ QLabel, QRadioButton, QSizePolicy, - QVBoxLayout, QWidget, ) @@ -30,13 +29,6 @@ from .label_roi import LabelRoi -def _connect_method_if_present(host, signal, method_name): - method = getattr(host, method_name, None) - if method is not None: - signal.connect(method) - - - class LayoutControls(ImageControls, WindowEvents, LabelRoi): """Extracted from guiWin.""" @@ -61,8 +53,8 @@ def gui_createControlsToolbar(self): self.overlayToolbar = widgets.OverlayToolbar(parent=self) self.addToolBar(Qt.TopToolBarArea, self.overlayToolbar) self.overlayToolbar.setVisible(False) - _connect_method_if_present(self, self.overlayToolbar.sigSetTranspacency, "setOverlayTransparency") - _connect_method_if_present(self, self.overlayToolbar.sigSetSingleChannel, "setOverlaySingleChannel") + self.overlayToolbar.sigSetTranspacency.connect(self.setOverlayTransparency) + self.overlayToolbar.sigSetSingleChannel.connect(self.setOverlaySingleChannel) self.autoPilotZoomToObjToolbar = widgets.ToolBar("Auto-zoom to objects", self) self.autoPilotZoomToObjToolbar.setContextMenuPolicy(Qt.PreventContextMenu) @@ -80,7 +72,7 @@ def gui_createControlsToolbar(self): self.highlightIDToolbar.keepVisibleWhenActive = True self.controlToolBars.append(self.highlightIDToolbar) - _connect_method_if_present(self, self.highlightIDToolbar.sigIDChanged, "setHighlighedIDfromToolbar") + self.highlightIDToolbar.sigIDChanged.connect(self.setHighlighedIDfromToolbar) # Widgets toolbar brushEraserToolBar = widgets.ToolBar("Widgets", self) @@ -240,18 +232,32 @@ def gui_createControlsToolbar(self): self.loadLabelRoiLastParams() - _connect_method_if_present(self, self.labelRoiTrangeCheckbox.toggled, "labelRoiTrangeCheckboxToggled") - _connect_method_if_present(self, self.labelRoiReplaceExistingObjectsCheckbox.toggled, "storeLabelRoiParams") - _connect_method_if_present(self, self.labelRoiIsCircularRadioButton.toggled, "labelRoiIsCircularRadioButtonToggled") - _connect_method_if_present(self, self.labelRoiCircularRadiusSpinbox.valueChanged, "updateLabelRoiCircularSize") - _connect_method_if_present(self, self.labelRoiCircularRadiusSpinbox.valueChanged, "storeLabelRoiParams") - _connect_method_if_present(self, self.labelRoiZdepthSpinbox.valueChanged, "storeLabelRoiParams") - _connect_method_if_present(self, self.labelRoiAutoClearBorderCheckbox.toggled, "storeLabelRoiParams") - _connect_method_if_present(self, group.buttonToggled, "storeLabelRoiParams") + self.labelRoiTrangeCheckbox.toggled.connect(self.labelRoiTrangeCheckboxToggled) + self.labelRoiReplaceExistingObjectsCheckbox.toggled.connect( + self.storeLabelRoiParams + ) + self.labelRoiIsCircularRadioButton.toggled.connect( + self.labelRoiIsCircularRadioButtonToggled + ) + self.labelRoiCircularRadiusSpinbox.valueChanged.connect( + self.updateLabelRoiCircularSize + ) + self.labelRoiCircularRadiusSpinbox.valueChanged.connect( + self.storeLabelRoiParams + ) + self.labelRoiZdepthSpinbox.valueChanged.connect(self.storeLabelRoiParams) + self.labelRoiAutoClearBorderCheckbox.toggled.connect(self.storeLabelRoiParams) + group.buttonToggled.connect(self.storeLabelRoiParams) - _connect_method_if_present(self, self.labelRoiToEndFramesAction.triggered, "labelRoiToEndFramesTriggered") - _connect_method_if_present(self, self.labelRoiFromCurrentFrameAction.triggered, "labelRoiFromCurrentFrameTriggered") - _connect_method_if_present(self, self.labelRoiViewCurrentModelAction.triggered, "labelRoiViewCurrentModel") + self.labelRoiToEndFramesAction.triggered.connect( + self.labelRoiToEndFramesTriggered + ) + self.labelRoiFromCurrentFrameAction.triggered.connect( + self.labelRoiFromCurrentFrameTriggered + ) + self.labelRoiViewCurrentModelAction.triggered.connect( + self.labelRoiViewCurrentModel + ) self.keepIDsToolbar = widgets.ToolBar("Keep IDs controls", self) self.keepIDsConfirmAction = QAction() @@ -274,14 +280,14 @@ def gui_createControlsToolbar(self): self.keepIDsToolbar.setVisible(False) self.controlToolBars.append(self.keepIDsToolbar) - _connect_method_if_present(self, self.keptIDsLineEdit.sigEnterPressed, "applyKeepObjects") - _connect_method_if_present(self, self.keptIDsLineEdit.sigIDsChanged, "updateKeepIDs") - _connect_method_if_present(self, self.keepIDsConfirmAction.triggered, "applyKeepObjects") + self.keptIDsLineEdit.sigEnterPressed.connect(self.applyKeepObjects) + self.keptIDsLineEdit.sigIDsChanged.connect(self.updateKeepIDs) + self.keepIDsConfirmAction.triggered.connect(self.applyKeepObjects) # closeToolbarAction = QAction( # QIcon(":cancelButton.svg"), "Close toolbar...", self # ) - # _connect_method_if_present(self, closeToolbarAction.triggered, "closeToolbars") + # closeToolbarAction.triggered.connect(self.closeToolbars) # self.autoPilotZoomToObjToolbar.addAction(closeToolbarAction) self.autoPilotZoomToObjToolbar.addWidget(widgets.QVLine()) @@ -294,13 +300,13 @@ def gui_createControlsToolbar(self): spinBox.label = QLabel(" Zoom to ID: ") spinBox.labelAction = self.autoPilotZoomToObjToolbar.addWidget(spinBox.label) spinBox.action = self.autoPilotZoomToObjToolbar.addWidget(spinBox) - _connect_method_if_present(self, spinBox.editingFinished, "zoomToObj") - _connect_method_if_present(self, spinBox.sigUpClicked, "autoZoomNextObj") - _connect_method_if_present(self, spinBox.sigDownClicked, "autoZoomPrevObj") + spinBox.editingFinished.connect(self.zoomToObj) + spinBox.sigUpClicked.connect(self.autoZoomNextObj) + spinBox.sigDownClicked.connect(self.autoZoomPrevObj) self.autoPilotZoomToObjSpinBox = spinBox toggle = widgets.Toggle() self.autoPilotZoomToObjToggle = toggle - _connect_method_if_present(self, toggle.toggled, "autoPilotZoomToObjToggled") + toggle.toggled.connect(self.autoPilotZoomToObjToggled) toggle.label = QLabel(" Auto-pilot: ") tooltip = ( "When auto-pilot is active, you can use Up/Down arrows to " @@ -318,7 +324,7 @@ def gui_createControlsToolbar(self): self.pointsLayersToolbar = widgets.PointsLayersToolbar(parent=self) self.pointsLayersToolbar.setContextMenuPolicy(Qt.PreventContextMenu) - _connect_method_if_present(self, self.pointsLayersToolbar.sigAddPointsLayer, "addPointsLayerTriggered") + self.pointsLayersToolbar.sigAddPointsLayer.connect(self.addPointsLayerTriggered) self.addToolBar(Qt.TopToolBarArea, self.pointsLayersToolbar) @@ -331,11 +337,13 @@ def gui_createControlsToolbar(self): self.manualTrackingToolbar = widgets.ManualTrackingToolBar( "Manual tracking controls", self ) - _connect_method_if_present(self, self.manualTrackingToolbar.sigIDchanged, "initGhostObject") - _connect_method_if_present(self, self.manualTrackingToolbar.sigDisableGhost, "clearGhost") - _connect_method_if_present(self, self.manualTrackingToolbar.sigClearGhostContour, "clearGhostContour") - _connect_method_if_present(self, self.manualTrackingToolbar.sigClearGhostMask, "clearGhostMask") - _connect_method_if_present(self, self.manualTrackingToolbar.sigGhostOpacityChanged, "updateGhostMaskOpacity") + self.manualTrackingToolbar.sigIDchanged.connect(self.initGhostObject) + self.manualTrackingToolbar.sigDisableGhost.connect(self.clearGhost) + self.manualTrackingToolbar.sigClearGhostContour.connect(self.clearGhostContour) + self.manualTrackingToolbar.sigClearGhostMask.connect(self.clearGhostMask) + self.manualTrackingToolbar.sigGhostOpacityChanged.connect( + self.updateGhostMaskOpacity + ) self.addToolBar(Qt.TopToolBarArea, self.manualTrackingToolbar) self.manualTrackingToolbar.setVisible(False) @@ -344,7 +352,9 @@ def gui_createControlsToolbar(self): self.manualBackgroundToolbar = widgets.ManualBackgroundToolBar( "Manual background controls", self ) - _connect_method_if_present(self, self.manualBackgroundToolbar.sigIDchanged, "initManualBackgroundObject") + self.manualBackgroundToolbar.sigIDchanged.connect( + self.initManualBackgroundObject + ) self.addToolBar(Qt.TopToolBarArea, self.manualBackgroundToolbar) self.manualBackgroundToolbar.setVisible(False) self.controlToolBars.append(self.manualBackgroundToolbar) @@ -356,7 +366,7 @@ def gui_createControlsToolbar(self): for name, action in self.copyLostObjToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - _connect_method_if_present(self, self.copyLostObjToolbar.sigCopyAllObjects, "copyAllLostObjects") + self.copyLostObjToolbar.sigCopyAllObjects.connect(self.copyAllLostObjects) self.addToolBar(Qt.TopToolBarArea, self.copyLostObjToolbar) self.copyLostObjToolbar.setVisible(False) @@ -392,18 +402,27 @@ def gui_createControlsToolbar(self): for name, action in self.magicPromptsToolbar.widgetsWithShortcut.items(): self.widgetsWithShortcut[name] = action - _connect_method_if_present(self, self.magicPromptsToolbar.sigComputeOnZoom, "magicPromptsComputeOnZoomTriggered") - _connect_method_if_present(self, self.magicPromptsToolbar.sigComputeOnImage, "magicPromptsComputeOnImageTriggered") - _connect_method_if_present(self, self.magicPromptsToolbar.sigInitSelectedModel, "magicPromptsInitModel") - _connect_method_if_present(self, self.magicPromptsToolbar.sigViewModelParams, "viewSetMagicPromptModelParams") - if hasattr(self, "magicPromptsClearPoints"): - self.magicPromptsToolbar.sigClearPoints.connect( - partial(self.magicPromptsClearPoints, only_zoom=False) - ) - self.magicPromptsToolbar.sigClearPointsOnZmom.connect( - partial(self.magicPromptsClearPoints, only_zoom=True) - ) - _connect_method_if_present(self, self.magicPromptsToolbar.sigInterpolateZslice, "magicPromptsInterpolateZsliceToggled") + self.magicPromptsToolbar.sigComputeOnZoom.connect( + self.magicPromptsComputeOnZoomTriggered + ) + self.magicPromptsToolbar.sigComputeOnImage.connect( + self.magicPromptsComputeOnImageTriggered + ) + self.magicPromptsToolbar.sigInitSelectedModel.connect( + self.magicPromptsInitModel + ) + self.magicPromptsToolbar.sigViewModelParams.connect( + self.viewSetMagicPromptModelParams + ) + self.magicPromptsToolbar.sigClearPoints.connect( + partial(self.magicPromptsClearPoints, only_zoom=False) + ) + self.magicPromptsToolbar.sigClearPointsOnZmom.connect( + partial(self.magicPromptsClearPoints, only_zoom=True) + ) + self.magicPromptsToolbar.sigInterpolateZslice.connect( + self.magicPromptsInterpolateZsliceToggled + ) self.addToolBar(Qt.TopToolBarArea, self.magicPromptsToolbar) self.magicPromptsToolbar.setVisible(False) @@ -459,9 +478,11 @@ def gui_createMainLayout(self): row += 1 self.resizeBottomLayoutLine = widgets.VerticalResizeHline() mainLayout.addWidget(self.resizeBottomLayoutLine, row, col, 1, 2) - _connect_method_if_present(self, self.resizeBottomLayoutLine.dragged, "resizeBottomLayoutLineDragged") - _connect_method_if_present(self, self.resizeBottomLayoutLine.clicked, "resizeBottomLayoutLineClicked") - _connect_method_if_present(self, self.resizeBottomLayoutLine.released, "resizeBottomLayoutLineReleased") + self.resizeBottomLayoutLine.dragged.connect(self.resizeBottomLayoutLineDragged) + self.resizeBottomLayoutLine.clicked.connect(self.resizeBottomLayoutLineClicked) + self.resizeBottomLayoutLine.released.connect( + self.resizeBottomLayoutLineReleased + ) # row += 1 # mainLayout.addItem(QSpacerItem(5,5), row+1, col, 1, 2) @@ -559,7 +580,9 @@ def gui_populateToolSettingsMenu(self): self.brushHoverCenterModeAction.setChecked(useCenterBrushCursorHoverID) self.brushHoverCircleModeAction.setChecked(not useCenterBrushCursorHoverID) - _connect_method_if_present(self, self.brushHoverCenterModeAction.toggled, "useCenterBrushCursorHoverIDtoggled") + self.brushHoverCenterModeAction.toggled.connect( + self.useCenterBrushCursorHoverIDtoggled + ) self.settingsMenu.addSeparator() @@ -605,7 +628,7 @@ def gui_populateToolSettingsMenu(self): action.setChecked(True) else: all_checked = False - _connect_method_if_present(self, action.toggled, "keepToolActiveActionToggled") + action.toggled.connect(self.keepToolActiveActionToggled) menu.addAction(action) self.keepToolActiveActions[toolName] = action @@ -614,7 +637,7 @@ def gui_populateToolSettingsMenu(self): action = QAction(button) action.setText("Apply when visitng new frame") action.setCheckable(True) - _connect_method_if_present(self, action.toggled, "applyToolNewFrameActionToggled") + action.toggled.connect(self.applyToolNewFrameActionToggled) menu.addAction(action) self.applyToolNewFrameActions[toolName] = action self.applyToolNewFrameButtons[toolName] = button @@ -634,7 +657,9 @@ def gui_populateToolSettingsMenu(self): self.keepAllToolsActiveToggle.setText("Keep all tools active after using them") self.keepAllToolsActiveToggle.setCheckable(True) self.keepAllToolsActiveToggle.setChecked(all_checked) - _connect_method_if_present(self, self.keepAllToolsActiveToggle.toggled, "keepAllToolsActiveActionToggled") + self.keepAllToolsActiveToggle.toggled.connect( + self.keepAllToolsActiveActionToggled + ) self.settingsMenu.addAction(self.keepAllToolsActiveToggle) self.settingsMenu.addSeparator() diff --git a/cellacdc/mixins/main_menu.py b/cellacdc/mixins/main_menu.py index 15014f529..4ce6c14aa 100644 --- a/cellacdc/mixins/main_menu.py +++ b/cellacdc/mixins/main_menu.py @@ -135,8 +135,7 @@ def gui_createMenuBar(self): SegmMenu.addAction(self.postProcessSegmAction) SegmMenu.addAction(self.autoSegmAction) SegmMenu.addAction(self.relabelSequentialAction) - if hasattr(self, "nonViewerEditMenuOpened"): - SegmMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) + SegmMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) # Tracking menu trackingMenu = menuBar.addMenu("&Tracking") @@ -152,8 +151,7 @@ def gui_createMenuBar(self): trackingMenu.addAction(self.repeatTrackingVideoAction) trackingMenu.addAction(self.repeatTrackingMenuAction) - if hasattr(self, "nonViewerEditMenuOpened"): - trackingMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) + trackingMenu.aboutToShow.connect(self.nonViewerEditMenuOpened) if self.mainWin is not None: trackingMenu.addAction(self.mainWin.applyTrackingFromTableAction) diff --git a/cellacdc/mixins/main_toolbar.py b/cellacdc/mixins/main_toolbar.py index 02b6dad06..12dba9747 100644 --- a/cellacdc/mixins/main_toolbar.py +++ b/cellacdc/mixins/main_toolbar.py @@ -288,9 +288,7 @@ def gui_createToolBars(self): self.segForLostIDsButton = QToolButton(self) self.segForLostIDsButton.setIcon(QIcon(":segForLostIDs.svg")) self.segForLostIDsAction = editToolBar.addWidget(self.segForLostIDsButton) - self._connect_method_if_present( - self.segForLostIDsButton.clicked, "segForLostIDsButtonClicked" - ) + self.segForLostIDsButton.clicked.connect(self.segForLostIDsButtonClicked) # self.SegForLostIDsButton.setShortcut('U') # self.widgetsWithShortcut['Unknown lineage (lineage tree)'] = self.SegForLostIDsButton @@ -526,9 +524,7 @@ def gui_createToolBars(self): self.widgetsWithShortcut["Propagate (lineage tree)"] = ( self.propagateLinTreeButton ) - self._connect_method_if_present( - self.propagateLinTreeButton.clicked, "propagateLinTreeAction" - ) + self.propagateLinTreeButton.clicked.connect(self.propagateLinTreeAction) self.viewLinTreeInfoButton = QToolButton(self) self.viewLinTreeInfoButton.setIcon(QIcon(":addCustomAnnotation.svg")) @@ -537,9 +533,7 @@ def gui_createToolBars(self): self.widgetsWithShortcut["View Changes (lineage tree)"] = ( self.viewLinTreeInfoButton ) - self._connect_method_if_present( - self.viewLinTreeInfoButton.clicked, "viewLinTreeInfoAction" - ) + self.viewLinTreeInfoButton.clicked.connect(self.viewLinTreeInfoAction) modes_available = [ "Segmentation and Tracking", diff --git a/cellacdc/mixins/session.py b/cellacdc/mixins/session.py index 9e7aded2f..fd98da6fa 100644 --- a/cellacdc/mixins/session.py +++ b/cellacdc/mixins/session.py @@ -6,7 +6,6 @@ from functools import partial import numpy as np -import pandas as pd import skimage.measure from qtpy.QtWidgets import QAction diff --git a/cellacdc/mixins/tool_activation.py b/cellacdc/mixins/tool_activation.py index 1552a259a..d0c3f3f1a 100644 --- a/cellacdc/mixins/tool_activation.py +++ b/cellacdc/mixins/tool_activation.py @@ -502,8 +502,7 @@ def onEscape( self.clearTempBrushImage() self.isMouseDragImg1 = False self.typingEditID = False - if hasattr(self, "clearHighlightedID"): - self.clearHighlightedID() + self.clearHighlightedID() try: self.polyLineRoi.clearPoints() except Exception as e: diff --git a/pyproject.toml b/pyproject.toml index 421d080b4..e96d7a741 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,9 +142,6 @@ dev = [ cellacdc = "cellacdc.__main__:run" acdc = "cellacdc.__main__:run" Cell-ACDC = "cellacdc.__main__:run" -gui = "cellacdc.gui_visualization:main" -gui-basic = "cellacdc.gui_basic:main" -gui-segmentation = "cellacdc.gui_segmentation:main" [tool.setuptools] include-package-data = true From 5b798db223abb44e5d8cc7f317eb6a0e9de696c3 Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 15:03:55 +0200 Subject: [PATCH 15/21] Extract reusable GUI widgets into cellacdc/components modules. Split widgets.py into focused component modules while keeping widgets.py and _base_widgets.py as compatibility re-exports. Co-authored-by: Cursor --- cellacdc/_base_widgets.py | 39 +- cellacdc/components/__init__.py | 1 + cellacdc/components/base.py | 65 + cellacdc/components/buttons.py | 691 +++++++++ cellacdc/components/inputs_basic.py | 167 ++ cellacdc/components/layout.py | 256 +++ cellacdc/components/lists.py | 542 +++++++ cellacdc/components/palette.py | 131 ++ cellacdc/components/path_controls.py | 74 + cellacdc/components/progress.py | 245 +++ cellacdc/widgets.py | 2143 ++------------------------ tests/test_components_imports.py | 27 + 12 files changed, 2297 insertions(+), 2084 deletions(-) create mode 100644 cellacdc/components/__init__.py create mode 100644 cellacdc/components/base.py create mode 100644 cellacdc/components/buttons.py create mode 100644 cellacdc/components/inputs_basic.py create mode 100644 cellacdc/components/layout.py create mode 100644 cellacdc/components/lists.py create mode 100644 cellacdc/components/palette.py create mode 100644 cellacdc/components/path_controls.py create mode 100644 cellacdc/components/progress.py create mode 100644 tests/test_components_imports.py diff --git a/cellacdc/_base_widgets.py b/cellacdc/_base_widgets.py index 09423e2b3..b5874bc47 100644 --- a/cellacdc/_base_widgets.py +++ b/cellacdc/_base_widgets.py @@ -1,38 +1,3 @@ -from qtpy.QtWidgets import QDialog -from . import printl -from qtpy.QtCore import Qt, QEventLoop +from .components.base import QBaseDialog - -class QBaseDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - - def exec_(self, resizeWidthFactor=None): - if resizeWidthFactor is not None: - self.show() - self.resize(int(self.width() * resizeWidthFactor), self.height()) - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - - try: - self.setEnabled(True) - except Exception as err: - pass - - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - def keyPressEvent(self, event) -> None: - if event.key() == Qt.Key_Escape: - event.ignore() - return - - super().keyPressEvent(event) +__all__ = ["QBaseDialog"] diff --git a/cellacdc/components/__init__.py b/cellacdc/components/__init__.py new file mode 100644 index 000000000..ef991cf0b --- /dev/null +++ b/cellacdc/components/__init__.py @@ -0,0 +1 @@ +"""Reusable GUI components extracted from widgets.py and apps.py.""" diff --git a/cellacdc/components/base.py b/cellacdc/components/base.py new file mode 100644 index 000000000..06e13c336 --- /dev/null +++ b/cellacdc/components/base.py @@ -0,0 +1,65 @@ +from qtpy.QtCore import QEventLoop, Qt +from qtpy.QtWidgets import QDialog, QMainWindow + +from .. import printl + + +class QBaseDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + + def exec_(self, resizeWidthFactor=None): + if resizeWidthFactor is not None: + self.show() + self.resize(int(self.width() * resizeWidthFactor), self.height()) + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + + try: + self.setEnabled(True) + except Exception as err: + pass + + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + def keyPressEvent(self, event) -> None: + if event.key() == Qt.Key_Escape: + event.ignore() + return + + super().keyPressEvent(event) + + +class QBaseWindow(QMainWindow): + def __init__(self, parent=None): + super().__init__(parent) + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + def keyPressEvent(self, event) -> None: + if event.key() == Qt.Key_Escape: + event.ignore() + return + + super().keyPressEvent(event) diff --git a/cellacdc/components/buttons.py b/cellacdc/components/buttons.py new file mode 100644 index 000000000..00eec101c --- /dev/null +++ b/cellacdc/components/buttons.py @@ -0,0 +1,691 @@ +import os +from functools import partial + +from qtpy.QtCore import ( + QEvent, + QTimer, + Qt, + QUrl, + QSize, +) +from qtpy.QtGui import ( + QBrush, + QIcon, + QLinearGradient, + QPainter, + QPixmap, +) +from qtpy.QtWidgets import ( + QApplication, + QFileDialog, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QWidget, + QWidgetAction, +) + +from .. import myutils + +class PushButton(QPushButton): + def __init__( + self, *args, icon=None, alignIconLeft=False, flat=False, hoverable=False + ): + super().__init__(*args) + if icon is not None: + self.setIcon(icon) + self.alignIconLeft = alignIconLeft + self._text = None + if flat: + self.setFlat(True) + if hoverable: + self.installEventFilter(self) + + def setRetainSizeWhenHidden(self, retainSize): + sp = self.sizePolicy() + sp.setRetainSizeWhenHidden(retainSize) + self.setSizePolicy(sp) + + def eventFilter(self, object, event): + if event.type() == QEvent.Type.HoverEnter: + self.setFlat(False) + elif event.type() == QEvent.Type.HoverLeave: + self.setFlat(True) + return False + + def show(self): + text = self.text() + if not self.alignIconLeft: + super().show() + return + + self._text = text + self.setStyleSheet("text-align:left;") + self.setLayout(QGridLayout()) + textLabel = QLabel(self._text) + textLabel.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + textLabel.setAttribute(Qt.WA_TransparentForMouseEvents, True) + self._layout().addWidget(textLabel) + super().show() + + def confirmAction(self): + self.baseIcon = self.icon() + self.setIcon(QIcon(":greenTick.svg")) + QTimer.singleShot(2000, self.resetButton) + + def resetButton(self): + self.setIcon(self.baseIcon) + + def setText(self, text): + if self._text is None: + super().setText(text) + else: + super().setText(self._text) + + +class LoadPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":fork_lift.svg")) + + +class mergePushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":merge-IDs.svg")) + + +class okPushButton(PushButton): + def __init__(self, *args, isDefault=True, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":yesGray.svg")) + if isDefault: + self.setDefault(True) + # QShortcut(Qt.Key_Return, self, self.click) + # QShortcut(Qt.Key_Enter, self, self.click) + + +class MagnifyingGlassPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":magnGlass.svg")) + + +class MagnifyingGlassAllPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":magnGlass_all.svg")) + + +class AssignNewIDButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":assign_new_id.svg")) + + +class LockPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":lock.svg")) + self.toggled.connect(self.onToggled) + + def onToggled(self, checked): + if not self.isCheckable(): + return + + if checked: + self.setIcon(QIcon(":lock_closed.svg")) + else: + self.setIcon(QIcon(":lock_open.svg")) + + def setCheckable(self, checkable: bool): + super().setCheckable(checkable) + if checkable: + self.setIcon(QIcon(":lock_open.svg")) + else: + self.setIcon(QIcon(":lock.svg")) + + +class SkipPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":skip_arrow.svg")) + + +class BedPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":bed.svg")) + + +class BedPlusLabelPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":bed_plus_label.svg")) + iconH = self.iconSize().height() + iconW = int(iconH * 2.5) + self.setIconSize(QSize(iconW, iconH)) + + +class NoBedPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":no_bed.svg")) + + +class NavigatePushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":navigate.svg")) + + +class SwitchPlaneButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":switch_2d_plane.svg")) + self._planes = ("xy", "zy", "zx") + self._idx = 0 + + def switchPlane(self): + self._idx += 1 + + def setPlane(self, plane): + self._idx = self._planes.index(plane) + + def plane(self): + return self._planes[self._idx % 3] + + def depthAxes(self): + plane = self.plane() + for axes in "xyz": + if axes not in plane: + return axes + + +class zoomPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":zoom_out.svg")) + + def setIconZoomOut(self): + self.setIcon(QIcon(":zoom_out.svg")) + + def setIconZoomIn(self): + self.setIcon(QIcon(":zoom_in.svg")) + + +class WarningButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":warning.svg")) + + +class reloadPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":reload.svg")) + + +class savePushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":file-save.svg")) + + +class autoPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":cog_play.svg")) + + +class newFilePushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":file-new.svg")) + + +class helpPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":help.svg")) + + +class viewPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":eye.svg")) + + +class infoPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":info.svg")) + + +class threeDPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":3d.svg")) + + +class twoDPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":2d.svg")) + + +class addPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":add.svg")) + + +class futurePushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":arrow_future.svg")) + + +class FutureAllPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":arrow_future_all.svg")) + + +class currentPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":arrow_current.svg")) + + +class arrowUpPushButton(PushButton): + def __init__(self, *args, **kwargs): + alignIconLeft = kwargs.get("alignIconLeft", False) + super().__init__( + *args, icon=QIcon(":arrow-up.svg"), alignIconLeft=alignIconLeft + ) + + +class arrowDownPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":arrow-down.svg")) + + +class selectAllPushButton(PushButton): + sigClicked = Signal(object, bool) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._status = "deselect" + self.setIcon(QIcon(":deselect_all.svg")) + self.setText("Deselect all") + self.clicked.connect(self.onClicked) + self.setMinimumWidth(self.sizeHint().width()) + + def setChecked(self, checked): + if checked: + self._status == "deselect" + else: + self._status == "select" + self.click() + + def onClicked(self): + if self._status == "select": + icon_fn = ":deselect_all.svg" + self._status = "deselect" + checked = True + text = "Deselect all" + else: + icon_fn = ":select_all.svg" + text = "Select all" + self._status = "select" + checked = False + self.setIcon(QIcon(icon_fn)) + self.setText(text) + self.sigClicked.emit(self, checked) + + +class subtractPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":subtract.svg")) + + +class continuePushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":continue.svg")) + + +class calcPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":calc.svg")) + + +class playPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":play.svg")) + + +class stopPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":stop.svg")) + + +class copyPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":edit-copy.svg")) + self.clicked.connect(self.onClicked) + self._text_to_copy = None + + def setTextToCopy(self, text): + self._text_to_copy = text + + def onClicked(self): + self._original_text = self.text() + if self._text_to_copy is not None: + cb = QApplication.clipboard() + cb.clear(mode=cb.Clipboard) + cb.setText(self._text_to_copy, mode=cb.Clipboard) + + super().setText("Copied!") + self.setIcon(QIcon(":greenTick.svg")) + QTimer.singleShot(2000, self.resetButton) + + def resetButton(self): + self.setText(self._original_text) + self.setIcon(QIcon(":edit-copy.svg")) + + +class OpenFilePushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":folder-open.svg")) + + +class movePushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":folder-move.svg")) + + +class DownloadPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":download.svg")) + + +class showInFileManagerButton(PushButton): + def __init__(self, *args, setDefaultText=False, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":drawer.svg")) + self._path_to_browse = None + if setDefaultText: + self.setDefaultText() + + def setDefaultText(self): + self._text = myutils.get_show_in_file_manager_text() + self.setText(self._text) + + def setPathToBrowse(self, path: os.PathLike): + self._path_to_browse = path + self.clicked.connect(partial(myutils.showInExplorer, path)) + + +class OpenUrlButton(PushButton): + def __init__(self, url, *args, **kwargs): + self._url = url + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":browser.svg")) + self.clicked.connect(self.openUrl) + + def openUrl(self): + QDesktopServices.openUrl(QUrl(self._url)) + + +class LessThanPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":less_than.svg")) + flat = kwargs.get("flat") + if flat is not None: + self.setFlat(True) + + +class showDetailsButton(PushButton): + sigToggled = Signal(bool) + + def __init__(self, *args, txt="Show details...", parent=None): + super().__init__(txt, parent) + # self.setText(txt) + self.txt = txt + self.checkedIcon = QIcon(":hideUp.svg") + self.uncheckedIcon = QIcon(":showDown.svg") + self.setIcon(self.uncheckedIcon) + self.toggled.connect(self.onClicked) + self.setCheckable(True) + w = self.sizeHint().width() + 10 + self.setFixedWidth(w) + + def onClicked(self, checked): + if checked: + self.setText(self.txt.replace("Show", "Hide")) + self.setIcon(self.checkedIcon) + else: + self.setText(self.txt) + self.setIcon(self.uncheckedIcon) + + self.sigToggled.emit(checked) + + +class cancelPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":cancelButton.svg")) + + +class setPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":cog.svg")) + + +class TrainPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":train.svg")) + + +class noPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":no.svg")) + + +class editPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":edit-id.svg")) + + +class delPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":bin.svg")) + + +class eraserPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":eraser.svg")) + + +class CrossCursorPointButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":cross_cursor.svg")) + + +class TestPushButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":test.svg")) + + +class browseFileButton(PushButton): + sigPathSelected = Signal(str) + + def __init__( + self, + *args, + ext=None, + title="Select file", + start_dir="", + openFolder=False, + **kwargs, + ): + """PushButton with sigPathSelected Signal to select file or folder + + Parameters + ---------- + ext : dict or None, optional + If not None, this is a dictionary of + {'FILE NAME': ['.ext1', '.ext2', ...]}. + For example, to allow only selection of CSV files, + pass {'CSV': ['.csv']}. + + Note that the 'FILE NAME' is arbitrary. Default is None + title : str, optional + Title of the File Manager window. Default is 'Select file' + start_dir : str, optional + Directory where the File Manager window will initially be open. + Default is '' + openFolder : bool, optional + If True, allows for selection of folders instead of files. + Default is False + """ + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":folder-open.svg")) + self.clicked.connect(self.browse) + + self._title = title + self._start_dir = start_dir + self._openFolder = openFolder + self._file_types = "All Files (*)" + if ext is not None: + s_li = [] + for name, extensions in ext.items(): + _s = "" + if isinstance(extensions, str): + extensions = [extensions] + for ext in extensions: + _s = f"{_s}*{ext} " + s_li.append(f"{name} {_s.strip()}") + + self._file_types = ";;".join(s_li) + self._file_types = f"{self._file_types};;All Files (*)" + + def setStartPath(self, start_path): + self._start_dir = start_path + + def browse(self): + if self._openFolder: + fileDialog = QFileDialog.getExistingDirectory + args = (self, self._title, self._start_dir) + else: + fileDialog = QFileDialog.getOpenFileName + args = (self, self._title, self._start_dir, self._file_types) + file_path = fileDialog(*args) + if not isinstance(file_path, str): + file_path = file_path[0] + if file_path: + self.sigPathSelected.emit(file_path) + + +def getPushButton(buttonText, qparent=None): + isCancelButton = ( + buttonText.lower().find("cancel") != -1 + or buttonText.lower().find("abort") != -1 + ) + isYesButton = ( + buttonText.lower().find("yes") != -1 + or buttonText.lower().find("ok") != -1 + or buttonText.lower().find("continue") != -1 + or buttonText.lower().find("recommended") != -1 + ) + isSettingsButton = buttonText.lower().find("set") != -1 + isNoButton = ( + buttonText.replace(" ", "").lower() == "no" + or buttonText.lower().find("Do not ") != -1 + or buttonText.lower().find("no, ") != -1 + ) + isDelButton = buttonText.lower().find("delete") != -1 + isAddButton = buttonText.lower().find("add ") != -1 + is3Dbutton = buttonText.find(" 3D ") != -1 + is2Dbutton = buttonText.find(" 2D ") != -1 + isSaveButton = buttonText.lower().find("overwrite") != -1 + isNewFileButton = buttonText.lower().find("rename") != -1 + isTryAgainButton = buttonText.lower().find("try again") != -1 + + if isCancelButton: + button = cancelPushButton(buttonText, qparent) + if qparent is not None: + qparent.addCancelButton(button=button) + elif isYesButton: + button = okPushButton(buttonText, qparent) + if qparent is not None: + qparent.okButton = button + elif isSettingsButton: + button = setPushButton(buttonText, qparent) + elif isNoButton: + button = noPushButton(buttonText, qparent) + elif isDelButton: + button = delPushButton(buttonText, qparent) + elif isAddButton: + button = addPushButton(buttonText, qparent) + elif is3Dbutton: + button = threeDPushButton(buttonText, qparent) + elif is2Dbutton: + button = twoDPushButton(buttonText, qparent) + elif isSaveButton: + button = savePushButton(buttonText, qparent) + elif isNewFileButton: + button = newFilePushButton(buttonText, qparent) + elif isTryAgainButton: + button = reloadPushButton(buttonText, qparent) + else: + button = QPushButton(buttonText, qparent) + + return button, isCancelButton + + +def CustomGradientMenuAction(gradient: QLinearGradient, name: str, parent): + pixmap = QPixmap(100, 15) + painter = QPainter(pixmap) + brush = QBrush(gradient) + painter.fillRect(QRect(0, 0, 100, 15), brush) + painter.end() + label = QLabel() + label.setPixmap(pixmap) + label.setContentsMargins(1, 1, 1, 1) + labelName = QLabel(name) + hbox = QHBoxLayout() + delButton = delPushButton() + hbox.addWidget(labelName) + hbox.addStretch(1) + hbox.addWidget(label) + hbox.addWidget(delButton) + widget = QWidget() + widget.setLayout(hbox) + action = QWidgetAction(parent) + action.name = name + action.setDefaultWidget(widget) + action.delButton = delButton + delButton.action = action + return action diff --git a/cellacdc/components/inputs_basic.py b/cellacdc/components/inputs_basic.py new file mode 100644 index 000000000..253ef2d40 --- /dev/null +++ b/cellacdc/components/inputs_basic.py @@ -0,0 +1,167 @@ +import re + +from qtpy.QtCore import ( + QEvent, + Qt, + Signal, +) +from qtpy.QtGui import ( + QFontMetrics, + QKeyEvent, + QRegularExpressionValidator, +) +from qtpy.QtWidgets import ( + QLineEdit, + QScrollBar, +) + +from .palette import LINEEDIT_INVALID_ENTRY_STYLESHEET + +class ElidingLineEdit(QLineEdit): + def __init__(self, parent=None, minWidth=None): + super().__init__(parent) + self._text = "" + self._minWidth = minWidth + if minWidth is not None: + self.setMinimumWidth(minWidth) + + self.textEdited.connect(self.setText) + self.installEventFilter(self) + self._elide = True + + def setText(self, text: str, width=None, elide=True) -> None: + if width is None: + width = self._minWidth + + if width is None: + try: + textToPrevRatio = len(text) / len(self.text()) + width = round(self.width() * textToPrevRatio) + except ZeroDivisionError: + width = self.width() + + if width > self.width(): + width = self.width() + + self._text = text + if not elide or not self._elide: + super().setText(text) + return + + fm = QFontMetrics(self.font()) + elidedText = fm.elidedText(text, Qt.ElideLeft, width) + + super().setText(elidedText) + self.setToolTip(text) + + def text(self): + return self._text + + def resizeEvent(self, event): + newWidth = event.size().width() + self.setText(self._text, width=newWidth) + event.accept() + + def eventFilter(self, a0: "QObject", a1: "QEvent") -> bool: + isFocusIn = a1.type() == QEvent.Type.FocusIn + if isFocusIn and (self.isReadOnly() or not self.isEnabled()): + self.clearFocus() + return True + return super().eventFilter(a0, a1) + + def focusInEvent(self, event): + super().focusInEvent(event) + self._elide = False + self.setText(self._text, elide=False) + self.setCursorPosition(len(self.text())) + + def focusOutEvent(self, event): + self._elide = True + super().focusOutEvent(event) + self.setText(self._text) + + +class ValidLineEdit(QLineEdit): + def __init__(self, parent=None): + super().__init__(parent) + + def setInvalidStyleSheet(self): + self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) + + def setValidStyleSheet(self): + self.setStyleSheet("") + + +class KeepIDsLineEdit(ValidLineEdit): + sigIDsChanged = Signal(list) + sigSort = Signal() + sigEnterPressed = Signal() + + def __init__(self, instructionsLabel, parent=None): + super().__init__(parent) + + self.validPattern = "^[0-9-, ]+$" + regExpr = QRegularExpression(self.validPattern) + self.setValidator(QRegularExpressionValidator(regExpr)) + + self.textChanged.connect(self.onTextChanged) + self.editingFinished.connect(self.onEditingFinished) + + self.instructionsText = instructionsLabel.text() + self._label = instructionsLabel + + def keyPressEvent(self, event) -> None: + super().keyPressEvent(event) + if event.text() == ",": + self.sigSort.emit() + elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: + self.sigEnterPressed.emit() + + def onTextChanged(self, text): + IDs = [] + rangesMatch = re.findall(r"(\d+-\d+)", text) + if rangesMatch: + for rangeText in rangesMatch: + start, stop = rangeText.split("-") + start, stop = int(start), int(stop) + IDs.extend(range(start, stop + 1)) + text = re.sub(r"(\d+)-(\d+)", "", text) + IDsMatch = re.findall(r"(\d+)", text) + if IDsMatch: + for ID in IDsMatch: + IDs.append(int(ID)) + self.IDs = sorted(list(set(IDs))) + self.sigIDsChanged.emit(self.IDs) + + def onEditingFinished(self): + self.sigSort.emit() + + def warnNotExistingID(self): + self.setInvalidStyleSheet() + self._label.setText( + " Some of the IDs are not existing --> they will be IGNORED" + ) + self._label.setStyleSheet("color: red") + + def setInstructionsText(self): + self.setValidStyleSheet() + self._label.setText(self.instructionsText) + self._label.setStyleSheet("") + + +class ScrollBar(QScrollBar): + def __init__(self, *args): + super().__init__(*args) + self.installEventFilter(self) + self.setContextMenuPolicy(Qt.NoContextMenu) + + def eventFilter(self, object, event) -> bool: + if event.type() == QEvent.Type.Wheel: + return True + elif event.type() == QEvent.Type.MouseButtonPress: + # Filter right-click to prevent context menu + return event.button() == Qt.MouseButton.RightButton + elif event.type() == QEvent.Type.MouseButtonRelease: + # Filter right-click to prevent context menu + return event.button() == Qt.MouseButton.RightButton + return False diff --git a/cellacdc/components/layout.py b/cellacdc/components/layout.py new file mode 100644 index 000000000..341d53e58 --- /dev/null +++ b/cellacdc/components/layout.py @@ -0,0 +1,256 @@ +from qtpy.QtCore import QEvent, Qt, Signal +from qtpy.QtGui import QColor, QPalette +from qtpy.QtWidgets import ( + QCheckBox, + QFrame, + QGridLayout, + QGroupBox, + QHBoxLayout, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +import pyqtgraph as pg + +from .buttons import cancelPushButton, okPushButton +from .palette import BASE_COLOR + +class VerticalSpacerEmptyWidget(QWidget): + + def __init__(self, parent=None, height=5) -> None: + super().__init__(parent) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + self.setFixedHeight(height) +class QHWidgetSpacer(QWidget): + def __init__(self, width=10, parent=None) -> None: + super().__init__(parent) + self.setFixedWidth(width) + + +class QVWidgetSpacer(QWidget): + def __init__(self, height=10, parent=None) -> None: + super().__init__(parent) + self.setFixedHeight(height) + + +class QHLine(QFrame): + def __init__(self, shadow="Sunken", parent=None, color=None): + super().__init__(parent) + self.setFrameShape(QFrame.Shape.HLine) + self.setFrameShadow(getattr(QFrame, shadow)) + if color is not None: + self.setColor(color) + + def setColor(self, color): + qcolor = pg.mkColor(color) + pal = self.palette() + pal.setColor(QPalette.ColorRole.WindowText, qcolor) + self.setPalette(pal) + + +class QVLine(QFrame): + def __init__(self, shadow="Plain", parent=None, color=None): + super().__init__(parent) + self.setFrameShape(QFrame.Shape.VLine) + self.setFrameShadow(getattr(QFrame.Shadow, shadow)) + if color is not None: + self.setColor(color) + + def setColor(self, color): + qcolor = pg.mkColor(color) + pal = self.palette() + pal.setColor(QPalette.ColorRole.WindowText, qcolor) + self.setPalette(pal) + + +class VerticalResizeHline(QFrame): + dragged = Signal(object) + clicked = Signal(object) + released = Signal(object) + + def __init__(self): + super().__init__() + self.setCursor(Qt.SplitVCursor) + self.setFrameShape(QFrame.Shape.HLine) + self.setFrameShadow(QFrame.Shadow.Sunken) + self.installEventFilter(self) + self.isMousePressed = False + self._height = 4 + self.setMinimumHeight(self._height) + + def mousePressEvent(self, event) -> None: + self.isMousePressed = True + self.clicked.emit(event) + return super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: + self.dragged.emit(event) + return super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: + self.isMousePressed = False + self.released.emit(event) + return super().mouseReleaseEvent(event) + + def eventFilter(self, object, event): + if event.type() == QEvent.Type.Enter: + self.setLineWidth(0) + self.setMidLineWidth(self._height) + pal = self.palette() + pal.setColor(QPalette.ColorRole.WindowText, QColor(BASE_COLOR)) + self.setPalette(pal) + # self.setStyleSheet('background-color: #4d4d4d') + elif event.type() == QEvent.Type.Leave: + self.setMidLineWidth(0) + self.setLineWidth(1) + return False + + +class GroupBox(QGroupBox): + def __init__(self, *args, keyPressCallback=None): + super().__init__(*args) + self.keyPressCallback = None + self.setFocusPolicy(Qt.NoFocus) + + def keyPressEvent(self, event) -> None: + event.ignore() + if self.keyPressCallback is None: + return + + self.keyPressCallback() + + +class CheckBox(QCheckBox): + def __init__(self, *args, keyPressCallback=None): + super().__init__(*args) + self.keyPressCallback = None + self.setFocusPolicy(Qt.NoFocus) + + def keyPressEvent(self, event) -> None: + event.ignore() + if self.keyPressCallback is None: + return + + self.keyPressCallback() + + +class CancelOkButtonsLayout(QHBoxLayout): + def __init__(self, *args, additionalButtons=None): + super().__init__(*args) + + self.cancelButton = cancelPushButton("Cancel") + self.okButton = okPushButton(" Ok ") + + self.addStretch(1) + self.addWidget(self.cancelButton) + self.addSpacing(20) + + if additionalButtons is not None: + for button in additionalButtons: + self.addWidget(button) + + self.addWidget(self.okButton) + +class FormLayout(QGridLayout): + def __init__(self): + QGridLayout.__init__(self) + + def addFormWidget( + self, formWidget, leftLabelAlignment=Qt.AlignRight, align=None, row=0 + ): + for col, item in enumerate(formWidget.items): + if col == 0: + alignment = leftLabelAlignment + elif col == 2: + alignment = Qt.AlignLeft + else: + alignment = align + try: + if alignment is None: + self.addWidget(item, row, col) + else: + self.addWidget(item, row, col, alignment=alignment) + except TypeError: + self.addLayout(item, row, col) + + +class ScrollArea(QScrollArea): + sigLeaveEvent = Signal() + + def __init__( + self, parent=None, resizeVerticalOnShow=False, dropArrowKeyEvents=False + ) -> None: + super().__init__(parent) + self.setWidgetResizable(True) + self.setFrameStyle(QFrame.Shape.NoFrame) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.containerWidget = None + self.resizeVerticalOnShow = resizeVerticalOnShow + self.isOnlyVertical = False + self.dropArrowKeyEvents = dropArrowKeyEvents + + def setVerticalLayout(self, layout, widget=None): + if widget is None: + self.containerWidget = QWidget() + else: + self.containerWidget = widget + self.containerWidget.setLayout(layout) + self.containerWidget.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred + ) + self.setWidget(self.containerWidget) + self.containerWidget.installEventFilter(self) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.isOnlyVertical = True + + def setWidget(self, widget): + self.containerWidget = widget + super().setWidget(widget) + + def _resizeHorizontal(self): + self.setMinimumWidth( + self.containerWidget.minimumSizeHint().width() + + self.verticalScrollBar().width() + ) + + def minimumWidthNoScrollbar(self) -> int: + width = ( + self.containerWidget.minimumSizeHint().width() + + self.verticalScrollBar().width() + ) + return width + + def minimumHeightNoScrollbar(self) -> int: + height = ( + self.containerWidget.minimumSizeHint().height() + + self.horizontalScrollBar().height() + ) + return height + + def _resizeVertical(self): + height = ( + self.containerWidget.minimumSizeHint().height() + + self.horizontalScrollBar().height() + ) + self.containerWidget.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred + ) + + self.setFixedHeight(height) + + def eventFilter(self, object, event: QEvent): + if event.type() == QEvent.Type.Leave: + self.sigLeaveEvent.emit() + + if object != self.containerWidget: + return False + + isResize = event.type() == QEvent.Type.Resize + isShow = event.type() == QEvent.Type.Show + if isResize and self.isOnlyVertical: + self._resizeHorizontal() + elif isShow and self.resizeVerticalOnShow: + self._resizeVertical() + return False diff --git a/cellacdc/components/lists.py b/cellacdc/components/lists.py new file mode 100644 index 000000000..0264d43b5 --- /dev/null +++ b/cellacdc/components/lists.py @@ -0,0 +1,542 @@ +from qtpy.QtCore import ( + QAbstractItemModel, + QAbstractListModel, + QDataStream, + QIODevice, + QItemSelection, + QItemSelectionModel, + QModelIndex, + Qt, + Signal, + QSize, + QByteArray, + QObject, + QMimeData, +) +from qtpy.QtGui import QBrush +from qtpy.QtWidgets import ( + QAbstractItemView, + QComboBox, + QHBoxLayout, + QLayout, + QLabel, + QListView, + QListWidget, + QListWidgetItem, + QTreeWidget, + QTreeWidgetItem, + QTreeWidgetItemIterator, + QTextEdit, + QWidget, +) + +from .. import html_utils +from .palette import LISTWIDGET_STYLESHEET, TREEWIDGET_STYLESHEET, font + +class _ReorderableListModel(QAbstractListModel): + """ + ReorderableListModel is a list model which implements reordering of its + items via drag-n-drop + """ + + dragDropFinished = Signal() + + def __init__(self, items, parent=None): + QAbstractItemModel.__init__(self, parent) + self.nodes = items + self.lastDroppedItems = [] + self.pendingRemoveRowsAfterDrop = False + + def rowForItem(self, text): + """ + rowForItem method returns the row corresponding to the passed in item + or None if no such item exists in the model + """ + try: + row = self.nodes.index(text) + except ValueError: + return None + return row + + def index(self, row, column, parent): + if row < 0 or row >= len(self.nodes): + return QModelIndex() + return self.createIndex(row, column) + + def parent(self, index): + return QModelIndex() + + def rowCount(self, index): + if index.isValid(): + return 0 + return len(self.nodes) + + def data(self, index, role): + if not index.isValid(): + return None + if role == Qt.DisplayRole: + row = index.row() + if row < 0 or row >= len(self.nodes): + return None + return self.nodes[row] + elif role == Qt.SizeHintRole: + return QSize(48, 32) + else: + return None + + def supportedDropActions(self): + return Qt.MoveAction + + def flags(self, index): + if not index.isValid(): + return Qt.ItemIsEnabled + return ( + Qt.ItemIsEnabled + | Qt.ItemIsSelectable + | Qt.ItemIsDragEnabled + | Qt.ItemIsDropEnabled + ) + + def insertRows(self, row, count, index): + if index.isValid(): + return False + if count <= 0: + return False + # inserting 'count' empty rows starting at 'row' + self.beginInsertRows(QModelIndex(), row, row + count - 1) + for i in range(0, count): + self.nodes.insert(row + i, "") + self.endInsertRows() + return True + + def removeRows(self, row, count, index): + if index.isValid(): + return False + if count <= 0: + return False + num_rows = self.rowCount(QModelIndex()) + self.beginRemoveRows(QModelIndex(), row, row + count - 1) + for i in range(count, 0, -1): + self.nodes.pop(row - i + 1) + self.endRemoveRows() + + if self.pendingRemoveRowsAfterDrop: + """ + If we got here, it means this call to removeRows is the automatic + 'cleanup' action after drag-n-drop performed by Qt + """ + self.pendingRemoveRowsAfterDrop = False + self.dragDropFinished.emit() + + return True + + def setData(self, index, value, role): + if not index.isValid(): + return False + if index.row() < 0 or index.row() > len(self.nodes): + return False + self.nodes[index.row()] = str(value) + self.dataChanged.emit(index, index) + return True + + def mimeTypes(self): + return ["application/vnd.treeviewdragdrop.list"] + + def mimeData(self, indexes): + mimedata = QMimeData() + encoded_data = QByteArray() + stream = QDataStream(encoded_data, QIODevice.WriteOnly) + for index in indexes: + if index.isValid(): + text = self.data(index, 0) + stream << QByteArray(text.encode("utf-8")) + mimedata.setData("application/vnd.treeviewdragdrop.list", encoded_data) + return mimedata + + def dropMimeData(self, data, action, row, column, parent): + if action == Qt.IgnoreAction: + return True + if not data.hasFormat("application/vnd.treeviewdragdrop.list"): + return False + if column > 0: + return False + + num_rows = self.rowCount(QModelIndex()) + if num_rows <= 0: + return False + + if row < 0: + if parent.isValid(): + row = parent.row() + else: + return False + + encoded_data = data.data("application/vnd.treeviewdragdrop.list") + stream = QDataStream(encoded_data, QIODevice.ReadOnly) + + new_items = [] + rows = 0 + while not stream.atEnd(): + text = QByteArray() + stream >> text + text = bytes(text).decode("utf-8") + index = self.nodes.index(text) + new_items.append((text, index)) + rows += 1 + + self.lastDroppedItems = [] + for text, index in new_items: + target_row = row + if index < row: + target_row += 1 + self.beginInsertRows(QModelIndex(), target_row, target_row) + self.nodes.insert(target_row, self.nodes[index]) + self.endInsertRows() + self.lastDroppedItems.append(text) + row += 1 + + self.pendingRemoveRowsAfterDrop = True + return True + + +class _SelectionModel(QItemSelectionModel): + def __init__(self, parent=None, isSingleSelection=False): + QItemSelectionModel.__init__(self, parent) + self.isSingleSelection = isSingleSelection + + def onModelItemsReordered(self): + new_selection = QItemSelection() + new_index = QModelIndex() + for item in self.model().lastDroppedItems: + row = self.model().rowForItem(item) + if row is None: + continue + new_index = self.model().index(row, 0, QModelIndex()) + new_selection.select(new_index, new_index) + + self.clearSelection() + flags = ( + QItemSelectionModel.SelectionFlag.ClearAndSelect + | QItemSelectionModel.SelectionFlag.Rows + | QItemSelectionModel.SelectionFlag.Current + ) + self.select(new_selection, flags) + self.setCurrentIndex(new_index, flags) + if not self.isSingleSelection: + self.reset() + + +class ReorderableListView(QListView): + def __init__(self, items=None, parent=None, isSingleSelection=False) -> None: + super().__init__(parent) + if items is None: + items = [] + + self.isSingleSelection = isSingleSelection + self._model = _ReorderableListModel(items) + self._selectionModel = _SelectionModel(self._model) + self._model.dragDropFinished.connect(self._selectionModel.onModelItemsReordered) + self.setModel(self._model) + self.setSelectionModel(self._selectionModel) + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDragDropOverwriteMode(False) + styleSheet = f""" + QListView {{ + selection-background-color: rgba(200, 200, 200, 0.30); + selection-color: black; + show-decoration-selected: 1; + }} + QListView::item {{ + border-bottom: 1px solid rgba(180, 180, 180, 0.5); + }} + QListView::item:hover {{ + background-color: rgba(200, 200, 200, 0.30); + }} + """ + self.setStyleSheet(styleSheet) + + def setItems(self, items): + self._model.nodes = items + + def items(self): + return self._model.nodes + + # def mouseReleaseEvent(self, e: QMouseEvent) -> None: + # super().mouseReleaseEvent(e) + # self._selectionModel.reset() + + +class listWidget(QListWidget): + def __init__( + self, *args, isMultipleSelection=False, minimizeHeight=False, **kwargs + ): + super().__init__(*args, **kwargs) + self.itemHeight = None + self.setStyleSheet(LISTWIDGET_STYLESHEET) + self.setFont(font) + if isMultipleSelection: + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + + self.minimizeHeight = minimizeHeight + + def setSelectedAll(self, selected): + for i in range(self.count()): + self.item(i).setSelected(selected) + + def setSelectedItems(self, itemsText): + for i in range(self.count()): + item = self.item(i) + item.setSelected(item.text() in itemsText) + + def addItems(self, labels) -> None: + super().addItems(labels) + if self.itemHeight is not None: + self.setItemHeight() + + if self.minimizeHeight: + itemHeight = self.sizeHintForRow(0) + self.setMaximumHeight(itemHeight * self.count() + itemHeight * 2) + + def addItem(self, text): + super().addItem(text) + if self.itemHeight is None: + return + self.setItemHeight() + + def setItemHeight(self, height=40): + self.itemHeight = height + for i in range(self.count()): + item = self.item(i) + item.setSizeHint(QSize(0, height)) + + def selectedItemsText(self): + return [item.text() for item in self.selectedItems()] + + +class OrderableListWidget(QWidget): + sigEnterEvent = Signal(object) + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._labels = [] + + def setParentItem(self, item): + self._item = item + + def setLabelsColor(self, selected): + if selected: + stylesheet = "color : black" + else: + stylesheet = "" + + for label in self._labels: + label.setStyleSheet(stylesheet) + + def enterEvent(self, event): + super().enterEvent(event) + self.setLabelsColor(True) + self.sigEnterEvent.emit(self._item) + + # def leaveEvent(self, event): + # super().leaveEvent(event) + # self.setLabelsColor(self._item.isSelected()) + # printl('leave', self._item.isSelected()) + + def addLabel(self, label): + self._labels.append(label) + + +class OrderableList(listWidget): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.setMouseTracking(True) + self.itemEntered.connect(self.onItemEntered) + + def onItemEntered(self, enteredItem): + enteredRow = self.row(enteredItem) + for i in range(self.count()): + item = self.item(i) + item._container.setLabelsColor(i == enteredRow or item.isSelected()) + + def leaveEvent(self, event): + super().leaveEvent(event) + for i in range(self.count()): + item = self.item(i) + item._container.setLabelsColor(item.isSelected()) + + def addItems(self, items): + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + nr_items = len(items) + nn = [str(n) for n in range(1, nr_items + 1)] + for i, item in enumerate(items): + itemW = QListWidgetItem() + itemContainer = OrderableListWidget() + itemContainer.setParentItem(itemW) + itemText = QLabel(item) + tableNrLabel = QLabel("| Table nr.") + itemContainer.addLabel(tableNrLabel) + itemContainer.addLabel(itemText) + itemLayout = QHBoxLayout() + itemNumberWidget = QComboBox() + itemNumberWidget.addItems(nn) + itemLayout.addWidget(itemText) + itemLayout.addWidget(tableNrLabel) + itemLayout.addWidget(itemNumberWidget) + itemContainer.setLayout(itemLayout) + itemLayout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) + itemW.setSizeHint(itemContainer.sizeHint()) + self.addItem(itemW) + self.setItemWidget(itemW, itemContainer) + itemW._text = item + itemW._nrWidget = itemNumberWidget + itemW._container = itemContainer + itemNumberWidget.setDisabled(True) + itemNumberWidget.textActivated.connect(self.onTextActivated) + itemNumberWidget._currentNr = 1 + itemNumberWidget.row = i + itemContainer.sigEnterEvent.connect(self.onItemEntered) + + self.itemSelectionChanged.connect(self.onItemSelectionChanged) + + def keyPressEvent(self, event) -> None: + if event.key() == Qt.Key_Escape: + self.clearSelection() + event.ignore() + return + super().keyPressEvent(event) + + def updateNr(self): + for i in range(self.count()): + item = self.item(i) + item._currentNr = int(item._nrWidget.currentText()) + + def onItemSelectionChanged(self): + for i in range(self.count()): + item = self.item(i) + item._container.setLabelsColor(item.isSelected()) + item._nrWidget.setDisabled(not item.isSelected()) + if item._nrWidget.currentText() != "1": + item._nrWidget.setCurrentText("1") + item._currentNr = 1 + + for i, item in enumerate(self.selectedItems()): + item._nrWidget.setCurrentText(f"{i + 1}") + item._currentNr = i + 1 + + def onTextActivated(self, text): + changedNr = self.sender()._currentNr + for item in self.selectedItems(): + row = self.row(item) + if self.sender().row == row: + changedNr = item._currentNr + continue + + for item in self.selectedItems(): + row = self.row(item) + if self.sender().row == row: + continue + nr = int(item._nrWidget.currentText()) + if nr == int(text): + item._nrWidget.setCurrentText(str(changedNr)) + break + + self.updateNr() + + +class TreeWidget(QTreeWidget): + def __init__(self, *args, multiSelection=False): + super().__init__(*args) + self.setStyleSheet(TREEWIDGET_STYLESHEET) + self.setFont(font) + if multiSelection: + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.itemClicked.connect(self.selectAllChildren) + + self.isCtrlDown = False + self.isShiftDown = False + + def keyPressEvent(self, ev): + if ev.key() == Qt.Key_Escape: + self.clearSelection() + elif ev.key() == Qt.Key_Control: + self.isCtrlDown = True + elif ev.key() == Qt.Key_Shift: + self.isShiftDown = True + + def keyReleaseEvent(self, ev): + if ev.key() == Qt.Key_Control: + self.isCtrlDown = False + elif ev.key() == Qt.Key_Shift: + self.isShiftDown = False + + def onFocusChanged(self): + self.isCtrlDown = False + self.isShiftDown = False + + def selectAllChildren(self, item_or_label): + label = None + if isinstance(item_or_label, QLabel): + label = item_or_label + else: + item = item_or_label + if item.childCount() == 0: + return + + if label is not None: + if not self.isCtrlDown and not self.isShiftDown: + self.clearSelection() + label.item.setSelected(True) + if self.isShiftDown: + selectionStarted = False + it = QTreeWidgetItemIterator(self) + while it: + item = it.value() + if item is None: + break + if item.isSelected(): + selectionStarted = not selectionStarted + if selectionStarted: + item.setSelected(True) + it += 1 + + for item in self.selectedItems(): + if item.parent() is None: + for i in range(item.childCount()): + item.child(i).setSelected(True) + + + +class TreeWidgetItem(QTreeWidgetItem): + def __init__(self, *args, columnColors=None): + super().__init__(*args) + + if columnColors is not None: + for c, color in enumerate(columnColors): + if color is None: + continue + self.setBackground(c, QBrush(color)) + + +class FilterObject(QObject): + sigFilteredEvent = Signal(object, object) + + def __init__(self) -> None: + super().__init__() + + def eventFilter(self, object, event): + self.sigFilteredEvent.emit(object, event) + return super().eventFilter(object, event) + + +class readOnlyQList(QTextEdit): + def __init__(self, parent=None): + super().__init__(parent) + self.setReadOnly(True) + self.items = [] + + def addItems(self, items): + self.items.extend(items) + items = [str(item) for item in self.items] + columnList = html_utils.paragraph("
    ".join(items)) + self.setText(columnList) + diff --git a/cellacdc/components/palette.py b/cellacdc/components/palette.py new file mode 100644 index 000000000..fe5d088a0 --- /dev/null +++ b/cellacdc/components/palette.py @@ -0,0 +1,131 @@ +import os +import operator + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.colors import LinearSegmentedColormap + +import pyqtgraph as pg +from qtpy.QtGui import QFont + +from .. import config, settings_folderpath +from .. import _palettes + +LINEEDIT_WARNING_STYLESHEET = _palettes.lineedit_warning_stylesheet() +LINEEDIT_INVALID_ENTRY_STYLESHEET = _palettes.lineedit_invalid_entry_stylesheet() +TREEWIDGET_STYLESHEET = _palettes.TreeWidgetStyleSheet() +LISTWIDGET_STYLESHEET = _palettes.ListWidgetStyleSheet() +BASE_COLOR = _palettes.base_color() +PROGRESSBAR_QCOLOR = _palettes.QProgressBarColor() +PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR = _palettes.QProgressBarHighlightedTextColor() +TEXT_COLOR = _palettes.text_float_rgba() + +font = QFont() +font.setPixelSize(12) + +custom_cmaps_filepath = os.path.join(settings_folderpath, "custom_colormaps.ini") + +str_to_operator_mapper = {"+": operator.add, "-": operator.sub} + +sign_int_mapper = {"+": 1, "-": -1} + + +def removeHSVcmaps(): + hsv_cmaps = [] + for g, grad in pg.graphicsItems.GradientEditorItem.Gradients.items(): + if grad["mode"] == "hsv": + hsv_cmaps.append(g) + for g in hsv_cmaps: + del pg.graphicsItems.GradientEditorItem.Gradients[g] + + +def renamePgCmaps(): + Gradients = pg.graphicsItems.GradientEditorItem.Gradients + try: + Gradients["hot"] = Gradients.pop("thermal") + except KeyError: + pass + try: + Gradients.pop("greyclip") + except KeyError: + pass + + +def _tab20gradient(): + cmap = plt.get_cmap("tab20") + ticks = [(t, tuple([int(v * 255) for v in cmap(t)])) for t in np.linspace(0, 1, 20)] + gradient = {"ticks": ticks, "mode": "rgb"} + return gradient + + +def _tab10gradient(): + cmap = plt.get_cmap("tab10") + ticks = [(t, tuple([int(v * 255) for v in cmap(t)])) for t in np.linspace(0, 1, 20)] + gradient = {"ticks": ticks, "mode": "rgb"} + return gradient + + +def getCustomGradients(name="image"): + CustomGradients = {} + if not os.path.exists(custom_cmaps_filepath): + return CustomGradients + + cp = config.ConfigParser() + cp.read(custom_cmaps_filepath) + for section in cp.sections(): + if not section.startswith(f"{name}"): + continue + + cmap_name = section[len(f"{name}.") :] + CustomGradients[cmap_name] = {"ticks": [], "mode": "rgb"} + for option in cp.options(section): + value = cp[section][option] + pos, *rgb = value.split(",") + rgb = tuple([int(c) for c in rgb]) + pos = float(pos) + CustomGradients[cmap_name]["ticks"].append((pos, rgb)) + return CustomGradients + + +def addGradients(): + Gradients = pg.graphicsItems.GradientEditorItem.Gradients + Gradients["cividis"] = { + "ticks": [ + (0.0, (0, 34, 78, 255)), + (0.25, (66, 78, 108, 255)), + (0.5, (124, 123, 120, 255)), + (0.75, (187, 173, 108, 255)), + (1.0, (254, 232, 56, 255)), + ], + "mode": "rgb", + } + Gradients["cool"] = { + "ticks": [(0.0, (0, 255, 255, 255)), (1.0, (255, 0, 255, 255))], + "mode": "rgb", + } + Gradients["sunset"] = { + "ticks": [ + (0.0, (71, 118, 148, 255)), + (0.4, (222, 213, 141, 255)), + (0.8, (229, 184, 155, 255)), + (1.0, (240, 127, 97, 255)), + ], + "mode": "rgb", + } + Gradients["tab20"] = _tab20gradient() + Gradients["tab10"] = _tab10gradient() + cmaps = {} + for name, gradient in Gradients.items(): + ticks = gradient["ticks"] + colors = [tuple([v / 255 for v in tick[1]]) for tick in ticks] + cmaps[name] = LinearSegmentedColormap.from_list(name, colors, N=256) + return cmaps, Gradients + + +nonInvertibleCmaps = ["cool", "sunset", "bipolar"] + +renamePgCmaps() +removeHSVcmaps() +cmaps, Gradients = addGradients() +GradientsLabels = Gradients.copy() +GradientsImage = Gradients.copy() diff --git a/cellacdc/components/path_controls.py b/cellacdc/components/path_controls.py new file mode 100644 index 000000000..12537f3c1 --- /dev/null +++ b/cellacdc/components/path_controls.py @@ -0,0 +1,74 @@ +from qtpy.QtCore import Signal +from qtpy.QtGui import QShowEvent +from qtpy.QtWidgets import QFrame, QHBoxLayout, QLineEdit + +from .buttons import browseFileButton +from .inputs_basic import ElidingLineEdit + +class filePathControl(QFrame): + sigValueChanged = Signal(str) + + def __init__( + self, + parent=None, + browseFolder=False, + fileManagerTitle="Select file", + validExtensions=None, + startFolder="", + elide=False, + ): + super().__init__(parent) + + layout = QHBoxLayout() + if elide: + self.le = ElidingLineEdit() + else: + self.le = QLineEdit() + + self.browseButton = browseFileButton( + openFolder=browseFolder, + title=fileManagerTitle, + ext=validExtensions, + start_dir=startFolder, + ) + + layout.addWidget(self.le) + layout.addWidget(self.browseButton) + self.setLayout(layout) + + self.le.editingFinished.connect(self.setTextTooltip) + self.browseButton.sigPathSelected.connect(self.setText) + + self.setFrameStyle(QFrame.Shape.StyledPanel) + + def setText(self, text): + self.le.setText(text) + self.le.setToolTip(text) + self.sigValueChanged.emit(self.le.text()) + + def setTextTooltip(self): + self.le.setToolTip(self.le.text()) + self.sigValueChanged.emit(self.le.text()) + + def path(self): + return self.le.text() + + def showEvent(self, a0: QShowEvent) -> None: + self.le.setFixedHeight(self.browseButton.height()) + return super().showEvent(a0) + + +class FolderPathControl(filePathControl): + def __init__(self, **kwargs): + super().__init__(browseFolder=True, fileManagerTitle="Select folder", **kwargs) + + +class CsvFilePathControl(filePathControl): + def __init__(self, **kwargs): + super().__init__( + browseFolder=False, + fileManagerTitle="Select a CSV file", + validExtensions={"CSV files": [".csv", ".CSV"]}, + **kwargs, + ) + diff --git a/cellacdc/components/progress.py b/cellacdc/components/progress.py new file mode 100644 index 000000000..9fcfc8541 --- /dev/null +++ b/cellacdc/components/progress.py @@ -0,0 +1,245 @@ +import logging +import math +import sys +import time + +import numpy as np +import pyqtgraph as pg +from qtpy.QtCore import Property, QPropertyAnimation, QObject, QPointF, Qt, Signal +from qtpy.QtGui import QFont, QPalette, QPainter, QColor, QPen +from qtpy.QtWidgets import ( + QGraphicsBlurEffect, + QLabel, + QPlainTextEdit, + QProgressBar, + QTextEdit, +) + +from .. import _palettes, myutils +from .palette import PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, PROGRESSBAR_QCOLOR + + +class XStream(QObject): + _stdout = None + _stderr = None + messageWritten = Signal(str) + + def flush(self): + pass + + def fileno(self): + return -1 + + def write(self, msg): + if not self.signalsBlocked(): + self.messageWritten.emit(msg) + + @staticmethod + def stdout(): + if not XStream._stdout: + XStream._stdout = XStream() + sys.stdout = XStream._stdout + return XStream._stdout + + @staticmethod + def stderr(): + if not XStream._stderr: + XStream._stderr = XStream() + sys.stderr = XStream._stderr + return XStream._stderr + + +class QtHandler(logging.Handler): + def __init__(self): + super().__init__() + + def emit(self, record): + record = self.format(record) + if record: + XStream.stdout().write("%s\n" % record) + + +class QLog(QPlainTextEdit): + sigClose = Signal() + + def __init__(self, *args, logger=None): + super().__init__(*args) + self.logger = logger + self.setReadOnly(True) + + def connect(self): + XStream.stdout().messageWritten.connect(self.writeStdOutput) + + def writeStdOutput(self, text: str) -> None: + super().insertPlainText(text) + self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) + + def writeStdErr(self, text: str) -> None: + super().insertPlainText(text) + self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) + if self.logger is not None: + self.logger.exception(text) + + def insertPlainText(self, text: str) -> None: + super().insertPlainText(f"{text}\n") + self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) + + def closeEvent(self, event) -> None: + super().closeEvent(event) + self.sigClose.emit() + + +class QLogConsole(QTextEdit): + def __init__(self, parent=None): + super().__init__(parent) + self.setReadOnly(True) + font = QFont() + font.setPixelSize(13) + self.setFont(font) + + def write(self, message): + message = message.replace("\r ", "") + if message: + self.apppendText(message) + + def append(self, text: str) -> None: + super().append(text) + self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) + + def insertPlainText(self, text: str) -> None: + super().append(text) + self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) + + +class ProgressBar(QProgressBar): + def __init__(self, parent=None): + super().__init__(parent) + palette = self.palette() + palette.setColor(QPalette.ColorRole.Highlight, PROGRESSBAR_QCOLOR) + palette.setColor( + QPalette.ColorRole.HighlightedText, PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR + ) + self.setPalette(palette) + + +class ProgressBarWithETA(ProgressBar): + def __init__(self, parent=None): + self.parent = parent + super().__init__(parent=parent) + self.ETA_label = QLabel("NDh:NDm:NDs") + + def update(self, step: int): + self.setValue(self.value() + step) + t = time.perf_counter() + if not hasattr(self, "last_time_update"): + self.last_time_update = t + self.mean_value_duration = None + return + seconds_per_value = (t - self.last_time_update) / step + value_left = self.maximum() - self.value() + if self.mean_value_duration is None: + self.mean_value_duration = seconds_per_value + else: + self.mean_value_duration = ( + self.mean_value_duration * (self.value() - 1) + seconds_per_value + ) / self.value() + + seconds_left = self.mean_value_duration * value_left + ETA = myutils.seconds_to_ETA(seconds_left) + self.ETA_label.setText(ETA) + self.last_time_update = t + return ETA + + def show(self): + QProgressBar.show(self) + self.ETA_label.show() + + def hide(self): + QProgressBar.hide(self) + self.ETA_label.hide() + + +class NoneWidget: + def __init__(self): + pass + + def value(self): + return None + + def setValue(self, value): + return + + +class LoadingCircleAnimation(QLabel): + def __init__(self, size=32, motionBlur=False, parent=None): + super().__init__(parent) + self.setAlignment(Qt.AlignCenter) + self._size = size + size % 2 + self._radius = int(self._size / 2) + self.setFixedSize(self._size, self._size) + self._dotDiameter = int(self._size * 0.15) + self._dotDiameter = self._dotDiameter + self._dotDiameter % 2 + self._dotRadius = int(self._dotDiameter / 2) + + self._rgb = _palettes.getPainterColor()[:3] + self._index = 0 + + self.setBrushesAndAngles() + + if motionBlur: + blurEffect = QGraphicsBlurEffect() + blurRadius = self._size * 0.02 + if blurRadius < 1: + blurRadius = 1 + blurEffect.setBlurRadius(blurRadius) + self.setGraphicsEffect(blurEffect) + + self.animation = QPropertyAnimation(self, b"index", self) + self.animation.setStartValue(0) + self.animation.setEndValue(11) + self.animation.setLoopCount(-1) + self.animation.setDuration(1200) + self.animation.start() + + self.update() + + def setVisible(self, visible): + if visible: + self.animation.start() + else: + self.animation.stop() + super().setVisible(visible) + + def setBrushesAndAngles(self): + self._brushes = [] + self._pens = [] + alphas = np.round(np.linspace(0, 255, 12)).astype(int) + self._angles = np.arange(0, 360, 30) + for alpha in alphas: + color = QColor(*self._rgb, alpha) + self._brushes.append(pg.mkBrush(color)) + self._pens.append(pg.mkPen(color)) + + @Property(int) + def index(self): + return self._index + + @index.setter + def index(self, value): + self._index = value + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + painter.translate(self._radius, self._radius) + for i in range(12): + idx = i - self._index + angle = self._angles[i] + painter.setBrush(self._brushes[idx]) + painter.setPen(self._pens[idx]) + x = (self._radius - self._dotRadius) * math.cos(angle * math.pi / 180) + y = (self._radius - self._dotRadius) * math.sin(angle * math.pi / 180) + painter.drawEllipse(QPointF(x, y), self._dotRadius, self._dotRadius) + + painter.end() diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py index 93a72eddd..9c428462f 100755 --- a/cellacdc/widgets.py +++ b/cellacdc/widgets.py @@ -156,858 +156,45 @@ from .config import PREPROCESS_MAPPER from . import _base_widgets -LINEEDIT_WARNING_STYLESHEET = _palettes.lineedit_warning_stylesheet() -LINEEDIT_INVALID_ENTRY_STYLESHEET = _palettes.lineedit_invalid_entry_stylesheet() -TREEWIDGET_STYLESHEET = _palettes.TreeWidgetStyleSheet() -LISTWIDGET_STYLESHEET = _palettes.ListWidgetStyleSheet() -BASE_COLOR = _palettes.base_color() -PROGRESSBAR_QCOLOR = _palettes.QProgressBarColor() -PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR = _palettes.QProgressBarHighlightedTextColor() -TEXT_COLOR = _palettes.text_float_rgba() - -font = QFont() -font.setPixelSize(12) - -custom_cmaps_filepath = os.path.join(settings_folderpath, "custom_colormaps.ini") - -str_to_operator_mapper = {"+": operator.add, "-": operator.sub} - -sign_int_mapper = {"+": 1, "-": -1} - - -def removeHSVcmaps(): - hsv_cmaps = [] - for g, grad in pg.graphicsItems.GradientEditorItem.Gradients.items(): - if grad["mode"] == "hsv": - hsv_cmaps.append(g) - for g in hsv_cmaps: - del pg.graphicsItems.GradientEditorItem.Gradients[g] - - -def renamePgCmaps(): - Gradients = pg.graphicsItems.GradientEditorItem.Gradients - try: - Gradients["hot"] = Gradients.pop("thermal") - except KeyError: - pass - try: - Gradients.pop("greyclip") - except KeyError: - pass - - -def _tab20gradient(): - cmap = plt.get_cmap("tab20") - ticks = [(t, tuple([int(v * 255) for v in cmap(t)])) for t in np.linspace(0, 1, 20)] - gradient = {"ticks": ticks, "mode": "rgb"} - return gradient - - -def _tab10gradient(): - cmap = plt.get_cmap("tab10") - ticks = [(t, tuple([int(v * 255) for v in cmap(t)])) for t in np.linspace(0, 1, 20)] - gradient = {"ticks": ticks, "mode": "rgb"} - return gradient - - -def getCustomGradients(name="image"): - CustomGradients = {} - if not os.path.exists(custom_cmaps_filepath): - return CustomGradients - - cp = config.ConfigParser() - cp.read(custom_cmaps_filepath) - for section in cp.sections(): - if not section.startswith(f"{name}"): - continue - - cmap_name = section[len(f"{name}.") :] - CustomGradients[cmap_name] = {"ticks": [], "mode": "rgb"} - for option in cp.options(section): - value = cp[section][option] - pos, *rgb = value.split(",") - rgb = tuple([int(c) for c in rgb]) - pos = float(pos) - CustomGradients[cmap_name]["ticks"].append((pos, rgb)) - return CustomGradients - - -def addGradients(): - Gradients = pg.graphicsItems.GradientEditorItem.Gradients - Gradients["cividis"] = { - "ticks": [ - (0.0, (0, 34, 78, 255)), - (0.25, (66, 78, 108, 255)), - (0.5, (124, 123, 120, 255)), - (0.75, (187, 173, 108, 255)), - (1.0, (254, 232, 56, 255)), - ], - "mode": "rgb", - } - Gradients["cool"] = { - "ticks": [(0.0, (0, 255, 255, 255)), (1.0, (255, 0, 255, 255))], - "mode": "rgb", - } - Gradients["sunset"] = { - "ticks": [ - (0.0, (71, 118, 148, 255)), - (0.4, (222, 213, 141, 255)), - (0.8, (229, 184, 155, 255)), - (1.0, (240, 127, 97, 255)), - ], - "mode": "rgb", - } - Gradients["tab20"] = _tab20gradient() - Gradients["tab10"] = _tab10gradient() - cmaps = {} - for name, gradient in Gradients.items(): - ticks = gradient["ticks"] - colors = [tuple([v / 255 for v in tick[1]]) for tick in ticks] - cmaps[name] = LinearSegmentedColormap.from_list(name, colors, N=256) - return cmaps, Gradients - - -nonInvertibleCmaps = ["cool", "sunset", "bipolar"] - -renamePgCmaps() -removeHSVcmaps() -cmaps, Gradients = addGradients() -GradientsLabels = Gradients.copy() -GradientsImage = Gradients.copy() - - -class XStream(QObject): - _stdout = None - _stderr = None - messageWritten = Signal(str) - - def flush(self): - pass - - def fileno(self): - return -1 - - def write(self, msg): - if not self.signalsBlocked(): - self.messageWritten.emit(msg) - - @staticmethod - def stdout(): - if not XStream._stdout: - XStream._stdout = XStream() - sys.stdout = XStream._stdout - return XStream._stdout - - @staticmethod - def stderr(): - if not XStream._stderr: - XStream._stderr = XStream() - sys.stderr = XStream._stderr - return XStream._stderr - - -class QtHandler(logging.Handler): - def __init__(self): - super().__init__() - - def emit(self, record): - record = self.format(record) - if record: - XStream.stdout().write("%s\n" % record) - - -class QLog(QPlainTextEdit): - sigClose = Signal() - - def __init__(self, *args, logger=None): - super().__init__(*args) - self.logger = logger - self.setReadOnly(True) - - def connect(self): - XStream.stdout().messageWritten.connect(self.writeStdOutput) - # XStream.stderr().messageWritten.connect(self.writeStdErr) - - def writeStdOutput(self, text: str) -> None: - super().insertPlainText(text) - self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) - - def writeStdErr(self, text: str) -> None: - super().insertPlainText(text) - self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) - if self.logger is not None: - self.logger.exception(text) - - def insertPlainText(self, text: str) -> None: - super().insertPlainText(f"{text}\n") - self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) - - def closeEvent(self, event) -> None: - super().closeEvent(event) - self.sigClose.emit() - - -class PushButton(QPushButton): - def __init__( - self, *args, icon=None, alignIconLeft=False, flat=False, hoverable=False - ): - super().__init__(*args) - if icon is not None: - self.setIcon(icon) - self.alignIconLeft = alignIconLeft - self._text = None - if flat: - self.setFlat(True) - if hoverable: - self.installEventFilter(self) - - def setRetainSizeWhenHidden(self, retainSize): - sp = self.sizePolicy() - sp.setRetainSizeWhenHidden(retainSize) - self.setSizePolicy(sp) - - def eventFilter(self, object, event): - if event.type() == QEvent.Type.HoverEnter: - self.setFlat(False) - elif event.type() == QEvent.Type.HoverLeave: - self.setFlat(True) - return False - - def show(self): - text = self.text() - if not self.alignIconLeft: - super().show() - return - - self._text = text - self.setStyleSheet("text-align:left;") - self.setLayout(QGridLayout()) - textLabel = QLabel(self._text) - textLabel.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - textLabel.setAttribute(Qt.WA_TransparentForMouseEvents, True) - self._layout().addWidget(textLabel) - super().show() - - def confirmAction(self): - self.baseIcon = self.icon() - self.setIcon(QIcon(":greenTick.svg")) - QTimer.singleShot(2000, self.resetButton) - - def resetButton(self): - self.setIcon(self.baseIcon) - - def setText(self, text): - if self._text is None: - super().setText(text) - else: - super().setText(self._text) - - -class LoadPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":fork_lift.svg")) - - -class mergePushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":merge-IDs.svg")) - - -class okPushButton(PushButton): - def __init__(self, *args, isDefault=True, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":yesGray.svg")) - if isDefault: - self.setDefault(True) - # QShortcut(Qt.Key_Return, self, self.click) - # QShortcut(Qt.Key_Enter, self, self.click) - - -class MagnifyingGlassPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":magnGlass.svg")) - - -class MagnifyingGlassAllPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":magnGlass_all.svg")) - - -class AssignNewIDButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":assign_new_id.svg")) - - -class LockPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":lock.svg")) - self.toggled.connect(self.onToggled) - - def onToggled(self, checked): - if not self.isCheckable(): - return - - if checked: - self.setIcon(QIcon(":lock_closed.svg")) - else: - self.setIcon(QIcon(":lock_open.svg")) - - def setCheckable(self, checkable: bool): - super().setCheckable(checkable) - if checkable: - self.setIcon(QIcon(":lock_open.svg")) - else: - self.setIcon(QIcon(":lock.svg")) - - -class SkipPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":skip_arrow.svg")) - - -class BedPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":bed.svg")) - - -class BedPlusLabelPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":bed_plus_label.svg")) - iconH = self.iconSize().height() - iconW = int(iconH * 2.5) - self.setIconSize(QSize(iconW, iconH)) - - -class NoBedPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":no_bed.svg")) - - -class NavigatePushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":navigate.svg")) - - -class SwitchPlaneButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":switch_2d_plane.svg")) - self._planes = ("xy", "zy", "zx") - self._idx = 0 - - def switchPlane(self): - self._idx += 1 - - def setPlane(self, plane): - self._idx = self._planes.index(plane) - - def plane(self): - return self._planes[self._idx % 3] - - def depthAxes(self): - plane = self.plane() - for axes in "xyz": - if axes not in plane: - return axes - - -class zoomPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":zoom_out.svg")) - - def setIconZoomOut(self): - self.setIcon(QIcon(":zoom_out.svg")) - - def setIconZoomIn(self): - self.setIcon(QIcon(":zoom_in.svg")) - - -class WarningButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":warning.svg")) - - -class reloadPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":reload.svg")) - - -class savePushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":file-save.svg")) - - -class autoPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":cog_play.svg")) - - -class newFilePushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":file-new.svg")) - - -class helpPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":help.svg")) - - -class viewPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":eye.svg")) - - -class infoPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":info.svg")) - - -class threeDPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":3d.svg")) - - -class twoDPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":2d.svg")) - - -class addPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":add.svg")) - - -class futurePushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":arrow_future.svg")) - - -class FutureAllPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":arrow_future_all.svg")) - - -class currentPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":arrow_current.svg")) - - -class arrowUpPushButton(PushButton): - def __init__(self, *args, **kwargs): - alignIconLeft = kwargs.get("alignIconLeft", False) - super().__init__( - *args, icon=QIcon(":arrow-up.svg"), alignIconLeft=alignIconLeft - ) - - -class arrowDownPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":arrow-down.svg")) - - -class selectAllPushButton(PushButton): - sigClicked = Signal(object, bool) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._status = "deselect" - self.setIcon(QIcon(":deselect_all.svg")) - self.setText("Deselect all") - self.clicked.connect(self.onClicked) - self.setMinimumWidth(self.sizeHint().width()) - - def setChecked(self, checked): - if checked: - self._status == "deselect" - else: - self._status == "select" - self.click() - - def onClicked(self): - if self._status == "select": - icon_fn = ":deselect_all.svg" - self._status = "deselect" - checked = True - text = "Deselect all" - else: - icon_fn = ":select_all.svg" - text = "Select all" - self._status = "select" - checked = False - self.setIcon(QIcon(icon_fn)) - self.setText(text) - self.sigClicked.emit(self, checked) - - -class subtractPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":subtract.svg")) - - -class continuePushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":continue.svg")) - - -class calcPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":calc.svg")) - - -class playPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":play.svg")) - - -class stopPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":stop.svg")) - - -class copyPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":edit-copy.svg")) - self.clicked.connect(self.onClicked) - self._text_to_copy = None - - def setTextToCopy(self, text): - self._text_to_copy = text - - def onClicked(self): - self._original_text = self.text() - if self._text_to_copy is not None: - cb = QApplication.clipboard() - cb.clear(mode=cb.Clipboard) - cb.setText(self._text_to_copy, mode=cb.Clipboard) - - super().setText("Copied!") - self.setIcon(QIcon(":greenTick.svg")) - QTimer.singleShot(2000, self.resetButton) - - def resetButton(self): - self.setText(self._original_text) - self.setIcon(QIcon(":edit-copy.svg")) - - -class OpenFilePushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":folder-open.svg")) - - -class movePushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":folder-move.svg")) - - -class DownloadPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":download.svg")) - - -class showInFileManagerButton(PushButton): - def __init__(self, *args, setDefaultText=False, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":drawer.svg")) - self._path_to_browse = None - if setDefaultText: - self.setDefaultText() - - def setDefaultText(self): - self._text = myutils.get_show_in_file_manager_text() - self.setText(self._text) - - def setPathToBrowse(self, path: os.PathLike): - self._path_to_browse = path - self.clicked.connect(partial(myutils.showInExplorer, path)) - - -class OpenUrlButton(PushButton): - def __init__(self, url, *args, **kwargs): - self._url = url - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":browser.svg")) - self.clicked.connect(self.openUrl) - - def openUrl(self): - QDesktopServices.openUrl(QUrl(self._url)) - - -class LessThanPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":less_than.svg")) - flat = kwargs.get("flat") - if flat is not None: - self.setFlat(True) - - -class showDetailsButton(PushButton): - sigToggled = Signal(bool) - - def __init__(self, *args, txt="Show details...", parent=None): - super().__init__(txt, parent) - # self.setText(txt) - self.txt = txt - self.checkedIcon = QIcon(":hideUp.svg") - self.uncheckedIcon = QIcon(":showDown.svg") - self.setIcon(self.uncheckedIcon) - self.toggled.connect(self.onClicked) - self.setCheckable(True) - w = self.sizeHint().width() + 10 - self.setFixedWidth(w) - - def onClicked(self, checked): - if checked: - self.setText(self.txt.replace("Show", "Hide")) - self.setIcon(self.checkedIcon) - else: - self.setText(self.txt) - self.setIcon(self.uncheckedIcon) - - self.sigToggled.emit(checked) - - -class cancelPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":cancelButton.svg")) - - -class setPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":cog.svg")) - - -class TrainPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":train.svg")) - - -class noPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":no.svg")) - - -class editPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":edit-id.svg")) - - -class delPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":bin.svg")) - - -class eraserPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":eraser.svg")) - - -class CrossCursorPointButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":cross_cursor.svg")) - - -class TestPushButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":test.svg")) +from .components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from .components.progress import QtHandler, QLog, XStream # noqa: E402 +from .components.buttons import * # noqa: E402, F403 +from .components.layout import * # noqa: E402, F403 +from .components.inputs_basic import * # noqa: E402, F403 +from .components.path_controls import * # noqa: E402, F403 + +from .components.lists import * # noqa: E402, F403 +from .components.base import QBaseWindow # noqa: E402 +from .components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + + -class browseFileButton(PushButton): - sigPathSelected = Signal(str) - def __init__( - self, - *args, - ext=None, - title="Select file", - start_dir="", - openFolder=False, - **kwargs, - ): - """PushButton with sigPathSelected Signal to select file or folder - - Parameters - ---------- - ext : dict or None, optional - If not None, this is a dictionary of - {'FILE NAME': ['.ext1', '.ext2', ...]}. - For example, to allow only selection of CSV files, - pass {'CSV': ['.csv']}. - - Note that the 'FILE NAME' is arbitrary. Default is None - title : str, optional - Title of the File Manager window. Default is 'Select file' - start_dir : str, optional - Directory where the File Manager window will initially be open. - Default is '' - openFolder : bool, optional - If True, allows for selection of folders instead of files. - Default is False - """ - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":folder-open.svg")) - self.clicked.connect(self.browse) - - self._title = title - self._start_dir = start_dir - self._openFolder = openFolder - self._file_types = "All Files (*)" - if ext is not None: - s_li = [] - for name, extensions in ext.items(): - _s = "" - if isinstance(extensions, str): - extensions = [extensions] - for ext in extensions: - _s = f"{_s}*{ext} " - s_li.append(f"{name} {_s.strip()}") - - self._file_types = ";;".join(s_li) - self._file_types = f"{self._file_types};;All Files (*)" - - def setStartPath(self, start_path): - self._start_dir = start_path - - def browse(self): - if self._openFolder: - fileDialog = QFileDialog.getExistingDirectory - args = (self, self._title, self._start_dir) - else: - fileDialog = QFileDialog.getOpenFileName - args = (self, self._title, self._start_dir, self._file_types) - file_path = fileDialog(*args) - if not isinstance(file_path, str): - file_path = file_path[0] - if file_path: - self.sigPathSelected.emit(file_path) - - -def getPushButton(buttonText, qparent=None): - isCancelButton = ( - buttonText.lower().find("cancel") != -1 - or buttonText.lower().find("abort") != -1 - ) - isYesButton = ( - buttonText.lower().find("yes") != -1 - or buttonText.lower().find("ok") != -1 - or buttonText.lower().find("continue") != -1 - or buttonText.lower().find("recommended") != -1 - ) - isSettingsButton = buttonText.lower().find("set") != -1 - isNoButton = ( - buttonText.replace(" ", "").lower() == "no" - or buttonText.lower().find("Do not ") != -1 - or buttonText.lower().find("no, ") != -1 - ) - isDelButton = buttonText.lower().find("delete") != -1 - isAddButton = buttonText.lower().find("add ") != -1 - is3Dbutton = buttonText.find(" 3D ") != -1 - is2Dbutton = buttonText.find(" 2D ") != -1 - isSaveButton = buttonText.lower().find("overwrite") != -1 - isNewFileButton = buttonText.lower().find("rename") != -1 - isTryAgainButton = buttonText.lower().find("try again") != -1 - - if isCancelButton: - button = cancelPushButton(buttonText, qparent) - if qparent is not None: - qparent.addCancelButton(button=button) - elif isYesButton: - button = okPushButton(buttonText, qparent) - if qparent is not None: - qparent.okButton = button - elif isSettingsButton: - button = setPushButton(buttonText, qparent) - elif isNoButton: - button = noPushButton(buttonText, qparent) - elif isDelButton: - button = delPushButton(buttonText, qparent) - elif isAddButton: - button = addPushButton(buttonText, qparent) - elif is3Dbutton: - button = threeDPushButton(buttonText, qparent) - elif is2Dbutton: - button = twoDPushButton(buttonText, qparent) - elif isSaveButton: - button = savePushButton(buttonText, qparent) - elif isNewFileButton: - button = newFilePushButton(buttonText, qparent) - elif isTryAgainButton: - button = reloadPushButton(buttonText, qparent) - else: - button = QPushButton(buttonText, qparent) - - return button, isCancelButton - - -def CustomGradientMenuAction(gradient: QLinearGradient, name: str, parent): - pixmap = QPixmap(100, 15) - painter = QPainter(pixmap) - brush = QBrush(gradient) - painter.fillRect(QRect(0, 0, 100, 15), brush) - painter.end() - label = QLabel() - label.setPixmap(pixmap) - label.setContentsMargins(1, 1, 1, 1) - labelName = QLabel(name) - hbox = QHBoxLayout() - delButton = delPushButton() - hbox.addWidget(labelName) - hbox.addStretch(1) - hbox.addWidget(label) - hbox.addWidget(delButton) - widget = QWidget() - widget.setLayout(hbox) - action = QWidgetAction(parent) - action.name = name - action.setDefaultWidget(widget) - action.delButton = delButton - delButton.action = action - return action class ContourItem(pg.PlotCurveItem): @@ -1051,11 +238,6 @@ def restore(self): self.setData(*self._prevData) -class VerticalSpacerEmptyWidget(QWidget): - def __init__(self, parent=None, height=5) -> None: - super().__init__(parent) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - self.setFixedHeight(height) class CustomAnnotationScatterPlotItem(BaseScatterPlotItem): @@ -1063,387 +245,6 @@ def __init__(self, *args, **kargs): super().__init__(*args, **kargs) -class ElidingLineEdit(QLineEdit): - def __init__(self, parent=None, minWidth=None): - super().__init__(parent) - self._text = "" - self._minWidth = minWidth - if minWidth is not None: - self.setMinimumWidth(minWidth) - - self.textEdited.connect(self.setText) - self.installEventFilter(self) - self._elide = True - - def setText(self, text: str, width=None, elide=True) -> None: - if width is None: - width = self._minWidth - - if width is None: - try: - textToPrevRatio = len(text) / len(self.text()) - width = round(self.width() * textToPrevRatio) - except ZeroDivisionError: - width = self.width() - - if width > self.width(): - width = self.width() - - self._text = text - if not elide or not self._elide: - super().setText(text) - return - - fm = QFontMetrics(self.font()) - elidedText = fm.elidedText(text, Qt.ElideLeft, width) - - super().setText(elidedText) - self.setToolTip(text) - - def text(self): - return self._text - - def resizeEvent(self, event): - newWidth = event.size().width() - self.setText(self._text, width=newWidth) - event.accept() - - def eventFilter(self, a0: "QObject", a1: "QEvent") -> bool: - isFocusIn = a1.type() == QEvent.Type.FocusIn - if isFocusIn and (self.isReadOnly() or not self.isEnabled()): - self.clearFocus() - return True - return super().eventFilter(a0, a1) - - def focusInEvent(self, event): - super().focusInEvent(event) - self._elide = False - self.setText(self._text, elide=False) - self.setCursorPosition(len(self.text())) - - def focusOutEvent(self, event): - self._elide = True - super().focusOutEvent(event) - self.setText(self._text) - - -class ValidLineEdit(QLineEdit): - def __init__(self, parent=None): - super().__init__(parent) - - def setInvalidStyleSheet(self): - self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) - - def setValidStyleSheet(self): - self.setStyleSheet("") - - -class KeepIDsLineEdit(ValidLineEdit): - sigIDsChanged = Signal(list) - sigSort = Signal() - sigEnterPressed = Signal() - - def __init__(self, instructionsLabel, parent=None): - super().__init__(parent) - - self.validPattern = "^[0-9-, ]+$" - regExpr = QRegularExpression(self.validPattern) - self.setValidator(QRegularExpressionValidator(regExpr)) - - self.textChanged.connect(self.onTextChanged) - self.editingFinished.connect(self.onEditingFinished) - - self.instructionsText = instructionsLabel.text() - self._label = instructionsLabel - - def keyPressEvent(self, event) -> None: - super().keyPressEvent(event) - if event.text() == ",": - self.sigSort.emit() - elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: - self.sigEnterPressed.emit() - - def onTextChanged(self, text): - IDs = [] - rangesMatch = re.findall(r"(\d+-\d+)", text) - if rangesMatch: - for rangeText in rangesMatch: - start, stop = rangeText.split("-") - start, stop = int(start), int(stop) - IDs.extend(range(start, stop + 1)) - text = re.sub(r"(\d+)-(\d+)", "", text) - IDsMatch = re.findall(r"(\d+)", text) - if IDsMatch: - for ID in IDsMatch: - IDs.append(int(ID)) - self.IDs = sorted(list(set(IDs))) - self.sigIDsChanged.emit(self.IDs) - - def onEditingFinished(self): - self.sigSort.emit() - - def warnNotExistingID(self): - self.setInvalidStyleSheet() - self._label.setText( - " Some of the IDs are not existing --> they will be IGNORED" - ) - self._label.setStyleSheet("color: red") - - def setInstructionsText(self): - self.setValidStyleSheet() - self._label.setText(self.instructionsText) - self._label.setStyleSheet("") - - -class ScrollBar(QScrollBar): - def __init__(self, *args): - super().__init__(*args) - self.installEventFilter(self) - self.setContextMenuPolicy(Qt.NoContextMenu) - - def eventFilter(self, object, event) -> bool: - if event.type() == QEvent.Type.Wheel: - return True - elif event.type() == QEvent.Type.MouseButtonPress: - # Filter right-click to prevent context menu - return event.button() == Qt.MouseButton.RightButton - elif event.type() == QEvent.Type.MouseButtonRelease: - # Filter right-click to prevent context menu - return event.button() == Qt.MouseButton.RightButton - return False - - -class _ReorderableListModel(QAbstractListModel): - """ - ReorderableListModel is a list model which implements reordering of its - items via drag-n-drop - """ - - dragDropFinished = Signal() - - def __init__(self, items, parent=None): - QAbstractItemModel.__init__(self, parent) - self.nodes = items - self.lastDroppedItems = [] - self.pendingRemoveRowsAfterDrop = False - - def rowForItem(self, text): - """ - rowForItem method returns the row corresponding to the passed in item - or None if no such item exists in the model - """ - try: - row = self.nodes.index(text) - except ValueError: - return None - return row - - def index(self, row, column, parent): - if row < 0 or row >= len(self.nodes): - return QModelIndex() - return self.createIndex(row, column) - - def parent(self, index): - return QModelIndex() - - def rowCount(self, index): - if index.isValid(): - return 0 - return len(self.nodes) - - def data(self, index, role): - if not index.isValid(): - return None - if role == Qt.DisplayRole: - row = index.row() - if row < 0 or row >= len(self.nodes): - return None - return self.nodes[row] - elif role == Qt.SizeHintRole: - return QSize(48, 32) - else: - return None - - def supportedDropActions(self): - return Qt.MoveAction - - def flags(self, index): - if not index.isValid(): - return Qt.ItemIsEnabled - return ( - Qt.ItemIsEnabled - | Qt.ItemIsSelectable - | Qt.ItemIsDragEnabled - | Qt.ItemIsDropEnabled - ) - - def insertRows(self, row, count, index): - if index.isValid(): - return False - if count <= 0: - return False - # inserting 'count' empty rows starting at 'row' - self.beginInsertRows(QModelIndex(), row, row + count - 1) - for i in range(0, count): - self.nodes.insert(row + i, "") - self.endInsertRows() - return True - - def removeRows(self, row, count, index): - if index.isValid(): - return False - if count <= 0: - return False - num_rows = self.rowCount(QModelIndex()) - self.beginRemoveRows(QModelIndex(), row, row + count - 1) - for i in range(count, 0, -1): - self.nodes.pop(row - i + 1) - self.endRemoveRows() - - if self.pendingRemoveRowsAfterDrop: - """ - If we got here, it means this call to removeRows is the automatic - 'cleanup' action after drag-n-drop performed by Qt - """ - self.pendingRemoveRowsAfterDrop = False - self.dragDropFinished.emit() - - return True - - def setData(self, index, value, role): - if not index.isValid(): - return False - if index.row() < 0 or index.row() > len(self.nodes): - return False - self.nodes[index.row()] = str(value) - self.dataChanged.emit(index, index) - return True - - def mimeTypes(self): - return ["application/vnd.treeviewdragdrop.list"] - - def mimeData(self, indexes): - mimedata = QMimeData() - encoded_data = QByteArray() - stream = QDataStream(encoded_data, QIODevice.WriteOnly) - for index in indexes: - if index.isValid(): - text = self.data(index, 0) - stream << QByteArray(text.encode("utf-8")) - mimedata.setData("application/vnd.treeviewdragdrop.list", encoded_data) - return mimedata - - def dropMimeData(self, data, action, row, column, parent): - if action == Qt.IgnoreAction: - return True - if not data.hasFormat("application/vnd.treeviewdragdrop.list"): - return False - if column > 0: - return False - - num_rows = self.rowCount(QModelIndex()) - if num_rows <= 0: - return False - - if row < 0: - if parent.isValid(): - row = parent.row() - else: - return False - - encoded_data = data.data("application/vnd.treeviewdragdrop.list") - stream = QDataStream(encoded_data, QIODevice.ReadOnly) - - new_items = [] - rows = 0 - while not stream.atEnd(): - text = QByteArray() - stream >> text - text = bytes(text).decode("utf-8") - index = self.nodes.index(text) - new_items.append((text, index)) - rows += 1 - - self.lastDroppedItems = [] - for text, index in new_items: - target_row = row - if index < row: - target_row += 1 - self.beginInsertRows(QModelIndex(), target_row, target_row) - self.nodes.insert(target_row, self.nodes[index]) - self.endInsertRows() - self.lastDroppedItems.append(text) - row += 1 - - self.pendingRemoveRowsAfterDrop = True - return True - - -class _SelectionModel(QItemSelectionModel): - def __init__(self, parent=None, isSingleSelection=False): - QItemSelectionModel.__init__(self, parent) - self.isSingleSelection = isSingleSelection - - def onModelItemsReordered(self): - new_selection = QItemSelection() - new_index = QModelIndex() - for item in self.model().lastDroppedItems: - row = self.model().rowForItem(item) - if row is None: - continue - new_index = self.model().index(row, 0, QModelIndex()) - new_selection.select(new_index, new_index) - - self.clearSelection() - flags = ( - QItemSelectionModel.SelectionFlag.ClearAndSelect - | QItemSelectionModel.SelectionFlag.Rows - | QItemSelectionModel.SelectionFlag.Current - ) - self.select(new_selection, flags) - self.setCurrentIndex(new_index, flags) - if not self.isSingleSelection: - self.reset() - - -class ReorderableListView(QListView): - def __init__(self, items=None, parent=None, isSingleSelection=False) -> None: - super().__init__(parent) - if items is None: - items = [] - - self.isSingleSelection = isSingleSelection - self._model = _ReorderableListModel(items) - self._selectionModel = _SelectionModel(self._model) - self._model.dragDropFinished.connect(self._selectionModel.onModelItemsReordered) - self.setModel(self._model) - self.setSelectionModel(self._selectionModel) - self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) - self.setDragDropOverwriteMode(False) - styleSheet = f""" - QListView {{ - selection-background-color: rgba(200, 200, 200, 0.30); - selection-color: black; - show-decoration-selected: 1; - }} - QListView::item {{ - border-bottom: 1px solid rgba(180, 180, 180, 0.5); - }} - QListView::item:hover {{ - background-color: rgba(200, 200, 200, 0.30); - }} - """ - self.setStyleSheet(styleSheet) - - def setItems(self, items): - self._model.nodes = items - - def items(self): - return self._model.nodes - - # def mouseReleaseEvent(self, e: QMouseEvent) -> None: - # super().mouseReleaseEvent(e) - # self._selectionModel.reset() class QDialogListbox(QDialog): @@ -1657,345 +458,87 @@ def show(self, block=False): while horizontal_sb.isVisible(): self.resize(self.height(), self.width() + 10) - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class ExpandableListBox(QComboBox): - def __init__(self, parent=None, centered=True) -> None: - super().__init__(parent) - - self.setEditable(True) - self.lineEdit().setReadOnly(True) - - infoTxt = html_utils.paragraph( - "Select Positions to save

    " - "Ctrl+Click to select multiple items
    " - "Shift+Click to select a range of items
    ", - center=True, - ) - - self.listW = QDialogListbox( - "Select Positions to save", infoTxt, [], multiSelection=True, parent=self - ) - - self.listW.listBox.itemClicked.connect(self.listItemClicked) - self.listW.sigSelectionConfirmed.connect(self.updateCombobox) - - self.centered = centered - - def listItemClicked(self, item): - if item.text().find("All") == -1: - return - - for i in range(self.listW.listBox.count()): - _item = self.listW.listBox.item(i) - _item.setSelected(True) - - def clear(self) -> None: - self.listW.listBox.clear() - return super().clear() - - def setItems(self, items): - self.clear() - self.addItems(items) - - def addItems(self, items): - super().addItems(items) - self.listW.listBox.addItems(items) - self.listW.listBox.setCurrentRow(self.currentIndex()) - self.listItemClicked(self.listW.listBox.currentItem()) - if self.centered: - self.centerItems() - - def updateCombobox(self, selectedItemsText): - isAllItem = [i for i, t in enumerate(selectedItemsText) if t.find("All") != -1] - if len(selectedItemsText) == 1: - self.setCurrentText(selectedItemsText[0]) - elif isAllItem: - idx = isAllItem[0] - self.setCurrentText(selectedItemsText[idx]) - else: - super().clear() - super().addItems(["Custom selection"]) - - def centerItems(self, idx=None): - self.lineEdit().setAlignment(Qt.AlignCenter) - - def selectedItems(self): - return self.listW.listBox.selectedItems() - - def selectedItemsText(self): - return [item.text() for item in self.selectedItems()] - - def showPopup(self) -> None: - self.listW.show() - - -class filePathControl(QFrame): - sigValueChanged = Signal(str) - - def __init__( - self, - parent=None, - browseFolder=False, - fileManagerTitle="Select file", - validExtensions=None, - startFolder="", - elide=False, - ): - super().__init__(parent) - - layout = QHBoxLayout() - if elide: - self.le = ElidingLineEdit() - else: - self.le = QLineEdit() - - self.browseButton = browseFileButton( - openFolder=browseFolder, - title=fileManagerTitle, - ext=validExtensions, - start_dir=startFolder, - ) - - layout.addWidget(self.le) - layout.addWidget(self.browseButton) - self.setLayout(layout) - - self.le.editingFinished.connect(self.setTextTooltip) - self.browseButton.sigPathSelected.connect(self.setText) - - self.setFrameStyle(QFrame.Shape.StyledPanel) - - def setText(self, text): - self.le.setText(text) - self.le.setToolTip(text) - self.sigValueChanged.emit(self.le.text()) - - def setTextTooltip(self): - self.le.setToolTip(self.le.text()) - self.sigValueChanged.emit(self.le.text()) - - def path(self): - return self.le.text() - - def showEvent(self, a0: QShowEvent) -> None: - self.le.setFixedHeight(self.browseButton.height()) - return super().showEvent(a0) - - -class FolderPathControl(filePathControl): - def __init__(self, **kwargs): - super().__init__(browseFolder=True, fileManagerTitle="Select folder", **kwargs) - - -class CsvFilePathControl(filePathControl): - def __init__(self, **kwargs): - super().__init__( - browseFolder=False, - fileManagerTitle="Select a CSV file", - validExtensions={"CSV files": [".csv", ".CSV"]}, - **kwargs, - ) - - -class QHWidgetSpacer(QWidget): - def __init__(self, width=10, parent=None) -> None: - super().__init__(parent) - self.setFixedWidth(width) - - -class QVWidgetSpacer(QWidget): - def __init__(self, height=10, parent=None) -> None: - super().__init__(parent) - self.setFixedHeight(height) - - -class QHLine(QFrame): - def __init__(self, shadow="Sunken", parent=None, color=None): - super().__init__(parent) - self.setFrameShape(QFrame.Shape.HLine) - self.setFrameShadow(getattr(QFrame, shadow)) - if color is not None: - self.setColor(color) - - def setColor(self, color): - qcolor = pg.mkColor(color) - pal = self.palette() - pal.setColor(QPalette.ColorRole.WindowText, qcolor) - self.setPalette(pal) - - -class QVLine(QFrame): - def __init__(self, shadow="Plain", parent=None, color=None): - super().__init__(parent) - self.setFrameShape(QFrame.Shape.VLine) - self.setFrameShadow(getattr(QFrame.Shadow, shadow)) - if color is not None: - self.setColor(color) - - def setColor(self, color): - qcolor = pg.mkColor(color) - pal = self.palette() - pal.setColor(QPalette.ColorRole.WindowText, qcolor) - self.setPalette(pal) - - -class VerticalResizeHline(QFrame): - dragged = Signal(object) - clicked = Signal(object) - released = Signal(object) - - def __init__(self): - super().__init__() - self.setCursor(Qt.SplitVCursor) - self.setFrameShape(QFrame.Shape.HLine) - self.setFrameShadow(QFrame.Shadow.Sunken) - self.installEventFilter(self) - self.isMousePressed = False - self._height = 4 - self.setMinimumHeight(self._height) - - def mousePressEvent(self, event) -> None: - self.isMousePressed = True - self.clicked.emit(event) - return super().mousePressEvent(event) - - def mouseMoveEvent(self, event) -> None: - self.dragged.emit(event) - return super().mouseMoveEvent(event) - - def mouseReleaseEvent(self, event) -> None: - self.isMousePressed = False - self.released.emit(event) - return super().mouseReleaseEvent(event) + if block: + self.loop = QEventLoop() + self.loop.exec_() - def eventFilter(self, object, event): - if event.type() == QEvent.Type.Enter: - self.setLineWidth(0) - self.setMidLineWidth(self._height) - pal = self.palette() - pal.setColor(QPalette.ColorRole.WindowText, QColor(BASE_COLOR)) - self.setPalette(pal) - # self.setStyleSheet('background-color: #4d4d4d') - elif event.type() == QEvent.Type.Leave: - self.setMidLineWidth(0) - self.setLineWidth(1) - return False + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() -class GroupBox(QGroupBox): - def __init__(self, *args, keyPressCallback=None): - super().__init__(*args) - self.keyPressCallback = None - self.setFocusPolicy(Qt.NoFocus) +class ExpandableListBox(QComboBox): + def __init__(self, parent=None, centered=True) -> None: + super().__init__(parent) - def keyPressEvent(self, event) -> None: - event.ignore() - if self.keyPressCallback is None: - return + self.setEditable(True) + self.lineEdit().setReadOnly(True) - self.keyPressCallback() + infoTxt = html_utils.paragraph( + "Select Positions to save

    " + "Ctrl+Click to select multiple items
    " + "Shift+Click to select a range of items
    ", + center=True, + ) + self.listW = QDialogListbox( + "Select Positions to save", infoTxt, [], multiSelection=True, parent=self + ) -class CheckBox(QCheckBox): - def __init__(self, *args, keyPressCallback=None): - super().__init__(*args) - self.keyPressCallback = None - self.setFocusPolicy(Qt.NoFocus) + self.listW.listBox.itemClicked.connect(self.listItemClicked) + self.listW.sigSelectionConfirmed.connect(self.updateCombobox) - def keyPressEvent(self, event) -> None: - event.ignore() - if self.keyPressCallback is None: + self.centered = centered + + def listItemClicked(self, item): + if item.text().find("All") == -1: return - self.keyPressCallback() + for i in range(self.listW.listBox.count()): + _item = self.listW.listBox.item(i) + _item.setSelected(True) + def clear(self) -> None: + self.listW.listBox.clear() + return super().clear() -class ScrollArea(QScrollArea): - sigLeaveEvent = Signal() + def setItems(self, items): + self.clear() + self.addItems(items) - def __init__( - self, parent=None, resizeVerticalOnShow=False, dropArrowKeyEvents=False - ) -> None: - super().__init__(parent) - self.setWidgetResizable(True) - self.setFrameStyle(QFrame.Shape.NoFrame) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.containerWidget = None - self.resizeVerticalOnShow = resizeVerticalOnShow - self.isOnlyVertical = False - self.dropArrowKeyEvents = dropArrowKeyEvents - - def setVerticalLayout(self, layout, widget=None): - if widget is None: - self.containerWidget = QWidget() + def addItems(self, items): + super().addItems(items) + self.listW.listBox.addItems(items) + self.listW.listBox.setCurrentRow(self.currentIndex()) + self.listItemClicked(self.listW.listBox.currentItem()) + if self.centered: + self.centerItems() + + def updateCombobox(self, selectedItemsText): + isAllItem = [i for i, t in enumerate(selectedItemsText) if t.find("All") != -1] + if len(selectedItemsText) == 1: + self.setCurrentText(selectedItemsText[0]) + elif isAllItem: + idx = isAllItem[0] + self.setCurrentText(selectedItemsText[idx]) else: - self.containerWidget = widget - self.containerWidget.setLayout(layout) - self.containerWidget.setSizePolicy( - QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred - ) - self.setWidget(self.containerWidget) - self.containerWidget.installEventFilter(self) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.isOnlyVertical = True - - def setWidget(self, widget): - self.containerWidget = widget - super().setWidget(widget) - - def _resizeHorizontal(self): - self.setMinimumWidth( - self.containerWidget.minimumSizeHint().width() - + self.verticalScrollBar().width() - ) + super().clear() + super().addItems(["Custom selection"]) - def minimumWidthNoScrollbar(self) -> int: - width = ( - self.containerWidget.minimumSizeHint().width() - + self.verticalScrollBar().width() - ) - return width + def centerItems(self, idx=None): + self.lineEdit().setAlignment(Qt.AlignCenter) - def minimumHeightNoScrollbar(self) -> int: - height = ( - self.containerWidget.minimumSizeHint().height() - + self.horizontalScrollBar().height() - ) - return height + def selectedItems(self): + return self.listW.listBox.selectedItems() - def _resizeVertical(self): - height = ( - self.containerWidget.minimumSizeHint().height() - + self.horizontalScrollBar().height() - ) - self.containerWidget.setSizePolicy( - QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred - ) + def selectedItemsText(self): + return [item.text() for item in self.selectedItems()] - self.setFixedHeight(height) + def showPopup(self) -> None: + self.listW.show() - def eventFilter(self, object, event: QEvent): - if event.type() == QEvent.Type.Leave: - self.sigLeaveEvent.emit() - if object != self.containerWidget: - return False - isResize = event.type() == QEvent.Type.Resize - isShow = event.type() == QEvent.Type.Show - if isResize and self.isOnlyVertical: - self._resizeHorizontal() - elif isShow and self.resizeVerticalOnShow: - self._resizeVertical() - return False class QClickableLabel(QLabel): @@ -2157,297 +700,6 @@ def enterEvent(self, event): def addLabel(self, label): self._labels.append(label) - - -class OrderableList(listWidget): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.setMouseTracking(True) - self.itemEntered.connect(self.onItemEntered) - - def onItemEntered(self, enteredItem): - enteredRow = self.row(enteredItem) - for i in range(self.count()): - item = self.item(i) - item._container.setLabelsColor(i == enteredRow or item.isSelected()) - - def leaveEvent(self, event): - super().leaveEvent(event) - for i in range(self.count()): - item = self.item(i) - item._container.setLabelsColor(item.isSelected()) - - def addItems(self, items): - self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - nr_items = len(items) - nn = [str(n) for n in range(1, nr_items + 1)] - for i, item in enumerate(items): - itemW = QListWidgetItem() - itemContainer = OrderableListWidget() - itemContainer.setParentItem(itemW) - itemText = QLabel(item) - tableNrLabel = QLabel("| Table nr.") - itemContainer.addLabel(tableNrLabel) - itemContainer.addLabel(itemText) - itemLayout = QHBoxLayout() - itemNumberWidget = QComboBox() - itemNumberWidget.addItems(nn) - itemLayout.addWidget(itemText) - itemLayout.addWidget(tableNrLabel) - itemLayout.addWidget(itemNumberWidget) - itemContainer.setLayout(itemLayout) - itemLayout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize) - itemW.setSizeHint(itemContainer.sizeHint()) - self.addItem(itemW) - self.setItemWidget(itemW, itemContainer) - itemW._text = item - itemW._nrWidget = itemNumberWidget - itemW._container = itemContainer - itemNumberWidget.setDisabled(True) - itemNumberWidget.textActivated.connect(self.onTextActivated) - itemNumberWidget._currentNr = 1 - itemNumberWidget.row = i - itemContainer.sigEnterEvent.connect(self.onItemEntered) - - self.itemSelectionChanged.connect(self.onItemSelectionChanged) - - def keyPressEvent(self, event) -> None: - if event.key() == Qt.Key_Escape: - self.clearSelection() - event.ignore() - return - super().keyPressEvent(event) - - def updateNr(self): - for i in range(self.count()): - item = self.item(i) - item._currentNr = int(item._nrWidget.currentText()) - - def onItemSelectionChanged(self): - for i in range(self.count()): - item = self.item(i) - item._container.setLabelsColor(item.isSelected()) - item._nrWidget.setDisabled(not item.isSelected()) - if item._nrWidget.currentText() != "1": - item._nrWidget.setCurrentText("1") - item._currentNr = 1 - - for i, item in enumerate(self.selectedItems()): - item._nrWidget.setCurrentText(f"{i + 1}") - item._currentNr = i + 1 - - def onTextActivated(self, text): - changedNr = self.sender()._currentNr - for item in self.selectedItems(): - row = self.row(item) - if self.sender().row == row: - changedNr = item._currentNr - continue - - for item in self.selectedItems(): - row = self.row(item) - if self.sender().row == row: - continue - nr = int(item._nrWidget.currentText()) - if nr == int(text): - item._nrWidget.setCurrentText(str(changedNr)) - break - - self.updateNr() - - -class TreeWidget(QTreeWidget): - def __init__(self, *args, multiSelection=False): - super().__init__(*args) - self.setStyleSheet(TREEWIDGET_STYLESHEET) - self.setFont(font) - if multiSelection: - self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - self.itemClicked.connect(self.selectAllChildren) - - self.isCtrlDown = False - self.isShiftDown = False - - def keyPressEvent(self, ev): - if ev.key() == Qt.Key_Escape: - self.clearSelection() - elif ev.key() == Qt.Key_Control: - self.isCtrlDown = True - elif ev.key() == Qt.Key_Shift: - self.isShiftDown = True - - def keyReleaseEvent(self, ev): - if ev.key() == Qt.Key_Control: - self.isCtrlDown = False - elif ev.key() == Qt.Key_Shift: - self.isShiftDown = False - - def onFocusChanged(self): - self.isCtrlDown = False - self.isShiftDown = False - - def selectAllChildren(self, item_or_label): - label = None - if isinstance(item_or_label, QLabel): - label = item_or_label - else: - item = item_or_label - if item.childCount() == 0: - return - - if label is not None: - if not self.isCtrlDown and not self.isShiftDown: - self.clearSelection() - label.item.setSelected(True) - if self.isShiftDown: - selectionStarted = False - it = QTreeWidgetItemIterator(self) - while it: - item = it.value() - if item is None: - break - if item.isSelected(): - selectionStarted = not selectionStarted - if selectionStarted: - item.setSelected(True) - it += 1 - - for item in self.selectedItems(): - if item.parent() is None: - for i in range(item.childCount()): - item.child(i).setSelected(True) - - -class CancelOkButtonsLayout(QHBoxLayout): - def __init__(self, *args, additionalButtons=None): - super().__init__(*args) - - self.cancelButton = cancelPushButton("Cancel") - self.okButton = okPushButton(" Ok ") - - self.addStretch(1) - self.addWidget(self.cancelButton) - self.addSpacing(20) - - if additionalButtons is not None: - for button in additionalButtons: - self.addWidget(button) - - self.addWidget(self.okButton) - - -class TreeWidgetItem(QTreeWidgetItem): - def __init__(self, *args, columnColors=None): - super().__init__(*args) - - if columnColors is not None: - for c, color in enumerate(columnColors): - if color is None: - continue - self.setBackground(c, QBrush(color)) - - -class FilterObject(QObject): - sigFilteredEvent = Signal(object, object) - - def __init__(self) -> None: - super().__init__() - - def eventFilter(self, object, event): - self.sigFilteredEvent.emit(object, event) - return super().eventFilter(object, event) - - -class readOnlyQList(QTextEdit): - def __init__(self, parent=None): - super().__init__(parent) - self.setReadOnly(True) - self.items = [] - - def addItems(self, items): - self.items.extend(items) - items = [str(item) for item in self.items] - columnList = html_utils.paragraph("
    ".join(items)) - self.setText(columnList) - - -class pgScatterSymbolsCombobox(QComboBox): - def __init__(self, parent=None): - super().__init__(parent) - - symbols = [ - "'o' circle (default)", - "'s' square", - "'t' triangle", - "'d' diamond", - "'+' plus", - "'t1' triangle pointing upwards", - "'t2' triangle pointing right side", - "'t3' triangle pointing left side", - "'p' pentagon", - "'h' hexagon", - "'star'", - "'x' cross", - "'arrow_up'", - "'arrow_right'", - "'arrow_down'", - "'arrow_left'", - "'crosshair'", - ] - self.addItems(symbols) - - -class alphaNumericLineEdit(QLineEdit): - sigInvalidCharacterPressed = Signal(str) - sigInvalidCharactersEntered = Signal(object) - - def __init__(self, parent=None, additionalChars="", onlyWarn=False): - super().__init__(parent) - self.validPattern = rf"^[a-zA-Z0-9{additionalChars}_\-]+$" - self.invalidPattern = rf"[^a-zA-Z0-9{additionalChars}_\-]" - - if not onlyWarn: - regExp = QRegularExpression(self.validPattern) - self.setValidator(QRegularExpressionValidator(regExp)) - else: - self.textChanged.connect(self.emitInvalidCharactersEntered) - - def emitInvalidCharactersEntered(self, text): - invalidCharacters = self.invalidCharacters() - if not invalidCharacters: - return - - self.sigInvalidCharactersEntered.emit(set(invalidCharacters)) - - def invalidCharacters(self): - return re.findall(rf"{self.invalidPattern}", self.text()) - - def keyPressEvent(self, event: QKeyEvent): - if not event.text(): - return super().keyPressEvent(event) - - if event.modifiers() & ( - Qt.KeyboardModifier.ControlModifier - | Qt.KeyboardModifier.AltModifier - | Qt.KeyboardModifier.MetaModifier - ): - return super().keyPressEvent(event) - - if not event.text().isprintable(): - return super().keyPressEvent(event) - - super().keyPressEvent(event) - - if event.text() in self.text(): - return - - self.sigInvalidCharacterPressed.emit(event.text()) - - -class NumericCommaLineEdit(QLineEdit): - def __init__(self, parent=None): - super().__init__(parent) - self.validPattern = r"^[0-9,\.]+$" regExp = QRegularExpression(self.validPattern) self.setValidator(QRegularExpressionValidator(regExp)) @@ -3163,28 +1415,6 @@ def closeEvent(self, event): super().closeEvent(event) -class FormLayout(QGridLayout): - def __init__(self): - QGridLayout.__init__(self) - - def addFormWidget( - self, formWidget, leftLabelAlignment=Qt.AlignRight, align=None, row=0 - ): - for col, item in enumerate(formWidget.items): - if col == 0: - alignment = leftLabelAlignment - elif col == 2: - alignment = Qt.AlignLeft - else: - alignment = align - try: - if alignment is None: - self.addWidget(item, row, col) - else: - self.addWidget(item, row, col, alignment=alignment) - except TypeError: - self.addLayout(item, row, col) - def macShortcutToWindows(shortcut: str): if shortcut is None: @@ -6867,86 +5097,6 @@ def showMenu(self, ev): self.menu.popup(ev.screenPos()) -class QLogConsole(QTextEdit): - def __init__(self, parent=None): - super().__init__(parent) - self.setReadOnly(True) - font = QFont() - font.setPixelSize(13) - self.setFont(font) - - def write(self, message): - # Method required by tqdm pbar - message = message.replace("\r ", "") - if message: - self.apppendText(message) - - def append(self, text: str) -> None: - super().append(text) - self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) - - def insertPlainText(self, text: str) -> None: - super().append(text) - self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) - - -class ProgressBar(QProgressBar): - def __init__(self, parent=None): - super().__init__(parent) - palette = self.palette() - palette.setColor(QPalette.ColorRole.Highlight, PROGRESSBAR_QCOLOR) - palette.setColor( - QPalette.ColorRole.HighlightedText, PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR - ) - self.setPalette(palette) - - -class ProgressBarWithETA(ProgressBar): - def __init__(self, parent=None): - self.parent = parent - super().__init__(parent=parent) - self.ETA_label = QLabel("NDh:NDm:NDs") - - def update(self, step: int): - self.setValue(self.value() + step) - t = time.perf_counter() - if not hasattr(self, "last_time_update"): - self.last_time_update = t - self.mean_value_duration = None - return - seconds_per_value = (t - self.last_time_update) / step - value_left = self.maximum() - self.value() - if self.mean_value_duration is None: - self.mean_value_duration = seconds_per_value - else: - self.mean_value_duration = ( - self.mean_value_duration * (self.value() - 1) + seconds_per_value - ) / self.value() - - seconds_left = self.mean_value_duration * value_left - ETA = myutils.seconds_to_ETA(seconds_left) - self.ETA_label.setText(ETA) - self.last_time_update = t - return ETA - - def show(self): - QProgressBar.show(self) - self.ETA_label.show() - - def hide(self): - QProgressBar.hide(self) - self.ETA_label.hide() - - -class NoneWidget: - def __init__(self): - pass - - def value(self): - return None - - def setValue(self, value): - return class MainPlotItem(pg.PlotItem): @@ -8105,108 +6255,7 @@ def PostProcessSegmWidget( # painter.end() -class LoadingCircleAnimation(QLabel): - def __init__(self, size=32, motionBlur=False, parent=None): - super().__init__(parent) - # layout = QHBoxLayout() - - # self._label = QLabel() - self.setAlignment(Qt.AlignCenter) - self._size = size + size % 2 - self._radius = int(self._size / 2) - self.setFixedSize(self._size, self._size) - self._dotDiameter = int(self._size * 0.15) - self._dotDiameter = self._dotDiameter + self._dotDiameter % 2 - self._dotRadius = int(self._dotDiameter / 2) - - self._rgb = _palettes.getPainterColor()[:3] - self._index = 0 - - self.setBrushesAndAngles() - - if motionBlur: - blurEffect = QGraphicsBlurEffect() - blurRadius = self._size * 0.02 - if blurRadius < 1: - blurRadius = 1 - blurEffect.setBlurRadius(blurRadius) - self.setGraphicsEffect(blurEffect) - - self.animation = QPropertyAnimation(self, b"index", self) - self.animation.setStartValue(0) - self.animation.setEndValue(11) - self.animation.setLoopCount(-1) - self.animation.setDuration(1200) - self.animation.start() - - self.update() - - def setVisible(self, visible): - if visible: - self.animation.start() - else: - self.animation.stop() - super().setVisible(visible) - - def setBrushesAndAngles(self): - self._brushes = [] - self._pens = [] - alphas = np.round(np.linspace(0, 255, 12)).astype(int) - self._angles = np.arange(0, 360, 30) - for alpha in alphas: - color = QColor(*self._rgb, alpha) - self._brushes.append(pg.mkBrush(color)) - self._pens.append(pg.mkPen(color)) - - @Property(int) - def index(self): - return self._index - - @index.setter - def index(self, value): - self._index = value - self.update() - - def paintEvent(self, event): - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - painter.translate(self._radius, self._radius) - for i in range(12): - idx = i - self._index - angle = self._angles[i] - painter.setBrush(self._brushes[idx]) - painter.setPen(self._pens[idx]) - x = (self._radius - self._dotRadius) * math.cos(angle * math.pi / 180) - y = (self._radius - self._dotRadius) * math.sin(angle * math.pi / 180) - painter.drawEllipse(QPointF(x, y), self._dotRadius, self._dotRadius) - - painter.end() - - -class QBaseWindow(QMainWindow): - def __init__(self, parent=None): - super().__init__(parent) - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - def keyPressEvent(self, event) -> None: - if event.key() == Qt.Key_Escape: - event.ignore() - return - super().keyPressEvent(event) class ScrollBarWithNumericControl(QWidget): diff --git a/tests/test_components_imports.py b/tests/test_components_imports.py new file mode 100644 index 000000000..675477a56 --- /dev/null +++ b/tests/test_components_imports.py @@ -0,0 +1,27 @@ +"""Smoke tests for component module imports.""" + +import importlib +import unittest + + +COMPONENT_MODULES = [ + "cellacdc.components.palette", + "cellacdc.components.base", + "cellacdc.components.inputs_basic", +] + + +class TestComponentImports(unittest.TestCase): + def test_leaf_component_modules_import(self): + for module_name in COMPONENT_MODULES: + with self.subTest(module=module_name): + importlib.import_module(module_name) + + def test_widgets_module_compiles(self): + import py_compile + + py_compile.compile("cellacdc/widgets.py", doraise=True) + + +if __name__ == "__main__": + unittest.main() From f4b9259a4bd63e88d9cec6f0f5d0fae764266d3a Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 15:45:25 +0200 Subject: [PATCH 16/21] Add LangGraph-style workflow graphs for segm, measurements, and INI runs. Model CLI/GUI pipelines as composable state graphs and wire kernels, workers, and batch orchestration through them to decouple pipeline flow from imperative kernel logic. Co-authored-by: Cursor --- cellacdc/_run.py | 61 +- cellacdc/cli.py | 648 ++++++------------ cellacdc/workers.py | 224 ++---- cellacdc/workflow/__init__.py | 58 ++ cellacdc/workflow/adapters.py | 175 +++++ cellacdc/workflow/constants.py | 2 + cellacdc/workflow/graph.py | 105 +++ cellacdc/workflow/pipelines/__init__.py | 23 + cellacdc/workflow/pipelines/batch.py | 102 +++ cellacdc/workflow/pipelines/batch_graph.py | 57 ++ cellacdc/workflow/pipelines/full_workflow.py | 60 ++ .../workflow/pipelines/interactive_segm.py | 28 + .../pipelines/interactive_segm_nodes.py | 112 +++ .../pipelines/interactive_video_segm.py | 24 + .../pipelines/interactive_video_segm_nodes.py | 118 ++++ cellacdc/workflow/pipelines/measurements.py | 30 + .../pipelines/measurements_batch_graph.py | 57 ++ .../workflow/pipelines/measurements_gui.py | 35 + .../pipelines/measurements_gui_batch_graph.py | 70 ++ .../pipelines/measurements_gui_nodes.py | 135 ++++ .../workflow/pipelines/measurements_nodes.py | 61 ++ .../workflow/pipelines/postprocess_nodes.py | 39 ++ cellacdc/workflow/pipelines/segm.py | 67 ++ cellacdc/workflow/pipelines/segm_nodes.py | 528 ++++++++++++++ cellacdc/workflow/runnable.py | 52 ++ cellacdc/workflow/state.py | 239 +++++++ tests/test_workflow_graph.py | 234 +++++++ 27 files changed, 2730 insertions(+), 614 deletions(-) create mode 100644 cellacdc/workflow/__init__.py create mode 100644 cellacdc/workflow/adapters.py create mode 100644 cellacdc/workflow/constants.py create mode 100644 cellacdc/workflow/graph.py create mode 100644 cellacdc/workflow/pipelines/__init__.py create mode 100644 cellacdc/workflow/pipelines/batch.py create mode 100644 cellacdc/workflow/pipelines/batch_graph.py create mode 100644 cellacdc/workflow/pipelines/full_workflow.py create mode 100644 cellacdc/workflow/pipelines/interactive_segm.py create mode 100644 cellacdc/workflow/pipelines/interactive_segm_nodes.py create mode 100644 cellacdc/workflow/pipelines/interactive_video_segm.py create mode 100644 cellacdc/workflow/pipelines/interactive_video_segm_nodes.py create mode 100644 cellacdc/workflow/pipelines/measurements.py create mode 100644 cellacdc/workflow/pipelines/measurements_batch_graph.py create mode 100644 cellacdc/workflow/pipelines/measurements_gui.py create mode 100644 cellacdc/workflow/pipelines/measurements_gui_batch_graph.py create mode 100644 cellacdc/workflow/pipelines/measurements_gui_nodes.py create mode 100644 cellacdc/workflow/pipelines/measurements_nodes.py create mode 100644 cellacdc/workflow/pipelines/postprocess_nodes.py create mode 100644 cellacdc/workflow/pipelines/segm.py create mode 100644 cellacdc/workflow/pipelines/segm_nodes.py create mode 100644 cellacdc/workflow/runnable.py create mode 100644 cellacdc/workflow/state.py create mode 100644 tests/test_workflow_graph.py diff --git a/cellacdc/_run.py b/cellacdc/_run.py index 5e8b68b6b..3892222d0 100644 --- a/cellacdc/_run.py +++ b/cellacdc/_run.py @@ -574,22 +574,33 @@ def showEvent(self, event): def run_segm_workflow(workflow_params, logger, log_path): logger.info("Initializing segmentation and tracking kernel...") from cellacdc import cli + from cellacdc.workflow.adapters import ( + runnable_config_from_segm_kernel, + sync_segm_kernel_from_context, + ) + from cellacdc.workflow.pipelines.batch import run_segm_batch kernel = cli.SegmKernel(logger, log_path, is_cli=True) kernel.init_args_from_params(workflow_params, logger.info) ch_filepaths = kernel.parse_paths(workflow_params) stop_frame_nums = kernel.parse_stop_frame_numbers(workflow_params) pbar = tqdm(total=len(ch_filepaths), ncols=100) - for ch_filepath, stop_frame_n in zip(ch_filepaths, stop_frame_nums): - logger.info(f'\nProcessing "{ch_filepath}"...') - kernel.run(ch_filepath, stop_frame_n) - pbar.update() + run_segm_batch( + kernel._workflow_ctx, + ch_filepaths, + stop_frame_nums, + runnable_config_from_segm_kernel(kernel), + progress=pbar, + ) + sync_segm_kernel_from_context(kernel, kernel._workflow_ctx) pbar.close() def run_measurements_workflow(workflow_params, logger, log_path): logger.info("Initializing measurements kernel...") from cellacdc import cli + from cellacdc.workflow.pipelines.batch import run_measurements_batch + from cellacdc.workflow.runnable import RunnableConfig kernel = cli.ComputeMeasurementsKernel(logger, log_path, is_cli=True) ch_filepaths = kernel.parse_paths(workflow_params) @@ -597,19 +608,22 @@ def run_measurements_workflow(workflow_params, logger, log_path): end_filename_segm = workflow_params["measurements"]["end_filename_segm"] kernel.set_metrics_from_workflow_config_params(workflow_params["measurements"]) pbar = tqdm(total=len(ch_filepaths), ncols=100) - for ch_filepath, stop_frame_n in zip(ch_filepaths, stop_frame_nums): - logger.info(f'\nProcessing "{ch_filepath}"...') - kernel.run( - img_path=ch_filepath, - stop_frame_n=stop_frame_n, - end_filename_segm=end_filename_segm, - ) - pbar.update() + run_measurements_batch( + kernel, + ch_filepaths, + stop_frame_nums, + end_filename_segm, + RunnableConfig(logger_func=logger.info), + progress=pbar, + ) pbar.close() def run_cli(ini_filepath): from cellacdc import myutils + from cellacdc.workflow.pipelines.full_workflow import build_full_workflow_graph + from cellacdc.workflow.runnable import RunnableConfig + from cellacdc.workflow.state import FullWorkflowState logger, logs_path, log_path, log_filename = myutils.setupLogger( module="cli", logs_path=None @@ -622,14 +636,25 @@ def run_cli(ini_filepath): workflow_params = load.read_segm_workflow_from_config(ini_filepath) workflow_type = workflow_params["workflow"]["type"] + run_segm = workflow_type == "segmentation and/or tracking" + run_measurements = "measurements" in workflow_params - if workflow_type == "segmentation and/or tracking": - run_segm_workflow(workflow_params, logger, log_path) - - if "measurements" in workflow_params.keys(): + meas_params = None + if run_measurements: logger.info("Loading measurements workflow...") - meas_workflow_params = load.read_measurements_workflow_from_config(ini_filepath) - run_measurements_workflow(meas_workflow_params, logger, log_path) + meas_params = load.read_measurements_workflow_from_config(ini_filepath) + + workflow_ctx = type("WorkflowCliContext", (), {"logger": logger, "log_path": log_path})() + graph = build_full_workflow_graph(workflow_ctx).compile() + graph.invoke( + FullWorkflowState( + segm_params=workflow_params, + measurements_params=meas_params, + run_segm=run_segm, + run_measurements=run_measurements, + ), + RunnableConfig(logger_func=logger.info), + ) logger.info("**********************************************") logger.info(f"Cell-ACDC command-line closed. {myutils.get_salute_string()}") diff --git a/cellacdc/cli.py b/cellacdc/cli.py index 1154b43ad..822f54bf0 100644 --- a/cellacdc/cli.py +++ b/cellacdc/cli.py @@ -252,6 +252,11 @@ def init_args( self.init_tracker( self.do_tracking, track_params, tracker_name=tracker_name, tracker=tracker ) + from cellacdc.workflow.adapters import workflow_context_from_segm_kernel + from cellacdc.workflow.pipelines.segm import build_position_segm_graph + + self._workflow_ctx = workflow_context_from_segm_kernel(self) + self._position_segm_graph = build_position_segm_graph(self._workflow_ctx).compile() @exception_handler_cli def init_segm_model(self, posData): @@ -278,6 +283,11 @@ def init_segm_model(self, posData): self.is_segment3DT_available = any( [name == "segment3DT" for name in dir(self.model)] ) + if hasattr(self, "_workflow_ctx"): + self._workflow_ctx.model = self.model + self._workflow_ctx.is_segment3dt_available = self.is_segment3DT_available + self._workflow_ctx.init_model_kwargs = dict(self.init_model_kwargs or {}) + self._workflow_ctx.model_kwargs = dict(self.model_kwargs or {}) @exception_handler_cli def init_tracker(self, do_tracking, track_params, tracker_name="", tracker=None): @@ -315,373 +325,20 @@ def _tracker_track(self, lab, tracker_input_img=None): @exception_handler_cli def run(self, img_path, stop_frame_n): - posData = load.loadData(img_path, self.user_ch_name) - - self.logger_func(f"Loading {posData.relPath}...") - - posData.getBasenameAndChNames() - posData.buildPaths() - posData.loadImgData() - posData.loadOtherFiles( - load_segm_data=False, - load_acdc_df=False, - load_shifts=True, - loadSegmInfo=True, - load_delROIsInfo=False, - load_dataPrep_ROIcoords=True, - load_bkgr_data=True, - load_last_tracked_i=False, - load_metadata=True, - load_dataprep_free_roi=True, - end_filename_segm=self.segm_endname, + from cellacdc.workflow.adapters import ( + runnable_config_from_segm_kernel, + sync_segm_kernel_from_context, + update_workflow_context_from_segm_kernel, ) - # Get only name from the string 'segm_.npz' - endName = ( - self.segm_endname.replace("segm", "", 1).replace("_", "", 1).split(".")[0] - ) - if endName: - # Create a new file that is not the default 'segm.npz' - posData.setFilePaths(endName) - - segmFilename = os.path.basename(posData.segm_npz_path) - if self.do_save: - self.logger_func(f"\nSegmentation file {segmFilename}...") - - posData.SizeT = self.SizeT - if self.SizeZ > 1: - SizeZ = posData.img_data.shape[-3] - posData.SizeZ = SizeZ - else: - posData.SizeZ = 1 - - posData.isSegm3D = self.isSegm3D - posData.saveMetadata() - - isROIactive = False - if posData.dataPrep_ROIcoords is not None and self.use_ROI: - df_roi = posData.dataPrep_ROIcoords.loc[0] - isROIactive = df_roi.at["cropped", "value"] == 0 - x0, x1, y0, y1 = df_roi["value"].astype(int)[:4] - Y, X = posData.img_data.shape[-2:] - x0 = x0 if x0 > 0 else 0 - y0 = y0 if y0 > 0 else 0 - x1 = x1 if x1 < X else X - y1 = y1 if y1 < Y else Y - - # Note that stop_i is not used when SizeT == 1 so it does not matter - # which value it has in that case - stop_i = stop_frame_n - - if self.second_channel_name is not None: - self.logger_func(f'Loading second channel "{self.second_channel_name}"...') - secondChFilePath = load.get_filename_from_channel( - posData.images_path, self.second_channel_name - ) - secondChImgData = load.load_image_file(secondChFilePath) - - if posData.SizeT > 1: - self.t0 = 0 - if posData.SizeZ > 1 and not self.isSegm3D and not self.use3DdataFor2Dsegm: - # 2D segmentation on 3D data over time - img_data = posData.img_data - - if self.second_channel_name is not None: - second_ch_data_slice = secondChImgData[self.t0 : stop_i] - if isROIactive: - Y, X = img_data.shape[-2:] - img_data = img_data[:, :, y0:y1, x0:x1] - if self.second_channel_name is not None: - second_ch_data_slice = second_ch_data_slice[:, :, y0:y1, x0:x1] - pad_info = ((0, 0), (y0, Y - y1), (x0, X - x1)) - - img_data_slice = img_data[self.t0 : stop_i] - postprocess_img = img_data - - Y, X = img_data.shape[-2:] - newShape = (stop_i, Y, X) - img_data = np.zeros(newShape, img_data.dtype) - - if self.second_channel_name is not None: - second_ch_data = np.zeros(newShape, secondChImgData.dtype) - df = posData.segmInfo_df.loc[posData.filename] - for z_info in df[:stop_i].itertuples(): - i = z_info.Index - z = z_info.z_slice_used_dataPrep - zProjHow = z_info.which_z_proj - img = img_data_slice[i] - if self.second_channel_name is not None: - second_ch_img = second_ch_data_slice[i] - if zProjHow == "single z-slice": - img_data[i] = img[z] - if self.second_channel_name is not None: - second_ch_data[i] = second_ch_img[z] - elif zProjHow == "max z-projection": - img_data[i] = img.max(axis=0) - if self.second_channel_name is not None: - second_ch_data[i] = second_ch_img.max(axis=0) - elif zProjHow == "mean z-projection": - img_data[i] = img.mean(axis=0) - if self.second_channel_name is not None: - second_ch_data[i] = second_ch_img.mean(axis=0) - elif zProjHow == "median z-proj.": - img_data[i] = np.median(img, axis=0) - if self.second_channel_name is not None: - second_ch_data[i] = np.median(second_ch_img, axis=0) - elif posData.SizeZ > 1 and (self.isSegm3D or self.use3DdataFor2Dsegm): - # 3D segmentation on 3D data over time - img_data = posData.img_data[self.t0 : stop_i] - postprocess_img = img_data - if self.second_channel_name is not None: - second_ch_data = secondChImgData[self.t0 : stop_i] - if isROIactive: - Y, X = img_data.shape[-2:] - img_data = img_data[:, :, y0:y1, x0:x1] - if self.second_channel_name is not None: - second_ch_data = second_ch_data[:, :, y0:y1, x0:x1] - pad_info = ((0, 0), (0, 0), (y0, Y - y1), (x0, X - x1)) - else: - # 2D data over time - img_data = posData.img_data[self.t0 : stop_i] - postprocess_img = img_data - if self.second_channel_name is not None: - second_ch_data = secondChImgData[self.t0 : stop_i] - if isROIactive: - Y, X = img_data.shape[-2:] - img_data = img_data[:, y0:y1, x0:x1] - if self.second_channel_name is not None: - second_ch_data = second_ch_data[:, :, y0:y1, x0:x1] - pad_info = ((0, 0), (y0, Y - y1), (x0, X - x1)) - else: - if posData.SizeZ > 1 and not self.isSegm3D and not self.use3DdataFor2Dsegm: - img_data = posData.img_data - if self.second_channel_name is not None: - second_ch_data = secondChImgData - if isROIactive: - Y, X = img_data.shape[-2:] - pad_info = ((y0, Y - y1), (x0, X - x1)) - img_data = img_data[:, y0:y1, x0:x1] - if self.second_channel_name is not None: - second_ch_data = second_ch_data[:, :, y0:y1, x0:x1] - - postprocess_img = img_data - # 2D segmentation on single 3D image - z_info = posData.segmInfo_df.loc[posData.filename].iloc[0] - z = z_info.z_slice_used_dataPrep - zProjHow = z_info.which_z_proj - if zProjHow == "single z-slice": - img_data = img_data[z] - if self.second_channel_name is not None: - second_ch_data = second_ch_data[z] - elif zProjHow == "max z-projection": - img_data = img_data.max(axis=0) - if self.second_channel_name is not None: - second_ch_data = second_ch_data.max(axis=0) - elif zProjHow == "mean z-projection": - img_data = img_data.mean(axis=0) - if self.second_channel_name is not None: - second_ch_data = second_ch_data.mean(axis=0) - elif zProjHow == "median z-proj.": - img_data = np.median(img_data, axis=0) - if self.second_channel_name is not None: - second_ch_data[i] = np.median(second_ch_data, axis=0) - elif posData.SizeZ > 1 and (self.isSegm3D or self.use3DdataFor2Dsegm): - # 3D segmentation on 3D z-stack - img_data = posData.img_data - if self.second_channel_name is not None: - second_ch_data = secondChImgData - if isROIactive: - Y, X = img_data.shape[-2:] - pad_info = ((0, 0), (y0, Y - y1), (x0, X - x1)) - img_data = img_data[:, y0:y1, x0:x1] - if self.second_channel_name is not None: - second_ch_data = second_ch_data[:, y0:y1, x0:x1] - postprocess_img = img_data - else: - # Single 2D image - img_data = posData.img_data - if self.second_channel_name is not None: - second_ch_data = secondChImgData - if isROIactive: - Y, X = img_data.shape[-2:] - pad_info = ((y0, Y - y1), (x0, X - x1)) - img_data = img_data[y0:y1, x0:x1] - if self.second_channel_name is not None: - second_ch_data = second_ch_data[y0:y1, x0:x1] - postprocess_img = img_data - - self.logger_func(f"\nImage shape = {img_data.shape}") - - if self.model is None: - self.init_segm_model(posData) - - if self.model is None: - self.logger_func( - f"\nSegmentation model {self.model_name} was not initialized!" - ) - return - - """Segmentation routine""" - self.logger_func(f"\nSegmenting with {self.model_name}...") - t0 = time.perf_counter() - if posData.SizeT > 1: - if self.innerPbar_available and self.signals is not None: - self.signals.resetInnerPbar.emit(len(img_data)) - - if self.is_segment3DT_available and img_data.ndim == 3: - self.model_kwargs["signals"] = (self.signals, self.innerPbar_available) - if self.second_channel_name is not None: - img_data = self.model.second_ch_img_to_stack( - img_data, second_ch_data - ) - lab_stack = core.segm_model_segment( - self.model, - img_data, - self.model_kwargs, - is_timelapse_model_and_data=True, - preproc_recipe=self.preproc_recipe, - posData=posData, - ) - if self.innerPbar_available: - # emit one pos done - self.signals.progressBar.emit(1) - else: - lab_stack = [] - pbar = tqdm(total=len(img_data), ncols=100) - for t, img in enumerate(img_data): - if self.second_channel_name is not None: - img = self.model.second_ch_img_to_stack(img, second_ch_data[t]) - - lab = core.segm_model_segment( - self.model, - img, - self.model_kwargs, - frame_i=t, - preproc_recipe=self.preproc_recipe, - posData=posData, - ) - lab_stack.append(lab) - if self.innerPbar_available: - self.signals.innerProgressBar.emit(1) - else: - self.signals.progressBar.emit(1) - pbar.update() - pbar.close() - lab_stack = np.array(lab_stack, dtype=np.uint32) - if self.innerPbar_available: - # emit one pos done - self.signals.progressBar.emit(1) - else: - if self.second_channel_name is not None: - img_data = self.model.second_ch_img_to_stack(img_data, second_ch_data) - - lab_stack = core.segm_model_segment( - self.model, - img_data, - self.model_kwargs, - frame_i=0, - preproc_recipe=self.preproc_recipe, - posData=posData, - ) - self.signals.progressBar.emit(1) - # lab_stack = smooth_contours(lab_stack, radius=2) - - posData.saveSamEmbeddings(logger_func=self.logger_func) - - if len(posData.dataPrepFreeRoiPoints) > 0 and self.use_freehand_ROI: - self.logger_func("Removing objects outside the dataprep free-hand ROI...") - lab_stack = posData.clearSegmObjsDataPrepFreeRoi( - lab_stack, is_timelapse=posData.SizeT > 1 - ) - - if self.do_postprocess: - if posData.SizeT > 1: - pbar = tqdm(total=len(lab_stack), ncols=100) - for t, lab in enumerate(lab_stack): - lab_cleaned = core.post_process_segm( - lab, **self.standard_postrocess_kwargs - ) - lab_stack[t] = lab_cleaned - if self.custom_postproc_features: - lab_filtered = features.custom_post_process_segm( - posData, - self.custom_postproc_grouped_features, - lab_cleaned, - postprocess_img, - t, - posData.filename, - posData.user_ch_name, - self.custom_postproc_features, - ) - lab_stack[t] = lab_filtered - pbar.update() - pbar.close() - else: - lab_stack = core.post_process_segm( - lab_stack, **self.standard_postrocess_kwargs - ) - if self.custom_postproc_features: - lab_stack = features.custom_post_process_segm( - posData, - self.custom_postproc_grouped_features, - lab_stack, - postprocess_img, - 0, - posData.filename, - posData.user_ch_name, - self.custom_postproc_features, - ) - - if posData.SizeT > 1 and self.do_tracking: - self.logger_func(f"\nTracking with {self.tracker_name} tracker...") - if self.do_save: - # Since tracker could raise errors we save the not-tracked - # version which will eventually be overwritten - self.logger_func(f"Saving NON-tracked masks of {posData.relPath}...") - io.savez_compressed(posData.segm_npz_path, lab_stack) - - self.signals.innerPbar_available = self.innerPbar_available - self.track_params["signals"] = self.signals - if self.image_channel_tracker is not None: - # Check if loading the image for the tracker is required - if "image" in self.track_params: - trackerInputImage = self.track_params.pop("image") - else: - self.logger_func( - f'Loading image data of channel "{self.image_channel_tracker}"' - ) - trackerInputImage = posData.loadChannelData( - self.image_channel_tracker - ) - tracked_stack = self._tracker_track( - lab_stack, tracker_input_img=trackerInputImage - ) - else: - tracked_stack = self._tracker_track(lab_stack) - posData.fromTrackerToAcdcDf(self.tracker, tracked_stack, save=True) - else: - tracked_stack = lab_stack - try: - if self.innerPbar_available: - self.signals.innerProgressBar.emit(stop_frame_n) - else: - self.signals.progressBar.emit(stop_frame_n) - except AttributeError: - if self.innerPbar_available: - self.signals.innerProgressBar.emit(1) - else: - self.signals.progressBar.emit(1) - - if isROIactive: - self.logger_func(f"Padding with zeros {pad_info}...") - tracked_stack = np.pad(tracked_stack, pad_info, mode="constant") - - if self.do_save: - self.logger_func(f"Saving {posData.relPath}...") - io.savez_compressed(posData.segm_npz_path, tracked_stack) + from cellacdc.workflow.state import PositionState - t_end = time.perf_counter() - - self.logger_func(f"\n{posData.relPath} done.") + update_workflow_context_from_segm_kernel(self._workflow_ctx, self) + state = self._position_segm_graph.invoke( + PositionState(img_path=img_path, stop_frame_n=stop_frame_n), + runnable_config_from_segm_kernel(self), + ) + sync_segm_kernel_from_context(self, self._workflow_ctx) + return state class ComputeMeasurementsKernel(_WorkflowKernel): @@ -1042,50 +699,18 @@ def init_signals(self, computeMetricsWorker, saveDataWorker): self.customMetricsCritical = saveDataWorker.customMetricsCritical self.regionPropsCritical = saveDataWorker.regionPropsCritical - @exception_handler_cli - def run( + def _run_metrics_cli( self, - img_path: os.PathLike = "", - stop_frame_n: int = 1, - end_filename_segm: str = "", - computeMetricsWorker=None, - saveDataWorker=None, - posData=None, - save_metrics=True, - do_init_metrics=True, + posData, + stop_frame_n: int, + save_metrics: bool = True, last_cca_frame_i=None, ): - if posData is None: - posData = self._load_posData(img_path, end_filename_segm) - channel_names = posData.chNames - images_path = posData.images_path exp_foldername = os.path.basename(posData.exp_path) self._set_metrics_func_from_posData(posData) - - if computeMetricsWorker is not None and do_init_metrics: - computeMetricsWorker.emitSigInitMetricsDialog(posData) - if computeMetricsWorker.abort: - computeMetricsWorker.signals.finished.emit(computeMetricsWorker) - return - - if self.setup_done: - computeMetricsWorker.signals.finished.emit(computeMetricsWorker) - return - - computeMetricsWorker.emitSigAskRunNow() - if computeMetricsWorker.abort or computeMetricsWorker.savedToWorkflow: - computeMetricsWorker.signals.finished.emit(computeMetricsWorker) - return - - if not posData.segmFound: - rel_path = f"...{os.sep}{exp_foldername}{os.sep}{posData.pos_foldername}" - self.log(f'Skipping "{rel_path}" because segm. file was not found.') - return - - self.init_signals(computeMetricsWorker, saveDataWorker) - + self.init_signals(None, None) self.log( "Loading the following files:\n" f"Segmentation file name: {os.path.basename(posData.segm_npz_path)}\n" @@ -1093,25 +718,104 @@ def run( ) posData.init_segmInfo_df() - - if computeMetricsWorker is not None: - computeMetricsWorker.emitSigComputeVolume(posData, stop_frame_n) - self._init_metrics_to_save(posData) - if computeMetricsWorker is not None: - computeMetricsWorker.signals.initProgressBar.emit(stop_frame_n) - channels_to_load = [ ch for ch in channel_names - if not ch in self.chNamesToSkip and ch in self.chNamesToProcess + if ch not in self.chNamesToSkip and ch in self.chNamesToProcess ] - self.log(f"Loading channels {channels_to_load}...") - self._load_image_data(posData, channels_to_load) + acdc_df_li = [] + keys = [] + for frame_i in range(stop_frame_n): + lab = posData.segm_data[frame_i] + if not np.any(lab): + continue + + if frame_i == 0: + self.log("\nComputing cell volume...") + rp = skimage.measure.regionprops(lab) + rp = self._calc_volume_metrics(rp, posData) + + posData.lab = lab + posData.rp = rp + + if posData.acdc_df is None: + acdc_df = myutils.getBaseAcdcDf(rp) + else: + try: + acdc_df = posData.acdc_df.loc[frame_i].copy() + except Exception: + acdc_df = myutils.getBaseAcdcDf(rp) + + key = (frame_i, posData.TimeIncrement * frame_i) + acdc_df = load.pd_bool_and_float_to_int_to_str( + acdc_df, inplace=False, colsToCastInt=[] + ) + + if not save_metrics: + acdc_df_li.append(acdc_df) + keys.append(key) + continue + + try: + acdc_df = self._add_volume_metrics(acdc_df, rp, posData) + acdc_df, calc_metrics_addtional_args = self._init_calc_metrics( + acdc_df, rp, frame_i, lab, posData, saveDataWorker=None + ) + acdc_df = self._calc_metrics_iter_channels( + acdc_df, rp, frame_i, lab, posData, *calc_metrics_addtional_args + ) + except Exception as error: + self.log(f"\n{traceback.format_exc()}") + + if frame_i == 0: + acdc_df_li.append(acdc_df) + keys.append(key) + continue + + try: + prev_lab = posData.segm_data[frame_i - 1] + acdc_df = self._add_velocity_measurement( + acdc_df, prev_lab, lab, posData + ) + except Exception as error: + self.log(f"\n{traceback.format_exc()}") + + acdc_df_li.append(acdc_df) + keys.append(key) + + if not acdc_df_li: + print("-" * 30) + self.log( + "All selected positions in the experiment folder " + f"{exp_foldername} have EMPTY segmentation mask. " + "Metrics will not be saved." + ) + print("-" * 30) + return + + self._concat_and_save_acdc_df( + acdc_df_li, + keys, + posData, + save_metrics, + computeMetricsWorker=None, + saveDataWorker=None, + last_cca_frame_i=last_cca_frame_i, + ) + + def _compute_metrics_gui_frames( + self, + posData, + stop_frame_n, + save_metrics=True, + computeMetricsWorker=None, + saveDataWorker=None, + ): acdc_df_li = [] keys = [] for frame_i in range(stop_frame_n): @@ -1122,7 +826,6 @@ def run( lab = posData.segm_data[frame_i] if not np.any(lab): - # Empty segmentation mask --> skip continue acdc_df = None @@ -1148,7 +851,7 @@ def run( else: try: acdc_df = posData.acdc_df.loc[frame_i].copy() - except: + except Exception: acdc_df = myutils.getBaseAcdcDf(rp) key = (frame_i, posData.TimeIncrement * frame_i) @@ -1197,8 +900,9 @@ def run( traceback_format = traceback.format_exc() self.log(f"\n{traceback_format}") if computeMetricsWorker is not None: - e = str(error) - computeMetricsWorker.standardMetricsErrors[e] = traceback_format + computeMetricsWorker.standardMetricsErrors[str(error)] = ( + traceback_format + ) acdc_df_li.append(acdc_df) keys.append(key) @@ -1209,23 +913,109 @@ def run( if saveDataWorker is not None: saveDataWorker.emitUpdateProgressBar() - if not acdc_df_li: - print("-" * 30) - self.log( - "All selected positions in the experiment folder " - f"{exp_foldername} have EMPTY segmentation mask. " - "Metrics will not be saved." + return acdc_df_li, keys + + def _run_metrics_gui_via_graph( + self, + img_path="", + stop_frame_n=1, + end_filename_segm="", + computeMetricsWorker=None, + saveDataWorker=None, + posData=None, + save_metrics=True, + do_init_metrics=True, + last_cca_frame_i=None, + ): + from cellacdc.workflow.pipelines.measurements_gui import ( + build_gui_measurements_graph, + ) + from cellacdc.workflow.runnable import RunnableConfig + from cellacdc.workflow.state import MeasurementsGuiContext, MeasurementsGuiState + + ctx = MeasurementsGuiContext( + kernel=self, + compute_metrics_worker=computeMetricsWorker, + save_data_worker=saveDataWorker, + save_metrics=save_metrics, + do_init_metrics=do_init_metrics, + last_cca_frame_i=last_cca_frame_i, + end_filename_segm=end_filename_segm or self.end_filename_segm, + ) + graph = build_gui_measurements_graph( + ctx, + pos_data_loaded=posData is not None, + ).compile() + return graph.invoke( + MeasurementsGuiState( + img_path=img_path, + stop_frame_n=stop_frame_n, + pos_data=posData, + ), + RunnableConfig(logger_func=self.log), + ) + + def _run_metrics_via_graph( + self, + img_path, + stop_frame_n, + end_filename_segm, + save_metrics=True, + last_cca_frame_i=None, + ): + from cellacdc.workflow.pipelines.measurements import ( + build_measurements_position_graph, + ) + from cellacdc.workflow.runnable import RunnableConfig + from cellacdc.workflow.state import MeasurementsContext, MeasurementsState + + ctx = MeasurementsContext( + end_filename_segm=end_filename_segm or self.end_filename_segm, + kernel=self, + save_metrics=save_metrics, + ) + ctx.last_cca_frame_i = last_cca_frame_i + graph = build_measurements_position_graph(ctx).compile() + return graph.invoke( + MeasurementsState(img_path=img_path, stop_frame_n=stop_frame_n), + RunnableConfig(logger_func=self.log), + ) + + @exception_handler_cli + def run( + self, + img_path: os.PathLike = "", + stop_frame_n: int = 1, + end_filename_segm: str = "", + computeMetricsWorker=None, + saveDataWorker=None, + posData=None, + save_metrics=True, + do_init_metrics=True, + last_cca_frame_i=None, + ): + if ( + computeMetricsWorker is None + and saveDataWorker is None + and posData is None + ): + return self._run_metrics_via_graph( + img_path, + stop_frame_n, + end_filename_segm or self.end_filename_segm, + save_metrics=save_metrics, + last_cca_frame_i=last_cca_frame_i, ) - print("-" * 30) - return - self._concat_and_save_acdc_df( - acdc_df_li, - keys, - posData, - save_metrics, + return self._run_metrics_gui_via_graph( + img_path=img_path, + stop_frame_n=stop_frame_n, + end_filename_segm=end_filename_segm or self.end_filename_segm, computeMetricsWorker=computeMetricsWorker, saveDataWorker=saveDataWorker, + posData=posData, + save_metrics=save_metrics, + do_init_metrics=do_init_metrics, last_cca_frame_i=last_cca_frame_i, ) diff --git a/cellacdc/workers.py b/cellacdc/workers.py index 35c298607..3d4f9422c 100755 --- a/cellacdc/workers.py +++ b/cellacdc/workers.py @@ -760,18 +760,18 @@ def _segment_image(self, img, secondChannelImg): posData=self.posData, ) if self.Gui.applyPostProcessing: - lab = core.post_process_segm(lab, **self.Gui.standardPostProcessKwargs) - if self.Gui.customPostProcessFeatures: - lab = features.custom_post_process_segm( - self.posData, - self.Gui.customPostProcessGroupedFeatures, - lab, - img, - self.posData.frame_i, - self.posData.filename, - self.posData.user_ch_name, - self.Gui.customPostProcessFeatures, - ) + from cellacdc.workflow.pipelines.postprocess_nodes import apply_postprocess + + lab = apply_postprocess( + lab, + img, + self.posData, + self.posData.frame_i, + apply_postprocessing=True, + standard_postprocess_kwargs=self.Gui.standardPostProcessKwargs, + custom_postprocess_features=self.Gui.customPostProcessFeatures, + custom_postprocess_grouped_features=self.Gui.customPostProcessGroupedFeatures, + ) return lab @worker_exception_handler @@ -1139,72 +1139,28 @@ def emitDebug(self, to_debug): @worker_exception_handler def run(self): - t0 = time.perf_counter() - if self.mainWin.segment3D: - img = self.mainWin.getDisplayedZstack() - if self.z_range is not None: - startZ, stopZ = self.z_range - img = img[startZ : stopZ + 1] - else: - img = self.mainWin.getDisplayedImg1() - - posData = self.mainWin.data[self.mainWin.pos_i] - lab = np.zeros_like(posData.segm_data[0]) - - # self.emitDebug((img, self.secondChannelData)) - - if self.secondChannelData is not None: - img = self.mainWin.model.second_ch_img_to_stack(img, self.secondChannelData) - - start_z_slice = 0 - if self.z_range is not None: - start_z_slice, _ = self.z_range - elif not self.mainWin.segment3D and posData.isSegm3D: - idx = (posData.filename, posData.frame_i) - start_z_slice = posData.segmInfo_df.at[idx, "z_slice_used_gui"] - - _lab = core.segm_model_segment( - self.mainWin.model, - img, - self.mainWin.model_kwargs, - frame_i=posData.frame_i, - posData=posData, - start_z_slice=start_z_slice, + from cellacdc.workflow.adapters import ( + interactive_segm_context_from_main_win, + runnable_config_from_main_win, ) - posData.saveSamEmbeddings(logger_func=self.logger.info) - - if self.mainWin.applyPostProcessing: - _lab = core.post_process_segm( - _lab, **self.mainWin.standardPostProcessKwargs - ) - if self.mainWin.customPostProcessFeatures: - _lab = features.custom_post_process_segm( - posData, - self.mainWin.customPostProcessGroupedFeatures, - _lab, - img, - posData.frame_i, - posData.filename, - posData.user_ch_name, - self.mainWin.customPostProcessFeatures, - ) - - if self.z_range is not None: - # 3D segmentation of a z-slices subset - startZ, stopZ = self.z_range - lab[startZ : stopZ + 1] = _lab - elif not self.mainWin.segment3D and posData.isSegm3D: - # 3D segmentation but segmented current z-slice - idx = (posData.filename, posData.frame_i) - z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] - lab[z] = _lab - else: - # Either whole z-stack or 2D segmentation - lab = _lab + from cellacdc.workflow.pipelines.interactive_segm import ( + build_interactive_segm_graph, + ) + from cellacdc.workflow.state import InteractiveSegmState + t0 = time.perf_counter() + ctx = interactive_segm_context_from_main_win( + self.mainWin, + second_channel_data=self.secondChannelData, + z_range=self.z_range, + ) + graph = build_interactive_segm_graph(ctx).compile() + state = graph.invoke( + InteractiveSegmState(main_win=self.mainWin), + runnable_config_from_main_win(self.mainWin), + ) t1 = time.perf_counter() - exec_time = t1 - t0 - self.finished.emit(lab, exec_time) + self.finished.emit(state.lab, t1 - t0) class segmVideoWorker(QObject): @@ -1231,82 +1187,22 @@ def __init__(self, posData, paramWin, model, startFrameNum, stopFrameNum): self.stopFrameNum = stopFrameNum self.logger = workerLogger(self.progress) - def _check_extend_segm_data(self, segm_data, stop_frame_num): - if stop_frame_num <= len(segm_data): - return segm_data - extended_shape = (stop_frame_num, *segm_data.shape[1:]) - extended_segm_data = np.zeros(extended_shape, dtype=segm_data.dtype) - extended_segm_data[: len(segm_data)] = segm_data - if len(extended_shape) == 4: - return extended_segm_data - if self.posData.SizeZ == 1: - return extended_segm_data - else: - num_added_frames = len(extended_segm_data) - len(segm_data) - half_z = int(self.posData.SizeZ / 2) - # 2D segm on 3D over time data --> fix segmInfo - segmInfo_extended = pd.DataFrame( - { - "filename": [self.posData.filename] * num_added_frames, - "frame_i": list(range(len(segm_data), len(extended_segm_data))), - "z_slice_used_gui": [half_z] * num_added_frames, - "which_z_proj_gui": ["single z-slice"] * num_added_frames, - } - ).set_index(["filename", "frame_i"]) - segmInfo_df = pd.concat([self.posData.segmInfo_df, segmInfo_extended]) - self.posData.segmInfo_df = segmInfo_df - self.posData.segmInfo_df.to_csv(self.posData.segmInfo_df_csv_path) - return extended_segm_data - @worker_exception_handler def run(self): + from cellacdc.workflow.adapters import interactive_video_segm_context_from_worker + from cellacdc.workflow.pipelines.interactive_video_segm import ( + build_interactive_video_segm_graph, + ) + from cellacdc.workflow.state import InteractiveVideoSegmState + t0 = time.perf_counter() - self.posData.segm_data = self._check_extend_segm_data( - self.posData.segm_data, self.stopFrameNum + ctx = interactive_video_segm_context_from_worker(self) + graph = build_interactive_video_segm_graph(ctx).compile() + graph.invoke( + InteractiveVideoSegmState(pos_data=self.posData), ) - img_data = self.posData.img_data[self.startFrameNum - 1 : self.stopFrameNum] - is4D = img_data.ndim == 4 - is2D_segm = self.posData.segm_data.ndim == 3 - if is4D and is2D_segm: - filename = self.posData.filename - zz = self.posData.segmInfo_df.loc[filename, "z_slice_used_gui"] - else: - zz = None - for i, img in enumerate(img_data): - frame_i = i + self.startFrameNum - 1 - if self.secondChannelData is not None: - img = self.model.second_ch_img_to_stack(img, self.secondChannelData) - if zz is not None: - z_slice = zz.loc[frame_i] - img = img[z_slice] - - lab = core.segm_model_segment( - self.model, - img, - self.model_kwargs, - frame_i=frame_i, - preproc_recipe=self.preproc_recipe, - posData=self.posData, - ) - self.posData.saveSamEmbeddings(logger_func=self.logger.log) - if self.applyPostProcessing: - lab = core.post_process_segm(lab, **self.standardPostProcessKwargs) - if self.customPostProcessFeatures: - lab = features.custom_post_process_segm( - self.posData, - self.customPostProcessGroupedFeatures, - lab, - img, - self.posData.frame_i, - self.posData.filename, - self.posData.user_ch_name, - self.customPostProcessFeatures, - ) - self.posData.segm_data[frame_i] = lab - self.progressBar.emit(1) t1 = time.perf_counter() - exec_time = t1 - t0 - self.finished.emit(exec_time) + self.finished.emit(t1 - t0) class ComputeMetricsWorker(QObject): @@ -1421,28 +1317,22 @@ def run(self): False, ) - # Iterate pos and calculate metrics - numPos = len(self.allPosDataInputs) - for p, posDataInputs in enumerate(self.allPosDataInputs): - self.logger.log("=" * 40) - file_path = posDataInputs["file_path"] - chName = posDataInputs["chName"] - stopFrameNum = posDataInputs["stopFrameNum"] - - self.kernel.run( - img_path=file_path, - stop_frame_n=stopFrameNum, - end_filename_segm=self.mainWin.endFilenameSegm, - computeMetricsWorker=self, - do_init_metrics=p == 0, - ) - - if self.kernel.setup_done: - return + from cellacdc.workflow.pipelines.batch import run_gui_measurements_batch + from cellacdc.workflow.runnable import RunnableConfig + + run_gui_measurements_batch( + kernel=self.kernel, + paths=[inp["file_path"] for inp in self.allPosDataInputs], + stop_frame_numbers=[ + inp["stopFrameNum"] for inp in self.allPosDataInputs + ], + end_filename_segm=self.mainWin.endFilenameSegm, + compute_metrics_worker=self, + config=RunnableConfig(logger_func=self.logger.log), + ) - if self.abort: - self.signals.finished.emit(self) - return + if self.kernel.setup_done or self.abort: + return self.logger.log("*" * 30) diff --git a/cellacdc/workflow/__init__.py b/cellacdc/workflow/__init__.py new file mode 100644 index 000000000..e724a7d72 --- /dev/null +++ b/cellacdc/workflow/__init__.py @@ -0,0 +1,58 @@ +"""LangGraph-style workflow modeling for Cell-ACDC pipelines.""" + +from .adapters import ( + runnable_config_from_segm_kernel, + sync_segm_kernel_from_context, + update_workflow_context_from_segm_kernel, + workflow_context_from_ini, + workflow_context_from_segm_kernel, +) +from .constants import END, START +from .graph import CompiledStateGraph, StateGraph +from .runnable import Runnable, RunnableConfig, RunnableLambda, RunnableSequence +from .state import ( + BatchState, + FullWorkflowState, + InteractiveSegmContext, + InteractiveSegmState, + InteractiveVideoSegmContext, + InteractiveVideoSegmState, + MeasurementsBatchContext, + MeasurementsContext, + MeasurementsGuiBatchContext, + MeasurementsGuiContext, + MeasurementsGuiState, + MeasurementsState, + PositionState, + WorkflowContext, +) + +__all__ = [ + "BatchState", + "CompiledStateGraph", + "END", + "FullWorkflowState", + "InteractiveSegmContext", + "InteractiveSegmState", + "InteractiveVideoSegmContext", + "InteractiveVideoSegmState", + "MeasurementsBatchContext", + "MeasurementsContext", + "MeasurementsGuiBatchContext", + "MeasurementsGuiContext", + "MeasurementsGuiState", + "MeasurementsState", + "PositionState", + "Runnable", + "RunnableConfig", + "RunnableLambda", + "RunnableSequence", + "START", + "StateGraph", + "WorkflowContext", + "runnable_config_from_segm_kernel", + "sync_segm_kernel_from_context", + "update_workflow_context_from_segm_kernel", + "workflow_context_from_ini", + "workflow_context_from_segm_kernel", +] diff --git a/cellacdc/workflow/adapters.py b/cellacdc/workflow/adapters.py new file mode 100644 index 000000000..4e99a9102 --- /dev/null +++ b/cellacdc/workflow/adapters.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from typing import Any + +from .runnable import RunnableConfig +from .state import WorkflowContext + + +def workflow_context_from_segm_kernel(kernel: Any) -> WorkflowContext: + """Build a mutable workflow context from a SegmKernel instance.""" + return WorkflowContext( + user_ch_name=kernel.user_ch_name, + segm_endname=kernel.segm_endname, + model_name=kernel.model_name, + tracker_name=kernel.tracker_name, + do_tracking=kernel.do_tracking, + do_postprocess=kernel.do_postprocess, + do_save=kernel.do_save, + is_segm_3d=kernel.isSegm3D, + use_roi=kernel.use_ROI, + use_freehand_roi=kernel.use_freehand_ROI, + use_3d_data_for_2d_segm=kernel.use3DdataFor2Dsegm, + second_channel_name=kernel.second_channel_name, + image_channel_tracker=kernel.image_channel_tracker, + size_t=kernel.SizeT, + size_z=kernel.SizeZ, + model_kwargs=dict(kernel.model_kwargs or {}), + init_model_kwargs=dict(kernel.init_model_kwargs or {}), + track_params=dict(kernel.track_params or {}), + init_tracker_kwargs=dict(kernel.init_tracker_kwargs or {}), + standard_postprocess_kwargs=dict(kernel.standard_postrocess_kwargs or {}), + custom_postprocess_features=dict(kernel.custom_postproc_features or {}), + custom_postprocess_grouped_features=dict( + kernel.custom_postproc_grouped_features or {} + ), + preproc_recipe=kernel.preproc_recipe, + reduce_memory_usage=getattr(kernel, "reduce_memory_usage", False), + model=kernel.model, + tracker=kernel.tracker, + is_segment3dt_available=kernel.is_segment3DT_available, + inner_pbar_available=kernel.innerPbar_available, + signals=kernel.signals, + ) + + +def sync_segm_kernel_from_context(kernel: Any, ctx: WorkflowContext) -> None: + """Copy mutable pipeline resources back onto the kernel after a graph run.""" + kernel.model = ctx.model + kernel.tracker = ctx.tracker + kernel.is_segment3DT_available = ctx.is_segment3dt_available + kernel.model_kwargs = ctx.model_kwargs + kernel.track_params = ctx.track_params + kernel.init_model_kwargs = ctx.init_model_kwargs + + +def runnable_config_from_segm_kernel(kernel: Any) -> RunnableConfig: + return RunnableConfig( + logger_func=kernel.logger_func, + signals=kernel.signals, + metadata={"model_name": kernel.model_name}, + ) + + +def update_workflow_context_from_segm_kernel( + ctx: WorkflowContext, kernel: Any +) -> WorkflowContext: + """Refresh context fields that may change between batch positions.""" + ctx.model = kernel.model + ctx.tracker = kernel.tracker + ctx.is_segment3dt_available = kernel.is_segment3DT_available + ctx.model_kwargs = dict(kernel.model_kwargs or {}) + ctx.track_params = dict(kernel.track_params or {}) + ctx.signals = kernel.signals + return ctx + + +def _parse_custom_postproc_features_grouped(workflow_params: dict[str, Any]) -> dict: + custom_postproc_grouped_features: dict[str, Any] = {} + for section, options in workflow_params.items(): + if not section.startswith("postprocess_features."): + continue + category = section.split(".")[-1] + for option, value in options.items(): + if option == "names": + values = value.strip("\n").strip().split("\n") + custom_postproc_grouped_features[category] = values + continue + channel = option + if category not in custom_postproc_grouped_features: + custom_postproc_grouped_features[category] = {channel: [value]} + elif channel not in custom_postproc_grouped_features[category]: + custom_postproc_grouped_features[category][channel] = [value] + else: + custom_postproc_grouped_features[category][channel].append(value) + return custom_postproc_grouped_features + + +def workflow_context_from_ini(workflow_params: dict[str, Any]) -> WorkflowContext: + """Build a workflow context directly from parsed INI workflow parameters.""" + from cellacdc import config + + initialization = workflow_params["initialization"] + return WorkflowContext( + user_ch_name=initialization["user_ch_name"], + segm_endname=initialization.get("segm_endname", "segm.npz"), + model_name=initialization.get("model_name", ""), + tracker_name=initialization.get("tracker_name", ""), + do_tracking=initialization.get("do_tracking", False), + do_postprocess=initialization.get("do_postprocess", True), + do_save=initialization.get("do_save", True), + is_segm_3d=initialization.get("isSegm3D", False), + use_roi=initialization.get("use_ROI", True), + use_freehand_roi=initialization.get("use_freehand_ROI", True), + use_3d_data_for_2d_segm=initialization.get("use3DdataFor2Dsegm", False), + second_channel_name=initialization.get("second_channel_name"), + image_channel_tracker=initialization.get("image_channel_tracker"), + size_t=workflow_params["metadata"]["SizeT"], + size_z=workflow_params["metadata"]["SizeZ"], + model_kwargs=dict(workflow_params.get("segmentation_model_params", {})), + init_model_kwargs=dict( + workflow_params.get("init_segmentation_model_params", {}) + ), + track_params=dict(workflow_params.get("tracker_params", {})), + init_tracker_kwargs=dict(workflow_params.get("init_tracker_params", {})), + standard_postprocess_kwargs=dict( + workflow_params.get("standard_postprocess_features", {}) + ), + custom_postprocess_features=dict( + workflow_params.get("custom_postprocess_features", {}) + ), + custom_postprocess_grouped_features=_parse_custom_postproc_features_grouped( + workflow_params + ), + preproc_recipe=config.preprocess_ini_items_to_recipe(workflow_params), + reduce_memory_usage=initialization.get("reduce_memory_usage", False), + ) + + +def interactive_segm_context_from_main_win(main_win, second_channel_data=None, z_range=None): + from cellacdc.workflow.state import InteractiveSegmContext + + return InteractiveSegmContext( + model=main_win.model, + model_kwargs=main_win.model_kwargs, + apply_postprocessing=main_win.applyPostProcessing, + standard_postprocess_kwargs=main_win.standardPostProcessKwargs, + custom_postprocess_features=main_win.customPostProcessFeatures, + custom_postprocess_grouped_features=main_win.customPostProcessGroupedFeatures, + segment_3d=main_win.segment3D, + second_channel_data=second_channel_data, + z_range=z_range, + ) + + +def runnable_config_from_main_win(main_win): + return RunnableConfig(logger_func=main_win.logger.info) + + +def interactive_video_segm_context_from_worker(worker) -> InteractiveSegmContext: + from cellacdc.workflow.state import InteractiveVideoSegmContext + + return InteractiveVideoSegmContext( + model=worker.model, + model_kwargs=worker.model_kwargs, + apply_postprocessing=worker.applyPostProcessing, + standard_postprocess_kwargs=worker.standardPostProcessKwargs, + custom_postprocess_features=worker.customPostProcessFeatures, + custom_postprocess_grouped_features=worker.customPostProcessGroupedFeatures, + preproc_recipe=worker.preproc_recipe, + second_channel_data=getattr(worker, "secondChannelData", None), + start_frame_num=worker.startFrameNum, + stop_frame_num=worker.stopFrameNum, + progress_callback=worker.progressBar, + logger_func=worker.logger.log, + ) diff --git a/cellacdc/workflow/constants.py b/cellacdc/workflow/constants.py new file mode 100644 index 000000000..5448156f6 --- /dev/null +++ b/cellacdc/workflow/constants.py @@ -0,0 +1,2 @@ +START = "__start__" +END = "__end__" diff --git a/cellacdc/workflow/graph.py b/cellacdc/workflow/graph.py new file mode 100644 index 000000000..8cfd29d4f --- /dev/null +++ b/cellacdc/workflow/graph.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Generic, TypeVar + +from .constants import END, START +from .runnable import RunnableConfig +from .state import merge_state + +StateT = TypeVar("StateT") +ContextT = TypeVar("ContextT") + +NodeFn = Callable[[StateT, ContextT, RunnableConfig], dict[str, Any]] +RouteFn = Callable[[StateT, ContextT], str] + + +@dataclass(slots=True) +class CompiledStateGraph(Generic[StateT, ContextT]): + """Executable graph returned by StateGraph.compile().""" + + nodes: dict[str, NodeFn[StateT, ContextT]] + edges: dict[str, str] + conditional_edges: dict[str, tuple[RouteFn[StateT, ContextT], dict[str, str]]] + entrypoint: str + state_type: type[StateT] + context: ContextT + + def invoke( + self, + state: StateT, + config: RunnableConfig | None = None, + ) -> StateT: + config = config or RunnableConfig() + node_name = self.entrypoint + while node_name != END: + node = self.nodes[node_name] + update = node(state, self.context, config) + state = merge_state(state, update) + if node_name in self.conditional_edges: + route_fn, mapping = self.conditional_edges[node_name] + node_name = mapping[route_fn(state, self.context)] + continue + node_name = self.edges[node_name] + return state + + +@dataclass +class StateGraph(Generic[StateT, ContextT]): + """Declarative workflow graph (LangGraph StateGraph analogue).""" + + state_type: type[StateT] + context: ContextT + _nodes: dict[str, NodeFn[StateT, ContextT]] = field(default_factory=dict) + _edges: dict[str, str] = field(default_factory=dict) + _conditional_edges: dict[str, tuple[RouteFn[StateT, ContextT], dict[str, str]]] = ( + field(default_factory=dict) + ) + _entrypoint: str | None = None + + def add_node(self, name: str, fn: NodeFn[StateT, ContextT]) -> StateGraph: + self._nodes[name] = fn + return self + + def set_entry_point(self, name: str) -> StateGraph: + self._entrypoint = name + return self + + def add_edge(self, start: str, end: str) -> StateGraph: + self._edges[start] = end + return self + + def add_conditional_edges( + self, + start: str, + route: RouteFn[StateT, ContextT], + mapping: dict[str, str], + ) -> StateGraph: + self._conditional_edges[start] = (route, mapping) + return self + + def compile(self) -> CompiledStateGraph[StateT, ContextT]: + if self._entrypoint is None: + raise ValueError("Graph has no entry point. Call set_entry_point().") + if self._entrypoint not in self._nodes: + raise ValueError(f"Unknown entry point node: {self._entrypoint}") + return CompiledStateGraph( + nodes=dict(self._nodes), + edges=dict(self._edges), + conditional_edges=dict(self._conditional_edges), + entrypoint=self._entrypoint, + state_type=self.state_type, + context=self.context, + ) + + def get_graph(self) -> dict[str, Any]: + """Return a serializable graph description for tests and debugging.""" + return { + "nodes": sorted(self._nodes), + "edges": dict(self._edges), + "conditional_edges": { + name: sorted(mapping) + for name, (_, mapping) in self._conditional_edges.items() + }, + "entrypoint": self._entrypoint, + } diff --git a/cellacdc/workflow/pipelines/__init__.py b/cellacdc/workflow/pipelines/__init__.py new file mode 100644 index 000000000..efe19ac83 --- /dev/null +++ b/cellacdc/workflow/pipelines/__init__.py @@ -0,0 +1,23 @@ +from .batch import batch_state_from_workflow_params, run_measurements_batch, run_segm_batch +from .batch_graph import build_segm_batch_graph +from .full_workflow import build_full_workflow_graph +from .interactive_segm import build_interactive_segm_graph +from .interactive_video_segm import build_interactive_video_segm_graph +from .measurements import build_measurements_position_graph +from .measurements_batch_graph import build_measurements_batch_graph +from .measurements_gui import build_gui_measurements_graph +from .segm import build_position_segm_graph + +__all__ = [ + "batch_state_from_workflow_params", + "build_full_workflow_graph", + "build_gui_measurements_graph", + "build_interactive_segm_graph", + "build_interactive_video_segm_graph", + "build_measurements_batch_graph", + "build_measurements_position_graph", + "build_position_segm_graph", + "build_segm_batch_graph", + "run_measurements_batch", + "run_segm_batch", +] diff --git a/cellacdc/workflow/pipelines/batch.py b/cellacdc/workflow/pipelines/batch.py new file mode 100644 index 000000000..8f48e162d --- /dev/null +++ b/cellacdc/workflow/pipelines/batch.py @@ -0,0 +1,102 @@ +"""Batch execution helpers for workflow graphs.""" + +from __future__ import annotations + +from typing import Any + +from tqdm import tqdm + +from ..runnable import RunnableConfig +from ..state import BatchState, BatchWorkflowContext, PositionState, WorkflowContext +from .batch_graph import build_segm_batch_graph +from .measurements_batch_graph import build_measurements_batch_graph +from ..state import ( + MeasurementsBatchContext, + MeasurementsContext, + MeasurementsGuiBatchContext, + MeasurementsGuiState, +) +from .measurements_gui_batch_graph import build_gui_measurements_batch_graph + + +def run_segm_batch( + ctx: WorkflowContext, + paths: list[str], + stop_frame_numbers: list[int], + config: RunnableConfig | None = None, + progress: tqdm | None = None, +) -> list[PositionState]: + """Run the position segmentation graph for each path.""" + config = config or RunnableConfig() + if progress is not None: + config.metadata["progress"] = progress + + batch_ctx = BatchWorkflowContext(position_ctx=ctx) + graph = build_segm_batch_graph(batch_ctx).compile() + batch_state = graph.invoke( + BatchState(paths=paths, stop_frame_numbers=stop_frame_numbers), + config, + ) + return batch_state.results + + +def run_measurements_batch( + kernel: Any, + paths: list[str], + stop_frame_numbers: list[int], + end_filename_segm: str, + config: RunnableConfig | None = None, + progress: tqdm | None = None, +) -> list[Any]: + config = config or RunnableConfig(logger_func=kernel.log) + if progress is not None: + config.metadata["progress"] = progress + + measurements_ctx = MeasurementsContext( + end_filename_segm=end_filename_segm, + kernel=kernel, + ) + batch_ctx = MeasurementsBatchContext(measurements_ctx=measurements_ctx) + graph = build_measurements_batch_graph(batch_ctx).compile() + batch_state = graph.invoke( + BatchState(paths=paths, stop_frame_numbers=stop_frame_numbers), + config, + ) + return batch_state.results + + +def run_gui_measurements_batch( + kernel: Any, + paths: list[str], + stop_frame_numbers: list[int], + end_filename_segm: str, + *, + compute_metrics_worker: Any | None = None, + save_data_worker: Any | None = None, + save_metrics: bool = True, + config: RunnableConfig | None = None, + progress: tqdm | None = None, +) -> list[MeasurementsGuiState]: + config = config or RunnableConfig(logger_func=kernel.log) + if progress is not None: + config.metadata["progress"] = progress + + batch_ctx = MeasurementsGuiBatchContext( + kernel=kernel, + compute_metrics_worker=compute_metrics_worker, + save_data_worker=save_data_worker, + save_metrics=save_metrics, + end_filename_segm=end_filename_segm, + ) + graph = build_gui_measurements_batch_graph(batch_ctx).compile() + batch_state = graph.invoke( + BatchState(paths=paths, stop_frame_numbers=stop_frame_numbers), + config, + ) + return batch_state.results + + +def batch_state_from_workflow_params(workflow_params: dict[str, Any]) -> BatchState: + paths = workflow_params["paths_info"]["paths"] + stop_frames = [int(n) for n in workflow_params["paths_info"]["stop_frame_numbers"]] + return BatchState(paths=paths, stop_frame_numbers=stop_frames) diff --git a/cellacdc/workflow/pipelines/batch_graph.py b/cellacdc/workflow/pipelines/batch_graph.py new file mode 100644 index 000000000..f33c0c12d --- /dev/null +++ b/cellacdc/workflow/pipelines/batch_graph.py @@ -0,0 +1,57 @@ +"""Parent graph for batch segmentation over many positions.""" + +from __future__ import annotations + +from typing import Any + +from ..constants import END +from ..graph import StateGraph +from ..runnable import RunnableConfig +from ..state import BatchState, BatchWorkflowContext, PositionState +from .segm import build_position_segm_graph + + +def _position_graph(ctx: BatchWorkflowContext): + if ctx.position_graph is None: + ctx.position_graph = build_position_segm_graph(ctx.position_ctx).compile() + return ctx.position_graph + + +def process_position( + state: BatchState, + ctx: BatchWorkflowContext, + config: RunnableConfig, +) -> dict[str, Any]: + path = state.paths[state.current_index] + stop_frame_n = state.stop_frame_numbers[state.current_index] + config.logger_func(f'\nProcessing "{path}"...') + result = _position_graph(ctx).invoke( + PositionState(img_path=path, stop_frame_n=stop_frame_n), + config, + ) + results = list(state.results) + results.append(result) + progress = config.metadata.get("progress") + if progress is not None: + progress.update(1) + return {"results": results, "current_index": state.current_index + 1} + + +def _route_batch(state: BatchState, _ctx: BatchWorkflowContext) -> str: + if state.current_index >= len(state.paths): + return END + return "process_position" + + +def build_segm_batch_graph( + ctx: BatchWorkflowContext, +) -> StateGraph[BatchState, BatchWorkflowContext]: + graph = StateGraph(BatchState, ctx) + graph.add_node("process_position", process_position) + graph.set_entry_point("process_position") + graph.add_conditional_edges( + "process_position", + _route_batch, + {"process_position": "process_position", END: END}, + ) + return graph diff --git a/cellacdc/workflow/pipelines/full_workflow.py b/cellacdc/workflow/pipelines/full_workflow.py new file mode 100644 index 000000000..030bdae67 --- /dev/null +++ b/cellacdc/workflow/pipelines/full_workflow.py @@ -0,0 +1,60 @@ +"""Top-level INI workflow orchestration graph.""" + +from __future__ import annotations + +from typing import Any + +from ..constants import END +from ..graph import StateGraph +from ..runnable import RunnableConfig +from ..state import FullWorkflowState + + +def run_segm_phase( + state: FullWorkflowState, + ctx: Any, + config: RunnableConfig, +) -> dict[str, Any]: + if not state.run_segm: + return {"segm_done": True} + + from cellacdc._run import run_segm_workflow + + run_segm_workflow(state.segm_params, ctx.logger, ctx.log_path) + return {"segm_done": True} + + +def run_measurements_phase( + state: FullWorkflowState, + ctx: Any, + config: RunnableConfig, +) -> dict[str, Any]: + if not state.run_measurements or state.measurements_params is None: + return {"measurements_done": True} + + from cellacdc._run import run_measurements_workflow + + run_measurements_workflow(state.measurements_params, ctx.logger, ctx.log_path) + return {"measurements_done": True} + + +def _route_after_segm(state: FullWorkflowState, _ctx: Any) -> str: + if state.run_measurements: + return "run_measurements_phase" + return END + + +def build_full_workflow_graph( + ctx: Any, +) -> StateGraph[FullWorkflowState, Any]: + graph = StateGraph(FullWorkflowState, ctx) + graph.add_node("run_segm_phase", run_segm_phase) + graph.add_node("run_measurements_phase", run_measurements_phase) + graph.set_entry_point("run_segm_phase") + graph.add_conditional_edges( + "run_segm_phase", + _route_after_segm, + {"run_measurements_phase": "run_measurements_phase", END: END}, + ) + graph.add_edge("run_measurements_phase", END) + return graph diff --git a/cellacdc/workflow/pipelines/interactive_segm.py b/cellacdc/workflow/pipelines/interactive_segm.py new file mode 100644 index 000000000..53fa8df94 --- /dev/null +++ b/cellacdc/workflow/pipelines/interactive_segm.py @@ -0,0 +1,28 @@ +"""Interactive single-frame segmentation graph.""" + +from __future__ import annotations + +from ..constants import END +from ..graph import StateGraph +from ..state import InteractiveSegmContext, InteractiveSegmState +from . import interactive_segm_nodes as nodes + + +def build_interactive_segm_graph( + ctx: InteractiveSegmContext, +) -> StateGraph[InteractiveSegmState, InteractiveSegmContext]: + graph = StateGraph(InteractiveSegmState, ctx) + graph.add_node("prepare_frame", nodes.prepare_frame) + graph.add_node("segment_frame", nodes.segment_frame) + graph.add_node("postprocess_frame", nodes.postprocess_frame) + graph.add_node("merge_result", nodes.merge_result) + graph.set_entry_point("prepare_frame") + graph.add_edge("prepare_frame", "segment_frame") + graph.add_conditional_edges( + "segment_frame", + nodes._route_postprocess, + {"postprocess_frame": "postprocess_frame", "merge_result": "merge_result"}, + ) + graph.add_edge("postprocess_frame", "merge_result") + graph.add_edge("merge_result", END) + return graph diff --git a/cellacdc/workflow/pipelines/interactive_segm_nodes.py b/cellacdc/workflow/pipelines/interactive_segm_nodes.py new file mode 100644 index 000000000..fc857f1d7 --- /dev/null +++ b/cellacdc/workflow/pipelines/interactive_segm_nodes.py @@ -0,0 +1,112 @@ +"""Interactive single-frame segmentation nodes for the main viewer.""" + +from __future__ import annotations + +import time +from typing import Any + +from cellacdc import core + +from ..runnable import RunnableConfig +from ..state import InteractiveSegmContext, InteractiveSegmState +from .postprocess_nodes import apply_postprocess + + +def prepare_frame( + state: InteractiveSegmState, + ctx: InteractiveSegmContext, + config: RunnableConfig, +) -> dict[str, Any]: + main_win = state.main_win + pos_data = main_win.data[main_win.pos_i] + + if ctx.segment_3d: + img = main_win.getDisplayedZstack() + if ctx.z_range is not None: + start_z, stop_z = ctx.z_range + img = img[start_z : stop_z + 1] + else: + img = main_win.getDisplayedImg1() + + lab = __import__("numpy").zeros_like(pos_data.segm_data[0]) + start_z_slice = 0 + if ctx.z_range is not None: + start_z_slice, _ = ctx.z_range + elif not ctx.segment_3d and pos_data.isSegm3D: + idx = (pos_data.filename, pos_data.frame_i) + start_z_slice = pos_data.segmInfo_df.at[idx, "z_slice_used_gui"] + + return { + "pos_data": pos_data, + "img": img, + "lab": lab, + "start_z_slice": start_z_slice, + } + + +def segment_frame( + state: InteractiveSegmState, + ctx: InteractiveSegmContext, + config: RunnableConfig, +) -> dict[str, Any]: + img = state.img + if ctx.second_channel_data is not None: + img = ctx.model.second_ch_img_to_stack(img, ctx.second_channel_data) + + lab = core.segm_model_segment( + ctx.model, + img, + ctx.model_kwargs, + frame_i=state.pos_data.frame_i, + posData=state.pos_data, + start_z_slice=state.start_z_slice, + ) + state.pos_data.saveSamEmbeddings(logger_func=config.logger_func) + return {"img": img, "segmented_lab": lab} + + +def postprocess_frame( + state: InteractiveSegmState, + ctx: InteractiveSegmContext, + config: RunnableConfig, +) -> dict[str, Any]: + if not ctx.apply_postprocessing: + return {} + + lab = apply_postprocess( + state.segmented_lab, + state.img, + state.pos_data, + state.pos_data.frame_i, + apply_postprocessing=True, + standard_postprocess_kwargs=ctx.standard_postprocess_kwargs, + custom_postprocess_features=ctx.custom_postprocess_features, + custom_postprocess_grouped_features=ctx.custom_postprocess_grouped_features, + ) + return {"segmented_lab": lab} + + +def merge_result( + state: InteractiveSegmState, + ctx: InteractiveSegmContext, + config: RunnableConfig, +) -> dict[str, Any]: + pos_data = state.pos_data + lab = state.lab + segmented = state.segmented_lab + + if ctx.z_range is not None: + start_z, stop_z = ctx.z_range + lab[start_z : stop_z + 1] = segmented + elif not ctx.segment_3d and pos_data.isSegm3D: + idx = (pos_data.filename, pos_data.frame_i) + z = pos_data.segmInfo_df.at[idx, "z_slice_used_gui"] + lab[z] = segmented + else: + lab = segmented + + return {"lab": lab} + + +def _route_postprocess(_state: InteractiveSegmState, ctx: InteractiveSegmContext) -> str: + return "postprocess_frame" if ctx.apply_postprocessing else "merge_result" diff --git a/cellacdc/workflow/pipelines/interactive_video_segm.py b/cellacdc/workflow/pipelines/interactive_video_segm.py new file mode 100644 index 000000000..042634ec1 --- /dev/null +++ b/cellacdc/workflow/pipelines/interactive_video_segm.py @@ -0,0 +1,24 @@ +"""Interactive timelapse segmentation graph.""" + +from __future__ import annotations + +from ..constants import END +from ..graph import StateGraph +from ..state import InteractiveVideoSegmContext, InteractiveVideoSegmState +from . import interactive_video_segm_nodes as nodes + + +def build_interactive_video_segm_graph( + ctx: InteractiveVideoSegmContext, +) -> StateGraph[InteractiveVideoSegmState, InteractiveVideoSegmContext]: + graph = StateGraph(InteractiveVideoSegmState, ctx) + graph.add_node("extend_segm_data", nodes.extend_segm_data) + graph.add_node("prepare_video_stack", nodes.prepare_video_stack) + graph.add_node("segment_video_frames", nodes.segment_video_frames) + graph.add_node("finalize_video_run", nodes.finalize_video_run) + graph.set_entry_point("extend_segm_data") + graph.add_edge("extend_segm_data", "prepare_video_stack") + graph.add_edge("prepare_video_stack", "segment_video_frames") + graph.add_edge("segment_video_frames", "finalize_video_run") + graph.add_edge("finalize_video_run", END) + return graph diff --git a/cellacdc/workflow/pipelines/interactive_video_segm_nodes.py b/cellacdc/workflow/pipelines/interactive_video_segm_nodes.py new file mode 100644 index 000000000..81b7ff8d4 --- /dev/null +++ b/cellacdc/workflow/pipelines/interactive_video_segm_nodes.py @@ -0,0 +1,118 @@ +"""Interactive timelapse segmentation nodes for the main viewer.""" + +from __future__ import annotations + +import time +from typing import Any + +import numpy as np +import pandas as pd + +from cellacdc import core + +from ..runnable import RunnableConfig +from ..state import InteractiveVideoSegmContext, InteractiveVideoSegmState +from .postprocess_nodes import apply_postprocess + + +def extend_segm_data( + state: InteractiveVideoSegmState, + ctx: InteractiveVideoSegmContext, + config: RunnableConfig, +) -> dict[str, Any]: + pos_data = state.pos_data + segm_data = pos_data.segm_data + stop_frame_num = ctx.stop_frame_num + + if stop_frame_num <= len(segm_data): + return {"segm_data": segm_data} + + extended_shape = (stop_frame_num, *segm_data.shape[1:]) + extended_segm_data = np.zeros(extended_shape, dtype=segm_data.dtype) + extended_segm_data[: len(segm_data)] = segm_data + + if len(extended_shape) == 4 or pos_data.SizeZ == 1: + pos_data.segm_data = extended_segm_data + return {"segm_data": extended_segm_data} + + num_added_frames = len(extended_segm_data) - len(segm_data) + half_z = int(pos_data.SizeZ / 2) + segm_info_extended = pd.DataFrame( + { + "filename": [pos_data.filename] * num_added_frames, + "frame_i": list(range(len(segm_data), len(extended_segm_data))), + "z_slice_used_gui": [half_z] * num_added_frames, + "which_z_proj_gui": ["single z-slice"] * num_added_frames, + } + ).set_index(["filename", "frame_i"]) + pos_data.segmInfo_df = pd.concat([pos_data.segmInfo_df, segm_info_extended]) + pos_data.segmInfo_df.to_csv(pos_data.segmInfo_df_csv_path) + pos_data.segm_data = extended_segm_data + return {"segm_data": extended_segm_data} + + +def prepare_video_stack( + state: InteractiveVideoSegmState, + ctx: InteractiveVideoSegmContext, + config: RunnableConfig, +) -> dict[str, Any]: + pos_data = state.pos_data + img_data = pos_data.img_data[ctx.start_frame_num - 1 : ctx.stop_frame_num] + is_4d = img_data.ndim == 4 + is_2d_segm = pos_data.segm_data.ndim == 3 + z_slices = None + if is_4d and is_2d_segm: + z_slices = pos_data.segmInfo_df.loc[pos_data.filename, "z_slice_used_gui"] + return {"img_data": img_data, "z_slices": z_slices} + + +def segment_video_frames( + state: InteractiveVideoSegmState, + ctx: InteractiveVideoSegmContext, + config: RunnableConfig, +) -> dict[str, Any]: + pos_data = state.pos_data + progress = ctx.progress_callback + + for i, img in enumerate(state.img_data): + frame_i = i + ctx.start_frame_num - 1 + if ctx.second_channel_data is not None: + img = ctx.model.second_ch_img_to_stack(img, ctx.second_channel_data) + if state.z_slices is not None: + img = img[state.z_slices.loc[frame_i]] + + lab = core.segm_model_segment( + ctx.model, + img, + ctx.model_kwargs, + frame_i=frame_i, + preproc_recipe=ctx.preproc_recipe, + posData=pos_data, + ) + pos_data.saveSamEmbeddings(logger_func=ctx.logger_func) + + if ctx.apply_postprocessing: + lab = apply_postprocess( + lab, + img, + pos_data, + frame_i, + apply_postprocessing=True, + standard_postprocess_kwargs=ctx.standard_postprocess_kwargs, + custom_postprocess_features=ctx.custom_postprocess_features, + custom_postprocess_grouped_features=ctx.custom_postprocess_grouped_features, + ) + + pos_data.segm_data[frame_i] = lab + if progress is not None: + progress.emit(1) + + return {} + + +def finalize_video_run( + state: InteractiveVideoSegmState, + ctx: InteractiveVideoSegmContext, + config: RunnableConfig, +) -> dict[str, Any]: + return {} diff --git a/cellacdc/workflow/pipelines/measurements.py b/cellacdc/workflow/pipelines/measurements.py new file mode 100644 index 000000000..2c6a8da23 --- /dev/null +++ b/cellacdc/workflow/pipelines/measurements.py @@ -0,0 +1,30 @@ +"""Measurements position pipeline graph.""" + +from __future__ import annotations + +from ..constants import END +from ..graph import StateGraph +from ..state import MeasurementsContext, MeasurementsState +from . import measurements_nodes as nodes + + +def build_measurements_position_graph( + ctx: MeasurementsContext, +) -> StateGraph[MeasurementsState, MeasurementsContext]: + graph = StateGraph(MeasurementsState, ctx) + graph.add_node("load_position", nodes.load_position) + graph.add_node("validate_segm", nodes.validate_segm) + graph.add_node("compute_and_save", nodes.compute_and_save) + graph.set_entry_point("load_position") + graph.add_conditional_edges( + "load_position", + nodes._route_after_load, + {"validate_segm": "validate_segm", END: END}, + ) + graph.add_conditional_edges( + "validate_segm", + nodes._route_after_validate, + {"compute_and_save": "compute_and_save", END: END}, + ) + graph.add_edge("compute_and_save", END) + return graph diff --git a/cellacdc/workflow/pipelines/measurements_batch_graph.py b/cellacdc/workflow/pipelines/measurements_batch_graph.py new file mode 100644 index 000000000..74af5e5d5 --- /dev/null +++ b/cellacdc/workflow/pipelines/measurements_batch_graph.py @@ -0,0 +1,57 @@ +"""Measurements batch parent graph.""" + +from __future__ import annotations + +from ..constants import END +from ..graph import StateGraph +from ..runnable import RunnableConfig +from ..state import BatchState, MeasurementsBatchContext, MeasurementsState +from .measurements import build_measurements_position_graph + + +def _position_graph(ctx: MeasurementsBatchContext): + if ctx.position_graph is None: + ctx.position_graph = build_measurements_position_graph( + ctx.measurements_ctx + ).compile() + return ctx.position_graph + + +def process_position( + state: BatchState, + ctx: MeasurementsBatchContext, + config: RunnableConfig, +) -> dict[str, Any]: + path = state.paths[state.current_index] + stop_frame_n = state.stop_frame_numbers[state.current_index] + config.logger_func(f'\nProcessing "{path}"...') + result = _position_graph(ctx).invoke( + MeasurementsState(img_path=path, stop_frame_n=stop_frame_n), + config, + ) + results = list(state.results) + results.append(result) + progress = config.metadata.get("progress") + if progress is not None: + progress.update(1) + return {"results": results, "current_index": state.current_index + 1} + + +def _route_batch(state: BatchState, _ctx: MeasurementsBatchContext) -> str: + if state.current_index >= len(state.paths): + return END + return "process_position" + + +def build_measurements_batch_graph( + ctx: MeasurementsBatchContext, +) -> StateGraph[BatchState, MeasurementsBatchContext]: + graph = StateGraph(BatchState, ctx) + graph.add_node("process_position", process_position) + graph.set_entry_point("process_position") + graph.add_conditional_edges( + "process_position", + _route_batch, + {"process_position": "process_position", END: END}, + ) + return graph diff --git a/cellacdc/workflow/pipelines/measurements_gui.py b/cellacdc/workflow/pipelines/measurements_gui.py new file mode 100644 index 000000000..3fa959d2f --- /dev/null +++ b/cellacdc/workflow/pipelines/measurements_gui.py @@ -0,0 +1,35 @@ +"""GUI measurements position pipeline graph.""" + +from __future__ import annotations + +from ..constants import END +from ..graph import StateGraph +from ..state import MeasurementsGuiContext, MeasurementsGuiState +from . import measurements_gui_nodes as nodes + + +def build_gui_measurements_graph( + ctx: MeasurementsGuiContext, + *, + pos_data_loaded: bool = False, +) -> StateGraph[MeasurementsGuiState, MeasurementsGuiContext]: + graph = StateGraph(MeasurementsGuiState, ctx) + graph.add_node("load_position", nodes.load_position) + graph.add_node("prepare_gui_run", nodes.prepare_gui_run) + graph.add_node("compute_metrics_frames", nodes.compute_metrics_frames) + graph.add_node("save_metrics_results", nodes.save_metrics_results) + + if pos_data_loaded: + graph.set_entry_point("prepare_gui_run") + else: + graph.set_entry_point("load_position") + graph.add_edge("load_position", "prepare_gui_run") + + graph.add_conditional_edges( + "prepare_gui_run", + nodes._route_after_prepare, + {"compute_metrics_frames": "compute_metrics_frames", END: END}, + ) + graph.add_edge("compute_metrics_frames", "save_metrics_results") + graph.add_edge("save_metrics_results", END) + return graph diff --git a/cellacdc/workflow/pipelines/measurements_gui_batch_graph.py b/cellacdc/workflow/pipelines/measurements_gui_batch_graph.py new file mode 100644 index 000000000..f7da6d3fe --- /dev/null +++ b/cellacdc/workflow/pipelines/measurements_gui_batch_graph.py @@ -0,0 +1,70 @@ +"""GUI measurements batch parent graph.""" + +from __future__ import annotations + +from typing import Any + +from ..constants import END +from ..graph import StateGraph +from ..runnable import RunnableConfig +from ..state import BatchState, MeasurementsGuiBatchContext, MeasurementsGuiContext, MeasurementsGuiState +from .measurements_gui import build_gui_measurements_graph + + +def process_position( + state: BatchState, + ctx: MeasurementsGuiBatchContext, + config: RunnableConfig, +) -> dict[str, Any]: + path = state.paths[state.current_index] + stop_frame_n = state.stop_frame_numbers[state.current_index] + config.logger_func(f'\nProcessing "{path}"...') + + gui_ctx = MeasurementsGuiContext( + kernel=ctx.kernel, + compute_metrics_worker=ctx.compute_metrics_worker, + save_data_worker=ctx.save_data_worker, + save_metrics=ctx.save_metrics, + do_init_metrics=state.current_index == 0, + end_filename_segm=ctx.end_filename_segm, + ) + graph = build_gui_measurements_graph(gui_ctx, pos_data_loaded=False).compile() + result = graph.invoke( + MeasurementsGuiState(img_path=path, stop_frame_n=stop_frame_n), + config, + ) + + results = list(state.results) + results.append(result) + progress = config.metadata.get("progress") + if progress is not None: + progress.update(1) + + aborted = bool(getattr(result, "aborted", False)) + return { + "results": results, + "current_index": state.current_index + 1, + "aborted": aborted or state.aborted, + } + + +def _route_batch(state: BatchState, ctx: MeasurementsGuiBatchContext) -> str: + if state.aborted or ctx.kernel.setup_done: + return END + if state.current_index >= len(state.paths): + return END + return "process_position" + + +def build_gui_measurements_batch_graph( + ctx: MeasurementsGuiBatchContext, +) -> StateGraph[BatchState, MeasurementsGuiBatchContext]: + graph = StateGraph(BatchState, ctx) + graph.add_node("process_position", process_position) + graph.set_entry_point("process_position") + graph.add_conditional_edges( + "process_position", + _route_batch, + {"process_position": "process_position", END: END}, + ) + return graph diff --git a/cellacdc/workflow/pipelines/measurements_gui_nodes.py b/cellacdc/workflow/pipelines/measurements_gui_nodes.py new file mode 100644 index 000000000..058435702 --- /dev/null +++ b/cellacdc/workflow/pipelines/measurements_gui_nodes.py @@ -0,0 +1,135 @@ +"""GUI measurements pipeline nodes.""" + +from __future__ import annotations + +import os +import traceback + +import numpy as np +import skimage.measure + +from cellacdc import load, myutils + +from ..constants import END +from ..runnable import RunnableConfig +from ..state import MeasurementsGuiContext, MeasurementsGuiState + + +def load_position( + state: MeasurementsGuiState, + ctx: MeasurementsGuiContext, + config: RunnableConfig, +) -> dict[str, Any]: + end_name = ctx.end_filename_segm or ctx.kernel.end_filename_segm + pos_data = ctx.kernel._load_posData(state.img_path, end_name) + return {"pos_data": pos_data, "skipped": False, "aborted": False} + + +def prepare_gui_run( + state: MeasurementsGuiState, + ctx: MeasurementsGuiContext, + config: RunnableConfig, +) -> dict[str, Any]: + kernel = ctx.kernel + pos_data = state.pos_data + worker = ctx.compute_metrics_worker + save_worker = ctx.save_data_worker + exp_foldername = os.path.basename(pos_data.exp_path) + + kernel._set_metrics_func_from_posData(pos_data) + + if worker is not None and ctx.do_init_metrics: + worker.emitSigInitMetricsDialog(pos_data) + if worker.abort: + worker.signals.finished.emit(worker) + return {"aborted": True} + if kernel.setup_done: + worker.signals.finished.emit(worker) + return {"aborted": True} + worker.emitSigAskRunNow() + if worker.abort or worker.savedToWorkflow: + worker.signals.finished.emit(worker) + return {"aborted": True} + + if not pos_data.segmFound: + rel_path = f"...{os.sep}{exp_foldername}{os.sep}{pos_data.pos_foldername}" + kernel.log(f'Skipping "{rel_path}" because segm. file was not found.') + return {"skipped": True} + + kernel.init_signals(worker, save_worker) + kernel.log( + "Loading the following files:\n" + f"Segmentation file name: {os.path.basename(pos_data.segm_npz_path)}\n" + f"ACDC output file name: {os.path.basename(pos_data.acdc_output_csv_path)}" + ) + pos_data.init_segmInfo_df() + + if worker is not None: + worker.emitSigComputeVolume(pos_data, state.stop_frame_n) + + kernel._init_metrics_to_save(pos_data) + + if worker is not None: + worker.signals.initProgressBar.emit(state.stop_frame_n) + + channels_to_load = [ + ch + for ch in pos_data.chNames + if ch not in kernel.chNamesToSkip and ch in kernel.chNamesToProcess + ] + kernel.log(f"Loading channels {channels_to_load}...") + kernel._load_image_data(pos_data, channels_to_load) + return {} + + +def compute_metrics_frames( + state: MeasurementsGuiState, + ctx: MeasurementsGuiContext, + config: RunnableConfig, +) -> dict[str, Any]: + acdc_df_li, keys = ctx.kernel._compute_metrics_gui_frames( + state.pos_data, + state.stop_frame_n, + save_metrics=ctx.save_metrics, + compute_metrics_worker=ctx.compute_metrics_worker, + save_data_worker=ctx.save_data_worker, + ) + return {"acdc_df_li": acdc_df_li, "keys": keys} + + +def save_metrics_results( + state: MeasurementsGuiState, + ctx: MeasurementsGuiContext, + config: RunnableConfig, +) -> dict[str, Any]: + if not state.acdc_df_li: + exp_foldername = os.path.basename(state.pos_data.exp_path) + print("-" * 30) + ctx.kernel.log( + "All selected positions in the experiment folder " + f"{exp_foldername} have EMPTY segmentation mask. " + "Metrics will not be saved." + ) + print("-" * 30) + return {} + + ctx.kernel._concat_and_save_acdc_df( + state.acdc_df_li, + state.keys, + state.pos_data, + ctx.save_metrics, + computeMetricsWorker=ctx.compute_metrics_worker, + saveDataWorker=ctx.save_data_worker, + last_cca_frame_i=ctx.last_cca_frame_i, + ) + return {} + + +def _route_entry(state: MeasurementsGuiState, _ctx: MeasurementsGuiContext) -> str: + return "prepare_gui_run" if state.pos_data is not None else "load_position" + + +def _route_after_prepare(state: MeasurementsGuiState, _ctx: MeasurementsGuiContext) -> str: + if state.aborted or state.skipped: + return END + return "compute_metrics_frames" diff --git a/cellacdc/workflow/pipelines/measurements_nodes.py b/cellacdc/workflow/pipelines/measurements_nodes.py new file mode 100644 index 000000000..5dc83445c --- /dev/null +++ b/cellacdc/workflow/pipelines/measurements_nodes.py @@ -0,0 +1,61 @@ +"""Measurements position pipeline nodes.""" + +from __future__ import annotations + +import os +from typing import Any + +from ..constants import END +from ..runnable import RunnableConfig +from ..state import MeasurementsContext, MeasurementsState + + +def load_position( + state: MeasurementsState, + ctx: MeasurementsContext, + config: RunnableConfig, +) -> dict[str, Any]: + kernel = ctx.kernel + pos_data = kernel._load_posData(state.img_path, ctx.end_filename_segm) + return {"pos_data": pos_data, "skipped": False, "aborted": False, "error": None} + + +def validate_segm( + state: MeasurementsState, + ctx: MeasurementsContext, + config: RunnableConfig, +) -> dict[str, Any]: + pos_data = state.pos_data + if pos_data.segmFound: + return {} + + exp_foldername = os.path.basename(pos_data.exp_path) + rel_path = f"...{os.sep}{exp_foldername}{os.sep}{pos_data.pos_foldername}" + ctx.kernel.log(f'Skipping "{rel_path}" because segm. file was not found.') + return {"skipped": True} + + +def compute_and_save( + state: MeasurementsState, + ctx: MeasurementsContext, + config: RunnableConfig, +) -> dict[str, Any]: + ctx.kernel._run_metrics_cli( + state.pos_data, + state.stop_frame_n, + save_metrics=ctx.save_metrics, + last_cca_frame_i=ctx.last_cca_frame_i, + ) + return {} + + +def _route_after_validate(state: MeasurementsState, _ctx: MeasurementsContext) -> str: + if state.skipped: + return END + return "compute_and_save" + + +def _route_after_load(state: MeasurementsState, _ctx: MeasurementsContext) -> str: + if state.aborted: + return END + return "validate_segm" diff --git a/cellacdc/workflow/pipelines/postprocess_nodes.py b/cellacdc/workflow/pipelines/postprocess_nodes.py new file mode 100644 index 000000000..0b150e34c --- /dev/null +++ b/cellacdc/workflow/pipelines/postprocess_nodes.py @@ -0,0 +1,39 @@ +"""Shared postprocess helpers for workflow nodes.""" + +from __future__ import annotations + +from typing import Any + +from cellacdc import core, features + + +def apply_postprocess( + lab: Any, + img: Any, + pos_data: Any, + frame_i: int, + *, + apply_postprocessing: bool, + standard_postprocess_kwargs: dict[str, Any], + custom_postprocess_features: dict[str, Any], + custom_postprocess_grouped_features: dict[str, Any], + user_ch_name: str | None = None, +) -> Any: + if not apply_postprocessing: + return lab + + lab = core.post_process_segm(lab, **standard_postprocess_kwargs) + if not custom_postprocess_features: + return lab + + ch_name = user_ch_name or pos_data.user_ch_name + return features.custom_post_process_segm( + pos_data, + custom_postprocess_grouped_features, + lab, + img, + frame_i, + pos_data.filename, + ch_name, + custom_postprocess_features, + ) diff --git a/cellacdc/workflow/pipelines/segm.py b/cellacdc/workflow/pipelines/segm.py new file mode 100644 index 000000000..aee4676bd --- /dev/null +++ b/cellacdc/workflow/pipelines/segm.py @@ -0,0 +1,67 @@ +"""Segmentation position pipeline graph.""" + +from __future__ import annotations + +from ..constants import END +from ..graph import StateGraph +from ..state import PositionState, WorkflowContext +from . import segm_nodes as nodes + + +def build_position_segm_graph( + ctx: WorkflowContext, +) -> StateGraph[PositionState, WorkflowContext]: + """Build the per-position segmentation graph.""" + graph = StateGraph(PositionState, ctx) + for name, fn in ( + ("load_position", nodes.load_position), + ("prepare_stack", nodes.prepare_stack), + ("ensure_model", nodes.ensure_model), + ("segment", nodes.segment), + ("filter_freehand_roi", nodes.filter_freehand_roi), + ("postprocess", nodes.postprocess), + ("before_track", nodes.passthrough), + ("track", nodes.track), + ("skip_track", nodes.skip_track_progress), + ("before_pad", nodes.passthrough), + ("pad_roi", nodes.pad_roi), + ("before_save", nodes.passthrough), + ("save", nodes.save), + ): + graph.add_node(name, fn) + + graph.set_entry_point("load_position") + graph.add_edge("load_position", "prepare_stack") + graph.add_edge("prepare_stack", "ensure_model") + graph.add_conditional_edges( + "ensure_model", + nodes._route_after_model, + {"segment": "segment", END: END}, + ) + graph.add_edge("segment", "filter_freehand_roi") + graph.add_conditional_edges( + "filter_freehand_roi", + nodes._route_postprocess, + {"postprocess": "postprocess", "before_track": "before_track"}, + ) + graph.add_edge("postprocess", "before_track") + graph.add_conditional_edges( + "before_track", + nodes._route_track, + {"track": "track", "skip_track": "skip_track"}, + ) + graph.add_edge("track", "before_pad") + graph.add_edge("skip_track", "before_pad") + graph.add_conditional_edges( + "before_pad", + nodes._route_pad_roi, + {"pad_roi": "pad_roi", "before_save": "before_save"}, + ) + graph.add_edge("pad_roi", "before_save") + graph.add_conditional_edges( + "before_save", + nodes._route_save, + {"save": "save", END: END}, + ) + graph.add_edge("save", END) + return graph diff --git a/cellacdc/workflow/pipelines/segm_nodes.py b/cellacdc/workflow/pipelines/segm_nodes.py new file mode 100644 index 000000000..60bc43fd6 --- /dev/null +++ b/cellacdc/workflow/pipelines/segm_nodes.py @@ -0,0 +1,528 @@ +"""Segmentation pipeline node implementations.""" + +from __future__ import annotations + +import os +import time +from typing import Any + +import numpy as np +from tqdm import tqdm + +from cellacdc import core, features, io, load, myutils + +from ..constants import END +from ..runnable import RunnableConfig +from ..state import PositionState, WorkflowContext + + +def passthrough( + state: PositionState, + ctx: WorkflowContext, + config: RunnableConfig, +) -> dict[str, Any]: + return {} + + +def load_position( + state: PositionState, + ctx: WorkflowContext, + config: RunnableConfig, +) -> dict[str, Any]: + pos_data = load.loadData(state.img_path, ctx.user_ch_name) + config.logger_func(f"Loading {pos_data.relPath}...") + + pos_data.getBasenameAndChNames() + pos_data.buildPaths() + pos_data.loadImgData() + pos_data.loadOtherFiles( + load_segm_data=False, + load_acdc_df=False, + load_shifts=True, + loadSegmInfo=True, + load_delROIsInfo=False, + load_dataPrep_ROIcoords=True, + load_bkgr_data=True, + load_last_tracked_i=False, + load_metadata=True, + load_dataprep_free_roi=True, + end_filename_segm=ctx.segm_endname, + ) + + end_name = ( + ctx.segm_endname.replace("segm", "", 1).replace("_", "", 1).split(".")[0] + ) + if end_name: + pos_data.setFilePaths(end_name) + + if ctx.do_save: + segm_filename = os.path.basename(pos_data.segm_npz_path) + config.logger_func(f"\nSegmentation file {segm_filename}...") + + pos_data.SizeT = ctx.size_t + if ctx.size_z > 1: + pos_data.SizeZ = pos_data.img_data.shape[-3] + else: + pos_data.SizeZ = 1 + + pos_data.isSegm3D = ctx.is_segm_3d + pos_data.saveMetadata() + + is_roi_active = False + roi_bounds = None + if pos_data.dataPrep_ROIcoords is not None and ctx.use_roi: + df_roi = pos_data.dataPrep_ROIcoords.loc[0] + is_roi_active = df_roi.at["cropped", "value"] == 0 + x0, x1, y0, y1 = df_roi["value"].astype(int)[:4] + y_shape, x_shape = pos_data.img_data.shape[-2:] + x0 = x0 if x0 > 0 else 0 + y0 = y0 if y0 > 0 else 0 + x1 = x1 if x1 < x_shape else x_shape + y1 = y1 if y1 < y_shape else y_shape + roi_bounds = (x0, x1, y0, y1) + + return { + "pos_data": pos_data, + "is_roi_active": is_roi_active, + "roi_bounds": roi_bounds, + "stop_i": state.stop_frame_n, + "t0": 0, + "aborted": False, + "error": None, + } + + +def prepare_stack( + state: PositionState, + ctx: WorkflowContext, + config: RunnableConfig, +) -> dict[str, Any]: + pos_data = state.pos_data + stop_i = state.stop_i + is_roi_active = state.is_roi_active + pad_info = None + second_ch_data = None + + if ctx.second_channel_name is not None: + config.logger_func(f'Loading second channel "{ctx.second_channel_name}"...') + second_ch_filepath = load.get_filename_from_channel( + pos_data.images_path, ctx.second_channel_name + ) + second_ch_img_data = load.load_image_file(second_ch_filepath) + else: + second_ch_img_data = None + + x0 = x1 = y0 = y1 = 0 + if state.roi_bounds is not None: + x0, x1, y0, y1 = state.roi_bounds + + if pos_data.SizeT > 1: + t0 = state.t0 + if pos_data.SizeZ > 1 and not ctx.is_segm_3d and not ctx.use_3d_data_for_2d_segm: + img_data = pos_data.img_data + if ctx.second_channel_name is not None: + second_ch_data_slice = second_ch_img_data[t0:stop_i] + if is_roi_active: + y_shape, x_shape = img_data.shape[-2:] + img_data = img_data[:, :, y0:y1, x0:x1] + if ctx.second_channel_name is not None: + second_ch_data_slice = second_ch_data_slice[:, :, y0:y1, x0:x1] + pad_info = ((0, 0), (y0, y_shape - y1), (x0, x_shape - x1)) + + img_data_slice = img_data[t0:stop_i] + postprocess_img = img_data + y_shape, x_shape = img_data.shape[-2:] + new_shape = (stop_i, y_shape, x_shape) + img_data = np.zeros(new_shape, img_data.dtype) + if ctx.second_channel_name is not None: + second_ch_data = np.zeros(new_shape, second_ch_img_data.dtype) + + df = pos_data.segmInfo_df.loc[pos_data.filename] + for z_info in df[:stop_i].itertuples(): + i = z_info.Index + z = z_info.z_slice_used_dataPrep + z_proj_how = z_info.which_z_proj + img = img_data_slice[i] + if ctx.second_channel_name is not None: + second_ch_img = second_ch_data_slice[i] + if z_proj_how == "single z-slice": + img_data[i] = img[z] + if ctx.second_channel_name is not None: + second_ch_data[i] = second_ch_img[z] + elif z_proj_how == "max z-projection": + img_data[i] = img.max(axis=0) + if ctx.second_channel_name is not None: + second_ch_data[i] = second_ch_img.max(axis=0) + elif z_proj_how == "mean z-projection": + img_data[i] = img.mean(axis=0) + if ctx.second_channel_name is not None: + second_ch_data[i] = second_ch_img.mean(axis=0) + elif z_proj_how == "median z-proj.": + img_data[i] = np.median(img, axis=0) + if ctx.second_channel_name is not None: + second_ch_data[i] = np.median(second_ch_img, axis=0) + elif pos_data.SizeZ > 1 and (ctx.is_segm_3d or ctx.use_3d_data_for_2d_segm): + img_data = pos_data.img_data[t0:stop_i] + postprocess_img = img_data + if ctx.second_channel_name is not None: + second_ch_data = second_ch_img_data[t0:stop_i] + if is_roi_active: + y_shape, x_shape = img_data.shape[-2:] + img_data = img_data[:, :, y0:y1, x0:x1] + if ctx.second_channel_name is not None: + second_ch_data = second_ch_data[:, :, y0:y1, x0:x1] + pad_info = ((0, 0), (0, 0), (y0, y_shape - y1), (x0, x_shape - x1)) + else: + img_data = pos_data.img_data[t0:stop_i] + postprocess_img = img_data + if ctx.second_channel_name is not None: + second_ch_data = second_ch_img_data[t0:stop_i] + if is_roi_active: + y_shape, x_shape = img_data.shape[-2:] + img_data = img_data[:, y0:y1, x0:x1] + if ctx.second_channel_name is not None: + second_ch_data = second_ch_data[:, :, y0:y1, x0:x1] + pad_info = ((0, 0), (y0, y_shape - y1), (x0, x_shape - x1)) + elif pos_data.SizeZ > 1 and not ctx.is_segm_3d and not ctx.use_3d_data_for_2d_segm: + img_data = pos_data.img_data + if ctx.second_channel_name is not None: + second_ch_data = second_ch_img_data + if is_roi_active: + y_shape, x_shape = img_data.shape[-2:] + pad_info = ((y0, y_shape - y1), (x0, x_shape - x1)) + img_data = img_data[:, y0:y1, x0:x1] + if ctx.second_channel_name is not None: + second_ch_data = second_ch_data[:, :, y0:y1, x0:x1] + + postprocess_img = img_data + z_info = pos_data.segmInfo_df.loc[pos_data.filename].iloc[0] + z = z_info.z_slice_used_dataPrep + z_proj_how = z_info.which_z_proj + if z_proj_how == "single z-slice": + img_data = img_data[z] + if ctx.second_channel_name is not None: + second_ch_data = second_ch_data[z] + elif z_proj_how == "max z-projection": + img_data = img_data.max(axis=0) + if ctx.second_channel_name is not None: + second_ch_data = second_ch_data.max(axis=0) + elif z_proj_how == "mean z-projection": + img_data = img_data.mean(axis=0) + if ctx.second_channel_name is not None: + second_ch_data = second_ch_data.mean(axis=0) + elif z_proj_how == "median z-proj.": + img_data = np.median(img_data, axis=0) + if ctx.second_channel_name is not None: + second_ch_data = np.median(second_ch_data, axis=0) + elif pos_data.SizeZ > 1 and (ctx.is_segm_3d or ctx.use_3d_data_for_2d_segm): + img_data = pos_data.img_data + if ctx.second_channel_name is not None: + second_ch_data = second_ch_img_data + if is_roi_active: + y_shape, x_shape = img_data.shape[-2:] + pad_info = ((0, 0), (y0, y_shape - y1), (x0, x_shape - x1)) + img_data = img_data[:, y0:y1, x0:x1] + if ctx.second_channel_name is not None: + second_ch_data = second_ch_data[:, y0:y1, x0:x1] + postprocess_img = img_data + else: + img_data = pos_data.img_data + if ctx.second_channel_name is not None: + second_ch_data = second_ch_img_data + if is_roi_active: + y_shape, x_shape = img_data.shape[-2:] + pad_info = ((y0, y_shape - y1), (x0, x_shape - x1)) + img_data = img_data[y0:y1, x0:x1] + if ctx.second_channel_name is not None: + second_ch_data = second_ch_data[y0:y1, x0:x1] + postprocess_img = img_data + + config.logger_func(f"\nImage shape = {img_data.shape}") + return { + "img_data": img_data, + "second_ch_data": second_ch_data, + "postprocess_img": postprocess_img, + "pad_info": pad_info, + } + + +def ensure_model( + state: PositionState, + ctx: WorkflowContext, + config: RunnableConfig, +) -> dict[str, Any]: + if ctx.model is not None: + return {} + + if ctx.signals is not None: + ctx.signals.progress.emit( + f"\nInitializing {ctx.model_name} segmentation model..." + ) + else: + config.logger_func(f"\nInitializing {ctx.model_name} segmentation model...") + + acdc_segment = myutils.import_segment_module(ctx.model_name) + init_argspecs, segment_argspecs = myutils.getModelArgSpec(acdc_segment) + ctx.init_model_kwargs = myutils.parse_model_params( + init_argspecs, ctx.init_model_kwargs + ) + ctx.model_kwargs = myutils.parse_model_params(segment_argspecs, ctx.model_kwargs) + if ctx.second_channel_name is not None: + ctx.init_model_kwargs["is_rgb"] = True + + ctx.model = myutils.init_segm_model( + acdc_segment, state.pos_data, ctx.init_model_kwargs + ) + if ctx.model is None: + message = f"Segmentation model {ctx.model_name} was not initialized!" + config.logger_func(f"\n{message}") + return {"aborted": True, "error": message} + + ctx.is_segment3dt_available = any( + name == "segment3DT" for name in dir(ctx.model) + ) and not ctx.reduce_memory_usage + return {"model": ctx.model} + + +def segment( + state: PositionState, + ctx: WorkflowContext, + config: RunnableConfig, +) -> dict[str, Any]: + pos_data = state.pos_data + img_data = state.img_data + second_ch_data = state.second_ch_data + + config.logger_func(f"\nSegmenting with {ctx.model_name}...") + time.perf_counter() + + if pos_data.SizeT > 1: + if ctx.inner_pbar_available and ctx.signals is not None: + ctx.signals.resetInnerPbar.emit(len(img_data)) + + if ctx.is_segment3dt_available and img_data.ndim == 3: + ctx.model_kwargs["signals"] = (ctx.signals, ctx.inner_pbar_available) + if ctx.second_channel_name is not None: + img_data = ctx.model.second_ch_img_to_stack(img_data, second_ch_data) + lab_stack = core.segm_model_segment( + ctx.model, + img_data, + ctx.model_kwargs, + is_timelapse_model_and_data=True, + preproc_recipe=ctx.preproc_recipe, + posData=pos_data, + ) + if ctx.inner_pbar_available and ctx.signals is not None: + ctx.signals.progressBar.emit(1) + else: + lab_stack = [] + pbar = tqdm(total=len(img_data), ncols=100) + for t, img in enumerate(img_data): + if ctx.second_channel_name is not None: + img = ctx.model.second_ch_img_to_stack(img, second_ch_data[t]) + lab = core.segm_model_segment( + ctx.model, + img, + ctx.model_kwargs, + frame_i=t, + preproc_recipe=ctx.preproc_recipe, + posData=pos_data, + ) + lab_stack.append(lab) + if ctx.signals is not None: + if ctx.inner_pbar_available: + ctx.signals.innerProgressBar.emit(1) + else: + ctx.signals.progressBar.emit(1) + pbar.update() + pbar.close() + lab_stack = np.array(lab_stack, dtype=np.uint32) + if ctx.inner_pbar_available and ctx.signals is not None: + ctx.signals.progressBar.emit(1) + else: + if ctx.second_channel_name is not None: + img_data = ctx.model.second_ch_img_to_stack(img_data, second_ch_data) + lab_stack = core.segm_model_segment( + ctx.model, + img_data, + ctx.model_kwargs, + frame_i=0, + preproc_recipe=ctx.preproc_recipe, + posData=pos_data, + ) + if ctx.signals is not None: + ctx.signals.progressBar.emit(1) + + pos_data.saveSamEmbeddings(logger_func=config.logger_func) + return {"lab_stack": lab_stack, "img_data": img_data} + + +def filter_freehand_roi( + state: PositionState, + ctx: WorkflowContext, + config: RunnableConfig, +) -> dict[str, Any]: + pos_data = state.pos_data + lab_stack = state.lab_stack + if len(pos_data.dataPrepFreeRoiPoints) > 0 and ctx.use_freehand_roi: + config.logger_func("Removing objects outside the dataprep free-hand ROI...") + lab_stack = pos_data.clearSegmObjsDataPrepFreeRoi( + lab_stack, is_timelapse=pos_data.SizeT > 1 + ) + return {"lab_stack": lab_stack} + + +def postprocess( + state: PositionState, + ctx: WorkflowContext, + config: RunnableConfig, +) -> dict[str, Any]: + pos_data = state.pos_data + lab_stack = state.lab_stack + postprocess_img = state.postprocess_img + + if pos_data.SizeT > 1: + pbar = tqdm(total=len(lab_stack), ncols=100) + for t, lab in enumerate(lab_stack): + lab_cleaned = core.post_process_segm( + lab, **ctx.standard_postprocess_kwargs + ) + lab_stack[t] = lab_cleaned + if ctx.custom_postprocess_features: + lab_filtered = features.custom_post_process_segm( + pos_data, + ctx.custom_postprocess_grouped_features, + lab_cleaned, + postprocess_img, + t, + pos_data.filename, + pos_data.user_ch_name, + ctx.custom_postprocess_features, + ) + lab_stack[t] = lab_filtered + pbar.update() + pbar.close() + else: + lab_stack = core.post_process_segm( + lab_stack, **ctx.standard_postprocess_kwargs + ) + if ctx.custom_postprocess_features: + lab_stack = features.custom_post_process_segm( + pos_data, + ctx.custom_postprocess_grouped_features, + lab_stack, + postprocess_img, + 0, + pos_data.filename, + pos_data.user_ch_name, + ctx.custom_postprocess_features, + ) + return {"lab_stack": lab_stack} + + +def track( + state: PositionState, + ctx: WorkflowContext, + config: RunnableConfig, +) -> dict[str, Any]: + pos_data = state.pos_data + lab_stack = state.lab_stack + + config.logger_func(f"\nTracking with {ctx.tracker_name} tracker...") + if ctx.do_save: + config.logger_func(f"Saving NON-tracked masks of {pos_data.relPath}...") + io.savez_compressed(pos_data.segm_npz_path, lab_stack) + + if ctx.signals is not None: + ctx.signals.innerPbar_available = ctx.inner_pbar_available + ctx.track_params["signals"] = ctx.signals + + tracker_input_img = None + if ctx.image_channel_tracker is not None: + if "image" in ctx.track_params: + tracker_input_img = ctx.track_params.pop("image") + else: + config.logger_func( + f'Loading image data of channel "{ctx.image_channel_tracker}"' + ) + tracker_input_img = pos_data.loadChannelData(ctx.image_channel_tracker) + + tracked_stack = core.tracker_track( + lab_stack, + ctx.tracker, + ctx.track_params, + intensity_img=tracker_input_img, + logger_func=config.logger_func, + ) + pos_data.fromTrackerToAcdcDf(ctx.tracker, tracked_stack, save=True) + return {"tracked_stack": tracked_stack, "lab_stack": lab_stack} + + +def skip_track_progress( + state: PositionState, + ctx: WorkflowContext, + config: RunnableConfig, +) -> dict[str, Any]: + if ctx.signals is None: + return {"tracked_stack": state.lab_stack} + + try: + if ctx.inner_pbar_available: + ctx.signals.innerProgressBar.emit(state.stop_i) + else: + ctx.signals.progressBar.emit(state.stop_i) + except AttributeError: + if ctx.inner_pbar_available: + ctx.signals.innerProgressBar.emit(1) + else: + ctx.signals.progressBar.emit(1) + return {"tracked_stack": state.lab_stack} + + +def pad_roi( + state: PositionState, + ctx: WorkflowContext, + config: RunnableConfig, +) -> dict[str, Any]: + tracked_stack = state.tracked_stack + if state.pad_info is not None: + config.logger_func(f"Padding with zeros {state.pad_info}...") + tracked_stack = np.pad(tracked_stack, state.pad_info, mode="constant") + return {"tracked_stack": tracked_stack} + + +def save( + state: PositionState, + ctx: WorkflowContext, + config: RunnableConfig, +) -> dict[str, Any]: + pos_data = state.pos_data + config.logger_func(f"Saving {pos_data.relPath}...") + io.savez_compressed(pos_data.segm_npz_path, state.tracked_stack) + config.logger_func(f"\n{pos_data.relPath} done.") + return {} + + +def _route_after_model(state: PositionState, ctx: WorkflowContext) -> str: + if state.aborted or ctx.model is None: + return END + return "segment" + + +def _route_postprocess(_state: PositionState, ctx: WorkflowContext) -> str: + return "postprocess" if ctx.do_postprocess else "before_track" + + +def _route_track(state: PositionState, ctx: WorkflowContext) -> str: + if not ctx.do_tracking: + return "skip_track" + size_t = getattr(state.pos_data, "SizeT", ctx.size_t) + return "track" if size_t > 1 else "skip_track" + + +def _route_pad_roi(state: PositionState, _ctx: WorkflowContext) -> str: + return "pad_roi" if state.is_roi_active else "before_save" + + +def _route_save(_state: PositionState, ctx: WorkflowContext) -> str: + return "save" if ctx.do_save else END diff --git a/cellacdc/workflow/runnable.py b/cellacdc/workflow/runnable.py new file mode 100644 index 000000000..a41ca4ced --- /dev/null +++ b/cellacdc/workflow/runnable.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Protocol, runtime_checkable + + +@dataclass(slots=True) +class RunnableConfig: + """Per-run callbacks and metadata (LangChain RunnableConfig analogue).""" + + logger_func: Callable[[str], None] = print + tags: list[str] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + signals: Any | None = None + + +@runtime_checkable +class Runnable(Protocol): + """Minimal composable step interface.""" + + def invoke(self, input: Any, config: RunnableConfig | None = None) -> Any: ... + + +@dataclass(slots=True) +class RunnableLambda(Runnable): + """Wrap a plain callable as a Runnable.""" + + func: Callable[..., Any] + name: str | None = None + + def invoke(self, input: Any, config: RunnableConfig | None = None) -> Any: + if config is None: + return self.func(input) + return self.func(input, config) + + +@dataclass(slots=True) +class RunnableSequence(Runnable): + """Linear chain of runnables (LangChain RunnableSequence analogue).""" + + steps: tuple[Runnable, ...] + + def invoke(self, input: Any, config: RunnableConfig | None = None) -> Any: + value = input + for step in self.steps: + value = step.invoke(value, config) + return value + + def __or__(self, other: Runnable) -> RunnableSequence: + if isinstance(other, RunnableSequence): + return RunnableSequence(self.steps + other.steps) + return RunnableSequence(self.steps + (other,)) diff --git a/cellacdc/workflow/state.py b/cellacdc/workflow/state.py new file mode 100644 index 000000000..e1101985a --- /dev/null +++ b/cellacdc/workflow/state.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field, fields, replace +from typing import Any + + +def merge_state(state: Any, update: dict[str, Any] | None) -> Any: + if not update: + return state + if isinstance(state, dict): + return {**state, **update} + return replace(state, **update) + + +@dataclass(slots=True) +class WorkflowContext: + """Immutable workflow configuration (LangGraph context_schema analogue).""" + + user_ch_name: str + segm_endname: str = "segm.npz" + model_name: str = "" + tracker_name: str = "" + do_tracking: bool = False + do_postprocess: bool = True + do_save: bool = True + is_segm_3d: bool = False + use_roi: bool = True + use_freehand_roi: bool = True + use_3d_data_for_2d_segm: bool = False + second_channel_name: str | None = None + image_channel_tracker: str | None = None + size_t: int = 1 + size_z: int = 1 + model_kwargs: dict[str, Any] = field(default_factory=dict) + init_model_kwargs: dict[str, Any] = field(default_factory=dict) + track_params: dict[str, Any] = field(default_factory=dict) + init_tracker_kwargs: dict[str, Any] = field(default_factory=dict) + standard_postprocess_kwargs: dict[str, Any] = field(default_factory=dict) + custom_postprocess_features: dict[str, Any] = field(default_factory=dict) + custom_postprocess_grouped_features: dict[str, Any] = field(default_factory=dict) + preproc_recipe: list[dict[str, Any]] | None = None + reduce_memory_usage: bool = False + model: Any | None = None + tracker: Any | None = None + is_segment3dt_available: bool = False + inner_pbar_available: bool = False + signals: Any | None = None + + +@dataclass(slots=True) +class PositionState: + """Mutable per-position pipeline state (LangGraph state_schema analogue).""" + + img_path: str + stop_frame_n: int = 1 + pos_data: Any | None = None + img_data: Any | None = None + second_ch_data: Any | None = None + postprocess_img: Any | None = None + lab_stack: Any | None = None + tracked_stack: Any | None = None + model: Any | None = None + tracker: Any | None = None + is_roi_active: bool = False + pad_info: tuple | None = None + roi_bounds: tuple[int, int, int, int] | None = None + stop_i: int = 1 + t0: int = 0 + aborted: bool = False + error: str | None = None + + def as_update(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(slots=True) +class BatchState: + """Outer loop over many positions.""" + + paths: list[str] = field(default_factory=list) + stop_frame_numbers: list[int] = field(default_factory=list) + current_index: int = 0 + results: list[Any] = field(default_factory=list) + aborted: bool = False + + @property + def done(self) -> bool: + return self.current_index >= len(self.paths) + + @property + def current_path(self) -> str | None: + if self.done: + return None + return self.paths[self.current_index] + + @property + def current_stop_frame(self) -> int: + if not self.stop_frame_numbers: + return 1 + index = min(self.current_index, len(self.stop_frame_numbers) - 1) + return int(self.stop_frame_numbers[index]) + + +@dataclass(slots=True) +class BatchWorkflowContext: + """Context for batch parent graphs.""" + + position_ctx: WorkflowContext + position_graph: Any | None = None + + +@dataclass(slots=True) +class MeasurementsContext: + """Context for measurements position pipeline.""" + + end_filename_segm: str + kernel: Any + save_metrics: bool = True + last_cca_frame_i: Any | None = None + + +@dataclass(slots=True) +class MeasurementsBatchContext: + measurements_ctx: MeasurementsContext + position_graph: Any | None = None + + +@dataclass(slots=True) +class MeasurementsState: + img_path: str = "" + stop_frame_n: int = 1 + pos_data: Any | None = None + skipped: bool = False + aborted: bool = False + error: str | None = None + + +@dataclass(slots=True) +class InteractiveSegmContext: + """Context for in-viewer single-frame segmentation.""" + + model: Any + model_kwargs: dict[str, Any] + apply_postprocessing: bool = False + standard_postprocess_kwargs: dict[str, Any] = field(default_factory=dict) + custom_postprocess_features: dict[str, Any] = field(default_factory=dict) + custom_postprocess_grouped_features: dict[str, Any] = field(default_factory=dict) + segment_3d: bool = False + second_channel_data: Any | None = None + z_range: tuple[int, int] | None = None + + +@dataclass(slots=True) +class InteractiveSegmState: + main_win: Any + pos_data: Any | None = None + img: Any | None = None + lab: Any | None = None + segmented_lab: Any | None = None + start_z_slice: int = 0 + exec_time: float = 0.0 + + +@dataclass(slots=True) +class InteractiveVideoSegmContext: + """Context for in-viewer timelapse segmentation.""" + + model: Any + model_kwargs: dict[str, Any] + apply_postprocessing: bool = False + standard_postprocess_kwargs: dict[str, Any] = field(default_factory=dict) + custom_postprocess_features: dict[str, Any] = field(default_factory=dict) + custom_postprocess_grouped_features: dict[str, Any] = field(default_factory=dict) + preproc_recipe: list[dict[str, Any]] | None = None + second_channel_data: Any | None = None + start_frame_num: int = 1 + stop_frame_num: int = 1 + progress_callback: Any | None = None + logger_func: Any = print + + +@dataclass(slots=True) +class InteractiveVideoSegmState: + pos_data: Any + segm_data: Any | None = None + img_data: Any | None = None + z_slices: Any | None = None + exec_time: float = 0.0 + + +@dataclass(slots=True) +class MeasurementsGuiContext: + """Context for GUI-driven measurements runs.""" + + kernel: Any + compute_metrics_worker: Any | None = None + save_data_worker: Any | None = None + save_metrics: bool = True + do_init_metrics: bool = True + last_cca_frame_i: Any | None = None + end_filename_segm: str = "" + + +@dataclass(slots=True) +class MeasurementsGuiState: + img_path: str = "" + stop_frame_n: int = 1 + pos_data: Any | None = None + skipped: bool = False + aborted: bool = False + acdc_df_li: list[Any] = field(default_factory=list) + keys: list[Any] = field(default_factory=list) + + +@dataclass(slots=True) +class MeasurementsGuiBatchContext: + kernel: Any + compute_metrics_worker: Any | None = None + save_data_worker: Any | None = None + save_metrics: bool = True + end_filename_segm: str = "" + + +@dataclass(slots=True) +class FullWorkflowState: + """Top-level INI workflow state.""" + + segm_params: dict[str, Any] = field(default_factory=dict) + measurements_params: dict[str, Any] | None = None + run_segm: bool = True + run_measurements: bool = False + segm_done: bool = False + measurements_done: bool = False + + +def state_field_names(state_type: type) -> set[str]: + if hasattr(state_type, "__dataclass_fields__"): + return set(state_type.__dataclass_fields__) + return {field.name for field in fields(state_type)} diff --git a/tests/test_workflow_graph.py b/tests/test_workflow_graph.py new file mode 100644 index 000000000..0ccb66001 --- /dev/null +++ b/tests/test_workflow_graph.py @@ -0,0 +1,234 @@ +"""Tests for workflow graph modeling.""" + +import importlib.util +import sys +import types +import unittest +from pathlib import Path + + +def _bootstrap_workflow_package(): + root = Path(__file__).resolve().parents[1] + workflow_root = root / "cellacdc" / "workflow" + + cellacdc_pkg = sys.modules.get("cellacdc") + if cellacdc_pkg is None: + cellacdc_pkg = types.ModuleType("cellacdc") + cellacdc_pkg.__path__ = [str(root / "cellacdc")] + sys.modules["cellacdc"] = cellacdc_pkg + + workflow_pkg = types.ModuleType("cellacdc.workflow") + workflow_pkg.__path__ = [str(workflow_root)] + sys.modules["cellacdc.workflow"] = workflow_pkg + + for name in ("constants", "state", "runnable", "graph"): + module_name = f"cellacdc.workflow.{name}" + if module_name in sys.modules: + continue + spec = importlib.util.spec_from_file_location( + module_name, workflow_root / f"{name}.py" + ) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + setattr(workflow_pkg, name, module) + + return sys.modules["cellacdc.workflow"] + + +workflow = _bootstrap_workflow_package() +END = workflow.constants.END +PositionState = workflow.state.PositionState +WorkflowContext = workflow.state.WorkflowContext +RunnableConfig = workflow.runnable.RunnableConfig +StateGraph = workflow.graph.StateGraph + + +class TestSegmWorkflowGraph(unittest.TestCase): + def test_graph_structure_without_heavy_imports(self): + ctx = WorkflowContext(user_ch_name="phase", model_name="cellpose") + graph = StateGraph(PositionState, ctx) + graph.add_node("load_position", lambda s, c, cfg: {}) + graph.add_node("segment", lambda s, c, cfg: {}) + graph.set_entry_point("load_position") + graph.add_edge("load_position", "segment") + graph.add_edge("segment", END) + structure = graph.get_graph() + self.assertEqual(structure["entrypoint"], "load_position") + self.assertEqual(structure["edges"]["segment"], END) + + def test_compiled_graph_routes_to_end(self): + ctx = WorkflowContext(user_ch_name="phase", do_save=False) + logs: list[str] = [] + + def load_node(state, workflow_ctx, config): + logs.append("load") + return {} + + def save_node(state, workflow_ctx, config): + logs.append("save") + return {} + + graph = StateGraph(PositionState, ctx) + graph.add_node("load_position", load_node) + graph.add_node("save", save_node) + graph.set_entry_point("load_position") + graph.add_conditional_edges( + "load_position", + lambda _s, workflow_ctx: END if not workflow_ctx.do_save else "save", + {"save": "save", END: END}, + ) + graph.add_edge("save", END) + + compiled = graph.compile() + compiled.invoke( + PositionState(img_path="/tmp/test.tif"), + RunnableConfig(logger_func=logs.append), + ) + self.assertEqual(logs, ["load"]) + + def test_batch_graph_loops_over_paths(self): + invoked: list[str] = [] + + class _PositionGraph: + def invoke(self, state, config): + invoked.append(state.img_path) + return state + + BatchWorkflowContext = workflow.state.BatchWorkflowContext + BatchState = workflow.state.BatchState + + position_ctx = WorkflowContext(user_ch_name="phase") + batch_ctx = BatchWorkflowContext(position_ctx=position_ctx) + batch_ctx.position_graph = _PositionGraph() + + def process_position(state, ctx, config): + path = state.paths[state.current_index] + stop_frame_n = state.stop_frame_numbers[state.current_index] + result = ctx.position_graph.invoke( + PositionState(img_path=path, stop_frame_n=stop_frame_n), + config, + ) + return { + "results": [*state.results, result], + "current_index": state.current_index + 1, + } + + def route_batch(state, _ctx): + return END if state.current_index >= len(state.paths) else "process_position" + + graph = StateGraph(BatchState, batch_ctx) + graph.add_node("process_position", process_position) + graph.set_entry_point("process_position") + graph.add_conditional_edges( + "process_position", + route_batch, + {"process_position": "process_position", END: END}, + ) + graph.compile().invoke( + BatchState(paths=["/a.tif", "/b.tif"], stop_frame_numbers=[1, 2]), + RunnableConfig(), + ) + self.assertEqual(invoked, ["/a.tif", "/b.tif"]) + + def test_gui_measurements_batch_loops_and_stops_on_abort(self): + invoked: list[str] = [] + + class _GuiMeasurementsGraph: + def __init__(self, abort_on: str | None = None): + self.abort_on = abort_on + + def invoke(self, state, config): + invoked.append(state.img_path) + aborted = state.img_path == self.abort_on + return workflow.state.MeasurementsGuiState( + img_path=state.img_path, + aborted=aborted, + ) + + MeasurementsGuiBatchContext = workflow.state.MeasurementsGuiBatchContext + BatchState = workflow.state.BatchState + MeasurementsGuiContext = workflow.state.MeasurementsGuiContext + MeasurementsGuiState = workflow.state.MeasurementsGuiState + + class _Kernel: + setup_done = False + + @staticmethod + def log(msg): + pass + + batch_ctx = MeasurementsGuiBatchContext(kernel=_Kernel()) + + def build_graph(ctx, pos_data_loaded=False): + del pos_data_loaded + + class _Builder: + def compile(self): + return _GuiMeasurementsGraph(abort_on="/b.tif") + + return _Builder() + + def process_position(state, ctx, config): + path = state.paths[state.current_index] + stop_frame_n = state.stop_frame_numbers[state.current_index] + gui_ctx = MeasurementsGuiContext(kernel=ctx.kernel) + graph = build_graph(gui_ctx, pos_data_loaded=False).compile() + result = graph.invoke( + MeasurementsGuiState(img_path=path, stop_frame_n=stop_frame_n), + config, + ) + results = [*state.results, result] + aborted = bool(getattr(result, "aborted", False)) + return { + "results": results, + "current_index": state.current_index + 1, + "aborted": aborted or state.aborted, + } + + def route_batch(state, ctx): + if state.aborted or ctx.kernel.setup_done: + return END + if state.current_index >= len(state.paths): + return END + return "process_position" + + graph = StateGraph(BatchState, batch_ctx) + graph.add_node("process_position", process_position) + graph.set_entry_point("process_position") + graph.add_conditional_edges( + "process_position", + route_batch, + {"process_position": "process_position", END: END}, + ) + final = graph.compile().invoke( + BatchState(paths=["/a.tif", "/b.tif", "/c.tif"], stop_frame_numbers=[1, 1, 1]), + RunnableConfig(), + ) + self.assertEqual(invoked, ["/a.tif", "/b.tif"]) + self.assertTrue(final.aborted) + + def test_video_graph_structure(self): + graph = StateGraph( + workflow.state.InteractiveVideoSegmState, + None, + ) + steps = [ + "extend_segm_data", + "prepare_video_stack", + "segment_video_frames", + "finalize_video_run", + ] + for step in steps: + graph.add_node(step, lambda s, c, cfg: {}) + graph.set_entry_point("extend_segm_data") + for left, right in zip(steps, steps[1:]): + graph.add_edge(left, right) + graph.add_edge(steps[-1], END) + structure = graph.get_graph() + self.assertEqual(structure["entrypoint"], "extend_segm_data") + self.assertEqual(structure["edges"]["finalize_video_run"], END) + + +if __name__ == "__main__": + unittest.main() From 673f3171cb29d7534e2ead9e30f36933bb16e1e6 Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 16:08:41 +0200 Subject: [PATCH 17/21] Split god files into feature packages with compatibility shims. Move myutils, workers, widgets, and apps into focused subpackages so each module has a single responsibility while preserving existing import paths for callers. Co-authored-by: Cursor --- cellacdc/apps.py | 19626 +--------------------------- cellacdc/dialogs/__init__.py | 228 + cellacdc/dialogs/_base.py | 181 + cellacdc/dialogs/export.py | 1512 +++ cellacdc/dialogs/general.py | 3414 +++++ cellacdc/dialogs/measurements.py | 2979 +++++ cellacdc/dialogs/metadata.py | 3590 +++++ cellacdc/dialogs/models.py | 2265 ++++ cellacdc/dialogs/preprocess.py | 4000 ++++++ cellacdc/dialogs/tracking.py | 2871 ++++ cellacdc/myutils.py | 5946 --------- cellacdc/myutils/__init__.py | 520 + cellacdc/myutils/dataframe.py | 352 + cellacdc/myutils/install.py | 1724 +++ cellacdc/myutils/io.py | 130 + cellacdc/myutils/logging.py | 351 + cellacdc/myutils/misc.py | 1651 +++ cellacdc/myutils/models.py | 1150 ++ cellacdc/myutils/paths.py | 455 + cellacdc/myutils/qt.py | 80 + cellacdc/myutils/text.py | 141 + cellacdc/myutils/version.py | 555 + cellacdc/widgets.py | 10294 --------------- cellacdc/widgets/__init__.py | 294 + cellacdc/widgets/canvas.py | 4234 ++++++ cellacdc/widgets/controls.py | 5171 ++++++++ cellacdc/widgets/toolbars.py | 1229 ++ cellacdc/workers.py | 6688 ---------- cellacdc/workers/__init__.py | 149 + cellacdc/workers/_base.py | 239 + cellacdc/workers/alignment.py | 476 + cellacdc/workers/data_prep.py | 1276 ++ cellacdc/workers/gui.py | 110 + cellacdc/workers/io.py | 1117 ++ cellacdc/workers/metrics.py | 1520 +++ cellacdc/workers/segm.py | 894 ++ cellacdc/workers/tracking.py | 778 ++ cellacdc/workers/util.py | 709 + cellacdc/workflow/__init__.py | 2 + cellacdc/workflow/adapters.py | 43 + examples/run_headless_workflow.py | 180 + scripts/fix_split_imports.py | 87 + scripts/split_god_files.py | 486 + tests/test_components_imports.py | 2 +- tests/test_split_packages.py | 61 + 45 files changed, 47207 insertions(+), 42553 deletions(-) create mode 100644 cellacdc/dialogs/__init__.py create mode 100644 cellacdc/dialogs/_base.py create mode 100644 cellacdc/dialogs/export.py create mode 100644 cellacdc/dialogs/general.py create mode 100644 cellacdc/dialogs/measurements.py create mode 100644 cellacdc/dialogs/metadata.py create mode 100644 cellacdc/dialogs/models.py create mode 100644 cellacdc/dialogs/preprocess.py create mode 100644 cellacdc/dialogs/tracking.py delete mode 100644 cellacdc/myutils.py create mode 100644 cellacdc/myutils/__init__.py create mode 100644 cellacdc/myutils/dataframe.py create mode 100644 cellacdc/myutils/install.py create mode 100644 cellacdc/myutils/io.py create mode 100644 cellacdc/myutils/logging.py create mode 100644 cellacdc/myutils/misc.py create mode 100644 cellacdc/myutils/models.py create mode 100644 cellacdc/myutils/paths.py create mode 100644 cellacdc/myutils/qt.py create mode 100644 cellacdc/myutils/text.py create mode 100644 cellacdc/myutils/version.py delete mode 100755 cellacdc/widgets.py create mode 100644 cellacdc/widgets/__init__.py create mode 100644 cellacdc/widgets/canvas.py create mode 100644 cellacdc/widgets/controls.py create mode 100644 cellacdc/widgets/toolbars.py delete mode 100755 cellacdc/workers.py create mode 100644 cellacdc/workers/__init__.py create mode 100644 cellacdc/workers/_base.py create mode 100644 cellacdc/workers/alignment.py create mode 100644 cellacdc/workers/data_prep.py create mode 100644 cellacdc/workers/gui.py create mode 100644 cellacdc/workers/io.py create mode 100644 cellacdc/workers/metrics.py create mode 100644 cellacdc/workers/segm.py create mode 100644 cellacdc/workers/tracking.py create mode 100644 cellacdc/workers/util.py create mode 100644 examples/run_headless_workflow.py create mode 100644 scripts/fix_split_imports.py create mode 100644 scripts/split_god_files.py create mode 100644 tests/test_split_packages.py diff --git a/cellacdc/apps.py b/cellacdc/apps.py index 07757dbdc..f26cf6f89 100755 --- a/cellacdc/apps.py +++ b/cellacdc/apps.py @@ -1,19625 +1,3 @@ -import os -import sys -import re -from typing import Literal, Callable, Dict, Iterable, List, Tuple -import datetime -import pathlib -from collections import defaultdict -import zipfile -from heapq import nlargest -import matplotlib -import matplotlib.pyplot as plt -from matplotlib.lines import Line2D -from matplotlib.patches import Rectangle, Circle, PathPatch, Path -import numpy as np -import scipy.interpolate +"""Compatibility shim; implementation lives in dialogs/.""" -try: - import tkinter as tk -except Exception as err: - pass - -import cv2 -import traceback -from itertools import combinations, permutations -from collections import namedtuple -from natsort import natsorted - -# from MyWidgets import Slider, Button, MyRadioButtons -from skimage.measure import label, regionprops -from functools import partial -import skimage.filters -import skimage.measure -import skimage.morphology -import skimage.exposure -import skimage.draw -import skimage.registration -import skimage.color -import skimage.segmentation -from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk -import matplotlib.pyplot as plt -import seaborn as sns -import pandas as pd -import math -import time -import sympy as sp -import json -import html - -import pyqtgraph as pg - -pg.setConfigOption("imageAxisOrder", "row-major") - -from qtpy import QtCore -from qtpy.QtGui import ( - QIcon, - QFontMetrics, - QKeySequence, - QFont, - QRegularExpressionValidator, - QCursor, - QKeyEvent, - QPixmap, - QFont, - QPalette, - QMouseEvent, - QColor, -) -from qtpy.QtCore import ( - Qt, - QSize, - QEvent, - Signal, - QEventLoop, - QTimer, - QRegularExpression, -) -from qtpy.QtWidgets import ( - QFileDialog, - QApplication, - QMainWindow, - QMenu, - QLabel, - QToolBar, - QScrollBar, - QWidget, - QVBoxLayout, - QLineEdit, - QPushButton, - QHBoxLayout, - QDialog, - QFormLayout, - QListWidget, - QAbstractItemView, - QButtonGroup, - QCheckBox, - QSizePolicy, - QComboBox, - QSlider, - QGridLayout, - QSpinBox, - QToolButton, - QTableView, - QTextBrowser, - QDoubleSpinBox, - QScrollArea, - QFrame, - QProgressBar, - QGroupBox, - QRadioButton, - QDockWidget, - QMessageBox, - QStyle, - QPlainTextEdit, - QSpacerItem, - QTreeWidget, - QTreeWidgetItem, - QTextEdit, - QSplashScreen, - QAction, - QListWidgetItem, - QActionGroup, - QHeaderView, - QStyledItemDelegate, -) -import qtpy.compat - -from . import exception_handler -from . import load, prompts, core, measurements, html_utils -from . import is_mac, is_win, is_linux, settings_folderpath, config -from . import preproc_recipes_path, segm_recipes_path, combine_channels_recipes_path -from . import is_conda_env -from . import printl -from . import colors -from . import issues_url -from . import myutils -from . import qutils -from . import _palettes -from . import base_cca_dict -from . import widgets -from . import user_profile_path, promptable_models_path, models_path -from . import features -from . import _core -from . import _types -from . import plot -from . import urls -from .acdc_regex import float_regex, is_alphanumeric_filename, to_alphanumeric -from . import _base_widgets -from . import io -from . import cca_functions -from . import path - -POSITIVE_FLOAT_REGEX = float_regex(allow_negative=False) -TREEWIDGET_STYLESHEET = _palettes.TreeWidgetStyleSheet() -LISTWIDGET_STYLESHEET = _palettes.ListWidgetStyleSheet() -BACKGROUND_RGBA = _palettes.get_disabled_colors()["Button"] - -font = QFont() -font.setPixelSize(12) -italicFont = QFont() -italicFont.setPixelSize(12) -italicFont.setItalic(True) - - -class ArgWidget: - def __init__( - self, name, type, widget, defaultVal, valueSetter, valueGetter, changeSig=None - ): - self.name = name - self.type = type - self.widget = widget - self.defaultVal = defaultVal - self.valueSetter = valueSetter - self.valueGetter = valueGetter - if changeSig is not None: - self.changeSig = changeSig - - -def addCustomModelMessages(QParent=None): - modelFilePath = None - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = html_utils.paragraph(""" - Do you already have the acdcSegment.py file for your code - or do you need instructions on how to set-up your custom model?
    - """) - infoButton = widgets.infoPushButton(" I need instructions") - browseButton = widgets.browseFileButton(" I have the model, let me select it") - msg.information( - QParent, - "Add custom model", - txt, - buttonsTexts=("Cancel", infoButton, browseButton), - showDialog=False, - ) - browseButton.clicked.disconnect() - browseButton.clicked.connect(msg.buttonCallBack) - msg.exec_() - if msg.cancel: - return - if msg.clickedButton == infoButton: - txt = myutils.get_add_custom_model_instructions() - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.information( - QParent, - "Custom model instructions", - txt, - buttonsTexts=("Ok",), - path_to_browse=models_path, - browse_button_text="Open models folder...", - ) - else: - homePath = pathlib.Path.home() - modelFilePath = QFileDialog.getOpenFileName( - QParent, - "Select the acdcSegment.py file of your model", - str(homePath), - "acdcSegment.py file (*.py);;All files (*)", - )[0] - if not modelFilePath: - return - - return modelFilePath - - -def addCustomPromptModelMessages(QParent=None): - modelFilePath = None - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = html_utils.paragraph(""" - Do you already have the acdcPromptSegment.py file for your code - or do you need instructions on how to set-up your custom model?
    - """) - infoButton = widgets.infoPushButton(" I need instructions") - browseButton = widgets.browseFileButton(" I have the model, let me select it") - msg.information( - QParent, - "Add custom promptable model", - txt, - buttonsTexts=("Cancel", infoButton, browseButton), - showDialog=False, - ) - browseButton.clicked.disconnect() - browseButton.clicked.connect(msg.buttonCallBack) - msg.exec_() - if msg.cancel: - return - if msg.clickedButton == infoButton: - txt = myutils.get_add_custom_prompt_model_instructions() - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.information( - QParent, - "Custom promptable model instructions", - txt, - buttonsTexts=("Ok",), - path_to_browse=promptable_models_path, - browse_button_text="Open promptable models folder...", - ) - else: - homePath = pathlib.Path.home() - modelFilePath = QFileDialog.getOpenFileName( - QParent, - "Select the acdcPromptSegment.py file of your model", - str(homePath), - "acdcPromptSegment.py file (*.py);;All files (*)", - )[0] - if not modelFilePath: - return - - return modelFilePath - - -class QBaseDialog(_base_widgets.QBaseDialog): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -class customAnnotationDialog(QDialog): - sigDeleteSelecAnnot = Signal(object) - - def __init__(self, savedCustomAnnot, parent=None, state=None): - self.cancel = True - self.loop = None - self.clickedButton = None - self.savedCustomAnnot = savedCustomAnnot - - self.internalNames = measurements.get_all_acdc_df_colnames(include_custom=False) - - super().__init__(parent) - - self.setWindowTitle("Custom annotation") - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - - layout = widgets.FormLayout() - - row = 0 - typeCombobox = QComboBox() - typeCombobox.addItems( - ["Single time-point", "Multiple time-points", "Multiple values class"] - ) - if state is not None: - typeCombobox.setCurrentText(state["type"]) - self.typeCombobox = typeCombobox - body_txt = """ - Single time-point annotation: use this to annotate - an event that happens on a single frame in time - (e.g. cell division). -

    - Multiple time-points annotation: use this to annotate - an event that has a duration, i.e., a start frame and a stop - frame (e.g. cell cycle phase).

    - Multiple values class annotation: use this to annotate a class - that has multiple values. An example could be a cell cycle stage - that can have different values, such as 2-cells division - or 4-cells division. - """ - typeInfoTxt = f"{html_utils.paragraph(body_txt)}" - self.typeWidget = widgets.formWidget( - typeCombobox, - addInfoButton=True, - labelTextLeft="Type: ", - parent=self, - infoTxt=typeInfoTxt, - ) - layout.addFormWidget(self.typeWidget, row=row) - typeCombobox.currentTextChanged.connect(self.warnType) - - row += 1 - nameInfoTxt = """ - Name of the column that will be saved in the acdc_output.csv - file.

    - Valid charachters are letters and numbers separate by underscore - or dash only.

    - Additionally, some names are reserved because they are used - by Cell-ACDC for standard measurements.

    - Internally reserved names: - """ - self.nameInfoTxt = f"{html_utils.paragraph(nameInfoTxt)}" - self.nameWidget = widgets.formWidget( - widgets.alphaNumericLineEdit(), - addInfoButton=True, - labelTextLeft="Name: ", - parent=self, - infoTxt=self.nameInfoTxt, - ) - self.nameWidget.infoButton.disconnect() - self.nameWidget.infoButton.clicked.connect(self.showNameInfo) - if state is not None: - self.nameWidget.widget.setText(state["name"]) - self.nameWidget.widget.textChanged.connect(self.checkName) - layout.addFormWidget(self.nameWidget, row=row) - - row += 1 - self.nameInfoLabel = QLabel() - layout.addWidget(self.nameInfoLabel, row, 0, 1, 2, alignment=Qt.AlignCenter) - - row += 1 - spacing = QSpacerItem(10, 10) - layout.addItem(spacing, row, 0) - - row += 1 - symbolInfoTxt = """ - Symbol that will be drawn on the annotated cell at - the requested time frame. - """ - symbolInfoTxt = f"{html_utils.paragraph(symbolInfoTxt)}" - self.symbolWidget = widgets.formWidget( - widgets.pgScatterSymbolsCombobox(), - addInfoButton=True, - labelTextLeft="Symbol: ", - parent=self, - infoTxt=symbolInfoTxt, - ) - if state is not None: - self.symbolWidget.widget.setCurrentText(state["symbol"]) - layout.addFormWidget(self.symbolWidget, row=row) - - row += 1 - shortcutInfoTxt = """ - Shortcut that you can use to activate/deactivate annotation - of this event.

    Leave empty if you don't need a shortcut. - """ - shortcutInfoTxt = f"{html_utils.paragraph(shortcutInfoTxt)}" - self.shortcutWidget = widgets.formWidget( - widgets.ShortcutLineEdit(), - addInfoButton=True, - labelTextLeft="Shortcut: ", - parent=self, - infoTxt=shortcutInfoTxt, - ) - if state is not None: - self.shortcutWidget.widget.setText(state["shortcut"]) - layout.addFormWidget(self.shortcutWidget, row=row) - - row += 1 - descInfoTxt = """ - Description will be used as the tool tip that will be - displayed when you hover with th mouse cursor on the toolbar button - specific for this annotation - """ - descInfoTxt = f"{html_utils.paragraph(descInfoTxt)}" - self.descWidget = widgets.formWidget( - QPlainTextEdit(), - addInfoButton=True, - labelTextLeft="Description: ", - parent=self, - infoTxt=descInfoTxt, - ) - if state is not None: - self.descWidget.widget.setPlainText(state["description"]) - layout.addFormWidget(self.descWidget, row=row) - - row += 1 - optionsGroupBox = QGroupBox("Additional options") - optionsLayout = QGridLayout() - toggle = widgets.Toggle() - toggle.setChecked(True) - self.keepActiveToggle = toggle - toggleLabel = QLabel("Keep tool active after using it: ") - colorButtonLabel = QLabel("Symbol color: ") - self.hideAnnotTooggle = widgets.Toggle() - self.hideAnnotTooggle.setChecked(True) - hideAnnotTooggleLabel = QLabel("Hide annotation when button is not active: ") - self.colorButton = widgets.myColorButton(color=(255, 0, 0)) - self.colorButton.clicked.disconnect() - self.colorButton.clicked.connect(self.selectColor) - - optionsLayout.setColumnStretch(0, 1) - optRow = 0 - optionsLayout.addWidget(toggleLabel, optRow, 1) - optionsLayout.addWidget(toggle, optRow, 2) - optRow += 1 - optionsLayout.addWidget(hideAnnotTooggleLabel, optRow, 1) - optionsLayout.addWidget(self.hideAnnotTooggle, optRow, 2) - optionsLayout.setColumnStretch(3, 1) - optRow += 1 - optionsLayout.addWidget(colorButtonLabel, optRow, 1) - optionsLayout.addWidget(self.colorButton, optRow, 2) - - optionsGroupBox.setLayout(optionsLayout) - layout.addWidget(optionsGroupBox, row, 1, alignment=Qt.AlignCenter) - optionsInfoButton = QPushButton(self) - optionsInfoButton.setCursor(Qt.WhatsThisCursor) - optionsInfoButton.setIcon(QIcon(":info.svg")) - optionsInfoButton.clicked.connect(self.showOptionsInfo) - layout.addWidget(optionsInfoButton, row, 3, alignment=Qt.AlignRight) - - row += 1 - layout.addItem(QSpacerItem(5, 5), row, 0) - - row += 1 - noteText = ( - "NOTE: you can change these options later with
    " - "RIGHT-click on the associated left-side toolbar button.
    " - ) - noteLabel = QLabel(html_utils.paragraph(noteText, font_size="11px")) - layout.addWidget(noteLabel, row, 1, 1, 3) - - buttonsLayout = QHBoxLayout() - - self.loadSavedAnnotButton = widgets.OpenFilePushButton(" Load annotation... ") - if not savedCustomAnnot: - self.loadSavedAnnotButton.setDisabled(True) - self.okButton = widgets.okPushButton(" Ok ") - cancelButton = widgets.cancelPushButton("Cancel") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(self.loadSavedAnnotButton) - buttonsLayout.addWidget(self.okButton) - - cancelButton.clicked.connect(self.cancelCallBack) - self.cancelButton = cancelButton - self.loadSavedAnnotButton.clicked.connect(self.loadSavedAnnot) - self.okButton.clicked.connect(self.ok_cb) - self.okButton.setFocus() - - mainLayout = QVBoxLayout() - - noteTxt = """ - Custom annotations will be saved in the acdc_output.csv
    - file as a column with the name you write in the field Name
    - """ - noteTxt = f"{html_utils.paragraph(noteTxt, font_size='15px')}" - noteLabel = QLabel(noteTxt) - noteLabel.setAlignment(Qt.AlignCenter) - mainLayout.addWidget(noteLabel) - - mainLayout.addLayout(layout) - mainLayout.addStretch(1) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def checkName(self, text): - if not text: - txt = "Name cannot be empty" - self.nameInfoLabel.setText( - html_utils.paragraph(txt, font_size="11px", font_color="red") - ) - return - for name in self.internalNames: - if name.find(text) != -1: - txt = f'"{text}" cannot be part of the name, because reserved.' - self.nameInfoLabel.setText( - html_utils.paragraph(txt, font_size="11px", font_color="red") - ) - break - else: - self.nameInfoLabel.setText("") - - def loadSavedAnnot(self): - items = list(self.savedCustomAnnot.keys()) - self.selectAnnotWin = widgets.QDialogListbox( - "Load annotation parameters", - "Select annotation to load:", - items, - additionalButtons=("Delete selected annnotations",), - parent=self, - multiSelection=False, - ) - for button in self.selectAnnotWin._additionalButtons: - button.disconnect() - button.clicked.connect(self.deleteSelectedAnnot) - self.selectAnnotWin.exec_() - if self.selectAnnotWin.cancel: - return - if self.selectAnnotWin.listBox.count() == 0: - return - if not self.selectAnnotWin.selectedItemsText: - self.warnNoItemsSelected() - return - selectedName = self.selectAnnotWin.selectedItemsText[-1] - selectedAnnot = self.savedCustomAnnot[selectedName] - self.typeCombobox.setCurrentText(selectedAnnot["type"]) - self.nameWidget.widget.setText(selectedAnnot["name"]) - self.symbolWidget.widget.setCurrentText(selectedAnnot["symbol"]) - self.shortcutWidget.widget.setText(selectedAnnot["shortcut"]) - self.descWidget.widget.setPlainText(selectedAnnot["description"]) - self.colorButton.setColor(selectedAnnot["symbolColor"]) - keySequence = widgets.macShortcutToWindows(selectedAnnot["shortcut"]) - if keySequence: - self.shortcutWidget.widget.keySequence = widgets.KeySequenceFromText( - keySequence - ) - - def warnNoItemsSelected(self): - msg = widgets.myMessageBox(parent=self) - msg.setIcon(iconName="SP_MessageBoxWarning") - msg.setWindowTitle("Delete annotation?") - msg.addText("You didn't select any annotation!") - msg.addButton(" Ok ") - msg.exec_() - - def deleteSelectedAnnot(self): - msg = widgets.myMessageBox(parent=self) - msg.setIcon(iconName="SP_MessageBoxWarning") - msg.setWindowTitle("Delete annotation?") - msg.addText("Are you sure you want to delete the selected annotations?") - msg.addButton("Yes") - cancelButton = msg.addButton(" Cancel ") - msg.exec_() - if msg.clickedButton == cancelButton: - return - for item in self.selectAnnotWin.listBox.selectedItems(): - name = item.text() - self.savedCustomAnnot.pop(name) - self.sigDeleteSelecAnnot.emit(self.selectAnnotWin.listBox.selectedItems()) - items = list(self.savedCustomAnnot.keys()) - self.selectAnnotWin.listBox.clear() - self.selectAnnotWin.listBox.addItems(items) - - def selectColor(self): - color = self.colorButton.color() - self.colorButton.origColor = color - self.colorButton.colorDialog.setCurrentColor(color) - self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - self.colorButton.colorDialog.open() - w = self.width() - left = self.pos().x() - colorDialogTop = self.colorButton.colorDialog.pos().y() - self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) - - def warnType(self, currentText): - if currentText == "Single time-point": - return - - self.typeCombobox.setCurrentIndex(0) - - txt = """ - Unfortunately, the only annotation type that is available so far is - Single time-point.

    - We are working on implementing the other types too, so stay tuned!

    - Thank you for your patience! - """ - txt = f"{html_utils.paragraph(txt)}" - msg = widgets.myMessageBox() - msg.setIcon(iconName="SP_MessageBoxWarning") - msg.setWindowTitle(f"Feature not implemented yet") - msg.addText(txt) - msg.addButton(" Ok ") - msg.exec_() - - def showOptionsInfo(self): - info = """ - Keep tool active after using it: Choose whether the tool - should stay active or not after annotating.

    - Hide annotation when button is not active: Choose whether - annotation on the cell/object should be visible only if the - button is active or also when it is not active.
    - NOTE: annotations are always stored no matter whether - they are visible or not.

    - Symbol color: Choose color of the symbol that will be used - to label annotated cell/object. - """ - info = f"{html_utils.paragraph(info)}" - msg = widgets.myMessageBox() - msg.setIcon() - msg.setWindowTitle(f"Additional options info") - msg.addText(info) - msg.addButton(" Ok ") - msg.exec_() - - def ok_cb(self, checked=True): - self.cancel = False - self.clickedButton = self.okButton - self.close() - - def cancelCallBack(self, checked=True): - self.cancel = True - self.clickedButton = self.cancelButton - self.close() - - def showNameInfo(self): - msg = widgets.myMessageBox() - listView = widgets.readOnlyQList(msg) - listView.addItems(self.internalNames) - # listView.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) - msg.information( - self, "Annotation Name info", self.nameInfoTxt, widgets=listView - ) - - def closeEvent(self, event): - if self.clickedButton is None or self.clickedButton == self.cancelButton: - # cancel button or closed with 'x' button - self.cancel = True - return - - if self.clickedButton == self.okButton and not self.nameWidget.widget.text(): - msg = QMessageBox() - msg.critical(self, "Empty name", "The name cannot be empty!", msg.Ok) - event.ignore() - self.cancel = True - return - - if self.clickedButton == self.okButton and self.nameInfoLabel.text(): - msg = widgets.myMessageBox() - listView = widgets.listWidget(msg) - listView.addItems(self.internalNames) - listView.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) - name = self.nameWidget.widget.text() - txt = ( - f'"{name}" cannot be part of the name, ' - "because it is reserved for standard measurements " - "saved by Cell-ACDC.

    " - "Internally reserved names:" - ) - msg.critical( - self, "Not a valid name", html_utils.paragraph(txt), widgets=listView - ) - event.ignore() - self.cancel = True - return - - self.toolTip = ( - f"Name: {self.nameWidget.widget.text()}\n\n" - f"Type: {self.typeWidget.widget.currentText()}\n\n" - f"Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n" - f"Description: {self.descWidget.widget.toPlainText()}\n\n" - f'SHORTCUT: "{self.shortcutWidget.widget.text()}"' - ) - - symbol = self.symbolWidget.widget.currentText() - self.symbol = re.findall(r"\'(.+)\'", symbol)[0] - - self.state = { - "type": self.typeWidget.widget.currentText(), - "name": self.nameWidget.widget.text(), - "symbol": self.symbolWidget.widget.currentText(), - "shortcut": self.shortcutWidget.widget.text(), - "description": self.descWidget.widget.toPlainText(), - "keepActive": self.keepActiveToggle.isChecked(), - "isHideChecked": self.hideAnnotTooggle.isChecked(), - "symbolColor": self.colorButton.color(), - } - - if self.loop is not None: - self.loop.exit() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - super().show() - if block: - self.loop = QEventLoop() - self.loop.exec_() - - -class _PointsLayerAppearanceGroupbox(QGroupBox): - def __init__(self, *args): - super().__init__(*args) - - self.setTitle("Points appearance") - - layout = widgets.FormLayout() - - "----------------------------------------------------------------------" - row = 0 - symbolInfoTxt = """ - Symbol used to draw the points. - """ - symbolInfoTxt = f"{html_utils.paragraph(symbolInfoTxt)}" - self.symbolWidget = widgets.formWidget( - widgets.pgScatterSymbolsCombobox(), - addInfoButton=True, - labelTextLeft="Symbol: ", - parent=self, - infoTxt=symbolInfoTxt, - stretchWidget=False, - ) - layout.addFormWidget(self.symbolWidget, row=row) - "----------------------------------------------------------------------" - - "----------------------------------------------------------------------" - row += 1 - self.colorButton = widgets.myColorButton(color=(255, 0, 0)) - self.colorWidget = widgets.formWidget( - self.colorButton, stretchWidget=True, labelTextLeft="Colour: ", parent=self - ) - layout.addFormWidget(self.colorWidget, align=Qt.AlignLeft, row=row) - self.colorButton.clicked.disconnect() - self.colorButton.clicked.connect(self.selectColor) - "----------------------------------------------------------------------" - - "----------------------------------------------------------------------" - row += 1 - self.sizeSpinBox = widgets.SpinBox() - self.sizeSpinBox.setValue(5) - self.sizeWidget = widgets.formWidget( - self.sizeSpinBox, stretchWidget=True, labelTextLeft="Size: ", parent=self - ) - layout.addFormWidget(self.sizeWidget, row=row) - "----------------------------------------------------------------------" - - "----------------------------------------------------------------------" - row += 1 - zHeightTooltip = ( - 'If "Z-depth" is greater than 1, the points will be annotated ' - "in all the z-slices in the range `z - (Z-depth/2) < z < z + (Z-depth/2)`\n" - "where `z` is the center z-slice of the added point." - ) - self.zHeightSpinBox = widgets.OddSpinBox() - self.zHeightSpinBox.setValue(1) - self.zHeightSpinBox.setMinimum(1) - self.zHeightWidget = widgets.formWidget( - self.zHeightSpinBox, - stretchWidget=True, - labelTextLeft="Z-depth: ", - parent=self, - toolTip=zHeightTooltip, - ) - layout.addFormWidget(self.zHeightWidget, row=row) - "----------------------------------------------------------------------" - - "----------------------------------------------------------------------" - row += 1 - shortcutInfoTxt = """ - Shortcut that you can use to hide/show points. - """ - shortcutInfoTxt = f"{html_utils.paragraph(shortcutInfoTxt)}" - self.shortcutWidget = widgets.formWidget( - widgets.ShortcutLineEdit(), - addInfoButton=True, - labelTextLeft="Shortcut: ", - parent=self, - infoTxt=shortcutInfoTxt, - ) - layout.addFormWidget(self.shortcutWidget, row=row) - "----------------------------------------------------------------------" - - self.setLayout(layout) - - def restoreState(self, state): - self.shortcutWidget.widget.setText(state["shortcut"]) - self.colorButton.setColor(state["color"]) - self.symbolWidget.widget.setCurrentText(state["symbol"]) - self.sizeSpinBox.setValue(state["pointSize"]) - self.zHeightSpinBox.setValue(state["zHeight"]) - - def selectColor(self): - color = self.colorButton.color() - self.colorButton.origColor = color - self.colorButton.colorDialog.setCurrentColor(color) - self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - self.colorButton.colorDialog.open() - w = self.width() - left = self.pos().x() - colorDialogTop = self.colorButton.colorDialog.pos().y() - self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) - - def state(self): - r, g, b, a = self.colorButton.color().getRgb() - _state = { - "symbol": self.symbolWidget.widget.currentText(), - "color": (r, g, b), - "pointSize": self.sizeSpinBox.value(), - "zHeight": self.zHeightSpinBox.value(), - "shortcut": self.shortcutWidget.widget.text(), - } - return _state - - -class AddPointsLayerDialog(QBaseDialog): - sigClosed = Signal() - sigCriticalReadTable = Signal(str) - sigLoadedTable = Signal(object, str) - sigCheckClickEntryTableEndnameExists = Signal(str, bool) - - def __init__( - self, - channelNames=None, - imagesPath="", - SizeT=1, - hideCentroidsSection=False, - hideWeightedCentroidsSection=False, - hideFromTableSection=False, - hideManualEntrySection=False, - hideWithMouseClicksSection=False, - parent=None, - ): - self.cancel = True - super().__init__(parent) - - self._parent = parent - - self.imagesPath = imagesPath - - self.setWindowTitle("Add points layer") - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - - mainLayout = QVBoxLayout() - - scrollArea = widgets.ScrollArea() - typeGroupbox = QGroupBox("Points to draw") - typeLayout = QGridLayout() - typeGroupbox.setLayout(typeLayout) - typeLayout.addItem(QSpacerItem(10, 1), 0, 0) - typeLayout.setColumnStretch(0, 0) - typeLayout.setColumnStretch(2, 1) - vSpacing = 15 - - row = 0 - - sections = ( - ("addCentroidsSection", hideCentroidsSection), - ("addWeightedCentroidsSection", hideWeightedCentroidsSection), - ("addFromTableSection", hideFromTableSection), - ("addManualEntrySection", hideManualEntrySection), - ("addWithMouseClicksSection", hideWithMouseClicksSection), - ) - radioButtonChecked = False - for section, hideSection in sections: - addFunc = getattr(self, section) - row, sectionWidgets = addFunc( - row, - typeLayout, - imagesPath=imagesPath, - SizeT=SizeT, - channelNames=channelNames, - ) - if not hideSection: - spacer = QSpacerItem(1, vSpacing) - typeLayout.addItem(spacer, row, 0) - row += 1 - if not radioButtonChecked: - sectionWidgets[0].setChecked(True) - radioButtonChecked = True - continue - - for widget in sectionWidgets: - widget.setVisible(False) - - self.scrollArea = scrollArea - scrollArea.setWidget(typeGroupbox) - - self.appearanceGroupbox = _PointsLayerAppearanceGroupbox() - self.appearanceGroupbox.sizeSpinBox.setValue(3) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - self.buttonsLayout = buttonsLayout - - mainLayout.addWidget(scrollArea) - mainLayout.addSpacing(20) - _layout = QHBoxLayout() - _layout.addWidget(self.appearanceGroupbox) - _layout.addStretch(1) - mainLayout.addLayout(_layout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - self.setFont(font) - - def addCentroidsSection(self, row, layout, **kwargs): - sectionWidgets = [] - self.centroidsRadiobutton = QRadioButton("Centroids") - layout.addWidget(self.centroidsRadiobutton, row, 0, 1, 2) - sectionWidgets.append(self.centroidsRadiobutton) - - self.centroidsRadiobutton.setChecked(True) - return row + 1, sectionWidgets - - def addWeightedCentroidsSection(self, row, layout, channelNames=None, **kwargs): - if channelNames is None: - channelNames = [] - - sectionWidgets = [] - - self.weightedCentroidsRadiobutton = QRadioButton("Weighted centroids") - layout.addWidget(self.weightedCentroidsRadiobutton, row, 0, 1, 2) - sectionWidgets.append(self.weightedCentroidsRadiobutton) - - row += 1 - label = QLabel("Weighing channel: ") - label.setEnabled(False) - layout.addWidget(label, row, 1) - sectionWidgets.append(label) - - self.channelNameForWeightedCentr = widgets.QCenteredComboBox() - if channelNames: - self.channelNameForWeightedCentr.addItems(channelNames) - self.channelNameForWeightedCentr.setDisabled(True) - layout.addWidget(self.channelNameForWeightedCentr, row, 2) - sectionWidgets.append(self.channelNameForWeightedCentr) - - self.weightedCentroidsRadiobutton.toggled.connect(label.setEnabled) - self.weightedCentroidsRadiobutton.toggled.connect( - self.channelNameForWeightedCentr.setEnabled - ) - - return row + 1, sectionWidgets - - def addFromTableSection(self, row, layout, imagesPath="", SizeT=1, **kwargs): - sectionWidgets = [] - - self.fromTableRadiobutton = QRadioButton("From table") - layout.addWidget(self.fromTableRadiobutton, row, 0, 1, 2) - sectionWidgets.append(self.fromTableRadiobutton) - self.fromTableRadiobutton.widgets = [] - - row += 1 - self.tablePath = widgets.ElidingLineEdit() - self.tablePath.label = QLabel("Table file path: ") - layout.addWidget(self.tablePath.label, row, 1) - layout.addWidget(self.tablePath, row, 2) - self.fromTableRadiobutton.widgets.append(self.tablePath) - sectionWidgets.append(self.tablePath.label) - sectionWidgets.append(self.tablePath) - - browseButton = widgets.browseFileButton( - start_dir=imagesPath, ext={"Table": [".csv", ".h5"]} - ) - layout.addWidget(browseButton, row, 3) - browseButton.sigPathSelected.connect(self.tablePathSelected) - self.browseTableButton = browseButton - self.fromTableRadiobutton.widgets.append(browseButton) - sectionWidgets.append(browseButton) - - row += 1 - self.xColName = widgets.QCenteredComboBox() - self.xColName.addItem("None") - self.xColName.label = QLabel("X coord. column: ") - layout.addWidget(self.xColName.label, row, 1) - layout.addWidget(self.xColName, row, 2) - self.xColName.currentTextChanged.connect(self.checkColNameX) - self.fromTableRadiobutton.widgets.append(self.xColName) - sectionWidgets.append(self.xColName.label) - sectionWidgets.append(self.xColName) - - row += 1 - self.yColName = widgets.QCenteredComboBox() - self.yColName.addItem("None") - self.yColName.label = QLabel("Y coord. column: ") - layout.addWidget(self.yColName.label, row, 1) - layout.addWidget(self.yColName, row, 2) - self.yColName.currentTextChanged.connect(self.checkColNameY) - self.fromTableRadiobutton.widgets.append(self.yColName) - sectionWidgets.append(self.yColName.label) - sectionWidgets.append(self.yColName) - - row += 1 - self.zColName = widgets.QCenteredComboBox() - self.zColName.addItem("None") - self.zColName.label = QLabel("Z coord. column: ") - layout.addWidget(self.zColName.label, row, 1) - layout.addWidget(self.zColName, row, 2) - self.zColName.currentTextChanged.connect(self.checkColNameZ) - self.fromTableRadiobutton.widgets.append(self.zColName) - sectionWidgets.append(self.zColName.label) - sectionWidgets.append(self.zColName) - - row += 1 - self.tColName = widgets.QCenteredComboBox() - self.tColName.addItem("None") - self.tColName.label = QLabel("Frame index column: ") - layout.addWidget(self.tColName.label, row, 1) - layout.addWidget(self.tColName, row, 2) - self.fromTableRadiobutton.widgets.append(self.tColName) - sectionWidgets.append(self.tColName.label) - sectionWidgets.append(self.tColName) - - if SizeT == 1: - self.tColName.clear() - self.tColName.addItem("None") - self.tColName.label.setVisible(False) - self.tColName.setVisible(False) - - self.fromTableRadiobutton.toggled.connect(self.enableRadioButtonWidgets) - self.enableRadioButtonWidgets(False, sender=self.fromTableRadiobutton) - - return row + 1, sectionWidgets - - def addManualEntrySection(self, row, layout, SizeT=1, **kwargs): - sectionWidgets = [] - - self.manualEntryRadiobutton = QRadioButton("Manual entry") - layout.addWidget(self.manualEntryRadiobutton, row, 0, 1, 2) - self.manualEntryRadiobutton.widgets = [] - sectionWidgets.append(self.manualEntryRadiobutton) - - row += 1 - self.manualXspinbox = widgets.NumericCommaLineEdit() - self.manualXspinbox.label = QLabel("X coords: ") - layout.addWidget(self.manualXspinbox.label, row, 1) - layout.addWidget(self.manualXspinbox, row, 2) - self.manualEntryRadiobutton.widgets.append(self.manualXspinbox) - sectionWidgets.append(self.manualXspinbox.label) - sectionWidgets.append(self.manualXspinbox) - - row += 1 - self.manualYspinbox = widgets.NumericCommaLineEdit() - self.manualYspinbox.label = QLabel("Y coords: ") - layout.addWidget(self.manualYspinbox.label, row, 1) - layout.addWidget(self.manualYspinbox, row, 2) - self.manualEntryRadiobutton.widgets.append(self.manualYspinbox) - sectionWidgets.append(self.manualYspinbox.label) - sectionWidgets.append(self.manualYspinbox) - - row += 1 - self.manualZspinbox = widgets.NumericCommaLineEdit() - self.manualZspinbox.label = QLabel("Z coords: ") - layout.addWidget(self.manualZspinbox.label, row, 1) - layout.addWidget(self.manualZspinbox, row, 2) - self.manualEntryRadiobutton.widgets.append(self.manualZspinbox) - sectionWidgets.append(self.manualZspinbox.label) - sectionWidgets.append(self.manualZspinbox) - - row += 1 - self.manualTspinbox = widgets.NumericCommaLineEdit() - self.manualTspinbox.label = QLabel("Frame numbers: ") - layout.addWidget(self.manualTspinbox.label, row, 1) - layout.addWidget(self.manualTspinbox, row, 2) - self.manualEntryRadiobutton.widgets.append(self.manualTspinbox) - sectionWidgets.append(self.manualTspinbox.label) - sectionWidgets.append(self.manualTspinbox) - - if SizeT == 1: - self.manualTspinbox.setVisible(False) - self.manualTspinbox.label.setVisible(False) - - self.manualEntryRadiobutton.toggled.connect(self.enableRadioButtonWidgets) - self.enableRadioButtonWidgets(False, sender=self.manualEntryRadiobutton) - - return row + 1, sectionWidgets - - def addWithMouseClicksSection(self, row, layout, imagesPath="", **kwargs): - sectionWidgets = [] - - self.clickEntryIsLoadedDf = None - - self.clickEntryRadiobutton = QRadioButton("Add points with mouse clicks") - layout.addWidget(self.clickEntryRadiobutton, row, 0, 1, 2) - self.clickEntryRadiobutton.widgets = [] - sectionWidgets.append(self.clickEntryRadiobutton) - - row += 1 - self.snapToMaxToggle = widgets.Toggle() - self.snapToMaxToggle.label = QLabel("Snap to closest maximum: ") - layout.addWidget(self.snapToMaxToggle.label, row, 1) - layout.addWidget(self.snapToMaxToggle, row, 2, alignment=Qt.AlignCenter) - sectionWidgets.append(self.snapToMaxToggle.label) - sectionWidgets.append(self.snapToMaxToggle) - - self.snapToMaxInfoButton = widgets.infoPushButton() - layout.addWidget(self.snapToMaxInfoButton, row, 3) - sectionWidgets.append(self.snapToMaxInfoButton) - - self.snapToMaxInfoButton.clicked.connect(self.showSnapToMaxButton) - self.clickEntryRadiobutton.widgets.append(self.snapToMaxToggle) - self.clickEntryRadiobutton.widgets.append(self.snapToMaxInfoButton) - - row += 1 - self.autoPilotToggle = widgets.Toggle() - self.autoPilotToggle.label = QLabel("Use auto-pilot: ") - layout.addWidget(self.autoPilotToggle.label, row, 1) - layout.addWidget(self.autoPilotToggle, row, 2, alignment=Qt.AlignCenter) - sectionWidgets.append(self.autoPilotToggle.label) - sectionWidgets.append(self.autoPilotToggle) - self.autoPilotInfoButton = widgets.infoPushButton() - layout.addWidget(self.autoPilotInfoButton, row, 3) - sectionWidgets.append(self.autoPilotInfoButton) - - self.autoPilotInfoButton.clicked.connect(self.showAutoPilotInfo) - self.clickEntryRadiobutton.widgets.append(self.autoPilotToggle) - self.clickEntryRadiobutton.widgets.append(self.autoPilotInfoButton) - - row += 1 - self.clickEntryTableEndname = widgets.alphaNumericLineEdit() - self.clickEntryTableEndname.setText("points_added_by_clicking") - self.clickEntryTableEndname.setAlignment(Qt.AlignCenter) - self.clickEntryTableEndname.label = QLabel("Table endname: ") - loadButton = widgets.browseFileButton(start_dir=imagesPath, ext={"CSV": ".csv"}) - layout.addWidget(loadButton, row, 3) - sectionWidgets.append(loadButton) - - loadButton.sigPathSelected.connect(self.loadClickEntryTable) - self.loadButton = loadButton - self.clickEntryLoadTableButton = loadButton - layout.addWidget(self.clickEntryTableEndname.label, row, 1) - layout.addWidget(self.clickEntryTableEndname, row, 2) - self.clickEntryRadiobutton.widgets.append(self.clickEntryTableEndname) - self.clickEntryTableEndname.editingFinished.connect( - self.emitCheckClickEntryTableEndnameExists - ) - sectionWidgets.append(self.clickEntryTableEndname) - sectionWidgets.append(self.clickEntryTableEndname.label) - - row += 1 - instructionsText = html_utils.paragraph( - "
    Left-click to annotate a new point with a new id.

    " - "Right-click to annotate a point with the same id

    " - "Same click used to delete objects to annotate
    " - "a point with id = 0 (negative prompt)

    " - "Click on point to delete it", - font_size="11px", - ) - self.instructionsLabel = QLabel(instructionsText) - self.instructionsLabel.label = QLabel("Instructions") - layout.addWidget(self.instructionsLabel.label, row, 1) - layout.addWidget(self.instructionsLabel, row, 2) - self.clickEntryRadiobutton.widgets.append(self.instructionsLabel) - sectionWidgets.append(self.instructionsLabel) - sectionWidgets.append(self.instructionsLabel.label) - - self.clickEntryRadiobutton.toggled.connect(self.enableRadioButtonWidgets) - self.clickEntryRadiobutton.toggled.connect( - self.emitCheckClickEntryTableEndnameExists - ) - self.enableRadioButtonWidgets(False, sender=self.clickEntryRadiobutton) - - return row + 1, sectionWidgets - - def emitCheckClickEntryTableEndnameExists(self, *args, **kwargs): - if not self.clickEntryRadiobutton.isChecked(): - return - self.clickEntryIsLoadedDf = None - tableEndName = self.clickEntryTableEndname.text() - self.sigCheckClickEntryTableEndnameExists.emit(tableEndName, False) - - def loadClickEntryTable(self, csv_path): - self.clickEntryIsLoadedDf = None - posData = load.loadData(csv_path, "points") - posData.getBasenameAndChNames(qparent=self) - basename = posData.basename - filename = os.path.basename(csv_path) - filename, ext = os.path.splitext(filename) - if not basename.endswith("_"): - basename = f"{basename}_" - - endname = filename[len(basename) :] - self.clickEntryTableEndname.setText(endname) - self.sigCheckClickEntryTableEndnameExists.emit(endname, True) - - def showAutoPilotInfo(self): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(""" - With Auto-pilot mode active, Cell-ACDC will automatically zoom on - to an object
    - to allow you clicking on the points you want to add.

    - You can then go to the next object by pressing the - Enter key or go back to the
    - previous object by pressing Backspace. - """) - msg.information(self, "Auto-pilot info", txt) - - def showSnapToMaxButton(self): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(""" - With mode active, Cell-ACDC will - automatically add the point
    - to the closest maximum within the point footprint (defined in - the appearance settings). - """) - msg.information(self, "Snap to closest maximum info", txt) - - def closeEvent(self, event): - self.sigClosed.emit() - - def enableRadioButtonWidgets(self, enabled, sender=None): - if sender is None: - sender = self.sender() - for widget in sender.widgets: - widget.setDisabled(not enabled) - try: - widget.label.setDisabled(not enabled) - except: - pass - - def _readTable(self, path): - return load.load_df_points_layer(path) - - def tryAutoFillColNames(self, df): - if "x" in df.columns: - self.xColName.setCurrentText("x") - - if "y" in df.columns: - self.yColName.setCurrentText("y") - - if "z" in df.columns: - self.zColName.setCurrentText("z") - - if "frame_i" in df.columns: - self.tColName.setCurrentText("frame_i") - - def tablePathSelected(self, path): - self.tablePath.setText(path) - try: - df = self._readTable(path) - self.xColName.addItems(df.columns) - self.yColName.addItems(df.columns) - self.zColName.addItems(df.columns) - self.tColName.addItems(df.columns) - self.tryAutoFillColNames(df) - self.sigLoadedTable.emit(df, os.path.basename(path)) - self.browseTableButton.confirmAction() - except Exception as e: - traceback_format = traceback.format_exc() - self.sigCriticalReadTable.emit(traceback_format) - self.criticalReadTable(path, traceback_format) - self.tablePath.setText("") - - def criticalLenMismatchManualEntry(self): - txt = html_utils.paragraph(f""" - X coords and Y coords must have the same length. - """) - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.critical(self, f"X and Y have different length", txt) - - def criticalColNameIsNone(self, axis): - txt = html_utils.paragraph(f""" - The "{axis.upper()} coord. column" cannot be "None" - """) - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.critical(self, f"{axis.upper()} coord. is None", txt) - - def criticalReadTable(self, path, traceback_format): - txt = html_utils.paragraph(f""" - Something went wrong when reading the table from the - following path:

    - {path}

    - See the error message below. - """) - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - detailsText = traceback_format - msg.critical(self, "Error when reading table", txt, detailsText=detailsText) - - def criticalEmptyTablePath(self): - txt = html_utils.paragraph(f""" - The table file path cannot be empty. - """) - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.critical(self, "Table file path is empty", txt) - - def state(self): - _state = self.appearanceGroupbox.state() - return _state - - def _checkSelectedColName(self, colName, label): - labelsToCheck = ["z", "y", "x"] - labelsToCheck.remove(label) - for labelToCheck in labelsToCheck: - if colName.find(labelToCheck) != -1: - break - else: - return True - - txt = html_utils.paragraph(f""" - Are you sure that the {label.upper()} coord. column should contain - the letter {labelToCheck}? - """) - - msg = widgets.myMessageBox(wrapText=False) - _, noButton, yesButton = msg.warning( - self, - "Check column name", - txt, - buttonsTexts=("Cancel", "No, let me correct it", "Yes, I am"), - ) - if msg.cancel or msg.clickedButton == noButton: - return False - return True - - def checkColNameX(self, text): - accepted = self._checkSelectedColName(text, "x") - if accepted: - return - self.xColName.setCurrentText("None") - - def checkColNameY(self, text): - accepted = self._checkSelectedColName(text, "y") - if accepted: - return - self.yColName.setCurrentText("None") - - def checkColNameZ(self, text): - accepted = self._checkSelectedColName(text, "z") - if accepted: - return - self.zColName.setCurrentText("None") - - def ok_cb(self): - self.pointsData = {} - self.loadedDfInfo = None - self.loadedDf = None - self.weighingChannel = "" - if self.fromTableRadiobutton.isChecked(): - tablePath = self.tablePath.text() - if not tablePath: - self.criticalEmptyTablePath() - return - - try: - df = self._readTable(tablePath) - tColName = self.tColName.currentText() - xColName = self.xColName.currentText() - yColName = self.yColName.currentText() - zColName = self.zColName.currentText() - - self.loadedDfInfo = { - "filepath": tablePath, - "t": tColName, - "z": zColName, - "y": yColName, - "x": xColName, - } - - self._df_to_pointsData(df, tColName, zColName, yColName, xColName) - - except Exception as e: - traceback_format = traceback.format_exc() - self.sigCriticalReadTable.emit(traceback_format) - self.criticalReadTable(tablePath, traceback_format) - return - - if self.xColName.currentText() == "None": - self.criticalColNameIsNone("x") - return - if self.yColName.currentText() == "None": - self.criticalColNameIsNone("y") - return - - self.layerType = os.path.basename(self.tablePath.text()) - self.layerTypeIdx = 2 - elif self.centroidsRadiobutton.isChecked(): - self.layerType = "Centroids" - self.layerTypeIdx = 0 - elif self.weightedCentroidsRadiobutton.isChecked(): - channel = self.channelNameForWeightedCentr.currentText() - self.weighingChannel = channel - self.layerType = f"Centroids weighted by channel {channel}" - self.layerTypeIdx = 1 - elif self.manualEntryRadiobutton.isChecked(): - xx = self.manualXspinbox.values() - yy = self.manualYspinbox.values() - if len(xx) != len(yy): - self.criticalLenMismatchManualEntry() - return - zz = self.manualZspinbox.values() - tt = [t + 1 for t in self.manualTspinbox.values()] - df = pd.DataFrame({"x": xx, "y": yy, "id": np.arange(1, len(xx) + 1)}) - if tt: - df["t"] = tt - tCol = "t" - else: - tCol = "None" - if zz: - df["z"] = zz - zCol = "z" - else: - zCol = "None" - - self._df_to_pointsData(df, tCol, zCol, "y", "x") - - self.layerType = "Manual entry" - self.layerTypeIdx = 3 - elif self.clickEntryRadiobutton.isChecked(): - self.layerType = "Click to annotate point" - self.description = ( - "Left-click to add a point, click on point to delete it.\n" - "With auto-pilot you can navigate through object with Up/Down arrows." - ) - self.clickEntryTableEndnameText = self.clickEntryTableEndname.text() - self.layerTypeIdx = 4 - - self.cancel = False - symbol = self.appearanceGroupbox.symbolWidget.widget.currentText() - self.symbol = re.findall(r"\'(.+)\'", symbol)[0] - self.symbolText = symbol - self.color = self.appearanceGroupbox.colorButton.color() - self.pointSize = self.appearanceGroupbox.sizeSpinBox.value() - self.zHeight = self.appearanceGroupbox.zHeightSpinBox.value() - shortcutWidget = self.appearanceGroupbox.shortcutWidget - self.shortcut = shortcutWidget.widget.text() - self.keySequence = shortcutWidget.widget.keySequence - self.close() - - def _df_to_pointsData(self, df, tColName, zColName, yColName, xColName): - self.pointsData = load.loaded_df_to_points_data( - df, tColName, zColName, yColName, xColName - ) - - def showEvent(self, event) -> None: - if self._parent is None: - screen = self.screen() - else: - screen = self._parent.screen() - screenWidth = screen.size().width() - screenHeight = screen.size().height() - - maxHeight = screenHeight - 100 - - buttonHeight = self.buttonsLayout.okButton.minimumSizeHint().height() - height = ( - self.scrollArea.minimumHeightNoScrollbar() - + self.appearanceGroupbox.sizeHint().height() - + buttonHeight - + 70 - ) - width = self.scrollArea.minimumWidthNoScrollbar() + 50 - - height = min(height, maxHeight) - - self.resize(width, height) - - screenLeft = screen.geometry().x() - screenTop = screen.geometry().y() - w, h = self.width(), self.height() - left = int(screenLeft + screenWidth / 2 - w / 2) - top = int(screenTop + screenHeight / 2 - h / 2 - 20) - - self.move(left, top) - - -class EditPointsLayerAppearanceDialog(QBaseDialog): - sigClosed = Signal() - - def __init__(self, parent=None): - self.cancel = True - super().__init__(parent) - - self._parent = parent - - self.setWindowTitle("Custom annotation") - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - - mainLayout = QVBoxLayout() - - self.appearanceGroupbox = _PointsLayerAppearanceGroupbox() - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addWidget(self.appearanceGroupbox) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - self.setFont(font) - - def restoreState(self, state): - self.appearanceGroupbox.restoreState(state) - - def closeEvent(self, event): - super().closeEvent(event) - self.sigClosed.emit() - - def state(self): - _state = self.appearanceGroupbox.state() - return _state - - def ok_cb(self): - self.cancel = False - symbol = self.appearanceGroupbox.symbolWidget.widget.currentText() - self.symbol = re.findall(r"\'(.+)\'", symbol)[0] - self.color = self.appearanceGroupbox.colorButton.color() - self.pointSize = self.appearanceGroupbox.sizeSpinBox.value() - self.zHeight = self.appearanceGroupbox.zHeightSpinBox.value() - shortcutWidget = self.appearanceGroupbox.shortcutWidget - self.shortcut = shortcutWidget.widget.text() - self.keySequence = shortcutWidget.widget.keySequence - self.close() - - -class filenameDialog(QDialog): - def __init__( - self, - ext=".npz", - basename="", - title="Insert file name", - hintText="", - existingNames="", - parent=None, - allowEmpty=True, - helpText="", - defaultEntry="", - resizeOnShow=True, - additionalButtons=None, - addDoNotSaveButton=False, - ): - self.cancel = True - super().__init__(parent) - - self.resizeOnShow = resizeOnShow - - if hintText.find("segmentation") != -1: - if helpText: - helpText = f"{helpText}" - helpText_loc = """ - With Cell-ACDC you can create as many segmentation files - as you want.

    - If you plan to create only one file then you can leave the - text entry empty.
    - Cell-ACDC will save the segmentation file with the filename - ending with _segm.npz.

    - However, we recommend to insert some text that will easily - allow you to identify what is the segmentation file about.

    - For example, if you are about to segment the channel - phase_contr, you could write - phase_contr.
    - Cell-ACDC will then save the file with the - filename ending with _segm_phase_contr.npz.

    - This way you can create multiple segmentation files, - for example one for each channel or one for each segmentation model.

    - Note that the numerical features and annotations will be saved - in a CSV file ending with the same text as the segmentation file,
    - e.g., ending with _acdc_output_phase_contr.csv. - """ - helpText = f"{helpText}{html_utils.paragraph(helpText_loc)}" - - self.isSegmFile = basename.endswith("_segm") - self.allowEmpty = allowEmpty - self.basename = basename - if ext and not ext.startswith("."): - ext = f".{ext}" - self.ext = ext - - self.setWindowTitle(title) - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - - layout = QVBoxLayout() - entryLayout = QGridLayout() - buttonsLayout = QHBoxLayout() - - hintLabel = QLabel(hintText) - - basenameLabel = QLabel(basename) - - self.lineEdit = widgets.alphaNumericLineEdit(onlyWarn=True) - self.lineEdit.setAlignment(Qt.AlignCenter) - defaultEntry = to_alphanumeric(defaultEntry) - defaultEntry = defaultEntry.replace(".", "_") - self.lineEdit.setText(defaultEntry) - - extLabel = QLabel(ext) - - self.filenameLabel = QLabel() - self.filenameLabel.setText(f"{basename}{ext}") - - entryLayout.addWidget(basenameLabel, 0, 1) - entryLayout.addWidget(self.lineEdit, 0, 2) - entryLayout.addWidget(extLabel, 0, 3) - entryLayout.addWidget(self.filenameLabel, 1, 1, 1, 3, alignment=Qt.AlignCenter) - # entryLayout.setColumnStretch(0, 1) - entryLayout.setColumnStretch(2, 1) - - self.warningInvalidCharLabel = QLabel() - - okButton = widgets.okPushButton("Ok") - cancelButton = widgets.cancelPushButton("Cancel") - self.okButton = okButton - - buttonsLayout.addStretch() - buttonsLayout.addWidget(cancelButton) - - if addDoNotSaveButton: - doNotSaveButton = widgets.noPushButton("Do not save") - doNotSaveButton.clicked.connect(self.doNotSave_cb) - buttonsLayout.addWidget(doNotSaveButton) - self.doNotSave = False - - buttonsLayout.addSpacing(20) - if helpText: - helpButton = widgets.helpPushButton("Help...") - helpButton.clicked.connect(partial(self.showHelp, helpText)) - buttonsLayout.addWidget(helpButton) - if additionalButtons is not None: - for button in additionalButtons: - buttonsLayout.addWidget(button) - buttonsLayout.addWidget(okButton) - - cancelButton.clicked.connect(self.close) - okButton.clicked.connect(self.ok_cb) - self.lineEdit.textChanged.connect(self.updateFilename) - self.lineEdit.sigInvalidCharactersEntered.connect( - self.warnInvalidCharactersEntered - ) - - self.existingNames = [] - if existingNames: - self.existingNames = existingNames - # self.lineEdit.editingFinished.connect(self.checkExistingNames) - - layout.addWidget(hintLabel) - layout.addSpacing(20) - layout.addLayout(entryLayout) - layout.addSpacing(10) - layout.addWidget(self.warningInvalidCharLabel) - layout.addStretch(1) - layout.addSpacing(20) - layout.addLayout(buttonsLayout) - - self.setLayout(layout) - self.setFont(font) - - if defaultEntry: - self.updateFilename(defaultEntry) - - def doNotSave_cb(self): - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - "Are you sure you do not want to save the file?" - ) - noButton, yesButton = msg.warning( - self, "Do not save?", txt, buttonsTexts=("No", "Yes") - ) - if msg.clickedButton == noButton: - return - - self.doNotSave = True - self.cancel = False - self.close() - - def showHelp(self, text): - text = html_utils.paragraph(text) - msg = widgets.myMessageBox(wrapText=False) - msg.information(self, "Filename help", text) - - def _text(self): - return self.lineEdit.text() - - def warnInvalidCharactersEntered(self, characters: set[str]): - statement = "is not a valid character" - if len(characters) > 1: - statement = "are not valid characters" - - characters_str = "".join(characters) - characters_str = html.escape(characters_str) - warning_text = html_utils.span(f""" - WARNING: "{characters_str}" {statement}.
    - """) - warning_text = ( - f"{warning_text}" - "Valid characters are letters, numbers, underscore, and dash." - ) - self.warningInvalidCharLabel.setText(warning_text) - - def checkExistingNames(self): - is_existing = ( - self._text() in self.existingNames - or self.filenameLabel.text() in self.existingNames - ) - if not is_existing: - return True - - filename = self.filenameLabel.text() - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - "The following file

    " - f"{filename}

    " - "is already existing.

    " - "Do you want to overwrite the existing file?" - ) - noButton, yesButton = msg.warning( - self, "File name existing", txt, buttonsTexts=("No", "Yes") - ) - return msg.clickedButton == yesButton - - def updateFilename(self, text): - if self.lineEdit.invalidCharacters(): - return - - if not text: - self.filenameLabel.setText(f"{self.basename}{self.ext}") - else: - text = text.replace(" ", "_") - if self.basename: - if self.basename.endswith("_"): - self.filenameLabel.setText(f"{self.basename}{text}{self.ext}") - else: - self.filenameLabel.setText(f"{self.basename}_{text}{self.ext}") - else: - self.filenameLabel.setText(f"{text}{self.ext}") - - self.warningInvalidCharLabel.setText("") - - def checkEmptyText(self): - if self.allowEmpty: - return True - - if self._text(): - return True - - msg = widgets.myMessageBox() - msg.critical( - self, - "Empty text", - html_utils.paragraph("Text entry field cannot be empty"), - ) - return False - - def checkSegmFilename(self): - if not self.isSegmFile: - return True - - if "segm" not in self._text(): - return True - - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - "The text appended to the filename cannot contain the text " - '"segm".

    ' - "Sorry, that would confuse me. Thank you for your patience!" - ) - msg.critical(self, 'Cannot use "segm" in filename', txt) - return False - - def ok_cb(self, checked=True): - if self.warningInvalidCharLabel.text(): - return - - valid = self.checkExistingNames() - if not valid: - return - - valid = self.checkEmptyText() - if not valid: - return - - valid = self.checkSegmFilename() - if not valid: - return - - self.filename = self.filenameLabel.text() - self.entryText = self._text() - self.cancel = False - self.close() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - super().show() - if self.resizeOnShow: - self.lineEdit.setMinimumWidth(self.lineEdit.width() * 2) - self.okButton.setDefault(True) - if block: - self.loop = QEventLoop() - self.loop.exec_() - - -class wandToleranceWidget(QFrame): - def __init__(self, parent=None): - super().__init__(parent) - - self.slider = widgets.sliderWithSpinBox(title="Tolerance") - self.slider.setMaximum(255) - self.slider._layout.setColumnStretch(2, 21) - - self.setLayout(self.slider.layout) - - -class TrackSubCellObjectsDialog(QBaseDialog): - def __init__(self, basename="", parent=None): - self.cancel = True - super().__init__(parent=parent) - - self.setWindowTitle("Track sub-cellular objects parameters") - - mainLayout = QVBoxLayout() - entriesLayout = widgets.FormLayout() - - row = 0 - infoTxt = html_utils.paragraph(""" - Select behaviour with untracked objects:

    - NOTE: this utility always create new files. - Original segmentation masks
    are not modified
    . - """) - options = ( - "Delete sub-cellular objects that do not belong to any cell", - "Delete cells that do not have any sub-cellular object", - "Delete both cells and sub-cellular objects without an assignment", - "Only track the objects and keep all the non-tracked objects", - ) - combobox = widgets.QCenteredComboBox() - combobox.addItems(options) - self.optionsWidget = widgets.formWidget( - combobox, - addInfoButton=True, - labelTextLeft="Tracking mode: ", - infoTxt=infoTxt, - ) - entriesLayout.addFormWidget(self.optionsWidget, row=row) - - row += 1 - infoTxt = html_utils.paragraph(""" - Re-label sub-cellular objects before assigning them to the cell.

    - Activate this option if you have merged sub-cellular objects - that must be separated, or the segmentation is a boolean mask - (i.e., semantic segmentation). - """) - self.relabelSubObjLab = widgets.formWidget( - widgets.Toggle(), - addInfoButton=True, - stretchWidget=False, - labelTextLeft="Re-label sub-cellular objects before tracking: ", - infoTxt=infoTxt, - ) - entriesLayout.addFormWidget(self.relabelSubObjLab, row=row) - - row += 1 - IoAtext = html_utils.paragraph(""" - Enter a minimum percentage (0-1) of the sub-cellular object's area
    - that MUST overlap with the parent cell to be considered belonging to a cell: - """) - spinbox = widgets.CenteredDoubleSpinbox() - spinbox.setMaximum(1) - spinbox.setValue(0.5) - spinbox.setSingleStep(0.1) - self.IoAwidget = widgets.formWidget( - spinbox, - addInfoButton=True, - labelTextLeft="IoA threshold: ", - infoTxt=IoAtext, - ) - entriesLayout.addFormWidget(self.IoAwidget, row=row) - - row += 1 - infoTxt = html_utils.paragraph(""" - The third segmentation file is the result of subtracting the - sub-cellular objects from the parent objects

    - This is useful if, for example, you need to compute measurements - only from the cytoplasm (i.e., the sub-cellular object is the nucleus). - """) - self.createThirdSegmWidget = widgets.formWidget( - widgets.Toggle(), - addInfoButton=True, - stretchWidget=False, - labelTextLeft="Create third segmentation: ", - infoTxt=infoTxt, - ) - entriesLayout.addFormWidget(self.createThirdSegmWidget, row=row) - - row += 1 - infoTxt = html_utils.paragraph(""" - Text to append at the end of the third segmentation file.

    - The third segmentation file is the result of subtracting the - sub-cellular objects from the parent objects

    - This is useful if, for example, you need to compute measurements - only from the cytoplasm (i.e., the sub-cellular object is the nucleus). - """) - lineEdit = widgets.alphaNumericLineEdit() - lineEdit.setText("difference") - lineEdit.setAlignment(Qt.AlignCenter) - self.appendTextWidget = widgets.formWidget( - lineEdit, - addInfoButton=True, - labelTextLeft="Text to append: ", - infoTxt=infoTxt, - ) - entriesLayout.addFormWidget(self.appendTextWidget, row=row) - self.appendTextWidget.setDisabled(True) - - self.createThirdSegmWidget.widget.toggled.connect(self.createThirdSegmToggled) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addLayout(entriesLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - self.setFont(font) - - def createThirdSegmToggled(self, checked): - self.appendTextWidget.setDisabled(not checked) - - def ok_cb(self): - self.cancel = False - if self.createThirdSegmWidget.widget.isChecked(): - if not self.appendTextWidget.widget.text(): - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = html_utils.paragraph( - "When creating the third segmentation file, " - "the name to append cannot be empty!" - ) - msg.critical(self, "Empty name", txt) - return - - self.trackSubCellObjParams = { - "how": self.optionsWidget.widget.currentText(), - "IoA": self.IoAwidget.widget.value(), - "createThirdSegm": self.createThirdSegmWidget.widget.isChecked(), - "relabelSubObjLab": self.relabelSubObjLab.widget.isChecked(), - "thirdSegmAppendedText": self.appendTextWidget.widget.text(), - } - self.close() - - -class SetMeasurementsDialog(QBaseDialog): - sigClosed = Signal() - sigCancel = Signal() - sigRestart = Signal() - - def __init__( - self, - loadedChNames, - notLoadedChNames, - isZstack, - isSegm3D, - favourite_funcs=None, - parent=None, - allPos_acdc_df_cols=None, - acdc_df_path=None, - posData=None, - addCombineMetricCallback=None, - allPosData=None, - is_concat=False, - isSingleSelection=False, - state=None, - ): - super().__init__(parent=parent) - - self.checkBoxedGroup = QButtonGroup() - self.checkBoxedGroup.setExclusive(isSingleSelection) - - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - - self.cancel = True - - self.delExistingCols = False - self.okClicked = False - self.is_concat = is_concat - self.allPos_acdc_df_cols = allPos_acdc_df_cols - self.acdc_df_path = acdc_df_path - self.allPosData = allPosData - self.doNotWarn = False - - self.setWindowTitle("Set measurements") - # self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - - layout = QVBoxLayout() - - searchLayout = QHBoxLayout() - - searchLineEdit = widgets.SearchLineEdit() - searchLayout.addStretch(5) - searchLayout.addWidget(searchLineEdit) - searchLayout.setStretch(1, 3) - - mainScrollArea = widgets.ScrollArea() - mainScrollAreaWidget = QWidget() - mainScrollArea.setWidget(mainScrollAreaWidget) - - groupsLayout = QGridLayout() - self.groupsLayout = groupsLayout - - mainScrollAreaWidget.setLayout(groupsLayout) - - buttonsLayout = QHBoxLayout() - - self.chNameGroupboxes = [] - self.all_metrics = [] - - col = 0 - for col, chName in enumerate(loadedChNames): - channelGBox = widgets.channelMetricsQGBox( - isZstack, - chName, - isSegm3D, - favourite_funcs=favourite_funcs, - posData=posData, - is_concat=is_concat, - ) - channelGBox.chName = chName - groupsLayout.addWidget(channelGBox, 0, col, 3, 1) - self.chNameGroupboxes.append(channelGBox) - channelGBox.sigDelClicked.connect(self.delMixedChannelCombineMetric) - channelGBox.sigCheckboxToggled.connect(self.channelCheckboxToggled) - groupsLayout.setColumnStretch(col, 5) - self.all_metrics.extend([c.text() for c in channelGBox.checkBoxes]) - - current_col = col + 1 - for col, chName in enumerate(notLoadedChNames): - channelGBox = widgets.channelMetricsQGBox( - isZstack, - chName, - isSegm3D, - favourite_funcs=favourite_funcs, - posData=posData, - is_concat=is_concat, - ) - channelGBox.setChecked(False) - channelGBox.chName = chName - groupsLayout.addWidget(channelGBox, 0, current_col, 3, 1) - self.chNameGroupboxes.append(channelGBox) - groupsLayout.setColumnStretch(current_col, 5) - channelGBox.sigDelClicked.connect(self.delMixedChannelCombineMetric) - channelGBox.sigCheckboxToggled.connect(self.channelCheckboxToggled) - current_col += 1 - self.all_metrics.extend([c.text() for c in channelGBox.checkBoxes]) - - current_col += 1 - - if posData is None: - isTimelapse = False - else: - isTimelapse = posData.SizeT > 1 - size_metrics_desc = measurements.get_size_metrics_desc(isSegm3D, isTimelapse) - if not isSegm3D: - size_metrics_desc = { - key: val - for key, val in size_metrics_desc.items() - if not key.endswith("_3D") - } - - row = 0 - sizeMetricsQGBox = widgets._metricsQGBox( - size_metrics_desc, - "Physical measurements", - favourite_funcs=favourite_funcs, - isZstack=isZstack, - addCalcForEachZsliceToggle=isSegm3D, - ) - self.all_metrics.extend([c.text() for c in sizeMetricsQGBox.checkBoxes]) - self.sizeMetricsQGBox = sizeMetricsQGBox - for sizeCheckbox in sizeMetricsQGBox.checkBoxes: - sizeCheckbox.toggled.connect(self.sizeMetricToggled) - groupsLayout.addWidget(sizeMetricsQGBox, row, current_col) - groupsLayout.setRowStretch(0, 1) - groupsLayout.setColumnStretch(current_col, 3) - row += 1 - - props_info_txt_mapper = measurements.get_props_info_txt_mapper( - isSegm3D=isSegm3D - ) - rp_desc = props_info_txt_mapper - regionPropsQGBox = widgets._metricsQGBox( - rp_desc, - "Morphological properties", - favourite_funcs=favourite_funcs, - isZstack=isZstack, - ) - self.regionPropsQGBox = regionPropsQGBox - for rpCheckbox in regionPropsQGBox.checkBoxes: - rpCheckbox.toggled.connect(self.rpMetricToggled) - groupsLayout.addWidget(regionPropsQGBox, row, current_col) - groupsLayout.setRowStretch(1, 2) - self.all_metrics.extend([c.text() for c in regionPropsQGBox.checkBoxes]) - row += 1 - - # Custom metrics that are channel indipendent - self.chIndipendCustomeMetricsQGBox = None - out = measurements.ch_indipend_custom_metrics_desc( - isZstack, - isSegm3D=isSegm3D, - ) - ch_indipend_custom_metrics_desc = out - if ch_indipend_custom_metrics_desc: - self.chIndipendCustomeMetricsQGBox = widgets._metricsQGBox( - ch_indipend_custom_metrics_desc, - "Channel indipendent custom measurements", - favourite_funcs=favourite_funcs, - isZstack=isZstack, - parent=self, - ) - groupsLayout.addWidget(self.chIndipendCustomeMetricsQGBox, row, current_col) - groupsLayout.setRowStretch(1, 1) - row += 1 - - desc, equations = measurements.combine_mixed_channels_desc( - isSegm3D=isSegm3D, posData=posData, available_cols=self.all_metrics - ) - self.mixedChannelsCombineMetricsQGBox = None - if desc: - self.mixedChannelsCombineMetricsQGBox = widgets._metricsQGBox( - desc, - "Mixed channels combined measurements", - favourite_funcs=favourite_funcs, - isZstack=isZstack, - equations=equations, - addDelButton=True, - ) - self.mixedChannelsCombineMetricsQGBox.sigDelClicked.connect( - self.delMixedChannelCombineMetric - ) - groupsLayout.addWidget( - self.mixedChannelsCombineMetricsQGBox, row, current_col - ) - groupsLayout.setRowStretch(1, 1) - if not self.is_concat: - self.setDisabledMetricsRequestedForCombined(False) - self.mixedChannelsCombineMetricsQGBox.toggled.connect( - self.setDisabledMetricsRequestedForCombined - ) - for combCheckbox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: - combCheckbox.toggled.connect( - self.setDisabledMetricsRequestedForCombined - ) - else: - for combCheckbox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: - combCheckbox.toggled.connect(self.mixedChannelsMetricToggled) - row += 1 - - self.last_row = row - self.last_col = current_col - - okButton = widgets.okPushButton(" Ok ") - cancelButton = widgets.cancelPushButton("Cancel") - if addCombineMetricCallback is not None: - addCombineMetricButton = widgets.addPushButton( - "Add combined measurement..." - ) - addCombineMetricButton.clicked.connect(addCombineMetricCallback) - self.okButton = okButton - - loadLastSelButton = widgets.reloadPushButton("Load last selection...") - self.deselectAllButton = QPushButton("Deselect all") - self.deselectAllButton.setIcon(QIcon(":deselect_all.svg")) - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(self.deselectAllButton) - buttonsLayout.addSpacing(20) - - if addCombineMetricCallback is not None: - buttonsLayout.addWidget(addCombineMetricButton) - buttonsLayout.addSpacing(20) - - saveCurrentSelectionButton = widgets.savePushButton("Save current selection...") - saveCurrentSelectionButton.clicked.connect(self.saveCurrentSelectionClicked) - - buttonsLayout.addWidget(saveCurrentSelectionButton) - - loadSavedSelectionButton = widgets.OpenFilePushButton("Load saved selection...") - loadSavedSelectionButton.clicked.connect(self.loadSavedSelectionClicked) - buttonsLayout.addWidget(loadSavedSelectionButton) - - buttonsLayout.addWidget(loadLastSelButton) - - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(okButton) - - self.okButton = okButton - - layout.addLayout(searchLayout) - layout.addSpacing(10) - # layout.addLayout(groupsLayout) - layout.addWidget(mainScrollArea) - layout.addLayout(buttonsLayout) - - self.setLayout(layout) - - if state is not None: - self.setState(state) - - searchLineEdit.textEdited.connect(self.searchAndHighlight) - self.deselectAllButton.clicked.connect(self.deselectAll) - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - loadLastSelButton.clicked.connect(self.loadLastSelection) - - self.addCheckboxesToGroup() - - for channelGBox in self.chNameGroupboxes: - for checkbox in channelGBox.checkBoxes: - self.channelCheckboxToggled(checkbox) - - def allMetricsDict(self): - all_metrics = { - "standard": {}, - "regionprop": [], - "size": [], - "mixed_channels": [], - } - for chNameGroupbox in self.chNameGroupboxes: - channel_name = chNameGroupbox.chName - for checkBox in chNameGroupbox.checkBoxes: - if channel_name not in all_metrics["standard"]: - all_metrics["standard"][channel_name] = [] - all_metrics["standard"][channel_name].append(checkBox.text()) - - for checkBox in self.regionPropsQGBox.checkBoxes: - all_metrics["regionprop"].append(checkBox.text()) - - for checkBox in self.sizeMetricsQGBox.checkBoxes: - all_metrics["size"].append(checkBox.text()) - - if self.chIndipendCustomeMetricsQGBox is not None: - checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes - for checkBox in checkBoxes: - all_metrics["ch_indipend_custom_metric"].append(checkBox.text()) - - if self.mixedChannelsCombineMetricsQGBox is None: - return - - checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes - for checkBox in checkBoxes: - all_metrics["mixed_channels"].append(checkBox.text()) - - return all_metrics - - def searchAndHighlight(self, text): - for chNameGroupbox in self.chNameGroupboxes: - for groupbox in chNameGroupbox.groupboxes: - groupbox.highlightCheckboxesFromSearchText(text) - - self.regionPropsQGBox.highlightCheckboxesFromSearchText(text) - self.sizeMetricsQGBox.highlightCheckboxesFromSearchText(text) - - if self.chIndipendCustomeMetricsQGBox is not None: - self.chIndipendCustomeMetricsQGBox.highlightCheckboxesFromSearchText(text) - - if self.mixedChannelsCombineMetricsQGBox is None: - return - - self.mixedChannelsCombineMetricsQGBox.highlightCheckboxesFromSearchText(text) - - def selectedMetricNameAndGroup(self): - for chNameGroupbox in self.chNameGroupboxes: - for checkBox in chNameGroupbox.checkBoxes: - if checkBox.isChecked(): - return checkBox.text(), {"standard": chNameGroupbox.chName} - - for checkBox in self.regionPropsQGBox.checkBoxes: - if checkBox.isChecked(): - return checkBox.text(), "regionprop" - - for checkBox in self.sizeMetricsQGBox.checkBoxes: - if checkBox.isChecked(): - return checkBox.text(), "size" - - if self.chIndipendCustomeMetricsQGBox is not None: - checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes - for checkBox in checkBoxes: - if checkBox.isChecked(): - return checkBox.text(), "ch_indipend_custom_metric" - - if self.mixedChannelsCombineMetricsQGBox is None: - return - - checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes - for checkBox in checkBoxes: - if checkBox.isChecked(): - return checkBox.text(), "mixed_channels" - - def selectedMetricGroup(self): - for chNameGroupbox in self.chNameGroupboxes: - for checkBox in chNameGroupbox.checkBoxes: - if checkBox.isChecked(): - return checkBox.text() - - for checkBox in self.regionPropsQGBox.checkBoxes: - if checkBox.isChecked(): - return checkBox.text() - - for checkBox in self.sizeMetricsQGBox.checkBoxes: - if checkBox.isChecked(): - return checkBox.text() - - if self.chIndipendCustomeMetricsQGBox is not None: - checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes - for checkBox in checkBoxes: - if checkBox.isChecked(): - return checkBox.text() - - if self.mixedChannelsCombineMetricsQGBox is None: - return - - checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes - for checkBox in checkBoxes: - if checkBox.isChecked(): - return checkBox.text() - - def addCheckboxesToGroup(self): - for chNameGroupbox in self.chNameGroupboxes: - for checkBox in chNameGroupbox.checkBoxes: - self.checkBoxedGroup.addButton(checkBox) - - for checkBox in self.regionPropsQGBox.checkBoxes: - self.checkBoxedGroup.addButton(checkBox) - - for checkBox in self.sizeMetricsQGBox.checkBoxes: - self.checkBoxedGroup.addButton(checkBox) - - if self.chIndipendCustomeMetricsQGBox is not None: - checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes - for checkBox in checkBoxes: - self.checkBoxedGroup.addButton(checkBox) - - if self.mixedChannelsCombineMetricsQGBox is None: - return - - checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes - for checkBox in checkBoxes: - self.checkBoxedGroup.addButton(checkBox) - - def channelCheckboxToggled(self, checkbox): - # Make sure to automatically check the requested cell_vol metric for - # concentration metrics - if checkbox.text().find("concentration_") == -1: - return - - if self.is_concat: - # When this dialogue is used in concatenate pos utility we do not - # need to check that certain metrics are present - return - - pattern = r".+_from_vol_([a-z]+)(_3D)?(_?[A-Za-z0-9]*)" - repl = r"cell_vol_\1\2" - cell_vol_metric_name = re.sub(pattern, repl, checkbox.text()) - for sizeCheckbox in self.sizeMetricsQGBox.checkBoxes: - if sizeCheckbox.text() == cell_vol_metric_name: - break - else: - # Make sure to not check for similarly named custom metrics - return - - if checkbox.isChecked(): - sizeCheckbox.setChecked(True) - sizeCheckbox.isRequired = True - else: - # Do not enable cell vol checkbox is any of the other - # concentration metrics requiring it is checked - unit = cell_vol_metric_name[9:] - is3D = unit.endswith("3D") - for channelGBox in self.chNameGroupboxes: - if not channelGBox.isChecked(): - continue - for _checkbox in channelGBox.checkBoxes: - if _checkbox.text().find(f"_from_vol_{unit}") == -1: - continue - if not is3D and _checkbox.text().find(f"{unit}_3D") != -1: - # Metric is 3D but the cell_vol is not - continue - if _checkbox.isChecked(): - return - sizeCheckbox.isRequired = False - - def rpMetricToggled(self, checked): - pass - - def mixedChannelsMetricToggled(self, checked): - pass - - def sizeMetricToggled(self, checked): - """Method called when a checkbox of a size metric is toggled. - Check if the size value is required and explain why it cannot be - unchecked. - - Parameters - ---------- - checked : bool - State of the checkbox toggled - """ - checkbox = self.sender() - - if self.is_concat: - # When this dialogue is used in concatenate pos utility we do not - # need to check that certain metrics are present - return - - if not hasattr(checkbox, "isRequired"): - return - - if not checkbox.isRequired: - return - - if checkbox.isChecked(): - return - - checkbox.setChecked(True) - - if self.doNotWarn: - return - - linked_autoBkgr_metric = checkbox.text().replace("cell", "_autoBkgr_from") - linked_dataPrepBkgr_metric = checkbox.text().replace( - "cell", "_dataPrepBkgr_from" - ) - txt = html_utils.paragraph(f""" - This physical measurement cannot be unchecked - because it is required - by the {linked_autoBkgr_metric} and - {linked_dataPrepBkgr_metric} measurements - that you requested to save.

    - - Thank you for you patience! - """) - msg = widgets.myMessageBox(showCentered=False) - msg.warning(self, "Physical measurement required", txt) - - def deselectAll(self): - self.doNotWarn = True - for chNameGroupbox in self.chNameGroupboxes: - for gb in chNameGroupbox.groupboxes: - gb.checkAll(None, False) - cgb = getattr(chNameGroupbox, "customMetricsQGBox", None) - if cgb is not None: - cgb.checkAll(None, False) - - self.sizeMetricsQGBox.checkAll(None, False) - self.regionPropsQGBox.checkAll(None, False) - if self.chIndipendCustomeMetricsQGBox is not None: - self.chIndipendCustomeMetricsQGBox.checkAll(None, False) - - if self.mixedChannelsCombineMetricsQGBox is not None: - self.mixedChannelsCombineMetricsQGBox.checkAll(None, False) - self.doNotWarn = False - - def delMixedChannelCombineMetric(self, colname_to_del, hlayout): - cp = measurements.read_saved_user_combine_config() - for section in cp.sections(): - cp.remove_option(section, colname_to_del) - measurements.save_common_combine_metrics(cp) - - for i in range(hlayout.count()): - item = hlayout.itemAt(i) - w = item.widget() - if w is None: - continue - w.hide() - - if self.allPosData is not None: - for posData in self.allPosData: - _config = posData.combineMetricsConfig - for section in _config.sections(): - _config.remove_option(section, colname_to_del) - posData.saveCombineMetrics() - - def setState(self, state): - self.doNotWarn = True - for chNameGroupbox in self.chNameGroupboxes: - measurementsInfo = state.get(chNameGroupbox.title()) - if not measurementsInfo: - chNameGroupbox.setChecked(False) - else: - for checkBox in chNameGroupbox.checkBoxes: - colname = checkBox.text() - checkBox.setChecked(measurementsInfo[colname]) - - measurementsInfo = state.get(self.sizeMetricsQGBox.title()) - if not measurementsInfo: - self.sizeMetricsQGBox.setChecked(False) - else: - for checkBox in self.sizeMetricsQGBox.checkBoxes: - checked = checkBox.isChecked() - colname = checkBox.text() - checkBox.setChecked(measurementsInfo[colname]) - - measurementsInfo = state.get(self.regionPropsQGBox.title()) - if not measurementsInfo: - self.regionPropsQGBox.setChecked(False) - else: - self.regionPropsToSave = [] - for checkBox in self.regionPropsQGBox.checkBoxes: - checked = checkBox.isChecked() - colname = checkBox.text() - checkBox.setChecked(measurementsInfo[colname]) - - if self.chIndipendCustomeMetricsQGBox is not None: - measurementsInfo = state.get(self.chIndipendCustomeMetricsQGBox.title()) - if not measurementsInfo: - self.chIndipendCustomeMetricsQGBox.setChecked(False) - else: - checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes - for checkBox in checkBoxes: - checked = checkBox.isChecked() - colname = checkBox.text() - key = self.chIndipendCustomeMetricsQGBox.title() - checkBox.setChecked(measurementsInfo[colname]) - - if self.mixedChannelsCombineMetricsQGBox is not None: - measurementsInfo = state.get(self.mixedChannelsCombineMetricsQGBox.title()) - if not measurementsInfo: - self.mixedChannelsCombineMetricsQGBox.setChecked(False) - else: - checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes - for checkBox in checkBoxes: - checked = checkBox.isChecked() - colname = checkBox.text() - key = self.mixedChannelsCombineMetricsQGBox.title() - checkBox.setChecked(measurementsInfo[colname]) - - self.doNotWarn = False - - def state(self): - state = {self.sizeMetricsQGBox.title(): {}, self.regionPropsQGBox.title(): {}} - for chNameGroupbox in self.chNameGroupboxes: - state[chNameGroupbox.title()] = {} - if not chNameGroupbox.isChecked(): - # Channel unchecked - continue - else: - for checkBox in chNameGroupbox.checkBoxes: - colname = checkBox.text() - state[chNameGroupbox.title()][colname] = checkBox.isChecked() - - if not self.sizeMetricsQGBox.isChecked(): - pass - else: - for checkBox in self.sizeMetricsQGBox.checkBoxes: - checked = checkBox.isChecked() - colname = checkBox.text() - state[self.sizeMetricsQGBox.title()][colname] = checked - - if not self.regionPropsQGBox.isChecked(): - pass - else: - self.regionPropsToSave = [] - for checkBox in self.regionPropsQGBox.checkBoxes: - checked = checkBox.isChecked() - colname = checkBox.text() - state[self.regionPropsQGBox.title()][colname] = checked - - if self.chIndipendCustomeMetricsQGBox is not None: - state[self.chIndipendCustomeMetricsQGBox.title()] = {} - if self.chIndipendCustomeMetricsQGBox.isChecked(): - checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes - for checkBox in checkBoxes: - checked = checkBox.isChecked() - key = self.chIndipendCustomeMetricsQGBox.title() - colname = checkBox.text() - state[key][colname] = checked - - if self.mixedChannelsCombineMetricsQGBox is not None: - state[self.mixedChannelsCombineMetricsQGBox.title()] = {} - if self.mixedChannelsCombineMetricsQGBox.isChecked(): - checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes - for checkBox in checkBoxes: - checked = checkBox.isChecked() - key = self.mixedChannelsCombineMetricsQGBox.title() - colname = checkBox.text() - state[key][colname] = checked - - return state - - def restoreState(self, state): - for chNameGroupbox in self.chNameGroupboxes: - _state = state.get(chNameGroupbox.title()) - if _state is None or not _state: - continue - for checkBox in chNameGroupbox.checkBoxes: - isChecked = _state.get(checkBox.text()) - if isChecked is None: - continue - checkBox.setChecked(isChecked) - - _state = state.get(self.sizeMetricsQGBox.title()) - if _state is None or not _state: - pass - else: - for checkBox in self.sizeMetricsQGBox.checkBoxes: - isChecked = _state.get(checkBox.text()) - if isChecked is None: - continue - checkBox.setChecked(isChecked) - - _state = state.get(self.regionPropsQGBox.title()) - if _state is None or not _state: - pass - else: - for checkBox in self.regionPropsQGBox.checkBoxes: - isChecked = _state.get(checkBox.text()) - if isChecked is None: - continue - checkBox.setChecked(isChecked) - - if self.chIndipendCustomeMetricsQGBox is not None: - _state = state.get(self.chIndipendCustomeMetricsQGBox.title()) - if _state is None or not _state: - pass - else: - for checkBox in self.chIndipendCustomeMetricsQGBox.checkBoxes: - isChecked = _state.get(checkBox.text()) - if isChecked is None: - continue - checkBox.setChecked(isChecked) - - if self.mixedChannelsCombineMetricsQGBox is not None: - _state = state.get(self.mixedChannelsCombineMetricsQGBox.title()) - if _state is None or not _state: - pass - else: - for checkBox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: - isChecked = _state.get(checkBox.text()) - if isChecked is None: - continue - checkBox.setChecked(isChecked) - - def currentSelectionMapper(self): - current_selected_meas = defaultdict(dict) - - for chNameGroupbox in self.chNameGroupboxes: - if not chNameGroupbox.isChecked(): - continue - - chName = chNameGroupbox.chName - for checkBox in chNameGroupbox.checkBoxes: - if not checkBox.isChecked(): - continue - - current_selected_meas[chName][checkBox.text()] = "Yes" - - size_selected_meas = current_selected_meas.get(self.sizeMetricsQGBox.title()) - if self.sizeMetricsQGBox.isChecked(): - for checkBox in self.sizeMetricsQGBox.checkBoxes: - if not checkBox.isChecked(): - continue - - section = self.sizeMetricsQGBox.title() - current_selected_meas[section][checkBox.text()] = "Yes" - - size_selected_meas = current_selected_meas.get(self.regionPropsQGBox.title()) - if self.regionPropsQGBox.isChecked(): - for checkBox in self.regionPropsQGBox.checkBoxes: - if not checkBox.isChecked(): - continue - - section = self.regionPropsQGBox.title() - current_selected_meas[section][checkBox.text()] = "Yes" - - if self.chIndipendCustomeMetricsQGBox is not None: - if self.chIndipendCustomeMetricsQGBox.isChecked(): - for checkBox in self.chIndipendCustomeMetricsQGBox.checkBoxes: - if not checkBox.isChecked(): - continue - - section = self.chIndipendCustomeMetricsQGBox.title() - current_selected_meas[section][checkBox.text()] = "Yes" - - if self.mixedChannelsCombineMetricsQGBox is not None: - if self.mixedChannelsCombineMetricsQGBox.isChecked(): - for checkBox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: - if not checkBox.isChecked(): - continue - - section = self.mixedChannelsCombineMetricsQGBox.title() - current_selected_meas[section][checkBox.text()] = "Yes" - - return current_selected_meas - - def saveCurrentSelectionClicked(self): - current_selection_mapper = self.currentSelectionMapper() - defaultEntry = "_and_".join(current_selection_mapper.keys()) - defaultEntry = defaultEntry.replace(" ", "_").lower() - saved_selections = io.get_saved_measurements_selections() - win = filenameDialog( - basename="", - ext="", - hintText="Insert a name for the current selection:", - existingNames=saved_selections, - allowEmpty=False, - defaultEntry=defaultEntry, - ) - win.exec_() - if win.cancel: - return - - filename = win.filename - ini_filepath = io.save_measurements_selections( - filename, current_selection_mapper - ) - - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - txt = html_utils.paragraph(f""" - Done!

    - Current selection saved with name {filename} at - the following path: - """) - msg.information( - self, - "Selection saved", - txt, - commands=(ini_filepath,), - path_to_browse=os.path.dirname(ini_filepath), - ) - - def loadSavedSelectionClicked(self): - self.doNotWarn = True - - saved_selections = io.get_saved_measurements_selections() - - selectNameWin = widgets.QDialogListbox( - "Choose selection to load", - "Choose selection to load:\n", - saved_selections, - multiSelection=False, - parent=self, - ) - selectNameWin.exec_() - if selectNameWin.cancel: - return - - selection_mapper = io.read_measurements_selections( - selectNameWin.selectedItemsText[0] - ) - - self.setCurrentSelectionFromMapper(selection_mapper) - - self.doNotWarn = False - - def saveLastSelection(self): - last_selected_meas = self.currentSelectionMapper() - load.write_last_selected_set_measurements(last_selected_meas) - - def setCurrentSelectionFromMapper(self, selection_mapper): - for chNameGroupbox in self.chNameGroupboxes: - chName = chNameGroupbox.chName - chSelectedMeas = selection_mapper.get(chName) - if chSelectedMeas is None: - chNameGroupbox.setChecked(False) - continue - - chNameGroupbox.setChecked(True) - for checkBox in chNameGroupbox.checkBoxes: - checked = chSelectedMeas.get(checkBox.text()) - if checked is not None: - checkBox.setChecked(True) - else: - checkBox.setChecked(False) - - size_selected_meas = selection_mapper.get(self.sizeMetricsQGBox.title()) - if size_selected_meas is None: - self.sizeMetricsQGBox.setChecked(False) - else: - self.sizeMetricsQGBox.setChecked(True) - for checkBox in self.sizeMetricsQGBox.checkBoxes: - checked = size_selected_meas.get(checkBox.text()) - if checked is not None: - checkBox.setChecked(True) - else: - checkBox.setChecked(False) - - size_selected_meas = selection_mapper.get(self.regionPropsQGBox.title()) - if size_selected_meas is None: - self.regionPropsQGBox.setChecked(False) - else: - self.regionPropsQGBox.setChecked(True) - for checkBox in self.regionPropsQGBox.checkBoxes: - checked = size_selected_meas.get(checkBox.text()) - if checked is not None: - checkBox.setChecked(True) - else: - checkBox.setChecked(False) - - if self.chIndipendCustomeMetricsQGBox is not None: - ch_indip_custom_metrics = selection_mapper.get( - self.chIndipendCustomeMetricsQGBox.title() - ) - if size_selected_meas is None: - self.chIndipendCustomeMetricsQGBox.setChecked(False) - else: - self.chIndipendCustomeMetricsQGBox.setChecked(True) - for checkBox in self.chIndipendCustomeMetricsQGBox.checkBoxes: - checked = size_selected_meas.get(checkBox.text()) - if checked is not None: - checkBox.setChecked(True) - else: - checkBox.setChecked(False) - - if self.mixedChannelsCombineMetricsQGBox is not None: - ch_indip_custom_metrics = selection_mapper.get( - self.mixedChannelsCombineMetricsQGBox.title() - ) - if size_selected_meas is None: - self.mixedChannelsCombineMetricsQGBox.setChecked(False) - else: - self.mixedChannelsCombineMetricsQGBox.setChecked(True) - for checkBox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: - checked = size_selected_meas.get(checkBox.text()) - if checked is not None: - checkBox.setChecked(True) - else: - checkBox.setChecked(False) - - def loadLastSelection(self): - self.doNotWarn = True - last_selected_meas = load.read_last_selected_set_measurements() - last_selected_meas = dict(last_selected_meas) - - self.setCurrentSelectionFromMapper(last_selected_meas) - - self.doNotWarn = False - - def setDisabledMetricsRequestedForCombined(self, checked): - checkbox = self.sender() - - if self.is_concat: - # When this dialogue is used in concatenate pos utility we do not - # need to check that certain metrics are present - return - - # Set checked and disable those metrics that are requested for - # combined measurements - allCheckboxes = [] - - for chNameGroupbox in self.chNameGroupboxes: - for chCheckBox in chNameGroupbox.checkBoxes: - chCheckBox.setDisabled(False) - allCheckboxes.append(chCheckBox) - - for sizeCheckBox in self.sizeMetricsQGBox.checkBoxes: - sizeCheckBox.setDisabled(False) - allCheckboxes.append(chCheckBox) - - for rpCheckBox in self.regionPropsQGBox.checkBoxes: - rpCheckBox.setDisabled(False) - allCheckboxes.append(chCheckBox) - - if not self.mixedChannelsCombineMetricsQGBox.isChecked(): - return - - for cb in allCheckboxes: - metricName = cb.text() - for combCheckbox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: - equation = combCheckbox.equation - if equation.find(metricName) == -1: - continue - elif combCheckbox.isChecked(): - cb.setChecked(True) - cb.setDisabled(True) - cb.setToolTip( - "This metric cannot be removed because it is required " - f'by the combined measurement "{combCheckbox.text()}"' - ) - - def keyPressEvent(self, a0: QKeyEvent) -> None: - state = self.state() - return super().keyPressEvent(a0) - - def closeEvent(self, event): - if self.cancel: - self.sigCancel.emit() - super().closeEvent(event) - - def restart(self): - self.cancel = False - self.close() - self.sigRestart.emit() - - def setDisabledNotExistingMeasurements(self, existing_colnames): - self.existing_colnames = existing_colnames - for chNameGroupbox in self.chNameGroupboxes: - for checkBox in chNameGroupbox.checkBoxes: - colname = checkBox.text() - if colname in existing_colnames: - checkBox.setChecked(True) - continue - - checkBox.setChecked(False) - checkBox.setDisabled(True) - self.setNotExistingMeasurementTooltip(checkBox) - - for checkBox in self.sizeMetricsQGBox.checkBoxes: - colname = checkBox.text() - if colname in existing_colnames: - checkBox.setChecked(True) - continue - checkBox.setChecked(False) - checkBox.setDisabled(True) - self.setNotExistingMeasurementTooltip(checkBox) - - for checkBox in self.regionPropsQGBox.checkBoxes: - prop_name = checkBox.text() - for existing_col in existing_colnames: - if prop_name == existing_col: - checkBox.setChecked(True) - break - m = re.match(rf"{prop_name}-\d", existing_col) - if m is not None: - checkBox.setChecked(True) - break - else: - checkBox.setChecked(False) - checkBox.setDisabled(True) - self.setNotExistingMeasurementTooltip(checkBox) - - if self.mixedChannelsCombineMetricsQGBox is None: - return - - for combCheckbox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: - colname = combCheckbox.text() - if colname in existing_colnames: - combCheckbox.setChecked(True) - continue - combCheckbox.setChecked(False) - combCheckbox.setDisabled(True) - self.setNotExistingMeasurementTooltip(combCheckbox) - - def addNonMeasurementColumns(self, colnames): - additionalCols = measurements.get_non_measurements_cols( - colnames, self.all_metrics - ) - if not additionalCols: - return - self.nonMeasurementsGroupbox = widgets.CheckboxesGroupBox( - additionalCols, title="Additional columns", checkable=True - ) - self.groupsLayout.addWidget( - self.nonMeasurementsGroupbox, 0, self.last_col + 1, self.last_row + 1, 1 - ) - - def setNotExistingMeasurementTooltip(self, checkBox): - checkBox.setToolTip( - "Measurement is disabled because it is not present in selected " - "acdc_output tables, hence it cannot be addded to concatenated " - "table. " - ) - - def ok_cb(self): - for chNameGroupbox in self.chNameGroupboxes: - chNameGroupbox.calcForEachZsliceRequested = ( - chNameGroupbox.isCalcForEachZsliceRequested() - ) - - self.sizeMetricsQGBox.calcForEachZsliceRequested = ( - self.sizeMetricsQGBox.isCalcForEachZsliceRequested() - ) - - if self.allPos_acdc_df_cols is None: - self.saveLastSelection() - self.cancel = False - self.close() - self.sigClosed.emit() - return - - self.okClicked = True - existing_colnames = self.allPos_acdc_df_cols - unchecked_existing_colnames = [] - unchecked_existing_rps = [] - for chNameGroupbox in self.chNameGroupboxes: - for checkBox in chNameGroupbox.checkBoxes: - colname = checkBox.text() - is_existing = colname in existing_colnames - if not chNameGroupbox.isChecked() and is_existing: - unchecked_existing_colnames.append(colname) - continue - if not checkBox.isChecked() and is_existing: - unchecked_existing_colnames.append(colname) - - for checkBox in self.sizeMetricsQGBox.checkBoxes: - colname = checkBox.text() - is_existing = colname in existing_colnames - if not self.sizeMetricsQGBox.isChecked() and is_existing: - unchecked_existing_colnames.append(colname) - continue - - if not checkBox.isChecked() and is_existing: - unchecked_existing_colnames.append(colname) - for checkBox in self.regionPropsQGBox.checkBoxes: - colname = checkBox.text() - is_existing = any([col == colname for col in existing_colnames]) - if not self.regionPropsQGBox.isChecked() and is_existing: - unchecked_existing_rps.append(colname) - continue - - if not checkBox.isChecked() and is_existing: - unchecked_existing_rps.append(colname) - - if unchecked_existing_colnames or unchecked_existing_rps: - cancel, self.delExistingCols = self.warnUncheckedExistingMeasurements( - unchecked_existing_colnames, unchecked_existing_rps - ) - self.existingUncheckedColnames = unchecked_existing_colnames - self.existingUncheckedRps = unchecked_existing_rps - if cancel: - return - - self.saveLastSelection() - self.cancel = False - self.close() - self.sigClosed.emit() - - def warnUncheckedExistingMeasurements( - self, unchecked_existing_colnames, unchecked_existing_rps - ): - msg = widgets.myMessageBox() - msg.setWidth(500) - msg.addShowInFileManagerButton(self.acdc_df_path) - txt = html_utils.paragraph( - "You chose to not save some measurements that are " - "already present in the saved acdc_output.csv " - "file.

    " - "Do you want to delete these measurements or " - "keep them?

    " - "Existing measurements not selected:" - ) - listView = widgets.readOnlyQList(msg) - items = unchecked_existing_colnames.copy() - items.extend(unchecked_existing_rps) - listView.addItems(items) - _, delButton, keepButton = msg.warning( - self, - "Unchecked existing measurements", - txt, - widgets=listView, - buttonsTexts=("Cancel", "Delete", "Keep"), - ) - return msg.cancel, msg.clickedButton == delButton - - def show(self, block=False): - super().show(block=False) - self.deselectAllButton.setMinimumHeight(self.okButton.height()) - screenWidth = self.screen().size().width() - screenHeight = self.screen().size().height() - screenLeft = self.screen().geometry().x() - screenTop = self.screen().geometry().y() - h = screenHeight - 200 - minColWith = screenWidth / 5 - w = minColWith * (self.last_col + 1) - xLeft = int((screenWidth - w) / 2) - if w > screenWidth: - self.move(screenLeft + 10, screenTop + 50) - self.resize(screenWidth - 20, h) - else: - self.move(screenLeft + xLeft, screenTop + 50) - self.resize(int(w), h) - super().show(block=block) - - -class QDialogMetadataXML(QDialog): - def __init__( - self, - title="Metadata", - LensNA=1.0, - rawFilename="test", - SizeT=1, - SizeZ=1, - SizeC=1, - SizeS=1, - TimeIncrement=1.0, - TimeIncrementUnit="s", - PhysicalSizeX=1.0, - PhysicalSizeY=1.0, - PhysicalSizeZ=1.0, - PhysicalSizeUnit="μm", - ImageName="", - chNames=None, - emWavelens=None, - parent=None, - rawDataStruct=None, - sampleImgData=None, - rawFilePath=None, - ): - self.cancel = True - self.trust = False - self.overWrite = False - rawFilename = os.path.splitext(rawFilename)[0] - self.rawFilename = self.removeInvalidCharacters(rawFilename) - self.rawFilePath = rawFilePath - self.sampleImgData = sampleImgData - self.ImageName = ImageName - self.rawDataStruct = rawDataStruct - self.readSampleImgDataAgain = False - self.requestedReadingSampleImageDataAgain = False - self.imageViewer = None - super().__init__(parent) - self.setWindowTitle(title) - font = QFont() - font.setPixelSize(12) - self.setFont(font) - - mainLayout = QVBoxLayout() - entriesLayout = QGridLayout() - self.channelNameLayouts = ( - QVBoxLayout(), - QVBoxLayout(), - QVBoxLayout(), - QVBoxLayout(), - ) - self.channelEmWLayouts = ( - QVBoxLayout(), - QVBoxLayout(), - QVBoxLayout(), - QVBoxLayout(), - ) - buttonsLayout = QGridLayout() - - infoLabel = QLabel() - infoTxt = "Confirm/Edit the metadata below." - infoLabel.setText(infoTxt) - # padding: top, left, bottom, right - infoLabel.setStyleSheet("font-size:12pt; padding:0px 0px 5px 0px;") - mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) - - noteLabel = QLabel() - noteLabel.setText( - f"NOTE: If you are not sure about some of the entries " - 'you can try to click "Ok".\n' - "If they are wrong you will get " - "an error message later when trying to read the data." - ) - noteLabel.setAlignment(Qt.AlignCenter) - mainLayout.addWidget(noteLabel, alignment=Qt.AlignCenter) - - row = 0 - to_tif_radiobutton = QRadioButton(".tif") - to_tif_radiobutton.setChecked(True) - to_h5_radiobutton = QRadioButton(".h5") - to_h5_radiobutton.setToolTip( - ".h5 is highly recommended for big datasets to avoid memory issues.\n" - "As a rule of thumb, if the single position, single channel file\n" - "is larger than 1/5 of the available RAM we recommend using .h5 format" - ) - self.to_h5_radiobutton = to_h5_radiobutton - txt = "File format: " - label = QLabel(txt) - fileFormatLayout = QHBoxLayout() - fileFormatLayout.addStretch(1) - fileFormatLayout.addWidget(to_tif_radiobutton) - fileFormatLayout.addStretch(1) - fileFormatLayout.addWidget(to_h5_radiobutton) - fileFormatLayout.addStretch(1) - entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - entriesLayout.addLayout(fileFormatLayout, row, 1) - to_h5_radiobutton.toggled.connect(self.updateFileFormat) - - row += 1 - self.SizeS_SB = QSpinBox() - self.SizeS_SB.setAlignment(Qt.AlignCenter) - self.SizeS_SB.setMinimum(1) - self.SizeS_SB.setMaximum(2147483647) - self.SizeS_SB.setValue(SizeS) - txt = "Number of positions (SizeS): " - label = QLabel(txt) - entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - entriesLayout.addWidget(self.SizeS_SB, row, 1) - - if rawDataStruct == 0: - row += 1 - self.SizeS_SB.setValue(1) - self.SizeS_SB.setDisabled(True) - self.posSelector = widgets.ExpandableListBox() - positions = ["All positions"] - positions.extend([f"Position_{i + 1}" for i in range(SizeS)]) - self.posSelector.addItems(positions) - txt = "Positions to save: " - label = QLabel(txt) - entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - entriesLayout.addWidget(self.posSelector, row, 1) - self.SizeS_SB.valueChanged.connect(self.SizeSvalueChanged) - - row += 1 - self.LensNA_DSB = QDoubleSpinBox() - self.LensNA_DSB.setAlignment(Qt.AlignCenter) - self.LensNA_DSB.setSingleStep(0.1) - self.LensNA_DSB.setValue(LensNA) - txt = "Numerical Aperture Objective Lens: " - label = QLabel(txt) - entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - entriesLayout.addWidget(self.LensNA_DSB, row, 1) - - row += 1 - self.SizeT_SB = QSpinBox() - self.SizeT_SB.setAlignment(Qt.AlignCenter) - self.SizeT_SB.setMinimum(1) - self.SizeT_SB.setMaximum(2147483647) - self.SizeT_SB.setValue(SizeT) - txt = "Number of frames (SizeT): " - label = QLabel(txt) - entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - entriesLayout.addWidget(self.SizeT_SB, row, 1) - self.SizeT_SB.valueChanged.connect(self.hideShowTimeIncrement) - - row += 1 - self.timeRangeToSaveWidget = widgets.RangeSelector(integers=True) - self.timeRangeToSaveWidget.setRange(1, SizeT) - txt = "Time range to save: " - label = QLabel(txt) - self.timeRangeToSaveWidget.label = label - entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - entriesLayout.addWidget(self.timeRangeToSaveWidget, row, 1) - - row += 1 - self.SizeZ_SB = QSpinBox() - self.SizeZ_SB.setAlignment(Qt.AlignCenter) - self.SizeZ_SB.setMinimum(1) - self.SizeZ_SB.setMaximum(2147483647) - self.SizeZ_SB.setValue(SizeZ) - txt = "Number of z-slices in the z-stack (SizeZ): " - label = QLabel(txt) - entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - entriesLayout.addWidget(self.SizeZ_SB, row, 1) - self.SizeZ_SB.valueChanged.connect(self.hideShowPhysicalSizeZ) - - row += 1 - self.TimeIncrement_DSB = widgets.FloatLineEdit( - allowNegative=False, warningValues={1.0} - ) - self.TimeIncrement_DSB.setValue(TimeIncrement) - self.TimeIncrement_DSB.setMinimum(0.0) - txt = "Frame interval: " - label = QLabel(txt) - self.TimeIncrement_Label = label - entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - entriesLayout.addWidget(self.TimeIncrement_DSB, row, 1) - - self.TimeIncrementUnit_CB = QComboBox() - unitItems = ["ms", "seconds", "minutes", "hours"] - currentTxt = [unit for unit in unitItems if unit.startswith(TimeIncrementUnit)] - self.TimeIncrementUnit_CB.addItems(unitItems) - if currentTxt: - self.TimeIncrementUnit_CB.setCurrentText(currentTxt[0]) - entriesLayout.addWidget( - self.TimeIncrementUnit_CB, row, 2, alignment=Qt.AlignLeft - ) - - row += 1 - self.PhysicalSizeX_DSB = QDoubleSpinBox() - self.PhysicalSizeX_DSB.setAlignment(Qt.AlignCenter) - self.PhysicalSizeX_DSB.setMaximum(2147483647.0) - self.PhysicalSizeX_DSB.setSingleStep(0.001) - self.PhysicalSizeX_DSB.setDecimals(7) - self.PhysicalSizeX_DSB.setValue(PhysicalSizeX) - txt = "Pixel width (X): " - label = QLabel(txt) - entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - entriesLayout.addWidget(self.PhysicalSizeX_DSB, row, 1) - - self.PhysicalSizeUnit_CB = QComboBox() - unitItems = ["nm", "μm", "mm", "cm"] - currentTxt = [unit for unit in unitItems if unit.startswith(PhysicalSizeUnit)] - self.PhysicalSizeUnit_CB.addItems(unitItems) - if currentTxt: - self.PhysicalSizeUnit_CB.setCurrentText(currentTxt[0]) - else: - self.PhysicalSizeUnit_CB.setCurrentText(unitItems[1]) - entriesLayout.addWidget( - self.PhysicalSizeUnit_CB, row, 2, alignment=Qt.AlignLeft - ) - self.PhysicalSizeUnit_CB.currentTextChanged.connect(self.updatePSUnit) - - row += 1 - self.PhysicalSizeY_DSB = QDoubleSpinBox() - self.PhysicalSizeY_DSB.setAlignment(Qt.AlignCenter) - self.PhysicalSizeY_DSB.setMaximum(2147483647.0) - self.PhysicalSizeY_DSB.setSingleStep(0.001) - self.PhysicalSizeY_DSB.setDecimals(7) - self.PhysicalSizeY_DSB.setValue(PhysicalSizeY) - txt = "Pixel height (Y): " - label = QLabel(txt) - entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - entriesLayout.addWidget(self.PhysicalSizeY_DSB, row, 1) - - self.PhysicalSizeYUnit_Label = QLabel() - self.PhysicalSizeYUnit_Label.setStyleSheet( - "font-size:13px; padding:5px 0px 2px 0px;" - ) - unit = self.PhysicalSizeUnit_CB.currentText() - self.PhysicalSizeYUnit_Label.setText(unit) - entriesLayout.addWidget(self.PhysicalSizeYUnit_Label, row, 2) - - row += 1 - self.PhysicalSizeZ_DSB = QDoubleSpinBox() - self.PhysicalSizeZ_DSB.setAlignment(Qt.AlignCenter) - self.PhysicalSizeZ_DSB.setMaximum(2147483647.0) - self.PhysicalSizeZ_DSB.setSingleStep(0.001) - self.PhysicalSizeZ_DSB.setDecimals(7) - self.PhysicalSizeZ_DSB.setValue(PhysicalSizeZ) - txt = "Voxel depth (Z): " - self.PSZlabel = QLabel(txt) - entriesLayout.addWidget(self.PSZlabel, row, 0, alignment=Qt.AlignRight) - entriesLayout.addWidget(self.PhysicalSizeZ_DSB, row, 1) - - self.PhysicalSizeZUnit_Label = QLabel() - # padding: top, left, bottom, right - self.PhysicalSizeZUnit_Label.setStyleSheet( - "font-size:13px; padding:5px 0px 2px 0px;" - ) - unit = self.PhysicalSizeUnit_CB.currentText() - self.PhysicalSizeZUnit_Label.setText(unit) - entriesLayout.addWidget(self.PhysicalSizeZUnit_Label, row, 2) - - if SizeZ == 1: - self.PSZlabel.hide() - self.PhysicalSizeZ_DSB.hide() - self.PhysicalSizeZUnit_Label.hide() - - row += 1 - self.SizeC_SB = QSpinBox() - self.SizeC_SB.setAlignment(Qt.AlignCenter) - self.SizeC_SB.setMinimum(1) - self.SizeC_SB.setMaximum(2147483647) - self.SizeC_SB.setValue(SizeC) - txt = "Number of channels (SizeC): " - label = QLabel(txt) - entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - entriesLayout.addWidget(self.SizeC_SB, row, 1) - self.SizeC_SB.valueChanged.connect(self.addRemoveChannels) - - row += 1 - for j, layout in enumerate(self.channelNameLayouts): - entriesLayout.addLayout(layout, row, j) - - self.chNames_QLEs = [] - self.saveChannels_QCBs = [] - self.filename_QLabels = [] - self.showChannelDataButtons = [] - - ext = "h5" if self.to_h5_radiobutton.isChecked() else "tif" - for c in range(SizeC): - chName_QLE = QLineEdit() - chName_QLE.setStyleSheet("") - chName_QLE.setAlignment(Qt.AlignCenter) - chName_QLE.textChanged.connect(self.checkChNames) - if chNames is not None: - chName_QLE.setText(chNames[c]) - else: - chName_QLE.setText(f"channel_{c}") - filename = f"" - - txt = f"Channel {c} name: " - label = QLabel(txt) - - filenameDescLabel = QLabel(f"e.g., filename for channel {c}: ") - - chName = chName_QLE.text() - chName = self.removeInvalidCharacters(chName) - rawFilename = self.elidedRawFilename() - filenameLabel = QLabel(f""" -

    {rawFilename}_{chName}.{ext}

    - """) - filenameLabel.setToolTip(f"{self.rawFilename}_{chName}.{ext}") - - checkBox = QCheckBox("Save this channel") - checkBox.setChecked(True) - checkBox.stateChanged.connect(self.saveCh_checkBox_cb) - - self.channelNameLayouts[0].addWidget(label, alignment=Qt.AlignRight) - self.channelNameLayouts[0].addWidget( - filenameDescLabel, alignment=Qt.AlignRight - ) - self.channelNameLayouts[1].addWidget(chName_QLE) - self.channelNameLayouts[1].addWidget( - filenameLabel, alignment=Qt.AlignCenter - ) - - self.channelNameLayouts[2].addWidget(checkBox) - if c == 0 and ImageName: - addImageName_QCB = QCheckBox("Include image name") - addImageName_QCB.stateChanged.connect(self.addImageName_cb) - self.addImageName_QCB = addImageName_QCB - self.channelNameLayouts[2].addWidget(addImageName_QCB) - else: - self.addImageName_QCB = QCheckBox("dummy") - self.addImageName_QCB.hide() - self.channelNameLayouts[2].addWidget(QLabel()) - - showChannelDataButton = QPushButton() - showChannelDataButton.setIcon(QIcon(":eye-plus.svg")) - showChannelDataButton.clicked.connect(self.showChannelData) - self.channelNameLayouts[3].addWidget(showChannelDataButton) - if self.sampleImgData is None: - showChannelDataButton.setDisabled(True) - - self.chNames_QLEs.append(chName_QLE) - self.saveChannels_QCBs.append(checkBox) - self.filename_QLabels.append(filenameLabel) - self.showChannelDataButtons.append(showChannelDataButton) - - self.checkChNames() - - row += 1 - for j, layout in enumerate(self.channelEmWLayouts): - entriesLayout.addLayout(layout, row, j) - - self.emWavelens_DSBs = [] - for c in range(SizeC): - row += 1 - emWavelen_DSB = QDoubleSpinBox() - emWavelen_DSB.setAlignment(Qt.AlignCenter) - emWavelen_DSB.setMaximum(2147483647.0) - emWavelen_DSB.setSingleStep(0.001) - emWavelen_DSB.setDecimals(2) - if emWavelens is not None: - emWavelen_DSB.setValue(emWavelens[c]) - else: - emWavelen_DSB.setValue(500.0) - - txt = f"Channel {c} emission wavelength: " - label = QLabel(txt) - self.channelEmWLayouts[0].addWidget(label, alignment=Qt.AlignRight) - self.channelEmWLayouts[1].addWidget(emWavelen_DSB) - self.emWavelens_DSBs.append(emWavelen_DSB) - - unit = QLabel("nm") - unit.setStyleSheet("font-size:13px; padding:5px 0px 2px 0px;") - self.channelEmWLayouts[2].addWidget(unit) - - entriesLayout.setContentsMargins(0, 15, 0, 0) - - if rawDataStruct is None or rawDataStruct != -1: - okButton = widgets.okPushButton(" Ok ") - elif rawDataStruct == 1: - okButton = QPushButton(" Load next position ") - buttonsLayout.addWidget(okButton, 0, 1) - - self.trustButton = None - self.overWriteButton = None - if rawDataStruct == 1: - trustButton = QPushButton( - " Trust metadata reader\n for all next positions " - ) - trustButton.setToolTip( - "If you didn't have to manually modify metadata entries\n" - "it is very likely that metadata from the metadata reader\n" - "will be correct also for all the next positions.\n\n" - "Click this button to stop showing this dialog and use\n" - "the metadata from the reader\n" - "(except for channel names, I will use the manually entered)" - ) - buttonsLayout.addWidget(trustButton, 1, 1) - self.trustButton = trustButton - - overWriteButton = QPushButton( - " Use the above metadata\n for all the next positions " - ) - overWriteButton.setToolTip( - "If you had to manually modify metadata entries\n" - "AND you know they will be the same for all next positions\n" - "you can click this button to stop showing this dialog\n" - "and use the same metadata for all the next positions." - ) - buttonsLayout.addWidget(overWriteButton, 1, 2) - self.overWriteButton = overWriteButton - - trustButton.clicked.connect(self.ok_cb) - overWriteButton.clicked.connect(self.ok_cb) - - cancelButton = widgets.cancelPushButton("Cancel") - buttonsLayout.addWidget(cancelButton, 0, 2) - buttonsLayout.setColumnStretch(0, 1) - buttonsLayout.setColumnStretch(3, 1) - buttonsLayout.setContentsMargins(0, 10, 0, 0) - - mainLayout.addLayout(entriesLayout) - mainLayout.addLayout(buttonsLayout) - mainLayout.addStretch(1) - - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.cancel_cb) - - self.hideShowTimeIncrement(SizeT) - self.readSampleImgDataAgain = False - - self.setLayout(mainLayout) - # self.setModal(True) - - def saveCh_checkBox_cb(self, state): - self.checkChNames() - idx = self.saveChannels_QCBs.index(self.sender()) - LE = self.chNames_QLEs[idx] - idx *= 2 - LE.setDisabled(state == 0) - label = self.channelNameLayouts[0].itemAt(idx).widget() - if state == 0: - label.setStyleSheet("color: gray; font-size: 10pt") - else: - label.setStyleSheet("color: black; font-size: 10pt") - - label = self.channelNameLayouts[0].itemAt(idx + 1).widget() - if state == 0: - label.setStyleSheet("color: gray; font-size: 10pt") - else: - label.setStyleSheet("color: black; font-size: 10pt") - - label = self.channelNameLayouts[1].itemAt(idx + 1).widget() - if state == 0: - label.setStyleSheet("color: gray; font-size: 10pt") - else: - label.setStyleSheet("color: black; font-size: 10pt") - - def addImageName_cb(self, state): - for idx in range(self.SizeC_SB.value()): - self.updateFilename(idx) - - def setInvalidChName_StyleSheet(self, LE): - LE.setStyleSheet( - "border-radius: 4px;border: 1.5px solid red;padding: 1px 0px 1px 0px" - ) - - def removeInvalidCharacters(self, chName): - # Remove invalid charachters - chName = "".join( - c if c.isalnum() or c == "_" or c == "" else "_" for c in chName - ) - trim_ = chName.endswith("_") - while trim_: - chName = chName[:-1] - trim_ = chName.endswith("_") - return chName - - def updateFileFormat(self, is_h5): - for idx in range(len(self.chNames_QLEs)): - self.updateFilename(idx) - - def SizeSvalueChanged(self, SizeS): - positions = ["All positions"] - positions.extend([f"Position_{i + 1}" for i in range(SizeS)]) - self.posSelector.setItems(positions) - - def elidedRawFilename(self): - n = 31 - idx = int((n - 3) / 2) - if len(self.rawFilename) > 21: - elidedText = f"{self.rawFilename[:idx]}...{self.rawFilename[-idx:]}" - else: - elidedText = self.rawFilename - return elidedText - - def updateFilename(self, idx): - chName = self.chNames_QLEs[idx].text() - chName = self.removeInvalidCharacters(chName) - if self.rawDataStruct == 2: - rawFilename = f"{self.rawFilename}_s{idx + 1}" - else: - rawFilename = self.rawFilename - - ext = "h5" if self.to_h5_radiobutton.isChecked() else "tif" - - rawFilename = self.elidedRawFilename() - - filenameLabel = self.filename_QLabels[idx] - if self.addImageName_QCB.isChecked(): - self.ImageName = self.removeInvalidCharacters(self.ImageName) - filename = f""" -

    - {rawFilename}_{self.ImageName}_{chName}.{ext} -

    - """ - fullFilename = f"{self.rawFilename}_{self.ImageName}_{chName}.{ext}" - else: - filename = f""" -

    - {rawFilename}_{chName}.{ext} -

    - """ - fullFilename = f"{self.rawFilename}_{chName}.{ext}" - filenameLabel.setToolTip(fullFilename) - filenameLabel.setText(filename) - - def checkChNames(self, text=""): - if self.sender() in self.chNames_QLEs: - idx = self.chNames_QLEs.index(self.sender()) - self.updateFilename(idx) - elif self.sender() in self.saveChannels_QCBs: - idx = self.saveChannels_QCBs.index(self.sender()) - self.updateFilename(idx) - - areChNamesValid = True - if len(self.chNames_QLEs) == 1: - LE1 = self.chNames_QLEs[0] - saveCh = self.saveChannels_QCBs[0].isChecked() - if not saveCh: - LE1.setStyleSheet("") - return areChNamesValid - - s1 = LE1.text() - if not s1: - self.setInvalidChName_StyleSheet(LE1) - areChNamesValid = False - else: - LE1.setStyleSheet("") - return areChNamesValid - - for LE1, LE2 in combinations(self.chNames_QLEs, 2): - s1 = LE1.text() - s2 = LE2.text() - LE1_idx = self.chNames_QLEs.index(LE1) - LE2_idx = self.chNames_QLEs.index(LE2) - saveCh1 = self.saveChannels_QCBs[LE1_idx].isChecked() - saveCh2 = self.saveChannels_QCBs[LE2_idx].isChecked() - if not s1 or not s2 or s1 == s2: - if not s1 and saveCh1: - self.setInvalidChName_StyleSheet(LE1) - areChNamesValid = False - else: - LE1.setStyleSheet("") - if not s2 and saveCh2: - self.setInvalidChName_StyleSheet(LE2) - areChNamesValid = False - else: - LE2.setStyleSheet("") - if s1 == s2 and saveCh1 and saveCh2: - self.setInvalidChName_StyleSheet(LE1) - self.setInvalidChName_StyleSheet(LE2) - areChNamesValid = False - else: - LE1.setStyleSheet("") - LE2.setStyleSheet("") - return areChNamesValid - - def hideShowTimeIncrement(self, value): - if self.TimeIncrement_DSB.isVisible() and value == 1: - self.readSampleImgDataAgain = True - - if not self.TimeIncrement_DSB.isVisible() and value > 1: - self.readSampleImgDataAgain = True - - if value > 1: - self.TimeIncrement_DSB.show() - self.TimeIncrementUnit_CB.show() - self.TimeIncrement_Label.show() - self.timeRangeToSaveWidget.show() - self.timeRangeToSaveWidget.label.show() - self.timeRangeToSaveWidget.setRange(1, value) - else: - self.TimeIncrement_DSB.hide() - self.TimeIncrementUnit_CB.hide() - self.TimeIncrement_Label.hide() - self.timeRangeToSaveWidget.hide() - self.timeRangeToSaveWidget.label.hide() - - def hideShowPhysicalSizeZ(self, value): - if value > 1: - self.PSZlabel.show() - self.PhysicalSizeZ_DSB.show() - self.PhysicalSizeZUnit_Label.show() - else: - self.PSZlabel.hide() - self.PhysicalSizeZ_DSB.hide() - self.PhysicalSizeZUnit_Label.hide() - self.readSampleImgDataAgain = True - - def updatePSUnit(self, unit): - self.PhysicalSizeYUnit_Label.setText(unit) - self.PhysicalSizeZUnit_Label.setText(unit) - - def warnRestart(self): - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = html_utils.paragraph(""" - Since you manually changed some of the metadata, this dialogue will now restart
    - because it needs to read the image data again.

    - Thank you for your patience. - """) - msg.warning(self, "Restart required", txt) - - def showChannelData(self, checked=False, idx=None): - if self.readSampleImgDataAgain: - # User changed SizeZ, SizeT, or SizeC --> we need to read sample - # image again - del self.sampleImgData - self.requestedReadingSampleImageDataAgain = True - self.sampleImgData = None - self.warnRestart() - self.getValues() - self.cancel = False - self.close() - return - - if idx is None: - idx = self.showChannelDataButtons.index(self.sender()) - dimsOrder = "ctz" - imgData = self.sampleImgData[dimsOrder][idx] - posData = myutils.utilClass() - posData.frame_i = 0 - sampleSizeT = 4 if self.SizeT_SB.value() >= 4 else self.SizeT_SB.value() - posData.SizeT = sampleSizeT - SizeZ = self.SizeZ_SB.value() - posData.SizeZ = 20 if SizeZ > 20 else SizeZ - posData.filename = f"{self.rawFilename}_C={idx}" - posData.segmInfo_df = pd.DataFrame( - { - "filename": [posData.filename] * sampleSizeT, - "frame_i": range(sampleSizeT), - "which_z_proj_gui": ["single z-slice"] * sampleSizeT, - "z_slice_used_gui": [int(posData.SizeZ / 2)] * sampleSizeT, - } - ).set_index(["filename", "frame_i"]) - path_li = os.path.normpath(self.rawFilePath).split(os.sep) - posData.relPath = f"{f'{os.sep}'.join(path_li[-3:1])}" - posData.relPath = f"{posData.relPath}{os.sep}{posData.filename}" - if sampleSizeT == 1: - posData.img_data = [imgData] # single frame data - else: - posData.img_data = imgData - - if self.imageViewer is not None: - self.imageViewer.close() - - self.imageViewer = imageViewer( - posData=posData, isSigleFrame=False, enableOverlay=False - ) - self.imageViewer.channelIndex = idx - self.imageViewer.update_img() - self.imageViewer.sigClosed.connect(self.imageViewerClosed) - self.imageViewer.show() - - def imageViewerClosed(self): - self.imageViewer = None - - def addRemoveChannels(self, value): - self.readSampleImgDataAgain = True - currentSizeC = len(self.chNames_QLEs) - DeltaChannels = abs(value - currentSizeC) - ext = "h5" if self.to_h5_radiobutton.isChecked() else "tif" - if value > currentSizeC: - for c in range(currentSizeC, currentSizeC + DeltaChannels): - chName_QLE = QLineEdit() - chName_QLE.setStyleSheet("") - chName_QLE.setAlignment(Qt.AlignCenter) - chName_QLE.setText(f"channel_{c}") - chName_QLE.textChanged.connect(self.checkChNames) - - txt = f"Channel {c} name: " - label = QLabel(txt) - - filenameDescLabel = QLabel(f"e.g., filename for channel {c}: ") - - chName = chName_QLE.text() - rawFilename = self.elidedRawFilename() - filenameLabel = QLabel(f""" -

    {rawFilename}_{chName}.{ext}

    - """) - filenameLabel.setToolTip(f"{self.rawFilename}_{chName}.{ext}") - - checkBox = QCheckBox("Save this channel") - checkBox.setChecked(True) - checkBox.stateChanged.connect(self.saveCh_checkBox_cb) - - self.channelNameLayouts[0].addWidget(label, alignment=Qt.AlignRight) - self.channelNameLayouts[0].addWidget( - filenameDescLabel, alignment=Qt.AlignRight - ) - self.channelNameLayouts[1].addWidget(chName_QLE) - self.channelNameLayouts[1].addWidget( - filenameLabel, alignment=Qt.AlignCenter - ) - - self.channelNameLayouts[2].addWidget(checkBox) - self.channelNameLayouts[2].addWidget(QLabel()) - - showChannelDataButton = QPushButton() - showChannelDataButton.setIcon(QIcon(":eye-plus.svg")) - showChannelDataButton.clicked.connect(self.showChannelData) - self.channelNameLayouts[3].addWidget(showChannelDataButton) - if self.sampleImgData is None: - showChannelDataButton.setDisabled(True) - - self.chNames_QLEs.append(chName_QLE) - self.saveChannels_QCBs.append(checkBox) - self.filename_QLabels.append(filenameLabel) - self.showChannelDataButtons.append(showChannelDataButton) - - emWavelen_DSB = QDoubleSpinBox() - emWavelen_DSB.setAlignment(Qt.AlignCenter) - emWavelen_DSB.setMaximum(2147483647.0) - emWavelen_DSB.setSingleStep(0.001) - emWavelen_DSB.setDecimals(2) - emWavelen_DSB.setValue(500.0) - unit = QLabel("nm") - unit.setStyleSheet("font-size:13px; padding:5px 0px 2px 0px;") - - txt = f"Channel {c} emission wavelength: " - label = QLabel(txt) - self.channelEmWLayouts[0].addWidget(label, alignment=Qt.AlignRight) - self.channelEmWLayouts[1].addWidget(emWavelen_DSB) - self.channelEmWLayouts[2].addWidget(unit) - self.emWavelens_DSBs.append(emWavelen_DSB) - else: - for c in range(currentSizeC, currentSizeC + DeltaChannels): - idx = (c - 1) * 2 - label1 = self.channelNameLayouts[0].itemAt(idx).widget() - label2 = self.channelNameLayouts[0].itemAt(idx + 1).widget() - chName_QLE = self.channelNameLayouts[1].itemAt(idx).widget() - filename_L = self.channelNameLayouts[1].itemAt(idx + 1).widget() - checkBox = self.channelNameLayouts[2].itemAt(idx).widget() - dummyLabel = self.channelNameLayouts[2].itemAt(idx + 1).widget() - showButton = self.showChannelDataButtons[-1] - showButton.clicked.disconnect() - - self.channelNameLayouts[0].removeWidget(label1) - self.channelNameLayouts[0].removeWidget(label2) - self.channelNameLayouts[1].removeWidget(chName_QLE) - self.channelNameLayouts[1].removeWidget(filename_L) - self.channelNameLayouts[2].removeWidget(checkBox) - self.channelNameLayouts[2].removeWidget(dummyLabel) - self.channelNameLayouts[3].removeWidget(showButton) - - self.chNames_QLEs.pop(-1) - self.saveChannels_QCBs.pop(-1) - self.filename_QLabels.pop(-1) - self.showChannelDataButtons.pop(-1) - - label = self.channelEmWLayouts[0].itemAt(c - 1).widget() - emWavelen_DSB = self.channelEmWLayouts[1].itemAt(c - 1).widget() - unit = self.channelEmWLayouts[2].itemAt(c - 1).widget() - self.channelEmWLayouts[0].removeWidget(label) - self.channelEmWLayouts[1].removeWidget(emWavelen_DSB) - self.channelEmWLayouts[2].removeWidget(unit) - self.emWavelens_DSBs.pop(-1) - - self.adjustSize() - - def ok_cb(self, event): - areChNamesValid = self.checkChNames() - if not areChNamesValid: - err_msg = html_utils.paragraph( - "Channel names cannot be empty or equal to each other." - "

    " - "Insert a unique text for each channel name." - ) - msg = widgets.myMessageBox() - msg.critical(self, "Invalid channel names", err_msg) - return - - self.getValues() - self.convertUnits() - - if self.sender() == self.trustButton: - self.trust = True - elif self.sender() == self.overWriteButton: - self.overWrite = True - - self.cancel = False - self.close() - - def getValues(self): - self.LensNA = self.LensNA_DSB.value() - self.SizeT = self.SizeT_SB.value() - self.SizeZ = self.SizeZ_SB.value() - self.SizeC = self.SizeC_SB.value() - self.SizeS = self.SizeS_SB.value() - self.timeRangeToSave = self.timeRangeToSaveWidget.range() - self.TimeIncrement = self.TimeIncrement_DSB.value() - self.PhysicalSizeX = self.PhysicalSizeX_DSB.value() - self.PhysicalSizeY = self.PhysicalSizeY_DSB.value() - self.PhysicalSizeZ = self.PhysicalSizeZ_DSB.value() - self.to_h5 = self.to_h5_radiobutton.isChecked() - if hasattr(self, "posSelector"): - self.selectedPos = self.posSelector.selectedItemsText() - else: - self.selectedPos = ["All Positions"] - self.chNames = [] - if hasattr(self, "addImageName_QCB"): - self.addImageName = self.addImageName_QCB.isChecked() - else: - self.addImageName = False - self.saveChannels = [] - for LE, QCB in zip(self.chNames_QLEs, self.saveChannels_QCBs): - s = LE.text() - s = "".join(c if c.isalnum() or c == "_" or c == "" else "_" for c in s) - trim_ = s.endswith("_") - while trim_: - s = s[:-1] - trim_ = s.endswith("_") - self.chNames.append(s) - self.saveChannels.append(QCB.isChecked()) - self.emWavelens = [DSB.value() for DSB in self.emWavelens_DSBs] - - def convertUnits(self): - timeUnit = self.TimeIncrementUnit_CB.currentText() - if timeUnit == "ms": - self.TimeIncrement /= 1000 - elif timeUnit == "minutes": - self.TimeIncrement *= 60 - elif timeUnit == "hours": - self.TimeIncrement *= 3600 - - PhysicalSizeUnit = self.PhysicalSizeUnit_CB.currentText() - if timeUnit == "nm": - self.PhysicalSizeX /= 1000 - self.PhysicalSizeY /= 1000 - self.PhysicalSizeZ /= 1000 - elif timeUnit == "mm": - self.PhysicalSizeX *= 1000 - self.PhysicalSizeY *= 1000 - self.PhysicalSizeZ *= 1000 - elif timeUnit == "cm": - self.PhysicalSizeX *= 1e4 - self.PhysicalSizeY *= 1e4 - self.PhysicalSizeZ *= 1e4 - - def cancel_cb(self, event): - self.cancel = True - self.close() - - def exec_(self): - self.show(block=True) - - def setSize(self): - h = self.SizeS_SB.height() - self.TimeIncrement_DSB.setMinimumHeight(h) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - self.setSize() - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class CellACDCTrackerParamsWin(QDialog): - def __init__(self, parent=None): - self.cancel = True - super().__init__(parent) - - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - self.setWindowTitle("Cell-ACDC tracker parameters") - - paramsLayout = QGridLayout() - paramsBox = QGroupBox() - - row = 0 - label = QLabel(html_utils.paragraph("Minimum overlap between objects")) - paramsLayout.addWidget(label, row, 0) - maxOverlapSpinbox = QDoubleSpinBox() - maxOverlapSpinbox.setAlignment(Qt.AlignCenter) - maxOverlapSpinbox.setMinimum(0) - maxOverlapSpinbox.setMaximum(1) - maxOverlapSpinbox.setSingleStep(0.1) - maxOverlapSpinbox.setValue(0.4) - self.maxOverlapSpinbox = maxOverlapSpinbox - paramsLayout.addWidget(maxOverlapSpinbox, row, 1) - infoButton = widgets.infoPushButton() - infoButton.clicked.connect(self.showInfo) - paramsLayout.addWidget(infoButton, row, 2) - paramsLayout.setColumnStretch(0, 0) - paramsLayout.setColumnStretch(1, 1) - paramsLayout.setColumnStretch(2, 0) - - cancelButton = widgets.cancelPushButton("Cancel") - okButton = widgets.okPushButton(" Ok ") - cancelButton.clicked.connect(self.cancel_cb) - okButton.clicked.connect(self.ok_cb) - - buttonsLayout = QHBoxLayout() - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(okButton) - - layout = QVBoxLayout() - infoText = html_utils.paragraph("Cell-ACDC tracker parameters") - infoLabel = QLabel(infoText) - layout.addWidget(infoLabel, alignment=Qt.AlignCenter) - layout.addSpacing(10) - paramsBox.setLayout(paramsLayout) - layout.addWidget(paramsBox) - layout.addSpacing(20) - layout.addLayout(buttonsLayout) - layout.addStretch(1) - self.setLayout(layout) - self.setFont(font) - - def showInfo(self): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - "Cell-ACDC tracker computes the percentage of overlap between " - "all the objects
    at frame n and all the " - "objects in previous frame n-1.

    " - "All objects with overlap less than " - "Minimum overlap between objects
    are considered " - "new objects.

    " - "Set this value to 0 if you want to force tracking of ALL the " - "objects
    in the previous frame (e.g., if cells move a lot " - "between frames)" - ) - msg.information(self, "Cell-ACDC tracker info", txt) - - def ok_cb(self, checked=False): - self.cancel = False - self.params = {"IoA_thresh": self.maxOverlapSpinbox.value()} - self.close() - - def cancel_cb(self, event): - self.cancel = True - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - super().show() - self.resize(int(self.width() * 1.3), self.height()) - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class BayesianTrackerParamsWin(QDialog): - def __init__(self, segmShape, parent=None, channels=None, currentChannelName=None): - self.cancel = True - super().__init__(parent) - - self.channels = channels - self.currentChannelName = currentChannelName - - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - self.setWindowTitle("Bayesian tracker parameters") - - paramsLayout = QGridLayout() - paramsBox = QGroupBox() - - row = 0 - this_path = os.path.dirname(os.path.abspath(__file__)) - default_model_path = os.path.join( - this_path, "trackers", "BayesianTracker", "model", "cell_config.json" - ) - label = QLabel(html_utils.paragraph("Model path")) - paramsLayout.addWidget(label, row, 0) - modelPathLineEdit = QLineEdit() - start_dir = "" - if os.path.exists(default_model_path): - start_dir = os.path.dirname(default_model_path) - modelPathLineEdit.setText(default_model_path) - self.modelPathLineEdit = modelPathLineEdit - paramsLayout.addWidget(modelPathLineEdit, row, 1) - browseButton = widgets.browseFileButton( - title="Select Bayesian Tracker model file", - ext={"JSON Config": (".json",)}, - start_dir=start_dir, - ) - browseButton.sigPathSelected.connect(self.onPathSelected) - paramsLayout.addWidget(browseButton, row, 2, alignment=Qt.AlignLeft) - - if self.channels is not None: - row += 1 - label = QLabel(html_utils.paragraph("Intensity image channel: ")) - paramsLayout.addWidget(label, row, 0) - items = ["None", *self.channels] - self.channelCombobox = widgets.QCenteredComboBox() - self.channelCombobox.addItems(items) - paramsLayout.addWidget(self.channelCombobox, row, 1) - if self.currentChannelName is not None: - self.channelCombobox.setCurrentText(self.currentChannelName) - - row += 1 - label = QLabel(html_utils.paragraph("Features")) - paramsLayout.addWidget(label, row, 0) - selectFeaturesButton = widgets.setPushButton("Select features") - paramsLayout.addWidget(selectFeaturesButton, row, 1) - self.features = [] - selectFeaturesButton.clicked.connect(self.selectFeatures) - - row += 1 - label = QLabel(html_utils.paragraph("Verbose")) - paramsLayout.addWidget(label, row, 0) - verboseToggle = widgets.Toggle() - verboseToggle.setChecked(True) - self.verboseToggle = verboseToggle - paramsLayout.addWidget(verboseToggle, row, 1, alignment=Qt.AlignCenter) - - row += 1 - label = QLabel(html_utils.paragraph("Run optimizer")) - paramsLayout.addWidget(label, row, 0) - optimizeToggle = widgets.Toggle() - optimizeToggle.setChecked(True) - self.optimizeToggle = optimizeToggle - paramsLayout.addWidget(optimizeToggle, row, 1, alignment=Qt.AlignCenter) - - row += 1 - label = QLabel(html_utils.paragraph("Max search radius")) - paramsLayout.addWidget(label, row, 0) - maxSearchRadiusSpinbox = QSpinBox() - maxSearchRadiusSpinbox.setAlignment(Qt.AlignCenter) - maxSearchRadiusSpinbox.setMinimum(1) - maxSearchRadiusSpinbox.setMaximum(2147483647) - maxSearchRadiusSpinbox.setValue(50) - self.maxSearchRadiusSpinbox = maxSearchRadiusSpinbox - self.maxSearchRadiusSpinbox.setDisabled(True) - paramsLayout.addWidget(maxSearchRadiusSpinbox, row, 1) - - row += 1 - Z, Y, X = segmShape - label = QLabel(html_utils.paragraph("Tracking volume")) - paramsLayout.addWidget(label, row, 0) - volumeLineEdit = QLineEdit() - defaultVol = f" (0, {X}), (0, {Y}) " - if Z > 1: - defaultVol = f"{defaultVol}, (0, {Z}) " - volumeLineEdit.setText(defaultVol) - volumeLineEdit.setAlignment(Qt.AlignCenter) - self.volumeLineEdit = volumeLineEdit - paramsLayout.addWidget(volumeLineEdit, row, 1) - - row += 1 - label = QLabel(html_utils.paragraph("Interactive mode step size")) - paramsLayout.addWidget(label, row, 0) - stepSizeSpinbox = QSpinBox() - stepSizeSpinbox.setAlignment(Qt.AlignCenter) - stepSizeSpinbox.setMinimum(1) - stepSizeSpinbox.setMaximum(2147483647) - stepSizeSpinbox.setValue(100) - self.stepSizeSpinbox = stepSizeSpinbox - paramsLayout.addWidget(stepSizeSpinbox, row, 1) - - row += 1 - label = QLabel(html_utils.paragraph("Update method")) - paramsLayout.addWidget(label, row, 0) - updateMethodCombobox = QComboBox() - updateMethodCombobox.addItems(["EXACT", "APPROXIMATE"]) - self.updateMethodCombobox = updateMethodCombobox - self.updateMethodCombobox.currentTextChanged.connect(self.methodChanged) - paramsLayout.addWidget(updateMethodCombobox, row, 1) - - cancelButton = widgets.cancelPushButton("Cancel") - okButton = widgets.okPushButton(" Ok ") - cancelButton.clicked.connect(self.cancel_cb) - okButton.clicked.connect(self.ok_cb) - - buttonsLayout = QHBoxLayout() - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(okButton) - - layout = QVBoxLayout() - infoText = html_utils.paragraph("Bayesian Tracker parameters") - infoLabel = QLabel(infoText) - layout.addWidget(infoLabel, alignment=Qt.AlignCenter) - layout.addSpacing(10) - paramsBox.setLayout(paramsLayout) - layout.addWidget(paramsBox) - - url = "https://btrack.readthedocs.io/en/latest/index.html" - moreInfoText = html_utils.paragraph( - "Find more info on the Bayesian Tracker's " - f'home page' - ) - moreInfoLabel = QLabel(moreInfoText) - moreInfoLabel.setOpenExternalLinks(True) - layout.addWidget(moreInfoLabel, alignment=Qt.AlignCenter) - - layout.addSpacing(20) - layout.addLayout(buttonsLayout) - layout.addStretch(1) - self.setLayout(layout) - self.setFont(font) - - def selectFeatures(self): - features = measurements.get_btrack_features() - selectWin = widgets.QDialogListbox( - "Select features", - "Select features to use for tracking:\n", - features, - multiSelection=True, - parent=self, - includeSelectionHelp=True, - ) - for i in range(selectWin.listBox.count()): - item = selectWin.listBox.item(i) - if item.text() in self.features: - item.setSelected(True) - selectWin.exec_() - if selectWin.cancel: - return - self.features = selectWin.selectedItemsText - - def methodChanged(self, method): - if method == "APPROXIMATE": - self.maxSearchRadiusSpinbox.setDisabled(False) - else: - self.maxSearchRadiusSpinbox.setDisabled(True) - - def onPathSelected(self, path): - self.modelPathLineEdit.setText(path) - - def ok_cb(self, checked=False): - self.cancel = False - try: - m = re.findall(r"\((\d+), *(\d+)\)", self.volumeLineEdit.text()) - if len(m) < 2: - raise - self.volume = tuple([(int(start), int(end)) for start, end in m]) - if len(self.volume) == 2: - self.volume = (self.volume[0], self.volume[1], (-1e5, 1e5)) - except Exception as e: - self.warnNotAcceptedVolume() - return - - if not os.path.exists(self.modelPathLineEdit.text()): - self.warnNotVaidPath() - return - - self.intensityImageChannel = None - self.verbose = self.verboseToggle.isChecked() - self.max_search_radius = self.maxSearchRadiusSpinbox.value() - self.update_method = self.updateMethodCombobox.currentText() - self.model_path = os.path.normpath(self.modelPathLineEdit.text()) - self.params = { - "model_path": self.model_path, - "verbose": self.verbose, - "volume": self.volume, - "max_search_radius": self.max_search_radius, - "update_method": self.update_method, - "step_size": self.stepSizeSpinbox.value(), - "optimize": self.optimizeToggle.isChecked(), - "features": self.features, - } - if self.channels is not None: - if self.channelCombobox.currentText() != "None": - self.intensityImageChannel = self.channelCombobox.currentText() - self.close() - - def warnNotVaidPath(self): - url = "https://github.com/lowe-lab-ucl/segment-classify-track/tree/main/models" - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - "The model configuration file path

    " - f"{self.modelPathLineEdit.text()}

    " - "does not exist.

    " - "You can find some pre-configured models " - f'here.' - ) - msg.critical(self, "Invalid volume", txt) - - def warnNotAcceptedVolume(self): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - f"{self.volumeLineEdit.text()} is not a valid volume!

    " - "Valid volume is for example (0, 2048), (0, 2048)
    " - "for 2D segmentation or (0, 2048), (0, 2048), (0, 2048)
    " - "for 3D segmentation." - ) - msg.critical(self, "Invalid volume", txt) - - def cancel_cb(self, event): - self.cancel = True - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - super().show() - self.resize(int(self.width() * 1.3), self.height()) - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class DeltaTrackerParamsWin(QDialog): - def __init__(self, posData=None, parent=None): - self.cancel = True - super().__init__(parent) - - self.setWindowFlags(Qt.Dialog | Qt.WindowStaysOnTopHint) - self.setWindowTitle("Delta tracker parameters") - - paramsLayout = QGridLayout() - paramsBox = QGroupBox() - - row = 0 - this_path = os.path.dirname(os.path.abspath(__file__)) - default_model_path = this_path - - label = QLabel(html_utils.paragraph("Original Images path")) - paramsLayout.addWidget(label, row, 0) - modelPathLineEdit = QLineEdit() - start_dir = "" - if os.path.exists(default_model_path): - start_dir = os.path.dirname(default_model_path) - modelPathLineEdit.setText(default_model_path) - self.modelPathLineEdit = modelPathLineEdit - paramsLayout.addWidget(modelPathLineEdit, row, 1) - browseButton = widgets.browseFileButton( - title="Select Original Images", ext={"TIFF": (".tif",)}, start_dir=start_dir - ) - if posData is not None: - modelPathLineEdit.setText(posData.imgPath) - browseButton.sigPathSelected.connect(self.onPathSelected) - paramsLayout.addWidget(browseButton, row, 2, alignment=Qt.AlignLeft) - - row += 1 - label = QLabel(html_utils.paragraph("Model Type")) - paramsLayout.addWidget(label, row, 0) - updateMethodCombobox = QComboBox() - updateMethodCombobox.addItems(["2D", "mothermachine"]) - self.model_type = "2D" - self.updateMethodCombobox = updateMethodCombobox - self.updateMethodCombobox.currentTextChanged.connect(self.methodChanged) - paramsLayout.addWidget(updateMethodCombobox, row, 1) - - row += 1 - label = QLabel(html_utils.paragraph("Single Mother Machine Chamber?")) - paramsLayout.addWidget(label, row, 0) - chamberToggle = widgets.Toggle() - chamberToggle.setChecked(True) - self.chamberToggle = chamberToggle - paramsLayout.addWidget(chamberToggle, row, 1, alignment=Qt.AlignCenter) - - row += 1 - label = QLabel(html_utils.paragraph("Verbose")) - paramsLayout.addWidget(label, row, 0) - verboseToggle = widgets.Toggle() - verboseToggle.setChecked(True) - self.verboseToggle = verboseToggle - paramsLayout.addWidget(verboseToggle, row, 1, alignment=Qt.AlignCenter) - - row += 1 - label = QLabel(html_utils.paragraph("Legacy Save (.mat)")) - paramsLayout.addWidget(label, row, 0) - legacyToggle = widgets.Toggle() - legacyToggle.setChecked(False) - self.legacyToggle = legacyToggle - paramsLayout.addWidget(legacyToggle, row, 1, alignment=Qt.AlignCenter) - - row += 1 - label = QLabel(html_utils.paragraph("Pickle (.pkl)")) - paramsLayout.addWidget(label, row, 0) - pickleToggle = widgets.Toggle() - pickleToggle.setChecked(False) - self.pickleToggle = pickleToggle - paramsLayout.addWidget(pickleToggle, row, 1, alignment=Qt.AlignCenter) - - row += 1 - label = QLabel(html_utils.paragraph("Movie (.mp4) *only for 2D images")) - paramsLayout.addWidget(label, row, 0) - movieToggle = widgets.Toggle() - movieToggle.setChecked(False) - self.movieToggle = movieToggle - paramsLayout.addWidget(movieToggle, row, 1, alignment=Qt.AlignCenter) - - cancelButton = widgets.cancelPushButton("Cancel") - okButton = widgets.okPushButton(" Ok ") - cancelButton.clicked.connect(self.cancel_cb) - okButton.clicked.connect(self.ok_cb) - - buttonsLayout = QHBoxLayout() - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(okButton) - - layout = QVBoxLayout() - infoText = html_utils.paragraph("Delta Tracker parameters") - infoLabel = QLabel(infoText) - layout.addWidget(infoLabel, alignment=Qt.AlignCenter) - layout.addSpacing(10) - paramsBox.setLayout(paramsLayout) - layout.addWidget(paramsBox) - - url = "https://delta.readthedocs.io/en/latest/" - moreInfoText = html_utils.paragraph( - f'Find more info on Delta Tracker\'s home page' - ) - moreInfoLabel = QLabel(moreInfoText) - moreInfoLabel.setOpenExternalLinks(True) - layout.addWidget(moreInfoLabel, alignment=Qt.AlignCenter) - - layout.addSpacing(20) - layout.addLayout(buttonsLayout) - layout.addStretch(1) - self.setLayout(layout) - self.setFont(font) - - def methodChanged(self, method): - if method == "mothermachine": - self.model_type = "mothermachine" - - def onPathSelected(self, path): - self.modelPathLineEdit.setText(path) - - def ok_cb(self, checked=False): - self.cancel = False - - if not os.path.exists(self.modelPathLineEdit.text()): - self.warnNotVaidPath() - return - - self.verbose = self.verboseToggle.isChecked() - self.legacy = self.legacyToggle.isChecked() - self.pickle = self.pickleToggle.isChecked() - self.movie = self.movieToggle.isChecked() - self.chamber = self.chamberToggle.isChecked() - self.model_path = os.path.normpath(self.modelPathLineEdit.text()) - self.params = { - "original_images_path": self.model_path, - "verbose": self.verbose, - "legacy": self.legacy, - "pickle": self.pickle, - "movie": self.movie, - "model_type": self.model_type, - "single mothermachine chamber": self.chamber, - } - self.close() - - def cancel_cb(self, event): - self.cancel = True - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - super().show() - self.resize(int(self.width() * 1.3), self.height()) - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class QDialogWorkerProgress(QDialog): - sigClosed = Signal(bool) - - def __init__( - self, - title="Progress", - infoTxt="", - showInnerPbar=False, - pbarDesc="", - parent=None, - ): - self.workerFinished = False - self.aborted = False - self.clickCount = 0 - super().__init__(parent) - - abort_text = "Option+Command+C" if is_mac else "Ctrl+Alt+C" - self.abort_text = abort_text - - self.setWindowTitle(f"{title} ({abort_text} to abort)") - self.setWindowFlags(Qt.Window) - - mainLayout = QVBoxLayout() - pBarLayout = QGridLayout() - - if infoTxt: - infoLabel = QLabel(infoTxt) - mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) - - self.progressLabel = QLabel(pbarDesc) - - self.mainPbar = widgets.ProgressBarWithETA(self) - self.mainPbar.setValue(0) - pBarLayout.addWidget(self.mainPbar, 0, 0) - pBarLayout.addWidget(self.mainPbar.ETA_label, 0, 1) - - self.innerPbar = widgets.ProgressBarWithETA(self) - self.innerPbar.setValue(0) - pBarLayout.addWidget(self.innerPbar, 1, 0) - pBarLayout.addWidget(self.innerPbar.ETA_label, 1, 1) - if showInnerPbar: - self.innerPbar.show() - else: - self.innerPbar.hide() - - self.logConsole = widgets.QLogConsole() - - mainLayout.addWidget(self.progressLabel) - mainLayout.addLayout(pBarLayout) - mainLayout.addWidget(self.logConsole) - - self.setLayout(mainLayout) - # self.setModal(True) - - def keyPressEvent(self, event): - isCtrlAlt = event.modifiers() == (Qt.ControlModifier | Qt.AltModifier) - if isCtrlAlt and event.key() == Qt.Key_C: - doAbort = self.askAbort() - if doAbort: - self.aborted = True - self.workerFinished = True - self.close() - - def askAbort(self): - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - Aborting with {self.abort_text} to abort is - not safe.

    - The system status cannot be predicted and - it will require a restart.

    - Are you sure you want to abort? - """) - yesButton, noButton = msg.critical( - self, "Are you sure you want to abort?", txt, buttonsTexts=("Yes", "No") - ) - return msg.clickedButton == yesButton - - def closeEvent(self, event): - if not self.workerFinished: - event.ignore() - return - - self.sigClosed.emit(self.aborted) - - def log(self, text): - self.logConsole.append(text) - - def show(self, app): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - QDialog.show(self) - screen = app.primaryScreen() - screenWidth = screen.size().width() - screenHeight = screen.size().height() - parentGeometry = self.parent().geometry() - mainWinLeft, mainWinWidth = parentGeometry.left(), parentGeometry.width() - mainWinTop, mainWinHeight = parentGeometry.top(), parentGeometry.height() - mainWinCenterX = int(mainWinLeft + mainWinWidth / 2) - mainWinCenterY = int(mainWinTop + mainWinHeight / 2) - - width = int(screenWidth / 3) - width = width if self.width() < width else self.width() - height = int(screenHeight / 3) - left = int(mainWinCenterX - width / 2) - left = left if left >= 0 else 0 - top = int(mainWinCenterY - height / 2) - - self.setGeometry(left, top, width, height) - - -class QDialogCombobox(QDialog): - def __init__( - self, - title, - ComboBoxItems, - informativeText, - CbLabel="Select value: ", - parent=None, - defaultChannelName=None, - iconPixmap=None, - centeredCombobox=False, - ): - self.cancel = True - self.selectedItemText = "" - self.selectedItemIdx = None - super().__init__(parent=parent) - self.setWindowTitle(title) - - mainLayout = QVBoxLayout() - infoLayout = QHBoxLayout() - topLayout = QHBoxLayout() - bottomLayout = QHBoxLayout() - - self.mainLayout = mainLayout - - if iconPixmap is not None: - label = QLabel() - # padding: top, left, bottom, right - # label.setStyleSheet("padding:5px 0px 12px 0px;") - label.setPixmap(iconPixmap) - infoLayout.addWidget(label) - - if informativeText: - infoLabel = QLabel(informativeText) - infoLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) - - if CbLabel: - label = QLabel(CbLabel) - topLayout.addWidget(label, alignment=Qt.AlignRight) - - if centeredCombobox: - combobox = widgets.QCenteredComboBox() - else: - combobox = QComboBox() - combobox.addItems(ComboBoxItems) - if defaultChannelName is not None and defaultChannelName in ComboBoxItems: - combobox.setCurrentText(defaultChannelName) - self.ComboBox = combobox - topLayout.addWidget(combobox) - topLayout.setContentsMargins(0, 10, 0, 0) - - okButton = widgets.okPushButton("Ok") - - cancelButton = widgets.cancelPushButton("Cancel") - - bottomLayout.addStretch(1) - bottomLayout.addWidget(cancelButton) - bottomLayout.addSpacing(20) - bottomLayout.addWidget(okButton) - bottomLayout.setContentsMargins(0, 10, 0, 0) - - mainLayout.addLayout(infoLayout) - mainLayout.addLayout(topLayout) - mainLayout.addLayout(bottomLayout) - self.setLayout(mainLayout) - - # self.setModal(True) - - # Connect events - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - self.loop = None - - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - self.setFont(font) - - def ok_cb(self, checked=False): - self.cancel = False - self.selectedItemText = self.ComboBox.currentText() - self.selectedItemIdx = self.ComboBox.currentIndex() - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - QDialog.show(self) - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class MultiTimePointFilePattern(QBaseDialog): - def __init__(self, fileName, folderPath, readPatternFunc=None, parent=None): - super().__init__(parent) - - self.setWindowTitle("File name pattern") - self.cancel = True - self.additionalChannelWidgets = {} - - mainLayout = QVBoxLayout() - self.readPatternFunc = readPatternFunc - - infoText = html_utils.paragraph(""" - The image files for each time-point must be named with the following pattern:

    - position_channel_timepoint -

    - For example a file with name "pos1_GFP_1.tif" would be the first time-point of the channell GFP
    - and position called pos1.

    - The Position number will be determined by alphabetically sorting - all the image files.

    - Please, provide the channel names below. - Optionally, you can provide a basename
    - that will be pre-pended to the name of all created files.

    - You can also provide a folder path containing the segmentation masks file.
    - These files MUST be named exactly as the raw files. -
    - """) - - noteLayout = QHBoxLayout() - noteText = html_utils.paragraph(""" - Channels do not need to have the same number of frames, - however, Cell-ACDC will place
    - the frames at the right frame number - (given by timepoint number at the end
    - of the filename) and it will fill missing frames with zeros. - """) - noteLayout.addWidget( - QLabel(html_utils.to_admonition(noteText)), - # alignment=(Qt.AlignTop | Qt.AlignRight) - ) - - mainLayout.addWidget(QLabel(infoText)) - mainLayout.addLayout(noteLayout) - noteLayout.setStretch(0, 0) - noteLayout.setStretch(1, 1) - - label = QLabel( - html_utils.paragraph(f"Sample file name: {fileName}") - ) - mainLayout.addWidget(label, alignment=Qt.AlignCenter) - mainLayout.addSpacing(5) - - channelName = "" - posName = "" - frameNumber = None - if readPatternFunc is not None: - posName, frameNumber, channelName = readPatternFunc(fileName) - - formLayout = QGridLayout() - - ncols = 3 - self.vLayouts = [QVBoxLayout() for _ in range(ncols)] - for j, l in enumerate(self.vLayouts): - formLayout.addLayout(l, 0, j) - - row = 0 - items = QLabel("Position name: "), widgets.ReadOnlyLineEdit(), QLabel() - label, self.posNameEntry, button = items - self.posNameEntry.setAlignment(Qt.AlignCenter) - self.posNameEntry.setText(str(posName)) - for j, w in enumerate(items): - self.vLayouts[j].addWidget(w) - - row += 1 - items = (QLabel("Frame number name: "), widgets.ReadOnlyLineEdit(), QLabel()) - self.frameNumberEntry = items[1] - self.frameNumberEntry.setText(str(frameNumber)) - self.frameNumberEntry.setAlignment(Qt.AlignCenter) - for j, w in enumerate(items): - self.vLayouts[j].addWidget(w) - - row += 1 - self.channelNameLE = widgets.alphaNumericLineEdit() - items = ( - QLabel("Channel_1 name: "), - self.channelNameLE, - widgets.addPushButton(" Add channel"), - ) - self.addChannelButton = items[2] - self.addChannelButton._row = row - self.channelNameLE.setAlignment(Qt.AlignCenter) - self.channelNameLE.setText(channelName) - for j, w in enumerate(items): - self.vLayouts[j].addWidget(w) - - row += 1 - items = ( - QLabel("Basename (optional): "), - widgets.alphaNumericLineEdit(), - QLabel(), - ) - label, self.baseNameLE, button = items - self.baseNameLE.setAlignment(Qt.AlignCenter) - for j, w in enumerate(items): - self.vLayouts[j].addWidget(w) - - row += 1 - items = QLabel("File will be saved as: "), QLineEdit(), QLabel() - label, self.relPathEntry, button = items - self.relPathEntry.setAlignment(Qt.AlignCenter) - for j, w in enumerate(items): - self.vLayouts[j].addWidget(w) - - row += 1 - items = ( - QLabel("Segmentation masks folder path: "), - widgets.ElidingLineEdit(), - widgets.browseFileButton( - "Browse...", - title="Select folder containing segmentation masks", - start_dir=folderPath, - openFolder=True, - ), - ) - label, self.segmFolderPathEntry, button = items - button.sigPathSelected.connect(self.segmFolderpathSelected) - self.segmFolderPathEntry.setAlignment(Qt.AlignCenter) - for j, w in enumerate(items): - self.vLayouts[j].addWidget(w) - - self.formLayout = formLayout - - self.updateRelativePath() - - self.channelNameLE.textChanged.connect(self.updateRelativePath) - self.baseNameLE.textChanged.connect(self.updateRelativePath) - self.addChannelButton.clicked.connect(self.addChannel) - - mainLayout.addLayout(formLayout) - - buttonsLayout = widgets.CancelOkButtonsLayout() - showInFileManagerButton = widgets.showInFileManagerButton( - myutils.get_open_filemaneger_os_string() - ) - buttonsLayout.insertWidget(3, showInFileManagerButton) - func = partial(myutils.showInExplorer, folderPath) - showInFileManagerButton.clicked.connect(func) - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - mainLayout.addStretch() - - self.setLayout(mainLayout) - - self.setFont(font) - - def segmFolderpathSelected(self, path): - self.segmFolderPathEntry.setText(path) - - def addChannel(self): - self.addChannelButton._row += 1 - row = self.addChannelButton._row - - channel_idx = len(self.additionalChannelWidgets) - items = ( - QLabel(f"Channel_{channel_idx + 1} name: "), - widgets.alphaNumericLineEdit(), - widgets.subtractPushButton("Remove channel"), - ) - label, lineEdit, button = items - lineEdit.setAlignment(Qt.AlignCenter) - button.clicked.connect(self.removeChannel) - button._row = row - for j, w in enumerate(items): - self.vLayouts[j].insertWidget(row, w) - - self.additionalChannelWidgets[row] = items - lineEdit.setFocus() - - def removeChannel(self): - row = self.sender()._row - for j, w in enumerate(self.additionalChannelWidgets[row]): - self.vLayouts[j].removeWidget(w) - - self.additionalChannelWidgets.pop(row) - self.addChannelButton._row -= 1 - - def checkChannelNames(self): - allChannels = [self.channelNameLE.text()] - allChannels.extend( - [w[1].text() for w in self.additionalChannelWidgets.values()] - ) - for ch1, ch2 in combinations(allChannels, 2): - if ch1 == ch2: - break - if not ch1 or not ch2: - break - else: - # Channel names are fine - return allChannels - - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - txt = html_utils.paragraph(""" - Some channel names are empty or not different from each other. - """) - msg.critical(self, "Select two or more items", txt) - return None - - def updateRelativePath(self, text=""): - posName = self.posNameEntry.text() - frameNumber = self.frameNumberEntry.text() - channelName = self.channelNameLE.text() - basename = self.baseNameLE.text() - if basename: - filename = f"{basename}_{posName}_{channelName}.tif" - else: - filename = f"{posName}_{channelName}.tif" - relPath = f"...{os.sep}Position_1{os.sep}Images{os.sep}{filename}" - self.relPathEntry.setText(relPath) - - def ok_cb(self): - allChannels = self.checkChannelNames() - if allChannels is None: - return - self.allChannels = allChannels - self.basename = self.baseNameLE.text() - self.segmFolderPath = self.segmFolderPathEntry.text() - self.cancel = False - self.close() - - def showEvent(self, event) -> None: - self.channelNameLE.setFocus() - - -class OrderableListWidgetDialog(QBaseDialog): - def __init__( - self, items, title="Select items", infoTxt="", helpText="", parent=None - ): - super().__init__(parent) - - self.selectedItemsText = [] - - self.cancel = True - self.setWindowTitle(title) - - mainLayout = QVBoxLayout() - self.helpText = helpText - - if infoTxt: - mainLayout.addWidget(QLabel(html_utils.paragraph(infoTxt))) - - self.listWidget = widgets.OrderableList() - self.listWidget.addItems(items) - - buttonsLayout = widgets.CancelOkButtonsLayout() - if helpText: - helpButton = widgets.helpPushButton("Help...") - buttonsLayout.insertWidget(3, helpButton) - helpButton.clicked.connect(self.showHelp) - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addWidget(self.listWidget) - mainLayout.addSpacing(10) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def showHelp(self): - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = html_utils.paragraph(self.helpText) - msg.information(self, "Select tables help", txt) - - def ok_cb(self): - self.cancel = False - self.selectedItemsText = [None] * len(self.listWidget.selectedItems()) - for itemW in self.listWidget.selectedItems(): - idx = int(itemW._nrWidget.currentText()) - 1 - if idx >= len(self.selectedItemsText): - idx = len(self.selectedItemsText) - 1 - self.selectedItemsText[idx] = itemW._text - self.close() - - -class QDialogAutomaticThresholding(QBaseDialog): - def __init__(self, parent=None, isSegm3D=True): - super().__init__(parent) - - self.cancel = True - - self.setWindowTitle("Automatic thresholding parameters") - - layout = QVBoxLayout() - formLayout = QGridLayout() - buttonsLayout = QHBoxLayout() - - row = 0 - self.sigmaGaussSpinbox = QDoubleSpinBox() - self.sigmaGaussSpinbox.setValue(1) - self.sigmaGaussSpinbox.setMaximum(2**31) - self.sigmaGaussSpinbox.setAlignment(Qt.AlignCenter) - formLayout.addWidget( - QLabel("Gaussian filter sigma (0 to ignore): "), - row, - 0, - alignment=Qt.AlignRight, - ) - formLayout.addWidget(self.sigmaGaussSpinbox, row, 1, 1, 2) - - row += 1 - self.threshMethodCombobox = QComboBox() - self.threshMethodCombobox.addItems( - ["Isodata", "Li", "Mean", "Minimum", "Otsu", "Triangle", "Yen"] - ) - formLayout.addWidget( - QLabel("Thresholding algorithm: "), row, 0, alignment=Qt.AlignRight - ) - formLayout.addWidget(self.threshMethodCombobox, row, 1, 1, 2) - - self.segment3Dcheckbox = None - if isSegm3D: - row += 1 - formLayout.addWidget( - QLabel("Segment 3D volume: "), row, 0, alignment=Qt.AlignRight - ) - group = QButtonGroup() - group.setExclusive(True) - self.segment3Dcheckbox = QRadioButton("Yes") - segmentSliceBySliceCheckbox = QRadioButton("No, segment slice-by-slice") - group.addButton(self.segment3Dcheckbox) - group.addButton(segmentSliceBySliceCheckbox) - formLayout.addWidget(self.segment3Dcheckbox, row, 1) - formLayout.addWidget(segmentSliceBySliceCheckbox, row, 2) - self.segment3Dcheckbox.setChecked(True) - - okButton = widgets.okPushButton("Ok") - cancelButton = widgets.cancelPushButton("Cancel") - helpButton = widgets.helpPushButton("Help...") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(helpButton) - buttonsLayout.addWidget(okButton) - - layout.addLayout(formLayout) - layout.addSpacing(20) - layout.addLayout(buttonsLayout) - - okButton.clicked.connect(self.ok_cb) - helpButton.clicked.connect(self.help_cb) - cancelButton.clicked.connect(self.close) - - self.setLayout(layout) - self.setFont(font) - - self.configPars = self.loadLastSelection() - - def help_cb(self): - import webbrowser - - url = "https://scikit-image.org/docs/stable/auto_examples/applications/plot_thresholding.html" - webbrowser.open(url) - - def ok_cb(self): - self.cancel = False - self.gaussSigma = self.sigmaGaussSpinbox.value() - threshMethod = self.threshMethodCombobox.currentText().lower() - self.threshMethod = f"threshold_{threshMethod}" - self.segment_kwargs = { - "gauss_sigma": self.gaussSigma, - "threshold_method": self.threshMethod, - "segment_3D_volume": False, - } - self.reduceMemoryUsage = False - if self.segment3Dcheckbox is not None: - doSegm3D = self.segment3Dcheckbox.isChecked() - self.segment_kwargs["segment_3D_volume"] = doSegm3D - self.close() - - def loadLastSelection(self): - self.ini_path = os.path.join(settings_folderpath, "last_params_segm_models.ini") - if not os.path.exists(self.ini_path): - return - - configPars = config.ConfigParser() - configPars.read(self.ini_path) - - if "thresholding.segment" not in configPars.sections(): - return - - section = configPars["thresholding.segment"] - self.sigmaGaussSpinbox.setValue(float(section["gauss_sigma"])) - - threshold_method = section["threshold_method"] - Method = threshold_method[10:].capitalize() - self.threshMethodCombobox.setCurrentText(Method) - if self.segment3Dcheckbox is None: - return - self.segment3Dcheckbox.setChecked(section.getboolean("segment_3D_volume")) - - -class GenerateMotherBudTotalTableSelectColumnsDialog(QBaseDialog): - def __init__(self, df: pd.DataFrame, parent=None): - super().__init__(parent) - - self.setWindowTitle("Select columns to combine into the output table") - - self.cancel = True - - self.columns = core.natsort_acdc_columns(df.columns) - self.operations = ( - "Sum mother and bud", - "Copy column from mother", - ) - - self.mainLayout = QVBoxLayout() - - instructionsText = html_utils.paragraph(""" - Select which columns and how you want to combine them - into the output table.
    - """) - self.mainLayout.addWidget(QLabel(instructionsText)) - - settingsLayout = QGridLayout() - - row = 0 - settingsLayout.addWidget(widgets.QHLine(), row, 0, 1, 2) - - row += 1 - settingsLayout.addWidget( - QLabel("Copy all non-selected columns from mother cell"), row, 0 - ) - self.copyAllColsToggle = widgets.Toggle() - settingsLayout.addWidget(self.copyAllColsToggle, row, 1, alignment=Qt.AlignLeft) - - row += 1 - settingsLayout.addWidget(widgets.QHLine(), row, 0, 1, 2) - - self.mainLayout.addLayout(settingsLayout) - - scrollArea = widgets.ScrollArea() - scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) - scrollWidget = QWidget() - scrollArea.setWidget(scrollWidget) - self.centralLayout = QGridLayout() - scrollWidget.setLayout(self.centralLayout) - - self.centralLayout.addWidget(QLabel("Grouping columns"), 0, 0) - self.centralLayout.addWidget(QLabel("Column"), 0, 1) - self.centralLayout.addWidget(QLabel("Operation"), 0, 2) - self.centralLayout.setRowStretch(0, 0) - - self.groupingColsListWidget = widgets.listWidget( - isMultipleSelection=True, - ) - self.groupingColsListWidget.addItems(self.columns) - self.centralLayout.addWidget(self.groupingColsListWidget, 1, 0, 2, 1) - - selector = widgets.ComboBox(self) - selector.addItems(self.columns) - operationCombobox = widgets.ComboBox(self) - operationCombobox.addItems(self.operations) - self.addSelectorButton = widgets.addPushButton() - - dummyButton = widgets.delPushButton() - dummyButton.setRetainSizeWhenHidden(True) - dummyButton.hide() - self.centralLayout.addWidget(dummyButton, 1, 4) - - self.centralLayout.addWidget(selector, 1, 1) - self.centralLayout.addWidget(operationCombobox, 1, 2) - self.centralLayout.addWidget(self.addSelectorButton, 1, 3) - - self.centralLayout.setRowStretch(1, 1) - self.centralLayout.setRowStretch(2, 1) - - self.selectors = {1: (selector, operationCombobox)} - - buttonsLayout = widgets.CancelOkButtonsLayout() - - saveSelectionButton = widgets.savePushButton("Save current selection") - buttonsLayout.insertWidget(3, saveSelectionButton) - - loadDefaultColsButton = widgets.reloadPushButton( - "Load default summable columns" - ) - buttonsLayout.insertWidget(4, loadDefaultColsButton) - - loadPreviousSelButton = widgets.OpenFilePushButton("Load previous selection") - buttonsLayout.insertWidget(5, loadPreviousSelButton) - - saveSelectionButton.clicked.connect(self.saveSelection) - loadDefaultColsButton.clicked.connect(self.loadDefaultCols) - loadPreviousSelButton.clicked.connect(self.loadPreviousSelection) - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - self.mainLayout.addWidget(scrollArea) - self.mainLayout.addSpacing(20) - self.mainLayout.addLayout(buttonsLayout) - - self.addSelectorButton.clicked.connect(self.addSelector) - selector.currentTextChanged.connect(self.selectorTextChanged) - - self.setLayout(self.mainLayout) - self.setFont(font) - - def saveSelection(self): - saved_selections = io.get_saved_moth_bud_tot_selections() - existing_names = set(saved_selections.keys()) - win = filenameDialog( - basename="", - ext="", - hintText="Insert a name for the current selection:", - existingNames=existing_names, - allowEmpty=False, - defaultEntry="mother_bud_total_columns_selection", - ) - win.exec_() - if win.cancel: - return - - name = win.filename - saved_selections[name] = self.selectedOptions() - io.save_moth_bud_tot_selected_options(saved_selections) - - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - txt = html_utils.paragraph(f""" - Current selection saved with name {name}. - """) - msg.information(self, "Selection saved", txt) - - def loadDefaultCols(self): - from . import single_pos_index_cols - - grouping_cols = [col for col in single_pos_index_cols if col in self.columns] - self.groupingColsListWidget.setSelectedItems(grouping_cols) - - column_operation_mapper = { - col: "Sum mother and bud" for col in cca_functions.default_summable_columns - } - column_operation_mapper = { - col: op - for col, op in column_operation_mapper.items() - if col in self.columns and op in self.operations - } - self.addSelectors( - len(column_operation_mapper), - callback_on_finished=partial( - self.setSelectorValues, column_operation_mapper - ), - ) - - def loadPreviousSelection(self): - saved_selections = io.get_saved_moth_bud_tot_selections() - if not saved_selections: - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - txt = html_utils.paragraph(""" - There are no saved selections. - """) - msg.warning(self, "No saved selections", txt) - return - - existing_names = natsorted(saved_selections.keys(), key=str.casefold) - - selectNameWin = widgets.QDialogListbox( - "Choose selection to load", - "Choose selection to load:\n", - existing_names, - multiSelection=False, - parent=self, - ) - selectNameWin.exec_() - if selectNameWin.cancel: - return - - self.loadOptions(saved_selections[selectNameWin.selectedItemsText[0]]) - - def resetSelectors(self, callback_on_finished=None): - self.callback_on_finished = callback_on_finished - QTimer.singleShot(1, self._removeLastSelector) - - def _removeLastSelector(self): - if len(self.selectors) == 1: - if self.callback_on_finished is not None: - self.callback_on_finished() - return - - lastRow = max(self.selectors.keys()) - lastSelector, _ = self.selectors[lastRow] - self.removeSelector(sender=lastSelector.delButton) - QTimer.singleShot(1, self._removeLastSelector) - - def addSelectors(self, number, callback_on_finished=None): - self.callback_on_finished = callback_on_finished - QTimer.singleShot(1, partial(self._addSelectorRecursive, number)) - - def _addSelectorRecursive(self, number): - if len(self.selectors) == number: - if self.callback_on_finished is not None: - self.callback_on_finished() - return - - self.addSelector() - QTimer.singleShot(1, partial(self._addSelectorRecursive, number)) - - def loadOptions(self, options: dict): - if len(self.selectors) > 1: - self.resetSelectors(callback_on_finished=partial(self.loadOptions, options)) - return - - self.copyAllColsToggle.setChecked( - options.get("do_copy_all_nonselected_columns", False) - ) - self.groupingColsListWidget.setSelectedItems( - options.get("grouping_columns", []) - ) - column_operation_mapper = options.get("column_operation_mapper", {}) - column_operation_mapper = { - col: op - for col, op in column_operation_mapper.items() - if col in self.columns and op in self.operations - } - if len(column_operation_mapper) > 1: - self.addSelectors( - len(column_operation_mapper), - callback_on_finished=partial( - self.setSelectorValues, column_operation_mapper - ), - ) - return - - self.setSelectorValues(column_operation_mapper) - - def setSelectorValues(self, column_operation_mapper): - for i, (col, op) in enumerate(column_operation_mapper.items()): - selector, operationCombobox = self.selectors[i + 1] - selector.setCurrentText(col) - operationCombobox.setCurrentText(op) - - def resetSelectorsStyles(self): - for selector, _ in self.selectors.values(): - selector.setStyleSheet("") - - def selectorTextChanged(self, text): - self.resetSelectorsStyles() - selector = self.sender() - for other_selector, _ in self.selectors.values(): - if other_selector == selector: - continue - - if selector.currentText() != other_selector.currentText(): - continue - - self.setWarningStyleSelector(selector) - self.setWarningStyleSelector(other_selector) - - def addSelector(self): - row = len(self.selectors) + 1 - - selector = widgets.ComboBox(self) - selector.addItems(self.columns) - selector.setCurrentIndex(len(self.selectors)) - operationCombobox = widgets.ComboBox(self) - operationCombobox.addItems(self.operations) - delButton = widgets.delPushButton() - selector.delButton = delButton - delButton._row = row - - self.selectors[row] = (selector, operationCombobox) - - self.centralLayout.addWidget(selector, row, 1) - self.centralLayout.addWidget(operationCombobox, row, 2) - self.centralLayout.addWidget(delButton, row, 3) - - self.centralLayout.removeWidget(self.addSelectorButton) - self.centralLayout.addWidget(self.addSelectorButton, row, 4) - - delButton.clicked.connect(self.removeSelector) - - self.centralLayout.removeWidget(self.groupingColsListWidget) - rowSpan = self.centralLayout.rowCount() - self.centralLayout.addWidget(self.groupingColsListWidget, 1, 0, rowSpan, 1) - self.centralLayout.setRowStretch(rowSpan, 1) - - selector.currentTextChanged.connect(self.selectorTextChanged) - - def removeSelector(self, checked=False, sender=None): - if sender is None: - delButton = self.sender() - else: - delButton = sender - - selector, operationCombobox = self.selectors.pop(delButton._row) - - self.centralLayout.removeWidget(selector) - self.centralLayout.removeWidget(operationCombobox) - self.centralLayout.removeWidget(delButton) - - resorted_selectors = {} - for i, (row, (sel, op)) in enumerate(self.selectors.items()): - if i == 0: - resorted_selectors[i + 1] = (sel, op) - continue - - delButton = sel.delButton - delButton._row = i + 1 - self.centralLayout.removeWidget(sel) - self.centralLayout.removeWidget(op) - self.centralLayout.removeWidget(delButton) - self.centralLayout.addWidget(sel, i + 1, 1) - self.centralLayout.addWidget(op, i + 1, 2) - self.centralLayout.addWidget(delButton, i + 1, 3) - - resorted_selectors[i + 1] = (sel, op) - - last_row = i + 1 - col = 4 if last_row > 1 else 3 - self.centralLayout.removeWidget(self.addSelectorButton) - self.centralLayout.addWidget(self.addSelectorButton, i + 1, col) - - self.selectors = resorted_selectors - - def sizeHint(self): - width = super().sizeHint().width() - height = super().sizeHint().height() - groupingColsWidth = widgets.get_min_width_for_no_scrollbar( - self.groupingColsListWidget - ) - width += groupingColsWidth - return QSize(width, height) - - def checkDuplicatedSelectedColumns(self): - for selector, _ in self.selectors.values(): - selector.setStyleSheet("background-color: none") - for other_selector, _ in self.selectors.values(): - if other_selector == selector: - continue - - if other_selector.currentText() != selector.currentText(): - continue - - self.warnDuplicatedSelectedColumns(selector, other_selector) - return False - - return True - - def setWarningStyleSelector(self, selector): - popup = selector.view() - palette = popup.palette() - text_color = palette.color(palette.ColorRole.Text) - warningStyleSheet = f""" - QComboBox {{ - color: black; - background-color: orange; /* main area */ - }} - QComboBox QAbstractItemView {{ - background-color: {text_color.name()}; - }} - """ - selector.setStyleSheet(warningStyleSheet) - - def warnDuplicatedSelectedColumns(self, selector1, selector2): - self.setWarningStyleSelector(selector1) - self.setWarningStyleSelector(selector2) - - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - txt = html_utils.paragraph(f""" - The following column has been selected more than once - (highlighted in orange).

    - {selector1.currentText()}

    - Please, select each column only once.

    - Thank you for your patience! - """) - msg.warning(self, "Duplicated selection", txt) - - def checkGroupingColumnsNotSelected(self): - if self.groupingColsListWidget.selectedItems(): - return True - - return self.warnGroupingColumnsNotSelected() - - def warnGroupingColumnsNotSelected(self): - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - txt = html_utils.paragraph(f""" - Are you sure you do not want to select any grouping column?

    - Grouping columns are those needed to identify each unique - Position folder. - """) - _, noButton, yesButton = msg.question( - self, - "No grouping columns selected?", - txt, - buttonsTexts=( - "Cancel", - "No, let me select grouping columns", - "Yes, I do not need grouping columns", - ), - ) - return msg.clickedButton == yesButton - - def selectedOptions(self): - selected_options = { - "grouping_columns": self.groupingColsListWidget.selectedItemsText(), - "column_operation_mapper": { - selector.currentText(): operationCombobox.currentText() - for selector, operationCombobox in self.selectors.values() - }, - "do_copy_all_nonselected_columns": self.copyAllColsToggle.isChecked(), - } - return selected_options - - def ok_cb(self): - proceed = self.checkDuplicatedSelectedColumns() - if not proceed: - return - - proceed = self.checkGroupingColumnsNotSelected() - if not proceed: - return - - self.selected_options = self.selectedOptions() - - self.cancel = False - self.close() - - -class ApplyTrackTableSelectColumnsDialog(QBaseDialog): - def __init__(self, df, parent=None): - super().__init__(parent) - - self.setWindowTitle("Select columns containing tracking info") - - self.cancel = True - self.mainLayout = QVBoxLayout() - - options = ( - '"Frame index", "Tracked IDs" and "Segmentation mask IDs"
    ', - '"Frame index", "Tracked IDs", "X coord. centroid", and "Y coord. centroid"', - ) - self.instructionsText = html_utils.paragraph( - f""" - Select which columns contain the tracking information.

    - You must choose one of the following combinations:
    - {html_utils.to_list(options)} - Optionally, you can provide the column name containing the parent ID.
    - This will allow you to load lineage information into Cell-ACDC. - """ - ) - self.mainLayout.addWidget(QLabel(self.instructionsText)) - - formLayout = QFormLayout() - - self.frameIndexCombobox = widgets.QCenteredComboBox() - self.frameIndexCombobox.addItems(df.columns) - self.frameIndexCheckbox = QCheckBox("1st frame is index 1") - frameIndexLayout = QHBoxLayout() - frameIndexLayout.addWidget(self.frameIndexCombobox) - frameIndexLayout.addWidget(self.frameIndexCheckbox) - frameIndexLayout.setStretch(0, 2) - frameIndexLayout.setStretch(1, 0) - formLayout.addRow("Frame index: ", frameIndexLayout) - - self.trackedIDsCombobox = widgets.QCenteredComboBox() - self.trackedIDsCombobox.addItems(df.columns) - formLayout.addRow("Tracked IDs: ", self.trackedIDsCombobox) - - items = df.columns.to_list() - items.insert(0, "None") - self.maskIDsCombobox = widgets.QCenteredComboBox() - self.maskIDsCombobox.addItems(items) - formLayout.addRow("Segmentation mask IDs: ", self.maskIDsCombobox) - - self.xCentroidCombobox = widgets.QCenteredComboBox() - self.xCentroidCombobox.addItems(items) - formLayout.addRow("X coord. centroid: ", self.xCentroidCombobox) - - self.yCentroidCombobox = widgets.QCenteredComboBox() - self.yCentroidCombobox.addItems(items) - formLayout.addRow("Y coord. centroid: ", self.yCentroidCombobox) - - self.parentIDcombobox = widgets.QCenteredComboBox() - self.parentIDcombobox.addItems(items) - formLayout.addRow("Parent ID (optional): ", self.parentIDcombobox) - - deleteUntrackedLayout = QHBoxLayout() - self.deleteUntrackedIDsToggle = widgets.Toggle() - deleteUntrackedLayout.addStretch(1) - deleteUntrackedLayout.addWidget(self.deleteUntrackedIDsToggle) - deleteUntrackedLayout.addStretch(1) - formLayout.addRow("Delete untracked IDs: ", deleteUntrackedLayout) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - self.mainLayout.addSpacing(30) - self.mainLayout.addLayout(formLayout) - self.mainLayout.addSpacing(20) - self.mainLayout.addLayout(buttonsLayout) - - self.setLayout(self.mainLayout) - self.setFont(font) - - def ok_cb(self): - self.cancel = False - self.frameIndexCol = self.frameIndexCombobox.currentText() - self.trackedIDsCol = self.trackedIDsCombobox.currentText() - self.maskIDsCol = self.maskIDsCombobox.currentText() - self.xCentroidCol = self.xCentroidCombobox.currentText() - self.yCentroidCol = self.yCentroidCombobox.currentText() - self.deleteUntrackedIDs = self.deleteUntrackedIDsToggle.isChecked() - if self.maskIDsCol == "None": - if self.xCentroidCol == "None" or self.yCentroidCol == "None": - self.warnInvalidSelection() - return - else: - self.xCentroidCol = "None" - self.yCentroidCol = "None" - self.parentIDcol = self.parentIDcombobox.currentText() - self.isFirstFrameOne = self.frameIndexCheckbox.isChecked() - self.close() - - def warnInvalidSelection(self): - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.warning( - self, - "Invalid selection", - html_utils.paragraph( - f"Invalid selection
    {self.instructionsText}" - ), - ) - - -class SelectPromptableModelDialog(QBaseDialog): - def __init__(self, parent=None): - self.cancel = True - super().__init__(parent) - - self.setWindowTitle("Select model for segmentation") - - mainLayout = QVBoxLayout() - - label = QLabel(html_utils.paragraph("Select model to use for segmentation: ")) - mainLayout.addWidget(label, alignment=Qt.AlignCenter) - - listBox = widgets.listWidget() - models = myutils.get_list_of_promptable_models() - listBox.addItems(models) - listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - listBox.setCurrentRow(0) - listBox.itemDoubleClicked.connect(self.ok_cb) - - self.listBox = listBox - - mainLayout.addWidget(listBox) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def ok_cb(self): - self.cancel = False - self.model_name = self.listBox.currentItem().text() - self.close() - - -class QDialogSelectModel(QDialog): - def __init__(self, parent=None, addSkipSegmButton=False, customFirst=""): - self.cancel = True - super().__init__(parent) - self.setWindowTitle("Select model") - - mainLayout = QVBoxLayout() - topLayout = QVBoxLayout() - bottomLayout = QHBoxLayout() - - self.mainLayout = mainLayout - - label = QLabel(html_utils.paragraph("Select model to use for segmentation: ")) - # padding: top, left, bottom, right - label.setStyleSheet("padding:0px 0px 3px 0px;") - topLayout.addWidget(label, alignment=Qt.AlignCenter) - - listBox = widgets.listWidget() - models = myutils.get_list_of_models() - - if customFirst: - try: - idx = models.index(customFirst) - models.insert(0, models.pop(idx)) - except ValueError: - print(f"Warning: {customFirst} not found in models list.") - pass - - listBox.setFont(font) - listBox.addItems(models) - addCustomModelItem = QListWidgetItem("Add custom model...") - addCustomModelItem.setFont(italicFont) - listBox.addItem(addCustomModelItem) - listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - listBox.setCurrentRow(0) - self.listBox = listBox - listBox.itemDoubleClicked.connect(self.ok_cb) - topLayout.addWidget(listBox) - - cancelButton = widgets.cancelPushButton("Cancel") - okButton = widgets.okPushButton(" Ok ") - okButton.setShortcut(Qt.Key_Enter) - - bottomLayout.addStretch(1) - bottomLayout.addWidget(cancelButton) - bottomLayout.addSpacing(20) - if addSkipSegmButton: - skipSegmButton = widgets.SkipPushButton("Skip segmentation") - bottomLayout.addWidget(skipSegmButton) - skipSegmButton.clicked.connect(self.skipSegm) - bottomLayout.addWidget(okButton) - bottomLayout.setContentsMargins(0, 10, 0, 0) - - mainLayout.addLayout(topLayout) - mainLayout.addLayout(bottomLayout) - self.setLayout(mainLayout) - - # Connect events - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.cancel_cb) - - self.setStyleSheet(LISTWIDGET_STYLESHEET) - - def skipSegm(self): - self.cancel = False - self.selectedModel = "skip_segmentation" - self.close() - - def keyPressEvent(self, event: QKeyEvent) -> None: - if event.key() == Qt.Key_Escape: - event.ignore() - return - - super().keyPressEvent(event) - - def ok_cb(self, event): - self.clickedButton = self.sender() - self.cancel = False - item = self.listBox.currentItem() - model = item.text() - if model == "Add custom model...": - modelFilePath = addCustomModelMessages(self) - if modelFilePath is None: - return - myutils.store_custom_model_path(modelFilePath) - modelName = os.path.basename(os.path.dirname(modelFilePath)) - item = QListWidgetItem(modelName) - self.listBox.addItem(item) - self.listBox.setCurrentItem(item) - elif model == "Automatic thresholding": - self.selectedModel = "thresholding" - self.close() - else: - self.selectedModel = model - self.close() - - def cancel_cb(self, event): - self.cancel = True - self.selectedModel = None - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - - horizontal_sb = self.listBox.horizontalScrollBar() - while horizontal_sb.isVisible(): - self.resize(self.height(), self.width() + 10) - - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class ViewTextDialog(QBaseDialog): - def __init__(self, text, parent=None): - super().__init__(parent) - - mainLayout = QVBoxLayout() - - textViewWidget = QTextEdit() - textViewWidget.setReadOnly(True) - - textViewWidget.setText(text) - - buttonsLayout = QHBoxLayout() - okButton = widgets.okPushButton("Ok") - - okButton.clicked.connect(self.close) - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(okButton) - - mainLayout.addWidget(textViewWidget) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - self.setFont(font) - - -class startStopFramesDialog(QBaseDialog): - def __init__( - self, - SizeT, - currentFrameNum=0, - parent=None, - windowTitle="Select frame range to segment", - ): - super().__init__(parent=parent) - - self.setWindowTitle(windowTitle) - - self.cancel = True - - layout = QVBoxLayout() - buttonsLayout = QHBoxLayout() - - self.selectFramesGroupbox = widgets.selectStartStopFrames( - SizeT, currentFrameNum=currentFrameNum, parent=parent - ) - - okButton = widgets.okPushButton("Ok") - cancelButton = widgets.cancelPushButton("Cancel") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(okButton) - - layout.addWidget(self.selectFramesGroupbox) - layout.addLayout(buttonsLayout) - self.setLayout(layout) - - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - - self.setFont(font) - - def ok_cb(self): - if self.selectFramesGroupbox.warningLabel.text(): - return - else: - self.startFrame = self.selectFramesGroupbox.startFrame_SB.value() - self.stopFrame = self.selectFramesGroupbox.stopFrame_SB.value() - self.cancel = False - self.close() - - def show(self, block=False): - super().show(block=False) - - self.resize(int(self.width() * 1.5), self.height()) - - if block: - super().show(block=True) - - -class QDialogAppendTextFilename(QDialog): - def __init__(self, filename, ext, parent=None, font=None): - super().__init__(parent) - self.cancel = True - filenameNOext, _ = os.path.splitext(filename) - self.filenameNOext = filenameNOext - if ext.find(".") == -1: - ext = f".{ext}" - self.ext = ext - - self.setWindowTitle("Append text to file name") - - mainLayout = QVBoxLayout() - formLayout = QFormLayout() - buttonsLayout = QHBoxLayout() - - if font is not None: - self.setFont(font) - - self.LE = QLineEdit() - self.LE.setAlignment(Qt.AlignCenter) - formLayout.addRow("Appended text", self.LE) - self.LE.textChanged.connect(self.updateFinalFilename) - - self.finalName_label = QLabel(f'Final file name: "{filenameNOext}_{ext}"') - # padding: top, left, bottom, right - self.finalName_label.setStyleSheet("font-size:13px; padding:5px 0px 0px 0px;") - - okButton = widgets.okPushButton("Ok") - okButton.setShortcut(Qt.Key_Enter) - - cancelButton = widgets.cancelPushButton("Cancel") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(okButton) - - buttonsLayout.setContentsMargins(0, 10, 0, 0) - - mainLayout.addLayout(formLayout) - mainLayout.addWidget(self.finalName_label, alignment=Qt.AlignCenter) - mainLayout.addLayout(buttonsLayout) - - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - - self.formLayout = formLayout - - self.setLayout(mainLayout) - # self.setModal(True) - - def updateFinalFilename(self, text): - finalFilename = f"{self.filenameNOext}_{text}{self.ext}" - self.finalName_label.setText(f'Final file name: "{finalFilename}"') - - def ok_cb(self, event): - if not self.LE.text(): - err_msg = "Appended name cannot be empty!" - msg = QMessageBox() - msg.critical(self, "Empty name", err_msg, msg.Ok) - return - self.cancel = False - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class QDialogEntriesWidget(QDialog): - def __init__( - self, entriesLabels, defaultTxts, winTitle="Input", parent=None, font=None - ): - self.cancel = True - self.entriesTxt = [] - self.entriesLabels = entriesLabels - self.QLEs = [] - super().__init__(parent) - self.setWindowTitle(winTitle) - - mainLayout = QVBoxLayout() - formLayout = QFormLayout() - buttonsLayout = QHBoxLayout() - - if font is not None: - self.setFont(font) - - for label, txt in zip(entriesLabels, defaultTxts): - LE = QLineEdit() - LE.setAlignment(Qt.AlignCenter) - LE.setText(txt) - formLayout.addRow(label, LE) - self.QLEs.append(LE) - - okButton = widgets.okPushButton("Ok") - okButton.setShortcut(Qt.Key_Enter) - - cancelButton = widgets.cancelPushButton("Cancel") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(okButton) - - buttonsLayout.setContentsMargins(0, 10, 0, 0) - - mainLayout.addLayout(formLayout) - mainLayout.addLayout(buttonsLayout) - - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - - self.formLayout = formLayout - - self.setLayout(mainLayout) - # self.setModal(True) - - def ok_cb(self, event): - self.cancel = False - self.entriesTxt = [ - self.formLayout.itemAt(i, 1).widget().text() - for i in range(len(self.entriesLabels)) - ] - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class QDialogMetadata(QDialog): - def __init__( - self, - SizeT, - SizeZ, - TimeIncrement, - PhysicalSizeZ, - PhysicalSizeY, - PhysicalSizeX, - ask_SizeT, - ask_TimeIncrement, - ask_PhysicalSizes, - parent=None, - font=None, - imgDataShape=None, - posData=None, - singlePos=False, - askSegm3D=True, - additionalValues=None, - forceEnableAskSegm3D=False, - SizeT_metadata=None, - SizeZ_metadata=None, - basename="", - ): - self.cancel = True - self.ask_TimeIncrement = ask_TimeIncrement - self.ask_PhysicalSizes = ask_PhysicalSizes - self.askSegm3D = askSegm3D - self.imgDataShape = imgDataShape - self.posData = posData - self._additionalValues = additionalValues - self.SizeT_metadata = SizeT_metadata - self.SizeZ_metadata = SizeZ_metadata - super().__init__(parent) - self.setWindowTitle("Image properties") - - mainLayout = QVBoxLayout() - gridLayout = QGridLayout() - # formLayout = QFormLayout() - buttonsLayout = QGridLayout() - - if imgDataShape is not None: - label = QLabel( - html_utils.paragraph( - f"Image data shape = {imgDataShape}
    " - ) - ) - mainLayout.addWidget(label, alignment=Qt.AlignCenter) - - row = 0 - self.basenameLineEdit = None - if basename: - gridLayout.addWidget( - QLabel("Basename (read-only)"), row, 0, alignment=Qt.AlignRight - ) - self.basenameLineEdit = QLineEdit() - self.basenameLineEdit.setReadOnly(True) - self.basenameLineEdit.setText(basename) - minWidth = ( - self.basenameLineEdit.fontMetrics().boundingRect(basename).width() + 10 - ) - self.basenameLineEdit.setMinimumWidth(minWidth) - self.basenameLineEdit.setAlignment(Qt.AlignCenter) - gridLayout.addWidget(self.basenameLineEdit, row, 1) - row += 1 - - gridLayout.addWidget( - QLabel("Number of frames (SizeT)"), row, 0, alignment=Qt.AlignRight - ) - self.SizeT_SpinBox = QSpinBox() - self.SizeT_SpinBox.setMinimum(1) - self.SizeT_SpinBox.setMaximum(2147483647) - SizeTinfoButton = widgets.infoPushButton() - self.allowEditSizeTcheckbox = QCheckBox("Let me edit it") - if ask_SizeT: - self.SizeT_SpinBox.setValue(SizeT) - SizeTinfoButton.hide() - self.allowEditSizeTcheckbox.hide() - else: - self.SizeT_SpinBox.setValue(1) - self.SizeT_SpinBox.setDisabled(True) - SizeTinfoButton.show() - SizeTinfoButton.clicked.connect(self.showWhySizeTisGrayed) - self.allowEditSizeTcheckbox.show() - self.allowEditSizeTcheckbox.toggled.connect(self.allowEditSizeT) - self.SizeT_SpinBox.setAlignment(Qt.AlignCenter) - self.SizeT_SpinBox.valueChanged.connect(self.TimeIncrementShowHide) - gridLayout.addWidget(self.SizeT_SpinBox, row, 1) - gridLayout.addWidget(SizeTinfoButton, row, 2) - gridLayout.setColumnStretch(2, 0) - gridLayout.addWidget(self.allowEditSizeTcheckbox, row, 3) - gridLayout.setColumnStretch(3, 0) - - row += 1 - gridLayout.addWidget( - QLabel("Number of z-slices (SizeZ)"), row, 0, alignment=Qt.AlignRight - ) - self.SizeZ_SpinBox = QSpinBox() - self.SizeZ_SpinBox.setMinimum(1) - self.SizeZ_SpinBox.setMaximum(2147483647) - self.SizeZ_SpinBox.setValue(SizeZ) - self.SizeZ_SpinBox.setAlignment(Qt.AlignCenter) - self.SizeZ_SpinBox.valueChanged.connect(self.SizeZvalueChanged) - gridLayout.addWidget(self.SizeZ_SpinBox, row, 1) - - row += 1 - self.TimeIncrementLabel = QLabel("Time interval (s)") - gridLayout.addWidget(self.TimeIncrementLabel, row, 0, alignment=Qt.AlignRight) - self.TimeIncrementSpinBox = widgets.FloatLineEdit() - self.TimeIncrementSpinBox.setValue(TimeIncrement) - gridLayout.addWidget(self.TimeIncrementSpinBox, row, 1) - - if SizeT == 1 or not ask_TimeIncrement: - self.TimeIncrementSpinBox.hide() - self.TimeIncrementLabel.hide() - - row += 1 - self.PhysicalSizeZLabel = QLabel("Physical Size Z (um/pixel)") - gridLayout.addWidget(self.PhysicalSizeZLabel, row, 0, alignment=Qt.AlignRight) - self.PhysicalSizeZSpinBox = widgets.FloatLineEdit() - self.PhysicalSizeZSpinBox.setValue(PhysicalSizeZ) - gridLayout.addWidget(self.PhysicalSizeZSpinBox, row, 1) - - if SizeZ == 1 or not ask_PhysicalSizes: - self.PhysicalSizeZSpinBox.hide() - self.PhysicalSizeZLabel.hide() - - row += 1 - self.PhysicalSizeYLabel = QLabel("Physical Size Y (um/pixel)") - gridLayout.addWidget(self.PhysicalSizeYLabel, row, 0, alignment=Qt.AlignRight) - self.PhysicalSizeYSpinBox = widgets.FloatLineEdit() - self.PhysicalSizeYSpinBox.setValue(PhysicalSizeY) - gridLayout.addWidget(self.PhysicalSizeYSpinBox, row, 1) - - if not ask_PhysicalSizes: - self.PhysicalSizeYSpinBox.hide() - self.PhysicalSizeYLabel.hide() - - row += 1 - self.PhysicalSizeXLabel = QLabel("Physical Size X (um/pixel)") - gridLayout.addWidget(self.PhysicalSizeXLabel, row, 0, alignment=Qt.AlignRight) - self.PhysicalSizeXSpinBox = widgets.FloatLineEdit() - self.PhysicalSizeXSpinBox.setValue(PhysicalSizeX) - gridLayout.addWidget(self.PhysicalSizeXSpinBox, row, 1) - - if not ask_PhysicalSizes: - self.PhysicalSizeXSpinBox.hide() - self.PhysicalSizeXLabel.hide() - - row += 1 - self.isSegm3Dtoggle = widgets.Toggle() - if posData is not None: - self.isSegm3Dtoggle.setChecked(posData.getIsSegm3D()) - disableToggle = ( - # Disable toggle if not force enable and if - # segm data was found (we cannot change the shape of - # loaded segmentation in the GUI) - posData.segmFound is not None - and posData.segmFound - and not forceEnableAskSegm3D - ) - if disableToggle: - self.isSegm3Dtoggle.setDisabled(True) - self.isSegm3DLabel = QLabel("Work with 3D segmentation masks (z-stack)") - gridLayout.addWidget(self.isSegm3DLabel, row, 0, alignment=Qt.AlignRight) - gridLayout.addWidget(self.isSegm3Dtoggle, row, 1, alignment=Qt.AlignCenter) - self.infoButtonSegm3D = QPushButton(self) - self.infoButtonSegm3D.setCursor(Qt.WhatsThisCursor) - self.infoButtonSegm3D.setIcon(QIcon(":info.svg")) - gridLayout.addWidget(self.infoButtonSegm3D, row, 2, alignment=Qt.AlignLeft) - self.infoButtonSegm3D.clicked.connect(self.infoSegm3D) - if SizeZ == 1 or not askSegm3D: - self.isSegm3DLabel.hide() - self.isSegm3Dtoggle.hide() - self.infoButtonSegm3D.hide() - - self.SizeZvalueChanged(SizeZ) - - self.additionalFieldsWidgets = [] - addFieldButton = widgets.addPushButton("Add custom field") - addFieldInfoButton = widgets.infoPushButton() - addFieldInfoButton.clicked.connect(self.showAddFieldInfo) - addFieldButton.clicked.connect(self.addField) - addFieldLayout = QHBoxLayout() - addFieldLayout.addStretch(1) - addFieldLayout.addWidget(addFieldButton) - addFieldLayout.addWidget(addFieldInfoButton) - addFieldLayout.addStretch(1) - - if singlePos: - okTxt = "Apply only to this Position" - else: - okTxt = "Ok for loaded Positions" - okButton = widgets.okPushButton(okTxt) - okButton.setToolTip("Save metadata only for current positionh") - okButton.setShortcut(Qt.Key_Enter) - self.okButton = okButton - - if ask_TimeIncrement or ask_PhysicalSizes: - okAllButton = QPushButton("Apply to ALL Positions") - okAllButton.setToolTip( - "Update existing Physical Sizes, Time interval, cell volume (fl), " - "cell area (um^2), and time (s) for all the positions " - "in the experiment folder." - ) - self.okAllButton = okAllButton - - selectButton = QPushButton("Select the Positions to be updated") - selectButton.setToolTip( - "Ask to select positions then update existing Physical Sizes, " - "Time interval, cell volume (fl), cell area (um^2), and time (s)" - "for selected positions." - ) - self.selectButton = selectButton - else: - self.okAllButton = None - self.selectButton = None - okButton.setText("Ok") - - cancelButton = widgets.cancelPushButton("Cancel") - - buttonsLayout.setColumnStretch(0, 1) - buttonsLayout.addWidget(okButton, 0, 1) - if ask_TimeIncrement or ask_PhysicalSizes: - buttonsLayout.addWidget(okAllButton, 0, 2) - buttonsLayout.addWidget(selectButton, 1, 1) - buttonsLayout.addWidget(cancelButton, 1, 2) - else: - buttonsLayout.addWidget(cancelButton, 0, 2) - buttonsLayout.setColumnStretch(3, 1) - - gridLayout.setColumnMinimumWidth(1, 100) - mainLayout.addLayout(gridLayout) - mainLayout.addSpacing(10) - mainLayout.addLayout(addFieldLayout) - # mainLayout.addLayout(formLayout) - mainLayout.addSpacing(20) - mainLayout.addStretch(1) - mainLayout.addLayout(buttonsLayout) - self.mainLayout = mainLayout - - okButton.clicked.connect(self.ok_cb) - if ask_TimeIncrement or ask_PhysicalSizes: - okAllButton.clicked.connect(self.ok_cb) - selectButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.cancel_cb) - - self.addAdditionalValues(additionalValues) - - self.setLayout(mainLayout) - self.setFont(font) - # self.setModal(True) - - def showWhySizeTisGrayed(self): - txt = html_utils.paragraph(f""" - The "Number of frames" field is grayed-out because you loaded multiple Positions.

    - Cell-ACDC cannot load multiple time-lapse Positions, - so it is assuming you are loading NON time-lapse data.

    - To load time-lapse data, load one Position at a time.

    - Note that you can still edit the number of frames if you need to correct it.
    - However, you can only edit the metadata, then the loading process will be stopped. - """) - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.information(self, "Why is the number of frames grayed out?", txt) - - def addAdditionalValues(self, values): - if values is None: - return - - for i, (name, value) in enumerate(values.items()): - self.addField() - nameWidget = self.additionalFieldsWidgets[i]["nameWidget"] - valueWidget = self.additionalFieldsWidgets[i]["valueWidget"] - nameWidget.setText(str(name).strip("__")) - valueWidget.setText(str(value)) - - def addField(self): - nameWidget = QLineEdit() - nameWidget.setAlignment(Qt.AlignCenter) - valueWidget = QLineEdit() - valueWidget.setAlignment(Qt.AlignCenter) - removeButton = widgets.delPushButton() - - fieldLayout = QGridLayout() - fieldLayout.addWidget(QLabel("Name"), 0, 0) - fieldLayout.addWidget(nameWidget, 1, 0) - fieldLayout.addWidget(QLabel("Value"), 0, 1) - fieldLayout.addWidget(valueWidget, 1, 1) - fieldLayout.addWidget(removeButton, 1, 2) - - self.additionalFieldsWidgets.append( - { - "nameWidget": nameWidget, - "valueWidget": valueWidget, - "removeButton": removeButton, - "layout": fieldLayout, - } - ) - - idx = len(self.additionalFieldsWidgets) - 1 - removeButton.clicked.connect(partial(self.removeField, idx)) - - row = self.mainLayout.count() - 3 - self.mainLayout.insertLayout(row, fieldLayout) - - def removeField(self, idx): - widgets = self.additionalFieldsWidgets[idx] - - layoutToRemove = widgets["layout"] - for row in range(layoutToRemove.rowCount()): - for col in range(layoutToRemove.columnCount()): - item = layoutToRemove.itemAtPosition(row, col) - if item is not None: - widget = item.widget() - layoutToRemove.removeWidget(widget) - - self.additionalFieldsWidgets.pop(idx) - - self.mainLayout.removeItem(layoutToRemove) - - def showAddFieldInfo(self): - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - Add a field (name and value) that will be saved to the - metadata.csv file and as a column in the - acdc_output.csv table.

    - Example: a strain name or the replicate number. - """) - msg.information(self, "Add field info", txt) - - def infoSegm3D(self): - txt = ( - "Cell-ACDC supports both 2D and 3D segmentation. If your data " - "also have a time dimension, then you can choose to segment " - "a specific z-slice (2D segmentation mask per frame) or all of them " - "(3D segmentation mask per frame)

    " - "In any case, if you choose to activate 3D segmentation then the " - "segmentation mask will have the same number of z-slices " - "of the image data.

    " - "Additionally, in the model parameters window, you will be able " - "to choose if you want to segment the entire 3D volume at once " - "or use the 2D model on each z-slice, one by one.

    " - "NOTE: if the toggle is disabled it means you already " - "loaded segmentation data and the shape cannot be changed now.
    " - "if you need to start with a blank segmentation, " - 'use the "Create a new segmentation file" button instead of the ' - '"Load folder" button.' - "
    " - ) - msg = widgets.myMessageBox() - msg.setIcon() - msg.setWindowTitle(f"3D segmentation info") - msg.addText(html_utils.paragraph(txt)) - msg.addButton(" Ok ") - msg.exec_() - - def SizeZvalueChanged(self, val): - if len(self.imgDataShape) < 3: - return - - if val > 1 and self.imgDataShape is not None: - maxSizeZ = self.imgDataShape[-3] - self.SizeZ_SpinBox.setMaximum(maxSizeZ) - else: - self.SizeZ_SpinBox.setMaximum(2147483647) - - if val > 1: - if self.ask_PhysicalSizes: - self.PhysicalSizeZSpinBox.show() - self.PhysicalSizeZLabel.show() - if self.askSegm3D: - self.isSegm3DLabel.show() - self.isSegm3Dtoggle.show() - self.infoButtonSegm3D.show() - else: - self.PhysicalSizeZSpinBox.hide() - self.PhysicalSizeZLabel.hide() - self.isSegm3DLabel.hide() - self.isSegm3Dtoggle.hide() - self.infoButtonSegm3D.hide() - - self.checkSegmDataShape() - - def checkSegmDataShape(self): - if self.posData is None: - return - - if self.isSegm3Dtoggle.isEnabled(): - return - - SizeT = self.SizeT_SpinBox.value() - SizeZ = self.SizeZ_SpinBox.value() - segm_data_ndim = self.posData.segm_data.ndim - isSegm3D = False - if segm_data_ndim == 4: - # Segm data is 4D so it must be 3D over time - isSegm3D = True - elif segm_data_ndim == 3 and SizeZ > 1 and SizeT == 1: - # Segm data is 3D while SizeT == 1 and SizeZ > 1 - # --> also segm is 3D z-stack - isSegm3D = True - - self.isSegm3Dtoggle.setDisabled(False) - self.isSegm3Dtoggle.setChecked(isSegm3D) - self.isSegm3Dtoggle.setDisabled(True) - - def TimeIncrementShowHide(self, val): - self.checkSegmDataShape() - if not self.ask_TimeIncrement: - return - - if val > 1: - self.TimeIncrementSpinBox.show() - self.TimeIncrementLabel.show() - else: - self.TimeIncrementSpinBox.hide() - self.TimeIncrementLabel.hide() - - def allowEditSizeT(self, checked): - if checked: - self.SizeT_SpinBox.setDisabled(False) - if self.SizeT_metadata is not None: - self.SizeT_SpinBox.setValue(self.SizeT_metadata) - else: - self.SizeT_SpinBox.setDisabled(True) - self.SizeT_SpinBox.setValue(1) - - def warnEditingMetadata(self, Size, Size_metadata, which_dim): - txt = html_utils.paragraph(f""" - The number of {which_dim} in the saved metadata is {Size_metadata}, - but you are requesting to change it to {Size}.

    - Are you sure you want to proceed? - """) - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - _, noButton, yesButton = msg.warning( - self, - "WARNING: Edinting saved metadata", - txt, - buttonsTexts=("Cancel", "No", "Yes, edit the metadata"), - ) - return msg.clickedButton == yesButton - - def ok_cb(self, checked=False): - self.cancel = False - self.SizeT = self.SizeT_SpinBox.value() - self.SizeZ = self.SizeZ_SpinBox.value() - - if self.SizeT_metadata is not None: - if self.SizeT != self.SizeT_metadata: - proceed = self.warnEditingMetadata( - self.SizeT, self.SizeT_metadata, "frames" - ) - if not proceed: - return - - if self.SizeZ_metadata is not None: - if self.SizeZ != self.SizeZ_metadata: - proceed = self.warnEditingMetadata( - self.SizeZ, self.SizeZ_metadata, "z-slices" - ) - if not proceed: - return - - self.isSegm3D = self.isSegm3Dtoggle.isChecked() - - self.TimeIncrement = self.TimeIncrementSpinBox.value() - self.PhysicalSizeX = self.PhysicalSizeXSpinBox.value() - self.PhysicalSizeY = self.PhysicalSizeYSpinBox.value() - self.PhysicalSizeZ = self.PhysicalSizeZSpinBox.value() - self._additionalValues = { - f"__{field['nameWidget'].text()}": field["valueWidget"].text() - for field in self.additionalFieldsWidgets - } - proceed = self.checkShapeMismatchMetadata() - if not proceed: - return - - if self.posData is not None and self.sender() != self.okButton: - exp_path = self.posData.exp_path - pos_foldernames = myutils.get_pos_foldernames(exp_path) - if self.sender() == self.selectButton: - select_folder = load.select_exp_folder() - select_folder.pos_foldernames = pos_foldernames - select_folder.QtPrompt( - self, pos_foldernames, allow_cancel=False, toggleMulti=True - ) - pos_foldernames = select_folder.selected_pos - for pos in pos_foldernames: - images_path = os.path.join(exp_path, pos, "Images") - ls = myutils.listdir(images_path) - search = [file for file in ls if file.find("metadata.csv") != -1] - metadata_df = None - if search: - fileName = search[0] - metadata_csv_path = os.path.join(images_path, fileName) - metadata_df = pd.read_csv(metadata_csv_path).set_index( - "Description" - ) - if metadata_df is not None: - metadata_df.at["TimeIncrement", "values"] = self.TimeIncrement - metadata_df.at["PhysicalSizeZ", "values"] = self.PhysicalSizeZ - metadata_df.at["PhysicalSizeY", "values"] = self.PhysicalSizeY - metadata_df.at["PhysicalSizeX", "values"] = self.PhysicalSizeX - metadata_df.to_csv(metadata_csv_path) - - search = [file for file in ls if file.find("acdc_output.csv") != -1] - acdc_df = None - if search: - fileName = search[0] - acdc_df_path = os.path.join(images_path, fileName) - acdc_df = pd.read_csv(acdc_df_path) - yx_pxl_to_um2 = self.PhysicalSizeY * self.PhysicalSizeX - vox_to_fl = self.PhysicalSizeY * (self.PhysicalSizeX**2) - if "cell_vol_fl" not in acdc_df.columns: - continue - acdc_df["cell_vol_fl"] = acdc_df["cell_vol_vox"] * vox_to_fl - acdc_df["cell_area_um2"] = acdc_df["cell_area_pxl"] * yx_pxl_to_um2 - acdc_df["time_seconds"] = acdc_df["frame_i"] * self.TimeIncrement - try: - acdc_df.to_csv(acdc_df_path, index=False) - except PermissionError: - err_msg = html_utils.paragraph( - "The below file is open in another app " - "(Excel maybe?).

    " - f"{acdc_df_path}

    " - 'Close file and then press "Ok".' - ) - msg = widgets.myMessageBox() - msg.critical(self, "Permission denied", err_msg) - acdc_df.to_csv(acdc_df_path, index=False) - - elif self.sender() == self.selectButton: - pass - - self.close() - - def checkShapeMismatchMetadata(self): - valid4D = True - valid3D = True - valid2D = True - if self.imgDataShape is None: - self.close() - elif len(self.imgDataShape) == 4: - T, Z, Y, X = self.imgDataShape - valid4D = self.SizeT == T and self.SizeZ == Z - elif len(self.imgDataShape) == 3: - TorZ, Y, X = self.imgDataShape - valid3D = self.SizeT == TorZ or self.SizeZ == TorZ - elif len(self.imgDataShape) == 2: - valid2D = self.SizeT == 1 and self.SizeZ == 1 - - valid = all([valid4D, valid3D, valid2D]) - if valid: - return True - - if not valid4D: - txt = f""" - You loaded 4D data, hence the number of frames MUST be - {T}
    and the number of z-slices MUST be {Z}.

    - What do you want to do? - """ - if not valid3D: - txt = f""" - You loaded 3D data, hence either the number of frames or - the number of z-slices is {TorZ}.

    - However, if the number of frames is greater than 1 then the
    - number of z-slices MUST be 1, and vice-versa.

    - What do you want to do? - """ - - if not valid2D: - txt = f""" - You loaded 2D data, hence the number of frames MUST be 1 - and the number of z-slices MUST be 1.

    - What do you want to do? - """ - - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(txt) - - continueButton = widgets.okPushButton("Continue anyway") - correctButton = widgets.editPushButton("Let me correct") - - msg.warning( - self, - "Shape-metadata mismatch", - txt, - buttonsTexts=(continueButton, correctButton), - ) - if msg.cancel or msg.clickedButton == correctButton: - return False - - return True - - def cancel_cb(self, event): - self.cancel = True - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class QCropZtool(QBaseDialog): - sigClose = Signal() - sigZvalueChanged = Signal(str, int) - sigReset = Signal() - sigCrop = Signal(int, int) - - def __init__( - self, - SizeZ, - cropButtonText="Apply crop", - parent=None, - addDoNotShowAgain=False, - title="Select z-slices", - ): - super().__init__(parent) - - self.cancel = True - - self.setWindowFlags(Qt.Tool | Qt.WindowStaysOnTopHint) - - self.SizeZ = SizeZ - self.numDigits = len(str(self.SizeZ)) - - self.setWindowTitle(title) - - layout = QGridLayout() - buttonsLayout = QHBoxLayout() - - self.lowerZscrollbar = widgets.ScrollBarWithNumericControl() - self.lowerZscrollbar.setMaximum(SizeZ) - self.lowerZscrollbar.setMinimum(1) - self.lowerZscrollbar.setValue(1) - - self.upperZscrollbar = widgets.ScrollBarWithNumericControl() - self.upperZscrollbar.setMaximum(SizeZ) - self.upperZscrollbar.setValue(SizeZ) - - cancelButton = widgets.cancelPushButton("Cancel") - cropButton = widgets.okPushButton(cropButtonText) - buttonsLayout.addWidget(cropButton) - buttonsLayout.addWidget(cancelButton) - - row = 0 - layout.addWidget(QLabel("Lower z-slice "), row, 0, alignment=Qt.AlignRight) - layout.addWidget(self.lowerZscrollbar, row, 1) - - row += 1 - layout.setRowStretch(row, 5) - - row += 1 - layout.addWidget(QLabel("Upper z-slice "), row, 0, alignment=Qt.AlignRight) - layout.addWidget(self.upperZscrollbar, row, 1) - - row += 1 - if addDoNotShowAgain: - self.doNotShowAgainCheckbox = QCheckBox("Do not ask again") - layout.addWidget( - self.doNotShowAgainCheckbox, row, 1, alignment=Qt.AlignLeft - ) - row += 1 - - layout.addLayout(buttonsLayout, row, 1, alignment=Qt.AlignRight) - - layout.setColumnStretch(0, 0) - layout.setColumnStretch(1, 10) - - self.setLayout(layout) - - # resetButton.clicked.connect(self.emitReset) - cropButton.clicked.connect(self.emitCrop) - cancelButton.clicked.connect(self.close) - self.lowerZscrollbar.sigValueChanged.connect(self.ZvalueChanged) - self.upperZscrollbar.sigValueChanged.connect(self.ZvalueChanged) - - def emitReset(self): - self.sigReset.emit() - - def emitCrop(self): - self.cancel = False - low_z = self.lowerZscrollbar.value() - 1 - high_z = self.upperZscrollbar.value() - 1 - self.sigCrop.emit(low_z, high_z) - self.close() - - def updateScrollbars(self, lower_z, upper_z): - self.lowerZscrollbar.setValue(lower_z + 1) - self.upperZscrollbar.setValue(upper_z + 1) - - def ZvalueChanged(self, value): - which = "lower" if self.sender() == self.lowerZscrollbar else "upper" - if which == "lower" and value > self.upperZscrollbar.value() - 1: - self.lowerZscrollbar.setValue(self.upperZscrollbar.value() - 1) - return - if which == "upper" and value < self.lowerZscrollbar.value() + 1: - self.upperZscrollbar.setValue(self.lowerZscrollbar.value() + 1) - return - - z_slice_n = value - 1 - self.sigZvalueChanged.emit(which, z_slice_n) - - def showEvent(self, event): - self.resize(int(self.width() * 1.5), self.height()) - - def closeEvent(self, event): - super().closeEvent(event) - self.sigClose.emit() - - -class randomWalkerDialog(QDialog): - def __init__(self, mainWindow): - super().__init__(mainWindow) - self.cancel = True - self.mainWindow = mainWindow - - if mainWindow is not None: - posData = self.mainWindow.data[self.mainWindow.pos_i] - items = [posData.filename] - else: - items = ["test"] - try: - posData = self.mainWindow.data[self.mainWindow.pos_i] - items.extend(list(posData.ol_data_dict.keys())) - except Exception as e: - pass - - self.keys = items - - self.setWindowTitle("Random walker segmentation") - - self.colors = [self.mainWindow.RWbkgrColor, self.mainWindow.RWforegrColor] - - mainLayout = QVBoxLayout() - paramsLayout = QGridLayout() - buttonsLayout = QHBoxLayout() - - self.mainWindow.clearAllItems() - - row = 0 - paramsLayout.addWidget(QLabel("Background threshold:"), row, 0) - row += 1 - self.bkgrThreshValLabel = QLabel("0.05") - paramsLayout.addWidget(self.bkgrThreshValLabel, row, 1) - self.bkgrThreshSlider = QSlider(Qt.Horizontal) - self.bkgrThreshSlider.setMinimum(1) - self.bkgrThreshSlider.setMaximum(100) - self.bkgrThreshSlider.setValue(5) - self.bkgrThreshSlider.setTickPosition(QSlider.TickPosition.TicksBelow) - self.bkgrThreshSlider.setTickInterval(10) - paramsLayout.addWidget(self.bkgrThreshSlider, row, 0) - - row += 1 - foregrQSLabel = QLabel("Foreground threshold:") - # padding: top, left, bottom, right - foregrQSLabel.setStyleSheet("font-size:13px; padding:5px 0px 0px 0px;") - paramsLayout.addWidget(foregrQSLabel, row, 0) - row += 1 - self.foregrThreshValLabel = QLabel("0.95") - paramsLayout.addWidget(self.foregrThreshValLabel, row, 1) - self.foregrThreshSlider = QSlider(Qt.Horizontal) - self.foregrThreshSlider.setMinimum(1) - self.foregrThreshSlider.setMaximum(100) - self.foregrThreshSlider.setValue(95) - self.foregrThreshSlider.setTickPosition(QSlider.TickPosition.TicksBelow) - self.foregrThreshSlider.setTickInterval(10) - paramsLayout.addWidget(self.foregrThreshSlider, row, 0) - - # Parameters link label - row += 1 - url1 = "https://scikit-image.org/docs/dev/auto_examples/segmentation/plot_random_walker_segmentation.html" - url2 = "https://scikit-image.org/docs/dev/api/skimage.segmentation.html#skimage.segmentation.random_walker" - htmlTxt1 = f'here' - htmlTxt2 = f'here' - seeHereLabel = QLabel() - seeHereLabel.setText( - f"See {htmlTxt1} and {htmlTxt2} for details " - "about Random walker segmentation." - ) - seeHereLabel.setTextFormat(Qt.RichText) - seeHereLabel.setTextInteractionFlags(Qt.TextBrowserInteraction) - seeHereLabel.setOpenExternalLinks(True) - font = QFont() - font.setPixelSize(12) - seeHereLabel.setFont(font) - seeHereLabel.setStyleSheet("padding:12px 0px 0px 0px;") - paramsLayout.addWidget(seeHereLabel, row, 0, 1, 2) - - computeButton = QPushButton("Compute segmentation") - closeButton = QPushButton("Close") - - buttonsLayout.addWidget(computeButton, alignment=Qt.AlignRight) - buttonsLayout.addWidget(closeButton, alignment=Qt.AlignLeft) - - paramsLayout.setContentsMargins(0, 10, 0, 0) - buttonsLayout.setContentsMargins(0, 10, 0, 0) - - mainLayout.addLayout(paramsLayout) - mainLayout.addLayout(buttonsLayout) - - self.bkgrThreshSlider.sliderMoved.connect(self.bkgrSliderMoved) - self.foregrThreshSlider.sliderMoved.connect(self.foregrSliderMoved) - computeButton.clicked.connect(self.computeSegmAndPlot) - closeButton.clicked.connect(self.close) - - self.setLayout(mainLayout) - - self.getImage() - self.plotMarkers() - - def getImage(self): - img = self.mainWindow.getDisplayedImg1() - self.img = img / img.max() - self.imgRGB = (skimage.color.gray2rgb(self.img) * 255).astype(np.uint8) - - def setSize(self): - x = self.pos().x() - y = self.pos().y() - h = self.size().height() - w = self.size().width() - if w < 400: - w = 400 - self.setGeometry(x, y, w, h) - - def plotMarkers(self): - imgMin, imgMax = self.computeMarkers() - - img = self.img - - imgRGB = self.imgRGB.copy() - R, G, B = self.colors[0] - imgRGB[:, :, 0][img < imgMin] = R - imgRGB[:, :, 1][img < imgMin] = G - imgRGB[:, :, 2][img < imgMin] = B - R, G, B = self.colors[1] - imgRGB[:, :, 0][img > imgMax] = R - imgRGB[:, :, 1][img > imgMax] = G - imgRGB[:, :, 2][img > imgMax] = B - - self.mainWindow.img1.setImage(imgRGB) - - def computeMarkers(self): - bkgrThresh = self.bkgrThreshSlider.sliderPosition() / 100 - foregrThresh = self.foregrThreshSlider.sliderPosition() / 100 - img = self.img - self.markers = np.zeros(img.shape, np.uint8) - imgRange = img.max() - img.min() - imgMin = img.min() + imgRange * bkgrThresh - imgMax = img.min() + imgRange * foregrThresh - self.markers[img < imgMin] = 1 - self.markers[img > imgMax] = 2 - return imgMin, imgMax - - def computeSegm(self, checked=True): - self.mainWindow.storeUndoRedoStates(False) - self.mainWindow.titleLabel.setText("Randomly walking around... ", color="w") - img = self.img - img = skimage.exposure.rescale_intensity(img) - t0 = time.time() - lab = skimage.segmentation.random_walker(img, self.markers, mode="bf") - lab = skimage.measure.label(lab > 1) - t1 = time.time() - if len(np.unique(lab)) > 2: - lab = skimage.morphology.remove_small_objects(lab, min_size=5) - posData = self.mainWindow.data[self.mainWindow.pos_i] - posData.lab = lab - return t1 - t0 - - def computeSegmAndPlot(self): - deltaT = self.computeSegm() - - posData = self.mainWindow.data[self.mainWindow.pos_i] - - self.mainWindow.update_rp() - self.mainWindow.tracking(enforce=True) - self.mainWindow.updateAllImages() - self.mainWindow.warnEditingWithCca_df("Random Walker segmentation") - txt = f"Random Walker segmentation computed in {deltaT:.3f} s" - print("-----------------") - print(txt) - print("=================") - # self.mainWindow.titleLabel.setText(txt, color='g') - - def bkgrSliderMoved(self, intVal): - self.bkgrThreshValLabel.setText(f"{intVal / 100:.2f}") - self.plotMarkers() - - def foregrSliderMoved(self, intVal): - self.foregrThreshValLabel.setText(f"{intVal / 100:.2f}") - self.plotMarkers() - - def closeEvent(self, event): - self.mainWindow.segmModel = "" - self.mainWindow.updateAllImages() - - -class FutureFramesAction_QDialog(QDialog): - def __init__( - self, - frame_i, - last_tracked_i, - change_txt, - applyTrackingB=False, - parent=None, - addApplyAllButton=False, - ): - self.decision = None - self.last_tracked_i = last_tracked_i - super().__init__(parent) - self.setWindowTitle("Future frames action?") - - mainLayout = QVBoxLayout() - txtLayout = QVBoxLayout() - doNotShowLayout = QVBoxLayout() - buttonsLayout = QVBoxLayout() - - txt = html_utils.paragraph( - "You already visited/checked future frames " - f"{frame_i + 1}-{last_tracked_i + 1}.

    " - f'The requested "{change_txt}" change might result in
    ' - "NON-correct segmentation/tracking for those frames.
    " - ) - - txtLabel = QLabel(txt) - txtLabel.setAlignment(Qt.AlignCenter) - txtLayout.addWidget(txtLabel, alignment=Qt.AlignCenter) - - options = [ - f'Apply the "{change_txt}" only to current frame and re-initialize
    ' - "the future frames to the segmentation file present
    " - "on the hard drive.", - "Apply only to this frame and keep the future frames as they are.", - "Apply the change to ALL visited/checked future frames.", - ] - if addApplyAllButton: - options.append( - "Apply to ALL future frames including unvisited ones." - ) - if applyTrackingB: - options.append("Repeat ONLY tracking for all future frames (RECOMMENDED)") - - infoTxt = html_utils.paragraph( - f"Choose one of the following options:" - f"{html_utils.to_list(options, ordered=True)}" - ) - - infotxtLabel = QLabel(infoTxt) - txtLayout.addWidget(infotxtLabel, alignment=Qt.AlignCenter) - - noteLayout = QHBoxLayout() - noteTxt = html_utils.paragraph( - "Only changes applied to current frame can be undone.
    " - "Changes applied to future frames CANNOT be UNDONE
    " - ) - noteLayout.addWidget( - QLabel(html_utils.paragraph("NOTE:")), alignment=Qt.AlignTop - ) - noteTxtLabel = QLabel(noteTxt) - noteLayout.addWidget(noteTxtLabel) - noteLayout.addStretch(1) - txtLayout.addSpacing(10) - txtLayout.addLayout(noteLayout) - - # Do not show this message again checkbox - doNotShowCheckbox = QCheckBox( - "Remember my choice and do not show this message again" - ) - doNotShowLayout.addWidget(doNotShowCheckbox) - doNotShowLayout.setContentsMargins(50, 0, 0, 10) - self.doNotShowCheckbox = doNotShowCheckbox - - apply_and_reinit_b = widgets.reloadPushButton( - " 1. Apply only to this frame and re-initialize future frames" - ) - - self.apply_and_reinit_b = apply_and_reinit_b - buttonsLayout.addWidget(apply_and_reinit_b) - - apply_and_NOTreinit_b = widgets.currentPushButton( - " 2. Apply only to this frame and keep future frames as they are" - ) - self.apply_and_NOTreinit_b = apply_and_NOTreinit_b - buttonsLayout.addWidget(apply_and_NOTreinit_b) - - apply_to_all_visited_b = widgets.futurePushButton( - " 3. Apply to all future VISITED frames" - ) - self.apply_to_all_visited_b = apply_to_all_visited_b - buttonsLayout.addWidget(apply_to_all_visited_b) - - if addApplyAllButton: - apply_to_all_b = QPushButton( - " 4. Apply to ALL future frames (including unvisted)" - ) - apply_to_all_b.setIcon(QIcon(":arrow_future_all.svg")) - self.apply_to_all_b = apply_to_all_b - buttonsLayout.addWidget(apply_to_all_b) - - self.applyTrackingButton = None - if applyTrackingB: - n = "5" if addApplyAllButton else "4" - applyTrackingButton = QPushButton( - f" {n}. Repeat ONLY tracking for all future frames" - ) - applyTrackingButton.setIcon(QIcon(":repeat-tracking.svg")) - self.applyTrackingButton = applyTrackingButton - buttonsLayout.addWidget(applyTrackingButton) - - buttonsLayout.setContentsMargins(20, 0, 20, 0) - - self.formLayout = QFormLayout() - - ButtonsGroup = QButtonGroup(self) - ButtonsGroup.addButton(apply_and_reinit_b) - ButtonsGroup.addButton(apply_and_NOTreinit_b) - ButtonsGroup.addButton(apply_to_all_visited_b) - if addApplyAllButton: - ButtonsGroup.addButton(apply_to_all_b) - if applyTrackingB: - ButtonsGroup.addButton(applyTrackingButton) - - mainLayout.addLayout(txtLayout) - mainLayout.addLayout(doNotShowLayout) - mainLayout.addLayout(buttonsLayout) - mainLayout.addLayout(self.formLayout) - mainLayout.addStretch(1) - self.mainLayout = mainLayout - self.setLayout(mainLayout) - - # Connect events - ButtonsGroup.buttonClicked.connect(self.buttonClicked) - self.ButtonsGroup = ButtonsGroup - - # self.setModal(True) - - def buttonClicked(self, button): - if button == self.apply_and_reinit_b: - self.decision = "apply_and_reinit" - self.endFrame_i = None - elif button == self.apply_and_NOTreinit_b: - self.decision = "apply_and_NOTreinit" - self.endFrame_i = None - elif button == self.apply_to_all_visited_b: - self.decision = "apply_to_all_visited" - self.endFrame_i = self.last_tracked_i - elif button == self.applyTrackingButton: - self.decision = "only_tracking" - self.endFrame_i = self.last_tracked_i - elif button == self.apply_to_all_b: - self.decision = "apply_to_all" - self.endFrame_i = self.last_tracked_i - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - for button in self.ButtonsGroup.buttons(): - button.setMinimumHeight(int(button.height() * 1.2)) - if hasattr(self, "apply_to_all_b"): - iconHeight = self.apply_to_all_b.iconSize().height() - self.apply_to_all_b.setIconSize(QSize(iconHeight * 2, iconHeight)) - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class ComputeMetricsErrorsDialog(QBaseDialog): - def __init__(self, errorsDict, log_path="", parent=None, log_type="custom_metrics"): - super().__init__(parent) - - self.errorsDict = errorsDict - - layout = QGridLayout() - - self.setWindowTitle("Errors summary") - - label = QLabel(self) - standardIcon = getattr(QStyle, "SP_MessageBoxWarning") - icon = self.style().standardIcon(standardIcon) - pixmap = icon.pixmap(60, 60) - label.setPixmap(pixmap) - layout.addWidget(label, 0, 0, alignment=Qt.AlignTop) - - if log_type == "custom_metrics": - infoText = """ - When computing custom metrics the following metrics - were ignored because they raised an error.

    - """ - elif log_type == "standard_metrics": - infoText = """ - Some or all of the standard metrics were NOT saved - because Cell-ACDC encoutered the following errors.

    - """ - elif log_type == "region_props": - rp_url = "https://scikit-image.org/docs/0.18.x/api/skimage.measure.html#skimage.measure.regionprops" - rp_href = f'skimage.measure.regionprops' - infoText = f""" - Region properties were NOT saved because Cell-ACDC - encoutered the following errors.
    - Region properties are calculated using the scikit-image - function called {rp_href}.

    - """ - elif log_type == "missing_annot": - infoText = """ - The following Positions were SKIPPED because they did - not have cell cycle annotations.

    - To add lineage tree information you first need to do the - cell cycle analysis in module 3 "Main GUI".

    - """ - else: - infoText = """ - Process raised the errors listed below.

    - """ - - github_issues_href = f"here" - noteText = f""" - NOTE: If you need help understanding these errors you can - open an issue on our github page {github_issues_href}. - """ - - infoLabel = QLabel(html_utils.paragraph(f"{infoText}{noteText}")) - infoLabel.setOpenExternalLinks(True) - layout.addWidget(infoLabel, 0, 1) - - scrollArea = QScrollArea() - scrollAreaWidget = QWidget() - textLayout = QVBoxLayout() - for func_name, traceback_format in errorsDict.items(): - nameLabel = QLabel(f"{func_name}: ") - errorMessage = f"\n{traceback_format}" - errorLabel = QLabel(errorMessage) - errorLabel.setTextInteractionFlags( - Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard - ) - # errorLabel.setStyleSheet("background-color: white") - errorLabel.setFrameShape(QFrame.Shape.Panel) - errorLabel.setFrameShadow(QFrame.Shadow.Sunken) - textLayout.addWidget(nameLabel) - textLayout.addWidget(errorLabel) - textLayout.addStretch(1) - - scrollAreaWidget.setLayout(textLayout) - scrollArea.setWidget(scrollAreaWidget) - - layout.addWidget(scrollArea, 1, 1) - - buttonsLayout = QHBoxLayout() - showLogButton = widgets.showInFileManagerButton("Show log file...") - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(showLogButton) - - copyButton = widgets.copyPushButton("Copy error message") - copyButton.clicked.connect(self.copyErrorMessage) - buttonsLayout.addWidget(copyButton) - self.copyButton = copyButton - self.copyButton.text = "Copy error message" - self.copyButton.icon = self.copyButton.icon() - - okButton = widgets.okPushButton(" Ok ") - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(okButton) - - showLogButton.clicked.connect(partial(myutils.showInExplorer, log_path)) - okButton.clicked.connect(self.close) - layout.setVerticalSpacing(10) - layout.addLayout(buttonsLayout, 2, 1) - - self.setLayout(layout) - self.setFont(font) - - def copyErrorMessage(self): - cb = QApplication.clipboard() - cb.clear(mode=cb.Clipboard) - copiedText = "" - for _, traceback_format in self.errorsDict.items(): - errorBlock = f"{'=' * 30}\n{traceback_format}{'*' * 30}" - copiedText = f"{copiedText}{errorBlock}" - cb.setText(copiedText, mode=cb.Clipboard) - print("Error message copied.") - self.copyButton.setIcon(QIcon(":okButton.svg")) - self.copyButton.setText(" Copied to clipboard!") - QTimer.singleShot(2000, self.restoreCopyButton) - - def restoreCopyButton(self): - self.copyButton.setText(self.copyButton.text) - self.copyButton.setIcon(self.copyButton.icon) - - def showEvent(self, a0) -> None: - self.copyButton.setFixedWidth(self.copyButton.width()) - return super().showEvent(a0) - - -class PostProcessSegmParams(QGroupBox): - valueChanged = Signal(object) - editingFinished = Signal() - - def __init__( - self, - title, - posData, - useSliders=False, - parent=None, - maxSize=None, - force_postprocess_2D=False, - ): - QGroupBox.__init__(self, title, parent) - SizeZ = posData.SizeZ - self.isSegm3D = posData.isSegm3D - self.channelName = posData.user_ch_name - self.useSliders = useSliders - self.force_postprocess_2D = force_postprocess_2D - if maxSize is None: - maxSize = 2147483647 - - layout = QGridLayout() - - self.controlWidgets = [] - - row = 0 - label = QLabel("Minimum area (pixels) ") - layout.addWidget(label, row, 0, alignment=Qt.AlignRight) - - minSize_SB = widgets.PostProcessSegmWidget(1, 1000, 10, useSliders, label=label) - - txt = "Area is the total number of pixels in the segmented object." - - layout.addWidget(minSize_SB, row, 1) - infoButton = widgets.infoPushButton() - infoButton.clicked.connect(self.showInfo) - infoButton.tooltip = txt - infoButton.name = "area" - infoButton.desc = f'less than "{label.text()}"' - layout.addWidget(infoButton, row, 2) - self.minSize_SB = minSize_SB - self.controlWidgets.append(minSize_SB) - - # minSize_SB.disableThisCheckbox = QCheckBox('Disable this filter') - # layout.addWidget(minSize_SB.disableThisCheckbox, row, 3) - - row += 1 - label = QLabel("Minimum solidity (0-1) ") - layout.addWidget(label, row, 0, alignment=Qt.AlignRight) - minSolidity_DSB = widgets.PostProcessSegmWidget( - 0, 1.0, 0.5, useSliders, isFloat=True, normalize=True, label=label - ) - minSolidity_DSB.setValue(0.5) - minSolidity_DSB.setSingleStep(0.1) - self.controlWidgets.append(minSolidity_DSB) - - txt = ( - "Solidity is a measure of convexity. A solidity of 1 means " - "that the shape is fully convex (i.e., equal to the convex hull). " - "As solidity approaches 0 the object is more concave.
    " - "Write 0 for ignoring this parameter." - ) - - layout.addWidget(minSolidity_DSB, row, 1) - infoButton = widgets.infoPushButton() - infoButton.clicked.connect(self.showInfo) - infoButton.tooltip = txt - infoButton.name = "solidity" - infoButton.desc = f'less than "{label.text()}"' - layout.addWidget(infoButton, row, 2) - self.minSolidity_DSB = minSolidity_DSB - - row += 1 - label = QLabel("Max elongation (1=circle) ") - layout.addWidget(label, row, 0, alignment=Qt.AlignRight) - maxElongation_DSB = widgets.PostProcessSegmWidget( - 0, 100, 3, useSliders, isFloat=True, normalize=False, label=label - ) - maxElongation_DSB.setDecimals(1) - maxElongation_DSB.setSingleStep(1.0) - - txt = ( - "Elongation is the ratio between major and minor axis lengths. " - "An elongation of 1 is like a circle.
    " - "Write 0 for ignoring this parameter." - ) - - layout.addWidget(maxElongation_DSB, row, 1) - infoButton = widgets.infoPushButton() - infoButton.clicked.connect(self.showInfo) - infoButton.tooltip = txt - infoButton.name = "elongation" - infoButton.desc = f'greater than "{label.text()}"' - layout.addWidget(infoButton, row, 2) - self.maxElongation_DSB = maxElongation_DSB - self.controlWidgets.append(maxElongation_DSB) - - if self.isSegm3D: - row += 1 - label = QLabel("Minimum number of z-slices ") - layout.addWidget(label, row, 0, alignment=Qt.AlignRight) - minObjSizeZ_SB = widgets.PostProcessSegmWidget( - 0, SizeZ, 3, useSliders, isFloat=False, normalize=False, label=label - ) - - txt = "Minimum number of z-slices per object." - - layout.addWidget(minObjSizeZ_SB, row, 1) - infoButton = widgets.infoPushButton() - infoButton.clicked.connect(self.showInfo) - infoButton.tooltip = txt - infoButton.name = "number of z-slices" - infoButton.desc = f'less than "{label.text()}"' - layout.addWidget(infoButton, row, 2) - self.minObjSizeZ_SB = minObjSizeZ_SB - self.controlWidgets.append(minObjSizeZ_SB) - else: - self.minObjSizeZ_SB = widgets.NoneWidget() - - row += 1 - addCustomFeatureLayout = QHBoxLayout() - self.addCustomFeaturesButton = widgets.setPushButton( - "Select custom features for post-processing...", - ) - addCustomFeatureLayout.addWidget(self.addCustomFeaturesButton) - addCustomFeatureLayout.addStretch(1) - self.selectedFeaturesDialog = SelectFeaturesRangeDialog( - posData=posData, parent=self, force_postprocess_2D=force_postprocess_2D - ) - self.selectedFeaturesDialog.hide() - self.addCustomFeaturesButton.clicked.connect(self.selectedFeaturesDialog.show) - self.selectedFeaturesDialog.sigValueChanged.connect(self.onValueChanged) - - layout.addLayout(addCustomFeatureLayout, row, 0, 1, 2) - - layout.setColumnStretch(1, 2) - # layout.setRowStretch(row+1, 1) - - self.setLayout(layout) - - for widget in self.controlWidgets: - widget.valueChanged.connect(self.onValueChanged) - widget.editingFinished.connect(self.onEditingFinished) - - def selectedFeaturesRange(self): - return self.selectedFeaturesDialog.groupbox.selectedFeaturesRange() - - def groupedFeatures(self): - return self.selectedFeaturesDialog.groupbox.groupedFeatures() - - def restoreDefault(self): - self.minSolidity_DSB.setValue(0.5) - self.minSize_SB.setValue(10) - self.maxElongation_DSB.setValue(3) - self.minObjSizeZ_SB.setValue(3) - self.selectedFeaturesDialog.groupbox.resetFields() - - def restoreFromKwargs(self, kwargs): - for name, value in kwargs.items(): - if name == "min_solidity": - self.minSolidity_DSB.setValue(value) - elif name == "min_area": - self.minSize_SB.setValue(value) - elif name == "max_elongation": - self.maxElongation_DSB.setValue(value) - elif name == "min_obj_no_zslices": - self.minObjSizeZ_SB.setValue(value) - - def kwargs(self): - kwargs = { - "min_solidity": self.minSolidity_DSB.value(), - "min_area": self.minSize_SB.value(), - "max_elongation": self.maxElongation_DSB.value(), - "min_obj_no_zslices": self.minObjSizeZ_SB.value(), - } - return kwargs - - def onValueChanged(self, value): - self.valueChanged.emit(value) - - def onEditingFinished(self): - self.editingFinished.emit() - - def showInfo(self): - title = f"{self.sender().text()} info" - tooltip = self.sender().tooltip - name = self.sender().name - desc = self.sender().desc - txt = f""" - The post-processing step is applied to the output of the - segmentation model.

    - During this step, Cell-ACDC will remove all the objects with {name} - {desc}.

    - {tooltip} - """ - if self.isCheckable(): - note = f"""" - You can deactivate this step by un-checking the checkbox - called "Post-processing parameters". - """ - txt = f"{txt}{note}" - msg = widgets.myMessageBox(showCentered=False) - msg.information(self, title, html_utils.paragraph(txt)) - - -class PostProcessSegmDialog(QBaseDialog): - sigClosed = Signal() - sigValueChanged = Signal(object, object) - sigEditingFinished = Signal() - sigApplyToAllFutureFrames = Signal(object, object, object) - - def __init__(self, posData, mainWin=None, useSliders=True, maxSize=None): - super().__init__(mainWin) - self.cancel = True - self.mainWin = mainWin - self.isTimelapse = False - self.isMultiPos = False - if mainWin is not None: - self.isMultiPos = len(self.mainWin.data) > 1 - self.isTimelapse = self.mainWin.data[self.mainWin.pos_i].SizeT > 1 - - self.setWindowTitle("Post-processing segmentation parameters") - self.setWindowFlags(Qt.Tool | Qt.WindowStaysOnTopHint) - - mainLayout = QVBoxLayout() - buttonsLayout = QHBoxLayout() - - self.postProcessGroupbox = PostProcessSegmParams( - "Post-processing parameters", - posData, - useSliders=useSliders, - maxSize=maxSize, - parent=mainWin, - ) - - self.postProcessGroupbox.valueChanged.connect(self.valueChanged) - self.postProcessGroupbox.editingFinished.connect(self.onEditingFinished) - - if self.isTimelapse: - applyAllButton = widgets.futurePushButton("Apply to all frames...") - applyAllButton.clicked.connect(self.applyAll_cb) - applyButton = widgets.okPushButton("Apply", isDefault=False) - applyButton.clicked.connect(self.apply_cb) - elif self.isMultiPos: - applyAllButton = widgets.futurePushButton("Apply to all Positions...") - applyAllButton.clicked.connect(self.applyAll_cb) - applyButton = widgets.okPushButton("Apply", isDefault=False) - applyButton.clicked.connect(self.apply_cb) - else: - applyAllButton = widgets.okPushButton("Apply", isDefault=False) - applyAllButton.clicked.connect(self.ok_cb) - applyButton = None - - cancelButton = widgets.cancelPushButton("Cancel") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - if applyButton is not None: - buttonsLayout.addWidget(applyButton) - buttonsLayout.addWidget(applyAllButton) - - emitEditingFinishedButton = widgets.okPushButton() - buttonsLayout.addWidget(emitEditingFinishedButton) - emitEditingFinishedButton.hide() - buttonsLayout.setContentsMargins(0, 10, 0, 0) - - mainLayout.addWidget(self.postProcessGroupbox) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - cancelButton.clicked.connect(self.cancel_cb) - - if mainWin is not None: - self.setPosData() - - def keyPressEvent(self, event) -> None: - return super().keyPressEvent(event) - - def setPosData(self): - if self.mainWin is None: - return - - self.mainWin.storeUndoRedoStates(False) - self.posData = self.mainWin.data[self.mainWin.pos_i] - # self.img.setCurrentPosIndex(self.pos_i) - # self.img.minMaxValuesMapper = self.mainWin.img1.minMaxValuesMapper - self.origLab = self.posData.lab.copy() - self.origRp = skimage.measure.regionprops(self.origLab) - self.origObjs = {obj.label: obj for obj in self.origRp} - - def valueChanged(self, value): - lab, delObjs = self.apply() - self.sigValueChanged.emit(lab, delObjs) - - def apply(self, origLab=None): - self.mainWin.warnEditingWithCca_df( - "post-processing segmentation mask", update_images=False - ) - ccaAnnotRemoved = self.mainWin.removeCcaAnnotationsCurrentFrame() - if ccaAnnotRemoved: - self.mainWin.updateAllImages() - - if origLab is None: - origLab = self.origLab.copy() - - lab, delIDs = core.post_process_segm( - origLab, return_delIDs=True, **self.postProcessGroupbox.kwargs() - ) - - if self.postProcessGroupbox.selectedFeaturesRange(): - lab, custom_delIDs = features.custom_post_process_segm( - self.posData, - self.postProcessGroupbox.groupedFeatures(), - lab, - self.posData.img_data[self.posData.frame_i], - self.posData.frame_i, - self.posData.filename, - self.posData.user_ch_name, - self.postProcessGroupbox.selectedFeaturesRange(), - return_delIDs=True, - ) - delIDs.extend(custom_delIDs) - - delObjs = {delID: self.origObjs[delID] for delID in delIDs} - return lab, delObjs - - def onEditingFinished(self): - self.sigEditingFinished.emit() - - def ok_cb(self): - self.cancel = False - self.apply() - self.onEditingFinished() - self.close() - - def apply_cb(self): - self.cancel = False - self.apply() - self.onEditingFinished() - - def applyAll_cb(self): - self.cancel = False - self.sigApplyToAllFutureFrames.emit( - self.postProcessGroupbox.kwargs(), - self.postProcessGroupbox.groupedFeatures(), - self.postProcessGroupbox.selectedFeaturesRange(), - ) - self.close() - - def cancel_cb(self): - self.cancel = True - self.close() - - def undoChanges(self): - if self.mainWin is not None: - self.posData.lab = self.origLab - self.mainWin.update_rp() - self.mainWin.updateAllImages() - - # Undo if changes were applied to all future frames - if hasattr(self, "origSegmData"): - if self.isTimelapse: - current_frame_i = self.posData.frame_i - for frame_i in range(self.posData.segmSizeT): - self.posData.frame_i = frame_i - origLab = self.origSegmData[frame_i] - lab = self.posData.allData_li[frame_i]["labels"] - if lab is None: - # Non-visited frame modify segm_data - self.posData.segm_data[frame_i] = origLab - else: - self.posData.allData_li[frame_i]["labels"] = origLab.copy() - self.posData.lab = origLab.copy() - self.mainWin.update_rp() - # Get the rest of the stored metadata based on the new lab - self.mainWin.get_data() - self.mainWin.store_data() - # Back to current frame - self.posData.frame_i = current_frame_i - self.mainWin.get_data() - self.mainWin.updateAllImages() - elif self.isMultiPos: - current_pos_i = self.mainWin.pos_i - # Apply to all future frames or future positions - for pos_i, posData in enumerate(self.mainWin.data): - self.mainWin.pos_i = pos_i - origLab = self.origSegmData[pos_i] - self.posData.allData_li[0]["labels"] = lab.copy() - # Get the rest of the stored metadata based on the new lab - self.mainWin.get_data() - self.mainWin.store_data() - # Back to current pos and current frame - self.mainWin.pos_i = current_pos_i - self.mainWin.get_data() - self.mainWin.updateAllImages() - - def show(self, block=False): - # self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show(block=False) - self.resize(int(self.width() * 1.5), self.height()) - super().show(block=block) - - def closeEvent(self, event): - self.sigClosed.emit() - if self.cancel: - self.undoChanges() - super().closeEvent(event) - - -class imageViewer(QMainWindow): - """Main Window.""" - - sigClosed = Signal() - sigHoveringImage = Signal(object, object) - - def __init__( - self, - parent=None, - posData=None, - button_toUncheck=None, - spinBox=None, - linkWindow=None, - enableOverlay=False, - isSigleFrame=False, - enableMirroredCursor=False, - ): - self.button_toUncheck = button_toUncheck - self.parent = parent - self.posData = posData - self.spinBox = spinBox - self.linkWindow = linkWindow - self.enableMirroredCursor = enableMirroredCursor - self.isSigleFrame = isSigleFrame - self.minMaxValuesMapper = None - """Initializer.""" - super().__init__(parent) - - if posData is None: - posData = self.parent.data[self.parent.pos_i] - self.posData = posData - self.enableOverlay = enableOverlay - - self.gui_createActions() - self.gui_createMenuBar() - self.gui_createToolBars() - - self.gui_createStatusBar() - - self.gui_createGraphics() - - self.gui_connectImgActions() - - self.gui_createImgWidgets() - self.gui_connectActions() - - self.gui_setSingleFrameMode(self.isSigleFrame) - - self.setupMirroredCursor() - - mainContainer = QWidget() - self.setCentralWidget(mainContainer) - - mainLayout = QGridLayout() - mainLayout.addWidget(self.graphLayout, 0, 0, 1, 1) - mainLayout.addLayout(self.img_Widglayout, 1, 0) - - mainContainer.setLayout(mainLayout) - - self.frame_i = posData.frame_i - self.num_frames = posData.SizeT - - version = myutils.read_version() - self.setWindowTitle(f"Cell-ACDC v{version} - {posData.relPath}") - - def gui_createActions(self): - # File actions - self.exitAction = QAction("&Exit", self) - - # Toolbar actions - self.prevAction = QAction("Previous frame", self) - self.nextAction = QAction("Next Frame", self) - self.jumpForwardAction = QAction("Jump to 10 frames ahead", self) - self.jumpBackwardAction = QAction("Jump to 10 frames back", self) - self.prevAction.setShortcut("left") - self.nextAction.setShortcut("right") - self.jumpForwardAction.setShortcut("up") - self.jumpBackwardAction.setShortcut("down") - self.addAction(self.nextAction) - self.addAction(self.prevAction) - self.addAction(self.jumpBackwardAction) - self.addAction(self.jumpForwardAction) - if self.enableOverlay: - self.overlayButton = widgets.rightClickToolButton(parent=self) - self.overlayButton.setIcon(QIcon(":overlay.svg")) - self.overlayButton.setCheckable(True) - - def gui_createMenuBar(self): - menuBar = self.menuBar() - # File menu - fileMenu = QMenu("&File", self) - menuBar.addMenu(fileMenu) - # fileMenu.addAction(self.newAction) - fileMenu.addAction(self.exitAction) - - def gui_createToolBars(self): - toolbarSize = 30 - - editToolBar = QToolBar("Edit", self) - editToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) - self.addToolBar(editToolBar) - - self.editToolBar = editToolBar - - if self.enableOverlay: - editToolBar.addWidget(self.overlayButton) - - if self.linkWindow: - # Insert a spacing - editToolBar.addWidget(QLabel(" ")) - self.linkWindowCheckbox = QCheckBox("Link to main GUI") - self.linkWindowCheckbox.setChecked(True) - editToolBar.addWidget(self.linkWindowCheckbox) - - if self.enableMirroredCursor: - self.showMirroredCursorCheckbox = QCheckBox( - "Show mirrored cursor from main window" - ) - self.showMirroredCursorCheckbox.setChecked(True) - editToolBar.addWidget(self.showMirroredCursorCheckbox) - - def setupMirroredCursor(self): - self.cursor = pg.ScatterPlotItem( - symbol="+", - pxMode=True, - pen=pg.mkPen("k", width=1), - brush=pg.mkBrush("w"), - size=16, - tip=None, - ) - self.Plot.addItem(self.cursor) - - def gui_connectActions(self): - self.exitAction.triggered.connect(self.close) - self.prevAction.triggered.connect(self.prev_frame) - self.nextAction.triggered.connect(self.next_frame) - self.jumpForwardAction.triggered.connect(self.skip10ahead_frames) - self.jumpBackwardAction.triggered.connect(self.skip10back_frames) - if self.enableOverlay: - self.overlayButton.toggled.connect(self.overlay_cb) - self.overlayButton.sigRightClick.connect(self.showOverlayContextMenu) - - def gui_setSingleFrameMode(self, isSingleFrame: bool): - if not isSingleFrame: - return - - self.framesScrollBar.setDisabled(True) - self.framesScrollBar.setVisible(False) - self.frameLabel.hide() - self.t_label.hide() - self.prevAction.triggered.disconnect() - self.nextAction.triggered.disconnect() - self.jumpForwardAction.triggered.disconnect() - self.jumpBackwardAction.triggered.disconnect() - self.editToolBar.setVisible(False) - - def showOverlayContextMenu(self, event): - if not self.overlayButton.isChecked(): - return - - if self.parent is not None: - self.overlayContextMenu.exec_(QCursor.pos()) - - def gui_createStatusBar(self): - self.statusbar = self.statusBar() - # Temporary message - self.statusbar.showMessage("Ready", 3000) - # Permanent widget - self.wcLabel = QLabel(f"") - self.statusbar.addPermanentWidget(self.wcLabel) - - def gui_createGraphics(self): - self.graphLayout = pg.GraphicsLayoutWidget() - - # Plot Item container for image - self.Plot = pg.PlotItem() - self.Plot.invertY(True) - self.Plot.setAspectLocked(True) - self.Plot.hideAxis("bottom") - self.Plot.hideAxis("left") - self.graphLayout.addItem(self.Plot, row=1, col=1) - - # Image Item - self.img = widgets.BaseImageItem() - self.img.setEnableAutoLevels(True) - self.Plot.addItem(self.img) - - # Image histogram - self.imgGrad = widgets.myHistogramLUTitem(isViewer=True) - self.imgGrad.gradient.showMenu = self.showLutItemOverlayContextMenu - self.imgGrad.vb.raiseContextMenu = lambda x: None - self.imgGrad.setImageItem(self.img) - self.graphLayout.addItem(self.imgGrad, row=1, col=0) - - # Current frame text - self.frameLabel = pg.LabelItem(justify="center", color="w", size="14pt") - self.frameLabel.setText(" ") - self.graphLayout.addItem(self.frameLabel, row=2, col=0, colspan=2) - - if not self.enableOverlay: - return - - def gui_createOverlayItems(self): - self.createOverlayChannelsActions() - self.overlayLayersItems = {} - for ch in self.posData.chNames: - if ch == self.parent.user_ch_name: - continue - overlayItems = self.getOverlayItems(ch) - imageItem, lutItem, alphaScrollbar = overlayItems - lutItem.vb.raiseContextMenu = lambda x: None - lutItem.gradient.showMenu = self.showLutItemOverlayContextMenu - lutItem.overlayColorButton.sigColorChanging.connect(self.updateOlColors) - self.addAlphaScrollbar(ch, imageItem, alphaScrollbar) - self.overlayLayersItems[ch] = overlayItems - self.Plot.addItem(imageItem) - - def createOverlayChannelsActions(self): - self.overlayLutItemAdditionalActions = [] - separator = QAction(self) - separator.setSeparator(True) - self.overlayLutItemAdditionalActions.append(separator) - section = self.imgGrad.gradient.menu.addSection("Select channel to adjust: ") - self.overlayLutItemAdditionalActions.append(section) - self.imgGrad.gradient.menu.removeAction(section) - - self.overlayChNamesActionGroup = QActionGroup(self) - self.overlayChNamesActionGroup.setExclusive(True) - for chName in self.posData.chNames: - action = QAction(chName, self) - action.setCheckable(True) - if chName == self.parent.user_ch_name: - action.setChecked(True) - self.overlayChNamesActionGroup.addAction(action) - self.overlayChNamesActionGroup.triggered.connect( - self.chNameGradientActionClicked - ) - - def chNameGradientActionClicked(self, action): - # Action triggered from lutItem - self.checkedOverlayChName = action.text() - if action.text() == self.posData.user_ch_name: - self.setOverlayItemsVisible("", False) - else: - self.setOverlayItemsVisible(action.text(), True) - - def showLutItemOverlayContextMenu(self, event): - lutItem = self.currentLutItem - - for action in self.overlayLutItemAdditionalActions: - try: - lutItem.gradient.menu.removeAction(action) - except Exception as e: - pass - - for action in self.overlayChNamesActionGroup.actions(): - try: - lutItem.gradient.menu.removeAction(action) - except Exception as e: - pass - - if self.overlayButton.isChecked(): - for action in self.overlayLutItemAdditionalActions: - lutItem.gradient.menu.addAction(action) - - for action in self.overlayChNamesActionGroup.actions(): - if action.text() == self.posData.user_ch_name: - lutItem.gradient.menu.addAction(action) - continue - for filename in self.posData.ol_data: - if filename.endswith(action.text()): - lutItem.gradient.menu.addAction(action) - break - if filename.endswith(f"{action.text()}_aligned"): - lutItem.gradient.menu.addAction(action) - break - - try: - # Convert QPointF to QPoint - lutItem.gradient.menu.popup(event.screenPos().toPoint()) - except AttributeError: - lutItem.gradient.menu.popup(event.screenPos()) - - def gui_connectImgActions(self): - self.img.hoverEvent = self.gui_hoverEventImg - - def gui_createImgWidgets(self): - if self.posData is None: - posData = self.parent.data[self.parent.pos_i] - else: - posData = self.posData - self.img_Widglayout = QGridLayout() - - # Frames scrollbar - self.framesScrollBar = QScrollBar(Qt.Horizontal) - # self.framesScrollBar.setFixedHeight(20) - self.framesScrollBar.setMinimum(1) - self.framesScrollBar.setMaximum(posData.SizeT) - t_label = QLabel("frame ") - _font = QFont() - _font.setPixelSize(12) - t_label.setFont(_font) - self.img_Widglayout.addWidget(t_label, 0, 0, alignment=Qt.AlignRight) - self.img_Widglayout.addWidget(self.framesScrollBar, 0, 1, 1, 20) - self.t_label = t_label - self.framesScrollBar.valueChanged.connect(self.framesScrollBarMoved) - - # z-slice scrollbar - self.zSliceScrollBar = QScrollBar(Qt.Horizontal) - # self.zSliceScrollBar.setFixedHeight(20) - self.zSliceScrollBar.setMaximum(self.posData.SizeZ - 1) - _z_label = QLabel("z-slice ") - _font = QFont() - _font.setPixelSize(12) - _z_label.setFont(_font) - self.z_label = _z_label - self.img_Widglayout.addWidget(_z_label, 1, 0, alignment=Qt.AlignCenter) - self.img_Widglayout.addWidget(self.zSliceScrollBar, 1, 1, 1, 20) - - if self.posData.SizeZ == 1: - self.zSliceScrollBar.setDisabled(True) - self.zSliceScrollBar.setVisible(False) - _z_label.setVisible(False) - - self.img_Widglayout.setContentsMargins(100, 0, 50, 0) - self.zSliceScrollBar.valueChanged.connect(self.update_z_slice) - - if self.enableOverlay: - self.setOverlayColors() - self.gui_createOverlayItems() - self.createOverlayContextMenu() - - self.img.alphaScrollbar = self.addAlphaScrollbar( - self.parent.user_ch_name, self.img - ) - - def getOverlayItems(self, channelName): - imageItem = pg.ImageItem() - imageItem.setOpacity(0.5) - - lutItem = widgets.myHistogramLUTitem(isViewer=True) - - lutItem.setImageItem(imageItem) - lutItem.vb.raiseContextMenu = lambda x: None - initColor = self.overlayRGBs.pop(0) - self.parent.initColormapOverlayLayerItem(initColor, lutItem) - lutItem.addOverlayColorButton(initColor, channelName) - lutItem.initColor = initColor - lutItem.hide() - - alphaScrollBar = self.addAlphaScrollbar(channelName, imageItem) - return imageItem, lutItem, alphaScrollBar - - def setMirroredCursorPos(self, x, y): - if not self.enableMirroredCursor: - return - - if not self.showMirroredCursorCheckbox.isChecked(): - return - - self.cursor.setData([x], [y]) - - def setOverlayColors(self): - self.overlayRGBs = [ - (255, 255, 0), - (252, 72, 254), - (49, 222, 134), - (22, 108, 27), - ] - cmap = matplotlib.colormaps["gist_rainbow"] - self.overlayRGBs.extend( - [tuple([round(c * 255) for c in cmap(i)][:3]) for i in np.linspace(0, 1, 8)] - ) - - def setOpacityOverlayLayersItems(self, value, imageItem=None): - if imageItem is None: - imageItem = self.sender().imageItem - alpha = value / self.sender().maximum() - else: - alpha = value - imageItem.setOpacity(alpha) - - def overlay_cb(self, checked): - if checked: - if self.posData.ol_data is None: - selectedChannels = self.askSelectOverlayChannel() - if selectedChannels is None: - self.overlayButton.toggled.disconnect() - self.overlayButton.setChecked(False) - self.overlayButton.toggled.connect(self.overlay_cb) - return - success = self.parent.loadOverlayData(selectedChannels) - if not success: - return False - lastChannel = selectedChannels[-1] - self.checkedOverlayChName = lastChannel - imageItem = self.overlayLayersItems[lastChannel][0] - self.setOpacityOverlayLayersItems(0.5, imageItem=imageItem) - self.img.setOpacity(0.5) - self.setCheckedOverlayContextMenusActions(selectedChannels) - else: - self.checkedOverlayChName = self.parent.imgGrad.checkedChannelname - selectedChannels = self.parent.checkedOverlayChannels - self.setCheckedOverlayContextMenusActions(selectedChannels) - self.setOverlayItemsVisible(self.checkedOverlayChName, True) - else: - self.img.setOpacity(1.0) - self.setOverlayItemsVisible("", False) - for items in self.overlayLayersItems.values(): - imageItem = items[0] - imageItem.clear() - self.update_img() - - def createOverlayContextMenu(self): - ch_names = [ - ch for ch in self.posData.chNames if ch != self.posData.user_ch_name - ] - self.overlayContextMenu = QMenu() - self.overlayContextMenu.addSeparator() - self.checkedOverlayChannels = set() - for chName in ch_names: - action = QAction(chName, self.overlayContextMenu) - action.setCheckable(True) - action.toggled.connect(self.overlayChannelToggled) - self.overlayContextMenu.addAction(action) - - def setCheckedOverlayContextMenusActions(self, channelNames): - for action in self.overlayContextMenu.actions(): - if action.text() not in channelNames: - continue - action.setChecked(True) - self.checkedOverlayChannels.add(action.text()) - - def overlayChannelToggled(self, checked): - # Action toggled from overlayButton context menu - channelName = self.sender().text() - if checked: - posData = self.posData - if channelName not in posData.loadedFluoChannels: - self.parent.loadOverlayData([channelName], addToExisting=True) - self.setOverlayItemsVisible(channelName, True) - self.checkedOverlayChannels.add(channelName) - self.updateOlColors(None) - else: - self.checkedOverlayChannels.remove(channelName) - imageItem = self.overlayLayersItems[channelName][0] - imageItem.clear() - try: - channelToShow = next(iter(self.checkedOverlayChannels)) - self.setOverlayItemsVisible(channelToShow, True) - except StopIteration: - self.setOverlayItemsVisible("", False) - self.update_img() - - def updateOlColors(self, button): - lutItem = self.overlayLayersItems[self.checkedOverlayChName][1] - rgb = lutItem.overlayColorButton.color().getRgb()[:3] - self.parent.initColormapOverlayLayerItem(rgb, lutItem) - lutItem.overlayColorButton.setColor(rgb) - - def addAlphaScrollbar(self, channelName, imageItem, alphaScrollBar=None): - if alphaScrollBar is None: - alphaScrollBar = QScrollBar(Qt.Horizontal) - label = QLabel(f"Alpha {channelName}") - label.setFont(font) - label.hide() - alphaScrollBar.imageItem = imageItem - alphaScrollBar.label = label - alphaScrollBar.setFixedHeight(self.parent.h) - alphaScrollBar.hide() - alphaScrollBar.setMinimum(0) - alphaScrollBar.setMaximum(40) - alphaScrollBar.setValue(20) - alphaScrollBar.setToolTip( - f"Control the alpha value of the overlaid channel {channelName}.\n" - "alpha=0 results in NO overlay,\n" - "alpha=1 results in only fluorescence data visible" - ) - self.img_Widglayout.addWidget( - alphaScrollBar.label, 2, 0, alignment=Qt.AlignRight - ) - self.img_Widglayout.addWidget(alphaScrollBar, 2, 1, 1, 20) - sp = alphaScrollBar.label.sizePolicy() - sp.setRetainSizeWhenHidden(True) - alphaScrollBar.label.setSizePolicy(sp) - - sp = alphaScrollBar.sizePolicy() - sp.setRetainSizeWhenHidden(True) - alphaScrollBar.setSizePolicy(sp) - - alphaScrollBar.valueChanged.connect(self.setOpacityOverlayLayersItems) - return alphaScrollBar - - def setOverlayItemsVisible(self, channelName, visible): - if visible: - self.imgGrad.hide() - self.img.alphaScrollbar.hide() - self.img.alphaScrollbar.label.hide() - try: - self.graphLayout.removeItem(self.imgGrad) - except Exception as e: - pass - itemsToShow = None - for name, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB = items - if name == channelName: - itemsToShow = items - else: - lutItem.hide() - alphaSB.hide() - alphaSB.label.hide() - try: - self.graphLayout.removeItem(lutItem) - except Exception as e: - pass - - if itemsToShow is None: - self.graphLayout.addItem(self.imgGrad, row=1, col=0) - self.imgGrad.show() - self.currentLutItem = self.imgGrad - self.img.alphaScrollbar.show() - self.img.alphaScrollbar.label.show() - else: - _, lutItem, alphaSB = itemsToShow - lutItem.show() - alphaSB.show() - alphaSB.label.show() - self.currentLutItem = lutItem - self.graphLayout.addItem(lutItem, row=1, col=0) - else: - if self.overlayButton.isChecked(): - self.img.alphaScrollbar.show() - self.img.alphaScrollbar.label.show() - else: - self.img.alphaScrollbar.hide() - self.img.alphaScrollbar.label.hide() - for name, items in self.overlayLayersItems.items(): - _, lutItem, alphaSB = items - lutItem.hide() - alphaSB.hide() - alphaSB.label.hide() - try: - self.graphLayout.removeItem(lutItem) - except Exception as e: - pass - self.graphLayout.addItem(self.imgGrad, row=1, col=0) - self.imgGrad.show() - self.currentLutItem = self.imgGrad - - def framesScrollBarMoved(self, frame_n): - self.frame_i = frame_n - 1 - self.t_label.setText(f"frame n. {self.frame_i + 1}/{self.num_frames}") - if self.spinBox is not None: - self.spinBox.setValue(frame_n) - self.update_img() - - def gui_hoverEventImg(self, event): - # Update x, y, value label bottom right - try: - x, y = event.pos() - xdata, ydata = int(x), int(y) - _img = self.img.image - Y, X = _img.shape - if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: - val = _img[ydata, xdata] - self.wcLabel.setText(f"(x={x:.2f}, y={y:.2f}, value={val:.2f})") - else: - self.wcLabel.setText(f"") - except Exception as e: - self.wcLabel.setText(f"") - - emitHovering = ( - self.enableMirroredCursor and self.showMirroredCursorCheckbox.isChecked() - ) - if emitHovering: - if event.isExit(): - x, y = None, None - else: - x, y = event.pos() - self.sigHoveringImage.emit(x, y) - self.cursor.setData([], []) - - def next_frame(self): - if self.frame_i < self.num_frames - 1: - self.frame_i += 1 - else: - self.frame_i = 0 - self.update_img() - - def prev_frame(self): - if self.frame_i > 0: - self.frame_i -= 1 - else: - self.frame_i = self.num_frames - 1 - self.update_img() - - def skip10ahead_frames(self): - if self.frame_i < self.num_frames - 10: - self.frame_i += 10 - else: - self.frame_i = 0 - self.update_img() - - def skip10back_frames(self): - if self.frame_i > 9: - self.frame_i -= 10 - else: - self.frame_i = self.num_frames - 1 - self.update_img() - - def update_z_slice(self, z): - if self.posData is None: - posData = self.parent.data[self.parent.pos_i] - else: - posData = self.posData - idx = (posData.filename, posData.frame_i) - posData.segmInfo_df.at[idx, "z_slice_used_gui"] = z - - self.z_label.setText(f"z-slice {z + 1:02}/{posData.SizeZ}") - self.img.setCurrentZsliceIndex(z) - self.update_img() - - def getImage(self): - posData = self.posData - frame_i = self.frame_i - if posData.SizeZ > 1: - idx = (posData.filename, frame_i) - z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] - zProjHow = posData.segmInfo_df.at[idx, "which_z_proj_gui"] - img = posData.img_data[frame_i] - if zProjHow == "single z-slice": - self.zSliceScrollBar.setSliderPosition(z) - self.z_label.setText(f"z-slice {z + 1:02}/{posData.SizeZ}") - img = img[z].copy() - elif zProjHow == "max z-projection": - img = img.max(axis=0).copy() - elif zProjHow == "mean z-projection": - img = img.mean(axis=0).copy() - elif zProjHow == "median z-proj.": - img = np.median(img, axis=0).copy() - else: - img = posData.img_data[frame_i].copy() - return img - - def update_img(self): - self.frameLabel.setText(f"Current frame = {self.frame_i + 1}/{self.num_frames}") - if self.parent is None: - img = self.getImage() - else: - img = self.parent.getImage(frame_i=self.frame_i, raw=True) - - self.img.setCurrentFrameIndex(self.frame_i) - self.img.setImage(img) - self.framesScrollBar.setSliderPosition(self.frame_i + 1) - - if not self.enableOverlay: - return - - if not self.overlayButton.isChecked(): - return - - self.setOverlayImages(frame_i=self.frame_i) - - def askSelectOverlayChannel(self): - ch_names = [ - ch for ch in self.posData.chNames if ch != self.posData.user_ch_name - ] - selectFluo = widgets.QDialogListbox( - "Select channel", - "Select channel names to overlay:\n", - ch_names, - multiSelection=True, - parent=self, - ) - selectFluo.exec_() - if selectFluo.cancel: - return - - return selectFluo.selectedItemsText - - def setOverlayImages(self, frame_i=None): - posData = self.posData - for filename in posData.ol_data: - chName = myutils.get_chname_from_basename( - filename, posData.basename, remove_ext=False - ) - if chName not in self.checkedOverlayChannels: - continue - - imageItem = self.overlayLayersItems[chName][0] - ol_img = self.parent.getOlImg(filename, frame_i=frame_i) - imageItem.setImage(ol_img) - - def closeEvent(self, event): - if self.button_toUncheck is not None: - self.button_toUncheck.setChecked(False) - self.sigClosed.emit() - - def show(self, left=None, top=None): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - QMainWindow.show(self) - try: - self.framesScrollBar.setFixedHeight(self.parent.h) - except Exception as e: - pass - try: - self.zSliceScrollBar.setFixedHeight(self.parent.h) - except Exception as e: - pass - - try: - self.img.alphaScrollbar.setFixedHeight(self.parent.h) - except Exception as e: - pass - if left is not None and top is not None: - self.setGeometry(left, top, 850, 800) - - -class TreeSelectorDialog(QBaseDialog): - sigItemDoubleClicked = Signal(object) - - def __init__( - self, - title="Tree selector", - infoTxt="", - parent=None, - multiSelection=True, - widthFactor=None, - heightFactor=None, - expandOnDoubleClick=False, - isTopLevelSelectable=True, - allItemsExpanded=True, - allowNoSelection=True, - ): - super().__init__(parent) - - self.setWindowTitle(title) - - self.cancel = True - self.widthFactor = widthFactor - self.heightFactor = heightFactor - self.allItemsExpanded = allItemsExpanded - self.mainLayout = QVBoxLayout() - self._isTopLevelSelectable = isTopLevelSelectable - self.allowNoSelection = allowNoSelection - - if infoTxt: - self.mainLayout.addWidget(QLabel(html_utils.paragraph(infoTxt))) - - self.treeWidget = widgets.TreeWidget(multiSelection=multiSelection) - self.treeWidget.setExpandsOnDoubleClick(expandOnDoubleClick) - self.treeWidget.setHeaderHidden(True) - self.mainLayout.addWidget(self.treeWidget) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - self.mainLayout.addSpacing(20) - self.mainLayout.addLayout(buttonsLayout) - - self.buttonsLayout = buttonsLayout - - self.setLayout(self.mainLayout) - - self.treeWidget.itemClicked.connect(self.onItemClicked) - self.treeWidget.itemDoubleClicked.connect(self.onItemDoubleClicked) - - def onItemDoubleClicked(self, item): - self.sigItemDoubleClicked.emit(item) - - def onItemClicked(self, item): - if self._isTopLevelSelectable: - return - if item.parent() is None: - item.setSelected(False) - - def addTree(self, tree: dict): - for topLevel, children in tree.items(): - topLevelItem = widgets.TreeWidgetItem(self.treeWidget) - topLevelItem.setText(0, topLevel) - self.treeWidget.addTopLevelItem(topLevelItem) - childrenItems = [widgets.TreeWidgetItem([c]) for c in children] - topLevelItem.addChildren(childrenItems) - if not self.allItemsExpanded: - continue - topLevelItem.setExpanded(True) - - def resizeVertical(self): - if not self.isVisible(): - self.show() - - currentTreeWidgetHeight = self.treeWidget.height() - treeWidgetHeight = 0 - for i in range(self.treeWidget.topLevelItemCount()): - topLevelItem = self.treeWidget.topLevelItem(i) - rect = self.treeWidget.visualItemRect(topLevelItem) - treeWidgetHeight += rect.height() - for j in range(topLevelItem.childCount()): - childItem = topLevelItem.child(j) - rect = self.treeWidget.visualItemRect(childItem) - treeWidgetHeight += rect.height() - - deltaHeight = treeWidgetHeight - currentTreeWidgetHeight + 10 - self.resize(self.width(), self.height() + deltaHeight) - self.move(self.x(), 20) - - def setCurrentItem(self, itemText: dict): - if not itemText: - return - for i in range(self.treeWidget.topLevelItemCount()): - topLevelItem = self.treeWidget.topLevelItem(i) - topLevelName = topLevelItem.text(0) - childText = itemText.get(topLevelName) - if childText is None: - continue - for j in range(topLevelItem.childCount()): - childItem = topLevelItem.child(j) - childItemText = childItem.text(0) - if childItemText == childText: - childItem.setSelected(True) - topLevelItem.setExpanded(True) - self.treeWidget.scrollToItem(topLevelItem) - break - - def selectedItems(self): - self._selectedItems = {} - for i in range(self.treeWidget.topLevelItemCount()): - topLevelItem = self.treeWidget.topLevelItem(i) - topLevelName = topLevelItem.text(0) - for j in range(topLevelItem.childCount()): - childItem = topLevelItem.child(j) - if not childItem.isSelected(): - continue - if topLevelName not in self._selectedItems: - self._selectedItems[topLevelName] = [childItem.text(0)] - else: - self._selectedItems[topLevelName].append(childItem.text(0)) - return self._selectedItems - - def warnSelectionIsEmpty(self): - txt = html_utils.paragraph(""" - You did not select anything :(.

    - Please press Cancel to exit without selecting items. - Thanks! - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Selection is empty", txt) - - def ok_cb(self): - if not self.allowNoSelection and not self.selectedItems(): - self.warnSelectionIsEmpty() - return - self.cancel = False - self.close() - - def showEvent(self, event) -> None: - super().showEvent(event) - if self.widthFactor is not None: - self.resize(int(self.width() * self.widthFactor), self.height()) - if self.heightFactor is not None: - self.resize(self.width(), int(self.height() * self.heightFactor)) - - -class TreesSelectorDialog(QBaseDialog): - def __init__( - self, trees, groupsDescr=None, title="Trees selector", infoTxt="", parent=None - ): - super().__init__(parent) - - self.setWindowTitle(title) - - self.cancel = True - self.mainLayout = QVBoxLayout() - - if infoTxt: - self.mainLayout.addWidget(QLabel(html_utils.paragraph(infoTxt))) - - self.treeWidgets = {} - self.setLayout(self.mainLayout) - - createdGroupLayouts = {} - for treeName, tree in trees.items(): - if groupsDescr is None: - groupName = "" - else: - groupName = groupsDescr.get(treeName, "Group info missing") - groupLayout = createdGroupLayouts.get(groupName, None) - if groupLayout is None: - self.mainLayout.addWidget(QLabel(html_utils.paragraph(groupName))) - groupBox = QGroupBox() - self.mainLayout.addWidget(groupBox) - groupLayout = QVBoxLayout() - groupBox.setLayout(groupLayout) - createdGroupLayouts[groupName] = groupLayout - else: - groupLayout.addSpacing(10) - groupLayout.addWidget(QLabel(html_utils.paragraph(treeName))) - treeWidget = widgets.TreeWidget(multiSelection=True) - treeWidget.setHeaderHidden(True) - for topLevel, children in tree.items(): - topLevelItem = widgets.TreeWidgetItem(treeWidget) - topLevelItem.setText(0, topLevel) - treeWidget.addTopLevelItem(topLevelItem) - childrenItems = [widgets.TreeWidgetItem([c]) for c in children] - topLevelItem.addChildren(childrenItems) - topLevelItem.setExpanded(True) - self.treeWidgets[treeName] = treeWidget - groupLayout.addWidget(treeWidget) - self.mainLayout.addSpacing(20) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - self.mainLayout.addSpacing(10) - self.mainLayout.addLayout(buttonsLayout) - - def ok_cb(self): - self.cancel = False - self.selectedItems = {} - for treeName, treeWidget in self.treeWidgets.items(): - for i in range(treeWidget.topLevelItemCount()): - topLevelItem = treeWidget.topLevelItem(i) - for j in range(topLevelItem.childCount()): - childItem = topLevelItem.child(j) - if not childItem.isSelected(): - continue - if treeName not in self.selectedItems: - self.selectedItems[treeName] = [childItem.text(0)] - else: - self.selectedItems[treeName].append(childItem.text(0)) - self.close() - - -class MultiListSelector(QBaseDialog): - def __init__( - self, - lists: dict, - groupsDescr: dict = None, - title="Lists selector", - infoTxt="", - parent=None, - ): - super().__init__(parent) - - self.setWindowTitle(title) - - self.cancel = True - mainLayout = QVBoxLayout() - - if infoTxt: - mainLayout.addWidget(QLabel(html_utils.paragraph(infoTxt))) - - self.listWidgets = {} - createdGroupLayouts = {} - for listName, listItems in lists.items(): - if groupsDescr is None: - groupName = "" - else: - groupName = groupsDescr.get(listName, "Group info missing") - groupLayout = createdGroupLayouts.get(listName, None) - if groupLayout is None: - mainLayout.addWidget(QLabel(html_utils.paragraph(groupName))) - groupBox = QGroupBox() - mainLayout.addWidget(groupBox) - groupLayout = QVBoxLayout() - groupBox.setLayout(groupLayout) - createdGroupLayouts[groupName] = groupLayout - else: - groupLayout.addSpacing(10) - groupLayout.addWidget(QLabel(html_utils.paragraph(listName))) - listWidget = widgets.listWidget() - listWidget.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - listWidget.addItems(listItems) - groupLayout.addWidget(listWidget) - mainLayout.addSpacing(20) - self.listWidgets[listName] = listWidget - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addSpacing(10) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def ok_cb(self): - self.cancel = False - self.selectedItems = {} - for listName, listWidget in self.listWidgets.items(): - if not listWidget.selectedItems(): - continue - self.selectedItems[listName] = [ - item.text() for item in listWidget.selectedItems() - ] - self.close() - - -class selectPositionsMultiExp(QBaseDialog): - def __init__(self, expPaths: dict, infoPaths: dict = None, parent=None): - super().__init__(parent=parent) - - self.expPaths = expPaths - self.cancel = True - - mainLayout = QVBoxLayout() - - self.setWindowTitle("Select Positions to process") - - infoTxt = html_utils.paragraph( - "Select one or more Positions to process

    " - "Click on experiment path to select all positions
    " - "Ctrl+Click to select multiple items
    " - "Shift+Click to select a range of items
    ", - center=True, - ) - infoLabel = QLabel(infoTxt) - - self.treeWidget = QTreeWidget() - self.treeWidget.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - self.treeWidget.setHeaderHidden(True) - self.treeWidget.setFont(font) - for exp_path, positions in expPaths.items(): - pathLevels = exp_path.split(os.sep) - posFoldersInfo = None - if infoPaths is not None: - posFoldersInfo = infoPaths.get(exp_path) - if len(pathLevels) > 4: - itemText = os.path.join(*pathLevels[-4:]) - itemText = f"...{itemText}" - else: - itemText = exp_path - exp_path_item = QTreeWidgetItem([itemText]) - exp_path_item.setToolTip(0, exp_path) - exp_path_item.full_path = exp_path - self.treeWidget.addTopLevelItem(exp_path_item) - postions_items = [] - for pos in positions: - if posFoldersInfo is not None: - status = posFoldersInfo.get(pos, "") - else: - status = "" - pos_item_text = f"{pos}{status}" - pos_item = QTreeWidgetItem(exp_path_item, [pos_item_text]) - pos_item.posFoldername = pos - postions_items.append(pos_item) - exp_path_item.addChildren(postions_items) - exp_path_item.setExpanded(True) - - self.treeWidget.itemClicked.connect(self.selectAllChildren) - - buttonsLayout = QHBoxLayout() - cancelButton = widgets.cancelPushButton("Cancel") - okButton = widgets.okPushButton(" Ok ") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(okButton) - - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - - mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) - mainLayout.addWidget(self.treeWidget) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - self.setStyleSheet(TREEWIDGET_STYLESHEET) - - def selectAllChildren(self, item, col): - if item.parent() is not None: - return - - for i in range(item.childCount()): - item.child(i).setSelected(True) - - def ok_cb(self): - if not self.treeWidget.selectedItems(): - msg = widgets.myMessageBox(wrapText=False) - txt = "You did not select any experiment/Position folder!" - msg.warning(self, "Empty selection!", html_utils.paragraph(txt)) - return - - self.cancel = False - self.selectedPaths = {} - for item in self.treeWidget.selectedItems(): - if item.parent() is None: - continue - parent = item.parent() - exp_path = parent.full_path - pos_folder = item.posFoldername - if exp_path not in self.selectedPaths: - self.selectedPaths[exp_path] = [] - self.selectedPaths[exp_path].append(pos_folder) - - self.close() - - def showEvent(self, event): - self.resize(int(self.width() * 2), self.height()) - - -class editCcaTableWidget(QDialog): - sigApplyChangesFutureFrames = Signal(object, int) - - def __init__( - self, - cca_df, - SizeT, - title="Edit cell cycle annotations", - parent=None, - current_frame_i=0, - ): - self.inputCca_df = cca_df - self.cancel = True - self.SizeT = SizeT - self.cca_df = None - self.current_frame_i = current_frame_i - - super().__init__(parent) - self.setWindowTitle(title) - - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - - # Layouts - mainLayout = QVBoxLayout() - headerLayout = QGridLayout() - tableLayout = QGridLayout() - buttonsLayout = QHBoxLayout() - self.scrollArea = QScrollArea() - self.viewBox = QWidget() - - # Header labels - col = 0 - row = 0 - IDsLabel = QLabel("Cell ID") - AC = Qt.AlignCenter - IDsLabel.setAlignment(AC) - headerLayout.addWidget(IDsLabel, 0, col, alignment=AC) - - col += 1 - ccsLabel = QLabel("Cell cycle stage") - ccsLabel.setAlignment(Qt.AlignCenter) - headerLayout.addWidget(ccsLabel, 0, col, alignment=AC) - - col += 1 - relIDLabel = QLabel("Relative ID") - relIDLabel.setAlignment(Qt.AlignCenter) - headerLayout.addWidget(relIDLabel, 0, col, alignment=AC) - - col += 1 - genNumLabel = QLabel("Generation number") - genNumLabel.setAlignment(Qt.AlignCenter) - headerLayout.addWidget(genNumLabel, 0, col, alignment=AC) - genNumColWidth = genNumLabel.sizeHint().width() - - col += 1 - relationshipLabel = QLabel("Relationship") - relationshipLabel.setAlignment(Qt.AlignCenter) - headerLayout.addWidget(relationshipLabel, 0, col, alignment=AC) - - col += 1 - emergFrameLabel = QLabel("Emerging frame num.") - emergFrameLabel.setAlignment(Qt.AlignCenter) - headerLayout.addWidget(emergFrameLabel, 0, col, alignment=AC) - - col += 1 - divitionFrameLabel = QLabel("Division frame num.") - divitionFrameLabel.setAlignment(Qt.AlignCenter) - headerLayout.addWidget(divitionFrameLabel, 0, col, alignment=AC) - - col += 1 - historyKnownLabel = QLabel("Is history known?") - historyKnownLabel.setAlignment(Qt.AlignCenter) - headerLayout.addWidget(historyKnownLabel, 0, col, alignment=AC) - - self.headerLayout = headerLayout - - tableLayout.setHorizontalSpacing(20) - self.tableLayout = tableLayout - - # Add buttons - cancelButton = widgets.cancelPushButton("Cancel") - moreInfoButton = widgets.helpPushButton("More info...") - moreInfoButton.setIcon(QIcon(":info.svg")) - applyToFutureFramesbutton = widgets.futurePushButton( - "Apply changes to future frames..." - ) - okButton = widgets.okPushButton("Ok") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(moreInfoButton) - buttonsLayout.addWidget(applyToFutureFramesbutton) - buttonsLayout.addWidget(okButton) - - # Scroll area properties - self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.scrollArea.setFrameStyle(QFrame.Shape.NoFrame) - self.scrollArea.setWidgetResizable(True) - - # Add layouts - self.viewBox.setLayout(tableLayout) - self.scrollArea.setWidget(self.viewBox) - mainLayout.addLayout(headerLayout) - mainLayout.addWidget(self.scrollArea) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - # Populate table Layout - IDs = cca_df.index - self.IDs = IDs.to_list() - relIDsOptions = [str(ID) for ID in IDs] - relIDsOptions.insert(0, "-1") - self.IDlabels = [] - self.ccsComboBoxes = [] - self.genNumSpinBoxes = [] - self.relIDComboBoxes = [] - self.relationshipComboBoxes = [] - self.emergFrameSpinBoxes = [] - self.divisFrameSpinBoxes = [] - self.emergFrameSpinPrevValues = [] - self.divisFrameSpinPrevValues = [] - self.historyKnownCheckBoxes = [] - for row, ID in enumerate(IDs): - col = 0 - IDlabel = QLabel(f"{ID}") - IDlabel.setAlignment(Qt.AlignCenter) - tableLayout.addWidget(IDlabel, row + 1, col, alignment=AC) - self.IDlabels.append(IDlabel) - - col += 1 - ccsComboBox = QComboBox() - ccsComboBox.setFocusPolicy(Qt.StrongFocus) - ccsComboBox.installEventFilter(self) - ccsComboBox.addItems(["G1", "S/G2/M"]) - ccsValue = cca_df.at[ID, "cell_cycle_stage"] - if ccsValue == "S": - ccsValue = "S/G2/M" - - try: - ccsComboBox.setCurrentText(ccsValue) - except Exception as err: - printl(ccsValue) - printl(cca_df) - raise err - tableLayout.addWidget(ccsComboBox, row + 1, col, alignment=AC) - self.ccsComboBoxes.append(ccsComboBox) - ccsComboBox.activated.connect(self.clearComboboxFocus) - - col += 1 - relIDComboBox = QComboBox() - relIDComboBox.setFocusPolicy(Qt.StrongFocus) - relIDComboBox.installEventFilter(self) - relIDComboBox.addItems(relIDsOptions) - relIDComboBox.setCurrentText(str(cca_df.at[ID, "relative_ID"])) - tableLayout.addWidget(relIDComboBox, row + 1, col) - self.relIDComboBoxes.append(relIDComboBox) - relIDComboBox.currentIndexChanged.connect(self.setRelID) - relIDComboBox.activated.connect(self.clearComboboxFocus) - - col += 1 - genNumSpinBox = widgets.SpinBox() - genNumSpinBox.setFocusPolicy(Qt.StrongFocus) - genNumSpinBox.installEventFilter(self) - genNumSpinBox.setValue(2) - genNumSpinBox.setMaximum(2147483647) - genNumSpinBox.setAlignment(Qt.AlignCenter) - genNumSpinBox.setFixedWidth(int(genNumColWidth * 2 / 3)) - genNumSpinBox.setValue(int(cca_df.at[ID, "generation_num"])) - tableLayout.addWidget(genNumSpinBox, row + 1, col, alignment=AC) - self.genNumSpinBoxes.append(genNumSpinBox) - - col += 1 - relationshipComboBox = QComboBox() - relationshipComboBox.setFocusPolicy(Qt.StrongFocus) - relationshipComboBox.installEventFilter(self) - relationshipComboBox.addItems(["mother", "bud"]) - relationshipComboBox.setCurrentText(str(cca_df.at[ID, "relationship"])) - tableLayout.addWidget(relationshipComboBox, row + 1, col) - self.relationshipComboBoxes.append(relationshipComboBox) - relationshipComboBox.currentIndexChanged.connect( - self.relationshipChanged_cb - ) - relationshipComboBox.activated.connect(self.clearComboboxFocus) - - col += 1 - emergFrameSpinBox = widgets.SpinBox() - emergFrameSpinBox.setFocusPolicy(Qt.StrongFocus) - emergFrameSpinBox.installEventFilter(self) - emergFrameSpinBox.setMaximum(SizeT) - emergFrameSpinBox.setMinimum(-1) - emergFrameSpinBox.setValue(-1) - emergFrameSpinBox.setAlignment(Qt.AlignCenter) - emergFrameSpinBox.setFixedWidth(int(genNumColWidth * 2 / 3)) - emergFrame_i = cca_df.at[ID, "emerg_frame_i"] - val = emergFrame_i + 1 if emergFrame_i >= 0 else -1 - emergFrameSpinBox.setValue(val) - tableLayout.addWidget(emergFrameSpinBox, row + 1, col, alignment=AC) - self.emergFrameSpinBoxes.append(emergFrameSpinBox) - self.emergFrameSpinPrevValues.append(emergFrameSpinBox.value()) - emergFrameSpinBox.valueChanged.connect(self.skip0emergFrame) - - col += 1 - divisFrameSpinBox = widgets.SpinBox() - divisFrameSpinBox.setFocusPolicy(Qt.StrongFocus) - divisFrameSpinBox.installEventFilter(self) - divisFrameSpinBox.setMinimum(-1) - divisFrameSpinBox.setMaximum(SizeT) - divisFrameSpinBox.setValue(-1) - divisFrameSpinBox.setAlignment(Qt.AlignCenter) - divisFrameSpinBox.setFixedWidth(int(genNumColWidth * 2 / 3)) - divisFrame_i = int(cca_df.at[ID, "division_frame_i"]) - val = divisFrame_i + 1 if divisFrame_i >= 0 else -1 - divisFrameSpinBox.setValue(val) - tableLayout.addWidget(divisFrameSpinBox, row + 1, col, alignment=AC) - self.divisFrameSpinBoxes.append(divisFrameSpinBox) - self.divisFrameSpinPrevValues.append(divisFrameSpinBox.value()) - divisFrameSpinBox.valueChanged.connect(self.skip0divisFrame) - - col += 1 - HistoryCheckBox = QCheckBox() - HistoryCheckBox.setChecked(bool(cca_df.at[ID, "is_history_known"])) - tableLayout.addWidget(HistoryCheckBox, row + 1, col, alignment=AC) - self.historyKnownCheckBoxes.append(HistoryCheckBox) - - self.setLayout(mainLayout) - - # Connect to events - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.cancel_cb) - moreInfoButton.clicked.connect(self.moreInfo) - applyToFutureFramesbutton.clicked.connect(self.applyToFutureFrames) - - # self.setModal(True) - - def getChanges(self): - newCcaDf = self.getCca_df() - changes = {} - for row in newCcaDf.itertuples(): - ID = row.Index - for col in newCcaDf.columns: - inputValue = self.inputCca_df.at[ID, col] - newValue = getattr(row, col) - if newValue == inputValue: - continue - - if ID not in changes: - changes[ID] = {col: (inputValue, newValue)} - else: - changes[ID][col] = (inputValue, newValue) - return changes - - def applyToFutureFrames(self): - txt = "Enter up to which frame you want to apply the changes
    " - win = NumericEntryDialog( - title="Stop frame", - instructions=txt, - parent=self, - minValue=1, - maxValue=self.SizeT, - currentValue=self.current_frame_i, - ) - win.exec_() - if win.cancel: - return - - stop_frame_i = win.value - changes = self.getChanges() - changes_format = myutils.format_cca_manual_changes(changes) - detailsText = ( - f"Changes that will be applied from frame n. {self.current_frame_i + 1}" - f" to frame n. {stop_frame_i + 1}:\n\n{changes_format}" - ) - txt = html_utils.paragraph(""" -Use this feature with caution!

    -Before propagating to future frames carefully inspect what changes -will be applied (see below).

    -""") - msg = widgets.myMessageBox(wrapText=False) - msg.setDetailedText(detailsText, visible=True) - msg.warning(self, "Caution!", txt, buttonsTexts=("Yes, I am sure", "Cancel")) - if msg.cancel: - return - - self.sigApplyChangesFutureFrames.emit(changes, stop_frame_i) - - def moreInfo(self, checked=True): - desc = myutils.get_cca_colname_desc() - msg = widgets.myMessageBox(parent=self) - msg.setWindowTitle("Cell cycle annotations info") - msg.setWidth(400) - msg.setIcon() - for col, txt in desc.items(): - msg.addText(html_utils.paragraph(f"{col}: {txt}")) - msg.addButton(" Ok ") - msg.exec_() - - def setRelID(self, itemIndex): - idx = self.relIDComboBoxes.index(self.sender()) - relID = self.sender().currentText() - IDofRelID = self.IDs[idx] - relIDidx = self.IDs.index(int(relID)) - relIDComboBox = self.relIDComboBoxes[relIDidx] - relIDComboBox.setCurrentText(str(IDofRelID)) - - def skip0emergFrame(self, value): - idx = self.emergFrameSpinBoxes.index(self.sender()) - prevVal = self.emergFrameSpinPrevValues[idx] - if value == 0 and value > prevVal: - self.sender().setValue(1) - self.emergFrameSpinPrevValues[idx] = 1 - elif value == 0 and value < prevVal: - self.sender().setValue(-1) - self.emergFrameSpinPrevValues[idx] = -1 - - def skip0divisFrame(self, value): - idx = self.divisFrameSpinBoxes.index(self.sender()) - prevVal = self.divisFrameSpinPrevValues[idx] - if value == 0 and value > prevVal: - self.sender().setValue(1) - self.divisFrameSpinPrevValues[idx] = 1 - elif value == 0 and value < prevVal: - self.sender().setValue(-1) - self.divisFrameSpinPrevValues[idx] = -1 - - def relationshipChanged_cb(self, itemIndex): - idx = self.relationshipComboBoxes.index(self.sender()) - ccs = self.sender().currentText() - if ccs == "bud": - self.ccsComboBoxes[idx].setCurrentText("S/G2/M") - self.genNumSpinBoxes[idx].setValue(0) - - def getCca_df(self): - ccsValues = [var.currentText() for var in self.ccsComboBoxes] - ccsValues = [val if val == "G1" else "S" for val in ccsValues] - genNumValues = [var.value() for var in self.genNumSpinBoxes] - relIDValues = [int(var.currentText()) for var in self.relIDComboBoxes] - relatValues = [var.currentText() for var in self.relationshipComboBoxes] - emergFrameValues = [ - var.value() - 1 if var.value() > 0 else -1 - for var in self.emergFrameSpinBoxes - ] - divisFrameValues = [ - var.value() - 1 if var.value() > 0 else -1 - for var in self.divisFrameSpinBoxes - ] - historyValues = [var.isChecked() for var in self.historyKnownCheckBoxes] - check_rel = [ID == relID for ID, relID in zip(self.IDs, relIDValues)] - - # Buds in S phase must have 0 as number of cycles - check_buds_S = [ - ccs == "S" and rel_ship == "bud" and not numc == 0 - for ccs, rel_ship, numc in zip(ccsValues, relatValues, genNumValues) - ] - - # Mother cells must have at least 1 as number of cycles if history known - check_mothers = [ - rel_ship == "mother" and not numc >= 1 if is_history_known else False - for rel_ship, numc, is_history_known in zip( - relatValues, genNumValues, historyValues - ) - ] - - # Buds cannot be in G1 - check_buds_G1 = [ - ccs == "G1" and rel_ship == "bud" - for ccs, rel_ship in zip(ccsValues, relatValues) - ] - - # The number of cells in S phase must be half mothers and half buds - num_moth_S = len( - [ - 0 - for ccs, rel_ship in zip(ccsValues, relatValues) - if ccs == "S" and rel_ship == "mother" - ] - ) - num_bud_S = len( - [ - 0 - for ccs, rel_ship in zip(ccsValues, relatValues) - if ccs == "S" and rel_ship == "bud" - ] - ) - - # Cells in S phase cannot have -1 as relative's ID - check_relID_S = [ - ccs == "S" and relID == -1 for ccs, relID in zip(ccsValues, relIDValues) - ] - - # Mother cells with unknown history at emergence is recommended to have - # generation number = 2 (easier downstream analysis) - check_unknown_mothers = [ - rel_ship == "mother" - and not is_history_known - and gen_num != 2 - and (emerg_frame_i == self.current_frame_i or self.current_frame_i == 0) - for rel_ship, is_history_known, gen_num, emerg_frame_i in zip( - relatValues, historyValues, genNumValues, emergFrameValues - ) - ] - - if any(check_rel): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(""" - Some cells are mother or bud of itself!

    - Make sure that the relative ID is different from the Cell ID. - """) - msg.critical(self, "Some IDs are equal to relative ID", txt) - return None - elif any(check_unknown_mothers): - txt = html_utils.paragraph(""" - We recommend to set generation number to 2 for mother cells - with unknown history
    - that just appeared
    (i.e., first cell cycle in the video).

    - While it is allowed to insert any number, knowing that these - cells start at generation number 2
    - makes downstream analysis easier.

    - What do you want to do? - """) - correctButtonText = " Fine, let me correct. " - keepButtonText = " Keep the generation number that I chose. " - buttonsTexts = (correctButtonText, keepButtonText) - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.warning(self, "Recommendation", txt, buttonsTexts=buttonsTexts) - if msg.cancel or msg.clickedButton == correctButtonText: - return None - elif any(check_buds_S): - msg = widgets.myMessageBox(wrapText=False) - title = "Bud in S/G2/M not in 0 Generation number" - txt = html_utils.paragraph( - "Some buds " - "in S phase do not have 0 as Generation number!
    " - 'Buds in S phase must have 0 as "Generation number"' - ) - msg.critical(self, title, txt) - return None - elif any(check_mothers): - msg = widgets.myMessageBox(wrapText=False) - title = "Mother not in >=1 Generation number" - txt = html_utils.paragraph( - 'Some mother cells do not have >=1 as "Generation number"!
    ' - 'Mothers MUST have >1 "Generation number"' - ) - msg.critical(self, title, txt) - return None - elif any(check_buds_G1): - msg = widgets.myMessageBox(wrapText=False) - title = "Buds in G1!" - txt = html_utils.paragraph( - "Some buds are in G1 phase!

    Buds MUST be in S/G2/M phase" - ) - msg.critical(self, title, txt) - return None - elif num_moth_S != num_bud_S: - msg = widgets.myMessageBox(wrapText=False) - title = "Number of mothers-buds mismatch!" - txt = html_utils.paragraph( - f'There are {num_moth_S} mother cells in "S/G2/M" phase,' - f"but there are {num_bud_S} bud cells.

    " - 'The number of mothers and buds in "S/G2/M" ' - "phase must be equal!" - ) - msg.critical(self, title, txt) - return None - elif any(check_relID_S): - msg = widgets.myMessageBox(wrapText=False) - title = "Relative's ID of cells in S/G2/M = -1" - txt = html_utils.paragraph( - 'Some cells are in "S/G2/M" phase but have -1 as Relative\'s ID!
    ' - 'Cells in "S/G2/M" phase must have an existing ' - "ID as Relative's ID!" - ) - msg.critical(self, title, txt) - return None - - corrected_on_frame_i = self.inputCca_df["corrected_on_frame_i"] - cca_df = pd.DataFrame( - { - "cell_cycle_stage": ccsValues, - "generation_num": genNumValues, - "relative_ID": relIDValues, - "relationship": relatValues, - "emerg_frame_i": emergFrameValues, - "division_frame_i": divisFrameValues, - "is_history_known": historyValues, - "corrected_on_frame_i": corrected_on_frame_i, - "will_divide": self.inputCca_df["will_divide"], - }, - index=self.IDs, - ) - cca_df.index.name = "Cell_ID" - - # Add missing columns - for column, default in base_cca_dict.items(): - if column in cca_df.columns: - continue - - value = self.inputCca_df.get(column, default=default) - cca_df[column] = value - - # Check that every pair of cells in S are relative of each other - proceed = self.check_ID_rel_ID_mismatches(cca_df) - if not proceed: - return None - - d = dict.fromkeys(cca_df.select_dtypes(np.int64).columns, np.int32) - cca_df = cca_df.astype(d) - return cca_df - - def check_ID_rel_ID_mismatches(self, cca_df): - ID_rel_ID_mismatches = [] - for row in cca_df.itertuples(): - if row.cell_cycle_stage == "G1": - continue - - ID = row.Index - relID = row.relative_ID - relID_of_relID = cca_df.at[relID, "relative_ID"] - - if relID_of_relID != ID: - ID_rel_ID_mismatches.append((ID, relID, relID_of_relID)) - - if not ID_rel_ID_mismatches: - return True - - items = [ - f"Cell ID {ID} has relative ID = {relID}, " - f"while cell ID {relID} has relative ID = {relID_of_relID}" - for ID, relID, relID_of_relID in ID_rel_ID_mismatches - ] - title = "`ID-relative_ID` mismatches" - txt = html_utils.paragraph( - f"`ID-relative_ID` mismatches:{html_utils.to_list(items)}" - ) - msg = widgets.myMessageBox(wrapText=False) - msg.critical(self, title, txt) - return False - - def ok_cb(self, checked): - cca_df = self.getCca_df() - if cca_df is None: - return - self.cca_df = cca_df - self.cancel = False - self.close() - - def cancel_cb(self, checked): - self.cancel = True - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - ncols = self.tableLayout.columnCount() - maxLabelWidth = max( - [ - self.headerLayout.itemAt(j).widget().sizeHint().width() - for j in range(ncols) - ] - ) - minWidth = (maxLabelWidth + 5) * ncols - self.setMinimumWidth(minWidth) - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def eventFilter(self, object, event): - # Disable wheel scroll on widgets to allow scroll only on scrollarea - if event.type() == QEvent.Type.Wheel: - event.ignore() - return True - return False - - def clearComboboxFocus(self): - self.sender().clearFocus() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class askStopFrameSegm(QDialog): - def __init__(self, user_ch_file_paths, user_ch_name, parent=None): - self.parent = parent - self.cancel = True - - super().__init__(parent) - self.setWindowTitle("Enter stop frame") - - self.visualizeWindows = [] - - mainLayout = QVBoxLayout() - buttonsLayout = QHBoxLayout() - - # Message - infoTxt = html_utils.paragraph(""" - Enter a stop frame number when to stop - segmentation for each Position loaded: - """) - infoLabel = QLabel(infoTxt, self) - infoLabel.setAlignment(Qt.AlignCenter) - # padding: top, left, bottom, right - infoLabel.setStyleSheet("padding:0px 0px 8px 0px;") - - self.dataDict = {} - - exp_path_pos_mapper = path.get_exp_path_pos_foldernames_mapper( - user_ch_file_paths - ) - - columnsLayout = QHBoxLayout() - mainScrollArea = widgets.ScrollArea() - mainScrollAreaWidget = QWidget() - mainScrollAreaWidget.setLayout(columnsLayout) - mainScrollArea.setWidget(mainScrollAreaWidget) - self.mainScrollArea = mainScrollArea - - # Form layout widget - self.spinBoxes = [] - self.tab_idx = 0 - iter_items = exp_path_pos_mapper.items() - self.groupboxScrollAreas = [] - - for col, (exp_path, pos_folders_files) in enumerate(iter_items): - groupboxScrollArea = widgets.ScrollArea() - self.groupboxScrollAreas.append(groupboxScrollArea) - groupbox = QGroupBox() - groupbox.setCheckable(False) - groupbox.setToolTip(exp_path) - groupboxLayout = QFormLayout() - groupbox.setLayout(groupboxLayout) - groupboxScrollArea.setWidget(groupbox) - columnsLayout.addWidget(groupboxScrollArea) - pos_folders = pos_folders_files["pos_foldernames"] - filenames = pos_folders_files["filenames"] - for i, pos_foldername in enumerate(pos_folders): - img_filename = filenames[i] - images_path = os.path.join(exp_path, pos_foldername, "Images") - img_path = os.path.join(images_path, img_filename) - spinBox = widgets.mySpinBox() - spinBox.sigTabEvent.connect(self.keyTabEventSpinbox) - posData = load.loadData(img_path, user_ch_name, QParent=parent) - posData.getBasenameAndChNames(qparent=self) - posData.buildPaths() - posData.loadOtherFiles( - load_segm_data=False, - load_metadata=True, - loadSegmInfo=True, - ) - spinBox.setMaximum(posData.SizeT) - stopFrameNum = posData.readLastUsedStopFrameNumber() - if stopFrameNum is None: - spinBox.setValue(posData.SizeT) - else: - spinBox.setValue(stopFrameNum) - spinBox.setAlignment(Qt.AlignCenter) - visualizeButton = widgets.viewPushButton("Visualize") - visualizeButton.clicked.connect(self.visualize_cb) - formLabel = QLabel(html_utils.paragraph(f"{pos_foldername} ")) - layout = QHBoxLayout() - layout.addWidget(formLabel, alignment=Qt.AlignRight) - layout.addWidget(spinBox) - layout.addWidget(visualizeButton) - self.dataDict[visualizeButton] = (spinBox, posData) - groupboxLayout.addRow(layout) - spinBox.idx = i - self.spinBoxes.append(spinBox) - - fm = QFontMetrics(self.font()) - elidedTitle = fm.elidedText( - exp_path, Qt.ElideLeft, groupbox.sizeHint().width() - ) - groupbox.setTitle(elidedTitle) - - mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) - mainLayout.addWidget(mainScrollArea) - - okButton = widgets.okPushButton("Ok") - okButton.setShortcut(Qt.Key_Enter) - - cancelButton = widgets.cancelPushButton("Cancel") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(okButton) - - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - # # self.setModal(True) - - def keyTabEventSpinbox(self, event, sender): - self.tab_idx += 1 - if self.tab_idx >= len(self.spinBoxes): - self.tab_idx = 0 - focusSpinbox = self.spinBoxes[self.tab_idx] - focusSpinbox.setFocus() - - def saveStopFrameNumbers(self): - for spinBox, posData in self.dataDict.values(): - posData.metadata_df.at["stop_frame_num", "values"] = spinBox.value() - posData.metadataToCsv() - - def ok_cb(self, event): - self.cancel = False - try: - self.saveStopFrameNumbers() - except Exception as err: - printl(traceback.format_exc()) - self.stopFrames = [ - spinBox.value() for spinBox, posData in self.dataDict.values() - ] - self.close() - - def closeEvent(self, event): - for window in self.visualizeWindows: - window.close() - - def visualize_cb(self, checked=True): - self.setDisabled(True) - spinBox, posData = self.dataDict[self.sender()] - print("Loading image data...") - posData.loadImgData() - posData.frame_i = spinBox.value() - 1 - win = plot.imshow( - posData.img_data, lut="gray", figure_title=posData.relPath, block=False - ) - self.visualizeWindows.append(win) - self.setDisabled(False) - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - screenSize = self.screen().size() - maxWidth = screenSize.width() - 50 - maxHeight = screenSize.height() - 100 - width, height = 0, 0 - for scrollArea in self.groupboxScrollAreas: - width += scrollArea.minimumWidthNoScrollbar() - scrollAreaHeight = scrollArea.minimumHeightNoScrollbar() - if scrollAreaHeight > height: - height = scrollAreaHeight - - width += 70 - height += self.sizeHint().height() - self.mainScrollArea.sizeHint().height() - - if width > maxWidth: - width = maxWidth - - if height > maxHeight: - height = maxHeight - - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - self.resize(width, height) - self.move(25, 50) - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class QLineEditDialog(QDialog): - def __init__( - self, - title="Entry messagebox", - msg="Entry value", - defaultTxt="", - parent=None, - allowedValues=None, - warnLastFrame=False, - isInteger=False, - isFloat=False, - stretchEntry=True, - allowEmpty=True, - allowedTextEntries=None, - allowText=False, - lastVisitedFrame=None, - allowList=False, - ): - QDialog.__init__(self, parent) - - self.loop = None - self.cancel = True - self.assignNewID = False - self.allowedValues = allowedValues - self.warnLastFrame = warnLastFrame - self.isFloat = isFloat - self.allowEmpty = allowEmpty - self.isInteger = isInteger - self.allowedTextEntries = allowedTextEntries - self.allowText = allowText - self.lastVisitedFrame = lastVisitedFrame - if allowedValues and warnLastFrame: - self.maxValue = max(allowedValues) - - self.setWindowTitle(title) - - # Layouts - mainLayout = QVBoxLayout() - LineEditLayout = QVBoxLayout() - buttonsLayout = QHBoxLayout() - - # Widgets - if not msg.startswith(" np.iinfo(np.uint32).max: - self.entryWidget.setText(str(np.iinfo(np.uint32).max)) - except Exception as e: - text = text.replace(newChar, "") - self.entryWidget.setText(text) - return - - if self.allowedValues is not None: - currentVal = self.value() - if self.allowList: - currentVal = currentVal[-1] - if currentVal not in self.allowedValues: - self.notValidLabel.setText(f"{currentVal} not existing!") - else: - self.notValidLabel.setText("") - - def warnValLessLastFrame(self, val): - msg = widgets.myMessageBox() - warn_txt = html_utils.paragraph(f""" - WARNING: saving until a frame number below the last visited - frame ({self.lastVisitedFrame}) will result in LOSS of information - about any edit or annotation you did on frames - {val + 1}-{self.lastVisitedFrame}.

    - Are you sure you want to proceed? - """) - msg.warning( - self, - "WARNING: Potential loss of information", - warn_txt, - buttonsTexts=("Cancel", "Yes, I am sure."), - ) - return msg.cancel - - def warnValMoreLastVisitedFrame(self, val): - msg = widgets.myMessageBox() - warn_txt = html_utils.paragraph(f""" - The last visited/validated frame is {self.lastVisitedFrame} - .

    - Are you sure you want to save until frame n. {val}?
    - """) - msg.warning( - self, - "Saving past last visited frame", - warn_txt, - buttonsTexts=("Cancel", "Yes, I am sure."), - ) - return msg.cancel - - def ok_cb(self, event): - if not self.allowEmpty and not self.entryWidget.text(): - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - msg.critical( - self, - "Empty text", - html_utils.paragraph("Text entry field cannot be empty"), - ) - return - if self.allowedTextEntries is not None: - if self.entryWidget.text() not in self.allowedTextEntries: - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = html_utils.paragraph( - f'"{self.entryWidget.text()}" is not a valid entry.

    ' - "Valid entries are:
    " - f"{html_utils.to_list(self.allowedTextEntries)}" - ) - msg.critical(self, "Not a valid entry", txt) - return - - if self.allowedValues: - if self.notValidLabel.text(): - return - - val = self.value() - - if self.warnLastFrame and self.lastVisitedFrame is not None: - if val < self.lastVisitedFrame: - cancel = self.warnValLessLastFrame(val) - if cancel: - return - - if self.lastVisitedFrame is not None: - if val > self.lastVisitedFrame: - cancel = self.warnValMoreLastVisitedFrame(val) - if cancel: - return - - self.cancel = False - try: - self.EntryID = int(val) - except Exception as err: - self.EntryID = val - - self.enteredValue = val - self.close() - - def cancel_cb(self, event): - self.cancel = True - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class FindIDDialog(QLineEditDialog): - def __init__(self, **kwargs): - super().__init__(**kwargs) - - self.okButton.setIcon(QIcon(":magnGlass.svg")) - self.okButton.setText(" Find ") - - -class NumericEntryDialog(QBaseDialog): - def __init__( - self, - title="Entry a value", - currentValue=0, - instructions="Entry value", - parent=None, - maxValue=None, - minValue=None, - stretch=False, - ): - super().__init__(parent=parent) - self.setWindowTitle(title) - self.cancel = False - mainLayout = QVBoxLayout() - entryLayout = QHBoxLayout() - cancelOkLayout = widgets.CancelOkButtonsLayout() - cancelOkLayout.okButton.clicked.connect(self.ok_cb) - cancelOkLayout.cancelButton.clicked.connect(self.close) - - instructionsLabel = QLabel(html_utils.paragraph(instructions)) - mainLayout.addWidget(instructionsLabel) - - if type(currentValue) == int: - self.entryWidget = widgets.SpinBox() - self.entryWidget.setValue(currentValue) - self.valueGetter = "value" - if maxValue is not None: - self.entryWidget.setMaximum(maxValue) - if minValue is not None: - self.entryWidget.setMinimum(minValue) - - if stretch: - entryLayout.addWidget(self.entryWidget) - else: - entryLayout.addStretch(1) - entryLayout.addWidget(self.entryWidget) - entryLayout.addStretch(1) - - mainLayout.addLayout(entryLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(cancelOkLayout) - - self.setLayout(mainLayout) - - def ok_cb(self): - self.cancel = False - self.value = getattr(self.entryWidget, self.valueGetter)() - self.close() - - -class EditIDDialog(QDialog): - def __init__( - self, - clickedID, - IDs, - entryID=None, - doNotShowAgain=False, - parent=None, - nextUniqueID=1, - allIDs=None, - addPropagateCheckbox=False, - ): - self.assignNewID = False - self.IDs = IDs - self.clickedID = clickedID - self.cancel = True - self.how = None - self.mergeWithExistingID = True - self.doNotAskAgainExistingID = doNotShowAgain - self.allIDs = allIDs - if allIDs is None: - self.allIDs = set(self.IDs) - self.nextUniqueID = nextUniqueID - - super().__init__(parent) - self.setWindowTitle("Edit ID") - mainLayout = QVBoxLayout() - - VBoxLayout = QVBoxLayout() - msg = QLabel(f"Replace ID {clickedID} with:") - _font = QFont() - _font.setPixelSize(12) - msg.setFont(_font) - # padding: top, left, bottom, right - msg.setStyleSheet("padding:0px 0px 3px 0px;") - VBoxLayout.addWidget(msg, alignment=Qt.AlignCenter) - - entryWidget = QLineEdit() - entryWidget.setFont(_font) - entryWidget.setAlignment(Qt.AlignCenter) - self.entryWidget = entryWidget - VBoxLayout.addWidget(entryWidget) - if entryID is not None: - entryWidget.setText(str(entryID)) - entryWidget.selectAll() - - VBoxLayout.addWidget( - QLabel(f"Next unique ID = {nextUniqueID}"), alignment=Qt.AlignCenter - ) - - VBoxLayout.addWidget(widgets.QHLine()) - - self.warnExistingIDLabel = QLabel() - self.warnExistingIDLabel.setStyleSheet("color: red") - VBoxLayout.addWidget(self.warnExistingIDLabel, alignment=Qt.AlignCenter) - - note = QLabel( - "NOTE: To replace multiple IDs at once\n" - 'write "(old ID, new ID), (old ID, new ID)" etc.' - ) - note.setFont(_font) - note.setAlignment(Qt.AlignCenter) - # padding: top, left, bottom, right - note.setStyleSheet("padding:12px 0px 0px 0px;") - VBoxLayout.addWidget(note, alignment=Qt.AlignCenter) - mainLayout.addLayout(VBoxLayout) - - self.propagateCheckbox = None - if addPropagateCheckbox: - mainLayout.addSpacing(10) - self.propagateCheckbox = QCheckBox("Apply to future frames") - mainLayout.addWidget(self.propagateCheckbox) - - buttonsLayout = QHBoxLayout() - okButton = widgets.okPushButton("Ok") - cancelButton = widgets.cancelPushButton("Cancel") - applyNewIDButton = widgets.AssignNewIDButton("Assign new, unique ID") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(applyNewIDButton) - buttonsLayout.addWidget(okButton) - - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - # Connect events - self.prevText = "" - entryWidget.textChanged[str].connect(self.onTextChanged) - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.cancel_cb) - applyNewIDButton.clicked.connect(self.assignNewIDclicked) - - # self.setModal(True) - - def onTextChanged(self, text): - self.warnExistingIDLabel.setText("") - try: - ID = int(text) - if ID in self.allIDs: - self.warnExistingIDLabel.setText(f"WARNING: ID {ID} was already used") - except Exception as err: - pass - - # Get inserted char - idx = self.entryWidget.cursorPosition() - if idx == 0: - return - - newChar = text[idx - 1] - - # Do nothing if user is deleting text - if idx == 0 or len(text) < len(self.prevText): - self.prevText = text - return - - # Do not allow chars except for "(", ")", "int", "," - m = re.search(r"\(|\)|\d|,", newChar) - if m is None: - self.prevText = text - text = text.replace(newChar, "") - self.entryWidget.setText(text) - return - - # Cast integers greater than uint32 machine limit - m_iter = re.finditer(r"\d+", self.entryWidget.text()) - for m in m_iter: - val = int(m.group()) - uint32_max = np.iinfo(np.uint32).max - if val > uint32_max: - text = self.entryWidget.text() - text = f"{text[: m.start()]}{uint32_max}{text[m.end() :]}" - self.entryWidget.setText(text) - - # Automatically close ( bracket - if newChar == "(": - text += ")" - self.entryWidget.setText(text) - self.prevText = text - - def _warnExistingID(self, existingID, newID): - warn_msg = html_utils.paragraph(f""" - ID {existingID} is already existing.

    - How do you want to proceed?
    - """) - msg = widgets.myMessageBox() - doNotAskAgainCheckbox = QCheckBox("Remember my choice and do not ask again") - swapButton = widgets.reloadPushButton(f"Swap {newID} with {existingID}") - mergeButton = widgets.mergePushButton(f"Merge {newID} with {existingID}") - msg.warning( - self, - "Existing ID", - warn_msg, - buttonsTexts=("Cancel", mergeButton, swapButton), - widgets=doNotAskAgainCheckbox, - ) - if msg.cancel: - return False - self.doNotAskAgainExistingID = doNotAskAgainCheckbox.isChecked() - self.mergeWithExistingID = msg.clickedButton == mergeButton - return True - - def assignNewIDclicked(self): - self.cancel = False - self.how = None - self.assignNewID = True - self.close() - - def ok_cb(self, event): - txt = self.entryWidget.text() - valid = False - - # Check validity of inserted text - try: - ID = int(txt) - how = [(self.clickedID, ID)] - if ID in self.IDs and not self.doNotAskAgainExistingID: - proceed = self._warnExistingID(self.clickedID, ID) - if not proceed: - return - valid = True - else: - valid = True - except ValueError: - pattern = r"\((\d+),\s*(\d+)\)" - fa = re.findall(pattern, txt) - if fa: - how = [(int(g[0]), int(g[1])) for g in fa] - valid = True - else: - valid = False - - if not valid: - err_msg = html_utils.paragraph( - "You entered invalid text. Valid text is either a single integer" - f" ID that will be used to replace ID {self.clickedID} " - "or a list of elements enclosed in parenthesis separated by a comma
    " - "such as (5, 10), (8, 27) to replace ID 5 with ID 10 and ID 8 with ID 27" - ) - msg = widgets.myMessageBox() - msg.warning(self, "Invalid entry", err_msg) - return - - self.cancel = False - self.how = how - self.doPropagateFutureFrames = False - if self.propagateCheckbox is not None: - self.doPropagateFutureFrames = self.propagateCheckbox.isChecked() - self.close() - - def cancel_cb(self, event): - self.cancel = True - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class QtSelectItems(QDialog): - def __init__( - self, - title, - items, - informativeText, - CbLabel="Select value: ", - parent=None, - showInFileManagerPath=None, - ): - self.cancel = True - self.selectedItemsText = "" - self.selectedItemsIdx = None - self.showInFileManagerPath = showInFileManagerPath - self.items = items - super().__init__(parent) - self.setWindowTitle(title) - - mainLayout = QVBoxLayout() - topLayout = QHBoxLayout() - self.topLayout = topLayout - bottomLayout = QHBoxLayout() - - stretchRow = 0 - if informativeText: - infoLabel = QLabel(informativeText) - mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) - stretchRow = 1 - - label = QLabel(CbLabel) - topLayout.addWidget(label, alignment=Qt.AlignRight) - - combobox = QComboBox(self) - combobox.addItems(items) - self.ComboBox = combobox - topLayout.addWidget(combobox) - - okButton = widgets.okPushButton("Ok") - cancelButton = widgets.cancelPushButton("Cancel") - if showInFileManagerPath is not None: - txt = myutils.get_open_filemaneger_os_string() - showInFileManagerButton = widgets.showInFileManagerButton(txt) - - bottomLayout.addStretch(1) - bottomLayout.addWidget(cancelButton) - bottomLayout.addSpacing(20) - if showInFileManagerPath is not None: - bottomLayout.addWidget(showInFileManagerButton) - bottomLayout.addWidget(okButton) - - multiPosButton = QPushButton("Multiple selection") - multiPosButton.setCheckable(True) - self.multiPosButton = multiPosButton - bottomLayout.addWidget(multiPosButton, alignment=Qt.AlignLeft) - - listBox = widgets.listWidget() - listBox.addItems(items) - listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - listBox.setCurrentRow(0) - listBox.setFont(font) - topLayout.addWidget(listBox) - listBox.hide() - self.ListBox = listBox - - mainLayout.addLayout(topLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(bottomLayout) - - self.setLayout(mainLayout) - self.mainLayout = mainLayout - self.topLayout = topLayout - - # self.setModal(True) - - # Connect events - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - multiPosButton.toggled.connect(self.toggleMultiSelection) - if showInFileManagerPath is not None: - showInFileManagerButton.clicked.connect(self.showInFileManager) - - self.setFont(font) - - def setSelectedItems(self, selectedItemsText): - if self.multiPosButton.isChecked(): - for i in range(self.ListBox.count()): - item = self.ListBox.item(i) - if item.text() in selectedItemsText: - item.setSelected(True) - else: - idx = self.items.index(selectedItemsText[0]) - self.ComboBox.setCurrentIndex(idx) - - def showInFileManager(self): - selectedTexts, _ = self.getSelectedItems() - folder = selectedTexts[0].split("(")[0].strip() - path = os.path.join(self.showInFileManagerPath, folder) - if os.path.exists(path) and os.path.isdir(path): - showPath = path - else: - showPath = self.showInFileManagerPath - myutils.showInExplorer(showPath) - - def toggleMultiSelection(self, checked): - if checked: - self.multiPosButton.setText("Single selection") - self.ComboBox.hide() - self.ListBox.show() - # Show 10 items - n = self.ListBox.count() - if n > 10: - h = sum([self.ListBox.sizeHintForRow(i) for i in range(10)]) - else: - h = sum([self.ListBox.sizeHintForRow(i) for i in range(n)]) - self.ListBox.setMinimumHeight(h + 5) - self.ListBox.setFocusPolicy(Qt.StrongFocus) - self.ListBox.setFocus() - self.ListBox.setCurrentRow(0) - self.mainLayout.setStretchFactor(self.topLayout, 2) - else: - self.multiPosButton.setText("Multiple selection") - self.ListBox.hide() - self.ComboBox.show() - self.resize(self.width(), self.singleSelectionHeight) - - def getSelectedItems(self): - if self.multiPosButton.isChecked(): - selectedItems = self.ListBox.selectedItems() - selectedItemsText = [item.text() for item in selectedItems] - selectedItemsText = natsorted(selectedItemsText) - selectedItemsIdx = [self.items.index(txt) for txt in selectedItemsText] - else: - selectedItemsText = [self.ComboBox.currentText()] - selectedItemsIdx = [self.ComboBox.currentIndex()] - return selectedItemsText, selectedItemsIdx - - def ok_cb(self, event): - self.cancel = False - self.selectedItemsText, self.selectedItemsIdx = self.getSelectedItems() - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - self.singleSelectionHeight = self.height() - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class manualSeparateGui(QMainWindow): - def __init__( - self, - lab, - ID, - img, - fontSize="12pt", - IDcolor=[255, 255, 0], - parent=None, - loop=None, - drawMode="threepoints_arc", - ): - super().__init__(parent) - self.loop = loop - self.cancel = True - self.drawMode = drawMode - self._parent = parent - self.lab = lab.copy() - self.lab[lab != ID] = 0 - self.ID = ID - self.img = skimage.exposure.equalize_adapthist(img / img.max()) - self.IDcolor = IDcolor - self.countClicks = 0 - self.prevLabs = [] - self.prevAllCutsCoords = [] - self.labelItemsIDs = [] - self.undoIdx = 0 - self.fontSize = fontSize - self.AllCutsCoords = [] - self.setWindowTitle("Split object") - # self.setGeometry(Left, Top, 850, 800) - - self.gui_createActions() - self.gui_createMenuBar() - self.gui_createToolBars() - - self.gui_createStatusBar() - - self.gui_createGraphics() - self.gui_connectImgActions() - - self.gui_createImgWidgets() - self.gui_connectActions() - - self.updateImg() - self.zoomToObj() - - mainContainer = QWidget() - self.setCentralWidget(mainContainer) - - mainLayout = QGridLayout() - mainLayout.addWidget(self.graphLayout, 0, 0, 1, 1) - mainLayout.addLayout(self.img_Widglayout, 1, 0) - - mainContainer.setLayout(mainLayout) - - self.setWindowModality(Qt.WindowModal) - - def centerWindow(self): - parent = self._parent - if parent is not None: - # Center the window on main window - mainWinGeometry = parent.geometry() - mainWinLeft = mainWinGeometry.left() - mainWinTop = mainWinGeometry.top() - mainWinWidth = mainWinGeometry.width() - mainWinHeight = mainWinGeometry.height() - mainWinCenterX = int(mainWinLeft + mainWinWidth / 2) - mainWinCenterY = int(mainWinTop + mainWinHeight / 2) - winGeometry = self.geometry() - winWidth = winGeometry.width() - winHeight = winGeometry.height() - winLeft = int(mainWinCenterX - winWidth / 2) - winRight = int(mainWinCenterY - winHeight / 2) - self.move(winLeft, winRight) - - def gui_createActions(self): - # File actions - self.exitAction = QAction("&Exit", self) - self.helpAction = QAction("Help", self) - self.undoAction = QAction(QIcon(":undo.svg"), "Undo (Ctrl+Z)", self) - self.undoAction.setEnabled(False) - self.undoAction.setShortcut("Ctrl+Z") - - self.okAction = QAction(QIcon(":applyCrop.svg"), "Happy with that", self) - self.cancelAction = QAction(QIcon(":cancel.svg"), "Cancel", self) - - self.drawModesActionGroup = QActionGroup(self) - - self.threePointsArcAction = QAction( - QIcon(":threepoints_arc.svg"), "Separate with three-points arc", self - ) - self.threePointsArcAction.setCheckable(True) - self.threePointsArcAction.drawMode = "threepoints_arc" - self.drawModesActionGroup.addAction(self.threePointsArcAction) - - self.freeHandAction = QAction( - QIcon(":freehand.svg"), "Separate with freehand line", self - ) - self.freeHandAction.setCheckable(True) - self.freeHandAction.drawMode = "freehand" - self.drawModesActionGroup.addAction(self.freeHandAction) - - if self.drawMode == "threepoints_arc": - self.threePointsArcAction.setChecked(True) - elif self.drawMode == "freehand": - self.freeHandAction.setChecked(True) - - self.swapIDsAction = QAction(QIcon(":reload.svg"), "Swap IDs", self) - self.swapIDsAction.setToolTip('Swap the two displayed IDs\n\nShortcut: "S"') - self.swapIDsAction.setShortcut("S") - - def state(self): - return { - "is_overlay_active": self.overlayButton.isChecked(), - "is_three_points_active": self.threePointsArcAction.isChecked(), - "is_free_hand_active": self.freeHandAction.isChecked(), - } - - def show(self, block=False): - super().show() - if not block: - return - self.loop = QEventLoop(self) - self.loop.exec_() - - def setState(self, state): - if state is None: - return - self.overlayButton.setChecked(state.get("is_overlay_active", False)) - self.threePointsArcAction.setChecked(state.get("is_three_points_active", True)) - self.freeHandAction.setChecked(state.get("is_free_hand_active", False)) - - def gui_storeDrawMode(self): - self.drawMode = self.sender().drawMode - - def gui_createMenuBar(self): - menuBar = self.menuBar() - # style = "QMenuBar::item:selected { background: white; }" - # menuBar.setStyleSheet(style) - # File menu - fileMenu = QMenu("&File", self) - menuBar.addMenu(fileMenu) - - menuBar.addAction(self.helpAction) - fileMenu.addAction(self.exitAction) - - def gui_createToolBars(self): - toolbarSize = 30 - - editToolBar = QToolBar("Edit", self) - editToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) - self.addToolBar(editToolBar) - - editToolBar.addAction(self.okAction) - editToolBar.addAction(self.cancelAction) - - editToolBar.addAction(self.undoAction) - - self.overlayButton = QToolButton(self) - self.overlayButton.setIcon(QIcon(":overlay.svg")) - self.overlayButton.setCheckable(True) - self.overlayButton.setToolTip("Overlay channel's image") - editToolBar.addWidget(self.overlayButton) - - editToolBar.addAction(self.threePointsArcAction) - editToolBar.addAction(self.freeHandAction) - - editToolBar.addAction(self.swapIDsAction) - - self.warnLabel = QLabel() - editToolBar.addWidget(self.warnLabel) - - def gui_connectActions(self): - self.exitAction.triggered.connect(self.close) - self.helpAction.triggered.connect(self.help) - self.okAction.triggered.connect(self.ok_cb) - self.cancelAction.triggered.connect(self.close) - self.undoAction.triggered.connect(self.undo) - self.overlayButton.toggled.connect(self.toggleOverlay) - self.imgGrad.sigLookupTableChanged.connect(self.histLUT_cb) - self.swapIDsAction.triggered.connect(self.swapIDs) - - def gui_createStatusBar(self): - self.statusbar = self.statusBar() - # Temporary message - self.statusbar.showMessage("Ready", 3000) - # Permanent widget - self.wcLabel = QLabel(f"") - self.statusbar.addPermanentWidget(self.wcLabel) - - def gui_createGraphics(self): - self.graphLayout = pg.GraphicsLayoutWidget() - - # Plot Item container for image - self.ax = pg.PlotItem() - self.ax.invertY(True) - self.ax.setAspectLocked(True) - self.ax.hideAxis("bottom") - self.ax.hideAxis("left") - self.graphLayout.addItem(self.ax, row=1, col=1) - - # Image Item - self.imgItem = pg.ImageItem(np.zeros((512, 512))) - self.ax.addItem(self.imgItem) - - # Image histogram - self.imgGrad = widgets.myHistogramLUTitem() - - # Curvature items - self.hoverLinSpace = np.linspace(0, 1, 1000) - self.hoverLinePen = pg.mkPen( - color=(200, 0, 0, 255 * 0.5), width=2, style=Qt.DashLine - ) - self.hoverCurvePen = pg.mkPen(color=(200, 0, 0, 255 * 0.5), width=3) - self.lineHoverPlotItem = pg.PlotDataItem(pen=self.hoverLinePen) - self.curvHoverPlotItem = pg.PlotDataItem(pen=self.hoverCurvePen) - self.curvAnchors = pg.ScatterPlotItem( - symbol="o", - size=9, - brush=pg.mkBrush((255, 0, 0, 50)), - pen=pg.mkPen((255, 0, 0), width=2), - hoverable=True, - hoverPen=pg.mkPen((255, 0, 0), width=3), - hoverBrush=pg.mkBrush((255, 0, 0)), - ) - self.ax.addItem(self.curvAnchors) - self.ax.addItem(self.curvHoverPlotItem) - self.ax.addItem(self.lineHoverPlotItem) - - self.freeHandItem = widgets.PlotCurveItem(pen=pg.mkPen(color="r", width=2)) - self.ax.addItem(self.freeHandItem) - - def gui_createImgWidgets(self): - self.img_Widglayout = QGridLayout() - self.img_Widglayout.setContentsMargins(50, 0, 50, 0) - - alphaScrollBar_label = QLabel("Overlay alpha ") - alphaScrollBar = QScrollBar(Qt.Horizontal) - alphaScrollBar.setFixedHeight(20) - alphaScrollBar.setMinimum(0) - alphaScrollBar.setMaximum(40) - alphaScrollBar.setValue(12) - alphaScrollBar.setToolTip( - "Control the alpha value of the overlay.\n" - "alpha=0 results in NO overlay,\n" - "alpha=1 results in only labels visible" - ) - alphaScrollBar.sliderMoved.connect(self.alphaScrollBarMoved) - self.alphaScrollBar = alphaScrollBar - self.alphaScrollBar_label = alphaScrollBar_label - self.img_Widglayout.addWidget( - alphaScrollBar_label, 0, 0, alignment=Qt.AlignCenter - ) - self.img_Widglayout.addWidget(alphaScrollBar, 0, 1, 1, 20) - self.alphaScrollBar.hide() - self.alphaScrollBar_label.hide() - - def gui_connectImgActions(self): - self.imgItem.hoverEvent = self.gui_hoverEventImg - self.imgItem.mousePressEvent = self.gui_mousePressEventImg - self.imgItem.mouseMoveEvent = self.gui_mouseDragEventImg - self.imgItem.mouseReleaseEvent = self.gui_mouseReleaseEventImg - - def gui_hoverEventImg(self, event): - # Update x, y, value label bottom right - try: - x, y = event.pos() - xdata, ydata = int(x), int(y) - _img = self.lab - Y, X = _img.shape - if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: - val = _img[ydata, xdata] - self.wcLabel.setText(f"(x={x:.2f}, y={y:.2f}, ID={val:.0f})") - else: - self.wcLabel.setText(f"") - except Exception as e: - self.wcLabel.setText(f"") - - if event.isExit(): - return - - self.drawHoverEvent(*event.pos()) - - def gui_mousePressEventImg(self, event): - right_click = event.button() == Qt.MouseButton.RightButton - left_click = event.button() == Qt.MouseButton.LeftButton - - dragImg = left_click - - if dragImg: - pg.ImageItem.mousePressEvent(self.imgItem, event) - - if not right_click: - return - - self.drawPressEvent(event) - - def gui_mouseDragEventImg(self, event): - pass - - def gui_mouseReleaseEventImg(self, event): - if self.countClicks == 0: - return - if self.freeHandAction.isChecked(): - self.countClicks = 0 - xx, yy = self.freeHandItem.getData() - self.setSplitCurveCoords(xx, yy) - self.splitObjectAlongCurve() - self.freeHandItem.setData([], []) - self.curvAnchors.setData([], []) - - def getSpline(self, xx, yy): - tck, u = scipy.interpolate.splprep([xx, yy], s=0, k=2) - xi, yi = scipy.interpolate.splev(self.hoverLinSpace, tck) - return xi, yi - - def drawPressEvent(self, event): - if self.freeHandAction.isChecked(): - self.countClicks = 1 - x, y = event.pos().x(), event.pos().y() - self.curvAnchors.addPoints([x], [y]) - elif self.threePointsArcAction.isChecked(): - self.threePointsArcPressEvent(event) - - def drawHoverEvent(self, x, y): - if self.freeHandAction.isChecked(): - self.freeHandHoverEvent(x, y) - elif self.threePointsArcAction.isChecked(): - self.threePointsArcHoverEvent(x, y) - - def freeHandHoverEvent(self, x, y): - if self.countClicks == 0: - return - self.freeHandItem.addPoint(int(x), int(y)) - _xx, _yy = self.freeHandItem.getData() - xx = [_xx[0], x] - yy = [_yy[0], y] - self.curvAnchors.setData(xx, yy) - - def threePointsArcHoverEvent(self, x, y): - if self.countClicks == 1: - self.lineHoverPlotItem.setData([self.x0, x], [self.y0, y]) - elif self.countClicks == 2: - xx = [self.x0, x, self.x1] - yy = [self.y0, y, self.y1] - xi, yi = self.getSpline(xx, yy) - self.curvHoverPlotItem.setData(xi, yi) - elif self.countClicks == 0: - self.curvHoverPlotItem.setData([], []) - self.lineHoverPlotItem.setData([], []) - self.curvAnchors.setData([], []) - - def threePointsArcPressEvent(self, event): - if self.countClicks == 0: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - self.x0, self.y0 = xdata, ydata - self.curvAnchors.addPoints([xdata], [ydata]) - self.countClicks = 1 - elif self.countClicks == 1: - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - self.x1, self.y1 = xdata, ydata - self.curvAnchors.addPoints([xdata], [ydata]) - self.countClicks = 2 - elif self.countClicks == 2: - self.countClicks = 0 - x, y = event.pos().x(), event.pos().y() - xdata, ydata = int(x), int(y) - xx = [self.x0, xdata, self.x1] - yy = [self.y0, ydata, self.y1] - xi, yi = self.getSpline(xx, yy) - yy, xx = np.round(yi).astype(int), np.round(xi).astype(int) - self.setSplitCurveCoords(xx, yy) - self.splitObjectAlongCurve() - - def setSplitCurveCoords(self, xx, yy): - self.storeUndoState() - xxCurve, yyCurve = [], [] - for i, (r0, c0) in enumerate(zip(yy, xx)): - if i == len(yy) - 1: - break - r1 = yy[i + 1] - c1 = xx[i + 1] - rr, cc, _ = skimage.draw.line_aa(r0, c0, r1, c1) - # rr, cc = skimage.draw.line(r0, c0, r1, c1) - nonzeroMask = self.lab[rr, cc] > 0 - xxCurve.extend(cc[nonzeroMask]) - yyCurve.extend(rr[nonzeroMask]) - self.AllCutsCoords.append((yyCurve, xxCurve)) - for rr, cc in self.AllCutsCoords: - self.lab[rr, cc] = 0 - self.lab = skimage.morphology.remove_small_objects(self.lab, 5) - - def histLUT_cb(self, LUTitem): - if self.overlayButton.isChecked(): - overlay = self.getOverlay() - self.imgItem.setImage(overlay) - - def swapIDs(self, checked=False): - if len(self.rp) == 1: - self.warnLabel.setText( - html_utils.paragraph( - "WARNING: Split the object before swapping IDs", font_color="red" - ) - ) - return - - self.warnLabel.setText("") - - obj1 = self.rp[0] - obj2 = self.rp[1] - - self.lab[obj1.slice][obj1.image] = obj2.label - self.lab[obj2.slice][obj2.image] = obj1.label - - self.updateImg() - - def updateImg(self): - self.updateLookuptable() - rp = skimage.measure.regionprops(self.lab) - self.rp = rp - - if self.overlayButton.isChecked(): - overlay = self.getOverlay() - self.imgItem.setImage(overlay) - else: - self.imgItem.setImage(self.lab) - - # Draw ID on centroid of each label - for labelItemID in self.labelItemsIDs: - self.ax.removeItem(labelItemID) - self.labelItemsIDs = [] - for obj in rp: - labelItemID = widgets.myLabelItem() - labelItemID.setText(f"{obj.label}", color="r", size=f"{self.fontSize}px") - y, x = obj.centroid - w, h = labelItemID.rect().right(), labelItemID.rect().bottom() - labelItemID.setPos(x - w / 2, y - h / 2) - self.labelItemsIDs.append(labelItemID) - self.ax.addItem(labelItemID) - - def zoomToObj(self): - # Zoom to object - lab_mask = (self.lab > 0).astype(np.uint8) - rp = skimage.measure.regionprops(lab_mask) - obj = rp[0] - min_row, min_col, max_row, max_col = obj.bbox - xRange = min_col - 10, max_col + 10 - yRange = max_row + 10, min_row - 10 - self.ax.setRange(xRange=xRange, yRange=yRange) - - def storeUndoState(self): - self.prevLabs.append(self.lab.copy()) - self.prevAllCutsCoords.append(self.AllCutsCoords.copy()) - self.undoIdx += 1 - self.undoAction.setEnabled(True) - - def undo(self): - self.undoIdx -= 1 - self.lab = self.prevLabs[self.undoIdx] - self.AllCutsCoords = self.prevAllCutsCoords[self.undoIdx] - self.updateImg() - if self.undoIdx == 0: - self.undoAction.setEnabled(False) - self.prevLabs = [] - self.prevAllCutsCoords = [] - - def splitObjectAlongCurve(self): - self.lab = skimage.measure.label(self.lab, connectivity=1) - - # Relabel largest object with original ID - rp = skimage.measure.regionprops(self.lab) - areas = [obj.area for obj in rp] - IDs = [obj.label for obj in rp] - maxAreaIdx = areas.index(max(areas)) - maxAreaID = IDs[maxAreaIdx] - if self.ID not in self.lab: - self.lab[self.lab == maxAreaID] = self.ID - else: - tempID = self.lab.max() + 1 - self.lab[self.lab == maxAreaID] = tempID - self.lab[self.lab == self.ID] = maxAreaID - self.lab[self.lab == tempID] = self.ID - - # Keep only the two largest objects - larger_areas = nlargest(2, areas) - larger_ids = [rp[areas.index(area)].label for area in larger_areas] - for obj in rp: - if obj.label not in larger_ids: - self.lab[tuple(obj.coords.T)] = 0 - - rp = skimage.measure.regionprops(self.lab) - - if self._parent is not None: - self._parent.setBrushID() - # Use parent window setBrushID function for all other IDs - for obj in rp: - if self._parent is None: - break - if obj.label == self.ID: - continue - posData = self._parent.data[self._parent.pos_i] - posData.brushID += 1 - self.lab[obj.slice][obj.image] = posData.brushID - - # Replace 0s on the cutting curve with IDs - self.cutLab = self.lab.copy() - for rr, cc in self.AllCutsCoords: - for y, x in zip(rr, cc): - top_row = self.cutLab[y + 1, x - 1 : x + 2] - bot_row = self.cutLab[y - 1, x - 1 : x + 1] - left_col = self.cutLab[y - 1, x - 1] - right_col = self.cutLab[y : y + 2, x + 1] - allNeigh = list(top_row) - allNeigh.extend(bot_row) - allNeigh.append(left_col) - allNeigh.extend(right_col) - newID = max(allNeigh) - self.lab[y, x] = newID - - self.rp = skimage.measure.regionprops(self.lab) - self.updateImg() - - def updateLookuptable(self): - # Lookup table - self.cmap = colors.getFromMatplotlib("viridis") - self.lut = self.cmap.getLookupTable(0, 1, self.lab.max() + 1) - self.lut[0] = [25, 25, 25] - self.lut[self.ID] = self.IDcolor - if self.overlayButton.isChecked(): - self.imgItem.setLookupTable(None) - else: - self.imgItem.setLookupTable(self.lut) - - def keyPressEvent(self, ev): - if ev.key() == Qt.Key_Escape: - self.countClicks = 0 - self.curvHoverPlotItem.setData([], []) - self.lineHoverPlotItem.setData([], []) - self.curvAnchors.setData([], []) - self.freeHandItem.setData([], []) - elif ev.key() == Qt.Key_Enter or ev.key() == Qt.Key_Return: - self.ok_cb(True) - - def getOverlay(self): - # Rescale intensity based on hist ticks values - min = self.imgGrad.gradient.listTicks()[0][1] - max = self.imgGrad.gradient.listTicks()[1][1] - img = skimage.exposure.rescale_intensity(self.img, in_range=(min, max)) - alpha = self.alphaScrollBar.value() / self.alphaScrollBar.maximum() - - # Convert img and lab to RGBs - rgb_shape = (self.lab.shape[0], self.lab.shape[1], 3) - labRGB = np.zeros(rgb_shape) - labRGB[self.lab > 0] = [1, 1, 1] - imgRGB = skimage.color.gray2rgb(img) - overlay = imgRGB * (1.0 - alpha) + labRGB * alpha - - # Color eaach label - for obj in self.rp: - rgb = self.lut[obj.label] / 255 - overlay[obj.slice][obj.image] *= rgb - - # Convert (0,1) to (0,255) - overlay = (np.clip(overlay, 0, 1) * 255).astype(np.uint8) - return overlay - - def alphaScrollBarMoved(self, alpha_int): - overlay = self.getOverlay() - self.imgItem.setImage(overlay) - - def toggleOverlay(self, checked): - if checked: - self.graphLayout.addItem(self.imgGrad, row=1, col=0) - self.alphaScrollBar.show() - self.alphaScrollBar_label.show() - else: - self.graphLayout.removeItem(self.imgGrad) - self.alphaScrollBar.hide() - self.alphaScrollBar_label.hide() - self.updateImg() - - def help(self): - msg = QMessageBox() - msg.information( - self, - "Help", - "Separate object along a curved line.\n\n" - "To draw a curved line you will need 3 right-clicks:\n\n" - "1. Right-click outside of the object --> a line appears.\n" - "2. Right-click to end the line and a curve going through the " - "mouse cursor will appear.\n" - "3. Once you are happy with the cutting curve right-click again " - "and the object will be separated along the curve.\n\n" - "Note that you can separate as many times as you want.\n\n" - "Once happy click on the green tick on top-right or " - 'cancel the process with the "X" button', - ) - - def ok_cb(self, checked): - self.cancel = False - self.close() - - def closeEvent(self, event): - if self.loop is not None: - self.loop.exit() - - -class DataFrameModel(QtCore.QAbstractTableModel): - # https://stackoverflow.com/questions/44603119/how-to-display-a-pandas-data-frame-with-pyqt5-pyside2 - DtypeRole = QtCore.Qt.UserRole + 1000 - ValueRole = QtCore.Qt.UserRole + 1001 - - def __init__(self, df=pd.DataFrame(), parent=None): - super(DataFrameModel, self).__init__(parent) - self._dataframe = df - - def setDataFrame(self, dataframe): - self.beginResetModel() - self._dataframe = dataframe.copy() - self.endResetModel() - - def dataFrame(self): - return self._dataframe - - dataFrame = QtCore.Property(pd.DataFrame, fget=dataFrame, fset=setDataFrame) - - @QtCore.Slot(int, QtCore.Qt.Orientation, result=str) - def headerData( - self, - section: int, - orientation: QtCore.Qt.Orientation, - role: int = QtCore.Qt.DisplayRole, - ): - if role == QtCore.Qt.DisplayRole: - if orientation == QtCore.Qt.Horizontal: - return self._dataframe.columns[section] - else: - return str(self._dataframe.index[section]) - return QtCore.QVariant() - - def rowCount(self, parent=QtCore.QModelIndex()): - if parent.isValid(): - return 0 - return len(self._dataframe.index) - - def columnCount(self, parent=QtCore.QModelIndex()): - if parent.isValid(): - return 0 - return self._dataframe.columns.size - - def data(self, index, role=QtCore.Qt.DisplayRole): - if not index.isValid() or not ( - 0 <= index.row() < self.rowCount() - and 0 <= index.column() < self.columnCount() - ): - return QtCore.QVariant() - row = self._dataframe.index[index.row()] - col = self._dataframe.columns[index.column()] - dt = self._dataframe[col].dtype - - if role == Qt.TextAlignmentRole: - return Qt.AlignCenter - - val = self._dataframe.iloc[row][col] - if role == QtCore.Qt.DisplayRole: - return str(val) - elif role == DataFrameModel.ValueRole: - return val - if role == DataFrameModel.DtypeRole: - return dt - return QtCore.QVariant() - - def roleNames(self): - roles = { - QtCore.Qt.DisplayRole: b"display", - DataFrameModel.DtypeRole: b"dtype", - DataFrameModel.ValueRole: b"value", - } - return roles - - -class pdDataFrameWidget(QMainWindow): - def __init__(self, df, parent=None): - super().__init__(parent) - self.parent = parent - self.setWindowTitle("Cell cycle annotations") - - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - - mainContainer = QWidget() - self.setCentralWidget(mainContainer) - - layout = QVBoxLayout() - self._layout = layout - - self.tableView = QTableView(self) - layout.addWidget(self.tableView) - model = DataFrameModel(df) - self.tableView.setModel(model) - for i in range(len(df.columns)): - self.tableView.resizeColumnToContents(i) - # layout.addWidget(QPushButton('Ok', self)) - mainContainer.setLayout(layout) - - def updateTable(self, df, IDs=None): - if df is None: - df = self.parent.getBaseCca_df() - - if IDs is not None: - df = df.loc[IDs] - - df = df.reset_index() - model = DataFrameModel(df) - self.tableView.setModel(model) - for i in range(len(df.columns)): - self.tableView.resizeColumnToContents(i) - - def setGeometryWindow(self, maxWidth=1024): - width = self.tableView.verticalHeader().width() + 4 - for j in range(self.tableView.model().columnCount()): - width += self.tableView.columnWidth(j) + 4 - height = self.tableView.horizontalHeader().height() + 4 - h = height + (self.tableView.rowHeight(0) + 4) * 10 - w = width if width < maxWidth else maxWidth - self.setGeometry(100, 100, w, h) - - # Center window - parent = self.parent - if parent is not None: - # Center the window on main window - mainWinGeometry = parent.geometry() - mainWinLeft = mainWinGeometry.left() - mainWinTop = mainWinGeometry.top() - mainWinWidth = mainWinGeometry.width() - mainWinHeight = mainWinGeometry.height() - mainWinCenterX = int(mainWinLeft + mainWinWidth / 2) - mainWinCenterY = int(mainWinTop + mainWinHeight / 2) - winGeometry = self.geometry() - winWidth = winGeometry.width() - winHeight = winGeometry.height() - winLeft = int(mainWinCenterX - winWidth / 2) - winRight = int(mainWinCenterY - winHeight / 2) - self.move(winLeft, winRight) - - def closeEvent(self, event): - self.parent.ccaTableWin = None - - -class QDialogZsliceAbsent(QDialog): - def __init__(self, filename, SizeZ, filenamesWithInfo, parent=None): - self.runDataPrep = False - self.useMiddleSlice = False - self.useSameAsCh = False - - self.cancel = True - - super().__init__(parent) - self.setWindowTitle("Reference z-slice info absent") - - mainLayout = QVBoxLayout() - buttonsLayout = QGridLayout() - - txt = html_utils.paragraph( - f""" - You loaded the fluorescent file called

    {filename}

    - however you never selected which z-slice
    you want to use - when calculating metrics
    (e.g., mean, median, amount...etc.)

    - Choose one of following options: - """, - center=True, - ) - infoLabel = QLabel(txt) - mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) - - runDataPrepButton = QPushButton( - " Visualize the data now and select a z-slice " - ) - buttonsLayout.addWidget(runDataPrepButton, 0, 1, 1, 2) - runDataPrepButton.clicked.connect(self.runDataPrep_cb) - - useMiddleSliceButton = QPushButton( - f" Use the middle z-slice ({int(SizeZ / 2) + 1}) " - ) - buttonsLayout.addWidget(useMiddleSliceButton, 1, 1, 1, 2) - useMiddleSliceButton.clicked.connect(self.useMiddleSlice_cb) - - useSameAsChButton = QPushButton(" Use the same z-slice used for the channel: ") - useSameAsChButton.clicked.connect(self.useSameAsCh_cb) - - chNameComboBox = QComboBox() - chNameComboBox.addItems(filenamesWithInfo) - # chNameComboBox.setEditable(True) - # chNameComboBox.lineEdit().setAlignment(Qt.AlignCenter) - # chNameComboBox.lineEdit().setReadOnly(True) - self.chNameComboBox = chNameComboBox - buttonsLayout.addWidget(useSameAsChButton, 2, 1) - buttonsLayout.addWidget(chNameComboBox, 2, 2) - - buttonsLayout.setColumnStretch(0, 1) - buttonsLayout.setColumnStretch(3, 1) - buttonsLayout.setContentsMargins(10, 0, 10, 0) - - cancelButtonLayout = QHBoxLayout() - cancelButton = widgets.cancelPushButton("Cancel") - cancelButtonLayout.addStretch(1) - cancelButtonLayout.addWidget(cancelButton) - cancelButtonLayout.addStretch(1) - cancelButtonLayout.setStretch(1, 1) - cancelButton.clicked.connect(self.close) - - mainLayout.addLayout(buttonsLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(cancelButtonLayout) - mainLayout.addStretch(1) - - self.setLayout(mainLayout) - - font = QFont() - font.setPixelSize(12) - self.setFont(font) - - # self.setModal(True) - - def ok_cb(self, checked=True): - self.cancel = False - self.close() - - def useSameAsCh_cb(self, checked): - self.useSameAsCh = True - self.selectedChannel = self.chNameComboBox.currentText() - self.ok_cb() - - def useMiddleSlice_cb(self, checked): - self.useMiddleSlice = True - self.ok_cb() - - def runDataPrep_cb(self, checked): - self.runDataPrep = True - self.ok_cb() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class SelectSegmFileDialog(QDialog): - def __init__( - self, - images_ls, - parent_path, - parent=None, - addNewFileButton=False, - basename="", - infoText=None, - fileType="segmentation", - allowMultipleSelection=False, - custom_first=None, - ): - self.cancel = True - self.selectedItemText = "" - self.selectedItemIdx = None - self.removeOthers = False - self.okAllPos = False - self.newSegmEndName = None - self.allowMultipleSelection = allowMultipleSelection - self.basename = basename - images_ls = sorted(images_ls, key=len) - if custom_first is not None: - images_ls.remove(custom_first) - images_ls.insert(0, custom_first) - - # Remove the 'segm_' part to allow filenameDialog to check if - # a new file is existing (since we only ask for the part after - # 'segm_') - self.existingEndNames = [ - n.replace("segm", "", 1).replace("_", "", 1) for n in images_ls - ] - - self.images_ls = images_ls - self.parent_path = parent_path - super().__init__(parent) - - informativeText = html_utils.paragraph(f""" - The loaded Position folders already contains - {len(self.existingEndNames)} {fileType} masks
    - """) - - self.setWindowTitle(f"{fileType.capitalize()} files detected") - is_win = sys.platform.startswith("win") - - mainLayout = QVBoxLayout() - infoLayout = QHBoxLayout() - selectionLayout = QGridLayout() - buttonsLayout = QHBoxLayout() - - # Standard Qt Question icon - label = QLabel() - standardIcon = getattr(QStyle, "SP_MessageBoxQuestion") - icon = self.style().standardIcon(standardIcon) - pixmap = icon.pixmap(60, 60) - label.setPixmap(pixmap) - infoLayout.addWidget(label) - - infoLabel = QLabel(informativeText) - infoLayout.addWidget(infoLabel) - infoLayout.addStretch(1) - mainLayout.addLayout(infoLayout) - - if infoText is None: - infoText = f"Select which {fileType} file to load:" - - questionText = html_utils.paragraph(infoText) - label = QLabel(questionText) - listWidget = widgets.listWidget() - listWidget.addItems(images_ls) - listWidget.setCurrentRow(0) - listWidget.itemDoubleClicked.connect(self.listDoubleClicked) - if allowMultipleSelection: - listWidget.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - self.items = list(images_ls) - self.listWidget = listWidget - - okButton = widgets.okPushButton(" Load selected ") - txt = "Reveal in Finder..." if is_mac else "Show in Explorer..." - showInFileManagerButton = widgets.showInFileManagerButton(txt) - cancelButton = widgets.cancelPushButton(" Cancel ") - - if addNewFileButton: - newFileButton = widgets.newFilePushButton("New file...") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addWidget(showInFileManagerButton) - buttonsLayout.addSpacing(20) - if addNewFileButton: - buttonsLayout.addWidget(newFileButton) - buttonsLayout.addWidget(okButton) - - buttonsLayout.setContentsMargins(0, 10, 0, 10) - - selectionLayout.addWidget(label, 0, 1, alignment=Qt.AlignLeft) - selectionLayout.addWidget(listWidget, 1, 1) - selectionLayout.setColumnStretch(0, 0) - selectionLayout.setColumnStretch(1, 1) - selectionLayout.setColumnStretch(2, 0) - selectionLayout.addLayout(buttonsLayout, 2, 1) - - mainLayout.addLayout(selectionLayout) - self.setLayout(mainLayout) - - self.okButton = okButton - - # Connect events - okButton.clicked.connect(self.ok_cb) - if addNewFileButton: - newFileButton.clicked.connect(self.newFile_cb) - cancelButton.clicked.connect(self.close) - showInFileManagerButton.clicked.connect(self.showInFileManager) - - def listDoubleClicked(self, item): - self.ok_cb() - - def showInFileManager(self, checked=True): - myutils.showInExplorer(self.parent_path) - - def newFile_cb(self): - win = filenameDialog( - basename=f"{self.basename}segm", - hintText="Insert a filename for the segmentation file:", - existingNames=self.existingEndNames, - ) - win.exec_() - if win.cancel: - return - self.cancel = False - self.newSegmEndName = win.entryText - self.close() - - def setSelectedItemFromText(self, itemText): - for i in range(self.listWidget.count()): - if self.listWidget.item(i).text() == itemText: - self.listWidget.setCurrentRow(i) - break - - def ok_cb(self, event=None): - self.cancel = False - try: - self.selectedItemText = self.listWidget.selectedItems()[0].text() - except IndexError: - self.cancel = True - self.close() - return - self.selectedItemIdx = self.items.index(self.selectedItemText) - self.selectedItemTexts = [ - selectedItem.text() for selectedItem in self.listWidget.selectedItems() - ] - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class QDialogPbar(QDialog): - def __init__(self, title="Progress", infoTxt="", parent=None): - self.workerFinished = False - self.aborted = False - self.clickCount = 0 - super().__init__(parent) - - abort_text = "Option+Command+C" if is_mac else "Ctrl+Alt+C" - self.abort_text = abort_text - - self.setWindowTitle(f"{title} ({abort_text} to abort)") - self.setWindowFlags(Qt.Window) - - mainLayout = QVBoxLayout() - pBarLayout = QGridLayout() - - if infoTxt: - infoLabel = QLabel(infoTxt) - mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) - - self.progressLabel = QLabel() - - self.QPbar = widgets.ProgressBar(self) - pBarLayout.addWidget(self.QPbar, 0, 0) - self.ETA_label = QLabel("NDh:NDm:NDs") - pBarLayout.addWidget(self.ETA_label, 0, 1) - - self.metricsQPbar = widgets.ProgressBar(self) - self.metricsQPbar.setValue(0) - pBarLayout.addWidget(self.metricsQPbar, 1, 0) - - # pBarLayout.setColumnStretch(2, 1) - - mainLayout.addWidget(self.progressLabel) - mainLayout.addLayout(pBarLayout) - - self.setLayout(mainLayout) - # self.setModal(True) - - def keyPressEvent(self, event): - isCtrlAlt = event.modifiers() == (Qt.ControlModifier | Qt.AltModifier) - if isCtrlAlt and event.key() == Qt.Key_C: - doAbort = self.askAbort() - if doAbort: - self.aborted = True - self.workerFinished = True - self.close() - - def askAbort(self): - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - Aborting with {self.abort_text} to abort - is not safe.

    - The system status cannot be predicted and - it will require a restart.

    - Are you sure you want to abort? - """) - yesButton, noButton = msg.critical( - self, "Are you sure you want to abort?", txt, buttonsTexts=("Yes", "No") - ) - return msg.clickedButton == yesButton - - def abort(self): - self.clickCount += 1 - self.aborted = True - if self.clickCount > 3: - self.workerFinished = True - self.close() - - def closeEvent(self, event): - if not self.workerFinished: - event.ignore() - - -class FunctionParamsDialog(QBaseDialog): - sigValuesChanged = Signal(dict) - - def __init__( - self, - params_argspecs, - function_name="Function", - df_metadata=None, - parent=None, - addApplyButton=False, - ): - self.cancel = True - self.df_metadata = df_metadata - - super().__init__(parent) - - self.setWindowTitle(f"{function_name} parameters") - - self.mainLayout = QVBoxLayout() - - widgetsLayout, self.argsWidgets = self.getWidgetsLayout(params_argspecs) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - self.buttonsLayout = buttonsLayout - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - if addApplyButton: - applyButton = widgets.viewPushButton("Apply") - applyButton.clicked.connect(self.emitValuesChanged) - buttonsLayout.insertWidget(3, applyButton) - self.applyButton = applyButton - - self.mainLayout.addLayout(widgetsLayout) - self.mainLayout.addSpacing(20) - self.mainLayout.addLayout(buttonsLayout) - - self.setLayout(self.mainLayout) - - def emitValuesChanged(self, *args, **kwargs): - self.sigValuesChanged.emit(self.functionKwargs()) - - def functionKwargs(self): - function_kwargs = { - argWidget.name: argWidget.valueGetter(argWidget.widget) - for argWidget in self.argsWidgets - } - return function_kwargs - - def kwargWidgetMapper(self) -> Dict[str, tuple]: - kwarg_widget_mapper = { - argWidget.name: (argWidget.widget, argWidget.valueSetter) - for argWidget in self.argsWidgets - } - return kwarg_widget_mapper - - def ok_cb(self): - self.cancel = False - - self.function_kwargs = self.functionKwargs() - - self.close() - - def getValueFromMetadata(self, name): - try: - value = self.df_metadata.at[name, "values"] - except Exception as e: - # traceback.print_exc() - value = None - return value - - def getWidgetsLayout(self, params_argspecs): - widgetsLayout = QGridLayout() - ArgsWidgets_list = [] - - for row, ArgSpec in enumerate(params_argspecs): - if _types.is_widget_not_required(ArgSpec): - continue - - arg_name = ArgSpec.name - var_name = arg_name.replace("_", " ") - var_name = f"{var_name[0].upper()}{var_name[1:]}" - label = QLabel(f"{var_name}: ") - metadata_val = self.getValueFromMetadata(ArgSpec.name) - widgetsLayout.addWidget(label, row, 0, alignment=Qt.AlignLeft) - try: - values = ArgSpec.type().values - isCustomListType = True - except Exception as err: - isCustomListType = False - - isVectorEntry = False - try: - if isinstance(ArgSpec.type(), _types.Vector): - isVectorEntry = True - except Exception as err: - pass - - isFolderPath = False - try: - if isinstance(ArgSpec.type(), _types.FolderPath): - isFolderPath = True - except Exception as err: - pass - - isCustomWidget = hasattr(ArgSpec.type, "isWidget") - - if isCustomWidget: - widget = ArgSpec.type().widget - self.checkIfTypeCLassHasCastDtype(widget) - defaultVal = ArgSpec.default - valueSetter = widget.setValue - valueGetter = widget.value - widgetsLayout.addWidget(widget, row, 1, 1, 2) - try: - widget.sigValueChanged.connect(self.emitValuesChanged) - except Exception as err: - pass - elif isVectorEntry: - vectorLineEdit = widgets.VectorLineEdit() - self.checkIfTypeCLassHasCastDtype(ArgSpec.type) - vectorLineEdit.setValue(ArgSpec.default) - defaultVal = ArgSpec.default - valueSetter = widgets.VectorLineEdit.setValue - valueGetter = widgets.VectorLineEdit.value - widget = vectorLineEdit - widgetsLayout.addWidget(vectorLineEdit, row, 1, 1, 2) - widget.valueChangeFinished.connect(self.emitValuesChanged) - elif isFolderPath: - folderPathControl = widgets.FolderPathControl() - self.checkIfTypeCLassHasCastDtype(ArgSpec.type) - folderPathControl.setText(str(ArgSpec.default)) - widget = folderPathControl - defaultVal = str(ArgSpec.default) - valueSetter = widgets.FolderPathControl.setText - valueGetter = widgets.FolderPathControl.path - widgetsLayout.addWidget(folderPathControl, row, 1, 1, 2) - widget.sigValueChanged.connect(self.emitValuesChanged) - elif ArgSpec.type == bool: - booleanGroup = QButtonGroup() - booleanGroup.setExclusive(True) - checkBox = widgets.Toggle() - checkBox.setChecked(ArgSpec.default) - defaultVal = ArgSpec.default - valueSetter = widgets.Toggle.setChecked - valueGetter = widgets.Toggle.isChecked - widget = checkBox - widgetsLayout.addWidget( - checkBox, row, 1, 1, 2, alignment=Qt.AlignCenter - ) - widget.toggled.connect(self.emitValuesChanged) - elif ArgSpec.type == int: - spinBox = widgets.SpinBox() - if metadata_val is None: - spinBox.setValue(ArgSpec.default) - else: - spinBox.setValue(int(metadata_val)) - spinBox.isMetadataValue = True - defaultVal = ArgSpec.default - valueSetter = QSpinBox.setValue - valueGetter = QSpinBox.value - widget = spinBox - widgetsLayout.addWidget(spinBox, row, 1, 1, 2) - widget.sigValueChanged.connect(self.emitValuesChanged) - elif ArgSpec.type == float: - doubleSpinBox = widgets.FloatLineEdit() - if metadata_val is None: - doubleSpinBox.setValue(ArgSpec.default) - else: - doubleSpinBox.setValue(float(metadata_val)) - doubleSpinBox.isMetadataValue = True - widget = doubleSpinBox - defaultVal = ArgSpec.default - valueSetter = widgets.FloatLineEdit.setValue - valueGetter = widgets.FloatLineEdit.value - widgetsLayout.addWidget(doubleSpinBox, row, 1, 1, 2) - widget.valueChanged.connect(self.emitValuesChanged) - elif ArgSpec.type == os.PathLike: - filePathControl = widgets.filePathControl() - filePathControl.setText(str(ArgSpec.default)) - widget = filePathControl - defaultVal = str(ArgSpec.default) - valueSetter = widgets.filePathControl.setText - valueGetter = widgets.filePathControl.path - widgetsLayout.addWidget(filePathControl, row, 1, 1, 2) - widget.sigValueChanged.connect(self.emitValuesChanged) - elif isCustomListType: - items = ArgSpec.type().values - ArgSpec.type.cast_dtype = _types.to_str - defaultVal = str(ArgSpec.default) - combobox = widgets.AlphaNumericComboBox() - combobox.addItems(items) - combobox.setCurrentValue(defaultVal) - valueSetter = widgets.AlphaNumericComboBox.setCurrentValue - valueGetter = widgets.AlphaNumericComboBox.currentValue - widget = combobox - widgetsLayout.addWidget(combobox, row, 1, 1, 2) - widget.currentTextChanged.connect(self.emitValuesChanged) - else: - lineEdit = QLineEdit() - lineEdit.setText(str(ArgSpec.default)) - lineEdit.setAlignment(Qt.AlignCenter) - widget = lineEdit - defaultVal = str(ArgSpec.default) - valueSetter = QLineEdit.setText - valueGetter = QLineEdit.text - widgetsLayout.addWidget(lineEdit, row, 1, 1, 2) - widget.editingFinished.connect(self.emitValuesChanged) - - if ArgSpec.desc: - infoButton = self.getInfoButton(ArgSpec.name, ArgSpec.desc) - widgetsLayout.addWidget(infoButton, row, 3) - - argsInfo = ArgWidget( - name=ArgSpec.name, - type=ArgSpec.type, - widget=widget, - defaultVal=defaultVal, - valueSetter=valueSetter, - valueGetter=valueGetter, - ) - ArgsWidgets_list.append(argsInfo) - - widgetsLayout.setColumnStretch(0, 0) - widgetsLayout.setColumnStretch(1, 1) - widgetsLayout.setColumnStretch(3, 0) - - return widgetsLayout, ArgsWidgets_list - - def checkIfTypeCLassHasCastDtype(self, cls): - cast_dtype = getattr(cls, "cast_dtype", None) - if callable(cast_dtype): - return - - raise AttributeError( - "The custom type or widget does not have the `cast_dtype` method. " - "Please, implement it. The method should cast the value to the " - "correct type." - ) - - def getInfoButton(self, param_name, infoText): - infoButton = widgets.infoPushButton() - infoButton.param_name = param_name - infoButton.setToolTip( - f"Click to get more info about `{param_name}` parameter..." - ) - infoButton.infoText = infoText - infoButton.clicked.connect(self.showInfoParam) - return infoButton - - def showInfoParam(self): - text = self.sender().infoText - text = html_utils.rst_urls_to_html(text) - text = html_utils.rst_to_html(text) - text = html_utils.paragraph(text) - param_name = self.sender().param_name - msg = widgets.myMessageBox(wrapText=False) - msg.information(self, f"Info about `{param_name}` parameter", text) - - -class QDialogModelParams(QDialog): - def __init__( - self, - init_params, - segment_params, - model_name, - is_tracker=False, - url=None, - parent=None, - initLastParams=True, - posData=None, - channels=None, - currentChannelName=None, - segmFileEndnames=None, - df_metadata=None, - force_postprocess_2D=False, - model_module=None, - action_type="", - addPreProcessParams=True, - addPostProcessParams=True, - extraParams=None, - extraParamsTitle=None, - ini_filename=None, - add_additional_segm_params=False, - ): - self.cancel = True - super().__init__(parent) - self.channels = channels - self.is_tracker = is_tracker - self.currentChannelName = currentChannelName - self.channelCombobox = None - self.segmFileEndnames = segmFileEndnames - self.df_metadata = df_metadata - self.force_postprocess_2D = force_postprocess_2D - - self.skipSegmentation = False - if len(segment_params) > 0: - if segment_params[0].name.lower().find("skip_segmentation") != -1: - self.skipSegmentation = True - addPreProcessParams = False - else: - self.skipSegmentation = False - if ini_filename is not None: - self.ini_filename = ini_filename - elif is_tracker: - self.ini_filename = "last_params_trackers.ini" - addPreProcessParams = False - addPostProcessParams = False - else: - self.ini_filename = "last_params_segm_models.ini" - - self.addPreProcessParams = addPreProcessParams - - self.model_name = model_name - - self.setWindowTitle(f"{model_name} parameters") - - # Create main vertical layout and horizontal layout for two columns - mainLayout = QVBoxLayout() - - gridLayout = QGridLayout() - self.gridLayout = gridLayout - - loadFunc = self.loadLastSelection - - self.paramsGroupPosMapper = {} - - # LEFT COLUMN: Preprocessing params - row, col = 0, 0 - preProcessLayout = None - self.preProcessParamsWidget = None - if addPreProcessParams: - preProcessLayout = QVBoxLayout() - self.preProcessParamsWidget = PreProcessParamsWidget( - parent=self, addApplyButton=False - ) - self.preProcessParamsWidget.setChecked(False) - preProcessLayout.addWidget(self.preProcessParamsWidget) - self.preProcessParamsWidget.sigLoadRecipe.connect(self.loadPreprocRecipe) - gridLayout.addLayout(preProcessLayout, row, col, 1, 2) - self.paramsGroupPosMapper[self.preProcessParamsWidget] = (row, col) - gridLayout.addItem(QSpacerItem(10, 5), 0, col + 1) - # gridLayout.setColumnMinimumWidth(col+1, 15) - col += 2 - - # Center COLUMN: Init, Segmentation/Eval - row = 0 - self.secondColLayout = QVBoxLayout() - self.initParamsScrollArea = widgets.ScrollArea() - initParamsScrollAreaLayout = QVBoxLayout() - self.initParamsScrollArea.setVerticalLayout(initParamsScrollAreaLayout) - - initGroupBox, self.init_argsWidgets = self.createGroupParams( - init_params, "Parameters for model initialization" - ) - self.init_params = init_params - initDefaultButton = widgets.reloadPushButton("Restore default") - initLoadLastSelButton = widgets.OpenFilePushButton("Load last parameters") - initLoadLastSelButton.setIcon(QIcon(":folder-open.svg")) - initButtonsLayout = QHBoxLayout() - initButtonsLayout.addStretch(1) - initButtonsLayout.addWidget(initDefaultButton) - initButtonsLayout.addWidget(initLoadLastSelButton) - initDefaultButton.clicked.connect(self.restoreDefaultInit) - initLoadLastSelButton.clicked.connect( - partial(loadFunc, f"{self.model_name}.init", self.init_argsWidgets) - ) - - initParamsScrollAreaLayout.addWidget(initGroupBox) - - initParamsLayout = QVBoxLayout() - initParamsLayout.addWidget(QLabel(f"{initGroupBox.title()}")) - initGroupBox.setTitle("") - initParamsLayout.addWidget(self.initParamsScrollArea) - initParamsLayout.addLayout(initButtonsLayout) - self.secondColLayout.addLayout(initParamsLayout) - self.paramsGroupPosMapper[self.initParamsScrollArea] = (0, col) - - self.segmentParamsScrollArea = None - if not self.skipSegmentation: - self.segmentParamsScrollArea = widgets.ScrollArea() - segmentParamsScrollAreaLayout = QVBoxLayout() - self.segmentParamsScrollArea.setVerticalLayout( - segmentParamsScrollAreaLayout - ) - if action_type: - runGroupboxTitle = f"Parameters for {action_type}" - elif is_tracker: - runGroupboxTitle = "Parameters for tracking" - else: - runGroupboxTitle = "Parameters for segmentation" - - segmentGroupBox, self.argsWidgets = self.createGroupParams( - segment_params, runGroupboxTitle, addChannelSelector=True - ) - self.segment_params = segment_params - self.segmentGroupBox = segmentGroupBox - segmentDefaultButton = widgets.reloadPushButton("Restore default") - segmentLoadLastSelButton = widgets.OpenFilePushButton( - "Load last parameters" - ) - segmentButtonsLayout = QHBoxLayout() - segmentButtonsLayout.addStretch(1) - segmentButtonsLayout.addWidget(segmentDefaultButton) - segmentButtonsLayout.addWidget(segmentLoadLastSelButton) - segmentDefaultButton.clicked.connect(self.restoreDefaultSegment) - section = f"{self.model_name}.segment" - segmentLoadLastSelButton.clicked.connect( - partial(loadFunc, section, self.argsWidgets) - ) - segmentParamsScrollAreaLayout.addWidget(segmentGroupBox) - - segmentParamsLayout = QVBoxLayout() - segmentParamsLayout.addWidget(QLabel(f"{segmentGroupBox.title()}")) - segmentGroupBox.setTitle("") - segmentParamsLayout.addWidget(self.segmentParamsScrollArea) - segmentParamsLayout.addLayout(segmentButtonsLayout) - self.secondColLayout.addLayout(segmentParamsLayout) - self.paramsGroupPosMapper[self.segmentParamsScrollArea] = (1, col) - - gridLayout.addLayout(self.secondColLayout, row, col) - - gridLayout.addItem(QSpacerItem(10, 5), 0, col + 1) - col += 2 - - # Buttons layout (spans both columns) - buttonsLayout = QHBoxLayout() - cancelButton = widgets.cancelPushButton(" Cancel ") - okButton = widgets.okPushButton(" Ok ") - - enableLoadingSavingRecipe = not is_tracker and ( - addPreProcessParams or addPostProcessParams - ) - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - if enableLoadingSavingRecipe: - loadEntireRecipeButton = widgets.OpenFilePushButton("Load saved recipe...") - saveEntireRecipeButton = widgets.savePushButton( - "Save all parameters to recipe file..." - ) - buttonsLayout.addWidget(loadEntireRecipeButton) - buttonsLayout.addWidget(saveEntireRecipeButton) - loadEntireRecipeButton.clicked.connect(self.loadEntireRecipe) - saveEntireRecipeButton.clicked.connect(self.saveEntireRecipe) - - buttonsLayout.addWidget(okButton) - - buttonsLayout.setContentsMargins(0, 10, 0, 10) - - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - - self.okButton = okButton - - # Extra params in right column - row = 0 - self.extraArgsWidgets = None - self.extraParamsScrollArea = None - if extraParams is not None: - self.extraParamsScrollArea = widgets.ScrollArea() - extraParamsScrollAreaLayout = QVBoxLayout() - self.extraParamsScrollArea.setVerticalLayout(extraParamsScrollAreaLayout) - if extraParamsTitle is None: - extraParamsTitle = "Additional parameters" - - self.extraGroupBox, self.extraArgsWidgets = self.createGroupParams( - extraParams, extraParamsTitle - ) - - extraDefaultButton = widgets.reloadPushButton("Restore default") - extraLoadLastSelButton = widgets.OpenFilePushButton("Load last parameters") - extraButtonsLayout = QHBoxLayout() - extraButtonsLayout.addStretch(1) - extraButtonsLayout.addWidget(extraDefaultButton) - extraButtonsLayout.addWidget(extraLoadLastSelButton) - extraDefaultButton.clicked.connect(self.restoreDefaultExtra) - section = f"{self.model_name}.extra" - extraLoadLastSelButton.clicked.connect( - partial(loadFunc, section, self.extraArgsWidgets) - ) - - extraParamsScrollAreaLayout.addWidget(self.extraGroupBox) - - extraParamsLayout = QVBoxLayout() - extraParamsLayout.addWidget(QLabel(f"{self.extraGroupBox.title()}")) - self.extraGroupBox.setTitle("") - extraParamsLayout.addWidget(self.extraParamsScrollArea) - extraParamsLayout.addLayout(extraButtonsLayout) - self.paramsGroupPosMapper[self.extraParamsScrollArea] = (row, col) - gridLayout.addLayout(extraParamsLayout, row, col) - row += 1 - - # Post-processing in right-most column - self.postProcessGroupbox = None - self.seeHereLabel = None - thirdColumnLayout = QVBoxLayout() - if addPostProcessParams: - # Add minimum size spinbox which is valid for all models - postProcessGroupbox = PostProcessSegmParams( - "Post-processing segmentation parameters", - posData, - force_postprocess_2D=force_postprocess_2D, - ) - postProcessGroupbox.setCheckable(True) - postProcessGroupbox.setChecked(False) - self.postProcessGroupbox = postProcessGroupbox - - thirdColumnLayout.addWidget(postProcessGroupbox) - - postProcDefaultButton = widgets.reloadPushButton("Restore default") - postProcLoadLastSelButton = widgets.OpenFilePushButton( - "Load last parameters" - ) - postProcButtonsLayout = QHBoxLayout() - postProcButtonsLayout.addStretch(1) - postProcButtonsLayout.addWidget(postProcDefaultButton) - postProcButtonsLayout.addWidget(postProcLoadLastSelButton) - postProcDefaultButton.clicked.connect(self.restoreDefaultPostprocess) - postProcLoadLastSelButton.clicked.connect(self.loadLastSelectionPostProcess) - thirdColumnLayout.addLayout(postProcButtonsLayout) - thirdColumnLayout.addSpacing(15) - - if url is not None: - self.seeHereLabel = self.createSeeHereLabel(url) - thirdColumnLayout.addWidget(self.seeHereLabel, alignment=Qt.AlignCenter) - - self.paramsGroupPosMapper[self.preProcessParamsWidget] = (row, col) - - # Additional segmentation params in right column - self.additionalSegmGroupbox = None - if add_additional_segm_params: - thirdColumnLayout.addWidget(widgets.QHLine()) - additionalSegmGroupbox = self.getAdditionalSegmParams() - thirdColumnLayout.addWidget(additionalSegmGroupbox) - self.additionalSegmGroupbox = additionalSegmGroupbox - self.paramsGroupPosMapper[self.additionalSegmGroupbox] = (row, col) - - thirdColumnLayout.addStretch(1) - gridLayout.addLayout(thirdColumnLayout, row, col) - row += 1 - - # Add everything to main layout - mainLayout.addLayout(gridLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.configPars = self.readLastSelection() - if self.configPars is None: - initLoadLastSelButton.setDisabled(True) - segmentLoadLastSelButton.setDisabled(True) - if self.postProcessGroupbox is not None: - postProcLoadLastSelButton.setDisabled(True) - - if initLastParams: - initLoadLastSelButton.click() - if not self.skipSegmentation: - segmentLoadLastSelButton.click() - - if self.extraArgsWidgets is not None: - extraLoadLastSelButton.click() - - if self.postProcessGroupbox is not None: - postProcLoadLastSelButton.click() - - try: - self.connectCustomSignals(model_module) - except Exception as e: - printl(traceback.format_exc()) - - self.setLayout(mainLayout) - self.setFont(font) - # self.setModal(True) - - def warningNoSegmRecipes(self): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - "No segmentation recipes found!

    " - "To create a segmentation recipe you need click on " - "Save all parameters to recipe file... " - "button." - ) - msg.warning(self, "No segmentation recipes found!", txt) - - def selectIniFileToLoadEntireRecipe(self): - import qtpy.compat - - recipe_filepath = qtpy.compat.getopenfilename( - parent=self, - caption="Select INI file to load entire recipe", - filters="INI (*.ini);;All Files (*)", - )[0] - if not recipe_filepath: - return - - self.loadRecipeFromFilepath(recipe_filepath) - - txt = html_utils.paragraph("Done!

    Segmentation recipe loaded from:") - msg = widgets.myMessageBox() - msg.information( - self, - "Segmentation recipe loaded!", - txt, - commands=(recipe_filepath,), - path_to_browse=os.path.dirname(recipe_filepath), - ) - - print("Done. Segmentation recipe loaded from:", recipe_filepath) - - def loadEntireRecipe(self): - segm_recipes_path_model = os.path.join(segm_recipes_path, self.model_name) - - if not os.path.exists(segm_recipes_path_model): - # self.warningNoSegmRecipes() - self.selectIniFileToLoadEntireRecipe() - return - - recipe_files = os.listdir(segm_recipes_path_model) - - if not recipe_files: - # self.warningNoSegmRecipes() - self.selectIniFileToLoadEntireRecipe() - return - - headerLabels = ["Name", "Date Created"] - items = [] - for recipe_file in recipe_files: - cp = config.ConfigParser() - cp.read(os.path.join(segm_recipes_path_model, recipe_file)) - date_created = cp["info"]["created_on"] - items.append((recipe_file, date_created)) - - browseButton = widgets.browseFileButton( - "Select INI file...", - title="Select INI file to load entire recipe", - openFolder=False, - start_dir=myutils.getMostRecentPath(), - ext={"INI": ".ini"}, - ) - win = QTreeDialog( - items, - headerLabels=headerLabels, - title="Select a segmentation recipe to load", - infoText="Select a segmentation recipe to load:
    ", - path_to_browse=segm_recipes_path_model, - additional_buttons=(browseButton,), - ) - browseButton.sigPathSelected.connect( - partial( - self.entireRecipeIniFileSelected, - selectRecipeWin=win, - sender=browseButton, - ) - ) - win.exec_() - if win.cancel or not hasattr(win, "selectedText"): - print("Loading segmentation recipe cancelled.") - return - - if win.clickedButton == browseButton: - recipe_filepath = win.selectedIniFilepath - else: - recipe_filename = win.selectedText - recipe_filepath = os.path.join(segm_recipes_path_model, recipe_filename) - - self.loadRecipeFromFilepath(recipe_filepath) - - txt = html_utils.paragraph("Done!

    Segmentation recipe loaded from:") - msg = widgets.myMessageBox() - msg.information( - self, - "Segmentation recipe laoded!", - txt, - commands=(recipe_filepath,), - path_to_browse=os.path.dirname(recipe_filepath), - ) - - print("Done. Segmentation recipe loaded from:", recipe_filepath) - - def entireRecipeIniFileSelected( - self, recipe_filepath, selectRecipeWin=None, sender=None - ): - selectRecipeWin.selectedText = "None" - selectRecipeWin.clickedButton = sender - selectRecipeWin.selectedIniFilepath = recipe_filepath - selectRecipeWin.cancel = False - selectRecipeWin.close() - - def loadRecipeFromFilepath(self, recipe_filepath): - cp = config.ConfigParser() - cp.read(recipe_filepath) - - self.loadPreprocRecipe(configPars=cp) - self.loadLastSelection( - f"{self.model_name}.init", self.init_argsWidgets, configPars=cp - ) - self.loadLastSelection( - f"{self.model_name}.segment", self.argsWidgets, configPars=cp - ) - if self.extraArgsWidgets: - self.loadLastSelection( - f"{self.model_name}.extra", self.extraArgsWidgets, configPars=cp - ) - self.loadLastSelectionPostProcess(configPars=cp) - - def saveEntireRecipe(self): - segm_recipes_path_model = os.path.join(segm_recipes_path, self.model_name) - try: - existingNames = os.listdir(segm_recipes_path_model) - except FileNotFoundError: - existingNames = [] - - win = filenameDialog( - title="Filename for segmentation recipe", - basename="segmentation_recipe", - ext=".ini", - hintText="Insert a filename for the segmentation recipe:", - allowEmpty=False, - parent=self, - existingNames=existingNames, - ) - win.exec_() - if win.cancel: - return - - ini_filename = win.filename - os.makedirs(segm_recipes_path, exist_ok=True) - os.makedirs(segm_recipes_path_model, exist_ok=True) - ini_filepath = os.path.join(segm_recipes_path_model, ini_filename) - - configPars = self.getConfigPars(create_new=True) - - if hasattr(self, "reduceMemUsageToggle"): - configPars[f"{self.model_name}.additional_segm_params"] = {} - reduceMemoryUsage = self.reduceMemUsageToggle.isChecked() - option = self.reduceMemUsageToggle.label - configPars[f"{self.model_name}.additional_segm_params"][option] = str( - reduceMemoryUsage - ) - - configPars["info"] = {} - configPars["info"]["created_on"] = datetime.datetime.now().strftime( - r"%Y/%m/%d %H:%M" - ) - - with open(ini_filepath, "w") as configfile: - configPars.write(configfile) - - txt = html_utils.paragraph("Done!

    Segmentation recipe saved to:") - msg = widgets.myMessageBox() - msg.information( - self, - "Segmnentation recipe saved!", - txt, - commands=(ini_filepath,), - path_to_browse=os.path.dirname(ini_filepath), - ) - - print("Done. Segmentation recipe saved to:", ini_filepath) - - def getAdditionalSegmParams(self): - additionalSegmGroupbox = QGroupBox("Additional segmentation parameters") - local_row = 0 - additionalSegmLayout = QGridLayout() - option = "Reduce memory usage" - additionalSegmLayout.addWidget( - QLabel(f"{option}: "), local_row, 0, alignment=Qt.AlignRight - ) - self.reduceMemUsageToggle = widgets.Toggle() - additionalSegmLayout.addWidget( - self.reduceMemUsageToggle, local_row, 1, 1, 2, alignment=Qt.AlignCenter - ) - self.reduceMemUsageToggle.label = option - reduceMemUsageInfoButton = widgets.infoPushButton() - additionalSegmLayout.addWidget(reduceMemUsageInfoButton, local_row, 3) - reduceMemUsageInfoButton.clicked.connect(self.showInfoReduceMemUsage) - additionalSegmLayout.setColumnStretch(0, 0) - additionalSegmLayout.setColumnStretch(1, 1) - additionalSegmLayout.setColumnStretch(3, 0) - additionalSegmGroupbox.setLayout(additionalSegmLayout) - return additionalSegmGroupbox - - def showInfoReduceMemUsage(self): - infoText = html_utils.paragraph(f""" - If you are experiencing memory issues, you can try reducing the - memory usage by toggling this option.

    - This will reduce the memory usage by segmenting timelapse data - frame-by-frame instead of all frames at once. - """) - msg = widgets.myMessageBox(wrapText=False) - msg.information(self, "Reduce memory usage", infoText) - - def loadPreprocRecipe(self, configPars=None): - if self.configPars is None and configPars is None: - return - - if configPars is None: - configPars = self.configPars - - preprocConfigPars = {} - for section in configPars.sections(): - if not section.startswith(f"{self.model_name}.preprocess"): - continue - - preprocConfigPars[section] = configPars[section] - - if not preprocConfigPars: - return - - self.preProcessParamsWidget.loadRecipe(preprocConfigPars) - - def connectCustomSignals(self, model_module): - if model_module is None: - return - - if not hasattr(model_module, "CustomSignals"): - return - - customSignals = model_module.CustomSignals() - for slot_info in customSignals.slots_info: - group = slot_info["group"] - widget_name = slot_info["widget_name"] - if group == "init": - ArgsWidgets_list = self.init_argsWidgets - else: - ArgsWidgets_list = self.argsWidgets - for argwidget in ArgsWidgets_list: - if argwidget.name == widget_name: - signal = getattr(argwidget.widget, slot_info["signal"]) - signal.connect(partial(slot_info["slot"], self)) - break - - def selectedFeaturesRange(self): - if self.postProcessGroupbox is None: - return {} - return self.postProcessGroupbox.selectedFeaturesRange() - - def groupedFeatures(self): - if self.postProcessGroupbox is None: - return {} - return self.postProcessGroupbox.groupedFeatures() - - def setChannelNames(self, chNames): - if not hasattr(self, "channelsCombobox"): - return - - items = ["None"] - items.extend(chNames) - self.channelsCombobox.addItems(items) - - def getValueFromMetadata(self, name): - try: - value = self.df_metadata.at[name, "values"] - except Exception as e: - # traceback.print_exc() - value = None - return value - - def criticalSegmFileRequiredButNoneAvailable(self): - model_name = f"{self.model_name} model" - action_txt = ( - f"Please, segment the correct channel before using {self.model_name}." - ) - if self.model_name == "skip_segmentation": - model_name = "Skipping the segmentation" - action_txt = ( - "To be able to skip the segmentation step, you need " - "create at least one segmentation file." - ) - txt = html_utils.paragraph(f""" - {model_name} - requires an additional segmentation file - but there are none available!

    - {action_txt} -

    Thank you for you patience! - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Segmentation file required", txt) - raise FileNotFoundError( - "Model requires segmentation file but none are available." - ) - - def checkAddSegmEndnameCombobox(self, ArgSpec, groupBoxLayout, row): - if ArgSpec.name != "Auxiliary segmentation file": - return False - - if self.segmFileEndnames is None or not self.segmFileEndnames: - self.criticalSegmFileRequiredButNoneAvailable() - - label = QLabel(f"{ArgSpec.name}: ") - groupBoxLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - items = self.segmFileEndnames - self.segmEndnameCombobox = widgets.QCenteredComboBox() - self.segmEndnameCombobox.addItems(items) - groupBoxLayout.addWidget(self.segmEndnameCombobox, row, 1, 1, 2) - return True - - def createGroupParams(self, ArgSpecs_list, groupName, addChannelSelector=False): - ArgsWidgets_list = [] - groupBox = QGroupBox(groupName) - groupBoxLayout = QGridLayout() - - start_row = 0 - if self.is_tracker and self.channels is not None and addChannelSelector: - label = QLabel(f"Input image: ") - groupBoxLayout.addWidget(label, start_row, 0, alignment=Qt.AlignRight) - items = ["None", *self.channels] - self.channelCombobox = widgets.QCenteredComboBox() - self.channelCombobox.addItems(items) - groupBoxLayout.addWidget(self.channelCombobox, start_row, 1, 1, 2) - if self.currentChannelName is not None: - self.channelCombobox.setCurrentText(self.currentChannelName) - infoText = ( - "Some trackers require the intensity image as input.

    " - "If this one does not require it, leave the selected value " - "to `None`." - ) - infoButton = self.getInfoButton("Input image", infoText) - groupBoxLayout.addWidget(infoButton, start_row, 3) - start_row += 1 - - addSecondChannelSelector = addChannelSelector - if len(ArgSpecs_list) > 0: - if addSecondChannelSelector and ArgSpecs_list[0].docstring is not None: - isSingleChannel = ( - ArgSpecs_list[0].docstring.lower().find("single channel only") != -1 - ) - if isSingleChannel: - addSecondChannelSelector = False - - isDualChannelModel = self.model_name.find("cellpose") != -1 or any( - [_types.is_second_channel_type(ArgSpec.type) for ArgSpec in ArgSpecs_list] - ) - askSecondChannel = isDualChannelModel and addSecondChannelSelector - - if askSecondChannel: - label = QLabel("Second channel (optional): ") - groupBoxLayout.addWidget(label, start_row, 0, alignment=Qt.AlignRight) - self.channelsCombobox = widgets.QCenteredComboBox() - groupBoxLayout.addWidget(self.channelsCombobox, start_row, 1, 1, 2) - infoText = ( - "Some models can merge two channels (e.g., cyto + " - "nucleus) to obtain better perfomance.\n\n" - "Select a channel as additional input to the model." - ) - infoButton = self.getInfoButton("Second channel", infoText) - groupBoxLayout.addWidget(infoButton, start_row, 3) - start_row += 1 - - exclusive_withs = dict() - default_exclusives = dict() - row_mapper = dict() - for row, ArgSpec in enumerate(ArgSpecs_list): - if _types.is_second_channel_type(ArgSpec.type): - continue - - if _types.is_widget_not_required(ArgSpec): - continue - - row = row + start_row - skip = self.checkAddSegmEndnameCombobox(ArgSpec, groupBoxLayout, row) - if skip: - continue - - arg_name = ArgSpec.name - var_name = arg_name.replace("_", " ") - var_name = f"{var_name[0].upper()}{var_name[1:]}" - label = QLabel(f"{var_name}: ") - metadata_val = self.getValueFromMetadata(ArgSpec.name) - groupBoxLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) - try: - values = ArgSpec.type().values - isCustomListType = True - except Exception as err: - isCustomListType = False - - isVectorEntry = False - try: - if isinstance(ArgSpec.type(), _types.Vector): - isVectorEntry = True - except Exception as err: - pass - - isFolderPath = False - try: - if isinstance(ArgSpec.type(), _types.FolderPath): - isFolderPath = True - except Exception as err: - pass - - try: - exclusive_with = ArgSpec.type().is_exclusive_with - except Exception as err: - exclusive_with = [] - - try: - default_exclusive = ArgSpec.type().default_exclusive - except Exception as err: - default_exclusive = "" - - exclusive_withs[arg_name] = exclusive_with - default_exclusives[arg_name] = default_exclusive - row_mapper[arg_name] = row - - isCustomWidget = hasattr(ArgSpec.type, "isWidget") - - if isCustomWidget: - widget = ArgSpec.type().widget - defaultVal = ArgSpec.default - valueSetter = widget.setValue - valueGetter = widget.value - changeSig = widget.sigValueChanged - groupBoxLayout.addWidget(widget, row, 1, 1, 2) - elif isVectorEntry: - vectorLineEdit = widgets.VectorLineEdit() - vectorLineEdit.setValue(ArgSpec.default) - defaultVal = ArgSpec.default - valueSetter = widgets.VectorLineEdit.setValue - valueGetter = widgets.VectorLineEdit.value - changeSig = vectorLineEdit.valueChanged - widget = vectorLineEdit - groupBoxLayout.addWidget(vectorLineEdit, row, 1, 1, 2) - elif isFolderPath: - folderPathControl = widgets.FolderPathControl() - folderPathControl.setText(str(ArgSpec.default)) - widget = folderPathControl - defaultVal = str(ArgSpec.default) - valueSetter = widgets.FolderPathControl.setText - valueGetter = widgets.FolderPathControl.path - changeSig = widget.sigValueChanged - groupBoxLayout.addWidget(folderPathControl, row, 1, 1, 2) - elif ArgSpec.type == bool: - booleanGroup = QButtonGroup() - booleanGroup.setExclusive(True) - checkBox = widgets.Toggle() - checkBox.setChecked(ArgSpec.default) - defaultVal = ArgSpec.default - valueSetter = widgets.Toggle.setChecked - valueGetter = widgets.Toggle.isChecked - changeSig = checkBox.toggled - widget = checkBox - groupBoxLayout.addWidget( - checkBox, row, 1, 1, 2, alignment=Qt.AlignCenter - ) - elif ArgSpec.type == int: - spinBox = widgets.SpinBox() - if metadata_val is None: - spinBox.setValue(ArgSpec.default) - else: - spinBox.setValue(int(metadata_val)) - spinBox.isMetadataValue = True - defaultVal = ArgSpec.default - valueSetter = QSpinBox.setValue - valueGetter = QSpinBox.value - changeSig = spinBox.sigValueChanged - widget = spinBox - groupBoxLayout.addWidget(spinBox, row, 1, 1, 2) - elif ArgSpec.type == float: - doubleSpinBox = widgets.FloatLineEdit() - if metadata_val is None: - doubleSpinBox.setValue(ArgSpec.default) - else: - doubleSpinBox.setValue(float(metadata_val)) - doubleSpinBox.isMetadataValue = True - widget = doubleSpinBox - defaultVal = ArgSpec.default - valueSetter = widgets.FloatLineEdit.setValue - valueGetter = widgets.FloatLineEdit.value - changeSig = doubleSpinBox.valueChanged - groupBoxLayout.addWidget(doubleSpinBox, row, 1, 1, 2) - elif ArgSpec.type == os.PathLike: - filePathControl = widgets.filePathControl() - filePathControl.setText(str(ArgSpec.default)) - widget = filePathControl - defaultVal = str(ArgSpec.default) - valueSetter = widgets.filePathControl.setText - valueGetter = widgets.filePathControl.path - changeSig = filePathControl.sigValueChanged - groupBoxLayout.addWidget(filePathControl, row, 1, 1, 2) - elif isCustomListType: - items = ArgSpec.type().values - defaultVal = str(ArgSpec.default) - combobox = widgets.AlphaNumericComboBox() - combobox.addItems(items) - combobox.setCurrentValue(defaultVal) - valueSetter = widgets.AlphaNumericComboBox.setCurrentValue - valueGetter = widgets.AlphaNumericComboBox.currentValue - changeSig = combobox.currentTextChanged - widget = combobox - groupBoxLayout.addWidget(combobox, row, 1, 1, 2) - else: - lineEdit = QLineEdit() - lineEdit.setText(str(ArgSpec.default)) - lineEdit.setAlignment(Qt.AlignCenter) - widget = lineEdit - defaultVal = str(ArgSpec.default) - valueSetter = QLineEdit.setText - valueGetter = QLineEdit.text - changeSig = lineEdit.editingFinished - groupBoxLayout.addWidget(lineEdit, row, 1, 1, 2) - - if ArgSpec.desc: - infoButton = self.getInfoButton(ArgSpec.name, ArgSpec.desc) - groupBoxLayout.addWidget(infoButton, row, 3) - - argsInfo = ArgWidget( - name=ArgSpec.name, - type=ArgSpec.type, - widget=widget, - defaultVal=defaultVal, - valueSetter=valueSetter, - valueGetter=valueGetter, - changeSig=changeSig, - ) - ArgsWidgets_list.append(argsInfo) - - exclusive_group = core.connected_components_in_undirected_graph(exclusive_withs) - - for group in exclusive_group: - if len(group) == 1: - continue - for arg_name in group: - default_exclusive = default_exclusives[arg_name] - row = row_mapper[arg_name] - - argsInfo = ArgsWidgets_list[row] - valueSetter = argsInfo.valueSetter - widget = argsInfo.widget - valueGetter = argsInfo.valueGetter - - argsInfo.valueGetter = qutils.replace_certain_vals( - argsInfo.valueGetter, default_exclusive, None - ) - - for arg_name_other in group: - if arg_name == arg_name_other: - continue - row_other = row_mapper[arg_name_other] - argsInfo_other = ArgsWidgets_list[row_other] - changeSig_other = argsInfo_other.changeSig - changeSig_other.connect( - partial( - qutils.set_exclusive_valueSetter, - widget, - valueSetter, - default_exclusive, - ) - ) - - groupBoxLayout.setColumnStretch(0, 0) - groupBoxLayout.setColumnStretch(1, 1) - groupBoxLayout.setColumnStretch(3, 0) - nrows = groupBoxLayout.rowCount() - groupBoxLayout.setRowStretch(nrows, 1) - - groupBox.setLayout(groupBoxLayout) - return groupBox, ArgsWidgets_list - - def getInfoButton(self, param_name, infoText): - infoButton = widgets.infoPushButton() - infoButton.param_name = param_name - infoButton.setToolTip( - f"Click to get more info about `{param_name}` parameter..." - ) - infoButton.infoText = infoText - infoButton.clicked.connect(self.showInfoParam) - return infoButton - - def showInfoParam(self): - text = self.sender().infoText - text = text.replace("\n", "
    ") - text = html_utils.rst_urls_to_html(text) - text = html_utils.rst_to_html(text) - text = html_utils.paragraph(text) - param_name = self.sender().param_name - msg = widgets.myMessageBox(wrapText=False) - msg.information(self, f"Info about `{param_name}` parameter", text) - - def restoreDefaultInit(self): - for argWidget in self.init_argsWidgets: - defaultVal = argWidget.defaultVal - widget = argWidget.widget - valueSetter = argWidget.valueSetter - qutils.set_exclusive_valueSetter(widget, valueSetter, defaultVal) - - def restoreDefaultSegment(self): - for argWidget in self.argsWidgets: - defaultVal = argWidget.defaultVal - widget = argWidget.widget - valueSetter = argWidget.valueSetter - qutils.set_exclusive_valueSetter(widget, valueSetter, defaultVal) - - def restoreDefaultExtra(self): - for argWidget in self.extraArgsWidgets: - defaultVal = argWidget.defaultVal - widget = argWidget.widget - valueSetter = argWidget.valueSetter - qutils.set_exclusive_valueSetter(widget, valueSetter, defaultVal) - - def restoreDefaultPostprocess(self): - self.postProcessGroupbox.restoreDefault() - - def readLastSelection(self): - self.ini_path = os.path.join(settings_folderpath, self.ini_filename) - - if not os.path.exists(self.ini_path): - return None - - print(f"Reading last selected parameters from: {self.ini_path}") - configPars = config.ConfigParser() - configPars.read(self.ini_path) - return configPars - - def setValuesFromParams(self, init_params, segment_params, extra_params=None): - sections = { - f"{self.model_name}.init": (init_params, self.init_argsWidgets), - f"{self.model_name}.segment": (segment_params, self.argsWidgets), - } - if extra_params is not None: - sections[f"{self.model_name}.extra"] = (extra_params, self.extraArgsWidgets) - - for section, values in sections.items(): - params, argWidgetList = values - for argWidget in argWidgetList: - val = params.get(argWidget.name) - widget = argWidget.widget - if val is None: - continue - casters = [lambda x: x, int, float, str, bool] - for caster in casters: - try: - argWidget.valueSetter(widget, caster(val)) - break - except Exception as e: - continue - - def loadLastSelection(self, section, argWidgetList, checked=False, configPars=None): - if self.configPars is None and configPars is None: - return - - if configPars is None: - configPars = self.configPars - - getters = ["getboolean", "getint", "getfloat", "get"] - try: - options = configPars.options(section) - except Exception: - return - - for argWidget in argWidgetList: - option = argWidget.name - val = None - for getter in getters: - try: - val = getattr(configPars, getter)(section, option) - break - except Exception as err: - pass - widget = argWidget.widget - - if hasattr(widget, "isMetadataValue"): - continue - if val is None: - continue - - casters = [lambda x: x, int, float, str, bool] - for caster in casters: - try: - val = caster(val) - valueSetter = argWidget.valueSetter - qutils.set_exclusive_valueSetter(widget, valueSetter, val) - break - except Exception as e: - printl(traceback.format_exc()) - continue - - def loadLastSelectionPostProcess(self, checked=False, configPars=None): - if self.postProcessGroupbox is None: - return - - postProcessSection = f"{self.model_name}.postprocess" - - if isinstance(configPars, bool): - configPars = None - - if configPars is None: - configPars = self.configPars - - if postProcessSection in configPars.sections(): - try: - minSize = configPars.getint(postProcessSection, "minSize", fallback=10) - except ValueError: - minSize = 10 - - try: - minSolidity = configPars.getfloat( - postProcessSection, "minSolidity", fallback=0.5 - ) - except ValueError: - minSolidity = 0.5 - - try: - maxElongation = configPars.getfloat( - postProcessSection, "maxElongation", fallback=3 - ) - except ValueError: - maxElongation = 3 - - try: - minObjSizeZ = configPars.getint( - postProcessSection, "min_obj_no_zslices", fallback=3 - ) - except ValueError: - minObjSizeZ = 3 - - kwargs = { - "min_solidity": minSolidity, - "min_area": minSize, - "max_elongation": maxElongation, - "min_obj_no_zslices": minObjSizeZ, - } - self.postProcessGroupbox.restoreFromKwargs(kwargs) - - applyPostProcessing = configPars.getboolean( - postProcessSection, "applyPostProcessing" - ) - self.postProcessGroupbox.setChecked(applyPostProcessing) - - customPostProcessSection = f"{self.model_name}.custom_postprocess" - if postProcessSection not in configPars.sections(): - return - - selectFeaturesWidget = self.postProcessGroupbox.selectedFeaturesDialog.groupbox - selectFeaturesWidget.resetFields() - f = 0 - for col_name, value in configPars[customPostProcessSection].items(): - low, high = value.split(",") - low = low.strip() - high = high.strip() - if f > 0: - selectFeaturesWidget.addFeatureField() - - selector = selectFeaturesWidget.selectors[f] - selector.selectButton.setText(col_name) - selector.selectButton.setFlat(True) - - feature_group = measurements.get_metric_group_name(col_name) - selector.featureGroup = feature_group - - if low != "None": - try: - low_val = int(low) - except ValueError: - low_val = float(low) - - selector.lowRangeWidgets.checkbox.setChecked(True) - selector.lowRangeWidgets.spinbox.setValue(low_val) - - if high != "None": - try: - high_val = int(high) - except ValueError: - high_val = float(high) - - selector.highRangeWidgets.checkbox.setChecked(True) - selector.highRangeWidgets.spinbox.setValue(high_val) - - f += 1 - - def createSeeHereLabel(self, url): - htmlTxt = f'here' - seeHereLabel = QLabel() - seeHereLabel.setText(f""" -

    - See {htmlTxt} for details on the parameters -

    - """) - seeHereLabel.setTextFormat(Qt.RichText) - seeHereLabel.setTextInteractionFlags(Qt.TextBrowserInteraction) - seeHereLabel.setOpenExternalLinks(True) - seeHereLabel.setStyleSheet("padding:12px 0px 0px 0px;") - return seeHereLabel - - def argsWidgets_to_kwargs(self, argsWidgets): - kwargs_dict = { - argWidget.name: argWidget.valueGetter(argWidget.widget) - for argWidget in argsWidgets - } - return kwargs_dict - - def getInitKwargs(self): - init_kwargs = self.argsWidgets_to_kwargs(self.init_argsWidgets) - if hasattr(self, "segmEndnameCombobox"): - init_kwargs["segm_endname"] = self.segmEndnameCombobox.currentText() - - return init_kwargs - - def getModelKwargs(self): - if self.skipSegmentation: - return {} - - return self.argsWidgets_to_kwargs(self.argsWidgets) - - def getExtraKwargs(self): - if self.extraArgsWidgets is None: - return {} - - return self.argsWidgets_to_kwargs(self.extraArgsWidgets) - - def ok_cb(self, checked): - self.cancel = False - self.preproc_recipe = None - if self.preProcessParamsWidget is not None: - self.preproc_recipe = self.preProcessParamsWidget.recipe() - if self.preproc_recipe is None: - return - - self.init_kwargs = self.getInitKwargs() - - if self.extraArgsWidgets: - self.extra_kwargs = self.getExtraKwargs() - - self.model_kwargs = self.getModelKwargs() - self.segment_kwargs = self.model_kwargs - - if self.postProcessGroupbox is not None: - self.applyPostProcessing = self.postProcessGroupbox.isChecked() - self.standardPostProcessKwargs = self.postProcessGroupbox.kwargs() - self.secondChannelName = None - if hasattr(self, "channelsCombobox"): - self.secondChannelName = self.channelsCombobox.currentText() - if self.secondChannelName == "None": - self.secondChannelName = None - self.inputChannelName = "None" - if self.channelCombobox is not None: - self.inputChannelName = self.channelCombobox.currentText() - - self.reduceMemoryUsage = False - if hasattr(self, "reduceMemUsageToggle"): - self.reduceMemoryUsage = self.reduceMemUsageToggle.isChecked() - self.customPostProcessFeatures = self.selectedFeaturesRange() - self.customPostProcessGroupedFeatures = self.groupedFeatures() - self.saveLastSelection() - self.freePosData() - self.close() - - def freePosData(self): - if hasattr(self, "postProcessGroupbox"): - try: - for ( - selector - ) in self.postProcessGroupbox.selectedFeaturesDialog.groupbox.selectors: - qutils.hardDelete(selector) - except AttributeError: - pass - try: - qutils.hardDelete( - self.postProcessGroupbox.selectedFeaturesDialog.groupbox - ) - except AttributeError: - pass - try: - qutils.hardDelete(self.postProcessGroupbox.selectedFeaturesDialog) - except AttributeError: - pass - try: - qutils.hardDelete(self.postProcessGroupbox) - except AttributeError: - pass - - def getConfigPars(self, create_new=False): - if self.configPars is None or create_new: - configPars = config.ConfigParser() - else: - configPars = self.configPars - - if self.preProcessParamsWidget is not None: - preprocCp = self.preProcessParamsWidget.recipeConfigPars(self.model_name) - for section in preprocCp.sections(): - configPars[section] = preprocCp[section] - - configPars[f"{self.model_name}.init"] = {} - configPars[f"{self.model_name}.segment"] = {} - configPars[f"{self.model_name}.extra"] = {} - - init_kwargs = self.getInitKwargs() - model_kwargs = self.getModelKwargs() - - for key, val in init_kwargs.items(): - configPars[f"{self.model_name}.init"][key] = str(val) - for key, val in model_kwargs.items(): - configPars[f"{self.model_name}.segment"][key] = str(val) - if self.extraArgsWidgets: - extra_kwargs = self.getExtraKwargs() - for key, val in extra_kwargs.items(): - configPars[f"{self.model_name}.extra"][key] = str(val) - - configPars[f"{self.model_name}.postprocess"] = {} - if self.postProcessGroupbox is not None: - postProcKwargs = self.postProcessGroupbox.kwargs() - postProcessConfig = configPars[f"{self.model_name}.postprocess"] - postProcessConfig["minSize"] = str(postProcKwargs["min_area"]) - postProcessConfig["minSolidity"] = str(postProcKwargs["min_solidity"]) - postProcessConfig["maxElongation"] = str(postProcKwargs["max_elongation"]) - postProcessConfig["min_obj_no_zslices"] = str( - postProcKwargs["min_obj_no_zslices"] - ) - postProcessConfig["applyPostProcessing"] = str( - self.postProcessGroupbox.isChecked() - ) - - custom_postproc_section = f"{self.model_name}.custom_postprocess" - configPars[custom_postproc_section] = {} - if self.postProcessGroupbox is not None: - selectFeaturesWidget = ( - self.postProcessGroupbox.selectedFeaturesDialog.groupbox - ) - for selector in selectFeaturesWidget.selectors: - col_name = selector.selectButton.text() - lowStr = "None" - highStr = "None" - if selector.lowRangeWidgets.checkbox.isChecked(): - lowVal = selector.lowRangeWidgets.spinbox.value() - lowStr = str(lowVal) - if selector.highRangeWidgets.checkbox.isChecked(): - highVal = selector.highRangeWidgets.spinbox.value() - highStr = str(highVal) - - configPars[custom_postproc_section][col_name] = f"{lowStr}, {highStr}" - - return configPars - - def saveLastSelection(self): - self.configPars = self.getConfigPars() - with open(self.ini_path, "w") as configfile: - self.configPars.write(configfile) - - mode = "Segmentation" if not self.is_tracker else "Tracking" - - print(f'{mode} parameters saved at "{self.ini_path}"') - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - if self.model_name == "thresholding": - self.segmentGroupBox.setDisabled(True) - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - self.freePosData() - if hasattr(self, "loop"): - self.loop.exit() - - def cancel_cb(self, checked): - self.cancel = True - self.freePosData() - - def showEvent(self, event) -> None: - buttonHeight = self.okButton.minimumSizeHint().height() - heightInitParams = self.initParamsScrollArea.minimumHeightNoScrollbar() - heightLeft = 70 + buttonHeight - heightCenter = heightInitParams - heightRight = 0 - if self.segmentParamsScrollArea is not None: - heightSegmentParams = ( - self.segmentParamsScrollArea.minimumHeightNoScrollbar() - ) - heightCenter += heightSegmentParams + 70 + buttonHeight - - rowInitParams, _ = self.paramsGroupPosMapper[self.initParamsScrollArea] - rowSegmParams, _ = self.paramsGroupPosMapper[self.segmentParamsScrollArea] - - numInitParams = len(self.init_params) - numSegmentParams = len(self.segment_params) - - try: - segmentParamsStretch = max(1, round(numSegmentParams / numInitParams)) - except ZeroDivisionError as err: - segmentParamsStretch = 1 - self.secondColLayout.setStretch(rowInitParams, 1) - self.secondColLayout.setStretch(rowSegmParams, segmentParamsStretch) - - if self.extraParamsScrollArea is not None: - heightRight += ( - self.extraParamsScrollArea.minimumHeightNoScrollbar() - + 70 - + buttonHeight - ) - - if self.additionalSegmGroupbox is not None: - heightRight += self.additionalSegmGroupbox.minimumSizeHint().height() - heightRight += buttonHeight - if self.preProcessParamsWidget is not None: - heightPreprocParams = self.preProcessParamsWidget.minimumSizeHint().height() - heightLeft += heightPreprocParams - heightLeft += buttonHeight - if self.postProcessGroupbox is not None: - heightRight += self.postProcessGroupbox.minimumSizeHint().height() - heightRight += buttonHeight - if self.seeHereLabel is not None: - heightRight += self.seeHereLabel.minimumSizeHint().height() - height = max(heightLeft, heightRight, heightCenter) - screenHeight = self.screen().size().height() - screenGeom = self.screen().geometry() - screenLeft = screenGeom.left() - screenRight = screenGeom.right() - screenCenter = (screenLeft + screenRight) / 2 - width = self.sizeHint().width() - windowLeft = int(screenCenter - width / 2) - self.move(windowLeft, 20) - - if height >= screenHeight - 150: - height = screenHeight - 150 - self.resize(width, height) - - -class downloadModel: - def __init__(self, model_name, parent=None): - self.loop = None - self.model_name = model_name - self._parent = parent - - def download(self): - model_url = myutils._model_url(self.model_name) - if model_url is None: - return - - _, model_path = myutils.get_model_path(self.model_name, create_temp_dir=False) - model_name = self.model_name - model_exists = myutils.check_model_exists(model_path, model_name) - if not model_exists: - self.warnDownloadModel(model_path, self.model_name) - try: - self._parent.logger.info( - f'Downloading {self.model_name} model(s) to "{model_path}"' - ) - except Exception as err: - pass - - success = myutils.download_model(self.model_name) - if not success: - self.criticalDowloadFailed() - - def warnDownloadModel(self, model_path, model_name): - txt = html_utils.paragraph( - "Cell-ACDC needs to download the model " - f"{model_name}.

    " - "The files will be dowloaded into the following folder:

    " - f"{model_path}

    " - "Progress will be displayed in the terminal.
    " - ) - msg = widgets.myMessageBox() - msg.information(self._parent, "Download model", txt) - - def criticalDowloadFailed(self): - import cellacdc - - model_name = self.model_name - m = model_name.lower() - weights_filenames = getattr(cellacdc, f"{m}_weights_filenames") - url, alternative_url = myutils._model_url(model_name, return_alternative=True) - url_href = f'this link' - alternative_url_href = f'this link' - _, model_path = myutils.get_model_path(model_name, create_temp_dir=False) - txt = html_utils.paragraph(f""" - Automatic download of {model_name} failed.

    - Please, manually download the model weights from {url_href} or - {alternative_url_href}.

    - Next, unzip the content (or move the files if not a zip archive) - of the downloaded file into the following folder:

    - {model_path}

    - NOTE: if clicking on the link above does not work - copy one of the links below and paste it into the browser

    - {url} -

    - {alternative_url} - """) - weights_paths = [os.path.join(model_path, f) for f in weights_filenames] - weights = "\n\n".join(weights_paths) - detailsText = f"Files that {model_name} requires:\n\n{weights}" - msg = widgets.myMessageBox() - msg.critical( - self._parent, - f"Download of {model_name} failed", - txt, - detailsText=detailsText, - ) - self.close_() - - def close_(self): - return - # self.hide() - # self.close() - # if self.loop is not None: - # self.loop.exit() - - -class combineMetricsEquationDialog(QBaseDialog): - sigOk = Signal(object) - - def __init__( - self, allChNames, isZstack, isSegm3D, parent=None, debug=False, closeOnOk=True - ): - super().__init__(parent) - - self.setWindowTitle("Add combined measurement") - - self.initAttributes() - - self.allChNames = allChNames - - self.cancel = True - self.isOperatorMode = False - self.closeOnOk = closeOnOk - - mainLayout = QVBoxLayout() - equationLayout = QHBoxLayout() - - metricsTreeWidget = QTreeWidget() - metricsTreeWidget.setHeaderHidden(True) - metricsTreeWidget.setFont(font) - self.metricsTreeWidget = metricsTreeWidget - - for chName in allChNames: - channelTreeItem = QTreeWidgetItem(metricsTreeWidget) - channelTreeItem.setText(0, f"{chName} measurements") - metricsTreeWidget.addTopLevelItem(channelTreeItem) - - metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc( - isZstack, chName, isSegm3D=isSegm3D - ) - custom_metrics_desc = measurements.custom_metrics_desc( - isZstack, chName, isSegm3D=isSegm3D - ) - - foregrMetricsTreeItem = QTreeWidgetItem(channelTreeItem) - foregrMetricsTreeItem.setText(0, "Cell signal measurements") - channelTreeItem.addChild(foregrMetricsTreeItem) - - bkgrMetricsTreeItem = QTreeWidgetItem(channelTreeItem) - bkgrMetricsTreeItem.setText(0, "Background values") - channelTreeItem.addChild(bkgrMetricsTreeItem) - - if custom_metrics_desc: - customMetricsTreeItem = QTreeWidgetItem(channelTreeItem) - customMetricsTreeItem.setText(0, "Custom measurements") - channelTreeItem.addChild(customMetricsTreeItem) - - self.addTreeItems(foregrMetricsTreeItem, metrics_desc.keys(), isCol=True) - self.addTreeItems(bkgrMetricsTreeItem, bkgr_val_desc.keys(), isCol=True) - - if custom_metrics_desc: - self.addTreeItems( - customMetricsTreeItem, custom_metrics_desc.keys(), isCol=True - ) - - self.addChannelLessItems(isZstack, isSegm3D=isSegm3D) - - sizeMetricsTreeItem = QTreeWidgetItem(metricsTreeWidget) - sizeMetricsTreeItem.setText(0, "Size measurements") - metricsTreeWidget.addTopLevelItem(sizeMetricsTreeItem) - - size_metrics_desc = measurements.get_size_metrics_desc(isSegm3D, True) - self.addTreeItems(sizeMetricsTreeItem, size_metrics_desc.keys(), isCol=True) - - propMetricsTreeItem = QTreeWidgetItem(metricsTreeWidget) - propMetricsTreeItem.setText(0, "Region properties") - metricsTreeWidget.addTopLevelItem(propMetricsTreeItem) - - props_names = measurements.get_props_names() - self.addTreeItems(propMetricsTreeItem, props_names, isCol=True) - - operatorsLayout = QHBoxLayout() - operatorsLayout.addStretch(1) - - iconSize = 24 - - self.operatorButtons = [] - self.operators = [ - ("add", "+"), - ("subtract", "-"), - ("multiply", "*"), - ("divide", "/"), - ("open_bracket", "("), - ("close_bracket", ")"), - ("square", "**2"), - ("pow", "**"), - ("ln", "log("), - ("log10", "log10("), - ] - operatorFont = QFont() - operatorFont.setPixelSize(16) - for name, text in self.operators: - button = QPushButton() - button.setIcon(QIcon(f":{name}.svg")) - button.setIconSize(QSize(iconSize, iconSize)) - button.text = text - operatorsLayout.addWidget(button) - self.operatorButtons.append(button) - button.clicked.connect(self.addOperator) - # button.setFont(operatorFont) - - clearButton = QPushButton() - clearButton.setIcon(QIcon(":clear.svg")) - clearButton.setIconSize(QSize(iconSize, iconSize)) - clearButton.setFont(operatorFont) - - clearEntryButton = QPushButton() - clearEntryButton.setIcon(QIcon(":backspace.svg")) - clearEntryButton.setFont(operatorFont) - clearEntryButton.setIconSize(QSize(iconSize, iconSize)) - - operatorsLayout.addWidget(clearButton) - operatorsLayout.addWidget(clearEntryButton) - operatorsLayout.addStretch(1) - - newColNameLayout = QVBoxLayout() - newColNameLineEdit = widgets.alphaNumericLineEdit() - newColNameLineEdit.setAlignment(Qt.AlignCenter) - self.newColNameLineEdit = newColNameLineEdit - newColNameLayout.addStretch(1) - newColNameLayout.addWidget(QLabel("New measurement name:")) - newColNameLayout.addWidget(newColNameLineEdit) - newColNameLayout.addStretch(1) - - equationDisplayLayout = QVBoxLayout() - equationDisplayLayout.addWidget(QLabel("Equation:")) - equationDisplay = QPlainTextEdit() - # equationDisplay.setReadOnly(True) - self.equationDisplay = equationDisplay - equationDisplayLayout.addWidget(equationDisplay) - equationDisplayLayout.setStretch(0, 0) - equationDisplayLayout.setStretch(1, 1) - - equationLayout.addLayout(newColNameLayout) - equationLayout.addWidget(QLabel(" = ")) - equationLayout.addLayout(equationDisplayLayout) - equationLayout.setStretch(0, 1) - equationLayout.setStretch(1, 0) - equationLayout.setStretch(2, 2) - - testOutputLayout = QVBoxLayout() - testOutputLayout.addWidget(QLabel("Result of test with random inputs:")) - testOutputDisplay = QTextEdit() - testOutputDisplay.setReadOnly(True) - self.testOutputDisplay = testOutputDisplay - testOutputLayout.addWidget(testOutputDisplay) - testOutputLayout.setStretch(0, 0) - testOutputLayout.setStretch(1, 1) - - instructions = html_utils.paragraph(""" - Double-click on any of the available measurements - to add it to the equation.

    - NOTE: the result will be saved in the acdc_output.csv - file as a column with the same name
    - you enter in "New measurement name" - field.

    - """) - - buttonsLayout = QHBoxLayout() - - cancelButton = widgets.cancelPushButton("Cancel") - helpButton = widgets.infoPushButton(" Help...") - testButton = widgets.calcPushButton("Test output") - okButton = widgets.okPushButton(" Ok ") - okButton.setDisabled(True) - self.okButton = okButton - - buttonsLayout.addStretch(1) - - if debug: - debugButton = QPushButton("Debug") - debugButton.clicked.connect(self._debug) - buttonsLayout.addWidget(debugButton) - - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(helpButton) - buttonsLayout.addWidget(testButton) - buttonsLayout.addWidget(okButton) - - mainLayout.addWidget(QLabel(instructions)) - mainLayout.addWidget(QLabel("Available measurements:")) - mainLayout.addWidget(metricsTreeWidget) - mainLayout.addLayout(operatorsLayout) - mainLayout.addLayout(equationLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - mainLayout.addLayout(testOutputLayout) - - clearButton.clicked.connect(self.clearEquation) - clearEntryButton.clicked.connect(self.clearEntryEquation) - metricsTreeWidget.itemDoubleClicked.connect(self.addColname) - - helpButton.clicked.connect(self.showHelp) - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - testButton.clicked.connect(self.test_cb) - - self.setLayout(mainLayout) - self.setFont(font) - - self.setStyleSheet(TREEWIDGET_STYLESHEET) - - def addChannelLessItems(self, isZstack, isSegm3D=False): - allChannelsTreeItem = QTreeWidgetItem(self.metricsTreeWidget) - allChannelsTreeItem.setText(0, f"All channels measurements") - metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc( - isZstack, "", isSegm3D=isSegm3D - ) - custom_metrics_desc = measurements.custom_metrics_desc( - isZstack, "", isSegm3D=isSegm3D - ) - - foregrMetricsTreeItem = QTreeWidgetItem(allChannelsTreeItem) - foregrMetricsTreeItem.setText(0, "Cell signal measurements") - allChannelsTreeItem.addChild(foregrMetricsTreeItem) - - bkgrMetricsTreeItem = QTreeWidgetItem(allChannelsTreeItem) - bkgrMetricsTreeItem.setText(0, "Background values") - allChannelsTreeItem.addChild(bkgrMetricsTreeItem) - - if custom_metrics_desc: - customMetricsTreeItem = QTreeWidgetItem(allChannelsTreeItem) - customMetricsTreeItem.setText(0, "Custom measurements") - allChannelsTreeItem.addChild(customMetricsTreeItem) - - self.addTreeItems( - foregrMetricsTreeItem, metrics_desc.keys(), isCol=True, isChannelLess=True - ) - self.addTreeItems( - bkgrMetricsTreeItem, bkgr_val_desc.keys(), isCol=True, isChannelLess=True - ) - - if custom_metrics_desc: - self.addTreeItems( - customMetricsTreeItem, - custom_metrics_desc.keys(), - isCol=True, - isChannelLess=True, - ) - - def addOperator(self): - button = self.sender() - text = f"{self.equationDisplay.toPlainText()}{button.text}" - self.equationDisplay.setPlainText(text) - self.clearLenghts.append(len(button.text)) - - def clearEquation(self): - self.isOperatorMode = False - self.equationDisplay.setPlainText("") - self.initAttributes() - - def initAttributes(self): - self.clearLenghts = [] - self.equationColNames = [] - self.channelLessColnames = [] - - def clearEntryEquation(self): - if not self.clearLenghts: - return - - text = self.equationDisplay.toPlainText() - newText = text[: -self.clearLenghts[-1]] - clearedText = text[-self.clearLenghts[-1] :] - self.clearLenghts.pop(-1) - self.equationDisplay.setPlainText(newText) - if clearedText in self.equationColNames: - self.equationColNames.remove(clearedText) - if clearedText in self.channelLessColnames: - self.channelLessColnames.remove(clearedText) - - def addTreeItems(self, parentItem, itemsText, isCol=False, isChannelLess=False): - for text in itemsText: - _item = QTreeWidgetItem(parentItem) - _item.setText(0, text) - parentItem.addChild(_item) - if isCol: - _item.isCol = True - _item.isChannelLess = isChannelLess - - def addColname(self, item, column): - if not hasattr(item, "isCol"): - return - - colName = item.text(0) - text = f"{self.equationDisplay.toPlainText()}{colName}" - self.equationDisplay.setPlainText(text) - self.clearLenghts.append(len(colName)) - self.equationColNames.append(colName) - if item.isChannelLess: - self.channelLessColnames.append(colName) - - def _debug(self): - print(self.getEquationsDict()) - - def getEquationsDict(self): - equation = self.equationDisplay.toPlainText() - newColName = self.newColNameLineEdit.text() - if not self.channelLessColnames: - chNamesInTerms = set() - for term in self.equationColNames: - for chName in self.allChNames: - if chName in term: - chNamesInTerms.add(chName) - if len(chNamesInTerms) == 1: - # Equation uses metrics from a single channel --> append channel name - chName = chNamesInTerms.pop() - chColName = f"{chName}_{newColName}" - isMixedChannels = False - return {chColName: equation}, isMixedChannels - else: - # Equation doesn't use all channels metrics nor is single channel - isMixedChannels = True - return {newColName: equation}, isMixedChannels - - isMixedChannels = False - equations = {} - for chName in self.allChNames: - chEquation = equation - chEquationName = newColName - # Append each channel name to channelLess terms - for colName in self.channelLessColnames: - chColName = f"{chName}{colName}" - chEquation = chEquation.replace(colName, chColName) - chEquationName = f"{chName}_{newColName}" - equations[chEquationName] = chEquation - return equations, isMixedChannels - - def ok_cb(self): - if not self.newColNameLineEdit.text(): - self.warnEmptyEquationName() - return - - self.cancel = False - - # Save equation to "/acdc-metrics/combine_metrics.ini" file - config = measurements.read_saved_user_combine_config() - - equationsDict, isMixedChannels = self.getEquationsDict() - for newColName, equation in equationsDict.items(): - config = measurements.add_user_combine_metrics( - config, equation, newColName, isMixedChannels - ) - - isChannelLess = len(self.channelLessColnames) > 0 - if isChannelLess: - channelLess_equation = self.equationDisplay.toPlainText() - equation_name = self.newColNameLineEdit.text() - config = measurements.add_channelLess_combine_metrics( - config, channelLess_equation, equation_name, self.channelLessColnames - ) - - measurements.save_common_combine_metrics(config) - - self.sigOk.emit(self) - - if self.closeOnOk: - self.close() - - def warnEmptyEquationName(self): - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - "New measurement name" field cannot be empty! - """) - msg.critical(self, "Empty new measurement name", txt) - - def showHelp(self): - txt = measurements.get_combine_metrics_help_txt() - msg = widgets.myMessageBox( - showCentered=False, - wrapText=False, - scrollableText=True, - enlargeWidthFactor=1.7, - ) - path = measurements.acdc_metrics_path - msg.addShowInFileManagerButton(path, txt="Show saved file...") - msg.information(self, "Combine measurements help", txt) - - def test_cb(self): - # Evaluate equation with random inputs - equation = self.equationDisplay.toPlainText() - random_data = np.random.rand(1, len(self.equationColNames)) * 5 - df = pd.DataFrame(data=random_data, columns=self.equationColNames).round(5) - newColName = self.newColNameLineEdit.text() - try: - df[newColName] = df.eval(equation) - except Exception as e: - traceback.print_exc() - self.testOutputDisplay.setHtml(html_utils.paragraph(e)) - self.testOutputDisplay.setStyleSheet("border: 2px solid red") - return - - self.testOutputDisplay.setStyleSheet("border: 2px solid green") - self.okButton.setDisabled(False) - - result = df.round(5).iloc[0][newColName] - - # Substitute numbers into equation - inputs = df.iloc[0] - equation_numbers = equation - for c, col in enumerate(self.equationColNames): - equation_numbers = equation_numbers.replace(col, str(inputs[c])) - - # Format output into html text - cols = self.equationColNames - inputs_txt = [f"{col} = {input}" for col, input in zip(cols, inputs)] - list_html = html_utils.to_list(inputs_txt) - text = html_utils.paragraph(f""" - By substituting the following random inputs: - {list_html} - we get the equation:

    -   {newColName} = {equation_numbers}

    - that equals to:

    -   {newColName} = {result} - """) - self.testOutputDisplay.setHtml(text) - - -class stopFrameDialog(QBaseDialog): - def __init__(self, posDatas, parent=None): - super().__init__(parent=parent) - - self.cancel = True - - self.setWindowTitle("Stop frame") - - mainLayout = QVBoxLayout() - - infoTxt = html_utils.paragraph( - "Enter a stop frame number for each of the loaded Positions", - center=True, - ) - exp_path = posDatas[0].exp_path - exp_path = os.path.normpath(exp_path).split(os.sep) - exp_path = f"...{f'{os.sep}'.join(exp_path[-4:])}" - subInfoTxt = html_utils.paragraph( - f"Experiment folder: {exp_path}", font_size="12px", center=True - ) - infoLabel = QLabel(f"{infoTxt}{subInfoTxt}") - infoLabel.setToolTip(posDatas[0].exp_path) - mainLayout.addWidget(infoLabel) - mainLayout.addSpacing(20) - - self.posDatas = posDatas - for posData in posDatas: - _layout = QHBoxLayout() - _layout.addStretch(1) - _label = QLabel(html_utils.paragraph(f"{posData.pos_foldername}")) - _layout.addWidget(_label) - - _spinBox = QSpinBox() - _spinBox.setMaximum(214748364) - _spinBox.setAlignment(Qt.AlignCenter) - _spinBox.setFont(font) - if posData.acdc_df is not None: - _val = posData.acdc_df.index.get_level_values(0).max() + 1 - else: - _val = posData.readLastUsedStopFrameNumber() - if _val is None: - _val = posData.SizeT - _spinBox.setValue(_val) - - posData.stopFrameSpinbox = _spinBox - - _layout.addWidget(_spinBox) - - viewButton = widgets.viewPushButton("Visualize...") - viewButton.clicked.connect(partial(self.viewChannelData, posData, _spinBox)) - _layout.addWidget(viewButton, alignment=Qt.AlignRight) - - _layout.addStretch(1) - - mainLayout.addLayout(_layout) - - buttonsLayout = QHBoxLayout() - - okButton = widgets.okPushButton(" Ok ") - cancelButton = widgets.cancelPushButton(" Cancel ") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(okButton) - - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - - self.setLayout(mainLayout) - - def viewChannelData(self, posData, spinBox): - self.sender().setText("Loading...") - QTimer.singleShot( - 200, partial(self._viewChannelData, posData, spinBox, self.sender()) - ) - - def _viewChannelData(self, posData, spinBox, senderButton): - chNames = posData.chNames - if len(chNames) > 1: - ch_name_selector = prompts.select_channel_name( - which_channel="segm", allow_abort=False - ) - ch_name_selector.QtPrompt( - self, chNames, "Select channel name to visualize: " - ) - if ch_name_selector.was_aborted: - return - chName = ch_name_selector.channel_name - else: - chName = chNames[0] - - channel_file_path = load.get_filename_from_channel(posData.images_path, chName) - posData.frame_i = 0 - posData.loadImgData(imgPath=channel_file_path) - self.slideshowWin = imageViewer(posData=posData, spinBox=spinBox) - self.slideshowWin.update_img() - self.slideshowWin.show() - senderButton.setText("Visualize...") - - def ok_cb(self): - self.cancel = False - for posData in self.posDatas: - stopFrameNum = posData.stopFrameSpinbox.value() - posData.stopFrameNum = stopFrameNum - self.close() - - -class pgTestWindow(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - - layout = QVBoxLayout() - - self.graphLayout = pg.GraphicsLayoutWidget() - self.ax1 = pg.PlotItem() - self.ax1.setAspectLocked(True) - self.graphLayout.addItem(self.ax1) - - layout.addWidget(self.graphLayout) - - self.setLayout(layout) - - -class CombineMetricsMultiDfsDialog(QBaseDialog): - sigOk = Signal(object, object) - sigClose = Signal(bool) - - def __init__(self, acdcDfs, allChNames, parent=None, debug=False): - super().__init__(parent) - - self.setWindowTitle("Add combined measurement") - - self.initAttributes() - - self.acdcDfs = acdcDfs - self.cancel = True - self.isOperatorMode = False - - mainLayout = QVBoxLayout() - equationLayout = QHBoxLayout() - - treesLayout = QHBoxLayout() - for i, (acdc_df_endname, acdc_df) in enumerate(acdcDfs.items()): - metricsTreeWidget = QTreeWidget() - metricsTreeWidget.setHeaderHidden(True) - metricsTreeWidget.setFont(font) - - classified_metrics = measurements.classify_acdc_df_colnames( - acdc_df, allChNames - ) - - for chName in allChNames: - channelTreeItem = QTreeWidgetItem(metricsTreeWidget) - channelTreeItem.setText(0, f"{chName} measurements") - metricsTreeWidget.addTopLevelItem(channelTreeItem) - - standard_metrics = classified_metrics["foregr"][chName] - bkgr_metrics = classified_metrics["bkgr"][chName] - custom_metrics = classified_metrics["custom"][chName] - - if standard_metrics: - foregrMetricsTreeItem = QTreeWidgetItem(channelTreeItem) - foregrMetricsTreeItem.setText(0, "Cell signal measurements") - channelTreeItem.addChild(foregrMetricsTreeItem) - self.addTreeItems( - foregrMetricsTreeItem, standard_metrics, isCol=True, index=i - ) - - if bkgr_metrics: - bkgrMetricsTreeItem = QTreeWidgetItem(channelTreeItem) - bkgrMetricsTreeItem.setText(0, "Background values") - channelTreeItem.addChild(bkgrMetricsTreeItem) - self.addTreeItems( - bkgrMetricsTreeItem, bkgr_metrics, isCol=True, index=i - ) - - if custom_metrics: - customMetricsTreeItem = QTreeWidgetItem(channelTreeItem) - customMetricsTreeItem.setText(0, "Custom measurements") - channelTreeItem.addChild(customMetricsTreeItem) - self.addTreeItems( - customMetricsTreeItem, custom_metrics, isCol=True, index=i - ) - - if classified_metrics["size"]: - sizeMetricsTreeItem = QTreeWidgetItem(metricsTreeWidget) - sizeMetricsTreeItem.setText(0, "Size measurements") - metricsTreeWidget.addTopLevelItem(sizeMetricsTreeItem) - self.addTreeItems( - sizeMetricsTreeItem, classified_metrics["size"], isCol=True, index=i - ) - - if classified_metrics["props"]: - propMetricsTreeItem = QTreeWidgetItem(metricsTreeWidget) - propMetricsTreeItem.setText(0, "Region properties") - metricsTreeWidget.addTopLevelItem(propMetricsTreeItem) - self.addTreeItems( - propMetricsTreeItem, - classified_metrics["props"], - isCol=True, - index=i, - ) - - treeLayout = QVBoxLayout() - treeTitle = QLabel( - html_utils.paragraph( - f"{i + 1}. {acdc_df_endname} measurements " - ) - ) - treeLayout.addWidget(treeTitle) - treeLayout.addWidget(metricsTreeWidget) - treesLayout.addLayout(treeLayout) - - metricsTreeWidget.index = i - metricsTreeWidget.itemDoubleClicked.connect(self.addColname) - - operatorsLayout = QHBoxLayout() - operatorsLayout.addStretch(1) - - iconSize = 24 - - self.operatorButtons = [] - self.operators = [ - ("add", "+"), - ("subtract", "-"), - ("multiply", "*"), - ("divide", "/"), - ("open_bracket", "("), - ("close_bracket", ")"), - ("square", "**2"), - ("pow", "**"), - ("ln", "log("), - ("log10", "log10("), - ] - operatorFont = QFont() - operatorFont.setPixelSize(16) - for name, text in self.operators: - button = QPushButton() - button.setIcon(QIcon(f":{name}.svg")) - button.setIconSize(QSize(iconSize, iconSize)) - button.text = text - operatorsLayout.addWidget(button) - self.operatorButtons.append(button) - button.clicked.connect(self.addOperator) - # button.setFont(operatorFont) - - clearButton = QPushButton() - clearButton.setIcon(QIcon(":clear.svg")) - clearButton.setIconSize(QSize(iconSize, iconSize)) - clearButton.setFont(operatorFont) - - clearEntryButton = QPushButton() - clearEntryButton.setIcon(QIcon(":backspace.svg")) - clearEntryButton.setFont(operatorFont) - clearEntryButton.setIconSize(QSize(iconSize, iconSize)) - - operatorsLayout.addWidget(clearButton) - operatorsLayout.addWidget(clearEntryButton) - operatorsLayout.addStretch(1) - - newColNameLayout = QVBoxLayout() - newColNameLineEdit = widgets.alphaNumericLineEdit() - newColNameLineEdit.setAlignment(Qt.AlignCenter) - self.newColNameLineEdit = newColNameLineEdit - newColNameLayout.addStretch(1) - newColNameLayout.addWidget(QLabel("New measurement name:")) - newColNameLayout.addWidget(newColNameLineEdit) - newColNameLayout.addStretch(1) - - equationDisplayLayout = QVBoxLayout() - equationDisplayLayout.addWidget(QLabel("Equation:")) - equationDisplay = QPlainTextEdit() - # equationDisplay.setReadOnly(True) - self.equationDisplay = equationDisplay - equationDisplayLayout.addWidget(equationDisplay) - equationDisplayLayout.setStretch(0, 0) - equationDisplayLayout.setStretch(1, 1) - - equationLayout.addLayout(newColNameLayout) - equationLayout.addWidget(QLabel(" = ")) - equationLayout.addLayout(equationDisplayLayout) - equationLayout.setStretch(0, 1) - equationLayout.setStretch(1, 0) - equationLayout.setStretch(2, 2) - - instructions = html_utils.paragraph(""" - Double-click on any of the available measurements - to add it to the equation.

    - NOTE: the result will be saved in a new acdc_output - file as a column with the same name
    - you enter in "New measurement name" - field.

    - """) - - buttonsLayout = QHBoxLayout() - - cancelButton = widgets.cancelPushButton("Cancel") - testButton = widgets.calcPushButton("Test equation") - okButton = widgets.okPushButton(" Ok ") - okButton.setDisabled(True) - self.okButton = okButton - - if debug: - debugButton = QPushButton("Debug") - debugButton.clicked.connect(self._debug) - buttonsLayout.addWidget(debugButton) - - self.statusLabel = QLabel() - buttonsLayout.addWidget(self.statusLabel) - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(testButton) - buttonsLayout.addWidget(okButton) - - mainLayout.addWidget(QLabel(instructions)) - mainLayout.addLayout(treesLayout) - mainLayout.addLayout(operatorsLayout) - mainLayout.addLayout(equationLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - clearButton.clicked.connect(self.clearEquation) - clearEntryButton.clicked.connect(self.clearEntryEquation) - - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - testButton.clicked.connect(self.test_cb) - - self.equationDisplay.textChanged.connect(self.equationChanged) - # self.newColNameLineEdit.editingFinished.connect(self.equationChanged) - - self.setLayout(mainLayout) - self.setFont(font) - - self.setStyleSheet(TREEWIDGET_STYLESHEET) - - def setLogger(self, logger, logs_path, log_path): - self.logger = logger - self.logs_path = logs_path - self.log_path = log_path - - def closeEvent(self, event): - self.sigClose.emit(self.cancel) - return super().closeEvent(event) - - def getCombinedDf(self): - dfs = [] - for i, acdc_df in enumerate(self.acdcDfs.values()): - dfs.append(acdc_df.add_suffix(f"_table{i + 1}")) - return pd.concat(dfs, axis=1) - - def _log(self, txt): - if hasattr(self, "logger"): - self.logger.info(txt) - else: - print(f"[INFO]: {txt}") - - def equationChanged(self): - self.okButton.setDisabled(True) - self.statusLabel.setText("") - - @exception_handler - def test_cb(self): - combined_df = self.getCombinedDf() - new_df = pd.DataFrame(index=combined_df.index) - equation = self.equationDisplay.toPlainText() - newColName = self.newColNameLineEdit.text() - new_df[newColName] = combined_df.eval(equation) - self.okButton.setDisabled(False) - self._log("Equation test was successful.") - self.statusLabel.setText("Equation test was successful. You can now click OK.") - - def addOperator(self): - button = self.sender() - text = f"{self.equationDisplay.toPlainText()}{button.text}" - self.equationDisplay.setPlainText(text) - self.clearLenghts.append(len(button.text)) - - def clearEquation(self): - self.isOperatorMode = False - self.equationDisplay.setPlainText("") - self.initAttributes() - - def initAttributes(self): - self.clearLenghts = [] - self.equationColNames = [] - self.channelLessColnames = [] - - def clearEntryEquation(self): - if not self.clearLenghts: - return - - text = self.equationDisplay.toPlainText() - newText = text[: -self.clearLenghts[-1]] - clearedText = text[-self.clearLenghts[-1] :] - self.clearLenghts.pop(-1) - self.equationDisplay.setPlainText(newText) - if clearedText in self.equationColNames: - self.equationColNames.remove(clearedText) - if clearedText in self.channelLessColnames: - self.channelLessColnames.remove(clearedText) - - def addTreeItems( - self, parentItem, itemsText, isCol=False, isChannelLess=False, index=None - ): - for text in itemsText: - _item = QTreeWidgetItem(parentItem) - _item.setText(0, text) - parentItem.addChild(_item) - if isCol: - _item.isCol = True - if index is not None: - _item.index = index - _item.isChannelLess = isChannelLess - - def addColname(self, item, column): - if not hasattr(item, "isCol"): - return - - colName = f"{item.text(0)}_table{item.index + 1}" - text = f"{self.equationDisplay.toPlainText()}{colName}" - - self.equationDisplay.setPlainText(text) - self.clearLenghts.append(len(colName)) - self.equationColNames.append(colName) - if item.isChannelLess: - self.channelLessColnames.append(colName) - - def _debug(self): - print(self.getEquationsDict()) - - def ok_cb(self): - if not self.newColNameLineEdit.text(): - self.warnEmptyEquationName() - return - if not self.equationDisplay.toPlainText(): - self.warnEmptyEquation() - return - - self.expression = self.equationDisplay.toPlainText() - self.newColname = self.newColNameLineEdit.text() - self.cancel = False - self.sigOk.emit(self.newColname, self.expression) - self.close() - - def warnEmptyEquation(self): - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - "Equation" field cannot be empty! - """) - msg.critical(self, "Empty equation", txt) - - def warnEmptyEquationName(self): - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - "New measurement name" field cannot be empty! - """) - msg.critical(self, "Empty new measurement name", txt) - - -class CombineMetricsMultiDfsSummaryDialog(QBaseDialog): - sigLoadAdditionalAcdcDf = Signal() - - def __init__(self, acdcDfs, allChNames, parent=None, debug=False): - super().__init__(parent) - - self.editedIndex = None - self.cancel = True - self.acdcDfs = acdcDfs - self.allChNames = allChNames - - self.setWindowTitle("Combine measurements summary") - - mainLayout = QVBoxLayout() - viewLayout = QGridLayout() - buttonsLayout = QHBoxLayout() - - row = 0 - txt = html_utils.paragraph("Selected acdc_output tables:") - viewLayout.addWidget(QLabel(txt), row, 0) - - row += 1 - items = [ - f"• Table {i + 1}: {e}" - for i, e in enumerate(acdcDfs.keys()) - ] - selectedAcdcDfsList = widgets.readOnlyQList() - selectedAcdcDfsList.addItems(items) - self.selectedAcdcDfsList = selectedAcdcDfsList - - tablesButtonsLayout = QVBoxLayout() - loadAcdcDfButton = widgets.showInFileManagerButton("Load additional tables") - tablesButtonsLayout.addWidget(loadAcdcDfButton) - - loadEquationsButton = widgets.reloadPushButton("Load previously used equations") - tablesButtonsLayout.addWidget(loadEquationsButton) - - tablesButtonsLayout.addStretch(1) - - viewLayout.addWidget(selectedAcdcDfsList, row, 0) - viewLayout.addLayout(tablesButtonsLayout, row, 1) - viewLayout.setRowStretch(row, 1) - - row += 1 - txt = html_utils.paragraph("Equations:") - viewLayout.addWidget(QLabel(txt), row, 0) - - row += 1 - self.equationsList = widgets.TreeWidget() - self.equationsList.setFont(font) - self.equationsList.setHeaderLabels(["Metric", "Expression"]) - self.equationsList.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - - equationsButtonsLayout = QVBoxLayout() - addEquationButton = widgets.addPushButton("Add metric") - removeEquationButton = widgets.subtractPushButton("Remove metric(s)") - editEquationButton = widgets.editPushButton("Edit metric") - removeEquationButton.setDisabled(True) - editEquationButton.setDisabled(True) - self.removeEquationButton = removeEquationButton - self.editEquationButton = editEquationButton - - equationsButtonsLayout.addWidget(addEquationButton) - equationsButtonsLayout.addWidget(removeEquationButton) - equationsButtonsLayout.addWidget(editEquationButton) - equationsButtonsLayout.addStretch(1) - - viewLayout.addWidget(self.equationsList, row, 0) - viewLayout.addLayout(equationsButtonsLayout, row, 1) - viewLayout.setRowStretch(row, 2) - - cancelButton = widgets.cancelPushButton("Cancel") - okButton = widgets.okPushButton("Ok") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(okButton) - - viewLayout.setVerticalSpacing(10) - mainLayout.addLayout(viewLayout) - mainLayout.addSpacing(10) - mainLayout.addLayout(buttonsLayout) - - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - addEquationButton.clicked.connect(self.addEquation_cb) - loadAcdcDfButton.clicked.connect(self.loadButtonClicked) - loadEquationsButton.clicked.connect(self.loadEquationsButtonClicked) - removeEquationButton.clicked.connect(self.removeButtonClicked) - editEquationButton.clicked.connect(self.editButtonClicked) - self.equationsList.itemSelectionChanged.connect( - self.onEquationItemSelectionChanged - ) - - self.setLayout(mainLayout) - - def setLogger(self, logger, logs_path, log_path): - self.logger = logger - self.logs_path = logs_path - self.log_path = log_path - - def loadEquationsButtonClicked(self): - MostRecentPath = myutils.getMostRecentPath() - file_path = QFileDialog.getOpenFileName( - self, - "Select equations file", - MostRecentPath, - "Config Files (*.ini);;All Files (*)", - )[0] - if file_path == "": - return - - cp = config.ConfigParser() - cp.read(file_path) - sectionToMatch = [f"table{i + 1}:{end}" for i, end in enumerate(self.acdcDfs)] - sectionToMatch = ";".join(sectionToMatch) - - lists = {} - nonMatchingLists = {} - groupsDescr = {} - - for section in cp.sections(): - # Tag acdc_output names with html and table(\d+) with html bold tag - listName = ";".join( - [ - re.sub( - r"table(\d+):(.*)", r"table\g<1>: \g<2>", s - ) - for s in section.split(";") - ] - ) - listName = listName.replace(";", " ; ") - children = [f"{opt} = {cp[section][opt]}" for opt in cp[section]] - if section == sectionToMatch: - groupsDescr[listName] = ( - "Equations that were calculated from the same " - "table names you loaded" - ) - lists[listName] = children - else: - groupsDescr[listName] = ( - "Equations that were calculated from table names that " - "you did not load now" - ) - nonMatchingLists[listName] = children - # # Not implemented yet --> selecting from non matching table names - # # would require an additional widget where the user sets - # # what df1 and df2 are. - # trees[treeName] = children - - if not lists: - msg = widgets.myMessageBox(wrapText=False, showCentered=False) - txt = html_utils.paragraph(""" - None of the equations in the selected file used the same - table names that you loaded.

    - See below which table names and equations are present in the loaded file. - """) - with open(file_path) as iniFile: - detailedText = iniFile.read() - - msg.warning(self, "Not the same tables", txt, showDialog=False) - msg.setDetailedText(detailedText, visible=True) - msg.addShowInFileManagerButton(os.path.dirname(file_path)) - msg.exec_() - return - - selectWindow = MultiListSelector( - lists, - groupsDescr=groupsDescr, - title="Select equations to load", - infoTxt="Select equations you want to load", - ) - selectWindow.exec_() - if selectWindow.cancel or not selectWindow.selectedItems: - return - - for listName, equations in selectWindow.selectedItems.items(): - for equation in equations: - metricName, expression = equation.split(" = ") - self.addEquation(metricName, expression) - - def ok_cb(self): - self.cancel = False - self.equations = {} - for i in range(self.equationsList.topLevelItemCount()): - item = self.equationsList.topLevelItem(i) - self.equations[item.text(0)] = item.text(1) - - self.close() - - def loadButtonClicked(self): - self.sigLoadAdditionalAcdcDf.emit() - - def removeButtonClicked(self): - for item in self.equationsList.selectedItems(): - self.equationsList.invisibleRootItem().removeChild(item) - - def editButtonClicked(self): - self.editedItem = self.equationsList.selectedItems()[0] - self.editedIndex = self.equationsList.indexOfTopLevelItem(self.editedItem) - self.addEquation_cb() - - def onEquationItemSelectionChanged(self): - selectedItems = self.equationsList.selectedItems() - if len(selectedItems) == 1: - self.editEquationButton.setDisabled(False) - self.removeEquationButton.setDisabled(False) - elif len(selectedItems) > 1: - self.removeEquationButton.setDisabled(False) - self.editEquationButton.setDisabled(True) - else: - self.removeEquationButton.setDisabled(True) - self.editEquationButton.setDisabled(True) - - def addAcdcDfs(self, acdcDfsDict): - self.acdcDfs = {**self.acdcDfs, **acdcDfsDict} - items = [ - f"• Table {i + 1}: {e}" - for i, e in enumerate(self.acdcDfs.keys()) - ] - self.selectedAcdcDfsList = widgets.readOnlyQList() - self.selectedAcdcDfsList.addItems(items) - - def addEquation(self, newColname, expression): - if self.editedIndex is not None: - self.equationsList.invisibleRootItem().removeChild(self.editedItem) - bkgrColor = QColor(*BACKGROUND_RGBA[:3], 200) - item = widgets.TreeWidgetItem( - self.equationsList, columnColors=[None, bkgrColor] - ) - item.setText(0, newColname) - item.setText(1, expression) - if self.editedIndex is not None: - self.equationsList.insertTopLevelItem(self.editedIndex, item) - else: - self.equationsList.addTopLevelItem(item) - self.equationsList.resizeColumnToContents(0) - self.equationsList.resizeColumnToContents(1) - self.editedIndex = None - - def addEquation_cb(self): - self.addEquationWin = CombineMetricsMultiDfsDialog( - self.acdcDfs, self.allChNames, parent=self - ) - if hasattr(self, "logger"): - self.addEquationWin.setLogger(self.logger, self.logs_path, self.log_path) - if self.editedIndex is not None: - editedMetricName = self.editedItem.text(0) - self.addEquationWin.newColNameLineEdit.setText(editedMetricName) - editedExpression = self.editedItem.text(1) - self.addEquationWin.equationDisplay.setPlainText(editedExpression) - self.addEquationWin.show() - self.addEquationWin.sigOk.connect(self.addEquation) - self.addEquationWin.sigClose.connect(self.addEquationClosed) - - def addEquationClosed(self, cancelled): - if cancelled: - self.editedIndex = None - - def showEvent(self, event) -> None: - self.resize(int(self.width() * 2), self.height()) - - -class ShortcutEditorDialog(QBaseDialog): - def __init__( - self, - widgetsWithShortcut: dict, - delObjectKey="", - delObjectButton: Literal["Middle click", "Left click"] = "Middle click", - zoomOutKeyValue: int = None, - parent=None, - ): - self.cancel = True - super().__init__(parent) - - self.setWindowTitle("Customize keyboard shortcuts") - - mainLayout = QVBoxLayout() - - self.customShortcuts = {} - self.shortcutLineEdits = {} - - scrollArea = QScrollArea(self) - scrollArea.setWidgetResizable(True) - scrollAreaWidget = QWidget() - entriesLayout = QGridLayout() - - row = 0 - button = widgets.PushButton(self, flat=True) - button.setIcon(QIcon(":del_obj_click.svg")) - self.delObjShortcutLineEdit = widgets.ShortcutLineEdit( - allowModifiers=True, notAllowedModifier=Qt.AltModifier - ) - if delObjectKey is not None: - self.delObjShortcutLineEdit.setText(delObjectKey) - self.delObjButtonCombobox = QComboBox() - self.delObjButtonCombobox.addItems(["Middle click", "Left click"]) - self.delObjButtonCombobox.setCurrentText(delObjectButton) - entriesLayout.addWidget(button, row, 0) - entriesLayout.addWidget(QLabel("Delete object:"), row, 1) - entriesLayout.addWidget(self.delObjShortcutLineEdit, row, 2) - entriesLayout.addWidget( - self.delObjButtonCombobox, row, 3, alignment=Qt.AlignLeft - ) - - row += 1 - name = "Zoom out" - button = widgets.PushButton(self, flat=True) - label = QLabel("Zoom out:") - self.zoomShortcutLineEdit = widgets.ShortcutLineEdit() - if zoomOutKeyValue is not None: - zoomOutKeySequence = widgets.KeySequenceFromText(zoomOutKeyValue) - self.zoomShortcutLineEdit.setText(zoomOutKeySequence.toString()) - self.zoomShortcutLineEdit.key = zoomOutKeyValue - self.zoomShortcutLineEdit.textChanged.connect(self.checkDuplicateShortcuts) - entriesLayout.addWidget(button, row, 0) - entriesLayout.addWidget(label, row, 1) - entriesLayout.addWidget(self.zoomShortcutLineEdit, row, 2) - self.shortcutLineEdits[name] = self.zoomShortcutLineEdit - - row += 1 - for row, (name, widget) in enumerate(widgetsWithShortcut.items(), start=row): - button = widgets.PushButton(self, flat=True) - try: - button.setIcon(widget.icon()) - except: - pass - label = QLabel(f"{name}:") - shortcutLineEdit = widgets.ShortcutLineEdit() - if hasattr(widget, "keyPressShortcut"): - shortcutLineEdit.key = widget.keyPressShortcut - shortcut = widgets.KeySequenceFromText(widget.keyPressShortcut) - isShortcutKeyPress = True - else: - shortcut = widget.shortcut() - isShortcutKeyPress = False - shortcutLineEdit.setText(shortcut.toString()) - shortcutLineEdit.textChanged.connect(self.checkDuplicateShortcuts) - shortcutLineEdit.isShortcutKeyPress = isShortcutKeyPress - entriesLayout.addWidget(button, row, 0) - entriesLayout.addWidget(label, row, 1) - entriesLayout.addWidget(shortcutLineEdit, row, 2) - self.shortcutLineEdits[name] = shortcutLineEdit - - entriesLayout.setColumnStretch(0, 0) - entriesLayout.setColumnStretch(1, 0) - entriesLayout.setColumnStretch(2, 1) - entriesLayout.setColumnStretch(3, 0) - - scrollAreaWidget.setLayout(entriesLayout) - scrollArea.setWidget(scrollAreaWidget) - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addWidget(scrollArea) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setFont(font) - self.setLayout(mainLayout) - - def checkDuplicateShortcuts(self, text): - for name, shortcutLineEdit in self.shortcutLineEdits.items(): - if shortcutLineEdit == self.sender(): - continue - if shortcutLineEdit.text() != text: - continue - shortcutLineEdit.setText("") - - def warnInvalidKeySequenceDelObjWithLeftClick(self): - txt = html_utils.paragraph( - 'The selected key sequence to delete objects with "Left click" ' - "is invalid.

    " - 'Only "Middle click" can be used without pressing keys.

    ' - "Thank you for your patience!" - ) - msg = widgets.myMessageBox() - msg.warning(self, "Invalid key sequence to delete objects", txt) - - def ok_cb(self): - delObjButtonText = self.delObjButtonCombobox.currentText() - delObjKeySequence = self.delObjShortcutLineEdit.keySequence - if delObjButtonText == "Left click" and delObjKeySequence is None: - self.warnInvalidKeySequenceDelObjWithLeftClick() - return - - self.shortcutLineEdits.pop("Zoom out") - self.cancel = False - for name, shortcutLineEdit in self.shortcutLineEdits.items(): - text = shortcutLineEdit.text() - if shortcutLineEdit.isShortcutKeyPress: - self.customShortcuts[name] = (text, shortcutLineEdit.key) - else: - self.customShortcuts[name] = (text, shortcutLineEdit.keySequence) - - delObjQtButton = ( - Qt.MouseButton.LeftButton - if delObjButtonText == "Left click" - else Qt.MouseButton.MiddleButton - ) - self.delObjAction = delObjKeySequence, delObjQtButton - self.zoomOutKeyValue = self.zoomShortcutLineEdit.key - - self.close() - - def showEvent(self, event) -> None: - self.resize(int(self.width() * 1.2), self.height()) - self.move(self.x(), 100) - - -class SelectAcdcDfVersionToRestore(QBaseDialog): - def __init__(self, posData, parent=None): - super().__init__(parent=parent) - - self.cancel = True - - self.setWindowTitle("Select annotations table to restore") - - mainLayout = QVBoxLayout() - - acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) - instructionsLabel = html_utils.paragraph( - f"Select an older version of the {acdc_df_filename} " - "annotations table to load.

    " - "The datetime refers to the time you replaced the old version with " - "a newer one.

    " - ) - mainLayout.addWidget(QLabel(instructionsLabel)) - - self.savedListBox = None - if os.path.exists(posData.acdc_output_backup_zip_path): - zip_path = posData.acdc_output_backup_zip_path - self.savedArchivefilepath = zip_path - with zipfile.ZipFile(zip_path, mode="r") as zip: - csv_names = natsorted(zip.namelist(), reverse=True) - - keys = [csv_name[:-4] for csv_name in csv_names] - - self.savedKeys = keys - f = load.ISO_TIMESTAMP_FORMAT - timestamps = [datetime.datetime.strptime(key, f) for key in keys] - items = [date.strftime(r"%d %b %Y, %H:%M:%S") for date in timestamps] - mainLayout.addWidget(QLabel("Saved annotations:")) - self.savedListBox = widgets.listWidget() - self.savedListBox.addItems(items) - mainLayout.addWidget(self.savedListBox) - self.savedListBox.itemSelectionChanged.connect(self.onItemSelectionChanged) - - recovery_folderpath = posData.recoveryFolderpath() - unsaved_recovery_folderpath = os.path.join(recovery_folderpath, "never_saved") - self.neverSavedFolderpath = unsaved_recovery_folderpath - files = myutils.listdir(unsaved_recovery_folderpath) - csv_files = [file for file in files if file.endswith(".csv")] - self.neverSavedListBox = None - if csv_files: - csv_names = natsorted(csv_files, reverse=True) - keys = [csv_name[:-4] for csv_name in csv_names] - self.neverSavedKeys = keys - f = load.ISO_TIMESTAMP_FORMAT - timestamps = [datetime.datetime.strptime(key, f) for key in keys] - items = [date.strftime(r"%d %b %Y, %H:%M:%S") for date in timestamps] - mainLayout.addWidget(QLabel("Never saved annotations:")) - self.neverSavedListBox = widgets.listWidget() - self.neverSavedListBox.addItems(items) - mainLayout.addWidget(self.neverSavedListBox) - self.neverSavedListBox.itemSelectionChanged.connect( - self.onItemSelectionChanged - ) - - cancelOkLayout = widgets.CancelOkButtonsLayout() - - cancelOkLayout.okButton.clicked.connect(self.ok_cb) - cancelOkLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addSpacing(20) - mainLayout.addLayout(cancelOkLayout) - - self.setLayout(mainLayout) - - self.setFont(font) - - def ok_cb(self): - self.cancel = False - try: - for i in range(self.savedListBox.count()): - item = self.savedListBox.item(i) - if item.isSelected(): - self.selectedTimestamp = item.text() - self.selectedKey = self.savedKeys[i] - self.archiveFilePath = self.savedArchivefilepath - break - except Exception as e: - pass - - try: - for i in range(self.neverSavedListBox.count()): - item = self.neverSavedListBox.item(i) - if item.isSelected(): - self.selectedTimestamp = item.text() - self.selectedKey = self.neverSavedKeys[i] - self.archiveFilePath = self.neverSavedFolderpath - break - except Exception as e: - pass - self.close() - - def onItemSelectionChanged(self): - otherListBox = ( - self.savedListBox - if self.sender() == self.neverSavedListBox - else self.neverSavedListBox - ) - if otherListBox is None: - return - for i in range(otherListBox.count()): - item = otherListBox.item(i) - item.setSelected(False) - - -class ChangeUserProfileFolderPathDialog(QBaseDialog): - def __init__(self, posData, parent=None): - super().__init__(parent=parent) - - self.cancel = True - - self.setWindowTitle("Change user profile folder path") - - mainLayout = QVBoxLayout() - - acdc_folders = load.get_all_acdc_folders(user_profile_path) - acdc_folders_format = [f" - {folder}" for folder in acdc_folders] - acdc_folders_format = "
    ".join(acdc_folders_format) - - txt = f""" - Current user profile path:

    - {user_profile_path}

    - The user profile contains the following Cell-ACDC folders:

    - {acdc_folders_format}

    - After clicking "Ok" you will be asked to select the folder where - you want to migrate the user profile data. - """ - - txt = html_utils.paragraph(txt) - label = QLabel(txt) - - mainLayout.addWidget(label) - - buttonsLayout = widgets.CancelOkButtonsLayout() - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - mainLayout.addStretch() - - self.setLayout(mainLayout) - - def ok_cb(self): - self.cancel = False - self.close() - - -class SelectFeaturesRange: - def __init__( - self, posData, force_postprocess_2D=False, qparent=None, sigValueChanged=None - ) -> None: - self.posData = posData - self.qparent = qparent - self.force_postprocess_2D = force_postprocess_2D - self.sigValueChanged = sigValueChanged - - self.lowRangeWidgets = widgets.CheckableSpinBoxWidgets() - self.highRangeWidgets = widgets.CheckableSpinBoxWidgets() - - self.selectButton = widgets.FeatureSelectorButton("Click to select feature...") - self.selectButton.setSizeLongestText( - "Spotfit intens. metric, Foregr. integral gauss. peak" - ) - self.selectButton.clicked.connect(self.selectFeature) - self.selectButton.setCursor(Qt.PointingHandCursor) - - self.selectedFeatureGroups = {} - - self.widgets = [ - {"pos": (0, 0), "widget": self.lowRangeWidgets.checkbox}, - {"pos": (1, 0), "widget": self.lowRangeWidgets.spinbox}, - {"pos": (1, 1), "widget": widgets.LessThanPushButton(flat=True)}, - {"pos": (1, 2), "widget": self.selectButton}, - {"pos": (1, 3), "widget": widgets.LessThanPushButton(flat=True)}, - {"pos": (0, 4), "widget": self.highRangeWidgets.checkbox}, - {"pos": (1, 4), "widget": self.highRangeWidgets.spinbox}, - {"pos": (2, 0), "widget": widgets.VerticalSpacerEmptyWidget(height=10)}, - ] - self.columnsStretches = {0: 0, 1: 0, 2: 1, 3: 0, 4: 0} - - def setText(self, text): - self.selectButton.setText(text) - - def selectFeature(self): - loadedChNames = [self.posData.user_ch_name] - notLoadedChNames = [] - isZstack = self.posData.SizeZ > 1 and not self.force_postprocess_2D - isSegm3D = self.posData.isSegm3D and not self.force_postprocess_2D - self.selectFeatureDialog = SetMeasurementsDialog( - loadedChNames, - notLoadedChNames, - isZstack, - isSegm3D, - posData=self.posData, - parent=self.qparent, - isSingleSelection=True, - is_concat=True, - ) - # self.selectFeatureDialog.resizeVertical() - self.selectFeatureDialog.sigClosed.connect(self.setFeatureText) - self.selectFeatureDialog.show() - - def setFeatureText(self): - if self.selectFeatureDialog.cancel: - return - self.selectButton.setFlat(True) - selectedMetricName, selectedMetricGroup = ( - self.selectFeatureDialog.selectedMetricNameAndGroup() - ) - self.selectButton.setText(selectedMetricName) - self.featureGroup = selectedMetricGroup - - -class SelectFeaturesRangeDialog(QBaseDialog): - sigValueChanged = Signal(object) - - def __init__(self, posData=None, parent=None, force_postprocess_2D=False): - super().__init__(parent) - - self.force_postprocess_2D = force_postprocess_2D - - layout = QVBoxLayout() - self.setWindowTitle("Custom features for post-processing") - - self.groupbox = SelectFeaturesRangeGroupbox( - posData=posData, parent=parent, force_postprocess_2D=force_postprocess_2D - ) - - buttonsLayout = QHBoxLayout() - okPushButton = widgets.okPushButton(" Ok ") - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(okPushButton) - - okPushButton.clicked.connect(self.ok_cb) - - layout.addWidget(self.groupbox) - layout.addSpacing(10) - layout.addLayout(buttonsLayout) - - self.setLayout(layout) - - def ok_cb(self): - if self.groupbox.selectedFeaturesRange(): - self.sigValueChanged.emit(None) - self.hide() - - -class SelectFeaturesRangeGroupbox(QGroupBox): - def __init__(self, posData=None, parent=None, force_postprocess_2D=False): - super().__init__(parent) - - self.setTitle("Features and thresholds for filtering segmented objects") - # self.setCheckable(True) - - self.posData = posData - self.force_postprocess_2D = force_postprocess_2D - - self._layout = QGridLayout() - self._layout.setVerticalSpacing(0) - - firstSelector = SelectFeaturesRange( - posData, force_postprocess_2D=force_postprocess_2D - ) - self.addButton = widgets.addPushButton(" Add feature ") - self.addButton.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) - for col, widget in enumerate(firstSelector.widgets): - row, col = widget["pos"] - self._layout.addWidget(widget["widget"], row, col) - for col, stretch in firstSelector.columnsStretches.items(): - self._layout.setColumnStretch(col, stretch) - - lastCol = self._layout.columnCount() - self._layout.addWidget(self.addButton, 0, lastCol + 1, 2, 1) - self.lastCol = lastCol + 1 - self.selectors = [firstSelector] - - self.setLayout(self._layout) - - # self.setFont(font) - - self.addButton.clicked.connect(self.addFeatureField) - - def addFeatureField(self): - row = self._layout.rowCount() - selector = SelectFeaturesRange( - self.posData, force_postprocess_2D=self.force_postprocess_2D - ) - delButton = widgets.delPushButton("Remove feature") - delButton.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) - delButton.selector = selector - selector.delButton = delButton - for col, widget in enumerate(selector.widgets): - relRow, col = widget["pos"] - self._layout.addWidget(widget["widget"], relRow + row, col) - self._layout.addWidget(delButton, row, self.lastCol, 2, 1) - self.selectors.append(selector) - delButton.clicked.connect(self.removeFeatureField) - - def resetFields(self): - while len(self.selectors) > 1: - selector = self.selectors[-1] - selector.delButton.click() - firstSelector = self.selectors[0] - firstSelector.selectButton.setText("Click to select feature...") - firstSelector.lowRangeWidgets.checkbox.setChecked(False) - firstSelector.highRangeWidgets.checkbox.setChecked(False) - - def removeFeatureField(self): - delButton = self.sender() - for widget in delButton.selector.widgets: - self._layout.removeWidget(widget["widget"]) - self._layout.removeWidget(delButton) - self.selectors.remove(delButton.selector) - - def selectedFeaturesRange(self): - featuresRange = {} - for selector in self.selectors: - if selector.selectButton.text().find("Click") != -1: - continue - featuresRange[selector.selectButton.text()] = ( - selector.lowRangeWidgets.value(), - selector.highRangeWidgets.value(), - ) - return featuresRange - - def selectedFeaturesGroup(self): - featuresGroup = {} - for selector in self.selectors: - if selector.selectButton.text().find("Click") != -1: - continue - group = selector.featureGroup - featuresGroup[selector.selectButton.text()] = group - return featuresGroup - - def groupedFeatures(self): - featuresGroup = self.selectedFeaturesGroup() - groupedFeatures = {} - for feature, group in featuresGroup.items(): - group = featuresGroup[feature] - if isinstance(group, str): - key = group - if key not in groupedFeatures: - groupedFeatures[key] = [] - groupedFeatures[key].append(feature) - else: - key, channel = list(group.items())[0] - if key not in groupedFeatures: - groupedFeatures[key] = {} - if channel not in groupedFeatures[key]: - groupedFeatures[key][channel] = [] - groupedFeatures[key][channel].append(feature) - return groupedFeatures - - def setValue(self, value): - pass - - -def get_existing_directory(allow_images_path=True, **kwargs): - while True: - folder_path = qtpy.compat.getexistingdirectory(**kwargs) - if not folder_path: - return - - if allow_images_path: - return folder_path - - pos_folderpath = os.path.dirname(folder_path) - is_images_folder = ( - folder_path.endswith("Images") - and os.path.basename(pos_folderpath).startswith("Position_") - and os.path.isdir(folder_path) - ) - if not is_images_folder: - return folder_path - - txt = html_utils.paragraph( - "You cannot save to the Images folder " - "because it is reserved to files that start with the same " - "basename.

    Thank you for your patience!" - ) - msg = widgets.myMessageBox() - msg.warning(kwargs["parent"], "Cannot save here", txt) - - -class ScaleBarPropertiesDialog(QBaseDialog): - sigValueChanged = Signal(object) - - def __init__( - self, maxLength, maxThickness, PhysicalSizeX, parent=None, **properties - ): - super().__init__(parent=parent) - - self.cancel = True - self.setWindowTitle("Scale bar properties") - - self.PhysicalSizeX = PhysicalSizeX - - mainLayout = QVBoxLayout() - - formLayout = widgets.FormLayout() - formLayout.setVerticalSpacing(10) - formLayout.setHorizontalSpacing(50) - - row = 0 - unitCombobox = QComboBox() - unitFormWidget = widgets.formWidget(unitCombobox, labelTextLeft="Physical unit") - unitCombobox.addItems(["nm", "μm", "mm", "cm"]) - if properties.get("unit") is None: - unitCombobox.setCurrentIndex(1) - else: - unitCombobox.setCurrentText(properties.get("unit")) - formLayout.addFormWidget( - unitFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - self.unitCombobox = unitCombobox - - row += 1 - lengthDoubleSpinbox = widgets.DoubleSpinBox() - lengthDoubleSpinbox.setMaximum(maxLength) - lengthDoubleSpinbox.setMinimum(PhysicalSizeX) - lengthDoubleSpinbox.setDecimals(1) - if properties.get("length_unit") is not None: - lengthDoubleSpinbox.setValue(properties.get("length_unit")) - else: - deafultLength = np.ceil(PhysicalSizeX * 15) - lengthDoubleSpinbox.setValue(round(deafultLength)) - lengthFormWidget = widgets.formWidget( - lengthDoubleSpinbox, labelTextLeft="Length (μm)" - ) - self.lengthFormWidget = lengthFormWidget - self.lengthDoubleSpinbox = lengthDoubleSpinbox - formLayout.addFormWidget( - lengthFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - - row += 1 - thicknessSpinbox = widgets.DoubleSpinBox() - thicknessSpinbox.setMaximum(maxThickness) - thicknessSpinbox.setMinimum(1) - if properties.get("thickness") is not None: - thicknessSpinbox.setValue(properties.get("thickness")) - else: - thicknessSpinbox.setValue(round(4, 1)) - thicknessSpinbox.setDecimals(1) - thicknessFormWidget = widgets.formWidget( - thicknessSpinbox, labelTextLeft="Thickness (pixel)" - ) - formLayout.addFormWidget( - thicknessFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - self.thicknessSpinbox = thicknessSpinbox - - row += 1 - locCombobox = QComboBox() - locFormWidget = widgets.formWidget(locCombobox, labelTextLeft="Location") - locCombobox.addItems( - ["Bottom-right", "Bottom-left", "Top-left", "Top-right", "Custom"] - ) - loc = properties.get("loc") - if isinstance(loc, str): - locCombobox.setCurrentText(loc.capitalize()) - formLayout.addFormWidget( - locFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - self.locCombobox = locCombobox - - row += 1 - self.colorButton = widgets.myColorButton(color=(255, 255, 255)) - if properties.get("color") is not None: - self.colorButton.setColor(properties.get("color")) - colorFormWidget = widgets.formWidget( - self.colorButton, - labelTextLeft="Color", - widgetAlignment=Qt.AlignCenter, - stretchWidget=False, - ) - formLayout.addFormWidget( - colorFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - - row += 1 - displayTextToggle = widgets.Toggle() - if properties.get("is_text_visible") is not None: - displayTextToggle.setChecked(properties.get("is_text_visible")) - else: - displayTextToggle.setChecked(True) - displayTextFormWidget = widgets.formWidget( - displayTextToggle, - labelTextLeft="Display text", - widgetAlignment=Qt.AlignCenter, - stretchWidget=False, - ) - formLayout.addFormWidget( - displayTextFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - self.displayTextToggle = displayTextToggle - - row += 1 - fontSizeSpinbox = widgets.SpinBox() - if properties.get("font_size") is not None: - fontSizeSpinbox.setValue(int(properties.get("font_size"))) - else: - fontSizeSpinbox.setValue(12) - fontSizeFormWidget = widgets.formWidget( - fontSizeSpinbox, labelTextLeft="Font size (px)" - ) - self.fontSizeSpinbox = fontSizeSpinbox - formLayout.addFormWidget( - fontSizeFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - - row += 1 - decimalsSpinbox = widgets.SpinBox() - decimalsSpinbox.setMaximum(6) - decimalsSpinbox.setMinimum(0) - if properties.get("num_decimals") is not None: - decimalsSpinbox.setValue(properties.get("num_decimals")) - else: - decimalsSpinbox.setValue(0) - decimalsFormWidget = widgets.formWidget( - decimalsSpinbox, labelTextLeft="Number of decimals" - ) - formLayout.addFormWidget( - decimalsFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - self.decimalsSpinbox = decimalsSpinbox - - row += 1 - moveWithZoomToggle = widgets.Toggle() - moveWithZoomWidget = widgets.formWidget( - moveWithZoomToggle, - labelTextLeft="Move scale bar with zoom", - widgetAlignment=Qt.AlignCenter, - stretchWidget=False, - ) - formLayout.addFormWidget( - moveWithZoomWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - self.moveWithZoomToggle = moveWithZoomToggle - - mainLayout.addLayout(formLayout) - - buttonsLayout = widgets.CancelOkButtonsLayout() - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - mainLayout.addStretch() - - self.setLayout(mainLayout) - self.setFont(font) - - self.unitCombobox.currentTextChanged.connect(self.updateLengthUnit) - self.colorButton.clicked.disconnect() - self.colorButton.clicked.connect(self.selectColor) - - self.colorButton.sigColorChanging.connect(self.onValueChanged) - self.lengthDoubleSpinbox.valueChanged.connect(self.onValueChanged) - self.thicknessSpinbox.valueChanged.connect(self.onValueChanged) - self.locCombobox.currentTextChanged.connect(self.onValueChanged) - self.displayTextToggle.toggled.connect(self.onValueChanged) - self.fontSizeSpinbox.valueChanged.connect(self.onValueChanged) - self.decimalsSpinbox.valueChanged.connect(self.onValueChanged) - self.moveWithZoomToggle.toggled.connect(self.onValueChanged) - - def onValueChanged(self, *args, **kwargs): - self.sigValueChanged.emit(self.kwargs()) - - def selectColor(self): - color = self.colorButton.color() - self.colorButton.origColor = color - self.colorButton.colorDialog.setCurrentColor(color) - self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - self.colorButton.colorDialog.setParent(self) - self.colorButton.colorDialog.open() - w = self.width() - left = self.pos().x() - colorDialogTop = self.colorButton.colorDialog.pos().y() - self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) - - def updateLengthUnit(self, unit): - newText = re.sub(r"\(.*\)", f"({unit})", self.lengthFormWidget.labelLeft.text()) - self.lengthFormWidget.labelLeft.setText(newText) - self.onValueChanged(self) - - def kwargs(self): - unit = self.unitCombobox.currentText() - length_unit = self.lengthDoubleSpinbox.value() - length_um = _core.convert_length(length_unit, unit, "μm") - length_pixel = length_um / self.PhysicalSizeX - kwargs = { - "thickness": self.thicknessSpinbox.value(), - "length_pixel": length_pixel, - "length_unit": length_unit, - "is_text_visible": self.displayTextToggle.isChecked(), - "color": self.colorButton.color(), - "loc": self.locCombobox.currentText().lower(), - "font_size": self.fontSizeSpinbox.value(), - "unit": unit, - "num_decimals": self.decimalsSpinbox.value(), - "move_with_zoom": self.moveWithZoomToggle.isChecked(), - } - return kwargs - - def ok_cb(self): - self.cancel = False - self.close() - - -class SetColumnNamesDialog(QBaseDialog): - def __init__(self, columnNames, categories, optionalCategories=None, parent=None): - super().__init__(parent) - - if not optionalCategories: - optionalCategories = None - - self.cancel = True - - mainLayout = QVBoxLayout() - - mainLayout.addWidget( - QLabel( - html_utils.paragraph("Assign a column to the following categories:
    ") - ) - ) - - self.categoriesWidgets = {} - formLayout = QFormLayout() - for row, category in enumerate(categories): - combobox = widgets.ComboBox() - combobox.addItems(columnNames) - if optionalCategories is not None: - text = f"* {category}" - else: - text = category - formLayout.addRow(text, combobox) - self.categoriesWidgets[category] = combobox - - if optionalCategories is not None: - optionalItems = ["None", *columnNames] - for row, category in enumerate(optionalCategories): - combobox = widgets.ComboBox() - combobox.addItems(optionalItems) - formLayout.addRow(category, combobox) - self.categoriesWidgets[category] = combobox - - mainLayout.addLayout(formLayout) - if optionalCategories is not None: - mainLayout.addSpacing(10) - mainLayout.addWidget( - QLabel(html_utils.paragraph("* mandatory", font_size="11px")) - ) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - self.setFont(font) - - def _warnNonUniqueCategories(self, category_1, category_2): - txt = html_utils.paragraph(f""" - The following categories have the same column assigned to it.

    - Columns assigned to categories must be unique.

    - Categories with the same column: - {html_utils.to_list((category_1, category_2))} - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Non-unique columns", txt) - - def _checkUniqueNames(self): - self.textToCategoryMapper = {} - for category, combobox in self.categoriesWidgets.items(): - if combobox.text() == "None": - continue - - if combobox.text() not in self.textToCategoryMapper: - self.textToCategoryMapper[combobox.text()] = category - continue - - sameCategory = self.textToCategoryMapper[combobox.text()] - self._warnNonUniqueCategories(category, sameCategory) - return False - - return True - - def ok_cb(self): - proceed = self._checkUniqueNames() - if not proceed: - return - - self.selectedColumns = { - category: combobox.text() - for category, combobox in self.categoriesWidgets.items() - } - self.cancel = False - self.close() - - -class CombineFeaturesCalculator(QBaseDialog): - sigOk = Signal(object) - - def __init__( - self, - features_groups: dict, - group_name_to_col_mapper: dict = None, - title="Combine features calculator", - parent=None, - ): - super().__init__(parent) - - self.cancel = True - - self.setWindowTitle(title) - self.initAttributes() - - mainLayout = QVBoxLayout() - equationLayout = QHBoxLayout() - - metricsTreeWidget = QTreeWidget() - metricsTreeWidget.setHeaderHidden(True) - metricsTreeWidget.setFont(font) - self.metricsTreeWidget = metricsTreeWidget - - for groupName, features in features_groups.items(): - topLevelTreeWidgetItem = QTreeWidgetItem(metricsTreeWidget) - topLevelTreeWidgetItem.setText(0, groupName) - metricsTreeWidget.addTopLevelItem(topLevelTreeWidgetItem) - self.addTreeItems( - topLevelTreeWidgetItem, - features, - isCol=True, - name_to_col_mapper=group_name_to_col_mapper.get(groupName), - ) - - operatorsLayout = self.createOperatorsLayout() - newFeatureNameLayout = self.createNewFeatureNameLayout() - equationDisplayLayout = self.createEquationDisplayLayout() - - equationLayout.addLayout(newFeatureNameLayout) - equationLayout.addWidget(QLabel(" = ")) - equationLayout.addLayout(equationDisplayLayout) - equationLayout.setStretch(0, 1) - equationLayout.setStretch(1, 0) - equationLayout.setStretch(2, 2) - - testOutputLayout = self.createTestOutputLayout() - buttonsLayout = self.createButtonsOutputLayout() - - instructions = html_utils.paragraph(""" - Double-click on any of the available measurements - to add it to the equation.

    - Before clicking the `Ok` button, check that the equation returns - the expected result by clicking the `Test output` button. - """) - - mainLayout.addWidget(QLabel(instructions)) - mainLayout.addWidget(QLabel("Available measurements:")) - mainLayout.addWidget(metricsTreeWidget) - mainLayout.addLayout(operatorsLayout) - mainLayout.addLayout(equationLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - mainLayout.addLayout(testOutputLayout) - - metricsTreeWidget.itemDoubleClicked.connect(self.addFeatureName) - self.setLayout(mainLayout) - self.setFont(font) - - self.setStyleSheet(TREEWIDGET_STYLESHEET) - - def setExpandedAll(self, expanded): - if expanded: - self.expandAll() - else: - for i in range(self.metricsTreeWidget.topLevelItemCount()): - topLevelItem = self.metricsTreeWidget.topLevelItem(i) - topLevelItem.setExpanded(False) - - def expandAll(self): - for i in range(self.metricsTreeWidget.topLevelItemCount()): - topLevelItem = self.metricsTreeWidget.topLevelItem(i) - topLevelItem.setExpanded(True) - - def addTreeItems(self, parentItem, itemsText, isCol=False, name_to_col_mapper=None): - for text in itemsText: - _item = QTreeWidgetItem(parentItem) - _item.setText(0, text) - parentItem.addChild(_item) - if isCol: - _item.isCol = True - _item.variable_name = text - if name_to_col_mapper is None: - continue - - col_name = name_to_col_mapper.get(text, None) - if col_name is None: - continue - - _item.variable_name = col_name - - def addFeatureName(self, item, column): - if not hasattr(item, "isCol"): - return - - colName = item.variable_name - text = f"{self.equationDisplay.toPlainText()}{colName}" - self.equationDisplay.setPlainText(text) - self.clearLenghts.append(len(colName)) - self.equationColNames.append(colName) - - def clearEquation(self): - self.isOperatorMode = False - self.equationDisplay.setPlainText("") - self.initAttributes() - - def createButtonsOutputLayout(self): - buttonsLayout = QHBoxLayout() - - cancelButton = widgets.cancelPushButton("Cancel") - helpButton = widgets.infoPushButton(" Help...") - testButton = widgets.calcPushButton("Test output") - okButton = widgets.okPushButton(" Ok ") - okButton.setDisabled(True) - self.okButton = okButton - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(cancelButton) - buttonsLayout.addSpacing(20) - buttonsLayout.addWidget(helpButton) - buttonsLayout.addWidget(testButton) - buttonsLayout.addWidget(okButton) - - helpButton.clicked.connect(self.showHelp) - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.close) - testButton.clicked.connect(self.test_cb) - - return buttonsLayout - - def ok_cb(self): - if not self.newFeatureNameLineEdit.text(): - self.warnEmptyEquationName() - return - - self.equation = self.equationDisplay.toPlainText() - self.newFeatureName = self.newFeatureNameLineEdit.text() - self.cancel = False - self.close() - self.sigOk.emit(self) - - def test_cb(self): - # Evaluate equation with random inputs - equation = self.equationDisplay.toPlainText() - random_data = np.random.rand(1, len(self.equationColNames)) * 5 - df = pd.DataFrame(data=random_data, columns=self.equationColNames).round(5) - newColName = self.newFeatureNameLineEdit.text() - try: - df[newColName] = df.eval(equation) - except Exception as e: - traceback.print_exc() - self.testOutputDisplay.setHtml(html_utils.paragraph(e)) - self.testOutputDisplay.setStyleSheet("border: 2px solid red") - return - - self.testOutputDisplay.setStyleSheet("border: 2px solid green") - self.okButton.setDisabled(False) - - result = df.round(5).iloc[0][newColName] - - # Substitute numbers into equation - inputs = df.iloc[0] - equation_numbers = equation - for c, col in enumerate(self.equationColNames): - equation_numbers = equation_numbers.replace(col, str(inputs[c])) - - # Format output into html text - cols = self.equationColNames - inputs_txt = [f"{col} = {input}" for col, input in zip(cols, inputs)] - list_html = html_utils.to_list(inputs_txt) - text = html_utils.paragraph(f""" - By substituting the following random inputs: - {list_html} - we get the equation:

    -   {newColName} = {equation_numbers}

    - that equals to:

    -   {newColName} = {result} - """) - self.testOutputDisplay.setHtml(text) - - def warnEmptyEquationName(self): - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - "New measurement name" field cannot be empty! - """) - msg.critical(self, "Empty new measurement name", txt) - - def showHelp(self): - pass - - def createTestOutputLayout(self): - testOutputLayout = QVBoxLayout() - testOutputLayout.addWidget(QLabel("Result of test with random inputs:")) - testOutputDisplay = QTextEdit() - testOutputDisplay.setReadOnly(True) - self.testOutputDisplay = testOutputDisplay - testOutputLayout.addWidget(testOutputDisplay) - testOutputLayout.setStretch(0, 0) - testOutputLayout.setStretch(1, 1) - - return testOutputLayout - - def createEquationDisplayLayout(self): - equationDisplayLayout = QVBoxLayout() - equationDisplayLayout.addWidget(QLabel("Equation:")) - equationDisplay = QPlainTextEdit() - # equationDisplay.setReadOnly(True) - self.equationDisplay = equationDisplay - equationDisplayLayout.addWidget(equationDisplay) - equationDisplayLayout.setStretch(0, 0) - equationDisplayLayout.setStretch(1, 1) - return equationDisplayLayout - - def createNewFeatureNameLayout(self): - newFeatureNameLayout = QVBoxLayout() - newFeatureNameLineEdit = widgets.alphaNumericLineEdit() - newFeatureNameLineEdit.setAlignment(Qt.AlignCenter) - self.newFeatureNameLineEdit = newFeatureNameLineEdit - newFeatureNameLayout.addStretch(1) - newFeatureNameLayout.addWidget(QLabel("New measurement name:")) - newFeatureNameLayout.addWidget(newFeatureNameLineEdit) - newFeatureNameLayout.addStretch(1) - return newFeatureNameLayout - - def createOperatorsLayout(self): - operatorsLayout = QHBoxLayout() - operatorsLayout.addStretch(1) - - iconSize = 24 - - self.operatorButtons = [] - self.operators = [ - ("add", "+"), - ("subtract", "-"), - ("multiply", "*"), - ("divide", "/"), - ("open_bracket", "("), - ("close_bracket", ")"), - ("square", "**2"), - ("pow", "**"), - ("ln", "log("), - ("log10", "log10("), - ] - operatorFont = QFont() - operatorFont.setPixelSize(16) - for name, text in self.operators: - button = QPushButton() - button.setIcon(QIcon(f":{name}.svg")) - button.setIconSize(QSize(iconSize, iconSize)) - button.text = text - operatorsLayout.addWidget(button) - self.operatorButtons.append(button) - button.clicked.connect(self.addOperator) - # button.setFont(operatorFont) - - clearButton = QPushButton() - clearButton.setIcon(QIcon(":clear.svg")) - clearButton.setIconSize(QSize(iconSize, iconSize)) - clearButton.setFont(operatorFont) - - clearEntryButton = QPushButton() - clearEntryButton.setIcon(QIcon(":backspace.svg")) - clearEntryButton.setFont(operatorFont) - clearEntryButton.setIconSize(QSize(iconSize, iconSize)) - - operatorsLayout.addWidget(clearButton) - operatorsLayout.addWidget(clearEntryButton) - operatorsLayout.addStretch(1) - - clearButton.clicked.connect(self.clearEquation) - clearEntryButton.clicked.connect(self.clearEntryEquation) - - return operatorsLayout - - def addOperator(self): - button = self.sender() - text = f"{self.equationDisplay.toPlainText()}{button.text}" - self.equationDisplay.setPlainText(text) - self.clearLenghts.append(len(button.text)) - - def clearEquation(self): - self.isOperatorMode = False - self.equationDisplay.setPlainText("") - self.initAttributes() - - def initAttributes(self): - self.clearLenghts = [] - self.equationColNames = [] - self.channelLessColnames = [] - - def clearEntryEquation(self): - if not self.clearLenghts: - return - - text = self.equationDisplay.toPlainText() - newText = text[: -self.clearLenghts[-1]] - clearedText = text[-self.clearLenghts[-1] :] - self.clearLenghts.pop(-1) - self.equationDisplay.setPlainText(newText) - if clearedText in self.equationColNames: - self.equationColNames.remove(clearedText) - if clearedText in self.channelLessColnames: - self.channelLessColnames.remove(clearedText) - - -class QInput(QBaseDialog): - def __init__(self, parent=None, title="Input"): - self.cancel = True - self.allowEmpty = True - - super().__init__(parent) - - self.setWindowTitle(title) - - self.mainLayout = QVBoxLayout() - - self.infoLabel = QLabel() - self.mainLayout.addWidget(self.infoLabel) - - promptLayout = QHBoxLayout() - self.promptLabel = QLabel() - promptLayout.addWidget(self.promptLabel) - self.lineEdit = QLineEdit() - promptLayout.addWidget(self.lineEdit) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - self.mainLayout.addLayout(promptLayout) - self.mainLayout.addSpacing(20) - self.mainLayout.addLayout(buttonsLayout) - - self.buttonsLayout = buttonsLayout - - self.setFont(font) - self.setLayout(self.mainLayout) - - def askText(self, prompt, infoText="", allowEmpty=False): - self.allowEmpty = allowEmpty - if infoText: - infoText = f"{infoText}
    " - self.infoLabel.setText(html_utils.paragraph(infoText)) - self.promptLabel.setText(prompt) - self.exec_(resizeWidthFactor=1.5) - - def ok_cb(self): - self.answer = self.lineEdit.text() - if not self.allowEmpty and not self.answer: - msg = widgets.myMessageBox(showCentered=False) - msg.critical(self, "Empty", "Entry cannot be empty.") - return - self.cancel = False - self.close() - - -class InstallPyTorchDialog(QBaseDialog): - def __init__(self, parent=None, caller_name="Cell-ACDC"): - super().__init__(parent=parent) - - self.cancel = True - - mainLayout = QVBoxLayout() - - innerLayout = QGridLayout() - - iconLabel = QLabel(self) - standardIcon = getattr(QStyle, "SP_MessageBoxInformation") - icon = self.style().standardIcon(standardIcon) - pixmap = icon.pixmap(60, 60) - iconLabel.setPixmap(pixmap) - innerLayout.addWidget(iconLabel, 0, 0, alignment=Qt.AlignTop) - - href = html_utils.href_tag("How to install PyTorch", urls.install_pytorch) - important = html_utils.to_admonition( - """ - Should you choose to install PyTorch yourself, make sure to - activate
    - the correct acdc environment first
    . - """, - admonition_type="important", - ) - - infoText = html_utils.paragraph(f""" - {caller_name} needs to install the package PyTorch.

    - Select your preferences and click ok to install it now. - You will have to confirm the installation in the terminal.

    - Alternatively, you can close {caller_name} and run the command - yourself.

    - For more details see this guide: {href}
    - {important} - """) - innerLayout.addWidget(QLabel(infoText), 0, 1) - innerLayout.addItem(QSpacerItem(10, 10), 1, 1) - - preferencesLayout = QGridLayout() - - row = 0 - self.osCombobox = QComboBox() - self.osCombobox.addItems(["Linux", "Mac", "Windows"]) - preferencesLayout.addWidget(QLabel("Your OS"), row, 0) - preferencesLayout.addWidget(self.osCombobox, row, 1) - - if is_mac: - self.osCombobox.setCurrentText("Mac") - elif is_win: - self.osCombobox.setCurrentText("Windows") - - row += 1 - self.pkgManagerCombobox = QComboBox() - self.pkgManagerCombobox.addItems(["Pip"]) - if not is_conda_env(): - self.pkgManagerCombobox.setCurrentText("Pip") - self.pkgManagerCombobox.setDisabled(True) - - preferencesLayout.addWidget(QLabel("Package manager"), row, 0) - preferencesLayout.addWidget(self.pkgManagerCombobox, row, 1) - - row += 1 - self.cmptPlatformCombobox = QComboBox() - self.cmptPlatformCombobox.addItems( - ["CPU", "CUDA 11.8 (NVIDIA GPU)", "CUDA 12.1 (NVIDIA GPU)"] - ) - - preferencesLayout.addWidget(QLabel("Compute Platform"), row, 0) - preferencesLayout.addWidget(self.cmptPlatformCombobox, row, 1) - - row += 1 - pip_prefix, conda_prefix = myutils.get_pip_conda_prefix() - self.commandWidget = widgets.CopiableCommandWidget( - command=f"{pip_prefix} torch" - ) - preferencesLayout.addWidget(QLabel("Run this command: "), row, 0) - preferencesLayout.addWidget(self.commandWidget, row, 1, 1, 2) - preferencesLayout.setColumnStretch(0, 0) - preferencesLayout.setColumnStretch(1, 0) - preferencesLayout.setColumnStretch(2, 1) - - innerLayout.addLayout(preferencesLayout, 2, 1) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addLayout(innerLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - self.osCombobox.currentTextChanged.connect(self.updateCommand) - self.pkgManagerCombobox.currentTextChanged.connect(self.updateCommand) - self.cmptPlatformCombobox.currentTextChanged.connect(self.updateCommand) - - self.updateCommand() - - def updateCommand(self, *args, **kwargs): - osText = self.osCombobox.currentText() - pkgManager = self.pkgManagerCombobox.currentText() - cmptPlatform = self.cmptPlatformCombobox.currentText() - command = myutils.get_pytorch_command()[osText][pkgManager][cmptPlatform] - self.commandWidget.setCommand(command) - - def ok_cb(self): - self.command = self.commandWidget.command() - self.cancel = False - self.close() - - -class ExportToVideoParametersDialog(QBaseDialog): - sigOk = Signal(dict) - sigAddScaleBar = Signal(bool) - sigAddTimestamp = Signal(bool) - sigRescaleIntensLut = Signal(str, str) - sigChangeStartTime = Signal(str) - - def __init__( - self, - channels, - parent=None, - startFolderpath="", - startFilename="", - startFrameNum=1, - SizeT=1, - SizeZ=1, - isTimelapseVideo=True, - isScaleBarPresent=False, - isTimestampPresent=False, - rescaleIntensChannelHowMapper=None, - startTime=None, - ): - self.cancel = True - - if rescaleIntensChannelHowMapper is None: - rescaleIntensChannelHowMapper = {} - - super().__init__(parent=parent) - - self.setWindowTitle("Preferences for output video") - - mainLayout = QVBoxLayout() - - gridLayout = QGridLayout() - - navVar = "frame number" if isTimelapseVideo else "z-slice" - maxNavVar = SizeT if isTimelapseVideo else SizeZ - - self.isTimelapseVideo = isTimelapseVideo - - row = 0 - gridLayout.addWidget(QLabel(f"Start {navVar}:"), row, 0) - self.startNavVarNumberEntry = widgets.SpinBox() - self.startNavVarNumberEntry.setMinimum(1) - self.startNavVarNumberEntry.setMaximum(maxNavVar - 1) - self.startNavVarNumberEntry.setValue(startFrameNum) - gridLayout.addWidget(self.startNavVarNumberEntry, row, 1) - - row += 1 - gridLayout.addWidget(QLabel(f"Stop {navVar}:"), row, 0) - self.stopNavVarNumberEntry = widgets.SpinBox() - self.stopNavVarNumberEntry.setMinimum(2) - self.stopNavVarNumberEntry.setMaximum(maxNavVar) - self.stopNavVarNumberEntry.setValue(maxNavVar) - gridLayout.addWidget(self.stopNavVarNumberEntry, row, 1) - - row += 1 - gridLayout.addWidget(QLabel("File format:"), row, 0) - self.fileFormatCombobox = QComboBox() - self.fileFormatCombobox.addItems(["MP4", "AVI"]) - gridLayout.addWidget(self.fileFormatCombobox, row, 1) - - row += 1 - gridLayout.addWidget(QLabel("Frame rate (FPS):"), row, 0) - self.fpsWidget = widgets.FloatLineEdit(allowNegative=False) - self.fpsWidget.setValue(10.0) - gridLayout.addWidget(self.fpsWidget, row, 1) - - row += 1 - self.dpiWidget = widgets.IntLineEdit(allowNegative=False) - self.dpiWidget.setValue(300) - self.dpiWidget.label = QLabel("DPI") - gridLayout.addWidget(self.dpiWidget.label, row, 0) - gridLayout.addWidget(self.dpiWidget, row, 1) - - row += 1 - gridLayout.addWidget(QLabel("Folder path:"), row, 0) - self.folderPathLineEdit = widgets.ElidingLineEdit(minWidth=240) - self.folderPathLineEdit.setText(startFolderpath) - gridLayout.addWidget(self.folderPathLineEdit, row, 1) - self.browseButton = widgets.browseFileButton( - start_dir=startFolderpath, openFolder=True - ) - gridLayout.addWidget(self.browseButton, row, 2) - - row += 1 - gridLayout.addWidget(QLabel("Filename:"), row, 0) - self.filenameLineEdit = widgets.alphaNumericLineEdit() - self.filenameLineEdit.setAlignment(Qt.AlignCenter) - self.filenameLineEdit.setText(startFilename) - gridLayout.addWidget(self.filenameLineEdit, row, 1) - self.fileFormatLabel = QLabel(".mp4") - gridLayout.addWidget(self.fileFormatLabel, row, 2) - - row += 1 - gridLayout.addWidget(QLabel("Add Scale Bar:"), row, 0) - self.addScaleBarToggle = widgets.Toggle() - gridLayout.addWidget(self.addScaleBarToggle, row, 1, alignment=Qt.AlignCenter) - self.addScaleBarToggle.setChecked(isScaleBarPresent) - - if isTimelapseVideo: - row += 1 - gridLayout.addWidget(QLabel("Add timestamp:"), row, 0) - self.addTimestampToggle = widgets.Toggle() - gridLayout.addWidget( - self.addTimestampToggle, row, 1, alignment=Qt.AlignCenter - ) - self.addTimestampToggle.setChecked(isTimestampPresent) - - for channel in channels: - row += 1 - labelText = f"Rescale intensities (LUT) {channel}:" - gridLayout.addWidget(QLabel(labelText), row, 0) - rescaleItems = ["Rescale each 2D image"] - if SizeZ > 1: - rescaleItems.append("Rescale across z-stack") - if isTimelapseVideo: - rescaleItems.append("Rescale across time frames") - rescaleItems.append("Choose custom levels...") - rescaleItems.append("Do no rescale, display raw image") - rescaleIntensCombobox = QComboBox() - rescaleIntensCombobox.addItems(rescaleItems) - rescaleIntensHow = rescaleIntensChannelHowMapper.get(channel) - if rescaleIntensHow is not None: - rescaleIntensCombobox.setCurrentText(rescaleIntensHow) - gridLayout.addWidget(rescaleIntensCombobox, row, 1) - rescaleIntensCombobox.textActivated.connect( - partial(self.emitRescaleIntens, channel=channel) - ) - - row += 1 - gridLayout.addWidget(QLabel("Save a PNG for each frame:"), row, 0) - self.saveFramesToggle = widgets.Toggle() - gridLayout.addWidget(self.saveFramesToggle, row, 1, alignment=Qt.AlignCenter) - - gridLayout.setColumnStretch(0, 0) - gridLayout.setColumnStretch(1, 1) - gridLayout.setColumnStretch(2, 0) - - self.fileFormatCombobox.currentTextChanged.connect(self.updateFileFormat) - self.browseButton.sigPathSelected.connect(self.updateFolderPath) - self.addScaleBarToggle.toggled.connect(self.addScaleBarToggled) - if isTimelapseVideo: - self.addTimestampToggle.toggled.connect(self.addTimestampToggled) - - buttonsLayout = widgets.CancelOkButtonsLayout() - buttonsLayout.okButton.setText("Export") - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addLayout(gridLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def emitRescaleIntens(self, how, channel=""): - self.sigRescaleIntensLut.emit(how, channel) - - def addScaleBarToggled(self, checked): - self.sigAddScaleBar.emit(checked) - - def addTimestampToggled(self, checked): - self.sigAddTimestamp.emit(checked) - - def updateFolderPath(self, folderPath): - self.folderPathLineEdit.setText(folderPath) - self.browseButton.setStartPath(folderPath) - - def updateFileFormat(self, fileFormat): - self.fileFormatLabel.setText(f".{fileFormat.lower()}") - - def validateFolderPath(self): - folderPath = self.folderPathLineEdit.text() - if os.path.exists(folderPath) and os.path.isdir(folderPath): - return True - - text = html_utils.paragraph( - "The selected folder path is not a valid folder or does not exist" - ) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Not a valid folder", text) - return False - - def validateFilename(self): - filename = self.filenameLineEdit.text() - if filename: - return True - - text = html_utils.paragraph("The filename cannot be empty!") - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Not a valid folder", text) - return False - - def validate(self): - proceed = self.validateFolderPath() - if not proceed: - return False - - proceed = self.validateFilename() - if not proceed: - return False - - return True - - def preferences(self, makedirs=True): - filename = f"{self.filenameLineEdit.text()}{self.fileFormatLabel.text()}" - avi_filename = f"{self.filenameLineEdit.text()}.avi" - avi_filepath = os.path.join(self.folderPathLineEdit.text(), avi_filename) - png_foldername = f"{self.filenameLineEdit.text()}_frames_PNG" - pngs_folderpath = os.path.join(self.folderPathLineEdit.text(), png_foldername) - if makedirs: - os.makedirs(pngs_folderpath, exist_ok=True) - - preferences = { - "start_nav_var_num": self.startNavVarNumberEntry.value(), - "stop_nav_var_num": self.stopNavVarNumberEntry.value(), - "filepath": os.path.join(self.folderPathLineEdit.text(), filename), - "filename": self.filenameLineEdit.text(), - "avi_filepath": avi_filepath, - "pngs_folderpath": pngs_folderpath, - "num_digits": len(str(self.stopNavVarNumberEntry.value())), - "fps": self.fpsWidget.value(), - "save_pngs": self.saveFramesToggle.isChecked(), - "is_timelapse": self.isTimelapseVideo, - "dpi": self.dpiWidget.value(), - } - return preferences - - def ok_cb(self): - proceed = self.validate() - if not proceed: - return - self.cancel = False - self.sigOk.emit(self.preferences()) - self.selected_preferences = self.preferences() - self.close() - - -class TimestampPropertiesDialog(QBaseDialog): - sigValueChanged = Signal(object) - - def __init__(self, parent=None, **properties): - super().__init__(parent=parent) - - self.cancel = True - self.setWindowTitle("Timestamp preferences") - - mainLayout = QVBoxLayout() - - formLayout = widgets.FormLayout() - formLayout.setVerticalSpacing(10) - formLayout.setHorizontalSpacing(50) - - row = 0 - self.startTimeWidget = widgets.TimeWidget() - if properties.get("start_timedelta") is not None: - self.startTimeWidget.setValuesFromTimedelta( - properties.get("start_timedelta") - ) - startTimeFormWidget = widgets.formWidget( - self.startTimeWidget, - labelTextLeft="Start time", - ) - formLayout.addFormWidget( - startTimeFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - - row += 1 - self.colorButton = widgets.myColorButton(color=(255, 255, 255)) - if properties.get("color") is not None: - self.colorButton.setColor(properties.get("color")) - colorFormWidget = widgets.formWidget( - self.colorButton, - labelTextLeft="Color", - widgetAlignment=Qt.AlignCenter, - stretchWidget=False, - ) - formLayout.addFormWidget( - colorFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - - row += 1 - fontSizeWidget = widgets.FontSizeWidget() - if properties.get("font_size") is not None: - fontSizeWidget.setValue(properties.get("font_size")) - else: - fontSizeWidget.setValue(12) - fontSizeFormWidget = widgets.formWidget( - fontSizeWidget, labelTextLeft="Font size (px)" - ) - self.fontSizeWidget = fontSizeWidget - formLayout.addFormWidget( - fontSizeFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - - row += 1 - locCombobox = QComboBox() - locFormWidget = widgets.formWidget(locCombobox, labelTextLeft="Location") - locCombobox.addItems( - ["Top-left", "Top-right", "Bottom-left", "Bottom-right", "Custom"] - ) - loc = properties.get("loc") - if isinstance(loc, str): - locCombobox.setCurrentText(loc.capitalize()) - formLayout.addFormWidget( - locFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - self.locCombobox = locCombobox - - row += 1 - moveWithZoomToggle = widgets.Toggle() - moveWithZoomWidget = widgets.formWidget( - moveWithZoomToggle, - labelTextLeft="Move timestamp with zoom", - widgetAlignment=Qt.AlignCenter, - stretchWidget=False, - ) - formLayout.addFormWidget( - moveWithZoomWidget, row=row, leftLabelAlignment=Qt.AlignLeft - ) - self.moveWithZoomToggle = moveWithZoomToggle - - mainLayout.addLayout(formLayout) - - buttonsLayout = widgets.CancelOkButtonsLayout() - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - mainLayout.addStretch() - - self.setLayout(mainLayout) - self.setFont(font) - - self.colorButton.clicked.disconnect() - self.colorButton.clicked.connect(self.selectColor) - - self.startTimeWidget.sigValueChanged.connect(self.onValueChanged) - - self.locCombobox.currentTextChanged.connect(self.onValueChanged) - self.fontSizeWidget.sigTextChanged.connect(self.onValueChanged) - self.moveWithZoomToggle.toggled.connect(self.onValueChanged) - - def onValueChanged(self, *args, **kwargs): - self.sigValueChanged.emit(self.kwargs()) - - def selectColor(self): - color = self.colorButton.color() - self.colorButton.origColor = color - self.colorButton.colorDialog.setCurrentColor(color) - self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - self.colorButton.colorDialog.setParent(self) - self.colorButton.colorDialog.open() - w = self.width() - left = self.pos().x() - colorDialogTop = self.colorButton.colorDialog.pos().y() - self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) - - def kwargs(self): - kwargs = { - "color": self.colorButton.color(), - "start_timedelta": self.startTimeWidget.timedelta(), - "loc": self.locCombobox.currentText().lower(), - "font_size": self.fontSizeWidget.text(), - "move_with_zoom": self.moveWithZoomToggle.isChecked(), - } - return kwargs - - def ok_cb(self): - self.cancel = False - self.close() - - -class ExportToImageParametersDialog(QBaseDialog): - sigOk = Signal(dict) - sigAddScaleBar = Signal(bool) - sigRangeChanged = Signal(object) - - def __init__( - self, - parent=None, - startFolderpath="", - startFilename="", - startViewRange=None, - isScaleBarPresent=False, - ): - self.cancel = True - - super().__init__(parent=parent) - - self.setWindowTitle("Preferences for output image") - - mainLayout = QVBoxLayout() - - gridLayout = QGridLayout() - - row = 0 - gridLayout.addWidget(QLabel("View range X axis:"), row, 0) - self.xRangeSelector = widgets.RangeSelector(integers=True) - if startViewRange is not None: - xRange, yRange = startViewRange - self.xRangeSelector.setRange(*xRange) - gridLayout.addWidget(self.xRangeSelector, row, 1) - - row += 1 - gridLayout.addWidget(QLabel("View range Y axis:"), row, 0) - self.yRangeSelector = widgets.RangeSelector(integers=True) - if startViewRange is not None: - xRange, yRange = startViewRange - self.yRangeSelector.setRange(*yRange) - gridLayout.addWidget(self.yRangeSelector, row, 1) - - row += 1 - gridLayout.addWidget(QLabel("Width and Height:"), row, 0) - self.widthHeightSelector = widgets.RangeSelector(integers=True, ordered=False) - if startViewRange is not None: - xRange, yRange = startViewRange - width = int(xRange[1] - xRange[0]) - height = int(yRange[1] - yRange[0]) - self.widthHeightSelector.setRange(width, height) - gridLayout.addWidget(self.widthHeightSelector, row, 1) - self.lockSizeButton = widgets.LockPushButton() - self.lockSizeButton.setCheckable(True) - self.lockSizeButton.setToolTip("Lock width and height") - gridLayout.addWidget(self.lockSizeButton, row, 2) - - row += 1 - gridLayout.addWidget(QLabel("File format:"), row, 0) - self.fileFormatCombobox = QComboBox() - self.fileFormatCombobox.addItems(["SVG", "PNG", "TIFF", "JPEG"]) - gridLayout.addWidget(self.fileFormatCombobox, row, 1) - - row += 1 - self.dpiWidget = widgets.IntLineEdit(allowNegative=False) - self.dpiWidget.setValue(300) - self.dpiWidget.label = QLabel("DPI") - gridLayout.addWidget(self.dpiWidget.label, row, 0) - gridLayout.addWidget(self.dpiWidget, row, 1) - self.dpiWidget.hide() - self.dpiWidget.label.hide() - - row += 1 - gridLayout.addWidget(QLabel("Folder path:"), row, 0) - self.folderPathLineEdit = widgets.ElidingLineEdit(minWidth=240) - self.folderPathLineEdit.setText(startFolderpath) - gridLayout.addWidget(self.folderPathLineEdit, row, 1) - self.browseButton = widgets.browseFileButton( - start_dir=startFolderpath, openFolder=True - ) - gridLayout.addWidget(self.browseButton, row, 2) - - row += 1 - gridLayout.addWidget(QLabel("Filename:"), row, 0) - self.filenameLineEdit = widgets.alphaNumericLineEdit() - self.filenameLineEdit.setAlignment(Qt.AlignCenter) - self.filenameLineEdit.setText(startFilename) - gridLayout.addWidget(self.filenameLineEdit, row, 1) - self.fileFormatLabel = QLabel( - f".{self.fileFormatCombobox.currentText().lower()}" - ) - gridLayout.addWidget(self.fileFormatLabel, row, 2) - - row += 1 - gridLayout.addWidget(QLabel("Add Scale Bar:"), row, 0) - self.addScaleBarToggle = widgets.Toggle() - gridLayout.addWidget(self.addScaleBarToggle, row, 1, alignment=Qt.AlignCenter) - self.addScaleBarToggle.setChecked(isScaleBarPresent) - - self.fileFormatCombobox.currentTextChanged.connect(self.updateFileFormat) - self.browseButton.sigPathSelected.connect(self.updateFolderPath) - self.addScaleBarToggle.toggled.connect(self.addScaleBarToggled) - self.xRangeSelector.sigLowValueChanged.connect(self.x0Changed) - self.xRangeSelector.sigHighValueChanged.connect(self.x1Changed) - self.yRangeSelector.sigLowValueChanged.connect(self.y0Changed) - self.yRangeSelector.sigHighValueChanged.connect(self.y1Changed) - self.widthHeightSelector.sigLowValueChanged.connect(self.widthChanged) - self.widthHeightSelector.sigHighValueChanged.connect(self.heightChanged) - self.widthHeightSelector.sigRangeManuallyChanged.connect( - self.widthHeightManuallyChanged - ) - - buttonsLayout = widgets.CancelOkButtonsLayout() - buttonsLayout.okButton.setText("Export") - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - gridLayout.setColumnStretch(2, 0) - - mainLayout.addLayout(gridLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def widthHeightManuallyChanged(self, *args): - self.lockSizeButton.setChecked(True) - - def x0Changed(self, *args): - if self.lockSizeButton.isChecked(): - x0, _ = self.xRangeSelector.range() - yRange = self.yRangeSelector.range() - width, height = self.widthHeightSelector.range() - x1 = x0 + width - xRange = (x0, x1) - else: - xRange = self.xRangeSelector.range() - yRange = self.yRangeSelector.range() - _, height = self.widthHeightSelector.range() - width = int(xRange[1] - xRange[0]) - - self.xRangeSelector.setRangeNoEmit(*xRange) - self.yRangeSelector.setRangeNoEmit(*yRange) - self.widthHeightSelector.setRangeNoEmit(width, height) - self.rangeChanged() - - def x1Changed(self, *args): - if self.lockSizeButton.isChecked(): - _, x1 = self.xRangeSelector.range() - yRange = self.yRangeSelector.range() - width, height = self.widthHeightSelector.range() - x0 = x1 - width - xRange = (x0, x1) - else: - xRange = self.xRangeSelector.range() - yRange = self.yRangeSelector.range() - _, height = self.widthHeightSelector.range() - width = int(xRange[1] - xRange[0]) - - self.xRangeSelector.setRangeNoEmit(*xRange) - self.yRangeSelector.setRangeNoEmit(*yRange) - self.widthHeightSelector.setRangeNoEmit(width, height) - - self.rangeChanged() - - def y0Changed(self, *args): - if self.lockSizeButton.isChecked(): - xRange = self.xRangeSelector.range() - y0, _ = self.yRangeSelector.range() - width, height = self.widthHeightSelector.range() - y1 = y0 + height - yRange = (y0, y1) - else: - xRange = self.xRangeSelector.range() - yRange = self.yRangeSelector.range() - width, _ = self.widthHeightSelector.range() - height = int(yRange[1] - yRange[0]) - - self.xRangeSelector.setRangeNoEmit(*xRange) - self.yRangeSelector.setRangeNoEmit(*yRange) - self.widthHeightSelector.setRangeNoEmit(width, height) - - self.rangeChanged() - - def y1Changed(self, *args): - if self.lockSizeButton.isChecked(): - xRange = self.xRangeSelector.range() - _, y1 = self.yRangeSelector.range() - width, height = self.widthHeightSelector.range() - y0 = y1 - height - yRange = (y0, y1) - else: - xRange = self.xRangeSelector.range() - yRange = self.yRangeSelector.range() - width, _ = self.widthHeightSelector.range() - height = int(yRange[1] - yRange[0]) - - self.xRangeSelector.setRangeNoEmit(*xRange) - self.yRangeSelector.setRangeNoEmit(*yRange) - self.widthHeightSelector.setRangeNoEmit(width, height) - - self.rangeChanged() - - def widthChanged(self, *args): - self.widthHeightChanged() - self.rangeChanged() - - def heightChanged(self, *args): - self.widthHeightChanged() - self.rangeChanged() - - def updateViewRangeExportToImageDialog(self, viewBox, viewRange, changed): - xRange, yRange = viewRange - self.xRangeSelector.setRangeNoEmit(*xRange) - self.yRangeSelector.setRangeNoEmit(*yRange) - - def widthHeightChanged(self, *args): - x0, _ = self.xRangeSelector.range() - y0, _ = self.yRangeSelector.range() - width, height = self.widthHeightSelector.range() - x1 = x0 + width - y1 = y0 + height - self.xRangeSelector.setRangeNoEmit(x0, x1) - self.yRangeSelector.setRangeNoEmit(y0, y1) - self.rangeChanged() - - def rangeChanged(self, *args): - xRange = self.xRangeSelector.range() - yRange = self.yRangeSelector.range() - self.sigRangeChanged.emit((xRange, yRange)) - - def addScaleBarToggled(self, checked): - self.sigAddScaleBar.emit(checked) - - def updateFolderPath(self, folderPath): - self.folderPathLineEdit.setText(folderPath) - self.browseButton.setStartPath(folderPath) - - def updateFileFormat(self, fileFormat): - if fileFormat == "SVG": - self.dpiWidget.hide() - self.dpiWidget.label.hide() - else: - self.dpiWidget.show() - self.dpiWidget.label.show() - - self.fileFormatLabel.setText(f".{fileFormat.lower()}") - - def validateFolderPath(self): - folderPath = self.folderPathLineEdit.text() - if os.path.exists(folderPath) and os.path.isdir(folderPath): - return True - - text = html_utils.paragraph( - "The selected folder path is not a valid folder or does not exist" - ) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Not a valid folder", text) - return False - - def validateFilename(self): - filename = self.filenameLineEdit.text() - if filename: - return True - - text = html_utils.paragraph("The filename cannot be empty!") - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Not a valid folder", text) - return False - - def validate(self): - proceed = self.validateFolderPath() - if not proceed: - return False - - proceed = self.validateFilename() - if not proceed: - return False - - return True - - def setViewRange(self, xRange, yRange, emitSignal=True): - if self.lockSizeButton.isChecked(): - x0, _ = xRange - y0, _ = yRange - width, height = self.widthHeightSelector.range() - x1 = x0 + width - y1 = y0 + height - xRange = (x0, x1) - yRange = (y0, y1) - else: - width = int(xRange[1] - xRange[0]) - height = int(yRange[1] - yRange[0]) - - self.xRangeSelector.setRangeNoEmit(*xRange) - self.yRangeSelector.setRangeNoEmit(*yRange) - self.widthHeightSelector.setRangeNoEmit(width, height) - if not emitSignal: - return - - self.rangeChanged() - - def viewRange(self): - xRange = self.xRangeSelector.range() - yRange = self.yRangeSelector.range() - return (xRange, yRange) - - def preferences(self): - filename = f"{self.filenameLineEdit.text()}{self.fileFormatLabel.text()}" - preferences = { - "view_range_x": self.xRangeSelector.range(), - "view_range_y": self.yRangeSelector.range(), - "filepath": os.path.join(self.folderPathLineEdit.text(), filename), - "filename": self.filenameLineEdit.text(), - "dpi": self.dpiWidget.value(), - } - return preferences - - def ok_cb(self): - proceed = self.validate() - if not proceed: - return - self.cancel = False - self.sigOk.emit(self.preferences()) - self.selected_preferences = self.preferences() - self.close() - - -class DataPrepSubCropsPathsDialog(QBaseDialog): - def __init__(self, cropPaths=None, parent=None): - self.cancel = True - - super().__init__(parent=parent) - - mainLayout = QVBoxLayout() - - gridLayout = QGridLayout() - row = 0 - - if cropPaths is None: - cropPaths = {os.path.expanduser("~"): 1} - - if any([numCrops > 1 for numCrops in cropPaths.values()]): - row += 1 - gridLayout.addWidget(QLabel("Same folder for all crops:"), row, 0) - self.sameFolderPathToggle = widgets.Toggle() - gridLayout.addWidget( - self.sameFolderPathToggle, row, 1, alignment=Qt.AlignCenter - ) - self.sameFolderPathToggle.setChecked(True) - self.sameFolderPathToggle.toggled.connect(self.setSameFolderPath) - - self.windowMinWidth = 0 - minWidth = int(self.screen().size().width() / 3) - self.folderPathLineEdits = defaultdict(list) - for path, numCrops in cropPaths.items(): - row += 1 - gridLayout.addWidget(QLabel("Master Position:"), row, 0) - masterPathLabel = QLabel(f"{path}") - gridLayout.addWidget(masterPathLabel, row, 1) - - scrollArea = QScrollArea() - scrollArea.setWidgetResizable(True) - scrollAreaLayout = QGridLayout() - for i in range(numCrops): - label = QLabel(f"Crop {i + 1} folder path:") - scrollAreaLayout.addWidget(label, i, 0) - folderPathLineEdit = widgets.ElidingLineEdit() - folderPathLineEdit.label = label - folderPathLineEdit.setText(path) - scrollAreaLayout.addWidget(folderPathLineEdit, i, 1) - browseButton = widgets.browseFileButton(start_dir=path, openFolder=True) - scrollAreaLayout.addWidget(browseButton, i, 2) - browseButton.sigPathSelected.connect( - partial(self.updateFolderPath, lineEdit=folderPathLineEdit) - ) - self.folderPathLineEdits[path].append(folderPathLineEdit) - folderPathLineEdit.browseButton = browseButton - - scrollAreaLayout.setColumnStretch(0, 0) - scrollAreaLayout.setColumnStretch(1, 1) - scrollAreaLayout.setColumnStretch(2, 0) - container = QWidget() - container.setLayout(scrollAreaLayout) - scrollArea.setWidget(container) - - row += 1 - gridLayout.addWidget(scrollArea, row, 0, 1, 2) - noHorizontalScrollbarWidth = ( - container.sizeHint().width() - + scrollArea.verticalScrollBar().sizeHint().width() - + 20 - ) - if noHorizontalScrollbarWidth > self.windowMinWidth: - self.windowMinWidth = noHorizontalScrollbarWidth - - row += 1 - gridLayout.addWidget(widgets.QHLine(), row, 0, 1, 2) - - row += 1 - gridLayout.addItem(QSpacerItem(10, 10), row, 0, 1, 2) - - row += 1 - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addLayout(gridLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def show(self, block=False): - self.resize(self.windowMinWidth, self.sizeHint().height()) - super().show(block=block) - - def setSameFolderPath(self, checked): - for masterPath, lineEdits in self.folderPathLineEdits.items(): - referencePath = lineEdits[0].text() - for lineEdit in lineEdits[1:]: - if checked: - lineEdit.setText(referencePath) - - lineEdit.setDisabled(checked) - lineEdit.browseButton.setDisabled(checked) - lineEdit.label.setDisabled(checked) - - def updateFolderPath(self, path, lineEdit=None): - lineEdit.setText(path) - lineEdit.browseButton.setStartPath(path) - - def warnFolderPathNotValid(self, cropNum, masterPath, folderPath): - text = html_utils.paragraph( - f"The following folder path for crop number {cropNum} " - "is not a valid folder or does not exist:" - ) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Not a valid folder", text, commands=(folderPath,)) - - def askOverwritingPaths(self, overwritingPaths): - text = html_utils.paragraph( - "Data in the following paths will be overwritten with " - "cropped data.

    " - "Are you sure you want to continue?" - ) - msg = widgets.myMessageBox(wrapText=False) - _, yesButton = msg.warning( - self, - "Not a valid folder", - text, - commands=overwritingPaths, - buttonsTexts=("No, let me edit paths", "Yes, overwrite"), - ) - return msg.clickedButton == yesButton - - def validatePaths(self): - for masterPath, lineEdits in self.folderPathLineEdits.items(): - for i, lineEdit in enumerate(lineEdits): - path = lineEdit.text() - if os.path.exists(path) and os.path.isdir(path): - continue - - self.warnFolderPathNotValid(i + 1, masterPath, path) - return False - - overwritingPaths = [] - for masterPath, lineEdits in self.folderPathLineEdits.items(): - masterPath = masterPath.replace("\\", "/") - if not masterPath.endswith("Images"): - continue - - for i, lineEdit in enumerate(lineEdits): - path = lineEdit.text() - path = path.replace("\\", "/") - if path == masterPath: - overwritingPaths.append(masterPath) - - if not overwritingPaths: - return True - - return self.askOverwritingPaths(overwritingPaths) - - def paths(self): - selectedPaths = {} - for masterPath, lineEdits in self.folderPathLineEdits.items(): - selectedPaths[masterPath] = [le.text() for le in lineEdits] - return selectedPaths - - def ok_cb(self): - proceed = self.validatePaths() - if not proceed: - return - - self.folderPaths = self.paths() - self.cancel = False - self.close() - - -class PreProcessParamsWidget(QWidget): - sigLoadRecipe = Signal() - sigLoadSavedRecipe = Signal() - sigValuesChanged = Signal(list) - - def __init__(self, df_metadata=None, addApplyButton=False, parent=None): - super().__init__(parent) - - mainLayout = QVBoxLayout() - - self.df_metadata = df_metadata - self.addApplyButton = addApplyButton - - groupbox = QGroupBox() - self.groupbox = groupbox - - groupbox.setTitle("Pre-processing") - groupbox.setCheckable(True) - - self.gridLayout = QGridLayout() - self.row = -1 - self.stepsWidgets = {} - - self.gridLayout.setColumnStretch(0, 0) - self.gridLayout.setColumnStretch(1, 1) - self.gridLayout.setColumnStretch(2, 0) - self.gridLayout.setColumnStretch(3, 0) - self.gridLayout.setColumnStretch(4, 0) - groupbox.setLayout(self.gridLayout) - - buttonsLayout = QGridLayout() - row = 0 - col = 0 - buttonsLayout.setColumnStretch(col, 1) - - loadRecipeButton = widgets.OpenFilePushButton("Load saved recipe...") - self.loadRecipeButton = loadRecipeButton - buttonsLayout.addWidget(loadRecipeButton, row, col + 2) - - saveRecipeButton = widgets.savePushButton("Save current recipe...") - self.saveRecipeButton = saveRecipeButton - buttonsLayout.addWidget(saveRecipeButton, row + 1, col + 2) - - loadLastRecipeButton = widgets.reloadPushButton("Load last parameters") - self.loadLastRecipeButton = loadLastRecipeButton - buttonsLayout.addWidget(loadLastRecipeButton, row, col + 1) - - self.buttonsLayout = buttonsLayout - - loadLastRecipeButton.clicked.connect(self.emitLoadRecipe) - saveRecipeButton.clicked.connect(self.saveRecipe) - loadRecipeButton.clicked.connect(self.selectAndLoadRecipe) - - mainLayout.addWidget(groupbox) - mainLayout.addSpacing(10) - mainLayout.addLayout(buttonsLayout) - - self.addStep(is_first=True) - - mainLayout.setContentsMargins(0, 0, 0, 0) - self.setLayout(mainLayout) - - def stepSizeHeightHint(self): - stepWidgets = self.stepsWidgets[1] - height = ( - stepWidgets["stepLabel"].minimumSizeHint().height() - + stepWidgets["selector"].minimumSizeHint().height() - ) - return height - - def setChecked(self, checked): - self.groupbox.setChecked(checked) - - def emitLoadRecipe(self): - self.sigLoadRecipe.emit() - - def loadRecipe(self, configPars: dict): - for stepWidgets in list(self.stepsWidgets.values()): - try: - stepWidgets["delButton"].click() - except Exception as err: - pass - - configPars = self.sortStepsConfigPars(configPars) - for s in range(1, len(configPars)): - self.stepsWidgets[1]["addButton"].click() - - for i, (section, section_items) in enumerate(configPars.items()): - step_n = i + 1 - selector = self.stepsWidgets[step_n]["selector"] - kwarg_to_value_mapper = {} - for option, value in section_items.items(): - if option == "method": - selector.setCurrentText(value) - method = value - else: - kwarg_to_value_mapper[option] = value - selector.setParams(method, kwarg_to_value_mapper) - - self.setChecked(True) - - def sortStepsConfigPars(self, configPars: dict): - sortedConfigPars = {} - sortedKeys = sorted( - configPars.keys(), key=lambda key: int(re.findall(r"step(\d+)", key)[0]) - ) - for key in sortedKeys: - sortedConfigPars[key] = configPars[key] - return sortedConfigPars - - def saveRecipeUI( - self, folder_path, ext, title, basename, hintText, default_text - ): # -> tuple[Literal[False], Literal['']] | tuple[Literal[True], Any]: - win = filenameDialog( - title=title, - basename=basename, - ext=ext, - hintText=hintText, - allowEmpty=False, - defaultEntry=default_text, - parent=self, - ) - win.exec_() - if win.cancel: - return False, "" - - self.cancel = False - filepath = win.filename - os.makedirs(folder_path, exist_ok=True) - filepath = os.path.join(folder_path, filepath) - - if os.path.exists(filepath): - proceed = self.warnExistingRecipeFile(filepath) - if not proceed: - return False, "" - - return True, filepath - - def saveRecipe(self): - recipe = self.recipe() - if recipe is None: - return - - default_text = "" - for step in recipe[:2]: - method = step["method"] - func_name = config.PREPROCESS_MAPPER[method]["function_name"] - default_text = f"{default_text}-{func_name}" - default_text = default_text.lstrip("-") - - proceed, ini_filepath = self.saveRecipeUI( - preproc_recipes_path, - ".ini", - "Filename for pre-processing recipe", - "preprocessing_recipe", - "Insert a filename for the pre-processing recipe:", - default_text, - ) - if not proceed: - return - - cp = self.recipeConfigPars("acdc") - with open(ini_filepath, "w") as configfile: - cp.write(configfile) - - self.communicateSavingRecipeFinished(ini_filepath) - - def warnExistingRecipeFile(self, ini_filename): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - "A file with the following name

    " - f"{ini_filename}

    " - "already exists.

    " - "Do you want to overwrite the existing file?" - ) - noButton, yesButton = msg.warning( - self, - "File name existing", - txt, - buttonsTexts=("No, stop saving process", "Yes, overwrite existing file"), - ) - return msg.clickedButton == yesButton - - def warnNoAvailableRecipesToLoad(self): - text = html_utils.paragraph("There are no recipes saved. Sorry about that :(") - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "No recipes saved", text) - - # def selectIniFileToLoadRecipe(self): - # import qtpy.compat - # ini_filepath = qtpy.compat.getopenfilename( - # parent=self, - # caption='Select INI file to load pre-processing recipe', - # filters='INI (*.ini);;All Files (*)' - # )[0] - # if not ini_filepath: - # return - - # cp = config.ConfigParser() - # cp.read(ini_filepath) - # preprocConfigPars = {} - # for section in cp.sections(): - # if not section.startswith('acdc.preprocess'): - # continue - - # preprocConfigPars[section] = cp[section] - - # if not preprocConfigPars: - # return - - # self.loadRecipe(preprocConfigPars) - - def selectRecipeFilepath(self, recipes_path, recipe_prefix, ext_label, ext): - availableRecipes = [] - if os.path.exists(recipes_path): - for file in myutils.listdir(recipes_path): - if not file.startswith(recipe_prefix): - continue - endname = file.split(f"{recipe_prefix}_")[1] - availableRecipes.append(endname) - - if not availableRecipes: - import qtpy.compat - - filepath = qtpy.compat.getopenfilename( - parent=self, - caption=f"Select {ext_label} file to load recipe", - filters=f"{ext_label} (*.{ext});;All Files (*)", - )[0] - return filepath or None - - browseButton = widgets.browseFileButton( - f"Select {ext_label} file...", - title=f"Select {ext_label} file to load recipe", - openFolder=False, - start_dir=myutils.getMostRecentPath(), - ext={ext_label: f".{ext}"}, - ) - selectRecipeWin = widgets.QDialogListbox( - "Select recipe", - "Select recipe to load:\n", - availableRecipes, - multiSelection=False, - allowEmptySelection=False, - parent=self, - additionalButtons=(browseButton,), - ) - browseButton.sigPathSelected.connect( - partial( - self.recipeIniFileSelected, - selectRecipeWin=selectRecipeWin, - sender=browseButton, - ) - ) - selectRecipeWin.exec_() - if selectRecipeWin.cancel: - return None - - if selectRecipeWin.clickedButton == browseButton: - return selectRecipeWin.selectedIniFilepath - - selected_endname = selectRecipeWin.selectedItemsText[0] - filename = f"{recipe_prefix}_{selected_endname}" - return os.path.join(recipes_path, filename) - - def selectAndLoadRecipe(self): - filepath = self.selectRecipeFilepath( - preproc_recipes_path, "preprocessing_recipe", "INI", "ini" - ) - if filepath is None: - return - cp = config.ConfigParser() - cp.read(filepath) - preprocConfigPars = { - s: cp[s] for s in cp.sections() if s.startswith("acdc.preprocess") - } - if not preprocConfigPars: - return - self.loadRecipe(preprocConfigPars) - - def recipeIniFileSelected(self, ini_filepath, selectRecipeWin=None, sender=None): - selectRecipeWin.clickedButton = sender - selectRecipeWin.selectedIniFilepath = ini_filepath - selectRecipeWin.cancel = False - selectRecipeWin.close() - - def communicateSavingRecipeFinished(self, ini_filepath): - text = html_utils.paragraph("Done!

    Pre-processing recipe saved to:") - msg = widgets.myMessageBox(wrapText=False) - msg.information( - self, - "Pre-processing recipe saved!", - text, - commands=(ini_filepath,), - path_to_browse=os.path.dirname(ini_filepath), - ) - - def addStep(self, is_first=False): - stepWidgets = {} - - self.row += 1 - - step_n = len(self.stepsWidgets) + 1 - label = QLabel(f"Step {step_n}: ") - self.gridLayout.addWidget(label, self.row, 0) - stepWidgets["stepLabel"] = label - - selector = widgets.PreProcessingSelector() - self.gridLayout.addWidget(selector, self.row, 1) - stepWidgets["selector"] = selector - - setParamsButton = widgets.setPushButton() - setParamsButton.setToolTip("Set step parameters") - self.gridLayout.addWidget(setParamsButton, self.row, 2) - setParamsButton.clicked.connect(partial(self.setParamsStep, selector=selector)) - stepWidgets["setParamsButton"] = setParamsButton - - infoButton = widgets.infoPushButton() - self.gridLayout.addWidget(infoButton, self.row, 3) - infoButton.clicked.connect(partial(self.showInfo, selector=selector)) - stepWidgets["infoButton"] = infoButton - - if is_first: - addButton = widgets.addPushButton() - self.gridLayout.addWidget(addButton, self.row, 4) - addButton.clicked.connect(self.addStep) - stepWidgets["addButton"] = addButton - else: - delButton = widgets.delPushButton() - self.gridLayout.addWidget(delButton, self.row, 4) - delButton.clicked.connect(self.removeStep) - delButton.step_n = step_n - stepWidgets["delButton"] = delButton - - self.row += 1 - selector.row = self.row - selector.step_n = step_n - - hline = widgets.QHLine() - self.gridLayout.addWidget(hline, self.row, 0, 1, 6) - stepWidgets["hline"] = hline - self.row += 1 - - self.stepsWidgets[step_n] = stepWidgets - - selector.sigValuesChanged.connect(self.emitValuesChanged) - selector.currentTextChanged.connect( - partial(self.clearInitKwargs, step_n=step_n) - ) - - self.resetStretch() - - def emitValuesChanged(self, functionKwargs, step_n): - self.stepsWidgets[step_n]["step_kwargs"] = functionKwargs - - recipe = self.recipe(warn=False) - if recipe is None: - return - - self.sigValuesChanged.emit(recipe) - - def clearInitKwargs(self, selected_method, step_n=0): - stepWidgets = self.stepsWidgets[step_n] - stepWidgets.pop("step_kwargs", None) - - def resetStretch(self): - for row in range(self.gridLayout.rowCount()): - self.gridLayout.setRowStretch(row, 0) - - self.gridLayout.setRowStretch(self.gridLayout.rowCount(), 1) - self.row = self.gridLayout.rowCount() - 1 - - def showInfo(self, checked=False, selector=None): - if selector is None: - return - - htmlText = selector.htmlInfo() - htmlText = html_utils.paragraph(htmlText) - - method = selector.currentText() - msg = widgets.myMessageBox(wrapText=False) - msg.information(self, f"Info about `{method}`", htmlText) - - def setParamsStep( - self, checked=False, selector: "widgets.PreProcessingSelector" = None - ): - step_n = selector.step_n - stepFunctionKwargs = selector.askSetParams( - df_metadata=self.df_metadata, addApplyButton=self.addApplyButton - ) - if stepFunctionKwargs is None: - return - - self.stepsWidgets[step_n]["step_kwargs"] = stepFunctionKwargs - - def removeStep(self, checked=False, step_n=None): - if step_n is None: - step_n = self.sender().step_n - - stepWidgets = self.stepsWidgets[step_n] - - stepWidgets["stepLabel"].hide() - self.gridLayout.removeWidget(stepWidgets["stepLabel"]) - - stepWidgets["selector"].hide() - self.gridLayout.removeWidget(stepWidgets["selector"]) - - stepWidgets["infoButton"].hide() - self.gridLayout.removeWidget(stepWidgets["infoButton"]) - - # stepWidgets['addButton'].hide() - # self.gridLayout.removeWidget(stepWidgets['addButton']) - - stepWidgets["setParamsButton"].hide() - self.gridLayout.removeWidget(stepWidgets["setParamsButton"]) - - stepWidgets["delButton"].hide() - self.gridLayout.removeWidget(stepWidgets["delButton"]) - self.row -= 1 - - stepWidgets["hline"].hide() - self.gridLayout.removeWidget(stepWidgets["hline"]) - self.row -= 1 - - self.stepsWidgets.pop(step_n) - - stepsWidgetsMapper = {1: self.stepsWidgets[1]} - for i, stepWidgets in enumerate(self.stepsWidgets.values()): - if i == 0: - continue - step_n = i + 1 - label = stepWidgets["stepLabel"] - label.setText(f"Step {step_n}: ") - stepWidgets["delButton"].step_n = step_n - stepWidgets["selector"].step_n = step_n - stepsWidgetsMapper[step_n] = stepWidgets - - self.stepsWidgets = stepsWidgetsMapper - - self.resetStretch() - - def isChecked(self): - return self.groupbox.isChecked() - - def warnStepNotInit(self, method): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - f"The parameters for the preprocessing step {method} " - "were not initialized.

    " - "Please, click on the corresponding Set step parameters " - "button to initialize this step (cog icon).

    " - "Thank you for your patience!" - ) - msg.warning(self, "Params not initialized!", txt) - - def recipe(self, warn=True): - recipe = [] - if not self.groupbox.isChecked() and self.groupbox.isCheckable(): - return recipe - - for stepWidgets in self.stepsWidgets.values(): - method = stepWidgets["selector"].currentText() - step_kwargs = stepWidgets.get("step_kwargs") - if step_kwargs is None: - if warn: - self.warnStepNotInit(method) - return - - try: - init_func = config.PREPROCESS_INIT_MAPPER[method]["function"] - init_func(**step_kwargs) - except Exception as err: - pass - - recipe.append({"method": method, "kwargs": step_kwargs}) - - return recipe - - def recipeConfigPars(self, model_name): - cp = config.ConfigParser() - if not self.groupbox.isChecked() and self.groupbox.isCheckable(): - return cp - - for s, step in enumerate(self.recipe()): - section = f"{model_name}.preprocess.step{s + 1}" - cp[section] = {} - cp[section]["method"] = step["method"] - for option, value in step["kwargs"].items(): - cp[section][option] = str(value) - return cp - - -# class QComboBoxChangeColor(QComboBox): -# def __init__(self, forbidden_items=None, parent=None): -# super().__init__(parent) -# self.forbiddenItems = forbidden_items or set() -# self._defaultStyleSheet = self.styleSheet() -# self.currentTextChanged.connect(self._updateColor) - -# def _updateColor(self, text=None): -# if not hasattr(self, '_defaultStyleSheet'): -# self._defaultStyleSheet = self.styleSheet() -# if self.currentText() in self.forbiddenItems: -# self.setStyleSheet( -# self._defaultStyleSheet + """ -# /* Closed state */ -# QComboBox { -# color: red; -# } - -# /* Open state (popup visible) */ -# QComboBox:on { -# color: white; -# } -# """ -# ) -# else: -# self.setStyleSheet(self._defaultStyleSheet) - - -class CombineChannelsWidget(PreProcessParamsWidget): - sigValuesChangedCombineChannels = Signal() - - def __init__(self, channel_names: Iterable[str], parent=None): - self.channel_names = channel_names - - super().__init__(parent) - - self.parent = parent - qutils.delete_widget(self.loadLastRecipeButton) - qutils.delete_widget(self.saveRecipeButton) - qutils.delete_widget(self.loadRecipeButton) - - def addStep(self, is_first=False): - stepWidgets = {} - - self.row += 1 - if is_first: - self.row += 1 - - step_n = len(self.stepsWidgets) + 1 - tooltip = "Use this text in the formula" - if is_first: - label = QLabel("Formula var") - label.setToolTip(tooltip) - self.gridLayout.addWidget(label, self.row - 1, 1) - name_edit = QLineEdit(text=f"img{step_n}") - name_edit.setToolTip(tooltip) - self.gridLayout.addWidget(name_edit, self.row, 1) - stepWidgets["name_edit"] = name_edit - name_edit.textChanged.connect(self.emitValuesChanged) - - tooltip = "Select a channel or a segmentation mask" - if is_first: - label = QLabel("Channel") - label.setToolTip(tooltip) - self.gridLayout.addWidget(label, self.row - 1, 2) - ch_selector = QComboBox() - ch_selector.setToolTip(tooltip) - ch_selector.addItems(self.channel_names) - self.gridLayout.addWidget(ch_selector, self.row, 2) - stepWidgets["selector"] = ch_selector - ch_selector.currentTextChanged.connect(self.setBinarizeCheckableAndNorm) - - # add binarisaion spinbox - tooltip = ( - "If binarize is selected, the channel will be binarized first, before applying offset and multiplier.\n" - "If inverse binarize is selected, the channel will be binerized and " - "then the logical NOT will be applied." - ) - if is_first: - label = QLabel("Binarize") - label.setToolTip(tooltip) - self.gridLayout.addWidget(label, self.row - 1, 5) - options = ["No", "binarize", "inverse binarize"] - self.binarizeCombobox = QComboBox() - self.binarizeCombobox.addItems(options) - self.binarizeCombobox.setCurrentIndex(0) - self.binarizeCombobox.setEnabled(False) - self.binarizeCombobox.setToolTip(tooltip) - self.binarizeCombobox.currentIndexChanged.connect(self.emitValuesChanged) - self.gridLayout.addWidget(self.binarizeCombobox, self.row, 5) - stepWidgets["binarize"] = self.binarizeCombobox - - tooltip = "Min value of the channel to be normalized to." - if is_first: - label = QLabel("Min val") - label.setToolTip(tooltip) - self.gridLayout.addWidget(label, self.row - 1, 6) - self.minValueSpinbox = QDoubleSpinBox() - self.minValueSpinbox.setRange(-np.inf, np.inf) - self.minValueSpinbox.setSingleStep(0.1) - self.minValueSpinbox.setValue(0) - self.minValueSpinbox.setToolTip(tooltip) - - self.minValueSpinbox.valueChanged.connect(self.emitValuesChanged) - self.gridLayout.addWidget(self.minValueSpinbox, self.row, 6) - stepWidgets["minValueSpinbox"] = self.minValueSpinbox - - tooltip = "Max value of the channel to be normalized to." - if is_first: - label = QLabel("Max val") - label.setToolTip(tooltip) - self.gridLayout.addWidget(label, self.row - 1, 7) - self.maxValueSpinbox = QDoubleSpinBox() - self.maxValueSpinbox.setRange(-np.inf, np.inf) - self.maxValueSpinbox.setSingleStep(0.1) - self.maxValueSpinbox.setValue(1) - self.maxValueSpinbox.setToolTip(tooltip) - - self.maxValueSpinbox.valueChanged.connect(self.emitValuesChanged) - self.gridLayout.addWidget(self.maxValueSpinbox, self.row, 7) - stepWidgets["maxValueSpinbox"] = self.maxValueSpinbox - - if is_first: - addButton = widgets.addPushButton() - self.gridLayout.addWidget(addButton, self.row, 8) - addButton.clicked.connect(self.addStep) - stepWidgets["addButton"] = addButton - - else: - delButton = widgets.delPushButton() - self.gridLayout.addWidget(delButton, self.row, 8) - delButton.clicked.connect(self.removeStep) - delButton.step_n = step_n - stepWidgets["delButton"] = delButton - - self.row += 1 - ch_selector.row = self.row - ch_selector.step_n = step_n - - hline = widgets.QHLine() - self.gridLayout.addWidget(hline, self.row, 0, 1, 8) - stepWidgets["hline"] = hline - self.row += 1 - - self.stepsWidgets[step_n] = stepWidgets - - self.resetStretch() - self.sigValuesChangedCombineChannels.emit() - self.setBinarizeCheckableAndNorm() - - def emitValuesChanged(self, *args): - self.sigValuesChangedCombineChannels.emit() - - def setBinarizeCheckableAndNorm(self): - for step_n, stepWidgets in self.stepsWidgets.items(): - binarizeSelector = stepWidgets["binarize"] - channel = stepWidgets["selector"].currentText() - if "segm" in channel: - binarizeSelector.setEnabled(True) - # set min and max to 0 and 1 and disable - stepWidgets["minValueSpinbox"].setValue(0) - stepWidgets["maxValueSpinbox"].setValue(1) - stepWidgets["minValueSpinbox"].setEnabled(False) - stepWidgets["maxValueSpinbox"].setEnabled(False) - else: - binarizeSelector.setEnabled(False) - binarizeSelector.setCurrentIndex(0) - # set min and max to 0 and 1 and enable - stepWidgets["minValueSpinbox"].setEnabled(True) - stepWidgets["maxValueSpinbox"].setEnabled(True) - - self.emitValuesChanged() - - def removeStep(self, checked=False, step_n=None): - if step_n is None: - step_n = self.sender().step_n - - stepWidgets = self.stepsWidgets[step_n] - - stepWidgets["name_edit"].hide() - self.gridLayout.removeWidget(stepWidgets["name_edit"]) - - stepWidgets["selector"].hide() - self.gridLayout.removeWidget(stepWidgets["selector"]) - - stepWidgets["binarize"].hide() - self.gridLayout.removeWidget(stepWidgets["binarize"]) - - stepWidgets["minValueSpinbox"].hide() - self.gridLayout.removeWidget(stepWidgets["minValueSpinbox"]) - - stepWidgets["maxValueSpinbox"].hide() - self.gridLayout.removeWidget(stepWidgets["maxValueSpinbox"]) - - stepWidgets["delButton"].hide() - self.gridLayout.removeWidget(stepWidgets["delButton"]) - - self.row -= 1 - - stepWidgets["hline"].hide() - self.gridLayout.removeWidget(stepWidgets["hline"]) - self.row -= 1 - - self.stepsWidgets.pop(step_n) - - stepsWidgetsMapper = {1: self.stepsWidgets[1]} - for i, stepWidgets in enumerate(self.stepsWidgets.values()): - if i == 0: - continue - step_n = i + 1 - stepWidgets["delButton"].step_n = step_n - stepWidgets["selector"].step_n = step_n - stepsWidgetsMapper[step_n] = stepWidgets - - self.stepsWidgets = stepsWidgetsMapper - - self.resetStretch() - self.sigValuesChangedCombineChannels.emit() - - def steps(self): - steps = {} - if not self.groupbox.isChecked() and self.groupbox.isCheckable(): - return steps - - for step_number, stepWidgets in self.stepsWidgets.items(): - name = stepWidgets["name_edit"].text() - channel = stepWidgets["selector"].currentText() - binarize = stepWidgets["binarize"].currentText() - min_val = stepWidgets["minValueSpinbox"].value() - max_val = stepWidgets["maxValueSpinbox"].value() - steps[step_number] = { - "name": name, - "channel": channel, - "binarize": binarize, - "min_val": min_val, - "max_val": max_val, - } - - steps = dict(sorted(steps.items())) - return steps - - -class FormulaEditWidget(QWidget): - sigFormulaChanged = Signal(str, bool) # formula_str, is_valid - - def __init__(self, variable_names=None, parent=None): - super().__init__(parent) - self._variable_names = variable_names or [] - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(4) - - self._edit = QLineEdit() - self._edit.setPlaceholderText("e.g. img1 + img2 * 0.5") - layout.addWidget(self._edit) - - self._status_label = QLabel() - self._status_label.setWordWrap(True) - self._status_label.setStyleSheet("font-size: 11px;") - layout.addWidget(self._status_label) - - self._edit.textChanged.connect(self._onTextChanged) - self._clearStatus() - - self.parent = parent - - def setVariableNames(self, variable_names): - """Allows setting the variables. - - Parameters - ---------- - variable_names : list - list of variable names (strings) - """ - - self._variable_names = variable_names - self._onTextChanged(self._edit.text()) - - def text(self): - """Returns the current formula text.""" - return self._edit.text() - - def setText(self, text): - """Sets the formula text.""" - self._edit.setText(text) - - def _clearStatus(self): - self._status_label.setText("") - self._status_label.setStyleSheet("font-size: 11px;") - - def _onTextChanged(self, text): - if not text.strip(): - self._clearStatus() - - success, reconstructed_str = self.checkValidity(self._variable_names) - - if success: - self._status_label.setText(f"→ {reconstructed_str}") - self._status_label.setStyleSheet("font-size: 11px; color: green;") - else: - self._status_label.setText(reconstructed_str) - self._status_label.setStyleSheet("font-size: 11px; color: red;") - - self.sigFormulaChanged.emit(text, success) - - def checkValidity(self, variable_names=None): - if variable_names is None: - variable_names = self._variable_names - formula_str = self._edit.text() - arrays = {name: 1 for name in variable_names} - success = False - reconstructed_str = "ERROR" - forb_ch = self.parent.forbiddenChannels - if forb_ch: - stepsWidgets = self.parent.combineChannelsWidget.stepsWidgets - channels = { - stepsWidget["selector"].currentText() - for stepsWidget in stepsWidgets.values() - } - if forb_ch.intersection(channels): - reconstructed_str = ( - "Channels that are forbidden are not allowed to be used!:\n" - f"{forb_ch}" - ) - return False, reconstructed_str - if formula_str == "": - reconstructed_str = "First channel is returned/applied" - return True, reconstructed_str - try: - symbols = {name: sp.Symbol(name) for name in arrays} - expr = sp.sympify(formula_str, locals=symbols) - missing = {str(s) for s in expr.free_symbols} - arrays.keys() - if missing: - reconstructed_str = f"Missing variables: {missing}" - return False, reconstructed_str - - if formula_str == "": - reconstructed_str = "" - return True, reconstructed_str - - # filter out expressions that have no variables - if not any(s.is_Symbol for s in expr.free_symbols): - reconstructed_str = "No variables used" - return False, reconstructed_str - - reconstructed_str = str(expr) - success = True - except Exception as e: - if "syntax" in str(e): - reconstructed_str = f"Syntax error" - else: - reconstructed_str = str(e) - success = False - return success, reconstructed_str - - -class InitFijiMacroDialog(QBaseDialog): - def __init__(self, parent=None): - self.cancel = True - - super().__init__(parent=parent) - - mainLayout = QVBoxLayout() - - infoLabel = QLabel( - html_utils.paragraph( - """ - Place all the raw microscopy files in a folder without any other - file
    - and provide the following information: - """ - ) - ) - mainLayout.addWidget(infoLabel) - - gridLayout = QGridLayout() - - row = 0 - label = QLabel("Files internal structure: ") - gridLayout.addWidget(label, row, 0) - self.filesStructureCombobox = QComboBox() - self.filesStructureCombobox.addItems( - [ - 'Positions (aka "series") embedded in the file', - 'Positions (aka "series") separated, one for each file', - 'Positions (aka "series") and channels separated, one for each file', - ] - ) - gridLayout.addWidget(self.filesStructureCombobox, row, 1) - self.filesStructureCombobox.currentTextChanged.connect( - self.fileStructureChanged - ) - infoButton = widgets.infoPushButton() - gridLayout.addWidget(infoButton, row, 2) - infoButton.clicked.connect(self.showInfoFileStructure) - - row += 1 - label = QLabel("Folder with raw microscopy files: ") - gridLayout.addWidget(label, row, 0) - self.folderPathLineEdit = widgets.ElidingLineEdit() - gridLayout.addWidget(self.folderPathLineEdit, row, 1) - browseButton = widgets.browseFileButton(openFolder=True) - gridLayout.addWidget(browseButton, row, 2) - browseButton.sigPathSelected.connect( - partial(self.updateFolderPath, lineEdit=self.folderPathLineEdit) - ) - self.folderPathLineEdit.textChanged.connect(self.srcFolderPathChanged) - - row += 1 - label = QLabel("Destination folder: ") - gridLayout.addWidget(label, row, 0) - self.dstfolderPathLineEdit = widgets.ElidingLineEdit() - gridLayout.addWidget(self.dstfolderPathLineEdit, row, 1) - browseButton = widgets.browseFileButton(openFolder=True) - gridLayout.addWidget(browseButton, row, 2) - browseButton.sigPathSelected.connect(self.dstfolderPathLineEdit.setText) - - row += 1 - label = QLabel("Channel(s) name: ") - gridLayout.addWidget(label, row, 0) - self.channelNamesLineEdit = widgets.alphaNumericLineEdit(additionalChars=" ,") - gridLayout.addWidget(self.channelNamesLineEdit, row, 1) - checkButton = widgets.TestPushButton("Check") - gridLayout.addWidget(checkButton, row, 3) - checkButton.clicked.connect(self.checkChannelNames) - checkButton.setDisabled(True) - self.checkButton = checkButton - infoButton = widgets.infoPushButton() - gridLayout.addWidget(infoButton, row, 2) - infoButton.clicked.connect(self.showInfoChannelName) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - gridLayout.setColumnStretch(0, 0) - gridLayout.setColumnStretch(1, 1) - gridLayout.setColumnStretch(2, 0) - gridLayout.setColumnStretch(3, 0) - - mainLayout.addLayout(gridLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def fileStructureChanged(self, text): - self.checkButton.setDisabled(not "channels separated" in text) - - def checkChannelNames(self, checked=False): - proceed = self.validate() - if not proceed: - return - - src_folderpath = self.folderPath() - channel_names = self.channelNames() - extension = os.listdir(src_folderpath)[0].split(".")[-1] - basenames = io.move_separate_channels_tiffs_to_pos_folders( - src_folderpath, channel_names, get_only_basenames=True, extension=extension - ) - pos_folders_texts = [] - for p, basename in enumerate(basenames): - pos_folders_texts.append(f"Position_{p + 1}: {basename}") - - pos_folders_html_list = html_utils.to_list(pos_folders_texts, ordered=True) - text = html_utils.paragraph( - "The following Position folders will be created based on the provided channel names:
    " - f"{pos_folders_html_list}" - ) - msg = widgets.myMessageBox(wrapText=False) - msg.information(self, "Position folders", text) - - def srcFolderPathChanged(self, text): - if self.dstfolderPathLineEdit.text(): - return - - folderPath = self.folderPathLineEdit.text() - self.dstfolderPathLineEdit.setText(folderPath) - - def showInfoFileStructure(self): - txt = html_utils.paragraph(""" - Select whether the microscopy files contains multiple "series".

    - This typically depends on how you acquired the images at the - microscope, i.e., you generated multiple microscopy files - (e.g., snapshots), or you setup automatic acquisition of multiple - positions. - """) - msg = widgets.myMessageBox(wrapText=False) - msg.information(self, "Files structure info", txt) - - def showInfoChannelName(self): - txt = html_utils.paragraph(""" - Enter the channels name. Separate multiple channels with a comma.

    - The channel names will be used to name the individual TIFF files - (one for each channel).

    - If multiple channels are embedded in the microscopy file, make sure that you write the channels in the right order.
    - If you are unsure, open the file in Fiji first - and check the order of channels.

    - If the channels are already separated, make sure to write the - full channel name as it appears in the file, including capitalization and spaces.
    - For example, if the files are named "pos1_ch1.tif", "pos1_ch2.tif", etc., the channels names should be "ch1, ch2".

    - After providing the channel names, you can check that they are correct by clicking on the "Check" button next to the channel names field.
    - The number of Positions that will be created will be displayed alongside the basename. - """) - msg = widgets.myMessageBox(wrapText=False) - msg.information(self, "Files structure info", txt) - - def updateFolderPath(self, path, lineEdit=""): - for file in os.listdir(path): - if not is_alphanumeric_filename(file): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - f""" - The filename {file} contains invalid - characters.

    - Valid characters are letters, numbers, spaces, underscores - and dashes.

    - Please rename the file and try again.

    - Thank you for your patience! - """ - ) - msg.critical(self, "Invalid filename", txt, path_to_browse=path) - lineEdit.setText("") - return - - lineEdit.setText(path) - - def warnPathEmpty(self, path_name): - txt = html_utils.paragraph(f""" - {path_name} cannot be empty. - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Empty folder path", txt) - - def warnSelectedPathDoesNotExist(self, path): - txt = html_utils.paragraph(""" - The selected path does not exist.

    - Selected path: - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Folder path does not exist", txt, commands=(path,)) - - def warnSelectedPathNotAFolder(self, path): - txt = html_utils.paragraph(""" - The selected path is not a folder.

    - Selected path: - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Selected path not a folder", txt, commands=(path,)) - - def warnMultipleExtensionsPresent(self, path, extensions): - txt = html_utils.paragraph(f""" - The selected path contains files with different extensions. -

    - Extensions present: {extensions}

    - Please, make sure that all the files in the folder have the same - extension before proceeding.

    - Selected path: - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Multiple file extensions detected", txt, commands=(path,)) - - def warnChannelNamesEmpty(self): - txt = html_utils.paragraph(""" - Channel(s) name cannot be empty. - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Empty channel name", txt) - - def validate(self): - path = self.folderPath() - dst_path = self.dstfolderPathLineEdit.text() - paths = { - "Source folder": path, - "Destination folder": dst_path, - } - for _path_name, _path in paths.items(): - if not _path: - self.warnPathEmpty(_path_name) - return False - - if not os.path.exists(_path): - self.warnSelectedPathDoesNotExist(_path) - return False - - if not os.path.isdir(_path): - self.warnSelectedPathNotAFolder(_path) - return False - - files = myutils.listdir(path) - extensions = set([os.path.splitext(file)[1] for file in files]) - if len(extensions) > 1: - self.warnMultipleExtensionsPresent(path, extensions) - return False - - if not self.channelNamesLineEdit.text(): - self.warnChannelNamesEmpty() - return False - - return True - - def folderPath(self): - return self.folderPathLineEdit.text() - - def channelNames(self): - channel_names = self.channelNamesLineEdit.text().split(",") - channel_names = [ch.strip() for ch in channel_names] - return channel_names - - def ok_cb(self): - proceed = self.validate() - if not proceed: - return - - self.selectedFolderPath = self.folderPath() - self.filesStructure = self.filesStructureCombobox.currentText() - is_multiple_files = self.filesStructure.find("separated") != -1 - is_separate_channels = "channels separated" in self.filesStructure - dst_folderpath = self.dstfolderPathLineEdit.text() - self.init_macro_args = ( - self.folderPath(), - is_multiple_files, - is_separate_channels, - dst_folderpath, - self.channelNames(), - ) - self.cancel = False - self.close() - - -class ImageJRoisToSegmManager(QBaseDialog): - def __init__( - self, - rois_filepath, - TZYX_shape, - addUseSamePropsForNextPosButton=False, - parent=None, - ): - import roifile - - self.cancel = True - super().__init__(parent) - - self.setWindowTitle("ROI Manager") - - mainLayout = QVBoxLayout() - - rois = roifile.roiread(rois_filepath) - self.rois = {roi.name: roi for roi in rois} - - roisNamesTreeWidget = widgets.TreeWidget() - roisNamesTreeWidget.setHeaderLabels(["ROI name", "Cell_ID"]) - roisNamesTreeWidget.header().setSectionResizeMode(QHeaderView.ResizeToContents) - # roisNamesTreeWidget.header().setStretchLastSection(False) - for r, roi in enumerate(rois): - item = widgets.TreeWidgetItem() - item.setText(0, roi.name) - item.setText(1, str(r + 1)) - roisNamesTreeWidget.addTopLevelItem(item) - roisNamesTreeWidget.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - roisNamesTreeWidget.selectAll() - mainLayout.addWidget(QLabel("Select ROIs to convert")) - mainLayout.addWidget(roisNamesTreeWidget) - self.roisNamesTreeWidget = roisNamesTreeWidget - mainLayout.addSpacing(10) - mainLayout.addWidget(widgets.QHLine()) - mainLayout.addSpacing(5) - - gridLayout = None - self.lowZspinbox = None - - SizeT, SizeZ, SizeY, SizeX = TZYX_shape - if SizeZ > 1: - gridLayout = QGridLayout() - self.lowZspinbox = widgets.SpinBox() - self.lowZspinbox.setMinimum(0) - self.lowZspinbox.setMaximum(SizeZ - 1) - - self.highZspinbox = widgets.SpinBox() - self.highZspinbox.setMinimum(0) - self.highZspinbox.setMaximum(SizeZ - 1) - self.highZspinbox.setValue(SizeZ - 1) - - gridLayout.addWidget(QLabel("Repeat 2D ROIs over z-range: "), 1, 0) - - gridLayout.addWidget(QLabel("Start z-slice"), 0, 1) - gridLayout.addWidget(self.lowZspinbox, 1, 1) - - gridLayout.addWidget(QLabel("Stop z-slice"), 0, 2) - gridLayout.addWidget(self.highZspinbox, 1, 2) - - if gridLayout is not None: - mainLayout.addLayout(gridLayout) - mainLayout.addSpacing(5) - mainLayout.addWidget(widgets.QHLine()) - mainLayout.addSpacing(10) - - self.rescaleRoisGroupbox = widgets.RescaleImageJroisGroupbox(TZYX_shape) - self.rescaleRoisGroupbox.setChecked(False) - mainLayout.addWidget(self.rescaleRoisGroupbox) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - self.useSamePropsForNextPos = False - if addUseSamePropsForNextPosButton: - useSamePropsForNextPosButton = widgets.reloadPushButton( - "Keep the same preferences for all next Positions" - ) - buttonsLayout.insertWidget(3, useSamePropsForNextPosButton) - useSamePropsForNextPosButton.clicked.connect( - self.useSamePropsForNextPosClicked - ) - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def useSamePropsForNextPosClicked(self): - self.useSamePropsForNextPos = True - self.ok_cb() - - def warnRoiSelectionEmpty(self): - txt = html_utils.paragraph(f""" - You did not select any ROI.

    - ROIs selection cannot be empty. Thank you for your patience! - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "ROIs selection empty", txt) - - def ok_cb(self): - selectedRois = self.roisNamesTreeWidget.selectedItems() - if not selectedRois: - self.useSamePropsForNextPos = False - self.warnRoiSelectionEmpty() - return - - self.IDsToRoisMapper = {} - for item in selectedRois: - roiName = item.text(0) - ID = int(item.text(1)) - self.IDsToRoisMapper[ID] = self.rois[roiName] - - numRois = self.roisNamesTreeWidget.topLevelItemCount() - self.areAllRoisSelected = len(self.IDsToRoisMapper) == numRois - - self.rescaleSizes = self.rescaleRoisGroupbox.inputOutputSizes() - self.repeatRoisZslicesRange = None - if self.lowZspinbox is not None: - self.repeatRoisZslicesRange = ( - self.lowZspinbox.value(), - self.highZspinbox.value() + 1, - ) - - self.cancel = False - self.close() - - -class ResizeUtilProps(QBaseDialog): - def __init__(self, input_path="", parent=None): - self.cancel = True - super().__init__(parent) - - self.setWindowTitle("Resize Data Properties") - - mainLayout = QVBoxLayout() - - paramsLayout = QGridLayout() - - self._input_path = input_path - - row = 0 - paramsLayout.addWidget(QLabel("Overwrite raw data: "), row, 0) - self.overwriteToggle = widgets.Toggle() - self.overwriteToggle.setChecked(True) - paramsLayout.addWidget( - self.overwriteToggle, row, 1, 1, 2, alignment=Qt.AlignCenter - ) - - row += 1 - paramsLayout.addWidget(QLabel("Folder path for resized images: "), row, 0) - self.folderPathOutControl = widgets.filePathControl( - browseFolder=True, - fileManagerTitle="Select folder where to save resized data", - elide=True, - startFolder=myutils.getMostRecentPath(), - ) - self.folderPathOutControl.setDisabled(True) - paramsLayout.addWidget(self.folderPathOutControl, row, 1, 1, 2) - - row += 1 - paramsLayout.addWidget(QLabel("Text to append to files: "), row, 0) - self.textToAppendLineEdit = widgets.alphaNumericLineEdit() - self.textToAppendLineEdit.setAlignment(Qt.AlignCenter) - self.textToAppendLineEdit.setDisabled(True) - paramsLayout.addWidget(self.textToAppendLineEdit, row, 1, 1, 2) - - row += 1 - paramsLayout.addWidget(QLabel("Resize mode: "), row, 0) - self.downScaleRadioButton = QRadioButton("Downscale") - self.upScaleRadioButton = QRadioButton("Upscale") - self.downScaleRadioButton.setChecked(True) - paramsLayout.addWidget( - self.downScaleRadioButton, row, 1, alignment=Qt.AlignCenter - ) - paramsLayout.addWidget( - self.upScaleRadioButton, row, 2, alignment=Qt.AlignCenter - ) - - row += 1 - paramsLayout.addWidget(QLabel("Resize factor: "), row, 0) - self.factorSpinbox = widgets.FloatLineEdit(allowNegative=False) - self.factorSpinbox.setMinimum(1.0) - self.factorSpinbox.setValue(2.0) - paramsLayout.addWidget(self.factorSpinbox, row, 1, 1, 2) - - paramsLayout.setColumnStretch(0, 0) - paramsLayout.setVerticalSpacing(10) - - self.overwriteToggle.toggled.connect(self.overwriteToggled) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addLayout(paramsLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - mainLayout.addStretch(1) - - # self.textToAppendLineEdit.setText(self._getDefaultTextToAppend()) - - self.setLayout(mainLayout) - - def _getDefaultTextToAppend(self): - rescale_mode = "up" if self.upScaleRadioButton.isChecked() else "down" - factor = self.factorSpinbox.value() - text = f"{rescale_mode}scaled_factor_{factor}" - return text - - def overwriteToggled(self, checked): - self.folderPathOutControl.setDisabled(checked) - self.textToAppendLineEdit.setDisabled(checked) - if checked: - text = "" - else: - text = self._getDefaultTextToAppend() - self.textToAppendLineEdit.setText(text) - - def warnFolderPathEmpty(self): - txt = html_utils.paragraph(""" - To prevent overwriting raw data the Folder path for - resized images cannot be empty. - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Empty folder path", txt) - - def warnTextToAppendEmpty(self): - txt = html_utils.paragraph(""" - To prevent overwriting raw data the text to append - cannot be empty. - """) - msg = widgets.myMessageBox(wrapText=False) - msg.warning(self, "Empty text to append", txt) - - def ok_cb(self): - self.expFolderpathOut = self.folderPathOutControl.path() - self.textToAppend = self.textToAppendLineEdit.text() - isAccidentalOverwrite = ( - not self.overwriteToggle.isChecked() - and self.expFolderpathOut == self._input_path - and not self.textToAppend - ) - if isAccidentalOverwrite: - self.warnTextToAppendEmpty() - return - - if self.textToAppend and not self.textToAppend.startswith("_"): - self.textToAppend = f"_{self.textToAppend}" - - if self.overwriteToggle.isChecked(): - self.expFolderpathOut = None - - factor = self.factorSpinbox.value() - self.resizeFactor = ( - factor if self.upScaleRadioButton.isChecked() else 1 / factor - ) - - self.cancel = False - self.close() - - -class LogoDialog(QDialog): - def __init__(self, logo_path, icon_path, parent=None): - super().__init__(parent) - - layout = QVBoxLayout() - - self.setWindowFlags(Qt.FramelessWindowHint) - # self.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint) - # self.setAttribute(Qt.WA_TranslucentBackground) - # self.setWindowIcon(QIcon(icon_path)) - - labelLogo = QLabel() - pixmapLogo = QPixmap(logo_path) - labelLogo.setPixmap(pixmapLogo) - - layout.addWidget(labelLogo) - - self.setLayout(layout) - - -class SetCustomLevelsLut(QBaseDialog): - sigLevelsChanged = Signal(object) - - def __init__( - self, - init_min_value=None, - init_max_value=None, - minimum_min_value=0, - maximum_max_value=None, - parent=None, - ): - super().__init__(parent=parent) - - self.cancel = True - - self.setWindowTitle("Custom LUT levels") - - layout = QVBoxLayout() - - self.minLevelSlider = widgets.sliderWithSpinBox( - title="Minimum", - title_loc="top", - ) - self.minLevelSlider.setMinimum(minimum_min_value) - - if init_min_value is not None: - self.minLevelSlider.setValue(init_min_value) - - layout.addWidget(self.minLevelSlider) - - self.maxLevelSlider = widgets.sliderWithSpinBox( - title="Maximum", - title_loc="top", - ) - self.maxLevelSlider.setMinimum(minimum_min_value) - if init_max_value is not None: - self.maxLevelSlider.setValue(init_max_value) - - if maximum_max_value is not None: - self.maxLevelSlider.setMaximum(maximum_max_value) - self.minLevelSlider.setMaximum(maximum_max_value) - - layout.addWidget(self.maxLevelSlider) - - self.minLevelSlider.sigValueChange.connect(self.emitLevelsChanged) - self.maxLevelSlider.sigValueChange.connect(self.emitLevelsChanged) - - buttonsLayout = widgets.CancelOkButtonsLayout() - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - layout.addSpacing(20) - layout.addLayout(buttonsLayout) - - self.setLayout(layout) - - def sizeHint(self): - heightHint = super().sizeHint().height() - widthHint = super().sizeHint().width() * 2 - return QSize(widthHint, heightHint) - - def levels(self): - levels = (self.minLevelSlider.value(), self.maxLevelSlider.value()) - return levels - - def emitLevelsChanged(self, value): - self.sigLevelsChanged.emit(self.levels()) - - def ok_cb(self): - self.cancel = False - self.selectedLevels = self.levels() - self.close() - - -class FucciPreprocessDialog(FunctionParamsDialog): - def __init__( - self, - channel_names, - df_metadata=None, - parent=None, - ): - - from cellacdc.preprocess import fucci_filter - - params_argspecs = myutils.get_function_argspec(fucci_filter) - - super().__init__( - params_argspecs, - function_name="FUCCI pre-processing", - df_metadata=df_metadata, - parent=parent, - ) - - channelNamesLayout = QGridLayout() - - row = 0 - label = QLabel("First channel name: ") - channelNamesLayout.addWidget(label, row, 0, alignment=Qt.AlignLeft) - self.firstChNameWidget = QComboBox() - self.firstChNameWidget.addItems(channel_names) - channelNamesLayout.addWidget(self.firstChNameWidget, row, 1) - - row += 1 - label = QLabel("Second channel name: ") - channelNamesLayout.addWidget(label, row, 0, alignment=Qt.AlignLeft) - self.secondChNameWidget = QComboBox() - self.secondChNameWidget.addItems(channel_names) - self.secondChNameWidget.setCurrentText(list(channel_names)[1]) - channelNamesLayout.addWidget(self.secondChNameWidget, row, 1) - - channelNamesLayout.setColumnStretch(0, 0) - channelNamesLayout.setColumnStretch(1, 1) - - self.mainLayout.insertLayout(0, channelNamesLayout) - self.mainLayout.insertWidget(1, widgets.QHLine()) - - def ok_cb(self): - self.firstChannelName = self.firstChNameWidget.currentText() - self.secondChannelName = self.secondChNameWidget.currentText() - super().ok_cb() - - -class ViewCcaTableWindow(pdDataFrameWidget): - sigUpdateCcaTable = Signal(object) - - def __init__(self, df, parent=None): - super().__init__(df, parent=parent) - - updateTableButton = widgets.reloadPushButton("Update table with visible IDs...") - buttonsLayout = QHBoxLayout() - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(updateTableButton) - - self._layout.insertLayout(0, buttonsLayout) - - updateTableButton.clicked.connect(self.emitUpdateCcaTable) - - def emitUpdateCcaTable(self): - self.sigUpdateCcaTable.emit(self) - - -class ObjectCountDialog(QBaseDialog): - sigShowEvent = Signal() - sigUpdateCounts = Signal() - - def __init__( - self, - categoryCountMapper: dict, - parent=None, - data: list["load.loadData"] | None = None, - ): - super().__init__(parent=parent) - self.setWindowTitle("Object count") - - self.cancel = False - mainLayout = QVBoxLayout() - - cancelOkLayout = widgets.CancelOkButtonsLayout() - cancelOkLayout.okButton.clicked.connect(self.ok_cb) - cancelOkLayout.cancelButton.clicked.connect(self.close) - - self.data = data - if data is not None: - saveCountsButton = widgets.savePushButton("Export counts to CSV table") - saveCountsButton.clicked.connect(self.saveCounts) - cancelOkLayout.insertWidget(3, saveCountsButton) - - updateCountsButton = widgets.reloadPushButton("Update counts") - cancelOkLayout.insertWidget(3, updateCountsButton) - updateCountsButton.clicked.connect(self.emitUpdateCounts) - - mainLayout.addWidget( - QLabel(html_utils.paragraph("Object count
    ", font_size="18px")), - alignment=Qt.AlignLeft, - ) - self.showHideButtons = [] - self.categoryLabelMapper = {} - for category, count in categoryCountMapper.items(): - categoryLayout = QHBoxLayout() - categoryLayout.addSpacing(10) - catText = html_utils.paragraph(f"
    {category}
    ", font_size="13px") - catLabel = QLabel(catText) - categoryLayout.addWidget(catLabel) - categoryLayout.addStretch(1) - - countText = html_utils.paragraph(f"
    {count}
    ", font_size="13px") - countLabel = QLabel(countText) - categoryLayout.addWidget(countLabel) - - self.categoryLabelMapper[category] = countLabel - - showHideButton = widgets.showDetailsButton(txt="") - showHideButton.setChecked(True) - showHideButton.sigToggled.connect( - partial(self.showHideCount, labels=(catLabel, countLabel)) - ) - showHideButton.setToolTip(f'Show/hide "{category}" count') - categoryLayout.addSpacing(10) - categoryLayout.addWidget(showHideButton) - showHideButton.category = category - - self.showHideButtons.append(showHideButton) - - categoryLayout.setStretch(0, 0) - categoryLayout.setStretch(1, 0) - categoryLayout.setStretch(3, 0) - - mainLayout.addLayout(categoryLayout) - mainLayout.addWidget(widgets.QHLine()) - - mainLayout.addSpacing(10) - - infoLayout = QHBoxLayout() - self.livePreviewCheckbox = QCheckBox("Live preview") - self.livePreviewCheckbox.setChecked(True) - infoLayout.addWidget(self.livePreviewCheckbox) - infoLayout.addStretch(1) - self.warnLabel = QLabel("") - infoLayout.addWidget(self.warnLabel) - self.livePreviewCheckbox.toggled.connect(self.updateWarnLabel) - mainLayout.addLayout(infoLayout) - - mainLayout.addSpacing(30) - mainLayout.addStretch(1) - mainLayout.addLayout(cancelOkLayout) - - self.setLayout(mainLayout) - - def saveCounts(self, checked=False): - categories = self.activeCategories() - for posData in self.data: - countMapper = posData.countObjectsInSegm(categories) - countMapper.pop("In current frame", None) - df_count_endname = posData.saveObjCounts(countMapper) - - txt = html_utils.paragraph(f""" - Done!

    - Objects count table saved in every loaded Position folder
    - as a CSV file ending with {df_count_endname} - """) - msg = widgets.myMessageBox(wrapText=False) - msg.information(self, "Objects count saved", txt) - - def updateWarnLabel(self, checked): - if not checked: - self.warnLabel.setText( - html_utils.paragraph( - "WARNING: without live preview, counts are not updated", - font_color="red", - ) - ) - else: - self.warnLabel.setText("") - - def emitUpdateCounts(self): - self.sigUpdateCounts.emit() - - def activeCategories(self) -> List[str]: - activeCategories = [] - for showHideButton in self.showHideButtons: - if not showHideButton.isChecked(): - continue - activeCategories.append(showHideButton.category) - - return activeCategories - - def showHideCount(self, checked, labels): - for label in labels: - label.setVisible(checked) - - QTimer.singleShot(100, self.resizeToHeightHint) - - def updateCounts(self, categoryCountMapper): - for category, count in categoryCountMapper.items(): - countLabel = self.categoryLabelMapper[category] - countText = html_utils.paragraph(f"
    {count}
    ", font_size="13px") - countLabel.setText(countText) - - def resizeToHeightHint(self): - heightHint = self.sizeHint().height() - self.resize(self.width(), heightHint) - - def showEvent(self, event): - widthHint = self.sizeHint().width() - self.resize(int(widthHint * 1.5), self.height()) - self.sigShowEvent.emit() - - def ok_cb(self): - self.cancel = False - self.close() - - -class PreProcessRecipeDialog(QBaseDialog): - sigApplyImage = Signal(object) - sigApplyZstack = Signal(object) - sigApplyAllFrames = Signal(object) - sigApplyAllPos = Signal(object) - sigPreviewToggled = Signal(bool) - sigValuesChanged = Signal(list) - sigSavePreprocData = Signal(object) - sigClose = Signal(object) - - def __init__( - self, - isTimelapse=False, - isZstack=False, - isMultiPos=False, - df_metadata=None, - addApplyButton=False, - parent=None, - hideOnClosing=False, - ): - super().__init__(parent=parent) - - self.setWindowTitle("Pre-processing recipe") - - self.cancel = True - self.hideOnClosing = hideOnClosing - - mainLayout = QVBoxLayout() - - keepInputDataTypeLayout = QHBoxLayout() - self.keepInputDataTypeToggle = widgets.Toggle() - self.keepInputDataTypeToggle.setChecked(True) - self.keepInputDataTypeToggle.toggled.connect(self.emitValuesChanged) - - keepInputDataTypeLayout.addStretch(1) - keepInputDataTypeLayout.addWidget(QLabel("Keep input data type: ")) - keepInputDataTypeLayout.addWidget(self.keepInputDataTypeToggle) - keepInputDataTypeInfoButton = widgets.infoPushButton() - keepInputDataTypeLayout.addWidget(keepInputDataTypeInfoButton) - keepInputDataTypeInfoButton.clicked.connect(self.showInfoKeepInputDataType) - self.keepInputDataTypeLayout = keepInputDataTypeLayout - - self.preProcessParamsWidget = PreProcessParamsWidget( - df_metadata=df_metadata, addApplyButton=addApplyButton, parent=self - ) - self.preProcessParamsWidget.groupbox.setCheckable(False) - - buttonsLayout = QGridLayout() # self.preProcessParamsWidget.buttonsLayout - self.buttonsLayout = buttonsLayout - self.previewCheckbox = QCheckBox("Preview") - buttonsLayout.addWidget(self.previewCheckbox, 0, 0) - - # Relocate buttons of PreProcessParamsWidget to this dialog - pPPWBL = self.preProcessParamsWidget.buttonsLayout - loadRecipeButtIdx = pPPWBL.indexOf(self.preProcessParamsWidget.loadRecipeButton) - self.loadRecipeButton = pPPWBL.takeAt(loadRecipeButtIdx).widget() - buttonsLayout.addWidget(self.loadRecipeButton, 0, 1) - - saveRecipeButtIdx = pPPWBL.indexOf(self.preProcessParamsWidget.saveRecipeButton) - self.saveRecipeButton = pPPWBL.takeAt(saveRecipeButtIdx).widget() - buttonsLayout.addWidget(self.saveRecipeButton, 1, 1) - - loadLastRecipeButtIdx = pPPWBL.indexOf( - self.preProcessParamsWidget.loadLastRecipeButton - ) - self.loadLastRecipeButton = pPPWBL.takeAt(loadLastRecipeButtIdx).widget() - buttonsLayout.addWidget(self.loadLastRecipeButton, 1, 0) - - self.loadLastRecipeButton.hide() - - # self.cancelButton = widgets.cancelPushButton('Cancel') - # buttonsLayout.insertWidget(2, self.cancelButton) - # buttonsLayout.insertSpacing(3, 20) - - self.allButtons = [ - self.previewCheckbox, - self.loadRecipeButton, - self.saveRecipeButton, - ] - col = 3 - row = 0 - self.applyCurrentFrameButton = widgets.okPushButton("Apply to displayed image") - buttonsLayout.addWidget(self.applyCurrentFrameButton, row, col) - self.applyCurrentFrameButton.clicked.connect( - partial(self.apply, signal=self.sigApplyImage) - ) - self.allButtons.append(self.applyCurrentFrameButton) - - infoLayout = QHBoxLayout() - buttonsHeight = self.applyCurrentFrameButton.sizeHint().height() - self.loadingCircle = widgets.LoadingCircleAnimation(size=buttonsHeight) - sp = self.loadingCircle.sizePolicy() - sp.setRetainSizeWhenHidden(True) - self.loadingCircle.setSizePolicy(sp) - self.loadingCircle.setVisible(False) - infoLayout.addWidget(self.loadingCircle) - - self.infoLabel = QLabel("(Feel free to use Cell-ACDC while waiting)") - sp = self.infoLabel.sizePolicy() - sp.setRetainSizeWhenHidden(True) - self.infoLabel.setSizePolicy(sp) - self.infoLabel.hide() - infoLayout.addWidget(self.infoLabel) - - buttonsLayout.addLayout( - infoLayout, row + 1, 0, 3, 2, alignment=Qt.AlignBottom | Qt.AlignLeft - ) - - if isZstack: - row += 1 - self.applyAllZslicesButton = widgets.threeDPushButton( - "Apply to all z-slices of current image" - ) - buttonsLayout.addWidget(self.applyAllZslicesButton, row, col) - self.applyAllZslicesButton.clicked.connect(self.applyAllZslices) - self.allButtons.append(self.applyAllZslicesButton) - if isTimelapse: - row += 1 - self.applyAllFramesButton = widgets.futurePushButton("Apply to all frames") - buttonsLayout.addWidget(self.applyAllFramesButton, row, col) - self.applyAllFramesButton.clicked.connect(self.applyAllFrames) - self.allButtons.append(self.applyAllFramesButton) - if isMultiPos: - row += 1 - self.applyAllPosButton = widgets.futurePushButton("Apply to all Positions") - buttonsLayout.addWidget(self.applyAllPosButton, row, col) - self.applyAllPosButton.clicked.connect( - partial(self.apply, signal=self.sigApplyAllPos) - ) - self.allButtons.append(self.applyAllPosButton) - - row += 1 - self.savePreprocButton = widgets.savePushButton("Save pre-processed data...") - buttonsLayout.addWidget(self.savePreprocButton, row, col) - - self.allButtons.append(self.savePreprocButton) - self.savePreprocButton.clicked.connect(self.emitSignalSavePreprocData) - - self.previewCheckbox.toggled.connect(self.emitSigPreviewToggled) - self.preProcessParamsWidget.sigValuesChanged.connect(self.emitValuesChanged) - - # self.cancelButton.clicked.connect(self.close) - - mainLayout.addLayout(keepInputDataTypeLayout) - mainLayout.addSpacing(20) - mainLayout.addWidget(self.preProcessParamsWidget) - mainLayout.addLayout(buttonsLayout) - self.mainLayout = mainLayout - - self.setLayout(mainLayout) - - def applyAllZslices(self, checked=False): - # Preview needs to be turned off because we are computing on every - # z-slice - self.previewCheckbox.setChecked(False) - self.apply(signal=self.sigApplyZstack) - - def applyAllFrames(self, checked=False): - # Preview needs to be turned off because we are computing on all frames - self.previewCheckbox.setChecked(False) - self.apply(signal=self.sigApplyAllFrames) - - def emitSigPreviewToggled(self): - self.sigPreviewToggled.emit(self.previewCheckbox.isChecked()) - - def showInfoKeepInputDataType(self): - txt = html_utils.paragraph(""" - If checked, the data type of the pre-processed data will be - the same as the input data type.

    - This is useful to avoid saving the pre-processed data as - floating-point numbers (e.g., 32-bit float) which might - increase the file size.

    - We recommend keeping this option checked. - """) - msg = widgets.myMessageBox(wrapText=False) - msg.information(self, "Keep input data type", txt) - - def emitSignalSavePreprocData(self): - self.sigSavePreprocData.emit(self) - - def emitValuesChanged(self): - recipe = self.recipe(warn=False) - if recipe is None: - return - - self.sigValuesChanged.emit(recipe) - - def setDisabled(self, disabled: bool): - self.preProcessParamsWidget.setDisabled(disabled) - self.loadingCircle.setVisible(disabled) - self.infoLabel.setVisible(disabled) - for button in self.allButtons: - try: - button.setDisabled(disabled) - except RuntimeError as e: - printl(traceback.format_exc()) - printl(f"Error: {e}") - printl(f"Button: {button}") - - def apply(self, checked=False, signal: Signal = None): - recipe = self.recipe() - if recipe is None: - return - - if signal is not None: - signal.emit(recipe) - - if self.hideOnClosing: - self.setDisabled(True) - self.infoLabel.setText( - f"{self.sender().text().replace('Apply', 'Applying')}...
    " - "(Feel free to use Cell-ACDC while waiting)" - ) - else: - self.ok_cb() - - def appliedFinished(self): - self.setDisabled(False) - - def recipe(self, warn=True): - recipe = self.preProcessParamsWidget.recipe(warn=warn) - if recipe is None: - return - - for step in recipe: - step["keep_input_data_type"] = self.keepInputDataTypeToggle.isChecked() - return recipe - - def recipeConfigPars(self): - return self.preProcessParamsWidget.recipeConfigPars("acdc") - - def ok_cb(self): - if self.hideOnClosing: - self.hide() - return - - self.cancel = False - self.close() - - def close(self): - super().close() - self.sigClose.emit(self) - - -class PreProcessRecipeDialogUtil(PreProcessRecipeDialog): - def __init__( - self, - channel_names: Iterable[str], - df_metadata=None, - parent=None, - ): - self.cancel = True - - super().__init__( - isTimelapse=False, - isZstack=False, - isMultiPos=False, - addApplyButton=False, - df_metadata=df_metadata, - parent=parent, - hideOnClosing=False, - ) - - self.listSelector = widgets.listWidget( - isMultipleSelection=True, minimizeHeight=True - ) - self.listSelector.addItems(channel_names) - self.listSelector.setCurrentRow(0) - - self.mainLayout.insertWidget(0, self.listSelector) - self.mainLayout.insertWidget(0, QLabel("Select channel(s) to pre-process:")) - self.mainLayout.insertSpacing(2, 10) - self.mainLayout.insertWidget(2, widgets.QHLine()) - - self.savePreprocButton.hide() - self.previewCheckbox.hide() - self.applyCurrentFrameButton.setText("Ok") - - buttonsLayout = self.preProcessParamsWidget.buttonsLayout - - saveRecipeButtonIndex = buttonsLayout.indexOf( - self.preProcessParamsWidget.saveRecipeButton - ) - - if saveRecipeButtonIndex == -1: - return - - saveRecipeButtonItem = buttonsLayout.takeAt(saveRecipeButtonIndex) - - buttonsLayout.addItem(saveRecipeButtonItem, 0, 2) - - def warnChannelSelectionEmpty(self): - txt = html_utils.paragraph(""" - You did not select any channel.

    - Channel selection cannot be empty.

    - Thank you for your patience! - """) - - def ok_cb(self): - selectedChannelItems = self.listSelector.selectedItems() - if not selectedChannelItems: - self.warnChannelSelectionEmpty() - - recipe = self.recipe() - if recipe is None: - return - - self.selectedRecipe = recipe - self.selectedChannels = [item.text() for item in selectedChannelItems] - - self.cancel = False - self.close() - - -# class ComboDelegate(QStyledItemDelegate): -# def __init__(self, bad_values, parent=None): -# super().__init__(parent) -# self.bad_values = bad_values - -# def paint(self, painter, option, index): -# text = index.data() -# if text in self.bad_values: -# option.palette.setColor(option.palette.Text, QColor("red")) -# super().paint(painter, option, index) - - -class CombineChannelsSetupDialog(PreProcessRecipeDialog): - sigApplyImage = Signal(dict, bool, str) - sigApplyZstack = Signal(dict, bool, str) - sigApplyAllFrames = Signal(dict, bool, str) - sigApplyAllPos = Signal(dict, bool, str) - sigValuesChanged = Signal() - sigSaveAsSegmCheckboxToggled = Signal(bool) - - # sigApplyAllZslices = Signal(dict, bool, str) - # sigApplyAllFramesZslices = Signal(dict, bool, str) - - def __init__( - self, - channel_names, - df_metadata=None, - parent=None, - hideOnClosing=False, - isTimelapse=False, - isZstack=False, - isMultiPos=False, - ): - - self.combineChannelsWidget = CombineChannelsWidget(channel_names, parent=self) - self.warnExistingRecipeFile = self.combineChannelsWidget.warnExistingRecipeFile - self.communicateSavingRecipeFinished = ( - self.combineChannelsWidget.communicateSavingRecipeFinished - ) - self.saveRecipeUI = self.combineChannelsWidget.saveRecipeUI - self.selectRecipeFilepath = self.combineChannelsWidget.selectRecipeFilepath - - super().__init__( - isTimelapse=isTimelapse, - isZstack=isZstack, - isMultiPos=isMultiPos, - df_metadata=df_metadata, - parent=parent, - hideOnClosing=hideOnClosing, - ) - - self.combineChannelsWidget.sigValuesChangedCombineChannels.connect( - self.emitValuesChangedSteps - ) - - self.segm_blinked = False - self.validFormula = True # allow empty formula - self.forbiddenChannels = set() # channels that cannot be combined - - self.mainLayout.setSpacing(4) - - self.mainLayout.insertWidget(2, self.combineChannelsWidget) - self.combineChannelsWidget.groupbox.setCheckable(False) - self.combineChannelsWidget.groupbox.setTitle( - "Combine and manipulate channels and/or segmentation files" - ) - - self.formulaEditWidget = FormulaEditWidget(parent=self) - self._updateFormulaVariableNames() - self.formulaEditWidget.sigFormulaChanged.connect(self.formulaChanged) - self.formulaEditWidget.setToolTip( - 'Enter a formula to combine the channels. For example "img1 + img2 * 0.5"' - ) - self.mainLayout.insertWidget(3, self.formulaEditWidget) - - buttonsLayoutSaveGroup = QGridLayout() - - row = 0 - col = 0 - loadRecipeButton = widgets.OpenFilePushButton("Load saved recipe") - self.loadRecipeButtonComb = loadRecipeButton - buttonsLayoutSaveGroup.addWidget(loadRecipeButton, row, col) - self.loadRecipeButtonComb.clicked.connect(self.selectAndLoadRecipe) - - col += 1 - saveRecipeButton = widgets.savePushButton("Save current recipe") - self.saveRecipeButtonComb = saveRecipeButton - buttonsLayoutSaveGroup.addWidget(saveRecipeButton, row, col) - saveRecipeButton.clicked.connect(self.saveRecipe) - saveRecipeButton.setToolTip( - "Save the current recipe to a file\n" - f"Location: {combine_channels_recipes_path}" - ) - - col += 1 - loadLastRecipeButton = widgets.reloadPushButton("Load last recipe") - self.loadLastRecipeButtonComb = loadLastRecipeButton - buttonsLayoutSaveGroup.addWidget(loadLastRecipeButton, row, col) - self.mainLayout.addLayout(buttonsLayoutSaveGroup) - loadLastRecipeButton.clicked.connect(self.loadLastRecipe) - self.setLoadLastRecipe() - - loadLastRecipeButton.setContextMenuPolicy( - Qt.ContextMenuPolicy.CustomContextMenu - ) - loadLastRecipeButton.customContextMenuRequested.connect( - self._showLoadRecipeContextMenu - ) - - self.cancel = True - - self.setWindowTitle("Combine and manipulate channels and/or segmentation files") - self.preProcessParamsWidget.hide() - self.mainLayout.removeWidget(self.preProcessParamsWidget) - - self.savePreprocButton.setText("Save combined data...") - - tooltip = ( - "Save as a segmentation file, for example " - "when combining a binary mask with a segmentation mask." - ) - label = QLabel("Save as segmentation:") - self.saveAsSegmlabel = label - label.setToolTip(tooltip) - self.saveAsSegmCheckbox = widgets.Toggle() - self.saveAsSegmCheckbox.setToolTip(tooltip) - self.saveAsSegmCheckbox.setChecked(False) - self.saveAsSegmCheckbox.setEnabled(False) - self.saveAsSegmCheckbox.toggled.connect(self.emitSaveAsSegmCheckboxToggled) - - self.keepInputDataTypeLayout.insertWidget(0, label) - self.keepInputDataTypeLayout.insertWidget(1, self.saveAsSegmCheckbox) - - def setLoadLastRecipe(self): - filepath = self._lastRecipePath() - if not os.path.exists(filepath): - self.loadLastRecipeButtonComb.setEnabled(False) - - def returLoadSecondLastRecipe(self): - filepath = self._secondLastRecipePath() - if not os.path.exists(filepath): - return False - return True - - def _showLoadRecipeContextMenu(self, pos): - menu = QMenu(self) - action = menu.addAction("Load recipe from before the last one") - action.triggered.connect(self.loadPreviousRecipe) - action.setEnabled(self.returLoadSecondLastRecipe()) - menu.exec(self.loadLastRecipeButtonComb.mapToGlobal(pos)) - - def loadPreviousRecipe(self): - filepath = self._secondLastRecipePath() - if not os.path.exists(filepath): - return - - self.loadRecipe(filepath) - - def loadLastRecipe(self): - filepath = self._lastRecipePath() - if not os.path.exists(filepath): - return - - self.loadRecipe(filepath) - - def saveLastRecipe(self): - os.makedirs(combine_channels_recipes_path, exist_ok=True) - filepath = self._lastRecipePath() - - same = False - if os.path.exists(filepath): - steps_curr = self._getSaveRecipyDict() - with open(filepath, "r") as f: - steps_prev = json.load(f) - same = self._recipesMatch(steps_curr, steps_prev) - - if same: - return - - if os.path.exists(filepath): - new_filename = self._secondLastRecipePath() - if os.path.exists(new_filename): - os.remove(new_filename) - os.rename(filepath, new_filename) - self.saveRecipe(filepath=filepath) - - def _recipesMatch(self, steps_curr, steps_prev): - # Normalize current dict to strings for comparison with JSON-loaded dict - def normalize(d): - return {str(k): str(v) for k, v in d.items()} - - for raw_key in steps_curr: - key = str(raw_key) - if key not in steps_prev: - return False - if key in ("formula", "keep_input_data_type", "save_as_segm"): - if str(steps_curr[raw_key]) != str(steps_prev[key]): - return False - else: - step_dict = normalize(steps_curr[raw_key]) - step_dict_prev = steps_prev[key] - for key2, val2 in step_dict.items(): - if key2 not in step_dict_prev: - return False - if val2 != str(step_dict_prev[key2]): - return False - return True - - def _lastRecipePath(self): - return os.path.join( - combine_channels_recipes_path, ".last_combine_channels_recipe.json" - ) - - def _secondLastRecipePath(self): - return os.path.join( - combine_channels_recipes_path, ".previous_combine_channels_recipe.json" - ) - - def _getSaveRecipyDict(self): - steps = self.combineChannelsWidget.steps() # already returns a copy - formula = self.formulaEditWidget.text() - steps["formula"] = formula - steps["keep_input_data_type"] = self.keepInputDataTypeToggle.isChecked() - steps["save_as_segm"] = self.saveAsSegmCheckbox.isChecked() - return steps - - def saveRecipe(self, dummy=None, filepath=None): - os.makedirs(combine_channels_recipes_path, exist_ok=True) - - filepath_provided = filepath is not None - if not filepath_provided: - folder_content = myutils.listdir(combine_channels_recipes_path) - num_recipes = len(folder_content) - default_text = f"{num_recipes + 1}" - proceed, filepath = self.saveRecipeUI( - combine_channels_recipes_path, - ".json", - "Save recipe", - "combine_channels_recipe", - "Insert a filename for the recipe:", - default_text, - ) - - if not proceed: - return - - steps = self._getSaveRecipyDict() - - with open(filepath, "w") as f: - json.dump(steps, f, indent=2) - - if not filepath_provided: - self.communicateSavingRecipeFinished(filepath) - - def selectAndLoadRecipe(self): - filepath = self.selectRecipeFilepath( - combine_channels_recipes_path, "combine_channels_recipe", "JSON", "json" - ) - if filepath is None: - return - - self.loadRecipe(filepath) - - def loadRecipe(self, filepath): - with open(filepath, "r") as f: - recipe = json.load(f) - - recipe = dict(sorted(recipe.items())) - keys_used = set() - for key, value in recipe.items(): - if key == "formula": - formula = value - continue - if key == "keep_input_data_type": - self.keepInputDataTypeToggle.setChecked(value) - continue - if key == "save_as_segm": - self.saveAsSegmCheckbox.setChecked(value) - continue - - name = value["name"] - channel = value["channel"] - binarize = value["binarize"] - min_val = float(value["min_val"]) - max_val = float(value["max_val"]) - key = int(key) - stepWidgetsNum = len(self.combineChannelsWidget.stepsWidgets) - if key > stepWidgetsNum: - self.combineChannelsWidget.addStep() - - stepWidgets = self.combineChannelsWidget.stepsWidgets[key] - idx = stepWidgets["selector"].findText(channel) - if idx == -1: - stepWidgets["selector"].addItem(channel) - # stepWidgets['selector'].forbiddenItems.add(channel) - blinker = qutils.QControlBlink(stepWidgets["selector"], qparent=self) - blinker.start() - stepWidgets["selector"].blinker = blinker - self.forbiddenChannels.add(channel) - - stepWidgets["selector"].setCurrentText(channel) - stepWidgets["name_edit"].setText(name) - stepWidgets["binarize"].setCurrentText(binarize) - stepWidgets["minValueSpinbox"].setValue(min_val) - stepWidgets["maxValueSpinbox"].setValue(max_val) - - keys_used.add(key) - - # remove extra steps - keys_present = set(range(1, len(self.combineChannelsWidget.stepsWidgets) + 1)) - extra_keys = keys_present - keys_used - extra_keys = list(extra_keys) - extra_keys.sort(reverse=True) - for key in extra_keys: - self.combineChannelsWidget.removeStep(step_n=key) - # updates key dynamically so I have to rely that missing indx are always last steps - - # update formula - self.formulaEditWidget.setText(formula) - - for stepWidgets in self.combineChannelsWidget.stepsWidgets.values(): - combo = stepWidgets["selector"] - # set forbidden channels red in all steps - for i in range(combo.count()): - item = combo.itemText(i) - if item in self.forbiddenChannels: - combo.setItemData(i, QColor("red"), Qt.ForegroundRole) - - def _updateFormulaVariableNames(self): - names = [ - stepWidgets["name_edit"].text() - for stepWidgets in self.combineChannelsWidget.stepsWidgets.values() - ] - self.formulaEditWidget.setVariableNames(names) - - def formulaChanged(self, formula_str, is_valid): - self.setButtonsEnabled(is_valid) - self.validFormula = is_valid - if is_valid: - self.sigValuesChanged.emit() - - def setButtonsEnabled(self, enabled): - for i in range(self.buttonsLayout.count()): - item = self.buttonsLayout.itemAt(i) - widget = item.widget() - if widget is None: - continue - if isinstance(widget, QPushButton): - label = widget.text().lower().rstrip().lstrip() - if "apply" in label or "save" in label or "ok" in label: - if enabled: - try: - widget.setEnabled(True) - except: - pass - else: - try: - widget.setDisabled(True) - except: - pass - - def saveAsSegm(self): - return self.saveAsSegmCheckbox.isChecked() - - def emitSaveAsSegmCheckboxToggled(self): - if self.validFormula: - self.sigSaveAsSegmCheckboxToggled.emit(self.saveAsSegm()) - - def autoCheckSaveAsSegmCheckbox(self): - any_not_seg = False - for step in self.combineChannelsWidget.steps().values(): - channel = step["channel"] - if "segm" not in channel: - any_not_seg = True - break - - if any_not_seg: - self.saveAsSegmCheckbox.setChecked(False) - self.saveAsSegmCheckbox.setEnabled(False) - else: - if not self.segm_blinked: - self.saveAsSegmCheckbox.setEnabled(True) - self.blinker = qutils.QControlBlink( - self.saveAsSegmCheckbox, qparent=self - ) - self.blinker.start() - self.segm_blinked = True - - def apply(self, checked=False, signal: Signal = None): - steps = self.combineChannelsWidget.steps() - formula = self.formulaEditWidget.text() - keep_input_dtype = self.keepInputDataTypeToggle.isChecked() - if not steps or not self.validFormula: - return - - if signal is not None: - try: - signal.emit(steps, formula) - except TypeError as err: - signal.emit(steps, keep_input_dtype, formula) - - self.saveLastRecipe() - if self.hideOnClosing: - self.setDisabled(True) - self.infoLabel.setText( - f"{self.sender().text().replace('Apply', 'Applying')}...
    " - "(Feel free to use Cell-ACDC while waiting)" - ) - else: - self.ok_cb(saveLastRecipe=False) - - # Not needed anymore since now we funnel all changes to the formulaEditWidget, which then verifies the formula and - # emits a signal via formulaChangeda - # def emitValuesChanged(self): - # if not self.validFormula: - # return - # self.sigValuesChanged.emit() - - def emitValuesChangedSteps(self): - self.autoCheckSaveAsSegmCheckbox() - self._updateFormulaVariableNames() - - def ok_cb(self, dummy=None, saveLastRecipe=True): - if not self.validFormula: - return - - if saveLastRecipe: - self.saveLastRecipe() - - self.keepInputDataType = self.keepInputDataTypeToggle.isChecked() - self.selectedSteps = self.combineChannelsWidget.steps() - self.formula = self.formulaEditWidget.text() - self.cancel = False - self.close() - - -class CombineChannelsSetupDialogUtil(CombineChannelsSetupDialog): - def __init__( - self, - channel_names, - df_metadata=None, - parent=None, - ): - - super().__init__(channel_names, parent=parent, df_metadata=df_metadata) - - # add int input for number of workers - - self.mainLayout.addSpacing(20) - - qutils.hide_and_delete_layout(self.buttonsLayout) - buttonsLayout = widgets.CancelOkButtonsLayout() - self.buttonsLayout = buttonsLayout - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - self.mainLayout.addLayout(buttonsLayout) - - self.nThreadsSpinBox = QSpinBox() - self.nThreadsSpinBox.setMinimum(1) - self.nThreadsSpinBox.setValue(4) - self.nThreadsSpinBox.setToolTip("Number of threads to use for processing") - self.mainLayout.addWidget(QLabel("Number of threads:")) - self.mainLayout.addWidget(self.nThreadsSpinBox) - - -class CombineChannelsSetupDialogGUI(CombineChannelsSetupDialog): - def __init__( - self, - channel_names: Iterable[str], - df_metadata=None, - isTimelapse=False, - isZstack=False, - isMultiPos=False, - parent=None, - hideOnClosing=False, - ): - super().__init__( - channel_names, - df_metadata=df_metadata, - isTimelapse=isTimelapse, - isZstack=isZstack, - isMultiPos=isMultiPos, - parent=parent, - hideOnClosing=hideOnClosing, - ) - - # remove the preprocess buttons, we use the comb version of them - qutils.delete_widget(self.loadLastRecipeButton) - qutils.delete_widget(self.saveRecipeButton) - qutils.delete_widget(self.loadRecipeButton) - - # self.allButtons.remove(self.loadLastRecipeButton) - self.allButtons.remove(self.saveRecipeButton) - self.allButtons.remove(self.loadRecipeButton) - - self.previewCheckbox.setChecked(True) - self.saveAsSegmlabel.setText("Save and view as segmentation") - - def steps(self, return_keepInputDataType=False): - steps = self.combineChannelsWidget.steps() - formula = self.formulaEditWidget.text() - # if not return_keepInputDataType: - # return steps, formula - - keep_input_dtype = self.keepInputDataTypeToggle.isChecked() - return steps, keep_input_dtype, formula - - -class QCropTrangeTool(QBaseDialog): - sigClose = Signal() - sigTvalueChanged = Signal(int) - sigReset = Signal() - sigCrop = Signal(int, int) - - def __init__( - self, - SizeT, - cropButtonText="Apply crop", - parent=None, - addDoNotShowAgain=False, - title="Select frames range", - ): - super().__init__(parent) - - self.cancel = True - - self.setWindowFlags(Qt.Tool | Qt.WindowStaysOnTopHint) - - self.SizeT = SizeT - self.numDigits = len(str(self.SizeT)) - - self.setWindowTitle(title) - - layout = QGridLayout() - buttonsLayout = QHBoxLayout() - - self.startFrameScrollbar = widgets.sliderWithSpinBox( - spinbox_loc="left", maximum_on_label=SizeT - ) - self.startFrameScrollbar.setMaximum(SizeT, including_spinbox=True) - self.startFrameScrollbar.setMinimum(1, including_spinbox=True) - - self.endFrameScrollbar = widgets.sliderWithSpinBox( - spinbox_loc="left", maximum_on_label=SizeT - ) - self.endFrameScrollbar.setMaximum(SizeT, including_spinbox=True) - self.endFrameScrollbar.setMinimum(1, including_spinbox=True) - self.endFrameScrollbar.setValue(SizeT) - - cancelButton = widgets.cancelPushButton("Cancel") - cropButton = widgets.okPushButton(cropButtonText) - buttonsLayout.addWidget(cropButton) - buttonsLayout.addWidget(cancelButton) - - row = 0 - layout.addWidget(QLabel("Start frame n. "), row, 0, alignment=Qt.AlignRight) - layout.addWidget(self.startFrameScrollbar, row, 2) - - row += 1 - layout.setRowStretch(row, 5) - layout.addItem(QSpacerItem(10, 10), row, 0) - - row += 1 - layout.addWidget(QLabel("Stop frame n. "), row, 0, alignment=Qt.AlignRight) - layout.addWidget(self.endFrameScrollbar, row, 2) - - row += 1 - if addDoNotShowAgain: - self.doNotShowAgainCheckbox = QCheckBox("Do not ask again") - layout.addWidget( - self.doNotShowAgainCheckbox, row, 2, alignment=Qt.AlignLeft - ) - row += 1 - - layout.addItem(QSpacerItem(10, 20), row, 0) - layout.addLayout(buttonsLayout, row + 1, 2, alignment=Qt.AlignRight) - - layout.setColumnStretch(0, 0) - layout.setColumnStretch(1, 0) - layout.setColumnStretch(2, 10) - - self.setLayout(layout) - - # resetButton.clicked.connect(self.emitReset) - cropButton.clicked.connect(self.emitCrop) - cancelButton.clicked.connect(self.close) - self.startFrameScrollbar.sigValueChange.connect(self.TvalueChanged) - self.endFrameScrollbar.sigValueChange.connect(self.TvalueChanged) - - def emitReset(self): - self.sigReset.emit() - - def emitCrop(self): - self.cancel = False - low_z = self.startFrameScrollbar.value() - 1 - high_z = self.endFrameScrollbar.value() - 1 - self.sigCrop.emit(low_z, high_z) - self.close() - - def updateScrollbars(self, start_frame_i, lower_frame_i): - self.startFrameScrollbar.setValue(start_frame_i + 1) - self.endFrameScrollbar.setValue(lower_frame_i + 1) - - def TvalueChanged(self, value): - frame_i = value - 1 - self.sigTvalueChanged.emit(frame_i) - - def showEvent(self, event): - self.resize(int(self.width() * 2.0), self.height()) - - def closeEvent(self, event): - super().closeEvent(event) - self.sigClose.emit() - - -class QTreeDialog(QBaseDialog): - def __init__( - self, - items: List[Tuple[str]], - headerLabels: List[str] = None, - parent=None, - infoText="Select item", - title="Select item", - path_to_browse=None, - additional_buttons=None, - ): - self.cancel = True - super().__init__(parent) - - self.setWindowTitle(title) - - mainLayout = QVBoxLayout() - - infoLabel = QLabel(html_utils.paragraph(infoText)) - - self.treeWidget = widgets.TreeWidget() - if headerLabels is not None: - self.treeWidget.setHeaderLabels(headerLabels) - else: - self.treeWidget.setHeaderHidden(True) - - for row, texts in enumerate(items): - item = widgets.TreeWidgetItem(self.treeWidget) - for i, text in enumerate(texts): - item.setText(i, text) - self.treeWidget.addTopLevelItem(item) - - self.treeWidget.resizeColumnToContents(0) - self.treeWidget.resizeColumnToContents(1) - - # self.treeWidget.header().setStretchLastSection(False) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - if path_to_browse is not None: - browseButton = widgets.showInFileManagerButton(setDefaultText=True) - browseButton.setPathToBrowse(path_to_browse) - buttonsLayout.insertWidget(3, browseButton) - - if additional_buttons is not None: - for btn in additional_buttons: - buttonsLayout.insertWidget(3, btn) - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addWidget(infoLabel) - mainLayout.addWidget(self.treeWidget) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def show(self, block=False): - w = self.sizeHint().width() - h = self.sizeHint().height() - self.resize(int(w * 1.3), h) - super().show(block=block) - - def ok_cb(self): - self.clickedButton = self.sender() - self.cancel = False - self.selectedItem = self.treeWidget.currentItem() - self.selectedText = self.selectedItem.text(0) - self.close() - - -class SelectFoldersToAnalyse(QBaseDialog): - def __init__( - self, - parent=None, - preSelectedPaths=None, - onlyExpPaths=False, - scanFolderTree=True, - instructionsText="Select experiment folders to analyse", - askSelectPosFolders=False, - ): - super().__init__(parent) - - self.cancel = True - self.onlyExpPaths = onlyExpPaths - self.setWindowTitle("Select experiments to analyse") - self.scanTree = scanFolderTree - self.askSelectPosFolders = askSelectPosFolders - - mainLayout = QVBoxLayout() - - instructionsText = html_utils.paragraph( - f"{instructionsText}

    " - "Drag and drop folders or click on Add folder button to " - "add as many folders " - "as needed.
    ", - font_size="14px", - ) - instructionsLabel = QLabel(instructionsText) - instructionsLabel.setAlignment(Qt.AlignCenter) - - infoText = html_utils.paragraph( - "A valid folder is either a Position folder, " - "or an experiment folder (containing Position_n folders),
    " - "or any folder that contains multiple experiment folders.

    " - "In the last case, Cell-ACDC will automatically scan the entire tree of " - "sub-directories
    " - "and will add all experiments having the right folder structure.
    ", - font_size="12px", - ) - infoLabel = QLabel(infoText) - infoLabel.setAlignment(Qt.AlignCenter) - - self.listWidget = widgets.listWidget() - self.listWidget.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - if preSelectedPaths is not None: - self.listWidget.addItems(preSelectedPaths) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - delButton = widgets.delPushButton("Remove selected path(s)") - browseButton = widgets.browseFileButton( - "Add folder...", openFolder=True, start_dir=myutils.getMostRecentPath() - ) - - buttonsLayout.insertWidget(3, delButton) - buttonsLayout.insertWidget(4, browseButton) - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - browseButton.sigPathSelected.connect(self.addFolderPath) - delButton.clicked.connect(self.removePaths) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addWidget(instructionsLabel) - mainLayout.addWidget(infoLabel) - mainLayout.addWidget(self.listWidget) - - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - mainLayout.addStretch(1) - - self.setLayout(mainLayout) - - self.setAcceptDrops(True) - - self.setFont(font) - - def dragEnterEvent(self, event): - event.acceptProposedAction() - - def dropEvent(self, event): - event.setDropAction(Qt.CopyAction) - for url in event.mimeData().urls(): - dropped_path = url.toLocalFile() - if os.path.isfile(dropped_path): - dropped_path = os.path.dirname(dropped_path) - - QTimer.singleShot(50, partial(self.addFolderPath, dropped_path)) - - def pathsList(self): - return [ - self.listWidget.item(i).text().replace("\\", "/") - for i in range(self.listWidget.count()) - ] - - def expFolderToPosFoldernamesMapper(self): - expPathsPosFoldernamesMapper = defaultdict(set) - for selectedPath in self.pathsList(): - pos_foldernames = myutils.get_pos_foldernames( - selectedPath, check_if_is_sub_folder=True - ) - if not pos_foldernames: - images_path = myutils.get_images_folderpath(selectedPath) - expPathsPosFoldernamesMapper[selectedPath].add("") - else: - expPath = load.get_exp_path(selectedPath) - expPathsPosFoldernamesMapper[expPath].update(pos_foldernames) - - expPathsPosFoldernamesMapper = { - expPath: natsorted(pos_foldernames) - for expPath, pos_foldernames in expPathsPosFoldernamesMapper.items() - } - return expPathsPosFoldernamesMapper - - def ok_cb(self): - self.cancel = False - self.paths = self.pathsList() - self.selectedExpFolderToPosFoldernamesMapper = ( - self.expFolderToPosFoldernamesMapper() - ) - self.close() - - def warnNoValidPathsFound(self, selected_path): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(""" - The selected path (see below) does not contain any valid folder.

    - Please, make sure to select a Position folder, the Images folder - inside a Position folder, or any folder containing a Position folder - as a sub-directory.

    - Thank you for your patience!

    - Selected path: - """) - msg.warning( - self, - "Training workflow generated", - txt, - commands=(f"{selected_path}",), - path_to_browse=selected_path, - ) - - def warnNoValidExpPaths(self, selected_path): - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph(""" - The selected folder does - not contain any valid experiment folders. - """) - command = selected_path.replace("\\", os.sep) - command = selected_path.replace("/", os.sep) - msg.warning( - self, - "No valid folders found", - txt, - commands=(command,), - path_to_browse=selected_path, - ) - - def parse_select_from_exp_paths(self, exp_paths: dict[os.PathLike, Iterable[str]]): - if not self.askSelectPosFolders: - return list(exp_paths.keys()) - - paths = [] - for exp_path, pos_foldernames in exp_paths.items(): - if len(pos_foldernames) == 1: - paths.append(exp_path) - continue - - informativeText = html_utils.paragraph( - "The following experiment folder

    " - f"{exp_path}

    " - "contains multiple Position folders.

    " - "Please, select which Position folder(s) you want to analyse:
    " - ) - select_folder = load.select_exp_folder() - values = select_folder.get_values_dataprep(exp_path) - select_folder.QtPrompt( - self, - values, - toggleMulti=True, - informativeText=informativeText, - selectedValues=values, - ) - if select_folder.cancel: - return - - for pos in select_folder.selected_pos: - paths.append(os.path.join(exp_path, pos)) - - return paths - - def addFolderPath(self, selected_path): - myutils.addToRecentPaths(selected_path) - - folder_type = myutils.determine_folder_type(selected_path) - is_pos_folder, is_images_folder, folder_path = folder_type - if is_pos_folder: - paths = [selected_path] - elif is_images_folder: - paths = [os.path.dirname(selected_path)] - elif self.scanTree: - print(f'Scanning selected folder "{selected_path}"...') - exp_paths = path.get_posfolderpaths_walk(selected_path) - if not exp_paths: - self.warnNoValidExpPaths(selected_path) - return - - paths = self.parse_select_from_exp_paths(exp_paths) - if paths is None: - return - else: - paths = [selected_path] - - if not paths: - self.warnNoValidPathsFound(selected_path) - - for selectedPath in paths: - if self.onlyExpPaths: - selectedPath = load.get_exp_path(selectedPath) - - selectedPath = selectedPath.replace("\\", "/") - if selectedPath in self.pathsList(): - print( - f"[WARNING]: The following path was already selected: " - f'"{selectedPath}"' - ) - return - - self.listWidget.addItem(selectedPath) - - def removePaths(self): - for item in self.listWidget.selectedItems(): - row = self.listWidget.row(item) - self.listWidget.takeItem(row) - - -class OverlayLabelsAppearanceDialog(QBaseDialog): - sigValuesChanged = Signal(object) - - def __init__(self, scatterPlotItem: pg.ScatterPlotItem = None, parent=None): - super().__init__(parent) - - self.cancel = True - - self.setWindowTitle("Overlay contours appearance properties") - - mainLayout = QVBoxLayout() - - formLayout = widgets.FormLayout() - - row = -1 - - row += 1 - self.colorButton = widgets.myColorButton(color=(255, 0, 0)) - self.colorButton.clicked.disconnect() - self.colorButton.clicked.connect(self.selectColor) - self.colorButton.setCursor(Qt.PointingHandCursor) - self.colorWidget = widgets.formWidget( - self.colorButton, - addInfoButton=False, - stretchWidget=False, - labelTextLeft="Symbol color: ", - parent=self, - widgetAlignment="left", - ) - if scatterPlotItem is not None: - pen = scatterPlotItem.opts["pen"] - color = pen.color() - self.colorButton.setColor(color) - formLayout.addFormWidget(self.colorWidget, row=row) - - row += 1 - self.penWidthSpinBox = widgets.SpinBox() - self.penWidthSpinBox.setMinimum(0) - self.penWidthSpinBox.setValue(2) - - self.penWidthWidget = widgets.formWidget( - self.penWidthSpinBox, - addInfoButton=False, - stretchWidget=False, - labelTextLeft="Symbol weight: ", - parent=self, - widgetAlignment="left", - ) - if scatterPlotItem is not None: - pen = scatterPlotItem.opts["pen"] - width = pen.width() - self.penWidthSpinBox.setValue(width) - formLayout.addFormWidget(self.penWidthWidget, row=row) - - row += 1 - self.opacitySlider = widgets.sliderWithSpinBox(isFloat=True, normalize=True) - self.opacitySlider.setMinimum(0) - self.opacitySlider.setMaximum(100) - self.opacitySlider.setValue(0.8) - - self.opacityWidget = widgets.formWidget( - self.opacitySlider, - addInfoButton=False, - stretchWidget=True, - labelTextLeft="Symbol opacity: ", - parent=self, - ) - if scatterPlotItem is not None: - brush = scatterPlotItem.opts["brush"] - alpha = brush.color().alpha() - opacity = alpha / 255 - self.opacitySlider.setValue(opacity) - formLayout.addFormWidget(self.opacityWidget, row=row) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addLayout(formLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def selectColor(self): - color = self.colorButton.color() - self.colorButton.origColor = color - self.colorButton.colorDialog.setCurrentColor(color) - self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - self.colorButton.colorDialog.open() - w = self.width() - left = self.pos().x() - colorDialogTop = self.colorButton.colorDialog.pos().y() - self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) - - def getBrush(self): - r, g, b, _ = self.colorButton.color().getRgb() - alpha = round(self.opacitySlider.value() * 255) - brushColor = (r, g, b, alpha) - brush = pg.mkBrush(brushColor) - return brush - - def getPen(self): - color = self.colorButton.color() - penWidth = self.penWidthSpinBox.value() - if penWidth == 0: - return - - pen = pg.mkPen(color, width=penWidth) - return pen - - def ok_cb(self): - self.cancel = False - self.properties = {"brush": self.getBrush(), "pen": self.getPen()} - self.close() - - -class AutoSaveIntervalDialog(QBaseDialog): - sigValueChanged = Signal(float, str) - - def __init__(self, parent=None): - super().__init__(parent) - - self.cancel = True - - self.setWindowTitle("Change autosave interval") - - mainLayout = QVBoxLayout() - - self.autoSaveIntervalWidget = widgets.AutoSaveIntervalWidget(parent=self) - - mainLayout.addWidget(QLabel("Autosave interval:")) - mainLayout.addWidget(self.autoSaveIntervalWidget) - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def setValues(self, autoSaveIntevalValue, autoSaveIntervalUnit): - self.autoSaveIntervalWidget.spinbox.setValue(autoSaveIntevalValue) - self.autoSaveIntervalWidget.unitCombobox.setCurrentText(autoSaveIntervalUnit) - - def sizeHint(self): - defaultWidth = super().sizeHint().width() - defaultHeight = super().sizeHint().height() - return QSize(defaultWidth * 2, defaultHeight) - - def ok_cb(self): - self.cancel = False - self.sigValueChanged.emit( - self.autoSaveIntervalWidget.spinbox.value(), - self.autoSaveIntervalWidget.unitCombobox.currentText(), - ) - self.close() - - -class TestSegmModelInitalDialog(QBaseDialog): - def __init__(self, parent=None): - super().__init__(parent) - - self.cancel = True - - mainLayout = QVBoxLayout() - entriesLayout = widgets.FormLayout() - - row = 0 - self.startFrameNumberSpinbox = widgets.SpinBox() - self.startFrameNumberSpinbox.setMinimum(1) - - self.startFrameNumberFormWidget = widgets.formWidget( - self.startFrameNumberSpinbox, - labelTextLeft="Start frame number", - addActivateCheckbox=True, - ) - entriesLayout.addFormWidget(self.startFrameNumberFormWidget, row=row) - - row += 1 - self.stopFrameNumberSpinbox = widgets.SpinBox() - self.stopFrameNumberSpinbox.setMinimum(1) - - self.stopFrameNumberFormWidget = widgets.formWidget( - self.stopFrameNumberSpinbox, - labelTextLeft="Stop frame number", - addActivateCheckbox=True, - ) - entriesLayout.addFormWidget(self.stopFrameNumberFormWidget, row=row) - - row += 1 - self.startZsliceNumberSpinbox = widgets.SpinBox() - self.startZsliceNumberSpinbox.setMinimum(1) - - self.startZsliceNumberFormWidget = widgets.formWidget( - self.startZsliceNumberSpinbox, - labelTextLeft="Start z-slice number", - addActivateCheckbox=True, - ) - entriesLayout.addFormWidget(self.startZsliceNumberFormWidget, row=row) - - row += 1 - self.stopZsliceNumberSpinbox = widgets.SpinBox() - self.stopZsliceNumberSpinbox.setMinimum(1) - - self.stopZsliceNumberFormWidget = widgets.formWidget( - self.stopZsliceNumberSpinbox, - labelTextLeft="Stop z-slice number", - addActivateCheckbox=True, - ) - entriesLayout.addFormWidget(self.stopZsliceNumberFormWidget, row=row) - - row += 1 - - self.isTimelapseToggleFormWidget = widgets.formWidget( - widgets.Toggle(), - labelTextLeft="Is timelapse?", - stretchWidget=False, - valueGetterName="isChecked", - ) - entriesLayout.addFormWidget(self.isTimelapseToggleFormWidget, row=row) - - # self.stopFrameNumberSpinbox - # self.startZsliceNumberSpinbox - # self.stopZsliceNumberSpinbox - # self.isTimelapseToggle - - buttonsLayout = widgets.CancelOkButtonsLayout() - - buttonsLayout.okButton.clicked.connect(self.ok_cb) - buttonsLayout.cancelButton.clicked.connect(self.close) - - mainLayout.addLayout(entriesLayout) - mainLayout.addSpacing(20) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def ok_cb(self): - self.cancel = False - - self.start_frame_n = self.startFrameNumberFormWidget.value() - self.stop_frame_n = self.stopFrameNumberFormWidget.value() - self.start_z_slice_n = self.startZsliceNumberFormWidget.value() - self.stop_z_slice_n = self.stopZsliceNumberFormWidget.value() - self.is_timelapse = self.isTimelapseToggleFormWidget.value() - - self.close() +from .dialogs import * # noqa: F403 diff --git a/cellacdc/dialogs/__init__.py b/cellacdc/dialogs/__init__.py new file mode 100644 index 000000000..3a88c346c --- /dev/null +++ b/cellacdc/dialogs/__init__.py @@ -0,0 +1,228 @@ +"""Cell-ACDC dialog windows.""" + +from ._base import ( + ArgWidget, + QBaseDialog, +) + +from .export import ( + ExportToImageParametersDialog, + ExportToVideoParametersDialog, + LogoDialog, + ObjectCountDialog, + ScaleBarPropertiesDialog, + ShortcutEditorDialog, + TimestampPropertiesDialog, + ViewTextDialog, + pdDataFrameWidget, +) + +from .general import ( + AddPointsLayerDialog, + EditPointsLayerAppearanceDialog, + QDialogCombobox, + QDialogPbar, + QDialogWorkerProgress, + QLineEditDialog, + QTreeDialog, + QtSelectItems, + SelectSegmFileDialog, + SetCustomLevelsLut, + _PointsLayerAppearanceGroupbox, + askStopFrameSegm, + customAnnotationDialog, + get_existing_directory, + imageViewer, + pgTestWindow, +) + +from .measurements import ( + CombineFeaturesCalculator, + CombineMetricsMultiDfsDialog, + CombineMetricsMultiDfsSummaryDialog, + ComputeMetricsErrorsDialog, + SelectFeaturesRange, + SelectFeaturesRangeDialog, + SelectFeaturesRangeGroupbox, + SetMeasurementsDialog, + combineMetricsEquationDialog, +) + +from .metadata import ( + AutoSaveIntervalDialog, + MultiListSelector, + MultiTimePointFilePattern, + OrderableListWidgetDialog, + OverlayLabelsAppearanceDialog, + QCropTrangeTool, + QCropZtool, + QDialogAppendTextFilename, + QDialogEntriesWidget, + QDialogMetadata, + QDialogMetadataXML, + QDialogZsliceAbsent, + SelectFoldersToAnalyse, + SetColumnNamesDialog, + TreeSelectorDialog, + TreesSelectorDialog, + filenameDialog, + selectPositionsMultiExp, +) + +from .models import ( + ChangeUserProfileFolderPathDialog, + DataFrameModel, + InstallPyTorchDialog, + QDialogModelParams, + QDialogSelectModel, + QInput, + SelectAcdcDfVersionToRestore, + SelectPromptableModelDialog, + addCustomModelMessages, + addCustomPromptModelMessages, + downloadModel, +) + +from .preprocess import ( + CombineChannelsSetupDialog, + CombineChannelsSetupDialogGUI, + CombineChannelsSetupDialogUtil, + CombineChannelsWidget, + DataPrepSubCropsPathsDialog, + FormulaEditWidget, + FucciPreprocessDialog, + FunctionParamsDialog, + FutureFramesAction_QDialog, + ImageJRoisToSegmManager, + InitFijiMacroDialog, + PostProcessSegmDialog, + PostProcessSegmParams, + PreProcessParamsWidget, + PreProcessRecipeDialog, + PreProcessRecipeDialogUtil, + QDialogAutomaticThresholding, + ResizeUtilProps, + TestSegmModelInitalDialog, + randomWalkerDialog, + startStopFramesDialog, + stopFrameDialog, + wandToleranceWidget, +) + +from .tracking import ( + ApplyTrackTableSelectColumnsDialog, + BayesianTrackerParamsWin, + CellACDCTrackerParamsWin, + DeltaTrackerParamsWin, + EditIDDialog, + FindIDDialog, + GenerateMotherBudTotalTableSelectColumnsDialog, + NumericEntryDialog, + TrackSubCellObjectsDialog, + ViewCcaTableWindow, + editCcaTableWidget, + manualSeparateGui, +) + +__all__ = [ + "ArgWidget", + "QBaseDialog", + "ExportToImageParametersDialog", + "ExportToVideoParametersDialog", + "LogoDialog", + "ObjectCountDialog", + "ScaleBarPropertiesDialog", + "ShortcutEditorDialog", + "TimestampPropertiesDialog", + "ViewTextDialog", + "pdDataFrameWidget", + "AddPointsLayerDialog", + "EditPointsLayerAppearanceDialog", + "QDialogCombobox", + "QDialogPbar", + "QDialogWorkerProgress", + "QLineEditDialog", + "QTreeDialog", + "QtSelectItems", + "SelectSegmFileDialog", + "SetCustomLevelsLut", + "_PointsLayerAppearanceGroupbox", + "askStopFrameSegm", + "customAnnotationDialog", + "get_existing_directory", + "imageViewer", + "pgTestWindow", + "CombineFeaturesCalculator", + "CombineMetricsMultiDfsDialog", + "CombineMetricsMultiDfsSummaryDialog", + "ComputeMetricsErrorsDialog", + "SelectFeaturesRange", + "SelectFeaturesRangeDialog", + "SelectFeaturesRangeGroupbox", + "SetMeasurementsDialog", + "combineMetricsEquationDialog", + "AutoSaveIntervalDialog", + "MultiListSelector", + "MultiTimePointFilePattern", + "OrderableListWidgetDialog", + "OverlayLabelsAppearanceDialog", + "QCropTrangeTool", + "QCropZtool", + "QDialogAppendTextFilename", + "QDialogEntriesWidget", + "QDialogMetadata", + "QDialogMetadataXML", + "QDialogZsliceAbsent", + "SelectFoldersToAnalyse", + "SetColumnNamesDialog", + "TreeSelectorDialog", + "TreesSelectorDialog", + "filenameDialog", + "selectPositionsMultiExp", + "ChangeUserProfileFolderPathDialog", + "DataFrameModel", + "InstallPyTorchDialog", + "QDialogModelParams", + "QDialogSelectModel", + "QInput", + "SelectAcdcDfVersionToRestore", + "SelectPromptableModelDialog", + "addCustomModelMessages", + "addCustomPromptModelMessages", + "downloadModel", + "CombineChannelsSetupDialog", + "CombineChannelsSetupDialogGUI", + "CombineChannelsSetupDialogUtil", + "CombineChannelsWidget", + "DataPrepSubCropsPathsDialog", + "FormulaEditWidget", + "FucciPreprocessDialog", + "FunctionParamsDialog", + "FutureFramesAction_QDialog", + "ImageJRoisToSegmManager", + "InitFijiMacroDialog", + "PostProcessSegmDialog", + "PostProcessSegmParams", + "PreProcessParamsWidget", + "PreProcessRecipeDialog", + "PreProcessRecipeDialogUtil", + "QDialogAutomaticThresholding", + "ResizeUtilProps", + "TestSegmModelInitalDialog", + "randomWalkerDialog", + "startStopFramesDialog", + "stopFrameDialog", + "wandToleranceWidget", + "ApplyTrackTableSelectColumnsDialog", + "BayesianTrackerParamsWin", + "CellACDCTrackerParamsWin", + "DeltaTrackerParamsWin", + "EditIDDialog", + "FindIDDialog", + "GenerateMotherBudTotalTableSelectColumnsDialog", + "NumericEntryDialog", + "TrackSubCellObjectsDialog", + "ViewCcaTableWindow", + "editCcaTableWidget", + "manualSeparateGui", +] diff --git a/cellacdc/dialogs/_base.py b/cellacdc/dialogs/_base.py new file mode 100644 index 000000000..b58493c13 --- /dev/null +++ b/cellacdc/dialogs/_base.py @@ -0,0 +1,181 @@ +"""Cell-ACDC dialog windows: _base.""" + +import os +import sys +import re +from typing import Literal, Callable, Dict, Iterable, List, Tuple +import datetime +import pathlib +from collections import defaultdict +import zipfile +from heapq import nlargest +import matplotlib +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.patches import Rectangle, Circle, PathPatch, Path +import numpy as np +import scipy.interpolate + +try: + import tkinter as tk +except Exception as err: + pass + +import cv2 +import traceback +from itertools import combinations, permutations +from collections import namedtuple +from natsort import natsorted + +# from MyWidgets import Slider, Button, MyRadioButtons +from skimage.measure import label, regionprops +from functools import partial +import skimage.filters +import skimage.measure +import skimage.morphology +import skimage.exposure +import skimage.draw +import skimage.registration +import skimage.color +import skimage.segmentation +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk +import matplotlib.pyplot as plt +import seaborn as sns +import pandas as pd +import math +import time +import sympy as sp +import json +import html + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from qtpy import QtCore +from qtpy.QtGui import ( + QIcon, + QFontMetrics, + QKeySequence, + QFont, + QRegularExpressionValidator, + QCursor, + QKeyEvent, + QPixmap, + QFont, + QPalette, + QMouseEvent, + QColor, +) +from qtpy.QtCore import ( + Qt, + QSize, + QEvent, + Signal, + QEventLoop, + QTimer, + QRegularExpression, +) +from qtpy.QtWidgets import ( + QFileDialog, + QApplication, + QMainWindow, + QMenu, + QLabel, + QToolBar, + QScrollBar, + QWidget, + QVBoxLayout, + QLineEdit, + QPushButton, + QHBoxLayout, + QDialog, + QFormLayout, + QListWidget, + QAbstractItemView, + QButtonGroup, + QCheckBox, + QSizePolicy, + QComboBox, + QSlider, + QGridLayout, + QSpinBox, + QToolButton, + QTableView, + QTextBrowser, + QDoubleSpinBox, + QScrollArea, + QFrame, + QProgressBar, + QGroupBox, + QRadioButton, + QDockWidget, + QMessageBox, + QStyle, + QPlainTextEdit, + QSpacerItem, + QTreeWidget, + QTreeWidgetItem, + QTextEdit, + QSplashScreen, + QAction, + QListWidgetItem, + QActionGroup, + QHeaderView, + QStyledItemDelegate, +) +import qtpy.compat + +from .. import exception_handler +from .. import load, prompts, core, measurements, html_utils +from .. import is_mac, is_win, is_linux, settings_folderpath, config +from .. import preproc_recipes_path, segm_recipes_path, combine_channels_recipes_path +from .. import is_conda_env +from .. import printl +from .. import colors +from .. import issues_url +from .. import myutils +from .. import qutils +from .. import _palettes +from .. import base_cca_dict +from .. import widgets +from .. import user_profile_path, promptable_models_path, models_path +from .. import features +from .. import _core +from .. import _types +from .. import plot +from .. import urls +from ..acdc_regex import float_regex, is_alphanumeric_filename, to_alphanumeric +from .. import _base_widgets +from .. import io +from .. import cca_functions +from .. import path + +POSITIVE_FLOAT_REGEX = float_regex(allow_negative=False) +TREEWIDGET_STYLESHEET = _palettes.TreeWidgetStyleSheet() +LISTWIDGET_STYLESHEET = _palettes.ListWidgetStyleSheet() +BACKGROUND_RGBA = _palettes.get_disabled_colors()["Button"] + +font = QFont() +font.setPixelSize(12) +italicFont = QFont() +italicFont.setPixelSize(12) +italicFont.setItalic(True) + +class ArgWidget: + def __init__( + self, name, type, widget, defaultVal, valueSetter, valueGetter, changeSig=None + ): + self.name = name + self.type = type + self.widget = widget + self.defaultVal = defaultVal + self.valueSetter = valueSetter + self.valueGetter = valueGetter + if changeSig is not None: + self.changeSig = changeSig + + +class QBaseDialog(_base_widgets.QBaseDialog): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) diff --git a/cellacdc/dialogs/export.py b/cellacdc/dialogs/export.py new file mode 100644 index 000000000..f3cfebcb8 --- /dev/null +++ b/cellacdc/dialogs/export.py @@ -0,0 +1,1512 @@ +"""Cell-ACDC dialog windows: export.""" + +import os +import sys +import re +from typing import Literal, Callable, Dict, Iterable, List, Tuple +import datetime +import pathlib +from collections import defaultdict +import zipfile +from heapq import nlargest +import matplotlib +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.patches import Rectangle, Circle, PathPatch, Path +import numpy as np +import scipy.interpolate + +try: + import tkinter as tk +except Exception as err: + pass + +import cv2 +import traceback +from itertools import combinations, permutations +from collections import namedtuple +from natsort import natsorted + +# from MyWidgets import Slider, Button, MyRadioButtons +from skimage.measure import label, regionprops +from functools import partial +import skimage.filters +import skimage.measure +import skimage.morphology +import skimage.exposure +import skimage.draw +import skimage.registration +import skimage.color +import skimage.segmentation +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk +import matplotlib.pyplot as plt +import seaborn as sns +import pandas as pd +import math +import time +import sympy as sp +import json +import html + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from qtpy import QtCore +from qtpy.QtGui import ( + QIcon, + QFontMetrics, + QKeySequence, + QFont, + QRegularExpressionValidator, + QCursor, + QKeyEvent, + QPixmap, + QFont, + QPalette, + QMouseEvent, + QColor, +) +from qtpy.QtCore import ( + Qt, + QSize, + QEvent, + Signal, + QEventLoop, + QTimer, + QRegularExpression, +) +from qtpy.QtWidgets import ( + QFileDialog, + QApplication, + QMainWindow, + QMenu, + QLabel, + QToolBar, + QScrollBar, + QWidget, + QVBoxLayout, + QLineEdit, + QPushButton, + QHBoxLayout, + QDialog, + QFormLayout, + QListWidget, + QAbstractItemView, + QButtonGroup, + QCheckBox, + QSizePolicy, + QComboBox, + QSlider, + QGridLayout, + QSpinBox, + QToolButton, + QTableView, + QTextBrowser, + QDoubleSpinBox, + QScrollArea, + QFrame, + QProgressBar, + QGroupBox, + QRadioButton, + QDockWidget, + QMessageBox, + QStyle, + QPlainTextEdit, + QSpacerItem, + QTreeWidget, + QTreeWidgetItem, + QTextEdit, + QSplashScreen, + QAction, + QListWidgetItem, + QActionGroup, + QHeaderView, + QStyledItemDelegate, +) +import qtpy.compat + +from .. import exception_handler +from .. import load, prompts, core, measurements, html_utils +from .. import is_mac, is_win, is_linux, settings_folderpath, config +from .. import preproc_recipes_path, segm_recipes_path, combine_channels_recipes_path +from .. import is_conda_env +from .. import printl +from .. import colors +from .. import issues_url +from .. import myutils +from .. import qutils +from .. import _palettes +from .. import base_cca_dict +from .. import widgets +from .. import user_profile_path, promptable_models_path, models_path +from .. import features +from .. import _core +from .. import _types +from .. import plot +from .. import urls +from ..acdc_regex import float_regex, is_alphanumeric_filename, to_alphanumeric +from .. import _base_widgets +from .. import io +from .. import cca_functions +from .. import path + +POSITIVE_FLOAT_REGEX = float_regex(allow_negative=False) +TREEWIDGET_STYLESHEET = _palettes.TreeWidgetStyleSheet() +LISTWIDGET_STYLESHEET = _palettes.ListWidgetStyleSheet() +BACKGROUND_RGBA = _palettes.get_disabled_colors()["Button"] + +font = QFont() +font.setPixelSize(12) +italicFont = QFont() +italicFont.setPixelSize(12) +italicFont.setItalic(True) + +from ._base import ( + QBaseDialog, +) + +class ViewTextDialog(QBaseDialog): + def __init__(self, text, parent=None): + super().__init__(parent) + + mainLayout = QVBoxLayout() + + textViewWidget = QTextEdit() + textViewWidget.setReadOnly(True) + + textViewWidget.setText(text) + + buttonsLayout = QHBoxLayout() + okButton = widgets.okPushButton("Ok") + + okButton.clicked.connect(self.close) + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(okButton) + + mainLayout.addWidget(textViewWidget) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + self.setFont(font) + + +class pdDataFrameWidget(QMainWindow): + def __init__(self, df, parent=None): + super().__init__(parent) + self.parent = parent + self.setWindowTitle("Cell cycle annotations") + + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + + mainContainer = QWidget() + self.setCentralWidget(mainContainer) + + layout = QVBoxLayout() + self._layout = layout + + self.tableView = QTableView(self) + layout.addWidget(self.tableView) + model = DataFrameModel(df) + self.tableView.setModel(model) + for i in range(len(df.columns)): + self.tableView.resizeColumnToContents(i) + # layout.addWidget(QPushButton('Ok', self)) + mainContainer.setLayout(layout) + + def updateTable(self, df, IDs=None): + if df is None: + df = self.parent.getBaseCca_df() + + if IDs is not None: + df = df.loc[IDs] + + df = df.reset_index() + model = DataFrameModel(df) + self.tableView.setModel(model) + for i in range(len(df.columns)): + self.tableView.resizeColumnToContents(i) + + def setGeometryWindow(self, maxWidth=1024): + width = self.tableView.verticalHeader().width() + 4 + for j in range(self.tableView.model().columnCount()): + width += self.tableView.columnWidth(j) + 4 + height = self.tableView.horizontalHeader().height() + 4 + h = height + (self.tableView.rowHeight(0) + 4) * 10 + w = width if width < maxWidth else maxWidth + self.setGeometry(100, 100, w, h) + + # Center window + parent = self.parent + if parent is not None: + # Center the window on main window + mainWinGeometry = parent.geometry() + mainWinLeft = mainWinGeometry.left() + mainWinTop = mainWinGeometry.top() + mainWinWidth = mainWinGeometry.width() + mainWinHeight = mainWinGeometry.height() + mainWinCenterX = int(mainWinLeft + mainWinWidth / 2) + mainWinCenterY = int(mainWinTop + mainWinHeight / 2) + winGeometry = self.geometry() + winWidth = winGeometry.width() + winHeight = winGeometry.height() + winLeft = int(mainWinCenterX - winWidth / 2) + winRight = int(mainWinCenterY - winHeight / 2) + self.move(winLeft, winRight) + + def closeEvent(self, event): + self.parent.ccaTableWin = None + + +class ShortcutEditorDialog(QBaseDialog): + def __init__( + self, + widgetsWithShortcut: dict, + delObjectKey="", + delObjectButton: Literal["Middle click", "Left click"] = "Middle click", + zoomOutKeyValue: int = None, + parent=None, + ): + self.cancel = True + super().__init__(parent) + + self.setWindowTitle("Customize keyboard shortcuts") + + mainLayout = QVBoxLayout() + + self.customShortcuts = {} + self.shortcutLineEdits = {} + + scrollArea = QScrollArea(self) + scrollArea.setWidgetResizable(True) + scrollAreaWidget = QWidget() + entriesLayout = QGridLayout() + + row = 0 + button = widgets.PushButton(self, flat=True) + button.setIcon(QIcon(":del_obj_click.svg")) + self.delObjShortcutLineEdit = widgets.ShortcutLineEdit( + allowModifiers=True, notAllowedModifier=Qt.AltModifier + ) + if delObjectKey is not None: + self.delObjShortcutLineEdit.setText(delObjectKey) + self.delObjButtonCombobox = QComboBox() + self.delObjButtonCombobox.addItems(["Middle click", "Left click"]) + self.delObjButtonCombobox.setCurrentText(delObjectButton) + entriesLayout.addWidget(button, row, 0) + entriesLayout.addWidget(QLabel("Delete object:"), row, 1) + entriesLayout.addWidget(self.delObjShortcutLineEdit, row, 2) + entriesLayout.addWidget( + self.delObjButtonCombobox, row, 3, alignment=Qt.AlignLeft + ) + + row += 1 + name = "Zoom out" + button = widgets.PushButton(self, flat=True) + label = QLabel("Zoom out:") + self.zoomShortcutLineEdit = widgets.ShortcutLineEdit() + if zoomOutKeyValue is not None: + zoomOutKeySequence = widgets.KeySequenceFromText(zoomOutKeyValue) + self.zoomShortcutLineEdit.setText(zoomOutKeySequence.toString()) + self.zoomShortcutLineEdit.key = zoomOutKeyValue + self.zoomShortcutLineEdit.textChanged.connect(self.checkDuplicateShortcuts) + entriesLayout.addWidget(button, row, 0) + entriesLayout.addWidget(label, row, 1) + entriesLayout.addWidget(self.zoomShortcutLineEdit, row, 2) + self.shortcutLineEdits[name] = self.zoomShortcutLineEdit + + row += 1 + for row, (name, widget) in enumerate(widgetsWithShortcut.items(), start=row): + button = widgets.PushButton(self, flat=True) + try: + button.setIcon(widget.icon()) + except: + pass + label = QLabel(f"{name}:") + shortcutLineEdit = widgets.ShortcutLineEdit() + if hasattr(widget, "keyPressShortcut"): + shortcutLineEdit.key = widget.keyPressShortcut + shortcut = widgets.KeySequenceFromText(widget.keyPressShortcut) + isShortcutKeyPress = True + else: + shortcut = widget.shortcut() + isShortcutKeyPress = False + shortcutLineEdit.setText(shortcut.toString()) + shortcutLineEdit.textChanged.connect(self.checkDuplicateShortcuts) + shortcutLineEdit.isShortcutKeyPress = isShortcutKeyPress + entriesLayout.addWidget(button, row, 0) + entriesLayout.addWidget(label, row, 1) + entriesLayout.addWidget(shortcutLineEdit, row, 2) + self.shortcutLineEdits[name] = shortcutLineEdit + + entriesLayout.setColumnStretch(0, 0) + entriesLayout.setColumnStretch(1, 0) + entriesLayout.setColumnStretch(2, 1) + entriesLayout.setColumnStretch(3, 0) + + scrollAreaWidget.setLayout(entriesLayout) + scrollArea.setWidget(scrollAreaWidget) + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addWidget(scrollArea) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setFont(font) + self.setLayout(mainLayout) + + def checkDuplicateShortcuts(self, text): + for name, shortcutLineEdit in self.shortcutLineEdits.items(): + if shortcutLineEdit == self.sender(): + continue + if shortcutLineEdit.text() != text: + continue + shortcutLineEdit.setText("") + + def warnInvalidKeySequenceDelObjWithLeftClick(self): + txt = html_utils.paragraph( + 'The selected key sequence to delete objects with "Left click" ' + "is invalid.

    " + 'Only "Middle click" can be used without pressing keys.

    ' + "Thank you for your patience!" + ) + msg = widgets.myMessageBox() + msg.warning(self, "Invalid key sequence to delete objects", txt) + + def ok_cb(self): + delObjButtonText = self.delObjButtonCombobox.currentText() + delObjKeySequence = self.delObjShortcutLineEdit.keySequence + if delObjButtonText == "Left click" and delObjKeySequence is None: + self.warnInvalidKeySequenceDelObjWithLeftClick() + return + + self.shortcutLineEdits.pop("Zoom out") + self.cancel = False + for name, shortcutLineEdit in self.shortcutLineEdits.items(): + text = shortcutLineEdit.text() + if shortcutLineEdit.isShortcutKeyPress: + self.customShortcuts[name] = (text, shortcutLineEdit.key) + else: + self.customShortcuts[name] = (text, shortcutLineEdit.keySequence) + + delObjQtButton = ( + Qt.MouseButton.LeftButton + if delObjButtonText == "Left click" + else Qt.MouseButton.MiddleButton + ) + self.delObjAction = delObjKeySequence, delObjQtButton + self.zoomOutKeyValue = self.zoomShortcutLineEdit.key + + self.close() + + def showEvent(self, event) -> None: + self.resize(int(self.width() * 1.2), self.height()) + self.move(self.x(), 100) + + +class ScaleBarPropertiesDialog(QBaseDialog): + sigValueChanged = Signal(object) + + def __init__( + self, maxLength, maxThickness, PhysicalSizeX, parent=None, **properties + ): + super().__init__(parent=parent) + + self.cancel = True + self.setWindowTitle("Scale bar properties") + + self.PhysicalSizeX = PhysicalSizeX + + mainLayout = QVBoxLayout() + + formLayout = widgets.FormLayout() + formLayout.setVerticalSpacing(10) + formLayout.setHorizontalSpacing(50) + + row = 0 + unitCombobox = QComboBox() + unitFormWidget = widgets.formWidget(unitCombobox, labelTextLeft="Physical unit") + unitCombobox.addItems(["nm", "μm", "mm", "cm"]) + if properties.get("unit") is None: + unitCombobox.setCurrentIndex(1) + else: + unitCombobox.setCurrentText(properties.get("unit")) + formLayout.addFormWidget( + unitFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + self.unitCombobox = unitCombobox + + row += 1 + lengthDoubleSpinbox = widgets.DoubleSpinBox() + lengthDoubleSpinbox.setMaximum(maxLength) + lengthDoubleSpinbox.setMinimum(PhysicalSizeX) + lengthDoubleSpinbox.setDecimals(1) + if properties.get("length_unit") is not None: + lengthDoubleSpinbox.setValue(properties.get("length_unit")) + else: + deafultLength = np.ceil(PhysicalSizeX * 15) + lengthDoubleSpinbox.setValue(round(deafultLength)) + lengthFormWidget = widgets.formWidget( + lengthDoubleSpinbox, labelTextLeft="Length (μm)" + ) + self.lengthFormWidget = lengthFormWidget + self.lengthDoubleSpinbox = lengthDoubleSpinbox + formLayout.addFormWidget( + lengthFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + + row += 1 + thicknessSpinbox = widgets.DoubleSpinBox() + thicknessSpinbox.setMaximum(maxThickness) + thicknessSpinbox.setMinimum(1) + if properties.get("thickness") is not None: + thicknessSpinbox.setValue(properties.get("thickness")) + else: + thicknessSpinbox.setValue(round(4, 1)) + thicknessSpinbox.setDecimals(1) + thicknessFormWidget = widgets.formWidget( + thicknessSpinbox, labelTextLeft="Thickness (pixel)" + ) + formLayout.addFormWidget( + thicknessFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + self.thicknessSpinbox = thicknessSpinbox + + row += 1 + locCombobox = QComboBox() + locFormWidget = widgets.formWidget(locCombobox, labelTextLeft="Location") + locCombobox.addItems( + ["Bottom-right", "Bottom-left", "Top-left", "Top-right", "Custom"] + ) + loc = properties.get("loc") + if isinstance(loc, str): + locCombobox.setCurrentText(loc.capitalize()) + formLayout.addFormWidget( + locFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + self.locCombobox = locCombobox + + row += 1 + self.colorButton = widgets.myColorButton(color=(255, 255, 255)) + if properties.get("color") is not None: + self.colorButton.setColor(properties.get("color")) + colorFormWidget = widgets.formWidget( + self.colorButton, + labelTextLeft="Color", + widgetAlignment=Qt.AlignCenter, + stretchWidget=False, + ) + formLayout.addFormWidget( + colorFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + + row += 1 + displayTextToggle = widgets.Toggle() + if properties.get("is_text_visible") is not None: + displayTextToggle.setChecked(properties.get("is_text_visible")) + else: + displayTextToggle.setChecked(True) + displayTextFormWidget = widgets.formWidget( + displayTextToggle, + labelTextLeft="Display text", + widgetAlignment=Qt.AlignCenter, + stretchWidget=False, + ) + formLayout.addFormWidget( + displayTextFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + self.displayTextToggle = displayTextToggle + + row += 1 + fontSizeSpinbox = widgets.SpinBox() + if properties.get("font_size") is not None: + fontSizeSpinbox.setValue(int(properties.get("font_size"))) + else: + fontSizeSpinbox.setValue(12) + fontSizeFormWidget = widgets.formWidget( + fontSizeSpinbox, labelTextLeft="Font size (px)" + ) + self.fontSizeSpinbox = fontSizeSpinbox + formLayout.addFormWidget( + fontSizeFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + + row += 1 + decimalsSpinbox = widgets.SpinBox() + decimalsSpinbox.setMaximum(6) + decimalsSpinbox.setMinimum(0) + if properties.get("num_decimals") is not None: + decimalsSpinbox.setValue(properties.get("num_decimals")) + else: + decimalsSpinbox.setValue(0) + decimalsFormWidget = widgets.formWidget( + decimalsSpinbox, labelTextLeft="Number of decimals" + ) + formLayout.addFormWidget( + decimalsFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + self.decimalsSpinbox = decimalsSpinbox + + row += 1 + moveWithZoomToggle = widgets.Toggle() + moveWithZoomWidget = widgets.formWidget( + moveWithZoomToggle, + labelTextLeft="Move scale bar with zoom", + widgetAlignment=Qt.AlignCenter, + stretchWidget=False, + ) + formLayout.addFormWidget( + moveWithZoomWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + self.moveWithZoomToggle = moveWithZoomToggle + + mainLayout.addLayout(formLayout) + + buttonsLayout = widgets.CancelOkButtonsLayout() + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + mainLayout.addStretch() + + self.setLayout(mainLayout) + self.setFont(font) + + self.unitCombobox.currentTextChanged.connect(self.updateLengthUnit) + self.colorButton.clicked.disconnect() + self.colorButton.clicked.connect(self.selectColor) + + self.colorButton.sigColorChanging.connect(self.onValueChanged) + self.lengthDoubleSpinbox.valueChanged.connect(self.onValueChanged) + self.thicknessSpinbox.valueChanged.connect(self.onValueChanged) + self.locCombobox.currentTextChanged.connect(self.onValueChanged) + self.displayTextToggle.toggled.connect(self.onValueChanged) + self.fontSizeSpinbox.valueChanged.connect(self.onValueChanged) + self.decimalsSpinbox.valueChanged.connect(self.onValueChanged) + self.moveWithZoomToggle.toggled.connect(self.onValueChanged) + + def onValueChanged(self, *args, **kwargs): + self.sigValueChanged.emit(self.kwargs()) + + def selectColor(self): + color = self.colorButton.color() + self.colorButton.origColor = color + self.colorButton.colorDialog.setCurrentColor(color) + self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + self.colorButton.colorDialog.setParent(self) + self.colorButton.colorDialog.open() + w = self.width() + left = self.pos().x() + colorDialogTop = self.colorButton.colorDialog.pos().y() + self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) + + def updateLengthUnit(self, unit): + newText = re.sub(r"\(.*\)", f"({unit})", self.lengthFormWidget.labelLeft.text()) + self.lengthFormWidget.labelLeft.setText(newText) + self.onValueChanged(self) + + def kwargs(self): + unit = self.unitCombobox.currentText() + length_unit = self.lengthDoubleSpinbox.value() + length_um = _core.convert_length(length_unit, unit, "μm") + length_pixel = length_um / self.PhysicalSizeX + kwargs = { + "thickness": self.thicknessSpinbox.value(), + "length_pixel": length_pixel, + "length_unit": length_unit, + "is_text_visible": self.displayTextToggle.isChecked(), + "color": self.colorButton.color(), + "loc": self.locCombobox.currentText().lower(), + "font_size": self.fontSizeSpinbox.value(), + "unit": unit, + "num_decimals": self.decimalsSpinbox.value(), + "move_with_zoom": self.moveWithZoomToggle.isChecked(), + } + return kwargs + + def ok_cb(self): + self.cancel = False + self.close() + + +class ExportToVideoParametersDialog(QBaseDialog): + sigOk = Signal(dict) + sigAddScaleBar = Signal(bool) + sigAddTimestamp = Signal(bool) + sigRescaleIntensLut = Signal(str, str) + sigChangeStartTime = Signal(str) + + def __init__( + self, + channels, + parent=None, + startFolderpath="", + startFilename="", + startFrameNum=1, + SizeT=1, + SizeZ=1, + isTimelapseVideo=True, + isScaleBarPresent=False, + isTimestampPresent=False, + rescaleIntensChannelHowMapper=None, + startTime=None, + ): + self.cancel = True + + if rescaleIntensChannelHowMapper is None: + rescaleIntensChannelHowMapper = {} + + super().__init__(parent=parent) + + self.setWindowTitle("Preferences for output video") + + mainLayout = QVBoxLayout() + + gridLayout = QGridLayout() + + navVar = "frame number" if isTimelapseVideo else "z-slice" + maxNavVar = SizeT if isTimelapseVideo else SizeZ + + self.isTimelapseVideo = isTimelapseVideo + + row = 0 + gridLayout.addWidget(QLabel(f"Start {navVar}:"), row, 0) + self.startNavVarNumberEntry = widgets.SpinBox() + self.startNavVarNumberEntry.setMinimum(1) + self.startNavVarNumberEntry.setMaximum(maxNavVar - 1) + self.startNavVarNumberEntry.setValue(startFrameNum) + gridLayout.addWidget(self.startNavVarNumberEntry, row, 1) + + row += 1 + gridLayout.addWidget(QLabel(f"Stop {navVar}:"), row, 0) + self.stopNavVarNumberEntry = widgets.SpinBox() + self.stopNavVarNumberEntry.setMinimum(2) + self.stopNavVarNumberEntry.setMaximum(maxNavVar) + self.stopNavVarNumberEntry.setValue(maxNavVar) + gridLayout.addWidget(self.stopNavVarNumberEntry, row, 1) + + row += 1 + gridLayout.addWidget(QLabel("File format:"), row, 0) + self.fileFormatCombobox = QComboBox() + self.fileFormatCombobox.addItems(["MP4", "AVI"]) + gridLayout.addWidget(self.fileFormatCombobox, row, 1) + + row += 1 + gridLayout.addWidget(QLabel("Frame rate (FPS):"), row, 0) + self.fpsWidget = widgets.FloatLineEdit(allowNegative=False) + self.fpsWidget.setValue(10.0) + gridLayout.addWidget(self.fpsWidget, row, 1) + + row += 1 + self.dpiWidget = widgets.IntLineEdit(allowNegative=False) + self.dpiWidget.setValue(300) + self.dpiWidget.label = QLabel("DPI") + gridLayout.addWidget(self.dpiWidget.label, row, 0) + gridLayout.addWidget(self.dpiWidget, row, 1) + + row += 1 + gridLayout.addWidget(QLabel("Folder path:"), row, 0) + self.folderPathLineEdit = widgets.ElidingLineEdit(minWidth=240) + self.folderPathLineEdit.setText(startFolderpath) + gridLayout.addWidget(self.folderPathLineEdit, row, 1) + self.browseButton = widgets.browseFileButton( + start_dir=startFolderpath, openFolder=True + ) + gridLayout.addWidget(self.browseButton, row, 2) + + row += 1 + gridLayout.addWidget(QLabel("Filename:"), row, 0) + self.filenameLineEdit = widgets.alphaNumericLineEdit() + self.filenameLineEdit.setAlignment(Qt.AlignCenter) + self.filenameLineEdit.setText(startFilename) + gridLayout.addWidget(self.filenameLineEdit, row, 1) + self.fileFormatLabel = QLabel(".mp4") + gridLayout.addWidget(self.fileFormatLabel, row, 2) + + row += 1 + gridLayout.addWidget(QLabel("Add Scale Bar:"), row, 0) + self.addScaleBarToggle = widgets.Toggle() + gridLayout.addWidget(self.addScaleBarToggle, row, 1, alignment=Qt.AlignCenter) + self.addScaleBarToggle.setChecked(isScaleBarPresent) + + if isTimelapseVideo: + row += 1 + gridLayout.addWidget(QLabel("Add timestamp:"), row, 0) + self.addTimestampToggle = widgets.Toggle() + gridLayout.addWidget( + self.addTimestampToggle, row, 1, alignment=Qt.AlignCenter + ) + self.addTimestampToggle.setChecked(isTimestampPresent) + + for channel in channels: + row += 1 + labelText = f"Rescale intensities (LUT) {channel}:" + gridLayout.addWidget(QLabel(labelText), row, 0) + rescaleItems = ["Rescale each 2D image"] + if SizeZ > 1: + rescaleItems.append("Rescale across z-stack") + if isTimelapseVideo: + rescaleItems.append("Rescale across time frames") + rescaleItems.append("Choose custom levels...") + rescaleItems.append("Do no rescale, display raw image") + rescaleIntensCombobox = QComboBox() + rescaleIntensCombobox.addItems(rescaleItems) + rescaleIntensHow = rescaleIntensChannelHowMapper.get(channel) + if rescaleIntensHow is not None: + rescaleIntensCombobox.setCurrentText(rescaleIntensHow) + gridLayout.addWidget(rescaleIntensCombobox, row, 1) + rescaleIntensCombobox.textActivated.connect( + partial(self.emitRescaleIntens, channel=channel) + ) + + row += 1 + gridLayout.addWidget(QLabel("Save a PNG for each frame:"), row, 0) + self.saveFramesToggle = widgets.Toggle() + gridLayout.addWidget(self.saveFramesToggle, row, 1, alignment=Qt.AlignCenter) + + gridLayout.setColumnStretch(0, 0) + gridLayout.setColumnStretch(1, 1) + gridLayout.setColumnStretch(2, 0) + + self.fileFormatCombobox.currentTextChanged.connect(self.updateFileFormat) + self.browseButton.sigPathSelected.connect(self.updateFolderPath) + self.addScaleBarToggle.toggled.connect(self.addScaleBarToggled) + if isTimelapseVideo: + self.addTimestampToggle.toggled.connect(self.addTimestampToggled) + + buttonsLayout = widgets.CancelOkButtonsLayout() + buttonsLayout.okButton.setText("Export") + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addLayout(gridLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def emitRescaleIntens(self, how, channel=""): + self.sigRescaleIntensLut.emit(how, channel) + + def addScaleBarToggled(self, checked): + self.sigAddScaleBar.emit(checked) + + def addTimestampToggled(self, checked): + self.sigAddTimestamp.emit(checked) + + def updateFolderPath(self, folderPath): + self.folderPathLineEdit.setText(folderPath) + self.browseButton.setStartPath(folderPath) + + def updateFileFormat(self, fileFormat): + self.fileFormatLabel.setText(f".{fileFormat.lower()}") + + def validateFolderPath(self): + folderPath = self.folderPathLineEdit.text() + if os.path.exists(folderPath) and os.path.isdir(folderPath): + return True + + text = html_utils.paragraph( + "The selected folder path is not a valid folder or does not exist" + ) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Not a valid folder", text) + return False + + def validateFilename(self): + filename = self.filenameLineEdit.text() + if filename: + return True + + text = html_utils.paragraph("The filename cannot be empty!") + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Not a valid folder", text) + return False + + def validate(self): + proceed = self.validateFolderPath() + if not proceed: + return False + + proceed = self.validateFilename() + if not proceed: + return False + + return True + + def preferences(self, makedirs=True): + filename = f"{self.filenameLineEdit.text()}{self.fileFormatLabel.text()}" + avi_filename = f"{self.filenameLineEdit.text()}.avi" + avi_filepath = os.path.join(self.folderPathLineEdit.text(), avi_filename) + png_foldername = f"{self.filenameLineEdit.text()}_frames_PNG" + pngs_folderpath = os.path.join(self.folderPathLineEdit.text(), png_foldername) + if makedirs: + os.makedirs(pngs_folderpath, exist_ok=True) + + preferences = { + "start_nav_var_num": self.startNavVarNumberEntry.value(), + "stop_nav_var_num": self.stopNavVarNumberEntry.value(), + "filepath": os.path.join(self.folderPathLineEdit.text(), filename), + "filename": self.filenameLineEdit.text(), + "avi_filepath": avi_filepath, + "pngs_folderpath": pngs_folderpath, + "num_digits": len(str(self.stopNavVarNumberEntry.value())), + "fps": self.fpsWidget.value(), + "save_pngs": self.saveFramesToggle.isChecked(), + "is_timelapse": self.isTimelapseVideo, + "dpi": self.dpiWidget.value(), + } + return preferences + + def ok_cb(self): + proceed = self.validate() + if not proceed: + return + self.cancel = False + self.sigOk.emit(self.preferences()) + self.selected_preferences = self.preferences() + self.close() + + +class TimestampPropertiesDialog(QBaseDialog): + sigValueChanged = Signal(object) + + def __init__(self, parent=None, **properties): + super().__init__(parent=parent) + + self.cancel = True + self.setWindowTitle("Timestamp preferences") + + mainLayout = QVBoxLayout() + + formLayout = widgets.FormLayout() + formLayout.setVerticalSpacing(10) + formLayout.setHorizontalSpacing(50) + + row = 0 + self.startTimeWidget = widgets.TimeWidget() + if properties.get("start_timedelta") is not None: + self.startTimeWidget.setValuesFromTimedelta( + properties.get("start_timedelta") + ) + startTimeFormWidget = widgets.formWidget( + self.startTimeWidget, + labelTextLeft="Start time", + ) + formLayout.addFormWidget( + startTimeFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + + row += 1 + self.colorButton = widgets.myColorButton(color=(255, 255, 255)) + if properties.get("color") is not None: + self.colorButton.setColor(properties.get("color")) + colorFormWidget = widgets.formWidget( + self.colorButton, + labelTextLeft="Color", + widgetAlignment=Qt.AlignCenter, + stretchWidget=False, + ) + formLayout.addFormWidget( + colorFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + + row += 1 + fontSizeWidget = widgets.FontSizeWidget() + if properties.get("font_size") is not None: + fontSizeWidget.setValue(properties.get("font_size")) + else: + fontSizeWidget.setValue(12) + fontSizeFormWidget = widgets.formWidget( + fontSizeWidget, labelTextLeft="Font size (px)" + ) + self.fontSizeWidget = fontSizeWidget + formLayout.addFormWidget( + fontSizeFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + + row += 1 + locCombobox = QComboBox() + locFormWidget = widgets.formWidget(locCombobox, labelTextLeft="Location") + locCombobox.addItems( + ["Top-left", "Top-right", "Bottom-left", "Bottom-right", "Custom"] + ) + loc = properties.get("loc") + if isinstance(loc, str): + locCombobox.setCurrentText(loc.capitalize()) + formLayout.addFormWidget( + locFormWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + self.locCombobox = locCombobox + + row += 1 + moveWithZoomToggle = widgets.Toggle() + moveWithZoomWidget = widgets.formWidget( + moveWithZoomToggle, + labelTextLeft="Move timestamp with zoom", + widgetAlignment=Qt.AlignCenter, + stretchWidget=False, + ) + formLayout.addFormWidget( + moveWithZoomWidget, row=row, leftLabelAlignment=Qt.AlignLeft + ) + self.moveWithZoomToggle = moveWithZoomToggle + + mainLayout.addLayout(formLayout) + + buttonsLayout = widgets.CancelOkButtonsLayout() + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + mainLayout.addStretch() + + self.setLayout(mainLayout) + self.setFont(font) + + self.colorButton.clicked.disconnect() + self.colorButton.clicked.connect(self.selectColor) + + self.startTimeWidget.sigValueChanged.connect(self.onValueChanged) + + self.locCombobox.currentTextChanged.connect(self.onValueChanged) + self.fontSizeWidget.sigTextChanged.connect(self.onValueChanged) + self.moveWithZoomToggle.toggled.connect(self.onValueChanged) + + def onValueChanged(self, *args, **kwargs): + self.sigValueChanged.emit(self.kwargs()) + + def selectColor(self): + color = self.colorButton.color() + self.colorButton.origColor = color + self.colorButton.colorDialog.setCurrentColor(color) + self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + self.colorButton.colorDialog.setParent(self) + self.colorButton.colorDialog.open() + w = self.width() + left = self.pos().x() + colorDialogTop = self.colorButton.colorDialog.pos().y() + self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) + + def kwargs(self): + kwargs = { + "color": self.colorButton.color(), + "start_timedelta": self.startTimeWidget.timedelta(), + "loc": self.locCombobox.currentText().lower(), + "font_size": self.fontSizeWidget.text(), + "move_with_zoom": self.moveWithZoomToggle.isChecked(), + } + return kwargs + + def ok_cb(self): + self.cancel = False + self.close() + + +class ExportToImageParametersDialog(QBaseDialog): + sigOk = Signal(dict) + sigAddScaleBar = Signal(bool) + sigRangeChanged = Signal(object) + + def __init__( + self, + parent=None, + startFolderpath="", + startFilename="", + startViewRange=None, + isScaleBarPresent=False, + ): + self.cancel = True + + super().__init__(parent=parent) + + self.setWindowTitle("Preferences for output image") + + mainLayout = QVBoxLayout() + + gridLayout = QGridLayout() + + row = 0 + gridLayout.addWidget(QLabel("View range X axis:"), row, 0) + self.xRangeSelector = widgets.RangeSelector(integers=True) + if startViewRange is not None: + xRange, yRange = startViewRange + self.xRangeSelector.setRange(*xRange) + gridLayout.addWidget(self.xRangeSelector, row, 1) + + row += 1 + gridLayout.addWidget(QLabel("View range Y axis:"), row, 0) + self.yRangeSelector = widgets.RangeSelector(integers=True) + if startViewRange is not None: + xRange, yRange = startViewRange + self.yRangeSelector.setRange(*yRange) + gridLayout.addWidget(self.yRangeSelector, row, 1) + + row += 1 + gridLayout.addWidget(QLabel("Width and Height:"), row, 0) + self.widthHeightSelector = widgets.RangeSelector(integers=True, ordered=False) + if startViewRange is not None: + xRange, yRange = startViewRange + width = int(xRange[1] - xRange[0]) + height = int(yRange[1] - yRange[0]) + self.widthHeightSelector.setRange(width, height) + gridLayout.addWidget(self.widthHeightSelector, row, 1) + self.lockSizeButton = widgets.LockPushButton() + self.lockSizeButton.setCheckable(True) + self.lockSizeButton.setToolTip("Lock width and height") + gridLayout.addWidget(self.lockSizeButton, row, 2) + + row += 1 + gridLayout.addWidget(QLabel("File format:"), row, 0) + self.fileFormatCombobox = QComboBox() + self.fileFormatCombobox.addItems(["SVG", "PNG", "TIFF", "JPEG"]) + gridLayout.addWidget(self.fileFormatCombobox, row, 1) + + row += 1 + self.dpiWidget = widgets.IntLineEdit(allowNegative=False) + self.dpiWidget.setValue(300) + self.dpiWidget.label = QLabel("DPI") + gridLayout.addWidget(self.dpiWidget.label, row, 0) + gridLayout.addWidget(self.dpiWidget, row, 1) + self.dpiWidget.hide() + self.dpiWidget.label.hide() + + row += 1 + gridLayout.addWidget(QLabel("Folder path:"), row, 0) + self.folderPathLineEdit = widgets.ElidingLineEdit(minWidth=240) + self.folderPathLineEdit.setText(startFolderpath) + gridLayout.addWidget(self.folderPathLineEdit, row, 1) + self.browseButton = widgets.browseFileButton( + start_dir=startFolderpath, openFolder=True + ) + gridLayout.addWidget(self.browseButton, row, 2) + + row += 1 + gridLayout.addWidget(QLabel("Filename:"), row, 0) + self.filenameLineEdit = widgets.alphaNumericLineEdit() + self.filenameLineEdit.setAlignment(Qt.AlignCenter) + self.filenameLineEdit.setText(startFilename) + gridLayout.addWidget(self.filenameLineEdit, row, 1) + self.fileFormatLabel = QLabel( + f".{self.fileFormatCombobox.currentText().lower()}" + ) + gridLayout.addWidget(self.fileFormatLabel, row, 2) + + row += 1 + gridLayout.addWidget(QLabel("Add Scale Bar:"), row, 0) + self.addScaleBarToggle = widgets.Toggle() + gridLayout.addWidget(self.addScaleBarToggle, row, 1, alignment=Qt.AlignCenter) + self.addScaleBarToggle.setChecked(isScaleBarPresent) + + self.fileFormatCombobox.currentTextChanged.connect(self.updateFileFormat) + self.browseButton.sigPathSelected.connect(self.updateFolderPath) + self.addScaleBarToggle.toggled.connect(self.addScaleBarToggled) + self.xRangeSelector.sigLowValueChanged.connect(self.x0Changed) + self.xRangeSelector.sigHighValueChanged.connect(self.x1Changed) + self.yRangeSelector.sigLowValueChanged.connect(self.y0Changed) + self.yRangeSelector.sigHighValueChanged.connect(self.y1Changed) + self.widthHeightSelector.sigLowValueChanged.connect(self.widthChanged) + self.widthHeightSelector.sigHighValueChanged.connect(self.heightChanged) + self.widthHeightSelector.sigRangeManuallyChanged.connect( + self.widthHeightManuallyChanged + ) + + buttonsLayout = widgets.CancelOkButtonsLayout() + buttonsLayout.okButton.setText("Export") + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + gridLayout.setColumnStretch(2, 0) + + mainLayout.addLayout(gridLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def widthHeightManuallyChanged(self, *args): + self.lockSizeButton.setChecked(True) + + def x0Changed(self, *args): + if self.lockSizeButton.isChecked(): + x0, _ = self.xRangeSelector.range() + yRange = self.yRangeSelector.range() + width, height = self.widthHeightSelector.range() + x1 = x0 + width + xRange = (x0, x1) + else: + xRange = self.xRangeSelector.range() + yRange = self.yRangeSelector.range() + _, height = self.widthHeightSelector.range() + width = int(xRange[1] - xRange[0]) + + self.xRangeSelector.setRangeNoEmit(*xRange) + self.yRangeSelector.setRangeNoEmit(*yRange) + self.widthHeightSelector.setRangeNoEmit(width, height) + self.rangeChanged() + + def x1Changed(self, *args): + if self.lockSizeButton.isChecked(): + _, x1 = self.xRangeSelector.range() + yRange = self.yRangeSelector.range() + width, height = self.widthHeightSelector.range() + x0 = x1 - width + xRange = (x0, x1) + else: + xRange = self.xRangeSelector.range() + yRange = self.yRangeSelector.range() + _, height = self.widthHeightSelector.range() + width = int(xRange[1] - xRange[0]) + + self.xRangeSelector.setRangeNoEmit(*xRange) + self.yRangeSelector.setRangeNoEmit(*yRange) + self.widthHeightSelector.setRangeNoEmit(width, height) + + self.rangeChanged() + + def y0Changed(self, *args): + if self.lockSizeButton.isChecked(): + xRange = self.xRangeSelector.range() + y0, _ = self.yRangeSelector.range() + width, height = self.widthHeightSelector.range() + y1 = y0 + height + yRange = (y0, y1) + else: + xRange = self.xRangeSelector.range() + yRange = self.yRangeSelector.range() + width, _ = self.widthHeightSelector.range() + height = int(yRange[1] - yRange[0]) + + self.xRangeSelector.setRangeNoEmit(*xRange) + self.yRangeSelector.setRangeNoEmit(*yRange) + self.widthHeightSelector.setRangeNoEmit(width, height) + + self.rangeChanged() + + def y1Changed(self, *args): + if self.lockSizeButton.isChecked(): + xRange = self.xRangeSelector.range() + _, y1 = self.yRangeSelector.range() + width, height = self.widthHeightSelector.range() + y0 = y1 - height + yRange = (y0, y1) + else: + xRange = self.xRangeSelector.range() + yRange = self.yRangeSelector.range() + width, _ = self.widthHeightSelector.range() + height = int(yRange[1] - yRange[0]) + + self.xRangeSelector.setRangeNoEmit(*xRange) + self.yRangeSelector.setRangeNoEmit(*yRange) + self.widthHeightSelector.setRangeNoEmit(width, height) + + self.rangeChanged() + + def widthChanged(self, *args): + self.widthHeightChanged() + self.rangeChanged() + + def heightChanged(self, *args): + self.widthHeightChanged() + self.rangeChanged() + + def updateViewRangeExportToImageDialog(self, viewBox, viewRange, changed): + xRange, yRange = viewRange + self.xRangeSelector.setRangeNoEmit(*xRange) + self.yRangeSelector.setRangeNoEmit(*yRange) + + def widthHeightChanged(self, *args): + x0, _ = self.xRangeSelector.range() + y0, _ = self.yRangeSelector.range() + width, height = self.widthHeightSelector.range() + x1 = x0 + width + y1 = y0 + height + self.xRangeSelector.setRangeNoEmit(x0, x1) + self.yRangeSelector.setRangeNoEmit(y0, y1) + self.rangeChanged() + + def rangeChanged(self, *args): + xRange = self.xRangeSelector.range() + yRange = self.yRangeSelector.range() + self.sigRangeChanged.emit((xRange, yRange)) + + def addScaleBarToggled(self, checked): + self.sigAddScaleBar.emit(checked) + + def updateFolderPath(self, folderPath): + self.folderPathLineEdit.setText(folderPath) + self.browseButton.setStartPath(folderPath) + + def updateFileFormat(self, fileFormat): + if fileFormat == "SVG": + self.dpiWidget.hide() + self.dpiWidget.label.hide() + else: + self.dpiWidget.show() + self.dpiWidget.label.show() + + self.fileFormatLabel.setText(f".{fileFormat.lower()}") + + def validateFolderPath(self): + folderPath = self.folderPathLineEdit.text() + if os.path.exists(folderPath) and os.path.isdir(folderPath): + return True + + text = html_utils.paragraph( + "The selected folder path is not a valid folder or does not exist" + ) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Not a valid folder", text) + return False + + def validateFilename(self): + filename = self.filenameLineEdit.text() + if filename: + return True + + text = html_utils.paragraph("The filename cannot be empty!") + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Not a valid folder", text) + return False + + def validate(self): + proceed = self.validateFolderPath() + if not proceed: + return False + + proceed = self.validateFilename() + if not proceed: + return False + + return True + + def setViewRange(self, xRange, yRange, emitSignal=True): + if self.lockSizeButton.isChecked(): + x0, _ = xRange + y0, _ = yRange + width, height = self.widthHeightSelector.range() + x1 = x0 + width + y1 = y0 + height + xRange = (x0, x1) + yRange = (y0, y1) + else: + width = int(xRange[1] - xRange[0]) + height = int(yRange[1] - yRange[0]) + + self.xRangeSelector.setRangeNoEmit(*xRange) + self.yRangeSelector.setRangeNoEmit(*yRange) + self.widthHeightSelector.setRangeNoEmit(width, height) + if not emitSignal: + return + + self.rangeChanged() + + def viewRange(self): + xRange = self.xRangeSelector.range() + yRange = self.yRangeSelector.range() + return (xRange, yRange) + + def preferences(self): + filename = f"{self.filenameLineEdit.text()}{self.fileFormatLabel.text()}" + preferences = { + "view_range_x": self.xRangeSelector.range(), + "view_range_y": self.yRangeSelector.range(), + "filepath": os.path.join(self.folderPathLineEdit.text(), filename), + "filename": self.filenameLineEdit.text(), + "dpi": self.dpiWidget.value(), + } + return preferences + + def ok_cb(self): + proceed = self.validate() + if not proceed: + return + self.cancel = False + self.sigOk.emit(self.preferences()) + self.selected_preferences = self.preferences() + self.close() + + +class LogoDialog(QDialog): + def __init__(self, logo_path, icon_path, parent=None): + super().__init__(parent) + + layout = QVBoxLayout() + + self.setWindowFlags(Qt.FramelessWindowHint) + # self.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint) + # self.setAttribute(Qt.WA_TranslucentBackground) + # self.setWindowIcon(QIcon(icon_path)) + + labelLogo = QLabel() + pixmapLogo = QPixmap(logo_path) + labelLogo.setPixmap(pixmapLogo) + + layout.addWidget(labelLogo) + + self.setLayout(layout) + + +class ObjectCountDialog(QBaseDialog): + sigShowEvent = Signal() + sigUpdateCounts = Signal() + + def __init__( + self, + categoryCountMapper: dict, + parent=None, + data: list["load.loadData"] | None = None, + ): + super().__init__(parent=parent) + self.setWindowTitle("Object count") + + self.cancel = False + mainLayout = QVBoxLayout() + + cancelOkLayout = widgets.CancelOkButtonsLayout() + cancelOkLayout.okButton.clicked.connect(self.ok_cb) + cancelOkLayout.cancelButton.clicked.connect(self.close) + + self.data = data + if data is not None: + saveCountsButton = widgets.savePushButton("Export counts to CSV table") + saveCountsButton.clicked.connect(self.saveCounts) + cancelOkLayout.insertWidget(3, saveCountsButton) + + updateCountsButton = widgets.reloadPushButton("Update counts") + cancelOkLayout.insertWidget(3, updateCountsButton) + updateCountsButton.clicked.connect(self.emitUpdateCounts) + + mainLayout.addWidget( + QLabel(html_utils.paragraph("Object count
    ", font_size="18px")), + alignment=Qt.AlignLeft, + ) + self.showHideButtons = [] + self.categoryLabelMapper = {} + for category, count in categoryCountMapper.items(): + categoryLayout = QHBoxLayout() + categoryLayout.addSpacing(10) + catText = html_utils.paragraph(f"
    {category}
    ", font_size="13px") + catLabel = QLabel(catText) + categoryLayout.addWidget(catLabel) + categoryLayout.addStretch(1) + + countText = html_utils.paragraph(f"
    {count}
    ", font_size="13px") + countLabel = QLabel(countText) + categoryLayout.addWidget(countLabel) + + self.categoryLabelMapper[category] = countLabel + + showHideButton = widgets.showDetailsButton(txt="") + showHideButton.setChecked(True) + showHideButton.sigToggled.connect( + partial(self.showHideCount, labels=(catLabel, countLabel)) + ) + showHideButton.setToolTip(f'Show/hide "{category}" count') + categoryLayout.addSpacing(10) + categoryLayout.addWidget(showHideButton) + showHideButton.category = category + + self.showHideButtons.append(showHideButton) + + categoryLayout.setStretch(0, 0) + categoryLayout.setStretch(1, 0) + categoryLayout.setStretch(3, 0) + + mainLayout.addLayout(categoryLayout) + mainLayout.addWidget(widgets.QHLine()) + + mainLayout.addSpacing(10) + + infoLayout = QHBoxLayout() + self.livePreviewCheckbox = QCheckBox("Live preview") + self.livePreviewCheckbox.setChecked(True) + infoLayout.addWidget(self.livePreviewCheckbox) + infoLayout.addStretch(1) + self.warnLabel = QLabel("") + infoLayout.addWidget(self.warnLabel) + self.livePreviewCheckbox.toggled.connect(self.updateWarnLabel) + mainLayout.addLayout(infoLayout) + + mainLayout.addSpacing(30) + mainLayout.addStretch(1) + mainLayout.addLayout(cancelOkLayout) + + self.setLayout(mainLayout) + + def saveCounts(self, checked=False): + categories = self.activeCategories() + for posData in self.data: + countMapper = posData.countObjectsInSegm(categories) + countMapper.pop("In current frame", None) + df_count_endname = posData.saveObjCounts(countMapper) + + txt = html_utils.paragraph(f""" + Done!

    + Objects count table saved in every loaded Position folder
    + as a CSV file ending with {df_count_endname} + """) + msg = widgets.myMessageBox(wrapText=False) + msg.information(self, "Objects count saved", txt) + + def updateWarnLabel(self, checked): + if not checked: + self.warnLabel.setText( + html_utils.paragraph( + "WARNING: without live preview, counts are not updated", + font_color="red", + ) + ) + else: + self.warnLabel.setText("") + + def emitUpdateCounts(self): + self.sigUpdateCounts.emit() + + def activeCategories(self) -> List[str]: + activeCategories = [] + for showHideButton in self.showHideButtons: + if not showHideButton.isChecked(): + continue + activeCategories.append(showHideButton.category) + + return activeCategories + + def showHideCount(self, checked, labels): + for label in labels: + label.setVisible(checked) + + QTimer.singleShot(100, self.resizeToHeightHint) + + def updateCounts(self, categoryCountMapper): + for category, count in categoryCountMapper.items(): + countLabel = self.categoryLabelMapper[category] + countText = html_utils.paragraph(f"
    {count}
    ", font_size="13px") + countLabel.setText(countText) + + def resizeToHeightHint(self): + heightHint = self.sizeHint().height() + self.resize(self.width(), heightHint) + + def showEvent(self, event): + widthHint = self.sizeHint().width() + self.resize(int(widthHint * 1.5), self.height()) + self.sigShowEvent.emit() + + def ok_cb(self): + self.cancel = False + self.close() + +# Sibling imports (deferred to avoid import cycles) +from .models import ( + DataFrameModel, +) + diff --git a/cellacdc/dialogs/general.py b/cellacdc/dialogs/general.py new file mode 100644 index 000000000..dd9cf5944 --- /dev/null +++ b/cellacdc/dialogs/general.py @@ -0,0 +1,3414 @@ +"""Cell-ACDC dialog windows: general.""" + +import os +import sys +import re +from typing import Literal, Callable, Dict, Iterable, List, Tuple +import datetime +import pathlib +from collections import defaultdict +import zipfile +from heapq import nlargest +import matplotlib +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.patches import Rectangle, Circle, PathPatch, Path +import numpy as np +import scipy.interpolate + +try: + import tkinter as tk +except Exception as err: + pass + +import cv2 +import traceback +from itertools import combinations, permutations +from collections import namedtuple +from natsort import natsorted + +# from MyWidgets import Slider, Button, MyRadioButtons +from skimage.measure import label, regionprops +from functools import partial +import skimage.filters +import skimage.measure +import skimage.morphology +import skimage.exposure +import skimage.draw +import skimage.registration +import skimage.color +import skimage.segmentation +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk +import matplotlib.pyplot as plt +import seaborn as sns +import pandas as pd +import math +import time +import sympy as sp +import json +import html + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from qtpy import QtCore +from qtpy.QtGui import ( + QIcon, + QFontMetrics, + QKeySequence, + QFont, + QRegularExpressionValidator, + QCursor, + QKeyEvent, + QPixmap, + QFont, + QPalette, + QMouseEvent, + QColor, +) +from qtpy.QtCore import ( + Qt, + QSize, + QEvent, + Signal, + QEventLoop, + QTimer, + QRegularExpression, +) +from qtpy.QtWidgets import ( + QFileDialog, + QApplication, + QMainWindow, + QMenu, + QLabel, + QToolBar, + QScrollBar, + QWidget, + QVBoxLayout, + QLineEdit, + QPushButton, + QHBoxLayout, + QDialog, + QFormLayout, + QListWidget, + QAbstractItemView, + QButtonGroup, + QCheckBox, + QSizePolicy, + QComboBox, + QSlider, + QGridLayout, + QSpinBox, + QToolButton, + QTableView, + QTextBrowser, + QDoubleSpinBox, + QScrollArea, + QFrame, + QProgressBar, + QGroupBox, + QRadioButton, + QDockWidget, + QMessageBox, + QStyle, + QPlainTextEdit, + QSpacerItem, + QTreeWidget, + QTreeWidgetItem, + QTextEdit, + QSplashScreen, + QAction, + QListWidgetItem, + QActionGroup, + QHeaderView, + QStyledItemDelegate, +) +import qtpy.compat + +from .. import exception_handler +from .. import load, prompts, core, measurements, html_utils +from .. import is_mac, is_win, is_linux, settings_folderpath, config +from .. import preproc_recipes_path, segm_recipes_path, combine_channels_recipes_path +from .. import is_conda_env +from .. import printl +from .. import colors +from .. import issues_url +from .. import myutils +from .. import qutils +from .. import _palettes +from .. import base_cca_dict +from .. import widgets +from .. import user_profile_path, promptable_models_path, models_path +from .. import features +from .. import _core +from .. import _types +from .. import plot +from .. import urls +from ..acdc_regex import float_regex, is_alphanumeric_filename, to_alphanumeric +from .. import _base_widgets +from .. import io +from .. import cca_functions +from .. import path + +POSITIVE_FLOAT_REGEX = float_regex(allow_negative=False) +TREEWIDGET_STYLESHEET = _palettes.TreeWidgetStyleSheet() +LISTWIDGET_STYLESHEET = _palettes.ListWidgetStyleSheet() +BACKGROUND_RGBA = _palettes.get_disabled_colors()["Button"] + +font = QFont() +font.setPixelSize(12) +italicFont = QFont() +italicFont.setPixelSize(12) +italicFont.setItalic(True) + +from ._base import ( + QBaseDialog, +) + +class customAnnotationDialog(QDialog): + sigDeleteSelecAnnot = Signal(object) + + def __init__(self, savedCustomAnnot, parent=None, state=None): + self.cancel = True + self.loop = None + self.clickedButton = None + self.savedCustomAnnot = savedCustomAnnot + + self.internalNames = measurements.get_all_acdc_df_colnames(include_custom=False) + + super().__init__(parent) + + self.setWindowTitle("Custom annotation") + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + + layout = widgets.FormLayout() + + row = 0 + typeCombobox = QComboBox() + typeCombobox.addItems( + ["Single time-point", "Multiple time-points", "Multiple values class"] + ) + if state is not None: + typeCombobox.setCurrentText(state["type"]) + self.typeCombobox = typeCombobox + body_txt = """ + Single time-point annotation: use this to annotate + an event that happens on a single frame in time + (e.g. cell division). +

    + Multiple time-points annotation: use this to annotate + an event that has a duration, i.e., a start frame and a stop + frame (e.g. cell cycle phase).

    + Multiple values class annotation: use this to annotate a class + that has multiple values. An example could be a cell cycle stage + that can have different values, such as 2-cells division + or 4-cells division. + """ + typeInfoTxt = f"{html_utils.paragraph(body_txt)}" + self.typeWidget = widgets.formWidget( + typeCombobox, + addInfoButton=True, + labelTextLeft="Type: ", + parent=self, + infoTxt=typeInfoTxt, + ) + layout.addFormWidget(self.typeWidget, row=row) + typeCombobox.currentTextChanged.connect(self.warnType) + + row += 1 + nameInfoTxt = """ + Name of the column that will be saved in the acdc_output.csv + file.

    + Valid charachters are letters and numbers separate by underscore + or dash only.

    + Additionally, some names are reserved because they are used + by Cell-ACDC for standard measurements.

    + Internally reserved names: + """ + self.nameInfoTxt = f"{html_utils.paragraph(nameInfoTxt)}" + self.nameWidget = widgets.formWidget( + widgets.alphaNumericLineEdit(), + addInfoButton=True, + labelTextLeft="Name: ", + parent=self, + infoTxt=self.nameInfoTxt, + ) + self.nameWidget.infoButton.disconnect() + self.nameWidget.infoButton.clicked.connect(self.showNameInfo) + if state is not None: + self.nameWidget.widget.setText(state["name"]) + self.nameWidget.widget.textChanged.connect(self.checkName) + layout.addFormWidget(self.nameWidget, row=row) + + row += 1 + self.nameInfoLabel = QLabel() + layout.addWidget(self.nameInfoLabel, row, 0, 1, 2, alignment=Qt.AlignCenter) + + row += 1 + spacing = QSpacerItem(10, 10) + layout.addItem(spacing, row, 0) + + row += 1 + symbolInfoTxt = """ + Symbol that will be drawn on the annotated cell at + the requested time frame. + """ + symbolInfoTxt = f"{html_utils.paragraph(symbolInfoTxt)}" + self.symbolWidget = widgets.formWidget( + widgets.pgScatterSymbolsCombobox(), + addInfoButton=True, + labelTextLeft="Symbol: ", + parent=self, + infoTxt=symbolInfoTxt, + ) + if state is not None: + self.symbolWidget.widget.setCurrentText(state["symbol"]) + layout.addFormWidget(self.symbolWidget, row=row) + + row += 1 + shortcutInfoTxt = """ + Shortcut that you can use to activate/deactivate annotation + of this event.

    Leave empty if you don't need a shortcut. + """ + shortcutInfoTxt = f"{html_utils.paragraph(shortcutInfoTxt)}" + self.shortcutWidget = widgets.formWidget( + widgets.ShortcutLineEdit(), + addInfoButton=True, + labelTextLeft="Shortcut: ", + parent=self, + infoTxt=shortcutInfoTxt, + ) + if state is not None: + self.shortcutWidget.widget.setText(state["shortcut"]) + layout.addFormWidget(self.shortcutWidget, row=row) + + row += 1 + descInfoTxt = """ + Description will be used as the tool tip that will be + displayed when you hover with th mouse cursor on the toolbar button + specific for this annotation + """ + descInfoTxt = f"{html_utils.paragraph(descInfoTxt)}" + self.descWidget = widgets.formWidget( + QPlainTextEdit(), + addInfoButton=True, + labelTextLeft="Description: ", + parent=self, + infoTxt=descInfoTxt, + ) + if state is not None: + self.descWidget.widget.setPlainText(state["description"]) + layout.addFormWidget(self.descWidget, row=row) + + row += 1 + optionsGroupBox = QGroupBox("Additional options") + optionsLayout = QGridLayout() + toggle = widgets.Toggle() + toggle.setChecked(True) + self.keepActiveToggle = toggle + toggleLabel = QLabel("Keep tool active after using it: ") + colorButtonLabel = QLabel("Symbol color: ") + self.hideAnnotTooggle = widgets.Toggle() + self.hideAnnotTooggle.setChecked(True) + hideAnnotTooggleLabel = QLabel("Hide annotation when button is not active: ") + self.colorButton = widgets.myColorButton(color=(255, 0, 0)) + self.colorButton.clicked.disconnect() + self.colorButton.clicked.connect(self.selectColor) + + optionsLayout.setColumnStretch(0, 1) + optRow = 0 + optionsLayout.addWidget(toggleLabel, optRow, 1) + optionsLayout.addWidget(toggle, optRow, 2) + optRow += 1 + optionsLayout.addWidget(hideAnnotTooggleLabel, optRow, 1) + optionsLayout.addWidget(self.hideAnnotTooggle, optRow, 2) + optionsLayout.setColumnStretch(3, 1) + optRow += 1 + optionsLayout.addWidget(colorButtonLabel, optRow, 1) + optionsLayout.addWidget(self.colorButton, optRow, 2) + + optionsGroupBox.setLayout(optionsLayout) + layout.addWidget(optionsGroupBox, row, 1, alignment=Qt.AlignCenter) + optionsInfoButton = QPushButton(self) + optionsInfoButton.setCursor(Qt.WhatsThisCursor) + optionsInfoButton.setIcon(QIcon(":info.svg")) + optionsInfoButton.clicked.connect(self.showOptionsInfo) + layout.addWidget(optionsInfoButton, row, 3, alignment=Qt.AlignRight) + + row += 1 + layout.addItem(QSpacerItem(5, 5), row, 0) + + row += 1 + noteText = ( + "NOTE: you can change these options later with
    " + "RIGHT-click on the associated left-side toolbar button.
    " + ) + noteLabel = QLabel(html_utils.paragraph(noteText, font_size="11px")) + layout.addWidget(noteLabel, row, 1, 1, 3) + + buttonsLayout = QHBoxLayout() + + self.loadSavedAnnotButton = widgets.OpenFilePushButton(" Load annotation... ") + if not savedCustomAnnot: + self.loadSavedAnnotButton.setDisabled(True) + self.okButton = widgets.okPushButton(" Ok ") + cancelButton = widgets.cancelPushButton("Cancel") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(self.loadSavedAnnotButton) + buttonsLayout.addWidget(self.okButton) + + cancelButton.clicked.connect(self.cancelCallBack) + self.cancelButton = cancelButton + self.loadSavedAnnotButton.clicked.connect(self.loadSavedAnnot) + self.okButton.clicked.connect(self.ok_cb) + self.okButton.setFocus() + + mainLayout = QVBoxLayout() + + noteTxt = """ + Custom annotations will be saved in the acdc_output.csv
    + file as a column with the name you write in the field Name
    + """ + noteTxt = f"{html_utils.paragraph(noteTxt, font_size='15px')}" + noteLabel = QLabel(noteTxt) + noteLabel.setAlignment(Qt.AlignCenter) + mainLayout.addWidget(noteLabel) + + mainLayout.addLayout(layout) + mainLayout.addStretch(1) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def checkName(self, text): + if not text: + txt = "Name cannot be empty" + self.nameInfoLabel.setText( + html_utils.paragraph(txt, font_size="11px", font_color="red") + ) + return + for name in self.internalNames: + if name.find(text) != -1: + txt = f'"{text}" cannot be part of the name, because reserved.' + self.nameInfoLabel.setText( + html_utils.paragraph(txt, font_size="11px", font_color="red") + ) + break + else: + self.nameInfoLabel.setText("") + + def loadSavedAnnot(self): + items = list(self.savedCustomAnnot.keys()) + self.selectAnnotWin = widgets.QDialogListbox( + "Load annotation parameters", + "Select annotation to load:", + items, + additionalButtons=("Delete selected annnotations",), + parent=self, + multiSelection=False, + ) + for button in self.selectAnnotWin._additionalButtons: + button.disconnect() + button.clicked.connect(self.deleteSelectedAnnot) + self.selectAnnotWin.exec_() + if self.selectAnnotWin.cancel: + return + if self.selectAnnotWin.listBox.count() == 0: + return + if not self.selectAnnotWin.selectedItemsText: + self.warnNoItemsSelected() + return + selectedName = self.selectAnnotWin.selectedItemsText[-1] + selectedAnnot = self.savedCustomAnnot[selectedName] + self.typeCombobox.setCurrentText(selectedAnnot["type"]) + self.nameWidget.widget.setText(selectedAnnot["name"]) + self.symbolWidget.widget.setCurrentText(selectedAnnot["symbol"]) + self.shortcutWidget.widget.setText(selectedAnnot["shortcut"]) + self.descWidget.widget.setPlainText(selectedAnnot["description"]) + self.colorButton.setColor(selectedAnnot["symbolColor"]) + keySequence = widgets.macShortcutToWindows(selectedAnnot["shortcut"]) + if keySequence: + self.shortcutWidget.widget.keySequence = widgets.KeySequenceFromText( + keySequence + ) + + def warnNoItemsSelected(self): + msg = widgets.myMessageBox(parent=self) + msg.setIcon(iconName="SP_MessageBoxWarning") + msg.setWindowTitle("Delete annotation?") + msg.addText("You didn't select any annotation!") + msg.addButton(" Ok ") + msg.exec_() + + def deleteSelectedAnnot(self): + msg = widgets.myMessageBox(parent=self) + msg.setIcon(iconName="SP_MessageBoxWarning") + msg.setWindowTitle("Delete annotation?") + msg.addText("Are you sure you want to delete the selected annotations?") + msg.addButton("Yes") + cancelButton = msg.addButton(" Cancel ") + msg.exec_() + if msg.clickedButton == cancelButton: + return + for item in self.selectAnnotWin.listBox.selectedItems(): + name = item.text() + self.savedCustomAnnot.pop(name) + self.sigDeleteSelecAnnot.emit(self.selectAnnotWin.listBox.selectedItems()) + items = list(self.savedCustomAnnot.keys()) + self.selectAnnotWin.listBox.clear() + self.selectAnnotWin.listBox.addItems(items) + + def selectColor(self): + color = self.colorButton.color() + self.colorButton.origColor = color + self.colorButton.colorDialog.setCurrentColor(color) + self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + self.colorButton.colorDialog.open() + w = self.width() + left = self.pos().x() + colorDialogTop = self.colorButton.colorDialog.pos().y() + self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) + + def warnType(self, currentText): + if currentText == "Single time-point": + return + + self.typeCombobox.setCurrentIndex(0) + + txt = """ + Unfortunately, the only annotation type that is available so far is + Single time-point.

    + We are working on implementing the other types too, so stay tuned!

    + Thank you for your patience! + """ + txt = f"{html_utils.paragraph(txt)}" + msg = widgets.myMessageBox() + msg.setIcon(iconName="SP_MessageBoxWarning") + msg.setWindowTitle(f"Feature not implemented yet") + msg.addText(txt) + msg.addButton(" Ok ") + msg.exec_() + + def showOptionsInfo(self): + info = """ + Keep tool active after using it: Choose whether the tool + should stay active or not after annotating.

    + Hide annotation when button is not active: Choose whether + annotation on the cell/object should be visible only if the + button is active or also when it is not active.
    + NOTE: annotations are always stored no matter whether + they are visible or not.

    + Symbol color: Choose color of the symbol that will be used + to label annotated cell/object. + """ + info = f"{html_utils.paragraph(info)}" + msg = widgets.myMessageBox() + msg.setIcon() + msg.setWindowTitle(f"Additional options info") + msg.addText(info) + msg.addButton(" Ok ") + msg.exec_() + + def ok_cb(self, checked=True): + self.cancel = False + self.clickedButton = self.okButton + self.close() + + def cancelCallBack(self, checked=True): + self.cancel = True + self.clickedButton = self.cancelButton + self.close() + + def showNameInfo(self): + msg = widgets.myMessageBox() + listView = widgets.readOnlyQList(msg) + listView.addItems(self.internalNames) + # listView.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + msg.information( + self, "Annotation Name info", self.nameInfoTxt, widgets=listView + ) + + def closeEvent(self, event): + if self.clickedButton is None or self.clickedButton == self.cancelButton: + # cancel button or closed with 'x' button + self.cancel = True + return + + if self.clickedButton == self.okButton and not self.nameWidget.widget.text(): + msg = QMessageBox() + msg.critical(self, "Empty name", "The name cannot be empty!", msg.Ok) + event.ignore() + self.cancel = True + return + + if self.clickedButton == self.okButton and self.nameInfoLabel.text(): + msg = widgets.myMessageBox() + listView = widgets.listWidget(msg) + listView.addItems(self.internalNames) + listView.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + name = self.nameWidget.widget.text() + txt = ( + f'"{name}" cannot be part of the name, ' + "because it is reserved for standard measurements " + "saved by Cell-ACDC.

    " + "Internally reserved names:" + ) + msg.critical( + self, "Not a valid name", html_utils.paragraph(txt), widgets=listView + ) + event.ignore() + self.cancel = True + return + + self.toolTip = ( + f"Name: {self.nameWidget.widget.text()}\n\n" + f"Type: {self.typeWidget.widget.currentText()}\n\n" + f"Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n" + f"Description: {self.descWidget.widget.toPlainText()}\n\n" + f'SHORTCUT: "{self.shortcutWidget.widget.text()}"' + ) + + symbol = self.symbolWidget.widget.currentText() + self.symbol = re.findall(r"\'(.+)\'", symbol)[0] + + self.state = { + "type": self.typeWidget.widget.currentText(), + "name": self.nameWidget.widget.text(), + "symbol": self.symbolWidget.widget.currentText(), + "shortcut": self.shortcutWidget.widget.text(), + "description": self.descWidget.widget.toPlainText(), + "keepActive": self.keepActiveToggle.isChecked(), + "isHideChecked": self.hideAnnotTooggle.isChecked(), + "symbolColor": self.colorButton.color(), + } + + if self.loop is not None: + self.loop.exit() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + super().show() + if block: + self.loop = QEventLoop() + self.loop.exec_() + + +class _PointsLayerAppearanceGroupbox(QGroupBox): + def __init__(self, *args): + super().__init__(*args) + + self.setTitle("Points appearance") + + layout = widgets.FormLayout() + + "----------------------------------------------------------------------" + row = 0 + symbolInfoTxt = """ + Symbol used to draw the points. + """ + symbolInfoTxt = f"{html_utils.paragraph(symbolInfoTxt)}" + self.symbolWidget = widgets.formWidget( + widgets.pgScatterSymbolsCombobox(), + addInfoButton=True, + labelTextLeft="Symbol: ", + parent=self, + infoTxt=symbolInfoTxt, + stretchWidget=False, + ) + layout.addFormWidget(self.symbolWidget, row=row) + "----------------------------------------------------------------------" + + "----------------------------------------------------------------------" + row += 1 + self.colorButton = widgets.myColorButton(color=(255, 0, 0)) + self.colorWidget = widgets.formWidget( + self.colorButton, stretchWidget=True, labelTextLeft="Colour: ", parent=self + ) + layout.addFormWidget(self.colorWidget, align=Qt.AlignLeft, row=row) + self.colorButton.clicked.disconnect() + self.colorButton.clicked.connect(self.selectColor) + "----------------------------------------------------------------------" + + "----------------------------------------------------------------------" + row += 1 + self.sizeSpinBox = widgets.SpinBox() + self.sizeSpinBox.setValue(5) + self.sizeWidget = widgets.formWidget( + self.sizeSpinBox, stretchWidget=True, labelTextLeft="Size: ", parent=self + ) + layout.addFormWidget(self.sizeWidget, row=row) + "----------------------------------------------------------------------" + + "----------------------------------------------------------------------" + row += 1 + zHeightTooltip = ( + 'If "Z-depth" is greater than 1, the points will be annotated ' + "in all the z-slices in the range `z - (Z-depth/2) < z < z + (Z-depth/2)`\n" + "where `z` is the center z-slice of the added point." + ) + self.zHeightSpinBox = widgets.OddSpinBox() + self.zHeightSpinBox.setValue(1) + self.zHeightSpinBox.setMinimum(1) + self.zHeightWidget = widgets.formWidget( + self.zHeightSpinBox, + stretchWidget=True, + labelTextLeft="Z-depth: ", + parent=self, + toolTip=zHeightTooltip, + ) + layout.addFormWidget(self.zHeightWidget, row=row) + "----------------------------------------------------------------------" + + "----------------------------------------------------------------------" + row += 1 + shortcutInfoTxt = """ + Shortcut that you can use to hide/show points. + """ + shortcutInfoTxt = f"{html_utils.paragraph(shortcutInfoTxt)}" + self.shortcutWidget = widgets.formWidget( + widgets.ShortcutLineEdit(), + addInfoButton=True, + labelTextLeft="Shortcut: ", + parent=self, + infoTxt=shortcutInfoTxt, + ) + layout.addFormWidget(self.shortcutWidget, row=row) + "----------------------------------------------------------------------" + + self.setLayout(layout) + + def restoreState(self, state): + self.shortcutWidget.widget.setText(state["shortcut"]) + self.colorButton.setColor(state["color"]) + self.symbolWidget.widget.setCurrentText(state["symbol"]) + self.sizeSpinBox.setValue(state["pointSize"]) + self.zHeightSpinBox.setValue(state["zHeight"]) + + def selectColor(self): + color = self.colorButton.color() + self.colorButton.origColor = color + self.colorButton.colorDialog.setCurrentColor(color) + self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + self.colorButton.colorDialog.open() + w = self.width() + left = self.pos().x() + colorDialogTop = self.colorButton.colorDialog.pos().y() + self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) + + def state(self): + r, g, b, a = self.colorButton.color().getRgb() + _state = { + "symbol": self.symbolWidget.widget.currentText(), + "color": (r, g, b), + "pointSize": self.sizeSpinBox.value(), + "zHeight": self.zHeightSpinBox.value(), + "shortcut": self.shortcutWidget.widget.text(), + } + return _state + + +class AddPointsLayerDialog(QBaseDialog): + sigClosed = Signal() + sigCriticalReadTable = Signal(str) + sigLoadedTable = Signal(object, str) + sigCheckClickEntryTableEndnameExists = Signal(str, bool) + + def __init__( + self, + channelNames=None, + imagesPath="", + SizeT=1, + hideCentroidsSection=False, + hideWeightedCentroidsSection=False, + hideFromTableSection=False, + hideManualEntrySection=False, + hideWithMouseClicksSection=False, + parent=None, + ): + self.cancel = True + super().__init__(parent) + + self._parent = parent + + self.imagesPath = imagesPath + + self.setWindowTitle("Add points layer") + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + + mainLayout = QVBoxLayout() + + scrollArea = widgets.ScrollArea() + typeGroupbox = QGroupBox("Points to draw") + typeLayout = QGridLayout() + typeGroupbox.setLayout(typeLayout) + typeLayout.addItem(QSpacerItem(10, 1), 0, 0) + typeLayout.setColumnStretch(0, 0) + typeLayout.setColumnStretch(2, 1) + vSpacing = 15 + + row = 0 + + sections = ( + ("addCentroidsSection", hideCentroidsSection), + ("addWeightedCentroidsSection", hideWeightedCentroidsSection), + ("addFromTableSection", hideFromTableSection), + ("addManualEntrySection", hideManualEntrySection), + ("addWithMouseClicksSection", hideWithMouseClicksSection), + ) + radioButtonChecked = False + for section, hideSection in sections: + addFunc = getattr(self, section) + row, sectionWidgets = addFunc( + row, + typeLayout, + imagesPath=imagesPath, + SizeT=SizeT, + channelNames=channelNames, + ) + if not hideSection: + spacer = QSpacerItem(1, vSpacing) + typeLayout.addItem(spacer, row, 0) + row += 1 + if not radioButtonChecked: + sectionWidgets[0].setChecked(True) + radioButtonChecked = True + continue + + for widget in sectionWidgets: + widget.setVisible(False) + + self.scrollArea = scrollArea + scrollArea.setWidget(typeGroupbox) + + self.appearanceGroupbox = _PointsLayerAppearanceGroupbox() + self.appearanceGroupbox.sizeSpinBox.setValue(3) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + self.buttonsLayout = buttonsLayout + + mainLayout.addWidget(scrollArea) + mainLayout.addSpacing(20) + _layout = QHBoxLayout() + _layout.addWidget(self.appearanceGroupbox) + _layout.addStretch(1) + mainLayout.addLayout(_layout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + self.setFont(font) + + def addCentroidsSection(self, row, layout, **kwargs): + sectionWidgets = [] + self.centroidsRadiobutton = QRadioButton("Centroids") + layout.addWidget(self.centroidsRadiobutton, row, 0, 1, 2) + sectionWidgets.append(self.centroidsRadiobutton) + + self.centroidsRadiobutton.setChecked(True) + return row + 1, sectionWidgets + + def addWeightedCentroidsSection(self, row, layout, channelNames=None, **kwargs): + if channelNames is None: + channelNames = [] + + sectionWidgets = [] + + self.weightedCentroidsRadiobutton = QRadioButton("Weighted centroids") + layout.addWidget(self.weightedCentroidsRadiobutton, row, 0, 1, 2) + sectionWidgets.append(self.weightedCentroidsRadiobutton) + + row += 1 + label = QLabel("Weighing channel: ") + label.setEnabled(False) + layout.addWidget(label, row, 1) + sectionWidgets.append(label) + + self.channelNameForWeightedCentr = widgets.QCenteredComboBox() + if channelNames: + self.channelNameForWeightedCentr.addItems(channelNames) + self.channelNameForWeightedCentr.setDisabled(True) + layout.addWidget(self.channelNameForWeightedCentr, row, 2) + sectionWidgets.append(self.channelNameForWeightedCentr) + + self.weightedCentroidsRadiobutton.toggled.connect(label.setEnabled) + self.weightedCentroidsRadiobutton.toggled.connect( + self.channelNameForWeightedCentr.setEnabled + ) + + return row + 1, sectionWidgets + + def addFromTableSection(self, row, layout, imagesPath="", SizeT=1, **kwargs): + sectionWidgets = [] + + self.fromTableRadiobutton = QRadioButton("From table") + layout.addWidget(self.fromTableRadiobutton, row, 0, 1, 2) + sectionWidgets.append(self.fromTableRadiobutton) + self.fromTableRadiobutton.widgets = [] + + row += 1 + self.tablePath = widgets.ElidingLineEdit() + self.tablePath.label = QLabel("Table file path: ") + layout.addWidget(self.tablePath.label, row, 1) + layout.addWidget(self.tablePath, row, 2) + self.fromTableRadiobutton.widgets.append(self.tablePath) + sectionWidgets.append(self.tablePath.label) + sectionWidgets.append(self.tablePath) + + browseButton = widgets.browseFileButton( + start_dir=imagesPath, ext={"Table": [".csv", ".h5"]} + ) + layout.addWidget(browseButton, row, 3) + browseButton.sigPathSelected.connect(self.tablePathSelected) + self.browseTableButton = browseButton + self.fromTableRadiobutton.widgets.append(browseButton) + sectionWidgets.append(browseButton) + + row += 1 + self.xColName = widgets.QCenteredComboBox() + self.xColName.addItem("None") + self.xColName.label = QLabel("X coord. column: ") + layout.addWidget(self.xColName.label, row, 1) + layout.addWidget(self.xColName, row, 2) + self.xColName.currentTextChanged.connect(self.checkColNameX) + self.fromTableRadiobutton.widgets.append(self.xColName) + sectionWidgets.append(self.xColName.label) + sectionWidgets.append(self.xColName) + + row += 1 + self.yColName = widgets.QCenteredComboBox() + self.yColName.addItem("None") + self.yColName.label = QLabel("Y coord. column: ") + layout.addWidget(self.yColName.label, row, 1) + layout.addWidget(self.yColName, row, 2) + self.yColName.currentTextChanged.connect(self.checkColNameY) + self.fromTableRadiobutton.widgets.append(self.yColName) + sectionWidgets.append(self.yColName.label) + sectionWidgets.append(self.yColName) + + row += 1 + self.zColName = widgets.QCenteredComboBox() + self.zColName.addItem("None") + self.zColName.label = QLabel("Z coord. column: ") + layout.addWidget(self.zColName.label, row, 1) + layout.addWidget(self.zColName, row, 2) + self.zColName.currentTextChanged.connect(self.checkColNameZ) + self.fromTableRadiobutton.widgets.append(self.zColName) + sectionWidgets.append(self.zColName.label) + sectionWidgets.append(self.zColName) + + row += 1 + self.tColName = widgets.QCenteredComboBox() + self.tColName.addItem("None") + self.tColName.label = QLabel("Frame index column: ") + layout.addWidget(self.tColName.label, row, 1) + layout.addWidget(self.tColName, row, 2) + self.fromTableRadiobutton.widgets.append(self.tColName) + sectionWidgets.append(self.tColName.label) + sectionWidgets.append(self.tColName) + + if SizeT == 1: + self.tColName.clear() + self.tColName.addItem("None") + self.tColName.label.setVisible(False) + self.tColName.setVisible(False) + + self.fromTableRadiobutton.toggled.connect(self.enableRadioButtonWidgets) + self.enableRadioButtonWidgets(False, sender=self.fromTableRadiobutton) + + return row + 1, sectionWidgets + + def addManualEntrySection(self, row, layout, SizeT=1, **kwargs): + sectionWidgets = [] + + self.manualEntryRadiobutton = QRadioButton("Manual entry") + layout.addWidget(self.manualEntryRadiobutton, row, 0, 1, 2) + self.manualEntryRadiobutton.widgets = [] + sectionWidgets.append(self.manualEntryRadiobutton) + + row += 1 + self.manualXspinbox = widgets.NumericCommaLineEdit() + self.manualXspinbox.label = QLabel("X coords: ") + layout.addWidget(self.manualXspinbox.label, row, 1) + layout.addWidget(self.manualXspinbox, row, 2) + self.manualEntryRadiobutton.widgets.append(self.manualXspinbox) + sectionWidgets.append(self.manualXspinbox.label) + sectionWidgets.append(self.manualXspinbox) + + row += 1 + self.manualYspinbox = widgets.NumericCommaLineEdit() + self.manualYspinbox.label = QLabel("Y coords: ") + layout.addWidget(self.manualYspinbox.label, row, 1) + layout.addWidget(self.manualYspinbox, row, 2) + self.manualEntryRadiobutton.widgets.append(self.manualYspinbox) + sectionWidgets.append(self.manualYspinbox.label) + sectionWidgets.append(self.manualYspinbox) + + row += 1 + self.manualZspinbox = widgets.NumericCommaLineEdit() + self.manualZspinbox.label = QLabel("Z coords: ") + layout.addWidget(self.manualZspinbox.label, row, 1) + layout.addWidget(self.manualZspinbox, row, 2) + self.manualEntryRadiobutton.widgets.append(self.manualZspinbox) + sectionWidgets.append(self.manualZspinbox.label) + sectionWidgets.append(self.manualZspinbox) + + row += 1 + self.manualTspinbox = widgets.NumericCommaLineEdit() + self.manualTspinbox.label = QLabel("Frame numbers: ") + layout.addWidget(self.manualTspinbox.label, row, 1) + layout.addWidget(self.manualTspinbox, row, 2) + self.manualEntryRadiobutton.widgets.append(self.manualTspinbox) + sectionWidgets.append(self.manualTspinbox.label) + sectionWidgets.append(self.manualTspinbox) + + if SizeT == 1: + self.manualTspinbox.setVisible(False) + self.manualTspinbox.label.setVisible(False) + + self.manualEntryRadiobutton.toggled.connect(self.enableRadioButtonWidgets) + self.enableRadioButtonWidgets(False, sender=self.manualEntryRadiobutton) + + return row + 1, sectionWidgets + + def addWithMouseClicksSection(self, row, layout, imagesPath="", **kwargs): + sectionWidgets = [] + + self.clickEntryIsLoadedDf = None + + self.clickEntryRadiobutton = QRadioButton("Add points with mouse clicks") + layout.addWidget(self.clickEntryRadiobutton, row, 0, 1, 2) + self.clickEntryRadiobutton.widgets = [] + sectionWidgets.append(self.clickEntryRadiobutton) + + row += 1 + self.snapToMaxToggle = widgets.Toggle() + self.snapToMaxToggle.label = QLabel("Snap to closest maximum: ") + layout.addWidget(self.snapToMaxToggle.label, row, 1) + layout.addWidget(self.snapToMaxToggle, row, 2, alignment=Qt.AlignCenter) + sectionWidgets.append(self.snapToMaxToggle.label) + sectionWidgets.append(self.snapToMaxToggle) + + self.snapToMaxInfoButton = widgets.infoPushButton() + layout.addWidget(self.snapToMaxInfoButton, row, 3) + sectionWidgets.append(self.snapToMaxInfoButton) + + self.snapToMaxInfoButton.clicked.connect(self.showSnapToMaxButton) + self.clickEntryRadiobutton.widgets.append(self.snapToMaxToggle) + self.clickEntryRadiobutton.widgets.append(self.snapToMaxInfoButton) + + row += 1 + self.autoPilotToggle = widgets.Toggle() + self.autoPilotToggle.label = QLabel("Use auto-pilot: ") + layout.addWidget(self.autoPilotToggle.label, row, 1) + layout.addWidget(self.autoPilotToggle, row, 2, alignment=Qt.AlignCenter) + sectionWidgets.append(self.autoPilotToggle.label) + sectionWidgets.append(self.autoPilotToggle) + self.autoPilotInfoButton = widgets.infoPushButton() + layout.addWidget(self.autoPilotInfoButton, row, 3) + sectionWidgets.append(self.autoPilotInfoButton) + + self.autoPilotInfoButton.clicked.connect(self.showAutoPilotInfo) + self.clickEntryRadiobutton.widgets.append(self.autoPilotToggle) + self.clickEntryRadiobutton.widgets.append(self.autoPilotInfoButton) + + row += 1 + self.clickEntryTableEndname = widgets.alphaNumericLineEdit() + self.clickEntryTableEndname.setText("points_added_by_clicking") + self.clickEntryTableEndname.setAlignment(Qt.AlignCenter) + self.clickEntryTableEndname.label = QLabel("Table endname: ") + loadButton = widgets.browseFileButton(start_dir=imagesPath, ext={"CSV": ".csv"}) + layout.addWidget(loadButton, row, 3) + sectionWidgets.append(loadButton) + + loadButton.sigPathSelected.connect(self.loadClickEntryTable) + self.loadButton = loadButton + self.clickEntryLoadTableButton = loadButton + layout.addWidget(self.clickEntryTableEndname.label, row, 1) + layout.addWidget(self.clickEntryTableEndname, row, 2) + self.clickEntryRadiobutton.widgets.append(self.clickEntryTableEndname) + self.clickEntryTableEndname.editingFinished.connect( + self.emitCheckClickEntryTableEndnameExists + ) + sectionWidgets.append(self.clickEntryTableEndname) + sectionWidgets.append(self.clickEntryTableEndname.label) + + row += 1 + instructionsText = html_utils.paragraph( + "
    Left-click to annotate a new point with a new id.

    " + "Right-click to annotate a point with the same id

    " + "Same click used to delete objects to annotate
    " + "a point with id = 0 (negative prompt)

    " + "Click on point to delete it", + font_size="11px", + ) + self.instructionsLabel = QLabel(instructionsText) + self.instructionsLabel.label = QLabel("Instructions") + layout.addWidget(self.instructionsLabel.label, row, 1) + layout.addWidget(self.instructionsLabel, row, 2) + self.clickEntryRadiobutton.widgets.append(self.instructionsLabel) + sectionWidgets.append(self.instructionsLabel) + sectionWidgets.append(self.instructionsLabel.label) + + self.clickEntryRadiobutton.toggled.connect(self.enableRadioButtonWidgets) + self.clickEntryRadiobutton.toggled.connect( + self.emitCheckClickEntryTableEndnameExists + ) + self.enableRadioButtonWidgets(False, sender=self.clickEntryRadiobutton) + + return row + 1, sectionWidgets + + def emitCheckClickEntryTableEndnameExists(self, *args, **kwargs): + if not self.clickEntryRadiobutton.isChecked(): + return + self.clickEntryIsLoadedDf = None + tableEndName = self.clickEntryTableEndname.text() + self.sigCheckClickEntryTableEndnameExists.emit(tableEndName, False) + + def loadClickEntryTable(self, csv_path): + self.clickEntryIsLoadedDf = None + posData = load.loadData(csv_path, "points") + posData.getBasenameAndChNames(qparent=self) + basename = posData.basename + filename = os.path.basename(csv_path) + filename, ext = os.path.splitext(filename) + if not basename.endswith("_"): + basename = f"{basename}_" + + endname = filename[len(basename) :] + self.clickEntryTableEndname.setText(endname) + self.sigCheckClickEntryTableEndnameExists.emit(endname, True) + + def showAutoPilotInfo(self): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(""" + With Auto-pilot mode active, Cell-ACDC will automatically zoom on + to an object
    + to allow you clicking on the points you want to add.

    + You can then go to the next object by pressing the + Enter key or go back to the
    + previous object by pressing Backspace. + """) + msg.information(self, "Auto-pilot info", txt) + + def showSnapToMaxButton(self): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(""" + With mode active, Cell-ACDC will + automatically add the point
    + to the closest maximum within the point footprint (defined in + the appearance settings). + """) + msg.information(self, "Snap to closest maximum info", txt) + + def closeEvent(self, event): + self.sigClosed.emit() + + def enableRadioButtonWidgets(self, enabled, sender=None): + if sender is None: + sender = self.sender() + for widget in sender.widgets: + widget.setDisabled(not enabled) + try: + widget.label.setDisabled(not enabled) + except: + pass + + def _readTable(self, path): + return load.load_df_points_layer(path) + + def tryAutoFillColNames(self, df): + if "x" in df.columns: + self.xColName.setCurrentText("x") + + if "y" in df.columns: + self.yColName.setCurrentText("y") + + if "z" in df.columns: + self.zColName.setCurrentText("z") + + if "frame_i" in df.columns: + self.tColName.setCurrentText("frame_i") + + def tablePathSelected(self, path): + self.tablePath.setText(path) + try: + df = self._readTable(path) + self.xColName.addItems(df.columns) + self.yColName.addItems(df.columns) + self.zColName.addItems(df.columns) + self.tColName.addItems(df.columns) + self.tryAutoFillColNames(df) + self.sigLoadedTable.emit(df, os.path.basename(path)) + self.browseTableButton.confirmAction() + except Exception as e: + traceback_format = traceback.format_exc() + self.sigCriticalReadTable.emit(traceback_format) + self.criticalReadTable(path, traceback_format) + self.tablePath.setText("") + + def criticalLenMismatchManualEntry(self): + txt = html_utils.paragraph(f""" + X coords and Y coords must have the same length. + """) + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + msg.critical(self, f"X and Y have different length", txt) + + def criticalColNameIsNone(self, axis): + txt = html_utils.paragraph(f""" + The "{axis.upper()} coord. column" cannot be "None" + """) + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + msg.critical(self, f"{axis.upper()} coord. is None", txt) + + def criticalReadTable(self, path, traceback_format): + txt = html_utils.paragraph(f""" + Something went wrong when reading the table from the + following path:

    + {path}

    + See the error message below. + """) + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + detailsText = traceback_format + msg.critical(self, "Error when reading table", txt, detailsText=detailsText) + + def criticalEmptyTablePath(self): + txt = html_utils.paragraph(f""" + The table file path cannot be empty. + """) + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + msg.critical(self, "Table file path is empty", txt) + + def state(self): + _state = self.appearanceGroupbox.state() + return _state + + def _checkSelectedColName(self, colName, label): + labelsToCheck = ["z", "y", "x"] + labelsToCheck.remove(label) + for labelToCheck in labelsToCheck: + if colName.find(labelToCheck) != -1: + break + else: + return True + + txt = html_utils.paragraph(f""" + Are you sure that the {label.upper()} coord. column should contain + the letter {labelToCheck}? + """) + + msg = widgets.myMessageBox(wrapText=False) + _, noButton, yesButton = msg.warning( + self, + "Check column name", + txt, + buttonsTexts=("Cancel", "No, let me correct it", "Yes, I am"), + ) + if msg.cancel or msg.clickedButton == noButton: + return False + return True + + def checkColNameX(self, text): + accepted = self._checkSelectedColName(text, "x") + if accepted: + return + self.xColName.setCurrentText("None") + + def checkColNameY(self, text): + accepted = self._checkSelectedColName(text, "y") + if accepted: + return + self.yColName.setCurrentText("None") + + def checkColNameZ(self, text): + accepted = self._checkSelectedColName(text, "z") + if accepted: + return + self.zColName.setCurrentText("None") + + def ok_cb(self): + self.pointsData = {} + self.loadedDfInfo = None + self.loadedDf = None + self.weighingChannel = "" + if self.fromTableRadiobutton.isChecked(): + tablePath = self.tablePath.text() + if not tablePath: + self.criticalEmptyTablePath() + return + + try: + df = self._readTable(tablePath) + tColName = self.tColName.currentText() + xColName = self.xColName.currentText() + yColName = self.yColName.currentText() + zColName = self.zColName.currentText() + + self.loadedDfInfo = { + "filepath": tablePath, + "t": tColName, + "z": zColName, + "y": yColName, + "x": xColName, + } + + self._df_to_pointsData(df, tColName, zColName, yColName, xColName) + + except Exception as e: + traceback_format = traceback.format_exc() + self.sigCriticalReadTable.emit(traceback_format) + self.criticalReadTable(tablePath, traceback_format) + return + + if self.xColName.currentText() == "None": + self.criticalColNameIsNone("x") + return + if self.yColName.currentText() == "None": + self.criticalColNameIsNone("y") + return + + self.layerType = os.path.basename(self.tablePath.text()) + self.layerTypeIdx = 2 + elif self.centroidsRadiobutton.isChecked(): + self.layerType = "Centroids" + self.layerTypeIdx = 0 + elif self.weightedCentroidsRadiobutton.isChecked(): + channel = self.channelNameForWeightedCentr.currentText() + self.weighingChannel = channel + self.layerType = f"Centroids weighted by channel {channel}" + self.layerTypeIdx = 1 + elif self.manualEntryRadiobutton.isChecked(): + xx = self.manualXspinbox.values() + yy = self.manualYspinbox.values() + if len(xx) != len(yy): + self.criticalLenMismatchManualEntry() + return + zz = self.manualZspinbox.values() + tt = [t + 1 for t in self.manualTspinbox.values()] + df = pd.DataFrame({"x": xx, "y": yy, "id": np.arange(1, len(xx) + 1)}) + if tt: + df["t"] = tt + tCol = "t" + else: + tCol = "None" + if zz: + df["z"] = zz + zCol = "z" + else: + zCol = "None" + + self._df_to_pointsData(df, tCol, zCol, "y", "x") + + self.layerType = "Manual entry" + self.layerTypeIdx = 3 + elif self.clickEntryRadiobutton.isChecked(): + self.layerType = "Click to annotate point" + self.description = ( + "Left-click to add a point, click on point to delete it.\n" + "With auto-pilot you can navigate through object with Up/Down arrows." + ) + self.clickEntryTableEndnameText = self.clickEntryTableEndname.text() + self.layerTypeIdx = 4 + + self.cancel = False + symbol = self.appearanceGroupbox.symbolWidget.widget.currentText() + self.symbol = re.findall(r"\'(.+)\'", symbol)[0] + self.symbolText = symbol + self.color = self.appearanceGroupbox.colorButton.color() + self.pointSize = self.appearanceGroupbox.sizeSpinBox.value() + self.zHeight = self.appearanceGroupbox.zHeightSpinBox.value() + shortcutWidget = self.appearanceGroupbox.shortcutWidget + self.shortcut = shortcutWidget.widget.text() + self.keySequence = shortcutWidget.widget.keySequence + self.close() + + def _df_to_pointsData(self, df, tColName, zColName, yColName, xColName): + self.pointsData = load.loaded_df_to_points_data( + df, tColName, zColName, yColName, xColName + ) + + def showEvent(self, event) -> None: + if self._parent is None: + screen = self.screen() + else: + screen = self._parent.screen() + screenWidth = screen.size().width() + screenHeight = screen.size().height() + + maxHeight = screenHeight - 100 + + buttonHeight = self.buttonsLayout.okButton.minimumSizeHint().height() + height = ( + self.scrollArea.minimumHeightNoScrollbar() + + self.appearanceGroupbox.sizeHint().height() + + buttonHeight + + 70 + ) + width = self.scrollArea.minimumWidthNoScrollbar() + 50 + + height = min(height, maxHeight) + + self.resize(width, height) + + screenLeft = screen.geometry().x() + screenTop = screen.geometry().y() + w, h = self.width(), self.height() + left = int(screenLeft + screenWidth / 2 - w / 2) + top = int(screenTop + screenHeight / 2 - h / 2 - 20) + + self.move(left, top) + + +class EditPointsLayerAppearanceDialog(QBaseDialog): + sigClosed = Signal() + + def __init__(self, parent=None): + self.cancel = True + super().__init__(parent) + + self._parent = parent + + self.setWindowTitle("Custom annotation") + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + + mainLayout = QVBoxLayout() + + self.appearanceGroupbox = _PointsLayerAppearanceGroupbox() + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addWidget(self.appearanceGroupbox) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + self.setFont(font) + + def restoreState(self, state): + self.appearanceGroupbox.restoreState(state) + + def closeEvent(self, event): + super().closeEvent(event) + self.sigClosed.emit() + + def state(self): + _state = self.appearanceGroupbox.state() + return _state + + def ok_cb(self): + self.cancel = False + symbol = self.appearanceGroupbox.symbolWidget.widget.currentText() + self.symbol = re.findall(r"\'(.+)\'", symbol)[0] + self.color = self.appearanceGroupbox.colorButton.color() + self.pointSize = self.appearanceGroupbox.sizeSpinBox.value() + self.zHeight = self.appearanceGroupbox.zHeightSpinBox.value() + shortcutWidget = self.appearanceGroupbox.shortcutWidget + self.shortcut = shortcutWidget.widget.text() + self.keySequence = shortcutWidget.widget.keySequence + self.close() + + +class QDialogWorkerProgress(QDialog): + sigClosed = Signal(bool) + + def __init__( + self, + title="Progress", + infoTxt="", + showInnerPbar=False, + pbarDesc="", + parent=None, + ): + self.workerFinished = False + self.aborted = False + self.clickCount = 0 + super().__init__(parent) + + abort_text = "Option+Command+C" if is_mac else "Ctrl+Alt+C" + self.abort_text = abort_text + + self.setWindowTitle(f"{title} ({abort_text} to abort)") + self.setWindowFlags(Qt.Window) + + mainLayout = QVBoxLayout() + pBarLayout = QGridLayout() + + if infoTxt: + infoLabel = QLabel(infoTxt) + mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) + + self.progressLabel = QLabel(pbarDesc) + + self.mainPbar = widgets.ProgressBarWithETA(self) + self.mainPbar.setValue(0) + pBarLayout.addWidget(self.mainPbar, 0, 0) + pBarLayout.addWidget(self.mainPbar.ETA_label, 0, 1) + + self.innerPbar = widgets.ProgressBarWithETA(self) + self.innerPbar.setValue(0) + pBarLayout.addWidget(self.innerPbar, 1, 0) + pBarLayout.addWidget(self.innerPbar.ETA_label, 1, 1) + if showInnerPbar: + self.innerPbar.show() + else: + self.innerPbar.hide() + + self.logConsole = widgets.QLogConsole() + + mainLayout.addWidget(self.progressLabel) + mainLayout.addLayout(pBarLayout) + mainLayout.addWidget(self.logConsole) + + self.setLayout(mainLayout) + # self.setModal(True) + + def keyPressEvent(self, event): + isCtrlAlt = event.modifiers() == (Qt.ControlModifier | Qt.AltModifier) + if isCtrlAlt and event.key() == Qt.Key_C: + doAbort = self.askAbort() + if doAbort: + self.aborted = True + self.workerFinished = True + self.close() + + def askAbort(self): + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + Aborting with {self.abort_text} to abort is + not safe.

    + The system status cannot be predicted and + it will require a restart.

    + Are you sure you want to abort? + """) + yesButton, noButton = msg.critical( + self, "Are you sure you want to abort?", txt, buttonsTexts=("Yes", "No") + ) + return msg.clickedButton == yesButton + + def closeEvent(self, event): + if not self.workerFinished: + event.ignore() + return + + self.sigClosed.emit(self.aborted) + + def log(self, text): + self.logConsole.append(text) + + def show(self, app): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + QDialog.show(self) + screen = app.primaryScreen() + screenWidth = screen.size().width() + screenHeight = screen.size().height() + parentGeometry = self.parent().geometry() + mainWinLeft, mainWinWidth = parentGeometry.left(), parentGeometry.width() + mainWinTop, mainWinHeight = parentGeometry.top(), parentGeometry.height() + mainWinCenterX = int(mainWinLeft + mainWinWidth / 2) + mainWinCenterY = int(mainWinTop + mainWinHeight / 2) + + width = int(screenWidth / 3) + width = width if self.width() < width else self.width() + height = int(screenHeight / 3) + left = int(mainWinCenterX - width / 2) + left = left if left >= 0 else 0 + top = int(mainWinCenterY - height / 2) + + self.setGeometry(left, top, width, height) + + +class QDialogCombobox(QDialog): + def __init__( + self, + title, + ComboBoxItems, + informativeText, + CbLabel="Select value: ", + parent=None, + defaultChannelName=None, + iconPixmap=None, + centeredCombobox=False, + ): + self.cancel = True + self.selectedItemText = "" + self.selectedItemIdx = None + super().__init__(parent=parent) + self.setWindowTitle(title) + + mainLayout = QVBoxLayout() + infoLayout = QHBoxLayout() + topLayout = QHBoxLayout() + bottomLayout = QHBoxLayout() + + self.mainLayout = mainLayout + + if iconPixmap is not None: + label = QLabel() + # padding: top, left, bottom, right + # label.setStyleSheet("padding:5px 0px 12px 0px;") + label.setPixmap(iconPixmap) + infoLayout.addWidget(label) + + if informativeText: + infoLabel = QLabel(informativeText) + infoLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) + + if CbLabel: + label = QLabel(CbLabel) + topLayout.addWidget(label, alignment=Qt.AlignRight) + + if centeredCombobox: + combobox = widgets.QCenteredComboBox() + else: + combobox = QComboBox() + combobox.addItems(ComboBoxItems) + if defaultChannelName is not None and defaultChannelName in ComboBoxItems: + combobox.setCurrentText(defaultChannelName) + self.ComboBox = combobox + topLayout.addWidget(combobox) + topLayout.setContentsMargins(0, 10, 0, 0) + + okButton = widgets.okPushButton("Ok") + + cancelButton = widgets.cancelPushButton("Cancel") + + bottomLayout.addStretch(1) + bottomLayout.addWidget(cancelButton) + bottomLayout.addSpacing(20) + bottomLayout.addWidget(okButton) + bottomLayout.setContentsMargins(0, 10, 0, 0) + + mainLayout.addLayout(infoLayout) + mainLayout.addLayout(topLayout) + mainLayout.addLayout(bottomLayout) + self.setLayout(mainLayout) + + # self.setModal(True) + + # Connect events + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + self.loop = None + + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + self.setFont(font) + + def ok_cb(self, checked=False): + self.cancel = False + self.selectedItemText = self.ComboBox.currentText() + self.selectedItemIdx = self.ComboBox.currentIndex() + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + QDialog.show(self) + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class imageViewer(QMainWindow): + """Main Window.""" + + sigClosed = Signal() + sigHoveringImage = Signal(object, object) + + def __init__( + self, + parent=None, + posData=None, + button_toUncheck=None, + spinBox=None, + linkWindow=None, + enableOverlay=False, + isSigleFrame=False, + enableMirroredCursor=False, + ): + self.button_toUncheck = button_toUncheck + self.parent = parent + self.posData = posData + self.spinBox = spinBox + self.linkWindow = linkWindow + self.enableMirroredCursor = enableMirroredCursor + self.isSigleFrame = isSigleFrame + self.minMaxValuesMapper = None + """Initializer.""" + super().__init__(parent) + + if posData is None: + posData = self.parent.data[self.parent.pos_i] + self.posData = posData + self.enableOverlay = enableOverlay + + self.gui_createActions() + self.gui_createMenuBar() + self.gui_createToolBars() + + self.gui_createStatusBar() + + self.gui_createGraphics() + + self.gui_connectImgActions() + + self.gui_createImgWidgets() + self.gui_connectActions() + + self.gui_setSingleFrameMode(self.isSigleFrame) + + self.setupMirroredCursor() + + mainContainer = QWidget() + self.setCentralWidget(mainContainer) + + mainLayout = QGridLayout() + mainLayout.addWidget(self.graphLayout, 0, 0, 1, 1) + mainLayout.addLayout(self.img_Widglayout, 1, 0) + + mainContainer.setLayout(mainLayout) + + self.frame_i = posData.frame_i + self.num_frames = posData.SizeT + + version = myutils.read_version() + self.setWindowTitle(f"Cell-ACDC v{version} - {posData.relPath}") + + def gui_createActions(self): + # File actions + self.exitAction = QAction("&Exit", self) + + # Toolbar actions + self.prevAction = QAction("Previous frame", self) + self.nextAction = QAction("Next Frame", self) + self.jumpForwardAction = QAction("Jump to 10 frames ahead", self) + self.jumpBackwardAction = QAction("Jump to 10 frames back", self) + self.prevAction.setShortcut("left") + self.nextAction.setShortcut("right") + self.jumpForwardAction.setShortcut("up") + self.jumpBackwardAction.setShortcut("down") + self.addAction(self.nextAction) + self.addAction(self.prevAction) + self.addAction(self.jumpBackwardAction) + self.addAction(self.jumpForwardAction) + if self.enableOverlay: + self.overlayButton = widgets.rightClickToolButton(parent=self) + self.overlayButton.setIcon(QIcon(":overlay.svg")) + self.overlayButton.setCheckable(True) + + def gui_createMenuBar(self): + menuBar = self.menuBar() + # File menu + fileMenu = QMenu("&File", self) + menuBar.addMenu(fileMenu) + # fileMenu.addAction(self.newAction) + fileMenu.addAction(self.exitAction) + + def gui_createToolBars(self): + toolbarSize = 30 + + editToolBar = QToolBar("Edit", self) + editToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) + self.addToolBar(editToolBar) + + self.editToolBar = editToolBar + + if self.enableOverlay: + editToolBar.addWidget(self.overlayButton) + + if self.linkWindow: + # Insert a spacing + editToolBar.addWidget(QLabel(" ")) + self.linkWindowCheckbox = QCheckBox("Link to main GUI") + self.linkWindowCheckbox.setChecked(True) + editToolBar.addWidget(self.linkWindowCheckbox) + + if self.enableMirroredCursor: + self.showMirroredCursorCheckbox = QCheckBox( + "Show mirrored cursor from main window" + ) + self.showMirroredCursorCheckbox.setChecked(True) + editToolBar.addWidget(self.showMirroredCursorCheckbox) + + def setupMirroredCursor(self): + self.cursor = pg.ScatterPlotItem( + symbol="+", + pxMode=True, + pen=pg.mkPen("k", width=1), + brush=pg.mkBrush("w"), + size=16, + tip=None, + ) + self.Plot.addItem(self.cursor) + + def gui_connectActions(self): + self.exitAction.triggered.connect(self.close) + self.prevAction.triggered.connect(self.prev_frame) + self.nextAction.triggered.connect(self.next_frame) + self.jumpForwardAction.triggered.connect(self.skip10ahead_frames) + self.jumpBackwardAction.triggered.connect(self.skip10back_frames) + if self.enableOverlay: + self.overlayButton.toggled.connect(self.overlay_cb) + self.overlayButton.sigRightClick.connect(self.showOverlayContextMenu) + + def gui_setSingleFrameMode(self, isSingleFrame: bool): + if not isSingleFrame: + return + + self.framesScrollBar.setDisabled(True) + self.framesScrollBar.setVisible(False) + self.frameLabel.hide() + self.t_label.hide() + self.prevAction.triggered.disconnect() + self.nextAction.triggered.disconnect() + self.jumpForwardAction.triggered.disconnect() + self.jumpBackwardAction.triggered.disconnect() + self.editToolBar.setVisible(False) + + def showOverlayContextMenu(self, event): + if not self.overlayButton.isChecked(): + return + + if self.parent is not None: + self.overlayContextMenu.exec_(QCursor.pos()) + + def gui_createStatusBar(self): + self.statusbar = self.statusBar() + # Temporary message + self.statusbar.showMessage("Ready", 3000) + # Permanent widget + self.wcLabel = QLabel(f"") + self.statusbar.addPermanentWidget(self.wcLabel) + + def gui_createGraphics(self): + self.graphLayout = pg.GraphicsLayoutWidget() + + # Plot Item container for image + self.Plot = pg.PlotItem() + self.Plot.invertY(True) + self.Plot.setAspectLocked(True) + self.Plot.hideAxis("bottom") + self.Plot.hideAxis("left") + self.graphLayout.addItem(self.Plot, row=1, col=1) + + # Image Item + self.img = widgets.BaseImageItem() + self.img.setEnableAutoLevels(True) + self.Plot.addItem(self.img) + + # Image histogram + self.imgGrad = widgets.myHistogramLUTitem(isViewer=True) + self.imgGrad.gradient.showMenu = self.showLutItemOverlayContextMenu + self.imgGrad.vb.raiseContextMenu = lambda x: None + self.imgGrad.setImageItem(self.img) + self.graphLayout.addItem(self.imgGrad, row=1, col=0) + + # Current frame text + self.frameLabel = pg.LabelItem(justify="center", color="w", size="14pt") + self.frameLabel.setText(" ") + self.graphLayout.addItem(self.frameLabel, row=2, col=0, colspan=2) + + if not self.enableOverlay: + return + + def gui_createOverlayItems(self): + self.createOverlayChannelsActions() + self.overlayLayersItems = {} + for ch in self.posData.chNames: + if ch == self.parent.user_ch_name: + continue + overlayItems = self.getOverlayItems(ch) + imageItem, lutItem, alphaScrollbar = overlayItems + lutItem.vb.raiseContextMenu = lambda x: None + lutItem.gradient.showMenu = self.showLutItemOverlayContextMenu + lutItem.overlayColorButton.sigColorChanging.connect(self.updateOlColors) + self.addAlphaScrollbar(ch, imageItem, alphaScrollbar) + self.overlayLayersItems[ch] = overlayItems + self.Plot.addItem(imageItem) + + def createOverlayChannelsActions(self): + self.overlayLutItemAdditionalActions = [] + separator = QAction(self) + separator.setSeparator(True) + self.overlayLutItemAdditionalActions.append(separator) + section = self.imgGrad.gradient.menu.addSection("Select channel to adjust: ") + self.overlayLutItemAdditionalActions.append(section) + self.imgGrad.gradient.menu.removeAction(section) + + self.overlayChNamesActionGroup = QActionGroup(self) + self.overlayChNamesActionGroup.setExclusive(True) + for chName in self.posData.chNames: + action = QAction(chName, self) + action.setCheckable(True) + if chName == self.parent.user_ch_name: + action.setChecked(True) + self.overlayChNamesActionGroup.addAction(action) + self.overlayChNamesActionGroup.triggered.connect( + self.chNameGradientActionClicked + ) + + def chNameGradientActionClicked(self, action): + # Action triggered from lutItem + self.checkedOverlayChName = action.text() + if action.text() == self.posData.user_ch_name: + self.setOverlayItemsVisible("", False) + else: + self.setOverlayItemsVisible(action.text(), True) + + def showLutItemOverlayContextMenu(self, event): + lutItem = self.currentLutItem + + for action in self.overlayLutItemAdditionalActions: + try: + lutItem.gradient.menu.removeAction(action) + except Exception as e: + pass + + for action in self.overlayChNamesActionGroup.actions(): + try: + lutItem.gradient.menu.removeAction(action) + except Exception as e: + pass + + if self.overlayButton.isChecked(): + for action in self.overlayLutItemAdditionalActions: + lutItem.gradient.menu.addAction(action) + + for action in self.overlayChNamesActionGroup.actions(): + if action.text() == self.posData.user_ch_name: + lutItem.gradient.menu.addAction(action) + continue + for filename in self.posData.ol_data: + if filename.endswith(action.text()): + lutItem.gradient.menu.addAction(action) + break + if filename.endswith(f"{action.text()}_aligned"): + lutItem.gradient.menu.addAction(action) + break + + try: + # Convert QPointF to QPoint + lutItem.gradient.menu.popup(event.screenPos().toPoint()) + except AttributeError: + lutItem.gradient.menu.popup(event.screenPos()) + + def gui_connectImgActions(self): + self.img.hoverEvent = self.gui_hoverEventImg + + def gui_createImgWidgets(self): + if self.posData is None: + posData = self.parent.data[self.parent.pos_i] + else: + posData = self.posData + self.img_Widglayout = QGridLayout() + + # Frames scrollbar + self.framesScrollBar = QScrollBar(Qt.Horizontal) + # self.framesScrollBar.setFixedHeight(20) + self.framesScrollBar.setMinimum(1) + self.framesScrollBar.setMaximum(posData.SizeT) + t_label = QLabel("frame ") + _font = QFont() + _font.setPixelSize(12) + t_label.setFont(_font) + self.img_Widglayout.addWidget(t_label, 0, 0, alignment=Qt.AlignRight) + self.img_Widglayout.addWidget(self.framesScrollBar, 0, 1, 1, 20) + self.t_label = t_label + self.framesScrollBar.valueChanged.connect(self.framesScrollBarMoved) + + # z-slice scrollbar + self.zSliceScrollBar = QScrollBar(Qt.Horizontal) + # self.zSliceScrollBar.setFixedHeight(20) + self.zSliceScrollBar.setMaximum(self.posData.SizeZ - 1) + _z_label = QLabel("z-slice ") + _font = QFont() + _font.setPixelSize(12) + _z_label.setFont(_font) + self.z_label = _z_label + self.img_Widglayout.addWidget(_z_label, 1, 0, alignment=Qt.AlignCenter) + self.img_Widglayout.addWidget(self.zSliceScrollBar, 1, 1, 1, 20) + + if self.posData.SizeZ == 1: + self.zSliceScrollBar.setDisabled(True) + self.zSliceScrollBar.setVisible(False) + _z_label.setVisible(False) + + self.img_Widglayout.setContentsMargins(100, 0, 50, 0) + self.zSliceScrollBar.valueChanged.connect(self.update_z_slice) + + if self.enableOverlay: + self.setOverlayColors() + self.gui_createOverlayItems() + self.createOverlayContextMenu() + + self.img.alphaScrollbar = self.addAlphaScrollbar( + self.parent.user_ch_name, self.img + ) + + def getOverlayItems(self, channelName): + imageItem = pg.ImageItem() + imageItem.setOpacity(0.5) + + lutItem = widgets.myHistogramLUTitem(isViewer=True) + + lutItem.setImageItem(imageItem) + lutItem.vb.raiseContextMenu = lambda x: None + initColor = self.overlayRGBs.pop(0) + self.parent.initColormapOverlayLayerItem(initColor, lutItem) + lutItem.addOverlayColorButton(initColor, channelName) + lutItem.initColor = initColor + lutItem.hide() + + alphaScrollBar = self.addAlphaScrollbar(channelName, imageItem) + return imageItem, lutItem, alphaScrollBar + + def setMirroredCursorPos(self, x, y): + if not self.enableMirroredCursor: + return + + if not self.showMirroredCursorCheckbox.isChecked(): + return + + self.cursor.setData([x], [y]) + + def setOverlayColors(self): + self.overlayRGBs = [ + (255, 255, 0), + (252, 72, 254), + (49, 222, 134), + (22, 108, 27), + ] + cmap = matplotlib.colormaps["gist_rainbow"] + self.overlayRGBs.extend( + [tuple([round(c * 255) for c in cmap(i)][:3]) for i in np.linspace(0, 1, 8)] + ) + + def setOpacityOverlayLayersItems(self, value, imageItem=None): + if imageItem is None: + imageItem = self.sender().imageItem + alpha = value / self.sender().maximum() + else: + alpha = value + imageItem.setOpacity(alpha) + + def overlay_cb(self, checked): + if checked: + if self.posData.ol_data is None: + selectedChannels = self.askSelectOverlayChannel() + if selectedChannels is None: + self.overlayButton.toggled.disconnect() + self.overlayButton.setChecked(False) + self.overlayButton.toggled.connect(self.overlay_cb) + return + success = self.parent.loadOverlayData(selectedChannels) + if not success: + return False + lastChannel = selectedChannels[-1] + self.checkedOverlayChName = lastChannel + imageItem = self.overlayLayersItems[lastChannel][0] + self.setOpacityOverlayLayersItems(0.5, imageItem=imageItem) + self.img.setOpacity(0.5) + self.setCheckedOverlayContextMenusActions(selectedChannels) + else: + self.checkedOverlayChName = self.parent.imgGrad.checkedChannelname + selectedChannels = self.parent.checkedOverlayChannels + self.setCheckedOverlayContextMenusActions(selectedChannels) + self.setOverlayItemsVisible(self.checkedOverlayChName, True) + else: + self.img.setOpacity(1.0) + self.setOverlayItemsVisible("", False) + for items in self.overlayLayersItems.values(): + imageItem = items[0] + imageItem.clear() + self.update_img() + + def createOverlayContextMenu(self): + ch_names = [ + ch for ch in self.posData.chNames if ch != self.posData.user_ch_name + ] + self.overlayContextMenu = QMenu() + self.overlayContextMenu.addSeparator() + self.checkedOverlayChannels = set() + for chName in ch_names: + action = QAction(chName, self.overlayContextMenu) + action.setCheckable(True) + action.toggled.connect(self.overlayChannelToggled) + self.overlayContextMenu.addAction(action) + + def setCheckedOverlayContextMenusActions(self, channelNames): + for action in self.overlayContextMenu.actions(): + if action.text() not in channelNames: + continue + action.setChecked(True) + self.checkedOverlayChannels.add(action.text()) + + def overlayChannelToggled(self, checked): + # Action toggled from overlayButton context menu + channelName = self.sender().text() + if checked: + posData = self.posData + if channelName not in posData.loadedFluoChannels: + self.parent.loadOverlayData([channelName], addToExisting=True) + self.setOverlayItemsVisible(channelName, True) + self.checkedOverlayChannels.add(channelName) + self.updateOlColors(None) + else: + self.checkedOverlayChannels.remove(channelName) + imageItem = self.overlayLayersItems[channelName][0] + imageItem.clear() + try: + channelToShow = next(iter(self.checkedOverlayChannels)) + self.setOverlayItemsVisible(channelToShow, True) + except StopIteration: + self.setOverlayItemsVisible("", False) + self.update_img() + + def updateOlColors(self, button): + lutItem = self.overlayLayersItems[self.checkedOverlayChName][1] + rgb = lutItem.overlayColorButton.color().getRgb()[:3] + self.parent.initColormapOverlayLayerItem(rgb, lutItem) + lutItem.overlayColorButton.setColor(rgb) + + def addAlphaScrollbar(self, channelName, imageItem, alphaScrollBar=None): + if alphaScrollBar is None: + alphaScrollBar = QScrollBar(Qt.Horizontal) + label = QLabel(f"Alpha {channelName}") + label.setFont(font) + label.hide() + alphaScrollBar.imageItem = imageItem + alphaScrollBar.label = label + alphaScrollBar.setFixedHeight(self.parent.h) + alphaScrollBar.hide() + alphaScrollBar.setMinimum(0) + alphaScrollBar.setMaximum(40) + alphaScrollBar.setValue(20) + alphaScrollBar.setToolTip( + f"Control the alpha value of the overlaid channel {channelName}.\n" + "alpha=0 results in NO overlay,\n" + "alpha=1 results in only fluorescence data visible" + ) + self.img_Widglayout.addWidget( + alphaScrollBar.label, 2, 0, alignment=Qt.AlignRight + ) + self.img_Widglayout.addWidget(alphaScrollBar, 2, 1, 1, 20) + sp = alphaScrollBar.label.sizePolicy() + sp.setRetainSizeWhenHidden(True) + alphaScrollBar.label.setSizePolicy(sp) + + sp = alphaScrollBar.sizePolicy() + sp.setRetainSizeWhenHidden(True) + alphaScrollBar.setSizePolicy(sp) + + alphaScrollBar.valueChanged.connect(self.setOpacityOverlayLayersItems) + return alphaScrollBar + + def setOverlayItemsVisible(self, channelName, visible): + if visible: + self.imgGrad.hide() + self.img.alphaScrollbar.hide() + self.img.alphaScrollbar.label.hide() + try: + self.graphLayout.removeItem(self.imgGrad) + except Exception as e: + pass + itemsToShow = None + for name, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB = items + if name == channelName: + itemsToShow = items + else: + lutItem.hide() + alphaSB.hide() + alphaSB.label.hide() + try: + self.graphLayout.removeItem(lutItem) + except Exception as e: + pass + + if itemsToShow is None: + self.graphLayout.addItem(self.imgGrad, row=1, col=0) + self.imgGrad.show() + self.currentLutItem = self.imgGrad + self.img.alphaScrollbar.show() + self.img.alphaScrollbar.label.show() + else: + _, lutItem, alphaSB = itemsToShow + lutItem.show() + alphaSB.show() + alphaSB.label.show() + self.currentLutItem = lutItem + self.graphLayout.addItem(lutItem, row=1, col=0) + else: + if self.overlayButton.isChecked(): + self.img.alphaScrollbar.show() + self.img.alphaScrollbar.label.show() + else: + self.img.alphaScrollbar.hide() + self.img.alphaScrollbar.label.hide() + for name, items in self.overlayLayersItems.items(): + _, lutItem, alphaSB = items + lutItem.hide() + alphaSB.hide() + alphaSB.label.hide() + try: + self.graphLayout.removeItem(lutItem) + except Exception as e: + pass + self.graphLayout.addItem(self.imgGrad, row=1, col=0) + self.imgGrad.show() + self.currentLutItem = self.imgGrad + + def framesScrollBarMoved(self, frame_n): + self.frame_i = frame_n - 1 + self.t_label.setText(f"frame n. {self.frame_i + 1}/{self.num_frames}") + if self.spinBox is not None: + self.spinBox.setValue(frame_n) + self.update_img() + + def gui_hoverEventImg(self, event): + # Update x, y, value label bottom right + try: + x, y = event.pos() + xdata, ydata = int(x), int(y) + _img = self.img.image + Y, X = _img.shape + if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: + val = _img[ydata, xdata] + self.wcLabel.setText(f"(x={x:.2f}, y={y:.2f}, value={val:.2f})") + else: + self.wcLabel.setText(f"") + except Exception as e: + self.wcLabel.setText(f"") + + emitHovering = ( + self.enableMirroredCursor and self.showMirroredCursorCheckbox.isChecked() + ) + if emitHovering: + if event.isExit(): + x, y = None, None + else: + x, y = event.pos() + self.sigHoveringImage.emit(x, y) + self.cursor.setData([], []) + + def next_frame(self): + if self.frame_i < self.num_frames - 1: + self.frame_i += 1 + else: + self.frame_i = 0 + self.update_img() + + def prev_frame(self): + if self.frame_i > 0: + self.frame_i -= 1 + else: + self.frame_i = self.num_frames - 1 + self.update_img() + + def skip10ahead_frames(self): + if self.frame_i < self.num_frames - 10: + self.frame_i += 10 + else: + self.frame_i = 0 + self.update_img() + + def skip10back_frames(self): + if self.frame_i > 9: + self.frame_i -= 10 + else: + self.frame_i = self.num_frames - 1 + self.update_img() + + def update_z_slice(self, z): + if self.posData is None: + posData = self.parent.data[self.parent.pos_i] + else: + posData = self.posData + idx = (posData.filename, posData.frame_i) + posData.segmInfo_df.at[idx, "z_slice_used_gui"] = z + + self.z_label.setText(f"z-slice {z + 1:02}/{posData.SizeZ}") + self.img.setCurrentZsliceIndex(z) + self.update_img() + + def getImage(self): + posData = self.posData + frame_i = self.frame_i + if posData.SizeZ > 1: + idx = (posData.filename, frame_i) + z = posData.segmInfo_df.at[idx, "z_slice_used_gui"] + zProjHow = posData.segmInfo_df.at[idx, "which_z_proj_gui"] + img = posData.img_data[frame_i] + if zProjHow == "single z-slice": + self.zSliceScrollBar.setSliderPosition(z) + self.z_label.setText(f"z-slice {z + 1:02}/{posData.SizeZ}") + img = img[z].copy() + elif zProjHow == "max z-projection": + img = img.max(axis=0).copy() + elif zProjHow == "mean z-projection": + img = img.mean(axis=0).copy() + elif zProjHow == "median z-proj.": + img = np.median(img, axis=0).copy() + else: + img = posData.img_data[frame_i].copy() + return img + + def update_img(self): + self.frameLabel.setText(f"Current frame = {self.frame_i + 1}/{self.num_frames}") + if self.parent is None: + img = self.getImage() + else: + img = self.parent.getImage(frame_i=self.frame_i, raw=True) + + self.img.setCurrentFrameIndex(self.frame_i) + self.img.setImage(img) + self.framesScrollBar.setSliderPosition(self.frame_i + 1) + + if not self.enableOverlay: + return + + if not self.overlayButton.isChecked(): + return + + self.setOverlayImages(frame_i=self.frame_i) + + def askSelectOverlayChannel(self): + ch_names = [ + ch for ch in self.posData.chNames if ch != self.posData.user_ch_name + ] + selectFluo = widgets.QDialogListbox( + "Select channel", + "Select channel names to overlay:\n", + ch_names, + multiSelection=True, + parent=self, + ) + selectFluo.exec_() + if selectFluo.cancel: + return + + return selectFluo.selectedItemsText + + def setOverlayImages(self, frame_i=None): + posData = self.posData + for filename in posData.ol_data: + chName = myutils.get_chname_from_basename( + filename, posData.basename, remove_ext=False + ) + if chName not in self.checkedOverlayChannels: + continue + + imageItem = self.overlayLayersItems[chName][0] + ol_img = self.parent.getOlImg(filename, frame_i=frame_i) + imageItem.setImage(ol_img) + + def closeEvent(self, event): + if self.button_toUncheck is not None: + self.button_toUncheck.setChecked(False) + self.sigClosed.emit() + + def show(self, left=None, top=None): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + QMainWindow.show(self) + try: + self.framesScrollBar.setFixedHeight(self.parent.h) + except Exception as e: + pass + try: + self.zSliceScrollBar.setFixedHeight(self.parent.h) + except Exception as e: + pass + + try: + self.img.alphaScrollbar.setFixedHeight(self.parent.h) + except Exception as e: + pass + if left is not None and top is not None: + self.setGeometry(left, top, 850, 800) + + +class askStopFrameSegm(QDialog): + def __init__(self, user_ch_file_paths, user_ch_name, parent=None): + self.parent = parent + self.cancel = True + + super().__init__(parent) + self.setWindowTitle("Enter stop frame") + + self.visualizeWindows = [] + + mainLayout = QVBoxLayout() + buttonsLayout = QHBoxLayout() + + # Message + infoTxt = html_utils.paragraph(""" + Enter a stop frame number when to stop + segmentation for each Position loaded: + """) + infoLabel = QLabel(infoTxt, self) + infoLabel.setAlignment(Qt.AlignCenter) + # padding: top, left, bottom, right + infoLabel.setStyleSheet("padding:0px 0px 8px 0px;") + + self.dataDict = {} + + exp_path_pos_mapper = path.get_exp_path_pos_foldernames_mapper( + user_ch_file_paths + ) + + columnsLayout = QHBoxLayout() + mainScrollArea = widgets.ScrollArea() + mainScrollAreaWidget = QWidget() + mainScrollAreaWidget.setLayout(columnsLayout) + mainScrollArea.setWidget(mainScrollAreaWidget) + self.mainScrollArea = mainScrollArea + + # Form layout widget + self.spinBoxes = [] + self.tab_idx = 0 + iter_items = exp_path_pos_mapper.items() + self.groupboxScrollAreas = [] + + for col, (exp_path, pos_folders_files) in enumerate(iter_items): + groupboxScrollArea = widgets.ScrollArea() + self.groupboxScrollAreas.append(groupboxScrollArea) + groupbox = QGroupBox() + groupbox.setCheckable(False) + groupbox.setToolTip(exp_path) + groupboxLayout = QFormLayout() + groupbox.setLayout(groupboxLayout) + groupboxScrollArea.setWidget(groupbox) + columnsLayout.addWidget(groupboxScrollArea) + pos_folders = pos_folders_files["pos_foldernames"] + filenames = pos_folders_files["filenames"] + for i, pos_foldername in enumerate(pos_folders): + img_filename = filenames[i] + images_path = os.path.join(exp_path, pos_foldername, "Images") + img_path = os.path.join(images_path, img_filename) + spinBox = widgets.mySpinBox() + spinBox.sigTabEvent.connect(self.keyTabEventSpinbox) + posData = load.loadData(img_path, user_ch_name, QParent=parent) + posData.getBasenameAndChNames(qparent=self) + posData.buildPaths() + posData.loadOtherFiles( + load_segm_data=False, + load_metadata=True, + loadSegmInfo=True, + ) + spinBox.setMaximum(posData.SizeT) + stopFrameNum = posData.readLastUsedStopFrameNumber() + if stopFrameNum is None: + spinBox.setValue(posData.SizeT) + else: + spinBox.setValue(stopFrameNum) + spinBox.setAlignment(Qt.AlignCenter) + visualizeButton = widgets.viewPushButton("Visualize") + visualizeButton.clicked.connect(self.visualize_cb) + formLabel = QLabel(html_utils.paragraph(f"{pos_foldername} ")) + layout = QHBoxLayout() + layout.addWidget(formLabel, alignment=Qt.AlignRight) + layout.addWidget(spinBox) + layout.addWidget(visualizeButton) + self.dataDict[visualizeButton] = (spinBox, posData) + groupboxLayout.addRow(layout) + spinBox.idx = i + self.spinBoxes.append(spinBox) + + fm = QFontMetrics(self.font()) + elidedTitle = fm.elidedText( + exp_path, Qt.ElideLeft, groupbox.sizeHint().width() + ) + groupbox.setTitle(elidedTitle) + + mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) + mainLayout.addWidget(mainScrollArea) + + okButton = widgets.okPushButton("Ok") + okButton.setShortcut(Qt.Key_Enter) + + cancelButton = widgets.cancelPushButton("Cancel") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(okButton) + + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + # # self.setModal(True) + + def keyTabEventSpinbox(self, event, sender): + self.tab_idx += 1 + if self.tab_idx >= len(self.spinBoxes): + self.tab_idx = 0 + focusSpinbox = self.spinBoxes[self.tab_idx] + focusSpinbox.setFocus() + + def saveStopFrameNumbers(self): + for spinBox, posData in self.dataDict.values(): + posData.metadata_df.at["stop_frame_num", "values"] = spinBox.value() + posData.metadataToCsv() + + def ok_cb(self, event): + self.cancel = False + try: + self.saveStopFrameNumbers() + except Exception as err: + printl(traceback.format_exc()) + self.stopFrames = [ + spinBox.value() for spinBox, posData in self.dataDict.values() + ] + self.close() + + def closeEvent(self, event): + for window in self.visualizeWindows: + window.close() + + def visualize_cb(self, checked=True): + self.setDisabled(True) + spinBox, posData = self.dataDict[self.sender()] + print("Loading image data...") + posData.loadImgData() + posData.frame_i = spinBox.value() - 1 + win = plot.imshow( + posData.img_data, lut="gray", figure_title=posData.relPath, block=False + ) + self.visualizeWindows.append(win) + self.setDisabled(False) + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + screenSize = self.screen().size() + maxWidth = screenSize.width() - 50 + maxHeight = screenSize.height() - 100 + width, height = 0, 0 + for scrollArea in self.groupboxScrollAreas: + width += scrollArea.minimumWidthNoScrollbar() + scrollAreaHeight = scrollArea.minimumHeightNoScrollbar() + if scrollAreaHeight > height: + height = scrollAreaHeight + + width += 70 + height += self.sizeHint().height() - self.mainScrollArea.sizeHint().height() + + if width > maxWidth: + width = maxWidth + + if height > maxHeight: + height = maxHeight + + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + self.resize(width, height) + self.move(25, 50) + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class QLineEditDialog(QDialog): + def __init__( + self, + title="Entry messagebox", + msg="Entry value", + defaultTxt="", + parent=None, + allowedValues=None, + warnLastFrame=False, + isInteger=False, + isFloat=False, + stretchEntry=True, + allowEmpty=True, + allowedTextEntries=None, + allowText=False, + lastVisitedFrame=None, + allowList=False, + ): + QDialog.__init__(self, parent) + + self.loop = None + self.cancel = True + self.assignNewID = False + self.allowedValues = allowedValues + self.warnLastFrame = warnLastFrame + self.isFloat = isFloat + self.allowEmpty = allowEmpty + self.isInteger = isInteger + self.allowedTextEntries = allowedTextEntries + self.allowText = allowText + self.lastVisitedFrame = lastVisitedFrame + if allowedValues and warnLastFrame: + self.maxValue = max(allowedValues) + + self.setWindowTitle(title) + + # Layouts + mainLayout = QVBoxLayout() + LineEditLayout = QVBoxLayout() + buttonsLayout = QHBoxLayout() + + # Widgets + if not msg.startswith(" np.iinfo(np.uint32).max: + self.entryWidget.setText(str(np.iinfo(np.uint32).max)) + except Exception as e: + text = text.replace(newChar, "") + self.entryWidget.setText(text) + return + + if self.allowedValues is not None: + currentVal = self.value() + if self.allowList: + currentVal = currentVal[-1] + if currentVal not in self.allowedValues: + self.notValidLabel.setText(f"{currentVal} not existing!") + else: + self.notValidLabel.setText("") + + def warnValLessLastFrame(self, val): + msg = widgets.myMessageBox() + warn_txt = html_utils.paragraph(f""" + WARNING: saving until a frame number below the last visited + frame ({self.lastVisitedFrame}) will result in LOSS of information + about any edit or annotation you did on frames + {val + 1}-{self.lastVisitedFrame}.

    + Are you sure you want to proceed? + """) + msg.warning( + self, + "WARNING: Potential loss of information", + warn_txt, + buttonsTexts=("Cancel", "Yes, I am sure."), + ) + return msg.cancel + + def warnValMoreLastVisitedFrame(self, val): + msg = widgets.myMessageBox() + warn_txt = html_utils.paragraph(f""" + The last visited/validated frame is {self.lastVisitedFrame} + .

    + Are you sure you want to save until frame n. {val}?
    + """) + msg.warning( + self, + "Saving past last visited frame", + warn_txt, + buttonsTexts=("Cancel", "Yes, I am sure."), + ) + return msg.cancel + + def ok_cb(self, event): + if not self.allowEmpty and not self.entryWidget.text(): + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + msg.critical( + self, + "Empty text", + html_utils.paragraph("Text entry field cannot be empty"), + ) + return + if self.allowedTextEntries is not None: + if self.entryWidget.text() not in self.allowedTextEntries: + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = html_utils.paragraph( + f'"{self.entryWidget.text()}" is not a valid entry.

    ' + "Valid entries are:
    " + f"{html_utils.to_list(self.allowedTextEntries)}" + ) + msg.critical(self, "Not a valid entry", txt) + return + + if self.allowedValues: + if self.notValidLabel.text(): + return + + val = self.value() + + if self.warnLastFrame and self.lastVisitedFrame is not None: + if val < self.lastVisitedFrame: + cancel = self.warnValLessLastFrame(val) + if cancel: + return + + if self.lastVisitedFrame is not None: + if val > self.lastVisitedFrame: + cancel = self.warnValMoreLastVisitedFrame(val) + if cancel: + return + + self.cancel = False + try: + self.EntryID = int(val) + except Exception as err: + self.EntryID = val + + self.enteredValue = val + self.close() + + def cancel_cb(self, event): + self.cancel = True + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class QtSelectItems(QDialog): + def __init__( + self, + title, + items, + informativeText, + CbLabel="Select value: ", + parent=None, + showInFileManagerPath=None, + ): + self.cancel = True + self.selectedItemsText = "" + self.selectedItemsIdx = None + self.showInFileManagerPath = showInFileManagerPath + self.items = items + super().__init__(parent) + self.setWindowTitle(title) + + mainLayout = QVBoxLayout() + topLayout = QHBoxLayout() + self.topLayout = topLayout + bottomLayout = QHBoxLayout() + + stretchRow = 0 + if informativeText: + infoLabel = QLabel(informativeText) + mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) + stretchRow = 1 + + label = QLabel(CbLabel) + topLayout.addWidget(label, alignment=Qt.AlignRight) + + combobox = QComboBox(self) + combobox.addItems(items) + self.ComboBox = combobox + topLayout.addWidget(combobox) + + okButton = widgets.okPushButton("Ok") + cancelButton = widgets.cancelPushButton("Cancel") + if showInFileManagerPath is not None: + txt = myutils.get_open_filemaneger_os_string() + showInFileManagerButton = widgets.showInFileManagerButton(txt) + + bottomLayout.addStretch(1) + bottomLayout.addWidget(cancelButton) + bottomLayout.addSpacing(20) + if showInFileManagerPath is not None: + bottomLayout.addWidget(showInFileManagerButton) + bottomLayout.addWidget(okButton) + + multiPosButton = QPushButton("Multiple selection") + multiPosButton.setCheckable(True) + self.multiPosButton = multiPosButton + bottomLayout.addWidget(multiPosButton, alignment=Qt.AlignLeft) + + listBox = widgets.listWidget() + listBox.addItems(items) + listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + listBox.setCurrentRow(0) + listBox.setFont(font) + topLayout.addWidget(listBox) + listBox.hide() + self.ListBox = listBox + + mainLayout.addLayout(topLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(bottomLayout) + + self.setLayout(mainLayout) + self.mainLayout = mainLayout + self.topLayout = topLayout + + # self.setModal(True) + + # Connect events + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + multiPosButton.toggled.connect(self.toggleMultiSelection) + if showInFileManagerPath is not None: + showInFileManagerButton.clicked.connect(self.showInFileManager) + + self.setFont(font) + + def setSelectedItems(self, selectedItemsText): + if self.multiPosButton.isChecked(): + for i in range(self.ListBox.count()): + item = self.ListBox.item(i) + if item.text() in selectedItemsText: + item.setSelected(True) + else: + idx = self.items.index(selectedItemsText[0]) + self.ComboBox.setCurrentIndex(idx) + + def showInFileManager(self): + selectedTexts, _ = self.getSelectedItems() + folder = selectedTexts[0].split("(")[0].strip() + path = os.path.join(self.showInFileManagerPath, folder) + if os.path.exists(path) and os.path.isdir(path): + showPath = path + else: + showPath = self.showInFileManagerPath + myutils.showInExplorer(showPath) + + def toggleMultiSelection(self, checked): + if checked: + self.multiPosButton.setText("Single selection") + self.ComboBox.hide() + self.ListBox.show() + # Show 10 items + n = self.ListBox.count() + if n > 10: + h = sum([self.ListBox.sizeHintForRow(i) for i in range(10)]) + else: + h = sum([self.ListBox.sizeHintForRow(i) for i in range(n)]) + self.ListBox.setMinimumHeight(h + 5) + self.ListBox.setFocusPolicy(Qt.StrongFocus) + self.ListBox.setFocus() + self.ListBox.setCurrentRow(0) + self.mainLayout.setStretchFactor(self.topLayout, 2) + else: + self.multiPosButton.setText("Multiple selection") + self.ListBox.hide() + self.ComboBox.show() + self.resize(self.width(), self.singleSelectionHeight) + + def getSelectedItems(self): + if self.multiPosButton.isChecked(): + selectedItems = self.ListBox.selectedItems() + selectedItemsText = [item.text() for item in selectedItems] + selectedItemsText = natsorted(selectedItemsText) + selectedItemsIdx = [self.items.index(txt) for txt in selectedItemsText] + else: + selectedItemsText = [self.ComboBox.currentText()] + selectedItemsIdx = [self.ComboBox.currentIndex()] + return selectedItemsText, selectedItemsIdx + + def ok_cb(self, event): + self.cancel = False + self.selectedItemsText, self.selectedItemsIdx = self.getSelectedItems() + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + self.singleSelectionHeight = self.height() + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class SelectSegmFileDialog(QDialog): + def __init__( + self, + images_ls, + parent_path, + parent=None, + addNewFileButton=False, + basename="", + infoText=None, + fileType="segmentation", + allowMultipleSelection=False, + custom_first=None, + ): + self.cancel = True + self.selectedItemText = "" + self.selectedItemIdx = None + self.removeOthers = False + self.okAllPos = False + self.newSegmEndName = None + self.allowMultipleSelection = allowMultipleSelection + self.basename = basename + images_ls = sorted(images_ls, key=len) + if custom_first is not None: + images_ls.remove(custom_first) + images_ls.insert(0, custom_first) + + # Remove the 'segm_' part to allow filenameDialog to check if + # a new file is existing (since we only ask for the part after + # 'segm_') + self.existingEndNames = [ + n.replace("segm", "", 1).replace("_", "", 1) for n in images_ls + ] + + self.images_ls = images_ls + self.parent_path = parent_path + super().__init__(parent) + + informativeText = html_utils.paragraph(f""" + The loaded Position folders already contains + {len(self.existingEndNames)} {fileType} masks
    + """) + + self.setWindowTitle(f"{fileType.capitalize()} files detected") + is_win = sys.platform.startswith("win") + + mainLayout = QVBoxLayout() + infoLayout = QHBoxLayout() + selectionLayout = QGridLayout() + buttonsLayout = QHBoxLayout() + + # Standard Qt Question icon + label = QLabel() + standardIcon = getattr(QStyle, "SP_MessageBoxQuestion") + icon = self.style().standardIcon(standardIcon) + pixmap = icon.pixmap(60, 60) + label.setPixmap(pixmap) + infoLayout.addWidget(label) + + infoLabel = QLabel(informativeText) + infoLayout.addWidget(infoLabel) + infoLayout.addStretch(1) + mainLayout.addLayout(infoLayout) + + if infoText is None: + infoText = f"Select which {fileType} file to load:" + + questionText = html_utils.paragraph(infoText) + label = QLabel(questionText) + listWidget = widgets.listWidget() + listWidget.addItems(images_ls) + listWidget.setCurrentRow(0) + listWidget.itemDoubleClicked.connect(self.listDoubleClicked) + if allowMultipleSelection: + listWidget.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + self.items = list(images_ls) + self.listWidget = listWidget + + okButton = widgets.okPushButton(" Load selected ") + txt = "Reveal in Finder..." if is_mac else "Show in Explorer..." + showInFileManagerButton = widgets.showInFileManagerButton(txt) + cancelButton = widgets.cancelPushButton(" Cancel ") + + if addNewFileButton: + newFileButton = widgets.newFilePushButton("New file...") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addWidget(showInFileManagerButton) + buttonsLayout.addSpacing(20) + if addNewFileButton: + buttonsLayout.addWidget(newFileButton) + buttonsLayout.addWidget(okButton) + + buttonsLayout.setContentsMargins(0, 10, 0, 10) + + selectionLayout.addWidget(label, 0, 1, alignment=Qt.AlignLeft) + selectionLayout.addWidget(listWidget, 1, 1) + selectionLayout.setColumnStretch(0, 0) + selectionLayout.setColumnStretch(1, 1) + selectionLayout.setColumnStretch(2, 0) + selectionLayout.addLayout(buttonsLayout, 2, 1) + + mainLayout.addLayout(selectionLayout) + self.setLayout(mainLayout) + + self.okButton = okButton + + # Connect events + okButton.clicked.connect(self.ok_cb) + if addNewFileButton: + newFileButton.clicked.connect(self.newFile_cb) + cancelButton.clicked.connect(self.close) + showInFileManagerButton.clicked.connect(self.showInFileManager) + + def listDoubleClicked(self, item): + self.ok_cb() + + def showInFileManager(self, checked=True): + myutils.showInExplorer(self.parent_path) + + def newFile_cb(self): + win = filenameDialog( + basename=f"{self.basename}segm", + hintText="Insert a filename for the segmentation file:", + existingNames=self.existingEndNames, + ) + win.exec_() + if win.cancel: + return + self.cancel = False + self.newSegmEndName = win.entryText + self.close() + + def setSelectedItemFromText(self, itemText): + for i in range(self.listWidget.count()): + if self.listWidget.item(i).text() == itemText: + self.listWidget.setCurrentRow(i) + break + + def ok_cb(self, event=None): + self.cancel = False + try: + self.selectedItemText = self.listWidget.selectedItems()[0].text() + except IndexError: + self.cancel = True + self.close() + return + self.selectedItemIdx = self.items.index(self.selectedItemText) + self.selectedItemTexts = [ + selectedItem.text() for selectedItem in self.listWidget.selectedItems() + ] + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class QDialogPbar(QDialog): + def __init__(self, title="Progress", infoTxt="", parent=None): + self.workerFinished = False + self.aborted = False + self.clickCount = 0 + super().__init__(parent) + + abort_text = "Option+Command+C" if is_mac else "Ctrl+Alt+C" + self.abort_text = abort_text + + self.setWindowTitle(f"{title} ({abort_text} to abort)") + self.setWindowFlags(Qt.Window) + + mainLayout = QVBoxLayout() + pBarLayout = QGridLayout() + + if infoTxt: + infoLabel = QLabel(infoTxt) + mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) + + self.progressLabel = QLabel() + + self.QPbar = widgets.ProgressBar(self) + pBarLayout.addWidget(self.QPbar, 0, 0) + self.ETA_label = QLabel("NDh:NDm:NDs") + pBarLayout.addWidget(self.ETA_label, 0, 1) + + self.metricsQPbar = widgets.ProgressBar(self) + self.metricsQPbar.setValue(0) + pBarLayout.addWidget(self.metricsQPbar, 1, 0) + + # pBarLayout.setColumnStretch(2, 1) + + mainLayout.addWidget(self.progressLabel) + mainLayout.addLayout(pBarLayout) + + self.setLayout(mainLayout) + # self.setModal(True) + + def keyPressEvent(self, event): + isCtrlAlt = event.modifiers() == (Qt.ControlModifier | Qt.AltModifier) + if isCtrlAlt and event.key() == Qt.Key_C: + doAbort = self.askAbort() + if doAbort: + self.aborted = True + self.workerFinished = True + self.close() + + def askAbort(self): + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + Aborting with {self.abort_text} to abort + is not safe.

    + The system status cannot be predicted and + it will require a restart.

    + Are you sure you want to abort? + """) + yesButton, noButton = msg.critical( + self, "Are you sure you want to abort?", txt, buttonsTexts=("Yes", "No") + ) + return msg.clickedButton == yesButton + + def abort(self): + self.clickCount += 1 + self.aborted = True + if self.clickCount > 3: + self.workerFinished = True + self.close() + + def closeEvent(self, event): + if not self.workerFinished: + event.ignore() + + +class pgTestWindow(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + layout = QVBoxLayout() + + self.graphLayout = pg.GraphicsLayoutWidget() + self.ax1 = pg.PlotItem() + self.ax1.setAspectLocked(True) + self.graphLayout.addItem(self.ax1) + + layout.addWidget(self.graphLayout) + + self.setLayout(layout) + + +def get_existing_directory(allow_images_path=True, **kwargs): + while True: + folder_path = qtpy.compat.getexistingdirectory(**kwargs) + if not folder_path: + return + + if allow_images_path: + return folder_path + + pos_folderpath = os.path.dirname(folder_path) + is_images_folder = ( + folder_path.endswith("Images") + and os.path.basename(pos_folderpath).startswith("Position_") + and os.path.isdir(folder_path) + ) + if not is_images_folder: + return folder_path + + txt = html_utils.paragraph( + "You cannot save to the Images folder " + "because it is reserved to files that start with the same " + "basename.

    Thank you for your patience!" + ) + msg = widgets.myMessageBox() + msg.warning(kwargs["parent"], "Cannot save here", txt) + + +class SetCustomLevelsLut(QBaseDialog): + sigLevelsChanged = Signal(object) + + def __init__( + self, + init_min_value=None, + init_max_value=None, + minimum_min_value=0, + maximum_max_value=None, + parent=None, + ): + super().__init__(parent=parent) + + self.cancel = True + + self.setWindowTitle("Custom LUT levels") + + layout = QVBoxLayout() + + self.minLevelSlider = widgets.sliderWithSpinBox( + title="Minimum", + title_loc="top", + ) + self.minLevelSlider.setMinimum(minimum_min_value) + + if init_min_value is not None: + self.minLevelSlider.setValue(init_min_value) + + layout.addWidget(self.minLevelSlider) + + self.maxLevelSlider = widgets.sliderWithSpinBox( + title="Maximum", + title_loc="top", + ) + self.maxLevelSlider.setMinimum(minimum_min_value) + if init_max_value is not None: + self.maxLevelSlider.setValue(init_max_value) + + if maximum_max_value is not None: + self.maxLevelSlider.setMaximum(maximum_max_value) + self.minLevelSlider.setMaximum(maximum_max_value) + + layout.addWidget(self.maxLevelSlider) + + self.minLevelSlider.sigValueChange.connect(self.emitLevelsChanged) + self.maxLevelSlider.sigValueChange.connect(self.emitLevelsChanged) + + buttonsLayout = widgets.CancelOkButtonsLayout() + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + layout.addSpacing(20) + layout.addLayout(buttonsLayout) + + self.setLayout(layout) + + def sizeHint(self): + heightHint = super().sizeHint().height() + widthHint = super().sizeHint().width() * 2 + return QSize(widthHint, heightHint) + + def levels(self): + levels = (self.minLevelSlider.value(), self.maxLevelSlider.value()) + return levels + + def emitLevelsChanged(self, value): + self.sigLevelsChanged.emit(self.levels()) + + def ok_cb(self): + self.cancel = False + self.selectedLevels = self.levels() + self.close() + + +class QTreeDialog(QBaseDialog): + def __init__( + self, + items: List[Tuple[str]], + headerLabels: List[str] = None, + parent=None, + infoText="Select item", + title="Select item", + path_to_browse=None, + additional_buttons=None, + ): + self.cancel = True + super().__init__(parent) + + self.setWindowTitle(title) + + mainLayout = QVBoxLayout() + + infoLabel = QLabel(html_utils.paragraph(infoText)) + + self.treeWidget = widgets.TreeWidget() + if headerLabels is not None: + self.treeWidget.setHeaderLabels(headerLabels) + else: + self.treeWidget.setHeaderHidden(True) + + for row, texts in enumerate(items): + item = widgets.TreeWidgetItem(self.treeWidget) + for i, text in enumerate(texts): + item.setText(i, text) + self.treeWidget.addTopLevelItem(item) + + self.treeWidget.resizeColumnToContents(0) + self.treeWidget.resizeColumnToContents(1) + + # self.treeWidget.header().setStretchLastSection(False) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + if path_to_browse is not None: + browseButton = widgets.showInFileManagerButton(setDefaultText=True) + browseButton.setPathToBrowse(path_to_browse) + buttonsLayout.insertWidget(3, browseButton) + + if additional_buttons is not None: + for btn in additional_buttons: + buttonsLayout.insertWidget(3, btn) + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addWidget(infoLabel) + mainLayout.addWidget(self.treeWidget) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def show(self, block=False): + w = self.sizeHint().width() + h = self.sizeHint().height() + self.resize(int(w * 1.3), h) + super().show(block=block) + + def ok_cb(self): + self.clickedButton = self.sender() + self.cancel = False + self.selectedItem = self.treeWidget.currentItem() + self.selectedText = self.selectedItem.text(0) + self.close() + +# Sibling imports (deferred to avoid import cycles) +from .metadata import ( + filenameDialog, +) + diff --git a/cellacdc/dialogs/measurements.py b/cellacdc/dialogs/measurements.py new file mode 100644 index 000000000..43be79ab6 --- /dev/null +++ b/cellacdc/dialogs/measurements.py @@ -0,0 +1,2979 @@ +"""Cell-ACDC dialog windows: measurements.""" + +import os +import sys +import re +from typing import Literal, Callable, Dict, Iterable, List, Tuple +import datetime +import pathlib +from collections import defaultdict +import zipfile +from heapq import nlargest +import matplotlib +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.patches import Rectangle, Circle, PathPatch, Path +import numpy as np +import scipy.interpolate + +try: + import tkinter as tk +except Exception as err: + pass + +import cv2 +import traceback +from itertools import combinations, permutations +from collections import namedtuple +from natsort import natsorted + +# from MyWidgets import Slider, Button, MyRadioButtons +from skimage.measure import label, regionprops +from functools import partial +import skimage.filters +import skimage.measure +import skimage.morphology +import skimage.exposure +import skimage.draw +import skimage.registration +import skimage.color +import skimage.segmentation +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk +import matplotlib.pyplot as plt +import seaborn as sns +import pandas as pd +import math +import time +import sympy as sp +import json +import html + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from qtpy import QtCore +from qtpy.QtGui import ( + QIcon, + QFontMetrics, + QKeySequence, + QFont, + QRegularExpressionValidator, + QCursor, + QKeyEvent, + QPixmap, + QFont, + QPalette, + QMouseEvent, + QColor, +) +from qtpy.QtCore import ( + Qt, + QSize, + QEvent, + Signal, + QEventLoop, + QTimer, + QRegularExpression, +) +from qtpy.QtWidgets import ( + QFileDialog, + QApplication, + QMainWindow, + QMenu, + QLabel, + QToolBar, + QScrollBar, + QWidget, + QVBoxLayout, + QLineEdit, + QPushButton, + QHBoxLayout, + QDialog, + QFormLayout, + QListWidget, + QAbstractItemView, + QButtonGroup, + QCheckBox, + QSizePolicy, + QComboBox, + QSlider, + QGridLayout, + QSpinBox, + QToolButton, + QTableView, + QTextBrowser, + QDoubleSpinBox, + QScrollArea, + QFrame, + QProgressBar, + QGroupBox, + QRadioButton, + QDockWidget, + QMessageBox, + QStyle, + QPlainTextEdit, + QSpacerItem, + QTreeWidget, + QTreeWidgetItem, + QTextEdit, + QSplashScreen, + QAction, + QListWidgetItem, + QActionGroup, + QHeaderView, + QStyledItemDelegate, +) +import qtpy.compat + +from .. import exception_handler +from .. import load, prompts, core, measurements, html_utils +from .. import is_mac, is_win, is_linux, settings_folderpath, config +from .. import preproc_recipes_path, segm_recipes_path, combine_channels_recipes_path +from .. import is_conda_env +from .. import printl +from .. import colors +from .. import issues_url +from .. import myutils +from .. import qutils +from .. import _palettes +from .. import base_cca_dict +from .. import widgets +from .. import user_profile_path, promptable_models_path, models_path +from .. import features +from .. import _core +from .. import _types +from .. import plot +from .. import urls +from ..acdc_regex import float_regex, is_alphanumeric_filename, to_alphanumeric +from .. import _base_widgets +from .. import io +from .. import cca_functions +from .. import path + +POSITIVE_FLOAT_REGEX = float_regex(allow_negative=False) +TREEWIDGET_STYLESHEET = _palettes.TreeWidgetStyleSheet() +LISTWIDGET_STYLESHEET = _palettes.ListWidgetStyleSheet() +BACKGROUND_RGBA = _palettes.get_disabled_colors()["Button"] + +font = QFont() +font.setPixelSize(12) +italicFont = QFont() +italicFont.setPixelSize(12) +italicFont.setItalic(True) + +from ._base import ( + QBaseDialog, +) + +class SetMeasurementsDialog(QBaseDialog): + sigClosed = Signal() + sigCancel = Signal() + sigRestart = Signal() + + def __init__( + self, + loadedChNames, + notLoadedChNames, + isZstack, + isSegm3D, + favourite_funcs=None, + parent=None, + allPos_acdc_df_cols=None, + acdc_df_path=None, + posData=None, + addCombineMetricCallback=None, + allPosData=None, + is_concat=False, + isSingleSelection=False, + state=None, + ): + super().__init__(parent=parent) + + self.checkBoxedGroup = QButtonGroup() + self.checkBoxedGroup.setExclusive(isSingleSelection) + + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + + self.cancel = True + + self.delExistingCols = False + self.okClicked = False + self.is_concat = is_concat + self.allPos_acdc_df_cols = allPos_acdc_df_cols + self.acdc_df_path = acdc_df_path + self.allPosData = allPosData + self.doNotWarn = False + + self.setWindowTitle("Set measurements") + # self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + + layout = QVBoxLayout() + + searchLayout = QHBoxLayout() + + searchLineEdit = widgets.SearchLineEdit() + searchLayout.addStretch(5) + searchLayout.addWidget(searchLineEdit) + searchLayout.setStretch(1, 3) + + mainScrollArea = widgets.ScrollArea() + mainScrollAreaWidget = QWidget() + mainScrollArea.setWidget(mainScrollAreaWidget) + + groupsLayout = QGridLayout() + self.groupsLayout = groupsLayout + + mainScrollAreaWidget.setLayout(groupsLayout) + + buttonsLayout = QHBoxLayout() + + self.chNameGroupboxes = [] + self.all_metrics = [] + + col = 0 + for col, chName in enumerate(loadedChNames): + channelGBox = widgets.channelMetricsQGBox( + isZstack, + chName, + isSegm3D, + favourite_funcs=favourite_funcs, + posData=posData, + is_concat=is_concat, + ) + channelGBox.chName = chName + groupsLayout.addWidget(channelGBox, 0, col, 3, 1) + self.chNameGroupboxes.append(channelGBox) + channelGBox.sigDelClicked.connect(self.delMixedChannelCombineMetric) + channelGBox.sigCheckboxToggled.connect(self.channelCheckboxToggled) + groupsLayout.setColumnStretch(col, 5) + self.all_metrics.extend([c.text() for c in channelGBox.checkBoxes]) + + current_col = col + 1 + for col, chName in enumerate(notLoadedChNames): + channelGBox = widgets.channelMetricsQGBox( + isZstack, + chName, + isSegm3D, + favourite_funcs=favourite_funcs, + posData=posData, + is_concat=is_concat, + ) + channelGBox.setChecked(False) + channelGBox.chName = chName + groupsLayout.addWidget(channelGBox, 0, current_col, 3, 1) + self.chNameGroupboxes.append(channelGBox) + groupsLayout.setColumnStretch(current_col, 5) + channelGBox.sigDelClicked.connect(self.delMixedChannelCombineMetric) + channelGBox.sigCheckboxToggled.connect(self.channelCheckboxToggled) + current_col += 1 + self.all_metrics.extend([c.text() for c in channelGBox.checkBoxes]) + + current_col += 1 + + if posData is None: + isTimelapse = False + else: + isTimelapse = posData.SizeT > 1 + size_metrics_desc = measurements.get_size_metrics_desc(isSegm3D, isTimelapse) + if not isSegm3D: + size_metrics_desc = { + key: val + for key, val in size_metrics_desc.items() + if not key.endswith("_3D") + } + + row = 0 + sizeMetricsQGBox = widgets._metricsQGBox( + size_metrics_desc, + "Physical measurements", + favourite_funcs=favourite_funcs, + isZstack=isZstack, + addCalcForEachZsliceToggle=isSegm3D, + ) + self.all_metrics.extend([c.text() for c in sizeMetricsQGBox.checkBoxes]) + self.sizeMetricsQGBox = sizeMetricsQGBox + for sizeCheckbox in sizeMetricsQGBox.checkBoxes: + sizeCheckbox.toggled.connect(self.sizeMetricToggled) + groupsLayout.addWidget(sizeMetricsQGBox, row, current_col) + groupsLayout.setRowStretch(0, 1) + groupsLayout.setColumnStretch(current_col, 3) + row += 1 + + props_info_txt_mapper = measurements.get_props_info_txt_mapper( + isSegm3D=isSegm3D + ) + rp_desc = props_info_txt_mapper + regionPropsQGBox = widgets._metricsQGBox( + rp_desc, + "Morphological properties", + favourite_funcs=favourite_funcs, + isZstack=isZstack, + ) + self.regionPropsQGBox = regionPropsQGBox + for rpCheckbox in regionPropsQGBox.checkBoxes: + rpCheckbox.toggled.connect(self.rpMetricToggled) + groupsLayout.addWidget(regionPropsQGBox, row, current_col) + groupsLayout.setRowStretch(1, 2) + self.all_metrics.extend([c.text() for c in regionPropsQGBox.checkBoxes]) + row += 1 + + # Custom metrics that are channel indipendent + self.chIndipendCustomeMetricsQGBox = None + out = measurements.ch_indipend_custom_metrics_desc( + isZstack, + isSegm3D=isSegm3D, + ) + ch_indipend_custom_metrics_desc = out + if ch_indipend_custom_metrics_desc: + self.chIndipendCustomeMetricsQGBox = widgets._metricsQGBox( + ch_indipend_custom_metrics_desc, + "Channel indipendent custom measurements", + favourite_funcs=favourite_funcs, + isZstack=isZstack, + parent=self, + ) + groupsLayout.addWidget(self.chIndipendCustomeMetricsQGBox, row, current_col) + groupsLayout.setRowStretch(1, 1) + row += 1 + + desc, equations = measurements.combine_mixed_channels_desc( + isSegm3D=isSegm3D, posData=posData, available_cols=self.all_metrics + ) + self.mixedChannelsCombineMetricsQGBox = None + if desc: + self.mixedChannelsCombineMetricsQGBox = widgets._metricsQGBox( + desc, + "Mixed channels combined measurements", + favourite_funcs=favourite_funcs, + isZstack=isZstack, + equations=equations, + addDelButton=True, + ) + self.mixedChannelsCombineMetricsQGBox.sigDelClicked.connect( + self.delMixedChannelCombineMetric + ) + groupsLayout.addWidget( + self.mixedChannelsCombineMetricsQGBox, row, current_col + ) + groupsLayout.setRowStretch(1, 1) + if not self.is_concat: + self.setDisabledMetricsRequestedForCombined(False) + self.mixedChannelsCombineMetricsQGBox.toggled.connect( + self.setDisabledMetricsRequestedForCombined + ) + for combCheckbox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: + combCheckbox.toggled.connect( + self.setDisabledMetricsRequestedForCombined + ) + else: + for combCheckbox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: + combCheckbox.toggled.connect(self.mixedChannelsMetricToggled) + row += 1 + + self.last_row = row + self.last_col = current_col + + okButton = widgets.okPushButton(" Ok ") + cancelButton = widgets.cancelPushButton("Cancel") + if addCombineMetricCallback is not None: + addCombineMetricButton = widgets.addPushButton( + "Add combined measurement..." + ) + addCombineMetricButton.clicked.connect(addCombineMetricCallback) + self.okButton = okButton + + loadLastSelButton = widgets.reloadPushButton("Load last selection...") + self.deselectAllButton = QPushButton("Deselect all") + self.deselectAllButton.setIcon(QIcon(":deselect_all.svg")) + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(self.deselectAllButton) + buttonsLayout.addSpacing(20) + + if addCombineMetricCallback is not None: + buttonsLayout.addWidget(addCombineMetricButton) + buttonsLayout.addSpacing(20) + + saveCurrentSelectionButton = widgets.savePushButton("Save current selection...") + saveCurrentSelectionButton.clicked.connect(self.saveCurrentSelectionClicked) + + buttonsLayout.addWidget(saveCurrentSelectionButton) + + loadSavedSelectionButton = widgets.OpenFilePushButton("Load saved selection...") + loadSavedSelectionButton.clicked.connect(self.loadSavedSelectionClicked) + buttonsLayout.addWidget(loadSavedSelectionButton) + + buttonsLayout.addWidget(loadLastSelButton) + + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(okButton) + + self.okButton = okButton + + layout.addLayout(searchLayout) + layout.addSpacing(10) + # layout.addLayout(groupsLayout) + layout.addWidget(mainScrollArea) + layout.addLayout(buttonsLayout) + + self.setLayout(layout) + + if state is not None: + self.setState(state) + + searchLineEdit.textEdited.connect(self.searchAndHighlight) + self.deselectAllButton.clicked.connect(self.deselectAll) + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + loadLastSelButton.clicked.connect(self.loadLastSelection) + + self.addCheckboxesToGroup() + + for channelGBox in self.chNameGroupboxes: + for checkbox in channelGBox.checkBoxes: + self.channelCheckboxToggled(checkbox) + + def allMetricsDict(self): + all_metrics = { + "standard": {}, + "regionprop": [], + "size": [], + "mixed_channels": [], + } + for chNameGroupbox in self.chNameGroupboxes: + channel_name = chNameGroupbox.chName + for checkBox in chNameGroupbox.checkBoxes: + if channel_name not in all_metrics["standard"]: + all_metrics["standard"][channel_name] = [] + all_metrics["standard"][channel_name].append(checkBox.text()) + + for checkBox in self.regionPropsQGBox.checkBoxes: + all_metrics["regionprop"].append(checkBox.text()) + + for checkBox in self.sizeMetricsQGBox.checkBoxes: + all_metrics["size"].append(checkBox.text()) + + if self.chIndipendCustomeMetricsQGBox is not None: + checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes + for checkBox in checkBoxes: + all_metrics["ch_indipend_custom_metric"].append(checkBox.text()) + + if self.mixedChannelsCombineMetricsQGBox is None: + return + + checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes + for checkBox in checkBoxes: + all_metrics["mixed_channels"].append(checkBox.text()) + + return all_metrics + + def searchAndHighlight(self, text): + for chNameGroupbox in self.chNameGroupboxes: + for groupbox in chNameGroupbox.groupboxes: + groupbox.highlightCheckboxesFromSearchText(text) + + self.regionPropsQGBox.highlightCheckboxesFromSearchText(text) + self.sizeMetricsQGBox.highlightCheckboxesFromSearchText(text) + + if self.chIndipendCustomeMetricsQGBox is not None: + self.chIndipendCustomeMetricsQGBox.highlightCheckboxesFromSearchText(text) + + if self.mixedChannelsCombineMetricsQGBox is None: + return + + self.mixedChannelsCombineMetricsQGBox.highlightCheckboxesFromSearchText(text) + + def selectedMetricNameAndGroup(self): + for chNameGroupbox in self.chNameGroupboxes: + for checkBox in chNameGroupbox.checkBoxes: + if checkBox.isChecked(): + return checkBox.text(), {"standard": chNameGroupbox.chName} + + for checkBox in self.regionPropsQGBox.checkBoxes: + if checkBox.isChecked(): + return checkBox.text(), "regionprop" + + for checkBox in self.sizeMetricsQGBox.checkBoxes: + if checkBox.isChecked(): + return checkBox.text(), "size" + + if self.chIndipendCustomeMetricsQGBox is not None: + checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes + for checkBox in checkBoxes: + if checkBox.isChecked(): + return checkBox.text(), "ch_indipend_custom_metric" + + if self.mixedChannelsCombineMetricsQGBox is None: + return + + checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes + for checkBox in checkBoxes: + if checkBox.isChecked(): + return checkBox.text(), "mixed_channels" + + def selectedMetricGroup(self): + for chNameGroupbox in self.chNameGroupboxes: + for checkBox in chNameGroupbox.checkBoxes: + if checkBox.isChecked(): + return checkBox.text() + + for checkBox in self.regionPropsQGBox.checkBoxes: + if checkBox.isChecked(): + return checkBox.text() + + for checkBox in self.sizeMetricsQGBox.checkBoxes: + if checkBox.isChecked(): + return checkBox.text() + + if self.chIndipendCustomeMetricsQGBox is not None: + checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes + for checkBox in checkBoxes: + if checkBox.isChecked(): + return checkBox.text() + + if self.mixedChannelsCombineMetricsQGBox is None: + return + + checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes + for checkBox in checkBoxes: + if checkBox.isChecked(): + return checkBox.text() + + def addCheckboxesToGroup(self): + for chNameGroupbox in self.chNameGroupboxes: + for checkBox in chNameGroupbox.checkBoxes: + self.checkBoxedGroup.addButton(checkBox) + + for checkBox in self.regionPropsQGBox.checkBoxes: + self.checkBoxedGroup.addButton(checkBox) + + for checkBox in self.sizeMetricsQGBox.checkBoxes: + self.checkBoxedGroup.addButton(checkBox) + + if self.chIndipendCustomeMetricsQGBox is not None: + checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes + for checkBox in checkBoxes: + self.checkBoxedGroup.addButton(checkBox) + + if self.mixedChannelsCombineMetricsQGBox is None: + return + + checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes + for checkBox in checkBoxes: + self.checkBoxedGroup.addButton(checkBox) + + def channelCheckboxToggled(self, checkbox): + # Make sure to automatically check the requested cell_vol metric for + # concentration metrics + if checkbox.text().find("concentration_") == -1: + return + + if self.is_concat: + # When this dialogue is used in concatenate pos utility we do not + # need to check that certain metrics are present + return + + pattern = r".+_from_vol_([a-z]+)(_3D)?(_?[A-Za-z0-9]*)" + repl = r"cell_vol_\1\2" + cell_vol_metric_name = re.sub(pattern, repl, checkbox.text()) + for sizeCheckbox in self.sizeMetricsQGBox.checkBoxes: + if sizeCheckbox.text() == cell_vol_metric_name: + break + else: + # Make sure to not check for similarly named custom metrics + return + + if checkbox.isChecked(): + sizeCheckbox.setChecked(True) + sizeCheckbox.isRequired = True + else: + # Do not enable cell vol checkbox is any of the other + # concentration metrics requiring it is checked + unit = cell_vol_metric_name[9:] + is3D = unit.endswith("3D") + for channelGBox in self.chNameGroupboxes: + if not channelGBox.isChecked(): + continue + for _checkbox in channelGBox.checkBoxes: + if _checkbox.text().find(f"_from_vol_{unit}") == -1: + continue + if not is3D and _checkbox.text().find(f"{unit}_3D") != -1: + # Metric is 3D but the cell_vol is not + continue + if _checkbox.isChecked(): + return + sizeCheckbox.isRequired = False + + def rpMetricToggled(self, checked): + pass + + def mixedChannelsMetricToggled(self, checked): + pass + + def sizeMetricToggled(self, checked): + """Method called when a checkbox of a size metric is toggled. + Check if the size value is required and explain why it cannot be + unchecked. + + Parameters + ---------- + checked : bool + State of the checkbox toggled + """ + checkbox = self.sender() + + if self.is_concat: + # When this dialogue is used in concatenate pos utility we do not + # need to check that certain metrics are present + return + + if not hasattr(checkbox, "isRequired"): + return + + if not checkbox.isRequired: + return + + if checkbox.isChecked(): + return + + checkbox.setChecked(True) + + if self.doNotWarn: + return + + linked_autoBkgr_metric = checkbox.text().replace("cell", "_autoBkgr_from") + linked_dataPrepBkgr_metric = checkbox.text().replace( + "cell", "_dataPrepBkgr_from" + ) + txt = html_utils.paragraph(f""" + This physical measurement cannot be unchecked + because it is required + by the {linked_autoBkgr_metric} and + {linked_dataPrepBkgr_metric} measurements + that you requested to save.

    + + Thank you for you patience! + """) + msg = widgets.myMessageBox(showCentered=False) + msg.warning(self, "Physical measurement required", txt) + + def deselectAll(self): + self.doNotWarn = True + for chNameGroupbox in self.chNameGroupboxes: + for gb in chNameGroupbox.groupboxes: + gb.checkAll(None, False) + cgb = getattr(chNameGroupbox, "customMetricsQGBox", None) + if cgb is not None: + cgb.checkAll(None, False) + + self.sizeMetricsQGBox.checkAll(None, False) + self.regionPropsQGBox.checkAll(None, False) + if self.chIndipendCustomeMetricsQGBox is not None: + self.chIndipendCustomeMetricsQGBox.checkAll(None, False) + + if self.mixedChannelsCombineMetricsQGBox is not None: + self.mixedChannelsCombineMetricsQGBox.checkAll(None, False) + self.doNotWarn = False + + def delMixedChannelCombineMetric(self, colname_to_del, hlayout): + cp = measurements.read_saved_user_combine_config() + for section in cp.sections(): + cp.remove_option(section, colname_to_del) + measurements.save_common_combine_metrics(cp) + + for i in range(hlayout.count()): + item = hlayout.itemAt(i) + w = item.widget() + if w is None: + continue + w.hide() + + if self.allPosData is not None: + for posData in self.allPosData: + _config = posData.combineMetricsConfig + for section in _config.sections(): + _config.remove_option(section, colname_to_del) + posData.saveCombineMetrics() + + def setState(self, state): + self.doNotWarn = True + for chNameGroupbox in self.chNameGroupboxes: + measurementsInfo = state.get(chNameGroupbox.title()) + if not measurementsInfo: + chNameGroupbox.setChecked(False) + else: + for checkBox in chNameGroupbox.checkBoxes: + colname = checkBox.text() + checkBox.setChecked(measurementsInfo[colname]) + + measurementsInfo = state.get(self.sizeMetricsQGBox.title()) + if not measurementsInfo: + self.sizeMetricsQGBox.setChecked(False) + else: + for checkBox in self.sizeMetricsQGBox.checkBoxes: + checked = checkBox.isChecked() + colname = checkBox.text() + checkBox.setChecked(measurementsInfo[colname]) + + measurementsInfo = state.get(self.regionPropsQGBox.title()) + if not measurementsInfo: + self.regionPropsQGBox.setChecked(False) + else: + self.regionPropsToSave = [] + for checkBox in self.regionPropsQGBox.checkBoxes: + checked = checkBox.isChecked() + colname = checkBox.text() + checkBox.setChecked(measurementsInfo[colname]) + + if self.chIndipendCustomeMetricsQGBox is not None: + measurementsInfo = state.get(self.chIndipendCustomeMetricsQGBox.title()) + if not measurementsInfo: + self.chIndipendCustomeMetricsQGBox.setChecked(False) + else: + checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes + for checkBox in checkBoxes: + checked = checkBox.isChecked() + colname = checkBox.text() + key = self.chIndipendCustomeMetricsQGBox.title() + checkBox.setChecked(measurementsInfo[colname]) + + if self.mixedChannelsCombineMetricsQGBox is not None: + measurementsInfo = state.get(self.mixedChannelsCombineMetricsQGBox.title()) + if not measurementsInfo: + self.mixedChannelsCombineMetricsQGBox.setChecked(False) + else: + checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes + for checkBox in checkBoxes: + checked = checkBox.isChecked() + colname = checkBox.text() + key = self.mixedChannelsCombineMetricsQGBox.title() + checkBox.setChecked(measurementsInfo[colname]) + + self.doNotWarn = False + + def state(self): + state = {self.sizeMetricsQGBox.title(): {}, self.regionPropsQGBox.title(): {}} + for chNameGroupbox in self.chNameGroupboxes: + state[chNameGroupbox.title()] = {} + if not chNameGroupbox.isChecked(): + # Channel unchecked + continue + else: + for checkBox in chNameGroupbox.checkBoxes: + colname = checkBox.text() + state[chNameGroupbox.title()][colname] = checkBox.isChecked() + + if not self.sizeMetricsQGBox.isChecked(): + pass + else: + for checkBox in self.sizeMetricsQGBox.checkBoxes: + checked = checkBox.isChecked() + colname = checkBox.text() + state[self.sizeMetricsQGBox.title()][colname] = checked + + if not self.regionPropsQGBox.isChecked(): + pass + else: + self.regionPropsToSave = [] + for checkBox in self.regionPropsQGBox.checkBoxes: + checked = checkBox.isChecked() + colname = checkBox.text() + state[self.regionPropsQGBox.title()][colname] = checked + + if self.chIndipendCustomeMetricsQGBox is not None: + state[self.chIndipendCustomeMetricsQGBox.title()] = {} + if self.chIndipendCustomeMetricsQGBox.isChecked(): + checkBoxes = self.chIndipendCustomeMetricsQGBox.checkBoxes + for checkBox in checkBoxes: + checked = checkBox.isChecked() + key = self.chIndipendCustomeMetricsQGBox.title() + colname = checkBox.text() + state[key][colname] = checked + + if self.mixedChannelsCombineMetricsQGBox is not None: + state[self.mixedChannelsCombineMetricsQGBox.title()] = {} + if self.mixedChannelsCombineMetricsQGBox.isChecked(): + checkBoxes = self.mixedChannelsCombineMetricsQGBox.checkBoxes + for checkBox in checkBoxes: + checked = checkBox.isChecked() + key = self.mixedChannelsCombineMetricsQGBox.title() + colname = checkBox.text() + state[key][colname] = checked + + return state + + def restoreState(self, state): + for chNameGroupbox in self.chNameGroupboxes: + _state = state.get(chNameGroupbox.title()) + if _state is None or not _state: + continue + for checkBox in chNameGroupbox.checkBoxes: + isChecked = _state.get(checkBox.text()) + if isChecked is None: + continue + checkBox.setChecked(isChecked) + + _state = state.get(self.sizeMetricsQGBox.title()) + if _state is None or not _state: + pass + else: + for checkBox in self.sizeMetricsQGBox.checkBoxes: + isChecked = _state.get(checkBox.text()) + if isChecked is None: + continue + checkBox.setChecked(isChecked) + + _state = state.get(self.regionPropsQGBox.title()) + if _state is None or not _state: + pass + else: + for checkBox in self.regionPropsQGBox.checkBoxes: + isChecked = _state.get(checkBox.text()) + if isChecked is None: + continue + checkBox.setChecked(isChecked) + + if self.chIndipendCustomeMetricsQGBox is not None: + _state = state.get(self.chIndipendCustomeMetricsQGBox.title()) + if _state is None or not _state: + pass + else: + for checkBox in self.chIndipendCustomeMetricsQGBox.checkBoxes: + isChecked = _state.get(checkBox.text()) + if isChecked is None: + continue + checkBox.setChecked(isChecked) + + if self.mixedChannelsCombineMetricsQGBox is not None: + _state = state.get(self.mixedChannelsCombineMetricsQGBox.title()) + if _state is None or not _state: + pass + else: + for checkBox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: + isChecked = _state.get(checkBox.text()) + if isChecked is None: + continue + checkBox.setChecked(isChecked) + + def currentSelectionMapper(self): + current_selected_meas = defaultdict(dict) + + for chNameGroupbox in self.chNameGroupboxes: + if not chNameGroupbox.isChecked(): + continue + + chName = chNameGroupbox.chName + for checkBox in chNameGroupbox.checkBoxes: + if not checkBox.isChecked(): + continue + + current_selected_meas[chName][checkBox.text()] = "Yes" + + size_selected_meas = current_selected_meas.get(self.sizeMetricsQGBox.title()) + if self.sizeMetricsQGBox.isChecked(): + for checkBox in self.sizeMetricsQGBox.checkBoxes: + if not checkBox.isChecked(): + continue + + section = self.sizeMetricsQGBox.title() + current_selected_meas[section][checkBox.text()] = "Yes" + + size_selected_meas = current_selected_meas.get(self.regionPropsQGBox.title()) + if self.regionPropsQGBox.isChecked(): + for checkBox in self.regionPropsQGBox.checkBoxes: + if not checkBox.isChecked(): + continue + + section = self.regionPropsQGBox.title() + current_selected_meas[section][checkBox.text()] = "Yes" + + if self.chIndipendCustomeMetricsQGBox is not None: + if self.chIndipendCustomeMetricsQGBox.isChecked(): + for checkBox in self.chIndipendCustomeMetricsQGBox.checkBoxes: + if not checkBox.isChecked(): + continue + + section = self.chIndipendCustomeMetricsQGBox.title() + current_selected_meas[section][checkBox.text()] = "Yes" + + if self.mixedChannelsCombineMetricsQGBox is not None: + if self.mixedChannelsCombineMetricsQGBox.isChecked(): + for checkBox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: + if not checkBox.isChecked(): + continue + + section = self.mixedChannelsCombineMetricsQGBox.title() + current_selected_meas[section][checkBox.text()] = "Yes" + + return current_selected_meas + + def saveCurrentSelectionClicked(self): + current_selection_mapper = self.currentSelectionMapper() + defaultEntry = "_and_".join(current_selection_mapper.keys()) + defaultEntry = defaultEntry.replace(" ", "_").lower() + saved_selections = io.get_saved_measurements_selections() + win = filenameDialog( + basename="", + ext="", + hintText="Insert a name for the current selection:", + existingNames=saved_selections, + allowEmpty=False, + defaultEntry=defaultEntry, + ) + win.exec_() + if win.cancel: + return + + filename = win.filename + ini_filepath = io.save_measurements_selections( + filename, current_selection_mapper + ) + + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + txt = html_utils.paragraph(f""" + Done!

    + Current selection saved with name {filename} at + the following path: + """) + msg.information( + self, + "Selection saved", + txt, + commands=(ini_filepath,), + path_to_browse=os.path.dirname(ini_filepath), + ) + + def loadSavedSelectionClicked(self): + self.doNotWarn = True + + saved_selections = io.get_saved_measurements_selections() + + selectNameWin = widgets.QDialogListbox( + "Choose selection to load", + "Choose selection to load:\n", + saved_selections, + multiSelection=False, + parent=self, + ) + selectNameWin.exec_() + if selectNameWin.cancel: + return + + selection_mapper = io.read_measurements_selections( + selectNameWin.selectedItemsText[0] + ) + + self.setCurrentSelectionFromMapper(selection_mapper) + + self.doNotWarn = False + + def saveLastSelection(self): + last_selected_meas = self.currentSelectionMapper() + load.write_last_selected_set_measurements(last_selected_meas) + + def setCurrentSelectionFromMapper(self, selection_mapper): + for chNameGroupbox in self.chNameGroupboxes: + chName = chNameGroupbox.chName + chSelectedMeas = selection_mapper.get(chName) + if chSelectedMeas is None: + chNameGroupbox.setChecked(False) + continue + + chNameGroupbox.setChecked(True) + for checkBox in chNameGroupbox.checkBoxes: + checked = chSelectedMeas.get(checkBox.text()) + if checked is not None: + checkBox.setChecked(True) + else: + checkBox.setChecked(False) + + size_selected_meas = selection_mapper.get(self.sizeMetricsQGBox.title()) + if size_selected_meas is None: + self.sizeMetricsQGBox.setChecked(False) + else: + self.sizeMetricsQGBox.setChecked(True) + for checkBox in self.sizeMetricsQGBox.checkBoxes: + checked = size_selected_meas.get(checkBox.text()) + if checked is not None: + checkBox.setChecked(True) + else: + checkBox.setChecked(False) + + size_selected_meas = selection_mapper.get(self.regionPropsQGBox.title()) + if size_selected_meas is None: + self.regionPropsQGBox.setChecked(False) + else: + self.regionPropsQGBox.setChecked(True) + for checkBox in self.regionPropsQGBox.checkBoxes: + checked = size_selected_meas.get(checkBox.text()) + if checked is not None: + checkBox.setChecked(True) + else: + checkBox.setChecked(False) + + if self.chIndipendCustomeMetricsQGBox is not None: + ch_indip_custom_metrics = selection_mapper.get( + self.chIndipendCustomeMetricsQGBox.title() + ) + if size_selected_meas is None: + self.chIndipendCustomeMetricsQGBox.setChecked(False) + else: + self.chIndipendCustomeMetricsQGBox.setChecked(True) + for checkBox in self.chIndipendCustomeMetricsQGBox.checkBoxes: + checked = size_selected_meas.get(checkBox.text()) + if checked is not None: + checkBox.setChecked(True) + else: + checkBox.setChecked(False) + + if self.mixedChannelsCombineMetricsQGBox is not None: + ch_indip_custom_metrics = selection_mapper.get( + self.mixedChannelsCombineMetricsQGBox.title() + ) + if size_selected_meas is None: + self.mixedChannelsCombineMetricsQGBox.setChecked(False) + else: + self.mixedChannelsCombineMetricsQGBox.setChecked(True) + for checkBox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: + checked = size_selected_meas.get(checkBox.text()) + if checked is not None: + checkBox.setChecked(True) + else: + checkBox.setChecked(False) + + def loadLastSelection(self): + self.doNotWarn = True + last_selected_meas = load.read_last_selected_set_measurements() + last_selected_meas = dict(last_selected_meas) + + self.setCurrentSelectionFromMapper(last_selected_meas) + + self.doNotWarn = False + + def setDisabledMetricsRequestedForCombined(self, checked): + checkbox = self.sender() + + if self.is_concat: + # When this dialogue is used in concatenate pos utility we do not + # need to check that certain metrics are present + return + + # Set checked and disable those metrics that are requested for + # combined measurements + allCheckboxes = [] + + for chNameGroupbox in self.chNameGroupboxes: + for chCheckBox in chNameGroupbox.checkBoxes: + chCheckBox.setDisabled(False) + allCheckboxes.append(chCheckBox) + + for sizeCheckBox in self.sizeMetricsQGBox.checkBoxes: + sizeCheckBox.setDisabled(False) + allCheckboxes.append(chCheckBox) + + for rpCheckBox in self.regionPropsQGBox.checkBoxes: + rpCheckBox.setDisabled(False) + allCheckboxes.append(chCheckBox) + + if not self.mixedChannelsCombineMetricsQGBox.isChecked(): + return + + for cb in allCheckboxes: + metricName = cb.text() + for combCheckbox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: + equation = combCheckbox.equation + if equation.find(metricName) == -1: + continue + elif combCheckbox.isChecked(): + cb.setChecked(True) + cb.setDisabled(True) + cb.setToolTip( + "This metric cannot be removed because it is required " + f'by the combined measurement "{combCheckbox.text()}"' + ) + + def keyPressEvent(self, a0: QKeyEvent) -> None: + state = self.state() + return super().keyPressEvent(a0) + + def closeEvent(self, event): + if self.cancel: + self.sigCancel.emit() + super().closeEvent(event) + + def restart(self): + self.cancel = False + self.close() + self.sigRestart.emit() + + def setDisabledNotExistingMeasurements(self, existing_colnames): + self.existing_colnames = existing_colnames + for chNameGroupbox in self.chNameGroupboxes: + for checkBox in chNameGroupbox.checkBoxes: + colname = checkBox.text() + if colname in existing_colnames: + checkBox.setChecked(True) + continue + + checkBox.setChecked(False) + checkBox.setDisabled(True) + self.setNotExistingMeasurementTooltip(checkBox) + + for checkBox in self.sizeMetricsQGBox.checkBoxes: + colname = checkBox.text() + if colname in existing_colnames: + checkBox.setChecked(True) + continue + checkBox.setChecked(False) + checkBox.setDisabled(True) + self.setNotExistingMeasurementTooltip(checkBox) + + for checkBox in self.regionPropsQGBox.checkBoxes: + prop_name = checkBox.text() + for existing_col in existing_colnames: + if prop_name == existing_col: + checkBox.setChecked(True) + break + m = re.match(rf"{prop_name}-\d", existing_col) + if m is not None: + checkBox.setChecked(True) + break + else: + checkBox.setChecked(False) + checkBox.setDisabled(True) + self.setNotExistingMeasurementTooltip(checkBox) + + if self.mixedChannelsCombineMetricsQGBox is None: + return + + for combCheckbox in self.mixedChannelsCombineMetricsQGBox.checkBoxes: + colname = combCheckbox.text() + if colname in existing_colnames: + combCheckbox.setChecked(True) + continue + combCheckbox.setChecked(False) + combCheckbox.setDisabled(True) + self.setNotExistingMeasurementTooltip(combCheckbox) + + def addNonMeasurementColumns(self, colnames): + additionalCols = measurements.get_non_measurements_cols( + colnames, self.all_metrics + ) + if not additionalCols: + return + self.nonMeasurementsGroupbox = widgets.CheckboxesGroupBox( + additionalCols, title="Additional columns", checkable=True + ) + self.groupsLayout.addWidget( + self.nonMeasurementsGroupbox, 0, self.last_col + 1, self.last_row + 1, 1 + ) + + def setNotExistingMeasurementTooltip(self, checkBox): + checkBox.setToolTip( + "Measurement is disabled because it is not present in selected " + "acdc_output tables, hence it cannot be addded to concatenated " + "table. " + ) + + def ok_cb(self): + for chNameGroupbox in self.chNameGroupboxes: + chNameGroupbox.calcForEachZsliceRequested = ( + chNameGroupbox.isCalcForEachZsliceRequested() + ) + + self.sizeMetricsQGBox.calcForEachZsliceRequested = ( + self.sizeMetricsQGBox.isCalcForEachZsliceRequested() + ) + + if self.allPos_acdc_df_cols is None: + self.saveLastSelection() + self.cancel = False + self.close() + self.sigClosed.emit() + return + + self.okClicked = True + existing_colnames = self.allPos_acdc_df_cols + unchecked_existing_colnames = [] + unchecked_existing_rps = [] + for chNameGroupbox in self.chNameGroupboxes: + for checkBox in chNameGroupbox.checkBoxes: + colname = checkBox.text() + is_existing = colname in existing_colnames + if not chNameGroupbox.isChecked() and is_existing: + unchecked_existing_colnames.append(colname) + continue + if not checkBox.isChecked() and is_existing: + unchecked_existing_colnames.append(colname) + + for checkBox in self.sizeMetricsQGBox.checkBoxes: + colname = checkBox.text() + is_existing = colname in existing_colnames + if not self.sizeMetricsQGBox.isChecked() and is_existing: + unchecked_existing_colnames.append(colname) + continue + + if not checkBox.isChecked() and is_existing: + unchecked_existing_colnames.append(colname) + for checkBox in self.regionPropsQGBox.checkBoxes: + colname = checkBox.text() + is_existing = any([col == colname for col in existing_colnames]) + if not self.regionPropsQGBox.isChecked() and is_existing: + unchecked_existing_rps.append(colname) + continue + + if not checkBox.isChecked() and is_existing: + unchecked_existing_rps.append(colname) + + if unchecked_existing_colnames or unchecked_existing_rps: + cancel, self.delExistingCols = self.warnUncheckedExistingMeasurements( + unchecked_existing_colnames, unchecked_existing_rps + ) + self.existingUncheckedColnames = unchecked_existing_colnames + self.existingUncheckedRps = unchecked_existing_rps + if cancel: + return + + self.saveLastSelection() + self.cancel = False + self.close() + self.sigClosed.emit() + + def warnUncheckedExistingMeasurements( + self, unchecked_existing_colnames, unchecked_existing_rps + ): + msg = widgets.myMessageBox() + msg.setWidth(500) + msg.addShowInFileManagerButton(self.acdc_df_path) + txt = html_utils.paragraph( + "You chose to not save some measurements that are " + "already present in the saved acdc_output.csv " + "file.

    " + "Do you want to delete these measurements or " + "keep them?

    " + "Existing measurements not selected:" + ) + listView = widgets.readOnlyQList(msg) + items = unchecked_existing_colnames.copy() + items.extend(unchecked_existing_rps) + listView.addItems(items) + _, delButton, keepButton = msg.warning( + self, + "Unchecked existing measurements", + txt, + widgets=listView, + buttonsTexts=("Cancel", "Delete", "Keep"), + ) + return msg.cancel, msg.clickedButton == delButton + + def show(self, block=False): + super().show(block=False) + self.deselectAllButton.setMinimumHeight(self.okButton.height()) + screenWidth = self.screen().size().width() + screenHeight = self.screen().size().height() + screenLeft = self.screen().geometry().x() + screenTop = self.screen().geometry().y() + h = screenHeight - 200 + minColWith = screenWidth / 5 + w = minColWith * (self.last_col + 1) + xLeft = int((screenWidth - w) / 2) + if w > screenWidth: + self.move(screenLeft + 10, screenTop + 50) + self.resize(screenWidth - 20, h) + else: + self.move(screenLeft + xLeft, screenTop + 50) + self.resize(int(w), h) + super().show(block=block) + + +class ComputeMetricsErrorsDialog(QBaseDialog): + def __init__(self, errorsDict, log_path="", parent=None, log_type="custom_metrics"): + super().__init__(parent) + + self.errorsDict = errorsDict + + layout = QGridLayout() + + self.setWindowTitle("Errors summary") + + label = QLabel(self) + standardIcon = getattr(QStyle, "SP_MessageBoxWarning") + icon = self.style().standardIcon(standardIcon) + pixmap = icon.pixmap(60, 60) + label.setPixmap(pixmap) + layout.addWidget(label, 0, 0, alignment=Qt.AlignTop) + + if log_type == "custom_metrics": + infoText = """ + When computing custom metrics the following metrics + were ignored because they raised an error.

    + """ + elif log_type == "standard_metrics": + infoText = """ + Some or all of the standard metrics were NOT saved + because Cell-ACDC encoutered the following errors.

    + """ + elif log_type == "region_props": + rp_url = "https://scikit-image.org/docs/0.18.x/api/skimage.measure.html#skimage.measure.regionprops" + rp_href = f'skimage.measure.regionprops' + infoText = f""" + Region properties were NOT saved because Cell-ACDC + encoutered the following errors.
    + Region properties are calculated using the scikit-image + function called {rp_href}.

    + """ + elif log_type == "missing_annot": + infoText = """ + The following Positions were SKIPPED because they did + not have cell cycle annotations.

    + To add lineage tree information you first need to do the + cell cycle analysis in module 3 "Main GUI".

    + """ + else: + infoText = """ + Process raised the errors listed below.

    + """ + + github_issues_href = f"here" + noteText = f""" + NOTE: If you need help understanding these errors you can + open an issue on our github page {github_issues_href}. + """ + + infoLabel = QLabel(html_utils.paragraph(f"{infoText}{noteText}")) + infoLabel.setOpenExternalLinks(True) + layout.addWidget(infoLabel, 0, 1) + + scrollArea = QScrollArea() + scrollAreaWidget = QWidget() + textLayout = QVBoxLayout() + for func_name, traceback_format in errorsDict.items(): + nameLabel = QLabel(f"{func_name}: ") + errorMessage = f"\n{traceback_format}" + errorLabel = QLabel(errorMessage) + errorLabel.setTextInteractionFlags( + Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard + ) + # errorLabel.setStyleSheet("background-color: white") + errorLabel.setFrameShape(QFrame.Shape.Panel) + errorLabel.setFrameShadow(QFrame.Shadow.Sunken) + textLayout.addWidget(nameLabel) + textLayout.addWidget(errorLabel) + textLayout.addStretch(1) + + scrollAreaWidget.setLayout(textLayout) + scrollArea.setWidget(scrollAreaWidget) + + layout.addWidget(scrollArea, 1, 1) + + buttonsLayout = QHBoxLayout() + showLogButton = widgets.showInFileManagerButton("Show log file...") + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(showLogButton) + + copyButton = widgets.copyPushButton("Copy error message") + copyButton.clicked.connect(self.copyErrorMessage) + buttonsLayout.addWidget(copyButton) + self.copyButton = copyButton + self.copyButton.text = "Copy error message" + self.copyButton.icon = self.copyButton.icon() + + okButton = widgets.okPushButton(" Ok ") + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(okButton) + + showLogButton.clicked.connect(partial(myutils.showInExplorer, log_path)) + okButton.clicked.connect(self.close) + layout.setVerticalSpacing(10) + layout.addLayout(buttonsLayout, 2, 1) + + self.setLayout(layout) + self.setFont(font) + + def copyErrorMessage(self): + cb = QApplication.clipboard() + cb.clear(mode=cb.Clipboard) + copiedText = "" + for _, traceback_format in self.errorsDict.items(): + errorBlock = f"{'=' * 30}\n{traceback_format}{'*' * 30}" + copiedText = f"{copiedText}{errorBlock}" + cb.setText(copiedText, mode=cb.Clipboard) + print("Error message copied.") + self.copyButton.setIcon(QIcon(":okButton.svg")) + self.copyButton.setText(" Copied to clipboard!") + QTimer.singleShot(2000, self.restoreCopyButton) + + def restoreCopyButton(self): + self.copyButton.setText(self.copyButton.text) + self.copyButton.setIcon(self.copyButton.icon) + + def showEvent(self, a0) -> None: + self.copyButton.setFixedWidth(self.copyButton.width()) + return super().showEvent(a0) + + +class combineMetricsEquationDialog(QBaseDialog): + sigOk = Signal(object) + + def __init__( + self, allChNames, isZstack, isSegm3D, parent=None, debug=False, closeOnOk=True + ): + super().__init__(parent) + + self.setWindowTitle("Add combined measurement") + + self.initAttributes() + + self.allChNames = allChNames + + self.cancel = True + self.isOperatorMode = False + self.closeOnOk = closeOnOk + + mainLayout = QVBoxLayout() + equationLayout = QHBoxLayout() + + metricsTreeWidget = QTreeWidget() + metricsTreeWidget.setHeaderHidden(True) + metricsTreeWidget.setFont(font) + self.metricsTreeWidget = metricsTreeWidget + + for chName in allChNames: + channelTreeItem = QTreeWidgetItem(metricsTreeWidget) + channelTreeItem.setText(0, f"{chName} measurements") + metricsTreeWidget.addTopLevelItem(channelTreeItem) + + metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc( + isZstack, chName, isSegm3D=isSegm3D + ) + custom_metrics_desc = measurements.custom_metrics_desc( + isZstack, chName, isSegm3D=isSegm3D + ) + + foregrMetricsTreeItem = QTreeWidgetItem(channelTreeItem) + foregrMetricsTreeItem.setText(0, "Cell signal measurements") + channelTreeItem.addChild(foregrMetricsTreeItem) + + bkgrMetricsTreeItem = QTreeWidgetItem(channelTreeItem) + bkgrMetricsTreeItem.setText(0, "Background values") + channelTreeItem.addChild(bkgrMetricsTreeItem) + + if custom_metrics_desc: + customMetricsTreeItem = QTreeWidgetItem(channelTreeItem) + customMetricsTreeItem.setText(0, "Custom measurements") + channelTreeItem.addChild(customMetricsTreeItem) + + self.addTreeItems(foregrMetricsTreeItem, metrics_desc.keys(), isCol=True) + self.addTreeItems(bkgrMetricsTreeItem, bkgr_val_desc.keys(), isCol=True) + + if custom_metrics_desc: + self.addTreeItems( + customMetricsTreeItem, custom_metrics_desc.keys(), isCol=True + ) + + self.addChannelLessItems(isZstack, isSegm3D=isSegm3D) + + sizeMetricsTreeItem = QTreeWidgetItem(metricsTreeWidget) + sizeMetricsTreeItem.setText(0, "Size measurements") + metricsTreeWidget.addTopLevelItem(sizeMetricsTreeItem) + + size_metrics_desc = measurements.get_size_metrics_desc(isSegm3D, True) + self.addTreeItems(sizeMetricsTreeItem, size_metrics_desc.keys(), isCol=True) + + propMetricsTreeItem = QTreeWidgetItem(metricsTreeWidget) + propMetricsTreeItem.setText(0, "Region properties") + metricsTreeWidget.addTopLevelItem(propMetricsTreeItem) + + props_names = measurements.get_props_names() + self.addTreeItems(propMetricsTreeItem, props_names, isCol=True) + + operatorsLayout = QHBoxLayout() + operatorsLayout.addStretch(1) + + iconSize = 24 + + self.operatorButtons = [] + self.operators = [ + ("add", "+"), + ("subtract", "-"), + ("multiply", "*"), + ("divide", "/"), + ("open_bracket", "("), + ("close_bracket", ")"), + ("square", "**2"), + ("pow", "**"), + ("ln", "log("), + ("log10", "log10("), + ] + operatorFont = QFont() + operatorFont.setPixelSize(16) + for name, text in self.operators: + button = QPushButton() + button.setIcon(QIcon(f":{name}.svg")) + button.setIconSize(QSize(iconSize, iconSize)) + button.text = text + operatorsLayout.addWidget(button) + self.operatorButtons.append(button) + button.clicked.connect(self.addOperator) + # button.setFont(operatorFont) + + clearButton = QPushButton() + clearButton.setIcon(QIcon(":clear.svg")) + clearButton.setIconSize(QSize(iconSize, iconSize)) + clearButton.setFont(operatorFont) + + clearEntryButton = QPushButton() + clearEntryButton.setIcon(QIcon(":backspace.svg")) + clearEntryButton.setFont(operatorFont) + clearEntryButton.setIconSize(QSize(iconSize, iconSize)) + + operatorsLayout.addWidget(clearButton) + operatorsLayout.addWidget(clearEntryButton) + operatorsLayout.addStretch(1) + + newColNameLayout = QVBoxLayout() + newColNameLineEdit = widgets.alphaNumericLineEdit() + newColNameLineEdit.setAlignment(Qt.AlignCenter) + self.newColNameLineEdit = newColNameLineEdit + newColNameLayout.addStretch(1) + newColNameLayout.addWidget(QLabel("New measurement name:")) + newColNameLayout.addWidget(newColNameLineEdit) + newColNameLayout.addStretch(1) + + equationDisplayLayout = QVBoxLayout() + equationDisplayLayout.addWidget(QLabel("Equation:")) + equationDisplay = QPlainTextEdit() + # equationDisplay.setReadOnly(True) + self.equationDisplay = equationDisplay + equationDisplayLayout.addWidget(equationDisplay) + equationDisplayLayout.setStretch(0, 0) + equationDisplayLayout.setStretch(1, 1) + + equationLayout.addLayout(newColNameLayout) + equationLayout.addWidget(QLabel(" = ")) + equationLayout.addLayout(equationDisplayLayout) + equationLayout.setStretch(0, 1) + equationLayout.setStretch(1, 0) + equationLayout.setStretch(2, 2) + + testOutputLayout = QVBoxLayout() + testOutputLayout.addWidget(QLabel("Result of test with random inputs:")) + testOutputDisplay = QTextEdit() + testOutputDisplay.setReadOnly(True) + self.testOutputDisplay = testOutputDisplay + testOutputLayout.addWidget(testOutputDisplay) + testOutputLayout.setStretch(0, 0) + testOutputLayout.setStretch(1, 1) + + instructions = html_utils.paragraph(""" + Double-click on any of the available measurements + to add it to the equation.

    + NOTE: the result will be saved in the acdc_output.csv + file as a column with the same name
    + you enter in "New measurement name" + field.

    + """) + + buttonsLayout = QHBoxLayout() + + cancelButton = widgets.cancelPushButton("Cancel") + helpButton = widgets.infoPushButton(" Help...") + testButton = widgets.calcPushButton("Test output") + okButton = widgets.okPushButton(" Ok ") + okButton.setDisabled(True) + self.okButton = okButton + + buttonsLayout.addStretch(1) + + if debug: + debugButton = QPushButton("Debug") + debugButton.clicked.connect(self._debug) + buttonsLayout.addWidget(debugButton) + + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(helpButton) + buttonsLayout.addWidget(testButton) + buttonsLayout.addWidget(okButton) + + mainLayout.addWidget(QLabel(instructions)) + mainLayout.addWidget(QLabel("Available measurements:")) + mainLayout.addWidget(metricsTreeWidget) + mainLayout.addLayout(operatorsLayout) + mainLayout.addLayout(equationLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + mainLayout.addLayout(testOutputLayout) + + clearButton.clicked.connect(self.clearEquation) + clearEntryButton.clicked.connect(self.clearEntryEquation) + metricsTreeWidget.itemDoubleClicked.connect(self.addColname) + + helpButton.clicked.connect(self.showHelp) + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + testButton.clicked.connect(self.test_cb) + + self.setLayout(mainLayout) + self.setFont(font) + + self.setStyleSheet(TREEWIDGET_STYLESHEET) + + def addChannelLessItems(self, isZstack, isSegm3D=False): + allChannelsTreeItem = QTreeWidgetItem(self.metricsTreeWidget) + allChannelsTreeItem.setText(0, f"All channels measurements") + metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc( + isZstack, "", isSegm3D=isSegm3D + ) + custom_metrics_desc = measurements.custom_metrics_desc( + isZstack, "", isSegm3D=isSegm3D + ) + + foregrMetricsTreeItem = QTreeWidgetItem(allChannelsTreeItem) + foregrMetricsTreeItem.setText(0, "Cell signal measurements") + allChannelsTreeItem.addChild(foregrMetricsTreeItem) + + bkgrMetricsTreeItem = QTreeWidgetItem(allChannelsTreeItem) + bkgrMetricsTreeItem.setText(0, "Background values") + allChannelsTreeItem.addChild(bkgrMetricsTreeItem) + + if custom_metrics_desc: + customMetricsTreeItem = QTreeWidgetItem(allChannelsTreeItem) + customMetricsTreeItem.setText(0, "Custom measurements") + allChannelsTreeItem.addChild(customMetricsTreeItem) + + self.addTreeItems( + foregrMetricsTreeItem, metrics_desc.keys(), isCol=True, isChannelLess=True + ) + self.addTreeItems( + bkgrMetricsTreeItem, bkgr_val_desc.keys(), isCol=True, isChannelLess=True + ) + + if custom_metrics_desc: + self.addTreeItems( + customMetricsTreeItem, + custom_metrics_desc.keys(), + isCol=True, + isChannelLess=True, + ) + + def addOperator(self): + button = self.sender() + text = f"{self.equationDisplay.toPlainText()}{button.text}" + self.equationDisplay.setPlainText(text) + self.clearLenghts.append(len(button.text)) + + def clearEquation(self): + self.isOperatorMode = False + self.equationDisplay.setPlainText("") + self.initAttributes() + + def initAttributes(self): + self.clearLenghts = [] + self.equationColNames = [] + self.channelLessColnames = [] + + def clearEntryEquation(self): + if not self.clearLenghts: + return + + text = self.equationDisplay.toPlainText() + newText = text[: -self.clearLenghts[-1]] + clearedText = text[-self.clearLenghts[-1] :] + self.clearLenghts.pop(-1) + self.equationDisplay.setPlainText(newText) + if clearedText in self.equationColNames: + self.equationColNames.remove(clearedText) + if clearedText in self.channelLessColnames: + self.channelLessColnames.remove(clearedText) + + def addTreeItems(self, parentItem, itemsText, isCol=False, isChannelLess=False): + for text in itemsText: + _item = QTreeWidgetItem(parentItem) + _item.setText(0, text) + parentItem.addChild(_item) + if isCol: + _item.isCol = True + _item.isChannelLess = isChannelLess + + def addColname(self, item, column): + if not hasattr(item, "isCol"): + return + + colName = item.text(0) + text = f"{self.equationDisplay.toPlainText()}{colName}" + self.equationDisplay.setPlainText(text) + self.clearLenghts.append(len(colName)) + self.equationColNames.append(colName) + if item.isChannelLess: + self.channelLessColnames.append(colName) + + def _debug(self): + print(self.getEquationsDict()) + + def getEquationsDict(self): + equation = self.equationDisplay.toPlainText() + newColName = self.newColNameLineEdit.text() + if not self.channelLessColnames: + chNamesInTerms = set() + for term in self.equationColNames: + for chName in self.allChNames: + if chName in term: + chNamesInTerms.add(chName) + if len(chNamesInTerms) == 1: + # Equation uses metrics from a single channel --> append channel name + chName = chNamesInTerms.pop() + chColName = f"{chName}_{newColName}" + isMixedChannels = False + return {chColName: equation}, isMixedChannels + else: + # Equation doesn't use all channels metrics nor is single channel + isMixedChannels = True + return {newColName: equation}, isMixedChannels + + isMixedChannels = False + equations = {} + for chName in self.allChNames: + chEquation = equation + chEquationName = newColName + # Append each channel name to channelLess terms + for colName in self.channelLessColnames: + chColName = f"{chName}{colName}" + chEquation = chEquation.replace(colName, chColName) + chEquationName = f"{chName}_{newColName}" + equations[chEquationName] = chEquation + return equations, isMixedChannels + + def ok_cb(self): + if not self.newColNameLineEdit.text(): + self.warnEmptyEquationName() + return + + self.cancel = False + + # Save equation to "/acdc-metrics/combine_metrics.ini" file + config = measurements.read_saved_user_combine_config() + + equationsDict, isMixedChannels = self.getEquationsDict() + for newColName, equation in equationsDict.items(): + config = measurements.add_user_combine_metrics( + config, equation, newColName, isMixedChannels + ) + + isChannelLess = len(self.channelLessColnames) > 0 + if isChannelLess: + channelLess_equation = self.equationDisplay.toPlainText() + equation_name = self.newColNameLineEdit.text() + config = measurements.add_channelLess_combine_metrics( + config, channelLess_equation, equation_name, self.channelLessColnames + ) + + measurements.save_common_combine_metrics(config) + + self.sigOk.emit(self) + + if self.closeOnOk: + self.close() + + def warnEmptyEquationName(self): + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + "New measurement name" field cannot be empty! + """) + msg.critical(self, "Empty new measurement name", txt) + + def showHelp(self): + txt = measurements.get_combine_metrics_help_txt() + msg = widgets.myMessageBox( + showCentered=False, + wrapText=False, + scrollableText=True, + enlargeWidthFactor=1.7, + ) + path = measurements.acdc_metrics_path + msg.addShowInFileManagerButton(path, txt="Show saved file...") + msg.information(self, "Combine measurements help", txt) + + def test_cb(self): + # Evaluate equation with random inputs + equation = self.equationDisplay.toPlainText() + random_data = np.random.rand(1, len(self.equationColNames)) * 5 + df = pd.DataFrame(data=random_data, columns=self.equationColNames).round(5) + newColName = self.newColNameLineEdit.text() + try: + df[newColName] = df.eval(equation) + except Exception as e: + traceback.print_exc() + self.testOutputDisplay.setHtml(html_utils.paragraph(e)) + self.testOutputDisplay.setStyleSheet("border: 2px solid red") + return + + self.testOutputDisplay.setStyleSheet("border: 2px solid green") + self.okButton.setDisabled(False) + + result = df.round(5).iloc[0][newColName] + + # Substitute numbers into equation + inputs = df.iloc[0] + equation_numbers = equation + for c, col in enumerate(self.equationColNames): + equation_numbers = equation_numbers.replace(col, str(inputs[c])) + + # Format output into html text + cols = self.equationColNames + inputs_txt = [f"{col} = {input}" for col, input in zip(cols, inputs)] + list_html = html_utils.to_list(inputs_txt) + text = html_utils.paragraph(f""" + By substituting the following random inputs: + {list_html} + we get the equation:

    +   {newColName} = {equation_numbers}

    + that equals to:

    +   {newColName} = {result} + """) + self.testOutputDisplay.setHtml(text) + + +class CombineMetricsMultiDfsDialog(QBaseDialog): + sigOk = Signal(object, object) + sigClose = Signal(bool) + + def __init__(self, acdcDfs, allChNames, parent=None, debug=False): + super().__init__(parent) + + self.setWindowTitle("Add combined measurement") + + self.initAttributes() + + self.acdcDfs = acdcDfs + self.cancel = True + self.isOperatorMode = False + + mainLayout = QVBoxLayout() + equationLayout = QHBoxLayout() + + treesLayout = QHBoxLayout() + for i, (acdc_df_endname, acdc_df) in enumerate(acdcDfs.items()): + metricsTreeWidget = QTreeWidget() + metricsTreeWidget.setHeaderHidden(True) + metricsTreeWidget.setFont(font) + + classified_metrics = measurements.classify_acdc_df_colnames( + acdc_df, allChNames + ) + + for chName in allChNames: + channelTreeItem = QTreeWidgetItem(metricsTreeWidget) + channelTreeItem.setText(0, f"{chName} measurements") + metricsTreeWidget.addTopLevelItem(channelTreeItem) + + standard_metrics = classified_metrics["foregr"][chName] + bkgr_metrics = classified_metrics["bkgr"][chName] + custom_metrics = classified_metrics["custom"][chName] + + if standard_metrics: + foregrMetricsTreeItem = QTreeWidgetItem(channelTreeItem) + foregrMetricsTreeItem.setText(0, "Cell signal measurements") + channelTreeItem.addChild(foregrMetricsTreeItem) + self.addTreeItems( + foregrMetricsTreeItem, standard_metrics, isCol=True, index=i + ) + + if bkgr_metrics: + bkgrMetricsTreeItem = QTreeWidgetItem(channelTreeItem) + bkgrMetricsTreeItem.setText(0, "Background values") + channelTreeItem.addChild(bkgrMetricsTreeItem) + self.addTreeItems( + bkgrMetricsTreeItem, bkgr_metrics, isCol=True, index=i + ) + + if custom_metrics: + customMetricsTreeItem = QTreeWidgetItem(channelTreeItem) + customMetricsTreeItem.setText(0, "Custom measurements") + channelTreeItem.addChild(customMetricsTreeItem) + self.addTreeItems( + customMetricsTreeItem, custom_metrics, isCol=True, index=i + ) + + if classified_metrics["size"]: + sizeMetricsTreeItem = QTreeWidgetItem(metricsTreeWidget) + sizeMetricsTreeItem.setText(0, "Size measurements") + metricsTreeWidget.addTopLevelItem(sizeMetricsTreeItem) + self.addTreeItems( + sizeMetricsTreeItem, classified_metrics["size"], isCol=True, index=i + ) + + if classified_metrics["props"]: + propMetricsTreeItem = QTreeWidgetItem(metricsTreeWidget) + propMetricsTreeItem.setText(0, "Region properties") + metricsTreeWidget.addTopLevelItem(propMetricsTreeItem) + self.addTreeItems( + propMetricsTreeItem, + classified_metrics["props"], + isCol=True, + index=i, + ) + + treeLayout = QVBoxLayout() + treeTitle = QLabel( + html_utils.paragraph( + f"{i + 1}. {acdc_df_endname} measurements " + ) + ) + treeLayout.addWidget(treeTitle) + treeLayout.addWidget(metricsTreeWidget) + treesLayout.addLayout(treeLayout) + + metricsTreeWidget.index = i + metricsTreeWidget.itemDoubleClicked.connect(self.addColname) + + operatorsLayout = QHBoxLayout() + operatorsLayout.addStretch(1) + + iconSize = 24 + + self.operatorButtons = [] + self.operators = [ + ("add", "+"), + ("subtract", "-"), + ("multiply", "*"), + ("divide", "/"), + ("open_bracket", "("), + ("close_bracket", ")"), + ("square", "**2"), + ("pow", "**"), + ("ln", "log("), + ("log10", "log10("), + ] + operatorFont = QFont() + operatorFont.setPixelSize(16) + for name, text in self.operators: + button = QPushButton() + button.setIcon(QIcon(f":{name}.svg")) + button.setIconSize(QSize(iconSize, iconSize)) + button.text = text + operatorsLayout.addWidget(button) + self.operatorButtons.append(button) + button.clicked.connect(self.addOperator) + # button.setFont(operatorFont) + + clearButton = QPushButton() + clearButton.setIcon(QIcon(":clear.svg")) + clearButton.setIconSize(QSize(iconSize, iconSize)) + clearButton.setFont(operatorFont) + + clearEntryButton = QPushButton() + clearEntryButton.setIcon(QIcon(":backspace.svg")) + clearEntryButton.setFont(operatorFont) + clearEntryButton.setIconSize(QSize(iconSize, iconSize)) + + operatorsLayout.addWidget(clearButton) + operatorsLayout.addWidget(clearEntryButton) + operatorsLayout.addStretch(1) + + newColNameLayout = QVBoxLayout() + newColNameLineEdit = widgets.alphaNumericLineEdit() + newColNameLineEdit.setAlignment(Qt.AlignCenter) + self.newColNameLineEdit = newColNameLineEdit + newColNameLayout.addStretch(1) + newColNameLayout.addWidget(QLabel("New measurement name:")) + newColNameLayout.addWidget(newColNameLineEdit) + newColNameLayout.addStretch(1) + + equationDisplayLayout = QVBoxLayout() + equationDisplayLayout.addWidget(QLabel("Equation:")) + equationDisplay = QPlainTextEdit() + # equationDisplay.setReadOnly(True) + self.equationDisplay = equationDisplay + equationDisplayLayout.addWidget(equationDisplay) + equationDisplayLayout.setStretch(0, 0) + equationDisplayLayout.setStretch(1, 1) + + equationLayout.addLayout(newColNameLayout) + equationLayout.addWidget(QLabel(" = ")) + equationLayout.addLayout(equationDisplayLayout) + equationLayout.setStretch(0, 1) + equationLayout.setStretch(1, 0) + equationLayout.setStretch(2, 2) + + instructions = html_utils.paragraph(""" + Double-click on any of the available measurements + to add it to the equation.

    + NOTE: the result will be saved in a new acdc_output + file as a column with the same name
    + you enter in "New measurement name" + field.

    + """) + + buttonsLayout = QHBoxLayout() + + cancelButton = widgets.cancelPushButton("Cancel") + testButton = widgets.calcPushButton("Test equation") + okButton = widgets.okPushButton(" Ok ") + okButton.setDisabled(True) + self.okButton = okButton + + if debug: + debugButton = QPushButton("Debug") + debugButton.clicked.connect(self._debug) + buttonsLayout.addWidget(debugButton) + + self.statusLabel = QLabel() + buttonsLayout.addWidget(self.statusLabel) + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(testButton) + buttonsLayout.addWidget(okButton) + + mainLayout.addWidget(QLabel(instructions)) + mainLayout.addLayout(treesLayout) + mainLayout.addLayout(operatorsLayout) + mainLayout.addLayout(equationLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + clearButton.clicked.connect(self.clearEquation) + clearEntryButton.clicked.connect(self.clearEntryEquation) + + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + testButton.clicked.connect(self.test_cb) + + self.equationDisplay.textChanged.connect(self.equationChanged) + # self.newColNameLineEdit.editingFinished.connect(self.equationChanged) + + self.setLayout(mainLayout) + self.setFont(font) + + self.setStyleSheet(TREEWIDGET_STYLESHEET) + + def setLogger(self, logger, logs_path, log_path): + self.logger = logger + self.logs_path = logs_path + self.log_path = log_path + + def closeEvent(self, event): + self.sigClose.emit(self.cancel) + return super().closeEvent(event) + + def getCombinedDf(self): + dfs = [] + for i, acdc_df in enumerate(self.acdcDfs.values()): + dfs.append(acdc_df.add_suffix(f"_table{i + 1}")) + return pd.concat(dfs, axis=1) + + def _log(self, txt): + if hasattr(self, "logger"): + self.logger.info(txt) + else: + print(f"[INFO]: {txt}") + + def equationChanged(self): + self.okButton.setDisabled(True) + self.statusLabel.setText("") + + @exception_handler + def test_cb(self): + combined_df = self.getCombinedDf() + new_df = pd.DataFrame(index=combined_df.index) + equation = self.equationDisplay.toPlainText() + newColName = self.newColNameLineEdit.text() + new_df[newColName] = combined_df.eval(equation) + self.okButton.setDisabled(False) + self._log("Equation test was successful.") + self.statusLabel.setText("Equation test was successful. You can now click OK.") + + def addOperator(self): + button = self.sender() + text = f"{self.equationDisplay.toPlainText()}{button.text}" + self.equationDisplay.setPlainText(text) + self.clearLenghts.append(len(button.text)) + + def clearEquation(self): + self.isOperatorMode = False + self.equationDisplay.setPlainText("") + self.initAttributes() + + def initAttributes(self): + self.clearLenghts = [] + self.equationColNames = [] + self.channelLessColnames = [] + + def clearEntryEquation(self): + if not self.clearLenghts: + return + + text = self.equationDisplay.toPlainText() + newText = text[: -self.clearLenghts[-1]] + clearedText = text[-self.clearLenghts[-1] :] + self.clearLenghts.pop(-1) + self.equationDisplay.setPlainText(newText) + if clearedText in self.equationColNames: + self.equationColNames.remove(clearedText) + if clearedText in self.channelLessColnames: + self.channelLessColnames.remove(clearedText) + + def addTreeItems( + self, parentItem, itemsText, isCol=False, isChannelLess=False, index=None + ): + for text in itemsText: + _item = QTreeWidgetItem(parentItem) + _item.setText(0, text) + parentItem.addChild(_item) + if isCol: + _item.isCol = True + if index is not None: + _item.index = index + _item.isChannelLess = isChannelLess + + def addColname(self, item, column): + if not hasattr(item, "isCol"): + return + + colName = f"{item.text(0)}_table{item.index + 1}" + text = f"{self.equationDisplay.toPlainText()}{colName}" + + self.equationDisplay.setPlainText(text) + self.clearLenghts.append(len(colName)) + self.equationColNames.append(colName) + if item.isChannelLess: + self.channelLessColnames.append(colName) + + def _debug(self): + print(self.getEquationsDict()) + + def ok_cb(self): + if not self.newColNameLineEdit.text(): + self.warnEmptyEquationName() + return + if not self.equationDisplay.toPlainText(): + self.warnEmptyEquation() + return + + self.expression = self.equationDisplay.toPlainText() + self.newColname = self.newColNameLineEdit.text() + self.cancel = False + self.sigOk.emit(self.newColname, self.expression) + self.close() + + def warnEmptyEquation(self): + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + "Equation" field cannot be empty! + """) + msg.critical(self, "Empty equation", txt) + + def warnEmptyEquationName(self): + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + "New measurement name" field cannot be empty! + """) + msg.critical(self, "Empty new measurement name", txt) + + +class CombineMetricsMultiDfsSummaryDialog(QBaseDialog): + sigLoadAdditionalAcdcDf = Signal() + + def __init__(self, acdcDfs, allChNames, parent=None, debug=False): + super().__init__(parent) + + self.editedIndex = None + self.cancel = True + self.acdcDfs = acdcDfs + self.allChNames = allChNames + + self.setWindowTitle("Combine measurements summary") + + mainLayout = QVBoxLayout() + viewLayout = QGridLayout() + buttonsLayout = QHBoxLayout() + + row = 0 + txt = html_utils.paragraph("Selected acdc_output tables:") + viewLayout.addWidget(QLabel(txt), row, 0) + + row += 1 + items = [ + f"• Table {i + 1}: {e}" + for i, e in enumerate(acdcDfs.keys()) + ] + selectedAcdcDfsList = widgets.readOnlyQList() + selectedAcdcDfsList.addItems(items) + self.selectedAcdcDfsList = selectedAcdcDfsList + + tablesButtonsLayout = QVBoxLayout() + loadAcdcDfButton = widgets.showInFileManagerButton("Load additional tables") + tablesButtonsLayout.addWidget(loadAcdcDfButton) + + loadEquationsButton = widgets.reloadPushButton("Load previously used equations") + tablesButtonsLayout.addWidget(loadEquationsButton) + + tablesButtonsLayout.addStretch(1) + + viewLayout.addWidget(selectedAcdcDfsList, row, 0) + viewLayout.addLayout(tablesButtonsLayout, row, 1) + viewLayout.setRowStretch(row, 1) + + row += 1 + txt = html_utils.paragraph("Equations:") + viewLayout.addWidget(QLabel(txt), row, 0) + + row += 1 + self.equationsList = widgets.TreeWidget() + self.equationsList.setFont(font) + self.equationsList.setHeaderLabels(["Metric", "Expression"]) + self.equationsList.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + + equationsButtonsLayout = QVBoxLayout() + addEquationButton = widgets.addPushButton("Add metric") + removeEquationButton = widgets.subtractPushButton("Remove metric(s)") + editEquationButton = widgets.editPushButton("Edit metric") + removeEquationButton.setDisabled(True) + editEquationButton.setDisabled(True) + self.removeEquationButton = removeEquationButton + self.editEquationButton = editEquationButton + + equationsButtonsLayout.addWidget(addEquationButton) + equationsButtonsLayout.addWidget(removeEquationButton) + equationsButtonsLayout.addWidget(editEquationButton) + equationsButtonsLayout.addStretch(1) + + viewLayout.addWidget(self.equationsList, row, 0) + viewLayout.addLayout(equationsButtonsLayout, row, 1) + viewLayout.setRowStretch(row, 2) + + cancelButton = widgets.cancelPushButton("Cancel") + okButton = widgets.okPushButton("Ok") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(okButton) + + viewLayout.setVerticalSpacing(10) + mainLayout.addLayout(viewLayout) + mainLayout.addSpacing(10) + mainLayout.addLayout(buttonsLayout) + + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + addEquationButton.clicked.connect(self.addEquation_cb) + loadAcdcDfButton.clicked.connect(self.loadButtonClicked) + loadEquationsButton.clicked.connect(self.loadEquationsButtonClicked) + removeEquationButton.clicked.connect(self.removeButtonClicked) + editEquationButton.clicked.connect(self.editButtonClicked) + self.equationsList.itemSelectionChanged.connect( + self.onEquationItemSelectionChanged + ) + + self.setLayout(mainLayout) + + def setLogger(self, logger, logs_path, log_path): + self.logger = logger + self.logs_path = logs_path + self.log_path = log_path + + def loadEquationsButtonClicked(self): + MostRecentPath = myutils.getMostRecentPath() + file_path = QFileDialog.getOpenFileName( + self, + "Select equations file", + MostRecentPath, + "Config Files (*.ini);;All Files (*)", + )[0] + if file_path == "": + return + + cp = config.ConfigParser() + cp.read(file_path) + sectionToMatch = [f"table{i + 1}:{end}" for i, end in enumerate(self.acdcDfs)] + sectionToMatch = ";".join(sectionToMatch) + + lists = {} + nonMatchingLists = {} + groupsDescr = {} + + for section in cp.sections(): + # Tag acdc_output names with html and table(\d+) with html bold tag + listName = ";".join( + [ + re.sub( + r"table(\d+):(.*)", r"table\g<1>: \g<2>", s + ) + for s in section.split(";") + ] + ) + listName = listName.replace(";", " ; ") + children = [f"{opt} = {cp[section][opt]}" for opt in cp[section]] + if section == sectionToMatch: + groupsDescr[listName] = ( + "Equations that were calculated from the same " + "table names you loaded" + ) + lists[listName] = children + else: + groupsDescr[listName] = ( + "Equations that were calculated from table names that " + "you did not load now" + ) + nonMatchingLists[listName] = children + # # Not implemented yet --> selecting from non matching table names + # # would require an additional widget where the user sets + # # what df1 and df2 are. + # trees[treeName] = children + + if not lists: + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + txt = html_utils.paragraph(""" + None of the equations in the selected file used the same + table names that you loaded.

    + See below which table names and equations are present in the loaded file. + """) + with open(file_path) as iniFile: + detailedText = iniFile.read() + + msg.warning(self, "Not the same tables", txt, showDialog=False) + msg.setDetailedText(detailedText, visible=True) + msg.addShowInFileManagerButton(os.path.dirname(file_path)) + msg.exec_() + return + + selectWindow = MultiListSelector( + lists, + groupsDescr=groupsDescr, + title="Select equations to load", + infoTxt="Select equations you want to load", + ) + selectWindow.exec_() + if selectWindow.cancel or not selectWindow.selectedItems: + return + + for listName, equations in selectWindow.selectedItems.items(): + for equation in equations: + metricName, expression = equation.split(" = ") + self.addEquation(metricName, expression) + + def ok_cb(self): + self.cancel = False + self.equations = {} + for i in range(self.equationsList.topLevelItemCount()): + item = self.equationsList.topLevelItem(i) + self.equations[item.text(0)] = item.text(1) + + self.close() + + def loadButtonClicked(self): + self.sigLoadAdditionalAcdcDf.emit() + + def removeButtonClicked(self): + for item in self.equationsList.selectedItems(): + self.equationsList.invisibleRootItem().removeChild(item) + + def editButtonClicked(self): + self.editedItem = self.equationsList.selectedItems()[0] + self.editedIndex = self.equationsList.indexOfTopLevelItem(self.editedItem) + self.addEquation_cb() + + def onEquationItemSelectionChanged(self): + selectedItems = self.equationsList.selectedItems() + if len(selectedItems) == 1: + self.editEquationButton.setDisabled(False) + self.removeEquationButton.setDisabled(False) + elif len(selectedItems) > 1: + self.removeEquationButton.setDisabled(False) + self.editEquationButton.setDisabled(True) + else: + self.removeEquationButton.setDisabled(True) + self.editEquationButton.setDisabled(True) + + def addAcdcDfs(self, acdcDfsDict): + self.acdcDfs = {**self.acdcDfs, **acdcDfsDict} + items = [ + f"• Table {i + 1}: {e}" + for i, e in enumerate(self.acdcDfs.keys()) + ] + self.selectedAcdcDfsList = widgets.readOnlyQList() + self.selectedAcdcDfsList.addItems(items) + + def addEquation(self, newColname, expression): + if self.editedIndex is not None: + self.equationsList.invisibleRootItem().removeChild(self.editedItem) + bkgrColor = QColor(*BACKGROUND_RGBA[:3], 200) + item = widgets.TreeWidgetItem( + self.equationsList, columnColors=[None, bkgrColor] + ) + item.setText(0, newColname) + item.setText(1, expression) + if self.editedIndex is not None: + self.equationsList.insertTopLevelItem(self.editedIndex, item) + else: + self.equationsList.addTopLevelItem(item) + self.equationsList.resizeColumnToContents(0) + self.equationsList.resizeColumnToContents(1) + self.editedIndex = None + + def addEquation_cb(self): + self.addEquationWin = CombineMetricsMultiDfsDialog( + self.acdcDfs, self.allChNames, parent=self + ) + if hasattr(self, "logger"): + self.addEquationWin.setLogger(self.logger, self.logs_path, self.log_path) + if self.editedIndex is not None: + editedMetricName = self.editedItem.text(0) + self.addEquationWin.newColNameLineEdit.setText(editedMetricName) + editedExpression = self.editedItem.text(1) + self.addEquationWin.equationDisplay.setPlainText(editedExpression) + self.addEquationWin.show() + self.addEquationWin.sigOk.connect(self.addEquation) + self.addEquationWin.sigClose.connect(self.addEquationClosed) + + def addEquationClosed(self, cancelled): + if cancelled: + self.editedIndex = None + + def showEvent(self, event) -> None: + self.resize(int(self.width() * 2), self.height()) + + +class SelectFeaturesRange: + def __init__( + self, posData, force_postprocess_2D=False, qparent=None, sigValueChanged=None + ) -> None: + self.posData = posData + self.qparent = qparent + self.force_postprocess_2D = force_postprocess_2D + self.sigValueChanged = sigValueChanged + + self.lowRangeWidgets = widgets.CheckableSpinBoxWidgets() + self.highRangeWidgets = widgets.CheckableSpinBoxWidgets() + + self.selectButton = widgets.FeatureSelectorButton("Click to select feature...") + self.selectButton.setSizeLongestText( + "Spotfit intens. metric, Foregr. integral gauss. peak" + ) + self.selectButton.clicked.connect(self.selectFeature) + self.selectButton.setCursor(Qt.PointingHandCursor) + + self.selectedFeatureGroups = {} + + self.widgets = [ + {"pos": (0, 0), "widget": self.lowRangeWidgets.checkbox}, + {"pos": (1, 0), "widget": self.lowRangeWidgets.spinbox}, + {"pos": (1, 1), "widget": widgets.LessThanPushButton(flat=True)}, + {"pos": (1, 2), "widget": self.selectButton}, + {"pos": (1, 3), "widget": widgets.LessThanPushButton(flat=True)}, + {"pos": (0, 4), "widget": self.highRangeWidgets.checkbox}, + {"pos": (1, 4), "widget": self.highRangeWidgets.spinbox}, + {"pos": (2, 0), "widget": widgets.VerticalSpacerEmptyWidget(height=10)}, + ] + self.columnsStretches = {0: 0, 1: 0, 2: 1, 3: 0, 4: 0} + + def setText(self, text): + self.selectButton.setText(text) + + def selectFeature(self): + loadedChNames = [self.posData.user_ch_name] + notLoadedChNames = [] + isZstack = self.posData.SizeZ > 1 and not self.force_postprocess_2D + isSegm3D = self.posData.isSegm3D and not self.force_postprocess_2D + self.selectFeatureDialog = SetMeasurementsDialog( + loadedChNames, + notLoadedChNames, + isZstack, + isSegm3D, + posData=self.posData, + parent=self.qparent, + isSingleSelection=True, + is_concat=True, + ) + # self.selectFeatureDialog.resizeVertical() + self.selectFeatureDialog.sigClosed.connect(self.setFeatureText) + self.selectFeatureDialog.show() + + def setFeatureText(self): + if self.selectFeatureDialog.cancel: + return + self.selectButton.setFlat(True) + selectedMetricName, selectedMetricGroup = ( + self.selectFeatureDialog.selectedMetricNameAndGroup() + ) + self.selectButton.setText(selectedMetricName) + self.featureGroup = selectedMetricGroup + + +class SelectFeaturesRangeDialog(QBaseDialog): + sigValueChanged = Signal(object) + + def __init__(self, posData=None, parent=None, force_postprocess_2D=False): + super().__init__(parent) + + self.force_postprocess_2D = force_postprocess_2D + + layout = QVBoxLayout() + self.setWindowTitle("Custom features for post-processing") + + self.groupbox = SelectFeaturesRangeGroupbox( + posData=posData, parent=parent, force_postprocess_2D=force_postprocess_2D + ) + + buttonsLayout = QHBoxLayout() + okPushButton = widgets.okPushButton(" Ok ") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(okPushButton) + + okPushButton.clicked.connect(self.ok_cb) + + layout.addWidget(self.groupbox) + layout.addSpacing(10) + layout.addLayout(buttonsLayout) + + self.setLayout(layout) + + def ok_cb(self): + if self.groupbox.selectedFeaturesRange(): + self.sigValueChanged.emit(None) + self.hide() + + +class SelectFeaturesRangeGroupbox(QGroupBox): + def __init__(self, posData=None, parent=None, force_postprocess_2D=False): + super().__init__(parent) + + self.setTitle("Features and thresholds for filtering segmented objects") + # self.setCheckable(True) + + self.posData = posData + self.force_postprocess_2D = force_postprocess_2D + + self._layout = QGridLayout() + self._layout.setVerticalSpacing(0) + + firstSelector = SelectFeaturesRange( + posData, force_postprocess_2D=force_postprocess_2D + ) + self.addButton = widgets.addPushButton(" Add feature ") + self.addButton.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + for col, widget in enumerate(firstSelector.widgets): + row, col = widget["pos"] + self._layout.addWidget(widget["widget"], row, col) + for col, stretch in firstSelector.columnsStretches.items(): + self._layout.setColumnStretch(col, stretch) + + lastCol = self._layout.columnCount() + self._layout.addWidget(self.addButton, 0, lastCol + 1, 2, 1) + self.lastCol = lastCol + 1 + self.selectors = [firstSelector] + + self.setLayout(self._layout) + + # self.setFont(font) + + self.addButton.clicked.connect(self.addFeatureField) + + def addFeatureField(self): + row = self._layout.rowCount() + selector = SelectFeaturesRange( + self.posData, force_postprocess_2D=self.force_postprocess_2D + ) + delButton = widgets.delPushButton("Remove feature") + delButton.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + delButton.selector = selector + selector.delButton = delButton + for col, widget in enumerate(selector.widgets): + relRow, col = widget["pos"] + self._layout.addWidget(widget["widget"], relRow + row, col) + self._layout.addWidget(delButton, row, self.lastCol, 2, 1) + self.selectors.append(selector) + delButton.clicked.connect(self.removeFeatureField) + + def resetFields(self): + while len(self.selectors) > 1: + selector = self.selectors[-1] + selector.delButton.click() + firstSelector = self.selectors[0] + firstSelector.selectButton.setText("Click to select feature...") + firstSelector.lowRangeWidgets.checkbox.setChecked(False) + firstSelector.highRangeWidgets.checkbox.setChecked(False) + + def removeFeatureField(self): + delButton = self.sender() + for widget in delButton.selector.widgets: + self._layout.removeWidget(widget["widget"]) + self._layout.removeWidget(delButton) + self.selectors.remove(delButton.selector) + + def selectedFeaturesRange(self): + featuresRange = {} + for selector in self.selectors: + if selector.selectButton.text().find("Click") != -1: + continue + featuresRange[selector.selectButton.text()] = ( + selector.lowRangeWidgets.value(), + selector.highRangeWidgets.value(), + ) + return featuresRange + + def selectedFeaturesGroup(self): + featuresGroup = {} + for selector in self.selectors: + if selector.selectButton.text().find("Click") != -1: + continue + group = selector.featureGroup + featuresGroup[selector.selectButton.text()] = group + return featuresGroup + + def groupedFeatures(self): + featuresGroup = self.selectedFeaturesGroup() + groupedFeatures = {} + for feature, group in featuresGroup.items(): + group = featuresGroup[feature] + if isinstance(group, str): + key = group + if key not in groupedFeatures: + groupedFeatures[key] = [] + groupedFeatures[key].append(feature) + else: + key, channel = list(group.items())[0] + if key not in groupedFeatures: + groupedFeatures[key] = {} + if channel not in groupedFeatures[key]: + groupedFeatures[key][channel] = [] + groupedFeatures[key][channel].append(feature) + return groupedFeatures + + def setValue(self, value): + pass + + +class CombineFeaturesCalculator(QBaseDialog): + sigOk = Signal(object) + + def __init__( + self, + features_groups: dict, + group_name_to_col_mapper: dict = None, + title="Combine features calculator", + parent=None, + ): + super().__init__(parent) + + self.cancel = True + + self.setWindowTitle(title) + self.initAttributes() + + mainLayout = QVBoxLayout() + equationLayout = QHBoxLayout() + + metricsTreeWidget = QTreeWidget() + metricsTreeWidget.setHeaderHidden(True) + metricsTreeWidget.setFont(font) + self.metricsTreeWidget = metricsTreeWidget + + for groupName, features in features_groups.items(): + topLevelTreeWidgetItem = QTreeWidgetItem(metricsTreeWidget) + topLevelTreeWidgetItem.setText(0, groupName) + metricsTreeWidget.addTopLevelItem(topLevelTreeWidgetItem) + self.addTreeItems( + topLevelTreeWidgetItem, + features, + isCol=True, + name_to_col_mapper=group_name_to_col_mapper.get(groupName), + ) + + operatorsLayout = self.createOperatorsLayout() + newFeatureNameLayout = self.createNewFeatureNameLayout() + equationDisplayLayout = self.createEquationDisplayLayout() + + equationLayout.addLayout(newFeatureNameLayout) + equationLayout.addWidget(QLabel(" = ")) + equationLayout.addLayout(equationDisplayLayout) + equationLayout.setStretch(0, 1) + equationLayout.setStretch(1, 0) + equationLayout.setStretch(2, 2) + + testOutputLayout = self.createTestOutputLayout() + buttonsLayout = self.createButtonsOutputLayout() + + instructions = html_utils.paragraph(""" + Double-click on any of the available measurements + to add it to the equation.

    + Before clicking the `Ok` button, check that the equation returns + the expected result by clicking the `Test output` button. + """) + + mainLayout.addWidget(QLabel(instructions)) + mainLayout.addWidget(QLabel("Available measurements:")) + mainLayout.addWidget(metricsTreeWidget) + mainLayout.addLayout(operatorsLayout) + mainLayout.addLayout(equationLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + mainLayout.addLayout(testOutputLayout) + + metricsTreeWidget.itemDoubleClicked.connect(self.addFeatureName) + self.setLayout(mainLayout) + self.setFont(font) + + self.setStyleSheet(TREEWIDGET_STYLESHEET) + + def setExpandedAll(self, expanded): + if expanded: + self.expandAll() + else: + for i in range(self.metricsTreeWidget.topLevelItemCount()): + topLevelItem = self.metricsTreeWidget.topLevelItem(i) + topLevelItem.setExpanded(False) + + def expandAll(self): + for i in range(self.metricsTreeWidget.topLevelItemCount()): + topLevelItem = self.metricsTreeWidget.topLevelItem(i) + topLevelItem.setExpanded(True) + + def addTreeItems(self, parentItem, itemsText, isCol=False, name_to_col_mapper=None): + for text in itemsText: + _item = QTreeWidgetItem(parentItem) + _item.setText(0, text) + parentItem.addChild(_item) + if isCol: + _item.isCol = True + _item.variable_name = text + if name_to_col_mapper is None: + continue + + col_name = name_to_col_mapper.get(text, None) + if col_name is None: + continue + + _item.variable_name = col_name + + def addFeatureName(self, item, column): + if not hasattr(item, "isCol"): + return + + colName = item.variable_name + text = f"{self.equationDisplay.toPlainText()}{colName}" + self.equationDisplay.setPlainText(text) + self.clearLenghts.append(len(colName)) + self.equationColNames.append(colName) + + def clearEquation(self): + self.isOperatorMode = False + self.equationDisplay.setPlainText("") + self.initAttributes() + + def createButtonsOutputLayout(self): + buttonsLayout = QHBoxLayout() + + cancelButton = widgets.cancelPushButton("Cancel") + helpButton = widgets.infoPushButton(" Help...") + testButton = widgets.calcPushButton("Test output") + okButton = widgets.okPushButton(" Ok ") + okButton.setDisabled(True) + self.okButton = okButton + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(helpButton) + buttonsLayout.addWidget(testButton) + buttonsLayout.addWidget(okButton) + + helpButton.clicked.connect(self.showHelp) + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + testButton.clicked.connect(self.test_cb) + + return buttonsLayout + + def ok_cb(self): + if not self.newFeatureNameLineEdit.text(): + self.warnEmptyEquationName() + return + + self.equation = self.equationDisplay.toPlainText() + self.newFeatureName = self.newFeatureNameLineEdit.text() + self.cancel = False + self.close() + self.sigOk.emit(self) + + def test_cb(self): + # Evaluate equation with random inputs + equation = self.equationDisplay.toPlainText() + random_data = np.random.rand(1, len(self.equationColNames)) * 5 + df = pd.DataFrame(data=random_data, columns=self.equationColNames).round(5) + newColName = self.newFeatureNameLineEdit.text() + try: + df[newColName] = df.eval(equation) + except Exception as e: + traceback.print_exc() + self.testOutputDisplay.setHtml(html_utils.paragraph(e)) + self.testOutputDisplay.setStyleSheet("border: 2px solid red") + return + + self.testOutputDisplay.setStyleSheet("border: 2px solid green") + self.okButton.setDisabled(False) + + result = df.round(5).iloc[0][newColName] + + # Substitute numbers into equation + inputs = df.iloc[0] + equation_numbers = equation + for c, col in enumerate(self.equationColNames): + equation_numbers = equation_numbers.replace(col, str(inputs[c])) + + # Format output into html text + cols = self.equationColNames + inputs_txt = [f"{col} = {input}" for col, input in zip(cols, inputs)] + list_html = html_utils.to_list(inputs_txt) + text = html_utils.paragraph(f""" + By substituting the following random inputs: + {list_html} + we get the equation:

    +   {newColName} = {equation_numbers}

    + that equals to:

    +   {newColName} = {result} + """) + self.testOutputDisplay.setHtml(text) + + def warnEmptyEquationName(self): + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + "New measurement name" field cannot be empty! + """) + msg.critical(self, "Empty new measurement name", txt) + + def showHelp(self): + pass + + def createTestOutputLayout(self): + testOutputLayout = QVBoxLayout() + testOutputLayout.addWidget(QLabel("Result of test with random inputs:")) + testOutputDisplay = QTextEdit() + testOutputDisplay.setReadOnly(True) + self.testOutputDisplay = testOutputDisplay + testOutputLayout.addWidget(testOutputDisplay) + testOutputLayout.setStretch(0, 0) + testOutputLayout.setStretch(1, 1) + + return testOutputLayout + + def createEquationDisplayLayout(self): + equationDisplayLayout = QVBoxLayout() + equationDisplayLayout.addWidget(QLabel("Equation:")) + equationDisplay = QPlainTextEdit() + # equationDisplay.setReadOnly(True) + self.equationDisplay = equationDisplay + equationDisplayLayout.addWidget(equationDisplay) + equationDisplayLayout.setStretch(0, 0) + equationDisplayLayout.setStretch(1, 1) + return equationDisplayLayout + + def createNewFeatureNameLayout(self): + newFeatureNameLayout = QVBoxLayout() + newFeatureNameLineEdit = widgets.alphaNumericLineEdit() + newFeatureNameLineEdit.setAlignment(Qt.AlignCenter) + self.newFeatureNameLineEdit = newFeatureNameLineEdit + newFeatureNameLayout.addStretch(1) + newFeatureNameLayout.addWidget(QLabel("New measurement name:")) + newFeatureNameLayout.addWidget(newFeatureNameLineEdit) + newFeatureNameLayout.addStretch(1) + return newFeatureNameLayout + + def createOperatorsLayout(self): + operatorsLayout = QHBoxLayout() + operatorsLayout.addStretch(1) + + iconSize = 24 + + self.operatorButtons = [] + self.operators = [ + ("add", "+"), + ("subtract", "-"), + ("multiply", "*"), + ("divide", "/"), + ("open_bracket", "("), + ("close_bracket", ")"), + ("square", "**2"), + ("pow", "**"), + ("ln", "log("), + ("log10", "log10("), + ] + operatorFont = QFont() + operatorFont.setPixelSize(16) + for name, text in self.operators: + button = QPushButton() + button.setIcon(QIcon(f":{name}.svg")) + button.setIconSize(QSize(iconSize, iconSize)) + button.text = text + operatorsLayout.addWidget(button) + self.operatorButtons.append(button) + button.clicked.connect(self.addOperator) + # button.setFont(operatorFont) + + clearButton = QPushButton() + clearButton.setIcon(QIcon(":clear.svg")) + clearButton.setIconSize(QSize(iconSize, iconSize)) + clearButton.setFont(operatorFont) + + clearEntryButton = QPushButton() + clearEntryButton.setIcon(QIcon(":backspace.svg")) + clearEntryButton.setFont(operatorFont) + clearEntryButton.setIconSize(QSize(iconSize, iconSize)) + + operatorsLayout.addWidget(clearButton) + operatorsLayout.addWidget(clearEntryButton) + operatorsLayout.addStretch(1) + + clearButton.clicked.connect(self.clearEquation) + clearEntryButton.clicked.connect(self.clearEntryEquation) + + return operatorsLayout + + def addOperator(self): + button = self.sender() + text = f"{self.equationDisplay.toPlainText()}{button.text}" + self.equationDisplay.setPlainText(text) + self.clearLenghts.append(len(button.text)) + + def clearEquation(self): + self.isOperatorMode = False + self.equationDisplay.setPlainText("") + self.initAttributes() + + def initAttributes(self): + self.clearLenghts = [] + self.equationColNames = [] + self.channelLessColnames = [] + + def clearEntryEquation(self): + if not self.clearLenghts: + return + + text = self.equationDisplay.toPlainText() + newText = text[: -self.clearLenghts[-1]] + clearedText = text[-self.clearLenghts[-1] :] + self.clearLenghts.pop(-1) + self.equationDisplay.setPlainText(newText) + if clearedText in self.equationColNames: + self.equationColNames.remove(clearedText) + if clearedText in self.channelLessColnames: + self.channelLessColnames.remove(clearedText) + +# Sibling imports (deferred to avoid import cycles) +from .metadata import ( + MultiListSelector, + filenameDialog, +) + diff --git a/cellacdc/dialogs/metadata.py b/cellacdc/dialogs/metadata.py new file mode 100644 index 000000000..d59a5e4b5 --- /dev/null +++ b/cellacdc/dialogs/metadata.py @@ -0,0 +1,3590 @@ +"""Cell-ACDC dialog windows: metadata.""" + +import os +import sys +import re +from typing import Literal, Callable, Dict, Iterable, List, Tuple +import datetime +import pathlib +from collections import defaultdict +import zipfile +from heapq import nlargest +import matplotlib +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.patches import Rectangle, Circle, PathPatch, Path +import numpy as np +import scipy.interpolate + +try: + import tkinter as tk +except Exception as err: + pass + +import cv2 +import traceback +from itertools import combinations, permutations +from collections import namedtuple +from natsort import natsorted + +# from MyWidgets import Slider, Button, MyRadioButtons +from skimage.measure import label, regionprops +from functools import partial +import skimage.filters +import skimage.measure +import skimage.morphology +import skimage.exposure +import skimage.draw +import skimage.registration +import skimage.color +import skimage.segmentation +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk +import matplotlib.pyplot as plt +import seaborn as sns +import pandas as pd +import math +import time +import sympy as sp +import json +import html + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from qtpy import QtCore +from qtpy.QtGui import ( + QIcon, + QFontMetrics, + QKeySequence, + QFont, + QRegularExpressionValidator, + QCursor, + QKeyEvent, + QPixmap, + QFont, + QPalette, + QMouseEvent, + QColor, +) +from qtpy.QtCore import ( + Qt, + QSize, + QEvent, + Signal, + QEventLoop, + QTimer, + QRegularExpression, +) +from qtpy.QtWidgets import ( + QFileDialog, + QApplication, + QMainWindow, + QMenu, + QLabel, + QToolBar, + QScrollBar, + QWidget, + QVBoxLayout, + QLineEdit, + QPushButton, + QHBoxLayout, + QDialog, + QFormLayout, + QListWidget, + QAbstractItemView, + QButtonGroup, + QCheckBox, + QSizePolicy, + QComboBox, + QSlider, + QGridLayout, + QSpinBox, + QToolButton, + QTableView, + QTextBrowser, + QDoubleSpinBox, + QScrollArea, + QFrame, + QProgressBar, + QGroupBox, + QRadioButton, + QDockWidget, + QMessageBox, + QStyle, + QPlainTextEdit, + QSpacerItem, + QTreeWidget, + QTreeWidgetItem, + QTextEdit, + QSplashScreen, + QAction, + QListWidgetItem, + QActionGroup, + QHeaderView, + QStyledItemDelegate, +) +import qtpy.compat + +from .. import exception_handler +from .. import load, prompts, core, measurements, html_utils +from .. import is_mac, is_win, is_linux, settings_folderpath, config +from .. import preproc_recipes_path, segm_recipes_path, combine_channels_recipes_path +from .. import is_conda_env +from .. import printl +from .. import colors +from .. import issues_url +from .. import myutils +from .. import qutils +from .. import _palettes +from .. import base_cca_dict +from .. import widgets +from .. import user_profile_path, promptable_models_path, models_path +from .. import features +from .. import _core +from .. import _types +from .. import plot +from .. import urls +from ..acdc_regex import float_regex, is_alphanumeric_filename, to_alphanumeric +from .. import _base_widgets +from .. import io +from .. import cca_functions +from .. import path + +POSITIVE_FLOAT_REGEX = float_regex(allow_negative=False) +TREEWIDGET_STYLESHEET = _palettes.TreeWidgetStyleSheet() +LISTWIDGET_STYLESHEET = _palettes.ListWidgetStyleSheet() +BACKGROUND_RGBA = _palettes.get_disabled_colors()["Button"] + +font = QFont() +font.setPixelSize(12) +italicFont = QFont() +italicFont.setPixelSize(12) +italicFont.setItalic(True) + +from ._base import ( + QBaseDialog, +) + +class filenameDialog(QDialog): + def __init__( + self, + ext=".npz", + basename="", + title="Insert file name", + hintText="", + existingNames="", + parent=None, + allowEmpty=True, + helpText="", + defaultEntry="", + resizeOnShow=True, + additionalButtons=None, + addDoNotSaveButton=False, + ): + self.cancel = True + super().__init__(parent) + + self.resizeOnShow = resizeOnShow + + if hintText.find("segmentation") != -1: + if helpText: + helpText = f"{helpText}" + helpText_loc = """ + With Cell-ACDC you can create as many segmentation files + as you want.

    + If you plan to create only one file then you can leave the + text entry empty.
    + Cell-ACDC will save the segmentation file with the filename + ending with _segm.npz.

    + However, we recommend to insert some text that will easily + allow you to identify what is the segmentation file about.

    + For example, if you are about to segment the channel + phase_contr, you could write + phase_contr.
    + Cell-ACDC will then save the file with the + filename ending with _segm_phase_contr.npz.

    + This way you can create multiple segmentation files, + for example one for each channel or one for each segmentation model.

    + Note that the numerical features and annotations will be saved + in a CSV file ending with the same text as the segmentation file,
    + e.g., ending with _acdc_output_phase_contr.csv. + """ + helpText = f"{helpText}{html_utils.paragraph(helpText_loc)}" + + self.isSegmFile = basename.endswith("_segm") + self.allowEmpty = allowEmpty + self.basename = basename + if ext and not ext.startswith("."): + ext = f".{ext}" + self.ext = ext + + self.setWindowTitle(title) + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + + layout = QVBoxLayout() + entryLayout = QGridLayout() + buttonsLayout = QHBoxLayout() + + hintLabel = QLabel(hintText) + + basenameLabel = QLabel(basename) + + self.lineEdit = widgets.alphaNumericLineEdit(onlyWarn=True) + self.lineEdit.setAlignment(Qt.AlignCenter) + defaultEntry = to_alphanumeric(defaultEntry) + defaultEntry = defaultEntry.replace(".", "_") + self.lineEdit.setText(defaultEntry) + + extLabel = QLabel(ext) + + self.filenameLabel = QLabel() + self.filenameLabel.setText(f"{basename}{ext}") + + entryLayout.addWidget(basenameLabel, 0, 1) + entryLayout.addWidget(self.lineEdit, 0, 2) + entryLayout.addWidget(extLabel, 0, 3) + entryLayout.addWidget(self.filenameLabel, 1, 1, 1, 3, alignment=Qt.AlignCenter) + # entryLayout.setColumnStretch(0, 1) + entryLayout.setColumnStretch(2, 1) + + self.warningInvalidCharLabel = QLabel() + + okButton = widgets.okPushButton("Ok") + cancelButton = widgets.cancelPushButton("Cancel") + self.okButton = okButton + + buttonsLayout.addStretch() + buttonsLayout.addWidget(cancelButton) + + if addDoNotSaveButton: + doNotSaveButton = widgets.noPushButton("Do not save") + doNotSaveButton.clicked.connect(self.doNotSave_cb) + buttonsLayout.addWidget(doNotSaveButton) + self.doNotSave = False + + buttonsLayout.addSpacing(20) + if helpText: + helpButton = widgets.helpPushButton("Help...") + helpButton.clicked.connect(partial(self.showHelp, helpText)) + buttonsLayout.addWidget(helpButton) + if additionalButtons is not None: + for button in additionalButtons: + buttonsLayout.addWidget(button) + buttonsLayout.addWidget(okButton) + + cancelButton.clicked.connect(self.close) + okButton.clicked.connect(self.ok_cb) + self.lineEdit.textChanged.connect(self.updateFilename) + self.lineEdit.sigInvalidCharactersEntered.connect( + self.warnInvalidCharactersEntered + ) + + self.existingNames = [] + if existingNames: + self.existingNames = existingNames + # self.lineEdit.editingFinished.connect(self.checkExistingNames) + + layout.addWidget(hintLabel) + layout.addSpacing(20) + layout.addLayout(entryLayout) + layout.addSpacing(10) + layout.addWidget(self.warningInvalidCharLabel) + layout.addStretch(1) + layout.addSpacing(20) + layout.addLayout(buttonsLayout) + + self.setLayout(layout) + self.setFont(font) + + if defaultEntry: + self.updateFilename(defaultEntry) + + def doNotSave_cb(self): + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + "Are you sure you do not want to save the file?" + ) + noButton, yesButton = msg.warning( + self, "Do not save?", txt, buttonsTexts=("No", "Yes") + ) + if msg.clickedButton == noButton: + return + + self.doNotSave = True + self.cancel = False + self.close() + + def showHelp(self, text): + text = html_utils.paragraph(text) + msg = widgets.myMessageBox(wrapText=False) + msg.information(self, "Filename help", text) + + def _text(self): + return self.lineEdit.text() + + def warnInvalidCharactersEntered(self, characters: set[str]): + statement = "is not a valid character" + if len(characters) > 1: + statement = "are not valid characters" + + characters_str = "".join(characters) + characters_str = html.escape(characters_str) + warning_text = html_utils.span(f""" + WARNING: "{characters_str}" {statement}.
    + """) + warning_text = ( + f"{warning_text}" + "Valid characters are letters, numbers, underscore, and dash." + ) + self.warningInvalidCharLabel.setText(warning_text) + + def checkExistingNames(self): + is_existing = ( + self._text() in self.existingNames + or self.filenameLabel.text() in self.existingNames + ) + if not is_existing: + return True + + filename = self.filenameLabel.text() + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + "The following file

    " + f"{filename}

    " + "is already existing.

    " + "Do you want to overwrite the existing file?" + ) + noButton, yesButton = msg.warning( + self, "File name existing", txt, buttonsTexts=("No", "Yes") + ) + return msg.clickedButton == yesButton + + def updateFilename(self, text): + if self.lineEdit.invalidCharacters(): + return + + if not text: + self.filenameLabel.setText(f"{self.basename}{self.ext}") + else: + text = text.replace(" ", "_") + if self.basename: + if self.basename.endswith("_"): + self.filenameLabel.setText(f"{self.basename}{text}{self.ext}") + else: + self.filenameLabel.setText(f"{self.basename}_{text}{self.ext}") + else: + self.filenameLabel.setText(f"{text}{self.ext}") + + self.warningInvalidCharLabel.setText("") + + def checkEmptyText(self): + if self.allowEmpty: + return True + + if self._text(): + return True + + msg = widgets.myMessageBox() + msg.critical( + self, + "Empty text", + html_utils.paragraph("Text entry field cannot be empty"), + ) + return False + + def checkSegmFilename(self): + if not self.isSegmFile: + return True + + if "segm" not in self._text(): + return True + + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + "The text appended to the filename cannot contain the text " + '"segm".

    ' + "Sorry, that would confuse me. Thank you for your patience!" + ) + msg.critical(self, 'Cannot use "segm" in filename', txt) + return False + + def ok_cb(self, checked=True): + if self.warningInvalidCharLabel.text(): + return + + valid = self.checkExistingNames() + if not valid: + return + + valid = self.checkEmptyText() + if not valid: + return + + valid = self.checkSegmFilename() + if not valid: + return + + self.filename = self.filenameLabel.text() + self.entryText = self._text() + self.cancel = False + self.close() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + super().show() + if self.resizeOnShow: + self.lineEdit.setMinimumWidth(self.lineEdit.width() * 2) + self.okButton.setDefault(True) + if block: + self.loop = QEventLoop() + self.loop.exec_() + + +class QDialogMetadataXML(QDialog): + def __init__( + self, + title="Metadata", + LensNA=1.0, + rawFilename="test", + SizeT=1, + SizeZ=1, + SizeC=1, + SizeS=1, + TimeIncrement=1.0, + TimeIncrementUnit="s", + PhysicalSizeX=1.0, + PhysicalSizeY=1.0, + PhysicalSizeZ=1.0, + PhysicalSizeUnit="μm", + ImageName="", + chNames=None, + emWavelens=None, + parent=None, + rawDataStruct=None, + sampleImgData=None, + rawFilePath=None, + ): + self.cancel = True + self.trust = False + self.overWrite = False + rawFilename = os.path.splitext(rawFilename)[0] + self.rawFilename = self.removeInvalidCharacters(rawFilename) + self.rawFilePath = rawFilePath + self.sampleImgData = sampleImgData + self.ImageName = ImageName + self.rawDataStruct = rawDataStruct + self.readSampleImgDataAgain = False + self.requestedReadingSampleImageDataAgain = False + self.imageViewer = None + super().__init__(parent) + self.setWindowTitle(title) + font = QFont() + font.setPixelSize(12) + self.setFont(font) + + mainLayout = QVBoxLayout() + entriesLayout = QGridLayout() + self.channelNameLayouts = ( + QVBoxLayout(), + QVBoxLayout(), + QVBoxLayout(), + QVBoxLayout(), + ) + self.channelEmWLayouts = ( + QVBoxLayout(), + QVBoxLayout(), + QVBoxLayout(), + QVBoxLayout(), + ) + buttonsLayout = QGridLayout() + + infoLabel = QLabel() + infoTxt = "Confirm/Edit the metadata below." + infoLabel.setText(infoTxt) + # padding: top, left, bottom, right + infoLabel.setStyleSheet("font-size:12pt; padding:0px 0px 5px 0px;") + mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) + + noteLabel = QLabel() + noteLabel.setText( + f"NOTE: If you are not sure about some of the entries " + 'you can try to click "Ok".\n' + "If they are wrong you will get " + "an error message later when trying to read the data." + ) + noteLabel.setAlignment(Qt.AlignCenter) + mainLayout.addWidget(noteLabel, alignment=Qt.AlignCenter) + + row = 0 + to_tif_radiobutton = QRadioButton(".tif") + to_tif_radiobutton.setChecked(True) + to_h5_radiobutton = QRadioButton(".h5") + to_h5_radiobutton.setToolTip( + ".h5 is highly recommended for big datasets to avoid memory issues.\n" + "As a rule of thumb, if the single position, single channel file\n" + "is larger than 1/5 of the available RAM we recommend using .h5 format" + ) + self.to_h5_radiobutton = to_h5_radiobutton + txt = "File format: " + label = QLabel(txt) + fileFormatLayout = QHBoxLayout() + fileFormatLayout.addStretch(1) + fileFormatLayout.addWidget(to_tif_radiobutton) + fileFormatLayout.addStretch(1) + fileFormatLayout.addWidget(to_h5_radiobutton) + fileFormatLayout.addStretch(1) + entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + entriesLayout.addLayout(fileFormatLayout, row, 1) + to_h5_radiobutton.toggled.connect(self.updateFileFormat) + + row += 1 + self.SizeS_SB = QSpinBox() + self.SizeS_SB.setAlignment(Qt.AlignCenter) + self.SizeS_SB.setMinimum(1) + self.SizeS_SB.setMaximum(2147483647) + self.SizeS_SB.setValue(SizeS) + txt = "Number of positions (SizeS): " + label = QLabel(txt) + entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + entriesLayout.addWidget(self.SizeS_SB, row, 1) + + if rawDataStruct == 0: + row += 1 + self.SizeS_SB.setValue(1) + self.SizeS_SB.setDisabled(True) + self.posSelector = widgets.ExpandableListBox() + positions = ["All positions"] + positions.extend([f"Position_{i + 1}" for i in range(SizeS)]) + self.posSelector.addItems(positions) + txt = "Positions to save: " + label = QLabel(txt) + entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + entriesLayout.addWidget(self.posSelector, row, 1) + self.SizeS_SB.valueChanged.connect(self.SizeSvalueChanged) + + row += 1 + self.LensNA_DSB = QDoubleSpinBox() + self.LensNA_DSB.setAlignment(Qt.AlignCenter) + self.LensNA_DSB.setSingleStep(0.1) + self.LensNA_DSB.setValue(LensNA) + txt = "Numerical Aperture Objective Lens: " + label = QLabel(txt) + entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + entriesLayout.addWidget(self.LensNA_DSB, row, 1) + + row += 1 + self.SizeT_SB = QSpinBox() + self.SizeT_SB.setAlignment(Qt.AlignCenter) + self.SizeT_SB.setMinimum(1) + self.SizeT_SB.setMaximum(2147483647) + self.SizeT_SB.setValue(SizeT) + txt = "Number of frames (SizeT): " + label = QLabel(txt) + entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + entriesLayout.addWidget(self.SizeT_SB, row, 1) + self.SizeT_SB.valueChanged.connect(self.hideShowTimeIncrement) + + row += 1 + self.timeRangeToSaveWidget = widgets.RangeSelector(integers=True) + self.timeRangeToSaveWidget.setRange(1, SizeT) + txt = "Time range to save: " + label = QLabel(txt) + self.timeRangeToSaveWidget.label = label + entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + entriesLayout.addWidget(self.timeRangeToSaveWidget, row, 1) + + row += 1 + self.SizeZ_SB = QSpinBox() + self.SizeZ_SB.setAlignment(Qt.AlignCenter) + self.SizeZ_SB.setMinimum(1) + self.SizeZ_SB.setMaximum(2147483647) + self.SizeZ_SB.setValue(SizeZ) + txt = "Number of z-slices in the z-stack (SizeZ): " + label = QLabel(txt) + entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + entriesLayout.addWidget(self.SizeZ_SB, row, 1) + self.SizeZ_SB.valueChanged.connect(self.hideShowPhysicalSizeZ) + + row += 1 + self.TimeIncrement_DSB = widgets.FloatLineEdit( + allowNegative=False, warningValues={1.0} + ) + self.TimeIncrement_DSB.setValue(TimeIncrement) + self.TimeIncrement_DSB.setMinimum(0.0) + txt = "Frame interval: " + label = QLabel(txt) + self.TimeIncrement_Label = label + entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + entriesLayout.addWidget(self.TimeIncrement_DSB, row, 1) + + self.TimeIncrementUnit_CB = QComboBox() + unitItems = ["ms", "seconds", "minutes", "hours"] + currentTxt = [unit for unit in unitItems if unit.startswith(TimeIncrementUnit)] + self.TimeIncrementUnit_CB.addItems(unitItems) + if currentTxt: + self.TimeIncrementUnit_CB.setCurrentText(currentTxt[0]) + entriesLayout.addWidget( + self.TimeIncrementUnit_CB, row, 2, alignment=Qt.AlignLeft + ) + + row += 1 + self.PhysicalSizeX_DSB = QDoubleSpinBox() + self.PhysicalSizeX_DSB.setAlignment(Qt.AlignCenter) + self.PhysicalSizeX_DSB.setMaximum(2147483647.0) + self.PhysicalSizeX_DSB.setSingleStep(0.001) + self.PhysicalSizeX_DSB.setDecimals(7) + self.PhysicalSizeX_DSB.setValue(PhysicalSizeX) + txt = "Pixel width (X): " + label = QLabel(txt) + entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + entriesLayout.addWidget(self.PhysicalSizeX_DSB, row, 1) + + self.PhysicalSizeUnit_CB = QComboBox() + unitItems = ["nm", "μm", "mm", "cm"] + currentTxt = [unit for unit in unitItems if unit.startswith(PhysicalSizeUnit)] + self.PhysicalSizeUnit_CB.addItems(unitItems) + if currentTxt: + self.PhysicalSizeUnit_CB.setCurrentText(currentTxt[0]) + else: + self.PhysicalSizeUnit_CB.setCurrentText(unitItems[1]) + entriesLayout.addWidget( + self.PhysicalSizeUnit_CB, row, 2, alignment=Qt.AlignLeft + ) + self.PhysicalSizeUnit_CB.currentTextChanged.connect(self.updatePSUnit) + + row += 1 + self.PhysicalSizeY_DSB = QDoubleSpinBox() + self.PhysicalSizeY_DSB.setAlignment(Qt.AlignCenter) + self.PhysicalSizeY_DSB.setMaximum(2147483647.0) + self.PhysicalSizeY_DSB.setSingleStep(0.001) + self.PhysicalSizeY_DSB.setDecimals(7) + self.PhysicalSizeY_DSB.setValue(PhysicalSizeY) + txt = "Pixel height (Y): " + label = QLabel(txt) + entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + entriesLayout.addWidget(self.PhysicalSizeY_DSB, row, 1) + + self.PhysicalSizeYUnit_Label = QLabel() + self.PhysicalSizeYUnit_Label.setStyleSheet( + "font-size:13px; padding:5px 0px 2px 0px;" + ) + unit = self.PhysicalSizeUnit_CB.currentText() + self.PhysicalSizeYUnit_Label.setText(unit) + entriesLayout.addWidget(self.PhysicalSizeYUnit_Label, row, 2) + + row += 1 + self.PhysicalSizeZ_DSB = QDoubleSpinBox() + self.PhysicalSizeZ_DSB.setAlignment(Qt.AlignCenter) + self.PhysicalSizeZ_DSB.setMaximum(2147483647.0) + self.PhysicalSizeZ_DSB.setSingleStep(0.001) + self.PhysicalSizeZ_DSB.setDecimals(7) + self.PhysicalSizeZ_DSB.setValue(PhysicalSizeZ) + txt = "Voxel depth (Z): " + self.PSZlabel = QLabel(txt) + entriesLayout.addWidget(self.PSZlabel, row, 0, alignment=Qt.AlignRight) + entriesLayout.addWidget(self.PhysicalSizeZ_DSB, row, 1) + + self.PhysicalSizeZUnit_Label = QLabel() + # padding: top, left, bottom, right + self.PhysicalSizeZUnit_Label.setStyleSheet( + "font-size:13px; padding:5px 0px 2px 0px;" + ) + unit = self.PhysicalSizeUnit_CB.currentText() + self.PhysicalSizeZUnit_Label.setText(unit) + entriesLayout.addWidget(self.PhysicalSizeZUnit_Label, row, 2) + + if SizeZ == 1: + self.PSZlabel.hide() + self.PhysicalSizeZ_DSB.hide() + self.PhysicalSizeZUnit_Label.hide() + + row += 1 + self.SizeC_SB = QSpinBox() + self.SizeC_SB.setAlignment(Qt.AlignCenter) + self.SizeC_SB.setMinimum(1) + self.SizeC_SB.setMaximum(2147483647) + self.SizeC_SB.setValue(SizeC) + txt = "Number of channels (SizeC): " + label = QLabel(txt) + entriesLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + entriesLayout.addWidget(self.SizeC_SB, row, 1) + self.SizeC_SB.valueChanged.connect(self.addRemoveChannels) + + row += 1 + for j, layout in enumerate(self.channelNameLayouts): + entriesLayout.addLayout(layout, row, j) + + self.chNames_QLEs = [] + self.saveChannels_QCBs = [] + self.filename_QLabels = [] + self.showChannelDataButtons = [] + + ext = "h5" if self.to_h5_radiobutton.isChecked() else "tif" + for c in range(SizeC): + chName_QLE = QLineEdit() + chName_QLE.setStyleSheet("") + chName_QLE.setAlignment(Qt.AlignCenter) + chName_QLE.textChanged.connect(self.checkChNames) + if chNames is not None: + chName_QLE.setText(chNames[c]) + else: + chName_QLE.setText(f"channel_{c}") + filename = f"" + + txt = f"Channel {c} name: " + label = QLabel(txt) + + filenameDescLabel = QLabel(f"e.g., filename for channel {c}: ") + + chName = chName_QLE.text() + chName = self.removeInvalidCharacters(chName) + rawFilename = self.elidedRawFilename() + filenameLabel = QLabel(f""" +

    {rawFilename}_{chName}.{ext}

    + """) + filenameLabel.setToolTip(f"{self.rawFilename}_{chName}.{ext}") + + checkBox = QCheckBox("Save this channel") + checkBox.setChecked(True) + checkBox.stateChanged.connect(self.saveCh_checkBox_cb) + + self.channelNameLayouts[0].addWidget(label, alignment=Qt.AlignRight) + self.channelNameLayouts[0].addWidget( + filenameDescLabel, alignment=Qt.AlignRight + ) + self.channelNameLayouts[1].addWidget(chName_QLE) + self.channelNameLayouts[1].addWidget( + filenameLabel, alignment=Qt.AlignCenter + ) + + self.channelNameLayouts[2].addWidget(checkBox) + if c == 0 and ImageName: + addImageName_QCB = QCheckBox("Include image name") + addImageName_QCB.stateChanged.connect(self.addImageName_cb) + self.addImageName_QCB = addImageName_QCB + self.channelNameLayouts[2].addWidget(addImageName_QCB) + else: + self.addImageName_QCB = QCheckBox("dummy") + self.addImageName_QCB.hide() + self.channelNameLayouts[2].addWidget(QLabel()) + + showChannelDataButton = QPushButton() + showChannelDataButton.setIcon(QIcon(":eye-plus.svg")) + showChannelDataButton.clicked.connect(self.showChannelData) + self.channelNameLayouts[3].addWidget(showChannelDataButton) + if self.sampleImgData is None: + showChannelDataButton.setDisabled(True) + + self.chNames_QLEs.append(chName_QLE) + self.saveChannels_QCBs.append(checkBox) + self.filename_QLabels.append(filenameLabel) + self.showChannelDataButtons.append(showChannelDataButton) + + self.checkChNames() + + row += 1 + for j, layout in enumerate(self.channelEmWLayouts): + entriesLayout.addLayout(layout, row, j) + + self.emWavelens_DSBs = [] + for c in range(SizeC): + row += 1 + emWavelen_DSB = QDoubleSpinBox() + emWavelen_DSB.setAlignment(Qt.AlignCenter) + emWavelen_DSB.setMaximum(2147483647.0) + emWavelen_DSB.setSingleStep(0.001) + emWavelen_DSB.setDecimals(2) + if emWavelens is not None: + emWavelen_DSB.setValue(emWavelens[c]) + else: + emWavelen_DSB.setValue(500.0) + + txt = f"Channel {c} emission wavelength: " + label = QLabel(txt) + self.channelEmWLayouts[0].addWidget(label, alignment=Qt.AlignRight) + self.channelEmWLayouts[1].addWidget(emWavelen_DSB) + self.emWavelens_DSBs.append(emWavelen_DSB) + + unit = QLabel("nm") + unit.setStyleSheet("font-size:13px; padding:5px 0px 2px 0px;") + self.channelEmWLayouts[2].addWidget(unit) + + entriesLayout.setContentsMargins(0, 15, 0, 0) + + if rawDataStruct is None or rawDataStruct != -1: + okButton = widgets.okPushButton(" Ok ") + elif rawDataStruct == 1: + okButton = QPushButton(" Load next position ") + buttonsLayout.addWidget(okButton, 0, 1) + + self.trustButton = None + self.overWriteButton = None + if rawDataStruct == 1: + trustButton = QPushButton( + " Trust metadata reader\n for all next positions " + ) + trustButton.setToolTip( + "If you didn't have to manually modify metadata entries\n" + "it is very likely that metadata from the metadata reader\n" + "will be correct also for all the next positions.\n\n" + "Click this button to stop showing this dialog and use\n" + "the metadata from the reader\n" + "(except for channel names, I will use the manually entered)" + ) + buttonsLayout.addWidget(trustButton, 1, 1) + self.trustButton = trustButton + + overWriteButton = QPushButton( + " Use the above metadata\n for all the next positions " + ) + overWriteButton.setToolTip( + "If you had to manually modify metadata entries\n" + "AND you know they will be the same for all next positions\n" + "you can click this button to stop showing this dialog\n" + "and use the same metadata for all the next positions." + ) + buttonsLayout.addWidget(overWriteButton, 1, 2) + self.overWriteButton = overWriteButton + + trustButton.clicked.connect(self.ok_cb) + overWriteButton.clicked.connect(self.ok_cb) + + cancelButton = widgets.cancelPushButton("Cancel") + buttonsLayout.addWidget(cancelButton, 0, 2) + buttonsLayout.setColumnStretch(0, 1) + buttonsLayout.setColumnStretch(3, 1) + buttonsLayout.setContentsMargins(0, 10, 0, 0) + + mainLayout.addLayout(entriesLayout) + mainLayout.addLayout(buttonsLayout) + mainLayout.addStretch(1) + + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.cancel_cb) + + self.hideShowTimeIncrement(SizeT) + self.readSampleImgDataAgain = False + + self.setLayout(mainLayout) + # self.setModal(True) + + def saveCh_checkBox_cb(self, state): + self.checkChNames() + idx = self.saveChannels_QCBs.index(self.sender()) + LE = self.chNames_QLEs[idx] + idx *= 2 + LE.setDisabled(state == 0) + label = self.channelNameLayouts[0].itemAt(idx).widget() + if state == 0: + label.setStyleSheet("color: gray; font-size: 10pt") + else: + label.setStyleSheet("color: black; font-size: 10pt") + + label = self.channelNameLayouts[0].itemAt(idx + 1).widget() + if state == 0: + label.setStyleSheet("color: gray; font-size: 10pt") + else: + label.setStyleSheet("color: black; font-size: 10pt") + + label = self.channelNameLayouts[1].itemAt(idx + 1).widget() + if state == 0: + label.setStyleSheet("color: gray; font-size: 10pt") + else: + label.setStyleSheet("color: black; font-size: 10pt") + + def addImageName_cb(self, state): + for idx in range(self.SizeC_SB.value()): + self.updateFilename(idx) + + def setInvalidChName_StyleSheet(self, LE): + LE.setStyleSheet( + "border-radius: 4px;border: 1.5px solid red;padding: 1px 0px 1px 0px" + ) + + def removeInvalidCharacters(self, chName): + # Remove invalid charachters + chName = "".join( + c if c.isalnum() or c == "_" or c == "" else "_" for c in chName + ) + trim_ = chName.endswith("_") + while trim_: + chName = chName[:-1] + trim_ = chName.endswith("_") + return chName + + def updateFileFormat(self, is_h5): + for idx in range(len(self.chNames_QLEs)): + self.updateFilename(idx) + + def SizeSvalueChanged(self, SizeS): + positions = ["All positions"] + positions.extend([f"Position_{i + 1}" for i in range(SizeS)]) + self.posSelector.setItems(positions) + + def elidedRawFilename(self): + n = 31 + idx = int((n - 3) / 2) + if len(self.rawFilename) > 21: + elidedText = f"{self.rawFilename[:idx]}...{self.rawFilename[-idx:]}" + else: + elidedText = self.rawFilename + return elidedText + + def updateFilename(self, idx): + chName = self.chNames_QLEs[idx].text() + chName = self.removeInvalidCharacters(chName) + if self.rawDataStruct == 2: + rawFilename = f"{self.rawFilename}_s{idx + 1}" + else: + rawFilename = self.rawFilename + + ext = "h5" if self.to_h5_radiobutton.isChecked() else "tif" + + rawFilename = self.elidedRawFilename() + + filenameLabel = self.filename_QLabels[idx] + if self.addImageName_QCB.isChecked(): + self.ImageName = self.removeInvalidCharacters(self.ImageName) + filename = f""" +

    + {rawFilename}_{self.ImageName}_{chName}.{ext} +

    + """ + fullFilename = f"{self.rawFilename}_{self.ImageName}_{chName}.{ext}" + else: + filename = f""" +

    + {rawFilename}_{chName}.{ext} +

    + """ + fullFilename = f"{self.rawFilename}_{chName}.{ext}" + filenameLabel.setToolTip(fullFilename) + filenameLabel.setText(filename) + + def checkChNames(self, text=""): + if self.sender() in self.chNames_QLEs: + idx = self.chNames_QLEs.index(self.sender()) + self.updateFilename(idx) + elif self.sender() in self.saveChannels_QCBs: + idx = self.saveChannels_QCBs.index(self.sender()) + self.updateFilename(idx) + + areChNamesValid = True + if len(self.chNames_QLEs) == 1: + LE1 = self.chNames_QLEs[0] + saveCh = self.saveChannels_QCBs[0].isChecked() + if not saveCh: + LE1.setStyleSheet("") + return areChNamesValid + + s1 = LE1.text() + if not s1: + self.setInvalidChName_StyleSheet(LE1) + areChNamesValid = False + else: + LE1.setStyleSheet("") + return areChNamesValid + + for LE1, LE2 in combinations(self.chNames_QLEs, 2): + s1 = LE1.text() + s2 = LE2.text() + LE1_idx = self.chNames_QLEs.index(LE1) + LE2_idx = self.chNames_QLEs.index(LE2) + saveCh1 = self.saveChannels_QCBs[LE1_idx].isChecked() + saveCh2 = self.saveChannels_QCBs[LE2_idx].isChecked() + if not s1 or not s2 or s1 == s2: + if not s1 and saveCh1: + self.setInvalidChName_StyleSheet(LE1) + areChNamesValid = False + else: + LE1.setStyleSheet("") + if not s2 and saveCh2: + self.setInvalidChName_StyleSheet(LE2) + areChNamesValid = False + else: + LE2.setStyleSheet("") + if s1 == s2 and saveCh1 and saveCh2: + self.setInvalidChName_StyleSheet(LE1) + self.setInvalidChName_StyleSheet(LE2) + areChNamesValid = False + else: + LE1.setStyleSheet("") + LE2.setStyleSheet("") + return areChNamesValid + + def hideShowTimeIncrement(self, value): + if self.TimeIncrement_DSB.isVisible() and value == 1: + self.readSampleImgDataAgain = True + + if not self.TimeIncrement_DSB.isVisible() and value > 1: + self.readSampleImgDataAgain = True + + if value > 1: + self.TimeIncrement_DSB.show() + self.TimeIncrementUnit_CB.show() + self.TimeIncrement_Label.show() + self.timeRangeToSaveWidget.show() + self.timeRangeToSaveWidget.label.show() + self.timeRangeToSaveWidget.setRange(1, value) + else: + self.TimeIncrement_DSB.hide() + self.TimeIncrementUnit_CB.hide() + self.TimeIncrement_Label.hide() + self.timeRangeToSaveWidget.hide() + self.timeRangeToSaveWidget.label.hide() + + def hideShowPhysicalSizeZ(self, value): + if value > 1: + self.PSZlabel.show() + self.PhysicalSizeZ_DSB.show() + self.PhysicalSizeZUnit_Label.show() + else: + self.PSZlabel.hide() + self.PhysicalSizeZ_DSB.hide() + self.PhysicalSizeZUnit_Label.hide() + self.readSampleImgDataAgain = True + + def updatePSUnit(self, unit): + self.PhysicalSizeYUnit_Label.setText(unit) + self.PhysicalSizeZUnit_Label.setText(unit) + + def warnRestart(self): + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = html_utils.paragraph(""" + Since you manually changed some of the metadata, this dialogue will now restart
    + because it needs to read the image data again.

    + Thank you for your patience. + """) + msg.warning(self, "Restart required", txt) + + def showChannelData(self, checked=False, idx=None): + if self.readSampleImgDataAgain: + # User changed SizeZ, SizeT, or SizeC --> we need to read sample + # image again + del self.sampleImgData + self.requestedReadingSampleImageDataAgain = True + self.sampleImgData = None + self.warnRestart() + self.getValues() + self.cancel = False + self.close() + return + + if idx is None: + idx = self.showChannelDataButtons.index(self.sender()) + dimsOrder = "ctz" + imgData = self.sampleImgData[dimsOrder][idx] + posData = myutils.utilClass() + posData.frame_i = 0 + sampleSizeT = 4 if self.SizeT_SB.value() >= 4 else self.SizeT_SB.value() + posData.SizeT = sampleSizeT + SizeZ = self.SizeZ_SB.value() + posData.SizeZ = 20 if SizeZ > 20 else SizeZ + posData.filename = f"{self.rawFilename}_C={idx}" + posData.segmInfo_df = pd.DataFrame( + { + "filename": [posData.filename] * sampleSizeT, + "frame_i": range(sampleSizeT), + "which_z_proj_gui": ["single z-slice"] * sampleSizeT, + "z_slice_used_gui": [int(posData.SizeZ / 2)] * sampleSizeT, + } + ).set_index(["filename", "frame_i"]) + path_li = os.path.normpath(self.rawFilePath).split(os.sep) + posData.relPath = f"{f'{os.sep}'.join(path_li[-3:1])}" + posData.relPath = f"{posData.relPath}{os.sep}{posData.filename}" + if sampleSizeT == 1: + posData.img_data = [imgData] # single frame data + else: + posData.img_data = imgData + + if self.imageViewer is not None: + self.imageViewer.close() + + self.imageViewer = imageViewer( + posData=posData, isSigleFrame=False, enableOverlay=False + ) + self.imageViewer.channelIndex = idx + self.imageViewer.update_img() + self.imageViewer.sigClosed.connect(self.imageViewerClosed) + self.imageViewer.show() + + def imageViewerClosed(self): + self.imageViewer = None + + def addRemoveChannels(self, value): + self.readSampleImgDataAgain = True + currentSizeC = len(self.chNames_QLEs) + DeltaChannels = abs(value - currentSizeC) + ext = "h5" if self.to_h5_radiobutton.isChecked() else "tif" + if value > currentSizeC: + for c in range(currentSizeC, currentSizeC + DeltaChannels): + chName_QLE = QLineEdit() + chName_QLE.setStyleSheet("") + chName_QLE.setAlignment(Qt.AlignCenter) + chName_QLE.setText(f"channel_{c}") + chName_QLE.textChanged.connect(self.checkChNames) + + txt = f"Channel {c} name: " + label = QLabel(txt) + + filenameDescLabel = QLabel(f"e.g., filename for channel {c}: ") + + chName = chName_QLE.text() + rawFilename = self.elidedRawFilename() + filenameLabel = QLabel(f""" +

    {rawFilename}_{chName}.{ext}

    + """) + filenameLabel.setToolTip(f"{self.rawFilename}_{chName}.{ext}") + + checkBox = QCheckBox("Save this channel") + checkBox.setChecked(True) + checkBox.stateChanged.connect(self.saveCh_checkBox_cb) + + self.channelNameLayouts[0].addWidget(label, alignment=Qt.AlignRight) + self.channelNameLayouts[0].addWidget( + filenameDescLabel, alignment=Qt.AlignRight + ) + self.channelNameLayouts[1].addWidget(chName_QLE) + self.channelNameLayouts[1].addWidget( + filenameLabel, alignment=Qt.AlignCenter + ) + + self.channelNameLayouts[2].addWidget(checkBox) + self.channelNameLayouts[2].addWidget(QLabel()) + + showChannelDataButton = QPushButton() + showChannelDataButton.setIcon(QIcon(":eye-plus.svg")) + showChannelDataButton.clicked.connect(self.showChannelData) + self.channelNameLayouts[3].addWidget(showChannelDataButton) + if self.sampleImgData is None: + showChannelDataButton.setDisabled(True) + + self.chNames_QLEs.append(chName_QLE) + self.saveChannels_QCBs.append(checkBox) + self.filename_QLabels.append(filenameLabel) + self.showChannelDataButtons.append(showChannelDataButton) + + emWavelen_DSB = QDoubleSpinBox() + emWavelen_DSB.setAlignment(Qt.AlignCenter) + emWavelen_DSB.setMaximum(2147483647.0) + emWavelen_DSB.setSingleStep(0.001) + emWavelen_DSB.setDecimals(2) + emWavelen_DSB.setValue(500.0) + unit = QLabel("nm") + unit.setStyleSheet("font-size:13px; padding:5px 0px 2px 0px;") + + txt = f"Channel {c} emission wavelength: " + label = QLabel(txt) + self.channelEmWLayouts[0].addWidget(label, alignment=Qt.AlignRight) + self.channelEmWLayouts[1].addWidget(emWavelen_DSB) + self.channelEmWLayouts[2].addWidget(unit) + self.emWavelens_DSBs.append(emWavelen_DSB) + else: + for c in range(currentSizeC, currentSizeC + DeltaChannels): + idx = (c - 1) * 2 + label1 = self.channelNameLayouts[0].itemAt(idx).widget() + label2 = self.channelNameLayouts[0].itemAt(idx + 1).widget() + chName_QLE = self.channelNameLayouts[1].itemAt(idx).widget() + filename_L = self.channelNameLayouts[1].itemAt(idx + 1).widget() + checkBox = self.channelNameLayouts[2].itemAt(idx).widget() + dummyLabel = self.channelNameLayouts[2].itemAt(idx + 1).widget() + showButton = self.showChannelDataButtons[-1] + showButton.clicked.disconnect() + + self.channelNameLayouts[0].removeWidget(label1) + self.channelNameLayouts[0].removeWidget(label2) + self.channelNameLayouts[1].removeWidget(chName_QLE) + self.channelNameLayouts[1].removeWidget(filename_L) + self.channelNameLayouts[2].removeWidget(checkBox) + self.channelNameLayouts[2].removeWidget(dummyLabel) + self.channelNameLayouts[3].removeWidget(showButton) + + self.chNames_QLEs.pop(-1) + self.saveChannels_QCBs.pop(-1) + self.filename_QLabels.pop(-1) + self.showChannelDataButtons.pop(-1) + + label = self.channelEmWLayouts[0].itemAt(c - 1).widget() + emWavelen_DSB = self.channelEmWLayouts[1].itemAt(c - 1).widget() + unit = self.channelEmWLayouts[2].itemAt(c - 1).widget() + self.channelEmWLayouts[0].removeWidget(label) + self.channelEmWLayouts[1].removeWidget(emWavelen_DSB) + self.channelEmWLayouts[2].removeWidget(unit) + self.emWavelens_DSBs.pop(-1) + + self.adjustSize() + + def ok_cb(self, event): + areChNamesValid = self.checkChNames() + if not areChNamesValid: + err_msg = html_utils.paragraph( + "Channel names cannot be empty or equal to each other." + "

    " + "Insert a unique text for each channel name." + ) + msg = widgets.myMessageBox() + msg.critical(self, "Invalid channel names", err_msg) + return + + self.getValues() + self.convertUnits() + + if self.sender() == self.trustButton: + self.trust = True + elif self.sender() == self.overWriteButton: + self.overWrite = True + + self.cancel = False + self.close() + + def getValues(self): + self.LensNA = self.LensNA_DSB.value() + self.SizeT = self.SizeT_SB.value() + self.SizeZ = self.SizeZ_SB.value() + self.SizeC = self.SizeC_SB.value() + self.SizeS = self.SizeS_SB.value() + self.timeRangeToSave = self.timeRangeToSaveWidget.range() + self.TimeIncrement = self.TimeIncrement_DSB.value() + self.PhysicalSizeX = self.PhysicalSizeX_DSB.value() + self.PhysicalSizeY = self.PhysicalSizeY_DSB.value() + self.PhysicalSizeZ = self.PhysicalSizeZ_DSB.value() + self.to_h5 = self.to_h5_radiobutton.isChecked() + if hasattr(self, "posSelector"): + self.selectedPos = self.posSelector.selectedItemsText() + else: + self.selectedPos = ["All Positions"] + self.chNames = [] + if hasattr(self, "addImageName_QCB"): + self.addImageName = self.addImageName_QCB.isChecked() + else: + self.addImageName = False + self.saveChannels = [] + for LE, QCB in zip(self.chNames_QLEs, self.saveChannels_QCBs): + s = LE.text() + s = "".join(c if c.isalnum() or c == "_" or c == "" else "_" for c in s) + trim_ = s.endswith("_") + while trim_: + s = s[:-1] + trim_ = s.endswith("_") + self.chNames.append(s) + self.saveChannels.append(QCB.isChecked()) + self.emWavelens = [DSB.value() for DSB in self.emWavelens_DSBs] + + def convertUnits(self): + timeUnit = self.TimeIncrementUnit_CB.currentText() + if timeUnit == "ms": + self.TimeIncrement /= 1000 + elif timeUnit == "minutes": + self.TimeIncrement *= 60 + elif timeUnit == "hours": + self.TimeIncrement *= 3600 + + PhysicalSizeUnit = self.PhysicalSizeUnit_CB.currentText() + if timeUnit == "nm": + self.PhysicalSizeX /= 1000 + self.PhysicalSizeY /= 1000 + self.PhysicalSizeZ /= 1000 + elif timeUnit == "mm": + self.PhysicalSizeX *= 1000 + self.PhysicalSizeY *= 1000 + self.PhysicalSizeZ *= 1000 + elif timeUnit == "cm": + self.PhysicalSizeX *= 1e4 + self.PhysicalSizeY *= 1e4 + self.PhysicalSizeZ *= 1e4 + + def cancel_cb(self, event): + self.cancel = True + self.close() + + def exec_(self): + self.show(block=True) + + def setSize(self): + h = self.SizeS_SB.height() + self.TimeIncrement_DSB.setMinimumHeight(h) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + self.setSize() + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class MultiTimePointFilePattern(QBaseDialog): + def __init__(self, fileName, folderPath, readPatternFunc=None, parent=None): + super().__init__(parent) + + self.setWindowTitle("File name pattern") + self.cancel = True + self.additionalChannelWidgets = {} + + mainLayout = QVBoxLayout() + self.readPatternFunc = readPatternFunc + + infoText = html_utils.paragraph(""" + The image files for each time-point must be named with the following pattern:

    + position_channel_timepoint +

    + For example a file with name "pos1_GFP_1.tif" would be the first time-point of the channell GFP
    + and position called pos1.

    + The Position number will be determined by alphabetically sorting + all the image files.

    + Please, provide the channel names below. + Optionally, you can provide a basename
    + that will be pre-pended to the name of all created files.

    + You can also provide a folder path containing the segmentation masks file.
    + These files MUST be named exactly as the raw files. +
    + """) + + noteLayout = QHBoxLayout() + noteText = html_utils.paragraph(""" + Channels do not need to have the same number of frames, + however, Cell-ACDC will place
    + the frames at the right frame number + (given by timepoint number at the end
    + of the filename) and it will fill missing frames with zeros. + """) + noteLayout.addWidget( + QLabel(html_utils.to_admonition(noteText)), + # alignment=(Qt.AlignTop | Qt.AlignRight) + ) + + mainLayout.addWidget(QLabel(infoText)) + mainLayout.addLayout(noteLayout) + noteLayout.setStretch(0, 0) + noteLayout.setStretch(1, 1) + + label = QLabel( + html_utils.paragraph(f"Sample file name: {fileName}") + ) + mainLayout.addWidget(label, alignment=Qt.AlignCenter) + mainLayout.addSpacing(5) + + channelName = "" + posName = "" + frameNumber = None + if readPatternFunc is not None: + posName, frameNumber, channelName = readPatternFunc(fileName) + + formLayout = QGridLayout() + + ncols = 3 + self.vLayouts = [QVBoxLayout() for _ in range(ncols)] + for j, l in enumerate(self.vLayouts): + formLayout.addLayout(l, 0, j) + + row = 0 + items = QLabel("Position name: "), widgets.ReadOnlyLineEdit(), QLabel() + label, self.posNameEntry, button = items + self.posNameEntry.setAlignment(Qt.AlignCenter) + self.posNameEntry.setText(str(posName)) + for j, w in enumerate(items): + self.vLayouts[j].addWidget(w) + + row += 1 + items = (QLabel("Frame number name: "), widgets.ReadOnlyLineEdit(), QLabel()) + self.frameNumberEntry = items[1] + self.frameNumberEntry.setText(str(frameNumber)) + self.frameNumberEntry.setAlignment(Qt.AlignCenter) + for j, w in enumerate(items): + self.vLayouts[j].addWidget(w) + + row += 1 + self.channelNameLE = widgets.alphaNumericLineEdit() + items = ( + QLabel("Channel_1 name: "), + self.channelNameLE, + widgets.addPushButton(" Add channel"), + ) + self.addChannelButton = items[2] + self.addChannelButton._row = row + self.channelNameLE.setAlignment(Qt.AlignCenter) + self.channelNameLE.setText(channelName) + for j, w in enumerate(items): + self.vLayouts[j].addWidget(w) + + row += 1 + items = ( + QLabel("Basename (optional): "), + widgets.alphaNumericLineEdit(), + QLabel(), + ) + label, self.baseNameLE, button = items + self.baseNameLE.setAlignment(Qt.AlignCenter) + for j, w in enumerate(items): + self.vLayouts[j].addWidget(w) + + row += 1 + items = QLabel("File will be saved as: "), QLineEdit(), QLabel() + label, self.relPathEntry, button = items + self.relPathEntry.setAlignment(Qt.AlignCenter) + for j, w in enumerate(items): + self.vLayouts[j].addWidget(w) + + row += 1 + items = ( + QLabel("Segmentation masks folder path: "), + widgets.ElidingLineEdit(), + widgets.browseFileButton( + "Browse...", + title="Select folder containing segmentation masks", + start_dir=folderPath, + openFolder=True, + ), + ) + label, self.segmFolderPathEntry, button = items + button.sigPathSelected.connect(self.segmFolderpathSelected) + self.segmFolderPathEntry.setAlignment(Qt.AlignCenter) + for j, w in enumerate(items): + self.vLayouts[j].addWidget(w) + + self.formLayout = formLayout + + self.updateRelativePath() + + self.channelNameLE.textChanged.connect(self.updateRelativePath) + self.baseNameLE.textChanged.connect(self.updateRelativePath) + self.addChannelButton.clicked.connect(self.addChannel) + + mainLayout.addLayout(formLayout) + + buttonsLayout = widgets.CancelOkButtonsLayout() + showInFileManagerButton = widgets.showInFileManagerButton( + myutils.get_open_filemaneger_os_string() + ) + buttonsLayout.insertWidget(3, showInFileManagerButton) + func = partial(myutils.showInExplorer, folderPath) + showInFileManagerButton.clicked.connect(func) + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + mainLayout.addStretch() + + self.setLayout(mainLayout) + + self.setFont(font) + + def segmFolderpathSelected(self, path): + self.segmFolderPathEntry.setText(path) + + def addChannel(self): + self.addChannelButton._row += 1 + row = self.addChannelButton._row + + channel_idx = len(self.additionalChannelWidgets) + items = ( + QLabel(f"Channel_{channel_idx + 1} name: "), + widgets.alphaNumericLineEdit(), + widgets.subtractPushButton("Remove channel"), + ) + label, lineEdit, button = items + lineEdit.setAlignment(Qt.AlignCenter) + button.clicked.connect(self.removeChannel) + button._row = row + for j, w in enumerate(items): + self.vLayouts[j].insertWidget(row, w) + + self.additionalChannelWidgets[row] = items + lineEdit.setFocus() + + def removeChannel(self): + row = self.sender()._row + for j, w in enumerate(self.additionalChannelWidgets[row]): + self.vLayouts[j].removeWidget(w) + + self.additionalChannelWidgets.pop(row) + self.addChannelButton._row -= 1 + + def checkChannelNames(self): + allChannels = [self.channelNameLE.text()] + allChannels.extend( + [w[1].text() for w in self.additionalChannelWidgets.values()] + ) + for ch1, ch2 in combinations(allChannels, 2): + if ch1 == ch2: + break + if not ch1 or not ch2: + break + else: + # Channel names are fine + return allChannels + + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + txt = html_utils.paragraph(""" + Some channel names are empty or not different from each other. + """) + msg.critical(self, "Select two or more items", txt) + return None + + def updateRelativePath(self, text=""): + posName = self.posNameEntry.text() + frameNumber = self.frameNumberEntry.text() + channelName = self.channelNameLE.text() + basename = self.baseNameLE.text() + if basename: + filename = f"{basename}_{posName}_{channelName}.tif" + else: + filename = f"{posName}_{channelName}.tif" + relPath = f"...{os.sep}Position_1{os.sep}Images{os.sep}{filename}" + self.relPathEntry.setText(relPath) + + def ok_cb(self): + allChannels = self.checkChannelNames() + if allChannels is None: + return + self.allChannels = allChannels + self.basename = self.baseNameLE.text() + self.segmFolderPath = self.segmFolderPathEntry.text() + self.cancel = False + self.close() + + def showEvent(self, event) -> None: + self.channelNameLE.setFocus() + + +class OrderableListWidgetDialog(QBaseDialog): + def __init__( + self, items, title="Select items", infoTxt="", helpText="", parent=None + ): + super().__init__(parent) + + self.selectedItemsText = [] + + self.cancel = True + self.setWindowTitle(title) + + mainLayout = QVBoxLayout() + self.helpText = helpText + + if infoTxt: + mainLayout.addWidget(QLabel(html_utils.paragraph(infoTxt))) + + self.listWidget = widgets.OrderableList() + self.listWidget.addItems(items) + + buttonsLayout = widgets.CancelOkButtonsLayout() + if helpText: + helpButton = widgets.helpPushButton("Help...") + buttonsLayout.insertWidget(3, helpButton) + helpButton.clicked.connect(self.showHelp) + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addWidget(self.listWidget) + mainLayout.addSpacing(10) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def showHelp(self): + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = html_utils.paragraph(self.helpText) + msg.information(self, "Select tables help", txt) + + def ok_cb(self): + self.cancel = False + self.selectedItemsText = [None] * len(self.listWidget.selectedItems()) + for itemW in self.listWidget.selectedItems(): + idx = int(itemW._nrWidget.currentText()) - 1 + if idx >= len(self.selectedItemsText): + idx = len(self.selectedItemsText) - 1 + self.selectedItemsText[idx] = itemW._text + self.close() + + +class QDialogAppendTextFilename(QDialog): + def __init__(self, filename, ext, parent=None, font=None): + super().__init__(parent) + self.cancel = True + filenameNOext, _ = os.path.splitext(filename) + self.filenameNOext = filenameNOext + if ext.find(".") == -1: + ext = f".{ext}" + self.ext = ext + + self.setWindowTitle("Append text to file name") + + mainLayout = QVBoxLayout() + formLayout = QFormLayout() + buttonsLayout = QHBoxLayout() + + if font is not None: + self.setFont(font) + + self.LE = QLineEdit() + self.LE.setAlignment(Qt.AlignCenter) + formLayout.addRow("Appended text", self.LE) + self.LE.textChanged.connect(self.updateFinalFilename) + + self.finalName_label = QLabel(f'Final file name: "{filenameNOext}_{ext}"') + # padding: top, left, bottom, right + self.finalName_label.setStyleSheet("font-size:13px; padding:5px 0px 0px 0px;") + + okButton = widgets.okPushButton("Ok") + okButton.setShortcut(Qt.Key_Enter) + + cancelButton = widgets.cancelPushButton("Cancel") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(okButton) + + buttonsLayout.setContentsMargins(0, 10, 0, 0) + + mainLayout.addLayout(formLayout) + mainLayout.addWidget(self.finalName_label, alignment=Qt.AlignCenter) + mainLayout.addLayout(buttonsLayout) + + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + + self.formLayout = formLayout + + self.setLayout(mainLayout) + # self.setModal(True) + + def updateFinalFilename(self, text): + finalFilename = f"{self.filenameNOext}_{text}{self.ext}" + self.finalName_label.setText(f'Final file name: "{finalFilename}"') + + def ok_cb(self, event): + if not self.LE.text(): + err_msg = "Appended name cannot be empty!" + msg = QMessageBox() + msg.critical(self, "Empty name", err_msg, msg.Ok) + return + self.cancel = False + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class QDialogEntriesWidget(QDialog): + def __init__( + self, entriesLabels, defaultTxts, winTitle="Input", parent=None, font=None + ): + self.cancel = True + self.entriesTxt = [] + self.entriesLabels = entriesLabels + self.QLEs = [] + super().__init__(parent) + self.setWindowTitle(winTitle) + + mainLayout = QVBoxLayout() + formLayout = QFormLayout() + buttonsLayout = QHBoxLayout() + + if font is not None: + self.setFont(font) + + for label, txt in zip(entriesLabels, defaultTxts): + LE = QLineEdit() + LE.setAlignment(Qt.AlignCenter) + LE.setText(txt) + formLayout.addRow(label, LE) + self.QLEs.append(LE) + + okButton = widgets.okPushButton("Ok") + okButton.setShortcut(Qt.Key_Enter) + + cancelButton = widgets.cancelPushButton("Cancel") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(okButton) + + buttonsLayout.setContentsMargins(0, 10, 0, 0) + + mainLayout.addLayout(formLayout) + mainLayout.addLayout(buttonsLayout) + + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + + self.formLayout = formLayout + + self.setLayout(mainLayout) + # self.setModal(True) + + def ok_cb(self, event): + self.cancel = False + self.entriesTxt = [ + self.formLayout.itemAt(i, 1).widget().text() + for i in range(len(self.entriesLabels)) + ] + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class QDialogMetadata(QDialog): + def __init__( + self, + SizeT, + SizeZ, + TimeIncrement, + PhysicalSizeZ, + PhysicalSizeY, + PhysicalSizeX, + ask_SizeT, + ask_TimeIncrement, + ask_PhysicalSizes, + parent=None, + font=None, + imgDataShape=None, + posData=None, + singlePos=False, + askSegm3D=True, + additionalValues=None, + forceEnableAskSegm3D=False, + SizeT_metadata=None, + SizeZ_metadata=None, + basename="", + ): + self.cancel = True + self.ask_TimeIncrement = ask_TimeIncrement + self.ask_PhysicalSizes = ask_PhysicalSizes + self.askSegm3D = askSegm3D + self.imgDataShape = imgDataShape + self.posData = posData + self._additionalValues = additionalValues + self.SizeT_metadata = SizeT_metadata + self.SizeZ_metadata = SizeZ_metadata + super().__init__(parent) + self.setWindowTitle("Image properties") + + mainLayout = QVBoxLayout() + gridLayout = QGridLayout() + # formLayout = QFormLayout() + buttonsLayout = QGridLayout() + + if imgDataShape is not None: + label = QLabel( + html_utils.paragraph( + f"Image data shape = {imgDataShape}
    " + ) + ) + mainLayout.addWidget(label, alignment=Qt.AlignCenter) + + row = 0 + self.basenameLineEdit = None + if basename: + gridLayout.addWidget( + QLabel("Basename (read-only)"), row, 0, alignment=Qt.AlignRight + ) + self.basenameLineEdit = QLineEdit() + self.basenameLineEdit.setReadOnly(True) + self.basenameLineEdit.setText(basename) + minWidth = ( + self.basenameLineEdit.fontMetrics().boundingRect(basename).width() + 10 + ) + self.basenameLineEdit.setMinimumWidth(minWidth) + self.basenameLineEdit.setAlignment(Qt.AlignCenter) + gridLayout.addWidget(self.basenameLineEdit, row, 1) + row += 1 + + gridLayout.addWidget( + QLabel("Number of frames (SizeT)"), row, 0, alignment=Qt.AlignRight + ) + self.SizeT_SpinBox = QSpinBox() + self.SizeT_SpinBox.setMinimum(1) + self.SizeT_SpinBox.setMaximum(2147483647) + SizeTinfoButton = widgets.infoPushButton() + self.allowEditSizeTcheckbox = QCheckBox("Let me edit it") + if ask_SizeT: + self.SizeT_SpinBox.setValue(SizeT) + SizeTinfoButton.hide() + self.allowEditSizeTcheckbox.hide() + else: + self.SizeT_SpinBox.setValue(1) + self.SizeT_SpinBox.setDisabled(True) + SizeTinfoButton.show() + SizeTinfoButton.clicked.connect(self.showWhySizeTisGrayed) + self.allowEditSizeTcheckbox.show() + self.allowEditSizeTcheckbox.toggled.connect(self.allowEditSizeT) + self.SizeT_SpinBox.setAlignment(Qt.AlignCenter) + self.SizeT_SpinBox.valueChanged.connect(self.TimeIncrementShowHide) + gridLayout.addWidget(self.SizeT_SpinBox, row, 1) + gridLayout.addWidget(SizeTinfoButton, row, 2) + gridLayout.setColumnStretch(2, 0) + gridLayout.addWidget(self.allowEditSizeTcheckbox, row, 3) + gridLayout.setColumnStretch(3, 0) + + row += 1 + gridLayout.addWidget( + QLabel("Number of z-slices (SizeZ)"), row, 0, alignment=Qt.AlignRight + ) + self.SizeZ_SpinBox = QSpinBox() + self.SizeZ_SpinBox.setMinimum(1) + self.SizeZ_SpinBox.setMaximum(2147483647) + self.SizeZ_SpinBox.setValue(SizeZ) + self.SizeZ_SpinBox.setAlignment(Qt.AlignCenter) + self.SizeZ_SpinBox.valueChanged.connect(self.SizeZvalueChanged) + gridLayout.addWidget(self.SizeZ_SpinBox, row, 1) + + row += 1 + self.TimeIncrementLabel = QLabel("Time interval (s)") + gridLayout.addWidget(self.TimeIncrementLabel, row, 0, alignment=Qt.AlignRight) + self.TimeIncrementSpinBox = widgets.FloatLineEdit() + self.TimeIncrementSpinBox.setValue(TimeIncrement) + gridLayout.addWidget(self.TimeIncrementSpinBox, row, 1) + + if SizeT == 1 or not ask_TimeIncrement: + self.TimeIncrementSpinBox.hide() + self.TimeIncrementLabel.hide() + + row += 1 + self.PhysicalSizeZLabel = QLabel("Physical Size Z (um/pixel)") + gridLayout.addWidget(self.PhysicalSizeZLabel, row, 0, alignment=Qt.AlignRight) + self.PhysicalSizeZSpinBox = widgets.FloatLineEdit() + self.PhysicalSizeZSpinBox.setValue(PhysicalSizeZ) + gridLayout.addWidget(self.PhysicalSizeZSpinBox, row, 1) + + if SizeZ == 1 or not ask_PhysicalSizes: + self.PhysicalSizeZSpinBox.hide() + self.PhysicalSizeZLabel.hide() + + row += 1 + self.PhysicalSizeYLabel = QLabel("Physical Size Y (um/pixel)") + gridLayout.addWidget(self.PhysicalSizeYLabel, row, 0, alignment=Qt.AlignRight) + self.PhysicalSizeYSpinBox = widgets.FloatLineEdit() + self.PhysicalSizeYSpinBox.setValue(PhysicalSizeY) + gridLayout.addWidget(self.PhysicalSizeYSpinBox, row, 1) + + if not ask_PhysicalSizes: + self.PhysicalSizeYSpinBox.hide() + self.PhysicalSizeYLabel.hide() + + row += 1 + self.PhysicalSizeXLabel = QLabel("Physical Size X (um/pixel)") + gridLayout.addWidget(self.PhysicalSizeXLabel, row, 0, alignment=Qt.AlignRight) + self.PhysicalSizeXSpinBox = widgets.FloatLineEdit() + self.PhysicalSizeXSpinBox.setValue(PhysicalSizeX) + gridLayout.addWidget(self.PhysicalSizeXSpinBox, row, 1) + + if not ask_PhysicalSizes: + self.PhysicalSizeXSpinBox.hide() + self.PhysicalSizeXLabel.hide() + + row += 1 + self.isSegm3Dtoggle = widgets.Toggle() + if posData is not None: + self.isSegm3Dtoggle.setChecked(posData.getIsSegm3D()) + disableToggle = ( + # Disable toggle if not force enable and if + # segm data was found (we cannot change the shape of + # loaded segmentation in the GUI) + posData.segmFound is not None + and posData.segmFound + and not forceEnableAskSegm3D + ) + if disableToggle: + self.isSegm3Dtoggle.setDisabled(True) + self.isSegm3DLabel = QLabel("Work with 3D segmentation masks (z-stack)") + gridLayout.addWidget(self.isSegm3DLabel, row, 0, alignment=Qt.AlignRight) + gridLayout.addWidget(self.isSegm3Dtoggle, row, 1, alignment=Qt.AlignCenter) + self.infoButtonSegm3D = QPushButton(self) + self.infoButtonSegm3D.setCursor(Qt.WhatsThisCursor) + self.infoButtonSegm3D.setIcon(QIcon(":info.svg")) + gridLayout.addWidget(self.infoButtonSegm3D, row, 2, alignment=Qt.AlignLeft) + self.infoButtonSegm3D.clicked.connect(self.infoSegm3D) + if SizeZ == 1 or not askSegm3D: + self.isSegm3DLabel.hide() + self.isSegm3Dtoggle.hide() + self.infoButtonSegm3D.hide() + + self.SizeZvalueChanged(SizeZ) + + self.additionalFieldsWidgets = [] + addFieldButton = widgets.addPushButton("Add custom field") + addFieldInfoButton = widgets.infoPushButton() + addFieldInfoButton.clicked.connect(self.showAddFieldInfo) + addFieldButton.clicked.connect(self.addField) + addFieldLayout = QHBoxLayout() + addFieldLayout.addStretch(1) + addFieldLayout.addWidget(addFieldButton) + addFieldLayout.addWidget(addFieldInfoButton) + addFieldLayout.addStretch(1) + + if singlePos: + okTxt = "Apply only to this Position" + else: + okTxt = "Ok for loaded Positions" + okButton = widgets.okPushButton(okTxt) + okButton.setToolTip("Save metadata only for current positionh") + okButton.setShortcut(Qt.Key_Enter) + self.okButton = okButton + + if ask_TimeIncrement or ask_PhysicalSizes: + okAllButton = QPushButton("Apply to ALL Positions") + okAllButton.setToolTip( + "Update existing Physical Sizes, Time interval, cell volume (fl), " + "cell area (um^2), and time (s) for all the positions " + "in the experiment folder." + ) + self.okAllButton = okAllButton + + selectButton = QPushButton("Select the Positions to be updated") + selectButton.setToolTip( + "Ask to select positions then update existing Physical Sizes, " + "Time interval, cell volume (fl), cell area (um^2), and time (s)" + "for selected positions." + ) + self.selectButton = selectButton + else: + self.okAllButton = None + self.selectButton = None + okButton.setText("Ok") + + cancelButton = widgets.cancelPushButton("Cancel") + + buttonsLayout.setColumnStretch(0, 1) + buttonsLayout.addWidget(okButton, 0, 1) + if ask_TimeIncrement or ask_PhysicalSizes: + buttonsLayout.addWidget(okAllButton, 0, 2) + buttonsLayout.addWidget(selectButton, 1, 1) + buttonsLayout.addWidget(cancelButton, 1, 2) + else: + buttonsLayout.addWidget(cancelButton, 0, 2) + buttonsLayout.setColumnStretch(3, 1) + + gridLayout.setColumnMinimumWidth(1, 100) + mainLayout.addLayout(gridLayout) + mainLayout.addSpacing(10) + mainLayout.addLayout(addFieldLayout) + # mainLayout.addLayout(formLayout) + mainLayout.addSpacing(20) + mainLayout.addStretch(1) + mainLayout.addLayout(buttonsLayout) + self.mainLayout = mainLayout + + okButton.clicked.connect(self.ok_cb) + if ask_TimeIncrement or ask_PhysicalSizes: + okAllButton.clicked.connect(self.ok_cb) + selectButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.cancel_cb) + + self.addAdditionalValues(additionalValues) + + self.setLayout(mainLayout) + self.setFont(font) + # self.setModal(True) + + def showWhySizeTisGrayed(self): + txt = html_utils.paragraph(f""" + The "Number of frames" field is grayed-out because you loaded multiple Positions.

    + Cell-ACDC cannot load multiple time-lapse Positions, + so it is assuming you are loading NON time-lapse data.

    + To load time-lapse data, load one Position at a time.

    + Note that you can still edit the number of frames if you need to correct it.
    + However, you can only edit the metadata, then the loading process will be stopped. + """) + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + msg.information(self, "Why is the number of frames grayed out?", txt) + + def addAdditionalValues(self, values): + if values is None: + return + + for i, (name, value) in enumerate(values.items()): + self.addField() + nameWidget = self.additionalFieldsWidgets[i]["nameWidget"] + valueWidget = self.additionalFieldsWidgets[i]["valueWidget"] + nameWidget.setText(str(name).strip("__")) + valueWidget.setText(str(value)) + + def addField(self): + nameWidget = QLineEdit() + nameWidget.setAlignment(Qt.AlignCenter) + valueWidget = QLineEdit() + valueWidget.setAlignment(Qt.AlignCenter) + removeButton = widgets.delPushButton() + + fieldLayout = QGridLayout() + fieldLayout.addWidget(QLabel("Name"), 0, 0) + fieldLayout.addWidget(nameWidget, 1, 0) + fieldLayout.addWidget(QLabel("Value"), 0, 1) + fieldLayout.addWidget(valueWidget, 1, 1) + fieldLayout.addWidget(removeButton, 1, 2) + + self.additionalFieldsWidgets.append( + { + "nameWidget": nameWidget, + "valueWidget": valueWidget, + "removeButton": removeButton, + "layout": fieldLayout, + } + ) + + idx = len(self.additionalFieldsWidgets) - 1 + removeButton.clicked.connect(partial(self.removeField, idx)) + + row = self.mainLayout.count() - 3 + self.mainLayout.insertLayout(row, fieldLayout) + + def removeField(self, idx): + widgets = self.additionalFieldsWidgets[idx] + + layoutToRemove = widgets["layout"] + for row in range(layoutToRemove.rowCount()): + for col in range(layoutToRemove.columnCount()): + item = layoutToRemove.itemAtPosition(row, col) + if item is not None: + widget = item.widget() + layoutToRemove.removeWidget(widget) + + self.additionalFieldsWidgets.pop(idx) + + self.mainLayout.removeItem(layoutToRemove) + + def showAddFieldInfo(self): + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + Add a field (name and value) that will be saved to the + metadata.csv file and as a column in the + acdc_output.csv table.

    + Example: a strain name or the replicate number. + """) + msg.information(self, "Add field info", txt) + + def infoSegm3D(self): + txt = ( + "Cell-ACDC supports both 2D and 3D segmentation. If your data " + "also have a time dimension, then you can choose to segment " + "a specific z-slice (2D segmentation mask per frame) or all of them " + "(3D segmentation mask per frame)

    " + "In any case, if you choose to activate 3D segmentation then the " + "segmentation mask will have the same number of z-slices " + "of the image data.

    " + "Additionally, in the model parameters window, you will be able " + "to choose if you want to segment the entire 3D volume at once " + "or use the 2D model on each z-slice, one by one.

    " + "NOTE: if the toggle is disabled it means you already " + "loaded segmentation data and the shape cannot be changed now.
    " + "if you need to start with a blank segmentation, " + 'use the "Create a new segmentation file" button instead of the ' + '"Load folder" button.' + "
    " + ) + msg = widgets.myMessageBox() + msg.setIcon() + msg.setWindowTitle(f"3D segmentation info") + msg.addText(html_utils.paragraph(txt)) + msg.addButton(" Ok ") + msg.exec_() + + def SizeZvalueChanged(self, val): + if len(self.imgDataShape) < 3: + return + + if val > 1 and self.imgDataShape is not None: + maxSizeZ = self.imgDataShape[-3] + self.SizeZ_SpinBox.setMaximum(maxSizeZ) + else: + self.SizeZ_SpinBox.setMaximum(2147483647) + + if val > 1: + if self.ask_PhysicalSizes: + self.PhysicalSizeZSpinBox.show() + self.PhysicalSizeZLabel.show() + if self.askSegm3D: + self.isSegm3DLabel.show() + self.isSegm3Dtoggle.show() + self.infoButtonSegm3D.show() + else: + self.PhysicalSizeZSpinBox.hide() + self.PhysicalSizeZLabel.hide() + self.isSegm3DLabel.hide() + self.isSegm3Dtoggle.hide() + self.infoButtonSegm3D.hide() + + self.checkSegmDataShape() + + def checkSegmDataShape(self): + if self.posData is None: + return + + if self.isSegm3Dtoggle.isEnabled(): + return + + SizeT = self.SizeT_SpinBox.value() + SizeZ = self.SizeZ_SpinBox.value() + segm_data_ndim = self.posData.segm_data.ndim + isSegm3D = False + if segm_data_ndim == 4: + # Segm data is 4D so it must be 3D over time + isSegm3D = True + elif segm_data_ndim == 3 and SizeZ > 1 and SizeT == 1: + # Segm data is 3D while SizeT == 1 and SizeZ > 1 + # --> also segm is 3D z-stack + isSegm3D = True + + self.isSegm3Dtoggle.setDisabled(False) + self.isSegm3Dtoggle.setChecked(isSegm3D) + self.isSegm3Dtoggle.setDisabled(True) + + def TimeIncrementShowHide(self, val): + self.checkSegmDataShape() + if not self.ask_TimeIncrement: + return + + if val > 1: + self.TimeIncrementSpinBox.show() + self.TimeIncrementLabel.show() + else: + self.TimeIncrementSpinBox.hide() + self.TimeIncrementLabel.hide() + + def allowEditSizeT(self, checked): + if checked: + self.SizeT_SpinBox.setDisabled(False) + if self.SizeT_metadata is not None: + self.SizeT_SpinBox.setValue(self.SizeT_metadata) + else: + self.SizeT_SpinBox.setDisabled(True) + self.SizeT_SpinBox.setValue(1) + + def warnEditingMetadata(self, Size, Size_metadata, which_dim): + txt = html_utils.paragraph(f""" + The number of {which_dim} in the saved metadata is {Size_metadata}, + but you are requesting to change it to {Size}.

    + Are you sure you want to proceed? + """) + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + _, noButton, yesButton = msg.warning( + self, + "WARNING: Edinting saved metadata", + txt, + buttonsTexts=("Cancel", "No", "Yes, edit the metadata"), + ) + return msg.clickedButton == yesButton + + def ok_cb(self, checked=False): + self.cancel = False + self.SizeT = self.SizeT_SpinBox.value() + self.SizeZ = self.SizeZ_SpinBox.value() + + if self.SizeT_metadata is not None: + if self.SizeT != self.SizeT_metadata: + proceed = self.warnEditingMetadata( + self.SizeT, self.SizeT_metadata, "frames" + ) + if not proceed: + return + + if self.SizeZ_metadata is not None: + if self.SizeZ != self.SizeZ_metadata: + proceed = self.warnEditingMetadata( + self.SizeZ, self.SizeZ_metadata, "z-slices" + ) + if not proceed: + return + + self.isSegm3D = self.isSegm3Dtoggle.isChecked() + + self.TimeIncrement = self.TimeIncrementSpinBox.value() + self.PhysicalSizeX = self.PhysicalSizeXSpinBox.value() + self.PhysicalSizeY = self.PhysicalSizeYSpinBox.value() + self.PhysicalSizeZ = self.PhysicalSizeZSpinBox.value() + self._additionalValues = { + f"__{field['nameWidget'].text()}": field["valueWidget"].text() + for field in self.additionalFieldsWidgets + } + proceed = self.checkShapeMismatchMetadata() + if not proceed: + return + + if self.posData is not None and self.sender() != self.okButton: + exp_path = self.posData.exp_path + pos_foldernames = myutils.get_pos_foldernames(exp_path) + if self.sender() == self.selectButton: + select_folder = load.select_exp_folder() + select_folder.pos_foldernames = pos_foldernames + select_folder.QtPrompt( + self, pos_foldernames, allow_cancel=False, toggleMulti=True + ) + pos_foldernames = select_folder.selected_pos + for pos in pos_foldernames: + images_path = os.path.join(exp_path, pos, "Images") + ls = myutils.listdir(images_path) + search = [file for file in ls if file.find("metadata.csv") != -1] + metadata_df = None + if search: + fileName = search[0] + metadata_csv_path = os.path.join(images_path, fileName) + metadata_df = pd.read_csv(metadata_csv_path).set_index( + "Description" + ) + if metadata_df is not None: + metadata_df.at["TimeIncrement", "values"] = self.TimeIncrement + metadata_df.at["PhysicalSizeZ", "values"] = self.PhysicalSizeZ + metadata_df.at["PhysicalSizeY", "values"] = self.PhysicalSizeY + metadata_df.at["PhysicalSizeX", "values"] = self.PhysicalSizeX + metadata_df.to_csv(metadata_csv_path) + + search = [file for file in ls if file.find("acdc_output.csv") != -1] + acdc_df = None + if search: + fileName = search[0] + acdc_df_path = os.path.join(images_path, fileName) + acdc_df = pd.read_csv(acdc_df_path) + yx_pxl_to_um2 = self.PhysicalSizeY * self.PhysicalSizeX + vox_to_fl = self.PhysicalSizeY * (self.PhysicalSizeX**2) + if "cell_vol_fl" not in acdc_df.columns: + continue + acdc_df["cell_vol_fl"] = acdc_df["cell_vol_vox"] * vox_to_fl + acdc_df["cell_area_um2"] = acdc_df["cell_area_pxl"] * yx_pxl_to_um2 + acdc_df["time_seconds"] = acdc_df["frame_i"] * self.TimeIncrement + try: + acdc_df.to_csv(acdc_df_path, index=False) + except PermissionError: + err_msg = html_utils.paragraph( + "The below file is open in another app " + "(Excel maybe?).

    " + f"{acdc_df_path}

    " + 'Close file and then press "Ok".' + ) + msg = widgets.myMessageBox() + msg.critical(self, "Permission denied", err_msg) + acdc_df.to_csv(acdc_df_path, index=False) + + elif self.sender() == self.selectButton: + pass + + self.close() + + def checkShapeMismatchMetadata(self): + valid4D = True + valid3D = True + valid2D = True + if self.imgDataShape is None: + self.close() + elif len(self.imgDataShape) == 4: + T, Z, Y, X = self.imgDataShape + valid4D = self.SizeT == T and self.SizeZ == Z + elif len(self.imgDataShape) == 3: + TorZ, Y, X = self.imgDataShape + valid3D = self.SizeT == TorZ or self.SizeZ == TorZ + elif len(self.imgDataShape) == 2: + valid2D = self.SizeT == 1 and self.SizeZ == 1 + + valid = all([valid4D, valid3D, valid2D]) + if valid: + return True + + if not valid4D: + txt = f""" + You loaded 4D data, hence the number of frames MUST be + {T}
    and the number of z-slices MUST be {Z}.

    + What do you want to do? + """ + if not valid3D: + txt = f""" + You loaded 3D data, hence either the number of frames or + the number of z-slices is {TorZ}.

    + However, if the number of frames is greater than 1 then the
    + number of z-slices MUST be 1, and vice-versa.

    + What do you want to do? + """ + + if not valid2D: + txt = f""" + You loaded 2D data, hence the number of frames MUST be 1 + and the number of z-slices MUST be 1.

    + What do you want to do? + """ + + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(txt) + + continueButton = widgets.okPushButton("Continue anyway") + correctButton = widgets.editPushButton("Let me correct") + + msg.warning( + self, + "Shape-metadata mismatch", + txt, + buttonsTexts=(continueButton, correctButton), + ) + if msg.cancel or msg.clickedButton == correctButton: + return False + + return True + + def cancel_cb(self, event): + self.cancel = True + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class QCropZtool(QBaseDialog): + sigClose = Signal() + sigZvalueChanged = Signal(str, int) + sigReset = Signal() + sigCrop = Signal(int, int) + + def __init__( + self, + SizeZ, + cropButtonText="Apply crop", + parent=None, + addDoNotShowAgain=False, + title="Select z-slices", + ): + super().__init__(parent) + + self.cancel = True + + self.setWindowFlags(Qt.Tool | Qt.WindowStaysOnTopHint) + + self.SizeZ = SizeZ + self.numDigits = len(str(self.SizeZ)) + + self.setWindowTitle(title) + + layout = QGridLayout() + buttonsLayout = QHBoxLayout() + + self.lowerZscrollbar = widgets.ScrollBarWithNumericControl() + self.lowerZscrollbar.setMaximum(SizeZ) + self.lowerZscrollbar.setMinimum(1) + self.lowerZscrollbar.setValue(1) + + self.upperZscrollbar = widgets.ScrollBarWithNumericControl() + self.upperZscrollbar.setMaximum(SizeZ) + self.upperZscrollbar.setValue(SizeZ) + + cancelButton = widgets.cancelPushButton("Cancel") + cropButton = widgets.okPushButton(cropButtonText) + buttonsLayout.addWidget(cropButton) + buttonsLayout.addWidget(cancelButton) + + row = 0 + layout.addWidget(QLabel("Lower z-slice "), row, 0, alignment=Qt.AlignRight) + layout.addWidget(self.lowerZscrollbar, row, 1) + + row += 1 + layout.setRowStretch(row, 5) + + row += 1 + layout.addWidget(QLabel("Upper z-slice "), row, 0, alignment=Qt.AlignRight) + layout.addWidget(self.upperZscrollbar, row, 1) + + row += 1 + if addDoNotShowAgain: + self.doNotShowAgainCheckbox = QCheckBox("Do not ask again") + layout.addWidget( + self.doNotShowAgainCheckbox, row, 1, alignment=Qt.AlignLeft + ) + row += 1 + + layout.addLayout(buttonsLayout, row, 1, alignment=Qt.AlignRight) + + layout.setColumnStretch(0, 0) + layout.setColumnStretch(1, 10) + + self.setLayout(layout) + + # resetButton.clicked.connect(self.emitReset) + cropButton.clicked.connect(self.emitCrop) + cancelButton.clicked.connect(self.close) + self.lowerZscrollbar.sigValueChanged.connect(self.ZvalueChanged) + self.upperZscrollbar.sigValueChanged.connect(self.ZvalueChanged) + + def emitReset(self): + self.sigReset.emit() + + def emitCrop(self): + self.cancel = False + low_z = self.lowerZscrollbar.value() - 1 + high_z = self.upperZscrollbar.value() - 1 + self.sigCrop.emit(low_z, high_z) + self.close() + + def updateScrollbars(self, lower_z, upper_z): + self.lowerZscrollbar.setValue(lower_z + 1) + self.upperZscrollbar.setValue(upper_z + 1) + + def ZvalueChanged(self, value): + which = "lower" if self.sender() == self.lowerZscrollbar else "upper" + if which == "lower" and value > self.upperZscrollbar.value() - 1: + self.lowerZscrollbar.setValue(self.upperZscrollbar.value() - 1) + return + if which == "upper" and value < self.lowerZscrollbar.value() + 1: + self.upperZscrollbar.setValue(self.lowerZscrollbar.value() + 1) + return + + z_slice_n = value - 1 + self.sigZvalueChanged.emit(which, z_slice_n) + + def showEvent(self, event): + self.resize(int(self.width() * 1.5), self.height()) + + def closeEvent(self, event): + super().closeEvent(event) + self.sigClose.emit() + + +class TreeSelectorDialog(QBaseDialog): + sigItemDoubleClicked = Signal(object) + + def __init__( + self, + title="Tree selector", + infoTxt="", + parent=None, + multiSelection=True, + widthFactor=None, + heightFactor=None, + expandOnDoubleClick=False, + isTopLevelSelectable=True, + allItemsExpanded=True, + allowNoSelection=True, + ): + super().__init__(parent) + + self.setWindowTitle(title) + + self.cancel = True + self.widthFactor = widthFactor + self.heightFactor = heightFactor + self.allItemsExpanded = allItemsExpanded + self.mainLayout = QVBoxLayout() + self._isTopLevelSelectable = isTopLevelSelectable + self.allowNoSelection = allowNoSelection + + if infoTxt: + self.mainLayout.addWidget(QLabel(html_utils.paragraph(infoTxt))) + + self.treeWidget = widgets.TreeWidget(multiSelection=multiSelection) + self.treeWidget.setExpandsOnDoubleClick(expandOnDoubleClick) + self.treeWidget.setHeaderHidden(True) + self.mainLayout.addWidget(self.treeWidget) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + self.mainLayout.addSpacing(20) + self.mainLayout.addLayout(buttonsLayout) + + self.buttonsLayout = buttonsLayout + + self.setLayout(self.mainLayout) + + self.treeWidget.itemClicked.connect(self.onItemClicked) + self.treeWidget.itemDoubleClicked.connect(self.onItemDoubleClicked) + + def onItemDoubleClicked(self, item): + self.sigItemDoubleClicked.emit(item) + + def onItemClicked(self, item): + if self._isTopLevelSelectable: + return + if item.parent() is None: + item.setSelected(False) + + def addTree(self, tree: dict): + for topLevel, children in tree.items(): + topLevelItem = widgets.TreeWidgetItem(self.treeWidget) + topLevelItem.setText(0, topLevel) + self.treeWidget.addTopLevelItem(topLevelItem) + childrenItems = [widgets.TreeWidgetItem([c]) for c in children] + topLevelItem.addChildren(childrenItems) + if not self.allItemsExpanded: + continue + topLevelItem.setExpanded(True) + + def resizeVertical(self): + if not self.isVisible(): + self.show() + + currentTreeWidgetHeight = self.treeWidget.height() + treeWidgetHeight = 0 + for i in range(self.treeWidget.topLevelItemCount()): + topLevelItem = self.treeWidget.topLevelItem(i) + rect = self.treeWidget.visualItemRect(topLevelItem) + treeWidgetHeight += rect.height() + for j in range(topLevelItem.childCount()): + childItem = topLevelItem.child(j) + rect = self.treeWidget.visualItemRect(childItem) + treeWidgetHeight += rect.height() + + deltaHeight = treeWidgetHeight - currentTreeWidgetHeight + 10 + self.resize(self.width(), self.height() + deltaHeight) + self.move(self.x(), 20) + + def setCurrentItem(self, itemText: dict): + if not itemText: + return + for i in range(self.treeWidget.topLevelItemCount()): + topLevelItem = self.treeWidget.topLevelItem(i) + topLevelName = topLevelItem.text(0) + childText = itemText.get(topLevelName) + if childText is None: + continue + for j in range(topLevelItem.childCount()): + childItem = topLevelItem.child(j) + childItemText = childItem.text(0) + if childItemText == childText: + childItem.setSelected(True) + topLevelItem.setExpanded(True) + self.treeWidget.scrollToItem(topLevelItem) + break + + def selectedItems(self): + self._selectedItems = {} + for i in range(self.treeWidget.topLevelItemCount()): + topLevelItem = self.treeWidget.topLevelItem(i) + topLevelName = topLevelItem.text(0) + for j in range(topLevelItem.childCount()): + childItem = topLevelItem.child(j) + if not childItem.isSelected(): + continue + if topLevelName not in self._selectedItems: + self._selectedItems[topLevelName] = [childItem.text(0)] + else: + self._selectedItems[topLevelName].append(childItem.text(0)) + return self._selectedItems + + def warnSelectionIsEmpty(self): + txt = html_utils.paragraph(""" + You did not select anything :(.

    + Please press Cancel to exit without selecting items. + Thanks! + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Selection is empty", txt) + + def ok_cb(self): + if not self.allowNoSelection and not self.selectedItems(): + self.warnSelectionIsEmpty() + return + self.cancel = False + self.close() + + def showEvent(self, event) -> None: + super().showEvent(event) + if self.widthFactor is not None: + self.resize(int(self.width() * self.widthFactor), self.height()) + if self.heightFactor is not None: + self.resize(self.width(), int(self.height() * self.heightFactor)) + + +class TreesSelectorDialog(QBaseDialog): + def __init__( + self, trees, groupsDescr=None, title="Trees selector", infoTxt="", parent=None + ): + super().__init__(parent) + + self.setWindowTitle(title) + + self.cancel = True + self.mainLayout = QVBoxLayout() + + if infoTxt: + self.mainLayout.addWidget(QLabel(html_utils.paragraph(infoTxt))) + + self.treeWidgets = {} + self.setLayout(self.mainLayout) + + createdGroupLayouts = {} + for treeName, tree in trees.items(): + if groupsDescr is None: + groupName = "" + else: + groupName = groupsDescr.get(treeName, "Group info missing") + groupLayout = createdGroupLayouts.get(groupName, None) + if groupLayout is None: + self.mainLayout.addWidget(QLabel(html_utils.paragraph(groupName))) + groupBox = QGroupBox() + self.mainLayout.addWidget(groupBox) + groupLayout = QVBoxLayout() + groupBox.setLayout(groupLayout) + createdGroupLayouts[groupName] = groupLayout + else: + groupLayout.addSpacing(10) + groupLayout.addWidget(QLabel(html_utils.paragraph(treeName))) + treeWidget = widgets.TreeWidget(multiSelection=True) + treeWidget.setHeaderHidden(True) + for topLevel, children in tree.items(): + topLevelItem = widgets.TreeWidgetItem(treeWidget) + topLevelItem.setText(0, topLevel) + treeWidget.addTopLevelItem(topLevelItem) + childrenItems = [widgets.TreeWidgetItem([c]) for c in children] + topLevelItem.addChildren(childrenItems) + topLevelItem.setExpanded(True) + self.treeWidgets[treeName] = treeWidget + groupLayout.addWidget(treeWidget) + self.mainLayout.addSpacing(20) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + self.mainLayout.addSpacing(10) + self.mainLayout.addLayout(buttonsLayout) + + def ok_cb(self): + self.cancel = False + self.selectedItems = {} + for treeName, treeWidget in self.treeWidgets.items(): + for i in range(treeWidget.topLevelItemCount()): + topLevelItem = treeWidget.topLevelItem(i) + for j in range(topLevelItem.childCount()): + childItem = topLevelItem.child(j) + if not childItem.isSelected(): + continue + if treeName not in self.selectedItems: + self.selectedItems[treeName] = [childItem.text(0)] + else: + self.selectedItems[treeName].append(childItem.text(0)) + self.close() + + +class MultiListSelector(QBaseDialog): + def __init__( + self, + lists: dict, + groupsDescr: dict = None, + title="Lists selector", + infoTxt="", + parent=None, + ): + super().__init__(parent) + + self.setWindowTitle(title) + + self.cancel = True + mainLayout = QVBoxLayout() + + if infoTxt: + mainLayout.addWidget(QLabel(html_utils.paragraph(infoTxt))) + + self.listWidgets = {} + createdGroupLayouts = {} + for listName, listItems in lists.items(): + if groupsDescr is None: + groupName = "" + else: + groupName = groupsDescr.get(listName, "Group info missing") + groupLayout = createdGroupLayouts.get(listName, None) + if groupLayout is None: + mainLayout.addWidget(QLabel(html_utils.paragraph(groupName))) + groupBox = QGroupBox() + mainLayout.addWidget(groupBox) + groupLayout = QVBoxLayout() + groupBox.setLayout(groupLayout) + createdGroupLayouts[groupName] = groupLayout + else: + groupLayout.addSpacing(10) + groupLayout.addWidget(QLabel(html_utils.paragraph(listName))) + listWidget = widgets.listWidget() + listWidget.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + listWidget.addItems(listItems) + groupLayout.addWidget(listWidget) + mainLayout.addSpacing(20) + self.listWidgets[listName] = listWidget + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addSpacing(10) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def ok_cb(self): + self.cancel = False + self.selectedItems = {} + for listName, listWidget in self.listWidgets.items(): + if not listWidget.selectedItems(): + continue + self.selectedItems[listName] = [ + item.text() for item in listWidget.selectedItems() + ] + self.close() + + +class selectPositionsMultiExp(QBaseDialog): + def __init__(self, expPaths: dict, infoPaths: dict = None, parent=None): + super().__init__(parent=parent) + + self.expPaths = expPaths + self.cancel = True + + mainLayout = QVBoxLayout() + + self.setWindowTitle("Select Positions to process") + + infoTxt = html_utils.paragraph( + "Select one or more Positions to process

    " + "Click on experiment path to select all positions
    " + "Ctrl+Click to select multiple items
    " + "Shift+Click to select a range of items
    ", + center=True, + ) + infoLabel = QLabel(infoTxt) + + self.treeWidget = QTreeWidget() + self.treeWidget.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + self.treeWidget.setHeaderHidden(True) + self.treeWidget.setFont(font) + for exp_path, positions in expPaths.items(): + pathLevels = exp_path.split(os.sep) + posFoldersInfo = None + if infoPaths is not None: + posFoldersInfo = infoPaths.get(exp_path) + if len(pathLevels) > 4: + itemText = os.path.join(*pathLevels[-4:]) + itemText = f"...{itemText}" + else: + itemText = exp_path + exp_path_item = QTreeWidgetItem([itemText]) + exp_path_item.setToolTip(0, exp_path) + exp_path_item.full_path = exp_path + self.treeWidget.addTopLevelItem(exp_path_item) + postions_items = [] + for pos in positions: + if posFoldersInfo is not None: + status = posFoldersInfo.get(pos, "") + else: + status = "" + pos_item_text = f"{pos}{status}" + pos_item = QTreeWidgetItem(exp_path_item, [pos_item_text]) + pos_item.posFoldername = pos + postions_items.append(pos_item) + exp_path_item.addChildren(postions_items) + exp_path_item.setExpanded(True) + + self.treeWidget.itemClicked.connect(self.selectAllChildren) + + buttonsLayout = QHBoxLayout() + cancelButton = widgets.cancelPushButton("Cancel") + okButton = widgets.okPushButton(" Ok ") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(okButton) + + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + + mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) + mainLayout.addWidget(self.treeWidget) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + self.setStyleSheet(TREEWIDGET_STYLESHEET) + + def selectAllChildren(self, item, col): + if item.parent() is not None: + return + + for i in range(item.childCount()): + item.child(i).setSelected(True) + + def ok_cb(self): + if not self.treeWidget.selectedItems(): + msg = widgets.myMessageBox(wrapText=False) + txt = "You did not select any experiment/Position folder!" + msg.warning(self, "Empty selection!", html_utils.paragraph(txt)) + return + + self.cancel = False + self.selectedPaths = {} + for item in self.treeWidget.selectedItems(): + if item.parent() is None: + continue + parent = item.parent() + exp_path = parent.full_path + pos_folder = item.posFoldername + if exp_path not in self.selectedPaths: + self.selectedPaths[exp_path] = [] + self.selectedPaths[exp_path].append(pos_folder) + + self.close() + + def showEvent(self, event): + self.resize(int(self.width() * 2), self.height()) + + +class QDialogZsliceAbsent(QDialog): + def __init__(self, filename, SizeZ, filenamesWithInfo, parent=None): + self.runDataPrep = False + self.useMiddleSlice = False + self.useSameAsCh = False + + self.cancel = True + + super().__init__(parent) + self.setWindowTitle("Reference z-slice info absent") + + mainLayout = QVBoxLayout() + buttonsLayout = QGridLayout() + + txt = html_utils.paragraph( + f""" + You loaded the fluorescent file called

    {filename}

    + however you never selected which z-slice
    you want to use + when calculating metrics
    (e.g., mean, median, amount...etc.)

    + Choose one of following options: + """, + center=True, + ) + infoLabel = QLabel(txt) + mainLayout.addWidget(infoLabel, alignment=Qt.AlignCenter) + + runDataPrepButton = QPushButton( + " Visualize the data now and select a z-slice " + ) + buttonsLayout.addWidget(runDataPrepButton, 0, 1, 1, 2) + runDataPrepButton.clicked.connect(self.runDataPrep_cb) + + useMiddleSliceButton = QPushButton( + f" Use the middle z-slice ({int(SizeZ / 2) + 1}) " + ) + buttonsLayout.addWidget(useMiddleSliceButton, 1, 1, 1, 2) + useMiddleSliceButton.clicked.connect(self.useMiddleSlice_cb) + + useSameAsChButton = QPushButton(" Use the same z-slice used for the channel: ") + useSameAsChButton.clicked.connect(self.useSameAsCh_cb) + + chNameComboBox = QComboBox() + chNameComboBox.addItems(filenamesWithInfo) + # chNameComboBox.setEditable(True) + # chNameComboBox.lineEdit().setAlignment(Qt.AlignCenter) + # chNameComboBox.lineEdit().setReadOnly(True) + self.chNameComboBox = chNameComboBox + buttonsLayout.addWidget(useSameAsChButton, 2, 1) + buttonsLayout.addWidget(chNameComboBox, 2, 2) + + buttonsLayout.setColumnStretch(0, 1) + buttonsLayout.setColumnStretch(3, 1) + buttonsLayout.setContentsMargins(10, 0, 10, 0) + + cancelButtonLayout = QHBoxLayout() + cancelButton = widgets.cancelPushButton("Cancel") + cancelButtonLayout.addStretch(1) + cancelButtonLayout.addWidget(cancelButton) + cancelButtonLayout.addStretch(1) + cancelButtonLayout.setStretch(1, 1) + cancelButton.clicked.connect(self.close) + + mainLayout.addLayout(buttonsLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(cancelButtonLayout) + mainLayout.addStretch(1) + + self.setLayout(mainLayout) + + font = QFont() + font.setPixelSize(12) + self.setFont(font) + + # self.setModal(True) + + def ok_cb(self, checked=True): + self.cancel = False + self.close() + + def useSameAsCh_cb(self, checked): + self.useSameAsCh = True + self.selectedChannel = self.chNameComboBox.currentText() + self.ok_cb() + + def useMiddleSlice_cb(self, checked): + self.useMiddleSlice = True + self.ok_cb() + + def runDataPrep_cb(self, checked): + self.runDataPrep = True + self.ok_cb() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class SetColumnNamesDialog(QBaseDialog): + def __init__(self, columnNames, categories, optionalCategories=None, parent=None): + super().__init__(parent) + + if not optionalCategories: + optionalCategories = None + + self.cancel = True + + mainLayout = QVBoxLayout() + + mainLayout.addWidget( + QLabel( + html_utils.paragraph("Assign a column to the following categories:
    ") + ) + ) + + self.categoriesWidgets = {} + formLayout = QFormLayout() + for row, category in enumerate(categories): + combobox = widgets.ComboBox() + combobox.addItems(columnNames) + if optionalCategories is not None: + text = f"* {category}" + else: + text = category + formLayout.addRow(text, combobox) + self.categoriesWidgets[category] = combobox + + if optionalCategories is not None: + optionalItems = ["None", *columnNames] + for row, category in enumerate(optionalCategories): + combobox = widgets.ComboBox() + combobox.addItems(optionalItems) + formLayout.addRow(category, combobox) + self.categoriesWidgets[category] = combobox + + mainLayout.addLayout(formLayout) + if optionalCategories is not None: + mainLayout.addSpacing(10) + mainLayout.addWidget( + QLabel(html_utils.paragraph("* mandatory", font_size="11px")) + ) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + self.setFont(font) + + def _warnNonUniqueCategories(self, category_1, category_2): + txt = html_utils.paragraph(f""" + The following categories have the same column assigned to it.

    + Columns assigned to categories must be unique.

    + Categories with the same column: + {html_utils.to_list((category_1, category_2))} + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Non-unique columns", txt) + + def _checkUniqueNames(self): + self.textToCategoryMapper = {} + for category, combobox in self.categoriesWidgets.items(): + if combobox.text() == "None": + continue + + if combobox.text() not in self.textToCategoryMapper: + self.textToCategoryMapper[combobox.text()] = category + continue + + sameCategory = self.textToCategoryMapper[combobox.text()] + self._warnNonUniqueCategories(category, sameCategory) + return False + + return True + + def ok_cb(self): + proceed = self._checkUniqueNames() + if not proceed: + return + + self.selectedColumns = { + category: combobox.text() + for category, combobox in self.categoriesWidgets.items() + } + self.cancel = False + self.close() + + +class QCropTrangeTool(QBaseDialog): + sigClose = Signal() + sigTvalueChanged = Signal(int) + sigReset = Signal() + sigCrop = Signal(int, int) + + def __init__( + self, + SizeT, + cropButtonText="Apply crop", + parent=None, + addDoNotShowAgain=False, + title="Select frames range", + ): + super().__init__(parent) + + self.cancel = True + + self.setWindowFlags(Qt.Tool | Qt.WindowStaysOnTopHint) + + self.SizeT = SizeT + self.numDigits = len(str(self.SizeT)) + + self.setWindowTitle(title) + + layout = QGridLayout() + buttonsLayout = QHBoxLayout() + + self.startFrameScrollbar = widgets.sliderWithSpinBox( + spinbox_loc="left", maximum_on_label=SizeT + ) + self.startFrameScrollbar.setMaximum(SizeT, including_spinbox=True) + self.startFrameScrollbar.setMinimum(1, including_spinbox=True) + + self.endFrameScrollbar = widgets.sliderWithSpinBox( + spinbox_loc="left", maximum_on_label=SizeT + ) + self.endFrameScrollbar.setMaximum(SizeT, including_spinbox=True) + self.endFrameScrollbar.setMinimum(1, including_spinbox=True) + self.endFrameScrollbar.setValue(SizeT) + + cancelButton = widgets.cancelPushButton("Cancel") + cropButton = widgets.okPushButton(cropButtonText) + buttonsLayout.addWidget(cropButton) + buttonsLayout.addWidget(cancelButton) + + row = 0 + layout.addWidget(QLabel("Start frame n. "), row, 0, alignment=Qt.AlignRight) + layout.addWidget(self.startFrameScrollbar, row, 2) + + row += 1 + layout.setRowStretch(row, 5) + layout.addItem(QSpacerItem(10, 10), row, 0) + + row += 1 + layout.addWidget(QLabel("Stop frame n. "), row, 0, alignment=Qt.AlignRight) + layout.addWidget(self.endFrameScrollbar, row, 2) + + row += 1 + if addDoNotShowAgain: + self.doNotShowAgainCheckbox = QCheckBox("Do not ask again") + layout.addWidget( + self.doNotShowAgainCheckbox, row, 2, alignment=Qt.AlignLeft + ) + row += 1 + + layout.addItem(QSpacerItem(10, 20), row, 0) + layout.addLayout(buttonsLayout, row + 1, 2, alignment=Qt.AlignRight) + + layout.setColumnStretch(0, 0) + layout.setColumnStretch(1, 0) + layout.setColumnStretch(2, 10) + + self.setLayout(layout) + + # resetButton.clicked.connect(self.emitReset) + cropButton.clicked.connect(self.emitCrop) + cancelButton.clicked.connect(self.close) + self.startFrameScrollbar.sigValueChange.connect(self.TvalueChanged) + self.endFrameScrollbar.sigValueChange.connect(self.TvalueChanged) + + def emitReset(self): + self.sigReset.emit() + + def emitCrop(self): + self.cancel = False + low_z = self.startFrameScrollbar.value() - 1 + high_z = self.endFrameScrollbar.value() - 1 + self.sigCrop.emit(low_z, high_z) + self.close() + + def updateScrollbars(self, start_frame_i, lower_frame_i): + self.startFrameScrollbar.setValue(start_frame_i + 1) + self.endFrameScrollbar.setValue(lower_frame_i + 1) + + def TvalueChanged(self, value): + frame_i = value - 1 + self.sigTvalueChanged.emit(frame_i) + + def showEvent(self, event): + self.resize(int(self.width() * 2.0), self.height()) + + def closeEvent(self, event): + super().closeEvent(event) + self.sigClose.emit() + + +class SelectFoldersToAnalyse(QBaseDialog): + def __init__( + self, + parent=None, + preSelectedPaths=None, + onlyExpPaths=False, + scanFolderTree=True, + instructionsText="Select experiment folders to analyse", + askSelectPosFolders=False, + ): + super().__init__(parent) + + self.cancel = True + self.onlyExpPaths = onlyExpPaths + self.setWindowTitle("Select experiments to analyse") + self.scanTree = scanFolderTree + self.askSelectPosFolders = askSelectPosFolders + + mainLayout = QVBoxLayout() + + instructionsText = html_utils.paragraph( + f"{instructionsText}

    " + "Drag and drop folders or click on Add folder button to " + "add as many folders " + "as needed.
    ", + font_size="14px", + ) + instructionsLabel = QLabel(instructionsText) + instructionsLabel.setAlignment(Qt.AlignCenter) + + infoText = html_utils.paragraph( + "A valid folder is either a Position folder, " + "or an experiment folder (containing Position_n folders),
    " + "or any folder that contains multiple experiment folders.

    " + "In the last case, Cell-ACDC will automatically scan the entire tree of " + "sub-directories
    " + "and will add all experiments having the right folder structure.
    ", + font_size="12px", + ) + infoLabel = QLabel(infoText) + infoLabel.setAlignment(Qt.AlignCenter) + + self.listWidget = widgets.listWidget() + self.listWidget.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + if preSelectedPaths is not None: + self.listWidget.addItems(preSelectedPaths) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + delButton = widgets.delPushButton("Remove selected path(s)") + browseButton = widgets.browseFileButton( + "Add folder...", openFolder=True, start_dir=myutils.getMostRecentPath() + ) + + buttonsLayout.insertWidget(3, delButton) + buttonsLayout.insertWidget(4, browseButton) + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + browseButton.sigPathSelected.connect(self.addFolderPath) + delButton.clicked.connect(self.removePaths) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addWidget(instructionsLabel) + mainLayout.addWidget(infoLabel) + mainLayout.addWidget(self.listWidget) + + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + mainLayout.addStretch(1) + + self.setLayout(mainLayout) + + self.setAcceptDrops(True) + + self.setFont(font) + + def dragEnterEvent(self, event): + event.acceptProposedAction() + + def dropEvent(self, event): + event.setDropAction(Qt.CopyAction) + for url in event.mimeData().urls(): + dropped_path = url.toLocalFile() + if os.path.isfile(dropped_path): + dropped_path = os.path.dirname(dropped_path) + + QTimer.singleShot(50, partial(self.addFolderPath, dropped_path)) + + def pathsList(self): + return [ + self.listWidget.item(i).text().replace("\\", "/") + for i in range(self.listWidget.count()) + ] + + def expFolderToPosFoldernamesMapper(self): + expPathsPosFoldernamesMapper = defaultdict(set) + for selectedPath in self.pathsList(): + pos_foldernames = myutils.get_pos_foldernames( + selectedPath, check_if_is_sub_folder=True + ) + if not pos_foldernames: + images_path = myutils.get_images_folderpath(selectedPath) + expPathsPosFoldernamesMapper[selectedPath].add("") + else: + expPath = load.get_exp_path(selectedPath) + expPathsPosFoldernamesMapper[expPath].update(pos_foldernames) + + expPathsPosFoldernamesMapper = { + expPath: natsorted(pos_foldernames) + for expPath, pos_foldernames in expPathsPosFoldernamesMapper.items() + } + return expPathsPosFoldernamesMapper + + def ok_cb(self): + self.cancel = False + self.paths = self.pathsList() + self.selectedExpFolderToPosFoldernamesMapper = ( + self.expFolderToPosFoldernamesMapper() + ) + self.close() + + def warnNoValidPathsFound(self, selected_path): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(""" + The selected path (see below) does not contain any valid folder.

    + Please, make sure to select a Position folder, the Images folder + inside a Position folder, or any folder containing a Position folder + as a sub-directory.

    + Thank you for your patience!

    + Selected path: + """) + msg.warning( + self, + "Training workflow generated", + txt, + commands=(f"{selected_path}",), + path_to_browse=selected_path, + ) + + def warnNoValidExpPaths(self, selected_path): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(""" + The selected folder does + not contain any valid experiment folders. + """) + command = selected_path.replace("\\", os.sep) + command = selected_path.replace("/", os.sep) + msg.warning( + self, + "No valid folders found", + txt, + commands=(command,), + path_to_browse=selected_path, + ) + + def parse_select_from_exp_paths(self, exp_paths: dict[os.PathLike, Iterable[str]]): + if not self.askSelectPosFolders: + return list(exp_paths.keys()) + + paths = [] + for exp_path, pos_foldernames in exp_paths.items(): + if len(pos_foldernames) == 1: + paths.append(exp_path) + continue + + informativeText = html_utils.paragraph( + "The following experiment folder

    " + f"{exp_path}

    " + "contains multiple Position folders.

    " + "Please, select which Position folder(s) you want to analyse:
    " + ) + select_folder = load.select_exp_folder() + values = select_folder.get_values_dataprep(exp_path) + select_folder.QtPrompt( + self, + values, + toggleMulti=True, + informativeText=informativeText, + selectedValues=values, + ) + if select_folder.cancel: + return + + for pos in select_folder.selected_pos: + paths.append(os.path.join(exp_path, pos)) + + return paths + + def addFolderPath(self, selected_path): + myutils.addToRecentPaths(selected_path) + + folder_type = myutils.determine_folder_type(selected_path) + is_pos_folder, is_images_folder, folder_path = folder_type + if is_pos_folder: + paths = [selected_path] + elif is_images_folder: + paths = [os.path.dirname(selected_path)] + elif self.scanTree: + print(f'Scanning selected folder "{selected_path}"...') + exp_paths = path.get_posfolderpaths_walk(selected_path) + if not exp_paths: + self.warnNoValidExpPaths(selected_path) + return + + paths = self.parse_select_from_exp_paths(exp_paths) + if paths is None: + return + else: + paths = [selected_path] + + if not paths: + self.warnNoValidPathsFound(selected_path) + + for selectedPath in paths: + if self.onlyExpPaths: + selectedPath = load.get_exp_path(selectedPath) + + selectedPath = selectedPath.replace("\\", "/") + if selectedPath in self.pathsList(): + print( + f"[WARNING]: The following path was already selected: " + f'"{selectedPath}"' + ) + return + + self.listWidget.addItem(selectedPath) + + def removePaths(self): + for item in self.listWidget.selectedItems(): + row = self.listWidget.row(item) + self.listWidget.takeItem(row) + + +class OverlayLabelsAppearanceDialog(QBaseDialog): + sigValuesChanged = Signal(object) + + def __init__(self, scatterPlotItem: pg.ScatterPlotItem = None, parent=None): + super().__init__(parent) + + self.cancel = True + + self.setWindowTitle("Overlay contours appearance properties") + + mainLayout = QVBoxLayout() + + formLayout = widgets.FormLayout() + + row = -1 + + row += 1 + self.colorButton = widgets.myColorButton(color=(255, 0, 0)) + self.colorButton.clicked.disconnect() + self.colorButton.clicked.connect(self.selectColor) + self.colorButton.setCursor(Qt.PointingHandCursor) + self.colorWidget = widgets.formWidget( + self.colorButton, + addInfoButton=False, + stretchWidget=False, + labelTextLeft="Symbol color: ", + parent=self, + widgetAlignment="left", + ) + if scatterPlotItem is not None: + pen = scatterPlotItem.opts["pen"] + color = pen.color() + self.colorButton.setColor(color) + formLayout.addFormWidget(self.colorWidget, row=row) + + row += 1 + self.penWidthSpinBox = widgets.SpinBox() + self.penWidthSpinBox.setMinimum(0) + self.penWidthSpinBox.setValue(2) + + self.penWidthWidget = widgets.formWidget( + self.penWidthSpinBox, + addInfoButton=False, + stretchWidget=False, + labelTextLeft="Symbol weight: ", + parent=self, + widgetAlignment="left", + ) + if scatterPlotItem is not None: + pen = scatterPlotItem.opts["pen"] + width = pen.width() + self.penWidthSpinBox.setValue(width) + formLayout.addFormWidget(self.penWidthWidget, row=row) + + row += 1 + self.opacitySlider = widgets.sliderWithSpinBox(isFloat=True, normalize=True) + self.opacitySlider.setMinimum(0) + self.opacitySlider.setMaximum(100) + self.opacitySlider.setValue(0.8) + + self.opacityWidget = widgets.formWidget( + self.opacitySlider, + addInfoButton=False, + stretchWidget=True, + labelTextLeft="Symbol opacity: ", + parent=self, + ) + if scatterPlotItem is not None: + brush = scatterPlotItem.opts["brush"] + alpha = brush.color().alpha() + opacity = alpha / 255 + self.opacitySlider.setValue(opacity) + formLayout.addFormWidget(self.opacityWidget, row=row) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addLayout(formLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def selectColor(self): + color = self.colorButton.color() + self.colorButton.origColor = color + self.colorButton.colorDialog.setCurrentColor(color) + self.colorButton.colorDialog.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + self.colorButton.colorDialog.open() + w = self.width() + left = self.pos().x() + colorDialogTop = self.colorButton.colorDialog.pos().y() + self.colorButton.colorDialog.move(w + left + 10, colorDialogTop) + + def getBrush(self): + r, g, b, _ = self.colorButton.color().getRgb() + alpha = round(self.opacitySlider.value() * 255) + brushColor = (r, g, b, alpha) + brush = pg.mkBrush(brushColor) + return brush + + def getPen(self): + color = self.colorButton.color() + penWidth = self.penWidthSpinBox.value() + if penWidth == 0: + return + + pen = pg.mkPen(color, width=penWidth) + return pen + + def ok_cb(self): + self.cancel = False + self.properties = {"brush": self.getBrush(), "pen": self.getPen()} + self.close() + + +class AutoSaveIntervalDialog(QBaseDialog): + sigValueChanged = Signal(float, str) + + def __init__(self, parent=None): + super().__init__(parent) + + self.cancel = True + + self.setWindowTitle("Change autosave interval") + + mainLayout = QVBoxLayout() + + self.autoSaveIntervalWidget = widgets.AutoSaveIntervalWidget(parent=self) + + mainLayout.addWidget(QLabel("Autosave interval:")) + mainLayout.addWidget(self.autoSaveIntervalWidget) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def setValues(self, autoSaveIntevalValue, autoSaveIntervalUnit): + self.autoSaveIntervalWidget.spinbox.setValue(autoSaveIntevalValue) + self.autoSaveIntervalWidget.unitCombobox.setCurrentText(autoSaveIntervalUnit) + + def sizeHint(self): + defaultWidth = super().sizeHint().width() + defaultHeight = super().sizeHint().height() + return QSize(defaultWidth * 2, defaultHeight) + + def ok_cb(self): + self.cancel = False + self.sigValueChanged.emit( + self.autoSaveIntervalWidget.spinbox.value(), + self.autoSaveIntervalWidget.unitCombobox.currentText(), + ) + self.close() + +# Sibling imports (deferred to avoid import cycles) +from .general import ( + imageViewer, +) + diff --git a/cellacdc/dialogs/models.py b/cellacdc/dialogs/models.py new file mode 100644 index 000000000..85aabec8c --- /dev/null +++ b/cellacdc/dialogs/models.py @@ -0,0 +1,2265 @@ +"""Cell-ACDC dialog windows: models.""" + +import os +import sys +import re +from typing import Literal, Callable, Dict, Iterable, List, Tuple +import datetime +import pathlib +from collections import defaultdict +import zipfile +from heapq import nlargest +import matplotlib +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.patches import Rectangle, Circle, PathPatch, Path +import numpy as np +import scipy.interpolate + +try: + import tkinter as tk +except Exception as err: + pass + +import cv2 +import traceback +from itertools import combinations, permutations +from collections import namedtuple +from natsort import natsorted + +# from MyWidgets import Slider, Button, MyRadioButtons +from skimage.measure import label, regionprops +from functools import partial +import skimage.filters +import skimage.measure +import skimage.morphology +import skimage.exposure +import skimage.draw +import skimage.registration +import skimage.color +import skimage.segmentation +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk +import matplotlib.pyplot as plt +import seaborn as sns +import pandas as pd +import math +import time +import sympy as sp +import json +import html + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from qtpy import QtCore +from qtpy.QtGui import ( + QIcon, + QFontMetrics, + QKeySequence, + QFont, + QRegularExpressionValidator, + QCursor, + QKeyEvent, + QPixmap, + QFont, + QPalette, + QMouseEvent, + QColor, +) +from qtpy.QtCore import ( + Qt, + QSize, + QEvent, + Signal, + QEventLoop, + QTimer, + QRegularExpression, +) +from qtpy.QtWidgets import ( + QFileDialog, + QApplication, + QMainWindow, + QMenu, + QLabel, + QToolBar, + QScrollBar, + QWidget, + QVBoxLayout, + QLineEdit, + QPushButton, + QHBoxLayout, + QDialog, + QFormLayout, + QListWidget, + QAbstractItemView, + QButtonGroup, + QCheckBox, + QSizePolicy, + QComboBox, + QSlider, + QGridLayout, + QSpinBox, + QToolButton, + QTableView, + QTextBrowser, + QDoubleSpinBox, + QScrollArea, + QFrame, + QProgressBar, + QGroupBox, + QRadioButton, + QDockWidget, + QMessageBox, + QStyle, + QPlainTextEdit, + QSpacerItem, + QTreeWidget, + QTreeWidgetItem, + QTextEdit, + QSplashScreen, + QAction, + QListWidgetItem, + QActionGroup, + QHeaderView, + QStyledItemDelegate, +) +import qtpy.compat + +from .. import exception_handler +from .. import load, prompts, core, measurements, html_utils +from .. import is_mac, is_win, is_linux, settings_folderpath, config +from .. import preproc_recipes_path, segm_recipes_path, combine_channels_recipes_path +from .. import is_conda_env +from .. import printl +from .. import colors +from .. import issues_url +from .. import myutils +from .. import qutils +from .. import _palettes +from .. import base_cca_dict +from .. import widgets +from .. import user_profile_path, promptable_models_path, models_path +from .. import features +from .. import _core +from .. import _types +from .. import plot +from .. import urls +from ..acdc_regex import float_regex, is_alphanumeric_filename, to_alphanumeric +from .. import _base_widgets +from .. import io +from .. import cca_functions +from .. import path + +POSITIVE_FLOAT_REGEX = float_regex(allow_negative=False) +TREEWIDGET_STYLESHEET = _palettes.TreeWidgetStyleSheet() +LISTWIDGET_STYLESHEET = _palettes.ListWidgetStyleSheet() +BACKGROUND_RGBA = _palettes.get_disabled_colors()["Button"] + +font = QFont() +font.setPixelSize(12) +italicFont = QFont() +italicFont.setPixelSize(12) +italicFont.setItalic(True) + +from ._base import ( + QBaseDialog, +) + +def addCustomModelMessages(QParent=None): + modelFilePath = None + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = html_utils.paragraph(""" + Do you already have the acdcSegment.py file for your code + or do you need instructions on how to set-up your custom model?
    + """) + infoButton = widgets.infoPushButton(" I need instructions") + browseButton = widgets.browseFileButton(" I have the model, let me select it") + msg.information( + QParent, + "Add custom model", + txt, + buttonsTexts=("Cancel", infoButton, browseButton), + showDialog=False, + ) + browseButton.clicked.disconnect() + browseButton.clicked.connect(msg.buttonCallBack) + msg.exec_() + if msg.cancel: + return + if msg.clickedButton == infoButton: + txt = myutils.get_add_custom_model_instructions() + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + msg.information( + QParent, + "Custom model instructions", + txt, + buttonsTexts=("Ok",), + path_to_browse=models_path, + browse_button_text="Open models folder...", + ) + else: + homePath = pathlib.Path.home() + modelFilePath = QFileDialog.getOpenFileName( + QParent, + "Select the acdcSegment.py file of your model", + str(homePath), + "acdcSegment.py file (*.py);;All files (*)", + )[0] + if not modelFilePath: + return + + return modelFilePath + + +def addCustomPromptModelMessages(QParent=None): + modelFilePath = None + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = html_utils.paragraph(""" + Do you already have the acdcPromptSegment.py file for your code + or do you need instructions on how to set-up your custom model?
    + """) + infoButton = widgets.infoPushButton(" I need instructions") + browseButton = widgets.browseFileButton(" I have the model, let me select it") + msg.information( + QParent, + "Add custom promptable model", + txt, + buttonsTexts=("Cancel", infoButton, browseButton), + showDialog=False, + ) + browseButton.clicked.disconnect() + browseButton.clicked.connect(msg.buttonCallBack) + msg.exec_() + if msg.cancel: + return + if msg.clickedButton == infoButton: + txt = myutils.get_add_custom_prompt_model_instructions() + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + msg.information( + QParent, + "Custom promptable model instructions", + txt, + buttonsTexts=("Ok",), + path_to_browse=promptable_models_path, + browse_button_text="Open promptable models folder...", + ) + else: + homePath = pathlib.Path.home() + modelFilePath = QFileDialog.getOpenFileName( + QParent, + "Select the acdcPromptSegment.py file of your model", + str(homePath), + "acdcPromptSegment.py file (*.py);;All files (*)", + )[0] + if not modelFilePath: + return + + return modelFilePath + + +class SelectPromptableModelDialog(QBaseDialog): + def __init__(self, parent=None): + self.cancel = True + super().__init__(parent) + + self.setWindowTitle("Select model for segmentation") + + mainLayout = QVBoxLayout() + + label = QLabel(html_utils.paragraph("Select model to use for segmentation: ")) + mainLayout.addWidget(label, alignment=Qt.AlignCenter) + + listBox = widgets.listWidget() + models = myutils.get_list_of_promptable_models() + listBox.addItems(models) + listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + listBox.setCurrentRow(0) + listBox.itemDoubleClicked.connect(self.ok_cb) + + self.listBox = listBox + + mainLayout.addWidget(listBox) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def ok_cb(self): + self.cancel = False + self.model_name = self.listBox.currentItem().text() + self.close() + + +class QDialogSelectModel(QDialog): + def __init__(self, parent=None, addSkipSegmButton=False, customFirst=""): + self.cancel = True + super().__init__(parent) + self.setWindowTitle("Select model") + + mainLayout = QVBoxLayout() + topLayout = QVBoxLayout() + bottomLayout = QHBoxLayout() + + self.mainLayout = mainLayout + + label = QLabel(html_utils.paragraph("Select model to use for segmentation: ")) + # padding: top, left, bottom, right + label.setStyleSheet("padding:0px 0px 3px 0px;") + topLayout.addWidget(label, alignment=Qt.AlignCenter) + + listBox = widgets.listWidget() + models = myutils.get_list_of_models() + + if customFirst: + try: + idx = models.index(customFirst) + models.insert(0, models.pop(idx)) + except ValueError: + print(f"Warning: {customFirst} not found in models list.") + pass + + listBox.setFont(font) + listBox.addItems(models) + addCustomModelItem = QListWidgetItem("Add custom model...") + addCustomModelItem.setFont(italicFont) + listBox.addItem(addCustomModelItem) + listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + listBox.setCurrentRow(0) + self.listBox = listBox + listBox.itemDoubleClicked.connect(self.ok_cb) + topLayout.addWidget(listBox) + + cancelButton = widgets.cancelPushButton("Cancel") + okButton = widgets.okPushButton(" Ok ") + okButton.setShortcut(Qt.Key_Enter) + + bottomLayout.addStretch(1) + bottomLayout.addWidget(cancelButton) + bottomLayout.addSpacing(20) + if addSkipSegmButton: + skipSegmButton = widgets.SkipPushButton("Skip segmentation") + bottomLayout.addWidget(skipSegmButton) + skipSegmButton.clicked.connect(self.skipSegm) + bottomLayout.addWidget(okButton) + bottomLayout.setContentsMargins(0, 10, 0, 0) + + mainLayout.addLayout(topLayout) + mainLayout.addLayout(bottomLayout) + self.setLayout(mainLayout) + + # Connect events + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.cancel_cb) + + self.setStyleSheet(LISTWIDGET_STYLESHEET) + + def skipSegm(self): + self.cancel = False + self.selectedModel = "skip_segmentation" + self.close() + + def keyPressEvent(self, event: QKeyEvent) -> None: + if event.key() == Qt.Key_Escape: + event.ignore() + return + + super().keyPressEvent(event) + + def ok_cb(self, event): + self.clickedButton = self.sender() + self.cancel = False + item = self.listBox.currentItem() + model = item.text() + if model == "Add custom model...": + modelFilePath = addCustomModelMessages(self) + if modelFilePath is None: + return + myutils.store_custom_model_path(modelFilePath) + modelName = os.path.basename(os.path.dirname(modelFilePath)) + item = QListWidgetItem(modelName) + self.listBox.addItem(item) + self.listBox.setCurrentItem(item) + elif model == "Automatic thresholding": + self.selectedModel = "thresholding" + self.close() + else: + self.selectedModel = model + self.close() + + def cancel_cb(self, event): + self.cancel = True + self.selectedModel = None + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + + horizontal_sb = self.listBox.horizontalScrollBar() + while horizontal_sb.isVisible(): + self.resize(self.height(), self.width() + 10) + + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class DataFrameModel(QtCore.QAbstractTableModel): + # https://stackoverflow.com/questions/44603119/how-to-display-a-pandas-data-frame-with-pyqt5-pyside2 + DtypeRole = QtCore.Qt.UserRole + 1000 + ValueRole = QtCore.Qt.UserRole + 1001 + + def __init__(self, df=pd.DataFrame(), parent=None): + super(DataFrameModel, self).__init__(parent) + self._dataframe = df + + def setDataFrame(self, dataframe): + self.beginResetModel() + self._dataframe = dataframe.copy() + self.endResetModel() + + def dataFrame(self): + return self._dataframe + + dataFrame = QtCore.Property(pd.DataFrame, fget=dataFrame, fset=setDataFrame) + + @QtCore.Slot(int, QtCore.Qt.Orientation, result=str) + def headerData( + self, + section: int, + orientation: QtCore.Qt.Orientation, + role: int = QtCore.Qt.DisplayRole, + ): + if role == QtCore.Qt.DisplayRole: + if orientation == QtCore.Qt.Horizontal: + return self._dataframe.columns[section] + else: + return str(self._dataframe.index[section]) + return QtCore.QVariant() + + def rowCount(self, parent=QtCore.QModelIndex()): + if parent.isValid(): + return 0 + return len(self._dataframe.index) + + def columnCount(self, parent=QtCore.QModelIndex()): + if parent.isValid(): + return 0 + return self._dataframe.columns.size + + def data(self, index, role=QtCore.Qt.DisplayRole): + if not index.isValid() or not ( + 0 <= index.row() < self.rowCount() + and 0 <= index.column() < self.columnCount() + ): + return QtCore.QVariant() + row = self._dataframe.index[index.row()] + col = self._dataframe.columns[index.column()] + dt = self._dataframe[col].dtype + + if role == Qt.TextAlignmentRole: + return Qt.AlignCenter + + val = self._dataframe.iloc[row][col] + if role == QtCore.Qt.DisplayRole: + return str(val) + elif role == DataFrameModel.ValueRole: + return val + if role == DataFrameModel.DtypeRole: + return dt + return QtCore.QVariant() + + def roleNames(self): + roles = { + QtCore.Qt.DisplayRole: b"display", + DataFrameModel.DtypeRole: b"dtype", + DataFrameModel.ValueRole: b"value", + } + return roles + + +class QDialogModelParams(QDialog): + def __init__( + self, + init_params, + segment_params, + model_name, + is_tracker=False, + url=None, + parent=None, + initLastParams=True, + posData=None, + channels=None, + currentChannelName=None, + segmFileEndnames=None, + df_metadata=None, + force_postprocess_2D=False, + model_module=None, + action_type="", + addPreProcessParams=True, + addPostProcessParams=True, + extraParams=None, + extraParamsTitle=None, + ini_filename=None, + add_additional_segm_params=False, + ): + self.cancel = True + super().__init__(parent) + self.channels = channels + self.is_tracker = is_tracker + self.currentChannelName = currentChannelName + self.channelCombobox = None + self.segmFileEndnames = segmFileEndnames + self.df_metadata = df_metadata + self.force_postprocess_2D = force_postprocess_2D + + self.skipSegmentation = False + if len(segment_params) > 0: + if segment_params[0].name.lower().find("skip_segmentation") != -1: + self.skipSegmentation = True + addPreProcessParams = False + else: + self.skipSegmentation = False + if ini_filename is not None: + self.ini_filename = ini_filename + elif is_tracker: + self.ini_filename = "last_params_trackers.ini" + addPreProcessParams = False + addPostProcessParams = False + else: + self.ini_filename = "last_params_segm_models.ini" + + self.addPreProcessParams = addPreProcessParams + + self.model_name = model_name + + self.setWindowTitle(f"{model_name} parameters") + + # Create main vertical layout and horizontal layout for two columns + mainLayout = QVBoxLayout() + + gridLayout = QGridLayout() + self.gridLayout = gridLayout + + loadFunc = self.loadLastSelection + + self.paramsGroupPosMapper = {} + + # LEFT COLUMN: Preprocessing params + row, col = 0, 0 + preProcessLayout = None + self.preProcessParamsWidget = None + if addPreProcessParams: + preProcessLayout = QVBoxLayout() + self.preProcessParamsWidget = PreProcessParamsWidget( + parent=self, addApplyButton=False + ) + self.preProcessParamsWidget.setChecked(False) + preProcessLayout.addWidget(self.preProcessParamsWidget) + self.preProcessParamsWidget.sigLoadRecipe.connect(self.loadPreprocRecipe) + gridLayout.addLayout(preProcessLayout, row, col, 1, 2) + self.paramsGroupPosMapper[self.preProcessParamsWidget] = (row, col) + gridLayout.addItem(QSpacerItem(10, 5), 0, col + 1) + # gridLayout.setColumnMinimumWidth(col+1, 15) + col += 2 + + # Center COLUMN: Init, Segmentation/Eval + row = 0 + self.secondColLayout = QVBoxLayout() + self.initParamsScrollArea = widgets.ScrollArea() + initParamsScrollAreaLayout = QVBoxLayout() + self.initParamsScrollArea.setVerticalLayout(initParamsScrollAreaLayout) + + initGroupBox, self.init_argsWidgets = self.createGroupParams( + init_params, "Parameters for model initialization" + ) + self.init_params = init_params + initDefaultButton = widgets.reloadPushButton("Restore default") + initLoadLastSelButton = widgets.OpenFilePushButton("Load last parameters") + initLoadLastSelButton.setIcon(QIcon(":folder-open.svg")) + initButtonsLayout = QHBoxLayout() + initButtonsLayout.addStretch(1) + initButtonsLayout.addWidget(initDefaultButton) + initButtonsLayout.addWidget(initLoadLastSelButton) + initDefaultButton.clicked.connect(self.restoreDefaultInit) + initLoadLastSelButton.clicked.connect( + partial(loadFunc, f"{self.model_name}.init", self.init_argsWidgets) + ) + + initParamsScrollAreaLayout.addWidget(initGroupBox) + + initParamsLayout = QVBoxLayout() + initParamsLayout.addWidget(QLabel(f"{initGroupBox.title()}")) + initGroupBox.setTitle("") + initParamsLayout.addWidget(self.initParamsScrollArea) + initParamsLayout.addLayout(initButtonsLayout) + self.secondColLayout.addLayout(initParamsLayout) + self.paramsGroupPosMapper[self.initParamsScrollArea] = (0, col) + + self.segmentParamsScrollArea = None + if not self.skipSegmentation: + self.segmentParamsScrollArea = widgets.ScrollArea() + segmentParamsScrollAreaLayout = QVBoxLayout() + self.segmentParamsScrollArea.setVerticalLayout( + segmentParamsScrollAreaLayout + ) + if action_type: + runGroupboxTitle = f"Parameters for {action_type}" + elif is_tracker: + runGroupboxTitle = "Parameters for tracking" + else: + runGroupboxTitle = "Parameters for segmentation" + + segmentGroupBox, self.argsWidgets = self.createGroupParams( + segment_params, runGroupboxTitle, addChannelSelector=True + ) + self.segment_params = segment_params + self.segmentGroupBox = segmentGroupBox + segmentDefaultButton = widgets.reloadPushButton("Restore default") + segmentLoadLastSelButton = widgets.OpenFilePushButton( + "Load last parameters" + ) + segmentButtonsLayout = QHBoxLayout() + segmentButtonsLayout.addStretch(1) + segmentButtonsLayout.addWidget(segmentDefaultButton) + segmentButtonsLayout.addWidget(segmentLoadLastSelButton) + segmentDefaultButton.clicked.connect(self.restoreDefaultSegment) + section = f"{self.model_name}.segment" + segmentLoadLastSelButton.clicked.connect( + partial(loadFunc, section, self.argsWidgets) + ) + segmentParamsScrollAreaLayout.addWidget(segmentGroupBox) + + segmentParamsLayout = QVBoxLayout() + segmentParamsLayout.addWidget(QLabel(f"{segmentGroupBox.title()}")) + segmentGroupBox.setTitle("") + segmentParamsLayout.addWidget(self.segmentParamsScrollArea) + segmentParamsLayout.addLayout(segmentButtonsLayout) + self.secondColLayout.addLayout(segmentParamsLayout) + self.paramsGroupPosMapper[self.segmentParamsScrollArea] = (1, col) + + gridLayout.addLayout(self.secondColLayout, row, col) + + gridLayout.addItem(QSpacerItem(10, 5), 0, col + 1) + col += 2 + + # Buttons layout (spans both columns) + buttonsLayout = QHBoxLayout() + cancelButton = widgets.cancelPushButton(" Cancel ") + okButton = widgets.okPushButton(" Ok ") + + enableLoadingSavingRecipe = not is_tracker and ( + addPreProcessParams or addPostProcessParams + ) + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + if enableLoadingSavingRecipe: + loadEntireRecipeButton = widgets.OpenFilePushButton("Load saved recipe...") + saveEntireRecipeButton = widgets.savePushButton( + "Save all parameters to recipe file..." + ) + buttonsLayout.addWidget(loadEntireRecipeButton) + buttonsLayout.addWidget(saveEntireRecipeButton) + loadEntireRecipeButton.clicked.connect(self.loadEntireRecipe) + saveEntireRecipeButton.clicked.connect(self.saveEntireRecipe) + + buttonsLayout.addWidget(okButton) + + buttonsLayout.setContentsMargins(0, 10, 0, 10) + + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + + self.okButton = okButton + + # Extra params in right column + row = 0 + self.extraArgsWidgets = None + self.extraParamsScrollArea = None + if extraParams is not None: + self.extraParamsScrollArea = widgets.ScrollArea() + extraParamsScrollAreaLayout = QVBoxLayout() + self.extraParamsScrollArea.setVerticalLayout(extraParamsScrollAreaLayout) + if extraParamsTitle is None: + extraParamsTitle = "Additional parameters" + + self.extraGroupBox, self.extraArgsWidgets = self.createGroupParams( + extraParams, extraParamsTitle + ) + + extraDefaultButton = widgets.reloadPushButton("Restore default") + extraLoadLastSelButton = widgets.OpenFilePushButton("Load last parameters") + extraButtonsLayout = QHBoxLayout() + extraButtonsLayout.addStretch(1) + extraButtonsLayout.addWidget(extraDefaultButton) + extraButtonsLayout.addWidget(extraLoadLastSelButton) + extraDefaultButton.clicked.connect(self.restoreDefaultExtra) + section = f"{self.model_name}.extra" + extraLoadLastSelButton.clicked.connect( + partial(loadFunc, section, self.extraArgsWidgets) + ) + + extraParamsScrollAreaLayout.addWidget(self.extraGroupBox) + + extraParamsLayout = QVBoxLayout() + extraParamsLayout.addWidget(QLabel(f"{self.extraGroupBox.title()}")) + self.extraGroupBox.setTitle("") + extraParamsLayout.addWidget(self.extraParamsScrollArea) + extraParamsLayout.addLayout(extraButtonsLayout) + self.paramsGroupPosMapper[self.extraParamsScrollArea] = (row, col) + gridLayout.addLayout(extraParamsLayout, row, col) + row += 1 + + # Post-processing in right-most column + self.postProcessGroupbox = None + self.seeHereLabel = None + thirdColumnLayout = QVBoxLayout() + if addPostProcessParams: + # Add minimum size spinbox which is valid for all models + postProcessGroupbox = PostProcessSegmParams( + "Post-processing segmentation parameters", + posData, + force_postprocess_2D=force_postprocess_2D, + ) + postProcessGroupbox.setCheckable(True) + postProcessGroupbox.setChecked(False) + self.postProcessGroupbox = postProcessGroupbox + + thirdColumnLayout.addWidget(postProcessGroupbox) + + postProcDefaultButton = widgets.reloadPushButton("Restore default") + postProcLoadLastSelButton = widgets.OpenFilePushButton( + "Load last parameters" + ) + postProcButtonsLayout = QHBoxLayout() + postProcButtonsLayout.addStretch(1) + postProcButtonsLayout.addWidget(postProcDefaultButton) + postProcButtonsLayout.addWidget(postProcLoadLastSelButton) + postProcDefaultButton.clicked.connect(self.restoreDefaultPostprocess) + postProcLoadLastSelButton.clicked.connect(self.loadLastSelectionPostProcess) + thirdColumnLayout.addLayout(postProcButtonsLayout) + thirdColumnLayout.addSpacing(15) + + if url is not None: + self.seeHereLabel = self.createSeeHereLabel(url) + thirdColumnLayout.addWidget(self.seeHereLabel, alignment=Qt.AlignCenter) + + self.paramsGroupPosMapper[self.preProcessParamsWidget] = (row, col) + + # Additional segmentation params in right column + self.additionalSegmGroupbox = None + if add_additional_segm_params: + thirdColumnLayout.addWidget(widgets.QHLine()) + additionalSegmGroupbox = self.getAdditionalSegmParams() + thirdColumnLayout.addWidget(additionalSegmGroupbox) + self.additionalSegmGroupbox = additionalSegmGroupbox + self.paramsGroupPosMapper[self.additionalSegmGroupbox] = (row, col) + + thirdColumnLayout.addStretch(1) + gridLayout.addLayout(thirdColumnLayout, row, col) + row += 1 + + # Add everything to main layout + mainLayout.addLayout(gridLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.configPars = self.readLastSelection() + if self.configPars is None: + initLoadLastSelButton.setDisabled(True) + segmentLoadLastSelButton.setDisabled(True) + if self.postProcessGroupbox is not None: + postProcLoadLastSelButton.setDisabled(True) + + if initLastParams: + initLoadLastSelButton.click() + if not self.skipSegmentation: + segmentLoadLastSelButton.click() + + if self.extraArgsWidgets is not None: + extraLoadLastSelButton.click() + + if self.postProcessGroupbox is not None: + postProcLoadLastSelButton.click() + + try: + self.connectCustomSignals(model_module) + except Exception as e: + printl(traceback.format_exc()) + + self.setLayout(mainLayout) + self.setFont(font) + # self.setModal(True) + + def warningNoSegmRecipes(self): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + "No segmentation recipes found!

    " + "To create a segmentation recipe you need click on " + "Save all parameters to recipe file... " + "button." + ) + msg.warning(self, "No segmentation recipes found!", txt) + + def selectIniFileToLoadEntireRecipe(self): + import qtpy.compat + + recipe_filepath = qtpy.compat.getopenfilename( + parent=self, + caption="Select INI file to load entire recipe", + filters="INI (*.ini);;All Files (*)", + )[0] + if not recipe_filepath: + return + + self.loadRecipeFromFilepath(recipe_filepath) + + txt = html_utils.paragraph("Done!

    Segmentation recipe loaded from:") + msg = widgets.myMessageBox() + msg.information( + self, + "Segmentation recipe loaded!", + txt, + commands=(recipe_filepath,), + path_to_browse=os.path.dirname(recipe_filepath), + ) + + print("Done. Segmentation recipe loaded from:", recipe_filepath) + + def loadEntireRecipe(self): + segm_recipes_path_model = os.path.join(segm_recipes_path, self.model_name) + + if not os.path.exists(segm_recipes_path_model): + # self.warningNoSegmRecipes() + self.selectIniFileToLoadEntireRecipe() + return + + recipe_files = os.listdir(segm_recipes_path_model) + + if not recipe_files: + # self.warningNoSegmRecipes() + self.selectIniFileToLoadEntireRecipe() + return + + headerLabels = ["Name", "Date Created"] + items = [] + for recipe_file in recipe_files: + cp = config.ConfigParser() + cp.read(os.path.join(segm_recipes_path_model, recipe_file)) + date_created = cp["info"]["created_on"] + items.append((recipe_file, date_created)) + + browseButton = widgets.browseFileButton( + "Select INI file...", + title="Select INI file to load entire recipe", + openFolder=False, + start_dir=myutils.getMostRecentPath(), + ext={"INI": ".ini"}, + ) + win = QTreeDialog( + items, + headerLabels=headerLabels, + title="Select a segmentation recipe to load", + infoText="Select a segmentation recipe to load:
    ", + path_to_browse=segm_recipes_path_model, + additional_buttons=(browseButton,), + ) + browseButton.sigPathSelected.connect( + partial( + self.entireRecipeIniFileSelected, + selectRecipeWin=win, + sender=browseButton, + ) + ) + win.exec_() + if win.cancel or not hasattr(win, "selectedText"): + print("Loading segmentation recipe cancelled.") + return + + if win.clickedButton == browseButton: + recipe_filepath = win.selectedIniFilepath + else: + recipe_filename = win.selectedText + recipe_filepath = os.path.join(segm_recipes_path_model, recipe_filename) + + self.loadRecipeFromFilepath(recipe_filepath) + + txt = html_utils.paragraph("Done!

    Segmentation recipe loaded from:") + msg = widgets.myMessageBox() + msg.information( + self, + "Segmentation recipe laoded!", + txt, + commands=(recipe_filepath,), + path_to_browse=os.path.dirname(recipe_filepath), + ) + + print("Done. Segmentation recipe loaded from:", recipe_filepath) + + def entireRecipeIniFileSelected( + self, recipe_filepath, selectRecipeWin=None, sender=None + ): + selectRecipeWin.selectedText = "None" + selectRecipeWin.clickedButton = sender + selectRecipeWin.selectedIniFilepath = recipe_filepath + selectRecipeWin.cancel = False + selectRecipeWin.close() + + def loadRecipeFromFilepath(self, recipe_filepath): + cp = config.ConfigParser() + cp.read(recipe_filepath) + + self.loadPreprocRecipe(configPars=cp) + self.loadLastSelection( + f"{self.model_name}.init", self.init_argsWidgets, configPars=cp + ) + self.loadLastSelection( + f"{self.model_name}.segment", self.argsWidgets, configPars=cp + ) + if self.extraArgsWidgets: + self.loadLastSelection( + f"{self.model_name}.extra", self.extraArgsWidgets, configPars=cp + ) + self.loadLastSelectionPostProcess(configPars=cp) + + def saveEntireRecipe(self): + segm_recipes_path_model = os.path.join(segm_recipes_path, self.model_name) + try: + existingNames = os.listdir(segm_recipes_path_model) + except FileNotFoundError: + existingNames = [] + + win = filenameDialog( + title="Filename for segmentation recipe", + basename="segmentation_recipe", + ext=".ini", + hintText="Insert a filename for the segmentation recipe:", + allowEmpty=False, + parent=self, + existingNames=existingNames, + ) + win.exec_() + if win.cancel: + return + + ini_filename = win.filename + os.makedirs(segm_recipes_path, exist_ok=True) + os.makedirs(segm_recipes_path_model, exist_ok=True) + ini_filepath = os.path.join(segm_recipes_path_model, ini_filename) + + configPars = self.getConfigPars(create_new=True) + + if hasattr(self, "reduceMemUsageToggle"): + configPars[f"{self.model_name}.additional_segm_params"] = {} + reduceMemoryUsage = self.reduceMemUsageToggle.isChecked() + option = self.reduceMemUsageToggle.label + configPars[f"{self.model_name}.additional_segm_params"][option] = str( + reduceMemoryUsage + ) + + configPars["info"] = {} + configPars["info"]["created_on"] = datetime.datetime.now().strftime( + r"%Y/%m/%d %H:%M" + ) + + with open(ini_filepath, "w") as configfile: + configPars.write(configfile) + + txt = html_utils.paragraph("Done!

    Segmentation recipe saved to:") + msg = widgets.myMessageBox() + msg.information( + self, + "Segmnentation recipe saved!", + txt, + commands=(ini_filepath,), + path_to_browse=os.path.dirname(ini_filepath), + ) + + print("Done. Segmentation recipe saved to:", ini_filepath) + + def getAdditionalSegmParams(self): + additionalSegmGroupbox = QGroupBox("Additional segmentation parameters") + local_row = 0 + additionalSegmLayout = QGridLayout() + option = "Reduce memory usage" + additionalSegmLayout.addWidget( + QLabel(f"{option}: "), local_row, 0, alignment=Qt.AlignRight + ) + self.reduceMemUsageToggle = widgets.Toggle() + additionalSegmLayout.addWidget( + self.reduceMemUsageToggle, local_row, 1, 1, 2, alignment=Qt.AlignCenter + ) + self.reduceMemUsageToggle.label = option + reduceMemUsageInfoButton = widgets.infoPushButton() + additionalSegmLayout.addWidget(reduceMemUsageInfoButton, local_row, 3) + reduceMemUsageInfoButton.clicked.connect(self.showInfoReduceMemUsage) + additionalSegmLayout.setColumnStretch(0, 0) + additionalSegmLayout.setColumnStretch(1, 1) + additionalSegmLayout.setColumnStretch(3, 0) + additionalSegmGroupbox.setLayout(additionalSegmLayout) + return additionalSegmGroupbox + + def showInfoReduceMemUsage(self): + infoText = html_utils.paragraph(f""" + If you are experiencing memory issues, you can try reducing the + memory usage by toggling this option.

    + This will reduce the memory usage by segmenting timelapse data + frame-by-frame instead of all frames at once. + """) + msg = widgets.myMessageBox(wrapText=False) + msg.information(self, "Reduce memory usage", infoText) + + def loadPreprocRecipe(self, configPars=None): + if self.configPars is None and configPars is None: + return + + if configPars is None: + configPars = self.configPars + + preprocConfigPars = {} + for section in configPars.sections(): + if not section.startswith(f"{self.model_name}.preprocess"): + continue + + preprocConfigPars[section] = configPars[section] + + if not preprocConfigPars: + return + + self.preProcessParamsWidget.loadRecipe(preprocConfigPars) + + def connectCustomSignals(self, model_module): + if model_module is None: + return + + if not hasattr(model_module, "CustomSignals"): + return + + customSignals = model_module.CustomSignals() + for slot_info in customSignals.slots_info: + group = slot_info["group"] + widget_name = slot_info["widget_name"] + if group == "init": + ArgsWidgets_list = self.init_argsWidgets + else: + ArgsWidgets_list = self.argsWidgets + for argwidget in ArgsWidgets_list: + if argwidget.name == widget_name: + signal = getattr(argwidget.widget, slot_info["signal"]) + signal.connect(partial(slot_info["slot"], self)) + break + + def selectedFeaturesRange(self): + if self.postProcessGroupbox is None: + return {} + return self.postProcessGroupbox.selectedFeaturesRange() + + def groupedFeatures(self): + if self.postProcessGroupbox is None: + return {} + return self.postProcessGroupbox.groupedFeatures() + + def setChannelNames(self, chNames): + if not hasattr(self, "channelsCombobox"): + return + + items = ["None"] + items.extend(chNames) + self.channelsCombobox.addItems(items) + + def getValueFromMetadata(self, name): + try: + value = self.df_metadata.at[name, "values"] + except Exception as e: + # traceback.print_exc() + value = None + return value + + def criticalSegmFileRequiredButNoneAvailable(self): + model_name = f"{self.model_name} model" + action_txt = ( + f"Please, segment the correct channel before using {self.model_name}." + ) + if self.model_name == "skip_segmentation": + model_name = "Skipping the segmentation" + action_txt = ( + "To be able to skip the segmentation step, you need " + "create at least one segmentation file." + ) + txt = html_utils.paragraph(f""" + {model_name} + requires an additional segmentation file + but there are none available!

    + {action_txt} +

    Thank you for you patience! + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Segmentation file required", txt) + raise FileNotFoundError( + "Model requires segmentation file but none are available." + ) + + def checkAddSegmEndnameCombobox(self, ArgSpec, groupBoxLayout, row): + if ArgSpec.name != "Auxiliary segmentation file": + return False + + if self.segmFileEndnames is None or not self.segmFileEndnames: + self.criticalSegmFileRequiredButNoneAvailable() + + label = QLabel(f"{ArgSpec.name}: ") + groupBoxLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + items = self.segmFileEndnames + self.segmEndnameCombobox = widgets.QCenteredComboBox() + self.segmEndnameCombobox.addItems(items) + groupBoxLayout.addWidget(self.segmEndnameCombobox, row, 1, 1, 2) + return True + + def createGroupParams(self, ArgSpecs_list, groupName, addChannelSelector=False): + ArgsWidgets_list = [] + groupBox = QGroupBox(groupName) + groupBoxLayout = QGridLayout() + + start_row = 0 + if self.is_tracker and self.channels is not None and addChannelSelector: + label = QLabel(f"Input image: ") + groupBoxLayout.addWidget(label, start_row, 0, alignment=Qt.AlignRight) + items = ["None", *self.channels] + self.channelCombobox = widgets.QCenteredComboBox() + self.channelCombobox.addItems(items) + groupBoxLayout.addWidget(self.channelCombobox, start_row, 1, 1, 2) + if self.currentChannelName is not None: + self.channelCombobox.setCurrentText(self.currentChannelName) + infoText = ( + "Some trackers require the intensity image as input.

    " + "If this one does not require it, leave the selected value " + "to `None`." + ) + infoButton = self.getInfoButton("Input image", infoText) + groupBoxLayout.addWidget(infoButton, start_row, 3) + start_row += 1 + + addSecondChannelSelector = addChannelSelector + if len(ArgSpecs_list) > 0: + if addSecondChannelSelector and ArgSpecs_list[0].docstring is not None: + isSingleChannel = ( + ArgSpecs_list[0].docstring.lower().find("single channel only") != -1 + ) + if isSingleChannel: + addSecondChannelSelector = False + + isDualChannelModel = self.model_name.find("cellpose") != -1 or any( + [_types.is_second_channel_type(ArgSpec.type) for ArgSpec in ArgSpecs_list] + ) + askSecondChannel = isDualChannelModel and addSecondChannelSelector + + if askSecondChannel: + label = QLabel("Second channel (optional): ") + groupBoxLayout.addWidget(label, start_row, 0, alignment=Qt.AlignRight) + self.channelsCombobox = widgets.QCenteredComboBox() + groupBoxLayout.addWidget(self.channelsCombobox, start_row, 1, 1, 2) + infoText = ( + "Some models can merge two channels (e.g., cyto + " + "nucleus) to obtain better perfomance.\n\n" + "Select a channel as additional input to the model." + ) + infoButton = self.getInfoButton("Second channel", infoText) + groupBoxLayout.addWidget(infoButton, start_row, 3) + start_row += 1 + + exclusive_withs = dict() + default_exclusives = dict() + row_mapper = dict() + for row, ArgSpec in enumerate(ArgSpecs_list): + if _types.is_second_channel_type(ArgSpec.type): + continue + + if _types.is_widget_not_required(ArgSpec): + continue + + row = row + start_row + skip = self.checkAddSegmEndnameCombobox(ArgSpec, groupBoxLayout, row) + if skip: + continue + + arg_name = ArgSpec.name + var_name = arg_name.replace("_", " ") + var_name = f"{var_name[0].upper()}{var_name[1:]}" + label = QLabel(f"{var_name}: ") + metadata_val = self.getValueFromMetadata(ArgSpec.name) + groupBoxLayout.addWidget(label, row, 0, alignment=Qt.AlignRight) + try: + values = ArgSpec.type().values + isCustomListType = True + except Exception as err: + isCustomListType = False + + isVectorEntry = False + try: + if isinstance(ArgSpec.type(), _types.Vector): + isVectorEntry = True + except Exception as err: + pass + + isFolderPath = False + try: + if isinstance(ArgSpec.type(), _types.FolderPath): + isFolderPath = True + except Exception as err: + pass + + try: + exclusive_with = ArgSpec.type().is_exclusive_with + except Exception as err: + exclusive_with = [] + + try: + default_exclusive = ArgSpec.type().default_exclusive + except Exception as err: + default_exclusive = "" + + exclusive_withs[arg_name] = exclusive_with + default_exclusives[arg_name] = default_exclusive + row_mapper[arg_name] = row + + isCustomWidget = hasattr(ArgSpec.type, "isWidget") + + if isCustomWidget: + widget = ArgSpec.type().widget + defaultVal = ArgSpec.default + valueSetter = widget.setValue + valueGetter = widget.value + changeSig = widget.sigValueChanged + groupBoxLayout.addWidget(widget, row, 1, 1, 2) + elif isVectorEntry: + vectorLineEdit = widgets.VectorLineEdit() + vectorLineEdit.setValue(ArgSpec.default) + defaultVal = ArgSpec.default + valueSetter = widgets.VectorLineEdit.setValue + valueGetter = widgets.VectorLineEdit.value + changeSig = vectorLineEdit.valueChanged + widget = vectorLineEdit + groupBoxLayout.addWidget(vectorLineEdit, row, 1, 1, 2) + elif isFolderPath: + folderPathControl = widgets.FolderPathControl() + folderPathControl.setText(str(ArgSpec.default)) + widget = folderPathControl + defaultVal = str(ArgSpec.default) + valueSetter = widgets.FolderPathControl.setText + valueGetter = widgets.FolderPathControl.path + changeSig = widget.sigValueChanged + groupBoxLayout.addWidget(folderPathControl, row, 1, 1, 2) + elif ArgSpec.type == bool: + booleanGroup = QButtonGroup() + booleanGroup.setExclusive(True) + checkBox = widgets.Toggle() + checkBox.setChecked(ArgSpec.default) + defaultVal = ArgSpec.default + valueSetter = widgets.Toggle.setChecked + valueGetter = widgets.Toggle.isChecked + changeSig = checkBox.toggled + widget = checkBox + groupBoxLayout.addWidget( + checkBox, row, 1, 1, 2, alignment=Qt.AlignCenter + ) + elif ArgSpec.type == int: + spinBox = widgets.SpinBox() + if metadata_val is None: + spinBox.setValue(ArgSpec.default) + else: + spinBox.setValue(int(metadata_val)) + spinBox.isMetadataValue = True + defaultVal = ArgSpec.default + valueSetter = QSpinBox.setValue + valueGetter = QSpinBox.value + changeSig = spinBox.sigValueChanged + widget = spinBox + groupBoxLayout.addWidget(spinBox, row, 1, 1, 2) + elif ArgSpec.type == float: + doubleSpinBox = widgets.FloatLineEdit() + if metadata_val is None: + doubleSpinBox.setValue(ArgSpec.default) + else: + doubleSpinBox.setValue(float(metadata_val)) + doubleSpinBox.isMetadataValue = True + widget = doubleSpinBox + defaultVal = ArgSpec.default + valueSetter = widgets.FloatLineEdit.setValue + valueGetter = widgets.FloatLineEdit.value + changeSig = doubleSpinBox.valueChanged + groupBoxLayout.addWidget(doubleSpinBox, row, 1, 1, 2) + elif ArgSpec.type == os.PathLike: + filePathControl = widgets.filePathControl() + filePathControl.setText(str(ArgSpec.default)) + widget = filePathControl + defaultVal = str(ArgSpec.default) + valueSetter = widgets.filePathControl.setText + valueGetter = widgets.filePathControl.path + changeSig = filePathControl.sigValueChanged + groupBoxLayout.addWidget(filePathControl, row, 1, 1, 2) + elif isCustomListType: + items = ArgSpec.type().values + defaultVal = str(ArgSpec.default) + combobox = widgets.AlphaNumericComboBox() + combobox.addItems(items) + combobox.setCurrentValue(defaultVal) + valueSetter = widgets.AlphaNumericComboBox.setCurrentValue + valueGetter = widgets.AlphaNumericComboBox.currentValue + changeSig = combobox.currentTextChanged + widget = combobox + groupBoxLayout.addWidget(combobox, row, 1, 1, 2) + else: + lineEdit = QLineEdit() + lineEdit.setText(str(ArgSpec.default)) + lineEdit.setAlignment(Qt.AlignCenter) + widget = lineEdit + defaultVal = str(ArgSpec.default) + valueSetter = QLineEdit.setText + valueGetter = QLineEdit.text + changeSig = lineEdit.editingFinished + groupBoxLayout.addWidget(lineEdit, row, 1, 1, 2) + + if ArgSpec.desc: + infoButton = self.getInfoButton(ArgSpec.name, ArgSpec.desc) + groupBoxLayout.addWidget(infoButton, row, 3) + + argsInfo = ArgWidget( + name=ArgSpec.name, + type=ArgSpec.type, + widget=widget, + defaultVal=defaultVal, + valueSetter=valueSetter, + valueGetter=valueGetter, + changeSig=changeSig, + ) + ArgsWidgets_list.append(argsInfo) + + exclusive_group = core.connected_components_in_undirected_graph(exclusive_withs) + + for group in exclusive_group: + if len(group) == 1: + continue + for arg_name in group: + default_exclusive = default_exclusives[arg_name] + row = row_mapper[arg_name] + + argsInfo = ArgsWidgets_list[row] + valueSetter = argsInfo.valueSetter + widget = argsInfo.widget + valueGetter = argsInfo.valueGetter + + argsInfo.valueGetter = qutils.replace_certain_vals( + argsInfo.valueGetter, default_exclusive, None + ) + + for arg_name_other in group: + if arg_name == arg_name_other: + continue + row_other = row_mapper[arg_name_other] + argsInfo_other = ArgsWidgets_list[row_other] + changeSig_other = argsInfo_other.changeSig + changeSig_other.connect( + partial( + qutils.set_exclusive_valueSetter, + widget, + valueSetter, + default_exclusive, + ) + ) + + groupBoxLayout.setColumnStretch(0, 0) + groupBoxLayout.setColumnStretch(1, 1) + groupBoxLayout.setColumnStretch(3, 0) + nrows = groupBoxLayout.rowCount() + groupBoxLayout.setRowStretch(nrows, 1) + + groupBox.setLayout(groupBoxLayout) + return groupBox, ArgsWidgets_list + + def getInfoButton(self, param_name, infoText): + infoButton = widgets.infoPushButton() + infoButton.param_name = param_name + infoButton.setToolTip( + f"Click to get more info about `{param_name}` parameter..." + ) + infoButton.infoText = infoText + infoButton.clicked.connect(self.showInfoParam) + return infoButton + + def showInfoParam(self): + text = self.sender().infoText + text = text.replace("\n", "
    ") + text = html_utils.rst_urls_to_html(text) + text = html_utils.rst_to_html(text) + text = html_utils.paragraph(text) + param_name = self.sender().param_name + msg = widgets.myMessageBox(wrapText=False) + msg.information(self, f"Info about `{param_name}` parameter", text) + + def restoreDefaultInit(self): + for argWidget in self.init_argsWidgets: + defaultVal = argWidget.defaultVal + widget = argWidget.widget + valueSetter = argWidget.valueSetter + qutils.set_exclusive_valueSetter(widget, valueSetter, defaultVal) + + def restoreDefaultSegment(self): + for argWidget in self.argsWidgets: + defaultVal = argWidget.defaultVal + widget = argWidget.widget + valueSetter = argWidget.valueSetter + qutils.set_exclusive_valueSetter(widget, valueSetter, defaultVal) + + def restoreDefaultExtra(self): + for argWidget in self.extraArgsWidgets: + defaultVal = argWidget.defaultVal + widget = argWidget.widget + valueSetter = argWidget.valueSetter + qutils.set_exclusive_valueSetter(widget, valueSetter, defaultVal) + + def restoreDefaultPostprocess(self): + self.postProcessGroupbox.restoreDefault() + + def readLastSelection(self): + self.ini_path = os.path.join(settings_folderpath, self.ini_filename) + + if not os.path.exists(self.ini_path): + return None + + print(f"Reading last selected parameters from: {self.ini_path}") + configPars = config.ConfigParser() + configPars.read(self.ini_path) + return configPars + + def setValuesFromParams(self, init_params, segment_params, extra_params=None): + sections = { + f"{self.model_name}.init": (init_params, self.init_argsWidgets), + f"{self.model_name}.segment": (segment_params, self.argsWidgets), + } + if extra_params is not None: + sections[f"{self.model_name}.extra"] = (extra_params, self.extraArgsWidgets) + + for section, values in sections.items(): + params, argWidgetList = values + for argWidget in argWidgetList: + val = params.get(argWidget.name) + widget = argWidget.widget + if val is None: + continue + casters = [lambda x: x, int, float, str, bool] + for caster in casters: + try: + argWidget.valueSetter(widget, caster(val)) + break + except Exception as e: + continue + + def loadLastSelection(self, section, argWidgetList, checked=False, configPars=None): + if self.configPars is None and configPars is None: + return + + if configPars is None: + configPars = self.configPars + + getters = ["getboolean", "getint", "getfloat", "get"] + try: + options = configPars.options(section) + except Exception: + return + + for argWidget in argWidgetList: + option = argWidget.name + val = None + for getter in getters: + try: + val = getattr(configPars, getter)(section, option) + break + except Exception as err: + pass + widget = argWidget.widget + + if hasattr(widget, "isMetadataValue"): + continue + if val is None: + continue + + casters = [lambda x: x, int, float, str, bool] + for caster in casters: + try: + val = caster(val) + valueSetter = argWidget.valueSetter + qutils.set_exclusive_valueSetter(widget, valueSetter, val) + break + except Exception as e: + printl(traceback.format_exc()) + continue + + def loadLastSelectionPostProcess(self, checked=False, configPars=None): + if self.postProcessGroupbox is None: + return + + postProcessSection = f"{self.model_name}.postprocess" + + if isinstance(configPars, bool): + configPars = None + + if configPars is None: + configPars = self.configPars + + if postProcessSection in configPars.sections(): + try: + minSize = configPars.getint(postProcessSection, "minSize", fallback=10) + except ValueError: + minSize = 10 + + try: + minSolidity = configPars.getfloat( + postProcessSection, "minSolidity", fallback=0.5 + ) + except ValueError: + minSolidity = 0.5 + + try: + maxElongation = configPars.getfloat( + postProcessSection, "maxElongation", fallback=3 + ) + except ValueError: + maxElongation = 3 + + try: + minObjSizeZ = configPars.getint( + postProcessSection, "min_obj_no_zslices", fallback=3 + ) + except ValueError: + minObjSizeZ = 3 + + kwargs = { + "min_solidity": minSolidity, + "min_area": minSize, + "max_elongation": maxElongation, + "min_obj_no_zslices": minObjSizeZ, + } + self.postProcessGroupbox.restoreFromKwargs(kwargs) + + applyPostProcessing = configPars.getboolean( + postProcessSection, "applyPostProcessing" + ) + self.postProcessGroupbox.setChecked(applyPostProcessing) + + customPostProcessSection = f"{self.model_name}.custom_postprocess" + if postProcessSection not in configPars.sections(): + return + + selectFeaturesWidget = self.postProcessGroupbox.selectedFeaturesDialog.groupbox + selectFeaturesWidget.resetFields() + f = 0 + for col_name, value in configPars[customPostProcessSection].items(): + low, high = value.split(",") + low = low.strip() + high = high.strip() + if f > 0: + selectFeaturesWidget.addFeatureField() + + selector = selectFeaturesWidget.selectors[f] + selector.selectButton.setText(col_name) + selector.selectButton.setFlat(True) + + feature_group = measurements.get_metric_group_name(col_name) + selector.featureGroup = feature_group + + if low != "None": + try: + low_val = int(low) + except ValueError: + low_val = float(low) + + selector.lowRangeWidgets.checkbox.setChecked(True) + selector.lowRangeWidgets.spinbox.setValue(low_val) + + if high != "None": + try: + high_val = int(high) + except ValueError: + high_val = float(high) + + selector.highRangeWidgets.checkbox.setChecked(True) + selector.highRangeWidgets.spinbox.setValue(high_val) + + f += 1 + + def createSeeHereLabel(self, url): + htmlTxt = f'here' + seeHereLabel = QLabel() + seeHereLabel.setText(f""" +

    + See {htmlTxt} for details on the parameters +

    + """) + seeHereLabel.setTextFormat(Qt.RichText) + seeHereLabel.setTextInteractionFlags(Qt.TextBrowserInteraction) + seeHereLabel.setOpenExternalLinks(True) + seeHereLabel.setStyleSheet("padding:12px 0px 0px 0px;") + return seeHereLabel + + def argsWidgets_to_kwargs(self, argsWidgets): + kwargs_dict = { + argWidget.name: argWidget.valueGetter(argWidget.widget) + for argWidget in argsWidgets + } + return kwargs_dict + + def getInitKwargs(self): + init_kwargs = self.argsWidgets_to_kwargs(self.init_argsWidgets) + if hasattr(self, "segmEndnameCombobox"): + init_kwargs["segm_endname"] = self.segmEndnameCombobox.currentText() + + return init_kwargs + + def getModelKwargs(self): + if self.skipSegmentation: + return {} + + return self.argsWidgets_to_kwargs(self.argsWidgets) + + def getExtraKwargs(self): + if self.extraArgsWidgets is None: + return {} + + return self.argsWidgets_to_kwargs(self.extraArgsWidgets) + + def ok_cb(self, checked): + self.cancel = False + self.preproc_recipe = None + if self.preProcessParamsWidget is not None: + self.preproc_recipe = self.preProcessParamsWidget.recipe() + if self.preproc_recipe is None: + return + + self.init_kwargs = self.getInitKwargs() + + if self.extraArgsWidgets: + self.extra_kwargs = self.getExtraKwargs() + + self.model_kwargs = self.getModelKwargs() + self.segment_kwargs = self.model_kwargs + + if self.postProcessGroupbox is not None: + self.applyPostProcessing = self.postProcessGroupbox.isChecked() + self.standardPostProcessKwargs = self.postProcessGroupbox.kwargs() + self.secondChannelName = None + if hasattr(self, "channelsCombobox"): + self.secondChannelName = self.channelsCombobox.currentText() + if self.secondChannelName == "None": + self.secondChannelName = None + self.inputChannelName = "None" + if self.channelCombobox is not None: + self.inputChannelName = self.channelCombobox.currentText() + + self.reduceMemoryUsage = False + if hasattr(self, "reduceMemUsageToggle"): + self.reduceMemoryUsage = self.reduceMemUsageToggle.isChecked() + self.customPostProcessFeatures = self.selectedFeaturesRange() + self.customPostProcessGroupedFeatures = self.groupedFeatures() + self.saveLastSelection() + self.freePosData() + self.close() + + def freePosData(self): + if hasattr(self, "postProcessGroupbox"): + try: + for ( + selector + ) in self.postProcessGroupbox.selectedFeaturesDialog.groupbox.selectors: + qutils.hardDelete(selector) + except AttributeError: + pass + try: + qutils.hardDelete( + self.postProcessGroupbox.selectedFeaturesDialog.groupbox + ) + except AttributeError: + pass + try: + qutils.hardDelete(self.postProcessGroupbox.selectedFeaturesDialog) + except AttributeError: + pass + try: + qutils.hardDelete(self.postProcessGroupbox) + except AttributeError: + pass + + def getConfigPars(self, create_new=False): + if self.configPars is None or create_new: + configPars = config.ConfigParser() + else: + configPars = self.configPars + + if self.preProcessParamsWidget is not None: + preprocCp = self.preProcessParamsWidget.recipeConfigPars(self.model_name) + for section in preprocCp.sections(): + configPars[section] = preprocCp[section] + + configPars[f"{self.model_name}.init"] = {} + configPars[f"{self.model_name}.segment"] = {} + configPars[f"{self.model_name}.extra"] = {} + + init_kwargs = self.getInitKwargs() + model_kwargs = self.getModelKwargs() + + for key, val in init_kwargs.items(): + configPars[f"{self.model_name}.init"][key] = str(val) + for key, val in model_kwargs.items(): + configPars[f"{self.model_name}.segment"][key] = str(val) + if self.extraArgsWidgets: + extra_kwargs = self.getExtraKwargs() + for key, val in extra_kwargs.items(): + configPars[f"{self.model_name}.extra"][key] = str(val) + + configPars[f"{self.model_name}.postprocess"] = {} + if self.postProcessGroupbox is not None: + postProcKwargs = self.postProcessGroupbox.kwargs() + postProcessConfig = configPars[f"{self.model_name}.postprocess"] + postProcessConfig["minSize"] = str(postProcKwargs["min_area"]) + postProcessConfig["minSolidity"] = str(postProcKwargs["min_solidity"]) + postProcessConfig["maxElongation"] = str(postProcKwargs["max_elongation"]) + postProcessConfig["min_obj_no_zslices"] = str( + postProcKwargs["min_obj_no_zslices"] + ) + postProcessConfig["applyPostProcessing"] = str( + self.postProcessGroupbox.isChecked() + ) + + custom_postproc_section = f"{self.model_name}.custom_postprocess" + configPars[custom_postproc_section] = {} + if self.postProcessGroupbox is not None: + selectFeaturesWidget = ( + self.postProcessGroupbox.selectedFeaturesDialog.groupbox + ) + for selector in selectFeaturesWidget.selectors: + col_name = selector.selectButton.text() + lowStr = "None" + highStr = "None" + if selector.lowRangeWidgets.checkbox.isChecked(): + lowVal = selector.lowRangeWidgets.spinbox.value() + lowStr = str(lowVal) + if selector.highRangeWidgets.checkbox.isChecked(): + highVal = selector.highRangeWidgets.spinbox.value() + highStr = str(highVal) + + configPars[custom_postproc_section][col_name] = f"{lowStr}, {highStr}" + + return configPars + + def saveLastSelection(self): + self.configPars = self.getConfigPars() + with open(self.ini_path, "w") as configfile: + self.configPars.write(configfile) + + mode = "Segmentation" if not self.is_tracker else "Tracking" + + print(f'{mode} parameters saved at "{self.ini_path}"') + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + if self.model_name == "thresholding": + self.segmentGroupBox.setDisabled(True) + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + self.freePosData() + if hasattr(self, "loop"): + self.loop.exit() + + def cancel_cb(self, checked): + self.cancel = True + self.freePosData() + + def showEvent(self, event) -> None: + buttonHeight = self.okButton.minimumSizeHint().height() + heightInitParams = self.initParamsScrollArea.minimumHeightNoScrollbar() + heightLeft = 70 + buttonHeight + heightCenter = heightInitParams + heightRight = 0 + if self.segmentParamsScrollArea is not None: + heightSegmentParams = ( + self.segmentParamsScrollArea.minimumHeightNoScrollbar() + ) + heightCenter += heightSegmentParams + 70 + buttonHeight + + rowInitParams, _ = self.paramsGroupPosMapper[self.initParamsScrollArea] + rowSegmParams, _ = self.paramsGroupPosMapper[self.segmentParamsScrollArea] + + numInitParams = len(self.init_params) + numSegmentParams = len(self.segment_params) + + try: + segmentParamsStretch = max(1, round(numSegmentParams / numInitParams)) + except ZeroDivisionError as err: + segmentParamsStretch = 1 + self.secondColLayout.setStretch(rowInitParams, 1) + self.secondColLayout.setStretch(rowSegmParams, segmentParamsStretch) + + if self.extraParamsScrollArea is not None: + heightRight += ( + self.extraParamsScrollArea.minimumHeightNoScrollbar() + + 70 + + buttonHeight + ) + + if self.additionalSegmGroupbox is not None: + heightRight += self.additionalSegmGroupbox.minimumSizeHint().height() + heightRight += buttonHeight + if self.preProcessParamsWidget is not None: + heightPreprocParams = self.preProcessParamsWidget.minimumSizeHint().height() + heightLeft += heightPreprocParams + heightLeft += buttonHeight + if self.postProcessGroupbox is not None: + heightRight += self.postProcessGroupbox.minimumSizeHint().height() + heightRight += buttonHeight + if self.seeHereLabel is not None: + heightRight += self.seeHereLabel.minimumSizeHint().height() + height = max(heightLeft, heightRight, heightCenter) + screenHeight = self.screen().size().height() + screenGeom = self.screen().geometry() + screenLeft = screenGeom.left() + screenRight = screenGeom.right() + screenCenter = (screenLeft + screenRight) / 2 + width = self.sizeHint().width() + windowLeft = int(screenCenter - width / 2) + self.move(windowLeft, 20) + + if height >= screenHeight - 150: + height = screenHeight - 150 + self.resize(width, height) + + +class downloadModel: + def __init__(self, model_name, parent=None): + self.loop = None + self.model_name = model_name + self._parent = parent + + def download(self): + model_url = myutils._model_url(self.model_name) + if model_url is None: + return + + _, model_path = myutils.get_model_path(self.model_name, create_temp_dir=False) + model_name = self.model_name + model_exists = myutils.check_model_exists(model_path, model_name) + if not model_exists: + self.warnDownloadModel(model_path, self.model_name) + try: + self._parent.logger.info( + f'Downloading {self.model_name} model(s) to "{model_path}"' + ) + except Exception as err: + pass + + success = myutils.download_model(self.model_name) + if not success: + self.criticalDowloadFailed() + + def warnDownloadModel(self, model_path, model_name): + txt = html_utils.paragraph( + "Cell-ACDC needs to download the model " + f"{model_name}.

    " + "The files will be dowloaded into the following folder:

    " + f"{model_path}

    " + "Progress will be displayed in the terminal.
    " + ) + msg = widgets.myMessageBox() + msg.information(self._parent, "Download model", txt) + + def criticalDowloadFailed(self): + import cellacdc + + model_name = self.model_name + m = model_name.lower() + weights_filenames = getattr(cellacdc, f"{m}_weights_filenames") + url, alternative_url = myutils._model_url(model_name, return_alternative=True) + url_href = f'this link' + alternative_url_href = f'this link' + _, model_path = myutils.get_model_path(model_name, create_temp_dir=False) + txt = html_utils.paragraph(f""" + Automatic download of {model_name} failed.

    + Please, manually download the model weights from {url_href} or + {alternative_url_href}.

    + Next, unzip the content (or move the files if not a zip archive) + of the downloaded file into the following folder:

    + {model_path}

    + NOTE: if clicking on the link above does not work + copy one of the links below and paste it into the browser

    + {url} +

    + {alternative_url} + """) + weights_paths = [os.path.join(model_path, f) for f in weights_filenames] + weights = "\n\n".join(weights_paths) + detailsText = f"Files that {model_name} requires:\n\n{weights}" + msg = widgets.myMessageBox() + msg.critical( + self._parent, + f"Download of {model_name} failed", + txt, + detailsText=detailsText, + ) + self.close_() + + def close_(self): + return + + +class SelectAcdcDfVersionToRestore(QBaseDialog): + def __init__(self, posData, parent=None): + super().__init__(parent=parent) + + self.cancel = True + + self.setWindowTitle("Select annotations table to restore") + + mainLayout = QVBoxLayout() + + acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) + instructionsLabel = html_utils.paragraph( + f"Select an older version of the {acdc_df_filename} " + "annotations table to load.

    " + "The datetime refers to the time you replaced the old version with " + "a newer one.

    " + ) + mainLayout.addWidget(QLabel(instructionsLabel)) + + self.savedListBox = None + if os.path.exists(posData.acdc_output_backup_zip_path): + zip_path = posData.acdc_output_backup_zip_path + self.savedArchivefilepath = zip_path + with zipfile.ZipFile(zip_path, mode="r") as zip: + csv_names = natsorted(zip.namelist(), reverse=True) + + keys = [csv_name[:-4] for csv_name in csv_names] + + self.savedKeys = keys + f = load.ISO_TIMESTAMP_FORMAT + timestamps = [datetime.datetime.strptime(key, f) for key in keys] + items = [date.strftime(r"%d %b %Y, %H:%M:%S") for date in timestamps] + mainLayout.addWidget(QLabel("Saved annotations:")) + self.savedListBox = widgets.listWidget() + self.savedListBox.addItems(items) + mainLayout.addWidget(self.savedListBox) + self.savedListBox.itemSelectionChanged.connect(self.onItemSelectionChanged) + + recovery_folderpath = posData.recoveryFolderpath() + unsaved_recovery_folderpath = os.path.join(recovery_folderpath, "never_saved") + self.neverSavedFolderpath = unsaved_recovery_folderpath + files = myutils.listdir(unsaved_recovery_folderpath) + csv_files = [file for file in files if file.endswith(".csv")] + self.neverSavedListBox = None + if csv_files: + csv_names = natsorted(csv_files, reverse=True) + keys = [csv_name[:-4] for csv_name in csv_names] + self.neverSavedKeys = keys + f = load.ISO_TIMESTAMP_FORMAT + timestamps = [datetime.datetime.strptime(key, f) for key in keys] + items = [date.strftime(r"%d %b %Y, %H:%M:%S") for date in timestamps] + mainLayout.addWidget(QLabel("Never saved annotations:")) + self.neverSavedListBox = widgets.listWidget() + self.neverSavedListBox.addItems(items) + mainLayout.addWidget(self.neverSavedListBox) + self.neverSavedListBox.itemSelectionChanged.connect( + self.onItemSelectionChanged + ) + + cancelOkLayout = widgets.CancelOkButtonsLayout() + + cancelOkLayout.okButton.clicked.connect(self.ok_cb) + cancelOkLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addSpacing(20) + mainLayout.addLayout(cancelOkLayout) + + self.setLayout(mainLayout) + + self.setFont(font) + + def ok_cb(self): + self.cancel = False + try: + for i in range(self.savedListBox.count()): + item = self.savedListBox.item(i) + if item.isSelected(): + self.selectedTimestamp = item.text() + self.selectedKey = self.savedKeys[i] + self.archiveFilePath = self.savedArchivefilepath + break + except Exception as e: + pass + + try: + for i in range(self.neverSavedListBox.count()): + item = self.neverSavedListBox.item(i) + if item.isSelected(): + self.selectedTimestamp = item.text() + self.selectedKey = self.neverSavedKeys[i] + self.archiveFilePath = self.neverSavedFolderpath + break + except Exception as e: + pass + self.close() + + def onItemSelectionChanged(self): + otherListBox = ( + self.savedListBox + if self.sender() == self.neverSavedListBox + else self.neverSavedListBox + ) + if otherListBox is None: + return + for i in range(otherListBox.count()): + item = otherListBox.item(i) + item.setSelected(False) + + +class ChangeUserProfileFolderPathDialog(QBaseDialog): + def __init__(self, posData, parent=None): + super().__init__(parent=parent) + + self.cancel = True + + self.setWindowTitle("Change user profile folder path") + + mainLayout = QVBoxLayout() + + acdc_folders = load.get_all_acdc_folders(user_profile_path) + acdc_folders_format = [f" - {folder}" for folder in acdc_folders] + acdc_folders_format = "
    ".join(acdc_folders_format) + + txt = f""" + Current user profile path:

    + {user_profile_path}

    + The user profile contains the following Cell-ACDC folders:

    + {acdc_folders_format}

    + After clicking "Ok" you will be asked to select the folder where + you want to migrate the user profile data. + """ + + txt = html_utils.paragraph(txt) + label = QLabel(txt) + + mainLayout.addWidget(label) + + buttonsLayout = widgets.CancelOkButtonsLayout() + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + mainLayout.addStretch() + + self.setLayout(mainLayout) + + def ok_cb(self): + self.cancel = False + self.close() + + +class QInput(QBaseDialog): + def __init__(self, parent=None, title="Input"): + self.cancel = True + self.allowEmpty = True + + super().__init__(parent) + + self.setWindowTitle(title) + + self.mainLayout = QVBoxLayout() + + self.infoLabel = QLabel() + self.mainLayout.addWidget(self.infoLabel) + + promptLayout = QHBoxLayout() + self.promptLabel = QLabel() + promptLayout.addWidget(self.promptLabel) + self.lineEdit = QLineEdit() + promptLayout.addWidget(self.lineEdit) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + self.mainLayout.addLayout(promptLayout) + self.mainLayout.addSpacing(20) + self.mainLayout.addLayout(buttonsLayout) + + self.buttonsLayout = buttonsLayout + + self.setFont(font) + self.setLayout(self.mainLayout) + + def askText(self, prompt, infoText="", allowEmpty=False): + self.allowEmpty = allowEmpty + if infoText: + infoText = f"{infoText}
    " + self.infoLabel.setText(html_utils.paragraph(infoText)) + self.promptLabel.setText(prompt) + self.exec_(resizeWidthFactor=1.5) + + def ok_cb(self): + self.answer = self.lineEdit.text() + if not self.allowEmpty and not self.answer: + msg = widgets.myMessageBox(showCentered=False) + msg.critical(self, "Empty", "Entry cannot be empty.") + return + self.cancel = False + self.close() + + +class InstallPyTorchDialog(QBaseDialog): + def __init__(self, parent=None, caller_name="Cell-ACDC"): + super().__init__(parent=parent) + + self.cancel = True + + mainLayout = QVBoxLayout() + + innerLayout = QGridLayout() + + iconLabel = QLabel(self) + standardIcon = getattr(QStyle, "SP_MessageBoxInformation") + icon = self.style().standardIcon(standardIcon) + pixmap = icon.pixmap(60, 60) + iconLabel.setPixmap(pixmap) + innerLayout.addWidget(iconLabel, 0, 0, alignment=Qt.AlignTop) + + href = html_utils.href_tag("How to install PyTorch", urls.install_pytorch) + important = html_utils.to_admonition( + """ + Should you choose to install PyTorch yourself, make sure to + activate
    + the correct acdc environment first
    . + """, + admonition_type="important", + ) + + infoText = html_utils.paragraph(f""" + {caller_name} needs to install the package PyTorch.

    + Select your preferences and click ok to install it now. + You will have to confirm the installation in the terminal.

    + Alternatively, you can close {caller_name} and run the command + yourself.

    + For more details see this guide: {href}
    + {important} + """) + innerLayout.addWidget(QLabel(infoText), 0, 1) + innerLayout.addItem(QSpacerItem(10, 10), 1, 1) + + preferencesLayout = QGridLayout() + + row = 0 + self.osCombobox = QComboBox() + self.osCombobox.addItems(["Linux", "Mac", "Windows"]) + preferencesLayout.addWidget(QLabel("Your OS"), row, 0) + preferencesLayout.addWidget(self.osCombobox, row, 1) + + if is_mac: + self.osCombobox.setCurrentText("Mac") + elif is_win: + self.osCombobox.setCurrentText("Windows") + + row += 1 + self.pkgManagerCombobox = QComboBox() + self.pkgManagerCombobox.addItems(["Pip"]) + if not is_conda_env(): + self.pkgManagerCombobox.setCurrentText("Pip") + self.pkgManagerCombobox.setDisabled(True) + + preferencesLayout.addWidget(QLabel("Package manager"), row, 0) + preferencesLayout.addWidget(self.pkgManagerCombobox, row, 1) + + row += 1 + self.cmptPlatformCombobox = QComboBox() + self.cmptPlatformCombobox.addItems( + ["CPU", "CUDA 11.8 (NVIDIA GPU)", "CUDA 12.1 (NVIDIA GPU)"] + ) + + preferencesLayout.addWidget(QLabel("Compute Platform"), row, 0) + preferencesLayout.addWidget(self.cmptPlatformCombobox, row, 1) + + row += 1 + pip_prefix, conda_prefix = myutils.get_pip_conda_prefix() + self.commandWidget = widgets.CopiableCommandWidget( + command=f"{pip_prefix} torch" + ) + preferencesLayout.addWidget(QLabel("Run this command: "), row, 0) + preferencesLayout.addWidget(self.commandWidget, row, 1, 1, 2) + preferencesLayout.setColumnStretch(0, 0) + preferencesLayout.setColumnStretch(1, 0) + preferencesLayout.setColumnStretch(2, 1) + + innerLayout.addLayout(preferencesLayout, 2, 1) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addLayout(innerLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + self.osCombobox.currentTextChanged.connect(self.updateCommand) + self.pkgManagerCombobox.currentTextChanged.connect(self.updateCommand) + self.cmptPlatformCombobox.currentTextChanged.connect(self.updateCommand) + + self.updateCommand() + + def updateCommand(self, *args, **kwargs): + osText = self.osCombobox.currentText() + pkgManager = self.pkgManagerCombobox.currentText() + cmptPlatform = self.cmptPlatformCombobox.currentText() + command = myutils.get_pytorch_command()[osText][pkgManager][cmptPlatform] + self.commandWidget.setCommand(command) + + def ok_cb(self): + self.command = self.commandWidget.command() + self.cancel = False + self.close() + +# Sibling imports (deferred to avoid import cycles) +from ._base import ( + ArgWidget, +) +from .general import ( + QTreeDialog, +) +from .metadata import ( + filenameDialog, +) +from .preprocess import ( + PostProcessSegmParams, + PreProcessParamsWidget, +) + diff --git a/cellacdc/dialogs/preprocess.py b/cellacdc/dialogs/preprocess.py new file mode 100644 index 000000000..69c998a4a --- /dev/null +++ b/cellacdc/dialogs/preprocess.py @@ -0,0 +1,4000 @@ +"""Cell-ACDC dialog windows: preprocess.""" + +import os +import sys +import re +from typing import Literal, Callable, Dict, Iterable, List, Tuple +import datetime +import pathlib +from collections import defaultdict +import zipfile +from heapq import nlargest +import matplotlib +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.patches import Rectangle, Circle, PathPatch, Path +import numpy as np +import scipy.interpolate + +try: + import tkinter as tk +except Exception as err: + pass + +import cv2 +import traceback +from itertools import combinations, permutations +from collections import namedtuple +from natsort import natsorted + +# from MyWidgets import Slider, Button, MyRadioButtons +from skimage.measure import label, regionprops +from functools import partial +import skimage.filters +import skimage.measure +import skimage.morphology +import skimage.exposure +import skimage.draw +import skimage.registration +import skimage.color +import skimage.segmentation +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk +import matplotlib.pyplot as plt +import seaborn as sns +import pandas as pd +import math +import time +import sympy as sp +import json +import html + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from qtpy import QtCore +from qtpy.QtGui import ( + QIcon, + QFontMetrics, + QKeySequence, + QFont, + QRegularExpressionValidator, + QCursor, + QKeyEvent, + QPixmap, + QFont, + QPalette, + QMouseEvent, + QColor, +) +from qtpy.QtCore import ( + Qt, + QSize, + QEvent, + Signal, + QEventLoop, + QTimer, + QRegularExpression, +) +from qtpy.QtWidgets import ( + QFileDialog, + QApplication, + QMainWindow, + QMenu, + QLabel, + QToolBar, + QScrollBar, + QWidget, + QVBoxLayout, + QLineEdit, + QPushButton, + QHBoxLayout, + QDialog, + QFormLayout, + QListWidget, + QAbstractItemView, + QButtonGroup, + QCheckBox, + QSizePolicy, + QComboBox, + QSlider, + QGridLayout, + QSpinBox, + QToolButton, + QTableView, + QTextBrowser, + QDoubleSpinBox, + QScrollArea, + QFrame, + QProgressBar, + QGroupBox, + QRadioButton, + QDockWidget, + QMessageBox, + QStyle, + QPlainTextEdit, + QSpacerItem, + QTreeWidget, + QTreeWidgetItem, + QTextEdit, + QSplashScreen, + QAction, + QListWidgetItem, + QActionGroup, + QHeaderView, + QStyledItemDelegate, +) +import qtpy.compat + +from .. import exception_handler +from .. import load, prompts, core, measurements, html_utils +from .. import is_mac, is_win, is_linux, settings_folderpath, config +from .. import preproc_recipes_path, segm_recipes_path, combine_channels_recipes_path +from .. import is_conda_env +from .. import printl +from .. import colors +from .. import issues_url +from .. import myutils +from .. import qutils +from .. import _palettes +from .. import base_cca_dict +from .. import widgets +from .. import user_profile_path, promptable_models_path, models_path +from .. import features +from .. import _core +from .. import _types +from .. import plot +from .. import urls +from ..acdc_regex import float_regex, is_alphanumeric_filename, to_alphanumeric +from .. import _base_widgets +from .. import io +from .. import cca_functions +from .. import path + +POSITIVE_FLOAT_REGEX = float_regex(allow_negative=False) +TREEWIDGET_STYLESHEET = _palettes.TreeWidgetStyleSheet() +LISTWIDGET_STYLESHEET = _palettes.ListWidgetStyleSheet() +BACKGROUND_RGBA = _palettes.get_disabled_colors()["Button"] + +font = QFont() +font.setPixelSize(12) +italicFont = QFont() +italicFont.setPixelSize(12) +italicFont.setItalic(True) + +from ._base import ( + QBaseDialog, +) + +class wandToleranceWidget(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + + self.slider = widgets.sliderWithSpinBox(title="Tolerance") + self.slider.setMaximum(255) + self.slider._layout.setColumnStretch(2, 21) + + self.setLayout(self.slider.layout) + + +class QDialogAutomaticThresholding(QBaseDialog): + def __init__(self, parent=None, isSegm3D=True): + super().__init__(parent) + + self.cancel = True + + self.setWindowTitle("Automatic thresholding parameters") + + layout = QVBoxLayout() + formLayout = QGridLayout() + buttonsLayout = QHBoxLayout() + + row = 0 + self.sigmaGaussSpinbox = QDoubleSpinBox() + self.sigmaGaussSpinbox.setValue(1) + self.sigmaGaussSpinbox.setMaximum(2**31) + self.sigmaGaussSpinbox.setAlignment(Qt.AlignCenter) + formLayout.addWidget( + QLabel("Gaussian filter sigma (0 to ignore): "), + row, + 0, + alignment=Qt.AlignRight, + ) + formLayout.addWidget(self.sigmaGaussSpinbox, row, 1, 1, 2) + + row += 1 + self.threshMethodCombobox = QComboBox() + self.threshMethodCombobox.addItems( + ["Isodata", "Li", "Mean", "Minimum", "Otsu", "Triangle", "Yen"] + ) + formLayout.addWidget( + QLabel("Thresholding algorithm: "), row, 0, alignment=Qt.AlignRight + ) + formLayout.addWidget(self.threshMethodCombobox, row, 1, 1, 2) + + self.segment3Dcheckbox = None + if isSegm3D: + row += 1 + formLayout.addWidget( + QLabel("Segment 3D volume: "), row, 0, alignment=Qt.AlignRight + ) + group = QButtonGroup() + group.setExclusive(True) + self.segment3Dcheckbox = QRadioButton("Yes") + segmentSliceBySliceCheckbox = QRadioButton("No, segment slice-by-slice") + group.addButton(self.segment3Dcheckbox) + group.addButton(segmentSliceBySliceCheckbox) + formLayout.addWidget(self.segment3Dcheckbox, row, 1) + formLayout.addWidget(segmentSliceBySliceCheckbox, row, 2) + self.segment3Dcheckbox.setChecked(True) + + okButton = widgets.okPushButton("Ok") + cancelButton = widgets.cancelPushButton("Cancel") + helpButton = widgets.helpPushButton("Help...") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(helpButton) + buttonsLayout.addWidget(okButton) + + layout.addLayout(formLayout) + layout.addSpacing(20) + layout.addLayout(buttonsLayout) + + okButton.clicked.connect(self.ok_cb) + helpButton.clicked.connect(self.help_cb) + cancelButton.clicked.connect(self.close) + + self.setLayout(layout) + self.setFont(font) + + self.configPars = self.loadLastSelection() + + def help_cb(self): + import webbrowser + + url = "https://scikit-image.org/docs/stable/auto_examples/applications/plot_thresholding.html" + webbrowser.open(url) + + def ok_cb(self): + self.cancel = False + self.gaussSigma = self.sigmaGaussSpinbox.value() + threshMethod = self.threshMethodCombobox.currentText().lower() + self.threshMethod = f"threshold_{threshMethod}" + self.segment_kwargs = { + "gauss_sigma": self.gaussSigma, + "threshold_method": self.threshMethod, + "segment_3D_volume": False, + } + self.reduceMemoryUsage = False + if self.segment3Dcheckbox is not None: + doSegm3D = self.segment3Dcheckbox.isChecked() + self.segment_kwargs["segment_3D_volume"] = doSegm3D + self.close() + + def loadLastSelection(self): + self.ini_path = os.path.join(settings_folderpath, "last_params_segm_models.ini") + if not os.path.exists(self.ini_path): + return + + configPars = config.ConfigParser() + configPars.read(self.ini_path) + + if "thresholding.segment" not in configPars.sections(): + return + + section = configPars["thresholding.segment"] + self.sigmaGaussSpinbox.setValue(float(section["gauss_sigma"])) + + threshold_method = section["threshold_method"] + Method = threshold_method[10:].capitalize() + self.threshMethodCombobox.setCurrentText(Method) + if self.segment3Dcheckbox is None: + return + self.segment3Dcheckbox.setChecked(section.getboolean("segment_3D_volume")) + + +class startStopFramesDialog(QBaseDialog): + def __init__( + self, + SizeT, + currentFrameNum=0, + parent=None, + windowTitle="Select frame range to segment", + ): + super().__init__(parent=parent) + + self.setWindowTitle(windowTitle) + + self.cancel = True + + layout = QVBoxLayout() + buttonsLayout = QHBoxLayout() + + self.selectFramesGroupbox = widgets.selectStartStopFrames( + SizeT, currentFrameNum=currentFrameNum, parent=parent + ) + + okButton = widgets.okPushButton("Ok") + cancelButton = widgets.cancelPushButton("Cancel") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(okButton) + + layout.addWidget(self.selectFramesGroupbox) + layout.addLayout(buttonsLayout) + self.setLayout(layout) + + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + + self.setFont(font) + + def ok_cb(self): + if self.selectFramesGroupbox.warningLabel.text(): + return + else: + self.startFrame = self.selectFramesGroupbox.startFrame_SB.value() + self.stopFrame = self.selectFramesGroupbox.stopFrame_SB.value() + self.cancel = False + self.close() + + def show(self, block=False): + super().show(block=False) + + self.resize(int(self.width() * 1.5), self.height()) + + if block: + super().show(block=True) + + +class randomWalkerDialog(QDialog): + def __init__(self, mainWindow): + super().__init__(mainWindow) + self.cancel = True + self.mainWindow = mainWindow + + if mainWindow is not None: + posData = self.mainWindow.data[self.mainWindow.pos_i] + items = [posData.filename] + else: + items = ["test"] + try: + posData = self.mainWindow.data[self.mainWindow.pos_i] + items.extend(list(posData.ol_data_dict.keys())) + except Exception as e: + pass + + self.keys = items + + self.setWindowTitle("Random walker segmentation") + + self.colors = [self.mainWindow.RWbkgrColor, self.mainWindow.RWforegrColor] + + mainLayout = QVBoxLayout() + paramsLayout = QGridLayout() + buttonsLayout = QHBoxLayout() + + self.mainWindow.clearAllItems() + + row = 0 + paramsLayout.addWidget(QLabel("Background threshold:"), row, 0) + row += 1 + self.bkgrThreshValLabel = QLabel("0.05") + paramsLayout.addWidget(self.bkgrThreshValLabel, row, 1) + self.bkgrThreshSlider = QSlider(Qt.Horizontal) + self.bkgrThreshSlider.setMinimum(1) + self.bkgrThreshSlider.setMaximum(100) + self.bkgrThreshSlider.setValue(5) + self.bkgrThreshSlider.setTickPosition(QSlider.TickPosition.TicksBelow) + self.bkgrThreshSlider.setTickInterval(10) + paramsLayout.addWidget(self.bkgrThreshSlider, row, 0) + + row += 1 + foregrQSLabel = QLabel("Foreground threshold:") + # padding: top, left, bottom, right + foregrQSLabel.setStyleSheet("font-size:13px; padding:5px 0px 0px 0px;") + paramsLayout.addWidget(foregrQSLabel, row, 0) + row += 1 + self.foregrThreshValLabel = QLabel("0.95") + paramsLayout.addWidget(self.foregrThreshValLabel, row, 1) + self.foregrThreshSlider = QSlider(Qt.Horizontal) + self.foregrThreshSlider.setMinimum(1) + self.foregrThreshSlider.setMaximum(100) + self.foregrThreshSlider.setValue(95) + self.foregrThreshSlider.setTickPosition(QSlider.TickPosition.TicksBelow) + self.foregrThreshSlider.setTickInterval(10) + paramsLayout.addWidget(self.foregrThreshSlider, row, 0) + + # Parameters link label + row += 1 + url1 = "https://scikit-image.org/docs/dev/auto_examples/segmentation/plot_random_walker_segmentation.html" + url2 = "https://scikit-image.org/docs/dev/api/skimage.segmentation.html#skimage.segmentation.random_walker" + htmlTxt1 = f'here' + htmlTxt2 = f'here' + seeHereLabel = QLabel() + seeHereLabel.setText( + f"See {htmlTxt1} and {htmlTxt2} for details " + "about Random walker segmentation." + ) + seeHereLabel.setTextFormat(Qt.RichText) + seeHereLabel.setTextInteractionFlags(Qt.TextBrowserInteraction) + seeHereLabel.setOpenExternalLinks(True) + font = QFont() + font.setPixelSize(12) + seeHereLabel.setFont(font) + seeHereLabel.setStyleSheet("padding:12px 0px 0px 0px;") + paramsLayout.addWidget(seeHereLabel, row, 0, 1, 2) + + computeButton = QPushButton("Compute segmentation") + closeButton = QPushButton("Close") + + buttonsLayout.addWidget(computeButton, alignment=Qt.AlignRight) + buttonsLayout.addWidget(closeButton, alignment=Qt.AlignLeft) + + paramsLayout.setContentsMargins(0, 10, 0, 0) + buttonsLayout.setContentsMargins(0, 10, 0, 0) + + mainLayout.addLayout(paramsLayout) + mainLayout.addLayout(buttonsLayout) + + self.bkgrThreshSlider.sliderMoved.connect(self.bkgrSliderMoved) + self.foregrThreshSlider.sliderMoved.connect(self.foregrSliderMoved) + computeButton.clicked.connect(self.computeSegmAndPlot) + closeButton.clicked.connect(self.close) + + self.setLayout(mainLayout) + + self.getImage() + self.plotMarkers() + + def getImage(self): + img = self.mainWindow.getDisplayedImg1() + self.img = img / img.max() + self.imgRGB = (skimage.color.gray2rgb(self.img) * 255).astype(np.uint8) + + def setSize(self): + x = self.pos().x() + y = self.pos().y() + h = self.size().height() + w = self.size().width() + if w < 400: + w = 400 + self.setGeometry(x, y, w, h) + + def plotMarkers(self): + imgMin, imgMax = self.computeMarkers() + + img = self.img + + imgRGB = self.imgRGB.copy() + R, G, B = self.colors[0] + imgRGB[:, :, 0][img < imgMin] = R + imgRGB[:, :, 1][img < imgMin] = G + imgRGB[:, :, 2][img < imgMin] = B + R, G, B = self.colors[1] + imgRGB[:, :, 0][img > imgMax] = R + imgRGB[:, :, 1][img > imgMax] = G + imgRGB[:, :, 2][img > imgMax] = B + + self.mainWindow.img1.setImage(imgRGB) + + def computeMarkers(self): + bkgrThresh = self.bkgrThreshSlider.sliderPosition() / 100 + foregrThresh = self.foregrThreshSlider.sliderPosition() / 100 + img = self.img + self.markers = np.zeros(img.shape, np.uint8) + imgRange = img.max() - img.min() + imgMin = img.min() + imgRange * bkgrThresh + imgMax = img.min() + imgRange * foregrThresh + self.markers[img < imgMin] = 1 + self.markers[img > imgMax] = 2 + return imgMin, imgMax + + def computeSegm(self, checked=True): + self.mainWindow.storeUndoRedoStates(False) + self.mainWindow.titleLabel.setText("Randomly walking around... ", color="w") + img = self.img + img = skimage.exposure.rescale_intensity(img) + t0 = time.time() + lab = skimage.segmentation.random_walker(img, self.markers, mode="bf") + lab = skimage.measure.label(lab > 1) + t1 = time.time() + if len(np.unique(lab)) > 2: + lab = skimage.morphology.remove_small_objects(lab, min_size=5) + posData = self.mainWindow.data[self.mainWindow.pos_i] + posData.lab = lab + return t1 - t0 + + def computeSegmAndPlot(self): + deltaT = self.computeSegm() + + posData = self.mainWindow.data[self.mainWindow.pos_i] + + self.mainWindow.update_rp() + self.mainWindow.tracking(enforce=True) + self.mainWindow.updateAllImages() + self.mainWindow.warnEditingWithCca_df("Random Walker segmentation") + txt = f"Random Walker segmentation computed in {deltaT:.3f} s" + print("-----------------") + print(txt) + print("=================") + # self.mainWindow.titleLabel.setText(txt, color='g') + + def bkgrSliderMoved(self, intVal): + self.bkgrThreshValLabel.setText(f"{intVal / 100:.2f}") + self.plotMarkers() + + def foregrSliderMoved(self, intVal): + self.foregrThreshValLabel.setText(f"{intVal / 100:.2f}") + self.plotMarkers() + + def closeEvent(self, event): + self.mainWindow.segmModel = "" + self.mainWindow.updateAllImages() + + +class FutureFramesAction_QDialog(QDialog): + def __init__( + self, + frame_i, + last_tracked_i, + change_txt, + applyTrackingB=False, + parent=None, + addApplyAllButton=False, + ): + self.decision = None + self.last_tracked_i = last_tracked_i + super().__init__(parent) + self.setWindowTitle("Future frames action?") + + mainLayout = QVBoxLayout() + txtLayout = QVBoxLayout() + doNotShowLayout = QVBoxLayout() + buttonsLayout = QVBoxLayout() + + txt = html_utils.paragraph( + "You already visited/checked future frames " + f"{frame_i + 1}-{last_tracked_i + 1}.

    " + f'The requested "{change_txt}" change might result in
    ' + "NON-correct segmentation/tracking for those frames.
    " + ) + + txtLabel = QLabel(txt) + txtLabel.setAlignment(Qt.AlignCenter) + txtLayout.addWidget(txtLabel, alignment=Qt.AlignCenter) + + options = [ + f'Apply the "{change_txt}" only to current frame and re-initialize
    ' + "the future frames to the segmentation file present
    " + "on the hard drive.", + "Apply only to this frame and keep the future frames as they are.", + "Apply the change to ALL visited/checked future frames.", + ] + if addApplyAllButton: + options.append( + "Apply to ALL future frames including unvisited ones." + ) + if applyTrackingB: + options.append("Repeat ONLY tracking for all future frames (RECOMMENDED)") + + infoTxt = html_utils.paragraph( + f"Choose one of the following options:" + f"{html_utils.to_list(options, ordered=True)}" + ) + + infotxtLabel = QLabel(infoTxt) + txtLayout.addWidget(infotxtLabel, alignment=Qt.AlignCenter) + + noteLayout = QHBoxLayout() + noteTxt = html_utils.paragraph( + "Only changes applied to current frame can be undone.
    " + "Changes applied to future frames CANNOT be UNDONE
    " + ) + noteLayout.addWidget( + QLabel(html_utils.paragraph("NOTE:")), alignment=Qt.AlignTop + ) + noteTxtLabel = QLabel(noteTxt) + noteLayout.addWidget(noteTxtLabel) + noteLayout.addStretch(1) + txtLayout.addSpacing(10) + txtLayout.addLayout(noteLayout) + + # Do not show this message again checkbox + doNotShowCheckbox = QCheckBox( + "Remember my choice and do not show this message again" + ) + doNotShowLayout.addWidget(doNotShowCheckbox) + doNotShowLayout.setContentsMargins(50, 0, 0, 10) + self.doNotShowCheckbox = doNotShowCheckbox + + apply_and_reinit_b = widgets.reloadPushButton( + " 1. Apply only to this frame and re-initialize future frames" + ) + + self.apply_and_reinit_b = apply_and_reinit_b + buttonsLayout.addWidget(apply_and_reinit_b) + + apply_and_NOTreinit_b = widgets.currentPushButton( + " 2. Apply only to this frame and keep future frames as they are" + ) + self.apply_and_NOTreinit_b = apply_and_NOTreinit_b + buttonsLayout.addWidget(apply_and_NOTreinit_b) + + apply_to_all_visited_b = widgets.futurePushButton( + " 3. Apply to all future VISITED frames" + ) + self.apply_to_all_visited_b = apply_to_all_visited_b + buttonsLayout.addWidget(apply_to_all_visited_b) + + if addApplyAllButton: + apply_to_all_b = QPushButton( + " 4. Apply to ALL future frames (including unvisted)" + ) + apply_to_all_b.setIcon(QIcon(":arrow_future_all.svg")) + self.apply_to_all_b = apply_to_all_b + buttonsLayout.addWidget(apply_to_all_b) + + self.applyTrackingButton = None + if applyTrackingB: + n = "5" if addApplyAllButton else "4" + applyTrackingButton = QPushButton( + f" {n}. Repeat ONLY tracking for all future frames" + ) + applyTrackingButton.setIcon(QIcon(":repeat-tracking.svg")) + self.applyTrackingButton = applyTrackingButton + buttonsLayout.addWidget(applyTrackingButton) + + buttonsLayout.setContentsMargins(20, 0, 20, 0) + + self.formLayout = QFormLayout() + + ButtonsGroup = QButtonGroup(self) + ButtonsGroup.addButton(apply_and_reinit_b) + ButtonsGroup.addButton(apply_and_NOTreinit_b) + ButtonsGroup.addButton(apply_to_all_visited_b) + if addApplyAllButton: + ButtonsGroup.addButton(apply_to_all_b) + if applyTrackingB: + ButtonsGroup.addButton(applyTrackingButton) + + mainLayout.addLayout(txtLayout) + mainLayout.addLayout(doNotShowLayout) + mainLayout.addLayout(buttonsLayout) + mainLayout.addLayout(self.formLayout) + mainLayout.addStretch(1) + self.mainLayout = mainLayout + self.setLayout(mainLayout) + + # Connect events + ButtonsGroup.buttonClicked.connect(self.buttonClicked) + self.ButtonsGroup = ButtonsGroup + + # self.setModal(True) + + def buttonClicked(self, button): + if button == self.apply_and_reinit_b: + self.decision = "apply_and_reinit" + self.endFrame_i = None + elif button == self.apply_and_NOTreinit_b: + self.decision = "apply_and_NOTreinit" + self.endFrame_i = None + elif button == self.apply_to_all_visited_b: + self.decision = "apply_to_all_visited" + self.endFrame_i = self.last_tracked_i + elif button == self.applyTrackingButton: + self.decision = "only_tracking" + self.endFrame_i = self.last_tracked_i + elif button == self.apply_to_all_b: + self.decision = "apply_to_all" + self.endFrame_i = self.last_tracked_i + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + for button in self.ButtonsGroup.buttons(): + button.setMinimumHeight(int(button.height() * 1.2)) + if hasattr(self, "apply_to_all_b"): + iconHeight = self.apply_to_all_b.iconSize().height() + self.apply_to_all_b.setIconSize(QSize(iconHeight * 2, iconHeight)) + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class PostProcessSegmParams(QGroupBox): + valueChanged = Signal(object) + editingFinished = Signal() + + def __init__( + self, + title, + posData, + useSliders=False, + parent=None, + maxSize=None, + force_postprocess_2D=False, + ): + QGroupBox.__init__(self, title, parent) + SizeZ = posData.SizeZ + self.isSegm3D = posData.isSegm3D + self.channelName = posData.user_ch_name + self.useSliders = useSliders + self.force_postprocess_2D = force_postprocess_2D + if maxSize is None: + maxSize = 2147483647 + + layout = QGridLayout() + + self.controlWidgets = [] + + row = 0 + label = QLabel("Minimum area (pixels) ") + layout.addWidget(label, row, 0, alignment=Qt.AlignRight) + + minSize_SB = widgets.PostProcessSegmWidget(1, 1000, 10, useSliders, label=label) + + txt = "Area is the total number of pixels in the segmented object." + + layout.addWidget(minSize_SB, row, 1) + infoButton = widgets.infoPushButton() + infoButton.clicked.connect(self.showInfo) + infoButton.tooltip = txt + infoButton.name = "area" + infoButton.desc = f'less than "{label.text()}"' + layout.addWidget(infoButton, row, 2) + self.minSize_SB = minSize_SB + self.controlWidgets.append(minSize_SB) + + # minSize_SB.disableThisCheckbox = QCheckBox('Disable this filter') + # layout.addWidget(minSize_SB.disableThisCheckbox, row, 3) + + row += 1 + label = QLabel("Minimum solidity (0-1) ") + layout.addWidget(label, row, 0, alignment=Qt.AlignRight) + minSolidity_DSB = widgets.PostProcessSegmWidget( + 0, 1.0, 0.5, useSliders, isFloat=True, normalize=True, label=label + ) + minSolidity_DSB.setValue(0.5) + minSolidity_DSB.setSingleStep(0.1) + self.controlWidgets.append(minSolidity_DSB) + + txt = ( + "Solidity is a measure of convexity. A solidity of 1 means " + "that the shape is fully convex (i.e., equal to the convex hull). " + "As solidity approaches 0 the object is more concave.
    " + "Write 0 for ignoring this parameter." + ) + + layout.addWidget(minSolidity_DSB, row, 1) + infoButton = widgets.infoPushButton() + infoButton.clicked.connect(self.showInfo) + infoButton.tooltip = txt + infoButton.name = "solidity" + infoButton.desc = f'less than "{label.text()}"' + layout.addWidget(infoButton, row, 2) + self.minSolidity_DSB = minSolidity_DSB + + row += 1 + label = QLabel("Max elongation (1=circle) ") + layout.addWidget(label, row, 0, alignment=Qt.AlignRight) + maxElongation_DSB = widgets.PostProcessSegmWidget( + 0, 100, 3, useSliders, isFloat=True, normalize=False, label=label + ) + maxElongation_DSB.setDecimals(1) + maxElongation_DSB.setSingleStep(1.0) + + txt = ( + "Elongation is the ratio between major and minor axis lengths. " + "An elongation of 1 is like a circle.
    " + "Write 0 for ignoring this parameter." + ) + + layout.addWidget(maxElongation_DSB, row, 1) + infoButton = widgets.infoPushButton() + infoButton.clicked.connect(self.showInfo) + infoButton.tooltip = txt + infoButton.name = "elongation" + infoButton.desc = f'greater than "{label.text()}"' + layout.addWidget(infoButton, row, 2) + self.maxElongation_DSB = maxElongation_DSB + self.controlWidgets.append(maxElongation_DSB) + + if self.isSegm3D: + row += 1 + label = QLabel("Minimum number of z-slices ") + layout.addWidget(label, row, 0, alignment=Qt.AlignRight) + minObjSizeZ_SB = widgets.PostProcessSegmWidget( + 0, SizeZ, 3, useSliders, isFloat=False, normalize=False, label=label + ) + + txt = "Minimum number of z-slices per object." + + layout.addWidget(minObjSizeZ_SB, row, 1) + infoButton = widgets.infoPushButton() + infoButton.clicked.connect(self.showInfo) + infoButton.tooltip = txt + infoButton.name = "number of z-slices" + infoButton.desc = f'less than "{label.text()}"' + layout.addWidget(infoButton, row, 2) + self.minObjSizeZ_SB = minObjSizeZ_SB + self.controlWidgets.append(minObjSizeZ_SB) + else: + self.minObjSizeZ_SB = widgets.NoneWidget() + + row += 1 + addCustomFeatureLayout = QHBoxLayout() + self.addCustomFeaturesButton = widgets.setPushButton( + "Select custom features for post-processing...", + ) + addCustomFeatureLayout.addWidget(self.addCustomFeaturesButton) + addCustomFeatureLayout.addStretch(1) + self.selectedFeaturesDialog = SelectFeaturesRangeDialog( + posData=posData, parent=self, force_postprocess_2D=force_postprocess_2D + ) + self.selectedFeaturesDialog.hide() + self.addCustomFeaturesButton.clicked.connect(self.selectedFeaturesDialog.show) + self.selectedFeaturesDialog.sigValueChanged.connect(self.onValueChanged) + + layout.addLayout(addCustomFeatureLayout, row, 0, 1, 2) + + layout.setColumnStretch(1, 2) + # layout.setRowStretch(row+1, 1) + + self.setLayout(layout) + + for widget in self.controlWidgets: + widget.valueChanged.connect(self.onValueChanged) + widget.editingFinished.connect(self.onEditingFinished) + + def selectedFeaturesRange(self): + return self.selectedFeaturesDialog.groupbox.selectedFeaturesRange() + + def groupedFeatures(self): + return self.selectedFeaturesDialog.groupbox.groupedFeatures() + + def restoreDefault(self): + self.minSolidity_DSB.setValue(0.5) + self.minSize_SB.setValue(10) + self.maxElongation_DSB.setValue(3) + self.minObjSizeZ_SB.setValue(3) + self.selectedFeaturesDialog.groupbox.resetFields() + + def restoreFromKwargs(self, kwargs): + for name, value in kwargs.items(): + if name == "min_solidity": + self.minSolidity_DSB.setValue(value) + elif name == "min_area": + self.minSize_SB.setValue(value) + elif name == "max_elongation": + self.maxElongation_DSB.setValue(value) + elif name == "min_obj_no_zslices": + self.minObjSizeZ_SB.setValue(value) + + def kwargs(self): + kwargs = { + "min_solidity": self.minSolidity_DSB.value(), + "min_area": self.minSize_SB.value(), + "max_elongation": self.maxElongation_DSB.value(), + "min_obj_no_zslices": self.minObjSizeZ_SB.value(), + } + return kwargs + + def onValueChanged(self, value): + self.valueChanged.emit(value) + + def onEditingFinished(self): + self.editingFinished.emit() + + def showInfo(self): + title = f"{self.sender().text()} info" + tooltip = self.sender().tooltip + name = self.sender().name + desc = self.sender().desc + txt = f""" + The post-processing step is applied to the output of the + segmentation model.

    + During this step, Cell-ACDC will remove all the objects with {name} + {desc}.

    + {tooltip} + """ + if self.isCheckable(): + note = f"""" + You can deactivate this step by un-checking the checkbox + called "Post-processing parameters". + """ + txt = f"{txt}{note}" + msg = widgets.myMessageBox(showCentered=False) + msg.information(self, title, html_utils.paragraph(txt)) + + +class PostProcessSegmDialog(QBaseDialog): + sigClosed = Signal() + sigValueChanged = Signal(object, object) + sigEditingFinished = Signal() + sigApplyToAllFutureFrames = Signal(object, object, object) + + def __init__(self, posData, mainWin=None, useSliders=True, maxSize=None): + super().__init__(mainWin) + self.cancel = True + self.mainWin = mainWin + self.isTimelapse = False + self.isMultiPos = False + if mainWin is not None: + self.isMultiPos = len(self.mainWin.data) > 1 + self.isTimelapse = self.mainWin.data[self.mainWin.pos_i].SizeT > 1 + + self.setWindowTitle("Post-processing segmentation parameters") + self.setWindowFlags(Qt.Tool | Qt.WindowStaysOnTopHint) + + mainLayout = QVBoxLayout() + buttonsLayout = QHBoxLayout() + + self.postProcessGroupbox = PostProcessSegmParams( + "Post-processing parameters", + posData, + useSliders=useSliders, + maxSize=maxSize, + parent=mainWin, + ) + + self.postProcessGroupbox.valueChanged.connect(self.valueChanged) + self.postProcessGroupbox.editingFinished.connect(self.onEditingFinished) + + if self.isTimelapse: + applyAllButton = widgets.futurePushButton("Apply to all frames...") + applyAllButton.clicked.connect(self.applyAll_cb) + applyButton = widgets.okPushButton("Apply", isDefault=False) + applyButton.clicked.connect(self.apply_cb) + elif self.isMultiPos: + applyAllButton = widgets.futurePushButton("Apply to all Positions...") + applyAllButton.clicked.connect(self.applyAll_cb) + applyButton = widgets.okPushButton("Apply", isDefault=False) + applyButton.clicked.connect(self.apply_cb) + else: + applyAllButton = widgets.okPushButton("Apply", isDefault=False) + applyAllButton.clicked.connect(self.ok_cb) + applyButton = None + + cancelButton = widgets.cancelPushButton("Cancel") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + if applyButton is not None: + buttonsLayout.addWidget(applyButton) + buttonsLayout.addWidget(applyAllButton) + + emitEditingFinishedButton = widgets.okPushButton() + buttonsLayout.addWidget(emitEditingFinishedButton) + emitEditingFinishedButton.hide() + buttonsLayout.setContentsMargins(0, 10, 0, 0) + + mainLayout.addWidget(self.postProcessGroupbox) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + cancelButton.clicked.connect(self.cancel_cb) + + if mainWin is not None: + self.setPosData() + + def keyPressEvent(self, event) -> None: + return super().keyPressEvent(event) + + def setPosData(self): + if self.mainWin is None: + return + + self.mainWin.storeUndoRedoStates(False) + self.posData = self.mainWin.data[self.mainWin.pos_i] + # self.img.setCurrentPosIndex(self.pos_i) + # self.img.minMaxValuesMapper = self.mainWin.img1.minMaxValuesMapper + self.origLab = self.posData.lab.copy() + self.origRp = skimage.measure.regionprops(self.origLab) + self.origObjs = {obj.label: obj for obj in self.origRp} + + def valueChanged(self, value): + lab, delObjs = self.apply() + self.sigValueChanged.emit(lab, delObjs) + + def apply(self, origLab=None): + self.mainWin.warnEditingWithCca_df( + "post-processing segmentation mask", update_images=False + ) + ccaAnnotRemoved = self.mainWin.removeCcaAnnotationsCurrentFrame() + if ccaAnnotRemoved: + self.mainWin.updateAllImages() + + if origLab is None: + origLab = self.origLab.copy() + + lab, delIDs = core.post_process_segm( + origLab, return_delIDs=True, **self.postProcessGroupbox.kwargs() + ) + + if self.postProcessGroupbox.selectedFeaturesRange(): + lab, custom_delIDs = features.custom_post_process_segm( + self.posData, + self.postProcessGroupbox.groupedFeatures(), + lab, + self.posData.img_data[self.posData.frame_i], + self.posData.frame_i, + self.posData.filename, + self.posData.user_ch_name, + self.postProcessGroupbox.selectedFeaturesRange(), + return_delIDs=True, + ) + delIDs.extend(custom_delIDs) + + delObjs = {delID: self.origObjs[delID] for delID in delIDs} + return lab, delObjs + + def onEditingFinished(self): + self.sigEditingFinished.emit() + + def ok_cb(self): + self.cancel = False + self.apply() + self.onEditingFinished() + self.close() + + def apply_cb(self): + self.cancel = False + self.apply() + self.onEditingFinished() + + def applyAll_cb(self): + self.cancel = False + self.sigApplyToAllFutureFrames.emit( + self.postProcessGroupbox.kwargs(), + self.postProcessGroupbox.groupedFeatures(), + self.postProcessGroupbox.selectedFeaturesRange(), + ) + self.close() + + def cancel_cb(self): + self.cancel = True + self.close() + + def undoChanges(self): + if self.mainWin is not None: + self.posData.lab = self.origLab + self.mainWin.update_rp() + self.mainWin.updateAllImages() + + # Undo if changes were applied to all future frames + if hasattr(self, "origSegmData"): + if self.isTimelapse: + current_frame_i = self.posData.frame_i + for frame_i in range(self.posData.segmSizeT): + self.posData.frame_i = frame_i + origLab = self.origSegmData[frame_i] + lab = self.posData.allData_li[frame_i]["labels"] + if lab is None: + # Non-visited frame modify segm_data + self.posData.segm_data[frame_i] = origLab + else: + self.posData.allData_li[frame_i]["labels"] = origLab.copy() + self.posData.lab = origLab.copy() + self.mainWin.update_rp() + # Get the rest of the stored metadata based on the new lab + self.mainWin.get_data() + self.mainWin.store_data() + # Back to current frame + self.posData.frame_i = current_frame_i + self.mainWin.get_data() + self.mainWin.updateAllImages() + elif self.isMultiPos: + current_pos_i = self.mainWin.pos_i + # Apply to all future frames or future positions + for pos_i, posData in enumerate(self.mainWin.data): + self.mainWin.pos_i = pos_i + origLab = self.origSegmData[pos_i] + self.posData.allData_li[0]["labels"] = lab.copy() + # Get the rest of the stored metadata based on the new lab + self.mainWin.get_data() + self.mainWin.store_data() + # Back to current pos and current frame + self.mainWin.pos_i = current_pos_i + self.mainWin.get_data() + self.mainWin.updateAllImages() + + def show(self, block=False): + # self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show(block=False) + self.resize(int(self.width() * 1.5), self.height()) + super().show(block=block) + + def closeEvent(self, event): + self.sigClosed.emit() + if self.cancel: + self.undoChanges() + super().closeEvent(event) + + +class FunctionParamsDialog(QBaseDialog): + sigValuesChanged = Signal(dict) + + def __init__( + self, + params_argspecs, + function_name="Function", + df_metadata=None, + parent=None, + addApplyButton=False, + ): + self.cancel = True + self.df_metadata = df_metadata + + super().__init__(parent) + + self.setWindowTitle(f"{function_name} parameters") + + self.mainLayout = QVBoxLayout() + + widgetsLayout, self.argsWidgets = self.getWidgetsLayout(params_argspecs) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + self.buttonsLayout = buttonsLayout + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + if addApplyButton: + applyButton = widgets.viewPushButton("Apply") + applyButton.clicked.connect(self.emitValuesChanged) + buttonsLayout.insertWidget(3, applyButton) + self.applyButton = applyButton + + self.mainLayout.addLayout(widgetsLayout) + self.mainLayout.addSpacing(20) + self.mainLayout.addLayout(buttonsLayout) + + self.setLayout(self.mainLayout) + + def emitValuesChanged(self, *args, **kwargs): + self.sigValuesChanged.emit(self.functionKwargs()) + + def functionKwargs(self): + function_kwargs = { + argWidget.name: argWidget.valueGetter(argWidget.widget) + for argWidget in self.argsWidgets + } + return function_kwargs + + def kwargWidgetMapper(self) -> Dict[str, tuple]: + kwarg_widget_mapper = { + argWidget.name: (argWidget.widget, argWidget.valueSetter) + for argWidget in self.argsWidgets + } + return kwarg_widget_mapper + + def ok_cb(self): + self.cancel = False + + self.function_kwargs = self.functionKwargs() + + self.close() + + def getValueFromMetadata(self, name): + try: + value = self.df_metadata.at[name, "values"] + except Exception as e: + # traceback.print_exc() + value = None + return value + + def getWidgetsLayout(self, params_argspecs): + widgetsLayout = QGridLayout() + ArgsWidgets_list = [] + + for row, ArgSpec in enumerate(params_argspecs): + if _types.is_widget_not_required(ArgSpec): + continue + + arg_name = ArgSpec.name + var_name = arg_name.replace("_", " ") + var_name = f"{var_name[0].upper()}{var_name[1:]}" + label = QLabel(f"{var_name}: ") + metadata_val = self.getValueFromMetadata(ArgSpec.name) + widgetsLayout.addWidget(label, row, 0, alignment=Qt.AlignLeft) + try: + values = ArgSpec.type().values + isCustomListType = True + except Exception as err: + isCustomListType = False + + isVectorEntry = False + try: + if isinstance(ArgSpec.type(), _types.Vector): + isVectorEntry = True + except Exception as err: + pass + + isFolderPath = False + try: + if isinstance(ArgSpec.type(), _types.FolderPath): + isFolderPath = True + except Exception as err: + pass + + isCustomWidget = hasattr(ArgSpec.type, "isWidget") + + if isCustomWidget: + widget = ArgSpec.type().widget + self.checkIfTypeCLassHasCastDtype(widget) + defaultVal = ArgSpec.default + valueSetter = widget.setValue + valueGetter = widget.value + widgetsLayout.addWidget(widget, row, 1, 1, 2) + try: + widget.sigValueChanged.connect(self.emitValuesChanged) + except Exception as err: + pass + elif isVectorEntry: + vectorLineEdit = widgets.VectorLineEdit() + self.checkIfTypeCLassHasCastDtype(ArgSpec.type) + vectorLineEdit.setValue(ArgSpec.default) + defaultVal = ArgSpec.default + valueSetter = widgets.VectorLineEdit.setValue + valueGetter = widgets.VectorLineEdit.value + widget = vectorLineEdit + widgetsLayout.addWidget(vectorLineEdit, row, 1, 1, 2) + widget.valueChangeFinished.connect(self.emitValuesChanged) + elif isFolderPath: + folderPathControl = widgets.FolderPathControl() + self.checkIfTypeCLassHasCastDtype(ArgSpec.type) + folderPathControl.setText(str(ArgSpec.default)) + widget = folderPathControl + defaultVal = str(ArgSpec.default) + valueSetter = widgets.FolderPathControl.setText + valueGetter = widgets.FolderPathControl.path + widgetsLayout.addWidget(folderPathControl, row, 1, 1, 2) + widget.sigValueChanged.connect(self.emitValuesChanged) + elif ArgSpec.type == bool: + booleanGroup = QButtonGroup() + booleanGroup.setExclusive(True) + checkBox = widgets.Toggle() + checkBox.setChecked(ArgSpec.default) + defaultVal = ArgSpec.default + valueSetter = widgets.Toggle.setChecked + valueGetter = widgets.Toggle.isChecked + widget = checkBox + widgetsLayout.addWidget( + checkBox, row, 1, 1, 2, alignment=Qt.AlignCenter + ) + widget.toggled.connect(self.emitValuesChanged) + elif ArgSpec.type == int: + spinBox = widgets.SpinBox() + if metadata_val is None: + spinBox.setValue(ArgSpec.default) + else: + spinBox.setValue(int(metadata_val)) + spinBox.isMetadataValue = True + defaultVal = ArgSpec.default + valueSetter = QSpinBox.setValue + valueGetter = QSpinBox.value + widget = spinBox + widgetsLayout.addWidget(spinBox, row, 1, 1, 2) + widget.sigValueChanged.connect(self.emitValuesChanged) + elif ArgSpec.type == float: + doubleSpinBox = widgets.FloatLineEdit() + if metadata_val is None: + doubleSpinBox.setValue(ArgSpec.default) + else: + doubleSpinBox.setValue(float(metadata_val)) + doubleSpinBox.isMetadataValue = True + widget = doubleSpinBox + defaultVal = ArgSpec.default + valueSetter = widgets.FloatLineEdit.setValue + valueGetter = widgets.FloatLineEdit.value + widgetsLayout.addWidget(doubleSpinBox, row, 1, 1, 2) + widget.valueChanged.connect(self.emitValuesChanged) + elif ArgSpec.type == os.PathLike: + filePathControl = widgets.filePathControl() + filePathControl.setText(str(ArgSpec.default)) + widget = filePathControl + defaultVal = str(ArgSpec.default) + valueSetter = widgets.filePathControl.setText + valueGetter = widgets.filePathControl.path + widgetsLayout.addWidget(filePathControl, row, 1, 1, 2) + widget.sigValueChanged.connect(self.emitValuesChanged) + elif isCustomListType: + items = ArgSpec.type().values + ArgSpec.type.cast_dtype = _types.to_str + defaultVal = str(ArgSpec.default) + combobox = widgets.AlphaNumericComboBox() + combobox.addItems(items) + combobox.setCurrentValue(defaultVal) + valueSetter = widgets.AlphaNumericComboBox.setCurrentValue + valueGetter = widgets.AlphaNumericComboBox.currentValue + widget = combobox + widgetsLayout.addWidget(combobox, row, 1, 1, 2) + widget.currentTextChanged.connect(self.emitValuesChanged) + else: + lineEdit = QLineEdit() + lineEdit.setText(str(ArgSpec.default)) + lineEdit.setAlignment(Qt.AlignCenter) + widget = lineEdit + defaultVal = str(ArgSpec.default) + valueSetter = QLineEdit.setText + valueGetter = QLineEdit.text + widgetsLayout.addWidget(lineEdit, row, 1, 1, 2) + widget.editingFinished.connect(self.emitValuesChanged) + + if ArgSpec.desc: + infoButton = self.getInfoButton(ArgSpec.name, ArgSpec.desc) + widgetsLayout.addWidget(infoButton, row, 3) + + argsInfo = ArgWidget( + name=ArgSpec.name, + type=ArgSpec.type, + widget=widget, + defaultVal=defaultVal, + valueSetter=valueSetter, + valueGetter=valueGetter, + ) + ArgsWidgets_list.append(argsInfo) + + widgetsLayout.setColumnStretch(0, 0) + widgetsLayout.setColumnStretch(1, 1) + widgetsLayout.setColumnStretch(3, 0) + + return widgetsLayout, ArgsWidgets_list + + def checkIfTypeCLassHasCastDtype(self, cls): + cast_dtype = getattr(cls, "cast_dtype", None) + if callable(cast_dtype): + return + + raise AttributeError( + "The custom type or widget does not have the `cast_dtype` method. " + "Please, implement it. The method should cast the value to the " + "correct type." + ) + + def getInfoButton(self, param_name, infoText): + infoButton = widgets.infoPushButton() + infoButton.param_name = param_name + infoButton.setToolTip( + f"Click to get more info about `{param_name}` parameter..." + ) + infoButton.infoText = infoText + infoButton.clicked.connect(self.showInfoParam) + return infoButton + + def showInfoParam(self): + text = self.sender().infoText + text = html_utils.rst_urls_to_html(text) + text = html_utils.rst_to_html(text) + text = html_utils.paragraph(text) + param_name = self.sender().param_name + msg = widgets.myMessageBox(wrapText=False) + msg.information(self, f"Info about `{param_name}` parameter", text) + + +class stopFrameDialog(QBaseDialog): + def __init__(self, posDatas, parent=None): + super().__init__(parent=parent) + + self.cancel = True + + self.setWindowTitle("Stop frame") + + mainLayout = QVBoxLayout() + + infoTxt = html_utils.paragraph( + "Enter a stop frame number for each of the loaded Positions", + center=True, + ) + exp_path = posDatas[0].exp_path + exp_path = os.path.normpath(exp_path).split(os.sep) + exp_path = f"...{f'{os.sep}'.join(exp_path[-4:])}" + subInfoTxt = html_utils.paragraph( + f"Experiment folder: {exp_path}", font_size="12px", center=True + ) + infoLabel = QLabel(f"{infoTxt}{subInfoTxt}") + infoLabel.setToolTip(posDatas[0].exp_path) + mainLayout.addWidget(infoLabel) + mainLayout.addSpacing(20) + + self.posDatas = posDatas + for posData in posDatas: + _layout = QHBoxLayout() + _layout.addStretch(1) + _label = QLabel(html_utils.paragraph(f"{posData.pos_foldername}")) + _layout.addWidget(_label) + + _spinBox = QSpinBox() + _spinBox.setMaximum(214748364) + _spinBox.setAlignment(Qt.AlignCenter) + _spinBox.setFont(font) + if posData.acdc_df is not None: + _val = posData.acdc_df.index.get_level_values(0).max() + 1 + else: + _val = posData.readLastUsedStopFrameNumber() + if _val is None: + _val = posData.SizeT + _spinBox.setValue(_val) + + posData.stopFrameSpinbox = _spinBox + + _layout.addWidget(_spinBox) + + viewButton = widgets.viewPushButton("Visualize...") + viewButton.clicked.connect(partial(self.viewChannelData, posData, _spinBox)) + _layout.addWidget(viewButton, alignment=Qt.AlignRight) + + _layout.addStretch(1) + + mainLayout.addLayout(_layout) + + buttonsLayout = QHBoxLayout() + + okButton = widgets.okPushButton(" Ok ") + cancelButton = widgets.cancelPushButton(" Cancel ") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(okButton) + + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.close) + + self.setLayout(mainLayout) + + def viewChannelData(self, posData, spinBox): + self.sender().setText("Loading...") + QTimer.singleShot( + 200, partial(self._viewChannelData, posData, spinBox, self.sender()) + ) + + def _viewChannelData(self, posData, spinBox, senderButton): + chNames = posData.chNames + if len(chNames) > 1: + ch_name_selector = prompts.select_channel_name( + which_channel="segm", allow_abort=False + ) + ch_name_selector.QtPrompt( + self, chNames, "Select channel name to visualize: " + ) + if ch_name_selector.was_aborted: + return + chName = ch_name_selector.channel_name + else: + chName = chNames[0] + + channel_file_path = load.get_filename_from_channel(posData.images_path, chName) + posData.frame_i = 0 + posData.loadImgData(imgPath=channel_file_path) + self.slideshowWin = imageViewer(posData=posData, spinBox=spinBox) + self.slideshowWin.update_img() + self.slideshowWin.show() + senderButton.setText("Visualize...") + + def ok_cb(self): + self.cancel = False + for posData in self.posDatas: + stopFrameNum = posData.stopFrameSpinbox.value() + posData.stopFrameNum = stopFrameNum + self.close() + + +class DataPrepSubCropsPathsDialog(QBaseDialog): + def __init__(self, cropPaths=None, parent=None): + self.cancel = True + + super().__init__(parent=parent) + + mainLayout = QVBoxLayout() + + gridLayout = QGridLayout() + row = 0 + + if cropPaths is None: + cropPaths = {os.path.expanduser("~"): 1} + + if any([numCrops > 1 for numCrops in cropPaths.values()]): + row += 1 + gridLayout.addWidget(QLabel("Same folder for all crops:"), row, 0) + self.sameFolderPathToggle = widgets.Toggle() + gridLayout.addWidget( + self.sameFolderPathToggle, row, 1, alignment=Qt.AlignCenter + ) + self.sameFolderPathToggle.setChecked(True) + self.sameFolderPathToggle.toggled.connect(self.setSameFolderPath) + + self.windowMinWidth = 0 + minWidth = int(self.screen().size().width() / 3) + self.folderPathLineEdits = defaultdict(list) + for path, numCrops in cropPaths.items(): + row += 1 + gridLayout.addWidget(QLabel("Master Position:"), row, 0) + masterPathLabel = QLabel(f"{path}") + gridLayout.addWidget(masterPathLabel, row, 1) + + scrollArea = QScrollArea() + scrollArea.setWidgetResizable(True) + scrollAreaLayout = QGridLayout() + for i in range(numCrops): + label = QLabel(f"Crop {i + 1} folder path:") + scrollAreaLayout.addWidget(label, i, 0) + folderPathLineEdit = widgets.ElidingLineEdit() + folderPathLineEdit.label = label + folderPathLineEdit.setText(path) + scrollAreaLayout.addWidget(folderPathLineEdit, i, 1) + browseButton = widgets.browseFileButton(start_dir=path, openFolder=True) + scrollAreaLayout.addWidget(browseButton, i, 2) + browseButton.sigPathSelected.connect( + partial(self.updateFolderPath, lineEdit=folderPathLineEdit) + ) + self.folderPathLineEdits[path].append(folderPathLineEdit) + folderPathLineEdit.browseButton = browseButton + + scrollAreaLayout.setColumnStretch(0, 0) + scrollAreaLayout.setColumnStretch(1, 1) + scrollAreaLayout.setColumnStretch(2, 0) + container = QWidget() + container.setLayout(scrollAreaLayout) + scrollArea.setWidget(container) + + row += 1 + gridLayout.addWidget(scrollArea, row, 0, 1, 2) + noHorizontalScrollbarWidth = ( + container.sizeHint().width() + + scrollArea.verticalScrollBar().sizeHint().width() + + 20 + ) + if noHorizontalScrollbarWidth > self.windowMinWidth: + self.windowMinWidth = noHorizontalScrollbarWidth + + row += 1 + gridLayout.addWidget(widgets.QHLine(), row, 0, 1, 2) + + row += 1 + gridLayout.addItem(QSpacerItem(10, 10), row, 0, 1, 2) + + row += 1 + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addLayout(gridLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def show(self, block=False): + self.resize(self.windowMinWidth, self.sizeHint().height()) + super().show(block=block) + + def setSameFolderPath(self, checked): + for masterPath, lineEdits in self.folderPathLineEdits.items(): + referencePath = lineEdits[0].text() + for lineEdit in lineEdits[1:]: + if checked: + lineEdit.setText(referencePath) + + lineEdit.setDisabled(checked) + lineEdit.browseButton.setDisabled(checked) + lineEdit.label.setDisabled(checked) + + def updateFolderPath(self, path, lineEdit=None): + lineEdit.setText(path) + lineEdit.browseButton.setStartPath(path) + + def warnFolderPathNotValid(self, cropNum, masterPath, folderPath): + text = html_utils.paragraph( + f"The following folder path for crop number {cropNum} " + "is not a valid folder or does not exist:" + ) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Not a valid folder", text, commands=(folderPath,)) + + def askOverwritingPaths(self, overwritingPaths): + text = html_utils.paragraph( + "Data in the following paths will be overwritten with " + "cropped data.

    " + "Are you sure you want to continue?" + ) + msg = widgets.myMessageBox(wrapText=False) + _, yesButton = msg.warning( + self, + "Not a valid folder", + text, + commands=overwritingPaths, + buttonsTexts=("No, let me edit paths", "Yes, overwrite"), + ) + return msg.clickedButton == yesButton + + def validatePaths(self): + for masterPath, lineEdits in self.folderPathLineEdits.items(): + for i, lineEdit in enumerate(lineEdits): + path = lineEdit.text() + if os.path.exists(path) and os.path.isdir(path): + continue + + self.warnFolderPathNotValid(i + 1, masterPath, path) + return False + + overwritingPaths = [] + for masterPath, lineEdits in self.folderPathLineEdits.items(): + masterPath = masterPath.replace("\\", "/") + if not masterPath.endswith("Images"): + continue + + for i, lineEdit in enumerate(lineEdits): + path = lineEdit.text() + path = path.replace("\\", "/") + if path == masterPath: + overwritingPaths.append(masterPath) + + if not overwritingPaths: + return True + + return self.askOverwritingPaths(overwritingPaths) + + def paths(self): + selectedPaths = {} + for masterPath, lineEdits in self.folderPathLineEdits.items(): + selectedPaths[masterPath] = [le.text() for le in lineEdits] + return selectedPaths + + def ok_cb(self): + proceed = self.validatePaths() + if not proceed: + return + + self.folderPaths = self.paths() + self.cancel = False + self.close() + + +class PreProcessParamsWidget(QWidget): + sigLoadRecipe = Signal() + sigLoadSavedRecipe = Signal() + sigValuesChanged = Signal(list) + + def __init__(self, df_metadata=None, addApplyButton=False, parent=None): + super().__init__(parent) + + mainLayout = QVBoxLayout() + + self.df_metadata = df_metadata + self.addApplyButton = addApplyButton + + groupbox = QGroupBox() + self.groupbox = groupbox + + groupbox.setTitle("Pre-processing") + groupbox.setCheckable(True) + + self.gridLayout = QGridLayout() + self.row = -1 + self.stepsWidgets = {} + + self.gridLayout.setColumnStretch(0, 0) + self.gridLayout.setColumnStretch(1, 1) + self.gridLayout.setColumnStretch(2, 0) + self.gridLayout.setColumnStretch(3, 0) + self.gridLayout.setColumnStretch(4, 0) + groupbox.setLayout(self.gridLayout) + + buttonsLayout = QGridLayout() + row = 0 + col = 0 + buttonsLayout.setColumnStretch(col, 1) + + loadRecipeButton = widgets.OpenFilePushButton("Load saved recipe...") + self.loadRecipeButton = loadRecipeButton + buttonsLayout.addWidget(loadRecipeButton, row, col + 2) + + saveRecipeButton = widgets.savePushButton("Save current recipe...") + self.saveRecipeButton = saveRecipeButton + buttonsLayout.addWidget(saveRecipeButton, row + 1, col + 2) + + loadLastRecipeButton = widgets.reloadPushButton("Load last parameters") + self.loadLastRecipeButton = loadLastRecipeButton + buttonsLayout.addWidget(loadLastRecipeButton, row, col + 1) + + self.buttonsLayout = buttonsLayout + + loadLastRecipeButton.clicked.connect(self.emitLoadRecipe) + saveRecipeButton.clicked.connect(self.saveRecipe) + loadRecipeButton.clicked.connect(self.selectAndLoadRecipe) + + mainLayout.addWidget(groupbox) + mainLayout.addSpacing(10) + mainLayout.addLayout(buttonsLayout) + + self.addStep(is_first=True) + + mainLayout.setContentsMargins(0, 0, 0, 0) + self.setLayout(mainLayout) + + def stepSizeHeightHint(self): + stepWidgets = self.stepsWidgets[1] + height = ( + stepWidgets["stepLabel"].minimumSizeHint().height() + + stepWidgets["selector"].minimumSizeHint().height() + ) + return height + + def setChecked(self, checked): + self.groupbox.setChecked(checked) + + def emitLoadRecipe(self): + self.sigLoadRecipe.emit() + + def loadRecipe(self, configPars: dict): + for stepWidgets in list(self.stepsWidgets.values()): + try: + stepWidgets["delButton"].click() + except Exception as err: + pass + + configPars = self.sortStepsConfigPars(configPars) + for s in range(1, len(configPars)): + self.stepsWidgets[1]["addButton"].click() + + for i, (section, section_items) in enumerate(configPars.items()): + step_n = i + 1 + selector = self.stepsWidgets[step_n]["selector"] + kwarg_to_value_mapper = {} + for option, value in section_items.items(): + if option == "method": + selector.setCurrentText(value) + method = value + else: + kwarg_to_value_mapper[option] = value + selector.setParams(method, kwarg_to_value_mapper) + + self.setChecked(True) + + def sortStepsConfigPars(self, configPars: dict): + sortedConfigPars = {} + sortedKeys = sorted( + configPars.keys(), key=lambda key: int(re.findall(r"step(\d+)", key)[0]) + ) + for key in sortedKeys: + sortedConfigPars[key] = configPars[key] + return sortedConfigPars + + def saveRecipeUI( + self, folder_path, ext, title, basename, hintText, default_text + ): # -> tuple[Literal[False], Literal['']] | tuple[Literal[True], Any]: + win = filenameDialog( + title=title, + basename=basename, + ext=ext, + hintText=hintText, + allowEmpty=False, + defaultEntry=default_text, + parent=self, + ) + win.exec_() + if win.cancel: + return False, "" + + self.cancel = False + filepath = win.filename + os.makedirs(folder_path, exist_ok=True) + filepath = os.path.join(folder_path, filepath) + + if os.path.exists(filepath): + proceed = self.warnExistingRecipeFile(filepath) + if not proceed: + return False, "" + + return True, filepath + + def saveRecipe(self): + recipe = self.recipe() + if recipe is None: + return + + default_text = "" + for step in recipe[:2]: + method = step["method"] + func_name = config.PREPROCESS_MAPPER[method]["function_name"] + default_text = f"{default_text}-{func_name}" + default_text = default_text.lstrip("-") + + proceed, ini_filepath = self.saveRecipeUI( + preproc_recipes_path, + ".ini", + "Filename for pre-processing recipe", + "preprocessing_recipe", + "Insert a filename for the pre-processing recipe:", + default_text, + ) + if not proceed: + return + + cp = self.recipeConfigPars("acdc") + with open(ini_filepath, "w") as configfile: + cp.write(configfile) + + self.communicateSavingRecipeFinished(ini_filepath) + + def warnExistingRecipeFile(self, ini_filename): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + "A file with the following name

    " + f"{ini_filename}

    " + "already exists.

    " + "Do you want to overwrite the existing file?" + ) + noButton, yesButton = msg.warning( + self, + "File name existing", + txt, + buttonsTexts=("No, stop saving process", "Yes, overwrite existing file"), + ) + return msg.clickedButton == yesButton + + def warnNoAvailableRecipesToLoad(self): + text = html_utils.paragraph("There are no recipes saved. Sorry about that :(") + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "No recipes saved", text) + + # def selectIniFileToLoadRecipe(self): + # import qtpy.compat + # ini_filepath = qtpy.compat.getopenfilename( + # parent=self, + # caption='Select INI file to load pre-processing recipe', + # filters='INI (*.ini);;All Files (*)' + # )[0] + # if not ini_filepath: + # return + + # cp = config.ConfigParser() + # cp.read(ini_filepath) + # preprocConfigPars = {} + # for section in cp.sections(): + # if not section.startswith('acdc.preprocess'): + # continue + + # preprocConfigPars[section] = cp[section] + + # if not preprocConfigPars: + # return + + # self.loadRecipe(preprocConfigPars) + + def selectRecipeFilepath(self, recipes_path, recipe_prefix, ext_label, ext): + availableRecipes = [] + if os.path.exists(recipes_path): + for file in myutils.listdir(recipes_path): + if not file.startswith(recipe_prefix): + continue + endname = file.split(f"{recipe_prefix}_")[1] + availableRecipes.append(endname) + + if not availableRecipes: + import qtpy.compat + + filepath = qtpy.compat.getopenfilename( + parent=self, + caption=f"Select {ext_label} file to load recipe", + filters=f"{ext_label} (*.{ext});;All Files (*)", + )[0] + return filepath or None + + browseButton = widgets.browseFileButton( + f"Select {ext_label} file...", + title=f"Select {ext_label} file to load recipe", + openFolder=False, + start_dir=myutils.getMostRecentPath(), + ext={ext_label: f".{ext}"}, + ) + selectRecipeWin = widgets.QDialogListbox( + "Select recipe", + "Select recipe to load:\n", + availableRecipes, + multiSelection=False, + allowEmptySelection=False, + parent=self, + additionalButtons=(browseButton,), + ) + browseButton.sigPathSelected.connect( + partial( + self.recipeIniFileSelected, + selectRecipeWin=selectRecipeWin, + sender=browseButton, + ) + ) + selectRecipeWin.exec_() + if selectRecipeWin.cancel: + return None + + if selectRecipeWin.clickedButton == browseButton: + return selectRecipeWin.selectedIniFilepath + + selected_endname = selectRecipeWin.selectedItemsText[0] + filename = f"{recipe_prefix}_{selected_endname}" + return os.path.join(recipes_path, filename) + + def selectAndLoadRecipe(self): + filepath = self.selectRecipeFilepath( + preproc_recipes_path, "preprocessing_recipe", "INI", "ini" + ) + if filepath is None: + return + cp = config.ConfigParser() + cp.read(filepath) + preprocConfigPars = { + s: cp[s] for s in cp.sections() if s.startswith("acdc.preprocess") + } + if not preprocConfigPars: + return + self.loadRecipe(preprocConfigPars) + + def recipeIniFileSelected(self, ini_filepath, selectRecipeWin=None, sender=None): + selectRecipeWin.clickedButton = sender + selectRecipeWin.selectedIniFilepath = ini_filepath + selectRecipeWin.cancel = False + selectRecipeWin.close() + + def communicateSavingRecipeFinished(self, ini_filepath): + text = html_utils.paragraph("Done!

    Pre-processing recipe saved to:") + msg = widgets.myMessageBox(wrapText=False) + msg.information( + self, + "Pre-processing recipe saved!", + text, + commands=(ini_filepath,), + path_to_browse=os.path.dirname(ini_filepath), + ) + + def addStep(self, is_first=False): + stepWidgets = {} + + self.row += 1 + + step_n = len(self.stepsWidgets) + 1 + label = QLabel(f"Step {step_n}: ") + self.gridLayout.addWidget(label, self.row, 0) + stepWidgets["stepLabel"] = label + + selector = widgets.PreProcessingSelector() + self.gridLayout.addWidget(selector, self.row, 1) + stepWidgets["selector"] = selector + + setParamsButton = widgets.setPushButton() + setParamsButton.setToolTip("Set step parameters") + self.gridLayout.addWidget(setParamsButton, self.row, 2) + setParamsButton.clicked.connect(partial(self.setParamsStep, selector=selector)) + stepWidgets["setParamsButton"] = setParamsButton + + infoButton = widgets.infoPushButton() + self.gridLayout.addWidget(infoButton, self.row, 3) + infoButton.clicked.connect(partial(self.showInfo, selector=selector)) + stepWidgets["infoButton"] = infoButton + + if is_first: + addButton = widgets.addPushButton() + self.gridLayout.addWidget(addButton, self.row, 4) + addButton.clicked.connect(self.addStep) + stepWidgets["addButton"] = addButton + else: + delButton = widgets.delPushButton() + self.gridLayout.addWidget(delButton, self.row, 4) + delButton.clicked.connect(self.removeStep) + delButton.step_n = step_n + stepWidgets["delButton"] = delButton + + self.row += 1 + selector.row = self.row + selector.step_n = step_n + + hline = widgets.QHLine() + self.gridLayout.addWidget(hline, self.row, 0, 1, 6) + stepWidgets["hline"] = hline + self.row += 1 + + self.stepsWidgets[step_n] = stepWidgets + + selector.sigValuesChanged.connect(self.emitValuesChanged) + selector.currentTextChanged.connect( + partial(self.clearInitKwargs, step_n=step_n) + ) + + self.resetStretch() + + def emitValuesChanged(self, functionKwargs, step_n): + self.stepsWidgets[step_n]["step_kwargs"] = functionKwargs + + recipe = self.recipe(warn=False) + if recipe is None: + return + + self.sigValuesChanged.emit(recipe) + + def clearInitKwargs(self, selected_method, step_n=0): + stepWidgets = self.stepsWidgets[step_n] + stepWidgets.pop("step_kwargs", None) + + def resetStretch(self): + for row in range(self.gridLayout.rowCount()): + self.gridLayout.setRowStretch(row, 0) + + self.gridLayout.setRowStretch(self.gridLayout.rowCount(), 1) + self.row = self.gridLayout.rowCount() - 1 + + def showInfo(self, checked=False, selector=None): + if selector is None: + return + + htmlText = selector.htmlInfo() + htmlText = html_utils.paragraph(htmlText) + + method = selector.currentText() + msg = widgets.myMessageBox(wrapText=False) + msg.information(self, f"Info about `{method}`", htmlText) + + def setParamsStep( + self, checked=False, selector: "widgets.PreProcessingSelector" = None + ): + step_n = selector.step_n + stepFunctionKwargs = selector.askSetParams( + df_metadata=self.df_metadata, addApplyButton=self.addApplyButton + ) + if stepFunctionKwargs is None: + return + + self.stepsWidgets[step_n]["step_kwargs"] = stepFunctionKwargs + + def removeStep(self, checked=False, step_n=None): + if step_n is None: + step_n = self.sender().step_n + + stepWidgets = self.stepsWidgets[step_n] + + stepWidgets["stepLabel"].hide() + self.gridLayout.removeWidget(stepWidgets["stepLabel"]) + + stepWidgets["selector"].hide() + self.gridLayout.removeWidget(stepWidgets["selector"]) + + stepWidgets["infoButton"].hide() + self.gridLayout.removeWidget(stepWidgets["infoButton"]) + + # stepWidgets['addButton'].hide() + # self.gridLayout.removeWidget(stepWidgets['addButton']) + + stepWidgets["setParamsButton"].hide() + self.gridLayout.removeWidget(stepWidgets["setParamsButton"]) + + stepWidgets["delButton"].hide() + self.gridLayout.removeWidget(stepWidgets["delButton"]) + self.row -= 1 + + stepWidgets["hline"].hide() + self.gridLayout.removeWidget(stepWidgets["hline"]) + self.row -= 1 + + self.stepsWidgets.pop(step_n) + + stepsWidgetsMapper = {1: self.stepsWidgets[1]} + for i, stepWidgets in enumerate(self.stepsWidgets.values()): + if i == 0: + continue + step_n = i + 1 + label = stepWidgets["stepLabel"] + label.setText(f"Step {step_n}: ") + stepWidgets["delButton"].step_n = step_n + stepWidgets["selector"].step_n = step_n + stepsWidgetsMapper[step_n] = stepWidgets + + self.stepsWidgets = stepsWidgetsMapper + + self.resetStretch() + + def isChecked(self): + return self.groupbox.isChecked() + + def warnStepNotInit(self, method): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + f"The parameters for the preprocessing step {method} " + "were not initialized.

    " + "Please, click on the corresponding Set step parameters " + "button to initialize this step (cog icon).

    " + "Thank you for your patience!" + ) + msg.warning(self, "Params not initialized!", txt) + + def recipe(self, warn=True): + recipe = [] + if not self.groupbox.isChecked() and self.groupbox.isCheckable(): + return recipe + + for stepWidgets in self.stepsWidgets.values(): + method = stepWidgets["selector"].currentText() + step_kwargs = stepWidgets.get("step_kwargs") + if step_kwargs is None: + if warn: + self.warnStepNotInit(method) + return + + try: + init_func = config.PREPROCESS_INIT_MAPPER[method]["function"] + init_func(**step_kwargs) + except Exception as err: + pass + + recipe.append({"method": method, "kwargs": step_kwargs}) + + return recipe + + def recipeConfigPars(self, model_name): + cp = config.ConfigParser() + if not self.groupbox.isChecked() and self.groupbox.isCheckable(): + return cp + + for s, step in enumerate(self.recipe()): + section = f"{model_name}.preprocess.step{s + 1}" + cp[section] = {} + cp[section]["method"] = step["method"] + for option, value in step["kwargs"].items(): + cp[section][option] = str(value) + return cp + + +class CombineChannelsWidget(PreProcessParamsWidget): + sigValuesChangedCombineChannels = Signal() + + def __init__(self, channel_names: Iterable[str], parent=None): + self.channel_names = channel_names + + super().__init__(parent) + + self.parent = parent + qutils.delete_widget(self.loadLastRecipeButton) + qutils.delete_widget(self.saveRecipeButton) + qutils.delete_widget(self.loadRecipeButton) + + def addStep(self, is_first=False): + stepWidgets = {} + + self.row += 1 + if is_first: + self.row += 1 + + step_n = len(self.stepsWidgets) + 1 + tooltip = "Use this text in the formula" + if is_first: + label = QLabel("Formula var") + label.setToolTip(tooltip) + self.gridLayout.addWidget(label, self.row - 1, 1) + name_edit = QLineEdit(text=f"img{step_n}") + name_edit.setToolTip(tooltip) + self.gridLayout.addWidget(name_edit, self.row, 1) + stepWidgets["name_edit"] = name_edit + name_edit.textChanged.connect(self.emitValuesChanged) + + tooltip = "Select a channel or a segmentation mask" + if is_first: + label = QLabel("Channel") + label.setToolTip(tooltip) + self.gridLayout.addWidget(label, self.row - 1, 2) + ch_selector = QComboBox() + ch_selector.setToolTip(tooltip) + ch_selector.addItems(self.channel_names) + self.gridLayout.addWidget(ch_selector, self.row, 2) + stepWidgets["selector"] = ch_selector + ch_selector.currentTextChanged.connect(self.setBinarizeCheckableAndNorm) + + # add binarisaion spinbox + tooltip = ( + "If binarize is selected, the channel will be binarized first, before applying offset and multiplier.\n" + "If inverse binarize is selected, the channel will be binerized and " + "then the logical NOT will be applied." + ) + if is_first: + label = QLabel("Binarize") + label.setToolTip(tooltip) + self.gridLayout.addWidget(label, self.row - 1, 5) + options = ["No", "binarize", "inverse binarize"] + self.binarizeCombobox = QComboBox() + self.binarizeCombobox.addItems(options) + self.binarizeCombobox.setCurrentIndex(0) + self.binarizeCombobox.setEnabled(False) + self.binarizeCombobox.setToolTip(tooltip) + self.binarizeCombobox.currentIndexChanged.connect(self.emitValuesChanged) + self.gridLayout.addWidget(self.binarizeCombobox, self.row, 5) + stepWidgets["binarize"] = self.binarizeCombobox + + tooltip = "Min value of the channel to be normalized to." + if is_first: + label = QLabel("Min val") + label.setToolTip(tooltip) + self.gridLayout.addWidget(label, self.row - 1, 6) + self.minValueSpinbox = QDoubleSpinBox() + self.minValueSpinbox.setRange(-np.inf, np.inf) + self.minValueSpinbox.setSingleStep(0.1) + self.minValueSpinbox.setValue(0) + self.minValueSpinbox.setToolTip(tooltip) + + self.minValueSpinbox.valueChanged.connect(self.emitValuesChanged) + self.gridLayout.addWidget(self.minValueSpinbox, self.row, 6) + stepWidgets["minValueSpinbox"] = self.minValueSpinbox + + tooltip = "Max value of the channel to be normalized to." + if is_first: + label = QLabel("Max val") + label.setToolTip(tooltip) + self.gridLayout.addWidget(label, self.row - 1, 7) + self.maxValueSpinbox = QDoubleSpinBox() + self.maxValueSpinbox.setRange(-np.inf, np.inf) + self.maxValueSpinbox.setSingleStep(0.1) + self.maxValueSpinbox.setValue(1) + self.maxValueSpinbox.setToolTip(tooltip) + + self.maxValueSpinbox.valueChanged.connect(self.emitValuesChanged) + self.gridLayout.addWidget(self.maxValueSpinbox, self.row, 7) + stepWidgets["maxValueSpinbox"] = self.maxValueSpinbox + + if is_first: + addButton = widgets.addPushButton() + self.gridLayout.addWidget(addButton, self.row, 8) + addButton.clicked.connect(self.addStep) + stepWidgets["addButton"] = addButton + + else: + delButton = widgets.delPushButton() + self.gridLayout.addWidget(delButton, self.row, 8) + delButton.clicked.connect(self.removeStep) + delButton.step_n = step_n + stepWidgets["delButton"] = delButton + + self.row += 1 + ch_selector.row = self.row + ch_selector.step_n = step_n + + hline = widgets.QHLine() + self.gridLayout.addWidget(hline, self.row, 0, 1, 8) + stepWidgets["hline"] = hline + self.row += 1 + + self.stepsWidgets[step_n] = stepWidgets + + self.resetStretch() + self.sigValuesChangedCombineChannels.emit() + self.setBinarizeCheckableAndNorm() + + def emitValuesChanged(self, *args): + self.sigValuesChangedCombineChannels.emit() + + def setBinarizeCheckableAndNorm(self): + for step_n, stepWidgets in self.stepsWidgets.items(): + binarizeSelector = stepWidgets["binarize"] + channel = stepWidgets["selector"].currentText() + if "segm" in channel: + binarizeSelector.setEnabled(True) + # set min and max to 0 and 1 and disable + stepWidgets["minValueSpinbox"].setValue(0) + stepWidgets["maxValueSpinbox"].setValue(1) + stepWidgets["minValueSpinbox"].setEnabled(False) + stepWidgets["maxValueSpinbox"].setEnabled(False) + else: + binarizeSelector.setEnabled(False) + binarizeSelector.setCurrentIndex(0) + # set min and max to 0 and 1 and enable + stepWidgets["minValueSpinbox"].setEnabled(True) + stepWidgets["maxValueSpinbox"].setEnabled(True) + + self.emitValuesChanged() + + def removeStep(self, checked=False, step_n=None): + if step_n is None: + step_n = self.sender().step_n + + stepWidgets = self.stepsWidgets[step_n] + + stepWidgets["name_edit"].hide() + self.gridLayout.removeWidget(stepWidgets["name_edit"]) + + stepWidgets["selector"].hide() + self.gridLayout.removeWidget(stepWidgets["selector"]) + + stepWidgets["binarize"].hide() + self.gridLayout.removeWidget(stepWidgets["binarize"]) + + stepWidgets["minValueSpinbox"].hide() + self.gridLayout.removeWidget(stepWidgets["minValueSpinbox"]) + + stepWidgets["maxValueSpinbox"].hide() + self.gridLayout.removeWidget(stepWidgets["maxValueSpinbox"]) + + stepWidgets["delButton"].hide() + self.gridLayout.removeWidget(stepWidgets["delButton"]) + + self.row -= 1 + + stepWidgets["hline"].hide() + self.gridLayout.removeWidget(stepWidgets["hline"]) + self.row -= 1 + + self.stepsWidgets.pop(step_n) + + stepsWidgetsMapper = {1: self.stepsWidgets[1]} + for i, stepWidgets in enumerate(self.stepsWidgets.values()): + if i == 0: + continue + step_n = i + 1 + stepWidgets["delButton"].step_n = step_n + stepWidgets["selector"].step_n = step_n + stepsWidgetsMapper[step_n] = stepWidgets + + self.stepsWidgets = stepsWidgetsMapper + + self.resetStretch() + self.sigValuesChangedCombineChannels.emit() + + def steps(self): + steps = {} + if not self.groupbox.isChecked() and self.groupbox.isCheckable(): + return steps + + for step_number, stepWidgets in self.stepsWidgets.items(): + name = stepWidgets["name_edit"].text() + channel = stepWidgets["selector"].currentText() + binarize = stepWidgets["binarize"].currentText() + min_val = stepWidgets["minValueSpinbox"].value() + max_val = stepWidgets["maxValueSpinbox"].value() + steps[step_number] = { + "name": name, + "channel": channel, + "binarize": binarize, + "min_val": min_val, + "max_val": max_val, + } + + steps = dict(sorted(steps.items())) + return steps + + +class FormulaEditWidget(QWidget): + sigFormulaChanged = Signal(str, bool) # formula_str, is_valid + + def __init__(self, variable_names=None, parent=None): + super().__init__(parent) + self._variable_names = variable_names or [] + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + self._edit = QLineEdit() + self._edit.setPlaceholderText("e.g. img1 + img2 * 0.5") + layout.addWidget(self._edit) + + self._status_label = QLabel() + self._status_label.setWordWrap(True) + self._status_label.setStyleSheet("font-size: 11px;") + layout.addWidget(self._status_label) + + self._edit.textChanged.connect(self._onTextChanged) + self._clearStatus() + + self.parent = parent + + def setVariableNames(self, variable_names): + """Allows setting the variables. + + Parameters + ---------- + variable_names : list + list of variable names (strings) + """ + + self._variable_names = variable_names + self._onTextChanged(self._edit.text()) + + def text(self): + """Returns the current formula text.""" + return self._edit.text() + + def setText(self, text): + """Sets the formula text.""" + self._edit.setText(text) + + def _clearStatus(self): + self._status_label.setText("") + self._status_label.setStyleSheet("font-size: 11px;") + + def _onTextChanged(self, text): + if not text.strip(): + self._clearStatus() + + success, reconstructed_str = self.checkValidity(self._variable_names) + + if success: + self._status_label.setText(f"→ {reconstructed_str}") + self._status_label.setStyleSheet("font-size: 11px; color: green;") + else: + self._status_label.setText(reconstructed_str) + self._status_label.setStyleSheet("font-size: 11px; color: red;") + + self.sigFormulaChanged.emit(text, success) + + def checkValidity(self, variable_names=None): + if variable_names is None: + variable_names = self._variable_names + formula_str = self._edit.text() + arrays = {name: 1 for name in variable_names} + success = False + reconstructed_str = "ERROR" + forb_ch = self.parent.forbiddenChannels + if forb_ch: + stepsWidgets = self.parent.combineChannelsWidget.stepsWidgets + channels = { + stepsWidget["selector"].currentText() + for stepsWidget in stepsWidgets.values() + } + if forb_ch.intersection(channels): + reconstructed_str = ( + "Channels that are forbidden are not allowed to be used!:\n" + f"{forb_ch}" + ) + return False, reconstructed_str + if formula_str == "": + reconstructed_str = "First channel is returned/applied" + return True, reconstructed_str + try: + symbols = {name: sp.Symbol(name) for name in arrays} + expr = sp.sympify(formula_str, locals=symbols) + missing = {str(s) for s in expr.free_symbols} - arrays.keys() + if missing: + reconstructed_str = f"Missing variables: {missing}" + return False, reconstructed_str + + if formula_str == "": + reconstructed_str = "" + return True, reconstructed_str + + # filter out expressions that have no variables + if not any(s.is_Symbol for s in expr.free_symbols): + reconstructed_str = "No variables used" + return False, reconstructed_str + + reconstructed_str = str(expr) + success = True + except Exception as e: + if "syntax" in str(e): + reconstructed_str = f"Syntax error" + else: + reconstructed_str = str(e) + success = False + return success, reconstructed_str + + +class InitFijiMacroDialog(QBaseDialog): + def __init__(self, parent=None): + self.cancel = True + + super().__init__(parent=parent) + + mainLayout = QVBoxLayout() + + infoLabel = QLabel( + html_utils.paragraph( + """ + Place all the raw microscopy files in a folder without any other + file
    + and provide the following information: + """ + ) + ) + mainLayout.addWidget(infoLabel) + + gridLayout = QGridLayout() + + row = 0 + label = QLabel("Files internal structure: ") + gridLayout.addWidget(label, row, 0) + self.filesStructureCombobox = QComboBox() + self.filesStructureCombobox.addItems( + [ + 'Positions (aka "series") embedded in the file', + 'Positions (aka "series") separated, one for each file', + 'Positions (aka "series") and channels separated, one for each file', + ] + ) + gridLayout.addWidget(self.filesStructureCombobox, row, 1) + self.filesStructureCombobox.currentTextChanged.connect( + self.fileStructureChanged + ) + infoButton = widgets.infoPushButton() + gridLayout.addWidget(infoButton, row, 2) + infoButton.clicked.connect(self.showInfoFileStructure) + + row += 1 + label = QLabel("Folder with raw microscopy files: ") + gridLayout.addWidget(label, row, 0) + self.folderPathLineEdit = widgets.ElidingLineEdit() + gridLayout.addWidget(self.folderPathLineEdit, row, 1) + browseButton = widgets.browseFileButton(openFolder=True) + gridLayout.addWidget(browseButton, row, 2) + browseButton.sigPathSelected.connect( + partial(self.updateFolderPath, lineEdit=self.folderPathLineEdit) + ) + self.folderPathLineEdit.textChanged.connect(self.srcFolderPathChanged) + + row += 1 + label = QLabel("Destination folder: ") + gridLayout.addWidget(label, row, 0) + self.dstfolderPathLineEdit = widgets.ElidingLineEdit() + gridLayout.addWidget(self.dstfolderPathLineEdit, row, 1) + browseButton = widgets.browseFileButton(openFolder=True) + gridLayout.addWidget(browseButton, row, 2) + browseButton.sigPathSelected.connect(self.dstfolderPathLineEdit.setText) + + row += 1 + label = QLabel("Channel(s) name: ") + gridLayout.addWidget(label, row, 0) + self.channelNamesLineEdit = widgets.alphaNumericLineEdit(additionalChars=" ,") + gridLayout.addWidget(self.channelNamesLineEdit, row, 1) + checkButton = widgets.TestPushButton("Check") + gridLayout.addWidget(checkButton, row, 3) + checkButton.clicked.connect(self.checkChannelNames) + checkButton.setDisabled(True) + self.checkButton = checkButton + infoButton = widgets.infoPushButton() + gridLayout.addWidget(infoButton, row, 2) + infoButton.clicked.connect(self.showInfoChannelName) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + gridLayout.setColumnStretch(0, 0) + gridLayout.setColumnStretch(1, 1) + gridLayout.setColumnStretch(2, 0) + gridLayout.setColumnStretch(3, 0) + + mainLayout.addLayout(gridLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def fileStructureChanged(self, text): + self.checkButton.setDisabled(not "channels separated" in text) + + def checkChannelNames(self, checked=False): + proceed = self.validate() + if not proceed: + return + + src_folderpath = self.folderPath() + channel_names = self.channelNames() + extension = os.listdir(src_folderpath)[0].split(".")[-1] + basenames = io.move_separate_channels_tiffs_to_pos_folders( + src_folderpath, channel_names, get_only_basenames=True, extension=extension + ) + pos_folders_texts = [] + for p, basename in enumerate(basenames): + pos_folders_texts.append(f"Position_{p + 1}: {basename}") + + pos_folders_html_list = html_utils.to_list(pos_folders_texts, ordered=True) + text = html_utils.paragraph( + "The following Position folders will be created based on the provided channel names:
    " + f"{pos_folders_html_list}" + ) + msg = widgets.myMessageBox(wrapText=False) + msg.information(self, "Position folders", text) + + def srcFolderPathChanged(self, text): + if self.dstfolderPathLineEdit.text(): + return + + folderPath = self.folderPathLineEdit.text() + self.dstfolderPathLineEdit.setText(folderPath) + + def showInfoFileStructure(self): + txt = html_utils.paragraph(""" + Select whether the microscopy files contains multiple "series".

    + This typically depends on how you acquired the images at the + microscope, i.e., you generated multiple microscopy files + (e.g., snapshots), or you setup automatic acquisition of multiple + positions. + """) + msg = widgets.myMessageBox(wrapText=False) + msg.information(self, "Files structure info", txt) + + def showInfoChannelName(self): + txt = html_utils.paragraph(""" + Enter the channels name. Separate multiple channels with a comma.

    + The channel names will be used to name the individual TIFF files + (one for each channel).

    + If multiple channels are embedded in the microscopy file, make sure that you write the channels in the right order.
    + If you are unsure, open the file in Fiji first + and check the order of channels.

    + If the channels are already separated, make sure to write the + full channel name as it appears in the file, including capitalization and spaces.
    + For example, if the files are named "pos1_ch1.tif", "pos1_ch2.tif", etc., the channels names should be "ch1, ch2".

    + After providing the channel names, you can check that they are correct by clicking on the "Check" button next to the channel names field.
    + The number of Positions that will be created will be displayed alongside the basename. + """) + msg = widgets.myMessageBox(wrapText=False) + msg.information(self, "Files structure info", txt) + + def updateFolderPath(self, path, lineEdit=""): + for file in os.listdir(path): + if not is_alphanumeric_filename(file): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + f""" + The filename {file} contains invalid + characters.

    + Valid characters are letters, numbers, spaces, underscores + and dashes.

    + Please rename the file and try again.

    + Thank you for your patience! + """ + ) + msg.critical(self, "Invalid filename", txt, path_to_browse=path) + lineEdit.setText("") + return + + lineEdit.setText(path) + + def warnPathEmpty(self, path_name): + txt = html_utils.paragraph(f""" + {path_name} cannot be empty. + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Empty folder path", txt) + + def warnSelectedPathDoesNotExist(self, path): + txt = html_utils.paragraph(""" + The selected path does not exist.

    + Selected path: + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Folder path does not exist", txt, commands=(path,)) + + def warnSelectedPathNotAFolder(self, path): + txt = html_utils.paragraph(""" + The selected path is not a folder.

    + Selected path: + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Selected path not a folder", txt, commands=(path,)) + + def warnMultipleExtensionsPresent(self, path, extensions): + txt = html_utils.paragraph(f""" + The selected path contains files with different extensions. +

    + Extensions present: {extensions}

    + Please, make sure that all the files in the folder have the same + extension before proceeding.

    + Selected path: + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Multiple file extensions detected", txt, commands=(path,)) + + def warnChannelNamesEmpty(self): + txt = html_utils.paragraph(""" + Channel(s) name cannot be empty. + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Empty channel name", txt) + + def validate(self): + path = self.folderPath() + dst_path = self.dstfolderPathLineEdit.text() + paths = { + "Source folder": path, + "Destination folder": dst_path, + } + for _path_name, _path in paths.items(): + if not _path: + self.warnPathEmpty(_path_name) + return False + + if not os.path.exists(_path): + self.warnSelectedPathDoesNotExist(_path) + return False + + if not os.path.isdir(_path): + self.warnSelectedPathNotAFolder(_path) + return False + + files = myutils.listdir(path) + extensions = set([os.path.splitext(file)[1] for file in files]) + if len(extensions) > 1: + self.warnMultipleExtensionsPresent(path, extensions) + return False + + if not self.channelNamesLineEdit.text(): + self.warnChannelNamesEmpty() + return False + + return True + + def folderPath(self): + return self.folderPathLineEdit.text() + + def channelNames(self): + channel_names = self.channelNamesLineEdit.text().split(",") + channel_names = [ch.strip() for ch in channel_names] + return channel_names + + def ok_cb(self): + proceed = self.validate() + if not proceed: + return + + self.selectedFolderPath = self.folderPath() + self.filesStructure = self.filesStructureCombobox.currentText() + is_multiple_files = self.filesStructure.find("separated") != -1 + is_separate_channels = "channels separated" in self.filesStructure + dst_folderpath = self.dstfolderPathLineEdit.text() + self.init_macro_args = ( + self.folderPath(), + is_multiple_files, + is_separate_channels, + dst_folderpath, + self.channelNames(), + ) + self.cancel = False + self.close() + + +class ImageJRoisToSegmManager(QBaseDialog): + def __init__( + self, + rois_filepath, + TZYX_shape, + addUseSamePropsForNextPosButton=False, + parent=None, + ): + import roifile + + self.cancel = True + super().__init__(parent) + + self.setWindowTitle("ROI Manager") + + mainLayout = QVBoxLayout() + + rois = roifile.roiread(rois_filepath) + self.rois = {roi.name: roi for roi in rois} + + roisNamesTreeWidget = widgets.TreeWidget() + roisNamesTreeWidget.setHeaderLabels(["ROI name", "Cell_ID"]) + roisNamesTreeWidget.header().setSectionResizeMode(QHeaderView.ResizeToContents) + # roisNamesTreeWidget.header().setStretchLastSection(False) + for r, roi in enumerate(rois): + item = widgets.TreeWidgetItem() + item.setText(0, roi.name) + item.setText(1, str(r + 1)) + roisNamesTreeWidget.addTopLevelItem(item) + roisNamesTreeWidget.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + roisNamesTreeWidget.selectAll() + mainLayout.addWidget(QLabel("Select ROIs to convert")) + mainLayout.addWidget(roisNamesTreeWidget) + self.roisNamesTreeWidget = roisNamesTreeWidget + mainLayout.addSpacing(10) + mainLayout.addWidget(widgets.QHLine()) + mainLayout.addSpacing(5) + + gridLayout = None + self.lowZspinbox = None + + SizeT, SizeZ, SizeY, SizeX = TZYX_shape + if SizeZ > 1: + gridLayout = QGridLayout() + self.lowZspinbox = widgets.SpinBox() + self.lowZspinbox.setMinimum(0) + self.lowZspinbox.setMaximum(SizeZ - 1) + + self.highZspinbox = widgets.SpinBox() + self.highZspinbox.setMinimum(0) + self.highZspinbox.setMaximum(SizeZ - 1) + self.highZspinbox.setValue(SizeZ - 1) + + gridLayout.addWidget(QLabel("Repeat 2D ROIs over z-range: "), 1, 0) + + gridLayout.addWidget(QLabel("Start z-slice"), 0, 1) + gridLayout.addWidget(self.lowZspinbox, 1, 1) + + gridLayout.addWidget(QLabel("Stop z-slice"), 0, 2) + gridLayout.addWidget(self.highZspinbox, 1, 2) + + if gridLayout is not None: + mainLayout.addLayout(gridLayout) + mainLayout.addSpacing(5) + mainLayout.addWidget(widgets.QHLine()) + mainLayout.addSpacing(10) + + self.rescaleRoisGroupbox = widgets.RescaleImageJroisGroupbox(TZYX_shape) + self.rescaleRoisGroupbox.setChecked(False) + mainLayout.addWidget(self.rescaleRoisGroupbox) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + self.useSamePropsForNextPos = False + if addUseSamePropsForNextPosButton: + useSamePropsForNextPosButton = widgets.reloadPushButton( + "Keep the same preferences for all next Positions" + ) + buttonsLayout.insertWidget(3, useSamePropsForNextPosButton) + useSamePropsForNextPosButton.clicked.connect( + self.useSamePropsForNextPosClicked + ) + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def useSamePropsForNextPosClicked(self): + self.useSamePropsForNextPos = True + self.ok_cb() + + def warnRoiSelectionEmpty(self): + txt = html_utils.paragraph(f""" + You did not select any ROI.

    + ROIs selection cannot be empty. Thank you for your patience! + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "ROIs selection empty", txt) + + def ok_cb(self): + selectedRois = self.roisNamesTreeWidget.selectedItems() + if not selectedRois: + self.useSamePropsForNextPos = False + self.warnRoiSelectionEmpty() + return + + self.IDsToRoisMapper = {} + for item in selectedRois: + roiName = item.text(0) + ID = int(item.text(1)) + self.IDsToRoisMapper[ID] = self.rois[roiName] + + numRois = self.roisNamesTreeWidget.topLevelItemCount() + self.areAllRoisSelected = len(self.IDsToRoisMapper) == numRois + + self.rescaleSizes = self.rescaleRoisGroupbox.inputOutputSizes() + self.repeatRoisZslicesRange = None + if self.lowZspinbox is not None: + self.repeatRoisZslicesRange = ( + self.lowZspinbox.value(), + self.highZspinbox.value() + 1, + ) + + self.cancel = False + self.close() + + +class ResizeUtilProps(QBaseDialog): + def __init__(self, input_path="", parent=None): + self.cancel = True + super().__init__(parent) + + self.setWindowTitle("Resize Data Properties") + + mainLayout = QVBoxLayout() + + paramsLayout = QGridLayout() + + self._input_path = input_path + + row = 0 + paramsLayout.addWidget(QLabel("Overwrite raw data: "), row, 0) + self.overwriteToggle = widgets.Toggle() + self.overwriteToggle.setChecked(True) + paramsLayout.addWidget( + self.overwriteToggle, row, 1, 1, 2, alignment=Qt.AlignCenter + ) + + row += 1 + paramsLayout.addWidget(QLabel("Folder path for resized images: "), row, 0) + self.folderPathOutControl = widgets.filePathControl( + browseFolder=True, + fileManagerTitle="Select folder where to save resized data", + elide=True, + startFolder=myutils.getMostRecentPath(), + ) + self.folderPathOutControl.setDisabled(True) + paramsLayout.addWidget(self.folderPathOutControl, row, 1, 1, 2) + + row += 1 + paramsLayout.addWidget(QLabel("Text to append to files: "), row, 0) + self.textToAppendLineEdit = widgets.alphaNumericLineEdit() + self.textToAppendLineEdit.setAlignment(Qt.AlignCenter) + self.textToAppendLineEdit.setDisabled(True) + paramsLayout.addWidget(self.textToAppendLineEdit, row, 1, 1, 2) + + row += 1 + paramsLayout.addWidget(QLabel("Resize mode: "), row, 0) + self.downScaleRadioButton = QRadioButton("Downscale") + self.upScaleRadioButton = QRadioButton("Upscale") + self.downScaleRadioButton.setChecked(True) + paramsLayout.addWidget( + self.downScaleRadioButton, row, 1, alignment=Qt.AlignCenter + ) + paramsLayout.addWidget( + self.upScaleRadioButton, row, 2, alignment=Qt.AlignCenter + ) + + row += 1 + paramsLayout.addWidget(QLabel("Resize factor: "), row, 0) + self.factorSpinbox = widgets.FloatLineEdit(allowNegative=False) + self.factorSpinbox.setMinimum(1.0) + self.factorSpinbox.setValue(2.0) + paramsLayout.addWidget(self.factorSpinbox, row, 1, 1, 2) + + paramsLayout.setColumnStretch(0, 0) + paramsLayout.setVerticalSpacing(10) + + self.overwriteToggle.toggled.connect(self.overwriteToggled) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addLayout(paramsLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + mainLayout.addStretch(1) + + # self.textToAppendLineEdit.setText(self._getDefaultTextToAppend()) + + self.setLayout(mainLayout) + + def _getDefaultTextToAppend(self): + rescale_mode = "up" if self.upScaleRadioButton.isChecked() else "down" + factor = self.factorSpinbox.value() + text = f"{rescale_mode}scaled_factor_{factor}" + return text + + def overwriteToggled(self, checked): + self.folderPathOutControl.setDisabled(checked) + self.textToAppendLineEdit.setDisabled(checked) + if checked: + text = "" + else: + text = self._getDefaultTextToAppend() + self.textToAppendLineEdit.setText(text) + + def warnFolderPathEmpty(self): + txt = html_utils.paragraph(""" + To prevent overwriting raw data the Folder path for + resized images cannot be empty. + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Empty folder path", txt) + + def warnTextToAppendEmpty(self): + txt = html_utils.paragraph(""" + To prevent overwriting raw data the text to append + cannot be empty. + """) + msg = widgets.myMessageBox(wrapText=False) + msg.warning(self, "Empty text to append", txt) + + def ok_cb(self): + self.expFolderpathOut = self.folderPathOutControl.path() + self.textToAppend = self.textToAppendLineEdit.text() + isAccidentalOverwrite = ( + not self.overwriteToggle.isChecked() + and self.expFolderpathOut == self._input_path + and not self.textToAppend + ) + if isAccidentalOverwrite: + self.warnTextToAppendEmpty() + return + + if self.textToAppend and not self.textToAppend.startswith("_"): + self.textToAppend = f"_{self.textToAppend}" + + if self.overwriteToggle.isChecked(): + self.expFolderpathOut = None + + factor = self.factorSpinbox.value() + self.resizeFactor = ( + factor if self.upScaleRadioButton.isChecked() else 1 / factor + ) + + self.cancel = False + self.close() + + +class FucciPreprocessDialog(FunctionParamsDialog): + def __init__( + self, + channel_names, + df_metadata=None, + parent=None, + ): + + from cellacdc.preprocess import fucci_filter + + params_argspecs = myutils.get_function_argspec(fucci_filter) + + super().__init__( + params_argspecs, + function_name="FUCCI pre-processing", + df_metadata=df_metadata, + parent=parent, + ) + + channelNamesLayout = QGridLayout() + + row = 0 + label = QLabel("First channel name: ") + channelNamesLayout.addWidget(label, row, 0, alignment=Qt.AlignLeft) + self.firstChNameWidget = QComboBox() + self.firstChNameWidget.addItems(channel_names) + channelNamesLayout.addWidget(self.firstChNameWidget, row, 1) + + row += 1 + label = QLabel("Second channel name: ") + channelNamesLayout.addWidget(label, row, 0, alignment=Qt.AlignLeft) + self.secondChNameWidget = QComboBox() + self.secondChNameWidget.addItems(channel_names) + self.secondChNameWidget.setCurrentText(list(channel_names)[1]) + channelNamesLayout.addWidget(self.secondChNameWidget, row, 1) + + channelNamesLayout.setColumnStretch(0, 0) + channelNamesLayout.setColumnStretch(1, 1) + + self.mainLayout.insertLayout(0, channelNamesLayout) + self.mainLayout.insertWidget(1, widgets.QHLine()) + + def ok_cb(self): + self.firstChannelName = self.firstChNameWidget.currentText() + self.secondChannelName = self.secondChNameWidget.currentText() + super().ok_cb() + + +class PreProcessRecipeDialog(QBaseDialog): + sigApplyImage = Signal(object) + sigApplyZstack = Signal(object) + sigApplyAllFrames = Signal(object) + sigApplyAllPos = Signal(object) + sigPreviewToggled = Signal(bool) + sigValuesChanged = Signal(list) + sigSavePreprocData = Signal(object) + sigClose = Signal(object) + + def __init__( + self, + isTimelapse=False, + isZstack=False, + isMultiPos=False, + df_metadata=None, + addApplyButton=False, + parent=None, + hideOnClosing=False, + ): + super().__init__(parent=parent) + + self.setWindowTitle("Pre-processing recipe") + + self.cancel = True + self.hideOnClosing = hideOnClosing + + mainLayout = QVBoxLayout() + + keepInputDataTypeLayout = QHBoxLayout() + self.keepInputDataTypeToggle = widgets.Toggle() + self.keepInputDataTypeToggle.setChecked(True) + self.keepInputDataTypeToggle.toggled.connect(self.emitValuesChanged) + + keepInputDataTypeLayout.addStretch(1) + keepInputDataTypeLayout.addWidget(QLabel("Keep input data type: ")) + keepInputDataTypeLayout.addWidget(self.keepInputDataTypeToggle) + keepInputDataTypeInfoButton = widgets.infoPushButton() + keepInputDataTypeLayout.addWidget(keepInputDataTypeInfoButton) + keepInputDataTypeInfoButton.clicked.connect(self.showInfoKeepInputDataType) + self.keepInputDataTypeLayout = keepInputDataTypeLayout + + self.preProcessParamsWidget = PreProcessParamsWidget( + df_metadata=df_metadata, addApplyButton=addApplyButton, parent=self + ) + self.preProcessParamsWidget.groupbox.setCheckable(False) + + buttonsLayout = QGridLayout() # self.preProcessParamsWidget.buttonsLayout + self.buttonsLayout = buttonsLayout + self.previewCheckbox = QCheckBox("Preview") + buttonsLayout.addWidget(self.previewCheckbox, 0, 0) + + # Relocate buttons of PreProcessParamsWidget to this dialog + pPPWBL = self.preProcessParamsWidget.buttonsLayout + loadRecipeButtIdx = pPPWBL.indexOf(self.preProcessParamsWidget.loadRecipeButton) + self.loadRecipeButton = pPPWBL.takeAt(loadRecipeButtIdx).widget() + buttonsLayout.addWidget(self.loadRecipeButton, 0, 1) + + saveRecipeButtIdx = pPPWBL.indexOf(self.preProcessParamsWidget.saveRecipeButton) + self.saveRecipeButton = pPPWBL.takeAt(saveRecipeButtIdx).widget() + buttonsLayout.addWidget(self.saveRecipeButton, 1, 1) + + loadLastRecipeButtIdx = pPPWBL.indexOf( + self.preProcessParamsWidget.loadLastRecipeButton + ) + self.loadLastRecipeButton = pPPWBL.takeAt(loadLastRecipeButtIdx).widget() + buttonsLayout.addWidget(self.loadLastRecipeButton, 1, 0) + + self.loadLastRecipeButton.hide() + + # self.cancelButton = widgets.cancelPushButton('Cancel') + # buttonsLayout.insertWidget(2, self.cancelButton) + # buttonsLayout.insertSpacing(3, 20) + + self.allButtons = [ + self.previewCheckbox, + self.loadRecipeButton, + self.saveRecipeButton, + ] + col = 3 + row = 0 + self.applyCurrentFrameButton = widgets.okPushButton("Apply to displayed image") + buttonsLayout.addWidget(self.applyCurrentFrameButton, row, col) + self.applyCurrentFrameButton.clicked.connect( + partial(self.apply, signal=self.sigApplyImage) + ) + self.allButtons.append(self.applyCurrentFrameButton) + + infoLayout = QHBoxLayout() + buttonsHeight = self.applyCurrentFrameButton.sizeHint().height() + self.loadingCircle = widgets.LoadingCircleAnimation(size=buttonsHeight) + sp = self.loadingCircle.sizePolicy() + sp.setRetainSizeWhenHidden(True) + self.loadingCircle.setSizePolicy(sp) + self.loadingCircle.setVisible(False) + infoLayout.addWidget(self.loadingCircle) + + self.infoLabel = QLabel("(Feel free to use Cell-ACDC while waiting)") + sp = self.infoLabel.sizePolicy() + sp.setRetainSizeWhenHidden(True) + self.infoLabel.setSizePolicy(sp) + self.infoLabel.hide() + infoLayout.addWidget(self.infoLabel) + + buttonsLayout.addLayout( + infoLayout, row + 1, 0, 3, 2, alignment=Qt.AlignBottom | Qt.AlignLeft + ) + + if isZstack: + row += 1 + self.applyAllZslicesButton = widgets.threeDPushButton( + "Apply to all z-slices of current image" + ) + buttonsLayout.addWidget(self.applyAllZslicesButton, row, col) + self.applyAllZslicesButton.clicked.connect(self.applyAllZslices) + self.allButtons.append(self.applyAllZslicesButton) + if isTimelapse: + row += 1 + self.applyAllFramesButton = widgets.futurePushButton("Apply to all frames") + buttonsLayout.addWidget(self.applyAllFramesButton, row, col) + self.applyAllFramesButton.clicked.connect(self.applyAllFrames) + self.allButtons.append(self.applyAllFramesButton) + if isMultiPos: + row += 1 + self.applyAllPosButton = widgets.futurePushButton("Apply to all Positions") + buttonsLayout.addWidget(self.applyAllPosButton, row, col) + self.applyAllPosButton.clicked.connect( + partial(self.apply, signal=self.sigApplyAllPos) + ) + self.allButtons.append(self.applyAllPosButton) + + row += 1 + self.savePreprocButton = widgets.savePushButton("Save pre-processed data...") + buttonsLayout.addWidget(self.savePreprocButton, row, col) + + self.allButtons.append(self.savePreprocButton) + self.savePreprocButton.clicked.connect(self.emitSignalSavePreprocData) + + self.previewCheckbox.toggled.connect(self.emitSigPreviewToggled) + self.preProcessParamsWidget.sigValuesChanged.connect(self.emitValuesChanged) + + # self.cancelButton.clicked.connect(self.close) + + mainLayout.addLayout(keepInputDataTypeLayout) + mainLayout.addSpacing(20) + mainLayout.addWidget(self.preProcessParamsWidget) + mainLayout.addLayout(buttonsLayout) + self.mainLayout = mainLayout + + self.setLayout(mainLayout) + + def applyAllZslices(self, checked=False): + # Preview needs to be turned off because we are computing on every + # z-slice + self.previewCheckbox.setChecked(False) + self.apply(signal=self.sigApplyZstack) + + def applyAllFrames(self, checked=False): + # Preview needs to be turned off because we are computing on all frames + self.previewCheckbox.setChecked(False) + self.apply(signal=self.sigApplyAllFrames) + + def emitSigPreviewToggled(self): + self.sigPreviewToggled.emit(self.previewCheckbox.isChecked()) + + def showInfoKeepInputDataType(self): + txt = html_utils.paragraph(""" + If checked, the data type of the pre-processed data will be + the same as the input data type.

    + This is useful to avoid saving the pre-processed data as + floating-point numbers (e.g., 32-bit float) which might + increase the file size.

    + We recommend keeping this option checked. + """) + msg = widgets.myMessageBox(wrapText=False) + msg.information(self, "Keep input data type", txt) + + def emitSignalSavePreprocData(self): + self.sigSavePreprocData.emit(self) + + def emitValuesChanged(self): + recipe = self.recipe(warn=False) + if recipe is None: + return + + self.sigValuesChanged.emit(recipe) + + def setDisabled(self, disabled: bool): + self.preProcessParamsWidget.setDisabled(disabled) + self.loadingCircle.setVisible(disabled) + self.infoLabel.setVisible(disabled) + for button in self.allButtons: + try: + button.setDisabled(disabled) + except RuntimeError as e: + printl(traceback.format_exc()) + printl(f"Error: {e}") + printl(f"Button: {button}") + + def apply(self, checked=False, signal: Signal = None): + recipe = self.recipe() + if recipe is None: + return + + if signal is not None: + signal.emit(recipe) + + if self.hideOnClosing: + self.setDisabled(True) + self.infoLabel.setText( + f"{self.sender().text().replace('Apply', 'Applying')}...
    " + "(Feel free to use Cell-ACDC while waiting)" + ) + else: + self.ok_cb() + + def appliedFinished(self): + self.setDisabled(False) + + def recipe(self, warn=True): + recipe = self.preProcessParamsWidget.recipe(warn=warn) + if recipe is None: + return + + for step in recipe: + step["keep_input_data_type"] = self.keepInputDataTypeToggle.isChecked() + return recipe + + def recipeConfigPars(self): + return self.preProcessParamsWidget.recipeConfigPars("acdc") + + def ok_cb(self): + if self.hideOnClosing: + self.hide() + return + + self.cancel = False + self.close() + + def close(self): + super().close() + self.sigClose.emit(self) + + +class PreProcessRecipeDialogUtil(PreProcessRecipeDialog): + def __init__( + self, + channel_names: Iterable[str], + df_metadata=None, + parent=None, + ): + self.cancel = True + + super().__init__( + isTimelapse=False, + isZstack=False, + isMultiPos=False, + addApplyButton=False, + df_metadata=df_metadata, + parent=parent, + hideOnClosing=False, + ) + + self.listSelector = widgets.listWidget( + isMultipleSelection=True, minimizeHeight=True + ) + self.listSelector.addItems(channel_names) + self.listSelector.setCurrentRow(0) + + self.mainLayout.insertWidget(0, self.listSelector) + self.mainLayout.insertWidget(0, QLabel("Select channel(s) to pre-process:")) + self.mainLayout.insertSpacing(2, 10) + self.mainLayout.insertWidget(2, widgets.QHLine()) + + self.savePreprocButton.hide() + self.previewCheckbox.hide() + self.applyCurrentFrameButton.setText("Ok") + + buttonsLayout = self.preProcessParamsWidget.buttonsLayout + + saveRecipeButtonIndex = buttonsLayout.indexOf( + self.preProcessParamsWidget.saveRecipeButton + ) + + if saveRecipeButtonIndex == -1: + return + + saveRecipeButtonItem = buttonsLayout.takeAt(saveRecipeButtonIndex) + + buttonsLayout.addItem(saveRecipeButtonItem, 0, 2) + + def warnChannelSelectionEmpty(self): + txt = html_utils.paragraph(""" + You did not select any channel.

    + Channel selection cannot be empty.

    + Thank you for your patience! + """) + + def ok_cb(self): + selectedChannelItems = self.listSelector.selectedItems() + if not selectedChannelItems: + self.warnChannelSelectionEmpty() + + recipe = self.recipe() + if recipe is None: + return + + self.selectedRecipe = recipe + self.selectedChannels = [item.text() for item in selectedChannelItems] + + self.cancel = False + self.close() + + +class CombineChannelsSetupDialog(PreProcessRecipeDialog): + sigApplyImage = Signal(dict, bool, str) + sigApplyZstack = Signal(dict, bool, str) + sigApplyAllFrames = Signal(dict, bool, str) + sigApplyAllPos = Signal(dict, bool, str) + sigValuesChanged = Signal() + sigSaveAsSegmCheckboxToggled = Signal(bool) + + # sigApplyAllZslices = Signal(dict, bool, str) + # sigApplyAllFramesZslices = Signal(dict, bool, str) + + def __init__( + self, + channel_names, + df_metadata=None, + parent=None, + hideOnClosing=False, + isTimelapse=False, + isZstack=False, + isMultiPos=False, + ): + + self.combineChannelsWidget = CombineChannelsWidget(channel_names, parent=self) + self.warnExistingRecipeFile = self.combineChannelsWidget.warnExistingRecipeFile + self.communicateSavingRecipeFinished = ( + self.combineChannelsWidget.communicateSavingRecipeFinished + ) + self.saveRecipeUI = self.combineChannelsWidget.saveRecipeUI + self.selectRecipeFilepath = self.combineChannelsWidget.selectRecipeFilepath + + super().__init__( + isTimelapse=isTimelapse, + isZstack=isZstack, + isMultiPos=isMultiPos, + df_metadata=df_metadata, + parent=parent, + hideOnClosing=hideOnClosing, + ) + + self.combineChannelsWidget.sigValuesChangedCombineChannels.connect( + self.emitValuesChangedSteps + ) + + self.segm_blinked = False + self.validFormula = True # allow empty formula + self.forbiddenChannels = set() # channels that cannot be combined + + self.mainLayout.setSpacing(4) + + self.mainLayout.insertWidget(2, self.combineChannelsWidget) + self.combineChannelsWidget.groupbox.setCheckable(False) + self.combineChannelsWidget.groupbox.setTitle( + "Combine and manipulate channels and/or segmentation files" + ) + + self.formulaEditWidget = FormulaEditWidget(parent=self) + self._updateFormulaVariableNames() + self.formulaEditWidget.sigFormulaChanged.connect(self.formulaChanged) + self.formulaEditWidget.setToolTip( + 'Enter a formula to combine the channels. For example "img1 + img2 * 0.5"' + ) + self.mainLayout.insertWidget(3, self.formulaEditWidget) + + buttonsLayoutSaveGroup = QGridLayout() + + row = 0 + col = 0 + loadRecipeButton = widgets.OpenFilePushButton("Load saved recipe") + self.loadRecipeButtonComb = loadRecipeButton + buttonsLayoutSaveGroup.addWidget(loadRecipeButton, row, col) + self.loadRecipeButtonComb.clicked.connect(self.selectAndLoadRecipe) + + col += 1 + saveRecipeButton = widgets.savePushButton("Save current recipe") + self.saveRecipeButtonComb = saveRecipeButton + buttonsLayoutSaveGroup.addWidget(saveRecipeButton, row, col) + saveRecipeButton.clicked.connect(self.saveRecipe) + saveRecipeButton.setToolTip( + "Save the current recipe to a file\n" + f"Location: {combine_channels_recipes_path}" + ) + + col += 1 + loadLastRecipeButton = widgets.reloadPushButton("Load last recipe") + self.loadLastRecipeButtonComb = loadLastRecipeButton + buttonsLayoutSaveGroup.addWidget(loadLastRecipeButton, row, col) + self.mainLayout.addLayout(buttonsLayoutSaveGroup) + loadLastRecipeButton.clicked.connect(self.loadLastRecipe) + self.setLoadLastRecipe() + + loadLastRecipeButton.setContextMenuPolicy( + Qt.ContextMenuPolicy.CustomContextMenu + ) + loadLastRecipeButton.customContextMenuRequested.connect( + self._showLoadRecipeContextMenu + ) + + self.cancel = True + + self.setWindowTitle("Combine and manipulate channels and/or segmentation files") + self.preProcessParamsWidget.hide() + self.mainLayout.removeWidget(self.preProcessParamsWidget) + + self.savePreprocButton.setText("Save combined data...") + + tooltip = ( + "Save as a segmentation file, for example " + "when combining a binary mask with a segmentation mask." + ) + label = QLabel("Save as segmentation:") + self.saveAsSegmlabel = label + label.setToolTip(tooltip) + self.saveAsSegmCheckbox = widgets.Toggle() + self.saveAsSegmCheckbox.setToolTip(tooltip) + self.saveAsSegmCheckbox.setChecked(False) + self.saveAsSegmCheckbox.setEnabled(False) + self.saveAsSegmCheckbox.toggled.connect(self.emitSaveAsSegmCheckboxToggled) + + self.keepInputDataTypeLayout.insertWidget(0, label) + self.keepInputDataTypeLayout.insertWidget(1, self.saveAsSegmCheckbox) + + def setLoadLastRecipe(self): + filepath = self._lastRecipePath() + if not os.path.exists(filepath): + self.loadLastRecipeButtonComb.setEnabled(False) + + def returLoadSecondLastRecipe(self): + filepath = self._secondLastRecipePath() + if not os.path.exists(filepath): + return False + return True + + def _showLoadRecipeContextMenu(self, pos): + menu = QMenu(self) + action = menu.addAction("Load recipe from before the last one") + action.triggered.connect(self.loadPreviousRecipe) + action.setEnabled(self.returLoadSecondLastRecipe()) + menu.exec(self.loadLastRecipeButtonComb.mapToGlobal(pos)) + + def loadPreviousRecipe(self): + filepath = self._secondLastRecipePath() + if not os.path.exists(filepath): + return + + self.loadRecipe(filepath) + + def loadLastRecipe(self): + filepath = self._lastRecipePath() + if not os.path.exists(filepath): + return + + self.loadRecipe(filepath) + + def saveLastRecipe(self): + os.makedirs(combine_channels_recipes_path, exist_ok=True) + filepath = self._lastRecipePath() + + same = False + if os.path.exists(filepath): + steps_curr = self._getSaveRecipyDict() + with open(filepath, "r") as f: + steps_prev = json.load(f) + same = self._recipesMatch(steps_curr, steps_prev) + + if same: + return + + if os.path.exists(filepath): + new_filename = self._secondLastRecipePath() + if os.path.exists(new_filename): + os.remove(new_filename) + os.rename(filepath, new_filename) + self.saveRecipe(filepath=filepath) + + def _recipesMatch(self, steps_curr, steps_prev): + # Normalize current dict to strings for comparison with JSON-loaded dict + def normalize(d): + return {str(k): str(v) for k, v in d.items()} + + for raw_key in steps_curr: + key = str(raw_key) + if key not in steps_prev: + return False + if key in ("formula", "keep_input_data_type", "save_as_segm"): + if str(steps_curr[raw_key]) != str(steps_prev[key]): + return False + else: + step_dict = normalize(steps_curr[raw_key]) + step_dict_prev = steps_prev[key] + for key2, val2 in step_dict.items(): + if key2 not in step_dict_prev: + return False + if val2 != str(step_dict_prev[key2]): + return False + return True + + def _lastRecipePath(self): + return os.path.join( + combine_channels_recipes_path, ".last_combine_channels_recipe.json" + ) + + def _secondLastRecipePath(self): + return os.path.join( + combine_channels_recipes_path, ".previous_combine_channels_recipe.json" + ) + + def _getSaveRecipyDict(self): + steps = self.combineChannelsWidget.steps() # already returns a copy + formula = self.formulaEditWidget.text() + steps["formula"] = formula + steps["keep_input_data_type"] = self.keepInputDataTypeToggle.isChecked() + steps["save_as_segm"] = self.saveAsSegmCheckbox.isChecked() + return steps + + def saveRecipe(self, dummy=None, filepath=None): + os.makedirs(combine_channels_recipes_path, exist_ok=True) + + filepath_provided = filepath is not None + if not filepath_provided: + folder_content = myutils.listdir(combine_channels_recipes_path) + num_recipes = len(folder_content) + default_text = f"{num_recipes + 1}" + proceed, filepath = self.saveRecipeUI( + combine_channels_recipes_path, + ".json", + "Save recipe", + "combine_channels_recipe", + "Insert a filename for the recipe:", + default_text, + ) + + if not proceed: + return + + steps = self._getSaveRecipyDict() + + with open(filepath, "w") as f: + json.dump(steps, f, indent=2) + + if not filepath_provided: + self.communicateSavingRecipeFinished(filepath) + + def selectAndLoadRecipe(self): + filepath = self.selectRecipeFilepath( + combine_channels_recipes_path, "combine_channels_recipe", "JSON", "json" + ) + if filepath is None: + return + + self.loadRecipe(filepath) + + def loadRecipe(self, filepath): + with open(filepath, "r") as f: + recipe = json.load(f) + + recipe = dict(sorted(recipe.items())) + keys_used = set() + for key, value in recipe.items(): + if key == "formula": + formula = value + continue + if key == "keep_input_data_type": + self.keepInputDataTypeToggle.setChecked(value) + continue + if key == "save_as_segm": + self.saveAsSegmCheckbox.setChecked(value) + continue + + name = value["name"] + channel = value["channel"] + binarize = value["binarize"] + min_val = float(value["min_val"]) + max_val = float(value["max_val"]) + key = int(key) + stepWidgetsNum = len(self.combineChannelsWidget.stepsWidgets) + if key > stepWidgetsNum: + self.combineChannelsWidget.addStep() + + stepWidgets = self.combineChannelsWidget.stepsWidgets[key] + idx = stepWidgets["selector"].findText(channel) + if idx == -1: + stepWidgets["selector"].addItem(channel) + # stepWidgets['selector'].forbiddenItems.add(channel) + blinker = qutils.QControlBlink(stepWidgets["selector"], qparent=self) + blinker.start() + stepWidgets["selector"].blinker = blinker + self.forbiddenChannels.add(channel) + + stepWidgets["selector"].setCurrentText(channel) + stepWidgets["name_edit"].setText(name) + stepWidgets["binarize"].setCurrentText(binarize) + stepWidgets["minValueSpinbox"].setValue(min_val) + stepWidgets["maxValueSpinbox"].setValue(max_val) + + keys_used.add(key) + + # remove extra steps + keys_present = set(range(1, len(self.combineChannelsWidget.stepsWidgets) + 1)) + extra_keys = keys_present - keys_used + extra_keys = list(extra_keys) + extra_keys.sort(reverse=True) + for key in extra_keys: + self.combineChannelsWidget.removeStep(step_n=key) + # updates key dynamically so I have to rely that missing indx are always last steps + + # update formula + self.formulaEditWidget.setText(formula) + + for stepWidgets in self.combineChannelsWidget.stepsWidgets.values(): + combo = stepWidgets["selector"] + # set forbidden channels red in all steps + for i in range(combo.count()): + item = combo.itemText(i) + if item in self.forbiddenChannels: + combo.setItemData(i, QColor("red"), Qt.ForegroundRole) + + def _updateFormulaVariableNames(self): + names = [ + stepWidgets["name_edit"].text() + for stepWidgets in self.combineChannelsWidget.stepsWidgets.values() + ] + self.formulaEditWidget.setVariableNames(names) + + def formulaChanged(self, formula_str, is_valid): + self.setButtonsEnabled(is_valid) + self.validFormula = is_valid + if is_valid: + self.sigValuesChanged.emit() + + def setButtonsEnabled(self, enabled): + for i in range(self.buttonsLayout.count()): + item = self.buttonsLayout.itemAt(i) + widget = item.widget() + if widget is None: + continue + if isinstance(widget, QPushButton): + label = widget.text().lower().rstrip().lstrip() + if "apply" in label or "save" in label or "ok" in label: + if enabled: + try: + widget.setEnabled(True) + except: + pass + else: + try: + widget.setDisabled(True) + except: + pass + + def saveAsSegm(self): + return self.saveAsSegmCheckbox.isChecked() + + def emitSaveAsSegmCheckboxToggled(self): + if self.validFormula: + self.sigSaveAsSegmCheckboxToggled.emit(self.saveAsSegm()) + + def autoCheckSaveAsSegmCheckbox(self): + any_not_seg = False + for step in self.combineChannelsWidget.steps().values(): + channel = step["channel"] + if "segm" not in channel: + any_not_seg = True + break + + if any_not_seg: + self.saveAsSegmCheckbox.setChecked(False) + self.saveAsSegmCheckbox.setEnabled(False) + else: + if not self.segm_blinked: + self.saveAsSegmCheckbox.setEnabled(True) + self.blinker = qutils.QControlBlink( + self.saveAsSegmCheckbox, qparent=self + ) + self.blinker.start() + self.segm_blinked = True + + def apply(self, checked=False, signal: Signal = None): + steps = self.combineChannelsWidget.steps() + formula = self.formulaEditWidget.text() + keep_input_dtype = self.keepInputDataTypeToggle.isChecked() + if not steps or not self.validFormula: + return + + if signal is not None: + try: + signal.emit(steps, formula) + except TypeError as err: + signal.emit(steps, keep_input_dtype, formula) + + self.saveLastRecipe() + if self.hideOnClosing: + self.setDisabled(True) + self.infoLabel.setText( + f"{self.sender().text().replace('Apply', 'Applying')}...
    " + "(Feel free to use Cell-ACDC while waiting)" + ) + else: + self.ok_cb(saveLastRecipe=False) + + # Not needed anymore since now we funnel all changes to the formulaEditWidget, which then verifies the formula and + # emits a signal via formulaChangeda + # def emitValuesChanged(self): + # if not self.validFormula: + # return + # self.sigValuesChanged.emit() + + def emitValuesChangedSteps(self): + self.autoCheckSaveAsSegmCheckbox() + self._updateFormulaVariableNames() + + def ok_cb(self, dummy=None, saveLastRecipe=True): + if not self.validFormula: + return + + if saveLastRecipe: + self.saveLastRecipe() + + self.keepInputDataType = self.keepInputDataTypeToggle.isChecked() + self.selectedSteps = self.combineChannelsWidget.steps() + self.formula = self.formulaEditWidget.text() + self.cancel = False + self.close() + + +class CombineChannelsSetupDialogUtil(CombineChannelsSetupDialog): + def __init__( + self, + channel_names, + df_metadata=None, + parent=None, + ): + + super().__init__(channel_names, parent=parent, df_metadata=df_metadata) + + # add int input for number of workers + + self.mainLayout.addSpacing(20) + + qutils.hide_and_delete_layout(self.buttonsLayout) + buttonsLayout = widgets.CancelOkButtonsLayout() + self.buttonsLayout = buttonsLayout + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + self.mainLayout.addLayout(buttonsLayout) + + self.nThreadsSpinBox = QSpinBox() + self.nThreadsSpinBox.setMinimum(1) + self.nThreadsSpinBox.setValue(4) + self.nThreadsSpinBox.setToolTip("Number of threads to use for processing") + self.mainLayout.addWidget(QLabel("Number of threads:")) + self.mainLayout.addWidget(self.nThreadsSpinBox) + + +class CombineChannelsSetupDialogGUI(CombineChannelsSetupDialog): + def __init__( + self, + channel_names: Iterable[str], + df_metadata=None, + isTimelapse=False, + isZstack=False, + isMultiPos=False, + parent=None, + hideOnClosing=False, + ): + super().__init__( + channel_names, + df_metadata=df_metadata, + isTimelapse=isTimelapse, + isZstack=isZstack, + isMultiPos=isMultiPos, + parent=parent, + hideOnClosing=hideOnClosing, + ) + + # remove the preprocess buttons, we use the comb version of them + qutils.delete_widget(self.loadLastRecipeButton) + qutils.delete_widget(self.saveRecipeButton) + qutils.delete_widget(self.loadRecipeButton) + + # self.allButtons.remove(self.loadLastRecipeButton) + self.allButtons.remove(self.saveRecipeButton) + self.allButtons.remove(self.loadRecipeButton) + + self.previewCheckbox.setChecked(True) + self.saveAsSegmlabel.setText("Save and view as segmentation") + + def steps(self, return_keepInputDataType=False): + steps = self.combineChannelsWidget.steps() + formula = self.formulaEditWidget.text() + # if not return_keepInputDataType: + # return steps, formula + + keep_input_dtype = self.keepInputDataTypeToggle.isChecked() + return steps, keep_input_dtype, formula + + +class TestSegmModelInitalDialog(QBaseDialog): + def __init__(self, parent=None): + super().__init__(parent) + + self.cancel = True + + mainLayout = QVBoxLayout() + entriesLayout = widgets.FormLayout() + + row = 0 + self.startFrameNumberSpinbox = widgets.SpinBox() + self.startFrameNumberSpinbox.setMinimum(1) + + self.startFrameNumberFormWidget = widgets.formWidget( + self.startFrameNumberSpinbox, + labelTextLeft="Start frame number", + addActivateCheckbox=True, + ) + entriesLayout.addFormWidget(self.startFrameNumberFormWidget, row=row) + + row += 1 + self.stopFrameNumberSpinbox = widgets.SpinBox() + self.stopFrameNumberSpinbox.setMinimum(1) + + self.stopFrameNumberFormWidget = widgets.formWidget( + self.stopFrameNumberSpinbox, + labelTextLeft="Stop frame number", + addActivateCheckbox=True, + ) + entriesLayout.addFormWidget(self.stopFrameNumberFormWidget, row=row) + + row += 1 + self.startZsliceNumberSpinbox = widgets.SpinBox() + self.startZsliceNumberSpinbox.setMinimum(1) + + self.startZsliceNumberFormWidget = widgets.formWidget( + self.startZsliceNumberSpinbox, + labelTextLeft="Start z-slice number", + addActivateCheckbox=True, + ) + entriesLayout.addFormWidget(self.startZsliceNumberFormWidget, row=row) + + row += 1 + self.stopZsliceNumberSpinbox = widgets.SpinBox() + self.stopZsliceNumberSpinbox.setMinimum(1) + + self.stopZsliceNumberFormWidget = widgets.formWidget( + self.stopZsliceNumberSpinbox, + labelTextLeft="Stop z-slice number", + addActivateCheckbox=True, + ) + entriesLayout.addFormWidget(self.stopZsliceNumberFormWidget, row=row) + + row += 1 + + self.isTimelapseToggleFormWidget = widgets.formWidget( + widgets.Toggle(), + labelTextLeft="Is timelapse?", + stretchWidget=False, + valueGetterName="isChecked", + ) + entriesLayout.addFormWidget(self.isTimelapseToggleFormWidget, row=row) + + # self.stopFrameNumberSpinbox + # self.startZsliceNumberSpinbox + # self.stopZsliceNumberSpinbox + # self.isTimelapseToggle + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addLayout(entriesLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def ok_cb(self): + self.cancel = False + + self.start_frame_n = self.startFrameNumberFormWidget.value() + self.stop_frame_n = self.stopFrameNumberFormWidget.value() + self.start_z_slice_n = self.startZsliceNumberFormWidget.value() + self.stop_z_slice_n = self.stopZsliceNumberFormWidget.value() + self.is_timelapse = self.isTimelapseToggleFormWidget.value() + + self.close() + +# Sibling imports (deferred to avoid import cycles) +from ._base import ( + ArgWidget, +) +from .general import ( + imageViewer, +) +from .measurements import ( + SelectFeaturesRangeDialog, +) +from .metadata import ( + filenameDialog, +) + diff --git a/cellacdc/dialogs/tracking.py b/cellacdc/dialogs/tracking.py new file mode 100644 index 000000000..badd57f94 --- /dev/null +++ b/cellacdc/dialogs/tracking.py @@ -0,0 +1,2871 @@ +"""Cell-ACDC dialog windows: tracking.""" + +import os +import sys +import re +from typing import Literal, Callable, Dict, Iterable, List, Tuple +import datetime +import pathlib +from collections import defaultdict +import zipfile +from heapq import nlargest +import matplotlib +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.patches import Rectangle, Circle, PathPatch, Path +import numpy as np +import scipy.interpolate + +try: + import tkinter as tk +except Exception as err: + pass + +import cv2 +import traceback +from itertools import combinations, permutations +from collections import namedtuple +from natsort import natsorted + +# from MyWidgets import Slider, Button, MyRadioButtons +from skimage.measure import label, regionprops +from functools import partial +import skimage.filters +import skimage.measure +import skimage.morphology +import skimage.exposure +import skimage.draw +import skimage.registration +import skimage.color +import skimage.segmentation +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk +import matplotlib.pyplot as plt +import seaborn as sns +import pandas as pd +import math +import time +import sympy as sp +import json +import html + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from qtpy import QtCore +from qtpy.QtGui import ( + QIcon, + QFontMetrics, + QKeySequence, + QFont, + QRegularExpressionValidator, + QCursor, + QKeyEvent, + QPixmap, + QFont, + QPalette, + QMouseEvent, + QColor, +) +from qtpy.QtCore import ( + Qt, + QSize, + QEvent, + Signal, + QEventLoop, + QTimer, + QRegularExpression, +) +from qtpy.QtWidgets import ( + QFileDialog, + QApplication, + QMainWindow, + QMenu, + QLabel, + QToolBar, + QScrollBar, + QWidget, + QVBoxLayout, + QLineEdit, + QPushButton, + QHBoxLayout, + QDialog, + QFormLayout, + QListWidget, + QAbstractItemView, + QButtonGroup, + QCheckBox, + QSizePolicy, + QComboBox, + QSlider, + QGridLayout, + QSpinBox, + QToolButton, + QTableView, + QTextBrowser, + QDoubleSpinBox, + QScrollArea, + QFrame, + QProgressBar, + QGroupBox, + QRadioButton, + QDockWidget, + QMessageBox, + QStyle, + QPlainTextEdit, + QSpacerItem, + QTreeWidget, + QTreeWidgetItem, + QTextEdit, + QSplashScreen, + QAction, + QListWidgetItem, + QActionGroup, + QHeaderView, + QStyledItemDelegate, +) +import qtpy.compat + +from .. import exception_handler +from .. import load, prompts, core, measurements, html_utils +from .. import is_mac, is_win, is_linux, settings_folderpath, config +from .. import preproc_recipes_path, segm_recipes_path, combine_channels_recipes_path +from .. import is_conda_env +from .. import printl +from .. import colors +from .. import issues_url +from .. import myutils +from .. import qutils +from .. import _palettes +from .. import base_cca_dict +from .. import widgets +from .. import user_profile_path, promptable_models_path, models_path +from .. import features +from .. import _core +from .. import _types +from .. import plot +from .. import urls +from ..acdc_regex import float_regex, is_alphanumeric_filename, to_alphanumeric +from .. import _base_widgets +from .. import io +from .. import cca_functions +from .. import path + +POSITIVE_FLOAT_REGEX = float_regex(allow_negative=False) +TREEWIDGET_STYLESHEET = _palettes.TreeWidgetStyleSheet() +LISTWIDGET_STYLESHEET = _palettes.ListWidgetStyleSheet() +BACKGROUND_RGBA = _palettes.get_disabled_colors()["Button"] + +font = QFont() +font.setPixelSize(12) +italicFont = QFont() +italicFont.setPixelSize(12) +italicFont.setItalic(True) + +from ._base import ( + QBaseDialog, +) +from .export import ( + pdDataFrameWidget, +) +from .general import ( + QLineEditDialog, +) + +class TrackSubCellObjectsDialog(QBaseDialog): + def __init__(self, basename="", parent=None): + self.cancel = True + super().__init__(parent=parent) + + self.setWindowTitle("Track sub-cellular objects parameters") + + mainLayout = QVBoxLayout() + entriesLayout = widgets.FormLayout() + + row = 0 + infoTxt = html_utils.paragraph(""" + Select behaviour with untracked objects:

    + NOTE: this utility always create new files. + Original segmentation masks
    are not modified
    . + """) + options = ( + "Delete sub-cellular objects that do not belong to any cell", + "Delete cells that do not have any sub-cellular object", + "Delete both cells and sub-cellular objects without an assignment", + "Only track the objects and keep all the non-tracked objects", + ) + combobox = widgets.QCenteredComboBox() + combobox.addItems(options) + self.optionsWidget = widgets.formWidget( + combobox, + addInfoButton=True, + labelTextLeft="Tracking mode: ", + infoTxt=infoTxt, + ) + entriesLayout.addFormWidget(self.optionsWidget, row=row) + + row += 1 + infoTxt = html_utils.paragraph(""" + Re-label sub-cellular objects before assigning them to the cell.

    + Activate this option if you have merged sub-cellular objects + that must be separated, or the segmentation is a boolean mask + (i.e., semantic segmentation). + """) + self.relabelSubObjLab = widgets.formWidget( + widgets.Toggle(), + addInfoButton=True, + stretchWidget=False, + labelTextLeft="Re-label sub-cellular objects before tracking: ", + infoTxt=infoTxt, + ) + entriesLayout.addFormWidget(self.relabelSubObjLab, row=row) + + row += 1 + IoAtext = html_utils.paragraph(""" + Enter a minimum percentage (0-1) of the sub-cellular object's area
    + that MUST overlap with the parent cell to be considered belonging to a cell: + """) + spinbox = widgets.CenteredDoubleSpinbox() + spinbox.setMaximum(1) + spinbox.setValue(0.5) + spinbox.setSingleStep(0.1) + self.IoAwidget = widgets.formWidget( + spinbox, + addInfoButton=True, + labelTextLeft="IoA threshold: ", + infoTxt=IoAtext, + ) + entriesLayout.addFormWidget(self.IoAwidget, row=row) + + row += 1 + infoTxt = html_utils.paragraph(""" + The third segmentation file is the result of subtracting the + sub-cellular objects from the parent objects

    + This is useful if, for example, you need to compute measurements + only from the cytoplasm (i.e., the sub-cellular object is the nucleus). + """) + self.createThirdSegmWidget = widgets.formWidget( + widgets.Toggle(), + addInfoButton=True, + stretchWidget=False, + labelTextLeft="Create third segmentation: ", + infoTxt=infoTxt, + ) + entriesLayout.addFormWidget(self.createThirdSegmWidget, row=row) + + row += 1 + infoTxt = html_utils.paragraph(""" + Text to append at the end of the third segmentation file.

    + The third segmentation file is the result of subtracting the + sub-cellular objects from the parent objects

    + This is useful if, for example, you need to compute measurements + only from the cytoplasm (i.e., the sub-cellular object is the nucleus). + """) + lineEdit = widgets.alphaNumericLineEdit() + lineEdit.setText("difference") + lineEdit.setAlignment(Qt.AlignCenter) + self.appendTextWidget = widgets.formWidget( + lineEdit, + addInfoButton=True, + labelTextLeft="Text to append: ", + infoTxt=infoTxt, + ) + entriesLayout.addFormWidget(self.appendTextWidget, row=row) + self.appendTextWidget.setDisabled(True) + + self.createThirdSegmWidget.widget.toggled.connect(self.createThirdSegmToggled) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + mainLayout.addLayout(entriesLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + self.setFont(font) + + def createThirdSegmToggled(self, checked): + self.appendTextWidget.setDisabled(not checked) + + def ok_cb(self): + self.cancel = False + if self.createThirdSegmWidget.widget.isChecked(): + if not self.appendTextWidget.widget.text(): + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = html_utils.paragraph( + "When creating the third segmentation file, " + "the name to append cannot be empty!" + ) + msg.critical(self, "Empty name", txt) + return + + self.trackSubCellObjParams = { + "how": self.optionsWidget.widget.currentText(), + "IoA": self.IoAwidget.widget.value(), + "createThirdSegm": self.createThirdSegmWidget.widget.isChecked(), + "relabelSubObjLab": self.relabelSubObjLab.widget.isChecked(), + "thirdSegmAppendedText": self.appendTextWidget.widget.text(), + } + self.close() + + +class CellACDCTrackerParamsWin(QDialog): + def __init__(self, parent=None): + self.cancel = True + super().__init__(parent) + + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + self.setWindowTitle("Cell-ACDC tracker parameters") + + paramsLayout = QGridLayout() + paramsBox = QGroupBox() + + row = 0 + label = QLabel(html_utils.paragraph("Minimum overlap between objects")) + paramsLayout.addWidget(label, row, 0) + maxOverlapSpinbox = QDoubleSpinBox() + maxOverlapSpinbox.setAlignment(Qt.AlignCenter) + maxOverlapSpinbox.setMinimum(0) + maxOverlapSpinbox.setMaximum(1) + maxOverlapSpinbox.setSingleStep(0.1) + maxOverlapSpinbox.setValue(0.4) + self.maxOverlapSpinbox = maxOverlapSpinbox + paramsLayout.addWidget(maxOverlapSpinbox, row, 1) + infoButton = widgets.infoPushButton() + infoButton.clicked.connect(self.showInfo) + paramsLayout.addWidget(infoButton, row, 2) + paramsLayout.setColumnStretch(0, 0) + paramsLayout.setColumnStretch(1, 1) + paramsLayout.setColumnStretch(2, 0) + + cancelButton = widgets.cancelPushButton("Cancel") + okButton = widgets.okPushButton(" Ok ") + cancelButton.clicked.connect(self.cancel_cb) + okButton.clicked.connect(self.ok_cb) + + buttonsLayout = QHBoxLayout() + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(okButton) + + layout = QVBoxLayout() + infoText = html_utils.paragraph("Cell-ACDC tracker parameters") + infoLabel = QLabel(infoText) + layout.addWidget(infoLabel, alignment=Qt.AlignCenter) + layout.addSpacing(10) + paramsBox.setLayout(paramsLayout) + layout.addWidget(paramsBox) + layout.addSpacing(20) + layout.addLayout(buttonsLayout) + layout.addStretch(1) + self.setLayout(layout) + self.setFont(font) + + def showInfo(self): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + "Cell-ACDC tracker computes the percentage of overlap between " + "all the objects
    at frame n and all the " + "objects in previous frame n-1.

    " + "All objects with overlap less than " + "Minimum overlap between objects
    are considered " + "new objects.

    " + "Set this value to 0 if you want to force tracking of ALL the " + "objects
    in the previous frame (e.g., if cells move a lot " + "between frames)" + ) + msg.information(self, "Cell-ACDC tracker info", txt) + + def ok_cb(self, checked=False): + self.cancel = False + self.params = {"IoA_thresh": self.maxOverlapSpinbox.value()} + self.close() + + def cancel_cb(self, event): + self.cancel = True + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + super().show() + self.resize(int(self.width() * 1.3), self.height()) + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class BayesianTrackerParamsWin(QDialog): + def __init__(self, segmShape, parent=None, channels=None, currentChannelName=None): + self.cancel = True + super().__init__(parent) + + self.channels = channels + self.currentChannelName = currentChannelName + + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + self.setWindowTitle("Bayesian tracker parameters") + + paramsLayout = QGridLayout() + paramsBox = QGroupBox() + + row = 0 + this_path = os.path.dirname(os.path.abspath(__file__)) + default_model_path = os.path.join( + this_path, "trackers", "BayesianTracker", "model", "cell_config.json" + ) + label = QLabel(html_utils.paragraph("Model path")) + paramsLayout.addWidget(label, row, 0) + modelPathLineEdit = QLineEdit() + start_dir = "" + if os.path.exists(default_model_path): + start_dir = os.path.dirname(default_model_path) + modelPathLineEdit.setText(default_model_path) + self.modelPathLineEdit = modelPathLineEdit + paramsLayout.addWidget(modelPathLineEdit, row, 1) + browseButton = widgets.browseFileButton( + title="Select Bayesian Tracker model file", + ext={"JSON Config": (".json",)}, + start_dir=start_dir, + ) + browseButton.sigPathSelected.connect(self.onPathSelected) + paramsLayout.addWidget(browseButton, row, 2, alignment=Qt.AlignLeft) + + if self.channels is not None: + row += 1 + label = QLabel(html_utils.paragraph("Intensity image channel: ")) + paramsLayout.addWidget(label, row, 0) + items = ["None", *self.channels] + self.channelCombobox = widgets.QCenteredComboBox() + self.channelCombobox.addItems(items) + paramsLayout.addWidget(self.channelCombobox, row, 1) + if self.currentChannelName is not None: + self.channelCombobox.setCurrentText(self.currentChannelName) + + row += 1 + label = QLabel(html_utils.paragraph("Features")) + paramsLayout.addWidget(label, row, 0) + selectFeaturesButton = widgets.setPushButton("Select features") + paramsLayout.addWidget(selectFeaturesButton, row, 1) + self.features = [] + selectFeaturesButton.clicked.connect(self.selectFeatures) + + row += 1 + label = QLabel(html_utils.paragraph("Verbose")) + paramsLayout.addWidget(label, row, 0) + verboseToggle = widgets.Toggle() + verboseToggle.setChecked(True) + self.verboseToggle = verboseToggle + paramsLayout.addWidget(verboseToggle, row, 1, alignment=Qt.AlignCenter) + + row += 1 + label = QLabel(html_utils.paragraph("Run optimizer")) + paramsLayout.addWidget(label, row, 0) + optimizeToggle = widgets.Toggle() + optimizeToggle.setChecked(True) + self.optimizeToggle = optimizeToggle + paramsLayout.addWidget(optimizeToggle, row, 1, alignment=Qt.AlignCenter) + + row += 1 + label = QLabel(html_utils.paragraph("Max search radius")) + paramsLayout.addWidget(label, row, 0) + maxSearchRadiusSpinbox = QSpinBox() + maxSearchRadiusSpinbox.setAlignment(Qt.AlignCenter) + maxSearchRadiusSpinbox.setMinimum(1) + maxSearchRadiusSpinbox.setMaximum(2147483647) + maxSearchRadiusSpinbox.setValue(50) + self.maxSearchRadiusSpinbox = maxSearchRadiusSpinbox + self.maxSearchRadiusSpinbox.setDisabled(True) + paramsLayout.addWidget(maxSearchRadiusSpinbox, row, 1) + + row += 1 + Z, Y, X = segmShape + label = QLabel(html_utils.paragraph("Tracking volume")) + paramsLayout.addWidget(label, row, 0) + volumeLineEdit = QLineEdit() + defaultVol = f" (0, {X}), (0, {Y}) " + if Z > 1: + defaultVol = f"{defaultVol}, (0, {Z}) " + volumeLineEdit.setText(defaultVol) + volumeLineEdit.setAlignment(Qt.AlignCenter) + self.volumeLineEdit = volumeLineEdit + paramsLayout.addWidget(volumeLineEdit, row, 1) + + row += 1 + label = QLabel(html_utils.paragraph("Interactive mode step size")) + paramsLayout.addWidget(label, row, 0) + stepSizeSpinbox = QSpinBox() + stepSizeSpinbox.setAlignment(Qt.AlignCenter) + stepSizeSpinbox.setMinimum(1) + stepSizeSpinbox.setMaximum(2147483647) + stepSizeSpinbox.setValue(100) + self.stepSizeSpinbox = stepSizeSpinbox + paramsLayout.addWidget(stepSizeSpinbox, row, 1) + + row += 1 + label = QLabel(html_utils.paragraph("Update method")) + paramsLayout.addWidget(label, row, 0) + updateMethodCombobox = QComboBox() + updateMethodCombobox.addItems(["EXACT", "APPROXIMATE"]) + self.updateMethodCombobox = updateMethodCombobox + self.updateMethodCombobox.currentTextChanged.connect(self.methodChanged) + paramsLayout.addWidget(updateMethodCombobox, row, 1) + + cancelButton = widgets.cancelPushButton("Cancel") + okButton = widgets.okPushButton(" Ok ") + cancelButton.clicked.connect(self.cancel_cb) + okButton.clicked.connect(self.ok_cb) + + buttonsLayout = QHBoxLayout() + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(okButton) + + layout = QVBoxLayout() + infoText = html_utils.paragraph("Bayesian Tracker parameters") + infoLabel = QLabel(infoText) + layout.addWidget(infoLabel, alignment=Qt.AlignCenter) + layout.addSpacing(10) + paramsBox.setLayout(paramsLayout) + layout.addWidget(paramsBox) + + url = "https://btrack.readthedocs.io/en/latest/index.html" + moreInfoText = html_utils.paragraph( + "Find more info on the Bayesian Tracker's " + f'home page' + ) + moreInfoLabel = QLabel(moreInfoText) + moreInfoLabel.setOpenExternalLinks(True) + layout.addWidget(moreInfoLabel, alignment=Qt.AlignCenter) + + layout.addSpacing(20) + layout.addLayout(buttonsLayout) + layout.addStretch(1) + self.setLayout(layout) + self.setFont(font) + + def selectFeatures(self): + features = measurements.get_btrack_features() + selectWin = widgets.QDialogListbox( + "Select features", + "Select features to use for tracking:\n", + features, + multiSelection=True, + parent=self, + includeSelectionHelp=True, + ) + for i in range(selectWin.listBox.count()): + item = selectWin.listBox.item(i) + if item.text() in self.features: + item.setSelected(True) + selectWin.exec_() + if selectWin.cancel: + return + self.features = selectWin.selectedItemsText + + def methodChanged(self, method): + if method == "APPROXIMATE": + self.maxSearchRadiusSpinbox.setDisabled(False) + else: + self.maxSearchRadiusSpinbox.setDisabled(True) + + def onPathSelected(self, path): + self.modelPathLineEdit.setText(path) + + def ok_cb(self, checked=False): + self.cancel = False + try: + m = re.findall(r"\((\d+), *(\d+)\)", self.volumeLineEdit.text()) + if len(m) < 2: + raise + self.volume = tuple([(int(start), int(end)) for start, end in m]) + if len(self.volume) == 2: + self.volume = (self.volume[0], self.volume[1], (-1e5, 1e5)) + except Exception as e: + self.warnNotAcceptedVolume() + return + + if not os.path.exists(self.modelPathLineEdit.text()): + self.warnNotVaidPath() + return + + self.intensityImageChannel = None + self.verbose = self.verboseToggle.isChecked() + self.max_search_radius = self.maxSearchRadiusSpinbox.value() + self.update_method = self.updateMethodCombobox.currentText() + self.model_path = os.path.normpath(self.modelPathLineEdit.text()) + self.params = { + "model_path": self.model_path, + "verbose": self.verbose, + "volume": self.volume, + "max_search_radius": self.max_search_radius, + "update_method": self.update_method, + "step_size": self.stepSizeSpinbox.value(), + "optimize": self.optimizeToggle.isChecked(), + "features": self.features, + } + if self.channels is not None: + if self.channelCombobox.currentText() != "None": + self.intensityImageChannel = self.channelCombobox.currentText() + self.close() + + def warnNotVaidPath(self): + url = "https://github.com/lowe-lab-ucl/segment-classify-track/tree/main/models" + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + "The model configuration file path

    " + f"{self.modelPathLineEdit.text()}

    " + "does not exist.

    " + "You can find some pre-configured models " + f'here.' + ) + msg.critical(self, "Invalid volume", txt) + + def warnNotAcceptedVolume(self): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + f"{self.volumeLineEdit.text()} is not a valid volume!

    " + "Valid volume is for example (0, 2048), (0, 2048)
    " + "for 2D segmentation or (0, 2048), (0, 2048), (0, 2048)
    " + "for 3D segmentation." + ) + msg.critical(self, "Invalid volume", txt) + + def cancel_cb(self, event): + self.cancel = True + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + super().show() + self.resize(int(self.width() * 1.3), self.height()) + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class DeltaTrackerParamsWin(QDialog): + def __init__(self, posData=None, parent=None): + self.cancel = True + super().__init__(parent) + + self.setWindowFlags(Qt.Dialog | Qt.WindowStaysOnTopHint) + self.setWindowTitle("Delta tracker parameters") + + paramsLayout = QGridLayout() + paramsBox = QGroupBox() + + row = 0 + this_path = os.path.dirname(os.path.abspath(__file__)) + default_model_path = this_path + + label = QLabel(html_utils.paragraph("Original Images path")) + paramsLayout.addWidget(label, row, 0) + modelPathLineEdit = QLineEdit() + start_dir = "" + if os.path.exists(default_model_path): + start_dir = os.path.dirname(default_model_path) + modelPathLineEdit.setText(default_model_path) + self.modelPathLineEdit = modelPathLineEdit + paramsLayout.addWidget(modelPathLineEdit, row, 1) + browseButton = widgets.browseFileButton( + title="Select Original Images", ext={"TIFF": (".tif",)}, start_dir=start_dir + ) + if posData is not None: + modelPathLineEdit.setText(posData.imgPath) + browseButton.sigPathSelected.connect(self.onPathSelected) + paramsLayout.addWidget(browseButton, row, 2, alignment=Qt.AlignLeft) + + row += 1 + label = QLabel(html_utils.paragraph("Model Type")) + paramsLayout.addWidget(label, row, 0) + updateMethodCombobox = QComboBox() + updateMethodCombobox.addItems(["2D", "mothermachine"]) + self.model_type = "2D" + self.updateMethodCombobox = updateMethodCombobox + self.updateMethodCombobox.currentTextChanged.connect(self.methodChanged) + paramsLayout.addWidget(updateMethodCombobox, row, 1) + + row += 1 + label = QLabel(html_utils.paragraph("Single Mother Machine Chamber?")) + paramsLayout.addWidget(label, row, 0) + chamberToggle = widgets.Toggle() + chamberToggle.setChecked(True) + self.chamberToggle = chamberToggle + paramsLayout.addWidget(chamberToggle, row, 1, alignment=Qt.AlignCenter) + + row += 1 + label = QLabel(html_utils.paragraph("Verbose")) + paramsLayout.addWidget(label, row, 0) + verboseToggle = widgets.Toggle() + verboseToggle.setChecked(True) + self.verboseToggle = verboseToggle + paramsLayout.addWidget(verboseToggle, row, 1, alignment=Qt.AlignCenter) + + row += 1 + label = QLabel(html_utils.paragraph("Legacy Save (.mat)")) + paramsLayout.addWidget(label, row, 0) + legacyToggle = widgets.Toggle() + legacyToggle.setChecked(False) + self.legacyToggle = legacyToggle + paramsLayout.addWidget(legacyToggle, row, 1, alignment=Qt.AlignCenter) + + row += 1 + label = QLabel(html_utils.paragraph("Pickle (.pkl)")) + paramsLayout.addWidget(label, row, 0) + pickleToggle = widgets.Toggle() + pickleToggle.setChecked(False) + self.pickleToggle = pickleToggle + paramsLayout.addWidget(pickleToggle, row, 1, alignment=Qt.AlignCenter) + + row += 1 + label = QLabel(html_utils.paragraph("Movie (.mp4) *only for 2D images")) + paramsLayout.addWidget(label, row, 0) + movieToggle = widgets.Toggle() + movieToggle.setChecked(False) + self.movieToggle = movieToggle + paramsLayout.addWidget(movieToggle, row, 1, alignment=Qt.AlignCenter) + + cancelButton = widgets.cancelPushButton("Cancel") + okButton = widgets.okPushButton(" Ok ") + cancelButton.clicked.connect(self.cancel_cb) + okButton.clicked.connect(self.ok_cb) + + buttonsLayout = QHBoxLayout() + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(okButton) + + layout = QVBoxLayout() + infoText = html_utils.paragraph("Delta Tracker parameters") + infoLabel = QLabel(infoText) + layout.addWidget(infoLabel, alignment=Qt.AlignCenter) + layout.addSpacing(10) + paramsBox.setLayout(paramsLayout) + layout.addWidget(paramsBox) + + url = "https://delta.readthedocs.io/en/latest/" + moreInfoText = html_utils.paragraph( + f'Find more info on Delta Tracker\'s home page' + ) + moreInfoLabel = QLabel(moreInfoText) + moreInfoLabel.setOpenExternalLinks(True) + layout.addWidget(moreInfoLabel, alignment=Qt.AlignCenter) + + layout.addSpacing(20) + layout.addLayout(buttonsLayout) + layout.addStretch(1) + self.setLayout(layout) + self.setFont(font) + + def methodChanged(self, method): + if method == "mothermachine": + self.model_type = "mothermachine" + + def onPathSelected(self, path): + self.modelPathLineEdit.setText(path) + + def ok_cb(self, checked=False): + self.cancel = False + + if not os.path.exists(self.modelPathLineEdit.text()): + self.warnNotVaidPath() + return + + self.verbose = self.verboseToggle.isChecked() + self.legacy = self.legacyToggle.isChecked() + self.pickle = self.pickleToggle.isChecked() + self.movie = self.movieToggle.isChecked() + self.chamber = self.chamberToggle.isChecked() + self.model_path = os.path.normpath(self.modelPathLineEdit.text()) + self.params = { + "original_images_path": self.model_path, + "verbose": self.verbose, + "legacy": self.legacy, + "pickle": self.pickle, + "movie": self.movie, + "model_type": self.model_type, + "single mothermachine chamber": self.chamber, + } + self.close() + + def cancel_cb(self, event): + self.cancel = True + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + super().show() + self.resize(int(self.width() * 1.3), self.height()) + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class GenerateMotherBudTotalTableSelectColumnsDialog(QBaseDialog): + def __init__(self, df: pd.DataFrame, parent=None): + super().__init__(parent) + + self.setWindowTitle("Select columns to combine into the output table") + + self.cancel = True + + self.columns = core.natsort_acdc_columns(df.columns) + self.operations = ( + "Sum mother and bud", + "Copy column from mother", + ) + + self.mainLayout = QVBoxLayout() + + instructionsText = html_utils.paragraph(""" + Select which columns and how you want to combine them + into the output table.
    + """) + self.mainLayout.addWidget(QLabel(instructionsText)) + + settingsLayout = QGridLayout() + + row = 0 + settingsLayout.addWidget(widgets.QHLine(), row, 0, 1, 2) + + row += 1 + settingsLayout.addWidget( + QLabel("Copy all non-selected columns from mother cell"), row, 0 + ) + self.copyAllColsToggle = widgets.Toggle() + settingsLayout.addWidget(self.copyAllColsToggle, row, 1, alignment=Qt.AlignLeft) + + row += 1 + settingsLayout.addWidget(widgets.QHLine(), row, 0, 1, 2) + + self.mainLayout.addLayout(settingsLayout) + + scrollArea = widgets.ScrollArea() + scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) + scrollWidget = QWidget() + scrollArea.setWidget(scrollWidget) + self.centralLayout = QGridLayout() + scrollWidget.setLayout(self.centralLayout) + + self.centralLayout.addWidget(QLabel("Grouping columns"), 0, 0) + self.centralLayout.addWidget(QLabel("Column"), 0, 1) + self.centralLayout.addWidget(QLabel("Operation"), 0, 2) + self.centralLayout.setRowStretch(0, 0) + + self.groupingColsListWidget = widgets.listWidget( + isMultipleSelection=True, + ) + self.groupingColsListWidget.addItems(self.columns) + self.centralLayout.addWidget(self.groupingColsListWidget, 1, 0, 2, 1) + + selector = widgets.ComboBox(self) + selector.addItems(self.columns) + operationCombobox = widgets.ComboBox(self) + operationCombobox.addItems(self.operations) + self.addSelectorButton = widgets.addPushButton() + + dummyButton = widgets.delPushButton() + dummyButton.setRetainSizeWhenHidden(True) + dummyButton.hide() + self.centralLayout.addWidget(dummyButton, 1, 4) + + self.centralLayout.addWidget(selector, 1, 1) + self.centralLayout.addWidget(operationCombobox, 1, 2) + self.centralLayout.addWidget(self.addSelectorButton, 1, 3) + + self.centralLayout.setRowStretch(1, 1) + self.centralLayout.setRowStretch(2, 1) + + self.selectors = {1: (selector, operationCombobox)} + + buttonsLayout = widgets.CancelOkButtonsLayout() + + saveSelectionButton = widgets.savePushButton("Save current selection") + buttonsLayout.insertWidget(3, saveSelectionButton) + + loadDefaultColsButton = widgets.reloadPushButton( + "Load default summable columns" + ) + buttonsLayout.insertWidget(4, loadDefaultColsButton) + + loadPreviousSelButton = widgets.OpenFilePushButton("Load previous selection") + buttonsLayout.insertWidget(5, loadPreviousSelButton) + + saveSelectionButton.clicked.connect(self.saveSelection) + loadDefaultColsButton.clicked.connect(self.loadDefaultCols) + loadPreviousSelButton.clicked.connect(self.loadPreviousSelection) + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + self.mainLayout.addWidget(scrollArea) + self.mainLayout.addSpacing(20) + self.mainLayout.addLayout(buttonsLayout) + + self.addSelectorButton.clicked.connect(self.addSelector) + selector.currentTextChanged.connect(self.selectorTextChanged) + + self.setLayout(self.mainLayout) + self.setFont(font) + + def saveSelection(self): + saved_selections = io.get_saved_moth_bud_tot_selections() + existing_names = set(saved_selections.keys()) + win = filenameDialog( + basename="", + ext="", + hintText="Insert a name for the current selection:", + existingNames=existing_names, + allowEmpty=False, + defaultEntry="mother_bud_total_columns_selection", + ) + win.exec_() + if win.cancel: + return + + name = win.filename + saved_selections[name] = self.selectedOptions() + io.save_moth_bud_tot_selected_options(saved_selections) + + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + txt = html_utils.paragraph(f""" + Current selection saved with name {name}. + """) + msg.information(self, "Selection saved", txt) + + def loadDefaultCols(self): + from . import single_pos_index_cols + + grouping_cols = [col for col in single_pos_index_cols if col in self.columns] + self.groupingColsListWidget.setSelectedItems(grouping_cols) + + column_operation_mapper = { + col: "Sum mother and bud" for col in cca_functions.default_summable_columns + } + column_operation_mapper = { + col: op + for col, op in column_operation_mapper.items() + if col in self.columns and op in self.operations + } + self.addSelectors( + len(column_operation_mapper), + callback_on_finished=partial( + self.setSelectorValues, column_operation_mapper + ), + ) + + def loadPreviousSelection(self): + saved_selections = io.get_saved_moth_bud_tot_selections() + if not saved_selections: + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + txt = html_utils.paragraph(""" + There are no saved selections. + """) + msg.warning(self, "No saved selections", txt) + return + + existing_names = natsorted(saved_selections.keys(), key=str.casefold) + + selectNameWin = widgets.QDialogListbox( + "Choose selection to load", + "Choose selection to load:\n", + existing_names, + multiSelection=False, + parent=self, + ) + selectNameWin.exec_() + if selectNameWin.cancel: + return + + self.loadOptions(saved_selections[selectNameWin.selectedItemsText[0]]) + + def resetSelectors(self, callback_on_finished=None): + self.callback_on_finished = callback_on_finished + QTimer.singleShot(1, self._removeLastSelector) + + def _removeLastSelector(self): + if len(self.selectors) == 1: + if self.callback_on_finished is not None: + self.callback_on_finished() + return + + lastRow = max(self.selectors.keys()) + lastSelector, _ = self.selectors[lastRow] + self.removeSelector(sender=lastSelector.delButton) + QTimer.singleShot(1, self._removeLastSelector) + + def addSelectors(self, number, callback_on_finished=None): + self.callback_on_finished = callback_on_finished + QTimer.singleShot(1, partial(self._addSelectorRecursive, number)) + + def _addSelectorRecursive(self, number): + if len(self.selectors) == number: + if self.callback_on_finished is not None: + self.callback_on_finished() + return + + self.addSelector() + QTimer.singleShot(1, partial(self._addSelectorRecursive, number)) + + def loadOptions(self, options: dict): + if len(self.selectors) > 1: + self.resetSelectors(callback_on_finished=partial(self.loadOptions, options)) + return + + self.copyAllColsToggle.setChecked( + options.get("do_copy_all_nonselected_columns", False) + ) + self.groupingColsListWidget.setSelectedItems( + options.get("grouping_columns", []) + ) + column_operation_mapper = options.get("column_operation_mapper", {}) + column_operation_mapper = { + col: op + for col, op in column_operation_mapper.items() + if col in self.columns and op in self.operations + } + if len(column_operation_mapper) > 1: + self.addSelectors( + len(column_operation_mapper), + callback_on_finished=partial( + self.setSelectorValues, column_operation_mapper + ), + ) + return + + self.setSelectorValues(column_operation_mapper) + + def setSelectorValues(self, column_operation_mapper): + for i, (col, op) in enumerate(column_operation_mapper.items()): + selector, operationCombobox = self.selectors[i + 1] + selector.setCurrentText(col) + operationCombobox.setCurrentText(op) + + def resetSelectorsStyles(self): + for selector, _ in self.selectors.values(): + selector.setStyleSheet("") + + def selectorTextChanged(self, text): + self.resetSelectorsStyles() + selector = self.sender() + for other_selector, _ in self.selectors.values(): + if other_selector == selector: + continue + + if selector.currentText() != other_selector.currentText(): + continue + + self.setWarningStyleSelector(selector) + self.setWarningStyleSelector(other_selector) + + def addSelector(self): + row = len(self.selectors) + 1 + + selector = widgets.ComboBox(self) + selector.addItems(self.columns) + selector.setCurrentIndex(len(self.selectors)) + operationCombobox = widgets.ComboBox(self) + operationCombobox.addItems(self.operations) + delButton = widgets.delPushButton() + selector.delButton = delButton + delButton._row = row + + self.selectors[row] = (selector, operationCombobox) + + self.centralLayout.addWidget(selector, row, 1) + self.centralLayout.addWidget(operationCombobox, row, 2) + self.centralLayout.addWidget(delButton, row, 3) + + self.centralLayout.removeWidget(self.addSelectorButton) + self.centralLayout.addWidget(self.addSelectorButton, row, 4) + + delButton.clicked.connect(self.removeSelector) + + self.centralLayout.removeWidget(self.groupingColsListWidget) + rowSpan = self.centralLayout.rowCount() + self.centralLayout.addWidget(self.groupingColsListWidget, 1, 0, rowSpan, 1) + self.centralLayout.setRowStretch(rowSpan, 1) + + selector.currentTextChanged.connect(self.selectorTextChanged) + + def removeSelector(self, checked=False, sender=None): + if sender is None: + delButton = self.sender() + else: + delButton = sender + + selector, operationCombobox = self.selectors.pop(delButton._row) + + self.centralLayout.removeWidget(selector) + self.centralLayout.removeWidget(operationCombobox) + self.centralLayout.removeWidget(delButton) + + resorted_selectors = {} + for i, (row, (sel, op)) in enumerate(self.selectors.items()): + if i == 0: + resorted_selectors[i + 1] = (sel, op) + continue + + delButton = sel.delButton + delButton._row = i + 1 + self.centralLayout.removeWidget(sel) + self.centralLayout.removeWidget(op) + self.centralLayout.removeWidget(delButton) + self.centralLayout.addWidget(sel, i + 1, 1) + self.centralLayout.addWidget(op, i + 1, 2) + self.centralLayout.addWidget(delButton, i + 1, 3) + + resorted_selectors[i + 1] = (sel, op) + + last_row = i + 1 + col = 4 if last_row > 1 else 3 + self.centralLayout.removeWidget(self.addSelectorButton) + self.centralLayout.addWidget(self.addSelectorButton, i + 1, col) + + self.selectors = resorted_selectors + + def sizeHint(self): + width = super().sizeHint().width() + height = super().sizeHint().height() + groupingColsWidth = widgets.get_min_width_for_no_scrollbar( + self.groupingColsListWidget + ) + width += groupingColsWidth + return QSize(width, height) + + def checkDuplicatedSelectedColumns(self): + for selector, _ in self.selectors.values(): + selector.setStyleSheet("background-color: none") + for other_selector, _ in self.selectors.values(): + if other_selector == selector: + continue + + if other_selector.currentText() != selector.currentText(): + continue + + self.warnDuplicatedSelectedColumns(selector, other_selector) + return False + + return True + + def setWarningStyleSelector(self, selector): + popup = selector.view() + palette = popup.palette() + text_color = palette.color(palette.ColorRole.Text) + warningStyleSheet = f""" + QComboBox {{ + color: black; + background-color: orange; /* main area */ + }} + QComboBox QAbstractItemView {{ + background-color: {text_color.name()}; + }} + """ + selector.setStyleSheet(warningStyleSheet) + + def warnDuplicatedSelectedColumns(self, selector1, selector2): + self.setWarningStyleSelector(selector1) + self.setWarningStyleSelector(selector2) + + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + txt = html_utils.paragraph(f""" + The following column has been selected more than once + (highlighted in orange).

    + {selector1.currentText()}

    + Please, select each column only once.

    + Thank you for your patience! + """) + msg.warning(self, "Duplicated selection", txt) + + def checkGroupingColumnsNotSelected(self): + if self.groupingColsListWidget.selectedItems(): + return True + + return self.warnGroupingColumnsNotSelected() + + def warnGroupingColumnsNotSelected(self): + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + txt = html_utils.paragraph(f""" + Are you sure you do not want to select any grouping column?

    + Grouping columns are those needed to identify each unique + Position folder. + """) + _, noButton, yesButton = msg.question( + self, + "No grouping columns selected?", + txt, + buttonsTexts=( + "Cancel", + "No, let me select grouping columns", + "Yes, I do not need grouping columns", + ), + ) + return msg.clickedButton == yesButton + + def selectedOptions(self): + selected_options = { + "grouping_columns": self.groupingColsListWidget.selectedItemsText(), + "column_operation_mapper": { + selector.currentText(): operationCombobox.currentText() + for selector, operationCombobox in self.selectors.values() + }, + "do_copy_all_nonselected_columns": self.copyAllColsToggle.isChecked(), + } + return selected_options + + def ok_cb(self): + proceed = self.checkDuplicatedSelectedColumns() + if not proceed: + return + + proceed = self.checkGroupingColumnsNotSelected() + if not proceed: + return + + self.selected_options = self.selectedOptions() + + self.cancel = False + self.close() + + +class ApplyTrackTableSelectColumnsDialog(QBaseDialog): + def __init__(self, df, parent=None): + super().__init__(parent) + + self.setWindowTitle("Select columns containing tracking info") + + self.cancel = True + self.mainLayout = QVBoxLayout() + + options = ( + '"Frame index", "Tracked IDs" and "Segmentation mask IDs"
    ', + '"Frame index", "Tracked IDs", "X coord. centroid", and "Y coord. centroid"', + ) + self.instructionsText = html_utils.paragraph( + f""" + Select which columns contain the tracking information.

    + You must choose one of the following combinations:
    + {html_utils.to_list(options)} + Optionally, you can provide the column name containing the parent ID.
    + This will allow you to load lineage information into Cell-ACDC. + """ + ) + self.mainLayout.addWidget(QLabel(self.instructionsText)) + + formLayout = QFormLayout() + + self.frameIndexCombobox = widgets.QCenteredComboBox() + self.frameIndexCombobox.addItems(df.columns) + self.frameIndexCheckbox = QCheckBox("1st frame is index 1") + frameIndexLayout = QHBoxLayout() + frameIndexLayout.addWidget(self.frameIndexCombobox) + frameIndexLayout.addWidget(self.frameIndexCheckbox) + frameIndexLayout.setStretch(0, 2) + frameIndexLayout.setStretch(1, 0) + formLayout.addRow("Frame index: ", frameIndexLayout) + + self.trackedIDsCombobox = widgets.QCenteredComboBox() + self.trackedIDsCombobox.addItems(df.columns) + formLayout.addRow("Tracked IDs: ", self.trackedIDsCombobox) + + items = df.columns.to_list() + items.insert(0, "None") + self.maskIDsCombobox = widgets.QCenteredComboBox() + self.maskIDsCombobox.addItems(items) + formLayout.addRow("Segmentation mask IDs: ", self.maskIDsCombobox) + + self.xCentroidCombobox = widgets.QCenteredComboBox() + self.xCentroidCombobox.addItems(items) + formLayout.addRow("X coord. centroid: ", self.xCentroidCombobox) + + self.yCentroidCombobox = widgets.QCenteredComboBox() + self.yCentroidCombobox.addItems(items) + formLayout.addRow("Y coord. centroid: ", self.yCentroidCombobox) + + self.parentIDcombobox = widgets.QCenteredComboBox() + self.parentIDcombobox.addItems(items) + formLayout.addRow("Parent ID (optional): ", self.parentIDcombobox) + + deleteUntrackedLayout = QHBoxLayout() + self.deleteUntrackedIDsToggle = widgets.Toggle() + deleteUntrackedLayout.addStretch(1) + deleteUntrackedLayout.addWidget(self.deleteUntrackedIDsToggle) + deleteUntrackedLayout.addStretch(1) + formLayout.addRow("Delete untracked IDs: ", deleteUntrackedLayout) + + buttonsLayout = widgets.CancelOkButtonsLayout() + + buttonsLayout.okButton.clicked.connect(self.ok_cb) + buttonsLayout.cancelButton.clicked.connect(self.close) + + self.mainLayout.addSpacing(30) + self.mainLayout.addLayout(formLayout) + self.mainLayout.addSpacing(20) + self.mainLayout.addLayout(buttonsLayout) + + self.setLayout(self.mainLayout) + self.setFont(font) + + def ok_cb(self): + self.cancel = False + self.frameIndexCol = self.frameIndexCombobox.currentText() + self.trackedIDsCol = self.trackedIDsCombobox.currentText() + self.maskIDsCol = self.maskIDsCombobox.currentText() + self.xCentroidCol = self.xCentroidCombobox.currentText() + self.yCentroidCol = self.yCentroidCombobox.currentText() + self.deleteUntrackedIDs = self.deleteUntrackedIDsToggle.isChecked() + if self.maskIDsCol == "None": + if self.xCentroidCol == "None" or self.yCentroidCol == "None": + self.warnInvalidSelection() + return + else: + self.xCentroidCol = "None" + self.yCentroidCol = "None" + self.parentIDcol = self.parentIDcombobox.currentText() + self.isFirstFrameOne = self.frameIndexCheckbox.isChecked() + self.close() + + def warnInvalidSelection(self): + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + msg.warning( + self, + "Invalid selection", + html_utils.paragraph( + f"Invalid selection
    {self.instructionsText}" + ), + ) + + +class editCcaTableWidget(QDialog): + sigApplyChangesFutureFrames = Signal(object, int) + + def __init__( + self, + cca_df, + SizeT, + title="Edit cell cycle annotations", + parent=None, + current_frame_i=0, + ): + self.inputCca_df = cca_df + self.cancel = True + self.SizeT = SizeT + self.cca_df = None + self.current_frame_i = current_frame_i + + super().__init__(parent) + self.setWindowTitle(title) + + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + + # Layouts + mainLayout = QVBoxLayout() + headerLayout = QGridLayout() + tableLayout = QGridLayout() + buttonsLayout = QHBoxLayout() + self.scrollArea = QScrollArea() + self.viewBox = QWidget() + + # Header labels + col = 0 + row = 0 + IDsLabel = QLabel("Cell ID") + AC = Qt.AlignCenter + IDsLabel.setAlignment(AC) + headerLayout.addWidget(IDsLabel, 0, col, alignment=AC) + + col += 1 + ccsLabel = QLabel("Cell cycle stage") + ccsLabel.setAlignment(Qt.AlignCenter) + headerLayout.addWidget(ccsLabel, 0, col, alignment=AC) + + col += 1 + relIDLabel = QLabel("Relative ID") + relIDLabel.setAlignment(Qt.AlignCenter) + headerLayout.addWidget(relIDLabel, 0, col, alignment=AC) + + col += 1 + genNumLabel = QLabel("Generation number") + genNumLabel.setAlignment(Qt.AlignCenter) + headerLayout.addWidget(genNumLabel, 0, col, alignment=AC) + genNumColWidth = genNumLabel.sizeHint().width() + + col += 1 + relationshipLabel = QLabel("Relationship") + relationshipLabel.setAlignment(Qt.AlignCenter) + headerLayout.addWidget(relationshipLabel, 0, col, alignment=AC) + + col += 1 + emergFrameLabel = QLabel("Emerging frame num.") + emergFrameLabel.setAlignment(Qt.AlignCenter) + headerLayout.addWidget(emergFrameLabel, 0, col, alignment=AC) + + col += 1 + divitionFrameLabel = QLabel("Division frame num.") + divitionFrameLabel.setAlignment(Qt.AlignCenter) + headerLayout.addWidget(divitionFrameLabel, 0, col, alignment=AC) + + col += 1 + historyKnownLabel = QLabel("Is history known?") + historyKnownLabel.setAlignment(Qt.AlignCenter) + headerLayout.addWidget(historyKnownLabel, 0, col, alignment=AC) + + self.headerLayout = headerLayout + + tableLayout.setHorizontalSpacing(20) + self.tableLayout = tableLayout + + # Add buttons + cancelButton = widgets.cancelPushButton("Cancel") + moreInfoButton = widgets.helpPushButton("More info...") + moreInfoButton.setIcon(QIcon(":info.svg")) + applyToFutureFramesbutton = widgets.futurePushButton( + "Apply changes to future frames..." + ) + okButton = widgets.okPushButton("Ok") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(moreInfoButton) + buttonsLayout.addWidget(applyToFutureFramesbutton) + buttonsLayout.addWidget(okButton) + + # Scroll area properties + self.scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.scrollArea.setFrameStyle(QFrame.Shape.NoFrame) + self.scrollArea.setWidgetResizable(True) + + # Add layouts + self.viewBox.setLayout(tableLayout) + self.scrollArea.setWidget(self.viewBox) + mainLayout.addLayout(headerLayout) + mainLayout.addWidget(self.scrollArea) + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + # Populate table Layout + IDs = cca_df.index + self.IDs = IDs.to_list() + relIDsOptions = [str(ID) for ID in IDs] + relIDsOptions.insert(0, "-1") + self.IDlabels = [] + self.ccsComboBoxes = [] + self.genNumSpinBoxes = [] + self.relIDComboBoxes = [] + self.relationshipComboBoxes = [] + self.emergFrameSpinBoxes = [] + self.divisFrameSpinBoxes = [] + self.emergFrameSpinPrevValues = [] + self.divisFrameSpinPrevValues = [] + self.historyKnownCheckBoxes = [] + for row, ID in enumerate(IDs): + col = 0 + IDlabel = QLabel(f"{ID}") + IDlabel.setAlignment(Qt.AlignCenter) + tableLayout.addWidget(IDlabel, row + 1, col, alignment=AC) + self.IDlabels.append(IDlabel) + + col += 1 + ccsComboBox = QComboBox() + ccsComboBox.setFocusPolicy(Qt.StrongFocus) + ccsComboBox.installEventFilter(self) + ccsComboBox.addItems(["G1", "S/G2/M"]) + ccsValue = cca_df.at[ID, "cell_cycle_stage"] + if ccsValue == "S": + ccsValue = "S/G2/M" + + try: + ccsComboBox.setCurrentText(ccsValue) + except Exception as err: + printl(ccsValue) + printl(cca_df) + raise err + tableLayout.addWidget(ccsComboBox, row + 1, col, alignment=AC) + self.ccsComboBoxes.append(ccsComboBox) + ccsComboBox.activated.connect(self.clearComboboxFocus) + + col += 1 + relIDComboBox = QComboBox() + relIDComboBox.setFocusPolicy(Qt.StrongFocus) + relIDComboBox.installEventFilter(self) + relIDComboBox.addItems(relIDsOptions) + relIDComboBox.setCurrentText(str(cca_df.at[ID, "relative_ID"])) + tableLayout.addWidget(relIDComboBox, row + 1, col) + self.relIDComboBoxes.append(relIDComboBox) + relIDComboBox.currentIndexChanged.connect(self.setRelID) + relIDComboBox.activated.connect(self.clearComboboxFocus) + + col += 1 + genNumSpinBox = widgets.SpinBox() + genNumSpinBox.setFocusPolicy(Qt.StrongFocus) + genNumSpinBox.installEventFilter(self) + genNumSpinBox.setValue(2) + genNumSpinBox.setMaximum(2147483647) + genNumSpinBox.setAlignment(Qt.AlignCenter) + genNumSpinBox.setFixedWidth(int(genNumColWidth * 2 / 3)) + genNumSpinBox.setValue(int(cca_df.at[ID, "generation_num"])) + tableLayout.addWidget(genNumSpinBox, row + 1, col, alignment=AC) + self.genNumSpinBoxes.append(genNumSpinBox) + + col += 1 + relationshipComboBox = QComboBox() + relationshipComboBox.setFocusPolicy(Qt.StrongFocus) + relationshipComboBox.installEventFilter(self) + relationshipComboBox.addItems(["mother", "bud"]) + relationshipComboBox.setCurrentText(str(cca_df.at[ID, "relationship"])) + tableLayout.addWidget(relationshipComboBox, row + 1, col) + self.relationshipComboBoxes.append(relationshipComboBox) + relationshipComboBox.currentIndexChanged.connect( + self.relationshipChanged_cb + ) + relationshipComboBox.activated.connect(self.clearComboboxFocus) + + col += 1 + emergFrameSpinBox = widgets.SpinBox() + emergFrameSpinBox.setFocusPolicy(Qt.StrongFocus) + emergFrameSpinBox.installEventFilter(self) + emergFrameSpinBox.setMaximum(SizeT) + emergFrameSpinBox.setMinimum(-1) + emergFrameSpinBox.setValue(-1) + emergFrameSpinBox.setAlignment(Qt.AlignCenter) + emergFrameSpinBox.setFixedWidth(int(genNumColWidth * 2 / 3)) + emergFrame_i = cca_df.at[ID, "emerg_frame_i"] + val = emergFrame_i + 1 if emergFrame_i >= 0 else -1 + emergFrameSpinBox.setValue(val) + tableLayout.addWidget(emergFrameSpinBox, row + 1, col, alignment=AC) + self.emergFrameSpinBoxes.append(emergFrameSpinBox) + self.emergFrameSpinPrevValues.append(emergFrameSpinBox.value()) + emergFrameSpinBox.valueChanged.connect(self.skip0emergFrame) + + col += 1 + divisFrameSpinBox = widgets.SpinBox() + divisFrameSpinBox.setFocusPolicy(Qt.StrongFocus) + divisFrameSpinBox.installEventFilter(self) + divisFrameSpinBox.setMinimum(-1) + divisFrameSpinBox.setMaximum(SizeT) + divisFrameSpinBox.setValue(-1) + divisFrameSpinBox.setAlignment(Qt.AlignCenter) + divisFrameSpinBox.setFixedWidth(int(genNumColWidth * 2 / 3)) + divisFrame_i = int(cca_df.at[ID, "division_frame_i"]) + val = divisFrame_i + 1 if divisFrame_i >= 0 else -1 + divisFrameSpinBox.setValue(val) + tableLayout.addWidget(divisFrameSpinBox, row + 1, col, alignment=AC) + self.divisFrameSpinBoxes.append(divisFrameSpinBox) + self.divisFrameSpinPrevValues.append(divisFrameSpinBox.value()) + divisFrameSpinBox.valueChanged.connect(self.skip0divisFrame) + + col += 1 + HistoryCheckBox = QCheckBox() + HistoryCheckBox.setChecked(bool(cca_df.at[ID, "is_history_known"])) + tableLayout.addWidget(HistoryCheckBox, row + 1, col, alignment=AC) + self.historyKnownCheckBoxes.append(HistoryCheckBox) + + self.setLayout(mainLayout) + + # Connect to events + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.cancel_cb) + moreInfoButton.clicked.connect(self.moreInfo) + applyToFutureFramesbutton.clicked.connect(self.applyToFutureFrames) + + # self.setModal(True) + + def getChanges(self): + newCcaDf = self.getCca_df() + changes = {} + for row in newCcaDf.itertuples(): + ID = row.Index + for col in newCcaDf.columns: + inputValue = self.inputCca_df.at[ID, col] + newValue = getattr(row, col) + if newValue == inputValue: + continue + + if ID not in changes: + changes[ID] = {col: (inputValue, newValue)} + else: + changes[ID][col] = (inputValue, newValue) + return changes + + def applyToFutureFrames(self): + txt = "Enter up to which frame you want to apply the changes
    " + win = NumericEntryDialog( + title="Stop frame", + instructions=txt, + parent=self, + minValue=1, + maxValue=self.SizeT, + currentValue=self.current_frame_i, + ) + win.exec_() + if win.cancel: + return + + stop_frame_i = win.value + changes = self.getChanges() + changes_format = myutils.format_cca_manual_changes(changes) + detailsText = ( + f"Changes that will be applied from frame n. {self.current_frame_i + 1}" + f" to frame n. {stop_frame_i + 1}:\n\n{changes_format}" + ) + txt = html_utils.paragraph(""" +Use this feature with caution!

    +Before propagating to future frames carefully inspect what changes +will be applied (see below).

    +""") + msg = widgets.myMessageBox(wrapText=False) + msg.setDetailedText(detailsText, visible=True) + msg.warning(self, "Caution!", txt, buttonsTexts=("Yes, I am sure", "Cancel")) + if msg.cancel: + return + + self.sigApplyChangesFutureFrames.emit(changes, stop_frame_i) + + def moreInfo(self, checked=True): + desc = myutils.get_cca_colname_desc() + msg = widgets.myMessageBox(parent=self) + msg.setWindowTitle("Cell cycle annotations info") + msg.setWidth(400) + msg.setIcon() + for col, txt in desc.items(): + msg.addText(html_utils.paragraph(f"{col}: {txt}")) + msg.addButton(" Ok ") + msg.exec_() + + def setRelID(self, itemIndex): + idx = self.relIDComboBoxes.index(self.sender()) + relID = self.sender().currentText() + IDofRelID = self.IDs[idx] + relIDidx = self.IDs.index(int(relID)) + relIDComboBox = self.relIDComboBoxes[relIDidx] + relIDComboBox.setCurrentText(str(IDofRelID)) + + def skip0emergFrame(self, value): + idx = self.emergFrameSpinBoxes.index(self.sender()) + prevVal = self.emergFrameSpinPrevValues[idx] + if value == 0 and value > prevVal: + self.sender().setValue(1) + self.emergFrameSpinPrevValues[idx] = 1 + elif value == 0 and value < prevVal: + self.sender().setValue(-1) + self.emergFrameSpinPrevValues[idx] = -1 + + def skip0divisFrame(self, value): + idx = self.divisFrameSpinBoxes.index(self.sender()) + prevVal = self.divisFrameSpinPrevValues[idx] + if value == 0 and value > prevVal: + self.sender().setValue(1) + self.divisFrameSpinPrevValues[idx] = 1 + elif value == 0 and value < prevVal: + self.sender().setValue(-1) + self.divisFrameSpinPrevValues[idx] = -1 + + def relationshipChanged_cb(self, itemIndex): + idx = self.relationshipComboBoxes.index(self.sender()) + ccs = self.sender().currentText() + if ccs == "bud": + self.ccsComboBoxes[idx].setCurrentText("S/G2/M") + self.genNumSpinBoxes[idx].setValue(0) + + def getCca_df(self): + ccsValues = [var.currentText() for var in self.ccsComboBoxes] + ccsValues = [val if val == "G1" else "S" for val in ccsValues] + genNumValues = [var.value() for var in self.genNumSpinBoxes] + relIDValues = [int(var.currentText()) for var in self.relIDComboBoxes] + relatValues = [var.currentText() for var in self.relationshipComboBoxes] + emergFrameValues = [ + var.value() - 1 if var.value() > 0 else -1 + for var in self.emergFrameSpinBoxes + ] + divisFrameValues = [ + var.value() - 1 if var.value() > 0 else -1 + for var in self.divisFrameSpinBoxes + ] + historyValues = [var.isChecked() for var in self.historyKnownCheckBoxes] + check_rel = [ID == relID for ID, relID in zip(self.IDs, relIDValues)] + + # Buds in S phase must have 0 as number of cycles + check_buds_S = [ + ccs == "S" and rel_ship == "bud" and not numc == 0 + for ccs, rel_ship, numc in zip(ccsValues, relatValues, genNumValues) + ] + + # Mother cells must have at least 1 as number of cycles if history known + check_mothers = [ + rel_ship == "mother" and not numc >= 1 if is_history_known else False + for rel_ship, numc, is_history_known in zip( + relatValues, genNumValues, historyValues + ) + ] + + # Buds cannot be in G1 + check_buds_G1 = [ + ccs == "G1" and rel_ship == "bud" + for ccs, rel_ship in zip(ccsValues, relatValues) + ] + + # The number of cells in S phase must be half mothers and half buds + num_moth_S = len( + [ + 0 + for ccs, rel_ship in zip(ccsValues, relatValues) + if ccs == "S" and rel_ship == "mother" + ] + ) + num_bud_S = len( + [ + 0 + for ccs, rel_ship in zip(ccsValues, relatValues) + if ccs == "S" and rel_ship == "bud" + ] + ) + + # Cells in S phase cannot have -1 as relative's ID + check_relID_S = [ + ccs == "S" and relID == -1 for ccs, relID in zip(ccsValues, relIDValues) + ] + + # Mother cells with unknown history at emergence is recommended to have + # generation number = 2 (easier downstream analysis) + check_unknown_mothers = [ + rel_ship == "mother" + and not is_history_known + and gen_num != 2 + and (emerg_frame_i == self.current_frame_i or self.current_frame_i == 0) + for rel_ship, is_history_known, gen_num, emerg_frame_i in zip( + relatValues, historyValues, genNumValues, emergFrameValues + ) + ] + + if any(check_rel): + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph(""" + Some cells are mother or bud of itself!

    + Make sure that the relative ID is different from the Cell ID. + """) + msg.critical(self, "Some IDs are equal to relative ID", txt) + return None + elif any(check_unknown_mothers): + txt = html_utils.paragraph(""" + We recommend to set generation number to 2 for mother cells + with unknown history
    + that just appeared
    (i.e., first cell cycle in the video).

    + While it is allowed to insert any number, knowing that these + cells start at generation number 2
    + makes downstream analysis easier.

    + What do you want to do? + """) + correctButtonText = " Fine, let me correct. " + keepButtonText = " Keep the generation number that I chose. " + buttonsTexts = (correctButtonText, keepButtonText) + msg = widgets.myMessageBox(wrapText=False, showCentered=False) + msg.warning(self, "Recommendation", txt, buttonsTexts=buttonsTexts) + if msg.cancel or msg.clickedButton == correctButtonText: + return None + elif any(check_buds_S): + msg = widgets.myMessageBox(wrapText=False) + title = "Bud in S/G2/M not in 0 Generation number" + txt = html_utils.paragraph( + "Some buds " + "in S phase do not have 0 as Generation number!
    " + 'Buds in S phase must have 0 as "Generation number"' + ) + msg.critical(self, title, txt) + return None + elif any(check_mothers): + msg = widgets.myMessageBox(wrapText=False) + title = "Mother not in >=1 Generation number" + txt = html_utils.paragraph( + 'Some mother cells do not have >=1 as "Generation number"!
    ' + 'Mothers MUST have >1 "Generation number"' + ) + msg.critical(self, title, txt) + return None + elif any(check_buds_G1): + msg = widgets.myMessageBox(wrapText=False) + title = "Buds in G1!" + txt = html_utils.paragraph( + "Some buds are in G1 phase!

    Buds MUST be in S/G2/M phase" + ) + msg.critical(self, title, txt) + return None + elif num_moth_S != num_bud_S: + msg = widgets.myMessageBox(wrapText=False) + title = "Number of mothers-buds mismatch!" + txt = html_utils.paragraph( + f'There are {num_moth_S} mother cells in "S/G2/M" phase,' + f"but there are {num_bud_S} bud cells.

    " + 'The number of mothers and buds in "S/G2/M" ' + "phase must be equal!" + ) + msg.critical(self, title, txt) + return None + elif any(check_relID_S): + msg = widgets.myMessageBox(wrapText=False) + title = "Relative's ID of cells in S/G2/M = -1" + txt = html_utils.paragraph( + 'Some cells are in "S/G2/M" phase but have -1 as Relative\'s ID!
    ' + 'Cells in "S/G2/M" phase must have an existing ' + "ID as Relative's ID!" + ) + msg.critical(self, title, txt) + return None + + corrected_on_frame_i = self.inputCca_df["corrected_on_frame_i"] + cca_df = pd.DataFrame( + { + "cell_cycle_stage": ccsValues, + "generation_num": genNumValues, + "relative_ID": relIDValues, + "relationship": relatValues, + "emerg_frame_i": emergFrameValues, + "division_frame_i": divisFrameValues, + "is_history_known": historyValues, + "corrected_on_frame_i": corrected_on_frame_i, + "will_divide": self.inputCca_df["will_divide"], + }, + index=self.IDs, + ) + cca_df.index.name = "Cell_ID" + + # Add missing columns + for column, default in base_cca_dict.items(): + if column in cca_df.columns: + continue + + value = self.inputCca_df.get(column, default=default) + cca_df[column] = value + + # Check that every pair of cells in S are relative of each other + proceed = self.check_ID_rel_ID_mismatches(cca_df) + if not proceed: + return None + + d = dict.fromkeys(cca_df.select_dtypes(np.int64).columns, np.int32) + cca_df = cca_df.astype(d) + return cca_df + + def check_ID_rel_ID_mismatches(self, cca_df): + ID_rel_ID_mismatches = [] + for row in cca_df.itertuples(): + if row.cell_cycle_stage == "G1": + continue + + ID = row.Index + relID = row.relative_ID + relID_of_relID = cca_df.at[relID, "relative_ID"] + + if relID_of_relID != ID: + ID_rel_ID_mismatches.append((ID, relID, relID_of_relID)) + + if not ID_rel_ID_mismatches: + return True + + items = [ + f"Cell ID {ID} has relative ID = {relID}, " + f"while cell ID {relID} has relative ID = {relID_of_relID}" + for ID, relID, relID_of_relID in ID_rel_ID_mismatches + ] + title = "`ID-relative_ID` mismatches" + txt = html_utils.paragraph( + f"`ID-relative_ID` mismatches:{html_utils.to_list(items)}" + ) + msg = widgets.myMessageBox(wrapText=False) + msg.critical(self, title, txt) + return False + + def ok_cb(self, checked): + cca_df = self.getCca_df() + if cca_df is None: + return + self.cca_df = cca_df + self.cancel = False + self.close() + + def cancel_cb(self, checked): + self.cancel = True + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + ncols = self.tableLayout.columnCount() + maxLabelWidth = max( + [ + self.headerLayout.itemAt(j).widget().sizeHint().width() + for j in range(ncols) + ] + ) + minWidth = (maxLabelWidth + 5) * ncols + self.setMinimumWidth(minWidth) + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def eventFilter(self, object, event): + # Disable wheel scroll on widgets to allow scroll only on scrollarea + if event.type() == QEvent.Type.Wheel: + event.ignore() + return True + return False + + def clearComboboxFocus(self): + self.sender().clearFocus() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class FindIDDialog(QLineEditDialog): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.okButton.setIcon(QIcon(":magnGlass.svg")) + self.okButton.setText(" Find ") + + +class NumericEntryDialog(QBaseDialog): + def __init__( + self, + title="Entry a value", + currentValue=0, + instructions="Entry value", + parent=None, + maxValue=None, + minValue=None, + stretch=False, + ): + super().__init__(parent=parent) + self.setWindowTitle(title) + self.cancel = False + mainLayout = QVBoxLayout() + entryLayout = QHBoxLayout() + cancelOkLayout = widgets.CancelOkButtonsLayout() + cancelOkLayout.okButton.clicked.connect(self.ok_cb) + cancelOkLayout.cancelButton.clicked.connect(self.close) + + instructionsLabel = QLabel(html_utils.paragraph(instructions)) + mainLayout.addWidget(instructionsLabel) + + if type(currentValue) == int: + self.entryWidget = widgets.SpinBox() + self.entryWidget.setValue(currentValue) + self.valueGetter = "value" + if maxValue is not None: + self.entryWidget.setMaximum(maxValue) + if minValue is not None: + self.entryWidget.setMinimum(minValue) + + if stretch: + entryLayout.addWidget(self.entryWidget) + else: + entryLayout.addStretch(1) + entryLayout.addWidget(self.entryWidget) + entryLayout.addStretch(1) + + mainLayout.addLayout(entryLayout) + mainLayout.addSpacing(20) + mainLayout.addLayout(cancelOkLayout) + + self.setLayout(mainLayout) + + def ok_cb(self): + self.cancel = False + self.value = getattr(self.entryWidget, self.valueGetter)() + self.close() + + +class EditIDDialog(QDialog): + def __init__( + self, + clickedID, + IDs, + entryID=None, + doNotShowAgain=False, + parent=None, + nextUniqueID=1, + allIDs=None, + addPropagateCheckbox=False, + ): + self.assignNewID = False + self.IDs = IDs + self.clickedID = clickedID + self.cancel = True + self.how = None + self.mergeWithExistingID = True + self.doNotAskAgainExistingID = doNotShowAgain + self.allIDs = allIDs + if allIDs is None: + self.allIDs = set(self.IDs) + self.nextUniqueID = nextUniqueID + + super().__init__(parent) + self.setWindowTitle("Edit ID") + mainLayout = QVBoxLayout() + + VBoxLayout = QVBoxLayout() + msg = QLabel(f"Replace ID {clickedID} with:") + _font = QFont() + _font.setPixelSize(12) + msg.setFont(_font) + # padding: top, left, bottom, right + msg.setStyleSheet("padding:0px 0px 3px 0px;") + VBoxLayout.addWidget(msg, alignment=Qt.AlignCenter) + + entryWidget = QLineEdit() + entryWidget.setFont(_font) + entryWidget.setAlignment(Qt.AlignCenter) + self.entryWidget = entryWidget + VBoxLayout.addWidget(entryWidget) + if entryID is not None: + entryWidget.setText(str(entryID)) + entryWidget.selectAll() + + VBoxLayout.addWidget( + QLabel(f"Next unique ID = {nextUniqueID}"), alignment=Qt.AlignCenter + ) + + VBoxLayout.addWidget(widgets.QHLine()) + + self.warnExistingIDLabel = QLabel() + self.warnExistingIDLabel.setStyleSheet("color: red") + VBoxLayout.addWidget(self.warnExistingIDLabel, alignment=Qt.AlignCenter) + + note = QLabel( + "NOTE: To replace multiple IDs at once\n" + 'write "(old ID, new ID), (old ID, new ID)" etc.' + ) + note.setFont(_font) + note.setAlignment(Qt.AlignCenter) + # padding: top, left, bottom, right + note.setStyleSheet("padding:12px 0px 0px 0px;") + VBoxLayout.addWidget(note, alignment=Qt.AlignCenter) + mainLayout.addLayout(VBoxLayout) + + self.propagateCheckbox = None + if addPropagateCheckbox: + mainLayout.addSpacing(10) + self.propagateCheckbox = QCheckBox("Apply to future frames") + mainLayout.addWidget(self.propagateCheckbox) + + buttonsLayout = QHBoxLayout() + okButton = widgets.okPushButton("Ok") + cancelButton = widgets.cancelPushButton("Cancel") + applyNewIDButton = widgets.AssignNewIDButton("Assign new, unique ID") + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(cancelButton) + buttonsLayout.addSpacing(20) + buttonsLayout.addWidget(applyNewIDButton) + buttonsLayout.addWidget(okButton) + + mainLayout.addSpacing(20) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + # Connect events + self.prevText = "" + entryWidget.textChanged[str].connect(self.onTextChanged) + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.cancel_cb) + applyNewIDButton.clicked.connect(self.assignNewIDclicked) + + # self.setModal(True) + + def onTextChanged(self, text): + self.warnExistingIDLabel.setText("") + try: + ID = int(text) + if ID in self.allIDs: + self.warnExistingIDLabel.setText(f"WARNING: ID {ID} was already used") + except Exception as err: + pass + + # Get inserted char + idx = self.entryWidget.cursorPosition() + if idx == 0: + return + + newChar = text[idx - 1] + + # Do nothing if user is deleting text + if idx == 0 or len(text) < len(self.prevText): + self.prevText = text + return + + # Do not allow chars except for "(", ")", "int", "," + m = re.search(r"\(|\)|\d|,", newChar) + if m is None: + self.prevText = text + text = text.replace(newChar, "") + self.entryWidget.setText(text) + return + + # Cast integers greater than uint32 machine limit + m_iter = re.finditer(r"\d+", self.entryWidget.text()) + for m in m_iter: + val = int(m.group()) + uint32_max = np.iinfo(np.uint32).max + if val > uint32_max: + text = self.entryWidget.text() + text = f"{text[: m.start()]}{uint32_max}{text[m.end() :]}" + self.entryWidget.setText(text) + + # Automatically close ( bracket + if newChar == "(": + text += ")" + self.entryWidget.setText(text) + self.prevText = text + + def _warnExistingID(self, existingID, newID): + warn_msg = html_utils.paragraph(f""" + ID {existingID} is already existing.

    + How do you want to proceed?
    + """) + msg = widgets.myMessageBox() + doNotAskAgainCheckbox = QCheckBox("Remember my choice and do not ask again") + swapButton = widgets.reloadPushButton(f"Swap {newID} with {existingID}") + mergeButton = widgets.mergePushButton(f"Merge {newID} with {existingID}") + msg.warning( + self, + "Existing ID", + warn_msg, + buttonsTexts=("Cancel", mergeButton, swapButton), + widgets=doNotAskAgainCheckbox, + ) + if msg.cancel: + return False + self.doNotAskAgainExistingID = doNotAskAgainCheckbox.isChecked() + self.mergeWithExistingID = msg.clickedButton == mergeButton + return True + + def assignNewIDclicked(self): + self.cancel = False + self.how = None + self.assignNewID = True + self.close() + + def ok_cb(self, event): + txt = self.entryWidget.text() + valid = False + + # Check validity of inserted text + try: + ID = int(txt) + how = [(self.clickedID, ID)] + if ID in self.IDs and not self.doNotAskAgainExistingID: + proceed = self._warnExistingID(self.clickedID, ID) + if not proceed: + return + valid = True + else: + valid = True + except ValueError: + pattern = r"\((\d+),\s*(\d+)\)" + fa = re.findall(pattern, txt) + if fa: + how = [(int(g[0]), int(g[1])) for g in fa] + valid = True + else: + valid = False + + if not valid: + err_msg = html_utils.paragraph( + "You entered invalid text. Valid text is either a single integer" + f" ID that will be used to replace ID {self.clickedID} " + "or a list of elements enclosed in parenthesis separated by a comma
    " + "such as (5, 10), (8, 27) to replace ID 5 with ID 10 and ID 8 with ID 27" + ) + msg = widgets.myMessageBox() + msg.warning(self, "Invalid entry", err_msg) + return + + self.cancel = False + self.how = how + self.doPropagateFutureFrames = False + if self.propagateCheckbox is not None: + self.doPropagateFutureFrames = self.propagateCheckbox.isChecked() + self.close() + + def cancel_cb(self, event): + self.cancel = True + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class manualSeparateGui(QMainWindow): + def __init__( + self, + lab, + ID, + img, + fontSize="12pt", + IDcolor=[255, 255, 0], + parent=None, + loop=None, + drawMode="threepoints_arc", + ): + super().__init__(parent) + self.loop = loop + self.cancel = True + self.drawMode = drawMode + self._parent = parent + self.lab = lab.copy() + self.lab[lab != ID] = 0 + self.ID = ID + self.img = skimage.exposure.equalize_adapthist(img / img.max()) + self.IDcolor = IDcolor + self.countClicks = 0 + self.prevLabs = [] + self.prevAllCutsCoords = [] + self.labelItemsIDs = [] + self.undoIdx = 0 + self.fontSize = fontSize + self.AllCutsCoords = [] + self.setWindowTitle("Split object") + # self.setGeometry(Left, Top, 850, 800) + + self.gui_createActions() + self.gui_createMenuBar() + self.gui_createToolBars() + + self.gui_createStatusBar() + + self.gui_createGraphics() + self.gui_connectImgActions() + + self.gui_createImgWidgets() + self.gui_connectActions() + + self.updateImg() + self.zoomToObj() + + mainContainer = QWidget() + self.setCentralWidget(mainContainer) + + mainLayout = QGridLayout() + mainLayout.addWidget(self.graphLayout, 0, 0, 1, 1) + mainLayout.addLayout(self.img_Widglayout, 1, 0) + + mainContainer.setLayout(mainLayout) + + self.setWindowModality(Qt.WindowModal) + + def centerWindow(self): + parent = self._parent + if parent is not None: + # Center the window on main window + mainWinGeometry = parent.geometry() + mainWinLeft = mainWinGeometry.left() + mainWinTop = mainWinGeometry.top() + mainWinWidth = mainWinGeometry.width() + mainWinHeight = mainWinGeometry.height() + mainWinCenterX = int(mainWinLeft + mainWinWidth / 2) + mainWinCenterY = int(mainWinTop + mainWinHeight / 2) + winGeometry = self.geometry() + winWidth = winGeometry.width() + winHeight = winGeometry.height() + winLeft = int(mainWinCenterX - winWidth / 2) + winRight = int(mainWinCenterY - winHeight / 2) + self.move(winLeft, winRight) + + def gui_createActions(self): + # File actions + self.exitAction = QAction("&Exit", self) + self.helpAction = QAction("Help", self) + self.undoAction = QAction(QIcon(":undo.svg"), "Undo (Ctrl+Z)", self) + self.undoAction.setEnabled(False) + self.undoAction.setShortcut("Ctrl+Z") + + self.okAction = QAction(QIcon(":applyCrop.svg"), "Happy with that", self) + self.cancelAction = QAction(QIcon(":cancel.svg"), "Cancel", self) + + self.drawModesActionGroup = QActionGroup(self) + + self.threePointsArcAction = QAction( + QIcon(":threepoints_arc.svg"), "Separate with three-points arc", self + ) + self.threePointsArcAction.setCheckable(True) + self.threePointsArcAction.drawMode = "threepoints_arc" + self.drawModesActionGroup.addAction(self.threePointsArcAction) + + self.freeHandAction = QAction( + QIcon(":freehand.svg"), "Separate with freehand line", self + ) + self.freeHandAction.setCheckable(True) + self.freeHandAction.drawMode = "freehand" + self.drawModesActionGroup.addAction(self.freeHandAction) + + if self.drawMode == "threepoints_arc": + self.threePointsArcAction.setChecked(True) + elif self.drawMode == "freehand": + self.freeHandAction.setChecked(True) + + self.swapIDsAction = QAction(QIcon(":reload.svg"), "Swap IDs", self) + self.swapIDsAction.setToolTip('Swap the two displayed IDs\n\nShortcut: "S"') + self.swapIDsAction.setShortcut("S") + + def state(self): + return { + "is_overlay_active": self.overlayButton.isChecked(), + "is_three_points_active": self.threePointsArcAction.isChecked(), + "is_free_hand_active": self.freeHandAction.isChecked(), + } + + def show(self, block=False): + super().show() + if not block: + return + self.loop = QEventLoop(self) + self.loop.exec_() + + def setState(self, state): + if state is None: + return + self.overlayButton.setChecked(state.get("is_overlay_active", False)) + self.threePointsArcAction.setChecked(state.get("is_three_points_active", True)) + self.freeHandAction.setChecked(state.get("is_free_hand_active", False)) + + def gui_storeDrawMode(self): + self.drawMode = self.sender().drawMode + + def gui_createMenuBar(self): + menuBar = self.menuBar() + # style = "QMenuBar::item:selected { background: white; }" + # menuBar.setStyleSheet(style) + # File menu + fileMenu = QMenu("&File", self) + menuBar.addMenu(fileMenu) + + menuBar.addAction(self.helpAction) + fileMenu.addAction(self.exitAction) + + def gui_createToolBars(self): + toolbarSize = 30 + + editToolBar = QToolBar("Edit", self) + editToolBar.setIconSize(QSize(toolbarSize, toolbarSize)) + self.addToolBar(editToolBar) + + editToolBar.addAction(self.okAction) + editToolBar.addAction(self.cancelAction) + + editToolBar.addAction(self.undoAction) + + self.overlayButton = QToolButton(self) + self.overlayButton.setIcon(QIcon(":overlay.svg")) + self.overlayButton.setCheckable(True) + self.overlayButton.setToolTip("Overlay channel's image") + editToolBar.addWidget(self.overlayButton) + + editToolBar.addAction(self.threePointsArcAction) + editToolBar.addAction(self.freeHandAction) + + editToolBar.addAction(self.swapIDsAction) + + self.warnLabel = QLabel() + editToolBar.addWidget(self.warnLabel) + + def gui_connectActions(self): + self.exitAction.triggered.connect(self.close) + self.helpAction.triggered.connect(self.help) + self.okAction.triggered.connect(self.ok_cb) + self.cancelAction.triggered.connect(self.close) + self.undoAction.triggered.connect(self.undo) + self.overlayButton.toggled.connect(self.toggleOverlay) + self.imgGrad.sigLookupTableChanged.connect(self.histLUT_cb) + self.swapIDsAction.triggered.connect(self.swapIDs) + + def gui_createStatusBar(self): + self.statusbar = self.statusBar() + # Temporary message + self.statusbar.showMessage("Ready", 3000) + # Permanent widget + self.wcLabel = QLabel(f"") + self.statusbar.addPermanentWidget(self.wcLabel) + + def gui_createGraphics(self): + self.graphLayout = pg.GraphicsLayoutWidget() + + # Plot Item container for image + self.ax = pg.PlotItem() + self.ax.invertY(True) + self.ax.setAspectLocked(True) + self.ax.hideAxis("bottom") + self.ax.hideAxis("left") + self.graphLayout.addItem(self.ax, row=1, col=1) + + # Image Item + self.imgItem = pg.ImageItem(np.zeros((512, 512))) + self.ax.addItem(self.imgItem) + + # Image histogram + self.imgGrad = widgets.myHistogramLUTitem() + + # Curvature items + self.hoverLinSpace = np.linspace(0, 1, 1000) + self.hoverLinePen = pg.mkPen( + color=(200, 0, 0, 255 * 0.5), width=2, style=Qt.DashLine + ) + self.hoverCurvePen = pg.mkPen(color=(200, 0, 0, 255 * 0.5), width=3) + self.lineHoverPlotItem = pg.PlotDataItem(pen=self.hoverLinePen) + self.curvHoverPlotItem = pg.PlotDataItem(pen=self.hoverCurvePen) + self.curvAnchors = pg.ScatterPlotItem( + symbol="o", + size=9, + brush=pg.mkBrush((255, 0, 0, 50)), + pen=pg.mkPen((255, 0, 0), width=2), + hoverable=True, + hoverPen=pg.mkPen((255, 0, 0), width=3), + hoverBrush=pg.mkBrush((255, 0, 0)), + ) + self.ax.addItem(self.curvAnchors) + self.ax.addItem(self.curvHoverPlotItem) + self.ax.addItem(self.lineHoverPlotItem) + + self.freeHandItem = widgets.PlotCurveItem(pen=pg.mkPen(color="r", width=2)) + self.ax.addItem(self.freeHandItem) + + def gui_createImgWidgets(self): + self.img_Widglayout = QGridLayout() + self.img_Widglayout.setContentsMargins(50, 0, 50, 0) + + alphaScrollBar_label = QLabel("Overlay alpha ") + alphaScrollBar = QScrollBar(Qt.Horizontal) + alphaScrollBar.setFixedHeight(20) + alphaScrollBar.setMinimum(0) + alphaScrollBar.setMaximum(40) + alphaScrollBar.setValue(12) + alphaScrollBar.setToolTip( + "Control the alpha value of the overlay.\n" + "alpha=0 results in NO overlay,\n" + "alpha=1 results in only labels visible" + ) + alphaScrollBar.sliderMoved.connect(self.alphaScrollBarMoved) + self.alphaScrollBar = alphaScrollBar + self.alphaScrollBar_label = alphaScrollBar_label + self.img_Widglayout.addWidget( + alphaScrollBar_label, 0, 0, alignment=Qt.AlignCenter + ) + self.img_Widglayout.addWidget(alphaScrollBar, 0, 1, 1, 20) + self.alphaScrollBar.hide() + self.alphaScrollBar_label.hide() + + def gui_connectImgActions(self): + self.imgItem.hoverEvent = self.gui_hoverEventImg + self.imgItem.mousePressEvent = self.gui_mousePressEventImg + self.imgItem.mouseMoveEvent = self.gui_mouseDragEventImg + self.imgItem.mouseReleaseEvent = self.gui_mouseReleaseEventImg + + def gui_hoverEventImg(self, event): + # Update x, y, value label bottom right + try: + x, y = event.pos() + xdata, ydata = int(x), int(y) + _img = self.lab + Y, X = _img.shape + if xdata >= 0 and xdata < X and ydata >= 0 and ydata < Y: + val = _img[ydata, xdata] + self.wcLabel.setText(f"(x={x:.2f}, y={y:.2f}, ID={val:.0f})") + else: + self.wcLabel.setText(f"") + except Exception as e: + self.wcLabel.setText(f"") + + if event.isExit(): + return + + self.drawHoverEvent(*event.pos()) + + def gui_mousePressEventImg(self, event): + right_click = event.button() == Qt.MouseButton.RightButton + left_click = event.button() == Qt.MouseButton.LeftButton + + dragImg = left_click + + if dragImg: + pg.ImageItem.mousePressEvent(self.imgItem, event) + + if not right_click: + return + + self.drawPressEvent(event) + + def gui_mouseDragEventImg(self, event): + pass + + def gui_mouseReleaseEventImg(self, event): + if self.countClicks == 0: + return + if self.freeHandAction.isChecked(): + self.countClicks = 0 + xx, yy = self.freeHandItem.getData() + self.setSplitCurveCoords(xx, yy) + self.splitObjectAlongCurve() + self.freeHandItem.setData([], []) + self.curvAnchors.setData([], []) + + def getSpline(self, xx, yy): + tck, u = scipy.interpolate.splprep([xx, yy], s=0, k=2) + xi, yi = scipy.interpolate.splev(self.hoverLinSpace, tck) + return xi, yi + + def drawPressEvent(self, event): + if self.freeHandAction.isChecked(): + self.countClicks = 1 + x, y = event.pos().x(), event.pos().y() + self.curvAnchors.addPoints([x], [y]) + elif self.threePointsArcAction.isChecked(): + self.threePointsArcPressEvent(event) + + def drawHoverEvent(self, x, y): + if self.freeHandAction.isChecked(): + self.freeHandHoverEvent(x, y) + elif self.threePointsArcAction.isChecked(): + self.threePointsArcHoverEvent(x, y) + + def freeHandHoverEvent(self, x, y): + if self.countClicks == 0: + return + self.freeHandItem.addPoint(int(x), int(y)) + _xx, _yy = self.freeHandItem.getData() + xx = [_xx[0], x] + yy = [_yy[0], y] + self.curvAnchors.setData(xx, yy) + + def threePointsArcHoverEvent(self, x, y): + if self.countClicks == 1: + self.lineHoverPlotItem.setData([self.x0, x], [self.y0, y]) + elif self.countClicks == 2: + xx = [self.x0, x, self.x1] + yy = [self.y0, y, self.y1] + xi, yi = self.getSpline(xx, yy) + self.curvHoverPlotItem.setData(xi, yi) + elif self.countClicks == 0: + self.curvHoverPlotItem.setData([], []) + self.lineHoverPlotItem.setData([], []) + self.curvAnchors.setData([], []) + + def threePointsArcPressEvent(self, event): + if self.countClicks == 0: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + self.x0, self.y0 = xdata, ydata + self.curvAnchors.addPoints([xdata], [ydata]) + self.countClicks = 1 + elif self.countClicks == 1: + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + self.x1, self.y1 = xdata, ydata + self.curvAnchors.addPoints([xdata], [ydata]) + self.countClicks = 2 + elif self.countClicks == 2: + self.countClicks = 0 + x, y = event.pos().x(), event.pos().y() + xdata, ydata = int(x), int(y) + xx = [self.x0, xdata, self.x1] + yy = [self.y0, ydata, self.y1] + xi, yi = self.getSpline(xx, yy) + yy, xx = np.round(yi).astype(int), np.round(xi).astype(int) + self.setSplitCurveCoords(xx, yy) + self.splitObjectAlongCurve() + + def setSplitCurveCoords(self, xx, yy): + self.storeUndoState() + xxCurve, yyCurve = [], [] + for i, (r0, c0) in enumerate(zip(yy, xx)): + if i == len(yy) - 1: + break + r1 = yy[i + 1] + c1 = xx[i + 1] + rr, cc, _ = skimage.draw.line_aa(r0, c0, r1, c1) + # rr, cc = skimage.draw.line(r0, c0, r1, c1) + nonzeroMask = self.lab[rr, cc] > 0 + xxCurve.extend(cc[nonzeroMask]) + yyCurve.extend(rr[nonzeroMask]) + self.AllCutsCoords.append((yyCurve, xxCurve)) + for rr, cc in self.AllCutsCoords: + self.lab[rr, cc] = 0 + self.lab = skimage.morphology.remove_small_objects(self.lab, 5) + + def histLUT_cb(self, LUTitem): + if self.overlayButton.isChecked(): + overlay = self.getOverlay() + self.imgItem.setImage(overlay) + + def swapIDs(self, checked=False): + if len(self.rp) == 1: + self.warnLabel.setText( + html_utils.paragraph( + "WARNING: Split the object before swapping IDs", font_color="red" + ) + ) + return + + self.warnLabel.setText("") + + obj1 = self.rp[0] + obj2 = self.rp[1] + + self.lab[obj1.slice][obj1.image] = obj2.label + self.lab[obj2.slice][obj2.image] = obj1.label + + self.updateImg() + + def updateImg(self): + self.updateLookuptable() + rp = skimage.measure.regionprops(self.lab) + self.rp = rp + + if self.overlayButton.isChecked(): + overlay = self.getOverlay() + self.imgItem.setImage(overlay) + else: + self.imgItem.setImage(self.lab) + + # Draw ID on centroid of each label + for labelItemID in self.labelItemsIDs: + self.ax.removeItem(labelItemID) + self.labelItemsIDs = [] + for obj in rp: + labelItemID = widgets.myLabelItem() + labelItemID.setText(f"{obj.label}", color="r", size=f"{self.fontSize}px") + y, x = obj.centroid + w, h = labelItemID.rect().right(), labelItemID.rect().bottom() + labelItemID.setPos(x - w / 2, y - h / 2) + self.labelItemsIDs.append(labelItemID) + self.ax.addItem(labelItemID) + + def zoomToObj(self): + # Zoom to object + lab_mask = (self.lab > 0).astype(np.uint8) + rp = skimage.measure.regionprops(lab_mask) + obj = rp[0] + min_row, min_col, max_row, max_col = obj.bbox + xRange = min_col - 10, max_col + 10 + yRange = max_row + 10, min_row - 10 + self.ax.setRange(xRange=xRange, yRange=yRange) + + def storeUndoState(self): + self.prevLabs.append(self.lab.copy()) + self.prevAllCutsCoords.append(self.AllCutsCoords.copy()) + self.undoIdx += 1 + self.undoAction.setEnabled(True) + + def undo(self): + self.undoIdx -= 1 + self.lab = self.prevLabs[self.undoIdx] + self.AllCutsCoords = self.prevAllCutsCoords[self.undoIdx] + self.updateImg() + if self.undoIdx == 0: + self.undoAction.setEnabled(False) + self.prevLabs = [] + self.prevAllCutsCoords = [] + + def splitObjectAlongCurve(self): + self.lab = skimage.measure.label(self.lab, connectivity=1) + + # Relabel largest object with original ID + rp = skimage.measure.regionprops(self.lab) + areas = [obj.area for obj in rp] + IDs = [obj.label for obj in rp] + maxAreaIdx = areas.index(max(areas)) + maxAreaID = IDs[maxAreaIdx] + if self.ID not in self.lab: + self.lab[self.lab == maxAreaID] = self.ID + else: + tempID = self.lab.max() + 1 + self.lab[self.lab == maxAreaID] = tempID + self.lab[self.lab == self.ID] = maxAreaID + self.lab[self.lab == tempID] = self.ID + + # Keep only the two largest objects + larger_areas = nlargest(2, areas) + larger_ids = [rp[areas.index(area)].label for area in larger_areas] + for obj in rp: + if obj.label not in larger_ids: + self.lab[tuple(obj.coords.T)] = 0 + + rp = skimage.measure.regionprops(self.lab) + + if self._parent is not None: + self._parent.setBrushID() + # Use parent window setBrushID function for all other IDs + for obj in rp: + if self._parent is None: + break + if obj.label == self.ID: + continue + posData = self._parent.data[self._parent.pos_i] + posData.brushID += 1 + self.lab[obj.slice][obj.image] = posData.brushID + + # Replace 0s on the cutting curve with IDs + self.cutLab = self.lab.copy() + for rr, cc in self.AllCutsCoords: + for y, x in zip(rr, cc): + top_row = self.cutLab[y + 1, x - 1 : x + 2] + bot_row = self.cutLab[y - 1, x - 1 : x + 1] + left_col = self.cutLab[y - 1, x - 1] + right_col = self.cutLab[y : y + 2, x + 1] + allNeigh = list(top_row) + allNeigh.extend(bot_row) + allNeigh.append(left_col) + allNeigh.extend(right_col) + newID = max(allNeigh) + self.lab[y, x] = newID + + self.rp = skimage.measure.regionprops(self.lab) + self.updateImg() + + def updateLookuptable(self): + # Lookup table + self.cmap = colors.getFromMatplotlib("viridis") + self.lut = self.cmap.getLookupTable(0, 1, self.lab.max() + 1) + self.lut[0] = [25, 25, 25] + self.lut[self.ID] = self.IDcolor + if self.overlayButton.isChecked(): + self.imgItem.setLookupTable(None) + else: + self.imgItem.setLookupTable(self.lut) + + def keyPressEvent(self, ev): + if ev.key() == Qt.Key_Escape: + self.countClicks = 0 + self.curvHoverPlotItem.setData([], []) + self.lineHoverPlotItem.setData([], []) + self.curvAnchors.setData([], []) + self.freeHandItem.setData([], []) + elif ev.key() == Qt.Key_Enter or ev.key() == Qt.Key_Return: + self.ok_cb(True) + + def getOverlay(self): + # Rescale intensity based on hist ticks values + min = self.imgGrad.gradient.listTicks()[0][1] + max = self.imgGrad.gradient.listTicks()[1][1] + img = skimage.exposure.rescale_intensity(self.img, in_range=(min, max)) + alpha = self.alphaScrollBar.value() / self.alphaScrollBar.maximum() + + # Convert img and lab to RGBs + rgb_shape = (self.lab.shape[0], self.lab.shape[1], 3) + labRGB = np.zeros(rgb_shape) + labRGB[self.lab > 0] = [1, 1, 1] + imgRGB = skimage.color.gray2rgb(img) + overlay = imgRGB * (1.0 - alpha) + labRGB * alpha + + # Color eaach label + for obj in self.rp: + rgb = self.lut[obj.label] / 255 + overlay[obj.slice][obj.image] *= rgb + + # Convert (0,1) to (0,255) + overlay = (np.clip(overlay, 0, 1) * 255).astype(np.uint8) + return overlay + + def alphaScrollBarMoved(self, alpha_int): + overlay = self.getOverlay() + self.imgItem.setImage(overlay) + + def toggleOverlay(self, checked): + if checked: + self.graphLayout.addItem(self.imgGrad, row=1, col=0) + self.alphaScrollBar.show() + self.alphaScrollBar_label.show() + else: + self.graphLayout.removeItem(self.imgGrad) + self.alphaScrollBar.hide() + self.alphaScrollBar_label.hide() + self.updateImg() + + def help(self): + msg = QMessageBox() + msg.information( + self, + "Help", + "Separate object along a curved line.\n\n" + "To draw a curved line you will need 3 right-clicks:\n\n" + "1. Right-click outside of the object --> a line appears.\n" + "2. Right-click to end the line and a curve going through the " + "mouse cursor will appear.\n" + "3. Once you are happy with the cutting curve right-click again " + "and the object will be separated along the curve.\n\n" + "Note that you can separate as many times as you want.\n\n" + "Once happy click on the green tick on top-right or " + 'cancel the process with the "X" button', + ) + + def ok_cb(self, checked): + self.cancel = False + self.close() + + def closeEvent(self, event): + if self.loop is not None: + self.loop.exit() + + +class ViewCcaTableWindow(pdDataFrameWidget): + sigUpdateCcaTable = Signal(object) + + def __init__(self, df, parent=None): + super().__init__(df, parent=parent) + + updateTableButton = widgets.reloadPushButton("Update table with visible IDs...") + buttonsLayout = QHBoxLayout() + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(updateTableButton) + + self._layout.insertLayout(0, buttonsLayout) + + updateTableButton.clicked.connect(self.emitUpdateCcaTable) + + def emitUpdateCcaTable(self): + self.sigUpdateCcaTable.emit(self) + +# Sibling imports (deferred to avoid import cycles) +from .metadata import ( + filenameDialog, +) + diff --git a/cellacdc/myutils.py b/cellacdc/myutils.py deleted file mode 100644 index 871f81da3..000000000 --- a/cellacdc/myutils.py +++ /dev/null @@ -1,5946 +0,0 @@ -import os -import re -import ast - -import typing -from typing import Literal, List, Callable, Tuple, Dict - -import pathlib -import difflib -import sys -import platform -import tempfile -import shutil -import traceback -import logging -import datetime -import time -import subprocess -import importlib -from uuid import uuid4 -from importlib import import_module -from math import pow, ceil, floor -from functools import wraps, partial -from collections import namedtuple, Counter -from tqdm import tqdm -import requests -import zipfile -import json -import numpy as np -import pandas as pd -import skimage -import inspect - -import traceback -import itertools -from packaging import version as packaging_version - -from natsort import natsorted - -import tifffile -import skimage.io -import skimage.measure - -from . import GUI_INSTALLED, KNOWN_EXTENSIONS, is_conda_env - -from . import core, load -from . import html_utils, is_linux, is_win, is_mac, issues_url, is_mac_arm64 -from . import cellacdc_path, printl, acdc_fiji_path, logs_path, acdc_ffmpeg_path -from . import user_profile_path, recentPaths_path -from . import models_list_file_path, models_path -from . import promptable_models_list_file_path, promptable_models_path -from . import github_home_url -from . import try_input_install_package -from . import _warnings -from . import urls -from . import qrc_resources_path -from . import settings_folderpath -from .segmenters._cellpose_base import min_target_versions_cp - -if GUI_INSTALLED: - from qtpy.QtWidgets import QMessageBox - from qtpy.QtCore import Signal, QObject, QCoreApplication - - from . import widgets, apps - from . import config - -ArgSpec = namedtuple("ArgSpec", ["name", "default", "type", "desc", "docstring"]) - - -def get_module_name(script_file_path): - parts = pathlib.Path(script_file_path).parts - parts = list(parts[parts.index("cellacdc") + 1 :]) - parts[-1] = os.path.splitext(parts[-1])[0] - module = ".".join(parts) - return module - - -def get_pos_status_acdc(pos_path): - images_path = os.path.join(pos_path, "Images") - ls = listdir(images_path) - for file in ls: - if file.endswith("acdc_output.csv"): - acdc_df_path = os.path.join(images_path, file) - break - else: - return "" - - acdc_df = pd.read_csv(acdc_df_path) - last_tracked_i = acdc_df["frame_i"].max() - last_cca_i = 0 - if "cell_cycle_stage" in acdc_df.columns: - cca_df = acdc_df[["frame_i", "cell_cycle_stage"]].dropna() - last_cca_i = cca_df["frame_i"].max() - if last_cca_i > 0: - return ( - f" (last tracked frame = {last_tracked_i + 1}, " - f"last annotated frame = {last_cca_i + 1})" - ) - else: - return f" (last tracked frame = {last_tracked_i + 1})" - - -def get_pos_status_spotmax(pos_path): - spotmax_out_path = os.path.join(pos_path, "spotMAX_output") - is_smax_out_present = "Yes" if os.path.exists(spotmax_out_path) else "No" - if os.path.exists(spotmax_out_path): - return " (SpotMAX output exists)" - else: - return "" - - -def get_pos_status(pos_path, caller: Literal["Cell-ACDC", "SpotMAX"] = "Cell-ACDC"): - if caller == "Cell-ACDC": - return get_pos_status_acdc(pos_path) - - if caller == "SpotMAX": - return get_pos_status_spotmax(pos_path) - - -def get_gdrive_path(): - if is_win: - return os.path.join(f"G:{os.sep}", "My Drive") - elif is_mac: - return os.path.join( - "/Users/francesco.padovani/Library/CloudStorage/" - "GoogleDrive-padovaf@tcd.ie/My Drive" - ) - - -def get_acdc_data_path(): - Cell_ACDC_path = os.path.dirname(cellacdc_path) - return os.path.join(Cell_ACDC_path, "data") - - -def get_open_filemaneger_os_string(): - if is_win: - return "Show in Explorer..." - elif is_mac: - return "Reveal in Finder..." - elif is_linux: - return "Show in File Manager..." - - -def filterCommonStart(images_path): - startNameLen = 6 - ls = listdir(images_path) - if not ls: - return [] - allFilesStartNames = [f[:startNameLen] for f in ls] - mostCommonStart = Counter(allFilesStartNames).most_common(1)[0][0] - commonStartFilenames = [f for f in ls if f.startswith(mostCommonStart)] - return commonStartFilenames - - -def get_salute_string(): - time_now = datetime.datetime.now().time() - time_end_morning = datetime.time(12, 00, 00) - time_end_lunch = datetime.time(13, 00, 00) - time_end_afternoon = datetime.time(15, 00, 00) - time_end_evening = datetime.time(20, 00, 00) - time_end_night = datetime.time(4, 00, 00) - if time_now >= time_end_night and time_now < time_end_morning: - return "Have a good day!" - elif time_now >= time_end_morning and time_now < time_end_lunch: - return "Enjoy your lunch!" - elif time_now >= time_end_lunch and time_now < time_end_afternoon: - return "Have a good afternoon!" - elif time_now >= time_end_afternoon and time_now < time_end_evening: - return "Have a good evening!" - else: - return "Have a good night!" - - -def remove_known_extension(name): - for ext in KNOWN_EXTENSIONS: - if name.endswith(ext): - return name[: -len(ext)], ext - - return name, "" - - -def getCustomAnnotTooltip(annotState): - toolTip = ( - f"Name: {annotState['name']}\n\n" - f"Type: {annotState['type']}\n\n" - f"Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n" - f"Description: {annotState['description']}\n\n" - f'SHORTCUT: "{annotState["shortcut"]}"' - ) - return toolTip - - -def trim_path(path, depth=3, start_with_dots=True): - path_li = os.path.abspath(path).split(os.sep) - rel_path = f"{f'{os.sep}'.join(path_li[-depth:])}" - if start_with_dots: - return f"...{os.sep}{rel_path}" - else: - return rel_path - - -def get_add_custom_prompt_model_instructions(): - init_sh = html_utils.init_sh - segment_sh = html_utils.segment_sh - add_prompt_sh = html_utils.add_prompt_sh - href = f'here' - text = html_utils.paragraph(f""" - To use a custom prompt model, you need to create a Python file with the name - acdcPromptModel.py.
    - Note that the folder name where you place this file will be used as the - model name.

    - In this file, you will implement a class called Model with - at least the {init_sh} to initialise the model,
    - the {add_prompt_sh} method to add prompts (points, boxes, etc.) - to the model, and the {segment_sh} method to run the - segmentation.

    - Have a look at the existing models in the promptable_models - folder for examples.

    - If it doesn't work, please report the issue {href} with the - code you wrote. Thanks! - """) - return text - - -def get_add_custom_model_instructions(): - user_manual_url = "https://github.com/SchmollerLab/Cell_ACDC/blob/main/UserManual/Cell-ACDC_User_Manual.pdf" - href_user_manual = f'user manual' - href = f'here' - class_sh = html_utils.class_sh - def_sh = html_utils.def_sh - kwargs_sh = html_utils.kwargs_sh - Model_sh = html_utils.Model_sh - segment_sh = html_utils.segment_sh - predict_sh = html_utils.predict_sh - init_sh = html_utils.init_sh - myModel_sh = html_utils.myModel_sh - return_sh = html_utils.return_sh - equal_sh = html_utils.equal_sh - open_par_sh = html_utils.open_par_sh - close_par_sh = html_utils.close_par_sh - image_sh = html_utils.image_sh - from_sh = html_utils.from_sh - import_sh = html_utils.import_sh - s = html_utils.paragraph(f""" - To use a custom model first create a folder with the name of your model.

    - Inside this new folder create a file named acdcSegment.py.

    - In the acdcSegment.py file you will implement the model class.

    - Have a look at the other existing models, but essentially you have to create - a class called Model with at least
    - the {init_sh} and the {segment_sh} method.

    - The {segment_sh} method takes the image (2D or 3D) as an input and return the segmentation mask.

    - You can find more details in the {href_user_manual} at the section - called Adding segmentation models to the pipeline.

    - Pseudo-code for the acdcSegment.py file: -
    
    -    {from_sh} myModel {import_sh} {myModel_sh}
    -
    -    {class_sh} {Model_sh}:
    -        {def_sh} {init_sh}(self, {kwargs_sh}):
    -            self.model {equal_sh} {myModel_sh}{open_par_sh}{close_par_sh}
    -
    -        {def_sh} {segment_sh}(self, {image_sh}, {kwargs_sh}):
    -            labels {equal_sh} self.model.{predict_sh}{open_par_sh}{image_sh}{close_par_sh}
    -            {return_sh} labels
    -    
    - - If it doesn't work, please report the issue {href} with the - code you wrote. Thanks. - """) - return s - - -def is_iterable(item): - try: - iter(item) - return True - except TypeError as e: - return False - - -class utilClass: - pass - - -def get_trimmed_list(li: list, max_num_digits=10): - if len(li) == 0: - return "[]" - - tom_num_digits = sum([len(str(val)) for val in li]) - - if tom_num_digits == 0: - return f"[{', '.join(map(str, li))}]" - - avg_num_digits = tom_num_digits / len(li) - max_num_vals = int(round(max_num_digits / avg_num_digits)) - - if tom_num_digits > max_num_digits: - front_vals = ceil(max_num_vals / 2) - back_vals = max_num_vals // 2 - - if front_vals + back_vals >= len(li): - return f"[{', '.join(map(str, li))}]" - - li = li[:front_vals] + ["..."] + li[len(li) - back_vals :] - - return f"[{', '.join(map(str, li))}]" - - -def get_trimmed_dict(di: dict, max_num_digits=10): - di_str = di.copy() - total_num_digits = sum([len(str(key)) + len(str(val)) for key, val in di.items()]) - avg_num_digits = total_num_digits / len(di) - max_num_vals = int(round(max_num_digits / avg_num_digits)) - if total_num_digits > max_num_digits: - keys = list(di_str.keys()) - for key in keys[max_num_vals:-max_num_vals]: - del di_str[key] - di_str[keys[max_num_vals]] = "..." - return f"[{', '.join([f'{key} -> {val}' for key, val in di_str.items()])}]" - - -def checked_reset_index(df): - if df.index.names is None or df.index.names == [None]: - return df.reset_index(drop=True) - else: - return df.reset_index() - - -def checked_reset_index_Cell_ID(df): - if df.index.names == ["Cell_ID"]: - return df - df = checked_reset_index(df) - return df.set_index("Cell_ID") - - -def _bytes_to_MB(size_bytes): - factor = pow(2, -20) - size_MB = round(size_bytes * factor) - return size_MB - - -def _bytes_to_GB(size_bytes): - factor = pow(2, -30) - size_GB = round(size_bytes * factor, 2) - return size_GB - - -def getMemoryFootprint(files_list): - required_memory = sum( - [48 if file.endswith(".h5") else os.path.getsize(file) for file in files_list] - ) - return required_memory - - -def get_logs_path(): - return logs_path - - -class Logger(logging.Logger): - def __init__(self, module="base", name="cellacdc-logger", level=logging.DEBUG): - super().__init__(f"{name}-{module}", level=level) - self._stdout = sys.stdout - self._stderr = StdErr(logger=self) - sys.stderr = self._stderr - self._levelToName = { - 50: "CRITICAL", - 40: "ERROR", - 30: "WARNING", - 20: "INFO", - 10: "DEBUG", - 0: "NOTSET", - } - - def write(self, text, log_to_file=True, write_to_stdout=True): - """Capture print statements, print to terminal and log text to - the open log file - - Parameters - ---------- - text : str - Text to log - log_to_file : bool, optional - If True, call `info` method with `text`. Default is True - """ - if write_to_stdout: - self._stdout.write(text) - - if not log_to_file: - return - - if text == "\n": - return - - if not text: - return - - self.debug(text) - - def close(self): - for handler in self.handlers: - handler.close() - self.removeHandler(handler) - sys.stdout = self._stdout - self._stderr.close() - - def __del__(self): - sys.stdout = self._stdout - self._stderr.close() - - def info(self, text, *args, **kwargs): - super().info(text, *args, **kwargs) - try: - self.write(f"{text}\n", log_to_file=False) - except TypeError: - # Sometimes the logger is patched (e.g., by spotiflow), which - # triggers the TypeError because the patching function does not have - # log_to_file argument - self.write(f"{text}\n") - - def warning(self, text, *args, **kwargs): - super().warning(text, *args, **kwargs) - try: - self.write(f"[WARNING]: {text}\n", log_to_file=False) - except TypeError: - # Sometimes the logger is patched (e.g., by spotiflow), which - # triggers the TypeError because the patching function does not have - # log_to_file argument - self.write(f"[WARNING]: {text}\n") - - def error(self, text, *args, write_traceback=True, **kwargs): - super().error(text, *args, **kwargs) - self.write(traceback.format_exc()) - try: - self.write(f"[ERROR]: {text}\n", log_to_file=False) - except TypeError: - # Sometimes the logger is patched (e.g., by spotiflow), which - # triggers the TypeError because the patching function does not have - # log_to_file argument - self.write(f"[ERROR]: {text}\n") - - def plain(self, text, write_to_stdout=False): - orig_formatters = [handler.formatter for handler in self.handlers] - for handler in self.handlers: - handler.setFormatter(logging.Formatter("%(message)s")) - self.write(text, write_to_stdout=write_to_stdout) - for handler in self.handlers: - handler.setFormatter(orig_formatters.pop(0)) - - def critical(self, text, *args, **kwargs): - super().critical(text, *args, **kwargs) - try: - self.write(f"[CRITICAL]: {text}\n", log_to_file=False) - except TypeError: - # Sometimes the logger is patched (e.g., by spotiflow), which - # triggers the TypeError because the patching function does not have - # log_to_file argument - self.write(f"[CRITICAL]: {text}\n") - - def exception(self, text, *args, write_traceback=True, **kwargs): - super().exception(text, *args, **kwargs) - self.write(traceback.format_exc()) - try: - self.write(f"[ERROR]: {text}\n", log_to_file=False) - except TypeError: - # Sometimes the logger is patched (e.g., by spotiflow), which - # triggers the TypeError because the patching function does not have - # log_to_file argument - self.write(f"[ERROR]: {text}\n") - - def log(self, level, text): - if not isinstance(level, int): - printl(level, text, type(level), type(text), sep="\n") - super().log(level, text) - levelName = self._levelToName.get(level, "INFO") - getattr(self, levelName.lower())(text) - - def flush(self): - self._stdout.flush() - - -class StdErr: - def __init__(self, logger: Logger = None): - self._sys_stderr = sys.stderr - self._err_msg_line_buffer = [] - self._logger = logger - - def write(self, text: str): - if text.startswith("Traceback"): - print("-" * 100) - - self._sys_stderr.write(text) - - if not text: - return - - self._err_msg_line_buffer.append(text) - if not text.endswith("\n"): - return - - # If the line ends with a newline, flush the buffer - err_line = "".join(self._err_msg_line_buffer) - if self._logger is not None: - self._logger.plain(err_line, write_to_stdout=False) - else: - print(err_line) - - self._err_msg_line_buffer = [] - - def flush(self): - self._sys_stderr.flush() - - def close(self): - """Close the StdErr stream""" - sys.stderr = self._sys_stderr - - -def delete_older_log_files(logs_path): - if not os.path.exists(logs_path): - return - - log_files = os.listdir(logs_path) - for log_file in log_files: - if not log_file.endswith(".log"): - continue - - log_filepath = os.path.join(logs_path, log_file) - try: - mtime = os.path.getmtime(log_filepath) - except Exception as err: - continue - - mdatetime = datetime.datetime.fromtimestamp(mtime) - days = (datetime.datetime.now() - mdatetime).days - if days < 7: - continue - - try: - os.remove(log_filepath) - except Exception as err: - continue - - -def get_info_version_text(is_cli=False, cli_formatted_text=True): - version = read_version() - release_date = get_date_from_version(version, package="cellacdc") - py_ver = sys.version_info - env_folderpath = sys.prefix - python_version = f"{py_ver.major}.{py_ver.minor}.{py_ver.micro}" - info_txts = [ - f"Version {version}", - f"Released on: {release_date}", - f'Installed in "{cellacdc_path}"', - f'Environment folder: "{env_folderpath}"', - f'User profile folder: "{user_profile_path}"', - f'Settings folder: "{settings_folderpath}"', - f"Python {python_version}", - f"Platform: {platform.platform()}", - f"System: {platform.system()}", - ] - if is_linux: - try: - distro_name = get_linux_distribution_name() - except Exception as err: - distro_name = "Undetermined" - - info_txts.append(f"Linux distribution: {distro_name}") - - if GUI_INSTALLED and not is_cli: - info_txts.append(f'Icons from: "{qrc_resources_path}"') - try: - from qtpy import QtCore - - info_txts.append(f"Qt {QtCore.__version__}") - except Exception as err: - info_txts.append("Qt: Not installed") - - try: - branch_name = get_git_branch_name() - info_txts.append(f'Git branch: "{branch_name}"') - except Exception as err: - pass - - info_txts.append(f"Working directory: {os.getcwd()}") - - if not cli_formatted_text: - return info_txts - - info_txts = [f" - {txt}" for txt in info_txts] - - max_len = max([len(txt) for txt in info_txts]) + 2 - - formatted_info_txts = [] - for txt in info_txts: - horiz_spacing = " " * (max_len - len(txt)) - txt = f"{txt}{horiz_spacing}|" - formatted_info_txts.append(txt) - - formatted_info_txts.insert(0, "Cell-ACDC info:\n") - formatted_info_txts.insert(0, "=" * max_len) - formatted_info_txts.append("=" * max_len) - info_txt = "\n".join(formatted_info_txts) - - try: - from spotmax.utils import get_info_version_text as smax_info - - smax_info_txt = smax_info(include_platform=False, is_cli=is_cli) - info_txt += "\n\n" + smax_info_txt - except ImportError: - pass - - return info_txt - - -def _log_system_info(logger, log_path, is_cli=False, also_spotmax=False): - logger.info(f'Initialized log file "{log_path}"') - - info_txt = get_info_version_text(is_cli=is_cli) - - logger.info(info_txt) - - if not also_spotmax: - return - - from spotmax.utils import get_info_version_text as smax_info - - smax_info_txt = smax_info(include_platform=False) - logger.info(smax_info_txt) - - -def setupLogger(module="base", logs_path=None, caller="Cell-ACDC"): - if logs_path is None: - logs_path = get_logs_path() - - logger = Logger(module=module) - sys.stdout = logger - - delete_older_log_files(logs_path) - if not os.path.exists(logs_path): - os.mkdir(logs_path) - - date_time = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - id = uuid4() - log_filename = f"{date_time}_{module}_{id}_stdout.log" - log_path = os.path.join(logs_path, log_filename) - - output_file_handler = logging.FileHandler(log_path, mode="w") - - # Format your logs (optional) - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s:\n" - "------------------------\n" - "%(message)s\n" - "------------------------\n", - datefmt="%d-%m-%Y, %H:%M:%S", - ) - output_file_handler.setFormatter(formatter) - - logger.addHandler(output_file_handler) - - _log_system_info(logger, log_path, also_spotmax=caller != "Cell-ACDC") - - # if module == 'gui' and GUI_INSTALLED: - # qt_handler = widgets.QtHandler() - # qt_handler.setFormatter(logging.Formatter("%(message)s")) - # logger.addHandler(qt_handler) - - return logger, logs_path, log_path, log_filename - - -def get_pos_foldernames(exp_path, check_if_is_sub_folder=False): - if not check_if_is_sub_folder: - ls = listdir(exp_path) - pos_foldernames = [ - pos for pos in ls if is_pos_folderpath(os.path.join(exp_path, pos)) - ] - else: - folder_type = determine_folder_type(exp_path) - is_pos_folder, is_images_folder, _ = folder_type - if is_pos_folder: - return [os.path.basename(exp_path)] - elif is_images_folder: - pos_path = os.path.dirname(exp_path) - if is_pos_folderpath(pos_path): - return [os.path.basename(pos_path)] - else: - return [] - else: - return get_pos_foldernames(exp_path) - return pos_foldernames - - -def get_images_folderpath(folderpath): - if os.path.isfile(folderpath): - folderpath = os.path.dirname(folderpath) - - if folderpath.endswith("Images"): - return folderpath - - images_folderpath = os.path.join(folderpath, "Images") - if os.path.exists(images_folderpath): - return images_folderpath - - return "" - - -def getMostRecentPath(): - if os.path.exists(recentPaths_path): - df = pd.read_csv(recentPaths_path, index_col="index") - if "opened_last_on" in df.columns: - df = df.sort_values("opened_last_on", ascending=False) - MostRecentPath = "" - for path in df["path"]: - if os.path.exists(path): - MostRecentPath = path - break - else: - MostRecentPath = "" - return MostRecentPath - - -def addToRecentPaths(exp_path, logger=None): - if not os.path.exists(exp_path): - return - exp_path = exp_path.replace("\\", "/") - if os.path.exists(recentPaths_path): - try: - df = pd.read_csv(recentPaths_path, index_col="index") - recentPaths = df["path"].to_list() - if "opened_last_on" in df.columns: - openedOn = df["opened_last_on"].to_list() - else: - openedOn = [np.nan] * len(recentPaths) - if exp_path in recentPaths: - pop_idx = recentPaths.index(exp_path) - recentPaths.pop(pop_idx) - openedOn.pop(pop_idx) - recentPaths.insert(0, exp_path) - openedOn.insert(0, datetime.datetime.now()) - # Keep max 40 recent paths - if len(recentPaths) > 40: - recentPaths.pop(-1) - openedOn.pop(-1) - except Exception as e: - recentPaths = [exp_path] - openedOn = [datetime.datetime.now()] - else: - recentPaths = [exp_path] - openedOn = [datetime.datetime.now()] - df = pd.DataFrame( - { - "path": recentPaths, - "opened_last_on": pd.Series(openedOn, dtype="datetime64[ns]"), - } - ) - df.index.name = "index" - df.to_csv(recentPaths_path) - - -def checkDataIntegrity(filenames, parent_path, parentQWidget=None): - if not filenames: - msg = widgets.myMessageBox(wrapText=False) - txt = html_utils.paragraph( - "Cell-ACDC could not find any files in the folder " - f"{parent_path}.

    " - "Please make sure that the folder contains at least one image file.

    " - "Thank you for your patience!" - ) - msg.warning(parentQWidget, "Selected folder is emppty", txt) - raise FileNotFoundError(f"No files found in the folder {parent_path}. ") - - char = filenames[0][:2] - startWithSameChar = all([f.startswith(char) for f in filenames]) - if not startWithSameChar: - msg = widgets.myMessageBox() - txt = html_utils.paragraph( - "Cell-ACDC detected files inside the folder " - "that do not start with the same, common basename.

    " - "To ensure correct loading of the data, the folder where " - "the file(s) is/are should either contain a single image file or" - "only files that start with the same, common basename.

    " - "For example the following filenames:

    " - "F014_s01_phase_contr.tif
    " - "F014_s01_mCitrine.tif

    " - "are named correctly since they all start with the " - 'the common basename "F014_s01_". After the common basename you ' - 'can write whatever text you want. In the example above, "phase_contr" ' - 'and "mCitrine" are the channel names.

    ' - "Data loading may still be successfull, so Cell-ACDC will " - "still try to load data now.
    " - ) - filesFormat = [f" - {file}" for file in filenames] - filesFormat = "\n".join(filesFormat) - detailsText = f"Files present in the folder {parent_path}:\n\n{filesFormat}" - msg.addShowInFileManagerButton(parent_path, txt="Open folder...") - msg.warning( - parentQWidget, - "Data structure compromised", - txt, - detailsText=detailsText, - buttonsTexts=("Cancel", "Ok"), - ) - if msg.cancel: - raise TypeError("Process aborted by the user.") - return False - return True - - -def get_cca_colname_desc(): - desc = { - "Cell ID": ( - "ID of the segmented cell. All of the other columns " - "are properties of this ID." - ), - "Cell cycle stage": ("G1 if the cell does NOT have a bud. S/G2/M if it does."), - "Relative ID": ( - "ID of the bud related to the Cell ID (row). For cells in G1 write the " - "bud ID it had in the previous cycle." - ), - "Generation number": ( - "Number of times the cell divided from a bud. For cells in the first " - "frame write any number greater than 1." - ), - "Relationship": ( - "Relationship of the current Cell ID (row). " - "Either mother or bud. An object is a bud if " - "it didn't divide from the mother yet. All other instances " - "(e.g., cell in G1) are still labelled as mother." - ), - "Emerging frame num.": ( - "Frame number at which the object emerged/appeared in the scene." - ), - "Division frame num.": ( - "Frame number at which the bud separated from the mother." - ), - "Is history known?": ( - "Cells that are already present in the first frame or appears " - "from outside of the field of view, have some information missing. " - "For example, for cells in the first frame we do not know how many " - "times it budded and divided in the past. " - "In these cases Is history known? is True." - ), - } - return desc - - -def testQcoreApp(): - print(QCoreApplication.instance()) - - -def store_custom_model_path(model_file_path): - model_file_path = model_file_path.replace("\\", "/") - model_name = os.path.basename(os.path.dirname(model_file_path)) - cp = config.ConfigParser() - if os.path.exists(models_list_file_path): - cp.read(models_list_file_path) - if model_name not in cp: - cp[model_name] = {} - cp[model_name]["path"] = model_file_path - with open(models_list_file_path, "w") as configFile: - cp.write(configFile) - - -def store_custom_promptable_model_path(promptable_model_file_path): - model_file_path = promptable_model_file_path.replace("\\", "/") - model_name = os.path.basename(os.path.dirname(model_file_path)) - cp = config.ConfigParser() - if os.path.exists(promptable_models_list_file_path): - cp.read(promptable_models_list_file_path) - if model_name not in cp: - cp[model_name] = {} - cp[model_name]["path"] = model_file_path - with open(promptable_models_list_file_path, "w") as configFile: - cp.write(configFile) - - -def check_git_installed(parent=None): - try: - subprocess.check_call(["git", "--version"], shell=True) - return True - except Exception as e: - print("=" * 20) - traceback.print_exc() - print("=" * 20) - git_url = "https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - In order to install javabridge you first need to install - Git (it was not found).

    - Close Cell-ACDC and follow the instructions - {html_utils.tag("here", f'a href="{git_url}"')}.

    - NOTE: After installing Git you might need to restart the - terminal. - """) - msg.warning(parent, "Git not installed", txt) - return False - - -def browse_url(url): - import webbrowser - - webbrowser.open(url) - - -def browse_docs(): - browse_url(urls.docs_homepage) - - -def install_java(): - try: - subprocess.check_call(["javac", "-version"], shell=True) - return False - except Exception as e: - from . import widgets - - win = widgets.installJavaDialog() - win.exec_() - return win.clickedButton == win.cancelButton - - -def install_javabridge(force_compile=False, attempt_uninstall_first=False): - if attempt_uninstall_first: - try: - subprocess.check_call( - [sys.executable, "-m", "pip", "uninstall", "-y", "javabridge"] - ) - except Exception as e: - pass - if sys.platform.startswith("win"): - if force_compile: - subprocess.check_call( - [ - sys.executable, - "-m", - "pip", - "install", - "-U", - "git+https://github.com/SchmollerLab/python-javabridge-acdc", - ] - ) - else: - subprocess.check_call( - [ - sys.executable, - "-m", - "pip", - "install", - "-U", - "git+https://github.com/SchmollerLab/python-javabridge-windows", - ] - ) - elif is_mac: - subprocess.check_call( - [ - sys.executable, - "-m", - "pip", - "install", - "-U", - "git+https://github.com/SchmollerLab/python-javabridge-acdc", - ] - ) - elif is_linux: - subprocess.check_call( - [ - sys.executable, - "-m", - "pip", - "install", - "-U", - "git+https://github.com/LeeKamentsky/python-javabridge.git@master", - ] - ) - - -def is_in_bounds(x, y, X, Y): - in_bounds = x >= 0 and x < X and y >= 0 and y < Y - return in_bounds - - -def read_version(logger=None, return_success=False): - cellacdc_parent_path = os.path.dirname(cellacdc_path) - cellacdc_parent_folder = os.path.basename(cellacdc_parent_path) - if cellacdc_parent_folder == "site-packages": - from . import __version__ - - version = __version__ - success = True - else: - try: - from setuptools_scm import get_version - - version = get_version(root="..", relative_to=__file__) - success = True - except Exception as e: - if logger is None: - logger = print - logger("*" * 40) - logger(traceback.format_exc()) - logger("-" * 40) - logger( - "[WARNING]: Cell-ACDC could not determine the current version. " - "Returning the version determined at installation time. " - "See details above." - ) - logger("=" * 40) - try: - from . import _version - - version = _version.version - success = False - except Exception as e: - version = "ND" - success = False - - if return_success: - return version, success - else: - return version - - -def get_date_from_version(version: str, package="cellacdc", debug=False): - try: - response = requests.get(f"https://pypi.org/pypi/{package}/json", timeout=2) - res_json = response.json() - pypi_releases_json = res_json["releases"] - version_json = pypi_releases_json[version][0] - upload_time = version_json["upload_time_iso_8601"] - date = datetime.datetime.strptime(upload_time, r"%Y-%m-%dT%H:%M:%S.%fZ") - date_str = date.strftime(r"%A %d %B %Y at %H:%M") - return date_str - except Exception as err: - if debug: - traceback.print_exc() - - try: - # Locate the direct_url.json file for the package - # installed with pip git+ - dist = importlib.metadata.distribution(package) - dist_info_dir = dist._path # internal path to .dist-info - direct_url_path = os.path.join(dist_info_dir, "direct_url.json") - - with open(direct_url_path) as f: - data = json.load(f) - - vcs_info = data["vcs_info"] - commit_id = vcs_info.get("commit_id") - url = data.get("url") - - parts = url.split("github.com/")[1].split(".git")[0] - owner, repo = parts.split("/", 1) - - # Query GitHub API for commit date - api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{commit_id}" - response = requests.get(api_url) - response.raise_for_status() - - commit_data = response.json() - date_utc = commit_data["commit"]["committer"]["date"] - - date_str = format_commit_date_utc(date_utc) - - return date_str - except Exception as err: - if debug: - traceback.print_exc() - - try: - if package == "cellacdc": - pkg_path = cellacdc_path - elif package == "spotmax": - from spotmax import spotmax_path - - pkg_path = spotmax_path - commit_hash = re.findall(r"\+g([A-Za-z0-9]+)(\.d)?", version)[0][0] - git_path = os.path.dirname(pkg_path) - command = f"git -C {git_path} show {commit_hash}" - commit_log = _subprocess_run_command( - command, shell=False, callback="check_output" - ) - commit_log = commit_log.decode() - date_log = re.findall(r"Date:(.*) \+", commit_log)[0].strip() - date = datetime.datetime.strptime(date_log, r"%a %b %d %H:%M:%S %Y") - date_str = date.strftime(r"%A %d %B %Y at %H:%M") - return date_str - except Exception as err: - if debug: - traceback.print_exc() - - return "ND" - - -def get_git_branch_name(): - command = "git rev-parse --abbrev-ref HEAD" - output = _subprocess_run_command(command, shell=False, callback="check_output") - branch_name = output.decode().strip() - return branch_name - - -def showInExplorer(path): - if is_mac: - os.system(f'open "{path}"') - elif is_linux: - os.system(f'xdg-open "{path}"') - else: - os.startfile(path) - - -def exec_time(func): - @wraps(func) - def inner_function(self, *args, **kwargs): - t0 = time.perf_counter() - if func.__code__.co_argcount == 1 and func.__defaults__ is None: - result = func(self) - elif func.__code__.co_argcount > 1 and func.__defaults__ is None: - result = func(self, *args) - else: - result = func(self, *args, **kwargs) - t1 = time.perf_counter() - s = f"{func.__name__} execution time = {(t1 - t0) * 1000:.3f} ms" - printl(s, is_decorator=True) - return result - - return inner_function - - -def setRetainSizePolicy(widget, retain=True): - sp = widget.sizePolicy() - sp.setRetainSizeWhenHidden(retain) - widget.setSizePolicy(sp) - - -def getAcdcDfSegmPaths(images_path): - ls = listdir(images_path) - basename = getBasename(ls) - paths = {} - for file in ls: - filePath = os.path.join(images_path, file) - fileName, ext = os.path.splitext(file) - endName = fileName[len(basename) :] - if endName.find("acdc_output") != -1 and ext == ".csv": - info_name = endName.replace("acdc_output", "") - paths.setdefault(info_name, {}) - paths[info_name]["acdc_df_path"] = filePath - paths[info_name]["acdc_df_filename"] = fileName - elif endName.find("segm") != -1 and ext == ".npz": - info_name = endName.replace("segm", "") - paths.setdefault(info_name, {}) - paths[info_name]["segm_path"] = filePath - paths[info_name]["segm_filename"] = fileName - return paths - - -def getChannelFilePath(images_path, chName): - file = "" - alignedFilePath = "" - tifFilePath = "" - h5FilePath = "" - for file in listdir(images_path): - filePath = os.path.join(images_path, file) - if file.endswith(f"{chName}_aligned.npz"): - alignedFilePath = filePath - elif file.endswith(f"{chName}.tif"): - tifFilePath = filePath - elif file.endswith(f"{chName}.h5"): - h5FilePath = filePath - if alignedFilePath: - return alignedFilePath - elif h5FilePath: - return h5FilePath - elif tifFilePath: - return tifFilePath - else: - return "" - - -def get_number_fstring_formatter(dtype, precision=4): - if np.issubdtype(dtype, np.integer): - return "d" - else: - return f".{precision}f" - - -def get_chname_from_basename(filename, basename, remove_ext=True): - if remove_ext: - filename, ext = os.path.splitext(filename) - chName = filename[len(basename) :] - aligned_idx = chName.find("_aligned") - if aligned_idx != -1: - chName = chName[:aligned_idx] - return chName - - -def getBaseAcdcDf(rp): - zeros_list = [0] * len(rp) - nones_list = [None] * len(rp) - minus1_list = [-1] * len(rp) - IDs = [] - xx_centroid = [] - yy_centroid = [] - zz_centroid = [] - for obj in rp: - xc, yc = obj.centroid[-2:] - IDs.append(obj.label) - xx_centroid.append(xc) - yy_centroid.append(yc) - if len(obj.centroid) == 3: - zc = obj.centroid[0] - zz_centroid.append(zc) - - df = pd.DataFrame( - { - "Cell_ID": IDs, - "is_cell_dead": zeros_list, - "is_cell_excluded": zeros_list, - "x_centroid": xx_centroid, - "y_centroid": yy_centroid, - "was_manually_edited": minus1_list, - } - ).set_index("Cell_ID") - if zz_centroid: - df["z_centroid"] = zz_centroid - - return df - - -def getBasenameAndChNames(images_path, useExt=None): - _tempPosData = utilClass() - _tempPosData.images_path = images_path - load.loadData.getBasenameAndChNames(_tempPosData, useExt=useExt) - return _tempPosData.basename, _tempPosData.chNames - - -def getBasename(files): - basename = files[0] - for file in files: - # Determine the basename based on intersection of all files - _, ext = os.path.splitext(file) - sm = difflib.SequenceMatcher(None, file, basename) - i, j, k = sm.find_longest_match(0, len(file), 0, len(basename)) - basename = file[i : i + k] - return basename - - -def findalliter(patter, string): - """Function used to return all re.findall objects in string""" - m_test = re.findall(r"(\d+)_(.+)", string) - m_iter = [m_test] - while m_test: - m_test = re.findall(r"(\d+)_(.+)", m_test[0][1]) - m_iter.append(m_test) - return m_iter - - -def clipSelemMask(mask, shape, Yc, Xc, copy=True): - if copy: - mask = mask.copy() - - Y, X = shape - h, w = mask.shape - - # Bottom, Left, Top, Right global coordinates of mask - Y0, X0, Y1, X1 = Yc - (h / 2), Xc - (w / 2), Yc + (h / 2), Xc + (w / 2) - mask_limits = [floor(Y0) + 1, floor(X0) + 1, floor(Y1) + 1, floor(X1) + 1] - - if Y0 >= 0 and X0 >= 0 and Y1 <= Y and X1 <= X: - # Mask is withing shape boundaries, no need to clip - ystart, xstart, yend, xend = mask_limits - mask_slice = slice(ystart, yend), slice(xstart, xend) - return mask, mask_slice - - if Y0 < 0: - # Mask is exceeding at the bottom - ystart = floor(abs(Y0)) - mask_limits[0] = 0 - mask = mask[ystart:] - if X0 < 0: - # Mask is exceeding at the left - xstart = floor(abs(X0)) - mask_limits[1] = 0 - mask = mask[:, xstart:] - if Y1 > Y: - # Mask is exceeding at the top - yend = ceil(abs(Y1)) - Y - mask_limits[2] = Y - mask = mask[:-yend] - if X1 > X: - # Mask is exceeding at the right - xend = ceil(abs(X1)) - X - mask_limits[3] = X - mask = mask[:, :-xend] - - ystart, xstart, yend, xend = mask_limits - mask_slice = slice(ystart, yend), slice(xstart, xend) - return mask, mask_slice - - -def listdir(path) -> List[str]: - return natsorted( - [ - f - for f in os.listdir(path) - if not f.startswith(".") - and not f == "desktop.ini" - and not f == "recovery" - and not f.endswith(".new.npz") - ] - ) - - -def setDefaultValueArgSpecsFromKwargs(params: List[ArgSpec], kwargs: Dict[str, object]): - new_params = [] - for param in params: - new_value = kwargs.get(param.name) - if new_value is None: - new_params.append(param) - continue - - new_param = ArgSpec( - name=param.name, - default=new_value, - type=param.type, - desc=param.desc, - docstring=param.docstring, - ) - new_params.append(new_param) - return new_params - - -def insertModelArgSpec( - params, param_name, param_value, param_type=None, desc="", docstring="" -): - updated_params = [] - for param in params: - if param.name == param_name: - if param_type is None: - param_type = param.type - new_param = ArgSpec( - name=param_name, - default=param_value, - type=param_type, - desc=desc, - docstring=docstring, - ) - updated_params.append(new_param) - else: - updated_params.append(param) - return updated_params - - -def get_function_argspec( - function, - args_to_skip={ - "logger_func", - }, -): - argspecs = inspect.getfullargspec(function) - kwargs_type_hints = typing.get_type_hints(function) - docstring = function.__doc__ - params = params_to_ArgSpec( - argspecs, kwargs_type_hints, docstring, args_to_skip=args_to_skip - ) - return params - - -def getModelArgSpec(acdcSegment): - init_ArgSpec = inspect.getfullargspec(acdcSegment.Model.__init__) - init_kwargs_type_hints = typing.get_type_hints(acdcSegment.Model.__init__) - init_doc = acdcSegment.Model.__init__.__doc__ - init_params = params_to_ArgSpec(init_ArgSpec, init_kwargs_type_hints, init_doc) - init_params = add_segm_data_param(init_params, init_ArgSpec) - - segment_ArgSpec = inspect.getfullargspec(acdcSegment.Model.segment) - segment_kwargs_type_hints = typing.get_type_hints(acdcSegment.Model.segment) - try: - segment_ArgSpec.args.remove("frame_i") - except Exception as e: - pass - - segment_doc = acdcSegment.Model.segment.__doc__ - segment_params = params_to_ArgSpec( - segment_ArgSpec, - segment_kwargs_type_hints, - segment_doc, - ) - - return init_params, segment_params - - -def _get_doc_stop_idx(docstring, start_idx, next_param_name=None, debug=False): - if debug: - import pdb - - pdb.set_trace() - - if next_param_name is not None: - doc_stop_idx = docstring.find(f"{next_param_name} : ") - if doc_stop_idx > 1: - return doc_stop_idx - - docstring_from_start = docstring[start_idx:] - next_param_searched = re.search(r"\w+ : ", docstring_from_start) - if next_param_searched is not None: - return next_param_searched.start(0) + start_idx - - doc_stop_idx = docstring.find("Returns") - if doc_stop_idx > 1: - return doc_stop_idx - - doc_stop_idx = docstring.find("Notes") - if doc_stop_idx > 1: - return doc_stop_idx - - return -1 - - -def parse_model_param_doc(name, next_param_name=None, docstring=None): - if not docstring: - return "" - - try: - # Extract parameter description from 'param : ...' - start_text = f"{name} : " - if docstring.find(start_text) == -1: - # Parameter not present in docstring - return "" - - doc_start_idx = docstring.find(start_text) + len(start_text) - - doc_stop_idx = _get_doc_stop_idx( - docstring, doc_start_idx, next_param_name=next_param_name - ) - if doc_stop_idx == -1: - doc_stop_idx = len(docstring) - - param_doc = docstring[doc_start_idx:doc_stop_idx] - - # Start at first end of line - param_doc = param_doc[param_doc.find("\n") + 1 :] - - # Replace multiples spaces with single space - param_doc = re.sub(" +", " ", param_doc) - - # Remove trailing spaces - param_doc = param_doc.strip() - except Exception as err: - param_doc = "" - - param_doc = param_doc.replace(", optional", "") - - return param_doc - - -def add_segm_data_param(init_params, init_argspecs): - if init_argspecs.defaults is None: - num_kwargs = 0 - else: - num_kwargs = len(init_argspecs.defaults) - - # Segm model requires segm data --> add it to params - num_args = len(init_argspecs.args) - num_kwargs - if num_args == 1: - # Args is only self --> segm data not needed - return init_params - - desc = ( - "This model requires an additional segmentation file as input.\n\n" - "Please, select which segmentation file to provide to the model." - ) - - segm_data_argspec = ArgSpec( - name="Auxiliary segmentation file", - default="", - type=str, - desc=desc, - docstring=None, - ) - - init_params.insert(0, segm_data_argspec) - return init_params - - -def params_to_ArgSpec(fullargspecs, type_hints, docstring, args_to_skip=None): - params = [] - - if fullargspecs.defaults is None: - return params - - if args_to_skip is None: - args_to_skip = set() - - num_params = len(fullargspecs.args) - ip = num_params - len(fullargspecs.defaults) - if ip < 0: - return params - - for arg, default in zip(fullargspecs.args[ip:], fullargspecs.defaults): - if arg in args_to_skip: - continue - - if arg in type_hints: - _type = type_hints[arg] - else: - _type = type(default) - - next_param_name = None - if ip + 1 < num_params: - next_param_name = fullargspecs.args[ip + 1] - - param_doc = parse_model_param_doc( - arg, next_param_name=next_param_name, docstring=docstring - ) - param = ArgSpec( - name=arg, default=default, type=_type, desc=param_doc, docstring=docstring - ) - params.append(param) - ip += 1 - return params - - -def getClassArgSpecs(classModule, runMethodName="run"): - init_ArgSpec = inspect.getfullargspec(classModule.__init__) - init_kwargs_type_hints = typing.get_type_hints(classModule.__init__) - init_doc = classModule.__init__.__doc__ - init_params = params_to_ArgSpec(init_ArgSpec, init_kwargs_type_hints, init_doc) - - run_ArgSpec = inspect.getfullargspec(getattr(classModule, runMethodName)) - run_kwargs_type_hints = typing.get_type_hints(getattr(classModule, runMethodName)) - run_doc = getattr(classModule, runMethodName).__doc__ - run_params = params_to_ArgSpec( - run_ArgSpec, - run_kwargs_type_hints, - run_doc, - args_to_skip={"signals", "export_to"}, - ) - return init_params, run_params - - -def getTrackerArgSpec(trackerModule, realTime=False): - init_ArgSpec = inspect.getfullargspec(trackerModule.tracker.__init__) - init_kwargs_type_hints = typing.get_type_hints(trackerModule.tracker.__init__) - init_doc = trackerModule.tracker.__init__.__doc__ - init_params = params_to_ArgSpec(init_ArgSpec, init_kwargs_type_hints, init_doc) - if realTime: - track_ArgSpec = inspect.getfullargspec(trackerModule.tracker.track_frame) - track_kwargs_type_hints = typing.get_type_hints( - trackerModule.tracker.track_frame - ) - track_doc = trackerModule.tracker.track_frame.__doc__ - else: - track_ArgSpec = inspect.getfullargspec(trackerModule.tracker.track) - track_kwargs_type_hints = typing.get_type_hints(trackerModule.tracker.track) - track_doc = trackerModule.tracker.track.__doc__ - - track_params = params_to_ArgSpec( - track_ArgSpec, - track_kwargs_type_hints, - track_doc, - args_to_skip={"signals", "export_to"}, - ) - return init_params, track_params - - -def isIntensityImgRequiredForTracker(trackerModule): - track_ArgSpec = inspect.getfullargspec(trackerModule.tracker.track) - num_args = len(track_ArgSpec.args) - len(track_ArgSpec.defaults) - # If the number of args is 3 then we have `self, labels, image` as args - # which means the tracker requires the image - return num_args == 3 - - -def getDefault_SegmInfo_df(posData, filename): - mid_slice = int(posData.SizeZ / 2) - df = pd.DataFrame( - { - "filename": [filename] * posData.SizeT, - "frame_i": range(posData.SizeT), - "z_slice_used_dataPrep": [mid_slice] * posData.SizeT, - "which_z_proj": ["single z-slice"] * posData.SizeT, - "z_slice_used_gui": [mid_slice] * posData.SizeT, - "which_z_proj_gui": ["single z-slice"] * posData.SizeT, - "resegmented_in_gui": [False] * posData.SizeT, - "is_from_dataPrep": [False] * posData.SizeT, - } - ).set_index(["filename", "frame_i"]) - return df - - -def get_examples_path(which): - if which == "time_lapse_2D": - foldername = "TimeLapse_2D" - url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/KgJQtsQKZJnWZjL/download/TimeLapse_2D.zip" - file_size = 45143552 - elif which == "snapshots_3D": - foldername = "Multi_3D_zStack_Analysed" - url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/3RNjGiPwKcdnGtj/download/Yeast_Analysed_multi3D_zStacks.zip" - file_size = 124822528 - else: - return "" - - examples_path = os.path.join(user_profile_path, "acdc-examples") - example_path = os.path.join(examples_path, foldername) - return examples_path, example_path, url, file_size - - -def download_examples(which="time_lapse_2D", progress=None): - examples_path, example_path, url, file_size = get_examples_path(which) - if os.path.exists(example_path): - if progress is not None: - # display 100% progressbar - progress.emit(0, 0) - return example_path - - zip_dst = os.path.join(examples_path, "example_temp.zip") - - if not os.path.exists(examples_path): - os.makedirs(examples_path, exist_ok=True) - - print(f"Downloading example to {example_path}") - - download_url(url, zip_dst, verbose=False, file_size=file_size, progress=progress) - exctract_to = examples_path - extract_zip(zip_dst, exctract_to) - - if progress is not None: - # display 100% progressbar - progress.emit(0, 0) - - # Remove downloaded zip archive - os.remove(zip_dst) - print("Example downloaded successfully") - return example_path - - -def get_acdc_java_path(): - acdc_java_path = os.path.join(user_profile_path, "acdc-java") - dot_acdc_java_path = os.path.join(user_profile_path, ".acdc-java") - return acdc_java_path, dot_acdc_java_path - - -def get_java_url(): - is_linux = sys.platform.startswith("linux") - is_mac = sys.platform == "darwin" - is_win = sys.platform.startswith("win") - is_win64 = is_win and (os.environ["PROCESSOR_ARCHITECTURE"] == "AMD64") - - # https://drive.google.com/drive/u/0/folders/1MxhySsxB1aBrqb31QmLfVpq8z1vDyLbo - if is_win64: - os_foldername = "win64" - unzipped_foldername = "java_portable_windows-0.1" - file_size = 214798150 - # url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/eMyirTw8qG2wJMt/download/java_portable_windows-0.1.zip' - url = "https://github.com/SchmollerLab/java_portable_windows/archive/refs/tags/v0.1.zip" - elif is_mac: - os_foldername = "macOS" - unzipped_foldername = "java_portable_macos-0.1" - url = "https://github.com/SchmollerLab/java_portable_macos/archive/refs/tags/v0.1.zip" - # url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/SjZb8aommXgrECq/download/java_portable_macos-0.1.zip' - file_size = 108478751 - elif is_linux: - os_foldername = "linux" - unzipped_foldername = "java_portable_linux-0.1" - url = "https://github.com/SchmollerLab/java_portable_linux/archive/refs/tags/v0.1.zip" - # url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/HjeQagixE2cjbZL/download/java_portable_linux-0.1.zip' - file_size = 92520706 - return url, file_size, os_foldername, unzipped_foldername - - -def _jdk_exists(jre_path): - # If jre_path exists and it's windows search for ~/acdc-java/win64/jdk - # or ~/.acdc-java/win64/jdk. If not Windows return jre_path - if not jre_path: - return "" - os_acdc_java_path = os.path.dirname(jre_path) - os_foldername = os.path.basename(os_acdc_java_path) - if not os_foldername.startswith("win"): - return jre_path - if os.path.exists(os_acdc_java_path): - for folder in os.listdir(os_acdc_java_path): - if not folder.startswith("jdk"): - continue - dir_path = os.path.join(os_acdc_java_path, folder) - for file in os.listdir(dir_path): - if file == "bin": - return dir_path - return "" - - -def get_package_version(import_pkg_name): - import importlib.metadata - - version = importlib.metadata.version(import_pkg_name) - return version - - -def check_upgrade_javabridge(): - try: - version = get_package_version("javabridge") - except Exception as e: - return - patch = int(version.split(".")[2]) - if patch > 18: - return - install_javabridge() - - -def _java_exists(os_foldername): - acdc_java_path, dot_acdc_java_path = get_acdc_java_path() - os_acdc_java_path = os.path.join(acdc_java_path, os_foldername) - if os.path.exists(os_acdc_java_path): - for folder in os.listdir(os_acdc_java_path): - if not folder.startswith("jre"): - continue - dir_path = os.path.join(os_acdc_java_path, folder) - for file in os.listdir(dir_path): - if file == "bin": - return dir_path - - # Some users still has the old .acdc folder --> check - os_dot_acdc_java_path = os.path.join(dot_acdc_java_path, os_foldername) - if os.path.exists(os_dot_acdc_java_path): - for folder in os.listdir(os_dot_acdc_java_path): - if not folder.startswith("jre"): - continue - dir_path = os.path.join(os_dot_acdc_java_path, folder) - for file in os.listdir(dir_path): - if file == "bin": - return dir_path - return "" - - # Check if the user unzipped the javabridge_portable folder and not its content - os_acdc_java_path = os.path.join(acdc_java_path, os_foldername) - if os.path.exists(os_acdc_java_path): - for folder in os.listdir(os_acdc_java_path): - dir_path = os.path.join(os_acdc_java_path, folder) - if folder.startswith("java_portable") and os.path.isdir(dir_path): - # Move files one level up - unzipped_path = os.path.join(os_acdc_java_path, folder) - for name in os.listdir(unzipped_path): - # move files up one level - src = os.path.join(unzipped_path, name) - shutil.move(src, os_acdc_java_path) - try: - shutil.rmtree(unzipped_path) - except PermissionError as e: - pass - # Check if what we moved one level up was actually java - for folder in os.listdir(os_acdc_java_path): - if not folder.startswith("jre"): - continue - dir_path = os.path.join(os_acdc_java_path, folder) - for file in os.listdir(dir_path): - if file == "bin": - return dir_path - return "" - - -def download_java(): - url, file_size, os_foldername, unzipped_foldername = get_java_url() - jre_path = _java_exists(os_foldername) - jdk_path = _jdk_exists(jre_path) - if os_foldername.startswith("win") and jre_path and jdk_path: - return jre_path, jdk_path, url - - if jre_path: - # on macOS jdk is the same as jre - return jre_path, jre_path, url - - acdc_java_path, _ = get_acdc_java_path() - os_acdc_java_path = os.path.join(acdc_java_path, os_foldername) - temp_zip = os.path.join(os_acdc_java_path, "acdc_java_temp.zip") - - if not os.path.exists(os_acdc_java_path): - os.makedirs(os_acdc_java_path, exist_ok=True) - - try: - download_url(url, temp_zip, file_size=file_size, desc="Java") - extract_zip(temp_zip, os_acdc_java_path) - except Exception as e: - print("=======================") - traceback.print_exc() - print("=======================") - finally: - os.remove(temp_zip) - - # Move files one level up - unzipped_path = os.path.join(os_acdc_java_path, unzipped_foldername) - for name in os.listdir(unzipped_path): - # move files up one level - src = os.path.join(unzipped_path, name) - shutil.move(src, os_acdc_java_path) - try: - shutil.rmtree(unzipped_path) - except PermissionError as e: - pass - - jre_path = _java_exists(os_foldername) - jdk_path = _jdk_exists(jre_path) - return jre_path, jdk_path, url - - -def get_model_path(model_name, create_temp_dir=True): - if model_name == "Automatic thresholding": - model_name == "thresholding" - - model_info_path = os.path.join(models_path, model_name, "model") - - if os.path.exists(model_info_path): - for file in listdir(model_info_path): - if file != "weights_location_path.txt": - continue - with open(os.path.join(model_info_path, file), "r") as txt: - model_path = txt.read() - model_path = os.path.expanduser(model_path) - if not os.path.exists(model_path): - model_path = _write_model_location_to_txt(model_name) - else: - break - else: - model_path = _write_model_location_to_txt(model_name) - else: - os.makedirs(model_info_path, exist_ok=True) - model_path = _write_model_location_to_txt(model_name) - - model_path = migrate_to_new_user_profile_path(model_path) - - if not os.path.exists(model_path): - os.makedirs(model_path, exist_ok=True) - - if not create_temp_dir: - return "", model_path - - exists = check_model_exists(model_path, model_name) - if exists: - return "", model_path - - temp_zip_path = _create_temp_dir() - return temp_zip_path, model_path - - -def check_model_exists(model_path, model_name): - try: - import cellacdc - - m = model_name.lower() - weights_filenames = getattr(cellacdc, f"{m}_weights_filenames") - files_present = listdir(model_path) - return all([f in files_present for f in weights_filenames]) - except Exception as e: - return True - - -def _create_temp_dir(): - temp_model_path = tempfile.mkdtemp() - temp_zip_path = os.path.join(temp_model_path, "model_temp.zip") - return temp_zip_path - - -def _model_url(model_name, return_alternative=False): - if model_name == "YeaZ": - url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/8PMePcwJXmaMMS6/download/YeaZ_weights.zip" - alternative_url = ( - "https://zenodo.org/record/6125825/files/YeaZ_weights.zip?download=1" - ) - file_size = 693685011 - elif model_name == "YeastMate": - url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/pMT8pAmMkNtN8BP/download/yeastmate_weights.zip" - alternative_url = ( - "https://zenodo.org/record/6140067/files/yeastmate_weights.zip?download=1" - ) - file_size = 164911104 - elif model_name == "segment_anything": - url = [ - "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth", - "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_l_0b3195.pth", - "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth", - ] - file_size = [2564550879, 1249524736, 375042383] - alternative_url = "" - elif model_name == "YeaZ_v2": - url = [ - "https://hmgubox2.helmholtz-muenchen.de/index.php/s/5PARckkcJcN9D3S/download/weights_budding_BF_multilab_0_1", - "https://hmgubox2.helmholtz-muenchen.de/index.php/s/CTHq4HN3adyFbnE/download/weights_budding_PhC_multilab_0_1", - "https://hmgubox2.helmholtz-muenchen.de/index.php/s/QTtBJycYnLQZsHQ/download/weights_fission_multilab_0_2", - ] - file_size = [124142981, 124143031, 124144759] - alternative_url = "https://github.com/rahi-lab/YeaZ-GUI#installation" - elif model_name == "DeepSea": - url = [ - "https://github.com/abzargar/DeepSea/raw/master/deepsea/trained_models/segmentation.pth", - "https://github.com/abzargar/DeepSea/raw/master/deepsea/trained_models/tracker.pth", - ] - file_size = [7988969, 8637439] - alternative_url = "" - elif model_name == "TAPIR": - url = ["https://storage.googleapis.com/dm-tapnet/tapir_checkpoint.npy"] - file_size = [124408122] - alternative_url = "" - elif model_name == "Cellpose_germlineNuclei": - url = [ - "https://hmgubox2.helmholtz-muenchen.de/index.php/s/AXG6fFfD8o5GZ83/download/cellpose_germlineNuclei_2023" - ] - file_size = [26570752] - alternative_url = "" - elif model_name == "omnipose": - url = [ - "https://hmgubox2.helmholtz-muenchen.de/index.php/s/DynLkocWRbQfyRp/download/bact_fluor_cptorch_0" - "https://hmgubox2.helmholtz-muenchen.de/index.php/s/2248Eoyozp3Ezj2/download/bact_fluor_omnitorch_0", - "https://hmgubox2.helmholtz-muenchen.de/index.php/s/GiacDfXGerxE7PT/download/bact_phase_omnitorch_0", - "https://hmgubox2.helmholtz-muenchen.de/index.php/s/DDq8s3CgnG2Yw6H/download/cyto2_omnitorch_0", - "https://hmgubox2.helmholtz-muenchen.de/index.php/s/MM5meM2J5HbWqXR/download/plant_cptorch_0", - "https://hmgubox2.helmholtz-muenchen.de/index.php/s/aap7znrWq5sE6JQ/download/plant_omnitorch_0", - "https://hmgubox2.helmholtz-muenchen.de/index.php/s/w5M46x9qr8zLHZH/download/size_cyto2_omnitorch_0.npy", - ] - file_size = [26558464, 26558464, 26558464, 26558464, 26558464, 75071488, 4096] - alternative_url = "" - elif model_name == "sam2": - url = [ - "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_tiny.pt", - "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_small.pt", - "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_base_plus.pt", - "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_large.pt", - ] - file_size = [155233385, 184211977, 319128965, 910600801] - alternative_url = "" - else: - return - if return_alternative: - return url, alternative_url - else: - return url, file_size - - -def _download_segment_anything_models(): - urls, file_sizes = _model_url("segment_anything") - temp_model_path = tempfile.mkdtemp() - _, final_model_path = get_model_path("segment_anything", create_temp_dir=False) - for url, file_size in zip(urls, file_sizes): - filename = url.split("/")[-1] - final_dst = os.path.join(final_model_path, filename) - if os.path.exists(final_dst): - continue - - temp_dst = os.path.join(temp_model_path, filename) - download_url( - url, temp_dst, file_size=file_size, desc="segment_anything", verbose=False - ) - - shutil.move(temp_dst, final_dst) - - -def _download_sam2_models(): - urls, file_sizes = _model_url("sam2") - temp_model_path = tempfile.mkdtemp() - _, final_model_path = get_model_path("sam2", create_temp_dir=False) - for url, file_size in zip(urls, file_sizes): - filename = url.split("/")[-1] - final_dst = os.path.join(final_model_path, filename) - if os.path.exists(final_dst): - continue - - temp_dst = os.path.join(temp_model_path, filename) - download_url(url, temp_dst, file_size=file_size, desc="sam2", verbose=False) - - shutil.move(temp_dst, final_dst) - - -def _download_deepsea_models(): - urls, file_sizes = _model_url("DeepSea") - temp_model_path = tempfile.mkdtemp() - _, final_model_path = get_model_path("deepsea", create_temp_dir=False) - for url, file_size in zip(urls, file_sizes): - filename = url.split("/")[-1] - final_dst = os.path.join(final_model_path, filename) - if os.path.exists(final_dst): - continue - - temp_dst = os.path.join(temp_model_path, filename) - download_url(url, temp_dst, file_size=file_size, desc="deepsea", verbose=False) - - shutil.move(temp_dst, final_dst) - - -def download_manual(): - manual_folder_path = os.path.join(user_profile_path, "acdc-manual") - if not os.path.exists(manual_folder_path): - os.makedirs(manual_folder_path, exist_ok=True) - - manual_file_path = os.path.join(user_profile_path, "Cell-ACDC_User_Manual.pdf") - if not os.path.exists(manual_file_path): - url = "https://github.com/SchmollerLab/Cell_ACDC/raw/main/UserManual/Cell-ACDC_User_Manual.pdf" - download_url(url, manual_file_path, file_size=1727470) - return manual_file_path - - -def download_bioformats_jar(qparent=None, logger_info=print, logger_exception=print): - dst_filepath = os.path.join( - cellacdc_path, "bioformats", "jars", "bioformats_package.jar" - ) - if os.path.exists(dst_filepath): - return True, dst_filepath - urls_to_try = (urls.bioformats_jar_home_url, urls.bioformats_jar_hmgu_url) - success = False - for url in urls_to_try: - try: - logger_info(f"Downloading `bioformats_package.jar`...") - download_url(url, dst_filepath, file_size=43233280) - success = True - break - except Exception as err: - success = False - traceback_str = traceback.format_exc() - logger_exception(traceback_str) - continue - - if success: - return True, dst_filepath - - _warnings.warn_download_bioformats_jar_failed(dst_filepath, qparent=qparent) - raise ModuleNotFoundError( - "Bioformats package jar could not be downloaded. Please, " - f"download it from here {urls.bioformats_download_page} and " - f'place it in the following path "{dst_filepath}". ' - "Thank you for your patience!" - ) - return False, dst_filepath - - -def showUserManual(): - manual_file_path = download_manual() - showInExplorer(manual_file_path) - - -def get_confirm_token(response): - for key, value in response.cookies.items(): - if key.startswith("download_warning"): - return value - return None - - -def download_url(url, dst, desc="", file_size=None, verbose=True, progress=None): - import urllib3 - - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - - CHUNK_SIZE = 32768 - if verbose: - print(f"Downloading {desc} to: {os.path.dirname(dst)}") - response = requests.get(url, stream=True, timeout=20, verify=False) - if file_size is not None and progress is not None: - progress.emit(file_size, -1) - pbar = tqdm( - total=file_size, unit="B", unit_scale=True, unit_divisor=1024, ncols=100 - ) - with open(dst, "wb") as f: - for chunk in response.iter_content(CHUNK_SIZE): - # if chunk: - f.write(chunk) - pbar.update(len(chunk)) - if progress is not None: - progress.emit(-1, len(chunk)) - pbar.close() - - -def save_response_content( - response, destination, file_size=None, model_name="cellpose", progress=None -): - print(f"Downloading {model_name} to: {os.path.dirname(destination)}") - CHUNK_SIZE = 32768 - - # Download to a temp folder in user path - temp_folder = pathlib.Path.home().joinpath(".acdc_temp") - if not os.path.exists(temp_folder): - os.mkdir(temp_folder) - temp_dst = os.path.join(temp_folder, os.path.basename(destination)) - if file_size is not None and progress is not None: - progress.emit(file_size, -1) - pbar = tqdm( - total=file_size, unit="B", unit_scale=True, unit_divisor=1024, ncols=100 - ) - with open(temp_dst, "wb") as f: - for chunk in response.iter_content(CHUNK_SIZE): - if chunk: - f.write(chunk) - pbar.update(len(chunk)) - if progress is not None: - progress.emit(-1, len(chunk)) - pbar.close() - - # Move to destination and delete temp folder - destination_dir = os.path.dirname(destination) - if not os.path.exists(destination_dir): - os.makedirs(destination_dir, exist_ok=True) - shutil.move(temp_dst, destination) - shutil.rmtree(temp_folder) - - -def extract_zip(zip_path, extract_to_path, verbose=True): - if verbose: - print(f"Extracting to {extract_to_path}...") - with zipfile.ZipFile(zip_path, "r") as zip_ref: - zip_ref.extractall(extract_to_path) - - -def check_v123_model_path(model_name): - # Cell-ACDC v1.2.3 saved the weights inside the package, - # while from v1.2.4 we save them on user folder. If we find the - # weights in the package we move them to user folder without downloading - # new ones. - v123_model_path = os.path.join(models_path, model_name, "model") - exists = check_model_exists(v123_model_path, model_name) - if exists: - return v123_model_path - else: - return "" - - -def is_old_user_profile_path(path_to_check: os.PathLike): - from . import user_data_dir - - user_data_folderpath = user_data_dir() - user_profile_path_txt = os.path.join( - user_data_folderpath, "acdc_user_profile_location.txt" - ) - if os.path.exists(user_profile_path_txt): - return False - - from . import user_home_path - - user_home_path = user_home_path.replace("\\", "/") - path_to_check = path_to_check.replace("\\", "/") - return user_home_path == path_to_check - - -def migrate_to_new_user_profile_path(path_to_migrate: os.PathLike): - parent_dir = os.path.dirname(path_to_migrate) - if not is_old_user_profile_path(parent_dir): - return path_to_migrate - folder = os.path.basename(path_to_migrate) - return os.path.join(user_profile_path, folder) - - -def _write_model_location_to_txt(model_name): - model_info_path = os.path.join(models_path, model_name, "model") - model_path = os.path.join(user_profile_path, f"acdc-{model_name}") - file = "weights_location_path.txt" - with open(os.path.join(model_info_path, file), "w") as txt: - txt.write(model_path) - return os.path.expanduser(model_path) - - -def determine_folder_type(folder_path): - is_pos_folder = is_pos_folderpath(folder_path) - is_images_folder = folder_path.endswith("Images") and listdir(folder_path) - contains_images_folder = os.path.exists(os.path.join(folder_path, "Images")) - contains_pos_folders = len(get_pos_foldernames(folder_path)) > 0 - if contains_pos_folders: - is_pos_folder = False - is_images_folder = False - elif contains_images_folder and not is_pos_folder: - # Folder created by loading an image - is_images_folder = True - folder_path = os.path.join(folder_path, "Images") - - return is_pos_folder, is_images_folder, folder_path - - -def download_model(model_name): - if model_name == "segment_anything": - try: - _download_segment_anything_models() - return True - except Exception as e: - traceback.print_exc() - return False - elif model_name == "sam2": - try: - _download_sam2_models() - return True - except Exception as e: - traceback.print_exc() - return False - elif model_name == "DeepSea": - try: - _download_deepsea_models() - return True - except Exception as e: - traceback.print_exc() - return False - elif model_name == "TAPIR": - try: - _download_tapir_model() - return True - except Exception as e: - traceback.print_exc() - return False - elif model_name == "YeaZ_v2": - try: - _download_yeaz_models() - return True - except Exception as e: - traceback.print_exc() - return False - elif model_name == "Cellpose_germlineNuclei": - try: - _download_cellpose_germlineNuclei_model() - return True - except Exception as e: - traceback.print_exc() - return False - elif model_name == "omnipose": - try: - _download_omnipose_models() - return True - except Exception as err: - return False - elif model_name != "YeastMate" and model_name != "YeaZ": - # We manage only YeastMate and YeaZ - return True - - try: - # Check if model exists - temp_zip_path, model_path = get_model_path(model_name) - if not temp_zip_path: - # Model exists return - return True - - # Check if user has model in the old v1.2.3 location - v123_model_path = check_v123_model_path(model_name) - if v123_model_path: - print(f"Weights files found in {v123_model_path}") - print(f"--> moving to new location: {model_path}...") - for file in listdir(v123_model_path): - src = os.path.join(v123_model_path, file) - dst = os.path.join(model_path, file) - shutil.copy(src, dst) - return True - - # Download model from url to tempDir/model_temp.zip - temp_dir = os.path.dirname(temp_zip_path) - url, file_size = _model_url(model_name) - print(f"Downloading {model_name} to {model_path}") - download_url( - url, temp_zip_path, file_size=file_size, desc=model_name, verbose=False - ) - - # Extract zip file inside temp dir - print(f"Extracting model...") - extract_zip(temp_zip_path, temp_dir, verbose=False) - - # Move unzipped files to ~/acdc-{model_name} folder - print(f"Moving files from temporary folder to {model_path}...") - for file in listdir(temp_dir): - if file.endswith(".zip"): - continue - src = os.path.join(temp_dir, file) - dst = os.path.join(model_path, file) - shutil.move(src, dst) - - # Remove temp directory - print(f"Removing temporary folder...") - shutil.rmtree(temp_dir) - return True - - except Exception as e: - traceback.print_exc() - return False - - -# def get_tiff_metadata( -# image_arr, -# SizeT=None, -# SizeZ=None, -# PhysicalSizeZ=None, -# PhysicalSizeX=None, -# PhysicalSizeY=None, -# TimeIncrement=None -# ): -# SizeY, SizeX = image_arr.shape[-2:] -# Type = str(image_arr.dtype) - -# metadata = { -# 'SizeX': SizeX, -# 'SizeY': SizeY, -# 'Type': Type -# } - -# axes = 'YX' -# if SizeZ is not None and SizeZ > 1: -# axes = f'Z{axes}' -# metadata['SizeZ'] = SizeZ - -# if SizeT is not None and SizeT > 1: -# axes = f'T{axes}' -# metadata['SizeT'] = SizeT - -# metadata['axes'] = axes - -# if PhysicalSizeX is not None: -# metadata['PhysicalSizeX'] = PhysicalSizeX - -# if PhysicalSizeY is not None: -# metadata['PhysicalSizeY'] = PhysicalSizeY - -# if PhysicalSizeZ is not None: -# metadata['PhysicalSizeZ'] = PhysicalSizeZ - -# if TimeIncrement is not None: -# metadata['TimeIncrement'] = TimeIncrement - -# return metadata - - -def get_tiff_metadata( - image_arr, - SizeT=None, - SizeZ=None, - PhysicalSizeZ=None, - PhysicalSizeX=None, - PhysicalSizeY=None, - TimeIncrement=None, -): - SizeY, SizeX = image_arr.shape[-2:] - Type = str(image_arr.dtype) - - metadata = {"Pixels": {"SizeX": SizeX, "SizeY": SizeY, "Type": Type}} - - axes = "YX" - if SizeZ is not None and SizeZ > 1: - axes = f"Z{axes}" - metadata["Pixels"]["SizeZ"] = SizeZ - - if SizeT is not None and SizeT > 1: - axes = f"T{axes}" - metadata["Pixels"]["SizeT"] = SizeT - - metadata["axes"] = axes - - if PhysicalSizeX is not None: - metadata["Pixels"]["PhysicalSizeX"] = PhysicalSizeX - - if PhysicalSizeY is not None: - metadata["Pixels"]["PhysicalSizeY"] = PhysicalSizeY - - if PhysicalSizeZ is not None: - metadata["Pixels"]["PhysicalSizeZ"] = PhysicalSizeZ - - if TimeIncrement is not None: - metadata["Pixels"]["TimeIncrement"] = TimeIncrement - - return metadata - - -def to_tiff( - new_path, - data, - SizeT=None, - SizeZ=None, - PhysicalSizeZ=None, - PhysicalSizeX=None, - PhysicalSizeY=None, - TimeIncrement=None, -): - valid_dtypes = (np.uint8, np.uint16, np.float32) - is_valid_dtype = False - for valid_dtype in valid_dtypes: - if np.issubdtype(data.dtype, valid_dtype): - is_valid_dtype = True - break - - if not is_valid_dtype: - data = data.astype(np.float32) - - metadata = get_tiff_metadata( - data, - SizeT=SizeT, - SizeZ=SizeZ, - PhysicalSizeZ=PhysicalSizeZ, - PhysicalSizeX=PhysicalSizeX, - PhysicalSizeY=PhysicalSizeY, - TimeIncrement=TimeIncrement, - ) - - # # Potential alternative - # hyperstack = tifffile.memmap( - # new_path, - # shape=img.shape, - # dtype=img.dtype, - # imagej=True, - # metadata={'axes': 'TZYX'}, - # ) - # hyperstack[:] = img - # hyperstack.flush() - - try: - tifffile.imwrite(new_path, data, metadata=metadata, imagej=True) - except Exception as err: - tifffile.imwrite(new_path, data) - - -def from_lab_to_obj_coords(lab): - rp = skimage.measure.regionprops(lab) - dfs = [] - keys = [] - for obj in rp: - keys.append(obj.label) - obj_coords = obj.coords - ndim = obj_coords.shape[1] - if ndim == 3: - columns = ["z", "y", "x"] - else: - columns = ["y", "x"] - df_obj = pd.DataFrame(data=obj_coords, columns=columns) - dfs.append(df_obj) - df = pd.concat(dfs, keys=keys, names=["Cell_ID", "idx"]).droplevel("idx") - return df - - -def lab2d_to_rois(ImagejRoi, lab2D, ndigits, t=None, z=None): - rp = skimage.measure.regionprops(lab2D) - rois = [] - for obj in rp: - cont = core.get_obj_contours(obj) - yc, xc = obj.centroid - x_str = str((int(xc))).zfill(ndigits) - y_str = str((int(yc))).zfill(ndigits) - name = f"{x_str}-{y_str}" - if z is not None: - z_str = str(z).zfill(ndigits) - name = f"{z_str}-{name}" - - if t is not None: - t_str = str(t).zfill(ndigits) - name = f"{t_str}-{name}" - - name = f"id={obj.label}-{name}" - - roi = ImagejRoi.frompoints(cont, name=name, t=t, z=z, index=obj.label) - rois.append(roi) - return rois - - -def from_lab_to_imagej_rois(lab, ImagejRoi, t=0, SizeT=1, max_ID=None): - if max_ID is None: - max_ID = lab.max() - - if SizeT == 1: - t = None - - SizeY, SizeX = lab.shape[-2:] - ndigitsT = len(str(SizeT)) - ndigitsY = len(str(SizeY)) - ndigitsX = len(str(SizeX)) - - if lab.ndim == 3: - rois = [] - SizeZ = len(lab) - ndigitsZ = len(str(SizeZ)) - ndigits = max(ndigitsT, ndigitsZ, ndigitsY, ndigitsX) - for z, lab2D in enumerate(lab): - z_rois = lab2d_to_rois(ImagejRoi, lab2D, ndigits, t=t, z=z) - rois.extend(z_rois) - else: - ndigits = max(ndigitsT, ndigitsY, ndigitsX) - rois = lab2d_to_rois(ImagejRoi, lab, ndigits, t=t) - return rois - - -def from_imagej_rois_to_segm_data( - TZYX_shape, ID_to_roi_mapper, rescale_rois_sizes, repeat_2d_rois_zslices_range -): - SizeT, SizeZ, SizeY, SizeX = TZYX_shape - segm_data = np.zeros(TZYX_shape, dtype=np.uint32) - for ID, roi in ID_to_roi_mapper.items(): - name = roi.name - name_parts = name.split("-") - zz = [0] - if len(name_parts) == 2 and SizeZ > 1: - # 2D roi in 3D segm data --> place 2D roi on each z-slice - zz = range(*repeat_2d_rois_zslices_range) - - elif len(name_parts) > 2 and SizeZ > 1: - # 2D roi from a 3D roi --> place at requested z-slice - zz = [int(name_parts[-3])] - - tt = [0] * len(zz) - if SizeT > 1: - tt = [roi.t_position] * len(zz) - - y0, x0 = roi.top, roi.left - contours = roi.integer_coordinates + (x0, y0) - xx = contours[:, 0] - yy = contours[:, 1] - if rescale_rois_sizes is not None: - rescale_z = rescale_rois_sizes["Z"] - rescale_y = rescale_rois_sizes["Y"] - rescale_x = rescale_rois_sizes["X"] - - factor_z = rescale_z[1] / rescale_z[0] - factor_y = rescale_y[1] / rescale_y[0] - factor_x = rescale_x[1] / rescale_x[0] - - xx = np.clip(np.round(xx * factor_x).astype(int), 0, SizeX - 1) - yy = np.clip(np.round(yy * factor_y).astype(int), 0, SizeY - 1) - - for t, z in zip(tt, zz): - if rescale_rois_sizes is not None: - z = round(z * factor_z) - z = z if z < SizeZ else SizeZ - z = z if z >= 0 else 0 - - rr, cc = skimage.draw.polygon(yy, xx) - segm_data[t, z, rr, cc] = ID - - return np.squeeze(segm_data) - - -def aliases_real_time_trackers(reverse=False): - """ - Returns a dictionary with aliases for real-time trackers. - """ - - aliases = { - "CellACDC_normal_division": "Cell-ACDC symmetric division", - "CellACDC_2steps": "Cell-ACDC 2 steps", - } - - if reverse: - aliases = {v: k for k, v in aliases.items()} - - return aliases - - -def get_list_of_real_time_trackers(): - trackers = get_list_of_trackers() - rt_trackers = [] - aliases = aliases_real_time_trackers() - for tracker in trackers: - if tracker == "CellACDC": - continue - if tracker == "YeaZ": - continue - tracker_filename = f"{tracker}_tracker.py" - tracker_path = os.path.join( - cellacdc_path, "trackers", tracker, tracker_filename - ) - try: - with open(tracker_path) as file: - txt = file.read() - if txt.find("def track_frame") != -1: - rt_trackers.append(tracker) - except Exception as e: - continue - - for i, tracker in enumerate(rt_trackers): - if tracker in aliases: - rt_trackers[i] = aliases[tracker] - - return natsorted(rt_trackers, key=str.casefold) - - -def get_list_of_trackers(): - trackers_path = os.path.join(cellacdc_path, "trackers") - trackers = [] - for name in listdir(trackers_path): - _path = os.path.join(trackers_path, name) - tracker_script_path = os.path.join(_path, f"{name}_tracker.py") - is_valid_tracker = ( - os.path.isdir(_path) - and os.path.exists(tracker_script_path) - and not name.endswith("__") - ) - - if name.startswith("_"): - continue - - if is_valid_tracker: - trackers.append(name) - return natsorted(trackers, key=str.casefold) - - -def get_list_of_models(): - models = set() - for name in listdir(models_path): - _path = os.path.join(models_path, name) - if not os.path.exists(_path): - continue - - if not os.path.isdir(_path): - continue - - if name.endswith("__"): - continue - - if name.startswith("_"): - continue - - if name == "skip_segmentation": - continue - - if not os.path.exists(os.path.join(_path, "acdcSegment.py")): - continue - - if name == "thresholding": - name = "Automatic thresholding" - - models.add(name) - - if not os.path.exists(models_list_file_path): - return natsorted(list(models), key=str.casefold) - - cp = config.ConfigParser() - cp.read(models_list_file_path) - models.update(cp.sections()) - return natsorted(list(models), key=str.casefold) - - -def get_list_of_promptable_models(): - models = set() - for name in listdir(promptable_models_path): - _path = os.path.join(promptable_models_path, name) - if not os.path.exists(_path): - continue - - if not os.path.isdir(_path): - continue - - if name.endswith("__"): - continue - - if not os.path.exists(os.path.join(_path, "acdcPromptSegment.py")): - continue - - models.add(name) - - if not os.path.exists(promptable_models_list_file_path): - return natsorted(list(models), key=str.casefold) - - cp = config.ConfigParser() - cp.read(promptable_models_list_file_path) - models.update(cp.sections()) - return natsorted(list(models), key=str.casefold) - - -def seconds_to_ETA(seconds): - seconds = round(seconds) - ETA = datetime.timedelta(seconds=seconds) - ETA_split = str(ETA).split(":") - if seconds < 0: - ETA = "00h:00m:00s" - elif seconds >= 86400: - days, hhmmss = str(ETA).split(",") - h, m, s = hhmmss.split(":") - ETA = f"{days}, {int(h):02}h:{int(m):02}m:{int(s):02}s" - else: - h, m, s = str(ETA).split(":") - ETA = f"{int(h):02}h:{int(m):02}m:{int(s):02}s" - return ETA - - -def to_uint8(img): - if img.dtype == np.uint8: - return img - img = np.round(img_to_float(img) * 255).astype(np.uint8) - return img - - -def to_uint16(img): - if img.dtype == np.uint16: - return img - img = np.round(img_to_float(img) * 65535).astype(np.uint16) - return img - - -def elided_text(text, max_len=50, elid_idx=None): - if len(text) <= max_len: - return text - - if elid_idx is None: - elid_idx = int(max_len / 2) - if elid_idx >= max_len: - elid_idx = max_len - 1 - idx1 = elid_idx - idx2 = elid_idx - max_len - text = f"{text[:idx1]}...{text[idx2:]}" - return text - - -def to_relative_path(path, levels=3, prefix="..."): - path = path.replace("\\", "/") - parts = path.split("/") - if levels >= len(parts): - return path - parts = parts[-levels:] - rel_path = "/".join(parts) - rel_path.replace("/", os.sep) - if prefix: - rel_path = f"{prefix}{os.sep}{rel_path}" - return rel_path - - -def img_to_float(img, force_dtype=None, force_missing_dtype=None, warn=True): - input_img_dtype = img.dtype - value = img[(0,) * img.ndim] - img_max = np.max(img) - # Check if float outside of -1, 1 - if img_max <= 1.0 and isinstance(value, (np.floating, float)): - return img - - uint8_max = np.iinfo(np.uint8).max - uint16_max = np.iinfo(np.uint16).max - uint32_max = np.iinfo(np.uint32).max - - img = img.astype(float) - if force_dtype is not None: - dtype_max = np.iinfo(force_dtype).max - img = img / dtype_max - elif input_img_dtype == np.uint8: - # Input image is 8-bit - img = img / uint8_max - elif input_img_dtype == np.uint16: - # Input image is 16-bit - img = img / uint16_max - elif input_img_dtype == np.uint32: - # Input image is 32-bit - img = img / uint32_max - elif force_missing_dtype is not None: - img = img.astype(force_dtype) - elif img_max <= uint8_max: - # Input image is probably 8-bit - if warn: - _warnings.warn_image_overflow_dtype(input_img_dtype, img_max, "8-bit") - img = img / uint8_max - elif img_max <= uint16_max: - # Input image is probably 16-bit - if warn: - _warnings.warn_image_overflow_dtype(input_img_dtype, img_max, "16-bit") - img = img / uint16_max - elif img_max <= uint32_max: - # Input image is probably 32-bit - if warn: - _warnings.warn_image_overflow_dtype(input_img_dtype, img_max, "32-bit") - img = img / uint32_max - else: - # Input image is a non-supported data type - raise TypeError( - f"The maximum value in the image is {img_max} which is greater than the " - f"maximum value supported of {uint32_max} (32-bit). " - "Please consider converting your images to 32-bit or 16-bit first." - ) - return img - - -def float_img_to_dtype(img, dtype): - if img.dtype == dtype: - return img - - img_max = img.max() - if img_max > 1.0: - raise TypeError( - "Images of float data type with values greater than 1.0 cannot " - f"be safely casted to {dtype}. " - f"The max value of the input image is {img_max:.3f}" - ) - - img_min = img.min() - if img_min < -1.0: - raise TypeError( - "Images of float data type with values smaller than -1.0 cannot " - f"be safely casted to {dtype}." - f"The minumum value of the input image is {img_min:.3f}" - ) - - if dtype == np.uint8: - return skimage.img_as_ubyte(img) - - if dtype == np.uint16: - return skimage.img_as_uint(img) - - if dtype == np.float32: - return img.astype(np.float32) - - if dtype == np.float64: - return img.astype(np.float64) - - raise TypeError( - f"Invalid output data type `{dtype}`. " - "Valid output data types are `np.uint8` and `np.uint16`" - ) - - -def convert_to_dtype(data: np.ndarray, dtype): - if data.dtype == dtype: - return data - val = data[tuple([0] * data.ndim)] - if isinstance(val, (np.floating, float)): - data = float_img_to_dtype(data, dtype) - elif dtype == np.uint8: - data = np.round(img_to_float(data) * 255).astype(np.uint8) - elif dtype == np.uint16: - data = np.round(img_to_float(data) * 65535).astype(np.uint16) - else: - raise TypeError( - f"Invalid output data type `{dtype}`. " - "Valid data types are floating-point format, `np.uint8` " - "and `np.uint16`" - ) - return data - - -def _install_homebrew_command(): - return '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' - - -def _brew_install_java_command(): - return "brew install --cask homebrew/cask-versions/adoptopenjdk8" - - -def _brew_install_hdf5(): - return "brew install hdf5" - - -def _apt_update_command(): - return "sudo apt-get update" - - -def _apt_gcc_command(): - return "sudo apt install python-dev gcc" - - -def _apt_install_java_command(): - return "sudo apt-get install openjdk-8-jdk" - - -def _java_instructions_linux(): - s1 = html_utils.paragraph(""" - Run the following commands
    - in the Teminal one by one: - """) - - s2 = html_utils.paragraph(f""" - {_apt_gcc_command().replace(" ", " ")} - """) - - s3 = html_utils.paragraph(f""" - {_apt_update_command().replace(" ", " ")} - """) - - s4 = html_utils.paragraph(f""" - {_apt_install_java_command().replace(" ", " ")} - """) - - s5 = html_utils.paragraph(""" - The first command is used to install GCC, which is needed later.

    - The second and third commands are used is used to install - Java Development Kit 8.

    - Follow the instructions on the terminal to complete - installation.

    - """) - return s1, s2, s3, s4 - - -def _java_instructions_macOS(): - s1 = html_utils.paragraph(""" - Run the following commands
    - in the Teminal one by one: - """) - - s2 = html_utils.paragraph(f""" - {_install_homebrew_command()} - """) - - s3 = html_utils.paragraph(f""" - {_brew_install_java_command().replace(" ", " ")} - """) - - s4 = html_utils.paragraph(""" - The first command is used to install Homebrew
    - a package manager for macOS/Linux.

    - The second command is used to install Java 8.
    - Follow the instructions on the terminal to complete - installation.

    - Alternatively, you can install Java as a regular app
    - by downloading the app from - - here - . - """) - return s1, s2, s3, s4 - - -def jdk_windows_url(): - return "https://hmgubox2.helmholtz-muenchen.de/index.php/s/R62Ktcda6jWea2s" - - -def cpp_windows_url(): - return "https://visualstudio.microsoft.com/visual-cpp-build-tools/" - - -def _java_instructions_windows(): - jdk_url = f'"{jdk_windows_url()}"' - cpp_url = f'"{cpp_windows_url()}"' - s1 = html_utils.paragraph(""" - Download and install Java Development Kit and
    - Microsoft C++ Build Tools for Windows (links below).

    - IMPORTANT: when installing "Microsoft C++ Build Tools"
    - make sure to select "Desktop development with C++".
    - Click "See the screenshot" for more details.
    - """) - - s2 = html_utils.paragraph(f""" - Java Development Kit: - - here - - """) - - s3 = html_utils.paragraph(f""" - Microsoft C++ Build Tools: - - here - - """) - return s1, s2, s3 - - -def install_javabridge_instructions_text(): - if is_win: - return _java_instructions_windows() - elif is_mac: - return _java_instructions_macOS() - elif is_linux: - return _java_instructions_linux() - - -def install_javabridge_help(parent=None): - msg = widgets.myMessageBox() - txt = html_utils.paragraph(f""" - Cell-ACDC is going to download and install - javabridge.

    - Make sure you have an active internet connection, - before continuing. - Progress will be displayed on the terminal

    - IMPORTANT: If the installation fails, please open an issue - on our - - GitHub page - .

    - Alternatively, you can cancel the process and try later. - """) - msg.setIcon() - msg.setWindowTitle("Installing javabridge") - msg.addText(txt) - msg.addButton(" Ok ") - cancel = msg.addButton(" Cancel ") - msg.exec_() - return msg.clickedButton == cancel - - -def check_napari_plugin(plugin_name, module_name, parent=None): - try: - import_module(module_name) - except ModuleNotFoundError as e: - url = "https://napari.org/stable/plugins/find_and_install_plugin.html#find-and-install-plugins" - href = html_utils.href_tag("this guide", url) - txt = html_utils.paragraph(f""" - To correctly use this napari utility you need to install the - plugin called {plugin_name}.

    - Please, read {href} on how to install plugins in napari.

    - You will need to restart both napari and Cell-ACDC after installing - the plugin.

    - NOTE: in the text box in napari you will need to write the full name - {plugin_name} becasue it is NOT A SEARCH BOX. - """) - msg = widgets.myMessageBox() - msg.critical(parent, f"Napari plugin required", txt) - raise e - - -def _install_pip_package( - pkg_name: str, - logger: Callable = print, - install_dependencies: bool = True, - force_binary: bool = True, - pref_binary: bool = True, -) -> None: - command = [ - sys.executable, - "-m", - "pip", - "install", - pkg_name, - ] - if force_binary: - command.append("--only-binary=:all:") - elif pref_binary: - command.append("--prefer-binary") - if not install_dependencies: - command.append("--no-deps") - try: - subprocess.check_call(command) - except subprocess.CalledProcessError as e: - if "--only-binary=:all:" in str(e): - logger( - f"Error: {pkg_name} does not have a binary distribution available, trying preferred binary." - ) - _install_pip_package( - pkg_name=pkg_name, - logger=logger, - install_dependencies=install_dependencies, - force_binary=False, - pref_binary=True, - ) - elif "--prefer-binary" in str(e): - logger( - f"Error: {pkg_name} does not have a preferred binary distribution available, trying source." - ) - command.remove("--prefer-binary") - command.append("--no-binary=:all:") - _install_pip_package( - pkg_name=pkg_name, - logger=logger, - install_dependencies=install_dependencies, - force_binary=False, - pref_binary=False, - ) - else: - logger(f"""Error: {pkg_name} installation failed. Please check the error message. This is probably due to the package - not being available for your platform or python version.""") - raise e - - -def uninstall_pip_package(pkg_name): - subprocess.check_call([sys.executable, "-m", "pip", "uninstall", "-y", pkg_name]) - - -def uninstall_omnipose_acdc(): - """Uninstall omnipose-acdc if present. Since v1.5.0 it is not needed.""" - import json - - pip_list_output = subprocess.check_output( - [sys.executable, "-m", "pip", "list", "--format", "json"] - ) - installed_packages = json.loads(pip_list_output) - pkgs_to_uninstall = [] - for package_info in installed_packages: - if package_info["name"] == "omnipose-acdc": - pkgs_to_uninstall.append("omnipose-acdc") - elif package_info["name"] == "cellpose-omni-acdc": - pkgs_to_uninstall.append("cellpose-omni-acdc") - - for pkg_to_uninstall in pkgs_to_uninstall: - uninstall_pip_package(pkg_to_uninstall) - - -def get_cellpose_major_version(errors="raise"): - major_installed = None - try: - installed_version = get_package_version("cellpose") - major_installed = int(installed_version.split(".")[0]) - except Exception as err: - if errors == "raise": - raise err - - return major_installed - - -def check_cellpose_version(version: str): - if isinstance(version, int): - version = f"{version}.0" - - major_requested = int(version.split(".")[0]) - cancel = False - try: - installed_version = get_package_version("cellpose") - major_installed = int(installed_version.split(".")[0]) - is_version_correct = major_installed == major_requested - if not is_version_correct: - cancel = _warnings.warn_installing_different_cellpose_version( - version, installed_version - ) - if not is_second_version_greater( - min_target_versions_cp[str(major_requested)], installed_version - ): - is_version_correct = False - except Exception as err: - is_version_correct = False - - if cancel: - raise ModuleNotFoundError("Cellpose installation cancelled by the user.") - return is_version_correct - - -def purge_module(module_name): - to_delete = [ - mod - for mod in sys.modules - if mod == module_name or mod.startswith(module_name + ".") - ] - for mod in to_delete: - del sys.modules[mod] - - importlib.invalidate_caches() - importlib.import_module(module_name) - if module_name in sys.modules: - importlib.reload(sys.modules[module_name]) - else: - raise ModuleNotFoundError(f"Module '{module_name}' not found in sys.modules.") - - -def is_second_version_greater( - target_version: str, - current_version: str, -): - """ - Compares two model versions and returns True if the current version is - greater than or equal to the target version. - """ - target_version = packaging_version.parse(target_version) - current_version = packaging_version.parse(current_version) - - return current_version >= target_version - - -def is_pkg_version_within_range(package_version: str, min_version="", max_version=""): - package_version_number = packaging_version.parse(package_version) - is_greater_than_min = True - if min_version: - min_version_number = packaging_version.parse(min_version) - is_greater_than_min = package_version_number >= min_version_number - - is_less_than_max = True - if max_version: - max_version_number = packaging_version.parse(max_version) - is_less_than_max = package_version_number <= max_version_number - - return is_greater_than_min and is_less_than_max - - -def check_install_cellpose( - version: Literal["2.0", "3.0", "4.0", "any"] = "2.0", - version_to_install_if_missing: Literal["2.0", "3.0", "4.0"] = "4.0", -): - if isinstance(version, int): - version = f"{version}.0" - - check_install_torch() - - if version == "any": - try: - from cellpose import models - - return - except Exception as err: - version = version_to_install_if_missing # after this the version will for sure be a valid format and not 'any' - - is_version_correct = check_cellpose_version(version) - if is_version_correct: - return - - major_version = int(version.split(".")[0]) - - next_version = major_version + 1 - - min_version = min_target_versions_cp[str(major_version)] - - check_install_package( - "cellpose", - max_version=f"{next_version}.0", - min_version=min_version, - include_lower_version=True, - ) - - purge_module("cellpose") - - -def check_install_baby(): - check_install_package( - "TensorFlow", - pypi_name="tensorflow", - import_pkg_name="tensorflow", - max_version="2.14", - ) - check_install_package("baby", pypi_name="baby-seg", import_pkg_name="baby") - - -def check_install_nnInteractive(): - check_install_package("huggingface-hub") - check_install_torch() - check_install_package("nnInteractive") - - purge_module("nnInteractive") - - importlib.invalidate_caches() - import nnInteractive - - importlib.reload(nnInteractive) - - -def check_install_microsam(): - check_install_package("micro-sam", pypi_name="micro_sam", installer="conda") - - -def check_install_yeaz(): - check_install_torch() - check_install_package("yeaz") - - -def check_install_segment_anything(): - check_install_torch() - check_install_package("segment_anything") - - -def check_install_sam2(): - check_install_torch() - check_install_package("sam2") - - -def check_install_cellsam(): - check_install_torch() - check_install_package( - "cellSAM", - pypi_name="git+https://github.com/vanvalenlab/cellSAM.git", - import_pkg_name="cellSAM", - note=( - "CellSAM requires a DeepCell access token to download models.\n" - "Set the DEEPCELL_ACCESS_TOKEN environment variable before use.\n" - "Get your token at: https://deepcell.org" - ), - ) - - -def is_gui_running(): - if not GUI_INSTALLED: - return False - - return QCoreApplication.instance() is not None - - -def check_pkg_version( - import_pkg_name, min_version, include_lower_version, raise_err=True -): - is_version_correct = False - try: - installed_version = get_package_version(import_pkg_name) - if include_lower_version: - is_version_correct = packaging_version.parse( - installed_version - ) >= packaging_version.parse(min_version) - else: - is_version_correct = packaging_version.parse( - installed_version - ) > packaging_version.parse(min_version) - except Exception as err: - is_version_correct = False - - if raise_err and not is_version_correct: - raise ModuleNotFoundError(f"{import_pkg_name}>{min_version} not installed.") - else: - return is_version_correct - - -def check_pkg_exact_version(import_pkg_name, version: str, raise_err=True): - is_version_correct = False - try: - installed_version = get_package_version(import_pkg_name) - is_version_correct = packaging_version.parse( - installed_version - ) == packaging_version.parse(version) - except Exception as err: - is_version_correct = False - - if raise_err and not is_version_correct: - raise ModuleNotFoundError(f"{import_pkg_name}=={version} not installed.") - else: - return is_version_correct - - -def check_pkg_max_version( - import_pkg_name, max_version, include_higher_version, raise_err=True -): - is_version_correct = False - try: - from packaging import version - - installed_version = get_package_version(import_pkg_name) - if include_higher_version: - is_version_correct = packaging_version.parse( - installed_version - ) <= packaging_version.parse(max_version) - else: - is_version_correct = packaging_version.parse( - installed_version - ) < packaging_version.parse(max_version) - except Exception as err: - is_version_correct = False - - if raise_err and not is_version_correct: - raise ModuleNotFoundError(f"{import_pkg_name}<={max_version} not installed.") - else: - return is_version_correct - - -def install_package_conda(conda_pkg_name, channel="conda-forge"): - if not is_conda_env(): - raise EnvironmentError("Cell-ACDC is not running in a `conda` environment.") - conda_prefix, pip_prefix = get_pip_conda_prefix() - conda_prefix = re.sub( - r"(-c\sconda-forge\s?|--channel=conda-forge\s?)", f"-c {channel} ", conda_prefix - ) - - command = f"{conda_prefix} -y {conda_pkg_name}" - _subprocess_run_command(command) - - -def _subprocess_run_command(command, shell=True, callback="check_call"): - func = getattr(subprocess, callback) - try: - out = func(command, shell=shell) - except Exception as err: - print( - f"[WARNING]: Command `{command}` failed. Trying with `{command.split()}`..." - ) - out = func(command.split(), shell=shell) - - return out - - -def check_install_omnipose(): - try: - import_module("omnipose") - return - except ModuleNotFoundError: - pass - - try: - check_install_package("omnipose", pypi_name="omnipose_acdc") - except Exception as err: - install_package_conda("mahotas") - _install_pip_package("omnipose-acdc") - - -def _run_command(command: str | list[str], shell=False): - if not isinstance(command, (str, list)): - raise TypeError( - f"Command must be a string or a list of strings, not {type(command)}" - ) - - command_str = None - if isinstance(command, str): - args_list = [command] - command_str = command - else: - args_list = command - if len(command) == 1: - command_str = command[0] - - try: - subprocess.check_call(args_list, shell=shell) - return - except Exception as err: - pass - - if command_str is None: - return - - try: - subprocess.check_call(command_str, shell=shell) - return - except Exception as err: - pass - - try: - from . import acdc_regex - - args = acdc_regex.RE_SPLIT_SPACES_IGNORE_QUOTES.split(command_str)[1::2] - subprocess.check_call(args, shell=shell) - return - except Exception as err: - pass - - -def _warn_dll_torch(qparent=None): - msg = widgets.myMessageBox() - txt = html_utils.paragraph(""" - An error message will occur after you close this message.
    - Please save your data and restart Cell-ACDC.
    - Sorry for the inconvenience!
    - This error is not critical for the main functionality of Cell-ACDC, - and only concerns the segmentation model. Your can save your data without - a problem.
    - The specific reason is that PyTorch and QtPy have weird issues with - DLL conflicts. - """) - msg.information( - qparent, - "Please restart Cell-ACDC", - txt, - buttonsTexts=("Ok, I will save my data and restart Cell-ACDC"), - ) - - -def check_install_torch(is_cli=False, caller_name="Cell-ACDC", qparent=None): - try: - import torch - import torchvision - - return - - except OSError as err: - if "dll" in str(err): - _warn_dll_torch(qparent=qparent) - raise err - else: - traceback.print_exc() - except Exception as err: - traceback.print_exc() - - if is_cli: - _install_pytorch_cli(caller_name=caller_name) - return - - win = apps.InstallPyTorchDialog(parent=qparent, caller_name=caller_name) - win.exec_() - if win.cancel: - _warnings.log_pytorch_not_installed() - return - - command = win.command - print(f'Running command: "{command}"') - _run_command(command) - - try: - import torch - except OSError as e: - if "dll" in str(e): - _warn_dll_torch(qparent=qparent) - raise e - - purge_module("torch") - - -def check_install_package( - pkg_name: str, - import_pkg_name: str = "", - pypi_name="", - note="", - parent=None, - raise_on_cancel=True, - logger_func=print, - is_cli=False, - caller_name="Cell-ACDC", - force_upgrade=False, - upgrade=False, - min_version="", - max_version="", - exact_version="", - install_dependencies=True, - return_outcome=False, - installer: Literal["pip", "conda"] = "pip", - include_higher_version: bool = False, - include_lower_version: bool = False, -): - """Try to import a package. If import fails, ask user to install it - automatically. - - Parameters - ---------- - pkg_name : str - The name of the package that is displayed to the user. - import_pkg_name : str, optional - The name of the package as it should be imported (case sensitive). - If empty string, `pkg_name` will be imported instead. Default is '' - pypi_name : str, optional - The name of the package to be installed with pip. - If empty string, `pkg_name` will be installed instead. Default is '' - note : str, optional - Additional text to display to the user. Default is '' - parent : QObject, optional - Calling QtWidget. Default is None - raise_on_cancel : bool, optional - Raise exception if processed cancelled. Default is True - logger_func : callable, optional - Function used to log text. Default is print - is_cli : bool, optional - If True, message will be displayed in the terminal. - If False, message will be displayed in a Qt message box. - Default is False - caller_name : str, optional - Program calling this function. Default is 'Cell-ACDC' - force_upgrade : bool, optional - If True, we force the upgrade even if package is installed. - upgrade : bool, optional - If True, pip will upgrade the package. This value is True if - `force_upgrade` is True. Without min_version and max_version - it will never upgrade or downgrade the package. - min_version : str, optional - If not empty it must be a valid version `major[.minor][.patch]` where - minor and patch are optional. If the installed package is older the - upgrade will be forced. - max_version : str, optional - If not empty it must be a valid version `major[.minor][.patch]` where - minor and patch are optional. If the installed package is newer the - upgrade will be forced. - exact_version : str, optional - If not empty, install this exact version. It must be a valid - `major[.minor][.patch]`. - install_dependencies : bool, optional - If False, the `--no-deps` flag will be added to the pip command. - return_outcome : bool, optional - If True, returns 1 on successfull action - installer : str, optional - Package manager to use to install the package. Either 'pip' or 'conda'. - Default is 'pip' - include_higher_version : bool, optional - If True, if the higher version is installed, it will not be downgraded. - Default is False - include_lower_version : bool, optional - If True, if the lower version is installed, it will not be upgraded. - Default is False - - Raises - ------ - ModuleNotFoundError - Error raised if process is cancelled and `raise_on_cancel=True`. - """ - if not import_pkg_name: - import_pkg_name = pkg_name - - if not is_gui_running(): - is_cli = True - - try: # check_pkg_version and check_pkg_max_version - import_pkg_name = import_pkg_name.replace("-", "_") - import_module(import_pkg_name) - if force_upgrade: - upgrade = True - raise ModuleNotFoundError( - f'User requested to forcefully upgrade the package "{pkg_name}"' - ) - if exact_version: - check_pkg_exact_version(import_pkg_name, exact_version) - if min_version: - check_pkg_version(import_pkg_name, min_version, include_lower_version) - if max_version: - check_pkg_max_version(import_pkg_name, max_version, include_higher_version) - except ModuleNotFoundError: - proceed = _install_package_msg( - pkg_name, - note=note, - parent=parent, - upgrade=upgrade, - is_cli=is_cli, - caller_name=caller_name, - logger_func=logger_func, - pkg_command=pypi_name, - max_version=max_version, - min_version=min_version, - exact_version=exact_version, - installer=installer, - include_higher_version=include_higher_version, - include_lower_version=include_lower_version, - ) - if pypi_name: - pkg_name = pypi_name - if not proceed: - if raise_on_cancel: - raise ModuleNotFoundError(f"User aborted {pkg_name} installation") - else: - return traceback.format_exc() - try: - if pkg_name == "tensorflow": - _install_tensorflow(max_version=max_version, min_version=min_version) - elif pkg_name == "deepsea": - _install_deepsea() - elif pkg_name == "segment_anything": - _install_segment_anything() - elif pkg_name == "sam2": - _install_sam2() - else: - pkg_command = _get_pkg_command_pip_install( - pkg_name, - exact_version=exact_version, - max_version=max_version, - min_version=min_version, - including_higher_version=include_higher_version, - including_lower_version=include_lower_version, - ) - if installer == "pip": - _install_pip_package( - pkg_command, install_dependencies=install_dependencies - ) - else: - install_package_conda(pkg_command) - except Exception as e: - printl(traceback.format_exc()) - _inform_install_package_failed( - pkg_name, parent=parent, do_exit=raise_on_cancel - ) - if return_outcome: - return True - - -def check_install_custom_dependencies(custom_install_requires, *args, **kwargs): - """Used to install a package with custom dependencies, usefull if they have - random pinned versions for their dependencies. - - For *args and **kwargs see `myutils.check_install_package`. - - Parameters - ---------- - custom_install_requires : list - list of dependencies. Check either requirements.txt, setup.py, - setup.cfg, pyproject.toml, or any other file that lists the dependencies. - For formatting of the dependencies with min max version, - use _get_pkg_command_pip_install. - """ - kwargs["install_dependencies"] = False - kwargs["return_outcome"] = True - success = check_install_package(*args, **kwargs) - if not success: - return - for pkg_name in custom_install_requires: - _install_pip_package(pkg_name) - - -def get_chained_attr(_object, _name): - for attr in _name.split("."): - _object = getattr(_object, attr) - return _object - - -def check_matplotlib_version(qparent=None): - mpl_version = get_package_version("matplotlib") - mpl_version_digits = mpl_version.split(".") - - mpl_major = int(mpl_version_digits[0]) - mpl_minor = int(mpl_version_digits[1]) - is_less_than_3_5 = mpl_major < 3 or (mpl_major >= 3 and mpl_minor < 5) - if not is_less_than_3_5: - return - - proceed = _install_package_msg("matplotlib", parent=qparent, upgrade=True) - if not proceed: - raise ModuleNotFoundError(f'User aborted "matplotlib" installation') - import subprocess - - try: - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "-U", "matplotlib"] - ) - except Exception as e: - printl(traceback.format_exc()) - _inform_install_package_failed("matplotlib", parent=qparent, do_exit=False) - - -def _inform_install_package_failed(pkg_name, parent=None, do_exit=True): - conda_prefix, pip_prefix = get_pip_conda_prefix() - - install_command = f"{pip_prefix} --upgrade {pkg_name}" - txt = html_utils.paragraph(f""" - Unfortunately, installation of {pkg_name} returned an error.

    - Try restarting Cell-ACDC. If it doesn't work, - please close Cell-ACDC and, with the acdc environment ACTIVE, - install {pkg_name} manually using the follwing command:

    - {install_command}

    - Thank you for your patience. - """) - msg = widgets.myMessageBox() - msg.critical(parent, f"{pkg_name} installation failed", txt) - print("*" * 50) - print( - f'[ERROR]: Installation of "{pkg_name}" failed. ' - f"Please, close Cell-ACDC and run the command " - f"{pip_prefix} --upgrade {pkg_name}`" - ) - print("^" * 50) - - -def download_fiji(logger_func=print): - url = None - if is_mac: - url = "https://downloads.micron.ox.ac.uk/fiji_update/mirrors/fiji-latest/fiji-macosx.zip" - file_size = 474_525_405 - - if url is None: - return - - if os.path.exists(get_fiji_exec_folderpath()): - return - - os.makedirs(acdc_fiji_path) - - temp_dir = tempfile.mkdtemp() - zip_dst = os.path.join(temp_dir, "fiji-macosx.zip") - logger_func(f'Downloading Fiji to "{acdc_fiji_path}"...') - download_url(url, zip_dst, verbose=False, file_size=file_size) - extract_zip(zip_dst, acdc_fiji_path) - - return acdc_fiji_path - - -def _install_package_msg( - pkg_name, - note="", - parent=None, - upgrade=False, - caller_name="Cell-ACDC", - is_cli=False, - pkg_command="", - logger_func=print, - exact_version="", - max_version="", - min_version="", - installer: Literal["pip", "conda"] = "pip", - include_higher_version: bool = False, - include_lower_version: bool = False, -): - if is_cli: - proceed = _install_package_cli_msg( - pkg_name, - note=note, - upgrade=upgrade, - caller_name=caller_name, - pkg_command=pkg_command, - exact_version=exact_version, - max_version=max_version, - min_version=min_version, - logger_func=logger_func, - installer=installer, - include_higher_version=include_higher_version, - include_lower_version=include_lower_version, - ) - else: - proceed = _install_package_gui_msg( - pkg_name, - note=note, - parent=parent, - upgrade=upgrade, - caller_name=caller_name, - pkg_command=pkg_command, - exact_version=exact_version, - max_version=max_version, - min_version=min_version, - logger_func=logger_func, - installer=installer, - including_higher_version=include_higher_version, - including_lower_version=include_lower_version, - ) - return proceed - - -def get_cli_multi_choice_question(question, choices): - choices_format = [f"{i + 1}) {choice}." for i, choice in enumerate(choices)] - choices_format = " ".join(choices_format) - choices_opts = "/".join([str(i) for i in range(1, len(choices) + 1)]) - text = f"{question} {choices_format} q) Quit. ({choices_opts})?: " - return text - - -def _install_pytorch_cli(caller_name="Cell-ACDC", action="install", logger_func=print): - separator = "-" * 60 - txt = ( - f"{separator}\n{caller_name} needs to {action} PyTorch\n\n" - "You can choose to install it now or stop the process and install it " - "later. To install it correctly, we need to know your preferences.\n" - ) - logger_func(txt) - questions = { - "Choose your OS:": ("Windows", "Mac", "Linux"), - "Package manager:": ("Pip"), - "Compute platform:": ( - "CPU", - "CUDA 11.8 (NVIDIA GPU)", - "CUDA 12.1 (NVIDIA GPU)", - ), - } - selected_command = get_pytorch_command() - selected_preferences = [] - for question, choices in questions.items(): - input_txt = get_cli_multi_choice_question(question, choices) - while True: - answer = input(input_txt) - if answer.lower() == "q": - exit("Execution stopped by the user.") - - try: - idx = int(answer) - 1 - if idx >= len(choices): - raise TypeError("Not a valid answer") - except Exception as err: - print("-" * 100) - logger_func( - f'"{answer}" is not a valid answer.' - 'Choose one of the options or "q" to quit.' - ) - print("^" * 100) - continue - - preference = choices[idx] - selected_command = selected_command[preference] - selected_preferences.append(preference) - print("") - break - - print("-" * 100) - selected_preferences = ", ".join(selected_preferences) - logger_func(f"Selected preferences: {selected_preferences}") - print("-" * 100) - logger_func(f"Command:\n\n{selected_command}\n") - while True: - answer = input("Do you want to run the command now ([y]/n)?: ") - if answer.lower() == "n": - exit("Execution stopped by the user.") - - if answer.lower() == "y" or not answer: - break - - print("-" * 100) - print(f'"{answer}" is not a valid answer. Choose "y" for yes or "n" for no.') - print("^" * 100) - - if selected_command.startswith("conda"): - try: - subprocess.check_call([selected_command], shell=True) - except Exception as err: - cmd_list = selected_command.split() - cmd_list = [cmd.strip('"') for cmd in cmd_list] - cmd_list = [cmd.strip("'") for cmd in cmd_list] - cmd_list = [cmd.lstrip(".") for cmd in cmd_list] - subprocess.check_call(cmd_list, shell=True) - else: - cmd_list = selected_command.split()[1:] - cmd_list = [cmd.strip('"') for cmd in cmd_list] - cmd_list = [cmd.strip("'") for cmd in cmd_list] - cmd_list = [cmd.lstrip(".") for cmd in cmd_list] - subprocess.check_call([sys.executable, *cmd_list], shell=True) - - -def _get_pkg_command_pip_install( - pkg_command, - exact_version="", - max_version="", - min_version="", - including_lower_version=False, - including_higher_version=False, -): - if exact_version: - pkg_command = f"{pkg_command}=={exact_version}" - return pkg_command - - if including_higher_version: - sign_max = "<=" - else: - sign_max = "<" - if including_lower_version: - sign_min = ">=" - else: - sign_min = ">" - if min_version: - pkg_command = f"{pkg_command}{sign_min}{min_version}" - if max_version: - pkg_command = f"{pkg_command}," - - if max_version: - pkg_command = f"{pkg_command}{sign_max}{max_version}" - - return pkg_command - - -def _install_package_cli_msg( - pkg_name, - note="", - upgrade=False, - caller_name="Cell-ACDC", - logger_func=print, - pkg_command="", - exact_version="", - max_version="", - min_version="", - installer: Literal["pip", "conda"] = "pip", - include_lower_version=False, - include_higher_version=False, -): - if not pkg_command: - pkg_command = pkg_name - - pkg_command = _get_pkg_command_pip_install( - pkg_command, - exact_version=exact_version, - max_version=max_version, - min_version=min_version, - including_lower_version=include_lower_version, - including_higher_version=include_higher_version, - ) - - if upgrade: - action = "upgrade" - else: - action = "install" - - conda_prefix, pip_prefix = get_pip_conda_prefix() - - if installer == "pip": - install_command = f"{pip_prefix} --upgrade {pkg_command}" - elif installer == "conda": - install_command = f"{conda_prefix} {pkg_command}" - - separator = "-" * 60 - txt = ( - f"{separator}\n{caller_name} needs to {action} {pkg_name}\n\n" - "You can choose to install it now or stop the process and install it " - "later with the following command:\n\n" - f"{install_command}\n" - ) - logger_func(txt) - - while True: - answer = try_input_install_package(pkg_name, install_command) - if not answer or answer.lower() == "y": - return True - - if answer.lower() == "n": - return False - - logger_func( - f'{answer} is not a valid answer. Valid answers are "y" for Yes and ' - '"n" for No.' - ) - - -def _install_package_gui_msg( - pkg_name, - note="", - parent=None, - upgrade=False, - caller_name="Cell-ACDC", - pkg_command="", - logger_func=None, - exact_version="", - max_version="", - min_version="", - including_lower_version=False, - including_higher_version=False, - installer: Literal["pip", "conda"] = "pip", -): - msg = widgets.myMessageBox(parent=parent) - if upgrade: - install_text = "upgrade" - else: - install_text = "install" - if pkg_name == "BayesianTracker": - pkg_name = "btrack" - - if not pkg_command: - pkg_command = pkg_name - - pkg_command = _get_pkg_command_pip_install( - pkg_command, - exact_version=exact_version, - max_version=max_version, - min_version=min_version, - including_lower_version=including_lower_version, - including_higher_version=including_higher_version, - ) - - conda_prefix, pip_prefix = get_pip_conda_prefix() - - if installer == "pip": - command = f"{pip_prefix} --upgrade {pkg_command}" - elif installer == "conda": - command = f"{conda_prefix} {pkg_command}" - - command_html = command.lower().replace("<", "<").replace(">", ">") - - txt = html_utils.paragraph(f""" - {caller_name} is going to download and {install_text} - {pkg_name}.

    - Make sure you have an active internet connection, - before continuing.
    - Progress will be displayed on the terminal

    - You might have to restart {caller_name}.

    - Alternatively, you can cancel the process and try later.

    - To install later, or if the installation fails, run the following - command: - """) - if note: - txt = f"{txt}{note}" - _, okButton = msg.information( - parent, - f"Install {pkg_name}", - txt, - buttonsTexts=("Cancel", "Ok"), - commands=(command_html,), - ) - return msg.clickedButton == okButton - - -def _install_tensorflow(max_version="", min_version=""): - cpu = platform.processor() - pkg_command = _get_pkg_command_pip_install( - "tensorflow", max_version=max_version, min_version=min_version - ) - conda_prefix, pip_prefix = get_pip_conda_prefix() - - if is_mac and cpu == "arm": - args = [f'{conda_prefix} "{pkg_command}"'] - shell = True - else: - args = [sys.executable, "-m", "pip", "install", "-U", pkg_command] - shell = False - subprocess.check_call(args, shell=shell) - - # purge numpy - purge_module("numpy") - - -def _install_segment_anything(): - args = [ - sys.executable, - "-m", - "pip", - "install", - "-U", - "--use-pep517", - "git+https://github.com/facebookresearch/segment-anything.git", - ] - subprocess.check_call(args) - - -def _install_sam2(): - args = [ - sys.executable, - "-m", - "pip", - "install", - "-U", - "--use-pep517", - "git+https://github.com/facebookresearch/sam2.git", - ] - subprocess.check_call(args) - - -def _install_deepsea(): - subprocess.check_call([sys.executable, "-m", "pip", "install", "deepsea"]) - - -def import_tracker_module(tracker_name): - module_name = f"cellacdc.trackers.{tracker_name}.{tracker_name}_tracker" - tracker_module = import_module(module_name) - return tracker_module - - -def download_ffmpeg(): - ffmpeg_folderpath = acdc_ffmpeg_path - if is_win: - url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/rXioWZpwjwn9JTT/download/windows_ffmpeg-7.0-full_build.zip" - file_size = 173477888 - ffmep_exec_path = os.path.join(ffmpeg_folderpath, "bin", "ffmpeg.exe") - elif is_mac: - url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/We7rcTLzqAP4zf7/download/mac_ffmpeg.zip" - file_size = 25288704 - ffmep_exec_path = os.path.join(ffmpeg_folderpath, "ffmpeg") - elif is_linux: - ffmep_exec_path = "" - return ffmep_exec_path - - if os.path.exists(ffmep_exec_path): - return ffmep_exec_path.replace("\\", os.sep).replace("/", os.sep) - - print("Downloading FFMPEG...") - temp_dir = tempfile.mkdtemp() - temp_zip_path = os.path.join(temp_dir, "acdc-ffmpeg.zip") - - download_url( - url, - temp_zip_path, - verbose=True, - file_size=file_size, - ) - extract_zip(temp_zip_path, ffmpeg_folderpath) - - return ffmep_exec_path.replace("\\", os.sep).replace("/", os.sep) - - -def get_fiji_binary_filepath_mac(fiji_app_filepath): - if not is_mac: - return "" - - fiji_binary_path = os.path.join( - fiji_app_filepath, "Contents", "MacOS", "ImageJ-macosx" - ) - if os.path.exists(fiji_binary_path): - return fiji_binary_path - - fiji_binary_path = os.path.join( - fiji_app_filepath, "Contents", "MacOS", "fiji-macos" - ) - if os.path.exists(fiji_binary_path): - return fiji_binary_path - - return "" - - -def get_fiji_exec_folderpath() -> str: - if not is_mac: - return "" - - from cellacdc import fiji_location_filepath - - if os.path.exists(fiji_location_filepath): - with open(fiji_location_filepath, "r") as txt: - fiji_app_filepath = txt.read() - - return get_fiji_binary_filepath_mac(fiji_app_filepath) - - if os.path.exists("/Applications/Fiji.app"): - return get_fiji_binary_filepath_mac("/Applications/Fiji.app") - - acdc_fiji_app_path = os.path.join(acdc_fiji_path, "Fiji.app") - acdc_fiji_binary_path = get_fiji_binary_filepath_mac(acdc_fiji_app_path) - - return acdc_fiji_binary_path - - -def get_fiji_base_command(): - command = None - if is_mac: - command = get_fiji_exec_folderpath() - - return command - - -def _init_fiji_cli(): - if is_win: - return True - - fiji_app_folderpath = get_fiji_exec_folderpath() - args_add_to_path = [f"chmod 755 {fiji_app_folderpath}"] - try: - subprocess.check_call(args_add_to_path, shell=True) - return True - except Exception as e: - printl(f"Error occurred while setting permissions: {e}") - return False - - -def test_fiji_base_command(logger_func=print): - base_command = get_fiji_base_command() - - if base_command is None: - logger_func("[WARNING]: Fiji is not present.") - return False - - command = f"{base_command} --headless" - return run_fiji_command(command=command, logger_func=logger_func) - - -def run_fiji_command(command=None, logger_func=print): - if command is None: - command = f"{get_fiji_base_command()} --headless" - - init_success = _init_fiji_cli() - if not init_success: - return False - - separator = "-" * 100 - commands = (command, command.split()) - for args in commands: - logger_func(f'{separator}\nTrying Fiji command: "{args}"...\n{separator}\n') - try: - subprocess.check_call(args, shell=True) - return True - except Exception as err: - continue - return False - - -def import_promptable_segment_module(model_name): - try: - acdcPromptSegment = import_module( - f"cellacdc.segmenters_promptable.{model_name}.acdcPromptSegment" - ) - except ModuleNotFoundError as e: - # Check if custom model - cp = config.ConfigParser() - cp.read(promptable_models_list_file_path) - model_path = cp[model_name]["path"] - spec = importlib.util.spec_from_file_location("acdcPromptSegment", model_path) - acdcPromptSegment = importlib.util.module_from_spec(spec) - sys.modules["acdcPromptSegment"] = acdcPromptSegment - spec.loader.exec_module(acdcPromptSegment) - return acdcPromptSegment - - -def init_tracker( - posData, trackerName, realTime=False, qparent=None, return_init_params=False -): - from . import apps - - downloadWin = apps.downloadModel(trackerName, parent=qparent) - downloadWin.download() - - trackerModule = import_tracker_module(trackerName) - init_params = {} - track_params = {} - paramsWin = None - if trackerName == "BayesianTracker": - Y, X = posData.img_data_shape[-2:] - if posData.isSegm3D: - labShape = (posData.SizeZ, Y, X) - else: - labShape = (1, Y, X) - paramsWin = apps.BayesianTrackerParamsWin( - labShape, - parent=qparent, - channels=posData.chNames, - currentChannelName=posData.user_ch_name, - ) - paramsWin.exec_() - if not paramsWin.cancel: - init_params = paramsWin.params - track_params["export_to"] = posData.get_btrack_export_path() - if paramsWin.intensityImageChannel is not None: - chName = paramsWin.intensityImageChannel - track_params["image"] = posData.loadChannelData(chName) - track_params["image_channel_name"] = chName - elif trackerName == "CellACDC": - paramsWin = apps.CellACDCTrackerParamsWin(parent=qparent) - paramsWin.exec_() - if not paramsWin.cancel: - init_params = paramsWin.params - elif trackerName == "delta": - paramsWin = apps.DeltaTrackerParamsWin(posData=posData, parent=qparent) - paramsWin.exec_() - if not paramsWin.cancel: - init_params = paramsWin.params - else: - init_argspecs, track_argspecs = getTrackerArgSpec( - trackerModule, realTime=realTime - ) - intensityImgRequiredForTracker = isIntensityImgRequiredForTracker(trackerModule) - if init_argspecs or track_argspecs: - try: - url = trackerModule.url_help() - except AttributeError: - url = None - try: - channels = posData.chNames - except Exception as e: - channels = None - try: - currentChannelName = posData.user_ch_name - except Exception as e: - currentChannelName = None - try: - df_metadata = posData.metadata_df - except Exception as e: - df_metadata = None - - if not intensityImgRequiredForTracker: - currentChannelName = None - - paramsWin = apps.QDialogModelParams( - init_argspecs, - track_argspecs, - trackerName, - url=url, - channels=channels, - is_tracker=True, - currentChannelName=currentChannelName, - df_metadata=df_metadata, - posData=posData, - ) - if not intensityImgRequiredForTracker and channels is not None: - paramsWin.channelCombobox.setDisabled(True) - - paramsWin.exec_() - if not paramsWin.cancel: - init_params = paramsWin.init_kwargs - track_params = paramsWin.model_kwargs - if paramsWin.inputChannelName != "None": - chName = paramsWin.inputChannelName - track_params["image"] = posData.loadChannelData(chName) - track_params["image_channel_name"] = chName - if "export_to_extension" in track_params: - ext = track_params["export_to_extension"] - track_params["export_to"] = posData.get_tracker_export_path( - trackerName, ext - ) - - if paramsWin is not None and paramsWin.cancel: - tracker = (None,) - track_params = None - init_params = None - else: - tracker = trackerModule.tracker(**init_params) - - if return_init_params: - return tracker, track_params, init_params - else: - return tracker, track_params - - -def import_segment_module(model_name): - try: - acdcSegment = import_module(f"cellacdc.segmenters.{model_name}.acdcSegment") - except ModuleNotFoundError as e: - # Check if custom model - cp = config.ConfigParser() - cp.read(models_list_file_path) - model_path = cp[model_name]["path"] - spec = importlib.util.spec_from_file_location("acdcSegment", model_path) - acdcSegment = importlib.util.module_from_spec(spec) - sys.modules["acdcSegment"] = acdcSegment - spec.loader.exec_module(acdcSegment) - return acdcSegment - - -def get_pip_conda_prefix(list_return=False): - from .config import parser_args - - try: - cp = parser_args - if cp["install_details"] is not None: - no_cli_install = True - install_details = cp["install_details"] - venv_path = install_details["venv_path"] - conda_path = install_details["conda_path"] - if " " not in conda_path: - conda_path = conda_path.strip('"').strip("'") - else: - no_cli_install = False - except: - no_cli_install = False - pass - - if no_cli_install: - conda_prefix = f"{conda_path} install -y -p {venv_path} -c conda-forge" - exec_path = sys.executable - if " " in exec_path: - exec_path = f'"{exec_path}"' - pip_prefix = f"{exec_path} -m pip install" - else: - conda_prefix = "conda install -y -c conda-forge" - pip_prefix = "pip install" - - pip_list = [sys.executable, "-m", "pip", "install"] - if no_cli_install: - conda_list = [ - conda_path.strip('"').strip("'"), - "install", - "-y", - "-p", - venv_path.strip('"').strip("'"), - "-c", - "conda-forge", - ] - else: - conda_list = ["conda", "install", "-y", "-c", "conda-forge"] - if list_return: - return conda_list, pip_list - else: - return conda_prefix, pip_prefix - - -def _warn_install_gpu(model_name, ask_installs, qparent=None): - - cellpose_cuda_url = ( - r"https://github.com/mouseland/cellpose#gpu-version-cuda-on-windows-or-linux" - ) - torch_cuda_url = r"https://pytorch.org/get-started/locally/" - direct_ml_url = r"https://microsoft.github.io/DirectML/" - torch_directml_url = ( - r"https://learn.microsoft.com/en-us/windows/ai/directml/pytorch-windows" - ) - - cellpose_href = f"{html_utils.href_tag('here', cellpose_cuda_url)}" - torch_href = f"{html_utils.href_tag('here', torch_cuda_url)}" - direct_ml_href = f"{html_utils.href_tag('direct_ml_DirectMLref', direct_ml_url)}" - torch_directml_href = ( - f"{html_utils.href_tag('directml pytorch', torch_directml_url)}" - ) - - conda_prefix, pip_prefix = get_pip_conda_prefix() - - msg = widgets.myMessageBox(showCentered=False, wrapText=False) - txt = html_utils.paragraph(f""" - In order to use {model_name} with the GPU you need - to install a PyTorch version which can use it.
    - We recomment using CUDA over DirectML, but if you are using a Windows - machine with an AMD GPU, you can use DirectML.
    - """) - txt_cuda_title = html_utils.paragraph(f"CUDA", font_size="18px") - - pip_prefix = pip_prefix.replace("install -y", "uninstall") - txt_cuda = html_utils.paragraph(f""" - Check out these instructions {cellpose_href}, and {torch_href}.
    - First, uninstall the CPU version of PyTorch with the following command: - {pip_prefix} uninstall torch -
    Then, install the CUDA version required by your GPU with the following - command (in this case 12.8): - {pip_prefix} torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128 -
    - """) - - add_info = html_utils.to_admonition( - f""" - Pleae use the following table to find the correct link for the command. - You can check the highest CUDA
    version supported on your system with the - command nvidia-smi in the terminal.
    - - {html_utils.table_style_header} - - CUDA Version - PyTorch Installation Link - - - CUDA 11.8 - https://download.pytorch.org/whl/cu118 - - - CUDA 12.6 - https://download.pytorch.org/whl/cu126 - - - CUDA 12.8 - https://download.pytorch.org/whl/cu128 - - - """, - "info", - ) - - txt_cuda = f"{txt_cuda}{add_info}" - - txt_directML_title = html_utils.paragraph(f"DirectML", font_size="18px") - txt_directML = html_utils.paragraph(f""" - Check out {direct_ml_href}, and {torch_directml_href} for more info.
    - Only supported on Windows 10/11 with Python 3.8-3.12.
    - Click the Install DirectML button to install DirectML. -

    - """) - - txt_end = html_utils.paragraph(f""" - How do you want to proceed? - """) - - stopButton = widgets.cancelPushButton("Stop the process") - directMLButton = widgets.okPushButton("Install DirectML") - proceedButton = widgets.okPushButton("Proceed without GPU") - - buttons = [stopButton] - - if "cuda" in ask_installs: - txt = f"{txt}{txt_cuda_title}{txt_cuda}" - if "directML" in ask_installs: - txt = f"{txt}{txt_directML_title}{txt_directML}" - buttons.append(directMLButton) - txt = f"{txt}{txt_end}" - buttons.append(proceedButton) - - msg.warning( - qparent, - "PyTorch GPU version not installed", - txt, - buttonsTexts=buttons, - ) - - if msg.cancel: - return False, False - - if msg.clickedButton == directMLButton: - py_ver = sys.version_info - if is_win and py_ver.major == 3 and py_ver.minor < 13: - success = check_install_package( - pkg_name="torch-directml", - import_pkg_name="torch_directml", - pypi_name="torch-directml", - return_outcome=True, - ) - purge_module("torch") - return success, True - else: - msg = widgets.myMessageBox() - msg.warning( - qparent, - "DirectML not supported", - "DirectML is only supported on Python 3.8-3.12 and Windows 10/11", - ) - return False, False - - if msg.clickedButton == stopButton: - return False, False - - if msg.clickedButton == proceedButton: - return True, False - - -def check_gpu_requested_segm_model(init_kwargs): - gpu = init_kwargs.get("gpu", False) - if gpu: - return True - - device_type = init_kwargs.get("device_type", "cpu") - return device_type == "gpu" or device_type == "" - - -def check_gpu_available( - model_name, - use_gpu, - do_not_warn=False, - qparent=None, - cuda=False, - directML=False, - return_available_gpu_type=False, -): - if not use_gpu: - if return_available_gpu_type: - return True, [] - else: - return True - - ask_for_cuda = False - if cuda: - try: - import torch - - if not torch.cuda.is_available(): - ask_for_cuda = True - if not torch.cuda.device_count() > 0: - ask_for_cuda = True - except ModuleNotFoundError: - ask_for_cuda = True - - ask_for_directML = False - if directML: - if is_win: - try: - import torch_directml - - if not torch_directml.is_available(): - ask_for_directML = True - except ModuleNotFoundError: - ask_for_directML = True - - frameworks = _available_frameworks(model_name) - ask_installs = set() if not ask_for_cuda else {"cuda"} - ask_installs.update({"directML"} if ask_for_directML else set()) - framework_available = False - available_frameworks_list = [] - for framework, model_compatible in frameworks.items(): - if not model_compatible: - continue - if framework == "cuda": - import torch - - if not torch.cuda.is_available(): - ask_installs.add("cuda") - elif not torch.cuda.device_count() > 0: - ask_installs.add("cuda") - else: - framework_available = True - available_frameworks_list.append("cuda") - elif framework == "directML": - if is_win: - try: - import torch_directml - - if not torch_directml.is_available(): - ask_installs.add("directML") - else: - framework_available = True - available_frameworks_list.append("directML") - except ModuleNotFoundError: - ask_installs.add("directML") - elif is_mac_arm64: - framework_available = True - break - - if framework_available and not ask_for_cuda and not ask_for_directML: - if return_available_gpu_type: - return True, available_frameworks_list - else: - return True - - elif do_not_warn: - if return_available_gpu_type: - return False, available_frameworks_list - else: - return False - - proceed, directML_installed = _warn_install_gpu( - model_name, ask_installs, qparent=qparent - ) - if return_available_gpu_type: - if directML_installed: - available_frameworks_list.append("directML") - return proceed, available_frameworks_list - else: - return proceed - - -def _available_frameworks(model_name): - frameworks = { - "cuda": ( - model_name.lower().find("cellpose") != -1 - or model_name.lower().find("omnipose") != -1 - or model_name.lower().find("deepsea") != -1 - or model_name.lower().find("segment_anything") != -1 - or model_name.lower().find("sam2") != -1 - or model_name.lower().find("yeaz") != -1 - or model_name.lower().find("yeaz_v2") != -1 - ), - "directML": ( - model_name.lower().find("cellpose_v4") != -1 - or model_name.lower().find("cellpose_v3") != -1 # has its own way to check - ), - } - return frameworks - - -def find_missing_integers(lst, max_range=None): - if max_range is not None: - max_range = lst[-1] + 1 - return [x for x in range(lst[0], max_range) if x not in lst] - - -def synthetic_image_geneator(size=(512, 512), f_x=1, f_y=1): - Y, X = size - x = np.linspace(0, 10, Y) - y = np.linspace(0, 10, X) - xx, yy = np.meshgrid(x, y) - img = np.sin(f_x * xx) * np.cos(f_y * yy) - return img - - -def get_show_in_file_manager_text(): - if is_mac: - return "Reveal in Finder" - elif is_linux: - return "Show in File Manager" - elif is_win: - return "Show in File Explorer" - - -def get_slices_local_into_global_arr(bbox_coords, global_shape): - slice_global_to_local = [] - slice_crop_local = [] - for (_min, _max), _D in zip(bbox_coords, global_shape): - _min_crop, _max_crop = None, None - if _min < 0: - _min_crop = abs(_min) - _min = 0 - if _max > _D: - _max_crop = _D - _max - _max = _D - - slice_global_to_local.append(slice(_min, _max)) - slice_crop_local.append(slice(_min_crop, _max_crop)) - - return tuple(slice_global_to_local), tuple(slice_crop_local) - - -def get_pip_install_cellacdc_version_command(version=None): - conda_prefix, pip_prefix = get_pip_conda_prefix() - - if version is None: - version = read_version() - commit_hash_idx = version.find("+g") - is_dev_version = commit_hash_idx > 0 - if is_dev_version: - commit_hash = version[commit_hash_idx + 2 :].split(".")[0] - command = f'{pip_prefix} --upgrade "git+{github_home_url}.git@{commit_hash}"' - command_github = None - else: - command = f"{pip_prefix} --upgrade cellacdc=={version}" - command_github = f'{pip_prefix} --upgrade "git+{urls.github_url}@{version}"' - return command, command_github - - -def get_git_pull_checkout_cellacdc_version_commands(version=None): - if version is None: - version = read_version() - commit_hash_idx = version.find("+g") - is_dev_version = commit_hash_idx > 0 - if not is_dev_version: - return [] - commit_hash = version[commit_hash_idx + 2 :].split(".")[0] - commands = ( - f'cd "{os.path.dirname(cellacdc_path)}"', - "git pull", - f"git checkout {commit_hash}", - ) - return commands - - -def check_install_tapir(): - check_install_package( - "tapnet", pypi_name="git+https://github.com/ElpadoCan/TAPIR.git" - ) - - -def _download_tapir_model(): - urls, file_sizes = _model_url("TAPIR") - temp_model_path = tempfile.mkdtemp() - _, final_model_path = get_model_path("TAPIR", create_temp_dir=False) - for url, file_size in zip(urls, file_sizes): - filename = url.split("/")[-1] - final_dst = os.path.join(final_model_path, filename) - if os.path.exists(final_dst): - continue - - temp_dst = os.path.join(temp_model_path, filename) - download_url(url, temp_dst, file_size=file_size, desc="TAPIR", verbose=False) - - shutil.move(temp_dst, final_dst) - - -def _download_yeaz_models(): - urls, file_sizes = _model_url("YeaZ_v2") - temp_model_path = tempfile.mkdtemp() - _, final_model_path = get_model_path("YeaZ_v2", create_temp_dir=False) - for url, file_size in zip(urls, file_sizes): - filename = url.split("/")[-1] - final_dst = os.path.join(final_model_path, filename) - if os.path.exists(final_dst): - continue - - temp_dst = os.path.join(temp_model_path, filename) - download_url(url, temp_dst, file_size=file_size, desc="YeaZ_v2", verbose=False) - - shutil.move(temp_dst, final_dst) - - -def _download_cellpose_germlineNuclei_model(): - urls, file_sizes = _model_url("Cellpose_germlineNuclei") - temp_model_path = tempfile.mkdtemp() - _, final_model_path = get_model_path( - "Cellpose_germlineNuclei", create_temp_dir=False - ) - for url, file_size in zip(urls, file_sizes): - filename = url.split("/")[-1] - final_dst = os.path.join(final_model_path, filename) - if os.path.exists(final_dst): - continue - - temp_dst = os.path.join(temp_model_path, filename) - download_url( - url, - temp_dst, - file_size=file_size, - desc="Cellpose_germlineNuclei", - verbose=False, - ) - - shutil.move(temp_dst, final_dst) - - -def _download_omnipose_models(): - urls, file_sizes = _model_url("omnipose") - temp_model_path = tempfile.mkdtemp() - final_model_path = os.path.expanduser(r"~\.cellpose\models") - for url, file_size in zip(urls, file_sizes): - filename = url.split("/")[-1] - final_dst = os.path.join(final_model_path, filename) - if os.path.exists(final_dst): - continue - - temp_dst = os.path.join(temp_model_path, filename) - download_url(url, temp_dst, file_size=file_size, desc="omnipose", verbose=False) - - shutil.move(temp_dst, final_dst) - - -def format_cca_manual_changes(changes: dict): - txt = "" - for ID, changes_ID in changes.items(): - txt = f"{txt}* ID {ID}:\n" - for col, (old_val, new_val) in changes_ID.items(): - txt = f"{txt} - {col}: {old_val} --> {new_val}\n" - txt = f"{txt}--------------------------------\n\n" - return txt - - -def init_prompt_segm_model(acdcPromptSegment, posData, init_kwargs): - model = acdcPromptSegment.Model(**init_kwargs) - return model - - -def init_segm_model(acdcSegment, posData, init_kwargs): - segm_endname = init_kwargs.pop("segm_endname", "None") - if segm_endname != "None": - load_segm = True - if not hasattr(posData, "segm_data"): - load_segm = True - elif posData.segm_npz_path.endswith(f"{segm_endname}.npz"): - load_segm = False - if not load_segm: - segm_data = np.squeeze(posData.segm_data) - else: - segm_filepath, _ = load.get_path_from_endname( - segm_endname, posData.images_path - ) - printl(f'Loading segmentation data from "{segm_filepath}"...') - segm_data = np.load(segm_filepath)["arr_0"] - else: - segm_data = None - - # Initialize input_points_df for models promptable with points - input_points_filepath = init_kwargs.pop("input_points_path", "") - if input_points_filepath: - input_points_df = init_input_points_df(posData, input_points_filepath) - init_kwargs["input_points_df"] = input_points_df - - try: - # Models introduced before 1.3.2 do not have the segm_data as input - kwargs = inspect.getfullargspec(acdcSegment.Model.__init__).args - if "is_rgb" not in kwargs and "is_rgb" in init_kwargs: - del init_kwargs["is_rgb"] - model = acdcSegment.Model(**init_kwargs) - - except Exception as e: - model = acdcSegment.Model(segm_data, **init_kwargs) - - if hasattr(model, "init_successful"): - if not model.init_successful: - return None - return model - - -def _parse_bool_str(value): - if isinstance(value, bool): - return value - - if value == "True": - return True - elif value == "False": - return False - - -def check_install_trackastra(): - check_install_package( - "Trackastra", import_pkg_name="trackastra", pypi_name="trackastra" - ) - - -def get_torch_device(gpu=False): - import torch - - if torch.cuda.is_available() and gpu: - device = torch.device("cuda") - elif torch.backends.mps.is_available(): - device = torch.device("mps") - else: - device = torch.device("cpu") - return device - - -def parse_model_params(model_argspecs, model_params): - parsed_model_params = {} - for row, argspec in enumerate(model_argspecs): - value = model_params.get(argspec.name) - if value is None: - continue - if argspec.type == bool: - value = _parse_bool_str(value) - elif argspec.type == int: - value = int(value) - elif argspec.type == float: - value = float(value) - parsed_model_params[argspec.name] = value - return parsed_model_params - - -# def init_cellpose_denoise_model(): -# from . import apps - -# from cellacdc.models.cellpose_v3._denoise import ( -# CellposeDenoiseModel, url_help -# ) - -# init_argspecs, run_argspecs = getClassArgSpecs(CellposeDenoiseModel) -# url = url_help() - -# paramsWin = apps.QDialogModelParams( -# init_argspecs, run_argspecs, 'Cellpose 3.0', -# url=url, is_tracker=True, action_type='denoising' -# ) -# paramsWin.exec_() -# if paramsWin.cancel: -# return - -# init_params = paramsWin.init_kwargs -# run_params = paramsWin.model_kwargs -# denoise_model = CellposeDenoiseModel(**init_params) -# return denoise_model, init_params, run_params - - -def init_input_points_df(posData, input_points_filepath): - input_points_df = None - if os.path.exists(input_points_filepath): - input_points_df = pd.read_csv(input_points_filepath) - else: - # input_points_filepath is actually and endname - for file in listdir(posData.images_path): - if file.endswith(input_points_filepath): - filepath = os.path.join(posData.images_path, file) - input_points_df = pd.read_csv(filepath) - break - - if input_points_df is None: - raise FileNotFoundError( - f'Could not find input points table from file "input_points_filepath" ' - "Perhaps, you forgot to save the table?" - ) - - for col in ("x", "y", "id"): - if col not in input_points_df.columns: - raise KeyError( - f"Input points table is missing colum {col}. It must have " - "the colums (x, y, id)" - ) - - return input_points_df - - -def are_acdc_dfs_equal(df_left, df_right): - if df_left.shape != df_right.shape: - return False - - try: - for col in df_left.columns: - if col not in df_right.columns: - return False - - try: - eq_mask = np.isclose(df_left[col], df_right[col], equal_nan=True) - except Exception as err: - # Data type is string - eq_mask = df_left[col] == df_right[col] - - nan_mask = (df_left[col].isna()) & (df_right[col].isna()) - equality_mask = (eq_mask) | (nan_mask) - if not equality_mask.all(): - return False - except Exception as err: - return False - - return True - - -def is_pos_folderpath(folderpath): - """Determine if a path is a valid Cell-ACDC Position folder - - Parameters - ---------- - folderpath : PathLike - Path to check - - Returns - ------- - bool - True if the path is a valid Cell-ACDC Position folder, False otherwise - - Notes - ----- - A valid Cell-ACDC Position folder must: - - Have a name matching the pattern 'Position_' - - Be a directory - - Contain an 'Images' subdirectory - - The 'Images' subdirectory must not be empty - """ - foldername = os.path.basename(folderpath) - is_valid_pos_folder = ( - re.search(r"^Position_(\d+)$", foldername) is not None - and os.path.isdir(folderpath) - and os.path.exists(os.path.join(folderpath, "Images")) - and listdir(os.path.join(folderpath, "Images")) - ) - return is_valid_pos_folder - - -def log_segm_params( - model_name, - init_params, - segm_params, - logger_func=print, - preproc_recipe=None, - apply_post_process=False, - standard_postprocess_kwargs=None, - custom_postprocess_features=None, -): - init_params_format = [ - f" * {option} = {value}" for option, value in init_params.items() - ] - init_params_format = "\n".join(init_params_format) - - segm_params_format = [ - f" * {option} = {value}" for option, value in segm_params.items() - ] - segm_params_format = "\n".join(segm_params_format) - - preproc_recipe_format = None - if preproc_recipe is not None: - preproc_recipe_format = [] - for s, step in enumerate(preproc_recipe): - preproc_recipe_format.append(f" * Step {s + 1}") - method = step["method"] - preproc_recipe_format.append(f" - Method: {method}") - for option, value in step["kwargs"].items(): - preproc_recipe_format.append(f" - {option}: {value}") - preproc_recipe_format = "\n".join(preproc_recipe_format) - - standard_postproc_format = None - if apply_post_process and standard_postprocess_kwargs is not None: - standard_postproc_format = [ - f" * {option} = {value}" - for option, value in standard_postprocess_kwargs.items() - ] - standard_postproc_format = "\n".join(standard_postproc_format) - - custom_postproc_format = None - if apply_post_process and custom_postprocess_features is not None: - custom_postproc_format = [ - f" * {feature} = ({low}, {high})" - for feature, (low, high) in custom_postprocess_features.items() - ] - custom_postproc_format = "\n".join(custom_postproc_format) - - separator = "-" * 100 - params_format = ( - f"{separator}\n" - f"Model name: {model_name}\n\n" - "Preprocessing recipe:\n\n" - f"{preproc_recipe_format}\n\n" - "Initialization parameters:\n\n" - f"{init_params_format}\n\n" - "Segmentation parameters:\n\n" - f"{segm_params_format}\n\n" - "Post-processing:\n\n" - f"{standard_postproc_format}\n\n" - "Custom post-processing:\n\n" - f"{custom_postproc_format}\n" - f"{separator}" - ) - logger_func(params_format) - - -def pairwise(iterable): - # pairwise('ABCDEFG') → AB BC CD DE EF FG - iterator = iter(iterable) - a = next(iterator, None) - for b in iterator: - yield a, b - a = b - - -def append_text_filename(filename: str, text_to_append: str): - filename_noext, ext = os.path.splitext(filename) - filename_out = f"{filename_noext}{text_to_append}{ext}" - return filename_out - - -def validate_images_path(input_path: os.PathLike, create_dirs_tree=False): - is_images_path = input_path.endswith("Images") - parent_dir = os.path.dirname(input_path) - parent_foldername = os.path.basename(parent_dir) - is_pos_folder = re.search( - r"^Position_(\d+)$", parent_foldername - ) is not None and os.path.isdir(parent_dir) - if not is_pos_folder: - existing_pos_foldernames = get_pos_foldernames(input_path) - pos_n = len(existing_pos_foldernames) + 1 - pos_folderpath = os.path.join(input_path, f"Position_{pos_n}") - images_path = os.path.join(pos_folderpath, "Images") - elif is_images_path: - pos_folderpath = input_path - images_path = os.path.join(pos_folderpath, "Images") - else: - images_path = input_path - - if create_dirs_tree: - os.makedirs(images_path, exist_ok=True) - - return images_path - - -def fix_acdc_df_dtypes(acdc_df): - acdc_df["is_cell_excluded"] = acdc_df["is_cell_excluded"].astype(bool) - return acdc_df - - -def _relabel_cca_dfs_and_segm_data( - cca_dfs, - IDs_mapper, - asymm_tracked_segm, - progressbar=True, -): - # Rename Cell_ID index according to asymmetric cell div convention - if progressbar: - pbar = tqdm( - desc="Applying asymmetric division", total=len(IDs_mapper), ncols=100 - ) - for key, (root_ID, parent_ID) in IDs_mapper.items(): - div_frame_i, daughter_ID = key - for frame_i in range(div_frame_i, len(asymm_tracked_segm)): - lab = asymm_tracked_segm[frame_i] - rp = skimage.measure.regionprops(lab) - rp_mapper = {obj.label: obj for obj in rp} - obj_daught = rp_mapper.get(daughter_ID) - mother_ID = root_ID if rp_mapper.get(root_ID) is None else parent_ID - - cca_dfs[frame_i].rename(index={daughter_ID: mother_ID}, inplace=True) - - if obj_daught is None: - continue - - lab[obj_daught.slice][obj_daught.image] = mother_ID - - if progressbar: - pbar.update() - - if progressbar: - pbar.close() - - -def df_ctc_to_acdc_df( - df_ctc, - tracked_segm, - cell_division_mode="Normal", - return_list=False, - progressbar=True, -): - """Convert Cell Tracking Challenge DataFrame with annotated division to - Cell-ACDC cell cycle annotations DataFrame. - - Parameters - ---------- - df_ctc : pd.DataFrame - DataFrame with {'label', 't1', 't2', 'parent'} columns where - 't1' is the frame index of cell division. - tracked_segm : (T, Y, X) array of ints - Array of tracked segmentation labels. - cell_division_mode : {'Normal', 'Asymmetric'}, optional - Type of cell division. `Normal` is the standard cell division, - where the mother cell divides into two daughter cells. For the - tracking, that means the two daughter cells get a new, unique ID - each. - - `Asymmetric` means that the mother cell grows one daughter - cell that eventually divides from the mother (e.g., budding yeast). - For the tracking, this means that the mother cell ID keeps - existing after division and the daughter cell gets a new, unique ID. - - If `Asymmetric`, the third returned element is the segmentation data - with the asymmetric Cell IDs. - return_list : bool, optional - If `True`, the second returned element is the list of created dataframes, - one per frame. Default is False - progressbar : bool, optional - If `True`, displays a tqdm progressbar. Default is True - """ - cca_dfs = [] - keys = [] - df_ctc = df_ctc.set_index(["t1", "parent"]) - - if cell_division_mode == "Asymmetric": - asymm_tracked_segm = tracked_segm.copy() - - asymmetric_IDs_rename_mapper = {} - if progressbar: - pbar = tqdm( - desc="Converting to Cell-ACDC format", total=len(tracked_segm), ncols=100 - ) - for frame_i, lab in enumerate(tracked_segm): - rp = skimage.measure.regionprops(lab) - IDs = [obj.label for obj in rp] - cca_df = core.getBaseCca_df(IDs, with_tree_cols=True) - keys.append(frame_i) - if frame_i == 0: - cca_dfs.append(cca_df) - if progressbar: - pbar.update() - continue - - # Copy annotations from previous frames - prev_cca_df = cca_dfs[frame_i - 1] - old_IDs = cca_df.index.intersection(prev_cca_df.index) - cca_df.loc[old_IDs] = prev_cca_df.loc[old_IDs] - - try: - df_ctc_i = df_ctc.loc[frame_i] - except KeyError as err: - # No division detected --> nothing to annotate - cca_dfs.append(cca_df) - if progressbar: - pbar.update() - continue - - for parent_ID, df_ctc_i_pID in df_ctc_i.groupby(level=0): - daughter_IDs = df_ctc_i_pID["label"].to_list() - - if parent_ID == 0: - continue - - cca_df.loc[daughter_IDs, "parent_ID_tree"] = parent_ID - cca_df.loc[daughter_IDs, "emerg_frame_i"] = frame_i - cca_df.loc[daughter_IDs, "division_frame_i"] = frame_i - - root_ID = prev_cca_df.at[parent_ID, "root_ID_tree"] - if root_ID == -1: - root_ID = parent_ID - cca_df.loc[daughter_IDs, "root_ID_tree"] = root_ID - - cca_df.loc[daughter_IDs[0], "sister_ID_tree"] = daughter_IDs[1] - cca_df.loc[daughter_IDs[1], "sister_ID_tree"] = daughter_IDs[0] - - prev_gen_num = prev_cca_df.loc[parent_ID, "generation_num_tree"] - cca_df.loc[daughter_IDs, "generation_num_tree"] = prev_gen_num + 1 - - # Annotate division from df_ctc_i into - if cell_division_mode == "Asymmetric": - # Recycle the root_ID and assign it to one of the daughters - replaced_daught_ID = daughter_IDs[1] - key = (frame_i, replaced_daught_ID) - asymmetric_IDs_rename_mapper[key] = (root_ID, parent_ID) - - cca_dfs.append(cca_df) - - if progressbar: - pbar.update() - - if progressbar: - pbar.close() - - if asymmetric_IDs_rename_mapper: - _relabel_cca_dfs_and_segm_data( - cca_dfs, - asymmetric_IDs_rename_mapper, - asymm_tracked_segm, - progressbar=True, - ) - - cca_df = pd.concat(cca_dfs, keys=keys, names=["frame_i"]) - - out = [cca_df, None, None] - - if return_list: - out[1] = cca_dfs - - if cell_division_mode == "Asymmetric": - out[2] = asymm_tracked_segm - - return out - - -def check_install_instanseg(): - check_install_package( - pkg_name="InstanSeg", import_pkg_name="instanseg", pypi_name="instanseg-torch" - ) - - -def validate_tracker_input(tracker, segm_video_to_track): - try: - warning_text = tracker.validate_input(segm_video_to_track) - return warning_text - except Exception as err: - printl(traceback.format_exc()) - pass - return - - -def format_IDs(IDs): - if isinstance(IDs, str): - raise ValueError("IDs must not be a string") - - IDsRange = [] - text = "" - sorted_vals = sorted(IDs) - for i, e in enumerate(sorted_vals): - e = int(e) - # Get previous and next value (if possible) - if i > 0: - prevVal = sorted_vals[i - 1] - else: - prevVal = -1 - if i < len(sorted_vals) - 1: - nextVal = sorted_vals[i + 1] - else: - nextVal = -1 - - if e - prevVal == 1 or nextVal - e == 1: - if not IDsRange: - if nextVal - e == 1 and e - prevVal != 1: - # Current value is the first value of a new range - IDsRange = [e] - else: - # Current value is the second element of a new range - IDsRange = [prevVal, e] - else: - if e - prevVal == 1: - # Current value is part of an ongoing range - IDsRange.append(e) - else: - # Current value is the first element of a new range - # --> create range text and this element will - # be added to the new range at the next iter - start, stop = IDsRange[0], IDsRange[-1] - if stop - start > 1: - sep = "-" - else: - sep = "," - text = f"{text},{start}{sep}{stop}" - IDsRange = [] - else: - # Current value doesn't belong to a range - if IDsRange: - # There was a range not added to text --> add it now - start, stop = IDsRange[0], IDsRange[-1] - if stop - start > 1: - sep = "-" - else: - sep = "," - text = f"{text},{start}{sep}{stop}" - - text = f"{text},{e}" - IDsRange = [] - - if IDsRange: - # Last range was not added --> add it now - start, stop = IDsRange[0], IDsRange[-1] - text = f"{text},{start}-{stop}" - - text = text[1:] - - return text - - -def get_empty_stored_data_dict(): - return { - "regionprops": None, - "labels": None, - "acdc_df": None, - "delROIs_info": {"rois": [], "delMasks": [], "delIDsROI": [], "state": []}, - "IDs": [], - "manually_edited_lab": {"lab": {}, "zoom_slice": None}, - } - - -def iterate_along_axes(arr, axes, arr_ndim=None): - if arr_ndim is None: - arr_ndim = arr.ndim - axes = list(axes) - front_axes = axes + [i for i in range(arr_ndim) if i not in axes] - arr_moved = np.moveaxis(arr, front_axes, range(arr_ndim)) - iter_shape = arr_moved.shape[: len(axes)] - for idx in np.ndindex(iter_shape): - # Build the index for the original array - full_idx = [slice(None)] * arr_ndim - for axis, i in zip(axes, idx): - full_idx[axis] = i - yield tuple(full_idx) - - -def get_input_output_mapper( - input_shape: Tuple[int], - iterate_axes: Tuple[int], - output_shape: Tuple[int], - output_axes: Tuple[int], -) -> List[Tuple[Tuple[int, ...], Tuple[int, ...]]]: - """Creates list of tuples with the input and output indices - - Parameters - ---------- - input_shape : Tuple[int] - Shape of the input array - iterate_axes : Tuple[int] - Axes to iterate over - output_shape : Tuple[int] - Shape of the output array - output_axes : Tuple[int] - Axes of the output array - """ - assert len(iterate_axes) == len(output_axes) - - iterate_shape = tuple(input_shape[axis] for axis in iterate_axes) - mapper = [] - - for idx_vals in itertools.product(*[range(s) for s in iterate_shape]): - # Build full input index - input_index = [slice(None)] * len(input_shape) - for axis in iterate_axes: - i = iterate_axes.index(axis) - input_index[axis] = idx_vals[i] - - # Build full output index - output_index = [slice(None)] * len(output_shape) - for axis in output_axes: - i = output_axes.index(axis) - output_index[axis] = idx_vals[i] - - input_index = tuple(input_index) - output_index = tuple(output_index) - - mapper.append((input_index, output_index)) - - return mapper - - -def translateStrNone(*args): - args = list(args) - for i, arg in enumerate(args): - if isinstance(arg, str): - if arg.lower() == "none": - args[i] = None - elif arg.lower() == "true": - args[i] = True - elif arg.lower() == "false": - args[i] = False - - return args - - -def get_pytorch_command(): - """Get the command to install pytorch CPU or CUDA - - Returns - ------- - dict - Dictionary mapping OS to commands for installing PyTorch - - Notes - ----- - As of Oct 2024, the `pytorch` channel on Anaconda was deprecated. - See here https://github.com/pytorch/pytorch/issues/138506 - """ - conda_prefix, pip_prefix = get_pip_conda_prefix() - - pytorch_commands = { - "Windows": { - # 'Conda': { - # 'CPU': f'{conda_prefix} pytorch torchvision cpuonly -c conda-forge', - # 'CUDA 11.8 (NVIDIA GPU)': f'{conda_prefix} pytorch torchvision pytorch-cuda=11.8 -c conda-forge -c nvidia', - # 'CUDA 12.1 (NVIDIA GPU)': f'{conda_prefix} pytorch torchvision pytorch-cuda=12.1 -c conda-forge -c nvidia' - # }, - "Pip": { - "CPU": f"{pip_prefix} torch torchvision", - "CUDA 11.8 (NVIDIA GPU)": f"{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cu118", - "CUDA 12.1 (NVIDIA GPU)": f"{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cu121", - } - }, - "Mac": { - # 'Conda': { - # 'CPU': f'{conda_prefix} pytorch torchvision cpuonly -c conda-forge', - # 'CUDA 11.8 (NVIDIA GPU)': '[WARNING]: CUDA is not available on MacOS', - # 'CUDA 12.1 (NVIDIA GPU)': '[WARNING]: CUDA is not available on MacOS' - # }, - "Pip": { - "CPU": f"{pip_prefix} torch torchvision", - "CUDA 11.8 (NVIDIA GPU)": "[WARNING]: CUDA is not available on MacOS", - "CUDA 12.1 (NVIDIA GPU)": "[WARNING]: CUDA is not available on MacOS", - } - }, - "Linux": { - # 'Conda': { - # 'CPU': f'{conda_prefix} pytorch torchvision cpuonly -c conda-forge', - # 'CUDA 11.8 (NVIDIA GPU)': f'{conda_prefix} pytorch torchvision pytorch-cuda=11.8 -c conda-forge -c nvidia', - # 'CUDA 12.1 (NVIDIA GPU)': f'{conda_prefix} pytorch torchvision pytorch-cuda=12.1 -c conda-forge -c nvidia' - # }, - "Pip": { - "CPU": f"{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cpu", - "CUDA 11.8 (NVIDIA GPU)": f"{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cu118", - "CUDA 12.1 (NVIDIA GPU)": f"{pip_prefix} torch torchvision", - } - }, - } - - return pytorch_commands - - -def get_package_info(package_name): - try: - result = subprocess.run( - [sys.executable, "-m", "pip", "show", package_name], - capture_output=True, - text=True, - check=True, - ) - - info = {} - for line in result.stdout.split("\n"): - if ":" in line: - key, value = line.split(":", 1) - info[key.strip()] = value.strip() - - # Check if it's editable by looking at the location - location = info.get("Location", "") - editable_location = info.get("Editable project location", "") - - return { - "installed": True, - "editable": bool(editable_location), - "location": location, - "editable_location": editable_location, - } - - except subprocess.CalledProcessError: - return {"installed": False, "editable": False} - - -# Usage -def update_package(parent, package_name): - package_info = get_package_info(package_name) - if not package_info["installed"]: - printl(f"Package {package_name} is not installed.") - return False - editable = package_info.get("editable", False) - if editable: - return update_editable_package(parent, package_name, package_info) - else: - return update_not_editable_package(package_name, package_info) - - -def update_editable_package(parent, package_name, package_info): - repo_location = package_info.get("editable_location", "") - - if not repo_location or not os.path.exists(repo_location): - print(f"Repository location not found for {package_name}") - return False - - return _update_repo_with_git_command(package_name, repo_location) - - -def _update_repo_with_git_command(package_name, repo_location): - """Update repository using git command""" - try: - print( - f"Updating {package_name} repository at {repo_location} using git command..." - ) - - # Change to repository directory - original_cwd = os.getcwd() - os.chdir(repo_location) - - stashed_changes = False - - # check if there is a portable git - from .config import parser_args - - try: - cp = parser_args - if cp["install_details"] is not None: - no_cli_install = True - install_details = cp["install_details"] - target_dir = install_details.get("target_dir", "") - target_dir = target_dir.strip().strip('"').strip("'") - target_dir = os.path.abspath(target_dir) - else: - no_cli_install = False - except: - no_cli_install = False - pass - - if is_win and no_cli_install: - git_loc = os.path.join(target_dir, "portable_git", "cmd", "git.exe") - if not os.path.exists(git_loc): - print(f"Portable git not found at {git_loc}. Using system git.") - git_loc = "git" - else: - git_loc = "git" - - # Check if git is available - if not shutil.which(git_loc): - print( - f"Git command not found. Please install git to update {package_name}." - ) - return False - - try: - # Check for uncommitted changes - - branch_result = subprocess.run( - [git_loc, "branch", "--show-current"], - capture_output=True, - text=True, - check=True, - ) - current_branch = branch_result.stdout.strip() - print(f"Current branch: {current_branch}") - - result = subprocess.run( - [git_loc, "status", "--porcelain"], - capture_output=True, - text=True, - check=True, - ) - if result.stdout.strip(): - print(f"Repository {package_name} has uncommitted changes") - print("Stashing changes before update...") - subprocess.run([git_loc, "stash"], check=True) - stashed_changes = True - - # Pull changes - subprocess.run([git_loc, "pull"], check=True) - print(f"Successfully updated {package_name}") - - # Pop stashed changes if any were stashed - if stashed_changes: - try: - subprocess.run([git_loc, "stash", "pop"], check=True) - print("Restored stashed changes") - except subprocess.CalledProcessError as pop_error: - print(f"Warning: Could not restore stashed changes: {pop_error}") - - return True - - except subprocess.CalledProcessError as e: - print(f"Git command failed for {package_name}: {e}") - return False - finally: - os.chdir(original_cwd) - - except Exception as e: - print(f"Error updating {package_name} with git command: {e}") - return False - - -def update_not_editable_package(package_name, package_info): - """Update a non-editable package using pip""" - try: - _, pip_list = get_pip_conda_prefix(list_return=True) - command = pip_list + ["--upgrade ", package_name] - - print(f"Updating {package_name} using pip...") - result = subprocess.run(command, shell=True, capture_output=True, text=True) - - if result.returncode == 0: - print(f"Successfully updated {package_name}") - return True - else: - print(f"Failed to update {package_name}: {result.stderr}") - return False - - except Exception as e: - print(f"Error updating {package_name}: {e}") - return False - - -def try_kwargs(func, *args, **kwargs): - """ - Attempt to call a function with the provided arguments and keyword arguments. - - If the function raises a TypeError due to unexpected keyword arguments, - those arguments are dynamically removed, and the function is retried. - This process continues until the function succeeds or no keyword arguments - remain, in which case the exception is re-raised. - - Args: - func (Callable): The function to call. - *args: Positional arguments to pass to the function. - **kwargs: Keyword arguments to pass to the function. - - Returns: - Tuple[Any, List[str]]: A tuple containing: - - The result of the function call (or None if it fails). - - A list of keyword arguments that were removed. - - Raises: - ValueError: If a keyword argument mentioned in the error message - is not found in the provided kwargs. - TypeError: If the function fails with a TypeError after all keyword - arguments have been removed. - """ - - kwargs = kwargs.copy() # Create a copy to avoid modifying the original - removed_kwargs = [] - pattern = r"unexpected keyword argument ['\"](\w+)['\"]" - while True: - try: - return func(*args, **kwargs), removed_kwargs - except TypeError as e: - match = re.search(pattern, str(e)) - if match: - kwarg_name = match.group(1) - if kwarg_name in kwargs: - del kwargs[kwarg_name] - removed_kwargs.append(kwarg_name) - else: - raise ValueError( - f"Keyword argument '{kwarg_name}' not found in kwargs." - ) - else: - raise e - - if len(kwargs) == 0: - print(f"Function {func.__name__} failed with TypeError: {e}") - raise e - - -def get_obj_by_label(rp, target_label): - """ - Returns the object with the specified label from the given list of objects. - - Parameters - ---------- - rp : list - The list of objects to search through. - target_label : str - The label of the object to find. - - Returns - ------- - object - The object with the specified label, or None if not found. - """ - for obj in rp: - if obj.label == target_label: - return obj - return None - - -def find_distances_ID(rps, point=None, ID=None): - """ - Calculate the distances between a given point and the centroids of a list of regionprops. - - Parameters - ---------- - rps : list - List of regionprops objects. - point : tuple, optional - The coordinates of the point. Defaults to None. - ID : int, optional - The label ID of the regionprops object. Defaults to None. - - Returns - ------- - numpy.ndarray - A matrix of distances between the point and the centroids. - - Raises - ------ - ValueError - If ID is not found in the list of regionprops (list of cells). - ValueError - If neither ID nor point is provided. - ValueError - If both ID and point are provided. - """ - - if ID is not None and point is None: - try: - point = [rp.centroid for rp in rps if rp.label == ID][0] - except IndexError: - raise ValueError(f"ID {ID} not found in regionprops (list of cells).") - - elif ID is None and point is None: - raise ValueError("Either ID or point must be provided.") - - elif ID is not None and point is not None: - raise ValueError("Only one of ID or point must be provided.") - - point = point[ - ::-1 - ] # rp are in (y, x) format (or (z, y, x) for 3D data) so I need to reverse order - point = np.array([point]) - centroids = np.array([rp.centroid for rp in rps]) - diff = point[:, np.newaxis] - centroids - dist_matrix = np.linalg.norm(diff, axis=2) - return dist_matrix - - -def sort_IDs_dist(rps, point=None, ID=None): - """Sorts the IDs of regionprops based on their distances to a given point. - - Parameters - ---------- - rps : list - A list of regionprops objects representing cells. - point : tuple, optional - The coordinates of the point to calculate distances from. - If not provided, it will be calculated based on the given ID. - ID : int, optional - The ID of the regionprops object to calculate distances from. - If this and point are both provided, or neither, an error will be - raised. - - Returns - ------- - list - A sorted list of IDs based on their distances to the given point. - - Raises - ------ - ValueError - If ID is not found in the list of regionprops objects. - ValueError - If neither ID nor point is provided. - ValueError - If both ID and point are provided. - - """ - if ID is not None and point is None: - try: - point = [rp.centroid for rp in rps if rp.label == ID][0] - except IndexError: - raise ValueError(f"ID {ID} not found in regionprops (list of cells).") - - elif ID is None and point is None: - raise ValueError("Either ID or point must be provided.") - - elif ID is not None and point is not None: - raise ValueError("Only one of ID or point must be provided.") - - IDs = [rp.label for rp in rps] - if len(IDs) == 0: - return [] - elif len(IDs) == 1: - return IDs - dist_matrix = find_distances_ID(rps, point=point) - dist_matrix = np.squeeze(dist_matrix) - - sorted_ids = sorted(zip(dist_matrix, IDs)) - sorted_ids = [ID for _, ID in sorted_ids] - return sorted_ids - - -def safe_get_or_call(obj, path: str): - """Safely get nested attributes or call methods with literal args from a string path.""" - expr = ast.parse(path, mode="eval").body - - def _eval(node, current_obj): - if isinstance(node, ast.Attribute): - return getattr(_eval(node.value, current_obj), node.attr) - elif isinstance(node, ast.Call): - func = _eval(node.func, current_obj) - args = [ast.literal_eval(arg) for arg in node.args] - kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in node.keywords} - return func(*args, **kwargs) - elif isinstance(node, ast.Name): - # First name in chain is assumed to be from `obj` - return getattr(current_obj, node.id) - else: - raise ValueError(f"Unsupported syntax: {ast.dump(node)}") - - return _eval(expr, obj) - - -def format_commit_date_utc(utc_str): - # Parse the UTC date string (ISO 8601 format) - dt = datetime.datetime.fromisoformat(utc_str.replace("Z", "+00:00")) - - # Convert to your local time zone (optional) - local_dt = dt.astimezone() # removes UTC offset if local - - # Format nicely - return local_dt.strftime(r"%A %d %B %Y at %H:%M") - - -def get_linux_distribution_name(): - import csv - - RELEASE_DATA = {} - with open("/etc/os-release") as f: - reader = csv.reader(f, delimiter="=") - for row in reader: - if row: - RELEASE_DATA[row[0]] = row[1] - if RELEASE_DATA["ID"] in ["debian", "raspbian"]: - with open("/etc/debian_version") as f: - DEBIAN_VERSION = f.readline().strip() - major_version = DEBIAN_VERSION.split(".")[0] - version_split = RELEASE_DATA["VERSION"].split(" ", maxsplit=1) - if version_split[0] == major_version: - # Just major version shown, replace it with the full version - RELEASE_DATA["VERSION"] = " ".join([DEBIAN_VERSION] + version_split[1:]) - - name_version = f"{RELEASE_DATA['NAME']} {RELEASE_DATA['VERSION']}" - - return name_version - - -def reset_settings(): - question = ( - 'Do you want to reset Cell-ACDC settings- type "h" for help - (y/[n]/h)? ' - ) - info_txt = ( - "If you reset Cell-ACDC settings, the folder below will be deleted.\n\n" - "This means deeleting things like custom shortcuts, recent paths, last " - "selections, and GUI preferences.\n\n" - f'Settings folder path: "{settings_folderpath}"' - ) - answer = "y" - while True: - try: - answer = input(f"\n{question}") - except Exception as err: - break - - if answer == "n": - print("*" * 100) - return "Resetting Cell-ACDC settings cancelled." - - if answer == "y": - break - - if answer == "h": - print("-" * 100) - print(f"\n{info_txt}") - print("=" * 100) - - print( - f'"{answer}" is not a valid answer. ' - 'Type "y" for "yes", "n" for "no", or "h" for help.' - ) - - try: - os.remove(settings_folderpath) - print("*" * 100) - out_txt = ( - "Cell-ACDC settings have been reset.\n\n" - "The following folder was deleted:\n\n" - f"{settings_folderpath}" - ) - except Exception as err: - traceback.print_exc() - print("*" * 100) - out_txt = ( - "**ERROR** occured when trying to remove the settings folder.\n\n" - "To reset Cell-ACDC settings, please remove this folder:\n\n" - f"{settings_folderpath}\n" - ) - return out_txt - - -def separate_fluo_segment_channels(channels): - segms_to_load = [] - channels_to_load = [] - current_segm = False - for ch in channels: - if ch == "current segm.": - current_segm = True - elif "segm" in ch: - segms_to_load.append(ch) - else: - channels_to_load.append(ch) - return segms_to_load, channels_to_load, current_segm diff --git a/cellacdc/myutils/__init__.py b/cellacdc/myutils/__init__.py new file mode 100644 index 000000000..9a2ebce5b --- /dev/null +++ b/cellacdc/myutils/__init__.py @@ -0,0 +1,520 @@ +"""Cell-ACDC utility helpers.""" + +from .dataframe import ( + are_acdc_dfs_equal, + checked_reset_index, + checked_reset_index_Cell_ID, + df_ctc_to_acdc_df, + fix_acdc_df_dtypes, + format_IDs, + get_cca_colname_desc, +) + +from .install import ( + _apt_install_java_command, + _brew_install_hdf5, + _brew_install_java_command, + _get_pkg_command_pip_install, + _inform_install_package_failed, + _install_deepsea, + _install_homebrew_command, + _install_package_cli_msg, + _install_package_gui_msg, + _install_package_msg, + _install_pip_package, + _install_pytorch_cli, + _install_sam2, + _install_segment_anything, + _install_tensorflow, + _java_exists, + _java_instructions_linux, + _java_instructions_macOS, + _java_instructions_windows, + _warn_dll_torch, + _warn_install_gpu, + check_git_installed, + check_gpu_available, + check_gpu_requested_segm_model, + check_install_baby, + check_install_cellpose, + check_install_cellsam, + check_install_custom_dependencies, + check_install_instanseg, + check_install_microsam, + check_install_nnInteractive, + check_install_omnipose, + check_install_package, + check_install_sam2, + check_install_segment_anything, + check_install_tapir, + check_install_torch, + check_install_trackastra, + check_install_yeaz, + check_upgrade_javabridge, + download_java, + get_java_url, + get_package_info, + get_package_version, + get_pip_conda_prefix, + get_pip_install_cellacdc_version_command, + get_pytorch_command, + get_torch_device, + install_java, + install_javabridge, + install_javabridge_help, + install_javabridge_instructions_text, + install_package_conda, + uninstall_omnipose_acdc, + uninstall_pip_package, + update_editable_package, + update_not_editable_package, + update_package, +) + +from .io import ( + _bytes_to_GB, + _bytes_to_MB, + browse_docs, + browse_url, + getMemoryFootprint, + save_response_content, +) + +from .logging import ( + Logger, + _log_system_info, + delete_older_log_files, + get_logs_path, + log_segm_params, + setupLogger, +) + +from .misc import ( + StdErr, + _apt_gcc_command, + _apt_update_command, + _available_frameworks, + _get_doc_stop_idx, + _init_fiji_cli, + _jdk_exists, + _parse_bool_str, + _relabel_cca_dfs_and_segm_data, + _run_command, + _subprocess_run_command, + addToRecentPaths, + add_segm_data_param, + checkDataIntegrity, + check_napari_plugin, + clipSelemMask, + convert_to_dtype, + cpp_windows_url, + exec_time, + extract_zip, + filterCommonStart, + find_distances_ID, + find_missing_integers, + findalliter, + float_img_to_dtype, + format_cca_manual_changes, + format_commit_date_utc, + from_imagej_rois_to_segm_data, + from_lab_to_imagej_rois, + from_lab_to_obj_coords, + getAcdcDfSegmPaths, + getBaseAcdcDf, + getBasename, + getBasenameAndChNames, + getChannelFilePath, + getCustomAnnotTooltip, + getDefault_SegmInfo_df, + getMostRecentPath, + get_chained_attr, + get_chname_from_basename, + get_confirm_token, + get_empty_stored_data_dict, + get_fiji_base_command, + get_function_argspec, + get_input_output_mapper, + get_linux_distribution_name, + get_module_name, + get_obj_by_label, + get_slices_local_into_global_arr, + get_tiff_metadata, + img_to_float, + import_segment_module, + init_input_points_df, + is_gui_running, + is_in_bounds, + is_iterable, + iterate_along_axes, + jdk_windows_url, + lab2d_to_rois, + pairwise, + purge_module, + remove_known_extension, + reset_settings, + run_fiji_command, + safe_get_or_call, + seconds_to_ETA, + separate_fluo_segment_channels, + setRetainSizePolicy, + showInExplorer, + showUserManual, + sort_IDs_dist, + synthetic_image_geneator, + test_fiji_base_command, + to_tiff, + to_uint16, + to_uint8, + translateStrNone, + try_kwargs, + utilClass, +) + +from .models import ( + _download_cellpose_germlineNuclei_model, + _download_deepsea_models, + _download_omnipose_models, + _download_sam2_models, + _download_segment_anything_models, + _download_tapir_model, + _download_yeaz_models, + _model_url, + _write_model_location_to_txt, + aliases_real_time_trackers, + check_model_exists, + download_bioformats_jar, + download_examples, + download_ffmpeg, + download_fiji, + download_manual, + download_model, + download_url, + getClassArgSpecs, + getModelArgSpec, + getTrackerArgSpec, + get_add_custom_model_instructions, + get_add_custom_prompt_model_instructions, + get_list_of_models, + get_list_of_promptable_models, + get_list_of_real_time_trackers, + get_list_of_trackers, + import_promptable_segment_module, + import_tracker_module, + init_prompt_segm_model, + init_segm_model, + init_tracker, + insertModelArgSpec, + isIntensityImgRequiredForTracker, + params_to_ArgSpec, + parse_model_param_doc, + parse_model_params, + setDefaultValueArgSpecsFromKwargs, + validate_tracker_input, +) + +from .paths import ( + _create_temp_dir, + check_v123_model_path, + determine_folder_type, + get_acdc_data_path, + get_acdc_java_path, + get_examples_path, + get_fiji_binary_filepath_mac, + get_fiji_exec_folderpath, + get_gdrive_path, + get_images_folderpath, + get_model_path, + get_open_filemaneger_os_string, + get_pos_foldernames, + get_pos_status, + get_pos_status_acdc, + get_pos_status_spotmax, + is_old_user_profile_path, + is_pos_folderpath, + listdir, + migrate_to_new_user_profile_path, + store_custom_model_path, + store_custom_promptable_model_path, + to_relative_path, + trim_path, + validate_images_path, +) + +from .qt import ( + get_cli_multi_choice_question, + testQcoreApp, +) + +from .text import ( + append_text_filename, + elided_text, + get_number_fstring_formatter, + get_show_in_file_manager_text, + get_trimmed_dict, + get_trimmed_list, +) + +from .version import ( + _update_repo_with_git_command, + check_cellpose_version, + check_matplotlib_version, + check_pkg_exact_version, + check_pkg_max_version, + check_pkg_version, + get_cellpose_major_version, + get_date_from_version, + get_git_branch_name, + get_git_pull_checkout_cellacdc_version_commands, + get_info_version_text, + get_salute_string, + is_pkg_version_within_range, + is_second_version_greater, + read_version, +) + +__all__ = [ + "are_acdc_dfs_equal", + "checked_reset_index", + "checked_reset_index_Cell_ID", + "df_ctc_to_acdc_df", + "fix_acdc_df_dtypes", + "format_IDs", + "get_cca_colname_desc", + "_apt_install_java_command", + "_brew_install_hdf5", + "_brew_install_java_command", + "_get_pkg_command_pip_install", + "_inform_install_package_failed", + "_install_deepsea", + "_install_homebrew_command", + "_install_package_cli_msg", + "_install_package_gui_msg", + "_install_package_msg", + "_install_pip_package", + "_install_pytorch_cli", + "_install_sam2", + "_install_segment_anything", + "_install_tensorflow", + "_java_exists", + "_java_instructions_linux", + "_java_instructions_macOS", + "_java_instructions_windows", + "_warn_dll_torch", + "_warn_install_gpu", + "check_git_installed", + "check_gpu_available", + "check_gpu_requested_segm_model", + "check_install_baby", + "check_install_cellpose", + "check_install_cellsam", + "check_install_custom_dependencies", + "check_install_instanseg", + "check_install_microsam", + "check_install_nnInteractive", + "check_install_omnipose", + "check_install_package", + "check_install_sam2", + "check_install_segment_anything", + "check_install_tapir", + "check_install_torch", + "check_install_trackastra", + "check_install_yeaz", + "check_upgrade_javabridge", + "download_java", + "get_java_url", + "get_package_info", + "get_package_version", + "get_pip_conda_prefix", + "get_pip_install_cellacdc_version_command", + "get_pytorch_command", + "get_torch_device", + "install_java", + "install_javabridge", + "install_javabridge_help", + "install_javabridge_instructions_text", + "install_package_conda", + "uninstall_omnipose_acdc", + "uninstall_pip_package", + "update_editable_package", + "update_not_editable_package", + "update_package", + "_bytes_to_GB", + "_bytes_to_MB", + "browse_docs", + "browse_url", + "getMemoryFootprint", + "save_response_content", + "Logger", + "_log_system_info", + "delete_older_log_files", + "get_logs_path", + "log_segm_params", + "setupLogger", + "StdErr", + "_apt_gcc_command", + "_apt_update_command", + "_available_frameworks", + "_get_doc_stop_idx", + "_init_fiji_cli", + "_jdk_exists", + "_parse_bool_str", + "_relabel_cca_dfs_and_segm_data", + "_run_command", + "_subprocess_run_command", + "addToRecentPaths", + "add_segm_data_param", + "checkDataIntegrity", + "check_napari_plugin", + "clipSelemMask", + "convert_to_dtype", + "cpp_windows_url", + "exec_time", + "extract_zip", + "filterCommonStart", + "find_distances_ID", + "find_missing_integers", + "findalliter", + "float_img_to_dtype", + "format_cca_manual_changes", + "format_commit_date_utc", + "from_imagej_rois_to_segm_data", + "from_lab_to_imagej_rois", + "from_lab_to_obj_coords", + "getAcdcDfSegmPaths", + "getBaseAcdcDf", + "getBasename", + "getBasenameAndChNames", + "getChannelFilePath", + "getCustomAnnotTooltip", + "getDefault_SegmInfo_df", + "getMostRecentPath", + "get_chained_attr", + "get_chname_from_basename", + "get_confirm_token", + "get_empty_stored_data_dict", + "get_fiji_base_command", + "get_function_argspec", + "get_input_output_mapper", + "get_linux_distribution_name", + "get_module_name", + "get_obj_by_label", + "get_slices_local_into_global_arr", + "get_tiff_metadata", + "img_to_float", + "import_segment_module", + "init_input_points_df", + "is_gui_running", + "is_in_bounds", + "is_iterable", + "iterate_along_axes", + "jdk_windows_url", + "lab2d_to_rois", + "pairwise", + "purge_module", + "remove_known_extension", + "reset_settings", + "run_fiji_command", + "safe_get_or_call", + "seconds_to_ETA", + "separate_fluo_segment_channels", + "setRetainSizePolicy", + "showInExplorer", + "showUserManual", + "sort_IDs_dist", + "synthetic_image_geneator", + "test_fiji_base_command", + "to_tiff", + "to_uint16", + "to_uint8", + "translateStrNone", + "try_kwargs", + "utilClass", + "_download_cellpose_germlineNuclei_model", + "_download_deepsea_models", + "_download_omnipose_models", + "_download_sam2_models", + "_download_segment_anything_models", + "_download_tapir_model", + "_download_yeaz_models", + "_model_url", + "_write_model_location_to_txt", + "aliases_real_time_trackers", + "check_model_exists", + "download_bioformats_jar", + "download_examples", + "download_ffmpeg", + "download_fiji", + "download_manual", + "download_model", + "download_url", + "getClassArgSpecs", + "getModelArgSpec", + "getTrackerArgSpec", + "get_add_custom_model_instructions", + "get_add_custom_prompt_model_instructions", + "get_list_of_models", + "get_list_of_promptable_models", + "get_list_of_real_time_trackers", + "get_list_of_trackers", + "import_promptable_segment_module", + "import_tracker_module", + "init_prompt_segm_model", + "init_segm_model", + "init_tracker", + "insertModelArgSpec", + "isIntensityImgRequiredForTracker", + "params_to_ArgSpec", + "parse_model_param_doc", + "parse_model_params", + "setDefaultValueArgSpecsFromKwargs", + "validate_tracker_input", + "_create_temp_dir", + "check_v123_model_path", + "determine_folder_type", + "get_acdc_data_path", + "get_acdc_java_path", + "get_examples_path", + "get_fiji_binary_filepath_mac", + "get_fiji_exec_folderpath", + "get_gdrive_path", + "get_images_folderpath", + "get_model_path", + "get_open_filemaneger_os_string", + "get_pos_foldernames", + "get_pos_status", + "get_pos_status_acdc", + "get_pos_status_spotmax", + "is_old_user_profile_path", + "is_pos_folderpath", + "listdir", + "migrate_to_new_user_profile_path", + "store_custom_model_path", + "store_custom_promptable_model_path", + "to_relative_path", + "trim_path", + "validate_images_path", + "get_cli_multi_choice_question", + "testQcoreApp", + "append_text_filename", + "elided_text", + "get_number_fstring_formatter", + "get_show_in_file_manager_text", + "get_trimmed_dict", + "get_trimmed_list", + "_update_repo_with_git_command", + "check_cellpose_version", + "check_matplotlib_version", + "check_pkg_exact_version", + "check_pkg_max_version", + "check_pkg_version", + "get_cellpose_major_version", + "get_date_from_version", + "get_git_branch_name", + "get_git_pull_checkout_cellacdc_version_commands", + "get_info_version_text", + "get_salute_string", + "is_pkg_version_within_range", + "is_second_version_greater", + "read_version", +] diff --git a/cellacdc/myutils/dataframe.py b/cellacdc/myutils/dataframe.py new file mode 100644 index 000000000..c061f8c08 --- /dev/null +++ b/cellacdc/myutils/dataframe.py @@ -0,0 +1,352 @@ +"""Cell-ACDC utility helpers: dataframe.""" + +import os +import re +import ast + +import typing +from typing import Literal, List, Callable, Tuple, Dict + +import pathlib +import difflib +import sys +import platform +import tempfile +import shutil +import traceback +import logging +import datetime +import time +import subprocess +import importlib +from uuid import uuid4 +from importlib import import_module +from math import pow, ceil, floor +from functools import wraps, partial +from collections import namedtuple, Counter +from tqdm import tqdm +import requests +import zipfile +import json +import numpy as np +import pandas as pd +import skimage +import inspect + +import traceback +import itertools +from packaging import version as packaging_version + +from natsort import natsorted + +import tifffile +import skimage.io +import skimage.measure + +from .. import GUI_INSTALLED, KNOWN_EXTENSIONS, is_conda_env + +from .. import core, load +from .. import html_utils, is_linux, is_win, is_mac, issues_url, is_mac_arm64 +from .. import cellacdc_path, printl, acdc_fiji_path, logs_path, acdc_ffmpeg_path +from .. import user_profile_path, recentPaths_path +from .. import models_list_file_path, models_path +from .. import promptable_models_list_file_path, promptable_models_path +from .. import github_home_url +from .. import try_input_install_package +from .. import _warnings +from .. import urls +from .. import qrc_resources_path +from .. import settings_folderpath +from ..segmenters._cellpose_base import min_target_versions_cp + +if GUI_INSTALLED: + from qtpy.QtWidgets import QMessageBox + from qtpy.QtCore import Signal, QObject, QCoreApplication + + from .. import widgets, apps + from .. import config + +ArgSpec = namedtuple("ArgSpec", ["name", "default", "type", "desc", "docstring"]) + +def checked_reset_index(df): + if df.index.names is None or df.index.names == [None]: + return df.reset_index(drop=True) + else: + return df.reset_index() + + +def checked_reset_index_Cell_ID(df): + if df.index.names == ["Cell_ID"]: + return df + df = checked_reset_index(df) + return df.set_index("Cell_ID") + + +def get_cca_colname_desc(): + desc = { + "Cell ID": ( + "ID of the segmented cell. All of the other columns " + "are properties of this ID." + ), + "Cell cycle stage": ("G1 if the cell does NOT have a bud. S/G2/M if it does."), + "Relative ID": ( + "ID of the bud related to the Cell ID (row). For cells in G1 write the " + "bud ID it had in the previous cycle." + ), + "Generation number": ( + "Number of times the cell divided from a bud. For cells in the first " + "frame write any number greater than 1." + ), + "Relationship": ( + "Relationship of the current Cell ID (row). " + "Either mother or bud. An object is a bud if " + "it didn't divide from the mother yet. All other instances " + "(e.g., cell in G1) are still labelled as mother." + ), + "Emerging frame num.": ( + "Frame number at which the object emerged/appeared in the scene." + ), + "Division frame num.": ( + "Frame number at which the bud separated from the mother." + ), + "Is history known?": ( + "Cells that are already present in the first frame or appears " + "from outside of the field of view, have some information missing. " + "For example, for cells in the first frame we do not know how many " + "times it budded and divided in the past. " + "In these cases Is history known? is True." + ), + } + return desc + + +def are_acdc_dfs_equal(df_left, df_right): + if df_left.shape != df_right.shape: + return False + + try: + for col in df_left.columns: + if col not in df_right.columns: + return False + + try: + eq_mask = np.isclose(df_left[col], df_right[col], equal_nan=True) + except Exception as err: + # Data type is string + eq_mask = df_left[col] == df_right[col] + + nan_mask = (df_left[col].isna()) & (df_right[col].isna()) + equality_mask = (eq_mask) | (nan_mask) + if not equality_mask.all(): + return False + except Exception as err: + return False + + return True + + +def fix_acdc_df_dtypes(acdc_df): + acdc_df["is_cell_excluded"] = acdc_df["is_cell_excluded"].astype(bool) + return acdc_df + + +def df_ctc_to_acdc_df( + df_ctc, + tracked_segm, + cell_division_mode="Normal", + return_list=False, + progressbar=True, +): + """Convert Cell Tracking Challenge DataFrame with annotated division to + Cell-ACDC cell cycle annotations DataFrame. + + Parameters + ---------- + df_ctc : pd.DataFrame + DataFrame with {'label', 't1', 't2', 'parent'} columns where + 't1' is the frame index of cell division. + tracked_segm : (T, Y, X) array of ints + Array of tracked segmentation labels. + cell_division_mode : {'Normal', 'Asymmetric'}, optional + Type of cell division. `Normal` is the standard cell division, + where the mother cell divides into two daughter cells. For the + tracking, that means the two daughter cells get a new, unique ID + each. + + `Asymmetric` means that the mother cell grows one daughter + cell that eventually divides from the mother (e.g., budding yeast). + For the tracking, this means that the mother cell ID keeps + existing after division and the daughter cell gets a new, unique ID. + + If `Asymmetric`, the third returned element is the segmentation data + with the asymmetric Cell IDs. + return_list : bool, optional + If `True`, the second returned element is the list of created dataframes, + one per frame. Default is False + progressbar : bool, optional + If `True`, displays a tqdm progressbar. Default is True + """ + cca_dfs = [] + keys = [] + df_ctc = df_ctc.set_index(["t1", "parent"]) + + if cell_division_mode == "Asymmetric": + asymm_tracked_segm = tracked_segm.copy() + + asymmetric_IDs_rename_mapper = {} + if progressbar: + pbar = tqdm( + desc="Converting to Cell-ACDC format", total=len(tracked_segm), ncols=100 + ) + for frame_i, lab in enumerate(tracked_segm): + rp = skimage.measure.regionprops(lab) + IDs = [obj.label for obj in rp] + cca_df = core.getBaseCca_df(IDs, with_tree_cols=True) + keys.append(frame_i) + if frame_i == 0: + cca_dfs.append(cca_df) + if progressbar: + pbar.update() + continue + + # Copy annotations from previous frames + prev_cca_df = cca_dfs[frame_i - 1] + old_IDs = cca_df.index.intersection(prev_cca_df.index) + cca_df.loc[old_IDs] = prev_cca_df.loc[old_IDs] + + try: + df_ctc_i = df_ctc.loc[frame_i] + except KeyError as err: + # No division detected --> nothing to annotate + cca_dfs.append(cca_df) + if progressbar: + pbar.update() + continue + + for parent_ID, df_ctc_i_pID in df_ctc_i.groupby(level=0): + daughter_IDs = df_ctc_i_pID["label"].to_list() + + if parent_ID == 0: + continue + + cca_df.loc[daughter_IDs, "parent_ID_tree"] = parent_ID + cca_df.loc[daughter_IDs, "emerg_frame_i"] = frame_i + cca_df.loc[daughter_IDs, "division_frame_i"] = frame_i + + root_ID = prev_cca_df.at[parent_ID, "root_ID_tree"] + if root_ID == -1: + root_ID = parent_ID + cca_df.loc[daughter_IDs, "root_ID_tree"] = root_ID + + cca_df.loc[daughter_IDs[0], "sister_ID_tree"] = daughter_IDs[1] + cca_df.loc[daughter_IDs[1], "sister_ID_tree"] = daughter_IDs[0] + + prev_gen_num = prev_cca_df.loc[parent_ID, "generation_num_tree"] + cca_df.loc[daughter_IDs, "generation_num_tree"] = prev_gen_num + 1 + + # Annotate division from df_ctc_i into + if cell_division_mode == "Asymmetric": + # Recycle the root_ID and assign it to one of the daughters + replaced_daught_ID = daughter_IDs[1] + key = (frame_i, replaced_daught_ID) + asymmetric_IDs_rename_mapper[key] = (root_ID, parent_ID) + + cca_dfs.append(cca_df) + + if progressbar: + pbar.update() + + if progressbar: + pbar.close() + + if asymmetric_IDs_rename_mapper: + _relabel_cca_dfs_and_segm_data( + cca_dfs, + asymmetric_IDs_rename_mapper, + asymm_tracked_segm, + progressbar=True, + ) + + cca_df = pd.concat(cca_dfs, keys=keys, names=["frame_i"]) + + out = [cca_df, None, None] + + if return_list: + out[1] = cca_dfs + + if cell_division_mode == "Asymmetric": + out[2] = asymm_tracked_segm + + return out + + +def format_IDs(IDs): + if isinstance(IDs, str): + raise ValueError("IDs must not be a string") + + IDsRange = [] + text = "" + sorted_vals = sorted(IDs) + for i, e in enumerate(sorted_vals): + e = int(e) + # Get previous and next value (if possible) + if i > 0: + prevVal = sorted_vals[i - 1] + else: + prevVal = -1 + if i < len(sorted_vals) - 1: + nextVal = sorted_vals[i + 1] + else: + nextVal = -1 + + if e - prevVal == 1 or nextVal - e == 1: + if not IDsRange: + if nextVal - e == 1 and e - prevVal != 1: + # Current value is the first value of a new range + IDsRange = [e] + else: + # Current value is the second element of a new range + IDsRange = [prevVal, e] + else: + if e - prevVal == 1: + # Current value is part of an ongoing range + IDsRange.append(e) + else: + # Current value is the first element of a new range + # --> create range text and this element will + # be added to the new range at the next iter + start, stop = IDsRange[0], IDsRange[-1] + if stop - start > 1: + sep = "-" + else: + sep = "," + text = f"{text},{start}{sep}{stop}" + IDsRange = [] + else: + # Current value doesn't belong to a range + if IDsRange: + # There was a range not added to text --> add it now + start, stop = IDsRange[0], IDsRange[-1] + if stop - start > 1: + sep = "-" + else: + sep = "," + text = f"{text},{start}{sep}{stop}" + + text = f"{text},{e}" + IDsRange = [] + + if IDsRange: + # Last range was not added --> add it now + start, stop = IDsRange[0], IDsRange[-1] + text = f"{text},{start}-{stop}" + + text = text[1:] + + return text + +# Sibling imports (deferred to avoid import cycles) +from .misc import ( + _relabel_cca_dfs_and_segm_data, +) + diff --git a/cellacdc/myutils/install.py b/cellacdc/myutils/install.py new file mode 100644 index 000000000..2de69144c --- /dev/null +++ b/cellacdc/myutils/install.py @@ -0,0 +1,1724 @@ +"""Cell-ACDC utility helpers: install.""" + +import os +import re +import ast + +import typing +from typing import Literal, List, Callable, Tuple, Dict + +import pathlib +import difflib +import sys +import platform +import tempfile +import shutil +import traceback +import logging +import datetime +import time +import subprocess +import importlib +from uuid import uuid4 +from importlib import import_module +from math import pow, ceil, floor +from functools import wraps, partial +from collections import namedtuple, Counter +from tqdm import tqdm +import requests +import zipfile +import json +import numpy as np +import pandas as pd +import skimage +import inspect + +import traceback +import itertools +from packaging import version as packaging_version + +from natsort import natsorted + +import tifffile +import skimage.io +import skimage.measure + +from .. import GUI_INSTALLED, KNOWN_EXTENSIONS, is_conda_env + +from .. import core, load +from .. import html_utils, is_linux, is_win, is_mac, issues_url, is_mac_arm64 +from .. import cellacdc_path, printl, acdc_fiji_path, logs_path, acdc_ffmpeg_path +from .. import user_profile_path, recentPaths_path +from .. import models_list_file_path, models_path +from .. import promptable_models_list_file_path, promptable_models_path +from .. import github_home_url +from .. import try_input_install_package +from .. import _warnings +from .. import urls +from .. import qrc_resources_path +from .. import settings_folderpath +from ..segmenters._cellpose_base import min_target_versions_cp + +if GUI_INSTALLED: + from qtpy.QtWidgets import QMessageBox + from qtpy.QtCore import Signal, QObject, QCoreApplication + + from .. import widgets, apps + from .. import config + +ArgSpec = namedtuple("ArgSpec", ["name", "default", "type", "desc", "docstring"]) + +def check_git_installed(parent=None): + try: + subprocess.check_call(["git", "--version"], shell=True) + return True + except Exception as e: + print("=" * 20) + traceback.print_exc() + print("=" * 20) + git_url = "https://git-scm.com/book/en/v2/Getting-Started-Installing-Git" + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + In order to install javabridge you first need to install + Git (it was not found).

    + Close Cell-ACDC and follow the instructions + {html_utils.tag("here", f'a href="{git_url}"')}.

    + NOTE: After installing Git you might need to restart the + terminal. + """) + msg.warning(parent, "Git not installed", txt) + return False + + +def install_java(): + try: + subprocess.check_call(["javac", "-version"], shell=True) + return False + except Exception as e: + from . import widgets + + win = widgets.installJavaDialog() + win.exec_() + return win.clickedButton == win.cancelButton + + +def install_javabridge(force_compile=False, attempt_uninstall_first=False): + if attempt_uninstall_first: + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "uninstall", "-y", "javabridge"] + ) + except Exception as e: + pass + if sys.platform.startswith("win"): + if force_compile: + subprocess.check_call( + [ + sys.executable, + "-m", + "pip", + "install", + "-U", + "git+https://github.com/SchmollerLab/python-javabridge-acdc", + ] + ) + else: + subprocess.check_call( + [ + sys.executable, + "-m", + "pip", + "install", + "-U", + "git+https://github.com/SchmollerLab/python-javabridge-windows", + ] + ) + elif is_mac: + subprocess.check_call( + [ + sys.executable, + "-m", + "pip", + "install", + "-U", + "git+https://github.com/SchmollerLab/python-javabridge-acdc", + ] + ) + elif is_linux: + subprocess.check_call( + [ + sys.executable, + "-m", + "pip", + "install", + "-U", + "git+https://github.com/LeeKamentsky/python-javabridge.git@master", + ] + ) + + +def get_java_url(): + is_linux = sys.platform.startswith("linux") + is_mac = sys.platform == "darwin" + is_win = sys.platform.startswith("win") + is_win64 = is_win and (os.environ["PROCESSOR_ARCHITECTURE"] == "AMD64") + + # https://drive.google.com/drive/u/0/folders/1MxhySsxB1aBrqb31QmLfVpq8z1vDyLbo + if is_win64: + os_foldername = "win64" + unzipped_foldername = "java_portable_windows-0.1" + file_size = 214798150 + # url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/eMyirTw8qG2wJMt/download/java_portable_windows-0.1.zip' + url = "https://github.com/SchmollerLab/java_portable_windows/archive/refs/tags/v0.1.zip" + elif is_mac: + os_foldername = "macOS" + unzipped_foldername = "java_portable_macos-0.1" + url = "https://github.com/SchmollerLab/java_portable_macos/archive/refs/tags/v0.1.zip" + # url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/SjZb8aommXgrECq/download/java_portable_macos-0.1.zip' + file_size = 108478751 + elif is_linux: + os_foldername = "linux" + unzipped_foldername = "java_portable_linux-0.1" + url = "https://github.com/SchmollerLab/java_portable_linux/archive/refs/tags/v0.1.zip" + # url = 'https://hmgubox2.helmholtz-muenchen.de/index.php/s/HjeQagixE2cjbZL/download/java_portable_linux-0.1.zip' + file_size = 92520706 + return url, file_size, os_foldername, unzipped_foldername + + +def get_package_version(import_pkg_name): + import importlib.metadata + + version = importlib.metadata.version(import_pkg_name) + return version + + +def check_upgrade_javabridge(): + try: + version = get_package_version("javabridge") + except Exception as e: + return + patch = int(version.split(".")[2]) + if patch > 18: + return + install_javabridge() + + +def _java_exists(os_foldername): + acdc_java_path, dot_acdc_java_path = get_acdc_java_path() + os_acdc_java_path = os.path.join(acdc_java_path, os_foldername) + if os.path.exists(os_acdc_java_path): + for folder in os.listdir(os_acdc_java_path): + if not folder.startswith("jre"): + continue + dir_path = os.path.join(os_acdc_java_path, folder) + for file in os.listdir(dir_path): + if file == "bin": + return dir_path + + # Some users still has the old .acdc folder --> check + os_dot_acdc_java_path = os.path.join(dot_acdc_java_path, os_foldername) + if os.path.exists(os_dot_acdc_java_path): + for folder in os.listdir(os_dot_acdc_java_path): + if not folder.startswith("jre"): + continue + dir_path = os.path.join(os_dot_acdc_java_path, folder) + for file in os.listdir(dir_path): + if file == "bin": + return dir_path + return "" + + # Check if the user unzipped the javabridge_portable folder and not its content + os_acdc_java_path = os.path.join(acdc_java_path, os_foldername) + if os.path.exists(os_acdc_java_path): + for folder in os.listdir(os_acdc_java_path): + dir_path = os.path.join(os_acdc_java_path, folder) + if folder.startswith("java_portable") and os.path.isdir(dir_path): + # Move files one level up + unzipped_path = os.path.join(os_acdc_java_path, folder) + for name in os.listdir(unzipped_path): + # move files up one level + src = os.path.join(unzipped_path, name) + shutil.move(src, os_acdc_java_path) + try: + shutil.rmtree(unzipped_path) + except PermissionError as e: + pass + # Check if what we moved one level up was actually java + for folder in os.listdir(os_acdc_java_path): + if not folder.startswith("jre"): + continue + dir_path = os.path.join(os_acdc_java_path, folder) + for file in os.listdir(dir_path): + if file == "bin": + return dir_path + return "" + + +def download_java(): + url, file_size, os_foldername, unzipped_foldername = get_java_url() + jre_path = _java_exists(os_foldername) + jdk_path = _jdk_exists(jre_path) + if os_foldername.startswith("win") and jre_path and jdk_path: + return jre_path, jdk_path, url + + if jre_path: + # on macOS jdk is the same as jre + return jre_path, jre_path, url + + acdc_java_path, _ = get_acdc_java_path() + os_acdc_java_path = os.path.join(acdc_java_path, os_foldername) + temp_zip = os.path.join(os_acdc_java_path, "acdc_java_temp.zip") + + if not os.path.exists(os_acdc_java_path): + os.makedirs(os_acdc_java_path, exist_ok=True) + + try: + download_url(url, temp_zip, file_size=file_size, desc="Java") + extract_zip(temp_zip, os_acdc_java_path) + except Exception as e: + print("=======================") + traceback.print_exc() + print("=======================") + finally: + os.remove(temp_zip) + + # Move files one level up + unzipped_path = os.path.join(os_acdc_java_path, unzipped_foldername) + for name in os.listdir(unzipped_path): + # move files up one level + src = os.path.join(unzipped_path, name) + shutil.move(src, os_acdc_java_path) + try: + shutil.rmtree(unzipped_path) + except PermissionError as e: + pass + + jre_path = _java_exists(os_foldername) + jdk_path = _jdk_exists(jre_path) + return jre_path, jdk_path, url + + +def _install_homebrew_command(): + return '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + + +def _brew_install_java_command(): + return "brew install --cask homebrew/cask-versions/adoptopenjdk8" + + +def _brew_install_hdf5(): + return "brew install hdf5" + + +def _apt_install_java_command(): + return "sudo apt-get install openjdk-8-jdk" + + +def _java_instructions_linux(): + s1 = html_utils.paragraph(""" + Run the following commands
    + in the Teminal one by one: + """) + + s2 = html_utils.paragraph(f""" + {_apt_gcc_command().replace(" ", " ")} + """) + + s3 = html_utils.paragraph(f""" + {_apt_update_command().replace(" ", " ")} + """) + + s4 = html_utils.paragraph(f""" + {_apt_install_java_command().replace(" ", " ")} + """) + + s5 = html_utils.paragraph(""" + The first command is used to install GCC, which is needed later.

    + The second and third commands are used is used to install + Java Development Kit 8.

    + Follow the instructions on the terminal to complete + installation.

    + """) + return s1, s2, s3, s4 + + +def _java_instructions_macOS(): + s1 = html_utils.paragraph(""" + Run the following commands
    + in the Teminal one by one: + """) + + s2 = html_utils.paragraph(f""" + {_install_homebrew_command()} + """) + + s3 = html_utils.paragraph(f""" + {_brew_install_java_command().replace(" ", " ")} + """) + + s4 = html_utils.paragraph(""" + The first command is used to install Homebrew
    + a package manager for macOS/Linux.

    + The second command is used to install Java 8.
    + Follow the instructions on the terminal to complete + installation.

    + Alternatively, you can install Java as a regular app
    + by downloading the app from + + here + . + """) + return s1, s2, s3, s4 + + +def _java_instructions_windows(): + jdk_url = f'"{jdk_windows_url()}"' + cpp_url = f'"{cpp_windows_url()}"' + s1 = html_utils.paragraph(""" + Download and install Java Development Kit and
    + Microsoft C++ Build Tools for Windows (links below).

    + IMPORTANT: when installing "Microsoft C++ Build Tools"
    + make sure to select "Desktop development with C++".
    + Click "See the screenshot" for more details.
    + """) + + s2 = html_utils.paragraph(f""" + Java Development Kit: + + here + + """) + + s3 = html_utils.paragraph(f""" + Microsoft C++ Build Tools: + + here + + """) + return s1, s2, s3 + + +def install_javabridge_instructions_text(): + if is_win: + return _java_instructions_windows() + elif is_mac: + return _java_instructions_macOS() + elif is_linux: + return _java_instructions_linux() + + +def install_javabridge_help(parent=None): + msg = widgets.myMessageBox() + txt = html_utils.paragraph(f""" + Cell-ACDC is going to download and install + javabridge.

    + Make sure you have an active internet connection, + before continuing. + Progress will be displayed on the terminal

    + IMPORTANT: If the installation fails, please open an issue + on our + + GitHub page + .

    + Alternatively, you can cancel the process and try later. + """) + msg.setIcon() + msg.setWindowTitle("Installing javabridge") + msg.addText(txt) + msg.addButton(" Ok ") + cancel = msg.addButton(" Cancel ") + msg.exec_() + return msg.clickedButton == cancel + + +def _install_pip_package( + pkg_name: str, + logger: Callable = print, + install_dependencies: bool = True, + force_binary: bool = True, + pref_binary: bool = True, +) -> None: + command = [ + sys.executable, + "-m", + "pip", + "install", + pkg_name, + ] + if force_binary: + command.append("--only-binary=:all:") + elif pref_binary: + command.append("--prefer-binary") + if not install_dependencies: + command.append("--no-deps") + try: + subprocess.check_call(command) + except subprocess.CalledProcessError as e: + if "--only-binary=:all:" in str(e): + logger( + f"Error: {pkg_name} does not have a binary distribution available, trying preferred binary." + ) + _install_pip_package( + pkg_name=pkg_name, + logger=logger, + install_dependencies=install_dependencies, + force_binary=False, + pref_binary=True, + ) + elif "--prefer-binary" in str(e): + logger( + f"Error: {pkg_name} does not have a preferred binary distribution available, trying source." + ) + command.remove("--prefer-binary") + command.append("--no-binary=:all:") + _install_pip_package( + pkg_name=pkg_name, + logger=logger, + install_dependencies=install_dependencies, + force_binary=False, + pref_binary=False, + ) + else: + logger(f"""Error: {pkg_name} installation failed. Please check the error message. This is probably due to the package + not being available for your platform or python version.""") + raise e + + +def uninstall_pip_package(pkg_name): + subprocess.check_call([sys.executable, "-m", "pip", "uninstall", "-y", pkg_name]) + + +def uninstall_omnipose_acdc(): + """Uninstall omnipose-acdc if present. Since v1.5.0 it is not needed.""" + import json + + pip_list_output = subprocess.check_output( + [sys.executable, "-m", "pip", "list", "--format", "json"] + ) + installed_packages = json.loads(pip_list_output) + pkgs_to_uninstall = [] + for package_info in installed_packages: + if package_info["name"] == "omnipose-acdc": + pkgs_to_uninstall.append("omnipose-acdc") + elif package_info["name"] == "cellpose-omni-acdc": + pkgs_to_uninstall.append("cellpose-omni-acdc") + + for pkg_to_uninstall in pkgs_to_uninstall: + uninstall_pip_package(pkg_to_uninstall) + + +def check_install_cellpose( + version: Literal["2.0", "3.0", "4.0", "any"] = "2.0", + version_to_install_if_missing: Literal["2.0", "3.0", "4.0"] = "4.0", +): + if isinstance(version, int): + version = f"{version}.0" + + check_install_torch() + + if version == "any": + try: + from cellpose import models + + return + except Exception as err: + version = version_to_install_if_missing # after this the version will for sure be a valid format and not 'any' + + is_version_correct = check_cellpose_version(version) + if is_version_correct: + return + + major_version = int(version.split(".")[0]) + + next_version = major_version + 1 + + min_version = min_target_versions_cp[str(major_version)] + + check_install_package( + "cellpose", + max_version=f"{next_version}.0", + min_version=min_version, + include_lower_version=True, + ) + + purge_module("cellpose") + + +def check_install_baby(): + check_install_package( + "TensorFlow", + pypi_name="tensorflow", + import_pkg_name="tensorflow", + max_version="2.14", + ) + check_install_package("baby", pypi_name="baby-seg", import_pkg_name="baby") + + +def check_install_nnInteractive(): + check_install_package("huggingface-hub") + check_install_torch() + check_install_package("nnInteractive") + + purge_module("nnInteractive") + + importlib.invalidate_caches() + import nnInteractive + + importlib.reload(nnInteractive) + + +def check_install_microsam(): + check_install_package("micro-sam", pypi_name="micro_sam", installer="conda") + + +def check_install_yeaz(): + check_install_torch() + check_install_package("yeaz") + + +def check_install_segment_anything(): + check_install_torch() + check_install_package("segment_anything") + + +def check_install_sam2(): + check_install_torch() + check_install_package("sam2") + + +def check_install_cellsam(): + check_install_torch() + check_install_package( + "cellSAM", + pypi_name="git+https://github.com/vanvalenlab/cellSAM.git", + import_pkg_name="cellSAM", + note=( + "CellSAM requires a DeepCell access token to download models.\n" + "Set the DEEPCELL_ACCESS_TOKEN environment variable before use.\n" + "Get your token at: https://deepcell.org" + ), + ) + + +def install_package_conda(conda_pkg_name, channel="conda-forge"): + if not is_conda_env(): + raise EnvironmentError("Cell-ACDC is not running in a `conda` environment.") + conda_prefix, pip_prefix = get_pip_conda_prefix() + conda_prefix = re.sub( + r"(-c\sconda-forge\s?|--channel=conda-forge\s?)", f"-c {channel} ", conda_prefix + ) + + command = f"{conda_prefix} -y {conda_pkg_name}" + _subprocess_run_command(command) + + +def check_install_omnipose(): + try: + import_module("omnipose") + return + except ModuleNotFoundError: + pass + + try: + check_install_package("omnipose", pypi_name="omnipose_acdc") + except Exception as err: + install_package_conda("mahotas") + _install_pip_package("omnipose-acdc") + + +def _warn_dll_torch(qparent=None): + msg = widgets.myMessageBox() + txt = html_utils.paragraph(""" + An error message will occur after you close this message.
    + Please save your data and restart Cell-ACDC.
    + Sorry for the inconvenience!
    + This error is not critical for the main functionality of Cell-ACDC, + and only concerns the segmentation model. Your can save your data without + a problem.
    + The specific reason is that PyTorch and QtPy have weird issues with + DLL conflicts. + """) + msg.information( + qparent, + "Please restart Cell-ACDC", + txt, + buttonsTexts=("Ok, I will save my data and restart Cell-ACDC"), + ) + + +def check_install_torch(is_cli=False, caller_name="Cell-ACDC", qparent=None): + try: + import torch + import torchvision + + return + + except OSError as err: + if "dll" in str(err): + _warn_dll_torch(qparent=qparent) + raise err + else: + traceback.print_exc() + except Exception as err: + traceback.print_exc() + + if is_cli: + _install_pytorch_cli(caller_name=caller_name) + return + + win = apps.InstallPyTorchDialog(parent=qparent, caller_name=caller_name) + win.exec_() + if win.cancel: + _warnings.log_pytorch_not_installed() + return + + command = win.command + print(f'Running command: "{command}"') + _run_command(command) + + try: + import torch + except OSError as e: + if "dll" in str(e): + _warn_dll_torch(qparent=qparent) + raise e + + purge_module("torch") + + +def check_install_package( + pkg_name: str, + import_pkg_name: str = "", + pypi_name="", + note="", + parent=None, + raise_on_cancel=True, + logger_func=print, + is_cli=False, + caller_name="Cell-ACDC", + force_upgrade=False, + upgrade=False, + min_version="", + max_version="", + exact_version="", + install_dependencies=True, + return_outcome=False, + installer: Literal["pip", "conda"] = "pip", + include_higher_version: bool = False, + include_lower_version: bool = False, +): + """Try to import a package. If import fails, ask user to install it + automatically. + + Parameters + ---------- + pkg_name : str + The name of the package that is displayed to the user. + import_pkg_name : str, optional + The name of the package as it should be imported (case sensitive). + If empty string, `pkg_name` will be imported instead. Default is '' + pypi_name : str, optional + The name of the package to be installed with pip. + If empty string, `pkg_name` will be installed instead. Default is '' + note : str, optional + Additional text to display to the user. Default is '' + parent : QObject, optional + Calling QtWidget. Default is None + raise_on_cancel : bool, optional + Raise exception if processed cancelled. Default is True + logger_func : callable, optional + Function used to log text. Default is print + is_cli : bool, optional + If True, message will be displayed in the terminal. + If False, message will be displayed in a Qt message box. + Default is False + caller_name : str, optional + Program calling this function. Default is 'Cell-ACDC' + force_upgrade : bool, optional + If True, we force the upgrade even if package is installed. + upgrade : bool, optional + If True, pip will upgrade the package. This value is True if + `force_upgrade` is True. Without min_version and max_version + it will never upgrade or downgrade the package. + min_version : str, optional + If not empty it must be a valid version `major[.minor][.patch]` where + minor and patch are optional. If the installed package is older the + upgrade will be forced. + max_version : str, optional + If not empty it must be a valid version `major[.minor][.patch]` where + minor and patch are optional. If the installed package is newer the + upgrade will be forced. + exact_version : str, optional + If not empty, install this exact version. It must be a valid + `major[.minor][.patch]`. + install_dependencies : bool, optional + If False, the `--no-deps` flag will be added to the pip command. + return_outcome : bool, optional + If True, returns 1 on successfull action + installer : str, optional + Package manager to use to install the package. Either 'pip' or 'conda'. + Default is 'pip' + include_higher_version : bool, optional + If True, if the higher version is installed, it will not be downgraded. + Default is False + include_lower_version : bool, optional + If True, if the lower version is installed, it will not be upgraded. + Default is False + + Raises + ------ + ModuleNotFoundError + Error raised if process is cancelled and `raise_on_cancel=True`. + """ + if not import_pkg_name: + import_pkg_name = pkg_name + + if not is_gui_running(): + is_cli = True + + try: # check_pkg_version and check_pkg_max_version + import_pkg_name = import_pkg_name.replace("-", "_") + import_module(import_pkg_name) + if force_upgrade: + upgrade = True + raise ModuleNotFoundError( + f'User requested to forcefully upgrade the package "{pkg_name}"' + ) + if exact_version: + check_pkg_exact_version(import_pkg_name, exact_version) + if min_version: + check_pkg_version(import_pkg_name, min_version, include_lower_version) + if max_version: + check_pkg_max_version(import_pkg_name, max_version, include_higher_version) + except ModuleNotFoundError: + proceed = _install_package_msg( + pkg_name, + note=note, + parent=parent, + upgrade=upgrade, + is_cli=is_cli, + caller_name=caller_name, + logger_func=logger_func, + pkg_command=pypi_name, + max_version=max_version, + min_version=min_version, + exact_version=exact_version, + installer=installer, + include_higher_version=include_higher_version, + include_lower_version=include_lower_version, + ) + if pypi_name: + pkg_name = pypi_name + if not proceed: + if raise_on_cancel: + raise ModuleNotFoundError(f"User aborted {pkg_name} installation") + else: + return traceback.format_exc() + try: + if pkg_name == "tensorflow": + _install_tensorflow(max_version=max_version, min_version=min_version) + elif pkg_name == "deepsea": + _install_deepsea() + elif pkg_name == "segment_anything": + _install_segment_anything() + elif pkg_name == "sam2": + _install_sam2() + else: + pkg_command = _get_pkg_command_pip_install( + pkg_name, + exact_version=exact_version, + max_version=max_version, + min_version=min_version, + including_higher_version=include_higher_version, + including_lower_version=include_lower_version, + ) + if installer == "pip": + _install_pip_package( + pkg_command, install_dependencies=install_dependencies + ) + else: + install_package_conda(pkg_command) + except Exception as e: + printl(traceback.format_exc()) + _inform_install_package_failed( + pkg_name, parent=parent, do_exit=raise_on_cancel + ) + if return_outcome: + return True + + +def check_install_custom_dependencies(custom_install_requires, *args, **kwargs): + """Used to install a package with custom dependencies, usefull if they have + random pinned versions for their dependencies. + + For *args and **kwargs see `myutils.check_install_package`. + + Parameters + ---------- + custom_install_requires : list + list of dependencies. Check either requirements.txt, setup.py, + setup.cfg, pyproject.toml, or any other file that lists the dependencies. + For formatting of the dependencies with min max version, + use _get_pkg_command_pip_install. + """ + kwargs["install_dependencies"] = False + kwargs["return_outcome"] = True + success = check_install_package(*args, **kwargs) + if not success: + return + for pkg_name in custom_install_requires: + _install_pip_package(pkg_name) + + +def _inform_install_package_failed(pkg_name, parent=None, do_exit=True): + conda_prefix, pip_prefix = get_pip_conda_prefix() + + install_command = f"{pip_prefix} --upgrade {pkg_name}" + txt = html_utils.paragraph(f""" + Unfortunately, installation of {pkg_name} returned an error.

    + Try restarting Cell-ACDC. If it doesn't work, + please close Cell-ACDC and, with the acdc environment ACTIVE, + install {pkg_name} manually using the follwing command:

    + {install_command}

    + Thank you for your patience. + """) + msg = widgets.myMessageBox() + msg.critical(parent, f"{pkg_name} installation failed", txt) + print("*" * 50) + print( + f'[ERROR]: Installation of "{pkg_name}" failed. ' + f"Please, close Cell-ACDC and run the command " + f"{pip_prefix} --upgrade {pkg_name}`" + ) + print("^" * 50) + + +def _install_package_msg( + pkg_name, + note="", + parent=None, + upgrade=False, + caller_name="Cell-ACDC", + is_cli=False, + pkg_command="", + logger_func=print, + exact_version="", + max_version="", + min_version="", + installer: Literal["pip", "conda"] = "pip", + include_higher_version: bool = False, + include_lower_version: bool = False, +): + if is_cli: + proceed = _install_package_cli_msg( + pkg_name, + note=note, + upgrade=upgrade, + caller_name=caller_name, + pkg_command=pkg_command, + exact_version=exact_version, + max_version=max_version, + min_version=min_version, + logger_func=logger_func, + installer=installer, + include_higher_version=include_higher_version, + include_lower_version=include_lower_version, + ) + else: + proceed = _install_package_gui_msg( + pkg_name, + note=note, + parent=parent, + upgrade=upgrade, + caller_name=caller_name, + pkg_command=pkg_command, + exact_version=exact_version, + max_version=max_version, + min_version=min_version, + logger_func=logger_func, + installer=installer, + including_higher_version=include_higher_version, + including_lower_version=include_lower_version, + ) + return proceed + + +def _install_pytorch_cli(caller_name="Cell-ACDC", action="install", logger_func=print): + separator = "-" * 60 + txt = ( + f"{separator}\n{caller_name} needs to {action} PyTorch\n\n" + "You can choose to install it now or stop the process and install it " + "later. To install it correctly, we need to know your preferences.\n" + ) + logger_func(txt) + questions = { + "Choose your OS:": ("Windows", "Mac", "Linux"), + "Package manager:": ("Pip"), + "Compute platform:": ( + "CPU", + "CUDA 11.8 (NVIDIA GPU)", + "CUDA 12.1 (NVIDIA GPU)", + ), + } + selected_command = get_pytorch_command() + selected_preferences = [] + for question, choices in questions.items(): + input_txt = get_cli_multi_choice_question(question, choices) + while True: + answer = input(input_txt) + if answer.lower() == "q": + exit("Execution stopped by the user.") + + try: + idx = int(answer) - 1 + if idx >= len(choices): + raise TypeError("Not a valid answer") + except Exception as err: + print("-" * 100) + logger_func( + f'"{answer}" is not a valid answer.' + 'Choose one of the options or "q" to quit.' + ) + print("^" * 100) + continue + + preference = choices[idx] + selected_command = selected_command[preference] + selected_preferences.append(preference) + print("") + break + + print("-" * 100) + selected_preferences = ", ".join(selected_preferences) + logger_func(f"Selected preferences: {selected_preferences}") + print("-" * 100) + logger_func(f"Command:\n\n{selected_command}\n") + while True: + answer = input("Do you want to run the command now ([y]/n)?: ") + if answer.lower() == "n": + exit("Execution stopped by the user.") + + if answer.lower() == "y" or not answer: + break + + print("-" * 100) + print(f'"{answer}" is not a valid answer. Choose "y" for yes or "n" for no.') + print("^" * 100) + + if selected_command.startswith("conda"): + try: + subprocess.check_call([selected_command], shell=True) + except Exception as err: + cmd_list = selected_command.split() + cmd_list = [cmd.strip('"') for cmd in cmd_list] + cmd_list = [cmd.strip("'") for cmd in cmd_list] + cmd_list = [cmd.lstrip(".") for cmd in cmd_list] + subprocess.check_call(cmd_list, shell=True) + else: + cmd_list = selected_command.split()[1:] + cmd_list = [cmd.strip('"') for cmd in cmd_list] + cmd_list = [cmd.strip("'") for cmd in cmd_list] + cmd_list = [cmd.lstrip(".") for cmd in cmd_list] + subprocess.check_call([sys.executable, *cmd_list], shell=True) + + +def _get_pkg_command_pip_install( + pkg_command, + exact_version="", + max_version="", + min_version="", + including_lower_version=False, + including_higher_version=False, +): + if exact_version: + pkg_command = f"{pkg_command}=={exact_version}" + return pkg_command + + if including_higher_version: + sign_max = "<=" + else: + sign_max = "<" + if including_lower_version: + sign_min = ">=" + else: + sign_min = ">" + if min_version: + pkg_command = f"{pkg_command}{sign_min}{min_version}" + if max_version: + pkg_command = f"{pkg_command}," + + if max_version: + pkg_command = f"{pkg_command}{sign_max}{max_version}" + + return pkg_command + + +def _install_package_cli_msg( + pkg_name, + note="", + upgrade=False, + caller_name="Cell-ACDC", + logger_func=print, + pkg_command="", + exact_version="", + max_version="", + min_version="", + installer: Literal["pip", "conda"] = "pip", + include_lower_version=False, + include_higher_version=False, +): + if not pkg_command: + pkg_command = pkg_name + + pkg_command = _get_pkg_command_pip_install( + pkg_command, + exact_version=exact_version, + max_version=max_version, + min_version=min_version, + including_lower_version=include_lower_version, + including_higher_version=include_higher_version, + ) + + if upgrade: + action = "upgrade" + else: + action = "install" + + conda_prefix, pip_prefix = get_pip_conda_prefix() + + if installer == "pip": + install_command = f"{pip_prefix} --upgrade {pkg_command}" + elif installer == "conda": + install_command = f"{conda_prefix} {pkg_command}" + + separator = "-" * 60 + txt = ( + f"{separator}\n{caller_name} needs to {action} {pkg_name}\n\n" + "You can choose to install it now or stop the process and install it " + "later with the following command:\n\n" + f"{install_command}\n" + ) + logger_func(txt) + + while True: + answer = try_input_install_package(pkg_name, install_command) + if not answer or answer.lower() == "y": + return True + + if answer.lower() == "n": + return False + + logger_func( + f'{answer} is not a valid answer. Valid answers are "y" for Yes and ' + '"n" for No.' + ) + + +def _install_package_gui_msg( + pkg_name, + note="", + parent=None, + upgrade=False, + caller_name="Cell-ACDC", + pkg_command="", + logger_func=None, + exact_version="", + max_version="", + min_version="", + including_lower_version=False, + including_higher_version=False, + installer: Literal["pip", "conda"] = "pip", +): + msg = widgets.myMessageBox(parent=parent) + if upgrade: + install_text = "upgrade" + else: + install_text = "install" + if pkg_name == "BayesianTracker": + pkg_name = "btrack" + + if not pkg_command: + pkg_command = pkg_name + + pkg_command = _get_pkg_command_pip_install( + pkg_command, + exact_version=exact_version, + max_version=max_version, + min_version=min_version, + including_lower_version=including_lower_version, + including_higher_version=including_higher_version, + ) + + conda_prefix, pip_prefix = get_pip_conda_prefix() + + if installer == "pip": + command = f"{pip_prefix} --upgrade {pkg_command}" + elif installer == "conda": + command = f"{conda_prefix} {pkg_command}" + + command_html = command.lower().replace("<", "<").replace(">", ">") + + txt = html_utils.paragraph(f""" + {caller_name} is going to download and {install_text} + {pkg_name}.

    + Make sure you have an active internet connection, + before continuing.
    + Progress will be displayed on the terminal

    + You might have to restart {caller_name}.

    + Alternatively, you can cancel the process and try later.

    + To install later, or if the installation fails, run the following + command: + """) + if note: + txt = f"{txt}{note}" + _, okButton = msg.information( + parent, + f"Install {pkg_name}", + txt, + buttonsTexts=("Cancel", "Ok"), + commands=(command_html,), + ) + return msg.clickedButton == okButton + + +def _install_tensorflow(max_version="", min_version=""): + cpu = platform.processor() + pkg_command = _get_pkg_command_pip_install( + "tensorflow", max_version=max_version, min_version=min_version + ) + conda_prefix, pip_prefix = get_pip_conda_prefix() + + if is_mac and cpu == "arm": + args = [f'{conda_prefix} "{pkg_command}"'] + shell = True + else: + args = [sys.executable, "-m", "pip", "install", "-U", pkg_command] + shell = False + subprocess.check_call(args, shell=shell) + + # purge numpy + purge_module("numpy") + + +def _install_segment_anything(): + args = [ + sys.executable, + "-m", + "pip", + "install", + "-U", + "--use-pep517", + "git+https://github.com/facebookresearch/segment-anything.git", + ] + subprocess.check_call(args) + + +def _install_sam2(): + args = [ + sys.executable, + "-m", + "pip", + "install", + "-U", + "--use-pep517", + "git+https://github.com/facebookresearch/sam2.git", + ] + subprocess.check_call(args) + + +def _install_deepsea(): + subprocess.check_call([sys.executable, "-m", "pip", "install", "deepsea"]) + + +def get_pip_conda_prefix(list_return=False): + from .config import parser_args + + try: + cp = parser_args + if cp["install_details"] is not None: + no_cli_install = True + install_details = cp["install_details"] + venv_path = install_details["venv_path"] + conda_path = install_details["conda_path"] + if " " not in conda_path: + conda_path = conda_path.strip('"').strip("'") + else: + no_cli_install = False + except: + no_cli_install = False + pass + + if no_cli_install: + conda_prefix = f"{conda_path} install -y -p {venv_path} -c conda-forge" + exec_path = sys.executable + if " " in exec_path: + exec_path = f'"{exec_path}"' + pip_prefix = f"{exec_path} -m pip install" + else: + conda_prefix = "conda install -y -c conda-forge" + pip_prefix = "pip install" + + pip_list = [sys.executable, "-m", "pip", "install"] + if no_cli_install: + conda_list = [ + conda_path.strip('"').strip("'"), + "install", + "-y", + "-p", + venv_path.strip('"').strip("'"), + "-c", + "conda-forge", + ] + else: + conda_list = ["conda", "install", "-y", "-c", "conda-forge"] + if list_return: + return conda_list, pip_list + else: + return conda_prefix, pip_prefix + + +def _warn_install_gpu(model_name, ask_installs, qparent=None): + + cellpose_cuda_url = ( + r"https://github.com/mouseland/cellpose#gpu-version-cuda-on-windows-or-linux" + ) + torch_cuda_url = r"https://pytorch.org/get-started/locally/" + direct_ml_url = r"https://microsoft.github.io/DirectML/" + torch_directml_url = ( + r"https://learn.microsoft.com/en-us/windows/ai/directml/pytorch-windows" + ) + + cellpose_href = f"{html_utils.href_tag('here', cellpose_cuda_url)}" + torch_href = f"{html_utils.href_tag('here', torch_cuda_url)}" + direct_ml_href = f"{html_utils.href_tag('direct_ml_DirectMLref', direct_ml_url)}" + torch_directml_href = ( + f"{html_utils.href_tag('directml pytorch', torch_directml_url)}" + ) + + conda_prefix, pip_prefix = get_pip_conda_prefix() + + msg = widgets.myMessageBox(showCentered=False, wrapText=False) + txt = html_utils.paragraph(f""" + In order to use {model_name} with the GPU you need + to install a PyTorch version which can use it.
    + We recomment using CUDA over DirectML, but if you are using a Windows + machine with an AMD GPU, you can use DirectML.
    + """) + txt_cuda_title = html_utils.paragraph(f"CUDA", font_size="18px") + + pip_prefix = pip_prefix.replace("install -y", "uninstall") + txt_cuda = html_utils.paragraph(f""" + Check out these instructions {cellpose_href}, and {torch_href}.
    + First, uninstall the CPU version of PyTorch with the following command: + {pip_prefix} uninstall torch +
    Then, install the CUDA version required by your GPU with the following + command (in this case 12.8): + {pip_prefix} torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128 +
    + """) + + add_info = html_utils.to_admonition( + f""" + Pleae use the following table to find the correct link for the command. + You can check the highest CUDA
    version supported on your system with the + command nvidia-smi in the terminal.
    + + {html_utils.table_style_header} + + CUDA Version + PyTorch Installation Link + + + CUDA 11.8 + https://download.pytorch.org/whl/cu118 + + + CUDA 12.6 + https://download.pytorch.org/whl/cu126 + + + CUDA 12.8 + https://download.pytorch.org/whl/cu128 + + + """, + "info", + ) + + txt_cuda = f"{txt_cuda}{add_info}" + + txt_directML_title = html_utils.paragraph(f"DirectML", font_size="18px") + txt_directML = html_utils.paragraph(f""" + Check out {direct_ml_href}, and {torch_directml_href} for more info.
    + Only supported on Windows 10/11 with Python 3.8-3.12.
    + Click the Install DirectML button to install DirectML. +

    + """) + + txt_end = html_utils.paragraph(f""" + How do you want to proceed? + """) + + stopButton = widgets.cancelPushButton("Stop the process") + directMLButton = widgets.okPushButton("Install DirectML") + proceedButton = widgets.okPushButton("Proceed without GPU") + + buttons = [stopButton] + + if "cuda" in ask_installs: + txt = f"{txt}{txt_cuda_title}{txt_cuda}" + if "directML" in ask_installs: + txt = f"{txt}{txt_directML_title}{txt_directML}" + buttons.append(directMLButton) + txt = f"{txt}{txt_end}" + buttons.append(proceedButton) + + msg.warning( + qparent, + "PyTorch GPU version not installed", + txt, + buttonsTexts=buttons, + ) + + if msg.cancel: + return False, False + + if msg.clickedButton == directMLButton: + py_ver = sys.version_info + if is_win and py_ver.major == 3 and py_ver.minor < 13: + success = check_install_package( + pkg_name="torch-directml", + import_pkg_name="torch_directml", + pypi_name="torch-directml", + return_outcome=True, + ) + purge_module("torch") + return success, True + else: + msg = widgets.myMessageBox() + msg.warning( + qparent, + "DirectML not supported", + "DirectML is only supported on Python 3.8-3.12 and Windows 10/11", + ) + return False, False + + if msg.clickedButton == stopButton: + return False, False + + if msg.clickedButton == proceedButton: + return True, False + + +def check_gpu_requested_segm_model(init_kwargs): + gpu = init_kwargs.get("gpu", False) + if gpu: + return True + + device_type = init_kwargs.get("device_type", "cpu") + return device_type == "gpu" or device_type == "" + + +def check_gpu_available( + model_name, + use_gpu, + do_not_warn=False, + qparent=None, + cuda=False, + directML=False, + return_available_gpu_type=False, +): + if not use_gpu: + if return_available_gpu_type: + return True, [] + else: + return True + + ask_for_cuda = False + if cuda: + try: + import torch + + if not torch.cuda.is_available(): + ask_for_cuda = True + if not torch.cuda.device_count() > 0: + ask_for_cuda = True + except ModuleNotFoundError: + ask_for_cuda = True + + ask_for_directML = False + if directML: + if is_win: + try: + import torch_directml + + if not torch_directml.is_available(): + ask_for_directML = True + except ModuleNotFoundError: + ask_for_directML = True + + frameworks = _available_frameworks(model_name) + ask_installs = set() if not ask_for_cuda else {"cuda"} + ask_installs.update({"directML"} if ask_for_directML else set()) + framework_available = False + available_frameworks_list = [] + for framework, model_compatible in frameworks.items(): + if not model_compatible: + continue + if framework == "cuda": + import torch + + if not torch.cuda.is_available(): + ask_installs.add("cuda") + elif not torch.cuda.device_count() > 0: + ask_installs.add("cuda") + else: + framework_available = True + available_frameworks_list.append("cuda") + elif framework == "directML": + if is_win: + try: + import torch_directml + + if not torch_directml.is_available(): + ask_installs.add("directML") + else: + framework_available = True + available_frameworks_list.append("directML") + except ModuleNotFoundError: + ask_installs.add("directML") + elif is_mac_arm64: + framework_available = True + break + + if framework_available and not ask_for_cuda and not ask_for_directML: + if return_available_gpu_type: + return True, available_frameworks_list + else: + return True + + elif do_not_warn: + if return_available_gpu_type: + return False, available_frameworks_list + else: + return False + + proceed, directML_installed = _warn_install_gpu( + model_name, ask_installs, qparent=qparent + ) + if return_available_gpu_type: + if directML_installed: + available_frameworks_list.append("directML") + return proceed, available_frameworks_list + else: + return proceed + + +def get_pip_install_cellacdc_version_command(version=None): + conda_prefix, pip_prefix = get_pip_conda_prefix() + + if version is None: + version = read_version() + commit_hash_idx = version.find("+g") + is_dev_version = commit_hash_idx > 0 + if is_dev_version: + commit_hash = version[commit_hash_idx + 2 :].split(".")[0] + command = f'{pip_prefix} --upgrade "git+{github_home_url}.git@{commit_hash}"' + command_github = None + else: + command = f"{pip_prefix} --upgrade cellacdc=={version}" + command_github = f'{pip_prefix} --upgrade "git+{urls.github_url}@{version}"' + return command, command_github + + +def check_install_tapir(): + check_install_package( + "tapnet", pypi_name="git+https://github.com/ElpadoCan/TAPIR.git" + ) + + +def check_install_trackastra(): + check_install_package( + "Trackastra", import_pkg_name="trackastra", pypi_name="trackastra" + ) + + +def get_torch_device(gpu=False): + import torch + + if torch.cuda.is_available() and gpu: + device = torch.device("cuda") + elif torch.backends.mps.is_available(): + device = torch.device("mps") + else: + device = torch.device("cpu") + return device + + +def check_install_instanseg(): + check_install_package( + pkg_name="InstanSeg", import_pkg_name="instanseg", pypi_name="instanseg-torch" + ) + + +def get_pytorch_command(): + """Get the command to install pytorch CPU or CUDA + + Returns + ------- + dict + Dictionary mapping OS to commands for installing PyTorch + + Notes + ----- + As of Oct 2024, the `pytorch` channel on Anaconda was deprecated. + See here https://github.com/pytorch/pytorch/issues/138506 + """ + conda_prefix, pip_prefix = get_pip_conda_prefix() + + pytorch_commands = { + "Windows": { + # 'Conda': { + # 'CPU': f'{conda_prefix} pytorch torchvision cpuonly -c conda-forge', + # 'CUDA 11.8 (NVIDIA GPU)': f'{conda_prefix} pytorch torchvision pytorch-cuda=11.8 -c conda-forge -c nvidia', + # 'CUDA 12.1 (NVIDIA GPU)': f'{conda_prefix} pytorch torchvision pytorch-cuda=12.1 -c conda-forge -c nvidia' + # }, + "Pip": { + "CPU": f"{pip_prefix} torch torchvision", + "CUDA 11.8 (NVIDIA GPU)": f"{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cu118", + "CUDA 12.1 (NVIDIA GPU)": f"{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cu121", + } + }, + "Mac": { + # 'Conda': { + # 'CPU': f'{conda_prefix} pytorch torchvision cpuonly -c conda-forge', + # 'CUDA 11.8 (NVIDIA GPU)': '[WARNING]: CUDA is not available on MacOS', + # 'CUDA 12.1 (NVIDIA GPU)': '[WARNING]: CUDA is not available on MacOS' + # }, + "Pip": { + "CPU": f"{pip_prefix} torch torchvision", + "CUDA 11.8 (NVIDIA GPU)": "[WARNING]: CUDA is not available on MacOS", + "CUDA 12.1 (NVIDIA GPU)": "[WARNING]: CUDA is not available on MacOS", + } + }, + "Linux": { + # 'Conda': { + # 'CPU': f'{conda_prefix} pytorch torchvision cpuonly -c conda-forge', + # 'CUDA 11.8 (NVIDIA GPU)': f'{conda_prefix} pytorch torchvision pytorch-cuda=11.8 -c conda-forge -c nvidia', + # 'CUDA 12.1 (NVIDIA GPU)': f'{conda_prefix} pytorch torchvision pytorch-cuda=12.1 -c conda-forge -c nvidia' + # }, + "Pip": { + "CPU": f"{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cpu", + "CUDA 11.8 (NVIDIA GPU)": f"{pip_prefix} torch torchvision --index-url https://download.pytorch.org/whl/cu118", + "CUDA 12.1 (NVIDIA GPU)": f"{pip_prefix} torch torchvision", + } + }, + } + + return pytorch_commands + + +def get_package_info(package_name): + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "show", package_name], + capture_output=True, + text=True, + check=True, + ) + + info = {} + for line in result.stdout.split("\n"): + if ":" in line: + key, value = line.split(":", 1) + info[key.strip()] = value.strip() + + # Check if it's editable by looking at the location + location = info.get("Location", "") + editable_location = info.get("Editable project location", "") + + return { + "installed": True, + "editable": bool(editable_location), + "location": location, + "editable_location": editable_location, + } + + except subprocess.CalledProcessError: + return {"installed": False, "editable": False} + + +def update_package(parent, package_name): + package_info = get_package_info(package_name) + if not package_info["installed"]: + printl(f"Package {package_name} is not installed.") + return False + editable = package_info.get("editable", False) + if editable: + return update_editable_package(parent, package_name, package_info) + else: + return update_not_editable_package(package_name, package_info) + + +def update_editable_package(parent, package_name, package_info): + repo_location = package_info.get("editable_location", "") + + if not repo_location or not os.path.exists(repo_location): + print(f"Repository location not found for {package_name}") + return False + + return _update_repo_with_git_command(package_name, repo_location) + + +def update_not_editable_package(package_name, package_info): + """Update a non-editable package using pip""" + try: + _, pip_list = get_pip_conda_prefix(list_return=True) + command = pip_list + ["--upgrade ", package_name] + + print(f"Updating {package_name} using pip...") + result = subprocess.run(command, shell=True, capture_output=True, text=True) + + if result.returncode == 0: + print(f"Successfully updated {package_name}") + return True + else: + print(f"Failed to update {package_name}: {result.stderr}") + return False + + except Exception as e: + print(f"Error updating {package_name}: {e}") + return False + +# Sibling imports (deferred to avoid import cycles) +from .misc import ( + _apt_gcc_command, + _apt_update_command, + _available_frameworks, + _jdk_exists, + _run_command, + _subprocess_run_command, + cpp_windows_url, + extract_zip, + is_gui_running, + jdk_windows_url, + purge_module, +) +from .models import ( + download_url, +) +from .paths import ( + get_acdc_java_path, +) +from .qt import ( + get_cli_multi_choice_question, +) +from .version import ( + _update_repo_with_git_command, + check_cellpose_version, + check_pkg_exact_version, + check_pkg_max_version, + check_pkg_version, + read_version, +) + diff --git a/cellacdc/myutils/io.py b/cellacdc/myutils/io.py new file mode 100644 index 000000000..8673e30e4 --- /dev/null +++ b/cellacdc/myutils/io.py @@ -0,0 +1,130 @@ +"""Cell-ACDC utility helpers: io.""" + +import os +import re +import ast + +import typing +from typing import Literal, List, Callable, Tuple, Dict + +import pathlib +import difflib +import sys +import platform +import tempfile +import shutil +import traceback +import logging +import datetime +import time +import subprocess +import importlib +from uuid import uuid4 +from importlib import import_module +from math import pow, ceil, floor +from functools import wraps, partial +from collections import namedtuple, Counter +from tqdm import tqdm +import requests +import zipfile +import json +import numpy as np +import pandas as pd +import skimage +import inspect + +import traceback +import itertools +from packaging import version as packaging_version + +from natsort import natsorted + +import tifffile +import skimage.io +import skimage.measure + +from .. import GUI_INSTALLED, KNOWN_EXTENSIONS, is_conda_env + +from .. import core, load +from .. import html_utils, is_linux, is_win, is_mac, issues_url, is_mac_arm64 +from .. import cellacdc_path, printl, acdc_fiji_path, logs_path, acdc_ffmpeg_path +from .. import user_profile_path, recentPaths_path +from .. import models_list_file_path, models_path +from .. import promptable_models_list_file_path, promptable_models_path +from .. import github_home_url +from .. import try_input_install_package +from .. import _warnings +from .. import urls +from .. import qrc_resources_path +from .. import settings_folderpath +from ..segmenters._cellpose_base import min_target_versions_cp + +if GUI_INSTALLED: + from qtpy.QtWidgets import QMessageBox + from qtpy.QtCore import Signal, QObject, QCoreApplication + + from .. import widgets, apps + from .. import config + +ArgSpec = namedtuple("ArgSpec", ["name", "default", "type", "desc", "docstring"]) + +def _bytes_to_MB(size_bytes): + factor = pow(2, -20) + size_MB = round(size_bytes * factor) + return size_MB + + +def _bytes_to_GB(size_bytes): + factor = pow(2, -30) + size_GB = round(size_bytes * factor, 2) + return size_GB + + +def getMemoryFootprint(files_list): + required_memory = sum( + [48 if file.endswith(".h5") else os.path.getsize(file) for file in files_list] + ) + return required_memory + + +def browse_url(url): + import webbrowser + + webbrowser.open(url) + + +def browse_docs(): + browse_url(urls.docs_homepage) + + +def save_response_content( + response, destination, file_size=None, model_name="cellpose", progress=None +): + print(f"Downloading {model_name} to: {os.path.dirname(destination)}") + CHUNK_SIZE = 32768 + + # Download to a temp folder in user path + temp_folder = pathlib.Path.home().joinpath(".acdc_temp") + if not os.path.exists(temp_folder): + os.mkdir(temp_folder) + temp_dst = os.path.join(temp_folder, os.path.basename(destination)) + if file_size is not None and progress is not None: + progress.emit(file_size, -1) + pbar = tqdm( + total=file_size, unit="B", unit_scale=True, unit_divisor=1024, ncols=100 + ) + with open(temp_dst, "wb") as f: + for chunk in response.iter_content(CHUNK_SIZE): + if chunk: + f.write(chunk) + pbar.update(len(chunk)) + if progress is not None: + progress.emit(-1, len(chunk)) + pbar.close() + + # Move to destination and delete temp folder + destination_dir = os.path.dirname(destination) + if not os.path.exists(destination_dir): + os.makedirs(destination_dir, exist_ok=True) + shutil.move(temp_dst, destination) + shutil.rmtree(temp_folder) diff --git a/cellacdc/myutils/logging.py b/cellacdc/myutils/logging.py new file mode 100644 index 000000000..572c242af --- /dev/null +++ b/cellacdc/myutils/logging.py @@ -0,0 +1,351 @@ +"""Cell-ACDC utility helpers: logging.""" + +import os +import re +import ast + +import typing +from typing import Literal, List, Callable, Tuple, Dict + +import pathlib +import difflib +import sys +import platform +import tempfile +import shutil +import traceback +import logging +import datetime +import time +import subprocess +import importlib +from uuid import uuid4 +from importlib import import_module +from math import pow, ceil, floor +from functools import wraps, partial +from collections import namedtuple, Counter +from tqdm import tqdm +import requests +import zipfile +import json +import numpy as np +import pandas as pd +import skimage +import inspect + +import traceback +import itertools +from packaging import version as packaging_version + +from natsort import natsorted + +import tifffile +import skimage.io +import skimage.measure + +from .. import GUI_INSTALLED, KNOWN_EXTENSIONS, is_conda_env + +from .. import core, load +from .. import html_utils, is_linux, is_win, is_mac, issues_url, is_mac_arm64 +from .. import cellacdc_path, printl, acdc_fiji_path, logs_path, acdc_ffmpeg_path +from .. import user_profile_path, recentPaths_path +from .. import models_list_file_path, models_path +from .. import promptable_models_list_file_path, promptable_models_path +from .. import github_home_url +from .. import try_input_install_package +from .. import _warnings +from .. import urls +from .. import qrc_resources_path +from .. import settings_folderpath +from ..segmenters._cellpose_base import min_target_versions_cp + +if GUI_INSTALLED: + from qtpy.QtWidgets import QMessageBox + from qtpy.QtCore import Signal, QObject, QCoreApplication + + from .. import widgets, apps + from .. import config + +ArgSpec = namedtuple("ArgSpec", ["name", "default", "type", "desc", "docstring"]) + +def get_logs_path(): + return logs_path + + +class Logger(logging.Logger): + def __init__(self, module="base", name="cellacdc-logger", level=logging.DEBUG): + super().__init__(f"{name}-{module}", level=level) + self._stdout = sys.stdout + self._stderr = StdErr(logger=self) + sys.stderr = self._stderr + self._levelToName = { + 50: "CRITICAL", + 40: "ERROR", + 30: "WARNING", + 20: "INFO", + 10: "DEBUG", + 0: "NOTSET", + } + + def write(self, text, log_to_file=True, write_to_stdout=True): + """Capture print statements, print to terminal and log text to + the open log file + + Parameters + ---------- + text : str + Text to log + log_to_file : bool, optional + If True, call `info` method with `text`. Default is True + """ + if write_to_stdout: + self._stdout.write(text) + + if not log_to_file: + return + + if text == "\n": + return + + if not text: + return + + self.debug(text) + + def close(self): + for handler in self.handlers: + handler.close() + self.removeHandler(handler) + sys.stdout = self._stdout + self._stderr.close() + + def __del__(self): + sys.stdout = self._stdout + self._stderr.close() + + def info(self, text, *args, **kwargs): + super().info(text, *args, **kwargs) + try: + self.write(f"{text}\n", log_to_file=False) + except TypeError: + # Sometimes the logger is patched (e.g., by spotiflow), which + # triggers the TypeError because the patching function does not have + # log_to_file argument + self.write(f"{text}\n") + + def warning(self, text, *args, **kwargs): + super().warning(text, *args, **kwargs) + try: + self.write(f"[WARNING]: {text}\n", log_to_file=False) + except TypeError: + # Sometimes the logger is patched (e.g., by spotiflow), which + # triggers the TypeError because the patching function does not have + # log_to_file argument + self.write(f"[WARNING]: {text}\n") + + def error(self, text, *args, write_traceback=True, **kwargs): + super().error(text, *args, **kwargs) + self.write(traceback.format_exc()) + try: + self.write(f"[ERROR]: {text}\n", log_to_file=False) + except TypeError: + # Sometimes the logger is patched (e.g., by spotiflow), which + # triggers the TypeError because the patching function does not have + # log_to_file argument + self.write(f"[ERROR]: {text}\n") + + def plain(self, text, write_to_stdout=False): + orig_formatters = [handler.formatter for handler in self.handlers] + for handler in self.handlers: + handler.setFormatter(logging.Formatter("%(message)s")) + self.write(text, write_to_stdout=write_to_stdout) + for handler in self.handlers: + handler.setFormatter(orig_formatters.pop(0)) + + def critical(self, text, *args, **kwargs): + super().critical(text, *args, **kwargs) + try: + self.write(f"[CRITICAL]: {text}\n", log_to_file=False) + except TypeError: + # Sometimes the logger is patched (e.g., by spotiflow), which + # triggers the TypeError because the patching function does not have + # log_to_file argument + self.write(f"[CRITICAL]: {text}\n") + + def exception(self, text, *args, write_traceback=True, **kwargs): + super().exception(text, *args, **kwargs) + self.write(traceback.format_exc()) + try: + self.write(f"[ERROR]: {text}\n", log_to_file=False) + except TypeError: + # Sometimes the logger is patched (e.g., by spotiflow), which + # triggers the TypeError because the patching function does not have + # log_to_file argument + self.write(f"[ERROR]: {text}\n") + + def log(self, level, text): + if not isinstance(level, int): + printl(level, text, type(level), type(text), sep="\n") + super().log(level, text) + levelName = self._levelToName.get(level, "INFO") + getattr(self, levelName.lower())(text) + + def flush(self): + self._stdout.flush() + + +def delete_older_log_files(logs_path): + if not os.path.exists(logs_path): + return + + log_files = os.listdir(logs_path) + for log_file in log_files: + if not log_file.endswith(".log"): + continue + + log_filepath = os.path.join(logs_path, log_file) + try: + mtime = os.path.getmtime(log_filepath) + except Exception as err: + continue + + mdatetime = datetime.datetime.fromtimestamp(mtime) + days = (datetime.datetime.now() - mdatetime).days + if days < 7: + continue + + try: + os.remove(log_filepath) + except Exception as err: + continue + + +def _log_system_info(logger, log_path, is_cli=False, also_spotmax=False): + logger.info(f'Initialized log file "{log_path}"') + + info_txt = get_info_version_text(is_cli=is_cli) + + logger.info(info_txt) + + if not also_spotmax: + return + + from spotmax.utils import get_info_version_text as smax_info + + smax_info_txt = smax_info(include_platform=False) + logger.info(smax_info_txt) + + +def setupLogger(module="base", logs_path=None, caller="Cell-ACDC"): + if logs_path is None: + logs_path = get_logs_path() + + logger = Logger(module=module) + sys.stdout = logger + + delete_older_log_files(logs_path) + if not os.path.exists(logs_path): + os.mkdir(logs_path) + + date_time = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + id = uuid4() + log_filename = f"{date_time}_{module}_{id}_stdout.log" + log_path = os.path.join(logs_path, log_filename) + + output_file_handler = logging.FileHandler(log_path, mode="w") + + # Format your logs (optional) + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s:\n" + "------------------------\n" + "%(message)s\n" + "------------------------\n", + datefmt="%d-%m-%Y, %H:%M:%S", + ) + output_file_handler.setFormatter(formatter) + + logger.addHandler(output_file_handler) + + _log_system_info(logger, log_path, also_spotmax=caller != "Cell-ACDC") + + # if module == 'gui' and GUI_INSTALLED: + # qt_handler = widgets.QtHandler() + # qt_handler.setFormatter(logging.Formatter("%(message)s")) + # logger.addHandler(qt_handler) + + return logger, logs_path, log_path, log_filename + + +def log_segm_params( + model_name, + init_params, + segm_params, + logger_func=print, + preproc_recipe=None, + apply_post_process=False, + standard_postprocess_kwargs=None, + custom_postprocess_features=None, +): + init_params_format = [ + f" * {option} = {value}" for option, value in init_params.items() + ] + init_params_format = "\n".join(init_params_format) + + segm_params_format = [ + f" * {option} = {value}" for option, value in segm_params.items() + ] + segm_params_format = "\n".join(segm_params_format) + + preproc_recipe_format = None + if preproc_recipe is not None: + preproc_recipe_format = [] + for s, step in enumerate(preproc_recipe): + preproc_recipe_format.append(f" * Step {s + 1}") + method = step["method"] + preproc_recipe_format.append(f" - Method: {method}") + for option, value in step["kwargs"].items(): + preproc_recipe_format.append(f" - {option}: {value}") + preproc_recipe_format = "\n".join(preproc_recipe_format) + + standard_postproc_format = None + if apply_post_process and standard_postprocess_kwargs is not None: + standard_postproc_format = [ + f" * {option} = {value}" + for option, value in standard_postprocess_kwargs.items() + ] + standard_postproc_format = "\n".join(standard_postproc_format) + + custom_postproc_format = None + if apply_post_process and custom_postprocess_features is not None: + custom_postproc_format = [ + f" * {feature} = ({low}, {high})" + for feature, (low, high) in custom_postprocess_features.items() + ] + custom_postproc_format = "\n".join(custom_postproc_format) + + separator = "-" * 100 + params_format = ( + f"{separator}\n" + f"Model name: {model_name}\n\n" + "Preprocessing recipe:\n\n" + f"{preproc_recipe_format}\n\n" + "Initialization parameters:\n\n" + f"{init_params_format}\n\n" + "Segmentation parameters:\n\n" + f"{segm_params_format}\n\n" + "Post-processing:\n\n" + f"{standard_postproc_format}\n\n" + "Custom post-processing:\n\n" + f"{custom_postproc_format}\n" + f"{separator}" + ) + logger_func(params_format) + +# Sibling imports (deferred to avoid import cycles) +from .misc import ( + StdErr, +) +from .version import ( + get_info_version_text, +) + diff --git a/cellacdc/myutils/misc.py b/cellacdc/myutils/misc.py new file mode 100644 index 000000000..c440f36a9 --- /dev/null +++ b/cellacdc/myutils/misc.py @@ -0,0 +1,1651 @@ +"""Cell-ACDC utility helpers: misc.""" + +import os +import re +import ast + +import typing +from typing import Literal, List, Callable, Tuple, Dict + +import pathlib +import difflib +import sys +import platform +import tempfile +import shutil +import traceback +import logging +import datetime +import time +import subprocess +import importlib +from uuid import uuid4 +from importlib import import_module +from math import pow, ceil, floor +from functools import wraps, partial +from collections import namedtuple, Counter +from tqdm import tqdm +import requests +import zipfile +import json +import numpy as np +import pandas as pd +import skimage +import inspect + +import traceback +import itertools +from packaging import version as packaging_version + +from natsort import natsorted + +import tifffile +import skimage.io +import skimage.measure + +from .. import GUI_INSTALLED, KNOWN_EXTENSIONS, is_conda_env + +from .. import core, load +from .. import html_utils, is_linux, is_win, is_mac, issues_url, is_mac_arm64 +from .. import cellacdc_path, printl, acdc_fiji_path, logs_path, acdc_ffmpeg_path +from .. import user_profile_path, recentPaths_path +from .. import models_list_file_path, models_path +from .. import promptable_models_list_file_path, promptable_models_path +from .. import github_home_url +from .. import try_input_install_package +from .. import _warnings +from .. import urls +from .. import qrc_resources_path +from .. import settings_folderpath +from ..segmenters._cellpose_base import min_target_versions_cp + +if GUI_INSTALLED: + from qtpy.QtWidgets import QMessageBox + from qtpy.QtCore import Signal, QObject, QCoreApplication + + from .. import widgets, apps + from .. import config + +ArgSpec = namedtuple("ArgSpec", ["name", "default", "type", "desc", "docstring"]) + +def get_module_name(script_file_path): + parts = pathlib.Path(script_file_path).parts + parts = list(parts[parts.index("cellacdc") + 1 :]) + parts[-1] = os.path.splitext(parts[-1])[0] + module = ".".join(parts) + return module + + +def filterCommonStart(images_path): + startNameLen = 6 + ls = listdir(images_path) + if not ls: + return [] + allFilesStartNames = [f[:startNameLen] for f in ls] + mostCommonStart = Counter(allFilesStartNames).most_common(1)[0][0] + commonStartFilenames = [f for f in ls if f.startswith(mostCommonStart)] + return commonStartFilenames + + +def remove_known_extension(name): + for ext in KNOWN_EXTENSIONS: + if name.endswith(ext): + return name[: -len(ext)], ext + + return name, "" + + +def getCustomAnnotTooltip(annotState): + toolTip = ( + f"Name: {annotState['name']}\n\n" + f"Type: {annotState['type']}\n\n" + f"Usage: activate the button and RIGHT-CLICK on cell to annotate\n\n" + f"Description: {annotState['description']}\n\n" + f'SHORTCUT: "{annotState["shortcut"]}"' + ) + return toolTip + + +def is_iterable(item): + try: + iter(item) + return True + except TypeError as e: + return False + + +class utilClass: + pass + + +class StdErr: + def __init__(self, logger: Logger = None): + self._sys_stderr = sys.stderr + self._err_msg_line_buffer = [] + self._logger = logger + + def write(self, text: str): + if text.startswith("Traceback"): + print("-" * 100) + + self._sys_stderr.write(text) + + if not text: + return + + self._err_msg_line_buffer.append(text) + if not text.endswith("\n"): + return + + # If the line ends with a newline, flush the buffer + err_line = "".join(self._err_msg_line_buffer) + if self._logger is not None: + self._logger.plain(err_line, write_to_stdout=False) + else: + print(err_line) + + self._err_msg_line_buffer = [] + + def flush(self): + self._sys_stderr.flush() + + def close(self): + """Close the StdErr stream""" + sys.stderr = self._sys_stderr + + +def getMostRecentPath(): + if os.path.exists(recentPaths_path): + df = pd.read_csv(recentPaths_path, index_col="index") + if "opened_last_on" in df.columns: + df = df.sort_values("opened_last_on", ascending=False) + MostRecentPath = "" + for path in df["path"]: + if os.path.exists(path): + MostRecentPath = path + break + else: + MostRecentPath = "" + return MostRecentPath + + +def addToRecentPaths(exp_path, logger=None): + if not os.path.exists(exp_path): + return + exp_path = exp_path.replace("\\", "/") + if os.path.exists(recentPaths_path): + try: + df = pd.read_csv(recentPaths_path, index_col="index") + recentPaths = df["path"].to_list() + if "opened_last_on" in df.columns: + openedOn = df["opened_last_on"].to_list() + else: + openedOn = [np.nan] * len(recentPaths) + if exp_path in recentPaths: + pop_idx = recentPaths.index(exp_path) + recentPaths.pop(pop_idx) + openedOn.pop(pop_idx) + recentPaths.insert(0, exp_path) + openedOn.insert(0, datetime.datetime.now()) + # Keep max 40 recent paths + if len(recentPaths) > 40: + recentPaths.pop(-1) + openedOn.pop(-1) + except Exception as e: + recentPaths = [exp_path] + openedOn = [datetime.datetime.now()] + else: + recentPaths = [exp_path] + openedOn = [datetime.datetime.now()] + df = pd.DataFrame( + { + "path": recentPaths, + "opened_last_on": pd.Series(openedOn, dtype="datetime64[ns]"), + } + ) + df.index.name = "index" + df.to_csv(recentPaths_path) + + +def checkDataIntegrity(filenames, parent_path, parentQWidget=None): + if not filenames: + msg = widgets.myMessageBox(wrapText=False) + txt = html_utils.paragraph( + "Cell-ACDC could not find any files in the folder " + f"{parent_path}.

    " + "Please make sure that the folder contains at least one image file.

    " + "Thank you for your patience!" + ) + msg.warning(parentQWidget, "Selected folder is emppty", txt) + raise FileNotFoundError(f"No files found in the folder {parent_path}. ") + + char = filenames[0][:2] + startWithSameChar = all([f.startswith(char) for f in filenames]) + if not startWithSameChar: + msg = widgets.myMessageBox() + txt = html_utils.paragraph( + "Cell-ACDC detected files inside the folder " + "that do not start with the same, common basename.

    " + "To ensure correct loading of the data, the folder where " + "the file(s) is/are should either contain a single image file or" + "only files that start with the same, common basename.

    " + "For example the following filenames:

    " + "F014_s01_phase_contr.tif
    " + "F014_s01_mCitrine.tif

    " + "are named correctly since they all start with the " + 'the common basename "F014_s01_". After the common basename you ' + 'can write whatever text you want. In the example above, "phase_contr" ' + 'and "mCitrine" are the channel names.

    ' + "Data loading may still be successfull, so Cell-ACDC will " + "still try to load data now.
    " + ) + filesFormat = [f" - {file}" for file in filenames] + filesFormat = "\n".join(filesFormat) + detailsText = f"Files present in the folder {parent_path}:\n\n{filesFormat}" + msg.addShowInFileManagerButton(parent_path, txt="Open folder...") + msg.warning( + parentQWidget, + "Data structure compromised", + txt, + detailsText=detailsText, + buttonsTexts=("Cancel", "Ok"), + ) + if msg.cancel: + raise TypeError("Process aborted by the user.") + return False + return True + + +def is_in_bounds(x, y, X, Y): + in_bounds = x >= 0 and x < X and y >= 0 and y < Y + return in_bounds + + +def showInExplorer(path): + if is_mac: + os.system(f'open "{path}"') + elif is_linux: + os.system(f'xdg-open "{path}"') + else: + os.startfile(path) + + +def exec_time(func): + @wraps(func) + def inner_function(self, *args, **kwargs): + t0 = time.perf_counter() + if func.__code__.co_argcount == 1 and func.__defaults__ is None: + result = func(self) + elif func.__code__.co_argcount > 1 and func.__defaults__ is None: + result = func(self, *args) + else: + result = func(self, *args, **kwargs) + t1 = time.perf_counter() + s = f"{func.__name__} execution time = {(t1 - t0) * 1000:.3f} ms" + printl(s, is_decorator=True) + return result + + return inner_function + + +def setRetainSizePolicy(widget, retain=True): + sp = widget.sizePolicy() + sp.setRetainSizeWhenHidden(retain) + widget.setSizePolicy(sp) + + +def getAcdcDfSegmPaths(images_path): + ls = listdir(images_path) + basename = getBasename(ls) + paths = {} + for file in ls: + filePath = os.path.join(images_path, file) + fileName, ext = os.path.splitext(file) + endName = fileName[len(basename) :] + if endName.find("acdc_output") != -1 and ext == ".csv": + info_name = endName.replace("acdc_output", "") + paths.setdefault(info_name, {}) + paths[info_name]["acdc_df_path"] = filePath + paths[info_name]["acdc_df_filename"] = fileName + elif endName.find("segm") != -1 and ext == ".npz": + info_name = endName.replace("segm", "") + paths.setdefault(info_name, {}) + paths[info_name]["segm_path"] = filePath + paths[info_name]["segm_filename"] = fileName + return paths + + +def getChannelFilePath(images_path, chName): + file = "" + alignedFilePath = "" + tifFilePath = "" + h5FilePath = "" + for file in listdir(images_path): + filePath = os.path.join(images_path, file) + if file.endswith(f"{chName}_aligned.npz"): + alignedFilePath = filePath + elif file.endswith(f"{chName}.tif"): + tifFilePath = filePath + elif file.endswith(f"{chName}.h5"): + h5FilePath = filePath + if alignedFilePath: + return alignedFilePath + elif h5FilePath: + return h5FilePath + elif tifFilePath: + return tifFilePath + else: + return "" + + +def get_chname_from_basename(filename, basename, remove_ext=True): + if remove_ext: + filename, ext = os.path.splitext(filename) + chName = filename[len(basename) :] + aligned_idx = chName.find("_aligned") + if aligned_idx != -1: + chName = chName[:aligned_idx] + return chName + + +def getBaseAcdcDf(rp): + zeros_list = [0] * len(rp) + nones_list = [None] * len(rp) + minus1_list = [-1] * len(rp) + IDs = [] + xx_centroid = [] + yy_centroid = [] + zz_centroid = [] + for obj in rp: + xc, yc = obj.centroid[-2:] + IDs.append(obj.label) + xx_centroid.append(xc) + yy_centroid.append(yc) + if len(obj.centroid) == 3: + zc = obj.centroid[0] + zz_centroid.append(zc) + + df = pd.DataFrame( + { + "Cell_ID": IDs, + "is_cell_dead": zeros_list, + "is_cell_excluded": zeros_list, + "x_centroid": xx_centroid, + "y_centroid": yy_centroid, + "was_manually_edited": minus1_list, + } + ).set_index("Cell_ID") + if zz_centroid: + df["z_centroid"] = zz_centroid + + return df + + +def getBasenameAndChNames(images_path, useExt=None): + _tempPosData = utilClass() + _tempPosData.images_path = images_path + load.loadData.getBasenameAndChNames(_tempPosData, useExt=useExt) + return _tempPosData.basename, _tempPosData.chNames + + +def getBasename(files): + basename = files[0] + for file in files: + # Determine the basename based on intersection of all files + _, ext = os.path.splitext(file) + sm = difflib.SequenceMatcher(None, file, basename) + i, j, k = sm.find_longest_match(0, len(file), 0, len(basename)) + basename = file[i : i + k] + return basename + + +def findalliter(patter, string): + """Function used to return all re.findall objects in string""" + m_test = re.findall(r"(\d+)_(.+)", string) + m_iter = [m_test] + while m_test: + m_test = re.findall(r"(\d+)_(.+)", m_test[0][1]) + m_iter.append(m_test) + return m_iter + + +def clipSelemMask(mask, shape, Yc, Xc, copy=True): + if copy: + mask = mask.copy() + + Y, X = shape + h, w = mask.shape + + # Bottom, Left, Top, Right global coordinates of mask + Y0, X0, Y1, X1 = Yc - (h / 2), Xc - (w / 2), Yc + (h / 2), Xc + (w / 2) + mask_limits = [floor(Y0) + 1, floor(X0) + 1, floor(Y1) + 1, floor(X1) + 1] + + if Y0 >= 0 and X0 >= 0 and Y1 <= Y and X1 <= X: + # Mask is withing shape boundaries, no need to clip + ystart, xstart, yend, xend = mask_limits + mask_slice = slice(ystart, yend), slice(xstart, xend) + return mask, mask_slice + + if Y0 < 0: + # Mask is exceeding at the bottom + ystart = floor(abs(Y0)) + mask_limits[0] = 0 + mask = mask[ystart:] + if X0 < 0: + # Mask is exceeding at the left + xstart = floor(abs(X0)) + mask_limits[1] = 0 + mask = mask[:, xstart:] + if Y1 > Y: + # Mask is exceeding at the top + yend = ceil(abs(Y1)) - Y + mask_limits[2] = Y + mask = mask[:-yend] + if X1 > X: + # Mask is exceeding at the right + xend = ceil(abs(X1)) - X + mask_limits[3] = X + mask = mask[:, :-xend] + + ystart, xstart, yend, xend = mask_limits + mask_slice = slice(ystart, yend), slice(xstart, xend) + return mask, mask_slice + + +def get_function_argspec( + function, + args_to_skip={ + "logger_func", + }, +): + argspecs = inspect.getfullargspec(function) + kwargs_type_hints = typing.get_type_hints(function) + docstring = function.__doc__ + params = params_to_ArgSpec( + argspecs, kwargs_type_hints, docstring, args_to_skip=args_to_skip + ) + return params + + +def _get_doc_stop_idx(docstring, start_idx, next_param_name=None, debug=False): + if debug: + import pdb + + pdb.set_trace() + + if next_param_name is not None: + doc_stop_idx = docstring.find(f"{next_param_name} : ") + if doc_stop_idx > 1: + return doc_stop_idx + + docstring_from_start = docstring[start_idx:] + next_param_searched = re.search(r"\w+ : ", docstring_from_start) + if next_param_searched is not None: + return next_param_searched.start(0) + start_idx + + doc_stop_idx = docstring.find("Returns") + if doc_stop_idx > 1: + return doc_stop_idx + + doc_stop_idx = docstring.find("Notes") + if doc_stop_idx > 1: + return doc_stop_idx + + return -1 + + +def add_segm_data_param(init_params, init_argspecs): + if init_argspecs.defaults is None: + num_kwargs = 0 + else: + num_kwargs = len(init_argspecs.defaults) + + # Segm model requires segm data --> add it to params + num_args = len(init_argspecs.args) - num_kwargs + if num_args == 1: + # Args is only self --> segm data not needed + return init_params + + desc = ( + "This model requires an additional segmentation file as input.\n\n" + "Please, select which segmentation file to provide to the model." + ) + + segm_data_argspec = ArgSpec( + name="Auxiliary segmentation file", + default="", + type=str, + desc=desc, + docstring=None, + ) + + init_params.insert(0, segm_data_argspec) + return init_params + + +def getDefault_SegmInfo_df(posData, filename): + mid_slice = int(posData.SizeZ / 2) + df = pd.DataFrame( + { + "filename": [filename] * posData.SizeT, + "frame_i": range(posData.SizeT), + "z_slice_used_dataPrep": [mid_slice] * posData.SizeT, + "which_z_proj": ["single z-slice"] * posData.SizeT, + "z_slice_used_gui": [mid_slice] * posData.SizeT, + "which_z_proj_gui": ["single z-slice"] * posData.SizeT, + "resegmented_in_gui": [False] * posData.SizeT, + "is_from_dataPrep": [False] * posData.SizeT, + } + ).set_index(["filename", "frame_i"]) + return df + + +def _jdk_exists(jre_path): + # If jre_path exists and it's windows search for ~/acdc-java/win64/jdk + # or ~/.acdc-java/win64/jdk. If not Windows return jre_path + if not jre_path: + return "" + os_acdc_java_path = os.path.dirname(jre_path) + os_foldername = os.path.basename(os_acdc_java_path) + if not os_foldername.startswith("win"): + return jre_path + if os.path.exists(os_acdc_java_path): + for folder in os.listdir(os_acdc_java_path): + if not folder.startswith("jdk"): + continue + dir_path = os.path.join(os_acdc_java_path, folder) + for file in os.listdir(dir_path): + if file == "bin": + return dir_path + return "" + + +def showUserManual(): + manual_file_path = download_manual() + showInExplorer(manual_file_path) + + +def get_confirm_token(response): + for key, value in response.cookies.items(): + if key.startswith("download_warning"): + return value + return None + + +def extract_zip(zip_path, extract_to_path, verbose=True): + if verbose: + print(f"Extracting to {extract_to_path}...") + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(extract_to_path) + + +def get_tiff_metadata( + image_arr, + SizeT=None, + SizeZ=None, + PhysicalSizeZ=None, + PhysicalSizeX=None, + PhysicalSizeY=None, + TimeIncrement=None, +): + SizeY, SizeX = image_arr.shape[-2:] + Type = str(image_arr.dtype) + + metadata = {"Pixels": {"SizeX": SizeX, "SizeY": SizeY, "Type": Type}} + + axes = "YX" + if SizeZ is not None and SizeZ > 1: + axes = f"Z{axes}" + metadata["Pixels"]["SizeZ"] = SizeZ + + if SizeT is not None and SizeT > 1: + axes = f"T{axes}" + metadata["Pixels"]["SizeT"] = SizeT + + metadata["axes"] = axes + + if PhysicalSizeX is not None: + metadata["Pixels"]["PhysicalSizeX"] = PhysicalSizeX + + if PhysicalSizeY is not None: + metadata["Pixels"]["PhysicalSizeY"] = PhysicalSizeY + + if PhysicalSizeZ is not None: + metadata["Pixels"]["PhysicalSizeZ"] = PhysicalSizeZ + + if TimeIncrement is not None: + metadata["Pixels"]["TimeIncrement"] = TimeIncrement + + return metadata + + +def to_tiff( + new_path, + data, + SizeT=None, + SizeZ=None, + PhysicalSizeZ=None, + PhysicalSizeX=None, + PhysicalSizeY=None, + TimeIncrement=None, +): + valid_dtypes = (np.uint8, np.uint16, np.float32) + is_valid_dtype = False + for valid_dtype in valid_dtypes: + if np.issubdtype(data.dtype, valid_dtype): + is_valid_dtype = True + break + + if not is_valid_dtype: + data = data.astype(np.float32) + + metadata = get_tiff_metadata( + data, + SizeT=SizeT, + SizeZ=SizeZ, + PhysicalSizeZ=PhysicalSizeZ, + PhysicalSizeX=PhysicalSizeX, + PhysicalSizeY=PhysicalSizeY, + TimeIncrement=TimeIncrement, + ) + + # # Potential alternative + # hyperstack = tifffile.memmap( + # new_path, + # shape=img.shape, + # dtype=img.dtype, + # imagej=True, + # metadata={'axes': 'TZYX'}, + # ) + # hyperstack[:] = img + # hyperstack.flush() + + try: + tifffile.imwrite(new_path, data, metadata=metadata, imagej=True) + except Exception as err: + tifffile.imwrite(new_path, data) + + +def from_lab_to_obj_coords(lab): + rp = skimage.measure.regionprops(lab) + dfs = [] + keys = [] + for obj in rp: + keys.append(obj.label) + obj_coords = obj.coords + ndim = obj_coords.shape[1] + if ndim == 3: + columns = ["z", "y", "x"] + else: + columns = ["y", "x"] + df_obj = pd.DataFrame(data=obj_coords, columns=columns) + dfs.append(df_obj) + df = pd.concat(dfs, keys=keys, names=["Cell_ID", "idx"]).droplevel("idx") + return df + + +def lab2d_to_rois(ImagejRoi, lab2D, ndigits, t=None, z=None): + rp = skimage.measure.regionprops(lab2D) + rois = [] + for obj in rp: + cont = core.get_obj_contours(obj) + yc, xc = obj.centroid + x_str = str((int(xc))).zfill(ndigits) + y_str = str((int(yc))).zfill(ndigits) + name = f"{x_str}-{y_str}" + if z is not None: + z_str = str(z).zfill(ndigits) + name = f"{z_str}-{name}" + + if t is not None: + t_str = str(t).zfill(ndigits) + name = f"{t_str}-{name}" + + name = f"id={obj.label}-{name}" + + roi = ImagejRoi.frompoints(cont, name=name, t=t, z=z, index=obj.label) + rois.append(roi) + return rois + + +def from_lab_to_imagej_rois(lab, ImagejRoi, t=0, SizeT=1, max_ID=None): + if max_ID is None: + max_ID = lab.max() + + if SizeT == 1: + t = None + + SizeY, SizeX = lab.shape[-2:] + ndigitsT = len(str(SizeT)) + ndigitsY = len(str(SizeY)) + ndigitsX = len(str(SizeX)) + + if lab.ndim == 3: + rois = [] + SizeZ = len(lab) + ndigitsZ = len(str(SizeZ)) + ndigits = max(ndigitsT, ndigitsZ, ndigitsY, ndigitsX) + for z, lab2D in enumerate(lab): + z_rois = lab2d_to_rois(ImagejRoi, lab2D, ndigits, t=t, z=z) + rois.extend(z_rois) + else: + ndigits = max(ndigitsT, ndigitsY, ndigitsX) + rois = lab2d_to_rois(ImagejRoi, lab, ndigits, t=t) + return rois + + +def from_imagej_rois_to_segm_data( + TZYX_shape, ID_to_roi_mapper, rescale_rois_sizes, repeat_2d_rois_zslices_range +): + SizeT, SizeZ, SizeY, SizeX = TZYX_shape + segm_data = np.zeros(TZYX_shape, dtype=np.uint32) + for ID, roi in ID_to_roi_mapper.items(): + name = roi.name + name_parts = name.split("-") + zz = [0] + if len(name_parts) == 2 and SizeZ > 1: + # 2D roi in 3D segm data --> place 2D roi on each z-slice + zz = range(*repeat_2d_rois_zslices_range) + + elif len(name_parts) > 2 and SizeZ > 1: + # 2D roi from a 3D roi --> place at requested z-slice + zz = [int(name_parts[-3])] + + tt = [0] * len(zz) + if SizeT > 1: + tt = [roi.t_position] * len(zz) + + y0, x0 = roi.top, roi.left + contours = roi.integer_coordinates + (x0, y0) + xx = contours[:, 0] + yy = contours[:, 1] + if rescale_rois_sizes is not None: + rescale_z = rescale_rois_sizes["Z"] + rescale_y = rescale_rois_sizes["Y"] + rescale_x = rescale_rois_sizes["X"] + + factor_z = rescale_z[1] / rescale_z[0] + factor_y = rescale_y[1] / rescale_y[0] + factor_x = rescale_x[1] / rescale_x[0] + + xx = np.clip(np.round(xx * factor_x).astype(int), 0, SizeX - 1) + yy = np.clip(np.round(yy * factor_y).astype(int), 0, SizeY - 1) + + for t, z in zip(tt, zz): + if rescale_rois_sizes is not None: + z = round(z * factor_z) + z = z if z < SizeZ else SizeZ + z = z if z >= 0 else 0 + + rr, cc = skimage.draw.polygon(yy, xx) + segm_data[t, z, rr, cc] = ID + + return np.squeeze(segm_data) + + +def seconds_to_ETA(seconds): + seconds = round(seconds) + ETA = datetime.timedelta(seconds=seconds) + ETA_split = str(ETA).split(":") + if seconds < 0: + ETA = "00h:00m:00s" + elif seconds >= 86400: + days, hhmmss = str(ETA).split(",") + h, m, s = hhmmss.split(":") + ETA = f"{days}, {int(h):02}h:{int(m):02}m:{int(s):02}s" + else: + h, m, s = str(ETA).split(":") + ETA = f"{int(h):02}h:{int(m):02}m:{int(s):02}s" + return ETA + + +def to_uint8(img): + if img.dtype == np.uint8: + return img + img = np.round(img_to_float(img) * 255).astype(np.uint8) + return img + + +def to_uint16(img): + if img.dtype == np.uint16: + return img + img = np.round(img_to_float(img) * 65535).astype(np.uint16) + return img + + +def img_to_float(img, force_dtype=None, force_missing_dtype=None, warn=True): + input_img_dtype = img.dtype + value = img[(0,) * img.ndim] + img_max = np.max(img) + # Check if float outside of -1, 1 + if img_max <= 1.0 and isinstance(value, (np.floating, float)): + return img + + uint8_max = np.iinfo(np.uint8).max + uint16_max = np.iinfo(np.uint16).max + uint32_max = np.iinfo(np.uint32).max + + img = img.astype(float) + if force_dtype is not None: + dtype_max = np.iinfo(force_dtype).max + img = img / dtype_max + elif input_img_dtype == np.uint8: + # Input image is 8-bit + img = img / uint8_max + elif input_img_dtype == np.uint16: + # Input image is 16-bit + img = img / uint16_max + elif input_img_dtype == np.uint32: + # Input image is 32-bit + img = img / uint32_max + elif force_missing_dtype is not None: + img = img.astype(force_dtype) + elif img_max <= uint8_max: + # Input image is probably 8-bit + if warn: + _warnings.warn_image_overflow_dtype(input_img_dtype, img_max, "8-bit") + img = img / uint8_max + elif img_max <= uint16_max: + # Input image is probably 16-bit + if warn: + _warnings.warn_image_overflow_dtype(input_img_dtype, img_max, "16-bit") + img = img / uint16_max + elif img_max <= uint32_max: + # Input image is probably 32-bit + if warn: + _warnings.warn_image_overflow_dtype(input_img_dtype, img_max, "32-bit") + img = img / uint32_max + else: + # Input image is a non-supported data type + raise TypeError( + f"The maximum value in the image is {img_max} which is greater than the " + f"maximum value supported of {uint32_max} (32-bit). " + "Please consider converting your images to 32-bit or 16-bit first." + ) + return img + + +def float_img_to_dtype(img, dtype): + if img.dtype == dtype: + return img + + img_max = img.max() + if img_max > 1.0: + raise TypeError( + "Images of float data type with values greater than 1.0 cannot " + f"be safely casted to {dtype}. " + f"The max value of the input image is {img_max:.3f}" + ) + + img_min = img.min() + if img_min < -1.0: + raise TypeError( + "Images of float data type with values smaller than -1.0 cannot " + f"be safely casted to {dtype}." + f"The minumum value of the input image is {img_min:.3f}" + ) + + if dtype == np.uint8: + return skimage.img_as_ubyte(img) + + if dtype == np.uint16: + return skimage.img_as_uint(img) + + if dtype == np.float32: + return img.astype(np.float32) + + if dtype == np.float64: + return img.astype(np.float64) + + raise TypeError( + f"Invalid output data type `{dtype}`. " + "Valid output data types are `np.uint8` and `np.uint16`" + ) + + +def convert_to_dtype(data: np.ndarray, dtype): + if data.dtype == dtype: + return data + val = data[tuple([0] * data.ndim)] + if isinstance(val, (np.floating, float)): + data = float_img_to_dtype(data, dtype) + elif dtype == np.uint8: + data = np.round(img_to_float(data) * 255).astype(np.uint8) + elif dtype == np.uint16: + data = np.round(img_to_float(data) * 65535).astype(np.uint16) + else: + raise TypeError( + f"Invalid output data type `{dtype}`. " + "Valid data types are floating-point format, `np.uint8` " + "and `np.uint16`" + ) + return data + + +def _apt_update_command(): + return "sudo apt-get update" + + +def _apt_gcc_command(): + return "sudo apt install python-dev gcc" + + +def jdk_windows_url(): + return "https://hmgubox2.helmholtz-muenchen.de/index.php/s/R62Ktcda6jWea2s" + + +def cpp_windows_url(): + return "https://visualstudio.microsoft.com/visual-cpp-build-tools/" + + +def check_napari_plugin(plugin_name, module_name, parent=None): + try: + import_module(module_name) + except ModuleNotFoundError as e: + url = "https://napari.org/stable/plugins/find_and_install_plugin.html#find-and-install-plugins" + href = html_utils.href_tag("this guide", url) + txt = html_utils.paragraph(f""" + To correctly use this napari utility you need to install the + plugin called {plugin_name}.

    + Please, read {href} on how to install plugins in napari.

    + You will need to restart both napari and Cell-ACDC after installing + the plugin.

    + NOTE: in the text box in napari you will need to write the full name + {plugin_name} becasue it is NOT A SEARCH BOX. + """) + msg = widgets.myMessageBox() + msg.critical(parent, f"Napari plugin required", txt) + raise e + + +def purge_module(module_name): + to_delete = [ + mod + for mod in sys.modules + if mod == module_name or mod.startswith(module_name + ".") + ] + for mod in to_delete: + del sys.modules[mod] + + importlib.invalidate_caches() + importlib.import_module(module_name) + if module_name in sys.modules: + importlib.reload(sys.modules[module_name]) + else: + raise ModuleNotFoundError(f"Module '{module_name}' not found in sys.modules.") + + +def is_gui_running(): + if not GUI_INSTALLED: + return False + + return QCoreApplication.instance() is not None + + +def _subprocess_run_command(command, shell=True, callback="check_call"): + func = getattr(subprocess, callback) + try: + out = func(command, shell=shell) + except Exception as err: + print( + f"[WARNING]: Command `{command}` failed. Trying with `{command.split()}`..." + ) + out = func(command.split(), shell=shell) + + return out + + +def _run_command(command: str | list[str], shell=False): + if not isinstance(command, (str, list)): + raise TypeError( + f"Command must be a string or a list of strings, not {type(command)}" + ) + + command_str = None + if isinstance(command, str): + args_list = [command] + command_str = command + else: + args_list = command + if len(command) == 1: + command_str = command[0] + + try: + subprocess.check_call(args_list, shell=shell) + return + except Exception as err: + pass + + if command_str is None: + return + + try: + subprocess.check_call(command_str, shell=shell) + return + except Exception as err: + pass + + try: + from . import acdc_regex + + args = acdc_regex.RE_SPLIT_SPACES_IGNORE_QUOTES.split(command_str)[1::2] + subprocess.check_call(args, shell=shell) + return + except Exception as err: + pass + + +def get_chained_attr(_object, _name): + for attr in _name.split("."): + _object = getattr(_object, attr) + return _object + + +def get_fiji_base_command(): + command = None + if is_mac: + command = get_fiji_exec_folderpath() + + return command + + +def _init_fiji_cli(): + if is_win: + return True + + fiji_app_folderpath = get_fiji_exec_folderpath() + args_add_to_path = [f"chmod 755 {fiji_app_folderpath}"] + try: + subprocess.check_call(args_add_to_path, shell=True) + return True + except Exception as e: + printl(f"Error occurred while setting permissions: {e}") + return False + + +def test_fiji_base_command(logger_func=print): + base_command = get_fiji_base_command() + + if base_command is None: + logger_func("[WARNING]: Fiji is not present.") + return False + + command = f"{base_command} --headless" + return run_fiji_command(command=command, logger_func=logger_func) + + +def run_fiji_command(command=None, logger_func=print): + if command is None: + command = f"{get_fiji_base_command()} --headless" + + init_success = _init_fiji_cli() + if not init_success: + return False + + separator = "-" * 100 + commands = (command, command.split()) + for args in commands: + logger_func(f'{separator}\nTrying Fiji command: "{args}"...\n{separator}\n') + try: + subprocess.check_call(args, shell=True) + return True + except Exception as err: + continue + return False + + +def import_segment_module(model_name): + try: + acdcSegment = import_module(f"cellacdc.segmenters.{model_name}.acdcSegment") + except ModuleNotFoundError as e: + # Check if custom model + cp = config.ConfigParser() + cp.read(models_list_file_path) + model_path = cp[model_name]["path"] + spec = importlib.util.spec_from_file_location("acdcSegment", model_path) + acdcSegment = importlib.util.module_from_spec(spec) + sys.modules["acdcSegment"] = acdcSegment + spec.loader.exec_module(acdcSegment) + return acdcSegment + + +def _available_frameworks(model_name): + frameworks = { + "cuda": ( + model_name.lower().find("cellpose") != -1 + or model_name.lower().find("omnipose") != -1 + or model_name.lower().find("deepsea") != -1 + or model_name.lower().find("segment_anything") != -1 + or model_name.lower().find("sam2") != -1 + or model_name.lower().find("yeaz") != -1 + or model_name.lower().find("yeaz_v2") != -1 + ), + "directML": ( + model_name.lower().find("cellpose_v4") != -1 + or model_name.lower().find("cellpose_v3") != -1 # has its own way to check + ), + } + return frameworks + + +def find_missing_integers(lst, max_range=None): + if max_range is not None: + max_range = lst[-1] + 1 + return [x for x in range(lst[0], max_range) if x not in lst] + + +def synthetic_image_geneator(size=(512, 512), f_x=1, f_y=1): + Y, X = size + x = np.linspace(0, 10, Y) + y = np.linspace(0, 10, X) + xx, yy = np.meshgrid(x, y) + img = np.sin(f_x * xx) * np.cos(f_y * yy) + return img + + +def get_slices_local_into_global_arr(bbox_coords, global_shape): + slice_global_to_local = [] + slice_crop_local = [] + for (_min, _max), _D in zip(bbox_coords, global_shape): + _min_crop, _max_crop = None, None + if _min < 0: + _min_crop = abs(_min) + _min = 0 + if _max > _D: + _max_crop = _D - _max + _max = _D + + slice_global_to_local.append(slice(_min, _max)) + slice_crop_local.append(slice(_min_crop, _max_crop)) + + return tuple(slice_global_to_local), tuple(slice_crop_local) + + +def format_cca_manual_changes(changes: dict): + txt = "" + for ID, changes_ID in changes.items(): + txt = f"{txt}* ID {ID}:\n" + for col, (old_val, new_val) in changes_ID.items(): + txt = f"{txt} - {col}: {old_val} --> {new_val}\n" + txt = f"{txt}--------------------------------\n\n" + return txt + + +def _parse_bool_str(value): + if isinstance(value, bool): + return value + + if value == "True": + return True + elif value == "False": + return False + + +def init_input_points_df(posData, input_points_filepath): + input_points_df = None + if os.path.exists(input_points_filepath): + input_points_df = pd.read_csv(input_points_filepath) + else: + # input_points_filepath is actually and endname + for file in listdir(posData.images_path): + if file.endswith(input_points_filepath): + filepath = os.path.join(posData.images_path, file) + input_points_df = pd.read_csv(filepath) + break + + if input_points_df is None: + raise FileNotFoundError( + f'Could not find input points table from file "input_points_filepath" ' + "Perhaps, you forgot to save the table?" + ) + + for col in ("x", "y", "id"): + if col not in input_points_df.columns: + raise KeyError( + f"Input points table is missing colum {col}. It must have " + "the colums (x, y, id)" + ) + + return input_points_df + + +def pairwise(iterable): + # pairwise('ABCDEFG') → AB BC CD DE EF FG + iterator = iter(iterable) + a = next(iterator, None) + for b in iterator: + yield a, b + a = b + + +def _relabel_cca_dfs_and_segm_data( + cca_dfs, + IDs_mapper, + asymm_tracked_segm, + progressbar=True, +): + # Rename Cell_ID index according to asymmetric cell div convention + if progressbar: + pbar = tqdm( + desc="Applying asymmetric division", total=len(IDs_mapper), ncols=100 + ) + for key, (root_ID, parent_ID) in IDs_mapper.items(): + div_frame_i, daughter_ID = key + for frame_i in range(div_frame_i, len(asymm_tracked_segm)): + lab = asymm_tracked_segm[frame_i] + rp = skimage.measure.regionprops(lab) + rp_mapper = {obj.label: obj for obj in rp} + obj_daught = rp_mapper.get(daughter_ID) + mother_ID = root_ID if rp_mapper.get(root_ID) is None else parent_ID + + cca_dfs[frame_i].rename(index={daughter_ID: mother_ID}, inplace=True) + + if obj_daught is None: + continue + + lab[obj_daught.slice][obj_daught.image] = mother_ID + + if progressbar: + pbar.update() + + if progressbar: + pbar.close() + + +def get_empty_stored_data_dict(): + return { + "regionprops": None, + "labels": None, + "acdc_df": None, + "delROIs_info": {"rois": [], "delMasks": [], "delIDsROI": [], "state": []}, + "IDs": [], + "manually_edited_lab": {"lab": {}, "zoom_slice": None}, + } + + +def iterate_along_axes(arr, axes, arr_ndim=None): + if arr_ndim is None: + arr_ndim = arr.ndim + axes = list(axes) + front_axes = axes + [i for i in range(arr_ndim) if i not in axes] + arr_moved = np.moveaxis(arr, front_axes, range(arr_ndim)) + iter_shape = arr_moved.shape[: len(axes)] + for idx in np.ndindex(iter_shape): + # Build the index for the original array + full_idx = [slice(None)] * arr_ndim + for axis, i in zip(axes, idx): + full_idx[axis] = i + yield tuple(full_idx) + + +def get_input_output_mapper( + input_shape: Tuple[int], + iterate_axes: Tuple[int], + output_shape: Tuple[int], + output_axes: Tuple[int], +) -> List[Tuple[Tuple[int, ...], Tuple[int, ...]]]: + """Creates list of tuples with the input and output indices + + Parameters + ---------- + input_shape : Tuple[int] + Shape of the input array + iterate_axes : Tuple[int] + Axes to iterate over + output_shape : Tuple[int] + Shape of the output array + output_axes : Tuple[int] + Axes of the output array + """ + assert len(iterate_axes) == len(output_axes) + + iterate_shape = tuple(input_shape[axis] for axis in iterate_axes) + mapper = [] + + for idx_vals in itertools.product(*[range(s) for s in iterate_shape]): + # Build full input index + input_index = [slice(None)] * len(input_shape) + for axis in iterate_axes: + i = iterate_axes.index(axis) + input_index[axis] = idx_vals[i] + + # Build full output index + output_index = [slice(None)] * len(output_shape) + for axis in output_axes: + i = output_axes.index(axis) + output_index[axis] = idx_vals[i] + + input_index = tuple(input_index) + output_index = tuple(output_index) + + mapper.append((input_index, output_index)) + + return mapper + + +def translateStrNone(*args): + args = list(args) + for i, arg in enumerate(args): + if isinstance(arg, str): + if arg.lower() == "none": + args[i] = None + elif arg.lower() == "true": + args[i] = True + elif arg.lower() == "false": + args[i] = False + + return args + + +def try_kwargs(func, *args, **kwargs): + """ + Attempt to call a function with the provided arguments and keyword arguments. + + If the function raises a TypeError due to unexpected keyword arguments, + those arguments are dynamically removed, and the function is retried. + This process continues until the function succeeds or no keyword arguments + remain, in which case the exception is re-raised. + + Args: + func (Callable): The function to call. + *args: Positional arguments to pass to the function. + **kwargs: Keyword arguments to pass to the function. + + Returns: + Tuple[Any, List[str]]: A tuple containing: + - The result of the function call (or None if it fails). + - A list of keyword arguments that were removed. + + Raises: + ValueError: If a keyword argument mentioned in the error message + is not found in the provided kwargs. + TypeError: If the function fails with a TypeError after all keyword + arguments have been removed. + """ + + kwargs = kwargs.copy() # Create a copy to avoid modifying the original + removed_kwargs = [] + pattern = r"unexpected keyword argument ['\"](\w+)['\"]" + while True: + try: + return func(*args, **kwargs), removed_kwargs + except TypeError as e: + match = re.search(pattern, str(e)) + if match: + kwarg_name = match.group(1) + if kwarg_name in kwargs: + del kwargs[kwarg_name] + removed_kwargs.append(kwarg_name) + else: + raise ValueError( + f"Keyword argument '{kwarg_name}' not found in kwargs." + ) + else: + raise e + + if len(kwargs) == 0: + print(f"Function {func.__name__} failed with TypeError: {e}") + raise e + + +def get_obj_by_label(rp, target_label): + """ + Returns the object with the specified label from the given list of objects. + + Parameters + ---------- + rp : list + The list of objects to search through. + target_label : str + The label of the object to find. + + Returns + ------- + object + The object with the specified label, or None if not found. + """ + for obj in rp: + if obj.label == target_label: + return obj + return None + + +def find_distances_ID(rps, point=None, ID=None): + """ + Calculate the distances between a given point and the centroids of a list of regionprops. + + Parameters + ---------- + rps : list + List of regionprops objects. + point : tuple, optional + The coordinates of the point. Defaults to None. + ID : int, optional + The label ID of the regionprops object. Defaults to None. + + Returns + ------- + numpy.ndarray + A matrix of distances between the point and the centroids. + + Raises + ------ + ValueError + If ID is not found in the list of regionprops (list of cells). + ValueError + If neither ID nor point is provided. + ValueError + If both ID and point are provided. + """ + + if ID is not None and point is None: + try: + point = [rp.centroid for rp in rps if rp.label == ID][0] + except IndexError: + raise ValueError(f"ID {ID} not found in regionprops (list of cells).") + + elif ID is None and point is None: + raise ValueError("Either ID or point must be provided.") + + elif ID is not None and point is not None: + raise ValueError("Only one of ID or point must be provided.") + + point = point[ + ::-1 + ] # rp are in (y, x) format (or (z, y, x) for 3D data) so I need to reverse order + point = np.array([point]) + centroids = np.array([rp.centroid for rp in rps]) + diff = point[:, np.newaxis] - centroids + dist_matrix = np.linalg.norm(diff, axis=2) + return dist_matrix + + +def sort_IDs_dist(rps, point=None, ID=None): + """Sorts the IDs of regionprops based on their distances to a given point. + + Parameters + ---------- + rps : list + A list of regionprops objects representing cells. + point : tuple, optional + The coordinates of the point to calculate distances from. + If not provided, it will be calculated based on the given ID. + ID : int, optional + The ID of the regionprops object to calculate distances from. + If this and point are both provided, or neither, an error will be + raised. + + Returns + ------- + list + A sorted list of IDs based on their distances to the given point. + + Raises + ------ + ValueError + If ID is not found in the list of regionprops objects. + ValueError + If neither ID nor point is provided. + ValueError + If both ID and point are provided. + + """ + if ID is not None and point is None: + try: + point = [rp.centroid for rp in rps if rp.label == ID][0] + except IndexError: + raise ValueError(f"ID {ID} not found in regionprops (list of cells).") + + elif ID is None and point is None: + raise ValueError("Either ID or point must be provided.") + + elif ID is not None and point is not None: + raise ValueError("Only one of ID or point must be provided.") + + IDs = [rp.label for rp in rps] + if len(IDs) == 0: + return [] + elif len(IDs) == 1: + return IDs + dist_matrix = find_distances_ID(rps, point=point) + dist_matrix = np.squeeze(dist_matrix) + + sorted_ids = sorted(zip(dist_matrix, IDs)) + sorted_ids = [ID for _, ID in sorted_ids] + return sorted_ids + + +def safe_get_or_call(obj, path: str): + """Safely get nested attributes or call methods with literal args from a string path.""" + expr = ast.parse(path, mode="eval").body + + def _eval(node, current_obj): + if isinstance(node, ast.Attribute): + return getattr(_eval(node.value, current_obj), node.attr) + elif isinstance(node, ast.Call): + func = _eval(node.func, current_obj) + args = [ast.literal_eval(arg) for arg in node.args] + kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in node.keywords} + return func(*args, **kwargs) + elif isinstance(node, ast.Name): + # First name in chain is assumed to be from `obj` + return getattr(current_obj, node.id) + else: + raise ValueError(f"Unsupported syntax: {ast.dump(node)}") + + return _eval(expr, obj) + + +def format_commit_date_utc(utc_str): + # Parse the UTC date string (ISO 8601 format) + dt = datetime.datetime.fromisoformat(utc_str.replace("Z", "+00:00")) + + # Convert to your local time zone (optional) + local_dt = dt.astimezone() # removes UTC offset if local + + # Format nicely + return local_dt.strftime(r"%A %d %B %Y at %H:%M") + + +def get_linux_distribution_name(): + import csv + + RELEASE_DATA = {} + with open("/etc/os-release") as f: + reader = csv.reader(f, delimiter="=") + for row in reader: + if row: + RELEASE_DATA[row[0]] = row[1] + if RELEASE_DATA["ID"] in ["debian", "raspbian"]: + with open("/etc/debian_version") as f: + DEBIAN_VERSION = f.readline().strip() + major_version = DEBIAN_VERSION.split(".")[0] + version_split = RELEASE_DATA["VERSION"].split(" ", maxsplit=1) + if version_split[0] == major_version: + # Just major version shown, replace it with the full version + RELEASE_DATA["VERSION"] = " ".join([DEBIAN_VERSION] + version_split[1:]) + + name_version = f"{RELEASE_DATA['NAME']} {RELEASE_DATA['VERSION']}" + + return name_version + + +def reset_settings(): + question = ( + 'Do you want to reset Cell-ACDC settings- type "h" for help - (y/[n]/h)? ' + ) + info_txt = ( + "If you reset Cell-ACDC settings, the folder below will be deleted.\n\n" + "This means deeleting things like custom shortcuts, recent paths, last " + "selections, and GUI preferences.\n\n" + f'Settings folder path: "{settings_folderpath}"' + ) + answer = "y" + while True: + try: + answer = input(f"\n{question}") + except Exception as err: + break + + if answer == "n": + print("*" * 100) + return "Resetting Cell-ACDC settings cancelled." + + if answer == "y": + break + + if answer == "h": + print("-" * 100) + print(f"\n{info_txt}") + print("=" * 100) + + print( + f'"{answer}" is not a valid answer. ' + 'Type "y" for "yes", "n" for "no", or "h" for help.' + ) + + try: + os.remove(settings_folderpath) + print("*" * 100) + out_txt = ( + "Cell-ACDC settings have been reset.\n\n" + "The following folder was deleted:\n\n" + f"{settings_folderpath}" + ) + except Exception as err: + traceback.print_exc() + print("*" * 100) + out_txt = ( + "**ERROR** occured when trying to remove the settings folder.\n\n" + "To reset Cell-ACDC settings, please remove this folder:\n\n" + f"{settings_folderpath}\n" + ) + return out_txt + + +def separate_fluo_segment_channels(channels): + segms_to_load = [] + channels_to_load = [] + current_segm = False + for ch in channels: + if ch == "current segm.": + current_segm = True + elif "segm" in ch: + segms_to_load.append(ch) + else: + channels_to_load.append(ch) + return segms_to_load, channels_to_load, current_segm + +# Sibling imports (deferred to avoid import cycles) +from .logging import ( + Logger, +) +from .models import ( + download_manual, + params_to_ArgSpec, +) +from .paths import ( + get_fiji_exec_folderpath, + listdir, +) + diff --git a/cellacdc/myutils/models.py b/cellacdc/myutils/models.py new file mode 100644 index 000000000..8a5262a57 --- /dev/null +++ b/cellacdc/myutils/models.py @@ -0,0 +1,1150 @@ +"""Cell-ACDC utility helpers: models.""" + +import os +import re +import ast + +import typing +from typing import Literal, List, Callable, Tuple, Dict + +import pathlib +import difflib +import sys +import platform +import tempfile +import shutil +import traceback +import logging +import datetime +import time +import subprocess +import importlib +from uuid import uuid4 +from importlib import import_module +from math import pow, ceil, floor +from functools import wraps, partial +from collections import namedtuple, Counter +from tqdm import tqdm +import requests +import zipfile +import json +import numpy as np +import pandas as pd +import skimage +import inspect + +import traceback +import itertools +from packaging import version as packaging_version + +from natsort import natsorted + +import tifffile +import skimage.io +import skimage.measure + +from .. import GUI_INSTALLED, KNOWN_EXTENSIONS, is_conda_env + +from .. import core, load +from .. import html_utils, is_linux, is_win, is_mac, issues_url, is_mac_arm64 +from .. import cellacdc_path, printl, acdc_fiji_path, logs_path, acdc_ffmpeg_path +from .. import user_profile_path, recentPaths_path +from .. import models_list_file_path, models_path +from .. import promptable_models_list_file_path, promptable_models_path +from .. import github_home_url +from .. import try_input_install_package +from .. import _warnings +from .. import urls +from .. import qrc_resources_path +from .. import settings_folderpath +from ..segmenters._cellpose_base import min_target_versions_cp + +if GUI_INSTALLED: + from qtpy.QtWidgets import QMessageBox + from qtpy.QtCore import Signal, QObject, QCoreApplication + + from .. import widgets, apps + from .. import config + +ArgSpec = namedtuple("ArgSpec", ["name", "default", "type", "desc", "docstring"]) + +def get_add_custom_prompt_model_instructions(): + init_sh = html_utils.init_sh + segment_sh = html_utils.segment_sh + add_prompt_sh = html_utils.add_prompt_sh + href = f'here' + text = html_utils.paragraph(f""" + To use a custom prompt model, you need to create a Python file with the name + acdcPromptModel.py.
    + Note that the folder name where you place this file will be used as the + model name.

    + In this file, you will implement a class called Model with + at least the {init_sh} to initialise the model,
    + the {add_prompt_sh} method to add prompts (points, boxes, etc.) + to the model, and the {segment_sh} method to run the + segmentation.

    + Have a look at the existing models in the promptable_models + folder for examples.

    + If it doesn't work, please report the issue {href} with the + code you wrote. Thanks! + """) + return text + + +def get_add_custom_model_instructions(): + user_manual_url = "https://github.com/SchmollerLab/Cell_ACDC/blob/main/UserManual/Cell-ACDC_User_Manual.pdf" + href_user_manual = f'user manual' + href = f'here' + class_sh = html_utils.class_sh + def_sh = html_utils.def_sh + kwargs_sh = html_utils.kwargs_sh + Model_sh = html_utils.Model_sh + segment_sh = html_utils.segment_sh + predict_sh = html_utils.predict_sh + init_sh = html_utils.init_sh + myModel_sh = html_utils.myModel_sh + return_sh = html_utils.return_sh + equal_sh = html_utils.equal_sh + open_par_sh = html_utils.open_par_sh + close_par_sh = html_utils.close_par_sh + image_sh = html_utils.image_sh + from_sh = html_utils.from_sh + import_sh = html_utils.import_sh + s = html_utils.paragraph(f""" + To use a custom model first create a folder with the name of your model.

    + Inside this new folder create a file named acdcSegment.py.

    + In the acdcSegment.py file you will implement the model class.

    + Have a look at the other existing models, but essentially you have to create + a class called Model with at least
    + the {init_sh} and the {segment_sh} method.

    + The {segment_sh} method takes the image (2D or 3D) as an input and return the segmentation mask.

    + You can find more details in the {href_user_manual} at the section + called Adding segmentation models to the pipeline.

    + Pseudo-code for the acdcSegment.py file: +
    
    +    {from_sh} myModel {import_sh} {myModel_sh}
    +
    +    {class_sh} {Model_sh}:
    +        {def_sh} {init_sh}(self, {kwargs_sh}):
    +            self.model {equal_sh} {myModel_sh}{open_par_sh}{close_par_sh}
    +
    +        {def_sh} {segment_sh}(self, {image_sh}, {kwargs_sh}):
    +            labels {equal_sh} self.model.{predict_sh}{open_par_sh}{image_sh}{close_par_sh}
    +            {return_sh} labels
    +    
    + + If it doesn't work, please report the issue {href} with the + code you wrote. Thanks. + """) + return s + + +def setDefaultValueArgSpecsFromKwargs(params: List[ArgSpec], kwargs: Dict[str, object]): + new_params = [] + for param in params: + new_value = kwargs.get(param.name) + if new_value is None: + new_params.append(param) + continue + + new_param = ArgSpec( + name=param.name, + default=new_value, + type=param.type, + desc=param.desc, + docstring=param.docstring, + ) + new_params.append(new_param) + return new_params + + +def insertModelArgSpec( + params, param_name, param_value, param_type=None, desc="", docstring="" +): + updated_params = [] + for param in params: + if param.name == param_name: + if param_type is None: + param_type = param.type + new_param = ArgSpec( + name=param_name, + default=param_value, + type=param_type, + desc=desc, + docstring=docstring, + ) + updated_params.append(new_param) + else: + updated_params.append(param) + return updated_params + + +def getModelArgSpec(acdcSegment): + init_ArgSpec = inspect.getfullargspec(acdcSegment.Model.__init__) + init_kwargs_type_hints = typing.get_type_hints(acdcSegment.Model.__init__) + init_doc = acdcSegment.Model.__init__.__doc__ + init_params = params_to_ArgSpec(init_ArgSpec, init_kwargs_type_hints, init_doc) + init_params = add_segm_data_param(init_params, init_ArgSpec) + + segment_ArgSpec = inspect.getfullargspec(acdcSegment.Model.segment) + segment_kwargs_type_hints = typing.get_type_hints(acdcSegment.Model.segment) + try: + segment_ArgSpec.args.remove("frame_i") + except Exception as e: + pass + + segment_doc = acdcSegment.Model.segment.__doc__ + segment_params = params_to_ArgSpec( + segment_ArgSpec, + segment_kwargs_type_hints, + segment_doc, + ) + + return init_params, segment_params + + +def parse_model_param_doc(name, next_param_name=None, docstring=None): + if not docstring: + return "" + + try: + # Extract parameter description from 'param : ...' + start_text = f"{name} : " + if docstring.find(start_text) == -1: + # Parameter not present in docstring + return "" + + doc_start_idx = docstring.find(start_text) + len(start_text) + + doc_stop_idx = _get_doc_stop_idx( + docstring, doc_start_idx, next_param_name=next_param_name + ) + if doc_stop_idx == -1: + doc_stop_idx = len(docstring) + + param_doc = docstring[doc_start_idx:doc_stop_idx] + + # Start at first end of line + param_doc = param_doc[param_doc.find("\n") + 1 :] + + # Replace multiples spaces with single space + param_doc = re.sub(" +", " ", param_doc) + + # Remove trailing spaces + param_doc = param_doc.strip() + except Exception as err: + param_doc = "" + + param_doc = param_doc.replace(", optional", "") + + return param_doc + + +def params_to_ArgSpec(fullargspecs, type_hints, docstring, args_to_skip=None): + params = [] + + if fullargspecs.defaults is None: + return params + + if args_to_skip is None: + args_to_skip = set() + + num_params = len(fullargspecs.args) + ip = num_params - len(fullargspecs.defaults) + if ip < 0: + return params + + for arg, default in zip(fullargspecs.args[ip:], fullargspecs.defaults): + if arg in args_to_skip: + continue + + if arg in type_hints: + _type = type_hints[arg] + else: + _type = type(default) + + next_param_name = None + if ip + 1 < num_params: + next_param_name = fullargspecs.args[ip + 1] + + param_doc = parse_model_param_doc( + arg, next_param_name=next_param_name, docstring=docstring + ) + param = ArgSpec( + name=arg, default=default, type=_type, desc=param_doc, docstring=docstring + ) + params.append(param) + ip += 1 + return params + + +def getClassArgSpecs(classModule, runMethodName="run"): + init_ArgSpec = inspect.getfullargspec(classModule.__init__) + init_kwargs_type_hints = typing.get_type_hints(classModule.__init__) + init_doc = classModule.__init__.__doc__ + init_params = params_to_ArgSpec(init_ArgSpec, init_kwargs_type_hints, init_doc) + + run_ArgSpec = inspect.getfullargspec(getattr(classModule, runMethodName)) + run_kwargs_type_hints = typing.get_type_hints(getattr(classModule, runMethodName)) + run_doc = getattr(classModule, runMethodName).__doc__ + run_params = params_to_ArgSpec( + run_ArgSpec, + run_kwargs_type_hints, + run_doc, + args_to_skip={"signals", "export_to"}, + ) + return init_params, run_params + + +def getTrackerArgSpec(trackerModule, realTime=False): + init_ArgSpec = inspect.getfullargspec(trackerModule.tracker.__init__) + init_kwargs_type_hints = typing.get_type_hints(trackerModule.tracker.__init__) + init_doc = trackerModule.tracker.__init__.__doc__ + init_params = params_to_ArgSpec(init_ArgSpec, init_kwargs_type_hints, init_doc) + if realTime: + track_ArgSpec = inspect.getfullargspec(trackerModule.tracker.track_frame) + track_kwargs_type_hints = typing.get_type_hints( + trackerModule.tracker.track_frame + ) + track_doc = trackerModule.tracker.track_frame.__doc__ + else: + track_ArgSpec = inspect.getfullargspec(trackerModule.tracker.track) + track_kwargs_type_hints = typing.get_type_hints(trackerModule.tracker.track) + track_doc = trackerModule.tracker.track.__doc__ + + track_params = params_to_ArgSpec( + track_ArgSpec, + track_kwargs_type_hints, + track_doc, + args_to_skip={"signals", "export_to"}, + ) + return init_params, track_params + + +def isIntensityImgRequiredForTracker(trackerModule): + track_ArgSpec = inspect.getfullargspec(trackerModule.tracker.track) + num_args = len(track_ArgSpec.args) - len(track_ArgSpec.defaults) + # If the number of args is 3 then we have `self, labels, image` as args + # which means the tracker requires the image + return num_args == 3 + + +def download_examples(which="time_lapse_2D", progress=None): + examples_path, example_path, url, file_size = get_examples_path(which) + if os.path.exists(example_path): + if progress is not None: + # display 100% progressbar + progress.emit(0, 0) + return example_path + + zip_dst = os.path.join(examples_path, "example_temp.zip") + + if not os.path.exists(examples_path): + os.makedirs(examples_path, exist_ok=True) + + print(f"Downloading example to {example_path}") + + download_url(url, zip_dst, verbose=False, file_size=file_size, progress=progress) + exctract_to = examples_path + extract_zip(zip_dst, exctract_to) + + if progress is not None: + # display 100% progressbar + progress.emit(0, 0) + + # Remove downloaded zip archive + os.remove(zip_dst) + print("Example downloaded successfully") + return example_path + + +def check_model_exists(model_path, model_name): + try: + import cellacdc + + m = model_name.lower() + weights_filenames = getattr(cellacdc, f"{m}_weights_filenames") + files_present = listdir(model_path) + return all([f in files_present for f in weights_filenames]) + except Exception as e: + return True + + +def _model_url(model_name, return_alternative=False): + if model_name == "YeaZ": + url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/8PMePcwJXmaMMS6/download/YeaZ_weights.zip" + alternative_url = ( + "https://zenodo.org/record/6125825/files/YeaZ_weights.zip?download=1" + ) + file_size = 693685011 + elif model_name == "YeastMate": + url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/pMT8pAmMkNtN8BP/download/yeastmate_weights.zip" + alternative_url = ( + "https://zenodo.org/record/6140067/files/yeastmate_weights.zip?download=1" + ) + file_size = 164911104 + elif model_name == "segment_anything": + url = [ + "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth", + "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_l_0b3195.pth", + "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth", + ] + file_size = [2564550879, 1249524736, 375042383] + alternative_url = "" + elif model_name == "YeaZ_v2": + url = [ + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/5PARckkcJcN9D3S/download/weights_budding_BF_multilab_0_1", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/CTHq4HN3adyFbnE/download/weights_budding_PhC_multilab_0_1", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/QTtBJycYnLQZsHQ/download/weights_fission_multilab_0_2", + ] + file_size = [124142981, 124143031, 124144759] + alternative_url = "https://github.com/rahi-lab/YeaZ-GUI#installation" + elif model_name == "DeepSea": + url = [ + "https://github.com/abzargar/DeepSea/raw/master/deepsea/trained_models/segmentation.pth", + "https://github.com/abzargar/DeepSea/raw/master/deepsea/trained_models/tracker.pth", + ] + file_size = [7988969, 8637439] + alternative_url = "" + elif model_name == "TAPIR": + url = ["https://storage.googleapis.com/dm-tapnet/tapir_checkpoint.npy"] + file_size = [124408122] + alternative_url = "" + elif model_name == "Cellpose_germlineNuclei": + url = [ + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/AXG6fFfD8o5GZ83/download/cellpose_germlineNuclei_2023" + ] + file_size = [26570752] + alternative_url = "" + elif model_name == "omnipose": + url = [ + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/DynLkocWRbQfyRp/download/bact_fluor_cptorch_0" + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/2248Eoyozp3Ezj2/download/bact_fluor_omnitorch_0", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/GiacDfXGerxE7PT/download/bact_phase_omnitorch_0", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/DDq8s3CgnG2Yw6H/download/cyto2_omnitorch_0", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/MM5meM2J5HbWqXR/download/plant_cptorch_0", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/aap7znrWq5sE6JQ/download/plant_omnitorch_0", + "https://hmgubox2.helmholtz-muenchen.de/index.php/s/w5M46x9qr8zLHZH/download/size_cyto2_omnitorch_0.npy", + ] + file_size = [26558464, 26558464, 26558464, 26558464, 26558464, 75071488, 4096] + alternative_url = "" + elif model_name == "sam2": + url = [ + "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_tiny.pt", + "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_small.pt", + "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_base_plus.pt", + "https://dl.fbaipublicfiles.com/segment_anything_2/092824/sam2.1_hiera_large.pt", + ] + file_size = [155233385, 184211977, 319128965, 910600801] + alternative_url = "" + else: + return + if return_alternative: + return url, alternative_url + else: + return url, file_size + + +def _download_segment_anything_models(): + urls, file_sizes = _model_url("segment_anything") + temp_model_path = tempfile.mkdtemp() + _, final_model_path = get_model_path("segment_anything", create_temp_dir=False) + for url, file_size in zip(urls, file_sizes): + filename = url.split("/")[-1] + final_dst = os.path.join(final_model_path, filename) + if os.path.exists(final_dst): + continue + + temp_dst = os.path.join(temp_model_path, filename) + download_url( + url, temp_dst, file_size=file_size, desc="segment_anything", verbose=False + ) + + shutil.move(temp_dst, final_dst) + + +def _download_sam2_models(): + urls, file_sizes = _model_url("sam2") + temp_model_path = tempfile.mkdtemp() + _, final_model_path = get_model_path("sam2", create_temp_dir=False) + for url, file_size in zip(urls, file_sizes): + filename = url.split("/")[-1] + final_dst = os.path.join(final_model_path, filename) + if os.path.exists(final_dst): + continue + + temp_dst = os.path.join(temp_model_path, filename) + download_url(url, temp_dst, file_size=file_size, desc="sam2", verbose=False) + + shutil.move(temp_dst, final_dst) + + +def _download_deepsea_models(): + urls, file_sizes = _model_url("DeepSea") + temp_model_path = tempfile.mkdtemp() + _, final_model_path = get_model_path("deepsea", create_temp_dir=False) + for url, file_size in zip(urls, file_sizes): + filename = url.split("/")[-1] + final_dst = os.path.join(final_model_path, filename) + if os.path.exists(final_dst): + continue + + temp_dst = os.path.join(temp_model_path, filename) + download_url(url, temp_dst, file_size=file_size, desc="deepsea", verbose=False) + + shutil.move(temp_dst, final_dst) + + +def download_manual(): + manual_folder_path = os.path.join(user_profile_path, "acdc-manual") + if not os.path.exists(manual_folder_path): + os.makedirs(manual_folder_path, exist_ok=True) + + manual_file_path = os.path.join(user_profile_path, "Cell-ACDC_User_Manual.pdf") + if not os.path.exists(manual_file_path): + url = "https://github.com/SchmollerLab/Cell_ACDC/raw/main/UserManual/Cell-ACDC_User_Manual.pdf" + download_url(url, manual_file_path, file_size=1727470) + return manual_file_path + + +def download_bioformats_jar(qparent=None, logger_info=print, logger_exception=print): + dst_filepath = os.path.join( + cellacdc_path, "bioformats", "jars", "bioformats_package.jar" + ) + if os.path.exists(dst_filepath): + return True, dst_filepath + urls_to_try = (urls.bioformats_jar_home_url, urls.bioformats_jar_hmgu_url) + success = False + for url in urls_to_try: + try: + logger_info(f"Downloading `bioformats_package.jar`...") + download_url(url, dst_filepath, file_size=43233280) + success = True + break + except Exception as err: + success = False + traceback_str = traceback.format_exc() + logger_exception(traceback_str) + continue + + if success: + return True, dst_filepath + + _warnings.warn_download_bioformats_jar_failed(dst_filepath, qparent=qparent) + raise ModuleNotFoundError( + "Bioformats package jar could not be downloaded. Please, " + f"download it from here {urls.bioformats_download_page} and " + f'place it in the following path "{dst_filepath}". ' + "Thank you for your patience!" + ) + return False, dst_filepath + + +def download_url(url, dst, desc="", file_size=None, verbose=True, progress=None): + import urllib3 + + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + CHUNK_SIZE = 32768 + if verbose: + print(f"Downloading {desc} to: {os.path.dirname(dst)}") + response = requests.get(url, stream=True, timeout=20, verify=False) + if file_size is not None and progress is not None: + progress.emit(file_size, -1) + pbar = tqdm( + total=file_size, unit="B", unit_scale=True, unit_divisor=1024, ncols=100 + ) + with open(dst, "wb") as f: + for chunk in response.iter_content(CHUNK_SIZE): + # if chunk: + f.write(chunk) + pbar.update(len(chunk)) + if progress is not None: + progress.emit(-1, len(chunk)) + pbar.close() + + +def _write_model_location_to_txt(model_name): + model_info_path = os.path.join(models_path, model_name, "model") + model_path = os.path.join(user_profile_path, f"acdc-{model_name}") + file = "weights_location_path.txt" + with open(os.path.join(model_info_path, file), "w") as txt: + txt.write(model_path) + return os.path.expanduser(model_path) + + +def download_model(model_name): + if model_name == "segment_anything": + try: + _download_segment_anything_models() + return True + except Exception as e: + traceback.print_exc() + return False + elif model_name == "sam2": + try: + _download_sam2_models() + return True + except Exception as e: + traceback.print_exc() + return False + elif model_name == "DeepSea": + try: + _download_deepsea_models() + return True + except Exception as e: + traceback.print_exc() + return False + elif model_name == "TAPIR": + try: + _download_tapir_model() + return True + except Exception as e: + traceback.print_exc() + return False + elif model_name == "YeaZ_v2": + try: + _download_yeaz_models() + return True + except Exception as e: + traceback.print_exc() + return False + elif model_name == "Cellpose_germlineNuclei": + try: + _download_cellpose_germlineNuclei_model() + return True + except Exception as e: + traceback.print_exc() + return False + elif model_name == "omnipose": + try: + _download_omnipose_models() + return True + except Exception as err: + return False + elif model_name != "YeastMate" and model_name != "YeaZ": + # We manage only YeastMate and YeaZ + return True + + try: + # Check if model exists + temp_zip_path, model_path = get_model_path(model_name) + if not temp_zip_path: + # Model exists return + return True + + # Check if user has model in the old v1.2.3 location + v123_model_path = check_v123_model_path(model_name) + if v123_model_path: + print(f"Weights files found in {v123_model_path}") + print(f"--> moving to new location: {model_path}...") + for file in listdir(v123_model_path): + src = os.path.join(v123_model_path, file) + dst = os.path.join(model_path, file) + shutil.copy(src, dst) + return True + + # Download model from url to tempDir/model_temp.zip + temp_dir = os.path.dirname(temp_zip_path) + url, file_size = _model_url(model_name) + print(f"Downloading {model_name} to {model_path}") + download_url( + url, temp_zip_path, file_size=file_size, desc=model_name, verbose=False + ) + + # Extract zip file inside temp dir + print(f"Extracting model...") + extract_zip(temp_zip_path, temp_dir, verbose=False) + + # Move unzipped files to ~/acdc-{model_name} folder + print(f"Moving files from temporary folder to {model_path}...") + for file in listdir(temp_dir): + if file.endswith(".zip"): + continue + src = os.path.join(temp_dir, file) + dst = os.path.join(model_path, file) + shutil.move(src, dst) + + # Remove temp directory + print(f"Removing temporary folder...") + shutil.rmtree(temp_dir) + return True + + except Exception as e: + traceback.print_exc() + return False + + +def aliases_real_time_trackers(reverse=False): + """ + Returns a dictionary with aliases for real-time trackers. + """ + + aliases = { + "CellACDC_normal_division": "Cell-ACDC symmetric division", + "CellACDC_2steps": "Cell-ACDC 2 steps", + } + + if reverse: + aliases = {v: k for k, v in aliases.items()} + + return aliases + + +def get_list_of_real_time_trackers(): + trackers = get_list_of_trackers() + rt_trackers = [] + aliases = aliases_real_time_trackers() + for tracker in trackers: + if tracker == "CellACDC": + continue + if tracker == "YeaZ": + continue + tracker_filename = f"{tracker}_tracker.py" + tracker_path = os.path.join( + cellacdc_path, "trackers", tracker, tracker_filename + ) + try: + with open(tracker_path) as file: + txt = file.read() + if txt.find("def track_frame") != -1: + rt_trackers.append(tracker) + except Exception as e: + continue + + for i, tracker in enumerate(rt_trackers): + if tracker in aliases: + rt_trackers[i] = aliases[tracker] + + return natsorted(rt_trackers, key=str.casefold) + + +def get_list_of_trackers(): + trackers_path = os.path.join(cellacdc_path, "trackers") + trackers = [] + for name in listdir(trackers_path): + _path = os.path.join(trackers_path, name) + tracker_script_path = os.path.join(_path, f"{name}_tracker.py") + is_valid_tracker = ( + os.path.isdir(_path) + and os.path.exists(tracker_script_path) + and not name.endswith("__") + ) + + if name.startswith("_"): + continue + + if is_valid_tracker: + trackers.append(name) + return natsorted(trackers, key=str.casefold) + + +def get_list_of_models(): + models = set() + for name in listdir(models_path): + _path = os.path.join(models_path, name) + if not os.path.exists(_path): + continue + + if not os.path.isdir(_path): + continue + + if name.endswith("__"): + continue + + if name.startswith("_"): + continue + + if name == "skip_segmentation": + continue + + if not os.path.exists(os.path.join(_path, "acdcSegment.py")): + continue + + if name == "thresholding": + name = "Automatic thresholding" + + models.add(name) + + if not os.path.exists(models_list_file_path): + return natsorted(list(models), key=str.casefold) + + cp = config.ConfigParser() + cp.read(models_list_file_path) + models.update(cp.sections()) + return natsorted(list(models), key=str.casefold) + + +def get_list_of_promptable_models(): + models = set() + for name in listdir(promptable_models_path): + _path = os.path.join(promptable_models_path, name) + if not os.path.exists(_path): + continue + + if not os.path.isdir(_path): + continue + + if name.endswith("__"): + continue + + if not os.path.exists(os.path.join(_path, "acdcPromptSegment.py")): + continue + + models.add(name) + + if not os.path.exists(promptable_models_list_file_path): + return natsorted(list(models), key=str.casefold) + + cp = config.ConfigParser() + cp.read(promptable_models_list_file_path) + models.update(cp.sections()) + return natsorted(list(models), key=str.casefold) + + +def download_fiji(logger_func=print): + url = None + if is_mac: + url = "https://downloads.micron.ox.ac.uk/fiji_update/mirrors/fiji-latest/fiji-macosx.zip" + file_size = 474_525_405 + + if url is None: + return + + if os.path.exists(get_fiji_exec_folderpath()): + return + + os.makedirs(acdc_fiji_path) + + temp_dir = tempfile.mkdtemp() + zip_dst = os.path.join(temp_dir, "fiji-macosx.zip") + logger_func(f'Downloading Fiji to "{acdc_fiji_path}"...') + download_url(url, zip_dst, verbose=False, file_size=file_size) + extract_zip(zip_dst, acdc_fiji_path) + + return acdc_fiji_path + + +def import_tracker_module(tracker_name): + module_name = f"cellacdc.trackers.{tracker_name}.{tracker_name}_tracker" + tracker_module = import_module(module_name) + return tracker_module + + +def download_ffmpeg(): + ffmpeg_folderpath = acdc_ffmpeg_path + if is_win: + url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/rXioWZpwjwn9JTT/download/windows_ffmpeg-7.0-full_build.zip" + file_size = 173477888 + ffmep_exec_path = os.path.join(ffmpeg_folderpath, "bin", "ffmpeg.exe") + elif is_mac: + url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/We7rcTLzqAP4zf7/download/mac_ffmpeg.zip" + file_size = 25288704 + ffmep_exec_path = os.path.join(ffmpeg_folderpath, "ffmpeg") + elif is_linux: + ffmep_exec_path = "" + return ffmep_exec_path + + if os.path.exists(ffmep_exec_path): + return ffmep_exec_path.replace("\\", os.sep).replace("/", os.sep) + + print("Downloading FFMPEG...") + temp_dir = tempfile.mkdtemp() + temp_zip_path = os.path.join(temp_dir, "acdc-ffmpeg.zip") + + download_url( + url, + temp_zip_path, + verbose=True, + file_size=file_size, + ) + extract_zip(temp_zip_path, ffmpeg_folderpath) + + return ffmep_exec_path.replace("\\", os.sep).replace("/", os.sep) + + +def import_promptable_segment_module(model_name): + try: + acdcPromptSegment = import_module( + f"cellacdc.segmenters_promptable.{model_name}.acdcPromptSegment" + ) + except ModuleNotFoundError as e: + # Check if custom model + cp = config.ConfigParser() + cp.read(promptable_models_list_file_path) + model_path = cp[model_name]["path"] + spec = importlib.util.spec_from_file_location("acdcPromptSegment", model_path) + acdcPromptSegment = importlib.util.module_from_spec(spec) + sys.modules["acdcPromptSegment"] = acdcPromptSegment + spec.loader.exec_module(acdcPromptSegment) + return acdcPromptSegment + + +def init_tracker( + posData, trackerName, realTime=False, qparent=None, return_init_params=False +): + from . import apps + + downloadWin = apps.downloadModel(trackerName, parent=qparent) + downloadWin.download() + + trackerModule = import_tracker_module(trackerName) + init_params = {} + track_params = {} + paramsWin = None + if trackerName == "BayesianTracker": + Y, X = posData.img_data_shape[-2:] + if posData.isSegm3D: + labShape = (posData.SizeZ, Y, X) + else: + labShape = (1, Y, X) + paramsWin = apps.BayesianTrackerParamsWin( + labShape, + parent=qparent, + channels=posData.chNames, + currentChannelName=posData.user_ch_name, + ) + paramsWin.exec_() + if not paramsWin.cancel: + init_params = paramsWin.params + track_params["export_to"] = posData.get_btrack_export_path() + if paramsWin.intensityImageChannel is not None: + chName = paramsWin.intensityImageChannel + track_params["image"] = posData.loadChannelData(chName) + track_params["image_channel_name"] = chName + elif trackerName == "CellACDC": + paramsWin = apps.CellACDCTrackerParamsWin(parent=qparent) + paramsWin.exec_() + if not paramsWin.cancel: + init_params = paramsWin.params + elif trackerName == "delta": + paramsWin = apps.DeltaTrackerParamsWin(posData=posData, parent=qparent) + paramsWin.exec_() + if not paramsWin.cancel: + init_params = paramsWin.params + else: + init_argspecs, track_argspecs = getTrackerArgSpec( + trackerModule, realTime=realTime + ) + intensityImgRequiredForTracker = isIntensityImgRequiredForTracker(trackerModule) + if init_argspecs or track_argspecs: + try: + url = trackerModule.url_help() + except AttributeError: + url = None + try: + channels = posData.chNames + except Exception as e: + channels = None + try: + currentChannelName = posData.user_ch_name + except Exception as e: + currentChannelName = None + try: + df_metadata = posData.metadata_df + except Exception as e: + df_metadata = None + + if not intensityImgRequiredForTracker: + currentChannelName = None + + paramsWin = apps.QDialogModelParams( + init_argspecs, + track_argspecs, + trackerName, + url=url, + channels=channels, + is_tracker=True, + currentChannelName=currentChannelName, + df_metadata=df_metadata, + posData=posData, + ) + if not intensityImgRequiredForTracker and channels is not None: + paramsWin.channelCombobox.setDisabled(True) + + paramsWin.exec_() + if not paramsWin.cancel: + init_params = paramsWin.init_kwargs + track_params = paramsWin.model_kwargs + if paramsWin.inputChannelName != "None": + chName = paramsWin.inputChannelName + track_params["image"] = posData.loadChannelData(chName) + track_params["image_channel_name"] = chName + if "export_to_extension" in track_params: + ext = track_params["export_to_extension"] + track_params["export_to"] = posData.get_tracker_export_path( + trackerName, ext + ) + + if paramsWin is not None and paramsWin.cancel: + tracker = (None,) + track_params = None + init_params = None + else: + tracker = trackerModule.tracker(**init_params) + + if return_init_params: + return tracker, track_params, init_params + else: + return tracker, track_params + + +def _download_tapir_model(): + urls, file_sizes = _model_url("TAPIR") + temp_model_path = tempfile.mkdtemp() + _, final_model_path = get_model_path("TAPIR", create_temp_dir=False) + for url, file_size in zip(urls, file_sizes): + filename = url.split("/")[-1] + final_dst = os.path.join(final_model_path, filename) + if os.path.exists(final_dst): + continue + + temp_dst = os.path.join(temp_model_path, filename) + download_url(url, temp_dst, file_size=file_size, desc="TAPIR", verbose=False) + + shutil.move(temp_dst, final_dst) + + +def _download_yeaz_models(): + urls, file_sizes = _model_url("YeaZ_v2") + temp_model_path = tempfile.mkdtemp() + _, final_model_path = get_model_path("YeaZ_v2", create_temp_dir=False) + for url, file_size in zip(urls, file_sizes): + filename = url.split("/")[-1] + final_dst = os.path.join(final_model_path, filename) + if os.path.exists(final_dst): + continue + + temp_dst = os.path.join(temp_model_path, filename) + download_url(url, temp_dst, file_size=file_size, desc="YeaZ_v2", verbose=False) + + shutil.move(temp_dst, final_dst) + + +def _download_cellpose_germlineNuclei_model(): + urls, file_sizes = _model_url("Cellpose_germlineNuclei") + temp_model_path = tempfile.mkdtemp() + _, final_model_path = get_model_path( + "Cellpose_germlineNuclei", create_temp_dir=False + ) + for url, file_size in zip(urls, file_sizes): + filename = url.split("/")[-1] + final_dst = os.path.join(final_model_path, filename) + if os.path.exists(final_dst): + continue + + temp_dst = os.path.join(temp_model_path, filename) + download_url( + url, + temp_dst, + file_size=file_size, + desc="Cellpose_germlineNuclei", + verbose=False, + ) + + shutil.move(temp_dst, final_dst) + + +def _download_omnipose_models(): + urls, file_sizes = _model_url("omnipose") + temp_model_path = tempfile.mkdtemp() + final_model_path = os.path.expanduser(r"~\.cellpose\models") + for url, file_size in zip(urls, file_sizes): + filename = url.split("/")[-1] + final_dst = os.path.join(final_model_path, filename) + if os.path.exists(final_dst): + continue + + temp_dst = os.path.join(temp_model_path, filename) + download_url(url, temp_dst, file_size=file_size, desc="omnipose", verbose=False) + + shutil.move(temp_dst, final_dst) + + +def init_prompt_segm_model(acdcPromptSegment, posData, init_kwargs): + model = acdcPromptSegment.Model(**init_kwargs) + return model + + +def init_segm_model(acdcSegment, posData, init_kwargs): + segm_endname = init_kwargs.pop("segm_endname", "None") + if segm_endname != "None": + load_segm = True + if not hasattr(posData, "segm_data"): + load_segm = True + elif posData.segm_npz_path.endswith(f"{segm_endname}.npz"): + load_segm = False + if not load_segm: + segm_data = np.squeeze(posData.segm_data) + else: + segm_filepath, _ = load.get_path_from_endname( + segm_endname, posData.images_path + ) + printl(f'Loading segmentation data from "{segm_filepath}"...') + segm_data = np.load(segm_filepath)["arr_0"] + else: + segm_data = None + + # Initialize input_points_df for models promptable with points + input_points_filepath = init_kwargs.pop("input_points_path", "") + if input_points_filepath: + input_points_df = init_input_points_df(posData, input_points_filepath) + init_kwargs["input_points_df"] = input_points_df + + try: + # Models introduced before 1.3.2 do not have the segm_data as input + kwargs = inspect.getfullargspec(acdcSegment.Model.__init__).args + if "is_rgb" not in kwargs and "is_rgb" in init_kwargs: + del init_kwargs["is_rgb"] + model = acdcSegment.Model(**init_kwargs) + + except Exception as e: + model = acdcSegment.Model(segm_data, **init_kwargs) + + if hasattr(model, "init_successful"): + if not model.init_successful: + return None + return model + + +def parse_model_params(model_argspecs, model_params): + parsed_model_params = {} + for row, argspec in enumerate(model_argspecs): + value = model_params.get(argspec.name) + if value is None: + continue + if argspec.type == bool: + value = _parse_bool_str(value) + elif argspec.type == int: + value = int(value) + elif argspec.type == float: + value = float(value) + parsed_model_params[argspec.name] = value + return parsed_model_params + + +def validate_tracker_input(tracker, segm_video_to_track): + try: + warning_text = tracker.validate_input(segm_video_to_track) + return warning_text + except Exception as err: + printl(traceback.format_exc()) + pass + return + +# Sibling imports (deferred to avoid import cycles) +from .misc import ( + _get_doc_stop_idx, + _parse_bool_str, + add_segm_data_param, + extract_zip, + init_input_points_df, +) +from .paths import ( + check_v123_model_path, + get_examples_path, + get_fiji_exec_folderpath, + get_model_path, + listdir, +) + diff --git a/cellacdc/myutils/paths.py b/cellacdc/myutils/paths.py new file mode 100644 index 000000000..da35742ef --- /dev/null +++ b/cellacdc/myutils/paths.py @@ -0,0 +1,455 @@ +"""Cell-ACDC utility helpers: paths.""" + +import os +import re +import ast + +import typing +from typing import Literal, List, Callable, Tuple, Dict + +import pathlib +import difflib +import sys +import platform +import tempfile +import shutil +import traceback +import logging +import datetime +import time +import subprocess +import importlib +from uuid import uuid4 +from importlib import import_module +from math import pow, ceil, floor +from functools import wraps, partial +from collections import namedtuple, Counter +from tqdm import tqdm +import requests +import zipfile +import json +import numpy as np +import pandas as pd +import skimage +import inspect + +import traceback +import itertools +from packaging import version as packaging_version + +from natsort import natsorted + +import tifffile +import skimage.io +import skimage.measure + +from .. import GUI_INSTALLED, KNOWN_EXTENSIONS, is_conda_env + +from .. import core, load +from .. import html_utils, is_linux, is_win, is_mac, issues_url, is_mac_arm64 +from .. import cellacdc_path, printl, acdc_fiji_path, logs_path, acdc_ffmpeg_path +from .. import user_profile_path, recentPaths_path +from .. import models_list_file_path, models_path +from .. import promptable_models_list_file_path, promptable_models_path +from .. import github_home_url +from .. import try_input_install_package +from .. import _warnings +from .. import urls +from .. import qrc_resources_path +from .. import settings_folderpath +from ..segmenters._cellpose_base import min_target_versions_cp + +if GUI_INSTALLED: + from qtpy.QtWidgets import QMessageBox + from qtpy.QtCore import Signal, QObject, QCoreApplication + + from .. import widgets, apps + from .. import config + +ArgSpec = namedtuple("ArgSpec", ["name", "default", "type", "desc", "docstring"]) + +def get_pos_status_acdc(pos_path): + images_path = os.path.join(pos_path, "Images") + ls = listdir(images_path) + for file in ls: + if file.endswith("acdc_output.csv"): + acdc_df_path = os.path.join(images_path, file) + break + else: + return "" + + acdc_df = pd.read_csv(acdc_df_path) + last_tracked_i = acdc_df["frame_i"].max() + last_cca_i = 0 + if "cell_cycle_stage" in acdc_df.columns: + cca_df = acdc_df[["frame_i", "cell_cycle_stage"]].dropna() + last_cca_i = cca_df["frame_i"].max() + if last_cca_i > 0: + return ( + f" (last tracked frame = {last_tracked_i + 1}, " + f"last annotated frame = {last_cca_i + 1})" + ) + else: + return f" (last tracked frame = {last_tracked_i + 1})" + + +def get_pos_status_spotmax(pos_path): + spotmax_out_path = os.path.join(pos_path, "spotMAX_output") + is_smax_out_present = "Yes" if os.path.exists(spotmax_out_path) else "No" + if os.path.exists(spotmax_out_path): + return " (SpotMAX output exists)" + else: + return "" + + +def get_pos_status(pos_path, caller: Literal["Cell-ACDC", "SpotMAX"] = "Cell-ACDC"): + if caller == "Cell-ACDC": + return get_pos_status_acdc(pos_path) + + if caller == "SpotMAX": + return get_pos_status_spotmax(pos_path) + + +def get_gdrive_path(): + if is_win: + return os.path.join(f"G:{os.sep}", "My Drive") + elif is_mac: + return os.path.join( + "/Users/francesco.padovani/Library/CloudStorage/" + "GoogleDrive-padovaf@tcd.ie/My Drive" + ) + + +def get_acdc_data_path(): + Cell_ACDC_path = os.path.dirname(cellacdc_path) + return os.path.join(Cell_ACDC_path, "data") + + +def get_open_filemaneger_os_string(): + if is_win: + return "Show in Explorer..." + elif is_mac: + return "Reveal in Finder..." + elif is_linux: + return "Show in File Manager..." + + +def trim_path(path, depth=3, start_with_dots=True): + path_li = os.path.abspath(path).split(os.sep) + rel_path = f"{f'{os.sep}'.join(path_li[-depth:])}" + if start_with_dots: + return f"...{os.sep}{rel_path}" + else: + return rel_path + + +def get_pos_foldernames(exp_path, check_if_is_sub_folder=False): + if not check_if_is_sub_folder: + ls = listdir(exp_path) + pos_foldernames = [ + pos for pos in ls if is_pos_folderpath(os.path.join(exp_path, pos)) + ] + else: + folder_type = determine_folder_type(exp_path) + is_pos_folder, is_images_folder, _ = folder_type + if is_pos_folder: + return [os.path.basename(exp_path)] + elif is_images_folder: + pos_path = os.path.dirname(exp_path) + if is_pos_folderpath(pos_path): + return [os.path.basename(pos_path)] + else: + return [] + else: + return get_pos_foldernames(exp_path) + return pos_foldernames + + +def get_images_folderpath(folderpath): + if os.path.isfile(folderpath): + folderpath = os.path.dirname(folderpath) + + if folderpath.endswith("Images"): + return folderpath + + images_folderpath = os.path.join(folderpath, "Images") + if os.path.exists(images_folderpath): + return images_folderpath + + return "" + + +def store_custom_model_path(model_file_path): + model_file_path = model_file_path.replace("\\", "/") + model_name = os.path.basename(os.path.dirname(model_file_path)) + cp = config.ConfigParser() + if os.path.exists(models_list_file_path): + cp.read(models_list_file_path) + if model_name not in cp: + cp[model_name] = {} + cp[model_name]["path"] = model_file_path + with open(models_list_file_path, "w") as configFile: + cp.write(configFile) + + +def store_custom_promptable_model_path(promptable_model_file_path): + model_file_path = promptable_model_file_path.replace("\\", "/") + model_name = os.path.basename(os.path.dirname(model_file_path)) + cp = config.ConfigParser() + if os.path.exists(promptable_models_list_file_path): + cp.read(promptable_models_list_file_path) + if model_name not in cp: + cp[model_name] = {} + cp[model_name]["path"] = model_file_path + with open(promptable_models_list_file_path, "w") as configFile: + cp.write(configFile) + + +def listdir(path) -> List[str]: + return natsorted( + [ + f + for f in os.listdir(path) + if not f.startswith(".") + and not f == "desktop.ini" + and not f == "recovery" + and not f.endswith(".new.npz") + ] + ) + + +def get_examples_path(which): + if which == "time_lapse_2D": + foldername = "TimeLapse_2D" + url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/KgJQtsQKZJnWZjL/download/TimeLapse_2D.zip" + file_size = 45143552 + elif which == "snapshots_3D": + foldername = "Multi_3D_zStack_Analysed" + url = "https://hmgubox2.helmholtz-muenchen.de/index.php/s/3RNjGiPwKcdnGtj/download/Yeast_Analysed_multi3D_zStacks.zip" + file_size = 124822528 + else: + return "" + + examples_path = os.path.join(user_profile_path, "acdc-examples") + example_path = os.path.join(examples_path, foldername) + return examples_path, example_path, url, file_size + + +def get_acdc_java_path(): + acdc_java_path = os.path.join(user_profile_path, "acdc-java") + dot_acdc_java_path = os.path.join(user_profile_path, ".acdc-java") + return acdc_java_path, dot_acdc_java_path + + +def get_model_path(model_name, create_temp_dir=True): + if model_name == "Automatic thresholding": + model_name == "thresholding" + + model_info_path = os.path.join(models_path, model_name, "model") + + if os.path.exists(model_info_path): + for file in listdir(model_info_path): + if file != "weights_location_path.txt": + continue + with open(os.path.join(model_info_path, file), "r") as txt: + model_path = txt.read() + model_path = os.path.expanduser(model_path) + if not os.path.exists(model_path): + model_path = _write_model_location_to_txt(model_name) + else: + break + else: + model_path = _write_model_location_to_txt(model_name) + else: + os.makedirs(model_info_path, exist_ok=True) + model_path = _write_model_location_to_txt(model_name) + + model_path = migrate_to_new_user_profile_path(model_path) + + if not os.path.exists(model_path): + os.makedirs(model_path, exist_ok=True) + + if not create_temp_dir: + return "", model_path + + exists = check_model_exists(model_path, model_name) + if exists: + return "", model_path + + temp_zip_path = _create_temp_dir() + return temp_zip_path, model_path + + +def _create_temp_dir(): + temp_model_path = tempfile.mkdtemp() + temp_zip_path = os.path.join(temp_model_path, "model_temp.zip") + return temp_zip_path + + +def check_v123_model_path(model_name): + # Cell-ACDC v1.2.3 saved the weights inside the package, + # while from v1.2.4 we save them on user folder. If we find the + # weights in the package we move them to user folder without downloading + # new ones. + v123_model_path = os.path.join(models_path, model_name, "model") + exists = check_model_exists(v123_model_path, model_name) + if exists: + return v123_model_path + else: + return "" + + +def is_old_user_profile_path(path_to_check: os.PathLike): + from . import user_data_dir + + user_data_folderpath = user_data_dir() + user_profile_path_txt = os.path.join( + user_data_folderpath, "acdc_user_profile_location.txt" + ) + if os.path.exists(user_profile_path_txt): + return False + + from . import user_home_path + + user_home_path = user_home_path.replace("\\", "/") + path_to_check = path_to_check.replace("\\", "/") + return user_home_path == path_to_check + + +def migrate_to_new_user_profile_path(path_to_migrate: os.PathLike): + parent_dir = os.path.dirname(path_to_migrate) + if not is_old_user_profile_path(parent_dir): + return path_to_migrate + folder = os.path.basename(path_to_migrate) + return os.path.join(user_profile_path, folder) + + +def determine_folder_type(folder_path): + is_pos_folder = is_pos_folderpath(folder_path) + is_images_folder = folder_path.endswith("Images") and listdir(folder_path) + contains_images_folder = os.path.exists(os.path.join(folder_path, "Images")) + contains_pos_folders = len(get_pos_foldernames(folder_path)) > 0 + if contains_pos_folders: + is_pos_folder = False + is_images_folder = False + elif contains_images_folder and not is_pos_folder: + # Folder created by loading an image + is_images_folder = True + folder_path = os.path.join(folder_path, "Images") + + return is_pos_folder, is_images_folder, folder_path + + +def to_relative_path(path, levels=3, prefix="..."): + path = path.replace("\\", "/") + parts = path.split("/") + if levels >= len(parts): + return path + parts = parts[-levels:] + rel_path = "/".join(parts) + rel_path.replace("/", os.sep) + if prefix: + rel_path = f"{prefix}{os.sep}{rel_path}" + return rel_path + + +def get_fiji_binary_filepath_mac(fiji_app_filepath): + if not is_mac: + return "" + + fiji_binary_path = os.path.join( + fiji_app_filepath, "Contents", "MacOS", "ImageJ-macosx" + ) + if os.path.exists(fiji_binary_path): + return fiji_binary_path + + fiji_binary_path = os.path.join( + fiji_app_filepath, "Contents", "MacOS", "fiji-macos" + ) + if os.path.exists(fiji_binary_path): + return fiji_binary_path + + return "" + + +def get_fiji_exec_folderpath() -> str: + if not is_mac: + return "" + + from cellacdc import fiji_location_filepath + + if os.path.exists(fiji_location_filepath): + with open(fiji_location_filepath, "r") as txt: + fiji_app_filepath = txt.read() + + return get_fiji_binary_filepath_mac(fiji_app_filepath) + + if os.path.exists("/Applications/Fiji.app"): + return get_fiji_binary_filepath_mac("/Applications/Fiji.app") + + acdc_fiji_app_path = os.path.join(acdc_fiji_path, "Fiji.app") + acdc_fiji_binary_path = get_fiji_binary_filepath_mac(acdc_fiji_app_path) + + return acdc_fiji_binary_path + + +def is_pos_folderpath(folderpath): + """Determine if a path is a valid Cell-ACDC Position folder + + Parameters + ---------- + folderpath : PathLike + Path to check + + Returns + ------- + bool + True if the path is a valid Cell-ACDC Position folder, False otherwise + + Notes + ----- + A valid Cell-ACDC Position folder must: + - Have a name matching the pattern 'Position_' + - Be a directory + - Contain an 'Images' subdirectory + - The 'Images' subdirectory must not be empty + """ + foldername = os.path.basename(folderpath) + is_valid_pos_folder = ( + re.search(r"^Position_(\d+)$", foldername) is not None + and os.path.isdir(folderpath) + and os.path.exists(os.path.join(folderpath, "Images")) + and listdir(os.path.join(folderpath, "Images")) + ) + return is_valid_pos_folder + + +def validate_images_path(input_path: os.PathLike, create_dirs_tree=False): + is_images_path = input_path.endswith("Images") + parent_dir = os.path.dirname(input_path) + parent_foldername = os.path.basename(parent_dir) + is_pos_folder = re.search( + r"^Position_(\d+)$", parent_foldername + ) is not None and os.path.isdir(parent_dir) + if not is_pos_folder: + existing_pos_foldernames = get_pos_foldernames(input_path) + pos_n = len(existing_pos_foldernames) + 1 + pos_folderpath = os.path.join(input_path, f"Position_{pos_n}") + images_path = os.path.join(pos_folderpath, "Images") + elif is_images_path: + pos_folderpath = input_path + images_path = os.path.join(pos_folderpath, "Images") + else: + images_path = input_path + + if create_dirs_tree: + os.makedirs(images_path, exist_ok=True) + + return images_path + +# Sibling imports (deferred to avoid import cycles) +from .models import ( + _write_model_location_to_txt, + check_model_exists, +) + diff --git a/cellacdc/myutils/qt.py b/cellacdc/myutils/qt.py new file mode 100644 index 000000000..4d809615a --- /dev/null +++ b/cellacdc/myutils/qt.py @@ -0,0 +1,80 @@ +"""Cell-ACDC utility helpers: qt.""" + +import os +import re +import ast + +import typing +from typing import Literal, List, Callable, Tuple, Dict + +import pathlib +import difflib +import sys +import platform +import tempfile +import shutil +import traceback +import logging +import datetime +import time +import subprocess +import importlib +from uuid import uuid4 +from importlib import import_module +from math import pow, ceil, floor +from functools import wraps, partial +from collections import namedtuple, Counter +from tqdm import tqdm +import requests +import zipfile +import json +import numpy as np +import pandas as pd +import skimage +import inspect + +import traceback +import itertools +from packaging import version as packaging_version + +from natsort import natsorted + +import tifffile +import skimage.io +import skimage.measure + +from .. import GUI_INSTALLED, KNOWN_EXTENSIONS, is_conda_env + +from .. import core, load +from .. import html_utils, is_linux, is_win, is_mac, issues_url, is_mac_arm64 +from .. import cellacdc_path, printl, acdc_fiji_path, logs_path, acdc_ffmpeg_path +from .. import user_profile_path, recentPaths_path +from .. import models_list_file_path, models_path +from .. import promptable_models_list_file_path, promptable_models_path +from .. import github_home_url +from .. import try_input_install_package +from .. import _warnings +from .. import urls +from .. import qrc_resources_path +from .. import settings_folderpath +from ..segmenters._cellpose_base import min_target_versions_cp + +if GUI_INSTALLED: + from qtpy.QtWidgets import QMessageBox + from qtpy.QtCore import Signal, QObject, QCoreApplication + + from .. import widgets, apps + from .. import config + +ArgSpec = namedtuple("ArgSpec", ["name", "default", "type", "desc", "docstring"]) + +def testQcoreApp(): + print(QCoreApplication.instance()) + + +def get_cli_multi_choice_question(question, choices): + choices_format = [f"{i + 1}) {choice}." for i, choice in enumerate(choices)] + choices_format = " ".join(choices_format) + choices_opts = "/".join([str(i) for i in range(1, len(choices) + 1)]) + text = f"{question} {choices_format} q) Quit. ({choices_opts})?: " + return text diff --git a/cellacdc/myutils/text.py b/cellacdc/myutils/text.py new file mode 100644 index 000000000..0faff3a72 --- /dev/null +++ b/cellacdc/myutils/text.py @@ -0,0 +1,141 @@ +"""Cell-ACDC utility helpers: text.""" + +import os +import re +import ast + +import typing +from typing import Literal, List, Callable, Tuple, Dict + +import pathlib +import difflib +import sys +import platform +import tempfile +import shutil +import traceback +import logging +import datetime +import time +import subprocess +import importlib +from uuid import uuid4 +from importlib import import_module +from math import pow, ceil, floor +from functools import wraps, partial +from collections import namedtuple, Counter +from tqdm import tqdm +import requests +import zipfile +import json +import numpy as np +import pandas as pd +import skimage +import inspect + +import traceback +import itertools +from packaging import version as packaging_version + +from natsort import natsorted + +import tifffile +import skimage.io +import skimage.measure + +from .. import GUI_INSTALLED, KNOWN_EXTENSIONS, is_conda_env + +from .. import core, load +from .. import html_utils, is_linux, is_win, is_mac, issues_url, is_mac_arm64 +from .. import cellacdc_path, printl, acdc_fiji_path, logs_path, acdc_ffmpeg_path +from .. import user_profile_path, recentPaths_path +from .. import models_list_file_path, models_path +from .. import promptable_models_list_file_path, promptable_models_path +from .. import github_home_url +from .. import try_input_install_package +from .. import _warnings +from .. import urls +from .. import qrc_resources_path +from .. import settings_folderpath +from ..segmenters._cellpose_base import min_target_versions_cp + +if GUI_INSTALLED: + from qtpy.QtWidgets import QMessageBox + from qtpy.QtCore import Signal, QObject, QCoreApplication + + from .. import widgets, apps + from .. import config + +ArgSpec = namedtuple("ArgSpec", ["name", "default", "type", "desc", "docstring"]) + +def get_trimmed_list(li: list, max_num_digits=10): + if len(li) == 0: + return "[]" + + tom_num_digits = sum([len(str(val)) for val in li]) + + if tom_num_digits == 0: + return f"[{', '.join(map(str, li))}]" + + avg_num_digits = tom_num_digits / len(li) + max_num_vals = int(round(max_num_digits / avg_num_digits)) + + if tom_num_digits > max_num_digits: + front_vals = ceil(max_num_vals / 2) + back_vals = max_num_vals // 2 + + if front_vals + back_vals >= len(li): + return f"[{', '.join(map(str, li))}]" + + li = li[:front_vals] + ["..."] + li[len(li) - back_vals :] + + return f"[{', '.join(map(str, li))}]" + + +def get_trimmed_dict(di: dict, max_num_digits=10): + di_str = di.copy() + total_num_digits = sum([len(str(key)) + len(str(val)) for key, val in di.items()]) + avg_num_digits = total_num_digits / len(di) + max_num_vals = int(round(max_num_digits / avg_num_digits)) + if total_num_digits > max_num_digits: + keys = list(di_str.keys()) + for key in keys[max_num_vals:-max_num_vals]: + del di_str[key] + di_str[keys[max_num_vals]] = "..." + return f"[{', '.join([f'{key} -> {val}' for key, val in di_str.items()])}]" + + +def get_number_fstring_formatter(dtype, precision=4): + if np.issubdtype(dtype, np.integer): + return "d" + else: + return f".{precision}f" + + +def elided_text(text, max_len=50, elid_idx=None): + if len(text) <= max_len: + return text + + if elid_idx is None: + elid_idx = int(max_len / 2) + if elid_idx >= max_len: + elid_idx = max_len - 1 + idx1 = elid_idx + idx2 = elid_idx - max_len + text = f"{text[:idx1]}...{text[idx2:]}" + return text + + +def get_show_in_file_manager_text(): + if is_mac: + return "Reveal in Finder" + elif is_linux: + return "Show in File Manager" + elif is_win: + return "Show in File Explorer" + + +def append_text_filename(filename: str, text_to_append: str): + filename_noext, ext = os.path.splitext(filename) + filename_out = f"{filename_noext}{text_to_append}{ext}" + return filename_out diff --git a/cellacdc/myutils/version.py b/cellacdc/myutils/version.py new file mode 100644 index 000000000..fe12d9a54 --- /dev/null +++ b/cellacdc/myutils/version.py @@ -0,0 +1,555 @@ +"""Cell-ACDC utility helpers: version.""" + +import os +import re +import ast + +import typing +from typing import Literal, List, Callable, Tuple, Dict + +import pathlib +import difflib +import sys +import platform +import tempfile +import shutil +import traceback +import logging +import datetime +import time +import subprocess +import importlib +from uuid import uuid4 +from importlib import import_module +from math import pow, ceil, floor +from functools import wraps, partial +from collections import namedtuple, Counter +from tqdm import tqdm +import requests +import zipfile +import json +import numpy as np +import pandas as pd +import skimage +import inspect + +import traceback +import itertools +from packaging import version as packaging_version + +from natsort import natsorted + +import tifffile +import skimage.io +import skimage.measure + +from .. import GUI_INSTALLED, KNOWN_EXTENSIONS, is_conda_env + +from .. import core, load +from .. import html_utils, is_linux, is_win, is_mac, issues_url, is_mac_arm64 +from .. import cellacdc_path, printl, acdc_fiji_path, logs_path, acdc_ffmpeg_path +from .. import user_profile_path, recentPaths_path +from .. import models_list_file_path, models_path +from .. import promptable_models_list_file_path, promptable_models_path +from .. import github_home_url +from .. import try_input_install_package +from .. import _warnings +from .. import urls +from .. import qrc_resources_path +from .. import settings_folderpath +from ..segmenters._cellpose_base import min_target_versions_cp + +if GUI_INSTALLED: + from qtpy.QtWidgets import QMessageBox + from qtpy.QtCore import Signal, QObject, QCoreApplication + + from .. import widgets, apps + from .. import config + +ArgSpec = namedtuple("ArgSpec", ["name", "default", "type", "desc", "docstring"]) + +def get_salute_string(): + time_now = datetime.datetime.now().time() + time_end_morning = datetime.time(12, 00, 00) + time_end_lunch = datetime.time(13, 00, 00) + time_end_afternoon = datetime.time(15, 00, 00) + time_end_evening = datetime.time(20, 00, 00) + time_end_night = datetime.time(4, 00, 00) + if time_now >= time_end_night and time_now < time_end_morning: + return "Have a good day!" + elif time_now >= time_end_morning and time_now < time_end_lunch: + return "Enjoy your lunch!" + elif time_now >= time_end_lunch and time_now < time_end_afternoon: + return "Have a good afternoon!" + elif time_now >= time_end_afternoon and time_now < time_end_evening: + return "Have a good evening!" + else: + return "Have a good night!" + + +def get_info_version_text(is_cli=False, cli_formatted_text=True): + version = read_version() + release_date = get_date_from_version(version, package="cellacdc") + py_ver = sys.version_info + env_folderpath = sys.prefix + python_version = f"{py_ver.major}.{py_ver.minor}.{py_ver.micro}" + info_txts = [ + f"Version {version}", + f"Released on: {release_date}", + f'Installed in "{cellacdc_path}"', + f'Environment folder: "{env_folderpath}"', + f'User profile folder: "{user_profile_path}"', + f'Settings folder: "{settings_folderpath}"', + f"Python {python_version}", + f"Platform: {platform.platform()}", + f"System: {platform.system()}", + ] + if is_linux: + try: + distro_name = get_linux_distribution_name() + except Exception as err: + distro_name = "Undetermined" + + info_txts.append(f"Linux distribution: {distro_name}") + + if GUI_INSTALLED and not is_cli: + info_txts.append(f'Icons from: "{qrc_resources_path}"') + try: + from qtpy import QtCore + + info_txts.append(f"Qt {QtCore.__version__}") + except Exception as err: + info_txts.append("Qt: Not installed") + + try: + branch_name = get_git_branch_name() + info_txts.append(f'Git branch: "{branch_name}"') + except Exception as err: + pass + + info_txts.append(f"Working directory: {os.getcwd()}") + + if not cli_formatted_text: + return info_txts + + info_txts = [f" - {txt}" for txt in info_txts] + + max_len = max([len(txt) for txt in info_txts]) + 2 + + formatted_info_txts = [] + for txt in info_txts: + horiz_spacing = " " * (max_len - len(txt)) + txt = f"{txt}{horiz_spacing}|" + formatted_info_txts.append(txt) + + formatted_info_txts.insert(0, "Cell-ACDC info:\n") + formatted_info_txts.insert(0, "=" * max_len) + formatted_info_txts.append("=" * max_len) + info_txt = "\n".join(formatted_info_txts) + + try: + from spotmax.utils import get_info_version_text as smax_info + + smax_info_txt = smax_info(include_platform=False, is_cli=is_cli) + info_txt += "\n\n" + smax_info_txt + except ImportError: + pass + + return info_txt + + +def read_version(logger=None, return_success=False): + cellacdc_parent_path = os.path.dirname(cellacdc_path) + cellacdc_parent_folder = os.path.basename(cellacdc_parent_path) + if cellacdc_parent_folder == "site-packages": + from . import __version__ + + version = __version__ + success = True + else: + try: + from setuptools_scm import get_version + + version = get_version(root="..", relative_to=__file__) + success = True + except Exception as e: + if logger is None: + logger = print + logger("*" * 40) + logger(traceback.format_exc()) + logger("-" * 40) + logger( + "[WARNING]: Cell-ACDC could not determine the current version. " + "Returning the version determined at installation time. " + "See details above." + ) + logger("=" * 40) + try: + from . import _version + + version = _version.version + success = False + except Exception as e: + version = "ND" + success = False + + if return_success: + return version, success + else: + return version + + +def get_date_from_version(version: str, package="cellacdc", debug=False): + try: + response = requests.get(f"https://pypi.org/pypi/{package}/json", timeout=2) + res_json = response.json() + pypi_releases_json = res_json["releases"] + version_json = pypi_releases_json[version][0] + upload_time = version_json["upload_time_iso_8601"] + date = datetime.datetime.strptime(upload_time, r"%Y-%m-%dT%H:%M:%S.%fZ") + date_str = date.strftime(r"%A %d %B %Y at %H:%M") + return date_str + except Exception as err: + if debug: + traceback.print_exc() + + try: + # Locate the direct_url.json file for the package + # installed with pip git+ + dist = importlib.metadata.distribution(package) + dist_info_dir = dist._path # internal path to .dist-info + direct_url_path = os.path.join(dist_info_dir, "direct_url.json") + + with open(direct_url_path) as f: + data = json.load(f) + + vcs_info = data["vcs_info"] + commit_id = vcs_info.get("commit_id") + url = data.get("url") + + parts = url.split("github.com/")[1].split(".git")[0] + owner, repo = parts.split("/", 1) + + # Query GitHub API for commit date + api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/{commit_id}" + response = requests.get(api_url) + response.raise_for_status() + + commit_data = response.json() + date_utc = commit_data["commit"]["committer"]["date"] + + date_str = format_commit_date_utc(date_utc) + + return date_str + except Exception as err: + if debug: + traceback.print_exc() + + try: + if package == "cellacdc": + pkg_path = cellacdc_path + elif package == "spotmax": + from spotmax import spotmax_path + + pkg_path = spotmax_path + commit_hash = re.findall(r"\+g([A-Za-z0-9]+)(\.d)?", version)[0][0] + git_path = os.path.dirname(pkg_path) + command = f"git -C {git_path} show {commit_hash}" + commit_log = _subprocess_run_command( + command, shell=False, callback="check_output" + ) + commit_log = commit_log.decode() + date_log = re.findall(r"Date:(.*) \+", commit_log)[0].strip() + date = datetime.datetime.strptime(date_log, r"%a %b %d %H:%M:%S %Y") + date_str = date.strftime(r"%A %d %B %Y at %H:%M") + return date_str + except Exception as err: + if debug: + traceback.print_exc() + + return "ND" + + +def get_git_branch_name(): + command = "git rev-parse --abbrev-ref HEAD" + output = _subprocess_run_command(command, shell=False, callback="check_output") + branch_name = output.decode().strip() + return branch_name + + +def get_cellpose_major_version(errors="raise"): + major_installed = None + try: + installed_version = get_package_version("cellpose") + major_installed = int(installed_version.split(".")[0]) + except Exception as err: + if errors == "raise": + raise err + + return major_installed + + +def check_cellpose_version(version: str): + if isinstance(version, int): + version = f"{version}.0" + + major_requested = int(version.split(".")[0]) + cancel = False + try: + installed_version = get_package_version("cellpose") + major_installed = int(installed_version.split(".")[0]) + is_version_correct = major_installed == major_requested + if not is_version_correct: + cancel = _warnings.warn_installing_different_cellpose_version( + version, installed_version + ) + if not is_second_version_greater( + min_target_versions_cp[str(major_requested)], installed_version + ): + is_version_correct = False + except Exception as err: + is_version_correct = False + + if cancel: + raise ModuleNotFoundError("Cellpose installation cancelled by the user.") + return is_version_correct + + +def is_second_version_greater( + target_version: str, + current_version: str, +): + """ + Compares two model versions and returns True if the current version is + greater than or equal to the target version. + """ + target_version = packaging_version.parse(target_version) + current_version = packaging_version.parse(current_version) + + return current_version >= target_version + + +def is_pkg_version_within_range(package_version: str, min_version="", max_version=""): + package_version_number = packaging_version.parse(package_version) + is_greater_than_min = True + if min_version: + min_version_number = packaging_version.parse(min_version) + is_greater_than_min = package_version_number >= min_version_number + + is_less_than_max = True + if max_version: + max_version_number = packaging_version.parse(max_version) + is_less_than_max = package_version_number <= max_version_number + + return is_greater_than_min and is_less_than_max + + +def check_pkg_version( + import_pkg_name, min_version, include_lower_version, raise_err=True +): + is_version_correct = False + try: + installed_version = get_package_version(import_pkg_name) + if include_lower_version: + is_version_correct = packaging_version.parse( + installed_version + ) >= packaging_version.parse(min_version) + else: + is_version_correct = packaging_version.parse( + installed_version + ) > packaging_version.parse(min_version) + except Exception as err: + is_version_correct = False + + if raise_err and not is_version_correct: + raise ModuleNotFoundError(f"{import_pkg_name}>{min_version} not installed.") + else: + return is_version_correct + + +def check_pkg_exact_version(import_pkg_name, version: str, raise_err=True): + is_version_correct = False + try: + installed_version = get_package_version(import_pkg_name) + is_version_correct = packaging_version.parse( + installed_version + ) == packaging_version.parse(version) + except Exception as err: + is_version_correct = False + + if raise_err and not is_version_correct: + raise ModuleNotFoundError(f"{import_pkg_name}=={version} not installed.") + else: + return is_version_correct + + +def check_pkg_max_version( + import_pkg_name, max_version, include_higher_version, raise_err=True +): + is_version_correct = False + try: + from packaging import version + + installed_version = get_package_version(import_pkg_name) + if include_higher_version: + is_version_correct = packaging_version.parse( + installed_version + ) <= packaging_version.parse(max_version) + else: + is_version_correct = packaging_version.parse( + installed_version + ) < packaging_version.parse(max_version) + except Exception as err: + is_version_correct = False + + if raise_err and not is_version_correct: + raise ModuleNotFoundError(f"{import_pkg_name}<={max_version} not installed.") + else: + return is_version_correct + + +def check_matplotlib_version(qparent=None): + mpl_version = get_package_version("matplotlib") + mpl_version_digits = mpl_version.split(".") + + mpl_major = int(mpl_version_digits[0]) + mpl_minor = int(mpl_version_digits[1]) + is_less_than_3_5 = mpl_major < 3 or (mpl_major >= 3 and mpl_minor < 5) + if not is_less_than_3_5: + return + + proceed = _install_package_msg("matplotlib", parent=qparent, upgrade=True) + if not proceed: + raise ModuleNotFoundError(f'User aborted "matplotlib" installation') + import subprocess + + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "-U", "matplotlib"] + ) + except Exception as e: + printl(traceback.format_exc()) + _inform_install_package_failed("matplotlib", parent=qparent, do_exit=False) + + +def get_git_pull_checkout_cellacdc_version_commands(version=None): + if version is None: + version = read_version() + commit_hash_idx = version.find("+g") + is_dev_version = commit_hash_idx > 0 + if not is_dev_version: + return [] + commit_hash = version[commit_hash_idx + 2 :].split(".")[0] + commands = ( + f'cd "{os.path.dirname(cellacdc_path)}"', + "git pull", + f"git checkout {commit_hash}", + ) + return commands + + +def _update_repo_with_git_command(package_name, repo_location): + """Update repository using git command""" + try: + print( + f"Updating {package_name} repository at {repo_location} using git command..." + ) + + # Change to repository directory + original_cwd = os.getcwd() + os.chdir(repo_location) + + stashed_changes = False + + # check if there is a portable git + from .config import parser_args + + try: + cp = parser_args + if cp["install_details"] is not None: + no_cli_install = True + install_details = cp["install_details"] + target_dir = install_details.get("target_dir", "") + target_dir = target_dir.strip().strip('"').strip("'") + target_dir = os.path.abspath(target_dir) + else: + no_cli_install = False + except: + no_cli_install = False + pass + + if is_win and no_cli_install: + git_loc = os.path.join(target_dir, "portable_git", "cmd", "git.exe") + if not os.path.exists(git_loc): + print(f"Portable git not found at {git_loc}. Using system git.") + git_loc = "git" + else: + git_loc = "git" + + # Check if git is available + if not shutil.which(git_loc): + print( + f"Git command not found. Please install git to update {package_name}." + ) + return False + + try: + # Check for uncommitted changes + + branch_result = subprocess.run( + [git_loc, "branch", "--show-current"], + capture_output=True, + text=True, + check=True, + ) + current_branch = branch_result.stdout.strip() + print(f"Current branch: {current_branch}") + + result = subprocess.run( + [git_loc, "status", "--porcelain"], + capture_output=True, + text=True, + check=True, + ) + if result.stdout.strip(): + print(f"Repository {package_name} has uncommitted changes") + print("Stashing changes before update...") + subprocess.run([git_loc, "stash"], check=True) + stashed_changes = True + + # Pull changes + subprocess.run([git_loc, "pull"], check=True) + print(f"Successfully updated {package_name}") + + # Pop stashed changes if any were stashed + if stashed_changes: + try: + subprocess.run([git_loc, "stash", "pop"], check=True) + print("Restored stashed changes") + except subprocess.CalledProcessError as pop_error: + print(f"Warning: Could not restore stashed changes: {pop_error}") + + return True + + except subprocess.CalledProcessError as e: + print(f"Git command failed for {package_name}: {e}") + return False + finally: + os.chdir(original_cwd) + + except Exception as e: + print(f"Error updating {package_name} with git command: {e}") + return False + +# Sibling imports (deferred to avoid import cycles) +from .install import ( + _inform_install_package_failed, + _install_package_msg, + get_package_version, +) +from .misc import ( + _subprocess_run_command, + format_commit_date_utc, + get_linux_distribution_name, +) + diff --git a/cellacdc/widgets.py b/cellacdc/widgets.py deleted file mode 100755 index 9c428462f..000000000 --- a/cellacdc/widgets.py +++ /dev/null @@ -1,10294 +0,0 @@ -from collections import defaultdict, deque -from typing import Dict, List, Union, Iterable, Sequence -import os -import sys -import operator -import time -import re -import datetime -import numpy as np -import pandas as pd -import math -import traceback -import logging -import textwrap -import random - -from functools import partial -from math import ceil - -import skimage.draw -import skimage.morphology - -from matplotlib.colors import ListedColormap, LinearSegmentedColormap -import matplotlib.pyplot as plt -import matplotlib -from matplotlib.backends.backend_agg import FigureCanvasAgg - -from qtpy.QtCore import ( - Signal, - QTimer, - Qt, - QPoint, - QUrl, - Property, - QPropertyAnimation, - QEasingCurve, - QLocale, - QSize, - QRect, - QPointF, - QRect, - QPoint, - QEasingCurve, - QRegularExpression, - QEvent, - QEventLoop, - QPropertyAnimation, - QObject, - QItemSelectionModel, - QAbstractListModel, - QModelIndex, - QByteArray, - QDataStream, - QMimeData, - QAbstractItemModel, - QIODevice, - QItemSelection, - PYQT6, - QRectF, -) -from qtpy.QtGui import ( - QFont, - QPalette, - QColor, - QPen, - QKeyEvent, - QBrush, - QPainter, - QRegularExpressionValidator, - QIcon, - QPixmap, - QKeySequence, - QLinearGradient, - QShowEvent, - QDesktopServices, - QFontMetrics, - QGuiApplication, - QLinearGradient, - QImage, - QCursor, - QPicture, -) -from qtpy.QtWidgets import ( - QTextEdit, - QLabel, - QProgressBar, - QHBoxLayout, - QToolButton, - QCheckBox, - QApplication, - QWidget, - QVBoxLayout, - QMainWindow, - QTreeWidgetItemIterator, - QLineEdit, - QSlider, - QSpinBox, - QGridLayout, - QRadioButton, - QScrollArea, - QSizePolicy, - QComboBox, - QPushButton, - QScrollBar, - QGroupBox, - QAbstractSlider, - QDoubleSpinBox, - QWidgetAction, - QAction, - QTabWidget, - QAbstractSpinBox, - QToolBar, - QStyleOptionSpinBox, - QStyle, - QDialog, - QSpacerItem, - QFrame, - QMenu, - QActionGroup, - QListWidget, - QPlainTextEdit, - QFileDialog, - QListView, - QAbstractItemView, - QTreeWidget, - QTreeWidgetItem, - QListWidgetItem, - QLayout, - QStylePainter, - QGraphicsBlurEffect, - QGraphicsProxyWidget, - QGraphicsObject, - QButtonGroup, - QStyleOptionSlider, -) -import qtpy.compat - -import pyqtgraph as pg - -pg.setConfigOption("imageAxisOrder", "row-major") - -from . import myutils, measurements, is_mac, is_win, html_utils, is_linux -from . import printl, settings_folderpath -from . import colors, config -from . import html_path -from . import _palettes -from . import load -from . import apps -from . import plot -from . import annotate -from . import urls -from . import _core, core -from . import QtScoped -from . import prompts -from .acdc_regex import float_regex -from .config import PREPROCESS_MAPPER -from . import _base_widgets - -from .components.palette import ( # noqa: E402 - BASE_COLOR, - Gradients, - GradientsImage, - GradientsLabels, - LINEEDIT_INVALID_ENTRY_STYLESHEET, - LINEEDIT_WARNING_STYLESHEET, - LISTWIDGET_STYLESHEET, - PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, - PROGRESSBAR_QCOLOR, - TEXT_COLOR, - TREEWIDGET_STYLESHEET, - cmaps, - font, - getCustomGradients, - nonInvertibleCmaps, - sign_int_mapper, - str_to_operator_mapper, -) -from .components.progress import QtHandler, QLog, XStream # noqa: E402 -from .components.buttons import * # noqa: E402, F403 -from .components.layout import * # noqa: E402, F403 -from .components.inputs_basic import * # noqa: E402, F403 -from .components.path_controls import * # noqa: E402, F403 - -from .components.lists import * # noqa: E402, F403 -from .components.base import QBaseWindow # noqa: E402 -from .components.progress import ( # noqa: E402 - LoadingCircleAnimation, - NoneWidget, - ProgressBar, - ProgressBarWithETA, - QLogConsole, -) - - - - - - - -class ContourItem(pg.PlotCurveItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - self._prevData = None - - def clear(self): - try: - self.setData([], []) - except AttributeError as e: - pass - - def tempClear(self): - try: - self._prevData = [d.copy() for d in self.getData()] - self.clear() - except Exception as e: - pass - - def restore(self): - if self._prevData is not None: - if self._prevData[0] is not None: - self.setData(*self._prevData) - - -class BaseScatterPlotItem(pg.ScatterPlotItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - - def tempClear(self): - try: - self._prevData = [d.copy() for d in self.getData()] - self.setData([], []) - except Exception as e: - pass - - def restore(self): - if self._prevData is not None: - if self._prevData[0] is not None: - self.setData(*self._prevData) - - - - -class CustomAnnotationScatterPlotItem(BaseScatterPlotItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - - - - -class QDialogListbox(QDialog): - sigSelectionConfirmed = Signal(list) - - def __init__( - self, - title, - text, - items, - cancelText="Cancel", - multiSelection=True, - parent=None, - additionalButtons=(), - includeSelectionHelp=False, - allowSingleSelection=True, - preSelectedItems=None, - allowEmptySelection=True, - ): - self.cancel = True - items = list(items) - - super().__init__(parent) - self.setWindowTitle(title) - - if preSelectedItems is None: - if items: - preSelectedItems = (items[0],) - else: - preSelectedItems = set() - - self.allowSingleSelection = allowSingleSelection - self.allowEmptySelection = allowEmptySelection - - mainLayout = QVBoxLayout() - topLayout = QVBoxLayout() - bottomLayout = QHBoxLayout() - - self.mainLayout = mainLayout - - label = QLabel(text) - _font = QFont() - _font.setPixelSize(13) - label.setFont(_font) - # padding: top, left, bottom, right - label.setStyleSheet("padding:0px 0px 3px 0px;") - topLayout.addWidget(label, alignment=Qt.AlignCenter) - - if includeSelectionHelp: - selectionHelpLabel = QLabel() - txt = html_utils.paragraph("""
    - Ctrl+Click to select multiple items
    - Shift+Click to select a range of items
    - """) - selectionHelpLabel.setText(txt) - topLayout.addWidget(label, alignment=Qt.AlignCenter) - - listBox = listWidget() - listBox.setFont(_font) - listBox.addItems(items) - if multiSelection: - listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - else: - listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - listBox.setCurrentRow(0) - for i in range(listBox.count()): - item = listBox.item(i) - item.setSelected(item.text() in preSelectedItems) - - self.listBox = listBox - if not multiSelection: - listBox.itemDoubleClicked.connect(self.ok_cb) - topLayout.addWidget(listBox) - - if cancelText.lower().find("cancel") != -1: - cancelButton = cancelPushButton(cancelText) - else: - cancelButton = QPushButton(cancelText) - okButton = okPushButton(" Ok ") - - bottomLayout.addStretch(1) - bottomLayout.addWidget(cancelButton) - bottomLayout.addSpacing(20) - - if additionalButtons: - self._additionalButtons = [] - for button in additionalButtons: - if isinstance(button, str): - _button, isCancelButton = getPushButton(button) - self._additionalButtons.append(_button) - bottomLayout.addWidget(_button) - _button.clicked.connect(self.ok_cb) - else: - bottomLayout.addWidget(button) - - bottomLayout.addWidget(okButton) - bottomLayout.setContentsMargins(0, 10, 0, 0) - - mainLayout.addLayout(topLayout) - mainLayout.addLayout(bottomLayout) - self.setLayout(mainLayout) - - # Connect events - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.cancel_cb) - - if multiSelection: - listBox.itemClicked.connect(self.onItemClicked) - listBox.itemSelectionChanged.connect(self.onItemSelectionChanged) - - self.setStyleSheet(LISTWIDGET_STYLESHEET) - self.areItemsSelected = [ - listBox.item(i).isSelected() for i in range(listBox.count()) - ] - self.setFont(font) - - def keyPressEvent(self, event) -> None: - mod = event.modifiers() - if mod == Qt.ShiftModifier or mod == Qt.ControlModifier: - self.listBox.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - elif event.key() == Qt.Key_Escape: - self.listBox.clearSelection() - event.ignore() - return - super().keyPressEvent(event) - - def onItemSelectionChanged(self): - if not self.listBox.selectedItems(): - self.areItemsSelected = [False for i in range(self.listBox.count())] - - def onItemClicked(self, item): - mod = QGuiApplication.keyboardModifiers() - if mod == Qt.ShiftModifier or mod == Qt.ControlModifier: - self.listBox.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - return - - self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) - itemIdx = self.listBox.row(item) - wasSelected = self.areItemsSelected[itemIdx] - if wasSelected: - item.setSelected(False) - - self.areItemsSelected = [ - self.listBox.item(i).isSelected() for i in range(self.listBox.count()) - ] - # self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - # else: - # selectedItems.append(item) - - # self.listBox.clearSelection() - # for i in range(self.listBox.count()): - # item = self.listBox.item(i).setSelected(True) - - # print(self.listBox.selectedItems()) - - def setSelectedItems(self, itemsTexts): - for i in range(self.listBox.count()): - item = self.listBox.item(i) - if item.text() in itemsTexts: - item.setSelected(True) - self.listBox.update() - - def warnSelectionEmpty(self): - msg = myMessageBox(wrapText=False, showCentered=False) - txt = html_utils.paragraph( - "You need to select at least one item!.

    " - "Use Ctrl+Click to select multiple items
    " - "or Shift+Click to select a range of items" - ) - msg.warning(self, "Selection cannot be empty!", txt) - - def ok_cb(self, checked=False): - self.clickedButton = self.sender() - self.cancel = False - selectedItems = self.listBox.selectedItems() - self.selectedItemsText = [item.text() for item in selectedItems] - if not self.allowSingleSelection and len(self.selectedItemsText) < 2: - msg = myMessageBox(wrapText=False, showCentered=False) - txt = html_utils.paragraph( - "You need to select two or more items.

    " - "Use Ctrl+Click to select multiple items
    , or
    " - "Shift+Click to select a range of items" - ) - msg.warning(self, "Select two or more items", txt) - return - - if not self.allowEmptySelection and not self.selectedItemsText: - self.warnSelectionEmpty() - return - - self.sigSelectionConfirmed.emit(self.selectedItemsText) - self.close() - - def cancel_cb(self, event): - self.cancel = True - self.selectedItemsText = None - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - - horizontal_sb = self.listBox.horizontalScrollBar() - while horizontal_sb.isVisible(): - self.resize(self.height(), self.width() + 10) - - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class ExpandableListBox(QComboBox): - def __init__(self, parent=None, centered=True) -> None: - super().__init__(parent) - - self.setEditable(True) - self.lineEdit().setReadOnly(True) - - infoTxt = html_utils.paragraph( - "Select Positions to save

    " - "Ctrl+Click to select multiple items
    " - "Shift+Click to select a range of items
    ", - center=True, - ) - - self.listW = QDialogListbox( - "Select Positions to save", infoTxt, [], multiSelection=True, parent=self - ) - - self.listW.listBox.itemClicked.connect(self.listItemClicked) - self.listW.sigSelectionConfirmed.connect(self.updateCombobox) - - self.centered = centered - - def listItemClicked(self, item): - if item.text().find("All") == -1: - return - - for i in range(self.listW.listBox.count()): - _item = self.listW.listBox.item(i) - _item.setSelected(True) - - def clear(self) -> None: - self.listW.listBox.clear() - return super().clear() - - def setItems(self, items): - self.clear() - self.addItems(items) - - def addItems(self, items): - super().addItems(items) - self.listW.listBox.addItems(items) - self.listW.listBox.setCurrentRow(self.currentIndex()) - self.listItemClicked(self.listW.listBox.currentItem()) - if self.centered: - self.centerItems() - - def updateCombobox(self, selectedItemsText): - isAllItem = [i for i, t in enumerate(selectedItemsText) if t.find("All") != -1] - if len(selectedItemsText) == 1: - self.setCurrentText(selectedItemsText[0]) - elif isAllItem: - idx = isAllItem[0] - self.setCurrentText(selectedItemsText[idx]) - else: - super().clear() - super().addItems(["Custom selection"]) - - def centerItems(self, idx=None): - self.lineEdit().setAlignment(Qt.AlignCenter) - - def selectedItems(self): - return self.listW.listBox.selectedItems() - - def selectedItemsText(self): - return [item.text() for item in self.selectedItems()] - - def showPopup(self) -> None: - self.listW.show() - - - - - -class QClickableLabel(QLabel): - clicked = Signal(object) - - def __init__(self, parent=None): - self._parent = parent - super().__init__(parent) - self._checkableItem = None - - def setCheckableItem(self, widget): - self._checkableItem = widget - - def mousePressEvent(self, event): - self.clicked.emit(self) - if self._checkableItem is not None: - status = not self._checkableItem.isChecked() - self._checkableItem.setChecked(status) - - def setChecked(self, checked): - self._checkableItem.setChecked(checked) - - -class QCenteredComboBox(QComboBox): - def __init__(self, parent=None) -> None: - super().__init__(parent) - - self.setEditable(True) - self.lineEdit().setReadOnly(True) - self.lineEdit().setAlignment(Qt.AlignCenter) - self.lineEdit().installEventFilter(self) - - self.currentIndexChanged.connect(self.centerItems) - - self._isPopupVisibile = False - - def centerItems(self, idx): - for i in range(self.count()): - self.setItemData(i, Qt.AlignCenter, Qt.TextAlignmentRole) - - def eventFilter(self, lineEdit, event): - # Reimplement show popup on click - if event.type() == QEvent.Type.MouseButtonPress and self.isEnabled(): - if self._isPopupVisibile: - self.hidePopup() - self._isPopupVisibile = False - else: - self.showPopup() - self._isPopupVisibile = True - return True - return False - - -class AlphaNumericComboBox(QCenteredComboBox): - def __init__(self, parent=None) -> None: - super().__init__(parent=parent) - - def addItems(self, items): - self._dtype = type(items[0]) - super().addItems([str(item) for item in items]) - - def setCurrentValue(self, value): - super().setCurrentText(str(value)) - - def currentValue(self): - return self._dtype(super().currentText()) - - -class statusBarPermanentLabel(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - - self.rightLabel = QLabel("") - self.leftLabel = QLabel("") - - layout = QHBoxLayout() - layout.addWidget(self.leftLabel) - layout.addStretch(10) - layout.addWidget(self.rightLabel) - - self.setLayout(layout) - - -class listWidget(QListWidget): - def __init__( - self, *args, isMultipleSelection=False, minimizeHeight=False, **kwargs - ): - super().__init__(*args, **kwargs) - self.itemHeight = None - self.setStyleSheet(LISTWIDGET_STYLESHEET) - self.setFont(font) - if isMultipleSelection: - self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - - self.minimizeHeight = minimizeHeight - - def setSelectedAll(self, selected): - for i in range(self.count()): - self.item(i).setSelected(selected) - - def setSelectedItems(self, itemsText): - for i in range(self.count()): - item = self.item(i) - item.setSelected(item.text() in itemsText) - - def addItems(self, labels) -> None: - super().addItems(labels) - if self.itemHeight is not None: - self.setItemHeight() - - if self.minimizeHeight: - itemHeight = self.sizeHintForRow(0) - self.setMaximumHeight(itemHeight * self.count() + itemHeight * 2) - - def addItem(self, text): - super().addItem(text) - if self.itemHeight is None: - return - self.setItemHeight() - - def setItemHeight(self, height=40): - self.itemHeight = height - for i in range(self.count()): - item = self.item(i) - item.setSizeHint(QSize(0, height)) - - def selectedItemsText(self): - return [item.text() for item in self.selectedItems()] - - -class OrderableListWidget(QWidget): - sigEnterEvent = Signal(object) - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self._labels = [] - - def setParentItem(self, item): - self._item = item - - def setLabelsColor(self, selected): - if selected: - stylesheet = "color : black" - else: - stylesheet = "" - - for label in self._labels: - label.setStyleSheet(stylesheet) - - def enterEvent(self, event): - super().enterEvent(event) - self.setLabelsColor(True) - self.sigEnterEvent.emit(self._item) - - # def leaveEvent(self, event): - # super().leaveEvent(event) - # self.setLabelsColor(self._item.isSelected()) - # printl('leave', self._item.isSelected()) - - def addLabel(self, label): - self._labels.append(label) - self.validPattern = r"^[0-9,\.]+$" - regExp = QRegularExpression(self.validPattern) - self.setValidator(QRegularExpressionValidator(regExp)) - - def values(self): - try: - vals = [float(c) for c in self.text().split(",")] - except Exception as e: - vals = [] - return vals - - -class mySpinBox(QSpinBox): - sigTabEvent = Signal(object, object) - - def __init__(self, *args) -> None: - super().__init__(*args) - - def event(self, event): - if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key_Tab: - self.sigTabEvent.emit(event, self) - return True - - return super().event(event) - - -class KeptObjectIDsList(list): - def __init__(self, lineEdit, confirmSelectionAction, *args): - self.lineEdit = lineEdit - self.lineEdit.setText("") - self.confirmSelectionAction = confirmSelectionAction - confirmSelectionAction.setDisabled(True) - super().__init__(*args) - - def setText(self): - text = myutils.format_IDs(self) - - self.lineEdit.setText(text) - - def append(self, element, editText=True): - super().append(element) - if editText: - self.setText() - if not self.confirmSelectionAction.isEnabled(): - self.confirmSelectionAction.setEnabled(True) - - def remove(self, element, editText=True): - super().remove(element) - if editText: - self.setText() - if not self: - self.confirmSelectionAction.setEnabled(False) - - -class ScatterPlotItem(pg.ScatterPlotItem): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.updateBrushAndPen(**kwargs) - - def updateBrushAndPen(self, **kwargs): - brush = kwargs.get("brush") - if brush is not None: - self._itemBrush = brush - pen = kwargs.get("pen") - if pen is not None: - self._itemPen = pen - - def setData(self, *args, **kwargs): - super().setData(*args, **kwargs) - self.updateBrushAndPen(**kwargs) - - def itemBrush(self): - return self._itemBrush - - def itemPen(self): - return self._itemPen - - def removePoint(self, index): - newData = np.delete(self.data, index) - # Update the index of current points - for i in range(index, len(newData)): - spotItem = newData[i]["item"] - spotItem._index = i - newData[i]["item"] = spotItem - - self.data = newData - self.prepareGeometryChange() - self.informViewBoundsChanged() - self.bounds = [None, None] - self.invalidate() - self.updateSpots(newData) - self.sigPlotChanged.emit(self) - - def coordsToNumpy(self, includeData=False, rounded=True, decimals=None): - points = self.points() - nrows = len(points) - coords_arr = np.zeros((nrows, 2)) - data_arr = None - for p, point in enumerate(points): - pos = point.pos() - x, y = pos.x(), pos.y() - if includeData: - data = point.data() - if data_arr is None: - try: - ncols = len(data) - except Exception as e: - data = [data] - ncols = 1 - data_arr = np.zeros((nrows, ncols)) - for j, data_j in enumerate(data): - data_arr[p, j] = data_j - - coords_arr[p, 0] = y - coords_arr[p, 1] = x - if not includeData: - out_arr = coords_arr - elif data_arr is not None: - out_arr = np.column_stack((data_arr, coords_arr)) - else: - out_arr = coords_arr - cast_to_int = decimals is None - decimals = decimals if decimals is not None else 0 - if rounded: - out_arr = np.round(out_arr, decimals) - if cast_to_int: - out_arr = out_arr.astype(int) - return out_arr - - -class myLabelItem(pg.LabelItem): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._prevText = "" - - def setText(self, text, **args): - self.text = text - opts = self.opts - for k in args: - opts[k] = args[k] - - if "size" in self.opts: - size = self.opts["size"] - if size == "0pt" or size == "0px": - self.opts["size"] = "1pt" - super().setText("", size="1pt") - return - - optlist = [] - - color = self.opts["color"] - if color is None: - color = pg.getConfigOption("foreground") - color = pg.functions.mkColor(color) - optlist.append("color: " + color.name(QColor.NameFormat.HexArgb)) - if "size" in opts: - size = opts["size"] - if not isinstance(size, str): - size = f"{size}px" - optlist.append("font-size: " + size) - if "bold" in opts and opts["bold"] in [True, False]: - optlist.append( - "font-weight: " + {True: "bold", False: "normal"}[opts["bold"]] - ) - if "italic" in opts and opts["italic"] in [True, False]: - optlist.append( - "font-style: " + {True: "italic", False: "normal"}[opts["italic"]] - ) - full = "%s" % ("; ".join(optlist), text) - # print full - self.item.setHtml(full) - self.updateMin() - self.resizeEvent(None) - self.updateGeometry() - - def tempClearText(self): - if self.text: - self._prevText = self.text - self.setText("") - - def restoreText(self): - if self._prevText: - self.setText(self._prevText) - - -class myMessageBox(_base_widgets.QBaseDialog): - def __init__( - self, - parent=None, - showCentered=True, - wrapText=True, - scrollableText=False, - enlargeWidthFactor=0, - resizeButtons=True, - allowClose=True, - ): - super().__init__(parent) - - self.wrapText = wrapText - self.enlargeWidthFactor = enlargeWidthFactor - self.resizeButtons = resizeButtons - - self.cancel = True - self.cancelButton = None - self.okButton = None - self.clickedButton = None - self.alreadyShown = False - self.allowClose = allowClose - - self.showCentered = showCentered - - self.scrollableText = scrollableText - - self._layout = QGridLayout() - self.commandsLayout = None - self._layout.setHorizontalSpacing(20) - self.buttonsLayout = QHBoxLayout() - self.buttonsLayout.setSpacing(2) - self.buttons = [] - self.widgets = [] - self.layouts = [] - self.labels = [] - self.labelsWidgets = [] - self._pixmapLabels = [] - self.detailsTextWidget = None - self.showInFileManagButton = None - self.visibleDetails = False - self.doNotShowAgainCheckbox = None - - self.currentRow = 0 - self.textWidget = None - self._w = None - - self.textLayout = QVBoxLayout() - - self._layout.setColumnStretch(1, 1) - self.setLayout(self._layout) - - self.setFont(font) - - def mousePressEvent(self, event): - for label in self.labels: - label.setTextInteractionFlags( - Qt.TextBrowserInteraction | Qt.TextSelectableByKeyboard - ) - - def setIcon(self, iconName="SP_MessageBoxInformation"): - label = QLabel(self) - - standardIcon = getattr(QStyle, iconName) - icon = self.style().standardIcon(standardIcon) - pixmap = icon.pixmap(60, 60) - label.setPixmap(pixmap) - - self._layout.addWidget(label, 0, 0, alignment=Qt.AlignTop) - - def addImage(self, image_path): - pixmap = QPixmap(image_path) - label = QLabel() - label.setPixmap(pixmap) - self._layout.addWidget(label, self.currentRow, 1) - self.currentRow += 1 - - def addShowInFileManagerButton(self, path, txt=None): - if txt is None: - txt = "Reveal in Finder..." if is_mac else "Show in Explorer..." - self.showInFileManagButton = showInFileManagerButton(txt) - self.buttonsLayout.addWidget(self.showInFileManagButton) - func = partial(myutils.showInExplorer, path) - self.showInFileManagButton.clicked.connect(func) - - def addBrowseUrlButton(self, url, button_text=""): - self.openUrlButton = OpenUrlButton(url, button_text) - self.buttonsLayout.addWidget(self.openUrlButton) - - def addCancelButton(self, button=None, connect=False): - if button is None: - self.cancelButton = cancelPushButton("Cancel") - else: - self.cancelButton = button - self.cancelButton.setIcon(QIcon(":cancelButton.svg")) - - self.buttonsLayout.insertWidget(0, self.cancelButton) - self.buttonsLayout.insertSpacing(1, 20) - if connect: - self.cancelButton.clicked.connect(self.buttonCallBack) - - def splitLatexBlocks(self, text): - texts = re.split(r"(.+?)
    ", text) - return texts - - def splitCopiableBlocks(self, texts: Sequence[str] | str): - if isinstance(texts, str): - texts = (texts,) - - texts_out = [] - for text in texts: - texts_out.extend(re.split(r"(.+?)", text)) - return texts_out - - def addText(self, text): - texts = self.splitLatexBlocks(text) - texts = self.splitCopiableBlocks(texts) - - labelsWidget = LabelsWidget(texts, wrapText=self.wrapText) - self.labelsWidgets.append(labelsWidget) - self.labels.extend(labelsWidget.labels) - if self.scrollableText: - textWidget = QScrollArea() - textWidget.setFrameStyle(QFrame.Shape.NoFrame) - textWidget.setWidget(labelsWidget) - else: - textWidget = labelsWidget - - self.textLayout.addWidget(textWidget) - - if self.textWidget is None: - self.textWidget = QWidget() - self.textWidget.setLayout(self.textLayout) - self._layout.addWidget(self.textWidget, self.currentRow, 1) - self.textRow = self.currentRow - self.currentRow += 1 - - return labelsWidget - - def addCopiableCommand(self, command): - copiableCommandWidget = CopiableCommandWidget(command) - screenWidth = self.screen().size().width() - maxWidth = int(0.75 * screenWidth) - sizeHint = copiableCommandWidget.sizeHint() - width = sizeHint.width() - if width > maxWidth: - copiableCommandWidget = addWidgetToScrollArea( - copiableCommandWidget, resizeMinHeightNoVerticalScrollbar=True - ) - self._layout.addWidget(copiableCommandWidget, self.currentRow, 1) - self.currentRow += 1 - - def copyToClipboard(self): - cb = QApplication.clipboard() - cb.clear(mode=cb.Clipboard) - cb.setText(self.sender()._command, mode=cb.Clipboard) - print("Command copied!") - - def addButton(self, buttonText): - if not isinstance(buttonText, str): - # Passing button directly - button = buttonText - self.buttonsLayout.addWidget(button) - button.clicked.connect(self.buttonCallBack) - self.buttons.append(button) - return button - - button, isCancelButton = getPushButton(buttonText, qparent=self) - if not isCancelButton: - self.buttonsLayout.addWidget(button) - - button.clicked.connect(self.buttonCallBack) - self.buttons.append(button) - return button - - def addDoNotShowAgainCheckbox(self, text="Do not show again"): - self.doNotShowAgainCheckbox = QCheckBox(text) - - def addWidget(self, widget): - self._layout.addWidget(widget, self.currentRow, 1) - self.widgets.append(widget) - self.currentRow += 1 - - def addLayout(self, layout): - self._layout.addLayout(layout, self.currentRow, 1) - self.layouts.append(layout) - self.currentRow += 1 - - def setWidth(self, w): - self._w = w - - def show(self, block=False): - self.endOfScrollableRow = self.currentRow - - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - # spacer - spacer = QSpacerItem(10, 10) - self._layout.addItem(spacer, self.currentRow, 1) - self._layout.setRowStretch(self.currentRow, 0) - - # buttons - self.currentRow += 1 - - if self.detailsTextWidget is not None: - self.buttonsLayout.insertWidget(1, self.detailsButton) - - # Do not show again checkbox - if self.doNotShowAgainCheckbox is not None: - self._layout.addWidget( - self.doNotShowAgainCheckbox, self.currentRow, 1, 1, 2 - ) - self.currentRow += 1 - - # spacer - self._layout.addItem(QSpacerItem(10, 10), self.currentRow, 1) - self.currentRow += 1 - - # buttons - self._layout.addLayout( - self.buttonsLayout, self.currentRow, 0, 1, 2, alignment=Qt.AlignRight - ) - - # Details - if self.detailsTextWidget is not None: - # spacer - self.currentRow += 1 - self._layout.addItem(QSpacerItem(20, 20), self.currentRow, 1) - - # detailsTextWidget - self.currentRow += 1 - self._layout.addWidget(self.detailsTextWidget, self.currentRow, 0, 1, 2) - - # spacer - self.currentRow += 1 - spacer = QSpacerItem(10, 10) - self._layout.addItem(spacer, self.currentRow, 1) - self._layout.setRowStretch(self.currentRow, 0) - - screenHeight = self.screen().size().height() - dialogHeight = self.sizeHint().height() - dialogWidth = self.sizeHint().width() - screenWidth = self.screen().size().width() - - # Check if scrollbar is needed - if dialogHeight > screenHeight and self.textWidget is not None: - textScrollArea = ScrollArea() - textScrollArea.setWidget(self.textWidget) - scrollAreaWidthNoSB = textScrollArea.minimumWidthNoScrollbar() - scrollAreaWidth = textScrollArea.sizeHint().width() - desiredDeltaWidth = scrollAreaWidthNoSB - scrollAreaWidth - if desiredDeltaWidth > 0: - desiredWidth = dialogWidth + desiredDeltaWidth - if desiredWidth < screenWidth: - self._w = desiredWidth - - self._layout.removeWidget(self.textWidget) - self._layout.addWidget(textScrollArea, self.textRow, 1) - - super().show() - QTimer.singleShot(5, self._resize) - - self.alreadyShown = True - - if block: - self._block() - - def setDetailedText(self, text, visible=False, wrap=True): - text = text.replace("\n", "
    ") - self.detailsTextWidget = QTextEdit(text) - self.detailsTextWidget.setReadOnly(True) - if not wrap: - self.detailsTextWidget.setLineWrapMode(QTextEdit.NoWrap) - self.detailsButton = showDetailsButton() - self.detailsButton.setCheckable(True) - self.detailsButton.clicked.connect(self._showDetails) - self.detailsTextWidget.hide() - self.visibleDetails = visible - - def _showDetails(self, checked): - if checked: - self.origHeight = self.height() - self.resize(self.width(), self.height() + 300) - self.detailsTextWidget.show() - else: - self.detailsTextWidget.hide() - func = partial(self.resize, self.width(), self.origHeight) - QTimer.singleShot(10, func) - - def _resize(self): - if self.resizeButtons: - widths = [button.width() for button in self.buttons] - if widths: - max_width = max(widths) - for button in self.buttons: - if button == self.cancelButton: - continue - button.setMinimumWidth(max_width) - - heights = [button.height() for button in self.buttons] - if heights: - max_h = max(heights) - for button in self.buttons: - button.setMinimumHeight(max_h) - if self.detailsTextWidget is not None: - self.detailsButton.setMinimumHeight(max_h) - if self.showInFileManagButton is not None: - self.showInFileManagButton.setMinimumHeight(max_h) - - if self._w is not None and self.width() < self._w: - self.resize(self._w, self.height()) - - if self.width() < 350: - self.resize(350, self.height()) - - if self.enlargeWidthFactor > 0: - self.resize(int(self.width() * self.enlargeWidthFactor), self.height()) - - if self.visibleDetails: - self.detailsButton.click() - - if self.showCentered: - screen = self.screen() - screenWidth = screen.size().width() - screenHeight = screen.size().height() - screenLeft = screen.geometry().x() - screenTop = screen.geometry().y() - w, h = self.width(), self.height() - left = int(screenLeft + screenWidth / 2 - w / 2) - top = int(screenTop + screenHeight / 2 - h / 2) - if top < screenTop: - top = screenTop - if left < screenLeft: - left = screenLeft - self.move(left, top) - - self._h = self.height() - - if self.okButton is not None: - self.okButton.setFocus() - - screen = self.screen() - screenWidth = screen.size().width() - screenHeight = screen.size().height() - - # Check Force wrap Text - for labelWidget in self.labelsWidgets: - textWidth = labelWidget.width() - if not textWidth > screenWidth - 10: - continue - factor = np.ceil(textWidth / screenWidth) - lineLength = int(labelWidget.nCharsLongestLine / factor) - for label in labelWidget.labels: - if isinstance(label, CopiableCommandWidget): - continue - - text = label.text() - chunks = textwrap.wrap(text, lineLength) - text = "
    ".join(chunks) - label.setText(text) - - QTimer.singleShot(100, self._resizeWrappedText) - - if self.widgets: - return - - if self.layouts: - return - - # # Start resizing height every 1 ms - # self.resizeCallsCount = 0 - # self.timer = QTimer() - # from config import warningHandler - # warningHandler.sigGeometryWarning.connect(self.timer.stop) - # self.timer.timeout.connect(self._resizeHeight) - # self.timer.start(1) - - def _resizeWrappedText(self): - screenWidth = self.screen().size().width() - 5 - self.resize(screenWidth, self.height()) - screenLeft = self.screen().geometry().left() - self.move(screenLeft, self.geometry().top()) - - def _resizeHeight(self): - try: - # Resize until a "Unable to set geometry" warning is captured - # by copnfig.warningHandler._resizeWarningHandler or # - # height doesn't change anymore - self.resize(self.width(), self.height() - 1) - if self.height() == self._h or self.resizeCallsCount > 100: - self.timer.stop() - return - - self.resizeCallsCount += 1 - self._h = self.height() - except Exception as e: - # traceback.format_exc() - self.timer.stop() - - def _template( - self, - parent, - title, - message, - detailsText=None, - buttonsTexts=None, - layouts=None, - widgets=None, - commands=None, - path_to_browse=None, - browse_button_text=None, - url_to_open=None, - open_url_button_text="Open url", - image_paths=None, - wrapDetails=True, - add_do_not_show_again_checkbox=False, - ): - if parent is not None: - self.setParent(parent) - self.setWindowTitle(title) - self.addText(message) - if commands is not None: - if isinstance(commands, str): - commands = (commands,) - for command in commands: - self.addCopiableCommand(command) - - if image_paths is not None: - if isinstance(image_paths, str): - image_paths = (image_paths,) - for image_path in image_paths: - self.addImage(image_path) - - if layouts is not None: - if myutils.is_iterable(layouts): - for layout in layouts: - self.addLayout(layout) - else: - self.addLayout(layout) - - if widgets is not None: - self._layout.addItem(QSpacerItem(20, 20), self.currentRow, 1) - self.currentRow += 1 - if myutils.is_iterable(widgets): - for widget in widgets: - self.addWidget(widget) - else: - self.addWidget(widgets) - - if path_to_browse is not None: - self.addShowInFileManagerButton(path_to_browse, txt=browse_button_text) - - if url_to_open is not None: - self.addBrowseUrlButton(url_to_open, button_text=open_url_button_text) - - buttons = [] - if buttonsTexts is None: - okButton = self.addButton(" Ok ") - buttons.append(okButton) - elif isinstance(buttonsTexts, str): - button = self.addButton(buttonsTexts) - buttons.append(button) - else: - for buttonText in buttonsTexts: - button = self.addButton(buttonText) - buttons.append(button) - - if detailsText is not None: - self.setDetailedText(detailsText, visible=True, wrap=wrapDetails) - - if add_do_not_show_again_checkbox: - self.addDoNotShowAgainCheckbox() - - return buttons - - def critical(self, *args, showDialog=True, **kwargs): - self.setIcon(iconName="SP_MessageBoxCritical") - buttons = self._template(*args, **kwargs) - if showDialog: - self.exec_() - return buttons - - def information(self, *args, showDialog=True, **kwargs): - self.setIcon(iconName="SP_MessageBoxInformation") - buttons = self._template(*args, **kwargs) - if showDialog: - self.exec_() - return buttons - - def warning(self, *args, showDialog=True, **kwargs): - self.setIcon(iconName="SP_MessageBoxWarning") - buttons = self._template(*args, **kwargs) - if showDialog: - self.exec_() - return buttons - - def question(self, *args, showDialog=True, **kwargs): - self.setIcon(iconName="SP_MessageBoxQuestion") - buttons = self._template(*args, **kwargs) - if showDialog: - self.exec_() - return buttons - - def _block(self): - self.loop = QEventLoop() - self.loop.exec_() - - def exec_(self): - self.show(block=True) - - def clickButtonFromText(self, buttonText): - for button in self.buttons: - if button.text() == buttonText: - button.click() - return - - def buttonCallBack(self, checked=True): - self.clickedButton = self.sender() - if self.clickedButton != self.cancelButton: - self.cancel = False - self.allowClose = True - self.close() - - def closeEvent(self, event): - if not self.allowClose: - event.ignore() - return - super().closeEvent(event) - - - -def macShortcutToWindows(shortcut: str): - if shortcut is None: - return - - s = ( - shortcut.replace("Control", "Meta") - .replace("Option", "Alt") - .replace("Command", "Ctrl") - ) - return s - - -def windowsShortcutToMac(shortcut: str): - if shortcut is None: - return - - if not is_mac: - return shortcut - - s = ( - shortcut.replace("Meta", "Control") - .replace("Alt", "Option") - .replace("Ctrl", "Command") - ) - return s - - -class ToolBarSeparator: - def __init__(self, width=5, toolbar: QToolBar = None): - self._parts = ( - QHWidgetSpacer(width=width), - QVLine(), - QHWidgetSpacer(width=width), - ) - self._actions = [] - self._toolbar = None - if toolbar is not None: - self.addToToolbar(toolbar) - - def addToToolbar(self, toolbar): - self._toolbar = toolbar - for part in self._parts: - action = toolbar.addWidget(part) - self._actions.append(action) - - def removeFromToolbar(self): - if self._toolbar is None: - return - - for action in self._actions: - self._toolbar.removeAction(action) - - -class ToolBar(QToolBar): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.widgetsWithShortcut = {} - - for child in self.children(): - if child.objectName() == "qt_toolbar_ext_button": - self.extendButton = child - self.extendButton.setIcon(QIcon(":expand.svg")) - break - - def addSeparator(self, width=5): - separator = ToolBarSeparator(width=width, toolbar=self) - return separator - - def removeSeparator(self, separator): - separator.removeFromToolbar() - - def addSpinBox(self, label=""): - spinbox = SpinBox(disableKeyPress=True) - if label: - spinbox.label = QLabel(label) - spinbox.labelAction = self.addWidget(spinbox.label) - - spinbox.action = self.addWidget(spinbox) - return spinbox - - def addButton(self, icon_str: str, text="", checkable=False): - action = QAction(QIcon(icon_str), text, self) - action.setCheckable(checkable) - self.addAction(action) - return action - - def addComboBox(self, items=None, label=""): - combobox = ComboBox() - - if items is not None: - combobox.addItems(items) - - if label: - combobox.label = QLabel(label) - combobox.labelAction = self.addWidget(combobox.label) - - combobox.action = self.addWidget(combobox) - return combobox - - def addLabel(self, text=""): - label = QLabel(text) - label.action = self.addWidget(label) - return label - - def addCheckBox(self, text="", checked=False): - checkbox = QCheckBox(text) - checkbox.setChecked(checked) - checkbox.action = self.addWidget(checkbox) - return checkbox - - -class ManualTrackingToolBar(ToolBar): - sigIDchanged = Signal(int) - sigDisableGhost = Signal() - sigClearGhostContour = Signal() - sigClearGhostMask = Signal() - sigGhostOpacityChanged = Signal(int) - - def __init__(self, *args) -> None: - super().__init__(*args) - self.spinboxID = self.addSpinBox(label="ID to track: ") - self.spinboxID.setMinimum(1) - - self.addSeparator() - - self.showGhostCheckbox = QCheckBox("Show ghost object") - self.showGhostCheckbox.setChecked(True) - self.addWidget(self.showGhostCheckbox) - - self.ghostContourRadiobutton = QRadioButton("Contour") - self.ghostMaskRadiobutton = QRadioButton("Mask ; ") - self.ghostMaskRadiobutton.setChecked(True) - self.addWidget(self.ghostContourRadiobutton) - self.addWidget(self.ghostMaskRadiobutton) - - self.ghostMaskOpacitySpinbox = self.addSpinBox("Mask opacity: ") - self.ghostMaskOpacitySpinbox.setMaximum(100) - self.ghostMaskOpacitySpinbox.setValue(30) - - self.showGhostCheckbox.toggled.connect(self.showGhostCheckboxToggled) - self.ghostContourRadiobutton.toggled.connect( - self.ghostContourRadiobuttonToggled - ) - self.spinboxID.valueChanged.connect(self.IDchanged) - - self.ghostMaskOpacitySpinbox.valueChanged.connect(self.ghostOpacityValueChanged) - - self.addSeparator() - - self.infoLabel = QLabel("") - self.addWidget(self.infoLabel) - - def showInfo(self, text): - text = html_utils.paragraph(text, font_color="black") - self.infoLabel.setText(text) - - def showWarning(self, text): - text = html_utils.paragraph(f"WARNING: {text}", font_color="red") - self.infoLabel.setText(text) - - def clearInfoText(self): - self.infoLabel.setText("") - - def IDchanged(self, value): - self.sigIDchanged.emit(value) - - def showGhostCheckboxToggled(self, checked): - disabled = not checked - self.ghostContourRadiobutton.setDisabled(disabled) - self.ghostMaskRadiobutton.setDisabled(disabled) - self.ghostMaskOpacitySpinbox.setDisabled(disabled) - self.ghostMaskOpacitySpinbox.label.setDisabled(disabled) - if disabled: - self.sigDisableGhost.emit() - - def ghostContourRadiobuttonToggled(self, checked): - self.ghostMaskOpacitySpinbox.setDisabled(checked) - self.ghostMaskOpacitySpinbox.label.setDisabled(checked) - if checked: - self.sigClearGhostMask.emit() - else: - self.sigClearGhostContour.emit() - - def ghostOpacityValueChanged(self, value): - self.sigGhostOpacityChanged.emit(value) - - -class CopyLostObjectToolbar(ToolBar): - sigCopyAllObjects = Signal(int, int) - - def __init__(self, *args) -> None: - super().__init__(*args) - - action = self.addButton(":copyContour_all.svg") - # action.setShortcut('Alt+C') - action.keyPressShortcut = KeySequenceFromText("Alt+C") - action.setToolTip("Copy all lost objects\n\nShortcut: Alt+C") - self.widgetsWithShortcut["Copy all lost objects"] = action - - action.triggered.connect(self.emitSigCopyAllObjects) - - self.addSeparator() - - self.maxOverlapNumberControl = self.addSpinBox( - label="Maximum overlap to accept lost object [%]: " - ) - self.maxOverlapNumberControl.setMinimum(0) - self.maxOverlapNumberControl.setValue(10) - tooltip = ( - "Maximum overlap to accept lost object [%]\n\n" - "If the overlap between the lost object and an object already " - "existing is greater than this value,\n" - "the lost object will not be added." - ) - self.maxOverlapNumberControl.setToolTip(tooltip) - self.maxOverlapNumberControl.label.setToolTip(tooltip) - - self.addSeparator() - - self.untilFrameNumberControl = self.addSpinBox( - label="Copy lost object(s) for the next number of frames: " - ) - self.untilFrameNumberControl.setMinimum(0) - self.untilFrameNumberControl.setValue(0) - - def emitSigCopyAllObjects(self): - self.sigCopyAllObjects.emit( - self.untilFrameNumberControl.value(), self.maxOverlapNumberControl.value() - ) - - -class DrawClearRegionToolbar(ToolBar): - def __init__(self, *args) -> None: - super().__init__(*args) - - group = QButtonGroup() - group.setExclusive(True) - self.clearTouchingObjsRadioButton = QRadioButton("Clear all touching objects") - self.clearOnlyEnclosedObjsRadioButton = QRadioButton( - "Clear only fully enclosed objects" - ) - self.clearOnlyEnclosedObjsRadioButton.setChecked(True) - group.addButton(self.clearTouchingObjsRadioButton) - group.addButton(self.clearOnlyEnclosedObjsRadioButton) - - self.addWidget(self.clearTouchingObjsRadioButton) - self.addWidget(self.clearOnlyEnclosedObjsRadioButton) - - self.addSeparator() - - self.numZslicesUpSpinbox = self.addSpinBox( - label="Num. of z-slices to clear upwards: " - ) - self.numZslicesUpSpinbox.setMinimum(0) - self.numZslicesUpSpinbox.setValue(0) - - self.numZslicesDownSpinbox = self.addSpinBox( - label="Num. of z-slices to clear downwards: " - ) - self.numZslicesDownSpinbox.setMinimum(0) - self.numZslicesDownSpinbox.setValue(0) - - def setZslicesControlEnabled(self, enabled, SizeZ=None): - self.numZslicesUpSpinbox.labelAction.setVisible(enabled) - self.numZslicesUpSpinbox.action.setVisible(enabled) - - self.numZslicesDownSpinbox.labelAction.setVisible(enabled) - self.numZslicesDownSpinbox.action.setVisible(enabled) - - if SizeZ is None: - return - - self.numZslicesUpSpinbox.setMaximum(SizeZ) - self.numZslicesDownSpinbox.setMaximum(SizeZ) - - def zRange(self, z_slice, SizeZ): - if z_slice is None: - zRange = (0, SizeZ) - return zRange - - numZslicesUp = self.numZslicesUpSpinbox.value() - numZslicesDown = self.numZslicesDownSpinbox.value() - - zmin = z_slice - numZslicesDown - zmax = z_slice + numZslicesDown + 1 - - zmin = zmin if zmin >= 0 else 0 - zmax = zmax if zmax <= SizeZ else SizeZ - - return (zmin, zmax) - - -class ManualBackgroundToolBar(ToolBar): - sigIDchanged = Signal(int) - - def __init__(self, *args) -> None: - super().__init__(*args) - self.spinboxID = self.addSpinBox(label="Set background of ID ") - self.spinboxID.setMinimum(1) - self.spinboxID.valueChanged.connect(self.IDchanged) - - self.infoLabel = QLabel("") - self.addWidget(self.infoLabel) - - def IDchanged(self, value): - self.sigIDchanged.emit(value) - - def showWarning(self, text): - text = html_utils.paragraph(f"WARNING: {text}", font_color="red") - self.infoLabel.setText(text) - - def clearInfoText(self): - self.infoLabel.setText("") - - -class rightClickToolButton(QToolButton): - sigRightClick = Signal(object) - sigLeftClick = Signal(object, object) - - def __init__(self, parent=None): - super().__init__(parent) - - def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.LeftButton: - super().mousePressEvent(event) - self.sigLeftClick.emit(self, event) - elif event.button() == Qt.MouseButton.RightButton: - self.sigRightClick.emit(event) - - -class SavePointsLayerButton(rightClickToolButton): - sigRenameTableAction = Signal(object, str) - - def __init__(self, table_endname, parent=None): - super().__init__(parent=parent) - self.setIcon(QIcon(":file-save.svg")) - - self.table_endname = table_endname - - self.setToolTip( - "Save annotated points in the CSV file ending " - f"with '{self.table_endname}.csv'" - ) - - self.sigRightClick.connect(self.showContextMenu) - - def showContextMenu(self, event): - contextMenu = QMenu(self) - contextMenu.addSeparator() - - renameAction = QAction("Rename points layer table") - renameAction.triggered.connect(self.renameTable) - contextMenu.addAction(renameAction) - - contextMenu.exec(event.globalPos()) - - def renameTable(self): - win = apps.filenameDialog( - parent=self, - title="Rename points layer table file", - allowEmpty=False, - defaultEntry=self.table_endname, - ext=".csv", - ) - win.exec_() - if win.cancel: - return - - self.table_endname = win.entryText - self.setToolTip( - "Save annotated points in the CSV file ending " - f"with '{self.table_endname}.csv'" - ) - self.sigRenameTableAction.emit(self, self.table_endname) - - -class ToolButtonCustomColor(rightClickToolButton): - def __init__(self, symbol, color="r", parent=None): - super().__init__(parent=parent) - if not isinstance(color, QColor): - color = pg.mkColor(color) - self.symbol = symbol - self.setColor(color) - - def setColor(self, color): - self.penColor = color - self.brushColor = [0, 0, 0, 100] - self.brushColor[:3] = color.getRgb()[:3] - - def updateSymbol(self, symbol, update=True): - self.symbol = symbol - if not update: - return - self.update() - - def updateColor(self, color, update=True): - self.setColor(color) - if not update: - return - self.update() - - def updateIcon(self, symbol, color): - self.updateSymbol(symbol) - self.updateColor(color) - self.update() - - def paintEvent(self, event): - QToolButton.paintEvent(self, event) - p = QPainter(self) - w, h = self.width(), self.height() - sf = 0.6 - p.scale(w * sf, h * sf) - p.translate(0.5 / sf, 0.5 / sf) - symbol = pg.graphicsItems.ScatterPlotItem.Symbols[self.symbol] - pen = pg.mkPen(color=self.penColor, width=2) - brush = pg.mkBrush(color=self.brushColor) - try: - p.setRenderHint(QPainter.RenderHint.Antialiasing) - p.setPen(pen) - p.setBrush(brush) - p.drawPath(symbol) - except Exception as e: - traceback.print_exc() - finally: - p.end() - - -class GradientToolButton(rightClickToolButton): - def __init__(self, colors=((255, 0, 0),), parent=None): - super().__init__(parent=parent) - self._qcolors = [pg.mkColor(c) for c in colors] - if len(self._qcolors) < 2: - self._qcolors.append(self._qcolors[0]) - - def paintEvent(self, event): - super().paintEvent(event) - - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - - pen = pg.mkPen(color=self._qcolors[-1], width=2) - - pad = 7 - - rect = self.rect().adjusted(pad, pad, -pad, -pad) # A little padding - - # Gradient: bottom to top - gradient = QLinearGradient(QPointF(rect.bottomLeft()), QPointF(rect.topLeft())) - - # Set color stops evenly distributed - num_colors = len(self._qcolors) - for i, color in enumerate(self._qcolors): - gradient.setColorAt(i / (num_colors - 1), color) - - if not self.isChecked(): - painter.setOpacity(0.4) - - painter.setBrush(gradient) - painter.setPen(pen) - painter.drawRect(rect) - - painter.end() - - -class PointsLayerToolButton(ToolButtonCustomColor): - sigEditAppearance = Signal(object) - sigShowIdsToggled = Signal(object, bool) - sigRemove = Signal(object) - - def __init__(self, symbol, color="r", parent=None): - super().__init__(symbol, color=color, parent=parent) - self.sigRightClick.connect(self.showContextMenu) - - def showContextMenu(self, event): - contextMenu = QMenu(self) - contextMenu.addSeparator() - - editAction = QAction("Edit points appearance...") - editAction.triggered.connect(self.editAppearance) - contextMenu.addAction(editAction) - - removeAction = QAction("Remove points") - removeAction.triggered.connect(self.emitRemove) - contextMenu.addAction(removeAction) - - showIdsAction = QAction("Show point ids") - showIdsAction.setCheckable(True) - showIdsAction.setChecked(True) - contextMenu.addAction(showIdsAction) - showIdsAction.toggled.connect(self.emitShowIdsToggled) - - contextMenu.exec(event.globalPos()) - - def emitRemove(self): - self.sigRemove.emit(self) - - def emitShowIdsToggled(self, checked): - self.sigShowIdsToggled.emit(self, checked) - - def editAppearance(self): - self.sigEditAppearance.emit(self) - - -class customAnnotToolButton(ToolButtonCustomColor): - sigRemoveAction = Signal(object) - sigKeepActiveAction = Signal(object) - sigModifyAction = Signal(object) - sigHideAction = Signal(object) - - def __init__( - self, symbol, color, keepToolActive=True, parent=None, isHideChecked=True - ): - super().__init__(symbol, color=color, parent=parent) - self.symbol = symbol - self.keepToolActive = keepToolActive - self.isHideChecked = isHideChecked - self.sigRightClick.connect(self.showContextMenu) - - def showContextMenu(self, event): - contextMenu = QMenu(self) - contextMenu.addSeparator() - - removeAction = QAction("Remove annotation") - removeAction.triggered.connect(self.removeAction) - contextMenu.addAction(removeAction) - - editAction = QAction("Modify annotation parameters...") - editAction.triggered.connect(self.modifyAction) - contextMenu.addAction(editAction) - - hideAction = QAction("Hide annotations") - hideAction.setCheckable(True) - hideAction.setChecked(self.isHideChecked) - hideAction.triggered.connect(self.hideAction) - contextMenu.addAction(hideAction) - - keepActiveAction = QAction("Keep tool active after using it") - keepActiveAction.setCheckable(True) - keepActiveAction.setChecked(self.keepToolActive) - keepActiveAction.triggered.connect(self.keepToolActiveActionToggled) - contextMenu.addAction(keepActiveAction) - - contextMenu.exec(event.globalPos()) - - def keepToolActiveActionToggled(self, checked): - self.keepToolActive = checked - self.sigKeepActiveAction.emit(self) - - def modifyAction(self): - self.sigModifyAction.emit(self) - - def removeAction(self): - self.sigRemoveAction.emit(self) - - def hideAction(self, checked): - self.isHideChecked = checked - self.sigHideAction.emit(self) - - -class LabelRoiCircularItem(pg.ScatterPlotItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - - def setImageShape(self, shape): - self._shape = shape - - def slice(self, zRange=None, tRange=None): - self.mask() - if zRange is None: - _slice = self._slice - else: - zmin, zmax = zRange - _slice = (slice(zmin, zmax), *self._slice) - - if tRange is not None: - tmin, tmax = tRange - _slice = (slice(tmin, tmax), *_slice) - - return _slice - - def mask(self): - shape = self._shape - radius = int(self.opts["size"] / 2) - mask = skimage.morphology.disk(radius, dtype=bool) - xx, yy = self.getData() - Yc, Xc = yy[0], xx[0] - mask, self._slice = myutils.clipSelemMask(mask, shape, Yc, Xc, copy=False) - return mask - - -class Toggle(QCheckBox): - def __init__( - self, - label_text="", - initial=None, - width=80, - bg_color="#b3b3b3", - circle_color="#ffffff", - active_color="#26dd66", # '#005ce6', - animation_curve=QEasingCurve.Type.InOutQuad, - ): - QCheckBox.__init__(self) - - # self.setFixedSize(width, 28) - self.setCursor(Qt.PointingHandCursor) - - self._label_text = label_text - self._bg_color = bg_color - self._circle_color = circle_color - self._active_color = active_color - self._disabled_active_color = colors.lighten_color(active_color) - self._disabled_circle_color = colors.lighten_color(circle_color) - self._disabled_bg_color = colors.lighten_color(bg_color, amount=0.5) - self._circle_margin = 4 - - self._circle_position = int(self._circle_margin / 2) - self.animation = QPropertyAnimation(self, b"circle_position", self) - self.animation.setEasingCurve(animation_curve) - self.animation.setDuration(200) - - self.stateChanged.connect(self.start_transition) - self.requestedState = None - - self.installEventFilter(self) - self._isChecked = False - - if initial is not None: - self.setChecked(initial) - - def sizeHint(self): - return QSize(36, 18) - - def eventFilter(self, object, event): - # To get the actual position of the circle we need to wait that - # the widget is visible before setting the state - if event.type() == QEvent.Type.Show and self.requestedState is not None: - self.setChecked(self.requestedState) - return False - - def setChecked(self, state): - # To get the actual position of the circle we need to wait that - # the widget is visible before setting the state - self._isChecked = state - if self.isVisible(): - self.requestedState = None - QCheckBox.setChecked(self, state > 0) - else: - self.requestedState = state - - def isChecked(self): - if self.isVisible(): - return super().isChecked() - else: - return self._isChecked - - def circlePos(self, state: bool): - start = int(self._circle_margin / 2) - if state: - if self.isVisible(): - height, width = self.height(), self.width() - else: - sizeHint = self.sizeHint() - height, width = sizeHint.height(), sizeHint.width() - circle_diameter = height - self._circle_margin - pos = width - start - circle_diameter - else: - pos = start - return pos - - @Property(float) - def circle_position(self): - return self._circle_position - - @circle_position.setter - def circle_position(self, pos): - self._circle_position = pos - self.update() - - def start_transition(self, state): - self.animation.stop() - pos = self.circlePos(state) - self.animation.setEndValue(pos) - self.animation.start() - - def hitButton(self, pos: QPoint): - return self.contentsRect().contains(pos) - - def setDisabled(self, disable): - QCheckBox.setDisabled(self, disable) - if hasattr(self, "label"): - self.label.setDisabled(disable) - self.update() - - def paintEvent(self, e): - circle_color = ( - self._circle_color if self.isEnabled() else self._disabled_circle_color - ) - active_color = ( - self._active_color if self.isEnabled() else self._disabled_active_color - ) - unchecked_color = ( - self._bg_color if self.isEnabled() else self._disabled_bg_color - ) - - # set painter - p = QPainter(self) - p.setRenderHint(QPainter.RenderHint.Antialiasing) - - # set no pen - p.setPen(Qt.NoPen) - - # draw rectangle - rect = QRect(0, 0, self.width(), self.height()) - - if not self.isChecked(): - # Draw background - p.setBrush(QColor(unchecked_color)) - half_h = int(self.height() / 2) - p.drawRoundedRect(0, 0, rect.width(), self.height(), half_h, half_h) - - # Draw circle - p.setBrush(QColor(circle_color)) - p.drawEllipse( - int(self._circle_position), - int(self._circle_margin / 2), - self.height() - self._circle_margin, - self.height() - self._circle_margin, - ) - else: - # Draw background - p.setBrush(QColor(active_color)) - half_h = int(self.height() / 2) - p.drawRoundedRect(0, 0, rect.width(), self.height(), half_h, half_h) - - # Draw circle - p.setBrush(QColor(circle_color)) - p.drawEllipse( - int(self._circle_position), - int(self._circle_margin / 2), - self.height() - self._circle_margin, - self.height() - self._circle_margin, - ) - - p.end() - - -def QKeyEventToString(event: QKeyEvent, notAllowedModifier=None): - isAltKey = event.key() == Qt.Key_Alt - isCtrlKey = event.key() == Qt.Key_Control - isShiftKey = event.key() == Qt.Key_Shift - isModifierKey = isAltKey or isCtrlKey or isShiftKey - - modifiers = event.modifiers() - isNotAllowedMod = notAllowedModifier is not None and modifiers == notAllowedModifier - if isNotAllowedMod: - return - - modifers_value = modifiers.value if PYQT6 else modifiers - if isModifierKey: - keySequenceText = KeySequenceFromText(modifers_value).toString() - else: - keySequenceText = QKeySequence(modifers_value | event.key()).toString() - - keySequenceText = keySequenceText.encode("ascii", "ignore").decode("utf-8") - - return keySequenceText - - -class ShortcutLineEdit(QLineEdit): - def __init__(self, parent=None, allowModifiers=False, notAllowedModifier=None): - self.keySequence = None - super().__init__(parent) - self._allowModifiers = allowModifiers - self._notAllowedModifier = notAllowedModifier - self.setAlignment(Qt.AlignCenter) - - def text(self): - text = macShortcutToWindows(super().text()) - - return text - - def setText(self, text): - text = windowsShortcutToMac(text) - - super().setText(text) - if not text: - self.keySequence = None - return - try: - self.keySequence = KeySequenceFromText(self.text()) - except Exception as e: - pass - - def keyPressEvent(self, event: QKeyEvent): - if event.key() == Qt.Key_Backspace or event.key() == Qt.Key_Delete: - self.setText("") - return - - keySequenceText = QKeyEventToString( - event, notAllowedModifier=self._notAllowedModifier - ) - self.setText(keySequenceText) - self.key = event.key() - - def keyReleaseEvent(self, event: QKeyEvent) -> None: - if self.text().endswith("+"): - if not self._allowModifiers: - self.setText("") - else: - self.setText(self.text().rstrip("+").strip()) - - -class selectStartStopFrames(QGroupBox): - def __init__(self, SizeT, currentFrameNum=0, parent=None): - super().__init__(parent) - selectFramesLayout = QGridLayout() - - self.startFrame_SB = QSpinBox() - self.startFrame_SB.setAlignment(Qt.AlignCenter) - self.startFrame_SB.setMinimum(1) - self.startFrame_SB.setMaximum(SizeT - 1) - self.startFrame_SB.setValue(currentFrameNum) - - self.stopFrame_SB = QSpinBox() - self.stopFrame_SB.setAlignment(Qt.AlignCenter) - self.stopFrame_SB.setMinimum(1) - self.stopFrame_SB.setMaximum(SizeT) - self.stopFrame_SB.setValue(SizeT) - - selectFramesLayout.addWidget(QLabel("Start frame n."), 0, 0) - selectFramesLayout.addWidget(self.startFrame_SB, 1, 0) - - selectFramesLayout.addWidget(QLabel("Stop frame n."), 0, 1) - selectFramesLayout.addWidget(self.stopFrame_SB, 1, 1) - - self.warningLabel = QLabel() - palette = self.warningLabel.palette() - palette.setColor(self.warningLabel.backgroundRole(), Qt.red) - palette.setColor(self.warningLabel.foregroundRole(), Qt.red) - self.warningLabel.setPalette(palette) - selectFramesLayout.addWidget( - self.warningLabel, 2, 0, 1, 2, alignment=Qt.AlignCenter - ) - - self.setLayout(selectFramesLayout) - - self.stopFrame_SB.valueChanged.connect(self._checkRange) - - def _checkRange(self): - start = self.startFrame_SB.value() - stop = self.stopFrame_SB.value() - if stop <= start: - self.warningLabel.setText("stop frame smaller than start frame") - else: - self.warningLabel.setText("") - - -class formWidget(QWidget): - sigApplyButtonClicked = Signal(object) - sigComputeButtonClicked = Signal(object) - - def __init__( - self, - widget, - initialVal=None, - stretchWidget=True, - widgetAlignment=None, - labelTextLeft="", - labelTextRight="", - font=None, - addInfoButton=False, - addApplyButton=False, - addComputeButton=False, - addActivateCheckbox=False, - key="", - infoTxt="", - valueGetterName="value", - toolTip="", - parent=None, - ): - QWidget.__init__(self, parent) - self.widget = widget - self.key = key - self.infoTxt = infoTxt - self.widgetAlignment = widgetAlignment - self.valueGetterName = valueGetterName - - widget.setParent(self) - - if isinstance(initialVal, bool): - widget.setChecked(initialVal) - elif isinstance(initialVal, str): - widget.setCurrentText(initialVal) - elif isinstance(initialVal, float) or isinstance(initialVal, int): - widget.setValue(initialVal) - - self.items = [] - - if font is None: - font = QFont() - font.setPixelSize(13) - - self.labelLeft = QClickableLabel(widget) - self.labelLeft.setText(labelTextLeft) - self.labelLeft.setFont(font) - self.items.append(self.labelLeft) - - if not stretchWidget: - widgetLayout = QHBoxLayout() - if widgetAlignment != "left": - widgetLayout.addStretch(1) - widgetLayout.addWidget(widget) - if widgetAlignment != "right": - widgetLayout.addStretch(1) - self.items.append(widgetLayout) - else: - self.items.append(widget) - - self.labelRight = QClickableLabel(widget) - self.labelRight.setText(labelTextRight) - self.labelRight.setFont(font) - self.items.append(self.labelRight) - - if toolTip: - self.labelLeft.setToolTip(toolTip) - self.widget.setToolTip(toolTip) - self.labelRight.setToolTip(toolTip) - - if addInfoButton: - infoButton = QPushButton(self) - infoButton.setCursor(Qt.WhatsThisCursor) - infoButton.setIcon(QIcon(":info.svg")) - if labelTextLeft: - infoButton.setToolTip(f'Info about "{self.labelLeft.text()}" parameter') - else: - infoButton.setToolTip( - f'Info about "{self.labelRight.text()}" measurement' - ) - infoButton.clicked.connect(self.showInfo) - self.infoButton = infoButton - self.items.append(infoButton) - - if addApplyButton: - applyButton = QPushButton(self) - applyButton.setCursor(Qt.PointingHandCursor) - applyButton.setCheckable(True) - applyButton.setIcon(QIcon(":apply.svg")) - applyButton.setToolTip(f"Apply this step and visualize results") - applyButton.clicked.connect(self.applyButtonClicked) - self.items.append(applyButton) - - if addComputeButton: - computeButton = QPushButton(self) - computeButton.setCursor(Qt.BusyCursor) - computeButton.setIcon(QIcon(":compute.svg")) - computeButton.setToolTip(f"Compute this step and visualize results") - computeButton.clicked.connect(self.computeButtonClicked) - self.items.append(computeButton) - - self.activateCheckbox = None - if addActivateCheckbox: - self.activateCheckbox = QCheckBox("Activate") - self.activateCheckbox.setChecked(False) - self.widget.setDisabled(True) - self.activateCheckbox.toggled.connect(self.setWidgetEnabled) - self.items.append(self.activateCheckbox) - - self.labelLeft.clicked.connect(self.tryChecking) - self.labelRight.clicked.connect(self.tryChecking) - - def setWidgetEnabled(self, checked): - self.widget.setDisabled(not checked) - - def value(self): - if self.activateCheckbox is None: - return getattr(self.widget, self.valueGetterName)() - - if not self.activateCheckbox.isChecked(): - return - - return getattr(self.widget, self.valueGetterName)() - - def tryChecking(self, label): - try: - self.widget.setChecked(not self.widget.isChecked()) - except AttributeError as e: - pass - - def applyButtonClicked(self): - self.sigApplyButtonClicked.emit(self) - - def computeButtonClicked(self): - self.sigComputeButtonClicked.emit(self) - - def showInfo(self): - msg = myMessageBox() - msg.setIcon() - msg.setWindowTitle(f"{self.labelLeft.text()} info") - msg.addText(self.infoTxt) - msg.addButton(" Ok ") - msg.exec_() - - def setDisabled(self, disabled: bool) -> None: - for item in self.items: - try: - item.setDisabled(disabled) - except Exception as err: - pass - - -class ToggleTerminalButton(PushButton): - sigClicked = Signal(bool) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":terminal_up.svg")) - self.setFixedSize(34, 18) - self.setIconSize(QSize(30, 14)) - self.setFlat(True) - self.terminalVisible = False - self.clicked.connect(self.mouseClick) - - def mouseClick(self): - if self.terminalVisible: - self.setIcon(QIcon(":terminal_up.svg")) - self.terminalVisible = False - else: - self.setIcon(QIcon(":terminal_down.svg")) - self.terminalVisible = True - self.sigClicked.emit(self.terminalVisible) - - def showEvent(self, a0) -> None: - self.idlePalette = self.palette() - return super().showEvent(a0) - - def enterEvent(self, event) -> None: - self.setFlat(False) - # pal = self.palette() - # pal.setColor(QPalette.ColorRole.Button, QColor(200, 200, 200)) - # self.setAutoFillBackground(True) - # self.setPalette(pal) - self.update() - return super().enterEvent(event) - - def leaveEvent(self, event) -> None: - self.setFlat(True) - # self.setPalette(self.idlePalette) - self.update() - return super().leaveEvent(event) - - -class CenteredDoubleSpinbox(QDoubleSpinBox): - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31 - 1) - - -class readOnlyDoubleSpinbox(QDoubleSpinBox): - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setReadOnly(True) - self.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) - self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31 - 1) - # self.setStyleSheet('background-color: rgba(240, 240, 240, 200);') - - -class readOnlySpinbox(QSpinBox): - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setReadOnly(True) - self.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) - self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31 - 1) - # self.setStyleSheet('background-color: rgba(240, 240, 240, 200);') - - -class DoubleSpinBox(QDoubleSpinBox): - sigValueChanged = Signal(int) - - def __init__(self, parent=None, disableKeyPress=False): - super().__init__(parent=parent) - self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31 - 1) - self.setMinimum(-(2**31)) - self._valueChangedFunction = None - self.disableKeyPress = disableKeyPress - - def keyPressEvent(self, event) -> None: - isBackSpaceKey = event.key() == Qt.Key_Backspace - isDeleteKey = event.key() == Qt.Key_Delete - try: - int(event.text()) - isIntegerKey = True - except: - isIntegerKey = False - acceptEvent = isBackSpaceKey or isDeleteKey or isIntegerKey - if self.disableKeyPress and not acceptEvent: - event.ignore() - self.clearFocus() - else: - super().keyPressEvent(event) - - def textFromValue(self, value: float) -> str: - text = super().textFromValue(value) - return text.replace(QLocale().decimalPoint(), ".") - - def valueFromText(self, text: str) -> float: - text = text.replace(".", QLocale().decimalPoint()) - return super().valueFromText(text) - - -class SpinBox(QSpinBox): - sigValueChanged = Signal(int) - sigUpClicked = Signal() - sigDownClicked = Signal() - - def __init__(self, parent=None, disableKeyPress=False, allowNegative=True): - super().__init__(parent=parent) - self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31 - 1) - if allowNegative: - self.setMinimum(-(2**31)) - else: - self.setMinimum(0) - self._valueChangedFunction = None - self.disableKeyPress = disableKeyPress - self._linkedWidget = None - - def mousePressEvent(self, event) -> None: - super().mousePressEvent(event) - opt = QStyleOptionSpinBox() - self.initStyleOption(opt) - - control = self.style().hitTestComplexControl( - QStyle.ComplexControl.CC_SpinBox, opt, event.pos(), self - ) - if control == QStyle.SubControl.SC_SpinBoxUp: - self.sigUpClicked.emit() - elif control == QStyle.SubControl.SC_SpinBoxDown: - self.sigDownClicked.emit() - - # def focusOutEvent(self, event): - # self.editingFinished.emit() - # super().focusOutEvent(event) - # printl('emitted') - - def keyPressEvent(self, event) -> None: - isBackSpaceKey = event.key() == Qt.Key_Backspace - isDeleteKey = event.key() == Qt.Key_Delete - try: - int(event.text()) - isIntegerKey = True - except: - isIntegerKey = False - acceptEvent = isBackSpaceKey or isDeleteKey or isIntegerKey - if self.disableKeyPress and not acceptEvent: - event.ignore() - self.clearFocus() - else: - super().keyPressEvent(event) - - def connectValueChanged(self, function): - self._valueChangedFunction = function - self.valueChanged.connect(function) - - def setValue(self, value, setLinkedWidget=True): - super().setValue(int(value)) - if self._linkedWidget is not None and setLinkedWidget: - self._linkedWidget.setValue(value) - - def setValueNoEmit(self, value): - if self._valueChangedFunction is None: - self.setValue(value) - return - try: - self.valueChanged.disconnect() - except TypeError as e: # this fails if its not cennected yet - pass - - self.setValue(value) - self.valueChanged.connect(self._valueChangedFunction) - - def wheelEvent(self, event): - event.ignore() - - def setLinkedValueWidget(self, widget): - self._linkedWidget = widget - - -class ReadOnlyLineEdit(QLineEdit): - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setReadOnly(True) - # self.setStyleSheet( - # 'background-color: rgba(240, 240, 240, 200);' - # ) - self.installEventFilter(self) - - def eventFilter(self, a0: "QObject", a1: "QEvent") -> bool: - if a1.type() == QEvent.Type.FocusIn: - return True - return super().eventFilter(a0, a1) - - def setValue(self, value): - self.setText(str(value)) - - def value(self, casting_func: callable = None): - text = self.text() - if casting_func is not None: - return casting_func(text) - return text - - -class FloatLineEdit(QLineEdit): - valueChanged = Signal(float) - - def __init__( - self, - *args, - notAllowed=None, - allowNegative=True, - initial=None, - readOnly=False, - decimals=6, - warningValues=None, - ): - QLineEdit.__init__(self, *args) - if readOnly: - self.setReadOnly(readOnly) - self.notAllowed = notAllowed - self.warningValues = warningValues - self._maximum = np.inf - self._minimum = -np.inf - self._decimals = decimals - - self.isNumericRegExp = rf"^{float_regex(allow_negative=allowNegative)}$" - regExp = QRegularExpression(self.isNumericRegExp) - self.setValidator(QRegularExpressionValidator(regExp)) - self.setAlignment(Qt.AlignCenter) - - font = QFont() - font.setPixelSize(11) - self.setFont(font) - - self.textChanged.connect(self.emitValueChanged) - - if initial is not None: - self.setValue(initial) - else: - self.setValue(0) - - def setDecimals(self, decimals): - self._decimals = 6 - - def castMinMax(self, value: int): - if value > self._maximum: - value = self._maximum - if value < self._minimum: - value = self._minimum - return value - - def setValue(self, value: float): - value = self.castMinMax(value) - self.setText(str(round(value, self._decimals))) - - def value(self): - m = re.match(self.isNumericRegExp, self.text()) - if m is not None: - text = m.group(0) - try: - val = float(text) - except ValueError: - val = 0.0 - else: - val = 0.0 - - return self.castMinMax(val) - - def setMaximum(self, maximum): - self._maximum = maximum - self.setValue(self.value()) - - def setMinimum(self, minimum): - self._minimum = minimum - self.setValue(self.value()) - - def emitValueChanged(self, text): - val = self.value() - reset_stylesheet = True - if self.warningValues is not None and val in self.warningValues: - self.setStyleSheet(LINEEDIT_WARNING_STYLESHEET) - reset_stylesheet = False - - if self.notAllowed is not None and val in self.notAllowed: - self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) - reset_stylesheet = False - else: - self.valueChanged.emit(self.value()) - - if reset_stylesheet: - self.setStyleSheet("") - - -class IntLineEdit(QLineEdit): - valueChanged = Signal(float) - - def __init__( - self, *args, notAllowed=None, allowNegative=True, initial=None, readOnly=False - ): - QLineEdit.__init__(self, *args) - self.notAllowed = notAllowed - if readOnly: - self.setReadOnly(readOnly) - - self._maximum = np.inf - self._minimum = -np.inf - - self._regExp = r"\d+" - if allowNegative: - self._regExp = r"-?\d+" - - regExp = QRegularExpression(self._regExp) - self.setValidator(QRegularExpressionValidator(regExp)) - self.setAlignment(Qt.AlignCenter) - - font = QFont() - font.setPixelSize(11) - self.setFont(font) - - self.textChanged.connect(self.emitValueChanged) - - if initial is not None: - self.setValue(initial) - else: - self.setValue(0) - - def setMaximum(self, maximum): - self._maximum = maximum - self.setValue(self.value()) - - def setMinimum(self, minimum): - self._minimum = minimum - self.setValue(self.value()) - - def castMinMax(self, value: int): - if value > self._maximum: - value = self._maximum - if value < self._minimum: - value = self._minimum - return value - - def setValue(self, value: int): - value = self.castMinMax(value) - self.setText(str(value)) - - def value(self): - m = re.match(self._regExp, self.text()) - if m is not None: - text = m.group(0) - try: - val = int(text) - except ValueError: - val = 0 - else: - val = 0 - - return self.castMinMax(val) - - def emitValueChanged(self, text): - if not text: - return - - val = self.value() - self.setValue(val) - if self.notAllowed is not None and val in self.notAllowed: - self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) - else: - self.setStyleSheet("") - self.valueChanged.emit(self.value()) - - -class CheckboxesGroupBox(QGroupBox): - def __init__(self, texts, title="", checkable=False, parent=None): - super().__init__(parent) - - self.setTitle(title) - self.setCheckable(checkable) - layout = QVBoxLayout() - - scrollLayout = QVBoxLayout() - container = QWidget() - scrollarea = QScrollArea() - - self.checkBoxes = [] - for text in texts: - checkbox = QCheckBox(text) - checkbox.setChecked(True) - scrollLayout.addWidget(checkbox) - self.checkBoxes.append(checkbox) - - container.setLayout(scrollLayout) - scrollarea.setWidget(container) - layout.addWidget(scrollarea) - - buttonsLayout = QHBoxLayout() - selectAllButton = selectAllPushButton() - selectAllButton.sigClicked.connect(self.checkAll) - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(selectAllButton) - layout.addLayout(buttonsLayout) - - self.setLayout(layout) - - def checkAll(self, button, checked): - for checkBox in self.checkBoxes: - checkBox.setChecked(checked) - - -class _metricsQGBox(QGroupBox): - sigDelClicked = Signal(str, object) - - def __init__( - self, - desc_dict, - title, - favourite_funcs=None, - isZstack=False, - equations=None, - addDelButton=False, - delButtonMetricsDesc=None, - parent=None, - addCalcForEachZsliceToggle=False, - ): - QGroupBox.__init__(self, parent) - - highlightRgba = _palettes._highlight_rgba() - r, g, b, a = highlightRgba - self._highlightStylesheetColor = f"rgb({r}, {g}, {b})" - - self._parent = parent - self.scrollArea = QScrollArea() - self.scrollAreaWidget = QWidget() - self.favourite_funcs = favourite_funcs - - self.doNotWarn = False - - layout = QVBoxLayout() - inner_layout = QVBoxLayout() - self.inner_layout = inner_layout - if delButtonMetricsDesc is None: - delButtonMetricsDesc = [] - - self.checkBoxes = [] - self.checkedState = {} - for metric_colname, metric_desc in desc_dict.items(): - rowLayout = QHBoxLayout() - - checkBox = QCheckBox(metric_colname) - checkBox.setChecked(True) - checkBox.scrollArea = self.scrollArea - self.checkBoxes.append(checkBox) - self.checkedState[checkBox] = True - - try: - checkBox.equation = equations[metric_colname] - except Exception as e: - pass - - if addDelButton or metric_colname in delButtonMetricsDesc: - delButton = delPushButton() - delButton.setToolTip("Delete custom combined measurement") - delButton.colname = metric_colname - delButton.checkbox = checkBox - delButton.clicked.connect(self.onDelClicked) - delButton._layout = rowLayout - rowLayout.addWidget(delButton) - - infoButton = infoPushButton() - infoButton.setCursor(Qt.WhatsThisCursor) - infoButton.info = metric_desc - infoButton.colname = metric_colname - infoButton.clicked.connect(self.showInfo) - - rowLayout.addWidget(infoButton) - rowLayout.addWidget(checkBox) - rowLayout.addStretch(1) - - inner_layout.addLayout(rowLayout) - - self.scrollAreaWidget.setLayout(inner_layout) - self.scrollArea.setWidget(self.scrollAreaWidget) - layout.addWidget(self.scrollArea) - - buttonsLayout = QHBoxLayout() - - buttonsLayout.addStretch(1) - - self.selectAllButton = selectAllPushButton() - self.selectAllButton.sigClicked.connect(self.checkAll) - - buttonsLayout.addWidget(self.selectAllButton) - - if favourite_funcs is not None: - self.loadFavouritesButton = reloadPushButton(" Load last selection... ") - self.loadFavouritesButton.clicked.connect(self.checkFavouriteFuncs) - # self.checkFavouriteFuncs() - buttonsLayout.addWidget(self.loadFavouritesButton) - - layout.addLayout(buttonsLayout) - - self.calcForEachZsliceToggle = None - if addCalcForEachZsliceToggle: - buttonsLayout = QHBoxLayout() - self.calcForEachZsliceToggle = Toggle() - tooltip = ( - "Calculate `cell_area` for each z-slice.\n\n" - "The measurements will be saved in the column with name\n" - "ending with `_zsliceN` where N is the z-slice number\n" - "(starting from 0)." - ) - calcForEachZsliceLabel = QClickableLabel("Calculate for each z-slice") - calcForEachZsliceLabel.setToolTip(tooltip) - self.calcForEachZsliceToggle.setToolTip(tooltip) - buttonsLayout.addWidget(self.calcForEachZsliceToggle) - buttonsLayout.addWidget(calcForEachZsliceLabel) - buttonsLayout.addStretch(1) - layout.addLayout(buttonsLayout) - calcForEachZsliceLabel.clicked.connect( - partial( - self.toggleCalcForEachZslice, toggle=self.calcForEachZsliceToggle - ) - ) - - self.setTitle(title) - self.setCheckable(True) - self.setLayout(layout) - _font = QFont() - _font.setPixelSize(11) - self.setFont(_font) - - self.toggled.connect(self.toggled_cb) - - def toggleCalcForEachZslice(self, label, toggle=None): - if toggle is None: - toggle = self.calcForEachZsliceToggle - - toggle.setChecked(not toggle.isChecked()) - - def isCalcForEachZsliceRequested(self): - if self.calcForEachZsliceToggle is None: - return False - - return self.calcForEachZsliceToggle.isChecked() - - def highlightCheckboxesFromSearchText(self, text): - for checkbox in self.checkBoxes: - if not text: - highlighted = False - else: - highlighted = checkbox.text().lower().find(text.lower()) != -1 - - self.setCheckboxHighlighted(highlighted, checkbox) - - def setCheckboxHighlighted(self, highlighted, checkbox): - if highlighted: - checkbox.setStyleSheet( - f"background: {self._highlightStylesheetColor}; color: black" - ) - self.scrollArea.ensureWidgetVisible(checkbox) - else: - checkbox.setStyleSheet("") - - def onDelClicked(self): - button = self.sender() - button.checkbox.setChecked(False) - self.sigDelClicked.emit(button.colname, button._layout) - - def toggled_cb(self, checked): - for checkbox in self.checkBoxes: - if not checked: - self.checkedState[checkbox] = checkbox.isChecked() - checkbox.setChecked(False) - else: - checkbox.setChecked(self.checkedState[checkbox]) - - def checkFavouriteFuncs(self, checked=True, isZstack=False): - self.doNotWarn = True - if self._parent is not None: - self._parent.doNotWarn = True - for checkBox in self.checkBoxes: - checkBox.setChecked(False) - for favourite_func in self.favourite_funcs: - func_name = checkBox.text() - if func_name.endswith(favourite_func): - checkBox.setChecked(True) - break - self.doNotWarn = False - if self._parent is not None: - self._parent.doNotWarn = False - - def checkAll(self, button, checked): - if self._parent is not None: - self._parent.doNotWarn = True - for checkBox in self.checkBoxes: - checkBox.setChecked(checked) - if self._parent is not None: - self._parent.doNotWarn = False - - def showInfo(self, checked=False): - info_txt = self.sender().info - msg = myMessageBox() - msg.setWidth(600) - msg.setIcon() - msg.setWindowTitle(f"{self.sender().colname} info") - msg.addText(info_txt) - msg.addButton(" Ok ") - msg.exec_() - - def show(self): - super().show() - fw = self.inner_layout.contentsRect().width() - sw = self.scrollArea.verticalScrollBar().sizeHint().width() - self.minWidth = fw + sw - - -class channelMetricsQGBox(QGroupBox): - sigDelClicked = Signal(str, object) - sigCheckboxToggled = Signal(object) - - def __init__( - self, - isZstack, - chName, - isSegm3D, - is_concat=False, - posData=None, - favourite_funcs=None, - ): - QGroupBox.__init__(self) - - self.doNotWarn = False - self.is_concat = is_concat - isManualBackgrPresent = False - if posData is not None: - if posData.manualBackgroundLab is not None: - isManualBackgrPresent = True - - layout = QVBoxLayout() - metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc( - isZstack, - chName, - isSegm3D=isSegm3D, - isManualBackgrPresent=isManualBackgrPresent, - ) - - metricsQGBox = _metricsQGBox( - metrics_desc, - "Standard measurements", - favourite_funcs=favourite_funcs, - parent=self, - isZstack=isZstack, - ) - self.metricsQGBox = metricsQGBox - - bkgrValsQGBox = _metricsQGBox( - bkgr_val_desc, - "Background values", - favourite_funcs=favourite_funcs, - parent=self, - isZstack=isZstack, - ) - self.bkgrValsQGBox = bkgrValsQGBox - - self.checkBoxes = metricsQGBox.checkBoxes.copy() - self.checkBoxes.extend(bkgrValsQGBox.checkBoxes) - - self.uncheckAndDisableDataPrepIfPosNotPrepped(posData) - - self.groupboxes = [metricsQGBox, bkgrValsQGBox] - - for checkbox in metricsQGBox.checkBoxes: - checkbox.toggled.connect(self.standardMetricToggled) - self.standardMetricToggled(checkbox.isChecked(), checkbox=checkbox) - - for bkgrCheckbox in bkgrValsQGBox.checkBoxes: - bkgrCheckbox.toggled.connect(self.backgroundMetricToggled) - - layout.addWidget(metricsQGBox) - layout.addWidget(bkgrValsQGBox) - - items = measurements.custom_metrics_desc( - isZstack, chName, posData=posData, isSegm3D=isSegm3D, return_combine=True - ) - custom_metrics_desc, combine_metrics_desc = items - - if custom_metrics_desc: - customMetricsQGBox = _metricsQGBox( - custom_metrics_desc, - "Custom measurements", - delButtonMetricsDesc=combine_metrics_desc, - favourite_funcs=favourite_funcs, - isZstack=isZstack, - ) - layout.addWidget(customMetricsQGBox) - self.checkBoxes.extend(customMetricsQGBox.checkBoxes) - customMetricsQGBox.sigDelClicked.connect(self.onDelClicked) - self.customMetricsQGBox = customMetricsQGBox - - self.calcForEachZsliceToggle = None - if isZstack: - buttonsLayout = QHBoxLayout() - self.calcForEachZsliceToggle = Toggle() - tooltip = ( - "Calculate the selected measurements for each z-slice.\n\n" - "The measurements will be saved in the column with name\n" - "ending with `_zsliceN` where N is the z-slice number\n" - "(starting from 0)." - ) - calcForEachZsliceLabel = QClickableLabel("Calculate for each z-slice") - calcForEachZsliceLabel.setToolTip(tooltip) - self.calcForEachZsliceToggle.setToolTip(tooltip) - buttonsLayout.addWidget(self.calcForEachZsliceToggle) - buttonsLayout.addWidget(calcForEachZsliceLabel) - buttonsLayout.addStretch(1) - layout.addLayout(buttonsLayout) - calcForEachZsliceLabel.clicked.connect( - partial( - self.toggleCalcForEachZslice, toggle=self.calcForEachZsliceToggle - ) - ) - - self.setTitle(f"{chName} metrics") - self.setCheckable(True) - self.setLayout(layout) - - def toggleCalcForEachZslice(self, label, toggle=None): - if toggle is None: - toggle = self.calcForEachZsliceToggle - - toggle.setChecked(not toggle.isChecked()) - - def isCalcForEachZsliceRequested(self): - if self.calcForEachZsliceToggle is None: - return False - - return self.calcForEachZsliceToggle.isChecked() - - def uncheckAndDisableDataPrepIfPosNotPrepped(self, posData): - # Uncheck and disable dataprep metrics if pos is not prepped - if posData is None: - return - - if posData.isBkgrROIpresent(): - return - - for checkbox in self.checkBoxes: - if checkbox.text().find("dataPrep") == -1: - continue - - checkbox.setChecked(False) - checkbox.isDataPrepDisabled = True - - def _warnDataPrepCannotBeChecked(self): - if self.doNotWarn: - return - txt = html_utils.paragraph(""" - Data prep measurements cannot be saved because you did - not select any background ROI at the data prep step.

    - - You can read more details about data prep metrics by clicking - on the info button besides the measurement's name.

    - - Thank you for you patience! - """) - msg = myMessageBox(showCentered=False) - msg.warning(self, "Metric cannot be saved", txt) - - def standardMetricToggled(self, checked, checkbox=None): - """Method called when a check-box is toggled. It performs the following - actions: - 1. If the user try to check a data prep measurement, such as - dataPrep_amount, and this cannot be saved (checkbox has the attr - `isDataPrepDisabled`) then it warns and explains why it cannot be saved - 2. Make sure that background value median is checked if the user - requires amount or concentration metric. - 3. Do not allow unchecking background value median and explain why. - - Parameters - ---------- - checked : bool - State of the checkbox toggled - checkbox : QtWidgets.QCheckBox, optional - The checkbox that has been toggled. Default is None. If None - use `self.sender()` - """ - if self.is_concat: - return - - if checkbox is None: - checkbox = self.sender() - - if hasattr(checkbox, "isDataPrepDisabled"): - # Warn that user cannot check data prep metrics and uncheck it - if not checkbox.isChecked(): - return - checkbox.setChecked(False) - self._warnDataPrepCannotBeChecked() - return - - self.sigCheckboxToggled.emit(checkbox) - if checkbox.text().find("amount_") == -1: - return - pattern = r"amount_([A-Za-z]+)(_?[A-Za-z0-9]*)" - repl = r"\g<1>_bkgrVal_median\g<2>" - bkgrValMetric = s1 = re.sub(pattern, repl, checkbox.text()) - for bkgrCheckbox in self.groupboxes[1].checkBoxes: - if bkgrCheckbox.text() == bkgrValMetric: - break - else: - # Make sure to not check for similarly named custom metrics - return - - if checked: - bkgrCheckbox.setChecked(True) - bkgrCheckbox.isRequired = True - else: - bkgrCheckbox.setDisabled(False) - bkgrCheckbox.isRequired = False - - def backgroundMetricToggled(self, checked): - """Method called when a checkbox of a background metric is toggled. - Check if the background value is required and explain why it cannot be - unchecked. - - Parameters - ---------- - checked : bool - State of the checkbox toggled - """ - if self.is_concat: - return - - checkbox = self.sender() - if not hasattr(checkbox, "isRequired"): - return - - if not checkbox.isRequired: - return - - if checkbox.isChecked(): - return - - if self.doNotWarn: - return - - checkbox.setChecked(True) - txt = html_utils.paragraph(""" - This background value cannot be unchecked because it is required - by the _amount and _concentration measurements - that you requested to save.

    - - Thank you for you patience! - """) - msg = myMessageBox(showCentered=False) - msg.warning(self, "Background value required", txt) - - def onDelClicked(self, colname_to_del, hlayout): - self.sigDelClicked.emit(colname_to_del, hlayout) - - def checkFavouriteFuncs(self): - self.doNotWarn = True - for groupbox in self.groupboxes: - groupbox.checkFavouriteFuncs() - self.doNotWarn = False - - -class PixelSizeGroupbox(QGroupBox): - sigValueChanged = Signal(float, float, float) - sigReset = Signal() - - def __init__(self, parent=None): - super().__init__("Pixel size", parent) - - mainLayout = QGridLayout() - - row = 0 - label = QLabel("Pixel width (μm): ") - self.pixelWidthWidget = FloatLineEdit(initial=1.0) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.pixelWidthWidget, row, 1) - - row += 1 - label = QLabel("Pixel height (μm): ") - self.pixelHeightWidget = FloatLineEdit(initial=1.0) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.pixelHeightWidget, row, 1) - - row += 1 - label = QLabel("Voxel depth (μm): ") - self.voxelDepthWidget = FloatLineEdit(initial=1.0) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.voxelDepthWidget, row, 1) - - row += 1 - resetButton = reloadPushButton("Reset") - mainLayout.addWidget(resetButton, row, 1, alignment=Qt.AlignRight) - - row += 1 - mainLayout.addWidget(QHLine(), row, 0, 1, 2) - - mainLayout.setColumnStretch(0, 0) - mainLayout.setColumnStretch(1, 1) - - self.setLayout(mainLayout) - - self.pixelWidthWidget.valueChanged.connect(self.emitValueChanged) - self.pixelHeightWidget.valueChanged.connect(self.emitValueChanged) - self.voxelDepthWidget.valueChanged.connect(self.emitValueChanged) - resetButton.clicked.connect(self.emitReset) - - def emitReset(self): - self.sigReset.emit() - - def emitValueChanged(self, value): - PhysicalSizeX = self.pixelWidthWidget.value() - PhysicalSizeY = self.pixelHeightWidget.value() - PhysicalSizeZ = self.voxelDepthWidget.value() - self.sigValueChanged.emit(PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) - - -class objPropsQGBox(QGroupBox): - def __init__(self, parent=None): - QGroupBox.__init__(self, "Properties", parent) - - mainLayout = QGridLayout() - - row = 0 - label = QLabel("Object ID: ") - self.idSB = IntLineEdit() - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.idSB, row, 1) - - row += 1 - mainLayout.addWidget(QHLine(), row, 0, 1, 2) - - row += 1 - self.notExistingIDLabel = QLabel() - self.notExistingIDLabel.setStyleSheet("font-size:11px; color: rgb(255, 0, 0);") - mainLayout.addWidget( - self.notExistingIDLabel, row, 0, 1, 2, alignment=Qt.AlignCenter - ) - - row += 1 - label = QLabel("Area (pixel): ") - self.cellAreaPxlSB = IntLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.cellAreaPxlSB, row, 1) - - row += 1 - label = QLabel("Area (µm2): ") - self.cellAreaUm2DSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.cellAreaUm2DSB, row, 1) - - row += 1 - mainLayout.addWidget(QHLine(), row, 0, 1, 2) - - row += 1 - label = QLabel("Rotational volume (voxel): ") - self.cellVolVoxSB = IntLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.cellVolVoxSB, row, 1) - - row += 1 - label = QLabel("3D volume (voxel): ") - self.cellVolVox3D_SB = IntLineEdit(readOnly=True) - self.cellVolVox3D_SB.label = label - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.cellVolVox3D_SB, row, 1) - - row += 1 - label = QLabel("Rotational volume (fl): ") - self.cellVolFlDSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.cellVolFlDSB, row, 1) - - row += 1 - label = QLabel("3D volume (fl): ") - self.cellVolFl3D_DSB = FloatLineEdit(readOnly=True) - self.cellVolFl3D_DSB.label = label - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.cellVolFl3D_DSB, row, 1) - - row += 1 - mainLayout.addWidget(QHLine(), row, 0, 1, 2) - - row += 1 - label = QLabel("Solidity: ") - self.solidityDSB = FloatLineEdit(readOnly=True) - self.solidityDSB.setMaximum(1) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.solidityDSB, row, 1) - - row += 1 - label = QLabel("Elongation: ") - self.elongationDSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.elongationDSB, row, 1) - - row += 1 - mainLayout.addWidget(QHLine(), row, 0, 1, 2) - - row += 1 - propsNames = measurements.get_props_names()[1:] - self.additionalPropsCombobox = QComboBox() - self.additionalPropsCombobox.addItems(propsNames) - self.additionalPropsCombobox.indicator = FloatLineEdit(readOnly=True) - mainLayout.addWidget(self.additionalPropsCombobox, row, 0) - mainLayout.addWidget(self.additionalPropsCombobox.indicator, row, 1) - - row += 1 - mainLayout.addWidget(QHLine(), row, 0, 1, 2) - - mainLayout.setColumnStretch(0, 0) - mainLayout.setColumnStretch(1, 1) - - self.setLayout(mainLayout) - - -class objIntesityMeasurQGBox(QGroupBox): - def __init__(self, parent=None): - QGroupBox.__init__(self, "Intensity measurements", parent) - - mainLayout = QGridLayout() - - row = 0 - label = QLabel("Raw intensity measurements") - - row += 1 - label = QLabel("Channel: ") - self.channelCombobox = QComboBox() - self.channelCombobox.addItem("placeholderlong") - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.channelCombobox, row, 1) - - row += 1 - label = QLabel("Minimum: ") - self.minimumDSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.minimumDSB, row, 1) - - row += 1 - label = QLabel("Maximum: ") - self.maximumDSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.maximumDSB, row, 1) - - row += 1 - label = QLabel("Mean: ") - self.meanDSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.meanDSB, row, 1) - - row += 1 - label = QLabel("Median: ") - self.medianDSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.medianDSB, row, 1) - - row += 1 - metricsDesc = measurements._get_metrics_names() - metricsFunc, _ = measurements.standard_metrics_func() - items = list(set([metricsDesc[key] for key in metricsFunc.keys()])) - items.append("Concentration") - items.sort() - nameFuncDict = {} - for name, desc in metricsDesc.items(): - if name.find("_dataPrepBkgr") != -1 or name.find("_manualBkgr") != -1: - # Skip dataPrepBkgr and manualBkgr since in the dock widget - # we display only autoBkgr metrics - continue - if name.startswith("concentration_"): - # We use amount function because dividing by volume is taken - # care in the GUI - name = "amount_autoBkgr" - nameFuncDict[desc] = metricsFunc[name] - - funcionCombobox = QComboBox() - funcionCombobox.addItems(items) - self.additionalMeasCombobox = funcionCombobox - self.additionalMeasCombobox.indicator = FloatLineEdit(readOnly=True) - self.additionalMeasCombobox.functions = nameFuncDict - mainLayout.addWidget(funcionCombobox, row, 0) - mainLayout.addWidget(self.additionalMeasCombobox.indicator, row, 1) - - self.setLayout(mainLayout) - - def addChannels(self, channels): - self.channelCombobox.clear() - self.channelCombobox.addItems(channels) - - -class guiTabControl(QTabWidget): - def __init__(self, *args): - super().__init__(args[0]) - - self._defaultPixelSize = None - - self.propsTab = QScrollArea(self) - - container = QWidget() - layout = QVBoxLayout() - - self.pixelSizeQGBox = PixelSizeGroupbox(parent=self.propsTab) - self.propsQGBox = objPropsQGBox(parent=self.propsTab) - self.intensMeasurQGBox = objIntesityMeasurQGBox(parent=self.propsTab) - - self.highlightCheckbox = QCheckBox("Highlight objects on mouse hover") - self.highlightCheckbox.setChecked(False) - - self.highlightSearchedCheckbox = QCheckBox("Highlight searched object") - self.highlightSearchedCheckbox.setChecked(True) - - highlightLayout = QHBoxLayout() - highlightLayout.addWidget(self.highlightCheckbox) - highlightLayout.addStretch(1) - highlightLayout.addWidget(QLabel("|")) - highlightLayout.addStretch(1) - highlightLayout.addWidget(self.highlightSearchedCheckbox) - - layout.addLayout(highlightLayout) - layout.addWidget(self.pixelSizeQGBox) - layout.addWidget(self.propsQGBox) - layout.addWidget(self.intensMeasurQGBox) - layout.addStretch(1) - container.setLayout(layout) - - self.propsTab.setWidgetResizable(True) - self.propsTab.setWidget(container) - self.addTab(self.propsTab, "Measurements") - - self.pixelSizeQGBox.sigValueChanged.connect(self.pixelSizeChanged) - self.pixelSizeQGBox.sigReset.connect(self.resetPixelSize) - - def addChannels(self, channels): - self.intensMeasurQGBox.addChannels(channels) - - def resetPixelSize(self): - if self._defaultPixelSize is None: - return - - self.initPixelSize(*self._defaultPixelSize) - - def initPixelSize(self, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ): - self.pixelSizeQGBox.pixelWidthWidget.setValue(PhysicalSizeX) - self.pixelSizeQGBox.pixelHeightWidget.setValue(PhysicalSizeY) - self.pixelSizeQGBox.voxelDepthWidget.setValue(PhysicalSizeZ) - self._defaultPixelSize = (PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) - - def pixelSizeChanged(self, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ): - propsQGBox = self.propsQGBox - yx_pxl_to_um2 = PhysicalSizeY * PhysicalSizeX - vox_rot_to_fl = float(PhysicalSizeY) * pow(float(PhysicalSizeX), 2) - vox_3D_to_fl = PhysicalSizeZ * PhysicalSizeY * PhysicalSizeX - - area_pxl = propsQGBox.cellAreaPxlSB.value() - area_um2 = area_pxl * yx_pxl_to_um2 - propsQGBox.cellAreaUm2DSB.setValue(area_um2) - - vol_rot_vox = propsQGBox.cellVolVoxSB.value() - vol_rot_fl = vol_rot_vox * vox_rot_to_fl - propsQGBox.cellVolFlDSB.setValue(vol_rot_fl) - - vol_3D_vox = propsQGBox.cellVolVox3D_SB.value() - vol_3D_fl = vol_3D_vox * vox_3D_to_fl - propsQGBox.cellVolFl3D_DSB.setValue(vol_3D_fl) - - -class expandCollapseButton(PushButton): - sigClicked = Signal() - - def __init__(self, parent=None, **kwargs): - super().__init__(parent, **kwargs) - self.setIcon(QIcon(":expand.svg")) - self.setFlat(True) - self.installEventFilter(self) - self.isExpand = True - self.clicked.connect(self.buttonClicked) - - def buttonClicked(self, checked=False): - if self.isExpand: - self.setIcon(QIcon(":collapse.svg")) - self.isExpand = False - if self.text(): - self.setText(self.text().replace("Hide", "Show")) - else: - self.setIcon(QIcon(":expand.svg")) - self.isExpand = True - if self.text(): - self.setText(self.text().replace("Show", "Hide")) - self.sigClicked.emit() - - def eventFilter(self, object, event): - if event.type() == QEvent.Type.HoverEnter: - self.setFlat(False) - elif event.type() == QEvent.Type.HoverLeave: - self.setFlat(True) - return False - - -class view_visualcpp_screenshot(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - layout = QHBoxLayout() - - self.setWindowTitle("Visual Studio Builld Tools installation") - - pixmap = QPixmap(":visualcpp.png") - label = QLabel() - label.setPixmap(pixmap) - - layout.addWidget(label) - self.setLayout(layout) - - -class PolyLineROI(pg.PolyLineROI): - def __init__(self, positions, closed=False, pos=None, **args): - super().__init__(positions, closed, pos, **args) - - -class BaseGradientEditorItemImage(pg.GradientEditorItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - - def restoreState(self, state): - pg.graphicsItems.GradientEditorItem.Gradients = GradientsImage - return super().restoreState(state) - - -class MouseCursor(QWidget): - def __init__(self, parent=None) -> None: - super().__init__(parent) - self._x = None - self._y = None - self.setMouseTracking(True) - - def mouseMoveEvent(self, event) -> None: - self.move(event.pos()) - self.update() - return super().mouseMoveEvent(event) - - # def drawAtPos(self, x, y): - # self._x = x - # self._y = y - # self.update() - - def paintEvent(self, event) -> None: - p = QPainter(self) - # p.setPen(QPen(QColor(0,0,0))) - # p.setBrush(QBrush(QColor(70,70,70,200))) - p.drawLine(0, 0, 200, 0) - p.end() - - -class BaseGradientEditorItemLabels(pg.GradientEditorItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - - def restoreState(self, state): - pg.graphicsItems.GradientEditorItem.Gradients = GradientsLabels - return super().restoreState(state) - - -class baseHistogramLUTitem(pg.HistogramLUTItem): - sigAddColormap = Signal(object, str) - sigRescaleIntes = Signal(object) - - def __init__(self, name="image", axisLabel="", parent=None, **kwargs): - pg.GradientEditorItem = BaseGradientEditorItemLabels - - super().__init__(**kwargs) - - self.labelStyle = {"color": "#ffffff", "font-size": "11px"} - - if axisLabel: - self.setAxisLabel(axisLabel) - - self.cmaps = cmaps - self._parent = parent - self.name = name - - self.gradient.colorDialog.setWindowFlags(Qt.Dialog | Qt.WindowStaysOnTopHint) - self.gradient.colorDialog.accepted.disconnect() - self.gradient.colorDialog.accepted.connect(self.tickColorAccepted) - - self.isInverted = False - self.lastGradientName = "grey" - self.lastGradient = Gradients["grey"] - - for action in self.gradient.menu.actions(): - if action.text() == "HSV": - HSV_action = action - elif action.text() == "RGB": - RGB_ation = action - self.gradient.menu.removeAction(HSV_action) - self.gradient.menu.removeAction(RGB_ation) - - # Rescale intensities (LUT) - rescaleIntensMenu = self.gradient.menu.addMenu("Rescale intensities (LUT)") - rescaleActionGroup = QActionGroup(self) - rescaleActionGroup.setExclusive(True) - - self.rescaleEach2DimgAction = QAction( - "Rescale each 2D image", rescaleIntensMenu - ) - self.rescaleEach2DimgAction.setCheckable(True) - self.rescaleEach2DimgAction.setChecked(True) - rescaleActionGroup.addAction(self.rescaleEach2DimgAction) - rescaleIntensMenu.addAction(self.rescaleEach2DimgAction) - - self.rescaleAcrossZstackAction = QAction( - "Rescale across z-stack", rescaleIntensMenu - ) - self.rescaleAcrossZstackAction.setCheckable(True) - self.rescaleAcrossZstackAction.setChecked(False) - rescaleActionGroup.addAction(self.rescaleAcrossZstackAction) - rescaleIntensMenu.addAction(self.rescaleAcrossZstackAction) - - self.rescaleAcrossTimeAction = QAction( - "Rescale across time frames", rescaleIntensMenu - ) - self.rescaleAcrossTimeAction.setCheckable(True) - self.rescaleAcrossTimeAction.setChecked(False) - rescaleActionGroup.addAction(self.rescaleAcrossTimeAction) - rescaleIntensMenu.addAction(self.rescaleAcrossTimeAction) - - self.customRescaleAction = QAction("Choose custom levels...", rescaleIntensMenu) - self.customRescaleAction.setCheckable(True) - rescaleActionGroup.addAction(self.customRescaleAction) - rescaleIntensMenu.addAction(self.customRescaleAction) - - self.doNotRescaleAction = QAction( - "Do no rescale, display raw image", rescaleIntensMenu - ) - self.doNotRescaleAction.setCheckable(True) - rescaleActionGroup.addAction(self.doNotRescaleAction) - rescaleIntensMenu.addAction(self.doNotRescaleAction) - - self.rescaleActionGroup = rescaleActionGroup - rescaleActionGroup.triggered.connect(self.rescaleActionTriggered) - - # Add custom colormap action - self.customCmapsMenu = self.gradient.menu.addMenu("Custom colormaps") - self.customCmapsMenu.aboutToShow.connect(self.onShowCustomCmapsMenu) - self.customCmapsMenu.triggered.connect(self.customCmapsMenuTriggered) - - self.saveColormapAction = QAction("Save current colormap...", self) - self.gradient.menu.addAction(self.saveColormapAction) - self.saveColormapAction.triggered.connect(self.saveColormap) - - self.addCustomGradients() - - # Set inverted gradients for invert bw action - self.addInvertedColorMaps() - - self.gradient.menu.addSeparator() - - # hide histogram tool - self.vb.hide() - - # Disable moving the axis up and down - self.axis.unlinkFromView() - - # Disable histogram default context Menu event - self.vb.raiseContextMenu = lambda x: None - - def rescaleActionTriggered(self, action): - self.sigRescaleIntes.emit(action) - - def onShowCustomCmapsMenu(self): - self.customCmapsMenu.show() - - def customCmapsMenuTriggered(self, action): - cmap = action.cmap - self.gradient.colorMapMenuClicked(cmap) - self.gradient.showTicks(True) - - def setAxisLabel(self, text): - self.labelText = text - self.axis.setLabel(text, **self.labelStyle) - - def updateAxisLabel(self): - text = self.axis.label.toPlainText() - if not text: - return - self.setAxisLabel(text) - - def setGradient(self, gradient): - self.gradient.restoreState(gradient) - self.lastGradient = gradient - - def colormapClicked(self, checked=False, name=None): - name = self.sender().name - self.lastGradientName = name - if self.isInverted: - self.setGradient(self.invertedGradients[name]) - else: - self.setGradient(Gradients[name]) - - def sortTicks(self, ticks): - sortedTicks = sorted(ticks, key=operator.itemgetter(0)) - return sortedTicks - - def getInvertedGradients(self): - invertedGradients = {} - for name, gradient in Gradients.items(): - ticks = gradient["ticks"] - sortedTicks = self.sortTicks(ticks) - if name in nonInvertibleCmaps: - invertedColors = sortedTicks - else: - invertedColors = [ - (t[0], ti[1]) for t, ti in zip(sortedTicks, sortedTicks[::-1]) - ] - invertedGradient = {} - invertedGradient["ticks"] = invertedColors - invertedGradient["mode"] = gradient["mode"] - invertedGradients[name] = invertedGradient - return invertedGradients - - def addInvertedColorMaps(self): - self.invertedGradients = self.getInvertedGradients() - for action in self.gradient.menu.actions(): - if not hasattr(action, "name"): - continue - - if action.name not in self.cmaps: - continue - - action.triggered.disconnect() - action.triggered.connect(self.colormapClicked) - - px = QPixmap(100, 15) - p = QPainter(px) - invertedGradient = self.invertedGradients[action.name] - qtGradient = QLinearGradient(QPointF(0, 0), QPointF(100, 0)) - ticks = self.sortTicks(invertedGradient["ticks"]) - qtGradient.setStops([(x, QColor(*color)) for x, color in ticks]) - brush = QBrush(qtGradient) - p.fillRect(QRect(0, 0, 100, 15), brush) - p.end() - widget = action.defaultWidget() - hbox = widget.layout() - rectLabelWidget = QLabel() - rectLabelWidget.setPixmap(px) - hbox.addWidget(rectLabelWidget) - rectLabelWidget.hide() - - def setInvertedColorMaps(self, inverted): - if inverted: - showIdx = 2 - hideIdx = 1 - self.labelStyle["color"] = "#000000" - else: - showIdx = 1 - hideIdx = 2 - self.labelStyle["color"] = "#ffffff" - - for action in self.gradient.menu.actions(): - if not hasattr(action, "name"): - continue - - if action.name not in self.cmaps: - continue - - widget = action.defaultWidget() - hbox = widget.layout() - hideCmapRect = hbox.itemAt(hideIdx).widget() - showCmapRect = hbox.itemAt(showIdx).widget() - hideCmapRect.hide() - showCmapRect.show() - - self.updateAxisLabel() - self.isInverted = inverted - - def invertGradient(self, gradient): - ticks = gradient["ticks"] - sortedTicks = self.sortTicks(ticks) - invertedColors = [ - (t[0], ti[1]) for t, ti in zip(sortedTicks, sortedTicks[::-1]) - ] - invertedGradient = {} - invertedGradient["ticks"] = invertedColors - invertedGradient["mode"] = gradient["mode"] - return invertedGradient - - def invertCurrentColormap(self, inverted, debug=False): - self.setGradient(self.invertGradient(self.lastGradient)) - - def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): - self.originalLength = self.gradient.length - self.gradient.length = 100 - if restore: - self.gradient.restoreState(gradient_ticks) - gradient = self.gradient.getGradient() - action = CustomGradientMenuAction(gradient, gradient_name, self.gradient) - # action.triggered.connect(self.gradient.contextMenuClicked) - action.delButton.clicked.connect(self.removeCustomGradient) - action.cmap = colors.pg_ticks_to_colormap(gradient_ticks["ticks"]) - # self.gradient.menu.insertAction(self.saveColormapAction, action) - self.customCmapsMenu.addAction(action) - self.gradient.length = self.originalLength - GradientsImage[gradient_name] = gradient_ticks - - def removeCustomGradient(self): - button = self.sender() - action = button.action - self.customCmapsMenu.removeAction(action) - cp = config.ConfigParser() - cp.read(custom_cmaps_filepath) - cp.remove_section(f"image.{action.name}") - with open(custom_cmaps_filepath, mode="w") as file: - cp.write(file) - - def addCustomGradients(self): - try: - CustomGradients = getCustomGradients(name="image") - if not CustomGradients: - return - for gradient_name, gradient_ticks in CustomGradients.items(): - self.addCustomGradient(gradient_name, gradient_ticks) - except Exception as e: - printl(traceback.format_exc()) - pass - - def _askNameColormap(self): - inputWin = apps.QInput(parent=self._parent, title="Colormap name") - inputWin.askText("Insert a name for the colormap: ", allowEmpty=False) - if inputWin.cancel: - return - cmapName = inputWin.answer - return cmapName - - def saveColormap(self): - cmapName = self._askNameColormap() - if cmapName is None: - return - - cp = config.ConfigParser() - if os.path.exists(custom_cmaps_filepath): - cp.read(custom_cmaps_filepath) - - SECTION = f"{self.name}.{cmapName}" - cp[SECTION] = {} - - # gradient_ticks = [] - state = self.gradient.saveState() - for key, value in state.items(): - if key != "ticks": - continue - for t, tick in enumerate(value): - pos, rgb = tick - # gradient_ticks.append((pos, rgb)) - rgb = ",".join([str(c) for c in rgb]) - val = f"{pos},{rgb}" - cp[SECTION][f"tick_{t}_pos_rgb"] = val - - with open(custom_cmaps_filepath, mode="w") as file: - cp.write(file) - - self.addCustomGradient(cmapName, state, restore=False) - - def tickColorAccepted(self): - self.gradient.currentColorAccepted() - # self.sigTickColorAccepted.emit(self.gradient.colorDialog.color().getRgb()) - - def setRescaleIntensitiesHow(self, how): - for action in self.rescaleActionGroup.actions(): - if action.text() == how: - action.setChecked(True) - return - - -class ROI(pg.ROI): - def __init__( - self, - pos, - size=pg.Point(1, 1), - angle=0, - invertible=False, - maxBounds=None, - snapSize=1, - scaleSnap=False, - translateSnap=False, - rotateSnap=False, - parent=None, - pen=None, - hoverPen=None, - handlePen=None, - handleHoverPen=None, - movable=True, - rotatable=True, - resizable=True, - removable=False, - aspectLocked=False, - ): - super().__init__( - pos, - size, - angle, - invertible, - maxBounds, - snapSize, - scaleSnap, - translateSnap, - rotateSnap, - parent, - pen, - hoverPen, - handlePen, - handleHoverPen, - movable, - rotatable, - resizable, - removable, - aspectLocked, - ) - - def slice(self, zRange=None, tRange=None): - x0, y0 = [int(round(c)) for c in self.pos()] - w, h = [int(round(c)) for c in self.size()] - xmin, xmax = x0, x0 + w - if xmin > xmax: - xmin, xmax = xmax, xmin - ymin, ymax = y0, y0 + h - if ymin > ymax: - ymin, ymax = ymax, ymin - if zRange is not None: - zmin, zmax = zRange - _slice = (slice(zmin, zmax), slice(ymin, ymax), slice(xmin, xmax)) - else: - _slice = (slice(ymin, ymax), slice(xmin, xmax)) - if tRange is not None: - tmin, tmax = tRange - _slice = (slice(tmin, tmax), *_slice) - return _slice - - def bbox(self): - x0, y0 = [int(round(c)) for c in self.pos()] - w, h = [int(round(c)) for c in self.size()] - xmin, xmax = x0, x0 + w - if xmin > xmax: - xmin, xmax = xmax, xmin - ymin, ymax = y0, y0 + h - if ymin > ymax: - ymin, ymax = ymax, ymin - - return ymin, xmin, ymax, xmax - - -class ZoomROI(ROI): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.viewRangesQueue = deque() - - def getLastRange(self): - xRange, yRange = self.viewRangesQueue.pop() - return xRange, yRange - - def storeLastRange(self, xRange, yRange): - self.viewRangesQueue.append((xRange, yRange)) - - -class DelROI(pg.ROI): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def clearPoints(self): - """ - Remove all handles and segments. - """ - while len(self.handles) > 0: - self.removeHandle(self.handles[0]["item"]) - - -class PlotCurveItem(pg.PlotCurveItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - - def addPoint(self, x, y, **kargs): - _xx, _yy = self.getData() - if _xx is None or len(_xx) == 0: - self.xData = np.array([x], dtype=int) - self.yData = np.array([y], dtype=int) - return - if _xx[-1] == x and _yy[-1] == y: - # Do not append same point - return - - # Pre-allocate array and insert data (faster than append) - xx = np.zeros(len(_xx) + 1, dtype=_xx.dtype) - xx[:-1] = _xx - xx[-1] = x - yy = np.zeros(len(_yy) + 1, dtype=_xx.dtype) - yy[:-1] = _yy - yy[-1] = y - self.setData(xx, yy, **kargs) - - def clear(self): - try: - self.setData([], []) - except Exception as e: - pass - super().clear() - - def closeCurve(self): - _xx, _yy = self.getData() - self.addPoint(_xx[0], _yy[0]) - - def mask(self): - ymin, xmin, ymax, xmax = self.bbox() - _mask = np.zeros((ymax - ymin + 1, xmax - xmin + 1), dtype=bool) - local_xx, local_yy = self.getLocalData() - rr, cc = skimage.draw.polygon(local_yy, local_xx) - _mask[rr, cc] = True - return _mask - - def getLocalData(self): - _xx, _yy = self.getData() - return _xx - _xx.min(), _yy - _yy.min() - - def slice(self, zRange=None, tRange=None): - ymin, xmin, ymax, xmax = self.bbox() - if zRange is not None: - zmin, zmax = zRange - _slice = (slice(zmin, zmax), slice(ymin, ymax + 1), slice(xmin, xmax + 1)) - else: - _slice = (slice(ymin, ymax + 1), slice(xmin, xmax + 1)) - if tRange is not None: - tmin, tmax = tRange - _slice = (slice(tmin, tmax), *_slice) - return _slice - - def bbox(self): - _xx, _yy = self.getData() - return _yy.min(), _xx.min(), _yy.max(), _xx.max() - - -class ToggleVisibilityButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setFlat(True) - # self.setCheckable(True) - self._state = False - self.setIcon(QIcon(":unchecked.svg")) - self.clicked.connect(self.onClicked) - self.setStyleSheet(""" - QPushButton::pressed { - background-color: none; - border-style: none; - } - """) - - def onClicked(self): - self._state = not self._state - if self._state: - self.setIcon(QIcon(":eye-checked.svg")) - else: - self.setIcon(QIcon(":unchecked.svg")) - - -class ToggleVisibilityCheckBox(QCheckBox): - def __init__(self, *args, pixelSize=24): - super().__init__(*args) - self._pixelSize = pixelSize - self.onToggled(False) - self.toggled.connect(self.onToggled) - - def setPixelSize(self, pixelSize): - self._pixelSize = pixelSize - - def onToggled(self, checked): - if checked: - self.setStyleSheet(f""" - QCheckBox::indicator {{ - width: {self._pixelSize}px; - height: {self._pixelSize}px; - }} - - QCheckBox::indicator:checked - {{ - image: url(:eye-checked.svg); - }} - """) - else: - self.setStyleSheet(f""" - QCheckBox::indicator {{ - width: {self._pixelSize}px; - height: {self._pixelSize}px; - }} - - QCheckBox::indicator:unchecked - {{ - image: url(:unchecked.svg); - }} - """) - - -class myHistogramLUTitem(baseHistogramLUTitem): - sigGradientMenuEvent = Signal(object) - sigGradientChanged = Signal(object) - sigTickColorAccepted = Signal(object) - sigAddScaleBar = Signal(bool) - sigAddTimestamp = Signal(bool) - - def __init__( - self, parent=None, name="image", axisLabel="", isViewer=False, **kwargs - ): - super().__init__(parent=parent, name=name, axisLabel=axisLabel, **kwargs) - - self.name = name - self._parent = parent - - self.childLutItem = None - - self.isViewer = isViewer - if isViewer: - # In the viewer we don't allow additional settings from the menu - return - - # Add scale bar action - self.addScaleBarAction = QAction("Add scale bar", self) - self.addScaleBarAction.setCheckable(True) - self.addScaleBarAction.triggered.connect(self.emitAddScaleBar) - self.gradient.menu.addAction(self.addScaleBarAction) - - # Add timestamp action - self.addTimestampAction = QAction("Add timestamp", self) - self.addTimestampAction.setCheckable(True) - self.addTimestampAction.triggered.connect(self.emitAddTimestamp) - self.gradient.menu.addAction(self.addTimestampAction) - - # Invert bw action - self.invertBwAction = QAction("Invert black/white", self) - self.invertBwAction.setCheckable(True) - self.gradient.menu.addAction(self.invertBwAction) - - # Font size menu action - self.fontSizeMenu = QMenu("Text font size") - self.gradient.menu.addMenu(self.fontSizeMenu) - - # Text color button - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Text color: ")) - self.textColorButton = myColorButton(color=(255, 255, 255)) - hbox.addStretch(1) - hbox.addWidget(self.textColorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.textColorButton.click) - self.gradient.menu.addAction(act) - - # Contours line weight - contLineWeightMenu = QMenu("Contours line weight", self.gradient.menu) - self.contLineWightActionGroup = QActionGroup(self) - self.contLineWightActionGroup.setExclusionPolicy( - QActionGroup.ExclusionPolicy.Exclusive - ) - for w in range(1, 11): - action = QAction(str(w)) - action.setCheckable(True) - if w == 2: - action.setChecked(True) - action.lineWeight = w - self.contLineWightActionGroup.addAction(action) - action = contLineWeightMenu.addAction(action) - self.gradient.menu.addMenu(contLineWeightMenu) - - # Contours color button - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Contours color: ")) - self.contoursColorButton = myColorButton(color=(25, 25, 25)) - hbox.addStretch(1) - hbox.addWidget(self.contoursColorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.contoursColorButton.click) - self.gradient.menu.addAction(act) - - # Mother-bud line weight - mothBudLineWeightMenu = QMenu("Mother-bud line weight", self.gradient.menu) - self.mothBudLineWightActionGroup = QActionGroup(self) - self.mothBudLineWightActionGroup.setExclusionPolicy( - QActionGroup.ExclusionPolicy.Exclusive - ) - for w in range(1, 11): - action = QAction(str(w)) - action.setCheckable(True) - if w == 2: - action.setChecked(True) - action.lineWeight = w - self.mothBudLineWightActionGroup.addAction(action) - action = mothBudLineWeightMenu.addAction(action) - self.gradient.menu.addMenu(mothBudLineWeightMenu) - - # Mother-bud line color - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Mother-bud line color: ")) - self.mothBudLineColorButton = myColorButton(color=(255, 0, 0)) - hbox.addStretch(1) - hbox.addWidget(self.mothBudLineColorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.mothBudLineColorButton.click) - self.gradient.menu.addAction(act) - - self.labelsAlphaMenu = self.gradient.menu.addMenu( - "Segm. masks overlay alpha..." - ) - # self.labelsAlphaMenu.setDisabled(True) - hbox = QHBoxLayout() - self.labelsAlphaSlider = sliderWithSpinBox( - title="Alpha", title_loc="in_line", isFloat=True, normalize=True - ) - self.labelsAlphaSlider.setMaximum(100) - self.labelsAlphaSlider.setSingleStep(0.05) - self.labelsAlphaSlider.setValue(0.3) - hbox.addWidget(self.labelsAlphaSlider) - shortCutText = "Command+Up/Down" if is_mac else "Ctrl+Up/Down" - hbox.addWidget(QLabel(f"({shortCutText})")) - widget = QWidget() - widget.setLayout(hbox) - act = QWidgetAction(self) - act.setDefaultWidget(widget) - self.labelsAlphaMenu.addSeparator() - self.labelsAlphaMenu.addAction(act) - - # Default settings - self.defaultSettingsAction = QAction("Restore default settings...", self) - self.gradient.menu.addAction(self.defaultSettingsAction) - - self.filterObject = FilterObject() - self.filterObject.sigFilteredEvent.connect(self.gradientMenuEventFilter) - self.gradient.menu.installEventFilter(self.filterObject) - self.highlightedAction = None - self.lastHoveredAction = None - - def setChildLutItem(self, childLutItem): - self.childLutItem = childLutItem - - def removeAddScaleBarAction(self): - self.gradient.menu.removeAction(self.addScaleBarAction) - - def removeAddTimestampAction(self): - self.gradient.menu.removeAction(self.addTimestampAction) - - def emitAddScaleBar(self): - self.sigAddScaleBar.emit(self.addScaleBarAction.isChecked()) - - def emitAddTimestamp(self): - self.sigAddTimestamp.emit(self.addTimestampAction.isChecked()) - - def gradientChanged(self): - super().gradientChanged() - self.sigGradientChanged.emit(self) - - def gradientMenuEventFilter(self, object, event): - if event.type() == QEvent.Type.MouseMove: - hoveredAction = self.gradient.menu.actionAt(event.pos()) - isActionEntered = hoveredAction != self.lastHoveredAction - if isActionEntered: - if isinstance(hoveredAction, highlightableQWidgetAction): - # print('Entered a custom action') - pass - isActionLeft = ( - self.highlightedAction is not None - and self.highlightedAction != hoveredAction - ) - if isActionLeft: - if isinstance(self.highlightedAction, highlightableQWidgetAction): - # print('Left a custom action') - pass - self.highlightedAction = hoveredAction - - self.lastHoveredAction = hoveredAction - - def addOverlayColorButton(self, rgbColor, channelName): - # Overlay color button - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Overlay color: ")) - self.overlayColorButton = myColorButton(color=rgbColor) - self.overlayColorButton.channel = channelName - hbox.addStretch(1) - hbox.addWidget(self.overlayColorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.overlayColorButton.click) - self.gradient.menu.addAction(act) - - def uncheckContLineWeightActions(self): - for act in self.contLineWightActionGroup.actions(): - try: - act.toggled.disconnect() - except Exception as e: - pass - act.setChecked(False) - - def uncheckMothBudLineLineWeightActions(self): - for act in self.mothBudLineWightActionGroup.actions(): - try: - act.toggled.disconnect() - except Exception as e: - pass - act.setChecked(False) - - def restoreState(self, df): - if "textIDsColor" in df.index: - rgbString = df.at["textIDsColor", "value"] - r, g, b = colors.rgb_str_to_values(rgbString) - self.textColorButton.setColor((r, g, b)) - - if "contLineColor" in df.index: - rgba_str = df.at["contLineColor", "value"] - rgb = colors.rgba_str_to_values(rgba_str)[:3] - self.contoursColorButton.setColor(rgb) - - if "contLineWeight" in df.index: - w = df.at["contLineWeight", "value"] - w = int(w) - for action in self.contLineWightActionGroup.actions(): - if action.lineWeight == w: - action.setChecked(True) - break - - if "mothBudLineWeight" in df.index: - w = df.at["mothBudLineWeight", "value"] - w = int(w) - for action in self.mothBudLineWightActionGroup.actions(): - if action.lineWeight == w: - action.setChecked(True) - break - - if "overlaySegmMasksAlpha" in df.index: - alpha = df.at["overlaySegmMasksAlpha", "value"] - self.labelsAlphaSlider.setValue(float(alpha)) - - if "mothBudLineColor" in df.index: - rgba_str = df.at["mothBudLineColor", "value"] - rgb = colors.rgba_str_to_values(rgba_str)[:3] - self.mothBudLineColorButton.setColor(rgb) - - checked = df.at["is_bw_inverted", "value"] == "Yes" - self.invertBwAction.setChecked(checked) - - self.restoreColormap(df) - - def saveState(self, df): - # remove previous state - df = df[~df.index.str.contains("img_cmap")].copy() - - state = self.gradient.saveState() - for key, value in state.items(): - if key == "ticks": - for t, tick in enumerate(value): - pos, rgb = tick - df.at[f"img_cmap_tick{t}_rgb", "value"] = rgb - df.at[f"img_cmap_tick{t}_pos", "value"] = pos - else: - if isinstance(value, bool): - value = "Yes" if value else "No" - df.at[f"img_cmap_{key}", "value"] = value - return df - - def restoreColormap(self, df): - state = {"mode": "rgb", "ticksVisible": True, "ticks": []} - ticks_pos = {} - ticks_rgb = {} - stateFound = False - for setting, value in df.itertuples(): - idx = setting.find("img_cmap_") - if idx == -1: - continue - - stateFound = True - m = re.findall(r"tick(\d+)_(\w+)", setting) - if m: - tick_idx, tick_type = m[0] - if tick_type == "pos": - ticks_pos[int(tick_idx)] = float(value) - elif tick_type == "rgb": - ticks_rgb[int(tick_idx)] = colors.rgba_str_to_values(value) - else: - key = setting[9:] - if value == "Yes": - value = True - elif value == "No": - value = False - state[key] = value - - if stateFound: - ticks = [(0, 0)] * len(ticks_pos) - for idx, val in ticks_pos.items(): - pos = val - rgb = ticks_rgb[idx] - ticks[idx] = (pos, rgb) - - state["ticks"] = ticks - self.gradient.restoreState(state) - - def regionChanged(self): - super().regionChanged() - if self.childLutItem is None: - return - - imageItem = self.imageItem() - try: - mn, mx = imageItem.quickMinMax(targetSize=65536) - # mn and mx can still be NaN if the data is all-NaN - if mn == mx or imageItem._xp.isnan(mn) or imageItem._xp.isnan(mx): - mn = 0 - mx = 255 - except AttributeError as err: - mn, mx = self.getLevels() - - self.childLutItem.setLevels(min=mn, max=mx) - - -class labelledQScrollbar(ScrollBar): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._label = None - - def setLabel(self, label): - self._label = label - - def updateLabel(self): - if self._label is not None: - position = self.sliderPosition() - s = self._label.text() - s = re.sub(r"(\d+)/(\d+)", rf"{position + 1:02}/\2", s) - self._label.setText(s) - - def setSliderPosition(self, position): - QScrollBar.setSliderPosition(self, position) - self.updateLabel() - - def setValue(self, value): - QScrollBar.setValue(self, value) - self.updateLabel() - - -class navigateQScrollBar(ScrollBar): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._disableCustomPressEvent = False - self.signal_slot_mapper = {} - - def disableCustomPressEvent(self): - self._disableCustomPressEvent = True - - def enableCustomPressEvent(self): - self._disableCustomPressEvent = False - - def setAbsoluteMaximum(self, absoluteMaximum): - self._absoluteMaximum = absoluteMaximum - - def absoluteMaximum(self): - return self._absoluteMaximum - - def mousePressEvent(self, event): - super().mousePressEvent(event) - if self.maximum() == self._absoluteMaximum: - return - - if self._disableCustomPressEvent: - return - - def setValueNoSignal(self, value): - for signal_name, slot in self.signal_slot_mapper.items(): - signal = getattr(self, signal_name) - try: - signal.disconnect() - except Exception as e: - pass - - self.setSliderPosition(value) - self.connectEvents(self.signal_slot_mapper) - - def connectEvents(self, signal_slot_mapper: dict): - self.signal_slot_mapper = signal_slot_mapper - for signal_name, slot in signal_slot_mapper.items(): - signal = getattr(self, signal_name) - try: - signal.disconnect() - except Exception as e: - pass - signal.connect(slot) - - -class linkedQScrollbar(ScrollBar): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._linkedScrollBar = None - - def linkScrollBar(self, scrollbar): - self._linkedScrollBar = scrollbar - scrollbar.setSliderPosition(self.sliderPosition()) - - def unlinkScrollBar(self): - self._linkedScrollBar = None - - def setSliderPosition(self, position): - QScrollBar.setSliderPosition(self, position) - if self._linkedScrollBar is not None: - self._linkedScrollBar.setSliderPosition(position) - - def setMaximum(self, max): - QScrollBar.setMaximum(self, max) - if self._linkedScrollBar is not None: - self._linkedScrollBar.setMaximum(max) - - -class myColorButton(pg.ColorButton): - def __init__(self, parent=None, color=(128, 128, 128), padding=5): - super().__init__(parent=parent, color=color) - if isinstance(padding, (int, float)): - self.padding = (padding, padding, -padding, -padding) - else: - self.padding = padding - self._c = 225 - self._hoverDeltaC = 30 - self._alpha = 100 - self._bkgrColor = QColor(self._c, self._c, self._c, self._alpha) - self._borderColor = QColor(171, 171, 171) - self._rectBorderPen = QPen(QBrush(QColor(0, 0, 0)), 0.3) - - def paintEvent(self, event): - # QPushButton.paintEvent(self, ev) - p = QStylePainter(self) - p.setRenderHint(QPainter.RenderHint.Antialiasing) - rect = self.rect() - p.setBrush(QBrush(self._bkgrColor)) - p.setPen(QPen(self._borderColor)) - p.drawRoundedRect(rect, 5, 5) - # p.fillRect(self.rect(), self._bkgrColor) - rect = self.rect().adjusted(*self.padding) - ## draw white base, then texture for indicating transparency, then actual color - p.setBrush(pg.mkBrush("w")) - p.drawRect(rect) - p.setBrush(QBrush(Qt.BrushStyle.DiagCrossPattern)) - p.drawRect(rect) - p.setPen(self._rectBorderPen) - p.setBrush(pg.mkBrush(self._color)) - p.drawRect(rect) - p.end() - - def enterEvent(self, event): - c = self._c + self._hoverDeltaC - self._bkgrColor = QColor(c, c, c, self._alpha) - self.update() - - def leaveEvent(self, event): - c = self._c - self._bkgrColor = QColor(c, c, c, self._alpha) - self.update() - - -class highlightableQWidgetAction(QWidgetAction): - def __init__(self, parent) -> None: - super().__init__(parent) - - -class overlayLabelsGradientWidget(pg.GradientWidget): - def __init__( - self, - imageItem, - selectActionGroup, - segmEndname, - parent=None, - orientation="right", - ): - pg.GradientWidget.__init__(self, parent=parent, orientation=orientation) - - self.imageItem = imageItem - self.selectActionGroup = selectActionGroup - - for action in self.menu.actions(): - if action.text() == "HSV": - HSV_action = action - elif action.text() == "RGB": - RGB_ation = action - self.menu.removeAction(HSV_action) - self.menu.removeAction(RGB_ation) - - # Shuffle colors action - self.shuffleCmapAction = QAction("Randomly shuffle colormap (Shift+S)", self) - self.menu.addAction(self.shuffleCmapAction) - - # Drawing mode - drawModeMenu = QMenu("Drawing mode", self) - self.drawModeActionGroup = QActionGroup(self) - contoursDrawModeAction = QAction("Draw contours", drawModeMenu) - contoursDrawModeAction.setCheckable(True) - contoursDrawModeAction.setChecked(True) - contoursDrawModeAction.segmEndname = segmEndname - self.drawModeActionGroup.addAction(contoursDrawModeAction) - drawModeMenu.addAction(contoursDrawModeAction) - olDrawModeAction = QAction("Overlay labels", drawModeMenu) - olDrawModeAction.setCheckable(True) - olDrawModeAction.segmEndname = segmEndname - self.drawModeActionGroup.addAction(olDrawModeAction) - drawModeMenu.addAction(olDrawModeAction) - self.menu.addMenu(drawModeMenu) - - self.labelsAlphaMenu = self.menu.addMenu("Overlay labels alpha...") - hbox = QHBoxLayout() - self.labelsAlphaSlider = sliderWithSpinBox( - title="Alpha", title_loc="in_line", isFloat=True, normalize=True - ) - self.labelsAlphaSlider.setMaximum(100) - self.labelsAlphaSlider.setSingleStep(0.05) - self.labelsAlphaSlider.setValue(0.3) - hbox.addWidget(self.labelsAlphaSlider) - widget = QWidget() - widget.setLayout(hbox) - act = QWidgetAction(self) - act.setDefaultWidget(widget) - self.labelsAlphaMenu.addSeparator() - self.labelsAlphaMenu.addAction(act) - - self.menu.addSeparator() - self.menu.addSection("Select segm. file to adjust:") - for action in selectActionGroup.actions(): - self.menu.addAction(action) - - self.item.loadPreset("viridis") - self.updateImageLut(None) - self.updateImageOpacity(0.3) - - # Connect events - self.sigGradientChangeFinished.connect(self.updateImageLut) - self.labelsAlphaSlider.valueChanged.connect(self.updateImageOpacity) - self.shuffleCmapAction.triggered.connect(self.shuffleCmap) - - def shuffleCmap(self): - lut = self.imageItem.lut - np.random.shuffle(lut) - lut[0] = [0, 0, 0, 0] - self.imageItem.setLookupTable(lut) - self.imageItem.update() - - def updateImageLut(self, gradientItem): - lut = np.zeros((255, 4), dtype=np.uint8) - lut[:, -1] = 255 - lut[:, :-1] = self.item.colorMap().getLookupTable(0, 1, 255) - np.random.shuffle(lut) - lut[0] = [0, 0, 0, 0] - self.imageItem.setLookupTable(lut) - self.imageItem.setLevels([0, 255]) - - def updateImageOpacity(self, value): - self.imageItem.setOpacity(value) - - -class labelsGradientWidget(pg.GradientWidget): - sigShowRightImgToggled = Signal(bool) - sigShowLabelsImgToggled = Signal(bool) - sigShowNextFrameToggled = Signal(bool) - - def __init__(self, *args, parent=None, orientation="right", **kargs): - pg.GradientEditorItem = BaseGradientEditorItemLabels - - pg.GradientWidget.__init__( - self, *args, parent=parent, orientation=orientation, **kargs - ) - - self._parent = parent - self.name = "labels" - - for action in self.menu.actions(): - if action.text() == "HSV": - HSV_action = action - elif action.text() == "RGB": - RGB_ation = action - self.menu.removeAction(HSV_action) - self.menu.removeAction(RGB_ation) - - # Add custom colormap action - self.customCmapsMenu = self.menu.addMenu("Custom colormaps") - self.customCmapsMenu.aboutToShow.connect(self.onShowCustomCmapsMenu) - self.customCmapsMenu.triggered.connect(self.customCmapsMenuTriggered) - - self.saveColormapAction = QAction("Save current colormap...", self) - self.menu.addAction(self.saveColormapAction) - self.saveColormapAction.triggered.connect(self.saveColormap) - - self.addCustomGradients() - - # Background color button - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Background color: ")) - self.colorButton = myColorButton(color=(25, 25, 25)) - hbox.addStretch(1) - hbox.addWidget(self.colorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.colorButton.click) - self.menu.addAction(act) - - # Font size menu action - self.fontSizeMenu = QMenu("Text font size", self) - self.menu.addMenu(self.fontSizeMenu) - - # IDs color button - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Text color: ")) - self.textColorButton = myColorButton(color=(25, 25, 25)) - hbox.addStretch(1) - hbox.addWidget(self.textColorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.textColorButton.click) - self.menu.addAction(act) - self.menu.addSeparator() - - # Shuffle colors action - self.shuffleCmapAction = QAction("Randomly shuffle colormap (Shift+S)", self) - self.menu.addAction(self.shuffleCmapAction) - - self.greedyShuffleCmapAction = QAction( - "Greedily shuffle colormap (Alt+Shift+S)", self - ) - self.menu.addAction(self.greedyShuffleCmapAction) - - self.permanentGreedyCmapAction = QAction("Always use greedy colormap", self) - self.permanentGreedyCmapAction.setCheckable(True) - self.menu.addAction(self.permanentGreedyCmapAction) - - # Invert bw action - self.invertBwAction = QAction("Invert black/white", self) - self.invertBwAction.setCheckable(True) - self.menu.addAction(self.invertBwAction) - - # Show labels action - self.showLabelsImgAction = QAction("Show segmentation image", self) - self.showLabelsImgAction.setCheckable(True) - self.menu.addAction(self.showLabelsImgAction) - - # Show right image action - self.showRightImgAction = QAction("Show duplicated left image", self) - self.showRightImgAction.setCheckable(True) - self.menu.addAction(self.showRightImgAction) - - # Show next frame action - self.showNextFrameAction = QAction("Show next frame", self) - self.showNextFrameAction.setCheckable(True) - self.menu.addAction(self.showNextFrameAction) - - # Default settings - self.defaultSettingsAction = QAction("Restore default settings...", self) - self.menu.addAction(self.defaultSettingsAction) - - self.menu.addSeparator() - - self.showRightImgAction.toggled.connect(self.showRightImageToggled) - self.showLabelsImgAction.toggled.connect(self.showLabelsImageToggled) - self.showNextFrameAction.toggled.connect(self.showNextFrameToggled) - - def onShowCustomCmapsMenu(self): - self.customCmapsMenu.show() - - def customCmapsMenuTriggered(self, action): - cmap = action.cmap - self.item.colorMapMenuClicked(cmap) - self.item.showTicks(True) - - def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): - currentState = self.item.saveState() - self.originalLength = self.item.length - self.item.length = 100 - if restore: - self.item.restoreState(gradient_ticks) - gradient = self.item.getGradient() - action = CustomGradientMenuAction(gradient, gradient_name, self.item) - # action.triggered.connect(self.item.contextMenuClicked) - action.delButton.clicked.connect(self.removeCustomGradient) - action.cmap = colors.pg_ticks_to_colormap(gradient_ticks["ticks"]) - # self.item.menu.insertAction(self.saveColormapAction, action) - self.customCmapsMenu.addAction(action) - self.item.length = self.originalLength - self.item.restoreState(currentState) - GradientsLabels[gradient_name] = gradient_ticks - - def removeCustomGradient(self): - button = self.sender() - action = button.action - self.customCmapsMenu.removeAction(action) - cp = config.ConfigParser() - cp.read(custom_cmaps_filepath) - cp.remove_section(f"labels.{action.name}") - with open(custom_cmaps_filepath, mode="w") as file: - cp.write(file) - - def addCustomGradients(self): - try: - CustomGradients = getCustomGradients(name="labels") - if not CustomGradients: - return - for gradient_name, gradient_ticks in CustomGradients.items(): - self.addCustomGradient(gradient_name, gradient_ticks) - except Exception as e: - printl(traceback.format_exc()) - pass - - def _askNameColormap(self): - inputWin = apps.QInput(parent=self._parent, title="Colormap name") - inputWin.askText("Insert a name for the colormap: ", allowEmpty=False) - if inputWin.cancel: - return - cmapName = inputWin.answer - return cmapName - - def saveColormap(self): - cmapName = self._askNameColormap() - if cmapName is None: - return - - cp = config.ConfigParser() - if os.path.exists(custom_cmaps_filepath): - cp.read(custom_cmaps_filepath) - - SECTION = f"{self.name}.{cmapName}" - cp[SECTION] = {} - - state = self.item.saveState() - for key, value in state.items(): - if key != "ticks": - continue - for t, tick in enumerate(value): - pos, rgb = tick - rgb = ",".join([str(c) for c in rgb]) - val = f"{pos},{rgb}" - cp[SECTION][f"tick_{t}_pos_rgb"] = val - - with open(custom_cmaps_filepath, mode="w") as file: - cp.write(file) - - self.addCustomGradient(cmapName, state, restore=False) - - def isRightImageVisible(self): - return ( - self.showLabelsImgAction.isChecked() or self.showNextFrameAction.isChecked() - ) - - def showRightImageToggled(self, checked): - if checked and self.isRightImageVisible(): - # Hide the right labels image before showing right image - self.showLabelsImgAction.setChecked(False) - self.showNextFrameAction.setChecked(False) - self.sigShowLabelsImgToggled.emit(False) - self.sigShowNextFrameToggled.emit(checked) - self.sigShowRightImgToggled.emit(checked) - - def showLabelsImageToggled(self, checked): - if checked and self.isRightImageVisible(): - # Hide the right image before showing labels image - self.showRightImgAction.setChecked(False) - self.showNextFrameAction.setChecked(False) - self.sigShowRightImgToggled.emit(False) - self.sigShowNextFrameToggled.emit(False) - self.sigShowLabelsImgToggled.emit(checked) - - def showNextFrameToggled(self, checked): - if checked and self.isRightImageVisible(): - # Hide the right image before showing labels image - self.showRightImgAction.setChecked(False) - self.showLabelsImgAction.setChecked(False) - self.sigShowRightImgToggled.emit(False) - self.sigShowLabelsImgToggled.emit(False) - self.sigShowNextFrameToggled.emit(checked) - - def saveState(self, df): - # remove previous state - df = df[~df.index.str.contains("lab_cmap")].copy() - - state = self.item.saveState() - for key, value in state.items(): - if key == "ticks": - for t, tick in enumerate(value): - pos, rgb = tick - df.at[f"lab_cmap_tick{t}_rgb", "value"] = rgb - df.at[f"lab_cmap_tick{t}_pos", "value"] = pos - else: - if isinstance(value, bool): - value = "Yes" if value else "No" - df.at[f"lab_cmap_{key}", "value"] = value - return df - - def restoreState(self, df, loadCmap=True): - # Insert background color - if "labels_bkgrColor" in df.index: - rgbString = df.at["labels_bkgrColor", "value"] - r, g, b = colors.rgb_str_to_values(rgbString) - self.colorButton.setColor((r, g, b)) - - if "labels_text_color" in df.index: - rgbString = df.at["labels_text_color", "value"] - r, g, b = colors.rgb_str_to_values(rgbString) - self.textColorButton.setColor((r, g, b)) - else: - self.textColorButton.setColor((255, 0, 0)) - - checked = df.at["is_bw_inverted", "value"] == "Yes" - self.invertBwAction.setChecked(checked) - - if not loadCmap: - return - - state = {"mode": "rgb", "ticksVisible": True, "ticks": []} - ticks_pos = {} - ticks_rgb = {} - stateFound = False - for setting, value in df.itertuples(): - idx = setting.find("lab_cmap_") - if idx == -1: - continue - - stateFound = True - m = re.findall(r"tick(\d+)_(\w+)", setting) - if m: - tick_idx, tick_type = m[0] - if tick_type == "pos": - ticks_pos[int(tick_idx)] = float(value) - elif tick_type == "rgb": - ticks_rgb[int(tick_idx)] = colors.rgba_str_to_values(value) - else: - key = setting[9:] - if value == "Yes": - value = True - elif value == "No": - value = False - state[key] = value - - if stateFound: - ticks = [(0, 0)] * len(ticks_pos) - for idx, val in ticks_pos.items(): - pos = val - rgb = ticks_rgb[idx] - ticks[idx] = (pos, rgb) - - state["ticks"] = ticks - self.item.restoreState(state) - else: - self.item.loadPreset("viridis") - - return stateFound - - def showMenu(self, ev): - try: - # Convert QPointF to QPoint - self.menu.popup(ev.screenPos().toPoint()) - except AttributeError: - self.menu.popup(ev.screenPos()) - - - - -class MainPlotItem(pg.PlotItem): - def __init__( - self, - parent=None, - name=None, - labels=None, - title=None, - viewBox=None, - axisItems=None, - enableMenu=True, - showWelcomeText=False, - **kargs, - ): - super().__init__( - parent, name, labels, title, viewBox, axisItems, enableMenu, **kargs - ) - # Overwrite zoom out button behaviour to disable autoRange after - # clicking it. - # If autorange is enabled, it is called everytime the brush or eraser - # scatter plot items touches the border causing flickering - self.disableAutoRange() - self.autoBtn.mode = "manual" - if showWelcomeText: - self.infoTextItem = pg.TextItem() - self.addItem(self.infoTextItem) - html_filepath = os.path.join(html_path, "gui_welcome.html") - with open(html_filepath) as html_file: - htmlText = html_file.read() - self.infoTextItem.setHtml(htmlText) - self.infoTextItem.setPos(0, 0) - - self.delRoiItems = {} - self.highlightingRectItems = None - self._baseImageItem = None - self._imageItems = [] - self.highlightingRectItemsColor = None - - def addHighlightingRectItems(self, color=None): - self.highlightingRectItems = { - "left": RectItem(QRectF()), - "right": RectItem(QRectF()), - "top": RectItem(QRectF()), - "bottom": RectItem(QRectF()), - } - for rect in self.highlightingRectItems.values(): - self.addItem(rect) - - if color is None: - return - - self.setHighlightingRectItemsColor(color) - - def setHighlightingRectItemsColor(self, color): - if color == self.highlightingRectItemsColor: - return - - for item in self.highlightingRectItems.values(): - item.setColor(color) - - self.highlightingRectItemsColor = color - - def addBaseImageItem(self, baseImageItem): - self._baseImageItem = baseImageItem - self._imageItems.append(baseImageItem) - self.addItem(baseImageItem) - - def addImageItem(self, imageItem): - self._imageItems.append(imageItem) - self.addItem(imageItem) - - def setHighlighted(self, highlighted, color=None): - if color is None: - color = self.highlightingRectItemsColor - - if color is None: - color = "green" - - if self.highlightingRectItems is None: - self.addHighlightingRectItems(color=color) - - if not highlighted: - for rect in self.highlightingRectItems.values(): - rect.setQRect(QRectF()) - return - - self.setHighlightingRectItemsColor(color) - - ((xmin, xmax), (ymin, ymax)) = self.viewRange() - xmin = xmin if xmin >= 0 else 0 - ymin = ymin if ymin >= 0 else 0 - if self._baseImageItem is not None: - Y, X = self._baseImageItem.image.shape[:2] - xmax = min(xmax, X) - ymax = min(ymax, Y) - - w = xmax - xmin - h = ymax - ymin - - bs = round(((w + h) / 2) * 0.02) - if bs < 1: - bs = 1 - - x0 = xmin - x1 = xmin + bs - x2 = xmax - bs - x3 = xmax - - y0 = ymin - y1 = ymin + bs - y2 = ymax - bs - y3 = ymax - - self.highlightingRectItems["left"].setRect(x0, y0, bs, y3 - y0) - self.highlightingRectItems["top"].setRect(x1, y0, x3 - x1, bs) - self.highlightingRectItems["right"].setRect(x2, y1, bs, y3 - y1) - self.highlightingRectItems["bottom"].setRect(x1, y2, x2 - x1, bs) - self.update() - - def clear(self): - super().clear() - - self.delRoiItems = {} - self.highlightingRectItems = None - self._baseImageItem = None - self._imageItems = [] - self.highlightingRectItemsColor = None - - try: - self.removeItem(self.infoTextItem) - except Exception as e: - pass - - def autoBtnClicked(self): - self.vb.autoRange() - self.autoBtn.hide() - - def addDelRoiItem(self, roiItem, key): - if self.isDelRoiItemPresent(roiItem): - return - - self.delRoiItems[key] = roiItem - roiItem.key = key - self.addItem(roiItem) - - def removeDelRoiItem(self, roiItem): - key = roiItem.key - self.delRoiItems.pop(key, None) - try: - self.removeItem(roiItem) - except Exception as err: - return - - def isDelRoiItemPresent(self, roiItem): - try: - key = roiItem.key - except AttributeError as e: - return False - - try: - roi = self.delRoiItems[key] - except Exception as err: - return False - - return True - - def viewRange(self, mask_img=None): - if mask_img is None: - return super().viewRange() - - mask_rp = skimage.measure.regionprops(skimage.measure.label(mask_img)) - if not mask_rp: - return super().viewRange() - - mask_obj = mask_rp[0] - ymin, xmin, ymax, xmax = mask_obj.bbox - return (xmin, xmax), (ymin, ymax) - - -class sliderWithSpinBox(QWidget): - sigValueChange = Signal(object) - valueChanged = Signal(object) - editingFinished = Signal() - - def __init__(self, *args, **kwargs): - super().__init__(*args) - - layout = QGridLayout() - - title = kwargs.get("title") - row = 0 - col = 0 - if title is not None: - titleLabel = QLabel(self) - titleLabel.setText(title) - loc = kwargs.get("title_loc", "top") - if loc == "top": - layout.addWidget(titleLabel, 0, col, alignment=Qt.AlignLeft) - elif loc == "in_line": - row = -1 - col = 1 - layout.addWidget(titleLabel, 0, 0, alignment=Qt.AlignLeft) - layout.setColumnStretch(0, 0) - - self._normalize = False - normalize = kwargs.get("normalize") - if normalize is not None and normalize: - self._normalize = True - self._isFloat = True - - self._isFloat = False - isFloat = kwargs.get("isFloat") - if isFloat is not None and isFloat: - self._isFloat = True - - self.slider = QSlider(Qt.Horizontal, self) - - if self._normalize or self._isFloat: - self.spinBox = DoubleSpinBox(self) - else: - self.spinBox = SpinBox(self) - self.spinBox.setAlignment(Qt.AlignCenter) - self.spinBox.setMaximum(2**31 - 1) - - maximum_on_label = kwargs.get("maximum_on_label") - spinbox_loc = kwargs.get("spinbox_loc", "right") - if spinbox_loc == "right": - spinbox_col = col + 1 - slider_col = col - if maximum_on_label is not None: - maximum_on_label_col = spinbox_col + 1 - elif spinbox_loc == "left": - spinbox_col = col - slider_col = col + 1 - if maximum_on_label is not None: - maximum_on_label_col = spinbox_col + 1 - slider_col += 1 - - if maximum_on_label is not None: - self.labelMaximum = QLabel() - layout.addWidget(self.labelMaximum, row + 1, maximum_on_label_col) - layout.addWidget(self.slider, row + 1, slider_col) - layout.addWidget(self.spinBox, row + 1, spinbox_col) - - if title is not None: - layout.setRowStretch(0, 1) - layout.setRowStretch(row + 1, 1) - layout.setColumnStretch(slider_col, 6) - layout.setColumnStretch(spinbox_col, 1) - - self._layout = layout - self.lastCol = col + 1 - self.sliderCol = slider_col - - self.slider.valueChanged.connect(self.sliderValueChanged) - self.slider.sliderReleased.connect(self.onEditingFinished) - self.spinBox.valueChanged.connect(self.spinboxValueChanged) - self.spinBox.editingFinished.connect(self.onEditingFinished) - - layout.setContentsMargins(5, 0, 5, 0) - - self.setLayout(layout) - - if maximum_on_label is not None: - self.setMaximum(maximum_on_label) - self.labelMaximum.setText(f"/{maximum_on_label}") - - def onEditingFinished(self): - self.editingFinished.emit() - - def maximum(self): - return self.slider.maximum() - - def minimum(self): - return self.slider.minimum() - - def setValue(self, value, emitSignal=False): - valueInt = value - if self._normalize: - valueInt = int(value * self.slider.maximum()) - elif self._isFloat: - valueInt = int(value) - - self.spinBox.valueChanged.disconnect() - self.spinBox.setValue(value) - self.spinBox.valueChanged.connect(self.spinboxValueChanged) - - self.slider.valueChanged.disconnect() - if valueInt > self.slider.maximum(): - self.slider.setMaximum(valueInt) - self.slider.setValue(valueInt) - self.slider.valueChanged.connect(self.sliderValueChanged) - - if emitSignal: - self.sigValueChange.emit(self.value()) - self.valueChanged.emit(self.value()) - - def setMaximum(self, max, including_spinbox=False): - self.slider.setMaximum(max) - if including_spinbox: - self.spinBox.setMaximum(max) - - def setSingleStep(self, step): - self.spinBox.setSingleStep(step) - - def setMinimum(self, min, including_spinbox=False): - self.slider.setMinimum(min) - if including_spinbox: - self.spinBox.setMinimum(min) - - def setSingleStep(self, step): - self.spinBox.setSingleStep(step) - - def setDecimals(self, decimals): - self.spinBox.setDecimals(decimals) - - def setTickPosition(self, position): - self.slider.setTickPosition(position) - - def setTickInterval(self, interval): - self.slider.setTickInterval(interval) - - def sliderValueChanged(self, val): - self.spinBox.valueChanged.disconnect() - if self._normalize: - valF = val / self.slider.maximum() - self.spinBox.setValue(valF) - else: - self.spinBox.setValue(val) - self.spinBox.valueChanged.connect(self.spinboxValueChanged) - self.sigValueChange.emit(self.value()) - self.valueChanged.emit(self.value()) - - def spinboxValueChanged(self, val): - if self._normalize: - val = int(val * self.slider.maximum()) - elif self._isFloat: - val = int(val) - - self.slider.valueChanged.disconnect() - self.slider.setValue(val) - self.slider.valueChanged.connect(self.sliderValueChanged) - self.sigValueChange.emit(self.value()) - self.valueChanged.emit(self.value()) - - def value(self): - return self.spinBox.value() - - def setDisabled(self, disabled) -> None: - self.slider.setDisabled(disabled) - self.spinBox.setDisabled(disabled) - - -class BaseImageItem(pg.ImageItem): - def __init__(self, image=None, **kargs): - self.minMaxValuesMapper = None - self.minMaxValuesMapperPreproc = None - self.minMaxValuesMapperCombined = None - self.minMaxValuesMapperEqualized = None - self.pos_i = 0 - self.z = 0 - self.frame_i = 0 - self.usePreprocessed = False - self.useEqualized = False - self.useCombined = False - self._isRgba = False - - super().__init__(image, **kargs) - self.autoLevelsEnabled = None - - def isRgba(self): - return self._isRgba - - def setEnableAutoLevels(self, enabled: bool): - self.autoLevelsEnabled = enabled - - def setImage(self, image=None, autoLevels=None, **kargs): - if autoLevels is None: - autoLevels = self.autoLevelsEnabled - - if image is not None and image.ndim == 3 and image.shape[2] in (3, 4): - self._isRgba = True - - super().setImage(image, autoLevels=autoLevels, **kargs) - - def preComputedMinMaxValues(self, data: List["load.loadData"]): - self.minMaxValuesMapper = {} - for pos_i, posData in enumerate(data): - img_data = posData.img_data - requires_time_dim = posData.img_data.ndim == 2 or ( - posData.img_data.ndim == 3 and posData.SizeZ > 1 - ) - if requires_time_dim: - img_data = (img_data,) - - for frame_i, image in enumerate(img_data): - if image.ndim == 3: - self._updateMinMaxValuesProjections( - image, pos_i, frame_i, self.minMaxValuesMapper - ) - - if image.ndim == 2: - image = (image,) - - for z, img in enumerate(image): - self.minMaxValuesMapper[(pos_i, frame_i, z)] = ( - np.nanmin(img), - np.nanmax(img), - ) - - def updateMinMaxValuesEqualizedData( - self, - data: List["load.loadData"], - pos_i: int, - frame_i: int, - z_slice: Union[int, str], - ): - if self.minMaxValuesMapperEqualized is None: - self.minMaxValuesMapperEqualized = {} - - posData = data[pos_i] - img = posData.equalized_img_data[frame_i][z_slice] - key = (pos_i, frame_i, z_slice) - self.minMaxValuesMapperEqualized[key] = (np.nanmin(img), np.nanmax(img)) - - def updateMinMaxValuesEqualizedDataProjections( - self, - data: List["load.loadData"], - pos_i: int, - frame_i: int, - ): - posData = data[pos_i] - eq_zstack = posData.equalized_img_data[frame_i] - - self._updateMinMaxValuesProjections( - eq_zstack, pos_i, frame_i, self.minMaxValuesMapperEqualized - ) - - def _updateMinMaxValuesProjections(self, zstack, pos_i, frame_i, mapper): - max_proj = zstack.max(axis=0) - key = (pos_i, frame_i, "max z-projection") - mapper[key] = np.nanmin(max_proj), np.nanmax(max_proj) - - mean_proj = zstack.mean(axis=0) - key = (pos_i, frame_i, "mean z-projection") - mapper[key] = np.nanmin(mean_proj), np.nanmax(mean_proj) - - median_proj = np.median(zstack, axis=0) - key = (pos_i, frame_i, "median z-proj.") - mapper[key] = np.nanmin(median_proj), np.nanmax(median_proj) - - def updateMinMaxValuesPreprocessedData( - self, - data: List["load.loadData"], - pos_i: int, - frame_i: int, - z_slice: Union[int, str], - ): - if self.minMaxValuesMapperPreproc is None: - self.minMaxValuesMapperPreproc = {} - - posData = data[pos_i] - img = posData.preproc_img_data[frame_i][z_slice] - key = (pos_i, frame_i, z_slice) - self.minMaxValuesMapperPreproc[key] = (np.nanmin(img), np.nanmax(img)) - - def updateMinMaxValuesPreprocessedProjections( - self, - data: List["load.loadData"], - pos_i: int, - frame_i: int, - ): - posData = data[pos_i] - zstack = posData.preproc_img_data[frame_i] - - self._updateMinMaxValuesProjections( - zstack, pos_i, frame_i, self.minMaxValuesMapperPreproc - ) - - def updateMinMaxValuesCombinedData( - self, - data: List["load.loadData"], - pos_i: int, - frame_i: int, - z_slice: Union[int, str], - ): - if self.minMaxValuesMapperCombined is None: - self.minMaxValuesMapperCombined = {} - - posData = data[pos_i] - img = posData.combine_img_data[frame_i][z_slice] - key = (pos_i, frame_i, z_slice) - self.minMaxValuesMapperCombined[key] = (np.nanmin(img), np.nanmax(img)) - - def updateMinMaxValuesCombinedDataProjections( - self, - data: List["load.loadData"], - pos_i: int, - frame_i: int, - ): - posData = data[pos_i] - zstack = posData.combine_img_data[frame_i] - - self._updateMinMaxValuesProjections( - zstack, pos_i, frame_i, self.minMaxValuesMapperCombined - ) - - def setCurrentPosIndex(self, pos_i: int): - self.pos_i = pos_i - - def setCurrentFrameIndex(self, frame_i: int): - self.frame_i = frame_i - - def setCurrentZsliceIndex(self, z: int): - self.z = z - - def quickMinMax(self, targetSize=1e6): - if self.isRgba(): - return super().quickMinMax(targetSize=targetSize) - - if self.usePreprocessed and self.minMaxValuesMapperPreproc is not None: - minMaxValuesMapper = self.minMaxValuesMapperPreproc - elif self.useCombined and self.minMaxValuesMapperCombined is not None: - minMaxValuesMapper = self.minMaxValuesMapperCombined - elif self.useEqualized and self.minMaxValuesMapperEqualized is not None: - minMaxValuesMapper = self.minMaxValuesMapperEqualized - else: - minMaxValuesMapper = self.minMaxValuesMapper - - if minMaxValuesMapper is None: - return super().quickMinMax(targetSize=targetSize) - - try: - key = (self.pos_i, self.frame_i, self.z) - levels = minMaxValuesMapper[key] - return levels - except Exception as err: - pass - - try: - key = (self.pos_i, self.frame_i, self.z) - levels = self.minMaxValuesMapper[key] - return levels - except Exception as err: - return super().quickMinMax(targetSize=targetSize) - - def setOpacity(self, value, **kwargs): - if value == 0: - value = 0.001 - - if value == 1: - value = 0.999 - - super().setOpacity(value) - - -class BaseLabelsImageItem(pg.ImageItem): - def __init__(self, image=None, **kargs): - super().__init__(image, **kargs) - - def setImage(self, image=None, **kwargs): - if image is None: - return - autoLevels = kwargs.get("autoLevels") - if autoLevels is None: - kwargs["autoLevels"] = False - super().setImage(image, **kwargs) - - -class OverlayImageItem(pg.ImageItem): - def __init__(self, image=None, **kargs): - super().__init__(image, **kargs) - self.autoLevelsEnabled = None - - def setEnableAutoLevels(self, enabled: bool): - self.autoLevelsEnabled = enabled - - def setImage(self, image=None, autoLevels=None, **kargs): - if autoLevels is None: - autoLevels = self.autoLevelsEnabled - - super().setImage(image, autoLevels=autoLevels, **kargs) - - def setOpacity(self, value, **kwargs): - if value == 0: - value = 0.001 - - if value == 1: - value = 0.999 - - super().setOpacity(value) - - -class ParentImageItem(BaseImageItem): - def __init__( - self, - image=None, - linkedImageItem=None, - activatingActions=None, - debug=False, - **kargs, - ): - super().__init__(image, **kargs) - self.linkedImageItem = linkedImageItem - self.activatingActions = activatingActions - self.debug = debug - self._forceDoNotUpdateLinked = False - self.autoLevelsEnabled = None - - def clear(self): - if self.linkedImageItem is not None: - self.linkedImageItem.clear() - return super().clear() - - def isLinkedImageItemActive(self): - if self._forceDoNotUpdateLinked: - return False - - if self.linkedImageItem is None: - return False - - if self.activatingActions is None: - return False - - for action in self.activatingActions: - if action.isChecked(): - return True - - return False - - def setEnableAutoLevels(self, enabled: bool): - self.autoLevelsEnabled = enabled - - def setUsePreprocessed(self, usePreprocessed): - self.usePreprocessed = usePreprocessed - if self.linkedImageItem is None: - return - - self.linkedImageItem.usePreprocessed = usePreprocessed - - def setUseCombined(self, useCombined): - self.useCombined = useCombined - if self.linkedImageItem is None: - return - - self.linkedImageItem.useCombined = useCombined - - def preComputedMinMaxValues(self, *args, **kwargs): - super().preComputedMinMaxValues(*args, **kwargs) - if self.linkedImageItem is None: - return - - self.linkedImageItem.minMaxValuesMapper = self.minMaxValuesMapper - - def updateMinMaxValuesPreprocessedData(self, *args, **kwargs): - super().updateMinMaxValuesPreprocessedData(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.minMaxValuesMapper = self.minMaxValuesMapper - - def updateMinMaxValuesCombinedData(self, *args, **kwargs): - super().updateMinMaxValuesCombinedData(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.minMaxValuesMapperCombined = ( - self.minMaxValuesMapperCombined - ) - - def updateMinMaxValuesCombinedDataProjections(self, *args, **kwargs): - super().updateMinMaxValuesCombinedDataProjections(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.minMaxValuesMapperCombined = ( - self.minMaxValuesMapperCombined - ) - - def updateMinMaxValuesEqualizedDataProjections(self, *args, **kwargs): - super().updateMinMaxValuesEqualizedDataProjections(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.minMaxValuesMapperEqualized = ( - self.minMaxValuesMapperEqualized - ) - - def updateMinMaxValuesEqualizedData(self, *args, **kwargs): - super().updateMinMaxValuesEqualizedData(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.minMaxValuesMapperEqualized = ( - self.minMaxValuesMapperEqualized - ) - - def setCurrentPosIndex(self, *args, **kwargs): - super().setCurrentPosIndex(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.pos_i = self.pos_i - - def setCurrentFrameIndex(self, *args, **kwargs): - super().setCurrentFrameIndex(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.frame_i = self.frame_i + 1 - - def setCurrentZsliceIndex(self, *args, **kwargs): - super().setCurrentZsliceIndex(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.z = self.z - - def setImage( - self, - image=None, - autoLevels=None, - next_frame_image=None, - scrollbar_value=None, - force_set_linked=False, - **kargs, - ): - if autoLevels is None: - autoLevels = self.autoLevelsEnabled - - super().setImage(image, autoLevels=autoLevels, **kargs) - - if self.linkedImageItem is None: - return - - if not self.isLinkedImageItemActive() and not force_set_linked: - return - - if next_frame_image is not None: - self.linkedImageItem.setImage( - next_frame_image, scrollbar_value=scrollbar_value, autoLevels=autoLevels - ) - elif image is not None: - self.linkedImageItem.setImage(image) - - def updateImage(self, *args, **kargs): - if self.isLinkedImageItemActive(): - self.linkedImageItem.image = self.image - self.linkedImageItem.updateImage(*args, **kargs) - return super().updateImage(*args, **kargs) - - def setOpacity(self, value, applyToLinked=True): - super().setOpacity(value) - if not applyToLinked: - return - - if self.linkedImageItem is None: - return - - self.linkedImageItem.setOpacity(value) - - def setLookupTable(self, lut): - super().setLookupTable(lut) - # if self.linkedImageItem is not None: - # self.linkedImageItem.setLookupTable(lut) - - -class ChildImageItem(BaseImageItem): - def __init__(self, *args, linkedScrollbar=None, **kwargs): - BaseImageItem.__init__(self, *args, **kwargs) - self.linkedScrollbar = linkedScrollbar - - def setImage(self, img=None, z=None, scrollbar_value=None, **kargs): - autoLevels = kargs.get("autoLevels") - if autoLevels is None: - kargs["autoLevels"] = False - - if img is None: - BaseImageItem.setImage(self, img, **kargs) - return - - if img.ndim == 3 and img.shape[-1] > 4 and z is not None: - BaseImageItem.setImage(self, img[z], **kargs) - else: - BaseImageItem.setImage(self, img, **kargs) - - if self.linkedScrollbar is None: - return - - if not self.linkedScrollbar.isEnabled(): - return - - if scrollbar_value is None: - return - - self.linkedScrollbar.setValueNoSignal(scrollbar_value) - - -class labImageItem(pg.ImageItem): - def __init__(self, *args, **kwargs): - pg.ImageItem.__init__(self, *args, **kwargs) - - def setImage(self, img=None, z=None, **kargs): - autoLevels = kargs.get("autoLevels") - if autoLevels is None: - kargs["autoLevels"] = False - - if img is None: - pg.ImageItem.setImage(self, img, **kargs) - return - - if img.ndim == 3 and img.shape[-1] > 4 and z is not None: - pg.ImageItem.setImage(self, img[z], **kargs) - else: - pg.ImageItem.setImage(self, img, **kargs) - - -class PostProcessSegmSlider(sliderWithSpinBox): - def __init__(self, *args, label=None, **kwargs): - super().__init__(*args, **kwargs) - - self.label = label - self.checkbox = QCheckBox("Disable") - self._layout.addWidget(self.checkbox, self.sliderCol, self.lastCol + 1) - self.checkbox.toggled.connect(self.onCheckBoxToggled) - self.valueChanged.connect(self.checkExpandRange) - - def onCheckBoxToggled(self, checked: bool) -> None: - super().setDisabled(checked) - if self.label is not None: - self.label.setDisabled(checked) - self.onValueChanged(None) - self.onEditingFinished() - - def onValueChanged(self, value): - self.valueChanged.emit(value) - - def checkExpandRange(self, value): - if value == self.maximum(): - range = int(self.maximum() - self.minimum()) - half_range = int(range / 2) - newMinimum = self.minimum() + half_range - newMaximum = self.maximum() + half_range - self.setMaximum(newMaximum) - self.setMinimum(newMinimum) - elif value == self.minimum(): - range = int(self.maximum() - self.minimum()) - half_range = int(range / 2) - newMinimum = self.minimum() - half_range - newMaximum = self.maximum() - half_range - self.setMaximum(newMaximum) - self.setMinimum(newMinimum) - - def onEditingFinished(self): - self.editingFinished.emit() - - def value(self): - if self.checkbox.isChecked(): - return None - else: - return super().value() - - -class GhostContourItem(pg.PlotDataItem): - def __init__( - self, ParentPlotItem, penColor=(245, 184, 0, 100), textColor=(245, 184, 0) - ): - super().__init__() - # Yellow pen - self.setPen(pg.mkPen(width=2, color=penColor)) - self.label = myLabelItem() - self.label.setAttr("bold", True) - self.label.setAttr("color", textColor) - self._ParentPlotItem = ParentPlotItem - - def addToPlotItem(self): - self._ParentPlotItem.addItem(self) - self._ParentPlotItem.addItem(self.label) - - def removeFromPlotItem(self): - self._ParentPlotItem.removeItem(self.label) - self._ParentPlotItem.removeItem(self) - - def setData( - self, xx=None, yy=None, fontSize=11, ID=0, y_cursor=None, x_cursor=None - ): - if xx is None: - xx = [] - if yy is None: - yy = [] - super().setData(xx, yy) - if not hasattr(self, "label"): - return - - if ID == 0: - self.label.setText("") - else: - self.label.setText(f"{ID}", size=fontSize) - w, h = self.label.itemRect().width(), self.label.itemRect().height() - self.label.setPos(x_cursor, y_cursor - h) - - def clear(self): - self.setData([], []) - - -class GhostMaskItem(pg.ImageItem): - def __init__(self, ParentPlotItem): - super().__init__() - self.label = myLabelItem() - self.label.setAttr("bold", True) - self.label.setAttr("color", (245, 184, 0)) - self._ParentPlotItem = ParentPlotItem - - def initImage(self, imgShape): - image = np.zeros(imgShape, dtype=np.uint32) - self.setImage(image) - - def initLookupTable(self, rgbaColor): - lut = np.zeros((2, 4), dtype=np.uint8) - lut[1, -1] = 255 - lut[1, :-1] = rgbaColor - self.setLookupTable(lut) - - def addToPlotItem(self): - self._ParentPlotItem.addItem(self) - self._ParentPlotItem.addItem(self.label) - - def removeFromPlotItem(self): - self._ParentPlotItem.removeItem(self.label) - self._ParentPlotItem.removeItem(self) - - def updateGhostImage(self, ID=0, y_cursor=None, x_cursor=None, fontSize=None): - self.setImage(self.image) - - if ID == 0: - self.label.setText("") - return - - self.label.setText(f"{ID}", size=fontSize) - w, h = self.label.itemRect().width(), self.label.itemRect().height() - self.label.item.setPos(x_cursor, y_cursor - h) - - def clear(self): - if hasattr(self, "label"): - self.label.setText("") - if self.image is None: - return - self.image[:] = 0 - self.setImage(self.image) - - -class PostProcessSegmSpinbox(QWidget): - valueChanged = Signal(int) - editingFinished = Signal() - sigCheckboxToggled = Signal() - - def __init__(self, *args, isFloat=False, label=None, **kwargs): - super().__init__(*args, **kwargs) - - layout = QHBoxLayout() - - if isFloat: - self.spinBox = DoubleSpinBox() - else: - self.spinBox = SpinBox() - - self.spinBox.valueChanged.connect(self.onValueChanged) - self.spinBox.editingFinished.connect(self.onEditingFinished) - - layout.addWidget(self.spinBox) - self.checkbox = QCheckBox("Disable") - layout.addWidget(self.checkbox) - layout.setStretch(0, 1) - layout.setStretch(1, 0) - - self.label = label - - self.checkbox.toggled.connect(self.onCheckBoxToggled) - - layout.setContentsMargins(5, 0, 5, 0) - - self.setLayout(layout) - - def onCheckBoxToggled(self, checked: bool) -> None: - self.spinBox.setDisabled(checked) - if self.label is not None: - self.label.setDisabled(checked) - self.onValueChanged(None) - self.onEditingFinished() - - def onValueChanged(self, value): - self.valueChanged.emit(value) - - def onEditingFinished(self): - self.editingFinished.emit() - - def maximum(self): - return self.spinBox.maximum() - - def setValue(self, value): - self.spinBox.setValue(value) - - def sizeHint(self): - return self.spinBox.sizeHint() - - def setMaximum(self, max): - self.spinBox.setMaximum(max) - - def setSingleStep(self, step): - self.spinBox.setSingleStep(step) - - def setMinimum(self, min): - self.spinBox.setMinimum(min) - - def setSingleStep(self, step): - self.spinBox.setSingleStep(step) - - def setDecimals(self, decimals): - self.spinBox.setDecimals(decimals) - - def value(self): - if self.checkbox.isChecked(): - return None - else: - return self.spinBox.value() - - -class CopiableCommandWidget(QGroupBox): - def __init__(self, command="", parent=None, font_size="13px"): - super().__init__(parent) - - layout = QHBoxLayout() - - label = QLabel(self) - self.label = label - self._font_size = font_size - self.setCommand(command, font_size=font_size) - label.setTextInteractionFlags( - Qt.TextBrowserInteraction | Qt.TextSelectableByKeyboard - ) - layout.addWidget(label) - layout.addWidget(QVLine(shadow="Plain", color="#4d4d4d")) - copyButton = copyPushButton("Copy", flat=True, hoverable=True) - copyButton.clicked.connect(self.copyToClipboard) - layout.addWidget(copyButton) - layout.addStretch(1) - - self.setLayout(layout) - - def setWordWrap(self, wordWrap): - self.label.setWordWrap(wordWrap) - - def copyToClipboard(self): - cb = QApplication.clipboard() - cb.clear(mode=cb.Clipboard) - cb.setText(self._command, mode=cb.Clipboard) - print("Command copied!") - - def setCommand(self, command, font_size=None): - if font_size is None: - font_size = self._font_size - - self._command = command - txt = html_utils.paragraph(f"{command}", font_size=font_size) - self.label.setText(txt) - - def command(self): - return self._command - - def text(self): - return self.label.text() - - def setTextInteractionFlags(self, flags): - self.label.setTextInteractionFlags(flags) - - -def PostProcessSegmWidget( - minimum, maximum, value, useSliders, isFloat=False, normalize=False, label=None -): - if useSliders: - if normalize: - maximum = int(maximum * 100) - widget = PostProcessSegmSlider( - normalize=normalize, isFloat=isFloat, label=label - ) - else: - widget = PostProcessSegmSpinbox(label=label, isFloat=isFloat) - widget.setMinimum(minimum) - widget.setMaximum(maximum) - widget.setValue(value) - return widget - - -# class Spinner(QLabel): -# def __init__(self, size=150, parent=None): -# super().__init__(parent) -# # layout = QHBoxLayout() - -# # self._label = QLabel() -# self.setAlignment(Qt.AlignCenter) -# # self._label.setText('Ciao') -# self._pixmap = QPixmap(':spinner.svg') - -# self._pixmapSize = size + size%2 -# self._halfPixmapSize = int(self._pixmapSize/2) -# printl(self._pixmapSize, self._halfPixmapSize) -# self.setPixmap(self._pixmap.scaled(self._pixmapSize, self._pixmapSize)) - -# # self.setFixedSize(160, 160) -# self._angle = 0 - -# blurEffect = QGraphicsBlurEffect() -# blurEffect.setBlurRadius(1.4) -# self.setGraphicsEffect(blurEffect) - -# # layout.addWidget(self._label) -# # self.setLayout(layout) - -# self.animation = QPropertyAnimation(self, b"angle", self) -# self.animation.setStartValue(0) -# self.animation.setEndValue(360) -# self.animation.setLoopCount(-1) -# self.animation.setDuration(1700) -# self.animation.start() - -# @Property(int) -# def angle(self): -# return self._angle - -# @angle.setter -# def angle(self, value): -# self._angle = value -# self.update() - -# def paintEvent(self, ev=None): -# width, height = self.size().width(), self.size().height() -# radius_x = int(width/2) -# radius_y = int(height/2) -# x = radius_x-self._halfPixmapSize -# y = radius_y-self._halfPixmapSize -# painter = QPainter(self) -# painter.setRenderHint(QPainter.Antialiasing) -# painter.translate(radius_x, radius_y) -# painter.rotate(self._angle) -# painter.translate(-radius_x, -radius_y) -# painter.drawPixmap(x, y, self._pixmap.scaled(self._pixmapSize, self._pixmapSize)) -# painter.end() - - - - - -class ScrollBarWithNumericControl(QWidget): - sigValueChanged = Signal(int) - sigMaxProjToggled = Signal(bool, object) - - def __init__( - self, - orientation=Qt.Horizontal, - add_max_proj_button=False, - parent=None, - labelText="", - ) -> None: - super().__init__(parent) - - self._slot = None - - layout = QHBoxLayout() - self.scrollbar = QScrollBar(orientation, self) - self.spinbox = QSpinBox(self) - self.maxLabel = QLabel(self) - idx = 0 - if labelText: - layout.addWidget(QLabel(labelText)) - layout.setStretch(idx, 0) - idx += 1 - - layout.addWidget(self.spinbox) - layout.setStretch(idx, 0) - idx += 1 - - layout.addWidget(self.maxLabel) - layout.setStretch(idx, 0) - idx += 1 - - layout.addWidget(self.scrollbar) - layout.setStretch(idx, 1) - idx += 1 - - if add_max_proj_button: - self.maxProjCheckbox = QCheckBox("MAX") - self.scrollbar.maxProjCheckbox = self.maxProjCheckbox - layout.addWidget(self.maxProjCheckbox) - layout.setStretch(idx, 0) - - layout.setContentsMargins(5, 0, 5, 0) - - self.setLayout(layout) - - self.spinbox.valueChanged.connect(self.spinboxValueChanged) - self.scrollbar.valueChanged.connect(self.scrollbarValueChanged) - - if add_max_proj_button: - self.maxProjCheckbox.toggled.connect(self.maxProjToggled) - - def connectValueChanged(self, slot): - self.sigValueChanged.connect(slot) - self._slot = slot - - def setValueNoSignal(self, value): - if self._slot is None: - return - self.sigValueChanged.disconnect() - self.setValue(value) - self.sigValueChanged.connect(self._slot) - - def maxProjToggled(self, checked): - self.scrollbar.setDisabled(checked) - self.sigMaxProjToggled.emit(checked, self) - - def showEvent(self, event) -> None: - super().showEvent(event) - - self.scrollbar.setMinimumHeight(self.spinbox.height()) - - def setMaximum(self, maximum): - self.maxLabel.setText(f"/{maximum}") - self.scrollbar.setMaximum(maximum) - self.spinbox.setMaximum(maximum) - - def setMinimum(self, minumum): - self.scrollbar.setMinimum(minumum) - self.spinbox.setMinimum(minumum) - - def spinboxValueChanged(self, value): - self.scrollbar.setValue(value) - - def scrollbarValueChanged(self, value): - self.spinbox.setValue(value) - self.sigValueChanged.emit(value) - - def setValue(self, value): - self.scrollbar.setValue(value) - - def value(self): - return self.scrollbar.value() - - def maximum(self): - return self.scrollbar.maximum() - - -class ImShowPlotItem(pg.PlotItem): - def __init__( - self, - parent=None, - name=None, - labels=None, - title=None, - viewBox=None, - axisItems=None, - enableMenu=True, - **kargs, - ): - super().__init__( - parent, name, labels, title, viewBox, axisItems, enableMenu, **kargs - ) - # Overwrite zoom out button behaviour to disable autoRange after - # clicking it. - # If autorange is enabled, it is called everytime the brush or eraser - # scatter plot items touches the border causing flickering - self.disableAutoRange() - self.autoBtn.mode = "manual" - self.invertY(True) - self.setAspectLocked(True) - self.addImageItem(kargs.get("imageItem")) - - self._selected = False - self.selectingRects = [] - - def setSelectableTitle(self, title: QGraphicsProxyWidget, **kwargs): - self.layout.removeItem(self.titleLabel) - self.layout.addItem(title, 0, 1, alignment=Qt.AlignCenter) - - def isSelected(self): - return self._selected - - def setSelected( - self, selected: bool, xlim=(-np.inf, np.inf), ylim=(-np.inf, np.inf) - ): - if selected == self._selected: - return - - if selected: - ((xmin, xmax), (ymin, ymax)) = self.viewRange() - ylim_min, ylim_max = ylim - xlim_min, xlim_max = xlim - - xmin = max(xlim_min, xmin) - xmax = min(xlim_max, xmax) - ymin = max(ylim_min, ymin) - ymax = min(ylim_max, ymax) - - w = xmax - xmin - h = ymax - ymin - - bs = round(((w + h) / 2) * 0.02) - if bs < 1: - bs = 1 - - rect_left = RectItem(QRectF(xmin, ymin, bs, h)) - rect_top = RectItem(QRectF(xmin + bs, ymin, w - bs - bs, bs)) - rect_right = RectItem(QRectF(xmax - bs, ymin, bs, h)) - rect_bottom = RectItem(QRectF(xmin + bs, ymax - bs, w - bs - bs, bs)) - self.selectingRects.append(rect_left) - self.selectingRects.append(rect_top) - self.selectingRects.append(rect_right) - self.selectingRects.append(rect_bottom) - - self.addItem(rect_left) - self.addItem(rect_top) - self.addItem(rect_right) - self.addItem(rect_bottom) - else: - for rect in self.selectingRects: - self.removeItem(rect) - self.selectingRects = [] - - self._selected = selected - - def addImageItem(self, imageItem): - self.imageItem = imageItem - if imageItem is None: - return - - self.setupContextMenu() - self.addItem(imageItem) - - def setupContextMenu(self): - shuffleCmapAction = QAction("Shuffle colormap", self.vb.menu) - shuffleCmapAction.triggered.connect(self.shuffleColormap) - self.vb.menu.addAction(shuffleCmapAction) - - self.resetCmapAction = QAction("Reset colormap", self.vb.menu) - self.resetCmapAction.triggered.connect(self.resetColormap) - self.vb.menu.addAction(self.resetCmapAction) - self.resetCmapAction.setDisabled(True) - - def shuffleColormap(self): - N = self.imageItem._numLevels - colors = self.imageItem.lut / 255 - cmap = LinearSegmentedColormap.from_list("shuffled", colors, N=N) - lut = plot.matplotlib_cmap_to_lut(cmap, n_colors=N) - if not self.resetCmapAction.isEnabled(): - self._defaultLut = lut.copy() - bkgrColor = lut[0].copy() - np.random.shuffle(lut) - lut[0] = bkgrColor - self.imageItem.setLookupTable(lut) - self.imageItem.update() - self.resetCmapAction.setDisabled(False) - - def resetColormap(self): - self.imageItem.setLookupTable(self._defaultLut) - - def autoBtnClicked(self): - self.autoRange() - - def autoRange(self): - self.vb.autoRange() - self.autoBtn.hide() - - -class _ImShowImageItem(pg.ImageItem): - sigDataHover = Signal(str) - sigHoverEvent = Signal(object, object) - sigMousePressEvent = Signal(object, object) - - def __init__(self, idx) -> None: - super().__init__() - self._idx = idx - self._cursors = [] - self._autoLevels = True - - def _getHoverImageValue(self, xdata, ydata): - try: - value = self.image[ydata, xdata] - return value - except Exception as err: - return - - def setAutoLevels(self, autoLevels): - self._autoLevels = autoLevels - - def mousePressEvent(self, event): - self.sigMousePressEvent.emit(self, event) - super().mousePressEvent(event) - - def setOtherImagesCursors(self, cursors): - self._cursors = cursors - - def clearCursors(self): - for p, cursor in enumerate(self._cursors): - if p == self._idx: - continue - - cursor.setData([], []) - - def setImage(self, *args, **kwargs): - if "autoLevels" not in kwargs: - kwargs["autoLevels"] = self._autoLevels - - super().setImage(*args, **kwargs) - if not args: - return - - if not kwargs["autoLevels"]: - return - - image = args[0] - self._imageMax = image.max() - self._imageMin = image.min() - self._numLevels = self._imageMax - self._imageMin - - def hoverEvent(self, event): - self.sigHoverEvent.emit(self, event) - - if event.isExit(): - self.clearCursors() - self.sigDataHover.emit("") - return - - x, y = event.pos() - xdata, ydata = int(x), int(y) - value = self._getHoverImageValue(xdata, ydata) - if value is None: - self.clearCursors() - self.sigDataHover.emit("") - return - - try: - self.sigDataHover.emit(f"x={xdata}, y={ydata}, {value = :.4f}") - except Exception as e: - self.sigDataHover.emit(f"x={xdata}, y={ydata}, {[val for val in value]}") - - for p, cursor in enumerate(self._cursors): - if p == self._idx: - continue - - cursor.setData([x], [y]) - - -class ImShow(QBaseWindow): - def __init__( - self, - parent=None, - link_scrollbars=True, - infer_rgb=True, - figure_title="", - selectable_images=False, - ): - super().__init__(parent=parent) - self._linkedScrollbars = link_scrollbars - self._infer_rgb = infer_rgb - self._figure_title = figure_title - self._selectable_images = True - self.selected_idx = None - - self._autoLevels = True - - self.textItems = [] - self.group_to_idx_mapper = {"": 0} - - def _getGraphicsScrollbar(self, idx, image, imageItem, maximum): - proxy = QGraphicsProxyWidget(imageItem) - scrollbar = ScrollBarWithNumericControl( - orientation=Qt.Horizontal, add_max_proj_button=True - ) - scrollbar.sigValueChanged.connect(self.OnScrollbarValueChanged) - scrollbar.sigMaxProjToggled.connect(self.onMaxProjToggled) - scrollbar.idx = idx - scrollbar.image = image - scrollbar.imageItem = imageItem - scrollbar.setMaximum(maximum) - proxy.setWidget(scrollbar) - proxy.scrollbar = scrollbar - return proxy - - def OnScrollbarValueChanged(self, value): - scrollbar = self.sender() - imageItem = scrollbar.imageItem - img = self._get2Dimg(imageItem, scrollbar.image) - imageItem.setImage(img) # , autoLevels=self._autoLevels) - - overlayLab = self._get2DlabOverlay(imageItem) - if overlayLab is not None: - imageItem.labImageItem.setImage(overlayLab, autoLevels=False) - - self.setPointsVisible(imageItem) - - self.updateIDs() - - if not self._linkedScrollbars: - return - if len(self.ImageItems) == 1: - return - - self._linkedScrollbars = False - try: - idx = scrollbar.idx - for otherImageItem in self.ImageItems: - if otherImageItem.gridPos == imageItem.gridPos: - continue - if otherImageItem.image.shape != imageItem.image.shape: - continue - for otherScrollbar in otherImageItem.ScrollBars: - if otherScrollbar.idx != idx: - continue - otherScrollbar.setValue(scrollbar.value()) - except Exception as e: - pass - finally: - self._linkedScrollbars = True - - def _get2Dimg(self, imageItem, image): - for scrollbar in imageItem.ScrollBars: - if scrollbar.maxProjCheckbox.isChecked(): - image = image.max(axis=0) - else: - image = image[scrollbar.value()] - return image - - def _get2DlabOverlay(self, imageItem): - try: - lab = imageItem.lab - except Exception as err: - return - - for scrollbar in imageItem.ScrollBars: - if scrollbar.maxProjCheckbox.isChecked(): - lab = lab.max(axis=0) - else: - lab = lab[scrollbar.value()] - - return lab - - def isObjVisible(self, obj, imageItem): - if len(obj.centroid) == 2: - return True - - z_scrollbar = imageItem.ScrollBars[-1] - if z_scrollbar.maxProjCheckbox.isChecked(): - return True - - z_slice = z_scrollbar.value() - min_z, min_y, min_x, max_z, max_y, max_x = obj.bbox - if z_slice >= min_z and z_slice < max_z: - return True - - return False - - def onMaxProjToggled(self, checked, scrollbar): - imageItem = scrollbar.imageItem - img = self._get2Dimg(imageItem, scrollbar.image) - imageItem.setImage(img) # , autoLevels=self._autoLevels) - overlayLab = self._get2DlabOverlay(imageItem) - if overlayLab is not None: - imageItem.labImageItem.setImage(overlayLab, autoLevels=False) - self.setPointsVisible(imageItem) - if not self._linkedScrollbars: - return - if len(self.ImageItems) == 1: - return - - self._linkedScrollbars = False - try: - idx = scrollbar.idx - for otherImageItem in self.ImageItems: - if otherImageItem.gridPos == imageItem.gridPos: - continue - if otherImageItem.image.shape != imageItem.image.shape: - continue - for otherScrollbar in otherImageItem.ScrollBars: - if otherScrollbar.idx != idx: - continue - otherScrollbar.maxProjCheckbox.setChecked(checked) - except Exception as e: - pass - finally: - self._linkedScrollbars = True - - self.updateIDs() - - def setPointsVisible(self, imageItem): - if not hasattr(imageItem, "pointsItems"): - return - - first_coord = imageItem.ScrollBars[0].value() - isMaxProj = imageItem.ScrollBars[0].maxProjCheckbox.isChecked() - for pointsItems in imageItem.pointsItems.values(): - for p, plotItem in enumerate(pointsItems): - plotItem.setVisible((isMaxProj) or (p == first_coord)) - - def setupStatusBar(self): - self.statusbar = self.statusBar() - self.wcLabel = QLabel(f"") - self.statusbar.addPermanentWidget(self.wcLabel) - - def setupMainLayout(self): - self._layout = QHBoxLayout() - self._container = QWidget() - self._container.setLayout(self._layout) - self.setCentralWidget(self._container) - - def setupGraphicLayout( - self, *images, hide_axes=True, max_ncols=4, color_scheme="light" - ): - self.graphicLayout = pg.GraphicsLayoutWidget() - self._colorScheme = color_scheme - - # Set a light background - if color_scheme == "light": - self.graphicLayout.setBackground((235, 235, 235)) - else: - self.graphicLayout.setBackground((30, 30, 30)) - - ncells = max_ncols * ceil(len(images) / max_ncols) - - nrows = ncells // max_ncols - nrows = nrows if nrows > 0 else 1 - ncols = max_ncols if len(images) > max_ncols else len(images) - - if color_scheme == "light": - color = "black" - else: - color = "white" - - self.titleLabel = pg.LabelItem(justify="center", color=color, size="14pt") - self.titleLabel.setText(self._figure_title) - self.graphicLayout.addItem(self.titleLabel, row=0, col=0, colspan=ncols) - start_row = 1 - - # Check if additional rows are needed for the scrollbars - max_ndim = max([image.ndim for image in images]) - if max_ndim > 4: - raise TypeError("One or more of the images have more than 4 dimensions.") - if max_ndim == 4: - rows_range = range(0, (nrows - 1) * 3 + 1, 3) - elif max_ndim == 3: - rows_range = range(0, (nrows - 1) * 2 + 1, 2) - else: - rows_range = range(nrows) - - self.PlotItems = [] - self.ImageItems = [] - self.ScrollBars = [] - i = 0 - for r in rows_range: - row = r + start_row - for col in range(ncols): - try: - image = images[i] - except IndexError: - break - plotItem = ImShowPlotItem() - if hide_axes: - plotItem.hideAxis("bottom") - plotItem.hideAxis("left") - self.graphicLayout.addItem(plotItem, row=row, col=col) - plotItem.loc = (row, col) - self.PlotItems.append(plotItem) - - imageItem = _ImShowImageItem(i) - plotItem.addImageItem(imageItem) - imageItem.plot = plotItem - imageItem.sigHoverEvent.connect(self.onImageItemHoverEvent) - imageItem.sigMousePressEvent.connect(self.onImageItemMousePressEvent) - self.ImageItems.append(imageItem) - imageItem.gridPos = (row, col) - imageItem.ScrollBars = [] - - is_rgb = image.shape[-1] == 3 and self._infer_rgb - is_rgba = image.shape[-1] == 4 and self._infer_rgb - does_not_require_scrollbars = image.ndim == 2 or ( - image.ndim == 3 and (is_rgb or is_rgba) - ) - if does_not_require_scrollbars: - i += 1 - continue - - idx_image = 3 if (is_rgb or is_rgba) else 2 - for s in range(image.ndim - idx_image): - maximum = image.shape[s] - 1 - scrollbarProxy = self._getGraphicsScrollbar( - s, image, imageItem, maximum - ) - self.graphicLayout.addItem(scrollbarProxy, row=row + s + 1, col=col) - imageItem.ScrollBars.append(scrollbarProxy.scrollbar) - - i += 1 - - self._layout.addWidget(self.graphicLayout) - - def onImageItemMousePressEvent(self, imageItem, event): - if not self._selectable_images: - return - - plotItem = imageItem.plot - if not plotItem.isSelected(): - return - - self.selected_idx = self.PlotItems.index(plotItem) - event.ignore() - self.close() - - def onImageItemHoverEvent(self, imageItem, event): - if not self._selectable_images: - return - - modifiers = QGuiApplication.keyboardModifiers() - isCtrl = modifiers == Qt.ControlModifier - plotItem = imageItem.plot - Y, X = imageItem.image.shape[:2] - plotItem.setSelected(isCtrl and not event.isExit(), xlim=(0, X), ylim=(0, Y)) - - def movePlotItem(self, title): - combobox = self.sender() - plotItem = combobox.plotItem - row, col = plotItem.loc - - otherPlotItemIdx = combobox.titles.index(title) - otherPlotItem = self.PlotItems[otherPlotItemIdx] - other_row, other_col = otherPlotItem.loc - - self.graphicLayout.removeItem(plotItem) - self.graphicLayout.removeItem(otherPlotItem) - self.graphicLayout.addItem(otherPlotItem, row=row, col=col) - self.graphicLayout.addItem(plotItem, row=other_row, col=other_col) - - combobox.blockSignals(True) - combobox.setCurrentText(combobox.default_text) - combobox.blockSignals(False) - - plotItemIdx = combobox.titles.index(combobox.default_text) - - otherPlotItem.loc = (row, col) - plotItem.loc = (other_row, other_col) - - def setupTitles(self, *titles): - for plotItem, title in zip(self.PlotItems, titles): - combobox = ComboBox() - combobox.default_text = title - combobox.titles = list(titles) - combobox.addItems(titles) - combobox.setMaximumWidth(combobox.sizeHint().width()) - combobox.setCurrentText(title) - comboboxGraphicsItem = QGraphicsProxyWidget() - comboboxGraphicsItem.setWidget(combobox) - combobox.plotItem = plotItem - plotItem.setSelectableTitle(comboboxGraphicsItem) - combobox.currentTextChanged.connect(self.movePlotItem) - - # color = 'k' if self._colorScheme == 'light' else 'w' - # for plotItem, title in zip(self.PlotItems, titles): - # plotItem.setSelectableTitle(title, color=color) - - def updateStatusBarLabel(self, text): - self.wcLabel.setText(text) - - def autoRange(self): - for plot in self.PlotItems: - plot.autoRange() - - def showImages( - self, - *images, - labels_overlays: np.ndarray | List[np.ndarray] = None, - luts=None, - labels_overlays_luts=None, - autoLevels=True, - autoLevelsOnScroll=False, - ): - from .plot import matplotlib_cmap_to_lut - - images = [np.squeeze(img) for img in images] - self.luts = luts - self._autoLevels = autoLevels - self._autoLevelsOnScroll = autoLevelsOnScroll - for image in images: - if image.ndim > 5 or image.ndim < 2: - raise TypeError( - f"Input image has {image.ndim} dimensions. " - "Only 2-D, 3-D, and 4-D images are supported" - ) - - if isinstance(labels_overlays, np.ndarray): - labels_overlays = [labels_overlays] - - if isinstance(labels_overlays_luts, np.ndarray): - labels_overlays_luts = [labels_overlays_luts] - - if ( - labels_overlays_luts is not None - and labels_overlays is not None - and (len(labels_overlays_luts) != len(labels_overlays)) - ): - raise TypeError( - f"Number of lables_overlays_luts is {len(labels_overlays_luts)}, " - f"while number of labels_overaly is {len(labels_overlays)}. " - "Pass `None` if you want to use default lut for the labels_overlays." - ) - - if labels_overlays is not None and (len(labels_overlays) != len(images)): - raise TypeError( - f"Number of images is {len(images)}, " - f"while number of labels_overaly is {len(labels_overlays)}. " - "Pass `None` if you do not need overlaid labeles." - ) - - for i, (image, imageItem) in enumerate(zip(images, self.ImageItems)): - if luts is not None: - _autoLevels = autoLevels - lut = luts[i] - if not autoLevels and lut is not None: - imageItem.setLevels([0, len(lut)]) - else: - _autoLevels = True - if lut is None: - lut = matplotlib_cmap_to_lut("viridis") - imageItem.setLookupTable(lut) - else: - _autoLevels = True - - is_rgb = image.shape[-1] == 3 and self._infer_rgb - is_rgba = image.shape[-1] == 4 and self._infer_rgb - does_not_require_scrollbars = image.ndim == 2 or ( - image.ndim == 3 and (is_rgb or is_rgba) - ) - - if does_not_require_scrollbars: - imageItem.setAutoLevels(_autoLevels) - imageItem.setImage(image) - else: - if not self._autoLevelsOnScroll and not _autoLevels: - imageItem.setAutoLevels(False) - imageItem.setLevels([image.min(), image.max()]) - for scrollbar in imageItem.ScrollBars: - scrollbar.setValue(int(scrollbar.maximum() / 2)) - - imageItem.sigDataHover.connect(self.updateStatusBarLabel) - - if labels_overlays is None: - continue - - lab_overlay = labels_overlays[i] - if lab_overlay is None: - continue - - if lab_overlay.shape != image.shape: - raise TypeError( - f"`lab_overlay` at index {i} has shape " - f"{lab_overlay.shape} which is different " - f"from image shape {image.shape}. " - "The image and the `lab_overlay` must " - "have the same shape." - ) - - plot = imageItem.plot - labImageItem = pg.ImageItem() - labImageItem.setOpacity(0.4) - plot.addImageItem(labImageItem) - - if labels_overlays_luts is not None: - labels_overlays_lut = labels_overlays_luts[i] - else: - labels_overlays_lut = self._getDefaultLabelsOverlayLut(lab_overlay) - - labImageItem.setLookupTable(labels_overlays_lut) - labImageItem.setLevels([0, len(labels_overlays_lut)]) - - imageItem.lab = lab_overlay - imageItem.labImageItem = labImageItem - - overlayLab = self._get2DlabOverlay(imageItem) - labImageItem.setImage(overlayLab, autoLevels=False) - - # Share axis between images with same X, Y shape - all_shapes = [image.shape[-2:] for image in images] - unique_shapes = set(all_shapes) - shame_shape_plots = [] - for unique_shape in unique_shapes: - plots = [ - self.PlotItems[i] - for i, shape in enumerate(all_shapes) - if shape == unique_shape - ] - shame_shape_plots.append(plots) - - for plots in shame_shape_plots: - for plot in plots: - plot.vb.setYLink(plots[0].vb) - plot.vb.setXLink(plots[0].vb) - - def _getDefaultLabelsOverlayLut(self, lab_overlay): - IDs = [obj.label for obj in skimage.measure.regionprops(lab_overlay)] - n_objs = len(IDs) - lut = np.zeros((n_objs + 1, 4), dtype=np.uint8) - rgbas = colors.plt_colormap_to_pg_lut("tab20", ncolors=n_objs) - np.random.shuffle(rgbas) - lut[1:] = rgbas - return lut - - def _createPointsScatterItem(self, xx, yy, group, colors=None, data=None): - if colors is None: - cmap = matplotlib.colormaps["jet_r"] - idx = self.group_to_idx_mapper[group] - r, g, b = [round(c * 255) for c in cmap(idx)][:3] - brush = pg.mkBrush(color=(r, g, b, 100)) - pen = pg.mkPen(width=2, color=(r, g, b)) - hoverBrush = pg.mkBrush((r, g, b, 200)) - else: - brush = [] - pen = [] - hoverBrush = None - for color in colors: - rgb = matplotlib.colors.to_rgb(color) - rgb = [round(c * 255) for c in rgb] - _brush = pg.mkBrush(color=(*rgb, 100)) - _pen = pg.mkPen(width=2, color=rgb) - brush.append(_brush) - pen.append(_pen) - - item = pg.ScatterPlotItem( - xx, - yy, - symbol="o", - pxMode=False, - size=3, - brush=brush, - pen=pen, - hoverable=True, - hoverBrush=hoverBrush, - data=data, - ) - return item - - def drawPointsFromDf( - self, points_df: pd.DataFrame | List[pd.DataFrame], points_groups=None - ): - if not isinstance(points_df, (list, tuple)): - points_df = [points_df] * len(self.PlotItems) - - for p, df in enumerate(points_df): - if isinstance(points_groups, str): - points_groups = [points_groups] - - if points_groups is None: - grouped = [("", df)] - groups = [""] - else: - grouped = df.groupby(points_groups) - groups = grouped.groups.keys() - - idxs_space = np.linspace(0, 1, len(groups)) - self.group_to_idx_mapper = dict(zip(groups, idxs_space)) - - for group, df in grouped: - yy = df["y"].values - xx = df["x"].values - points_coords = np.column_stack((yy, xx)) - if "z" in df.columns: - zz = df["z"].values - points_coords = np.column_stack((zz, points_coords)) - if len(group) == 1: - group = group[0] - - colors = None - if "color" in df.columns: - colors = df["color"].values - - data = None - if "data" in df.columns: - data = df["data"].values - - self.drawPoints( - points_coords, colors=colors, group=group, idx=p, data=data - ) - - def drawPoints( - self, - points_coords: np.ndarray, - group="", - idx=None, - colors=None, - data=None, - ): - offset = 0.5 if np.issubdtype(points_coords.dtype, np.integer) else 0 - n_dim = points_coords.shape[1] - - if idx is not None: - PlotItems = [self.PlotItems[idx]] - ImageItems = [self.ImageItems[idx]] - else: - PlotItems = self.PlotItems - ImageItems = self.ImageItems - - if n_dim == 2: - if data is None: - data = group - - zz = [0] * len(points_coords) - self.points_coords = np.column_stack((zz, points_coords)) - for p, plotItem in enumerate(PlotItems): - imageItem = ImageItems[p] - xx = points_coords[:, 1] + offset - yy = points_coords[:, 0] + offset - pointsItem = self._createPointsScatterItem( - xx, yy, group, data=data, colors=colors - ) - pointsItem.z = 0 - plotItem.addItem(pointsItem) - imageItem.pointsItems = {group: [pointsItem]} - elif n_dim == 3: - self.points_coords = points_coords - for p, plotItem in enumerate(PlotItems): - imageItem = ImageItems[p] - imageItem.pointsItems = defaultdict(list) - scrollbar = imageItem.ScrollBars[0] - for first_coord in range(scrollbar.maximum() + 1): - coords_idx = np.nonzero(points_coords[:, 0] == first_coord) - coords = points_coords[coords_idx] - if colors is None: - _colors = None - else: - _colors = np.asarray(colors)[coords_idx] - if len(_colors) == 0: - _colors = None - - _data = group - if data is not None: - _data = data[coords_idx] - if len(_data) == 0: - _data = group - - xx = coords[:, 2] + offset - yy = coords[:, 1] + offset - pointsItem = self._createPointsScatterItem( - xx, yy, group, data=_data, colors=_colors - ) - pointsItem.z = first_coord - plotItem.addItem(pointsItem) - pointsItem.setVisible(False) - imageItem.pointsItems[group].append(pointsItem) - self.setPointsVisible(imageItem) - - def setupDuplicatedCursors(self): - self.cursors = [] - for p, plotItem in enumerate(self.PlotItems): - cursor = pg.ScatterPlotItem( - symbol="+", - pxMode=True, - pen=pg.mkPen("k", width=1), - brush=pg.mkBrush("w"), - size=16, - tip=None, - ) - self.cursors.append(cursor) - plotItem.addItem(cursor) - - for imageItem in self.ImageItems: - imageItem.setOtherImagesCursors(self.cursors) - - def setPointsData(self, points_data): - points_df = pd.DataFrame( - { - "z": self.points_coords[:, 0], - "y": self.points_coords[:, 1], - "x": self.points_coords[:, 2], - } - ) - if isinstance(points_data, pd.Series): - points_df[points_data.name] = points_data.values - elif isinstance(points_data, pd.DataFrame): - points_df = points_df.join(points_data) - elif isinstance(points_data, np.ndarray): - if points_data.ndim == 1: - points_data = points_data[np.newaxis] - else: - points_data = points_data.T - for i, values in enumerate(points_data): - points_df[f"col_{i}"] = values - - self.points_df = points_df.set_index(["z", "y", "x"]).sort_index() - - for p, plotItem in enumerate(self.PlotItems): - imageItem = self.ImageItems[p] - for pointsItems in imageItem.pointsItems.values(): - for pointsItem in pointsItems: - pointsItem.sigClicked.connect(self.pointsClicked) - - def pointsClicked(self, item, points, event): - point = points[0] - x, y = point.pos() - coords = (item.z, int(y), int(x)) - point_data = self.points_df.loc[[coords]] - now = datetime.datetime.now().strftime("%H:%M:%S") - print("*" * 60) - print(f"Point clicked at {now}. Data:") - print("-" * 60) - print(point_data) - print("") - print("*" * 60) - - def annotateObjectIDs(self, annotate_labels_idxs=None, init=False): - if init: - self.annotate_labels_idxs = annotate_labels_idxs - self.textItems = [{} for _ in self.PlotItems] - if self.annotate_labels_idxs is None: - return - for i, plotItem in enumerate(self.PlotItems): - if i not in self.annotate_labels_idxs: - continue - plotTextItems = self.textItems[i] - imageItem = self.ImageItems[i] - try: - if init: - # 3D labels (if 3D) - lab = imageItem.lab - else: - lab = imageItem.labImageItem.image - except Exception as err: - lab = imageItem.image - - rp = skimage.measure.regionprops(lab) - for obj in rp: - textItem = plotTextItems.get(obj.label) - yc, xc = obj.centroid[-2:] - if textItem is None: - textItem = pg.TextItem(text="", anchor=(0.5, 0.5), color="r") - plotItem.addItem(textItem) - plotTextItems[obj.label] = textItem - - if self.isObjVisible(obj, imageItem): - text = str(obj.label) - else: - text = "" - - textItem.setText(text) - textItem.setPos(xc, yc) - - # plotItem.enableAutoRange() - - def clearLabels(self): - for textItems in self.textItems: - for textItem in textItems.values(): - textItem.setText("") - - def updateIDs(self): - self.clearLabels() - try: - self.annotateObjectIDs(annotate_labels_idxs=self.annotate_labels_idxs) - except Exception as err: - pass - - def show(self, block=False, screenToWindowRatio=None): - super().show(block=block) - if screenToWindowRatio is None: - return - screenGeometry = self.screen().geometry() - screenWidth = screenGeometry.width() - screenHeight = screenGeometry.height() - finalWidth = int(screenToWindowRatio * screenWidth) - finalHeight = int(screenToWindowRatio * screenHeight) - screenTop = screenGeometry.top() - screenLeft = screenGeometry.left() - xc, yc = screenLeft + screenWidth / 2, screenTop + screenHeight / 2 - winLeft = int(xc - finalWidth / 2) - winTop = int(yc - finalHeight / 2) - self.setGeometry(winLeft, winTop, finalWidth, finalHeight) - - def run(self, block=False, showMaximised=False, screenToWindowRatio=None): - if showMaximised: - self.showMaximized() - else: - self.show(screenToWindowRatio=screenToWindowRatio) - QTimer.singleShot(100, self.autoRange) - - if block: - self.exec_() - - def resizeEvent(self, event) -> None: - self.PlotItems[0].autoRange() - return super().resizeEvent(event) - - -class FeatureSelectorButton(QPushButton): - def __init__(self, text, parent=None, alignment=""): - super().__init__(text, parent=parent) - self._isFeatureSet = False - self._alignment = alignment - self.setCursor(Qt.PointingHandCursor) - - def setFeatureText(self, text): - self.setText(text) - self.setFlat(True) - self._isFeatureSet = True - if self._alignment: - self.setStyleSheet(f"text-align:{self._alignment};") - - def enterEvent(self, event) -> None: - if self._isFeatureSet: - self.setFlat(False) - return super().enterEvent(event) - - def leaveEvent(self, event) -> None: - if self._isFeatureSet: - self.setFlat(True) - self.update() - return super().leaveEvent(event) - - def setSizeLongestText(self, longestText): - currentText = self.text() - self.setText(longestText) - w, h = self.sizeHint().width(), self.sizeHint().height() - self.setMinimumWidth(w + 10) - # self.setMinimumHeight(h+5) - self.setText(currentText) - - -class CheckableSpinBoxWidgets: - def __init__(self, isFloat=True): - if isFloat: - self.spinbox = FloatLineEdit() - else: - self.spinbox = SpinBox() - self.checkbox = QCheckBox("Activate") - self.spinbox.setEnabled(False) - self.checkbox.toggled.connect(self.spinbox.setEnabled) - - def value(self): - if not self.checkbox.isChecked(): - return - return self.spinbox.value() - - -class Label(QLabel): - def __init__(self, parent=None, force_html=False): - super().__init__(parent) - self._force_html = force_html - - def setText(self, text): - if self._force_html: - text = html_utils.paragraph(text) - super().setText(text) - - -class LabelItem(pg.LabelItem): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def bbox(self): - xl, yl = self.pos().x(), self.pos().y() - wl, hl = self.itemRect().width(), self.itemRect().height() - return yl, xl, yl + hl, xl + wl - - def setBold(self, bold=True): - self.origPos = self.pos() - self.setText(self.text, bold=bold) - self.setPos(self.origPos) - - -class ScaleBar(QGraphicsObject): - sigEditProperties = Signal(object) - sigRemove = Signal(object) - - def __init__(self, imageShape, viewRange, parent=None): - super().__init__(parent) - self.SizeY, self.SizeX = imageShape - self.updateViewRange(viewRange) - self.plotItem = PlotCurveItem() - self.labelItem = LabelItem() - self._x_pad = 5 - self._y_pad = 3 - self._highlighted = False - self._parent = parent - self.clicked = False - self.createContextMenu() - - def updateViewRange(self, viewRange): - xRange, yRange = viewRange - x0, x1 = xRange - y0, y1 = yRange - if x0 < 0: - x0 = 0 - - if x1 > self.SizeX: - x1 = self.SizeX - - if y0 < 0: - y0 = 0 - - if y1 > self.SizeY: - y1 = self.SizeY - - self.xmax = x1 - self.xmin = x0 - - self.ymax = y1 - self.ymin = y0 - - def createContextMenu(self): - self.contextMenu = QMenu() - action = QAction("Edit properties...", self.contextMenu) - action.triggered.connect(self.emitEditProperties) - self.contextMenu.addSeparator() - action = QAction("Remove", self.contextMenu) - action.triggered.connect(self.emitRemove) - self.contextMenu.addAction(action) - - def emitEditProperties(self): - self.setHighlighted(False) - self.sigEditProperties.emit(self.properties()) - - def emitRemove(self): - self.sigRemove.emit(self) - - def isHighlighted(self): - return self._highlighted - - def setHighlighted(self, highlighted): - if self._highlighted and highlighted: - return - - if not self._highlighted and not highlighted: - return - - pen = self.highlightPen if highlighted else self.pen - self.labelItem.setBold(bold=highlighted) - self.plotItem.setPen(pen) - - self._highlighted = highlighted - - def showContextMenu(self, x, y): - self.contextMenu.popup(QPoint(int(x), int(y))) - - def properties(self): - properties = { - "thickness": self._thickness, - "length_pixel": self._length, - "length_unit": self._length_unit, - "is_text_visible": self._is_text_visible, - "color": self._color, - "loc": self._loc, - "font_size": float(self._font_size[:-2]), - "unit": self._unit, - "num_decimals": self._num_decimals, - "move_with_zoom": self._move_with_zoom, - } - return properties - - def move(self, xm, ym): - self._loc = "Custom" - - Dy = ym - self.yc - Dx = xm - self.xc - - x0 = self.x0c + Dx - x1 = x0 + self._length - y0 = y1 = self.y0c + Dy - self.plotItem.setData([x0, x1], [y0, y1]) - self.setTextPos() - - def paint(self, painter, option, widget): - pass - - def boundingRect(self): - ymin, xmin, ymax, xmax = self.bbox() - return QRectF(xmin, ymin, xmax - xmin, ymax - ymin) - - def setLocationProperty(self, loc: str): - self._loc = loc - - def setMoveWithZoomProperty(self, move_with_zoom): - self._move_with_zoom = move_with_zoom - - def setProperties( - self, - length_pixel, - length_unit, - thickness=3, - color="w", - is_text_visible=True, - loc="top-left", - font_size=12, - unit="", - num_decimals=0, - move_with_zoom=False, - ): - self._loc = loc - self._color = color - self._length = length_pixel - self._length_unit = length_unit - self._is_text_visible = is_text_visible - self._font_size = f"{font_size}px" - self._unit = unit - self._num_decimals = num_decimals - self._move_with_zoom = move_with_zoom - self._thickness = thickness - self.pen = pg.mkPen(width=thickness, color=color, cosmetic=False) - self.highlightPen = pg.mkPen(width=thickness + 2, color=color, cosmetic=False) - self.pen.setCapStyle(Qt.PenCapStyle.FlatCap) - self.highlightPen.setCapStyle(Qt.PenCapStyle.FlatCap) - self.plotItem.setPen(self.pen) - - def updatePhysicalLength(self, PhysicalSizeX): - length_unit = self._length_unit - unit = self._unit - length_um = _core.convert_length(length_unit, unit, "μm") - length_pixel = length_um / PhysicalSizeX - self._length = length_pixel - self.update() - - def addToAxis(self, ax): - ax.addItem(self.plotItem) - ax.addItem(self.labelItem) - - def setText(self): - if self._is_text_visible: - number = round(self._length_unit, self._num_decimals) - if self._num_decimals == 0: - number = int(number) - text = f"{number} {self._unit}" - else: - text = "" - self.labelItem.setText(text, color=self._color, size=self._font_size) - - def setTextPos(self): - xx, yy = self.plotItem.getData() - x0 = xx[0] - y0 = yy[0] - xc = x0 + self._length / 2 - wl = self.labelItem.itemRect().width() - hl = self.labelItem.itemRect().height() - xl = xc - wl / 2 - yt = y0 - hl - self.labelItem.setPos(xl, yt) - - def updatePosViewRangeChanged(self, viewRange): - if self._loc == "custom": - xx, yy = self.plotItem.getData() - x0p = xx[0] - y0p = yy[0] - xcp = x0p + self._length / 2 - hl = self.labelItem.itemRect().height() - ycp = y0p - hl / 2 - x0 = self.xmin - y0 = self.ymin - x_range = self.xmax - x0 - y_range = self.ymax - y0 - Dx_perc = (xcp - x0) / x_range - Dy_perc = (ycp - y0) / y_range - - self.updateViewRange(viewRange) - - X0 = self.xmin - Y0 = self.ymin - - X_range = self.xmax - X0 - Y_range = self.ymax - Y0 - - Xcp = X0 + (Dx_perc * X_range) - Ycp = Y0 + (Dy_perc * Y_range) - X0p = Xcp - (self._length / 2) - Y0p = Ycp + (hl / 2) - - X1p = X0p + self._length - Y1p = Y0p - - self.plotItem.setData([X0p, X1p], [Y0p, Y1p]) - else: - self.updateViewRange(viewRange) - self.update() - - def getStartXCoordFromLoc(self, loc): - if loc == "custom": - xx, yy = self.plotItem.getData() - x0 = xx[0] - return x0 - - self.setText() - wl = self.labelItem.itemRect().width() - if loc.find("left") != -1: - x0 = self._x_pad + self.xmin - xc = x0 + self._length / 2 - xl = xc - wl / 2 - if xl < x0: - # Text is larger than line --> move line to the right - x0 = self._x_pad + abs(xl - self._x_pad) - else: - x0 = self.xmax - self._length - self._x_pad - xc = x0 + self._length / 2 - x1 = x0 + self._length - xr = xc + wl / 2 - if xr > x1: - # Text is larger than line --> move line to the left - delta_overshoot = xr - x1 - x0 = x0 - delta_overshoot - return x0 - - def getStartYCoordFromLoc(self, loc): - if loc == "custom": - xx, yy = self.plotItem.getData() - y0 = yy[0] - return y0 - - self.setText() - textHeight = self.labelItem.itemRect().height() - if loc.find("top") != -1: - return textHeight + self._y_pad + self.ymin - else: - return self.ymax - self._y_pad - self._thickness - - def update(self): - x0 = self.getStartXCoordFromLoc(self._loc) # + self._thickness/2 - y0 = self.getStartYCoordFromLoc(self._loc) - - x1 = x0 + self._length # - self._thickness/2 - self.plotItem.setData([x0, x1], [y0, y0]) - - self.setText() - self.setTextPos() - - def draw(self, length_pixel, length_unit, **kwargs): - self.setProperties(length_pixel, length_unit, **kwargs) - self.update() - - def bbox(self): - y_line_min, x_line_min, y_line_max, x_line_max = self.plotItem.bbox() - y_lab_min, x_lab_min, y_lab_max, x_lab_max = self.labelItem.bbox() - ymin = min(y_line_min, y_lab_min) - xmin = min(x_line_min, x_lab_min) - ymax = max(y_line_max, y_lab_max) - xmax = max(x_line_max, x_lab_max) - return ymin, xmin, ymax, xmax - - def mousePressed(self, x, y): - self.clicked = True - self.xc, self.yc = x, y - xx, yy = self.plotItem.getData() - self.x0c = xx[0] - self.y0c = yy[0] - - def removeFromAxis(self, ax): - ax.removeItem(self.labelItem) - ax.removeItem(self.plotItem) - - -class ComboBox(QComboBox): - sigTextChanged = Signal(str) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._previousText = None - self._valueChanged = False - self.currentTextChanged.connect(self.emitTextChanged) - self.installEventFilter(self) - - def eventFilter(self, object, event) -> bool: - if object == self and event.type() == QEvent.Type.Wheel: - # Forward event to parent so QScrollArea can scroll - QApplication.sendEvent(self.parent(), event) - return True # Consume for the combo itself - - return super().eventFilter(object, event) - - def text(self): - return self.currentText() - - def emitTextChanged(self, text): - self._valueChanged = True - self.sigTextChanged.emit(text) - - def mousePressEvent(self, event): - self._previousText = self.currentText() - super().mousePressEvent(event) - - def previousText(self): - return self._previousText - - def addItems(self, items): - super().addItems(items) - self._previousText = items[0] - - def itemsText(self): - return [self.itemText(i) for i in range(self.count())] - - def setCurrentIndex(self, idx): - itemsText = self.itemsText() - currentText = itemsText[idx] - self._valueChanged = currentText != self._previousText - self._previousText = self.currentText() - super().setCurrentIndex(idx) - - def setCurrentText(self, text): - currentText = text - self._valueChanged = currentText != self._previousText - self._previousText = self.currentText() - super().setCurrentText(text) - - -class SetMeasurementsGroupBox(QGroupBox): - def __init__( - self, - title, - itemsText, - checkable=True, - itemsInfo=None, - lastSelection=None, - itemsInfoUrls=None, - parent=None, - ): - super().__init__(parent) - - if itemsInfo is None: - itemsInfo = {} - - if itemsInfo is None: - itemsInfoUrls = {} - - highlightRgba = _palettes._highlight_rgba() - r, g, b, a = highlightRgba - self._highlightStylesheetColor = f"rgb({r}, {g}, {b})" - - self.setTitle(title) - self.setCheckable(checkable) - - mainLayout = QVBoxLayout() - - scrollArea = QScrollArea() - scrollArea.setWidgetResizable(True) - scrollAreaLayout = QVBoxLayout() - scrollAreaWidget = QWidget() - self.scrollAreaWidget = scrollAreaWidget - self.scrollAreaLayout = scrollAreaLayout - - self.checkboxes = {} - for text in itemsText: - rowLayout = QHBoxLayout() - infoText = itemsInfo.get(text) - infoUrl = itemsInfoUrls.get(text) - if infoText is not None or infoUrl is not None: - infoButton = infoPushButton() - infoButton.setCursor(Qt.WhatsThisCursor) - rowLayout.addWidget(infoButton) - - if infoText is not None: - infoButton.itemText = text - infoButton.infoText = infoText - infoButton.clicked.connect(self.showInfo) - - if infoUrl is not None: - infoButton.itemText = text - infoButton.infoUrl = infoUrl - infoButton.clicked.connect(self.openInfoUrl) - - checkbox = QCheckBox(text) - checkbox.setParent(self.scrollAreaWidget) - checkbox.setChecked(True) - rowLayout.addWidget(checkbox) - rowLayout.addStretch(1) - - self.checkboxes[text] = checkbox - - scrollAreaLayout.addLayout(rowLayout) - - scrollAreaLayout.addStretch(1) - - scrollAreaWidget.setLayout(scrollAreaLayout) - scrollArea.setWidget(scrollAreaWidget) - self.scrollArea = scrollArea - - buttonsLayout = QHBoxLayout() - self.selectAllButton = selectAllPushButton() - self.selectAllButton.sigClicked.connect(self.setCheckedAll) - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(self.selectAllButton) - self.buttonsLayout = buttonsLayout - - if lastSelection is not None: - self.lastSelection = lastSelection - self.loadLastSelButton = reloadPushButton(" Load last selection... ") - self.loadLastSelButton.clicked.connect(self.loadLastSelection) - buttonsLayout.addWidget(self.loadLastSelButton) - - mainLayout.addWidget(scrollArea) - mainLayout.addSpacing(10) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def openInfoUrl(self): - url = self.sender().infoUrl - QDesktopServices.openUrl(QUrl(url)) - # import webbrowser - # url = self.sender().infoUrl - # webbrowser.open(url) - - def getWidthNoScrollBarNeeded(self): - width = ( - self.scrollArea.verticalScrollBar().sizeHint().width() - # self.scrollAreaLayout.contentsRect().width() - + self.scrollAreaWidget.sizeHint().width() - + 30 - ) - buttonsWidth = 0 - for i in range(self.buttonsLayout.count()): - widget = self.buttonsLayout.itemAt(i).widget() - if not isinstance(widget, QPushButton): - continue - buttonsWidth += widget.sizeHint().width() + 16 - largerWidth = max(width, buttonsWidth) - return largerWidth - - def resizeWidthNoScrollBarNeeded(self): - width = self.getWidthNoScrollBarNeeded() - self.setMinimumWidth(width) - # self.setFixedWidth(width) - - def loadLastSelection(self): - for text, checkbox in self.checkboxes.items(): - checked = self.lastSelection.get(text, False) - checkbox.setChecked(checked) - - def showInfo(self): - infoText = self.sender().infoText - itemText = self.sender().itemText - - title = f"{itemText} description" - msg = myMessageBox() - msg.setWidth(int(self.screen().size().width() / 2)) - msg.information(self, title, infoText) - - def setCheckedAll(self, button, checked): - for checkbox in self.checkboxes.values(): - checkbox.setChecked(checked) - - def highlightCheckboxesFromSearchText(self, text): - for checkbox in self.checkboxes.values(): - if not text: - highlighted = False - else: - highlighted = checkbox.text().lower().find(text.lower()) != -1 - - self.setCheckboxHighlighted(highlighted, checkbox) - - def setCheckboxHighlighted(self, highlighted, checkbox): - if highlighted: - checkbox.setStyleSheet( - f"background: {self._highlightStylesheetColor}; color: black" - ) - self.scrollArea.ensureWidgetVisible(checkbox) - else: - checkbox.setStyleSheet("") - - -class SearchLineEdit(QLineEdit): - def __init__(self, parent=None): - super().__init__(parent) - - self.initSearch() - self.setFocusPolicy(Qt.ClickFocus) - - def focusInEvent(self, event) -> None: - super().focusInEvent(event) - if super().text() == "Search...": - self.setText("") - self.setStyleSheet("") - - def focusOutEvent(self, event) -> None: - super().focusOutEvent(event) - if not super().text(): - self.initSearch() - - def initSearch(self): - self.setText("Search...") - self.setStyleSheet("color: rgb(150, 150, 150)") - self.clearFocus() - - def text(self): - if super().text() == "Search...": - return "" - return super().text() - - -class ToolButtonTextIcon(rightClickToolButton): - def __init__(self, text="", parent=None): - super().__init__(parent=parent) - self._text = text - self._penColor = _palettes.text_pen_color() - - def setText(self, text): - self._text = text - self.update() - - def text(self): - return self._text - - def paintEvent(self, event): - QToolButton.paintEvent(self, event) - p = QPainter(self) - - pen = pg.mkPen(color=self._penColor, width=2) - p.setPen(pen) - - w, h = self.width(), self.height() - sf = 0.7 - rect_w = w * sf - rect_h = h * sf - x = (w - rect_w) / 2 - y = (h - rect_h) / 2 - rect = QRectF(x, y, rect_w, rect_h) - - font = p.font() - font.setBold(True) - font.setPixelSize(int(h / len(self._text))) - p.setFont(font) - - p.drawText(rect, Qt.AlignCenter, self._text) - p.end() - - -class RulerPlotItem(pg.PlotDataItem): - def __init__(self, *args, **kwargs): - self.labelItem = pg.LabelItem() - super().__init__(*args, **kwargs) - - def setData(self, *args, lengthText="", **kwargs): - super().setData(*args, **kwargs) - self.labelItem.setText("") - if not lengthText: - return - self.setLengthText(lengthText) - - def setLengthText(self, lengthText): - xx, yy = self.getData() - x0, x1 = sorted(xx) - y0, y1 = sorted(yy) - xc = round(x0 + (x1 - x0) / 2) - yc = round(y0 + (y1 - y0) / 2) - self.labelItem.setText(lengthText, size="11px", color="r") - # xc = x0 + self._length/2 - wl = self.labelItem.itemRect().width() - hl = self.labelItem.itemRect().height() - xl = xc - wl / 2 - yt = y0 - hl - self.labelItem.setPos(xl, yt) - - -class VectorLineEdit(QLineEdit): - valueChanged = Signal(object) - valueChangeFinished = Signal(object) - - def __init__(self, parent=None, initial=None): - super().__init__(parent) - - self._minimum = -np.inf - - float_re = float_regex() - vector_regex = rf"\(?\[?{float_re}(,\s?{float_re})+\)?\]?" - regex = rf"^{vector_regex}$|^{float_re}$" - self.validRegex = regex - - regExp = QRegularExpression(regex) - self.setValidator(QRegularExpressionValidator(regExp)) - self.setAlignment(Qt.AlignCenter) - - self.textChanged.connect(self.emitValueChanged) - self.editingFinished.connect(self.emitValueChangeFinished) - if initial is None: - self.setText("0.0") - - font = QFont() - font.setPixelSize(11) - self.setFont(font) - - def emitValueChangeFinished(self): - value = self.value() - self.textChanged.disconnect() - self.editingFinished.disconnect() - self.setValue(value) - self.textChanged.connect(self.emitValueChanged) - self.editingFinished.connect(self.emitValueChangeFinished) - - self.emitValueChanged(self.text(), signal=self.valueChangeFinished) - - def emitValueChanged(self, text, signal=None): - m = re.match(self.validRegex, text) - if m is None: - self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) - return - - if signal is None: - signal = self.valueChanged - - self.setStyleSheet("") - signal.emit(self.value()) - - def increaseValue(self, step): - value = self.value() - if isinstance(value, (float, int)): - value += step - else: - value = [val + step for val in value] - value = str(value).lstrip("[").rstrip("]") - self.setValue(value) - self.emitValueChangeFinished() - - def decreaseValue(self, step): - value = self.value() - if isinstance(value, (float, int)): - value -= step - else: - value = [val - step for val in value] - value = str(value).lstrip("[").rstrip("]") - self.setText(value) - self.emitValueChangeFinished() - - def setValue(self, value): - if isinstance(value, (float, int)): - if value < self._minimum: - value = self._minimum - else: - clipped = [] - for val in value: - if val < self._minimum: - val = self._minimum - clipped.append(val) - value = str(clipped).lstrip("[").rstrip("]") - self.setText(value) - - def setText(self, text): - super().setText(str(text)) - - def clipValue(self, val: float): - if val < self._minimum: - val = self._minimum - return val - - def value(self): - m = re.match(self.validRegex, self.text()) - if m is None: - return 0.0 - - try: - value = self.clipValue(float(self.text())) - return value - except Exception as e: - text = self.text() - text = text.replace("(", "") - text = text.replace(")", "") - text = text.replace("[", "") - text = text.replace("]", "") - values = text.split(",") - return [self.clipValue(float(value)) for value in values] - - def setMinimum(self, minimum): - self._minimum = float(minimum) - - -class LatexLabel(QLabel): - def __init__(self, latexText, parent=None): - super().__init__(parent) - - latexText = latexText.replace("", "$") - if not latexText.startswith("$"): - latexText = f"${latexText}" - - if not latexText.endswith("$"): - latexText = f"{latexText}$" - - latexText = latexText.replace("
    ", "\n") - - pixmap = self.mathTex_to_QPixmap(latexText) - self.setPixmap(pixmap) - - def mathTex_to_QPixmap(self, mathTex): - # ---- set up a mpl figure instance ---- - - fig = matplotlib.figure.Figure() - fig.patch.set_facecolor("none") - fig.set_canvas(FigureCanvasAgg(fig)) - renderer = fig.canvas.get_renderer() - - # ---- plot the mathTex expression ---- - - ax = fig.add_axes([0, 0, 1, 1]) - ax.axis("off") - ax.patch.set_facecolor("none") - t = ax.text( - 0, 0, mathTex, ha="left", va="bottom", fontsize=13, color=TEXT_COLOR - ) - - # ---- fit figure size to text artist ---- - - fwidth, fheight = fig.get_size_inches() - fig_bbox = fig.get_window_extent(renderer) - - text_bbox = t.get_window_extent(renderer) - - tight_fwidth = text_bbox.width * fwidth / fig_bbox.width - tight_fheight = text_bbox.height * fheight / fig_bbox.height - - fig.set_size_inches(tight_fwidth, tight_fheight) - - # ---- convert mpl figure to QPixmap ---- - - buf, size = fig.canvas.print_to_buffer() - qimage = QImage.rgbSwapped(QImage(buf, size[0], size[1], QImage.Format_ARGB32)) - qpixmap = QPixmap(qimage) - - return qpixmap - - -class LabelsWidget(QWidget): - def __init__(self, texts, wrapText=False, parent=None): - super().__init__(parent=parent) - - layout = QVBoxLayout() - - texts = self.fixParagraphTags(texts) - - self.textLengths = [] - self.labels = [] - for t, text in enumerate(texts): - if not text: - continue - - if text.startswith(""): - layout.addSpacing(10) - label = LatexLabel(text) - layout.addWidget(label, alignment=Qt.AlignCenter) - try: - # Add spacing only if next text is not a formula - nextText = texts[t + 1] - if not nextText.startswith(""): - layout.addSpacing(10) - except IndexError: - layout.addSpacing(10) - elif text.startswith(""): - text = text.removeprefix("").removeprefix("") - label = CopiableCommandWidget(command=text, parent=self) - layout.addWidget(label) - else: - label = QLabel(text) - label.setWordWrap(wrapText) - label.setOpenExternalLinks(True) - layout.addWidget(label) - if wrapText: - self.textLengths.append(1) - self.textLengths.extend([len(line) for line in text.split("
    ")]) - - self.labels.append(label) - - self.nCharsLongestLine = max(self.textLengths, default=1) - - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - - def setWordWrap(self, wordWrap): - for label in self.labels: - label.setWordWrap(wordWrap) - - def fixParagraphTags(self, texts): - firstText = texts[0] - if firstText.find("

    ', firstText) - if searched is None: - openTag = '

    ' - else: - openTag = searched.group() - - not_allowed = {" ", "\n"} - - fixedTexts = [] - for text in texts: - if text.startswith("") or text.startswith(""): - fixedTexts.append(text) - continue - - if set(text) <= not_allowed: - # Ignore texts that are made of only \n and spaces - continue - - if text.find("

    ") == -1: - text = rf"{text}<\p>" - - if text.find(openTag) == -1: - text = f"{openTag}{text}" - - text = text.replace("\n", "") - - fixedTexts.append(text) - return fixedTexts - - -class SwitchPlaneCombobox(QComboBox): - sigPlaneChanged = Signal(str, str) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.addItems(["xy", "zy", "zx"]) - self._previousPlane = "xy" - self.currentTextChanged.connect(self.emitPlaneChanged) - - def emitPlaneChanged(self, plane): - self.sigPlaneChanged.emit(self._previousPlane, plane) - self._previousPlane = plane - - def setPlane(self, plane): - self.setCurrentText(plane) - - def setCurrentText(self, text): - self._previousPlane = self.plane() - super().setCurrentText(text) - - def plane(self): - return self.currentText() - - def depthAxes(self): - plane = self.plane() - for axes in "xyz": - if axes not in plane: - return axes - - -class SamInputPointsWidget(QWidget): - sigValueChanged = Signal(str) - - def __init__(self, parent=None): - super().__init__(parent) - - _layout = QHBoxLayout() - - self.lineEntry = ElidingLineEdit(parent=self) - self.lineEntry.setAlignment(Qt.AlignCenter) - self.lineEntry.editingFinished.connect(self.emitValueChanged) - - self.editButton = editPushButton() - self.browseButton = browseFileButton( - ext={"CSV": ".csv"}, start_dir=myutils.getMostRecentPath() - ) - - _layout.addWidget(self.lineEntry) - _layout.addWidget(self.editButton) - _layout.addWidget(self.browseButton) - - _layout.setStretch(0, 1) - _layout.setStretch(1, 0) - _layout.setStretch(1, 0) - - self.browseButton.sigPathSelected.connect(self.browseCsvFiles) - self.editButton.clicked.connect(self.showInfoEditPoints) - - _layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(_layout) - - def emitValueChanged(self, text): - self.sigValueChanged.emit(text) - - def showInfoEditPoints(self): - note = html_utils.to_note( - "When adding points with the mouse left button you will create a " - "new object for each point. To add multiple points for the same " - "object click the right button." - ) - txt = html_utils.paragraph(f""" - To add input points for Segment Anything open the GUI (module 3), - load the data, and then click on the button
    - on the top toolbar called Add points layer.

    - Select the option "Add points by clicking" and click on the image - to add points.

    - Finally, save the table and browse to the saved file on this widget. -
    {note} - """) - msg = myMessageBox(wrapText=False) - msg.information(self, "Info edit points", txt) - - def criticalMissingColumn(self, filepath, missing_col): - txt = html_utils.paragraph(f""" - [ERROR]: The selected table does not contain the column - {missing_col}.

    - A valid table must contain the columns (x, y, id) - with an additional z column for 3D z-stacks data. - """) - msg = myMessageBox(wrapText=False) - msg.critical(self, "Invalid table", txt) - - def setValue(self, value: str): - self.lineEntry.setText(value) - - def value(self): - return self.lineEntry.text() - - def cast_dtype(self, value) -> str: - return str(value) - - def browseCsvFiles(self, filepath): - # Check if metadata.csv file exists with basename and set only the - # endname of the file - df_points = pd.read_csv(filepath) - for col in ("x", "y", "id"): - if col not in df_points.columns: - self.criticalMissingColumn(filepath, col) - return - - # Check if basename is present in metadata - folderpath = os.path.dirname(filepath) - basename = None - for file in myutils.listdir(folderpath): - if file.endswith("metadata.csv"): - metadata_csv_path = os.path.join(folderpath, file) - df = pd.read_csv(metadata_csv_path, index_col="Description") - try: - basename = df.at["basename", "values"] - except Exception as e: - basename = None - break - - # Check if file is inside images folder and get basename - is_images_folder = folderpath.endswith("Images") - if is_images_folder: - images_path = folderpath - img_filepath = None - for file in myutils.listdir(images_path): - if file.endswith(".tif"): - img_filepath = os.path.join(images_path, file) - break - - if file.endswith("aligned.npz"): - img_filepath = os.path.join(images_path, file) - break - - if img_filepath is not None: - posData = load.loadData(img_filepath, "", QParent=self) - posData.getBasenameAndChNames() - filename = os.path.basename(filepath) - if filename.startswith(posData.basename): - basename = posData.basename - - if basename is None: - self.lineEntry.setText(filepath) - else: - filename = os.path.basename(filepath) - endname = filename[len(basename) :] - self.lineEntry.setText(endname) - - -class PointsScatterPlotItem(pg.ScatterPlotItem): - sigHoverEntered = Signal(object, object, object) - - def __init__(self, *args, ax=None, show_data_as_tip=False, **kwargs): - self.textItem = annotate.TextAnnotationsScatterItem(size=12, anchor=(1.0, 1.0)) - self.textItem.createSymbols( - [str(int_id) for int_id in range(200)], includeBold=False - ) - # self._textItems = {} - super().__init__(*args, **kwargs) - self.textItem.setParentItem(self) - self._font = QFont() - self._font.setPixelSize(12) - self.show_data_as_tip = show_data_as_tip - self.drawIds = True - self.ax = ax - self.sigHovered.connect(self.onHover) - self.lastHoveredPoint = None - - def onHover(self, item, points, event): - if len(points) == 0: - vb = self.getViewBox() - vb.setToolTip("") - return - - if self.lastHoveredPoint != points[0]: - self.sigHoverEntered.emit(item, points, event) - self.lastHoveredPoint = points[0] - - if not self.opts["hoverable"]: - return - - if not self.show_data_as_tip: - return - - tip_li = [str(point.data()) for point in points] - tip = "\n\n".join(tip_li) - - vb = self.getViewBox() - vb.setToolTip(tip) - - def setData(self, *args, **kwargs): - self.clearTextItems() - super().setData(*args, **kwargs) - data = kwargs.get("data") - if data is None: - return - - if len(data) == 0: - return - - first_point_data = data[0] - if not isinstance(first_point_data, (int, str)): - return - - if not self.drawIds: - return - - if self.show_data_as_tip: - return - - color = self.opts["brush"].color() - self.textItem.setColors({"id": color.getRgb()}) - size = self.opts["size"] - radius = size / 2 - # xx, yy = args - # for x, y, point_data in zip(xx, yy, data): - for point in self.points(): - text = str(point.data()) - if not text: - continue - - x, y = point.pos().x(), point.pos().y() - xt, yt = x + radius - 0.5, y - radius + 0.5 - opts = { - "text": text, - "bold": False, - "color_name": "id", - } - data = self.textItem.addObjAnnot((xt, yt), anchor=(-0.3, 1.3), **opts) - self.textItem.appendData(data, opts["text"]) - - self.textItem.draw() - # hexColor = color.name() - # htmlText = html_utils.span( - # text, color=hexColor, font_size='13pt', bold=True - # ) - - # textItem = self._textItems.get((x, y)) - # if textItem is None: - # textItem = pg.TextItem(html=htmlText, anchor=(0, 1)) - # textItem.setParentItem(self) - # self._textItems[(x, y)] = textItem - # self.ax.addItem(textItem) - # else: - # textItem.setHtml(htmlText) - # textItem.setPos(x+radius-0.5, y-radius+0.5) - - def clearTextItems(self): - self.textItem.clearData() - # for textItem in self._textItems.values(): - # textItem.setText('') - - def clear(self): - super().clear() - self.clearTextItems() - - def setVisible(self, visible): - super().setVisible(visible) - self.textItem.setVisible(visible) - - -class installJavaDialog(myMessageBox): - def __init__(self, parent=None): - super().__init__(parent) - - self.setWindowTitle("Install Java") - self.setIcon("SP_MessageBoxWarning") - - txt_macOS = html_utils.paragraph(""" - Your system doesn't have the Java Development Kit - installed
    and/or a C++ compiler which is required for the installation of - javabridge

    - Cell-ACDC is now going to install Java for you.

    - NOTE: After clicking on "Install", follow the instructions
    - on the terminal
    . You will be asked to confirm steps and insert
    - your password to allow the installation.


    - If you prefer to do it manually, cancel the process
    - and follow the instructions below. - """) - - txt_windows = html_utils.paragraph(""" - Unfortunately, installing pre-compiled version of - javabridge failed.

    - Cell-ACDC is going to try to compile it now.

    - However, before proceeding, you need to install - Java Development Kit
    and a C++ compiler.

    - See instructions below on how to install it. - """) - - if not is_win: - self.instructionsButton = self.addButton("Show intructions...") - self.instructionsButton.setCheckable(True) - self.instructionsButton.disconnect() - self.instructionsButton.clicked.connect(self.showInstructions) - installButton = self.addButton("Install") - installButton.disconnect() - installButton.clicked.connect(self.installJava) - txt = txt_macOS - else: - okButton = self.addButton("Ok") - txt = txt_windows - - self.cancelButton = self.addButton("Cancel") - - label = self.addText(txt) - label.setWordWrap(False) - - self.resizeCount = 0 - - def addInstructionsWindows(self): - self.scrollArea = QScrollArea() - _container = QWidget() - _layout = QVBoxLayout() - for t, text in enumerate(myutils.install_javabridge_instructions_text()): - label = QLabel() - label.setText(text) - if t == 1 or t == 2: - label.setOpenExternalLinks(True) - label.setTextInteractionFlags(Qt.TextBrowserInteraction) - code_layout = QHBoxLayout() - code_layout.addWidget(label) - copyButton = QToolButton() - copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - copyButton.setIcon(QIcon(":edit-copy.svg")) - copyButton.setText("Copy link") - if t == 1: - copyButton.textToCopy = myutils.jdk_windows_url() - code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) - else: - copyButton.textToCopy = myutils.cpp_windows_url() - screenshotButton = QToolButton() - screenshotButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - screenshotButton.setIcon(QIcon(":cog.svg")) - screenshotButton.setText("See screenshot") - code_layout.addWidget(screenshotButton, alignment=Qt.AlignLeft) - code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) - screenshotButton.clicked.connect(self.viewScreenshot) - copyButton.clicked.connect(self.copyToClipboard) - code_layout.setStretch(0, 2) - code_layout.setStretch(1, 0) - _layout.addLayout(code_layout) - else: - _layout.addWidget(label) - - _container.setLayout(_layout) - self.scrollArea.setWidget(_container) - self.currentRow += 1 - self._layout.addWidget( - self.scrollArea, self.currentRow, 1, alignment=Qt.AlignTop - ) - - # Stretch last row - self.currentRow += 1 - self._layout.setRowStretch(self.currentRow, 1) - - def viewScreenshot(self, checked=False): - self.screenShotWin = view_visualcpp_screenshot(parent=self) - self.screenShotWin.show() - - def addInstructionsMacOS(self): - self.scrollArea = QScrollArea() - _container = QWidget() - _layout = QVBoxLayout() - for t, text in enumerate(myutils.install_javabridge_instructions_text()): - label = QLabel() - label.setText(text) - # label.setWordWrap(True) - if t == 1 or t == 2: - label.setWordWrap(True) - code_layout = QHBoxLayout() - code_layout.addWidget(label) - copyButton = QToolButton() - copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - copyButton.setIcon(QIcon(":edit-copy.svg")) - copyButton.setText("Copy") - if t == 1: - copyButton.textToCopy = myutils._install_homebrew_command() - else: - copyButton.textToCopy = myutils._brew_install_java_command() - copyButton.clicked.connect(self.copyToClipboard) - code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) - # code_layout.addStretch(1) - code_layout.setStretch(0, 2) - code_layout.setStretch(1, 0) - _layout.addLayout(code_layout) - else: - _layout.addWidget(label) - _container.setLayout(_layout) - self.scrollArea.setWidget(_container) - self.currentRow += 1 - self._layout.addWidget( - self.scrollArea, self.currentRow, 1, alignment=Qt.AlignTop - ) - - # Stretch last row - self.currentRow += 1 - self._layout.setRowStretch(self.currentRow, 1) - self.scrollArea.hide() - - def addInstructionsLinux(self): - self.scrollArea = QScrollArea() - _container = QWidget() - _layout = QVBoxLayout() - for t, text in enumerate(myutils.install_javabridge_instructions_text()): - label = QLabel() - label.setText(text) - # label.setWordWrap(True) - if t == 1 or t == 2 or t == 3: - label.setWordWrap(True) - code_layout = QHBoxLayout() - code_layout.addWidget(label) - copyButton = QToolButton() - copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - copyButton.setIcon(QIcon(":edit-copy.svg")) - copyButton.setText("Copy") - if t == 1: - copyButton.textToCopy = myutils._apt_update_command() - elif t == 2: - copyButton.textToCopy = myutils._apt_install_java_command() - elif t == 3: - copyButton.textToCopy = myutils._apt_gcc_command() - copyButton.clicked.connect(self.copyToClipboard) - code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) - # code_layout.addStretch(1) - code_layout.setStretch(0, 2) - code_layout.setStretch(1, 0) - _layout.addLayout(code_layout) - else: - _layout.addWidget(label) - _container.setLayout(_layout) - self.scrollArea.setWidget(_container) - self.currentRow += 1 - self._layout.addWidget( - self.scrollArea, self.currentRow, 1, alignment=Qt.AlignTop - ) - - # Stretch last row - self.currentRow += 1 - self._layout.setRowStretch(self.currentRow, 1) - self.scrollArea.hide() - - def copyToClipboard(self): - cb = QApplication.clipboard() - cb.clear(mode=cb.Clipboard) - cb.setText(self.sender().textToCopy, mode=cb.Clipboard) - print("Command copied!") - - def showInstructions(self, checked): - if checked: - self.instructionsButton.setText("Hide instructions") - self.origHeight = self.height() - self.resize(self.width(), self.height() + 300) - self.scrollArea.show() - else: - self.instructionsButton.setText("Show instructions...") - self.scrollArea.hide() - func = partial(self.resize, self.width(), self.origHeight) - QTimer.singleShot(50, func) - - def installJava(self): - import subprocess - - try: - if is_mac: - try: - subprocess.check_call(["brew", "update"]) - except Exception as e: - subprocess.run( - myutils._install_homebrew_command(), - check=True, - text=True, - shell=True, - ) - subprocess.run( - myutils._brew_install_java_command(), - check=True, - text=True, - shell=True, - ) - elif is_linux: - subprocess.run( - myutils._apt_gcc_command()(), check=True, text=True, shell=True - ) - subprocess.run( - myutils._apt_update_command()(), check=True, text=True, shell=True - ) - subprocess.run( - myutils._apt_install_java_command()(), - check=True, - text=True, - shell=True, - ) - self.close() - except Exception as e: - print("=======================") - traceback.print_exc() - print("=======================") - msg = myMessageBox(wrapText=False) - err_msg = html_utils.paragraph(""" - Automatic installation of Java failed.

    - Please, try manually by following the instructions provided - below (click on "Show instructions..." button). Thanks - """) - msg.critical(self, "Java installation failed", err_msg) - - def show(self, block=False): - super().show(block=False) - print(is_linux) - if is_win: - self.addInstructionsWindows() - elif is_mac: - self.addInstructionsMacOS() - elif is_linux: - self.addInstructionsLinux() - self.move(self.pos().x(), 20) - if is_win: - self.resize(self.width(), self.height() + 200) - if block: - self._block() - - def exec_(self): - self.show(block=True) - - -class selectTrackerGUI(QDialogListbox): - def __init__(self, SizeT, currentFrameNo=1, parent=None): - trackers = myutils.get_list_of_trackers() - super().__init__( - "Select tracker", - "Select one of the following trackers", - trackers, - multiSelection=False, - parent=parent, - ) - self.setWindowTitle("Select tracker") - - self.selectFramesGroupbox = selectStartStopFrames( - SizeT, currentFrameNum=currentFrameNo, parent=parent - ) - - self.mainLayout.insertWidget(1, self.selectFramesGroupbox) - - def ok_cb(self, event): - if self.selectFramesGroupbox.warningLabel.text(): - return - else: - self.startFrame = self.selectFramesGroupbox.startFrame_SB.value() - self.stopFrame = self.selectFramesGroupbox.stopFrame_SB.value() - super().ok_cb(event) - - -def addWidgetToScrollArea( - widget, - resizeMinWidthNoHorizontalScrollbar=False, - resizeMinHeightNoVerticalScrollbar=False, -): - container = QWidget() - layout = QVBoxLayout() - layout.addWidget(widget) - layout.addStretch(1) - container.setLayout(layout) - scrollArea = QScrollArea() - scrollArea.setWidgetResizable(True) - scrollArea.setWidget(container) - - if resizeMinWidthNoHorizontalScrollbar: - scrollArea.setMinimumWidth( - container.sizeHint().width() - + scrollArea.verticalScrollBar().sizeHint().width() - ) - - if resizeMinHeightNoVerticalScrollbar: - scrollArea.setMinimumHeight( - container.sizeHint().height() - + scrollArea.horizontalScrollBar().sizeHint().height() - ) - - return scrollArea - - -class CheckableAction(QAction): - clicked = Signal(bool) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setCheckable(True) - self.toggled.connect(self.emitClicked) - - def emitClicked(self, checked): - self.clicked.emit(checked) - - def setChecked(self, checked): - self.toggled.disconnect() - super().setChecked(checked) - self.toggled.connect(self.emitClicked) - - -class OddSpinBox(SpinBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setSingleStep(2) - self.editingFinished.connect(self.roundToOdd) - - def roundToOdd(self): - if self.value() % 2 == 1: - return - - self.setValue(self.value() + 1) - - -class TimestampItem(LabelItem): - sigEditProperties = Signal(object) - sigRemove = Signal(object) - - def __init__( - self, - SizeY, - SizeX, - viewRange, - secondsPerFrame=1, - parent=None, - start_timedelta=None, - ): - self._secondsPerFrame = secondsPerFrame - self._x_pad = 3 - self._y_pad = 2 - self.xmin, self.ymin = 0, 0 - self.SizeY = SizeY - self.SizeX = SizeX - self._highlighted = False - self._parent = parent - if start_timedelta is None: - start_timedelta = datetime.timedelta(seconds=0) - self._start_timedelta = start_timedelta - self.clicked = False - super().__init__(self) - self.updateViewRange(viewRange) - self.createContextMenu() - - def setSecondsPerFrame(self, secondsPerFrame): - self._secondsPerFrame = secondsPerFrame - - def getBboxViewRange(self, viewRange): - xRange, yRange = viewRange - x0, x1 = xRange - y0, y1 = yRange - if x0 < 0: - x0 = 0 - - if x1 > self.SizeX: - x1 = self.SizeX - - if y0 < 0: - y0 = 0 - - if y1 > self.SizeY: - y1 = self.SizeY - - return x0, y0, x1, y1 - - def updateViewRange(self, viewRange): - x0, y0, x1, y1 = self.getBboxViewRange(viewRange) - - self.xmax = x1 - self.xmin = x0 - - self.ymax = y1 - self.ymin = y0 - - def createContextMenu(self): - self.contextMenu = QMenu() - action = QAction("Edit properties...", self.contextMenu) - action.triggered.connect(self.emitEditProperties) - self.contextMenu.addSeparator() - action = QAction("Remove", self.contextMenu) - action.triggered.connect(self.emitRemove) - self.contextMenu.addAction(action) - - def emitRemove(self): - self.sigRemove.emit(self) - - def mousePressed(self, x, y): - self.clicked = True - - def emitEditProperties(self): - self.setHighlighted(False) - self.sigEditProperties.emit(self.properties()) - - def isHighlighted(self): - return self._highlighted - - def setHighlighted(self, highlighted): - if self._highlighted and highlighted: - return - - if not self._highlighted and not highlighted: - return - - super().setText(self.text, bold=highlighted) - - self._highlighted = highlighted - - def showContextMenu(self, x, y): - self.contextMenu.popup(QPoint(int(x), int(y))) - - def setLocationProperty(self, loc: str): - self._loc = loc - - def properties(self): - properties = { - "color": self._color, - "loc": self._loc, - "font_size": int(self._font_size[:-2]), - "start_timedelta": self._start_timedelta, - "move_with_zoom": self._move_with_zoom, - } - return properties - - def draw(self, frame_i, **kwargs): - self.setProperties(**kwargs) - self.update(frame_i) - - def update(self, frame_i): - self.setPosFromLoc() - self.setText(frame_i) - - def setMoveWithZoomProperty(self, move_with_zoom): - self._move_with_zoom = move_with_zoom - - def updatePosViewRangeChanged(self, viewRange): - if self._loc == "custom": - textHeight = self.itemRect().height() - textWidth = self.itemRect().width() - x0p = self.pos().x() - y0p = self.pos().y() - xcp = x0p + textWidth / 2 - ycp = y0p + textHeight / 2 - x0 = self.xmin - y0 = self.ymin - x_range = self.xmax - x0 - y_range = self.ymax - y0 - Dx_perc = (xcp - x0) / x_range - Dy_perc = (ycp - y0) / y_range - - self.updateViewRange(viewRange) - - X0 = self.xmin - Y0 = self.ymin - - X_range = self.xmax - X0 - Y_range = self.ymax - Y0 - - Xcp = X0 + (Dx_perc * X_range) - Ycp = Y0 + (Dy_perc * Y_range) - X0p = Xcp - (textWidth / 2) - Y0p = Ycp - (textHeight / 2) - - y_pos_max = self.ymax - textHeight - self._y_pad - if Y0p > y_pos_max: - Y0p = y_pos_max - - x_pos_max = self.xmax - textWidth - self._x_pad - if X0p > x_pos_max: - X0p = x_pos_max - - self.setPos(X0p, Y0p) - else: - self.updateViewRange(viewRange) - self.setPosFromLoc() - - def setPosFromLoc(self): - textHeight = self.itemRect().height() - textWidth = self.itemRect().width() - if self._loc == "custom": - return - - if self._loc.find("top") != -1: - y0 = self._y_pad + self.ymin - else: - y0 = self.ymax - textHeight - self._y_pad - - if self._loc.find("left") != -1: - x0 = self._x_pad + self.xmin - else: - x0 = self.xmax - textWidth - self._x_pad - - self.setPos(x0, y0) - - def setProperties( - self, - color=(255, 255, 255), - font_size="13px", - loc="top-left", - start_timedelta=None, - move_with_zoom=False, - ): - if start_timedelta is not None: - self._start_timedelta = start_timedelta - self._color = color - self._loc = loc - self._font_size = font_size - self._move_with_zoom = move_with_zoom - - def move(self, xm, ym): - Dy = ym - self.yc - Dx = xm - self.xc - x0 = self.x0c + Dx - y0 = self.y0c + Dy - self.setPos(x0, y0) - - def mousePressed(self, x, y): - self.clicked = True - self.xc, self.yc = x, y - self.x0c = self.pos().x() - self.y0c = self.pos().y() - - def setText(self, frame_i): - if not isinstance(frame_i, int): - return - - seconds = frame_i * self._secondsPerFrame - timedelta = datetime.timedelta(seconds=round(seconds)) - - diff_seconds = timedelta.total_seconds() + self._start_timedelta.total_seconds() - if diff_seconds >= 0: - timedelta = datetime.timedelta(seconds=round(diff_seconds)) - text = str(timedelta) - else: - abs_diff = abs( - timedelta.total_seconds() + self._start_timedelta.total_seconds() - ) - abs_timedelta = datetime.timedelta(seconds=round(abs_diff)) - text = f"-{abs_timedelta}" - - # printl(timedelta) - super().setText(text, color=self._color, size=self._font_size) - - def addToAxis(self, ax): - ax.addItem(self) - - def removeFromAxis(self, ax): - ax.removeItem(self) - - -class FontSizeWidget(QWidget): - sigTextChanged = Signal(str) - - def __init__(self, parent=None, unit="px", initalVal=12): - super().__init__(parent) - - layout = QHBoxLayout() - - self.spinbox = SpinBox() - self.spinbox.setValue(initalVal) - layout.addWidget(self.spinbox) - - self.unitLabel = QLabel(unit) - layout.addWidget(self.unitLabel) - - layout.setContentsMargins(0, 0, 0, 0) - layout.setStretch(0, 1) - layout.setStretch(1, 0) - - self.setLayout(layout) - - self.spinbox.valueChanged.connect(self.emitTextChanged) - - def emitTextChanged(self, value): - self.sigTextChanged.emit(self.text()) - - def setValue(self, value): - if isinstance(value, str): - value = int(value.replace(self.unitLabel.text(), "").strip()) - self.spinbox.setValue(value) - - def setText(self, text): - value = int(text.replace(self.unitLabel.text(), "").strip()) - self.setValue(value) - - def text(self): - return f"{self.spinbox.value()}{self.unitLabel.text()}" - - def value(self): - return self.spinbox.value() - - -class RangeSelector(QWidget): - sigRangeChanged = Signal(object, object) - sigLowValueChanged = Signal(object) - sigHighValueChanged = Signal(object) - sigRangeManuallyChanged = Signal(object, object) - - def __init__(self, parent=None, integers=False, ordered=True): - super().__init__(parent) - - self._integers = integers - self._ordered = ordered - - layout = QHBoxLayout() - - if integers: - self.lowSpinbox = SpinBox() - self.highSpinbox = SpinBox() - else: - self.lowSpinbox = DoubleSpinBox() - self.highSpinbox = DoubleSpinBox() - - layout.addWidget(self.lowSpinbox) - layout.addWidget(self.highSpinbox) - - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - - self.lowSpinbox.valueChanged.connect(self.lowValueChanged) - self.highSpinbox.valueChanged.connect(self.highValueChanged) - - self.lowSpinbox.editingFinished.connect(self.lowValueEditingFinished) - self.highSpinbox.editingFinished.connect(self.highValueEditingFinished) - - def lowValueEditingFinished(self): - self.sigRangeManuallyChanged.emit(*self.range()) - self.emitRangeChanged() - - def highValueEditingFinished(self): - self.sigRangeManuallyChanged.emit(*self.range()) - self.emitRangeChanged() - - def lowValueChanged(self, value): - self.emitRangeChanged() - self.sigLowValueChanged.emit(value) - - def highValueChanged(self, value): - self.emitRangeChanged() - self.sigHighValueChanged.emit(value) - - def emitRangeChanged(self): - self.sigRangeChanged.emit(*self.range()) - - def setRangeNoEmit(self, lowValue, highValue, decimals=3): - self.lowSpinbox.valueChanged.disconnect() - self.highSpinbox.valueChanged.disconnect() - - self.setRange(round(lowValue, 3), round(highValue, 3)) - - self.lowSpinbox.valueChanged.connect(self.lowValueChanged) - self.highSpinbox.valueChanged.connect(self.highValueChanged) - - def setRange(self, lowValue, highValue): - # if lowValue > highValue and self._ordered: - # highValue = lowValue + 1 - - if self._integers: - lowValue = round(lowValue) - highValue = round(highValue) - - self.lowSpinbox.setValue(lowValue) - self.highSpinbox.setValue(highValue) - - def range(self): - return self.lowSpinbox.value(), self.highSpinbox.value() - - -class LineEdit(QLineEdit): - def __init__(self, parent=None): - super().__init__(parent) - self.setAlignment(Qt.AlignCenter) - - def value(self): - return self.text() - - def setValue(self, value): - self.setText(str(value)) - - -class PreProcessingSelector(QComboBox): - sigValuesChanged = Signal(dict, int) - - def __init__(self, parent=None): - super().__init__(parent) - self._parent = parent - - self.addItems(PREPROCESS_MAPPER.keys()) - self.methodToDefaultValuesMapper = {} - self.step_n = -1 - self.setParamsWindow = None - - def htmlInfo(self): - href = html_utils.href_tag("GitHub page", urls.issues_url) - docstring = PREPROCESS_MAPPER[self.currentText()]["docstring"] - if docstring is None: - text = "This function is not documented, yet. Sorry :(" - else: - text = html_utils.rst_docstring_to_html(docstring) - text = ( - f"{text}

    " - f"Feel free to submit an issue on our {href} if you " - "need help with this filter." - ) - return text - - def setParams(self, method: str, kwargToValueMapper: Dict[str, str]): - self.methodToDefaultValuesMapper[method] = kwargToValueMapper - - def askSetParams(self, df_metadata=None, addApplyButton=False): - method = self.currentText() - function = PREPROCESS_MAPPER[method]["function"] - params_argspecs = myutils.get_function_argspec( - function, - args_to_skip={"logger_func", "apply_to_all_zslices", "apply_to_all_frames"}, - ) - default_values = self.methodToDefaultValuesMapper.get(method, {}) - for kwarg, value in default_values.items(): - for p, param_argspec in enumerate(params_argspecs): - if param_argspec.name != kwarg: - continue - - if hasattr(param_argspec.type, "cast_dtype"): - cls = param_argspec.type - value = cls.cast_dtype(value) - else: - value = param_argspec.type(value) - - if value == param_argspec.default: - continue - param_argspec = param_argspec._replace(default=value) - params_argspecs[p] = param_argspec - - if self.setParamsWindow is not None: - self.setParamsWindow.raise_() - self.setParamsWindow.activateWindow() - return - - self.setParamsWindow = apps.FunctionParamsDialog( - params_argspecs, - df_metadata=df_metadata, - function_name=method, - addApplyButton=addApplyButton, - parent=self._parent, - ) - self.setParamsWindow.sigValuesChanged.connect(self.emitValuesChanged) - self.setParamsWindow.emitValuesChanged() - self.setParamsWindow.exec_() - if self.setParamsWindow.cancel: - return - - self.setParams(method, self.setParamsWindow.function_kwargs) - - function_kwargs = self.setParamsWindow.function_kwargs - self.setParamsWindow = None - - return function_kwargs - - def emitValuesChanged(self, functionKwargs: dict): - self.sigValuesChanged.emit(functionKwargs, self.step_n) - - -class RescaleImageJroisGroupbox(QGroupBox): - def __init__(self, TZYX_out_shape, parent=None): - super().__init__(parent) - - self.setTitle("Rescale ROIs") - self.setCheckable(True) - - gridLayout = QGridLayout() - - dims = ("Z", "Y", "X") - self.widgets = {} - for row, SizeD in enumerate(TZYX_out_shape[1:]): - if SizeD == 1: - continue - - dim = dims[row] - inputSpinbox = SpinBox() - inputSpinbox.setMinimum(1) - inputSpinbox.setValue(SizeD) - - outZwidget = QLineEdit() - outZwidget.setReadOnly(True) - outZwidget.setAlignment(Qt.AlignCenter) - # outZwidget.setValue(SizeD) - outZwidget.setText(str(SizeD)) - - row0 = row * 2 - row1 = row0 + 1 - gridLayout.addWidget(QLabel(f"{dim}-dimension: "), row1, 0) - - gridLayout.addWidget(QLabel("Input size"), row0, 1) - gridLayout.addWidget(inputSpinbox, row1, 1) - - gridLayout.addWidget(QLabel("Output size"), row0, 2) - gridLayout.addWidget(outZwidget, row1, 2) - - self.widgets[dim] = (inputSpinbox, SizeD) - - self.setLayout(gridLayout) - - def inputOutputSizes(self): - if not self.isChecked(): - return - - sizes = { - dim: (spinbox.value(), int(SizeD)) - for dim, (spinbox, SizeD) in self.widgets.items() - } - return sizes - - -class WhitelistLineEdit(KeepIDsLineEdit): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def setText(self, IDs): - if not isinstance(IDs, set) and not isinstance(IDs, list): - raise TypeError("IDs must be a set or list") - - formatted_text = myutils.format_IDs(IDs) - super().setText(formatted_text) - - -class WhitelistIDsToolbar(ToolBar): - sigWhitelistChanged = Signal(list) - sigViewOGIDs = Signal(bool) - sigWhitelistAccepted = Signal(list) - sigAddNewIDs = Signal(bool) - sigLoadOGLabs = Signal() - sigTrackOGagainstPreviousFrame = Signal(bool) - - def __init__(self, addNewIDToggleState, *args) -> None: - super().__init__(*args) - - whitelistLineEditLabel = QLabel("Whitelist IDs: ") - self.addWidget(whitelistLineEditLabel) - - self.whitelistLineEdit = WhitelistLineEdit(whitelistLineEditLabel, parent=self) - self.whitelistLineEdit.sigEnterPressed.connect(self.accept) - self.whitelistLineEdit.sigIDsChanged.connect(self.emitWhitelistChanged) - self.addWidget(self.whitelistLineEdit) - - # accept button - self.acceptButton = self.addButton(":greenTick.svg") - self.acceptButton.triggered.connect(self.accept) - - # add a view OG toggle - self.viewOGToggle = self.addButton(":eye.svg", checkable=True) - viewOGTooltip = ( - "View the non-whitelisted segmentation mask.\n\n" - "You can activate this to add new IDs to the whitelist,\n" - "correct tracking errors, etc." - ) - self.viewOGToggle.setChecked(True) - self.viewOGToggle.setToolTip(viewOGTooltip) - self.viewOGToggle.setShortcut("Shift+K") - key = "View the non-whitelisted segmentation mask" - self.widgetsWithShortcut[key] = self.viewOGToggle - - self.viewOGToggle.toggled.connect(self.emitViewOGIDs) - self.emitViewOGIDs(True) - - # add a Toggle to add new IDs - self.addNewIDToggle = QCheckBox("Automatically add new IDs to whitelist") - self.addNewIDToggle.setChecked(addNewIDToggleState) - self.addWidget(self.addNewIDToggle) - self.addNewIDToggle.toggled.connect(self.emitAddNewIDs) - self.emitAddNewIDs(addNewIDToggleState) - - self.addSeparator() - - # add a button to load og df - self.loadOGButton = self.addButton(":open_file.svg") - self.loadOGButton.triggered.connect(self.sigLoadOGLabs.emit) - self.loadOGButton.setToolTip( - "Select which segmentation mask file to load as the non-whitelisted masks" - ) - - self.TrackOGagainstPreviousFrameButton = self.addButton(":segment.svg") - self.TrackOGagainstPreviousFrameButton.triggered.connect( - self.sigTrackOGagainstPreviousFrame.emit - ) - self.TrackOGagainstPreviousFrameButton.setToolTip( - "Track the non-whitelisted segmentation masks against the previous frame and copy over successfull tacks" - ) - - self.addSeparator() - - # add an info button - self.infoButton = self.addButton(":info.svg") - self.infoButton.triggered.connect(self.showInfo) - - # add a spacer to the toolbar - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - self.addWidget(spacer) - - def emitWhitelistChanged(self, whitelist): - self.sigWhitelistChanged.emit(whitelist) - - def emitViewOGIDs(self, checked): - self.sigViewOGIDs.emit(checked) - - def accept(self): - try: - whitelist = self.whitelistLineEdit.IDs - except AttributeError as e: - if "has no attribute 'IDs'" in str(e): - whitelist = list() - self.viewOGToggle.toggled.disconnect() - self.viewOGToggle.setChecked(False) - self.viewOGToggle.toggled.connect(self.emitViewOGIDs) - self.sigWhitelistAccepted.emit(whitelist) - - def emitAddNewIDs(self, checked): - self.sigAddNewIDs.emit(checked) - - def showInfo(self): - msg = myMessageBox(wrapText=False) - txt = html_utils.paragraph(""" - This function is used to track a subset of segmented objects.

    - - To add new IDs to the white list, click with left mouse button on the - object to add.
    - You can also write directly into the Whitelist IDs widget
    - and separate the IDs by commas.

    - - After adding the IDs, click on the "Accept" button to remove the - non-whitelisted objects.
    - Every time you visit a new frame, the non-whitelisted objects will - be removed automatically.

    - Use the "Eye" button to view the non-whitelisted segmentation masks.
    - This will allow you to correct tracking errors, add new IDs to the - white list, etc.

    - - If you previously saved the whitelisted masks, you can load the - non-whitelisted file
    - by clicking on the "Load file" button to restart from where you - left last time. - """) - msg.information(self, "White list IDs", txt) - - -class MagicPromptsToolbar(ToolBar): - sigPromptTypeChanged = Signal(object, str) - sigComputeOnZoom = Signal(object) - sigComputeOnImage = Signal(object) - sigClearPoints = Signal(object) - sigClearPointsOnZmom = Signal(object) - sigInitSelectedModel = Signal(str, object, list, list, str, object) - sigViewModelParams = Signal(str, object, list, list, str, object, object, object) - sigInterpolateZslice = Signal(bool) - - def __init__(self, parent=None): - super().__init__(parent) - - self._parent = parent - - prompt_types = ("Points",) - - self.selectModelAction = self.addButton(":select-list.svg") - self.selectModelAction.setToolTip("Select the promptable model to use") - - self.viewModelParamsAction = self.addButton(":view.svg") - self.viewModelParamsAction.setToolTip( - "View the currently selected model parameters" - ) - self.viewModelParamsAction.setDisabled(True) - - self.addSeparator() - - self.promptTypeCombobox = self.addComboBox( - prompt_types, - label="Prompt type: ", - ) - - self.addSeparator() - - self.interpolateZslicesCheckbox = self.addCheckBox( - "Interpolate points on missing z-slices", checked=False - ) - self.interpolateZslicesCheckbox.setToolTip( - "If checked, when working with 3D segmentation masks, you can " - "add points on some z-slices only and the points on the missing " - "z-slices will be determined by linear interpolation.\n\n" - "This is useful when working with 2D models that segments " - "each z-slice independently.\n\n" - "NOTE: The points will be added only when running the model and " - "removed afterwards." - ) - - self.addSeparator() - - self.computeOnZoomAction = self.addButton(":compute-zoom.svg") - self.computeOnZoomAction.setToolTip( - "Compute the segmentation on the zoomed area of the image (faster)" - ) - - self.computeAction = self.addButton(":compute.svg") - self.computeAction.setToolTip("Compute the segmentation on the whole image") - - self.clearPointsAction = self.addButton(":clear-points.svg") - self.clearPointsAction.setToolTip("Clear all points") - self.clearPointsAction.setDisabled(True) - - self.clearPointsActionOnZoom = self.addButton(":clear-points-zoom.svg") - self.clearPointsActionOnZoom.setToolTip( - "Clear all points on the zoomed area of the image" - ) - self.clearPointsActionOnZoom.setDisabled(True) - - self.addSeparator() - - self.infoAction = self.addButton(":info.svg") - self.infoAction.setToolTip("Show instructions how to use promptable models") - - self.addSeparator() - - self.infoAction.triggered.connect(self.showHelp) - self.selectModelAction.triggered.connect(self.selectModel) - self.viewModelParamsAction.triggered.connect(self.viewModelParams) - self.promptTypeCombobox.sigTextChanged.connect(self.emitPromptTypeChanged) - self.computeOnZoomAction.triggered.connect(self.emitSigComputeOnZoom) - self.computeAction.triggered.connect(self.emitSigComputeOnImage) - self.clearPointsAction.triggered.connect(self.emitSigClearPoints) - self.clearPointsActionOnZoom.triggered.connect(self.emitSigClearPointsOnZoom) - self.interpolateZslicesCheckbox.toggled.connect(self.sigInterpolateZslice.emit) - - def showHelp(self): - msg = myMessageBox(wrapText=False) - txt = html_utils.paragraph(""" - This toolbar allows you to use promptable models for - segmentation.

    - - To use a promptable model, first select the model by clicking on the - "Select model" button.
    - This will open a dialog where you can select the model to use.

    - - After selecting the model, you can view the model parameters - by clicking on the "View model parameters" button.

    - - To add points to the image, make sure you have points layer correctly - initialised. You should see controls
    - called "Left-click ID" and "Right-click ID".

    - - You can add points for a new object by left-clicking on the image, - while you can add points
    - for the same object by right-clicking. - To delete a point, click on it again.

    - - To change the right-click ID, - you can either type in the corresponding control,
    - or type the object id on the keyboard followed by "Enter".

    - - To add negative prompts (i.e., for the background), use the - same action you use to delete objects
    - (default is middle-click on Windows and Cmd+Click on MacOS).

    - Note that you can also add object-specific negative prompts (i.e., - they affect only that object)
    - by adding the negative prompt on the newly segmented object - directly.

    - - Once you are happy with the added points, click either the - "Compute on zoomed area"
    - button or the "Compute on whole image" button.

    - - Finally, you can clear all points by clicking on the - "Clear points" button.

    - - Note that you can also save the points by clicking on the - "Save points" button to load them later and start from - where you left.

    - """) - msg.information(self, "Promptable models help", txt) - - def emitSigClearPoints(self): - self.sigClearPoints.emit(self) - - def emitSigClearPointsOnZoom(self): - self.sigClearPointsOnZmom.emit(self) - - def emitSigComputeOnZoom(self): - self.sigComputeOnZoom.emit(self) - - def emitSigComputeOnImage(self): - self.sigComputeOnImage.emit(self) - - def selectModel(self): - win = apps.SelectPromptableModelDialog(parent=self._parent) - win.exec_() - if win.cancel: - print("Promptable model selection cancelled") - return - - model_name = win.model_name - print(f"Importing promptable model {model_name}...") - - # Download model weights, consistent with gui.py - downloadWin = apps.downloadModel(model_name, parent=self._parent) - downloadWin.download() - - acdcPromptSegment = myutils.import_promptable_segment_module(model_name) - init_argspecs, segment_argspecs = myutils.getModelArgSpec(acdcPromptSegment) - - try: - help_url = acdcPromptSegment.url_help() - except AttributeError: - help_url = None - - self._model_name = model_name - self._acdcPromptSegment = acdcPromptSegment - self._init_argspecs = init_argspecs - self._segment_argspecs = segment_argspecs - self._help_url = help_url - - self.sigInitSelectedModel.emit( - model_name, - acdcPromptSegment, - init_argspecs, - segment_argspecs, - help_url, - self, - ) - - def setInitializedModel(self, init_kwargs, segment_kwargs): - self._init_kwargs = init_kwargs - self._segment_kwargs = segment_kwargs - - def viewModelParams(self): - self.sigViewModelParams.emit( - self._model_name, - self._acdcPromptSegment, - self._init_argspecs, - self._segment_argspecs, - self._help_url, - self._init_kwargs, - self._segment_kwargs, - self, - ) - - def emitPromptTypeChanged(self, text): - self.sigPromptTypeChanged.emit(self, text) - - -class KeySequenceFromText(QKeySequence): - def __init__(self, text: str): - if isinstance(text, str): - text = macShortcutToWindows(text) - super().__init__(text) - self._text = text - - def toString(self): - if isinstance(self._text, str): - return windowsShortcutToMac(self._text) - else: - return windowsShortcutToMac(super().toString()) - - -def modifierKeyToText(modifierKey: int): - if modifierKey == Qt.ControlModifier: - return "Ctrl" - elif modifierKey == Qt.AltModifier: - return "Alt" - elif modifierKey == Qt.ShiftModifier: - return "Shift" - elif modifierKey == Qt.MetaModifier: - return "Meta" - else: - return "" - - -class TimeWidget(QGroupBox): - sigValueChanged = Signal(object) - - def __init__(self, parent=None, orientation="vertical"): - super().__init__(parent) - - mainLayout = QHBoxLayout() - - if orientation == "vertical": - spinboxesLayout = QVBoxLayout() - elif orientation == "horizontal": - spinboxesLayout = QHBoxLayout() - else: - raise ValueError('orientation must be "vertical" or "horizontal"') - - self.signCombobox = QComboBox() - self.signCombobox.addItems(("+", "-")) - self.signCombobox.currentTextChanged.connect(self.emitValueChanged) - - mainLayout.addWidget(self.signCombobox) - - self.spinboxesMapper = {} - units = ("days", "hours", "minutes", "seconds") - for unit in units: - layout = QHBoxLayout() - spinbox = SpinBox() - spinbox.setMinimum(0) - label = QLabel(unit) - layout.addWidget(spinbox) - layout.addWidget(label) - spinbox.valueChanged.connect(self.emitValueChanged) - self.spinboxesMapper[unit] = spinbox - spinboxesLayout.addLayout(layout) - - mainLayout.addLayout(spinboxesLayout) - - self.setLayout(mainLayout) - mainLayout.setContentsMargins(5, 5, 5, 5) - - def values(self): - values = {} - for unit, spinbox in self.spinboxesMapper.items(): - values[unit] = spinbox.value() - - signText = self.signCombobox.currentText() - return values, sign_int_mapper[signText] - - def setValuesFromTimedelta(self, timedelta): - total_seconds = timedelta.total_seconds() - sign = 1 if total_seconds > 0 else -1 - days = timedelta.days - hours, remainder = divmod(timedelta.seconds, 3600) - minutes, seconds = divmod(remainder, 60) - - values = {"days": days, "hours": hours, "minutes": minutes, "seconds": seconds} - - self.setValues(values, sign=sign) - - def timedelta(self): - values, sign = self.values() - return datetime.timedelta(**values) * sign - - def setValues(self, values: dict[str, int | float], sign=1): - signText = "+" if sign > 0 else "-" - self.signCombobox.setCurrentText(signText) - for unit, value in values.items(): - spinbox = self.spinboxesMapper[unit] - spinbox.setValue(value) - - def emitValueChanged(self, value): - self.sigValueChanged.emit(self.values()) - - -class PointsLayersToolbar(ToolBar): - sigAddPointsLayer = Signal() - - def __init__(self, name="Points layers", parent=None): - - super().__init__(name, parent) - - self.guiWin = parent - - self.setContextMenuPolicy(Qt.PreventContextMenu) - - self.addPointsLayerAction = self.addButton(":addPointsLayer.svg") - - self.addSeparator() - - self.pointsLayersLabel = self.addLabel("Points layers: ") - - self.addPointsLayerAction.triggered.connect(self.emitAddPointsLayer) - self.doAddPointsZslicesInterpolation = False - - def emitAddPointsLayer(self): - self.sigAddPointsLayer.emit() - - def fromActionToDataFrame(self, action, posData, isSegm3D=False): - df = pd.DataFrame(columns=["frame_i", "Cell_ID", "z", "y", "x", "id"]) - frames_vals = [] - IDs = [] - zz = [] - yy = [] - xx = [] - ids = [] - pos_i = self.guiWin.pos_i - if pos_i not in action.pointsData: - printl( - "No points data for position", pos_i - ) # should really not happen, but its not a disaster if it does - return df - pointsDataPos = action.pointsData[pos_i] - for frame_i, framePointsData in pointsDataPos.items(): - if posData.SizeZ > 1: - for z, zSlicePointsData in framePointsData.items(): - yyxx = zip(zSlicePointsData["y"], zSlicePointsData["x"]) - for y, x in yyxx: - if isSegm3D: - ID = posData.lab[int(z), int(y), int(x)] - else: - ID = posData.lab[int(y), int(x)] - frames_vals.append(frame_i) - IDs.append(ID) - zz.append(z) - yy.append(y) - xx.append(x) - ids.extend(zSlicePointsData["id"]) - else: - yyxx = zip(framePointsData["y"], framePointsData["x"]) - for y, x in yyxx: - ID = posData.lab[int(y), int(x)] - frames_vals.append(frame_i) - IDs.append(ID) - yy.append(y) - xx.append(x) - ids.extend(framePointsData["id"]) - df["frame_i"] = frames_vals - df["Cell_ID"] = IDs - df["y"] = yy - df["x"] = xx - df["id"] = ids - if zz: - df["z"] = zz - - df = self.addPointsZslicesInterpolation(df, posData.lab, isSegm3D) - - return df - - def addPointsZslicesInterpolation( - self, df: pd.DataFrame, lab: np.ndarray, isSegm3D: bool - ): - if not self.doAddPointsZslicesInterpolation: - return df - - if not isSegm3D: - return df - - if "z" not in df.columns: - return df - - df_new_rows = [] - for (frame_i, point_id), df_id in df.groupby(["frame_i", "id"]): - xx = df_id["x"].values - yy = df_id["y"].values - zz = df_id["z"].values - - p0, d = core.linear_fit_3d(xx, yy, zz) - - new_row_df = df_id.iloc[[0]].copy() - - z0, z1 = int(np.min(zz)), int(np.max(zz)) - for z in range(z0, z1 + 1): - if z in zz: - continue - - t_int = (z - p0[2]) / d[2] - x_new, y_new, z_new = p0 + t_int * d - new_row_df["z"] = round(z_new) - new_row_df["y"] = round(y_new) - new_row_df["x"] = round(x_new) - - Cell_ID = lab[int(round(z_new)), int(round(y_new)), int(round(x_new))] - new_row_df["Cell_ID"] = Cell_ID - - df_new_rows.append(new_row_df.copy()) - - if not df_new_rows: - return df - - df_new = pd.concat(df_new_rows, ignore_index=True) - df = pd.concat([df, df_new], ignore_index=True) - df = df.sort_values(by=["frame_i", "id", "z"]).reset_index(drop=True) - - return df - - -class PromptableModelPointsLayerToolbar(PointsLayersToolbar): - def __init__(self, name="Promptable model points layers", parent=None): - super().__init__(name, parent=parent) - - self.isPointsLayerInit = False - - self.addPointsLayerAction.setDisabled(True) - self.addPointsLayerAction.setVisible(False) - - def pointsLayerDf(self, posData, isSegm3D=False): - for action in self.actions()[1:]: - if not hasattr(action, "button"): - continue - - df = self.fromActionToDataFrame(action, posData, isSegm3D=isSegm3D) - return df - - def scatterItem(self): - for action in self.actions()[1:]: - if not hasattr(action, "button"): - continue - - return action.scatterItem - - -class RectItem(pg.GraphicsObject): - def __init__(self, rect, pen=None, brush=(255, 0, 0, 100), parent=None): - super().__init__(parent) - self._rect = rect - self._pen = pg.mkPen(pen) - self._brush = pg.mkBrush(brush) - self.picture = QPicture() - self._generate_picture() - - def setColor(self, color): - rgba = matplotlib.colors.to_rgba(color, alpha=100 / 255) - rgba = [round(c * 255) for c in rgba] - self._brush = pg.mkBrush(rgba) - self._generate_picture() - self.update() - - def setRect(self, x, y, width, height): - self._rect = QRectF(x, y, width, height) - self._generate_picture() - self.update() - - def setQRect(self, qrect): - self._rect = qrect - self._generate_picture() - self.update() - - @property - def rect(self): - return self._rect - - def _generate_picture(self): - painter = QPainter(self.picture) - painter.setPen(self._pen) - painter.setBrush(self._brush) - painter.drawRect(self._rect) - painter.end() - - def paint(self, painter, option, widget=None): - painter.drawPicture(0, 0, self.picture) - - def boundingRect(self): - return QRectF(self.picture.boundingRect()) - - -def get_min_width_for_no_scrollbar(list_widget: QListWidget) -> int: - """ - Calculate the minimum width needed for the QListWidget - so that the horizontal scrollbar will not be required. - """ - font_metrics = QFontMetrics(list_widget.font()) - max_width = 0 - - for i in range(list_widget.count()): - item = list_widget.item(i) - text_width = font_metrics.horizontalAdvance(item.text()) - max_width = max(max_width, text_width) - - # Add padding for icon, scrollbar margin, and frame - padding = 30 # Adjust as needed (depends on style and icons) - return max_width + padding - - -class OverlayToolbar(ToolBar): - sigSetTranspacency = Signal(bool) - sigSetSingleChannel = Signal(bool) - - def __init__(self, name="Overlay tools", parent=None): - - super().__init__(name, parent) - - self.guiWin = parent - - self.setContextMenuPolicy(Qt.PreventContextMenu) - - self.addSeparator() - - self.transparencyCheckbox = self.addCheckBox( - text="True transparency (RGBA composite)" - ) - - self.transparencyCheckbox.setToolTip( - "Activate to achieve true pixel-wise transparency where " - "the pixel intensity is 0 or set to 0 using the " - "LUT sliders on the left of the images.\n\n" - "Since it is significantly slower, we recommended to activate this " - "only if you need to export images for figures." - ) - - self.addSeparator() - - self.singleChannelCheckbox = self.addCheckBox(text="Single channel") - - self.singleChannelCheckbox.setToolTip( - "When single channel mode is activated, selecting a channel " - "will display only that channel in the overlay." - ) - - self.transparencyCheckbox.toggled.connect(self.sigSetTranspacency.emit) - self.singleChannelCheckbox.toggled.connect(self.sigSetSingleChannel.emit) - - def setTransparent(self, transparent: bool): - self.transparencyCheckbox.setChecked(transparent) - - def isTransparent(self): - return self.transparencyCheckbox.isChecked() - - def isSingleChannel(self): - return self.singleChannelCheckbox.isChecked() - - -class OverlayChannelToolButton(GradientToolButton): - def __init__( - self, - channel_name: str, - lut_item: myHistogramLUTitem, - shortcut="0", - parent=None, - ): - super().__init__(colors=lut_item.gradient.getLookupTable(256), parent=parent) - self._channel_name = channel_name - - lut_item.sigGradientChanged.connect(self.updateColors) - - self.setToolTip(f'Show/hide "{channel_name}" channel\n\nShortcut: {shortcut}') - - self.setCheckable(True) - - def channelName(self): - return self._channel_name - - def updateColors(self, lut_item): - colors = lut_item.gradient.getLookupTable(256) - self._qcolors = [pg.mkColor(c) for c in colors] - self.update() - - def setVisible(self, visible: bool): - super().setVisible(visible) - if not hasattr(self, "action"): - return - - self.action.setVisible(visible) - - -class YeazV2SelectModelNameCombobox(ComboBox): - sigValueChanged = Signal(str) - - def __init__( - self, *args, custom_select_item_text="Select custom weights file...", **kwargs - ): - super().__init__(*args, **kwargs) - self._csi_text = custom_select_item_text - self.sigTextChanged.connect(self.onTextChanged) - self.initItems() - - def initItems(self): - from cellacdc.segmenters.YeaZ_v2 import load_models_filepath - - models_name, models_name_filepath_mapper = load_models_filepath() - self.addItems(models_name) - - def onTextChanged(self, text): - if text != self._csi_text: - return - - start_dir = myutils.getMostRecentPath() - model_filepath = qtpy.compat.getopenfilename( - parent=self, - caption="Select YeaZ weights file", - filters="All Files (*)", - basedir=start_dir, - )[0] - if not model_filepath: - self.setCurrentIndex(0) - return - - msg = html_utils.paragraph(f""" - Insert a name for the following YeaZ model:

    - {model_filepath}
    - """) - modelNameWindow = apps.QLineEditDialog( - title="Insert a name for the model", msg=msg, allowEmpty=False, parent=self - ) - modelNameWindow.exec_() - if modelNameWindow.cancel: - self.setCurrentIndex(0) - return - - model_name = modelNameWindow.enteredValue - - from cellacdc.segmenters.YeaZ_v2 import add_model_filepath - - add_model_filepath(model_name, model_filepath) - - self.addItem(model_name) - self.setCurrentText(model_name) - - print( - "YeaZ_v2 model added!\n\n" - f" * Name: {model_name}\n" - f" * File path: {model_filepath}\n" - ) - - def addItem(self, item): - idx = self.count() - 1 - self.insertItem(idx, item) - - def addItems(self, items): - super().clear() - super().addItems(items) - super().addItem(self._csi_text) - idx = len(items) - font = self.font() - font.setItalic(True) - self.setItemData(idx, font, Qt.FontRole) - - def setValue(self, value: str): - self.setCurrentText(value) - - def value(self, *args): - return self.currentText() - - -class HighlightedIDToolbar(ToolBar): - sigIDChanged = Signal(int) - - def __init__(self, name="Highlighted ID", parent=None): - - super().__init__(name, parent) - - self.spinbox = self.addSpinBox("Highlighted ID: ") - self.spinbox.valueChanged.connect(self.emitSigIDChanged) - - self.addSeparator() - - def emitSigIDChanged(self, *args, **kwargs): - self.sigIDChanged.emit(self.spinbox.value()) - - def setIDNoSignals(self, ID: int): - self.spinbox.blockSignals(True) - self.spinbox.setValue(ID) - self.spinbox.blockSignals(False) - - -class AutoSaveIntervalWidget(QWidget): - sigValueChanged = Signal(float, str) - - def __init__(self, parent=None): - super().__init__(parent) - - layout = QHBoxLayout() - - autoSaveIntervalTooltip = "Autosave every minutes or frames specified here." - - self.setToolTip(autoSaveIntervalTooltip) - - self.spinbox = DoubleSpinBox() - self.spinbox.setMinimum(0) - self.spinbox.setValue(2) - self.spinbox.setDecimals(2) - self.spinbox.setSingleStep(1.0) - - layout.addWidget(self.spinbox) - - self.unitCombobox = ComboBox() - self.unitCombobox.addItems(["minutes", "frames"]) - layout.addWidget(self.unitCombobox) - - layout.setStretch(0, 1) - layout.setStretch(1, 0) - layout.setContentsMargins(5, 0, 5, 0) - - self.setLayout(layout) - - self.spinbox.sigValueChanged.connect(self.emitSigValueChanged) - self.unitCombobox.sigTextChanged.connect(self.emitSigValueChanged) - - def emitSigValueChanged(self, *args, **kwargs): - self.sigValueChanged.emit(self.spinbox.value(), self.unitCombobox.currentText()) - - -class CheckableWidget(QWidget): - def __init__(self, widget, valueGetterName="value", parent=None): - super().__init__(parent) - - self.widget = widget - self.valueGetterName = valueGetterName - - widget.setDisabled(True) - - layout = QHBoxLayout() - - layout.addWidget(widget) - - self.checkbox = QCheckBox("Activate") - self.checkbox.toggled.connect(self.setWidgetEnabled) - - layout.addSpacing(5) - layout.addWidget(self.checkbox) - - layout.setContentsMargins(5, 0, 5, 0) - - self.setLayout(layout) - - def setWidgetEnabled(self, checked): - self.widget.setDisabled(not checked) - - def value(self): - if not self.checkbox.isChecked(): - return - - return getattr(self.widget, self.valueGetterName)() - - -class WandControlsToolbar(ToolBar): - def __init__(self, name="Magic wand controls", parent=None): - super().__init__(name, parent) - - self.toleranceSpinbox = self.addSpinBox("Tolerance [%]: ") - self.toleranceSpinbox.setMinimum(0) - self.toleranceSpinbox.setMaximum(100) - self.toleranceSpinbox.setValue(5) - self.toleranceSpinbox.setToolTip( - "The tolerance is calculated as a percentage of the minimum-maximum " - "pixel values range of the loaded dataset.\n\n" - "If tolerance is greater than 0, the pixels adjacent to the added " - "pixels with value within +- tolerance will be considered part of " - "the object." - ) - self.addLabel(r"% of min-max intensity range ") - - self.addSeparator() - - self.autoFillHolesCheckbox = self.addCheckBox("Auto-fill holes") - - self.addSeparator() - - self.useConvexHullCheckbox = self.addCheckBox("Use convex hull mask") - - self.addSeparator() - - -class warnVisualCppRequired(myMessageBox): - def __init__(self, pkg_name="javabridge", parent=None): - super().__init__(parent) - self.screenShotWin = None - - self.setIcon(iconName="SP_MessageBoxWarning") - self.setWindowTitle(f"Installation of {pkg_name} info") - txt = html_utils.paragraph(f""" - Installation of {pkg_name} on Windows requires - Microsoft Visual C++ 14.0 or higher.

    - Cell-ACDC will anyway try to install {pkg_name} now.

    - If the installation fails, please close Cell-ACDC, - then download and install "Microsoft C++ Build Tools" - from the link below - before trying this module again.

    - - https://visualstudio.microsoft.com/visual-cpp-build-tools/ -

    - IMPORTANT: when installing "Microsoft C++ Build Tools" - make sure to select "Desktop development with C++". - Click "See the screenshot" for more details. - """) - seeScreenshotButton = QPushButton("See screenshot...") - okButton = okPushButton("Ok") - okButton = self.addButton("Ok") - okButton.disconnect() - okButton.clicked.connect(self.ok_cb) - self.addButton(seeScreenshotButton) - seeScreenshotButton.disconnect() - seeScreenshotButton.clicked.connect(self.viewScreenshot) - self.addCancelButton(connect=True) - self.addText(txt) - - def ok_cb(self): - self.cancel = False - self.close() - - def viewScreenshot(self, checked=False): - self.screenShotWin = view_visualcpp_screenshot(self) - self.screenShotWin.show() - - def closeEvent(self, event): - if self.screenShotWin is not None: - self.screenShotWin.close() - - return super().closeEvent(event) diff --git a/cellacdc/widgets/__init__.py b/cellacdc/widgets/__init__.py new file mode 100644 index 000000000..0836ba747 --- /dev/null +++ b/cellacdc/widgets/__init__.py @@ -0,0 +1,294 @@ +"""GUI widgets package (controls, canvas, toolbars) + components re-exports.""" + +from ..components.palette import * # noqa: F403 +from ..components.progress import * # noqa: F403 +from ..components.buttons import * # noqa: F403 +from ..components.layout import * # noqa: F403 +from ..components.inputs_basic import * # noqa: F403 +from ..components.path_controls import * # noqa: F403 +from ..components.lists import * # noqa: F403 +from ..components.base import QBaseWindow, QBaseDialog # noqa: F401 + +from .canvas import ( + BaseGradientEditorItemImage, + BaseGradientEditorItemLabels, + BaseImageItem, + BaseLabelsImageItem, + BaseScatterPlotItem, + ChildImageItem, + ContourItem, + CustomAnnotationScatterPlotItem, + DelROI, + GhostContourItem, + GhostMaskItem, + ImShow, + ImShowPlotItem, + LabelItem, + LabelRoiCircularItem, + MainPlotItem, + MouseCursor, + OverlayImageItem, + ParentImageItem, + PlotCurveItem, + PointsScatterPlotItem, + PolyLineROI, + ROI, + RectItem, + RulerPlotItem, + ScaleBar, + ScatterPlotItem, + ScrollBarWithNumericControl, + ZoomROI, + _ImShowImageItem, + baseHistogramLUTitem, + labImageItem, + labelledQScrollbar, + labelsGradientWidget, + linkedQScrollbar, + myColorButton, + myHistogramLUTitem, + myLabelItem, + navigateQScrollBar, + overlayLabelsGradientWidget, + sliderWithSpinBox, +) + +from .controls import ( + AlphaNumericComboBox, + AutoSaveIntervalWidget, + CenteredDoubleSpinbox, + CheckableAction, + CheckableSpinBoxWidgets, + CheckableWidget, + CheckboxesGroupBox, + ComboBox, + CopiableCommandWidget, + DoubleSpinBox, + ExpandableListBox, + FeatureSelectorButton, + FloatLineEdit, + FontSizeWidget, + IntLineEdit, + KeptObjectIDsList, + KeySequenceFromText, + Label, + LabelsWidget, + LatexLabel, + LineEdit, + ManualBackgroundToolBar, + ManualTrackingToolBar, + OddSpinBox, + OrderableListWidget, + PixelSizeGroupbox, + PostProcessSegmSlider, + PostProcessSegmSpinbox, + PostProcessSegmWidget, + PreProcessingSelector, + QCenteredComboBox, + QClickableLabel, + QDialogListbox, + QKeyEventToString, + RangeSelector, + ReadOnlyLineEdit, + RescaleImageJroisGroupbox, + SamInputPointsWidget, + SavePointsLayerButton, + SearchLineEdit, + SetMeasurementsGroupBox, + ShortcutLineEdit, + SpinBox, + SwitchPlaneCombobox, + TimeWidget, + TimestampItem, + Toggle, + ToggleTerminalButton, + ToggleVisibilityButton, + ToggleVisibilityCheckBox, + VectorLineEdit, + WhitelistLineEdit, + YeazV2SelectModelNameCombobox, + _metricsQGBox, + addWidgetToScrollArea, + channelMetricsQGBox, + expandCollapseButton, + formWidget, + get_min_width_for_no_scrollbar, + guiTabControl, + highlightableQWidgetAction, + installJavaDialog, + listWidget, + macShortcutToWindows, + modifierKeyToText, + myMessageBox, + mySpinBox, + objIntesityMeasurQGBox, + objPropsQGBox, + readOnlyDoubleSpinbox, + readOnlySpinbox, + selectStartStopFrames, + selectTrackerGUI, + statusBarPermanentLabel, + view_visualcpp_screenshot, + warnVisualCppRequired, + windowsShortcutToMac, +) + +from .toolbars import ( + CopyLostObjectToolbar, + DrawClearRegionToolbar, + GradientToolButton, + HighlightedIDToolbar, + MagicPromptsToolbar, + OverlayChannelToolButton, + OverlayToolbar, + PointsLayerToolButton, + PointsLayersToolbar, + PromptableModelPointsLayerToolbar, + ToolBar, + ToolBarSeparator, + ToolButtonCustomColor, + ToolButtonTextIcon, + WandControlsToolbar, + WhitelistIDsToolbar, + customAnnotToolButton, + rightClickToolButton, +) + +__all__ = [ + "BaseGradientEditorItemImage", + "BaseGradientEditorItemLabels", + "BaseImageItem", + "BaseLabelsImageItem", + "BaseScatterPlotItem", + "ChildImageItem", + "ContourItem", + "CustomAnnotationScatterPlotItem", + "DelROI", + "GhostContourItem", + "GhostMaskItem", + "ImShow", + "ImShowPlotItem", + "LabelItem", + "LabelRoiCircularItem", + "MainPlotItem", + "MouseCursor", + "OverlayImageItem", + "ParentImageItem", + "PlotCurveItem", + "PointsScatterPlotItem", + "PolyLineROI", + "ROI", + "RectItem", + "RulerPlotItem", + "ScaleBar", + "ScatterPlotItem", + "ScrollBarWithNumericControl", + "ZoomROI", + "_ImShowImageItem", + "baseHistogramLUTitem", + "labImageItem", + "labelledQScrollbar", + "labelsGradientWidget", + "linkedQScrollbar", + "myColorButton", + "myHistogramLUTitem", + "myLabelItem", + "navigateQScrollBar", + "overlayLabelsGradientWidget", + "sliderWithSpinBox", + "AlphaNumericComboBox", + "AutoSaveIntervalWidget", + "CenteredDoubleSpinbox", + "CheckableAction", + "CheckableSpinBoxWidgets", + "CheckableWidget", + "CheckboxesGroupBox", + "ComboBox", + "CopiableCommandWidget", + "DoubleSpinBox", + "ExpandableListBox", + "FeatureSelectorButton", + "FloatLineEdit", + "FontSizeWidget", + "IntLineEdit", + "KeptObjectIDsList", + "KeySequenceFromText", + "Label", + "LabelsWidget", + "LatexLabel", + "LineEdit", + "ManualBackgroundToolBar", + "ManualTrackingToolBar", + "OddSpinBox", + "OrderableListWidget", + "PixelSizeGroupbox", + "PostProcessSegmSlider", + "PostProcessSegmSpinbox", + "PostProcessSegmWidget", + "PreProcessingSelector", + "QCenteredComboBox", + "QClickableLabel", + "QDialogListbox", + "QKeyEventToString", + "RangeSelector", + "ReadOnlyLineEdit", + "RescaleImageJroisGroupbox", + "SamInputPointsWidget", + "SavePointsLayerButton", + "SearchLineEdit", + "SetMeasurementsGroupBox", + "ShortcutLineEdit", + "SpinBox", + "SwitchPlaneCombobox", + "TimeWidget", + "TimestampItem", + "Toggle", + "ToggleTerminalButton", + "ToggleVisibilityButton", + "ToggleVisibilityCheckBox", + "VectorLineEdit", + "WhitelistLineEdit", + "YeazV2SelectModelNameCombobox", + "_metricsQGBox", + "addWidgetToScrollArea", + "channelMetricsQGBox", + "expandCollapseButton", + "formWidget", + "get_min_width_for_no_scrollbar", + "guiTabControl", + "highlightableQWidgetAction", + "installJavaDialog", + "listWidget", + "macShortcutToWindows", + "modifierKeyToText", + "myMessageBox", + "mySpinBox", + "objIntesityMeasurQGBox", + "objPropsQGBox", + "readOnlyDoubleSpinbox", + "readOnlySpinbox", + "selectStartStopFrames", + "selectTrackerGUI", + "statusBarPermanentLabel", + "view_visualcpp_screenshot", + "warnVisualCppRequired", + "windowsShortcutToMac", + "CopyLostObjectToolbar", + "DrawClearRegionToolbar", + "GradientToolButton", + "HighlightedIDToolbar", + "MagicPromptsToolbar", + "OverlayChannelToolButton", + "OverlayToolbar", + "PointsLayerToolButton", + "PointsLayersToolbar", + "PromptableModelPointsLayerToolbar", + "ToolBar", + "ToolBarSeparator", + "ToolButtonCustomColor", + "ToolButtonTextIcon", + "WandControlsToolbar", + "WhitelistIDsToolbar", + "customAnnotToolButton", + "rightClickToolButton", +] diff --git a/cellacdc/widgets/canvas.py b/cellacdc/widgets/canvas.py new file mode 100644 index 000000000..1bd477557 --- /dev/null +++ b/cellacdc/widgets/canvas.py @@ -0,0 +1,4234 @@ +"""GUI widgets: canvas.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from .. import myutils, measurements, is_mac, is_win, html_utils, is_linux +from .. import printl, settings_folderpath +from .. import colors, config +from .. import html_path +from .. import _palettes +from .. import load +from .. import apps +from .. import plot +from .. import annotate +from .. import urls +from .. import _core, core +from .. import QtScoped +from .. import prompts +from ..acdc_regex import float_regex +from ..config import PREPROCESS_MAPPER +from .. import _base_widgets + +from ..components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ..components.progress import QtHandler, QLog, XStream # noqa: E402 +from ..components.buttons import * # noqa: E402, F403 +from ..components.layout import * # noqa: E402, F403 +from ..components.inputs_basic import * # noqa: E402, F403 +from ..components.path_controls import * # noqa: E402, F403 + +from ..components.lists import * # noqa: E402, F403 +from ..components.base import QBaseWindow # noqa: E402 +from ..components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +class ContourItem(pg.PlotCurveItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + self._prevData = None + + def clear(self): + try: + self.setData([], []) + except AttributeError as e: + pass + + def tempClear(self): + try: + self._prevData = [d.copy() for d in self.getData()] + self.clear() + except Exception as e: + pass + + def restore(self): + if self._prevData is not None: + if self._prevData[0] is not None: + self.setData(*self._prevData) + + +class BaseScatterPlotItem(pg.ScatterPlotItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + def tempClear(self): + try: + self._prevData = [d.copy() for d in self.getData()] + self.setData([], []) + except Exception as e: + pass + + def restore(self): + if self._prevData is not None: + if self._prevData[0] is not None: + self.setData(*self._prevData) + + +class CustomAnnotationScatterPlotItem(BaseScatterPlotItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + +class ScatterPlotItem(pg.ScatterPlotItem): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.updateBrushAndPen(**kwargs) + + def updateBrushAndPen(self, **kwargs): + brush = kwargs.get("brush") + if brush is not None: + self._itemBrush = brush + pen = kwargs.get("pen") + if pen is not None: + self._itemPen = pen + + def setData(self, *args, **kwargs): + super().setData(*args, **kwargs) + self.updateBrushAndPen(**kwargs) + + def itemBrush(self): + return self._itemBrush + + def itemPen(self): + return self._itemPen + + def removePoint(self, index): + newData = np.delete(self.data, index) + # Update the index of current points + for i in range(index, len(newData)): + spotItem = newData[i]["item"] + spotItem._index = i + newData[i]["item"] = spotItem + + self.data = newData + self.prepareGeometryChange() + self.informViewBoundsChanged() + self.bounds = [None, None] + self.invalidate() + self.updateSpots(newData) + self.sigPlotChanged.emit(self) + + def coordsToNumpy(self, includeData=False, rounded=True, decimals=None): + points = self.points() + nrows = len(points) + coords_arr = np.zeros((nrows, 2)) + data_arr = None + for p, point in enumerate(points): + pos = point.pos() + x, y = pos.x(), pos.y() + if includeData: + data = point.data() + if data_arr is None: + try: + ncols = len(data) + except Exception as e: + data = [data] + ncols = 1 + data_arr = np.zeros((nrows, ncols)) + for j, data_j in enumerate(data): + data_arr[p, j] = data_j + + coords_arr[p, 0] = y + coords_arr[p, 1] = x + if not includeData: + out_arr = coords_arr + elif data_arr is not None: + out_arr = np.column_stack((data_arr, coords_arr)) + else: + out_arr = coords_arr + cast_to_int = decimals is None + decimals = decimals if decimals is not None else 0 + if rounded: + out_arr = np.round(out_arr, decimals) + if cast_to_int: + out_arr = out_arr.astype(int) + return out_arr + + +class myLabelItem(pg.LabelItem): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._prevText = "" + + def setText(self, text, **args): + self.text = text + opts = self.opts + for k in args: + opts[k] = args[k] + + if "size" in self.opts: + size = self.opts["size"] + if size == "0pt" or size == "0px": + self.opts["size"] = "1pt" + super().setText("", size="1pt") + return + + optlist = [] + + color = self.opts["color"] + if color is None: + color = pg.getConfigOption("foreground") + color = pg.functions.mkColor(color) + optlist.append("color: " + color.name(QColor.NameFormat.HexArgb)) + if "size" in opts: + size = opts["size"] + if not isinstance(size, str): + size = f"{size}px" + optlist.append("font-size: " + size) + if "bold" in opts and opts["bold"] in [True, False]: + optlist.append( + "font-weight: " + {True: "bold", False: "normal"}[opts["bold"]] + ) + if "italic" in opts and opts["italic"] in [True, False]: + optlist.append( + "font-style: " + {True: "italic", False: "normal"}[opts["italic"]] + ) + full = "%s" % ("; ".join(optlist), text) + # print full + self.item.setHtml(full) + self.updateMin() + self.resizeEvent(None) + self.updateGeometry() + + def tempClearText(self): + if self.text: + self._prevText = self.text + self.setText("") + + def restoreText(self): + if self._prevText: + self.setText(self._prevText) + + +class LabelRoiCircularItem(pg.ScatterPlotItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + def setImageShape(self, shape): + self._shape = shape + + def slice(self, zRange=None, tRange=None): + self.mask() + if zRange is None: + _slice = self._slice + else: + zmin, zmax = zRange + _slice = (slice(zmin, zmax), *self._slice) + + if tRange is not None: + tmin, tmax = tRange + _slice = (slice(tmin, tmax), *_slice) + + return _slice + + def mask(self): + shape = self._shape + radius = int(self.opts["size"] / 2) + mask = skimage.morphology.disk(radius, dtype=bool) + xx, yy = self.getData() + Yc, Xc = yy[0], xx[0] + mask, self._slice = myutils.clipSelemMask(mask, shape, Yc, Xc, copy=False) + return mask + + +class PolyLineROI(pg.PolyLineROI): + def __init__(self, positions, closed=False, pos=None, **args): + super().__init__(positions, closed, pos, **args) + + +class BaseGradientEditorItemImage(pg.GradientEditorItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + def restoreState(self, state): + pg.graphicsItems.GradientEditorItem.Gradients = GradientsImage + return super().restoreState(state) + + +class MouseCursor(QWidget): + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._x = None + self._y = None + self.setMouseTracking(True) + + def mouseMoveEvent(self, event) -> None: + self.move(event.pos()) + self.update() + return super().mouseMoveEvent(event) + + # def drawAtPos(self, x, y): + # self._x = x + # self._y = y + # self.update() + + def paintEvent(self, event) -> None: + p = QPainter(self) + # p.setPen(QPen(QColor(0,0,0))) + # p.setBrush(QBrush(QColor(70,70,70,200))) + p.drawLine(0, 0, 200, 0) + p.end() + + +class BaseGradientEditorItemLabels(pg.GradientEditorItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + def restoreState(self, state): + pg.graphicsItems.GradientEditorItem.Gradients = GradientsLabels + return super().restoreState(state) + + +class baseHistogramLUTitem(pg.HistogramLUTItem): + sigAddColormap = Signal(object, str) + sigRescaleIntes = Signal(object) + + def __init__(self, name="image", axisLabel="", parent=None, **kwargs): + pg.GradientEditorItem = BaseGradientEditorItemLabels + + super().__init__(**kwargs) + + self.labelStyle = {"color": "#ffffff", "font-size": "11px"} + + if axisLabel: + self.setAxisLabel(axisLabel) + + self.cmaps = cmaps + self._parent = parent + self.name = name + + self.gradient.colorDialog.setWindowFlags(Qt.Dialog | Qt.WindowStaysOnTopHint) + self.gradient.colorDialog.accepted.disconnect() + self.gradient.colorDialog.accepted.connect(self.tickColorAccepted) + + self.isInverted = False + self.lastGradientName = "grey" + self.lastGradient = Gradients["grey"] + + for action in self.gradient.menu.actions(): + if action.text() == "HSV": + HSV_action = action + elif action.text() == "RGB": + RGB_ation = action + self.gradient.menu.removeAction(HSV_action) + self.gradient.menu.removeAction(RGB_ation) + + # Rescale intensities (LUT) + rescaleIntensMenu = self.gradient.menu.addMenu("Rescale intensities (LUT)") + rescaleActionGroup = QActionGroup(self) + rescaleActionGroup.setExclusive(True) + + self.rescaleEach2DimgAction = QAction( + "Rescale each 2D image", rescaleIntensMenu + ) + self.rescaleEach2DimgAction.setCheckable(True) + self.rescaleEach2DimgAction.setChecked(True) + rescaleActionGroup.addAction(self.rescaleEach2DimgAction) + rescaleIntensMenu.addAction(self.rescaleEach2DimgAction) + + self.rescaleAcrossZstackAction = QAction( + "Rescale across z-stack", rescaleIntensMenu + ) + self.rescaleAcrossZstackAction.setCheckable(True) + self.rescaleAcrossZstackAction.setChecked(False) + rescaleActionGroup.addAction(self.rescaleAcrossZstackAction) + rescaleIntensMenu.addAction(self.rescaleAcrossZstackAction) + + self.rescaleAcrossTimeAction = QAction( + "Rescale across time frames", rescaleIntensMenu + ) + self.rescaleAcrossTimeAction.setCheckable(True) + self.rescaleAcrossTimeAction.setChecked(False) + rescaleActionGroup.addAction(self.rescaleAcrossTimeAction) + rescaleIntensMenu.addAction(self.rescaleAcrossTimeAction) + + self.customRescaleAction = QAction("Choose custom levels...", rescaleIntensMenu) + self.customRescaleAction.setCheckable(True) + rescaleActionGroup.addAction(self.customRescaleAction) + rescaleIntensMenu.addAction(self.customRescaleAction) + + self.doNotRescaleAction = QAction( + "Do no rescale, display raw image", rescaleIntensMenu + ) + self.doNotRescaleAction.setCheckable(True) + rescaleActionGroup.addAction(self.doNotRescaleAction) + rescaleIntensMenu.addAction(self.doNotRescaleAction) + + self.rescaleActionGroup = rescaleActionGroup + rescaleActionGroup.triggered.connect(self.rescaleActionTriggered) + + # Add custom colormap action + self.customCmapsMenu = self.gradient.menu.addMenu("Custom colormaps") + self.customCmapsMenu.aboutToShow.connect(self.onShowCustomCmapsMenu) + self.customCmapsMenu.triggered.connect(self.customCmapsMenuTriggered) + + self.saveColormapAction = QAction("Save current colormap...", self) + self.gradient.menu.addAction(self.saveColormapAction) + self.saveColormapAction.triggered.connect(self.saveColormap) + + self.addCustomGradients() + + # Set inverted gradients for invert bw action + self.addInvertedColorMaps() + + self.gradient.menu.addSeparator() + + # hide histogram tool + self.vb.hide() + + # Disable moving the axis up and down + self.axis.unlinkFromView() + + # Disable histogram default context Menu event + self.vb.raiseContextMenu = lambda x: None + + def rescaleActionTriggered(self, action): + self.sigRescaleIntes.emit(action) + + def onShowCustomCmapsMenu(self): + self.customCmapsMenu.show() + + def customCmapsMenuTriggered(self, action): + cmap = action.cmap + self.gradient.colorMapMenuClicked(cmap) + self.gradient.showTicks(True) + + def setAxisLabel(self, text): + self.labelText = text + self.axis.setLabel(text, **self.labelStyle) + + def updateAxisLabel(self): + text = self.axis.label.toPlainText() + if not text: + return + self.setAxisLabel(text) + + def setGradient(self, gradient): + self.gradient.restoreState(gradient) + self.lastGradient = gradient + + def colormapClicked(self, checked=False, name=None): + name = self.sender().name + self.lastGradientName = name + if self.isInverted: + self.setGradient(self.invertedGradients[name]) + else: + self.setGradient(Gradients[name]) + + def sortTicks(self, ticks): + sortedTicks = sorted(ticks, key=operator.itemgetter(0)) + return sortedTicks + + def getInvertedGradients(self): + invertedGradients = {} + for name, gradient in Gradients.items(): + ticks = gradient["ticks"] + sortedTicks = self.sortTicks(ticks) + if name in nonInvertibleCmaps: + invertedColors = sortedTicks + else: + invertedColors = [ + (t[0], ti[1]) for t, ti in zip(sortedTicks, sortedTicks[::-1]) + ] + invertedGradient = {} + invertedGradient["ticks"] = invertedColors + invertedGradient["mode"] = gradient["mode"] + invertedGradients[name] = invertedGradient + return invertedGradients + + def addInvertedColorMaps(self): + self.invertedGradients = self.getInvertedGradients() + for action in self.gradient.menu.actions(): + if not hasattr(action, "name"): + continue + + if action.name not in self.cmaps: + continue + + action.triggered.disconnect() + action.triggered.connect(self.colormapClicked) + + px = QPixmap(100, 15) + p = QPainter(px) + invertedGradient = self.invertedGradients[action.name] + qtGradient = QLinearGradient(QPointF(0, 0), QPointF(100, 0)) + ticks = self.sortTicks(invertedGradient["ticks"]) + qtGradient.setStops([(x, QColor(*color)) for x, color in ticks]) + brush = QBrush(qtGradient) + p.fillRect(QRect(0, 0, 100, 15), brush) + p.end() + widget = action.defaultWidget() + hbox = widget.layout() + rectLabelWidget = QLabel() + rectLabelWidget.setPixmap(px) + hbox.addWidget(rectLabelWidget) + rectLabelWidget.hide() + + def setInvertedColorMaps(self, inverted): + if inverted: + showIdx = 2 + hideIdx = 1 + self.labelStyle["color"] = "#000000" + else: + showIdx = 1 + hideIdx = 2 + self.labelStyle["color"] = "#ffffff" + + for action in self.gradient.menu.actions(): + if not hasattr(action, "name"): + continue + + if action.name not in self.cmaps: + continue + + widget = action.defaultWidget() + hbox = widget.layout() + hideCmapRect = hbox.itemAt(hideIdx).widget() + showCmapRect = hbox.itemAt(showIdx).widget() + hideCmapRect.hide() + showCmapRect.show() + + self.updateAxisLabel() + self.isInverted = inverted + + def invertGradient(self, gradient): + ticks = gradient["ticks"] + sortedTicks = self.sortTicks(ticks) + invertedColors = [ + (t[0], ti[1]) for t, ti in zip(sortedTicks, sortedTicks[::-1]) + ] + invertedGradient = {} + invertedGradient["ticks"] = invertedColors + invertedGradient["mode"] = gradient["mode"] + return invertedGradient + + def invertCurrentColormap(self, inverted, debug=False): + self.setGradient(self.invertGradient(self.lastGradient)) + + def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): + self.originalLength = self.gradient.length + self.gradient.length = 100 + if restore: + self.gradient.restoreState(gradient_ticks) + gradient = self.gradient.getGradient() + action = CustomGradientMenuAction(gradient, gradient_name, self.gradient) + # action.triggered.connect(self.gradient.contextMenuClicked) + action.delButton.clicked.connect(self.removeCustomGradient) + action.cmap = colors.pg_ticks_to_colormap(gradient_ticks["ticks"]) + # self.gradient.menu.insertAction(self.saveColormapAction, action) + self.customCmapsMenu.addAction(action) + self.gradient.length = self.originalLength + GradientsImage[gradient_name] = gradient_ticks + + def removeCustomGradient(self): + button = self.sender() + action = button.action + self.customCmapsMenu.removeAction(action) + cp = config.ConfigParser() + cp.read(custom_cmaps_filepath) + cp.remove_section(f"image.{action.name}") + with open(custom_cmaps_filepath, mode="w") as file: + cp.write(file) + + def addCustomGradients(self): + try: + CustomGradients = getCustomGradients(name="image") + if not CustomGradients: + return + for gradient_name, gradient_ticks in CustomGradients.items(): + self.addCustomGradient(gradient_name, gradient_ticks) + except Exception as e: + printl(traceback.format_exc()) + pass + + def _askNameColormap(self): + inputWin = apps.QInput(parent=self._parent, title="Colormap name") + inputWin.askText("Insert a name for the colormap: ", allowEmpty=False) + if inputWin.cancel: + return + cmapName = inputWin.answer + return cmapName + + def saveColormap(self): + cmapName = self._askNameColormap() + if cmapName is None: + return + + cp = config.ConfigParser() + if os.path.exists(custom_cmaps_filepath): + cp.read(custom_cmaps_filepath) + + SECTION = f"{self.name}.{cmapName}" + cp[SECTION] = {} + + # gradient_ticks = [] + state = self.gradient.saveState() + for key, value in state.items(): + if key != "ticks": + continue + for t, tick in enumerate(value): + pos, rgb = tick + # gradient_ticks.append((pos, rgb)) + rgb = ",".join([str(c) for c in rgb]) + val = f"{pos},{rgb}" + cp[SECTION][f"tick_{t}_pos_rgb"] = val + + with open(custom_cmaps_filepath, mode="w") as file: + cp.write(file) + + self.addCustomGradient(cmapName, state, restore=False) + + def tickColorAccepted(self): + self.gradient.currentColorAccepted() + # self.sigTickColorAccepted.emit(self.gradient.colorDialog.color().getRgb()) + + def setRescaleIntensitiesHow(self, how): + for action in self.rescaleActionGroup.actions(): + if action.text() == how: + action.setChecked(True) + return + + +class ROI(pg.ROI): + def __init__( + self, + pos, + size=pg.Point(1, 1), + angle=0, + invertible=False, + maxBounds=None, + snapSize=1, + scaleSnap=False, + translateSnap=False, + rotateSnap=False, + parent=None, + pen=None, + hoverPen=None, + handlePen=None, + handleHoverPen=None, + movable=True, + rotatable=True, + resizable=True, + removable=False, + aspectLocked=False, + ): + super().__init__( + pos, + size, + angle, + invertible, + maxBounds, + snapSize, + scaleSnap, + translateSnap, + rotateSnap, + parent, + pen, + hoverPen, + handlePen, + handleHoverPen, + movable, + rotatable, + resizable, + removable, + aspectLocked, + ) + + def slice(self, zRange=None, tRange=None): + x0, y0 = [int(round(c)) for c in self.pos()] + w, h = [int(round(c)) for c in self.size()] + xmin, xmax = x0, x0 + w + if xmin > xmax: + xmin, xmax = xmax, xmin + ymin, ymax = y0, y0 + h + if ymin > ymax: + ymin, ymax = ymax, ymin + if zRange is not None: + zmin, zmax = zRange + _slice = (slice(zmin, zmax), slice(ymin, ymax), slice(xmin, xmax)) + else: + _slice = (slice(ymin, ymax), slice(xmin, xmax)) + if tRange is not None: + tmin, tmax = tRange + _slice = (slice(tmin, tmax), *_slice) + return _slice + + def bbox(self): + x0, y0 = [int(round(c)) for c in self.pos()] + w, h = [int(round(c)) for c in self.size()] + xmin, xmax = x0, x0 + w + if xmin > xmax: + xmin, xmax = xmax, xmin + ymin, ymax = y0, y0 + h + if ymin > ymax: + ymin, ymax = ymax, ymin + + return ymin, xmin, ymax, xmax + + +class ZoomROI(ROI): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.viewRangesQueue = deque() + + def getLastRange(self): + xRange, yRange = self.viewRangesQueue.pop() + return xRange, yRange + + def storeLastRange(self, xRange, yRange): + self.viewRangesQueue.append((xRange, yRange)) + + +class DelROI(pg.ROI): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def clearPoints(self): + """ + Remove all handles and segments. + """ + while len(self.handles) > 0: + self.removeHandle(self.handles[0]["item"]) + + +class PlotCurveItem(pg.PlotCurveItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + def addPoint(self, x, y, **kargs): + _xx, _yy = self.getData() + if _xx is None or len(_xx) == 0: + self.xData = np.array([x], dtype=int) + self.yData = np.array([y], dtype=int) + return + if _xx[-1] == x and _yy[-1] == y: + # Do not append same point + return + + # Pre-allocate array and insert data (faster than append) + xx = np.zeros(len(_xx) + 1, dtype=_xx.dtype) + xx[:-1] = _xx + xx[-1] = x + yy = np.zeros(len(_yy) + 1, dtype=_xx.dtype) + yy[:-1] = _yy + yy[-1] = y + self.setData(xx, yy, **kargs) + + def clear(self): + try: + self.setData([], []) + except Exception as e: + pass + super().clear() + + def closeCurve(self): + _xx, _yy = self.getData() + self.addPoint(_xx[0], _yy[0]) + + def mask(self): + ymin, xmin, ymax, xmax = self.bbox() + _mask = np.zeros((ymax - ymin + 1, xmax - xmin + 1), dtype=bool) + local_xx, local_yy = self.getLocalData() + rr, cc = skimage.draw.polygon(local_yy, local_xx) + _mask[rr, cc] = True + return _mask + + def getLocalData(self): + _xx, _yy = self.getData() + return _xx - _xx.min(), _yy - _yy.min() + + def slice(self, zRange=None, tRange=None): + ymin, xmin, ymax, xmax = self.bbox() + if zRange is not None: + zmin, zmax = zRange + _slice = (slice(zmin, zmax), slice(ymin, ymax + 1), slice(xmin, xmax + 1)) + else: + _slice = (slice(ymin, ymax + 1), slice(xmin, xmax + 1)) + if tRange is not None: + tmin, tmax = tRange + _slice = (slice(tmin, tmax), *_slice) + return _slice + + def bbox(self): + _xx, _yy = self.getData() + return _yy.min(), _xx.min(), _yy.max(), _xx.max() + + +class myHistogramLUTitem(baseHistogramLUTitem): + sigGradientMenuEvent = Signal(object) + sigGradientChanged = Signal(object) + sigTickColorAccepted = Signal(object) + sigAddScaleBar = Signal(bool) + sigAddTimestamp = Signal(bool) + + def __init__( + self, parent=None, name="image", axisLabel="", isViewer=False, **kwargs + ): + super().__init__(parent=parent, name=name, axisLabel=axisLabel, **kwargs) + + self.name = name + self._parent = parent + + self.childLutItem = None + + self.isViewer = isViewer + if isViewer: + # In the viewer we don't allow additional settings from the menu + return + + # Add scale bar action + self.addScaleBarAction = QAction("Add scale bar", self) + self.addScaleBarAction.setCheckable(True) + self.addScaleBarAction.triggered.connect(self.emitAddScaleBar) + self.gradient.menu.addAction(self.addScaleBarAction) + + # Add timestamp action + self.addTimestampAction = QAction("Add timestamp", self) + self.addTimestampAction.setCheckable(True) + self.addTimestampAction.triggered.connect(self.emitAddTimestamp) + self.gradient.menu.addAction(self.addTimestampAction) + + # Invert bw action + self.invertBwAction = QAction("Invert black/white", self) + self.invertBwAction.setCheckable(True) + self.gradient.menu.addAction(self.invertBwAction) + + # Font size menu action + self.fontSizeMenu = QMenu("Text font size") + self.gradient.menu.addMenu(self.fontSizeMenu) + + # Text color button + hbox = QHBoxLayout() + hbox.addWidget(QLabel("Text color: ")) + self.textColorButton = myColorButton(color=(255, 255, 255)) + hbox.addStretch(1) + hbox.addWidget(self.textColorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + act.setDefaultWidget(widget) + act.triggered.connect(self.textColorButton.click) + self.gradient.menu.addAction(act) + + # Contours line weight + contLineWeightMenu = QMenu("Contours line weight", self.gradient.menu) + self.contLineWightActionGroup = QActionGroup(self) + self.contLineWightActionGroup.setExclusionPolicy( + QActionGroup.ExclusionPolicy.Exclusive + ) + for w in range(1, 11): + action = QAction(str(w)) + action.setCheckable(True) + if w == 2: + action.setChecked(True) + action.lineWeight = w + self.contLineWightActionGroup.addAction(action) + action = contLineWeightMenu.addAction(action) + self.gradient.menu.addMenu(contLineWeightMenu) + + # Contours color button + hbox = QHBoxLayout() + hbox.addWidget(QLabel("Contours color: ")) + self.contoursColorButton = myColorButton(color=(25, 25, 25)) + hbox.addStretch(1) + hbox.addWidget(self.contoursColorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + act.setDefaultWidget(widget) + act.triggered.connect(self.contoursColorButton.click) + self.gradient.menu.addAction(act) + + # Mother-bud line weight + mothBudLineWeightMenu = QMenu("Mother-bud line weight", self.gradient.menu) + self.mothBudLineWightActionGroup = QActionGroup(self) + self.mothBudLineWightActionGroup.setExclusionPolicy( + QActionGroup.ExclusionPolicy.Exclusive + ) + for w in range(1, 11): + action = QAction(str(w)) + action.setCheckable(True) + if w == 2: + action.setChecked(True) + action.lineWeight = w + self.mothBudLineWightActionGroup.addAction(action) + action = mothBudLineWeightMenu.addAction(action) + self.gradient.menu.addMenu(mothBudLineWeightMenu) + + # Mother-bud line color + hbox = QHBoxLayout() + hbox.addWidget(QLabel("Mother-bud line color: ")) + self.mothBudLineColorButton = myColorButton(color=(255, 0, 0)) + hbox.addStretch(1) + hbox.addWidget(self.mothBudLineColorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + act.setDefaultWidget(widget) + act.triggered.connect(self.mothBudLineColorButton.click) + self.gradient.menu.addAction(act) + + self.labelsAlphaMenu = self.gradient.menu.addMenu( + "Segm. masks overlay alpha..." + ) + # self.labelsAlphaMenu.setDisabled(True) + hbox = QHBoxLayout() + self.labelsAlphaSlider = sliderWithSpinBox( + title="Alpha", title_loc="in_line", isFloat=True, normalize=True + ) + self.labelsAlphaSlider.setMaximum(100) + self.labelsAlphaSlider.setSingleStep(0.05) + self.labelsAlphaSlider.setValue(0.3) + hbox.addWidget(self.labelsAlphaSlider) + shortCutText = "Command+Up/Down" if is_mac else "Ctrl+Up/Down" + hbox.addWidget(QLabel(f"({shortCutText})")) + widget = QWidget() + widget.setLayout(hbox) + act = QWidgetAction(self) + act.setDefaultWidget(widget) + self.labelsAlphaMenu.addSeparator() + self.labelsAlphaMenu.addAction(act) + + # Default settings + self.defaultSettingsAction = QAction("Restore default settings...", self) + self.gradient.menu.addAction(self.defaultSettingsAction) + + self.filterObject = FilterObject() + self.filterObject.sigFilteredEvent.connect(self.gradientMenuEventFilter) + self.gradient.menu.installEventFilter(self.filterObject) + self.highlightedAction = None + self.lastHoveredAction = None + + def setChildLutItem(self, childLutItem): + self.childLutItem = childLutItem + + def removeAddScaleBarAction(self): + self.gradient.menu.removeAction(self.addScaleBarAction) + + def removeAddTimestampAction(self): + self.gradient.menu.removeAction(self.addTimestampAction) + + def emitAddScaleBar(self): + self.sigAddScaleBar.emit(self.addScaleBarAction.isChecked()) + + def emitAddTimestamp(self): + self.sigAddTimestamp.emit(self.addTimestampAction.isChecked()) + + def gradientChanged(self): + super().gradientChanged() + self.sigGradientChanged.emit(self) + + def gradientMenuEventFilter(self, object, event): + if event.type() == QEvent.Type.MouseMove: + hoveredAction = self.gradient.menu.actionAt(event.pos()) + isActionEntered = hoveredAction != self.lastHoveredAction + if isActionEntered: + if isinstance(hoveredAction, highlightableQWidgetAction): + # print('Entered a custom action') + pass + isActionLeft = ( + self.highlightedAction is not None + and self.highlightedAction != hoveredAction + ) + if isActionLeft: + if isinstance(self.highlightedAction, highlightableQWidgetAction): + # print('Left a custom action') + pass + self.highlightedAction = hoveredAction + + self.lastHoveredAction = hoveredAction + + def addOverlayColorButton(self, rgbColor, channelName): + # Overlay color button + hbox = QHBoxLayout() + hbox.addWidget(QLabel("Overlay color: ")) + self.overlayColorButton = myColorButton(color=rgbColor) + self.overlayColorButton.channel = channelName + hbox.addStretch(1) + hbox.addWidget(self.overlayColorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + act.setDefaultWidget(widget) + act.triggered.connect(self.overlayColorButton.click) + self.gradient.menu.addAction(act) + + def uncheckContLineWeightActions(self): + for act in self.contLineWightActionGroup.actions(): + try: + act.toggled.disconnect() + except Exception as e: + pass + act.setChecked(False) + + def uncheckMothBudLineLineWeightActions(self): + for act in self.mothBudLineWightActionGroup.actions(): + try: + act.toggled.disconnect() + except Exception as e: + pass + act.setChecked(False) + + def restoreState(self, df): + if "textIDsColor" in df.index: + rgbString = df.at["textIDsColor", "value"] + r, g, b = colors.rgb_str_to_values(rgbString) + self.textColorButton.setColor((r, g, b)) + + if "contLineColor" in df.index: + rgba_str = df.at["contLineColor", "value"] + rgb = colors.rgba_str_to_values(rgba_str)[:3] + self.contoursColorButton.setColor(rgb) + + if "contLineWeight" in df.index: + w = df.at["contLineWeight", "value"] + w = int(w) + for action in self.contLineWightActionGroup.actions(): + if action.lineWeight == w: + action.setChecked(True) + break + + if "mothBudLineWeight" in df.index: + w = df.at["mothBudLineWeight", "value"] + w = int(w) + for action in self.mothBudLineWightActionGroup.actions(): + if action.lineWeight == w: + action.setChecked(True) + break + + if "overlaySegmMasksAlpha" in df.index: + alpha = df.at["overlaySegmMasksAlpha", "value"] + self.labelsAlphaSlider.setValue(float(alpha)) + + if "mothBudLineColor" in df.index: + rgba_str = df.at["mothBudLineColor", "value"] + rgb = colors.rgba_str_to_values(rgba_str)[:3] + self.mothBudLineColorButton.setColor(rgb) + + checked = df.at["is_bw_inverted", "value"] == "Yes" + self.invertBwAction.setChecked(checked) + + self.restoreColormap(df) + + def saveState(self, df): + # remove previous state + df = df[~df.index.str.contains("img_cmap")].copy() + + state = self.gradient.saveState() + for key, value in state.items(): + if key == "ticks": + for t, tick in enumerate(value): + pos, rgb = tick + df.at[f"img_cmap_tick{t}_rgb", "value"] = rgb + df.at[f"img_cmap_tick{t}_pos", "value"] = pos + else: + if isinstance(value, bool): + value = "Yes" if value else "No" + df.at[f"img_cmap_{key}", "value"] = value + return df + + def restoreColormap(self, df): + state = {"mode": "rgb", "ticksVisible": True, "ticks": []} + ticks_pos = {} + ticks_rgb = {} + stateFound = False + for setting, value in df.itertuples(): + idx = setting.find("img_cmap_") + if idx == -1: + continue + + stateFound = True + m = re.findall(r"tick(\d+)_(\w+)", setting) + if m: + tick_idx, tick_type = m[0] + if tick_type == "pos": + ticks_pos[int(tick_idx)] = float(value) + elif tick_type == "rgb": + ticks_rgb[int(tick_idx)] = colors.rgba_str_to_values(value) + else: + key = setting[9:] + if value == "Yes": + value = True + elif value == "No": + value = False + state[key] = value + + if stateFound: + ticks = [(0, 0)] * len(ticks_pos) + for idx, val in ticks_pos.items(): + pos = val + rgb = ticks_rgb[idx] + ticks[idx] = (pos, rgb) + + state["ticks"] = ticks + self.gradient.restoreState(state) + + def regionChanged(self): + super().regionChanged() + if self.childLutItem is None: + return + + imageItem = self.imageItem() + try: + mn, mx = imageItem.quickMinMax(targetSize=65536) + # mn and mx can still be NaN if the data is all-NaN + if mn == mx or imageItem._xp.isnan(mn) or imageItem._xp.isnan(mx): + mn = 0 + mx = 255 + except AttributeError as err: + mn, mx = self.getLevels() + + self.childLutItem.setLevels(min=mn, max=mx) + + +class labelledQScrollbar(ScrollBar): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._label = None + + def setLabel(self, label): + self._label = label + + def updateLabel(self): + if self._label is not None: + position = self.sliderPosition() + s = self._label.text() + s = re.sub(r"(\d+)/(\d+)", rf"{position + 1:02}/\2", s) + self._label.setText(s) + + def setSliderPosition(self, position): + QScrollBar.setSliderPosition(self, position) + self.updateLabel() + + def setValue(self, value): + QScrollBar.setValue(self, value) + self.updateLabel() + + +class navigateQScrollBar(ScrollBar): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._disableCustomPressEvent = False + self.signal_slot_mapper = {} + + def disableCustomPressEvent(self): + self._disableCustomPressEvent = True + + def enableCustomPressEvent(self): + self._disableCustomPressEvent = False + + def setAbsoluteMaximum(self, absoluteMaximum): + self._absoluteMaximum = absoluteMaximum + + def absoluteMaximum(self): + return self._absoluteMaximum + + def mousePressEvent(self, event): + super().mousePressEvent(event) + if self.maximum() == self._absoluteMaximum: + return + + if self._disableCustomPressEvent: + return + + def setValueNoSignal(self, value): + for signal_name, slot in self.signal_slot_mapper.items(): + signal = getattr(self, signal_name) + try: + signal.disconnect() + except Exception as e: + pass + + self.setSliderPosition(value) + self.connectEvents(self.signal_slot_mapper) + + def connectEvents(self, signal_slot_mapper: dict): + self.signal_slot_mapper = signal_slot_mapper + for signal_name, slot in signal_slot_mapper.items(): + signal = getattr(self, signal_name) + try: + signal.disconnect() + except Exception as e: + pass + signal.connect(slot) + + +class linkedQScrollbar(ScrollBar): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._linkedScrollBar = None + + def linkScrollBar(self, scrollbar): + self._linkedScrollBar = scrollbar + scrollbar.setSliderPosition(self.sliderPosition()) + + def unlinkScrollBar(self): + self._linkedScrollBar = None + + def setSliderPosition(self, position): + QScrollBar.setSliderPosition(self, position) + if self._linkedScrollBar is not None: + self._linkedScrollBar.setSliderPosition(position) + + def setMaximum(self, max): + QScrollBar.setMaximum(self, max) + if self._linkedScrollBar is not None: + self._linkedScrollBar.setMaximum(max) + + +class myColorButton(pg.ColorButton): + def __init__(self, parent=None, color=(128, 128, 128), padding=5): + super().__init__(parent=parent, color=color) + if isinstance(padding, (int, float)): + self.padding = (padding, padding, -padding, -padding) + else: + self.padding = padding + self._c = 225 + self._hoverDeltaC = 30 + self._alpha = 100 + self._bkgrColor = QColor(self._c, self._c, self._c, self._alpha) + self._borderColor = QColor(171, 171, 171) + self._rectBorderPen = QPen(QBrush(QColor(0, 0, 0)), 0.3) + + def paintEvent(self, event): + # QPushButton.paintEvent(self, ev) + p = QStylePainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = self.rect() + p.setBrush(QBrush(self._bkgrColor)) + p.setPen(QPen(self._borderColor)) + p.drawRoundedRect(rect, 5, 5) + # p.fillRect(self.rect(), self._bkgrColor) + rect = self.rect().adjusted(*self.padding) + ## draw white base, then texture for indicating transparency, then actual color + p.setBrush(pg.mkBrush("w")) + p.drawRect(rect) + p.setBrush(QBrush(Qt.BrushStyle.DiagCrossPattern)) + p.drawRect(rect) + p.setPen(self._rectBorderPen) + p.setBrush(pg.mkBrush(self._color)) + p.drawRect(rect) + p.end() + + def enterEvent(self, event): + c = self._c + self._hoverDeltaC + self._bkgrColor = QColor(c, c, c, self._alpha) + self.update() + + def leaveEvent(self, event): + c = self._c + self._bkgrColor = QColor(c, c, c, self._alpha) + self.update() + + +class overlayLabelsGradientWidget(pg.GradientWidget): + def __init__( + self, + imageItem, + selectActionGroup, + segmEndname, + parent=None, + orientation="right", + ): + pg.GradientWidget.__init__(self, parent=parent, orientation=orientation) + + self.imageItem = imageItem + self.selectActionGroup = selectActionGroup + + for action in self.menu.actions(): + if action.text() == "HSV": + HSV_action = action + elif action.text() == "RGB": + RGB_ation = action + self.menu.removeAction(HSV_action) + self.menu.removeAction(RGB_ation) + + # Shuffle colors action + self.shuffleCmapAction = QAction("Randomly shuffle colormap (Shift+S)", self) + self.menu.addAction(self.shuffleCmapAction) + + # Drawing mode + drawModeMenu = QMenu("Drawing mode", self) + self.drawModeActionGroup = QActionGroup(self) + contoursDrawModeAction = QAction("Draw contours", drawModeMenu) + contoursDrawModeAction.setCheckable(True) + contoursDrawModeAction.setChecked(True) + contoursDrawModeAction.segmEndname = segmEndname + self.drawModeActionGroup.addAction(contoursDrawModeAction) + drawModeMenu.addAction(contoursDrawModeAction) + olDrawModeAction = QAction("Overlay labels", drawModeMenu) + olDrawModeAction.setCheckable(True) + olDrawModeAction.segmEndname = segmEndname + self.drawModeActionGroup.addAction(olDrawModeAction) + drawModeMenu.addAction(olDrawModeAction) + self.menu.addMenu(drawModeMenu) + + self.labelsAlphaMenu = self.menu.addMenu("Overlay labels alpha...") + hbox = QHBoxLayout() + self.labelsAlphaSlider = sliderWithSpinBox( + title="Alpha", title_loc="in_line", isFloat=True, normalize=True + ) + self.labelsAlphaSlider.setMaximum(100) + self.labelsAlphaSlider.setSingleStep(0.05) + self.labelsAlphaSlider.setValue(0.3) + hbox.addWidget(self.labelsAlphaSlider) + widget = QWidget() + widget.setLayout(hbox) + act = QWidgetAction(self) + act.setDefaultWidget(widget) + self.labelsAlphaMenu.addSeparator() + self.labelsAlphaMenu.addAction(act) + + self.menu.addSeparator() + self.menu.addSection("Select segm. file to adjust:") + for action in selectActionGroup.actions(): + self.menu.addAction(action) + + self.item.loadPreset("viridis") + self.updateImageLut(None) + self.updateImageOpacity(0.3) + + # Connect events + self.sigGradientChangeFinished.connect(self.updateImageLut) + self.labelsAlphaSlider.valueChanged.connect(self.updateImageOpacity) + self.shuffleCmapAction.triggered.connect(self.shuffleCmap) + + def shuffleCmap(self): + lut = self.imageItem.lut + np.random.shuffle(lut) + lut[0] = [0, 0, 0, 0] + self.imageItem.setLookupTable(lut) + self.imageItem.update() + + def updateImageLut(self, gradientItem): + lut = np.zeros((255, 4), dtype=np.uint8) + lut[:, -1] = 255 + lut[:, :-1] = self.item.colorMap().getLookupTable(0, 1, 255) + np.random.shuffle(lut) + lut[0] = [0, 0, 0, 0] + self.imageItem.setLookupTable(lut) + self.imageItem.setLevels([0, 255]) + + def updateImageOpacity(self, value): + self.imageItem.setOpacity(value) + + +class labelsGradientWidget(pg.GradientWidget): + sigShowRightImgToggled = Signal(bool) + sigShowLabelsImgToggled = Signal(bool) + sigShowNextFrameToggled = Signal(bool) + + def __init__(self, *args, parent=None, orientation="right", **kargs): + pg.GradientEditorItem = BaseGradientEditorItemLabels + + pg.GradientWidget.__init__( + self, *args, parent=parent, orientation=orientation, **kargs + ) + + self._parent = parent + self.name = "labels" + + for action in self.menu.actions(): + if action.text() == "HSV": + HSV_action = action + elif action.text() == "RGB": + RGB_ation = action + self.menu.removeAction(HSV_action) + self.menu.removeAction(RGB_ation) + + # Add custom colormap action + self.customCmapsMenu = self.menu.addMenu("Custom colormaps") + self.customCmapsMenu.aboutToShow.connect(self.onShowCustomCmapsMenu) + self.customCmapsMenu.triggered.connect(self.customCmapsMenuTriggered) + + self.saveColormapAction = QAction("Save current colormap...", self) + self.menu.addAction(self.saveColormapAction) + self.saveColormapAction.triggered.connect(self.saveColormap) + + self.addCustomGradients() + + # Background color button + hbox = QHBoxLayout() + hbox.addWidget(QLabel("Background color: ")) + self.colorButton = myColorButton(color=(25, 25, 25)) + hbox.addStretch(1) + hbox.addWidget(self.colorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + act.setDefaultWidget(widget) + act.triggered.connect(self.colorButton.click) + self.menu.addAction(act) + + # Font size menu action + self.fontSizeMenu = QMenu("Text font size", self) + self.menu.addMenu(self.fontSizeMenu) + + # IDs color button + hbox = QHBoxLayout() + hbox.addWidget(QLabel("Text color: ")) + self.textColorButton = myColorButton(color=(25, 25, 25)) + hbox.addStretch(1) + hbox.addWidget(self.textColorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + act.setDefaultWidget(widget) + act.triggered.connect(self.textColorButton.click) + self.menu.addAction(act) + self.menu.addSeparator() + + # Shuffle colors action + self.shuffleCmapAction = QAction("Randomly shuffle colormap (Shift+S)", self) + self.menu.addAction(self.shuffleCmapAction) + + self.greedyShuffleCmapAction = QAction( + "Greedily shuffle colormap (Alt+Shift+S)", self + ) + self.menu.addAction(self.greedyShuffleCmapAction) + + self.permanentGreedyCmapAction = QAction("Always use greedy colormap", self) + self.permanentGreedyCmapAction.setCheckable(True) + self.menu.addAction(self.permanentGreedyCmapAction) + + # Invert bw action + self.invertBwAction = QAction("Invert black/white", self) + self.invertBwAction.setCheckable(True) + self.menu.addAction(self.invertBwAction) + + # Show labels action + self.showLabelsImgAction = QAction("Show segmentation image", self) + self.showLabelsImgAction.setCheckable(True) + self.menu.addAction(self.showLabelsImgAction) + + # Show right image action + self.showRightImgAction = QAction("Show duplicated left image", self) + self.showRightImgAction.setCheckable(True) + self.menu.addAction(self.showRightImgAction) + + # Show next frame action + self.showNextFrameAction = QAction("Show next frame", self) + self.showNextFrameAction.setCheckable(True) + self.menu.addAction(self.showNextFrameAction) + + # Default settings + self.defaultSettingsAction = QAction("Restore default settings...", self) + self.menu.addAction(self.defaultSettingsAction) + + self.menu.addSeparator() + + self.showRightImgAction.toggled.connect(self.showRightImageToggled) + self.showLabelsImgAction.toggled.connect(self.showLabelsImageToggled) + self.showNextFrameAction.toggled.connect(self.showNextFrameToggled) + + def onShowCustomCmapsMenu(self): + self.customCmapsMenu.show() + + def customCmapsMenuTriggered(self, action): + cmap = action.cmap + self.item.colorMapMenuClicked(cmap) + self.item.showTicks(True) + + def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): + currentState = self.item.saveState() + self.originalLength = self.item.length + self.item.length = 100 + if restore: + self.item.restoreState(gradient_ticks) + gradient = self.item.getGradient() + action = CustomGradientMenuAction(gradient, gradient_name, self.item) + # action.triggered.connect(self.item.contextMenuClicked) + action.delButton.clicked.connect(self.removeCustomGradient) + action.cmap = colors.pg_ticks_to_colormap(gradient_ticks["ticks"]) + # self.item.menu.insertAction(self.saveColormapAction, action) + self.customCmapsMenu.addAction(action) + self.item.length = self.originalLength + self.item.restoreState(currentState) + GradientsLabels[gradient_name] = gradient_ticks + + def removeCustomGradient(self): + button = self.sender() + action = button.action + self.customCmapsMenu.removeAction(action) + cp = config.ConfigParser() + cp.read(custom_cmaps_filepath) + cp.remove_section(f"labels.{action.name}") + with open(custom_cmaps_filepath, mode="w") as file: + cp.write(file) + + def addCustomGradients(self): + try: + CustomGradients = getCustomGradients(name="labels") + if not CustomGradients: + return + for gradient_name, gradient_ticks in CustomGradients.items(): + self.addCustomGradient(gradient_name, gradient_ticks) + except Exception as e: + printl(traceback.format_exc()) + pass + + def _askNameColormap(self): + inputWin = apps.QInput(parent=self._parent, title="Colormap name") + inputWin.askText("Insert a name for the colormap: ", allowEmpty=False) + if inputWin.cancel: + return + cmapName = inputWin.answer + return cmapName + + def saveColormap(self): + cmapName = self._askNameColormap() + if cmapName is None: + return + + cp = config.ConfigParser() + if os.path.exists(custom_cmaps_filepath): + cp.read(custom_cmaps_filepath) + + SECTION = f"{self.name}.{cmapName}" + cp[SECTION] = {} + + state = self.item.saveState() + for key, value in state.items(): + if key != "ticks": + continue + for t, tick in enumerate(value): + pos, rgb = tick + rgb = ",".join([str(c) for c in rgb]) + val = f"{pos},{rgb}" + cp[SECTION][f"tick_{t}_pos_rgb"] = val + + with open(custom_cmaps_filepath, mode="w") as file: + cp.write(file) + + self.addCustomGradient(cmapName, state, restore=False) + + def isRightImageVisible(self): + return ( + self.showLabelsImgAction.isChecked() or self.showNextFrameAction.isChecked() + ) + + def showRightImageToggled(self, checked): + if checked and self.isRightImageVisible(): + # Hide the right labels image before showing right image + self.showLabelsImgAction.setChecked(False) + self.showNextFrameAction.setChecked(False) + self.sigShowLabelsImgToggled.emit(False) + self.sigShowNextFrameToggled.emit(checked) + self.sigShowRightImgToggled.emit(checked) + + def showLabelsImageToggled(self, checked): + if checked and self.isRightImageVisible(): + # Hide the right image before showing labels image + self.showRightImgAction.setChecked(False) + self.showNextFrameAction.setChecked(False) + self.sigShowRightImgToggled.emit(False) + self.sigShowNextFrameToggled.emit(False) + self.sigShowLabelsImgToggled.emit(checked) + + def showNextFrameToggled(self, checked): + if checked and self.isRightImageVisible(): + # Hide the right image before showing labels image + self.showRightImgAction.setChecked(False) + self.showLabelsImgAction.setChecked(False) + self.sigShowRightImgToggled.emit(False) + self.sigShowLabelsImgToggled.emit(False) + self.sigShowNextFrameToggled.emit(checked) + + def saveState(self, df): + # remove previous state + df = df[~df.index.str.contains("lab_cmap")].copy() + + state = self.item.saveState() + for key, value in state.items(): + if key == "ticks": + for t, tick in enumerate(value): + pos, rgb = tick + df.at[f"lab_cmap_tick{t}_rgb", "value"] = rgb + df.at[f"lab_cmap_tick{t}_pos", "value"] = pos + else: + if isinstance(value, bool): + value = "Yes" if value else "No" + df.at[f"lab_cmap_{key}", "value"] = value + return df + + def restoreState(self, df, loadCmap=True): + # Insert background color + if "labels_bkgrColor" in df.index: + rgbString = df.at["labels_bkgrColor", "value"] + r, g, b = colors.rgb_str_to_values(rgbString) + self.colorButton.setColor((r, g, b)) + + if "labels_text_color" in df.index: + rgbString = df.at["labels_text_color", "value"] + r, g, b = colors.rgb_str_to_values(rgbString) + self.textColorButton.setColor((r, g, b)) + else: + self.textColorButton.setColor((255, 0, 0)) + + checked = df.at["is_bw_inverted", "value"] == "Yes" + self.invertBwAction.setChecked(checked) + + if not loadCmap: + return + + state = {"mode": "rgb", "ticksVisible": True, "ticks": []} + ticks_pos = {} + ticks_rgb = {} + stateFound = False + for setting, value in df.itertuples(): + idx = setting.find("lab_cmap_") + if idx == -1: + continue + + stateFound = True + m = re.findall(r"tick(\d+)_(\w+)", setting) + if m: + tick_idx, tick_type = m[0] + if tick_type == "pos": + ticks_pos[int(tick_idx)] = float(value) + elif tick_type == "rgb": + ticks_rgb[int(tick_idx)] = colors.rgba_str_to_values(value) + else: + key = setting[9:] + if value == "Yes": + value = True + elif value == "No": + value = False + state[key] = value + + if stateFound: + ticks = [(0, 0)] * len(ticks_pos) + for idx, val in ticks_pos.items(): + pos = val + rgb = ticks_rgb[idx] + ticks[idx] = (pos, rgb) + + state["ticks"] = ticks + self.item.restoreState(state) + else: + self.item.loadPreset("viridis") + + return stateFound + + def showMenu(self, ev): + try: + # Convert QPointF to QPoint + self.menu.popup(ev.screenPos().toPoint()) + except AttributeError: + self.menu.popup(ev.screenPos()) + + +class MainPlotItem(pg.PlotItem): + def __init__( + self, + parent=None, + name=None, + labels=None, + title=None, + viewBox=None, + axisItems=None, + enableMenu=True, + showWelcomeText=False, + **kargs, + ): + super().__init__( + parent, name, labels, title, viewBox, axisItems, enableMenu, **kargs + ) + # Overwrite zoom out button behaviour to disable autoRange after + # clicking it. + # If autorange is enabled, it is called everytime the brush or eraser + # scatter plot items touches the border causing flickering + self.disableAutoRange() + self.autoBtn.mode = "manual" + if showWelcomeText: + self.infoTextItem = pg.TextItem() + self.addItem(self.infoTextItem) + html_filepath = os.path.join(html_path, "gui_welcome.html") + with open(html_filepath) as html_file: + htmlText = html_file.read() + self.infoTextItem.setHtml(htmlText) + self.infoTextItem.setPos(0, 0) + + self.delRoiItems = {} + self.highlightingRectItems = None + self._baseImageItem = None + self._imageItems = [] + self.highlightingRectItemsColor = None + + def addHighlightingRectItems(self, color=None): + self.highlightingRectItems = { + "left": RectItem(QRectF()), + "right": RectItem(QRectF()), + "top": RectItem(QRectF()), + "bottom": RectItem(QRectF()), + } + for rect in self.highlightingRectItems.values(): + self.addItem(rect) + + if color is None: + return + + self.setHighlightingRectItemsColor(color) + + def setHighlightingRectItemsColor(self, color): + if color == self.highlightingRectItemsColor: + return + + for item in self.highlightingRectItems.values(): + item.setColor(color) + + self.highlightingRectItemsColor = color + + def addBaseImageItem(self, baseImageItem): + self._baseImageItem = baseImageItem + self._imageItems.append(baseImageItem) + self.addItem(baseImageItem) + + def addImageItem(self, imageItem): + self._imageItems.append(imageItem) + self.addItem(imageItem) + + def setHighlighted(self, highlighted, color=None): + if color is None: + color = self.highlightingRectItemsColor + + if color is None: + color = "green" + + if self.highlightingRectItems is None: + self.addHighlightingRectItems(color=color) + + if not highlighted: + for rect in self.highlightingRectItems.values(): + rect.setQRect(QRectF()) + return + + self.setHighlightingRectItemsColor(color) + + ((xmin, xmax), (ymin, ymax)) = self.viewRange() + xmin = xmin if xmin >= 0 else 0 + ymin = ymin if ymin >= 0 else 0 + if self._baseImageItem is not None: + Y, X = self._baseImageItem.image.shape[:2] + xmax = min(xmax, X) + ymax = min(ymax, Y) + + w = xmax - xmin + h = ymax - ymin + + bs = round(((w + h) / 2) * 0.02) + if bs < 1: + bs = 1 + + x0 = xmin + x1 = xmin + bs + x2 = xmax - bs + x3 = xmax + + y0 = ymin + y1 = ymin + bs + y2 = ymax - bs + y3 = ymax + + self.highlightingRectItems["left"].setRect(x0, y0, bs, y3 - y0) + self.highlightingRectItems["top"].setRect(x1, y0, x3 - x1, bs) + self.highlightingRectItems["right"].setRect(x2, y1, bs, y3 - y1) + self.highlightingRectItems["bottom"].setRect(x1, y2, x2 - x1, bs) + self.update() + + def clear(self): + super().clear() + + self.delRoiItems = {} + self.highlightingRectItems = None + self._baseImageItem = None + self._imageItems = [] + self.highlightingRectItemsColor = None + + try: + self.removeItem(self.infoTextItem) + except Exception as e: + pass + + def autoBtnClicked(self): + self.vb.autoRange() + self.autoBtn.hide() + + def addDelRoiItem(self, roiItem, key): + if self.isDelRoiItemPresent(roiItem): + return + + self.delRoiItems[key] = roiItem + roiItem.key = key + self.addItem(roiItem) + + def removeDelRoiItem(self, roiItem): + key = roiItem.key + self.delRoiItems.pop(key, None) + try: + self.removeItem(roiItem) + except Exception as err: + return + + def isDelRoiItemPresent(self, roiItem): + try: + key = roiItem.key + except AttributeError as e: + return False + + try: + roi = self.delRoiItems[key] + except Exception as err: + return False + + return True + + def viewRange(self, mask_img=None): + if mask_img is None: + return super().viewRange() + + mask_rp = skimage.measure.regionprops(skimage.measure.label(mask_img)) + if not mask_rp: + return super().viewRange() + + mask_obj = mask_rp[0] + ymin, xmin, ymax, xmax = mask_obj.bbox + return (xmin, xmax), (ymin, ymax) + + +class sliderWithSpinBox(QWidget): + sigValueChange = Signal(object) + valueChanged = Signal(object) + editingFinished = Signal() + + def __init__(self, *args, **kwargs): + super().__init__(*args) + + layout = QGridLayout() + + title = kwargs.get("title") + row = 0 + col = 0 + if title is not None: + titleLabel = QLabel(self) + titleLabel.setText(title) + loc = kwargs.get("title_loc", "top") + if loc == "top": + layout.addWidget(titleLabel, 0, col, alignment=Qt.AlignLeft) + elif loc == "in_line": + row = -1 + col = 1 + layout.addWidget(titleLabel, 0, 0, alignment=Qt.AlignLeft) + layout.setColumnStretch(0, 0) + + self._normalize = False + normalize = kwargs.get("normalize") + if normalize is not None and normalize: + self._normalize = True + self._isFloat = True + + self._isFloat = False + isFloat = kwargs.get("isFloat") + if isFloat is not None and isFloat: + self._isFloat = True + + self.slider = QSlider(Qt.Horizontal, self) + + if self._normalize or self._isFloat: + self.spinBox = DoubleSpinBox(self) + else: + self.spinBox = SpinBox(self) + self.spinBox.setAlignment(Qt.AlignCenter) + self.spinBox.setMaximum(2**31 - 1) + + maximum_on_label = kwargs.get("maximum_on_label") + spinbox_loc = kwargs.get("spinbox_loc", "right") + if spinbox_loc == "right": + spinbox_col = col + 1 + slider_col = col + if maximum_on_label is not None: + maximum_on_label_col = spinbox_col + 1 + elif spinbox_loc == "left": + spinbox_col = col + slider_col = col + 1 + if maximum_on_label is not None: + maximum_on_label_col = spinbox_col + 1 + slider_col += 1 + + if maximum_on_label is not None: + self.labelMaximum = QLabel() + layout.addWidget(self.labelMaximum, row + 1, maximum_on_label_col) + layout.addWidget(self.slider, row + 1, slider_col) + layout.addWidget(self.spinBox, row + 1, spinbox_col) + + if title is not None: + layout.setRowStretch(0, 1) + layout.setRowStretch(row + 1, 1) + layout.setColumnStretch(slider_col, 6) + layout.setColumnStretch(spinbox_col, 1) + + self._layout = layout + self.lastCol = col + 1 + self.sliderCol = slider_col + + self.slider.valueChanged.connect(self.sliderValueChanged) + self.slider.sliderReleased.connect(self.onEditingFinished) + self.spinBox.valueChanged.connect(self.spinboxValueChanged) + self.spinBox.editingFinished.connect(self.onEditingFinished) + + layout.setContentsMargins(5, 0, 5, 0) + + self.setLayout(layout) + + if maximum_on_label is not None: + self.setMaximum(maximum_on_label) + self.labelMaximum.setText(f"/{maximum_on_label}") + + def onEditingFinished(self): + self.editingFinished.emit() + + def maximum(self): + return self.slider.maximum() + + def minimum(self): + return self.slider.minimum() + + def setValue(self, value, emitSignal=False): + valueInt = value + if self._normalize: + valueInt = int(value * self.slider.maximum()) + elif self._isFloat: + valueInt = int(value) + + self.spinBox.valueChanged.disconnect() + self.spinBox.setValue(value) + self.spinBox.valueChanged.connect(self.spinboxValueChanged) + + self.slider.valueChanged.disconnect() + if valueInt > self.slider.maximum(): + self.slider.setMaximum(valueInt) + self.slider.setValue(valueInt) + self.slider.valueChanged.connect(self.sliderValueChanged) + + if emitSignal: + self.sigValueChange.emit(self.value()) + self.valueChanged.emit(self.value()) + + def setMaximum(self, max, including_spinbox=False): + self.slider.setMaximum(max) + if including_spinbox: + self.spinBox.setMaximum(max) + + def setSingleStep(self, step): + self.spinBox.setSingleStep(step) + + def setMinimum(self, min, including_spinbox=False): + self.slider.setMinimum(min) + if including_spinbox: + self.spinBox.setMinimum(min) + + def setSingleStep(self, step): + self.spinBox.setSingleStep(step) + + def setDecimals(self, decimals): + self.spinBox.setDecimals(decimals) + + def setTickPosition(self, position): + self.slider.setTickPosition(position) + + def setTickInterval(self, interval): + self.slider.setTickInterval(interval) + + def sliderValueChanged(self, val): + self.spinBox.valueChanged.disconnect() + if self._normalize: + valF = val / self.slider.maximum() + self.spinBox.setValue(valF) + else: + self.spinBox.setValue(val) + self.spinBox.valueChanged.connect(self.spinboxValueChanged) + self.sigValueChange.emit(self.value()) + self.valueChanged.emit(self.value()) + + def spinboxValueChanged(self, val): + if self._normalize: + val = int(val * self.slider.maximum()) + elif self._isFloat: + val = int(val) + + self.slider.valueChanged.disconnect() + self.slider.setValue(val) + self.slider.valueChanged.connect(self.sliderValueChanged) + self.sigValueChange.emit(self.value()) + self.valueChanged.emit(self.value()) + + def value(self): + return self.spinBox.value() + + def setDisabled(self, disabled) -> None: + self.slider.setDisabled(disabled) + self.spinBox.setDisabled(disabled) + + +class BaseImageItem(pg.ImageItem): + def __init__(self, image=None, **kargs): + self.minMaxValuesMapper = None + self.minMaxValuesMapperPreproc = None + self.minMaxValuesMapperCombined = None + self.minMaxValuesMapperEqualized = None + self.pos_i = 0 + self.z = 0 + self.frame_i = 0 + self.usePreprocessed = False + self.useEqualized = False + self.useCombined = False + self._isRgba = False + + super().__init__(image, **kargs) + self.autoLevelsEnabled = None + + def isRgba(self): + return self._isRgba + + def setEnableAutoLevels(self, enabled: bool): + self.autoLevelsEnabled = enabled + + def setImage(self, image=None, autoLevels=None, **kargs): + if autoLevels is None: + autoLevels = self.autoLevelsEnabled + + if image is not None and image.ndim == 3 and image.shape[2] in (3, 4): + self._isRgba = True + + super().setImage(image, autoLevels=autoLevels, **kargs) + + def preComputedMinMaxValues(self, data: List["load.loadData"]): + self.minMaxValuesMapper = {} + for pos_i, posData in enumerate(data): + img_data = posData.img_data + requires_time_dim = posData.img_data.ndim == 2 or ( + posData.img_data.ndim == 3 and posData.SizeZ > 1 + ) + if requires_time_dim: + img_data = (img_data,) + + for frame_i, image in enumerate(img_data): + if image.ndim == 3: + self._updateMinMaxValuesProjections( + image, pos_i, frame_i, self.minMaxValuesMapper + ) + + if image.ndim == 2: + image = (image,) + + for z, img in enumerate(image): + self.minMaxValuesMapper[(pos_i, frame_i, z)] = ( + np.nanmin(img), + np.nanmax(img), + ) + + def updateMinMaxValuesEqualizedData( + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + z_slice: Union[int, str], + ): + if self.minMaxValuesMapperEqualized is None: + self.minMaxValuesMapperEqualized = {} + + posData = data[pos_i] + img = posData.equalized_img_data[frame_i][z_slice] + key = (pos_i, frame_i, z_slice) + self.minMaxValuesMapperEqualized[key] = (np.nanmin(img), np.nanmax(img)) + + def updateMinMaxValuesEqualizedDataProjections( + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + ): + posData = data[pos_i] + eq_zstack = posData.equalized_img_data[frame_i] + + self._updateMinMaxValuesProjections( + eq_zstack, pos_i, frame_i, self.minMaxValuesMapperEqualized + ) + + def _updateMinMaxValuesProjections(self, zstack, pos_i, frame_i, mapper): + max_proj = zstack.max(axis=0) + key = (pos_i, frame_i, "max z-projection") + mapper[key] = np.nanmin(max_proj), np.nanmax(max_proj) + + mean_proj = zstack.mean(axis=0) + key = (pos_i, frame_i, "mean z-projection") + mapper[key] = np.nanmin(mean_proj), np.nanmax(mean_proj) + + median_proj = np.median(zstack, axis=0) + key = (pos_i, frame_i, "median z-proj.") + mapper[key] = np.nanmin(median_proj), np.nanmax(median_proj) + + def updateMinMaxValuesPreprocessedData( + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + z_slice: Union[int, str], + ): + if self.minMaxValuesMapperPreproc is None: + self.minMaxValuesMapperPreproc = {} + + posData = data[pos_i] + img = posData.preproc_img_data[frame_i][z_slice] + key = (pos_i, frame_i, z_slice) + self.minMaxValuesMapperPreproc[key] = (np.nanmin(img), np.nanmax(img)) + + def updateMinMaxValuesPreprocessedProjections( + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + ): + posData = data[pos_i] + zstack = posData.preproc_img_data[frame_i] + + self._updateMinMaxValuesProjections( + zstack, pos_i, frame_i, self.minMaxValuesMapperPreproc + ) + + def updateMinMaxValuesCombinedData( + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + z_slice: Union[int, str], + ): + if self.minMaxValuesMapperCombined is None: + self.minMaxValuesMapperCombined = {} + + posData = data[pos_i] + img = posData.combine_img_data[frame_i][z_slice] + key = (pos_i, frame_i, z_slice) + self.minMaxValuesMapperCombined[key] = (np.nanmin(img), np.nanmax(img)) + + def updateMinMaxValuesCombinedDataProjections( + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + ): + posData = data[pos_i] + zstack = posData.combine_img_data[frame_i] + + self._updateMinMaxValuesProjections( + zstack, pos_i, frame_i, self.minMaxValuesMapperCombined + ) + + def setCurrentPosIndex(self, pos_i: int): + self.pos_i = pos_i + + def setCurrentFrameIndex(self, frame_i: int): + self.frame_i = frame_i + + def setCurrentZsliceIndex(self, z: int): + self.z = z + + def quickMinMax(self, targetSize=1e6): + if self.isRgba(): + return super().quickMinMax(targetSize=targetSize) + + if self.usePreprocessed and self.minMaxValuesMapperPreproc is not None: + minMaxValuesMapper = self.minMaxValuesMapperPreproc + elif self.useCombined and self.minMaxValuesMapperCombined is not None: + minMaxValuesMapper = self.minMaxValuesMapperCombined + elif self.useEqualized and self.minMaxValuesMapperEqualized is not None: + minMaxValuesMapper = self.minMaxValuesMapperEqualized + else: + minMaxValuesMapper = self.minMaxValuesMapper + + if minMaxValuesMapper is None: + return super().quickMinMax(targetSize=targetSize) + + try: + key = (self.pos_i, self.frame_i, self.z) + levels = minMaxValuesMapper[key] + return levels + except Exception as err: + pass + + try: + key = (self.pos_i, self.frame_i, self.z) + levels = self.minMaxValuesMapper[key] + return levels + except Exception as err: + return super().quickMinMax(targetSize=targetSize) + + def setOpacity(self, value, **kwargs): + if value == 0: + value = 0.001 + + if value == 1: + value = 0.999 + + super().setOpacity(value) + + +class BaseLabelsImageItem(pg.ImageItem): + def __init__(self, image=None, **kargs): + super().__init__(image, **kargs) + + def setImage(self, image=None, **kwargs): + if image is None: + return + autoLevels = kwargs.get("autoLevels") + if autoLevels is None: + kwargs["autoLevels"] = False + super().setImage(image, **kwargs) + + +class OverlayImageItem(pg.ImageItem): + def __init__(self, image=None, **kargs): + super().__init__(image, **kargs) + self.autoLevelsEnabled = None + + def setEnableAutoLevels(self, enabled: bool): + self.autoLevelsEnabled = enabled + + def setImage(self, image=None, autoLevels=None, **kargs): + if autoLevels is None: + autoLevels = self.autoLevelsEnabled + + super().setImage(image, autoLevels=autoLevels, **kargs) + + def setOpacity(self, value, **kwargs): + if value == 0: + value = 0.001 + + if value == 1: + value = 0.999 + + super().setOpacity(value) + + +class ParentImageItem(BaseImageItem): + def __init__( + self, + image=None, + linkedImageItem=None, + activatingActions=None, + debug=False, + **kargs, + ): + super().__init__(image, **kargs) + self.linkedImageItem = linkedImageItem + self.activatingActions = activatingActions + self.debug = debug + self._forceDoNotUpdateLinked = False + self.autoLevelsEnabled = None + + def clear(self): + if self.linkedImageItem is not None: + self.linkedImageItem.clear() + return super().clear() + + def isLinkedImageItemActive(self): + if self._forceDoNotUpdateLinked: + return False + + if self.linkedImageItem is None: + return False + + if self.activatingActions is None: + return False + + for action in self.activatingActions: + if action.isChecked(): + return True + + return False + + def setEnableAutoLevels(self, enabled: bool): + self.autoLevelsEnabled = enabled + + def setUsePreprocessed(self, usePreprocessed): + self.usePreprocessed = usePreprocessed + if self.linkedImageItem is None: + return + + self.linkedImageItem.usePreprocessed = usePreprocessed + + def setUseCombined(self, useCombined): + self.useCombined = useCombined + if self.linkedImageItem is None: + return + + self.linkedImageItem.useCombined = useCombined + + def preComputedMinMaxValues(self, *args, **kwargs): + super().preComputedMinMaxValues(*args, **kwargs) + if self.linkedImageItem is None: + return + + self.linkedImageItem.minMaxValuesMapper = self.minMaxValuesMapper + + def updateMinMaxValuesPreprocessedData(self, *args, **kwargs): + super().updateMinMaxValuesPreprocessedData(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.minMaxValuesMapper = self.minMaxValuesMapper + + def updateMinMaxValuesCombinedData(self, *args, **kwargs): + super().updateMinMaxValuesCombinedData(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.minMaxValuesMapperCombined = ( + self.minMaxValuesMapperCombined + ) + + def updateMinMaxValuesCombinedDataProjections(self, *args, **kwargs): + super().updateMinMaxValuesCombinedDataProjections(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.minMaxValuesMapperCombined = ( + self.minMaxValuesMapperCombined + ) + + def updateMinMaxValuesEqualizedDataProjections(self, *args, **kwargs): + super().updateMinMaxValuesEqualizedDataProjections(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.minMaxValuesMapperEqualized = ( + self.minMaxValuesMapperEqualized + ) + + def updateMinMaxValuesEqualizedData(self, *args, **kwargs): + super().updateMinMaxValuesEqualizedData(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.minMaxValuesMapperEqualized = ( + self.minMaxValuesMapperEqualized + ) + + def setCurrentPosIndex(self, *args, **kwargs): + super().setCurrentPosIndex(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.pos_i = self.pos_i + + def setCurrentFrameIndex(self, *args, **kwargs): + super().setCurrentFrameIndex(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.frame_i = self.frame_i + 1 + + def setCurrentZsliceIndex(self, *args, **kwargs): + super().setCurrentZsliceIndex(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.z = self.z + + def setImage( + self, + image=None, + autoLevels=None, + next_frame_image=None, + scrollbar_value=None, + force_set_linked=False, + **kargs, + ): + if autoLevels is None: + autoLevels = self.autoLevelsEnabled + + super().setImage(image, autoLevels=autoLevels, **kargs) + + if self.linkedImageItem is None: + return + + if not self.isLinkedImageItemActive() and not force_set_linked: + return + + if next_frame_image is not None: + self.linkedImageItem.setImage( + next_frame_image, scrollbar_value=scrollbar_value, autoLevels=autoLevels + ) + elif image is not None: + self.linkedImageItem.setImage(image) + + def updateImage(self, *args, **kargs): + if self.isLinkedImageItemActive(): + self.linkedImageItem.image = self.image + self.linkedImageItem.updateImage(*args, **kargs) + return super().updateImage(*args, **kargs) + + def setOpacity(self, value, applyToLinked=True): + super().setOpacity(value) + if not applyToLinked: + return + + if self.linkedImageItem is None: + return + + self.linkedImageItem.setOpacity(value) + + def setLookupTable(self, lut): + super().setLookupTable(lut) + + +class ChildImageItem(BaseImageItem): + def __init__(self, *args, linkedScrollbar=None, **kwargs): + BaseImageItem.__init__(self, *args, **kwargs) + self.linkedScrollbar = linkedScrollbar + + def setImage(self, img=None, z=None, scrollbar_value=None, **kargs): + autoLevels = kargs.get("autoLevels") + if autoLevels is None: + kargs["autoLevels"] = False + + if img is None: + BaseImageItem.setImage(self, img, **kargs) + return + + if img.ndim == 3 and img.shape[-1] > 4 and z is not None: + BaseImageItem.setImage(self, img[z], **kargs) + else: + BaseImageItem.setImage(self, img, **kargs) + + if self.linkedScrollbar is None: + return + + if not self.linkedScrollbar.isEnabled(): + return + + if scrollbar_value is None: + return + + self.linkedScrollbar.setValueNoSignal(scrollbar_value) + + +class labImageItem(pg.ImageItem): + def __init__(self, *args, **kwargs): + pg.ImageItem.__init__(self, *args, **kwargs) + + def setImage(self, img=None, z=None, **kargs): + autoLevels = kargs.get("autoLevels") + if autoLevels is None: + kargs["autoLevels"] = False + + if img is None: + pg.ImageItem.setImage(self, img, **kargs) + return + + if img.ndim == 3 and img.shape[-1] > 4 and z is not None: + pg.ImageItem.setImage(self, img[z], **kargs) + else: + pg.ImageItem.setImage(self, img, **kargs) + + +class GhostContourItem(pg.PlotDataItem): + def __init__( + self, ParentPlotItem, penColor=(245, 184, 0, 100), textColor=(245, 184, 0) + ): + super().__init__() + # Yellow pen + self.setPen(pg.mkPen(width=2, color=penColor)) + self.label = myLabelItem() + self.label.setAttr("bold", True) + self.label.setAttr("color", textColor) + self._ParentPlotItem = ParentPlotItem + + def addToPlotItem(self): + self._ParentPlotItem.addItem(self) + self._ParentPlotItem.addItem(self.label) + + def removeFromPlotItem(self): + self._ParentPlotItem.removeItem(self.label) + self._ParentPlotItem.removeItem(self) + + def setData( + self, xx=None, yy=None, fontSize=11, ID=0, y_cursor=None, x_cursor=None + ): + if xx is None: + xx = [] + if yy is None: + yy = [] + super().setData(xx, yy) + if not hasattr(self, "label"): + return + + if ID == 0: + self.label.setText("") + else: + self.label.setText(f"{ID}", size=fontSize) + w, h = self.label.itemRect().width(), self.label.itemRect().height() + self.label.setPos(x_cursor, y_cursor - h) + + def clear(self): + self.setData([], []) + + +class GhostMaskItem(pg.ImageItem): + def __init__(self, ParentPlotItem): + super().__init__() + self.label = myLabelItem() + self.label.setAttr("bold", True) + self.label.setAttr("color", (245, 184, 0)) + self._ParentPlotItem = ParentPlotItem + + def initImage(self, imgShape): + image = np.zeros(imgShape, dtype=np.uint32) + self.setImage(image) + + def initLookupTable(self, rgbaColor): + lut = np.zeros((2, 4), dtype=np.uint8) + lut[1, -1] = 255 + lut[1, :-1] = rgbaColor + self.setLookupTable(lut) + + def addToPlotItem(self): + self._ParentPlotItem.addItem(self) + self._ParentPlotItem.addItem(self.label) + + def removeFromPlotItem(self): + self._ParentPlotItem.removeItem(self.label) + self._ParentPlotItem.removeItem(self) + + def updateGhostImage(self, ID=0, y_cursor=None, x_cursor=None, fontSize=None): + self.setImage(self.image) + + if ID == 0: + self.label.setText("") + return + + self.label.setText(f"{ID}", size=fontSize) + w, h = self.label.itemRect().width(), self.label.itemRect().height() + self.label.item.setPos(x_cursor, y_cursor - h) + + def clear(self): + if hasattr(self, "label"): + self.label.setText("") + if self.image is None: + return + self.image[:] = 0 + self.setImage(self.image) + + +class ScrollBarWithNumericControl(QWidget): + sigValueChanged = Signal(int) + sigMaxProjToggled = Signal(bool, object) + + def __init__( + self, + orientation=Qt.Horizontal, + add_max_proj_button=False, + parent=None, + labelText="", + ) -> None: + super().__init__(parent) + + self._slot = None + + layout = QHBoxLayout() + self.scrollbar = QScrollBar(orientation, self) + self.spinbox = QSpinBox(self) + self.maxLabel = QLabel(self) + idx = 0 + if labelText: + layout.addWidget(QLabel(labelText)) + layout.setStretch(idx, 0) + idx += 1 + + layout.addWidget(self.spinbox) + layout.setStretch(idx, 0) + idx += 1 + + layout.addWidget(self.maxLabel) + layout.setStretch(idx, 0) + idx += 1 + + layout.addWidget(self.scrollbar) + layout.setStretch(idx, 1) + idx += 1 + + if add_max_proj_button: + self.maxProjCheckbox = QCheckBox("MAX") + self.scrollbar.maxProjCheckbox = self.maxProjCheckbox + layout.addWidget(self.maxProjCheckbox) + layout.setStretch(idx, 0) + + layout.setContentsMargins(5, 0, 5, 0) + + self.setLayout(layout) + + self.spinbox.valueChanged.connect(self.spinboxValueChanged) + self.scrollbar.valueChanged.connect(self.scrollbarValueChanged) + + if add_max_proj_button: + self.maxProjCheckbox.toggled.connect(self.maxProjToggled) + + def connectValueChanged(self, slot): + self.sigValueChanged.connect(slot) + self._slot = slot + + def setValueNoSignal(self, value): + if self._slot is None: + return + self.sigValueChanged.disconnect() + self.setValue(value) + self.sigValueChanged.connect(self._slot) + + def maxProjToggled(self, checked): + self.scrollbar.setDisabled(checked) + self.sigMaxProjToggled.emit(checked, self) + + def showEvent(self, event) -> None: + super().showEvent(event) + + self.scrollbar.setMinimumHeight(self.spinbox.height()) + + def setMaximum(self, maximum): + self.maxLabel.setText(f"/{maximum}") + self.scrollbar.setMaximum(maximum) + self.spinbox.setMaximum(maximum) + + def setMinimum(self, minumum): + self.scrollbar.setMinimum(minumum) + self.spinbox.setMinimum(minumum) + + def spinboxValueChanged(self, value): + self.scrollbar.setValue(value) + + def scrollbarValueChanged(self, value): + self.spinbox.setValue(value) + self.sigValueChanged.emit(value) + + def setValue(self, value): + self.scrollbar.setValue(value) + + def value(self): + return self.scrollbar.value() + + def maximum(self): + return self.scrollbar.maximum() + + +class ImShowPlotItem(pg.PlotItem): + def __init__( + self, + parent=None, + name=None, + labels=None, + title=None, + viewBox=None, + axisItems=None, + enableMenu=True, + **kargs, + ): + super().__init__( + parent, name, labels, title, viewBox, axisItems, enableMenu, **kargs + ) + # Overwrite zoom out button behaviour to disable autoRange after + # clicking it. + # If autorange is enabled, it is called everytime the brush or eraser + # scatter plot items touches the border causing flickering + self.disableAutoRange() + self.autoBtn.mode = "manual" + self.invertY(True) + self.setAspectLocked(True) + self.addImageItem(kargs.get("imageItem")) + + self._selected = False + self.selectingRects = [] + + def setSelectableTitle(self, title: QGraphicsProxyWidget, **kwargs): + self.layout.removeItem(self.titleLabel) + self.layout.addItem(title, 0, 1, alignment=Qt.AlignCenter) + + def isSelected(self): + return self._selected + + def setSelected( + self, selected: bool, xlim=(-np.inf, np.inf), ylim=(-np.inf, np.inf) + ): + if selected == self._selected: + return + + if selected: + ((xmin, xmax), (ymin, ymax)) = self.viewRange() + ylim_min, ylim_max = ylim + xlim_min, xlim_max = xlim + + xmin = max(xlim_min, xmin) + xmax = min(xlim_max, xmax) + ymin = max(ylim_min, ymin) + ymax = min(ylim_max, ymax) + + w = xmax - xmin + h = ymax - ymin + + bs = round(((w + h) / 2) * 0.02) + if bs < 1: + bs = 1 + + rect_left = RectItem(QRectF(xmin, ymin, bs, h)) + rect_top = RectItem(QRectF(xmin + bs, ymin, w - bs - bs, bs)) + rect_right = RectItem(QRectF(xmax - bs, ymin, bs, h)) + rect_bottom = RectItem(QRectF(xmin + bs, ymax - bs, w - bs - bs, bs)) + self.selectingRects.append(rect_left) + self.selectingRects.append(rect_top) + self.selectingRects.append(rect_right) + self.selectingRects.append(rect_bottom) + + self.addItem(rect_left) + self.addItem(rect_top) + self.addItem(rect_right) + self.addItem(rect_bottom) + else: + for rect in self.selectingRects: + self.removeItem(rect) + self.selectingRects = [] + + self._selected = selected + + def addImageItem(self, imageItem): + self.imageItem = imageItem + if imageItem is None: + return + + self.setupContextMenu() + self.addItem(imageItem) + + def setupContextMenu(self): + shuffleCmapAction = QAction("Shuffle colormap", self.vb.menu) + shuffleCmapAction.triggered.connect(self.shuffleColormap) + self.vb.menu.addAction(shuffleCmapAction) + + self.resetCmapAction = QAction("Reset colormap", self.vb.menu) + self.resetCmapAction.triggered.connect(self.resetColormap) + self.vb.menu.addAction(self.resetCmapAction) + self.resetCmapAction.setDisabled(True) + + def shuffleColormap(self): + N = self.imageItem._numLevels + colors = self.imageItem.lut / 255 + cmap = LinearSegmentedColormap.from_list("shuffled", colors, N=N) + lut = plot.matplotlib_cmap_to_lut(cmap, n_colors=N) + if not self.resetCmapAction.isEnabled(): + self._defaultLut = lut.copy() + bkgrColor = lut[0].copy() + np.random.shuffle(lut) + lut[0] = bkgrColor + self.imageItem.setLookupTable(lut) + self.imageItem.update() + self.resetCmapAction.setDisabled(False) + + def resetColormap(self): + self.imageItem.setLookupTable(self._defaultLut) + + def autoBtnClicked(self): + self.autoRange() + + def autoRange(self): + self.vb.autoRange() + self.autoBtn.hide() + + +class _ImShowImageItem(pg.ImageItem): + sigDataHover = Signal(str) + sigHoverEvent = Signal(object, object) + sigMousePressEvent = Signal(object, object) + + def __init__(self, idx) -> None: + super().__init__() + self._idx = idx + self._cursors = [] + self._autoLevels = True + + def _getHoverImageValue(self, xdata, ydata): + try: + value = self.image[ydata, xdata] + return value + except Exception as err: + return + + def setAutoLevels(self, autoLevels): + self._autoLevels = autoLevels + + def mousePressEvent(self, event): + self.sigMousePressEvent.emit(self, event) + super().mousePressEvent(event) + + def setOtherImagesCursors(self, cursors): + self._cursors = cursors + + def clearCursors(self): + for p, cursor in enumerate(self._cursors): + if p == self._idx: + continue + + cursor.setData([], []) + + def setImage(self, *args, **kwargs): + if "autoLevels" not in kwargs: + kwargs["autoLevels"] = self._autoLevels + + super().setImage(*args, **kwargs) + if not args: + return + + if not kwargs["autoLevels"]: + return + + image = args[0] + self._imageMax = image.max() + self._imageMin = image.min() + self._numLevels = self._imageMax - self._imageMin + + def hoverEvent(self, event): + self.sigHoverEvent.emit(self, event) + + if event.isExit(): + self.clearCursors() + self.sigDataHover.emit("") + return + + x, y = event.pos() + xdata, ydata = int(x), int(y) + value = self._getHoverImageValue(xdata, ydata) + if value is None: + self.clearCursors() + self.sigDataHover.emit("") + return + + try: + self.sigDataHover.emit(f"x={xdata}, y={ydata}, {value = :.4f}") + except Exception as e: + self.sigDataHover.emit(f"x={xdata}, y={ydata}, {[val for val in value]}") + + for p, cursor in enumerate(self._cursors): + if p == self._idx: + continue + + cursor.setData([x], [y]) + + +class ImShow(QBaseWindow): + def __init__( + self, + parent=None, + link_scrollbars=True, + infer_rgb=True, + figure_title="", + selectable_images=False, + ): + super().__init__(parent=parent) + self._linkedScrollbars = link_scrollbars + self._infer_rgb = infer_rgb + self._figure_title = figure_title + self._selectable_images = True + self.selected_idx = None + + self._autoLevels = True + + self.textItems = [] + self.group_to_idx_mapper = {"": 0} + + def _getGraphicsScrollbar(self, idx, image, imageItem, maximum): + proxy = QGraphicsProxyWidget(imageItem) + scrollbar = ScrollBarWithNumericControl( + orientation=Qt.Horizontal, add_max_proj_button=True + ) + scrollbar.sigValueChanged.connect(self.OnScrollbarValueChanged) + scrollbar.sigMaxProjToggled.connect(self.onMaxProjToggled) + scrollbar.idx = idx + scrollbar.image = image + scrollbar.imageItem = imageItem + scrollbar.setMaximum(maximum) + proxy.setWidget(scrollbar) + proxy.scrollbar = scrollbar + return proxy + + def OnScrollbarValueChanged(self, value): + scrollbar = self.sender() + imageItem = scrollbar.imageItem + img = self._get2Dimg(imageItem, scrollbar.image) + imageItem.setImage(img) # , autoLevels=self._autoLevels) + + overlayLab = self._get2DlabOverlay(imageItem) + if overlayLab is not None: + imageItem.labImageItem.setImage(overlayLab, autoLevels=False) + + self.setPointsVisible(imageItem) + + self.updateIDs() + + if not self._linkedScrollbars: + return + if len(self.ImageItems) == 1: + return + + self._linkedScrollbars = False + try: + idx = scrollbar.idx + for otherImageItem in self.ImageItems: + if otherImageItem.gridPos == imageItem.gridPos: + continue + if otherImageItem.image.shape != imageItem.image.shape: + continue + for otherScrollbar in otherImageItem.ScrollBars: + if otherScrollbar.idx != idx: + continue + otherScrollbar.setValue(scrollbar.value()) + except Exception as e: + pass + finally: + self._linkedScrollbars = True + + def _get2Dimg(self, imageItem, image): + for scrollbar in imageItem.ScrollBars: + if scrollbar.maxProjCheckbox.isChecked(): + image = image.max(axis=0) + else: + image = image[scrollbar.value()] + return image + + def _get2DlabOverlay(self, imageItem): + try: + lab = imageItem.lab + except Exception as err: + return + + for scrollbar in imageItem.ScrollBars: + if scrollbar.maxProjCheckbox.isChecked(): + lab = lab.max(axis=0) + else: + lab = lab[scrollbar.value()] + + return lab + + def isObjVisible(self, obj, imageItem): + if len(obj.centroid) == 2: + return True + + z_scrollbar = imageItem.ScrollBars[-1] + if z_scrollbar.maxProjCheckbox.isChecked(): + return True + + z_slice = z_scrollbar.value() + min_z, min_y, min_x, max_z, max_y, max_x = obj.bbox + if z_slice >= min_z and z_slice < max_z: + return True + + return False + + def onMaxProjToggled(self, checked, scrollbar): + imageItem = scrollbar.imageItem + img = self._get2Dimg(imageItem, scrollbar.image) + imageItem.setImage(img) # , autoLevels=self._autoLevels) + overlayLab = self._get2DlabOverlay(imageItem) + if overlayLab is not None: + imageItem.labImageItem.setImage(overlayLab, autoLevels=False) + self.setPointsVisible(imageItem) + if not self._linkedScrollbars: + return + if len(self.ImageItems) == 1: + return + + self._linkedScrollbars = False + try: + idx = scrollbar.idx + for otherImageItem in self.ImageItems: + if otherImageItem.gridPos == imageItem.gridPos: + continue + if otherImageItem.image.shape != imageItem.image.shape: + continue + for otherScrollbar in otherImageItem.ScrollBars: + if otherScrollbar.idx != idx: + continue + otherScrollbar.maxProjCheckbox.setChecked(checked) + except Exception as e: + pass + finally: + self._linkedScrollbars = True + + self.updateIDs() + + def setPointsVisible(self, imageItem): + if not hasattr(imageItem, "pointsItems"): + return + + first_coord = imageItem.ScrollBars[0].value() + isMaxProj = imageItem.ScrollBars[0].maxProjCheckbox.isChecked() + for pointsItems in imageItem.pointsItems.values(): + for p, plotItem in enumerate(pointsItems): + plotItem.setVisible((isMaxProj) or (p == first_coord)) + + def setupStatusBar(self): + self.statusbar = self.statusBar() + self.wcLabel = QLabel(f"") + self.statusbar.addPermanentWidget(self.wcLabel) + + def setupMainLayout(self): + self._layout = QHBoxLayout() + self._container = QWidget() + self._container.setLayout(self._layout) + self.setCentralWidget(self._container) + + def setupGraphicLayout( + self, *images, hide_axes=True, max_ncols=4, color_scheme="light" + ): + self.graphicLayout = pg.GraphicsLayoutWidget() + self._colorScheme = color_scheme + + # Set a light background + if color_scheme == "light": + self.graphicLayout.setBackground((235, 235, 235)) + else: + self.graphicLayout.setBackground((30, 30, 30)) + + ncells = max_ncols * ceil(len(images) / max_ncols) + + nrows = ncells // max_ncols + nrows = nrows if nrows > 0 else 1 + ncols = max_ncols if len(images) > max_ncols else len(images) + + if color_scheme == "light": + color = "black" + else: + color = "white" + + self.titleLabel = pg.LabelItem(justify="center", color=color, size="14pt") + self.titleLabel.setText(self._figure_title) + self.graphicLayout.addItem(self.titleLabel, row=0, col=0, colspan=ncols) + start_row = 1 + + # Check if additional rows are needed for the scrollbars + max_ndim = max([image.ndim for image in images]) + if max_ndim > 4: + raise TypeError("One or more of the images have more than 4 dimensions.") + if max_ndim == 4: + rows_range = range(0, (nrows - 1) * 3 + 1, 3) + elif max_ndim == 3: + rows_range = range(0, (nrows - 1) * 2 + 1, 2) + else: + rows_range = range(nrows) + + self.PlotItems = [] + self.ImageItems = [] + self.ScrollBars = [] + i = 0 + for r in rows_range: + row = r + start_row + for col in range(ncols): + try: + image = images[i] + except IndexError: + break + plotItem = ImShowPlotItem() + if hide_axes: + plotItem.hideAxis("bottom") + plotItem.hideAxis("left") + self.graphicLayout.addItem(plotItem, row=row, col=col) + plotItem.loc = (row, col) + self.PlotItems.append(plotItem) + + imageItem = _ImShowImageItem(i) + plotItem.addImageItem(imageItem) + imageItem.plot = plotItem + imageItem.sigHoverEvent.connect(self.onImageItemHoverEvent) + imageItem.sigMousePressEvent.connect(self.onImageItemMousePressEvent) + self.ImageItems.append(imageItem) + imageItem.gridPos = (row, col) + imageItem.ScrollBars = [] + + is_rgb = image.shape[-1] == 3 and self._infer_rgb + is_rgba = image.shape[-1] == 4 and self._infer_rgb + does_not_require_scrollbars = image.ndim == 2 or ( + image.ndim == 3 and (is_rgb or is_rgba) + ) + if does_not_require_scrollbars: + i += 1 + continue + + idx_image = 3 if (is_rgb or is_rgba) else 2 + for s in range(image.ndim - idx_image): + maximum = image.shape[s] - 1 + scrollbarProxy = self._getGraphicsScrollbar( + s, image, imageItem, maximum + ) + self.graphicLayout.addItem(scrollbarProxy, row=row + s + 1, col=col) + imageItem.ScrollBars.append(scrollbarProxy.scrollbar) + + i += 1 + + self._layout.addWidget(self.graphicLayout) + + def onImageItemMousePressEvent(self, imageItem, event): + if not self._selectable_images: + return + + plotItem = imageItem.plot + if not plotItem.isSelected(): + return + + self.selected_idx = self.PlotItems.index(plotItem) + event.ignore() + self.close() + + def onImageItemHoverEvent(self, imageItem, event): + if not self._selectable_images: + return + + modifiers = QGuiApplication.keyboardModifiers() + isCtrl = modifiers == Qt.ControlModifier + plotItem = imageItem.plot + Y, X = imageItem.image.shape[:2] + plotItem.setSelected(isCtrl and not event.isExit(), xlim=(0, X), ylim=(0, Y)) + + def movePlotItem(self, title): + combobox = self.sender() + plotItem = combobox.plotItem + row, col = plotItem.loc + + otherPlotItemIdx = combobox.titles.index(title) + otherPlotItem = self.PlotItems[otherPlotItemIdx] + other_row, other_col = otherPlotItem.loc + + self.graphicLayout.removeItem(plotItem) + self.graphicLayout.removeItem(otherPlotItem) + self.graphicLayout.addItem(otherPlotItem, row=row, col=col) + self.graphicLayout.addItem(plotItem, row=other_row, col=other_col) + + combobox.blockSignals(True) + combobox.setCurrentText(combobox.default_text) + combobox.blockSignals(False) + + plotItemIdx = combobox.titles.index(combobox.default_text) + + otherPlotItem.loc = (row, col) + plotItem.loc = (other_row, other_col) + + def setupTitles(self, *titles): + for plotItem, title in zip(self.PlotItems, titles): + combobox = ComboBox() + combobox.default_text = title + combobox.titles = list(titles) + combobox.addItems(titles) + combobox.setMaximumWidth(combobox.sizeHint().width()) + combobox.setCurrentText(title) + comboboxGraphicsItem = QGraphicsProxyWidget() + comboboxGraphicsItem.setWidget(combobox) + combobox.plotItem = plotItem + plotItem.setSelectableTitle(comboboxGraphicsItem) + combobox.currentTextChanged.connect(self.movePlotItem) + + # color = 'k' if self._colorScheme == 'light' else 'w' + # for plotItem, title in zip(self.PlotItems, titles): + # plotItem.setSelectableTitle(title, color=color) + + def updateStatusBarLabel(self, text): + self.wcLabel.setText(text) + + def autoRange(self): + for plot in self.PlotItems: + plot.autoRange() + + def showImages( + self, + *images, + labels_overlays: np.ndarray | List[np.ndarray] = None, + luts=None, + labels_overlays_luts=None, + autoLevels=True, + autoLevelsOnScroll=False, + ): + from .plot import matplotlib_cmap_to_lut + + images = [np.squeeze(img) for img in images] + self.luts = luts + self._autoLevels = autoLevels + self._autoLevelsOnScroll = autoLevelsOnScroll + for image in images: + if image.ndim > 5 or image.ndim < 2: + raise TypeError( + f"Input image has {image.ndim} dimensions. " + "Only 2-D, 3-D, and 4-D images are supported" + ) + + if isinstance(labels_overlays, np.ndarray): + labels_overlays = [labels_overlays] + + if isinstance(labels_overlays_luts, np.ndarray): + labels_overlays_luts = [labels_overlays_luts] + + if ( + labels_overlays_luts is not None + and labels_overlays is not None + and (len(labels_overlays_luts) != len(labels_overlays)) + ): + raise TypeError( + f"Number of lables_overlays_luts is {len(labels_overlays_luts)}, " + f"while number of labels_overaly is {len(labels_overlays)}. " + "Pass `None` if you want to use default lut for the labels_overlays." + ) + + if labels_overlays is not None and (len(labels_overlays) != len(images)): + raise TypeError( + f"Number of images is {len(images)}, " + f"while number of labels_overaly is {len(labels_overlays)}. " + "Pass `None` if you do not need overlaid labeles." + ) + + for i, (image, imageItem) in enumerate(zip(images, self.ImageItems)): + if luts is not None: + _autoLevels = autoLevels + lut = luts[i] + if not autoLevels and lut is not None: + imageItem.setLevels([0, len(lut)]) + else: + _autoLevels = True + if lut is None: + lut = matplotlib_cmap_to_lut("viridis") + imageItem.setLookupTable(lut) + else: + _autoLevels = True + + is_rgb = image.shape[-1] == 3 and self._infer_rgb + is_rgba = image.shape[-1] == 4 and self._infer_rgb + does_not_require_scrollbars = image.ndim == 2 or ( + image.ndim == 3 and (is_rgb or is_rgba) + ) + + if does_not_require_scrollbars: + imageItem.setAutoLevels(_autoLevels) + imageItem.setImage(image) + else: + if not self._autoLevelsOnScroll and not _autoLevels: + imageItem.setAutoLevels(False) + imageItem.setLevels([image.min(), image.max()]) + for scrollbar in imageItem.ScrollBars: + scrollbar.setValue(int(scrollbar.maximum() / 2)) + + imageItem.sigDataHover.connect(self.updateStatusBarLabel) + + if labels_overlays is None: + continue + + lab_overlay = labels_overlays[i] + if lab_overlay is None: + continue + + if lab_overlay.shape != image.shape: + raise TypeError( + f"`lab_overlay` at index {i} has shape " + f"{lab_overlay.shape} which is different " + f"from image shape {image.shape}. " + "The image and the `lab_overlay` must " + "have the same shape." + ) + + plot = imageItem.plot + labImageItem = pg.ImageItem() + labImageItem.setOpacity(0.4) + plot.addImageItem(labImageItem) + + if labels_overlays_luts is not None: + labels_overlays_lut = labels_overlays_luts[i] + else: + labels_overlays_lut = self._getDefaultLabelsOverlayLut(lab_overlay) + + labImageItem.setLookupTable(labels_overlays_lut) + labImageItem.setLevels([0, len(labels_overlays_lut)]) + + imageItem.lab = lab_overlay + imageItem.labImageItem = labImageItem + + overlayLab = self._get2DlabOverlay(imageItem) + labImageItem.setImage(overlayLab, autoLevels=False) + + # Share axis between images with same X, Y shape + all_shapes = [image.shape[-2:] for image in images] + unique_shapes = set(all_shapes) + shame_shape_plots = [] + for unique_shape in unique_shapes: + plots = [ + self.PlotItems[i] + for i, shape in enumerate(all_shapes) + if shape == unique_shape + ] + shame_shape_plots.append(plots) + + for plots in shame_shape_plots: + for plot in plots: + plot.vb.setYLink(plots[0].vb) + plot.vb.setXLink(plots[0].vb) + + def _getDefaultLabelsOverlayLut(self, lab_overlay): + IDs = [obj.label for obj in skimage.measure.regionprops(lab_overlay)] + n_objs = len(IDs) + lut = np.zeros((n_objs + 1, 4), dtype=np.uint8) + rgbas = colors.plt_colormap_to_pg_lut("tab20", ncolors=n_objs) + np.random.shuffle(rgbas) + lut[1:] = rgbas + return lut + + def _createPointsScatterItem(self, xx, yy, group, colors=None, data=None): + if colors is None: + cmap = matplotlib.colormaps["jet_r"] + idx = self.group_to_idx_mapper[group] + r, g, b = [round(c * 255) for c in cmap(idx)][:3] + brush = pg.mkBrush(color=(r, g, b, 100)) + pen = pg.mkPen(width=2, color=(r, g, b)) + hoverBrush = pg.mkBrush((r, g, b, 200)) + else: + brush = [] + pen = [] + hoverBrush = None + for color in colors: + rgb = matplotlib.colors.to_rgb(color) + rgb = [round(c * 255) for c in rgb] + _brush = pg.mkBrush(color=(*rgb, 100)) + _pen = pg.mkPen(width=2, color=rgb) + brush.append(_brush) + pen.append(_pen) + + item = pg.ScatterPlotItem( + xx, + yy, + symbol="o", + pxMode=False, + size=3, + brush=brush, + pen=pen, + hoverable=True, + hoverBrush=hoverBrush, + data=data, + ) + return item + + def drawPointsFromDf( + self, points_df: pd.DataFrame | List[pd.DataFrame], points_groups=None + ): + if not isinstance(points_df, (list, tuple)): + points_df = [points_df] * len(self.PlotItems) + + for p, df in enumerate(points_df): + if isinstance(points_groups, str): + points_groups = [points_groups] + + if points_groups is None: + grouped = [("", df)] + groups = [""] + else: + grouped = df.groupby(points_groups) + groups = grouped.groups.keys() + + idxs_space = np.linspace(0, 1, len(groups)) + self.group_to_idx_mapper = dict(zip(groups, idxs_space)) + + for group, df in grouped: + yy = df["y"].values + xx = df["x"].values + points_coords = np.column_stack((yy, xx)) + if "z" in df.columns: + zz = df["z"].values + points_coords = np.column_stack((zz, points_coords)) + if len(group) == 1: + group = group[0] + + colors = None + if "color" in df.columns: + colors = df["color"].values + + data = None + if "data" in df.columns: + data = df["data"].values + + self.drawPoints( + points_coords, colors=colors, group=group, idx=p, data=data + ) + + def drawPoints( + self, + points_coords: np.ndarray, + group="", + idx=None, + colors=None, + data=None, + ): + offset = 0.5 if np.issubdtype(points_coords.dtype, np.integer) else 0 + n_dim = points_coords.shape[1] + + if idx is not None: + PlotItems = [self.PlotItems[idx]] + ImageItems = [self.ImageItems[idx]] + else: + PlotItems = self.PlotItems + ImageItems = self.ImageItems + + if n_dim == 2: + if data is None: + data = group + + zz = [0] * len(points_coords) + self.points_coords = np.column_stack((zz, points_coords)) + for p, plotItem in enumerate(PlotItems): + imageItem = ImageItems[p] + xx = points_coords[:, 1] + offset + yy = points_coords[:, 0] + offset + pointsItem = self._createPointsScatterItem( + xx, yy, group, data=data, colors=colors + ) + pointsItem.z = 0 + plotItem.addItem(pointsItem) + imageItem.pointsItems = {group: [pointsItem]} + elif n_dim == 3: + self.points_coords = points_coords + for p, plotItem in enumerate(PlotItems): + imageItem = ImageItems[p] + imageItem.pointsItems = defaultdict(list) + scrollbar = imageItem.ScrollBars[0] + for first_coord in range(scrollbar.maximum() + 1): + coords_idx = np.nonzero(points_coords[:, 0] == first_coord) + coords = points_coords[coords_idx] + if colors is None: + _colors = None + else: + _colors = np.asarray(colors)[coords_idx] + if len(_colors) == 0: + _colors = None + + _data = group + if data is not None: + _data = data[coords_idx] + if len(_data) == 0: + _data = group + + xx = coords[:, 2] + offset + yy = coords[:, 1] + offset + pointsItem = self._createPointsScatterItem( + xx, yy, group, data=_data, colors=_colors + ) + pointsItem.z = first_coord + plotItem.addItem(pointsItem) + pointsItem.setVisible(False) + imageItem.pointsItems[group].append(pointsItem) + self.setPointsVisible(imageItem) + + def setupDuplicatedCursors(self): + self.cursors = [] + for p, plotItem in enumerate(self.PlotItems): + cursor = pg.ScatterPlotItem( + symbol="+", + pxMode=True, + pen=pg.mkPen("k", width=1), + brush=pg.mkBrush("w"), + size=16, + tip=None, + ) + self.cursors.append(cursor) + plotItem.addItem(cursor) + + for imageItem in self.ImageItems: + imageItem.setOtherImagesCursors(self.cursors) + + def setPointsData(self, points_data): + points_df = pd.DataFrame( + { + "z": self.points_coords[:, 0], + "y": self.points_coords[:, 1], + "x": self.points_coords[:, 2], + } + ) + if isinstance(points_data, pd.Series): + points_df[points_data.name] = points_data.values + elif isinstance(points_data, pd.DataFrame): + points_df = points_df.join(points_data) + elif isinstance(points_data, np.ndarray): + if points_data.ndim == 1: + points_data = points_data[np.newaxis] + else: + points_data = points_data.T + for i, values in enumerate(points_data): + points_df[f"col_{i}"] = values + + self.points_df = points_df.set_index(["z", "y", "x"]).sort_index() + + for p, plotItem in enumerate(self.PlotItems): + imageItem = self.ImageItems[p] + for pointsItems in imageItem.pointsItems.values(): + for pointsItem in pointsItems: + pointsItem.sigClicked.connect(self.pointsClicked) + + def pointsClicked(self, item, points, event): + point = points[0] + x, y = point.pos() + coords = (item.z, int(y), int(x)) + point_data = self.points_df.loc[[coords]] + now = datetime.datetime.now().strftime("%H:%M:%S") + print("*" * 60) + print(f"Point clicked at {now}. Data:") + print("-" * 60) + print(point_data) + print("") + print("*" * 60) + + def annotateObjectIDs(self, annotate_labels_idxs=None, init=False): + if init: + self.annotate_labels_idxs = annotate_labels_idxs + self.textItems = [{} for _ in self.PlotItems] + if self.annotate_labels_idxs is None: + return + for i, plotItem in enumerate(self.PlotItems): + if i not in self.annotate_labels_idxs: + continue + plotTextItems = self.textItems[i] + imageItem = self.ImageItems[i] + try: + if init: + # 3D labels (if 3D) + lab = imageItem.lab + else: + lab = imageItem.labImageItem.image + except Exception as err: + lab = imageItem.image + + rp = skimage.measure.regionprops(lab) + for obj in rp: + textItem = plotTextItems.get(obj.label) + yc, xc = obj.centroid[-2:] + if textItem is None: + textItem = pg.TextItem(text="", anchor=(0.5, 0.5), color="r") + plotItem.addItem(textItem) + plotTextItems[obj.label] = textItem + + if self.isObjVisible(obj, imageItem): + text = str(obj.label) + else: + text = "" + + textItem.setText(text) + textItem.setPos(xc, yc) + + # plotItem.enableAutoRange() + + def clearLabels(self): + for textItems in self.textItems: + for textItem in textItems.values(): + textItem.setText("") + + def updateIDs(self): + self.clearLabels() + try: + self.annotateObjectIDs(annotate_labels_idxs=self.annotate_labels_idxs) + except Exception as err: + pass + + def show(self, block=False, screenToWindowRatio=None): + super().show(block=block) + if screenToWindowRatio is None: + return + screenGeometry = self.screen().geometry() + screenWidth = screenGeometry.width() + screenHeight = screenGeometry.height() + finalWidth = int(screenToWindowRatio * screenWidth) + finalHeight = int(screenToWindowRatio * screenHeight) + screenTop = screenGeometry.top() + screenLeft = screenGeometry.left() + xc, yc = screenLeft + screenWidth / 2, screenTop + screenHeight / 2 + winLeft = int(xc - finalWidth / 2) + winTop = int(yc - finalHeight / 2) + self.setGeometry(winLeft, winTop, finalWidth, finalHeight) + + def run(self, block=False, showMaximised=False, screenToWindowRatio=None): + if showMaximised: + self.showMaximized() + else: + self.show(screenToWindowRatio=screenToWindowRatio) + QTimer.singleShot(100, self.autoRange) + + if block: + self.exec_() + + def resizeEvent(self, event) -> None: + self.PlotItems[0].autoRange() + return super().resizeEvent(event) + + +class LabelItem(pg.LabelItem): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def bbox(self): + xl, yl = self.pos().x(), self.pos().y() + wl, hl = self.itemRect().width(), self.itemRect().height() + return yl, xl, yl + hl, xl + wl + + def setBold(self, bold=True): + self.origPos = self.pos() + self.setText(self.text, bold=bold) + self.setPos(self.origPos) + + +class ScaleBar(QGraphicsObject): + sigEditProperties = Signal(object) + sigRemove = Signal(object) + + def __init__(self, imageShape, viewRange, parent=None): + super().__init__(parent) + self.SizeY, self.SizeX = imageShape + self.updateViewRange(viewRange) + self.plotItem = PlotCurveItem() + self.labelItem = LabelItem() + self._x_pad = 5 + self._y_pad = 3 + self._highlighted = False + self._parent = parent + self.clicked = False + self.createContextMenu() + + def updateViewRange(self, viewRange): + xRange, yRange = viewRange + x0, x1 = xRange + y0, y1 = yRange + if x0 < 0: + x0 = 0 + + if x1 > self.SizeX: + x1 = self.SizeX + + if y0 < 0: + y0 = 0 + + if y1 > self.SizeY: + y1 = self.SizeY + + self.xmax = x1 + self.xmin = x0 + + self.ymax = y1 + self.ymin = y0 + + def createContextMenu(self): + self.contextMenu = QMenu() + action = QAction("Edit properties...", self.contextMenu) + action.triggered.connect(self.emitEditProperties) + self.contextMenu.addSeparator() + action = QAction("Remove", self.contextMenu) + action.triggered.connect(self.emitRemove) + self.contextMenu.addAction(action) + + def emitEditProperties(self): + self.setHighlighted(False) + self.sigEditProperties.emit(self.properties()) + + def emitRemove(self): + self.sigRemove.emit(self) + + def isHighlighted(self): + return self._highlighted + + def setHighlighted(self, highlighted): + if self._highlighted and highlighted: + return + + if not self._highlighted and not highlighted: + return + + pen = self.highlightPen if highlighted else self.pen + self.labelItem.setBold(bold=highlighted) + self.plotItem.setPen(pen) + + self._highlighted = highlighted + + def showContextMenu(self, x, y): + self.contextMenu.popup(QPoint(int(x), int(y))) + + def properties(self): + properties = { + "thickness": self._thickness, + "length_pixel": self._length, + "length_unit": self._length_unit, + "is_text_visible": self._is_text_visible, + "color": self._color, + "loc": self._loc, + "font_size": float(self._font_size[:-2]), + "unit": self._unit, + "num_decimals": self._num_decimals, + "move_with_zoom": self._move_with_zoom, + } + return properties + + def move(self, xm, ym): + self._loc = "Custom" + + Dy = ym - self.yc + Dx = xm - self.xc + + x0 = self.x0c + Dx + x1 = x0 + self._length + y0 = y1 = self.y0c + Dy + self.plotItem.setData([x0, x1], [y0, y1]) + self.setTextPos() + + def paint(self, painter, option, widget): + pass + + def boundingRect(self): + ymin, xmin, ymax, xmax = self.bbox() + return QRectF(xmin, ymin, xmax - xmin, ymax - ymin) + + def setLocationProperty(self, loc: str): + self._loc = loc + + def setMoveWithZoomProperty(self, move_with_zoom): + self._move_with_zoom = move_with_zoom + + def setProperties( + self, + length_pixel, + length_unit, + thickness=3, + color="w", + is_text_visible=True, + loc="top-left", + font_size=12, + unit="", + num_decimals=0, + move_with_zoom=False, + ): + self._loc = loc + self._color = color + self._length = length_pixel + self._length_unit = length_unit + self._is_text_visible = is_text_visible + self._font_size = f"{font_size}px" + self._unit = unit + self._num_decimals = num_decimals + self._move_with_zoom = move_with_zoom + self._thickness = thickness + self.pen = pg.mkPen(width=thickness, color=color, cosmetic=False) + self.highlightPen = pg.mkPen(width=thickness + 2, color=color, cosmetic=False) + self.pen.setCapStyle(Qt.PenCapStyle.FlatCap) + self.highlightPen.setCapStyle(Qt.PenCapStyle.FlatCap) + self.plotItem.setPen(self.pen) + + def updatePhysicalLength(self, PhysicalSizeX): + length_unit = self._length_unit + unit = self._unit + length_um = _core.convert_length(length_unit, unit, "μm") + length_pixel = length_um / PhysicalSizeX + self._length = length_pixel + self.update() + + def addToAxis(self, ax): + ax.addItem(self.plotItem) + ax.addItem(self.labelItem) + + def setText(self): + if self._is_text_visible: + number = round(self._length_unit, self._num_decimals) + if self._num_decimals == 0: + number = int(number) + text = f"{number} {self._unit}" + else: + text = "" + self.labelItem.setText(text, color=self._color, size=self._font_size) + + def setTextPos(self): + xx, yy = self.plotItem.getData() + x0 = xx[0] + y0 = yy[0] + xc = x0 + self._length / 2 + wl = self.labelItem.itemRect().width() + hl = self.labelItem.itemRect().height() + xl = xc - wl / 2 + yt = y0 - hl + self.labelItem.setPos(xl, yt) + + def updatePosViewRangeChanged(self, viewRange): + if self._loc == "custom": + xx, yy = self.plotItem.getData() + x0p = xx[0] + y0p = yy[0] + xcp = x0p + self._length / 2 + hl = self.labelItem.itemRect().height() + ycp = y0p - hl / 2 + x0 = self.xmin + y0 = self.ymin + x_range = self.xmax - x0 + y_range = self.ymax - y0 + Dx_perc = (xcp - x0) / x_range + Dy_perc = (ycp - y0) / y_range + + self.updateViewRange(viewRange) + + X0 = self.xmin + Y0 = self.ymin + + X_range = self.xmax - X0 + Y_range = self.ymax - Y0 + + Xcp = X0 + (Dx_perc * X_range) + Ycp = Y0 + (Dy_perc * Y_range) + X0p = Xcp - (self._length / 2) + Y0p = Ycp + (hl / 2) + + X1p = X0p + self._length + Y1p = Y0p + + self.plotItem.setData([X0p, X1p], [Y0p, Y1p]) + else: + self.updateViewRange(viewRange) + self.update() + + def getStartXCoordFromLoc(self, loc): + if loc == "custom": + xx, yy = self.plotItem.getData() + x0 = xx[0] + return x0 + + self.setText() + wl = self.labelItem.itemRect().width() + if loc.find("left") != -1: + x0 = self._x_pad + self.xmin + xc = x0 + self._length / 2 + xl = xc - wl / 2 + if xl < x0: + # Text is larger than line --> move line to the right + x0 = self._x_pad + abs(xl - self._x_pad) + else: + x0 = self.xmax - self._length - self._x_pad + xc = x0 + self._length / 2 + x1 = x0 + self._length + xr = xc + wl / 2 + if xr > x1: + # Text is larger than line --> move line to the left + delta_overshoot = xr - x1 + x0 = x0 - delta_overshoot + return x0 + + def getStartYCoordFromLoc(self, loc): + if loc == "custom": + xx, yy = self.plotItem.getData() + y0 = yy[0] + return y0 + + self.setText() + textHeight = self.labelItem.itemRect().height() + if loc.find("top") != -1: + return textHeight + self._y_pad + self.ymin + else: + return self.ymax - self._y_pad - self._thickness + + def update(self): + x0 = self.getStartXCoordFromLoc(self._loc) # + self._thickness/2 + y0 = self.getStartYCoordFromLoc(self._loc) + + x1 = x0 + self._length # - self._thickness/2 + self.plotItem.setData([x0, x1], [y0, y0]) + + self.setText() + self.setTextPos() + + def draw(self, length_pixel, length_unit, **kwargs): + self.setProperties(length_pixel, length_unit, **kwargs) + self.update() + + def bbox(self): + y_line_min, x_line_min, y_line_max, x_line_max = self.plotItem.bbox() + y_lab_min, x_lab_min, y_lab_max, x_lab_max = self.labelItem.bbox() + ymin = min(y_line_min, y_lab_min) + xmin = min(x_line_min, x_lab_min) + ymax = max(y_line_max, y_lab_max) + xmax = max(x_line_max, x_lab_max) + return ymin, xmin, ymax, xmax + + def mousePressed(self, x, y): + self.clicked = True + self.xc, self.yc = x, y + xx, yy = self.plotItem.getData() + self.x0c = xx[0] + self.y0c = yy[0] + + def removeFromAxis(self, ax): + ax.removeItem(self.labelItem) + ax.removeItem(self.plotItem) + + +class RulerPlotItem(pg.PlotDataItem): + def __init__(self, *args, **kwargs): + self.labelItem = pg.LabelItem() + super().__init__(*args, **kwargs) + + def setData(self, *args, lengthText="", **kwargs): + super().setData(*args, **kwargs) + self.labelItem.setText("") + if not lengthText: + return + self.setLengthText(lengthText) + + def setLengthText(self, lengthText): + xx, yy = self.getData() + x0, x1 = sorted(xx) + y0, y1 = sorted(yy) + xc = round(x0 + (x1 - x0) / 2) + yc = round(y0 + (y1 - y0) / 2) + self.labelItem.setText(lengthText, size="11px", color="r") + # xc = x0 + self._length/2 + wl = self.labelItem.itemRect().width() + hl = self.labelItem.itemRect().height() + xl = xc - wl / 2 + yt = y0 - hl + self.labelItem.setPos(xl, yt) + + +class PointsScatterPlotItem(pg.ScatterPlotItem): + sigHoverEntered = Signal(object, object, object) + + def __init__(self, *args, ax=None, show_data_as_tip=False, **kwargs): + self.textItem = annotate.TextAnnotationsScatterItem(size=12, anchor=(1.0, 1.0)) + self.textItem.createSymbols( + [str(int_id) for int_id in range(200)], includeBold=False + ) + # self._textItems = {} + super().__init__(*args, **kwargs) + self.textItem.setParentItem(self) + self._font = QFont() + self._font.setPixelSize(12) + self.show_data_as_tip = show_data_as_tip + self.drawIds = True + self.ax = ax + self.sigHovered.connect(self.onHover) + self.lastHoveredPoint = None + + def onHover(self, item, points, event): + if len(points) == 0: + vb = self.getViewBox() + vb.setToolTip("") + return + + if self.lastHoveredPoint != points[0]: + self.sigHoverEntered.emit(item, points, event) + self.lastHoveredPoint = points[0] + + if not self.opts["hoverable"]: + return + + if not self.show_data_as_tip: + return + + tip_li = [str(point.data()) for point in points] + tip = "\n\n".join(tip_li) + + vb = self.getViewBox() + vb.setToolTip(tip) + + def setData(self, *args, **kwargs): + self.clearTextItems() + super().setData(*args, **kwargs) + data = kwargs.get("data") + if data is None: + return + + if len(data) == 0: + return + + first_point_data = data[0] + if not isinstance(first_point_data, (int, str)): + return + + if not self.drawIds: + return + + if self.show_data_as_tip: + return + + color = self.opts["brush"].color() + self.textItem.setColors({"id": color.getRgb()}) + size = self.opts["size"] + radius = size / 2 + # xx, yy = args + # for x, y, point_data in zip(xx, yy, data): + for point in self.points(): + text = str(point.data()) + if not text: + continue + + x, y = point.pos().x(), point.pos().y() + xt, yt = x + radius - 0.5, y - radius + 0.5 + opts = { + "text": text, + "bold": False, + "color_name": "id", + } + data = self.textItem.addObjAnnot((xt, yt), anchor=(-0.3, 1.3), **opts) + self.textItem.appendData(data, opts["text"]) + + self.textItem.draw() + # hexColor = color.name() + # htmlText = html_utils.span( + # text, color=hexColor, font_size='13pt', bold=True + # ) + + # textItem = self._textItems.get((x, y)) + # if textItem is None: + # textItem = pg.TextItem(html=htmlText, anchor=(0, 1)) + # textItem.setParentItem(self) + # self._textItems[(x, y)] = textItem + # self.ax.addItem(textItem) + # else: + # textItem.setHtml(htmlText) + # textItem.setPos(x+radius-0.5, y-radius+0.5) + + def clearTextItems(self): + self.textItem.clearData() + # for textItem in self._textItems.values(): + # textItem.setText('') + + def clear(self): + super().clear() + self.clearTextItems() + + def setVisible(self, visible): + super().setVisible(visible) + self.textItem.setVisible(visible) + + +class RectItem(pg.GraphicsObject): + def __init__(self, rect, pen=None, brush=(255, 0, 0, 100), parent=None): + super().__init__(parent) + self._rect = rect + self._pen = pg.mkPen(pen) + self._brush = pg.mkBrush(brush) + self.picture = QPicture() + self._generate_picture() + + def setColor(self, color): + rgba = matplotlib.colors.to_rgba(color, alpha=100 / 255) + rgba = [round(c * 255) for c in rgba] + self._brush = pg.mkBrush(rgba) + self._generate_picture() + self.update() + + def setRect(self, x, y, width, height): + self._rect = QRectF(x, y, width, height) + self._generate_picture() + self.update() + + def setQRect(self, qrect): + self._rect = qrect + self._generate_picture() + self.update() + + @property + def rect(self): + return self._rect + + def _generate_picture(self): + painter = QPainter(self.picture) + painter.setPen(self._pen) + painter.setBrush(self._brush) + painter.drawRect(self._rect) + painter.end() + + def paint(self, painter, option, widget=None): + painter.drawPicture(0, 0, self.picture) + + def boundingRect(self): + return QRectF(self.picture.boundingRect()) + +# Sibling imports (deferred to avoid import cycles) +from .controls import ( + ComboBox, + DoubleSpinBox, + SpinBox, + highlightableQWidgetAction, +) + diff --git a/cellacdc/widgets/controls.py b/cellacdc/widgets/controls.py new file mode 100644 index 000000000..da17f29e8 --- /dev/null +++ b/cellacdc/widgets/controls.py @@ -0,0 +1,5171 @@ +"""GUI widgets: controls.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from .. import myutils, measurements, is_mac, is_win, html_utils, is_linux +from .. import printl, settings_folderpath +from .. import colors, config +from .. import html_path +from .. import _palettes +from .. import load +from .. import apps +from .. import plot +from .. import annotate +from .. import urls +from .. import _core, core +from .. import QtScoped +from .. import prompts +from ..acdc_regex import float_regex +from ..config import PREPROCESS_MAPPER +from .. import _base_widgets + +from ..components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ..components.progress import QtHandler, QLog, XStream # noqa: E402 +from ..components.buttons import * # noqa: E402, F403 +from ..components.layout import * # noqa: E402, F403 +from ..components.inputs_basic import * # noqa: E402, F403 +from ..components.path_controls import * # noqa: E402, F403 + +from ..components.lists import * # noqa: E402, F403 +from ..components.base import QBaseWindow # noqa: E402 +from ..components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +from .canvas import ( + LabelItem, + sliderWithSpinBox, +) +from .toolbars import ( + ToolBar, + rightClickToolButton, +) + +class QDialogListbox(QDialog): + sigSelectionConfirmed = Signal(list) + + def __init__( + self, + title, + text, + items, + cancelText="Cancel", + multiSelection=True, + parent=None, + additionalButtons=(), + includeSelectionHelp=False, + allowSingleSelection=True, + preSelectedItems=None, + allowEmptySelection=True, + ): + self.cancel = True + items = list(items) + + super().__init__(parent) + self.setWindowTitle(title) + + if preSelectedItems is None: + if items: + preSelectedItems = (items[0],) + else: + preSelectedItems = set() + + self.allowSingleSelection = allowSingleSelection + self.allowEmptySelection = allowEmptySelection + + mainLayout = QVBoxLayout() + topLayout = QVBoxLayout() + bottomLayout = QHBoxLayout() + + self.mainLayout = mainLayout + + label = QLabel(text) + _font = QFont() + _font.setPixelSize(13) + label.setFont(_font) + # padding: top, left, bottom, right + label.setStyleSheet("padding:0px 0px 3px 0px;") + topLayout.addWidget(label, alignment=Qt.AlignCenter) + + if includeSelectionHelp: + selectionHelpLabel = QLabel() + txt = html_utils.paragraph("""
    + Ctrl+Click to select multiple items
    + Shift+Click to select a range of items
    + """) + selectionHelpLabel.setText(txt) + topLayout.addWidget(label, alignment=Qt.AlignCenter) + + listBox = listWidget() + listBox.setFont(_font) + listBox.addItems(items) + if multiSelection: + listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + else: + listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + listBox.setCurrentRow(0) + for i in range(listBox.count()): + item = listBox.item(i) + item.setSelected(item.text() in preSelectedItems) + + self.listBox = listBox + if not multiSelection: + listBox.itemDoubleClicked.connect(self.ok_cb) + topLayout.addWidget(listBox) + + if cancelText.lower().find("cancel") != -1: + cancelButton = cancelPushButton(cancelText) + else: + cancelButton = QPushButton(cancelText) + okButton = okPushButton(" Ok ") + + bottomLayout.addStretch(1) + bottomLayout.addWidget(cancelButton) + bottomLayout.addSpacing(20) + + if additionalButtons: + self._additionalButtons = [] + for button in additionalButtons: + if isinstance(button, str): + _button, isCancelButton = getPushButton(button) + self._additionalButtons.append(_button) + bottomLayout.addWidget(_button) + _button.clicked.connect(self.ok_cb) + else: + bottomLayout.addWidget(button) + + bottomLayout.addWidget(okButton) + bottomLayout.setContentsMargins(0, 10, 0, 0) + + mainLayout.addLayout(topLayout) + mainLayout.addLayout(bottomLayout) + self.setLayout(mainLayout) + + # Connect events + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.cancel_cb) + + if multiSelection: + listBox.itemClicked.connect(self.onItemClicked) + listBox.itemSelectionChanged.connect(self.onItemSelectionChanged) + + self.setStyleSheet(LISTWIDGET_STYLESHEET) + self.areItemsSelected = [ + listBox.item(i).isSelected() for i in range(listBox.count()) + ] + self.setFont(font) + + def keyPressEvent(self, event) -> None: + mod = event.modifiers() + if mod == Qt.ShiftModifier or mod == Qt.ControlModifier: + self.listBox.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + elif event.key() == Qt.Key_Escape: + self.listBox.clearSelection() + event.ignore() + return + super().keyPressEvent(event) + + def onItemSelectionChanged(self): + if not self.listBox.selectedItems(): + self.areItemsSelected = [False for i in range(self.listBox.count())] + + def onItemClicked(self, item): + mod = QGuiApplication.keyboardModifiers() + if mod == Qt.ShiftModifier or mod == Qt.ControlModifier: + self.listBox.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + return + + self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) + itemIdx = self.listBox.row(item) + wasSelected = self.areItemsSelected[itemIdx] + if wasSelected: + item.setSelected(False) + + self.areItemsSelected = [ + self.listBox.item(i).isSelected() for i in range(self.listBox.count()) + ] + # self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + # else: + # selectedItems.append(item) + + # self.listBox.clearSelection() + # for i in range(self.listBox.count()): + # item = self.listBox.item(i).setSelected(True) + + # print(self.listBox.selectedItems()) + + def setSelectedItems(self, itemsTexts): + for i in range(self.listBox.count()): + item = self.listBox.item(i) + if item.text() in itemsTexts: + item.setSelected(True) + self.listBox.update() + + def warnSelectionEmpty(self): + msg = myMessageBox(wrapText=False, showCentered=False) + txt = html_utils.paragraph( + "You need to select at least one item!.

    " + "Use Ctrl+Click to select multiple items
    " + "or Shift+Click to select a range of items" + ) + msg.warning(self, "Selection cannot be empty!", txt) + + def ok_cb(self, checked=False): + self.clickedButton = self.sender() + self.cancel = False + selectedItems = self.listBox.selectedItems() + self.selectedItemsText = [item.text() for item in selectedItems] + if not self.allowSingleSelection and len(self.selectedItemsText) < 2: + msg = myMessageBox(wrapText=False, showCentered=False) + txt = html_utils.paragraph( + "You need to select two or more items.

    " + "Use Ctrl+Click to select multiple items
    , or
    " + "Shift+Click to select a range of items" + ) + msg.warning(self, "Select two or more items", txt) + return + + if not self.allowEmptySelection and not self.selectedItemsText: + self.warnSelectionEmpty() + return + + self.sigSelectionConfirmed.emit(self.selectedItemsText) + self.close() + + def cancel_cb(self, event): + self.cancel = True + self.selectedItemsText = None + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + + horizontal_sb = self.listBox.horizontalScrollBar() + while horizontal_sb.isVisible(): + self.resize(self.height(), self.width() + 10) + + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class ExpandableListBox(QComboBox): + def __init__(self, parent=None, centered=True) -> None: + super().__init__(parent) + + self.setEditable(True) + self.lineEdit().setReadOnly(True) + + infoTxt = html_utils.paragraph( + "Select Positions to save

    " + "Ctrl+Click to select multiple items
    " + "Shift+Click to select a range of items
    ", + center=True, + ) + + self.listW = QDialogListbox( + "Select Positions to save", infoTxt, [], multiSelection=True, parent=self + ) + + self.listW.listBox.itemClicked.connect(self.listItemClicked) + self.listW.sigSelectionConfirmed.connect(self.updateCombobox) + + self.centered = centered + + def listItemClicked(self, item): + if item.text().find("All") == -1: + return + + for i in range(self.listW.listBox.count()): + _item = self.listW.listBox.item(i) + _item.setSelected(True) + + def clear(self) -> None: + self.listW.listBox.clear() + return super().clear() + + def setItems(self, items): + self.clear() + self.addItems(items) + + def addItems(self, items): + super().addItems(items) + self.listW.listBox.addItems(items) + self.listW.listBox.setCurrentRow(self.currentIndex()) + self.listItemClicked(self.listW.listBox.currentItem()) + if self.centered: + self.centerItems() + + def updateCombobox(self, selectedItemsText): + isAllItem = [i for i, t in enumerate(selectedItemsText) if t.find("All") != -1] + if len(selectedItemsText) == 1: + self.setCurrentText(selectedItemsText[0]) + elif isAllItem: + idx = isAllItem[0] + self.setCurrentText(selectedItemsText[idx]) + else: + super().clear() + super().addItems(["Custom selection"]) + + def centerItems(self, idx=None): + self.lineEdit().setAlignment(Qt.AlignCenter) + + def selectedItems(self): + return self.listW.listBox.selectedItems() + + def selectedItemsText(self): + return [item.text() for item in self.selectedItems()] + + def showPopup(self) -> None: + self.listW.show() + + +class QClickableLabel(QLabel): + clicked = Signal(object) + + def __init__(self, parent=None): + self._parent = parent + super().__init__(parent) + self._checkableItem = None + + def setCheckableItem(self, widget): + self._checkableItem = widget + + def mousePressEvent(self, event): + self.clicked.emit(self) + if self._checkableItem is not None: + status = not self._checkableItem.isChecked() + self._checkableItem.setChecked(status) + + def setChecked(self, checked): + self._checkableItem.setChecked(checked) + + +class QCenteredComboBox(QComboBox): + def __init__(self, parent=None) -> None: + super().__init__(parent) + + self.setEditable(True) + self.lineEdit().setReadOnly(True) + self.lineEdit().setAlignment(Qt.AlignCenter) + self.lineEdit().installEventFilter(self) + + self.currentIndexChanged.connect(self.centerItems) + + self._isPopupVisibile = False + + def centerItems(self, idx): + for i in range(self.count()): + self.setItemData(i, Qt.AlignCenter, Qt.TextAlignmentRole) + + def eventFilter(self, lineEdit, event): + # Reimplement show popup on click + if event.type() == QEvent.Type.MouseButtonPress and self.isEnabled(): + if self._isPopupVisibile: + self.hidePopup() + self._isPopupVisibile = False + else: + self.showPopup() + self._isPopupVisibile = True + return True + return False + + +class AlphaNumericComboBox(QCenteredComboBox): + def __init__(self, parent=None) -> None: + super().__init__(parent=parent) + + def addItems(self, items): + self._dtype = type(items[0]) + super().addItems([str(item) for item in items]) + + def setCurrentValue(self, value): + super().setCurrentText(str(value)) + + def currentValue(self): + return self._dtype(super().currentText()) + + +class statusBarPermanentLabel(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + self.rightLabel = QLabel("") + self.leftLabel = QLabel("") + + layout = QHBoxLayout() + layout.addWidget(self.leftLabel) + layout.addStretch(10) + layout.addWidget(self.rightLabel) + + self.setLayout(layout) + + +class listWidget(QListWidget): + def __init__( + self, *args, isMultipleSelection=False, minimizeHeight=False, **kwargs + ): + super().__init__(*args, **kwargs) + self.itemHeight = None + self.setStyleSheet(LISTWIDGET_STYLESHEET) + self.setFont(font) + if isMultipleSelection: + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + + self.minimizeHeight = minimizeHeight + + def setSelectedAll(self, selected): + for i in range(self.count()): + self.item(i).setSelected(selected) + + def setSelectedItems(self, itemsText): + for i in range(self.count()): + item = self.item(i) + item.setSelected(item.text() in itemsText) + + def addItems(self, labels) -> None: + super().addItems(labels) + if self.itemHeight is not None: + self.setItemHeight() + + if self.minimizeHeight: + itemHeight = self.sizeHintForRow(0) + self.setMaximumHeight(itemHeight * self.count() + itemHeight * 2) + + def addItem(self, text): + super().addItem(text) + if self.itemHeight is None: + return + self.setItemHeight() + + def setItemHeight(self, height=40): + self.itemHeight = height + for i in range(self.count()): + item = self.item(i) + item.setSizeHint(QSize(0, height)) + + def selectedItemsText(self): + return [item.text() for item in self.selectedItems()] + + +class OrderableListWidget(QWidget): + sigEnterEvent = Signal(object) + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._labels = [] + + def setParentItem(self, item): + self._item = item + + def setLabelsColor(self, selected): + if selected: + stylesheet = "color : black" + else: + stylesheet = "" + + for label in self._labels: + label.setStyleSheet(stylesheet) + + def enterEvent(self, event): + super().enterEvent(event) + self.setLabelsColor(True) + self.sigEnterEvent.emit(self._item) + + # def leaveEvent(self, event): + # super().leaveEvent(event) + # self.setLabelsColor(self._item.isSelected()) + # printl('leave', self._item.isSelected()) + + def addLabel(self, label): + self._labels.append(label) + self.validPattern = r"^[0-9,\.]+$" + regExp = QRegularExpression(self.validPattern) + self.setValidator(QRegularExpressionValidator(regExp)) + + def values(self): + try: + vals = [float(c) for c in self.text().split(",")] + except Exception as e: + vals = [] + return vals + + +class mySpinBox(QSpinBox): + sigTabEvent = Signal(object, object) + + def __init__(self, *args) -> None: + super().__init__(*args) + + def event(self, event): + if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key_Tab: + self.sigTabEvent.emit(event, self) + return True + + return super().event(event) + + +class KeptObjectIDsList(list): + def __init__(self, lineEdit, confirmSelectionAction, *args): + self.lineEdit = lineEdit + self.lineEdit.setText("") + self.confirmSelectionAction = confirmSelectionAction + confirmSelectionAction.setDisabled(True) + super().__init__(*args) + + def setText(self): + text = myutils.format_IDs(self) + + self.lineEdit.setText(text) + + def append(self, element, editText=True): + super().append(element) + if editText: + self.setText() + if not self.confirmSelectionAction.isEnabled(): + self.confirmSelectionAction.setEnabled(True) + + def remove(self, element, editText=True): + super().remove(element) + if editText: + self.setText() + if not self: + self.confirmSelectionAction.setEnabled(False) + + +class myMessageBox(_base_widgets.QBaseDialog): + def __init__( + self, + parent=None, + showCentered=True, + wrapText=True, + scrollableText=False, + enlargeWidthFactor=0, + resizeButtons=True, + allowClose=True, + ): + super().__init__(parent) + + self.wrapText = wrapText + self.enlargeWidthFactor = enlargeWidthFactor + self.resizeButtons = resizeButtons + + self.cancel = True + self.cancelButton = None + self.okButton = None + self.clickedButton = None + self.alreadyShown = False + self.allowClose = allowClose + + self.showCentered = showCentered + + self.scrollableText = scrollableText + + self._layout = QGridLayout() + self.commandsLayout = None + self._layout.setHorizontalSpacing(20) + self.buttonsLayout = QHBoxLayout() + self.buttonsLayout.setSpacing(2) + self.buttons = [] + self.widgets = [] + self.layouts = [] + self.labels = [] + self.labelsWidgets = [] + self._pixmapLabels = [] + self.detailsTextWidget = None + self.showInFileManagButton = None + self.visibleDetails = False + self.doNotShowAgainCheckbox = None + + self.currentRow = 0 + self.textWidget = None + self._w = None + + self.textLayout = QVBoxLayout() + + self._layout.setColumnStretch(1, 1) + self.setLayout(self._layout) + + self.setFont(font) + + def mousePressEvent(self, event): + for label in self.labels: + label.setTextInteractionFlags( + Qt.TextBrowserInteraction | Qt.TextSelectableByKeyboard + ) + + def setIcon(self, iconName="SP_MessageBoxInformation"): + label = QLabel(self) + + standardIcon = getattr(QStyle, iconName) + icon = self.style().standardIcon(standardIcon) + pixmap = icon.pixmap(60, 60) + label.setPixmap(pixmap) + + self._layout.addWidget(label, 0, 0, alignment=Qt.AlignTop) + + def addImage(self, image_path): + pixmap = QPixmap(image_path) + label = QLabel() + label.setPixmap(pixmap) + self._layout.addWidget(label, self.currentRow, 1) + self.currentRow += 1 + + def addShowInFileManagerButton(self, path, txt=None): + if txt is None: + txt = "Reveal in Finder..." if is_mac else "Show in Explorer..." + self.showInFileManagButton = showInFileManagerButton(txt) + self.buttonsLayout.addWidget(self.showInFileManagButton) + func = partial(myutils.showInExplorer, path) + self.showInFileManagButton.clicked.connect(func) + + def addBrowseUrlButton(self, url, button_text=""): + self.openUrlButton = OpenUrlButton(url, button_text) + self.buttonsLayout.addWidget(self.openUrlButton) + + def addCancelButton(self, button=None, connect=False): + if button is None: + self.cancelButton = cancelPushButton("Cancel") + else: + self.cancelButton = button + self.cancelButton.setIcon(QIcon(":cancelButton.svg")) + + self.buttonsLayout.insertWidget(0, self.cancelButton) + self.buttonsLayout.insertSpacing(1, 20) + if connect: + self.cancelButton.clicked.connect(self.buttonCallBack) + + def splitLatexBlocks(self, text): + texts = re.split(r"(.+?)
    ", text) + return texts + + def splitCopiableBlocks(self, texts: Sequence[str] | str): + if isinstance(texts, str): + texts = (texts,) + + texts_out = [] + for text in texts: + texts_out.extend(re.split(r"(.+?)", text)) + return texts_out + + def addText(self, text): + texts = self.splitLatexBlocks(text) + texts = self.splitCopiableBlocks(texts) + + labelsWidget = LabelsWidget(texts, wrapText=self.wrapText) + self.labelsWidgets.append(labelsWidget) + self.labels.extend(labelsWidget.labels) + if self.scrollableText: + textWidget = QScrollArea() + textWidget.setFrameStyle(QFrame.Shape.NoFrame) + textWidget.setWidget(labelsWidget) + else: + textWidget = labelsWidget + + self.textLayout.addWidget(textWidget) + + if self.textWidget is None: + self.textWidget = QWidget() + self.textWidget.setLayout(self.textLayout) + self._layout.addWidget(self.textWidget, self.currentRow, 1) + self.textRow = self.currentRow + self.currentRow += 1 + + return labelsWidget + + def addCopiableCommand(self, command): + copiableCommandWidget = CopiableCommandWidget(command) + screenWidth = self.screen().size().width() + maxWidth = int(0.75 * screenWidth) + sizeHint = copiableCommandWidget.sizeHint() + width = sizeHint.width() + if width > maxWidth: + copiableCommandWidget = addWidgetToScrollArea( + copiableCommandWidget, resizeMinHeightNoVerticalScrollbar=True + ) + self._layout.addWidget(copiableCommandWidget, self.currentRow, 1) + self.currentRow += 1 + + def copyToClipboard(self): + cb = QApplication.clipboard() + cb.clear(mode=cb.Clipboard) + cb.setText(self.sender()._command, mode=cb.Clipboard) + print("Command copied!") + + def addButton(self, buttonText): + if not isinstance(buttonText, str): + # Passing button directly + button = buttonText + self.buttonsLayout.addWidget(button) + button.clicked.connect(self.buttonCallBack) + self.buttons.append(button) + return button + + button, isCancelButton = getPushButton(buttonText, qparent=self) + if not isCancelButton: + self.buttonsLayout.addWidget(button) + + button.clicked.connect(self.buttonCallBack) + self.buttons.append(button) + return button + + def addDoNotShowAgainCheckbox(self, text="Do not show again"): + self.doNotShowAgainCheckbox = QCheckBox(text) + + def addWidget(self, widget): + self._layout.addWidget(widget, self.currentRow, 1) + self.widgets.append(widget) + self.currentRow += 1 + + def addLayout(self, layout): + self._layout.addLayout(layout, self.currentRow, 1) + self.layouts.append(layout) + self.currentRow += 1 + + def setWidth(self, w): + self._w = w + + def show(self, block=False): + self.endOfScrollableRow = self.currentRow + + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + # spacer + spacer = QSpacerItem(10, 10) + self._layout.addItem(spacer, self.currentRow, 1) + self._layout.setRowStretch(self.currentRow, 0) + + # buttons + self.currentRow += 1 + + if self.detailsTextWidget is not None: + self.buttonsLayout.insertWidget(1, self.detailsButton) + + # Do not show again checkbox + if self.doNotShowAgainCheckbox is not None: + self._layout.addWidget( + self.doNotShowAgainCheckbox, self.currentRow, 1, 1, 2 + ) + self.currentRow += 1 + + # spacer + self._layout.addItem(QSpacerItem(10, 10), self.currentRow, 1) + self.currentRow += 1 + + # buttons + self._layout.addLayout( + self.buttonsLayout, self.currentRow, 0, 1, 2, alignment=Qt.AlignRight + ) + + # Details + if self.detailsTextWidget is not None: + # spacer + self.currentRow += 1 + self._layout.addItem(QSpacerItem(20, 20), self.currentRow, 1) + + # detailsTextWidget + self.currentRow += 1 + self._layout.addWidget(self.detailsTextWidget, self.currentRow, 0, 1, 2) + + # spacer + self.currentRow += 1 + spacer = QSpacerItem(10, 10) + self._layout.addItem(spacer, self.currentRow, 1) + self._layout.setRowStretch(self.currentRow, 0) + + screenHeight = self.screen().size().height() + dialogHeight = self.sizeHint().height() + dialogWidth = self.sizeHint().width() + screenWidth = self.screen().size().width() + + # Check if scrollbar is needed + if dialogHeight > screenHeight and self.textWidget is not None: + textScrollArea = ScrollArea() + textScrollArea.setWidget(self.textWidget) + scrollAreaWidthNoSB = textScrollArea.minimumWidthNoScrollbar() + scrollAreaWidth = textScrollArea.sizeHint().width() + desiredDeltaWidth = scrollAreaWidthNoSB - scrollAreaWidth + if desiredDeltaWidth > 0: + desiredWidth = dialogWidth + desiredDeltaWidth + if desiredWidth < screenWidth: + self._w = desiredWidth + + self._layout.removeWidget(self.textWidget) + self._layout.addWidget(textScrollArea, self.textRow, 1) + + super().show() + QTimer.singleShot(5, self._resize) + + self.alreadyShown = True + + if block: + self._block() + + def setDetailedText(self, text, visible=False, wrap=True): + text = text.replace("\n", "
    ") + self.detailsTextWidget = QTextEdit(text) + self.detailsTextWidget.setReadOnly(True) + if not wrap: + self.detailsTextWidget.setLineWrapMode(QTextEdit.NoWrap) + self.detailsButton = showDetailsButton() + self.detailsButton.setCheckable(True) + self.detailsButton.clicked.connect(self._showDetails) + self.detailsTextWidget.hide() + self.visibleDetails = visible + + def _showDetails(self, checked): + if checked: + self.origHeight = self.height() + self.resize(self.width(), self.height() + 300) + self.detailsTextWidget.show() + else: + self.detailsTextWidget.hide() + func = partial(self.resize, self.width(), self.origHeight) + QTimer.singleShot(10, func) + + def _resize(self): + if self.resizeButtons: + widths = [button.width() for button in self.buttons] + if widths: + max_width = max(widths) + for button in self.buttons: + if button == self.cancelButton: + continue + button.setMinimumWidth(max_width) + + heights = [button.height() for button in self.buttons] + if heights: + max_h = max(heights) + for button in self.buttons: + button.setMinimumHeight(max_h) + if self.detailsTextWidget is not None: + self.detailsButton.setMinimumHeight(max_h) + if self.showInFileManagButton is not None: + self.showInFileManagButton.setMinimumHeight(max_h) + + if self._w is not None and self.width() < self._w: + self.resize(self._w, self.height()) + + if self.width() < 350: + self.resize(350, self.height()) + + if self.enlargeWidthFactor > 0: + self.resize(int(self.width() * self.enlargeWidthFactor), self.height()) + + if self.visibleDetails: + self.detailsButton.click() + + if self.showCentered: + screen = self.screen() + screenWidth = screen.size().width() + screenHeight = screen.size().height() + screenLeft = screen.geometry().x() + screenTop = screen.geometry().y() + w, h = self.width(), self.height() + left = int(screenLeft + screenWidth / 2 - w / 2) + top = int(screenTop + screenHeight / 2 - h / 2) + if top < screenTop: + top = screenTop + if left < screenLeft: + left = screenLeft + self.move(left, top) + + self._h = self.height() + + if self.okButton is not None: + self.okButton.setFocus() + + screen = self.screen() + screenWidth = screen.size().width() + screenHeight = screen.size().height() + + # Check Force wrap Text + for labelWidget in self.labelsWidgets: + textWidth = labelWidget.width() + if not textWidth > screenWidth - 10: + continue + factor = np.ceil(textWidth / screenWidth) + lineLength = int(labelWidget.nCharsLongestLine / factor) + for label in labelWidget.labels: + if isinstance(label, CopiableCommandWidget): + continue + + text = label.text() + chunks = textwrap.wrap(text, lineLength) + text = "
    ".join(chunks) + label.setText(text) + + QTimer.singleShot(100, self._resizeWrappedText) + + if self.widgets: + return + + if self.layouts: + return + + # # Start resizing height every 1 ms + # self.resizeCallsCount = 0 + # self.timer = QTimer() + # from config import warningHandler + # warningHandler.sigGeometryWarning.connect(self.timer.stop) + # self.timer.timeout.connect(self._resizeHeight) + # self.timer.start(1) + + def _resizeWrappedText(self): + screenWidth = self.screen().size().width() - 5 + self.resize(screenWidth, self.height()) + screenLeft = self.screen().geometry().left() + self.move(screenLeft, self.geometry().top()) + + def _resizeHeight(self): + try: + # Resize until a "Unable to set geometry" warning is captured + # by copnfig.warningHandler._resizeWarningHandler or # + # height doesn't change anymore + self.resize(self.width(), self.height() - 1) + if self.height() == self._h or self.resizeCallsCount > 100: + self.timer.stop() + return + + self.resizeCallsCount += 1 + self._h = self.height() + except Exception as e: + # traceback.format_exc() + self.timer.stop() + + def _template( + self, + parent, + title, + message, + detailsText=None, + buttonsTexts=None, + layouts=None, + widgets=None, + commands=None, + path_to_browse=None, + browse_button_text=None, + url_to_open=None, + open_url_button_text="Open url", + image_paths=None, + wrapDetails=True, + add_do_not_show_again_checkbox=False, + ): + if parent is not None: + self.setParent(parent) + self.setWindowTitle(title) + self.addText(message) + if commands is not None: + if isinstance(commands, str): + commands = (commands,) + for command in commands: + self.addCopiableCommand(command) + + if image_paths is not None: + if isinstance(image_paths, str): + image_paths = (image_paths,) + for image_path in image_paths: + self.addImage(image_path) + + if layouts is not None: + if myutils.is_iterable(layouts): + for layout in layouts: + self.addLayout(layout) + else: + self.addLayout(layout) + + if widgets is not None: + self._layout.addItem(QSpacerItem(20, 20), self.currentRow, 1) + self.currentRow += 1 + if myutils.is_iterable(widgets): + for widget in widgets: + self.addWidget(widget) + else: + self.addWidget(widgets) + + if path_to_browse is not None: + self.addShowInFileManagerButton(path_to_browse, txt=browse_button_text) + + if url_to_open is not None: + self.addBrowseUrlButton(url_to_open, button_text=open_url_button_text) + + buttons = [] + if buttonsTexts is None: + okButton = self.addButton(" Ok ") + buttons.append(okButton) + elif isinstance(buttonsTexts, str): + button = self.addButton(buttonsTexts) + buttons.append(button) + else: + for buttonText in buttonsTexts: + button = self.addButton(buttonText) + buttons.append(button) + + if detailsText is not None: + self.setDetailedText(detailsText, visible=True, wrap=wrapDetails) + + if add_do_not_show_again_checkbox: + self.addDoNotShowAgainCheckbox() + + return buttons + + def critical(self, *args, showDialog=True, **kwargs): + self.setIcon(iconName="SP_MessageBoxCritical") + buttons = self._template(*args, **kwargs) + if showDialog: + self.exec_() + return buttons + + def information(self, *args, showDialog=True, **kwargs): + self.setIcon(iconName="SP_MessageBoxInformation") + buttons = self._template(*args, **kwargs) + if showDialog: + self.exec_() + return buttons + + def warning(self, *args, showDialog=True, **kwargs): + self.setIcon(iconName="SP_MessageBoxWarning") + buttons = self._template(*args, **kwargs) + if showDialog: + self.exec_() + return buttons + + def question(self, *args, showDialog=True, **kwargs): + self.setIcon(iconName="SP_MessageBoxQuestion") + buttons = self._template(*args, **kwargs) + if showDialog: + self.exec_() + return buttons + + def _block(self): + self.loop = QEventLoop() + self.loop.exec_() + + def exec_(self): + self.show(block=True) + + def clickButtonFromText(self, buttonText): + for button in self.buttons: + if button.text() == buttonText: + button.click() + return + + def buttonCallBack(self, checked=True): + self.clickedButton = self.sender() + if self.clickedButton != self.cancelButton: + self.cancel = False + self.allowClose = True + self.close() + + def closeEvent(self, event): + if not self.allowClose: + event.ignore() + return + super().closeEvent(event) + + +def macShortcutToWindows(shortcut: str): + if shortcut is None: + return + + s = ( + shortcut.replace("Control", "Meta") + .replace("Option", "Alt") + .replace("Command", "Ctrl") + ) + return s + + +def windowsShortcutToMac(shortcut: str): + if shortcut is None: + return + + if not is_mac: + return shortcut + + s = ( + shortcut.replace("Meta", "Control") + .replace("Alt", "Option") + .replace("Ctrl", "Command") + ) + return s + + +class ManualTrackingToolBar(ToolBar): + sigIDchanged = Signal(int) + sigDisableGhost = Signal() + sigClearGhostContour = Signal() + sigClearGhostMask = Signal() + sigGhostOpacityChanged = Signal(int) + + def __init__(self, *args) -> None: + super().__init__(*args) + self.spinboxID = self.addSpinBox(label="ID to track: ") + self.spinboxID.setMinimum(1) + + self.addSeparator() + + self.showGhostCheckbox = QCheckBox("Show ghost object") + self.showGhostCheckbox.setChecked(True) + self.addWidget(self.showGhostCheckbox) + + self.ghostContourRadiobutton = QRadioButton("Contour") + self.ghostMaskRadiobutton = QRadioButton("Mask ; ") + self.ghostMaskRadiobutton.setChecked(True) + self.addWidget(self.ghostContourRadiobutton) + self.addWidget(self.ghostMaskRadiobutton) + + self.ghostMaskOpacitySpinbox = self.addSpinBox("Mask opacity: ") + self.ghostMaskOpacitySpinbox.setMaximum(100) + self.ghostMaskOpacitySpinbox.setValue(30) + + self.showGhostCheckbox.toggled.connect(self.showGhostCheckboxToggled) + self.ghostContourRadiobutton.toggled.connect( + self.ghostContourRadiobuttonToggled + ) + self.spinboxID.valueChanged.connect(self.IDchanged) + + self.ghostMaskOpacitySpinbox.valueChanged.connect(self.ghostOpacityValueChanged) + + self.addSeparator() + + self.infoLabel = QLabel("") + self.addWidget(self.infoLabel) + + def showInfo(self, text): + text = html_utils.paragraph(text, font_color="black") + self.infoLabel.setText(text) + + def showWarning(self, text): + text = html_utils.paragraph(f"WARNING: {text}", font_color="red") + self.infoLabel.setText(text) + + def clearInfoText(self): + self.infoLabel.setText("") + + def IDchanged(self, value): + self.sigIDchanged.emit(value) + + def showGhostCheckboxToggled(self, checked): + disabled = not checked + self.ghostContourRadiobutton.setDisabled(disabled) + self.ghostMaskRadiobutton.setDisabled(disabled) + self.ghostMaskOpacitySpinbox.setDisabled(disabled) + self.ghostMaskOpacitySpinbox.label.setDisabled(disabled) + if disabled: + self.sigDisableGhost.emit() + + def ghostContourRadiobuttonToggled(self, checked): + self.ghostMaskOpacitySpinbox.setDisabled(checked) + self.ghostMaskOpacitySpinbox.label.setDisabled(checked) + if checked: + self.sigClearGhostMask.emit() + else: + self.sigClearGhostContour.emit() + + def ghostOpacityValueChanged(self, value): + self.sigGhostOpacityChanged.emit(value) + + +class ManualBackgroundToolBar(ToolBar): + sigIDchanged = Signal(int) + + def __init__(self, *args) -> None: + super().__init__(*args) + self.spinboxID = self.addSpinBox(label="Set background of ID ") + self.spinboxID.setMinimum(1) + self.spinboxID.valueChanged.connect(self.IDchanged) + + self.infoLabel = QLabel("") + self.addWidget(self.infoLabel) + + def IDchanged(self, value): + self.sigIDchanged.emit(value) + + def showWarning(self, text): + text = html_utils.paragraph(f"WARNING: {text}", font_color="red") + self.infoLabel.setText(text) + + def clearInfoText(self): + self.infoLabel.setText("") + + +class SavePointsLayerButton(rightClickToolButton): + sigRenameTableAction = Signal(object, str) + + def __init__(self, table_endname, parent=None): + super().__init__(parent=parent) + self.setIcon(QIcon(":file-save.svg")) + + self.table_endname = table_endname + + self.setToolTip( + "Save annotated points in the CSV file ending " + f"with '{self.table_endname}.csv'" + ) + + self.sigRightClick.connect(self.showContextMenu) + + def showContextMenu(self, event): + contextMenu = QMenu(self) + contextMenu.addSeparator() + + renameAction = QAction("Rename points layer table") + renameAction.triggered.connect(self.renameTable) + contextMenu.addAction(renameAction) + + contextMenu.exec(event.globalPos()) + + def renameTable(self): + win = apps.filenameDialog( + parent=self, + title="Rename points layer table file", + allowEmpty=False, + defaultEntry=self.table_endname, + ext=".csv", + ) + win.exec_() + if win.cancel: + return + + self.table_endname = win.entryText + self.setToolTip( + "Save annotated points in the CSV file ending " + f"with '{self.table_endname}.csv'" + ) + self.sigRenameTableAction.emit(self, self.table_endname) + + +class Toggle(QCheckBox): + def __init__( + self, + label_text="", + initial=None, + width=80, + bg_color="#b3b3b3", + circle_color="#ffffff", + active_color="#26dd66", # '#005ce6', + animation_curve=QEasingCurve.Type.InOutQuad, + ): + QCheckBox.__init__(self) + + # self.setFixedSize(width, 28) + self.setCursor(Qt.PointingHandCursor) + + self._label_text = label_text + self._bg_color = bg_color + self._circle_color = circle_color + self._active_color = active_color + self._disabled_active_color = colors.lighten_color(active_color) + self._disabled_circle_color = colors.lighten_color(circle_color) + self._disabled_bg_color = colors.lighten_color(bg_color, amount=0.5) + self._circle_margin = 4 + + self._circle_position = int(self._circle_margin / 2) + self.animation = QPropertyAnimation(self, b"circle_position", self) + self.animation.setEasingCurve(animation_curve) + self.animation.setDuration(200) + + self.stateChanged.connect(self.start_transition) + self.requestedState = None + + self.installEventFilter(self) + self._isChecked = False + + if initial is not None: + self.setChecked(initial) + + def sizeHint(self): + return QSize(36, 18) + + def eventFilter(self, object, event): + # To get the actual position of the circle we need to wait that + # the widget is visible before setting the state + if event.type() == QEvent.Type.Show and self.requestedState is not None: + self.setChecked(self.requestedState) + return False + + def setChecked(self, state): + # To get the actual position of the circle we need to wait that + # the widget is visible before setting the state + self._isChecked = state + if self.isVisible(): + self.requestedState = None + QCheckBox.setChecked(self, state > 0) + else: + self.requestedState = state + + def isChecked(self): + if self.isVisible(): + return super().isChecked() + else: + return self._isChecked + + def circlePos(self, state: bool): + start = int(self._circle_margin / 2) + if state: + if self.isVisible(): + height, width = self.height(), self.width() + else: + sizeHint = self.sizeHint() + height, width = sizeHint.height(), sizeHint.width() + circle_diameter = height - self._circle_margin + pos = width - start - circle_diameter + else: + pos = start + return pos + + @Property(float) + def circle_position(self): + return self._circle_position + + @circle_position.setter + def circle_position(self, pos): + self._circle_position = pos + self.update() + + def start_transition(self, state): + self.animation.stop() + pos = self.circlePos(state) + self.animation.setEndValue(pos) + self.animation.start() + + def hitButton(self, pos: QPoint): + return self.contentsRect().contains(pos) + + def setDisabled(self, disable): + QCheckBox.setDisabled(self, disable) + if hasattr(self, "label"): + self.label.setDisabled(disable) + self.update() + + def paintEvent(self, e): + circle_color = ( + self._circle_color if self.isEnabled() else self._disabled_circle_color + ) + active_color = ( + self._active_color if self.isEnabled() else self._disabled_active_color + ) + unchecked_color = ( + self._bg_color if self.isEnabled() else self._disabled_bg_color + ) + + # set painter + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + # set no pen + p.setPen(Qt.NoPen) + + # draw rectangle + rect = QRect(0, 0, self.width(), self.height()) + + if not self.isChecked(): + # Draw background + p.setBrush(QColor(unchecked_color)) + half_h = int(self.height() / 2) + p.drawRoundedRect(0, 0, rect.width(), self.height(), half_h, half_h) + + # Draw circle + p.setBrush(QColor(circle_color)) + p.drawEllipse( + int(self._circle_position), + int(self._circle_margin / 2), + self.height() - self._circle_margin, + self.height() - self._circle_margin, + ) + else: + # Draw background + p.setBrush(QColor(active_color)) + half_h = int(self.height() / 2) + p.drawRoundedRect(0, 0, rect.width(), self.height(), half_h, half_h) + + # Draw circle + p.setBrush(QColor(circle_color)) + p.drawEllipse( + int(self._circle_position), + int(self._circle_margin / 2), + self.height() - self._circle_margin, + self.height() - self._circle_margin, + ) + + p.end() + + +def QKeyEventToString(event: QKeyEvent, notAllowedModifier=None): + isAltKey = event.key() == Qt.Key_Alt + isCtrlKey = event.key() == Qt.Key_Control + isShiftKey = event.key() == Qt.Key_Shift + isModifierKey = isAltKey or isCtrlKey or isShiftKey + + modifiers = event.modifiers() + isNotAllowedMod = notAllowedModifier is not None and modifiers == notAllowedModifier + if isNotAllowedMod: + return + + modifers_value = modifiers.value if PYQT6 else modifiers + if isModifierKey: + keySequenceText = KeySequenceFromText(modifers_value).toString() + else: + keySequenceText = QKeySequence(modifers_value | event.key()).toString() + + keySequenceText = keySequenceText.encode("ascii", "ignore").decode("utf-8") + + return keySequenceText + + +class ShortcutLineEdit(QLineEdit): + def __init__(self, parent=None, allowModifiers=False, notAllowedModifier=None): + self.keySequence = None + super().__init__(parent) + self._allowModifiers = allowModifiers + self._notAllowedModifier = notAllowedModifier + self.setAlignment(Qt.AlignCenter) + + def text(self): + text = macShortcutToWindows(super().text()) + + return text + + def setText(self, text): + text = windowsShortcutToMac(text) + + super().setText(text) + if not text: + self.keySequence = None + return + try: + self.keySequence = KeySequenceFromText(self.text()) + except Exception as e: + pass + + def keyPressEvent(self, event: QKeyEvent): + if event.key() == Qt.Key_Backspace or event.key() == Qt.Key_Delete: + self.setText("") + return + + keySequenceText = QKeyEventToString( + event, notAllowedModifier=self._notAllowedModifier + ) + self.setText(keySequenceText) + self.key = event.key() + + def keyReleaseEvent(self, event: QKeyEvent) -> None: + if self.text().endswith("+"): + if not self._allowModifiers: + self.setText("") + else: + self.setText(self.text().rstrip("+").strip()) + + +class selectStartStopFrames(QGroupBox): + def __init__(self, SizeT, currentFrameNum=0, parent=None): + super().__init__(parent) + selectFramesLayout = QGridLayout() + + self.startFrame_SB = QSpinBox() + self.startFrame_SB.setAlignment(Qt.AlignCenter) + self.startFrame_SB.setMinimum(1) + self.startFrame_SB.setMaximum(SizeT - 1) + self.startFrame_SB.setValue(currentFrameNum) + + self.stopFrame_SB = QSpinBox() + self.stopFrame_SB.setAlignment(Qt.AlignCenter) + self.stopFrame_SB.setMinimum(1) + self.stopFrame_SB.setMaximum(SizeT) + self.stopFrame_SB.setValue(SizeT) + + selectFramesLayout.addWidget(QLabel("Start frame n."), 0, 0) + selectFramesLayout.addWidget(self.startFrame_SB, 1, 0) + + selectFramesLayout.addWidget(QLabel("Stop frame n."), 0, 1) + selectFramesLayout.addWidget(self.stopFrame_SB, 1, 1) + + self.warningLabel = QLabel() + palette = self.warningLabel.palette() + palette.setColor(self.warningLabel.backgroundRole(), Qt.red) + palette.setColor(self.warningLabel.foregroundRole(), Qt.red) + self.warningLabel.setPalette(palette) + selectFramesLayout.addWidget( + self.warningLabel, 2, 0, 1, 2, alignment=Qt.AlignCenter + ) + + self.setLayout(selectFramesLayout) + + self.stopFrame_SB.valueChanged.connect(self._checkRange) + + def _checkRange(self): + start = self.startFrame_SB.value() + stop = self.stopFrame_SB.value() + if stop <= start: + self.warningLabel.setText("stop frame smaller than start frame") + else: + self.warningLabel.setText("") + + +class formWidget(QWidget): + sigApplyButtonClicked = Signal(object) + sigComputeButtonClicked = Signal(object) + + def __init__( + self, + widget, + initialVal=None, + stretchWidget=True, + widgetAlignment=None, + labelTextLeft="", + labelTextRight="", + font=None, + addInfoButton=False, + addApplyButton=False, + addComputeButton=False, + addActivateCheckbox=False, + key="", + infoTxt="", + valueGetterName="value", + toolTip="", + parent=None, + ): + QWidget.__init__(self, parent) + self.widget = widget + self.key = key + self.infoTxt = infoTxt + self.widgetAlignment = widgetAlignment + self.valueGetterName = valueGetterName + + widget.setParent(self) + + if isinstance(initialVal, bool): + widget.setChecked(initialVal) + elif isinstance(initialVal, str): + widget.setCurrentText(initialVal) + elif isinstance(initialVal, float) or isinstance(initialVal, int): + widget.setValue(initialVal) + + self.items = [] + + if font is None: + font = QFont() + font.setPixelSize(13) + + self.labelLeft = QClickableLabel(widget) + self.labelLeft.setText(labelTextLeft) + self.labelLeft.setFont(font) + self.items.append(self.labelLeft) + + if not stretchWidget: + widgetLayout = QHBoxLayout() + if widgetAlignment != "left": + widgetLayout.addStretch(1) + widgetLayout.addWidget(widget) + if widgetAlignment != "right": + widgetLayout.addStretch(1) + self.items.append(widgetLayout) + else: + self.items.append(widget) + + self.labelRight = QClickableLabel(widget) + self.labelRight.setText(labelTextRight) + self.labelRight.setFont(font) + self.items.append(self.labelRight) + + if toolTip: + self.labelLeft.setToolTip(toolTip) + self.widget.setToolTip(toolTip) + self.labelRight.setToolTip(toolTip) + + if addInfoButton: + infoButton = QPushButton(self) + infoButton.setCursor(Qt.WhatsThisCursor) + infoButton.setIcon(QIcon(":info.svg")) + if labelTextLeft: + infoButton.setToolTip(f'Info about "{self.labelLeft.text()}" parameter') + else: + infoButton.setToolTip( + f'Info about "{self.labelRight.text()}" measurement' + ) + infoButton.clicked.connect(self.showInfo) + self.infoButton = infoButton + self.items.append(infoButton) + + if addApplyButton: + applyButton = QPushButton(self) + applyButton.setCursor(Qt.PointingHandCursor) + applyButton.setCheckable(True) + applyButton.setIcon(QIcon(":apply.svg")) + applyButton.setToolTip(f"Apply this step and visualize results") + applyButton.clicked.connect(self.applyButtonClicked) + self.items.append(applyButton) + + if addComputeButton: + computeButton = QPushButton(self) + computeButton.setCursor(Qt.BusyCursor) + computeButton.setIcon(QIcon(":compute.svg")) + computeButton.setToolTip(f"Compute this step and visualize results") + computeButton.clicked.connect(self.computeButtonClicked) + self.items.append(computeButton) + + self.activateCheckbox = None + if addActivateCheckbox: + self.activateCheckbox = QCheckBox("Activate") + self.activateCheckbox.setChecked(False) + self.widget.setDisabled(True) + self.activateCheckbox.toggled.connect(self.setWidgetEnabled) + self.items.append(self.activateCheckbox) + + self.labelLeft.clicked.connect(self.tryChecking) + self.labelRight.clicked.connect(self.tryChecking) + + def setWidgetEnabled(self, checked): + self.widget.setDisabled(not checked) + + def value(self): + if self.activateCheckbox is None: + return getattr(self.widget, self.valueGetterName)() + + if not self.activateCheckbox.isChecked(): + return + + return getattr(self.widget, self.valueGetterName)() + + def tryChecking(self, label): + try: + self.widget.setChecked(not self.widget.isChecked()) + except AttributeError as e: + pass + + def applyButtonClicked(self): + self.sigApplyButtonClicked.emit(self) + + def computeButtonClicked(self): + self.sigComputeButtonClicked.emit(self) + + def showInfo(self): + msg = myMessageBox() + msg.setIcon() + msg.setWindowTitle(f"{self.labelLeft.text()} info") + msg.addText(self.infoTxt) + msg.addButton(" Ok ") + msg.exec_() + + def setDisabled(self, disabled: bool) -> None: + for item in self.items: + try: + item.setDisabled(disabled) + except Exception as err: + pass + + +class ToggleTerminalButton(PushButton): + sigClicked = Signal(bool) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":terminal_up.svg")) + self.setFixedSize(34, 18) + self.setIconSize(QSize(30, 14)) + self.setFlat(True) + self.terminalVisible = False + self.clicked.connect(self.mouseClick) + + def mouseClick(self): + if self.terminalVisible: + self.setIcon(QIcon(":terminal_up.svg")) + self.terminalVisible = False + else: + self.setIcon(QIcon(":terminal_down.svg")) + self.terminalVisible = True + self.sigClicked.emit(self.terminalVisible) + + def showEvent(self, a0) -> None: + self.idlePalette = self.palette() + return super().showEvent(a0) + + def enterEvent(self, event) -> None: + self.setFlat(False) + # pal = self.palette() + # pal.setColor(QPalette.ColorRole.Button, QColor(200, 200, 200)) + # self.setAutoFillBackground(True) + # self.setPalette(pal) + self.update() + return super().enterEvent(event) + + def leaveEvent(self, event) -> None: + self.setFlat(True) + # self.setPalette(self.idlePalette) + self.update() + return super().leaveEvent(event) + + +class CenteredDoubleSpinbox(QDoubleSpinBox): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setAlignment(Qt.AlignCenter) + self.setMaximum(2**31 - 1) + + +class readOnlyDoubleSpinbox(QDoubleSpinBox): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setReadOnly(True) + self.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) + self.setAlignment(Qt.AlignCenter) + self.setMaximum(2**31 - 1) + + +class readOnlySpinbox(QSpinBox): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setReadOnly(True) + self.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) + self.setAlignment(Qt.AlignCenter) + self.setMaximum(2**31 - 1) + + +class DoubleSpinBox(QDoubleSpinBox): + sigValueChanged = Signal(int) + + def __init__(self, parent=None, disableKeyPress=False): + super().__init__(parent=parent) + self.setAlignment(Qt.AlignCenter) + self.setMaximum(2**31 - 1) + self.setMinimum(-(2**31)) + self._valueChangedFunction = None + self.disableKeyPress = disableKeyPress + + def keyPressEvent(self, event) -> None: + isBackSpaceKey = event.key() == Qt.Key_Backspace + isDeleteKey = event.key() == Qt.Key_Delete + try: + int(event.text()) + isIntegerKey = True + except: + isIntegerKey = False + acceptEvent = isBackSpaceKey or isDeleteKey or isIntegerKey + if self.disableKeyPress and not acceptEvent: + event.ignore() + self.clearFocus() + else: + super().keyPressEvent(event) + + def textFromValue(self, value: float) -> str: + text = super().textFromValue(value) + return text.replace(QLocale().decimalPoint(), ".") + + def valueFromText(self, text: str) -> float: + text = text.replace(".", QLocale().decimalPoint()) + return super().valueFromText(text) + + +class SpinBox(QSpinBox): + sigValueChanged = Signal(int) + sigUpClicked = Signal() + sigDownClicked = Signal() + + def __init__(self, parent=None, disableKeyPress=False, allowNegative=True): + super().__init__(parent=parent) + self.setAlignment(Qt.AlignCenter) + self.setMaximum(2**31 - 1) + if allowNegative: + self.setMinimum(-(2**31)) + else: + self.setMinimum(0) + self._valueChangedFunction = None + self.disableKeyPress = disableKeyPress + self._linkedWidget = None + + def mousePressEvent(self, event) -> None: + super().mousePressEvent(event) + opt = QStyleOptionSpinBox() + self.initStyleOption(opt) + + control = self.style().hitTestComplexControl( + QStyle.ComplexControl.CC_SpinBox, opt, event.pos(), self + ) + if control == QStyle.SubControl.SC_SpinBoxUp: + self.sigUpClicked.emit() + elif control == QStyle.SubControl.SC_SpinBoxDown: + self.sigDownClicked.emit() + + # def focusOutEvent(self, event): + # self.editingFinished.emit() + # super().focusOutEvent(event) + # printl('emitted') + + def keyPressEvent(self, event) -> None: + isBackSpaceKey = event.key() == Qt.Key_Backspace + isDeleteKey = event.key() == Qt.Key_Delete + try: + int(event.text()) + isIntegerKey = True + except: + isIntegerKey = False + acceptEvent = isBackSpaceKey or isDeleteKey or isIntegerKey + if self.disableKeyPress and not acceptEvent: + event.ignore() + self.clearFocus() + else: + super().keyPressEvent(event) + + def connectValueChanged(self, function): + self._valueChangedFunction = function + self.valueChanged.connect(function) + + def setValue(self, value, setLinkedWidget=True): + super().setValue(int(value)) + if self._linkedWidget is not None and setLinkedWidget: + self._linkedWidget.setValue(value) + + def setValueNoEmit(self, value): + if self._valueChangedFunction is None: + self.setValue(value) + return + try: + self.valueChanged.disconnect() + except TypeError as e: # this fails if its not cennected yet + pass + + self.setValue(value) + self.valueChanged.connect(self._valueChangedFunction) + + def wheelEvent(self, event): + event.ignore() + + def setLinkedValueWidget(self, widget): + self._linkedWidget = widget + + +class ReadOnlyLineEdit(QLineEdit): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setReadOnly(True) + # self.setStyleSheet( + # 'background-color: rgba(240, 240, 240, 200);' + # ) + self.installEventFilter(self) + + def eventFilter(self, a0: "QObject", a1: "QEvent") -> bool: + if a1.type() == QEvent.Type.FocusIn: + return True + return super().eventFilter(a0, a1) + + def setValue(self, value): + self.setText(str(value)) + + def value(self, casting_func: callable = None): + text = self.text() + if casting_func is not None: + return casting_func(text) + return text + + +class FloatLineEdit(QLineEdit): + valueChanged = Signal(float) + + def __init__( + self, + *args, + notAllowed=None, + allowNegative=True, + initial=None, + readOnly=False, + decimals=6, + warningValues=None, + ): + QLineEdit.__init__(self, *args) + if readOnly: + self.setReadOnly(readOnly) + self.notAllowed = notAllowed + self.warningValues = warningValues + self._maximum = np.inf + self._minimum = -np.inf + self._decimals = decimals + + self.isNumericRegExp = rf"^{float_regex(allow_negative=allowNegative)}$" + regExp = QRegularExpression(self.isNumericRegExp) + self.setValidator(QRegularExpressionValidator(regExp)) + self.setAlignment(Qt.AlignCenter) + + font = QFont() + font.setPixelSize(11) + self.setFont(font) + + self.textChanged.connect(self.emitValueChanged) + + if initial is not None: + self.setValue(initial) + else: + self.setValue(0) + + def setDecimals(self, decimals): + self._decimals = 6 + + def castMinMax(self, value: int): + if value > self._maximum: + value = self._maximum + if value < self._minimum: + value = self._minimum + return value + + def setValue(self, value: float): + value = self.castMinMax(value) + self.setText(str(round(value, self._decimals))) + + def value(self): + m = re.match(self.isNumericRegExp, self.text()) + if m is not None: + text = m.group(0) + try: + val = float(text) + except ValueError: + val = 0.0 + else: + val = 0.0 + + return self.castMinMax(val) + + def setMaximum(self, maximum): + self._maximum = maximum + self.setValue(self.value()) + + def setMinimum(self, minimum): + self._minimum = minimum + self.setValue(self.value()) + + def emitValueChanged(self, text): + val = self.value() + reset_stylesheet = True + if self.warningValues is not None and val in self.warningValues: + self.setStyleSheet(LINEEDIT_WARNING_STYLESHEET) + reset_stylesheet = False + + if self.notAllowed is not None and val in self.notAllowed: + self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) + reset_stylesheet = False + else: + self.valueChanged.emit(self.value()) + + if reset_stylesheet: + self.setStyleSheet("") + + +class IntLineEdit(QLineEdit): + valueChanged = Signal(float) + + def __init__( + self, *args, notAllowed=None, allowNegative=True, initial=None, readOnly=False + ): + QLineEdit.__init__(self, *args) + self.notAllowed = notAllowed + if readOnly: + self.setReadOnly(readOnly) + + self._maximum = np.inf + self._minimum = -np.inf + + self._regExp = r"\d+" + if allowNegative: + self._regExp = r"-?\d+" + + regExp = QRegularExpression(self._regExp) + self.setValidator(QRegularExpressionValidator(regExp)) + self.setAlignment(Qt.AlignCenter) + + font = QFont() + font.setPixelSize(11) + self.setFont(font) + + self.textChanged.connect(self.emitValueChanged) + + if initial is not None: + self.setValue(initial) + else: + self.setValue(0) + + def setMaximum(self, maximum): + self._maximum = maximum + self.setValue(self.value()) + + def setMinimum(self, minimum): + self._minimum = minimum + self.setValue(self.value()) + + def castMinMax(self, value: int): + if value > self._maximum: + value = self._maximum + if value < self._minimum: + value = self._minimum + return value + + def setValue(self, value: int): + value = self.castMinMax(value) + self.setText(str(value)) + + def value(self): + m = re.match(self._regExp, self.text()) + if m is not None: + text = m.group(0) + try: + val = int(text) + except ValueError: + val = 0 + else: + val = 0 + + return self.castMinMax(val) + + def emitValueChanged(self, text): + if not text: + return + + val = self.value() + self.setValue(val) + if self.notAllowed is not None and val in self.notAllowed: + self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) + else: + self.setStyleSheet("") + self.valueChanged.emit(self.value()) + + +class CheckboxesGroupBox(QGroupBox): + def __init__(self, texts, title="", checkable=False, parent=None): + super().__init__(parent) + + self.setTitle(title) + self.setCheckable(checkable) + layout = QVBoxLayout() + + scrollLayout = QVBoxLayout() + container = QWidget() + scrollarea = QScrollArea() + + self.checkBoxes = [] + for text in texts: + checkbox = QCheckBox(text) + checkbox.setChecked(True) + scrollLayout.addWidget(checkbox) + self.checkBoxes.append(checkbox) + + container.setLayout(scrollLayout) + scrollarea.setWidget(container) + layout.addWidget(scrollarea) + + buttonsLayout = QHBoxLayout() + selectAllButton = selectAllPushButton() + selectAllButton.sigClicked.connect(self.checkAll) + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(selectAllButton) + layout.addLayout(buttonsLayout) + + self.setLayout(layout) + + def checkAll(self, button, checked): + for checkBox in self.checkBoxes: + checkBox.setChecked(checked) + + +class _metricsQGBox(QGroupBox): + sigDelClicked = Signal(str, object) + + def __init__( + self, + desc_dict, + title, + favourite_funcs=None, + isZstack=False, + equations=None, + addDelButton=False, + delButtonMetricsDesc=None, + parent=None, + addCalcForEachZsliceToggle=False, + ): + QGroupBox.__init__(self, parent) + + highlightRgba = _palettes._highlight_rgba() + r, g, b, a = highlightRgba + self._highlightStylesheetColor = f"rgb({r}, {g}, {b})" + + self._parent = parent + self.scrollArea = QScrollArea() + self.scrollAreaWidget = QWidget() + self.favourite_funcs = favourite_funcs + + self.doNotWarn = False + + layout = QVBoxLayout() + inner_layout = QVBoxLayout() + self.inner_layout = inner_layout + if delButtonMetricsDesc is None: + delButtonMetricsDesc = [] + + self.checkBoxes = [] + self.checkedState = {} + for metric_colname, metric_desc in desc_dict.items(): + rowLayout = QHBoxLayout() + + checkBox = QCheckBox(metric_colname) + checkBox.setChecked(True) + checkBox.scrollArea = self.scrollArea + self.checkBoxes.append(checkBox) + self.checkedState[checkBox] = True + + try: + checkBox.equation = equations[metric_colname] + except Exception as e: + pass + + if addDelButton or metric_colname in delButtonMetricsDesc: + delButton = delPushButton() + delButton.setToolTip("Delete custom combined measurement") + delButton.colname = metric_colname + delButton.checkbox = checkBox + delButton.clicked.connect(self.onDelClicked) + delButton._layout = rowLayout + rowLayout.addWidget(delButton) + + infoButton = infoPushButton() + infoButton.setCursor(Qt.WhatsThisCursor) + infoButton.info = metric_desc + infoButton.colname = metric_colname + infoButton.clicked.connect(self.showInfo) + + rowLayout.addWidget(infoButton) + rowLayout.addWidget(checkBox) + rowLayout.addStretch(1) + + inner_layout.addLayout(rowLayout) + + self.scrollAreaWidget.setLayout(inner_layout) + self.scrollArea.setWidget(self.scrollAreaWidget) + layout.addWidget(self.scrollArea) + + buttonsLayout = QHBoxLayout() + + buttonsLayout.addStretch(1) + + self.selectAllButton = selectAllPushButton() + self.selectAllButton.sigClicked.connect(self.checkAll) + + buttonsLayout.addWidget(self.selectAllButton) + + if favourite_funcs is not None: + self.loadFavouritesButton = reloadPushButton(" Load last selection... ") + self.loadFavouritesButton.clicked.connect(self.checkFavouriteFuncs) + # self.checkFavouriteFuncs() + buttonsLayout.addWidget(self.loadFavouritesButton) + + layout.addLayout(buttonsLayout) + + self.calcForEachZsliceToggle = None + if addCalcForEachZsliceToggle: + buttonsLayout = QHBoxLayout() + self.calcForEachZsliceToggle = Toggle() + tooltip = ( + "Calculate `cell_area` for each z-slice.\n\n" + "The measurements will be saved in the column with name\n" + "ending with `_zsliceN` where N is the z-slice number\n" + "(starting from 0)." + ) + calcForEachZsliceLabel = QClickableLabel("Calculate for each z-slice") + calcForEachZsliceLabel.setToolTip(tooltip) + self.calcForEachZsliceToggle.setToolTip(tooltip) + buttonsLayout.addWidget(self.calcForEachZsliceToggle) + buttonsLayout.addWidget(calcForEachZsliceLabel) + buttonsLayout.addStretch(1) + layout.addLayout(buttonsLayout) + calcForEachZsliceLabel.clicked.connect( + partial( + self.toggleCalcForEachZslice, toggle=self.calcForEachZsliceToggle + ) + ) + + self.setTitle(title) + self.setCheckable(True) + self.setLayout(layout) + _font = QFont() + _font.setPixelSize(11) + self.setFont(_font) + + self.toggled.connect(self.toggled_cb) + + def toggleCalcForEachZslice(self, label, toggle=None): + if toggle is None: + toggle = self.calcForEachZsliceToggle + + toggle.setChecked(not toggle.isChecked()) + + def isCalcForEachZsliceRequested(self): + if self.calcForEachZsliceToggle is None: + return False + + return self.calcForEachZsliceToggle.isChecked() + + def highlightCheckboxesFromSearchText(self, text): + for checkbox in self.checkBoxes: + if not text: + highlighted = False + else: + highlighted = checkbox.text().lower().find(text.lower()) != -1 + + self.setCheckboxHighlighted(highlighted, checkbox) + + def setCheckboxHighlighted(self, highlighted, checkbox): + if highlighted: + checkbox.setStyleSheet( + f"background: {self._highlightStylesheetColor}; color: black" + ) + self.scrollArea.ensureWidgetVisible(checkbox) + else: + checkbox.setStyleSheet("") + + def onDelClicked(self): + button = self.sender() + button.checkbox.setChecked(False) + self.sigDelClicked.emit(button.colname, button._layout) + + def toggled_cb(self, checked): + for checkbox in self.checkBoxes: + if not checked: + self.checkedState[checkbox] = checkbox.isChecked() + checkbox.setChecked(False) + else: + checkbox.setChecked(self.checkedState[checkbox]) + + def checkFavouriteFuncs(self, checked=True, isZstack=False): + self.doNotWarn = True + if self._parent is not None: + self._parent.doNotWarn = True + for checkBox in self.checkBoxes: + checkBox.setChecked(False) + for favourite_func in self.favourite_funcs: + func_name = checkBox.text() + if func_name.endswith(favourite_func): + checkBox.setChecked(True) + break + self.doNotWarn = False + if self._parent is not None: + self._parent.doNotWarn = False + + def checkAll(self, button, checked): + if self._parent is not None: + self._parent.doNotWarn = True + for checkBox in self.checkBoxes: + checkBox.setChecked(checked) + if self._parent is not None: + self._parent.doNotWarn = False + + def showInfo(self, checked=False): + info_txt = self.sender().info + msg = myMessageBox() + msg.setWidth(600) + msg.setIcon() + msg.setWindowTitle(f"{self.sender().colname} info") + msg.addText(info_txt) + msg.addButton(" Ok ") + msg.exec_() + + def show(self): + super().show() + fw = self.inner_layout.contentsRect().width() + sw = self.scrollArea.verticalScrollBar().sizeHint().width() + self.minWidth = fw + sw + + +class channelMetricsQGBox(QGroupBox): + sigDelClicked = Signal(str, object) + sigCheckboxToggled = Signal(object) + + def __init__( + self, + isZstack, + chName, + isSegm3D, + is_concat=False, + posData=None, + favourite_funcs=None, + ): + QGroupBox.__init__(self) + + self.doNotWarn = False + self.is_concat = is_concat + isManualBackgrPresent = False + if posData is not None: + if posData.manualBackgroundLab is not None: + isManualBackgrPresent = True + + layout = QVBoxLayout() + metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc( + isZstack, + chName, + isSegm3D=isSegm3D, + isManualBackgrPresent=isManualBackgrPresent, + ) + + metricsQGBox = _metricsQGBox( + metrics_desc, + "Standard measurements", + favourite_funcs=favourite_funcs, + parent=self, + isZstack=isZstack, + ) + self.metricsQGBox = metricsQGBox + + bkgrValsQGBox = _metricsQGBox( + bkgr_val_desc, + "Background values", + favourite_funcs=favourite_funcs, + parent=self, + isZstack=isZstack, + ) + self.bkgrValsQGBox = bkgrValsQGBox + + self.checkBoxes = metricsQGBox.checkBoxes.copy() + self.checkBoxes.extend(bkgrValsQGBox.checkBoxes) + + self.uncheckAndDisableDataPrepIfPosNotPrepped(posData) + + self.groupboxes = [metricsQGBox, bkgrValsQGBox] + + for checkbox in metricsQGBox.checkBoxes: + checkbox.toggled.connect(self.standardMetricToggled) + self.standardMetricToggled(checkbox.isChecked(), checkbox=checkbox) + + for bkgrCheckbox in bkgrValsQGBox.checkBoxes: + bkgrCheckbox.toggled.connect(self.backgroundMetricToggled) + + layout.addWidget(metricsQGBox) + layout.addWidget(bkgrValsQGBox) + + items = measurements.custom_metrics_desc( + isZstack, chName, posData=posData, isSegm3D=isSegm3D, return_combine=True + ) + custom_metrics_desc, combine_metrics_desc = items + + if custom_metrics_desc: + customMetricsQGBox = _metricsQGBox( + custom_metrics_desc, + "Custom measurements", + delButtonMetricsDesc=combine_metrics_desc, + favourite_funcs=favourite_funcs, + isZstack=isZstack, + ) + layout.addWidget(customMetricsQGBox) + self.checkBoxes.extend(customMetricsQGBox.checkBoxes) + customMetricsQGBox.sigDelClicked.connect(self.onDelClicked) + self.customMetricsQGBox = customMetricsQGBox + + self.calcForEachZsliceToggle = None + if isZstack: + buttonsLayout = QHBoxLayout() + self.calcForEachZsliceToggle = Toggle() + tooltip = ( + "Calculate the selected measurements for each z-slice.\n\n" + "The measurements will be saved in the column with name\n" + "ending with `_zsliceN` where N is the z-slice number\n" + "(starting from 0)." + ) + calcForEachZsliceLabel = QClickableLabel("Calculate for each z-slice") + calcForEachZsliceLabel.setToolTip(tooltip) + self.calcForEachZsliceToggle.setToolTip(tooltip) + buttonsLayout.addWidget(self.calcForEachZsliceToggle) + buttonsLayout.addWidget(calcForEachZsliceLabel) + buttonsLayout.addStretch(1) + layout.addLayout(buttonsLayout) + calcForEachZsliceLabel.clicked.connect( + partial( + self.toggleCalcForEachZslice, toggle=self.calcForEachZsliceToggle + ) + ) + + self.setTitle(f"{chName} metrics") + self.setCheckable(True) + self.setLayout(layout) + + def toggleCalcForEachZslice(self, label, toggle=None): + if toggle is None: + toggle = self.calcForEachZsliceToggle + + toggle.setChecked(not toggle.isChecked()) + + def isCalcForEachZsliceRequested(self): + if self.calcForEachZsliceToggle is None: + return False + + return self.calcForEachZsliceToggle.isChecked() + + def uncheckAndDisableDataPrepIfPosNotPrepped(self, posData): + # Uncheck and disable dataprep metrics if pos is not prepped + if posData is None: + return + + if posData.isBkgrROIpresent(): + return + + for checkbox in self.checkBoxes: + if checkbox.text().find("dataPrep") == -1: + continue + + checkbox.setChecked(False) + checkbox.isDataPrepDisabled = True + + def _warnDataPrepCannotBeChecked(self): + if self.doNotWarn: + return + txt = html_utils.paragraph(""" + Data prep measurements cannot be saved because you did + not select any background ROI at the data prep step.

    + + You can read more details about data prep metrics by clicking + on the info button besides the measurement's name.

    + + Thank you for you patience! + """) + msg = myMessageBox(showCentered=False) + msg.warning(self, "Metric cannot be saved", txt) + + def standardMetricToggled(self, checked, checkbox=None): + """Method called when a check-box is toggled. It performs the following + actions: + 1. If the user try to check a data prep measurement, such as + dataPrep_amount, and this cannot be saved (checkbox has the attr + `isDataPrepDisabled`) then it warns and explains why it cannot be saved + 2. Make sure that background value median is checked if the user + requires amount or concentration metric. + 3. Do not allow unchecking background value median and explain why. + + Parameters + ---------- + checked : bool + State of the checkbox toggled + checkbox : QtWidgets.QCheckBox, optional + The checkbox that has been toggled. Default is None. If None + use `self.sender()` + """ + if self.is_concat: + return + + if checkbox is None: + checkbox = self.sender() + + if hasattr(checkbox, "isDataPrepDisabled"): + # Warn that user cannot check data prep metrics and uncheck it + if not checkbox.isChecked(): + return + checkbox.setChecked(False) + self._warnDataPrepCannotBeChecked() + return + + self.sigCheckboxToggled.emit(checkbox) + if checkbox.text().find("amount_") == -1: + return + pattern = r"amount_([A-Za-z]+)(_?[A-Za-z0-9]*)" + repl = r"\g<1>_bkgrVal_median\g<2>" + bkgrValMetric = s1 = re.sub(pattern, repl, checkbox.text()) + for bkgrCheckbox in self.groupboxes[1].checkBoxes: + if bkgrCheckbox.text() == bkgrValMetric: + break + else: + # Make sure to not check for similarly named custom metrics + return + + if checked: + bkgrCheckbox.setChecked(True) + bkgrCheckbox.isRequired = True + else: + bkgrCheckbox.setDisabled(False) + bkgrCheckbox.isRequired = False + + def backgroundMetricToggled(self, checked): + """Method called when a checkbox of a background metric is toggled. + Check if the background value is required and explain why it cannot be + unchecked. + + Parameters + ---------- + checked : bool + State of the checkbox toggled + """ + if self.is_concat: + return + + checkbox = self.sender() + if not hasattr(checkbox, "isRequired"): + return + + if not checkbox.isRequired: + return + + if checkbox.isChecked(): + return + + if self.doNotWarn: + return + + checkbox.setChecked(True) + txt = html_utils.paragraph(""" + This background value cannot be unchecked because it is required + by the _amount and _concentration measurements + that you requested to save.

    + + Thank you for you patience! + """) + msg = myMessageBox(showCentered=False) + msg.warning(self, "Background value required", txt) + + def onDelClicked(self, colname_to_del, hlayout): + self.sigDelClicked.emit(colname_to_del, hlayout) + + def checkFavouriteFuncs(self): + self.doNotWarn = True + for groupbox in self.groupboxes: + groupbox.checkFavouriteFuncs() + self.doNotWarn = False + + +class PixelSizeGroupbox(QGroupBox): + sigValueChanged = Signal(float, float, float) + sigReset = Signal() + + def __init__(self, parent=None): + super().__init__("Pixel size", parent) + + mainLayout = QGridLayout() + + row = 0 + label = QLabel("Pixel width (μm): ") + self.pixelWidthWidget = FloatLineEdit(initial=1.0) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.pixelWidthWidget, row, 1) + + row += 1 + label = QLabel("Pixel height (μm): ") + self.pixelHeightWidget = FloatLineEdit(initial=1.0) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.pixelHeightWidget, row, 1) + + row += 1 + label = QLabel("Voxel depth (μm): ") + self.voxelDepthWidget = FloatLineEdit(initial=1.0) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.voxelDepthWidget, row, 1) + + row += 1 + resetButton = reloadPushButton("Reset") + mainLayout.addWidget(resetButton, row, 1, alignment=Qt.AlignRight) + + row += 1 + mainLayout.addWidget(QHLine(), row, 0, 1, 2) + + mainLayout.setColumnStretch(0, 0) + mainLayout.setColumnStretch(1, 1) + + self.setLayout(mainLayout) + + self.pixelWidthWidget.valueChanged.connect(self.emitValueChanged) + self.pixelHeightWidget.valueChanged.connect(self.emitValueChanged) + self.voxelDepthWidget.valueChanged.connect(self.emitValueChanged) + resetButton.clicked.connect(self.emitReset) + + def emitReset(self): + self.sigReset.emit() + + def emitValueChanged(self, value): + PhysicalSizeX = self.pixelWidthWidget.value() + PhysicalSizeY = self.pixelHeightWidget.value() + PhysicalSizeZ = self.voxelDepthWidget.value() + self.sigValueChanged.emit(PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) + + +class objPropsQGBox(QGroupBox): + def __init__(self, parent=None): + QGroupBox.__init__(self, "Properties", parent) + + mainLayout = QGridLayout() + + row = 0 + label = QLabel("Object ID: ") + self.idSB = IntLineEdit() + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.idSB, row, 1) + + row += 1 + mainLayout.addWidget(QHLine(), row, 0, 1, 2) + + row += 1 + self.notExistingIDLabel = QLabel() + self.notExistingIDLabel.setStyleSheet("font-size:11px; color: rgb(255, 0, 0);") + mainLayout.addWidget( + self.notExistingIDLabel, row, 0, 1, 2, alignment=Qt.AlignCenter + ) + + row += 1 + label = QLabel("Area (pixel): ") + self.cellAreaPxlSB = IntLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.cellAreaPxlSB, row, 1) + + row += 1 + label = QLabel("Area (µm2): ") + self.cellAreaUm2DSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.cellAreaUm2DSB, row, 1) + + row += 1 + mainLayout.addWidget(QHLine(), row, 0, 1, 2) + + row += 1 + label = QLabel("Rotational volume (voxel): ") + self.cellVolVoxSB = IntLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.cellVolVoxSB, row, 1) + + row += 1 + label = QLabel("3D volume (voxel): ") + self.cellVolVox3D_SB = IntLineEdit(readOnly=True) + self.cellVolVox3D_SB.label = label + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.cellVolVox3D_SB, row, 1) + + row += 1 + label = QLabel("Rotational volume (fl): ") + self.cellVolFlDSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.cellVolFlDSB, row, 1) + + row += 1 + label = QLabel("3D volume (fl): ") + self.cellVolFl3D_DSB = FloatLineEdit(readOnly=True) + self.cellVolFl3D_DSB.label = label + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.cellVolFl3D_DSB, row, 1) + + row += 1 + mainLayout.addWidget(QHLine(), row, 0, 1, 2) + + row += 1 + label = QLabel("Solidity: ") + self.solidityDSB = FloatLineEdit(readOnly=True) + self.solidityDSB.setMaximum(1) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.solidityDSB, row, 1) + + row += 1 + label = QLabel("Elongation: ") + self.elongationDSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.elongationDSB, row, 1) + + row += 1 + mainLayout.addWidget(QHLine(), row, 0, 1, 2) + + row += 1 + propsNames = measurements.get_props_names()[1:] + self.additionalPropsCombobox = QComboBox() + self.additionalPropsCombobox.addItems(propsNames) + self.additionalPropsCombobox.indicator = FloatLineEdit(readOnly=True) + mainLayout.addWidget(self.additionalPropsCombobox, row, 0) + mainLayout.addWidget(self.additionalPropsCombobox.indicator, row, 1) + + row += 1 + mainLayout.addWidget(QHLine(), row, 0, 1, 2) + + mainLayout.setColumnStretch(0, 0) + mainLayout.setColumnStretch(1, 1) + + self.setLayout(mainLayout) + + +class objIntesityMeasurQGBox(QGroupBox): + def __init__(self, parent=None): + QGroupBox.__init__(self, "Intensity measurements", parent) + + mainLayout = QGridLayout() + + row = 0 + label = QLabel("Raw intensity measurements") + + row += 1 + label = QLabel("Channel: ") + self.channelCombobox = QComboBox() + self.channelCombobox.addItem("placeholderlong") + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.channelCombobox, row, 1) + + row += 1 + label = QLabel("Minimum: ") + self.minimumDSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.minimumDSB, row, 1) + + row += 1 + label = QLabel("Maximum: ") + self.maximumDSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.maximumDSB, row, 1) + + row += 1 + label = QLabel("Mean: ") + self.meanDSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.meanDSB, row, 1) + + row += 1 + label = QLabel("Median: ") + self.medianDSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.medianDSB, row, 1) + + row += 1 + metricsDesc = measurements._get_metrics_names() + metricsFunc, _ = measurements.standard_metrics_func() + items = list(set([metricsDesc[key] for key in metricsFunc.keys()])) + items.append("Concentration") + items.sort() + nameFuncDict = {} + for name, desc in metricsDesc.items(): + if name.find("_dataPrepBkgr") != -1 or name.find("_manualBkgr") != -1: + # Skip dataPrepBkgr and manualBkgr since in the dock widget + # we display only autoBkgr metrics + continue + if name.startswith("concentration_"): + # We use amount function because dividing by volume is taken + # care in the GUI + name = "amount_autoBkgr" + nameFuncDict[desc] = metricsFunc[name] + + funcionCombobox = QComboBox() + funcionCombobox.addItems(items) + self.additionalMeasCombobox = funcionCombobox + self.additionalMeasCombobox.indicator = FloatLineEdit(readOnly=True) + self.additionalMeasCombobox.functions = nameFuncDict + mainLayout.addWidget(funcionCombobox, row, 0) + mainLayout.addWidget(self.additionalMeasCombobox.indicator, row, 1) + + self.setLayout(mainLayout) + + def addChannels(self, channels): + self.channelCombobox.clear() + self.channelCombobox.addItems(channels) + + +class guiTabControl(QTabWidget): + def __init__(self, *args): + super().__init__(args[0]) + + self._defaultPixelSize = None + + self.propsTab = QScrollArea(self) + + container = QWidget() + layout = QVBoxLayout() + + self.pixelSizeQGBox = PixelSizeGroupbox(parent=self.propsTab) + self.propsQGBox = objPropsQGBox(parent=self.propsTab) + self.intensMeasurQGBox = objIntesityMeasurQGBox(parent=self.propsTab) + + self.highlightCheckbox = QCheckBox("Highlight objects on mouse hover") + self.highlightCheckbox.setChecked(False) + + self.highlightSearchedCheckbox = QCheckBox("Highlight searched object") + self.highlightSearchedCheckbox.setChecked(True) + + highlightLayout = QHBoxLayout() + highlightLayout.addWidget(self.highlightCheckbox) + highlightLayout.addStretch(1) + highlightLayout.addWidget(QLabel("|")) + highlightLayout.addStretch(1) + highlightLayout.addWidget(self.highlightSearchedCheckbox) + + layout.addLayout(highlightLayout) + layout.addWidget(self.pixelSizeQGBox) + layout.addWidget(self.propsQGBox) + layout.addWidget(self.intensMeasurQGBox) + layout.addStretch(1) + container.setLayout(layout) + + self.propsTab.setWidgetResizable(True) + self.propsTab.setWidget(container) + self.addTab(self.propsTab, "Measurements") + + self.pixelSizeQGBox.sigValueChanged.connect(self.pixelSizeChanged) + self.pixelSizeQGBox.sigReset.connect(self.resetPixelSize) + + def addChannels(self, channels): + self.intensMeasurQGBox.addChannels(channels) + + def resetPixelSize(self): + if self._defaultPixelSize is None: + return + + self.initPixelSize(*self._defaultPixelSize) + + def initPixelSize(self, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ): + self.pixelSizeQGBox.pixelWidthWidget.setValue(PhysicalSizeX) + self.pixelSizeQGBox.pixelHeightWidget.setValue(PhysicalSizeY) + self.pixelSizeQGBox.voxelDepthWidget.setValue(PhysicalSizeZ) + self._defaultPixelSize = (PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) + + def pixelSizeChanged(self, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ): + propsQGBox = self.propsQGBox + yx_pxl_to_um2 = PhysicalSizeY * PhysicalSizeX + vox_rot_to_fl = float(PhysicalSizeY) * pow(float(PhysicalSizeX), 2) + vox_3D_to_fl = PhysicalSizeZ * PhysicalSizeY * PhysicalSizeX + + area_pxl = propsQGBox.cellAreaPxlSB.value() + area_um2 = area_pxl * yx_pxl_to_um2 + propsQGBox.cellAreaUm2DSB.setValue(area_um2) + + vol_rot_vox = propsQGBox.cellVolVoxSB.value() + vol_rot_fl = vol_rot_vox * vox_rot_to_fl + propsQGBox.cellVolFlDSB.setValue(vol_rot_fl) + + vol_3D_vox = propsQGBox.cellVolVox3D_SB.value() + vol_3D_fl = vol_3D_vox * vox_3D_to_fl + propsQGBox.cellVolFl3D_DSB.setValue(vol_3D_fl) + + +class expandCollapseButton(PushButton): + sigClicked = Signal() + + def __init__(self, parent=None, **kwargs): + super().__init__(parent, **kwargs) + self.setIcon(QIcon(":expand.svg")) + self.setFlat(True) + self.installEventFilter(self) + self.isExpand = True + self.clicked.connect(self.buttonClicked) + + def buttonClicked(self, checked=False): + if self.isExpand: + self.setIcon(QIcon(":collapse.svg")) + self.isExpand = False + if self.text(): + self.setText(self.text().replace("Hide", "Show")) + else: + self.setIcon(QIcon(":expand.svg")) + self.isExpand = True + if self.text(): + self.setText(self.text().replace("Show", "Hide")) + self.sigClicked.emit() + + def eventFilter(self, object, event): + if event.type() == QEvent.Type.HoverEnter: + self.setFlat(False) + elif event.type() == QEvent.Type.HoverLeave: + self.setFlat(True) + return False + + +class view_visualcpp_screenshot(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + layout = QHBoxLayout() + + self.setWindowTitle("Visual Studio Builld Tools installation") + + pixmap = QPixmap(":visualcpp.png") + label = QLabel() + label.setPixmap(pixmap) + + layout.addWidget(label) + self.setLayout(layout) + + +class ToggleVisibilityButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setFlat(True) + # self.setCheckable(True) + self._state = False + self.setIcon(QIcon(":unchecked.svg")) + self.clicked.connect(self.onClicked) + self.setStyleSheet(""" + QPushButton::pressed { + background-color: none; + border-style: none; + } + """) + + def onClicked(self): + self._state = not self._state + if self._state: + self.setIcon(QIcon(":eye-checked.svg")) + else: + self.setIcon(QIcon(":unchecked.svg")) + + +class ToggleVisibilityCheckBox(QCheckBox): + def __init__(self, *args, pixelSize=24): + super().__init__(*args) + self._pixelSize = pixelSize + self.onToggled(False) + self.toggled.connect(self.onToggled) + + def setPixelSize(self, pixelSize): + self._pixelSize = pixelSize + + def onToggled(self, checked): + if checked: + self.setStyleSheet(f""" + QCheckBox::indicator {{ + width: {self._pixelSize}px; + height: {self._pixelSize}px; + }} + + QCheckBox::indicator:checked + {{ + image: url(:eye-checked.svg); + }} + """) + else: + self.setStyleSheet(f""" + QCheckBox::indicator {{ + width: {self._pixelSize}px; + height: {self._pixelSize}px; + }} + + QCheckBox::indicator:unchecked + {{ + image: url(:unchecked.svg); + }} + """) + + +class highlightableQWidgetAction(QWidgetAction): + def __init__(self, parent) -> None: + super().__init__(parent) + + +class PostProcessSegmSlider(sliderWithSpinBox): + def __init__(self, *args, label=None, **kwargs): + super().__init__(*args, **kwargs) + + self.label = label + self.checkbox = QCheckBox("Disable") + self._layout.addWidget(self.checkbox, self.sliderCol, self.lastCol + 1) + self.checkbox.toggled.connect(self.onCheckBoxToggled) + self.valueChanged.connect(self.checkExpandRange) + + def onCheckBoxToggled(self, checked: bool) -> None: + super().setDisabled(checked) + if self.label is not None: + self.label.setDisabled(checked) + self.onValueChanged(None) + self.onEditingFinished() + + def onValueChanged(self, value): + self.valueChanged.emit(value) + + def checkExpandRange(self, value): + if value == self.maximum(): + range = int(self.maximum() - self.minimum()) + half_range = int(range / 2) + newMinimum = self.minimum() + half_range + newMaximum = self.maximum() + half_range + self.setMaximum(newMaximum) + self.setMinimum(newMinimum) + elif value == self.minimum(): + range = int(self.maximum() - self.minimum()) + half_range = int(range / 2) + newMinimum = self.minimum() - half_range + newMaximum = self.maximum() - half_range + self.setMaximum(newMaximum) + self.setMinimum(newMinimum) + + def onEditingFinished(self): + self.editingFinished.emit() + + def value(self): + if self.checkbox.isChecked(): + return None + else: + return super().value() + + +class PostProcessSegmSpinbox(QWidget): + valueChanged = Signal(int) + editingFinished = Signal() + sigCheckboxToggled = Signal() + + def __init__(self, *args, isFloat=False, label=None, **kwargs): + super().__init__(*args, **kwargs) + + layout = QHBoxLayout() + + if isFloat: + self.spinBox = DoubleSpinBox() + else: + self.spinBox = SpinBox() + + self.spinBox.valueChanged.connect(self.onValueChanged) + self.spinBox.editingFinished.connect(self.onEditingFinished) + + layout.addWidget(self.spinBox) + self.checkbox = QCheckBox("Disable") + layout.addWidget(self.checkbox) + layout.setStretch(0, 1) + layout.setStretch(1, 0) + + self.label = label + + self.checkbox.toggled.connect(self.onCheckBoxToggled) + + layout.setContentsMargins(5, 0, 5, 0) + + self.setLayout(layout) + + def onCheckBoxToggled(self, checked: bool) -> None: + self.spinBox.setDisabled(checked) + if self.label is not None: + self.label.setDisabled(checked) + self.onValueChanged(None) + self.onEditingFinished() + + def onValueChanged(self, value): + self.valueChanged.emit(value) + + def onEditingFinished(self): + self.editingFinished.emit() + + def maximum(self): + return self.spinBox.maximum() + + def setValue(self, value): + self.spinBox.setValue(value) + + def sizeHint(self): + return self.spinBox.sizeHint() + + def setMaximum(self, max): + self.spinBox.setMaximum(max) + + def setSingleStep(self, step): + self.spinBox.setSingleStep(step) + + def setMinimum(self, min): + self.spinBox.setMinimum(min) + + def setSingleStep(self, step): + self.spinBox.setSingleStep(step) + + def setDecimals(self, decimals): + self.spinBox.setDecimals(decimals) + + def value(self): + if self.checkbox.isChecked(): + return None + else: + return self.spinBox.value() + + +class CopiableCommandWidget(QGroupBox): + def __init__(self, command="", parent=None, font_size="13px"): + super().__init__(parent) + + layout = QHBoxLayout() + + label = QLabel(self) + self.label = label + self._font_size = font_size + self.setCommand(command, font_size=font_size) + label.setTextInteractionFlags( + Qt.TextBrowserInteraction | Qt.TextSelectableByKeyboard + ) + layout.addWidget(label) + layout.addWidget(QVLine(shadow="Plain", color="#4d4d4d")) + copyButton = copyPushButton("Copy", flat=True, hoverable=True) + copyButton.clicked.connect(self.copyToClipboard) + layout.addWidget(copyButton) + layout.addStretch(1) + + self.setLayout(layout) + + def setWordWrap(self, wordWrap): + self.label.setWordWrap(wordWrap) + + def copyToClipboard(self): + cb = QApplication.clipboard() + cb.clear(mode=cb.Clipboard) + cb.setText(self._command, mode=cb.Clipboard) + print("Command copied!") + + def setCommand(self, command, font_size=None): + if font_size is None: + font_size = self._font_size + + self._command = command + txt = html_utils.paragraph(f"{command}", font_size=font_size) + self.label.setText(txt) + + def command(self): + return self._command + + def text(self): + return self.label.text() + + def setTextInteractionFlags(self, flags): + self.label.setTextInteractionFlags(flags) + + +def PostProcessSegmWidget( + minimum, maximum, value, useSliders, isFloat=False, normalize=False, label=None +): + if useSliders: + if normalize: + maximum = int(maximum * 100) + widget = PostProcessSegmSlider( + normalize=normalize, isFloat=isFloat, label=label + ) + else: + widget = PostProcessSegmSpinbox(label=label, isFloat=isFloat) + widget.setMinimum(minimum) + widget.setMaximum(maximum) + widget.setValue(value) + return widget + + +class FeatureSelectorButton(QPushButton): + def __init__(self, text, parent=None, alignment=""): + super().__init__(text, parent=parent) + self._isFeatureSet = False + self._alignment = alignment + self.setCursor(Qt.PointingHandCursor) + + def setFeatureText(self, text): + self.setText(text) + self.setFlat(True) + self._isFeatureSet = True + if self._alignment: + self.setStyleSheet(f"text-align:{self._alignment};") + + def enterEvent(self, event) -> None: + if self._isFeatureSet: + self.setFlat(False) + return super().enterEvent(event) + + def leaveEvent(self, event) -> None: + if self._isFeatureSet: + self.setFlat(True) + self.update() + return super().leaveEvent(event) + + def setSizeLongestText(self, longestText): + currentText = self.text() + self.setText(longestText) + w, h = self.sizeHint().width(), self.sizeHint().height() + self.setMinimumWidth(w + 10) + # self.setMinimumHeight(h+5) + self.setText(currentText) + + +class CheckableSpinBoxWidgets: + def __init__(self, isFloat=True): + if isFloat: + self.spinbox = FloatLineEdit() + else: + self.spinbox = SpinBox() + self.checkbox = QCheckBox("Activate") + self.spinbox.setEnabled(False) + self.checkbox.toggled.connect(self.spinbox.setEnabled) + + def value(self): + if not self.checkbox.isChecked(): + return + return self.spinbox.value() + + +class Label(QLabel): + def __init__(self, parent=None, force_html=False): + super().__init__(parent) + self._force_html = force_html + + def setText(self, text): + if self._force_html: + text = html_utils.paragraph(text) + super().setText(text) + + +class ComboBox(QComboBox): + sigTextChanged = Signal(str) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._previousText = None + self._valueChanged = False + self.currentTextChanged.connect(self.emitTextChanged) + self.installEventFilter(self) + + def eventFilter(self, object, event) -> bool: + if object == self and event.type() == QEvent.Type.Wheel: + # Forward event to parent so QScrollArea can scroll + QApplication.sendEvent(self.parent(), event) + return True # Consume for the combo itself + + return super().eventFilter(object, event) + + def text(self): + return self.currentText() + + def emitTextChanged(self, text): + self._valueChanged = True + self.sigTextChanged.emit(text) + + def mousePressEvent(self, event): + self._previousText = self.currentText() + super().mousePressEvent(event) + + def previousText(self): + return self._previousText + + def addItems(self, items): + super().addItems(items) + self._previousText = items[0] + + def itemsText(self): + return [self.itemText(i) for i in range(self.count())] + + def setCurrentIndex(self, idx): + itemsText = self.itemsText() + currentText = itemsText[idx] + self._valueChanged = currentText != self._previousText + self._previousText = self.currentText() + super().setCurrentIndex(idx) + + def setCurrentText(self, text): + currentText = text + self._valueChanged = currentText != self._previousText + self._previousText = self.currentText() + super().setCurrentText(text) + + +class SetMeasurementsGroupBox(QGroupBox): + def __init__( + self, + title, + itemsText, + checkable=True, + itemsInfo=None, + lastSelection=None, + itemsInfoUrls=None, + parent=None, + ): + super().__init__(parent) + + if itemsInfo is None: + itemsInfo = {} + + if itemsInfo is None: + itemsInfoUrls = {} + + highlightRgba = _palettes._highlight_rgba() + r, g, b, a = highlightRgba + self._highlightStylesheetColor = f"rgb({r}, {g}, {b})" + + self.setTitle(title) + self.setCheckable(checkable) + + mainLayout = QVBoxLayout() + + scrollArea = QScrollArea() + scrollArea.setWidgetResizable(True) + scrollAreaLayout = QVBoxLayout() + scrollAreaWidget = QWidget() + self.scrollAreaWidget = scrollAreaWidget + self.scrollAreaLayout = scrollAreaLayout + + self.checkboxes = {} + for text in itemsText: + rowLayout = QHBoxLayout() + infoText = itemsInfo.get(text) + infoUrl = itemsInfoUrls.get(text) + if infoText is not None or infoUrl is not None: + infoButton = infoPushButton() + infoButton.setCursor(Qt.WhatsThisCursor) + rowLayout.addWidget(infoButton) + + if infoText is not None: + infoButton.itemText = text + infoButton.infoText = infoText + infoButton.clicked.connect(self.showInfo) + + if infoUrl is not None: + infoButton.itemText = text + infoButton.infoUrl = infoUrl + infoButton.clicked.connect(self.openInfoUrl) + + checkbox = QCheckBox(text) + checkbox.setParent(self.scrollAreaWidget) + checkbox.setChecked(True) + rowLayout.addWidget(checkbox) + rowLayout.addStretch(1) + + self.checkboxes[text] = checkbox + + scrollAreaLayout.addLayout(rowLayout) + + scrollAreaLayout.addStretch(1) + + scrollAreaWidget.setLayout(scrollAreaLayout) + scrollArea.setWidget(scrollAreaWidget) + self.scrollArea = scrollArea + + buttonsLayout = QHBoxLayout() + self.selectAllButton = selectAllPushButton() + self.selectAllButton.sigClicked.connect(self.setCheckedAll) + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(self.selectAllButton) + self.buttonsLayout = buttonsLayout + + if lastSelection is not None: + self.lastSelection = lastSelection + self.loadLastSelButton = reloadPushButton(" Load last selection... ") + self.loadLastSelButton.clicked.connect(self.loadLastSelection) + buttonsLayout.addWidget(self.loadLastSelButton) + + mainLayout.addWidget(scrollArea) + mainLayout.addSpacing(10) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def openInfoUrl(self): + url = self.sender().infoUrl + QDesktopServices.openUrl(QUrl(url)) + # import webbrowser + # url = self.sender().infoUrl + # webbrowser.open(url) + + def getWidthNoScrollBarNeeded(self): + width = ( + self.scrollArea.verticalScrollBar().sizeHint().width() + # self.scrollAreaLayout.contentsRect().width() + + self.scrollAreaWidget.sizeHint().width() + + 30 + ) + buttonsWidth = 0 + for i in range(self.buttonsLayout.count()): + widget = self.buttonsLayout.itemAt(i).widget() + if not isinstance(widget, QPushButton): + continue + buttonsWidth += widget.sizeHint().width() + 16 + largerWidth = max(width, buttonsWidth) + return largerWidth + + def resizeWidthNoScrollBarNeeded(self): + width = self.getWidthNoScrollBarNeeded() + self.setMinimumWidth(width) + # self.setFixedWidth(width) + + def loadLastSelection(self): + for text, checkbox in self.checkboxes.items(): + checked = self.lastSelection.get(text, False) + checkbox.setChecked(checked) + + def showInfo(self): + infoText = self.sender().infoText + itemText = self.sender().itemText + + title = f"{itemText} description" + msg = myMessageBox() + msg.setWidth(int(self.screen().size().width() / 2)) + msg.information(self, title, infoText) + + def setCheckedAll(self, button, checked): + for checkbox in self.checkboxes.values(): + checkbox.setChecked(checked) + + def highlightCheckboxesFromSearchText(self, text): + for checkbox in self.checkboxes.values(): + if not text: + highlighted = False + else: + highlighted = checkbox.text().lower().find(text.lower()) != -1 + + self.setCheckboxHighlighted(highlighted, checkbox) + + def setCheckboxHighlighted(self, highlighted, checkbox): + if highlighted: + checkbox.setStyleSheet( + f"background: {self._highlightStylesheetColor}; color: black" + ) + self.scrollArea.ensureWidgetVisible(checkbox) + else: + checkbox.setStyleSheet("") + + +class SearchLineEdit(QLineEdit): + def __init__(self, parent=None): + super().__init__(parent) + + self.initSearch() + self.setFocusPolicy(Qt.ClickFocus) + + def focusInEvent(self, event) -> None: + super().focusInEvent(event) + if super().text() == "Search...": + self.setText("") + self.setStyleSheet("") + + def focusOutEvent(self, event) -> None: + super().focusOutEvent(event) + if not super().text(): + self.initSearch() + + def initSearch(self): + self.setText("Search...") + self.setStyleSheet("color: rgb(150, 150, 150)") + self.clearFocus() + + def text(self): + if super().text() == "Search...": + return "" + return super().text() + + +class VectorLineEdit(QLineEdit): + valueChanged = Signal(object) + valueChangeFinished = Signal(object) + + def __init__(self, parent=None, initial=None): + super().__init__(parent) + + self._minimum = -np.inf + + float_re = float_regex() + vector_regex = rf"\(?\[?{float_re}(,\s?{float_re})+\)?\]?" + regex = rf"^{vector_regex}$|^{float_re}$" + self.validRegex = regex + + regExp = QRegularExpression(regex) + self.setValidator(QRegularExpressionValidator(regExp)) + self.setAlignment(Qt.AlignCenter) + + self.textChanged.connect(self.emitValueChanged) + self.editingFinished.connect(self.emitValueChangeFinished) + if initial is None: + self.setText("0.0") + + font = QFont() + font.setPixelSize(11) + self.setFont(font) + + def emitValueChangeFinished(self): + value = self.value() + self.textChanged.disconnect() + self.editingFinished.disconnect() + self.setValue(value) + self.textChanged.connect(self.emitValueChanged) + self.editingFinished.connect(self.emitValueChangeFinished) + + self.emitValueChanged(self.text(), signal=self.valueChangeFinished) + + def emitValueChanged(self, text, signal=None): + m = re.match(self.validRegex, text) + if m is None: + self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) + return + + if signal is None: + signal = self.valueChanged + + self.setStyleSheet("") + signal.emit(self.value()) + + def increaseValue(self, step): + value = self.value() + if isinstance(value, (float, int)): + value += step + else: + value = [val + step for val in value] + value = str(value).lstrip("[").rstrip("]") + self.setValue(value) + self.emitValueChangeFinished() + + def decreaseValue(self, step): + value = self.value() + if isinstance(value, (float, int)): + value -= step + else: + value = [val - step for val in value] + value = str(value).lstrip("[").rstrip("]") + self.setText(value) + self.emitValueChangeFinished() + + def setValue(self, value): + if isinstance(value, (float, int)): + if value < self._minimum: + value = self._minimum + else: + clipped = [] + for val in value: + if val < self._minimum: + val = self._minimum + clipped.append(val) + value = str(clipped).lstrip("[").rstrip("]") + self.setText(value) + + def setText(self, text): + super().setText(str(text)) + + def clipValue(self, val: float): + if val < self._minimum: + val = self._minimum + return val + + def value(self): + m = re.match(self.validRegex, self.text()) + if m is None: + return 0.0 + + try: + value = self.clipValue(float(self.text())) + return value + except Exception as e: + text = self.text() + text = text.replace("(", "") + text = text.replace(")", "") + text = text.replace("[", "") + text = text.replace("]", "") + values = text.split(",") + return [self.clipValue(float(value)) for value in values] + + def setMinimum(self, minimum): + self._minimum = float(minimum) + + +class LatexLabel(QLabel): + def __init__(self, latexText, parent=None): + super().__init__(parent) + + latexText = latexText.replace("", "$") + if not latexText.startswith("$"): + latexText = f"${latexText}" + + if not latexText.endswith("$"): + latexText = f"{latexText}$" + + latexText = latexText.replace("
    ", "\n") + + pixmap = self.mathTex_to_QPixmap(latexText) + self.setPixmap(pixmap) + + def mathTex_to_QPixmap(self, mathTex): + # ---- set up a mpl figure instance ---- + + fig = matplotlib.figure.Figure() + fig.patch.set_facecolor("none") + fig.set_canvas(FigureCanvasAgg(fig)) + renderer = fig.canvas.get_renderer() + + # ---- plot the mathTex expression ---- + + ax = fig.add_axes([0, 0, 1, 1]) + ax.axis("off") + ax.patch.set_facecolor("none") + t = ax.text( + 0, 0, mathTex, ha="left", va="bottom", fontsize=13, color=TEXT_COLOR + ) + + # ---- fit figure size to text artist ---- + + fwidth, fheight = fig.get_size_inches() + fig_bbox = fig.get_window_extent(renderer) + + text_bbox = t.get_window_extent(renderer) + + tight_fwidth = text_bbox.width * fwidth / fig_bbox.width + tight_fheight = text_bbox.height * fheight / fig_bbox.height + + fig.set_size_inches(tight_fwidth, tight_fheight) + + # ---- convert mpl figure to QPixmap ---- + + buf, size = fig.canvas.print_to_buffer() + qimage = QImage.rgbSwapped(QImage(buf, size[0], size[1], QImage.Format_ARGB32)) + qpixmap = QPixmap(qimage) + + return qpixmap + + +class LabelsWidget(QWidget): + def __init__(self, texts, wrapText=False, parent=None): + super().__init__(parent=parent) + + layout = QVBoxLayout() + + texts = self.fixParagraphTags(texts) + + self.textLengths = [] + self.labels = [] + for t, text in enumerate(texts): + if not text: + continue + + if text.startswith(""): + layout.addSpacing(10) + label = LatexLabel(text) + layout.addWidget(label, alignment=Qt.AlignCenter) + try: + # Add spacing only if next text is not a formula + nextText = texts[t + 1] + if not nextText.startswith(""): + layout.addSpacing(10) + except IndexError: + layout.addSpacing(10) + elif text.startswith(""): + text = text.removeprefix("").removeprefix("") + label = CopiableCommandWidget(command=text, parent=self) + layout.addWidget(label) + else: + label = QLabel(text) + label.setWordWrap(wrapText) + label.setOpenExternalLinks(True) + layout.addWidget(label) + if wrapText: + self.textLengths.append(1) + self.textLengths.extend([len(line) for line in text.split("
    ")]) + + self.labels.append(label) + + self.nCharsLongestLine = max(self.textLengths, default=1) + + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + def setWordWrap(self, wordWrap): + for label in self.labels: + label.setWordWrap(wordWrap) + + def fixParagraphTags(self, texts): + firstText = texts[0] + if firstText.find("

    ', firstText) + if searched is None: + openTag = '

    ' + else: + openTag = searched.group() + + not_allowed = {" ", "\n"} + + fixedTexts = [] + for text in texts: + if text.startswith("") or text.startswith(""): + fixedTexts.append(text) + continue + + if set(text) <= not_allowed: + # Ignore texts that are made of only \n and spaces + continue + + if text.find("

    ") == -1: + text = rf"{text}<\p>" + + if text.find(openTag) == -1: + text = f"{openTag}{text}" + + text = text.replace("\n", "") + + fixedTexts.append(text) + return fixedTexts + + +class SwitchPlaneCombobox(QComboBox): + sigPlaneChanged = Signal(str, str) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.addItems(["xy", "zy", "zx"]) + self._previousPlane = "xy" + self.currentTextChanged.connect(self.emitPlaneChanged) + + def emitPlaneChanged(self, plane): + self.sigPlaneChanged.emit(self._previousPlane, plane) + self._previousPlane = plane + + def setPlane(self, plane): + self.setCurrentText(plane) + + def setCurrentText(self, text): + self._previousPlane = self.plane() + super().setCurrentText(text) + + def plane(self): + return self.currentText() + + def depthAxes(self): + plane = self.plane() + for axes in "xyz": + if axes not in plane: + return axes + + +class SamInputPointsWidget(QWidget): + sigValueChanged = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + + _layout = QHBoxLayout() + + self.lineEntry = ElidingLineEdit(parent=self) + self.lineEntry.setAlignment(Qt.AlignCenter) + self.lineEntry.editingFinished.connect(self.emitValueChanged) + + self.editButton = editPushButton() + self.browseButton = browseFileButton( + ext={"CSV": ".csv"}, start_dir=myutils.getMostRecentPath() + ) + + _layout.addWidget(self.lineEntry) + _layout.addWidget(self.editButton) + _layout.addWidget(self.browseButton) + + _layout.setStretch(0, 1) + _layout.setStretch(1, 0) + _layout.setStretch(1, 0) + + self.browseButton.sigPathSelected.connect(self.browseCsvFiles) + self.editButton.clicked.connect(self.showInfoEditPoints) + + _layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(_layout) + + def emitValueChanged(self, text): + self.sigValueChanged.emit(text) + + def showInfoEditPoints(self): + note = html_utils.to_note( + "When adding points with the mouse left button you will create a " + "new object for each point. To add multiple points for the same " + "object click the right button." + ) + txt = html_utils.paragraph(f""" + To add input points for Segment Anything open the GUI (module 3), + load the data, and then click on the button
    + on the top toolbar called Add points layer.

    + Select the option "Add points by clicking" and click on the image + to add points.

    + Finally, save the table and browse to the saved file on this widget. +
    {note} + """) + msg = myMessageBox(wrapText=False) + msg.information(self, "Info edit points", txt) + + def criticalMissingColumn(self, filepath, missing_col): + txt = html_utils.paragraph(f""" + [ERROR]: The selected table does not contain the column + {missing_col}.

    + A valid table must contain the columns (x, y, id) + with an additional z column for 3D z-stacks data. + """) + msg = myMessageBox(wrapText=False) + msg.critical(self, "Invalid table", txt) + + def setValue(self, value: str): + self.lineEntry.setText(value) + + def value(self): + return self.lineEntry.text() + + def cast_dtype(self, value) -> str: + return str(value) + + def browseCsvFiles(self, filepath): + # Check if metadata.csv file exists with basename and set only the + # endname of the file + df_points = pd.read_csv(filepath) + for col in ("x", "y", "id"): + if col not in df_points.columns: + self.criticalMissingColumn(filepath, col) + return + + # Check if basename is present in metadata + folderpath = os.path.dirname(filepath) + basename = None + for file in myutils.listdir(folderpath): + if file.endswith("metadata.csv"): + metadata_csv_path = os.path.join(folderpath, file) + df = pd.read_csv(metadata_csv_path, index_col="Description") + try: + basename = df.at["basename", "values"] + except Exception as e: + basename = None + break + + # Check if file is inside images folder and get basename + is_images_folder = folderpath.endswith("Images") + if is_images_folder: + images_path = folderpath + img_filepath = None + for file in myutils.listdir(images_path): + if file.endswith(".tif"): + img_filepath = os.path.join(images_path, file) + break + + if file.endswith("aligned.npz"): + img_filepath = os.path.join(images_path, file) + break + + if img_filepath is not None: + posData = load.loadData(img_filepath, "", QParent=self) + posData.getBasenameAndChNames() + filename = os.path.basename(filepath) + if filename.startswith(posData.basename): + basename = posData.basename + + if basename is None: + self.lineEntry.setText(filepath) + else: + filename = os.path.basename(filepath) + endname = filename[len(basename) :] + self.lineEntry.setText(endname) + + +class installJavaDialog(myMessageBox): + def __init__(self, parent=None): + super().__init__(parent) + + self.setWindowTitle("Install Java") + self.setIcon("SP_MessageBoxWarning") + + txt_macOS = html_utils.paragraph(""" + Your system doesn't have the Java Development Kit + installed
    and/or a C++ compiler which is required for the installation of + javabridge

    + Cell-ACDC is now going to install Java for you.

    + NOTE: After clicking on "Install", follow the instructions
    + on the terminal
    . You will be asked to confirm steps and insert
    + your password to allow the installation.


    + If you prefer to do it manually, cancel the process
    + and follow the instructions below. + """) + + txt_windows = html_utils.paragraph(""" + Unfortunately, installing pre-compiled version of + javabridge failed.

    + Cell-ACDC is going to try to compile it now.

    + However, before proceeding, you need to install + Java Development Kit
    and a C++ compiler.

    + See instructions below on how to install it. + """) + + if not is_win: + self.instructionsButton = self.addButton("Show intructions...") + self.instructionsButton.setCheckable(True) + self.instructionsButton.disconnect() + self.instructionsButton.clicked.connect(self.showInstructions) + installButton = self.addButton("Install") + installButton.disconnect() + installButton.clicked.connect(self.installJava) + txt = txt_macOS + else: + okButton = self.addButton("Ok") + txt = txt_windows + + self.cancelButton = self.addButton("Cancel") + + label = self.addText(txt) + label.setWordWrap(False) + + self.resizeCount = 0 + + def addInstructionsWindows(self): + self.scrollArea = QScrollArea() + _container = QWidget() + _layout = QVBoxLayout() + for t, text in enumerate(myutils.install_javabridge_instructions_text()): + label = QLabel() + label.setText(text) + if t == 1 or t == 2: + label.setOpenExternalLinks(True) + label.setTextInteractionFlags(Qt.TextBrowserInteraction) + code_layout = QHBoxLayout() + code_layout.addWidget(label) + copyButton = QToolButton() + copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + copyButton.setIcon(QIcon(":edit-copy.svg")) + copyButton.setText("Copy link") + if t == 1: + copyButton.textToCopy = myutils.jdk_windows_url() + code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) + else: + copyButton.textToCopy = myutils.cpp_windows_url() + screenshotButton = QToolButton() + screenshotButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + screenshotButton.setIcon(QIcon(":cog.svg")) + screenshotButton.setText("See screenshot") + code_layout.addWidget(screenshotButton, alignment=Qt.AlignLeft) + code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) + screenshotButton.clicked.connect(self.viewScreenshot) + copyButton.clicked.connect(self.copyToClipboard) + code_layout.setStretch(0, 2) + code_layout.setStretch(1, 0) + _layout.addLayout(code_layout) + else: + _layout.addWidget(label) + + _container.setLayout(_layout) + self.scrollArea.setWidget(_container) + self.currentRow += 1 + self._layout.addWidget( + self.scrollArea, self.currentRow, 1, alignment=Qt.AlignTop + ) + + # Stretch last row + self.currentRow += 1 + self._layout.setRowStretch(self.currentRow, 1) + + def viewScreenshot(self, checked=False): + self.screenShotWin = view_visualcpp_screenshot(parent=self) + self.screenShotWin.show() + + def addInstructionsMacOS(self): + self.scrollArea = QScrollArea() + _container = QWidget() + _layout = QVBoxLayout() + for t, text in enumerate(myutils.install_javabridge_instructions_text()): + label = QLabel() + label.setText(text) + # label.setWordWrap(True) + if t == 1 or t == 2: + label.setWordWrap(True) + code_layout = QHBoxLayout() + code_layout.addWidget(label) + copyButton = QToolButton() + copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + copyButton.setIcon(QIcon(":edit-copy.svg")) + copyButton.setText("Copy") + if t == 1: + copyButton.textToCopy = myutils._install_homebrew_command() + else: + copyButton.textToCopy = myutils._brew_install_java_command() + copyButton.clicked.connect(self.copyToClipboard) + code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) + # code_layout.addStretch(1) + code_layout.setStretch(0, 2) + code_layout.setStretch(1, 0) + _layout.addLayout(code_layout) + else: + _layout.addWidget(label) + _container.setLayout(_layout) + self.scrollArea.setWidget(_container) + self.currentRow += 1 + self._layout.addWidget( + self.scrollArea, self.currentRow, 1, alignment=Qt.AlignTop + ) + + # Stretch last row + self.currentRow += 1 + self._layout.setRowStretch(self.currentRow, 1) + self.scrollArea.hide() + + def addInstructionsLinux(self): + self.scrollArea = QScrollArea() + _container = QWidget() + _layout = QVBoxLayout() + for t, text in enumerate(myutils.install_javabridge_instructions_text()): + label = QLabel() + label.setText(text) + # label.setWordWrap(True) + if t == 1 or t == 2 or t == 3: + label.setWordWrap(True) + code_layout = QHBoxLayout() + code_layout.addWidget(label) + copyButton = QToolButton() + copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + copyButton.setIcon(QIcon(":edit-copy.svg")) + copyButton.setText("Copy") + if t == 1: + copyButton.textToCopy = myutils._apt_update_command() + elif t == 2: + copyButton.textToCopy = myutils._apt_install_java_command() + elif t == 3: + copyButton.textToCopy = myutils._apt_gcc_command() + copyButton.clicked.connect(self.copyToClipboard) + code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) + # code_layout.addStretch(1) + code_layout.setStretch(0, 2) + code_layout.setStretch(1, 0) + _layout.addLayout(code_layout) + else: + _layout.addWidget(label) + _container.setLayout(_layout) + self.scrollArea.setWidget(_container) + self.currentRow += 1 + self._layout.addWidget( + self.scrollArea, self.currentRow, 1, alignment=Qt.AlignTop + ) + + # Stretch last row + self.currentRow += 1 + self._layout.setRowStretch(self.currentRow, 1) + self.scrollArea.hide() + + def copyToClipboard(self): + cb = QApplication.clipboard() + cb.clear(mode=cb.Clipboard) + cb.setText(self.sender().textToCopy, mode=cb.Clipboard) + print("Command copied!") + + def showInstructions(self, checked): + if checked: + self.instructionsButton.setText("Hide instructions") + self.origHeight = self.height() + self.resize(self.width(), self.height() + 300) + self.scrollArea.show() + else: + self.instructionsButton.setText("Show instructions...") + self.scrollArea.hide() + func = partial(self.resize, self.width(), self.origHeight) + QTimer.singleShot(50, func) + + def installJava(self): + import subprocess + + try: + if is_mac: + try: + subprocess.check_call(["brew", "update"]) + except Exception as e: + subprocess.run( + myutils._install_homebrew_command(), + check=True, + text=True, + shell=True, + ) + subprocess.run( + myutils._brew_install_java_command(), + check=True, + text=True, + shell=True, + ) + elif is_linux: + subprocess.run( + myutils._apt_gcc_command()(), check=True, text=True, shell=True + ) + subprocess.run( + myutils._apt_update_command()(), check=True, text=True, shell=True + ) + subprocess.run( + myutils._apt_install_java_command()(), + check=True, + text=True, + shell=True, + ) + self.close() + except Exception as e: + print("=======================") + traceback.print_exc() + print("=======================") + msg = myMessageBox(wrapText=False) + err_msg = html_utils.paragraph(""" + Automatic installation of Java failed.

    + Please, try manually by following the instructions provided + below (click on "Show instructions..." button). Thanks + """) + msg.critical(self, "Java installation failed", err_msg) + + def show(self, block=False): + super().show(block=False) + print(is_linux) + if is_win: + self.addInstructionsWindows() + elif is_mac: + self.addInstructionsMacOS() + elif is_linux: + self.addInstructionsLinux() + self.move(self.pos().x(), 20) + if is_win: + self.resize(self.width(), self.height() + 200) + if block: + self._block() + + def exec_(self): + self.show(block=True) + + +class selectTrackerGUI(QDialogListbox): + def __init__(self, SizeT, currentFrameNo=1, parent=None): + trackers = myutils.get_list_of_trackers() + super().__init__( + "Select tracker", + "Select one of the following trackers", + trackers, + multiSelection=False, + parent=parent, + ) + self.setWindowTitle("Select tracker") + + self.selectFramesGroupbox = selectStartStopFrames( + SizeT, currentFrameNum=currentFrameNo, parent=parent + ) + + self.mainLayout.insertWidget(1, self.selectFramesGroupbox) + + def ok_cb(self, event): + if self.selectFramesGroupbox.warningLabel.text(): + return + else: + self.startFrame = self.selectFramesGroupbox.startFrame_SB.value() + self.stopFrame = self.selectFramesGroupbox.stopFrame_SB.value() + super().ok_cb(event) + + +def addWidgetToScrollArea( + widget, + resizeMinWidthNoHorizontalScrollbar=False, + resizeMinHeightNoVerticalScrollbar=False, +): + container = QWidget() + layout = QVBoxLayout() + layout.addWidget(widget) + layout.addStretch(1) + container.setLayout(layout) + scrollArea = QScrollArea() + scrollArea.setWidgetResizable(True) + scrollArea.setWidget(container) + + if resizeMinWidthNoHorizontalScrollbar: + scrollArea.setMinimumWidth( + container.sizeHint().width() + + scrollArea.verticalScrollBar().sizeHint().width() + ) + + if resizeMinHeightNoVerticalScrollbar: + scrollArea.setMinimumHeight( + container.sizeHint().height() + + scrollArea.horizontalScrollBar().sizeHint().height() + ) + + return scrollArea + + +class CheckableAction(QAction): + clicked = Signal(bool) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setCheckable(True) + self.toggled.connect(self.emitClicked) + + def emitClicked(self, checked): + self.clicked.emit(checked) + + def setChecked(self, checked): + self.toggled.disconnect() + super().setChecked(checked) + self.toggled.connect(self.emitClicked) + + +class OddSpinBox(SpinBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setSingleStep(2) + self.editingFinished.connect(self.roundToOdd) + + def roundToOdd(self): + if self.value() % 2 == 1: + return + + self.setValue(self.value() + 1) + + +class TimestampItem(LabelItem): + sigEditProperties = Signal(object) + sigRemove = Signal(object) + + def __init__( + self, + SizeY, + SizeX, + viewRange, + secondsPerFrame=1, + parent=None, + start_timedelta=None, + ): + self._secondsPerFrame = secondsPerFrame + self._x_pad = 3 + self._y_pad = 2 + self.xmin, self.ymin = 0, 0 + self.SizeY = SizeY + self.SizeX = SizeX + self._highlighted = False + self._parent = parent + if start_timedelta is None: + start_timedelta = datetime.timedelta(seconds=0) + self._start_timedelta = start_timedelta + self.clicked = False + super().__init__(self) + self.updateViewRange(viewRange) + self.createContextMenu() + + def setSecondsPerFrame(self, secondsPerFrame): + self._secondsPerFrame = secondsPerFrame + + def getBboxViewRange(self, viewRange): + xRange, yRange = viewRange + x0, x1 = xRange + y0, y1 = yRange + if x0 < 0: + x0 = 0 + + if x1 > self.SizeX: + x1 = self.SizeX + + if y0 < 0: + y0 = 0 + + if y1 > self.SizeY: + y1 = self.SizeY + + return x0, y0, x1, y1 + + def updateViewRange(self, viewRange): + x0, y0, x1, y1 = self.getBboxViewRange(viewRange) + + self.xmax = x1 + self.xmin = x0 + + self.ymax = y1 + self.ymin = y0 + + def createContextMenu(self): + self.contextMenu = QMenu() + action = QAction("Edit properties...", self.contextMenu) + action.triggered.connect(self.emitEditProperties) + self.contextMenu.addSeparator() + action = QAction("Remove", self.contextMenu) + action.triggered.connect(self.emitRemove) + self.contextMenu.addAction(action) + + def emitRemove(self): + self.sigRemove.emit(self) + + def mousePressed(self, x, y): + self.clicked = True + + def emitEditProperties(self): + self.setHighlighted(False) + self.sigEditProperties.emit(self.properties()) + + def isHighlighted(self): + return self._highlighted + + def setHighlighted(self, highlighted): + if self._highlighted and highlighted: + return + + if not self._highlighted and not highlighted: + return + + super().setText(self.text, bold=highlighted) + + self._highlighted = highlighted + + def showContextMenu(self, x, y): + self.contextMenu.popup(QPoint(int(x), int(y))) + + def setLocationProperty(self, loc: str): + self._loc = loc + + def properties(self): + properties = { + "color": self._color, + "loc": self._loc, + "font_size": int(self._font_size[:-2]), + "start_timedelta": self._start_timedelta, + "move_with_zoom": self._move_with_zoom, + } + return properties + + def draw(self, frame_i, **kwargs): + self.setProperties(**kwargs) + self.update(frame_i) + + def update(self, frame_i): + self.setPosFromLoc() + self.setText(frame_i) + + def setMoveWithZoomProperty(self, move_with_zoom): + self._move_with_zoom = move_with_zoom + + def updatePosViewRangeChanged(self, viewRange): + if self._loc == "custom": + textHeight = self.itemRect().height() + textWidth = self.itemRect().width() + x0p = self.pos().x() + y0p = self.pos().y() + xcp = x0p + textWidth / 2 + ycp = y0p + textHeight / 2 + x0 = self.xmin + y0 = self.ymin + x_range = self.xmax - x0 + y_range = self.ymax - y0 + Dx_perc = (xcp - x0) / x_range + Dy_perc = (ycp - y0) / y_range + + self.updateViewRange(viewRange) + + X0 = self.xmin + Y0 = self.ymin + + X_range = self.xmax - X0 + Y_range = self.ymax - Y0 + + Xcp = X0 + (Dx_perc * X_range) + Ycp = Y0 + (Dy_perc * Y_range) + X0p = Xcp - (textWidth / 2) + Y0p = Ycp - (textHeight / 2) + + y_pos_max = self.ymax - textHeight - self._y_pad + if Y0p > y_pos_max: + Y0p = y_pos_max + + x_pos_max = self.xmax - textWidth - self._x_pad + if X0p > x_pos_max: + X0p = x_pos_max + + self.setPos(X0p, Y0p) + else: + self.updateViewRange(viewRange) + self.setPosFromLoc() + + def setPosFromLoc(self): + textHeight = self.itemRect().height() + textWidth = self.itemRect().width() + if self._loc == "custom": + return + + if self._loc.find("top") != -1: + y0 = self._y_pad + self.ymin + else: + y0 = self.ymax - textHeight - self._y_pad + + if self._loc.find("left") != -1: + x0 = self._x_pad + self.xmin + else: + x0 = self.xmax - textWidth - self._x_pad + + self.setPos(x0, y0) + + def setProperties( + self, + color=(255, 255, 255), + font_size="13px", + loc="top-left", + start_timedelta=None, + move_with_zoom=False, + ): + if start_timedelta is not None: + self._start_timedelta = start_timedelta + self._color = color + self._loc = loc + self._font_size = font_size + self._move_with_zoom = move_with_zoom + + def move(self, xm, ym): + Dy = ym - self.yc + Dx = xm - self.xc + x0 = self.x0c + Dx + y0 = self.y0c + Dy + self.setPos(x0, y0) + + def mousePressed(self, x, y): + self.clicked = True + self.xc, self.yc = x, y + self.x0c = self.pos().x() + self.y0c = self.pos().y() + + def setText(self, frame_i): + if not isinstance(frame_i, int): + return + + seconds = frame_i * self._secondsPerFrame + timedelta = datetime.timedelta(seconds=round(seconds)) + + diff_seconds = timedelta.total_seconds() + self._start_timedelta.total_seconds() + if diff_seconds >= 0: + timedelta = datetime.timedelta(seconds=round(diff_seconds)) + text = str(timedelta) + else: + abs_diff = abs( + timedelta.total_seconds() + self._start_timedelta.total_seconds() + ) + abs_timedelta = datetime.timedelta(seconds=round(abs_diff)) + text = f"-{abs_timedelta}" + + # printl(timedelta) + super().setText(text, color=self._color, size=self._font_size) + + def addToAxis(self, ax): + ax.addItem(self) + + def removeFromAxis(self, ax): + ax.removeItem(self) + + +class FontSizeWidget(QWidget): + sigTextChanged = Signal(str) + + def __init__(self, parent=None, unit="px", initalVal=12): + super().__init__(parent) + + layout = QHBoxLayout() + + self.spinbox = SpinBox() + self.spinbox.setValue(initalVal) + layout.addWidget(self.spinbox) + + self.unitLabel = QLabel(unit) + layout.addWidget(self.unitLabel) + + layout.setContentsMargins(0, 0, 0, 0) + layout.setStretch(0, 1) + layout.setStretch(1, 0) + + self.setLayout(layout) + + self.spinbox.valueChanged.connect(self.emitTextChanged) + + def emitTextChanged(self, value): + self.sigTextChanged.emit(self.text()) + + def setValue(self, value): + if isinstance(value, str): + value = int(value.replace(self.unitLabel.text(), "").strip()) + self.spinbox.setValue(value) + + def setText(self, text): + value = int(text.replace(self.unitLabel.text(), "").strip()) + self.setValue(value) + + def text(self): + return f"{self.spinbox.value()}{self.unitLabel.text()}" + + def value(self): + return self.spinbox.value() + + +class RangeSelector(QWidget): + sigRangeChanged = Signal(object, object) + sigLowValueChanged = Signal(object) + sigHighValueChanged = Signal(object) + sigRangeManuallyChanged = Signal(object, object) + + def __init__(self, parent=None, integers=False, ordered=True): + super().__init__(parent) + + self._integers = integers + self._ordered = ordered + + layout = QHBoxLayout() + + if integers: + self.lowSpinbox = SpinBox() + self.highSpinbox = SpinBox() + else: + self.lowSpinbox = DoubleSpinBox() + self.highSpinbox = DoubleSpinBox() + + layout.addWidget(self.lowSpinbox) + layout.addWidget(self.highSpinbox) + + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + self.lowSpinbox.valueChanged.connect(self.lowValueChanged) + self.highSpinbox.valueChanged.connect(self.highValueChanged) + + self.lowSpinbox.editingFinished.connect(self.lowValueEditingFinished) + self.highSpinbox.editingFinished.connect(self.highValueEditingFinished) + + def lowValueEditingFinished(self): + self.sigRangeManuallyChanged.emit(*self.range()) + self.emitRangeChanged() + + def highValueEditingFinished(self): + self.sigRangeManuallyChanged.emit(*self.range()) + self.emitRangeChanged() + + def lowValueChanged(self, value): + self.emitRangeChanged() + self.sigLowValueChanged.emit(value) + + def highValueChanged(self, value): + self.emitRangeChanged() + self.sigHighValueChanged.emit(value) + + def emitRangeChanged(self): + self.sigRangeChanged.emit(*self.range()) + + def setRangeNoEmit(self, lowValue, highValue, decimals=3): + self.lowSpinbox.valueChanged.disconnect() + self.highSpinbox.valueChanged.disconnect() + + self.setRange(round(lowValue, 3), round(highValue, 3)) + + self.lowSpinbox.valueChanged.connect(self.lowValueChanged) + self.highSpinbox.valueChanged.connect(self.highValueChanged) + + def setRange(self, lowValue, highValue): + # if lowValue > highValue and self._ordered: + # highValue = lowValue + 1 + + if self._integers: + lowValue = round(lowValue) + highValue = round(highValue) + + self.lowSpinbox.setValue(lowValue) + self.highSpinbox.setValue(highValue) + + def range(self): + return self.lowSpinbox.value(), self.highSpinbox.value() + + +class LineEdit(QLineEdit): + def __init__(self, parent=None): + super().__init__(parent) + self.setAlignment(Qt.AlignCenter) + + def value(self): + return self.text() + + def setValue(self, value): + self.setText(str(value)) + + +class PreProcessingSelector(QComboBox): + sigValuesChanged = Signal(dict, int) + + def __init__(self, parent=None): + super().__init__(parent) + self._parent = parent + + self.addItems(PREPROCESS_MAPPER.keys()) + self.methodToDefaultValuesMapper = {} + self.step_n = -1 + self.setParamsWindow = None + + def htmlInfo(self): + href = html_utils.href_tag("GitHub page", urls.issues_url) + docstring = PREPROCESS_MAPPER[self.currentText()]["docstring"] + if docstring is None: + text = "This function is not documented, yet. Sorry :(" + else: + text = html_utils.rst_docstring_to_html(docstring) + text = ( + f"{text}

    " + f"Feel free to submit an issue on our {href} if you " + "need help with this filter." + ) + return text + + def setParams(self, method: str, kwargToValueMapper: Dict[str, str]): + self.methodToDefaultValuesMapper[method] = kwargToValueMapper + + def askSetParams(self, df_metadata=None, addApplyButton=False): + method = self.currentText() + function = PREPROCESS_MAPPER[method]["function"] + params_argspecs = myutils.get_function_argspec( + function, + args_to_skip={"logger_func", "apply_to_all_zslices", "apply_to_all_frames"}, + ) + default_values = self.methodToDefaultValuesMapper.get(method, {}) + for kwarg, value in default_values.items(): + for p, param_argspec in enumerate(params_argspecs): + if param_argspec.name != kwarg: + continue + + if hasattr(param_argspec.type, "cast_dtype"): + cls = param_argspec.type + value = cls.cast_dtype(value) + else: + value = param_argspec.type(value) + + if value == param_argspec.default: + continue + param_argspec = param_argspec._replace(default=value) + params_argspecs[p] = param_argspec + + if self.setParamsWindow is not None: + self.setParamsWindow.raise_() + self.setParamsWindow.activateWindow() + return + + self.setParamsWindow = apps.FunctionParamsDialog( + params_argspecs, + df_metadata=df_metadata, + function_name=method, + addApplyButton=addApplyButton, + parent=self._parent, + ) + self.setParamsWindow.sigValuesChanged.connect(self.emitValuesChanged) + self.setParamsWindow.emitValuesChanged() + self.setParamsWindow.exec_() + if self.setParamsWindow.cancel: + return + + self.setParams(method, self.setParamsWindow.function_kwargs) + + function_kwargs = self.setParamsWindow.function_kwargs + self.setParamsWindow = None + + return function_kwargs + + def emitValuesChanged(self, functionKwargs: dict): + self.sigValuesChanged.emit(functionKwargs, self.step_n) + + +class RescaleImageJroisGroupbox(QGroupBox): + def __init__(self, TZYX_out_shape, parent=None): + super().__init__(parent) + + self.setTitle("Rescale ROIs") + self.setCheckable(True) + + gridLayout = QGridLayout() + + dims = ("Z", "Y", "X") + self.widgets = {} + for row, SizeD in enumerate(TZYX_out_shape[1:]): + if SizeD == 1: + continue + + dim = dims[row] + inputSpinbox = SpinBox() + inputSpinbox.setMinimum(1) + inputSpinbox.setValue(SizeD) + + outZwidget = QLineEdit() + outZwidget.setReadOnly(True) + outZwidget.setAlignment(Qt.AlignCenter) + # outZwidget.setValue(SizeD) + outZwidget.setText(str(SizeD)) + + row0 = row * 2 + row1 = row0 + 1 + gridLayout.addWidget(QLabel(f"{dim}-dimension: "), row1, 0) + + gridLayout.addWidget(QLabel("Input size"), row0, 1) + gridLayout.addWidget(inputSpinbox, row1, 1) + + gridLayout.addWidget(QLabel("Output size"), row0, 2) + gridLayout.addWidget(outZwidget, row1, 2) + + self.widgets[dim] = (inputSpinbox, SizeD) + + self.setLayout(gridLayout) + + def inputOutputSizes(self): + if not self.isChecked(): + return + + sizes = { + dim: (spinbox.value(), int(SizeD)) + for dim, (spinbox, SizeD) in self.widgets.items() + } + return sizes + + +class WhitelistLineEdit(KeepIDsLineEdit): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def setText(self, IDs): + if not isinstance(IDs, set) and not isinstance(IDs, list): + raise TypeError("IDs must be a set or list") + + formatted_text = myutils.format_IDs(IDs) + super().setText(formatted_text) + + +class KeySequenceFromText(QKeySequence): + def __init__(self, text: str): + if isinstance(text, str): + text = macShortcutToWindows(text) + super().__init__(text) + self._text = text + + def toString(self): + if isinstance(self._text, str): + return windowsShortcutToMac(self._text) + else: + return windowsShortcutToMac(super().toString()) + + +def modifierKeyToText(modifierKey: int): + if modifierKey == Qt.ControlModifier: + return "Ctrl" + elif modifierKey == Qt.AltModifier: + return "Alt" + elif modifierKey == Qt.ShiftModifier: + return "Shift" + elif modifierKey == Qt.MetaModifier: + return "Meta" + else: + return "" + + +class TimeWidget(QGroupBox): + sigValueChanged = Signal(object) + + def __init__(self, parent=None, orientation="vertical"): + super().__init__(parent) + + mainLayout = QHBoxLayout() + + if orientation == "vertical": + spinboxesLayout = QVBoxLayout() + elif orientation == "horizontal": + spinboxesLayout = QHBoxLayout() + else: + raise ValueError('orientation must be "vertical" or "horizontal"') + + self.signCombobox = QComboBox() + self.signCombobox.addItems(("+", "-")) + self.signCombobox.currentTextChanged.connect(self.emitValueChanged) + + mainLayout.addWidget(self.signCombobox) + + self.spinboxesMapper = {} + units = ("days", "hours", "minutes", "seconds") + for unit in units: + layout = QHBoxLayout() + spinbox = SpinBox() + spinbox.setMinimum(0) + label = QLabel(unit) + layout.addWidget(spinbox) + layout.addWidget(label) + spinbox.valueChanged.connect(self.emitValueChanged) + self.spinboxesMapper[unit] = spinbox + spinboxesLayout.addLayout(layout) + + mainLayout.addLayout(spinboxesLayout) + + self.setLayout(mainLayout) + mainLayout.setContentsMargins(5, 5, 5, 5) + + def values(self): + values = {} + for unit, spinbox in self.spinboxesMapper.items(): + values[unit] = spinbox.value() + + signText = self.signCombobox.currentText() + return values, sign_int_mapper[signText] + + def setValuesFromTimedelta(self, timedelta): + total_seconds = timedelta.total_seconds() + sign = 1 if total_seconds > 0 else -1 + days = timedelta.days + hours, remainder = divmod(timedelta.seconds, 3600) + minutes, seconds = divmod(remainder, 60) + + values = {"days": days, "hours": hours, "minutes": minutes, "seconds": seconds} + + self.setValues(values, sign=sign) + + def timedelta(self): + values, sign = self.values() + return datetime.timedelta(**values) * sign + + def setValues(self, values: dict[str, int | float], sign=1): + signText = "+" if sign > 0 else "-" + self.signCombobox.setCurrentText(signText) + for unit, value in values.items(): + spinbox = self.spinboxesMapper[unit] + spinbox.setValue(value) + + def emitValueChanged(self, value): + self.sigValueChanged.emit(self.values()) + + +def get_min_width_for_no_scrollbar(list_widget: QListWidget) -> int: + """ + Calculate the minimum width needed for the QListWidget + so that the horizontal scrollbar will not be required. + """ + font_metrics = QFontMetrics(list_widget.font()) + max_width = 0 + + for i in range(list_widget.count()): + item = list_widget.item(i) + text_width = font_metrics.horizontalAdvance(item.text()) + max_width = max(max_width, text_width) + + # Add padding for icon, scrollbar margin, and frame + padding = 30 # Adjust as needed (depends on style and icons) + return max_width + padding + + +class YeazV2SelectModelNameCombobox(ComboBox): + sigValueChanged = Signal(str) + + def __init__( + self, *args, custom_select_item_text="Select custom weights file...", **kwargs + ): + super().__init__(*args, **kwargs) + self._csi_text = custom_select_item_text + self.sigTextChanged.connect(self.onTextChanged) + self.initItems() + + def initItems(self): + from cellacdc.segmenters.YeaZ_v2 import load_models_filepath + + models_name, models_name_filepath_mapper = load_models_filepath() + self.addItems(models_name) + + def onTextChanged(self, text): + if text != self._csi_text: + return + + start_dir = myutils.getMostRecentPath() + model_filepath = qtpy.compat.getopenfilename( + parent=self, + caption="Select YeaZ weights file", + filters="All Files (*)", + basedir=start_dir, + )[0] + if not model_filepath: + self.setCurrentIndex(0) + return + + msg = html_utils.paragraph(f""" + Insert a name for the following YeaZ model:

    + {model_filepath}
    + """) + modelNameWindow = apps.QLineEditDialog( + title="Insert a name for the model", msg=msg, allowEmpty=False, parent=self + ) + modelNameWindow.exec_() + if modelNameWindow.cancel: + self.setCurrentIndex(0) + return + + model_name = modelNameWindow.enteredValue + + from cellacdc.segmenters.YeaZ_v2 import add_model_filepath + + add_model_filepath(model_name, model_filepath) + + self.addItem(model_name) + self.setCurrentText(model_name) + + print( + "YeaZ_v2 model added!\n\n" + f" * Name: {model_name}\n" + f" * File path: {model_filepath}\n" + ) + + def addItem(self, item): + idx = self.count() - 1 + self.insertItem(idx, item) + + def addItems(self, items): + super().clear() + super().addItems(items) + super().addItem(self._csi_text) + idx = len(items) + font = self.font() + font.setItalic(True) + self.setItemData(idx, font, Qt.FontRole) + + def setValue(self, value: str): + self.setCurrentText(value) + + def value(self, *args): + return self.currentText() + + +class AutoSaveIntervalWidget(QWidget): + sigValueChanged = Signal(float, str) + + def __init__(self, parent=None): + super().__init__(parent) + + layout = QHBoxLayout() + + autoSaveIntervalTooltip = "Autosave every minutes or frames specified here." + + self.setToolTip(autoSaveIntervalTooltip) + + self.spinbox = DoubleSpinBox() + self.spinbox.setMinimum(0) + self.spinbox.setValue(2) + self.spinbox.setDecimals(2) + self.spinbox.setSingleStep(1.0) + + layout.addWidget(self.spinbox) + + self.unitCombobox = ComboBox() + self.unitCombobox.addItems(["minutes", "frames"]) + layout.addWidget(self.unitCombobox) + + layout.setStretch(0, 1) + layout.setStretch(1, 0) + layout.setContentsMargins(5, 0, 5, 0) + + self.setLayout(layout) + + self.spinbox.sigValueChanged.connect(self.emitSigValueChanged) + self.unitCombobox.sigTextChanged.connect(self.emitSigValueChanged) + + def emitSigValueChanged(self, *args, **kwargs): + self.sigValueChanged.emit(self.spinbox.value(), self.unitCombobox.currentText()) + + +class CheckableWidget(QWidget): + def __init__(self, widget, valueGetterName="value", parent=None): + super().__init__(parent) + + self.widget = widget + self.valueGetterName = valueGetterName + + widget.setDisabled(True) + + layout = QHBoxLayout() + + layout.addWidget(widget) + + self.checkbox = QCheckBox("Activate") + self.checkbox.toggled.connect(self.setWidgetEnabled) + + layout.addSpacing(5) + layout.addWidget(self.checkbox) + + layout.setContentsMargins(5, 0, 5, 0) + + self.setLayout(layout) + + def setWidgetEnabled(self, checked): + self.widget.setDisabled(not checked) + + def value(self): + if not self.checkbox.isChecked(): + return + + return getattr(self.widget, self.valueGetterName)() + + +class warnVisualCppRequired(myMessageBox): + def __init__(self, pkg_name="javabridge", parent=None): + super().__init__(parent) + self.screenShotWin = None + + self.setIcon(iconName="SP_MessageBoxWarning") + self.setWindowTitle(f"Installation of {pkg_name} info") + txt = html_utils.paragraph(f""" + Installation of {pkg_name} on Windows requires + Microsoft Visual C++ 14.0 or higher.

    + Cell-ACDC will anyway try to install {pkg_name} now.

    + If the installation fails, please close Cell-ACDC, + then download and install "Microsoft C++ Build Tools" + from the link below + before trying this module again.

    + + https://visualstudio.microsoft.com/visual-cpp-build-tools/ +

    + IMPORTANT: when installing "Microsoft C++ Build Tools" + make sure to select "Desktop development with C++". + Click "See the screenshot" for more details. + """) + seeScreenshotButton = QPushButton("See screenshot...") + okButton = okPushButton("Ok") + okButton = self.addButton("Ok") + okButton.disconnect() + okButton.clicked.connect(self.ok_cb) + self.addButton(seeScreenshotButton) + seeScreenshotButton.disconnect() + seeScreenshotButton.clicked.connect(self.viewScreenshot) + self.addCancelButton(connect=True) + self.addText(txt) + + def ok_cb(self): + self.cancel = False + self.close() + + def viewScreenshot(self, checked=False): + self.screenShotWin = view_visualcpp_screenshot(self) + self.screenShotWin.show() + + def closeEvent(self, event): + if self.screenShotWin is not None: + self.screenShotWin.close() + + return super().closeEvent(event) diff --git a/cellacdc/widgets/toolbars.py b/cellacdc/widgets/toolbars.py new file mode 100644 index 000000000..de0405734 --- /dev/null +++ b/cellacdc/widgets/toolbars.py @@ -0,0 +1,1229 @@ +"""GUI widgets: toolbars.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from .. import myutils, measurements, is_mac, is_win, html_utils, is_linux +from .. import printl, settings_folderpath +from .. import colors, config +from .. import html_path +from .. import _palettes +from .. import load +from .. import apps +from .. import plot +from .. import annotate +from .. import urls +from .. import _core, core +from .. import QtScoped +from .. import prompts +from ..acdc_regex import float_regex +from ..config import PREPROCESS_MAPPER +from .. import _base_widgets + +from ..components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ..components.progress import QtHandler, QLog, XStream # noqa: E402 +from ..components.buttons import * # noqa: E402, F403 +from ..components.layout import * # noqa: E402, F403 +from ..components.inputs_basic import * # noqa: E402, F403 +from ..components.path_controls import * # noqa: E402, F403 + +from ..components.lists import * # noqa: E402, F403 +from ..components.base import QBaseWindow # noqa: E402 +from ..components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +class ToolBarSeparator: + def __init__(self, width=5, toolbar: QToolBar = None): + self._parts = ( + QHWidgetSpacer(width=width), + QVLine(), + QHWidgetSpacer(width=width), + ) + self._actions = [] + self._toolbar = None + if toolbar is not None: + self.addToToolbar(toolbar) + + def addToToolbar(self, toolbar): + self._toolbar = toolbar + for part in self._parts: + action = toolbar.addWidget(part) + self._actions.append(action) + + def removeFromToolbar(self): + if self._toolbar is None: + return + + for action in self._actions: + self._toolbar.removeAction(action) + + +class ToolBar(QToolBar): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.widgetsWithShortcut = {} + + for child in self.children(): + if child.objectName() == "qt_toolbar_ext_button": + self.extendButton = child + self.extendButton.setIcon(QIcon(":expand.svg")) + break + + def addSeparator(self, width=5): + separator = ToolBarSeparator(width=width, toolbar=self) + return separator + + def removeSeparator(self, separator): + separator.removeFromToolbar() + + def addSpinBox(self, label=""): + spinbox = SpinBox(disableKeyPress=True) + if label: + spinbox.label = QLabel(label) + spinbox.labelAction = self.addWidget(spinbox.label) + + spinbox.action = self.addWidget(spinbox) + return spinbox + + def addButton(self, icon_str: str, text="", checkable=False): + action = QAction(QIcon(icon_str), text, self) + action.setCheckable(checkable) + self.addAction(action) + return action + + def addComboBox(self, items=None, label=""): + combobox = ComboBox() + + if items is not None: + combobox.addItems(items) + + if label: + combobox.label = QLabel(label) + combobox.labelAction = self.addWidget(combobox.label) + + combobox.action = self.addWidget(combobox) + return combobox + + def addLabel(self, text=""): + label = QLabel(text) + label.action = self.addWidget(label) + return label + + def addCheckBox(self, text="", checked=False): + checkbox = QCheckBox(text) + checkbox.setChecked(checked) + checkbox.action = self.addWidget(checkbox) + return checkbox + + +class CopyLostObjectToolbar(ToolBar): + sigCopyAllObjects = Signal(int, int) + + def __init__(self, *args) -> None: + super().__init__(*args) + + action = self.addButton(":copyContour_all.svg") + # action.setShortcut('Alt+C') + action.keyPressShortcut = KeySequenceFromText("Alt+C") + action.setToolTip("Copy all lost objects\n\nShortcut: Alt+C") + self.widgetsWithShortcut["Copy all lost objects"] = action + + action.triggered.connect(self.emitSigCopyAllObjects) + + self.addSeparator() + + self.maxOverlapNumberControl = self.addSpinBox( + label="Maximum overlap to accept lost object [%]: " + ) + self.maxOverlapNumberControl.setMinimum(0) + self.maxOverlapNumberControl.setValue(10) + tooltip = ( + "Maximum overlap to accept lost object [%]\n\n" + "If the overlap between the lost object and an object already " + "existing is greater than this value,\n" + "the lost object will not be added." + ) + self.maxOverlapNumberControl.setToolTip(tooltip) + self.maxOverlapNumberControl.label.setToolTip(tooltip) + + self.addSeparator() + + self.untilFrameNumberControl = self.addSpinBox( + label="Copy lost object(s) for the next number of frames: " + ) + self.untilFrameNumberControl.setMinimum(0) + self.untilFrameNumberControl.setValue(0) + + def emitSigCopyAllObjects(self): + self.sigCopyAllObjects.emit( + self.untilFrameNumberControl.value(), self.maxOverlapNumberControl.value() + ) + + +class DrawClearRegionToolbar(ToolBar): + def __init__(self, *args) -> None: + super().__init__(*args) + + group = QButtonGroup() + group.setExclusive(True) + self.clearTouchingObjsRadioButton = QRadioButton("Clear all touching objects") + self.clearOnlyEnclosedObjsRadioButton = QRadioButton( + "Clear only fully enclosed objects" + ) + self.clearOnlyEnclosedObjsRadioButton.setChecked(True) + group.addButton(self.clearTouchingObjsRadioButton) + group.addButton(self.clearOnlyEnclosedObjsRadioButton) + + self.addWidget(self.clearTouchingObjsRadioButton) + self.addWidget(self.clearOnlyEnclosedObjsRadioButton) + + self.addSeparator() + + self.numZslicesUpSpinbox = self.addSpinBox( + label="Num. of z-slices to clear upwards: " + ) + self.numZslicesUpSpinbox.setMinimum(0) + self.numZslicesUpSpinbox.setValue(0) + + self.numZslicesDownSpinbox = self.addSpinBox( + label="Num. of z-slices to clear downwards: " + ) + self.numZslicesDownSpinbox.setMinimum(0) + self.numZslicesDownSpinbox.setValue(0) + + def setZslicesControlEnabled(self, enabled, SizeZ=None): + self.numZslicesUpSpinbox.labelAction.setVisible(enabled) + self.numZslicesUpSpinbox.action.setVisible(enabled) + + self.numZslicesDownSpinbox.labelAction.setVisible(enabled) + self.numZslicesDownSpinbox.action.setVisible(enabled) + + if SizeZ is None: + return + + self.numZslicesUpSpinbox.setMaximum(SizeZ) + self.numZslicesDownSpinbox.setMaximum(SizeZ) + + def zRange(self, z_slice, SizeZ): + if z_slice is None: + zRange = (0, SizeZ) + return zRange + + numZslicesUp = self.numZslicesUpSpinbox.value() + numZslicesDown = self.numZslicesDownSpinbox.value() + + zmin = z_slice - numZslicesDown + zmax = z_slice + numZslicesDown + 1 + + zmin = zmin if zmin >= 0 else 0 + zmax = zmax if zmax <= SizeZ else SizeZ + + return (zmin, zmax) + + +class rightClickToolButton(QToolButton): + sigRightClick = Signal(object) + sigLeftClick = Signal(object, object) + + def __init__(self, parent=None): + super().__init__(parent) + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + super().mousePressEvent(event) + self.sigLeftClick.emit(self, event) + elif event.button() == Qt.MouseButton.RightButton: + self.sigRightClick.emit(event) + + +class ToolButtonCustomColor(rightClickToolButton): + def __init__(self, symbol, color="r", parent=None): + super().__init__(parent=parent) + if not isinstance(color, QColor): + color = pg.mkColor(color) + self.symbol = symbol + self.setColor(color) + + def setColor(self, color): + self.penColor = color + self.brushColor = [0, 0, 0, 100] + self.brushColor[:3] = color.getRgb()[:3] + + def updateSymbol(self, symbol, update=True): + self.symbol = symbol + if not update: + return + self.update() + + def updateColor(self, color, update=True): + self.setColor(color) + if not update: + return + self.update() + + def updateIcon(self, symbol, color): + self.updateSymbol(symbol) + self.updateColor(color) + self.update() + + def paintEvent(self, event): + QToolButton.paintEvent(self, event) + p = QPainter(self) + w, h = self.width(), self.height() + sf = 0.6 + p.scale(w * sf, h * sf) + p.translate(0.5 / sf, 0.5 / sf) + symbol = pg.graphicsItems.ScatterPlotItem.Symbols[self.symbol] + pen = pg.mkPen(color=self.penColor, width=2) + brush = pg.mkBrush(color=self.brushColor) + try: + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.setPen(pen) + p.setBrush(brush) + p.drawPath(symbol) + except Exception as e: + traceback.print_exc() + finally: + p.end() + + +class GradientToolButton(rightClickToolButton): + def __init__(self, colors=((255, 0, 0),), parent=None): + super().__init__(parent=parent) + self._qcolors = [pg.mkColor(c) for c in colors] + if len(self._qcolors) < 2: + self._qcolors.append(self._qcolors[0]) + + def paintEvent(self, event): + super().paintEvent(event) + + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + pen = pg.mkPen(color=self._qcolors[-1], width=2) + + pad = 7 + + rect = self.rect().adjusted(pad, pad, -pad, -pad) # A little padding + + # Gradient: bottom to top + gradient = QLinearGradient(QPointF(rect.bottomLeft()), QPointF(rect.topLeft())) + + # Set color stops evenly distributed + num_colors = len(self._qcolors) + for i, color in enumerate(self._qcolors): + gradient.setColorAt(i / (num_colors - 1), color) + + if not self.isChecked(): + painter.setOpacity(0.4) + + painter.setBrush(gradient) + painter.setPen(pen) + painter.drawRect(rect) + + painter.end() + + +class PointsLayerToolButton(ToolButtonCustomColor): + sigEditAppearance = Signal(object) + sigShowIdsToggled = Signal(object, bool) + sigRemove = Signal(object) + + def __init__(self, symbol, color="r", parent=None): + super().__init__(symbol, color=color, parent=parent) + self.sigRightClick.connect(self.showContextMenu) + + def showContextMenu(self, event): + contextMenu = QMenu(self) + contextMenu.addSeparator() + + editAction = QAction("Edit points appearance...") + editAction.triggered.connect(self.editAppearance) + contextMenu.addAction(editAction) + + removeAction = QAction("Remove points") + removeAction.triggered.connect(self.emitRemove) + contextMenu.addAction(removeAction) + + showIdsAction = QAction("Show point ids") + showIdsAction.setCheckable(True) + showIdsAction.setChecked(True) + contextMenu.addAction(showIdsAction) + showIdsAction.toggled.connect(self.emitShowIdsToggled) + + contextMenu.exec(event.globalPos()) + + def emitRemove(self): + self.sigRemove.emit(self) + + def emitShowIdsToggled(self, checked): + self.sigShowIdsToggled.emit(self, checked) + + def editAppearance(self): + self.sigEditAppearance.emit(self) + + +class customAnnotToolButton(ToolButtonCustomColor): + sigRemoveAction = Signal(object) + sigKeepActiveAction = Signal(object) + sigModifyAction = Signal(object) + sigHideAction = Signal(object) + + def __init__( + self, symbol, color, keepToolActive=True, parent=None, isHideChecked=True + ): + super().__init__(symbol, color=color, parent=parent) + self.symbol = symbol + self.keepToolActive = keepToolActive + self.isHideChecked = isHideChecked + self.sigRightClick.connect(self.showContextMenu) + + def showContextMenu(self, event): + contextMenu = QMenu(self) + contextMenu.addSeparator() + + removeAction = QAction("Remove annotation") + removeAction.triggered.connect(self.removeAction) + contextMenu.addAction(removeAction) + + editAction = QAction("Modify annotation parameters...") + editAction.triggered.connect(self.modifyAction) + contextMenu.addAction(editAction) + + hideAction = QAction("Hide annotations") + hideAction.setCheckable(True) + hideAction.setChecked(self.isHideChecked) + hideAction.triggered.connect(self.hideAction) + contextMenu.addAction(hideAction) + + keepActiveAction = QAction("Keep tool active after using it") + keepActiveAction.setCheckable(True) + keepActiveAction.setChecked(self.keepToolActive) + keepActiveAction.triggered.connect(self.keepToolActiveActionToggled) + contextMenu.addAction(keepActiveAction) + + contextMenu.exec(event.globalPos()) + + def keepToolActiveActionToggled(self, checked): + self.keepToolActive = checked + self.sigKeepActiveAction.emit(self) + + def modifyAction(self): + self.sigModifyAction.emit(self) + + def removeAction(self): + self.sigRemoveAction.emit(self) + + def hideAction(self, checked): + self.isHideChecked = checked + self.sigHideAction.emit(self) + + +class ToolButtonTextIcon(rightClickToolButton): + def __init__(self, text="", parent=None): + super().__init__(parent=parent) + self._text = text + self._penColor = _palettes.text_pen_color() + + def setText(self, text): + self._text = text + self.update() + + def text(self): + return self._text + + def paintEvent(self, event): + QToolButton.paintEvent(self, event) + p = QPainter(self) + + pen = pg.mkPen(color=self._penColor, width=2) + p.setPen(pen) + + w, h = self.width(), self.height() + sf = 0.7 + rect_w = w * sf + rect_h = h * sf + x = (w - rect_w) / 2 + y = (h - rect_h) / 2 + rect = QRectF(x, y, rect_w, rect_h) + + font = p.font() + font.setBold(True) + font.setPixelSize(int(h / len(self._text))) + p.setFont(font) + + p.drawText(rect, Qt.AlignCenter, self._text) + p.end() + + +class WhitelistIDsToolbar(ToolBar): + sigWhitelistChanged = Signal(list) + sigViewOGIDs = Signal(bool) + sigWhitelistAccepted = Signal(list) + sigAddNewIDs = Signal(bool) + sigLoadOGLabs = Signal() + sigTrackOGagainstPreviousFrame = Signal(bool) + + def __init__(self, addNewIDToggleState, *args) -> None: + super().__init__(*args) + + whitelistLineEditLabel = QLabel("Whitelist IDs: ") + self.addWidget(whitelistLineEditLabel) + + self.whitelistLineEdit = WhitelistLineEdit(whitelistLineEditLabel, parent=self) + self.whitelistLineEdit.sigEnterPressed.connect(self.accept) + self.whitelistLineEdit.sigIDsChanged.connect(self.emitWhitelistChanged) + self.addWidget(self.whitelistLineEdit) + + # accept button + self.acceptButton = self.addButton(":greenTick.svg") + self.acceptButton.triggered.connect(self.accept) + + # add a view OG toggle + self.viewOGToggle = self.addButton(":eye.svg", checkable=True) + viewOGTooltip = ( + "View the non-whitelisted segmentation mask.\n\n" + "You can activate this to add new IDs to the whitelist,\n" + "correct tracking errors, etc." + ) + self.viewOGToggle.setChecked(True) + self.viewOGToggle.setToolTip(viewOGTooltip) + self.viewOGToggle.setShortcut("Shift+K") + key = "View the non-whitelisted segmentation mask" + self.widgetsWithShortcut[key] = self.viewOGToggle + + self.viewOGToggle.toggled.connect(self.emitViewOGIDs) + self.emitViewOGIDs(True) + + # add a Toggle to add new IDs + self.addNewIDToggle = QCheckBox("Automatically add new IDs to whitelist") + self.addNewIDToggle.setChecked(addNewIDToggleState) + self.addWidget(self.addNewIDToggle) + self.addNewIDToggle.toggled.connect(self.emitAddNewIDs) + self.emitAddNewIDs(addNewIDToggleState) + + self.addSeparator() + + # add a button to load og df + self.loadOGButton = self.addButton(":open_file.svg") + self.loadOGButton.triggered.connect(self.sigLoadOGLabs.emit) + self.loadOGButton.setToolTip( + "Select which segmentation mask file to load as the non-whitelisted masks" + ) + + self.TrackOGagainstPreviousFrameButton = self.addButton(":segment.svg") + self.TrackOGagainstPreviousFrameButton.triggered.connect( + self.sigTrackOGagainstPreviousFrame.emit + ) + self.TrackOGagainstPreviousFrameButton.setToolTip( + "Track the non-whitelisted segmentation masks against the previous frame and copy over successfull tacks" + ) + + self.addSeparator() + + # add an info button + self.infoButton = self.addButton(":info.svg") + self.infoButton.triggered.connect(self.showInfo) + + # add a spacer to the toolbar + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + self.addWidget(spacer) + + def emitWhitelistChanged(self, whitelist): + self.sigWhitelistChanged.emit(whitelist) + + def emitViewOGIDs(self, checked): + self.sigViewOGIDs.emit(checked) + + def accept(self): + try: + whitelist = self.whitelistLineEdit.IDs + except AttributeError as e: + if "has no attribute 'IDs'" in str(e): + whitelist = list() + self.viewOGToggle.toggled.disconnect() + self.viewOGToggle.setChecked(False) + self.viewOGToggle.toggled.connect(self.emitViewOGIDs) + self.sigWhitelistAccepted.emit(whitelist) + + def emitAddNewIDs(self, checked): + self.sigAddNewIDs.emit(checked) + + def showInfo(self): + msg = myMessageBox(wrapText=False) + txt = html_utils.paragraph(""" + This function is used to track a subset of segmented objects.

    + + To add new IDs to the white list, click with left mouse button on the + object to add.
    + You can also write directly into the Whitelist IDs widget
    + and separate the IDs by commas.

    + + After adding the IDs, click on the "Accept" button to remove the + non-whitelisted objects.
    + Every time you visit a new frame, the non-whitelisted objects will + be removed automatically.

    + Use the "Eye" button to view the non-whitelisted segmentation masks.
    + This will allow you to correct tracking errors, add new IDs to the + white list, etc.

    + + If you previously saved the whitelisted masks, you can load the + non-whitelisted file
    + by clicking on the "Load file" button to restart from where you + left last time. + """) + msg.information(self, "White list IDs", txt) + + +class MagicPromptsToolbar(ToolBar): + sigPromptTypeChanged = Signal(object, str) + sigComputeOnZoom = Signal(object) + sigComputeOnImage = Signal(object) + sigClearPoints = Signal(object) + sigClearPointsOnZmom = Signal(object) + sigInitSelectedModel = Signal(str, object, list, list, str, object) + sigViewModelParams = Signal(str, object, list, list, str, object, object, object) + sigInterpolateZslice = Signal(bool) + + def __init__(self, parent=None): + super().__init__(parent) + + self._parent = parent + + prompt_types = ("Points",) + + self.selectModelAction = self.addButton(":select-list.svg") + self.selectModelAction.setToolTip("Select the promptable model to use") + + self.viewModelParamsAction = self.addButton(":view.svg") + self.viewModelParamsAction.setToolTip( + "View the currently selected model parameters" + ) + self.viewModelParamsAction.setDisabled(True) + + self.addSeparator() + + self.promptTypeCombobox = self.addComboBox( + prompt_types, + label="Prompt type: ", + ) + + self.addSeparator() + + self.interpolateZslicesCheckbox = self.addCheckBox( + "Interpolate points on missing z-slices", checked=False + ) + self.interpolateZslicesCheckbox.setToolTip( + "If checked, when working with 3D segmentation masks, you can " + "add points on some z-slices only and the points on the missing " + "z-slices will be determined by linear interpolation.\n\n" + "This is useful when working with 2D models that segments " + "each z-slice independently.\n\n" + "NOTE: The points will be added only when running the model and " + "removed afterwards." + ) + + self.addSeparator() + + self.computeOnZoomAction = self.addButton(":compute-zoom.svg") + self.computeOnZoomAction.setToolTip( + "Compute the segmentation on the zoomed area of the image (faster)" + ) + + self.computeAction = self.addButton(":compute.svg") + self.computeAction.setToolTip("Compute the segmentation on the whole image") + + self.clearPointsAction = self.addButton(":clear-points.svg") + self.clearPointsAction.setToolTip("Clear all points") + self.clearPointsAction.setDisabled(True) + + self.clearPointsActionOnZoom = self.addButton(":clear-points-zoom.svg") + self.clearPointsActionOnZoom.setToolTip( + "Clear all points on the zoomed area of the image" + ) + self.clearPointsActionOnZoom.setDisabled(True) + + self.addSeparator() + + self.infoAction = self.addButton(":info.svg") + self.infoAction.setToolTip("Show instructions how to use promptable models") + + self.addSeparator() + + self.infoAction.triggered.connect(self.showHelp) + self.selectModelAction.triggered.connect(self.selectModel) + self.viewModelParamsAction.triggered.connect(self.viewModelParams) + self.promptTypeCombobox.sigTextChanged.connect(self.emitPromptTypeChanged) + self.computeOnZoomAction.triggered.connect(self.emitSigComputeOnZoom) + self.computeAction.triggered.connect(self.emitSigComputeOnImage) + self.clearPointsAction.triggered.connect(self.emitSigClearPoints) + self.clearPointsActionOnZoom.triggered.connect(self.emitSigClearPointsOnZoom) + self.interpolateZslicesCheckbox.toggled.connect(self.sigInterpolateZslice.emit) + + def showHelp(self): + msg = myMessageBox(wrapText=False) + txt = html_utils.paragraph(""" + This toolbar allows you to use promptable models for + segmentation.

    + + To use a promptable model, first select the model by clicking on the + "Select model" button.
    + This will open a dialog where you can select the model to use.

    + + After selecting the model, you can view the model parameters + by clicking on the "View model parameters" button.

    + + To add points to the image, make sure you have points layer correctly + initialised. You should see controls
    + called "Left-click ID" and "Right-click ID".

    + + You can add points for a new object by left-clicking on the image, + while you can add points
    + for the same object by right-clicking. + To delete a point, click on it again.

    + + To change the right-click ID, + you can either type in the corresponding control,
    + or type the object id on the keyboard followed by "Enter".

    + + To add negative prompts (i.e., for the background), use the + same action you use to delete objects
    + (default is middle-click on Windows and Cmd+Click on MacOS).

    + Note that you can also add object-specific negative prompts (i.e., + they affect only that object)
    + by adding the negative prompt on the newly segmented object + directly.

    + + Once you are happy with the added points, click either the + "Compute on zoomed area"
    + button or the "Compute on whole image" button.

    + + Finally, you can clear all points by clicking on the + "Clear points" button.

    + + Note that you can also save the points by clicking on the + "Save points" button to load them later and start from + where you left.

    + """) + msg.information(self, "Promptable models help", txt) + + def emitSigClearPoints(self): + self.sigClearPoints.emit(self) + + def emitSigClearPointsOnZoom(self): + self.sigClearPointsOnZmom.emit(self) + + def emitSigComputeOnZoom(self): + self.sigComputeOnZoom.emit(self) + + def emitSigComputeOnImage(self): + self.sigComputeOnImage.emit(self) + + def selectModel(self): + win = apps.SelectPromptableModelDialog(parent=self._parent) + win.exec_() + if win.cancel: + print("Promptable model selection cancelled") + return + + model_name = win.model_name + print(f"Importing promptable model {model_name}...") + + # Download model weights, consistent with gui.py + downloadWin = apps.downloadModel(model_name, parent=self._parent) + downloadWin.download() + + acdcPromptSegment = myutils.import_promptable_segment_module(model_name) + init_argspecs, segment_argspecs = myutils.getModelArgSpec(acdcPromptSegment) + + try: + help_url = acdcPromptSegment.url_help() + except AttributeError: + help_url = None + + self._model_name = model_name + self._acdcPromptSegment = acdcPromptSegment + self._init_argspecs = init_argspecs + self._segment_argspecs = segment_argspecs + self._help_url = help_url + + self.sigInitSelectedModel.emit( + model_name, + acdcPromptSegment, + init_argspecs, + segment_argspecs, + help_url, + self, + ) + + def setInitializedModel(self, init_kwargs, segment_kwargs): + self._init_kwargs = init_kwargs + self._segment_kwargs = segment_kwargs + + def viewModelParams(self): + self.sigViewModelParams.emit( + self._model_name, + self._acdcPromptSegment, + self._init_argspecs, + self._segment_argspecs, + self._help_url, + self._init_kwargs, + self._segment_kwargs, + self, + ) + + def emitPromptTypeChanged(self, text): + self.sigPromptTypeChanged.emit(self, text) + + +class PointsLayersToolbar(ToolBar): + sigAddPointsLayer = Signal() + + def __init__(self, name="Points layers", parent=None): + + super().__init__(name, parent) + + self.guiWin = parent + + self.setContextMenuPolicy(Qt.PreventContextMenu) + + self.addPointsLayerAction = self.addButton(":addPointsLayer.svg") + + self.addSeparator() + + self.pointsLayersLabel = self.addLabel("Points layers: ") + + self.addPointsLayerAction.triggered.connect(self.emitAddPointsLayer) + self.doAddPointsZslicesInterpolation = False + + def emitAddPointsLayer(self): + self.sigAddPointsLayer.emit() + + def fromActionToDataFrame(self, action, posData, isSegm3D=False): + df = pd.DataFrame(columns=["frame_i", "Cell_ID", "z", "y", "x", "id"]) + frames_vals = [] + IDs = [] + zz = [] + yy = [] + xx = [] + ids = [] + pos_i = self.guiWin.pos_i + if pos_i not in action.pointsData: + printl( + "No points data for position", pos_i + ) # should really not happen, but its not a disaster if it does + return df + pointsDataPos = action.pointsData[pos_i] + for frame_i, framePointsData in pointsDataPos.items(): + if posData.SizeZ > 1: + for z, zSlicePointsData in framePointsData.items(): + yyxx = zip(zSlicePointsData["y"], zSlicePointsData["x"]) + for y, x in yyxx: + if isSegm3D: + ID = posData.lab[int(z), int(y), int(x)] + else: + ID = posData.lab[int(y), int(x)] + frames_vals.append(frame_i) + IDs.append(ID) + zz.append(z) + yy.append(y) + xx.append(x) + ids.extend(zSlicePointsData["id"]) + else: + yyxx = zip(framePointsData["y"], framePointsData["x"]) + for y, x in yyxx: + ID = posData.lab[int(y), int(x)] + frames_vals.append(frame_i) + IDs.append(ID) + yy.append(y) + xx.append(x) + ids.extend(framePointsData["id"]) + df["frame_i"] = frames_vals + df["Cell_ID"] = IDs + df["y"] = yy + df["x"] = xx + df["id"] = ids + if zz: + df["z"] = zz + + df = self.addPointsZslicesInterpolation(df, posData.lab, isSegm3D) + + return df + + def addPointsZslicesInterpolation( + self, df: pd.DataFrame, lab: np.ndarray, isSegm3D: bool + ): + if not self.doAddPointsZslicesInterpolation: + return df + + if not isSegm3D: + return df + + if "z" not in df.columns: + return df + + df_new_rows = [] + for (frame_i, point_id), df_id in df.groupby(["frame_i", "id"]): + xx = df_id["x"].values + yy = df_id["y"].values + zz = df_id["z"].values + + p0, d = core.linear_fit_3d(xx, yy, zz) + + new_row_df = df_id.iloc[[0]].copy() + + z0, z1 = int(np.min(zz)), int(np.max(zz)) + for z in range(z0, z1 + 1): + if z in zz: + continue + + t_int = (z - p0[2]) / d[2] + x_new, y_new, z_new = p0 + t_int * d + new_row_df["z"] = round(z_new) + new_row_df["y"] = round(y_new) + new_row_df["x"] = round(x_new) + + Cell_ID = lab[int(round(z_new)), int(round(y_new)), int(round(x_new))] + new_row_df["Cell_ID"] = Cell_ID + + df_new_rows.append(new_row_df.copy()) + + if not df_new_rows: + return df + + df_new = pd.concat(df_new_rows, ignore_index=True) + df = pd.concat([df, df_new], ignore_index=True) + df = df.sort_values(by=["frame_i", "id", "z"]).reset_index(drop=True) + + return df + + +class PromptableModelPointsLayerToolbar(PointsLayersToolbar): + def __init__(self, name="Promptable model points layers", parent=None): + super().__init__(name, parent=parent) + + self.isPointsLayerInit = False + + self.addPointsLayerAction.setDisabled(True) + self.addPointsLayerAction.setVisible(False) + + def pointsLayerDf(self, posData, isSegm3D=False): + for action in self.actions()[1:]: + if not hasattr(action, "button"): + continue + + df = self.fromActionToDataFrame(action, posData, isSegm3D=isSegm3D) + return df + + def scatterItem(self): + for action in self.actions()[1:]: + if not hasattr(action, "button"): + continue + + return action.scatterItem + + +class OverlayToolbar(ToolBar): + sigSetTranspacency = Signal(bool) + sigSetSingleChannel = Signal(bool) + + def __init__(self, name="Overlay tools", parent=None): + + super().__init__(name, parent) + + self.guiWin = parent + + self.setContextMenuPolicy(Qt.PreventContextMenu) + + self.addSeparator() + + self.transparencyCheckbox = self.addCheckBox( + text="True transparency (RGBA composite)" + ) + + self.transparencyCheckbox.setToolTip( + "Activate to achieve true pixel-wise transparency where " + "the pixel intensity is 0 or set to 0 using the " + "LUT sliders on the left of the images.\n\n" + "Since it is significantly slower, we recommended to activate this " + "only if you need to export images for figures." + ) + + self.addSeparator() + + self.singleChannelCheckbox = self.addCheckBox(text="Single channel") + + self.singleChannelCheckbox.setToolTip( + "When single channel mode is activated, selecting a channel " + "will display only that channel in the overlay." + ) + + self.transparencyCheckbox.toggled.connect(self.sigSetTranspacency.emit) + self.singleChannelCheckbox.toggled.connect(self.sigSetSingleChannel.emit) + + def setTransparent(self, transparent: bool): + self.transparencyCheckbox.setChecked(transparent) + + def isTransparent(self): + return self.transparencyCheckbox.isChecked() + + def isSingleChannel(self): + return self.singleChannelCheckbox.isChecked() + + +class OverlayChannelToolButton(GradientToolButton): + def __init__( + self, + channel_name: str, + lut_item: myHistogramLUTitem, + shortcut="0", + parent=None, + ): + super().__init__(colors=lut_item.gradient.getLookupTable(256), parent=parent) + self._channel_name = channel_name + + lut_item.sigGradientChanged.connect(self.updateColors) + + self.setToolTip(f'Show/hide "{channel_name}" channel\n\nShortcut: {shortcut}') + + self.setCheckable(True) + + def channelName(self): + return self._channel_name + + def updateColors(self, lut_item): + colors = lut_item.gradient.getLookupTable(256) + self._qcolors = [pg.mkColor(c) for c in colors] + self.update() + + def setVisible(self, visible: bool): + super().setVisible(visible) + if not hasattr(self, "action"): + return + + self.action.setVisible(visible) + + +class HighlightedIDToolbar(ToolBar): + sigIDChanged = Signal(int) + + def __init__(self, name="Highlighted ID", parent=None): + + super().__init__(name, parent) + + self.spinbox = self.addSpinBox("Highlighted ID: ") + self.spinbox.valueChanged.connect(self.emitSigIDChanged) + + self.addSeparator() + + def emitSigIDChanged(self, *args, **kwargs): + self.sigIDChanged.emit(self.spinbox.value()) + + def setIDNoSignals(self, ID: int): + self.spinbox.blockSignals(True) + self.spinbox.setValue(ID) + self.spinbox.blockSignals(False) + + +class WandControlsToolbar(ToolBar): + def __init__(self, name="Magic wand controls", parent=None): + super().__init__(name, parent) + + self.toleranceSpinbox = self.addSpinBox("Tolerance [%]: ") + self.toleranceSpinbox.setMinimum(0) + self.toleranceSpinbox.setMaximum(100) + self.toleranceSpinbox.setValue(5) + self.toleranceSpinbox.setToolTip( + "The tolerance is calculated as a percentage of the minimum-maximum " + "pixel values range of the loaded dataset.\n\n" + "If tolerance is greater than 0, the pixels adjacent to the added " + "pixels with value within +- tolerance will be considered part of " + "the object." + ) + self.addLabel(r"% of min-max intensity range ") + + self.addSeparator() + + self.autoFillHolesCheckbox = self.addCheckBox("Auto-fill holes") + + self.addSeparator() + + self.useConvexHullCheckbox = self.addCheckBox("Use convex hull mask") + + self.addSeparator() + +# Sibling imports (deferred to avoid import cycles) +from .canvas import ( + myHistogramLUTitem, +) +from .controls import ( + ComboBox, + KeySequenceFromText, + SpinBox, + WhitelistLineEdit, + myMessageBox, +) + diff --git a/cellacdc/workers.py b/cellacdc/workers.py deleted file mode 100755 index 3d4f9422c..000000000 --- a/cellacdc/workers.py +++ /dev/null @@ -1,6688 +0,0 @@ -import re -import os -import shutil -import time -import json -import concurrent.futures -from functools import partial -from collections import defaultdict, deque -import itertools - -from typing import Union, List, Dict, Callable, Any, Tuple, Iterable - -from functools import wraps -import numpy as np -import pandas as pd -import h5py -import traceback - -import skimage.io -import skimage.measure -import skimage.exposure - -import queue - -from tqdm import tqdm - -from qtpy.QtCore import Signal, QObject, QMutex, QWaitCondition - -from cellacdc import html_utils - -from . import load, myutils, core, prompts, printl, config, segm_re_pattern, io -from . import transformation, measurements, cca_functions -from .path import copy_or_move_tree -from . import features, plot -from . import core -from . import cca_df_colnames, lineage_tree_cols, default_annot_df -from . import cca_df_colnames_with_tree -from . import cli -from .utils import resize -from . import segm_utils - -DEBUG = False - - -def worker_exception_handler(func): - @wraps(func) - def run(self): - try: - func(self) - except Exception as error: - printl(traceback.format_exc()) - try: - self.dataQ.clear() - except Exception as err: - pass - - # Some workers have both self.critical and self.signals.critical - # errors but only one of them is connected --> emit both just - # in case - try: - self.critical.emit((self, error)) - except Exception as err: - self.signals.critical.emit((self, error)) - - try: - self.signals.critical.emit((self, error)) - except Exception as err: - self.critical.emit((self, error)) - - try: - self.mutex.unlock() - except Exception as err: - pass - - return run - - -class workerLogger: - def __init__(self, sigProcess): - self.sigProcess = sigProcess - - def log(self, message, level="INFO"): - try: - self.sigProcess.emit(str(message), level) - except Exception as err: - print(message, level) - try: - traceback_format = traceback.format_exc() - print(traceback_format) - except Exception as err: - pass - printl(err) - finally: - pass - - def info(self, message): - self.log(message, level="INFO") - - def warning(self, message): - self.log(message, level="WARNING") - - def exception(self, message): - self.log(message, level="EXCEPTION") - - -class signals(QObject): - progress = Signal(str, object) - finished = Signal(object) - initProgressBar = Signal(int) - progressBar = Signal(int) - critical = Signal(object) - dataIntegrityWarning = Signal(str) - dataIntegrityCritical = Signal() - sigLoadingFinished = Signal() - sigLoadingNewChunk = Signal(object) - resetInnerPbar = Signal(int) - progress_tqdm = Signal(int) - signal_close_tqdm = Signal() - create_tqdm = Signal(int) - innerProgressBar = Signal(int) - sigPermissionError = Signal(str, object) - sigSelectSegmFiles = Signal(object, object) - sigSelectAcdcOutputFiles = Signal(object, object, str, bool, bool) - sigSelectSpotmaxRun = Signal(object, object, object, str, bool, bool) - sigSetMeasurements = Signal(object) - sigInitAddMetrics = Signal(object, object) - sigUpdatePbarDesc = Signal(str) - sigComputeVolume = Signal(int, object) - sigAskStopFrame = Signal(object) - sigWarnMismatchSegmDataShape = Signal(object) - sigErrorsReport = Signal(dict, dict, dict) - sigMissingAcdcAnnot = Signal(dict) - sigRecovery = Signal(object) - sigInitInnerPbar = Signal(int) - sigUpdateInnerPbar = Signal(int) - sigSelectFile = Signal(str, str, str) - sigAskCopyCca = Signal(str) - sigSelectFilesWithText = Signal(str, object, str, object) - sigAskRunNow = Signal(object) - - -class AutoPilotWorker(QObject): - finished = Signal() - critical = Signal(object) - progress = Signal(str, object) - sigStarted = Signal() - sigStopTimer = Signal() - - def __init__(self, guiWin): - QObject.__init__(self) - self.logger = workerLogger(self.progress) - self.guiWin = guiWin - self.app = guiWin.app - # self.timer = timer - - def timerCallback(self): - pass - - def stop(self): - self.sigStopTimer.emit() - self.finished.emit() - - def run(self): - self.sigStarted.emit() - - -class FindNextNewIdWorker(QObject): - def __init__(self, posData, guiWin): - QObject.__init__(self) - self.signals = signals() - self.logger = workerLogger(self.signals.progress) - self.posData = posData - self.guiWin = guiWin - - @worker_exception_handler - def run(self): - prev_IDs = None - next_frame_i = -1 - for frame_i, data_dict in enumerate(self.posData.allData_li): - lab = data_dict["labels"] - rp = data_dict["regionprops"] - IDs = data_dict["IDs"] - if lab is None: - lab = self.posData.segm_data[frame_i] - rp = skimage.measure.regionprops(lab) - IDs = [obj.label for obj in rp] - - if prev_IDs is None: - prev_IDs = IDs - continue - - newIDs = [ID for ID in IDs if ID not in prev_IDs] - if newIDs: - next_frame_i = frame_i - break - prev_IDs = IDs - - self.signals.finished.emit(next_frame_i) - - -class SegForLostIDsWorker(QObject): - sigAskInit = Signal() - sigAskInstallModel = Signal(str) - sigshowImageDebug = Signal(object) - sigStoreData = Signal(bool) - sigUpdateRP = Signal(bool, bool) - # sigGetData = Signal() - # sigGet2Dlab = Signal() - # sigGetTrackedLostIDs = Signal() - # sigGetBrushID = Signal() - sigSegForLostIDsWorkerAskInstallGPU = Signal(str, bool) - sigTrackManuallyAddedObject = Signal(object, object, bool, bool) - - def __init__(self, guiWin, mutex, waitCond, debug=False): - QObject.__init__(self) - self.signals = signals() - self.logger = workerLogger(self.signals.progress) - self.guiWin = guiWin - self.mutex = mutex - self.waitCond = waitCond - self._debug = debug - - def emitSigAskInit(self): - self.mutex.lock() - self.sigAskInit.emit() - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def emitSigShowImageDebug(self, img): - # self.mutex.lock() - self.sigshowImageDebug.emit(img) - # self.waitCond.wait(self.mutex) - # self.mutex.unlock() - - def emitSigStoreData(self, autosave): - self.mutex.lock() - self.sigStoreData.emit(autosave) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def emitSigUpdateRP(self, wl_track_og_curr, wl_update): - self.mutex.lock() - self.sigUpdateRP.emit(wl_track_og_curr, wl_update) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - # def emitSigGetData(self): - # self.mutex.lock() - # self.sigGetData.emit() - # self.waitCond.wait(self.mutex) - # self.mutex.unlock() - - def emitSigAskInstallModel(self, model_name): - self.mutex.lock() - self.sigAskInstallModel.emit(model_name) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def emitSigAskInstallGPU(self, base_model_name, use_gpu): - self.mutex.lock() - self.sigSegForLostIDsWorkerAskInstallGPU.emit(base_model_name, use_gpu) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - # def emitGet2Dlab(self): - # self.mutex.lock() - # self.sigGet2Dlab.emit() - # self.waitCond.wait(self.mutex) - # self.mutex.unlock() - - # def emitGetTrackedLostIDs(self): - # self.mutex.lock() - # self.sigGetTrackedLostIDs.emit() - # self.waitCond.wait(self.mutex) - # self.mutex.unlock() - - # def emitGetBrushID(self): - # self.mutex.lock() - # self.sigGetBrushID.emit() - # self.waitCond.wait(self.mutex) - # self.mutex.unlock() - - def emitTrackManuallyAddedObject(self, IDs, isLost, wl_update, wl_track_og_curr): - self.mutex.lock() - self.sigTrackManuallyAddedObject.emit(IDs, isLost, wl_update, wl_track_og_curr) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - @worker_exception_handler - def run(self): - posData = self.guiWin.data[self.guiWin.pos_i] - frame_i = posData.frame_i - - if not self.guiWin.SegForLostIDsSettings: - self.emitSigAskInit() - - if not self.guiWin.SegForLostIDsSettings: - self.signals.finished.emit(self) - return - - self.logger.info("Segmentation for lost IDs started.") - model_name = "local_seg" - base_model_name = self.guiWin.SegForLostIDsSettings["base_model_name"] - idx = self.guiWin.modelNames.index(model_name) - acdcSegment = self.guiWin.acdcSegment_li[idx] - - init_kwargs = self.guiWin.SegForLostIDsSettings["win"].init_kwargs - - use_gpu = init_kwargs.get("device_type", "cpu") != "cpu" - use_gpu = use_gpu or init_kwargs.get("use_gpu", False) - - self.emitSigAskInstallGPU(base_model_name, use_gpu) - - if not self.gpu_go: - self.signals.finished.emit(self) - return - - if not self.dont_force_cpu: - if "device" in init_kwargs: - init_kwargs["device"] = "cpu" - if "use_gpu" in init_kwargs: - init_kwargs["use_gpu"] = False - - if ( - acdcSegment is None - or base_model_name != self.guiWin.local_seg_base_model_name - ): - try: - self.logger.info(f"Importing {base_model_name}...") - self.emitSigAskInstallModel(base_model_name) - acdcSegment = myutils.import_segment_module(base_model_name) - self.guiWin.acdcSegment_li[idx] = acdcSegment - self.guiWin.local_seg_base_model_name = base_model_name - except (IndexError, ImportError, KeyError) as e: - self.logger.warning( - f"Cannot import {base_model_name} model. Please install it first." - ) - self.signals.critical.emit( - ( - self, - f"Cannot import {base_model_name} model. " - "Please install it first.", - ) - ) - self.signals.finished.emit(self) - return - - win = self.guiWin.SegForLostIDsSettings["win"] - init_kwargs_new = self.guiWin.SegForLostIDsSettings["init_kwargs_new"] - args_new = self.guiWin.SegForLostIDsSettings["args_new"] - - model = myutils.init_segm_model(acdcSegment, posData, init_kwargs_new) - if model is None: - self.logger.info("Segmentation model was not initialized correctly!") - self.signals.critical.emit( - (self, "Segmentation model was not initialized correctly!") - ) - self.signals.finished.emit(self) - return - if self._debug: - try: - model.setupLogger(self.guiwin.logger) - except Exception as e: - pass - - assigned_IDs = [] - missing_IDs_global = set() - original_lab = posData.lab.copy() - IDs_bboxs_list = [] - bboxs_list = [] - - curr_img = self.guiWin.getDisplayedImg1() - prev_lab = self.guiWin.get_2Dlab(posData.allData_li[frame_i - 1]["labels"]) - prev_IDs = set(posData.allData_li[frame_i - 1]["IDs"]) - - # should probably not paly so much with posData.lab, instead handle stuff myself - self.signals.initProgressBar.emit(2 * args_new["max_iterations"]) - new_labs = np.zeros( - [args_new["max_iterations"], *posData.lab.shape], dtype=np.uint32 - ) - for i in range(args_new["max_iterations"]): - curr_lab = self.guiWin.get_2Dlab(posData.lab) - tracked_lost_IDs = self.guiWin.getTrackedLostIDs() - new_unique_ID = self.guiWin.setBrushID(useCurrentLab=True, return_val=True) - - missing_IDs = prev_IDs - set(posData.IDs) - set(tracked_lost_IDs) - missing_IDs_global.update(missing_IDs) - - assigned_IDs_prev = assigned_IDs.copy() - out = segm_utils.single_cell_seg( - model, - prev_lab, - curr_lab, - curr_img, - missing_IDs, - new_unique_ID, - win, - posData, - distance_filler_growth=args_new["distance_filler_growth"], - overlap_threshold=args_new["overlap_threshold"], - padding=args_new["padding"], - ) - new_lab, assigned_IDs, IDs_bboxs, bboxs = out - - IDs_bboxs_list.append(IDs_bboxs) - bboxs_list.append(bboxs) - posData.lab = new_lab - self.emitSigUpdateRP(wl_update=True, wl_track_og_curr=False) - newly_assigned_IDs = set(assigned_IDs) - set(assigned_IDs_prev) - self.emitTrackManuallyAddedObject(newly_assigned_IDs, True, False, False) - new_labs[i] = posData.lab.copy() - self.signals.progressBar.emit(1) - - if self._debug: - originals = [] - models = [] - - posData.lab = original_lab.copy() - - global_area_mean = np.mean([obj.area for obj in posData.rp]) - for IDs_bboxs, bboxs in zip(IDs_bboxs_list, bboxs_list): - model_lab = new_labs[i] - if self._debug: - originals.append(original_lab.copy()) - models.append(posData.lab.copy()) - - for IDs, bbox in zip(IDs_bboxs, bboxs): - box_x_min, box_x_max, box_y_min, box_y_max = bbox - original_bbox_lab = original_lab[ - box_x_min:box_x_max, box_y_min:box_y_max - ] - original_bbox_lab_cleared_borders = skimage.segmentation.clear_border( - original_bbox_lab - ) - box_model_lab = model_lab[box_x_min:box_x_max, box_y_min:box_y_max] - - # original_bbox_lab[np.isin(original_bbox_lab, IDs)] = 0 should be a given. If not seg for lost IDs this recommended - - box_model_lab = skimage.segmentation.clear_border( - box_model_lab, buffer_size=1 - ) - - rp_model_lab = skimage.measure.regionprops(box_model_lab) - rp_original_lab = skimage.measure.regionprops(original_bbox_lab) - rp_original_lab_cleared = skimage.measure.regionprops( - original_bbox_lab_cleared_borders - ) - - original_IDs = [obj.label for obj in rp_original_lab] - areas = [obj.area for obj in rp_original_lab_cleared] - if len(areas) > 0: - area_mean = np.mean(areas) - else: - area_mean = global_area_mean - if args_new["allow_only_tracked_cells"]: - filtered_IDs = [ - obj.label - for obj in rp_model_lab - if obj.area > (1 - args_new["size_perc_diff"]) * area_mean - and obj.area < (1 + args_new["size_perc_diff"]) * area_mean - and obj.label not in original_IDs - and obj.label in missing_IDs_global - ] - else: - filtered_IDs = [ - obj.label - for obj in rp_model_lab - if obj.area > (1 - args_new["size_perc_diff"]) * area_mean - and obj.area < (1 + args_new["size_perc_diff"]) * area_mean - and obj.label not in original_IDs - ] - - if self._debug or DEBUG: - filtered_sizes = [ - (obj.label, obj.area) - for obj in rp_model_lab - if obj.label in filtered_IDs - ] - self.logger.info(f"Filtered sizes: {filtered_sizes}") - for label in filtered_IDs: - original_bbox_lab[box_model_lab == label] = ( - label # here the stuff should be tracked, so we keep the ID! - ) - - # original_lab[box_x_min:box_x_max, box_y_min:box_y_max] = original_bbox_lab - - self.signals.progressBar.emit(1) - - posData.lab = original_lab - - # if self._debug: - # originals = np.concatenate(originals, axis=0) - # models = np.concatenate(models, axis=0) - # self.emitSigShowImageDebug(originals) - # self.emitSigShowImageDebug(models) - - self.emitSigUpdateRP(wl_track_og_curr=True, wl_update=True) - self.emitSigStoreData(autosave=True) - - self.logger.info("Segmentation for lost IDs done.") - - self.signals.finished.emit(self) - - -class AlignDataWorker(QObject): - sigWarnTifAligned = Signal(object, object, object) - sigAskAlignSegmData = Signal() - - def __init__(self, posData, dataPrepWin, mutex, waitCond): - QObject.__init__(self) - self.signals = signals() - self.logger = workerLogger(self.signals.progress) - self.posData = posData - self.dataPrepWin = dataPrepWin - self.mutex = mutex - self.waitCond = waitCond - self.doNotAlignSegmData = False - self.doAbort = False - - def set_attr(self, align, user_ch_name): - self.align = align - self.user_ch_name = user_ch_name - - def pause(self): - self.mutex.lock() - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def restart(self): - self.waitCond.wakeAll() - - def emitWarnTifAligned(self, numFramesWith0s, tif, posData): - self.sigWarnTifAligned.emit(numFramesWith0s, tif, posData) - self.pause() - - def emitSigAskAlignSegmData(self): - self.sigAskAlignSegmData.emit() - self.pause() - - def _align_data(self): - _zip = zip(self.posData.tif_paths, self.posData.npz_paths) - aligned = False - self.posData.all_npz_paths = [ - tif.replace(".tif", "_aligned.npz") for tif in self.posData.tif_paths - ] - for i, (tif, npz) in enumerate(_zip): - doAlign = npz is None or self.posData.loaded_shifts is None - - filename_tif = os.path.basename(tif) - user_ch_filename = f"{self.posData.basename}{self.user_ch_name}.tif" - - if not doAlign: - _npz = f"{os.path.splitext(tif)[0]}_aligned.npz" - if os.path.exists(_npz): - self.posData.all_npz_paths[i] = _npz - continue - - if filename_tif != user_ch_filename: - continue - - if not self.align: - continue - - # Align based on user_ch_name - aligned = True - self.logger.log(f"Aligning: {tif}") - - tif_data = load.imread(tif) - numFramesWith0s = self.dataPrepWin.detectTifAlignment( - tif_data, self.posData - ) - if self.align: - self.emitWarnTifAligned(numFramesWith0s, tif, self.posData) - if self.doAbort: - return - - # Alignment routine - if self.posData.SizeZ > 1: - align_func = core.align_frames_3D - df = self.posData.segmInfo_df.loc[self.posData.filename] - zz = df["z_slice_used_dataPrep"].to_list() - if not self.posData.filename.endswith("aligned") and self.align: - # Add aligned channel to segmInfo - df_aligned = self.posData.segmInfo_df.rename( - index={ - self.posData.filename: f"{self.posData.filename}_aligned" - } - ) - self.posData.segmInfo_df = pd.concat( - [self.posData.segmInfo_df, df_aligned] - ) - self.posData.segmInfo_df.to_csv(self.posData.segmInfo_df_csv_path) - else: - align_func = core.align_frames_2D - zz = None - - if self.align: - self.signals.initProgressBar.emit(len(tif_data)) - aligned_frames, shifts = align_func( - tif_data, - slices=zz, - user_shifts=self.posData.loaded_shifts, - sigPyqt=self.signals.progressBar, - ) - self.posData.loaded_shifts = shifts - else: - aligned_frames = tif_data - - if self.align: - self.signals.initProgressBar.emit(0) - _npz = f"{os.path.splitext(tif)[0]}_aligned.npz" - self.logger.log(f"Storing temporary file: {_npz}") - temp_npz = self.dataPrepWin.getTempfilePath(_npz) - io.savez_compressed(temp_npz, aligned_frames) - self.dataPrepWin.storeTempFileMove(temp_npz, _npz) - np.save(self.posData.align_shifts_path, self.posData.loaded_shifts) - self.posData.all_npz_paths[i] = _npz - - self.logger.log(f"Storing temporary file: {tif}") - temp_tif = self.dataPrepWin.getTempfilePath(tif) - myutils.to_tiff(temp_tif, aligned_frames) - self.dataPrepWin.storeTempFileMove(temp_tif, tif) - self.posData.img_data = load.imread(temp_tif) - - _zip = zip(self.posData.tif_paths, self.posData.npz_paths) - for i, (tif, npz) in enumerate(_zip): - doAlign = npz is None or aligned - - if not doAlign: - continue - - if tif.endswith(f"{self.user_ch_name}.tif"): - continue - - if not self.align: - continue - - # Align the other channels - if self.posData.loaded_shifts is None: - break - - if self.align: - self.logger.log(f"Aligning: {tif}") - tif_data = load.imread(tif) - - # Alignment routine - if self.posData.SizeZ > 1: - align_func = core.align_frames_3D - df = self.posData.segmInfo_df.loc[self.posData.filename] - zz = df["z_slice_used_dataPrep"].to_list() - else: - align_func = core.align_frames_2D - zz = None - if self.align: - self.signals.initProgressBar.emit(len(tif_data)) - aligned_frames, shifts = align_func( - tif_data, - slices=zz, - user_shifts=self.posData.loaded_shifts, - sigPyqt=self.signals.progressBar, - ) - else: - aligned_frames = tif_data - - _npz = f"{os.path.splitext(tif)[0]}_aligned.npz" - - if self.align: - self.signals.initProgressBar.emit(0) - self.logger.log(f"Saving: {_npz}") - temp_npz = self.dataPrepWin.getTempfilePath(_npz) - io.savez_compressed(temp_npz, aligned_frames) - self.dataPrepWin.storeTempFileMove(temp_npz, _npz) - self.posData.all_npz_paths[i] = _npz - - self.logger.log(f"Saving: {tif}") - temp_tif = self.dataPrepWin.getTempfilePath(tif) - myutils.to_tiff(temp_tif, aligned_frames) - self.dataPrepWin.storeTempFileMove(temp_tif, tif) - - if not aligned: - return - - if not self.posData.segmFound: - return - - # Align segmentation data accordingly - self.segmAligned = False - if self.posData.loaded_shifts is None or not self.align: - return - - self.emitSigAskAlignSegmData() - if self.doNotAlignSegmData: - return - - self.dataPrepWin.segmAligned = True - self.logger.log(f"Aligning: {self.posData.segm_npz_path}") - self.posData.segm_data, shifts = core.align_frames_2D( - self.posData.segm_data, slices=None, user_shifts=self.posData.loaded_shifts - ) - self.logger.log(f"Saving: {self.posData.segm_npz_path}") - temp_npz = self.dataPrepWin.getTempfilePath(self.posData.segm_npz_path) - io.savez_compressed(temp_npz, self.posData.segm_data) - self.dataPrepWin.storeTempFileMove(temp_npz, self.posData.segm_npz_path) - - @worker_exception_handler - def run(self): - self._align_data() - self.signals.finished.emit(self) - - -class LabelRoiWorker(QObject): - finished = Signal() - critical = Signal(object) - progress = Signal(str, object) - sigProgressBar = Signal(int) - sigLabellingDone = Signal(object, bool) - - def __init__(self, Gui): - QObject.__init__(self) - self.logger = workerLogger(self.progress) - self.Gui = Gui - self.mutex = Gui.labelRoiMutex - self.waitCond = Gui.labelRoiWaitCond - self.exit = False - self.started = False - - def pause(self): - self.logger.log("Draw box around object to start magic labeller.") - self.mutex.lock() - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def start(self, roiImg, posData, roiSecondChannel=None, isTimelapse=False): - self.posData = posData - self.isTimelapse = isTimelapse - self.imageData = roiImg - self.roiSecondChannel = roiSecondChannel - self.restart() - - def restart(self, log=True): - if log: - self.logger.log("Magic labeller started...") - self.started = True - self.waitCond.wakeAll() - - def _stop(self): - self.logger.log("Magic labeller backend process done. Closing it...") - self.exit = True - self.waitCond.wakeAll() - - def _segment_image(self, img, secondChannelImg): - if secondChannelImg is not None: - img = self.Gui.labelRoiModel.second_ch_img_to_stack(img, secondChannelImg) - - lab = core.segm_model_segment( - self.Gui.labelRoiModel, - img, - self.Gui.model_kwargs, - preproc_recipe=self.Gui.preproc_recipe, - posData=self.posData, - ) - if self.Gui.applyPostProcessing: - from cellacdc.workflow.pipelines.postprocess_nodes import apply_postprocess - - lab = apply_postprocess( - lab, - img, - self.posData, - self.posData.frame_i, - apply_postprocessing=True, - standard_postprocess_kwargs=self.Gui.standardPostProcessKwargs, - custom_postprocess_features=self.Gui.customPostProcessFeatures, - custom_postprocess_grouped_features=self.Gui.customPostProcessGroupedFeatures, - ) - return lab - - @worker_exception_handler - def run(self): - while not self.exit: - if self.exit: - break - elif self.started: - self.logger.log("Magic labeller is doing its magic...") - if self.isTimelapse: - segmData = np.zeros(self.imageData.shape, dtype=np.uint32) - for frame_i, img in enumerate(self.imageData): - if self.roiSecondChannel is not None: - secondChannelImg = self.roiSecondChannel[frame_i] - else: - secondChannelImg = None - lab = self._segment_image(img, secondChannelImg) - segmData[frame_i] = lab - self.sigProgressBar.emit(1) - else: - img = self.imageData - secondChannelImg = self.roiSecondChannel - segmData = self._segment_image(img, secondChannelImg) - - self.sigLabellingDone.emit(segmData, self.isTimelapse) - self.started = False - self.pause() - self.finished.emit() - - -class StoreGuiStateWorker(QObject): - finished = Signal(object) - sigDone = Signal() - progress = Signal(str, object) - - def __init__(self, mutex, waitCond): - QObject.__init__(self) - self.mutex = mutex - self.waitCond = waitCond - self.exit = False - self.isFinished = False - self.q = queue.Queue() - self.logger = workerLogger(self.progress) - - def pause(self): - self.mutex.lock() - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def enqueue(self, posData, img1): - self.q.put((posData, img1)) - self.waitCond.wakeAll() - - def _stop(self): - self.exit = True - self.waitCond.wakeAll() - - def run(self): - while True: - if self.exit: - self.logger.log("Closing store state worker...") - break - elif not self.q.empty(): - posData, img1 = self.q.get() - # self.logger.log('Storing state...') - if posData.cca_df is not None: - cca_df = posData.cca_df.copy() - else: - cca_df = None - - state = { - "image": img1.copy(), - "labels": posData.storedLab.copy(), - "editID_info": posData.editID_info.copy(), - "binnedIDs": posData.binnedIDs.copy(), - "ripIDs": posData.ripIDs.copy(), - "cca_df": cca_df, - } - posData.UndoRedoStates[posData.frame_i].insert(0, state) - if self.q.empty(): - # self.logger.log('State stored...') - self.sigDone.emit() - else: - self.pause() - - self.isFinished = True - self.finished.emit(self) - - -class AutoSaveWorker(QObject): - finished = Signal(object) - sigDone = Signal() - critical = Signal(object) - progress = Signal(str, object) - sigStartTimer = Signal(object, object) - sigStopTimer = Signal() - sigAutoSaveCannotProceed = Signal() - - def __init__(self, mutex, waitCond, savedSegmData): - QObject.__init__(self) - self.savedSegmData = savedSegmData - self.logger = workerLogger(self.progress) - self.mutex = mutex - self.waitCond = waitCond - self.exit = False - self.isFinished = False - self.stopSaving = False - self.isSaving = False - self.isPaused = False - self.dataQ = deque(maxlen=5) - self.isAutoSaveON = False - self.isAutoSaveAnnotON = True - self.debug = False - - def pause(self): - if self.debug: - self.logger.log("Autosaving is idle.") - self.mutex.lock() - self.isPaused = True - self.waitCond.wait(self.mutex) - self.mutex.unlock() - self.isPaused = False - - def enqueue(self, posData): - # First stop previously saving data - if self.isSaving: - self.stopSaving = True - self._enqueue(posData) - - def _enqueue(self, posData): - if self.debug: - self.logger.log("Enqueing posData autosave...") - self.dataQ.append(posData) - if len(self.dataQ) == 1: - # Wake up worker upon inserting first element - self.stopSaving = False - self.waitCond.wakeAll() - - def _stop(self): - self.exit = True - self.waitCond.wakeAll() - - def stop(self): - self.stopSaving = True - while not len(self.dataQ) == 0: - data = self.dataQ.pop() - del data - self._stop() - - def cancelSaving(self): ... - - @worker_exception_handler - def run(self): - while True: - if self.exit: - self.logger.log("Closing autosaving worker...") - break - elif not len(self.dataQ) == 0: - if self.debug: - self.logger.log("Autosaving...") - data = self.dataQ.pop() - self.isSaving = True - try: - self.saveData(data) - except Exception as e: - error = traceback.format_exc() - print("*" * 40) - self.logger.log(error) - print("=" * 40) - self.isSaving = False - - if len(self.dataQ) == 0: - self.sigDone.emit() - else: - self.pause() - self.isFinished = True - self.finished.emit(self) - if self.debug: - self.logger.log("Autosave finished signal emitted") - - def getLastTrackedFrame(self, posData): - last_tracked_i = 0 - for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict["labels"] - if lab is None: - frame_i -= 1 - break - if frame_i > 0: - return frame_i - else: - return last_tracked_i - - def saveData(self, posData): - if self.debug: - self.logger.log("Started autosaving...") - - if not self.isAutoSaveON and not self.isAutoSaveAnnotON: - return - - try: - posData.setTempPaths() - except Exception as e: - self.logger.log( - "[WARNING]: Cell-ACDC cannot create the recovery folder for " - "the autosaving process. Autosaving will be turned off." - ) - self.sigAutoSaveCannotProceed.emit() - return - segm_npz_path = posData.segm_npz_temp_path - - end_i = self.getLastTrackedFrame(posData) - - saved_segm_data = None - if self.isAutoSaveON: - if end_i < len(posData.segm_data): - saved_segm_data = posData.segm_data - else: - frame_shape = posData.segm_data.shape[1:] - segm_shape = (end_i + 1, *frame_shape) - saved_segm_data = np.zeros(segm_shape, dtype=np.uint32) - - keys = [] - acdc_df_li = [] - - for frame_i, data_dict in enumerate(posData.allData_li[: end_i + 1]): - if self.stopSaving: - break - - # Build saved_segm_data - lab = data_dict["labels"] - if lab is None: - break - - if self.isAutoSaveON and saved_segm_data is not None: - if posData.SizeT > 1: - saved_segm_data[frame_i] = lab - else: - saved_segm_data = lab - - if self.isAutoSaveAnnotON: - acdc_df = data_dict["acdc_df"] - - if acdc_df is None: - continue - - if not np.any(lab): - continue - - if self.isAutoSaveAnnotON: - acdc_df = load.pd_bool_and_float_to_int_to_str( - acdc_df, inplace=False, colsToCastInt=[] - ) - - acdc_df_li.append(acdc_df) - key = (frame_i, posData.TimeIncrement * frame_i) - keys.append(key) - - if self.stopSaving: - break - - if not self.stopSaving: - if self.isAutoSaveON: - segm_data = np.squeeze(saved_segm_data) - self._saveSegm(segm_npz_path, segm_data) - - if acdc_df_li: - all_frames_acdc_df = pd.concat( - acdc_df_li, keys=keys, names=["frame_i", "time_seconds", "Cell_ID"] - ) - self._save_acdc_df(all_frames_acdc_df, posData) - - if self.debug: - self.logger.log(f"Autosaving done.") - self.logger.log(f"Stopped autosaving {self.stopSaving}.") - - self.stopSaving = False - - def _saveSegm(self, recovery_path, data): - try: - equalToSavedSegm = np.all(self.savedSegmData == data) - except Exception as err: - return - - if equalToSavedSegm: - return - else: - io.savez_compressed(recovery_path, np.squeeze(data)) - - def _save_acdc_df(self, recovery_acdc_df: pd.DataFrame, posData): - recovery_folderpath = posData.recoveryFolderpath() - if not os.path.exists(posData.acdc_output_csv_path): - load.store_unsaved_acdc_df(recovery_folderpath, recovery_acdc_df) - return - - saved_acdc_df_path = posData.acdc_output_csv_path - saved_acdc_df = pd.read_csv( - saved_acdc_df_path, dtype=load.acdc_df_str_cols - ).set_index(["frame_i", "Cell_ID"]) - - recovery_acdc_df = recovery_acdc_df.reset_index( - allow_duplicates=True - ).set_index(["frame_i", "Cell_ID"]) - recovery_acdc_df = recovery_acdc_df.loc[ - :, ~recovery_acdc_df.columns.duplicated() - ] - try: - # Try to insert into the recovery_acdc_df any column that was saved - # but is not in the recovered df (e.g., metrics) - df_left = recovery_acdc_df - existing_cols = df_left.columns.intersection(saved_acdc_df.columns) - df_right = saved_acdc_df.drop(columns=existing_cols) - recovery_acdc_df = df_left.join(df_right, how="left") - except Exception as error: - self.logger.log(f"[WARNING]: {error}") - - # Check if last saved acdc_df is equal - last_unsaved_csv_path = load.get_last_stored_unsaved_acdc_df_filepath( - recovery_folderpath - ) - if last_unsaved_csv_path is None: - reference_acdc_df = saved_acdc_df - else: - try: - reference_acdc_df = pd.read_csv( - last_unsaved_csv_path, dtype=load.acdc_df_str_cols - ).set_index(["frame_i", "Cell_ID"]) - except Exception as e: - self.logger.log(f"[WARNING]: {e}") - reference_acdc_df = saved_acdc_df - - if myutils.are_acdc_dfs_equal(recovery_acdc_df, reference_acdc_df): - return - - load.store_unsaved_acdc_df(recovery_folderpath, recovery_acdc_df) - - -class segmWorker(QObject): - finished = Signal(np.ndarray, float) - debug = Signal(object) - critical = Signal(object) - - def __init__( - self, - mainWin, - secondChannelData=None, - mutex: QWaitCondition = None, - waitCond: QMutex = None, - ): - QObject.__init__(self) - self.mainWin = mainWin - self.logger = self.mainWin.logger - self.z_range = None - self.secondChannelData = secondChannelData - self.mutex = mutex - self.waitCond = waitCond - - def emitDebug(self, to_debug): - if self.mutex is None: - return - - self.mutex.lock() - self.debug.emit(to_debug) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - @worker_exception_handler - def run(self): - from cellacdc.workflow.adapters import ( - interactive_segm_context_from_main_win, - runnable_config_from_main_win, - ) - from cellacdc.workflow.pipelines.interactive_segm import ( - build_interactive_segm_graph, - ) - from cellacdc.workflow.state import InteractiveSegmState - - t0 = time.perf_counter() - ctx = interactive_segm_context_from_main_win( - self.mainWin, - second_channel_data=self.secondChannelData, - z_range=self.z_range, - ) - graph = build_interactive_segm_graph(ctx).compile() - state = graph.invoke( - InteractiveSegmState(main_win=self.mainWin), - runnable_config_from_main_win(self.mainWin), - ) - t1 = time.perf_counter() - self.finished.emit(state.lab, t1 - t0) - - -class segmVideoWorker(QObject): - finished = Signal(float) - debug = Signal(object) - critical = Signal(object) - progressBar = Signal(int) - progress = Signal(str, object) - - def __init__(self, posData, paramWin, model, startFrameNum, stopFrameNum): - QObject.__init__(self) - self.standardPostProcessKwargs = paramWin.standardPostProcessKwargs - self.applyPostProcessing = paramWin.applyPostProcessing - self.customPostProcessFeatures = paramWin.customPostProcessFeatures - self.customPostProcessGroupedFeatures = ( - paramWin.customPostProcessGroupedFeatures - ) - self.model_kwargs = paramWin.model_kwargs - self.preproc_recipe = paramWin.preproc_recipe - self.secondChannelName = paramWin.secondChannelName - self.model = model - self.posData = posData - self.startFrameNum = startFrameNum - self.stopFrameNum = stopFrameNum - self.logger = workerLogger(self.progress) - - @worker_exception_handler - def run(self): - from cellacdc.workflow.adapters import interactive_video_segm_context_from_worker - from cellacdc.workflow.pipelines.interactive_video_segm import ( - build_interactive_video_segm_graph, - ) - from cellacdc.workflow.state import InteractiveVideoSegmState - - t0 = time.perf_counter() - ctx = interactive_video_segm_context_from_worker(self) - graph = build_interactive_video_segm_graph(ctx).compile() - graph.invoke( - InteractiveVideoSegmState(pos_data=self.posData), - ) - t1 = time.perf_counter() - self.finished.emit(t1 - t0) - - -class ComputeMetricsWorker(QObject): - progressBar = Signal(int, int, float) - - def __init__(self, mainWin): - QObject.__init__(self) - self.signals = signals() - self.abort = False - self.setup_done = False - self.logger = workerLogger(self.signals.progress) - self.mutex = QMutex() - self.waitCond = QWaitCondition() - self.mainWin = mainWin - - def emitSelectSegmFiles(self, exp_path, pos_foldernames): - self.mutex.lock() - self.signals.sigSelectSegmFiles.emit(exp_path, pos_foldernames) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - return True - else: - return False - - @worker_exception_handler - def run(self): - np.seterr(invalid="ignore") - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.standardMetricsErrors = {} - self.customMetricsErrors = {} - self.regionPropsErrors = {} - tot_pos = len(pos_foldernames) - self.allPosDataInputs = [] - posDatas = [] - self.logger.log("-" * 30) - expFoldername = os.path.basename(exp_path) - - if i == 0: - abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) - if abort: - self.signals.finished.emit(self) - return - - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.signals.finished.emit(self) - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames( - images_path, useExt=(".tif", ".h5") - ) - - self.signals.sigUpdatePbarDesc.emit(f"Loading {pos_path}...") - - # Use first found channel, it doesn't matter for metrics - chName = chNames[0] - file_path = myutils.getChannelFilePath(images_path, chName) - - # Load data - posData = load.loadData(file_path, chName) - posData.getBasenameAndChNames(useExt=(".tif", ".h5")) - posData.buildPaths() - - posData.loadOtherFiles( - load_segm_data=False, - load_acdc_df=True, - load_metadata=True, - loadSegmInfo=True, - load_customCombineMetrics=True, - ) - - posDatas.append(posData) - - self.allPosDataInputs.append( - { - "file_path": file_path, - "chName": chName, - "combineMetricsConfig": posData.combineMetricsConfig, - "combineMetricsPath": posData.custom_combine_metrics_path, - } - ) - - if any([posData.SizeT > 1 for posData in posDatas]): - self.mutex.lock() - self.signals.sigAskStopFrame.emit(posDatas) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - self.signals.finished.emit(self) - return - for p, posData in enumerate(posDatas): - self.allPosDataInputs[p]["stopFrameNum"] = posData.stopFrameNum - else: - for p, posData in enumerate(posDatas): - self.allPosDataInputs[p]["stopFrameNum"] = 1 - - self.kernel = cli.ComputeMeasurementsKernel( - self.logger, - self.mainWin.log_path, - False, - ) - - from cellacdc.workflow.pipelines.batch import run_gui_measurements_batch - from cellacdc.workflow.runnable import RunnableConfig - - run_gui_measurements_batch( - kernel=self.kernel, - paths=[inp["file_path"] for inp in self.allPosDataInputs], - stop_frame_numbers=[ - inp["stopFrameNum"] for inp in self.allPosDataInputs - ], - end_filename_segm=self.mainWin.endFilenameSegm, - compute_metrics_worker=self, - config=RunnableConfig(logger_func=self.logger.log), - ) - - if self.kernel.setup_done or self.abort: - return - - self.logger.log("*" * 30) - - self.mutex.lock() - self.signals.sigErrorsReport.emit( - self.standardMetricsErrors, - self.customMetricsErrors, - self.regionPropsErrors, - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - self.signals.finished.emit(self) - - def emitSigComputeVolume(self, posData, stop_frame_n): - # Recreate allData_li attribute of the gui - posData.allData_li = [] - for frame_i, lab in enumerate(posData.segm_data[:stop_frame_n]): - data_dict = {"labels": lab, "regionprops": skimage.measure.regionprops(lab)} - posData.allData_li.append(data_dict) - self.mutex.lock() - self.signals.sigComputeVolume.emit(stop_frame_n, posData) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def emitSigPermissionErrorAndSave( - self, posData, traceback_str, all_frames_acdc_df, custom_annot_columns - ): - self.mutex.lock() - self.signals.sigPermissionError.emit( - traceback_str, posData.acdc_output_csv_path - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - load.save_acdc_df_file( - all_frames_acdc_df, - posData.acdc_output_csv_path, - custom_annot_columns=custom_annot_columns, - ) - - def emitSigInitMetricsDialog(self, posData): - self.mainWin.gui.data = [posData] - self.mainWin.gui.pos_i = 0 - self.mainWin.gui.isSegm3D = posData.getIsSegm3D() - self.mutex.lock() - self.signals.sigInitAddMetrics.emit(posData, self.allPosDataInputs) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def emitSigAskRunNow(self): - self.mutex.lock() - self.signals.sigAskRunNow.emit(self) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - -class loadDataWorker(QObject): - def __init__(self, mainWin, user_ch_file_paths, user_ch_name, firstPosData): - QObject.__init__(self) - self.signals = signals() - self.mainWin = mainWin - self.user_ch_file_paths = user_ch_file_paths - self.user_ch_name = user_ch_name - self.logger = workerLogger(self.signals.progress) - self.mutex = self.mainWin.loadDataMutex - self.waitCond = self.mainWin.loadDataWaitCond - self.firstPosData = firstPosData - self.abort = False - self.loadUnsaved = False - self.recoveryAsked = False - self.loadSafeOverwriteNpz = False - - def pause(self): - self.mutex.lock() - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def checkSelectedDataShape(self, posData, numPos): - skipPos = False - abort = False - emitWarning = ( - not posData.segmFound and posData.SizeT > 1 and not self.mainWin.isNewFile - ) - if emitWarning: - self.signals.dataIntegrityWarning.emit(posData.pos_foldername) - self.pause() - abort = self.abort - return skipPos, abort - - def warnMismatchSegmDataShape(self, posData): - self.skipPos = False - self.mutex.lock() - self.signals.sigWarnMismatchSegmDataShape.emit(posData) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.skipPos - - @worker_exception_handler - def run(self): - data = [] - user_ch_file_paths = self.user_ch_file_paths - numPos = len(self.user_ch_file_paths) - user_ch_name = self.user_ch_name - self.signals.initProgressBar.emit(len(user_ch_file_paths)) - for i, file_path in enumerate(user_ch_file_paths): - if i == 0: - posData = self.firstPosData - segmFound = self.firstPosData.segmFound - loadSegm = False - else: - posData = load.loadData(file_path, user_ch_name) - loadSegm = True - - self.logger.log(f"Loading {posData.relPath}...") - - posData.loadSizeS = self.mainWin.loadSizeS - posData.loadSizeT = self.mainWin.loadSizeT - posData.loadSizeZ = self.mainWin.loadSizeZ - posData.SizeT = self.mainWin.SizeT - posData.SizeZ = self.mainWin.SizeZ - posData.isSegm3D = self.mainWin.isSegm3D - - if i > 0: - # First pos was already loaded in the main thread - # see loadSelectedData function in gui.py - posData.getBasenameAndChNames() - posData.buildPaths() - if not self.firstPosData.onlyEditMetadata: - posData.loadImgData() - - if self.firstPosData.onlyEditMetadata: - loadSegm = False - - posData.loadOtherFiles( - load_segm_data=loadSegm, - load_acdc_df=True, - load_shifts=True, - loadSegmInfo=True, - load_delROIsInfo=True, - load_bkgr_data=True, - loadBkgrROIs=True, - load_dataPrep_ROIcoords=True, - load_last_tracked_i=True, - load_metadata=True, - load_customAnnot=True, - load_customCombineMetrics=True, - end_filename_segm=self.mainWin.selectedSegmEndName, - create_new_segm=self.mainWin.isNewFile, - new_endname=self.mainWin.newSegmEndName, - labelBoolSegm=self.mainWin.labelBoolSegm, - ) - posData.labelSegmData() - - if i == 0: - posData.segmFound = segmFound - - posData.addYXcentroidColsIfMissing(show_progress=True) - - isPosSegm3D = posData.getIsSegm3D() - isMismatch = ( - isPosSegm3D != self.mainWin.isSegm3D - and isPosSegm3D is not None - and not self.mainWin.isNewFile - ) - if isMismatch: - skipPos = self.warnMismatchSegmDataShape(posData) - if skipPos: - self.logger.log( - f'Skipping "{posData.relPath}" because segmentation ' - "data shape different from first Position loaded." - ) - continue - else: - data = "abort" - break - - self.logger.log( - "Loaded paths:\n" - f"Segmentation file name: {os.path.basename(posData.segm_npz_path)}\n" - f"ACDC output file name {os.path.basename(posData.acdc_output_csv_path)}" - ) - - posData.SizeT = self.mainWin.SizeT - if self.mainWin.SizeZ > 1: - SizeZ = posData.img_data_shape[-3] - posData.SizeZ = SizeZ - else: - posData.SizeZ = 1 - posData.TimeIncrement = self.mainWin.TimeIncrement - posData.PhysicalSizeZ = self.mainWin.PhysicalSizeZ - posData.PhysicalSizeY = self.mainWin.PhysicalSizeY - posData.PhysicalSizeX = self.mainWin.PhysicalSizeX - posData.isSegm3D = self.mainWin.isSegm3D - posData.saveMetadata( - signals=self.signals, - mutex=self.mutex, - waitCond=self.waitCond, - additionalMetadata=self.firstPosData._additionalMetadataValues, - ) - if hasattr(posData, "img_data_shape"): - SizeY, SizeX = posData.img_data_shape[-2:] - - if posData.SizeZ > 1 and posData.img_data.ndim < 3: - posData.SizeZ = 1 - posData.segmInfo_df = None - try: - os.remove(posData.segmInfo_df_csv_path) - except FileNotFoundError: - pass - - posData.setBlankSegmData(posData.SizeT, posData.SizeZ, SizeY, SizeX) - if not self.firstPosData.onlyEditMetadata: - skipPos, abort = self.checkSelectedDataShape(posData, numPos) - else: - skipPos, abort = False, False - - if skipPos: - continue - elif abort: - data = "abort" - break - - posData.setTempPaths(createFolder=False) - isRecoveredDataPresent = ( - os.path.exists(posData.segm_npz_temp_path) - or posData.isRecoveredAcdcDfPresent() - or posData.isSafeNpzOverwritePresent() - ) - if isRecoveredDataPresent and not self.mainWin.newSegmEndName: - if not self.recoveryAsked: - self.mutex.lock() - self.signals.sigRecovery.emit(posData) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - self.recoveryAsked = True - if self.abort: - data = "abort" - break - if self.loadUnsaved: - self.logger.log("Loading unsaved data...") - if os.path.exists(posData.segm_npz_temp_path): - segm_npz_path = posData.segm_npz_temp_path - posData.segm_data = np.load(segm_npz_path)["arr_0"] - segm_filename = os.path.basename(segm_npz_path) - posData.segm_npz_path = os.path.join( - posData.images_path, segm_filename - ) - - posData.loadMostRecentUnsavedAcdcDf() - elif self.loadSafeOverwriteNpz: - self.logger.log("Loading safe npz overwrite...") - segm_safe_npz_path = posData.getSafeNpzOverwritePath() - posData.segm_data = np.load(segm_safe_npz_path)["arr_0"] - - # Allow single 2D/3D image - if posData.SizeT == 1: - posData.img_data = posData.img_data[np.newaxis] - posData.segm_data = posData.segm_data[np.newaxis] - if hasattr(posData, "img_data_shape"): - img_shape = posData.img_data_shape - img_shape = "Not Loaded" - if hasattr(posData, "img_data_shape"): - datasetShape = posData.img_data.shape - else: - datasetShape = "Not Loaded" - if posData.segm_data is not None: - posData.segmSizeT = len(posData.segm_data) - SizeT = posData.SizeT - SizeZ = posData.SizeZ - self.logger.log(f"Full dataset shape = {img_shape}") - self.logger.log(f"Loaded dataset shape = {datasetShape}") - self.logger.log(f"Number of frames = {SizeT}") - self.logger.log(f"Number of z-slices per frame = {SizeZ}") - data.append(posData) - self.signals.progressBar.emit(1) - - if not data: - data = None - self.signals.dataIntegrityCritical.emit() - - self.signals.finished.emit(data) - - -class trackingWorker(QObject): - finished = Signal() - critical = Signal(object) - progress = Signal(str) - debug = Signal(object) - - def __init__(self, posData, mainWin, video_to_track): - QObject.__init__(self) - self.mainWin = mainWin - self.posData = posData - self.mutex = QMutex() - self.signals = signals() - self.waitCond = QWaitCondition() - self.tracker = self.mainWin.tracker - self.track_params = self.mainWin.track_params - self.video_to_track = video_to_track - - def _get_first_untracked_lab(self): - start_frame_i = self.mainWin.start_n - 1 - frameData = self.posData.allData_li[start_frame_i] - lab = frameData["labels"] - if lab is not None: - return lab - else: - return self.posData.segm_data[start_frame_i] - - def _relabel_first_frame_labels(self, tracked_video): - first_untracked_lab = self._get_first_untracked_lab() - self.mainWin.setAllIDs() - max_allIDs = max(self.posData.allIDs, default=0) - max_tracked_video = tracked_video.max() - overall_max = max(max_allIDs, max_tracked_video) - uniqueID = overall_max + 1 - - tracked_video = transformation.retrack_based_on_untracked_first_frame( - tracked_video, first_untracked_lab, uniqueID=uniqueID - ) - return tracked_video - - def _setProgressBarIndefiniteWait(self): - try: - if hasattr(self.signals, "innerPbar_available"): - if self.signals.innerPbar_available: - # Use inner pbar of the GUI widget (top pbar is for positions) - self.signals.sigInitInnerPbar.emit(1) - return - else: - self.signals.initProgressBar.emit(1) - except Exception as err: - pass - - @worker_exception_handler - def run(self): - self.mutex.lock() - self.progress.emit("Tracking process started (more details in the terminal)...") - - trackerInputImage = None - self.track_params["signals"] = self.signals - if "image" in self.track_params: - trackerInputImage = self.track_params.pop("image") - start_frame_i = self.mainWin.start_n - 1 - stop_frame_n = self.mainWin.stop_n - - trackerInputImage = trackerInputImage[start_frame_i:stop_frame_n] - - tracked_video = core.tracker_track( - self.video_to_track, - self.tracker, - self.track_params, - intensity_img=trackerInputImage, - logger_func=self.progress.emit, - ) - - self._setProgressBarIndefiniteWait() - - # self.debug.emit((tracked_video, self)) - # self.waitCond.wait(self.mutex) - - self.progress.emit("Re-tracking first frame to ensure continuity...") - # Relabel first frame objects back to IDs they had before tracking - # (to ensure continuity with past untracked frames) - tracked_video = self._relabel_first_frame_labels(tracked_video) - - print("") - self.progress.emit("Generating annotations...") - acdc_df = self.posData.fromTrackerToAcdcDf( - self.tracker, tracked_video, start_frame_i=self.mainWin.start_n - 1 - ) - # Store new tracked video - current_frame_i = self.posData.frame_i - self.trackingOnNeverVisitedFrames = False - print("") - self.progress.emit("Storing tracked video...") - pbar = tqdm(total=len(tracked_video), ncols=100) - for rel_frame_i, lab in enumerate(tracked_video): - frame_i = rel_frame_i + self.mainWin.start_n - 1 - - if acdc_df is not None: - cca_cols = acdc_df.columns.intersection(cca_df_colnames_with_tree) - # Store cca_df if it is an output of the tracker - cca_df = acdc_df.loc[frame_i][cca_cols] - self.mainWin.store_cca_df( - frame_i=frame_i, cca_df=cca_df, mainThread=False, autosave=False - ) - - if self.posData.allData_li[frame_i]["labels"] is None: - # repeating tracking on a never visited frame - # --> modify only raw data and ask later what to do - self.posData.segm_data[frame_i] = lab - self.trackingOnNeverVisitedFrames = True - else: - # Get the rest of the stored metadata based on the new lab - self.posData.allData_li[frame_i]["labels"] = lab - self.posData.frame_i = frame_i - self.mainWin.get_data() - self.mainWin.store_data(autosave=False) - - pbar.update() - pbar.close() - - # Back to current frame - self.posData.frame_i = current_frame_i - self.mainWin.get_data() - self.mainWin.store_data(autosave=True) - self.mutex.unlock() - self.finished.emit() - - -class reapplyDataPrepWorker(QObject): - finished = Signal() - debug = Signal(object) - critical = Signal(object) - progress = Signal(str) - initPbar = Signal(int) - updatePbar = Signal() - sigCriticalNoChannels = Signal(str) - sigSelectChannels = Signal(object, object, object, str) - - def __init__(self, expPath, posFoldernames): - super().__init__() - self.expPath = expPath - self.posFoldernames = posFoldernames - self.abort = False - self.mutex = QMutex() - self.waitCond = QWaitCondition() - - def raiseSegmInfoNotFound(self, path): - raise FileNotFoundError( - "The following file is required for the alignment of 4D data " - f'but it was not found: "{path}"' - ) - - def saveBkgrData(self, imageData, posData, isAligned=False): - bkgrROI_data = {} - for r, roi in enumerate(posData.bkgrROIs): - xl, yt = [int(round(c)) for c in roi.pos()] - w, h = [int(round(c)) for c in roi.size()] - if not yt + h > yt or not xl + w > xl: - # Prevent 0 height or 0 width roi - continue - is4D = posData.SizeT > 1 and posData.SizeZ > 1 - is3Dz = posData.SizeT == 1 and posData.SizeZ > 1 - is3Dt = posData.SizeT > 1 and posData.SizeZ == 1 - is2D = posData.SizeT == 1 and posData.SizeZ == 1 - if is4D: - bkgr_data = imageData[:, :, yt : yt + h, xl : xl + w] - elif is3Dz or is3Dt: - bkgr_data = imageData[:, yt : yt + h, xl : xl + w] - elif is2D: - bkgr_data = imageData[yt : yt + h, xl : xl + w] - bkgrROI_data[f"roi{r}_data"] = bkgr_data - - if not bkgrROI_data: - return - - if isAligned: - bkgr_data_fn = f"{posData.filename}_aligned_bkgrRoiData.npz" - else: - bkgr_data_fn = f"{posData.filename}_bkgrRoiData.npz" - bkgr_data_path = os.path.join(posData.images_path, bkgr_data_fn) - self.progress.emit("Saving background data to:") - self.progress.emit(bkgr_data_path) - io.savez_compressed(bkgr_data_path, **bkgrROI_data) - - def run(self): - ch_name_selector = prompts.select_channel_name( - which_channel="segm", allow_abort=False - ) - for p, pos in enumerate(self.posFoldernames): - if self.abort: - break - - self.progress.emit(f"Processing {pos}...") - - posPath = os.path.join(self.expPath, pos) - imagesPath = os.path.join(posPath, "Images") - - ls = myutils.listdir(imagesPath) - if p == 0: - ch_names, basenameNotFound = ch_name_selector.get_available_channels( - ls, imagesPath - ) - if not ch_names: - self.sigCriticalNoChannels.emit(imagesPath) - break - self.mutex.lock() - if len(self.posFoldernames) == 1: - # User selected only one pos --> allow selecting and adding - # and external .tif file that will be renamed with the basename - basename = ch_name_selector.basename - else: - basename = None - self.sigSelectChannels.emit( - ch_name_selector, ch_names, imagesPath, basename - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - break - - self.progress.emit(f"Selected channels: {self.selectedChannels}") - - for chName in self.selectedChannels: - filePath = load.get_filename_from_channel(imagesPath, chName) - posData = load.loadData(filePath, chName) - posData.getBasenameAndChNames() - posData.buildPaths() - posData.loadImgData() - posData.loadOtherFiles( - load_segm_data=False, - getTifPath=True, - load_metadata=True, - load_shifts=True, - load_dataPrep_ROIcoords=True, - loadBkgrROIs=True, - ) - - imageData = posData.img_data - - prepped = False - isAligned = False - # Align - if posData.loaded_shifts is not None: - self.progress.emit("Aligning frames...") - shifts = posData.loaded_shifts - if imageData.ndim == 4: - align_func = core.align_frames_3D - else: - align_func = core.align_frames_2D - imageData, _ = align_func(imageData, user_shifts=shifts) - prepped = True - isAligned = True - - # Crop and save background - if posData.dataPrep_ROIcoords is not None: - df = posData.dataPrep_ROIcoords - isCropped = int(df.at["cropped", "value"]) == 1 - if isCropped: - self.saveBkgrData(imageData, posData, isAligned) - self.progress.emit("Cropping...") - x0 = int(df.at["x_left", "value"]) - y0 = int(df.at["y_top", "value"]) - x1 = int(df.at["x_right", "value"]) - y1 = int(df.at["y_bottom", "value"]) - if imageData.ndim == 4: - imageData = imageData[:, :, y0:y1, x0:x1] - elif imageData.ndim == 3: - imageData = imageData[:, y0:y1, x0:x1] - elif imageData.ndim == 2: - imageData = imageData[y0:y1, x0:x1] - prepped = True - else: - filename = os.path.basename(posData.dataPrepBkgrROis_path) - self.progress.emit( - f'WARNING: the file "{filename}" was not found. ' - "I cannot crop the data." - ) - - if prepped: - self.progress.emit("Saving prepped data...") - io.savez_compressed(posData.align_npz_path, imageData) - if hasattr(posData, "tif_path"): - myutils.to_tiff(posData.tif_path, imageData) - - self.updatePbar.emit() - if self.abort: - break - self.finished.emit() - - -class LazyLoader(QObject): - sigLoadingFinished = Signal() - - def __init__(self, mutex, waitCond, readH5mutex, waitReadH5cond): - QObject.__init__(self) - self.signals = signals() - self.mutex = mutex - self.waitCond = waitCond - self.exit = False - self.salute = True - self.sender = None - self.H5readWait = False - self.waitReadH5cond = waitReadH5cond - self.readH5mutex = readH5mutex - - def setArgs(self, posData, current_idx, axis, updateImgOnFinished): - self.wait = False - self.updateImgOnFinished = updateImgOnFinished - self.posData = posData - self.current_idx = current_idx - self.axis = axis - - def pauseH5read(self): - self.readH5mutex.lock() - self.waitReadH5cond.wait(self.mutex) - self.readH5mutex.unlock() - - def pause(self): - self.mutex.lock() - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - @worker_exception_handler - def run(self): - while True: - if self.exit: - self.signals.progress.emit("Closing lazy loader...", "INFO") - break - elif self.wait: - self.signals.progress.emit("Lazy loader paused.", "INFO") - self.pause() - else: - self.signals.progress.emit("Lazy loader resumed.", "INFO") - self.posData.loadChannelDataChunk( - self.current_idx, axis=self.axis, worker=self - ) - self.sigLoadingFinished.emit() - self.wait = True - - self.signals.finished.emit(None) - - -class ImagesToPositionsWorker(QObject): - finished = Signal() - debug = Signal(object) - critical = Signal(object) - progress = Signal(str) - initPbar = Signal(int) - updatePbar = Signal() - - def __init__(self, folderPath, targetFolderPath, appendText): - super().__init__() - self.abort = False - self.folderPath = folderPath - self.targetFolderPath = targetFolderPath - self.appendText = appendText - - @worker_exception_handler - def run(self): - self.progress.emit(f'Selected folder: "{self.folderPath}"') - self.progress.emit(f'Target folder: "{self.targetFolderPath}"') - self.progress.emit(" ") - ls = myutils.listdir(self.folderPath) - numFiles = len(ls) - self.initPbar.emit(numFiles) - numPosDigits = len(str(numFiles)) - if numPosDigits == 1: - numPosDigits = 2 - pos = 1 - for file in ls: - if self.abort: - break - - filePath = os.path.join(self.folderPath, file) - if os.path.isdir(filePath): - # Skip directories - self.updatePbar.emit() - continue - - self.progress.emit(f"Loading file: {file}") - filename, ext = os.path.splitext(file) - s0p = str(pos).zfill(numPosDigits) - try: - data = load.imread(filePath) - if data.ndim == 3 and (data.shape[-1] == 3 or data.shape[-1] == 4): - self.progress.emit("Converting RGB image to grayscale...") - data = skimage.color.rgb2gray(data) - data = skimage.img_as_ubyte(data) - - posName = f"Position_{pos}" - posPath = os.path.join(self.targetFolderPath, posName) - imagesPath = os.path.join(posPath, "Images") - if not os.path.exists(imagesPath): - os.makedirs(imagesPath, exist_ok=True) - newFilename = f"s{s0p}_{filename}_{self.appendText}.tif" - relPath = os.path.join(posName, "Images", newFilename) - tifFilePath = os.path.join(imagesPath, newFilename) - self.progress.emit(f"Saving to file: ...{os.sep}{relPath}") - myutils.to_tiff(tifFilePath, data) - pos += 1 - except Exception as e: - self.progress.emit( - f"WARNING: {file} is not a valid image file. Skipping it." - ) - - self.progress.emit(" ") - self.updatePbar.emit() - - if self.abort: - break - self.finished.emit() - - -class BaseWorkerUtil(QObject): - progressBar = Signal(int, int, float) - - def __init__(self, mainWin): - QObject.__init__(self) - self.signals = signals() - self.abort = False - self.skipExp = False - self.logger = workerLogger(self.signals.progress) - self.mutex = QMutex() - self.waitCond = QWaitCondition() - self.mainWin = mainWin - - def emitSelectSegmFiles(self, exp_path, pos_foldernames): - self.mutex.lock() - self.signals.sigSelectSegmFiles.emit(exp_path, pos_foldernames) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def emitSelectFilesWithText(self, exp_path, pos_foldernames, with_text, ext=None): - self.mutex.lock() - self.signals.sigSelectFilesWithText.emit( - exp_path, pos_foldernames, with_text, ext - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def emitSelectFile(self, start_dir, caption="", filters="All files (*.)"): - self.mutex.lock() - self.signals.sigSelectFile.emit(start_dir, caption, filters) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def emitSelectAcdcOutputFiles( - self, - exp_path, - pos_foldernames, - infoText="", - allowSingleSelection=False, - multiSelection=True, - ): - self.mutex.lock() - self.signals.sigSelectAcdcOutputFiles.emit( - exp_path, pos_foldernames, infoText, allowSingleSelection, multiSelection - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def emitSelectSpotmaxRun( - self, - exp_path, - pos_foldernames, - all_runs, - infoText="", - allowSingleSelection=True, - multiSelection=True, - ): - self.mutex.lock() - self.signals.sigSelectSpotmaxRun.emit( - exp_path, - pos_foldernames, - all_runs, - infoText, - allowSingleSelection, - multiSelection, - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - -class DataPrepSaveBkgrDataWorker(QObject): - def __init__(self, posData, dataPrepWin): - QObject.__init__(self) - self.signals = signals() - self.logger = workerLogger(self.signals.progress) - self.posData = posData - self.dataPrepWin = dataPrepWin - - @worker_exception_handler - def run(self): - self.dataPrepWin.saveBkgrData(self.posData) - self.signals.finished.emit(self) - - -class DataPrepCropWorker(QObject): - def __init__(self, posData, dataPrepWin, dstPath): - QObject.__init__(self) - self.signals = signals() - self.logger = workerLogger(self.signals.progress) - self.posData = posData - self.dataPrepWin = dataPrepWin - self.dstPath = dstPath - - @worker_exception_handler - def run(self): - self.dataPrepWin.saveSingleCrop( - self.posData, self.posData.cropROIs[0], self.dstPath - ) - self.signals.finished.emit(self) - - -class TrackSubCellObjectsWorker(BaseWorkerUtil): - sigAskAppendName = Signal(str, list) - sigCriticalNotEnoughSegmFiles = Signal(str) - sigAborted = Signal() - - def __init__(self, mainWin): - super().__init__(mainWin) - if mainWin.trackingMode.find("Delete both") != -1: - self.trackingMode = "delete_both" - elif mainWin.trackingMode.find("Delete sub-cellular") != -1: - self.trackingMode = "delete_sub" - elif mainWin.trackingMode.find("Delete cells") != -1: - self.trackingMode = "delete_cells" - elif mainWin.trackingMode.find("Only track") != -1: - self.trackingMode = "only_track" - - self.relabelSubObjLab = mainWin.relabelSubObjLab - self.IoAthresh = mainWin.IoAthresh - self.createThirdSegm = mainWin.createThirdSegm - self.thirdSegmAppendedText = mainWin.thirdSegmAppendedText - - @worker_exception_handler - def run(self): - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - red_text = html_utils.span("OF THE CELLs") - self.mainWin.infoText = f"Select segmentation file {red_text}" - abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) - if abort: - self.sigAborted.emit() - return - - # Critical --> there are not enough segm files - if len(self.mainWin.existingSegmEndNames) < 2: - self.mutex.lock() - self.sigCriticalNotEnoughSegmFiles.emit(exp_path) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - self.sigAborted.emit() - return - - self.cellsSegmEndFilename = self.mainWin.endFilenameSegm - - red_text = html_utils.span("OF THE SUB-CELLULAR OBJECTS") - self.mainWin.infoText = f"Select segmentation file {red_text}" - abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) - if abort: - self.sigAborted.emit() - return - - # Ask appendend name - self.mutex.lock() - self.sigAskAppendName.emit( - self.mainWin.endFilenameSegm, self.mainWin.existingSegmEndNames - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - self.sigAborted.emit() - return - - appendedName = self.appendedName - self.signals.initProgressBar.emit(len(pos_foldernames)) - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.sigAborted.emit() - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - images_path = os.path.join(exp_path, pos, "Images") - endFilenameSegm = self.mainWin.endFilenameSegm - ls = myutils.listdir(images_path) - file_path = [ - os.path.join(images_path, f) - for f in ls - if f.endswith(f"{endFilenameSegm}.npz") - ][0] - - posData = load.loadData(file_path, "") - - self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") - - posData.getBasenameAndChNames() - posData.buildPaths() - - posData.loadOtherFiles( - load_segm_data=True, - load_acdc_df=True, - load_metadata=True, - end_filename_segm=endFilenameSegm, - ) - - # Load cells segmentation file - segmDataCells, segmCellsPath = load.load_segm_file( - images_path, - end_name_segm_file=self.cellsSegmEndFilename, - return_path=True, - ) - acdc_df_cells_endname = self.cellsSegmEndFilename.replace( - "_segm", "_acdc_output" - ) - acdc_df_cell, acdc_df_cells_path = load.load_acdc_df_file( - images_path, - end_name_acdc_df_file=acdc_df_cells_endname, - return_path=True, - ) - - if posData.SizeT > 1: - numFrames = min((len(segmDataCells), len(posData.segm_data))) - segmDataCells = segmDataCells[:numFrames] - posData.segm_data = posData.segm_data[:numFrames] - else: - numFrames = 1 - - self.signals.sigInitInnerPbar.emit(numFrames * 2) - - self.logger.log("Tracking sub-cellular objects...") - tracked = core.track_sub_cell_objects( - segmDataCells, - posData.segm_data, - self.IoAthresh, - how=self.trackingMode, - SizeT=numFrames, - sigProgress=self.signals.sigUpdateInnerPbar, - relabel_sub_obj_lab=self.relabelSubObjLab, - ) - ( - trackedSubSegmData, - trackedCellsSegmData, - numSubObjPerCell, - replacedSubIds, - ) = tracked - - self.logger.log("Saving tracked segmentation files...") - subSegmFilename, ext = os.path.splitext(posData.segm_npz_path) - trackedSubPath = f"{subSegmFilename}_{appendedName}.npz" - io.savez_compressed(trackedSubPath, trackedSubSegmData) - posData.saveIsSegm3Dmetadata(trackedSubPath) - - if trackedCellsSegmData is not None: - cellsSegmFilename, ext = os.path.splitext(segmCellsPath) - trackedCellsPath = f"{cellsSegmFilename}_{appendedName}.npz" - io.savez_compressed(trackedCellsPath, trackedCellsSegmData) - - if self.createThirdSegm: - self.logger.log( - f"Generating segmentation from " - f'"{self.cellsSegmEndFilename} - {appendedName}" ' - "difference..." - ) - if trackedCellsSegmData is not None: - parentSegmData = trackedCellsSegmData - else: - parentSegmData = segmDataCells - diffSegmData = parentSegmData.copy() - diffSegmData[trackedSubSegmData != 0] = 0 - - self.logger.log("Saving difference segmentation file...") - diffSegmPath = ( - f"{subSegmFilename}_{appendedName}" - f"_{self.thirdSegmAppendedText}.npz" - ) - io.savez_compressed(diffSegmPath, diffSegmData) - posData.saveIsSegm3Dmetadata(diffSegmPath) - del diffSegmData - - if self.relabelSubObjLab: - # When we relabel the sub-cell objs acdc_df is not valid anymore - # because IDs could be different - posData.acdc_df = None - - self.logger.log("Generating acdc_output tables...") - # Update or create acdc_df for sub-cellular objects - acdc_dfs_tracked = core.track_sub_cell_objects_acdc_df( - trackedSubSegmData, - posData.acdc_df, - replacedSubIds, - numSubObjPerCell, - tracked_cells_segm_data=trackedCellsSegmData, - cells_acdc_df=acdc_df_cell, - SizeT=posData.SizeT, - sigProgress=self.signals.sigUpdateInnerPbar, - ) - subTrackedAcdcDf, trackedAcdcDf = acdc_dfs_tracked - - self.logger.log("Saving acdc_output tables...") - subAcdcDfFilename, _ = os.path.splitext(posData.acdc_output_csv_path) - subTrackedAcdcDfPath = f"{subAcdcDfFilename}_{appendedName}.csv" - subTrackedAcdcDf.to_csv(subTrackedAcdcDfPath) - - if trackedAcdcDf is not None: - basen = posData.basename - cellsSegmFilename = os.path.basename(segmCellsPath) - cellsSegmFilename, ext = os.path.splitext(cellsSegmFilename) - cellsSegmEndname = cellsSegmFilename[len(basen) :] - trackedAcdcDfEndname = cellsSegmEndname.replace( - "segm", "acdc_output" - ) - trackedAcdcDfFilename = f"{basen}{trackedAcdcDfEndname}" - trackedAcdcDfFilename = ( - f"{trackedAcdcDfFilename}_{appendedName}.csv" - ) - trackedAcdcDfPath = os.path.join( - posData.images_path, trackedAcdcDfFilename - ) - trackedAcdcDf.to_csv(trackedAcdcDfPath) - - if self.createThirdSegm: - if posData.SizeT == 1: - parentSegmData = parentSegmData[np.newaxis] - subAcdcDfFilename = subSegmFilename.replace( - ".npz", ".csv" - ).replace("segm", "acdc_output") - diffAcdcDfPath = ( - f"{subAcdcDfFilename}_{appendedName}" - f"_{self.thirdSegmAppendedText}.csv" - ) - third_segm_acdc_df = ( - core.track_sub_cell_objects_third_segm_acdc_df( - parentSegmData, trackedAcdcDf - ) - ) - third_segm_acdc_df.to_csv(diffAcdcDfPath) - - self.signals.progressBar.emit(1) - - self.signals.finished.emit(self) - - -class PostProcessSegmWorker(QObject): - def __init__( - self, - postProcessKwargs, - customPostProcessGroupedFeatures, - customPostProcessFeatures, - mainWin, - ): - super().__init__() - self.signals = signals() - self.logger = workerLogger(self.signals.progress) - self.kwargs = postProcessKwargs - self.customPostProcessGroupedFeatures = customPostProcessGroupedFeatures - self.customPostProcessFeatures = customPostProcessFeatures - self.mainWin = mainWin - - @worker_exception_handler - def run(self): - mainWin = self.mainWin - data = mainWin.data - posData = data[mainWin.pos_i] - if len(data) > 1: - self.signals.initProgressBar.emit(len(data)) - else: - current_frame_i = posData.frame_i - self.signals.initProgressBar.emit(posData.SizeT - current_frame_i) - - self.logger.log("Post-process segmentation process started.") - self._run() - self.signals.finished.emit(None) - - def _run(self): - kwargs = self.kwargs - mainWin = self.mainWin - data = mainWin.data - - for posData in data: - current_frame_i = posData.frame_i - data_li = posData.allData_li[current_frame_i:] - for i, data_dict in enumerate(data_li): - frame_i = current_frame_i + i - visited = True - lab = data_dict["labels"] - if lab is None: - visited = False - try: - lab = posData.segm_data[frame_i] - except Exception as e: - return - - image = posData.img_data[frame_i] - - processed_lab = core.post_process_segm( - lab, return_delIDs=False, **kwargs - ) - if self.customPostProcessFeatures: - processed_lab = features.custom_post_process_segm( - posData, - self.customPostProcessGroupedFeatures, - processed_lab, - image, - posData.frame_i, - posData.filename, - posData.user_ch_name, - self.customPostProcessFeatures, - ) - if visited: - posData.allData_li[frame_i]["labels"] = processed_lab - # Get the rest of the stored metadata based on the new lab - posData.frame_i = frame_i - mainWin.get_data() - mainWin.store_data(autosave=False) - else: - posData.segm_data[frame_i] = lab - - self.signals.progressBar.emit(1) - - posData.frame_i = current_frame_i - - -class CreateConnected3Dsegm(BaseWorkerUtil): - sigAskAppendName = Signal(str, list) - sigAborted = Signal() - - def __init__(self, mainWin): - super().__init__(mainWin) - - def criticalSegmIsNot3D(self): - raise TypeError( - "Input segmentation masks are not 3D. You can use this utility " - "only on 3D z-stack data or 4D z-stack over time data." - ) - - @worker_exception_handler - def run(self): - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - self.mainWin.infoText = f"Select 3D segmentation file to connect" - abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) - if abort: - self.sigAborted.emit() - return - - # Ask appendend name - self.mutex.lock() - self.sigAskAppendName.emit( - self.mainWin.endFilenameSegm, self.mainWin.existingSegmEndNames - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - self.sigAborted.emit() - return - - appendedName = self.appendedName - self.signals.initProgressBar.emit(len(pos_foldernames)) - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.sigAborted.emit() - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - images_path = os.path.join(exp_path, pos, "Images") - endFilenameSegm = self.mainWin.endFilenameSegm - ls = myutils.listdir(images_path) - file_path = [ - os.path.join(images_path, f) - for f in ls - if f.endswith(f"{endFilenameSegm}.npz") - ][0] - - posData = load.loadData(file_path, "") - - self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") - - posData.getBasenameAndChNames() - posData.buildPaths() - - posData.loadOtherFiles( - load_segm_data=True, - load_acdc_df=True, - load_metadata=True, - end_filename_segm=endFilenameSegm, - ) - if posData.segm_data.ndim == 3: - posData.segm_data = posData.segm_data[np.newaxis] - - self.logger.log("Connecting 3D objects...") - - numFrames = len(posData.segm_data) - self.signals.sigInitInnerPbar.emit(numFrames) - connectedSegmData = np.zeros_like(posData.segm_data) - for frame_i, lab in enumerate(posData.segm_data): - if lab.ndim != 3: - self.criticalSegmIsNot3D() - - connected_lab = core.connect_3Dlab_zboundaries(lab) - connectedSegmData[frame_i] = connected_lab - - self.signals.sigUpdateInnerPbar.emit(1) - - self.logger.log("Saving connected 3D segmentation file...") - segmFilename, ext = os.path.splitext(posData.segm_npz_path) - newSegmFilepath = f"{segmFilename}_{appendedName}.npz" - connectedSegmData = np.squeeze(connectedSegmData) - io.savez_compressed(newSegmFilepath, connectedSegmData) - - self.signals.progressBar.emit(1) - - self.signals.finished.emit(self) - - -class ApplyTrackInfoWorker(BaseWorkerUtil): - def __init__( - self, - parentWin, - endFilenameSegm, - trackInfoCsvPath, - trackedSegmFilename, - trackColsInfo, - posPath, - ): - super().__init__(parentWin) - self.endFilenameSegm = endFilenameSegm - self.trackInfoCsvPath = trackInfoCsvPath - self.trackedSegmFilename = trackedSegmFilename - self.trackColsInfo = trackColsInfo - self.posPath = posPath - - @worker_exception_handler - def run(self): - self.logger.log("Loading segmentation file...") - self.signals.initProgressBar.emit(0) - imagesPath = os.path.join(self.posPath, "Images") - segmFilename = [ - f - for f in myutils.listdir(imagesPath) - if f.endswith(f"{self.endFilenameSegm}.npz") - ][0] - segmFilePath = os.path.join(imagesPath, segmFilename) - segmData = np.load(segmFilePath)["arr_0"] - - self.logger.log("Loading table containing tracking info...") - df = pd.read_csv(self.trackInfoCsvPath) - - frameIndexCol = self.trackColsInfo["frameIndexCol"] - - parentIDcol = self.trackColsInfo["parentIDcol"] - pbarMax = len(df[frameIndexCol].unique()) - self.signals.initProgressBar.emit(pbarMax) - - # Apply tracking info - result = core.apply_tracking_from_table( - segmData, - self.trackColsInfo, - df, - signal=self.signals.progressBar, - logger=self.logger.log, - pbarMax=pbarMax, - ) - trackedData, trackedIDsMapper, deleteIDsMapper = result - - if self.trackedSegmFilename: - trackedSegmFilepath = os.path.join(imagesPath, self.trackedSegmFilename) - else: - trackedSegmFilepath = os.path.join(segmFilePath) - - self.signals.initProgressBar.emit(0) - self.logger.log("Saving tracked segmentation file...") - io.savez_compressed(trackedSegmFilepath, trackedData) - - mapperPath = os.path.splitext(trackedSegmFilepath)[0] - mapperJsonPath = f"{mapperPath}_deletedIDs_mapper.json" - mapperJsonName = os.path.basename(mapperJsonPath) - self.logger.log(f"Saving deleted IDs to {mapperJsonName}...") - with open(mapperJsonPath, "w") as file: - file.write(json.dumps(deleteIDsMapper)) - - mapperPath = os.path.splitext(trackedSegmFilepath)[0] - mapperJsonPath = f"{mapperPath}_replacedIDs_mapper.json" - mapperJsonName = os.path.basename(mapperJsonPath) - self.logger.log(f"Saving IDs replacements to {mapperJsonName}...") - with open(mapperJsonPath, "w") as file: - file.write(json.dumps(trackedIDsMapper)) - - self.logger.log("Generating acdc_output table...") - acdc_df = None - if not self.trackedSegmFilename: - # Fix existing acdc_df - acdcEndname = self.endFilenameSegm.replace("_segm", "_acdc_output") - acdcFilename = [ - f - for f in myutils.listdir(imagesPath) - if f.endswith(f"{acdcEndname}.csv") - ] - if acdcFilename: - acdcFilePath = os.path.join(imagesPath, acdcFilename[0]) - acdc_df = pd.read_csv(acdcFilePath, index_col=["frame_i", "Cell_ID"]) - - if acdc_df is not None: - acdc_df = core.apply_trackedIDs_mapper_to_acdc_df( - trackedIDsMapper, deleteIDsMapper, acdc_df - ) - else: - acdc_dfs = [] - keys = [] - for frame_i, lab in enumerate(trackedData): - rp = skimage.measure.regionprops(lab) - acdc_df_frame_i = myutils.getBaseAcdcDf(rp) - acdc_dfs.append(acdc_df_frame_i) - keys.append(frame_i) - - acdc_df = pd.concat(acdc_dfs, keys=keys, names=["frame_i", "Cell_ID"]) - segmFilename = os.path.basename(trackedSegmFilepath) - acdcFilename = re.sub(segm_re_pattern, "_acdc_output", segmFilename) - acdcFilePath = os.path.join(imagesPath, acdcFilename) - - self.signals.initProgressBar.emit(pbarMax) - parentIDcol = self.trackColsInfo["parentIDcol"] - trackIDsCol = self.trackColsInfo["trackIDsCol"] - if parentIDcol != "None": - self.logger.log(f'Adding lineage info from "{parentIDcol}" column...') - acdc_df = core.add_cca_info_from_parentID_col( - df, - acdc_df, - frameIndexCol, - trackIDsCol, - parentIDcol, - len(segmData), - signal=self.signals.progressBar, - maskID_colname=self.trackColsInfo["maskIDsCol"], - x_colname=self.trackColsInfo["xCentroidCol"], - y_colname=self.trackColsInfo["yCentroidCol"], - ) - - self.logger.log("Saving acdc_output table...") - acdc_df.to_csv(acdcFilePath) - - self.signals.finished.emit(self) - - -class RestructMultiPosWorker(BaseWorkerUtil): - sigSaveTiff = Signal(str, object, object) - - def __init__(self, rootFolderPath, dstFolderPath, action="copy"): - super().__init__(None) - self.rootFolderPath = rootFolderPath - self.dstFolderPath = dstFolderPath - self.mutex = QMutex() - self.waitCond = QWaitCondition() - self.action = action - - @worker_exception_handler - def run(self): - load._restructure_multi_files_multi_pos( - self.rootFolderPath, - self.dstFolderPath, - signals=self.signals, - logger=self.logger.log, - action=self.action, - ) - self.signals.finished.emit(self) - - -class RestructMultiTimepointsWorker(BaseWorkerUtil): - sigSaveTiff = Signal(str, object, object) - - def __init__( - self, - allChannels, - frame_name_pattern, - basename, - validFilenames, - rootFolderPath, - dstFolderPath, - segmFolderPath="", - ): - super().__init__(None) - self.allChannels = allChannels - self.frame_name_pattern = frame_name_pattern - self.basename = basename - self.validFilenames = validFilenames - self.rootFolderPath = rootFolderPath - self.dstFolderPath = dstFolderPath - self.segmFolderPath = segmFolderPath - self.mutex = QMutex() - self.waitCond = QWaitCondition() - - @worker_exception_handler - def run(self): - allChannels = self.allChannels - frame_name_pattern = self.frame_name_pattern - rootFolderPath = self.rootFolderPath - dstFolderPath = self.dstFolderPath - segmFolderPath = self.segmFolderPath - filesInfo = {} - self.signals.initProgressBar.emit(len(self.validFilenames) + 1) - for file in self.validFilenames: - try: - # Determine which channel is this file - for ch in allChannels: - m = re.findall(rf"(.*)_{ch}{frame_name_pattern}", file) - if m: - break - else: - raise FileNotFoundError( - f'The file name "{file}" does not contain any channel name' - ) - posName, _, frameName = m[0] - frameNumber = int(frameName) - if posName not in filesInfo: - filesInfo[posName] = {ch: [(file, frameNumber)]} - elif ch not in filesInfo[posName]: - filesInfo[posName][ch] = [(file, frameNumber)] - else: - filesInfo[posName][ch].append((file, frameNumber)) - except Exception as e: - self.logger.log(traceback.format_exc()) - self.logger.log( - f'WARNING: File "{file}" does not contain valid pattern. ' - "Skipping it." - ) - continue - - self.signals.progressBar.emit(1) - - df_metadata = None - partial_basename = self.basename - allPosDataInfo = [] - for p, (posName, channelInfo) in enumerate(filesInfo.items()): - self.logger.log(f"=" * 40) - self.logger.log(f'Processing position "{posName}"...') - - for _, filesList in channelInfo.items(): - # Get info from first file - filePath = os.path.join(rootFolderPath, filesList[0][0]) - try: - img = load.imread(filePath) - break - except Exception as e: - self.logger.log(traceback.format_exc()) - continue - else: - self.logger.log( - f"WARNING: No valid image files found for position {posName}" - ) - continue - - # Get basename - if partial_basename: - basename = f"{partial_basename}_{posName}_" - else: - basename = f"{posName}_" - - # Get SizeT from first file - SizeT = len(filesList) - - # Save metadata.csv - df_metadata = pd.DataFrame( - {"SizeT": SizeT, "basename": basename}, index=["values"] - ) - - # Iterate channels - for c, (channelName, filesList) in enumerate(channelInfo.items()): - self.logger.log(f' Processing channel "{channelName}"...') - # Sort by frame number - sortedFilesList = sorted(filesList, key=lambda t: t[1]) - - df_metadata[f"channel_{c}_name"] = [channelName] - - imagesPath = os.path.join(dstFolderPath, f"Position_{p + 1}", "Images") - if not os.path.exists(imagesPath): - os.makedirs(imagesPath, exist_ok=True) - - # Iterate frames - videoData = None - srcSegmPaths = [""] * SizeT - frameNumbers = [] - for frame_i, fileInfo in enumerate(sortedFilesList): - file, _ = fileInfo - ext = os.path.splitext(file)[1] - srcImgFilePath = os.path.join(rootFolderPath, file) - try: - img = load.imread(srcImgFilePath) - if videoData is None: - shape = (SizeT, *img.shape) - videoData = np.zeros(shape, dtype=img.dtype) - videoData[frame_i] = img - pattern = self.frame_name_pattern - frameNumberMatch = re.findall(pattern, file)[0][1] - frameNumber = int(frameNumberMatch) - frameNumbers.append(frameNumber) - except Exception as e: - self.logger.log(traceback.format_exc()) - continue - - if segmFolderPath and c == 0: - srcSegmFilePath = os.path.join(segmFolderPath, file) - srcSegmPaths[frame_i] = srcSegmFilePath - - SizeZ = 1 - if img.ndim == 3: - SizeZ = len(img) - - df_metadata["SizeZ"] = [SizeZ] - - self.signals.progressBar.emit(1) - - if videoData is None: - self.logger.log( - f"WARNING: No valid image files found for position " - f'"{posName}", channel "{channelName}"' - ) - continue - else: - imgFileName = f"{basename}{channelName}.tif" - dstImgFilePath = os.path.join(imagesPath, imgFileName) - dstSegmFileName = f"{basename}segm_{channelName}.npz" - dstSegmPath = os.path.join(imagesPath, dstSegmFileName) - imgDataInfo = { - "path": dstImgFilePath, - "SizeT": SizeT, - "SizeZ": SizeZ, - "data": videoData, - "frameNumbers": frameNumbers, - "dst_segm_path": dstSegmPath, - "src_segm_paths": srcSegmPaths, - } - allPosDataInfo.append(imgDataInfo) - - if df_metadata is not None: - metadata_csv_path = os.path.join(imagesPath, f"{basename}metadata.csv") - df_metadata = df_metadata.T - df_metadata.index.name = "Description" - df_metadata.to_csv(metadata_csv_path) - - self.logger.log(f"*" * 40) - - if not allPosDataInfo: - self.signals.finished.emit(self) - return - - self.signals.initProgressBar.emit(len(allPosDataInfo)) - self.logger.log("Saving image files...") - maxSizeT = max([d["SizeT"] for d in allPosDataInfo]) - minFrameNumber = min([d["frameNumbers"][0] for d in allPosDataInfo]) - # Pad missing frames in video files according to frame number - for p, imgDataInfo in enumerate(allPosDataInfo): - SizeT = imgDataInfo["SizeT"] - SizeZ = imgDataInfo["SizeZ"] - dstImgFilePath = imgDataInfo["path"] - videoData = imgDataInfo["data"] - frameNumbers = imgDataInfo["frameNumbers"] - paddedShape = (maxSizeT, *videoData.shape[1:]) - imgDataInfo["paddedShape"] = paddedShape - dtype = videoData.dtype - paddedVideoData = np.zeros(paddedShape, dtype=dtype) - for n, img in zip(frameNumbers, videoData): - frame_i = n - minFrameNumber - paddedVideoData[frame_i] = img - - del videoData - imgDataInfo["data"] = None - - self.mutex.lock() - self.sigSaveTiff.emit(dstImgFilePath, paddedVideoData, self.waitCond) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - self.signals.progressBar.emit(1) - - if not segmFolderPath: - self.signals.finished.emit(self) - return - - self.signals.initProgressBar.emit(len(allPosDataInfo)) - self.logger.log("Saving segmentation files...") - for p, imgDataInfo in enumerate(allPosDataInfo): - SizeT = imgDataInfo["SizeT"] - frameNumbers = imgDataInfo["frameNumbers"] - SizeT = imgDataInfo["SizeT"] - SizeZ = imgDataInfo["SizeZ"] - frameNumbers = imgDataInfo["frameNumbers"] - paddedShape = imgDataInfo["paddedShape"] - segmData = np.zeros(paddedShape, dtype=np.uint32) - for n, segmFilePath in zip(frameNumbers, imgDataInfo["src_segm_paths"]): - frame_i = n - minFrameNumber - try: - lab = load.imread(segmFilePath).astype(np.uint32) - segmData[frame_i] = lab - except Exception as e: - self.logger.log(traceback.format_exc()) - self.logger.log( - "WARNING: The following segmentation file does not " - f'exist, saving empty masks: "{srcSegmFilePath}"' - ) - - io.savez_compressed(imgDataInfo["dst_segm_path"], segmData) - del segmData - - self.signals.finished.emit(self) - - -class ComputeMetricsMultiChannelWorker(BaseWorkerUtil): - sigAskAppendName = Signal(str, list, list) - sigCriticalNotEnoughSegmFiles = Signal(str) - sigAborted = Signal() - sigHowCombineMetrics = Signal(str, list, list, list) - - def __init__(self, mainWin): - super().__init__(mainWin) - - def emitHowCombineMetrics( - self, - imagesPath, - selectedAcdcOutputEndnames, - existingAcdcOutputEndnames, - allChNames, - ): - self.mutex.lock() - self.sigHowCombineMetrics.emit( - imagesPath, - selectedAcdcOutputEndnames, - existingAcdcOutputEndnames, - allChNames, - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def loadAcdcDfs(self, imagesPath, selectedAcdcOutputEndnames): - for end in selectedAcdcOutputEndnames: - filePath, _ = load.get_path_from_endname(end, imagesPath) - acdc_df = pd.read_csv(filePath) - yield acdc_df - - def run_iter_exp(self, exp_path, pos_foldernames, i, tot_exp): - tot_pos = len(pos_foldernames) - - abort = self.emitSelectAcdcOutputFiles( - exp_path, - pos_foldernames, - infoText=" to combine", - allowSingleSelection=False, - ) - if abort: - self.sigAborted.emit() - return - - # Ask appendend name - self.mutex.lock() - self.sigAskAppendName.emit( - f"{self.mainWin.basename_pos1}acdc_output", - self.mainWin.existingAcdcOutputEndnames, - self.mainWin.selectedAcdcOutputEndnames, - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - self.sigAborted.emit() - return - - selectedAcdcOutputEndnames = self.mainWin.selectedAcdcOutputEndnames - existingAcdcOutputEndnames = self.mainWin.existingAcdcOutputEndnames - appendedName = self.appendedName - - self.signals.initProgressBar.emit(len(pos_foldernames)) - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.sigAborted.emit() - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, {pos} ({p + 1}/{tot_pos})" - ) - - imagesPath = os.path.join(exp_path, pos, "Images") - basename, chNames = myutils.getBasenameAndChNames( - imagesPath, useExt=(".tif", ".h5") - ) - - if p == 0: - abort = self.emitHowCombineMetrics( - imagesPath, - selectedAcdcOutputEndnames, - existingAcdcOutputEndnames, - chNames, - ) - if abort: - self.sigAborted.emit() - return - acdcDfs = self.acdcDfs.values() - # Update selected acdc_dfs since the user could have - # loaded additional ones inside the emitHowCombineMetrics - # dialog - selectedAcdcOutputEndnames = self.acdcDfs.keys() - else: - acdcDfs = self.loadAcdcDfs(imagesPath, selectedAcdcOutputEndnames) - - dfs = [] - for i, acdc_df in enumerate(acdcDfs): - dfs.append(acdc_df.add_suffix(f"_table{i + 1}")) - combined_df = pd.concat(dfs, axis=1) - - newAcdcDf = pd.DataFrame(index=combined_df.index) - for newColname, equation in self.equations.items(): - newAcdcDf[newColname] = combined_df.eval(equation) - - newAcdcDfPath = os.path.join( - imagesPath, f"{basename}acdc_output_{appendedName}.csv" - ) - newAcdcDf.to_csv(newAcdcDfPath) - - equationsIniPath = os.path.join( - imagesPath, f"{basename}equations_{appendedName}.ini" - ) - equationsConfig = config.ConfigParser() - if os.path.exists(equationsIniPath): - equationsConfig.read(equationsIniPath) - equationsConfig = self.addEquationsToConfigPars( - equationsConfig, selectedAcdcOutputEndnames, self.equations - ) - with open(equationsIniPath, "w") as configfile: - equationsConfig.write(configfile) - - self.signals.progressBar.emit(1) - - return True - - def addEquationsToConfigPars(self, cp, selectedAcdcOutputEndnames, equations): - section = [ - f"df{i + 1}:{end}" for i, end in enumerate(selectedAcdcOutputEndnames) - ] - section = ";".join(section) - if section not in cp: - cp[section] = {} - - for metricName, expression in equations.items(): - cp[section][metricName] = expression - - return cp - - @worker_exception_handler - def run(self): - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - self.errors = {} - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - try: - result = self.run_iter_exp(exp_path, pos_foldernames, i, tot_exp) - if result is None: - return - except Exception as e: - traceback_str = traceback.format_exc() - self.errors[e] = traceback_str - self.logger.log(traceback_str) - - self.signals.finished.emit(self) - - -class ConcatAcdcDfsWorker(BaseWorkerUtil): - sigAborted = Signal() - sigAskFolder = Signal(str) - sigSetMeasurements = Signal(object) - sigAskAppendName = Signal(str, list) - - def __init__(self, mainWin, format="CSV"): - super().__init__(mainWin) - if format.startswith("CSV"): - self._to_format = "to_csv" - elif format.startswith("XLS"): - self._to_format = "to_excel" - - def emitSetMeasurements(self, kwargs): - self.mutex.lock() - self.sigSetMeasurements.emit(kwargs) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def emitAskAppendName(self, allPos_acdc_df_basename): - # Ask appendend name - self.mutex.lock() - self.sigAskAppendName.emit(allPos_acdc_df_basename, []) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - @worker_exception_handler - def run(self): - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - - self.signals.initProgressBar.emit(0) - acdc_dfs_allexp = [] - acdc_objs_count_dfs_allexp = {} - keys_exp = [] - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - if i == 0: - abort = self.emitSelectAcdcOutputFiles( - exp_path, - pos_foldernames, - infoText=" to combine", - allowSingleSelection=True, - multiSelection=False, - ) - if abort: - self.sigAborted.emit() - return - - selectedAcdcOutputEndname = self.mainWin.selectedAcdcOutputEndnames[0] - selectedAcdcObjsCountEndname = selectedAcdcOutputEndname.replace( - "acdc_output", "acdc_objects_count" - ) - - self.signals.initProgressBar.emit(len(pos_foldernames)) - acdc_dfs = [] - acdc_objs_count_dfs = {} - keys = [] - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.sigAborted.emit() - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - images_path = os.path.join(exp_path, pos, "Images") - - ls = myutils.listdir(images_path) - - acdc_output_file = [ - f for f in ls if f.endswith(f"{selectedAcdcOutputEndname}.csv") - ] - if not acdc_output_file: - self.logger.log( - f"{pos} does not contain any " - f"{selectedAcdcOutputEndname}.csv file. " - "Skipping it." - ) - self.signals.progressBar.emit(1) - continue - - acdc_objs_count_file = [ - f for f in ls if f.endswith(f"{selectedAcdcObjsCountEndname}.csv") - ] - if acdc_objs_count_file: - df_count_filepath = os.path.join( - images_path, acdc_objs_count_file[0] - ) - df_count = pd.read_csv(df_count_filepath) - acdc_objs_count_dfs[pos] = df_count - - acdc_df_filepath = os.path.join(images_path, acdc_output_file[0]) - acdc_df = pd.read_csv(acdc_df_filepath).set_index("Cell_ID") - acdc_dfs.append(acdc_df) - keys.append(pos) - - self.signals.progressBar.emit(1) - - self.signals.initProgressBar.emit(0) - acdc_df_allpos = pd.concat( - acdc_dfs, keys=keys, names=["Position_n", "Cell_ID"] - ) - acdc_df_allpos["experiment_folderpath"] = exp_path - - basename, chNames = myutils.getBasenameAndChNames( - images_path, useExt=(".tif", ".h5") - ) - df_metadata = load.load_metadata_df(images_path) - SizeZ = df_metadata.at["SizeZ", "values"] - SizeZ = int(float(SizeZ)) - existing_colnames = acdc_df_allpos.columns - isSegm3D = any([col.endswith("3D") for col in existing_colnames]) - - if i == 0: - kwargs = { - "loadedChNames": chNames, - "notLoadedChNames": [], - "isZstack": SizeZ > 1, - "isSegm3D": isSegm3D, - "existing_colnames": existing_colnames, - } - self.emitSetMeasurements(kwargs) - if self.abort: - self.sigAborted.emit() - return - - selected_cols = [ - col for col in self.selectedColumns if col in acdc_df_allpos.columns - ] - acdc_df_allpos = acdc_df_allpos[selected_cols] - acdc_dfs_allexp.append(acdc_df_allpos) - exp_name = os.path.basename(exp_path) - keys_exp.append((exp_path, exp_name)) - - allpos_dir = os.path.join(exp_path, "AllPos_acdc_output") - if not os.path.exists(allpos_dir): - os.mkdir(allpos_dir) - - allPos_acdc_df_basename = f"AllPos_{selectedAcdcOutputEndname}" - if i == 0: - self.emitAskAppendName(allPos_acdc_df_basename) - if self.abort: - self.sigAborted.emit() - return - - acdc_objs_count_df_allpos_filename = self.concat_df_filename.replace( - "acdc_output", "acdc_objects_count" - ) - - acdc_dfs_allpos_filepath = os.path.join(allpos_dir, self.concat_df_filename) - - self.logger.log( - "Saving all positions concatenated file to " - f'"{acdc_dfs_allpos_filepath}"' - ) - to_format_func = getattr(acdc_df_allpos, self._to_format) - to_format_func(acdc_dfs_allpos_filepath) - self.acdc_dfs_allpos_filepath = acdc_dfs_allpos_filepath - - if not acdc_objs_count_dfs: - continue - - acdc_objs_count_df_allpos = pd.concat( - acdc_objs_count_dfs, names=["Position_n"] - ) - acdc_objs_count_df_allpos["experiment_folderpath"] = exp_path - - acdc_objs_count_df_allpos_filepath = os.path.join( - allpos_dir, acdc_objs_count_df_allpos_filename - ) - - self.logger.log( - "Saving all positions objects count file to " - f'"{acdc_objs_count_df_allpos_filepath}"' - ) - to_format_func = getattr(acdc_objs_count_df_allpos, self._to_format) - to_format_func(acdc_objs_count_df_allpos_filepath) - - acdc_objs_count_dfs_allexp[(exp_path, exp_name)] = acdc_objs_count_df_allpos - - if len(keys_exp) <= 1: - self.signals.finished.emit(self) - return - - allExp_filename = f"multiExp_{self.concat_df_filename}" - self.mutex.lock() - self.sigAskFolder.emit(allExp_filename) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - self.sigAborted.emit() - return - - acdc_df_allexp = pd.concat( - acdc_dfs_allexp, - keys=keys_exp, - names=["experiment_folderpath", "experiment_foldername"], - ) - acdc_dfs_allexp_filepath = os.path.join(self.allExpSaveFolder, allExp_filename) - self.logger.log( - "Saving multiple experiments concatenated file to " - f'"{acdc_dfs_allexp_filepath}"' - ) - to_format_func = getattr(acdc_df_allexp, self._to_format) - to_format_func(acdc_dfs_allexp_filepath) - - if acdc_objs_count_dfs_allexp: - allexp_count_df_filename = f"multiExp_{acdc_objs_count_df_allpos_filename}" - acdc_objs_count_df_allexp = pd.concat( - acdc_objs_count_dfs_allexp, - names=["experiment_folderpath", "experiment_foldername"], - ) - acdc_objs_count_df_allexp_filepath = os.path.join( - self.allExpSaveFolder, allexp_count_df_filename - ) - self.logger.log( - "Saving multiple experiments concatenated file to " - f'"{acdc_objs_count_df_allexp_filepath}"' - ) - to_format_func = getattr(acdc_objs_count_df_allexp, self._to_format) - to_format_func(acdc_objs_count_df_allexp_filepath) - - self.signals.finished.emit(self) - - -class FromImajeJroiToSegmNpzWorker(BaseWorkerUtil): - sigSelectRoisProps = Signal(str, object, bool) - - def __init__(self, mainWin): - super().__init__(mainWin) - - def emitSelectRoisProps(self, roi_filepath, TZYX_shape, is_multi_pos): - self.mutex.lock() - self.sigSelectRoisProps.emit(roi_filepath, TZYX_shape, is_multi_pos) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - @worker_exception_handler - def run(self): - import roifile - - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - abort = self.emitSelectFilesWithText( - exp_path, pos_foldernames, "imagej_rois", ext=".zip" - ) - if abort: - self.signals.finished.emit(self) - return - - self.askRoiPreferences = True - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.signals.finished.emit(self) - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - images_path = os.path.join(exp_path, pos, "Images") - endFilenameRoi = self.mainWin.endFilenameWithText - ls = myutils.listdir(images_path) - rois_filepaths = [ - os.path.join(images_path, f) - for f in ls - if f.endswith(f"{endFilenameRoi}.zip") - ] - - if not rois_filepaths: - self.logger.log( - "[WARNING]: The following Position folder does not " - f"contain any file ending with {endFilenameRoi}. " - f'Skipping it. "{os.path.join(exp_path, pos)}")' - ) - continue - - rois_filepath = rois_filepaths[0] - - if self.askRoiPreferences: - is_multi_pos = len(pos_foldernames) > 1 - self.logger.log("Loading image data to get image shape...") - TZYX_shape = load.get_tzyx_shape(images_path) - abort = self.emitSelectRoisProps( - rois_filepath, TZYX_shape, is_multi_pos - ) - if abort: - self.signals.finished.emit(self) - return - - self.askRoiPreferences = not self.useSamePropsForNextPos - elif self.areAllRoisSelected: - rois = roifile.roiread(rois_filepath) - self.IDsToRoisMapper = {i + i: roi for roi in enumerate(rois)} - else: - # Use same ID of previous position - rois = roifile.roiread(rois_filepath) - IDsToRoisMapper = {i + i: roi for i, roi in enumerate(rois)} - self.IDsToRoisMapper = { - ID: IDsToRoisMapper[ID] for ID in self.IDsToRoisMapper.keys() - } - - self.logger.log("Generating segm mask from ROIs...") - segm_data = myutils.from_imagej_rois_to_segm_data( - TZYX_shape, - self.IDsToRoisMapper, - self.rescaleRoisSizes, - self.repeatRoisZslicesRange, - ) - - segm_filepath = rois_filepath.replace("imagej_rois", "segm").replace( - ".zip", ".npz" - ) - self.logger.log(f'Saving segm mask to "{segm_filepath}"...') - io.savez_compressed(segm_filepath, segm_data) - - self.signals.finished.emit(self) - - -class ToImajeJroiWorker(BaseWorkerUtil): - def __init__(self, mainWin): - super().__init__(mainWin) - - @worker_exception_handler - def run(self): - from roifile import ImagejRoi, roiwrite - - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) - if abort: - self.signals.finished.emit(self) - return - - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.signals.finished.emit(self) - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - images_path = os.path.join(exp_path, pos, "Images") - endFilenameSegm = self.mainWin.endFilenameSegm - ls = myutils.listdir(images_path) - - files_path = [ - os.path.join(images_path, f) - for f in ls - if f.endswith(f"{endFilenameSegm}.npz") - ] - - if not files_path: - self.logger.log( - "[WARNING]: The following Position folder does not " - f"contain any file ending with {endFilenameSegm}. " - f'Skipping it. "{os.path.join(exp_path, pos)}")' - ) - continue - - file_path = files_path[0] - - posData = load.loadData(file_path, "") - - self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") - - posData.getBasenameAndChNames() - posData.buildPaths() - - posData.loadOtherFiles( - load_segm_data=True, - load_metadata=True, - end_filename_segm=endFilenameSegm, - ) - - if posData.SizeT > 1: - rois = [] - max_ID = posData.segm_data.max() - for t, lab in enumerate(posData.segm_data): - rois_t = myutils.from_lab_to_imagej_rois( - lab, ImagejRoi, t=t, SizeT=posData.SizeT, max_ID=max_ID - ) - rois.extend(rois_t) - else: - rois = myutils.from_lab_to_imagej_rois(posData.segm_data, ImagejRoi) - - roi_filepath = posData.segm_npz_path.replace(".npz", ".zip") - roi_filepath = roi_filepath.replace("_segm", "_imagej_rois") - - try: - os.remove(roi_filepath) - except Exception as e: - pass - - roiwrite(roi_filepath, rois) - - self.signals.finished.emit(self) - - -class ToSymDivWorker(QObject): - progressBar = Signal(int, int, float) - - def __init__(self, mainWin): - QObject.__init__(self) - self.signals = signals() - self.abort = False - self.logger = workerLogger(self.signals.progress) - self.mutex = QMutex() - self.waitCond = QWaitCondition() - self.mainWin = mainWin - - def emitSelectSegmFiles(self, exp_path, pos_foldernames): - self.mutex.lock() - self.signals.sigSelectSegmFiles.emit(exp_path, pos_foldernames) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - @worker_exception_handler - def run(self): - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - self.missingAnnotErrors = {} - tot_pos = len(pos_foldernames) - self.allPosDataInputs = [] - posDatas = [] - self.logger.log("-" * 30) - expFoldername = os.path.basename(exp_path) - - abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) - if abort: - self.signals.finished.emit(self) - return - - self.signals.initProgressBar.emit(len(pos_foldernames)) - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.signals.finished.emit(self) - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames( - images_path, useExt=(".tif", ".h5") - ) - - self.signals.sigUpdatePbarDesc.emit(f"Loading {pos_path}...") - - # Use first found channel, it doesn't matter for metrics - for chName in chNames: - file_path = myutils.getChannelFilePath(images_path, chName) - if file_path: - break - else: - raise FileNotFoundError( - f'None of the channels "{chNames}" were found in the path ' - f'"{images_path}".' - ) - - # Load data - posData = load.loadData(file_path, chName) - posData.getBasenameAndChNames(useExt=(".tif", ".h5")) - - posData.loadOtherFiles( - load_segm_data=False, - load_acdc_df=True, - load_metadata=True, - loadSegmInfo=True, - ) - - posDatas.append(posData) - - self.allPosDataInputs.append({"file_path": file_path, "chName": chName}) - - # Iterate pos and calculate metrics - numPos = len(self.allPosDataInputs) - for p, posDataInputs in enumerate(self.allPosDataInputs): - file_path = posDataInputs["file_path"] - chName = posDataInputs["chName"] - - posData = load.loadData(file_path, chName) - - self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") - - posData.getBasenameAndChNames(useExt=(".tif", ".h5")) - posData.buildPaths() - posData.loadImgData() - - posData.loadOtherFiles( - load_segm_data=False, - load_acdc_df=True, - end_filename_segm=self.mainWin.endFilenameSegm, - ) - if not posData.acdc_df_found: - relPath = ( - f"...{os.sep}{expFoldername}{os.sep}{posData.pos_foldername}" - ) - self.logger.log( - f'WARNING: Skipping "{relPath}" ' - f"because acdc_output.csv file was not found." - ) - self.missingAnnotErrors[relPath] = ( - f'
    FileNotFoundError: the Positon "{relPath}" ' - "does not have the acdc_output.csv file.
    " - ) - - continue - - acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) - self.logger.log( - f'Loaded path:\nACDC output file name: "{acdc_df_filename}"' - ) - - self.logger.log("Building tree...") - try: - tree = core.LineageTree(posData.acdc_df) - error = tree.build() - if isinstance(error, KeyError): - self.logger.log(str(error)) - - self.logger.log( - "WARNING: Annotations missing in " - f'"{posData.acdc_output_csv_path}"' - ) - self.missingAnnotErrors[acdc_df_filename] = str(error) - continue - elif error is not None: - raise error - posData.acdc_df = tree.df - except Exception as error: - traceback_format = traceback.format_exc() - self.logger.log(traceback_format) - self.errors[error] = traceback_format - - try: - posData.acdc_df.to_csv(posData.acdc_output_csv_path) - except PermissionError: - traceback_str = traceback.format_exc() - self.mutex.lock() - self.signals.sigPermissionError.emit( - traceback_str, posData.acdc_output_csv_path - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - posData.acdc_df.to_csv(posData.acdc_output_csv_path) - - self.signals.progressBar.emit(1) - - self.signals.finished.emit(self) - - -class AlignWorker(BaseWorkerUtil): - sigAborted = Signal() - sigAskUseSavedShifts = Signal(str, str) - sigAskSelectChannel = Signal(list) - - def __init__(self, mainWin): - super().__init__(mainWin) - - def emitAskUseSavedShifts(self, expPath, basename): - self.mutex.lock() - self.sigAskUseSavedShifts.emit(expPath, basename) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def emitAskSelectChannel(self, channels): - self.mutex.lock() - self.sigAskSelectChannel.emit(channels) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - @worker_exception_handler - def run(self): - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - shiftsFound = False - for pos in pos_foldernames: - images_path = os.path.join(exp_path, pos, "Images") - ls = myutils.listdir(images_path) - for file in ls: - if file.endswith("align_shift.npy"): - shiftsFound = True - basename, chNames = myutils.getBasenameAndChNames( - images_path, useExt=(".tif", ".h5") - ) - break - if shiftsFound: - break - - savedShiftsHow = None - if shiftsFound: - basename_ch0 = f"{basename}{chNames[0]}_" - abort = self.emitAskUseSavedShifts(exp_path, basename_ch0) - if abort: - self.sigAborted.emit() - return - - savedShiftsHow = self.savedShiftsHow - - self.signals.initProgressBar.emit(len(pos_foldernames)) - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.sigAborted.emit() - return - - self.logger.log("*" * 40) - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - pos_path = os.path.join(exp_path, pos) - images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames( - images_path, useExt=(".tif", ".h5") - ) - - self.signals.sigUpdatePbarDesc.emit(f"Loading {pos_path}...") - - if p == 0: - self.logger.log(f"Asking to select reference channel...") - abort = self.emitAskSelectChannel(chNames) - if abort: - self.sigAborted.emit() - return - chName = self.chName - - file_path = myutils.getChannelFilePath(images_path, chName) - - # Load data - posData = load.loadData(file_path, chName) - posData.getBasenameAndChNames(useExt=(".tif", ".h5")) - posData.buildPaths() - posData.loadImgData() - - posData.loadOtherFiles( - load_segm_data=False, load_shifts=True, loadSegmInfo=True - ) - - if posData.img_data.ndim == 4: - align_func = core.align_frames_3D - if posData.segmInfo_df is None: - raise FileNotFoundError( - "To align 4D data you need to select which z-slice " - "you want to use for alignment. Please run the module " - "`1. Launch data prep module...` before aligning the " - "frames. (z-slice info MISSING from position " - f'"{posData.relPath}")' - ) - df = posData.segmInfo_df.loc[posData.filename] - zz = df["z_slice_used_dataPrep"].to_list() - elif posData.img_data.ndim == 3: - align_func = core.align_frames_2D - zz = None - - useSavedShifts = ( - savedShiftsHow == "use_saved_shifts" - and posData.loaded_shifts is not None - ) - if useSavedShifts: - user_shifts = posData.loaded_shifts - else: - user_shifts = None - - if savedShiftsHow == "rever_alignment": - if posData.loaded_shifts is None: - self.logger.log( - f'WARNING: Cannot revert alignment in "{posData.relPath}" ' - "since it is missing previously computed shifts. " - "Skipping this positon." - ) - continue - - # Revert alignment and save selected channel - for chName in chNames: - self.logger.log(f'Reverting alignment on "{chName}"...') - if chName == posData.user_ch_name: - data = posData.img_data - else: - file_path = myutils.getChannelFilePath(images_path, chName) - data = load.load_image_file(file_path) - - self.signals.sigInitInnerPbar.emit(len(data) - 1) - revertedData = core.revert_alignment( - posData.loaded_shifts, - data, - sigPyqt=self.signals.sigUpdateInnerPbar, - ) - self.logger.log(f'Saving "{chName}"...') - self.signals.sigInitInnerPbar.emit(0) - self.saveAlignedData( - revertedData, - images_path, - posData.basename, - chName, - self.revertedAlignEndname, - ext=posData.ext, - ) - del revertedData, data - else: - for chName in chNames: - self.logger.log(f'Aligning "{chName}"...') - if chName == posData.user_ch_name: - data = posData.img_data - else: - file_path = myutils.getChannelFilePath(images_path, chName) - data = load.load_image_file(file_path) - self.signals.sigInitInnerPbar.emit(len(data) - 1) - - alignedImgData, shifts = align_func( - data, - slices=zz, - user_shifts=user_shifts, - sigPyqt=self.signals.sigUpdateInnerPbar, - ) - self.logger.log(f'Saving "{chName}"...') - np.save(posData.align_shifts_path, shifts) - - self.signals.sigInitInnerPbar.emit(0) - self.saveAlignedData( - alignedImgData, - images_path, - posData.basename, - chName, - "", - ext=posData.non_aligned_ext, - ) - self.saveAlignedData( - alignedImgData, - images_path, - posData.basename, - chName, - "aligned", - ext=".npz", - ) - del alignedImgData, data - - self.signals.finished.emit(self) - - def saveAlignedData(self, data, imagesPath, basename, chName, endname, ext=".tif"): - if endname: - newFilename = f"{basename}{chName}_{endname}{ext}" - else: - newFilename = f"{basename}{chName}{ext}" - - filePath = os.path.join(imagesPath, newFilename) - - if ext == ".tif": - SizeT = data.shape[0] - SizeZ = 1 - if data.ndim == 4: - SizeZ = data.shape[1] - myutils.to_tiff(filePath, data) - elif ext == ".npz": - io.savez_compressed(filePath, data) - elif ext == ".h5": - load.save_to_h5(filePath, data) - - -class ToObjCoordsWorker(BaseWorkerUtil): - def __init__(self, mainWin): - super().__init__(mainWin) - - @worker_exception_handler - def run(self): - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) - if abort: - self.signals.finished.emit(self) - return - - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.signals.finished.emit(self) - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - images_path = os.path.join(exp_path, pos, "Images") - endFilenameSegm = self.mainWin.endFilenameSegm - ls = myutils.listdir(images_path) - file_path = [ - os.path.join(images_path, f) - for f in ls - if f.endswith(f"{endFilenameSegm}.npz") - ][0] - - posData = load.loadData(file_path, "") - - self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") - - posData.getBasenameAndChNames() - posData.buildPaths() - - posData.loadOtherFiles( - load_segm_data=True, - load_metadata=True, - end_filename_segm=endFilenameSegm, - ) - - if posData.SizeT == 1: - posData.segm_data = posData.segm_data[np.newaxis] - - dfs = [] - n_frames = len(posData.segm_data) - self.signals.initProgressBar.emit(n_frames) - for frame_i, lab in enumerate(posData.segm_data): - df_coords_i = myutils.from_lab_to_obj_coords(lab) - dfs.append(df_coords_i) - self.signals.progressBar.emit(1) - df_filepath = posData.segm_npz_path.replace(".npz", ".csv") - df_filepath = df_filepath.replace("_segm", "_objects_coordinates") - - keys = list(range(len(posData.segm_data))) - df = pd.concat(dfs, keys=keys, names=["frame_i"]) - - self.signals.initProgressBar.emit(0) - df.to_csv(df_filepath) - - self.signals.finished.emit(self) - - -class Stack2DsegmTo3Dsegm(BaseWorkerUtil): - sigAskAppendName = Signal(str, list) - sigAborted = Signal() - - def __init__(self, mainWin, SizeZ): - super().__init__(mainWin) - self.SizeZ = SizeZ - - @worker_exception_handler - def run(self): - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - self.mainWin.infoText = f"Select 2D segmentation file to stack" - abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) - if abort: - self.sigAborted.emit() - return - - # Ask appendend name - self.mutex.lock() - self.sigAskAppendName.emit( - self.mainWin.endFilenameSegm, self.mainWin.existingSegmEndNames - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - self.sigAborted.emit() - return - - appendedName = self.appendedName - self.signals.initProgressBar.emit(len(pos_foldernames)) - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.sigAborted.emit() - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - images_path = os.path.join(exp_path, pos, "Images") - endFilenameSegm = self.mainWin.endFilenameSegm - ls = myutils.listdir(images_path) - file_path = [ - os.path.join(images_path, f) - for f in ls - if f.endswith(f"{endFilenameSegm}.npz") - ][0] - - posData = load.loadData(file_path, "") - - self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") - - posData.getBasenameAndChNames() - posData.buildPaths() - - posData.loadOtherFiles( - load_segm_data=True, - load_acdc_df=True, - load_metadata=True, - end_filename_segm=endFilenameSegm, - ) - if posData.segm_data.ndim == 2: - posData.segm_data = posData.segm_data[np.newaxis] - - self.logger.log("Stacking 2D into 3D objects...") - - numFrames = len(posData.segm_data) - self.signals.sigInitInnerPbar.emit(numFrames) - T, Y, X = posData.segm_data.shape - newShape = (T, self.SizeZ, Y, X) - segmData2D = np.zeros(newShape, dtype=np.uint32) - for frame_i, lab in enumerate(posData.segm_data): - stacked_lab = core.stack_2Dlab_to_3D(lab, self.SizeZ) - segmData2D[frame_i] = stacked_lab - - self.signals.sigUpdateInnerPbar.emit(1) - - self.logger.log("Saving stacked 3D segmentation file...") - segmFilename, ext = os.path.splitext(posData.segm_npz_path) - newSegmFilepath = f"{segmFilename}_{appendedName}.npz" - segmData2D = np.squeeze(segmData2D) - io.savez_compressed(newSegmFilepath, segmData2D) - - self.signals.progressBar.emit(1) - - self.signals.finished.emit(self) - - -class MigrateUserProfileWorker(QObject): - finished = Signal(object) - critical = Signal(object) - progress = Signal(str) - debug = Signal(object) - - def __init__(self, src_path, dst_path, acdc_folders): - QObject.__init__(self) - self.signals = signals() - self.src_path = src_path - self.dst_path = dst_path - self.acdc_folders = acdc_folders - - @worker_exception_handler - def run(self): - import shutil - from . import models_path - - self.progress.emit( - "Migrating user profile data from " - f'"{self.src_path}" to "{self.dst_path}"...' - ) - acdc_folders = self.acdc_folders - self.signals.initProgressBar.emit(2 * len(acdc_folders)) - dst_folder = os.path.basename(self.dst_path) - folders_to_remove = [] - for acdc_folder in acdc_folders: - if acdc_folder == dst_folder: - # Skip the destination folder that would be picked up if the - # user called it with acdc at the start of the name - self.signals.progressBar.emit(2) - continue - src = os.path.join(self.src_path, acdc_folder) - dst = os.path.join(self.dst_path, acdc_folder) - self.progress.emit(f"Copying {src} to {dst}...") - files_failed_move = copy_or_move_tree( - src, - dst, - copy=False, - sigInitPbar=self.signals.sigInitInnerPbar, - sigUpdatePbar=self.signals.sigUpdateInnerPbar, - ) - folders_to_remove.append(src) - self.signals.progressBar.emit(1) - - for to_remove in folders_to_remove: - try: - self.progress.emit(f'Removing "{to_remove}"...') - shutil.rmtree(to_remove) - except Exception as err: - self.progress.emit( - "--------------------------------------------------------\n" - f'[WARNING]: Removal of the folder "{to_remove}" failed. ' - "Please remove manually.\n" - "--------------------------------------------------------" - ) - finally: - self.signals.progressBar.emit(1) - - # Update model's paths - load.migrate_models_paths(self.dst_path) - - # Store user profile data folder path - from . import user_profile_path_txt - - os.makedirs(os.path.dirname(user_profile_path_txt), exist_ok=True) - with open(user_profile_path_txt, "w") as txt: - txt.write(self.dst_path) - - self.finished.emit(self) - - -class DelObjectsOutsideSegmROIWorker(QObject): - finished = Signal(object) - critical = Signal(object) - progress = Signal(str) - debug = Signal(object) - - def __init__( - self, - segm_roi_endname: os.PathLike, - segm_data: np.ndarray, - images_path: os.PathLike, - ): - QObject.__init__(self) - self.signals = signals() - self.segm_roi_endname = segm_roi_endname - self.segm_data = segm_data - self.images_path = images_path - - @worker_exception_handler - def run(self): - segm_roi_endname = self.segm_roi_endname - segm_roi_filepath, _ = load.get_path_from_endname( - segm_roi_endname, self.images_path - ) - self.progress.emit(f'Loading segmentation file "{segm_roi_filepath}"...') - segm_roi_data = load.load_image_file(segm_roi_filepath) - - self.progress.emit(f"Deleting objects outside of selected ROIs...") - cleared_segm_data, delIDs = transformation.del_objs_outside_segm_roi( - segm_roi_data, self.segm_data - ) - - self.finished.emit((self, cleared_segm_data, delIDs)) - - -class ConcatSpotmaxDfsWorker(BaseWorkerUtil): - sigAborted = Signal() - sigAskFolder = Signal(str) - sigSetMeasurements = Signal(object) - sigAskAppendName = Signal(str, list) - - def __init__(self, mainWin, format="CSV"): - super().__init__(mainWin) - if format.startswith("CSV"): - self._final_ext = ".csv" - elif format.startswith("XLS"): - self._final_ext = ".xlsx" - self.acdcOutputEndname = None - - def emitSetMeasurements(self, kwargs): - self.mutex.lock() - self.sigSetMeasurements.emit(kwargs) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def emitAskAppendName(self, allPos_spotmax_df_basename): - # Ask appendend name - self.mutex.lock() - self.sigAskAppendName.emit(allPos_spotmax_df_basename, []) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def emitAskCopyCca(self, images_path): - self.mutex.lock() - self.signals.sigAskCopyCca.emit(images_path) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def setAcdcOutputEndname(self, acdcOutputEndname): - self.acdcOutputEndname = acdcOutputEndname - - def getAcdcDf(self, images_path): - if self.acdcOutputEndname is None: - return - - for file in myutils.listdir(images_path): - if not file.endswith(self.acdcOutputEndname): - continue - - filepath = os.path.join(images_path, file) - acdc_df = pd.read_csv(filepath, index_col=["frame_i", "Cell_ID"]) - return acdc_df - - def copyCcaColsFromAcdcDf(self, df, acdc_df, debug=False): - if acdc_df is None: - return df - - if debug: - printl(acdc_df.columns.to_list(), pretty=True) - - idx = df.index.intersection(acdc_df.index) - for col in cca_df_colnames: - if col not in acdc_df.columns: - continue - - if col not in self.selectedColumns: - continue - - df.loc[idx, col] = acdc_df.loc[idx, col] - - for col in lineage_tree_cols: - if col not in acdc_df.columns: - continue - - if col not in self.selectedColumns: - continue - - df.loc[idx, col] = acdc_df.loc[idx, col] - - for col in default_annot_df.keys(): - if col not in acdc_df.columns: - continue - - if col not in self.selectedColumns: - continue - - df.loc[idx, col] = acdc_df.loc[idx, col] - - for col in self.selectedColumns: - if col not in acdc_df.columns: - continue - - df.loc[idx, col] = acdc_df.loc[idx, col] - - if debug and col == "cell_vol_fl": - printl(df[[col]]) - - return df - - def emitAskFolderWhereToSaveMultiExp(self): - self.mutex.lock() - self.sigAskFolder.emit("") - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - self.sigAborted.emit() - return - - return self.allExpSaveFolder - - def askSelectMeasurements(self, exp_path, posFoldernames): - acdc_dfs = [] - keys = [] - for p, pos in enumerate(posFoldernames): - if self.abort: - self.sigAborted.emit() - return False - - images_path = os.path.join(exp_path, pos, "Images") - acdc_df = self.getAcdcDf(images_path) - if acdc_df is None: - continue - - acdc_dfs.append(acdc_df) - keys.append(pos) - - if not acdc_dfs: - return True - - acdc_df_allpos = pd.concat( - acdc_dfs, keys=keys, names=["Position_n", "frame_i", "Cell_ID"] - ) - acdc_df_allpos["experiment_folderpath"] = exp_path - basename, chNames = myutils.getBasenameAndChNames( - images_path, useExt=(".tif", ".h5") - ) - df_metadata = load.load_metadata_df(images_path) - SizeZ = df_metadata.at["SizeZ", "values"] - SizeZ = int(float(SizeZ)) - existing_colnames = acdc_df_allpos.columns - isSegm3D = any([col.endswith("3D") for col in existing_colnames]) - - kwargs = { - "loadedChNames": chNames, - "notLoadedChNames": [], - "isZstack": SizeZ > 1, - "isSegm3D": isSegm3D, - "existing_colnames": existing_colnames, - } - self.emitSetMeasurements(kwargs) - if self.abort: - self.sigAborted.emit() - return False - - return True - - @worker_exception_handler - def run(self): - from spotmax import DFs_FILENAMES, DF_REF_CH_FILENAME - from spotmax.utils import get_runs_num_and_desc - import spotmax.io - - self.selectedColumns = None - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - spotmax_dfs_spots_allexp = defaultdict(lambda: defaultdict(list)) - spotmax_dfs_aggr_allexp = defaultdict(lambda: defaultdict(list)) - ref_ch_dfs_allexp = defaultdict(lambda: defaultdict(list)) - runNumberAlreadyAsked = False - copyFromCcaAlreadyAsked = False - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - all_runs = get_runs_num_and_desc(exp_path, pos_foldernames=pos_foldernames) - if not all_runs: - self.logger.log( - "[WARNING] The following experiment does not contain " - f'valid spotMAX output files. Skipping it. "{exp_path}"' - ) - continue - - if not runNumberAlreadyAsked: - abort = self.emitSelectSpotmaxRun( - exp_path, - pos_foldernames, - all_runs, - infoText=" to combine", - allowSingleSelection=True, - multiSelection=False, - ) - if abort: - self.sigAborted.emit() - return - runNumberAlreadyAsked = True - - selectedSpotmaxRuns = self.mainWin.selectedSpotmaxRuns - - self.signals.initProgressBar.emit(len(pos_foldernames)) - dfs_spots = defaultdict(list) - dfs_aggr = defaultdict(list) - dfs_ref_ch = defaultdict(list) - pos_runs = defaultdict(list) - pos_runs_ref_ch = defaultdict(list) - pos_ini_filepaths = {} - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.sigAborted.emit() - return - - pos_path = os.path.join(exp_path, pos) - spotmax_output_path = os.path.join(pos_path, "spotMAX_output") - - if not os.path.exists(spotmax_output_path): - self.logger.log( - "[WARNING] The following Position folder does not contain " - f'valid spotMAX output files. Skipping it. "{pos_path}"' - ) - continue - - images_path = os.path.join(exp_path, pos, "Images") - - if not copyFromCcaAlreadyAsked: - self.emitAskCopyCca(images_path) - if self.abort: - self.sigAborted.emit() - return - - self.askSelectMeasurements(exp_path, pos_foldernames) - if self.abort: - return - copyFromCcaAlreadyAsked = True - - acdc_df = self.getAcdcDf(images_path) - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - for run_desc in selectedSpotmaxRuns: - run, desc = run_desc.split("_...") - ini_filename = f"{run}_analysis_parameters{desc}.ini" - ini_filepath = os.path.join(spotmax_output_path, ini_filename) - if not os.path.exists(ini_filepath): - self.logger.log( - "[WARNING] The following Position folder does not contain " - f"the spotMAX output file for run number {run}. " - f'Skipping it. "{pos_path}"' - ) - continue - - pos_ini_filepaths[(run, desc)] = ini_filepath - for _, pattern_filename in DFs_FILENAMES.items(): - run_filename = pattern_filename.replace("*rn*", run) - run_filename = run_filename.replace("*desc*", desc) - aggr_filename = f"{run_filename}_aggregated.csv" - aggr_filepath = os.path.join(spotmax_output_path, aggr_filename) - if not os.path.exists(aggr_filepath): - continue - - df_spots_filename = f"{run_filename}.h5" - spots_filepath = os.path.join( - spotmax_output_path, df_spots_filename - ) - ext_spots = ".h5" - if not os.path.exists(spots_filepath): - df_spots_filename = f"{run_filename}.csv" - spots_filepath = os.path.join( - spotmax_output_path, df_spots_filename - ) - ext_spots = ".csv" - - if not os.path.exists(spots_filepath): - continue - - analysis_step = re.findall( - r"\*rn\*(.*)\*desc\*", pattern_filename - )[0] - key = (run, analysis_step, desc, ext_spots) - try: - df_spots = ( - spotmax.io.load_spots_table( - spotmax_output_path, df_spots_filename - ) - .reset_index() - .set_index(["frame_i", "Cell_ID"]) - ) - df_spots = self.copyCcaColsFromAcdcDf( - df_spots, acdc_df, debug=False - ) - df_spots = df_spots.reset_index().set_index( - ["frame_i", "Cell_ID", "spot_id"] - ) - dfs_spots[key].append(df_spots) - except Exception as err: - self.logger.log(str(err), level="ERROR") - self.logger.log( - "WARNING: Error when reading single-spots " - "tables (possibly because there are no spots). " - "Skipping this Position.", - level="WARNING", - ) - pass - - df_aggregated = pd.read_csv( - aggr_filepath, index_col=["frame_i", "Cell_ID"] - ) - df_aggregated = self.copyCcaColsFromAcdcDf( - df_aggregated, acdc_df - ) - dfs_aggr[key].append(df_aggregated) - pos_runs[key].append(pos) - - ref_ch_id_text = re.findall( - r"\*rn\*(.*)\*desc\*", DF_REF_CH_FILENAME - )[0] - ref_ch_filename = DF_REF_CH_FILENAME.replace("*rn*", run) - ref_ch_filename = ref_ch_filename.replace("*desc*", desc) - ref_ch_filepath = os.path.join(spotmax_output_path, ref_ch_filename) - if not os.path.exists(ref_ch_filepath): - continue - - df_ref_ch = pd.read_csv( - ref_ch_filepath, index_col=["frame_i", "Cell_ID"] - ) - df_ref_ch = self.copyCcaColsFromAcdcDf(df_ref_ch, acdc_df) - ref_ch_key = (run, ref_ch_id_text, desc) - dfs_ref_ch[ref_ch_key].append(df_ref_ch) - pos_runs_ref_ch[ref_ch_key].append(pos) - - self.signals.progressBar.emit(1) - - self.signals.initProgressBar.emit(0) - - self.logger.log("Saving concantenated files...") - - allpos_folderpath = os.path.join(exp_path, "spotMAX_multipos_output") - os.makedirs(allpos_folderpath, exist_ok=True) - - exp_name = os.path.basename(exp_path) - for key, dfs in dfs_spots.items(): - pos_keys = pos_runs[key] - run, analysis_step, desc, ext_spots = key - - if ext_spots == ".csv": - ext_spots = self._final_ext - filename = f"multipos_{run}{analysis_step}{desc}{ext_spots}" - all_exp_key = filename - df_spots_concat = spotmax.io.save_concat_dfs( - dfs, - pos_keys, - allpos_folderpath, - filename, - ext_spots, - names=["Position_n"], - return_concat_df=True, - ) - df_spots_concat["experiment_foldername"] = exp_name - df_spots_concat["experiment_folderpath"] = exp_path - spotmax_dfs_spots_allexp[all_exp_key]["dfs"].append(df_spots_concat) - spotmax_dfs_spots_allexp[all_exp_key]["keys"].append(exp_path) - ini_filepath = pos_ini_filepaths[(run, desc)] - ini_filename = os.path.basename(ini_filepath) - dst_ini_filepath = os.path.join(allpos_folderpath, ini_filename) - if not os.path.exists(dst_ini_filepath): - shutil.copy2(ini_filepath, dst_ini_filepath) - - spotmax_dfs_spots_allexp[all_exp_key]["ini_filepath"].append( - dst_ini_filepath - ) - - for key, dfs in dfs_aggr.items(): - pos_keys = pos_runs[key] - run, analysis_step, desc, _ = key - filename = ( - f"multipos_{run}{analysis_step}{desc}_aggregated{self._final_ext}" - ) - all_exp_aggr_key = filename - df_aggr_concat = spotmax.io.save_concat_dfs( - dfs, - pos_keys, - allpos_folderpath, - filename, - self._final_ext, - names=["Position_n"], - return_concat_df=True, - ) - spotmax_dfs_aggr_allexp[all_exp_aggr_key]["dfs"].append(df_aggr_concat) - spotmax_dfs_aggr_allexp[all_exp_aggr_key]["keys"].append( - (exp_path, exp_name) - ) - - for key, dfs in dfs_ref_ch.items(): - run, ref_ch_id_text, desc = key - pos_keys = pos_runs_ref_ch[key] - filename = f"multipos_{run}{ref_ch_id_text}{desc}{self._final_ext}" - all_exp_ref_ch_key = filename - df_ref_ch_concat = spotmax.io.save_concat_dfs( - dfs, - pos_keys, - allpos_folderpath, - filename, - self._final_ext, - names=["Position_n"], - return_concat_df=True, - ) - ref_ch_dfs_allexp[all_exp_ref_ch_key]["dfs"].append(df_ref_ch_concat) - ref_ch_dfs_allexp[all_exp_ref_ch_key]["keys"].append( - (exp_path, exp_name) - ) - - multiexp_dst_folderpath = "" - if len(expPaths) == 1: - self.signals.finished.emit(self) - return - - multiexp_dst_folderpath = self.emitAskFolderWhereToSaveMultiExp() - printl(multiexp_dst_folderpath) - if multiexp_dst_folderpath is None: - return - - self.logger.log( - f'Saving multi-experiment files to "{multiexp_dst_folderpath}"...' - ) - names = ["experiment_folderpath", "experiment_foldername"] - for filename, items in spotmax_dfs_spots_allexp.items(): - keys = items["keys"] - dfs = items["dfs"] - multiexp_filename = f"multiexp_{filename}" - extension = os.path.splitext(filename)[-1] - spotmax.io.save_concat_dfs( - dfs, - keys, - multiexp_dst_folderpath, - multiexp_filename, - extension, - names=["experiment_folderpath"], - ) - ini_filepath = items["ini_filepath"][0] - ini_filename = os.path.basename(ini_filepath) - dst_ini_filepath = os.path.join(multiexp_dst_folderpath, ini_filename) - if not os.path.exists(dst_ini_filepath): - shutil.copy2(ini_filepath, dst_ini_filepath) - - for filename, items in spotmax_dfs_aggr_allexp.items(): - keys = items["keys"] - dfs = items["dfs"] - printl(keys, pretty=True) - multiexp_filename = f"multiexp_{filename}" - extension = os.path.splitext(filename)[-1] - spotmax.io.save_concat_dfs( - dfs, - keys, - multiexp_dst_folderpath, - multiexp_filename, - extension, - names=names, - ) - - for filename, items in ref_ch_dfs_allexp.items(): - keys = items["keys"] - dfs = items["dfs"] - multiexp_filename = f"multiexp_{filename}" - extension = os.path.splitext(filename)[-1] - spotmax.io.save_concat_dfs( - dfs, - keys, - multiexp_dst_folderpath, - multiexp_filename, - extension, - names=names, - ) - - self.signals.finished.emit(self) - - -class FilterObjsFromCoordsTable(BaseWorkerUtil): - sigAskAppendName = Signal(str, list) - sigAborted = Signal() - sigSetColumnsNames = Signal(object, object, object) - - def __init__(self, mainWin): - super().__init__(mainWin) - - def emitSetColumnsNames(self, columns, categories, optionalCategories): - self.mutex.lock() - self.sigSetColumnsNames.emit(columns, categories, optionalCategories) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def getColumnsCategories( - self, df_coords, exp_path, pos_foldernames, endFilenameSegm - ): - columns = df_coords.columns.to_list() - categories = ["X coord. column", "Y coord. column"] - optionalCategories = [] - - images_path = os.path.join(exp_path, pos_foldernames[0], "Images") - metadata_df = load.load_metadata_df(images_path) - SizeT = float(metadata_df.at["SizeT", "values"]) - SizeZ = float(metadata_df.at["SizeZ", "values"]) - - segmData = load.load_segm_file(images_path, end_name_segm_file=endFilenameSegm) - - if segmData.ndim == 4: - categories.append("Z coord. column") - categories.append("Frame index column") - elif segmData.ndim == 3: - if SizeZ > 1 and SizeT == 1: - # 3D z-stack data - categories.append("Z coord. column") - else: - optionalCategories.append("Z coord. column") - - if SizeT > 1: - # 3D time-lapse - categories.append("Frame index column") - else: - optionalCategories.append("Frame index column") - else: - optionalCategories.append("Z coord. column") - optionalCategories.append("Frame index column") - - if len(pos_foldernames) > 1: - categories.append("Position_n") - else: - optionalCategories.append("Position_n") - - return columns, categories, optionalCategories - - def getDfCoords( - self, df_coords, selectedColumnsPerCategory, pos_foldername, frame_i - ): - pos_col = selectedColumnsPerCategory.get("Position_n", "None") - frame_i_col = selectedColumnsPerCategory.get("Frame index column", "None") - x_col = selectedColumnsPerCategory["X coord. column"] - y_col = selectedColumnsPerCategory["Y coord. column"] - if pos_col != "None": - df_coords = df_coords[df_coords[pos_col] == pos_foldername] - if frame_i_col != "None": - df_coords = df_coords[df_coords[frame_i_col] == frame_i] - - xy_cols = [x_col, y_col] - - df_out = pd.DataFrame( - index=df_coords.index, data=df_coords[xy_cols].values, columns=["x", "y"] - ) - z_col = selectedColumnsPerCategory.get("Z coord. column", "None") - if z_col != "None": - df_out["z"] = df_coords[z_col] - - return df_out - - @worker_exception_handler - def run(self): - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - self.mainWin.infoText = f"Select segmentation file to filter" - abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) - if abort: - self.sigAborted.emit() - return - endFilenameSegm = self.mainWin.endFilenameSegm - - self.logger.log("Asking to select the CSV table file...") - - abort = self.emitSelectFile( - exp_path, - "Select CSV table file with coordinates to filter", - "CSV (*.csv)", - ) - if abort: - self.sigAborted.emit() - return - - self.logger.log(f"Loading table file `{self.mainWin.selectedFilepath}`..") - df_coords = pd.read_csv(self.mainWin.selectedFilepath) - - columns, categories, optionalCategories = self.getColumnsCategories( - df_coords, exp_path, pos_foldernames, endFilenameSegm - ) - - abort = self.emitSetColumnsNames(columns, categories, optionalCategories) - if abort: - self.sigAborted.emit() - return - - selectedColumnsPerCategory = self.mainWin.selectedColumnsPerCategory - - # Ask appendend name - self.mutex.lock() - self.sigAskAppendName.emit( - self.mainWin.endFilenameSegm, self.mainWin.existingSegmEndNames - ) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - self.sigAborted.emit() - return - - appendedName = self.appendedName - self.signals.initProgressBar.emit(len(pos_foldernames)) - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.sigAborted.emit() - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - images_path = os.path.join(exp_path, pos, "Images") - ls = myutils.listdir(images_path) - file_path = [ - os.path.join(images_path, f) - for f in ls - if f.endswith(f"{endFilenameSegm}.npz") - ][0] - - posData = load.loadData(file_path, "") - - self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") - - posData.getBasenameAndChNames() - posData.buildPaths() - - posData.loadOtherFiles( - load_segm_data=True, - load_acdc_df=True, - load_metadata=True, - end_filename_segm=endFilenameSegm, - ) - if posData.SizeT == 1: - posData.segm_data = posData.segm_data[np.newaxis] - - self.logger.log("Filtering objects...") - - numFrames = len(posData.segm_data) - self.signals.sigInitInnerPbar.emit(numFrames) - filteredSegmData = np.zeros_like(posData.segm_data) - for frame_i, lab in enumerate(posData.segm_data): - df_coords_frame_i = self.getDfCoords( - df_coords, selectedColumnsPerCategory, pos, frame_i - ) - if df_coords_frame_i.empty: - num_frames_missing = len(posData.segm_data[frame_i:]) - self.signals.sigUpdateInnerPbar.emit(num_frames_missing) - filteredSegmData = filteredSegmData[:frame_i] - break - - filtered_lab = core.filter_segm_objs_from_table_coords( - lab, df_coords_frame_i - ) - filteredSegmData[frame_i] = filtered_lab - - self.signals.sigUpdateInnerPbar.emit(1) - - self.logger.log("Saving filtered segmentation file...") - segmFilename, ext = os.path.splitext(posData.segm_npz_path) - newSegmFilepath = f"{segmFilename}_{appendedName}.npz" - filteredSegmData = np.squeeze(filteredSegmData) - io.savez_compressed(newSegmFilepath, filteredSegmData) - - self.signals.progressBar.emit(1) - - self.signals.finished.emit(self) - - -class ScreenRecorderWorker(QObject): - sigGrabScreen = Signal() - finished = Signal() - - def __init__(self, screenRecorderWin, folder_path): - QObject.__init__(self) - self.screenRecorderWin = screenRecorderWin - self.folder_path = folder_path - - def run(self): - for i in range(4): - fn = f"shot_{i:03}.jpg" - grab_path = os.path.join(self.folder_path, fn) - screen = self.screenRecorderWin.screen() - screenshot = screen.grabWindow(self.screenRecorderWin.winId()) - screenshot.save(grab_path, "jpg") - print(grab_path) - time.sleep(0.2) - - self.finished.emit() - - -class CcaIntegrityCheckerWorker(QObject): - finished = Signal(object) - critical = Signal(object) - progress = Signal(str, object) - sigDone = Signal() - sigWarning = Signal(str, str) - sigFixWillDivide = Signal(str, list) - - def __init__(self, mutex, waitCond): - QObject.__init__(self) - self.logger = workerLogger(self.progress) - self.mutex = mutex - self.waitCond = waitCond - self.exit = False - self.isFinished = False - self.abortChecking = False - self.isChecking = False - self.isPaused = False - self.debug = False - self.dataQ = deque(maxlen=10) - - def pause(self): - if self.debug: - self.logger.log("Cell cycle annotations checker is idle.") - self.mutex.lock() - self.isPaused = True - self.waitCond.wait(self.mutex) - self.mutex.unlock() - self.isPaused = False - - def enqueue(self, posData): - # First stop previous checking - if self.isChecking: - self.abortChecking = True - self._enqueue(posData) - - def _enqueue(self, posData): - if self.debug: - self.logger.log("Enqueing posData...") - self.dataQ.append(posData) - if len(self.dataQ) == 1: - # Wake worker upon inserting first element - self.abortChecking = False - self.waitCond.wakeAll() - - def clearQueue(self): - self.dataQ.clear() - - def _stop(self): - self.exit = True - self.waitCond.wakeAll() - - def abort(self): - self.abortChecking = True - while not len(self.dataQ) == 0: - data = self.dataQ.pop() - del data - self._stop() - - def _check_equality_num_mothers_buds_in_S(self, checker, frame_i): - num_moth_S, num_buds = checker.get_num_mothers_and_buds_in_S() - - if num_moth_S == num_buds: - return True - - category = "number of buds different from number of mothers in S phase" - ul_items = [ - f"Number of buds = {num_buds}", - f"Number of mothers in S phase = {num_moth_S}", - ] - txt = html_utils.paragraph( - f"At frame n. {frame_i + 1} the number of buds and number of " - "mother cells in S phase are different!" - f"{html_utils.to_list(ul_items)}" - ) - self.sigWarning.emit(txt, category) - return False - - def _check_mothers_multiple_buds(self, checker, frame_i): - mother_IDs_with_multiple_buds = checker.get_mother_IDs_with_multiple_buds() - if len(mother_IDs_with_multiple_buds) == 0: - return True - - category = "mother cells with multiple buds" - txt = html_utils.paragraph( - f"At frame n. {frame_i + 1} " - "the following mother cells have multiple buds assigned to it" - f"

    {mother_IDs_with_multiple_buds}" - ) - self.sigWarning.emit(txt, category) - return False - - def _check_cells_without_G1(self, checker, global_cca_df): - IDs_cycles_without_G1 = checker.get_IDs_cycles_without_G1(global_cca_df) - if len(IDs_cycles_without_G1) == 0: - return True - - category = "cell cycles without G1" - txt = html_utils.paragraph( - "Cell-ACDC requires that every cell cycle has at least " - "one frame in G1.
    " - "The following pairs of (ID, generation number) " - "do not satisfy this condition:

    " - f"{IDs_cycles_without_G1}" - ) - self.sigWarning.emit(txt, category) - return False - - def _check_will_divide_is_true(self, checker, global_cca_df): - # NOTE: unfortunately this function performs pandas manipulations - # that are either not thread-safe or in any case are freezing the - # GUI. For now we don't run this until we find a solution - return True - - IDs_will_divide_wrong = checker.get_IDs_gen_num_will_divide_wrong(global_cca_df) - if len(IDs_will_divide_wrong) == 0: - return True - - txt = html_utils.paragraph( - "Cell-ACDC found that `will_divide` is annotated as True on the " - "following (ID, generation number) cell
    " - "despite the fact that division is still not annotated on " - "these cells

    :" - f"{IDs_will_divide_wrong}" - ) - self.sigFixWillDivide.emit(txt, IDs_will_divide_wrong) - return False - - def _check_buds_gen_num_zero(self, checker, frame_i): - bud_IDs_gen_num_nonzero = checker.get_bud_IDs_gen_num_nonzero() - if len(bud_IDs_gen_num_nonzero) == 0: - return True - - category = "buds whose generation number is not zero" - txt = html_utils.paragraph( - f"At frame n. {frame_i + 1} " - "the following bud IDs have generation number different from 0:" - f"

    {bud_IDs_gen_num_nonzero}" - ) - self.sigWarning.emit(txt, category) - return False - - def _check_mothers_gen_num_greater_one(self, checker, frame_i): - moth_IDs_gen_num_non_greater_one = ( - checker.get_moth_IDs_gen_num_non_greater_one() - ) - if len(moth_IDs_gen_num_non_greater_one) == 0: - return True - - category = "mothers whose generation number is < 1" - txt = html_utils.paragraph( - f"At frame n. {frame_i + 1} " - "the following mother cells have generation number < 1:" - f"

    {moth_IDs_gen_num_non_greater_one}" - ) - self.sigWarning.emit(txt, category) - return False - - def _check_buds_G1(self, checker, frame_i): - buds_G1 = checker.get_buds_G1() - if len(buds_G1) == 0: - return True - - category = "buds in G1" - txt = html_utils.paragraph( - f"At frame n. {frame_i + 1} " - "the following bud IDs are in G1 (buds must be in S):" - f"

    {buds_G1}" - ) - self.sigWarning.emit(txt, category) - return False - - def _check_cell_S_rel_ID_zero(self, checker, frame_i): - cell_S_rel_ID_zero = checker.get_cell_S_rel_ID_zero() - if len(cell_S_rel_ID_zero) == 0: - return True - - category = "buds in G1" - txt = html_utils.paragraph( - f"At frame n. {frame_i + 1} " - "the following cell IDs in S phase do not have " - "relative_ID > 0:" - f"

    {cell_S_rel_ID_zero}" - ) - self.sigWarning.emit(txt, category) - return False - - def _check_ID_rel_ID_mismatches(self, checker, frame_i): - ID_rel_ID_mismatches = checker.get_ID_rel_ID_mismatches() - if len(ID_rel_ID_mismatches) == 0: - return True - - items = [ - f"Cell ID {ID} has relative ID = {relID}, " - f"while cell ID {relID} has relative ID = {relID_of_relID}" - for ID, relID, relID_of_relID in ID_rel_ID_mismatches - ] - category = "`ID-relative_ID` mismatches" - txt = html_utils.paragraph( - f"At frame n. {frame_i + 1} " - "there are the following `ID-relative_ID` mismatches:" - f"{html_utils.to_list(items)}" - ) - self.sigWarning.emit(txt, category) - return False - - def _check_lonely_cells_in_S(self, checker, frame_i): - lonely_cells_in_S = checker.get_lonely_cells_in_S() - if len(lonely_cells_in_S) == 0: - return True - - category = "Lovely cells in S phase" - txt = html_utils.paragraph( - f"At frame n. {frame_i + 1} " - "the following cell IDs are in `S` phase but their `relative_ID` " - f"does not exist:

    " - f"{lonely_cells_in_S}" - ) - self.sigWarning.emit(txt, category) - return False - - def _get_cca_df_copy(self, acdc_df): - try: - cca_df = pd.DataFrame( - data=acdc_df[cca_df_colnames].values, - columns=cca_df_colnames, - index=acdc_df.index, - ) - return cca_df - except KeyError as error: - return - - def check(self, posData): - self.isChecking = True - checkpoints = ( - "_check_lonely_cells_in_S", - "_check_equality_num_mothers_buds_in_S", - "_check_mothers_multiple_buds", - "_check_buds_gen_num_zero", - "_check_mothers_gen_num_greater_one", - "_check_buds_G1", - "_check_cell_S_rel_ID_zero", - "_check_ID_rel_ID_mismatches", - ) - cca_dfs = [] - keys = [] - check_integrity_globally = True - for frame_i, data_dict in enumerate(posData.allData_li): - if self.abortChecking: - check_integrity_globally = False - break - - lab = data_dict["labels"] - if lab is None: - break - - cca_df = data_dict.get("cca_df_checker") - if cca_df is None: - # There are no annotations at frame_i --> stop - break - - IDs = data_dict["IDs"] - checker = core.CcaIntegrityChecker(cca_df, lab, IDs) - - for checkpoint in checkpoints: - proceed = getattr(self, checkpoint)(checker, frame_i) - if not proceed: - break - - if not proceed: - check_integrity_globally = False - break - - cca_dfs.append(cca_df) - keys.append(frame_i) - - if check_integrity_globally and len(cca_dfs) > 1: - global_checkpoints = [ - "_check_cells_without_G1", - # '_check_will_divide_is_true' - ] - # Check integrity globally - global_cca_df = pd.concat(cca_dfs, keys=keys, names=["frame_i"]) - for checkpoint in global_checkpoints: - proceed = getattr(self, checkpoint)(checker, global_cca_df) - if not proceed: - break - - self.abortChecking = False - self.isChecking = False - time.sleep(1) - - @worker_exception_handler - def run(self): - while True: - if self.exit: - self.logger.log("Closing cell cycle integrity checker worker...") - break - elif not len(self.dataQ) == 0: - if self.debug: - self.logger.log( - "Checking integrity of cell cycle annotations " - f"({len(self.dataQ)})..." - ) - data = self.dataQ.pop() - self.check(data) - if len(self.dataQ) == 0: - self.sigDone.emit() - else: - self.pause() - self.isFinished = True - self.finished.emit(self) - - -class ApplyImageFilterWorker(QObject): - finished = Signal(object) - critical = Signal(object) - progress = Signal(str) - - def __init__(self, filter_func, input_data): - QObject.__init__(self) - self.filter_func = filter_func - self.input_data = input_data - - @worker_exception_handler - def run(self): - self.progress.emit("Filtering image...") - filtered_data = self.filter_func(self.input_data) - self.finished.emit(filtered_data) - - -class MoveTempFilesWorker(QObject): - def __init__(self, temp_files_to_move: Dict[os.PathLike, os.PathLike]): - QObject.__init__(self) - self.signals = signals() - self.logger = workerLogger(self.signals.progress) - self.temp_files_to_move = temp_files_to_move - - @worker_exception_handler - def run(self): - for src, dst in self.temp_files_to_move.items(): - self.logger.log(f"Saving channel data to: {dst}...") - shutil.move(src, dst) - tempDir = os.path.dirname(src) - shutil.rmtree(tempDir) - self.signals.progressBar.emit(1) - self.signals.finished.emit(self) - - -class ResizeUtilWorker(BaseWorkerUtil): - sigSetResizeProps = Signal(str) - - def emitSetResizeProps(self, input_path): - self.mutex.lock() - self.sigSetResizeProps.emit(input_path) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def __init__(self, mainWin): - super().__init__(mainWin) - - def validateOutputPath(self, path): - if path is None: - return - - images_path = myutils.validate_images_path(path, create_dirs_tree=True) - return images_path - - @worker_exception_handler - def run(self): - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - abort = self.emitSetResizeProps(exp_path) - if abort: - self.signals.finished.emit(self) - return - - tot_pos = len(pos_foldernames) - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.signals.finished.emit(self) - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - images_path = os.path.join(exp_path, pos, "Images") - - rf = self.resizeFactor - text_to_append = self.textToAppend - images_path_out = self.validateOutputPath(self.expFolderpathOut) - if images_path_out is None: - images_path_out = images_path - resize.run( - images_path, - rf, - text_to_append=text_to_append, - images_path_out=images_path_out, - ) - - self.signals.finished.emit(self) - - -class FucciPreprocessWorker(BaseWorkerUtil): - sigAskAppendName = Signal(str) - sigAskParams = Signal(object, object) - sigAborted = Signal() - - def __init__(self, mainWin): - super().__init__(mainWin) - - def emitAskParams(self, exp_path, pos_foldernames): - self.mutex.lock() - self.sigAskParams.emit(exp_path, pos_foldernames) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def applyPipeline(self, first_ch_data, second_ch_data, filter_kwargs): - processed_data = np.zeros(first_ch_data.shape, dtype=np.uint8) - pbar = tqdm(total=len(processed_data), ncols=100) - with concurrent.futures.ThreadPoolExecutor() as executor: - iterable = enumerate(zip(first_ch_data, second_ch_data)) - func = partial(core.fucci_pipeline_executor_map, **filter_kwargs) - result = executor.map(func, iterable) - for frame_i, processed_img in result: - processed_img = skimage.exposure.rescale_intensity( - processed_img, out_range=(0, 255) - ) - processed_img = processed_img.astype(np.uint8) - processed_data[frame_i] = processed_img - pbar.update() - pbar.close() - - return processed_data - - @worker_exception_handler - def run(self): - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - self.mainWin.infoText = f"Setup parameters" - - if i == 0: - abort = self.emitAskParams(exp_path, pos_foldernames) - if abort: - self.sigAborted.emit() - return - - # Ask appendend name - self.mutex.lock() - self.sigAskAppendName.emit(self.basename) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - self.sigAborted.emit() - return - - appendedName = self.appendedName - self.signals.initProgressBar.emit(len(pos_foldernames)) - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.sigAborted.emit() - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - images_path = os.path.join(exp_path, pos, "Images") - - self.logger.log(f"Loading {self.firstChannelName} channel data...") - first_ch_filepath = load.get_filename_from_channel( - images_path, self.firstChannelName - ) - first_ch_data = load.load_image_file(first_ch_filepath) - - self.logger.log(f"Loading {self.secondChannelName} channel data...") - second_ch_filepath = load.get_filename_from_channel( - images_path, self.secondChannelName - ) - second_ch_data = load.load_image_file(second_ch_filepath) - - self.logger.log("Applying FUCCI pre-processing pipeline...\n") - processed_data = self.applyPipeline( - first_ch_data, second_ch_data, self.fucciFilterKwargs - ) - - basename, chNames = myutils.getBasenameAndChNames(images_path) - _, ext = os.path.splitext(first_ch_filepath) - processed_filename = f"{basename}{appendedName}{ext}" - processed_filepath = os.path.join(images_path, processed_filename) - self.logger.log( - f'Saving pre-processed images to "{processed_filepath}"...' - ) - io.save_image_data(processed_filepath, processed_data) - - self.signals.progressBar.emit(1) - - self.signals.finished.emit(self) - - -class SimpleWorker(QObject): - def __init__(self, posData, func, func_args=None, func_kwargs=None): - QObject.__init__(self) - self.posData = posData - self.signals = signals() - self.output = {} - - if func_args is None: - func_args = [] - - if func_kwargs is None: - func_kwargs = {} - - self.func = func - self.func_args = func_args - self.func_kwargs = func_kwargs - self.posData = posData - - @worker_exception_handler - def run(self): - self.result = self.func(self.posData, *self.func_args, **self.func_kwargs) - self.signals.finished.emit(self.output) - - -class CopyAllLostObjectsWorker(QObject): - navigateToFrame = Signal(int) - returnToFrame = Signal(int) - copyLostObjectMask = Signal(int) - refreshRp = Signal() - progressBar = Signal(int) - finished = Signal(object) - critical = Signal(object) - - def __init__(self, gui, posData, for_future_frame_n, max_overlap_perc): - super().__init__() - self.gui = gui - self.posData = posData - self.for_future_frame_n = for_future_frame_n - self.max_overlap_perc = max_overlap_perc - - @worker_exception_handler - def run(self): - current_frame_i = self.posData.frame_i - last_visited_frame_i = self.gui.get_last_tracked_i() - last_copied_frame_i = current_frame_i + self.for_future_frame_n + 1 - frames_range = (current_frame_i, last_copied_frame_i) - overlap_warning = False - output = {} - - for frame_i in range(*frames_range): - if frame_i == self.posData.SizeT: - break - - if frame_i > self.posData.frame_i: - # Main thread navigates, runs tracking, updates rp/IDs, etc - self.navigateToFrame.emit(frame_i) - - for lostObj in skimage.measure.regionprops(self.gui.lostObjImage): - overlap = np.count_nonzero( - self.gui.currentLab2D[lostObj.slice][lostObj.image] - ) - overlap_perc = overlap / lostObj.area * 100 - if overlap_perc > self.max_overlap_perc: - overlap_warning = True - continue - - self.copyLostObjectMask.emit(lostObj.label) - - # Refresh rp so the next frame's updateLostNewCurrentIDs sees the - # copied IDs as belonging to this frame and marks them lost there. - self.refreshRp.emit() - - self.progressBar.emit(1) - - if self.for_future_frame_n == 0: - output["overlap_warning"] = overlap_warning - self.finished.emit(output) - return - - # Back to current frame - self.returnToFrame.emit(current_frame_i) - - if last_visited_frame_i < last_copied_frame_i: - output["doReinitLastSegmFrame"] = True - output["last_visited_frame_i"] = last_visited_frame_i - - output["overlap_warning"] = overlap_warning - self.finished.emit(output) - - -class SaveProcessedDataWorker(QObject): - def __init__( - self, - allPosData: Iterable["load.loadData"], - appended_text_filename: str, - ext: str = None, - ): - QObject.__init__(self) - self.allPosData = allPosData - self.signals = signals() - self.logger = workerLogger(self.signals.progress) - self.appended_text_filename = appended_text_filename - self.ext = ext - - @worker_exception_handler - def run(self): - self.signals.initProgressBar.emit(0) - for posData in self.allPosData: - ext_loc = self.ext if self.ext is not None else posData.ext - processed_filename = ( - f"{posData.basename}{posData.user_ch_name}_" - f"{self.appended_text_filename}{ext_loc}" - ) - processed_filepath = os.path.join(posData.images_path, processed_filename) - self.logger.log(f"Saving {processed_filepath}...") - processed_data = posData.preprocessedDataArray() - if processed_data is None: - self.logger.log( - f"[WARNING]: {posData.pos_foldername} does not have " - "preprocessed data. Skipping it." - ) - continue - - io.save_image_data(processed_filepath, processed_data) - - self.signals.finished.emit(self) - - -class SaveCombinedChannelsWorker(QObject): - sigDebugShowImg = Signal(object) - - def __init__( - self, allPosData: Iterable["load.loadData"], filename: str, debug: bool = False - ): - QObject.__init__(self) - self.allPosData = allPosData - self.signals = signals() - self.logger = workerLogger(self.signals.progress) - self.filename = filename - self.debug = debug - - @worker_exception_handler - def run(self): - self.signals.initProgressBar.emit(0) - for posData in self.allPosData: - processed_filepath = os.path.join(posData.images_path, self.filename) - self.logger.log(f"Saving {processed_filepath}...") - processed_data = posData.combinedChannelsDataArray() - if processed_data is None: - self.logger.log( - f"[WARNING]: {posData.pos_foldername} does not have " - "combined channels data. Skipping it." - ) - continue - if self.debug: - printl(processed_data.shape) - printl(processed_data.dtype) - printl(processed_data.min()) - printl(processed_data.max()) - printl(processed_filepath) - self.sigDebugShowImg.emit(processed_data) - # cellacdc.plot.imshow(processed_data) - io.save_image_data(processed_filepath, processed_data) - - self.signals.finished.emit(self) - - -class CustomPreprocessWorkerGUI(QObject): - sigDone = Signal(object, str) - sigPreviewDone = Signal(object, tuple) - sigIsQueueEmpty = Signal(bool) - - def __init__(self, mutex, waitCond): - QObject.__init__(self) - self.signals = signals() - self.mutex = mutex - self.waitCond = waitCond - self.logger = workerLogger(self.signals.progress) - self.dataQ = deque(maxlen=2) - self.exit = False - self.wait = True - self._abort = False - - def enqueue( - self, - func: Callable, - image: np.ndarray, - recipe: Dict[str, Any], - key: Tuple[int, int, Union[int, str]], - ): - self.dataQ.append((func, image, recipe, key)) - if len(self.dataQ) == 1: - self.sigIsQueueEmpty.emit(False) - # Wake up worker upon inserting first element - self.wakeUp() - - def wakeUp(self): - self.wait = False - self.waitCond.wakeAll() - - def pause(self): - self.wait = True - self.mutex.lock() - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def abort(self): - self._abort = True - - def stop(self): - self.abort() - self.exit = True - self.waitCond.wakeAll() - self.signals.finished.emit(self) - - def setupJob( - self, func: Callable, image_data: np.ndarray, recipe: Dict[str, Any], how: str - ): - self._func = func - self._image_data = image_data - self._recipe = recipe - self._how = how - - def runJob(self, image=None, recipe=None): - if image is None: - image = self._image_data.copy() - if recipe is None: - recipe = self._recipe - - return self.applyRecipe(self._func, image, recipe) - - def applyRecipe( - self, func: Callable, image: np.ndarray, recipe: List[Dict[str, Any]] - ): - preprocessed_data = func(image, recipe) - - keep_input_data_type = recipe[0].get("keep_input_data_type", True) - if not keep_input_data_type: - return preprocessed_data - - try: - preprocessed_data = myutils.convert_to_dtype(preprocessed_data, image.dtype) - except Exception as err: - preprocessed_data = preprocessed_data.astype(image.dtype) - return preprocessed_data - - @worker_exception_handler - def run(self): - while True: - if self.exit: - self.logger.log("Closing pre-processing worker...") - break - elif self.wait: - self.logger.log("Pre-processing worker paused.") - self.pause() - elif len(self.dataQ) > 0: - func, image, recipe, key = self.dataQ.pop() - processed_data = self.applyRecipe(func, image, recipe) - self.sigPreviewDone.emit(processed_data, key) - if len(self.dataQ) == 0: - self.wait = True - self.sigIsQueueEmpty.emit(True) - else: - self.logger.log("Pre-processing worker resumed.") - processed_data = self.runJob() - self.sigDone.emit(processed_data, self._how) - self.wait = True - - self.signals.finished.emit(self) - - -class CombineChannelsWorkerGUI(CustomPreprocessWorkerGUI): - sigDone = Signal(object, list) - sigPreviewDone = Signal(object, list) - sigAskLoadChannels = Signal(set, object) - - def __init__( - self, - mutex, - waitCond, - logger_func: Callable, - ): - # signals_parent=None): - super().__init__(mutex, waitCond) - - self.waitCondLoadFluoChannels = QWaitCondition() - self.logger_func = logger_func - - # if not signals_parent: - # signals_parent = signals() - - # self.signals = signals_parent - - def enqueue( - self, - data, - steps: Dict[str, Any], - key: Tuple[int, int, Union[int, str]], - keep_input_data_type: bool, - output_as_segm: bool, - formula: str, - ): - self.dataQ.append( - (data, steps, key, keep_input_data_type, output_as_segm, formula) - ) - if len(self.dataQ) == 1: - self.sigIsQueueEmpty.emit(False) - # Wake up worker upon inserting first element - self.wakeUp() - - def setupJob( - self, - data: Dict[str, np.ndarray], - steps: Dict[str, Any], - keep_input_data_type: bool, - key: Tuple[Union[int, None], Union[int, None], Union[int, None]], - output_as_segm: bool, - formula: str, - ): - self._key = key - self._steps = steps - self._data = data - self._keep_input_data_type = keep_input_data_type - self._output_as_segm = output_as_segm - self._formula = formula - - def runJob( - self, - data=None, - steps=None, - keep_input_data_type=None, - key=None, - output_as_segm=None, - formula=None, - ): - if data is None: - data = self._data - if steps is None: - steps = self._steps - if keep_input_data_type is None: - keep_input_data_type = self._keep_input_data_type - if key is None: - key = self._key - if output_as_segm is None: - output_as_segm = self._output_as_segm - if formula is None: - formula = self._formula - - if not steps and formula is None: - return - - return self.applySteps( - data, steps, keep_input_data_type, key, output_as_segm, formula=formula - ) - - def applySteps( - self, - data: Dict[str, np.ndarray], - steps: List[Dict[str, Any]], - keep_input_data_type: bool, - key: Tuple[Union[int, None], Union[int, None], Union[int, None]], - output_as_segm: bool, - formula: str, - ): - - new_keys = [] - key = list(key) - if key[0] is None: - pos_number = len(data) - key[0] = list(range(pos_number)) - else: - key[0] = [key[0]] - - for pos_i in key[0]: - new_keys_per_pos = [[pos_i]] - if key[1] is None: - frames = data[pos_i].SizeT - new_keys_per_pos.append(list(range(frames))) - else: - new_keys_per_pos.append([key[1]]) - - if key[2] is None: - z_slices = data[pos_i].SizeZ - if not z_slices: - z_slices = 1 - new_keys_per_pos.append(list(range(z_slices))) - else: - new_keys_per_pos.append([key[2]]) - - new_keys_per_pos = list(itertools.product(*new_keys_per_pos)) - new_keys.extend(new_keys_per_pos) - - output_imgs, out_keys = core.combine_channels_multithread_return_imgs( - steps=steps, - data=data, - keep_input_data_type=keep_input_data_type, - keys=new_keys, - logger_func=self.logger, - signals=self.signals, - output_as_segm=output_as_segm, - formula=formula, - ) - return output_imgs, out_keys - - def requiredChannels(self, steps=None, pos_i=None): - if steps is None: - steps = self._steps - - required_channels = core.get_selected_channels(steps) - if pos_i is None: - pos_i = self._key[0] - - return required_channels, pos_i - - @worker_exception_handler - def run(self): - while True: - if self.exit: - self.logger.log("Closing combining channels worker...") - break - elif self.wait: - self.logger.log("Combining channels worker paused.") - self.pause() - elif len(self.dataQ) > 0: - data, steps, key, keep_input_data_type, output_as_segm, formula = ( - self.dataQ.pop() - ) - requ_steps, pos_i = self.requiredChannels(steps, key[0]) - self.emitsigAskLoadChannels(requ_steps, pos_i) - output_imgs, out_keys = self.applySteps( - data, - steps, - keep_input_data_type, - key, - output_as_segm=output_as_segm, - formula=formula, - ) - self.sigPreviewDone.emit(output_imgs, out_keys) - if len(self.dataQ) == 0: - self.wait = True - self.sigIsQueueEmpty.emit(True) - else: - self.logger.log("Combining channels worker resumed.") - requ_steps, pos_i = self.requiredChannels() - self.emitsigAskLoadChannels(requ_steps, pos_i) - output_imgs, out_keys = self.runJob() - self.sigDone.emit(output_imgs, out_keys) - self.wait = True - - self.signals.finished.emit(self) - - def emitsigAskLoadChannels(self, requChannels, pos_i): - self.mutex.lock() - self.sigAskLoadChannels.emit(requChannels, pos_i) - self.waitCondLoadFluoChannels.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def wake_waitCondLoadFluoChannels(self): - self.mutex.lock() - self.waitCondLoadFluoChannels.wakeAll() - self.mutex.unlock() - - -class CustomPreprocessWorkerUtil(BaseWorkerUtil): - sigAskAppendName = Signal(str) - sigAskSetupRecipe = Signal(object, object) - sigAborted = Signal() - - def __init__(self, mainWin): - super().__init__(mainWin) - - def emitAskSetupRecipe(self, exp_path, pos_foldernames): - self.mutex.lock() - self.sigAskSetupRecipe.emit(exp_path, pos_foldernames) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def applyPipeline( - self, - images_path: os.PathLike, - channel_names: Iterable[str], - recipe: List[Dict[str, Any]], - appended_text_filename: str, - ): - posData = None - preprocessed_data = {} - for channel in channel_names: - self.logger.log(f"Loading {channel} channel data...") - ch_filepath = load.get_filename_from_channel(images_path, channel) - ch_image_data = load.load_image_file(ch_filepath) - if posData is None: - posData = load.loadData(ch_filepath, channel) - posData.getBasenameAndChNames() - posData.buildPaths() - posData.loadOtherFiles( - load_segm_data=False, - load_metadata=True, - ) - if posData.SizeT == 1: - ch_image_data = (ch_image_data,) - - preprocessed_ch_data = core.preprocess_image_from_recipe_multithread( - ch_image_data, recipe - ) - - keep_input_data_type = recipe[0].get("keep_input_data_type", True) - if keep_input_data_type: - preprocessed_ch_data = myutils.convert_to_dtype( - preprocessed_ch_data, ch_image_data.dtype - ) - - _, ext = os.path.splitext(ch_filepath) - basename = posData.basename - processed_filename = f"{basename}{channel}_{appended_text_filename}{ext}" - preprocessed_data[processed_filename] = preprocessed_ch_data - - return preprocessed_data - - @worker_exception_handler - def run(self): - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - self.mainWin.infoText = "Setup recipe" - - if i == 0: - abort = self.emitAskSetupRecipe(exp_path, pos_foldernames) - if abort: - self.sigAborted.emit() - return - - # Ask append name - self.mutex.lock() - basename = f"{self.basename}{self.selectedChannels[0]}_" - self.sigAskAppendName.emit(basename) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - self.sigAborted.emit() - return - - appendedName = self.appendedName - self.signals.initProgressBar.emit(len(pos_foldernames)) - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.sigAborted.emit() - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - images_path = os.path.join(exp_path, pos, "Images") - self.logger.log("Applying custom pre-processing recipe...\n") - processed_data = self.applyPipeline( - images_path, self.selectedChannels, self.recipe, appendedName - ) - - for filename, preprocessed_ch_data in processed_data.items(): - preprocessed_filepath = os.path.join(images_path, filename) - self.logger.log( - f'Saving pre-processed images to "{preprocessed_filepath}"...' - ) - - io.save_image_data(preprocessed_filepath, preprocessed_ch_data) - self.signals.progressBar.emit(1) - - self.signals.finished.emit(self) - - -class CombineChannelsWorkerUtil(BaseWorkerUtil): - sigAskAppendName = Signal(str) - sigAskSetup = Signal(object) - sigAborted = Signal() - - def __init__(self, mainWin, mutex=None, waitCond=None): - super().__init__(mainWin) - - def emitAskSetup(self, expPaths): - self.mutex.lock() - self.sigAskSetup.emit(expPaths) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def applyPipeline( - self, - image_paths: os.PathLike, - steps: Dict[str, Dict[str, Any]], - appended_text_filename: str, - keep_input_data_type: bool, - n_threads: int = None, - formula: str = None, - ): - save_filepaths = [] - images_path_to_process = [] - if self.saveAsSegm: - out_ext = ".npz" - basename_ext = "segm_" - else: - out_ext = ".tif" - basename_ext = "" - for images_path in image_paths: - basename, channels = myutils.getBasenameAndChNames(images_path) - - savename = f"{basename}{basename_ext}{appended_text_filename}{out_ext}" - - images_path_to_process.append(images_path) - save_filepaths.append(os.path.join(images_path, savename)) - - core.combine_channels_multithread( - steps=steps, - images_paths=images_path_to_process, - keep_input_data_type=keep_input_data_type, - save_filepaths=save_filepaths, - signals=self.signals, - logger_func=self.logger.log, - n_threads=n_threads, - output_as_segm=self.saveAsSegm, - formula=formula, - ) - - @worker_exception_handler - def run(self): - - self.signals.initProgressBar.emit(0) - - expPaths = self.mainWin.expPaths - abort = self.emitAskSetup(expPaths) - if abort: - self.sigAborted.emit() - return - - # Ask append name - self.mutex.lock() - basename = f"{self.basename}" - self.sigAskAppendName.emit(basename) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - if self.abort: - self.sigAborted.emit() - return - - appendedName = self.appendedName - - selectedSteps = self.selectedSteps - - self.logger.log("Applying pipeline...") - self.logger.log("Selected steps:") - for step in selectedSteps.values(): - self.logger.log(step) - - image_paths = [] - for exp_path, pos_foldernames in expPaths.items(): - image_paths += [ - os.path.join(exp_path, pos, "Images") for pos in pos_foldernames - ] - - self.signals.initProgressBar.emit(len(pos_foldernames)) - formula = self.formula - self.applyPipeline( - image_paths, - selectedSteps, - appendedName, - self.keepInputDataType, - n_threads=self.nThreads, - formula=formula, - ) - - self.signals.finished.emit(self) - - -class saveDataWorker(QObject): - finished = Signal() - progress = Signal(str) - sigLog = Signal(str) - progressBar = Signal(int, int, float) - critical = Signal(object) - addMetricsCritical = Signal(str, str) - regionPropsCritical = Signal(str, str) - criticalPermissionError = Signal(str) - metricsPbarProgress = Signal(int, int) - askZsliceAbsent = Signal(str, object) - customMetricsCritical = Signal(str, str) - sigCombinedMetricsMissingColumn = Signal(str, str) - sigDebug = Signal(object) - - def __init__(self, mainWin): - QObject.__init__(self) - self.mainWin = mainWin - self.saveWin = mainWin.saveWin - self.mutex = mainWin.mutex - self.waitCond = mainWin.waitCond - self.customMetricsErrors = {} - self.addMetricsErrors = {} - self.regionPropsErrors = {} - self.abort = False - - def checkAbort(self): - if self.saveWin.aborted: - self.finished.emit() - return True - return False - - def saveManualBackgroundData(self, posData, frame_i): - data_dict = posData.allData_li[frame_i] - if "manualBackgroundLab" not in data_dict: - return - - manualBackgrData = data_dict["manualBackgroundLab"] - posData.saveManualBackgroundData(manualBackgrData) - - def emitSigPermissionErrorAndSave( - self, all_frames_acdc_df, acdc_output_csv_path, custom_annot_columns - ): - err_msg = ( - "The below file is open in another app " - "(Excel maybe?).\n\n" - f"{acdc_output_csv_path}\n\n" - 'Close file and then press "Ok".' - ) - self.mutex.lock() - self.criticalPermissionError.emit(err_msg) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - # Save segmentation metadata - load.save_acdc_df_file( - all_frames_acdc_df, - acdc_output_csv_path, - custom_annot_columns=custom_annot_columns, - last_cca_frame_i=self.mainWin.save_cca_until_frame_i, - ) - - def _emitSigDebug(self, stuff_to_debug): - self.mutex.lock() - self.sigDebug.emit(stuff_to_debug) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - - def emitUpdateProgressBar(self): - t = time.perf_counter() - exec_time = t - self.time_last_pbar_update - self.progressBar.emit(1, -1, exec_time) - self.time_last_pbar_update = t - - def saveAcdcDf(self, posData: load.loadData, end_i): - acdc_dfs_li = [] - keys = [] - self.progress.emit(f"Saving annotations for {posData.relPath}...") - for frame_i, data_dict in enumerate(posData.allData_li[: end_i + 1]): - if self.saveWin.aborted: - self.finished.emit() - return - - # Build saved_segm_data - lab = data_dict["labels"] - if lab is None: - break - - acdc_df = posData.allData_li[frame_i]["acdc_df"] - if acdc_df is None: - continue - - acdc_dfs_li.append(acdc_df) - keys.append((frame_i, posData.TimeIncrement * frame_i)) - - if not acdc_dfs_li: - return - - self.mainWin._measurements_kernel._concat_and_save_acdc_df( - acdc_dfs_li, - keys, - posData, - self.mainWin.save_metrics, - saveDataWorker=self, - last_cca_frame_i=self.mainWin.save_cca_until_frame_i, - ) - - def saveSegmData(self, posData, end_i, saved_segm_data): - self.progress.emit(f"Saving segmentation data for {posData.relPath}...") - - # extend saved_segm_data if needed - if posData.SizeT > 1: - missing_frames_number = end_i + 1 - len(saved_segm_data) - if missing_frames_number > 0: - saved_segm_data = np.concatenate( - ( - saved_segm_data, - np.zeros( - (missing_frames_number, *saved_segm_data.shape[1:]), - dtype=saved_segm_data.dtype, - ), - ), - ) - - for frame_i, data_dict in enumerate(posData.allData_li[: end_i + 1]): - if self.saveWin.aborted: - self.finished.emit() - return - - # Build saved_segm_data - lab = data_dict["labels"] - if lab is None: - break - - posData.lab = lab - - if posData.SizeT > 1: - saved_segm_data[frame_i] = lab - else: - saved_segm_data = lab - if "manualBackgroundLab" in data_dict: - manualBackgrData = data_dict["manualBackgroundLab"] - posData.saveManualBackgroundData(manualBackgrData) - - # Save segmentation file - io.savez_compressed(posData.segm_npz_path, np.squeeze(saved_segm_data)) - posData.segm_data = saved_segm_data - # Allow single 2D/3D image - if posData.SizeT == 1: - posData.segm_data = posData.segm_data[np.newaxis] - - try: - os.remove(posData.segm_npz_temp_path) - except Exception as e: - pass - - @worker_exception_handler - def run(self): - posToSave = self.mainWin.posToSave - if posToSave is None: - numPosToSave = 1 - else: - numPosToSave = len(posToSave) - save_metrics = self.mainWin.save_metrics - if self.isQuickSave: - save_metrics = False - self.time_last_pbar_update = time.perf_counter() - mode = self.mode - for p, posData in enumerate(self.mainWin.data): - if self.saveWin.aborted: - self.finished.emit() - return - - if posToSave is not None: - if posData.pos_foldername not in posToSave: - self.progress.emit(f"Skipping {posData.relPath}") - continue - - last_tracked_i_path = posData.last_tracked_i_path - end_i = self.mainWin.save_until_frame_i - self.saveSegmData(posData, end_i, posData.segm_data) - - posData.saveCustomAnnotationParams() - current_frame_i = posData.frame_i - - posData.saveTrackedLostCentroids() - - if not self.mainWin.isSnapshot: - last_tracked_i = self.mainWin.last_tracked_i - if last_tracked_i is None: - self.mainWin.saveWin.aborted = True - self.finished.emit() - return - elif self.mainWin.isSnapshot: - last_tracked_i = 0 - - if p == 0: - self.progressBar.emit(0, numPosToSave * (last_tracked_i + 1), 0) - - acdc_output_csv_path = posData.acdc_output_csv_path - delROIs_info_path = posData.delROIs_info_path - - # Add segmented channel data for calc metrics if requested - add_user_channel_data = True - for chName in self.mainWin._measurements_kernel.chNamesToSkip: - skipUserChannel = posData.filename.endswith( - chName - ) or posData.filename.endswith(f"{chName}_aligned") - if skipUserChannel: - add_user_channel_data = False - - if add_user_channel_data and not self.isQuickSave: - posData.fluo_data_dict[posData.filename] = posData.img_data - - if not self.isQuickSave: - posData.fluo_bkgrData_dict[posData.filename] = posData.bkgrData - - posData.setLoadedChannelNames() - - if not self.isQuickSave: - self.mainWin.initMetricsToSave(posData) - self.mainWin._measurements_kernel.run( - posData=posData, - stop_frame_n=end_i + 1, - saveDataWorker=self, - save_metrics=self.mainWin.save_metrics, - last_cca_frame_i=self.mainWin.save_cca_until_frame_i, - ) - else: - self.saveAcdcDf(posData, end_i) - - self.progress.emit(f"Saving {posData.relPath}") - - if not self.do_not_save_og_whitelist: - og_save_path = os.path.join( - posData.images_path, self.append_name_og_whitelist - ) - posData.whitelist.saveOGLabs(og_save_path) - - if posData.whitelist: - whitelistIDs_path = posData.segm_npz_path.replace( - ".npz", "_whitelistIDs.json" - ) - new_centroids_path = posData.segm_npz_path.replace( - ".npz", "_new_centroids.json" - ) - posData.whitelist.save( - whitelistIDs_path, new_centroids_path=new_centroids_path - ) - - if posData.segmInfo_df is not None: - try: - posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) - except PermissionError: - err_msg = ( - "The below file is open in another app " - "(Excel maybe?).\n\n" - f"{posData.segmInfo_df_csv_path}\n\n" - 'Close file and then press "Ok".' - ) - self.mutex.lock() - self.criticalPermissionError.emit(err_msg) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) - - posData.fluo_data_dict.pop(posData.filename, None) - - if not self.isQuickSave: - posData.fluo_bkgrData_dict.pop(posData.filename) - - if posData.SizeT > 1: - self.progress.emit("Almost done...") - self.progressBar.emit(0, 0, 0) - - if self.isQuickSave: - # Go back to current frame - posData.frame_i = current_frame_i - self.mainWin.get_data() - continue - - with open(last_tracked_i_path, "w+") as txt: - txt.write(str(end_i)) - - # Save combined metrics equations - posData.saveCombineMetrics() - self.mainWin.pointsLayerDataToDf(posData) - posData.saveClickEntryPointsDfs() - - posData.last_tracked_i = last_tracked_i - - # Go back to current frame - posData.frame_i = current_frame_i - self.mainWin.get_data() - - if mode == "Segmentation and Tracking" or mode == "Viewer": - self.progress.emit(f"Saved data until frame number {end_i + 1}") - elif mode == "Cell cycle analysis": - self.progress.emit( - "Saved cell cycle annotations until frame " - f"number {self.mainWin.last_cca_frame_i + 1}" - ) - # self.progressBar.emit(1) - if self.mainWin.isSnapshot: - self.progress.emit(f"Saved all {p + 1} Positions!") - - self.finished.emit() - - -class relabelSequentialWorker(QObject): - finished = Signal() - critical = Signal(object) - progress = Signal(str) - sigRemoveItemsGUI = Signal(int) - debug = Signal(object) - - def __init__(self, mainWin, posFoldernames): - QObject.__init__(self) - self.mainWin = mainWin - self.data = mainWin.data - self.posFoldernames = posFoldernames - self.mutex = QMutex() - self.waitCond = QWaitCondition() - - def progressNewIDs(self, oldIDs, newIDs): - li = list(zip(oldIDs, newIDs)) - s = "\n".join([str(pair).replace(",", " -->") for pair in li]) - s = f"IDs relabelled as follows:\n{s}" - self.progress.emit(s) - - @worker_exception_handler - def run(self): - self.mutex.lock() - - self.progress.emit("Relabelling process started...") - mainWin = self.mainWin - - current_pos_i = mainWin.pos_i - - for p, posData in enumerate(self.data): - if posData.pos_foldername not in self.posFoldernames: - continue - - mainWin.pos_i = p - current_lab = mainWin.get_2Dlab(posData.lab).copy() - current_frame_i = posData.frame_i - segm_data = [] - for frame_i, data_dict in enumerate(posData.allData_li): - lab = data_dict["labels"] - if lab is None: - break - segm_data.append(lab) - # if frame_i == current_frame_i: - # break - - if not segm_data: - segm_data = np.array([current_lab]) - - segm_data = np.array(segm_data) - segm_data, oldIDs, newIDs = core.relabel_sequential( - segm_data, is_timelapse=posData.SizeT > 1 - ) - self.progressNewIDs(oldIDs, newIDs) - self.sigRemoveItemsGUI.emit(np.max(segm_data)) - - self.progress.emit( - "Updating stored data and cell cycle annotations (if present)..." - ) - - mainWin.updateAnnotatedIDs(oldIDs, newIDs, logger=self.progress.emit) - mainWin.store_data(mainThread=False) - - for frame_i, lab in enumerate(segm_data): - posData.frame_i = frame_i - posData.lab = lab - mainWin.get_cca_df() - if posData.cca_df is not None: - mainWin.update_cca_df_relabelling(posData, oldIDs, newIDs) - mainWin.update_rp(draw=False) - mainWin.store_data(mainThread=False) - - # Go back to current frame - mainWin.pos_i = current_pos_i - posData = self.data[mainWin.pos_i] - posData.frame_i = current_frame_i - mainWin.get_data() - - self.mutex.unlock() - self.finished.emit() - - -class MagicPromptsWorker(QObject): - def __init__( - self, - posData, - image, - df_points, - model, - model_segment_kwargs, - image_origin=(0, 0, 0), - global_image=None, - ): - QObject.__init__(self) - - self.signals = signals() - self.posData = posData - self.image = image - if global_image is not None: - self.global_image = global_image - else: - self.global_image = image - self.df_points = df_points - self.image_origin = image_origin - self.model = model - self.model_segment_kwargs = model_segment_kwargs - - @worker_exception_handler - def run(self): - from cellacdc.segmenters_promptable import utils - - for row in self.df_points.itertuples(): - prompt_id = row.id - point = (row.z, row.y, row.x) - print(f"Adding point prompt {point} with id = {prompt_id}...") - parent_obj_id = row.Cell_ID if row.Cell_ID == prompt_id else 0 - self.model.add_prompt( - prompt=point, - prompt_id=prompt_id, - parent_obj_id=parent_obj_id, - image=self.image, - image_origin=self.image_origin, - prompt_type="point", - ) - - lab_out = self.model.segment( - self.global_image, lab=self.posData.lab, **self.model_segment_kwargs - ) - edited_IDs = self.df_points["Cell_ID"].unique() - - lab_new, lab_union, lab_interesection = utils.insert_model_output_into_labels( - self.posData.lab, lab_out, edited_IDs=edited_IDs - ) - - self.signals.finished.emit((lab_new, lab_union, lab_interesection)) - - -class FillHolesInSegWorker(BaseWorkerUtil): - sigAskAppendName = Signal(str) - sigAborted = Signal() - sigSelectSegmFiles = Signal(str, list) - - def __init__(self, mainWin): - super().__init__(mainWin) - - def emitSelectSegmFiles(self, exp_path, pos_foldernames): - self.mutex.lock() - self.sigSelectSegmFiles.emit(exp_path, pos_foldernames) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - def emitAskAppendName(self, basename): - self.mutex.lock() - self.sigAskAppendName.emit(basename) - self.waitCond.wait(self.mutex) - self.mutex.unlock() - return self.abort - - @worker_exception_handler - def run(self): - expPaths = self.mainWin.expPaths - lab_paths_dict = dict() - unique_segm_files = set() - tot_segm_files = 0 - for exp_path, pos_foldernames in expPaths.items(): - abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) - if abort: - self.sigAborted.emit() - return - for pos_folder in pos_foldernames: - imgs_path = os.path.join(exp_path, pos_folder, "Images") - lab_paths_dict[imgs_path] = self.endFilenameSegmTemp - tot_segm_files += len(self.endFilenameSegmTemp) - unique_segm_files.update(self.endFilenameSegmTemp) - - self.logger.info("Filling holes in segmentation masks...") - abort = self.emitAskAppendName("/".join(unique_segm_files)) - if abort: - self.sigAborted.emit() - return - self.signals.initProgressBar.emit(tot_segm_files) - for images_path, segm_file_names in lab_paths_dict.items(): - for segm_file_name in segm_file_names: - segm_data, segm_data_path = load.load_segm_file( - images_path, end_name_segm_file=segm_file_name, return_path=True - ) - segm_data_shape = segm_data.shape - segm_data_ndim = len(segm_data_shape) - if segm_data_ndim == 2: - segm_data = segm_data[np.newaxis, np.newaxis, ...] - elif segm_data_ndim == 3: - segm_data = segm_data[np.newaxis, ...] - elif segm_data_ndim == 4: - segm_data = segm_data - else: - raise NotImplementedError("This ndim is not supported!") - for i, stack in enumerate(segm_data): - for j, lab in enumerate(stack): - segm_data[i, j] = core.fill_holes_in_segmentation(lab) - - segm_data_save_path = segm_data_path.replace( - segm_file_name, f"{segm_file_name}{self.appendedName}" - ) - io.savez_compressed(segm_data_save_path, segm_data) - self.signals.progressBar.emit(1) - self.signals.finished.emit(self) - - -class GenerateMotherBudTotalTableWorker(BaseWorkerUtil): - def __init__( - self, parentWin, input_csv_filepath, selected_options, out_csv_filepath - ): - super().__init__(parentWin) - self.input_csv_filepath = input_csv_filepath - self.selected_options = selected_options - self.out_csv_filepath = out_csv_filepath - - @worker_exception_handler - def run(self): - self.logger.log(f'Loading table "{self.input_csv_filepath}"...') - self.signals.initProgressBar.emit(0) - - input_df = pd.read_csv(self.input_csv_filepath) - - self.logger.log("Generating output table...") - out_df = cca_functions.generate_mother_bud_total_df( - input_df, **self.selected_options - ) - - self.logger.log(f'Saving output table to "{self.out_csv_filepath}"...') - - out_df.to_csv(self.out_csv_filepath) - - self.signals.finished.emit(self) - - -class CountObjectsInSegm(BaseWorkerUtil): - sigAskAppendName = Signal(str, list) - sigAborted = Signal() - - def __init__(self, mainWin): - super().__init__(mainWin) - - @worker_exception_handler - def run(self): - debugging = False - expPaths = self.mainWin.expPaths - tot_exp = len(expPaths) - self.signals.initProgressBar.emit(0) - for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): - self.errors = {} - tot_pos = len(pos_foldernames) - - self.mainWin.infoText = f"Select segmentation file to count" - abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) - if abort: - self.sigAborted.emit() - return - - self.signals.initProgressBar.emit(len(pos_foldernames)) - for p, pos in enumerate(pos_foldernames): - if self.abort: - self.sigAborted.emit() - return - - self.logger.log( - f"Processing experiment n. {i + 1}/{tot_exp}, " - f"{pos} ({p + 1}/{tot_pos})" - ) - - images_path = os.path.join(exp_path, pos, "Images") - endFilenameSegm = self.mainWin.endFilenameSegm - ls = myutils.listdir(images_path) - file_path = [ - os.path.join(images_path, f) - for f in ls - if f.endswith(f"{endFilenameSegm}.npz") - ][0] - - posData = load.loadData(file_path, "") - - self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") - - posData.getBasenameAndChNames() - posData.buildPaths() - - posData.loadOtherFiles( - load_segm_data=True, - load_acdc_df=False, - load_metadata=True, - end_filename_segm=endFilenameSegm, - ) - if posData.segm_data.ndim == 3: - posData.segm_data = posData.segm_data[np.newaxis] - - self.logger.log("Counting objects...") - - countMapper = posData.countObjectsInSegm() - countMapper.pop("In current frame", None) - df_count_endname = posData.saveObjCounts(countMapper) - - self.logger.log( - "Saved object counts table to file ending with: " - f'"{df_count_endname}"' - ) - - self.signals.progressBar.emit(1) - - self.signals.finished.emit(self) diff --git a/cellacdc/workers/__init__.py b/cellacdc/workers/__init__.py new file mode 100644 index 000000000..aec650a87 --- /dev/null +++ b/cellacdc/workers/__init__.py @@ -0,0 +1,149 @@ +"""Background Qt workers.""" + +from ._base import ( + BaseWorkerUtil, + SimpleWorker, + signals, + workerLogger, + worker_exception_handler, +) + +from .alignment import ( + AlignDataWorker, + AlignWorker, +) + +from .data_prep import ( + CombineChannelsWorkerGUI, + CombineChannelsWorkerUtil, + CustomPreprocessWorkerGUI, + CustomPreprocessWorkerUtil, + DataPrepCropWorker, + DataPrepSaveBkgrDataWorker, + FucciPreprocessWorker, + ImagesToPositionsWorker, + RestructMultiPosWorker, + RestructMultiTimepointsWorker, + SaveCombinedChannelsWorker, + SaveProcessedDataWorker, + reapplyDataPrepWorker, +) + +from .gui import ( + AutoPilotWorker, + FindNextNewIdWorker, +) + +from .io import ( + AutoSaveWorker, + LazyLoader, + MigrateUserProfileWorker, + MoveTempFilesWorker, + StoreGuiStateWorker, + loadDataWorker, + relabelSequentialWorker, + saveDataWorker, +) + +from .metrics import ( + CcaIntegrityCheckerWorker, + ComputeMetricsMultiChannelWorker, + ComputeMetricsWorker, + ConcatAcdcDfsWorker, + ConcatSpotmaxDfsWorker, + CountObjectsInSegm, + GenerateMotherBudTotalTableWorker, +) + +from .segm import ( + CreateConnected3Dsegm, + DelObjectsOutsideSegmROIWorker, + FillHolesInSegWorker, + LabelRoiWorker, + MagicPromptsWorker, + PostProcessSegmWorker, + SegForLostIDsWorker, + segmVideoWorker, + segmWorker, +) + +from .tracking import ( + ApplyTrackInfoWorker, + CopyAllLostObjectsWorker, + ToSymDivWorker, + TrackSubCellObjectsWorker, + trackingWorker, +) + +from .util import ( + ApplyImageFilterWorker, + FilterObjsFromCoordsTable, + FromImajeJroiToSegmNpzWorker, + ResizeUtilWorker, + ScreenRecorderWorker, + Stack2DsegmTo3Dsegm, + ToImajeJroiWorker, + ToObjCoordsWorker, +) + +__all__ = [ + "BaseWorkerUtil", + "SimpleWorker", + "signals", + "workerLogger", + "worker_exception_handler", + "AlignDataWorker", + "AlignWorker", + "CombineChannelsWorkerGUI", + "CombineChannelsWorkerUtil", + "CustomPreprocessWorkerGUI", + "CustomPreprocessWorkerUtil", + "DataPrepCropWorker", + "DataPrepSaveBkgrDataWorker", + "FucciPreprocessWorker", + "ImagesToPositionsWorker", + "RestructMultiPosWorker", + "RestructMultiTimepointsWorker", + "SaveCombinedChannelsWorker", + "SaveProcessedDataWorker", + "reapplyDataPrepWorker", + "AutoPilotWorker", + "FindNextNewIdWorker", + "AutoSaveWorker", + "LazyLoader", + "MigrateUserProfileWorker", + "MoveTempFilesWorker", + "StoreGuiStateWorker", + "loadDataWorker", + "relabelSequentialWorker", + "saveDataWorker", + "CcaIntegrityCheckerWorker", + "ComputeMetricsMultiChannelWorker", + "ComputeMetricsWorker", + "ConcatAcdcDfsWorker", + "ConcatSpotmaxDfsWorker", + "CountObjectsInSegm", + "GenerateMotherBudTotalTableWorker", + "CreateConnected3Dsegm", + "DelObjectsOutsideSegmROIWorker", + "FillHolesInSegWorker", + "LabelRoiWorker", + "MagicPromptsWorker", + "PostProcessSegmWorker", + "SegForLostIDsWorker", + "segmVideoWorker", + "segmWorker", + "ApplyTrackInfoWorker", + "CopyAllLostObjectsWorker", + "ToSymDivWorker", + "TrackSubCellObjectsWorker", + "trackingWorker", + "ApplyImageFilterWorker", + "FilterObjsFromCoordsTable", + "FromImajeJroiToSegmNpzWorker", + "ResizeUtilWorker", + "ScreenRecorderWorker", + "Stack2DsegmTo3Dsegm", + "ToImajeJroiWorker", + "ToObjCoordsWorker", +] diff --git a/cellacdc/workers/_base.py b/cellacdc/workers/_base.py new file mode 100644 index 000000000..f43f4379f --- /dev/null +++ b/cellacdc/workers/_base.py @@ -0,0 +1,239 @@ +"""Background Qt workers: _base.""" + +import re +import os +import shutil +import time +import json +import concurrent.futures +from functools import partial +from collections import defaultdict, deque +import itertools + +from typing import Union, List, Dict, Callable, Any, Tuple, Iterable + +from functools import wraps +import numpy as np +import pandas as pd +import h5py +import traceback + +import skimage.io +import skimage.measure +import skimage.exposure + +import queue + +from tqdm import tqdm + +from qtpy.QtCore import Signal, QObject, QMutex, QWaitCondition + +from cellacdc import html_utils + +from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import transformation, measurements, cca_functions +from ..path import copy_or_move_tree +from .. import features, plot +from .. import core +from .. import cca_df_colnames, lineage_tree_cols, default_annot_df +from .. import cca_df_colnames_with_tree +from .. import cli +from ..utils import resize +from .. import segm_utils + +DEBUG = False + +def worker_exception_handler(func): + @wraps(func) + def run(self): + try: + func(self) + except Exception as error: + printl(traceback.format_exc()) + try: + self.dataQ.clear() + except Exception as err: + pass + + # Some workers have both self.critical and self.signals.critical + # errors but only one of them is connected --> emit both just + # in case + try: + self.critical.emit((self, error)) + except Exception as err: + self.signals.critical.emit((self, error)) + + try: + self.signals.critical.emit((self, error)) + except Exception as err: + self.critical.emit((self, error)) + + try: + self.mutex.unlock() + except Exception as err: + pass + + return run + + +class workerLogger: + def __init__(self, sigProcess): + self.sigProcess = sigProcess + + def log(self, message, level="INFO"): + try: + self.sigProcess.emit(str(message), level) + except Exception as err: + print(message, level) + try: + traceback_format = traceback.format_exc() + print(traceback_format) + except Exception as err: + pass + printl(err) + finally: + pass + + def info(self, message): + self.log(message, level="INFO") + + def warning(self, message): + self.log(message, level="WARNING") + + def exception(self, message): + self.log(message, level="EXCEPTION") + + +class signals(QObject): + progress = Signal(str, object) + finished = Signal(object) + initProgressBar = Signal(int) + progressBar = Signal(int) + critical = Signal(object) + dataIntegrityWarning = Signal(str) + dataIntegrityCritical = Signal() + sigLoadingFinished = Signal() + sigLoadingNewChunk = Signal(object) + resetInnerPbar = Signal(int) + progress_tqdm = Signal(int) + signal_close_tqdm = Signal() + create_tqdm = Signal(int) + innerProgressBar = Signal(int) + sigPermissionError = Signal(str, object) + sigSelectSegmFiles = Signal(object, object) + sigSelectAcdcOutputFiles = Signal(object, object, str, bool, bool) + sigSelectSpotmaxRun = Signal(object, object, object, str, bool, bool) + sigSetMeasurements = Signal(object) + sigInitAddMetrics = Signal(object, object) + sigUpdatePbarDesc = Signal(str) + sigComputeVolume = Signal(int, object) + sigAskStopFrame = Signal(object) + sigWarnMismatchSegmDataShape = Signal(object) + sigErrorsReport = Signal(dict, dict, dict) + sigMissingAcdcAnnot = Signal(dict) + sigRecovery = Signal(object) + sigInitInnerPbar = Signal(int) + sigUpdateInnerPbar = Signal(int) + sigSelectFile = Signal(str, str, str) + sigAskCopyCca = Signal(str) + sigSelectFilesWithText = Signal(str, object, str, object) + sigAskRunNow = Signal(object) + + +class BaseWorkerUtil(QObject): + progressBar = Signal(int, int, float) + + def __init__(self, mainWin): + QObject.__init__(self) + self.signals = signals() + self.abort = False + self.skipExp = False + self.logger = workerLogger(self.signals.progress) + self.mutex = QMutex() + self.waitCond = QWaitCondition() + self.mainWin = mainWin + + def emitSelectSegmFiles(self, exp_path, pos_foldernames): + self.mutex.lock() + self.signals.sigSelectSegmFiles.emit(exp_path, pos_foldernames) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def emitSelectFilesWithText(self, exp_path, pos_foldernames, with_text, ext=None): + self.mutex.lock() + self.signals.sigSelectFilesWithText.emit( + exp_path, pos_foldernames, with_text, ext + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def emitSelectFile(self, start_dir, caption="", filters="All files (*.)"): + self.mutex.lock() + self.signals.sigSelectFile.emit(start_dir, caption, filters) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def emitSelectAcdcOutputFiles( + self, + exp_path, + pos_foldernames, + infoText="", + allowSingleSelection=False, + multiSelection=True, + ): + self.mutex.lock() + self.signals.sigSelectAcdcOutputFiles.emit( + exp_path, pos_foldernames, infoText, allowSingleSelection, multiSelection + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def emitSelectSpotmaxRun( + self, + exp_path, + pos_foldernames, + all_runs, + infoText="", + allowSingleSelection=True, + multiSelection=True, + ): + self.mutex.lock() + self.signals.sigSelectSpotmaxRun.emit( + exp_path, + pos_foldernames, + all_runs, + infoText, + allowSingleSelection, + multiSelection, + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + +class SimpleWorker(QObject): + def __init__(self, posData, func, func_args=None, func_kwargs=None): + QObject.__init__(self) + self.posData = posData + self.signals = signals() + self.output = {} + + if func_args is None: + func_args = [] + + if func_kwargs is None: + func_kwargs = {} + + self.func = func + self.func_args = func_args + self.func_kwargs = func_kwargs + self.posData = posData + + @worker_exception_handler + def run(self): + self.result = self.func(self.posData, *self.func_args, **self.func_kwargs) + self.signals.finished.emit(self.output) diff --git a/cellacdc/workers/alignment.py b/cellacdc/workers/alignment.py new file mode 100644 index 000000000..11f6aa77a --- /dev/null +++ b/cellacdc/workers/alignment.py @@ -0,0 +1,476 @@ +"""Background Qt workers: alignment.""" + +import re +import os +import shutil +import time +import json +import concurrent.futures +from functools import partial +from collections import defaultdict, deque +import itertools + +from typing import Union, List, Dict, Callable, Any, Tuple, Iterable + +from functools import wraps +import numpy as np +import pandas as pd +import h5py +import traceback + +import skimage.io +import skimage.measure +import skimage.exposure + +import queue + +from tqdm import tqdm + +from qtpy.QtCore import Signal, QObject, QMutex, QWaitCondition + +from cellacdc import html_utils + +from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import transformation, measurements, cca_functions +from ..path import copy_or_move_tree +from .. import features, plot +from .. import core +from .. import cca_df_colnames, lineage_tree_cols, default_annot_df +from .. import cca_df_colnames_with_tree +from .. import cli +from ..utils import resize +from .. import segm_utils + +DEBUG = False + +from ._base import ( + BaseWorkerUtil, +) + +class AlignDataWorker(QObject): + sigWarnTifAligned = Signal(object, object, object) + sigAskAlignSegmData = Signal() + + def __init__(self, posData, dataPrepWin, mutex, waitCond): + QObject.__init__(self) + self.signals = signals() + self.logger = workerLogger(self.signals.progress) + self.posData = posData + self.dataPrepWin = dataPrepWin + self.mutex = mutex + self.waitCond = waitCond + self.doNotAlignSegmData = False + self.doAbort = False + + def set_attr(self, align, user_ch_name): + self.align = align + self.user_ch_name = user_ch_name + + def pause(self): + self.mutex.lock() + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def restart(self): + self.waitCond.wakeAll() + + def emitWarnTifAligned(self, numFramesWith0s, tif, posData): + self.sigWarnTifAligned.emit(numFramesWith0s, tif, posData) + self.pause() + + def emitSigAskAlignSegmData(self): + self.sigAskAlignSegmData.emit() + self.pause() + + def _align_data(self): + _zip = zip(self.posData.tif_paths, self.posData.npz_paths) + aligned = False + self.posData.all_npz_paths = [ + tif.replace(".tif", "_aligned.npz") for tif in self.posData.tif_paths + ] + for i, (tif, npz) in enumerate(_zip): + doAlign = npz is None or self.posData.loaded_shifts is None + + filename_tif = os.path.basename(tif) + user_ch_filename = f"{self.posData.basename}{self.user_ch_name}.tif" + + if not doAlign: + _npz = f"{os.path.splitext(tif)[0]}_aligned.npz" + if os.path.exists(_npz): + self.posData.all_npz_paths[i] = _npz + continue + + if filename_tif != user_ch_filename: + continue + + if not self.align: + continue + + # Align based on user_ch_name + aligned = True + self.logger.log(f"Aligning: {tif}") + + tif_data = load.imread(tif) + numFramesWith0s = self.dataPrepWin.detectTifAlignment( + tif_data, self.posData + ) + if self.align: + self.emitWarnTifAligned(numFramesWith0s, tif, self.posData) + if self.doAbort: + return + + # Alignment routine + if self.posData.SizeZ > 1: + align_func = core.align_frames_3D + df = self.posData.segmInfo_df.loc[self.posData.filename] + zz = df["z_slice_used_dataPrep"].to_list() + if not self.posData.filename.endswith("aligned") and self.align: + # Add aligned channel to segmInfo + df_aligned = self.posData.segmInfo_df.rename( + index={ + self.posData.filename: f"{self.posData.filename}_aligned" + } + ) + self.posData.segmInfo_df = pd.concat( + [self.posData.segmInfo_df, df_aligned] + ) + self.posData.segmInfo_df.to_csv(self.posData.segmInfo_df_csv_path) + else: + align_func = core.align_frames_2D + zz = None + + if self.align: + self.signals.initProgressBar.emit(len(tif_data)) + aligned_frames, shifts = align_func( + tif_data, + slices=zz, + user_shifts=self.posData.loaded_shifts, + sigPyqt=self.signals.progressBar, + ) + self.posData.loaded_shifts = shifts + else: + aligned_frames = tif_data + + if self.align: + self.signals.initProgressBar.emit(0) + _npz = f"{os.path.splitext(tif)[0]}_aligned.npz" + self.logger.log(f"Storing temporary file: {_npz}") + temp_npz = self.dataPrepWin.getTempfilePath(_npz) + io.savez_compressed(temp_npz, aligned_frames) + self.dataPrepWin.storeTempFileMove(temp_npz, _npz) + np.save(self.posData.align_shifts_path, self.posData.loaded_shifts) + self.posData.all_npz_paths[i] = _npz + + self.logger.log(f"Storing temporary file: {tif}") + temp_tif = self.dataPrepWin.getTempfilePath(tif) + myutils.to_tiff(temp_tif, aligned_frames) + self.dataPrepWin.storeTempFileMove(temp_tif, tif) + self.posData.img_data = load.imread(temp_tif) + + _zip = zip(self.posData.tif_paths, self.posData.npz_paths) + for i, (tif, npz) in enumerate(_zip): + doAlign = npz is None or aligned + + if not doAlign: + continue + + if tif.endswith(f"{self.user_ch_name}.tif"): + continue + + if not self.align: + continue + + # Align the other channels + if self.posData.loaded_shifts is None: + break + + if self.align: + self.logger.log(f"Aligning: {tif}") + tif_data = load.imread(tif) + + # Alignment routine + if self.posData.SizeZ > 1: + align_func = core.align_frames_3D + df = self.posData.segmInfo_df.loc[self.posData.filename] + zz = df["z_slice_used_dataPrep"].to_list() + else: + align_func = core.align_frames_2D + zz = None + if self.align: + self.signals.initProgressBar.emit(len(tif_data)) + aligned_frames, shifts = align_func( + tif_data, + slices=zz, + user_shifts=self.posData.loaded_shifts, + sigPyqt=self.signals.progressBar, + ) + else: + aligned_frames = tif_data + + _npz = f"{os.path.splitext(tif)[0]}_aligned.npz" + + if self.align: + self.signals.initProgressBar.emit(0) + self.logger.log(f"Saving: {_npz}") + temp_npz = self.dataPrepWin.getTempfilePath(_npz) + io.savez_compressed(temp_npz, aligned_frames) + self.dataPrepWin.storeTempFileMove(temp_npz, _npz) + self.posData.all_npz_paths[i] = _npz + + self.logger.log(f"Saving: {tif}") + temp_tif = self.dataPrepWin.getTempfilePath(tif) + myutils.to_tiff(temp_tif, aligned_frames) + self.dataPrepWin.storeTempFileMove(temp_tif, tif) + + if not aligned: + return + + if not self.posData.segmFound: + return + + # Align segmentation data accordingly + self.segmAligned = False + if self.posData.loaded_shifts is None or not self.align: + return + + self.emitSigAskAlignSegmData() + if self.doNotAlignSegmData: + return + + self.dataPrepWin.segmAligned = True + self.logger.log(f"Aligning: {self.posData.segm_npz_path}") + self.posData.segm_data, shifts = core.align_frames_2D( + self.posData.segm_data, slices=None, user_shifts=self.posData.loaded_shifts + ) + self.logger.log(f"Saving: {self.posData.segm_npz_path}") + temp_npz = self.dataPrepWin.getTempfilePath(self.posData.segm_npz_path) + io.savez_compressed(temp_npz, self.posData.segm_data) + self.dataPrepWin.storeTempFileMove(temp_npz, self.posData.segm_npz_path) + + @worker_exception_handler + def run(self): + self._align_data() + self.signals.finished.emit(self) + + +class AlignWorker(BaseWorkerUtil): + sigAborted = Signal() + sigAskUseSavedShifts = Signal(str, str) + sigAskSelectChannel = Signal(list) + + def __init__(self, mainWin): + super().__init__(mainWin) + + def emitAskUseSavedShifts(self, expPath, basename): + self.mutex.lock() + self.sigAskUseSavedShifts.emit(expPath, basename) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def emitAskSelectChannel(self, channels): + self.mutex.lock() + self.sigAskSelectChannel.emit(channels) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + @worker_exception_handler + def run(self): + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + shiftsFound = False + for pos in pos_foldernames: + images_path = os.path.join(exp_path, pos, "Images") + ls = myutils.listdir(images_path) + for file in ls: + if file.endswith("align_shift.npy"): + shiftsFound = True + basename, chNames = myutils.getBasenameAndChNames( + images_path, useExt=(".tif", ".h5") + ) + break + if shiftsFound: + break + + savedShiftsHow = None + if shiftsFound: + basename_ch0 = f"{basename}{chNames[0]}_" + abort = self.emitAskUseSavedShifts(exp_path, basename_ch0) + if abort: + self.sigAborted.emit() + return + + savedShiftsHow = self.savedShiftsHow + + self.signals.initProgressBar.emit(len(pos_foldernames)) + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.sigAborted.emit() + return + + self.logger.log("*" * 40) + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + pos_path = os.path.join(exp_path, pos) + images_path = os.path.join(pos_path, "Images") + basename, chNames = myutils.getBasenameAndChNames( + images_path, useExt=(".tif", ".h5") + ) + + self.signals.sigUpdatePbarDesc.emit(f"Loading {pos_path}...") + + if p == 0: + self.logger.log(f"Asking to select reference channel...") + abort = self.emitAskSelectChannel(chNames) + if abort: + self.sigAborted.emit() + return + chName = self.chName + + file_path = myutils.getChannelFilePath(images_path, chName) + + # Load data + posData = load.loadData(file_path, chName) + posData.getBasenameAndChNames(useExt=(".tif", ".h5")) + posData.buildPaths() + posData.loadImgData() + + posData.loadOtherFiles( + load_segm_data=False, load_shifts=True, loadSegmInfo=True + ) + + if posData.img_data.ndim == 4: + align_func = core.align_frames_3D + if posData.segmInfo_df is None: + raise FileNotFoundError( + "To align 4D data you need to select which z-slice " + "you want to use for alignment. Please run the module " + "`1. Launch data prep module...` before aligning the " + "frames. (z-slice info MISSING from position " + f'"{posData.relPath}")' + ) + df = posData.segmInfo_df.loc[posData.filename] + zz = df["z_slice_used_dataPrep"].to_list() + elif posData.img_data.ndim == 3: + align_func = core.align_frames_2D + zz = None + + useSavedShifts = ( + savedShiftsHow == "use_saved_shifts" + and posData.loaded_shifts is not None + ) + if useSavedShifts: + user_shifts = posData.loaded_shifts + else: + user_shifts = None + + if savedShiftsHow == "rever_alignment": + if posData.loaded_shifts is None: + self.logger.log( + f'WARNING: Cannot revert alignment in "{posData.relPath}" ' + "since it is missing previously computed shifts. " + "Skipping this positon." + ) + continue + + # Revert alignment and save selected channel + for chName in chNames: + self.logger.log(f'Reverting alignment on "{chName}"...') + if chName == posData.user_ch_name: + data = posData.img_data + else: + file_path = myutils.getChannelFilePath(images_path, chName) + data = load.load_image_file(file_path) + + self.signals.sigInitInnerPbar.emit(len(data) - 1) + revertedData = core.revert_alignment( + posData.loaded_shifts, + data, + sigPyqt=self.signals.sigUpdateInnerPbar, + ) + self.logger.log(f'Saving "{chName}"...') + self.signals.sigInitInnerPbar.emit(0) + self.saveAlignedData( + revertedData, + images_path, + posData.basename, + chName, + self.revertedAlignEndname, + ext=posData.ext, + ) + del revertedData, data + else: + for chName in chNames: + self.logger.log(f'Aligning "{chName}"...') + if chName == posData.user_ch_name: + data = posData.img_data + else: + file_path = myutils.getChannelFilePath(images_path, chName) + data = load.load_image_file(file_path) + self.signals.sigInitInnerPbar.emit(len(data) - 1) + + alignedImgData, shifts = align_func( + data, + slices=zz, + user_shifts=user_shifts, + sigPyqt=self.signals.sigUpdateInnerPbar, + ) + self.logger.log(f'Saving "{chName}"...') + np.save(posData.align_shifts_path, shifts) + + self.signals.sigInitInnerPbar.emit(0) + self.saveAlignedData( + alignedImgData, + images_path, + posData.basename, + chName, + "", + ext=posData.non_aligned_ext, + ) + self.saveAlignedData( + alignedImgData, + images_path, + posData.basename, + chName, + "aligned", + ext=".npz", + ) + del alignedImgData, data + + self.signals.finished.emit(self) + + def saveAlignedData(self, data, imagesPath, basename, chName, endname, ext=".tif"): + if endname: + newFilename = f"{basename}{chName}_{endname}{ext}" + else: + newFilename = f"{basename}{chName}{ext}" + + filePath = os.path.join(imagesPath, newFilename) + + if ext == ".tif": + SizeT = data.shape[0] + SizeZ = 1 + if data.ndim == 4: + SizeZ = data.shape[1] + myutils.to_tiff(filePath, data) + elif ext == ".npz": + io.savez_compressed(filePath, data) + elif ext == ".h5": + load.save_to_h5(filePath, data) + +# Sibling imports (deferred to avoid import cycles) +from ._base import ( + signals, + workerLogger, + worker_exception_handler, +) + diff --git a/cellacdc/workers/data_prep.py b/cellacdc/workers/data_prep.py new file mode 100644 index 000000000..72ec99719 --- /dev/null +++ b/cellacdc/workers/data_prep.py @@ -0,0 +1,1276 @@ +"""Background Qt workers: data_prep.""" + +import re +import os +import shutil +import time +import json +import concurrent.futures +from functools import partial +from collections import defaultdict, deque +import itertools + +from typing import Union, List, Dict, Callable, Any, Tuple, Iterable + +from functools import wraps +import numpy as np +import pandas as pd +import h5py +import traceback + +import skimage.io +import skimage.measure +import skimage.exposure + +import queue + +from tqdm import tqdm + +from qtpy.QtCore import Signal, QObject, QMutex, QWaitCondition + +from cellacdc import html_utils + +from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import transformation, measurements, cca_functions +from ..path import copy_or_move_tree +from .. import features, plot +from .. import core +from .. import cca_df_colnames, lineage_tree_cols, default_annot_df +from .. import cca_df_colnames_with_tree +from .. import cli +from ..utils import resize +from .. import segm_utils + +DEBUG = False + +from ._base import ( + BaseWorkerUtil, +) + +class reapplyDataPrepWorker(QObject): + finished = Signal() + debug = Signal(object) + critical = Signal(object) + progress = Signal(str) + initPbar = Signal(int) + updatePbar = Signal() + sigCriticalNoChannels = Signal(str) + sigSelectChannels = Signal(object, object, object, str) + + def __init__(self, expPath, posFoldernames): + super().__init__() + self.expPath = expPath + self.posFoldernames = posFoldernames + self.abort = False + self.mutex = QMutex() + self.waitCond = QWaitCondition() + + def raiseSegmInfoNotFound(self, path): + raise FileNotFoundError( + "The following file is required for the alignment of 4D data " + f'but it was not found: "{path}"' + ) + + def saveBkgrData(self, imageData, posData, isAligned=False): + bkgrROI_data = {} + for r, roi in enumerate(posData.bkgrROIs): + xl, yt = [int(round(c)) for c in roi.pos()] + w, h = [int(round(c)) for c in roi.size()] + if not yt + h > yt or not xl + w > xl: + # Prevent 0 height or 0 width roi + continue + is4D = posData.SizeT > 1 and posData.SizeZ > 1 + is3Dz = posData.SizeT == 1 and posData.SizeZ > 1 + is3Dt = posData.SizeT > 1 and posData.SizeZ == 1 + is2D = posData.SizeT == 1 and posData.SizeZ == 1 + if is4D: + bkgr_data = imageData[:, :, yt : yt + h, xl : xl + w] + elif is3Dz or is3Dt: + bkgr_data = imageData[:, yt : yt + h, xl : xl + w] + elif is2D: + bkgr_data = imageData[yt : yt + h, xl : xl + w] + bkgrROI_data[f"roi{r}_data"] = bkgr_data + + if not bkgrROI_data: + return + + if isAligned: + bkgr_data_fn = f"{posData.filename}_aligned_bkgrRoiData.npz" + else: + bkgr_data_fn = f"{posData.filename}_bkgrRoiData.npz" + bkgr_data_path = os.path.join(posData.images_path, bkgr_data_fn) + self.progress.emit("Saving background data to:") + self.progress.emit(bkgr_data_path) + io.savez_compressed(bkgr_data_path, **bkgrROI_data) + + def run(self): + ch_name_selector = prompts.select_channel_name( + which_channel="segm", allow_abort=False + ) + for p, pos in enumerate(self.posFoldernames): + if self.abort: + break + + self.progress.emit(f"Processing {pos}...") + + posPath = os.path.join(self.expPath, pos) + imagesPath = os.path.join(posPath, "Images") + + ls = myutils.listdir(imagesPath) + if p == 0: + ch_names, basenameNotFound = ch_name_selector.get_available_channels( + ls, imagesPath + ) + if not ch_names: + self.sigCriticalNoChannels.emit(imagesPath) + break + self.mutex.lock() + if len(self.posFoldernames) == 1: + # User selected only one pos --> allow selecting and adding + # and external .tif file that will be renamed with the basename + basename = ch_name_selector.basename + else: + basename = None + self.sigSelectChannels.emit( + ch_name_selector, ch_names, imagesPath, basename + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + break + + self.progress.emit(f"Selected channels: {self.selectedChannels}") + + for chName in self.selectedChannels: + filePath = load.get_filename_from_channel(imagesPath, chName) + posData = load.loadData(filePath, chName) + posData.getBasenameAndChNames() + posData.buildPaths() + posData.loadImgData() + posData.loadOtherFiles( + load_segm_data=False, + getTifPath=True, + load_metadata=True, + load_shifts=True, + load_dataPrep_ROIcoords=True, + loadBkgrROIs=True, + ) + + imageData = posData.img_data + + prepped = False + isAligned = False + # Align + if posData.loaded_shifts is not None: + self.progress.emit("Aligning frames...") + shifts = posData.loaded_shifts + if imageData.ndim == 4: + align_func = core.align_frames_3D + else: + align_func = core.align_frames_2D + imageData, _ = align_func(imageData, user_shifts=shifts) + prepped = True + isAligned = True + + # Crop and save background + if posData.dataPrep_ROIcoords is not None: + df = posData.dataPrep_ROIcoords + isCropped = int(df.at["cropped", "value"]) == 1 + if isCropped: + self.saveBkgrData(imageData, posData, isAligned) + self.progress.emit("Cropping...") + x0 = int(df.at["x_left", "value"]) + y0 = int(df.at["y_top", "value"]) + x1 = int(df.at["x_right", "value"]) + y1 = int(df.at["y_bottom", "value"]) + if imageData.ndim == 4: + imageData = imageData[:, :, y0:y1, x0:x1] + elif imageData.ndim == 3: + imageData = imageData[:, y0:y1, x0:x1] + elif imageData.ndim == 2: + imageData = imageData[y0:y1, x0:x1] + prepped = True + else: + filename = os.path.basename(posData.dataPrepBkgrROis_path) + self.progress.emit( + f'WARNING: the file "{filename}" was not found. ' + "I cannot crop the data." + ) + + if prepped: + self.progress.emit("Saving prepped data...") + io.savez_compressed(posData.align_npz_path, imageData) + if hasattr(posData, "tif_path"): + myutils.to_tiff(posData.tif_path, imageData) + + self.updatePbar.emit() + if self.abort: + break + self.finished.emit() + + +class ImagesToPositionsWorker(QObject): + finished = Signal() + debug = Signal(object) + critical = Signal(object) + progress = Signal(str) + initPbar = Signal(int) + updatePbar = Signal() + + def __init__(self, folderPath, targetFolderPath, appendText): + super().__init__() + self.abort = False + self.folderPath = folderPath + self.targetFolderPath = targetFolderPath + self.appendText = appendText + + @worker_exception_handler + def run(self): + self.progress.emit(f'Selected folder: "{self.folderPath}"') + self.progress.emit(f'Target folder: "{self.targetFolderPath}"') + self.progress.emit(" ") + ls = myutils.listdir(self.folderPath) + numFiles = len(ls) + self.initPbar.emit(numFiles) + numPosDigits = len(str(numFiles)) + if numPosDigits == 1: + numPosDigits = 2 + pos = 1 + for file in ls: + if self.abort: + break + + filePath = os.path.join(self.folderPath, file) + if os.path.isdir(filePath): + # Skip directories + self.updatePbar.emit() + continue + + self.progress.emit(f"Loading file: {file}") + filename, ext = os.path.splitext(file) + s0p = str(pos).zfill(numPosDigits) + try: + data = load.imread(filePath) + if data.ndim == 3 and (data.shape[-1] == 3 or data.shape[-1] == 4): + self.progress.emit("Converting RGB image to grayscale...") + data = skimage.color.rgb2gray(data) + data = skimage.img_as_ubyte(data) + + posName = f"Position_{pos}" + posPath = os.path.join(self.targetFolderPath, posName) + imagesPath = os.path.join(posPath, "Images") + if not os.path.exists(imagesPath): + os.makedirs(imagesPath, exist_ok=True) + newFilename = f"s{s0p}_{filename}_{self.appendText}.tif" + relPath = os.path.join(posName, "Images", newFilename) + tifFilePath = os.path.join(imagesPath, newFilename) + self.progress.emit(f"Saving to file: ...{os.sep}{relPath}") + myutils.to_tiff(tifFilePath, data) + pos += 1 + except Exception as e: + self.progress.emit( + f"WARNING: {file} is not a valid image file. Skipping it." + ) + + self.progress.emit(" ") + self.updatePbar.emit() + + if self.abort: + break + self.finished.emit() + + +class DataPrepSaveBkgrDataWorker(QObject): + def __init__(self, posData, dataPrepWin): + QObject.__init__(self) + self.signals = signals() + self.logger = workerLogger(self.signals.progress) + self.posData = posData + self.dataPrepWin = dataPrepWin + + @worker_exception_handler + def run(self): + self.dataPrepWin.saveBkgrData(self.posData) + self.signals.finished.emit(self) + + +class DataPrepCropWorker(QObject): + def __init__(self, posData, dataPrepWin, dstPath): + QObject.__init__(self) + self.signals = signals() + self.logger = workerLogger(self.signals.progress) + self.posData = posData + self.dataPrepWin = dataPrepWin + self.dstPath = dstPath + + @worker_exception_handler + def run(self): + self.dataPrepWin.saveSingleCrop( + self.posData, self.posData.cropROIs[0], self.dstPath + ) + self.signals.finished.emit(self) + + +class RestructMultiPosWorker(BaseWorkerUtil): + sigSaveTiff = Signal(str, object, object) + + def __init__(self, rootFolderPath, dstFolderPath, action="copy"): + super().__init__(None) + self.rootFolderPath = rootFolderPath + self.dstFolderPath = dstFolderPath + self.mutex = QMutex() + self.waitCond = QWaitCondition() + self.action = action + + @worker_exception_handler + def run(self): + load._restructure_multi_files_multi_pos( + self.rootFolderPath, + self.dstFolderPath, + signals=self.signals, + logger=self.logger.log, + action=self.action, + ) + self.signals.finished.emit(self) + + +class RestructMultiTimepointsWorker(BaseWorkerUtil): + sigSaveTiff = Signal(str, object, object) + + def __init__( + self, + allChannels, + frame_name_pattern, + basename, + validFilenames, + rootFolderPath, + dstFolderPath, + segmFolderPath="", + ): + super().__init__(None) + self.allChannels = allChannels + self.frame_name_pattern = frame_name_pattern + self.basename = basename + self.validFilenames = validFilenames + self.rootFolderPath = rootFolderPath + self.dstFolderPath = dstFolderPath + self.segmFolderPath = segmFolderPath + self.mutex = QMutex() + self.waitCond = QWaitCondition() + + @worker_exception_handler + def run(self): + allChannels = self.allChannels + frame_name_pattern = self.frame_name_pattern + rootFolderPath = self.rootFolderPath + dstFolderPath = self.dstFolderPath + segmFolderPath = self.segmFolderPath + filesInfo = {} + self.signals.initProgressBar.emit(len(self.validFilenames) + 1) + for file in self.validFilenames: + try: + # Determine which channel is this file + for ch in allChannels: + m = re.findall(rf"(.*)_{ch}{frame_name_pattern}", file) + if m: + break + else: + raise FileNotFoundError( + f'The file name "{file}" does not contain any channel name' + ) + posName, _, frameName = m[0] + frameNumber = int(frameName) + if posName not in filesInfo: + filesInfo[posName] = {ch: [(file, frameNumber)]} + elif ch not in filesInfo[posName]: + filesInfo[posName][ch] = [(file, frameNumber)] + else: + filesInfo[posName][ch].append((file, frameNumber)) + except Exception as e: + self.logger.log(traceback.format_exc()) + self.logger.log( + f'WARNING: File "{file}" does not contain valid pattern. ' + "Skipping it." + ) + continue + + self.signals.progressBar.emit(1) + + df_metadata = None + partial_basename = self.basename + allPosDataInfo = [] + for p, (posName, channelInfo) in enumerate(filesInfo.items()): + self.logger.log(f"=" * 40) + self.logger.log(f'Processing position "{posName}"...') + + for _, filesList in channelInfo.items(): + # Get info from first file + filePath = os.path.join(rootFolderPath, filesList[0][0]) + try: + img = load.imread(filePath) + break + except Exception as e: + self.logger.log(traceback.format_exc()) + continue + else: + self.logger.log( + f"WARNING: No valid image files found for position {posName}" + ) + continue + + # Get basename + if partial_basename: + basename = f"{partial_basename}_{posName}_" + else: + basename = f"{posName}_" + + # Get SizeT from first file + SizeT = len(filesList) + + # Save metadata.csv + df_metadata = pd.DataFrame( + {"SizeT": SizeT, "basename": basename}, index=["values"] + ) + + # Iterate channels + for c, (channelName, filesList) in enumerate(channelInfo.items()): + self.logger.log(f' Processing channel "{channelName}"...') + # Sort by frame number + sortedFilesList = sorted(filesList, key=lambda t: t[1]) + + df_metadata[f"channel_{c}_name"] = [channelName] + + imagesPath = os.path.join(dstFolderPath, f"Position_{p + 1}", "Images") + if not os.path.exists(imagesPath): + os.makedirs(imagesPath, exist_ok=True) + + # Iterate frames + videoData = None + srcSegmPaths = [""] * SizeT + frameNumbers = [] + for frame_i, fileInfo in enumerate(sortedFilesList): + file, _ = fileInfo + ext = os.path.splitext(file)[1] + srcImgFilePath = os.path.join(rootFolderPath, file) + try: + img = load.imread(srcImgFilePath) + if videoData is None: + shape = (SizeT, *img.shape) + videoData = np.zeros(shape, dtype=img.dtype) + videoData[frame_i] = img + pattern = self.frame_name_pattern + frameNumberMatch = re.findall(pattern, file)[0][1] + frameNumber = int(frameNumberMatch) + frameNumbers.append(frameNumber) + except Exception as e: + self.logger.log(traceback.format_exc()) + continue + + if segmFolderPath and c == 0: + srcSegmFilePath = os.path.join(segmFolderPath, file) + srcSegmPaths[frame_i] = srcSegmFilePath + + SizeZ = 1 + if img.ndim == 3: + SizeZ = len(img) + + df_metadata["SizeZ"] = [SizeZ] + + self.signals.progressBar.emit(1) + + if videoData is None: + self.logger.log( + f"WARNING: No valid image files found for position " + f'"{posName}", channel "{channelName}"' + ) + continue + else: + imgFileName = f"{basename}{channelName}.tif" + dstImgFilePath = os.path.join(imagesPath, imgFileName) + dstSegmFileName = f"{basename}segm_{channelName}.npz" + dstSegmPath = os.path.join(imagesPath, dstSegmFileName) + imgDataInfo = { + "path": dstImgFilePath, + "SizeT": SizeT, + "SizeZ": SizeZ, + "data": videoData, + "frameNumbers": frameNumbers, + "dst_segm_path": dstSegmPath, + "src_segm_paths": srcSegmPaths, + } + allPosDataInfo.append(imgDataInfo) + + if df_metadata is not None: + metadata_csv_path = os.path.join(imagesPath, f"{basename}metadata.csv") + df_metadata = df_metadata.T + df_metadata.index.name = "Description" + df_metadata.to_csv(metadata_csv_path) + + self.logger.log(f"*" * 40) + + if not allPosDataInfo: + self.signals.finished.emit(self) + return + + self.signals.initProgressBar.emit(len(allPosDataInfo)) + self.logger.log("Saving image files...") + maxSizeT = max([d["SizeT"] for d in allPosDataInfo]) + minFrameNumber = min([d["frameNumbers"][0] for d in allPosDataInfo]) + # Pad missing frames in video files according to frame number + for p, imgDataInfo in enumerate(allPosDataInfo): + SizeT = imgDataInfo["SizeT"] + SizeZ = imgDataInfo["SizeZ"] + dstImgFilePath = imgDataInfo["path"] + videoData = imgDataInfo["data"] + frameNumbers = imgDataInfo["frameNumbers"] + paddedShape = (maxSizeT, *videoData.shape[1:]) + imgDataInfo["paddedShape"] = paddedShape + dtype = videoData.dtype + paddedVideoData = np.zeros(paddedShape, dtype=dtype) + for n, img in zip(frameNumbers, videoData): + frame_i = n - minFrameNumber + paddedVideoData[frame_i] = img + + del videoData + imgDataInfo["data"] = None + + self.mutex.lock() + self.sigSaveTiff.emit(dstImgFilePath, paddedVideoData, self.waitCond) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + self.signals.progressBar.emit(1) + + if not segmFolderPath: + self.signals.finished.emit(self) + return + + self.signals.initProgressBar.emit(len(allPosDataInfo)) + self.logger.log("Saving segmentation files...") + for p, imgDataInfo in enumerate(allPosDataInfo): + SizeT = imgDataInfo["SizeT"] + frameNumbers = imgDataInfo["frameNumbers"] + SizeT = imgDataInfo["SizeT"] + SizeZ = imgDataInfo["SizeZ"] + frameNumbers = imgDataInfo["frameNumbers"] + paddedShape = imgDataInfo["paddedShape"] + segmData = np.zeros(paddedShape, dtype=np.uint32) + for n, segmFilePath in zip(frameNumbers, imgDataInfo["src_segm_paths"]): + frame_i = n - minFrameNumber + try: + lab = load.imread(segmFilePath).astype(np.uint32) + segmData[frame_i] = lab + except Exception as e: + self.logger.log(traceback.format_exc()) + self.logger.log( + "WARNING: The following segmentation file does not " + f'exist, saving empty masks: "{srcSegmFilePath}"' + ) + + io.savez_compressed(imgDataInfo["dst_segm_path"], segmData) + del segmData + + self.signals.finished.emit(self) + + +class FucciPreprocessWorker(BaseWorkerUtil): + sigAskAppendName = Signal(str) + sigAskParams = Signal(object, object) + sigAborted = Signal() + + def __init__(self, mainWin): + super().__init__(mainWin) + + def emitAskParams(self, exp_path, pos_foldernames): + self.mutex.lock() + self.sigAskParams.emit(exp_path, pos_foldernames) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def applyPipeline(self, first_ch_data, second_ch_data, filter_kwargs): + processed_data = np.zeros(first_ch_data.shape, dtype=np.uint8) + pbar = tqdm(total=len(processed_data), ncols=100) + with concurrent.futures.ThreadPoolExecutor() as executor: + iterable = enumerate(zip(first_ch_data, second_ch_data)) + func = partial(core.fucci_pipeline_executor_map, **filter_kwargs) + result = executor.map(func, iterable) + for frame_i, processed_img in result: + processed_img = skimage.exposure.rescale_intensity( + processed_img, out_range=(0, 255) + ) + processed_img = processed_img.astype(np.uint8) + processed_data[frame_i] = processed_img + pbar.update() + pbar.close() + + return processed_data + + @worker_exception_handler + def run(self): + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + self.mainWin.infoText = f"Setup parameters" + + if i == 0: + abort = self.emitAskParams(exp_path, pos_foldernames) + if abort: + self.sigAborted.emit() + return + + # Ask appendend name + self.mutex.lock() + self.sigAskAppendName.emit(self.basename) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + self.sigAborted.emit() + return + + appendedName = self.appendedName + self.signals.initProgressBar.emit(len(pos_foldernames)) + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.sigAborted.emit() + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + images_path = os.path.join(exp_path, pos, "Images") + + self.logger.log(f"Loading {self.firstChannelName} channel data...") + first_ch_filepath = load.get_filename_from_channel( + images_path, self.firstChannelName + ) + first_ch_data = load.load_image_file(first_ch_filepath) + + self.logger.log(f"Loading {self.secondChannelName} channel data...") + second_ch_filepath = load.get_filename_from_channel( + images_path, self.secondChannelName + ) + second_ch_data = load.load_image_file(second_ch_filepath) + + self.logger.log("Applying FUCCI pre-processing pipeline...\n") + processed_data = self.applyPipeline( + first_ch_data, second_ch_data, self.fucciFilterKwargs + ) + + basename, chNames = myutils.getBasenameAndChNames(images_path) + _, ext = os.path.splitext(first_ch_filepath) + processed_filename = f"{basename}{appendedName}{ext}" + processed_filepath = os.path.join(images_path, processed_filename) + self.logger.log( + f'Saving pre-processed images to "{processed_filepath}"...' + ) + io.save_image_data(processed_filepath, processed_data) + + self.signals.progressBar.emit(1) + + self.signals.finished.emit(self) + + +class SaveProcessedDataWorker(QObject): + def __init__( + self, + allPosData: Iterable["load.loadData"], + appended_text_filename: str, + ext: str = None, + ): + QObject.__init__(self) + self.allPosData = allPosData + self.signals = signals() + self.logger = workerLogger(self.signals.progress) + self.appended_text_filename = appended_text_filename + self.ext = ext + + @worker_exception_handler + def run(self): + self.signals.initProgressBar.emit(0) + for posData in self.allPosData: + ext_loc = self.ext if self.ext is not None else posData.ext + processed_filename = ( + f"{posData.basename}{posData.user_ch_name}_" + f"{self.appended_text_filename}{ext_loc}" + ) + processed_filepath = os.path.join(posData.images_path, processed_filename) + self.logger.log(f"Saving {processed_filepath}...") + processed_data = posData.preprocessedDataArray() + if processed_data is None: + self.logger.log( + f"[WARNING]: {posData.pos_foldername} does not have " + "preprocessed data. Skipping it." + ) + continue + + io.save_image_data(processed_filepath, processed_data) + + self.signals.finished.emit(self) + + +class SaveCombinedChannelsWorker(QObject): + sigDebugShowImg = Signal(object) + + def __init__( + self, allPosData: Iterable["load.loadData"], filename: str, debug: bool = False + ): + QObject.__init__(self) + self.allPosData = allPosData + self.signals = signals() + self.logger = workerLogger(self.signals.progress) + self.filename = filename + self.debug = debug + + @worker_exception_handler + def run(self): + self.signals.initProgressBar.emit(0) + for posData in self.allPosData: + processed_filepath = os.path.join(posData.images_path, self.filename) + self.logger.log(f"Saving {processed_filepath}...") + processed_data = posData.combinedChannelsDataArray() + if processed_data is None: + self.logger.log( + f"[WARNING]: {posData.pos_foldername} does not have " + "combined channels data. Skipping it." + ) + continue + if self.debug: + printl(processed_data.shape) + printl(processed_data.dtype) + printl(processed_data.min()) + printl(processed_data.max()) + printl(processed_filepath) + self.sigDebugShowImg.emit(processed_data) + # cellacdc.plot.imshow(processed_data) + io.save_image_data(processed_filepath, processed_data) + + self.signals.finished.emit(self) + + +class CustomPreprocessWorkerGUI(QObject): + sigDone = Signal(object, str) + sigPreviewDone = Signal(object, tuple) + sigIsQueueEmpty = Signal(bool) + + def __init__(self, mutex, waitCond): + QObject.__init__(self) + self.signals = signals() + self.mutex = mutex + self.waitCond = waitCond + self.logger = workerLogger(self.signals.progress) + self.dataQ = deque(maxlen=2) + self.exit = False + self.wait = True + self._abort = False + + def enqueue( + self, + func: Callable, + image: np.ndarray, + recipe: Dict[str, Any], + key: Tuple[int, int, Union[int, str]], + ): + self.dataQ.append((func, image, recipe, key)) + if len(self.dataQ) == 1: + self.sigIsQueueEmpty.emit(False) + # Wake up worker upon inserting first element + self.wakeUp() + + def wakeUp(self): + self.wait = False + self.waitCond.wakeAll() + + def pause(self): + self.wait = True + self.mutex.lock() + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def abort(self): + self._abort = True + + def stop(self): + self.abort() + self.exit = True + self.waitCond.wakeAll() + self.signals.finished.emit(self) + + def setupJob( + self, func: Callable, image_data: np.ndarray, recipe: Dict[str, Any], how: str + ): + self._func = func + self._image_data = image_data + self._recipe = recipe + self._how = how + + def runJob(self, image=None, recipe=None): + if image is None: + image = self._image_data.copy() + if recipe is None: + recipe = self._recipe + + return self.applyRecipe(self._func, image, recipe) + + def applyRecipe( + self, func: Callable, image: np.ndarray, recipe: List[Dict[str, Any]] + ): + preprocessed_data = func(image, recipe) + + keep_input_data_type = recipe[0].get("keep_input_data_type", True) + if not keep_input_data_type: + return preprocessed_data + + try: + preprocessed_data = myutils.convert_to_dtype(preprocessed_data, image.dtype) + except Exception as err: + preprocessed_data = preprocessed_data.astype(image.dtype) + return preprocessed_data + + @worker_exception_handler + def run(self): + while True: + if self.exit: + self.logger.log("Closing pre-processing worker...") + break + elif self.wait: + self.logger.log("Pre-processing worker paused.") + self.pause() + elif len(self.dataQ) > 0: + func, image, recipe, key = self.dataQ.pop() + processed_data = self.applyRecipe(func, image, recipe) + self.sigPreviewDone.emit(processed_data, key) + if len(self.dataQ) == 0: + self.wait = True + self.sigIsQueueEmpty.emit(True) + else: + self.logger.log("Pre-processing worker resumed.") + processed_data = self.runJob() + self.sigDone.emit(processed_data, self._how) + self.wait = True + + self.signals.finished.emit(self) + + +class CombineChannelsWorkerGUI(CustomPreprocessWorkerGUI): + sigDone = Signal(object, list) + sigPreviewDone = Signal(object, list) + sigAskLoadChannels = Signal(set, object) + + def __init__( + self, + mutex, + waitCond, + logger_func: Callable, + ): + # signals_parent=None): + super().__init__(mutex, waitCond) + + self.waitCondLoadFluoChannels = QWaitCondition() + self.logger_func = logger_func + + # if not signals_parent: + # signals_parent = signals() + + # self.signals = signals_parent + + def enqueue( + self, + data, + steps: Dict[str, Any], + key: Tuple[int, int, Union[int, str]], + keep_input_data_type: bool, + output_as_segm: bool, + formula: str, + ): + self.dataQ.append( + (data, steps, key, keep_input_data_type, output_as_segm, formula) + ) + if len(self.dataQ) == 1: + self.sigIsQueueEmpty.emit(False) + # Wake up worker upon inserting first element + self.wakeUp() + + def setupJob( + self, + data: Dict[str, np.ndarray], + steps: Dict[str, Any], + keep_input_data_type: bool, + key: Tuple[Union[int, None], Union[int, None], Union[int, None]], + output_as_segm: bool, + formula: str, + ): + self._key = key + self._steps = steps + self._data = data + self._keep_input_data_type = keep_input_data_type + self._output_as_segm = output_as_segm + self._formula = formula + + def runJob( + self, + data=None, + steps=None, + keep_input_data_type=None, + key=None, + output_as_segm=None, + formula=None, + ): + if data is None: + data = self._data + if steps is None: + steps = self._steps + if keep_input_data_type is None: + keep_input_data_type = self._keep_input_data_type + if key is None: + key = self._key + if output_as_segm is None: + output_as_segm = self._output_as_segm + if formula is None: + formula = self._formula + + if not steps and formula is None: + return + + return self.applySteps( + data, steps, keep_input_data_type, key, output_as_segm, formula=formula + ) + + def applySteps( + self, + data: Dict[str, np.ndarray], + steps: List[Dict[str, Any]], + keep_input_data_type: bool, + key: Tuple[Union[int, None], Union[int, None], Union[int, None]], + output_as_segm: bool, + formula: str, + ): + + new_keys = [] + key = list(key) + if key[0] is None: + pos_number = len(data) + key[0] = list(range(pos_number)) + else: + key[0] = [key[0]] + + for pos_i in key[0]: + new_keys_per_pos = [[pos_i]] + if key[1] is None: + frames = data[pos_i].SizeT + new_keys_per_pos.append(list(range(frames))) + else: + new_keys_per_pos.append([key[1]]) + + if key[2] is None: + z_slices = data[pos_i].SizeZ + if not z_slices: + z_slices = 1 + new_keys_per_pos.append(list(range(z_slices))) + else: + new_keys_per_pos.append([key[2]]) + + new_keys_per_pos = list(itertools.product(*new_keys_per_pos)) + new_keys.extend(new_keys_per_pos) + + output_imgs, out_keys = core.combine_channels_multithread_return_imgs( + steps=steps, + data=data, + keep_input_data_type=keep_input_data_type, + keys=new_keys, + logger_func=self.logger, + signals=self.signals, + output_as_segm=output_as_segm, + formula=formula, + ) + return output_imgs, out_keys + + def requiredChannels(self, steps=None, pos_i=None): + if steps is None: + steps = self._steps + + required_channels = core.get_selected_channels(steps) + if pos_i is None: + pos_i = self._key[0] + + return required_channels, pos_i + + @worker_exception_handler + def run(self): + while True: + if self.exit: + self.logger.log("Closing combining channels worker...") + break + elif self.wait: + self.logger.log("Combining channels worker paused.") + self.pause() + elif len(self.dataQ) > 0: + data, steps, key, keep_input_data_type, output_as_segm, formula = ( + self.dataQ.pop() + ) + requ_steps, pos_i = self.requiredChannels(steps, key[0]) + self.emitsigAskLoadChannels(requ_steps, pos_i) + output_imgs, out_keys = self.applySteps( + data, + steps, + keep_input_data_type, + key, + output_as_segm=output_as_segm, + formula=formula, + ) + self.sigPreviewDone.emit(output_imgs, out_keys) + if len(self.dataQ) == 0: + self.wait = True + self.sigIsQueueEmpty.emit(True) + else: + self.logger.log("Combining channels worker resumed.") + requ_steps, pos_i = self.requiredChannels() + self.emitsigAskLoadChannels(requ_steps, pos_i) + output_imgs, out_keys = self.runJob() + self.sigDone.emit(output_imgs, out_keys) + self.wait = True + + self.signals.finished.emit(self) + + def emitsigAskLoadChannels(self, requChannels, pos_i): + self.mutex.lock() + self.sigAskLoadChannels.emit(requChannels, pos_i) + self.waitCondLoadFluoChannels.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def wake_waitCondLoadFluoChannels(self): + self.mutex.lock() + self.waitCondLoadFluoChannels.wakeAll() + self.mutex.unlock() + + +class CustomPreprocessWorkerUtil(BaseWorkerUtil): + sigAskAppendName = Signal(str) + sigAskSetupRecipe = Signal(object, object) + sigAborted = Signal() + + def __init__(self, mainWin): + super().__init__(mainWin) + + def emitAskSetupRecipe(self, exp_path, pos_foldernames): + self.mutex.lock() + self.sigAskSetupRecipe.emit(exp_path, pos_foldernames) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def applyPipeline( + self, + images_path: os.PathLike, + channel_names: Iterable[str], + recipe: List[Dict[str, Any]], + appended_text_filename: str, + ): + posData = None + preprocessed_data = {} + for channel in channel_names: + self.logger.log(f"Loading {channel} channel data...") + ch_filepath = load.get_filename_from_channel(images_path, channel) + ch_image_data = load.load_image_file(ch_filepath) + if posData is None: + posData = load.loadData(ch_filepath, channel) + posData.getBasenameAndChNames() + posData.buildPaths() + posData.loadOtherFiles( + load_segm_data=False, + load_metadata=True, + ) + if posData.SizeT == 1: + ch_image_data = (ch_image_data,) + + preprocessed_ch_data = core.preprocess_image_from_recipe_multithread( + ch_image_data, recipe + ) + + keep_input_data_type = recipe[0].get("keep_input_data_type", True) + if keep_input_data_type: + preprocessed_ch_data = myutils.convert_to_dtype( + preprocessed_ch_data, ch_image_data.dtype + ) + + _, ext = os.path.splitext(ch_filepath) + basename = posData.basename + processed_filename = f"{basename}{channel}_{appended_text_filename}{ext}" + preprocessed_data[processed_filename] = preprocessed_ch_data + + return preprocessed_data + + @worker_exception_handler + def run(self): + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + self.mainWin.infoText = "Setup recipe" + + if i == 0: + abort = self.emitAskSetupRecipe(exp_path, pos_foldernames) + if abort: + self.sigAborted.emit() + return + + # Ask append name + self.mutex.lock() + basename = f"{self.basename}{self.selectedChannels[0]}_" + self.sigAskAppendName.emit(basename) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + self.sigAborted.emit() + return + + appendedName = self.appendedName + self.signals.initProgressBar.emit(len(pos_foldernames)) + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.sigAborted.emit() + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + images_path = os.path.join(exp_path, pos, "Images") + self.logger.log("Applying custom pre-processing recipe...\n") + processed_data = self.applyPipeline( + images_path, self.selectedChannels, self.recipe, appendedName + ) + + for filename, preprocessed_ch_data in processed_data.items(): + preprocessed_filepath = os.path.join(images_path, filename) + self.logger.log( + f'Saving pre-processed images to "{preprocessed_filepath}"...' + ) + + io.save_image_data(preprocessed_filepath, preprocessed_ch_data) + self.signals.progressBar.emit(1) + + self.signals.finished.emit(self) + + +class CombineChannelsWorkerUtil(BaseWorkerUtil): + sigAskAppendName = Signal(str) + sigAskSetup = Signal(object) + sigAborted = Signal() + + def __init__(self, mainWin, mutex=None, waitCond=None): + super().__init__(mainWin) + + def emitAskSetup(self, expPaths): + self.mutex.lock() + self.sigAskSetup.emit(expPaths) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def applyPipeline( + self, + image_paths: os.PathLike, + steps: Dict[str, Dict[str, Any]], + appended_text_filename: str, + keep_input_data_type: bool, + n_threads: int = None, + formula: str = None, + ): + save_filepaths = [] + images_path_to_process = [] + if self.saveAsSegm: + out_ext = ".npz" + basename_ext = "segm_" + else: + out_ext = ".tif" + basename_ext = "" + for images_path in image_paths: + basename, channels = myutils.getBasenameAndChNames(images_path) + + savename = f"{basename}{basename_ext}{appended_text_filename}{out_ext}" + + images_path_to_process.append(images_path) + save_filepaths.append(os.path.join(images_path, savename)) + + core.combine_channels_multithread( + steps=steps, + images_paths=images_path_to_process, + keep_input_data_type=keep_input_data_type, + save_filepaths=save_filepaths, + signals=self.signals, + logger_func=self.logger.log, + n_threads=n_threads, + output_as_segm=self.saveAsSegm, + formula=formula, + ) + + @worker_exception_handler + def run(self): + + self.signals.initProgressBar.emit(0) + + expPaths = self.mainWin.expPaths + abort = self.emitAskSetup(expPaths) + if abort: + self.sigAborted.emit() + return + + # Ask append name + self.mutex.lock() + basename = f"{self.basename}" + self.sigAskAppendName.emit(basename) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + self.sigAborted.emit() + return + + appendedName = self.appendedName + + selectedSteps = self.selectedSteps + + self.logger.log("Applying pipeline...") + self.logger.log("Selected steps:") + for step in selectedSteps.values(): + self.logger.log(step) + + image_paths = [] + for exp_path, pos_foldernames in expPaths.items(): + image_paths += [ + os.path.join(exp_path, pos, "Images") for pos in pos_foldernames + ] + + self.signals.initProgressBar.emit(len(pos_foldernames)) + formula = self.formula + self.applyPipeline( + image_paths, + selectedSteps, + appendedName, + self.keepInputDataType, + n_threads=self.nThreads, + formula=formula, + ) + + self.signals.finished.emit(self) + +# Sibling imports (deferred to avoid import cycles) +from ._base import ( + signals, + workerLogger, + worker_exception_handler, +) + diff --git a/cellacdc/workers/gui.py b/cellacdc/workers/gui.py new file mode 100644 index 000000000..aa7a8d2e2 --- /dev/null +++ b/cellacdc/workers/gui.py @@ -0,0 +1,110 @@ +"""Background Qt workers: gui.""" + +import re +import os +import shutil +import time +import json +import concurrent.futures +from functools import partial +from collections import defaultdict, deque +import itertools + +from typing import Union, List, Dict, Callable, Any, Tuple, Iterable + +from functools import wraps +import numpy as np +import pandas as pd +import h5py +import traceback + +import skimage.io +import skimage.measure +import skimage.exposure + +import queue + +from tqdm import tqdm + +from qtpy.QtCore import Signal, QObject, QMutex, QWaitCondition + +from cellacdc import html_utils + +from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import transformation, measurements, cca_functions +from ..path import copy_or_move_tree +from .. import features, plot +from .. import core +from .. import cca_df_colnames, lineage_tree_cols, default_annot_df +from .. import cca_df_colnames_with_tree +from .. import cli +from ..utils import resize +from .. import segm_utils + +DEBUG = False + +class AutoPilotWorker(QObject): + finished = Signal() + critical = Signal(object) + progress = Signal(str, object) + sigStarted = Signal() + sigStopTimer = Signal() + + def __init__(self, guiWin): + QObject.__init__(self) + self.logger = workerLogger(self.progress) + self.guiWin = guiWin + self.app = guiWin.app + # self.timer = timer + + def timerCallback(self): + pass + + def stop(self): + self.sigStopTimer.emit() + self.finished.emit() + + def run(self): + self.sigStarted.emit() + + +class FindNextNewIdWorker(QObject): + def __init__(self, posData, guiWin): + QObject.__init__(self) + self.signals = signals() + self.logger = workerLogger(self.signals.progress) + self.posData = posData + self.guiWin = guiWin + + @worker_exception_handler + def run(self): + prev_IDs = None + next_frame_i = -1 + for frame_i, data_dict in enumerate(self.posData.allData_li): + lab = data_dict["labels"] + rp = data_dict["regionprops"] + IDs = data_dict["IDs"] + if lab is None: + lab = self.posData.segm_data[frame_i] + rp = skimage.measure.regionprops(lab) + IDs = [obj.label for obj in rp] + + if prev_IDs is None: + prev_IDs = IDs + continue + + newIDs = [ID for ID in IDs if ID not in prev_IDs] + if newIDs: + next_frame_i = frame_i + break + prev_IDs = IDs + + self.signals.finished.emit(next_frame_i) + +# Sibling imports (deferred to avoid import cycles) +from ._base import ( + signals, + workerLogger, + worker_exception_handler, +) + diff --git a/cellacdc/workers/io.py b/cellacdc/workers/io.py new file mode 100644 index 000000000..90222b062 --- /dev/null +++ b/cellacdc/workers/io.py @@ -0,0 +1,1117 @@ +"""Background Qt workers: io.""" + +import re +import os +import shutil +import time +import json +import concurrent.futures +from functools import partial +from collections import defaultdict, deque +import itertools + +from typing import Union, List, Dict, Callable, Any, Tuple, Iterable + +from functools import wraps +import numpy as np +import pandas as pd +import h5py +import traceback + +import skimage.io +import skimage.measure +import skimage.exposure + +import queue + +from tqdm import tqdm + +from qtpy.QtCore import Signal, QObject, QMutex, QWaitCondition + +from cellacdc import html_utils + +from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import transformation, measurements, cca_functions +from ..path import copy_or_move_tree +from .. import features, plot +from .. import core +from .. import cca_df_colnames, lineage_tree_cols, default_annot_df +from .. import cca_df_colnames_with_tree +from .. import cli +from ..utils import resize +from .. import segm_utils + +DEBUG = False + +class StoreGuiStateWorker(QObject): + finished = Signal(object) + sigDone = Signal() + progress = Signal(str, object) + + def __init__(self, mutex, waitCond): + QObject.__init__(self) + self.mutex = mutex + self.waitCond = waitCond + self.exit = False + self.isFinished = False + self.q = queue.Queue() + self.logger = workerLogger(self.progress) + + def pause(self): + self.mutex.lock() + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def enqueue(self, posData, img1): + self.q.put((posData, img1)) + self.waitCond.wakeAll() + + def _stop(self): + self.exit = True + self.waitCond.wakeAll() + + def run(self): + while True: + if self.exit: + self.logger.log("Closing store state worker...") + break + elif not self.q.empty(): + posData, img1 = self.q.get() + # self.logger.log('Storing state...') + if posData.cca_df is not None: + cca_df = posData.cca_df.copy() + else: + cca_df = None + + state = { + "image": img1.copy(), + "labels": posData.storedLab.copy(), + "editID_info": posData.editID_info.copy(), + "binnedIDs": posData.binnedIDs.copy(), + "ripIDs": posData.ripIDs.copy(), + "cca_df": cca_df, + } + posData.UndoRedoStates[posData.frame_i].insert(0, state) + if self.q.empty(): + # self.logger.log('State stored...') + self.sigDone.emit() + else: + self.pause() + + self.isFinished = True + self.finished.emit(self) + + +class AutoSaveWorker(QObject): + finished = Signal(object) + sigDone = Signal() + critical = Signal(object) + progress = Signal(str, object) + sigStartTimer = Signal(object, object) + sigStopTimer = Signal() + sigAutoSaveCannotProceed = Signal() + + def __init__(self, mutex, waitCond, savedSegmData): + QObject.__init__(self) + self.savedSegmData = savedSegmData + self.logger = workerLogger(self.progress) + self.mutex = mutex + self.waitCond = waitCond + self.exit = False + self.isFinished = False + self.stopSaving = False + self.isSaving = False + self.isPaused = False + self.dataQ = deque(maxlen=5) + self.isAutoSaveON = False + self.isAutoSaveAnnotON = True + self.debug = False + + def pause(self): + if self.debug: + self.logger.log("Autosaving is idle.") + self.mutex.lock() + self.isPaused = True + self.waitCond.wait(self.mutex) + self.mutex.unlock() + self.isPaused = False + + def enqueue(self, posData): + # First stop previously saving data + if self.isSaving: + self.stopSaving = True + self._enqueue(posData) + + def _enqueue(self, posData): + if self.debug: + self.logger.log("Enqueing posData autosave...") + self.dataQ.append(posData) + if len(self.dataQ) == 1: + # Wake up worker upon inserting first element + self.stopSaving = False + self.waitCond.wakeAll() + + def _stop(self): + self.exit = True + self.waitCond.wakeAll() + + def stop(self): + self.stopSaving = True + while not len(self.dataQ) == 0: + data = self.dataQ.pop() + del data + self._stop() + + def cancelSaving(self): ... + + @worker_exception_handler + def run(self): + while True: + if self.exit: + self.logger.log("Closing autosaving worker...") + break + elif not len(self.dataQ) == 0: + if self.debug: + self.logger.log("Autosaving...") + data = self.dataQ.pop() + self.isSaving = True + try: + self.saveData(data) + except Exception as e: + error = traceback.format_exc() + print("*" * 40) + self.logger.log(error) + print("=" * 40) + self.isSaving = False + + if len(self.dataQ) == 0: + self.sigDone.emit() + else: + self.pause() + self.isFinished = True + self.finished.emit(self) + if self.debug: + self.logger.log("Autosave finished signal emitted") + + def getLastTrackedFrame(self, posData): + last_tracked_i = 0 + for frame_i, data_dict in enumerate(posData.allData_li): + lab = data_dict["labels"] + if lab is None: + frame_i -= 1 + break + if frame_i > 0: + return frame_i + else: + return last_tracked_i + + def saveData(self, posData): + if self.debug: + self.logger.log("Started autosaving...") + + if not self.isAutoSaveON and not self.isAutoSaveAnnotON: + return + + try: + posData.setTempPaths() + except Exception as e: + self.logger.log( + "[WARNING]: Cell-ACDC cannot create the recovery folder for " + "the autosaving process. Autosaving will be turned off." + ) + self.sigAutoSaveCannotProceed.emit() + return + segm_npz_path = posData.segm_npz_temp_path + + end_i = self.getLastTrackedFrame(posData) + + saved_segm_data = None + if self.isAutoSaveON: + if end_i < len(posData.segm_data): + saved_segm_data = posData.segm_data + else: + frame_shape = posData.segm_data.shape[1:] + segm_shape = (end_i + 1, *frame_shape) + saved_segm_data = np.zeros(segm_shape, dtype=np.uint32) + + keys = [] + acdc_df_li = [] + + for frame_i, data_dict in enumerate(posData.allData_li[: end_i + 1]): + if self.stopSaving: + break + + # Build saved_segm_data + lab = data_dict["labels"] + if lab is None: + break + + if self.isAutoSaveON and saved_segm_data is not None: + if posData.SizeT > 1: + saved_segm_data[frame_i] = lab + else: + saved_segm_data = lab + + if self.isAutoSaveAnnotON: + acdc_df = data_dict["acdc_df"] + + if acdc_df is None: + continue + + if not np.any(lab): + continue + + if self.isAutoSaveAnnotON: + acdc_df = load.pd_bool_and_float_to_int_to_str( + acdc_df, inplace=False, colsToCastInt=[] + ) + + acdc_df_li.append(acdc_df) + key = (frame_i, posData.TimeIncrement * frame_i) + keys.append(key) + + if self.stopSaving: + break + + if not self.stopSaving: + if self.isAutoSaveON: + segm_data = np.squeeze(saved_segm_data) + self._saveSegm(segm_npz_path, segm_data) + + if acdc_df_li: + all_frames_acdc_df = pd.concat( + acdc_df_li, keys=keys, names=["frame_i", "time_seconds", "Cell_ID"] + ) + self._save_acdc_df(all_frames_acdc_df, posData) + + if self.debug: + self.logger.log(f"Autosaving done.") + self.logger.log(f"Stopped autosaving {self.stopSaving}.") + + self.stopSaving = False + + def _saveSegm(self, recovery_path, data): + try: + equalToSavedSegm = np.all(self.savedSegmData == data) + except Exception as err: + return + + if equalToSavedSegm: + return + else: + io.savez_compressed(recovery_path, np.squeeze(data)) + + def _save_acdc_df(self, recovery_acdc_df: pd.DataFrame, posData): + recovery_folderpath = posData.recoveryFolderpath() + if not os.path.exists(posData.acdc_output_csv_path): + load.store_unsaved_acdc_df(recovery_folderpath, recovery_acdc_df) + return + + saved_acdc_df_path = posData.acdc_output_csv_path + saved_acdc_df = pd.read_csv( + saved_acdc_df_path, dtype=load.acdc_df_str_cols + ).set_index(["frame_i", "Cell_ID"]) + + recovery_acdc_df = recovery_acdc_df.reset_index( + allow_duplicates=True + ).set_index(["frame_i", "Cell_ID"]) + recovery_acdc_df = recovery_acdc_df.loc[ + :, ~recovery_acdc_df.columns.duplicated() + ] + try: + # Try to insert into the recovery_acdc_df any column that was saved + # but is not in the recovered df (e.g., metrics) + df_left = recovery_acdc_df + existing_cols = df_left.columns.intersection(saved_acdc_df.columns) + df_right = saved_acdc_df.drop(columns=existing_cols) + recovery_acdc_df = df_left.join(df_right, how="left") + except Exception as error: + self.logger.log(f"[WARNING]: {error}") + + # Check if last saved acdc_df is equal + last_unsaved_csv_path = load.get_last_stored_unsaved_acdc_df_filepath( + recovery_folderpath + ) + if last_unsaved_csv_path is None: + reference_acdc_df = saved_acdc_df + else: + try: + reference_acdc_df = pd.read_csv( + last_unsaved_csv_path, dtype=load.acdc_df_str_cols + ).set_index(["frame_i", "Cell_ID"]) + except Exception as e: + self.logger.log(f"[WARNING]: {e}") + reference_acdc_df = saved_acdc_df + + if myutils.are_acdc_dfs_equal(recovery_acdc_df, reference_acdc_df): + return + + load.store_unsaved_acdc_df(recovery_folderpath, recovery_acdc_df) + + +class loadDataWorker(QObject): + def __init__(self, mainWin, user_ch_file_paths, user_ch_name, firstPosData): + QObject.__init__(self) + self.signals = signals() + self.mainWin = mainWin + self.user_ch_file_paths = user_ch_file_paths + self.user_ch_name = user_ch_name + self.logger = workerLogger(self.signals.progress) + self.mutex = self.mainWin.loadDataMutex + self.waitCond = self.mainWin.loadDataWaitCond + self.firstPosData = firstPosData + self.abort = False + self.loadUnsaved = False + self.recoveryAsked = False + self.loadSafeOverwriteNpz = False + + def pause(self): + self.mutex.lock() + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def checkSelectedDataShape(self, posData, numPos): + skipPos = False + abort = False + emitWarning = ( + not posData.segmFound and posData.SizeT > 1 and not self.mainWin.isNewFile + ) + if emitWarning: + self.signals.dataIntegrityWarning.emit(posData.pos_foldername) + self.pause() + abort = self.abort + return skipPos, abort + + def warnMismatchSegmDataShape(self, posData): + self.skipPos = False + self.mutex.lock() + self.signals.sigWarnMismatchSegmDataShape.emit(posData) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.skipPos + + @worker_exception_handler + def run(self): + data = [] + user_ch_file_paths = self.user_ch_file_paths + numPos = len(self.user_ch_file_paths) + user_ch_name = self.user_ch_name + self.signals.initProgressBar.emit(len(user_ch_file_paths)) + for i, file_path in enumerate(user_ch_file_paths): + if i == 0: + posData = self.firstPosData + segmFound = self.firstPosData.segmFound + loadSegm = False + else: + posData = load.loadData(file_path, user_ch_name) + loadSegm = True + + self.logger.log(f"Loading {posData.relPath}...") + + posData.loadSizeS = self.mainWin.loadSizeS + posData.loadSizeT = self.mainWin.loadSizeT + posData.loadSizeZ = self.mainWin.loadSizeZ + posData.SizeT = self.mainWin.SizeT + posData.SizeZ = self.mainWin.SizeZ + posData.isSegm3D = self.mainWin.isSegm3D + + if i > 0: + # First pos was already loaded in the main thread + # see loadSelectedData function in gui.py + posData.getBasenameAndChNames() + posData.buildPaths() + if not self.firstPosData.onlyEditMetadata: + posData.loadImgData() + + if self.firstPosData.onlyEditMetadata: + loadSegm = False + + posData.loadOtherFiles( + load_segm_data=loadSegm, + load_acdc_df=True, + load_shifts=True, + loadSegmInfo=True, + load_delROIsInfo=True, + load_bkgr_data=True, + loadBkgrROIs=True, + load_dataPrep_ROIcoords=True, + load_last_tracked_i=True, + load_metadata=True, + load_customAnnot=True, + load_customCombineMetrics=True, + end_filename_segm=self.mainWin.selectedSegmEndName, + create_new_segm=self.mainWin.isNewFile, + new_endname=self.mainWin.newSegmEndName, + labelBoolSegm=self.mainWin.labelBoolSegm, + ) + posData.labelSegmData() + + if i == 0: + posData.segmFound = segmFound + + posData.addYXcentroidColsIfMissing(show_progress=True) + + isPosSegm3D = posData.getIsSegm3D() + isMismatch = ( + isPosSegm3D != self.mainWin.isSegm3D + and isPosSegm3D is not None + and not self.mainWin.isNewFile + ) + if isMismatch: + skipPos = self.warnMismatchSegmDataShape(posData) + if skipPos: + self.logger.log( + f'Skipping "{posData.relPath}" because segmentation ' + "data shape different from first Position loaded." + ) + continue + else: + data = "abort" + break + + self.logger.log( + "Loaded paths:\n" + f"Segmentation file name: {os.path.basename(posData.segm_npz_path)}\n" + f"ACDC output file name {os.path.basename(posData.acdc_output_csv_path)}" + ) + + posData.SizeT = self.mainWin.SizeT + if self.mainWin.SizeZ > 1: + SizeZ = posData.img_data_shape[-3] + posData.SizeZ = SizeZ + else: + posData.SizeZ = 1 + posData.TimeIncrement = self.mainWin.TimeIncrement + posData.PhysicalSizeZ = self.mainWin.PhysicalSizeZ + posData.PhysicalSizeY = self.mainWin.PhysicalSizeY + posData.PhysicalSizeX = self.mainWin.PhysicalSizeX + posData.isSegm3D = self.mainWin.isSegm3D + posData.saveMetadata( + signals=self.signals, + mutex=self.mutex, + waitCond=self.waitCond, + additionalMetadata=self.firstPosData._additionalMetadataValues, + ) + if hasattr(posData, "img_data_shape"): + SizeY, SizeX = posData.img_data_shape[-2:] + + if posData.SizeZ > 1 and posData.img_data.ndim < 3: + posData.SizeZ = 1 + posData.segmInfo_df = None + try: + os.remove(posData.segmInfo_df_csv_path) + except FileNotFoundError: + pass + + posData.setBlankSegmData(posData.SizeT, posData.SizeZ, SizeY, SizeX) + if not self.firstPosData.onlyEditMetadata: + skipPos, abort = self.checkSelectedDataShape(posData, numPos) + else: + skipPos, abort = False, False + + if skipPos: + continue + elif abort: + data = "abort" + break + + posData.setTempPaths(createFolder=False) + isRecoveredDataPresent = ( + os.path.exists(posData.segm_npz_temp_path) + or posData.isRecoveredAcdcDfPresent() + or posData.isSafeNpzOverwritePresent() + ) + if isRecoveredDataPresent and not self.mainWin.newSegmEndName: + if not self.recoveryAsked: + self.mutex.lock() + self.signals.sigRecovery.emit(posData) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + self.recoveryAsked = True + if self.abort: + data = "abort" + break + if self.loadUnsaved: + self.logger.log("Loading unsaved data...") + if os.path.exists(posData.segm_npz_temp_path): + segm_npz_path = posData.segm_npz_temp_path + posData.segm_data = np.load(segm_npz_path)["arr_0"] + segm_filename = os.path.basename(segm_npz_path) + posData.segm_npz_path = os.path.join( + posData.images_path, segm_filename + ) + + posData.loadMostRecentUnsavedAcdcDf() + elif self.loadSafeOverwriteNpz: + self.logger.log("Loading safe npz overwrite...") + segm_safe_npz_path = posData.getSafeNpzOverwritePath() + posData.segm_data = np.load(segm_safe_npz_path)["arr_0"] + + # Allow single 2D/3D image + if posData.SizeT == 1: + posData.img_data = posData.img_data[np.newaxis] + posData.segm_data = posData.segm_data[np.newaxis] + if hasattr(posData, "img_data_shape"): + img_shape = posData.img_data_shape + img_shape = "Not Loaded" + if hasattr(posData, "img_data_shape"): + datasetShape = posData.img_data.shape + else: + datasetShape = "Not Loaded" + if posData.segm_data is not None: + posData.segmSizeT = len(posData.segm_data) + SizeT = posData.SizeT + SizeZ = posData.SizeZ + self.logger.log(f"Full dataset shape = {img_shape}") + self.logger.log(f"Loaded dataset shape = {datasetShape}") + self.logger.log(f"Number of frames = {SizeT}") + self.logger.log(f"Number of z-slices per frame = {SizeZ}") + data.append(posData) + self.signals.progressBar.emit(1) + + if not data: + data = None + self.signals.dataIntegrityCritical.emit() + + self.signals.finished.emit(data) + + +class LazyLoader(QObject): + sigLoadingFinished = Signal() + + def __init__(self, mutex, waitCond, readH5mutex, waitReadH5cond): + QObject.__init__(self) + self.signals = signals() + self.mutex = mutex + self.waitCond = waitCond + self.exit = False + self.salute = True + self.sender = None + self.H5readWait = False + self.waitReadH5cond = waitReadH5cond + self.readH5mutex = readH5mutex + + def setArgs(self, posData, current_idx, axis, updateImgOnFinished): + self.wait = False + self.updateImgOnFinished = updateImgOnFinished + self.posData = posData + self.current_idx = current_idx + self.axis = axis + + def pauseH5read(self): + self.readH5mutex.lock() + self.waitReadH5cond.wait(self.mutex) + self.readH5mutex.unlock() + + def pause(self): + self.mutex.lock() + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + @worker_exception_handler + def run(self): + while True: + if self.exit: + self.signals.progress.emit("Closing lazy loader...", "INFO") + break + elif self.wait: + self.signals.progress.emit("Lazy loader paused.", "INFO") + self.pause() + else: + self.signals.progress.emit("Lazy loader resumed.", "INFO") + self.posData.loadChannelDataChunk( + self.current_idx, axis=self.axis, worker=self + ) + self.sigLoadingFinished.emit() + self.wait = True + + self.signals.finished.emit(None) + + +class MigrateUserProfileWorker(QObject): + finished = Signal(object) + critical = Signal(object) + progress = Signal(str) + debug = Signal(object) + + def __init__(self, src_path, dst_path, acdc_folders): + QObject.__init__(self) + self.signals = signals() + self.src_path = src_path + self.dst_path = dst_path + self.acdc_folders = acdc_folders + + @worker_exception_handler + def run(self): + import shutil + from . import models_path + + self.progress.emit( + "Migrating user profile data from " + f'"{self.src_path}" to "{self.dst_path}"...' + ) + acdc_folders = self.acdc_folders + self.signals.initProgressBar.emit(2 * len(acdc_folders)) + dst_folder = os.path.basename(self.dst_path) + folders_to_remove = [] + for acdc_folder in acdc_folders: + if acdc_folder == dst_folder: + # Skip the destination folder that would be picked up if the + # user called it with acdc at the start of the name + self.signals.progressBar.emit(2) + continue + src = os.path.join(self.src_path, acdc_folder) + dst = os.path.join(self.dst_path, acdc_folder) + self.progress.emit(f"Copying {src} to {dst}...") + files_failed_move = copy_or_move_tree( + src, + dst, + copy=False, + sigInitPbar=self.signals.sigInitInnerPbar, + sigUpdatePbar=self.signals.sigUpdateInnerPbar, + ) + folders_to_remove.append(src) + self.signals.progressBar.emit(1) + + for to_remove in folders_to_remove: + try: + self.progress.emit(f'Removing "{to_remove}"...') + shutil.rmtree(to_remove) + except Exception as err: + self.progress.emit( + "--------------------------------------------------------\n" + f'[WARNING]: Removal of the folder "{to_remove}" failed. ' + "Please remove manually.\n" + "--------------------------------------------------------" + ) + finally: + self.signals.progressBar.emit(1) + + # Update model's paths + load.migrate_models_paths(self.dst_path) + + # Store user profile data folder path + from . import user_profile_path_txt + + os.makedirs(os.path.dirname(user_profile_path_txt), exist_ok=True) + with open(user_profile_path_txt, "w") as txt: + txt.write(self.dst_path) + + self.finished.emit(self) + + +class MoveTempFilesWorker(QObject): + def __init__(self, temp_files_to_move: Dict[os.PathLike, os.PathLike]): + QObject.__init__(self) + self.signals = signals() + self.logger = workerLogger(self.signals.progress) + self.temp_files_to_move = temp_files_to_move + + @worker_exception_handler + def run(self): + for src, dst in self.temp_files_to_move.items(): + self.logger.log(f"Saving channel data to: {dst}...") + shutil.move(src, dst) + tempDir = os.path.dirname(src) + shutil.rmtree(tempDir) + self.signals.progressBar.emit(1) + self.signals.finished.emit(self) + + +class saveDataWorker(QObject): + finished = Signal() + progress = Signal(str) + sigLog = Signal(str) + progressBar = Signal(int, int, float) + critical = Signal(object) + addMetricsCritical = Signal(str, str) + regionPropsCritical = Signal(str, str) + criticalPermissionError = Signal(str) + metricsPbarProgress = Signal(int, int) + askZsliceAbsent = Signal(str, object) + customMetricsCritical = Signal(str, str) + sigCombinedMetricsMissingColumn = Signal(str, str) + sigDebug = Signal(object) + + def __init__(self, mainWin): + QObject.__init__(self) + self.mainWin = mainWin + self.saveWin = mainWin.saveWin + self.mutex = mainWin.mutex + self.waitCond = mainWin.waitCond + self.customMetricsErrors = {} + self.addMetricsErrors = {} + self.regionPropsErrors = {} + self.abort = False + + def checkAbort(self): + if self.saveWin.aborted: + self.finished.emit() + return True + return False + + def saveManualBackgroundData(self, posData, frame_i): + data_dict = posData.allData_li[frame_i] + if "manualBackgroundLab" not in data_dict: + return + + manualBackgrData = data_dict["manualBackgroundLab"] + posData.saveManualBackgroundData(manualBackgrData) + + def emitSigPermissionErrorAndSave( + self, all_frames_acdc_df, acdc_output_csv_path, custom_annot_columns + ): + err_msg = ( + "The below file is open in another app " + "(Excel maybe?).\n\n" + f"{acdc_output_csv_path}\n\n" + 'Close file and then press "Ok".' + ) + self.mutex.lock() + self.criticalPermissionError.emit(err_msg) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + # Save segmentation metadata + load.save_acdc_df_file( + all_frames_acdc_df, + acdc_output_csv_path, + custom_annot_columns=custom_annot_columns, + last_cca_frame_i=self.mainWin.save_cca_until_frame_i, + ) + + def _emitSigDebug(self, stuff_to_debug): + self.mutex.lock() + self.sigDebug.emit(stuff_to_debug) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def emitUpdateProgressBar(self): + t = time.perf_counter() + exec_time = t - self.time_last_pbar_update + self.progressBar.emit(1, -1, exec_time) + self.time_last_pbar_update = t + + def saveAcdcDf(self, posData: load.loadData, end_i): + acdc_dfs_li = [] + keys = [] + self.progress.emit(f"Saving annotations for {posData.relPath}...") + for frame_i, data_dict in enumerate(posData.allData_li[: end_i + 1]): + if self.saveWin.aborted: + self.finished.emit() + return + + # Build saved_segm_data + lab = data_dict["labels"] + if lab is None: + break + + acdc_df = posData.allData_li[frame_i]["acdc_df"] + if acdc_df is None: + continue + + acdc_dfs_li.append(acdc_df) + keys.append((frame_i, posData.TimeIncrement * frame_i)) + + if not acdc_dfs_li: + return + + self.mainWin._measurements_kernel._concat_and_save_acdc_df( + acdc_dfs_li, + keys, + posData, + self.mainWin.save_metrics, + saveDataWorker=self, + last_cca_frame_i=self.mainWin.save_cca_until_frame_i, + ) + + def saveSegmData(self, posData, end_i, saved_segm_data): + self.progress.emit(f"Saving segmentation data for {posData.relPath}...") + + # extend saved_segm_data if needed + if posData.SizeT > 1: + missing_frames_number = end_i + 1 - len(saved_segm_data) + if missing_frames_number > 0: + saved_segm_data = np.concatenate( + ( + saved_segm_data, + np.zeros( + (missing_frames_number, *saved_segm_data.shape[1:]), + dtype=saved_segm_data.dtype, + ), + ), + ) + + for frame_i, data_dict in enumerate(posData.allData_li[: end_i + 1]): + if self.saveWin.aborted: + self.finished.emit() + return + + # Build saved_segm_data + lab = data_dict["labels"] + if lab is None: + break + + posData.lab = lab + + if posData.SizeT > 1: + saved_segm_data[frame_i] = lab + else: + saved_segm_data = lab + if "manualBackgroundLab" in data_dict: + manualBackgrData = data_dict["manualBackgroundLab"] + posData.saveManualBackgroundData(manualBackgrData) + + # Save segmentation file + io.savez_compressed(posData.segm_npz_path, np.squeeze(saved_segm_data)) + posData.segm_data = saved_segm_data + # Allow single 2D/3D image + if posData.SizeT == 1: + posData.segm_data = posData.segm_data[np.newaxis] + + try: + os.remove(posData.segm_npz_temp_path) + except Exception as e: + pass + + @worker_exception_handler + def run(self): + posToSave = self.mainWin.posToSave + if posToSave is None: + numPosToSave = 1 + else: + numPosToSave = len(posToSave) + save_metrics = self.mainWin.save_metrics + if self.isQuickSave: + save_metrics = False + self.time_last_pbar_update = time.perf_counter() + mode = self.mode + for p, posData in enumerate(self.mainWin.data): + if self.saveWin.aborted: + self.finished.emit() + return + + if posToSave is not None: + if posData.pos_foldername not in posToSave: + self.progress.emit(f"Skipping {posData.relPath}") + continue + + last_tracked_i_path = posData.last_tracked_i_path + end_i = self.mainWin.save_until_frame_i + self.saveSegmData(posData, end_i, posData.segm_data) + + posData.saveCustomAnnotationParams() + current_frame_i = posData.frame_i + + posData.saveTrackedLostCentroids() + + if not self.mainWin.isSnapshot: + last_tracked_i = self.mainWin.last_tracked_i + if last_tracked_i is None: + self.mainWin.saveWin.aborted = True + self.finished.emit() + return + elif self.mainWin.isSnapshot: + last_tracked_i = 0 + + if p == 0: + self.progressBar.emit(0, numPosToSave * (last_tracked_i + 1), 0) + + acdc_output_csv_path = posData.acdc_output_csv_path + delROIs_info_path = posData.delROIs_info_path + + # Add segmented channel data for calc metrics if requested + add_user_channel_data = True + for chName in self.mainWin._measurements_kernel.chNamesToSkip: + skipUserChannel = posData.filename.endswith( + chName + ) or posData.filename.endswith(f"{chName}_aligned") + if skipUserChannel: + add_user_channel_data = False + + if add_user_channel_data and not self.isQuickSave: + posData.fluo_data_dict[posData.filename] = posData.img_data + + if not self.isQuickSave: + posData.fluo_bkgrData_dict[posData.filename] = posData.bkgrData + + posData.setLoadedChannelNames() + + if not self.isQuickSave: + self.mainWin.initMetricsToSave(posData) + self.mainWin._measurements_kernel.run( + posData=posData, + stop_frame_n=end_i + 1, + saveDataWorker=self, + save_metrics=self.mainWin.save_metrics, + last_cca_frame_i=self.mainWin.save_cca_until_frame_i, + ) + else: + self.saveAcdcDf(posData, end_i) + + self.progress.emit(f"Saving {posData.relPath}") + + if not self.do_not_save_og_whitelist: + og_save_path = os.path.join( + posData.images_path, self.append_name_og_whitelist + ) + posData.whitelist.saveOGLabs(og_save_path) + + if posData.whitelist: + whitelistIDs_path = posData.segm_npz_path.replace( + ".npz", "_whitelistIDs.json" + ) + new_centroids_path = posData.segm_npz_path.replace( + ".npz", "_new_centroids.json" + ) + posData.whitelist.save( + whitelistIDs_path, new_centroids_path=new_centroids_path + ) + + if posData.segmInfo_df is not None: + try: + posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) + except PermissionError: + err_msg = ( + "The below file is open in another app " + "(Excel maybe?).\n\n" + f"{posData.segmInfo_df_csv_path}\n\n" + 'Close file and then press "Ok".' + ) + self.mutex.lock() + self.criticalPermissionError.emit(err_msg) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + posData.segmInfo_df.to_csv(posData.segmInfo_df_csv_path) + + posData.fluo_data_dict.pop(posData.filename, None) + + if not self.isQuickSave: + posData.fluo_bkgrData_dict.pop(posData.filename) + + if posData.SizeT > 1: + self.progress.emit("Almost done...") + self.progressBar.emit(0, 0, 0) + + if self.isQuickSave: + # Go back to current frame + posData.frame_i = current_frame_i + self.mainWin.get_data() + continue + + with open(last_tracked_i_path, "w+") as txt: + txt.write(str(end_i)) + + # Save combined metrics equations + posData.saveCombineMetrics() + self.mainWin.pointsLayerDataToDf(posData) + posData.saveClickEntryPointsDfs() + + posData.last_tracked_i = last_tracked_i + + # Go back to current frame + posData.frame_i = current_frame_i + self.mainWin.get_data() + + if mode == "Segmentation and Tracking" or mode == "Viewer": + self.progress.emit(f"Saved data until frame number {end_i + 1}") + elif mode == "Cell cycle analysis": + self.progress.emit( + "Saved cell cycle annotations until frame " + f"number {self.mainWin.last_cca_frame_i + 1}" + ) + # self.progressBar.emit(1) + if self.mainWin.isSnapshot: + self.progress.emit(f"Saved all {p + 1} Positions!") + + self.finished.emit() + + +class relabelSequentialWorker(QObject): + finished = Signal() + critical = Signal(object) + progress = Signal(str) + sigRemoveItemsGUI = Signal(int) + debug = Signal(object) + + def __init__(self, mainWin, posFoldernames): + QObject.__init__(self) + self.mainWin = mainWin + self.data = mainWin.data + self.posFoldernames = posFoldernames + self.mutex = QMutex() + self.waitCond = QWaitCondition() + + def progressNewIDs(self, oldIDs, newIDs): + li = list(zip(oldIDs, newIDs)) + s = "\n".join([str(pair).replace(",", " -->") for pair in li]) + s = f"IDs relabelled as follows:\n{s}" + self.progress.emit(s) + + @worker_exception_handler + def run(self): + self.mutex.lock() + + self.progress.emit("Relabelling process started...") + mainWin = self.mainWin + + current_pos_i = mainWin.pos_i + + for p, posData in enumerate(self.data): + if posData.pos_foldername not in self.posFoldernames: + continue + + mainWin.pos_i = p + current_lab = mainWin.get_2Dlab(posData.lab).copy() + current_frame_i = posData.frame_i + segm_data = [] + for frame_i, data_dict in enumerate(posData.allData_li): + lab = data_dict["labels"] + if lab is None: + break + segm_data.append(lab) + # if frame_i == current_frame_i: + # break + + if not segm_data: + segm_data = np.array([current_lab]) + + segm_data = np.array(segm_data) + segm_data, oldIDs, newIDs = core.relabel_sequential( + segm_data, is_timelapse=posData.SizeT > 1 + ) + self.progressNewIDs(oldIDs, newIDs) + self.sigRemoveItemsGUI.emit(np.max(segm_data)) + + self.progress.emit( + "Updating stored data and cell cycle annotations (if present)..." + ) + + mainWin.updateAnnotatedIDs(oldIDs, newIDs, logger=self.progress.emit) + mainWin.store_data(mainThread=False) + + for frame_i, lab in enumerate(segm_data): + posData.frame_i = frame_i + posData.lab = lab + mainWin.get_cca_df() + if posData.cca_df is not None: + mainWin.update_cca_df_relabelling(posData, oldIDs, newIDs) + mainWin.update_rp(draw=False) + mainWin.store_data(mainThread=False) + + # Go back to current frame + mainWin.pos_i = current_pos_i + posData = self.data[mainWin.pos_i] + posData.frame_i = current_frame_i + mainWin.get_data() + + self.mutex.unlock() + self.finished.emit() + +# Sibling imports (deferred to avoid import cycles) +from ._base import ( + signals, + workerLogger, + worker_exception_handler, +) + diff --git a/cellacdc/workers/metrics.py b/cellacdc/workers/metrics.py new file mode 100644 index 000000000..ce786baeb --- /dev/null +++ b/cellacdc/workers/metrics.py @@ -0,0 +1,1520 @@ +"""Background Qt workers: metrics.""" + +import re +import os +import shutil +import time +import json +import concurrent.futures +from functools import partial +from collections import defaultdict, deque +import itertools + +from typing import Union, List, Dict, Callable, Any, Tuple, Iterable + +from functools import wraps +import numpy as np +import pandas as pd +import h5py +import traceback + +import skimage.io +import skimage.measure +import skimage.exposure + +import queue + +from tqdm import tqdm + +from qtpy.QtCore import Signal, QObject, QMutex, QWaitCondition + +from cellacdc import html_utils + +from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import transformation, measurements, cca_functions +from ..path import copy_or_move_tree +from .. import features, plot +from .. import core +from .. import cca_df_colnames, lineage_tree_cols, default_annot_df +from .. import cca_df_colnames_with_tree +from .. import cli +from ..utils import resize +from .. import segm_utils + +DEBUG = False + +from ._base import ( + BaseWorkerUtil, +) + +class ComputeMetricsWorker(QObject): + progressBar = Signal(int, int, float) + + def __init__(self, mainWin): + QObject.__init__(self) + self.signals = signals() + self.abort = False + self.setup_done = False + self.logger = workerLogger(self.signals.progress) + self.mutex = QMutex() + self.waitCond = QWaitCondition() + self.mainWin = mainWin + + def emitSelectSegmFiles(self, exp_path, pos_foldernames): + self.mutex.lock() + self.signals.sigSelectSegmFiles.emit(exp_path, pos_foldernames) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + return True + else: + return False + + @worker_exception_handler + def run(self): + np.seterr(invalid="ignore") + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.standardMetricsErrors = {} + self.customMetricsErrors = {} + self.regionPropsErrors = {} + tot_pos = len(pos_foldernames) + self.allPosDataInputs = [] + posDatas = [] + self.logger.log("-" * 30) + expFoldername = os.path.basename(exp_path) + + if i == 0: + abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) + if abort: + self.signals.finished.emit(self) + return + + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.signals.finished.emit(self) + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + pos_path = os.path.join(exp_path, pos) + images_path = os.path.join(pos_path, "Images") + basename, chNames = myutils.getBasenameAndChNames( + images_path, useExt=(".tif", ".h5") + ) + + self.signals.sigUpdatePbarDesc.emit(f"Loading {pos_path}...") + + # Use first found channel, it doesn't matter for metrics + chName = chNames[0] + file_path = myutils.getChannelFilePath(images_path, chName) + + # Load data + posData = load.loadData(file_path, chName) + posData.getBasenameAndChNames(useExt=(".tif", ".h5")) + posData.buildPaths() + + posData.loadOtherFiles( + load_segm_data=False, + load_acdc_df=True, + load_metadata=True, + loadSegmInfo=True, + load_customCombineMetrics=True, + ) + + posDatas.append(posData) + + self.allPosDataInputs.append( + { + "file_path": file_path, + "chName": chName, + "combineMetricsConfig": posData.combineMetricsConfig, + "combineMetricsPath": posData.custom_combine_metrics_path, + } + ) + + if any([posData.SizeT > 1 for posData in posDatas]): + self.mutex.lock() + self.signals.sigAskStopFrame.emit(posDatas) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + self.signals.finished.emit(self) + return + for p, posData in enumerate(posDatas): + self.allPosDataInputs[p]["stopFrameNum"] = posData.stopFrameNum + else: + for p, posData in enumerate(posDatas): + self.allPosDataInputs[p]["stopFrameNum"] = 1 + + self.kernel = cli.ComputeMeasurementsKernel( + self.logger, + self.mainWin.log_path, + False, + ) + + from cellacdc.workflow.pipelines.batch import run_gui_measurements_batch + from cellacdc.workflow.runnable import RunnableConfig + + run_gui_measurements_batch( + kernel=self.kernel, + paths=[inp["file_path"] for inp in self.allPosDataInputs], + stop_frame_numbers=[ + inp["stopFrameNum"] for inp in self.allPosDataInputs + ], + end_filename_segm=self.mainWin.endFilenameSegm, + compute_metrics_worker=self, + config=RunnableConfig(logger_func=self.logger.log), + ) + + if self.kernel.setup_done or self.abort: + return + + self.logger.log("*" * 30) + + self.mutex.lock() + self.signals.sigErrorsReport.emit( + self.standardMetricsErrors, + self.customMetricsErrors, + self.regionPropsErrors, + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + self.signals.finished.emit(self) + + def emitSigComputeVolume(self, posData, stop_frame_n): + # Recreate allData_li attribute of the gui + posData.allData_li = [] + for frame_i, lab in enumerate(posData.segm_data[:stop_frame_n]): + data_dict = {"labels": lab, "regionprops": skimage.measure.regionprops(lab)} + posData.allData_li.append(data_dict) + self.mutex.lock() + self.signals.sigComputeVolume.emit(stop_frame_n, posData) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def emitSigPermissionErrorAndSave( + self, posData, traceback_str, all_frames_acdc_df, custom_annot_columns + ): + self.mutex.lock() + self.signals.sigPermissionError.emit( + traceback_str, posData.acdc_output_csv_path + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + load.save_acdc_df_file( + all_frames_acdc_df, + posData.acdc_output_csv_path, + custom_annot_columns=custom_annot_columns, + ) + + def emitSigInitMetricsDialog(self, posData): + self.mainWin.gui.data = [posData] + self.mainWin.gui.pos_i = 0 + self.mainWin.gui.isSegm3D = posData.getIsSegm3D() + self.mutex.lock() + self.signals.sigInitAddMetrics.emit(posData, self.allPosDataInputs) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def emitSigAskRunNow(self): + self.mutex.lock() + self.signals.sigAskRunNow.emit(self) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + +class ComputeMetricsMultiChannelWorker(BaseWorkerUtil): + sigAskAppendName = Signal(str, list, list) + sigCriticalNotEnoughSegmFiles = Signal(str) + sigAborted = Signal() + sigHowCombineMetrics = Signal(str, list, list, list) + + def __init__(self, mainWin): + super().__init__(mainWin) + + def emitHowCombineMetrics( + self, + imagesPath, + selectedAcdcOutputEndnames, + existingAcdcOutputEndnames, + allChNames, + ): + self.mutex.lock() + self.sigHowCombineMetrics.emit( + imagesPath, + selectedAcdcOutputEndnames, + existingAcdcOutputEndnames, + allChNames, + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def loadAcdcDfs(self, imagesPath, selectedAcdcOutputEndnames): + for end in selectedAcdcOutputEndnames: + filePath, _ = load.get_path_from_endname(end, imagesPath) + acdc_df = pd.read_csv(filePath) + yield acdc_df + + def run_iter_exp(self, exp_path, pos_foldernames, i, tot_exp): + tot_pos = len(pos_foldernames) + + abort = self.emitSelectAcdcOutputFiles( + exp_path, + pos_foldernames, + infoText=" to combine", + allowSingleSelection=False, + ) + if abort: + self.sigAborted.emit() + return + + # Ask appendend name + self.mutex.lock() + self.sigAskAppendName.emit( + f"{self.mainWin.basename_pos1}acdc_output", + self.mainWin.existingAcdcOutputEndnames, + self.mainWin.selectedAcdcOutputEndnames, + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + self.sigAborted.emit() + return + + selectedAcdcOutputEndnames = self.mainWin.selectedAcdcOutputEndnames + existingAcdcOutputEndnames = self.mainWin.existingAcdcOutputEndnames + appendedName = self.appendedName + + self.signals.initProgressBar.emit(len(pos_foldernames)) + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.sigAborted.emit() + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, {pos} ({p + 1}/{tot_pos})" + ) + + imagesPath = os.path.join(exp_path, pos, "Images") + basename, chNames = myutils.getBasenameAndChNames( + imagesPath, useExt=(".tif", ".h5") + ) + + if p == 0: + abort = self.emitHowCombineMetrics( + imagesPath, + selectedAcdcOutputEndnames, + existingAcdcOutputEndnames, + chNames, + ) + if abort: + self.sigAborted.emit() + return + acdcDfs = self.acdcDfs.values() + # Update selected acdc_dfs since the user could have + # loaded additional ones inside the emitHowCombineMetrics + # dialog + selectedAcdcOutputEndnames = self.acdcDfs.keys() + else: + acdcDfs = self.loadAcdcDfs(imagesPath, selectedAcdcOutputEndnames) + + dfs = [] + for i, acdc_df in enumerate(acdcDfs): + dfs.append(acdc_df.add_suffix(f"_table{i + 1}")) + combined_df = pd.concat(dfs, axis=1) + + newAcdcDf = pd.DataFrame(index=combined_df.index) + for newColname, equation in self.equations.items(): + newAcdcDf[newColname] = combined_df.eval(equation) + + newAcdcDfPath = os.path.join( + imagesPath, f"{basename}acdc_output_{appendedName}.csv" + ) + newAcdcDf.to_csv(newAcdcDfPath) + + equationsIniPath = os.path.join( + imagesPath, f"{basename}equations_{appendedName}.ini" + ) + equationsConfig = config.ConfigParser() + if os.path.exists(equationsIniPath): + equationsConfig.read(equationsIniPath) + equationsConfig = self.addEquationsToConfigPars( + equationsConfig, selectedAcdcOutputEndnames, self.equations + ) + with open(equationsIniPath, "w") as configfile: + equationsConfig.write(configfile) + + self.signals.progressBar.emit(1) + + return True + + def addEquationsToConfigPars(self, cp, selectedAcdcOutputEndnames, equations): + section = [ + f"df{i + 1}:{end}" for i, end in enumerate(selectedAcdcOutputEndnames) + ] + section = ";".join(section) + if section not in cp: + cp[section] = {} + + for metricName, expression in equations.items(): + cp[section][metricName] = expression + + return cp + + @worker_exception_handler + def run(self): + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + self.errors = {} + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + try: + result = self.run_iter_exp(exp_path, pos_foldernames, i, tot_exp) + if result is None: + return + except Exception as e: + traceback_str = traceback.format_exc() + self.errors[e] = traceback_str + self.logger.log(traceback_str) + + self.signals.finished.emit(self) + + +class ConcatAcdcDfsWorker(BaseWorkerUtil): + sigAborted = Signal() + sigAskFolder = Signal(str) + sigSetMeasurements = Signal(object) + sigAskAppendName = Signal(str, list) + + def __init__(self, mainWin, format="CSV"): + super().__init__(mainWin) + if format.startswith("CSV"): + self._to_format = "to_csv" + elif format.startswith("XLS"): + self._to_format = "to_excel" + + def emitSetMeasurements(self, kwargs): + self.mutex.lock() + self.sigSetMeasurements.emit(kwargs) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def emitAskAppendName(self, allPos_acdc_df_basename): + # Ask appendend name + self.mutex.lock() + self.sigAskAppendName.emit(allPos_acdc_df_basename, []) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + @worker_exception_handler + def run(self): + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + + self.signals.initProgressBar.emit(0) + acdc_dfs_allexp = [] + acdc_objs_count_dfs_allexp = {} + keys_exp = [] + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + if i == 0: + abort = self.emitSelectAcdcOutputFiles( + exp_path, + pos_foldernames, + infoText=" to combine", + allowSingleSelection=True, + multiSelection=False, + ) + if abort: + self.sigAborted.emit() + return + + selectedAcdcOutputEndname = self.mainWin.selectedAcdcOutputEndnames[0] + selectedAcdcObjsCountEndname = selectedAcdcOutputEndname.replace( + "acdc_output", "acdc_objects_count" + ) + + self.signals.initProgressBar.emit(len(pos_foldernames)) + acdc_dfs = [] + acdc_objs_count_dfs = {} + keys = [] + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.sigAborted.emit() + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + images_path = os.path.join(exp_path, pos, "Images") + + ls = myutils.listdir(images_path) + + acdc_output_file = [ + f for f in ls if f.endswith(f"{selectedAcdcOutputEndname}.csv") + ] + if not acdc_output_file: + self.logger.log( + f"{pos} does not contain any " + f"{selectedAcdcOutputEndname}.csv file. " + "Skipping it." + ) + self.signals.progressBar.emit(1) + continue + + acdc_objs_count_file = [ + f for f in ls if f.endswith(f"{selectedAcdcObjsCountEndname}.csv") + ] + if acdc_objs_count_file: + df_count_filepath = os.path.join( + images_path, acdc_objs_count_file[0] + ) + df_count = pd.read_csv(df_count_filepath) + acdc_objs_count_dfs[pos] = df_count + + acdc_df_filepath = os.path.join(images_path, acdc_output_file[0]) + acdc_df = pd.read_csv(acdc_df_filepath).set_index("Cell_ID") + acdc_dfs.append(acdc_df) + keys.append(pos) + + self.signals.progressBar.emit(1) + + self.signals.initProgressBar.emit(0) + acdc_df_allpos = pd.concat( + acdc_dfs, keys=keys, names=["Position_n", "Cell_ID"] + ) + acdc_df_allpos["experiment_folderpath"] = exp_path + + basename, chNames = myutils.getBasenameAndChNames( + images_path, useExt=(".tif", ".h5") + ) + df_metadata = load.load_metadata_df(images_path) + SizeZ = df_metadata.at["SizeZ", "values"] + SizeZ = int(float(SizeZ)) + existing_colnames = acdc_df_allpos.columns + isSegm3D = any([col.endswith("3D") for col in existing_colnames]) + + if i == 0: + kwargs = { + "loadedChNames": chNames, + "notLoadedChNames": [], + "isZstack": SizeZ > 1, + "isSegm3D": isSegm3D, + "existing_colnames": existing_colnames, + } + self.emitSetMeasurements(kwargs) + if self.abort: + self.sigAborted.emit() + return + + selected_cols = [ + col for col in self.selectedColumns if col in acdc_df_allpos.columns + ] + acdc_df_allpos = acdc_df_allpos[selected_cols] + acdc_dfs_allexp.append(acdc_df_allpos) + exp_name = os.path.basename(exp_path) + keys_exp.append((exp_path, exp_name)) + + allpos_dir = os.path.join(exp_path, "AllPos_acdc_output") + if not os.path.exists(allpos_dir): + os.mkdir(allpos_dir) + + allPos_acdc_df_basename = f"AllPos_{selectedAcdcOutputEndname}" + if i == 0: + self.emitAskAppendName(allPos_acdc_df_basename) + if self.abort: + self.sigAborted.emit() + return + + acdc_objs_count_df_allpos_filename = self.concat_df_filename.replace( + "acdc_output", "acdc_objects_count" + ) + + acdc_dfs_allpos_filepath = os.path.join(allpos_dir, self.concat_df_filename) + + self.logger.log( + "Saving all positions concatenated file to " + f'"{acdc_dfs_allpos_filepath}"' + ) + to_format_func = getattr(acdc_df_allpos, self._to_format) + to_format_func(acdc_dfs_allpos_filepath) + self.acdc_dfs_allpos_filepath = acdc_dfs_allpos_filepath + + if not acdc_objs_count_dfs: + continue + + acdc_objs_count_df_allpos = pd.concat( + acdc_objs_count_dfs, names=["Position_n"] + ) + acdc_objs_count_df_allpos["experiment_folderpath"] = exp_path + + acdc_objs_count_df_allpos_filepath = os.path.join( + allpos_dir, acdc_objs_count_df_allpos_filename + ) + + self.logger.log( + "Saving all positions objects count file to " + f'"{acdc_objs_count_df_allpos_filepath}"' + ) + to_format_func = getattr(acdc_objs_count_df_allpos, self._to_format) + to_format_func(acdc_objs_count_df_allpos_filepath) + + acdc_objs_count_dfs_allexp[(exp_path, exp_name)] = acdc_objs_count_df_allpos + + if len(keys_exp) <= 1: + self.signals.finished.emit(self) + return + + allExp_filename = f"multiExp_{self.concat_df_filename}" + self.mutex.lock() + self.sigAskFolder.emit(allExp_filename) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + self.sigAborted.emit() + return + + acdc_df_allexp = pd.concat( + acdc_dfs_allexp, + keys=keys_exp, + names=["experiment_folderpath", "experiment_foldername"], + ) + acdc_dfs_allexp_filepath = os.path.join(self.allExpSaveFolder, allExp_filename) + self.logger.log( + "Saving multiple experiments concatenated file to " + f'"{acdc_dfs_allexp_filepath}"' + ) + to_format_func = getattr(acdc_df_allexp, self._to_format) + to_format_func(acdc_dfs_allexp_filepath) + + if acdc_objs_count_dfs_allexp: + allexp_count_df_filename = f"multiExp_{acdc_objs_count_df_allpos_filename}" + acdc_objs_count_df_allexp = pd.concat( + acdc_objs_count_dfs_allexp, + names=["experiment_folderpath", "experiment_foldername"], + ) + acdc_objs_count_df_allexp_filepath = os.path.join( + self.allExpSaveFolder, allexp_count_df_filename + ) + self.logger.log( + "Saving multiple experiments concatenated file to " + f'"{acdc_objs_count_df_allexp_filepath}"' + ) + to_format_func = getattr(acdc_objs_count_df_allexp, self._to_format) + to_format_func(acdc_objs_count_df_allexp_filepath) + + self.signals.finished.emit(self) + + +class ConcatSpotmaxDfsWorker(BaseWorkerUtil): + sigAborted = Signal() + sigAskFolder = Signal(str) + sigSetMeasurements = Signal(object) + sigAskAppendName = Signal(str, list) + + def __init__(self, mainWin, format="CSV"): + super().__init__(mainWin) + if format.startswith("CSV"): + self._final_ext = ".csv" + elif format.startswith("XLS"): + self._final_ext = ".xlsx" + self.acdcOutputEndname = None + + def emitSetMeasurements(self, kwargs): + self.mutex.lock() + self.sigSetMeasurements.emit(kwargs) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def emitAskAppendName(self, allPos_spotmax_df_basename): + # Ask appendend name + self.mutex.lock() + self.sigAskAppendName.emit(allPos_spotmax_df_basename, []) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def emitAskCopyCca(self, images_path): + self.mutex.lock() + self.signals.sigAskCopyCca.emit(images_path) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def setAcdcOutputEndname(self, acdcOutputEndname): + self.acdcOutputEndname = acdcOutputEndname + + def getAcdcDf(self, images_path): + if self.acdcOutputEndname is None: + return + + for file in myutils.listdir(images_path): + if not file.endswith(self.acdcOutputEndname): + continue + + filepath = os.path.join(images_path, file) + acdc_df = pd.read_csv(filepath, index_col=["frame_i", "Cell_ID"]) + return acdc_df + + def copyCcaColsFromAcdcDf(self, df, acdc_df, debug=False): + if acdc_df is None: + return df + + if debug: + printl(acdc_df.columns.to_list(), pretty=True) + + idx = df.index.intersection(acdc_df.index) + for col in cca_df_colnames: + if col not in acdc_df.columns: + continue + + if col not in self.selectedColumns: + continue + + df.loc[idx, col] = acdc_df.loc[idx, col] + + for col in lineage_tree_cols: + if col not in acdc_df.columns: + continue + + if col not in self.selectedColumns: + continue + + df.loc[idx, col] = acdc_df.loc[idx, col] + + for col in default_annot_df.keys(): + if col not in acdc_df.columns: + continue + + if col not in self.selectedColumns: + continue + + df.loc[idx, col] = acdc_df.loc[idx, col] + + for col in self.selectedColumns: + if col not in acdc_df.columns: + continue + + df.loc[idx, col] = acdc_df.loc[idx, col] + + if debug and col == "cell_vol_fl": + printl(df[[col]]) + + return df + + def emitAskFolderWhereToSaveMultiExp(self): + self.mutex.lock() + self.sigAskFolder.emit("") + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + self.sigAborted.emit() + return + + return self.allExpSaveFolder + + def askSelectMeasurements(self, exp_path, posFoldernames): + acdc_dfs = [] + keys = [] + for p, pos in enumerate(posFoldernames): + if self.abort: + self.sigAborted.emit() + return False + + images_path = os.path.join(exp_path, pos, "Images") + acdc_df = self.getAcdcDf(images_path) + if acdc_df is None: + continue + + acdc_dfs.append(acdc_df) + keys.append(pos) + + if not acdc_dfs: + return True + + acdc_df_allpos = pd.concat( + acdc_dfs, keys=keys, names=["Position_n", "frame_i", "Cell_ID"] + ) + acdc_df_allpos["experiment_folderpath"] = exp_path + basename, chNames = myutils.getBasenameAndChNames( + images_path, useExt=(".tif", ".h5") + ) + df_metadata = load.load_metadata_df(images_path) + SizeZ = df_metadata.at["SizeZ", "values"] + SizeZ = int(float(SizeZ)) + existing_colnames = acdc_df_allpos.columns + isSegm3D = any([col.endswith("3D") for col in existing_colnames]) + + kwargs = { + "loadedChNames": chNames, + "notLoadedChNames": [], + "isZstack": SizeZ > 1, + "isSegm3D": isSegm3D, + "existing_colnames": existing_colnames, + } + self.emitSetMeasurements(kwargs) + if self.abort: + self.sigAborted.emit() + return False + + return True + + @worker_exception_handler + def run(self): + from spotmax import DFs_FILENAMES, DF_REF_CH_FILENAME + from spotmax.utils import get_runs_num_and_desc + import spotmax.io + + self.selectedColumns = None + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + spotmax_dfs_spots_allexp = defaultdict(lambda: defaultdict(list)) + spotmax_dfs_aggr_allexp = defaultdict(lambda: defaultdict(list)) + ref_ch_dfs_allexp = defaultdict(lambda: defaultdict(list)) + runNumberAlreadyAsked = False + copyFromCcaAlreadyAsked = False + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + all_runs = get_runs_num_and_desc(exp_path, pos_foldernames=pos_foldernames) + if not all_runs: + self.logger.log( + "[WARNING] The following experiment does not contain " + f'valid spotMAX output files. Skipping it. "{exp_path}"' + ) + continue + + if not runNumberAlreadyAsked: + abort = self.emitSelectSpotmaxRun( + exp_path, + pos_foldernames, + all_runs, + infoText=" to combine", + allowSingleSelection=True, + multiSelection=False, + ) + if abort: + self.sigAborted.emit() + return + runNumberAlreadyAsked = True + + selectedSpotmaxRuns = self.mainWin.selectedSpotmaxRuns + + self.signals.initProgressBar.emit(len(pos_foldernames)) + dfs_spots = defaultdict(list) + dfs_aggr = defaultdict(list) + dfs_ref_ch = defaultdict(list) + pos_runs = defaultdict(list) + pos_runs_ref_ch = defaultdict(list) + pos_ini_filepaths = {} + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.sigAborted.emit() + return + + pos_path = os.path.join(exp_path, pos) + spotmax_output_path = os.path.join(pos_path, "spotMAX_output") + + if not os.path.exists(spotmax_output_path): + self.logger.log( + "[WARNING] The following Position folder does not contain " + f'valid spotMAX output files. Skipping it. "{pos_path}"' + ) + continue + + images_path = os.path.join(exp_path, pos, "Images") + + if not copyFromCcaAlreadyAsked: + self.emitAskCopyCca(images_path) + if self.abort: + self.sigAborted.emit() + return + + self.askSelectMeasurements(exp_path, pos_foldernames) + if self.abort: + return + copyFromCcaAlreadyAsked = True + + acdc_df = self.getAcdcDf(images_path) + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + for run_desc in selectedSpotmaxRuns: + run, desc = run_desc.split("_...") + ini_filename = f"{run}_analysis_parameters{desc}.ini" + ini_filepath = os.path.join(spotmax_output_path, ini_filename) + if not os.path.exists(ini_filepath): + self.logger.log( + "[WARNING] The following Position folder does not contain " + f"the spotMAX output file for run number {run}. " + f'Skipping it. "{pos_path}"' + ) + continue + + pos_ini_filepaths[(run, desc)] = ini_filepath + for _, pattern_filename in DFs_FILENAMES.items(): + run_filename = pattern_filename.replace("*rn*", run) + run_filename = run_filename.replace("*desc*", desc) + aggr_filename = f"{run_filename}_aggregated.csv" + aggr_filepath = os.path.join(spotmax_output_path, aggr_filename) + if not os.path.exists(aggr_filepath): + continue + + df_spots_filename = f"{run_filename}.h5" + spots_filepath = os.path.join( + spotmax_output_path, df_spots_filename + ) + ext_spots = ".h5" + if not os.path.exists(spots_filepath): + df_spots_filename = f"{run_filename}.csv" + spots_filepath = os.path.join( + spotmax_output_path, df_spots_filename + ) + ext_spots = ".csv" + + if not os.path.exists(spots_filepath): + continue + + analysis_step = re.findall( + r"\*rn\*(.*)\*desc\*", pattern_filename + )[0] + key = (run, analysis_step, desc, ext_spots) + try: + df_spots = ( + spotmax.io.load_spots_table( + spotmax_output_path, df_spots_filename + ) + .reset_index() + .set_index(["frame_i", "Cell_ID"]) + ) + df_spots = self.copyCcaColsFromAcdcDf( + df_spots, acdc_df, debug=False + ) + df_spots = df_spots.reset_index().set_index( + ["frame_i", "Cell_ID", "spot_id"] + ) + dfs_spots[key].append(df_spots) + except Exception as err: + self.logger.log(str(err), level="ERROR") + self.logger.log( + "WARNING: Error when reading single-spots " + "tables (possibly because there are no spots). " + "Skipping this Position.", + level="WARNING", + ) + pass + + df_aggregated = pd.read_csv( + aggr_filepath, index_col=["frame_i", "Cell_ID"] + ) + df_aggregated = self.copyCcaColsFromAcdcDf( + df_aggregated, acdc_df + ) + dfs_aggr[key].append(df_aggregated) + pos_runs[key].append(pos) + + ref_ch_id_text = re.findall( + r"\*rn\*(.*)\*desc\*", DF_REF_CH_FILENAME + )[0] + ref_ch_filename = DF_REF_CH_FILENAME.replace("*rn*", run) + ref_ch_filename = ref_ch_filename.replace("*desc*", desc) + ref_ch_filepath = os.path.join(spotmax_output_path, ref_ch_filename) + if not os.path.exists(ref_ch_filepath): + continue + + df_ref_ch = pd.read_csv( + ref_ch_filepath, index_col=["frame_i", "Cell_ID"] + ) + df_ref_ch = self.copyCcaColsFromAcdcDf(df_ref_ch, acdc_df) + ref_ch_key = (run, ref_ch_id_text, desc) + dfs_ref_ch[ref_ch_key].append(df_ref_ch) + pos_runs_ref_ch[ref_ch_key].append(pos) + + self.signals.progressBar.emit(1) + + self.signals.initProgressBar.emit(0) + + self.logger.log("Saving concantenated files...") + + allpos_folderpath = os.path.join(exp_path, "spotMAX_multipos_output") + os.makedirs(allpos_folderpath, exist_ok=True) + + exp_name = os.path.basename(exp_path) + for key, dfs in dfs_spots.items(): + pos_keys = pos_runs[key] + run, analysis_step, desc, ext_spots = key + + if ext_spots == ".csv": + ext_spots = self._final_ext + filename = f"multipos_{run}{analysis_step}{desc}{ext_spots}" + all_exp_key = filename + df_spots_concat = spotmax.io.save_concat_dfs( + dfs, + pos_keys, + allpos_folderpath, + filename, + ext_spots, + names=["Position_n"], + return_concat_df=True, + ) + df_spots_concat["experiment_foldername"] = exp_name + df_spots_concat["experiment_folderpath"] = exp_path + spotmax_dfs_spots_allexp[all_exp_key]["dfs"].append(df_spots_concat) + spotmax_dfs_spots_allexp[all_exp_key]["keys"].append(exp_path) + ini_filepath = pos_ini_filepaths[(run, desc)] + ini_filename = os.path.basename(ini_filepath) + dst_ini_filepath = os.path.join(allpos_folderpath, ini_filename) + if not os.path.exists(dst_ini_filepath): + shutil.copy2(ini_filepath, dst_ini_filepath) + + spotmax_dfs_spots_allexp[all_exp_key]["ini_filepath"].append( + dst_ini_filepath + ) + + for key, dfs in dfs_aggr.items(): + pos_keys = pos_runs[key] + run, analysis_step, desc, _ = key + filename = ( + f"multipos_{run}{analysis_step}{desc}_aggregated{self._final_ext}" + ) + all_exp_aggr_key = filename + df_aggr_concat = spotmax.io.save_concat_dfs( + dfs, + pos_keys, + allpos_folderpath, + filename, + self._final_ext, + names=["Position_n"], + return_concat_df=True, + ) + spotmax_dfs_aggr_allexp[all_exp_aggr_key]["dfs"].append(df_aggr_concat) + spotmax_dfs_aggr_allexp[all_exp_aggr_key]["keys"].append( + (exp_path, exp_name) + ) + + for key, dfs in dfs_ref_ch.items(): + run, ref_ch_id_text, desc = key + pos_keys = pos_runs_ref_ch[key] + filename = f"multipos_{run}{ref_ch_id_text}{desc}{self._final_ext}" + all_exp_ref_ch_key = filename + df_ref_ch_concat = spotmax.io.save_concat_dfs( + dfs, + pos_keys, + allpos_folderpath, + filename, + self._final_ext, + names=["Position_n"], + return_concat_df=True, + ) + ref_ch_dfs_allexp[all_exp_ref_ch_key]["dfs"].append(df_ref_ch_concat) + ref_ch_dfs_allexp[all_exp_ref_ch_key]["keys"].append( + (exp_path, exp_name) + ) + + multiexp_dst_folderpath = "" + if len(expPaths) == 1: + self.signals.finished.emit(self) + return + + multiexp_dst_folderpath = self.emitAskFolderWhereToSaveMultiExp() + printl(multiexp_dst_folderpath) + if multiexp_dst_folderpath is None: + return + + self.logger.log( + f'Saving multi-experiment files to "{multiexp_dst_folderpath}"...' + ) + names = ["experiment_folderpath", "experiment_foldername"] + for filename, items in spotmax_dfs_spots_allexp.items(): + keys = items["keys"] + dfs = items["dfs"] + multiexp_filename = f"multiexp_{filename}" + extension = os.path.splitext(filename)[-1] + spotmax.io.save_concat_dfs( + dfs, + keys, + multiexp_dst_folderpath, + multiexp_filename, + extension, + names=["experiment_folderpath"], + ) + ini_filepath = items["ini_filepath"][0] + ini_filename = os.path.basename(ini_filepath) + dst_ini_filepath = os.path.join(multiexp_dst_folderpath, ini_filename) + if not os.path.exists(dst_ini_filepath): + shutil.copy2(ini_filepath, dst_ini_filepath) + + for filename, items in spotmax_dfs_aggr_allexp.items(): + keys = items["keys"] + dfs = items["dfs"] + printl(keys, pretty=True) + multiexp_filename = f"multiexp_{filename}" + extension = os.path.splitext(filename)[-1] + spotmax.io.save_concat_dfs( + dfs, + keys, + multiexp_dst_folderpath, + multiexp_filename, + extension, + names=names, + ) + + for filename, items in ref_ch_dfs_allexp.items(): + keys = items["keys"] + dfs = items["dfs"] + multiexp_filename = f"multiexp_{filename}" + extension = os.path.splitext(filename)[-1] + spotmax.io.save_concat_dfs( + dfs, + keys, + multiexp_dst_folderpath, + multiexp_filename, + extension, + names=names, + ) + + self.signals.finished.emit(self) + + +class CcaIntegrityCheckerWorker(QObject): + finished = Signal(object) + critical = Signal(object) + progress = Signal(str, object) + sigDone = Signal() + sigWarning = Signal(str, str) + sigFixWillDivide = Signal(str, list) + + def __init__(self, mutex, waitCond): + QObject.__init__(self) + self.logger = workerLogger(self.progress) + self.mutex = mutex + self.waitCond = waitCond + self.exit = False + self.isFinished = False + self.abortChecking = False + self.isChecking = False + self.isPaused = False + self.debug = False + self.dataQ = deque(maxlen=10) + + def pause(self): + if self.debug: + self.logger.log("Cell cycle annotations checker is idle.") + self.mutex.lock() + self.isPaused = True + self.waitCond.wait(self.mutex) + self.mutex.unlock() + self.isPaused = False + + def enqueue(self, posData): + # First stop previous checking + if self.isChecking: + self.abortChecking = True + self._enqueue(posData) + + def _enqueue(self, posData): + if self.debug: + self.logger.log("Enqueing posData...") + self.dataQ.append(posData) + if len(self.dataQ) == 1: + # Wake worker upon inserting first element + self.abortChecking = False + self.waitCond.wakeAll() + + def clearQueue(self): + self.dataQ.clear() + + def _stop(self): + self.exit = True + self.waitCond.wakeAll() + + def abort(self): + self.abortChecking = True + while not len(self.dataQ) == 0: + data = self.dataQ.pop() + del data + self._stop() + + def _check_equality_num_mothers_buds_in_S(self, checker, frame_i): + num_moth_S, num_buds = checker.get_num_mothers_and_buds_in_S() + + if num_moth_S == num_buds: + return True + + category = "number of buds different from number of mothers in S phase" + ul_items = [ + f"Number of buds = {num_buds}", + f"Number of mothers in S phase = {num_moth_S}", + ] + txt = html_utils.paragraph( + f"At frame n. {frame_i + 1} the number of buds and number of " + "mother cells in S phase are different!" + f"{html_utils.to_list(ul_items)}" + ) + self.sigWarning.emit(txt, category) + return False + + def _check_mothers_multiple_buds(self, checker, frame_i): + mother_IDs_with_multiple_buds = checker.get_mother_IDs_with_multiple_buds() + if len(mother_IDs_with_multiple_buds) == 0: + return True + + category = "mother cells with multiple buds" + txt = html_utils.paragraph( + f"At frame n. {frame_i + 1} " + "the following mother cells have multiple buds assigned to it" + f"

    {mother_IDs_with_multiple_buds}" + ) + self.sigWarning.emit(txt, category) + return False + + def _check_cells_without_G1(self, checker, global_cca_df): + IDs_cycles_without_G1 = checker.get_IDs_cycles_without_G1(global_cca_df) + if len(IDs_cycles_without_G1) == 0: + return True + + category = "cell cycles without G1" + txt = html_utils.paragraph( + "Cell-ACDC requires that every cell cycle has at least " + "one frame in G1.
    " + "The following pairs of (ID, generation number) " + "do not satisfy this condition:

    " + f"{IDs_cycles_without_G1}" + ) + self.sigWarning.emit(txt, category) + return False + + def _check_will_divide_is_true(self, checker, global_cca_df): + # NOTE: unfortunately this function performs pandas manipulations + # that are either not thread-safe or in any case are freezing the + # GUI. For now we don't run this until we find a solution + return True + + IDs_will_divide_wrong = checker.get_IDs_gen_num_will_divide_wrong(global_cca_df) + if len(IDs_will_divide_wrong) == 0: + return True + + txt = html_utils.paragraph( + "Cell-ACDC found that `will_divide` is annotated as True on the " + "following (ID, generation number) cell
    " + "despite the fact that division is still not annotated on " + "these cells

    :" + f"{IDs_will_divide_wrong}" + ) + self.sigFixWillDivide.emit(txt, IDs_will_divide_wrong) + return False + + def _check_buds_gen_num_zero(self, checker, frame_i): + bud_IDs_gen_num_nonzero = checker.get_bud_IDs_gen_num_nonzero() + if len(bud_IDs_gen_num_nonzero) == 0: + return True + + category = "buds whose generation number is not zero" + txt = html_utils.paragraph( + f"At frame n. {frame_i + 1} " + "the following bud IDs have generation number different from 0:" + f"

    {bud_IDs_gen_num_nonzero}" + ) + self.sigWarning.emit(txt, category) + return False + + def _check_mothers_gen_num_greater_one(self, checker, frame_i): + moth_IDs_gen_num_non_greater_one = ( + checker.get_moth_IDs_gen_num_non_greater_one() + ) + if len(moth_IDs_gen_num_non_greater_one) == 0: + return True + + category = "mothers whose generation number is < 1" + txt = html_utils.paragraph( + f"At frame n. {frame_i + 1} " + "the following mother cells have generation number < 1:" + f"

    {moth_IDs_gen_num_non_greater_one}" + ) + self.sigWarning.emit(txt, category) + return False + + def _check_buds_G1(self, checker, frame_i): + buds_G1 = checker.get_buds_G1() + if len(buds_G1) == 0: + return True + + category = "buds in G1" + txt = html_utils.paragraph( + f"At frame n. {frame_i + 1} " + "the following bud IDs are in G1 (buds must be in S):" + f"

    {buds_G1}" + ) + self.sigWarning.emit(txt, category) + return False + + def _check_cell_S_rel_ID_zero(self, checker, frame_i): + cell_S_rel_ID_zero = checker.get_cell_S_rel_ID_zero() + if len(cell_S_rel_ID_zero) == 0: + return True + + category = "buds in G1" + txt = html_utils.paragraph( + f"At frame n. {frame_i + 1} " + "the following cell IDs in S phase do not have " + "relative_ID > 0:" + f"

    {cell_S_rel_ID_zero}" + ) + self.sigWarning.emit(txt, category) + return False + + def _check_ID_rel_ID_mismatches(self, checker, frame_i): + ID_rel_ID_mismatches = checker.get_ID_rel_ID_mismatches() + if len(ID_rel_ID_mismatches) == 0: + return True + + items = [ + f"Cell ID {ID} has relative ID = {relID}, " + f"while cell ID {relID} has relative ID = {relID_of_relID}" + for ID, relID, relID_of_relID in ID_rel_ID_mismatches + ] + category = "`ID-relative_ID` mismatches" + txt = html_utils.paragraph( + f"At frame n. {frame_i + 1} " + "there are the following `ID-relative_ID` mismatches:" + f"{html_utils.to_list(items)}" + ) + self.sigWarning.emit(txt, category) + return False + + def _check_lonely_cells_in_S(self, checker, frame_i): + lonely_cells_in_S = checker.get_lonely_cells_in_S() + if len(lonely_cells_in_S) == 0: + return True + + category = "Lovely cells in S phase" + txt = html_utils.paragraph( + f"At frame n. {frame_i + 1} " + "the following cell IDs are in `S` phase but their `relative_ID` " + f"does not exist:

    " + f"{lonely_cells_in_S}" + ) + self.sigWarning.emit(txt, category) + return False + + def _get_cca_df_copy(self, acdc_df): + try: + cca_df = pd.DataFrame( + data=acdc_df[cca_df_colnames].values, + columns=cca_df_colnames, + index=acdc_df.index, + ) + return cca_df + except KeyError as error: + return + + def check(self, posData): + self.isChecking = True + checkpoints = ( + "_check_lonely_cells_in_S", + "_check_equality_num_mothers_buds_in_S", + "_check_mothers_multiple_buds", + "_check_buds_gen_num_zero", + "_check_mothers_gen_num_greater_one", + "_check_buds_G1", + "_check_cell_S_rel_ID_zero", + "_check_ID_rel_ID_mismatches", + ) + cca_dfs = [] + keys = [] + check_integrity_globally = True + for frame_i, data_dict in enumerate(posData.allData_li): + if self.abortChecking: + check_integrity_globally = False + break + + lab = data_dict["labels"] + if lab is None: + break + + cca_df = data_dict.get("cca_df_checker") + if cca_df is None: + # There are no annotations at frame_i --> stop + break + + IDs = data_dict["IDs"] + checker = core.CcaIntegrityChecker(cca_df, lab, IDs) + + for checkpoint in checkpoints: + proceed = getattr(self, checkpoint)(checker, frame_i) + if not proceed: + break + + if not proceed: + check_integrity_globally = False + break + + cca_dfs.append(cca_df) + keys.append(frame_i) + + if check_integrity_globally and len(cca_dfs) > 1: + global_checkpoints = [ + "_check_cells_without_G1", + # '_check_will_divide_is_true' + ] + # Check integrity globally + global_cca_df = pd.concat(cca_dfs, keys=keys, names=["frame_i"]) + for checkpoint in global_checkpoints: + proceed = getattr(self, checkpoint)(checker, global_cca_df) + if not proceed: + break + + self.abortChecking = False + self.isChecking = False + time.sleep(1) + + @worker_exception_handler + def run(self): + while True: + if self.exit: + self.logger.log("Closing cell cycle integrity checker worker...") + break + elif not len(self.dataQ) == 0: + if self.debug: + self.logger.log( + "Checking integrity of cell cycle annotations " + f"({len(self.dataQ)})..." + ) + data = self.dataQ.pop() + self.check(data) + if len(self.dataQ) == 0: + self.sigDone.emit() + else: + self.pause() + self.isFinished = True + self.finished.emit(self) + + +class GenerateMotherBudTotalTableWorker(BaseWorkerUtil): + def __init__( + self, parentWin, input_csv_filepath, selected_options, out_csv_filepath + ): + super().__init__(parentWin) + self.input_csv_filepath = input_csv_filepath + self.selected_options = selected_options + self.out_csv_filepath = out_csv_filepath + + @worker_exception_handler + def run(self): + self.logger.log(f'Loading table "{self.input_csv_filepath}"...') + self.signals.initProgressBar.emit(0) + + input_df = pd.read_csv(self.input_csv_filepath) + + self.logger.log("Generating output table...") + out_df = cca_functions.generate_mother_bud_total_df( + input_df, **self.selected_options + ) + + self.logger.log(f'Saving output table to "{self.out_csv_filepath}"...') + + out_df.to_csv(self.out_csv_filepath) + + self.signals.finished.emit(self) + + +class CountObjectsInSegm(BaseWorkerUtil): + sigAskAppendName = Signal(str, list) + sigAborted = Signal() + + def __init__(self, mainWin): + super().__init__(mainWin) + + @worker_exception_handler + def run(self): + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + self.mainWin.infoText = f"Select segmentation file to count" + abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) + if abort: + self.sigAborted.emit() + return + + self.signals.initProgressBar.emit(len(pos_foldernames)) + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.sigAborted.emit() + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + images_path = os.path.join(exp_path, pos, "Images") + endFilenameSegm = self.mainWin.endFilenameSegm + ls = myutils.listdir(images_path) + file_path = [ + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") + ][0] + + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") + + posData.getBasenameAndChNames() + posData.buildPaths() + + posData.loadOtherFiles( + load_segm_data=True, + load_acdc_df=False, + load_metadata=True, + end_filename_segm=endFilenameSegm, + ) + if posData.segm_data.ndim == 3: + posData.segm_data = posData.segm_data[np.newaxis] + + self.logger.log("Counting objects...") + + countMapper = posData.countObjectsInSegm() + countMapper.pop("In current frame", None) + df_count_endname = posData.saveObjCounts(countMapper) + + self.logger.log( + "Saved object counts table to file ending with: " + f'"{df_count_endname}"' + ) + + self.signals.progressBar.emit(1) + + self.signals.finished.emit(self) + +# Sibling imports (deferred to avoid import cycles) +from ._base import ( + signals, + workerLogger, + worker_exception_handler, +) + diff --git a/cellacdc/workers/segm.py b/cellacdc/workers/segm.py new file mode 100644 index 000000000..85cd30d98 --- /dev/null +++ b/cellacdc/workers/segm.py @@ -0,0 +1,894 @@ +"""Background Qt workers: segm.""" + +import re +import os +import shutil +import time +import json +import concurrent.futures +from functools import partial +from collections import defaultdict, deque +import itertools + +from typing import Union, List, Dict, Callable, Any, Tuple, Iterable + +from functools import wraps +import numpy as np +import pandas as pd +import h5py +import traceback + +import skimage.io +import skimage.measure +import skimage.exposure + +import queue + +from tqdm import tqdm + +from qtpy.QtCore import Signal, QObject, QMutex, QWaitCondition + +from cellacdc import html_utils + +from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import transformation, measurements, cca_functions +from ..path import copy_or_move_tree +from .. import features, plot +from .. import core +from .. import cca_df_colnames, lineage_tree_cols, default_annot_df +from .. import cca_df_colnames_with_tree +from .. import cli +from ..utils import resize +from .. import segm_utils + +DEBUG = False + +from ._base import ( + BaseWorkerUtil, +) + +class SegForLostIDsWorker(QObject): + sigAskInit = Signal() + sigAskInstallModel = Signal(str) + sigshowImageDebug = Signal(object) + sigStoreData = Signal(bool) + sigUpdateRP = Signal(bool, bool) + # sigGetData = Signal() + # sigGet2Dlab = Signal() + # sigGetTrackedLostIDs = Signal() + # sigGetBrushID = Signal() + sigSegForLostIDsWorkerAskInstallGPU = Signal(str, bool) + sigTrackManuallyAddedObject = Signal(object, object, bool, bool) + + def __init__(self, guiWin, mutex, waitCond, debug=False): + QObject.__init__(self) + self.signals = signals() + self.logger = workerLogger(self.signals.progress) + self.guiWin = guiWin + self.mutex = mutex + self.waitCond = waitCond + self._debug = debug + + def emitSigAskInit(self): + self.mutex.lock() + self.sigAskInit.emit() + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def emitSigShowImageDebug(self, img): + # self.mutex.lock() + self.sigshowImageDebug.emit(img) + # self.waitCond.wait(self.mutex) + # self.mutex.unlock() + + def emitSigStoreData(self, autosave): + self.mutex.lock() + self.sigStoreData.emit(autosave) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def emitSigUpdateRP(self, wl_track_og_curr, wl_update): + self.mutex.lock() + self.sigUpdateRP.emit(wl_track_og_curr, wl_update) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + # def emitSigGetData(self): + # self.mutex.lock() + # self.sigGetData.emit() + # self.waitCond.wait(self.mutex) + # self.mutex.unlock() + + def emitSigAskInstallModel(self, model_name): + self.mutex.lock() + self.sigAskInstallModel.emit(model_name) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def emitSigAskInstallGPU(self, base_model_name, use_gpu): + self.mutex.lock() + self.sigSegForLostIDsWorkerAskInstallGPU.emit(base_model_name, use_gpu) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + # def emitGet2Dlab(self): + # self.mutex.lock() + # self.sigGet2Dlab.emit() + # self.waitCond.wait(self.mutex) + # self.mutex.unlock() + + # def emitGetTrackedLostIDs(self): + # self.mutex.lock() + # self.sigGetTrackedLostIDs.emit() + # self.waitCond.wait(self.mutex) + # self.mutex.unlock() + + # def emitGetBrushID(self): + # self.mutex.lock() + # self.sigGetBrushID.emit() + # self.waitCond.wait(self.mutex) + # self.mutex.unlock() + + def emitTrackManuallyAddedObject(self, IDs, isLost, wl_update, wl_track_og_curr): + self.mutex.lock() + self.sigTrackManuallyAddedObject.emit(IDs, isLost, wl_update, wl_track_og_curr) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + @worker_exception_handler + def run(self): + posData = self.guiWin.data[self.guiWin.pos_i] + frame_i = posData.frame_i + + if not self.guiWin.SegForLostIDsSettings: + self.emitSigAskInit() + + if not self.guiWin.SegForLostIDsSettings: + self.signals.finished.emit(self) + return + + self.logger.info("Segmentation for lost IDs started.") + model_name = "local_seg" + base_model_name = self.guiWin.SegForLostIDsSettings["base_model_name"] + idx = self.guiWin.modelNames.index(model_name) + acdcSegment = self.guiWin.acdcSegment_li[idx] + + init_kwargs = self.guiWin.SegForLostIDsSettings["win"].init_kwargs + + use_gpu = init_kwargs.get("device_type", "cpu") != "cpu" + use_gpu = use_gpu or init_kwargs.get("use_gpu", False) + + self.emitSigAskInstallGPU(base_model_name, use_gpu) + + if not self.gpu_go: + self.signals.finished.emit(self) + return + + if not self.dont_force_cpu: + if "device" in init_kwargs: + init_kwargs["device"] = "cpu" + if "use_gpu" in init_kwargs: + init_kwargs["use_gpu"] = False + + if ( + acdcSegment is None + or base_model_name != self.guiWin.local_seg_base_model_name + ): + try: + self.logger.info(f"Importing {base_model_name}...") + self.emitSigAskInstallModel(base_model_name) + acdcSegment = myutils.import_segment_module(base_model_name) + self.guiWin.acdcSegment_li[idx] = acdcSegment + self.guiWin.local_seg_base_model_name = base_model_name + except (IndexError, ImportError, KeyError) as e: + self.logger.warning( + f"Cannot import {base_model_name} model. Please install it first." + ) + self.signals.critical.emit( + ( + self, + f"Cannot import {base_model_name} model. " + "Please install it first.", + ) + ) + self.signals.finished.emit(self) + return + + win = self.guiWin.SegForLostIDsSettings["win"] + init_kwargs_new = self.guiWin.SegForLostIDsSettings["init_kwargs_new"] + args_new = self.guiWin.SegForLostIDsSettings["args_new"] + + model = myutils.init_segm_model(acdcSegment, posData, init_kwargs_new) + if model is None: + self.logger.info("Segmentation model was not initialized correctly!") + self.signals.critical.emit( + (self, "Segmentation model was not initialized correctly!") + ) + self.signals.finished.emit(self) + return + if self._debug: + try: + model.setupLogger(self.guiwin.logger) + except Exception as e: + pass + + assigned_IDs = [] + missing_IDs_global = set() + original_lab = posData.lab.copy() + IDs_bboxs_list = [] + bboxs_list = [] + + curr_img = self.guiWin.getDisplayedImg1() + prev_lab = self.guiWin.get_2Dlab(posData.allData_li[frame_i - 1]["labels"]) + prev_IDs = set(posData.allData_li[frame_i - 1]["IDs"]) + + # should probably not paly so much with posData.lab, instead handle stuff myself + self.signals.initProgressBar.emit(2 * args_new["max_iterations"]) + new_labs = np.zeros( + [args_new["max_iterations"], *posData.lab.shape], dtype=np.uint32 + ) + for i in range(args_new["max_iterations"]): + curr_lab = self.guiWin.get_2Dlab(posData.lab) + tracked_lost_IDs = self.guiWin.getTrackedLostIDs() + new_unique_ID = self.guiWin.setBrushID(useCurrentLab=True, return_val=True) + + missing_IDs = prev_IDs - set(posData.IDs) - set(tracked_lost_IDs) + missing_IDs_global.update(missing_IDs) + + assigned_IDs_prev = assigned_IDs.copy() + out = segm_utils.single_cell_seg( + model, + prev_lab, + curr_lab, + curr_img, + missing_IDs, + new_unique_ID, + win, + posData, + distance_filler_growth=args_new["distance_filler_growth"], + overlap_threshold=args_new["overlap_threshold"], + padding=args_new["padding"], + ) + new_lab, assigned_IDs, IDs_bboxs, bboxs = out + + IDs_bboxs_list.append(IDs_bboxs) + bboxs_list.append(bboxs) + posData.lab = new_lab + self.emitSigUpdateRP(wl_update=True, wl_track_og_curr=False) + newly_assigned_IDs = set(assigned_IDs) - set(assigned_IDs_prev) + self.emitTrackManuallyAddedObject(newly_assigned_IDs, True, False, False) + new_labs[i] = posData.lab.copy() + self.signals.progressBar.emit(1) + + if self._debug: + originals = [] + models = [] + + posData.lab = original_lab.copy() + + global_area_mean = np.mean([obj.area for obj in posData.rp]) + for IDs_bboxs, bboxs in zip(IDs_bboxs_list, bboxs_list): + model_lab = new_labs[i] + if self._debug: + originals.append(original_lab.copy()) + models.append(posData.lab.copy()) + + for IDs, bbox in zip(IDs_bboxs, bboxs): + box_x_min, box_x_max, box_y_min, box_y_max = bbox + original_bbox_lab = original_lab[ + box_x_min:box_x_max, box_y_min:box_y_max + ] + original_bbox_lab_cleared_borders = skimage.segmentation.clear_border( + original_bbox_lab + ) + box_model_lab = model_lab[box_x_min:box_x_max, box_y_min:box_y_max] + + # original_bbox_lab[np.isin(original_bbox_lab, IDs)] = 0 should be a given. If not seg for lost IDs this recommended + + box_model_lab = skimage.segmentation.clear_border( + box_model_lab, buffer_size=1 + ) + + rp_model_lab = skimage.measure.regionprops(box_model_lab) + rp_original_lab = skimage.measure.regionprops(original_bbox_lab) + rp_original_lab_cleared = skimage.measure.regionprops( + original_bbox_lab_cleared_borders + ) + + original_IDs = [obj.label for obj in rp_original_lab] + areas = [obj.area for obj in rp_original_lab_cleared] + if len(areas) > 0: + area_mean = np.mean(areas) + else: + area_mean = global_area_mean + if args_new["allow_only_tracked_cells"]: + filtered_IDs = [ + obj.label + for obj in rp_model_lab + if obj.area > (1 - args_new["size_perc_diff"]) * area_mean + and obj.area < (1 + args_new["size_perc_diff"]) * area_mean + and obj.label not in original_IDs + and obj.label in missing_IDs_global + ] + else: + filtered_IDs = [ + obj.label + for obj in rp_model_lab + if obj.area > (1 - args_new["size_perc_diff"]) * area_mean + and obj.area < (1 + args_new["size_perc_diff"]) * area_mean + and obj.label not in original_IDs + ] + + if self._debug or DEBUG: + filtered_sizes = [ + (obj.label, obj.area) + for obj in rp_model_lab + if obj.label in filtered_IDs + ] + self.logger.info(f"Filtered sizes: {filtered_sizes}") + for label in filtered_IDs: + original_bbox_lab[box_model_lab == label] = ( + label # here the stuff should be tracked, so we keep the ID! + ) + + # original_lab[box_x_min:box_x_max, box_y_min:box_y_max] = original_bbox_lab + + self.signals.progressBar.emit(1) + + posData.lab = original_lab + + # if self._debug: + # originals = np.concatenate(originals, axis=0) + # models = np.concatenate(models, axis=0) + # self.emitSigShowImageDebug(originals) + # self.emitSigShowImageDebug(models) + + self.emitSigUpdateRP(wl_track_og_curr=True, wl_update=True) + self.emitSigStoreData(autosave=True) + + self.logger.info("Segmentation for lost IDs done.") + + self.signals.finished.emit(self) + + +class LabelRoiWorker(QObject): + finished = Signal() + critical = Signal(object) + progress = Signal(str, object) + sigProgressBar = Signal(int) + sigLabellingDone = Signal(object, bool) + + def __init__(self, Gui): + QObject.__init__(self) + self.logger = workerLogger(self.progress) + self.Gui = Gui + self.mutex = Gui.labelRoiMutex + self.waitCond = Gui.labelRoiWaitCond + self.exit = False + self.started = False + + def pause(self): + self.logger.log("Draw box around object to start magic labeller.") + self.mutex.lock() + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + def start(self, roiImg, posData, roiSecondChannel=None, isTimelapse=False): + self.posData = posData + self.isTimelapse = isTimelapse + self.imageData = roiImg + self.roiSecondChannel = roiSecondChannel + self.restart() + + def restart(self, log=True): + if log: + self.logger.log("Magic labeller started...") + self.started = True + self.waitCond.wakeAll() + + def _stop(self): + self.logger.log("Magic labeller backend process done. Closing it...") + self.exit = True + self.waitCond.wakeAll() + + def _segment_image(self, img, secondChannelImg): + if secondChannelImg is not None: + img = self.Gui.labelRoiModel.second_ch_img_to_stack(img, secondChannelImg) + + lab = core.segm_model_segment( + self.Gui.labelRoiModel, + img, + self.Gui.model_kwargs, + preproc_recipe=self.Gui.preproc_recipe, + posData=self.posData, + ) + if self.Gui.applyPostProcessing: + from cellacdc.workflow.pipelines.postprocess_nodes import apply_postprocess + + lab = apply_postprocess( + lab, + img, + self.posData, + self.posData.frame_i, + apply_postprocessing=True, + standard_postprocess_kwargs=self.Gui.standardPostProcessKwargs, + custom_postprocess_features=self.Gui.customPostProcessFeatures, + custom_postprocess_grouped_features=self.Gui.customPostProcessGroupedFeatures, + ) + return lab + + @worker_exception_handler + def run(self): + while not self.exit: + if self.exit: + break + elif self.started: + self.logger.log("Magic labeller is doing its magic...") + if self.isTimelapse: + segmData = np.zeros(self.imageData.shape, dtype=np.uint32) + for frame_i, img in enumerate(self.imageData): + if self.roiSecondChannel is not None: + secondChannelImg = self.roiSecondChannel[frame_i] + else: + secondChannelImg = None + lab = self._segment_image(img, secondChannelImg) + segmData[frame_i] = lab + self.sigProgressBar.emit(1) + else: + img = self.imageData + secondChannelImg = self.roiSecondChannel + segmData = self._segment_image(img, secondChannelImg) + + self.sigLabellingDone.emit(segmData, self.isTimelapse) + self.started = False + self.pause() + self.finished.emit() + + +class segmWorker(QObject): + finished = Signal(np.ndarray, float) + debug = Signal(object) + critical = Signal(object) + + def __init__( + self, + mainWin, + secondChannelData=None, + mutex: QWaitCondition = None, + waitCond: QMutex = None, + ): + QObject.__init__(self) + self.mainWin = mainWin + self.logger = self.mainWin.logger + self.z_range = None + self.secondChannelData = secondChannelData + self.mutex = mutex + self.waitCond = waitCond + + def emitDebug(self, to_debug): + if self.mutex is None: + return + + self.mutex.lock() + self.debug.emit(to_debug) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + + @worker_exception_handler + def run(self): + from cellacdc.workflow.adapters import ( + interactive_segm_context_from_main_win, + runnable_config_from_main_win, + ) + from cellacdc.workflow.pipelines.interactive_segm import ( + build_interactive_segm_graph, + ) + from cellacdc.workflow.state import InteractiveSegmState + + t0 = time.perf_counter() + ctx = interactive_segm_context_from_main_win( + self.mainWin, + second_channel_data=self.secondChannelData, + z_range=self.z_range, + ) + graph = build_interactive_segm_graph(ctx).compile() + state = graph.invoke( + InteractiveSegmState(main_win=self.mainWin), + runnable_config_from_main_win(self.mainWin), + ) + t1 = time.perf_counter() + self.finished.emit(state.lab, t1 - t0) + + +class segmVideoWorker(QObject): + finished = Signal(float) + debug = Signal(object) + critical = Signal(object) + progressBar = Signal(int) + progress = Signal(str, object) + + def __init__(self, posData, paramWin, model, startFrameNum, stopFrameNum): + QObject.__init__(self) + self.standardPostProcessKwargs = paramWin.standardPostProcessKwargs + self.applyPostProcessing = paramWin.applyPostProcessing + self.customPostProcessFeatures = paramWin.customPostProcessFeatures + self.customPostProcessGroupedFeatures = ( + paramWin.customPostProcessGroupedFeatures + ) + self.model_kwargs = paramWin.model_kwargs + self.preproc_recipe = paramWin.preproc_recipe + self.secondChannelName = paramWin.secondChannelName + self.model = model + self.posData = posData + self.startFrameNum = startFrameNum + self.stopFrameNum = stopFrameNum + self.logger = workerLogger(self.progress) + + @worker_exception_handler + def run(self): + from cellacdc.workflow.adapters import interactive_video_segm_context_from_worker + from cellacdc.workflow.pipelines.interactive_video_segm import ( + build_interactive_video_segm_graph, + ) + from cellacdc.workflow.state import InteractiveVideoSegmState + + t0 = time.perf_counter() + ctx = interactive_video_segm_context_from_worker(self) + graph = build_interactive_video_segm_graph(ctx).compile() + graph.invoke( + InteractiveVideoSegmState(pos_data=self.posData), + ) + t1 = time.perf_counter() + self.finished.emit(t1 - t0) + + +class PostProcessSegmWorker(QObject): + def __init__( + self, + postProcessKwargs, + customPostProcessGroupedFeatures, + customPostProcessFeatures, + mainWin, + ): + super().__init__() + self.signals = signals() + self.logger = workerLogger(self.signals.progress) + self.kwargs = postProcessKwargs + self.customPostProcessGroupedFeatures = customPostProcessGroupedFeatures + self.customPostProcessFeatures = customPostProcessFeatures + self.mainWin = mainWin + + @worker_exception_handler + def run(self): + mainWin = self.mainWin + data = mainWin.data + posData = data[mainWin.pos_i] + if len(data) > 1: + self.signals.initProgressBar.emit(len(data)) + else: + current_frame_i = posData.frame_i + self.signals.initProgressBar.emit(posData.SizeT - current_frame_i) + + self.logger.log("Post-process segmentation process started.") + self._run() + self.signals.finished.emit(None) + + def _run(self): + kwargs = self.kwargs + mainWin = self.mainWin + data = mainWin.data + + for posData in data: + current_frame_i = posData.frame_i + data_li = posData.allData_li[current_frame_i:] + for i, data_dict in enumerate(data_li): + frame_i = current_frame_i + i + visited = True + lab = data_dict["labels"] + if lab is None: + visited = False + try: + lab = posData.segm_data[frame_i] + except Exception as e: + return + + image = posData.img_data[frame_i] + + processed_lab = core.post_process_segm( + lab, return_delIDs=False, **kwargs + ) + if self.customPostProcessFeatures: + processed_lab = features.custom_post_process_segm( + posData, + self.customPostProcessGroupedFeatures, + processed_lab, + image, + posData.frame_i, + posData.filename, + posData.user_ch_name, + self.customPostProcessFeatures, + ) + if visited: + posData.allData_li[frame_i]["labels"] = processed_lab + # Get the rest of the stored metadata based on the new lab + posData.frame_i = frame_i + mainWin.get_data() + mainWin.store_data(autosave=False) + else: + posData.segm_data[frame_i] = lab + + self.signals.progressBar.emit(1) + + posData.frame_i = current_frame_i + + +class CreateConnected3Dsegm(BaseWorkerUtil): + sigAskAppendName = Signal(str, list) + sigAborted = Signal() + + def __init__(self, mainWin): + super().__init__(mainWin) + + def criticalSegmIsNot3D(self): + raise TypeError( + "Input segmentation masks are not 3D. You can use this utility " + "only on 3D z-stack data or 4D z-stack over time data." + ) + + @worker_exception_handler + def run(self): + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + self.mainWin.infoText = f"Select 3D segmentation file to connect" + abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) + if abort: + self.sigAborted.emit() + return + + # Ask appendend name + self.mutex.lock() + self.sigAskAppendName.emit( + self.mainWin.endFilenameSegm, self.mainWin.existingSegmEndNames + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + self.sigAborted.emit() + return + + appendedName = self.appendedName + self.signals.initProgressBar.emit(len(pos_foldernames)) + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.sigAborted.emit() + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + images_path = os.path.join(exp_path, pos, "Images") + endFilenameSegm = self.mainWin.endFilenameSegm + ls = myutils.listdir(images_path) + file_path = [ + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") + ][0] + + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") + + posData.getBasenameAndChNames() + posData.buildPaths() + + posData.loadOtherFiles( + load_segm_data=True, + load_acdc_df=True, + load_metadata=True, + end_filename_segm=endFilenameSegm, + ) + if posData.segm_data.ndim == 3: + posData.segm_data = posData.segm_data[np.newaxis] + + self.logger.log("Connecting 3D objects...") + + numFrames = len(posData.segm_data) + self.signals.sigInitInnerPbar.emit(numFrames) + connectedSegmData = np.zeros_like(posData.segm_data) + for frame_i, lab in enumerate(posData.segm_data): + if lab.ndim != 3: + self.criticalSegmIsNot3D() + + connected_lab = core.connect_3Dlab_zboundaries(lab) + connectedSegmData[frame_i] = connected_lab + + self.signals.sigUpdateInnerPbar.emit(1) + + self.logger.log("Saving connected 3D segmentation file...") + segmFilename, ext = os.path.splitext(posData.segm_npz_path) + newSegmFilepath = f"{segmFilename}_{appendedName}.npz" + connectedSegmData = np.squeeze(connectedSegmData) + io.savez_compressed(newSegmFilepath, connectedSegmData) + + self.signals.progressBar.emit(1) + + self.signals.finished.emit(self) + + +class DelObjectsOutsideSegmROIWorker(QObject): + finished = Signal(object) + critical = Signal(object) + progress = Signal(str) + debug = Signal(object) + + def __init__( + self, + segm_roi_endname: os.PathLike, + segm_data: np.ndarray, + images_path: os.PathLike, + ): + QObject.__init__(self) + self.signals = signals() + self.segm_roi_endname = segm_roi_endname + self.segm_data = segm_data + self.images_path = images_path + + @worker_exception_handler + def run(self): + segm_roi_endname = self.segm_roi_endname + segm_roi_filepath, _ = load.get_path_from_endname( + segm_roi_endname, self.images_path + ) + self.progress.emit(f'Loading segmentation file "{segm_roi_filepath}"...') + segm_roi_data = load.load_image_file(segm_roi_filepath) + + self.progress.emit(f"Deleting objects outside of selected ROIs...") + cleared_segm_data, delIDs = transformation.del_objs_outside_segm_roi( + segm_roi_data, self.segm_data + ) + + self.finished.emit((self, cleared_segm_data, delIDs)) + + +class MagicPromptsWorker(QObject): + def __init__( + self, + posData, + image, + df_points, + model, + model_segment_kwargs, + image_origin=(0, 0, 0), + global_image=None, + ): + QObject.__init__(self) + + self.signals = signals() + self.posData = posData + self.image = image + if global_image is not None: + self.global_image = global_image + else: + self.global_image = image + self.df_points = df_points + self.image_origin = image_origin + self.model = model + self.model_segment_kwargs = model_segment_kwargs + + @worker_exception_handler + def run(self): + from cellacdc.segmenters_promptable import utils + + for row in self.df_points.itertuples(): + prompt_id = row.id + point = (row.z, row.y, row.x) + print(f"Adding point prompt {point} with id = {prompt_id}...") + parent_obj_id = row.Cell_ID if row.Cell_ID == prompt_id else 0 + self.model.add_prompt( + prompt=point, + prompt_id=prompt_id, + parent_obj_id=parent_obj_id, + image=self.image, + image_origin=self.image_origin, + prompt_type="point", + ) + + lab_out = self.model.segment( + self.global_image, lab=self.posData.lab, **self.model_segment_kwargs + ) + edited_IDs = self.df_points["Cell_ID"].unique() + + lab_new, lab_union, lab_interesection = utils.insert_model_output_into_labels( + self.posData.lab, lab_out, edited_IDs=edited_IDs + ) + + self.signals.finished.emit((lab_new, lab_union, lab_interesection)) + + +class FillHolesInSegWorker(BaseWorkerUtil): + sigAskAppendName = Signal(str) + sigAborted = Signal() + sigSelectSegmFiles = Signal(str, list) + + def __init__(self, mainWin): + super().__init__(mainWin) + + def emitSelectSegmFiles(self, exp_path, pos_foldernames): + self.mutex.lock() + self.sigSelectSegmFiles.emit(exp_path, pos_foldernames) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def emitAskAppendName(self, basename): + self.mutex.lock() + self.sigAskAppendName.emit(basename) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + @worker_exception_handler + def run(self): + expPaths = self.mainWin.expPaths + lab_paths_dict = dict() + unique_segm_files = set() + tot_segm_files = 0 + for exp_path, pos_foldernames in expPaths.items(): + abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) + if abort: + self.sigAborted.emit() + return + for pos_folder in pos_foldernames: + imgs_path = os.path.join(exp_path, pos_folder, "Images") + lab_paths_dict[imgs_path] = self.endFilenameSegmTemp + tot_segm_files += len(self.endFilenameSegmTemp) + unique_segm_files.update(self.endFilenameSegmTemp) + + self.logger.info("Filling holes in segmentation masks...") + abort = self.emitAskAppendName("/".join(unique_segm_files)) + if abort: + self.sigAborted.emit() + return + self.signals.initProgressBar.emit(tot_segm_files) + for images_path, segm_file_names in lab_paths_dict.items(): + for segm_file_name in segm_file_names: + segm_data, segm_data_path = load.load_segm_file( + images_path, end_name_segm_file=segm_file_name, return_path=True + ) + segm_data_shape = segm_data.shape + segm_data_ndim = len(segm_data_shape) + if segm_data_ndim == 2: + segm_data = segm_data[np.newaxis, np.newaxis, ...] + elif segm_data_ndim == 3: + segm_data = segm_data[np.newaxis, ...] + elif segm_data_ndim == 4: + segm_data = segm_data + else: + raise NotImplementedError("This ndim is not supported!") + for i, stack in enumerate(segm_data): + for j, lab in enumerate(stack): + segm_data[i, j] = core.fill_holes_in_segmentation(lab) + + segm_data_save_path = segm_data_path.replace( + segm_file_name, f"{segm_file_name}{self.appendedName}" + ) + io.savez_compressed(segm_data_save_path, segm_data) + self.signals.progressBar.emit(1) + self.signals.finished.emit(self) + +# Sibling imports (deferred to avoid import cycles) +from ._base import ( + signals, + workerLogger, + worker_exception_handler, +) + diff --git a/cellacdc/workers/tracking.py b/cellacdc/workers/tracking.py new file mode 100644 index 000000000..c4c2fc36e --- /dev/null +++ b/cellacdc/workers/tracking.py @@ -0,0 +1,778 @@ +"""Background Qt workers: tracking.""" + +import re +import os +import shutil +import time +import json +import concurrent.futures +from functools import partial +from collections import defaultdict, deque +import itertools + +from typing import Union, List, Dict, Callable, Any, Tuple, Iterable + +from functools import wraps +import numpy as np +import pandas as pd +import h5py +import traceback + +import skimage.io +import skimage.measure +import skimage.exposure + +import queue + +from tqdm import tqdm + +from qtpy.QtCore import Signal, QObject, QMutex, QWaitCondition + +from cellacdc import html_utils + +from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import transformation, measurements, cca_functions +from ..path import copy_or_move_tree +from .. import features, plot +from .. import core +from .. import cca_df_colnames, lineage_tree_cols, default_annot_df +from .. import cca_df_colnames_with_tree +from .. import cli +from ..utils import resize +from .. import segm_utils + +DEBUG = False + +from ._base import ( + BaseWorkerUtil, +) + +class trackingWorker(QObject): + finished = Signal() + critical = Signal(object) + progress = Signal(str) + debug = Signal(object) + + def __init__(self, posData, mainWin, video_to_track): + QObject.__init__(self) + self.mainWin = mainWin + self.posData = posData + self.mutex = QMutex() + self.signals = signals() + self.waitCond = QWaitCondition() + self.tracker = self.mainWin.tracker + self.track_params = self.mainWin.track_params + self.video_to_track = video_to_track + + def _get_first_untracked_lab(self): + start_frame_i = self.mainWin.start_n - 1 + frameData = self.posData.allData_li[start_frame_i] + lab = frameData["labels"] + if lab is not None: + return lab + else: + return self.posData.segm_data[start_frame_i] + + def _relabel_first_frame_labels(self, tracked_video): + first_untracked_lab = self._get_first_untracked_lab() + self.mainWin.setAllIDs() + max_allIDs = max(self.posData.allIDs, default=0) + max_tracked_video = tracked_video.max() + overall_max = max(max_allIDs, max_tracked_video) + uniqueID = overall_max + 1 + + tracked_video = transformation.retrack_based_on_untracked_first_frame( + tracked_video, first_untracked_lab, uniqueID=uniqueID + ) + return tracked_video + + def _setProgressBarIndefiniteWait(self): + try: + if hasattr(self.signals, "innerPbar_available"): + if self.signals.innerPbar_available: + # Use inner pbar of the GUI widget (top pbar is for positions) + self.signals.sigInitInnerPbar.emit(1) + return + else: + self.signals.initProgressBar.emit(1) + except Exception as err: + pass + + @worker_exception_handler + def run(self): + self.mutex.lock() + self.progress.emit("Tracking process started (more details in the terminal)...") + + trackerInputImage = None + self.track_params["signals"] = self.signals + if "image" in self.track_params: + trackerInputImage = self.track_params.pop("image") + start_frame_i = self.mainWin.start_n - 1 + stop_frame_n = self.mainWin.stop_n + + trackerInputImage = trackerInputImage[start_frame_i:stop_frame_n] + + tracked_video = core.tracker_track( + self.video_to_track, + self.tracker, + self.track_params, + intensity_img=trackerInputImage, + logger_func=self.progress.emit, + ) + + self._setProgressBarIndefiniteWait() + + # self.debug.emit((tracked_video, self)) + # self.waitCond.wait(self.mutex) + + self.progress.emit("Re-tracking first frame to ensure continuity...") + # Relabel first frame objects back to IDs they had before tracking + # (to ensure continuity with past untracked frames) + tracked_video = self._relabel_first_frame_labels(tracked_video) + + print("") + self.progress.emit("Generating annotations...") + acdc_df = self.posData.fromTrackerToAcdcDf( + self.tracker, tracked_video, start_frame_i=self.mainWin.start_n - 1 + ) + # Store new tracked video + current_frame_i = self.posData.frame_i + self.trackingOnNeverVisitedFrames = False + print("") + self.progress.emit("Storing tracked video...") + pbar = tqdm(total=len(tracked_video), ncols=100) + for rel_frame_i, lab in enumerate(tracked_video): + frame_i = rel_frame_i + self.mainWin.start_n - 1 + + if acdc_df is not None: + cca_cols = acdc_df.columns.intersection(cca_df_colnames_with_tree) + # Store cca_df if it is an output of the tracker + cca_df = acdc_df.loc[frame_i][cca_cols] + self.mainWin.store_cca_df( + frame_i=frame_i, cca_df=cca_df, mainThread=False, autosave=False + ) + + if self.posData.allData_li[frame_i]["labels"] is None: + # repeating tracking on a never visited frame + # --> modify only raw data and ask later what to do + self.posData.segm_data[frame_i] = lab + self.trackingOnNeverVisitedFrames = True + else: + # Get the rest of the stored metadata based on the new lab + self.posData.allData_li[frame_i]["labels"] = lab + self.posData.frame_i = frame_i + self.mainWin.get_data() + self.mainWin.store_data(autosave=False) + + pbar.update() + pbar.close() + + # Back to current frame + self.posData.frame_i = current_frame_i + self.mainWin.get_data() + self.mainWin.store_data(autosave=True) + self.mutex.unlock() + self.finished.emit() + + +class TrackSubCellObjectsWorker(BaseWorkerUtil): + sigAskAppendName = Signal(str, list) + sigCriticalNotEnoughSegmFiles = Signal(str) + sigAborted = Signal() + + def __init__(self, mainWin): + super().__init__(mainWin) + if mainWin.trackingMode.find("Delete both") != -1: + self.trackingMode = "delete_both" + elif mainWin.trackingMode.find("Delete sub-cellular") != -1: + self.trackingMode = "delete_sub" + elif mainWin.trackingMode.find("Delete cells") != -1: + self.trackingMode = "delete_cells" + elif mainWin.trackingMode.find("Only track") != -1: + self.trackingMode = "only_track" + + self.relabelSubObjLab = mainWin.relabelSubObjLab + self.IoAthresh = mainWin.IoAthresh + self.createThirdSegm = mainWin.createThirdSegm + self.thirdSegmAppendedText = mainWin.thirdSegmAppendedText + + @worker_exception_handler + def run(self): + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + red_text = html_utils.span("OF THE CELLs") + self.mainWin.infoText = f"Select segmentation file {red_text}" + abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) + if abort: + self.sigAborted.emit() + return + + # Critical --> there are not enough segm files + if len(self.mainWin.existingSegmEndNames) < 2: + self.mutex.lock() + self.sigCriticalNotEnoughSegmFiles.emit(exp_path) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + self.sigAborted.emit() + return + + self.cellsSegmEndFilename = self.mainWin.endFilenameSegm + + red_text = html_utils.span("OF THE SUB-CELLULAR OBJECTS") + self.mainWin.infoText = f"Select segmentation file {red_text}" + abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) + if abort: + self.sigAborted.emit() + return + + # Ask appendend name + self.mutex.lock() + self.sigAskAppendName.emit( + self.mainWin.endFilenameSegm, self.mainWin.existingSegmEndNames + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + self.sigAborted.emit() + return + + appendedName = self.appendedName + self.signals.initProgressBar.emit(len(pos_foldernames)) + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.sigAborted.emit() + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + images_path = os.path.join(exp_path, pos, "Images") + endFilenameSegm = self.mainWin.endFilenameSegm + ls = myutils.listdir(images_path) + file_path = [ + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") + ][0] + + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") + + posData.getBasenameAndChNames() + posData.buildPaths() + + posData.loadOtherFiles( + load_segm_data=True, + load_acdc_df=True, + load_metadata=True, + end_filename_segm=endFilenameSegm, + ) + + # Load cells segmentation file + segmDataCells, segmCellsPath = load.load_segm_file( + images_path, + end_name_segm_file=self.cellsSegmEndFilename, + return_path=True, + ) + acdc_df_cells_endname = self.cellsSegmEndFilename.replace( + "_segm", "_acdc_output" + ) + acdc_df_cell, acdc_df_cells_path = load.load_acdc_df_file( + images_path, + end_name_acdc_df_file=acdc_df_cells_endname, + return_path=True, + ) + + if posData.SizeT > 1: + numFrames = min((len(segmDataCells), len(posData.segm_data))) + segmDataCells = segmDataCells[:numFrames] + posData.segm_data = posData.segm_data[:numFrames] + else: + numFrames = 1 + + self.signals.sigInitInnerPbar.emit(numFrames * 2) + + self.logger.log("Tracking sub-cellular objects...") + tracked = core.track_sub_cell_objects( + segmDataCells, + posData.segm_data, + self.IoAthresh, + how=self.trackingMode, + SizeT=numFrames, + sigProgress=self.signals.sigUpdateInnerPbar, + relabel_sub_obj_lab=self.relabelSubObjLab, + ) + ( + trackedSubSegmData, + trackedCellsSegmData, + numSubObjPerCell, + replacedSubIds, + ) = tracked + + self.logger.log("Saving tracked segmentation files...") + subSegmFilename, ext = os.path.splitext(posData.segm_npz_path) + trackedSubPath = f"{subSegmFilename}_{appendedName}.npz" + io.savez_compressed(trackedSubPath, trackedSubSegmData) + posData.saveIsSegm3Dmetadata(trackedSubPath) + + if trackedCellsSegmData is not None: + cellsSegmFilename, ext = os.path.splitext(segmCellsPath) + trackedCellsPath = f"{cellsSegmFilename}_{appendedName}.npz" + io.savez_compressed(trackedCellsPath, trackedCellsSegmData) + + if self.createThirdSegm: + self.logger.log( + f"Generating segmentation from " + f'"{self.cellsSegmEndFilename} - {appendedName}" ' + "difference..." + ) + if trackedCellsSegmData is not None: + parentSegmData = trackedCellsSegmData + else: + parentSegmData = segmDataCells + diffSegmData = parentSegmData.copy() + diffSegmData[trackedSubSegmData != 0] = 0 + + self.logger.log("Saving difference segmentation file...") + diffSegmPath = ( + f"{subSegmFilename}_{appendedName}" + f"_{self.thirdSegmAppendedText}.npz" + ) + io.savez_compressed(diffSegmPath, diffSegmData) + posData.saveIsSegm3Dmetadata(diffSegmPath) + del diffSegmData + + if self.relabelSubObjLab: + # When we relabel the sub-cell objs acdc_df is not valid anymore + # because IDs could be different + posData.acdc_df = None + + self.logger.log("Generating acdc_output tables...") + # Update or create acdc_df for sub-cellular objects + acdc_dfs_tracked = core.track_sub_cell_objects_acdc_df( + trackedSubSegmData, + posData.acdc_df, + replacedSubIds, + numSubObjPerCell, + tracked_cells_segm_data=trackedCellsSegmData, + cells_acdc_df=acdc_df_cell, + SizeT=posData.SizeT, + sigProgress=self.signals.sigUpdateInnerPbar, + ) + subTrackedAcdcDf, trackedAcdcDf = acdc_dfs_tracked + + self.logger.log("Saving acdc_output tables...") + subAcdcDfFilename, _ = os.path.splitext(posData.acdc_output_csv_path) + subTrackedAcdcDfPath = f"{subAcdcDfFilename}_{appendedName}.csv" + subTrackedAcdcDf.to_csv(subTrackedAcdcDfPath) + + if trackedAcdcDf is not None: + basen = posData.basename + cellsSegmFilename = os.path.basename(segmCellsPath) + cellsSegmFilename, ext = os.path.splitext(cellsSegmFilename) + cellsSegmEndname = cellsSegmFilename[len(basen) :] + trackedAcdcDfEndname = cellsSegmEndname.replace( + "segm", "acdc_output" + ) + trackedAcdcDfFilename = f"{basen}{trackedAcdcDfEndname}" + trackedAcdcDfFilename = ( + f"{trackedAcdcDfFilename}_{appendedName}.csv" + ) + trackedAcdcDfPath = os.path.join( + posData.images_path, trackedAcdcDfFilename + ) + trackedAcdcDf.to_csv(trackedAcdcDfPath) + + if self.createThirdSegm: + if posData.SizeT == 1: + parentSegmData = parentSegmData[np.newaxis] + subAcdcDfFilename = subSegmFilename.replace( + ".npz", ".csv" + ).replace("segm", "acdc_output") + diffAcdcDfPath = ( + f"{subAcdcDfFilename}_{appendedName}" + f"_{self.thirdSegmAppendedText}.csv" + ) + third_segm_acdc_df = ( + core.track_sub_cell_objects_third_segm_acdc_df( + parentSegmData, trackedAcdcDf + ) + ) + third_segm_acdc_df.to_csv(diffAcdcDfPath) + + self.signals.progressBar.emit(1) + + self.signals.finished.emit(self) + + +class ApplyTrackInfoWorker(BaseWorkerUtil): + def __init__( + self, + parentWin, + endFilenameSegm, + trackInfoCsvPath, + trackedSegmFilename, + trackColsInfo, + posPath, + ): + super().__init__(parentWin) + self.endFilenameSegm = endFilenameSegm + self.trackInfoCsvPath = trackInfoCsvPath + self.trackedSegmFilename = trackedSegmFilename + self.trackColsInfo = trackColsInfo + self.posPath = posPath + + @worker_exception_handler + def run(self): + self.logger.log("Loading segmentation file...") + self.signals.initProgressBar.emit(0) + imagesPath = os.path.join(self.posPath, "Images") + segmFilename = [ + f + for f in myutils.listdir(imagesPath) + if f.endswith(f"{self.endFilenameSegm}.npz") + ][0] + segmFilePath = os.path.join(imagesPath, segmFilename) + segmData = np.load(segmFilePath)["arr_0"] + + self.logger.log("Loading table containing tracking info...") + df = pd.read_csv(self.trackInfoCsvPath) + + frameIndexCol = self.trackColsInfo["frameIndexCol"] + + parentIDcol = self.trackColsInfo["parentIDcol"] + pbarMax = len(df[frameIndexCol].unique()) + self.signals.initProgressBar.emit(pbarMax) + + # Apply tracking info + result = core.apply_tracking_from_table( + segmData, + self.trackColsInfo, + df, + signal=self.signals.progressBar, + logger=self.logger.log, + pbarMax=pbarMax, + ) + trackedData, trackedIDsMapper, deleteIDsMapper = result + + if self.trackedSegmFilename: + trackedSegmFilepath = os.path.join(imagesPath, self.trackedSegmFilename) + else: + trackedSegmFilepath = os.path.join(segmFilePath) + + self.signals.initProgressBar.emit(0) + self.logger.log("Saving tracked segmentation file...") + io.savez_compressed(trackedSegmFilepath, trackedData) + + mapperPath = os.path.splitext(trackedSegmFilepath)[0] + mapperJsonPath = f"{mapperPath}_deletedIDs_mapper.json" + mapperJsonName = os.path.basename(mapperJsonPath) + self.logger.log(f"Saving deleted IDs to {mapperJsonName}...") + with open(mapperJsonPath, "w") as file: + file.write(json.dumps(deleteIDsMapper)) + + mapperPath = os.path.splitext(trackedSegmFilepath)[0] + mapperJsonPath = f"{mapperPath}_replacedIDs_mapper.json" + mapperJsonName = os.path.basename(mapperJsonPath) + self.logger.log(f"Saving IDs replacements to {mapperJsonName}...") + with open(mapperJsonPath, "w") as file: + file.write(json.dumps(trackedIDsMapper)) + + self.logger.log("Generating acdc_output table...") + acdc_df = None + if not self.trackedSegmFilename: + # Fix existing acdc_df + acdcEndname = self.endFilenameSegm.replace("_segm", "_acdc_output") + acdcFilename = [ + f + for f in myutils.listdir(imagesPath) + if f.endswith(f"{acdcEndname}.csv") + ] + if acdcFilename: + acdcFilePath = os.path.join(imagesPath, acdcFilename[0]) + acdc_df = pd.read_csv(acdcFilePath, index_col=["frame_i", "Cell_ID"]) + + if acdc_df is not None: + acdc_df = core.apply_trackedIDs_mapper_to_acdc_df( + trackedIDsMapper, deleteIDsMapper, acdc_df + ) + else: + acdc_dfs = [] + keys = [] + for frame_i, lab in enumerate(trackedData): + rp = skimage.measure.regionprops(lab) + acdc_df_frame_i = myutils.getBaseAcdcDf(rp) + acdc_dfs.append(acdc_df_frame_i) + keys.append(frame_i) + + acdc_df = pd.concat(acdc_dfs, keys=keys, names=["frame_i", "Cell_ID"]) + segmFilename = os.path.basename(trackedSegmFilepath) + acdcFilename = re.sub(segm_re_pattern, "_acdc_output", segmFilename) + acdcFilePath = os.path.join(imagesPath, acdcFilename) + + self.signals.initProgressBar.emit(pbarMax) + parentIDcol = self.trackColsInfo["parentIDcol"] + trackIDsCol = self.trackColsInfo["trackIDsCol"] + if parentIDcol != "None": + self.logger.log(f'Adding lineage info from "{parentIDcol}" column...') + acdc_df = core.add_cca_info_from_parentID_col( + df, + acdc_df, + frameIndexCol, + trackIDsCol, + parentIDcol, + len(segmData), + signal=self.signals.progressBar, + maskID_colname=self.trackColsInfo["maskIDsCol"], + x_colname=self.trackColsInfo["xCentroidCol"], + y_colname=self.trackColsInfo["yCentroidCol"], + ) + + self.logger.log("Saving acdc_output table...") + acdc_df.to_csv(acdcFilePath) + + self.signals.finished.emit(self) + + +class ToSymDivWorker(QObject): + progressBar = Signal(int, int, float) + + def __init__(self, mainWin): + QObject.__init__(self) + self.signals = signals() + self.abort = False + self.logger = workerLogger(self.signals.progress) + self.mutex = QMutex() + self.waitCond = QWaitCondition() + self.mainWin = mainWin + + def emitSelectSegmFiles(self, exp_path, pos_foldernames): + self.mutex.lock() + self.signals.sigSelectSegmFiles.emit(exp_path, pos_foldernames) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + @worker_exception_handler + def run(self): + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + self.missingAnnotErrors = {} + tot_pos = len(pos_foldernames) + self.allPosDataInputs = [] + posDatas = [] + self.logger.log("-" * 30) + expFoldername = os.path.basename(exp_path) + + abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) + if abort: + self.signals.finished.emit(self) + return + + self.signals.initProgressBar.emit(len(pos_foldernames)) + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.signals.finished.emit(self) + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + pos_path = os.path.join(exp_path, pos) + images_path = os.path.join(pos_path, "Images") + basename, chNames = myutils.getBasenameAndChNames( + images_path, useExt=(".tif", ".h5") + ) + + self.signals.sigUpdatePbarDesc.emit(f"Loading {pos_path}...") + + # Use first found channel, it doesn't matter for metrics + for chName in chNames: + file_path = myutils.getChannelFilePath(images_path, chName) + if file_path: + break + else: + raise FileNotFoundError( + f'None of the channels "{chNames}" were found in the path ' + f'"{images_path}".' + ) + + # Load data + posData = load.loadData(file_path, chName) + posData.getBasenameAndChNames(useExt=(".tif", ".h5")) + + posData.loadOtherFiles( + load_segm_data=False, + load_acdc_df=True, + load_metadata=True, + loadSegmInfo=True, + ) + + posDatas.append(posData) + + self.allPosDataInputs.append({"file_path": file_path, "chName": chName}) + + # Iterate pos and calculate metrics + numPos = len(self.allPosDataInputs) + for p, posDataInputs in enumerate(self.allPosDataInputs): + file_path = posDataInputs["file_path"] + chName = posDataInputs["chName"] + + posData = load.loadData(file_path, chName) + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") + + posData.getBasenameAndChNames(useExt=(".tif", ".h5")) + posData.buildPaths() + posData.loadImgData() + + posData.loadOtherFiles( + load_segm_data=False, + load_acdc_df=True, + end_filename_segm=self.mainWin.endFilenameSegm, + ) + if not posData.acdc_df_found: + relPath = ( + f"...{os.sep}{expFoldername}{os.sep}{posData.pos_foldername}" + ) + self.logger.log( + f'WARNING: Skipping "{relPath}" ' + f"because acdc_output.csv file was not found." + ) + self.missingAnnotErrors[relPath] = ( + f'
    FileNotFoundError: the Positon "{relPath}" ' + "does not have the acdc_output.csv file.
    " + ) + + continue + + acdc_df_filename = os.path.basename(posData.acdc_output_csv_path) + self.logger.log( + f'Loaded path:\nACDC output file name: "{acdc_df_filename}"' + ) + + self.logger.log("Building tree...") + try: + tree = core.LineageTree(posData.acdc_df) + error = tree.build() + if isinstance(error, KeyError): + self.logger.log(str(error)) + + self.logger.log( + "WARNING: Annotations missing in " + f'"{posData.acdc_output_csv_path}"' + ) + self.missingAnnotErrors[acdc_df_filename] = str(error) + continue + elif error is not None: + raise error + posData.acdc_df = tree.df + except Exception as error: + traceback_format = traceback.format_exc() + self.logger.log(traceback_format) + self.errors[error] = traceback_format + + try: + posData.acdc_df.to_csv(posData.acdc_output_csv_path) + except PermissionError: + traceback_str = traceback.format_exc() + self.mutex.lock() + self.signals.sigPermissionError.emit( + traceback_str, posData.acdc_output_csv_path + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + posData.acdc_df.to_csv(posData.acdc_output_csv_path) + + self.signals.progressBar.emit(1) + + self.signals.finished.emit(self) + + +class CopyAllLostObjectsWorker(QObject): + navigateToFrame = Signal(int) + returnToFrame = Signal(int) + copyLostObjectMask = Signal(int) + refreshRp = Signal() + progressBar = Signal(int) + finished = Signal(object) + critical = Signal(object) + + def __init__(self, gui, posData, for_future_frame_n, max_overlap_perc): + super().__init__() + self.gui = gui + self.posData = posData + self.for_future_frame_n = for_future_frame_n + self.max_overlap_perc = max_overlap_perc + + @worker_exception_handler + def run(self): + current_frame_i = self.posData.frame_i + last_visited_frame_i = self.gui.get_last_tracked_i() + last_copied_frame_i = current_frame_i + self.for_future_frame_n + 1 + frames_range = (current_frame_i, last_copied_frame_i) + overlap_warning = False + output = {} + + for frame_i in range(*frames_range): + if frame_i == self.posData.SizeT: + break + + if frame_i > self.posData.frame_i: + # Main thread navigates, runs tracking, updates rp/IDs, etc + self.navigateToFrame.emit(frame_i) + + for lostObj in skimage.measure.regionprops(self.gui.lostObjImage): + overlap = np.count_nonzero( + self.gui.currentLab2D[lostObj.slice][lostObj.image] + ) + overlap_perc = overlap / lostObj.area * 100 + if overlap_perc > self.max_overlap_perc: + overlap_warning = True + continue + + self.copyLostObjectMask.emit(lostObj.label) + + # Refresh rp so the next frame's updateLostNewCurrentIDs sees the + # copied IDs as belonging to this frame and marks them lost there. + self.refreshRp.emit() + + self.progressBar.emit(1) + + if self.for_future_frame_n == 0: + output["overlap_warning"] = overlap_warning + self.finished.emit(output) + return + + # Back to current frame + self.returnToFrame.emit(current_frame_i) + + if last_visited_frame_i < last_copied_frame_i: + output["doReinitLastSegmFrame"] = True + output["last_visited_frame_i"] = last_visited_frame_i + + output["overlap_warning"] = overlap_warning + self.finished.emit(output) + +# Sibling imports (deferred to avoid import cycles) +from ._base import ( + signals, + workerLogger, + worker_exception_handler, +) + diff --git a/cellacdc/workers/util.py b/cellacdc/workers/util.py new file mode 100644 index 000000000..2b4fb5b6e --- /dev/null +++ b/cellacdc/workers/util.py @@ -0,0 +1,709 @@ +"""Background Qt workers: util.""" + +import re +import os +import shutil +import time +import json +import concurrent.futures +from functools import partial +from collections import defaultdict, deque +import itertools + +from typing import Union, List, Dict, Callable, Any, Tuple, Iterable + +from functools import wraps +import numpy as np +import pandas as pd +import h5py +import traceback + +import skimage.io +import skimage.measure +import skimage.exposure + +import queue + +from tqdm import tqdm + +from qtpy.QtCore import Signal, QObject, QMutex, QWaitCondition + +from cellacdc import html_utils + +from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import transformation, measurements, cca_functions +from ..path import copy_or_move_tree +from .. import features, plot +from .. import core +from .. import cca_df_colnames, lineage_tree_cols, default_annot_df +from .. import cca_df_colnames_with_tree +from .. import cli +from ..utils import resize +from .. import segm_utils + +DEBUG = False + +from ._base import ( + BaseWorkerUtil, +) + +class FromImajeJroiToSegmNpzWorker(BaseWorkerUtil): + sigSelectRoisProps = Signal(str, object, bool) + + def __init__(self, mainWin): + super().__init__(mainWin) + + def emitSelectRoisProps(self, roi_filepath, TZYX_shape, is_multi_pos): + self.mutex.lock() + self.sigSelectRoisProps.emit(roi_filepath, TZYX_shape, is_multi_pos) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + @worker_exception_handler + def run(self): + import roifile + + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + abort = self.emitSelectFilesWithText( + exp_path, pos_foldernames, "imagej_rois", ext=".zip" + ) + if abort: + self.signals.finished.emit(self) + return + + self.askRoiPreferences = True + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.signals.finished.emit(self) + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + images_path = os.path.join(exp_path, pos, "Images") + endFilenameRoi = self.mainWin.endFilenameWithText + ls = myutils.listdir(images_path) + rois_filepaths = [ + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameRoi}.zip") + ] + + if not rois_filepaths: + self.logger.log( + "[WARNING]: The following Position folder does not " + f"contain any file ending with {endFilenameRoi}. " + f'Skipping it. "{os.path.join(exp_path, pos)}")' + ) + continue + + rois_filepath = rois_filepaths[0] + + if self.askRoiPreferences: + is_multi_pos = len(pos_foldernames) > 1 + self.logger.log("Loading image data to get image shape...") + TZYX_shape = load.get_tzyx_shape(images_path) + abort = self.emitSelectRoisProps( + rois_filepath, TZYX_shape, is_multi_pos + ) + if abort: + self.signals.finished.emit(self) + return + + self.askRoiPreferences = not self.useSamePropsForNextPos + elif self.areAllRoisSelected: + rois = roifile.roiread(rois_filepath) + self.IDsToRoisMapper = {i + i: roi for roi in enumerate(rois)} + else: + # Use same ID of previous position + rois = roifile.roiread(rois_filepath) + IDsToRoisMapper = {i + i: roi for i, roi in enumerate(rois)} + self.IDsToRoisMapper = { + ID: IDsToRoisMapper[ID] for ID in self.IDsToRoisMapper.keys() + } + + self.logger.log("Generating segm mask from ROIs...") + segm_data = myutils.from_imagej_rois_to_segm_data( + TZYX_shape, + self.IDsToRoisMapper, + self.rescaleRoisSizes, + self.repeatRoisZslicesRange, + ) + + segm_filepath = rois_filepath.replace("imagej_rois", "segm").replace( + ".zip", ".npz" + ) + self.logger.log(f'Saving segm mask to "{segm_filepath}"...') + io.savez_compressed(segm_filepath, segm_data) + + self.signals.finished.emit(self) + + +class ToImajeJroiWorker(BaseWorkerUtil): + def __init__(self, mainWin): + super().__init__(mainWin) + + @worker_exception_handler + def run(self): + from roifile import ImagejRoi, roiwrite + + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) + if abort: + self.signals.finished.emit(self) + return + + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.signals.finished.emit(self) + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + images_path = os.path.join(exp_path, pos, "Images") + endFilenameSegm = self.mainWin.endFilenameSegm + ls = myutils.listdir(images_path) + + files_path = [ + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") + ] + + if not files_path: + self.logger.log( + "[WARNING]: The following Position folder does not " + f"contain any file ending with {endFilenameSegm}. " + f'Skipping it. "{os.path.join(exp_path, pos)}")' + ) + continue + + file_path = files_path[0] + + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") + + posData.getBasenameAndChNames() + posData.buildPaths() + + posData.loadOtherFiles( + load_segm_data=True, + load_metadata=True, + end_filename_segm=endFilenameSegm, + ) + + if posData.SizeT > 1: + rois = [] + max_ID = posData.segm_data.max() + for t, lab in enumerate(posData.segm_data): + rois_t = myutils.from_lab_to_imagej_rois( + lab, ImagejRoi, t=t, SizeT=posData.SizeT, max_ID=max_ID + ) + rois.extend(rois_t) + else: + rois = myutils.from_lab_to_imagej_rois(posData.segm_data, ImagejRoi) + + roi_filepath = posData.segm_npz_path.replace(".npz", ".zip") + roi_filepath = roi_filepath.replace("_segm", "_imagej_rois") + + try: + os.remove(roi_filepath) + except Exception as e: + pass + + roiwrite(roi_filepath, rois) + + self.signals.finished.emit(self) + + +class ToObjCoordsWorker(BaseWorkerUtil): + def __init__(self, mainWin): + super().__init__(mainWin) + + @worker_exception_handler + def run(self): + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) + if abort: + self.signals.finished.emit(self) + return + + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.signals.finished.emit(self) + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + images_path = os.path.join(exp_path, pos, "Images") + endFilenameSegm = self.mainWin.endFilenameSegm + ls = myutils.listdir(images_path) + file_path = [ + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") + ][0] + + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") + + posData.getBasenameAndChNames() + posData.buildPaths() + + posData.loadOtherFiles( + load_segm_data=True, + load_metadata=True, + end_filename_segm=endFilenameSegm, + ) + + if posData.SizeT == 1: + posData.segm_data = posData.segm_data[np.newaxis] + + dfs = [] + n_frames = len(posData.segm_data) + self.signals.initProgressBar.emit(n_frames) + for frame_i, lab in enumerate(posData.segm_data): + df_coords_i = myutils.from_lab_to_obj_coords(lab) + dfs.append(df_coords_i) + self.signals.progressBar.emit(1) + df_filepath = posData.segm_npz_path.replace(".npz", ".csv") + df_filepath = df_filepath.replace("_segm", "_objects_coordinates") + + keys = list(range(len(posData.segm_data))) + df = pd.concat(dfs, keys=keys, names=["frame_i"]) + + self.signals.initProgressBar.emit(0) + df.to_csv(df_filepath) + + self.signals.finished.emit(self) + + +class Stack2DsegmTo3Dsegm(BaseWorkerUtil): + sigAskAppendName = Signal(str, list) + sigAborted = Signal() + + def __init__(self, mainWin, SizeZ): + super().__init__(mainWin) + self.SizeZ = SizeZ + + @worker_exception_handler + def run(self): + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + self.mainWin.infoText = f"Select 2D segmentation file to stack" + abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) + if abort: + self.sigAborted.emit() + return + + # Ask appendend name + self.mutex.lock() + self.sigAskAppendName.emit( + self.mainWin.endFilenameSegm, self.mainWin.existingSegmEndNames + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + self.sigAborted.emit() + return + + appendedName = self.appendedName + self.signals.initProgressBar.emit(len(pos_foldernames)) + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.sigAborted.emit() + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + images_path = os.path.join(exp_path, pos, "Images") + endFilenameSegm = self.mainWin.endFilenameSegm + ls = myutils.listdir(images_path) + file_path = [ + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") + ][0] + + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") + + posData.getBasenameAndChNames() + posData.buildPaths() + + posData.loadOtherFiles( + load_segm_data=True, + load_acdc_df=True, + load_metadata=True, + end_filename_segm=endFilenameSegm, + ) + if posData.segm_data.ndim == 2: + posData.segm_data = posData.segm_data[np.newaxis] + + self.logger.log("Stacking 2D into 3D objects...") + + numFrames = len(posData.segm_data) + self.signals.sigInitInnerPbar.emit(numFrames) + T, Y, X = posData.segm_data.shape + newShape = (T, self.SizeZ, Y, X) + segmData2D = np.zeros(newShape, dtype=np.uint32) + for frame_i, lab in enumerate(posData.segm_data): + stacked_lab = core.stack_2Dlab_to_3D(lab, self.SizeZ) + segmData2D[frame_i] = stacked_lab + + self.signals.sigUpdateInnerPbar.emit(1) + + self.logger.log("Saving stacked 3D segmentation file...") + segmFilename, ext = os.path.splitext(posData.segm_npz_path) + newSegmFilepath = f"{segmFilename}_{appendedName}.npz" + segmData2D = np.squeeze(segmData2D) + io.savez_compressed(newSegmFilepath, segmData2D) + + self.signals.progressBar.emit(1) + + self.signals.finished.emit(self) + + +class FilterObjsFromCoordsTable(BaseWorkerUtil): + sigAskAppendName = Signal(str, list) + sigAborted = Signal() + sigSetColumnsNames = Signal(object, object, object) + + def __init__(self, mainWin): + super().__init__(mainWin) + + def emitSetColumnsNames(self, columns, categories, optionalCategories): + self.mutex.lock() + self.sigSetColumnsNames.emit(columns, categories, optionalCategories) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def getColumnsCategories( + self, df_coords, exp_path, pos_foldernames, endFilenameSegm + ): + columns = df_coords.columns.to_list() + categories = ["X coord. column", "Y coord. column"] + optionalCategories = [] + + images_path = os.path.join(exp_path, pos_foldernames[0], "Images") + metadata_df = load.load_metadata_df(images_path) + SizeT = float(metadata_df.at["SizeT", "values"]) + SizeZ = float(metadata_df.at["SizeZ", "values"]) + + segmData = load.load_segm_file(images_path, end_name_segm_file=endFilenameSegm) + + if segmData.ndim == 4: + categories.append("Z coord. column") + categories.append("Frame index column") + elif segmData.ndim == 3: + if SizeZ > 1 and SizeT == 1: + # 3D z-stack data + categories.append("Z coord. column") + else: + optionalCategories.append("Z coord. column") + + if SizeT > 1: + # 3D time-lapse + categories.append("Frame index column") + else: + optionalCategories.append("Frame index column") + else: + optionalCategories.append("Z coord. column") + optionalCategories.append("Frame index column") + + if len(pos_foldernames) > 1: + categories.append("Position_n") + else: + optionalCategories.append("Position_n") + + return columns, categories, optionalCategories + + def getDfCoords( + self, df_coords, selectedColumnsPerCategory, pos_foldername, frame_i + ): + pos_col = selectedColumnsPerCategory.get("Position_n", "None") + frame_i_col = selectedColumnsPerCategory.get("Frame index column", "None") + x_col = selectedColumnsPerCategory["X coord. column"] + y_col = selectedColumnsPerCategory["Y coord. column"] + if pos_col != "None": + df_coords = df_coords[df_coords[pos_col] == pos_foldername] + if frame_i_col != "None": + df_coords = df_coords[df_coords[frame_i_col] == frame_i] + + xy_cols = [x_col, y_col] + + df_out = pd.DataFrame( + index=df_coords.index, data=df_coords[xy_cols].values, columns=["x", "y"] + ) + z_col = selectedColumnsPerCategory.get("Z coord. column", "None") + if z_col != "None": + df_out["z"] = df_coords[z_col] + + return df_out + + @worker_exception_handler + def run(self): + debugging = False + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + self.errors = {} + tot_pos = len(pos_foldernames) + + self.mainWin.infoText = f"Select segmentation file to filter" + abort = self.emitSelectSegmFiles(exp_path, pos_foldernames) + if abort: + self.sigAborted.emit() + return + endFilenameSegm = self.mainWin.endFilenameSegm + + self.logger.log("Asking to select the CSV table file...") + + abort = self.emitSelectFile( + exp_path, + "Select CSV table file with coordinates to filter", + "CSV (*.csv)", + ) + if abort: + self.sigAborted.emit() + return + + self.logger.log(f"Loading table file `{self.mainWin.selectedFilepath}`..") + df_coords = pd.read_csv(self.mainWin.selectedFilepath) + + columns, categories, optionalCategories = self.getColumnsCategories( + df_coords, exp_path, pos_foldernames, endFilenameSegm + ) + + abort = self.emitSetColumnsNames(columns, categories, optionalCategories) + if abort: + self.sigAborted.emit() + return + + selectedColumnsPerCategory = self.mainWin.selectedColumnsPerCategory + + # Ask appendend name + self.mutex.lock() + self.sigAskAppendName.emit( + self.mainWin.endFilenameSegm, self.mainWin.existingSegmEndNames + ) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + if self.abort: + self.sigAborted.emit() + return + + appendedName = self.appendedName + self.signals.initProgressBar.emit(len(pos_foldernames)) + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.sigAborted.emit() + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + + images_path = os.path.join(exp_path, pos, "Images") + ls = myutils.listdir(images_path) + file_path = [ + os.path.join(images_path, f) + for f in ls + if f.endswith(f"{endFilenameSegm}.npz") + ][0] + + posData = load.loadData(file_path, "") + + self.signals.sigUpdatePbarDesc.emit(f"Processing {posData.pos_path}") + + posData.getBasenameAndChNames() + posData.buildPaths() + + posData.loadOtherFiles( + load_segm_data=True, + load_acdc_df=True, + load_metadata=True, + end_filename_segm=endFilenameSegm, + ) + if posData.SizeT == 1: + posData.segm_data = posData.segm_data[np.newaxis] + + self.logger.log("Filtering objects...") + + numFrames = len(posData.segm_data) + self.signals.sigInitInnerPbar.emit(numFrames) + filteredSegmData = np.zeros_like(posData.segm_data) + for frame_i, lab in enumerate(posData.segm_data): + df_coords_frame_i = self.getDfCoords( + df_coords, selectedColumnsPerCategory, pos, frame_i + ) + if df_coords_frame_i.empty: + num_frames_missing = len(posData.segm_data[frame_i:]) + self.signals.sigUpdateInnerPbar.emit(num_frames_missing) + filteredSegmData = filteredSegmData[:frame_i] + break + + filtered_lab = core.filter_segm_objs_from_table_coords( + lab, df_coords_frame_i + ) + filteredSegmData[frame_i] = filtered_lab + + self.signals.sigUpdateInnerPbar.emit(1) + + self.logger.log("Saving filtered segmentation file...") + segmFilename, ext = os.path.splitext(posData.segm_npz_path) + newSegmFilepath = f"{segmFilename}_{appendedName}.npz" + filteredSegmData = np.squeeze(filteredSegmData) + io.savez_compressed(newSegmFilepath, filteredSegmData) + + self.signals.progressBar.emit(1) + + self.signals.finished.emit(self) + + +class ScreenRecorderWorker(QObject): + sigGrabScreen = Signal() + finished = Signal() + + def __init__(self, screenRecorderWin, folder_path): + QObject.__init__(self) + self.screenRecorderWin = screenRecorderWin + self.folder_path = folder_path + + def run(self): + for i in range(4): + fn = f"shot_{i:03}.jpg" + grab_path = os.path.join(self.folder_path, fn) + screen = self.screenRecorderWin.screen() + screenshot = screen.grabWindow(self.screenRecorderWin.winId()) + screenshot.save(grab_path, "jpg") + print(grab_path) + time.sleep(0.2) + + self.finished.emit() + + +class ApplyImageFilterWorker(QObject): + finished = Signal(object) + critical = Signal(object) + progress = Signal(str) + + def __init__(self, filter_func, input_data): + QObject.__init__(self) + self.filter_func = filter_func + self.input_data = input_data + + @worker_exception_handler + def run(self): + self.progress.emit("Filtering image...") + filtered_data = self.filter_func(self.input_data) + self.finished.emit(filtered_data) + + +class ResizeUtilWorker(BaseWorkerUtil): + sigSetResizeProps = Signal(str) + + def emitSetResizeProps(self, input_path): + self.mutex.lock() + self.sigSetResizeProps.emit(input_path) + self.waitCond.wait(self.mutex) + self.mutex.unlock() + return self.abort + + def __init__(self, mainWin): + super().__init__(mainWin) + + def validateOutputPath(self, path): + if path is None: + return + + images_path = myutils.validate_images_path(path, create_dirs_tree=True) + return images_path + + @worker_exception_handler + def run(self): + expPaths = self.mainWin.expPaths + tot_exp = len(expPaths) + + self.signals.initProgressBar.emit(0) + for i, (exp_path, pos_foldernames) in enumerate(expPaths.items()): + abort = self.emitSetResizeProps(exp_path) + if abort: + self.signals.finished.emit(self) + return + + tot_pos = len(pos_foldernames) + for p, pos in enumerate(pos_foldernames): + if self.abort: + self.signals.finished.emit(self) + return + + self.logger.log( + f"Processing experiment n. {i + 1}/{tot_exp}, " + f"{pos} ({p + 1}/{tot_pos})" + ) + images_path = os.path.join(exp_path, pos, "Images") + + rf = self.resizeFactor + text_to_append = self.textToAppend + images_path_out = self.validateOutputPath(self.expFolderpathOut) + if images_path_out is None: + images_path_out = images_path + resize.run( + images_path, + rf, + text_to_append=text_to_append, + images_path_out=images_path_out, + ) + + self.signals.finished.emit(self) + +# Sibling imports (deferred to avoid import cycles) +from ._base import ( + worker_exception_handler, +) + diff --git a/cellacdc/workflow/__init__.py b/cellacdc/workflow/__init__.py index e724a7d72..b745b2263 100644 --- a/cellacdc/workflow/__init__.py +++ b/cellacdc/workflow/__init__.py @@ -1,6 +1,7 @@ """LangGraph-style workflow modeling for Cell-ACDC pipelines.""" from .adapters import ( + configure_measurements_kernel_for_cli, runnable_config_from_segm_kernel, sync_segm_kernel_from_context, update_workflow_context_from_segm_kernel, @@ -30,6 +31,7 @@ __all__ = [ "BatchState", "CompiledStateGraph", + "configure_measurements_kernel_for_cli", "END", "FullWorkflowState", "InteractiveSegmContext", diff --git a/cellacdc/workflow/adapters.py b/cellacdc/workflow/adapters.py index 4e99a9102..3e783ea40 100644 --- a/cellacdc/workflow/adapters.py +++ b/cellacdc/workflow/adapters.py @@ -173,3 +173,46 @@ def interactive_video_segm_context_from_worker(worker) -> InteractiveSegmContext progress_callback=worker.progressBar, logger_func=worker.logger.log, ) + + +def configure_measurements_kernel_for_cli( + kernel: Any, + channels: list[str] | str, + end_filename_segm: str = "segm", + *, + channels_to_skip: list[str] | None = None, + channels_to_process: list[str] | None = None, + is_segm_3d: bool = False, + is_timelapse: bool = False, + is_zstack: bool = False, +) -> Any: + """Configure a ComputeMeasurementsKernel for headless graph runs (no GUI/INI).""" + from cellacdc import measurements + + if isinstance(channels, str): + channels = [name.strip() for name in channels.split("\n") if name.strip()] + + kernel.init_args(channels, end_filename_segm) + kernel.chNamesToSkip = list(channels_to_skip or []) + kernel.chNamesToProcess = list(channels_to_process or channels) + kernel.metricsToSave = None + kernel.metricsToSkip = {ch: [] for ch in channels} + kernel.calc_for_each_zslice_mapper = {ch: False for ch in channels} + kernel.calc_size_for_each_zslice = False + kernel.save_object_counts_table = False + kernel.mixedChCombineMetricsToSkip = [] + kernel.regionPropsToSave = ( + measurements.get_props_names_3D() + if is_segm_3d + else measurements.get_props_names() + ) + kernel.sizeMetricsToSave = list( + measurements.get_size_metrics_desc(is_segm_3d, is_timelapse).keys() + ) + kernel.chIndipendCustomMetricsToSave = list( + measurements.ch_indipend_custom_metrics_desc( + is_zstack, + isSegm3D=is_segm_3d, + ).keys() + ) + return kernel diff --git a/examples/run_headless_workflow.py b/examples/run_headless_workflow.py new file mode 100644 index 000000000..f57949fcb --- /dev/null +++ b/examples/run_headless_workflow.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Run Cell-ACDC segmentation and measurements without GUI or INI files. + +Edit USER CONFIG below, then: + + python examples/run_headless_workflow.py + +Data must follow the usual ACDC layout: + + /path/to/MyExperiment/Position_001/Images/phase.tif + /path/to/MyExperiment/Position_001/Images/GFP.tif + ... + +You can also build or extend graphs directly — this script shows the +stock pipelines with plain Python configuration. +""" + +from __future__ import annotations + +import os +import sys + +from tqdm import tqdm + +# --------------------------------------------------------------------------- +# USER CONFIG — edit these +# --------------------------------------------------------------------------- + +EXPERIMENT_PATH = "/path/to/MyExperiment" +USER_CH_NAME = "phase" +FLUOR_CHANNELS = ["GFP"] # channels to quantify after segmentation +SEGM_ENDNAME = "segm.npz" # output segm file basename +STOP_FRAME = 10 # frames to process per position (use 1 for single frame) + +# Segmentation +MODEL_NAME = "cellpose" +MODEL_KWARGS = {"diameter": 30} +INIT_MODEL_KWARGS: dict = {} +DO_TRACKING = False +TRACKER_NAME = "" +TRACK_PARAMS: dict = {} +DO_POSTPROCESS = True +DO_SAVE = True +IS_SEGM_3D = False +USE_ROI = True + +# Postprocess (empty dicts = defaults / no custom features) +STANDARD_POSTPROCESS_KWARGS: dict = {} +CUSTOM_POSTPROCESS_FEATURES: dict = {} +CUSTOM_POSTPROCESS_GROUPED_FEATURES: dict = {} + +RUN_SEGMENTATION = True +RUN_MEASUREMENTS = True + +# --------------------------------------------------------------------------- + + +def collect_position_paths(exp_path: str, user_ch: str) -> list[str]: + from cellacdc import myutils + + paths: list[str] = [] + for pos in myutils.get_pos_foldernames(exp_path): + images_path = os.path.join(exp_path, pos, "Images") + paths.append(myutils.getChannelFilePath(images_path, user_ch)) + return paths + + +def build_segm_context(): + """Pure-Python workflow context — no kernel, no INI.""" + from cellacdc.workflow.state import WorkflowContext + + return WorkflowContext( + user_ch_name=USER_CH_NAME, + segm_endname=SEGM_ENDNAME, + model_name=MODEL_NAME, + tracker_name=TRACKER_NAME, + do_tracking=DO_TRACKING, + do_postprocess=DO_POSTPROCESS, + do_save=DO_SAVE, + is_segm_3d=IS_SEGM_3D, + use_roi=USE_ROI, + model_kwargs=dict(MODEL_KWARGS), + init_model_kwargs=dict(INIT_MODEL_KWARGS), + track_params=dict(TRACK_PARAMS), + standard_postprocess_kwargs=dict(STANDARD_POSTPROCESS_KWARGS), + custom_postprocess_features=dict(CUSTOM_POSTPROCESS_FEATURES), + custom_postprocess_grouped_features=dict(CUSTOM_POSTPROCESS_GROUPED_FEATURES), + size_t=STOP_FRAME, + size_z=1, + ) + + +def run_segmentation(logger, log_path, paths: list[str]) -> None: + from cellacdc.workflow.pipelines.batch import run_segm_batch + from cellacdc.workflow.runnable import RunnableConfig + + ctx = build_segm_context() + stops = [STOP_FRAME] * len(paths) + pbar = tqdm(total=len(paths), desc="Segmentation", ncols=100) + results = run_segm_batch( + ctx, + paths, + stops, + config=RunnableConfig(logger_func=logger.info), + progress=pbar, + ) + pbar.close() + aborted = [r for r in results if getattr(r, "aborted", False)] + if aborted: + logger.warning(f"{len(aborted)} position(s) aborted during segmentation.") + + +def run_measurements(logger, log_path, paths: list[str]) -> None: + from cellacdc import cli + from cellacdc.workflow.adapters import configure_measurements_kernel_for_cli + from cellacdc.workflow.pipelines.batch import run_measurements_batch + from cellacdc.workflow.runnable import RunnableConfig + + kernel = cli.ComputeMeasurementsKernel(logger, log_path, is_cli=True) + configure_measurements_kernel_for_cli( + kernel, + channels=[USER_CH_NAME, *FLUOR_CHANNELS], + end_filename_segm=SEGM_ENDNAME.replace(".npz", ""), + is_timelapse=STOP_FRAME > 1, + ) + + stops = [STOP_FRAME] * len(paths) + pbar = tqdm(total=len(paths), desc="Measurements", ncols=100) + run_measurements_batch( + kernel, + paths, + stops, + end_filename_segm=kernel.end_filename_segm, + config=RunnableConfig(logger_func=logger.info), + progress=pbar, + ) + pbar.close() + + +def run_single_position_graph_example(path: str) -> None: + """Minimal example: build one graph and invoke it once.""" + from cellacdc.workflow.pipelines.segm import build_position_segm_graph + from cellacdc.workflow.runnable import RunnableConfig + from cellacdc.workflow.state import PositionState + + graph = build_position_segm_graph(build_segm_context()).compile() + result = graph.invoke( + PositionState(img_path=path, stop_frame_n=STOP_FRAME), + RunnableConfig(logger_func=print), + ) + print("done:", result.aborted, result.error) + + +def main() -> int: + if EXPERIMENT_PATH.startswith("/path/to"): + print("Edit USER CONFIG in examples/run_headless_workflow.py first.", file=sys.stderr) + return 1 + + from cellacdc import myutils + + logger, _, log_path, _ = myutils.setupLogger(module="headless", logs_path=None) + paths = collect_position_paths(EXPERIMENT_PATH, USER_CH_NAME) + if not paths: + logger.error(f"No positions found under {EXPERIMENT_PATH}") + return 1 + + logger.info(f"Found {len(paths)} position(s)") + + if RUN_SEGMENTATION: + run_segmentation(logger, log_path, paths) + + if RUN_MEASUREMENTS: + run_measurements(logger, log_path, paths) + + logger.info("Finished.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/fix_split_imports.py b/scripts/fix_split_imports.py new file mode 100644 index 000000000..9e0ed9603 --- /dev/null +++ b/scripts/fix_split_imports.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Fix parent-package imports in split submodules (from . -> from ..).""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] / "cellacdc" + +PACKAGES: dict[str, set[str]] = { + "myutils": { + "dataframe", + "install", + "io", + "logging", + "misc", + "models", + "paths", + "qt", + "text", + "version", + }, + "workers": { + "_base", + "alignment", + "data_prep", + "gui", + "io", + "metrics", + "segm", + "tracking", + "util", + }, + "widgets": {"canvas", "controls", "toolbars"}, + "dialogs": { + "_base", + "export", + "general", + "measurements", + "metadata", + "models", + "preprocess", + "tracking", + }, +} + + +def fix_line(line: str, siblings: set[str]) -> str: + m = re.match(r"^(\s*)from \. import (.+)$", line) + if m: + indent, rest = m.groups() + return f"{indent}from .. import {rest}" + + m = re.match(r"^(\s*)from \.(\S+) import (.+)$", line) + if not m: + return line + indent, module, rest = m.groups() + top = module.split(".", 1)[0] + if top in siblings: + return line + return f"{indent}from ..{module} import {rest}" + + +def fix_file(path: Path, siblings: set[str]) -> bool: + lines = path.read_text().splitlines(keepends=True) + new_lines = [fix_line(line, siblings) for line in lines] + if new_lines != lines: + path.write_text("".join(new_lines)) + return True + return False + + +def main() -> None: + for pkg, siblings in PACKAGES.items(): + pkg_dir = ROOT / pkg + changed = 0 + for path in sorted(pkg_dir.glob("*.py")): + if path.name == "__init__.py": + continue + if fix_file(path, siblings): + changed += 1 + print(f"{pkg}: fixed {changed} files") + + +if __name__ == "__main__": + main() diff --git a/scripts/split_god_files.py b/scripts/split_god_files.py new file mode 100644 index 000000000..80c3362bb --- /dev/null +++ b/scripts/split_god_files.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""Split god files into packages while preserving public import paths.""" + +from __future__ import annotations + +import ast +import re +import shutil +import textwrap +from collections import defaultdict +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CELLACDC = ROOT / "cellacdc" + + +def extract_nodes(source: str) -> tuple[str, list[tuple[str, str, int, int]]]: + """Return preamble and (name, kind, start, end) for each top-level def/class.""" + lines = source.splitlines(keepends=True) + tree = ast.parse(source) + nodes: list[tuple[str, str, int, int]] = [] + first_start = None + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + end = getattr(node, "end_lineno", node.lineno) + kind = "class" if isinstance(node, ast.ClassDef) else "function" + nodes.append((node.name, kind, node.lineno, end)) + if first_start is None: + first_start = node.lineno + preamble_end = first_start - 1 if first_start else len(lines) + preamble = "".join(lines[:preamble_end]) + return preamble, nodes + + +def slice_nodes(source: str, nodes: list[tuple[str, str, int, int]], names: set[str]) -> str: + lines = source.splitlines(keepends=True) + chunks: list[str] = [] + for name, _kind, start, end in nodes: + if name in names: + chunks.append("".join(lines[start - 1 : end])) + return "\n\n".join(chunks) + + +def write_module( + path: Path, + doc: str, + preamble: str, + body: str, + *, + siblings: set[str] | None = None, +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if siblings is not None: + preamble = fix_preamble_imports(preamble, siblings) + content = f'"""{doc}"""\n\n{preamble.rstrip()}\n\n{body.rstrip()}\n' + path.write_text(content) + + +def assign_myutils(name: str) -> str: + rules: list[tuple[str, str]] = [ + ("logging", r"log|Logger"), + ("paths", r"path|folder|dir|recent|trim_path|explorer|filemaneger|gdrive|acdc_data|pos_folder|images_folder|PosStatus|pos_status"), + ("install", r"install|gpu|pytorch|torch|java|javabridge|conda|pip|package|mamba|upgrade_javabridge|java_exists|download_java|check_install"), + ("dataframe", r"df_|dataframe|acdc_df|ctc|reset_index|format_ID|cca_col|are_acdc_dfs|fix_acdc_df"), + ("version", r"version|git|branch|date_from|info_version|salute|cellpose.*version|second_version"), + ("models", r"model|download|Tracker|tracker|segm_params|init_tracker|ArgSpec|parse_model|insertModel|getModel|promptable|ModelArg|IntensityImgRequired"), + ("qt", r"widget|Qt|Q[A-Z]|retain|cli_multi_choice|testQcore"), + ("io", r"bytes|Memory|browse_docs|save_response|read_|write_|open_url|browse_url"), + ("text", r"tooltip|instruction|html|text|string|trim|annot|elided|fstring|append_text|show_in_file"), + ] + for module, pat in rules: + if re.search(pat, name): + return module + return "misc" + + +def assign_worker(name: str) -> str: + mapping = { + "worker_exception_handler": "_base", + "workerLogger": "_base", + "signals": "_base", + "BaseWorkerUtil": "_base", + "SimpleWorker": "_base", + "AutoPilotWorker": "gui", + "FindNextNewIdWorker": "gui", + "StoreGuiStateWorker": "io", + "AutoSaveWorker": "io", + "LazyLoader": "io", + "loadDataWorker": "io", + "saveDataWorker": "io", + "MoveTempFilesWorker": "io", + "MigrateUserProfileWorker": "io", + "relabelSequentialWorker": "io", + "segmWorker": "segm", + "segmVideoWorker": "segm", + "SegForLostIDsWorker": "segm", + "PostProcessSegmWorker": "segm", + "MagicPromptsWorker": "segm", + "FillHolesInSegWorker": "segm", + "DelObjectsOutsideSegmROIWorker": "segm", + "LabelRoiWorker": "segm", + "CreateConnected3Dsegm": "segm", + "trackingWorker": "tracking", + "TrackSubCellObjectsWorker": "tracking", + "ApplyTrackInfoWorker": "tracking", + "ToSymDivWorker": "tracking", + "CopyAllLostObjectsWorker": "tracking", + "ComputeMetricsWorker": "metrics", + "ComputeMetricsMultiChannelWorker": "metrics", + "ConcatAcdcDfsWorker": "metrics", + "ConcatSpotmaxDfsWorker": "metrics", + "CountObjectsInSegm": "metrics", + "GenerateMotherBudTotalTableWorker": "metrics", + "CcaIntegrityCheckerWorker": "metrics", + "reapplyDataPrepWorker": "data_prep", + "DataPrepSaveBkgrDataWorker": "data_prep", + "DataPrepCropWorker": "data_prep", + "RestructMultiPosWorker": "data_prep", + "RestructMultiTimepointsWorker": "data_prep", + "ImagesToPositionsWorker": "data_prep", + "CustomPreprocessWorkerGUI": "data_prep", + "CombineChannelsWorkerGUI": "data_prep", + "CustomPreprocessWorkerUtil": "data_prep", + "CombineChannelsWorkerUtil": "data_prep", + "SaveProcessedDataWorker": "data_prep", + "SaveCombinedChannelsWorker": "data_prep", + "FucciPreprocessWorker": "data_prep", + "AlignDataWorker": "alignment", + "AlignWorker": "alignment", + "FromImajeJroiToSegmNpzWorker": "util", + "ToImajeJroiWorker": "util", + "ToObjCoordsWorker": "util", + "Stack2DsegmTo3Dsegm": "util", + "ResizeUtilWorker": "util", + "FilterObjsFromCoordsTable": "util", + "ApplyImageFilterWorker": "util", + "ScreenRecorderWorker": "util", + } + return mapping.get(name, "util") + + +def assign_widget(name: str) -> str: + if "Toolbar" in name or "ToolButton" in name or name in { + "ToolBarSeparator", + "ToolBar", + "rightClickToolButton", + }: + return "toolbars" + canvas_markers = ( + "pg.", + "Plot", + "ImageItem", + "ROI", + "Histogram", + "Gradient", + "Scatter", + "Contour", + "ScaleBar", + "ImShow", + "Ghost", + "Ruler", + "RectItem", + "ColorButton", + "LabelItem", + "MainPlot", + "MouseCursor", + "ScrollBar", + "sliderWithSpinBox", + ) + if any(m in name for m in canvas_markers) or name in { + "ContourItem", + "BaseScatterPlotItem", + "CustomAnnotationScatterPlotItem", + "ScatterPlotItem", + "myLabelItem", + "PolyLineROI", + "ZoomROI", + "DelROI", + "PlotCurveItem", + "BaseGradientEditorItemImage", + "BaseGradientEditorItemLabels", + "baseHistogramLUTitem", + "myHistogramLUTitem", + "overlayLabelsGradientWidget", + "labelsGradientWidget", + "BaseImageItem", + "BaseLabelsImageItem", + "OverlayImageItem", + "ParentImageItem", + "ChildImageItem", + "labImageItem", + "labelledQScrollbar", + "navigateQScrollBar", + "linkedQScrollbar", + "myColorButton", + "ScrollBarWithNumericControl", + "PointsScatterPlotItem", + "LabelRoiCircularItem", + }: + return "canvas" + return "controls" + + +def assign_dialog(name: str) -> str: + if name in {"QBaseDialog", "ArgWidget"}: + return "_base" + if name in {"addCustomModelMessages", "addCustomPromptModelMessages"}: + return "models" + rules: list[tuple[str, str]] = [ + ("tracking", r"Tracker|Track|Cca|cca|editCca|lineage|ApplyTrack|MotherBud|SymDiv|manualSeparate|FindID|EditID|NumericEntry|swap|merge"), + ("metadata", r"Metadata|metadata|XML|QDialogMetadata|filenameDialog|AppendText|EntriesWidget|ColumnNames|CropZ|CropTrange|CropT|Zslice|MultiTimePoint|TreeSelector|TreesSelector|MultiList|selectPositions|OrderableList|SelectFolders|OverlayLabels|AutoSaveInterval"), + ("preprocess", r"PreProcess|CombineChannels|Fucci|ResizeUtil|InitFiji|ImageJRois|randomWalker|PostProcess|Threshold|Crop|Formula|DataPrepSubCrops|stopFrame|startStop|FutureFrames|FunctionParams|TestSegm|wandTolerance"), + ("measurements", r"Metric|Measurement|combineMetrics|SetMeasurements|ComputeMetrics|GenerateMother|ApplyTrackTable|SelectFeatures|CombineFeatures"), + ("export", r"Export|Video|Timestamp|ScaleBar|ViewText|pdDataFrame|ViewCcaTable|ObjectCount|Screen|Logo|ShortcutEditor"), + ("models", r"Model|downloadModel|SelectPromptable|SelectModel|InstallPyTorch|Bayesian|DeltaTracker|CellACDCTracker|Promptable|QDialogModelParams|QInput|ChangeUserProfile|SelectAcdcDf|Restore"), + ] + for module, pat in rules: + if re.search(pat, name): + return module + return "general" + + +def fix_preamble_imports(preamble: str, siblings: set[str]) -> str: + """Rewrite cellacdc-root imports for package submodules.""" + out: list[str] = [] + for line in preamble.splitlines(keepends=True): + newline = "\n" if line.endswith("\n") else "" + stripped = line.rstrip("\n") + m = re.match(r"^(\s*)from \. import (.+)$", stripped) + if m: + indent, rest = m.groups() + out.append(f"{indent}from .. import {rest}{newline}") + continue + m = re.match(r"^(\s*)from \.(\S+) import (.+)$", stripped) + if m: + indent, module, rest = m.groups() + top = module.split(".", 1)[0] + if top in siblings: + out.append(line) + else: + out.append(f"{indent}from ..{module} import {rest}{newline}") + continue + out.append(line) + return "".join(out) + + +def inject_cross_imports(pkg_dir: Path) -> None: + """Wire sibling symbols: top imports for bases, trailing imports for calls.""" + assign: dict[str, str] = {} + for p in pkg_dir.glob("*.py"): + if p.name == "__init__.py": + continue + for node in ast.parse(p.read_text()).body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + assign[node.name] = p.stem + + for p in sorted(pkg_dir.glob("*.py")): + if p.name == "__init__.py": + continue + mod = p.stem + source = p.read_text() + tree = ast.parse(source) + + top_needed: dict[str, set[str]] = defaultdict(set) + for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + for base in node.bases: + for sub in ast.walk(base): + if isinstance(sub, ast.Name) and sub.id in assign and assign[sub.id] != mod: + top_needed[assign[sub.id]].add(sub.id) + + trailing_needed: dict[str, set[str]] = defaultdict(set) + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id not in assign or assign[node.id] == mod: + continue + src = assign[node.id] + if node.id in top_needed.get(src, set()): + continue + trailing_needed[src].add(node.id) + + if not top_needed and not trailing_needed: + continue + + def render_imports(needed: dict[str, set[str]], prefix: str) -> str: + lines: list[str] = [] + for src_mod in sorted(needed): + names = sorted(needed[src_mod]) + lines.append(f"{prefix}from .{src_mod} import (") + for name in names: + lines.append(f"{prefix} {name},") + lines.append(f"{prefix})") + return "\n".join(lines) + ("\n\n" if lines else "") + + lines = source.splitlines(keepends=True) + first_def = next( + node.lineno + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + ) + top_block = render_imports(top_needed, "") + trailing_block = render_imports(trailing_needed, "") + if trailing_block: + trailing_block = "\n# Sibling imports (deferred to avoid import cycles)\n" + trailing_block + + new_source = ( + "".join(lines[: first_def - 1]) + + top_block + + "".join(lines[first_def - 1 :]) + + trailing_block + ) + p.write_text(new_source) + + +def split_package( + src_file: Path, + pkg_dir: Path, + assign_fn, + module_doc: str, + *, + delete_src: bool = True, + shim_file: Path | None = None, + shim_import_from: str | None = None, +) -> dict[str, list[str]]: + source = src_file.read_text() + preamble, nodes = extract_nodes(source) + groups: dict[str, set[str]] = defaultdict(set) + for name, _kind, _s, _e in nodes: + groups[assign_fn(name)].add(name) + + if pkg_dir.exists(): + shutil.rmtree(pkg_dir) + pkg_dir.mkdir(parents=True) + + exported: dict[str, list[str]] = {} + sibling_stems = set(groups) + for module, names in sorted(groups.items()): + body = slice_nodes(source, nodes, names) + if not body.strip(): + continue + out = pkg_dir / f"{module}.py" + write_module( + out, + f"{module_doc}: {module}.", + preamble, + body, + siblings=sibling_stems, + ) + exported[module] = sorted(names) + + init_lines = [ + f'"""{module_doc}."""', + "", + ] + all_names: list[str] = [] + for module in sorted(exported): + names = exported[module] + init_lines.append(f"from .{module} import (") + for name in names: + init_lines.append(f" {name},") + all_names.append(name) + init_lines.append(")") + init_lines.append("") + init_lines.append("__all__ = [") + for name in all_names: + init_lines.append(f' "{name}",') + init_lines.append("]") + (pkg_dir / "__init__.py").write_text("\n".join(init_lines) + "\n") + + inject_cross_imports(pkg_dir) + + if delete_src: + src_file.unlink() + + if shim_file is not None: + imp = shim_import_from or pkg_dir.name + shim = textwrap.dedent( + f'''\ + """Compatibility shim; implementation lives in {imp}/.""" + + from .{imp} import * # noqa: F403 + ''' + ) + shim_file.write_text(shim) + + return exported + + +def split_widgets(src_file: Path, pkg_dir: Path) -> None: + """widgets.py becomes a package that also re-exports components/.""" + source = src_file.read_text() + # Keep import block through component re-exports as package preamble. + marker = "\n\n\n\nclass ContourItem" + idx = source.find(marker) + if idx == -1: + raise RuntimeError("Could not locate widgets split marker") + header = source[: idx + 2] + body_source = source[idx + 2 :] + _empty, nodes = extract_nodes(body_source) + + groups: dict[str, set[str]] = defaultdict(set) + for name, _kind, _s, _e in nodes: + groups[assign_widget(name)].add(name) + + if pkg_dir.exists(): + shutil.rmtree(pkg_dir) + pkg_dir.mkdir(parents=True) + + exported: dict[str, list[str]] = {} + sibling_stems = set(groups) + for module, names in sorted(groups.items()): + chunk = slice_nodes(body_source, nodes, names) + if not chunk.strip(): + continue + write_module( + pkg_dir / f"{module}.py", + f"GUI widgets: {module}.", + header, + chunk, + siblings=sibling_stems, + ) + exported[module] = sorted(names) + + init_parts = [ + '"""GUI widgets package (controls, canvas, toolbars) + components re-exports."""', + "", + "from ..components.palette import * # noqa: F403", + "from ..components.progress import * # noqa: F403", + "from ..components.buttons import * # noqa: F403", + "from ..components.layout import * # noqa: F403", + "from ..components.inputs_basic import * # noqa: F403", + "from ..components.path_controls import * # noqa: F403", + "from ..components.lists import * # noqa: F403", + "from ..components.base import QBaseWindow, QBaseDialog # noqa: F401", + "", + ] + all_names: list[str] = [] + for module in sorted(exported): + names = exported[module] + init_parts.append(f"from .{module} import (") + for name in names: + init_parts.append(f" {name},") + all_names.append(name) + init_parts.append(")") + init_parts.append("") + + init_parts.append("__all__ = [") + for name in all_names: + init_parts.append(f' "{name}",') + init_parts.append("]") + (pkg_dir / "__init__.py").write_text("\n".join(init_parts) + "\n") + inject_cross_imports(pkg_dir) + src_file.unlink() + + +def main() -> None: + split_package( + CELLACDC / "myutils.py", + CELLACDC / "myutils", + assign_myutils, + "Cell-ACDC utility helpers", + delete_src=True, + ) + split_package( + CELLACDC / "workers.py", + CELLACDC / "workers", + assign_worker, + "Background Qt workers", + delete_src=True, + ) + split_widgets(CELLACDC / "widgets.py", CELLACDC / "widgets") + split_package( + CELLACDC / "apps.py", + CELLACDC / "dialogs", + assign_dialog, + "Cell-ACDC dialog windows", + delete_src=False, + shim_file=CELLACDC / "apps.py", + shim_import_from="dialogs", + ) + print("Split complete.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_components_imports.py b/tests/test_components_imports.py index 675477a56..4cd85f766 100644 --- a/tests/test_components_imports.py +++ b/tests/test_components_imports.py @@ -20,7 +20,7 @@ def test_leaf_component_modules_import(self): def test_widgets_module_compiles(self): import py_compile - py_compile.compile("cellacdc/widgets.py", doraise=True) + py_compile.compile("cellacdc/widgets/__init__.py", doraise=True) if __name__ == "__main__": diff --git a/tests/test_split_packages.py b/tests/test_split_packages.py new file mode 100644 index 000000000..e3ddfafff --- /dev/null +++ b/tests/test_split_packages.py @@ -0,0 +1,61 @@ +"""Smoke tests for split god-file packages.""" + +import py_compile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +PACKAGES = { + "cellacdc.myutils": [ + "logging", + "paths", + "install", + "dataframe", + ], + "cellacdc.workers": [ + "_base", + "segm", + "tracking", + "io", + ], + "cellacdc.widgets": [ + "controls", + "canvas", + "toolbars", + ], + "cellacdc.dialogs": [ + "_base", + "general", + "tracking", + "measurements", + ], +} + +SHIMS = [ + "cellacdc/apps.py", +] + + +class TestSplitPackages(unittest.TestCase): + def test_leaf_modules_compile(self): + for module_name in PACKAGES: + for leaf in PACKAGES[module_name]: + path = ROOT / module_name.replace(".", "/") / f"{leaf}.py" + with self.subTest(path=str(path)): + py_compile.compile(path, doraise=True) + + def test_package_init_modules_compile(self): + for module_name in PACKAGES: + path = ROOT / module_name.replace(".", "/") / "__init__.py" + with self.subTest(path=str(path)): + py_compile.compile(path, doraise=True) + + def test_shim_modules_compile(self): + for rel_path in SHIMS: + with self.subTest(path=rel_path): + py_compile.compile(ROOT / rel_path, doraise=True) + + +if __name__ == "__main__": + unittest.main() From 03ed5c379f390d6644948a3312fa09dcb0019fa7 Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 16:24:51 +0200 Subject: [PATCH 18/21] Rename myutils to utils and batch utilities package to tools. Clarify module boundaries: utils holds shared helpers, tools holds Utilities-menu batch workflows. Co-authored-by: Cursor --- cellacdc/__main__.py | 14 +- cellacdc/_deprecated/filters.py | 6 +- cellacdc/_main.py | 96 ++-- cellacdc/_run.py | 28 +- cellacdc/_warnings.py | 4 +- cellacdc/acdc_bioio_bioformats/_save_data.py | 2 +- .../_save_data_single_channel.py | 2 +- cellacdc/acdc_bioio_bioformats/_utils.py | 4 +- cellacdc/acdc_bioio_bioformats/install.py | 8 +- cellacdc/acdc_bioio_bioformats/reader.py | 2 +- cellacdc/autopilot.py | 4 +- cellacdc/cca_functions.py | 10 +- cellacdc/cli.py | 38 +- cellacdc/components/buttons.py | 6 +- cellacdc/components/progress.py | 4 +- cellacdc/core.py | 26 +- cellacdc/dataPrep.py | 30 +- cellacdc/dataReStruct.py | 12 +- cellacdc/dataStruct.py | 52 +- cellacdc/debugutils.py | 4 +- cellacdc/dialogs/_base.py | 2 +- cellacdc/dialogs/export.py | 2 +- cellacdc/dialogs/general.py | 12 +- cellacdc/dialogs/measurements.py | 6 +- cellacdc/dialogs/metadata.py | 22 +- cellacdc/dialogs/models.py | 32 +- cellacdc/dialogs/preprocess.py | 14 +- cellacdc/dialogs/tracking.py | 6 +- cellacdc/exporters.py | 4 +- cellacdc/fiji_macros/__init__.py | 6 +- cellacdc/gui.py | 6 +- cellacdc/help/about.py | 14 +- cellacdc/help/welcome.py | 10 +- cellacdc/html_utils.py | 2 +- cellacdc/io.py | 8 +- cellacdc/load.py | 104 ++-- cellacdc/mixins/actions.py | 8 +- cellacdc/mixins/annotation_display.py | 6 +- cellacdc/mixins/app_shell.py | 6 +- cellacdc/mixins/canvas_drawing.py | 6 +- cellacdc/mixins/canvas_selection.py | 2 +- cellacdc/mixins/cell_cycle.py | 8 +- cellacdc/mixins/combine.py | 4 +- cellacdc/mixins/custom_annotations.py | 2 +- cellacdc/mixins/data_loading.py | 32 +- cellacdc/mixins/frame_navigation.py | 6 +- cellacdc/mixins/graphics.py | 6 +- cellacdc/mixins/image_display.py | 28 +- cellacdc/mixins/label_editing.py | 2 +- cellacdc/mixins/layout_controls.py | 12 +- cellacdc/mixins/lineage_interactions.py | 8 +- cellacdc/mixins/magic_prompts.py | 8 +- cellacdc/mixins/saving.py | 4 +- cellacdc/mixins/seg_for_lost_ids.py | 10 +- cellacdc/mixins/segmentation.py | 28 +- cellacdc/mixins/session.py | 8 +- cellacdc/mixins/status_hover.py | 4 +- cellacdc/mixins/tool_activation.py | 2 +- cellacdc/mixins/tracking.py | 10 +- cellacdc/myutils/__init__.py | 520 ------------------ cellacdc/napari_utils/arboretum.py | 8 +- cellacdc/path.py | 6 +- cellacdc/plot.py | 4 +- cellacdc/preprocess.py | 4 +- cellacdc/prompts.py | 6 +- cellacdc/scripts/split_segm_mask_yeast.py | 12 +- cellacdc/segm.py | 30 +- cellacdc/segmenters/BABY/acdcSegment.py | 2 +- .../Cellpose_germlineNuclei/__init__.py | 4 +- cellacdc/segmenters/DeepSea/__init__.py | 10 +- cellacdc/segmenters/DeepSea/acdcSegment.py | 2 +- cellacdc/segmenters/InstanSeg/__init__.py | 4 +- cellacdc/segmenters/InstanSeg/acdcSegment.py | 8 +- cellacdc/segmenters/StarDist/__init__.py | 8 +- cellacdc/segmenters/YeaZ/__init__.py | 4 +- cellacdc/segmenters/YeaZ/acdcSegment.py | 4 +- cellacdc/segmenters/YeaZ_v2/__init__.py | 8 +- cellacdc/segmenters/YeaZ_v2/acdcSegment.py | 4 +- cellacdc/segmenters/YeastMate/__init__.py | 4 +- .../segmenters/_cellpose_base/_directML.py | 2 +- .../segmenters/_cellpose_base/acdcSegment.py | 8 +- cellacdc/segmenters/cellpose_v2/__init__.py | 4 +- .../segmenters/cellpose_v2/acdcSegment.py | 6 +- cellacdc/segmenters/cellpose_v3/__init__.py | 4 +- cellacdc/segmenters/cellpose_v3/_denoise.py | 4 +- .../segmenters/cellpose_v3/acdcSegment.py | 6 +- cellacdc/segmenters/cellpose_v4/__init__.py | 4 +- .../segmenters/cellpose_v4/acdcSegment.py | 8 +- cellacdc/segmenters/cellsam/__init__.py | 4 +- cellacdc/segmenters/cellsam/acdcSegment.py | 4 +- cellacdc/segmenters/delta/__init__.py | 4 +- cellacdc/segmenters/omnipose/__init__.py | 4 +- .../segmenters/omnipose_custom/__init__.py | 4 +- cellacdc/segmenters/pomBseen/__init__.py | 4 +- .../segmenters/pomBseen_nuclear/__init__.py | 4 +- cellacdc/segmenters/sam2/__init__.py | 6 +- cellacdc/segmenters/sam2/acdcSegment.py | 6 +- .../segmenters/segment_anything/__init__.py | 6 +- .../segment_anything/acdcSegment.py | 6 +- .../micro-sam/__init__.py | 4 +- .../nnInteractive/__init__.py | 4 +- .../nnInteractive/acdcPromptSegment.py | 4 +- .../segmenters_promptable/sam2/__init__.py | 4 +- .../sam2/acdcPromptSegment.py | 4 +- .../segment_anything/__init__.py | 4 +- .../segment_anything/acdcPromptSegment.py | 4 +- cellacdc/test_segm_model.py | 14 +- cellacdc/test_tracker.py | 10 +- cellacdc/tools/__init__.py | 0 cellacdc/{utils => tools}/acdcToSymDiv.py | 8 +- cellacdc/{utils => tools}/align.py | 4 +- .../{utils => tools}/applyTrackFromTable.py | 8 +- .../applyTrackFromTrackMateXML.py | 8 +- cellacdc/{utils => tools}/base.py | 20 +- cellacdc/{utils => tools}/combineChannels.py | 6 +- cellacdc/{utils => tools}/compute.py | 10 +- .../{utils => tools}/computeMultiChannel.py | 4 +- cellacdc/{utils => tools}/concat.py | 10 +- cellacdc/{utils => tools}/convert.py | 20 +- cellacdc/{utils => tools}/countObjects.py | 4 +- .../{utils => tools}/createConnected3Dsegm.py | 4 +- cellacdc/{utils => tools}/customPreprocess.py | 6 +- cellacdc/{utils => tools}/fillHolesInSegm.py | 4 +- .../filterObjFromCoordsTable.py | 4 +- .../{utils => tools}/fromImageJroiToSegm.py | 4 +- cellacdc/{utils => tools}/fucciPreprocess.py | 6 +- .../generateMothBudTotalTable.py | 10 +- cellacdc/{utils => tools}/rename.py | 12 +- cellacdc/{utils => tools}/repeat.py | 10 +- cellacdc/{utils => tools}/resize/__init__.py | 20 +- cellacdc/{utils => tools}/resize/util.py | 4 +- .../{utils => tools}/stack2Dinto3Dsegm.py | 4 +- cellacdc/{utils => tools}/toImageJroi.py | 4 +- cellacdc/{utils => tools}/toObjCoords.py | 4 +- .../{utils => tools}/trackSubCellObjects.py | 4 +- cellacdc/trackers/BABY/BABY_tracker.py | 4 +- cellacdc/trackers/BABY/__init__.py | 4 +- cellacdc/trackers/BayesianTracker/__init__.py | 6 +- .../CellACDC_normal_division_tracker.py | 4 +- cellacdc/trackers/DeepSea/DeepSea_tracker.py | 2 +- cellacdc/trackers/TAPIR/__init__.py | 6 +- .../trackers/Trackastra/Trackastra_tracker.py | 6 +- cellacdc/trackers/Trackastra/__init__.py | 4 +- cellacdc/trackers/trackpy/__init__.py | 4 +- cellacdc/utils/__init__.py | 520 ++++++++++++++++++ cellacdc/{myutils => utils}/dataframe.py | 0 cellacdc/{myutils => utils}/install.py | 2 +- cellacdc/{myutils => utils}/io.py | 0 cellacdc/{myutils => utils}/logging.py | 0 cellacdc/{myutils => utils}/misc.py | 0 cellacdc/{myutils => utils}/models.py | 0 cellacdc/{myutils => utils}/paths.py | 0 cellacdc/{myutils => utils}/qt.py | 0 cellacdc/{myutils => utils}/text.py | 0 cellacdc/{myutils => utils}/version.py | 0 cellacdc/whitelist.py | 4 +- cellacdc/widgets/canvas.py | 4 +- cellacdc/widgets/controls.py | 54 +- cellacdc/widgets/toolbars.py | 6 +- cellacdc/workers/_base.py | 4 +- cellacdc/workers/alignment.py | 22 +- cellacdc/workers/data_prep.py | 20 +- cellacdc/workers/gui.py | 4 +- cellacdc/workers/io.py | 6 +- cellacdc/workers/metrics.py | 20 +- cellacdc/workers/segm.py | 10 +- cellacdc/workers/tracking.py | 16 +- cellacdc/workers/util.py | 24 +- .../pipelines/measurements_gui_nodes.py | 2 +- cellacdc/workflow/pipelines/segm_nodes.py | 12 +- examples/run_headless_workflow.py | 10 +- scripts/fix_split_imports.py | 2 +- scripts/rename_utils_tools.py | 82 +++ scripts/split_god_files.py | 10 +- tests/prompt_segm/test_sam.py | 6 +- tests/prompt_segm/test_sam2.py | 6 +- tests/segm/test_cellsam.py | 4 +- tests/segm/test_sam.py | 6 +- tests/segm/test_sam2.py | 6 +- tests/test_split_packages.py | 7 +- 180 files changed, 1403 insertions(+), 1322 deletions(-) delete mode 100644 cellacdc/myutils/__init__.py create mode 100755 cellacdc/tools/__init__.py rename cellacdc/{utils => tools}/acdcToSymDiv.py (96%) rename cellacdc/{utils => tools}/align.py (97%) rename cellacdc/{utils => tools}/applyTrackFromTable.py (94%) rename cellacdc/{utils => tools}/applyTrackFromTrackMateXML.py (95%) rename cellacdc/{utils => tools}/base.py (96%) rename cellacdc/{utils => tools}/combineChannels.py (95%) rename cellacdc/{utils => tools}/compute.py (98%) rename cellacdc/{utils => tools}/computeMultiChannel.py (98%) rename cellacdc/{utils => tools}/concat.py (97%) rename cellacdc/{utils => tools}/convert.py (97%) rename cellacdc/{utils => tools}/countObjects.py (92%) rename cellacdc/{utils => tools}/createConnected3Dsegm.py (95%) rename cellacdc/{utils => tools}/customPreprocess.py (94%) rename cellacdc/{utils => tools}/fillHolesInSegm.py (96%) rename cellacdc/{utils => tools}/filterObjFromCoordsTable.py (96%) rename cellacdc/{utils => tools}/fromImageJroiToSegm.py (94%) rename cellacdc/{utils => tools}/fucciPreprocess.py (95%) rename cellacdc/{utils => tools}/generateMothBudTotalTable.py (90%) rename cellacdc/{utils => tools}/rename.py (96%) rename cellacdc/{utils => tools}/repeat.py (97%) rename cellacdc/{utils => tools}/resize/__init__.py (95%) rename cellacdc/{utils => tools}/resize/util.py (93%) rename cellacdc/{utils => tools}/stack2Dinto3Dsegm.py (95%) rename cellacdc/{utils => tools}/toImageJroi.py (90%) rename cellacdc/{utils => tools}/toObjCoords.py (90%) rename cellacdc/{utils => tools}/trackSubCellObjects.py (97%) mode change 100755 => 100644 cellacdc/utils/__init__.py rename cellacdc/{myutils => utils}/dataframe.py (100%) rename cellacdc/{myutils => utils}/install.py (99%) rename cellacdc/{myutils => utils}/io.py (100%) rename cellacdc/{myutils => utils}/logging.py (100%) rename cellacdc/{myutils => utils}/misc.py (100%) rename cellacdc/{myutils => utils}/models.py (100%) rename cellacdc/{myutils => utils}/paths.py (100%) rename cellacdc/{myutils => utils}/qt.py (100%) rename cellacdc/{myutils => utils}/text.py (100%) rename cellacdc/{myutils => utils}/version.py (100%) create mode 100644 scripts/rename_utils_tools.py diff --git a/cellacdc/__main__.py b/cellacdc/__main__.py index 0ef175de5..98fc6f849 100755 --- a/cellacdc/__main__.py +++ b/cellacdc/__main__.py @@ -47,14 +47,14 @@ def run(): PARAMS_PATH = parser_args["params"] if parser_args["version"] or parser_args["info"]: - from cellacdc.myutils import get_info_version_text + from cellacdc.utils import get_info_version_text info_txt = get_info_version_text() print(info_txt) exit() if parser_args["reset"]: - from cellacdc.myutils import reset_settings + from cellacdc.utils import reset_settings reset_info_txt = reset_settings() print(reset_info_txt) @@ -133,7 +133,7 @@ def run_gui(): # Create the application app, splashScreen = _run._setup_app(splashscreen=True) - from cellacdc import myutils, printl + from cellacdc import utils, printl print("Launching application...") @@ -145,18 +145,18 @@ def run_gui(): win = mainWin(app) try: - myutils.check_matplotlib_version(qparent=win) + utils.check_matplotlib_version(qparent=win) except Exception as e: pass - version, success = myutils.read_version(logger=win.logger.info, return_success=True) + version, success = utils.read_version(logger=win.logger.info, return_success=True) if not success: - error = myutils.check_install_package( + error = utils.check_install_package( "setuptools_scm", pypi_name="setuptools-scm" ) if error: win.logger.info(error) else: - version = myutils.read_version(logger=win.logger.info) + version = utils.read_version(logger=win.logger.info) win.setVersion(version) win.launchWelcomeGuide() win.show() diff --git a/cellacdc/_deprecated/filters.py b/cellacdc/_deprecated/filters.py index b3ffba969..4dedd27fb 100644 --- a/cellacdc/_deprecated/filters.py +++ b/cellacdc/_deprecated/filters.py @@ -5,7 +5,7 @@ from cellacdc import html_utils -from . import GUI_INSTALLED, core, myutils +from . import GUI_INSTALLED, core, utils from . import preprocess if GUI_INSTALLED: @@ -245,12 +245,12 @@ def filter(self, img): if sigma1_yx>0: filtered1 = skimage.filters.gaussian(img, sigma=sigmas1) else: - filtered1 = myutils.img_to_float(img) + filtered1 = utils.img_to_float(img) if sigma2_yx>0: filtered2 = skimage.filters.gaussian(img, sigma=sigmas2) else: - filtered2 = myutils.img_to_float(img) + filtered2 = utils.img_to_float(img) resultFiltered = filtered1 - filtered2 return resultFiltered diff --git a/cellacdc/_main.py b/cellacdc/_main.py index 3f20ca434..04b372e94 100644 --- a/cellacdc/_main.py +++ b/cellacdc/_main.py @@ -54,7 +54,7 @@ dataStruct, load, help, - myutils, + utils, cite_url, html_utils, widgets, @@ -62,30 +62,30 @@ dataReStruct, ) from .help import about -from .utils import concat as utilsConcat -from .utils import convert as utilsConvert -from .utils import rename as utilsRename -from .utils import align as utilsAlign -from .utils import compute as utilsCompute -from .utils import repeat as utilsRepeat -from .utils import toImageJroi as utilsToImageJroi -from .utils.resize import util as utilsResizePositionsUtil -from .utils import fromImageJroiToSegm as utilsFromImageJroi -from .utils import toObjCoords as utilsToObjCoords -from .utils import acdcToSymDiv as utilsSymDiv -from .utils import trackSubCellObjects as utilsTrackSubCell -from .utils import createConnected3Dsegm as utilsConnected3Dsegm -from .utils import countObjects as utilsCountObjectsInSegm -from .utils import fucciPreprocess as utilsFucciPreprocess -from .utils import customPreprocess as utilsCustomPreprocess -from .utils import combineChannels as utilsCombineChannels -from .utils import filterObjFromCoordsTable as utilsFilterObjsFromTable -from .utils import stack2Dinto3Dsegm as utilsStack2Dto3D -from .utils import computeMultiChannel as utilsComputeMultiCh -from .utils import applyTrackFromTable as utilsApplyTrackFromTab -from .utils import applyTrackFromTrackMateXML as utilsApplyTrackFromTrackMate -from .utils import fillHolesInSegm -from .utils import generateMothBudTotalTable as utilsGenerateMothBudTotTable +from .tools import concat as utilsConcat +from .tools import convert as utilsConvert +from .tools import rename as utilsRename +from .tools import align as utilsAlign +from .tools import compute as utilsCompute +from .tools import repeat as utilsRepeat +from .tools import toImageJroi as utilsToImageJroi +from .tools.resize import util as utilsResizePositionsUtil +from .tools import fromImageJroiToSegm as utilsFromImageJroi +from .tools import toObjCoords as utilsToObjCoords +from .tools import acdcToSymDiv as utilsSymDiv +from .tools import trackSubCellObjects as utilsTrackSubCell +from .tools import createConnected3Dsegm as utilsConnected3Dsegm +from .tools import countObjects as utilsCountObjectsInSegm +from .tools import fucciPreprocess as utilsFucciPreprocess +from .tools import customPreprocess as utilsCustomPreprocess +from .tools import combineChannels as utilsCombineChannels +from .tools import filterObjFromCoordsTable as utilsFilterObjsFromTable +from .tools import stack2Dinto3Dsegm as utilsStack2Dto3D +from .tools import computeMultiChannel as utilsComputeMultiCh +from .tools import applyTrackFromTable as utilsApplyTrackFromTab +from .tools import applyTrackFromTrackMateXML as utilsApplyTrackFromTrackMate +from .tools import fillHolesInSegm +from .tools import generateMothBudTotalTable as utilsGenerateMothBudTotTable from .info import utilsInfo from . import is_win, is_linux, settings_folderpath, issues_url, is_mac from . import settings_csv_path @@ -136,7 +136,7 @@ def __init__(self, app, parent=None): self.checkUserDataFolderPath = True - logger, logs_path, log_path, log_filename = myutils.setupLogger(module="main") + logger, logs_path, log_path, log_filename = utils.setupLogger(module="main") self.logger = logger self.log_path = log_path self.log_filename = log_filename @@ -925,7 +925,7 @@ def connectActions(self): if SPOTMAX_INSTALLED: self.aboutSmaxAction.triggered.connect(self.showAboutSmax) - self.userManualAction.triggered.connect(myutils.browse_docs) + self.userManualAction.triggered.connect(utils.browse_docs) self.contributeAction.triggered.connect(self.showContribute) self.citeAction.triggered.connect( partial(QDesktopServices.openUrl, QUrl(cite_url)) @@ -949,19 +949,19 @@ def connectActions(self): def openSettingsFolder(self): from . import settings_folderpath - myutils.showInExplorer(settings_folderpath) + utils.showInExplorer(settings_folderpath) def openUserProfileFolder(self): from . import user_profile_path - myutils.showInExplorer(user_profile_path) + utils.showInExplorer(user_profile_path) def showLogFiles(self): - logs_path = myutils.get_logs_path() - myutils.showInExplorer(logs_path) + logs_path = utils.get_logs_path() + utils.showInExplorer(logs_path) def launchUpdateSpotmax(self): - res = myutils.update_package( + res = utils.update_package( self, "spotmax", ) @@ -971,7 +971,7 @@ def launchUpdateSpotmax(self): self.showNoUpdateInfo("spotMAX") def launchUpdateACDC(self): - res = myutils.update_package(self, "cellacdc") + res = utils.update_package(self, "cellacdc") if res: self.showUpdateInfo("Cell-ACDC") else: @@ -1012,7 +1012,7 @@ def populateOpenRecent(self): if not os.path.exists(path): continue action = QAction(path, self) - action.triggered.connect(partial(myutils.showInExplorer, path)) + action.triggered.connect(partial(utils.showInExplorer, path)) actions.append(action) # Step 3. Add the actions to the menu self.recentPathsMenu.addActions(actions) @@ -1043,7 +1043,7 @@ def getSelectedPosPath(self, utilityName): print(f"{utilityName} aborted by the user.") return - mostRecentPath = myutils.getMostRecentPath() + mostRecentPath = utils.getMostRecentPath() exp_path = QFileDialog.getExistingDirectory( self, "Select Position_n folder", mostRecentPath ) @@ -1051,7 +1051,7 @@ def getSelectedPosPath(self, utilityName): print(f"{utilityName} aborted by the user.") return - myutils.addToRecentPaths(exp_path) + utils.addToRecentPaths(exp_path) baseFolder = os.path.basename(exp_path) isPosFolder = re.search(r"Position_(\d+)$", baseFolder) is not None isImagesFolder = baseFolder == "Images" @@ -1064,7 +1064,7 @@ def getSelectedPosPath(self, utilityName): posFolders = [os.path.basename(posPath)] exp_path = os.path.dirname(exp_path) else: - posFolders = myutils.get_pos_foldernames(exp_path) + posFolders = utils.get_pos_foldernames(exp_path) if not posFolders: msg = widgets.myMessageBox() msg.addShowInFileManagerButton(exp_path, txt="Show selected folder...") @@ -1118,7 +1118,7 @@ def getSelectedExpPaths(self, utilityName, exp_folderpath=None, custom_txt=None) return expPaths = {} - mostRecentPath = myutils.getMostRecentPath() + mostRecentPath = utils.getMostRecentPath() warn_exp_already_selected = True while True: if exp_folderpath is None: @@ -1130,12 +1130,12 @@ def getSelectedExpPaths(self, utilityName, exp_folderpath=None, custom_txt=None) ) if not exp_path: break - myutils.addToRecentPaths(exp_path) + utils.addToRecentPaths(exp_path) else: exp_path = exp_folderpath selected_path = exp_path baseFolder = os.path.basename(exp_path) - isPosFolder = myutils.is_pos_folderpath(exp_path) + isPosFolder = utils.is_pos_folderpath(exp_path) isImagesFolder = baseFolder == "Images" if isImagesFolder: posPath = os.path.dirname(exp_path) @@ -1224,8 +1224,8 @@ def warnNoValidExpPaths(self, selected_path): return msg.cancel def warnExpPathAlreadySelected(self, selected_path, exp_path): - selected_text = myutils.to_relative_path(selected_path) - exp_text = myutils.to_relative_path(exp_path) + selected_text = utils.to_relative_path(selected_path) + exp_text = utils.to_relative_path(exp_path) txt = html_utils.paragraph(f""" The experiment folder of the selected path was already previously selected.

    Are you adding Position folders one by one? If yes, you do not @@ -1347,12 +1347,12 @@ def applyTrackingFromTableFinished(self): self.applyTrackWin.close() def launchNapariUtil(self, action): - myutils.check_install_package("napari", parent=self) + utils.check_install_package("napari", parent=self) if action == self.arboretumAction: self._launchArboretum() def _launchArboretum(self): - myutils.check_install_package("napari_arboretum", parent=self) + utils.check_install_package("napari_arboretum", parent=self) from cellacdc.napari_utils import arboretum @@ -1393,7 +1393,7 @@ def launchToObjectsCoordsUtil(self): def launchFromImageJroiToSegmUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') - myutils.check_install_package("roifile", parent=self) + utils.check_install_package("roifile", parent=self) import roifile @@ -1436,7 +1436,7 @@ def launchResizeUtil(self): def launchToImageJroiUtil(self): self.logger.info(f'Launching utility "{self.sender().text()}"') - myutils.check_install_package("roifile", parent=self) + utils.check_install_package("roifile", parent=self) import roifile @@ -1763,7 +1763,7 @@ def getInfoPosStatus(self, expPaths, utilityName): posFoldersInfo = {} for pos in posFoldernames: pos_path = os.path.join(exp_path, pos) - status = myutils.get_pos_status(pos_path, caller=caller) + status = utils.get_pos_status(pos_path, caller=caller) posFoldersInfo[pos] = status infoPaths[exp_path] = posFoldersInfo return infoPaths @@ -2312,5 +2312,5 @@ def closeEvent(self, event): print("Failed to restart Cell-ACDC. Please restart manually") else: self.logger.info("**********************************************") - self.logger.info(f"Cell-ACDC closed. {myutils.get_salute_string()}") + self.logger.info(f"Cell-ACDC closed. {utils.get_salute_string()}") self.logger.info("**********************************************") diff --git a/cellacdc/_run.py b/cellacdc/_run.py index 3892222d0..83debc10f 100644 --- a/cellacdc/_run.py +++ b/cellacdc/_run.py @@ -4,7 +4,7 @@ from importlib import import_module import traceback from tqdm import tqdm -from . import config, myutils +from . import config, utils def _install_tables(parent_software="Cell-ACDC"): @@ -36,8 +36,8 @@ def _install_tables(parent_software="Cell-ACDC"): ) print("-" * 60) print(txt) - conda_prefix, pip_prefix = myutils.get_pip_conda_prefix() - conda_list, pip_list = myutils.get_pip_conda_prefix(list_return=True) + conda_prefix, pip_prefix = utils.get_pip_conda_prefix() + conda_list, pip_list = utils.get_pip_conda_prefix(list_return=True) conda_txt = f"{conda_prefix} pytables" pip_text = f"{pip_prefix} --upgrade tables" @@ -180,8 +180,8 @@ def _setup_gui_libraries(caller_name="Cell-ACDC", exit_at_end=True): try: import qtpy except ModuleNotFoundError as e: - conda_prefix, pip_prefix = myutils.get_pip_conda_prefix() - conda_list, pip_list = myutils.get_pip_conda_prefix(list_return=True) + conda_prefix, pip_prefix = utils.get_pip_conda_prefix() + conda_list, pip_list = utils.get_pip_conda_prefix(list_return=True) command_txt = f"{pip_prefix} --upgrade qtpy" @@ -401,7 +401,7 @@ def download_model_params(): pass if parser_args["YeaZModelsDownload"] or parser_args["AllModelsDownload"]: print("[INFO]: Downloading YeaZ models...") - from cellacdc.myutils import _download_yeaz_models + from cellacdc.utils import _download_yeaz_models try: _download_yeaz_models() @@ -411,7 +411,7 @@ def download_model_params(): pass if parser_args["DeepSeaModelsDownload"] or parser_args["AllModelsDownload"]: print("[INFO]: Downloading DeepSea models...") - from cellacdc.myutils import _download_deepsea_models + from cellacdc.utils import _download_deepsea_models try: _download_deepsea_models() @@ -422,7 +422,7 @@ def download_model_params(): if parser_args["TrackastraModelsDownload"] or parser_args["AllModelsDownload"]: print("[INFO]: Downloading TrackAstra models...") - # from cellacdc.myutils import _download_trackastra_models + # from cellacdc.utils import _download_trackastra_models from trackastra.model import Trackastra try: @@ -620,12 +620,12 @@ def run_measurements_workflow(workflow_params, logger, log_path): def run_cli(ini_filepath): - from cellacdc import myutils + from cellacdc import utils from cellacdc.workflow.pipelines.full_workflow import build_full_workflow_graph from cellacdc.workflow.runnable import RunnableConfig from cellacdc.workflow.state import FullWorkflowState - logger, logs_path, log_path, log_filename = myutils.setupLogger( + logger, logs_path, log_path, log_filename = utils.setupLogger( module="cli", logs_path=None ) @@ -657,7 +657,7 @@ def run_cli(ini_filepath): ) logger.info("**********************************************") - logger.info(f"Cell-ACDC command-line closed. {myutils.get_salute_string()}") + logger.info(f"Cell-ACDC command-line closed. {utils.get_salute_string()}") logger.info("**********************************************") @@ -703,15 +703,15 @@ def _setup_numpy(caller_name="Cell-ACDC"): import numpy installed_numpy_version = numpy.__version__ - is_numpy_version_within_range = myutils.is_pkg_version_within_range( + is_numpy_version_within_range = utils.is_pkg_version_within_range( installed_numpy_version, min_version=min_version, max_version=max_version ) if is_numpy_version_within_range: return - conda_prefix, pip_prefix = myutils.get_pip_conda_prefix() - conda_list, pip_list = myutils.get_pip_conda_prefix(list_return=True) + conda_prefix, pip_prefix = utils.get_pip_conda_prefix() + conda_list, pip_list = utils.get_pip_conda_prefix(list_return=True) command_txt = f'{pip_prefix} --upgrade "{numpy_versions_txt}"' diff --git a/cellacdc/_warnings.py b/cellacdc/_warnings.py index fc61e1098..86f49672e 100644 --- a/cellacdc/_warnings.py +++ b/cellacdc/_warnings.py @@ -2,7 +2,7 @@ from functools import partial import re -from cellacdc import html_utils, myutils +from cellacdc import html_utils, utils from . import issues_url from . import urls @@ -122,7 +122,7 @@ def warn_cca_integrity(txt, category, qparent, go_to_frame_callback=None): def warn_installing_different_cellpose_version(requested_version, installed_version): from cellacdc import widgets - if not myutils.is_gui_running(): + if not utils.is_gui_running(): print( f"[WARNING]: You requested to install `Cellpose {requested_version}` " f"but you already have `Cellpose {installed_version}`.\n\n" diff --git a/cellacdc/acdc_bioio_bioformats/_save_data.py b/cellacdc/acdc_bioio_bioformats/_save_data.py index a0f347860..96a239085 100644 --- a/cellacdc/acdc_bioio_bioformats/_save_data.py +++ b/cellacdc/acdc_bioio_bioformats/_save_data.py @@ -6,7 +6,7 @@ import h5py from cellacdc import bioio_sample_data_folderpath -from cellacdc import myutils +from cellacdc import utils from cellacdc import acdc_bioio_bioformats as bioformats import argparse diff --git a/cellacdc/acdc_bioio_bioformats/_save_data_single_channel.py b/cellacdc/acdc_bioio_bioformats/_save_data_single_channel.py index 7f5c94a6d..ec6943959 100644 --- a/cellacdc/acdc_bioio_bioformats/_save_data_single_channel.py +++ b/cellacdc/acdc_bioio_bioformats/_save_data_single_channel.py @@ -6,7 +6,7 @@ import h5py from cellacdc import bioio_sample_data_folderpath -from cellacdc import myutils +from cellacdc import utils from cellacdc import acdc_bioio_bioformats as bioformats import argparse diff --git a/cellacdc/acdc_bioio_bioformats/_utils.py b/cellacdc/acdc_bioio_bioformats/_utils.py index 983042088..d18604f07 100644 --- a/cellacdc/acdc_bioio_bioformats/_utils.py +++ b/cellacdc/acdc_bioio_bioformats/_utils.py @@ -11,7 +11,7 @@ import numpy as np import h5py -from cellacdc import myutils, bioio_sample_data_folderpath +from cellacdc import utils, bioio_sample_data_folderpath def setup_argparser(): @@ -126,7 +126,7 @@ def saveImgDataChannel( if not to_h5: imgData_ch = np.squeeze(np.array(imgData_ch, dtype=imgData.dtype)) - myutils.to_tiff( + utils.to_tiff( filePath, imgData_ch, SizeT=savedSizeT, diff --git a/cellacdc/acdc_bioio_bioformats/install.py b/cellacdc/acdc_bioio_bioformats/install.py index 8c796d14b..3be680800 100644 --- a/cellacdc/acdc_bioio_bioformats/install.py +++ b/cellacdc/acdc_bioio_bioformats/install.py @@ -2,7 +2,7 @@ import re -from cellacdc import myutils +from cellacdc import utils from . import EXTENSION_PACKAGE_MAPPER @@ -10,7 +10,7 @@ def _check_install_bioio_bioformats(qparent=None): - myutils.check_install_package( + utils.check_install_package( "scyjava", installer="conda", is_cli=qparent is None, @@ -18,7 +18,7 @@ def _check_install_bioio_bioformats(qparent=None): parent=qparent, ) - myutils.check_install_package( + utils.check_install_package( "bioio-bioformats", installer="pip", is_cli=qparent is None, @@ -44,7 +44,7 @@ def _check_install_extra_format_dependency(image_filepath: os.PathLike, qparent= _check_install_bioio_bioformats(qparent=qparent) return - myutils.check_install_package( + utils.check_install_package( package_name, installer="pip", is_cli=qparent is None, diff --git a/cellacdc/acdc_bioio_bioformats/reader.py b/cellacdc/acdc_bioio_bioformats/reader.py index 815946c88..a7fc4d2e9 100644 --- a/cellacdc/acdc_bioio_bioformats/reader.py +++ b/cellacdc/acdc_bioio_bioformats/reader.py @@ -4,7 +4,7 @@ import numpy as np from .. import printl -from ..myutils import safe_get_or_call +from ..utils import safe_get_or_call from . import install, EXTENSION_PACKAGE_MAPPER from . import EXTENSION_BIOIMAGE_KWARGS_MAPPER from . import EXTENSION_METADATA_ATTR_MAPPER diff --git a/cellacdc/autopilot.py b/cellacdc/autopilot.py index a5453ecfa..073724f10 100644 --- a/cellacdc/autopilot.py +++ b/cellacdc/autopilot.py @@ -2,7 +2,7 @@ from qtpy.QtCore import QTimer, QThread, Signal, QObject -from . import load, printl, myutils +from . import load, printl, utils class AutoPilotProfile: @@ -120,7 +120,7 @@ def loadPosTimerCallback(self): windowActions = self.loadingProfile[0]["windowActions"] windowActionsArgs = self.loadingProfile[0]["windowActionsArgs"] for action, args in zip(windowActions, windowActionsArgs): - func = myutils.get_chained_attr(window, action) + func = utils.get_chained_attr(window, action) func(*args) self.loadingProfile.pop(0) diff --git a/cellacdc/cca_functions.py b/cellacdc/cca_functions.py index df55e51ce..51b3c8a71 100755 --- a/cellacdc/cca_functions.py +++ b/cellacdc/cca_functions.py @@ -22,7 +22,7 @@ from . import _run from . import load, cca_df_colnames -from . import myutils, prompts, html_utils, printl +from . import utils, prompts, html_utils, printl default_summable_columns = ( "cell_area_um2", @@ -43,7 +43,7 @@ def configuration_dialog(): data_dirs = [] positions = [] while continue_selection: - MostRecentPath = myutils.getMostRecentPath() + MostRecentPath = utils.getMostRecentPath() data_dir = QFileDialog.getExistingDirectory( None, "Select experiment folder containing Position_n folders ", @@ -53,7 +53,7 @@ def configuration_dialog(): continue_selection = False break - myutils.addToRecentPaths(data_dir) + utils.addToRecentPaths(data_dir) foldername = os.path.basename(data_dir) if foldername == "Images": pos_path = os.path.dirname(data_dir) @@ -64,7 +64,7 @@ def configuration_dialog(): data_dir = os.path.dirname(data_dir) pos = [os.path.basename(pos_path)] else: - available_pos = myutils.get_pos_foldernames(data_dir) + available_pos = utils.get_pos_foldernames(data_dir) if not available_pos: print("******************************") print("Selected folder does not contain any Position folders.") @@ -235,7 +235,7 @@ def calculate_downstream_data( temp_df["position"] = positions[file_idx][pos_idx] temp_df["directory"] = pos_dir print("Saving calculated data for next time...") - files_in_curr_dir = myutils.listdir(pos_dir) + files_in_curr_dir = utils.listdir(pos_dir) common_prefix = _determine_common_prefix(files_in_curr_dir) save_path = os.path.join( pos_dir, f"{common_prefix}cca_properties_downstream.csv" diff --git a/cellacdc/cli.py b/cellacdc/cli.py index 822f54bf0..b1b75f442 100644 --- a/cellacdc/cli.py +++ b/cellacdc/cli.py @@ -15,7 +15,7 @@ from . import load from . import error_up_str from . import issues_url -from . import myutils +from . import utils from . import config from . import core from . import features @@ -117,7 +117,7 @@ def quit(self, error=None): else: self.logger.info( "Cell-ACDC command-line interface closed. " - f"{myutils.get_salute_string()}" + f"{utils.get_salute_string()}" ) self.logger.info("=" * 50) exit() @@ -263,18 +263,18 @@ def init_segm_model(self, posData): self.signals.progress.emit( f"\nInitializing {self.model_name} segmentation model..." ) - acdcSegment = myutils.import_segment_module(self.model_name) - init_argspecs, segment_argspecs = myutils.getModelArgSpec(acdcSegment) - self.init_model_kwargs = myutils.parse_model_params( + acdcSegment = utils.import_segment_module(self.model_name) + init_argspecs, segment_argspecs = utils.getModelArgSpec(acdcSegment) + self.init_model_kwargs = utils.parse_model_params( init_argspecs, self.init_model_kwargs ) - self.model_kwargs = myutils.parse_model_params( + self.model_kwargs = utils.parse_model_params( segment_argspecs, self.model_kwargs ) if self.second_channel_name is not None: self.init_model_kwargs["is_rgb"] = True - self.model = myutils.init_segm_model( + self.model = utils.init_segm_model( acdcSegment, posData, self.init_model_kwargs ) if self.model is None: @@ -297,17 +297,17 @@ def init_tracker(self, do_tracking, track_params, tracker_name="", tracker=None) if tracker is None: self.signals.progress.emit(f"Initializing {tracker_name} tracker...") - tracker_module = myutils.import_tracker_module(tracker_name) - init_argspecs, track_argspecs = myutils.getTrackerArgSpec( + tracker_module = utils.import_tracker_module(tracker_name) + init_argspecs, track_argspecs = utils.getTrackerArgSpec( tracker_module, realTime=False ) - self.init_tracker_kwargs = myutils.parse_model_params( + self.init_tracker_kwargs = utils.parse_model_params( init_argspecs, self.init_tracker_kwargs ) - self.init_tracker_kwargs = myutils.parse_model_params( + self.init_tracker_kwargs = utils.parse_model_params( init_argspecs, self.init_tracker_kwargs ) - track_params = myutils.parse_model_params(track_argspecs, track_params) + track_params = utils.parse_model_params(track_argspecs, track_params) tracker = tracker_module.tracker(**self.init_tracker_kwargs) self.track_params = track_params @@ -621,7 +621,7 @@ def _init_metrics_to_save(self, posData): def _load_posData(self, img_path, end_filename_segm): images_path = os.path.dirname(img_path) exp_foldername = os.path.basename(os.path.dirname(os.path.dirname(images_path))) - basename, channel_names = myutils.getBasenameAndChNames( + basename, channel_names = utils.getBasenameAndChNames( images_path, useExt=(".tif", ".h5") ) posData = load.loadData(img_path, channel_names[0]) @@ -744,12 +744,12 @@ def _run_metrics_cli( posData.rp = rp if posData.acdc_df is None: - acdc_df = myutils.getBaseAcdcDf(rp) + acdc_df = utils.getBaseAcdcDf(rp) else: try: acdc_df = posData.acdc_df.loc[frame_i].copy() except Exception: - acdc_df = myutils.getBaseAcdcDf(rp) + acdc_df = utils.getBaseAcdcDf(rp) key = (frame_i, posData.TimeIncrement * frame_i) acdc_df = load.pd_bool_and_float_to_int_to_str( @@ -847,12 +847,12 @@ def _compute_metrics_gui_frames( if acdc_df is None: if posData.acdc_df is None: - acdc_df = myutils.getBaseAcdcDf(rp) + acdc_df = utils.getBaseAcdcDf(rp) else: try: acdc_df = posData.acdc_df.loc[frame_i].copy() except Exception: - acdc_df = myutils.getBaseAcdcDf(rp) + acdc_df = utils.getBaseAcdcDf(rp) key = (frame_i, posData.TimeIncrement * frame_i) acdc_df = load.pd_bool_and_float_to_int_to_str( @@ -1388,10 +1388,10 @@ def _init_metrics(self, posData, isSegm3D): ) exp_path = posData.exp_path - posFoldernames = myutils.get_pos_foldernames(exp_path) + posFoldernames = utils.get_pos_foldernames(exp_path) for pos in posFoldernames: images_path = os.path.join(exp_path, pos, "Images") - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): if not file.endswith("custom_combine_metrics.ini"): continue filePath = os.path.join(images_path, file) diff --git a/cellacdc/components/buttons.py b/cellacdc/components/buttons.py index 00eec101c..bda76b40d 100644 --- a/cellacdc/components/buttons.py +++ b/cellacdc/components/buttons.py @@ -26,7 +26,7 @@ QWidgetAction, ) -from .. import myutils +from .. import utils class PushButton(QPushButton): def __init__( @@ -430,12 +430,12 @@ def __init__(self, *args, setDefaultText=False, **kwargs): self.setDefaultText() def setDefaultText(self): - self._text = myutils.get_show_in_file_manager_text() + self._text = utils.get_show_in_file_manager_text() self.setText(self._text) def setPathToBrowse(self, path: os.PathLike): self._path_to_browse = path - self.clicked.connect(partial(myutils.showInExplorer, path)) + self.clicked.connect(partial(utils.showInExplorer, path)) class OpenUrlButton(PushButton): diff --git a/cellacdc/components/progress.py b/cellacdc/components/progress.py index 9fcfc8541..bcb61cc96 100644 --- a/cellacdc/components/progress.py +++ b/cellacdc/components/progress.py @@ -15,7 +15,7 @@ QTextEdit, ) -from .. import _palettes, myutils +from .. import _palettes, utils from .palette import PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, PROGRESSBAR_QCOLOR @@ -145,7 +145,7 @@ def update(self, step: int): ) / self.value() seconds_left = self.mean_value_duration * value_left - ETA = myutils.seconds_to_ETA(seconds_left) + ETA = utils.seconds_to_ETA(seconds_left) self.ETA_label.setText(ETA) self.last_time_update = t return ETA diff --git a/cellacdc/core.py b/cellacdc/core.py index 6a1009a4d..b92e22c84 100755 --- a/cellacdc/core.py +++ b/cellacdc/core.py @@ -33,7 +33,7 @@ from tqdm import tqdm -from . import load, myutils +from . import load, utils from . import cca_df_colnames, printl, base_cca_dict, base_cca_tree_dict from . import features from . import error_up_str @@ -373,7 +373,7 @@ def track_sub_cell_objects_third_segm_acdc_df( rp = skimage.measure.regionprops(lab) IDs = [obj.label for obj in rp] if frame_i not in parent_objs_acdc_df.index.get_level_values(0): - acdc_df_frame_i = myutils.getBaseAcdcDf(rp) + acdc_df_frame_i = utils.getBaseAcdcDf(rp) else: acdc_df_frame_i = parent_objs_acdc_df.loc[frame_i] cols = acdc_df_frame_i.columns.intersection(all_non_metrics_cols) @@ -410,9 +410,9 @@ def track_sub_cell_objects_acdc_df( sub_ids = [sub_obj.label for sub_obj in rp_sub] old_sub_ids = all_old_sub_ids[frame_i] if subobj_acdc_df is None: - sub_acdc_df_frame_i = myutils.getBaseAcdcDf(rp_sub) + sub_acdc_df_frame_i = utils.getBaseAcdcDf(rp_sub) elif frame_i not in subobj_acdc_df.index.get_level_values(0): - sub_acdc_df_frame_i = myutils.getBaseAcdcDf(rp_sub) + sub_acdc_df_frame_i = utils.getBaseAcdcDf(rp_sub) else: sub_acdc_df_frame_i = subobj_acdc_df.loc[frame_i].rename(index=old_sub_ids) if "relative_ID" in sub_acdc_df_frame_i.columns: @@ -432,7 +432,7 @@ def track_sub_cell_objects_acdc_df( # --> check with `IDs_with_sub_obj = ... if id in lab` IDs_with_sub_obj = [id for id in sub_ids if id in lab] if cells_acdc_df is None: - acdc_df_frame_i = myutils.getBaseAcdcDf(rp) + acdc_df_frame_i = utils.getBaseAcdcDf(rp) else: acdc_df_frame_i = cells_acdc_df.loc[[frame_i]].copy() @@ -2586,7 +2586,7 @@ def combine_channels_func( channel_names = [step["channel"] for step in steps.values()] channel_keys = steps.keys() segm_channels, fluo_channel_names, current_segm = ( - myutils.separate_fluo_segment_channels(channel_names) + utils.separate_fluo_segment_channels(channel_names) ) original_dtype = None @@ -2600,7 +2600,7 @@ def combine_channels_func( if original_dtype is None: original_dtype = ch_image_data.dtype - ch_image_data = myutils.img_to_float(ch_image_data) + ch_image_data = utils.img_to_float(ch_image_data) target_dims, target_shape = _update_target_shape_target_dims( target_dims, target_shape, ch_image_data ) @@ -2642,7 +2642,7 @@ def combine_channels_func( ) if original_dtype is None: original_dtype = channel_img_data.dtype - channel_img_data_float = myutils.img_to_float(channel_img_data) + channel_img_data_float = utils.img_to_float(channel_img_data) target_dims, target_shape = _update_target_shape_target_dims( target_dims, target_shape, channel_img_data_float ) @@ -2759,8 +2759,8 @@ def combine_channels_func( txt = "" if keep_input_data_type and not output_as_segm: try: - output_img = myutils.convert_to_dtype(output_img, original_dtype) - method = "cellacdc.myutils.convert_to_dtype" + output_img = utils.convert_to_dtype(output_img, original_dtype) + method = "cellacdc.utils.convert_to_dtype" warning = "safe" prefix = "" except Exception as err: @@ -3263,7 +3263,7 @@ def parallel_count_objects(posData, logger_func): for future in tqdm(as_completed(futures), total=len(futures), ncols=100): i, data_dict, IDs = future.result() posData.allData_li[i] = ( - myutils.get_empty_stored_data_dict() + utils.get_empty_stored_data_dict() ) # or directly assign if it's mutable posData.allData_li[i]["IDs"] = data_dict["IDs"] posData.allData_li[i]["regionprops"] = data_dict["regionprops"] @@ -3292,7 +3292,7 @@ def count_objects(posData, logger_func): if benchmark: t0 = time.perf_counter() for i, lab in enumerate(segm_data): - posData.allData_li[i] = myutils.get_empty_stored_data_dict() + posData.allData_li[i] = utils.get_empty_stored_data_dict() rp = skimage.measure.regionprops(lab) IDs = [obj.label for obj in rp] posData.allData_li[i]["IDs"] = IDs @@ -3531,7 +3531,7 @@ def apply_func_to_imgs( image_out = np.empty(target_shape, dtype=target_type) - input_output_mapper = myutils.get_input_output_mapper( + input_output_mapper = utils.get_input_output_mapper( image_shape, iter_axis, target_shape, target_axis_iter ) diff --git a/cellacdc/dataPrep.py b/cellacdc/dataPrep.py index 275b17be4..9b01102b7 100755 --- a/cellacdc/dataPrep.py +++ b/cellacdc/dataPrep.py @@ -61,9 +61,9 @@ # Custom modules from . import exception_handler -from . import load, prompts, apps, core, myutils +from . import load, prompts, apps, core, utils from . import widgets -from . import html_utils, myutils, darkBkgrColor, printl +from . import html_utils, utils, darkBkgrColor, printl from . import autopilot, workers from . import recentPaths_path from . import urls @@ -106,7 +106,7 @@ def __init__(self, parent=None, buttonToRestore=None, mainWin=None, version=None self._version = version - logger, logs_path, log_path, log_filename = myutils.setupLogger( + logger, logs_path, log_path, log_filename = utils.setupLogger( module="dataPrep" ) self.logger = logger @@ -125,7 +125,7 @@ def __init__(self, parent=None, buttonToRestore=None, mainWin=None, version=None if mainWin is not None: self.app = mainWin.app - self._acdc_version = myutils.read_version() + self._acdc_version = utils.read_version() self.setWindowTitle(f"Cell-ACDC v{self._acdc_version} - data prep") self.setGeometry(100, 50, 850, 800) self.setWindowIcon(QIcon(":icon.ico")) @@ -837,7 +837,7 @@ def saveBkgrData(self, posData): for chName in posData.chNames: alignedFound = False tifFound = False - for file in myutils.listdir(posData.images_path): + for file in utils.listdir(posData.images_path): filePath = os.path.join(posData.images_path, file) filenameNOext, _ = os.path.splitext(file) if file.endswith(f"{chName}_aligned.npz"): @@ -1010,7 +1010,7 @@ def gui_mouseDragEventImg(self, event): x, y = event.pos().x(), event.pos().y() Y, X = posData.img_data.shape[-2:] xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): + if not utils.is_in_bounds(xdata, ydata, X, Y): return if self.isFreeRoiDrag: @@ -1076,7 +1076,7 @@ def saveCroppedChannel(self, cropped_data, npz_path, tif_path, posData): self.logger.info(f"Saving: {tif_path}") temp_tif = self.getTempfilePath(tif_path) - myutils.to_tiff( + utils.to_tiff( temp_tif, cropped_data, SizeT=getattr(posData, "SizeT", None), @@ -1133,7 +1133,7 @@ def copyAdditionalFilesToCropFolder( except IndexError: pass - for file in myutils.listdir(posData.images_path): + for file in utils.listdir(posData.images_path): copy_file = ( file.endswith("bkgrRoiData.npz") or file.endswith("dataPrep_bkgrROIs.json") @@ -1160,7 +1160,7 @@ def copyAdditionalFilesToCropFolder( def saveSingleCrop(self, posData, cropROI, dstPath): if dstPath != posData.images_path: - currentSubPosFolders = myutils.get_pos_foldernames(dstPath) + currentSubPosFolders = utils.get_pos_foldernames(dstPath) currentSubPosNumbers = [ int(pos.split("_")[-1]) for pos in currentSubPosFolders ] @@ -1318,7 +1318,7 @@ def saveMultiCrops(self, posData, cropDstPaths): basename = posData.basename for p, cropROI in enumerate(posData.cropROIs): parentSubPosPath = cropDstPaths[p] - currentSubPosFolders = myutils.get_pos_foldernames(parentSubPosPath) + currentSubPosFolders = utils.get_pos_foldernames(parentSubPosPath) currentSubPosNumbers = [ int(pos.split("_")[-1]) for pos in currentSubPosFolders ] @@ -2085,7 +2085,7 @@ def init_segmInfo_df(self): ) if NO_segmInfo and posData.SizeZ > 1: filename = posData.filename - df = myutils.getDefault_SegmInfo_df(posData, filename) + df = utils.getDefault_SegmInfo_df(posData, filename) if posData.segmInfo_df is None: posData.segmInfo_df = df else: @@ -2820,7 +2820,7 @@ def loadFiles(self, exp_path, user_ch_file_paths, user_ch_name): self.gui_connectGraphicsEvents() exp_path = self.data[self.pos_i].exp_path - pos_foldernames = myutils.get_pos_foldernames(exp_path) + pos_foldernames = utils.get_pos_foldernames(exp_path) if len(pos_foldernames) == 1: # There is only one position --> disable switch pos action self.loadPosAction.setDisabled(True) @@ -2880,7 +2880,7 @@ def showAbout(self): self.aboutWin.show() def showHowToDataPrep(self): - myutils.browse_url(urls.dataprep_docs) + utils.browse_url(urls.dataprep_docs) def openRecentFile(self, path): self.logger.info(f"Opening recent folder: {path}") @@ -2907,7 +2907,7 @@ def openFolder(self, checked=False, exp_path=None): ) return - folder_type = myutils.determine_folder_type(exp_path) + folder_type = utils.determine_folder_type(exp_path) is_pos_folder, is_images_folder, exp_path = folder_type self.titleLabel.setText("Loading data...", color="w") @@ -2969,7 +2969,7 @@ def openFolder(self, checked=False, exp_path=None): # Get info from first position selected images_path = self.images_paths[0] - filenames = myutils.listdir(images_path) + filenames = utils.listdir(images_path) if ch_name_selector.is_first_call: ch_names, warn = ch_name_selector.get_available_channels( filenames, images_path diff --git a/cellacdc/dataReStruct.py b/cellacdc/dataReStruct.py index c50e23048..966c590ba 100644 --- a/cellacdc/dataReStruct.py +++ b/cellacdc/dataReStruct.py @@ -11,7 +11,7 @@ from qtpy.QtCore import QThread from qtpy.QtWidgets import QFileDialog -from . import apps, html_utils, myutils, printl, widgets, workers +from . import apps, html_utils, utils, printl, widgets, workers # Frame number must be at the end with .ext, e.g., _t01.tif frame_name_patterns = (r"_(day)?(\d+)\.[A-Za-z0-9]+$", r"_(t)?(\d+)\.[A-Za-z0-9]+$") @@ -86,11 +86,11 @@ def run(mainWin): mainWin.log( "[Data Re-Struct] Asking to select the folder that contains the image files..." ) - MostRecentPath = myutils.getMostRecentPath() + MostRecentPath = utils.getMostRecentPath() rootFolderPath = QFileDialog.getExistingDirectory( mainWin.progressWin, "Select folder containing the image files", MostRecentPath ) - myutils.addToRecentPaths(rootFolderPath) + utils.addToRecentPaths(rootFolderPath) if not rootFolderPath: return False @@ -100,7 +100,7 @@ def run(mainWin): "Select the folder in which to save the images files", rootFolderPath, ) - myutils.addToRecentPaths(dstFolderPath) + utils.addToRecentPaths(dstFolderPath) if not rootFolderPath: return False @@ -139,7 +139,7 @@ def run(mainWin): def checkFileFormat(folderPath, mainWin): - ls = natsorted(myutils.listdir(folderPath)) + ls = natsorted(utils.listdir(folderPath)) files = [ filename for filename in ls @@ -192,7 +192,7 @@ def checkFileFormat(folderPath, mainWin): def saveTiff(filePath, data, waitCond): - myutils.to_tiff(filePath, data) + utils.to_tiff(filePath, data) waitCond.wakeAll() del data diff --git a/cellacdc/dataStruct.py b/cellacdc/dataStruct.py index 3a5e4f557..d2dd70a64 100755 --- a/cellacdc/dataStruct.py +++ b/cellacdc/dataStruct.py @@ -42,7 +42,7 @@ # a separate process that doesn't have a parent package from . import issues_url from . import exception_handler -from . import apps, myutils, widgets, html_utils, printl +from . import apps, utils, widgets, html_utils, printl from . import load, settings_csv_path from . import _palettes from . import recentPaths_path, cellacdc_path, settings_folderpath @@ -816,7 +816,7 @@ def saveImgDataChannel( if not self.to_h5: imgData_ch = np.squeeze(np.array(imgData_ch, dtype=imgData.dtype)) - myutils.to_tiff( + utils.to_tiff( filePath, imgData_ch, SizeT=savedSizeT, @@ -991,7 +991,7 @@ def saveData(self, images_path, rawFilePath, filename, p, series, pos_n, p_idx=0 raw_src_path = os.path.dirname(rawFilePath) rawFilePath = [ os.path.join(raw_src_path, f) - for f in myutils.listdir(raw_src_path) + for f in utils.listdir(raw_src_path) if f.find(rawFilename) != -1 ][0] @@ -1056,7 +1056,7 @@ def saveData(self, images_path, rawFilePath, filename, p, series, pos_n, p_idx=0 # contain "otherFilename" in the name otherFilename = f"{basename}{pos_n}" rawFilePath = set() - for f in myutils.listdir(raw_src_path): + for f in utils.listdir(raw_src_path): notRawFile = all( [f.find(rawName) == -1 for rawName in pos_rawFilenames] ) @@ -1219,7 +1219,7 @@ def __init__( self._version = version - logger, logs_path, log_path, log_filename = myutils.setupLogger( + logger, logs_path, log_path, log_filename = utils.setupLogger( module="dataStruct" ) self.logger = logger @@ -1240,7 +1240,7 @@ def __init__( self.metadataDialogIsOpen = False self.df_settings = pd.read_csv(settings_csv_path, index_col="setting") - version = myutils.read_version() + version = utils.read_version() self.setWindowTitle(f"Cell-ACDC v{version} - Data structure") self.setWindowIcon(QtGui.QIcon(":icon.ico")) @@ -1331,7 +1331,7 @@ def __init__( self.checkInstallPythonBioformats(parent) def checkInstallBioIO(self, parent): - myutils.check_install_package( + utils.check_install_package( "BioIO", import_pkg_name="bioio", pypi_name="bioio", @@ -1351,13 +1351,13 @@ def checkInstallPythonBioformats(self, parent): self.close() raise OSError("This module is supported ONLY on Windows 10/10 and macOS") - success, jar_dst_path = myutils.download_bioformats_jar( + success, jar_dst_path = utils.download_bioformats_jar( qparent=self, logger_info=self.logger.info, logger_exception=self.logger.exception, ) self.logger.info("Checking if Java is installed...") - myutils.check_upgrade_javabridge() + utils.check_upgrade_javabridge() try: import javabridge except ModuleNotFoundError as e: @@ -1365,11 +1365,11 @@ def checkInstallPythonBioformats(self, parent): traceback_str = traceback.format_exc() self.logger.exception(traceback_str) print("======================================") - cancel = myutils.install_javabridge_help(parent=self) + cancel = utils.install_javabridge_help(parent=self) if cancel: raise ModuleNotFoundError("User aborted javabridge installation") - isGitInstalled = myutils.check_git_installed(parent=self) + isGitInstalled = utils.check_git_installed(parent=self) if not isGitInstalled: raise ModuleNotFoundError( "Git is not installed. Install from " @@ -1377,15 +1377,15 @@ def checkInstallPythonBioformats(self, parent): ) try: - jre_path, jdk_path, url = myutils.download_java() + jre_path, jdk_path, url = utils.download_java() except Exception as e: print("======================================") traceback_str = traceback.format_exc() self.logger.exception(traceback_str) print("======================================") - java_info = myutils.get_java_url() + java_info = utils.get_java_url() url, file_size, os_foldername, unzipped_foldername = java_info - acdc_java_path, _ = myutils.get_acdc_java_path() + acdc_java_path, _ = utils.get_acdc_java_path() java_href = f'this' s = ( f"1. Download {java_href} .zip file and unzip it.
    " @@ -1416,23 +1416,23 @@ def checkInstallPythonBioformats(self, parent): ) if not is_win: - cancel = myutils.install_java() + cancel = utils.install_java() if cancel: raise ModuleNotFoundError("User aborted Java installation") return - myutils.install_javabridge() + utils.install_javabridge() except Exception as e: print("======================================") traceback_str = traceback.format_exc() self.logger.exception(traceback_str) print("======================================") - cancel = myutils.install_java() + cancel = utils.install_java() if cancel: raise ModuleNotFoundError("User aborted Java installation") return - myutils.install_javabridge(force_compile=True, attempt_uninstall_first=True) + utils.install_javabridge(force_compile=True, attempt_uninstall_first=True) try: import javabridge @@ -1714,7 +1714,7 @@ def askMoveRawMicroscopyFiles(self): return msg.clickedButton == moveButton, msg.cancel def _installLazyLoadModules(self): - myutils.check_install_package( + utils.check_install_package( "zarr", installer="pip", is_cli=False, @@ -1914,7 +1914,7 @@ def warnSelectedPathEmpty(self, raw_src_path): def checkFileFormat(self, raw_src_path): self.moveOtherFiles = False self.copyOtherFiles = False - ls = natsorted(myutils.listdir(raw_src_path)) + ls = natsorted(utils.listdir(raw_src_path)) files = [ filename for filename in ls @@ -2093,7 +2093,7 @@ def attemptSeparateMultiChannel(self, rawFilenames): stripped_filenames = [] for file in rawFilenames: filename, ext = os.path.splitext(file) - m_iter = myutils.findalliter(rf"(\d+)_(.+)", filename) + m_iter = utils.findalliter(rf"(\d+)_(.+)", filename) if len(m_iter) <= 1: self.criticalNoFilenamePattern() return False @@ -2112,7 +2112,7 @@ def attemptSeparateMultiChannel(self, rawFilenames): self.criticalNoFilenamePattern(error=traceback.format_exc()) return False - basename = myutils.getBasename(stripped_filenames) + basename = utils.getBasename(stripped_filenames) if not basename: self.criticalNoFilenamePattern() return False @@ -2222,7 +2222,7 @@ def askConfirmMetadata( self.waitCond.wakeAll() def askPosFoldersExisting(self, exp_dst_path): - pos_foldernames = myutils.get_pos_foldernames(exp_dst_path) + pos_foldernames = utils.get_pos_foldernames(exp_dst_path) if not pos_foldernames: return False, False, False, 1 @@ -2281,7 +2281,7 @@ def __init__(self, acdcLauncher): self.logger = self.acdcLauncher.logger def askSelectInstalledFiji(self): - if os.path.exists(myutils.get_fiji_exec_folderpath()): + if os.path.exists(utils.get_fiji_exec_folderpath()): return False, False txt = html_utils.paragraph(f""" @@ -2345,7 +2345,7 @@ def run(self): its creation process and cancel its execution later. """ self.logger.info("Testing Fiji command...") - fiji_success = myutils.test_fiji_base_command(self.logger.info) + fiji_success = utils.test_fiji_base_command(self.logger.info) commands = None if not fiji_success: if not did_user_selected_fiji: @@ -2378,7 +2378,7 @@ def run(self): self.cancel() return - myutils.download_fiji(logger_func=self.logger.info) + utils.download_fiji(logger_func=self.logger.info) win = apps.InitFijiMacroDialog(parent=self.acdcLauncher) win.exec_() diff --git a/cellacdc/debugutils.py b/cellacdc/debugutils.py index 28a001005..b135cfeba 100644 --- a/cellacdc/debugutils.py +++ b/cellacdc/debugutils.py @@ -1,6 +1,6 @@ import inspect, os, datetime, sys, traceback -from . import cellacdc_path, myutils +from . import cellacdc_path, utils import gc import psutil @@ -23,7 +23,7 @@ def showRefGraph(object_str: str, debug: bool = True): try: import objgraph except ImportError: - conda_prefix, pip_prefix = myutils.get_pip_conda_prefix() + conda_prefix, pip_prefix = utils.get_pip_conda_prefix() print( f"objgraph is not installed. Install it with '{pip_prefix} objgraph' to use reference graph features, as well as https://www.graphviz.org/" diff --git a/cellacdc/dialogs/_base.py b/cellacdc/dialogs/_base.py index b58493c13..b107439f8 100644 --- a/cellacdc/dialogs/_base.py +++ b/cellacdc/dialogs/_base.py @@ -134,7 +134,7 @@ from .. import printl from .. import colors from .. import issues_url -from .. import myutils +from .. import utils from .. import qutils from .. import _palettes from .. import base_cca_dict diff --git a/cellacdc/dialogs/export.py b/cellacdc/dialogs/export.py index f3cfebcb8..a11857f59 100644 --- a/cellacdc/dialogs/export.py +++ b/cellacdc/dialogs/export.py @@ -134,7 +134,7 @@ from .. import printl from .. import colors from .. import issues_url -from .. import myutils +from .. import utils from .. import qutils from .. import _palettes from .. import base_cca_dict diff --git a/cellacdc/dialogs/general.py b/cellacdc/dialogs/general.py index dd9cf5944..697fe6655 100644 --- a/cellacdc/dialogs/general.py +++ b/cellacdc/dialogs/general.py @@ -134,7 +134,7 @@ from .. import printl from .. import colors from .. import issues_url -from .. import myutils +from .. import utils from .. import qutils from .. import _palettes from .. import base_cca_dict @@ -1690,7 +1690,7 @@ def __init__( self.frame_i = posData.frame_i self.num_frames = posData.SizeT - version = myutils.read_version() + version = utils.read_version() self.setWindowTitle(f"Cell-ACDC v{version} - {posData.relPath}") def gui_createActions(self): @@ -2311,7 +2311,7 @@ def askSelectOverlayChannel(self): def setOverlayImages(self, frame_i=None): posData = self.posData for filename in posData.ol_data: - chName = myutils.get_chname_from_basename( + chName = utils.get_chname_from_basename( filename, posData.basename, remove_ext=False ) if chName not in self.checkedOverlayChannels: @@ -2858,7 +2858,7 @@ def __init__( okButton = widgets.okPushButton("Ok") cancelButton = widgets.cancelPushButton("Cancel") if showInFileManagerPath is not None: - txt = myutils.get_open_filemaneger_os_string() + txt = utils.get_open_filemaneger_os_string() showInFileManagerButton = widgets.showInFileManagerButton(txt) bottomLayout.addStretch(1) @@ -2919,7 +2919,7 @@ def showInFileManager(self): showPath = path else: showPath = self.showInFileManagerPath - myutils.showInExplorer(showPath) + utils.showInExplorer(showPath) def toggleMultiSelection(self, checked): if checked: @@ -3095,7 +3095,7 @@ def listDoubleClicked(self, item): self.ok_cb() def showInFileManager(self, checked=True): - myutils.showInExplorer(self.parent_path) + utils.showInExplorer(self.parent_path) def newFile_cb(self): win = filenameDialog( diff --git a/cellacdc/dialogs/measurements.py b/cellacdc/dialogs/measurements.py index 43be79ab6..9a82a63e1 100644 --- a/cellacdc/dialogs/measurements.py +++ b/cellacdc/dialogs/measurements.py @@ -134,7 +134,7 @@ from .. import printl from .. import colors from .. import issues_url -from .. import myutils +from .. import utils from .. import qutils from .. import _palettes from .. import base_cca_dict @@ -1385,7 +1385,7 @@ def __init__(self, errorsDict, log_path="", parent=None, log_type="custom_metric buttonsLayout.addSpacing(20) buttonsLayout.addWidget(okButton) - showLogButton.clicked.connect(partial(myutils.showInExplorer, log_path)) + showLogButton.clicked.connect(partial(utils.showInExplorer, log_path)) okButton.clicked.connect(self.close) layout.setVerticalSpacing(10) layout.addLayout(buttonsLayout, 2, 1) @@ -2280,7 +2280,7 @@ def setLogger(self, logger, logs_path, log_path): self.log_path = log_path def loadEquationsButtonClicked(self): - MostRecentPath = myutils.getMostRecentPath() + MostRecentPath = utils.getMostRecentPath() file_path = QFileDialog.getOpenFileName( self, "Select equations file", diff --git a/cellacdc/dialogs/metadata.py b/cellacdc/dialogs/metadata.py index d59a5e4b5..cdc7ec627 100644 --- a/cellacdc/dialogs/metadata.py +++ b/cellacdc/dialogs/metadata.py @@ -134,7 +134,7 @@ from .. import printl from .. import colors from .. import issues_url -from .. import myutils +from .. import utils from .. import qutils from .. import _palettes from .. import base_cca_dict @@ -1081,7 +1081,7 @@ def showChannelData(self, checked=False, idx=None): idx = self.showChannelDataButtons.index(self.sender()) dimsOrder = "ctz" imgData = self.sampleImgData[dimsOrder][idx] - posData = myutils.utilClass() + posData = utils.utilClass() posData.frame_i = 0 sampleSizeT = 4 if self.SizeT_SB.value() >= 4 else self.SizeT_SB.value() posData.SizeT = sampleSizeT @@ -1465,10 +1465,10 @@ def __init__(self, fileName, folderPath, readPatternFunc=None, parent=None): buttonsLayout = widgets.CancelOkButtonsLayout() showInFileManagerButton = widgets.showInFileManagerButton( - myutils.get_open_filemaneger_os_string() + utils.get_open_filemaneger_os_string() ) buttonsLayout.insertWidget(3, showInFileManagerButton) - func = partial(myutils.showInExplorer, folderPath) + func = partial(utils.showInExplorer, folderPath) showInFileManagerButton.clicked.connect(func) buttonsLayout.okButton.clicked.connect(self.ok_cb) buttonsLayout.cancelButton.clicked.connect(self.close) @@ -2240,7 +2240,7 @@ def ok_cb(self, checked=False): if self.posData is not None and self.sender() != self.okButton: exp_path = self.posData.exp_path - pos_foldernames = myutils.get_pos_foldernames(exp_path) + pos_foldernames = utils.get_pos_foldernames(exp_path) if self.sender() == self.selectButton: select_folder = load.select_exp_folder() select_folder.pos_foldernames = pos_foldernames @@ -2250,7 +2250,7 @@ def ok_cb(self, checked=False): pos_foldernames = select_folder.selected_pos for pos in pos_foldernames: images_path = os.path.join(exp_path, pos, "Images") - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) search = [file for file in ls if file.find("metadata.csv") != -1] metadata_df = None if search: @@ -3239,7 +3239,7 @@ def __init__( delButton = widgets.delPushButton("Remove selected path(s)") browseButton = widgets.browseFileButton( - "Add folder...", openFolder=True, start_dir=myutils.getMostRecentPath() + "Add folder...", openFolder=True, start_dir=utils.getMostRecentPath() ) buttonsLayout.insertWidget(3, delButton) @@ -3285,11 +3285,11 @@ def pathsList(self): def expFolderToPosFoldernamesMapper(self): expPathsPosFoldernamesMapper = defaultdict(set) for selectedPath in self.pathsList(): - pos_foldernames = myutils.get_pos_foldernames( + pos_foldernames = utils.get_pos_foldernames( selectedPath, check_if_is_sub_folder=True ) if not pos_foldernames: - images_path = myutils.get_images_folderpath(selectedPath) + images_path = utils.get_images_folderpath(selectedPath) expPathsPosFoldernamesMapper[selectedPath].add("") else: expPath = load.get_exp_path(selectedPath) @@ -3377,9 +3377,9 @@ def parse_select_from_exp_paths(self, exp_paths: dict[os.PathLike, Iterable[str] return paths def addFolderPath(self, selected_path): - myutils.addToRecentPaths(selected_path) + utils.addToRecentPaths(selected_path) - folder_type = myutils.determine_folder_type(selected_path) + folder_type = utils.determine_folder_type(selected_path) is_pos_folder, is_images_folder, folder_path = folder_type if is_pos_folder: paths = [selected_path] diff --git a/cellacdc/dialogs/models.py b/cellacdc/dialogs/models.py index 85aabec8c..c7579e463 100644 --- a/cellacdc/dialogs/models.py +++ b/cellacdc/dialogs/models.py @@ -134,7 +134,7 @@ from .. import printl from .. import colors from .. import issues_url -from .. import myutils +from .. import utils from .. import qutils from .. import _palettes from .. import base_cca_dict @@ -188,7 +188,7 @@ def addCustomModelMessages(QParent=None): if msg.cancel: return if msg.clickedButton == infoButton: - txt = myutils.get_add_custom_model_instructions() + txt = utils.get_add_custom_model_instructions() msg = widgets.myMessageBox(showCentered=False, wrapText=False) msg.information( QParent, @@ -234,7 +234,7 @@ def addCustomPromptModelMessages(QParent=None): if msg.cancel: return if msg.clickedButton == infoButton: - txt = myutils.get_add_custom_prompt_model_instructions() + txt = utils.get_add_custom_prompt_model_instructions() msg = widgets.myMessageBox(showCentered=False, wrapText=False) msg.information( QParent, @@ -271,7 +271,7 @@ def __init__(self, parent=None): mainLayout.addWidget(label, alignment=Qt.AlignCenter) listBox = widgets.listWidget() - models = myutils.get_list_of_promptable_models() + models = utils.get_list_of_promptable_models() listBox.addItems(models) listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) listBox.setCurrentRow(0) @@ -315,7 +315,7 @@ def __init__(self, parent=None, addSkipSegmButton=False, customFirst=""): topLayout.addWidget(label, alignment=Qt.AlignCenter) listBox = widgets.listWidget() - models = myutils.get_list_of_models() + models = utils.get_list_of_models() if customFirst: try: @@ -381,7 +381,7 @@ def ok_cb(self, event): modelFilePath = addCustomModelMessages(self) if modelFilePath is None: return - myutils.store_custom_model_path(modelFilePath) + utils.store_custom_model_path(modelFilePath) modelName = os.path.basename(os.path.dirname(modelFilePath)) item = QListWidgetItem(modelName) self.listBox.addItem(item) @@ -869,7 +869,7 @@ def loadEntireRecipe(self): "Select INI file...", title="Select INI file to load entire recipe", openFolder=False, - start_dir=myutils.getMostRecentPath(), + start_dir=utils.getMostRecentPath(), ext={"INI": ".ini"}, ) win = QTreeDialog( @@ -1861,13 +1861,13 @@ def __init__(self, model_name, parent=None): self._parent = parent def download(self): - model_url = myutils._model_url(self.model_name) + model_url = utils._model_url(self.model_name) if model_url is None: return - _, model_path = myutils.get_model_path(self.model_name, create_temp_dir=False) + _, model_path = utils.get_model_path(self.model_name, create_temp_dir=False) model_name = self.model_name - model_exists = myutils.check_model_exists(model_path, model_name) + model_exists = utils.check_model_exists(model_path, model_name) if not model_exists: self.warnDownloadModel(model_path, self.model_name) try: @@ -1877,7 +1877,7 @@ def download(self): except Exception as err: pass - success = myutils.download_model(self.model_name) + success = utils.download_model(self.model_name) if not success: self.criticalDowloadFailed() @@ -1898,10 +1898,10 @@ def criticalDowloadFailed(self): model_name = self.model_name m = model_name.lower() weights_filenames = getattr(cellacdc, f"{m}_weights_filenames") - url, alternative_url = myutils._model_url(model_name, return_alternative=True) + url, alternative_url = utils._model_url(model_name, return_alternative=True) url_href = f'this link' alternative_url_href = f'this link' - _, model_path = myutils.get_model_path(model_name, create_temp_dir=False) + _, model_path = utils.get_model_path(model_name, create_temp_dir=False) txt = html_utils.paragraph(f""" Automatic download of {model_name} failed.

    Please, manually download the model weights from {url_href} or @@ -1972,7 +1972,7 @@ def __init__(self, posData, parent=None): recovery_folderpath = posData.recoveryFolderpath() unsaved_recovery_folderpath = os.path.join(recovery_folderpath, "never_saved") self.neverSavedFolderpath = unsaved_recovery_folderpath - files = myutils.listdir(unsaved_recovery_folderpath) + files = utils.listdir(unsaved_recovery_folderpath) csv_files = [file for file in files if file.endswith(".csv")] self.neverSavedListBox = None if csv_files: @@ -2207,7 +2207,7 @@ def __init__(self, parent=None, caller_name="Cell-ACDC"): preferencesLayout.addWidget(self.cmptPlatformCombobox, row, 1) row += 1 - pip_prefix, conda_prefix = myutils.get_pip_conda_prefix() + pip_prefix, conda_prefix = utils.get_pip_conda_prefix() self.commandWidget = widgets.CopiableCommandWidget( command=f"{pip_prefix} torch" ) @@ -2240,7 +2240,7 @@ def updateCommand(self, *args, **kwargs): osText = self.osCombobox.currentText() pkgManager = self.pkgManagerCombobox.currentText() cmptPlatform = self.cmptPlatformCombobox.currentText() - command = myutils.get_pytorch_command()[osText][pkgManager][cmptPlatform] + command = utils.get_pytorch_command()[osText][pkgManager][cmptPlatform] self.commandWidget.setCommand(command) def ok_cb(self): diff --git a/cellacdc/dialogs/preprocess.py b/cellacdc/dialogs/preprocess.py index 69c998a4a..742a0ebfd 100644 --- a/cellacdc/dialogs/preprocess.py +++ b/cellacdc/dialogs/preprocess.py @@ -134,7 +134,7 @@ from .. import printl from .. import colors from .. import issues_url -from .. import myutils +from .. import utils from .. import qutils from .. import _palettes from .. import base_cca_dict @@ -1886,7 +1886,7 @@ def warnNoAvailableRecipesToLoad(self): def selectRecipeFilepath(self, recipes_path, recipe_prefix, ext_label, ext): availableRecipes = [] if os.path.exists(recipes_path): - for file in myutils.listdir(recipes_path): + for file in utils.listdir(recipes_path): if not file.startswith(recipe_prefix): continue endname = file.split(f"{recipe_prefix}_")[1] @@ -1906,7 +1906,7 @@ def selectRecipeFilepath(self, recipes_path, recipe_prefix, ext_label, ext): f"Select {ext_label} file...", title=f"Select {ext_label} file to load recipe", openFolder=False, - start_dir=myutils.getMostRecentPath(), + start_dir=utils.getMostRecentPath(), ext={ext_label: f".{ext}"}, ) selectRecipeWin = widgets.QDialogListbox( @@ -2726,7 +2726,7 @@ def validate(self): self.warnSelectedPathNotAFolder(_path) return False - files = myutils.listdir(path) + files = utils.listdir(path) extensions = set([os.path.splitext(file)[1] for file in files]) if len(extensions) > 1: self.warnMultipleExtensionsPresent(path, extensions) @@ -2927,7 +2927,7 @@ def __init__(self, input_path="", parent=None): browseFolder=True, fileManagerTitle="Select folder where to save resized data", elide=True, - startFolder=myutils.getMostRecentPath(), + startFolder=utils.getMostRecentPath(), ) self.folderPathOutControl.setDisabled(True) paramsLayout.addWidget(self.folderPathOutControl, row, 1, 1, 2) @@ -3045,7 +3045,7 @@ def __init__( from cellacdc.preprocess import fucci_filter - params_argspecs = myutils.get_function_argspec(fucci_filter) + params_argspecs = utils.get_function_argspec(fucci_filter) super().__init__( params_argspecs, @@ -3616,7 +3616,7 @@ def saveRecipe(self, dummy=None, filepath=None): filepath_provided = filepath is not None if not filepath_provided: - folder_content = myutils.listdir(combine_channels_recipes_path) + folder_content = utils.listdir(combine_channels_recipes_path) num_recipes = len(folder_content) default_text = f"{num_recipes + 1}" proceed, filepath = self.saveRecipeUI( diff --git a/cellacdc/dialogs/tracking.py b/cellacdc/dialogs/tracking.py index badd57f94..b408ed538 100644 --- a/cellacdc/dialogs/tracking.py +++ b/cellacdc/dialogs/tracking.py @@ -134,7 +134,7 @@ from .. import printl from .. import colors from .. import issues_url -from .. import myutils +from .. import utils from .. import qutils from .. import _palettes from .. import base_cca_dict @@ -1628,7 +1628,7 @@ def applyToFutureFrames(self): stop_frame_i = win.value changes = self.getChanges() - changes_format = myutils.format_cca_manual_changes(changes) + changes_format = utils.format_cca_manual_changes(changes) detailsText = ( f"Changes that will be applied from frame n. {self.current_frame_i + 1}" f" to frame n. {stop_frame_i + 1}:\n\n{changes_format}" @@ -1647,7 +1647,7 @@ def applyToFutureFrames(self): self.sigApplyChangesFutureFrames.emit(changes, stop_frame_i) def moreInfo(self, checked=True): - desc = myutils.get_cca_colname_desc() + desc = utils.get_cca_colname_desc() msg = widgets.myMessageBox(parent=self) msg.setWindowTitle("Cell cycle annotations info") msg.setWidth(400) diff --git a/cellacdc/exporters.py b/cellacdc/exporters.py index d4ff6b7fb..93c92ae5c 100644 --- a/cellacdc/exporters.py +++ b/cellacdc/exporters.py @@ -14,7 +14,7 @@ import pyqtgraph.exporters import pyqtgraph as pg -from . import transformation, printl, myutils +from . import transformation, printl, utils from . import is_mac, is_win from . import acdc_ffmpeg_path @@ -165,7 +165,7 @@ def avi_to_mp4(self): def avi_to_mp4(in_filepath_avi, out_filepath_mp4=None): - ffmep_exec_path = myutils.download_ffmpeg() + ffmep_exec_path = utils.download_ffmpeg() if out_filepath_mp4 is None: out_filepath_mp4 = in_filepath_avi.replace(".avi", ".mp4") diff --git a/cellacdc/fiji_macros/__init__.py b/cellacdc/fiji_macros/__init__.py index 30995937a..a7eb0c501 100644 --- a/cellacdc/fiji_macros/__init__.py +++ b/cellacdc/fiji_macros/__init__.py @@ -3,7 +3,7 @@ from typing import Iterable from uuid import uuid4 -from cellacdc import myutils +from cellacdc import utils from .. import acdc_fiji_path @@ -58,11 +58,11 @@ def init_macro( def command_run_macro(macro_filepath): - exec_path = myutils.get_fiji_exec_folderpath() + exec_path = utils.get_fiji_exec_folderpath() command = f"{exec_path} -macro {macro_filepath}" return command def run_macro(macro_command): - success = myutils.run_fiji_command(command=macro_command) + success = utils.run_fiji_command(command=macro_command) return success diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 2e95cecb9..91cca2d4f 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -5,8 +5,8 @@ from qtpy.QtCore import Qt, QTimer, Signal from qtpy.QtWidgets import QMainWindow, QButtonGroup, QWidget -from . import myutils, autopilot, favourite_func_metrics_csv_path, settings_folderpath -from .myutils import setupLogger +from . import utils, autopilot, favourite_func_metrics_csv_path, settings_folderpath +from .utils import setupLogger from .gui_decorators import get_data_exception_handler, resetViewRange custom_annot_path = os.path.join(settings_folderpath, "custom_annotations.json") @@ -104,7 +104,7 @@ def __init__( self.mainWin = mainWin self.app = app self.closeGUI = False - self._acdc_version = myutils.read_version() + self._acdc_version = utils.read_version() self.setAcceptDrops(True) self._appName = "Cell-ACDC" diff --git a/cellacdc/help/about.py b/cellacdc/help/about.py index 2b907d402..f9c7e3722 100755 --- a/cellacdc/help/about.py +++ b/cellacdc/help/about.py @@ -17,11 +17,11 @@ from qtpy.QtCore import Qt from qtpy import QtCore -from ..myutils import read_version, get_date_from_version -from ..myutils import get_pip_install_cellacdc_version_command -from ..myutils import get_git_pull_checkout_cellacdc_version_commands -from ..myutils import get_info_version_text -from .. import widgets, myutils +from ..utils import read_version, get_date_from_version +from ..utils import get_pip_install_cellacdc_version_command +from ..utils import get_git_pull_checkout_cellacdc_version_commands +from ..utils import get_info_version_text +from .. import widgets, utils from .. import html_utils, printl from .. import cellacdc_path @@ -136,9 +136,9 @@ def __init__(self, parent=None): self.showHowToInstallButton.clicked.connect(self.showHotToInstallInstructions) button = widgets.showInFileManagerButton( - myutils.get_open_filemaneger_os_string() + utils.get_open_filemaneger_os_string() ) - func = partial(myutils.showInExplorer, cellacdc_path) + func = partial(utils.showInExplorer, cellacdc_path) button.clicked.connect(func) installedLayout.addWidget(installedLabel) installedLayout.addWidget(self.copyCellACDCpathButton) diff --git a/cellacdc/help/welcome.py b/cellacdc/help/welcome.py index 6e18d5831..8b5deef68 100755 --- a/cellacdc/help/welcome.py +++ b/cellacdc/help/welcome.py @@ -33,7 +33,7 @@ script_path = os.path.dirname(os.path.realpath(__file__)) -from .. import gui, dataStruct, myutils, cite_url, html_utils, urls, widgets +from .. import gui, dataStruct, utils, cite_url, html_utils, urls, widgets from .. import _palettes # NOTE: Enable icons @@ -59,7 +59,7 @@ def __init__(self, which): self.which = which def run(self): - self.exp_path = myutils.download_examples(self.which, progress=self.progress) + self.exp_path = utils.download_examples(self.which, progress=self.progress) self.finished.emit() @@ -743,7 +743,7 @@ def addManualPage(self): openManualButton = widgets.showInFileManagerButton( " Download and open user manual... " ) - openManualButton.clicked.connect(myutils.browse_docs) + openManualButton.clicked.connect(utils.browse_docs) buttonLayout = QHBoxLayout() buttonLayout.addWidget(openManualButton) @@ -888,7 +888,7 @@ def addPbar(self): self.welcomeLayout.addWidget(self.QPbar, 3, 0, 1, 3) def testTimeLapseExample(self, checked=True): - _, example_path, _, _ = myutils.get_examples_path("time_lapse_2D") + _, example_path, _, _ = utils.get_examples_path("time_lapse_2D") txt = f"""


    Downloading example to {example_path}... @@ -955,7 +955,7 @@ def openGUIexample(self): self.openGUIfolder(self.worker.exp_path) def test3DzStacksExample(self, checked=True): - _, example_path, _, _ = myutils.get_examples_path("snapshots_3D") + _, example_path, _, _ = utils.get_examples_path("snapshots_3D") txt = f"""


    Downloading example to {example_path}... diff --git a/cellacdc/html_utils.py b/cellacdc/html_utils.py index dfc6e6cd5..84003a729 100755 --- a/cellacdc/html_utils.py +++ b/cellacdc/html_utils.py @@ -4,7 +4,7 @@ import sys import textwrap -from . import GUI_INSTALLED, myutils +from . import GUI_INSTALLED, utils from ._palettes import ( _get_highligth_header_background_rgba, diff --git a/cellacdc/io.py b/cellacdc/io.py index 4f85bc2f0..ad7d1b33d 100644 --- a/cellacdc/io.py +++ b/cellacdc/io.py @@ -14,7 +14,7 @@ import numpy as np import skimage.io -from . import path, load, myutils, printl +from . import path, load, utils, printl from . import moth_bud_tot_selected_columns_filepath from . import saved_measurements_selections_folderpath from . import config @@ -171,7 +171,7 @@ def save_image_data(filepath, img_data): elif filepath.endswith(".npy"): np.save(filepath, img_data) else: - myutils.to_tiff(filepath, img_data) + utils.to_tiff(filepath, img_data) return np.squeeze(img_data) @@ -215,7 +215,7 @@ def move_separate_channels_tiffs_to_pos_folders( extension=".tif", ): basenames = set() - for file in myutils.listdir(tiffs_folderpath): + for file in utils.listdir(tiffs_folderpath): if not file.endswith(extension): continue @@ -237,7 +237,7 @@ def move_separate_channels_tiffs_to_pos_folders( images_path = os.path.join(pos_folderpath, "Images") os.makedirs(images_path, exist_ok=True) - for file in myutils.listdir(tiffs_folderpath): + for file in utils.listdir(tiffs_folderpath): if not file.startswith(basename): continue diff --git a/cellacdc/load.py b/cellacdc/load.py index e21a9451a..392de1b03 100755 --- a/cellacdc/load.py +++ b/cellacdc/load.py @@ -30,7 +30,7 @@ warnings.simplefilter(action="ignore", category=FutureWarning) from . import prompts -from . import myutils, measurements, config +from . import utils, measurements, config from . import base_cca_dict, base_acdc_df, html_utils, settings_folderpath from . import cca_df_colnames, printl from . import ignore_exception, cellacdc_path @@ -158,7 +158,7 @@ def to_csv_through_temp(df, csv_path): def get_all_acdc_folders(user_profile_path): - models = myutils.get_list_of_models() + models = utils.get_list_of_models() acdc_folders = [f"acdc-{model}" for model in models] acdc_folders.append("acdc-java") acdc_folders.append(".acdc-logs") @@ -204,7 +204,7 @@ def write_last_selected_set_measurements(last_selected_meas: dict[str, dict]): def migrate_models_paths(dst_path): - models = myutils.get_list_of_models() + models = utils.get_list_of_models() user_profile_path = dst_path.replace("\\", "/") for model in models: model_path = os.path.join(models_path, model, "model") @@ -311,10 +311,10 @@ def read_segm_workflow_from_config(filepath) -> dict: def get_images_paths(folder_path): - folder_type = myutils.determine_folder_type(folder_path) + folder_type = utils.determine_folder_type(folder_path) is_pos_folder, is_images_folder, folder_path = folder_type if not is_pos_folder and not is_images_folder: - pos_foldernames = myutils.get_pos_foldernames(folder_path) + pos_foldernames = utils.get_pos_foldernames(folder_path) images_paths = [ os.path.join(folder_path, pos, "Images") for pos in pos_foldernames ] @@ -402,7 +402,7 @@ def load_segm_file(images_path, end_name_segm_file="segm", return_path=False): found_files = [ file - for file in myutils.listdir(images_path) + for file in utils.listdir(images_path) if file.endswith(end_name_segm_file) ] try: @@ -449,7 +449,7 @@ def get_tzyx_shape(images_path): def load_metadata_df(images_path): - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): if not file.endswith("metadata.csv"): continue filepath = os.path.join(images_path, file) @@ -663,7 +663,7 @@ def load_acdc_df_file( found_files = [ file - for file in myutils.listdir(images_path) + for file in utils.listdir(images_path) if file.endswith(end_name_acdc_df_file) ] if len(found_files) == 0: @@ -784,7 +784,7 @@ def store_unsaved_acdc_df(recovery_folderpath, df, log_func=printl): if not os.path.exists(unsaved_recovery_folderpath): os.mkdir(unsaved_recovery_folderpath) - files = myutils.listdir(unsaved_recovery_folderpath) + files = utils.listdir(unsaved_recovery_folderpath) csv_files = [file for file in files if file.endswith(".csv")] if len(files) > 20: csv_files = natsorted(csv_files) @@ -804,7 +804,7 @@ def get_last_stored_unsaved_acdc_df_filepath(recovery_folderpath): if not os.path.exists(unsaved_recovery_folderpath): return - files = myutils.listdir(unsaved_recovery_folderpath) + files = utils.listdir(unsaved_recovery_folderpath) csv_files = [file for file in files if file.endswith(".csv")] if not csv_files: return @@ -823,7 +823,7 @@ def get_last_stored_unsaved_acdc_df(recovery_folderpath): if not os.path.exists(unsaved_recovery_folderpath): return - files = myutils.listdir(unsaved_recovery_folderpath) + files = utils.listdir(unsaved_recovery_folderpath) csv_files = [file for file in files if file.endswith(".csv")] if not csv_files: return @@ -857,7 +857,7 @@ def get_user_ch_paths(images_paths, user_ch_name): user_ch_file_paths = [] for images_path in images_paths: img_aligned_found = False - for filename in myutils.listdir(images_path): + for filename in utils.listdir(images_path): if filename.find(f"{user_ch_name}_aligned.np") != -1: img_path_aligned = f"{images_path}/{filename}" img_aligned_found = True @@ -874,7 +874,7 @@ def get_user_ch_paths(images_paths, user_ch_name): def get_acdc_output_files(images_path): - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) acdc_output_files = [ file for file in ls if file.find("acdc_output") != -1 and file.endswith(".csv") @@ -883,7 +883,7 @@ def get_acdc_output_files(images_path): def get_segm_files(images_path): - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) segm_files = [ file @@ -899,16 +899,16 @@ def get_segm_files(images_path): def get_segm_endnames_from_exp_path(exp_path, pos_foldernames=None): if pos_foldernames is None: - pos_foldernames = myutils.get_pos_foldernames(exp_path) + pos_foldernames = utils.get_pos_foldernames(exp_path) existingEndNames = set() for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames(images_path) + basename, chNames = utils.getBasenameAndChNames(images_path) # Use first found channel, it doesn't matter for metrics for chName in chNames: - filePath = myutils.getChannelFilePath(images_path, chName) + filePath = utils.getChannelFilePath(images_path, chName) if filePath: break else: @@ -926,7 +926,7 @@ def get_segm_endnames_from_exp_path(exp_path, pos_foldernames=None): def get_files_with(images_path: os.PathLike, with_text: str, ext: str = None): - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) found_files = [] for file in ls: if file.find(with_text) == -1: @@ -942,7 +942,7 @@ def get_files_with(images_path: os.PathLike, with_text: str, ext: str = None): def load_segmInfo_df(pos_path): images_path = os.path.join(pos_path, "Images") - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): if file.endswith("segmInfo.csv"): csv_path = os.path.join(images_path, file) df = pd.read_csv(csv_path) @@ -971,7 +971,7 @@ def get_filename_from_channel( h5_path = "" npz_aligned_path = "" tif_path = "" - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): isValidEnd = True for not_allowed_end in not_allowed_ends: if file.endswith(not_allowed_end): @@ -1069,11 +1069,11 @@ def get_filepath_from_endname(images_path, endname): if channel_filepath: return channel_filepath - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): if file.endswith(endname): return os.path.join(images_path, file) - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): file_noext, ext = os.path.splitext(file) if file_noext.endswith(endname): return os.path.join(images_path, file) @@ -1082,7 +1082,7 @@ def get_filepath_from_endname(images_path, endname): def get_exp_path(path): - folder_type = myutils.determine_folder_type(path) + folder_type = utils.determine_folder_type(path) is_pos_folder, is_images_folder, _ = folder_type if is_pos_folder: exp_path = os.path.dirname(path) @@ -1112,7 +1112,7 @@ def get_endname_from_filepath(filepath, allow_empty=False): filename = os.path.basename(filepath) filename_noext, ext = os.path.splitext(filename) - images_files = myutils.listdir(parent_folderpath) + images_files = utils.listdir(parent_folderpath) basename = os.path.commonprefix(images_files) endname = filename_noext[len(basename) :] if not endname: @@ -1127,21 +1127,21 @@ def get_endnames_from_basename(basename, filenames): def get_path_from_endname(end_name, images_path, ext=None): if ext is None: - end_name, ext = myutils.remove_known_extension(end_name) + end_name, ext = utils.remove_known_extension(end_name) if os.path.exists(os.path.join(images_path, f"{end_name}{ext}")): return os.path.join(images_path, f"{end_name}{ext}") - basename = os.path.commonprefix(myutils.listdir(images_path)) + basename = os.path.commonprefix(utils.listdir(images_path)) searched_file = f"{basename}{end_name}{ext}" - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): filename, ext = os.path.splitext(file) if file == searched_file: return os.path.join(images_path, file), file elif filename == searched_file: return os.path.join(images_path, file), file - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): filename, ext = os.path.splitext(file) if file.endswith(end_name): return os.path.join(images_path, file), file @@ -1267,7 +1267,7 @@ def parse_metadata_csv_file(csv_filepath): def get_posData_metadata(images_path, basename): # First check if metadata.csv already has the channel names - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): if file.endswith("metadata.csv"): metadata_csv_path = os.path.join(images_path, file) parse_metadata_csv_file(metadata_csv_path) @@ -1285,7 +1285,7 @@ def get_posData_metadata(images_path, basename): def is_pos_prepped(images_path): - filenames = myutils.listdir(images_path) + filenames = utils.listdir(images_path) for filename in filenames: if filename.endswith("dataPrepROIs_coords.csv"): return True @@ -1301,7 +1301,7 @@ def is_pos_prepped(images_path): def is_bkgrROIs_present(images_path): - filenames = myutils.listdir(images_path) + filenames = utils.listdir(images_path) for filename in filenames: if filename.endswith("dataPrep_bkgrROIs.json"): return True @@ -1346,7 +1346,7 @@ def __init__( self.attempFixBasenameBug() self.non_aligned_ext = ".tif" if filename_ext.endswith("aligned.npz"): - for file in myutils.listdir(self.images_path): + for file in utils.listdir(self.images_path): if file.endswith(f"{user_ch_name}.h5"): self.non_aligned_ext = ".h5" break @@ -1363,7 +1363,7 @@ def attempFixBasenameBug(self): """ try: - ls = myutils.listdir(self.images_path) + ls = utils.listdir(self.images_path) for file in ls: if file.endswith("metadata.csv"): metadata_csv_path = os.path.join(self.images_path, file) @@ -1378,7 +1378,7 @@ def attempFixBasenameBug(self): except Exception as e: return - numPos = len(myutils.get_pos_foldernames(self.exp_path)) + numPos = len(utils.get_pos_foldernames(self.exp_path)) numPosDigits = len(str(numPos)) s0p = str(self.pos_num + 1).zfill(numPosDigits) @@ -1506,7 +1506,7 @@ def addYXcentroidColsIfMissing(self, show_progress=False): self.acdc_df.to_csv(self.acdc_output_csv_path) def getBasenameAndChNames(self, useExt=None, qparent=None): - ls = myutils.listdir(self.images_path) + ls = utils.listdir(self.images_path) selector = prompts.select_channel_name() self.chNames, _ = selector.get_available_channels( ls, self.images_path, useExt=useExt @@ -1636,7 +1636,7 @@ def init_segmInfo_df(self): ) if NO_segmInfo and self.SizeZ > 1: filename = self.filename - df = myutils.getDefault_SegmInfo_df(self, filename) + df = utils.getDefault_SegmInfo_df(self, filename) if self.segmInfo_df is None: self.segmInfo_df = df else: @@ -1912,7 +1912,7 @@ def loadOtherFiles( self.dataPrepFreeRoiPoints = [] self.labelBoolSegm = labelBoolSegm self.bkgrDataExists = False - ls = myutils.listdir(self.images_path) + ls = utils.listdir(self.images_path) if end_filename_segm: end_filename_segm = end_filename_segm.replace(".npz", "") @@ -2239,7 +2239,7 @@ def clearSegmObjsDataPrepFreeRoi(self, segm_data, is_timelapse=True): def getSpotmaxSingleSpotsfiles(self): from spotmax import DFs_FILENAMES - spotmax_files = myutils.listdir(self.spotmax_out_path) + spotmax_files = utils.listdir(self.spotmax_out_path) patterns = [ filename.replace("*rn*", "").replace("*desc*", "") for filename in DFs_FILENAMES.values() @@ -2980,7 +2980,7 @@ def isRecoveredAcdcDfPresent(self): if not os.path.exists(unsaved_recovery_folderpath): return - files = myutils.listdir(unsaved_recovery_folderpath) + files = utils.listdir(unsaved_recovery_folderpath) csv_files = [file for file in files if file.endswith(".csv")] if not csv_files: return @@ -3100,7 +3100,7 @@ def loadAllImgPaths(self): npy_paths = [] npz_paths = [] basename = self.basename[0:-1] - for filename in myutils.listdir(self.images_path): + for filename in utils.listdir(self.images_path): file_path = os.path.join(self.images_path, filename) f, ext = os.path.splitext(filename) m = re.match(rf"{basename}.*\.tif", filename) @@ -3111,7 +3111,7 @@ def loadAllImgPaths(self): npz = f"{f}_aligned.npz" npy_found = False npz_found = False - for name in myutils.listdir(self.images_path): + for name in utils.listdir(self.images_path): _path = os.path.join(self.images_path, name) if name == npy: npy_paths.append(_path) @@ -3538,14 +3538,14 @@ def append_last_cca_frame(self, acdc_df, text): def get_values_segmGUI(self, exp_path): self.exp_path = exp_path - pos_foldernames = myutils.get_pos_foldernames(exp_path) + pos_foldernames = utils.get_pos_foldernames(exp_path) self.pos_foldernames = pos_foldernames values = [] for pos in pos_foldernames: last_tracked_i_found = False pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") - filenames = myutils.listdir(images_path) + filenames = utils.listdir(images_path) for filename in filenames: if filename.find("acdc_output.csv") != -1: last_tracked_i_found = True @@ -3565,7 +3565,7 @@ def get_values_segmGUI(self, exp_path): def get_values_dataprep(self, exp_path): self.exp_path = exp_path - pos_foldernames = myutils.get_pos_foldernames(exp_path) + pos_foldernames = utils.get_pos_foldernames(exp_path) self.pos_foldernames = pos_foldernames values = [] for pos in pos_foldernames: @@ -3576,7 +3576,7 @@ def get_values_dataprep(self, exp_path): are_zslices_selected = False pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") - filenames = myutils.listdir(images_path) + filenames = utils.listdir(images_path) for filename in filenames: if filename.endswith("dataPrepROIs_coords.csv"): is_roi_info_present = True @@ -3619,7 +3619,7 @@ def get_values_dataprep(self, exp_path): def get_values_cca(self, exp_path): self.exp_path = exp_path - pos_foldernames = natsorted(myutils.listdir(exp_path)) + pos_foldernames = natsorted(utils.listdir(exp_path)) pos_foldernames = [ pos for pos in pos_foldernames if re.match(r"^Position_(\d+)", pos) ] @@ -3630,7 +3630,7 @@ def get_values_cca(self, exp_path): pos_path = os.path.join(exp_path, pos) if os.path.isdir(pos_path): images_path = f"{exp_path}/{pos}/Images" - filenames = myutils.listdir(images_path) + filenames = utils.listdir(images_path) for filename in filenames: if filename.find("cc_stage.csv") != -1: cc_stage_found = True @@ -3671,14 +3671,14 @@ def load_shifts(parent_path, basename=None): shifts_found = False shifts = None if basename is None: - for filename in myutils.listdir(parent_path): + for filename in utils.listdir(parent_path): if filename.find("align_shift.npy") > 0: shifts_found = True shifts_path = os.path.join(parent_path, filename) shifts = np.load(shifts_path) else: align_shift_fn = f"{basename}_align_shift.npy" - if align_shift_fn in myutils.listdir(parent_path): + if align_shift_fn in utils.listdir(parent_path): shifts_found = True shifts_path = os.path.join(parent_path, align_shift_fn) shifts = np.load(shifts_path) @@ -4170,7 +4170,7 @@ def search_filepath_in_pos_path_from_endname( if endname == sm_file: return os.path.join(spotmax_out_path, sm_file) - images_files = myutils.listdir(images_path) + images_files = utils.listdir(images_path) sample_filepath = os.path.join(images_path, images_files[0]) posData = loadData(sample_filepath, "") posData.getBasenameAndChNames() @@ -4181,7 +4181,7 @@ def search_filepath_in_pos_path_from_endname( def search_filepath_from_endname(exp_path, endname, include_spotmax_out=False): - pos_foldernames = myutils.get_pos_foldernames(exp_path) + pos_foldernames = utils.get_pos_foldernames(exp_path) for pos in pos_foldernames: pos_path = os.path.join(exp_path, pos) filepath = search_filepath_in_pos_path_from_endname( @@ -4192,7 +4192,7 @@ def search_filepath_from_endname(exp_path, endname, include_spotmax_out=False): def askOpenCsvFile(title="Open CSV file", start_dir=None, qparent=None): if start_dir is None: - start_dir = myutils.getMostRecentPath() + start_dir = utils.getMostRecentPath() file_types = f"CSV files (*.csv);;All Files (*)" diff --git a/cellacdc/mixins/actions.py b/cellacdc/mixins/actions.py index e0716fa29..1937f5e97 100644 --- a/cellacdc/mixins/actions.py +++ b/cellacdc/mixins/actions.py @@ -103,7 +103,7 @@ def gui_connectActions(self): # Connect Help actions self.tipsAction.triggered.connect(self.showTipsAndTricks) - self.UserManualAction.triggered.connect(myutils.browse_docs) + self.UserManualAction.triggered.connect(utils.browse_docs) self.openLogFileAction.triggered.connect(self.openLogFile) self.showLogFilesAction.triggered.connect(self.showLogFiles) self.aboutAction.triggered.connect(self.showAbout) @@ -469,7 +469,7 @@ def gui_createActions(self): self.skipToNewIdAction.setDisabled(True) # Edit actions - models = myutils.get_list_of_models() + models = utils.get_list_of_models() models = [*models, "local_seg"] # Add local_seg for SegForLostIDsAction self.segmActions = [] self.modelNames = [] @@ -539,14 +539,14 @@ def gui_createActions(self): self.trackWithYeazAction.setCheckable(True) self.trackingAlgosGroup.addAction(self.trackWithYeazAction) - rt_trackers = myutils.get_list_of_real_time_trackers() + rt_trackers = utils.get_list_of_real_time_trackers() for rt_tracker in rt_trackers: rtTrackerAction = QAction(rt_tracker, self) rtTrackerAction.setCheckable(True) self.trackingAlgosGroup.addAction(rtTrackerAction) self.trackWithAcdcAction.setChecked(True) - aliases = myutils.aliases_real_time_trackers() + aliases = utils.aliases_real_time_trackers() if "tracking_algorithm" in self.df_settings.index: trackingAlgo = self.df_settings.at["tracking_algorithm", "value"] diff --git a/cellacdc/mixins/annotation_display.py b/cellacdc/mixins/annotation_display.py index 32a96a557..fefea09d1 100644 --- a/cellacdc/mixins/annotation_display.py +++ b/cellacdc/mixins/annotation_display.py @@ -324,7 +324,7 @@ def drawAllLineageTreeLines(self): continue for ID in new_cells: - curr_obj = myutils.get_obj_by_label(rp, ID) + curr_obj = utils.get_obj_by_label(rp, ID) lin_tree_df_ID = lin_tree_df.loc[ID] # lin_tree_df_mother_ID = lin_tree_df_prev.loc[lin_tree_df_ID["parent_ID_tree"]] @@ -333,7 +333,7 @@ def drawAllLineageTreeLines(self): ): # make sure that new obj where the parents are not known get skipped continue - mother_obj = myutils.get_obj_by_label( + mother_obj = utils.get_obj_by_label( prev_rp, lin_tree_df_ID["parent_ID_tree"] ) @@ -713,7 +713,7 @@ def rtTrackerActionToggled(self, checked): if not checked: return - aliases = myutils.aliases_real_time_trackers(reverse=True) + aliases = utils.aliases_real_time_trackers(reverse=True) if self.sender().text() in aliases: trackingAlgo = aliases[self.sender().text()] else: diff --git a/cellacdc/mixins/app_shell.py b/cellacdc/mixins/app_shell.py index 7850c6442..73aeb6a10 100644 --- a/cellacdc/mixins/app_shell.py +++ b/cellacdc/mixins/app_shell.py @@ -221,7 +221,7 @@ def onToggleColorScheme(self): def openLogFile(self): self.logger.info(f'Opening log file "{self.log_path}"...') - myutils.showInExplorer(self.log_path) + utils.showInExplorer(self.log_path) def openNewWindow(self): self.logger.info("Opening a new window...") @@ -301,12 +301,12 @@ def showAbout(self): def showInExplorer_cb(self): posData = self.data[self.pos_i] path = posData.images_path - myutils.showInExplorer(path) + utils.showInExplorer(path) def showLogFiles(self): log_files_path = os.path.dirname(self.log_path) self.logger.info(f'Opening log files folder "{log_files_path}"...') - myutils.showInExplorer(log_files_path) + utils.showInExplorer(log_files_path) def showTipsAndTricks(self): from cellacdc.help import welcome diff --git a/cellacdc/mixins/canvas_drawing.py b/cellacdc/mixins/canvas_drawing.py index b3558e574..748ae3a0a 100644 --- a/cellacdc/mixins/canvas_drawing.py +++ b/cellacdc/mixins/canvas_drawing.py @@ -68,7 +68,7 @@ def gui_mouseDragEventImg1(self, event): posData = self.data[self.pos_i] Y, X = self.get_2Dlab(posData.lab).shape xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): + if not utils.is_in_bounds(xdata, ydata, X, Y): return if self.isRightClickDragImg1 and self.curvToolButton.isChecked(): @@ -235,7 +235,7 @@ def gui_mouseDragEventImg2(self, event): Y, X = self.get_2Dlab(posData.lab).shape x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): + if not utils.is_in_bounds(xdata, ydata, X, Y): return # Eraser dragging mouse --> keep erasing @@ -325,7 +325,7 @@ def gui_mouseReleaseEventImg1(self, event): Y, X = self.get_2Dlab(posData.lab).shape x, y = event.pos().x(), event.pos().y() xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): + if not utils.is_in_bounds(xdata, ydata, X, Y): self.isMouseDragImg2 = False self.updateAllImages() return diff --git a/cellacdc/mixins/canvas_selection.py b/cellacdc/mixins/canvas_selection.py index 63642c269..cafa84b15 100644 --- a/cellacdc/mixins/canvas_selection.py +++ b/cellacdc/mixins/canvas_selection.py @@ -705,7 +705,7 @@ def gui_mouseReleaseEventImg2(self, event): return xdata, ydata = int(x), int(y) - if not myutils.is_in_bounds(xdata, ydata, X, Y): + if not utils.is_in_bounds(xdata, ydata, X, Y): self.isMouseDragImg2 = False self.updateAllImages() return diff --git a/cellacdc/mixins/cell_cycle.py b/cellacdc/mixins/cell_cycle.py index dee2bbf63..148486d55 100644 --- a/cellacdc/mixins/cell_cycle.py +++ b/cellacdc/mixins/cell_cycle.py @@ -494,11 +494,11 @@ def autoAssignBud_YeastMate(self): # Check if model needs to be imported acdcSegment = self.acdcSegment_li[idx] if acdcSegment is None: - acdcSegment = myutils.import_segment_module(model_name) + acdcSegment = utils.import_segment_module(model_name) self.acdcSegment_li[idx] = acdcSegment # Read all models parameters - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) + init_params, segment_params = utils.getModelArgSpec(acdcSegment) # Prompt user to enter the model parameters try: url = acdcSegment.url_help() @@ -522,14 +522,14 @@ def autoAssignBud_YeastMate(self): return use_gpu = win.init_kwargs.get("gpu", False) - proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) + proceed = utils.check_gpu_available(model_name, use_gpu, qparent=self) if not proceed: self.logger.info("Segmentation process cancelled.") self.titleLabel.setText("Segmentation process cancelled.") return self.model_kwargs = win.model_kwargs - model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) + model = utils.init_segm_model(acdcSegment, posData, win.init_kwargs) if model is None: self.logger.info("Segmentation process cancelled.") self.titleLabel.setText("Segmentation process cancelled.") diff --git a/cellacdc/mixins/combine.py b/cellacdc/mixins/combine.py index 4448d4937..b3d8459d1 100644 --- a/cellacdc/mixins/combine.py +++ b/cellacdc/mixins/combine.py @@ -12,7 +12,7 @@ apps, core, html_utils, - myutils, + utils, preprocess, printl, widgets, @@ -557,7 +557,7 @@ def combineWorkerPreviewDone( def combineWorkerAskLoadChannels(self, requ_channels, pos_i): # spit channels and segm to load segms_to_load, channels_to_load, current_segm = ( - myutils.separate_fluo_segment_channels(requ_channels) + utils.separate_fluo_segment_channels(requ_channels) ) if pos_i is None: pos_i = list(range(len(self.data))) diff --git a/cellacdc/mixins/custom_annotations.py b/cellacdc/mixins/custom_annotations.py index ab0d209d8..652015042 100644 --- a/cellacdc/mixins/custom_annotations.py +++ b/cellacdc/mixins/custom_annotations.py @@ -208,7 +208,7 @@ def addCustomAnnotationSavedPos(self, pos_i=None): keySequence = widgets.KeySequenceFromText(keySequence) else: keySequence = None - toolTip = myutils.getCustomAnnotTooltip(annotState) + toolTip = utils.getCustomAnnotTooltip(annotState) keepActive = annotState.get("keepActive", True) isHideChecked = annotState.get("isHideChecked", True) diff --git a/cellacdc/mixins/data_loading.py b/cellacdc/mixins/data_loading.py index 89a1c62cc..09c9afe49 100644 --- a/cellacdc/mixins/data_loading.py +++ b/cellacdc/mixins/data_loading.py @@ -29,7 +29,7 @@ exception_handler, html_utils, load, - myutils, + utils, prompts, user_manual_url, widgets, @@ -204,7 +204,7 @@ def _openFile(self, file_path=None): if not is_imageJ_dtype: data.img_data = skimage.img_as_ubyte(data.img_data) - myutils.to_tiff(tif_path, data.img_data) + utils.to_tiff(tif_path, data.img_data) self._openFolder(exp_path=exp_path, imageFilePath=tif_path) def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): @@ -257,7 +257,7 @@ def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): self.addToRecentPaths(exp_path, logger=self.logger) self.addPathToOpenRecentMenu(exp_path) - folder_type = myutils.determine_folder_type(exp_path) + folder_type = utils.determine_folder_type(exp_path) is_pos_folder, is_images_folder, exp_path = folder_type self.titleLabel.setText("Loading data...", color=self.titleColor) @@ -285,7 +285,7 @@ def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): elif imageFilePath: # images_path = exp_path because called by openFile func - filenames = myutils.listdir(exp_path) + filenames = utils.listdir(exp_path) ch_names, basenameNotFound = ch_name_selector.get_available_channels( filenames, exp_path ) @@ -302,7 +302,7 @@ def _openFolder(self, checked=False, exp_path=None, imageFilePath=""): # Get info from first position selected images_path = self.images_paths[0] - filenames = myutils.listdir(images_path) + filenames = utils.listdir(images_path) if ch_name_selector.is_first_call and user_ch_name is None: ch_names, _ = ch_name_selector.get_available_channels( filenames, images_path @@ -368,7 +368,7 @@ def _workerDebug(self, stuff_to_debug): pass def addToRecentPaths(self, path, logger=None): - myutils.addToRecentPaths(path, logger=self.logger) + utils.addToRecentPaths(path, logger=self.logger) def askMismatchSegmDataShape(self, posData): msg = widgets.myMessageBox(wrapText=False) @@ -529,7 +529,7 @@ def checkMemoryRequirements(self, required_ram): def criticalFluoChannelNotFound(self, fluo_ch, posData): msg = widgets.myMessageBox(showCentered=False) - ls = "\n".join(myutils.listdir(posData.images_path)) + ls = "\n".join(utils.listdir(posData.images_path)) msg.setDetailedText(f"Files present in the {posData.relPath} folder:\n{ls}") title = "Requested channel data not found!" txt = html_utils.paragraph( @@ -588,7 +588,7 @@ def criticalInvalidPosFolder(self, exp_path): helpButton = widgets.helpPushButton("Help...") msg.addButton(helpButton) helpButton.clicked.disconnect() - helpButton.clicked.connect(partial(myutils.browse_url, data_structure_docs_url)) + helpButton.clicked.connect(partial(utils.browse_url, data_structure_docs_url)) msg.addShowInFileManagerButton(exp_path) msg.critical(self, "Incompatible folder", txt) @@ -608,7 +608,7 @@ def criticalNoTifFound(self, images_path): def getFileExtensions(self, images_path): alignedFound = any( - [f.find("_aligned.np") != -1 for f in myutils.listdir(images_path)] + [f.find("_aligned.np") != -1 for f in utils.listdir(images_path)] ) if alignedFound: extensions = ( @@ -619,10 +619,10 @@ def getFileExtensions(self, images_path): return extensions def getMostRecentPath(self): - return myutils.getMostRecentPath() + return utils.getMostRecentPath() def getPathFromChName(self, chName, posData): - ls = myutils.listdir(posData.images_path) + ls = utils.listdir(posData.images_path) endnames = {f[len(posData.basename) :]: f for f in ls} validEnds = ["_aligned.npz", "_aligned.h5", ".h5", ".tif", ".npz"] for end in validEnds: @@ -1398,9 +1398,9 @@ def stopAutomaticLoadingPos(self): self.AutoPilot = None def warnMemoryNotSufficient(self, total_ram, available_ram, required_ram): - total_ram = myutils._bytes_to_GB(total_ram) - available_ram = myutils._bytes_to_GB(available_ram) - required_ram = myutils._bytes_to_GB(required_ram) + total_ram = utils._bytes_to_GB(total_ram) + available_ram = utils._bytes_to_GB(available_ram) + required_ram = utils._bytes_to_GB(required_ram) required_perc = round(100 * required_ram / available_ram) msg = widgets.myMessageBox() txt = html_utils.paragraph(f""" @@ -1515,7 +1515,7 @@ def zSliceAbsent(self, filename, posData): if _posData is None: continue _, filename = self.getPathFromChName(user_ch_name, _posData) - df = myutils.getDefault_SegmInfo_df(_posData, filename) + df = utils.getDefault_SegmInfo_df(_posData, filename) _posData.segmInfo_df = pd.concat([df, _posData.segmInfo_df]) unique_idx = ~_posData.segmInfo_df.index.duplicated() _posData.segmInfo_df = _posData.segmInfo_df[unique_idx] @@ -1532,7 +1532,7 @@ def zSliceAbsent(self, filename, posData): self.worker.abort = True self.waitCond.wakeAll() return - dst_df = myutils.getDefault_SegmInfo_df(_posData, dstFilename) + dst_df = utils.getDefault_SegmInfo_df(_posData, dstFilename) for z_info in cellacdc_df.itertuples(): frame_i = z_info.Index zProjHow = z_info.which_z_proj diff --git a/cellacdc/mixins/frame_navigation.py b/cellacdc/mixins/frame_navigation.py index e64ec8943..3be3e2005 100644 --- a/cellacdc/mixins/frame_navigation.py +++ b/cellacdc/mixins/frame_navigation.py @@ -254,7 +254,7 @@ def extendSegmDataIfNeeded(self, stopFrameNum): return numFramesToAdd = stopFrameNum - segmSizeT posData.allData_li.extend( - [myutils.get_empty_stored_data_dict() for i in range(numFramesToAdd)] + [utils.get_empty_stored_data_dict() for i in range(numFramesToAdd)] ) lab_shape = posData.segm_data[0].shape shapeToAdd = (numFramesToAdd, *lab_shape) @@ -364,7 +364,7 @@ def manualAnnotRestoreLastTrackedFrame(self, last_tracked_i_to_restore): posData = self.data[self.pos_i] for frame_i in range(last_tracked_i_to_restore + 1, posData.SizeT): - data_frame_i = myutils.get_empty_stored_data_dict() + data_frame_i = utils.get_empty_stored_data_dict() data_frame_i["manually_edited_lab"] = posData.allData_li[frame_i][ "manually_edited_lab" @@ -623,7 +623,7 @@ def reInitLastSegmFrame( break posData.segm_data[i] = posData.allData_li[i]["labels"] - posData.allData_li[i] = myutils.get_empty_stored_data_dict() + posData.allData_li[i] = utils.get_empty_stored_data_dict() posData.tracked_lost_centroids[i] = set() posData.acdcTracker2stepsAnnotInfo.pop(i, None) diff --git a/cellacdc/mixins/graphics.py b/cellacdc/mixins/graphics.py index df95339ed..fbd374bab 100644 --- a/cellacdc/mixins/graphics.py +++ b/cellacdc/mixins/graphics.py @@ -24,7 +24,7 @@ apps, colors, html_utils, - myutils, + utils, widgets, workers, ) @@ -2291,7 +2291,7 @@ def setOverlayImages(self, frame_i=None): rgba_imgs_info = {} for filename in posData.ol_data: - chName = myutils.get_chname_from_basename( + chName = utils.get_chname_from_basename( filename, posData.basename, remove_ext=False ) if chName not in self.checkedOverlayChannels: @@ -2538,7 +2538,7 @@ def setRetainSizePolicyLutItems(self): return for channel, items in self.overlayLayersItems.items(): _, lutItem, alphaSB = items[:3] - myutils.setRetainSizePolicy(lutItem, retain=True) + utils.setRetainSizePolicy(lutItem, retain=True) QTimer.singleShot(300, self.autoRange) def setTrackedLostObjectContour(self, obj): diff --git a/cellacdc/mixins/image_display.py b/cellacdc/mixins/image_display.py index 6cfb457d3..96b6407df 100644 --- a/cellacdc/mixins/image_display.py +++ b/cellacdc/mixins/image_display.py @@ -17,7 +17,7 @@ disableWindow, exception_handler, graphLayoutBkgrColor, - myutils, + utils, settings_csv_path, ) @@ -147,11 +147,11 @@ def editImgProperties(self, checked=True): def enableZstackWidgets(self, enabled): if enabled: - myutils.setRetainSizePolicy(self.zSliceScrollBar) - myutils.setRetainSizePolicy(self.zProjComboBox) - myutils.setRetainSizePolicy(self.zSliceOverlay_SB) - myutils.setRetainSizePolicy(self.zProjOverlay_CB) - myutils.setRetainSizePolicy(self.overlay_z_label) + utils.setRetainSizePolicy(self.zSliceScrollBar) + utils.setRetainSizePolicy(self.zProjComboBox) + utils.setRetainSizePolicy(self.zSliceOverlay_SB) + utils.setRetainSizePolicy(self.zProjOverlay_CB) + utils.setRetainSizePolicy(self.overlay_z_label) self.zSliceScrollBar.setDisabled(False) self.zProjComboBox.show() if self.data[self.pos_i].SizeT > 1: @@ -163,11 +163,11 @@ def enableZstackWidgets(self, enabled): self.switchPlaneCombobox.setDisabled(False) self.SizeZlabel.show() else: - myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=False) - myutils.setRetainSizePolicy(self.zProjComboBox, retain=False) - myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=False) - myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=False) - myutils.setRetainSizePolicy(self.overlay_z_label, retain=False) + utils.setRetainSizePolicy(self.zSliceScrollBar, retain=False) + utils.setRetainSizePolicy(self.zProjComboBox, retain=False) + utils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=False) + utils.setRetainSizePolicy(self.zProjOverlay_CB, retain=False) + utils.setRetainSizePolicy(self.overlay_z_label, retain=False) self.zSliceScrollBar.setDisabled(True) self.zProjComboBox.hide() self.zProjComboBox.hide() @@ -710,7 +710,7 @@ def normalizeIntensities(self, img): if how == "Do not normalize. Display raw image": img = img elif how == "Convert to floating point format with values [0, 1]": - img = myutils.img_to_float(img) + img = utils.img_to_float(img) # elif how == 'Rescale to 8-bit unsigned integer format with values [0, 255]': # img = skimage.img_as_float(img) # img = (img*255).astype(np.uint8) @@ -1198,14 +1198,14 @@ def updateImageValueFormatter(self): if self.img1.image is not None: dtype = self.img1.image.dtype n_digits = len(str(int(self.img1.image.max()))) - self.imgValueFormatter = myutils.get_number_fstring_formatter( + self.imgValueFormatter = utils.get_number_fstring_formatter( dtype, precision=abs(n_digits - 5) ) rawImgData = self.data[self.pos_i].img_data dtype = rawImgData.dtype n_digits = len(str(int(rawImgData.max()))) - self.rawValueFormatter = myutils.get_number_fstring_formatter( + self.rawValueFormatter = utils.get_number_fstring_formatter( dtype, precision=abs(n_digits - 5) ) diff --git a/cellacdc/mixins/label_editing.py b/cellacdc/mixins/label_editing.py index 1d924223c..ac5ab9fec 100644 --- a/cellacdc/mixins/label_editing.py +++ b/cellacdc/mixins/label_editing.py @@ -643,7 +643,7 @@ def setHoverToolSymbolColor( posData = self.data[self.pos_i] Y, X = self.get_2Dlab(posData.lab).shape - if not myutils.is_in_bounds(xdata, ydata, X, Y): + if not utils.is_in_bounds(xdata, ydata, X, Y): return self.isHoverZneighID = False diff --git a/cellacdc/mixins/layout_controls.py b/cellacdc/mixins/layout_controls.py index 7cefbe3fa..7155b7def 100644 --- a/cellacdc/mixins/layout_controls.py +++ b/cellacdc/mixins/layout_controls.py @@ -21,7 +21,7 @@ QWidget, ) -from cellacdc import myutils, widgets +from cellacdc import utils, widgets from cellacdc.gui_decorators import resetViewRange from .image_controls import ImageControls @@ -725,11 +725,11 @@ def retainSpaceSlidersToggled(self, checked): retainSpaceZ = False else: retainSpaceZ = checked - myutils.setRetainSizePolicy(self.zSliceScrollBar, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.zProjComboBox, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.zProjOverlay_CB, retain=retainSpaceZ) - myutils.setRetainSizePolicy(self.overlay_z_label, retain=retainSpaceZ) + utils.setRetainSizePolicy(self.zSliceScrollBar, retain=retainSpaceZ) + utils.setRetainSizePolicy(self.zProjComboBox, retain=retainSpaceZ) + utils.setRetainSizePolicy(self.zSliceOverlay_SB, retain=retainSpaceZ) + utils.setRetainSizePolicy(self.zProjOverlay_CB, retain=retainSpaceZ) + utils.setRetainSizePolicy(self.overlay_z_label, retain=retainSpaceZ) QTimer.singleShot(200, self.resizeGui) diff --git a/cellacdc/mixins/lineage_interactions.py b/cellacdc/mixins/lineage_interactions.py index 513a2ef17..d5c9c19be 100644 --- a/cellacdc/mixins/lineage_interactions.py +++ b/cellacdc/mixins/lineage_interactions.py @@ -270,7 +270,7 @@ def getDistanceListMissingIDs(self, point, ID): self.distanceListMissingIDs[ID] = [relevant_rp[0].label] return [relevant_rp[0].label] else: - sorted_missing_IDs = myutils.sort_IDs_dist(relevant_rp, point=point) + sorted_missing_IDs = utils.sort_IDs_dist(relevant_rp, point=point) self.distanceListMissingIDs[ID] = sorted_missing_IDs return sorted_missing_IDs else: @@ -292,10 +292,10 @@ def get_difference_table(self, return_css_separated=False, return_differece=Fals compare_columns = ["parent_ID_tree"] new_df = new_df[original_df.columns] - new_df = myutils.checked_reset_index_Cell_ID(new_df) + new_df = utils.checked_reset_index_Cell_ID(new_df) new_df = new_df[compare_columns] new_df = new_df.sort_index() - original_df = myutils.checked_reset_index_Cell_ID(original_df) + original_df = utils.checked_reset_index_Cell_ID(original_df) original_df = original_df[compare_columns] original_df = original_df.sort_index() @@ -303,7 +303,7 @@ def get_difference_table(self, return_css_separated=False, return_differece=Fals if differences.empty: return - differences = myutils.checked_reset_index_Cell_ID(differences) + differences = utils.checked_reset_index_Cell_ID(differences) differences = differences["parent_ID_tree"] differences = differences.reset_index() diff --git a/cellacdc/mixins/magic_prompts.py b/cellacdc/mixins/magic_prompts.py index 7a3117fbb..5171184fc 100644 --- a/cellacdc/mixins/magic_prompts.py +++ b/cellacdc/mixins/magic_prompts.py @@ -30,7 +30,7 @@ def _importInitMagicPromptModel( ): self.logger.info(f"Initializing promptable model {model_name}...") init_kwargs = win.init_kwargs - model = myutils.init_prompt_segm_model( + model = utils.init_prompt_segm_model( acdcPromptSegment, posData, win.init_kwargs ) toolbar.model = model @@ -312,7 +312,7 @@ def showInstructionsCustomPromptModel(self): self.logger.info("Adding custom promptable model process stopped.") return - myutils.store_custom_promptable_model_path(modelFilePath) + utils.store_custom_promptable_model_path(modelFilePath) msg = widgets.myMessageBox(wrapText=False) info_txt = html_utils.paragraph(f""" @@ -394,10 +394,10 @@ def viewSetMagicPromptModelParams( ): posData = self.data[self.pos_i] - init_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( + init_argspecs = utils.setDefaultValueArgSpecsFromKwargs( init_argspecs, init_kwargs ) - segment_argspecs = myutils.setDefaultValueArgSpecsFromKwargs( + segment_argspecs = utils.setDefaultValueArgSpecsFromKwargs( segment_argspecs, segment_kwargs ) diff --git a/cellacdc/mixins/saving.py b/cellacdc/mixins/saving.py index 515ec23fb..176164960 100644 --- a/cellacdc/mixins/saving.py +++ b/cellacdc/mixins/saving.py @@ -842,7 +842,7 @@ def saveDataFinished(self): self.askConcatenate() if self.closeGUI: - salute_string = myutils.get_salute_string() + salute_string = utils.get_salute_string() msg = widgets.myMessageBox() txt = html_utils.paragraph( f"Data saved!. The GUI will now close.

    {salute_string}" @@ -882,7 +882,7 @@ def saveDataUpdatePbar(self, step, max=-1, exec_time=0.0): self.saveWin.QPbar.setValue(self.saveWin.QPbar.value() + step) steps_left = self.saveWin.QPbar.maximum() - self.saveWin.QPbar.value() seconds = round(exec_time * steps_left) - ETA = myutils.seconds_to_ETA(seconds) + ETA = utils.seconds_to_ETA(seconds) self.saveWin.ETA_label.setText(f"ETA: {ETA}") def saveMetricsCritical(self, traceback_format): diff --git a/cellacdc/mixins/seg_for_lost_ids.py b/cellacdc/mixins/seg_for_lost_ids.py index ee6433f78..20105b13c 100644 --- a/cellacdc/mixins/seg_for_lost_ids.py +++ b/cellacdc/mixins/seg_for_lost_ids.py @@ -41,7 +41,7 @@ def SegForLostIDsSetSettings(self): try: if acdcSegment is None or base_model_name != self.local_seg_base_model_name: self.logger.info(f"Importing {base_model_name}...") - acdcSegment = myutils.import_segment_module(base_model_name) + acdcSegment = utils.import_segment_module(base_model_name) self.acdcSegment_li[idx] = acdcSegment self.local_seg_base_model_name = base_model_name except (IndexError, ImportError, KeyError) as e: @@ -87,7 +87,7 @@ def SegForLostIDsSetSettings(self): extra_ArgSpec.append(param) - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) + init_params, segment_params = utils.getModelArgSpec(acdcSegment) segment_params = [arg for arg in segment_params if arg[0] != "diameter"] extraParamsTitle = "Settings for local segmentation" @@ -126,16 +126,16 @@ def SegForLostIDsSetSettings(self): } def SegForLostIDsWorkerAskInstallGPU(self, model_name, use_gpu): - result = myutils.check_gpu_available(model_name, use_gpu, qparent=self) + result = utils.check_gpu_available(model_name, use_gpu, qparent=self) self.SegForLostIDsWorker.gpu_go = result - dont_force_cpu = myutils.check_gpu_available( + dont_force_cpu = utils.check_gpu_available( model_name, use_gpu, do_not_warn=True ) self.SegForLostIDsWorker.dont_force_cpu = dont_force_cpu self.SegForLostIDsWaitCond.wakeAll() def SegForLostIDsWorkerAskInstallModel(self, model_name): - myutils.check_install_package(model_name) + utils.check_install_package(model_name) self.SegForLostIDsWaitCond.wakeAll() def SegForLostIDsWorkerFinished(self): diff --git a/cellacdc/mixins/segmentation.py b/cellacdc/mixins/segmentation.py index 73c08645f..7a4236cfc 100644 --- a/cellacdc/mixins/segmentation.py +++ b/cellacdc/mixins/segmentation.py @@ -29,7 +29,7 @@ def autoSegm_cb(self, checked): if checked: self.askSegmParam = True # Ask which model - models = myutils.get_list_of_models() + models = utils.get_list_of_models() win = widgets.QDialogListbox( "Select model", "Select model to use for segmentation: ", @@ -323,7 +323,7 @@ def repeatSegm(self, model_name="", askSegmParams=False, is_label_roi=False): acdcSegment = self.acdcSegment_li[idx] if acdcSegment is None: self.logger.info(f"Importing {model_name}...") - acdcSegment = myutils.import_segment_module(model_name) + acdcSegment = utils.import_segment_module(model_name) self.acdcSegment_li[idx] = acdcSegment # Ask parameters if the user clicked on the action @@ -334,7 +334,7 @@ def repeatSegm(self, model_name="", askSegmParams=False, is_label_roi=False): self.app.restoreOverrideCursor() self.segmModelName = model_name # Read all models parameters - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) + init_params, segment_params = utils.getModelArgSpec(acdcSegment) # Prompt user to enter the model parameters try: url = acdcSegment.url_help() @@ -353,10 +353,10 @@ def repeatSegm(self, model_name="", askSegmParams=False, is_label_roi=False): self.model_kwargs = win.segment_kwargs thresh_method = self.model_kwargs["threshold_method"] gauss_sigma = self.model_kwargs["gauss_sigma"] - segment_params = myutils.insertModelArgSpec( + segment_params = utils.insertModelArgSpec( segment_params, "threshold_method", thresh_method ) - segment_params = myutils.insertModelArgSpec( + segment_params = utils.insertModelArgSpec( segment_params, "gauss_sigma", gauss_sigma ) initLastParams = False @@ -379,7 +379,7 @@ def repeatSegm(self, model_name="", askSegmParams=False, is_label_roi=False): self.secondChannelName = win.secondChannelName self.preproc_recipe = win.preproc_recipe - myutils.log_segm_params( + utils.log_segm_params( model_name, win.init_kwargs, win.model_kwargs, @@ -391,13 +391,13 @@ def repeatSegm(self, model_name="", askSegmParams=False, is_label_roi=False): ) use_gpu = win.init_kwargs.get("gpu", False) - proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) + proceed = utils.check_gpu_available(model_name, use_gpu, qparent=self) if not proceed: self.logger.info("Segmentation process cancelled.") self.titleLabel.setText("Segmentation process cancelled.") return - model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) + model = utils.init_segm_model(acdcSegment, posData, win.init_kwargs) if model is None: self.logger.info("Segmentation process cancelled.") self.titleLabel.setText("Segmentation process cancelled.") @@ -543,11 +543,11 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): acdcSegment = self.acdcSegment_li[idx] if acdcSegment is None: self.logger.info(f"Importing {model_name}...") - acdcSegment = myutils.import_segment_module(model_name) + acdcSegment = utils.import_segment_module(model_name) self.acdcSegment_li[idx] = acdcSegment # Read all models parameters - init_params, segment_params = myutils.getModelArgSpec(acdcSegment) + init_params, segment_params = utils.getModelArgSpec(acdcSegment) # Prompt user to enter the model parameters try: url = acdcSegment.url_help() @@ -574,7 +574,7 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): self.applyPostProcessing = win.applyPostProcessing self.preproc_recipe = win.preproc_recipe - myutils.log_segm_params( + utils.log_segm_params( model_name, win.init_kwargs, win.model_kwargs, @@ -590,13 +590,13 @@ def repeatSegmVideo(self, model_name, startFrameNum, stopFrameNum): secondChannelData = self.getSecondChannelData() use_gpu = win.init_kwargs.get("gpu", False) - proceed = myutils.check_gpu_available(model_name, use_gpu, qparent=self) + proceed = utils.check_gpu_available(model_name, use_gpu, qparent=self) if not proceed: self.logger.info("Segmentation process cancelled.") self.titleLabel.setText("Segmentation process cancelled.") return - model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) + model = utils.init_segm_model(acdcSegment, posData, win.init_kwargs) if model is None: self.logger.info("Segmentation process cancelled.") self.titleLabel.setText("Segmentation process cancelled.") @@ -741,7 +741,7 @@ def showInstructionsCustomModel(self): self.logger.info("Adding custom model process stopped.") return - myutils.store_custom_model_path(modelFilePath) + utils.store_custom_model_path(modelFilePath) modelName = os.path.basename(os.path.dirname(modelFilePath)) customModelAction = QAction(modelName) self.segmSingleFrameMenu.addAction(customModelAction) diff --git a/cellacdc/mixins/session.py b/cellacdc/mixins/session.py index fd98da6fa..ce0a395f9 100644 --- a/cellacdc/mixins/session.py +++ b/cellacdc/mixins/session.py @@ -126,7 +126,7 @@ def _get_data_visited( try: binnedIDs_df = df[df["is_cell_excluded"] > 0] except Exception as err: - df = myutils.fix_acdc_df_dtypes(df) + df = utils.fix_acdc_df_dtypes(df) binnedIDs_df = df[df["is_cell_excluded"] > 0] posData.binnedIDs = set(binnedIDs_df.index) ripIDs_df = df[df["is_cell_dead"] > 0] @@ -271,7 +271,7 @@ def get_labels( def initPosAttr(self): exp_path = self.data[self.pos_i].exp_path - pos_foldernames = myutils.get_pos_foldernames(exp_path) + pos_foldernames = utils.get_pos_foldernames(exp_path) if len(pos_foldernames) == 1: self.loadPosAction.setDisabled(True) else: @@ -337,7 +337,7 @@ def initPosAttr(self): posData.allData_li.extend([None] * missing_frames) for i in range(posData.SizeT): if posData.allData_li[i] is None: - posData.allData_li[i] = myutils.get_empty_stored_data_dict() + posData.allData_li[i] = utils.get_empty_stored_data_dict() posData.lutLevels = {channel: {} for channel in self.ch_names} @@ -674,7 +674,7 @@ def store_manual_annot_data(self, posData=None, data_frame_i=None): def unstore_data(self): posData = self.data[self.pos_i] - posData.allData_li[posData.frame_i] = myutils.get_empty_stored_data_dict() + posData.allData_li[posData.frame_i] = utils.get_empty_stored_data_dict() def updateLastVisitedFrame(self, last_visited_frame_i=None): if last_visited_frame_i is None: diff --git a/cellacdc/mixins/status_hover.py b/cellacdc/mixins/status_hover.py index 74ab6947a..fefeedbc2 100644 --- a/cellacdc/mixins/status_hover.py +++ b/cellacdc/mixins/status_hover.py @@ -19,7 +19,7 @@ def _addOverlayHoverValuesFormatted(self, txt, xdata, ydata): return txt for filename in posData.ol_data: - chName = myutils.get_chname_from_basename( + chName = utils.get_chname_from_basename( filename, posData.basename, remove_ext=False ) if chName not in self.checkedOverlayChannels: @@ -54,7 +54,7 @@ def _addRulerMeasurementText(self, txt): def _channelHoverValues(self, descr, channel, value, ff=None): if ff is None: n_digits = len(str(int(value))) - ff = myutils.get_number_fstring_formatter( + ff = utils.get_number_fstring_formatter( type(value), precision=abs(n_digits - 5) ) txt = f"{descr} {channel}: value={value:{ff}}" diff --git a/cellacdc/mixins/tool_activation.py b/cellacdc/mixins/tool_activation.py index d0c3f3f1a..012d595b7 100644 --- a/cellacdc/mixins/tool_activation.py +++ b/cellacdc/mixins/tool_activation.py @@ -556,7 +556,7 @@ def setTitleFormatter(self, htmlTxt_li, htmlTxtFull_li, pretxt, color, IDs): if isinstance(IDs, set): IDs = list(IDs) - trim_IDs = myutils.get_trimmed_list(IDs) + trim_IDs = utils.get_trimmed_list(IDs) txt = f"{pretxt}: {trim_IDs}" txt_full = f"{pretxt}:
    {IDs}" diff --git a/cellacdc/mixins/tracking.py b/cellacdc/mixins/tracking.py index 817c4aeb0..a132263c4 100644 --- a/cellacdc/mixins/tracking.py +++ b/cellacdc/mixins/tracking.py @@ -56,7 +56,7 @@ def _drawGhostMask(self, x, y): bbox = ((Dy, Dy + h), (Dx, Dx + w)) Y, X = self.currentLab2D.shape - slices = myutils.get_slices_local_into_global_arr(bbox, (Y, X)) + slices = utils.get_slices_local_into_global_arr(bbox, (Y, X)) slice_global_to_local, slice_crop_local = slices obj_image = self.ghostObject.image[slice_crop_local] @@ -441,7 +441,7 @@ def initRealTimeTracker(self, force=False): if rtTrackerAction.isChecked(): break - aliases = myutils.aliases_real_time_trackers(reverse=True) + aliases = utils.aliases_real_time_trackers(reverse=True) rtTracker = rtTrackerAction.text() rtTracker_txt = rtTracker @@ -460,7 +460,7 @@ def initRealTimeTracker(self, force=False): self.logger.info(f"Initializing {rtTracker_txt} tracker...") self._rtTrackerName = rtTracker posData = self.data[self.pos_i] - realTimeTracker, track_frame_params = myutils.init_tracker( + realTimeTracker, track_frame_params = utils.init_tracker( posData, rtTracker, qparent=self, realTime=True ) if realTimeTracker is None: @@ -748,14 +748,14 @@ def repeatTrackingVideo(self, checked=False): video_to_track = video_to_track[start_n - 1 : stop_n] self.logger.info(f"Importing {trackerName} tracker...") - self.tracker, self.track_params, init_params = myutils.init_tracker( + self.tracker, self.track_params, init_params = utils.init_tracker( posData, trackerName, qparent=self, return_init_params=True ) if self.track_params is None: self.logger.info("Tracking aborted.") return - warningText = myutils.validate_tracker_input(self.tracker, video_to_track) + warningText = utils.validate_tracker_input(self.tracker, video_to_track) if warningText is not None: self.logger.info(warningText) self.warnTrackerInputNotValid(trackerName, warningText) diff --git a/cellacdc/myutils/__init__.py b/cellacdc/myutils/__init__.py deleted file mode 100644 index 9a2ebce5b..000000000 --- a/cellacdc/myutils/__init__.py +++ /dev/null @@ -1,520 +0,0 @@ -"""Cell-ACDC utility helpers.""" - -from .dataframe import ( - are_acdc_dfs_equal, - checked_reset_index, - checked_reset_index_Cell_ID, - df_ctc_to_acdc_df, - fix_acdc_df_dtypes, - format_IDs, - get_cca_colname_desc, -) - -from .install import ( - _apt_install_java_command, - _brew_install_hdf5, - _brew_install_java_command, - _get_pkg_command_pip_install, - _inform_install_package_failed, - _install_deepsea, - _install_homebrew_command, - _install_package_cli_msg, - _install_package_gui_msg, - _install_package_msg, - _install_pip_package, - _install_pytorch_cli, - _install_sam2, - _install_segment_anything, - _install_tensorflow, - _java_exists, - _java_instructions_linux, - _java_instructions_macOS, - _java_instructions_windows, - _warn_dll_torch, - _warn_install_gpu, - check_git_installed, - check_gpu_available, - check_gpu_requested_segm_model, - check_install_baby, - check_install_cellpose, - check_install_cellsam, - check_install_custom_dependencies, - check_install_instanseg, - check_install_microsam, - check_install_nnInteractive, - check_install_omnipose, - check_install_package, - check_install_sam2, - check_install_segment_anything, - check_install_tapir, - check_install_torch, - check_install_trackastra, - check_install_yeaz, - check_upgrade_javabridge, - download_java, - get_java_url, - get_package_info, - get_package_version, - get_pip_conda_prefix, - get_pip_install_cellacdc_version_command, - get_pytorch_command, - get_torch_device, - install_java, - install_javabridge, - install_javabridge_help, - install_javabridge_instructions_text, - install_package_conda, - uninstall_omnipose_acdc, - uninstall_pip_package, - update_editable_package, - update_not_editable_package, - update_package, -) - -from .io import ( - _bytes_to_GB, - _bytes_to_MB, - browse_docs, - browse_url, - getMemoryFootprint, - save_response_content, -) - -from .logging import ( - Logger, - _log_system_info, - delete_older_log_files, - get_logs_path, - log_segm_params, - setupLogger, -) - -from .misc import ( - StdErr, - _apt_gcc_command, - _apt_update_command, - _available_frameworks, - _get_doc_stop_idx, - _init_fiji_cli, - _jdk_exists, - _parse_bool_str, - _relabel_cca_dfs_and_segm_data, - _run_command, - _subprocess_run_command, - addToRecentPaths, - add_segm_data_param, - checkDataIntegrity, - check_napari_plugin, - clipSelemMask, - convert_to_dtype, - cpp_windows_url, - exec_time, - extract_zip, - filterCommonStart, - find_distances_ID, - find_missing_integers, - findalliter, - float_img_to_dtype, - format_cca_manual_changes, - format_commit_date_utc, - from_imagej_rois_to_segm_data, - from_lab_to_imagej_rois, - from_lab_to_obj_coords, - getAcdcDfSegmPaths, - getBaseAcdcDf, - getBasename, - getBasenameAndChNames, - getChannelFilePath, - getCustomAnnotTooltip, - getDefault_SegmInfo_df, - getMostRecentPath, - get_chained_attr, - get_chname_from_basename, - get_confirm_token, - get_empty_stored_data_dict, - get_fiji_base_command, - get_function_argspec, - get_input_output_mapper, - get_linux_distribution_name, - get_module_name, - get_obj_by_label, - get_slices_local_into_global_arr, - get_tiff_metadata, - img_to_float, - import_segment_module, - init_input_points_df, - is_gui_running, - is_in_bounds, - is_iterable, - iterate_along_axes, - jdk_windows_url, - lab2d_to_rois, - pairwise, - purge_module, - remove_known_extension, - reset_settings, - run_fiji_command, - safe_get_or_call, - seconds_to_ETA, - separate_fluo_segment_channels, - setRetainSizePolicy, - showInExplorer, - showUserManual, - sort_IDs_dist, - synthetic_image_geneator, - test_fiji_base_command, - to_tiff, - to_uint16, - to_uint8, - translateStrNone, - try_kwargs, - utilClass, -) - -from .models import ( - _download_cellpose_germlineNuclei_model, - _download_deepsea_models, - _download_omnipose_models, - _download_sam2_models, - _download_segment_anything_models, - _download_tapir_model, - _download_yeaz_models, - _model_url, - _write_model_location_to_txt, - aliases_real_time_trackers, - check_model_exists, - download_bioformats_jar, - download_examples, - download_ffmpeg, - download_fiji, - download_manual, - download_model, - download_url, - getClassArgSpecs, - getModelArgSpec, - getTrackerArgSpec, - get_add_custom_model_instructions, - get_add_custom_prompt_model_instructions, - get_list_of_models, - get_list_of_promptable_models, - get_list_of_real_time_trackers, - get_list_of_trackers, - import_promptable_segment_module, - import_tracker_module, - init_prompt_segm_model, - init_segm_model, - init_tracker, - insertModelArgSpec, - isIntensityImgRequiredForTracker, - params_to_ArgSpec, - parse_model_param_doc, - parse_model_params, - setDefaultValueArgSpecsFromKwargs, - validate_tracker_input, -) - -from .paths import ( - _create_temp_dir, - check_v123_model_path, - determine_folder_type, - get_acdc_data_path, - get_acdc_java_path, - get_examples_path, - get_fiji_binary_filepath_mac, - get_fiji_exec_folderpath, - get_gdrive_path, - get_images_folderpath, - get_model_path, - get_open_filemaneger_os_string, - get_pos_foldernames, - get_pos_status, - get_pos_status_acdc, - get_pos_status_spotmax, - is_old_user_profile_path, - is_pos_folderpath, - listdir, - migrate_to_new_user_profile_path, - store_custom_model_path, - store_custom_promptable_model_path, - to_relative_path, - trim_path, - validate_images_path, -) - -from .qt import ( - get_cli_multi_choice_question, - testQcoreApp, -) - -from .text import ( - append_text_filename, - elided_text, - get_number_fstring_formatter, - get_show_in_file_manager_text, - get_trimmed_dict, - get_trimmed_list, -) - -from .version import ( - _update_repo_with_git_command, - check_cellpose_version, - check_matplotlib_version, - check_pkg_exact_version, - check_pkg_max_version, - check_pkg_version, - get_cellpose_major_version, - get_date_from_version, - get_git_branch_name, - get_git_pull_checkout_cellacdc_version_commands, - get_info_version_text, - get_salute_string, - is_pkg_version_within_range, - is_second_version_greater, - read_version, -) - -__all__ = [ - "are_acdc_dfs_equal", - "checked_reset_index", - "checked_reset_index_Cell_ID", - "df_ctc_to_acdc_df", - "fix_acdc_df_dtypes", - "format_IDs", - "get_cca_colname_desc", - "_apt_install_java_command", - "_brew_install_hdf5", - "_brew_install_java_command", - "_get_pkg_command_pip_install", - "_inform_install_package_failed", - "_install_deepsea", - "_install_homebrew_command", - "_install_package_cli_msg", - "_install_package_gui_msg", - "_install_package_msg", - "_install_pip_package", - "_install_pytorch_cli", - "_install_sam2", - "_install_segment_anything", - "_install_tensorflow", - "_java_exists", - "_java_instructions_linux", - "_java_instructions_macOS", - "_java_instructions_windows", - "_warn_dll_torch", - "_warn_install_gpu", - "check_git_installed", - "check_gpu_available", - "check_gpu_requested_segm_model", - "check_install_baby", - "check_install_cellpose", - "check_install_cellsam", - "check_install_custom_dependencies", - "check_install_instanseg", - "check_install_microsam", - "check_install_nnInteractive", - "check_install_omnipose", - "check_install_package", - "check_install_sam2", - "check_install_segment_anything", - "check_install_tapir", - "check_install_torch", - "check_install_trackastra", - "check_install_yeaz", - "check_upgrade_javabridge", - "download_java", - "get_java_url", - "get_package_info", - "get_package_version", - "get_pip_conda_prefix", - "get_pip_install_cellacdc_version_command", - "get_pytorch_command", - "get_torch_device", - "install_java", - "install_javabridge", - "install_javabridge_help", - "install_javabridge_instructions_text", - "install_package_conda", - "uninstall_omnipose_acdc", - "uninstall_pip_package", - "update_editable_package", - "update_not_editable_package", - "update_package", - "_bytes_to_GB", - "_bytes_to_MB", - "browse_docs", - "browse_url", - "getMemoryFootprint", - "save_response_content", - "Logger", - "_log_system_info", - "delete_older_log_files", - "get_logs_path", - "log_segm_params", - "setupLogger", - "StdErr", - "_apt_gcc_command", - "_apt_update_command", - "_available_frameworks", - "_get_doc_stop_idx", - "_init_fiji_cli", - "_jdk_exists", - "_parse_bool_str", - "_relabel_cca_dfs_and_segm_data", - "_run_command", - "_subprocess_run_command", - "addToRecentPaths", - "add_segm_data_param", - "checkDataIntegrity", - "check_napari_plugin", - "clipSelemMask", - "convert_to_dtype", - "cpp_windows_url", - "exec_time", - "extract_zip", - "filterCommonStart", - "find_distances_ID", - "find_missing_integers", - "findalliter", - "float_img_to_dtype", - "format_cca_manual_changes", - "format_commit_date_utc", - "from_imagej_rois_to_segm_data", - "from_lab_to_imagej_rois", - "from_lab_to_obj_coords", - "getAcdcDfSegmPaths", - "getBaseAcdcDf", - "getBasename", - "getBasenameAndChNames", - "getChannelFilePath", - "getCustomAnnotTooltip", - "getDefault_SegmInfo_df", - "getMostRecentPath", - "get_chained_attr", - "get_chname_from_basename", - "get_confirm_token", - "get_empty_stored_data_dict", - "get_fiji_base_command", - "get_function_argspec", - "get_input_output_mapper", - "get_linux_distribution_name", - "get_module_name", - "get_obj_by_label", - "get_slices_local_into_global_arr", - "get_tiff_metadata", - "img_to_float", - "import_segment_module", - "init_input_points_df", - "is_gui_running", - "is_in_bounds", - "is_iterable", - "iterate_along_axes", - "jdk_windows_url", - "lab2d_to_rois", - "pairwise", - "purge_module", - "remove_known_extension", - "reset_settings", - "run_fiji_command", - "safe_get_or_call", - "seconds_to_ETA", - "separate_fluo_segment_channels", - "setRetainSizePolicy", - "showInExplorer", - "showUserManual", - "sort_IDs_dist", - "synthetic_image_geneator", - "test_fiji_base_command", - "to_tiff", - "to_uint16", - "to_uint8", - "translateStrNone", - "try_kwargs", - "utilClass", - "_download_cellpose_germlineNuclei_model", - "_download_deepsea_models", - "_download_omnipose_models", - "_download_sam2_models", - "_download_segment_anything_models", - "_download_tapir_model", - "_download_yeaz_models", - "_model_url", - "_write_model_location_to_txt", - "aliases_real_time_trackers", - "check_model_exists", - "download_bioformats_jar", - "download_examples", - "download_ffmpeg", - "download_fiji", - "download_manual", - "download_model", - "download_url", - "getClassArgSpecs", - "getModelArgSpec", - "getTrackerArgSpec", - "get_add_custom_model_instructions", - "get_add_custom_prompt_model_instructions", - "get_list_of_models", - "get_list_of_promptable_models", - "get_list_of_real_time_trackers", - "get_list_of_trackers", - "import_promptable_segment_module", - "import_tracker_module", - "init_prompt_segm_model", - "init_segm_model", - "init_tracker", - "insertModelArgSpec", - "isIntensityImgRequiredForTracker", - "params_to_ArgSpec", - "parse_model_param_doc", - "parse_model_params", - "setDefaultValueArgSpecsFromKwargs", - "validate_tracker_input", - "_create_temp_dir", - "check_v123_model_path", - "determine_folder_type", - "get_acdc_data_path", - "get_acdc_java_path", - "get_examples_path", - "get_fiji_binary_filepath_mac", - "get_fiji_exec_folderpath", - "get_gdrive_path", - "get_images_folderpath", - "get_model_path", - "get_open_filemaneger_os_string", - "get_pos_foldernames", - "get_pos_status", - "get_pos_status_acdc", - "get_pos_status_spotmax", - "is_old_user_profile_path", - "is_pos_folderpath", - "listdir", - "migrate_to_new_user_profile_path", - "store_custom_model_path", - "store_custom_promptable_model_path", - "to_relative_path", - "trim_path", - "validate_images_path", - "get_cli_multi_choice_question", - "testQcoreApp", - "append_text_filename", - "elided_text", - "get_number_fstring_formatter", - "get_show_in_file_manager_text", - "get_trimmed_dict", - "get_trimmed_list", - "_update_repo_with_git_command", - "check_cellpose_version", - "check_matplotlib_version", - "check_pkg_exact_version", - "check_pkg_max_version", - "check_pkg_version", - "get_cellpose_major_version", - "get_date_from_version", - "get_git_branch_name", - "get_git_pull_checkout_cellacdc_version_commands", - "get_info_version_text", - "get_salute_string", - "is_pkg_version_within_range", - "is_second_version_greater", - "read_version", -] diff --git a/cellacdc/napari_utils/arboretum.py b/cellacdc/napari_utils/arboretum.py index 0780bf0aa..00dac275a 100644 --- a/cellacdc/napari_utils/arboretum.py +++ b/cellacdc/napari_utils/arboretum.py @@ -2,9 +2,9 @@ from functools import partial from natsort import natsorted -from .. import myutils, apps, load, printl, core, widgets +from .. import utils, apps, load, printl, core, widgets from .. import exception_handler -from ..utils import base +from ..tools import base from qtpy.QtCore import QTimer, Signal @@ -12,7 +12,7 @@ class NapariArboretumDialog(base.MainThreadSinglePosUtilBase): def __init__(self, posPath, app, title: str, infoText: str, parent=None): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__(app, title, module, infoText, parent) self.sigClose.connect(self.close) @@ -23,7 +23,7 @@ def __init__(self, posPath, app, title: str, infoText: str, parent=None): @exception_handler def launchNapariArboretum(self, posPath): images_path = os.path.join(posPath, "Images") - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) image_files = [ file diff --git a/cellacdc/path.py b/cellacdc/path.py index 3bf3e98bb..68fd9da76 100644 --- a/cellacdc/path.py +++ b/cellacdc/path.py @@ -9,7 +9,7 @@ from . import is_mac, is_linux from . import printl -from . import myutils +from . import utils def listdir(path): @@ -107,7 +107,7 @@ def get_posfolderpaths_walk(folderpath): continue pos_folderpath = os.path.dirname(root) - if not myutils.is_pos_folderpath(pos_folderpath): + if not utils.is_pos_folderpath(pos_folderpath): continue exp_path = os.path.dirname(pos_folderpath).replace("\\", "/") @@ -129,7 +129,7 @@ def get_exp_path_pos_foldernames_mapper(paths): filename = os.path.basename(path) path = os.path.dirname(path) - folder_type = myutils.determine_folder_type(path) + folder_type = utils.determine_folder_type(path) is_pos_folder, is_images_folder, _ = folder_type if filename is not None and not is_images_folder: diff --git a/cellacdc/plot.py b/cellacdc/plot.py index 874579136..124864569 100644 --- a/cellacdc/plot.py +++ b/cellacdc/plot.py @@ -25,7 +25,7 @@ from . import printl from . import _core, error_below, error_close -from . import _run, core, myutils +from . import _run, core, utils def matplotlib_cmap_to_lut( @@ -67,7 +67,7 @@ def imshow( print_call_stack: bool = False, ): if print_call_stack: - myutils.print_call_stack() + utils.print_call_stack() if isinstance(images[0], dict): images_dict = images[0] diff --git a/cellacdc/preprocess.py b/cellacdc/preprocess.py index 3a4e7fbca..67ab4b11d 100644 --- a/cellacdc/preprocess.py +++ b/cellacdc/preprocess.py @@ -571,7 +571,7 @@ def _init_dummy_filter(**kwargs): def _init_basicpy_background_correction(**kwargs): - from . import myutils + from . import utils custom_install_requires = [ "hyperactive>=4.4.0", @@ -584,7 +584,7 @@ def _init_basicpy_background_correction(**kwargs): "scipy", # this will theoretically have the wrong version of scipy in the end ] - myutils.check_install_custom_dependencies( + utils.check_install_custom_dependencies( custom_install_requires, "basicpy", parent=kwargs.get("parent") ) diff --git a/cellacdc/prompts.py b/cellacdc/prompts.py index 4f1228dcd..a676c152e 100755 --- a/cellacdc/prompts.py +++ b/cellacdc/prompts.py @@ -23,7 +23,7 @@ from qtpy.QtGui import QFont from . import widgets, apps -from . import myutils, printl, html_utils, load +from . import utils, printl, html_utils, load from . import settings_folderpath @@ -109,7 +109,7 @@ def get_available_channels( ): # First check if metadata.csv already has the channel names metadata_csv_path = None - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): if file.endswith("metadata.csv"): metadata_csv_path = os.path.join(images_path, file) break @@ -127,7 +127,7 @@ def get_available_channels( # Find basename as intersection of filenames channel_names = set() self.basenameNotFound = False - isBasenamePresent = myutils.checkDataIntegrity(filenames, images_path) + isBasenamePresent = utils.checkDataIntegrity(filenames, images_path) if basename is None: basename = filenames[0] basename = filenames[0] diff --git a/cellacdc/scripts/split_segm_mask_yeast.py b/cellacdc/scripts/split_segm_mask_yeast.py index 33c2f667a..9cdd4ec8c 100644 --- a/cellacdc/scripts/split_segm_mask_yeast.py +++ b/cellacdc/scripts/split_segm_mask_yeast.py @@ -6,9 +6,9 @@ import qtpy.compat -from cellacdc import printl, myutils, apps, load, core, widgets +from cellacdc import printl, utils, apps, load, core, widgets from cellacdc._run import _setup_app -from cellacdc.utils.base import NewThreadMultipleExpBaseUtil +from cellacdc.tools.base import NewThreadMultipleExpBaseUtil from cellacdc import io DEBUG = False @@ -17,13 +17,13 @@ def ask_select_folder(): selected_path = qtpy.compat.getexistingdirectory( caption="Select experiment folder to analyse", - basedir=myutils.getMostRecentPath(), + basedir=utils.getMostRecentPath(), ) return selected_path def get_exp_path_pos_foldernames(selected_path): - folder_type = myutils.determine_folder_type(selected_path) + folder_type = utils.determine_folder_type(selected_path) is_pos_folder, is_images_folder, exp_path = folder_type if is_pos_folder: exp_path = os.path.dirname(selected_path) @@ -34,7 +34,7 @@ def get_exp_path_pos_foldernames(selected_path): pos_foldernames = [os.path.basename(pos_path)] else: exp_path = selected_path - pos_foldernames = myutils.get_pos_foldernames(exp_path) + pos_foldernames = utils.get_pos_foldernames(exp_path) return exp_path, pos_foldernames @@ -78,7 +78,7 @@ def run(): if not selected_path: exit("Execution cancelled") - myutils.addToRecentPaths(selected_path) + utils.addToRecentPaths(selected_path) exp_path, pos_foldernames = get_exp_path_pos_foldernames(selected_path) if len(pos_foldernames) > 1: diff --git a/cellacdc/segm.py b/cellacdc/segm.py index d93ab4f02..aca24674c 100755 --- a/cellacdc/segm.py +++ b/cellacdc/segm.py @@ -46,7 +46,7 @@ import qtpy.compat # Custom modules -from . import prompts, load, myutils, apps, core, dataPrep, widgets +from . import prompts, load, utils, apps, core, dataPrep, widgets from . import html_utils, printl from . import exception_handler from . import workers @@ -176,7 +176,7 @@ def __init__( self._version = version - logger, logs_path, log_path, log_filename = myutils.setupLogger(module="segm") + logger, logs_path, log_path, log_filename = utils.setupLogger(module="segm") self.logger = logger self.log_path = log_path self.log_filename = log_filename @@ -407,7 +407,7 @@ def main(self): for images_path in images_paths: print("") self.log(f"Processing {images_path}") - filenames = myutils.listdir(images_path) + filenames = utils.listdir(images_path) if not filenames: self.criticalImagesFolderEmpty(images_path) self.close() @@ -551,11 +551,11 @@ def main(self): self.log(f"Importing {model_name}...") self.model_name = model_name - acdcSegment = myutils.import_segment_module(model_name) + acdcSegment = utils.import_segment_module(model_name) self.acdcSegment = acdcSegment # Read all models parameters - init_params, segment_params = myutils.getModelArgSpec(self.acdcSegment) + init_params, segment_params = utils.getModelArgSpec(self.acdcSegment) # Prompt user to enter the model parameters try: @@ -587,7 +587,7 @@ def main(self): self.applyPostProcessing = win.applyPostProcessing self.secondChannelName = win.secondChannelName - myutils.log_segm_params( + utils.log_segm_params( model_name, win.init_kwargs, win.model_kwargs, @@ -606,7 +606,7 @@ def main(self): if self.secondChannelName is not None: init_kwargs["is_rgb"] = True - self.model = myutils.init_segm_model(acdcSegment, self.posData, init_kwargs) + self.model = utils.init_segm_model(acdcSegment, self.posData, init_kwargs) if self.model is None: self.logger.info("Segmentation model was not initialized correctly!") self.processStopped() @@ -865,7 +865,7 @@ def main(self): self.stopFrames = win.stopFrames # Ask whether to track the frames - trackers = myutils.get_list_of_trackers() + trackers = utils.get_list_of_trackers() txt = html_utils.paragraph(""" Do you want to track the objects?

    If yes, select the tracker to use

    @@ -892,7 +892,7 @@ def main(self): self.do_tracking = True trackerName = win.selectedItemsText[0] self.trackerName = trackerName - init_tracker_output = myutils.init_tracker( + init_tracker_output = utils.init_tracker( self.posData, trackerName, return_init_params=True, qparent=self ) self.tracker, self.track_params, self.tracker_init_params = ( @@ -1082,7 +1082,7 @@ def saveWorkflowToConfigFile(self): return False config_filename = win.filename - mostRecentPath = myutils.getMostRecentPath() + mostRecentPath = utils.getMostRecentPath() folder_path = apps.get_existing_directory( allow_images_path=False, parent=self, @@ -1154,7 +1154,7 @@ def askSaveMeasurements(self): ] selectedExpPaths = {exp_path: pos_foldernames} - from .utils import compute as utilsCompute + from .tools import compute as utilsCompute self.calcMeasUtility = utilsCompute.computeMeasurmentsUtilWin( selectedExpPaths, @@ -1377,7 +1377,7 @@ def segmWorkerProgressBar(self, step): self.exec_time_per_iter = t - self.time_last_pbar_update groups_2steps_left = steps_left / 2 seconds = round(self.exec_time_per_iter * groups_2steps_left) - ETA = myutils.seconds_to_ETA(seconds) + ETA = utils.seconds_to_ETA(seconds) self.ETA_label.setText(f"ETA: {ETA}") self.exec_time_per_iter = 0 self.time_last_pbar_update = t @@ -1388,7 +1388,7 @@ def segmWorkerInnerProgressBar(self, step): self.exec_time_per_frame = t - self.time_last_innerPbar_update steps_left = self.QPbar.maximum() - self.QPbar.value() seconds = round(self.exec_time_per_frame * steps_left) - ETA = myutils.seconds_to_ETA(seconds) + ETA = utils.seconds_to_ETA(seconds) self.innerETA_label.setText(f"ETA: {ETA}") self.exec_time_per_frame = 0 self.time_last_innerPbar_update = t @@ -1399,7 +1399,7 @@ def segmWorkerInnerProgressBar(self, step): numPos = self.QPbar.maximum() allPos_seconds = tot_seconds * numPos tot_seconds_left = allPos_seconds - tot_seconds - ETA = myutils.seconds_to_ETA(round(tot_seconds_left)) + ETA = utils.seconds_to_ETA(round(tot_seconds_left)) total_ETA = self.ETA_label.setText(f"ETA: {ETA}") def segmWorkerFinished(self, worker): @@ -1420,7 +1420,7 @@ def processFinished(self, total_exec_time): steps_left = self.QPbar.maximum() - self.QPbar.value() self.QPbar.setValue(self.QPbar.value() + steps_left) - txt = html_utils.paragraph(f"{txt}
    {myutils.get_salute_string()}") + txt = html_utils.paragraph(f"{txt}
    {utils.get_salute_string()}") self.progressLabel.setText(short_txt) msg = widgets.myMessageBox(self, wrapText=False) msg.information( diff --git a/cellacdc/segmenters/BABY/acdcSegment.py b/cellacdc/segmenters/BABY/acdcSegment.py index abea25034..d494f62c1 100644 --- a/cellacdc/segmenters/BABY/acdcSegment.py +++ b/cellacdc/segmenters/BABY/acdcSegment.py @@ -3,7 +3,7 @@ from baby import modelsets from baby import BabyCrawler -from cellacdc import myutils +from cellacdc import utils from cellacdc.trackers import BABY from cellacdc.trackers.BABY import BABY_tracker diff --git a/cellacdc/segmenters/Cellpose_germlineNuclei/__init__.py b/cellacdc/segmenters/Cellpose_germlineNuclei/__init__.py index 6a2be49ea..a9ac3e8ce 100644 --- a/cellacdc/segmenters/Cellpose_germlineNuclei/__init__.py +++ b/cellacdc/segmenters/Cellpose_germlineNuclei/__init__.py @@ -1,3 +1,3 @@ -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_cellpose() +utils.check_install_cellpose() diff --git a/cellacdc/segmenters/DeepSea/__init__.py b/cellacdc/segmenters/DeepSea/__init__.py index 66748c7b2..c89b5267e 100644 --- a/cellacdc/segmenters/DeepSea/__init__.py +++ b/cellacdc/segmenters/DeepSea/__init__.py @@ -3,18 +3,18 @@ import numpy as np -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_torch() -myutils.check_install_package("deepsea") -myutils.check_install_package("munkres") +utils.check_install_torch() +utils.check_install_package("deepsea") +utils.check_install_package("munkres") import torch import torchvision.transforms as transforms from PIL import Image -_, deepsea_segmenters_path = myutils.get_model_path("deepsea", create_temp_dir=False) +_, deepsea_segmenters_path = utils.get_model_path("deepsea", create_temp_dir=False) image_size = [383, 512] image_means = [0.5] diff --git a/cellacdc/segmenters/DeepSea/acdcSegment.py b/cellacdc/segmenters/DeepSea/acdcSegment.py index 0ff05f9ae..ece0496ce 100644 --- a/cellacdc/segmenters/DeepSea/acdcSegment.py +++ b/cellacdc/segmenters/DeepSea/acdcSegment.py @@ -10,7 +10,7 @@ import skimage.measure from deepsea.model import DeepSeaSegmentation -from cellacdc import myutils, printl +from cellacdc import utils, printl from . import _init_model from . import _get_segm_transforms diff --git a/cellacdc/segmenters/InstanSeg/__init__.py b/cellacdc/segmenters/InstanSeg/__init__.py index f3245d987..3fc7f61e3 100644 --- a/cellacdc/segmenters/InstanSeg/__init__.py +++ b/cellacdc/segmenters/InstanSeg/__init__.py @@ -1,5 +1,5 @@ -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_instanseg() +utils.check_install_instanseg() INSTANSEG_MODELS = ("fluorescence_nuclei_and_cells", "brightfield_nuclei") diff --git a/cellacdc/segmenters/InstanSeg/acdcSegment.py b/cellacdc/segmenters/InstanSeg/acdcSegment.py index 569ce1ef2..da6a8e2f4 100644 --- a/cellacdc/segmenters/InstanSeg/acdcSegment.py +++ b/cellacdc/segmenters/InstanSeg/acdcSegment.py @@ -2,7 +2,7 @@ from instanseg import InstanSeg -from ... import myutils, printl +from ... import utils, printl from ..._types import SecondChannelImage from . import INSTANSEG_MODELS @@ -36,11 +36,11 @@ def __init__( model_type = custom_model_type if device == "Auto": - device = myutils.get_torch_device(gpu=True) + device = utils.get_torch_device(gpu=True) elif device == "CPU": device = "cpu" elif device == "GPU": - device = myutils.get_torch_device(gpu=True) + device = utils.get_torch_device(gpu=True) self.model = InstanSeg(model_type, device=device, verbosity=verbosity) @@ -49,7 +49,7 @@ def preprocess(self, image, rescale_intensities, warn=True): image_min = image - image.min() image_float = image_min / image_min.max() else: - image_float = myutils.img_to_float(image, warn=warn) + image_float = utils.img_to_float(image, warn=warn) return (image_float * 255).astype(np.uint8) diff --git a/cellacdc/segmenters/StarDist/__init__.py b/cellacdc/segmenters/StarDist/__init__.py index 967aa9367..626441990 100755 --- a/cellacdc/segmenters/StarDist/__init__.py +++ b/cellacdc/segmenters/StarDist/__init__.py @@ -2,7 +2,7 @@ import sys import subprocess -from cellacdc import myutils +from cellacdc import utils note = "" if sys.platform == "darwin": @@ -16,9 +16,9 @@

    """ -myutils.check_install_package("tensorflow", note=note) -myutils.check_install_package("numpy", max_version="2.0.0") -myutils.check_install_package("stardist") +utils.check_install_package("tensorflow", note=note) +utils.check_install_package("numpy", max_version="2.0.0") +utils.check_install_package("stardist") import sys import tensorflow diff --git a/cellacdc/segmenters/YeaZ/__init__.py b/cellacdc/segmenters/YeaZ/__init__.py index c284d64cd..0b298b89f 100755 --- a/cellacdc/segmenters/YeaZ/__init__.py +++ b/cellacdc/segmenters/YeaZ/__init__.py @@ -1,3 +1,3 @@ -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_package("tensorflow", max_version="2.17") +utils.check_install_package("tensorflow", max_version="2.17") diff --git a/cellacdc/segmenters/YeaZ/acdcSegment.py b/cellacdc/segmenters/YeaZ/acdcSegment.py index c1b38b0ec..a37b4c4bc 100755 --- a/cellacdc/segmenters/YeaZ/acdcSegment.py +++ b/cellacdc/segmenters/YeaZ/acdcSegment.py @@ -14,7 +14,7 @@ from tensorflow import keras from tqdm import tqdm -from cellacdc import myutils +from cellacdc import utils from cellacdc import user_profile_path @@ -61,7 +61,7 @@ def yeaz_preprocess(self, image, tqdm_pbar=None, warn=True): # image = skimage.filters.gaussian(image, sigma=1) # image = skimage.exposure.equalize_adapthist(image) # image = image/image.max() - image = myutils.img_to_float(image, warn=warn) + image = utils.img_to_float(image, warn=warn) image = skimage.exposure.equalize_adapthist(image) if tqdm_pbar is not None: tqdm_pbar.emit(1) diff --git a/cellacdc/segmenters/YeaZ_v2/__init__.py b/cellacdc/segmenters/YeaZ_v2/__init__.py index 01af56e4b..94d05b57d 100644 --- a/cellacdc/segmenters/YeaZ_v2/__init__.py +++ b/cellacdc/segmenters/YeaZ_v2/__init__.py @@ -1,14 +1,14 @@ import os -from cellacdc import myutils, load +from cellacdc import utils, load -myutils.check_install_yeaz() +utils.check_install_yeaz() custom_weights_json_filename = "custom_weights_name_filepath.json" def add_model_filepath(name: str, filepath: os.PathLike): - _, model_folderpath = myutils.get_model_path("YeaZ_v2", create_temp_dir=False) + _, model_folderpath = utils.get_model_path("YeaZ_v2", create_temp_dir=False) custom_weights_json_file = os.path.join( model_folderpath, custom_weights_json_filename ) @@ -29,7 +29,7 @@ def load_models_filepath(): "Bright-field": "weights_budding_BF_multilab_0_1", "Fission yeast": "weights_fission_multilab_0_2", } - _, model_folderpath = myutils.get_model_path("YeaZ_v2", create_temp_dir=False) + _, model_folderpath = utils.get_model_path("YeaZ_v2", create_temp_dir=False) mapper = { name: os.path.join(model_folderpath, filename) for name, filename in mapper.items() diff --git a/cellacdc/segmenters/YeaZ_v2/acdcSegment.py b/cellacdc/segmenters/YeaZ_v2/acdcSegment.py index 754280024..208396176 100644 --- a/cellacdc/segmenters/YeaZ_v2/acdcSegment.py +++ b/cellacdc/segmenters/YeaZ_v2/acdcSegment.py @@ -12,7 +12,7 @@ from yeaz.unet import segment as yeaz_segment import yeaz.unet.neural_network as nn -from cellacdc import myutils, printl, load +from cellacdc import utils, printl, load from . import load_models_filepath @@ -143,7 +143,7 @@ def _segment_img_2D(self, image, thresh_val=0.0, min_distance=10, warn=True): return lab.astype(np.uint32) def _preprocess_image(self, image, tqdm_pbar=None, warn=True): - image = myutils.img_to_float(image, warn=warn) + image = utils.img_to_float(image, warn=warn) image = skimage.exposure.equalize_adapthist(image) if tqdm_pbar is not None: tqdm_pbar.emit(1) diff --git a/cellacdc/segmenters/YeastMate/__init__.py b/cellacdc/segmenters/YeastMate/__init__.py index 282f9918d..1276f0781 100755 --- a/cellacdc/segmenters/YeastMate/__init__.py +++ b/cellacdc/segmenters/YeastMate/__init__.py @@ -42,9 +42,9 @@ if QCoreApplication.instance() is None: app = QApplication(sys.argv) - from cellacdc import myutils + from cellacdc import utils - cancel = myutils._install_package_msg("YeastMate") + cancel = utils._install_package_msg("YeastMate") if cancel: raise ModuleNotFoundError("User aborted YeastMate installation") diff --git a/cellacdc/segmenters/_cellpose_base/_directML.py b/cellacdc/segmenters/_cellpose_base/_directML.py index 246cd8845..fafde0b0e 100644 --- a/cellacdc/segmenters/_cellpose_base/_directML.py +++ b/cellacdc/segmenters/_cellpose_base/_directML.py @@ -1,5 +1,5 @@ from cellacdc import printl -from cellacdc.myutils import check_install_package +from cellacdc.utils import check_install_package import sys diff --git a/cellacdc/segmenters/_cellpose_base/acdcSegment.py b/cellacdc/segmenters/_cellpose_base/acdcSegment.py index 3d6040894..910a17f2e 100644 --- a/cellacdc/segmenters/_cellpose_base/acdcSegment.py +++ b/cellacdc/segmenters/_cellpose_base/acdcSegment.py @@ -4,7 +4,7 @@ from typing import Tuple -from cellacdc import printl, myutils, core +from cellacdc import printl, utils, core import inspect @@ -109,7 +109,7 @@ def initConstants(self, is_rgb=False): self.img_ndim = None self.z_axis = None self.channel_axis = None - self.cp_version = myutils.get_cellpose_major_version() + self.cp_version = utils.get_cellpose_major_version() self._sizemodelnotfound = True self.batch_size = None self.printed_model_params = False @@ -155,7 +155,7 @@ def _eval(self, image, **kwargs): print(f"Image min: {sample_img.min()}, max: {sample_img.max()}") self.printed_model_params = True - out, removed_kwargs = myutils.try_kwargs(self.model.eval, image, **kwargs) + out, removed_kwargs = utils.try_kwargs(self.model.eval, image, **kwargs) segm = out[0] if removed_kwargs: print( @@ -660,7 +660,7 @@ def _initialize_image( def check_directml_gpu_gpu(model_name, directml_gpu, gpu, ask_install=True): if ask_install: - proceed, available_frameworks_list = myutils.check_gpu_available( + proceed, available_frameworks_list = utils.check_gpu_available( model_name, use_gpu=(gpu or directml_gpu), cuda=gpu, diff --git a/cellacdc/segmenters/cellpose_v2/__init__.py b/cellacdc/segmenters/cellpose_v2/__init__.py index 6abf1af8c..848f98ccc 100644 --- a/cellacdc/segmenters/cellpose_v2/__init__.py +++ b/cellacdc/segmenters/cellpose_v2/__init__.py @@ -1,6 +1,6 @@ -import cellacdc.myutils as myutils +import cellacdc.utils as utils -myutils.check_install_cellpose(2) +utils.check_install_cellpose(2) class AvailableModelsv2: diff --git a/cellacdc/segmenters/cellpose_v2/acdcSegment.py b/cellacdc/segmenters/cellpose_v2/acdcSegment.py index 3003ca11e..13412f330 100644 --- a/cellacdc/segmenters/cellpose_v2/acdcSegment.py +++ b/cellacdc/segmenters/cellpose_v2/acdcSegment.py @@ -1,13 +1,13 @@ import os from cellacdc.segmenters._cellpose_base.acdcSegment import Model as CellposeBaseModel import torch -from cellacdc import myutils +from cellacdc import utils from . import AvailableModelsv2 class Model(CellposeBaseModel): def __new__(cls, *args, **kwargs): - myutils.check_install_cellpose(2) + utils.check_install_cellpose(2) return super().__new__(cls) def __init__( @@ -61,7 +61,7 @@ def __init__( """ self.init_successful = False self.initConstants() - model_type, model_path, device = myutils.translateStrNone( + model_type, model_path, device = utils.translateStrNone( model_type, model_path, device ) diff --git a/cellacdc/segmenters/cellpose_v3/__init__.py b/cellacdc/segmenters/cellpose_v3/__init__.py index 93806ca14..4b39378a4 100644 --- a/cellacdc/segmenters/cellpose_v3/__init__.py +++ b/cellacdc/segmenters/cellpose_v3/__init__.py @@ -1,6 +1,6 @@ -import cellacdc.myutils as myutils +import cellacdc.utils as utils -myutils.check_install_cellpose(3) +utils.check_install_cellpose(3) class AvailableModelsv3: diff --git a/cellacdc/segmenters/cellpose_v3/_denoise.py b/cellacdc/segmenters/cellpose_v3/_denoise.py index fc11cd3ca..33a98b700 100644 --- a/cellacdc/segmenters/cellpose_v3/_denoise.py +++ b/cellacdc/segmenters/cellpose_v3/_denoise.py @@ -5,7 +5,7 @@ from cellpose.denoise import DenoiseModel from . import AvailableModelsv3Denoise import os -from cellacdc import myutils +from cellacdc import utils from cellacdc.segmenters._cellpose_base.acdcSegment import ( _initialize_image, @@ -104,7 +104,7 @@ def __init__( ) = check_deal_with_second_channel(deal_with_second_channel, is_rgb) self.batch_size = batch_size - denoise_model, denoise_model_path, device = myutils.translateStrNone( + denoise_model, denoise_model_path, device = utils.translateStrNone( denoise_model, denoise_model_path, device ) directml_gpu, gpu = cpu_gpu_directml_gpu( diff --git a/cellacdc/segmenters/cellpose_v3/acdcSegment.py b/cellacdc/segmenters/cellpose_v3/acdcSegment.py index 6578ddddb..b65c33a24 100644 --- a/cellacdc/segmenters/cellpose_v3/acdcSegment.py +++ b/cellacdc/segmenters/cellpose_v3/acdcSegment.py @@ -1,6 +1,6 @@ import os -from cellacdc import myutils, printl +from cellacdc import utils, printl from cellacdc.segmenters._cellpose_base.acdcSegment import Model as CellposeBaseModel from cellacdc.segmenters._cellpose_base.acdcSegment import ( BackboneOptions, @@ -23,7 +23,7 @@ class Model(CellposeBaseModel): def __new__(cls, *args, **kwargs): - myutils.check_install_cellpose(3) + utils.check_install_cellpose(3) return super().__new__(cls) def __init__( @@ -107,7 +107,7 @@ def __init__( self.batch_size = batch_size self.init_successful = False - out = myutils.translateStrNone( + out = utils.translateStrNone( model_type, model_path, device, denoise_model, denoise_model_path ) model_type, model_path, device, denoise_model, denoise_model_path = out diff --git a/cellacdc/segmenters/cellpose_v4/__init__.py b/cellacdc/segmenters/cellpose_v4/__init__.py index b0904b5bf..90b83d418 100644 --- a/cellacdc/segmenters/cellpose_v4/__init__.py +++ b/cellacdc/segmenters/cellpose_v4/__init__.py @@ -1,6 +1,6 @@ -import cellacdc.myutils as myutils +import cellacdc.utils as utils -myutils.check_install_cellpose(4) +utils.check_install_cellpose(4) class AvailableModelsv4: diff --git a/cellacdc/segmenters/cellpose_v4/acdcSegment.py b/cellacdc/segmenters/cellpose_v4/acdcSegment.py index 7e8a6e607..2c0291694 100644 --- a/cellacdc/segmenters/cellpose_v4/acdcSegment.py +++ b/cellacdc/segmenters/cellpose_v4/acdcSegment.py @@ -1,5 +1,5 @@ import os -from cellacdc import myutils, printl +from cellacdc import utils, printl import torch from cellacdc.segmenters._cellpose_base.acdcSegment import ( Model as CellposeBaseModel, @@ -14,7 +14,7 @@ class Model(CellposeBaseModel): def __new__(cls, *args, **kwargs): - myutils.check_install_cellpose(4) + utils.check_install_cellpose(4) return super().__new__(cls) def __init__( @@ -55,7 +55,7 @@ def __init__( self.init_successful = False self.initConstants() self.batch_size = batch_size - model_type, model_path, device = myutils.translateStrNone( + model_type, model_path, device = utils.translateStrNone( model_type, model_path, device ) @@ -77,7 +77,7 @@ def __init__( model_path = model_path or model_type - major_version = myutils.get_cellpose_major_version() + major_version = utils.get_cellpose_major_version() print(f"Initializing Cellpose v{major_version}...") from cellpose import models diff --git a/cellacdc/segmenters/cellsam/__init__.py b/cellacdc/segmenters/cellsam/__init__.py index 3dedb4691..c40d16f0c 100644 --- a/cellacdc/segmenters/cellsam/__init__.py +++ b/cellacdc/segmenters/cellsam/__init__.py @@ -1,6 +1,6 @@ -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_cellsam() +utils.check_install_cellsam() import cellSAM diff --git a/cellacdc/segmenters/cellsam/acdcSegment.py b/cellacdc/segmenters/cellsam/acdcSegment.py index 555bdb788..6d64afd11 100644 --- a/cellacdc/segmenters/cellsam/acdcSegment.py +++ b/cellacdc/segmenters/cellsam/acdcSegment.py @@ -10,7 +10,7 @@ from cellSAM.cellsam_pipeline import cellsam_pipeline, normalize_image from cellSAM.wsi import segment_wsi -from cellacdc import myutils, printl +from cellacdc import utils, printl class AvailableModels: @@ -115,7 +115,7 @@ def __init__( self.postprocess = postprocess self.remove_boundaries = remove_boundaries - model_path = myutils.translateStrNone(model_path)[0] + model_path = utils.translateStrNone(model_path)[0] if model_path: print(f"Loading CellSAM model from {model_path}...") diff --git a/cellacdc/segmenters/delta/__init__.py b/cellacdc/segmenters/delta/__init__.py index dd1955b17..f18c9391d 100644 --- a/cellacdc/segmenters/delta/__init__.py +++ b/cellacdc/segmenters/delta/__init__.py @@ -4,6 +4,6 @@ @author: jroberts / jamesr787 """ -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_package("delta", pypi_name="delta2") +utils.check_install_package("delta", pypi_name="delta2") diff --git a/cellacdc/segmenters/omnipose/__init__.py b/cellacdc/segmenters/omnipose/__init__.py index 22a77bbab..d9ffcd128 100644 --- a/cellacdc/segmenters/omnipose/__init__.py +++ b/cellacdc/segmenters/omnipose/__init__.py @@ -2,6 +2,6 @@ import sys import subprocess -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_omnipose() +utils.check_install_omnipose() diff --git a/cellacdc/segmenters/omnipose_custom/__init__.py b/cellacdc/segmenters/omnipose_custom/__init__.py index bb4a8ceef..a0b504c6f 100644 --- a/cellacdc/segmenters/omnipose_custom/__init__.py +++ b/cellacdc/segmenters/omnipose_custom/__init__.py @@ -2,6 +2,6 @@ import sys import subprocess -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_package("omnipose_acdc") +utils.check_install_package("omnipose_acdc") diff --git a/cellacdc/segmenters/pomBseen/__init__.py b/cellacdc/segmenters/pomBseen/__init__.py index 148dd7e30..6b779a3d1 100644 --- a/cellacdc/segmenters/pomBseen/__init__.py +++ b/cellacdc/segmenters/pomBseen/__init__.py @@ -1,3 +1,3 @@ -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_package("pombseen", pypi_name="pomBseen") +utils.check_install_package("pombseen", pypi_name="pomBseen") diff --git a/cellacdc/segmenters/pomBseen_nuclear/__init__.py b/cellacdc/segmenters/pomBseen_nuclear/__init__.py index 148dd7e30..6b779a3d1 100644 --- a/cellacdc/segmenters/pomBseen_nuclear/__init__.py +++ b/cellacdc/segmenters/pomBseen_nuclear/__init__.py @@ -1,3 +1,3 @@ -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_package("pombseen", pypi_name="pomBseen") +utils.check_install_package("pombseen", pypi_name="pomBseen") diff --git a/cellacdc/segmenters/sam2/__init__.py b/cellacdc/segmenters/sam2/__init__.py index 8990fdebd..97fee8a40 100644 --- a/cellacdc/segmenters/sam2/__init__.py +++ b/cellacdc/segmenters/sam2/__init__.py @@ -1,6 +1,6 @@ -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_sam2() +utils.check_install_sam2() import sam2 import os @@ -8,7 +8,7 @@ # Get SAM2 models path # Using the same pattern as segment_anything -_, sam_segmenters_path = myutils.get_model_path("sam2", create_temp_dir=False) +_, sam_segmenters_path = utils.get_model_path("sam2", create_temp_dir=False) # SAM2 model configurations # Format: 'Display Name': ('config_file', 'checkpoint_filename') diff --git a/cellacdc/segmenters/sam2/acdcSegment.py b/cellacdc/segmenters/sam2/acdcSegment.py index 92ba5bca9..bd47c1838 100644 --- a/cellacdc/segmenters/sam2/acdcSegment.py +++ b/cellacdc/segmenters/sam2/acdcSegment.py @@ -14,7 +14,7 @@ from sam2.sam2_image_predictor import SAM2ImagePredictor from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator -from cellacdc import myutils, widgets, printl +from cellacdc import utils, widgets, printl class AvailableModels: @@ -395,7 +395,7 @@ def _get_input_points(self, is_z_stack, df_points): def _init_embeddings(self, img_rgb): if img_rgb.ndim == 2: - img_rgb = myutils.to_uint8(img_rgb) + img_rgb = utils.to_uint8(img_rgb) img_rgb = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2RGB) # Create embeddings only if new image @@ -422,7 +422,7 @@ def _segment_2D_image( automatic_removal_of_background: bool = False, ) -> np.ndarray: - img = myutils.to_uint8(image) + img = utils.to_uint8(image) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) labels = np.zeros(image.shape[:2], dtype=np.uint32) diff --git a/cellacdc/segmenters/segment_anything/__init__.py b/cellacdc/segmenters/segment_anything/__init__.py index c62634229..c0aa3abcf 100644 --- a/cellacdc/segmenters/segment_anything/__init__.py +++ b/cellacdc/segmenters/segment_anything/__init__.py @@ -1,11 +1,11 @@ -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_segment_anything() +utils.check_install_segment_anything() import os from cellacdc import segment_anything_weights_filenames -_, sam_segmenters_path = myutils.get_model_path( +_, sam_segmenters_path = utils.get_model_path( "segment_anything", create_temp_dir=False ) diff --git a/cellacdc/segmenters/segment_anything/acdcSegment.py b/cellacdc/segmenters/segment_anything/acdcSegment.py index 40c4c645c..aab4cd591 100644 --- a/cellacdc/segmenters/segment_anything/acdcSegment.py +++ b/cellacdc/segmenters/segment_anything/acdcSegment.py @@ -13,7 +13,7 @@ from . import model_types, sam_segmenters_path from segment_anything import sam_model_registry, SamAutomaticMaskGenerator, SamPredictor -from cellacdc import myutils, widgets, printl +from cellacdc import utils, widgets, printl class AvailableModels: @@ -398,7 +398,7 @@ def _get_input_points(self, is_z_stack, df_points): def _init_embeddings(self, img_rgb): if img_rgb.ndim == 2: - img_rgb = myutils.to_uint8(img_rgb) + img_rgb = utils.to_uint8(img_rgb) img_rgb = cv2.cvtColor(img_rgb, cv2.COLOR_BGR2RGB) # Create embeddings only if new image @@ -425,7 +425,7 @@ def _segment_2D_image( automatic_removal_of_background: bool = False, ) -> np.ndarray: - img = myutils.to_uint8(image) + img = utils.to_uint8(image) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) labels = np.zeros(image.shape[:2], dtype=np.uint32) diff --git a/cellacdc/segmenters_promptable/micro-sam/__init__.py b/cellacdc/segmenters_promptable/micro-sam/__init__.py index 42233bf05..eb89a63d9 100644 --- a/cellacdc/segmenters_promptable/micro-sam/__init__.py +++ b/cellacdc/segmenters_promptable/micro-sam/__init__.py @@ -1,3 +1,3 @@ -import cellacdc.myutils as myutils +import cellacdc.utils as utils -myutils.check_install_microsam() +utils.check_install_microsam() diff --git a/cellacdc/segmenters_promptable/nnInteractive/__init__.py b/cellacdc/segmenters_promptable/nnInteractive/__init__.py index da985eec7..71baa747f 100644 --- a/cellacdc/segmenters_promptable/nnInteractive/__init__.py +++ b/cellacdc/segmenters_promptable/nnInteractive/__init__.py @@ -1,3 +1,3 @@ -import cellacdc.myutils as myutils +import cellacdc.utils as utils -myutils.check_install_nnInteractive() +utils.check_install_nnInteractive() diff --git a/cellacdc/segmenters_promptable/nnInteractive/acdcPromptSegment.py b/cellacdc/segmenters_promptable/nnInteractive/acdcPromptSegment.py index 398a5e37f..f7ad7faaa 100644 --- a/cellacdc/segmenters_promptable/nnInteractive/acdcPromptSegment.py +++ b/cellacdc/segmenters_promptable/nnInteractive/acdcPromptSegment.py @@ -8,7 +8,7 @@ import torch from cellacdc import user_profile_path -from cellacdc import myutils +from cellacdc import utils from cellacdc import printl from huggingface_hub import snapshot_download @@ -60,7 +60,7 @@ def __init__( device = None if device is None: - device = myutils.get_torch_device(gpu=run_on == "gpu") + device = utils.get_torch_device(gpu=run_on == "gpu") self.model = nnInteractiveInferenceSession( device=device, # Set inference device diff --git a/cellacdc/segmenters_promptable/sam2/__init__.py b/cellacdc/segmenters_promptable/sam2/__init__.py index 554a3b083..be442a127 100644 --- a/cellacdc/segmenters_promptable/sam2/__init__.py +++ b/cellacdc/segmenters_promptable/sam2/__init__.py @@ -1,3 +1,3 @@ -import cellacdc.myutils as myutils +import cellacdc.utils as utils -myutils.check_install_sam2() +utils.check_install_sam2() diff --git a/cellacdc/segmenters_promptable/sam2/acdcPromptSegment.py b/cellacdc/segmenters_promptable/sam2/acdcPromptSegment.py index 3b75cfe28..9d0e0e1da 100644 --- a/cellacdc/segmenters_promptable/sam2/acdcPromptSegment.py +++ b/cellacdc/segmenters_promptable/sam2/acdcPromptSegment.py @@ -9,7 +9,7 @@ from sam2.build_sam import build_sam2 from sam2.sam2_image_predictor import SAM2ImagePredictor -from cellacdc import myutils +from cellacdc import utils from cellacdc.segmenters.sam2 import model_types, sam_segmenters_path @@ -67,7 +67,7 @@ def _normalize_prompt(self, prompt): return int(z), float(y), float(x) def _to_rgb(self, image): - img = myutils.to_uint8(image) + img = utils.to_uint8(image) if img.ndim == 2: try: img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) diff --git a/cellacdc/segmenters_promptable/segment_anything/__init__.py b/cellacdc/segmenters_promptable/segment_anything/__init__.py index 14bd9dda0..d7cbb0acc 100644 --- a/cellacdc/segmenters_promptable/segment_anything/__init__.py +++ b/cellacdc/segmenters_promptable/segment_anything/__init__.py @@ -1,3 +1,3 @@ -import cellacdc.myutils as myutils +import cellacdc.utils as utils -myutils.check_install_segment_anything() +utils.check_install_segment_anything() diff --git a/cellacdc/segmenters_promptable/segment_anything/acdcPromptSegment.py b/cellacdc/segmenters_promptable/segment_anything/acdcPromptSegment.py index 437e1fa2c..922bd4250 100644 --- a/cellacdc/segmenters_promptable/segment_anything/acdcPromptSegment.py +++ b/cellacdc/segmenters_promptable/segment_anything/acdcPromptSegment.py @@ -8,7 +8,7 @@ from segment_anything import sam_model_registry, SamPredictor -from cellacdc import myutils +from cellacdc import utils from cellacdc.segmenters.segment_anything import model_types, sam_segmenters_path @@ -65,7 +65,7 @@ def _normalize_prompt(self, prompt): return int(z), float(y), float(x) def _to_rgb(self, image): - img = myutils.to_uint8(image) + img = utils.to_uint8(image) if img.ndim == 2: try: img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) diff --git a/cellacdc/test_segm_model.py b/cellacdc/test_segm_model.py index cbbefc21d..02e9bf9f1 100755 --- a/cellacdc/test_segm_model.py +++ b/cellacdc/test_segm_model.py @@ -8,7 +8,7 @@ from importlib import import_module from cellacdc._run import _setup_app -from cellacdc import apps, myutils, widgets, data, core, load +from cellacdc import apps, utils, widgets, data, core, load from cellacdc import prompts import skimage.color @@ -54,13 +54,13 @@ if test_data is None: tif_filepath, _ = qtpy.compat.getopenfilename( - basedir=myutils.getMostRecentPath(), filters=("Images (*.tif)") + basedir=utils.getMostRecentPath(), filters=("Images (*.tif)") ) if not tif_filepath: exit("Execution cancelled.") images_path = os.path.dirname(tif_filepath) - basename = os.path.commonprefix(myutils.listdir(images_path)) + basename = os.path.commonprefix(utils.listdir(images_path)) filename, ext = os.path.splitext(os.path.basename(tif_filepath)) channel = filename[len(basename) :] posData = load.loadData(tif_filepath, channel) @@ -85,7 +85,7 @@ imshow(img) cellacdc_path = os.path.dirname(os.path.abspath(__file__)) -models = myutils.get_list_of_models() +models = utils.get_list_of_models() win = widgets.QDialogListbox( "Select model", "Select model to use for segmentation: ", @@ -105,10 +105,10 @@ downloadWin.download() # Load model as a module -acdcSegment = myutils.import_segment_module(model_name) +acdcSegment = utils.import_segment_module(model_name) # Read all models parameters -init_params, segment_params = myutils.getModelArgSpec(acdcSegment) +init_params, segment_params = utils.getModelArgSpec(acdcSegment) # Prompt user to enter the model parameters try: @@ -138,7 +138,7 @@ segm_data = np.load(segm_filepath)["arr_0"] -model = myutils.init_segm_model(acdcSegment, posData, win.init_kwargs) +model = utils.init_segm_model(acdcSegment, posData, win.init_kwargs) if model is None: sys.exit("Segmentation model was not initialized correctly!") is_segment3DT_available = any([name == "segment3DT" for name in dir(model)]) diff --git a/cellacdc/test_tracker.py b/cellacdc/test_tracker.py index 8b2dd9711..b54c03913 100644 --- a/cellacdc/test_tracker.py +++ b/cellacdc/test_tracker.py @@ -2,7 +2,7 @@ import sys import numpy as np import skimage.measure -from cellacdc import core, myutils, widgets, load, html_utils +from cellacdc import core, utils, widgets, load, html_utils from cellacdc import data, data_path from cellacdc import transformation from cellacdc.plot import imshow @@ -40,13 +40,13 @@ if test_data is None: tif_filepath, _ = qtpy.compat.getopenfilename( - basedir=myutils.getMostRecentPath(), filters=("Images (*.tif)") + basedir=utils.getMostRecentPath(), filters=("Images (*.tif)") ) if not tif_filepath: exit("Execution cancelled.") images_path = os.path.dirname(tif_filepath) - basename = os.path.commonprefix(myutils.listdir(images_path)) + basename = os.path.commonprefix(utils.listdir(images_path)) filename, ext = os.path.splitext(os.path.basename(tif_filepath)) channel = filename[len(basename) :] posData = load.loadData(tif_filepath, channel) @@ -63,7 +63,7 @@ imshow(lab_stack, axis_titles=["Before tracking"], annotate_labels_idxs=[0]) -trackers = myutils.get_list_of_trackers() +trackers = utils.get_list_of_trackers() txt = html_utils.paragraph(""" Select the tracker to use

    """) @@ -78,7 +78,7 @@ trackerName = win.selectedItemsText[0] # Load tracker -tracker, track_params = myutils.init_tracker( +tracker, track_params = utils.init_tracker( posData, trackerName, qparent=None, realTime=REAL_TIME_TRACKER ) if track_params is None: diff --git a/cellacdc/tools/__init__.py b/cellacdc/tools/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/cellacdc/utils/acdcToSymDiv.py b/cellacdc/tools/acdcToSymDiv.py similarity index 96% rename from cellacdc/utils/acdcToSymDiv.py rename to cellacdc/tools/acdcToSymDiv.py index 551244569..0d2067acf 100644 --- a/cellacdc/utils/acdcToSymDiv.py +++ b/cellacdc/tools/acdcToSymDiv.py @@ -9,7 +9,7 @@ from qtpy.QtCore import Signal, QThread from qtpy.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QStyle -from .. import widgets, apps, workers, html_utils, myutils, gui, load, printl +from .. import widgets, apps, workers, html_utils, utils, gui, load, printl class AcdcToSymDivUtil(QDialog): @@ -19,7 +19,7 @@ def __init__(self, expPaths, app, parent=None): self.parent = parent - logger, logs_path, log_path, log_filename = myutils.setupLogger( + logger, logs_path, log_path, log_filename = utils.setupLogger( module="utils.AcdcToSymDiv" ) self.logger = logger @@ -125,10 +125,10 @@ def selectSegmFileLoadData(self, exp_path, pos_foldernames): for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames(images_path) + basename, chNames = utils.getBasenameAndChNames(images_path) # Use first found channel, it doesn't matter for metrics for chName in chNames: - filePath = myutils.getChannelFilePath(images_path, chName) + filePath = utils.getChannelFilePath(images_path, chName) if filePath: break else: diff --git a/cellacdc/utils/align.py b/cellacdc/tools/align.py similarity index 97% rename from cellacdc/utils/align.py rename to cellacdc/tools/align.py index 70aafa2f3..9cb5f3ee5 100755 --- a/cellacdc/utils/align.py +++ b/cellacdc/tools/align.py @@ -2,7 +2,7 @@ from qtpy.QtWidgets import QFileDialog -from .. import apps, myutils, workers, widgets, html_utils +from .. import apps, utils, workers, widgets, html_utils from .base import NewThreadMultipleExpBaseUtil @@ -17,7 +17,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) diff --git a/cellacdc/utils/applyTrackFromTable.py b/cellacdc/tools/applyTrackFromTable.py similarity index 94% rename from cellacdc/utils/applyTrackFromTable.py rename to cellacdc/tools/applyTrackFromTable.py index eba0b55bb..49bffed78 100644 --- a/cellacdc/utils/applyTrackFromTable.py +++ b/cellacdc/tools/applyTrackFromTable.py @@ -4,8 +4,8 @@ import pandas as pd from .. import exception_handler -from .. import myutils, apps, widgets, html_utils, printl, workers -from ..utils import base +from .. import utils, apps, widgets, html_utils, printl, workers +from . import base from qtpy.QtWidgets import QFileDialog @@ -14,7 +14,7 @@ class ApplyTrackingInfoFromTableUtil(base.MainThreadSinglePosUtilBase): def __init__( self, app, title: str, infoText: str, parent=None, callbackOnFinished=None ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__(app, title, module, infoText, parent) self.sigClose.connect(self.close) @@ -70,7 +70,7 @@ def run(self, posPath): imagesPath = os.path.join(posPath, "Images") segmFilename = [ f - for f in myutils.listdir(imagesPath) + for f in utils.listdir(imagesPath) if f.endswith(f"{endFilenameSegm}.npz") ][0] basename = os.path.splitext(segmFilename)[0] diff --git a/cellacdc/utils/applyTrackFromTrackMateXML.py b/cellacdc/tools/applyTrackFromTrackMateXML.py similarity index 95% rename from cellacdc/utils/applyTrackFromTrackMateXML.py rename to cellacdc/tools/applyTrackFromTrackMateXML.py index 30f758de2..ed91d1daf 100644 --- a/cellacdc/utils/applyTrackFromTrackMateXML.py +++ b/cellacdc/tools/applyTrackFromTrackMateXML.py @@ -4,9 +4,9 @@ import pandas as pd from .. import exception_handler -from .. import myutils, apps, widgets, html_utils, printl, workers +from .. import utils, apps, widgets, html_utils, printl, workers from .. import transformation, load -from ..utils import base +from . import base from qtpy.QtWidgets import QFileDialog @@ -15,7 +15,7 @@ class ApplyTrackingInfoFromTrackMateUtil(base.MainThreadSinglePosUtilBase): def __init__( self, app, title: str, infoText: str, parent=None, callbackOnFinished=None ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__(app, title, module, infoText, parent) self.sigClose.connect(self.close) @@ -76,7 +76,7 @@ def run(self, posPath): imagesPath = os.path.join(posPath, "Images") segmFilename = [ f - for f in myutils.listdir(imagesPath) + for f in utils.listdir(imagesPath) if f.endswith(f"{endFilenameSegm}.npz") ][0] basename = os.path.splitext(segmFilename)[0] diff --git a/cellacdc/utils/base.py b/cellacdc/tools/base.py similarity index 96% rename from cellacdc/utils/base.py rename to cellacdc/tools/base.py index 2962c79ca..9b9d5edbd 100644 --- a/cellacdc/utils/base.py +++ b/cellacdc/tools/base.py @@ -7,7 +7,7 @@ from qtpy.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel from qtpy import QtGui -from .. import exception_handler, myutils, html_utils, workers, widgets +from .. import exception_handler, utils, html_utils, workers, widgets from .. import _critical_exception_gui import os @@ -34,7 +34,7 @@ apps, workers, html_utils, - myutils, + utils, gui, load, printl, @@ -68,7 +68,7 @@ def __init__( self._parent = parent self.progressDialogueTitle = progressDialogueTitle - logger, logs_path, log_path, log_filename = myutils.setupLogger(module=module) + logger, logs_path, log_path, log_filename = utils.setupLogger(module=module) log_init_util(logger, expPaths, title, module) @@ -192,10 +192,10 @@ def selectAcdcOutputTables( for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames(images_path) + basename, chNames = utils.getBasenameAndChNames(images_path) # Use first found channel, it doesn't matter for basename for chName in chNames: - filePath = myutils.getChannelFilePath(images_path, chName) + filePath = utils.getChannelFilePath(images_path, chName) if filePath: break else: @@ -309,10 +309,10 @@ def _selectFileFromFilesWithText(self, exp_path, pos_foldernames, with_text, ext for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames(images_path) + basename, chNames = utils.getBasenameAndChNames(images_path) # Use first found channel, it doesn't matter for metrics for chName in chNames: - filePath = myutils.getChannelFilePath(images_path, chName) + filePath = utils.getChannelFilePath(images_path, chName) if filePath: break else: @@ -383,10 +383,10 @@ def selectSegmFileLoadData(self, exp_path, pos_foldernames): # for p, pos in enumerate(pos_foldernames): # pos_path = os.path.join(exp_path, pos) # images_path = os.path.join(pos_path, 'Images') - # basename, chNames = myutils.getBasenameAndChNames(images_path) + # basename, chNames = utils.getBasenameAndChNames(images_path) # # Use first found channel, it doesn't matter for metrics # for chName in chNames: - # filePath = myutils.getChannelFilePath(images_path, chName) + # filePath = utils.getChannelFilePath(images_path, chName) # if filePath: # break # else: @@ -519,7 +519,7 @@ def __init__( self._parent = parent - logger, logs_path, log_path, log_filename = myutils.setupLogger(module=module) + logger, logs_path, log_path, log_filename = utils.setupLogger(module=module) logger.info(f'Utility title: "{title}"') logger.info(f'Utility module: "{module}"') diff --git a/cellacdc/utils/combineChannels.py b/cellacdc/tools/combineChannels.py similarity index 95% rename from cellacdc/utils/combineChannels.py rename to cellacdc/tools/combineChannels.py index 419fb7846..ea1315e1d 100644 --- a/cellacdc/utils/combineChannels.py +++ b/cellacdc/tools/combineChannels.py @@ -2,7 +2,7 @@ import pandas as pd -from .. import apps, myutils, workers, widgets, html_utils, load +from .. import apps, utils, workers, widgets, html_utils, load from .. import printl from .base import NewThreadMultipleExpBaseUtil @@ -18,7 +18,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) @@ -39,7 +39,7 @@ def askSetup(self, expPaths): pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") self.images_paths.append(images_path) - basename, chNames_loc = myutils.getBasenameAndChNames(images_path) + basename, chNames_loc = utils.getBasenameAndChNames(images_path) segm_files = load.get_segm_files(images_path) segm_endnames = load.get_endnames(basename, segm_files) if i == 0 and j == 0: diff --git a/cellacdc/utils/compute.py b/cellacdc/tools/compute.py similarity index 98% rename from cellacdc/utils/compute.py rename to cellacdc/tools/compute.py index 26533e04e..48c14796a 100755 --- a/cellacdc/utils/compute.py +++ b/cellacdc/tools/compute.py @@ -18,7 +18,7 @@ apps, workers, html_utils, - myutils, + utils, gui, cca_functions, load, @@ -39,7 +39,7 @@ def __init__( title = "Compute measurements utility" infoText = "Computing measurements routine running..." progressDialogueTitle = "Computing measurements" - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) @@ -145,7 +145,7 @@ def askRunNowOrSaveToConfig(self, worker): return config_filename = win.filename - mostRecentPath = myutils.getMostRecentPath() + mostRecentPath = utils.getMostRecentPath() folder_path = apps.get_existing_directory( allow_images_path=False, parent=self.progressWin, @@ -268,10 +268,10 @@ def selectSegmFileLoadData(self, exp_path, pos_foldernames): for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames(images_path) + basename, chNames = utils.getBasenameAndChNames(images_path) # Use first found channel, it doesn't matter for metrics for chName in chNames: - filePath = myutils.getChannelFilePath(images_path, chName) + filePath = utils.getChannelFilePath(images_path, chName) if filePath: break else: diff --git a/cellacdc/utils/computeMultiChannel.py b/cellacdc/tools/computeMultiChannel.py similarity index 98% rename from cellacdc/utils/computeMultiChannel.py rename to cellacdc/tools/computeMultiChannel.py index f0801cb0b..b76164b59 100644 --- a/cellacdc/utils/computeMultiChannel.py +++ b/cellacdc/tools/computeMultiChannel.py @@ -1,6 +1,6 @@ import pandas as pd -from .. import apps, myutils, workers, widgets, html_utils, load +from .. import apps, utils, workers, widgets, html_utils, load from .base import NewThreadMultipleExpBaseUtil @@ -15,7 +15,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) diff --git a/cellacdc/utils/concat.py b/cellacdc/tools/concat.py similarity index 97% rename from cellacdc/utils/concat.py rename to cellacdc/tools/concat.py index c68f08984..069e56b2f 100755 --- a/cellacdc/utils/concat.py +++ b/cellacdc/tools/concat.py @@ -2,7 +2,7 @@ from cellacdc import measurements -from .. import apps, myutils, workers, widgets, html_utils +from .. import apps, utils, workers, widgets, html_utils from .. import printl from .base import NewThreadMultipleExpBaseUtil @@ -18,7 +18,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) @@ -41,7 +41,7 @@ def runWorker(self, format="CSV"): def askCopyCcaFromAcdcOutput(self, images_path): acdc_output_tables = [] - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): if not file.endswith(".csv"): continue @@ -198,7 +198,7 @@ def showEvent(self, event): self._ext = ".csv" else: self._ext = ".xlsx" - myutils.check_install_package( + utils.check_install_package( "OpenPyXL", import_pkg_name="openpyxl", pypi_name="XlsxWriter" ) self.runWorker(format=selectFormatWin.selectedItemsText[0]) @@ -235,7 +235,7 @@ def askFolderWhereToSaveAllExp(self, allExp_filename): self.worker.abort = True self.worker.waitCond.wakeAll() - mostRecentPath = myutils.getMostRecentPath() + mostRecentPath = utils.getMostRecentPath() save_to_dir = QFileDialog.getExistingDirectory( self, f"Select folder where to save multiple experiments table", diff --git a/cellacdc/utils/convert.py b/cellacdc/tools/convert.py similarity index 97% rename from cellacdc/utils/convert.py rename to cellacdc/tools/convert.py index 877134385..c18984d23 100755 --- a/cellacdc/utils/convert.py +++ b/cellacdc/tools/convert.py @@ -36,7 +36,7 @@ # Custom modules from .. import exception_handler, printl -from .. import prompts, load, myutils, apps, load, widgets, html_utils +from .. import prompts, load, utils, apps, load, widgets, html_utils from .. import workers from .. import cellacdc_path, recentPaths_path, settings_folderpath from .. import io @@ -137,7 +137,7 @@ def main(self): f'Cell-ACDC - Convert .{self.from_} to .{self.to} - "{exp_path}"' ) - folder_type = myutils.determine_folder_type(exp_path) + folder_type = utils.determine_folder_type(exp_path) is_pos_folder, is_images_folder, exp_path = folder_type print("Loading data...") @@ -207,7 +207,7 @@ def main(self): return for pos_i, images_path in enumerate(tqdm(images_paths, ncols=100)): - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) _basename = self.getBasename(images_path, selectedFilenames) for file in ls: if file.endswith(_endswith): @@ -239,7 +239,7 @@ def main(self): exit("Done.") def getBasename(self, images_path, selectedFilenames): - commonStartFilenames = myutils.filterCommonStart(images_path) + commonStartFilenames = utils.filterCommonStart(images_path) selector = prompts.select_channel_name() _, noBasename = selector.get_available_channels( commonStartFilenames, images_path, useExt=None @@ -294,7 +294,7 @@ def convert( if self.to == "npy": np.save(newPath, data) elif self.to == "tif": - myutils.to_tiff(newPath, data) + utils.to_tiff(newPath, data) elif self.to == "npz": io.savez_compressed(newPath, data) print("") @@ -309,7 +309,7 @@ def warnFileExisting(self, newFilePath): msg = widgets.myMessageBox(showCentered=False, wrapText=False) txt = html_utils.paragraph(f""" The following file is already existing:

    - {myutils.trim_path(newFilePath, depth=4)}

    + {utils.trim_path(newFilePath, depth=4)}

    What do you want to do? """) msg.addShowInFileManagerButton(newFilePath) @@ -392,7 +392,7 @@ def criticalNoCommonBasename(self, filenames, parent_path): msg.critical(self, "Name of selected file not compatible", txt) def selectFiles(self, images_path, filterExt=None): - files = myutils.listdir(images_path) + files = utils.listdir(images_path) if filterExt is not None: items = [] for file in files: @@ -482,7 +482,7 @@ class ImagesToPositions(QDialog): def __init__(self, parent=None) -> None: super().__init__(parent) - logger, logs_path, log_path, log_filename = myutils.setupLogger( + logger, logs_path, log_path, log_filename = utils.setupLogger( module="converter" ) @@ -571,7 +571,7 @@ def start(self): self.startButton.hide() self.stopButton.show() - MostRecentPath = myutils.getMostRecentPath() + MostRecentPath = utils.getMostRecentPath() folderPath = QFileDialog.getExistingDirectory( self, "Select folder containing images", MostRecentPath ) @@ -588,7 +588,7 @@ def start(self): self.stop() return - myutils.addToRecentPaths(tagertFolderPath, logger=self.logger) + utils.addToRecentPaths(tagertFolderPath, logger=self.logger) textToAppendInstructions = html_utils.paragraph( "Insert a name to append at the end of each new .tif file." diff --git a/cellacdc/utils/countObjects.py b/cellacdc/tools/countObjects.py similarity index 92% rename from cellacdc/utils/countObjects.py rename to cellacdc/tools/countObjects.py index e80df7431..5d412e0b7 100644 --- a/cellacdc/utils/countObjects.py +++ b/cellacdc/tools/countObjects.py @@ -1,4 +1,4 @@ -from .. import apps, myutils, workers, widgets, html_utils +from .. import apps, utils, workers, widgets, html_utils from .base import NewThreadMultipleExpBaseUtil @@ -13,7 +13,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) diff --git a/cellacdc/utils/createConnected3Dsegm.py b/cellacdc/tools/createConnected3Dsegm.py similarity index 95% rename from cellacdc/utils/createConnected3Dsegm.py rename to cellacdc/tools/createConnected3Dsegm.py index 864b4f656..3b4b97381 100644 --- a/cellacdc/utils/createConnected3Dsegm.py +++ b/cellacdc/tools/createConnected3Dsegm.py @@ -1,4 +1,4 @@ -from .. import apps, myutils, workers, widgets, html_utils +from .. import apps, utils, workers, widgets, html_utils from .base import NewThreadMultipleExpBaseUtil @@ -13,7 +13,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) diff --git a/cellacdc/utils/customPreprocess.py b/cellacdc/tools/customPreprocess.py similarity index 94% rename from cellacdc/utils/customPreprocess.py rename to cellacdc/tools/customPreprocess.py index 572d4071e..1e1ff95ff 100644 --- a/cellacdc/utils/customPreprocess.py +++ b/cellacdc/tools/customPreprocess.py @@ -2,7 +2,7 @@ import pandas as pd -from .. import apps, myutils, workers, widgets, html_utils, load, printl +from .. import apps, utils, workers, widgets, html_utils, load, printl from .base import NewThreadMultipleExpBaseUtil @@ -17,7 +17,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) @@ -36,7 +36,7 @@ def askSetupRecipe(self, exp_path, pos_foldernames): for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames(images_path) + basename, chNames = utils.getBasenameAndChNames(images_path) channel_names.update(chNames) if df_metadata is not None: continue diff --git a/cellacdc/utils/fillHolesInSegm.py b/cellacdc/tools/fillHolesInSegm.py similarity index 96% rename from cellacdc/utils/fillHolesInSegm.py rename to cellacdc/tools/fillHolesInSegm.py index 36f7ff516..6517a7e9a 100644 --- a/cellacdc/utils/fillHolesInSegm.py +++ b/cellacdc/tools/fillHolesInSegm.py @@ -1,4 +1,4 @@ -from .. import apps, myutils, workers, widgets, html_utils, load +from .. import apps, utils, workers, widgets, html_utils, load from .base import NewThreadMultipleExpBaseUtil import os @@ -14,7 +14,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) diff --git a/cellacdc/utils/filterObjFromCoordsTable.py b/cellacdc/tools/filterObjFromCoordsTable.py similarity index 96% rename from cellacdc/utils/filterObjFromCoordsTable.py rename to cellacdc/tools/filterObjFromCoordsTable.py index 70575fd03..230df7629 100644 --- a/cellacdc/utils/filterObjFromCoordsTable.py +++ b/cellacdc/tools/filterObjFromCoordsTable.py @@ -1,4 +1,4 @@ -from .. import apps, myutils, workers, widgets, html_utils +from .. import apps, utils, workers, widgets, html_utils from .base import NewThreadMultipleExpBaseUtil @@ -13,7 +13,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) diff --git a/cellacdc/utils/fromImageJroiToSegm.py b/cellacdc/tools/fromImageJroiToSegm.py similarity index 94% rename from cellacdc/utils/fromImageJroiToSegm.py rename to cellacdc/tools/fromImageJroiToSegm.py index 5017d2ca8..bb17bc7fd 100644 --- a/cellacdc/utils/fromImageJroiToSegm.py +++ b/cellacdc/tools/fromImageJroiToSegm.py @@ -1,4 +1,4 @@ -from .. import myutils, workers, widgets, html_utils +from .. import utils, workers, widgets, html_utils from .. import apps from .base import NewThreadMultipleExpBaseUtil @@ -14,7 +14,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) diff --git a/cellacdc/utils/fucciPreprocess.py b/cellacdc/tools/fucciPreprocess.py similarity index 95% rename from cellacdc/utils/fucciPreprocess.py rename to cellacdc/tools/fucciPreprocess.py index 84d54d46d..4faabf494 100644 --- a/cellacdc/utils/fucciPreprocess.py +++ b/cellacdc/tools/fucciPreprocess.py @@ -2,7 +2,7 @@ import pandas as pd -from .. import apps, myutils, workers, widgets, html_utils, load +from .. import apps, utils, workers, widgets, html_utils, load from .base import NewThreadMultipleExpBaseUtil @@ -17,7 +17,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) @@ -36,7 +36,7 @@ def askSelectParams(self, exp_path, pos_foldernames): for p, pos in enumerate(pos_foldernames): pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames(images_path) + basename, chNames = utils.getBasenameAndChNames(images_path) channel_names.update(chNames) if df_metadata is not None: continue diff --git a/cellacdc/utils/generateMothBudTotalTable.py b/cellacdc/tools/generateMothBudTotalTable.py similarity index 90% rename from cellacdc/utils/generateMothBudTotalTable.py rename to cellacdc/tools/generateMothBudTotalTable.py index 4cd472ad3..edd6a4fb1 100644 --- a/cellacdc/utils/generateMothBudTotalTable.py +++ b/cellacdc/tools/generateMothBudTotalTable.py @@ -4,8 +4,8 @@ import pandas as pd from .. import exception_handler -from .. import myutils, apps, widgets, html_utils, printl, workers -from ..utils import base +from .. import utils, apps, widgets, html_utils, printl, workers +from . import base from qtpy.QtWidgets import QFileDialog @@ -14,7 +14,7 @@ class GenerateMothBudTotalUtil(base.MainThreadSinglePosUtilBase): def __init__( self, app, title: str, infoText: str, parent=None, callbackOnFinished=None ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__(app, title, module, infoText, parent) self.sigClose.connect(self.close) @@ -37,12 +37,12 @@ def run(self): parent=self, caption="Select CSV file to load", filters="CSV (*.csv);;All Files (*)", - basedir=myutils.getMostRecentPath(), + basedir=utils.getMostRecentPath(), )[0] if input_csv_filepath is None or not input_csv_filepath: return False - myutils.addToRecentPaths(os.path.dirname(input_csv_filepath)) + utils.addToRecentPaths(os.path.dirname(input_csv_filepath)) self.logger.info(f'Reading column names in table "{input_csv_filepath}"...') diff --git a/cellacdc/utils/rename.py b/cellacdc/tools/rename.py similarity index 96% rename from cellacdc/utils/rename.py rename to cellacdc/tools/rename.py index dcd0a26d8..ec9e74079 100755 --- a/cellacdc/utils/rename.py +++ b/cellacdc/tools/rename.py @@ -31,7 +31,7 @@ sys.path.append(cellacdc_path) # Custom modules -from .. import prompts, load, myutils, apps, html_utils, widgets +from .. import prompts, load, utils, apps, html_utils, widgets from .. import recentPaths_path, cellacdc_path, settings_folderpath if os.name == "nt": @@ -111,7 +111,7 @@ def main(self): self.setWindowTitle(f'Cell-ACDC - Renaming files - "{exp_path}"') - folder_type = myutils.determine_folder_type(exp_path) + folder_type = utils.determine_folder_type(exp_path) is_pos_folder, is_images_folder, exp_path = folder_type print("Loading data...") @@ -168,7 +168,7 @@ def main(self): print(f'Renaming files by appending "_{appendedTxt}"...') if len(selectedFilenames) > 1 or len(images_paths) > 1: ch_name_selector = prompts.select_channel_name() - ls = myutils.listdir(images_paths[0]) + ls = utils.listdir(images_paths[0]) all_channelNames, abort = ch_name_selector.get_available_channels( ls, images_paths[0], useExt=None ) @@ -180,7 +180,7 @@ def main(self): f[len(ch_name_selector.basename) :] for f in selectedFilenames ] for images_path in tqdm(images_paths, ncols=100): - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) _, skip = ch_name_selector.get_available_channels( ls, images_path, useExt=None ) @@ -224,10 +224,10 @@ def askTxtAppend(self, filename): return self.win.cancel, self.win.LE.text() def criticalNoCommonBasename(self, filenames, parent_path): - myutils.checkDataIntegrity(filenames, parent_path, parentQWidget=self) + utils.checkDataIntegrity(filenames, parent_path, parentQWidget=self) def selectFiles(self, images_path, filterExt=None): - files = myutils.listdir(images_path) + files = utils.listdir(images_path) if filterExt is not None: items = [] for file in files: diff --git a/cellacdc/utils/repeat.py b/cellacdc/tools/repeat.py similarity index 97% rename from cellacdc/utils/repeat.py rename to cellacdc/tools/repeat.py index 644c3dd85..926a39a1a 100644 --- a/cellacdc/utils/repeat.py +++ b/cellacdc/tools/repeat.py @@ -15,7 +15,7 @@ from qtpy import QtGui from .. import exception_handler -from .. import myutils, html_utils, workers, widgets, load, apps +from .. import utils, html_utils, workers, widgets, load, apps class repeatDataPrepWindow(QDialog): @@ -24,7 +24,7 @@ def __init__(self, parent=None) -> None: name = "repeat data prep" - logger, logs_path, log_path, log_filename = myutils.setupLogger(module=name) + logger, logs_path, log_path, log_filename = utils.setupLogger(module=name) self.logger = logger self.log_path = log_path @@ -109,7 +109,7 @@ def start(self): self.startButton.hide() self.stopButton.show() - MostRecentPath = myutils.getMostRecentPath() + MostRecentPath = utils.getMostRecentPath() exp_path = QFileDialog.getExistingDirectory( self, "Select experiment folder or specific Position folder", MostRecentPath ) @@ -118,9 +118,9 @@ def start(self): self.stop() return - myutils.addToRecentPaths(exp_path, logger=self.logger) + utils.addToRecentPaths(exp_path, logger=self.logger) - folder_type = myutils.determine_folder_type(exp_path) + folder_type = utils.determine_folder_type(exp_path) is_pos_folder, is_images_folder, exp_path = folder_type if is_pos_folder: diff --git a/cellacdc/utils/resize/__init__.py b/cellacdc/tools/resize/__init__.py similarity index 95% rename from cellacdc/utils/resize/__init__.py rename to cellacdc/tools/resize/__init__.py index e404fd3f5..e3335d87c 100644 --- a/cellacdc/utils/resize/__init__.py +++ b/cellacdc/tools/resize/__init__.py @@ -9,7 +9,7 @@ import shutil import pandas as pd -from ... import load, myutils, io +from ... import load, utils, io def process_frame(imgs, images_indx, factor, is_segm): @@ -94,7 +94,7 @@ def resize_imgs(images_path_in, factor, images_path_out=None, text_to_append="") if images_path_out is None: images_path_out = images_path_in - list_dir = myutils.listdir(images_path_in) + list_dir = utils.listdir(images_path_in) # Get a list of all PNG files in the input folder images_files = [ @@ -126,7 +126,7 @@ def edit_subs_bkgrROIs(images_path_in, factor, images_path_out=None, text_to_app if images_path_out is None: images_path_out = images_path_in - list_dir = myutils.listdir(images_path_in) + list_dir = utils.listdir(images_path_in) bkgrROIs_jsons = [file for file in list_dir if file.endswith("bkgrROIs.json")] bkgrROIs_npzs = [file for file in list_dir if file.endswith("bkgrROIs.npz")] @@ -159,7 +159,7 @@ def edit_subs_bkgrROIs(images_path_in, factor, images_path_out=None, text_to_app data_scaled.append(data_part) - bkgrROIs_json_file_out = myutils.append_text_filename( + bkgrROIs_json_file_out = utils.append_text_filename( bkgrROIs_json_file, text_to_append ) images_path_out_file = os.path.join(images_path_out, bkgrROIs_json_file_out) @@ -212,7 +212,7 @@ def edit_acdc_csvs(images_path_in, factor, images_path_out=None, text_to_append= for column in columns_for_scaling: acdc_df[column] = (acdc_df[column] * factor).astype(int) - acdc_csv_file_out = myutils.append_text_filename(acdc_csv_file, text_to_append) + acdc_csv_file_out = utils.append_text_filename(acdc_csv_file, text_to_append) images_path_out_file = os.path.join(images_path_out, acdc_csv_file_out) acdc_df.to_csv(images_path_out_file, index=False) print(f"Modified CSV saved to:") @@ -223,7 +223,7 @@ def edit_metadata(images_path_in, factor, images_path_out=None, text_to_append=" if images_path_out is None: images_path_out = images_path_in - list_dir = myutils.listdir(images_path_in) + list_dir = utils.listdir(images_path_in) data_to_scale_int = ["SizeX", "SizeY"] data_to_scale_float = ["PhysicalSizeY", "PhysicalSizeX"] metadata_files = [file for file in list_dir if file.endswith("metadata.csv")] @@ -248,7 +248,7 @@ def edit_metadata(images_path_in, factor, images_path_out=None, text_to_append=" new_metadata += ",".join(entries) + "\n" - metadata_file_out = myutils.append_text_filename(metadata_file, text_to_append) + metadata_file_out = utils.append_text_filename(metadata_file, text_to_append) images_path_out_file = os.path.join(images_path_out, metadata_file_out) with open(images_path_out_file, "w") as file: file.write(new_metadata) @@ -263,7 +263,7 @@ def edit_lost_centroids( if images_path_out is None: images_path_out = images_path_in - list_dir = myutils.listdir(images_path_in) + list_dir = utils.listdir(images_path_in) lost_centroids_jsons = [ file for file in list_dir if file.endswith("tracked_lost_centroids.json") @@ -290,7 +290,7 @@ def edit_lost_centroids( frame_new.append(new_centroid) lost_centroids[frame_i] = frame_new - lost_centroids_json_out = myutils.append_text_filename( + lost_centroids_json_out = utils.append_text_filename( lost_centroids_json, text_to_append ) images_path_out_file = os.path.join(images_path_out, lost_centroids_json_out) @@ -329,7 +329,7 @@ def copy_aux_files(images_path_in, images_path_out=None): if images_path_out is None: images_path_out = images_path_in - list_dir = myutils.listdir(images_path_in) + list_dir = utils.listdir(images_path_in) files_endings = [ "_last_tracked_i.txt", "_combine_metrics.ini", diff --git a/cellacdc/utils/resize/util.py b/cellacdc/tools/resize/util.py similarity index 93% rename from cellacdc/utils/resize/util.py rename to cellacdc/tools/resize/util.py index 7f57539d6..0ae039e72 100644 --- a/cellacdc/utils/resize/util.py +++ b/cellacdc/tools/resize/util.py @@ -1,4 +1,4 @@ -from ... import myutils, workers, widgets, html_utils +from ... import utils, workers, widgets, html_utils from ... import apps from ..base import NewThreadMultipleExpBaseUtil @@ -14,7 +14,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) diff --git a/cellacdc/utils/stack2Dinto3Dsegm.py b/cellacdc/tools/stack2Dinto3Dsegm.py similarity index 95% rename from cellacdc/utils/stack2Dinto3Dsegm.py rename to cellacdc/tools/stack2Dinto3Dsegm.py index a0433654a..f163336cd 100644 --- a/cellacdc/utils/stack2Dinto3Dsegm.py +++ b/cellacdc/tools/stack2Dinto3Dsegm.py @@ -1,4 +1,4 @@ -from .. import apps, myutils, workers, widgets, html_utils +from .. import apps, utils, workers, widgets, html_utils from .base import NewThreadMultipleExpBaseUtil @@ -14,7 +14,7 @@ def __init__( SizeZ: int, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) diff --git a/cellacdc/utils/toImageJroi.py b/cellacdc/tools/toImageJroi.py similarity index 90% rename from cellacdc/utils/toImageJroi.py rename to cellacdc/tools/toImageJroi.py index 404f5a2bb..c9a864541 100644 --- a/cellacdc/utils/toImageJroi.py +++ b/cellacdc/tools/toImageJroi.py @@ -1,4 +1,4 @@ -from .. import myutils, workers, widgets, html_utils +from .. import utils, workers, widgets, html_utils from .base import NewThreadMultipleExpBaseUtil @@ -13,7 +13,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) diff --git a/cellacdc/utils/toObjCoords.py b/cellacdc/tools/toObjCoords.py similarity index 90% rename from cellacdc/utils/toObjCoords.py rename to cellacdc/tools/toObjCoords.py index ff6f396c1..632563e69 100644 --- a/cellacdc/utils/toObjCoords.py +++ b/cellacdc/tools/toObjCoords.py @@ -1,4 +1,4 @@ -from .. import myutils, workers, widgets, html_utils +from .. import utils, workers, widgets, html_utils from .base import NewThreadMultipleExpBaseUtil @@ -13,7 +13,7 @@ def __init__( progressDialogueTitle: str, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) diff --git a/cellacdc/utils/trackSubCellObjects.py b/cellacdc/tools/trackSubCellObjects.py similarity index 97% rename from cellacdc/utils/trackSubCellObjects.py rename to cellacdc/tools/trackSubCellObjects.py index ee8e2bf00..a70c02615 100644 --- a/cellacdc/utils/trackSubCellObjects.py +++ b/cellacdc/tools/trackSubCellObjects.py @@ -1,4 +1,4 @@ -from .. import apps, myutils, workers, widgets, html_utils +from .. import apps, utils, workers, widgets, html_utils from .base import NewThreadMultipleExpBaseUtil @@ -14,7 +14,7 @@ def __init__( trackSubCellObjParams: dict, parent=None, ): - module = myutils.get_module_name(__file__) + module = utils.get_module_name(__file__) super().__init__( expPaths, app, title, module, infoText, progressDialogueTitle, parent=parent ) diff --git a/cellacdc/trackers/BABY/BABY_tracker.py b/cellacdc/trackers/BABY/BABY_tracker.py index 6fa39f464..1466eb3e6 100644 --- a/cellacdc/trackers/BABY/BABY_tracker.py +++ b/cellacdc/trackers/BABY/BABY_tracker.py @@ -6,7 +6,7 @@ from baby import modelsets from baby import BabyCrawler -from cellacdc import myutils +from cellacdc import utils from cellacdc.trackers import BABY from ..CellACDC import CellACDC_tracker @@ -28,7 +28,7 @@ def _preprocess(self, image, swap_YX_axes_to_XY): if image.ndim == 2: image = image[np.newaxis] - image = myutils.to_uint16(image) + image = utils.to_uint16(image) # BABY requires z-slices as last dimension while Cell-ACDC takes # Z, Y, X input diff --git a/cellacdc/trackers/BABY/__init__.py b/cellacdc/trackers/BABY/__init__.py index 546662798..3e1ae7dca 100644 --- a/cellacdc/trackers/BABY/__init__.py +++ b/cellacdc/trackers/BABY/__init__.py @@ -1,6 +1,6 @@ -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_baby() +utils.check_install_baby() from baby import modelsets diff --git a/cellacdc/trackers/BayesianTracker/__init__.py b/cellacdc/trackers/BayesianTracker/__init__.py index b56a0721d..a63358d6f 100755 --- a/cellacdc/trackers/BayesianTracker/__init__.py +++ b/cellacdc/trackers/BayesianTracker/__init__.py @@ -2,7 +2,7 @@ try: import btrack - from cellacdc.myutils import get_package_version + from cellacdc.utils import get_package_version version = get_package_version("btrack") minor = version.split(".")[1] @@ -11,9 +11,9 @@ except Exception as e: pass -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_package( +utils.check_install_package( "Bayesian Tracker", import_pkg_name="btrack", pypi_name="btrack", diff --git a/cellacdc/trackers/CellACDC_normal_division/CellACDC_normal_division_tracker.py b/cellacdc/trackers/CellACDC_normal_division/CellACDC_normal_division_tracker.py index 19c962611..69a063bd5 100644 --- a/cellacdc/trackers/CellACDC_normal_division/CellACDC_normal_division_tracker.py +++ b/cellacdc/trackers/CellACDC_normal_division/CellACDC_normal_division_tracker.py @@ -2,12 +2,12 @@ from cellacdc.trackers.CellACDC.CellACDC_tracker import calc_Io_matrix from cellacdc.trackers.CellACDC.CellACDC_tracker import track_frame as track_frame_base from cellacdc.core import getBaseCca_df, printl -from cellacdc.myutils import checked_reset_index, checked_reset_index_Cell_ID +from cellacdc.utils import checked_reset_index, checked_reset_index_Cell_ID import numpy as np from skimage.measure import regionprops from tqdm import tqdm import pandas as pd -from cellacdc.myutils import exec_time +from cellacdc.utils import exec_time from cellacdc._types import NotGUIParam import copy import cellacdc.debugutils as debugutils diff --git a/cellacdc/trackers/DeepSea/DeepSea_tracker.py b/cellacdc/trackers/DeepSea/DeepSea_tracker.py index 6ec30d2fc..9d35243c6 100644 --- a/cellacdc/trackers/DeepSea/DeepSea_tracker.py +++ b/cellacdc/trackers/DeepSea/DeepSea_tracker.py @@ -14,7 +14,7 @@ from deepsea.model import DeepSeaTracker from deepsea.utils import track_cells -from cellacdc import myutils, printl +from cellacdc import utils, printl from cellacdc.segmenters.DeepSea import _init_model, _resize_img from cellacdc.segmenters.DeepSea import image_size as segm_image_size from cellacdc.segmenters.DeepSea import _get_segm_transforms diff --git a/cellacdc/trackers/TAPIR/__init__.py b/cellacdc/trackers/TAPIR/__init__.py index 9c1d97afd..b41d60327 100644 --- a/cellacdc/trackers/TAPIR/__init__.py +++ b/cellacdc/trackers/TAPIR/__init__.py @@ -1,7 +1,7 @@ import os -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_tapir() -_, model_path = myutils.get_model_path("TAPIR", create_temp_dir=False) +utils.check_install_tapir() +_, model_path = utils.get_model_path("TAPIR", create_temp_dir=False) TAPIR_CHECKPOINT_PATH = os.path.join(model_path, "tapir_checkpoint.npy") diff --git a/cellacdc/trackers/Trackastra/Trackastra_tracker.py b/cellacdc/trackers/Trackastra/Trackastra_tracker.py index 9838531fa..5a59b6ae7 100644 --- a/cellacdc/trackers/Trackastra/Trackastra_tracker.py +++ b/cellacdc/trackers/Trackastra/Trackastra_tracker.py @@ -3,7 +3,7 @@ from trackastra.model import Trackastra from trackastra.tracking import graph_to_ctc -from ... import _types, myutils, core +from ... import _types, utils, core from . import get_pretrained_model_names @@ -40,7 +40,7 @@ def __init__( If `True`, attempts to try to use the GPU for inference. Default is False """ - device = myutils.get_torch_device() + device = utils.get_torch_device() if model_folder_path: self.model = Trackastra.from_folder(model_folder_path, device=str(device)) else: @@ -107,7 +107,7 @@ def track( if linking_mode == "greedy_nodiv": return tracked_video - acdc_df, cca_dfs, asym_segm_tracked = myutils.df_ctc_to_acdc_df( + acdc_df, cca_dfs, asym_segm_tracked = utils.df_ctc_to_acdc_df( df_ctc, tracked_video, cell_division_mode=cell_division_mode, diff --git a/cellacdc/trackers/Trackastra/__init__.py b/cellacdc/trackers/Trackastra/__init__.py index f35699472..5e5120d1c 100644 --- a/cellacdc/trackers/Trackastra/__init__.py +++ b/cellacdc/trackers/Trackastra/__init__.py @@ -1,9 +1,9 @@ import os import json -from ... import myutils +from ... import utils -myutils.check_install_trackastra() +utils.check_install_trackastra() import trackastra diff --git a/cellacdc/trackers/trackpy/__init__.py b/cellacdc/trackers/trackpy/__init__.py index 58286025c..466c91042 100644 --- a/cellacdc/trackers/trackpy/__init__.py +++ b/cellacdc/trackers/trackpy/__init__.py @@ -1,3 +1,3 @@ -from cellacdc import myutils +from cellacdc import utils -myutils.check_install_package("trackpy") +utils.check_install_package("trackpy") diff --git a/cellacdc/utils/__init__.py b/cellacdc/utils/__init__.py old mode 100755 new mode 100644 index e69de29bb..54f11cddf --- a/cellacdc/utils/__init__.py +++ b/cellacdc/utils/__init__.py @@ -0,0 +1,520 @@ +"""Cell-ACDC shared helpers (logging, paths, install, models, …).""" + +from .dataframe import ( + are_acdc_dfs_equal, + checked_reset_index, + checked_reset_index_Cell_ID, + df_ctc_to_acdc_df, + fix_acdc_df_dtypes, + format_IDs, + get_cca_colname_desc, +) + +from .install import ( + _apt_install_java_command, + _brew_install_hdf5, + _brew_install_java_command, + _get_pkg_command_pip_install, + _inform_install_package_failed, + _install_deepsea, + _install_homebrew_command, + _install_package_cli_msg, + _install_package_gui_msg, + _install_package_msg, + _install_pip_package, + _install_pytorch_cli, + _install_sam2, + _install_segment_anything, + _install_tensorflow, + _java_exists, + _java_instructions_linux, + _java_instructions_macOS, + _java_instructions_windows, + _warn_dll_torch, + _warn_install_gpu, + check_git_installed, + check_gpu_available, + check_gpu_requested_segm_model, + check_install_baby, + check_install_cellpose, + check_install_cellsam, + check_install_custom_dependencies, + check_install_instanseg, + check_install_microsam, + check_install_nnInteractive, + check_install_omnipose, + check_install_package, + check_install_sam2, + check_install_segment_anything, + check_install_tapir, + check_install_torch, + check_install_trackastra, + check_install_yeaz, + check_upgrade_javabridge, + download_java, + get_java_url, + get_package_info, + get_package_version, + get_pip_conda_prefix, + get_pip_install_cellacdc_version_command, + get_pytorch_command, + get_torch_device, + install_java, + install_javabridge, + install_javabridge_help, + install_javabridge_instructions_text, + install_package_conda, + uninstall_omnipose_acdc, + uninstall_pip_package, + update_editable_package, + update_not_editable_package, + update_package, +) + +from .io import ( + _bytes_to_GB, + _bytes_to_MB, + browse_docs, + browse_url, + getMemoryFootprint, + save_response_content, +) + +from .logging import ( + Logger, + _log_system_info, + delete_older_log_files, + get_logs_path, + log_segm_params, + setupLogger, +) + +from .misc import ( + StdErr, + _apt_gcc_command, + _apt_update_command, + _available_frameworks, + _get_doc_stop_idx, + _init_fiji_cli, + _jdk_exists, + _parse_bool_str, + _relabel_cca_dfs_and_segm_data, + _run_command, + _subprocess_run_command, + addToRecentPaths, + add_segm_data_param, + checkDataIntegrity, + check_napari_plugin, + clipSelemMask, + convert_to_dtype, + cpp_windows_url, + exec_time, + extract_zip, + filterCommonStart, + find_distances_ID, + find_missing_integers, + findalliter, + float_img_to_dtype, + format_cca_manual_changes, + format_commit_date_utc, + from_imagej_rois_to_segm_data, + from_lab_to_imagej_rois, + from_lab_to_obj_coords, + getAcdcDfSegmPaths, + getBaseAcdcDf, + getBasename, + getBasenameAndChNames, + getChannelFilePath, + getCustomAnnotTooltip, + getDefault_SegmInfo_df, + getMostRecentPath, + get_chained_attr, + get_chname_from_basename, + get_confirm_token, + get_empty_stored_data_dict, + get_fiji_base_command, + get_function_argspec, + get_input_output_mapper, + get_linux_distribution_name, + get_module_name, + get_obj_by_label, + get_slices_local_into_global_arr, + get_tiff_metadata, + img_to_float, + import_segment_module, + init_input_points_df, + is_gui_running, + is_in_bounds, + is_iterable, + iterate_along_axes, + jdk_windows_url, + lab2d_to_rois, + pairwise, + purge_module, + remove_known_extension, + reset_settings, + run_fiji_command, + safe_get_or_call, + seconds_to_ETA, + separate_fluo_segment_channels, + setRetainSizePolicy, + showInExplorer, + showUserManual, + sort_IDs_dist, + synthetic_image_geneator, + test_fiji_base_command, + to_tiff, + to_uint16, + to_uint8, + translateStrNone, + try_kwargs, + utilClass, +) + +from .models import ( + _download_cellpose_germlineNuclei_model, + _download_deepsea_models, + _download_omnipose_models, + _download_sam2_models, + _download_segment_anything_models, + _download_tapir_model, + _download_yeaz_models, + _model_url, + _write_model_location_to_txt, + aliases_real_time_trackers, + check_model_exists, + download_bioformats_jar, + download_examples, + download_ffmpeg, + download_fiji, + download_manual, + download_model, + download_url, + getClassArgSpecs, + getModelArgSpec, + getTrackerArgSpec, + get_add_custom_model_instructions, + get_add_custom_prompt_model_instructions, + get_list_of_models, + get_list_of_promptable_models, + get_list_of_real_time_trackers, + get_list_of_trackers, + import_promptable_segment_module, + import_tracker_module, + init_prompt_segm_model, + init_segm_model, + init_tracker, + insertModelArgSpec, + isIntensityImgRequiredForTracker, + params_to_ArgSpec, + parse_model_param_doc, + parse_model_params, + setDefaultValueArgSpecsFromKwargs, + validate_tracker_input, +) + +from .paths import ( + _create_temp_dir, + check_v123_model_path, + determine_folder_type, + get_acdc_data_path, + get_acdc_java_path, + get_examples_path, + get_fiji_binary_filepath_mac, + get_fiji_exec_folderpath, + get_gdrive_path, + get_images_folderpath, + get_model_path, + get_open_filemaneger_os_string, + get_pos_foldernames, + get_pos_status, + get_pos_status_acdc, + get_pos_status_spotmax, + is_old_user_profile_path, + is_pos_folderpath, + listdir, + migrate_to_new_user_profile_path, + store_custom_model_path, + store_custom_promptable_model_path, + to_relative_path, + trim_path, + validate_images_path, +) + +from .qt import ( + get_cli_multi_choice_question, + testQcoreApp, +) + +from .text import ( + append_text_filename, + elided_text, + get_number_fstring_formatter, + get_show_in_file_manager_text, + get_trimmed_dict, + get_trimmed_list, +) + +from .version import ( + _update_repo_with_git_command, + check_cellpose_version, + check_matplotlib_version, + check_pkg_exact_version, + check_pkg_max_version, + check_pkg_version, + get_cellpose_major_version, + get_date_from_version, + get_git_branch_name, + get_git_pull_checkout_cellacdc_version_commands, + get_info_version_text, + get_salute_string, + is_pkg_version_within_range, + is_second_version_greater, + read_version, +) + +__all__ = [ + "are_acdc_dfs_equal", + "checked_reset_index", + "checked_reset_index_Cell_ID", + "df_ctc_to_acdc_df", + "fix_acdc_df_dtypes", + "format_IDs", + "get_cca_colname_desc", + "_apt_install_java_command", + "_brew_install_hdf5", + "_brew_install_java_command", + "_get_pkg_command_pip_install", + "_inform_install_package_failed", + "_install_deepsea", + "_install_homebrew_command", + "_install_package_cli_msg", + "_install_package_gui_msg", + "_install_package_msg", + "_install_pip_package", + "_install_pytorch_cli", + "_install_sam2", + "_install_segment_anything", + "_install_tensorflow", + "_java_exists", + "_java_instructions_linux", + "_java_instructions_macOS", + "_java_instructions_windows", + "_warn_dll_torch", + "_warn_install_gpu", + "check_git_installed", + "check_gpu_available", + "check_gpu_requested_segm_model", + "check_install_baby", + "check_install_cellpose", + "check_install_cellsam", + "check_install_custom_dependencies", + "check_install_instanseg", + "check_install_microsam", + "check_install_nnInteractive", + "check_install_omnipose", + "check_install_package", + "check_install_sam2", + "check_install_segment_anything", + "check_install_tapir", + "check_install_torch", + "check_install_trackastra", + "check_install_yeaz", + "check_upgrade_javabridge", + "download_java", + "get_java_url", + "get_package_info", + "get_package_version", + "get_pip_conda_prefix", + "get_pip_install_cellacdc_version_command", + "get_pytorch_command", + "get_torch_device", + "install_java", + "install_javabridge", + "install_javabridge_help", + "install_javabridge_instructions_text", + "install_package_conda", + "uninstall_omnipose_acdc", + "uninstall_pip_package", + "update_editable_package", + "update_not_editable_package", + "update_package", + "_bytes_to_GB", + "_bytes_to_MB", + "browse_docs", + "browse_url", + "getMemoryFootprint", + "save_response_content", + "Logger", + "_log_system_info", + "delete_older_log_files", + "get_logs_path", + "log_segm_params", + "setupLogger", + "StdErr", + "_apt_gcc_command", + "_apt_update_command", + "_available_frameworks", + "_get_doc_stop_idx", + "_init_fiji_cli", + "_jdk_exists", + "_parse_bool_str", + "_relabel_cca_dfs_and_segm_data", + "_run_command", + "_subprocess_run_command", + "addToRecentPaths", + "add_segm_data_param", + "checkDataIntegrity", + "check_napari_plugin", + "clipSelemMask", + "convert_to_dtype", + "cpp_windows_url", + "exec_time", + "extract_zip", + "filterCommonStart", + "find_distances_ID", + "find_missing_integers", + "findalliter", + "float_img_to_dtype", + "format_cca_manual_changes", + "format_commit_date_utc", + "from_imagej_rois_to_segm_data", + "from_lab_to_imagej_rois", + "from_lab_to_obj_coords", + "getAcdcDfSegmPaths", + "getBaseAcdcDf", + "getBasename", + "getBasenameAndChNames", + "getChannelFilePath", + "getCustomAnnotTooltip", + "getDefault_SegmInfo_df", + "getMostRecentPath", + "get_chained_attr", + "get_chname_from_basename", + "get_confirm_token", + "get_empty_stored_data_dict", + "get_fiji_base_command", + "get_function_argspec", + "get_input_output_mapper", + "get_linux_distribution_name", + "get_module_name", + "get_obj_by_label", + "get_slices_local_into_global_arr", + "get_tiff_metadata", + "img_to_float", + "import_segment_module", + "init_input_points_df", + "is_gui_running", + "is_in_bounds", + "is_iterable", + "iterate_along_axes", + "jdk_windows_url", + "lab2d_to_rois", + "pairwise", + "purge_module", + "remove_known_extension", + "reset_settings", + "run_fiji_command", + "safe_get_or_call", + "seconds_to_ETA", + "separate_fluo_segment_channels", + "setRetainSizePolicy", + "showInExplorer", + "showUserManual", + "sort_IDs_dist", + "synthetic_image_geneator", + "test_fiji_base_command", + "to_tiff", + "to_uint16", + "to_uint8", + "translateStrNone", + "try_kwargs", + "utilClass", + "_download_cellpose_germlineNuclei_model", + "_download_deepsea_models", + "_download_omnipose_models", + "_download_sam2_models", + "_download_segment_anything_models", + "_download_tapir_model", + "_download_yeaz_models", + "_model_url", + "_write_model_location_to_txt", + "aliases_real_time_trackers", + "check_model_exists", + "download_bioformats_jar", + "download_examples", + "download_ffmpeg", + "download_fiji", + "download_manual", + "download_model", + "download_url", + "getClassArgSpecs", + "getModelArgSpec", + "getTrackerArgSpec", + "get_add_custom_model_instructions", + "get_add_custom_prompt_model_instructions", + "get_list_of_models", + "get_list_of_promptable_models", + "get_list_of_real_time_trackers", + "get_list_of_trackers", + "import_promptable_segment_module", + "import_tracker_module", + "init_prompt_segm_model", + "init_segm_model", + "init_tracker", + "insertModelArgSpec", + "isIntensityImgRequiredForTracker", + "params_to_ArgSpec", + "parse_model_param_doc", + "parse_model_params", + "setDefaultValueArgSpecsFromKwargs", + "validate_tracker_input", + "_create_temp_dir", + "check_v123_model_path", + "determine_folder_type", + "get_acdc_data_path", + "get_acdc_java_path", + "get_examples_path", + "get_fiji_binary_filepath_mac", + "get_fiji_exec_folderpath", + "get_gdrive_path", + "get_images_folderpath", + "get_model_path", + "get_open_filemaneger_os_string", + "get_pos_foldernames", + "get_pos_status", + "get_pos_status_acdc", + "get_pos_status_spotmax", + "is_old_user_profile_path", + "is_pos_folderpath", + "listdir", + "migrate_to_new_user_profile_path", + "store_custom_model_path", + "store_custom_promptable_model_path", + "to_relative_path", + "trim_path", + "validate_images_path", + "get_cli_multi_choice_question", + "testQcoreApp", + "append_text_filename", + "elided_text", + "get_number_fstring_formatter", + "get_show_in_file_manager_text", + "get_trimmed_dict", + "get_trimmed_list", + "_update_repo_with_git_command", + "check_cellpose_version", + "check_matplotlib_version", + "check_pkg_exact_version", + "check_pkg_max_version", + "check_pkg_version", + "get_cellpose_major_version", + "get_date_from_version", + "get_git_branch_name", + "get_git_pull_checkout_cellacdc_version_commands", + "get_info_version_text", + "get_salute_string", + "is_pkg_version_within_range", + "is_second_version_greater", + "read_version", +] diff --git a/cellacdc/myutils/dataframe.py b/cellacdc/utils/dataframe.py similarity index 100% rename from cellacdc/myutils/dataframe.py rename to cellacdc/utils/dataframe.py diff --git a/cellacdc/myutils/install.py b/cellacdc/utils/install.py similarity index 99% rename from cellacdc/myutils/install.py rename to cellacdc/utils/install.py index 2de69144c..5944dffa8 100644 --- a/cellacdc/myutils/install.py +++ b/cellacdc/utils/install.py @@ -851,7 +851,7 @@ def check_install_custom_dependencies(custom_install_requires, *args, **kwargs): """Used to install a package with custom dependencies, usefull if they have random pinned versions for their dependencies. - For *args and **kwargs see `myutils.check_install_package`. + For *args and **kwargs see `utils.check_install_package`. Parameters ---------- diff --git a/cellacdc/myutils/io.py b/cellacdc/utils/io.py similarity index 100% rename from cellacdc/myutils/io.py rename to cellacdc/utils/io.py diff --git a/cellacdc/myutils/logging.py b/cellacdc/utils/logging.py similarity index 100% rename from cellacdc/myutils/logging.py rename to cellacdc/utils/logging.py diff --git a/cellacdc/myutils/misc.py b/cellacdc/utils/misc.py similarity index 100% rename from cellacdc/myutils/misc.py rename to cellacdc/utils/misc.py diff --git a/cellacdc/myutils/models.py b/cellacdc/utils/models.py similarity index 100% rename from cellacdc/myutils/models.py rename to cellacdc/utils/models.py diff --git a/cellacdc/myutils/paths.py b/cellacdc/utils/paths.py similarity index 100% rename from cellacdc/myutils/paths.py rename to cellacdc/utils/paths.py diff --git a/cellacdc/myutils/qt.py b/cellacdc/utils/qt.py similarity index 100% rename from cellacdc/myutils/qt.py rename to cellacdc/utils/qt.py diff --git a/cellacdc/myutils/text.py b/cellacdc/utils/text.py similarity index 100% rename from cellacdc/myutils/text.py rename to cellacdc/utils/text.py diff --git a/cellacdc/myutils/version.py b/cellacdc/utils/version.py similarity index 100% rename from cellacdc/myutils/version.py rename to cellacdc/utils/version.py diff --git a/cellacdc/whitelist.py b/cellacdc/whitelist.py index 87a63712c..78eb632b3 100644 --- a/cellacdc/whitelist.py +++ b/cellacdc/whitelist.py @@ -1,7 +1,7 @@ import os import numpy as np import skimage.measure -from . import printl, myutils +from . import printl, utils import json from typing import Set, List, Tuple import time @@ -760,7 +760,7 @@ def propagateIDs( if self._debug: printl("Propagating IDs...") - myutils.print_call_stack() + utils.print_call_stack() printl(new_whitelist, IDs_to_add, IDs_to_remove) # if labs is None and not allData_li and not IDs_curr: diff --git a/cellacdc/widgets/canvas.py b/cellacdc/widgets/canvas.py index 1bd477557..31e2cedd6 100644 --- a/cellacdc/widgets/canvas.py +++ b/cellacdc/widgets/canvas.py @@ -141,7 +141,7 @@ pg.setConfigOption("imageAxisOrder", "row-major") -from .. import myutils, measurements, is_mac, is_win, html_utils, is_linux +from .. import utils, measurements, is_mac, is_win, html_utils, is_linux from .. import printl, settings_folderpath from .. import colors, config from .. import html_path @@ -397,7 +397,7 @@ def mask(self): mask = skimage.morphology.disk(radius, dtype=bool) xx, yy = self.getData() Yc, Xc = yy[0], xx[0] - mask, self._slice = myutils.clipSelemMask(mask, shape, Yc, Xc, copy=False) + mask, self._slice = utils.clipSelemMask(mask, shape, Yc, Xc, copy=False) return mask diff --git a/cellacdc/widgets/controls.py b/cellacdc/widgets/controls.py index da17f29e8..da55a696b 100644 --- a/cellacdc/widgets/controls.py +++ b/cellacdc/widgets/controls.py @@ -141,7 +141,7 @@ pg.setConfigOption("imageAxisOrder", "row-major") -from .. import myutils, measurements, is_mac, is_win, html_utils, is_linux +from .. import utils, measurements, is_mac, is_win, html_utils, is_linux from .. import printl, settings_folderpath from .. import colors, config from .. import html_path @@ -687,7 +687,7 @@ def __init__(self, lineEdit, confirmSelectionAction, *args): super().__init__(*args) def setText(self): - text = myutils.format_IDs(self) + text = utils.format_IDs(self) self.lineEdit.setText(text) @@ -789,7 +789,7 @@ def addShowInFileManagerButton(self, path, txt=None): txt = "Reveal in Finder..." if is_mac else "Show in Explorer..." self.showInFileManagButton = showInFileManagerButton(txt) self.buttonsLayout.addWidget(self.showInFileManagButton) - func = partial(myutils.showInExplorer, path) + func = partial(utils.showInExplorer, path) self.showInFileManagButton.clicked.connect(func) def addBrowseUrlButton(self, url, button_text=""): @@ -1140,7 +1140,7 @@ def _template( self.addImage(image_path) if layouts is not None: - if myutils.is_iterable(layouts): + if utils.is_iterable(layouts): for layout in layouts: self.addLayout(layout) else: @@ -1149,7 +1149,7 @@ def _template( if widgets is not None: self._layout.addItem(QSpacerItem(20, 20), self.currentRow, 1) self.currentRow += 1 - if myutils.is_iterable(widgets): + if utils.is_iterable(widgets): for widget in widgets: self.addWidget(widget) else: @@ -3888,7 +3888,7 @@ def __init__(self, parent=None): self.editButton = editPushButton() self.browseButton = browseFileButton( - ext={"CSV": ".csv"}, start_dir=myutils.getMostRecentPath() + ext={"CSV": ".csv"}, start_dir=utils.getMostRecentPath() ) _layout.addWidget(self.lineEntry) @@ -3957,7 +3957,7 @@ def browseCsvFiles(self, filepath): # Check if basename is present in metadata folderpath = os.path.dirname(filepath) basename = None - for file in myutils.listdir(folderpath): + for file in utils.listdir(folderpath): if file.endswith("metadata.csv"): metadata_csv_path = os.path.join(folderpath, file) df = pd.read_csv(metadata_csv_path, index_col="Description") @@ -3972,7 +3972,7 @@ def browseCsvFiles(self, filepath): if is_images_folder: images_path = folderpath img_filepath = None - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): if file.endswith(".tif"): img_filepath = os.path.join(images_path, file) break @@ -4048,7 +4048,7 @@ def addInstructionsWindows(self): self.scrollArea = QScrollArea() _container = QWidget() _layout = QVBoxLayout() - for t, text in enumerate(myutils.install_javabridge_instructions_text()): + for t, text in enumerate(utils.install_javabridge_instructions_text()): label = QLabel() label.setText(text) if t == 1 or t == 2: @@ -4061,10 +4061,10 @@ def addInstructionsWindows(self): copyButton.setIcon(QIcon(":edit-copy.svg")) copyButton.setText("Copy link") if t == 1: - copyButton.textToCopy = myutils.jdk_windows_url() + copyButton.textToCopy = utils.jdk_windows_url() code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) else: - copyButton.textToCopy = myutils.cpp_windows_url() + copyButton.textToCopy = utils.cpp_windows_url() screenshotButton = QToolButton() screenshotButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) screenshotButton.setIcon(QIcon(":cog.svg")) @@ -4098,7 +4098,7 @@ def addInstructionsMacOS(self): self.scrollArea = QScrollArea() _container = QWidget() _layout = QVBoxLayout() - for t, text in enumerate(myutils.install_javabridge_instructions_text()): + for t, text in enumerate(utils.install_javabridge_instructions_text()): label = QLabel() label.setText(text) # label.setWordWrap(True) @@ -4111,9 +4111,9 @@ def addInstructionsMacOS(self): copyButton.setIcon(QIcon(":edit-copy.svg")) copyButton.setText("Copy") if t == 1: - copyButton.textToCopy = myutils._install_homebrew_command() + copyButton.textToCopy = utils._install_homebrew_command() else: - copyButton.textToCopy = myutils._brew_install_java_command() + copyButton.textToCopy = utils._brew_install_java_command() copyButton.clicked.connect(self.copyToClipboard) code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) # code_layout.addStretch(1) @@ -4138,7 +4138,7 @@ def addInstructionsLinux(self): self.scrollArea = QScrollArea() _container = QWidget() _layout = QVBoxLayout() - for t, text in enumerate(myutils.install_javabridge_instructions_text()): + for t, text in enumerate(utils.install_javabridge_instructions_text()): label = QLabel() label.setText(text) # label.setWordWrap(True) @@ -4151,11 +4151,11 @@ def addInstructionsLinux(self): copyButton.setIcon(QIcon(":edit-copy.svg")) copyButton.setText("Copy") if t == 1: - copyButton.textToCopy = myutils._apt_update_command() + copyButton.textToCopy = utils._apt_update_command() elif t == 2: - copyButton.textToCopy = myutils._apt_install_java_command() + copyButton.textToCopy = utils._apt_install_java_command() elif t == 3: - copyButton.textToCopy = myutils._apt_gcc_command() + copyButton.textToCopy = utils._apt_gcc_command() copyButton.clicked.connect(self.copyToClipboard) code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) # code_layout.addStretch(1) @@ -4203,26 +4203,26 @@ def installJava(self): subprocess.check_call(["brew", "update"]) except Exception as e: subprocess.run( - myutils._install_homebrew_command(), + utils._install_homebrew_command(), check=True, text=True, shell=True, ) subprocess.run( - myutils._brew_install_java_command(), + utils._brew_install_java_command(), check=True, text=True, shell=True, ) elif is_linux: subprocess.run( - myutils._apt_gcc_command()(), check=True, text=True, shell=True + utils._apt_gcc_command()(), check=True, text=True, shell=True ) subprocess.run( - myutils._apt_update_command()(), check=True, text=True, shell=True + utils._apt_update_command()(), check=True, text=True, shell=True ) subprocess.run( - myutils._apt_install_java_command()(), + utils._apt_install_java_command()(), check=True, text=True, shell=True, @@ -4261,7 +4261,7 @@ def exec_(self): class selectTrackerGUI(QDialogListbox): def __init__(self, SizeT, currentFrameNo=1, parent=None): - trackers = myutils.get_list_of_trackers() + trackers = utils.get_list_of_trackers() super().__init__( "Select tracker", "Select one of the following trackers", @@ -4743,7 +4743,7 @@ def setParams(self, method: str, kwargToValueMapper: Dict[str, str]): def askSetParams(self, df_metadata=None, addApplyButton=False): method = self.currentText() function = PREPROCESS_MAPPER[method]["function"] - params_argspecs = myutils.get_function_argspec( + params_argspecs = utils.get_function_argspec( function, args_to_skip={"logger_func", "apply_to_all_zslices", "apply_to_all_frames"}, ) @@ -4852,7 +4852,7 @@ def setText(self, IDs): if not isinstance(IDs, set) and not isinstance(IDs, list): raise TypeError("IDs must be a set or list") - formatted_text = myutils.format_IDs(IDs) + formatted_text = utils.format_IDs(IDs) super().setText(formatted_text) @@ -4995,7 +4995,7 @@ def onTextChanged(self, text): if text != self._csi_text: return - start_dir = myutils.getMostRecentPath() + start_dir = utils.getMostRecentPath() model_filepath = qtpy.compat.getopenfilename( parent=self, caption="Select YeaZ weights file", diff --git a/cellacdc/widgets/toolbars.py b/cellacdc/widgets/toolbars.py index de0405734..c7ae9b9e2 100644 --- a/cellacdc/widgets/toolbars.py +++ b/cellacdc/widgets/toolbars.py @@ -141,7 +141,7 @@ pg.setConfigOption("imageAxisOrder", "row-major") -from .. import myutils, measurements, is_mac, is_win, html_utils, is_linux +from .. import utils, measurements, is_mac, is_win, html_utils, is_linux from .. import printl, settings_folderpath from .. import colors, config from .. import html_path @@ -896,8 +896,8 @@ def selectModel(self): downloadWin = apps.downloadModel(model_name, parent=self._parent) downloadWin.download() - acdcPromptSegment = myutils.import_promptable_segment_module(model_name) - init_argspecs, segment_argspecs = myutils.getModelArgSpec(acdcPromptSegment) + acdcPromptSegment = utils.import_promptable_segment_module(model_name) + init_argspecs, segment_argspecs = utils.getModelArgSpec(acdcPromptSegment) try: help_url = acdcPromptSegment.url_help() diff --git a/cellacdc/workers/_base.py b/cellacdc/workers/_base.py index f43f4379f..532c54ecb 100644 --- a/cellacdc/workers/_base.py +++ b/cellacdc/workers/_base.py @@ -30,7 +30,7 @@ from cellacdc import html_utils -from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import load, utils, core, prompts, printl, config, segm_re_pattern, io from .. import transformation, measurements, cca_functions from ..path import copy_or_move_tree from .. import features, plot @@ -38,7 +38,7 @@ from .. import cca_df_colnames, lineage_tree_cols, default_annot_df from .. import cca_df_colnames_with_tree from .. import cli -from ..utils import resize +from ..tools import resize from .. import segm_utils DEBUG = False diff --git a/cellacdc/workers/alignment.py b/cellacdc/workers/alignment.py index 11f6aa77a..3138f0949 100644 --- a/cellacdc/workers/alignment.py +++ b/cellacdc/workers/alignment.py @@ -30,7 +30,7 @@ from cellacdc import html_utils -from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import load, utils, core, prompts, printl, config, segm_re_pattern, io from .. import transformation, measurements, cca_functions from ..path import copy_or_move_tree from .. import features, plot @@ -38,7 +38,7 @@ from .. import cca_df_colnames, lineage_tree_cols, default_annot_df from .. import cca_df_colnames_with_tree from .. import cli -from ..utils import resize +from ..tools import resize from .. import segm_utils DEBUG = False @@ -163,7 +163,7 @@ def _align_data(self): self.logger.log(f"Storing temporary file: {tif}") temp_tif = self.dataPrepWin.getTempfilePath(tif) - myutils.to_tiff(temp_tif, aligned_frames) + utils.to_tiff(temp_tif, aligned_frames) self.dataPrepWin.storeTempFileMove(temp_tif, tif) self.posData.img_data = load.imread(temp_tif) @@ -219,7 +219,7 @@ def _align_data(self): self.logger.log(f"Saving: {tif}") temp_tif = self.dataPrepWin.getTempfilePath(tif) - myutils.to_tiff(temp_tif, aligned_frames) + utils.to_tiff(temp_tif, aligned_frames) self.dataPrepWin.storeTempFileMove(temp_tif, tif) if not aligned: @@ -287,11 +287,11 @@ def run(self): shiftsFound = False for pos in pos_foldernames: images_path = os.path.join(exp_path, pos, "Images") - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) for file in ls: if file.endswith("align_shift.npy"): shiftsFound = True - basename, chNames = myutils.getBasenameAndChNames( + basename, chNames = utils.getBasenameAndChNames( images_path, useExt=(".tif", ".h5") ) break @@ -322,7 +322,7 @@ def run(self): pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames( + basename, chNames = utils.getBasenameAndChNames( images_path, useExt=(".tif", ".h5") ) @@ -336,7 +336,7 @@ def run(self): return chName = self.chName - file_path = myutils.getChannelFilePath(images_path, chName) + file_path = utils.getChannelFilePath(images_path, chName) # Load data posData = load.loadData(file_path, chName) @@ -388,7 +388,7 @@ def run(self): if chName == posData.user_ch_name: data = posData.img_data else: - file_path = myutils.getChannelFilePath(images_path, chName) + file_path = utils.getChannelFilePath(images_path, chName) data = load.load_image_file(file_path) self.signals.sigInitInnerPbar.emit(len(data) - 1) @@ -414,7 +414,7 @@ def run(self): if chName == posData.user_ch_name: data = posData.img_data else: - file_path = myutils.getChannelFilePath(images_path, chName) + file_path = utils.getChannelFilePath(images_path, chName) data = load.load_image_file(file_path) self.signals.sigInitInnerPbar.emit(len(data) - 1) @@ -461,7 +461,7 @@ def saveAlignedData(self, data, imagesPath, basename, chName, endname, ext=".tif SizeZ = 1 if data.ndim == 4: SizeZ = data.shape[1] - myutils.to_tiff(filePath, data) + utils.to_tiff(filePath, data) elif ext == ".npz": io.savez_compressed(filePath, data) elif ext == ".h5": diff --git a/cellacdc/workers/data_prep.py b/cellacdc/workers/data_prep.py index 72ec99719..828889aba 100644 --- a/cellacdc/workers/data_prep.py +++ b/cellacdc/workers/data_prep.py @@ -30,7 +30,7 @@ from cellacdc import html_utils -from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import load, utils, core, prompts, printl, config, segm_re_pattern, io from .. import transformation, measurements, cca_functions from ..path import copy_or_move_tree from .. import features, plot @@ -38,7 +38,7 @@ from .. import cca_df_colnames, lineage_tree_cols, default_annot_df from .. import cca_df_colnames_with_tree from .. import cli -from ..utils import resize +from ..tools import resize from .. import segm_utils DEBUG = False @@ -116,7 +116,7 @@ def run(self): posPath = os.path.join(self.expPath, pos) imagesPath = os.path.join(posPath, "Images") - ls = myutils.listdir(imagesPath) + ls = utils.listdir(imagesPath) if p == 0: ch_names, basenameNotFound = ch_name_selector.get_available_channels( ls, imagesPath @@ -201,7 +201,7 @@ def run(self): self.progress.emit("Saving prepped data...") io.savez_compressed(posData.align_npz_path, imageData) if hasattr(posData, "tif_path"): - myutils.to_tiff(posData.tif_path, imageData) + utils.to_tiff(posData.tif_path, imageData) self.updatePbar.emit() if self.abort: @@ -229,7 +229,7 @@ def run(self): self.progress.emit(f'Selected folder: "{self.folderPath}"') self.progress.emit(f'Target folder: "{self.targetFolderPath}"') self.progress.emit(" ") - ls = myutils.listdir(self.folderPath) + ls = utils.listdir(self.folderPath) numFiles = len(ls) self.initPbar.emit(numFiles) numPosDigits = len(str(numFiles)) @@ -265,7 +265,7 @@ def run(self): relPath = os.path.join(posName, "Images", newFilename) tifFilePath = os.path.join(imagesPath, newFilename) self.progress.emit(f"Saving to file: ...{os.sep}{relPath}") - myutils.to_tiff(tifFilePath, data) + utils.to_tiff(tifFilePath, data) pos += 1 except Exception as e: self.progress.emit( @@ -664,7 +664,7 @@ def run(self): first_ch_data, second_ch_data, self.fucciFilterKwargs ) - basename, chNames = myutils.getBasenameAndChNames(images_path) + basename, chNames = utils.getBasenameAndChNames(images_path) _, ext = os.path.splitext(first_ch_filepath) processed_filename = f"{basename}{appendedName}{ext}" processed_filepath = os.path.join(images_path, processed_filename) @@ -829,7 +829,7 @@ def applyRecipe( return preprocessed_data try: - preprocessed_data = myutils.convert_to_dtype(preprocessed_data, image.dtype) + preprocessed_data = utils.convert_to_dtype(preprocessed_data, image.dtype) except Exception as err: preprocessed_data = preprocessed_data.astype(image.dtype) return preprocessed_data @@ -1097,7 +1097,7 @@ def applyPipeline( keep_input_data_type = recipe[0].get("keep_input_data_type", True) if keep_input_data_type: - preprocessed_ch_data = myutils.convert_to_dtype( + preprocessed_ch_data = utils.convert_to_dtype( preprocessed_ch_data, ch_image_data.dtype ) @@ -1199,7 +1199,7 @@ def applyPipeline( out_ext = ".tif" basename_ext = "" for images_path in image_paths: - basename, channels = myutils.getBasenameAndChNames(images_path) + basename, channels = utils.getBasenameAndChNames(images_path) savename = f"{basename}{basename_ext}{appended_text_filename}{out_ext}" diff --git a/cellacdc/workers/gui.py b/cellacdc/workers/gui.py index aa7a8d2e2..3649b4cf9 100644 --- a/cellacdc/workers/gui.py +++ b/cellacdc/workers/gui.py @@ -30,7 +30,7 @@ from cellacdc import html_utils -from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import load, utils, core, prompts, printl, config, segm_re_pattern, io from .. import transformation, measurements, cca_functions from ..path import copy_or_move_tree from .. import features, plot @@ -38,7 +38,7 @@ from .. import cca_df_colnames, lineage_tree_cols, default_annot_df from .. import cca_df_colnames_with_tree from .. import cli -from ..utils import resize +from ..tools import resize from .. import segm_utils DEBUG = False diff --git a/cellacdc/workers/io.py b/cellacdc/workers/io.py index 90222b062..60e92a114 100644 --- a/cellacdc/workers/io.py +++ b/cellacdc/workers/io.py @@ -30,7 +30,7 @@ from cellacdc import html_utils -from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import load, utils, core, prompts, printl, config, segm_re_pattern, io from .. import transformation, measurements, cca_functions from ..path import copy_or_move_tree from .. import features, plot @@ -38,7 +38,7 @@ from .. import cca_df_colnames, lineage_tree_cols, default_annot_df from .. import cca_df_colnames_with_tree from .. import cli -from ..utils import resize +from ..tools import resize from .. import segm_utils DEBUG = False @@ -343,7 +343,7 @@ def _save_acdc_df(self, recovery_acdc_df: pd.DataFrame, posData): self.logger.log(f"[WARNING]: {e}") reference_acdc_df = saved_acdc_df - if myutils.are_acdc_dfs_equal(recovery_acdc_df, reference_acdc_df): + if utils.are_acdc_dfs_equal(recovery_acdc_df, reference_acdc_df): return load.store_unsaved_acdc_df(recovery_folderpath, recovery_acdc_df) diff --git a/cellacdc/workers/metrics.py b/cellacdc/workers/metrics.py index ce786baeb..dcac024fe 100644 --- a/cellacdc/workers/metrics.py +++ b/cellacdc/workers/metrics.py @@ -30,7 +30,7 @@ from cellacdc import html_utils -from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import load, utils, core, prompts, printl, config, segm_re_pattern, io from .. import transformation, measurements, cca_functions from ..path import copy_or_move_tree from .. import features, plot @@ -38,7 +38,7 @@ from .. import cca_df_colnames, lineage_tree_cols, default_annot_df from .. import cca_df_colnames_with_tree from .. import cli -from ..utils import resize +from ..tools import resize from .. import segm_utils DEBUG = False @@ -105,7 +105,7 @@ def run(self): pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames( + basename, chNames = utils.getBasenameAndChNames( images_path, useExt=(".tif", ".h5") ) @@ -113,7 +113,7 @@ def run(self): # Use first found channel, it doesn't matter for metrics chName = chNames[0] - file_path = myutils.getChannelFilePath(images_path, chName) + file_path = utils.getChannelFilePath(images_path, chName) # Load data posData = load.loadData(file_path, chName) @@ -305,7 +305,7 @@ def run_iter_exp(self, exp_path, pos_foldernames, i, tot_exp): ) imagesPath = os.path.join(exp_path, pos, "Images") - basename, chNames = myutils.getBasenameAndChNames( + basename, chNames = utils.getBasenameAndChNames( imagesPath, useExt=(".tif", ".h5") ) @@ -463,7 +463,7 @@ def run(self): images_path = os.path.join(exp_path, pos, "Images") - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) acdc_output_file = [ f for f in ls if f.endswith(f"{selectedAcdcOutputEndname}.csv") @@ -500,7 +500,7 @@ def run(self): ) acdc_df_allpos["experiment_folderpath"] = exp_path - basename, chNames = myutils.getBasenameAndChNames( + basename, chNames = utils.getBasenameAndChNames( images_path, useExt=(".tif", ".h5") ) df_metadata = load.load_metadata_df(images_path) @@ -661,7 +661,7 @@ def getAcdcDf(self, images_path): if self.acdcOutputEndname is None: return - for file in myutils.listdir(images_path): + for file in utils.listdir(images_path): if not file.endswith(self.acdcOutputEndname): continue @@ -749,7 +749,7 @@ def askSelectMeasurements(self, exp_path, posFoldernames): acdc_dfs, keys=keys, names=["Position_n", "frame_i", "Cell_ID"] ) acdc_df_allpos["experiment_folderpath"] = exp_path - basename, chNames = myutils.getBasenameAndChNames( + basename, chNames = utils.getBasenameAndChNames( images_path, useExt=(".tif", ".h5") ) df_metadata = load.load_metadata_df(images_path) @@ -1473,7 +1473,7 @@ def run(self): images_path = os.path.join(exp_path, pos, "Images") endFilenameSegm = self.mainWin.endFilenameSegm - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) file_path = [ os.path.join(images_path, f) for f in ls diff --git a/cellacdc/workers/segm.py b/cellacdc/workers/segm.py index 85cd30d98..0a7f08b53 100644 --- a/cellacdc/workers/segm.py +++ b/cellacdc/workers/segm.py @@ -30,7 +30,7 @@ from cellacdc import html_utils -from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import load, utils, core, prompts, printl, config, segm_re_pattern, io from .. import transformation, measurements, cca_functions from ..path import copy_or_move_tree from .. import features, plot @@ -38,7 +38,7 @@ from .. import cca_df_colnames, lineage_tree_cols, default_annot_df from .. import cca_df_colnames_with_tree from .. import cli -from ..utils import resize +from ..tools import resize from .. import segm_utils DEBUG = False @@ -177,7 +177,7 @@ def run(self): try: self.logger.info(f"Importing {base_model_name}...") self.emitSigAskInstallModel(base_model_name) - acdcSegment = myutils.import_segment_module(base_model_name) + acdcSegment = utils.import_segment_module(base_model_name) self.guiWin.acdcSegment_li[idx] = acdcSegment self.guiWin.local_seg_base_model_name = base_model_name except (IndexError, ImportError, KeyError) as e: @@ -198,7 +198,7 @@ def run(self): init_kwargs_new = self.guiWin.SegForLostIDsSettings["init_kwargs_new"] args_new = self.guiWin.SegForLostIDsSettings["args_new"] - model = myutils.init_segm_model(acdcSegment, posData, init_kwargs_new) + model = utils.init_segm_model(acdcSegment, posData, init_kwargs_new) if model is None: self.logger.info("Segmentation model was not initialized correctly!") self.signals.critical.emit( @@ -676,7 +676,7 @@ def run(self): images_path = os.path.join(exp_path, pos, "Images") endFilenameSegm = self.mainWin.endFilenameSegm - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) file_path = [ os.path.join(images_path, f) for f in ls diff --git a/cellacdc/workers/tracking.py b/cellacdc/workers/tracking.py index c4c2fc36e..9db3c602b 100644 --- a/cellacdc/workers/tracking.py +++ b/cellacdc/workers/tracking.py @@ -30,7 +30,7 @@ from cellacdc import html_utils -from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import load, utils, core, prompts, printl, config, segm_re_pattern, io from .. import transformation, measurements, cca_functions from ..path import copy_or_move_tree from .. import features, plot @@ -38,7 +38,7 @@ from .. import cca_df_colnames, lineage_tree_cols, default_annot_df from .. import cca_df_colnames_with_tree from .. import cli -from ..utils import resize +from ..tools import resize from .. import segm_utils DEBUG = False @@ -256,7 +256,7 @@ def run(self): images_path = os.path.join(exp_path, pos, "Images") endFilenameSegm = self.mainWin.endFilenameSegm - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) file_path = [ os.path.join(images_path, f) for f in ls @@ -438,7 +438,7 @@ def run(self): imagesPath = os.path.join(self.posPath, "Images") segmFilename = [ f - for f in myutils.listdir(imagesPath) + for f in utils.listdir(imagesPath) if f.endswith(f"{self.endFilenameSegm}.npz") ][0] segmFilePath = os.path.join(imagesPath, segmFilename) @@ -494,7 +494,7 @@ def run(self): acdcEndname = self.endFilenameSegm.replace("_segm", "_acdc_output") acdcFilename = [ f - for f in myutils.listdir(imagesPath) + for f in utils.listdir(imagesPath) if f.endswith(f"{acdcEndname}.csv") ] if acdcFilename: @@ -510,7 +510,7 @@ def run(self): keys = [] for frame_i, lab in enumerate(trackedData): rp = skimage.measure.regionprops(lab) - acdc_df_frame_i = myutils.getBaseAcdcDf(rp) + acdc_df_frame_i = utils.getBaseAcdcDf(rp) acdc_dfs.append(acdc_df_frame_i) keys.append(frame_i) @@ -595,7 +595,7 @@ def run(self): pos_path = os.path.join(exp_path, pos) images_path = os.path.join(pos_path, "Images") - basename, chNames = myutils.getBasenameAndChNames( + basename, chNames = utils.getBasenameAndChNames( images_path, useExt=(".tif", ".h5") ) @@ -603,7 +603,7 @@ def run(self): # Use first found channel, it doesn't matter for metrics for chName in chNames: - file_path = myutils.getChannelFilePath(images_path, chName) + file_path = utils.getChannelFilePath(images_path, chName) if file_path: break else: diff --git a/cellacdc/workers/util.py b/cellacdc/workers/util.py index 2b4fb5b6e..249b56c57 100644 --- a/cellacdc/workers/util.py +++ b/cellacdc/workers/util.py @@ -30,7 +30,7 @@ from cellacdc import html_utils -from .. import load, myutils, core, prompts, printl, config, segm_re_pattern, io +from .. import load, utils, core, prompts, printl, config, segm_re_pattern, io from .. import transformation, measurements, cca_functions from ..path import copy_or_move_tree from .. import features, plot @@ -38,7 +38,7 @@ from .. import cca_df_colnames, lineage_tree_cols, default_annot_df from .. import cca_df_colnames_with_tree from .. import cli -from ..utils import resize +from ..tools import resize from .. import segm_utils DEBUG = False @@ -91,7 +91,7 @@ def run(self): images_path = os.path.join(exp_path, pos, "Images") endFilenameRoi = self.mainWin.endFilenameWithText - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) rois_filepaths = [ os.path.join(images_path, f) for f in ls @@ -132,7 +132,7 @@ def run(self): } self.logger.log("Generating segm mask from ROIs...") - segm_data = myutils.from_imagej_rois_to_segm_data( + segm_data = utils.from_imagej_rois_to_segm_data( TZYX_shape, self.IDsToRoisMapper, self.rescaleRoisSizes, @@ -181,7 +181,7 @@ def run(self): images_path = os.path.join(exp_path, pos, "Images") endFilenameSegm = self.mainWin.endFilenameSegm - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) files_path = [ os.path.join(images_path, f) @@ -216,12 +216,12 @@ def run(self): rois = [] max_ID = posData.segm_data.max() for t, lab in enumerate(posData.segm_data): - rois_t = myutils.from_lab_to_imagej_rois( + rois_t = utils.from_lab_to_imagej_rois( lab, ImagejRoi, t=t, SizeT=posData.SizeT, max_ID=max_ID ) rois.extend(rois_t) else: - rois = myutils.from_lab_to_imagej_rois(posData.segm_data, ImagejRoi) + rois = utils.from_lab_to_imagej_rois(posData.segm_data, ImagejRoi) roi_filepath = posData.segm_npz_path.replace(".npz", ".zip") roi_filepath = roi_filepath.replace("_segm", "_imagej_rois") @@ -267,7 +267,7 @@ def run(self): images_path = os.path.join(exp_path, pos, "Images") endFilenameSegm = self.mainWin.endFilenameSegm - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) file_path = [ os.path.join(images_path, f) for f in ls @@ -294,7 +294,7 @@ def run(self): n_frames = len(posData.segm_data) self.signals.initProgressBar.emit(n_frames) for frame_i, lab in enumerate(posData.segm_data): - df_coords_i = myutils.from_lab_to_obj_coords(lab) + df_coords_i = utils.from_lab_to_obj_coords(lab) dfs.append(df_coords_i) self.signals.progressBar.emit(1) df_filepath = posData.segm_npz_path.replace(".npz", ".csv") @@ -358,7 +358,7 @@ def run(self): images_path = os.path.join(exp_path, pos, "Images") endFilenameSegm = self.mainWin.endFilenameSegm - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) file_path = [ os.path.join(images_path, f) for f in ls @@ -549,7 +549,7 @@ def run(self): ) images_path = os.path.join(exp_path, pos, "Images") - ls = myutils.listdir(images_path) + ls = utils.listdir(images_path) file_path = [ os.path.join(images_path, f) for f in ls @@ -661,7 +661,7 @@ def validateOutputPath(self, path): if path is None: return - images_path = myutils.validate_images_path(path, create_dirs_tree=True) + images_path = utils.validate_images_path(path, create_dirs_tree=True) return images_path @worker_exception_handler diff --git a/cellacdc/workflow/pipelines/measurements_gui_nodes.py b/cellacdc/workflow/pipelines/measurements_gui_nodes.py index 058435702..1aeb35d09 100644 --- a/cellacdc/workflow/pipelines/measurements_gui_nodes.py +++ b/cellacdc/workflow/pipelines/measurements_gui_nodes.py @@ -8,7 +8,7 @@ import numpy as np import skimage.measure -from cellacdc import load, myutils +from cellacdc import load, utils from ..constants import END from ..runnable import RunnableConfig diff --git a/cellacdc/workflow/pipelines/segm_nodes.py b/cellacdc/workflow/pipelines/segm_nodes.py index 60bc43fd6..68e48dec3 100644 --- a/cellacdc/workflow/pipelines/segm_nodes.py +++ b/cellacdc/workflow/pipelines/segm_nodes.py @@ -9,7 +9,7 @@ import numpy as np from tqdm import tqdm -from cellacdc import core, features, io, load, myutils +from cellacdc import core, features, io, load, utils from ..constants import END from ..runnable import RunnableConfig @@ -261,16 +261,16 @@ def ensure_model( else: config.logger_func(f"\nInitializing {ctx.model_name} segmentation model...") - acdc_segment = myutils.import_segment_module(ctx.model_name) - init_argspecs, segment_argspecs = myutils.getModelArgSpec(acdc_segment) - ctx.init_model_kwargs = myutils.parse_model_params( + acdc_segment = utils.import_segment_module(ctx.model_name) + init_argspecs, segment_argspecs = utils.getModelArgSpec(acdc_segment) + ctx.init_model_kwargs = utils.parse_model_params( init_argspecs, ctx.init_model_kwargs ) - ctx.model_kwargs = myutils.parse_model_params(segment_argspecs, ctx.model_kwargs) + ctx.model_kwargs = utils.parse_model_params(segment_argspecs, ctx.model_kwargs) if ctx.second_channel_name is not None: ctx.init_model_kwargs["is_rgb"] = True - ctx.model = myutils.init_segm_model( + ctx.model = utils.init_segm_model( acdc_segment, state.pos_data, ctx.init_model_kwargs ) if ctx.model is None: diff --git a/examples/run_headless_workflow.py b/examples/run_headless_workflow.py index f57949fcb..c6c7978b2 100644 --- a/examples/run_headless_workflow.py +++ b/examples/run_headless_workflow.py @@ -56,12 +56,12 @@ def collect_position_paths(exp_path: str, user_ch: str) -> list[str]: - from cellacdc import myutils + from cellacdc import utils paths: list[str] = [] - for pos in myutils.get_pos_foldernames(exp_path): + for pos in utils.get_pos_foldernames(exp_path): images_path = os.path.join(exp_path, pos, "Images") - paths.append(myutils.getChannelFilePath(images_path, user_ch)) + paths.append(utils.getChannelFilePath(images_path, user_ch)) return paths @@ -156,9 +156,9 @@ def main() -> int: print("Edit USER CONFIG in examples/run_headless_workflow.py first.", file=sys.stderr) return 1 - from cellacdc import myutils + from cellacdc import utils - logger, _, log_path, _ = myutils.setupLogger(module="headless", logs_path=None) + logger, _, log_path, _ = utils.setupLogger(module="headless", logs_path=None) paths = collect_position_paths(EXPERIMENT_PATH, USER_CH_NAME) if not paths: logger.error(f"No positions found under {EXPERIMENT_PATH}") diff --git a/scripts/fix_split_imports.py b/scripts/fix_split_imports.py index 9e0ed9603..38ae95a5a 100644 --- a/scripts/fix_split_imports.py +++ b/scripts/fix_split_imports.py @@ -9,7 +9,7 @@ ROOT = Path(__file__).resolve().parents[1] / "cellacdc" PACKAGES: dict[str, set[str]] = { - "myutils": { + "utils": { "dataframe", "install", "io", diff --git a/scripts/rename_utils_tools.py b/scripts/rename_utils_tools.py new file mode 100644 index 000000000..99c6d87cc --- /dev/null +++ b/scripts/rename_utils_tools.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Rename cellacdc.utils -> tools and cellacdc.utils -> utils in source files.""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +SKIP_DIRS = {".git", "__pycache__", ".venv", "venv", "node_modules"} + +# Phase 1: batch-tool package (old utils -> tools). Run before utils -> utils. +TOOLS_PATTERNS: list[tuple[str, str]] = [ + (r"\bfrom \.\.utils import resize\b", "from ..tools import resize"), + (r"\bfrom \.\.utils import base\b", "from ..tools import base"), + (r"\bfrom \.\.utils\.", "from ..tools."), + (r"\bfrom \.utils\.", "from .tools."), + (r"\bfrom \.utils import", "from .tools import"), + (r"\bfrom cellacdc\.utils\.", "from cellacdc.tools."), + (r"\bfrom cellacdc\.utils import", "from cellacdc.tools import"), + (r'"cellacdc/tools/', '"cellacdc/tools/'), + (r"'cellacdc/tools/", "'cellacdc/tools/"), + (r"\bcellacdc/utils/", "cellacdc/tools/"), +] + +# Phase 2: helper package (utils -> utils). +UTILS_PATTERNS: list[tuple[str, str]] = [ + (r"\bmyutils\b", "utils"), + (r'"cellacdc/utils/', '"cellacdc/tools/'), + (r"'cellacdc/utils/", "'cellacdc/tools/"), + (r"\bcellacdc/utils/", "cellacdc/tools/"), + (r"\bcellacdc\.utils\b", "cellacdc.utils"), +] + +# Phase 3: same-package imports inside tools/ +TOOLS_INTERNAL: list[tuple[str, str]] = [ + (r"\bfrom \.\.tools import base\b", "from . import base"), +] + + +def iter_files() -> list[Path]: + files: list[Path] = [] + for path in ROOT.rglob("*"): + if path.suffix != ".py": + continue + if any(part in SKIP_DIRS for part in path.parts): + continue + files.append(path) + return files + + +def apply_patterns(text: str, patterns: list[tuple[str, str]]) -> str: + for pattern, repl in patterns: + text = re.sub(pattern, repl, text) + return text + + +def fix_tools_package() -> None: + tools_dir = ROOT / "cellacdc" / "tools" + if not tools_dir.is_dir(): + return + for path in tools_dir.rglob("*.py"): + text = path.read_text() + updated = apply_patterns(text, TOOLS_INTERNAL) + if updated != text: + path.write_text(updated) + + +def main() -> None: + for path in iter_files(): + text = path.read_text() + updated = apply_patterns(text, TOOLS_PATTERNS) + updated = apply_patterns(updated, UTILS_PATTERNS) + if updated != text: + path.write_text(updated) + fix_tools_package() + print("Import rewrites complete.") + + +if __name__ == "__main__": + main() diff --git a/scripts/split_god_files.py b/scripts/split_god_files.py index 80c3362bb..818694acb 100644 --- a/scripts/split_god_files.py +++ b/scripts/split_god_files.py @@ -56,7 +56,7 @@ def write_module( path.write_text(content) -def assign_myutils(name: str) -> str: +def assign_utils(name: str) -> str: rules: list[tuple[str, str]] = [ ("logging", r"log|Logger"), ("paths", r"path|folder|dir|recent|trim_path|explorer|filemaneger|gdrive|acdc_data|pos_folder|images_folder|PosStatus|pos_status"), @@ -455,13 +455,7 @@ def split_widgets(src_file: Path, pkg_dir: Path) -> None: def main() -> None: - split_package( - CELLACDC / "myutils.py", - CELLACDC / "myutils", - assign_myutils, - "Cell-ACDC utility helpers", - delete_src=True, - ) + # utils/ is already split from the former myutils.py monolith. split_package( CELLACDC / "workers.py", CELLACDC / "workers", diff --git a/tests/prompt_segm/test_sam.py b/tests/prompt_segm/test_sam.py index 102af06c4..55093dc6f 100644 --- a/tests/prompt_segm/test_sam.py +++ b/tests/prompt_segm/test_sam.py @@ -4,7 +4,7 @@ import pytest -from cellacdc import myutils +from cellacdc import utils from tests.utils import ( ensure_sam, get_test_dataset, @@ -20,7 +20,7 @@ class TestPromptableSAM: @pytest.fixture(scope="class", autouse=True) def download_models(self): """Download SAM models if not present.""" - myutils.download_model("segment_anything") + utils.download_model("segment_anything") @pytest.fixture def test_data(self): @@ -46,7 +46,7 @@ def test_promptable_segmentation_with_ground_truth_centroids(self, test_data): centroids = get_ground_truth_centroids(gt_mask) assert len(centroids) > 0, "No objects found in ground truth" - acdcPromptSegment = myutils.import_promptable_segment_module("segment_anything") + acdcPromptSegment = utils.import_promptable_segment_module("segment_anything") model = acdcPromptSegment.Model(model_type="Large", gpu=True) # Add prompts for each ground truth centroid diff --git a/tests/prompt_segm/test_sam2.py b/tests/prompt_segm/test_sam2.py index 2ff5826b1..bf71db5d4 100644 --- a/tests/prompt_segm/test_sam2.py +++ b/tests/prompt_segm/test_sam2.py @@ -4,7 +4,7 @@ import pytest -from cellacdc import myutils +from cellacdc import utils from tests.utils import ( ensure_sam2, get_test_dataset, @@ -20,7 +20,7 @@ class TestPromptableSAM2: @pytest.fixture(scope="class", autouse=True) def download_models(self): """Download SAM2 models if not present.""" - myutils.download_model("sam2") + utils.download_model("sam2") @pytest.fixture def test_data(self): @@ -46,7 +46,7 @@ def test_promptable_segmentation_with_ground_truth_centroids(self, test_data): centroids = get_ground_truth_centroids(gt_mask) assert len(centroids) > 0, "No objects found in ground truth" - acdcPromptSegment = myutils.import_promptable_segment_module("sam2") + acdcPromptSegment = utils.import_promptable_segment_module("sam2") model = acdcPromptSegment.Model(model_type="Large", gpu=True) # Add prompts for each ground truth centroid diff --git a/tests/segm/test_cellsam.py b/tests/segm/test_cellsam.py index 3db69391f..b28b67d6c 100644 --- a/tests/segm/test_cellsam.py +++ b/tests/segm/test_cellsam.py @@ -4,7 +4,7 @@ import pytest -from cellacdc import myutils +from cellacdc import utils from tests.utils import ( ensure_cellsam, get_test_posdata, @@ -34,7 +34,7 @@ def test_automatic_segmentation_sampled_frames(self, test_frames, posData): """Test CellSAM automatic segmentation on sampled frames (every 20th).""" frames, frame_indices = test_frames - acdcSegment = myutils.import_segment_module("cellsam") + acdcSegment = utils.import_segment_module("cellsam") model = acdcSegment.Model( model_type="General", diff --git a/tests/segm/test_sam.py b/tests/segm/test_sam.py index 886693dd0..c3ca4ea1e 100644 --- a/tests/segm/test_sam.py +++ b/tests/segm/test_sam.py @@ -4,7 +4,7 @@ import pytest -from cellacdc import myutils +from cellacdc import utils from tests.utils import ( ensure_sam, get_test_posdata, @@ -23,7 +23,7 @@ class TestSAMAutomaticSegmentation: @pytest.fixture(scope="class", autouse=True) def download_models(self): """Download SAM models if not present.""" - myutils.download_model("segment_anything") + utils.download_model("segment_anything") @pytest.fixture def posData(self): @@ -39,7 +39,7 @@ def test_automatic_segmentation_sampled_frames(self, test_frames, posData): """Test SAM automatic segmentation on sampled frames.""" frames, frame_indices = test_frames - acdcSegment = myutils.import_segment_module("segment_anything") + acdcSegment = utils.import_segment_module("segment_anything") model = acdcSegment.Model( model_type="Small", diff --git a/tests/segm/test_sam2.py b/tests/segm/test_sam2.py index 25cf3a0d3..280c2e45f 100644 --- a/tests/segm/test_sam2.py +++ b/tests/segm/test_sam2.py @@ -4,7 +4,7 @@ import pytest -from cellacdc import myutils +from cellacdc import utils from tests.utils import ( ensure_sam2, get_test_posdata, @@ -23,7 +23,7 @@ class TestSAM2AutomaticSegmentation: @pytest.fixture(scope="class", autouse=True) def download_models(self): """Download SAM2 models if not present.""" - myutils.download_model("sam2") + utils.download_model("sam2") @pytest.fixture def posData(self): @@ -39,7 +39,7 @@ def test_automatic_segmentation_sampled_frames(self, test_frames, posData): """Test SAM2 automatic segmentation on sampled frames.""" frames, frame_indices = test_frames - acdcSegment = myutils.import_segment_module("sam2") + acdcSegment = utils.import_segment_module("sam2") model = acdcSegment.Model( model_type="Tiny", diff --git a/tests/test_split_packages.py b/tests/test_split_packages.py index e3ddfafff..f853f3ce5 100644 --- a/tests/test_split_packages.py +++ b/tests/test_split_packages.py @@ -7,7 +7,12 @@ ROOT = Path(__file__).resolve().parents[1] PACKAGES = { - "cellacdc.myutils": [ + "cellacdc.tools": [ + "base", + "concat", + "align", + ], + "cellacdc.utils": [ "logging", "paths", "install", From 1486bab6c8a9831eb9dd078ca3910411219b8abe Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 16:28:00 +0200 Subject: [PATCH 19/21] Split widgets into canvas, controls, and toolbars subpackages. Break up the remaining widget god modules by rendering role while keeping widgets.* imports working through the package barrel. Co-authored-by: Cursor --- cellacdc/widgets/__init__.py | 304 +- cellacdc/widgets/canvas.py | 4234 -------------- cellacdc/widgets/canvas/__init__.py | 104 + cellacdc/widgets/canvas/histogram.py | 1301 +++++ cellacdc/widgets/canvas/images.py | 796 +++ cellacdc/widgets/canvas/imshow.py | 1075 ++++ cellacdc/widgets/canvas/plot_items.py | 1170 ++++ cellacdc/widgets/canvas/rois.py | 303 + cellacdc/widgets/canvas/scrollbars.py | 595 ++ cellacdc/widgets/controls.py | 5171 ----------------- cellacdc/widgets/controls/__init__.py | 153 + cellacdc/widgets/controls/dialogs.py | 1309 +++++ cellacdc/widgets/controls/forms.py | 1382 +++++ cellacdc/widgets/controls/inputs.py | 976 ++++ cellacdc/widgets/controls/metrics.py | 1049 ++++ cellacdc/widgets/controls/panels.py | 1025 ++++ cellacdc/widgets/toolbars/__init__.py | 52 + cellacdc/widgets/toolbars/_base.py | 558 ++ .../{toolbars.py => toolbars/feature.py} | 422 +- scripts/split_widgets_subpackages.py | 446 ++ tests/test_split_packages.py | 31 +- 21 files changed, 12499 insertions(+), 9957 deletions(-) delete mode 100644 cellacdc/widgets/canvas.py create mode 100644 cellacdc/widgets/canvas/__init__.py create mode 100644 cellacdc/widgets/canvas/histogram.py create mode 100644 cellacdc/widgets/canvas/images.py create mode 100644 cellacdc/widgets/canvas/imshow.py create mode 100644 cellacdc/widgets/canvas/plot_items.py create mode 100644 cellacdc/widgets/canvas/rois.py create mode 100644 cellacdc/widgets/canvas/scrollbars.py delete mode 100644 cellacdc/widgets/controls.py create mode 100644 cellacdc/widgets/controls/__init__.py create mode 100644 cellacdc/widgets/controls/dialogs.py create mode 100644 cellacdc/widgets/controls/forms.py create mode 100644 cellacdc/widgets/controls/inputs.py create mode 100644 cellacdc/widgets/controls/metrics.py create mode 100644 cellacdc/widgets/controls/panels.py create mode 100644 cellacdc/widgets/toolbars/__init__.py create mode 100644 cellacdc/widgets/toolbars/_base.py rename cellacdc/widgets/{toolbars.py => toolbars/feature.py} (69%) create mode 100644 scripts/split_widgets_subpackages.py diff --git a/cellacdc/widgets/__init__.py b/cellacdc/widgets/__init__.py index 0836ba747..85c7bcf3c 100644 --- a/cellacdc/widgets/__init__.py +++ b/cellacdc/widgets/__init__.py @@ -1,4 +1,4 @@ -"""GUI widgets package (controls, canvas, toolbars) + components re-exports.""" +"""GUI widgets package (canvas, controls, toolbars) + components re-exports.""" from ..components.palette import * # noqa: F403 from ..components.progress import * # noqa: F403 @@ -12,283 +12,269 @@ from .canvas import ( BaseGradientEditorItemImage, BaseGradientEditorItemLabels, + baseHistogramLUTitem, + labelsGradientWidget, + myColorButton, + myHistogramLUTitem, + overlayLabelsGradientWidget, BaseImageItem, BaseLabelsImageItem, - BaseScatterPlotItem, ChildImageItem, - ContourItem, - CustomAnnotationScatterPlotItem, - DelROI, - GhostContourItem, GhostMaskItem, + OverlayImageItem, + ParentImageItem, + _ImShowImageItem, + labImageItem, ImShow, ImShowPlotItem, + BaseScatterPlotItem, + ContourItem, + CustomAnnotationScatterPlotItem, + GhostContourItem, LabelItem, LabelRoiCircularItem, MainPlotItem, - MouseCursor, - OverlayImageItem, - ParentImageItem, PlotCurveItem, PointsScatterPlotItem, - PolyLineROI, - ROI, RectItem, RulerPlotItem, ScaleBar, ScatterPlotItem, - ScrollBarWithNumericControl, + myLabelItem, + DelROI, + PolyLineROI, + ROI, ZoomROI, - _ImShowImageItem, - baseHistogramLUTitem, - labImageItem, + MouseCursor, + ScrollBarWithNumericControl, labelledQScrollbar, - labelsGradientWidget, linkedQScrollbar, - myColorButton, - myHistogramLUTitem, - myLabelItem, navigateQScrollBar, - overlayLabelsGradientWidget, sliderWithSpinBox, ) from .controls import ( - AlphaNumericComboBox, + QDialogListbox, + installJavaDialog, + myMessageBox, + selectTrackerGUI, + view_visualcpp_screenshot, + warnVisualCppRequired, AutoSaveIntervalWidget, - CenteredDoubleSpinbox, - CheckableAction, - CheckableSpinBoxWidgets, CheckableWidget, CheckboxesGroupBox, - ComboBox, CopiableCommandWidget, + FontSizeWidget, + LabelsWidget, + PostProcessSegmSlider, + PostProcessSegmSpinbox, + PreProcessingSelector, + RangeSelector, + RescaleImageJroisGroupbox, + SamInputPointsWidget, + TimeWidget, + YeazV2SelectModelNameCombobox, + formWidget, + guiTabControl, + selectStartStopFrames, + AlphaNumericComboBox, + CenteredDoubleSpinbox, + ComboBox, DoubleSpinBox, ExpandableListBox, - FeatureSelectorButton, FloatLineEdit, - FontSizeWidget, IntLineEdit, - KeptObjectIDsList, KeySequenceFromText, - Label, - LabelsWidget, - LatexLabel, LineEdit, - ManualBackgroundToolBar, - ManualTrackingToolBar, OddSpinBox, - OrderableListWidget, - PixelSizeGroupbox, - PostProcessSegmSlider, - PostProcessSegmSpinbox, - PostProcessSegmWidget, - PreProcessingSelector, QCenteredComboBox, QClickableLabel, - QDialogListbox, - QKeyEventToString, - RangeSelector, ReadOnlyLineEdit, - RescaleImageJroisGroupbox, - SamInputPointsWidget, - SavePointsLayerButton, SearchLineEdit, - SetMeasurementsGroupBox, ShortcutLineEdit, SpinBox, + VectorLineEdit, + WhitelistLineEdit, + highlightableQWidgetAction, + mySpinBox, + readOnlyDoubleSpinbox, + readOnlySpinbox, + PixelSizeGroupbox, + SetMeasurementsGroupBox, + _metricsQGBox, + channelMetricsQGBox, + objIntesityMeasurQGBox, + objPropsQGBox, + CheckableAction, + CheckableSpinBoxWidgets, + FeatureSelectorButton, + KeptObjectIDsList, + Label, + LatexLabel, + OrderableListWidget, SwitchPlaneCombobox, - TimeWidget, TimestampItem, Toggle, ToggleTerminalButton, ToggleVisibilityButton, ToggleVisibilityCheckBox, - VectorLineEdit, - WhitelistLineEdit, - YeazV2SelectModelNameCombobox, - _metricsQGBox, - addWidgetToScrollArea, - channelMetricsQGBox, expandCollapseButton, - formWidget, - get_min_width_for_no_scrollbar, - guiTabControl, - highlightableQWidgetAction, - installJavaDialog, listWidget, - macShortcutToWindows, - modifierKeyToText, - myMessageBox, - mySpinBox, - objIntesityMeasurQGBox, - objPropsQGBox, - readOnlyDoubleSpinbox, - readOnlySpinbox, - selectStartStopFrames, - selectTrackerGUI, statusBarPermanentLabel, - view_visualcpp_screenshot, - warnVisualCppRequired, - windowsShortcutToMac, ) from .toolbars import ( - CopyLostObjectToolbar, - DrawClearRegionToolbar, GradientToolButton, - HighlightedIDToolbar, - MagicPromptsToolbar, + ManualBackgroundToolBar, + ManualTrackingToolBar, OverlayChannelToolButton, - OverlayToolbar, PointsLayerToolButton, - PointsLayersToolbar, - PromptableModelPointsLayerToolbar, + SavePointsLayerButton, ToolBar, ToolBarSeparator, ToolButtonCustomColor, ToolButtonTextIcon, - WandControlsToolbar, - WhitelistIDsToolbar, customAnnotToolButton, rightClickToolButton, + CopyLostObjectToolbar, + DrawClearRegionToolbar, + HighlightedIDToolbar, + MagicPromptsToolbar, + OverlayToolbar, + PointsLayersToolbar, + PromptableModelPointsLayerToolbar, + WandControlsToolbar, + WhitelistIDsToolbar, ) __all__ = [ "BaseGradientEditorItemImage", "BaseGradientEditorItemLabels", + "baseHistogramLUTitem", + "labelsGradientWidget", + "myColorButton", + "myHistogramLUTitem", + "overlayLabelsGradientWidget", "BaseImageItem", "BaseLabelsImageItem", - "BaseScatterPlotItem", "ChildImageItem", - "ContourItem", - "CustomAnnotationScatterPlotItem", - "DelROI", - "GhostContourItem", "GhostMaskItem", + "OverlayImageItem", + "ParentImageItem", + "_ImShowImageItem", + "labImageItem", "ImShow", "ImShowPlotItem", + "BaseScatterPlotItem", + "ContourItem", + "CustomAnnotationScatterPlotItem", + "GhostContourItem", "LabelItem", "LabelRoiCircularItem", "MainPlotItem", - "MouseCursor", - "OverlayImageItem", - "ParentImageItem", "PlotCurveItem", "PointsScatterPlotItem", - "PolyLineROI", - "ROI", "RectItem", "RulerPlotItem", "ScaleBar", "ScatterPlotItem", - "ScrollBarWithNumericControl", + "myLabelItem", + "DelROI", + "PolyLineROI", + "ROI", "ZoomROI", - "_ImShowImageItem", - "baseHistogramLUTitem", - "labImageItem", + "MouseCursor", + "ScrollBarWithNumericControl", "labelledQScrollbar", - "labelsGradientWidget", "linkedQScrollbar", - "myColorButton", - "myHistogramLUTitem", - "myLabelItem", "navigateQScrollBar", - "overlayLabelsGradientWidget", "sliderWithSpinBox", - "AlphaNumericComboBox", + "QDialogListbox", + "installJavaDialog", + "myMessageBox", + "selectTrackerGUI", + "view_visualcpp_screenshot", + "warnVisualCppRequired", "AutoSaveIntervalWidget", - "CenteredDoubleSpinbox", - "CheckableAction", - "CheckableSpinBoxWidgets", "CheckableWidget", "CheckboxesGroupBox", - "ComboBox", "CopiableCommandWidget", + "FontSizeWidget", + "LabelsWidget", + "PostProcessSegmSlider", + "PostProcessSegmSpinbox", + "PreProcessingSelector", + "RangeSelector", + "RescaleImageJroisGroupbox", + "SamInputPointsWidget", + "TimeWidget", + "YeazV2SelectModelNameCombobox", + "formWidget", + "guiTabControl", + "selectStartStopFrames", + "AlphaNumericComboBox", + "CenteredDoubleSpinbox", + "ComboBox", "DoubleSpinBox", "ExpandableListBox", - "FeatureSelectorButton", "FloatLineEdit", - "FontSizeWidget", "IntLineEdit", - "KeptObjectIDsList", "KeySequenceFromText", - "Label", - "LabelsWidget", - "LatexLabel", "LineEdit", - "ManualBackgroundToolBar", - "ManualTrackingToolBar", "OddSpinBox", - "OrderableListWidget", - "PixelSizeGroupbox", - "PostProcessSegmSlider", - "PostProcessSegmSpinbox", - "PostProcessSegmWidget", - "PreProcessingSelector", "QCenteredComboBox", "QClickableLabel", - "QDialogListbox", - "QKeyEventToString", - "RangeSelector", "ReadOnlyLineEdit", - "RescaleImageJroisGroupbox", - "SamInputPointsWidget", - "SavePointsLayerButton", "SearchLineEdit", - "SetMeasurementsGroupBox", "ShortcutLineEdit", "SpinBox", + "VectorLineEdit", + "WhitelistLineEdit", + "highlightableQWidgetAction", + "mySpinBox", + "readOnlyDoubleSpinbox", + "readOnlySpinbox", + "PixelSizeGroupbox", + "SetMeasurementsGroupBox", + "_metricsQGBox", + "channelMetricsQGBox", + "objIntesityMeasurQGBox", + "objPropsQGBox", + "CheckableAction", + "CheckableSpinBoxWidgets", + "FeatureSelectorButton", + "KeptObjectIDsList", + "Label", + "LatexLabel", + "OrderableListWidget", "SwitchPlaneCombobox", - "TimeWidget", "TimestampItem", "Toggle", "ToggleTerminalButton", "ToggleVisibilityButton", "ToggleVisibilityCheckBox", - "VectorLineEdit", - "WhitelistLineEdit", - "YeazV2SelectModelNameCombobox", - "_metricsQGBox", - "addWidgetToScrollArea", - "channelMetricsQGBox", "expandCollapseButton", - "formWidget", - "get_min_width_for_no_scrollbar", - "guiTabControl", - "highlightableQWidgetAction", - "installJavaDialog", "listWidget", - "macShortcutToWindows", - "modifierKeyToText", - "myMessageBox", - "mySpinBox", - "objIntesityMeasurQGBox", - "objPropsQGBox", - "readOnlyDoubleSpinbox", - "readOnlySpinbox", - "selectStartStopFrames", - "selectTrackerGUI", "statusBarPermanentLabel", - "view_visualcpp_screenshot", - "warnVisualCppRequired", - "windowsShortcutToMac", - "CopyLostObjectToolbar", - "DrawClearRegionToolbar", "GradientToolButton", - "HighlightedIDToolbar", - "MagicPromptsToolbar", + "ManualBackgroundToolBar", + "ManualTrackingToolBar", "OverlayChannelToolButton", - "OverlayToolbar", "PointsLayerToolButton", - "PointsLayersToolbar", - "PromptableModelPointsLayerToolbar", + "SavePointsLayerButton", "ToolBar", "ToolBarSeparator", "ToolButtonCustomColor", "ToolButtonTextIcon", - "WandControlsToolbar", - "WhitelistIDsToolbar", "customAnnotToolButton", "rightClickToolButton", + "CopyLostObjectToolbar", + "DrawClearRegionToolbar", + "HighlightedIDToolbar", + "MagicPromptsToolbar", + "OverlayToolbar", + "PointsLayersToolbar", + "PromptableModelPointsLayerToolbar", + "WandControlsToolbar", + "WhitelistIDsToolbar", ] diff --git a/cellacdc/widgets/canvas.py b/cellacdc/widgets/canvas.py deleted file mode 100644 index 31e2cedd6..000000000 --- a/cellacdc/widgets/canvas.py +++ /dev/null @@ -1,4234 +0,0 @@ -"""GUI widgets: canvas.""" - -from collections import defaultdict, deque -from typing import Dict, List, Union, Iterable, Sequence -import os -import sys -import operator -import time -import re -import datetime -import numpy as np -import pandas as pd -import math -import traceback -import logging -import textwrap -import random - -from functools import partial -from math import ceil - -import skimage.draw -import skimage.morphology - -from matplotlib.colors import ListedColormap, LinearSegmentedColormap -import matplotlib.pyplot as plt -import matplotlib -from matplotlib.backends.backend_agg import FigureCanvasAgg - -from qtpy.QtCore import ( - Signal, - QTimer, - Qt, - QPoint, - QUrl, - Property, - QPropertyAnimation, - QEasingCurve, - QLocale, - QSize, - QRect, - QPointF, - QRect, - QPoint, - QEasingCurve, - QRegularExpression, - QEvent, - QEventLoop, - QPropertyAnimation, - QObject, - QItemSelectionModel, - QAbstractListModel, - QModelIndex, - QByteArray, - QDataStream, - QMimeData, - QAbstractItemModel, - QIODevice, - QItemSelection, - PYQT6, - QRectF, -) -from qtpy.QtGui import ( - QFont, - QPalette, - QColor, - QPen, - QKeyEvent, - QBrush, - QPainter, - QRegularExpressionValidator, - QIcon, - QPixmap, - QKeySequence, - QLinearGradient, - QShowEvent, - QDesktopServices, - QFontMetrics, - QGuiApplication, - QLinearGradient, - QImage, - QCursor, - QPicture, -) -from qtpy.QtWidgets import ( - QTextEdit, - QLabel, - QProgressBar, - QHBoxLayout, - QToolButton, - QCheckBox, - QApplication, - QWidget, - QVBoxLayout, - QMainWindow, - QTreeWidgetItemIterator, - QLineEdit, - QSlider, - QSpinBox, - QGridLayout, - QRadioButton, - QScrollArea, - QSizePolicy, - QComboBox, - QPushButton, - QScrollBar, - QGroupBox, - QAbstractSlider, - QDoubleSpinBox, - QWidgetAction, - QAction, - QTabWidget, - QAbstractSpinBox, - QToolBar, - QStyleOptionSpinBox, - QStyle, - QDialog, - QSpacerItem, - QFrame, - QMenu, - QActionGroup, - QListWidget, - QPlainTextEdit, - QFileDialog, - QListView, - QAbstractItemView, - QTreeWidget, - QTreeWidgetItem, - QListWidgetItem, - QLayout, - QStylePainter, - QGraphicsBlurEffect, - QGraphicsProxyWidget, - QGraphicsObject, - QButtonGroup, - QStyleOptionSlider, -) -import qtpy.compat - -import pyqtgraph as pg - -pg.setConfigOption("imageAxisOrder", "row-major") - -from .. import utils, measurements, is_mac, is_win, html_utils, is_linux -from .. import printl, settings_folderpath -from .. import colors, config -from .. import html_path -from .. import _palettes -from .. import load -from .. import apps -from .. import plot -from .. import annotate -from .. import urls -from .. import _core, core -from .. import QtScoped -from .. import prompts -from ..acdc_regex import float_regex -from ..config import PREPROCESS_MAPPER -from .. import _base_widgets - -from ..components.palette import ( # noqa: E402 - BASE_COLOR, - Gradients, - GradientsImage, - GradientsLabels, - LINEEDIT_INVALID_ENTRY_STYLESHEET, - LINEEDIT_WARNING_STYLESHEET, - LISTWIDGET_STYLESHEET, - PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, - PROGRESSBAR_QCOLOR, - TEXT_COLOR, - TREEWIDGET_STYLESHEET, - cmaps, - font, - getCustomGradients, - nonInvertibleCmaps, - sign_int_mapper, - str_to_operator_mapper, -) -from ..components.progress import QtHandler, QLog, XStream # noqa: E402 -from ..components.buttons import * # noqa: E402, F403 -from ..components.layout import * # noqa: E402, F403 -from ..components.inputs_basic import * # noqa: E402, F403 -from ..components.path_controls import * # noqa: E402, F403 - -from ..components.lists import * # noqa: E402, F403 -from ..components.base import QBaseWindow # noqa: E402 -from ..components.progress import ( # noqa: E402 - LoadingCircleAnimation, - NoneWidget, - ProgressBar, - ProgressBarWithETA, - QLogConsole, -) - -class ContourItem(pg.PlotCurveItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - self._prevData = None - - def clear(self): - try: - self.setData([], []) - except AttributeError as e: - pass - - def tempClear(self): - try: - self._prevData = [d.copy() for d in self.getData()] - self.clear() - except Exception as e: - pass - - def restore(self): - if self._prevData is not None: - if self._prevData[0] is not None: - self.setData(*self._prevData) - - -class BaseScatterPlotItem(pg.ScatterPlotItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - - def tempClear(self): - try: - self._prevData = [d.copy() for d in self.getData()] - self.setData([], []) - except Exception as e: - pass - - def restore(self): - if self._prevData is not None: - if self._prevData[0] is not None: - self.setData(*self._prevData) - - -class CustomAnnotationScatterPlotItem(BaseScatterPlotItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - - -class ScatterPlotItem(pg.ScatterPlotItem): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.updateBrushAndPen(**kwargs) - - def updateBrushAndPen(self, **kwargs): - brush = kwargs.get("brush") - if brush is not None: - self._itemBrush = brush - pen = kwargs.get("pen") - if pen is not None: - self._itemPen = pen - - def setData(self, *args, **kwargs): - super().setData(*args, **kwargs) - self.updateBrushAndPen(**kwargs) - - def itemBrush(self): - return self._itemBrush - - def itemPen(self): - return self._itemPen - - def removePoint(self, index): - newData = np.delete(self.data, index) - # Update the index of current points - for i in range(index, len(newData)): - spotItem = newData[i]["item"] - spotItem._index = i - newData[i]["item"] = spotItem - - self.data = newData - self.prepareGeometryChange() - self.informViewBoundsChanged() - self.bounds = [None, None] - self.invalidate() - self.updateSpots(newData) - self.sigPlotChanged.emit(self) - - def coordsToNumpy(self, includeData=False, rounded=True, decimals=None): - points = self.points() - nrows = len(points) - coords_arr = np.zeros((nrows, 2)) - data_arr = None - for p, point in enumerate(points): - pos = point.pos() - x, y = pos.x(), pos.y() - if includeData: - data = point.data() - if data_arr is None: - try: - ncols = len(data) - except Exception as e: - data = [data] - ncols = 1 - data_arr = np.zeros((nrows, ncols)) - for j, data_j in enumerate(data): - data_arr[p, j] = data_j - - coords_arr[p, 0] = y - coords_arr[p, 1] = x - if not includeData: - out_arr = coords_arr - elif data_arr is not None: - out_arr = np.column_stack((data_arr, coords_arr)) - else: - out_arr = coords_arr - cast_to_int = decimals is None - decimals = decimals if decimals is not None else 0 - if rounded: - out_arr = np.round(out_arr, decimals) - if cast_to_int: - out_arr = out_arr.astype(int) - return out_arr - - -class myLabelItem(pg.LabelItem): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._prevText = "" - - def setText(self, text, **args): - self.text = text - opts = self.opts - for k in args: - opts[k] = args[k] - - if "size" in self.opts: - size = self.opts["size"] - if size == "0pt" or size == "0px": - self.opts["size"] = "1pt" - super().setText("", size="1pt") - return - - optlist = [] - - color = self.opts["color"] - if color is None: - color = pg.getConfigOption("foreground") - color = pg.functions.mkColor(color) - optlist.append("color: " + color.name(QColor.NameFormat.HexArgb)) - if "size" in opts: - size = opts["size"] - if not isinstance(size, str): - size = f"{size}px" - optlist.append("font-size: " + size) - if "bold" in opts and opts["bold"] in [True, False]: - optlist.append( - "font-weight: " + {True: "bold", False: "normal"}[opts["bold"]] - ) - if "italic" in opts and opts["italic"] in [True, False]: - optlist.append( - "font-style: " + {True: "italic", False: "normal"}[opts["italic"]] - ) - full = "%s" % ("; ".join(optlist), text) - # print full - self.item.setHtml(full) - self.updateMin() - self.resizeEvent(None) - self.updateGeometry() - - def tempClearText(self): - if self.text: - self._prevText = self.text - self.setText("") - - def restoreText(self): - if self._prevText: - self.setText(self._prevText) - - -class LabelRoiCircularItem(pg.ScatterPlotItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - - def setImageShape(self, shape): - self._shape = shape - - def slice(self, zRange=None, tRange=None): - self.mask() - if zRange is None: - _slice = self._slice - else: - zmin, zmax = zRange - _slice = (slice(zmin, zmax), *self._slice) - - if tRange is not None: - tmin, tmax = tRange - _slice = (slice(tmin, tmax), *_slice) - - return _slice - - def mask(self): - shape = self._shape - radius = int(self.opts["size"] / 2) - mask = skimage.morphology.disk(radius, dtype=bool) - xx, yy = self.getData() - Yc, Xc = yy[0], xx[0] - mask, self._slice = utils.clipSelemMask(mask, shape, Yc, Xc, copy=False) - return mask - - -class PolyLineROI(pg.PolyLineROI): - def __init__(self, positions, closed=False, pos=None, **args): - super().__init__(positions, closed, pos, **args) - - -class BaseGradientEditorItemImage(pg.GradientEditorItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - - def restoreState(self, state): - pg.graphicsItems.GradientEditorItem.Gradients = GradientsImage - return super().restoreState(state) - - -class MouseCursor(QWidget): - def __init__(self, parent=None) -> None: - super().__init__(parent) - self._x = None - self._y = None - self.setMouseTracking(True) - - def mouseMoveEvent(self, event) -> None: - self.move(event.pos()) - self.update() - return super().mouseMoveEvent(event) - - # def drawAtPos(self, x, y): - # self._x = x - # self._y = y - # self.update() - - def paintEvent(self, event) -> None: - p = QPainter(self) - # p.setPen(QPen(QColor(0,0,0))) - # p.setBrush(QBrush(QColor(70,70,70,200))) - p.drawLine(0, 0, 200, 0) - p.end() - - -class BaseGradientEditorItemLabels(pg.GradientEditorItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - - def restoreState(self, state): - pg.graphicsItems.GradientEditorItem.Gradients = GradientsLabels - return super().restoreState(state) - - -class baseHistogramLUTitem(pg.HistogramLUTItem): - sigAddColormap = Signal(object, str) - sigRescaleIntes = Signal(object) - - def __init__(self, name="image", axisLabel="", parent=None, **kwargs): - pg.GradientEditorItem = BaseGradientEditorItemLabels - - super().__init__(**kwargs) - - self.labelStyle = {"color": "#ffffff", "font-size": "11px"} - - if axisLabel: - self.setAxisLabel(axisLabel) - - self.cmaps = cmaps - self._parent = parent - self.name = name - - self.gradient.colorDialog.setWindowFlags(Qt.Dialog | Qt.WindowStaysOnTopHint) - self.gradient.colorDialog.accepted.disconnect() - self.gradient.colorDialog.accepted.connect(self.tickColorAccepted) - - self.isInverted = False - self.lastGradientName = "grey" - self.lastGradient = Gradients["grey"] - - for action in self.gradient.menu.actions(): - if action.text() == "HSV": - HSV_action = action - elif action.text() == "RGB": - RGB_ation = action - self.gradient.menu.removeAction(HSV_action) - self.gradient.menu.removeAction(RGB_ation) - - # Rescale intensities (LUT) - rescaleIntensMenu = self.gradient.menu.addMenu("Rescale intensities (LUT)") - rescaleActionGroup = QActionGroup(self) - rescaleActionGroup.setExclusive(True) - - self.rescaleEach2DimgAction = QAction( - "Rescale each 2D image", rescaleIntensMenu - ) - self.rescaleEach2DimgAction.setCheckable(True) - self.rescaleEach2DimgAction.setChecked(True) - rescaleActionGroup.addAction(self.rescaleEach2DimgAction) - rescaleIntensMenu.addAction(self.rescaleEach2DimgAction) - - self.rescaleAcrossZstackAction = QAction( - "Rescale across z-stack", rescaleIntensMenu - ) - self.rescaleAcrossZstackAction.setCheckable(True) - self.rescaleAcrossZstackAction.setChecked(False) - rescaleActionGroup.addAction(self.rescaleAcrossZstackAction) - rescaleIntensMenu.addAction(self.rescaleAcrossZstackAction) - - self.rescaleAcrossTimeAction = QAction( - "Rescale across time frames", rescaleIntensMenu - ) - self.rescaleAcrossTimeAction.setCheckable(True) - self.rescaleAcrossTimeAction.setChecked(False) - rescaleActionGroup.addAction(self.rescaleAcrossTimeAction) - rescaleIntensMenu.addAction(self.rescaleAcrossTimeAction) - - self.customRescaleAction = QAction("Choose custom levels...", rescaleIntensMenu) - self.customRescaleAction.setCheckable(True) - rescaleActionGroup.addAction(self.customRescaleAction) - rescaleIntensMenu.addAction(self.customRescaleAction) - - self.doNotRescaleAction = QAction( - "Do no rescale, display raw image", rescaleIntensMenu - ) - self.doNotRescaleAction.setCheckable(True) - rescaleActionGroup.addAction(self.doNotRescaleAction) - rescaleIntensMenu.addAction(self.doNotRescaleAction) - - self.rescaleActionGroup = rescaleActionGroup - rescaleActionGroup.triggered.connect(self.rescaleActionTriggered) - - # Add custom colormap action - self.customCmapsMenu = self.gradient.menu.addMenu("Custom colormaps") - self.customCmapsMenu.aboutToShow.connect(self.onShowCustomCmapsMenu) - self.customCmapsMenu.triggered.connect(self.customCmapsMenuTriggered) - - self.saveColormapAction = QAction("Save current colormap...", self) - self.gradient.menu.addAction(self.saveColormapAction) - self.saveColormapAction.triggered.connect(self.saveColormap) - - self.addCustomGradients() - - # Set inverted gradients for invert bw action - self.addInvertedColorMaps() - - self.gradient.menu.addSeparator() - - # hide histogram tool - self.vb.hide() - - # Disable moving the axis up and down - self.axis.unlinkFromView() - - # Disable histogram default context Menu event - self.vb.raiseContextMenu = lambda x: None - - def rescaleActionTriggered(self, action): - self.sigRescaleIntes.emit(action) - - def onShowCustomCmapsMenu(self): - self.customCmapsMenu.show() - - def customCmapsMenuTriggered(self, action): - cmap = action.cmap - self.gradient.colorMapMenuClicked(cmap) - self.gradient.showTicks(True) - - def setAxisLabel(self, text): - self.labelText = text - self.axis.setLabel(text, **self.labelStyle) - - def updateAxisLabel(self): - text = self.axis.label.toPlainText() - if not text: - return - self.setAxisLabel(text) - - def setGradient(self, gradient): - self.gradient.restoreState(gradient) - self.lastGradient = gradient - - def colormapClicked(self, checked=False, name=None): - name = self.sender().name - self.lastGradientName = name - if self.isInverted: - self.setGradient(self.invertedGradients[name]) - else: - self.setGradient(Gradients[name]) - - def sortTicks(self, ticks): - sortedTicks = sorted(ticks, key=operator.itemgetter(0)) - return sortedTicks - - def getInvertedGradients(self): - invertedGradients = {} - for name, gradient in Gradients.items(): - ticks = gradient["ticks"] - sortedTicks = self.sortTicks(ticks) - if name in nonInvertibleCmaps: - invertedColors = sortedTicks - else: - invertedColors = [ - (t[0], ti[1]) for t, ti in zip(sortedTicks, sortedTicks[::-1]) - ] - invertedGradient = {} - invertedGradient["ticks"] = invertedColors - invertedGradient["mode"] = gradient["mode"] - invertedGradients[name] = invertedGradient - return invertedGradients - - def addInvertedColorMaps(self): - self.invertedGradients = self.getInvertedGradients() - for action in self.gradient.menu.actions(): - if not hasattr(action, "name"): - continue - - if action.name not in self.cmaps: - continue - - action.triggered.disconnect() - action.triggered.connect(self.colormapClicked) - - px = QPixmap(100, 15) - p = QPainter(px) - invertedGradient = self.invertedGradients[action.name] - qtGradient = QLinearGradient(QPointF(0, 0), QPointF(100, 0)) - ticks = self.sortTicks(invertedGradient["ticks"]) - qtGradient.setStops([(x, QColor(*color)) for x, color in ticks]) - brush = QBrush(qtGradient) - p.fillRect(QRect(0, 0, 100, 15), brush) - p.end() - widget = action.defaultWidget() - hbox = widget.layout() - rectLabelWidget = QLabel() - rectLabelWidget.setPixmap(px) - hbox.addWidget(rectLabelWidget) - rectLabelWidget.hide() - - def setInvertedColorMaps(self, inverted): - if inverted: - showIdx = 2 - hideIdx = 1 - self.labelStyle["color"] = "#000000" - else: - showIdx = 1 - hideIdx = 2 - self.labelStyle["color"] = "#ffffff" - - for action in self.gradient.menu.actions(): - if not hasattr(action, "name"): - continue - - if action.name not in self.cmaps: - continue - - widget = action.defaultWidget() - hbox = widget.layout() - hideCmapRect = hbox.itemAt(hideIdx).widget() - showCmapRect = hbox.itemAt(showIdx).widget() - hideCmapRect.hide() - showCmapRect.show() - - self.updateAxisLabel() - self.isInverted = inverted - - def invertGradient(self, gradient): - ticks = gradient["ticks"] - sortedTicks = self.sortTicks(ticks) - invertedColors = [ - (t[0], ti[1]) for t, ti in zip(sortedTicks, sortedTicks[::-1]) - ] - invertedGradient = {} - invertedGradient["ticks"] = invertedColors - invertedGradient["mode"] = gradient["mode"] - return invertedGradient - - def invertCurrentColormap(self, inverted, debug=False): - self.setGradient(self.invertGradient(self.lastGradient)) - - def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): - self.originalLength = self.gradient.length - self.gradient.length = 100 - if restore: - self.gradient.restoreState(gradient_ticks) - gradient = self.gradient.getGradient() - action = CustomGradientMenuAction(gradient, gradient_name, self.gradient) - # action.triggered.connect(self.gradient.contextMenuClicked) - action.delButton.clicked.connect(self.removeCustomGradient) - action.cmap = colors.pg_ticks_to_colormap(gradient_ticks["ticks"]) - # self.gradient.menu.insertAction(self.saveColormapAction, action) - self.customCmapsMenu.addAction(action) - self.gradient.length = self.originalLength - GradientsImage[gradient_name] = gradient_ticks - - def removeCustomGradient(self): - button = self.sender() - action = button.action - self.customCmapsMenu.removeAction(action) - cp = config.ConfigParser() - cp.read(custom_cmaps_filepath) - cp.remove_section(f"image.{action.name}") - with open(custom_cmaps_filepath, mode="w") as file: - cp.write(file) - - def addCustomGradients(self): - try: - CustomGradients = getCustomGradients(name="image") - if not CustomGradients: - return - for gradient_name, gradient_ticks in CustomGradients.items(): - self.addCustomGradient(gradient_name, gradient_ticks) - except Exception as e: - printl(traceback.format_exc()) - pass - - def _askNameColormap(self): - inputWin = apps.QInput(parent=self._parent, title="Colormap name") - inputWin.askText("Insert a name for the colormap: ", allowEmpty=False) - if inputWin.cancel: - return - cmapName = inputWin.answer - return cmapName - - def saveColormap(self): - cmapName = self._askNameColormap() - if cmapName is None: - return - - cp = config.ConfigParser() - if os.path.exists(custom_cmaps_filepath): - cp.read(custom_cmaps_filepath) - - SECTION = f"{self.name}.{cmapName}" - cp[SECTION] = {} - - # gradient_ticks = [] - state = self.gradient.saveState() - for key, value in state.items(): - if key != "ticks": - continue - for t, tick in enumerate(value): - pos, rgb = tick - # gradient_ticks.append((pos, rgb)) - rgb = ",".join([str(c) for c in rgb]) - val = f"{pos},{rgb}" - cp[SECTION][f"tick_{t}_pos_rgb"] = val - - with open(custom_cmaps_filepath, mode="w") as file: - cp.write(file) - - self.addCustomGradient(cmapName, state, restore=False) - - def tickColorAccepted(self): - self.gradient.currentColorAccepted() - # self.sigTickColorAccepted.emit(self.gradient.colorDialog.color().getRgb()) - - def setRescaleIntensitiesHow(self, how): - for action in self.rescaleActionGroup.actions(): - if action.text() == how: - action.setChecked(True) - return - - -class ROI(pg.ROI): - def __init__( - self, - pos, - size=pg.Point(1, 1), - angle=0, - invertible=False, - maxBounds=None, - snapSize=1, - scaleSnap=False, - translateSnap=False, - rotateSnap=False, - parent=None, - pen=None, - hoverPen=None, - handlePen=None, - handleHoverPen=None, - movable=True, - rotatable=True, - resizable=True, - removable=False, - aspectLocked=False, - ): - super().__init__( - pos, - size, - angle, - invertible, - maxBounds, - snapSize, - scaleSnap, - translateSnap, - rotateSnap, - parent, - pen, - hoverPen, - handlePen, - handleHoverPen, - movable, - rotatable, - resizable, - removable, - aspectLocked, - ) - - def slice(self, zRange=None, tRange=None): - x0, y0 = [int(round(c)) for c in self.pos()] - w, h = [int(round(c)) for c in self.size()] - xmin, xmax = x0, x0 + w - if xmin > xmax: - xmin, xmax = xmax, xmin - ymin, ymax = y0, y0 + h - if ymin > ymax: - ymin, ymax = ymax, ymin - if zRange is not None: - zmin, zmax = zRange - _slice = (slice(zmin, zmax), slice(ymin, ymax), slice(xmin, xmax)) - else: - _slice = (slice(ymin, ymax), slice(xmin, xmax)) - if tRange is not None: - tmin, tmax = tRange - _slice = (slice(tmin, tmax), *_slice) - return _slice - - def bbox(self): - x0, y0 = [int(round(c)) for c in self.pos()] - w, h = [int(round(c)) for c in self.size()] - xmin, xmax = x0, x0 + w - if xmin > xmax: - xmin, xmax = xmax, xmin - ymin, ymax = y0, y0 + h - if ymin > ymax: - ymin, ymax = ymax, ymin - - return ymin, xmin, ymax, xmax - - -class ZoomROI(ROI): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.viewRangesQueue = deque() - - def getLastRange(self): - xRange, yRange = self.viewRangesQueue.pop() - return xRange, yRange - - def storeLastRange(self, xRange, yRange): - self.viewRangesQueue.append((xRange, yRange)) - - -class DelROI(pg.ROI): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def clearPoints(self): - """ - Remove all handles and segments. - """ - while len(self.handles) > 0: - self.removeHandle(self.handles[0]["item"]) - - -class PlotCurveItem(pg.PlotCurveItem): - def __init__(self, *args, **kargs): - super().__init__(*args, **kargs) - - def addPoint(self, x, y, **kargs): - _xx, _yy = self.getData() - if _xx is None or len(_xx) == 0: - self.xData = np.array([x], dtype=int) - self.yData = np.array([y], dtype=int) - return - if _xx[-1] == x and _yy[-1] == y: - # Do not append same point - return - - # Pre-allocate array and insert data (faster than append) - xx = np.zeros(len(_xx) + 1, dtype=_xx.dtype) - xx[:-1] = _xx - xx[-1] = x - yy = np.zeros(len(_yy) + 1, dtype=_xx.dtype) - yy[:-1] = _yy - yy[-1] = y - self.setData(xx, yy, **kargs) - - def clear(self): - try: - self.setData([], []) - except Exception as e: - pass - super().clear() - - def closeCurve(self): - _xx, _yy = self.getData() - self.addPoint(_xx[0], _yy[0]) - - def mask(self): - ymin, xmin, ymax, xmax = self.bbox() - _mask = np.zeros((ymax - ymin + 1, xmax - xmin + 1), dtype=bool) - local_xx, local_yy = self.getLocalData() - rr, cc = skimage.draw.polygon(local_yy, local_xx) - _mask[rr, cc] = True - return _mask - - def getLocalData(self): - _xx, _yy = self.getData() - return _xx - _xx.min(), _yy - _yy.min() - - def slice(self, zRange=None, tRange=None): - ymin, xmin, ymax, xmax = self.bbox() - if zRange is not None: - zmin, zmax = zRange - _slice = (slice(zmin, zmax), slice(ymin, ymax + 1), slice(xmin, xmax + 1)) - else: - _slice = (slice(ymin, ymax + 1), slice(xmin, xmax + 1)) - if tRange is not None: - tmin, tmax = tRange - _slice = (slice(tmin, tmax), *_slice) - return _slice - - def bbox(self): - _xx, _yy = self.getData() - return _yy.min(), _xx.min(), _yy.max(), _xx.max() - - -class myHistogramLUTitem(baseHistogramLUTitem): - sigGradientMenuEvent = Signal(object) - sigGradientChanged = Signal(object) - sigTickColorAccepted = Signal(object) - sigAddScaleBar = Signal(bool) - sigAddTimestamp = Signal(bool) - - def __init__( - self, parent=None, name="image", axisLabel="", isViewer=False, **kwargs - ): - super().__init__(parent=parent, name=name, axisLabel=axisLabel, **kwargs) - - self.name = name - self._parent = parent - - self.childLutItem = None - - self.isViewer = isViewer - if isViewer: - # In the viewer we don't allow additional settings from the menu - return - - # Add scale bar action - self.addScaleBarAction = QAction("Add scale bar", self) - self.addScaleBarAction.setCheckable(True) - self.addScaleBarAction.triggered.connect(self.emitAddScaleBar) - self.gradient.menu.addAction(self.addScaleBarAction) - - # Add timestamp action - self.addTimestampAction = QAction("Add timestamp", self) - self.addTimestampAction.setCheckable(True) - self.addTimestampAction.triggered.connect(self.emitAddTimestamp) - self.gradient.menu.addAction(self.addTimestampAction) - - # Invert bw action - self.invertBwAction = QAction("Invert black/white", self) - self.invertBwAction.setCheckable(True) - self.gradient.menu.addAction(self.invertBwAction) - - # Font size menu action - self.fontSizeMenu = QMenu("Text font size") - self.gradient.menu.addMenu(self.fontSizeMenu) - - # Text color button - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Text color: ")) - self.textColorButton = myColorButton(color=(255, 255, 255)) - hbox.addStretch(1) - hbox.addWidget(self.textColorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.textColorButton.click) - self.gradient.menu.addAction(act) - - # Contours line weight - contLineWeightMenu = QMenu("Contours line weight", self.gradient.menu) - self.contLineWightActionGroup = QActionGroup(self) - self.contLineWightActionGroup.setExclusionPolicy( - QActionGroup.ExclusionPolicy.Exclusive - ) - for w in range(1, 11): - action = QAction(str(w)) - action.setCheckable(True) - if w == 2: - action.setChecked(True) - action.lineWeight = w - self.contLineWightActionGroup.addAction(action) - action = contLineWeightMenu.addAction(action) - self.gradient.menu.addMenu(contLineWeightMenu) - - # Contours color button - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Contours color: ")) - self.contoursColorButton = myColorButton(color=(25, 25, 25)) - hbox.addStretch(1) - hbox.addWidget(self.contoursColorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.contoursColorButton.click) - self.gradient.menu.addAction(act) - - # Mother-bud line weight - mothBudLineWeightMenu = QMenu("Mother-bud line weight", self.gradient.menu) - self.mothBudLineWightActionGroup = QActionGroup(self) - self.mothBudLineWightActionGroup.setExclusionPolicy( - QActionGroup.ExclusionPolicy.Exclusive - ) - for w in range(1, 11): - action = QAction(str(w)) - action.setCheckable(True) - if w == 2: - action.setChecked(True) - action.lineWeight = w - self.mothBudLineWightActionGroup.addAction(action) - action = mothBudLineWeightMenu.addAction(action) - self.gradient.menu.addMenu(mothBudLineWeightMenu) - - # Mother-bud line color - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Mother-bud line color: ")) - self.mothBudLineColorButton = myColorButton(color=(255, 0, 0)) - hbox.addStretch(1) - hbox.addWidget(self.mothBudLineColorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.mothBudLineColorButton.click) - self.gradient.menu.addAction(act) - - self.labelsAlphaMenu = self.gradient.menu.addMenu( - "Segm. masks overlay alpha..." - ) - # self.labelsAlphaMenu.setDisabled(True) - hbox = QHBoxLayout() - self.labelsAlphaSlider = sliderWithSpinBox( - title="Alpha", title_loc="in_line", isFloat=True, normalize=True - ) - self.labelsAlphaSlider.setMaximum(100) - self.labelsAlphaSlider.setSingleStep(0.05) - self.labelsAlphaSlider.setValue(0.3) - hbox.addWidget(self.labelsAlphaSlider) - shortCutText = "Command+Up/Down" if is_mac else "Ctrl+Up/Down" - hbox.addWidget(QLabel(f"({shortCutText})")) - widget = QWidget() - widget.setLayout(hbox) - act = QWidgetAction(self) - act.setDefaultWidget(widget) - self.labelsAlphaMenu.addSeparator() - self.labelsAlphaMenu.addAction(act) - - # Default settings - self.defaultSettingsAction = QAction("Restore default settings...", self) - self.gradient.menu.addAction(self.defaultSettingsAction) - - self.filterObject = FilterObject() - self.filterObject.sigFilteredEvent.connect(self.gradientMenuEventFilter) - self.gradient.menu.installEventFilter(self.filterObject) - self.highlightedAction = None - self.lastHoveredAction = None - - def setChildLutItem(self, childLutItem): - self.childLutItem = childLutItem - - def removeAddScaleBarAction(self): - self.gradient.menu.removeAction(self.addScaleBarAction) - - def removeAddTimestampAction(self): - self.gradient.menu.removeAction(self.addTimestampAction) - - def emitAddScaleBar(self): - self.sigAddScaleBar.emit(self.addScaleBarAction.isChecked()) - - def emitAddTimestamp(self): - self.sigAddTimestamp.emit(self.addTimestampAction.isChecked()) - - def gradientChanged(self): - super().gradientChanged() - self.sigGradientChanged.emit(self) - - def gradientMenuEventFilter(self, object, event): - if event.type() == QEvent.Type.MouseMove: - hoveredAction = self.gradient.menu.actionAt(event.pos()) - isActionEntered = hoveredAction != self.lastHoveredAction - if isActionEntered: - if isinstance(hoveredAction, highlightableQWidgetAction): - # print('Entered a custom action') - pass - isActionLeft = ( - self.highlightedAction is not None - and self.highlightedAction != hoveredAction - ) - if isActionLeft: - if isinstance(self.highlightedAction, highlightableQWidgetAction): - # print('Left a custom action') - pass - self.highlightedAction = hoveredAction - - self.lastHoveredAction = hoveredAction - - def addOverlayColorButton(self, rgbColor, channelName): - # Overlay color button - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Overlay color: ")) - self.overlayColorButton = myColorButton(color=rgbColor) - self.overlayColorButton.channel = channelName - hbox.addStretch(1) - hbox.addWidget(self.overlayColorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.overlayColorButton.click) - self.gradient.menu.addAction(act) - - def uncheckContLineWeightActions(self): - for act in self.contLineWightActionGroup.actions(): - try: - act.toggled.disconnect() - except Exception as e: - pass - act.setChecked(False) - - def uncheckMothBudLineLineWeightActions(self): - for act in self.mothBudLineWightActionGroup.actions(): - try: - act.toggled.disconnect() - except Exception as e: - pass - act.setChecked(False) - - def restoreState(self, df): - if "textIDsColor" in df.index: - rgbString = df.at["textIDsColor", "value"] - r, g, b = colors.rgb_str_to_values(rgbString) - self.textColorButton.setColor((r, g, b)) - - if "contLineColor" in df.index: - rgba_str = df.at["contLineColor", "value"] - rgb = colors.rgba_str_to_values(rgba_str)[:3] - self.contoursColorButton.setColor(rgb) - - if "contLineWeight" in df.index: - w = df.at["contLineWeight", "value"] - w = int(w) - for action in self.contLineWightActionGroup.actions(): - if action.lineWeight == w: - action.setChecked(True) - break - - if "mothBudLineWeight" in df.index: - w = df.at["mothBudLineWeight", "value"] - w = int(w) - for action in self.mothBudLineWightActionGroup.actions(): - if action.lineWeight == w: - action.setChecked(True) - break - - if "overlaySegmMasksAlpha" in df.index: - alpha = df.at["overlaySegmMasksAlpha", "value"] - self.labelsAlphaSlider.setValue(float(alpha)) - - if "mothBudLineColor" in df.index: - rgba_str = df.at["mothBudLineColor", "value"] - rgb = colors.rgba_str_to_values(rgba_str)[:3] - self.mothBudLineColorButton.setColor(rgb) - - checked = df.at["is_bw_inverted", "value"] == "Yes" - self.invertBwAction.setChecked(checked) - - self.restoreColormap(df) - - def saveState(self, df): - # remove previous state - df = df[~df.index.str.contains("img_cmap")].copy() - - state = self.gradient.saveState() - for key, value in state.items(): - if key == "ticks": - for t, tick in enumerate(value): - pos, rgb = tick - df.at[f"img_cmap_tick{t}_rgb", "value"] = rgb - df.at[f"img_cmap_tick{t}_pos", "value"] = pos - else: - if isinstance(value, bool): - value = "Yes" if value else "No" - df.at[f"img_cmap_{key}", "value"] = value - return df - - def restoreColormap(self, df): - state = {"mode": "rgb", "ticksVisible": True, "ticks": []} - ticks_pos = {} - ticks_rgb = {} - stateFound = False - for setting, value in df.itertuples(): - idx = setting.find("img_cmap_") - if idx == -1: - continue - - stateFound = True - m = re.findall(r"tick(\d+)_(\w+)", setting) - if m: - tick_idx, tick_type = m[0] - if tick_type == "pos": - ticks_pos[int(tick_idx)] = float(value) - elif tick_type == "rgb": - ticks_rgb[int(tick_idx)] = colors.rgba_str_to_values(value) - else: - key = setting[9:] - if value == "Yes": - value = True - elif value == "No": - value = False - state[key] = value - - if stateFound: - ticks = [(0, 0)] * len(ticks_pos) - for idx, val in ticks_pos.items(): - pos = val - rgb = ticks_rgb[idx] - ticks[idx] = (pos, rgb) - - state["ticks"] = ticks - self.gradient.restoreState(state) - - def regionChanged(self): - super().regionChanged() - if self.childLutItem is None: - return - - imageItem = self.imageItem() - try: - mn, mx = imageItem.quickMinMax(targetSize=65536) - # mn and mx can still be NaN if the data is all-NaN - if mn == mx or imageItem._xp.isnan(mn) or imageItem._xp.isnan(mx): - mn = 0 - mx = 255 - except AttributeError as err: - mn, mx = self.getLevels() - - self.childLutItem.setLevels(min=mn, max=mx) - - -class labelledQScrollbar(ScrollBar): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._label = None - - def setLabel(self, label): - self._label = label - - def updateLabel(self): - if self._label is not None: - position = self.sliderPosition() - s = self._label.text() - s = re.sub(r"(\d+)/(\d+)", rf"{position + 1:02}/\2", s) - self._label.setText(s) - - def setSliderPosition(self, position): - QScrollBar.setSliderPosition(self, position) - self.updateLabel() - - def setValue(self, value): - QScrollBar.setValue(self, value) - self.updateLabel() - - -class navigateQScrollBar(ScrollBar): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._disableCustomPressEvent = False - self.signal_slot_mapper = {} - - def disableCustomPressEvent(self): - self._disableCustomPressEvent = True - - def enableCustomPressEvent(self): - self._disableCustomPressEvent = False - - def setAbsoluteMaximum(self, absoluteMaximum): - self._absoluteMaximum = absoluteMaximum - - def absoluteMaximum(self): - return self._absoluteMaximum - - def mousePressEvent(self, event): - super().mousePressEvent(event) - if self.maximum() == self._absoluteMaximum: - return - - if self._disableCustomPressEvent: - return - - def setValueNoSignal(self, value): - for signal_name, slot in self.signal_slot_mapper.items(): - signal = getattr(self, signal_name) - try: - signal.disconnect() - except Exception as e: - pass - - self.setSliderPosition(value) - self.connectEvents(self.signal_slot_mapper) - - def connectEvents(self, signal_slot_mapper: dict): - self.signal_slot_mapper = signal_slot_mapper - for signal_name, slot in signal_slot_mapper.items(): - signal = getattr(self, signal_name) - try: - signal.disconnect() - except Exception as e: - pass - signal.connect(slot) - - -class linkedQScrollbar(ScrollBar): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._linkedScrollBar = None - - def linkScrollBar(self, scrollbar): - self._linkedScrollBar = scrollbar - scrollbar.setSliderPosition(self.sliderPosition()) - - def unlinkScrollBar(self): - self._linkedScrollBar = None - - def setSliderPosition(self, position): - QScrollBar.setSliderPosition(self, position) - if self._linkedScrollBar is not None: - self._linkedScrollBar.setSliderPosition(position) - - def setMaximum(self, max): - QScrollBar.setMaximum(self, max) - if self._linkedScrollBar is not None: - self._linkedScrollBar.setMaximum(max) - - -class myColorButton(pg.ColorButton): - def __init__(self, parent=None, color=(128, 128, 128), padding=5): - super().__init__(parent=parent, color=color) - if isinstance(padding, (int, float)): - self.padding = (padding, padding, -padding, -padding) - else: - self.padding = padding - self._c = 225 - self._hoverDeltaC = 30 - self._alpha = 100 - self._bkgrColor = QColor(self._c, self._c, self._c, self._alpha) - self._borderColor = QColor(171, 171, 171) - self._rectBorderPen = QPen(QBrush(QColor(0, 0, 0)), 0.3) - - def paintEvent(self, event): - # QPushButton.paintEvent(self, ev) - p = QStylePainter(self) - p.setRenderHint(QPainter.RenderHint.Antialiasing) - rect = self.rect() - p.setBrush(QBrush(self._bkgrColor)) - p.setPen(QPen(self._borderColor)) - p.drawRoundedRect(rect, 5, 5) - # p.fillRect(self.rect(), self._bkgrColor) - rect = self.rect().adjusted(*self.padding) - ## draw white base, then texture for indicating transparency, then actual color - p.setBrush(pg.mkBrush("w")) - p.drawRect(rect) - p.setBrush(QBrush(Qt.BrushStyle.DiagCrossPattern)) - p.drawRect(rect) - p.setPen(self._rectBorderPen) - p.setBrush(pg.mkBrush(self._color)) - p.drawRect(rect) - p.end() - - def enterEvent(self, event): - c = self._c + self._hoverDeltaC - self._bkgrColor = QColor(c, c, c, self._alpha) - self.update() - - def leaveEvent(self, event): - c = self._c - self._bkgrColor = QColor(c, c, c, self._alpha) - self.update() - - -class overlayLabelsGradientWidget(pg.GradientWidget): - def __init__( - self, - imageItem, - selectActionGroup, - segmEndname, - parent=None, - orientation="right", - ): - pg.GradientWidget.__init__(self, parent=parent, orientation=orientation) - - self.imageItem = imageItem - self.selectActionGroup = selectActionGroup - - for action in self.menu.actions(): - if action.text() == "HSV": - HSV_action = action - elif action.text() == "RGB": - RGB_ation = action - self.menu.removeAction(HSV_action) - self.menu.removeAction(RGB_ation) - - # Shuffle colors action - self.shuffleCmapAction = QAction("Randomly shuffle colormap (Shift+S)", self) - self.menu.addAction(self.shuffleCmapAction) - - # Drawing mode - drawModeMenu = QMenu("Drawing mode", self) - self.drawModeActionGroup = QActionGroup(self) - contoursDrawModeAction = QAction("Draw contours", drawModeMenu) - contoursDrawModeAction.setCheckable(True) - contoursDrawModeAction.setChecked(True) - contoursDrawModeAction.segmEndname = segmEndname - self.drawModeActionGroup.addAction(contoursDrawModeAction) - drawModeMenu.addAction(contoursDrawModeAction) - olDrawModeAction = QAction("Overlay labels", drawModeMenu) - olDrawModeAction.setCheckable(True) - olDrawModeAction.segmEndname = segmEndname - self.drawModeActionGroup.addAction(olDrawModeAction) - drawModeMenu.addAction(olDrawModeAction) - self.menu.addMenu(drawModeMenu) - - self.labelsAlphaMenu = self.menu.addMenu("Overlay labels alpha...") - hbox = QHBoxLayout() - self.labelsAlphaSlider = sliderWithSpinBox( - title="Alpha", title_loc="in_line", isFloat=True, normalize=True - ) - self.labelsAlphaSlider.setMaximum(100) - self.labelsAlphaSlider.setSingleStep(0.05) - self.labelsAlphaSlider.setValue(0.3) - hbox.addWidget(self.labelsAlphaSlider) - widget = QWidget() - widget.setLayout(hbox) - act = QWidgetAction(self) - act.setDefaultWidget(widget) - self.labelsAlphaMenu.addSeparator() - self.labelsAlphaMenu.addAction(act) - - self.menu.addSeparator() - self.menu.addSection("Select segm. file to adjust:") - for action in selectActionGroup.actions(): - self.menu.addAction(action) - - self.item.loadPreset("viridis") - self.updateImageLut(None) - self.updateImageOpacity(0.3) - - # Connect events - self.sigGradientChangeFinished.connect(self.updateImageLut) - self.labelsAlphaSlider.valueChanged.connect(self.updateImageOpacity) - self.shuffleCmapAction.triggered.connect(self.shuffleCmap) - - def shuffleCmap(self): - lut = self.imageItem.lut - np.random.shuffle(lut) - lut[0] = [0, 0, 0, 0] - self.imageItem.setLookupTable(lut) - self.imageItem.update() - - def updateImageLut(self, gradientItem): - lut = np.zeros((255, 4), dtype=np.uint8) - lut[:, -1] = 255 - lut[:, :-1] = self.item.colorMap().getLookupTable(0, 1, 255) - np.random.shuffle(lut) - lut[0] = [0, 0, 0, 0] - self.imageItem.setLookupTable(lut) - self.imageItem.setLevels([0, 255]) - - def updateImageOpacity(self, value): - self.imageItem.setOpacity(value) - - -class labelsGradientWidget(pg.GradientWidget): - sigShowRightImgToggled = Signal(bool) - sigShowLabelsImgToggled = Signal(bool) - sigShowNextFrameToggled = Signal(bool) - - def __init__(self, *args, parent=None, orientation="right", **kargs): - pg.GradientEditorItem = BaseGradientEditorItemLabels - - pg.GradientWidget.__init__( - self, *args, parent=parent, orientation=orientation, **kargs - ) - - self._parent = parent - self.name = "labels" - - for action in self.menu.actions(): - if action.text() == "HSV": - HSV_action = action - elif action.text() == "RGB": - RGB_ation = action - self.menu.removeAction(HSV_action) - self.menu.removeAction(RGB_ation) - - # Add custom colormap action - self.customCmapsMenu = self.menu.addMenu("Custom colormaps") - self.customCmapsMenu.aboutToShow.connect(self.onShowCustomCmapsMenu) - self.customCmapsMenu.triggered.connect(self.customCmapsMenuTriggered) - - self.saveColormapAction = QAction("Save current colormap...", self) - self.menu.addAction(self.saveColormapAction) - self.saveColormapAction.triggered.connect(self.saveColormap) - - self.addCustomGradients() - - # Background color button - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Background color: ")) - self.colorButton = myColorButton(color=(25, 25, 25)) - hbox.addStretch(1) - hbox.addWidget(self.colorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.colorButton.click) - self.menu.addAction(act) - - # Font size menu action - self.fontSizeMenu = QMenu("Text font size", self) - self.menu.addMenu(self.fontSizeMenu) - - # IDs color button - hbox = QHBoxLayout() - hbox.addWidget(QLabel("Text color: ")) - self.textColorButton = myColorButton(color=(25, 25, 25)) - hbox.addStretch(1) - hbox.addWidget(self.textColorButton) - widget = QWidget() - widget.setLayout(hbox) - act = highlightableQWidgetAction(self) - act.setDefaultWidget(widget) - act.triggered.connect(self.textColorButton.click) - self.menu.addAction(act) - self.menu.addSeparator() - - # Shuffle colors action - self.shuffleCmapAction = QAction("Randomly shuffle colormap (Shift+S)", self) - self.menu.addAction(self.shuffleCmapAction) - - self.greedyShuffleCmapAction = QAction( - "Greedily shuffle colormap (Alt+Shift+S)", self - ) - self.menu.addAction(self.greedyShuffleCmapAction) - - self.permanentGreedyCmapAction = QAction("Always use greedy colormap", self) - self.permanentGreedyCmapAction.setCheckable(True) - self.menu.addAction(self.permanentGreedyCmapAction) - - # Invert bw action - self.invertBwAction = QAction("Invert black/white", self) - self.invertBwAction.setCheckable(True) - self.menu.addAction(self.invertBwAction) - - # Show labels action - self.showLabelsImgAction = QAction("Show segmentation image", self) - self.showLabelsImgAction.setCheckable(True) - self.menu.addAction(self.showLabelsImgAction) - - # Show right image action - self.showRightImgAction = QAction("Show duplicated left image", self) - self.showRightImgAction.setCheckable(True) - self.menu.addAction(self.showRightImgAction) - - # Show next frame action - self.showNextFrameAction = QAction("Show next frame", self) - self.showNextFrameAction.setCheckable(True) - self.menu.addAction(self.showNextFrameAction) - - # Default settings - self.defaultSettingsAction = QAction("Restore default settings...", self) - self.menu.addAction(self.defaultSettingsAction) - - self.menu.addSeparator() - - self.showRightImgAction.toggled.connect(self.showRightImageToggled) - self.showLabelsImgAction.toggled.connect(self.showLabelsImageToggled) - self.showNextFrameAction.toggled.connect(self.showNextFrameToggled) - - def onShowCustomCmapsMenu(self): - self.customCmapsMenu.show() - - def customCmapsMenuTriggered(self, action): - cmap = action.cmap - self.item.colorMapMenuClicked(cmap) - self.item.showTicks(True) - - def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): - currentState = self.item.saveState() - self.originalLength = self.item.length - self.item.length = 100 - if restore: - self.item.restoreState(gradient_ticks) - gradient = self.item.getGradient() - action = CustomGradientMenuAction(gradient, gradient_name, self.item) - # action.triggered.connect(self.item.contextMenuClicked) - action.delButton.clicked.connect(self.removeCustomGradient) - action.cmap = colors.pg_ticks_to_colormap(gradient_ticks["ticks"]) - # self.item.menu.insertAction(self.saveColormapAction, action) - self.customCmapsMenu.addAction(action) - self.item.length = self.originalLength - self.item.restoreState(currentState) - GradientsLabels[gradient_name] = gradient_ticks - - def removeCustomGradient(self): - button = self.sender() - action = button.action - self.customCmapsMenu.removeAction(action) - cp = config.ConfigParser() - cp.read(custom_cmaps_filepath) - cp.remove_section(f"labels.{action.name}") - with open(custom_cmaps_filepath, mode="w") as file: - cp.write(file) - - def addCustomGradients(self): - try: - CustomGradients = getCustomGradients(name="labels") - if not CustomGradients: - return - for gradient_name, gradient_ticks in CustomGradients.items(): - self.addCustomGradient(gradient_name, gradient_ticks) - except Exception as e: - printl(traceback.format_exc()) - pass - - def _askNameColormap(self): - inputWin = apps.QInput(parent=self._parent, title="Colormap name") - inputWin.askText("Insert a name for the colormap: ", allowEmpty=False) - if inputWin.cancel: - return - cmapName = inputWin.answer - return cmapName - - def saveColormap(self): - cmapName = self._askNameColormap() - if cmapName is None: - return - - cp = config.ConfigParser() - if os.path.exists(custom_cmaps_filepath): - cp.read(custom_cmaps_filepath) - - SECTION = f"{self.name}.{cmapName}" - cp[SECTION] = {} - - state = self.item.saveState() - for key, value in state.items(): - if key != "ticks": - continue - for t, tick in enumerate(value): - pos, rgb = tick - rgb = ",".join([str(c) for c in rgb]) - val = f"{pos},{rgb}" - cp[SECTION][f"tick_{t}_pos_rgb"] = val - - with open(custom_cmaps_filepath, mode="w") as file: - cp.write(file) - - self.addCustomGradient(cmapName, state, restore=False) - - def isRightImageVisible(self): - return ( - self.showLabelsImgAction.isChecked() or self.showNextFrameAction.isChecked() - ) - - def showRightImageToggled(self, checked): - if checked and self.isRightImageVisible(): - # Hide the right labels image before showing right image - self.showLabelsImgAction.setChecked(False) - self.showNextFrameAction.setChecked(False) - self.sigShowLabelsImgToggled.emit(False) - self.sigShowNextFrameToggled.emit(checked) - self.sigShowRightImgToggled.emit(checked) - - def showLabelsImageToggled(self, checked): - if checked and self.isRightImageVisible(): - # Hide the right image before showing labels image - self.showRightImgAction.setChecked(False) - self.showNextFrameAction.setChecked(False) - self.sigShowRightImgToggled.emit(False) - self.sigShowNextFrameToggled.emit(False) - self.sigShowLabelsImgToggled.emit(checked) - - def showNextFrameToggled(self, checked): - if checked and self.isRightImageVisible(): - # Hide the right image before showing labels image - self.showRightImgAction.setChecked(False) - self.showLabelsImgAction.setChecked(False) - self.sigShowRightImgToggled.emit(False) - self.sigShowLabelsImgToggled.emit(False) - self.sigShowNextFrameToggled.emit(checked) - - def saveState(self, df): - # remove previous state - df = df[~df.index.str.contains("lab_cmap")].copy() - - state = self.item.saveState() - for key, value in state.items(): - if key == "ticks": - for t, tick in enumerate(value): - pos, rgb = tick - df.at[f"lab_cmap_tick{t}_rgb", "value"] = rgb - df.at[f"lab_cmap_tick{t}_pos", "value"] = pos - else: - if isinstance(value, bool): - value = "Yes" if value else "No" - df.at[f"lab_cmap_{key}", "value"] = value - return df - - def restoreState(self, df, loadCmap=True): - # Insert background color - if "labels_bkgrColor" in df.index: - rgbString = df.at["labels_bkgrColor", "value"] - r, g, b = colors.rgb_str_to_values(rgbString) - self.colorButton.setColor((r, g, b)) - - if "labels_text_color" in df.index: - rgbString = df.at["labels_text_color", "value"] - r, g, b = colors.rgb_str_to_values(rgbString) - self.textColorButton.setColor((r, g, b)) - else: - self.textColorButton.setColor((255, 0, 0)) - - checked = df.at["is_bw_inverted", "value"] == "Yes" - self.invertBwAction.setChecked(checked) - - if not loadCmap: - return - - state = {"mode": "rgb", "ticksVisible": True, "ticks": []} - ticks_pos = {} - ticks_rgb = {} - stateFound = False - for setting, value in df.itertuples(): - idx = setting.find("lab_cmap_") - if idx == -1: - continue - - stateFound = True - m = re.findall(r"tick(\d+)_(\w+)", setting) - if m: - tick_idx, tick_type = m[0] - if tick_type == "pos": - ticks_pos[int(tick_idx)] = float(value) - elif tick_type == "rgb": - ticks_rgb[int(tick_idx)] = colors.rgba_str_to_values(value) - else: - key = setting[9:] - if value == "Yes": - value = True - elif value == "No": - value = False - state[key] = value - - if stateFound: - ticks = [(0, 0)] * len(ticks_pos) - for idx, val in ticks_pos.items(): - pos = val - rgb = ticks_rgb[idx] - ticks[idx] = (pos, rgb) - - state["ticks"] = ticks - self.item.restoreState(state) - else: - self.item.loadPreset("viridis") - - return stateFound - - def showMenu(self, ev): - try: - # Convert QPointF to QPoint - self.menu.popup(ev.screenPos().toPoint()) - except AttributeError: - self.menu.popup(ev.screenPos()) - - -class MainPlotItem(pg.PlotItem): - def __init__( - self, - parent=None, - name=None, - labels=None, - title=None, - viewBox=None, - axisItems=None, - enableMenu=True, - showWelcomeText=False, - **kargs, - ): - super().__init__( - parent, name, labels, title, viewBox, axisItems, enableMenu, **kargs - ) - # Overwrite zoom out button behaviour to disable autoRange after - # clicking it. - # If autorange is enabled, it is called everytime the brush or eraser - # scatter plot items touches the border causing flickering - self.disableAutoRange() - self.autoBtn.mode = "manual" - if showWelcomeText: - self.infoTextItem = pg.TextItem() - self.addItem(self.infoTextItem) - html_filepath = os.path.join(html_path, "gui_welcome.html") - with open(html_filepath) as html_file: - htmlText = html_file.read() - self.infoTextItem.setHtml(htmlText) - self.infoTextItem.setPos(0, 0) - - self.delRoiItems = {} - self.highlightingRectItems = None - self._baseImageItem = None - self._imageItems = [] - self.highlightingRectItemsColor = None - - def addHighlightingRectItems(self, color=None): - self.highlightingRectItems = { - "left": RectItem(QRectF()), - "right": RectItem(QRectF()), - "top": RectItem(QRectF()), - "bottom": RectItem(QRectF()), - } - for rect in self.highlightingRectItems.values(): - self.addItem(rect) - - if color is None: - return - - self.setHighlightingRectItemsColor(color) - - def setHighlightingRectItemsColor(self, color): - if color == self.highlightingRectItemsColor: - return - - for item in self.highlightingRectItems.values(): - item.setColor(color) - - self.highlightingRectItemsColor = color - - def addBaseImageItem(self, baseImageItem): - self._baseImageItem = baseImageItem - self._imageItems.append(baseImageItem) - self.addItem(baseImageItem) - - def addImageItem(self, imageItem): - self._imageItems.append(imageItem) - self.addItem(imageItem) - - def setHighlighted(self, highlighted, color=None): - if color is None: - color = self.highlightingRectItemsColor - - if color is None: - color = "green" - - if self.highlightingRectItems is None: - self.addHighlightingRectItems(color=color) - - if not highlighted: - for rect in self.highlightingRectItems.values(): - rect.setQRect(QRectF()) - return - - self.setHighlightingRectItemsColor(color) - - ((xmin, xmax), (ymin, ymax)) = self.viewRange() - xmin = xmin if xmin >= 0 else 0 - ymin = ymin if ymin >= 0 else 0 - if self._baseImageItem is not None: - Y, X = self._baseImageItem.image.shape[:2] - xmax = min(xmax, X) - ymax = min(ymax, Y) - - w = xmax - xmin - h = ymax - ymin - - bs = round(((w + h) / 2) * 0.02) - if bs < 1: - bs = 1 - - x0 = xmin - x1 = xmin + bs - x2 = xmax - bs - x3 = xmax - - y0 = ymin - y1 = ymin + bs - y2 = ymax - bs - y3 = ymax - - self.highlightingRectItems["left"].setRect(x0, y0, bs, y3 - y0) - self.highlightingRectItems["top"].setRect(x1, y0, x3 - x1, bs) - self.highlightingRectItems["right"].setRect(x2, y1, bs, y3 - y1) - self.highlightingRectItems["bottom"].setRect(x1, y2, x2 - x1, bs) - self.update() - - def clear(self): - super().clear() - - self.delRoiItems = {} - self.highlightingRectItems = None - self._baseImageItem = None - self._imageItems = [] - self.highlightingRectItemsColor = None - - try: - self.removeItem(self.infoTextItem) - except Exception as e: - pass - - def autoBtnClicked(self): - self.vb.autoRange() - self.autoBtn.hide() - - def addDelRoiItem(self, roiItem, key): - if self.isDelRoiItemPresent(roiItem): - return - - self.delRoiItems[key] = roiItem - roiItem.key = key - self.addItem(roiItem) - - def removeDelRoiItem(self, roiItem): - key = roiItem.key - self.delRoiItems.pop(key, None) - try: - self.removeItem(roiItem) - except Exception as err: - return - - def isDelRoiItemPresent(self, roiItem): - try: - key = roiItem.key - except AttributeError as e: - return False - - try: - roi = self.delRoiItems[key] - except Exception as err: - return False - - return True - - def viewRange(self, mask_img=None): - if mask_img is None: - return super().viewRange() - - mask_rp = skimage.measure.regionprops(skimage.measure.label(mask_img)) - if not mask_rp: - return super().viewRange() - - mask_obj = mask_rp[0] - ymin, xmin, ymax, xmax = mask_obj.bbox - return (xmin, xmax), (ymin, ymax) - - -class sliderWithSpinBox(QWidget): - sigValueChange = Signal(object) - valueChanged = Signal(object) - editingFinished = Signal() - - def __init__(self, *args, **kwargs): - super().__init__(*args) - - layout = QGridLayout() - - title = kwargs.get("title") - row = 0 - col = 0 - if title is not None: - titleLabel = QLabel(self) - titleLabel.setText(title) - loc = kwargs.get("title_loc", "top") - if loc == "top": - layout.addWidget(titleLabel, 0, col, alignment=Qt.AlignLeft) - elif loc == "in_line": - row = -1 - col = 1 - layout.addWidget(titleLabel, 0, 0, alignment=Qt.AlignLeft) - layout.setColumnStretch(0, 0) - - self._normalize = False - normalize = kwargs.get("normalize") - if normalize is not None and normalize: - self._normalize = True - self._isFloat = True - - self._isFloat = False - isFloat = kwargs.get("isFloat") - if isFloat is not None and isFloat: - self._isFloat = True - - self.slider = QSlider(Qt.Horizontal, self) - - if self._normalize or self._isFloat: - self.spinBox = DoubleSpinBox(self) - else: - self.spinBox = SpinBox(self) - self.spinBox.setAlignment(Qt.AlignCenter) - self.spinBox.setMaximum(2**31 - 1) - - maximum_on_label = kwargs.get("maximum_on_label") - spinbox_loc = kwargs.get("spinbox_loc", "right") - if spinbox_loc == "right": - spinbox_col = col + 1 - slider_col = col - if maximum_on_label is not None: - maximum_on_label_col = spinbox_col + 1 - elif spinbox_loc == "left": - spinbox_col = col - slider_col = col + 1 - if maximum_on_label is not None: - maximum_on_label_col = spinbox_col + 1 - slider_col += 1 - - if maximum_on_label is not None: - self.labelMaximum = QLabel() - layout.addWidget(self.labelMaximum, row + 1, maximum_on_label_col) - layout.addWidget(self.slider, row + 1, slider_col) - layout.addWidget(self.spinBox, row + 1, spinbox_col) - - if title is not None: - layout.setRowStretch(0, 1) - layout.setRowStretch(row + 1, 1) - layout.setColumnStretch(slider_col, 6) - layout.setColumnStretch(spinbox_col, 1) - - self._layout = layout - self.lastCol = col + 1 - self.sliderCol = slider_col - - self.slider.valueChanged.connect(self.sliderValueChanged) - self.slider.sliderReleased.connect(self.onEditingFinished) - self.spinBox.valueChanged.connect(self.spinboxValueChanged) - self.spinBox.editingFinished.connect(self.onEditingFinished) - - layout.setContentsMargins(5, 0, 5, 0) - - self.setLayout(layout) - - if maximum_on_label is not None: - self.setMaximum(maximum_on_label) - self.labelMaximum.setText(f"/{maximum_on_label}") - - def onEditingFinished(self): - self.editingFinished.emit() - - def maximum(self): - return self.slider.maximum() - - def minimum(self): - return self.slider.minimum() - - def setValue(self, value, emitSignal=False): - valueInt = value - if self._normalize: - valueInt = int(value * self.slider.maximum()) - elif self._isFloat: - valueInt = int(value) - - self.spinBox.valueChanged.disconnect() - self.spinBox.setValue(value) - self.spinBox.valueChanged.connect(self.spinboxValueChanged) - - self.slider.valueChanged.disconnect() - if valueInt > self.slider.maximum(): - self.slider.setMaximum(valueInt) - self.slider.setValue(valueInt) - self.slider.valueChanged.connect(self.sliderValueChanged) - - if emitSignal: - self.sigValueChange.emit(self.value()) - self.valueChanged.emit(self.value()) - - def setMaximum(self, max, including_spinbox=False): - self.slider.setMaximum(max) - if including_spinbox: - self.spinBox.setMaximum(max) - - def setSingleStep(self, step): - self.spinBox.setSingleStep(step) - - def setMinimum(self, min, including_spinbox=False): - self.slider.setMinimum(min) - if including_spinbox: - self.spinBox.setMinimum(min) - - def setSingleStep(self, step): - self.spinBox.setSingleStep(step) - - def setDecimals(self, decimals): - self.spinBox.setDecimals(decimals) - - def setTickPosition(self, position): - self.slider.setTickPosition(position) - - def setTickInterval(self, interval): - self.slider.setTickInterval(interval) - - def sliderValueChanged(self, val): - self.spinBox.valueChanged.disconnect() - if self._normalize: - valF = val / self.slider.maximum() - self.spinBox.setValue(valF) - else: - self.spinBox.setValue(val) - self.spinBox.valueChanged.connect(self.spinboxValueChanged) - self.sigValueChange.emit(self.value()) - self.valueChanged.emit(self.value()) - - def spinboxValueChanged(self, val): - if self._normalize: - val = int(val * self.slider.maximum()) - elif self._isFloat: - val = int(val) - - self.slider.valueChanged.disconnect() - self.slider.setValue(val) - self.slider.valueChanged.connect(self.sliderValueChanged) - self.sigValueChange.emit(self.value()) - self.valueChanged.emit(self.value()) - - def value(self): - return self.spinBox.value() - - def setDisabled(self, disabled) -> None: - self.slider.setDisabled(disabled) - self.spinBox.setDisabled(disabled) - - -class BaseImageItem(pg.ImageItem): - def __init__(self, image=None, **kargs): - self.minMaxValuesMapper = None - self.minMaxValuesMapperPreproc = None - self.minMaxValuesMapperCombined = None - self.minMaxValuesMapperEqualized = None - self.pos_i = 0 - self.z = 0 - self.frame_i = 0 - self.usePreprocessed = False - self.useEqualized = False - self.useCombined = False - self._isRgba = False - - super().__init__(image, **kargs) - self.autoLevelsEnabled = None - - def isRgba(self): - return self._isRgba - - def setEnableAutoLevels(self, enabled: bool): - self.autoLevelsEnabled = enabled - - def setImage(self, image=None, autoLevels=None, **kargs): - if autoLevels is None: - autoLevels = self.autoLevelsEnabled - - if image is not None and image.ndim == 3 and image.shape[2] in (3, 4): - self._isRgba = True - - super().setImage(image, autoLevels=autoLevels, **kargs) - - def preComputedMinMaxValues(self, data: List["load.loadData"]): - self.minMaxValuesMapper = {} - for pos_i, posData in enumerate(data): - img_data = posData.img_data - requires_time_dim = posData.img_data.ndim == 2 or ( - posData.img_data.ndim == 3 and posData.SizeZ > 1 - ) - if requires_time_dim: - img_data = (img_data,) - - for frame_i, image in enumerate(img_data): - if image.ndim == 3: - self._updateMinMaxValuesProjections( - image, pos_i, frame_i, self.minMaxValuesMapper - ) - - if image.ndim == 2: - image = (image,) - - for z, img in enumerate(image): - self.minMaxValuesMapper[(pos_i, frame_i, z)] = ( - np.nanmin(img), - np.nanmax(img), - ) - - def updateMinMaxValuesEqualizedData( - self, - data: List["load.loadData"], - pos_i: int, - frame_i: int, - z_slice: Union[int, str], - ): - if self.minMaxValuesMapperEqualized is None: - self.minMaxValuesMapperEqualized = {} - - posData = data[pos_i] - img = posData.equalized_img_data[frame_i][z_slice] - key = (pos_i, frame_i, z_slice) - self.minMaxValuesMapperEqualized[key] = (np.nanmin(img), np.nanmax(img)) - - def updateMinMaxValuesEqualizedDataProjections( - self, - data: List["load.loadData"], - pos_i: int, - frame_i: int, - ): - posData = data[pos_i] - eq_zstack = posData.equalized_img_data[frame_i] - - self._updateMinMaxValuesProjections( - eq_zstack, pos_i, frame_i, self.minMaxValuesMapperEqualized - ) - - def _updateMinMaxValuesProjections(self, zstack, pos_i, frame_i, mapper): - max_proj = zstack.max(axis=0) - key = (pos_i, frame_i, "max z-projection") - mapper[key] = np.nanmin(max_proj), np.nanmax(max_proj) - - mean_proj = zstack.mean(axis=0) - key = (pos_i, frame_i, "mean z-projection") - mapper[key] = np.nanmin(mean_proj), np.nanmax(mean_proj) - - median_proj = np.median(zstack, axis=0) - key = (pos_i, frame_i, "median z-proj.") - mapper[key] = np.nanmin(median_proj), np.nanmax(median_proj) - - def updateMinMaxValuesPreprocessedData( - self, - data: List["load.loadData"], - pos_i: int, - frame_i: int, - z_slice: Union[int, str], - ): - if self.minMaxValuesMapperPreproc is None: - self.minMaxValuesMapperPreproc = {} - - posData = data[pos_i] - img = posData.preproc_img_data[frame_i][z_slice] - key = (pos_i, frame_i, z_slice) - self.minMaxValuesMapperPreproc[key] = (np.nanmin(img), np.nanmax(img)) - - def updateMinMaxValuesPreprocessedProjections( - self, - data: List["load.loadData"], - pos_i: int, - frame_i: int, - ): - posData = data[pos_i] - zstack = posData.preproc_img_data[frame_i] - - self._updateMinMaxValuesProjections( - zstack, pos_i, frame_i, self.minMaxValuesMapperPreproc - ) - - def updateMinMaxValuesCombinedData( - self, - data: List["load.loadData"], - pos_i: int, - frame_i: int, - z_slice: Union[int, str], - ): - if self.minMaxValuesMapperCombined is None: - self.minMaxValuesMapperCombined = {} - - posData = data[pos_i] - img = posData.combine_img_data[frame_i][z_slice] - key = (pos_i, frame_i, z_slice) - self.minMaxValuesMapperCombined[key] = (np.nanmin(img), np.nanmax(img)) - - def updateMinMaxValuesCombinedDataProjections( - self, - data: List["load.loadData"], - pos_i: int, - frame_i: int, - ): - posData = data[pos_i] - zstack = posData.combine_img_data[frame_i] - - self._updateMinMaxValuesProjections( - zstack, pos_i, frame_i, self.minMaxValuesMapperCombined - ) - - def setCurrentPosIndex(self, pos_i: int): - self.pos_i = pos_i - - def setCurrentFrameIndex(self, frame_i: int): - self.frame_i = frame_i - - def setCurrentZsliceIndex(self, z: int): - self.z = z - - def quickMinMax(self, targetSize=1e6): - if self.isRgba(): - return super().quickMinMax(targetSize=targetSize) - - if self.usePreprocessed and self.minMaxValuesMapperPreproc is not None: - minMaxValuesMapper = self.minMaxValuesMapperPreproc - elif self.useCombined and self.minMaxValuesMapperCombined is not None: - minMaxValuesMapper = self.minMaxValuesMapperCombined - elif self.useEqualized and self.minMaxValuesMapperEqualized is not None: - minMaxValuesMapper = self.minMaxValuesMapperEqualized - else: - minMaxValuesMapper = self.minMaxValuesMapper - - if minMaxValuesMapper is None: - return super().quickMinMax(targetSize=targetSize) - - try: - key = (self.pos_i, self.frame_i, self.z) - levels = minMaxValuesMapper[key] - return levels - except Exception as err: - pass - - try: - key = (self.pos_i, self.frame_i, self.z) - levels = self.minMaxValuesMapper[key] - return levels - except Exception as err: - return super().quickMinMax(targetSize=targetSize) - - def setOpacity(self, value, **kwargs): - if value == 0: - value = 0.001 - - if value == 1: - value = 0.999 - - super().setOpacity(value) - - -class BaseLabelsImageItem(pg.ImageItem): - def __init__(self, image=None, **kargs): - super().__init__(image, **kargs) - - def setImage(self, image=None, **kwargs): - if image is None: - return - autoLevels = kwargs.get("autoLevels") - if autoLevels is None: - kwargs["autoLevels"] = False - super().setImage(image, **kwargs) - - -class OverlayImageItem(pg.ImageItem): - def __init__(self, image=None, **kargs): - super().__init__(image, **kargs) - self.autoLevelsEnabled = None - - def setEnableAutoLevels(self, enabled: bool): - self.autoLevelsEnabled = enabled - - def setImage(self, image=None, autoLevels=None, **kargs): - if autoLevels is None: - autoLevels = self.autoLevelsEnabled - - super().setImage(image, autoLevels=autoLevels, **kargs) - - def setOpacity(self, value, **kwargs): - if value == 0: - value = 0.001 - - if value == 1: - value = 0.999 - - super().setOpacity(value) - - -class ParentImageItem(BaseImageItem): - def __init__( - self, - image=None, - linkedImageItem=None, - activatingActions=None, - debug=False, - **kargs, - ): - super().__init__(image, **kargs) - self.linkedImageItem = linkedImageItem - self.activatingActions = activatingActions - self.debug = debug - self._forceDoNotUpdateLinked = False - self.autoLevelsEnabled = None - - def clear(self): - if self.linkedImageItem is not None: - self.linkedImageItem.clear() - return super().clear() - - def isLinkedImageItemActive(self): - if self._forceDoNotUpdateLinked: - return False - - if self.linkedImageItem is None: - return False - - if self.activatingActions is None: - return False - - for action in self.activatingActions: - if action.isChecked(): - return True - - return False - - def setEnableAutoLevels(self, enabled: bool): - self.autoLevelsEnabled = enabled - - def setUsePreprocessed(self, usePreprocessed): - self.usePreprocessed = usePreprocessed - if self.linkedImageItem is None: - return - - self.linkedImageItem.usePreprocessed = usePreprocessed - - def setUseCombined(self, useCombined): - self.useCombined = useCombined - if self.linkedImageItem is None: - return - - self.linkedImageItem.useCombined = useCombined - - def preComputedMinMaxValues(self, *args, **kwargs): - super().preComputedMinMaxValues(*args, **kwargs) - if self.linkedImageItem is None: - return - - self.linkedImageItem.minMaxValuesMapper = self.minMaxValuesMapper - - def updateMinMaxValuesPreprocessedData(self, *args, **kwargs): - super().updateMinMaxValuesPreprocessedData(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.minMaxValuesMapper = self.minMaxValuesMapper - - def updateMinMaxValuesCombinedData(self, *args, **kwargs): - super().updateMinMaxValuesCombinedData(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.minMaxValuesMapperCombined = ( - self.minMaxValuesMapperCombined - ) - - def updateMinMaxValuesCombinedDataProjections(self, *args, **kwargs): - super().updateMinMaxValuesCombinedDataProjections(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.minMaxValuesMapperCombined = ( - self.minMaxValuesMapperCombined - ) - - def updateMinMaxValuesEqualizedDataProjections(self, *args, **kwargs): - super().updateMinMaxValuesEqualizedDataProjections(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.minMaxValuesMapperEqualized = ( - self.minMaxValuesMapperEqualized - ) - - def updateMinMaxValuesEqualizedData(self, *args, **kwargs): - super().updateMinMaxValuesEqualizedData(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.minMaxValuesMapperEqualized = ( - self.minMaxValuesMapperEqualized - ) - - def setCurrentPosIndex(self, *args, **kwargs): - super().setCurrentPosIndex(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.pos_i = self.pos_i - - def setCurrentFrameIndex(self, *args, **kwargs): - super().setCurrentFrameIndex(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.frame_i = self.frame_i + 1 - - def setCurrentZsliceIndex(self, *args, **kwargs): - super().setCurrentZsliceIndex(*args, **kwargs) - - if self.linkedImageItem is None: - return - - self.linkedImageItem.z = self.z - - def setImage( - self, - image=None, - autoLevels=None, - next_frame_image=None, - scrollbar_value=None, - force_set_linked=False, - **kargs, - ): - if autoLevels is None: - autoLevels = self.autoLevelsEnabled - - super().setImage(image, autoLevels=autoLevels, **kargs) - - if self.linkedImageItem is None: - return - - if not self.isLinkedImageItemActive() and not force_set_linked: - return - - if next_frame_image is not None: - self.linkedImageItem.setImage( - next_frame_image, scrollbar_value=scrollbar_value, autoLevels=autoLevels - ) - elif image is not None: - self.linkedImageItem.setImage(image) - - def updateImage(self, *args, **kargs): - if self.isLinkedImageItemActive(): - self.linkedImageItem.image = self.image - self.linkedImageItem.updateImage(*args, **kargs) - return super().updateImage(*args, **kargs) - - def setOpacity(self, value, applyToLinked=True): - super().setOpacity(value) - if not applyToLinked: - return - - if self.linkedImageItem is None: - return - - self.linkedImageItem.setOpacity(value) - - def setLookupTable(self, lut): - super().setLookupTable(lut) - - -class ChildImageItem(BaseImageItem): - def __init__(self, *args, linkedScrollbar=None, **kwargs): - BaseImageItem.__init__(self, *args, **kwargs) - self.linkedScrollbar = linkedScrollbar - - def setImage(self, img=None, z=None, scrollbar_value=None, **kargs): - autoLevels = kargs.get("autoLevels") - if autoLevels is None: - kargs["autoLevels"] = False - - if img is None: - BaseImageItem.setImage(self, img, **kargs) - return - - if img.ndim == 3 and img.shape[-1] > 4 and z is not None: - BaseImageItem.setImage(self, img[z], **kargs) - else: - BaseImageItem.setImage(self, img, **kargs) - - if self.linkedScrollbar is None: - return - - if not self.linkedScrollbar.isEnabled(): - return - - if scrollbar_value is None: - return - - self.linkedScrollbar.setValueNoSignal(scrollbar_value) - - -class labImageItem(pg.ImageItem): - def __init__(self, *args, **kwargs): - pg.ImageItem.__init__(self, *args, **kwargs) - - def setImage(self, img=None, z=None, **kargs): - autoLevels = kargs.get("autoLevels") - if autoLevels is None: - kargs["autoLevels"] = False - - if img is None: - pg.ImageItem.setImage(self, img, **kargs) - return - - if img.ndim == 3 and img.shape[-1] > 4 and z is not None: - pg.ImageItem.setImage(self, img[z], **kargs) - else: - pg.ImageItem.setImage(self, img, **kargs) - - -class GhostContourItem(pg.PlotDataItem): - def __init__( - self, ParentPlotItem, penColor=(245, 184, 0, 100), textColor=(245, 184, 0) - ): - super().__init__() - # Yellow pen - self.setPen(pg.mkPen(width=2, color=penColor)) - self.label = myLabelItem() - self.label.setAttr("bold", True) - self.label.setAttr("color", textColor) - self._ParentPlotItem = ParentPlotItem - - def addToPlotItem(self): - self._ParentPlotItem.addItem(self) - self._ParentPlotItem.addItem(self.label) - - def removeFromPlotItem(self): - self._ParentPlotItem.removeItem(self.label) - self._ParentPlotItem.removeItem(self) - - def setData( - self, xx=None, yy=None, fontSize=11, ID=0, y_cursor=None, x_cursor=None - ): - if xx is None: - xx = [] - if yy is None: - yy = [] - super().setData(xx, yy) - if not hasattr(self, "label"): - return - - if ID == 0: - self.label.setText("") - else: - self.label.setText(f"{ID}", size=fontSize) - w, h = self.label.itemRect().width(), self.label.itemRect().height() - self.label.setPos(x_cursor, y_cursor - h) - - def clear(self): - self.setData([], []) - - -class GhostMaskItem(pg.ImageItem): - def __init__(self, ParentPlotItem): - super().__init__() - self.label = myLabelItem() - self.label.setAttr("bold", True) - self.label.setAttr("color", (245, 184, 0)) - self._ParentPlotItem = ParentPlotItem - - def initImage(self, imgShape): - image = np.zeros(imgShape, dtype=np.uint32) - self.setImage(image) - - def initLookupTable(self, rgbaColor): - lut = np.zeros((2, 4), dtype=np.uint8) - lut[1, -1] = 255 - lut[1, :-1] = rgbaColor - self.setLookupTable(lut) - - def addToPlotItem(self): - self._ParentPlotItem.addItem(self) - self._ParentPlotItem.addItem(self.label) - - def removeFromPlotItem(self): - self._ParentPlotItem.removeItem(self.label) - self._ParentPlotItem.removeItem(self) - - def updateGhostImage(self, ID=0, y_cursor=None, x_cursor=None, fontSize=None): - self.setImage(self.image) - - if ID == 0: - self.label.setText("") - return - - self.label.setText(f"{ID}", size=fontSize) - w, h = self.label.itemRect().width(), self.label.itemRect().height() - self.label.item.setPos(x_cursor, y_cursor - h) - - def clear(self): - if hasattr(self, "label"): - self.label.setText("") - if self.image is None: - return - self.image[:] = 0 - self.setImage(self.image) - - -class ScrollBarWithNumericControl(QWidget): - sigValueChanged = Signal(int) - sigMaxProjToggled = Signal(bool, object) - - def __init__( - self, - orientation=Qt.Horizontal, - add_max_proj_button=False, - parent=None, - labelText="", - ) -> None: - super().__init__(parent) - - self._slot = None - - layout = QHBoxLayout() - self.scrollbar = QScrollBar(orientation, self) - self.spinbox = QSpinBox(self) - self.maxLabel = QLabel(self) - idx = 0 - if labelText: - layout.addWidget(QLabel(labelText)) - layout.setStretch(idx, 0) - idx += 1 - - layout.addWidget(self.spinbox) - layout.setStretch(idx, 0) - idx += 1 - - layout.addWidget(self.maxLabel) - layout.setStretch(idx, 0) - idx += 1 - - layout.addWidget(self.scrollbar) - layout.setStretch(idx, 1) - idx += 1 - - if add_max_proj_button: - self.maxProjCheckbox = QCheckBox("MAX") - self.scrollbar.maxProjCheckbox = self.maxProjCheckbox - layout.addWidget(self.maxProjCheckbox) - layout.setStretch(idx, 0) - - layout.setContentsMargins(5, 0, 5, 0) - - self.setLayout(layout) - - self.spinbox.valueChanged.connect(self.spinboxValueChanged) - self.scrollbar.valueChanged.connect(self.scrollbarValueChanged) - - if add_max_proj_button: - self.maxProjCheckbox.toggled.connect(self.maxProjToggled) - - def connectValueChanged(self, slot): - self.sigValueChanged.connect(slot) - self._slot = slot - - def setValueNoSignal(self, value): - if self._slot is None: - return - self.sigValueChanged.disconnect() - self.setValue(value) - self.sigValueChanged.connect(self._slot) - - def maxProjToggled(self, checked): - self.scrollbar.setDisabled(checked) - self.sigMaxProjToggled.emit(checked, self) - - def showEvent(self, event) -> None: - super().showEvent(event) - - self.scrollbar.setMinimumHeight(self.spinbox.height()) - - def setMaximum(self, maximum): - self.maxLabel.setText(f"/{maximum}") - self.scrollbar.setMaximum(maximum) - self.spinbox.setMaximum(maximum) - - def setMinimum(self, minumum): - self.scrollbar.setMinimum(minumum) - self.spinbox.setMinimum(minumum) - - def spinboxValueChanged(self, value): - self.scrollbar.setValue(value) - - def scrollbarValueChanged(self, value): - self.spinbox.setValue(value) - self.sigValueChanged.emit(value) - - def setValue(self, value): - self.scrollbar.setValue(value) - - def value(self): - return self.scrollbar.value() - - def maximum(self): - return self.scrollbar.maximum() - - -class ImShowPlotItem(pg.PlotItem): - def __init__( - self, - parent=None, - name=None, - labels=None, - title=None, - viewBox=None, - axisItems=None, - enableMenu=True, - **kargs, - ): - super().__init__( - parent, name, labels, title, viewBox, axisItems, enableMenu, **kargs - ) - # Overwrite zoom out button behaviour to disable autoRange after - # clicking it. - # If autorange is enabled, it is called everytime the brush or eraser - # scatter plot items touches the border causing flickering - self.disableAutoRange() - self.autoBtn.mode = "manual" - self.invertY(True) - self.setAspectLocked(True) - self.addImageItem(kargs.get("imageItem")) - - self._selected = False - self.selectingRects = [] - - def setSelectableTitle(self, title: QGraphicsProxyWidget, **kwargs): - self.layout.removeItem(self.titleLabel) - self.layout.addItem(title, 0, 1, alignment=Qt.AlignCenter) - - def isSelected(self): - return self._selected - - def setSelected( - self, selected: bool, xlim=(-np.inf, np.inf), ylim=(-np.inf, np.inf) - ): - if selected == self._selected: - return - - if selected: - ((xmin, xmax), (ymin, ymax)) = self.viewRange() - ylim_min, ylim_max = ylim - xlim_min, xlim_max = xlim - - xmin = max(xlim_min, xmin) - xmax = min(xlim_max, xmax) - ymin = max(ylim_min, ymin) - ymax = min(ylim_max, ymax) - - w = xmax - xmin - h = ymax - ymin - - bs = round(((w + h) / 2) * 0.02) - if bs < 1: - bs = 1 - - rect_left = RectItem(QRectF(xmin, ymin, bs, h)) - rect_top = RectItem(QRectF(xmin + bs, ymin, w - bs - bs, bs)) - rect_right = RectItem(QRectF(xmax - bs, ymin, bs, h)) - rect_bottom = RectItem(QRectF(xmin + bs, ymax - bs, w - bs - bs, bs)) - self.selectingRects.append(rect_left) - self.selectingRects.append(rect_top) - self.selectingRects.append(rect_right) - self.selectingRects.append(rect_bottom) - - self.addItem(rect_left) - self.addItem(rect_top) - self.addItem(rect_right) - self.addItem(rect_bottom) - else: - for rect in self.selectingRects: - self.removeItem(rect) - self.selectingRects = [] - - self._selected = selected - - def addImageItem(self, imageItem): - self.imageItem = imageItem - if imageItem is None: - return - - self.setupContextMenu() - self.addItem(imageItem) - - def setupContextMenu(self): - shuffleCmapAction = QAction("Shuffle colormap", self.vb.menu) - shuffleCmapAction.triggered.connect(self.shuffleColormap) - self.vb.menu.addAction(shuffleCmapAction) - - self.resetCmapAction = QAction("Reset colormap", self.vb.menu) - self.resetCmapAction.triggered.connect(self.resetColormap) - self.vb.menu.addAction(self.resetCmapAction) - self.resetCmapAction.setDisabled(True) - - def shuffleColormap(self): - N = self.imageItem._numLevels - colors = self.imageItem.lut / 255 - cmap = LinearSegmentedColormap.from_list("shuffled", colors, N=N) - lut = plot.matplotlib_cmap_to_lut(cmap, n_colors=N) - if not self.resetCmapAction.isEnabled(): - self._defaultLut = lut.copy() - bkgrColor = lut[0].copy() - np.random.shuffle(lut) - lut[0] = bkgrColor - self.imageItem.setLookupTable(lut) - self.imageItem.update() - self.resetCmapAction.setDisabled(False) - - def resetColormap(self): - self.imageItem.setLookupTable(self._defaultLut) - - def autoBtnClicked(self): - self.autoRange() - - def autoRange(self): - self.vb.autoRange() - self.autoBtn.hide() - - -class _ImShowImageItem(pg.ImageItem): - sigDataHover = Signal(str) - sigHoverEvent = Signal(object, object) - sigMousePressEvent = Signal(object, object) - - def __init__(self, idx) -> None: - super().__init__() - self._idx = idx - self._cursors = [] - self._autoLevels = True - - def _getHoverImageValue(self, xdata, ydata): - try: - value = self.image[ydata, xdata] - return value - except Exception as err: - return - - def setAutoLevels(self, autoLevels): - self._autoLevels = autoLevels - - def mousePressEvent(self, event): - self.sigMousePressEvent.emit(self, event) - super().mousePressEvent(event) - - def setOtherImagesCursors(self, cursors): - self._cursors = cursors - - def clearCursors(self): - for p, cursor in enumerate(self._cursors): - if p == self._idx: - continue - - cursor.setData([], []) - - def setImage(self, *args, **kwargs): - if "autoLevels" not in kwargs: - kwargs["autoLevels"] = self._autoLevels - - super().setImage(*args, **kwargs) - if not args: - return - - if not kwargs["autoLevels"]: - return - - image = args[0] - self._imageMax = image.max() - self._imageMin = image.min() - self._numLevels = self._imageMax - self._imageMin - - def hoverEvent(self, event): - self.sigHoverEvent.emit(self, event) - - if event.isExit(): - self.clearCursors() - self.sigDataHover.emit("") - return - - x, y = event.pos() - xdata, ydata = int(x), int(y) - value = self._getHoverImageValue(xdata, ydata) - if value is None: - self.clearCursors() - self.sigDataHover.emit("") - return - - try: - self.sigDataHover.emit(f"x={xdata}, y={ydata}, {value = :.4f}") - except Exception as e: - self.sigDataHover.emit(f"x={xdata}, y={ydata}, {[val for val in value]}") - - for p, cursor in enumerate(self._cursors): - if p == self._idx: - continue - - cursor.setData([x], [y]) - - -class ImShow(QBaseWindow): - def __init__( - self, - parent=None, - link_scrollbars=True, - infer_rgb=True, - figure_title="", - selectable_images=False, - ): - super().__init__(parent=parent) - self._linkedScrollbars = link_scrollbars - self._infer_rgb = infer_rgb - self._figure_title = figure_title - self._selectable_images = True - self.selected_idx = None - - self._autoLevels = True - - self.textItems = [] - self.group_to_idx_mapper = {"": 0} - - def _getGraphicsScrollbar(self, idx, image, imageItem, maximum): - proxy = QGraphicsProxyWidget(imageItem) - scrollbar = ScrollBarWithNumericControl( - orientation=Qt.Horizontal, add_max_proj_button=True - ) - scrollbar.sigValueChanged.connect(self.OnScrollbarValueChanged) - scrollbar.sigMaxProjToggled.connect(self.onMaxProjToggled) - scrollbar.idx = idx - scrollbar.image = image - scrollbar.imageItem = imageItem - scrollbar.setMaximum(maximum) - proxy.setWidget(scrollbar) - proxy.scrollbar = scrollbar - return proxy - - def OnScrollbarValueChanged(self, value): - scrollbar = self.sender() - imageItem = scrollbar.imageItem - img = self._get2Dimg(imageItem, scrollbar.image) - imageItem.setImage(img) # , autoLevels=self._autoLevels) - - overlayLab = self._get2DlabOverlay(imageItem) - if overlayLab is not None: - imageItem.labImageItem.setImage(overlayLab, autoLevels=False) - - self.setPointsVisible(imageItem) - - self.updateIDs() - - if not self._linkedScrollbars: - return - if len(self.ImageItems) == 1: - return - - self._linkedScrollbars = False - try: - idx = scrollbar.idx - for otherImageItem in self.ImageItems: - if otherImageItem.gridPos == imageItem.gridPos: - continue - if otherImageItem.image.shape != imageItem.image.shape: - continue - for otherScrollbar in otherImageItem.ScrollBars: - if otherScrollbar.idx != idx: - continue - otherScrollbar.setValue(scrollbar.value()) - except Exception as e: - pass - finally: - self._linkedScrollbars = True - - def _get2Dimg(self, imageItem, image): - for scrollbar in imageItem.ScrollBars: - if scrollbar.maxProjCheckbox.isChecked(): - image = image.max(axis=0) - else: - image = image[scrollbar.value()] - return image - - def _get2DlabOverlay(self, imageItem): - try: - lab = imageItem.lab - except Exception as err: - return - - for scrollbar in imageItem.ScrollBars: - if scrollbar.maxProjCheckbox.isChecked(): - lab = lab.max(axis=0) - else: - lab = lab[scrollbar.value()] - - return lab - - def isObjVisible(self, obj, imageItem): - if len(obj.centroid) == 2: - return True - - z_scrollbar = imageItem.ScrollBars[-1] - if z_scrollbar.maxProjCheckbox.isChecked(): - return True - - z_slice = z_scrollbar.value() - min_z, min_y, min_x, max_z, max_y, max_x = obj.bbox - if z_slice >= min_z and z_slice < max_z: - return True - - return False - - def onMaxProjToggled(self, checked, scrollbar): - imageItem = scrollbar.imageItem - img = self._get2Dimg(imageItem, scrollbar.image) - imageItem.setImage(img) # , autoLevels=self._autoLevels) - overlayLab = self._get2DlabOverlay(imageItem) - if overlayLab is not None: - imageItem.labImageItem.setImage(overlayLab, autoLevels=False) - self.setPointsVisible(imageItem) - if not self._linkedScrollbars: - return - if len(self.ImageItems) == 1: - return - - self._linkedScrollbars = False - try: - idx = scrollbar.idx - for otherImageItem in self.ImageItems: - if otherImageItem.gridPos == imageItem.gridPos: - continue - if otherImageItem.image.shape != imageItem.image.shape: - continue - for otherScrollbar in otherImageItem.ScrollBars: - if otherScrollbar.idx != idx: - continue - otherScrollbar.maxProjCheckbox.setChecked(checked) - except Exception as e: - pass - finally: - self._linkedScrollbars = True - - self.updateIDs() - - def setPointsVisible(self, imageItem): - if not hasattr(imageItem, "pointsItems"): - return - - first_coord = imageItem.ScrollBars[0].value() - isMaxProj = imageItem.ScrollBars[0].maxProjCheckbox.isChecked() - for pointsItems in imageItem.pointsItems.values(): - for p, plotItem in enumerate(pointsItems): - plotItem.setVisible((isMaxProj) or (p == first_coord)) - - def setupStatusBar(self): - self.statusbar = self.statusBar() - self.wcLabel = QLabel(f"") - self.statusbar.addPermanentWidget(self.wcLabel) - - def setupMainLayout(self): - self._layout = QHBoxLayout() - self._container = QWidget() - self._container.setLayout(self._layout) - self.setCentralWidget(self._container) - - def setupGraphicLayout( - self, *images, hide_axes=True, max_ncols=4, color_scheme="light" - ): - self.graphicLayout = pg.GraphicsLayoutWidget() - self._colorScheme = color_scheme - - # Set a light background - if color_scheme == "light": - self.graphicLayout.setBackground((235, 235, 235)) - else: - self.graphicLayout.setBackground((30, 30, 30)) - - ncells = max_ncols * ceil(len(images) / max_ncols) - - nrows = ncells // max_ncols - nrows = nrows if nrows > 0 else 1 - ncols = max_ncols if len(images) > max_ncols else len(images) - - if color_scheme == "light": - color = "black" - else: - color = "white" - - self.titleLabel = pg.LabelItem(justify="center", color=color, size="14pt") - self.titleLabel.setText(self._figure_title) - self.graphicLayout.addItem(self.titleLabel, row=0, col=0, colspan=ncols) - start_row = 1 - - # Check if additional rows are needed for the scrollbars - max_ndim = max([image.ndim for image in images]) - if max_ndim > 4: - raise TypeError("One or more of the images have more than 4 dimensions.") - if max_ndim == 4: - rows_range = range(0, (nrows - 1) * 3 + 1, 3) - elif max_ndim == 3: - rows_range = range(0, (nrows - 1) * 2 + 1, 2) - else: - rows_range = range(nrows) - - self.PlotItems = [] - self.ImageItems = [] - self.ScrollBars = [] - i = 0 - for r in rows_range: - row = r + start_row - for col in range(ncols): - try: - image = images[i] - except IndexError: - break - plotItem = ImShowPlotItem() - if hide_axes: - plotItem.hideAxis("bottom") - plotItem.hideAxis("left") - self.graphicLayout.addItem(plotItem, row=row, col=col) - plotItem.loc = (row, col) - self.PlotItems.append(plotItem) - - imageItem = _ImShowImageItem(i) - plotItem.addImageItem(imageItem) - imageItem.plot = plotItem - imageItem.sigHoverEvent.connect(self.onImageItemHoverEvent) - imageItem.sigMousePressEvent.connect(self.onImageItemMousePressEvent) - self.ImageItems.append(imageItem) - imageItem.gridPos = (row, col) - imageItem.ScrollBars = [] - - is_rgb = image.shape[-1] == 3 and self._infer_rgb - is_rgba = image.shape[-1] == 4 and self._infer_rgb - does_not_require_scrollbars = image.ndim == 2 or ( - image.ndim == 3 and (is_rgb or is_rgba) - ) - if does_not_require_scrollbars: - i += 1 - continue - - idx_image = 3 if (is_rgb or is_rgba) else 2 - for s in range(image.ndim - idx_image): - maximum = image.shape[s] - 1 - scrollbarProxy = self._getGraphicsScrollbar( - s, image, imageItem, maximum - ) - self.graphicLayout.addItem(scrollbarProxy, row=row + s + 1, col=col) - imageItem.ScrollBars.append(scrollbarProxy.scrollbar) - - i += 1 - - self._layout.addWidget(self.graphicLayout) - - def onImageItemMousePressEvent(self, imageItem, event): - if not self._selectable_images: - return - - plotItem = imageItem.plot - if not plotItem.isSelected(): - return - - self.selected_idx = self.PlotItems.index(plotItem) - event.ignore() - self.close() - - def onImageItemHoverEvent(self, imageItem, event): - if not self._selectable_images: - return - - modifiers = QGuiApplication.keyboardModifiers() - isCtrl = modifiers == Qt.ControlModifier - plotItem = imageItem.plot - Y, X = imageItem.image.shape[:2] - plotItem.setSelected(isCtrl and not event.isExit(), xlim=(0, X), ylim=(0, Y)) - - def movePlotItem(self, title): - combobox = self.sender() - plotItem = combobox.plotItem - row, col = plotItem.loc - - otherPlotItemIdx = combobox.titles.index(title) - otherPlotItem = self.PlotItems[otherPlotItemIdx] - other_row, other_col = otherPlotItem.loc - - self.graphicLayout.removeItem(plotItem) - self.graphicLayout.removeItem(otherPlotItem) - self.graphicLayout.addItem(otherPlotItem, row=row, col=col) - self.graphicLayout.addItem(plotItem, row=other_row, col=other_col) - - combobox.blockSignals(True) - combobox.setCurrentText(combobox.default_text) - combobox.blockSignals(False) - - plotItemIdx = combobox.titles.index(combobox.default_text) - - otherPlotItem.loc = (row, col) - plotItem.loc = (other_row, other_col) - - def setupTitles(self, *titles): - for plotItem, title in zip(self.PlotItems, titles): - combobox = ComboBox() - combobox.default_text = title - combobox.titles = list(titles) - combobox.addItems(titles) - combobox.setMaximumWidth(combobox.sizeHint().width()) - combobox.setCurrentText(title) - comboboxGraphicsItem = QGraphicsProxyWidget() - comboboxGraphicsItem.setWidget(combobox) - combobox.plotItem = plotItem - plotItem.setSelectableTitle(comboboxGraphicsItem) - combobox.currentTextChanged.connect(self.movePlotItem) - - # color = 'k' if self._colorScheme == 'light' else 'w' - # for plotItem, title in zip(self.PlotItems, titles): - # plotItem.setSelectableTitle(title, color=color) - - def updateStatusBarLabel(self, text): - self.wcLabel.setText(text) - - def autoRange(self): - for plot in self.PlotItems: - plot.autoRange() - - def showImages( - self, - *images, - labels_overlays: np.ndarray | List[np.ndarray] = None, - luts=None, - labels_overlays_luts=None, - autoLevels=True, - autoLevelsOnScroll=False, - ): - from .plot import matplotlib_cmap_to_lut - - images = [np.squeeze(img) for img in images] - self.luts = luts - self._autoLevels = autoLevels - self._autoLevelsOnScroll = autoLevelsOnScroll - for image in images: - if image.ndim > 5 or image.ndim < 2: - raise TypeError( - f"Input image has {image.ndim} dimensions. " - "Only 2-D, 3-D, and 4-D images are supported" - ) - - if isinstance(labels_overlays, np.ndarray): - labels_overlays = [labels_overlays] - - if isinstance(labels_overlays_luts, np.ndarray): - labels_overlays_luts = [labels_overlays_luts] - - if ( - labels_overlays_luts is not None - and labels_overlays is not None - and (len(labels_overlays_luts) != len(labels_overlays)) - ): - raise TypeError( - f"Number of lables_overlays_luts is {len(labels_overlays_luts)}, " - f"while number of labels_overaly is {len(labels_overlays)}. " - "Pass `None` if you want to use default lut for the labels_overlays." - ) - - if labels_overlays is not None and (len(labels_overlays) != len(images)): - raise TypeError( - f"Number of images is {len(images)}, " - f"while number of labels_overaly is {len(labels_overlays)}. " - "Pass `None` if you do not need overlaid labeles." - ) - - for i, (image, imageItem) in enumerate(zip(images, self.ImageItems)): - if luts is not None: - _autoLevels = autoLevels - lut = luts[i] - if not autoLevels and lut is not None: - imageItem.setLevels([0, len(lut)]) - else: - _autoLevels = True - if lut is None: - lut = matplotlib_cmap_to_lut("viridis") - imageItem.setLookupTable(lut) - else: - _autoLevels = True - - is_rgb = image.shape[-1] == 3 and self._infer_rgb - is_rgba = image.shape[-1] == 4 and self._infer_rgb - does_not_require_scrollbars = image.ndim == 2 or ( - image.ndim == 3 and (is_rgb or is_rgba) - ) - - if does_not_require_scrollbars: - imageItem.setAutoLevels(_autoLevels) - imageItem.setImage(image) - else: - if not self._autoLevelsOnScroll and not _autoLevels: - imageItem.setAutoLevels(False) - imageItem.setLevels([image.min(), image.max()]) - for scrollbar in imageItem.ScrollBars: - scrollbar.setValue(int(scrollbar.maximum() / 2)) - - imageItem.sigDataHover.connect(self.updateStatusBarLabel) - - if labels_overlays is None: - continue - - lab_overlay = labels_overlays[i] - if lab_overlay is None: - continue - - if lab_overlay.shape != image.shape: - raise TypeError( - f"`lab_overlay` at index {i} has shape " - f"{lab_overlay.shape} which is different " - f"from image shape {image.shape}. " - "The image and the `lab_overlay` must " - "have the same shape." - ) - - plot = imageItem.plot - labImageItem = pg.ImageItem() - labImageItem.setOpacity(0.4) - plot.addImageItem(labImageItem) - - if labels_overlays_luts is not None: - labels_overlays_lut = labels_overlays_luts[i] - else: - labels_overlays_lut = self._getDefaultLabelsOverlayLut(lab_overlay) - - labImageItem.setLookupTable(labels_overlays_lut) - labImageItem.setLevels([0, len(labels_overlays_lut)]) - - imageItem.lab = lab_overlay - imageItem.labImageItem = labImageItem - - overlayLab = self._get2DlabOverlay(imageItem) - labImageItem.setImage(overlayLab, autoLevels=False) - - # Share axis between images with same X, Y shape - all_shapes = [image.shape[-2:] for image in images] - unique_shapes = set(all_shapes) - shame_shape_plots = [] - for unique_shape in unique_shapes: - plots = [ - self.PlotItems[i] - for i, shape in enumerate(all_shapes) - if shape == unique_shape - ] - shame_shape_plots.append(plots) - - for plots in shame_shape_plots: - for plot in plots: - plot.vb.setYLink(plots[0].vb) - plot.vb.setXLink(plots[0].vb) - - def _getDefaultLabelsOverlayLut(self, lab_overlay): - IDs = [obj.label for obj in skimage.measure.regionprops(lab_overlay)] - n_objs = len(IDs) - lut = np.zeros((n_objs + 1, 4), dtype=np.uint8) - rgbas = colors.plt_colormap_to_pg_lut("tab20", ncolors=n_objs) - np.random.shuffle(rgbas) - lut[1:] = rgbas - return lut - - def _createPointsScatterItem(self, xx, yy, group, colors=None, data=None): - if colors is None: - cmap = matplotlib.colormaps["jet_r"] - idx = self.group_to_idx_mapper[group] - r, g, b = [round(c * 255) for c in cmap(idx)][:3] - brush = pg.mkBrush(color=(r, g, b, 100)) - pen = pg.mkPen(width=2, color=(r, g, b)) - hoverBrush = pg.mkBrush((r, g, b, 200)) - else: - brush = [] - pen = [] - hoverBrush = None - for color in colors: - rgb = matplotlib.colors.to_rgb(color) - rgb = [round(c * 255) for c in rgb] - _brush = pg.mkBrush(color=(*rgb, 100)) - _pen = pg.mkPen(width=2, color=rgb) - brush.append(_brush) - pen.append(_pen) - - item = pg.ScatterPlotItem( - xx, - yy, - symbol="o", - pxMode=False, - size=3, - brush=brush, - pen=pen, - hoverable=True, - hoverBrush=hoverBrush, - data=data, - ) - return item - - def drawPointsFromDf( - self, points_df: pd.DataFrame | List[pd.DataFrame], points_groups=None - ): - if not isinstance(points_df, (list, tuple)): - points_df = [points_df] * len(self.PlotItems) - - for p, df in enumerate(points_df): - if isinstance(points_groups, str): - points_groups = [points_groups] - - if points_groups is None: - grouped = [("", df)] - groups = [""] - else: - grouped = df.groupby(points_groups) - groups = grouped.groups.keys() - - idxs_space = np.linspace(0, 1, len(groups)) - self.group_to_idx_mapper = dict(zip(groups, idxs_space)) - - for group, df in grouped: - yy = df["y"].values - xx = df["x"].values - points_coords = np.column_stack((yy, xx)) - if "z" in df.columns: - zz = df["z"].values - points_coords = np.column_stack((zz, points_coords)) - if len(group) == 1: - group = group[0] - - colors = None - if "color" in df.columns: - colors = df["color"].values - - data = None - if "data" in df.columns: - data = df["data"].values - - self.drawPoints( - points_coords, colors=colors, group=group, idx=p, data=data - ) - - def drawPoints( - self, - points_coords: np.ndarray, - group="", - idx=None, - colors=None, - data=None, - ): - offset = 0.5 if np.issubdtype(points_coords.dtype, np.integer) else 0 - n_dim = points_coords.shape[1] - - if idx is not None: - PlotItems = [self.PlotItems[idx]] - ImageItems = [self.ImageItems[idx]] - else: - PlotItems = self.PlotItems - ImageItems = self.ImageItems - - if n_dim == 2: - if data is None: - data = group - - zz = [0] * len(points_coords) - self.points_coords = np.column_stack((zz, points_coords)) - for p, plotItem in enumerate(PlotItems): - imageItem = ImageItems[p] - xx = points_coords[:, 1] + offset - yy = points_coords[:, 0] + offset - pointsItem = self._createPointsScatterItem( - xx, yy, group, data=data, colors=colors - ) - pointsItem.z = 0 - plotItem.addItem(pointsItem) - imageItem.pointsItems = {group: [pointsItem]} - elif n_dim == 3: - self.points_coords = points_coords - for p, plotItem in enumerate(PlotItems): - imageItem = ImageItems[p] - imageItem.pointsItems = defaultdict(list) - scrollbar = imageItem.ScrollBars[0] - for first_coord in range(scrollbar.maximum() + 1): - coords_idx = np.nonzero(points_coords[:, 0] == first_coord) - coords = points_coords[coords_idx] - if colors is None: - _colors = None - else: - _colors = np.asarray(colors)[coords_idx] - if len(_colors) == 0: - _colors = None - - _data = group - if data is not None: - _data = data[coords_idx] - if len(_data) == 0: - _data = group - - xx = coords[:, 2] + offset - yy = coords[:, 1] + offset - pointsItem = self._createPointsScatterItem( - xx, yy, group, data=_data, colors=_colors - ) - pointsItem.z = first_coord - plotItem.addItem(pointsItem) - pointsItem.setVisible(False) - imageItem.pointsItems[group].append(pointsItem) - self.setPointsVisible(imageItem) - - def setupDuplicatedCursors(self): - self.cursors = [] - for p, plotItem in enumerate(self.PlotItems): - cursor = pg.ScatterPlotItem( - symbol="+", - pxMode=True, - pen=pg.mkPen("k", width=1), - brush=pg.mkBrush("w"), - size=16, - tip=None, - ) - self.cursors.append(cursor) - plotItem.addItem(cursor) - - for imageItem in self.ImageItems: - imageItem.setOtherImagesCursors(self.cursors) - - def setPointsData(self, points_data): - points_df = pd.DataFrame( - { - "z": self.points_coords[:, 0], - "y": self.points_coords[:, 1], - "x": self.points_coords[:, 2], - } - ) - if isinstance(points_data, pd.Series): - points_df[points_data.name] = points_data.values - elif isinstance(points_data, pd.DataFrame): - points_df = points_df.join(points_data) - elif isinstance(points_data, np.ndarray): - if points_data.ndim == 1: - points_data = points_data[np.newaxis] - else: - points_data = points_data.T - for i, values in enumerate(points_data): - points_df[f"col_{i}"] = values - - self.points_df = points_df.set_index(["z", "y", "x"]).sort_index() - - for p, plotItem in enumerate(self.PlotItems): - imageItem = self.ImageItems[p] - for pointsItems in imageItem.pointsItems.values(): - for pointsItem in pointsItems: - pointsItem.sigClicked.connect(self.pointsClicked) - - def pointsClicked(self, item, points, event): - point = points[0] - x, y = point.pos() - coords = (item.z, int(y), int(x)) - point_data = self.points_df.loc[[coords]] - now = datetime.datetime.now().strftime("%H:%M:%S") - print("*" * 60) - print(f"Point clicked at {now}. Data:") - print("-" * 60) - print(point_data) - print("") - print("*" * 60) - - def annotateObjectIDs(self, annotate_labels_idxs=None, init=False): - if init: - self.annotate_labels_idxs = annotate_labels_idxs - self.textItems = [{} for _ in self.PlotItems] - if self.annotate_labels_idxs is None: - return - for i, plotItem in enumerate(self.PlotItems): - if i not in self.annotate_labels_idxs: - continue - plotTextItems = self.textItems[i] - imageItem = self.ImageItems[i] - try: - if init: - # 3D labels (if 3D) - lab = imageItem.lab - else: - lab = imageItem.labImageItem.image - except Exception as err: - lab = imageItem.image - - rp = skimage.measure.regionprops(lab) - for obj in rp: - textItem = plotTextItems.get(obj.label) - yc, xc = obj.centroid[-2:] - if textItem is None: - textItem = pg.TextItem(text="", anchor=(0.5, 0.5), color="r") - plotItem.addItem(textItem) - plotTextItems[obj.label] = textItem - - if self.isObjVisible(obj, imageItem): - text = str(obj.label) - else: - text = "" - - textItem.setText(text) - textItem.setPos(xc, yc) - - # plotItem.enableAutoRange() - - def clearLabels(self): - for textItems in self.textItems: - for textItem in textItems.values(): - textItem.setText("") - - def updateIDs(self): - self.clearLabels() - try: - self.annotateObjectIDs(annotate_labels_idxs=self.annotate_labels_idxs) - except Exception as err: - pass - - def show(self, block=False, screenToWindowRatio=None): - super().show(block=block) - if screenToWindowRatio is None: - return - screenGeometry = self.screen().geometry() - screenWidth = screenGeometry.width() - screenHeight = screenGeometry.height() - finalWidth = int(screenToWindowRatio * screenWidth) - finalHeight = int(screenToWindowRatio * screenHeight) - screenTop = screenGeometry.top() - screenLeft = screenGeometry.left() - xc, yc = screenLeft + screenWidth / 2, screenTop + screenHeight / 2 - winLeft = int(xc - finalWidth / 2) - winTop = int(yc - finalHeight / 2) - self.setGeometry(winLeft, winTop, finalWidth, finalHeight) - - def run(self, block=False, showMaximised=False, screenToWindowRatio=None): - if showMaximised: - self.showMaximized() - else: - self.show(screenToWindowRatio=screenToWindowRatio) - QTimer.singleShot(100, self.autoRange) - - if block: - self.exec_() - - def resizeEvent(self, event) -> None: - self.PlotItems[0].autoRange() - return super().resizeEvent(event) - - -class LabelItem(pg.LabelItem): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def bbox(self): - xl, yl = self.pos().x(), self.pos().y() - wl, hl = self.itemRect().width(), self.itemRect().height() - return yl, xl, yl + hl, xl + wl - - def setBold(self, bold=True): - self.origPos = self.pos() - self.setText(self.text, bold=bold) - self.setPos(self.origPos) - - -class ScaleBar(QGraphicsObject): - sigEditProperties = Signal(object) - sigRemove = Signal(object) - - def __init__(self, imageShape, viewRange, parent=None): - super().__init__(parent) - self.SizeY, self.SizeX = imageShape - self.updateViewRange(viewRange) - self.plotItem = PlotCurveItem() - self.labelItem = LabelItem() - self._x_pad = 5 - self._y_pad = 3 - self._highlighted = False - self._parent = parent - self.clicked = False - self.createContextMenu() - - def updateViewRange(self, viewRange): - xRange, yRange = viewRange - x0, x1 = xRange - y0, y1 = yRange - if x0 < 0: - x0 = 0 - - if x1 > self.SizeX: - x1 = self.SizeX - - if y0 < 0: - y0 = 0 - - if y1 > self.SizeY: - y1 = self.SizeY - - self.xmax = x1 - self.xmin = x0 - - self.ymax = y1 - self.ymin = y0 - - def createContextMenu(self): - self.contextMenu = QMenu() - action = QAction("Edit properties...", self.contextMenu) - action.triggered.connect(self.emitEditProperties) - self.contextMenu.addSeparator() - action = QAction("Remove", self.contextMenu) - action.triggered.connect(self.emitRemove) - self.contextMenu.addAction(action) - - def emitEditProperties(self): - self.setHighlighted(False) - self.sigEditProperties.emit(self.properties()) - - def emitRemove(self): - self.sigRemove.emit(self) - - def isHighlighted(self): - return self._highlighted - - def setHighlighted(self, highlighted): - if self._highlighted and highlighted: - return - - if not self._highlighted and not highlighted: - return - - pen = self.highlightPen if highlighted else self.pen - self.labelItem.setBold(bold=highlighted) - self.plotItem.setPen(pen) - - self._highlighted = highlighted - - def showContextMenu(self, x, y): - self.contextMenu.popup(QPoint(int(x), int(y))) - - def properties(self): - properties = { - "thickness": self._thickness, - "length_pixel": self._length, - "length_unit": self._length_unit, - "is_text_visible": self._is_text_visible, - "color": self._color, - "loc": self._loc, - "font_size": float(self._font_size[:-2]), - "unit": self._unit, - "num_decimals": self._num_decimals, - "move_with_zoom": self._move_with_zoom, - } - return properties - - def move(self, xm, ym): - self._loc = "Custom" - - Dy = ym - self.yc - Dx = xm - self.xc - - x0 = self.x0c + Dx - x1 = x0 + self._length - y0 = y1 = self.y0c + Dy - self.plotItem.setData([x0, x1], [y0, y1]) - self.setTextPos() - - def paint(self, painter, option, widget): - pass - - def boundingRect(self): - ymin, xmin, ymax, xmax = self.bbox() - return QRectF(xmin, ymin, xmax - xmin, ymax - ymin) - - def setLocationProperty(self, loc: str): - self._loc = loc - - def setMoveWithZoomProperty(self, move_with_zoom): - self._move_with_zoom = move_with_zoom - - def setProperties( - self, - length_pixel, - length_unit, - thickness=3, - color="w", - is_text_visible=True, - loc="top-left", - font_size=12, - unit="", - num_decimals=0, - move_with_zoom=False, - ): - self._loc = loc - self._color = color - self._length = length_pixel - self._length_unit = length_unit - self._is_text_visible = is_text_visible - self._font_size = f"{font_size}px" - self._unit = unit - self._num_decimals = num_decimals - self._move_with_zoom = move_with_zoom - self._thickness = thickness - self.pen = pg.mkPen(width=thickness, color=color, cosmetic=False) - self.highlightPen = pg.mkPen(width=thickness + 2, color=color, cosmetic=False) - self.pen.setCapStyle(Qt.PenCapStyle.FlatCap) - self.highlightPen.setCapStyle(Qt.PenCapStyle.FlatCap) - self.plotItem.setPen(self.pen) - - def updatePhysicalLength(self, PhysicalSizeX): - length_unit = self._length_unit - unit = self._unit - length_um = _core.convert_length(length_unit, unit, "μm") - length_pixel = length_um / PhysicalSizeX - self._length = length_pixel - self.update() - - def addToAxis(self, ax): - ax.addItem(self.plotItem) - ax.addItem(self.labelItem) - - def setText(self): - if self._is_text_visible: - number = round(self._length_unit, self._num_decimals) - if self._num_decimals == 0: - number = int(number) - text = f"{number} {self._unit}" - else: - text = "" - self.labelItem.setText(text, color=self._color, size=self._font_size) - - def setTextPos(self): - xx, yy = self.plotItem.getData() - x0 = xx[0] - y0 = yy[0] - xc = x0 + self._length / 2 - wl = self.labelItem.itemRect().width() - hl = self.labelItem.itemRect().height() - xl = xc - wl / 2 - yt = y0 - hl - self.labelItem.setPos(xl, yt) - - def updatePosViewRangeChanged(self, viewRange): - if self._loc == "custom": - xx, yy = self.plotItem.getData() - x0p = xx[0] - y0p = yy[0] - xcp = x0p + self._length / 2 - hl = self.labelItem.itemRect().height() - ycp = y0p - hl / 2 - x0 = self.xmin - y0 = self.ymin - x_range = self.xmax - x0 - y_range = self.ymax - y0 - Dx_perc = (xcp - x0) / x_range - Dy_perc = (ycp - y0) / y_range - - self.updateViewRange(viewRange) - - X0 = self.xmin - Y0 = self.ymin - - X_range = self.xmax - X0 - Y_range = self.ymax - Y0 - - Xcp = X0 + (Dx_perc * X_range) - Ycp = Y0 + (Dy_perc * Y_range) - X0p = Xcp - (self._length / 2) - Y0p = Ycp + (hl / 2) - - X1p = X0p + self._length - Y1p = Y0p - - self.plotItem.setData([X0p, X1p], [Y0p, Y1p]) - else: - self.updateViewRange(viewRange) - self.update() - - def getStartXCoordFromLoc(self, loc): - if loc == "custom": - xx, yy = self.plotItem.getData() - x0 = xx[0] - return x0 - - self.setText() - wl = self.labelItem.itemRect().width() - if loc.find("left") != -1: - x0 = self._x_pad + self.xmin - xc = x0 + self._length / 2 - xl = xc - wl / 2 - if xl < x0: - # Text is larger than line --> move line to the right - x0 = self._x_pad + abs(xl - self._x_pad) - else: - x0 = self.xmax - self._length - self._x_pad - xc = x0 + self._length / 2 - x1 = x0 + self._length - xr = xc + wl / 2 - if xr > x1: - # Text is larger than line --> move line to the left - delta_overshoot = xr - x1 - x0 = x0 - delta_overshoot - return x0 - - def getStartYCoordFromLoc(self, loc): - if loc == "custom": - xx, yy = self.plotItem.getData() - y0 = yy[0] - return y0 - - self.setText() - textHeight = self.labelItem.itemRect().height() - if loc.find("top") != -1: - return textHeight + self._y_pad + self.ymin - else: - return self.ymax - self._y_pad - self._thickness - - def update(self): - x0 = self.getStartXCoordFromLoc(self._loc) # + self._thickness/2 - y0 = self.getStartYCoordFromLoc(self._loc) - - x1 = x0 + self._length # - self._thickness/2 - self.plotItem.setData([x0, x1], [y0, y0]) - - self.setText() - self.setTextPos() - - def draw(self, length_pixel, length_unit, **kwargs): - self.setProperties(length_pixel, length_unit, **kwargs) - self.update() - - def bbox(self): - y_line_min, x_line_min, y_line_max, x_line_max = self.plotItem.bbox() - y_lab_min, x_lab_min, y_lab_max, x_lab_max = self.labelItem.bbox() - ymin = min(y_line_min, y_lab_min) - xmin = min(x_line_min, x_lab_min) - ymax = max(y_line_max, y_lab_max) - xmax = max(x_line_max, x_lab_max) - return ymin, xmin, ymax, xmax - - def mousePressed(self, x, y): - self.clicked = True - self.xc, self.yc = x, y - xx, yy = self.plotItem.getData() - self.x0c = xx[0] - self.y0c = yy[0] - - def removeFromAxis(self, ax): - ax.removeItem(self.labelItem) - ax.removeItem(self.plotItem) - - -class RulerPlotItem(pg.PlotDataItem): - def __init__(self, *args, **kwargs): - self.labelItem = pg.LabelItem() - super().__init__(*args, **kwargs) - - def setData(self, *args, lengthText="", **kwargs): - super().setData(*args, **kwargs) - self.labelItem.setText("") - if not lengthText: - return - self.setLengthText(lengthText) - - def setLengthText(self, lengthText): - xx, yy = self.getData() - x0, x1 = sorted(xx) - y0, y1 = sorted(yy) - xc = round(x0 + (x1 - x0) / 2) - yc = round(y0 + (y1 - y0) / 2) - self.labelItem.setText(lengthText, size="11px", color="r") - # xc = x0 + self._length/2 - wl = self.labelItem.itemRect().width() - hl = self.labelItem.itemRect().height() - xl = xc - wl / 2 - yt = y0 - hl - self.labelItem.setPos(xl, yt) - - -class PointsScatterPlotItem(pg.ScatterPlotItem): - sigHoverEntered = Signal(object, object, object) - - def __init__(self, *args, ax=None, show_data_as_tip=False, **kwargs): - self.textItem = annotate.TextAnnotationsScatterItem(size=12, anchor=(1.0, 1.0)) - self.textItem.createSymbols( - [str(int_id) for int_id in range(200)], includeBold=False - ) - # self._textItems = {} - super().__init__(*args, **kwargs) - self.textItem.setParentItem(self) - self._font = QFont() - self._font.setPixelSize(12) - self.show_data_as_tip = show_data_as_tip - self.drawIds = True - self.ax = ax - self.sigHovered.connect(self.onHover) - self.lastHoveredPoint = None - - def onHover(self, item, points, event): - if len(points) == 0: - vb = self.getViewBox() - vb.setToolTip("") - return - - if self.lastHoveredPoint != points[0]: - self.sigHoverEntered.emit(item, points, event) - self.lastHoveredPoint = points[0] - - if not self.opts["hoverable"]: - return - - if not self.show_data_as_tip: - return - - tip_li = [str(point.data()) for point in points] - tip = "\n\n".join(tip_li) - - vb = self.getViewBox() - vb.setToolTip(tip) - - def setData(self, *args, **kwargs): - self.clearTextItems() - super().setData(*args, **kwargs) - data = kwargs.get("data") - if data is None: - return - - if len(data) == 0: - return - - first_point_data = data[0] - if not isinstance(first_point_data, (int, str)): - return - - if not self.drawIds: - return - - if self.show_data_as_tip: - return - - color = self.opts["brush"].color() - self.textItem.setColors({"id": color.getRgb()}) - size = self.opts["size"] - radius = size / 2 - # xx, yy = args - # for x, y, point_data in zip(xx, yy, data): - for point in self.points(): - text = str(point.data()) - if not text: - continue - - x, y = point.pos().x(), point.pos().y() - xt, yt = x + radius - 0.5, y - radius + 0.5 - opts = { - "text": text, - "bold": False, - "color_name": "id", - } - data = self.textItem.addObjAnnot((xt, yt), anchor=(-0.3, 1.3), **opts) - self.textItem.appendData(data, opts["text"]) - - self.textItem.draw() - # hexColor = color.name() - # htmlText = html_utils.span( - # text, color=hexColor, font_size='13pt', bold=True - # ) - - # textItem = self._textItems.get((x, y)) - # if textItem is None: - # textItem = pg.TextItem(html=htmlText, anchor=(0, 1)) - # textItem.setParentItem(self) - # self._textItems[(x, y)] = textItem - # self.ax.addItem(textItem) - # else: - # textItem.setHtml(htmlText) - # textItem.setPos(x+radius-0.5, y-radius+0.5) - - def clearTextItems(self): - self.textItem.clearData() - # for textItem in self._textItems.values(): - # textItem.setText('') - - def clear(self): - super().clear() - self.clearTextItems() - - def setVisible(self, visible): - super().setVisible(visible) - self.textItem.setVisible(visible) - - -class RectItem(pg.GraphicsObject): - def __init__(self, rect, pen=None, brush=(255, 0, 0, 100), parent=None): - super().__init__(parent) - self._rect = rect - self._pen = pg.mkPen(pen) - self._brush = pg.mkBrush(brush) - self.picture = QPicture() - self._generate_picture() - - def setColor(self, color): - rgba = matplotlib.colors.to_rgba(color, alpha=100 / 255) - rgba = [round(c * 255) for c in rgba] - self._brush = pg.mkBrush(rgba) - self._generate_picture() - self.update() - - def setRect(self, x, y, width, height): - self._rect = QRectF(x, y, width, height) - self._generate_picture() - self.update() - - def setQRect(self, qrect): - self._rect = qrect - self._generate_picture() - self.update() - - @property - def rect(self): - return self._rect - - def _generate_picture(self): - painter = QPainter(self.picture) - painter.setPen(self._pen) - painter.setBrush(self._brush) - painter.drawRect(self._rect) - painter.end() - - def paint(self, painter, option, widget=None): - painter.drawPicture(0, 0, self.picture) - - def boundingRect(self): - return QRectF(self.picture.boundingRect()) - -# Sibling imports (deferred to avoid import cycles) -from .controls import ( - ComboBox, - DoubleSpinBox, - SpinBox, - highlightableQWidgetAction, -) - diff --git a/cellacdc/widgets/canvas/__init__.py b/cellacdc/widgets/canvas/__init__.py new file mode 100644 index 000000000..7f36811a2 --- /dev/null +++ b/cellacdc/widgets/canvas/__init__.py @@ -0,0 +1,104 @@ +"""Canvas widgets.""" + +from .histogram import ( + BaseGradientEditorItemImage, + BaseGradientEditorItemLabels, + baseHistogramLUTitem, + labelsGradientWidget, + myColorButton, + myHistogramLUTitem, + overlayLabelsGradientWidget, +) + +from .images import ( + BaseImageItem, + BaseLabelsImageItem, + ChildImageItem, + GhostMaskItem, + OverlayImageItem, + ParentImageItem, + _ImShowImageItem, + labImageItem, +) + +from .imshow import ( + ImShow, + ImShowPlotItem, +) + +from .plot_items import ( + BaseScatterPlotItem, + ContourItem, + CustomAnnotationScatterPlotItem, + GhostContourItem, + LabelItem, + LabelRoiCircularItem, + MainPlotItem, + PlotCurveItem, + PointsScatterPlotItem, + RectItem, + RulerPlotItem, + ScaleBar, + ScatterPlotItem, + myLabelItem, +) + +from .rois import ( + DelROI, + PolyLineROI, + ROI, + ZoomROI, +) + +from .scrollbars import ( + MouseCursor, + ScrollBarWithNumericControl, + labelledQScrollbar, + linkedQScrollbar, + navigateQScrollBar, + sliderWithSpinBox, +) + +__all__ = [ + "BaseGradientEditorItemImage", + "BaseGradientEditorItemLabels", + "baseHistogramLUTitem", + "labelsGradientWidget", + "myColorButton", + "myHistogramLUTitem", + "overlayLabelsGradientWidget", + "BaseImageItem", + "BaseLabelsImageItem", + "ChildImageItem", + "GhostMaskItem", + "OverlayImageItem", + "ParentImageItem", + "_ImShowImageItem", + "labImageItem", + "ImShow", + "ImShowPlotItem", + "BaseScatterPlotItem", + "ContourItem", + "CustomAnnotationScatterPlotItem", + "GhostContourItem", + "LabelItem", + "LabelRoiCircularItem", + "MainPlotItem", + "PlotCurveItem", + "PointsScatterPlotItem", + "RectItem", + "RulerPlotItem", + "ScaleBar", + "ScatterPlotItem", + "myLabelItem", + "DelROI", + "PolyLineROI", + "ROI", + "ZoomROI", + "MouseCursor", + "ScrollBarWithNumericControl", + "labelledQScrollbar", + "linkedQScrollbar", + "navigateQScrollBar", + "sliderWithSpinBox", +] diff --git a/cellacdc/widgets/canvas/histogram.py b/cellacdc/widgets/canvas/histogram.py new file mode 100644 index 000000000..df7f710b6 --- /dev/null +++ b/cellacdc/widgets/canvas/histogram.py @@ -0,0 +1,1301 @@ +"""Canvas widgets: histogram.""" + +"""GUI widgets: canvas.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +class BaseGradientEditorItemImage(pg.GradientEditorItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + def restoreState(self, state): + pg.graphicsItems.GradientEditorItem.Gradients = GradientsImage + return super().restoreState(state) + + +class BaseGradientEditorItemLabels(pg.GradientEditorItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + def restoreState(self, state): + pg.graphicsItems.GradientEditorItem.Gradients = GradientsLabels + return super().restoreState(state) + + +class baseHistogramLUTitem(pg.HistogramLUTItem): + sigAddColormap = Signal(object, str) + sigRescaleIntes = Signal(object) + + def __init__(self, name="image", axisLabel="", parent=None, **kwargs): + pg.GradientEditorItem = BaseGradientEditorItemLabels + + super().__init__(**kwargs) + + self.labelStyle = {"color": "#ffffff", "font-size": "11px"} + + if axisLabel: + self.setAxisLabel(axisLabel) + + self.cmaps = cmaps + self._parent = parent + self.name = name + + self.gradient.colorDialog.setWindowFlags(Qt.Dialog | Qt.WindowStaysOnTopHint) + self.gradient.colorDialog.accepted.disconnect() + self.gradient.colorDialog.accepted.connect(self.tickColorAccepted) + + self.isInverted = False + self.lastGradientName = "grey" + self.lastGradient = Gradients["grey"] + + for action in self.gradient.menu.actions(): + if action.text() == "HSV": + HSV_action = action + elif action.text() == "RGB": + RGB_ation = action + self.gradient.menu.removeAction(HSV_action) + self.gradient.menu.removeAction(RGB_ation) + + # Rescale intensities (LUT) + rescaleIntensMenu = self.gradient.menu.addMenu("Rescale intensities (LUT)") + rescaleActionGroup = QActionGroup(self) + rescaleActionGroup.setExclusive(True) + + self.rescaleEach2DimgAction = QAction( + "Rescale each 2D image", rescaleIntensMenu + ) + self.rescaleEach2DimgAction.setCheckable(True) + self.rescaleEach2DimgAction.setChecked(True) + rescaleActionGroup.addAction(self.rescaleEach2DimgAction) + rescaleIntensMenu.addAction(self.rescaleEach2DimgAction) + + self.rescaleAcrossZstackAction = QAction( + "Rescale across z-stack", rescaleIntensMenu + ) + self.rescaleAcrossZstackAction.setCheckable(True) + self.rescaleAcrossZstackAction.setChecked(False) + rescaleActionGroup.addAction(self.rescaleAcrossZstackAction) + rescaleIntensMenu.addAction(self.rescaleAcrossZstackAction) + + self.rescaleAcrossTimeAction = QAction( + "Rescale across time frames", rescaleIntensMenu + ) + self.rescaleAcrossTimeAction.setCheckable(True) + self.rescaleAcrossTimeAction.setChecked(False) + rescaleActionGroup.addAction(self.rescaleAcrossTimeAction) + rescaleIntensMenu.addAction(self.rescaleAcrossTimeAction) + + self.customRescaleAction = QAction("Choose custom levels...", rescaleIntensMenu) + self.customRescaleAction.setCheckable(True) + rescaleActionGroup.addAction(self.customRescaleAction) + rescaleIntensMenu.addAction(self.customRescaleAction) + + self.doNotRescaleAction = QAction( + "Do no rescale, display raw image", rescaleIntensMenu + ) + self.doNotRescaleAction.setCheckable(True) + rescaleActionGroup.addAction(self.doNotRescaleAction) + rescaleIntensMenu.addAction(self.doNotRescaleAction) + + self.rescaleActionGroup = rescaleActionGroup + rescaleActionGroup.triggered.connect(self.rescaleActionTriggered) + + # Add custom colormap action + self.customCmapsMenu = self.gradient.menu.addMenu("Custom colormaps") + self.customCmapsMenu.aboutToShow.connect(self.onShowCustomCmapsMenu) + self.customCmapsMenu.triggered.connect(self.customCmapsMenuTriggered) + + self.saveColormapAction = QAction("Save current colormap...", self) + self.gradient.menu.addAction(self.saveColormapAction) + self.saveColormapAction.triggered.connect(self.saveColormap) + + self.addCustomGradients() + + # Set inverted gradients for invert bw action + self.addInvertedColorMaps() + + self.gradient.menu.addSeparator() + + # hide histogram tool + self.vb.hide() + + # Disable moving the axis up and down + self.axis.unlinkFromView() + + # Disable histogram default context Menu event + self.vb.raiseContextMenu = lambda x: None + + def rescaleActionTriggered(self, action): + self.sigRescaleIntes.emit(action) + + def onShowCustomCmapsMenu(self): + self.customCmapsMenu.show() + + def customCmapsMenuTriggered(self, action): + cmap = action.cmap + self.gradient.colorMapMenuClicked(cmap) + self.gradient.showTicks(True) + + def setAxisLabel(self, text): + self.labelText = text + self.axis.setLabel(text, **self.labelStyle) + + def updateAxisLabel(self): + text = self.axis.label.toPlainText() + if not text: + return + self.setAxisLabel(text) + + def setGradient(self, gradient): + self.gradient.restoreState(gradient) + self.lastGradient = gradient + + def colormapClicked(self, checked=False, name=None): + name = self.sender().name + self.lastGradientName = name + if self.isInverted: + self.setGradient(self.invertedGradients[name]) + else: + self.setGradient(Gradients[name]) + + def sortTicks(self, ticks): + sortedTicks = sorted(ticks, key=operator.itemgetter(0)) + return sortedTicks + + def getInvertedGradients(self): + invertedGradients = {} + for name, gradient in Gradients.items(): + ticks = gradient["ticks"] + sortedTicks = self.sortTicks(ticks) + if name in nonInvertibleCmaps: + invertedColors = sortedTicks + else: + invertedColors = [ + (t[0], ti[1]) for t, ti in zip(sortedTicks, sortedTicks[::-1]) + ] + invertedGradient = {} + invertedGradient["ticks"] = invertedColors + invertedGradient["mode"] = gradient["mode"] + invertedGradients[name] = invertedGradient + return invertedGradients + + def addInvertedColorMaps(self): + self.invertedGradients = self.getInvertedGradients() + for action in self.gradient.menu.actions(): + if not hasattr(action, "name"): + continue + + if action.name not in self.cmaps: + continue + + action.triggered.disconnect() + action.triggered.connect(self.colormapClicked) + + px = QPixmap(100, 15) + p = QPainter(px) + invertedGradient = self.invertedGradients[action.name] + qtGradient = QLinearGradient(QPointF(0, 0), QPointF(100, 0)) + ticks = self.sortTicks(invertedGradient["ticks"]) + qtGradient.setStops([(x, QColor(*color)) for x, color in ticks]) + brush = QBrush(qtGradient) + p.fillRect(QRect(0, 0, 100, 15), brush) + p.end() + widget = action.defaultWidget() + hbox = widget.layout() + rectLabelWidget = QLabel() + rectLabelWidget.setPixmap(px) + hbox.addWidget(rectLabelWidget) + rectLabelWidget.hide() + + def setInvertedColorMaps(self, inverted): + if inverted: + showIdx = 2 + hideIdx = 1 + self.labelStyle["color"] = "#000000" + else: + showIdx = 1 + hideIdx = 2 + self.labelStyle["color"] = "#ffffff" + + for action in self.gradient.menu.actions(): + if not hasattr(action, "name"): + continue + + if action.name not in self.cmaps: + continue + + widget = action.defaultWidget() + hbox = widget.layout() + hideCmapRect = hbox.itemAt(hideIdx).widget() + showCmapRect = hbox.itemAt(showIdx).widget() + hideCmapRect.hide() + showCmapRect.show() + + self.updateAxisLabel() + self.isInverted = inverted + + def invertGradient(self, gradient): + ticks = gradient["ticks"] + sortedTicks = self.sortTicks(ticks) + invertedColors = [ + (t[0], ti[1]) for t, ti in zip(sortedTicks, sortedTicks[::-1]) + ] + invertedGradient = {} + invertedGradient["ticks"] = invertedColors + invertedGradient["mode"] = gradient["mode"] + return invertedGradient + + def invertCurrentColormap(self, inverted, debug=False): + self.setGradient(self.invertGradient(self.lastGradient)) + + def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): + self.originalLength = self.gradient.length + self.gradient.length = 100 + if restore: + self.gradient.restoreState(gradient_ticks) + gradient = self.gradient.getGradient() + action = CustomGradientMenuAction(gradient, gradient_name, self.gradient) + # action.triggered.connect(self.gradient.contextMenuClicked) + action.delButton.clicked.connect(self.removeCustomGradient) + action.cmap = colors.pg_ticks_to_colormap(gradient_ticks["ticks"]) + # self.gradient.menu.insertAction(self.saveColormapAction, action) + self.customCmapsMenu.addAction(action) + self.gradient.length = self.originalLength + GradientsImage[gradient_name] = gradient_ticks + + def removeCustomGradient(self): + button = self.sender() + action = button.action + self.customCmapsMenu.removeAction(action) + cp = config.ConfigParser() + cp.read(custom_cmaps_filepath) + cp.remove_section(f"image.{action.name}") + with open(custom_cmaps_filepath, mode="w") as file: + cp.write(file) + + def addCustomGradients(self): + try: + CustomGradients = getCustomGradients(name="image") + if not CustomGradients: + return + for gradient_name, gradient_ticks in CustomGradients.items(): + self.addCustomGradient(gradient_name, gradient_ticks) + except Exception as e: + printl(traceback.format_exc()) + pass + + def _askNameColormap(self): + inputWin = apps.QInput(parent=self._parent, title="Colormap name") + inputWin.askText("Insert a name for the colormap: ", allowEmpty=False) + if inputWin.cancel: + return + cmapName = inputWin.answer + return cmapName + + def saveColormap(self): + cmapName = self._askNameColormap() + if cmapName is None: + return + + cp = config.ConfigParser() + if os.path.exists(custom_cmaps_filepath): + cp.read(custom_cmaps_filepath) + + SECTION = f"{self.name}.{cmapName}" + cp[SECTION] = {} + + # gradient_ticks = [] + state = self.gradient.saveState() + for key, value in state.items(): + if key != "ticks": + continue + for t, tick in enumerate(value): + pos, rgb = tick + # gradient_ticks.append((pos, rgb)) + rgb = ",".join([str(c) for c in rgb]) + val = f"{pos},{rgb}" + cp[SECTION][f"tick_{t}_pos_rgb"] = val + + with open(custom_cmaps_filepath, mode="w") as file: + cp.write(file) + + self.addCustomGradient(cmapName, state, restore=False) + + def tickColorAccepted(self): + self.gradient.currentColorAccepted() + # self.sigTickColorAccepted.emit(self.gradient.colorDialog.color().getRgb()) + + def setRescaleIntensitiesHow(self, how): + for action in self.rescaleActionGroup.actions(): + if action.text() == how: + action.setChecked(True) + return + + +class myHistogramLUTitem(baseHistogramLUTitem): + sigGradientMenuEvent = Signal(object) + sigGradientChanged = Signal(object) + sigTickColorAccepted = Signal(object) + sigAddScaleBar = Signal(bool) + sigAddTimestamp = Signal(bool) + + def __init__( + self, parent=None, name="image", axisLabel="", isViewer=False, **kwargs + ): + super().__init__(parent=parent, name=name, axisLabel=axisLabel, **kwargs) + + self.name = name + self._parent = parent + + self.childLutItem = None + + self.isViewer = isViewer + if isViewer: + # In the viewer we don't allow additional settings from the menu + return + + # Add scale bar action + self.addScaleBarAction = QAction("Add scale bar", self) + self.addScaleBarAction.setCheckable(True) + self.addScaleBarAction.triggered.connect(self.emitAddScaleBar) + self.gradient.menu.addAction(self.addScaleBarAction) + + # Add timestamp action + self.addTimestampAction = QAction("Add timestamp", self) + self.addTimestampAction.setCheckable(True) + self.addTimestampAction.triggered.connect(self.emitAddTimestamp) + self.gradient.menu.addAction(self.addTimestampAction) + + # Invert bw action + self.invertBwAction = QAction("Invert black/white", self) + self.invertBwAction.setCheckable(True) + self.gradient.menu.addAction(self.invertBwAction) + + # Font size menu action + self.fontSizeMenu = QMenu("Text font size") + self.gradient.menu.addMenu(self.fontSizeMenu) + + # Text color button + hbox = QHBoxLayout() + hbox.addWidget(QLabel("Text color: ")) + self.textColorButton = myColorButton(color=(255, 255, 255)) + hbox.addStretch(1) + hbox.addWidget(self.textColorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + act.setDefaultWidget(widget) + act.triggered.connect(self.textColorButton.click) + self.gradient.menu.addAction(act) + + # Contours line weight + contLineWeightMenu = QMenu("Contours line weight", self.gradient.menu) + self.contLineWightActionGroup = QActionGroup(self) + self.contLineWightActionGroup.setExclusionPolicy( + QActionGroup.ExclusionPolicy.Exclusive + ) + for w in range(1, 11): + action = QAction(str(w)) + action.setCheckable(True) + if w == 2: + action.setChecked(True) + action.lineWeight = w + self.contLineWightActionGroup.addAction(action) + action = contLineWeightMenu.addAction(action) + self.gradient.menu.addMenu(contLineWeightMenu) + + # Contours color button + hbox = QHBoxLayout() + hbox.addWidget(QLabel("Contours color: ")) + self.contoursColorButton = myColorButton(color=(25, 25, 25)) + hbox.addStretch(1) + hbox.addWidget(self.contoursColorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + act.setDefaultWidget(widget) + act.triggered.connect(self.contoursColorButton.click) + self.gradient.menu.addAction(act) + + # Mother-bud line weight + mothBudLineWeightMenu = QMenu("Mother-bud line weight", self.gradient.menu) + self.mothBudLineWightActionGroup = QActionGroup(self) + self.mothBudLineWightActionGroup.setExclusionPolicy( + QActionGroup.ExclusionPolicy.Exclusive + ) + for w in range(1, 11): + action = QAction(str(w)) + action.setCheckable(True) + if w == 2: + action.setChecked(True) + action.lineWeight = w + self.mothBudLineWightActionGroup.addAction(action) + action = mothBudLineWeightMenu.addAction(action) + self.gradient.menu.addMenu(mothBudLineWeightMenu) + + # Mother-bud line color + hbox = QHBoxLayout() + hbox.addWidget(QLabel("Mother-bud line color: ")) + self.mothBudLineColorButton = myColorButton(color=(255, 0, 0)) + hbox.addStretch(1) + hbox.addWidget(self.mothBudLineColorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + act.setDefaultWidget(widget) + act.triggered.connect(self.mothBudLineColorButton.click) + self.gradient.menu.addAction(act) + + self.labelsAlphaMenu = self.gradient.menu.addMenu( + "Segm. masks overlay alpha..." + ) + # self.labelsAlphaMenu.setDisabled(True) + hbox = QHBoxLayout() + self.labelsAlphaSlider = sliderWithSpinBox( + title="Alpha", title_loc="in_line", isFloat=True, normalize=True + ) + self.labelsAlphaSlider.setMaximum(100) + self.labelsAlphaSlider.setSingleStep(0.05) + self.labelsAlphaSlider.setValue(0.3) + hbox.addWidget(self.labelsAlphaSlider) + shortCutText = "Command+Up/Down" if is_mac else "Ctrl+Up/Down" + hbox.addWidget(QLabel(f"({shortCutText})")) + widget = QWidget() + widget.setLayout(hbox) + act = QWidgetAction(self) + act.setDefaultWidget(widget) + self.labelsAlphaMenu.addSeparator() + self.labelsAlphaMenu.addAction(act) + + # Default settings + self.defaultSettingsAction = QAction("Restore default settings...", self) + self.gradient.menu.addAction(self.defaultSettingsAction) + + self.filterObject = FilterObject() + self.filterObject.sigFilteredEvent.connect(self.gradientMenuEventFilter) + self.gradient.menu.installEventFilter(self.filterObject) + self.highlightedAction = None + self.lastHoveredAction = None + + def setChildLutItem(self, childLutItem): + self.childLutItem = childLutItem + + def removeAddScaleBarAction(self): + self.gradient.menu.removeAction(self.addScaleBarAction) + + def removeAddTimestampAction(self): + self.gradient.menu.removeAction(self.addTimestampAction) + + def emitAddScaleBar(self): + self.sigAddScaleBar.emit(self.addScaleBarAction.isChecked()) + + def emitAddTimestamp(self): + self.sigAddTimestamp.emit(self.addTimestampAction.isChecked()) + + def gradientChanged(self): + super().gradientChanged() + self.sigGradientChanged.emit(self) + + def gradientMenuEventFilter(self, object, event): + if event.type() == QEvent.Type.MouseMove: + hoveredAction = self.gradient.menu.actionAt(event.pos()) + isActionEntered = hoveredAction != self.lastHoveredAction + if isActionEntered: + if isinstance(hoveredAction, highlightableQWidgetAction): + # print('Entered a custom action') + pass + isActionLeft = ( + self.highlightedAction is not None + and self.highlightedAction != hoveredAction + ) + if isActionLeft: + if isinstance(self.highlightedAction, highlightableQWidgetAction): + # print('Left a custom action') + pass + self.highlightedAction = hoveredAction + + self.lastHoveredAction = hoveredAction + + def addOverlayColorButton(self, rgbColor, channelName): + # Overlay color button + hbox = QHBoxLayout() + hbox.addWidget(QLabel("Overlay color: ")) + self.overlayColorButton = myColorButton(color=rgbColor) + self.overlayColorButton.channel = channelName + hbox.addStretch(1) + hbox.addWidget(self.overlayColorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + act.setDefaultWidget(widget) + act.triggered.connect(self.overlayColorButton.click) + self.gradient.menu.addAction(act) + + def uncheckContLineWeightActions(self): + for act in self.contLineWightActionGroup.actions(): + try: + act.toggled.disconnect() + except Exception as e: + pass + act.setChecked(False) + + def uncheckMothBudLineLineWeightActions(self): + for act in self.mothBudLineWightActionGroup.actions(): + try: + act.toggled.disconnect() + except Exception as e: + pass + act.setChecked(False) + + def restoreState(self, df): + if "textIDsColor" in df.index: + rgbString = df.at["textIDsColor", "value"] + r, g, b = colors.rgb_str_to_values(rgbString) + self.textColorButton.setColor((r, g, b)) + + if "contLineColor" in df.index: + rgba_str = df.at["contLineColor", "value"] + rgb = colors.rgba_str_to_values(rgba_str)[:3] + self.contoursColorButton.setColor(rgb) + + if "contLineWeight" in df.index: + w = df.at["contLineWeight", "value"] + w = int(w) + for action in self.contLineWightActionGroup.actions(): + if action.lineWeight == w: + action.setChecked(True) + break + + if "mothBudLineWeight" in df.index: + w = df.at["mothBudLineWeight", "value"] + w = int(w) + for action in self.mothBudLineWightActionGroup.actions(): + if action.lineWeight == w: + action.setChecked(True) + break + + if "overlaySegmMasksAlpha" in df.index: + alpha = df.at["overlaySegmMasksAlpha", "value"] + self.labelsAlphaSlider.setValue(float(alpha)) + + if "mothBudLineColor" in df.index: + rgba_str = df.at["mothBudLineColor", "value"] + rgb = colors.rgba_str_to_values(rgba_str)[:3] + self.mothBudLineColorButton.setColor(rgb) + + checked = df.at["is_bw_inverted", "value"] == "Yes" + self.invertBwAction.setChecked(checked) + + self.restoreColormap(df) + + def saveState(self, df): + # remove previous state + df = df[~df.index.str.contains("img_cmap")].copy() + + state = self.gradient.saveState() + for key, value in state.items(): + if key == "ticks": + for t, tick in enumerate(value): + pos, rgb = tick + df.at[f"img_cmap_tick{t}_rgb", "value"] = rgb + df.at[f"img_cmap_tick{t}_pos", "value"] = pos + else: + if isinstance(value, bool): + value = "Yes" if value else "No" + df.at[f"img_cmap_{key}", "value"] = value + return df + + def restoreColormap(self, df): + state = {"mode": "rgb", "ticksVisible": True, "ticks": []} + ticks_pos = {} + ticks_rgb = {} + stateFound = False + for setting, value in df.itertuples(): + idx = setting.find("img_cmap_") + if idx == -1: + continue + + stateFound = True + m = re.findall(r"tick(\d+)_(\w+)", setting) + if m: + tick_idx, tick_type = m[0] + if tick_type == "pos": + ticks_pos[int(tick_idx)] = float(value) + elif tick_type == "rgb": + ticks_rgb[int(tick_idx)] = colors.rgba_str_to_values(value) + else: + key = setting[9:] + if value == "Yes": + value = True + elif value == "No": + value = False + state[key] = value + + if stateFound: + ticks = [(0, 0)] * len(ticks_pos) + for idx, val in ticks_pos.items(): + pos = val + rgb = ticks_rgb[idx] + ticks[idx] = (pos, rgb) + + state["ticks"] = ticks + self.gradient.restoreState(state) + + def regionChanged(self): + super().regionChanged() + if self.childLutItem is None: + return + + imageItem = self.imageItem() + try: + mn, mx = imageItem.quickMinMax(targetSize=65536) + # mn and mx can still be NaN if the data is all-NaN + if mn == mx or imageItem._xp.isnan(mn) or imageItem._xp.isnan(mx): + mn = 0 + mx = 255 + except AttributeError as err: + mn, mx = self.getLevels() + + self.childLutItem.setLevels(min=mn, max=mx) + + +class myColorButton(pg.ColorButton): + def __init__(self, parent=None, color=(128, 128, 128), padding=5): + super().__init__(parent=parent, color=color) + if isinstance(padding, (int, float)): + self.padding = (padding, padding, -padding, -padding) + else: + self.padding = padding + self._c = 225 + self._hoverDeltaC = 30 + self._alpha = 100 + self._bkgrColor = QColor(self._c, self._c, self._c, self._alpha) + self._borderColor = QColor(171, 171, 171) + self._rectBorderPen = QPen(QBrush(QColor(0, 0, 0)), 0.3) + + def paintEvent(self, event): + # QPushButton.paintEvent(self, ev) + p = QStylePainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = self.rect() + p.setBrush(QBrush(self._bkgrColor)) + p.setPen(QPen(self._borderColor)) + p.drawRoundedRect(rect, 5, 5) + # p.fillRect(self.rect(), self._bkgrColor) + rect = self.rect().adjusted(*self.padding) + ## draw white base, then texture for indicating transparency, then actual color + p.setBrush(pg.mkBrush("w")) + p.drawRect(rect) + p.setBrush(QBrush(Qt.BrushStyle.DiagCrossPattern)) + p.drawRect(rect) + p.setPen(self._rectBorderPen) + p.setBrush(pg.mkBrush(self._color)) + p.drawRect(rect) + p.end() + + def enterEvent(self, event): + c = self._c + self._hoverDeltaC + self._bkgrColor = QColor(c, c, c, self._alpha) + self.update() + + def leaveEvent(self, event): + c = self._c + self._bkgrColor = QColor(c, c, c, self._alpha) + self.update() + + +class overlayLabelsGradientWidget(pg.GradientWidget): + def __init__( + self, + imageItem, + selectActionGroup, + segmEndname, + parent=None, + orientation="right", + ): + pg.GradientWidget.__init__(self, parent=parent, orientation=orientation) + + self.imageItem = imageItem + self.selectActionGroup = selectActionGroup + + for action in self.menu.actions(): + if action.text() == "HSV": + HSV_action = action + elif action.text() == "RGB": + RGB_ation = action + self.menu.removeAction(HSV_action) + self.menu.removeAction(RGB_ation) + + # Shuffle colors action + self.shuffleCmapAction = QAction("Randomly shuffle colormap (Shift+S)", self) + self.menu.addAction(self.shuffleCmapAction) + + # Drawing mode + drawModeMenu = QMenu("Drawing mode", self) + self.drawModeActionGroup = QActionGroup(self) + contoursDrawModeAction = QAction("Draw contours", drawModeMenu) + contoursDrawModeAction.setCheckable(True) + contoursDrawModeAction.setChecked(True) + contoursDrawModeAction.segmEndname = segmEndname + self.drawModeActionGroup.addAction(contoursDrawModeAction) + drawModeMenu.addAction(contoursDrawModeAction) + olDrawModeAction = QAction("Overlay labels", drawModeMenu) + olDrawModeAction.setCheckable(True) + olDrawModeAction.segmEndname = segmEndname + self.drawModeActionGroup.addAction(olDrawModeAction) + drawModeMenu.addAction(olDrawModeAction) + self.menu.addMenu(drawModeMenu) + + self.labelsAlphaMenu = self.menu.addMenu("Overlay labels alpha...") + hbox = QHBoxLayout() + self.labelsAlphaSlider = sliderWithSpinBox( + title="Alpha", title_loc="in_line", isFloat=True, normalize=True + ) + self.labelsAlphaSlider.setMaximum(100) + self.labelsAlphaSlider.setSingleStep(0.05) + self.labelsAlphaSlider.setValue(0.3) + hbox.addWidget(self.labelsAlphaSlider) + widget = QWidget() + widget.setLayout(hbox) + act = QWidgetAction(self) + act.setDefaultWidget(widget) + self.labelsAlphaMenu.addSeparator() + self.labelsAlphaMenu.addAction(act) + + self.menu.addSeparator() + self.menu.addSection("Select segm. file to adjust:") + for action in selectActionGroup.actions(): + self.menu.addAction(action) + + self.item.loadPreset("viridis") + self.updateImageLut(None) + self.updateImageOpacity(0.3) + + # Connect events + self.sigGradientChangeFinished.connect(self.updateImageLut) + self.labelsAlphaSlider.valueChanged.connect(self.updateImageOpacity) + self.shuffleCmapAction.triggered.connect(self.shuffleCmap) + + def shuffleCmap(self): + lut = self.imageItem.lut + np.random.shuffle(lut) + lut[0] = [0, 0, 0, 0] + self.imageItem.setLookupTable(lut) + self.imageItem.update() + + def updateImageLut(self, gradientItem): + lut = np.zeros((255, 4), dtype=np.uint8) + lut[:, -1] = 255 + lut[:, :-1] = self.item.colorMap().getLookupTable(0, 1, 255) + np.random.shuffle(lut) + lut[0] = [0, 0, 0, 0] + self.imageItem.setLookupTable(lut) + self.imageItem.setLevels([0, 255]) + + def updateImageOpacity(self, value): + self.imageItem.setOpacity(value) + + +class labelsGradientWidget(pg.GradientWidget): + sigShowRightImgToggled = Signal(bool) + sigShowLabelsImgToggled = Signal(bool) + sigShowNextFrameToggled = Signal(bool) + + def __init__(self, *args, parent=None, orientation="right", **kargs): + pg.GradientEditorItem = BaseGradientEditorItemLabels + + pg.GradientWidget.__init__( + self, *args, parent=parent, orientation=orientation, **kargs + ) + + self._parent = parent + self.name = "labels" + + for action in self.menu.actions(): + if action.text() == "HSV": + HSV_action = action + elif action.text() == "RGB": + RGB_ation = action + self.menu.removeAction(HSV_action) + self.menu.removeAction(RGB_ation) + + # Add custom colormap action + self.customCmapsMenu = self.menu.addMenu("Custom colormaps") + self.customCmapsMenu.aboutToShow.connect(self.onShowCustomCmapsMenu) + self.customCmapsMenu.triggered.connect(self.customCmapsMenuTriggered) + + self.saveColormapAction = QAction("Save current colormap...", self) + self.menu.addAction(self.saveColormapAction) + self.saveColormapAction.triggered.connect(self.saveColormap) + + self.addCustomGradients() + + # Background color button + hbox = QHBoxLayout() + hbox.addWidget(QLabel("Background color: ")) + self.colorButton = myColorButton(color=(25, 25, 25)) + hbox.addStretch(1) + hbox.addWidget(self.colorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + act.setDefaultWidget(widget) + act.triggered.connect(self.colorButton.click) + self.menu.addAction(act) + + # Font size menu action + self.fontSizeMenu = QMenu("Text font size", self) + self.menu.addMenu(self.fontSizeMenu) + + # IDs color button + hbox = QHBoxLayout() + hbox.addWidget(QLabel("Text color: ")) + self.textColorButton = myColorButton(color=(25, 25, 25)) + hbox.addStretch(1) + hbox.addWidget(self.textColorButton) + widget = QWidget() + widget.setLayout(hbox) + act = highlightableQWidgetAction(self) + act.setDefaultWidget(widget) + act.triggered.connect(self.textColorButton.click) + self.menu.addAction(act) + self.menu.addSeparator() + + # Shuffle colors action + self.shuffleCmapAction = QAction("Randomly shuffle colormap (Shift+S)", self) + self.menu.addAction(self.shuffleCmapAction) + + self.greedyShuffleCmapAction = QAction( + "Greedily shuffle colormap (Alt+Shift+S)", self + ) + self.menu.addAction(self.greedyShuffleCmapAction) + + self.permanentGreedyCmapAction = QAction("Always use greedy colormap", self) + self.permanentGreedyCmapAction.setCheckable(True) + self.menu.addAction(self.permanentGreedyCmapAction) + + # Invert bw action + self.invertBwAction = QAction("Invert black/white", self) + self.invertBwAction.setCheckable(True) + self.menu.addAction(self.invertBwAction) + + # Show labels action + self.showLabelsImgAction = QAction("Show segmentation image", self) + self.showLabelsImgAction.setCheckable(True) + self.menu.addAction(self.showLabelsImgAction) + + # Show right image action + self.showRightImgAction = QAction("Show duplicated left image", self) + self.showRightImgAction.setCheckable(True) + self.menu.addAction(self.showRightImgAction) + + # Show next frame action + self.showNextFrameAction = QAction("Show next frame", self) + self.showNextFrameAction.setCheckable(True) + self.menu.addAction(self.showNextFrameAction) + + # Default settings + self.defaultSettingsAction = QAction("Restore default settings...", self) + self.menu.addAction(self.defaultSettingsAction) + + self.menu.addSeparator() + + self.showRightImgAction.toggled.connect(self.showRightImageToggled) + self.showLabelsImgAction.toggled.connect(self.showLabelsImageToggled) + self.showNextFrameAction.toggled.connect(self.showNextFrameToggled) + + def onShowCustomCmapsMenu(self): + self.customCmapsMenu.show() + + def customCmapsMenuTriggered(self, action): + cmap = action.cmap + self.item.colorMapMenuClicked(cmap) + self.item.showTicks(True) + + def addCustomGradient(self, gradient_name, gradient_ticks, restore=True): + currentState = self.item.saveState() + self.originalLength = self.item.length + self.item.length = 100 + if restore: + self.item.restoreState(gradient_ticks) + gradient = self.item.getGradient() + action = CustomGradientMenuAction(gradient, gradient_name, self.item) + # action.triggered.connect(self.item.contextMenuClicked) + action.delButton.clicked.connect(self.removeCustomGradient) + action.cmap = colors.pg_ticks_to_colormap(gradient_ticks["ticks"]) + # self.item.menu.insertAction(self.saveColormapAction, action) + self.customCmapsMenu.addAction(action) + self.item.length = self.originalLength + self.item.restoreState(currentState) + GradientsLabels[gradient_name] = gradient_ticks + + def removeCustomGradient(self): + button = self.sender() + action = button.action + self.customCmapsMenu.removeAction(action) + cp = config.ConfigParser() + cp.read(custom_cmaps_filepath) + cp.remove_section(f"labels.{action.name}") + with open(custom_cmaps_filepath, mode="w") as file: + cp.write(file) + + def addCustomGradients(self): + try: + CustomGradients = getCustomGradients(name="labels") + if not CustomGradients: + return + for gradient_name, gradient_ticks in CustomGradients.items(): + self.addCustomGradient(gradient_name, gradient_ticks) + except Exception as e: + printl(traceback.format_exc()) + pass + + def _askNameColormap(self): + inputWin = apps.QInput(parent=self._parent, title="Colormap name") + inputWin.askText("Insert a name for the colormap: ", allowEmpty=False) + if inputWin.cancel: + return + cmapName = inputWin.answer + return cmapName + + def saveColormap(self): + cmapName = self._askNameColormap() + if cmapName is None: + return + + cp = config.ConfigParser() + if os.path.exists(custom_cmaps_filepath): + cp.read(custom_cmaps_filepath) + + SECTION = f"{self.name}.{cmapName}" + cp[SECTION] = {} + + state = self.item.saveState() + for key, value in state.items(): + if key != "ticks": + continue + for t, tick in enumerate(value): + pos, rgb = tick + rgb = ",".join([str(c) for c in rgb]) + val = f"{pos},{rgb}" + cp[SECTION][f"tick_{t}_pos_rgb"] = val + + with open(custom_cmaps_filepath, mode="w") as file: + cp.write(file) + + self.addCustomGradient(cmapName, state, restore=False) + + def isRightImageVisible(self): + return ( + self.showLabelsImgAction.isChecked() or self.showNextFrameAction.isChecked() + ) + + def showRightImageToggled(self, checked): + if checked and self.isRightImageVisible(): + # Hide the right labels image before showing right image + self.showLabelsImgAction.setChecked(False) + self.showNextFrameAction.setChecked(False) + self.sigShowLabelsImgToggled.emit(False) + self.sigShowNextFrameToggled.emit(checked) + self.sigShowRightImgToggled.emit(checked) + + def showLabelsImageToggled(self, checked): + if checked and self.isRightImageVisible(): + # Hide the right image before showing labels image + self.showRightImgAction.setChecked(False) + self.showNextFrameAction.setChecked(False) + self.sigShowRightImgToggled.emit(False) + self.sigShowNextFrameToggled.emit(False) + self.sigShowLabelsImgToggled.emit(checked) + + def showNextFrameToggled(self, checked): + if checked and self.isRightImageVisible(): + # Hide the right image before showing labels image + self.showRightImgAction.setChecked(False) + self.showLabelsImgAction.setChecked(False) + self.sigShowRightImgToggled.emit(False) + self.sigShowLabelsImgToggled.emit(False) + self.sigShowNextFrameToggled.emit(checked) + + def saveState(self, df): + # remove previous state + df = df[~df.index.str.contains("lab_cmap")].copy() + + state = self.item.saveState() + for key, value in state.items(): + if key == "ticks": + for t, tick in enumerate(value): + pos, rgb = tick + df.at[f"lab_cmap_tick{t}_rgb", "value"] = rgb + df.at[f"lab_cmap_tick{t}_pos", "value"] = pos + else: + if isinstance(value, bool): + value = "Yes" if value else "No" + df.at[f"lab_cmap_{key}", "value"] = value + return df + + def restoreState(self, df, loadCmap=True): + # Insert background color + if "labels_bkgrColor" in df.index: + rgbString = df.at["labels_bkgrColor", "value"] + r, g, b = colors.rgb_str_to_values(rgbString) + self.colorButton.setColor((r, g, b)) + + if "labels_text_color" in df.index: + rgbString = df.at["labels_text_color", "value"] + r, g, b = colors.rgb_str_to_values(rgbString) + self.textColorButton.setColor((r, g, b)) + else: + self.textColorButton.setColor((255, 0, 0)) + + checked = df.at["is_bw_inverted", "value"] == "Yes" + self.invertBwAction.setChecked(checked) + + if not loadCmap: + return + + state = {"mode": "rgb", "ticksVisible": True, "ticks": []} + ticks_pos = {} + ticks_rgb = {} + stateFound = False + for setting, value in df.itertuples(): + idx = setting.find("lab_cmap_") + if idx == -1: + continue + + stateFound = True + m = re.findall(r"tick(\d+)_(\w+)", setting) + if m: + tick_idx, tick_type = m[0] + if tick_type == "pos": + ticks_pos[int(tick_idx)] = float(value) + elif tick_type == "rgb": + ticks_rgb[int(tick_idx)] = colors.rgba_str_to_values(value) + else: + key = setting[9:] + if value == "Yes": + value = True + elif value == "No": + value = False + state[key] = value + + if stateFound: + ticks = [(0, 0)] * len(ticks_pos) + for idx, val in ticks_pos.items(): + pos = val + rgb = ticks_rgb[idx] + ticks[idx] = (pos, rgb) + + state["ticks"] = ticks + self.item.restoreState(state) + else: + self.item.loadPreset("viridis") + + return stateFound + + def showMenu(self, ev): + try: + # Convert QPointF to QPoint + self.menu.popup(ev.screenPos().toPoint()) + except AttributeError: + self.menu.popup(ev.screenPos()) + +# Cross-module imports (deferred to avoid import cycles) +from .scrollbars import ( + sliderWithSpinBox, +) +from ..controls.inputs import ( + highlightableQWidgetAction, +) + diff --git a/cellacdc/widgets/canvas/images.py b/cellacdc/widgets/canvas/images.py new file mode 100644 index 000000000..34ef94680 --- /dev/null +++ b/cellacdc/widgets/canvas/images.py @@ -0,0 +1,796 @@ +"""Canvas widgets: images.""" + +"""GUI widgets: canvas.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +class BaseImageItem(pg.ImageItem): + def __init__(self, image=None, **kargs): + self.minMaxValuesMapper = None + self.minMaxValuesMapperPreproc = None + self.minMaxValuesMapperCombined = None + self.minMaxValuesMapperEqualized = None + self.pos_i = 0 + self.z = 0 + self.frame_i = 0 + self.usePreprocessed = False + self.useEqualized = False + self.useCombined = False + self._isRgba = False + + super().__init__(image, **kargs) + self.autoLevelsEnabled = None + + def isRgba(self): + return self._isRgba + + def setEnableAutoLevels(self, enabled: bool): + self.autoLevelsEnabled = enabled + + def setImage(self, image=None, autoLevels=None, **kargs): + if autoLevels is None: + autoLevels = self.autoLevelsEnabled + + if image is not None and image.ndim == 3 and image.shape[2] in (3, 4): + self._isRgba = True + + super().setImage(image, autoLevels=autoLevels, **kargs) + + def preComputedMinMaxValues(self, data: List["load.loadData"]): + self.minMaxValuesMapper = {} + for pos_i, posData in enumerate(data): + img_data = posData.img_data + requires_time_dim = posData.img_data.ndim == 2 or ( + posData.img_data.ndim == 3 and posData.SizeZ > 1 + ) + if requires_time_dim: + img_data = (img_data,) + + for frame_i, image in enumerate(img_data): + if image.ndim == 3: + self._updateMinMaxValuesProjections( + image, pos_i, frame_i, self.minMaxValuesMapper + ) + + if image.ndim == 2: + image = (image,) + + for z, img in enumerate(image): + self.minMaxValuesMapper[(pos_i, frame_i, z)] = ( + np.nanmin(img), + np.nanmax(img), + ) + + def updateMinMaxValuesEqualizedData( + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + z_slice: Union[int, str], + ): + if self.minMaxValuesMapperEqualized is None: + self.minMaxValuesMapperEqualized = {} + + posData = data[pos_i] + img = posData.equalized_img_data[frame_i][z_slice] + key = (pos_i, frame_i, z_slice) + self.minMaxValuesMapperEqualized[key] = (np.nanmin(img), np.nanmax(img)) + + def updateMinMaxValuesEqualizedDataProjections( + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + ): + posData = data[pos_i] + eq_zstack = posData.equalized_img_data[frame_i] + + self._updateMinMaxValuesProjections( + eq_zstack, pos_i, frame_i, self.minMaxValuesMapperEqualized + ) + + def _updateMinMaxValuesProjections(self, zstack, pos_i, frame_i, mapper): + max_proj = zstack.max(axis=0) + key = (pos_i, frame_i, "max z-projection") + mapper[key] = np.nanmin(max_proj), np.nanmax(max_proj) + + mean_proj = zstack.mean(axis=0) + key = (pos_i, frame_i, "mean z-projection") + mapper[key] = np.nanmin(mean_proj), np.nanmax(mean_proj) + + median_proj = np.median(zstack, axis=0) + key = (pos_i, frame_i, "median z-proj.") + mapper[key] = np.nanmin(median_proj), np.nanmax(median_proj) + + def updateMinMaxValuesPreprocessedData( + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + z_slice: Union[int, str], + ): + if self.minMaxValuesMapperPreproc is None: + self.minMaxValuesMapperPreproc = {} + + posData = data[pos_i] + img = posData.preproc_img_data[frame_i][z_slice] + key = (pos_i, frame_i, z_slice) + self.minMaxValuesMapperPreproc[key] = (np.nanmin(img), np.nanmax(img)) + + def updateMinMaxValuesPreprocessedProjections( + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + ): + posData = data[pos_i] + zstack = posData.preproc_img_data[frame_i] + + self._updateMinMaxValuesProjections( + zstack, pos_i, frame_i, self.minMaxValuesMapperPreproc + ) + + def updateMinMaxValuesCombinedData( + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + z_slice: Union[int, str], + ): + if self.minMaxValuesMapperCombined is None: + self.minMaxValuesMapperCombined = {} + + posData = data[pos_i] + img = posData.combine_img_data[frame_i][z_slice] + key = (pos_i, frame_i, z_slice) + self.minMaxValuesMapperCombined[key] = (np.nanmin(img), np.nanmax(img)) + + def updateMinMaxValuesCombinedDataProjections( + self, + data: List["load.loadData"], + pos_i: int, + frame_i: int, + ): + posData = data[pos_i] + zstack = posData.combine_img_data[frame_i] + + self._updateMinMaxValuesProjections( + zstack, pos_i, frame_i, self.minMaxValuesMapperCombined + ) + + def setCurrentPosIndex(self, pos_i: int): + self.pos_i = pos_i + + def setCurrentFrameIndex(self, frame_i: int): + self.frame_i = frame_i + + def setCurrentZsliceIndex(self, z: int): + self.z = z + + def quickMinMax(self, targetSize=1e6): + if self.isRgba(): + return super().quickMinMax(targetSize=targetSize) + + if self.usePreprocessed and self.minMaxValuesMapperPreproc is not None: + minMaxValuesMapper = self.minMaxValuesMapperPreproc + elif self.useCombined and self.minMaxValuesMapperCombined is not None: + minMaxValuesMapper = self.minMaxValuesMapperCombined + elif self.useEqualized and self.minMaxValuesMapperEqualized is not None: + minMaxValuesMapper = self.minMaxValuesMapperEqualized + else: + minMaxValuesMapper = self.minMaxValuesMapper + + if minMaxValuesMapper is None: + return super().quickMinMax(targetSize=targetSize) + + try: + key = (self.pos_i, self.frame_i, self.z) + levels = minMaxValuesMapper[key] + return levels + except Exception as err: + pass + + try: + key = (self.pos_i, self.frame_i, self.z) + levels = self.minMaxValuesMapper[key] + return levels + except Exception as err: + return super().quickMinMax(targetSize=targetSize) + + def setOpacity(self, value, **kwargs): + if value == 0: + value = 0.001 + + if value == 1: + value = 0.999 + + super().setOpacity(value) + + +class BaseLabelsImageItem(pg.ImageItem): + def __init__(self, image=None, **kargs): + super().__init__(image, **kargs) + + def setImage(self, image=None, **kwargs): + if image is None: + return + autoLevels = kwargs.get("autoLevels") + if autoLevels is None: + kwargs["autoLevels"] = False + super().setImage(image, **kwargs) + + +class OverlayImageItem(pg.ImageItem): + def __init__(self, image=None, **kargs): + super().__init__(image, **kargs) + self.autoLevelsEnabled = None + + def setEnableAutoLevels(self, enabled: bool): + self.autoLevelsEnabled = enabled + + def setImage(self, image=None, autoLevels=None, **kargs): + if autoLevels is None: + autoLevels = self.autoLevelsEnabled + + super().setImage(image, autoLevels=autoLevels, **kargs) + + def setOpacity(self, value, **kwargs): + if value == 0: + value = 0.001 + + if value == 1: + value = 0.999 + + super().setOpacity(value) + + +class ParentImageItem(BaseImageItem): + def __init__( + self, + image=None, + linkedImageItem=None, + activatingActions=None, + debug=False, + **kargs, + ): + super().__init__(image, **kargs) + self.linkedImageItem = linkedImageItem + self.activatingActions = activatingActions + self.debug = debug + self._forceDoNotUpdateLinked = False + self.autoLevelsEnabled = None + + def clear(self): + if self.linkedImageItem is not None: + self.linkedImageItem.clear() + return super().clear() + + def isLinkedImageItemActive(self): + if self._forceDoNotUpdateLinked: + return False + + if self.linkedImageItem is None: + return False + + if self.activatingActions is None: + return False + + for action in self.activatingActions: + if action.isChecked(): + return True + + return False + + def setEnableAutoLevels(self, enabled: bool): + self.autoLevelsEnabled = enabled + + def setUsePreprocessed(self, usePreprocessed): + self.usePreprocessed = usePreprocessed + if self.linkedImageItem is None: + return + + self.linkedImageItem.usePreprocessed = usePreprocessed + + def setUseCombined(self, useCombined): + self.useCombined = useCombined + if self.linkedImageItem is None: + return + + self.linkedImageItem.useCombined = useCombined + + def preComputedMinMaxValues(self, *args, **kwargs): + super().preComputedMinMaxValues(*args, **kwargs) + if self.linkedImageItem is None: + return + + self.linkedImageItem.minMaxValuesMapper = self.minMaxValuesMapper + + def updateMinMaxValuesPreprocessedData(self, *args, **kwargs): + super().updateMinMaxValuesPreprocessedData(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.minMaxValuesMapper = self.minMaxValuesMapper + + def updateMinMaxValuesCombinedData(self, *args, **kwargs): + super().updateMinMaxValuesCombinedData(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.minMaxValuesMapperCombined = ( + self.minMaxValuesMapperCombined + ) + + def updateMinMaxValuesCombinedDataProjections(self, *args, **kwargs): + super().updateMinMaxValuesCombinedDataProjections(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.minMaxValuesMapperCombined = ( + self.minMaxValuesMapperCombined + ) + + def updateMinMaxValuesEqualizedDataProjections(self, *args, **kwargs): + super().updateMinMaxValuesEqualizedDataProjections(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.minMaxValuesMapperEqualized = ( + self.minMaxValuesMapperEqualized + ) + + def updateMinMaxValuesEqualizedData(self, *args, **kwargs): + super().updateMinMaxValuesEqualizedData(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.minMaxValuesMapperEqualized = ( + self.minMaxValuesMapperEqualized + ) + + def setCurrentPosIndex(self, *args, **kwargs): + super().setCurrentPosIndex(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.pos_i = self.pos_i + + def setCurrentFrameIndex(self, *args, **kwargs): + super().setCurrentFrameIndex(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.frame_i = self.frame_i + 1 + + def setCurrentZsliceIndex(self, *args, **kwargs): + super().setCurrentZsliceIndex(*args, **kwargs) + + if self.linkedImageItem is None: + return + + self.linkedImageItem.z = self.z + + def setImage( + self, + image=None, + autoLevels=None, + next_frame_image=None, + scrollbar_value=None, + force_set_linked=False, + **kargs, + ): + if autoLevels is None: + autoLevels = self.autoLevelsEnabled + + super().setImage(image, autoLevels=autoLevels, **kargs) + + if self.linkedImageItem is None: + return + + if not self.isLinkedImageItemActive() and not force_set_linked: + return + + if next_frame_image is not None: + self.linkedImageItem.setImage( + next_frame_image, scrollbar_value=scrollbar_value, autoLevels=autoLevels + ) + elif image is not None: + self.linkedImageItem.setImage(image) + + def updateImage(self, *args, **kargs): + if self.isLinkedImageItemActive(): + self.linkedImageItem.image = self.image + self.linkedImageItem.updateImage(*args, **kargs) + return super().updateImage(*args, **kargs) + + def setOpacity(self, value, applyToLinked=True): + super().setOpacity(value) + if not applyToLinked: + return + + if self.linkedImageItem is None: + return + + self.linkedImageItem.setOpacity(value) + + def setLookupTable(self, lut): + super().setLookupTable(lut) + + +class ChildImageItem(BaseImageItem): + def __init__(self, *args, linkedScrollbar=None, **kwargs): + BaseImageItem.__init__(self, *args, **kwargs) + self.linkedScrollbar = linkedScrollbar + + def setImage(self, img=None, z=None, scrollbar_value=None, **kargs): + autoLevels = kargs.get("autoLevels") + if autoLevels is None: + kargs["autoLevels"] = False + + if img is None: + BaseImageItem.setImage(self, img, **kargs) + return + + if img.ndim == 3 and img.shape[-1] > 4 and z is not None: + BaseImageItem.setImage(self, img[z], **kargs) + else: + BaseImageItem.setImage(self, img, **kargs) + + if self.linkedScrollbar is None: + return + + if not self.linkedScrollbar.isEnabled(): + return + + if scrollbar_value is None: + return + + self.linkedScrollbar.setValueNoSignal(scrollbar_value) + + +class labImageItem(pg.ImageItem): + def __init__(self, *args, **kwargs): + pg.ImageItem.__init__(self, *args, **kwargs) + + def setImage(self, img=None, z=None, **kargs): + autoLevels = kargs.get("autoLevels") + if autoLevels is None: + kargs["autoLevels"] = False + + if img is None: + pg.ImageItem.setImage(self, img, **kargs) + return + + if img.ndim == 3 and img.shape[-1] > 4 and z is not None: + pg.ImageItem.setImage(self, img[z], **kargs) + else: + pg.ImageItem.setImage(self, img, **kargs) + + +class GhostMaskItem(pg.ImageItem): + def __init__(self, ParentPlotItem): + super().__init__() + self.label = myLabelItem() + self.label.setAttr("bold", True) + self.label.setAttr("color", (245, 184, 0)) + self._ParentPlotItem = ParentPlotItem + + def initImage(self, imgShape): + image = np.zeros(imgShape, dtype=np.uint32) + self.setImage(image) + + def initLookupTable(self, rgbaColor): + lut = np.zeros((2, 4), dtype=np.uint8) + lut[1, -1] = 255 + lut[1, :-1] = rgbaColor + self.setLookupTable(lut) + + def addToPlotItem(self): + self._ParentPlotItem.addItem(self) + self._ParentPlotItem.addItem(self.label) + + def removeFromPlotItem(self): + self._ParentPlotItem.removeItem(self.label) + self._ParentPlotItem.removeItem(self) + + def updateGhostImage(self, ID=0, y_cursor=None, x_cursor=None, fontSize=None): + self.setImage(self.image) + + if ID == 0: + self.label.setText("") + return + + self.label.setText(f"{ID}", size=fontSize) + w, h = self.label.itemRect().width(), self.label.itemRect().height() + self.label.item.setPos(x_cursor, y_cursor - h) + + def clear(self): + if hasattr(self, "label"): + self.label.setText("") + if self.image is None: + return + self.image[:] = 0 + self.setImage(self.image) + + +class _ImShowImageItem(pg.ImageItem): + sigDataHover = Signal(str) + sigHoverEvent = Signal(object, object) + sigMousePressEvent = Signal(object, object) + + def __init__(self, idx) -> None: + super().__init__() + self._idx = idx + self._cursors = [] + self._autoLevels = True + + def _getHoverImageValue(self, xdata, ydata): + try: + value = self.image[ydata, xdata] + return value + except Exception as err: + return + + def setAutoLevels(self, autoLevels): + self._autoLevels = autoLevels + + def mousePressEvent(self, event): + self.sigMousePressEvent.emit(self, event) + super().mousePressEvent(event) + + def setOtherImagesCursors(self, cursors): + self._cursors = cursors + + def clearCursors(self): + for p, cursor in enumerate(self._cursors): + if p == self._idx: + continue + + cursor.setData([], []) + + def setImage(self, *args, **kwargs): + if "autoLevels" not in kwargs: + kwargs["autoLevels"] = self._autoLevels + + super().setImage(*args, **kwargs) + if not args: + return + + if not kwargs["autoLevels"]: + return + + image = args[0] + self._imageMax = image.max() + self._imageMin = image.min() + self._numLevels = self._imageMax - self._imageMin + + def hoverEvent(self, event): + self.sigHoverEvent.emit(self, event) + + if event.isExit(): + self.clearCursors() + self.sigDataHover.emit("") + return + + x, y = event.pos() + xdata, ydata = int(x), int(y) + value = self._getHoverImageValue(xdata, ydata) + if value is None: + self.clearCursors() + self.sigDataHover.emit("") + return + + try: + self.sigDataHover.emit(f"x={xdata}, y={ydata}, {value = :.4f}") + except Exception as e: + self.sigDataHover.emit(f"x={xdata}, y={ydata}, {[val for val in value]}") + + for p, cursor in enumerate(self._cursors): + if p == self._idx: + continue + + cursor.setData([x], [y]) + +# Cross-module imports (deferred to avoid import cycles) +from .plot_items import ( + myLabelItem, +) + diff --git a/cellacdc/widgets/canvas/imshow.py b/cellacdc/widgets/canvas/imshow.py new file mode 100644 index 000000000..f975a8bed --- /dev/null +++ b/cellacdc/widgets/canvas/imshow.py @@ -0,0 +1,1075 @@ +"""Canvas widgets: imshow.""" + +"""GUI widgets: canvas.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +class ImShowPlotItem(pg.PlotItem): + def __init__( + self, + parent=None, + name=None, + labels=None, + title=None, + viewBox=None, + axisItems=None, + enableMenu=True, + **kargs, + ): + super().__init__( + parent, name, labels, title, viewBox, axisItems, enableMenu, **kargs + ) + # Overwrite zoom out button behaviour to disable autoRange after + # clicking it. + # If autorange is enabled, it is called everytime the brush or eraser + # scatter plot items touches the border causing flickering + self.disableAutoRange() + self.autoBtn.mode = "manual" + self.invertY(True) + self.setAspectLocked(True) + self.addImageItem(kargs.get("imageItem")) + + self._selected = False + self.selectingRects = [] + + def setSelectableTitle(self, title: QGraphicsProxyWidget, **kwargs): + self.layout.removeItem(self.titleLabel) + self.layout.addItem(title, 0, 1, alignment=Qt.AlignCenter) + + def isSelected(self): + return self._selected + + def setSelected( + self, selected: bool, xlim=(-np.inf, np.inf), ylim=(-np.inf, np.inf) + ): + if selected == self._selected: + return + + if selected: + ((xmin, xmax), (ymin, ymax)) = self.viewRange() + ylim_min, ylim_max = ylim + xlim_min, xlim_max = xlim + + xmin = max(xlim_min, xmin) + xmax = min(xlim_max, xmax) + ymin = max(ylim_min, ymin) + ymax = min(ylim_max, ymax) + + w = xmax - xmin + h = ymax - ymin + + bs = round(((w + h) / 2) * 0.02) + if bs < 1: + bs = 1 + + rect_left = RectItem(QRectF(xmin, ymin, bs, h)) + rect_top = RectItem(QRectF(xmin + bs, ymin, w - bs - bs, bs)) + rect_right = RectItem(QRectF(xmax - bs, ymin, bs, h)) + rect_bottom = RectItem(QRectF(xmin + bs, ymax - bs, w - bs - bs, bs)) + self.selectingRects.append(rect_left) + self.selectingRects.append(rect_top) + self.selectingRects.append(rect_right) + self.selectingRects.append(rect_bottom) + + self.addItem(rect_left) + self.addItem(rect_top) + self.addItem(rect_right) + self.addItem(rect_bottom) + else: + for rect in self.selectingRects: + self.removeItem(rect) + self.selectingRects = [] + + self._selected = selected + + def addImageItem(self, imageItem): + self.imageItem = imageItem + if imageItem is None: + return + + self.setupContextMenu() + self.addItem(imageItem) + + def setupContextMenu(self): + shuffleCmapAction = QAction("Shuffle colormap", self.vb.menu) + shuffleCmapAction.triggered.connect(self.shuffleColormap) + self.vb.menu.addAction(shuffleCmapAction) + + self.resetCmapAction = QAction("Reset colormap", self.vb.menu) + self.resetCmapAction.triggered.connect(self.resetColormap) + self.vb.menu.addAction(self.resetCmapAction) + self.resetCmapAction.setDisabled(True) + + def shuffleColormap(self): + N = self.imageItem._numLevels + colors = self.imageItem.lut / 255 + cmap = LinearSegmentedColormap.from_list("shuffled", colors, N=N) + lut = plot.matplotlib_cmap_to_lut(cmap, n_colors=N) + if not self.resetCmapAction.isEnabled(): + self._defaultLut = lut.copy() + bkgrColor = lut[0].copy() + np.random.shuffle(lut) + lut[0] = bkgrColor + self.imageItem.setLookupTable(lut) + self.imageItem.update() + self.resetCmapAction.setDisabled(False) + + def resetColormap(self): + self.imageItem.setLookupTable(self._defaultLut) + + def autoBtnClicked(self): + self.autoRange() + + def autoRange(self): + self.vb.autoRange() + self.autoBtn.hide() + + +class ImShow(QBaseWindow): + def __init__( + self, + parent=None, + link_scrollbars=True, + infer_rgb=True, + figure_title="", + selectable_images=False, + ): + super().__init__(parent=parent) + self._linkedScrollbars = link_scrollbars + self._infer_rgb = infer_rgb + self._figure_title = figure_title + self._selectable_images = True + self.selected_idx = None + + self._autoLevels = True + + self.textItems = [] + self.group_to_idx_mapper = {"": 0} + + def _getGraphicsScrollbar(self, idx, image, imageItem, maximum): + proxy = QGraphicsProxyWidget(imageItem) + scrollbar = ScrollBarWithNumericControl( + orientation=Qt.Horizontal, add_max_proj_button=True + ) + scrollbar.sigValueChanged.connect(self.OnScrollbarValueChanged) + scrollbar.sigMaxProjToggled.connect(self.onMaxProjToggled) + scrollbar.idx = idx + scrollbar.image = image + scrollbar.imageItem = imageItem + scrollbar.setMaximum(maximum) + proxy.setWidget(scrollbar) + proxy.scrollbar = scrollbar + return proxy + + def OnScrollbarValueChanged(self, value): + scrollbar = self.sender() + imageItem = scrollbar.imageItem + img = self._get2Dimg(imageItem, scrollbar.image) + imageItem.setImage(img) # , autoLevels=self._autoLevels) + + overlayLab = self._get2DlabOverlay(imageItem) + if overlayLab is not None: + imageItem.labImageItem.setImage(overlayLab, autoLevels=False) + + self.setPointsVisible(imageItem) + + self.updateIDs() + + if not self._linkedScrollbars: + return + if len(self.ImageItems) == 1: + return + + self._linkedScrollbars = False + try: + idx = scrollbar.idx + for otherImageItem in self.ImageItems: + if otherImageItem.gridPos == imageItem.gridPos: + continue + if otherImageItem.image.shape != imageItem.image.shape: + continue + for otherScrollbar in otherImageItem.ScrollBars: + if otherScrollbar.idx != idx: + continue + otherScrollbar.setValue(scrollbar.value()) + except Exception as e: + pass + finally: + self._linkedScrollbars = True + + def _get2Dimg(self, imageItem, image): + for scrollbar in imageItem.ScrollBars: + if scrollbar.maxProjCheckbox.isChecked(): + image = image.max(axis=0) + else: + image = image[scrollbar.value()] + return image + + def _get2DlabOverlay(self, imageItem): + try: + lab = imageItem.lab + except Exception as err: + return + + for scrollbar in imageItem.ScrollBars: + if scrollbar.maxProjCheckbox.isChecked(): + lab = lab.max(axis=0) + else: + lab = lab[scrollbar.value()] + + return lab + + def isObjVisible(self, obj, imageItem): + if len(obj.centroid) == 2: + return True + + z_scrollbar = imageItem.ScrollBars[-1] + if z_scrollbar.maxProjCheckbox.isChecked(): + return True + + z_slice = z_scrollbar.value() + min_z, min_y, min_x, max_z, max_y, max_x = obj.bbox + if z_slice >= min_z and z_slice < max_z: + return True + + return False + + def onMaxProjToggled(self, checked, scrollbar): + imageItem = scrollbar.imageItem + img = self._get2Dimg(imageItem, scrollbar.image) + imageItem.setImage(img) # , autoLevels=self._autoLevels) + overlayLab = self._get2DlabOverlay(imageItem) + if overlayLab is not None: + imageItem.labImageItem.setImage(overlayLab, autoLevels=False) + self.setPointsVisible(imageItem) + if not self._linkedScrollbars: + return + if len(self.ImageItems) == 1: + return + + self._linkedScrollbars = False + try: + idx = scrollbar.idx + for otherImageItem in self.ImageItems: + if otherImageItem.gridPos == imageItem.gridPos: + continue + if otherImageItem.image.shape != imageItem.image.shape: + continue + for otherScrollbar in otherImageItem.ScrollBars: + if otherScrollbar.idx != idx: + continue + otherScrollbar.maxProjCheckbox.setChecked(checked) + except Exception as e: + pass + finally: + self._linkedScrollbars = True + + self.updateIDs() + + def setPointsVisible(self, imageItem): + if not hasattr(imageItem, "pointsItems"): + return + + first_coord = imageItem.ScrollBars[0].value() + isMaxProj = imageItem.ScrollBars[0].maxProjCheckbox.isChecked() + for pointsItems in imageItem.pointsItems.values(): + for p, plotItem in enumerate(pointsItems): + plotItem.setVisible((isMaxProj) or (p == first_coord)) + + def setupStatusBar(self): + self.statusbar = self.statusBar() + self.wcLabel = QLabel(f"") + self.statusbar.addPermanentWidget(self.wcLabel) + + def setupMainLayout(self): + self._layout = QHBoxLayout() + self._container = QWidget() + self._container.setLayout(self._layout) + self.setCentralWidget(self._container) + + def setupGraphicLayout( + self, *images, hide_axes=True, max_ncols=4, color_scheme="light" + ): + self.graphicLayout = pg.GraphicsLayoutWidget() + self._colorScheme = color_scheme + + # Set a light background + if color_scheme == "light": + self.graphicLayout.setBackground((235, 235, 235)) + else: + self.graphicLayout.setBackground((30, 30, 30)) + + ncells = max_ncols * ceil(len(images) / max_ncols) + + nrows = ncells // max_ncols + nrows = nrows if nrows > 0 else 1 + ncols = max_ncols if len(images) > max_ncols else len(images) + + if color_scheme == "light": + color = "black" + else: + color = "white" + + self.titleLabel = pg.LabelItem(justify="center", color=color, size="14pt") + self.titleLabel.setText(self._figure_title) + self.graphicLayout.addItem(self.titleLabel, row=0, col=0, colspan=ncols) + start_row = 1 + + # Check if additional rows are needed for the scrollbars + max_ndim = max([image.ndim for image in images]) + if max_ndim > 4: + raise TypeError("One or more of the images have more than 4 dimensions.") + if max_ndim == 4: + rows_range = range(0, (nrows - 1) * 3 + 1, 3) + elif max_ndim == 3: + rows_range = range(0, (nrows - 1) * 2 + 1, 2) + else: + rows_range = range(nrows) + + self.PlotItems = [] + self.ImageItems = [] + self.ScrollBars = [] + i = 0 + for r in rows_range: + row = r + start_row + for col in range(ncols): + try: + image = images[i] + except IndexError: + break + plotItem = ImShowPlotItem() + if hide_axes: + plotItem.hideAxis("bottom") + plotItem.hideAxis("left") + self.graphicLayout.addItem(plotItem, row=row, col=col) + plotItem.loc = (row, col) + self.PlotItems.append(plotItem) + + imageItem = _ImShowImageItem(i) + plotItem.addImageItem(imageItem) + imageItem.plot = plotItem + imageItem.sigHoverEvent.connect(self.onImageItemHoverEvent) + imageItem.sigMousePressEvent.connect(self.onImageItemMousePressEvent) + self.ImageItems.append(imageItem) + imageItem.gridPos = (row, col) + imageItem.ScrollBars = [] + + is_rgb = image.shape[-1] == 3 and self._infer_rgb + is_rgba = image.shape[-1] == 4 and self._infer_rgb + does_not_require_scrollbars = image.ndim == 2 or ( + image.ndim == 3 and (is_rgb or is_rgba) + ) + if does_not_require_scrollbars: + i += 1 + continue + + idx_image = 3 if (is_rgb or is_rgba) else 2 + for s in range(image.ndim - idx_image): + maximum = image.shape[s] - 1 + scrollbarProxy = self._getGraphicsScrollbar( + s, image, imageItem, maximum + ) + self.graphicLayout.addItem(scrollbarProxy, row=row + s + 1, col=col) + imageItem.ScrollBars.append(scrollbarProxy.scrollbar) + + i += 1 + + self._layout.addWidget(self.graphicLayout) + + def onImageItemMousePressEvent(self, imageItem, event): + if not self._selectable_images: + return + + plotItem = imageItem.plot + if not plotItem.isSelected(): + return + + self.selected_idx = self.PlotItems.index(plotItem) + event.ignore() + self.close() + + def onImageItemHoverEvent(self, imageItem, event): + if not self._selectable_images: + return + + modifiers = QGuiApplication.keyboardModifiers() + isCtrl = modifiers == Qt.ControlModifier + plotItem = imageItem.plot + Y, X = imageItem.image.shape[:2] + plotItem.setSelected(isCtrl and not event.isExit(), xlim=(0, X), ylim=(0, Y)) + + def movePlotItem(self, title): + combobox = self.sender() + plotItem = combobox.plotItem + row, col = plotItem.loc + + otherPlotItemIdx = combobox.titles.index(title) + otherPlotItem = self.PlotItems[otherPlotItemIdx] + other_row, other_col = otherPlotItem.loc + + self.graphicLayout.removeItem(plotItem) + self.graphicLayout.removeItem(otherPlotItem) + self.graphicLayout.addItem(otherPlotItem, row=row, col=col) + self.graphicLayout.addItem(plotItem, row=other_row, col=other_col) + + combobox.blockSignals(True) + combobox.setCurrentText(combobox.default_text) + combobox.blockSignals(False) + + plotItemIdx = combobox.titles.index(combobox.default_text) + + otherPlotItem.loc = (row, col) + plotItem.loc = (other_row, other_col) + + def setupTitles(self, *titles): + for plotItem, title in zip(self.PlotItems, titles): + combobox = ComboBox() + combobox.default_text = title + combobox.titles = list(titles) + combobox.addItems(titles) + combobox.setMaximumWidth(combobox.sizeHint().width()) + combobox.setCurrentText(title) + comboboxGraphicsItem = QGraphicsProxyWidget() + comboboxGraphicsItem.setWidget(combobox) + combobox.plotItem = plotItem + plotItem.setSelectableTitle(comboboxGraphicsItem) + combobox.currentTextChanged.connect(self.movePlotItem) + + # color = 'k' if self._colorScheme == 'light' else 'w' + # for plotItem, title in zip(self.PlotItems, titles): + # plotItem.setSelectableTitle(title, color=color) + + def updateStatusBarLabel(self, text): + self.wcLabel.setText(text) + + def autoRange(self): + for plot in self.PlotItems: + plot.autoRange() + + def showImages( + self, + *images, + labels_overlays: np.ndarray | List[np.ndarray] = None, + luts=None, + labels_overlays_luts=None, + autoLevels=True, + autoLevelsOnScroll=False, + ): + from .plot import matplotlib_cmap_to_lut + + images = [np.squeeze(img) for img in images] + self.luts = luts + self._autoLevels = autoLevels + self._autoLevelsOnScroll = autoLevelsOnScroll + for image in images: + if image.ndim > 5 or image.ndim < 2: + raise TypeError( + f"Input image has {image.ndim} dimensions. " + "Only 2-D, 3-D, and 4-D images are supported" + ) + + if isinstance(labels_overlays, np.ndarray): + labels_overlays = [labels_overlays] + + if isinstance(labels_overlays_luts, np.ndarray): + labels_overlays_luts = [labels_overlays_luts] + + if ( + labels_overlays_luts is not None + and labels_overlays is not None + and (len(labels_overlays_luts) != len(labels_overlays)) + ): + raise TypeError( + f"Number of lables_overlays_luts is {len(labels_overlays_luts)}, " + f"while number of labels_overaly is {len(labels_overlays)}. " + "Pass `None` if you want to use default lut for the labels_overlays." + ) + + if labels_overlays is not None and (len(labels_overlays) != len(images)): + raise TypeError( + f"Number of images is {len(images)}, " + f"while number of labels_overaly is {len(labels_overlays)}. " + "Pass `None` if you do not need overlaid labeles." + ) + + for i, (image, imageItem) in enumerate(zip(images, self.ImageItems)): + if luts is not None: + _autoLevels = autoLevels + lut = luts[i] + if not autoLevels and lut is not None: + imageItem.setLevels([0, len(lut)]) + else: + _autoLevels = True + if lut is None: + lut = matplotlib_cmap_to_lut("viridis") + imageItem.setLookupTable(lut) + else: + _autoLevels = True + + is_rgb = image.shape[-1] == 3 and self._infer_rgb + is_rgba = image.shape[-1] == 4 and self._infer_rgb + does_not_require_scrollbars = image.ndim == 2 or ( + image.ndim == 3 and (is_rgb or is_rgba) + ) + + if does_not_require_scrollbars: + imageItem.setAutoLevels(_autoLevels) + imageItem.setImage(image) + else: + if not self._autoLevelsOnScroll and not _autoLevels: + imageItem.setAutoLevels(False) + imageItem.setLevels([image.min(), image.max()]) + for scrollbar in imageItem.ScrollBars: + scrollbar.setValue(int(scrollbar.maximum() / 2)) + + imageItem.sigDataHover.connect(self.updateStatusBarLabel) + + if labels_overlays is None: + continue + + lab_overlay = labels_overlays[i] + if lab_overlay is None: + continue + + if lab_overlay.shape != image.shape: + raise TypeError( + f"`lab_overlay` at index {i} has shape " + f"{lab_overlay.shape} which is different " + f"from image shape {image.shape}. " + "The image and the `lab_overlay` must " + "have the same shape." + ) + + plot = imageItem.plot + labImageItem = pg.ImageItem() + labImageItem.setOpacity(0.4) + plot.addImageItem(labImageItem) + + if labels_overlays_luts is not None: + labels_overlays_lut = labels_overlays_luts[i] + else: + labels_overlays_lut = self._getDefaultLabelsOverlayLut(lab_overlay) + + labImageItem.setLookupTable(labels_overlays_lut) + labImageItem.setLevels([0, len(labels_overlays_lut)]) + + imageItem.lab = lab_overlay + imageItem.labImageItem = labImageItem + + overlayLab = self._get2DlabOverlay(imageItem) + labImageItem.setImage(overlayLab, autoLevels=False) + + # Share axis between images with same X, Y shape + all_shapes = [image.shape[-2:] for image in images] + unique_shapes = set(all_shapes) + shame_shape_plots = [] + for unique_shape in unique_shapes: + plots = [ + self.PlotItems[i] + for i, shape in enumerate(all_shapes) + if shape == unique_shape + ] + shame_shape_plots.append(plots) + + for plots in shame_shape_plots: + for plot in plots: + plot.vb.setYLink(plots[0].vb) + plot.vb.setXLink(plots[0].vb) + + def _getDefaultLabelsOverlayLut(self, lab_overlay): + IDs = [obj.label for obj in skimage.measure.regionprops(lab_overlay)] + n_objs = len(IDs) + lut = np.zeros((n_objs + 1, 4), dtype=np.uint8) + rgbas = colors.plt_colormap_to_pg_lut("tab20", ncolors=n_objs) + np.random.shuffle(rgbas) + lut[1:] = rgbas + return lut + + def _createPointsScatterItem(self, xx, yy, group, colors=None, data=None): + if colors is None: + cmap = matplotlib.colormaps["jet_r"] + idx = self.group_to_idx_mapper[group] + r, g, b = [round(c * 255) for c in cmap(idx)][:3] + brush = pg.mkBrush(color=(r, g, b, 100)) + pen = pg.mkPen(width=2, color=(r, g, b)) + hoverBrush = pg.mkBrush((r, g, b, 200)) + else: + brush = [] + pen = [] + hoverBrush = None + for color in colors: + rgb = matplotlib.colors.to_rgb(color) + rgb = [round(c * 255) for c in rgb] + _brush = pg.mkBrush(color=(*rgb, 100)) + _pen = pg.mkPen(width=2, color=rgb) + brush.append(_brush) + pen.append(_pen) + + item = pg.ScatterPlotItem( + xx, + yy, + symbol="o", + pxMode=False, + size=3, + brush=brush, + pen=pen, + hoverable=True, + hoverBrush=hoverBrush, + data=data, + ) + return item + + def drawPointsFromDf( + self, points_df: pd.DataFrame | List[pd.DataFrame], points_groups=None + ): + if not isinstance(points_df, (list, tuple)): + points_df = [points_df] * len(self.PlotItems) + + for p, df in enumerate(points_df): + if isinstance(points_groups, str): + points_groups = [points_groups] + + if points_groups is None: + grouped = [("", df)] + groups = [""] + else: + grouped = df.groupby(points_groups) + groups = grouped.groups.keys() + + idxs_space = np.linspace(0, 1, len(groups)) + self.group_to_idx_mapper = dict(zip(groups, idxs_space)) + + for group, df in grouped: + yy = df["y"].values + xx = df["x"].values + points_coords = np.column_stack((yy, xx)) + if "z" in df.columns: + zz = df["z"].values + points_coords = np.column_stack((zz, points_coords)) + if len(group) == 1: + group = group[0] + + colors = None + if "color" in df.columns: + colors = df["color"].values + + data = None + if "data" in df.columns: + data = df["data"].values + + self.drawPoints( + points_coords, colors=colors, group=group, idx=p, data=data + ) + + def drawPoints( + self, + points_coords: np.ndarray, + group="", + idx=None, + colors=None, + data=None, + ): + offset = 0.5 if np.issubdtype(points_coords.dtype, np.integer) else 0 + n_dim = points_coords.shape[1] + + if idx is not None: + PlotItems = [self.PlotItems[idx]] + ImageItems = [self.ImageItems[idx]] + else: + PlotItems = self.PlotItems + ImageItems = self.ImageItems + + if n_dim == 2: + if data is None: + data = group + + zz = [0] * len(points_coords) + self.points_coords = np.column_stack((zz, points_coords)) + for p, plotItem in enumerate(PlotItems): + imageItem = ImageItems[p] + xx = points_coords[:, 1] + offset + yy = points_coords[:, 0] + offset + pointsItem = self._createPointsScatterItem( + xx, yy, group, data=data, colors=colors + ) + pointsItem.z = 0 + plotItem.addItem(pointsItem) + imageItem.pointsItems = {group: [pointsItem]} + elif n_dim == 3: + self.points_coords = points_coords + for p, plotItem in enumerate(PlotItems): + imageItem = ImageItems[p] + imageItem.pointsItems = defaultdict(list) + scrollbar = imageItem.ScrollBars[0] + for first_coord in range(scrollbar.maximum() + 1): + coords_idx = np.nonzero(points_coords[:, 0] == first_coord) + coords = points_coords[coords_idx] + if colors is None: + _colors = None + else: + _colors = np.asarray(colors)[coords_idx] + if len(_colors) == 0: + _colors = None + + _data = group + if data is not None: + _data = data[coords_idx] + if len(_data) == 0: + _data = group + + xx = coords[:, 2] + offset + yy = coords[:, 1] + offset + pointsItem = self._createPointsScatterItem( + xx, yy, group, data=_data, colors=_colors + ) + pointsItem.z = first_coord + plotItem.addItem(pointsItem) + pointsItem.setVisible(False) + imageItem.pointsItems[group].append(pointsItem) + self.setPointsVisible(imageItem) + + def setupDuplicatedCursors(self): + self.cursors = [] + for p, plotItem in enumerate(self.PlotItems): + cursor = pg.ScatterPlotItem( + symbol="+", + pxMode=True, + pen=pg.mkPen("k", width=1), + brush=pg.mkBrush("w"), + size=16, + tip=None, + ) + self.cursors.append(cursor) + plotItem.addItem(cursor) + + for imageItem in self.ImageItems: + imageItem.setOtherImagesCursors(self.cursors) + + def setPointsData(self, points_data): + points_df = pd.DataFrame( + { + "z": self.points_coords[:, 0], + "y": self.points_coords[:, 1], + "x": self.points_coords[:, 2], + } + ) + if isinstance(points_data, pd.Series): + points_df[points_data.name] = points_data.values + elif isinstance(points_data, pd.DataFrame): + points_df = points_df.join(points_data) + elif isinstance(points_data, np.ndarray): + if points_data.ndim == 1: + points_data = points_data[np.newaxis] + else: + points_data = points_data.T + for i, values in enumerate(points_data): + points_df[f"col_{i}"] = values + + self.points_df = points_df.set_index(["z", "y", "x"]).sort_index() + + for p, plotItem in enumerate(self.PlotItems): + imageItem = self.ImageItems[p] + for pointsItems in imageItem.pointsItems.values(): + for pointsItem in pointsItems: + pointsItem.sigClicked.connect(self.pointsClicked) + + def pointsClicked(self, item, points, event): + point = points[0] + x, y = point.pos() + coords = (item.z, int(y), int(x)) + point_data = self.points_df.loc[[coords]] + now = datetime.datetime.now().strftime("%H:%M:%S") + print("*" * 60) + print(f"Point clicked at {now}. Data:") + print("-" * 60) + print(point_data) + print("") + print("*" * 60) + + def annotateObjectIDs(self, annotate_labels_idxs=None, init=False): + if init: + self.annotate_labels_idxs = annotate_labels_idxs + self.textItems = [{} for _ in self.PlotItems] + if self.annotate_labels_idxs is None: + return + for i, plotItem in enumerate(self.PlotItems): + if i not in self.annotate_labels_idxs: + continue + plotTextItems = self.textItems[i] + imageItem = self.ImageItems[i] + try: + if init: + # 3D labels (if 3D) + lab = imageItem.lab + else: + lab = imageItem.labImageItem.image + except Exception as err: + lab = imageItem.image + + rp = skimage.measure.regionprops(lab) + for obj in rp: + textItem = plotTextItems.get(obj.label) + yc, xc = obj.centroid[-2:] + if textItem is None: + textItem = pg.TextItem(text="", anchor=(0.5, 0.5), color="r") + plotItem.addItem(textItem) + plotTextItems[obj.label] = textItem + + if self.isObjVisible(obj, imageItem): + text = str(obj.label) + else: + text = "" + + textItem.setText(text) + textItem.setPos(xc, yc) + + # plotItem.enableAutoRange() + + def clearLabels(self): + for textItems in self.textItems: + for textItem in textItems.values(): + textItem.setText("") + + def updateIDs(self): + self.clearLabels() + try: + self.annotateObjectIDs(annotate_labels_idxs=self.annotate_labels_idxs) + except Exception as err: + pass + + def show(self, block=False, screenToWindowRatio=None): + super().show(block=block) + if screenToWindowRatio is None: + return + screenGeometry = self.screen().geometry() + screenWidth = screenGeometry.width() + screenHeight = screenGeometry.height() + finalWidth = int(screenToWindowRatio * screenWidth) + finalHeight = int(screenToWindowRatio * screenHeight) + screenTop = screenGeometry.top() + screenLeft = screenGeometry.left() + xc, yc = screenLeft + screenWidth / 2, screenTop + screenHeight / 2 + winLeft = int(xc - finalWidth / 2) + winTop = int(yc - finalHeight / 2) + self.setGeometry(winLeft, winTop, finalWidth, finalHeight) + + def run(self, block=False, showMaximised=False, screenToWindowRatio=None): + if showMaximised: + self.showMaximized() + else: + self.show(screenToWindowRatio=screenToWindowRatio) + QTimer.singleShot(100, self.autoRange) + + if block: + self.exec_() + + def resizeEvent(self, event) -> None: + self.PlotItems[0].autoRange() + return super().resizeEvent(event) + +# Cross-module imports (deferred to avoid import cycles) +from .images import ( + _ImShowImageItem, + labImageItem, +) +from .plot_items import ( + RectItem, +) +from .scrollbars import ( + ScrollBarWithNumericControl, +) +from ..controls.inputs import ( + ComboBox, +) + diff --git a/cellacdc/widgets/canvas/plot_items.py b/cellacdc/widgets/canvas/plot_items.py new file mode 100644 index 000000000..29c8b2bf8 --- /dev/null +++ b/cellacdc/widgets/canvas/plot_items.py @@ -0,0 +1,1170 @@ +"""Canvas widgets: plot_items.""" + +"""GUI widgets: canvas.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +class ContourItem(pg.PlotCurveItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + self._prevData = None + + def clear(self): + try: + self.setData([], []) + except AttributeError as e: + pass + + def tempClear(self): + try: + self._prevData = [d.copy() for d in self.getData()] + self.clear() + except Exception as e: + pass + + def restore(self): + if self._prevData is not None: + if self._prevData[0] is not None: + self.setData(*self._prevData) + + +class BaseScatterPlotItem(pg.ScatterPlotItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + def tempClear(self): + try: + self._prevData = [d.copy() for d in self.getData()] + self.setData([], []) + except Exception as e: + pass + + def restore(self): + if self._prevData is not None: + if self._prevData[0] is not None: + self.setData(*self._prevData) + + +class CustomAnnotationScatterPlotItem(BaseScatterPlotItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + +class ScatterPlotItem(pg.ScatterPlotItem): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.updateBrushAndPen(**kwargs) + + def updateBrushAndPen(self, **kwargs): + brush = kwargs.get("brush") + if brush is not None: + self._itemBrush = brush + pen = kwargs.get("pen") + if pen is not None: + self._itemPen = pen + + def setData(self, *args, **kwargs): + super().setData(*args, **kwargs) + self.updateBrushAndPen(**kwargs) + + def itemBrush(self): + return self._itemBrush + + def itemPen(self): + return self._itemPen + + def removePoint(self, index): + newData = np.delete(self.data, index) + # Update the index of current points + for i in range(index, len(newData)): + spotItem = newData[i]["item"] + spotItem._index = i + newData[i]["item"] = spotItem + + self.data = newData + self.prepareGeometryChange() + self.informViewBoundsChanged() + self.bounds = [None, None] + self.invalidate() + self.updateSpots(newData) + self.sigPlotChanged.emit(self) + + def coordsToNumpy(self, includeData=False, rounded=True, decimals=None): + points = self.points() + nrows = len(points) + coords_arr = np.zeros((nrows, 2)) + data_arr = None + for p, point in enumerate(points): + pos = point.pos() + x, y = pos.x(), pos.y() + if includeData: + data = point.data() + if data_arr is None: + try: + ncols = len(data) + except Exception as e: + data = [data] + ncols = 1 + data_arr = np.zeros((nrows, ncols)) + for j, data_j in enumerate(data): + data_arr[p, j] = data_j + + coords_arr[p, 0] = y + coords_arr[p, 1] = x + if not includeData: + out_arr = coords_arr + elif data_arr is not None: + out_arr = np.column_stack((data_arr, coords_arr)) + else: + out_arr = coords_arr + cast_to_int = decimals is None + decimals = decimals if decimals is not None else 0 + if rounded: + out_arr = np.round(out_arr, decimals) + if cast_to_int: + out_arr = out_arr.astype(int) + return out_arr + + +class myLabelItem(pg.LabelItem): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._prevText = "" + + def setText(self, text, **args): + self.text = text + opts = self.opts + for k in args: + opts[k] = args[k] + + if "size" in self.opts: + size = self.opts["size"] + if size == "0pt" or size == "0px": + self.opts["size"] = "1pt" + super().setText("", size="1pt") + return + + optlist = [] + + color = self.opts["color"] + if color is None: + color = pg.getConfigOption("foreground") + color = pg.functions.mkColor(color) + optlist.append("color: " + color.name(QColor.NameFormat.HexArgb)) + if "size" in opts: + size = opts["size"] + if not isinstance(size, str): + size = f"{size}px" + optlist.append("font-size: " + size) + if "bold" in opts and opts["bold"] in [True, False]: + optlist.append( + "font-weight: " + {True: "bold", False: "normal"}[opts["bold"]] + ) + if "italic" in opts and opts["italic"] in [True, False]: + optlist.append( + "font-style: " + {True: "italic", False: "normal"}[opts["italic"]] + ) + full = "%s" % ("; ".join(optlist), text) + # print full + self.item.setHtml(full) + self.updateMin() + self.resizeEvent(None) + self.updateGeometry() + + def tempClearText(self): + if self.text: + self._prevText = self.text + self.setText("") + + def restoreText(self): + if self._prevText: + self.setText(self._prevText) + + +class LabelRoiCircularItem(pg.ScatterPlotItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + def setImageShape(self, shape): + self._shape = shape + + def slice(self, zRange=None, tRange=None): + self.mask() + if zRange is None: + _slice = self._slice + else: + zmin, zmax = zRange + _slice = (slice(zmin, zmax), *self._slice) + + if tRange is not None: + tmin, tmax = tRange + _slice = (slice(tmin, tmax), *_slice) + + return _slice + + def mask(self): + shape = self._shape + radius = int(self.opts["size"] / 2) + mask = skimage.morphology.disk(radius, dtype=bool) + xx, yy = self.getData() + Yc, Xc = yy[0], xx[0] + mask, self._slice = utils.clipSelemMask(mask, shape, Yc, Xc, copy=False) + return mask + + +class PlotCurveItem(pg.PlotCurveItem): + def __init__(self, *args, **kargs): + super().__init__(*args, **kargs) + + def addPoint(self, x, y, **kargs): + _xx, _yy = self.getData() + if _xx is None or len(_xx) == 0: + self.xData = np.array([x], dtype=int) + self.yData = np.array([y], dtype=int) + return + if _xx[-1] == x and _yy[-1] == y: + # Do not append same point + return + + # Pre-allocate array and insert data (faster than append) + xx = np.zeros(len(_xx) + 1, dtype=_xx.dtype) + xx[:-1] = _xx + xx[-1] = x + yy = np.zeros(len(_yy) + 1, dtype=_xx.dtype) + yy[:-1] = _yy + yy[-1] = y + self.setData(xx, yy, **kargs) + + def clear(self): + try: + self.setData([], []) + except Exception as e: + pass + super().clear() + + def closeCurve(self): + _xx, _yy = self.getData() + self.addPoint(_xx[0], _yy[0]) + + def mask(self): + ymin, xmin, ymax, xmax = self.bbox() + _mask = np.zeros((ymax - ymin + 1, xmax - xmin + 1), dtype=bool) + local_xx, local_yy = self.getLocalData() + rr, cc = skimage.draw.polygon(local_yy, local_xx) + _mask[rr, cc] = True + return _mask + + def getLocalData(self): + _xx, _yy = self.getData() + return _xx - _xx.min(), _yy - _yy.min() + + def slice(self, zRange=None, tRange=None): + ymin, xmin, ymax, xmax = self.bbox() + if zRange is not None: + zmin, zmax = zRange + _slice = (slice(zmin, zmax), slice(ymin, ymax + 1), slice(xmin, xmax + 1)) + else: + _slice = (slice(ymin, ymax + 1), slice(xmin, xmax + 1)) + if tRange is not None: + tmin, tmax = tRange + _slice = (slice(tmin, tmax), *_slice) + return _slice + + def bbox(self): + _xx, _yy = self.getData() + return _yy.min(), _xx.min(), _yy.max(), _xx.max() + + +class MainPlotItem(pg.PlotItem): + def __init__( + self, + parent=None, + name=None, + labels=None, + title=None, + viewBox=None, + axisItems=None, + enableMenu=True, + showWelcomeText=False, + **kargs, + ): + super().__init__( + parent, name, labels, title, viewBox, axisItems, enableMenu, **kargs + ) + # Overwrite zoom out button behaviour to disable autoRange after + # clicking it. + # If autorange is enabled, it is called everytime the brush or eraser + # scatter plot items touches the border causing flickering + self.disableAutoRange() + self.autoBtn.mode = "manual" + if showWelcomeText: + self.infoTextItem = pg.TextItem() + self.addItem(self.infoTextItem) + html_filepath = os.path.join(html_path, "gui_welcome.html") + with open(html_filepath) as html_file: + htmlText = html_file.read() + self.infoTextItem.setHtml(htmlText) + self.infoTextItem.setPos(0, 0) + + self.delRoiItems = {} + self.highlightingRectItems = None + self._baseImageItem = None + self._imageItems = [] + self.highlightingRectItemsColor = None + + def addHighlightingRectItems(self, color=None): + self.highlightingRectItems = { + "left": RectItem(QRectF()), + "right": RectItem(QRectF()), + "top": RectItem(QRectF()), + "bottom": RectItem(QRectF()), + } + for rect in self.highlightingRectItems.values(): + self.addItem(rect) + + if color is None: + return + + self.setHighlightingRectItemsColor(color) + + def setHighlightingRectItemsColor(self, color): + if color == self.highlightingRectItemsColor: + return + + for item in self.highlightingRectItems.values(): + item.setColor(color) + + self.highlightingRectItemsColor = color + + def addBaseImageItem(self, baseImageItem): + self._baseImageItem = baseImageItem + self._imageItems.append(baseImageItem) + self.addItem(baseImageItem) + + def addImageItem(self, imageItem): + self._imageItems.append(imageItem) + self.addItem(imageItem) + + def setHighlighted(self, highlighted, color=None): + if color is None: + color = self.highlightingRectItemsColor + + if color is None: + color = "green" + + if self.highlightingRectItems is None: + self.addHighlightingRectItems(color=color) + + if not highlighted: + for rect in self.highlightingRectItems.values(): + rect.setQRect(QRectF()) + return + + self.setHighlightingRectItemsColor(color) + + ((xmin, xmax), (ymin, ymax)) = self.viewRange() + xmin = xmin if xmin >= 0 else 0 + ymin = ymin if ymin >= 0 else 0 + if self._baseImageItem is not None: + Y, X = self._baseImageItem.image.shape[:2] + xmax = min(xmax, X) + ymax = min(ymax, Y) + + w = xmax - xmin + h = ymax - ymin + + bs = round(((w + h) / 2) * 0.02) + if bs < 1: + bs = 1 + + x0 = xmin + x1 = xmin + bs + x2 = xmax - bs + x3 = xmax + + y0 = ymin + y1 = ymin + bs + y2 = ymax - bs + y3 = ymax + + self.highlightingRectItems["left"].setRect(x0, y0, bs, y3 - y0) + self.highlightingRectItems["top"].setRect(x1, y0, x3 - x1, bs) + self.highlightingRectItems["right"].setRect(x2, y1, bs, y3 - y1) + self.highlightingRectItems["bottom"].setRect(x1, y2, x2 - x1, bs) + self.update() + + def clear(self): + super().clear() + + self.delRoiItems = {} + self.highlightingRectItems = None + self._baseImageItem = None + self._imageItems = [] + self.highlightingRectItemsColor = None + + try: + self.removeItem(self.infoTextItem) + except Exception as e: + pass + + def autoBtnClicked(self): + self.vb.autoRange() + self.autoBtn.hide() + + def addDelRoiItem(self, roiItem, key): + if self.isDelRoiItemPresent(roiItem): + return + + self.delRoiItems[key] = roiItem + roiItem.key = key + self.addItem(roiItem) + + def removeDelRoiItem(self, roiItem): + key = roiItem.key + self.delRoiItems.pop(key, None) + try: + self.removeItem(roiItem) + except Exception as err: + return + + def isDelRoiItemPresent(self, roiItem): + try: + key = roiItem.key + except AttributeError as e: + return False + + try: + roi = self.delRoiItems[key] + except Exception as err: + return False + + return True + + def viewRange(self, mask_img=None): + if mask_img is None: + return super().viewRange() + + mask_rp = skimage.measure.regionprops(skimage.measure.label(mask_img)) + if not mask_rp: + return super().viewRange() + + mask_obj = mask_rp[0] + ymin, xmin, ymax, xmax = mask_obj.bbox + return (xmin, xmax), (ymin, ymax) + + +class GhostContourItem(pg.PlotDataItem): + def __init__( + self, ParentPlotItem, penColor=(245, 184, 0, 100), textColor=(245, 184, 0) + ): + super().__init__() + # Yellow pen + self.setPen(pg.mkPen(width=2, color=penColor)) + self.label = myLabelItem() + self.label.setAttr("bold", True) + self.label.setAttr("color", textColor) + self._ParentPlotItem = ParentPlotItem + + def addToPlotItem(self): + self._ParentPlotItem.addItem(self) + self._ParentPlotItem.addItem(self.label) + + def removeFromPlotItem(self): + self._ParentPlotItem.removeItem(self.label) + self._ParentPlotItem.removeItem(self) + + def setData( + self, xx=None, yy=None, fontSize=11, ID=0, y_cursor=None, x_cursor=None + ): + if xx is None: + xx = [] + if yy is None: + yy = [] + super().setData(xx, yy) + if not hasattr(self, "label"): + return + + if ID == 0: + self.label.setText("") + else: + self.label.setText(f"{ID}", size=fontSize) + w, h = self.label.itemRect().width(), self.label.itemRect().height() + self.label.setPos(x_cursor, y_cursor - h) + + def clear(self): + self.setData([], []) + + +class LabelItem(pg.LabelItem): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def bbox(self): + xl, yl = self.pos().x(), self.pos().y() + wl, hl = self.itemRect().width(), self.itemRect().height() + return yl, xl, yl + hl, xl + wl + + def setBold(self, bold=True): + self.origPos = self.pos() + self.setText(self.text, bold=bold) + self.setPos(self.origPos) + + +class ScaleBar(QGraphicsObject): + sigEditProperties = Signal(object) + sigRemove = Signal(object) + + def __init__(self, imageShape, viewRange, parent=None): + super().__init__(parent) + self.SizeY, self.SizeX = imageShape + self.updateViewRange(viewRange) + self.plotItem = PlotCurveItem() + self.labelItem = LabelItem() + self._x_pad = 5 + self._y_pad = 3 + self._highlighted = False + self._parent = parent + self.clicked = False + self.createContextMenu() + + def updateViewRange(self, viewRange): + xRange, yRange = viewRange + x0, x1 = xRange + y0, y1 = yRange + if x0 < 0: + x0 = 0 + + if x1 > self.SizeX: + x1 = self.SizeX + + if y0 < 0: + y0 = 0 + + if y1 > self.SizeY: + y1 = self.SizeY + + self.xmax = x1 + self.xmin = x0 + + self.ymax = y1 + self.ymin = y0 + + def createContextMenu(self): + self.contextMenu = QMenu() + action = QAction("Edit properties...", self.contextMenu) + action.triggered.connect(self.emitEditProperties) + self.contextMenu.addSeparator() + action = QAction("Remove", self.contextMenu) + action.triggered.connect(self.emitRemove) + self.contextMenu.addAction(action) + + def emitEditProperties(self): + self.setHighlighted(False) + self.sigEditProperties.emit(self.properties()) + + def emitRemove(self): + self.sigRemove.emit(self) + + def isHighlighted(self): + return self._highlighted + + def setHighlighted(self, highlighted): + if self._highlighted and highlighted: + return + + if not self._highlighted and not highlighted: + return + + pen = self.highlightPen if highlighted else self.pen + self.labelItem.setBold(bold=highlighted) + self.plotItem.setPen(pen) + + self._highlighted = highlighted + + def showContextMenu(self, x, y): + self.contextMenu.popup(QPoint(int(x), int(y))) + + def properties(self): + properties = { + "thickness": self._thickness, + "length_pixel": self._length, + "length_unit": self._length_unit, + "is_text_visible": self._is_text_visible, + "color": self._color, + "loc": self._loc, + "font_size": float(self._font_size[:-2]), + "unit": self._unit, + "num_decimals": self._num_decimals, + "move_with_zoom": self._move_with_zoom, + } + return properties + + def move(self, xm, ym): + self._loc = "Custom" + + Dy = ym - self.yc + Dx = xm - self.xc + + x0 = self.x0c + Dx + x1 = x0 + self._length + y0 = y1 = self.y0c + Dy + self.plotItem.setData([x0, x1], [y0, y1]) + self.setTextPos() + + def paint(self, painter, option, widget): + pass + + def boundingRect(self): + ymin, xmin, ymax, xmax = self.bbox() + return QRectF(xmin, ymin, xmax - xmin, ymax - ymin) + + def setLocationProperty(self, loc: str): + self._loc = loc + + def setMoveWithZoomProperty(self, move_with_zoom): + self._move_with_zoom = move_with_zoom + + def setProperties( + self, + length_pixel, + length_unit, + thickness=3, + color="w", + is_text_visible=True, + loc="top-left", + font_size=12, + unit="", + num_decimals=0, + move_with_zoom=False, + ): + self._loc = loc + self._color = color + self._length = length_pixel + self._length_unit = length_unit + self._is_text_visible = is_text_visible + self._font_size = f"{font_size}px" + self._unit = unit + self._num_decimals = num_decimals + self._move_with_zoom = move_with_zoom + self._thickness = thickness + self.pen = pg.mkPen(width=thickness, color=color, cosmetic=False) + self.highlightPen = pg.mkPen(width=thickness + 2, color=color, cosmetic=False) + self.pen.setCapStyle(Qt.PenCapStyle.FlatCap) + self.highlightPen.setCapStyle(Qt.PenCapStyle.FlatCap) + self.plotItem.setPen(self.pen) + + def updatePhysicalLength(self, PhysicalSizeX): + length_unit = self._length_unit + unit = self._unit + length_um = _core.convert_length(length_unit, unit, "μm") + length_pixel = length_um / PhysicalSizeX + self._length = length_pixel + self.update() + + def addToAxis(self, ax): + ax.addItem(self.plotItem) + ax.addItem(self.labelItem) + + def setText(self): + if self._is_text_visible: + number = round(self._length_unit, self._num_decimals) + if self._num_decimals == 0: + number = int(number) + text = f"{number} {self._unit}" + else: + text = "" + self.labelItem.setText(text, color=self._color, size=self._font_size) + + def setTextPos(self): + xx, yy = self.plotItem.getData() + x0 = xx[0] + y0 = yy[0] + xc = x0 + self._length / 2 + wl = self.labelItem.itemRect().width() + hl = self.labelItem.itemRect().height() + xl = xc - wl / 2 + yt = y0 - hl + self.labelItem.setPos(xl, yt) + + def updatePosViewRangeChanged(self, viewRange): + if self._loc == "custom": + xx, yy = self.plotItem.getData() + x0p = xx[0] + y0p = yy[0] + xcp = x0p + self._length / 2 + hl = self.labelItem.itemRect().height() + ycp = y0p - hl / 2 + x0 = self.xmin + y0 = self.ymin + x_range = self.xmax - x0 + y_range = self.ymax - y0 + Dx_perc = (xcp - x0) / x_range + Dy_perc = (ycp - y0) / y_range + + self.updateViewRange(viewRange) + + X0 = self.xmin + Y0 = self.ymin + + X_range = self.xmax - X0 + Y_range = self.ymax - Y0 + + Xcp = X0 + (Dx_perc * X_range) + Ycp = Y0 + (Dy_perc * Y_range) + X0p = Xcp - (self._length / 2) + Y0p = Ycp + (hl / 2) + + X1p = X0p + self._length + Y1p = Y0p + + self.plotItem.setData([X0p, X1p], [Y0p, Y1p]) + else: + self.updateViewRange(viewRange) + self.update() + + def getStartXCoordFromLoc(self, loc): + if loc == "custom": + xx, yy = self.plotItem.getData() + x0 = xx[0] + return x0 + + self.setText() + wl = self.labelItem.itemRect().width() + if loc.find("left") != -1: + x0 = self._x_pad + self.xmin + xc = x0 + self._length / 2 + xl = xc - wl / 2 + if xl < x0: + # Text is larger than line --> move line to the right + x0 = self._x_pad + abs(xl - self._x_pad) + else: + x0 = self.xmax - self._length - self._x_pad + xc = x0 + self._length / 2 + x1 = x0 + self._length + xr = xc + wl / 2 + if xr > x1: + # Text is larger than line --> move line to the left + delta_overshoot = xr - x1 + x0 = x0 - delta_overshoot + return x0 + + def getStartYCoordFromLoc(self, loc): + if loc == "custom": + xx, yy = self.plotItem.getData() + y0 = yy[0] + return y0 + + self.setText() + textHeight = self.labelItem.itemRect().height() + if loc.find("top") != -1: + return textHeight + self._y_pad + self.ymin + else: + return self.ymax - self._y_pad - self._thickness + + def update(self): + x0 = self.getStartXCoordFromLoc(self._loc) # + self._thickness/2 + y0 = self.getStartYCoordFromLoc(self._loc) + + x1 = x0 + self._length # - self._thickness/2 + self.plotItem.setData([x0, x1], [y0, y0]) + + self.setText() + self.setTextPos() + + def draw(self, length_pixel, length_unit, **kwargs): + self.setProperties(length_pixel, length_unit, **kwargs) + self.update() + + def bbox(self): + y_line_min, x_line_min, y_line_max, x_line_max = self.plotItem.bbox() + y_lab_min, x_lab_min, y_lab_max, x_lab_max = self.labelItem.bbox() + ymin = min(y_line_min, y_lab_min) + xmin = min(x_line_min, x_lab_min) + ymax = max(y_line_max, y_lab_max) + xmax = max(x_line_max, x_lab_max) + return ymin, xmin, ymax, xmax + + def mousePressed(self, x, y): + self.clicked = True + self.xc, self.yc = x, y + xx, yy = self.plotItem.getData() + self.x0c = xx[0] + self.y0c = yy[0] + + def removeFromAxis(self, ax): + ax.removeItem(self.labelItem) + ax.removeItem(self.plotItem) + + +class RulerPlotItem(pg.PlotDataItem): + def __init__(self, *args, **kwargs): + self.labelItem = pg.LabelItem() + super().__init__(*args, **kwargs) + + def setData(self, *args, lengthText="", **kwargs): + super().setData(*args, **kwargs) + self.labelItem.setText("") + if not lengthText: + return + self.setLengthText(lengthText) + + def setLengthText(self, lengthText): + xx, yy = self.getData() + x0, x1 = sorted(xx) + y0, y1 = sorted(yy) + xc = round(x0 + (x1 - x0) / 2) + yc = round(y0 + (y1 - y0) / 2) + self.labelItem.setText(lengthText, size="11px", color="r") + # xc = x0 + self._length/2 + wl = self.labelItem.itemRect().width() + hl = self.labelItem.itemRect().height() + xl = xc - wl / 2 + yt = y0 - hl + self.labelItem.setPos(xl, yt) + + +class PointsScatterPlotItem(pg.ScatterPlotItem): + sigHoverEntered = Signal(object, object, object) + + def __init__(self, *args, ax=None, show_data_as_tip=False, **kwargs): + self.textItem = annotate.TextAnnotationsScatterItem(size=12, anchor=(1.0, 1.0)) + self.textItem.createSymbols( + [str(int_id) for int_id in range(200)], includeBold=False + ) + # self._textItems = {} + super().__init__(*args, **kwargs) + self.textItem.setParentItem(self) + self._font = QFont() + self._font.setPixelSize(12) + self.show_data_as_tip = show_data_as_tip + self.drawIds = True + self.ax = ax + self.sigHovered.connect(self.onHover) + self.lastHoveredPoint = None + + def onHover(self, item, points, event): + if len(points) == 0: + vb = self.getViewBox() + vb.setToolTip("") + return + + if self.lastHoveredPoint != points[0]: + self.sigHoverEntered.emit(item, points, event) + self.lastHoveredPoint = points[0] + + if not self.opts["hoverable"]: + return + + if not self.show_data_as_tip: + return + + tip_li = [str(point.data()) for point in points] + tip = "\n\n".join(tip_li) + + vb = self.getViewBox() + vb.setToolTip(tip) + + def setData(self, *args, **kwargs): + self.clearTextItems() + super().setData(*args, **kwargs) + data = kwargs.get("data") + if data is None: + return + + if len(data) == 0: + return + + first_point_data = data[0] + if not isinstance(first_point_data, (int, str)): + return + + if not self.drawIds: + return + + if self.show_data_as_tip: + return + + color = self.opts["brush"].color() + self.textItem.setColors({"id": color.getRgb()}) + size = self.opts["size"] + radius = size / 2 + # xx, yy = args + # for x, y, point_data in zip(xx, yy, data): + for point in self.points(): + text = str(point.data()) + if not text: + continue + + x, y = point.pos().x(), point.pos().y() + xt, yt = x + radius - 0.5, y - radius + 0.5 + opts = { + "text": text, + "bold": False, + "color_name": "id", + } + data = self.textItem.addObjAnnot((xt, yt), anchor=(-0.3, 1.3), **opts) + self.textItem.appendData(data, opts["text"]) + + self.textItem.draw() + # hexColor = color.name() + # htmlText = html_utils.span( + # text, color=hexColor, font_size='13pt', bold=True + # ) + + # textItem = self._textItems.get((x, y)) + # if textItem is None: + # textItem = pg.TextItem(html=htmlText, anchor=(0, 1)) + # textItem.setParentItem(self) + # self._textItems[(x, y)] = textItem + # self.ax.addItem(textItem) + # else: + # textItem.setHtml(htmlText) + # textItem.setPos(x+radius-0.5, y-radius+0.5) + + def clearTextItems(self): + self.textItem.clearData() + # for textItem in self._textItems.values(): + # textItem.setText('') + + def clear(self): + super().clear() + self.clearTextItems() + + def setVisible(self, visible): + super().setVisible(visible) + self.textItem.setVisible(visible) + + +class RectItem(pg.GraphicsObject): + def __init__(self, rect, pen=None, brush=(255, 0, 0, 100), parent=None): + super().__init__(parent) + self._rect = rect + self._pen = pg.mkPen(pen) + self._brush = pg.mkBrush(brush) + self.picture = QPicture() + self._generate_picture() + + def setColor(self, color): + rgba = matplotlib.colors.to_rgba(color, alpha=100 / 255) + rgba = [round(c * 255) for c in rgba] + self._brush = pg.mkBrush(rgba) + self._generate_picture() + self.update() + + def setRect(self, x, y, width, height): + self._rect = QRectF(x, y, width, height) + self._generate_picture() + self.update() + + def setQRect(self, qrect): + self._rect = qrect + self._generate_picture() + self.update() + + @property + def rect(self): + return self._rect + + def _generate_picture(self): + painter = QPainter(self.picture) + painter.setPen(self._pen) + painter.setBrush(self._brush) + painter.drawRect(self._rect) + painter.end() + + def paint(self, painter, option, widget=None): + painter.drawPicture(0, 0, self.picture) + + def boundingRect(self): + return QRectF(self.picture.boundingRect()) diff --git a/cellacdc/widgets/canvas/rois.py b/cellacdc/widgets/canvas/rois.py new file mode 100644 index 000000000..643e736ef --- /dev/null +++ b/cellacdc/widgets/canvas/rois.py @@ -0,0 +1,303 @@ +"""Canvas widgets: rois.""" + +"""GUI widgets: canvas.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +class PolyLineROI(pg.PolyLineROI): + def __init__(self, positions, closed=False, pos=None, **args): + super().__init__(positions, closed, pos, **args) + + +class ROI(pg.ROI): + def __init__( + self, + pos, + size=pg.Point(1, 1), + angle=0, + invertible=False, + maxBounds=None, + snapSize=1, + scaleSnap=False, + translateSnap=False, + rotateSnap=False, + parent=None, + pen=None, + hoverPen=None, + handlePen=None, + handleHoverPen=None, + movable=True, + rotatable=True, + resizable=True, + removable=False, + aspectLocked=False, + ): + super().__init__( + pos, + size, + angle, + invertible, + maxBounds, + snapSize, + scaleSnap, + translateSnap, + rotateSnap, + parent, + pen, + hoverPen, + handlePen, + handleHoverPen, + movable, + rotatable, + resizable, + removable, + aspectLocked, + ) + + def slice(self, zRange=None, tRange=None): + x0, y0 = [int(round(c)) for c in self.pos()] + w, h = [int(round(c)) for c in self.size()] + xmin, xmax = x0, x0 + w + if xmin > xmax: + xmin, xmax = xmax, xmin + ymin, ymax = y0, y0 + h + if ymin > ymax: + ymin, ymax = ymax, ymin + if zRange is not None: + zmin, zmax = zRange + _slice = (slice(zmin, zmax), slice(ymin, ymax), slice(xmin, xmax)) + else: + _slice = (slice(ymin, ymax), slice(xmin, xmax)) + if tRange is not None: + tmin, tmax = tRange + _slice = (slice(tmin, tmax), *_slice) + return _slice + + def bbox(self): + x0, y0 = [int(round(c)) for c in self.pos()] + w, h = [int(round(c)) for c in self.size()] + xmin, xmax = x0, x0 + w + if xmin > xmax: + xmin, xmax = xmax, xmin + ymin, ymax = y0, y0 + h + if ymin > ymax: + ymin, ymax = ymax, ymin + + return ymin, xmin, ymax, xmax + + +class ZoomROI(ROI): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.viewRangesQueue = deque() + + def getLastRange(self): + xRange, yRange = self.viewRangesQueue.pop() + return xRange, yRange + + def storeLastRange(self, xRange, yRange): + self.viewRangesQueue.append((xRange, yRange)) + + +class DelROI(pg.ROI): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def clearPoints(self): + """ + Remove all handles and segments. + """ + while len(self.handles) > 0: + self.removeHandle(self.handles[0]["item"]) diff --git a/cellacdc/widgets/canvas/scrollbars.py b/cellacdc/widgets/canvas/scrollbars.py new file mode 100644 index 000000000..b7803e122 --- /dev/null +++ b/cellacdc/widgets/canvas/scrollbars.py @@ -0,0 +1,595 @@ +"""Canvas widgets: scrollbars.""" + +"""GUI widgets: canvas.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +class MouseCursor(QWidget): + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._x = None + self._y = None + self.setMouseTracking(True) + + def mouseMoveEvent(self, event) -> None: + self.move(event.pos()) + self.update() + return super().mouseMoveEvent(event) + + # def drawAtPos(self, x, y): + # self._x = x + # self._y = y + # self.update() + + def paintEvent(self, event) -> None: + p = QPainter(self) + # p.setPen(QPen(QColor(0,0,0))) + # p.setBrush(QBrush(QColor(70,70,70,200))) + p.drawLine(0, 0, 200, 0) + p.end() + + +class labelledQScrollbar(ScrollBar): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._label = None + + def setLabel(self, label): + self._label = label + + def updateLabel(self): + if self._label is not None: + position = self.sliderPosition() + s = self._label.text() + s = re.sub(r"(\d+)/(\d+)", rf"{position + 1:02}/\2", s) + self._label.setText(s) + + def setSliderPosition(self, position): + QScrollBar.setSliderPosition(self, position) + self.updateLabel() + + def setValue(self, value): + QScrollBar.setValue(self, value) + self.updateLabel() + + +class navigateQScrollBar(ScrollBar): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._disableCustomPressEvent = False + self.signal_slot_mapper = {} + + def disableCustomPressEvent(self): + self._disableCustomPressEvent = True + + def enableCustomPressEvent(self): + self._disableCustomPressEvent = False + + def setAbsoluteMaximum(self, absoluteMaximum): + self._absoluteMaximum = absoluteMaximum + + def absoluteMaximum(self): + return self._absoluteMaximum + + def mousePressEvent(self, event): + super().mousePressEvent(event) + if self.maximum() == self._absoluteMaximum: + return + + if self._disableCustomPressEvent: + return + + def setValueNoSignal(self, value): + for signal_name, slot in self.signal_slot_mapper.items(): + signal = getattr(self, signal_name) + try: + signal.disconnect() + except Exception as e: + pass + + self.setSliderPosition(value) + self.connectEvents(self.signal_slot_mapper) + + def connectEvents(self, signal_slot_mapper: dict): + self.signal_slot_mapper = signal_slot_mapper + for signal_name, slot in signal_slot_mapper.items(): + signal = getattr(self, signal_name) + try: + signal.disconnect() + except Exception as e: + pass + signal.connect(slot) + + +class linkedQScrollbar(ScrollBar): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._linkedScrollBar = None + + def linkScrollBar(self, scrollbar): + self._linkedScrollBar = scrollbar + scrollbar.setSliderPosition(self.sliderPosition()) + + def unlinkScrollBar(self): + self._linkedScrollBar = None + + def setSliderPosition(self, position): + QScrollBar.setSliderPosition(self, position) + if self._linkedScrollBar is not None: + self._linkedScrollBar.setSliderPosition(position) + + def setMaximum(self, max): + QScrollBar.setMaximum(self, max) + if self._linkedScrollBar is not None: + self._linkedScrollBar.setMaximum(max) + + +class sliderWithSpinBox(QWidget): + sigValueChange = Signal(object) + valueChanged = Signal(object) + editingFinished = Signal() + + def __init__(self, *args, **kwargs): + super().__init__(*args) + + layout = QGridLayout() + + title = kwargs.get("title") + row = 0 + col = 0 + if title is not None: + titleLabel = QLabel(self) + titleLabel.setText(title) + loc = kwargs.get("title_loc", "top") + if loc == "top": + layout.addWidget(titleLabel, 0, col, alignment=Qt.AlignLeft) + elif loc == "in_line": + row = -1 + col = 1 + layout.addWidget(titleLabel, 0, 0, alignment=Qt.AlignLeft) + layout.setColumnStretch(0, 0) + + self._normalize = False + normalize = kwargs.get("normalize") + if normalize is not None and normalize: + self._normalize = True + self._isFloat = True + + self._isFloat = False + isFloat = kwargs.get("isFloat") + if isFloat is not None and isFloat: + self._isFloat = True + + self.slider = QSlider(Qt.Horizontal, self) + + if self._normalize or self._isFloat: + self.spinBox = DoubleSpinBox(self) + else: + self.spinBox = SpinBox(self) + self.spinBox.setAlignment(Qt.AlignCenter) + self.spinBox.setMaximum(2**31 - 1) + + maximum_on_label = kwargs.get("maximum_on_label") + spinbox_loc = kwargs.get("spinbox_loc", "right") + if spinbox_loc == "right": + spinbox_col = col + 1 + slider_col = col + if maximum_on_label is not None: + maximum_on_label_col = spinbox_col + 1 + elif spinbox_loc == "left": + spinbox_col = col + slider_col = col + 1 + if maximum_on_label is not None: + maximum_on_label_col = spinbox_col + 1 + slider_col += 1 + + if maximum_on_label is not None: + self.labelMaximum = QLabel() + layout.addWidget(self.labelMaximum, row + 1, maximum_on_label_col) + layout.addWidget(self.slider, row + 1, slider_col) + layout.addWidget(self.spinBox, row + 1, spinbox_col) + + if title is not None: + layout.setRowStretch(0, 1) + layout.setRowStretch(row + 1, 1) + layout.setColumnStretch(slider_col, 6) + layout.setColumnStretch(spinbox_col, 1) + + self._layout = layout + self.lastCol = col + 1 + self.sliderCol = slider_col + + self.slider.valueChanged.connect(self.sliderValueChanged) + self.slider.sliderReleased.connect(self.onEditingFinished) + self.spinBox.valueChanged.connect(self.spinboxValueChanged) + self.spinBox.editingFinished.connect(self.onEditingFinished) + + layout.setContentsMargins(5, 0, 5, 0) + + self.setLayout(layout) + + if maximum_on_label is not None: + self.setMaximum(maximum_on_label) + self.labelMaximum.setText(f"/{maximum_on_label}") + + def onEditingFinished(self): + self.editingFinished.emit() + + def maximum(self): + return self.slider.maximum() + + def minimum(self): + return self.slider.minimum() + + def setValue(self, value, emitSignal=False): + valueInt = value + if self._normalize: + valueInt = int(value * self.slider.maximum()) + elif self._isFloat: + valueInt = int(value) + + self.spinBox.valueChanged.disconnect() + self.spinBox.setValue(value) + self.spinBox.valueChanged.connect(self.spinboxValueChanged) + + self.slider.valueChanged.disconnect() + if valueInt > self.slider.maximum(): + self.slider.setMaximum(valueInt) + self.slider.setValue(valueInt) + self.slider.valueChanged.connect(self.sliderValueChanged) + + if emitSignal: + self.sigValueChange.emit(self.value()) + self.valueChanged.emit(self.value()) + + def setMaximum(self, max, including_spinbox=False): + self.slider.setMaximum(max) + if including_spinbox: + self.spinBox.setMaximum(max) + + def setSingleStep(self, step): + self.spinBox.setSingleStep(step) + + def setMinimum(self, min, including_spinbox=False): + self.slider.setMinimum(min) + if including_spinbox: + self.spinBox.setMinimum(min) + + def setSingleStep(self, step): + self.spinBox.setSingleStep(step) + + def setDecimals(self, decimals): + self.spinBox.setDecimals(decimals) + + def setTickPosition(self, position): + self.slider.setTickPosition(position) + + def setTickInterval(self, interval): + self.slider.setTickInterval(interval) + + def sliderValueChanged(self, val): + self.spinBox.valueChanged.disconnect() + if self._normalize: + valF = val / self.slider.maximum() + self.spinBox.setValue(valF) + else: + self.spinBox.setValue(val) + self.spinBox.valueChanged.connect(self.spinboxValueChanged) + self.sigValueChange.emit(self.value()) + self.valueChanged.emit(self.value()) + + def spinboxValueChanged(self, val): + if self._normalize: + val = int(val * self.slider.maximum()) + elif self._isFloat: + val = int(val) + + self.slider.valueChanged.disconnect() + self.slider.setValue(val) + self.slider.valueChanged.connect(self.sliderValueChanged) + self.sigValueChange.emit(self.value()) + self.valueChanged.emit(self.value()) + + def value(self): + return self.spinBox.value() + + def setDisabled(self, disabled) -> None: + self.slider.setDisabled(disabled) + self.spinBox.setDisabled(disabled) + + +class ScrollBarWithNumericControl(QWidget): + sigValueChanged = Signal(int) + sigMaxProjToggled = Signal(bool, object) + + def __init__( + self, + orientation=Qt.Horizontal, + add_max_proj_button=False, + parent=None, + labelText="", + ) -> None: + super().__init__(parent) + + self._slot = None + + layout = QHBoxLayout() + self.scrollbar = QScrollBar(orientation, self) + self.spinbox = QSpinBox(self) + self.maxLabel = QLabel(self) + idx = 0 + if labelText: + layout.addWidget(QLabel(labelText)) + layout.setStretch(idx, 0) + idx += 1 + + layout.addWidget(self.spinbox) + layout.setStretch(idx, 0) + idx += 1 + + layout.addWidget(self.maxLabel) + layout.setStretch(idx, 0) + idx += 1 + + layout.addWidget(self.scrollbar) + layout.setStretch(idx, 1) + idx += 1 + + if add_max_proj_button: + self.maxProjCheckbox = QCheckBox("MAX") + self.scrollbar.maxProjCheckbox = self.maxProjCheckbox + layout.addWidget(self.maxProjCheckbox) + layout.setStretch(idx, 0) + + layout.setContentsMargins(5, 0, 5, 0) + + self.setLayout(layout) + + self.spinbox.valueChanged.connect(self.spinboxValueChanged) + self.scrollbar.valueChanged.connect(self.scrollbarValueChanged) + + if add_max_proj_button: + self.maxProjCheckbox.toggled.connect(self.maxProjToggled) + + def connectValueChanged(self, slot): + self.sigValueChanged.connect(slot) + self._slot = slot + + def setValueNoSignal(self, value): + if self._slot is None: + return + self.sigValueChanged.disconnect() + self.setValue(value) + self.sigValueChanged.connect(self._slot) + + def maxProjToggled(self, checked): + self.scrollbar.setDisabled(checked) + self.sigMaxProjToggled.emit(checked, self) + + def showEvent(self, event) -> None: + super().showEvent(event) + + self.scrollbar.setMinimumHeight(self.spinbox.height()) + + def setMaximum(self, maximum): + self.maxLabel.setText(f"/{maximum}") + self.scrollbar.setMaximum(maximum) + self.spinbox.setMaximum(maximum) + + def setMinimum(self, minumum): + self.scrollbar.setMinimum(minumum) + self.spinbox.setMinimum(minumum) + + def spinboxValueChanged(self, value): + self.scrollbar.setValue(value) + + def scrollbarValueChanged(self, value): + self.spinbox.setValue(value) + self.sigValueChanged.emit(value) + + def setValue(self, value): + self.scrollbar.setValue(value) + + def value(self): + return self.scrollbar.value() + + def maximum(self): + return self.scrollbar.maximum() + +# Cross-module imports (deferred to avoid import cycles) +from ..controls.inputs import ( + DoubleSpinBox, + SpinBox, +) + diff --git a/cellacdc/widgets/controls.py b/cellacdc/widgets/controls.py deleted file mode 100644 index da55a696b..000000000 --- a/cellacdc/widgets/controls.py +++ /dev/null @@ -1,5171 +0,0 @@ -"""GUI widgets: controls.""" - -from collections import defaultdict, deque -from typing import Dict, List, Union, Iterable, Sequence -import os -import sys -import operator -import time -import re -import datetime -import numpy as np -import pandas as pd -import math -import traceback -import logging -import textwrap -import random - -from functools import partial -from math import ceil - -import skimage.draw -import skimage.morphology - -from matplotlib.colors import ListedColormap, LinearSegmentedColormap -import matplotlib.pyplot as plt -import matplotlib -from matplotlib.backends.backend_agg import FigureCanvasAgg - -from qtpy.QtCore import ( - Signal, - QTimer, - Qt, - QPoint, - QUrl, - Property, - QPropertyAnimation, - QEasingCurve, - QLocale, - QSize, - QRect, - QPointF, - QRect, - QPoint, - QEasingCurve, - QRegularExpression, - QEvent, - QEventLoop, - QPropertyAnimation, - QObject, - QItemSelectionModel, - QAbstractListModel, - QModelIndex, - QByteArray, - QDataStream, - QMimeData, - QAbstractItemModel, - QIODevice, - QItemSelection, - PYQT6, - QRectF, -) -from qtpy.QtGui import ( - QFont, - QPalette, - QColor, - QPen, - QKeyEvent, - QBrush, - QPainter, - QRegularExpressionValidator, - QIcon, - QPixmap, - QKeySequence, - QLinearGradient, - QShowEvent, - QDesktopServices, - QFontMetrics, - QGuiApplication, - QLinearGradient, - QImage, - QCursor, - QPicture, -) -from qtpy.QtWidgets import ( - QTextEdit, - QLabel, - QProgressBar, - QHBoxLayout, - QToolButton, - QCheckBox, - QApplication, - QWidget, - QVBoxLayout, - QMainWindow, - QTreeWidgetItemIterator, - QLineEdit, - QSlider, - QSpinBox, - QGridLayout, - QRadioButton, - QScrollArea, - QSizePolicy, - QComboBox, - QPushButton, - QScrollBar, - QGroupBox, - QAbstractSlider, - QDoubleSpinBox, - QWidgetAction, - QAction, - QTabWidget, - QAbstractSpinBox, - QToolBar, - QStyleOptionSpinBox, - QStyle, - QDialog, - QSpacerItem, - QFrame, - QMenu, - QActionGroup, - QListWidget, - QPlainTextEdit, - QFileDialog, - QListView, - QAbstractItemView, - QTreeWidget, - QTreeWidgetItem, - QListWidgetItem, - QLayout, - QStylePainter, - QGraphicsBlurEffect, - QGraphicsProxyWidget, - QGraphicsObject, - QButtonGroup, - QStyleOptionSlider, -) -import qtpy.compat - -import pyqtgraph as pg - -pg.setConfigOption("imageAxisOrder", "row-major") - -from .. import utils, measurements, is_mac, is_win, html_utils, is_linux -from .. import printl, settings_folderpath -from .. import colors, config -from .. import html_path -from .. import _palettes -from .. import load -from .. import apps -from .. import plot -from .. import annotate -from .. import urls -from .. import _core, core -from .. import QtScoped -from .. import prompts -from ..acdc_regex import float_regex -from ..config import PREPROCESS_MAPPER -from .. import _base_widgets - -from ..components.palette import ( # noqa: E402 - BASE_COLOR, - Gradients, - GradientsImage, - GradientsLabels, - LINEEDIT_INVALID_ENTRY_STYLESHEET, - LINEEDIT_WARNING_STYLESHEET, - LISTWIDGET_STYLESHEET, - PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, - PROGRESSBAR_QCOLOR, - TEXT_COLOR, - TREEWIDGET_STYLESHEET, - cmaps, - font, - getCustomGradients, - nonInvertibleCmaps, - sign_int_mapper, - str_to_operator_mapper, -) -from ..components.progress import QtHandler, QLog, XStream # noqa: E402 -from ..components.buttons import * # noqa: E402, F403 -from ..components.layout import * # noqa: E402, F403 -from ..components.inputs_basic import * # noqa: E402, F403 -from ..components.path_controls import * # noqa: E402, F403 - -from ..components.lists import * # noqa: E402, F403 -from ..components.base import QBaseWindow # noqa: E402 -from ..components.progress import ( # noqa: E402 - LoadingCircleAnimation, - NoneWidget, - ProgressBar, - ProgressBarWithETA, - QLogConsole, -) - -from .canvas import ( - LabelItem, - sliderWithSpinBox, -) -from .toolbars import ( - ToolBar, - rightClickToolButton, -) - -class QDialogListbox(QDialog): - sigSelectionConfirmed = Signal(list) - - def __init__( - self, - title, - text, - items, - cancelText="Cancel", - multiSelection=True, - parent=None, - additionalButtons=(), - includeSelectionHelp=False, - allowSingleSelection=True, - preSelectedItems=None, - allowEmptySelection=True, - ): - self.cancel = True - items = list(items) - - super().__init__(parent) - self.setWindowTitle(title) - - if preSelectedItems is None: - if items: - preSelectedItems = (items[0],) - else: - preSelectedItems = set() - - self.allowSingleSelection = allowSingleSelection - self.allowEmptySelection = allowEmptySelection - - mainLayout = QVBoxLayout() - topLayout = QVBoxLayout() - bottomLayout = QHBoxLayout() - - self.mainLayout = mainLayout - - label = QLabel(text) - _font = QFont() - _font.setPixelSize(13) - label.setFont(_font) - # padding: top, left, bottom, right - label.setStyleSheet("padding:0px 0px 3px 0px;") - topLayout.addWidget(label, alignment=Qt.AlignCenter) - - if includeSelectionHelp: - selectionHelpLabel = QLabel() - txt = html_utils.paragraph("""
    - Ctrl+Click to select multiple items
    - Shift+Click to select a range of items
    - """) - selectionHelpLabel.setText(txt) - topLayout.addWidget(label, alignment=Qt.AlignCenter) - - listBox = listWidget() - listBox.setFont(_font) - listBox.addItems(items) - if multiSelection: - listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - else: - listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) - listBox.setCurrentRow(0) - for i in range(listBox.count()): - item = listBox.item(i) - item.setSelected(item.text() in preSelectedItems) - - self.listBox = listBox - if not multiSelection: - listBox.itemDoubleClicked.connect(self.ok_cb) - topLayout.addWidget(listBox) - - if cancelText.lower().find("cancel") != -1: - cancelButton = cancelPushButton(cancelText) - else: - cancelButton = QPushButton(cancelText) - okButton = okPushButton(" Ok ") - - bottomLayout.addStretch(1) - bottomLayout.addWidget(cancelButton) - bottomLayout.addSpacing(20) - - if additionalButtons: - self._additionalButtons = [] - for button in additionalButtons: - if isinstance(button, str): - _button, isCancelButton = getPushButton(button) - self._additionalButtons.append(_button) - bottomLayout.addWidget(_button) - _button.clicked.connect(self.ok_cb) - else: - bottomLayout.addWidget(button) - - bottomLayout.addWidget(okButton) - bottomLayout.setContentsMargins(0, 10, 0, 0) - - mainLayout.addLayout(topLayout) - mainLayout.addLayout(bottomLayout) - self.setLayout(mainLayout) - - # Connect events - okButton.clicked.connect(self.ok_cb) - cancelButton.clicked.connect(self.cancel_cb) - - if multiSelection: - listBox.itemClicked.connect(self.onItemClicked) - listBox.itemSelectionChanged.connect(self.onItemSelectionChanged) - - self.setStyleSheet(LISTWIDGET_STYLESHEET) - self.areItemsSelected = [ - listBox.item(i).isSelected() for i in range(listBox.count()) - ] - self.setFont(font) - - def keyPressEvent(self, event) -> None: - mod = event.modifiers() - if mod == Qt.ShiftModifier or mod == Qt.ControlModifier: - self.listBox.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - elif event.key() == Qt.Key_Escape: - self.listBox.clearSelection() - event.ignore() - return - super().keyPressEvent(event) - - def onItemSelectionChanged(self): - if not self.listBox.selectedItems(): - self.areItemsSelected = [False for i in range(self.listBox.count())] - - def onItemClicked(self, item): - mod = QGuiApplication.keyboardModifiers() - if mod == Qt.ShiftModifier or mod == Qt.ControlModifier: - self.listBox.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - return - - self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) - itemIdx = self.listBox.row(item) - wasSelected = self.areItemsSelected[itemIdx] - if wasSelected: - item.setSelected(False) - - self.areItemsSelected = [ - self.listBox.item(i).isSelected() for i in range(self.listBox.count()) - ] - # self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - # else: - # selectedItems.append(item) - - # self.listBox.clearSelection() - # for i in range(self.listBox.count()): - # item = self.listBox.item(i).setSelected(True) - - # print(self.listBox.selectedItems()) - - def setSelectedItems(self, itemsTexts): - for i in range(self.listBox.count()): - item = self.listBox.item(i) - if item.text() in itemsTexts: - item.setSelected(True) - self.listBox.update() - - def warnSelectionEmpty(self): - msg = myMessageBox(wrapText=False, showCentered=False) - txt = html_utils.paragraph( - "You need to select at least one item!.

    " - "Use Ctrl+Click to select multiple items
    " - "or Shift+Click to select a range of items" - ) - msg.warning(self, "Selection cannot be empty!", txt) - - def ok_cb(self, checked=False): - self.clickedButton = self.sender() - self.cancel = False - selectedItems = self.listBox.selectedItems() - self.selectedItemsText = [item.text() for item in selectedItems] - if not self.allowSingleSelection and len(self.selectedItemsText) < 2: - msg = myMessageBox(wrapText=False, showCentered=False) - txt = html_utils.paragraph( - "You need to select two or more items.

    " - "Use Ctrl+Click to select multiple items
    , or
    " - "Shift+Click to select a range of items" - ) - msg.warning(self, "Select two or more items", txt) - return - - if not self.allowEmptySelection and not self.selectedItemsText: - self.warnSelectionEmpty() - return - - self.sigSelectionConfirmed.emit(self.selectedItemsText) - self.close() - - def cancel_cb(self, event): - self.cancel = True - self.selectedItemsText = None - self.close() - - def exec_(self): - self.show(block=True) - - def show(self, block=False): - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - super().show() - - horizontal_sb = self.listBox.horizontalScrollBar() - while horizontal_sb.isVisible(): - self.resize(self.height(), self.width() + 10) - - if block: - self.loop = QEventLoop() - self.loop.exec_() - - def closeEvent(self, event): - if hasattr(self, "loop"): - self.loop.exit() - - -class ExpandableListBox(QComboBox): - def __init__(self, parent=None, centered=True) -> None: - super().__init__(parent) - - self.setEditable(True) - self.lineEdit().setReadOnly(True) - - infoTxt = html_utils.paragraph( - "Select Positions to save

    " - "Ctrl+Click to select multiple items
    " - "Shift+Click to select a range of items
    ", - center=True, - ) - - self.listW = QDialogListbox( - "Select Positions to save", infoTxt, [], multiSelection=True, parent=self - ) - - self.listW.listBox.itemClicked.connect(self.listItemClicked) - self.listW.sigSelectionConfirmed.connect(self.updateCombobox) - - self.centered = centered - - def listItemClicked(self, item): - if item.text().find("All") == -1: - return - - for i in range(self.listW.listBox.count()): - _item = self.listW.listBox.item(i) - _item.setSelected(True) - - def clear(self) -> None: - self.listW.listBox.clear() - return super().clear() - - def setItems(self, items): - self.clear() - self.addItems(items) - - def addItems(self, items): - super().addItems(items) - self.listW.listBox.addItems(items) - self.listW.listBox.setCurrentRow(self.currentIndex()) - self.listItemClicked(self.listW.listBox.currentItem()) - if self.centered: - self.centerItems() - - def updateCombobox(self, selectedItemsText): - isAllItem = [i for i, t in enumerate(selectedItemsText) if t.find("All") != -1] - if len(selectedItemsText) == 1: - self.setCurrentText(selectedItemsText[0]) - elif isAllItem: - idx = isAllItem[0] - self.setCurrentText(selectedItemsText[idx]) - else: - super().clear() - super().addItems(["Custom selection"]) - - def centerItems(self, idx=None): - self.lineEdit().setAlignment(Qt.AlignCenter) - - def selectedItems(self): - return self.listW.listBox.selectedItems() - - def selectedItemsText(self): - return [item.text() for item in self.selectedItems()] - - def showPopup(self) -> None: - self.listW.show() - - -class QClickableLabel(QLabel): - clicked = Signal(object) - - def __init__(self, parent=None): - self._parent = parent - super().__init__(parent) - self._checkableItem = None - - def setCheckableItem(self, widget): - self._checkableItem = widget - - def mousePressEvent(self, event): - self.clicked.emit(self) - if self._checkableItem is not None: - status = not self._checkableItem.isChecked() - self._checkableItem.setChecked(status) - - def setChecked(self, checked): - self._checkableItem.setChecked(checked) - - -class QCenteredComboBox(QComboBox): - def __init__(self, parent=None) -> None: - super().__init__(parent) - - self.setEditable(True) - self.lineEdit().setReadOnly(True) - self.lineEdit().setAlignment(Qt.AlignCenter) - self.lineEdit().installEventFilter(self) - - self.currentIndexChanged.connect(self.centerItems) - - self._isPopupVisibile = False - - def centerItems(self, idx): - for i in range(self.count()): - self.setItemData(i, Qt.AlignCenter, Qt.TextAlignmentRole) - - def eventFilter(self, lineEdit, event): - # Reimplement show popup on click - if event.type() == QEvent.Type.MouseButtonPress and self.isEnabled(): - if self._isPopupVisibile: - self.hidePopup() - self._isPopupVisibile = False - else: - self.showPopup() - self._isPopupVisibile = True - return True - return False - - -class AlphaNumericComboBox(QCenteredComboBox): - def __init__(self, parent=None) -> None: - super().__init__(parent=parent) - - def addItems(self, items): - self._dtype = type(items[0]) - super().addItems([str(item) for item in items]) - - def setCurrentValue(self, value): - super().setCurrentText(str(value)) - - def currentValue(self): - return self._dtype(super().currentText()) - - -class statusBarPermanentLabel(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - - self.rightLabel = QLabel("") - self.leftLabel = QLabel("") - - layout = QHBoxLayout() - layout.addWidget(self.leftLabel) - layout.addStretch(10) - layout.addWidget(self.rightLabel) - - self.setLayout(layout) - - -class listWidget(QListWidget): - def __init__( - self, *args, isMultipleSelection=False, minimizeHeight=False, **kwargs - ): - super().__init__(*args, **kwargs) - self.itemHeight = None - self.setStyleSheet(LISTWIDGET_STYLESHEET) - self.setFont(font) - if isMultipleSelection: - self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - - self.minimizeHeight = minimizeHeight - - def setSelectedAll(self, selected): - for i in range(self.count()): - self.item(i).setSelected(selected) - - def setSelectedItems(self, itemsText): - for i in range(self.count()): - item = self.item(i) - item.setSelected(item.text() in itemsText) - - def addItems(self, labels) -> None: - super().addItems(labels) - if self.itemHeight is not None: - self.setItemHeight() - - if self.minimizeHeight: - itemHeight = self.sizeHintForRow(0) - self.setMaximumHeight(itemHeight * self.count() + itemHeight * 2) - - def addItem(self, text): - super().addItem(text) - if self.itemHeight is None: - return - self.setItemHeight() - - def setItemHeight(self, height=40): - self.itemHeight = height - for i in range(self.count()): - item = self.item(i) - item.setSizeHint(QSize(0, height)) - - def selectedItemsText(self): - return [item.text() for item in self.selectedItems()] - - -class OrderableListWidget(QWidget): - sigEnterEvent = Signal(object) - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self._labels = [] - - def setParentItem(self, item): - self._item = item - - def setLabelsColor(self, selected): - if selected: - stylesheet = "color : black" - else: - stylesheet = "" - - for label in self._labels: - label.setStyleSheet(stylesheet) - - def enterEvent(self, event): - super().enterEvent(event) - self.setLabelsColor(True) - self.sigEnterEvent.emit(self._item) - - # def leaveEvent(self, event): - # super().leaveEvent(event) - # self.setLabelsColor(self._item.isSelected()) - # printl('leave', self._item.isSelected()) - - def addLabel(self, label): - self._labels.append(label) - self.validPattern = r"^[0-9,\.]+$" - regExp = QRegularExpression(self.validPattern) - self.setValidator(QRegularExpressionValidator(regExp)) - - def values(self): - try: - vals = [float(c) for c in self.text().split(",")] - except Exception as e: - vals = [] - return vals - - -class mySpinBox(QSpinBox): - sigTabEvent = Signal(object, object) - - def __init__(self, *args) -> None: - super().__init__(*args) - - def event(self, event): - if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key_Tab: - self.sigTabEvent.emit(event, self) - return True - - return super().event(event) - - -class KeptObjectIDsList(list): - def __init__(self, lineEdit, confirmSelectionAction, *args): - self.lineEdit = lineEdit - self.lineEdit.setText("") - self.confirmSelectionAction = confirmSelectionAction - confirmSelectionAction.setDisabled(True) - super().__init__(*args) - - def setText(self): - text = utils.format_IDs(self) - - self.lineEdit.setText(text) - - def append(self, element, editText=True): - super().append(element) - if editText: - self.setText() - if not self.confirmSelectionAction.isEnabled(): - self.confirmSelectionAction.setEnabled(True) - - def remove(self, element, editText=True): - super().remove(element) - if editText: - self.setText() - if not self: - self.confirmSelectionAction.setEnabled(False) - - -class myMessageBox(_base_widgets.QBaseDialog): - def __init__( - self, - parent=None, - showCentered=True, - wrapText=True, - scrollableText=False, - enlargeWidthFactor=0, - resizeButtons=True, - allowClose=True, - ): - super().__init__(parent) - - self.wrapText = wrapText - self.enlargeWidthFactor = enlargeWidthFactor - self.resizeButtons = resizeButtons - - self.cancel = True - self.cancelButton = None - self.okButton = None - self.clickedButton = None - self.alreadyShown = False - self.allowClose = allowClose - - self.showCentered = showCentered - - self.scrollableText = scrollableText - - self._layout = QGridLayout() - self.commandsLayout = None - self._layout.setHorizontalSpacing(20) - self.buttonsLayout = QHBoxLayout() - self.buttonsLayout.setSpacing(2) - self.buttons = [] - self.widgets = [] - self.layouts = [] - self.labels = [] - self.labelsWidgets = [] - self._pixmapLabels = [] - self.detailsTextWidget = None - self.showInFileManagButton = None - self.visibleDetails = False - self.doNotShowAgainCheckbox = None - - self.currentRow = 0 - self.textWidget = None - self._w = None - - self.textLayout = QVBoxLayout() - - self._layout.setColumnStretch(1, 1) - self.setLayout(self._layout) - - self.setFont(font) - - def mousePressEvent(self, event): - for label in self.labels: - label.setTextInteractionFlags( - Qt.TextBrowserInteraction | Qt.TextSelectableByKeyboard - ) - - def setIcon(self, iconName="SP_MessageBoxInformation"): - label = QLabel(self) - - standardIcon = getattr(QStyle, iconName) - icon = self.style().standardIcon(standardIcon) - pixmap = icon.pixmap(60, 60) - label.setPixmap(pixmap) - - self._layout.addWidget(label, 0, 0, alignment=Qt.AlignTop) - - def addImage(self, image_path): - pixmap = QPixmap(image_path) - label = QLabel() - label.setPixmap(pixmap) - self._layout.addWidget(label, self.currentRow, 1) - self.currentRow += 1 - - def addShowInFileManagerButton(self, path, txt=None): - if txt is None: - txt = "Reveal in Finder..." if is_mac else "Show in Explorer..." - self.showInFileManagButton = showInFileManagerButton(txt) - self.buttonsLayout.addWidget(self.showInFileManagButton) - func = partial(utils.showInExplorer, path) - self.showInFileManagButton.clicked.connect(func) - - def addBrowseUrlButton(self, url, button_text=""): - self.openUrlButton = OpenUrlButton(url, button_text) - self.buttonsLayout.addWidget(self.openUrlButton) - - def addCancelButton(self, button=None, connect=False): - if button is None: - self.cancelButton = cancelPushButton("Cancel") - else: - self.cancelButton = button - self.cancelButton.setIcon(QIcon(":cancelButton.svg")) - - self.buttonsLayout.insertWidget(0, self.cancelButton) - self.buttonsLayout.insertSpacing(1, 20) - if connect: - self.cancelButton.clicked.connect(self.buttonCallBack) - - def splitLatexBlocks(self, text): - texts = re.split(r"(.+?)
    ", text) - return texts - - def splitCopiableBlocks(self, texts: Sequence[str] | str): - if isinstance(texts, str): - texts = (texts,) - - texts_out = [] - for text in texts: - texts_out.extend(re.split(r"(.+?)", text)) - return texts_out - - def addText(self, text): - texts = self.splitLatexBlocks(text) - texts = self.splitCopiableBlocks(texts) - - labelsWidget = LabelsWidget(texts, wrapText=self.wrapText) - self.labelsWidgets.append(labelsWidget) - self.labels.extend(labelsWidget.labels) - if self.scrollableText: - textWidget = QScrollArea() - textWidget.setFrameStyle(QFrame.Shape.NoFrame) - textWidget.setWidget(labelsWidget) - else: - textWidget = labelsWidget - - self.textLayout.addWidget(textWidget) - - if self.textWidget is None: - self.textWidget = QWidget() - self.textWidget.setLayout(self.textLayout) - self._layout.addWidget(self.textWidget, self.currentRow, 1) - self.textRow = self.currentRow - self.currentRow += 1 - - return labelsWidget - - def addCopiableCommand(self, command): - copiableCommandWidget = CopiableCommandWidget(command) - screenWidth = self.screen().size().width() - maxWidth = int(0.75 * screenWidth) - sizeHint = copiableCommandWidget.sizeHint() - width = sizeHint.width() - if width > maxWidth: - copiableCommandWidget = addWidgetToScrollArea( - copiableCommandWidget, resizeMinHeightNoVerticalScrollbar=True - ) - self._layout.addWidget(copiableCommandWidget, self.currentRow, 1) - self.currentRow += 1 - - def copyToClipboard(self): - cb = QApplication.clipboard() - cb.clear(mode=cb.Clipboard) - cb.setText(self.sender()._command, mode=cb.Clipboard) - print("Command copied!") - - def addButton(self, buttonText): - if not isinstance(buttonText, str): - # Passing button directly - button = buttonText - self.buttonsLayout.addWidget(button) - button.clicked.connect(self.buttonCallBack) - self.buttons.append(button) - return button - - button, isCancelButton = getPushButton(buttonText, qparent=self) - if not isCancelButton: - self.buttonsLayout.addWidget(button) - - button.clicked.connect(self.buttonCallBack) - self.buttons.append(button) - return button - - def addDoNotShowAgainCheckbox(self, text="Do not show again"): - self.doNotShowAgainCheckbox = QCheckBox(text) - - def addWidget(self, widget): - self._layout.addWidget(widget, self.currentRow, 1) - self.widgets.append(widget) - self.currentRow += 1 - - def addLayout(self, layout): - self._layout.addLayout(layout, self.currentRow, 1) - self.layouts.append(layout) - self.currentRow += 1 - - def setWidth(self, w): - self._w = w - - def show(self, block=False): - self.endOfScrollableRow = self.currentRow - - self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) - # spacer - spacer = QSpacerItem(10, 10) - self._layout.addItem(spacer, self.currentRow, 1) - self._layout.setRowStretch(self.currentRow, 0) - - # buttons - self.currentRow += 1 - - if self.detailsTextWidget is not None: - self.buttonsLayout.insertWidget(1, self.detailsButton) - - # Do not show again checkbox - if self.doNotShowAgainCheckbox is not None: - self._layout.addWidget( - self.doNotShowAgainCheckbox, self.currentRow, 1, 1, 2 - ) - self.currentRow += 1 - - # spacer - self._layout.addItem(QSpacerItem(10, 10), self.currentRow, 1) - self.currentRow += 1 - - # buttons - self._layout.addLayout( - self.buttonsLayout, self.currentRow, 0, 1, 2, alignment=Qt.AlignRight - ) - - # Details - if self.detailsTextWidget is not None: - # spacer - self.currentRow += 1 - self._layout.addItem(QSpacerItem(20, 20), self.currentRow, 1) - - # detailsTextWidget - self.currentRow += 1 - self._layout.addWidget(self.detailsTextWidget, self.currentRow, 0, 1, 2) - - # spacer - self.currentRow += 1 - spacer = QSpacerItem(10, 10) - self._layout.addItem(spacer, self.currentRow, 1) - self._layout.setRowStretch(self.currentRow, 0) - - screenHeight = self.screen().size().height() - dialogHeight = self.sizeHint().height() - dialogWidth = self.sizeHint().width() - screenWidth = self.screen().size().width() - - # Check if scrollbar is needed - if dialogHeight > screenHeight and self.textWidget is not None: - textScrollArea = ScrollArea() - textScrollArea.setWidget(self.textWidget) - scrollAreaWidthNoSB = textScrollArea.minimumWidthNoScrollbar() - scrollAreaWidth = textScrollArea.sizeHint().width() - desiredDeltaWidth = scrollAreaWidthNoSB - scrollAreaWidth - if desiredDeltaWidth > 0: - desiredWidth = dialogWidth + desiredDeltaWidth - if desiredWidth < screenWidth: - self._w = desiredWidth - - self._layout.removeWidget(self.textWidget) - self._layout.addWidget(textScrollArea, self.textRow, 1) - - super().show() - QTimer.singleShot(5, self._resize) - - self.alreadyShown = True - - if block: - self._block() - - def setDetailedText(self, text, visible=False, wrap=True): - text = text.replace("\n", "
    ") - self.detailsTextWidget = QTextEdit(text) - self.detailsTextWidget.setReadOnly(True) - if not wrap: - self.detailsTextWidget.setLineWrapMode(QTextEdit.NoWrap) - self.detailsButton = showDetailsButton() - self.detailsButton.setCheckable(True) - self.detailsButton.clicked.connect(self._showDetails) - self.detailsTextWidget.hide() - self.visibleDetails = visible - - def _showDetails(self, checked): - if checked: - self.origHeight = self.height() - self.resize(self.width(), self.height() + 300) - self.detailsTextWidget.show() - else: - self.detailsTextWidget.hide() - func = partial(self.resize, self.width(), self.origHeight) - QTimer.singleShot(10, func) - - def _resize(self): - if self.resizeButtons: - widths = [button.width() for button in self.buttons] - if widths: - max_width = max(widths) - for button in self.buttons: - if button == self.cancelButton: - continue - button.setMinimumWidth(max_width) - - heights = [button.height() for button in self.buttons] - if heights: - max_h = max(heights) - for button in self.buttons: - button.setMinimumHeight(max_h) - if self.detailsTextWidget is not None: - self.detailsButton.setMinimumHeight(max_h) - if self.showInFileManagButton is not None: - self.showInFileManagButton.setMinimumHeight(max_h) - - if self._w is not None and self.width() < self._w: - self.resize(self._w, self.height()) - - if self.width() < 350: - self.resize(350, self.height()) - - if self.enlargeWidthFactor > 0: - self.resize(int(self.width() * self.enlargeWidthFactor), self.height()) - - if self.visibleDetails: - self.detailsButton.click() - - if self.showCentered: - screen = self.screen() - screenWidth = screen.size().width() - screenHeight = screen.size().height() - screenLeft = screen.geometry().x() - screenTop = screen.geometry().y() - w, h = self.width(), self.height() - left = int(screenLeft + screenWidth / 2 - w / 2) - top = int(screenTop + screenHeight / 2 - h / 2) - if top < screenTop: - top = screenTop - if left < screenLeft: - left = screenLeft - self.move(left, top) - - self._h = self.height() - - if self.okButton is not None: - self.okButton.setFocus() - - screen = self.screen() - screenWidth = screen.size().width() - screenHeight = screen.size().height() - - # Check Force wrap Text - for labelWidget in self.labelsWidgets: - textWidth = labelWidget.width() - if not textWidth > screenWidth - 10: - continue - factor = np.ceil(textWidth / screenWidth) - lineLength = int(labelWidget.nCharsLongestLine / factor) - for label in labelWidget.labels: - if isinstance(label, CopiableCommandWidget): - continue - - text = label.text() - chunks = textwrap.wrap(text, lineLength) - text = "
    ".join(chunks) - label.setText(text) - - QTimer.singleShot(100, self._resizeWrappedText) - - if self.widgets: - return - - if self.layouts: - return - - # # Start resizing height every 1 ms - # self.resizeCallsCount = 0 - # self.timer = QTimer() - # from config import warningHandler - # warningHandler.sigGeometryWarning.connect(self.timer.stop) - # self.timer.timeout.connect(self._resizeHeight) - # self.timer.start(1) - - def _resizeWrappedText(self): - screenWidth = self.screen().size().width() - 5 - self.resize(screenWidth, self.height()) - screenLeft = self.screen().geometry().left() - self.move(screenLeft, self.geometry().top()) - - def _resizeHeight(self): - try: - # Resize until a "Unable to set geometry" warning is captured - # by copnfig.warningHandler._resizeWarningHandler or # - # height doesn't change anymore - self.resize(self.width(), self.height() - 1) - if self.height() == self._h or self.resizeCallsCount > 100: - self.timer.stop() - return - - self.resizeCallsCount += 1 - self._h = self.height() - except Exception as e: - # traceback.format_exc() - self.timer.stop() - - def _template( - self, - parent, - title, - message, - detailsText=None, - buttonsTexts=None, - layouts=None, - widgets=None, - commands=None, - path_to_browse=None, - browse_button_text=None, - url_to_open=None, - open_url_button_text="Open url", - image_paths=None, - wrapDetails=True, - add_do_not_show_again_checkbox=False, - ): - if parent is not None: - self.setParent(parent) - self.setWindowTitle(title) - self.addText(message) - if commands is not None: - if isinstance(commands, str): - commands = (commands,) - for command in commands: - self.addCopiableCommand(command) - - if image_paths is not None: - if isinstance(image_paths, str): - image_paths = (image_paths,) - for image_path in image_paths: - self.addImage(image_path) - - if layouts is not None: - if utils.is_iterable(layouts): - for layout in layouts: - self.addLayout(layout) - else: - self.addLayout(layout) - - if widgets is not None: - self._layout.addItem(QSpacerItem(20, 20), self.currentRow, 1) - self.currentRow += 1 - if utils.is_iterable(widgets): - for widget in widgets: - self.addWidget(widget) - else: - self.addWidget(widgets) - - if path_to_browse is not None: - self.addShowInFileManagerButton(path_to_browse, txt=browse_button_text) - - if url_to_open is not None: - self.addBrowseUrlButton(url_to_open, button_text=open_url_button_text) - - buttons = [] - if buttonsTexts is None: - okButton = self.addButton(" Ok ") - buttons.append(okButton) - elif isinstance(buttonsTexts, str): - button = self.addButton(buttonsTexts) - buttons.append(button) - else: - for buttonText in buttonsTexts: - button = self.addButton(buttonText) - buttons.append(button) - - if detailsText is not None: - self.setDetailedText(detailsText, visible=True, wrap=wrapDetails) - - if add_do_not_show_again_checkbox: - self.addDoNotShowAgainCheckbox() - - return buttons - - def critical(self, *args, showDialog=True, **kwargs): - self.setIcon(iconName="SP_MessageBoxCritical") - buttons = self._template(*args, **kwargs) - if showDialog: - self.exec_() - return buttons - - def information(self, *args, showDialog=True, **kwargs): - self.setIcon(iconName="SP_MessageBoxInformation") - buttons = self._template(*args, **kwargs) - if showDialog: - self.exec_() - return buttons - - def warning(self, *args, showDialog=True, **kwargs): - self.setIcon(iconName="SP_MessageBoxWarning") - buttons = self._template(*args, **kwargs) - if showDialog: - self.exec_() - return buttons - - def question(self, *args, showDialog=True, **kwargs): - self.setIcon(iconName="SP_MessageBoxQuestion") - buttons = self._template(*args, **kwargs) - if showDialog: - self.exec_() - return buttons - - def _block(self): - self.loop = QEventLoop() - self.loop.exec_() - - def exec_(self): - self.show(block=True) - - def clickButtonFromText(self, buttonText): - for button in self.buttons: - if button.text() == buttonText: - button.click() - return - - def buttonCallBack(self, checked=True): - self.clickedButton = self.sender() - if self.clickedButton != self.cancelButton: - self.cancel = False - self.allowClose = True - self.close() - - def closeEvent(self, event): - if not self.allowClose: - event.ignore() - return - super().closeEvent(event) - - -def macShortcutToWindows(shortcut: str): - if shortcut is None: - return - - s = ( - shortcut.replace("Control", "Meta") - .replace("Option", "Alt") - .replace("Command", "Ctrl") - ) - return s - - -def windowsShortcutToMac(shortcut: str): - if shortcut is None: - return - - if not is_mac: - return shortcut - - s = ( - shortcut.replace("Meta", "Control") - .replace("Alt", "Option") - .replace("Ctrl", "Command") - ) - return s - - -class ManualTrackingToolBar(ToolBar): - sigIDchanged = Signal(int) - sigDisableGhost = Signal() - sigClearGhostContour = Signal() - sigClearGhostMask = Signal() - sigGhostOpacityChanged = Signal(int) - - def __init__(self, *args) -> None: - super().__init__(*args) - self.spinboxID = self.addSpinBox(label="ID to track: ") - self.spinboxID.setMinimum(1) - - self.addSeparator() - - self.showGhostCheckbox = QCheckBox("Show ghost object") - self.showGhostCheckbox.setChecked(True) - self.addWidget(self.showGhostCheckbox) - - self.ghostContourRadiobutton = QRadioButton("Contour") - self.ghostMaskRadiobutton = QRadioButton("Mask ; ") - self.ghostMaskRadiobutton.setChecked(True) - self.addWidget(self.ghostContourRadiobutton) - self.addWidget(self.ghostMaskRadiobutton) - - self.ghostMaskOpacitySpinbox = self.addSpinBox("Mask opacity: ") - self.ghostMaskOpacitySpinbox.setMaximum(100) - self.ghostMaskOpacitySpinbox.setValue(30) - - self.showGhostCheckbox.toggled.connect(self.showGhostCheckboxToggled) - self.ghostContourRadiobutton.toggled.connect( - self.ghostContourRadiobuttonToggled - ) - self.spinboxID.valueChanged.connect(self.IDchanged) - - self.ghostMaskOpacitySpinbox.valueChanged.connect(self.ghostOpacityValueChanged) - - self.addSeparator() - - self.infoLabel = QLabel("") - self.addWidget(self.infoLabel) - - def showInfo(self, text): - text = html_utils.paragraph(text, font_color="black") - self.infoLabel.setText(text) - - def showWarning(self, text): - text = html_utils.paragraph(f"WARNING: {text}", font_color="red") - self.infoLabel.setText(text) - - def clearInfoText(self): - self.infoLabel.setText("") - - def IDchanged(self, value): - self.sigIDchanged.emit(value) - - def showGhostCheckboxToggled(self, checked): - disabled = not checked - self.ghostContourRadiobutton.setDisabled(disabled) - self.ghostMaskRadiobutton.setDisabled(disabled) - self.ghostMaskOpacitySpinbox.setDisabled(disabled) - self.ghostMaskOpacitySpinbox.label.setDisabled(disabled) - if disabled: - self.sigDisableGhost.emit() - - def ghostContourRadiobuttonToggled(self, checked): - self.ghostMaskOpacitySpinbox.setDisabled(checked) - self.ghostMaskOpacitySpinbox.label.setDisabled(checked) - if checked: - self.sigClearGhostMask.emit() - else: - self.sigClearGhostContour.emit() - - def ghostOpacityValueChanged(self, value): - self.sigGhostOpacityChanged.emit(value) - - -class ManualBackgroundToolBar(ToolBar): - sigIDchanged = Signal(int) - - def __init__(self, *args) -> None: - super().__init__(*args) - self.spinboxID = self.addSpinBox(label="Set background of ID ") - self.spinboxID.setMinimum(1) - self.spinboxID.valueChanged.connect(self.IDchanged) - - self.infoLabel = QLabel("") - self.addWidget(self.infoLabel) - - def IDchanged(self, value): - self.sigIDchanged.emit(value) - - def showWarning(self, text): - text = html_utils.paragraph(f"WARNING: {text}", font_color="red") - self.infoLabel.setText(text) - - def clearInfoText(self): - self.infoLabel.setText("") - - -class SavePointsLayerButton(rightClickToolButton): - sigRenameTableAction = Signal(object, str) - - def __init__(self, table_endname, parent=None): - super().__init__(parent=parent) - self.setIcon(QIcon(":file-save.svg")) - - self.table_endname = table_endname - - self.setToolTip( - "Save annotated points in the CSV file ending " - f"with '{self.table_endname}.csv'" - ) - - self.sigRightClick.connect(self.showContextMenu) - - def showContextMenu(self, event): - contextMenu = QMenu(self) - contextMenu.addSeparator() - - renameAction = QAction("Rename points layer table") - renameAction.triggered.connect(self.renameTable) - contextMenu.addAction(renameAction) - - contextMenu.exec(event.globalPos()) - - def renameTable(self): - win = apps.filenameDialog( - parent=self, - title="Rename points layer table file", - allowEmpty=False, - defaultEntry=self.table_endname, - ext=".csv", - ) - win.exec_() - if win.cancel: - return - - self.table_endname = win.entryText - self.setToolTip( - "Save annotated points in the CSV file ending " - f"with '{self.table_endname}.csv'" - ) - self.sigRenameTableAction.emit(self, self.table_endname) - - -class Toggle(QCheckBox): - def __init__( - self, - label_text="", - initial=None, - width=80, - bg_color="#b3b3b3", - circle_color="#ffffff", - active_color="#26dd66", # '#005ce6', - animation_curve=QEasingCurve.Type.InOutQuad, - ): - QCheckBox.__init__(self) - - # self.setFixedSize(width, 28) - self.setCursor(Qt.PointingHandCursor) - - self._label_text = label_text - self._bg_color = bg_color - self._circle_color = circle_color - self._active_color = active_color - self._disabled_active_color = colors.lighten_color(active_color) - self._disabled_circle_color = colors.lighten_color(circle_color) - self._disabled_bg_color = colors.lighten_color(bg_color, amount=0.5) - self._circle_margin = 4 - - self._circle_position = int(self._circle_margin / 2) - self.animation = QPropertyAnimation(self, b"circle_position", self) - self.animation.setEasingCurve(animation_curve) - self.animation.setDuration(200) - - self.stateChanged.connect(self.start_transition) - self.requestedState = None - - self.installEventFilter(self) - self._isChecked = False - - if initial is not None: - self.setChecked(initial) - - def sizeHint(self): - return QSize(36, 18) - - def eventFilter(self, object, event): - # To get the actual position of the circle we need to wait that - # the widget is visible before setting the state - if event.type() == QEvent.Type.Show and self.requestedState is not None: - self.setChecked(self.requestedState) - return False - - def setChecked(self, state): - # To get the actual position of the circle we need to wait that - # the widget is visible before setting the state - self._isChecked = state - if self.isVisible(): - self.requestedState = None - QCheckBox.setChecked(self, state > 0) - else: - self.requestedState = state - - def isChecked(self): - if self.isVisible(): - return super().isChecked() - else: - return self._isChecked - - def circlePos(self, state: bool): - start = int(self._circle_margin / 2) - if state: - if self.isVisible(): - height, width = self.height(), self.width() - else: - sizeHint = self.sizeHint() - height, width = sizeHint.height(), sizeHint.width() - circle_diameter = height - self._circle_margin - pos = width - start - circle_diameter - else: - pos = start - return pos - - @Property(float) - def circle_position(self): - return self._circle_position - - @circle_position.setter - def circle_position(self, pos): - self._circle_position = pos - self.update() - - def start_transition(self, state): - self.animation.stop() - pos = self.circlePos(state) - self.animation.setEndValue(pos) - self.animation.start() - - def hitButton(self, pos: QPoint): - return self.contentsRect().contains(pos) - - def setDisabled(self, disable): - QCheckBox.setDisabled(self, disable) - if hasattr(self, "label"): - self.label.setDisabled(disable) - self.update() - - def paintEvent(self, e): - circle_color = ( - self._circle_color if self.isEnabled() else self._disabled_circle_color - ) - active_color = ( - self._active_color if self.isEnabled() else self._disabled_active_color - ) - unchecked_color = ( - self._bg_color if self.isEnabled() else self._disabled_bg_color - ) - - # set painter - p = QPainter(self) - p.setRenderHint(QPainter.RenderHint.Antialiasing) - - # set no pen - p.setPen(Qt.NoPen) - - # draw rectangle - rect = QRect(0, 0, self.width(), self.height()) - - if not self.isChecked(): - # Draw background - p.setBrush(QColor(unchecked_color)) - half_h = int(self.height() / 2) - p.drawRoundedRect(0, 0, rect.width(), self.height(), half_h, half_h) - - # Draw circle - p.setBrush(QColor(circle_color)) - p.drawEllipse( - int(self._circle_position), - int(self._circle_margin / 2), - self.height() - self._circle_margin, - self.height() - self._circle_margin, - ) - else: - # Draw background - p.setBrush(QColor(active_color)) - half_h = int(self.height() / 2) - p.drawRoundedRect(0, 0, rect.width(), self.height(), half_h, half_h) - - # Draw circle - p.setBrush(QColor(circle_color)) - p.drawEllipse( - int(self._circle_position), - int(self._circle_margin / 2), - self.height() - self._circle_margin, - self.height() - self._circle_margin, - ) - - p.end() - - -def QKeyEventToString(event: QKeyEvent, notAllowedModifier=None): - isAltKey = event.key() == Qt.Key_Alt - isCtrlKey = event.key() == Qt.Key_Control - isShiftKey = event.key() == Qt.Key_Shift - isModifierKey = isAltKey or isCtrlKey or isShiftKey - - modifiers = event.modifiers() - isNotAllowedMod = notAllowedModifier is not None and modifiers == notAllowedModifier - if isNotAllowedMod: - return - - modifers_value = modifiers.value if PYQT6 else modifiers - if isModifierKey: - keySequenceText = KeySequenceFromText(modifers_value).toString() - else: - keySequenceText = QKeySequence(modifers_value | event.key()).toString() - - keySequenceText = keySequenceText.encode("ascii", "ignore").decode("utf-8") - - return keySequenceText - - -class ShortcutLineEdit(QLineEdit): - def __init__(self, parent=None, allowModifiers=False, notAllowedModifier=None): - self.keySequence = None - super().__init__(parent) - self._allowModifiers = allowModifiers - self._notAllowedModifier = notAllowedModifier - self.setAlignment(Qt.AlignCenter) - - def text(self): - text = macShortcutToWindows(super().text()) - - return text - - def setText(self, text): - text = windowsShortcutToMac(text) - - super().setText(text) - if not text: - self.keySequence = None - return - try: - self.keySequence = KeySequenceFromText(self.text()) - except Exception as e: - pass - - def keyPressEvent(self, event: QKeyEvent): - if event.key() == Qt.Key_Backspace or event.key() == Qt.Key_Delete: - self.setText("") - return - - keySequenceText = QKeyEventToString( - event, notAllowedModifier=self._notAllowedModifier - ) - self.setText(keySequenceText) - self.key = event.key() - - def keyReleaseEvent(self, event: QKeyEvent) -> None: - if self.text().endswith("+"): - if not self._allowModifiers: - self.setText("") - else: - self.setText(self.text().rstrip("+").strip()) - - -class selectStartStopFrames(QGroupBox): - def __init__(self, SizeT, currentFrameNum=0, parent=None): - super().__init__(parent) - selectFramesLayout = QGridLayout() - - self.startFrame_SB = QSpinBox() - self.startFrame_SB.setAlignment(Qt.AlignCenter) - self.startFrame_SB.setMinimum(1) - self.startFrame_SB.setMaximum(SizeT - 1) - self.startFrame_SB.setValue(currentFrameNum) - - self.stopFrame_SB = QSpinBox() - self.stopFrame_SB.setAlignment(Qt.AlignCenter) - self.stopFrame_SB.setMinimum(1) - self.stopFrame_SB.setMaximum(SizeT) - self.stopFrame_SB.setValue(SizeT) - - selectFramesLayout.addWidget(QLabel("Start frame n."), 0, 0) - selectFramesLayout.addWidget(self.startFrame_SB, 1, 0) - - selectFramesLayout.addWidget(QLabel("Stop frame n."), 0, 1) - selectFramesLayout.addWidget(self.stopFrame_SB, 1, 1) - - self.warningLabel = QLabel() - palette = self.warningLabel.palette() - palette.setColor(self.warningLabel.backgroundRole(), Qt.red) - palette.setColor(self.warningLabel.foregroundRole(), Qt.red) - self.warningLabel.setPalette(palette) - selectFramesLayout.addWidget( - self.warningLabel, 2, 0, 1, 2, alignment=Qt.AlignCenter - ) - - self.setLayout(selectFramesLayout) - - self.stopFrame_SB.valueChanged.connect(self._checkRange) - - def _checkRange(self): - start = self.startFrame_SB.value() - stop = self.stopFrame_SB.value() - if stop <= start: - self.warningLabel.setText("stop frame smaller than start frame") - else: - self.warningLabel.setText("") - - -class formWidget(QWidget): - sigApplyButtonClicked = Signal(object) - sigComputeButtonClicked = Signal(object) - - def __init__( - self, - widget, - initialVal=None, - stretchWidget=True, - widgetAlignment=None, - labelTextLeft="", - labelTextRight="", - font=None, - addInfoButton=False, - addApplyButton=False, - addComputeButton=False, - addActivateCheckbox=False, - key="", - infoTxt="", - valueGetterName="value", - toolTip="", - parent=None, - ): - QWidget.__init__(self, parent) - self.widget = widget - self.key = key - self.infoTxt = infoTxt - self.widgetAlignment = widgetAlignment - self.valueGetterName = valueGetterName - - widget.setParent(self) - - if isinstance(initialVal, bool): - widget.setChecked(initialVal) - elif isinstance(initialVal, str): - widget.setCurrentText(initialVal) - elif isinstance(initialVal, float) or isinstance(initialVal, int): - widget.setValue(initialVal) - - self.items = [] - - if font is None: - font = QFont() - font.setPixelSize(13) - - self.labelLeft = QClickableLabel(widget) - self.labelLeft.setText(labelTextLeft) - self.labelLeft.setFont(font) - self.items.append(self.labelLeft) - - if not stretchWidget: - widgetLayout = QHBoxLayout() - if widgetAlignment != "left": - widgetLayout.addStretch(1) - widgetLayout.addWidget(widget) - if widgetAlignment != "right": - widgetLayout.addStretch(1) - self.items.append(widgetLayout) - else: - self.items.append(widget) - - self.labelRight = QClickableLabel(widget) - self.labelRight.setText(labelTextRight) - self.labelRight.setFont(font) - self.items.append(self.labelRight) - - if toolTip: - self.labelLeft.setToolTip(toolTip) - self.widget.setToolTip(toolTip) - self.labelRight.setToolTip(toolTip) - - if addInfoButton: - infoButton = QPushButton(self) - infoButton.setCursor(Qt.WhatsThisCursor) - infoButton.setIcon(QIcon(":info.svg")) - if labelTextLeft: - infoButton.setToolTip(f'Info about "{self.labelLeft.text()}" parameter') - else: - infoButton.setToolTip( - f'Info about "{self.labelRight.text()}" measurement' - ) - infoButton.clicked.connect(self.showInfo) - self.infoButton = infoButton - self.items.append(infoButton) - - if addApplyButton: - applyButton = QPushButton(self) - applyButton.setCursor(Qt.PointingHandCursor) - applyButton.setCheckable(True) - applyButton.setIcon(QIcon(":apply.svg")) - applyButton.setToolTip(f"Apply this step and visualize results") - applyButton.clicked.connect(self.applyButtonClicked) - self.items.append(applyButton) - - if addComputeButton: - computeButton = QPushButton(self) - computeButton.setCursor(Qt.BusyCursor) - computeButton.setIcon(QIcon(":compute.svg")) - computeButton.setToolTip(f"Compute this step and visualize results") - computeButton.clicked.connect(self.computeButtonClicked) - self.items.append(computeButton) - - self.activateCheckbox = None - if addActivateCheckbox: - self.activateCheckbox = QCheckBox("Activate") - self.activateCheckbox.setChecked(False) - self.widget.setDisabled(True) - self.activateCheckbox.toggled.connect(self.setWidgetEnabled) - self.items.append(self.activateCheckbox) - - self.labelLeft.clicked.connect(self.tryChecking) - self.labelRight.clicked.connect(self.tryChecking) - - def setWidgetEnabled(self, checked): - self.widget.setDisabled(not checked) - - def value(self): - if self.activateCheckbox is None: - return getattr(self.widget, self.valueGetterName)() - - if not self.activateCheckbox.isChecked(): - return - - return getattr(self.widget, self.valueGetterName)() - - def tryChecking(self, label): - try: - self.widget.setChecked(not self.widget.isChecked()) - except AttributeError as e: - pass - - def applyButtonClicked(self): - self.sigApplyButtonClicked.emit(self) - - def computeButtonClicked(self): - self.sigComputeButtonClicked.emit(self) - - def showInfo(self): - msg = myMessageBox() - msg.setIcon() - msg.setWindowTitle(f"{self.labelLeft.text()} info") - msg.addText(self.infoTxt) - msg.addButton(" Ok ") - msg.exec_() - - def setDisabled(self, disabled: bool) -> None: - for item in self.items: - try: - item.setDisabled(disabled) - except Exception as err: - pass - - -class ToggleTerminalButton(PushButton): - sigClicked = Signal(bool) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setIcon(QIcon(":terminal_up.svg")) - self.setFixedSize(34, 18) - self.setIconSize(QSize(30, 14)) - self.setFlat(True) - self.terminalVisible = False - self.clicked.connect(self.mouseClick) - - def mouseClick(self): - if self.terminalVisible: - self.setIcon(QIcon(":terminal_up.svg")) - self.terminalVisible = False - else: - self.setIcon(QIcon(":terminal_down.svg")) - self.terminalVisible = True - self.sigClicked.emit(self.terminalVisible) - - def showEvent(self, a0) -> None: - self.idlePalette = self.palette() - return super().showEvent(a0) - - def enterEvent(self, event) -> None: - self.setFlat(False) - # pal = self.palette() - # pal.setColor(QPalette.ColorRole.Button, QColor(200, 200, 200)) - # self.setAutoFillBackground(True) - # self.setPalette(pal) - self.update() - return super().enterEvent(event) - - def leaveEvent(self, event) -> None: - self.setFlat(True) - # self.setPalette(self.idlePalette) - self.update() - return super().leaveEvent(event) - - -class CenteredDoubleSpinbox(QDoubleSpinBox): - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31 - 1) - - -class readOnlyDoubleSpinbox(QDoubleSpinBox): - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setReadOnly(True) - self.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) - self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31 - 1) - - -class readOnlySpinbox(QSpinBox): - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setReadOnly(True) - self.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) - self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31 - 1) - - -class DoubleSpinBox(QDoubleSpinBox): - sigValueChanged = Signal(int) - - def __init__(self, parent=None, disableKeyPress=False): - super().__init__(parent=parent) - self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31 - 1) - self.setMinimum(-(2**31)) - self._valueChangedFunction = None - self.disableKeyPress = disableKeyPress - - def keyPressEvent(self, event) -> None: - isBackSpaceKey = event.key() == Qt.Key_Backspace - isDeleteKey = event.key() == Qt.Key_Delete - try: - int(event.text()) - isIntegerKey = True - except: - isIntegerKey = False - acceptEvent = isBackSpaceKey or isDeleteKey or isIntegerKey - if self.disableKeyPress and not acceptEvent: - event.ignore() - self.clearFocus() - else: - super().keyPressEvent(event) - - def textFromValue(self, value: float) -> str: - text = super().textFromValue(value) - return text.replace(QLocale().decimalPoint(), ".") - - def valueFromText(self, text: str) -> float: - text = text.replace(".", QLocale().decimalPoint()) - return super().valueFromText(text) - - -class SpinBox(QSpinBox): - sigValueChanged = Signal(int) - sigUpClicked = Signal() - sigDownClicked = Signal() - - def __init__(self, parent=None, disableKeyPress=False, allowNegative=True): - super().__init__(parent=parent) - self.setAlignment(Qt.AlignCenter) - self.setMaximum(2**31 - 1) - if allowNegative: - self.setMinimum(-(2**31)) - else: - self.setMinimum(0) - self._valueChangedFunction = None - self.disableKeyPress = disableKeyPress - self._linkedWidget = None - - def mousePressEvent(self, event) -> None: - super().mousePressEvent(event) - opt = QStyleOptionSpinBox() - self.initStyleOption(opt) - - control = self.style().hitTestComplexControl( - QStyle.ComplexControl.CC_SpinBox, opt, event.pos(), self - ) - if control == QStyle.SubControl.SC_SpinBoxUp: - self.sigUpClicked.emit() - elif control == QStyle.SubControl.SC_SpinBoxDown: - self.sigDownClicked.emit() - - # def focusOutEvent(self, event): - # self.editingFinished.emit() - # super().focusOutEvent(event) - # printl('emitted') - - def keyPressEvent(self, event) -> None: - isBackSpaceKey = event.key() == Qt.Key_Backspace - isDeleteKey = event.key() == Qt.Key_Delete - try: - int(event.text()) - isIntegerKey = True - except: - isIntegerKey = False - acceptEvent = isBackSpaceKey or isDeleteKey or isIntegerKey - if self.disableKeyPress and not acceptEvent: - event.ignore() - self.clearFocus() - else: - super().keyPressEvent(event) - - def connectValueChanged(self, function): - self._valueChangedFunction = function - self.valueChanged.connect(function) - - def setValue(self, value, setLinkedWidget=True): - super().setValue(int(value)) - if self._linkedWidget is not None and setLinkedWidget: - self._linkedWidget.setValue(value) - - def setValueNoEmit(self, value): - if self._valueChangedFunction is None: - self.setValue(value) - return - try: - self.valueChanged.disconnect() - except TypeError as e: # this fails if its not cennected yet - pass - - self.setValue(value) - self.valueChanged.connect(self._valueChangedFunction) - - def wheelEvent(self, event): - event.ignore() - - def setLinkedValueWidget(self, widget): - self._linkedWidget = widget - - -class ReadOnlyLineEdit(QLineEdit): - def __init__(self, parent=None): - super().__init__(parent=parent) - self.setReadOnly(True) - # self.setStyleSheet( - # 'background-color: rgba(240, 240, 240, 200);' - # ) - self.installEventFilter(self) - - def eventFilter(self, a0: "QObject", a1: "QEvent") -> bool: - if a1.type() == QEvent.Type.FocusIn: - return True - return super().eventFilter(a0, a1) - - def setValue(self, value): - self.setText(str(value)) - - def value(self, casting_func: callable = None): - text = self.text() - if casting_func is not None: - return casting_func(text) - return text - - -class FloatLineEdit(QLineEdit): - valueChanged = Signal(float) - - def __init__( - self, - *args, - notAllowed=None, - allowNegative=True, - initial=None, - readOnly=False, - decimals=6, - warningValues=None, - ): - QLineEdit.__init__(self, *args) - if readOnly: - self.setReadOnly(readOnly) - self.notAllowed = notAllowed - self.warningValues = warningValues - self._maximum = np.inf - self._minimum = -np.inf - self._decimals = decimals - - self.isNumericRegExp = rf"^{float_regex(allow_negative=allowNegative)}$" - regExp = QRegularExpression(self.isNumericRegExp) - self.setValidator(QRegularExpressionValidator(regExp)) - self.setAlignment(Qt.AlignCenter) - - font = QFont() - font.setPixelSize(11) - self.setFont(font) - - self.textChanged.connect(self.emitValueChanged) - - if initial is not None: - self.setValue(initial) - else: - self.setValue(0) - - def setDecimals(self, decimals): - self._decimals = 6 - - def castMinMax(self, value: int): - if value > self._maximum: - value = self._maximum - if value < self._minimum: - value = self._minimum - return value - - def setValue(self, value: float): - value = self.castMinMax(value) - self.setText(str(round(value, self._decimals))) - - def value(self): - m = re.match(self.isNumericRegExp, self.text()) - if m is not None: - text = m.group(0) - try: - val = float(text) - except ValueError: - val = 0.0 - else: - val = 0.0 - - return self.castMinMax(val) - - def setMaximum(self, maximum): - self._maximum = maximum - self.setValue(self.value()) - - def setMinimum(self, minimum): - self._minimum = minimum - self.setValue(self.value()) - - def emitValueChanged(self, text): - val = self.value() - reset_stylesheet = True - if self.warningValues is not None and val in self.warningValues: - self.setStyleSheet(LINEEDIT_WARNING_STYLESHEET) - reset_stylesheet = False - - if self.notAllowed is not None and val in self.notAllowed: - self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) - reset_stylesheet = False - else: - self.valueChanged.emit(self.value()) - - if reset_stylesheet: - self.setStyleSheet("") - - -class IntLineEdit(QLineEdit): - valueChanged = Signal(float) - - def __init__( - self, *args, notAllowed=None, allowNegative=True, initial=None, readOnly=False - ): - QLineEdit.__init__(self, *args) - self.notAllowed = notAllowed - if readOnly: - self.setReadOnly(readOnly) - - self._maximum = np.inf - self._minimum = -np.inf - - self._regExp = r"\d+" - if allowNegative: - self._regExp = r"-?\d+" - - regExp = QRegularExpression(self._regExp) - self.setValidator(QRegularExpressionValidator(regExp)) - self.setAlignment(Qt.AlignCenter) - - font = QFont() - font.setPixelSize(11) - self.setFont(font) - - self.textChanged.connect(self.emitValueChanged) - - if initial is not None: - self.setValue(initial) - else: - self.setValue(0) - - def setMaximum(self, maximum): - self._maximum = maximum - self.setValue(self.value()) - - def setMinimum(self, minimum): - self._minimum = minimum - self.setValue(self.value()) - - def castMinMax(self, value: int): - if value > self._maximum: - value = self._maximum - if value < self._minimum: - value = self._minimum - return value - - def setValue(self, value: int): - value = self.castMinMax(value) - self.setText(str(value)) - - def value(self): - m = re.match(self._regExp, self.text()) - if m is not None: - text = m.group(0) - try: - val = int(text) - except ValueError: - val = 0 - else: - val = 0 - - return self.castMinMax(val) - - def emitValueChanged(self, text): - if not text: - return - - val = self.value() - self.setValue(val) - if self.notAllowed is not None and val in self.notAllowed: - self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) - else: - self.setStyleSheet("") - self.valueChanged.emit(self.value()) - - -class CheckboxesGroupBox(QGroupBox): - def __init__(self, texts, title="", checkable=False, parent=None): - super().__init__(parent) - - self.setTitle(title) - self.setCheckable(checkable) - layout = QVBoxLayout() - - scrollLayout = QVBoxLayout() - container = QWidget() - scrollarea = QScrollArea() - - self.checkBoxes = [] - for text in texts: - checkbox = QCheckBox(text) - checkbox.setChecked(True) - scrollLayout.addWidget(checkbox) - self.checkBoxes.append(checkbox) - - container.setLayout(scrollLayout) - scrollarea.setWidget(container) - layout.addWidget(scrollarea) - - buttonsLayout = QHBoxLayout() - selectAllButton = selectAllPushButton() - selectAllButton.sigClicked.connect(self.checkAll) - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(selectAllButton) - layout.addLayout(buttonsLayout) - - self.setLayout(layout) - - def checkAll(self, button, checked): - for checkBox in self.checkBoxes: - checkBox.setChecked(checked) - - -class _metricsQGBox(QGroupBox): - sigDelClicked = Signal(str, object) - - def __init__( - self, - desc_dict, - title, - favourite_funcs=None, - isZstack=False, - equations=None, - addDelButton=False, - delButtonMetricsDesc=None, - parent=None, - addCalcForEachZsliceToggle=False, - ): - QGroupBox.__init__(self, parent) - - highlightRgba = _palettes._highlight_rgba() - r, g, b, a = highlightRgba - self._highlightStylesheetColor = f"rgb({r}, {g}, {b})" - - self._parent = parent - self.scrollArea = QScrollArea() - self.scrollAreaWidget = QWidget() - self.favourite_funcs = favourite_funcs - - self.doNotWarn = False - - layout = QVBoxLayout() - inner_layout = QVBoxLayout() - self.inner_layout = inner_layout - if delButtonMetricsDesc is None: - delButtonMetricsDesc = [] - - self.checkBoxes = [] - self.checkedState = {} - for metric_colname, metric_desc in desc_dict.items(): - rowLayout = QHBoxLayout() - - checkBox = QCheckBox(metric_colname) - checkBox.setChecked(True) - checkBox.scrollArea = self.scrollArea - self.checkBoxes.append(checkBox) - self.checkedState[checkBox] = True - - try: - checkBox.equation = equations[metric_colname] - except Exception as e: - pass - - if addDelButton or metric_colname in delButtonMetricsDesc: - delButton = delPushButton() - delButton.setToolTip("Delete custom combined measurement") - delButton.colname = metric_colname - delButton.checkbox = checkBox - delButton.clicked.connect(self.onDelClicked) - delButton._layout = rowLayout - rowLayout.addWidget(delButton) - - infoButton = infoPushButton() - infoButton.setCursor(Qt.WhatsThisCursor) - infoButton.info = metric_desc - infoButton.colname = metric_colname - infoButton.clicked.connect(self.showInfo) - - rowLayout.addWidget(infoButton) - rowLayout.addWidget(checkBox) - rowLayout.addStretch(1) - - inner_layout.addLayout(rowLayout) - - self.scrollAreaWidget.setLayout(inner_layout) - self.scrollArea.setWidget(self.scrollAreaWidget) - layout.addWidget(self.scrollArea) - - buttonsLayout = QHBoxLayout() - - buttonsLayout.addStretch(1) - - self.selectAllButton = selectAllPushButton() - self.selectAllButton.sigClicked.connect(self.checkAll) - - buttonsLayout.addWidget(self.selectAllButton) - - if favourite_funcs is not None: - self.loadFavouritesButton = reloadPushButton(" Load last selection... ") - self.loadFavouritesButton.clicked.connect(self.checkFavouriteFuncs) - # self.checkFavouriteFuncs() - buttonsLayout.addWidget(self.loadFavouritesButton) - - layout.addLayout(buttonsLayout) - - self.calcForEachZsliceToggle = None - if addCalcForEachZsliceToggle: - buttonsLayout = QHBoxLayout() - self.calcForEachZsliceToggle = Toggle() - tooltip = ( - "Calculate `cell_area` for each z-slice.\n\n" - "The measurements will be saved in the column with name\n" - "ending with `_zsliceN` where N is the z-slice number\n" - "(starting from 0)." - ) - calcForEachZsliceLabel = QClickableLabel("Calculate for each z-slice") - calcForEachZsliceLabel.setToolTip(tooltip) - self.calcForEachZsliceToggle.setToolTip(tooltip) - buttonsLayout.addWidget(self.calcForEachZsliceToggle) - buttonsLayout.addWidget(calcForEachZsliceLabel) - buttonsLayout.addStretch(1) - layout.addLayout(buttonsLayout) - calcForEachZsliceLabel.clicked.connect( - partial( - self.toggleCalcForEachZslice, toggle=self.calcForEachZsliceToggle - ) - ) - - self.setTitle(title) - self.setCheckable(True) - self.setLayout(layout) - _font = QFont() - _font.setPixelSize(11) - self.setFont(_font) - - self.toggled.connect(self.toggled_cb) - - def toggleCalcForEachZslice(self, label, toggle=None): - if toggle is None: - toggle = self.calcForEachZsliceToggle - - toggle.setChecked(not toggle.isChecked()) - - def isCalcForEachZsliceRequested(self): - if self.calcForEachZsliceToggle is None: - return False - - return self.calcForEachZsliceToggle.isChecked() - - def highlightCheckboxesFromSearchText(self, text): - for checkbox in self.checkBoxes: - if not text: - highlighted = False - else: - highlighted = checkbox.text().lower().find(text.lower()) != -1 - - self.setCheckboxHighlighted(highlighted, checkbox) - - def setCheckboxHighlighted(self, highlighted, checkbox): - if highlighted: - checkbox.setStyleSheet( - f"background: {self._highlightStylesheetColor}; color: black" - ) - self.scrollArea.ensureWidgetVisible(checkbox) - else: - checkbox.setStyleSheet("") - - def onDelClicked(self): - button = self.sender() - button.checkbox.setChecked(False) - self.sigDelClicked.emit(button.colname, button._layout) - - def toggled_cb(self, checked): - for checkbox in self.checkBoxes: - if not checked: - self.checkedState[checkbox] = checkbox.isChecked() - checkbox.setChecked(False) - else: - checkbox.setChecked(self.checkedState[checkbox]) - - def checkFavouriteFuncs(self, checked=True, isZstack=False): - self.doNotWarn = True - if self._parent is not None: - self._parent.doNotWarn = True - for checkBox in self.checkBoxes: - checkBox.setChecked(False) - for favourite_func in self.favourite_funcs: - func_name = checkBox.text() - if func_name.endswith(favourite_func): - checkBox.setChecked(True) - break - self.doNotWarn = False - if self._parent is not None: - self._parent.doNotWarn = False - - def checkAll(self, button, checked): - if self._parent is not None: - self._parent.doNotWarn = True - for checkBox in self.checkBoxes: - checkBox.setChecked(checked) - if self._parent is not None: - self._parent.doNotWarn = False - - def showInfo(self, checked=False): - info_txt = self.sender().info - msg = myMessageBox() - msg.setWidth(600) - msg.setIcon() - msg.setWindowTitle(f"{self.sender().colname} info") - msg.addText(info_txt) - msg.addButton(" Ok ") - msg.exec_() - - def show(self): - super().show() - fw = self.inner_layout.contentsRect().width() - sw = self.scrollArea.verticalScrollBar().sizeHint().width() - self.minWidth = fw + sw - - -class channelMetricsQGBox(QGroupBox): - sigDelClicked = Signal(str, object) - sigCheckboxToggled = Signal(object) - - def __init__( - self, - isZstack, - chName, - isSegm3D, - is_concat=False, - posData=None, - favourite_funcs=None, - ): - QGroupBox.__init__(self) - - self.doNotWarn = False - self.is_concat = is_concat - isManualBackgrPresent = False - if posData is not None: - if posData.manualBackgroundLab is not None: - isManualBackgrPresent = True - - layout = QVBoxLayout() - metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc( - isZstack, - chName, - isSegm3D=isSegm3D, - isManualBackgrPresent=isManualBackgrPresent, - ) - - metricsQGBox = _metricsQGBox( - metrics_desc, - "Standard measurements", - favourite_funcs=favourite_funcs, - parent=self, - isZstack=isZstack, - ) - self.metricsQGBox = metricsQGBox - - bkgrValsQGBox = _metricsQGBox( - bkgr_val_desc, - "Background values", - favourite_funcs=favourite_funcs, - parent=self, - isZstack=isZstack, - ) - self.bkgrValsQGBox = bkgrValsQGBox - - self.checkBoxes = metricsQGBox.checkBoxes.copy() - self.checkBoxes.extend(bkgrValsQGBox.checkBoxes) - - self.uncheckAndDisableDataPrepIfPosNotPrepped(posData) - - self.groupboxes = [metricsQGBox, bkgrValsQGBox] - - for checkbox in metricsQGBox.checkBoxes: - checkbox.toggled.connect(self.standardMetricToggled) - self.standardMetricToggled(checkbox.isChecked(), checkbox=checkbox) - - for bkgrCheckbox in bkgrValsQGBox.checkBoxes: - bkgrCheckbox.toggled.connect(self.backgroundMetricToggled) - - layout.addWidget(metricsQGBox) - layout.addWidget(bkgrValsQGBox) - - items = measurements.custom_metrics_desc( - isZstack, chName, posData=posData, isSegm3D=isSegm3D, return_combine=True - ) - custom_metrics_desc, combine_metrics_desc = items - - if custom_metrics_desc: - customMetricsQGBox = _metricsQGBox( - custom_metrics_desc, - "Custom measurements", - delButtonMetricsDesc=combine_metrics_desc, - favourite_funcs=favourite_funcs, - isZstack=isZstack, - ) - layout.addWidget(customMetricsQGBox) - self.checkBoxes.extend(customMetricsQGBox.checkBoxes) - customMetricsQGBox.sigDelClicked.connect(self.onDelClicked) - self.customMetricsQGBox = customMetricsQGBox - - self.calcForEachZsliceToggle = None - if isZstack: - buttonsLayout = QHBoxLayout() - self.calcForEachZsliceToggle = Toggle() - tooltip = ( - "Calculate the selected measurements for each z-slice.\n\n" - "The measurements will be saved in the column with name\n" - "ending with `_zsliceN` where N is the z-slice number\n" - "(starting from 0)." - ) - calcForEachZsliceLabel = QClickableLabel("Calculate for each z-slice") - calcForEachZsliceLabel.setToolTip(tooltip) - self.calcForEachZsliceToggle.setToolTip(tooltip) - buttonsLayout.addWidget(self.calcForEachZsliceToggle) - buttonsLayout.addWidget(calcForEachZsliceLabel) - buttonsLayout.addStretch(1) - layout.addLayout(buttonsLayout) - calcForEachZsliceLabel.clicked.connect( - partial( - self.toggleCalcForEachZslice, toggle=self.calcForEachZsliceToggle - ) - ) - - self.setTitle(f"{chName} metrics") - self.setCheckable(True) - self.setLayout(layout) - - def toggleCalcForEachZslice(self, label, toggle=None): - if toggle is None: - toggle = self.calcForEachZsliceToggle - - toggle.setChecked(not toggle.isChecked()) - - def isCalcForEachZsliceRequested(self): - if self.calcForEachZsliceToggle is None: - return False - - return self.calcForEachZsliceToggle.isChecked() - - def uncheckAndDisableDataPrepIfPosNotPrepped(self, posData): - # Uncheck and disable dataprep metrics if pos is not prepped - if posData is None: - return - - if posData.isBkgrROIpresent(): - return - - for checkbox in self.checkBoxes: - if checkbox.text().find("dataPrep") == -1: - continue - - checkbox.setChecked(False) - checkbox.isDataPrepDisabled = True - - def _warnDataPrepCannotBeChecked(self): - if self.doNotWarn: - return - txt = html_utils.paragraph(""" - Data prep measurements cannot be saved because you did - not select any background ROI at the data prep step.

    - - You can read more details about data prep metrics by clicking - on the info button besides the measurement's name.

    - - Thank you for you patience! - """) - msg = myMessageBox(showCentered=False) - msg.warning(self, "Metric cannot be saved", txt) - - def standardMetricToggled(self, checked, checkbox=None): - """Method called when a check-box is toggled. It performs the following - actions: - 1. If the user try to check a data prep measurement, such as - dataPrep_amount, and this cannot be saved (checkbox has the attr - `isDataPrepDisabled`) then it warns and explains why it cannot be saved - 2. Make sure that background value median is checked if the user - requires amount or concentration metric. - 3. Do not allow unchecking background value median and explain why. - - Parameters - ---------- - checked : bool - State of the checkbox toggled - checkbox : QtWidgets.QCheckBox, optional - The checkbox that has been toggled. Default is None. If None - use `self.sender()` - """ - if self.is_concat: - return - - if checkbox is None: - checkbox = self.sender() - - if hasattr(checkbox, "isDataPrepDisabled"): - # Warn that user cannot check data prep metrics and uncheck it - if not checkbox.isChecked(): - return - checkbox.setChecked(False) - self._warnDataPrepCannotBeChecked() - return - - self.sigCheckboxToggled.emit(checkbox) - if checkbox.text().find("amount_") == -1: - return - pattern = r"amount_([A-Za-z]+)(_?[A-Za-z0-9]*)" - repl = r"\g<1>_bkgrVal_median\g<2>" - bkgrValMetric = s1 = re.sub(pattern, repl, checkbox.text()) - for bkgrCheckbox in self.groupboxes[1].checkBoxes: - if bkgrCheckbox.text() == bkgrValMetric: - break - else: - # Make sure to not check for similarly named custom metrics - return - - if checked: - bkgrCheckbox.setChecked(True) - bkgrCheckbox.isRequired = True - else: - bkgrCheckbox.setDisabled(False) - bkgrCheckbox.isRequired = False - - def backgroundMetricToggled(self, checked): - """Method called when a checkbox of a background metric is toggled. - Check if the background value is required and explain why it cannot be - unchecked. - - Parameters - ---------- - checked : bool - State of the checkbox toggled - """ - if self.is_concat: - return - - checkbox = self.sender() - if not hasattr(checkbox, "isRequired"): - return - - if not checkbox.isRequired: - return - - if checkbox.isChecked(): - return - - if self.doNotWarn: - return - - checkbox.setChecked(True) - txt = html_utils.paragraph(""" - This background value cannot be unchecked because it is required - by the _amount and _concentration measurements - that you requested to save.

    - - Thank you for you patience! - """) - msg = myMessageBox(showCentered=False) - msg.warning(self, "Background value required", txt) - - def onDelClicked(self, colname_to_del, hlayout): - self.sigDelClicked.emit(colname_to_del, hlayout) - - def checkFavouriteFuncs(self): - self.doNotWarn = True - for groupbox in self.groupboxes: - groupbox.checkFavouriteFuncs() - self.doNotWarn = False - - -class PixelSizeGroupbox(QGroupBox): - sigValueChanged = Signal(float, float, float) - sigReset = Signal() - - def __init__(self, parent=None): - super().__init__("Pixel size", parent) - - mainLayout = QGridLayout() - - row = 0 - label = QLabel("Pixel width (μm): ") - self.pixelWidthWidget = FloatLineEdit(initial=1.0) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.pixelWidthWidget, row, 1) - - row += 1 - label = QLabel("Pixel height (μm): ") - self.pixelHeightWidget = FloatLineEdit(initial=1.0) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.pixelHeightWidget, row, 1) - - row += 1 - label = QLabel("Voxel depth (μm): ") - self.voxelDepthWidget = FloatLineEdit(initial=1.0) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.voxelDepthWidget, row, 1) - - row += 1 - resetButton = reloadPushButton("Reset") - mainLayout.addWidget(resetButton, row, 1, alignment=Qt.AlignRight) - - row += 1 - mainLayout.addWidget(QHLine(), row, 0, 1, 2) - - mainLayout.setColumnStretch(0, 0) - mainLayout.setColumnStretch(1, 1) - - self.setLayout(mainLayout) - - self.pixelWidthWidget.valueChanged.connect(self.emitValueChanged) - self.pixelHeightWidget.valueChanged.connect(self.emitValueChanged) - self.voxelDepthWidget.valueChanged.connect(self.emitValueChanged) - resetButton.clicked.connect(self.emitReset) - - def emitReset(self): - self.sigReset.emit() - - def emitValueChanged(self, value): - PhysicalSizeX = self.pixelWidthWidget.value() - PhysicalSizeY = self.pixelHeightWidget.value() - PhysicalSizeZ = self.voxelDepthWidget.value() - self.sigValueChanged.emit(PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) - - -class objPropsQGBox(QGroupBox): - def __init__(self, parent=None): - QGroupBox.__init__(self, "Properties", parent) - - mainLayout = QGridLayout() - - row = 0 - label = QLabel("Object ID: ") - self.idSB = IntLineEdit() - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.idSB, row, 1) - - row += 1 - mainLayout.addWidget(QHLine(), row, 0, 1, 2) - - row += 1 - self.notExistingIDLabel = QLabel() - self.notExistingIDLabel.setStyleSheet("font-size:11px; color: rgb(255, 0, 0);") - mainLayout.addWidget( - self.notExistingIDLabel, row, 0, 1, 2, alignment=Qt.AlignCenter - ) - - row += 1 - label = QLabel("Area (pixel): ") - self.cellAreaPxlSB = IntLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.cellAreaPxlSB, row, 1) - - row += 1 - label = QLabel("Area (µm2): ") - self.cellAreaUm2DSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.cellAreaUm2DSB, row, 1) - - row += 1 - mainLayout.addWidget(QHLine(), row, 0, 1, 2) - - row += 1 - label = QLabel("Rotational volume (voxel): ") - self.cellVolVoxSB = IntLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.cellVolVoxSB, row, 1) - - row += 1 - label = QLabel("3D volume (voxel): ") - self.cellVolVox3D_SB = IntLineEdit(readOnly=True) - self.cellVolVox3D_SB.label = label - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.cellVolVox3D_SB, row, 1) - - row += 1 - label = QLabel("Rotational volume (fl): ") - self.cellVolFlDSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.cellVolFlDSB, row, 1) - - row += 1 - label = QLabel("3D volume (fl): ") - self.cellVolFl3D_DSB = FloatLineEdit(readOnly=True) - self.cellVolFl3D_DSB.label = label - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.cellVolFl3D_DSB, row, 1) - - row += 1 - mainLayout.addWidget(QHLine(), row, 0, 1, 2) - - row += 1 - label = QLabel("Solidity: ") - self.solidityDSB = FloatLineEdit(readOnly=True) - self.solidityDSB.setMaximum(1) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.solidityDSB, row, 1) - - row += 1 - label = QLabel("Elongation: ") - self.elongationDSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.elongationDSB, row, 1) - - row += 1 - mainLayout.addWidget(QHLine(), row, 0, 1, 2) - - row += 1 - propsNames = measurements.get_props_names()[1:] - self.additionalPropsCombobox = QComboBox() - self.additionalPropsCombobox.addItems(propsNames) - self.additionalPropsCombobox.indicator = FloatLineEdit(readOnly=True) - mainLayout.addWidget(self.additionalPropsCombobox, row, 0) - mainLayout.addWidget(self.additionalPropsCombobox.indicator, row, 1) - - row += 1 - mainLayout.addWidget(QHLine(), row, 0, 1, 2) - - mainLayout.setColumnStretch(0, 0) - mainLayout.setColumnStretch(1, 1) - - self.setLayout(mainLayout) - - -class objIntesityMeasurQGBox(QGroupBox): - def __init__(self, parent=None): - QGroupBox.__init__(self, "Intensity measurements", parent) - - mainLayout = QGridLayout() - - row = 0 - label = QLabel("Raw intensity measurements") - - row += 1 - label = QLabel("Channel: ") - self.channelCombobox = QComboBox() - self.channelCombobox.addItem("placeholderlong") - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.channelCombobox, row, 1) - - row += 1 - label = QLabel("Minimum: ") - self.minimumDSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.minimumDSB, row, 1) - - row += 1 - label = QLabel("Maximum: ") - self.maximumDSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.maximumDSB, row, 1) - - row += 1 - label = QLabel("Mean: ") - self.meanDSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.meanDSB, row, 1) - - row += 1 - label = QLabel("Median: ") - self.medianDSB = FloatLineEdit(readOnly=True) - mainLayout.addWidget(label, row, 0) - mainLayout.addWidget(self.medianDSB, row, 1) - - row += 1 - metricsDesc = measurements._get_metrics_names() - metricsFunc, _ = measurements.standard_metrics_func() - items = list(set([metricsDesc[key] for key in metricsFunc.keys()])) - items.append("Concentration") - items.sort() - nameFuncDict = {} - for name, desc in metricsDesc.items(): - if name.find("_dataPrepBkgr") != -1 or name.find("_manualBkgr") != -1: - # Skip dataPrepBkgr and manualBkgr since in the dock widget - # we display only autoBkgr metrics - continue - if name.startswith("concentration_"): - # We use amount function because dividing by volume is taken - # care in the GUI - name = "amount_autoBkgr" - nameFuncDict[desc] = metricsFunc[name] - - funcionCombobox = QComboBox() - funcionCombobox.addItems(items) - self.additionalMeasCombobox = funcionCombobox - self.additionalMeasCombobox.indicator = FloatLineEdit(readOnly=True) - self.additionalMeasCombobox.functions = nameFuncDict - mainLayout.addWidget(funcionCombobox, row, 0) - mainLayout.addWidget(self.additionalMeasCombobox.indicator, row, 1) - - self.setLayout(mainLayout) - - def addChannels(self, channels): - self.channelCombobox.clear() - self.channelCombobox.addItems(channels) - - -class guiTabControl(QTabWidget): - def __init__(self, *args): - super().__init__(args[0]) - - self._defaultPixelSize = None - - self.propsTab = QScrollArea(self) - - container = QWidget() - layout = QVBoxLayout() - - self.pixelSizeQGBox = PixelSizeGroupbox(parent=self.propsTab) - self.propsQGBox = objPropsQGBox(parent=self.propsTab) - self.intensMeasurQGBox = objIntesityMeasurQGBox(parent=self.propsTab) - - self.highlightCheckbox = QCheckBox("Highlight objects on mouse hover") - self.highlightCheckbox.setChecked(False) - - self.highlightSearchedCheckbox = QCheckBox("Highlight searched object") - self.highlightSearchedCheckbox.setChecked(True) - - highlightLayout = QHBoxLayout() - highlightLayout.addWidget(self.highlightCheckbox) - highlightLayout.addStretch(1) - highlightLayout.addWidget(QLabel("|")) - highlightLayout.addStretch(1) - highlightLayout.addWidget(self.highlightSearchedCheckbox) - - layout.addLayout(highlightLayout) - layout.addWidget(self.pixelSizeQGBox) - layout.addWidget(self.propsQGBox) - layout.addWidget(self.intensMeasurQGBox) - layout.addStretch(1) - container.setLayout(layout) - - self.propsTab.setWidgetResizable(True) - self.propsTab.setWidget(container) - self.addTab(self.propsTab, "Measurements") - - self.pixelSizeQGBox.sigValueChanged.connect(self.pixelSizeChanged) - self.pixelSizeQGBox.sigReset.connect(self.resetPixelSize) - - def addChannels(self, channels): - self.intensMeasurQGBox.addChannels(channels) - - def resetPixelSize(self): - if self._defaultPixelSize is None: - return - - self.initPixelSize(*self._defaultPixelSize) - - def initPixelSize(self, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ): - self.pixelSizeQGBox.pixelWidthWidget.setValue(PhysicalSizeX) - self.pixelSizeQGBox.pixelHeightWidget.setValue(PhysicalSizeY) - self.pixelSizeQGBox.voxelDepthWidget.setValue(PhysicalSizeZ) - self._defaultPixelSize = (PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) - - def pixelSizeChanged(self, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ): - propsQGBox = self.propsQGBox - yx_pxl_to_um2 = PhysicalSizeY * PhysicalSizeX - vox_rot_to_fl = float(PhysicalSizeY) * pow(float(PhysicalSizeX), 2) - vox_3D_to_fl = PhysicalSizeZ * PhysicalSizeY * PhysicalSizeX - - area_pxl = propsQGBox.cellAreaPxlSB.value() - area_um2 = area_pxl * yx_pxl_to_um2 - propsQGBox.cellAreaUm2DSB.setValue(area_um2) - - vol_rot_vox = propsQGBox.cellVolVoxSB.value() - vol_rot_fl = vol_rot_vox * vox_rot_to_fl - propsQGBox.cellVolFlDSB.setValue(vol_rot_fl) - - vol_3D_vox = propsQGBox.cellVolVox3D_SB.value() - vol_3D_fl = vol_3D_vox * vox_3D_to_fl - propsQGBox.cellVolFl3D_DSB.setValue(vol_3D_fl) - - -class expandCollapseButton(PushButton): - sigClicked = Signal() - - def __init__(self, parent=None, **kwargs): - super().__init__(parent, **kwargs) - self.setIcon(QIcon(":expand.svg")) - self.setFlat(True) - self.installEventFilter(self) - self.isExpand = True - self.clicked.connect(self.buttonClicked) - - def buttonClicked(self, checked=False): - if self.isExpand: - self.setIcon(QIcon(":collapse.svg")) - self.isExpand = False - if self.text(): - self.setText(self.text().replace("Hide", "Show")) - else: - self.setIcon(QIcon(":expand.svg")) - self.isExpand = True - if self.text(): - self.setText(self.text().replace("Show", "Hide")) - self.sigClicked.emit() - - def eventFilter(self, object, event): - if event.type() == QEvent.Type.HoverEnter: - self.setFlat(False) - elif event.type() == QEvent.Type.HoverLeave: - self.setFlat(True) - return False - - -class view_visualcpp_screenshot(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - layout = QHBoxLayout() - - self.setWindowTitle("Visual Studio Builld Tools installation") - - pixmap = QPixmap(":visualcpp.png") - label = QLabel() - label.setPixmap(pixmap) - - layout.addWidget(label) - self.setLayout(layout) - - -class ToggleVisibilityButton(PushButton): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setFlat(True) - # self.setCheckable(True) - self._state = False - self.setIcon(QIcon(":unchecked.svg")) - self.clicked.connect(self.onClicked) - self.setStyleSheet(""" - QPushButton::pressed { - background-color: none; - border-style: none; - } - """) - - def onClicked(self): - self._state = not self._state - if self._state: - self.setIcon(QIcon(":eye-checked.svg")) - else: - self.setIcon(QIcon(":unchecked.svg")) - - -class ToggleVisibilityCheckBox(QCheckBox): - def __init__(self, *args, pixelSize=24): - super().__init__(*args) - self._pixelSize = pixelSize - self.onToggled(False) - self.toggled.connect(self.onToggled) - - def setPixelSize(self, pixelSize): - self._pixelSize = pixelSize - - def onToggled(self, checked): - if checked: - self.setStyleSheet(f""" - QCheckBox::indicator {{ - width: {self._pixelSize}px; - height: {self._pixelSize}px; - }} - - QCheckBox::indicator:checked - {{ - image: url(:eye-checked.svg); - }} - """) - else: - self.setStyleSheet(f""" - QCheckBox::indicator {{ - width: {self._pixelSize}px; - height: {self._pixelSize}px; - }} - - QCheckBox::indicator:unchecked - {{ - image: url(:unchecked.svg); - }} - """) - - -class highlightableQWidgetAction(QWidgetAction): - def __init__(self, parent) -> None: - super().__init__(parent) - - -class PostProcessSegmSlider(sliderWithSpinBox): - def __init__(self, *args, label=None, **kwargs): - super().__init__(*args, **kwargs) - - self.label = label - self.checkbox = QCheckBox("Disable") - self._layout.addWidget(self.checkbox, self.sliderCol, self.lastCol + 1) - self.checkbox.toggled.connect(self.onCheckBoxToggled) - self.valueChanged.connect(self.checkExpandRange) - - def onCheckBoxToggled(self, checked: bool) -> None: - super().setDisabled(checked) - if self.label is not None: - self.label.setDisabled(checked) - self.onValueChanged(None) - self.onEditingFinished() - - def onValueChanged(self, value): - self.valueChanged.emit(value) - - def checkExpandRange(self, value): - if value == self.maximum(): - range = int(self.maximum() - self.minimum()) - half_range = int(range / 2) - newMinimum = self.minimum() + half_range - newMaximum = self.maximum() + half_range - self.setMaximum(newMaximum) - self.setMinimum(newMinimum) - elif value == self.minimum(): - range = int(self.maximum() - self.minimum()) - half_range = int(range / 2) - newMinimum = self.minimum() - half_range - newMaximum = self.maximum() - half_range - self.setMaximum(newMaximum) - self.setMinimum(newMinimum) - - def onEditingFinished(self): - self.editingFinished.emit() - - def value(self): - if self.checkbox.isChecked(): - return None - else: - return super().value() - - -class PostProcessSegmSpinbox(QWidget): - valueChanged = Signal(int) - editingFinished = Signal() - sigCheckboxToggled = Signal() - - def __init__(self, *args, isFloat=False, label=None, **kwargs): - super().__init__(*args, **kwargs) - - layout = QHBoxLayout() - - if isFloat: - self.spinBox = DoubleSpinBox() - else: - self.spinBox = SpinBox() - - self.spinBox.valueChanged.connect(self.onValueChanged) - self.spinBox.editingFinished.connect(self.onEditingFinished) - - layout.addWidget(self.spinBox) - self.checkbox = QCheckBox("Disable") - layout.addWidget(self.checkbox) - layout.setStretch(0, 1) - layout.setStretch(1, 0) - - self.label = label - - self.checkbox.toggled.connect(self.onCheckBoxToggled) - - layout.setContentsMargins(5, 0, 5, 0) - - self.setLayout(layout) - - def onCheckBoxToggled(self, checked: bool) -> None: - self.spinBox.setDisabled(checked) - if self.label is not None: - self.label.setDisabled(checked) - self.onValueChanged(None) - self.onEditingFinished() - - def onValueChanged(self, value): - self.valueChanged.emit(value) - - def onEditingFinished(self): - self.editingFinished.emit() - - def maximum(self): - return self.spinBox.maximum() - - def setValue(self, value): - self.spinBox.setValue(value) - - def sizeHint(self): - return self.spinBox.sizeHint() - - def setMaximum(self, max): - self.spinBox.setMaximum(max) - - def setSingleStep(self, step): - self.spinBox.setSingleStep(step) - - def setMinimum(self, min): - self.spinBox.setMinimum(min) - - def setSingleStep(self, step): - self.spinBox.setSingleStep(step) - - def setDecimals(self, decimals): - self.spinBox.setDecimals(decimals) - - def value(self): - if self.checkbox.isChecked(): - return None - else: - return self.spinBox.value() - - -class CopiableCommandWidget(QGroupBox): - def __init__(self, command="", parent=None, font_size="13px"): - super().__init__(parent) - - layout = QHBoxLayout() - - label = QLabel(self) - self.label = label - self._font_size = font_size - self.setCommand(command, font_size=font_size) - label.setTextInteractionFlags( - Qt.TextBrowserInteraction | Qt.TextSelectableByKeyboard - ) - layout.addWidget(label) - layout.addWidget(QVLine(shadow="Plain", color="#4d4d4d")) - copyButton = copyPushButton("Copy", flat=True, hoverable=True) - copyButton.clicked.connect(self.copyToClipboard) - layout.addWidget(copyButton) - layout.addStretch(1) - - self.setLayout(layout) - - def setWordWrap(self, wordWrap): - self.label.setWordWrap(wordWrap) - - def copyToClipboard(self): - cb = QApplication.clipboard() - cb.clear(mode=cb.Clipboard) - cb.setText(self._command, mode=cb.Clipboard) - print("Command copied!") - - def setCommand(self, command, font_size=None): - if font_size is None: - font_size = self._font_size - - self._command = command - txt = html_utils.paragraph(f"{command}", font_size=font_size) - self.label.setText(txt) - - def command(self): - return self._command - - def text(self): - return self.label.text() - - def setTextInteractionFlags(self, flags): - self.label.setTextInteractionFlags(flags) - - -def PostProcessSegmWidget( - minimum, maximum, value, useSliders, isFloat=False, normalize=False, label=None -): - if useSliders: - if normalize: - maximum = int(maximum * 100) - widget = PostProcessSegmSlider( - normalize=normalize, isFloat=isFloat, label=label - ) - else: - widget = PostProcessSegmSpinbox(label=label, isFloat=isFloat) - widget.setMinimum(minimum) - widget.setMaximum(maximum) - widget.setValue(value) - return widget - - -class FeatureSelectorButton(QPushButton): - def __init__(self, text, parent=None, alignment=""): - super().__init__(text, parent=parent) - self._isFeatureSet = False - self._alignment = alignment - self.setCursor(Qt.PointingHandCursor) - - def setFeatureText(self, text): - self.setText(text) - self.setFlat(True) - self._isFeatureSet = True - if self._alignment: - self.setStyleSheet(f"text-align:{self._alignment};") - - def enterEvent(self, event) -> None: - if self._isFeatureSet: - self.setFlat(False) - return super().enterEvent(event) - - def leaveEvent(self, event) -> None: - if self._isFeatureSet: - self.setFlat(True) - self.update() - return super().leaveEvent(event) - - def setSizeLongestText(self, longestText): - currentText = self.text() - self.setText(longestText) - w, h = self.sizeHint().width(), self.sizeHint().height() - self.setMinimumWidth(w + 10) - # self.setMinimumHeight(h+5) - self.setText(currentText) - - -class CheckableSpinBoxWidgets: - def __init__(self, isFloat=True): - if isFloat: - self.spinbox = FloatLineEdit() - else: - self.spinbox = SpinBox() - self.checkbox = QCheckBox("Activate") - self.spinbox.setEnabled(False) - self.checkbox.toggled.connect(self.spinbox.setEnabled) - - def value(self): - if not self.checkbox.isChecked(): - return - return self.spinbox.value() - - -class Label(QLabel): - def __init__(self, parent=None, force_html=False): - super().__init__(parent) - self._force_html = force_html - - def setText(self, text): - if self._force_html: - text = html_utils.paragraph(text) - super().setText(text) - - -class ComboBox(QComboBox): - sigTextChanged = Signal(str) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._previousText = None - self._valueChanged = False - self.currentTextChanged.connect(self.emitTextChanged) - self.installEventFilter(self) - - def eventFilter(self, object, event) -> bool: - if object == self and event.type() == QEvent.Type.Wheel: - # Forward event to parent so QScrollArea can scroll - QApplication.sendEvent(self.parent(), event) - return True # Consume for the combo itself - - return super().eventFilter(object, event) - - def text(self): - return self.currentText() - - def emitTextChanged(self, text): - self._valueChanged = True - self.sigTextChanged.emit(text) - - def mousePressEvent(self, event): - self._previousText = self.currentText() - super().mousePressEvent(event) - - def previousText(self): - return self._previousText - - def addItems(self, items): - super().addItems(items) - self._previousText = items[0] - - def itemsText(self): - return [self.itemText(i) for i in range(self.count())] - - def setCurrentIndex(self, idx): - itemsText = self.itemsText() - currentText = itemsText[idx] - self._valueChanged = currentText != self._previousText - self._previousText = self.currentText() - super().setCurrentIndex(idx) - - def setCurrentText(self, text): - currentText = text - self._valueChanged = currentText != self._previousText - self._previousText = self.currentText() - super().setCurrentText(text) - - -class SetMeasurementsGroupBox(QGroupBox): - def __init__( - self, - title, - itemsText, - checkable=True, - itemsInfo=None, - lastSelection=None, - itemsInfoUrls=None, - parent=None, - ): - super().__init__(parent) - - if itemsInfo is None: - itemsInfo = {} - - if itemsInfo is None: - itemsInfoUrls = {} - - highlightRgba = _palettes._highlight_rgba() - r, g, b, a = highlightRgba - self._highlightStylesheetColor = f"rgb({r}, {g}, {b})" - - self.setTitle(title) - self.setCheckable(checkable) - - mainLayout = QVBoxLayout() - - scrollArea = QScrollArea() - scrollArea.setWidgetResizable(True) - scrollAreaLayout = QVBoxLayout() - scrollAreaWidget = QWidget() - self.scrollAreaWidget = scrollAreaWidget - self.scrollAreaLayout = scrollAreaLayout - - self.checkboxes = {} - for text in itemsText: - rowLayout = QHBoxLayout() - infoText = itemsInfo.get(text) - infoUrl = itemsInfoUrls.get(text) - if infoText is not None or infoUrl is not None: - infoButton = infoPushButton() - infoButton.setCursor(Qt.WhatsThisCursor) - rowLayout.addWidget(infoButton) - - if infoText is not None: - infoButton.itemText = text - infoButton.infoText = infoText - infoButton.clicked.connect(self.showInfo) - - if infoUrl is not None: - infoButton.itemText = text - infoButton.infoUrl = infoUrl - infoButton.clicked.connect(self.openInfoUrl) - - checkbox = QCheckBox(text) - checkbox.setParent(self.scrollAreaWidget) - checkbox.setChecked(True) - rowLayout.addWidget(checkbox) - rowLayout.addStretch(1) - - self.checkboxes[text] = checkbox - - scrollAreaLayout.addLayout(rowLayout) - - scrollAreaLayout.addStretch(1) - - scrollAreaWidget.setLayout(scrollAreaLayout) - scrollArea.setWidget(scrollAreaWidget) - self.scrollArea = scrollArea - - buttonsLayout = QHBoxLayout() - self.selectAllButton = selectAllPushButton() - self.selectAllButton.sigClicked.connect(self.setCheckedAll) - - buttonsLayout.addStretch(1) - buttonsLayout.addWidget(self.selectAllButton) - self.buttonsLayout = buttonsLayout - - if lastSelection is not None: - self.lastSelection = lastSelection - self.loadLastSelButton = reloadPushButton(" Load last selection... ") - self.loadLastSelButton.clicked.connect(self.loadLastSelection) - buttonsLayout.addWidget(self.loadLastSelButton) - - mainLayout.addWidget(scrollArea) - mainLayout.addSpacing(10) - mainLayout.addLayout(buttonsLayout) - - self.setLayout(mainLayout) - - def openInfoUrl(self): - url = self.sender().infoUrl - QDesktopServices.openUrl(QUrl(url)) - # import webbrowser - # url = self.sender().infoUrl - # webbrowser.open(url) - - def getWidthNoScrollBarNeeded(self): - width = ( - self.scrollArea.verticalScrollBar().sizeHint().width() - # self.scrollAreaLayout.contentsRect().width() - + self.scrollAreaWidget.sizeHint().width() - + 30 - ) - buttonsWidth = 0 - for i in range(self.buttonsLayout.count()): - widget = self.buttonsLayout.itemAt(i).widget() - if not isinstance(widget, QPushButton): - continue - buttonsWidth += widget.sizeHint().width() + 16 - largerWidth = max(width, buttonsWidth) - return largerWidth - - def resizeWidthNoScrollBarNeeded(self): - width = self.getWidthNoScrollBarNeeded() - self.setMinimumWidth(width) - # self.setFixedWidth(width) - - def loadLastSelection(self): - for text, checkbox in self.checkboxes.items(): - checked = self.lastSelection.get(text, False) - checkbox.setChecked(checked) - - def showInfo(self): - infoText = self.sender().infoText - itemText = self.sender().itemText - - title = f"{itemText} description" - msg = myMessageBox() - msg.setWidth(int(self.screen().size().width() / 2)) - msg.information(self, title, infoText) - - def setCheckedAll(self, button, checked): - for checkbox in self.checkboxes.values(): - checkbox.setChecked(checked) - - def highlightCheckboxesFromSearchText(self, text): - for checkbox in self.checkboxes.values(): - if not text: - highlighted = False - else: - highlighted = checkbox.text().lower().find(text.lower()) != -1 - - self.setCheckboxHighlighted(highlighted, checkbox) - - def setCheckboxHighlighted(self, highlighted, checkbox): - if highlighted: - checkbox.setStyleSheet( - f"background: {self._highlightStylesheetColor}; color: black" - ) - self.scrollArea.ensureWidgetVisible(checkbox) - else: - checkbox.setStyleSheet("") - - -class SearchLineEdit(QLineEdit): - def __init__(self, parent=None): - super().__init__(parent) - - self.initSearch() - self.setFocusPolicy(Qt.ClickFocus) - - def focusInEvent(self, event) -> None: - super().focusInEvent(event) - if super().text() == "Search...": - self.setText("") - self.setStyleSheet("") - - def focusOutEvent(self, event) -> None: - super().focusOutEvent(event) - if not super().text(): - self.initSearch() - - def initSearch(self): - self.setText("Search...") - self.setStyleSheet("color: rgb(150, 150, 150)") - self.clearFocus() - - def text(self): - if super().text() == "Search...": - return "" - return super().text() - - -class VectorLineEdit(QLineEdit): - valueChanged = Signal(object) - valueChangeFinished = Signal(object) - - def __init__(self, parent=None, initial=None): - super().__init__(parent) - - self._minimum = -np.inf - - float_re = float_regex() - vector_regex = rf"\(?\[?{float_re}(,\s?{float_re})+\)?\]?" - regex = rf"^{vector_regex}$|^{float_re}$" - self.validRegex = regex - - regExp = QRegularExpression(regex) - self.setValidator(QRegularExpressionValidator(regExp)) - self.setAlignment(Qt.AlignCenter) - - self.textChanged.connect(self.emitValueChanged) - self.editingFinished.connect(self.emitValueChangeFinished) - if initial is None: - self.setText("0.0") - - font = QFont() - font.setPixelSize(11) - self.setFont(font) - - def emitValueChangeFinished(self): - value = self.value() - self.textChanged.disconnect() - self.editingFinished.disconnect() - self.setValue(value) - self.textChanged.connect(self.emitValueChanged) - self.editingFinished.connect(self.emitValueChangeFinished) - - self.emitValueChanged(self.text(), signal=self.valueChangeFinished) - - def emitValueChanged(self, text, signal=None): - m = re.match(self.validRegex, text) - if m is None: - self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) - return - - if signal is None: - signal = self.valueChanged - - self.setStyleSheet("") - signal.emit(self.value()) - - def increaseValue(self, step): - value = self.value() - if isinstance(value, (float, int)): - value += step - else: - value = [val + step for val in value] - value = str(value).lstrip("[").rstrip("]") - self.setValue(value) - self.emitValueChangeFinished() - - def decreaseValue(self, step): - value = self.value() - if isinstance(value, (float, int)): - value -= step - else: - value = [val - step for val in value] - value = str(value).lstrip("[").rstrip("]") - self.setText(value) - self.emitValueChangeFinished() - - def setValue(self, value): - if isinstance(value, (float, int)): - if value < self._minimum: - value = self._minimum - else: - clipped = [] - for val in value: - if val < self._minimum: - val = self._minimum - clipped.append(val) - value = str(clipped).lstrip("[").rstrip("]") - self.setText(value) - - def setText(self, text): - super().setText(str(text)) - - def clipValue(self, val: float): - if val < self._minimum: - val = self._minimum - return val - - def value(self): - m = re.match(self.validRegex, self.text()) - if m is None: - return 0.0 - - try: - value = self.clipValue(float(self.text())) - return value - except Exception as e: - text = self.text() - text = text.replace("(", "") - text = text.replace(")", "") - text = text.replace("[", "") - text = text.replace("]", "") - values = text.split(",") - return [self.clipValue(float(value)) for value in values] - - def setMinimum(self, minimum): - self._minimum = float(minimum) - - -class LatexLabel(QLabel): - def __init__(self, latexText, parent=None): - super().__init__(parent) - - latexText = latexText.replace("", "$") - if not latexText.startswith("$"): - latexText = f"${latexText}" - - if not latexText.endswith("$"): - latexText = f"{latexText}$" - - latexText = latexText.replace("
    ", "\n") - - pixmap = self.mathTex_to_QPixmap(latexText) - self.setPixmap(pixmap) - - def mathTex_to_QPixmap(self, mathTex): - # ---- set up a mpl figure instance ---- - - fig = matplotlib.figure.Figure() - fig.patch.set_facecolor("none") - fig.set_canvas(FigureCanvasAgg(fig)) - renderer = fig.canvas.get_renderer() - - # ---- plot the mathTex expression ---- - - ax = fig.add_axes([0, 0, 1, 1]) - ax.axis("off") - ax.patch.set_facecolor("none") - t = ax.text( - 0, 0, mathTex, ha="left", va="bottom", fontsize=13, color=TEXT_COLOR - ) - - # ---- fit figure size to text artist ---- - - fwidth, fheight = fig.get_size_inches() - fig_bbox = fig.get_window_extent(renderer) - - text_bbox = t.get_window_extent(renderer) - - tight_fwidth = text_bbox.width * fwidth / fig_bbox.width - tight_fheight = text_bbox.height * fheight / fig_bbox.height - - fig.set_size_inches(tight_fwidth, tight_fheight) - - # ---- convert mpl figure to QPixmap ---- - - buf, size = fig.canvas.print_to_buffer() - qimage = QImage.rgbSwapped(QImage(buf, size[0], size[1], QImage.Format_ARGB32)) - qpixmap = QPixmap(qimage) - - return qpixmap - - -class LabelsWidget(QWidget): - def __init__(self, texts, wrapText=False, parent=None): - super().__init__(parent=parent) - - layout = QVBoxLayout() - - texts = self.fixParagraphTags(texts) - - self.textLengths = [] - self.labels = [] - for t, text in enumerate(texts): - if not text: - continue - - if text.startswith(""): - layout.addSpacing(10) - label = LatexLabel(text) - layout.addWidget(label, alignment=Qt.AlignCenter) - try: - # Add spacing only if next text is not a formula - nextText = texts[t + 1] - if not nextText.startswith(""): - layout.addSpacing(10) - except IndexError: - layout.addSpacing(10) - elif text.startswith(""): - text = text.removeprefix("").removeprefix("") - label = CopiableCommandWidget(command=text, parent=self) - layout.addWidget(label) - else: - label = QLabel(text) - label.setWordWrap(wrapText) - label.setOpenExternalLinks(True) - layout.addWidget(label) - if wrapText: - self.textLengths.append(1) - self.textLengths.extend([len(line) for line in text.split("
    ")]) - - self.labels.append(label) - - self.nCharsLongestLine = max(self.textLengths, default=1) - - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - - def setWordWrap(self, wordWrap): - for label in self.labels: - label.setWordWrap(wordWrap) - - def fixParagraphTags(self, texts): - firstText = texts[0] - if firstText.find("

    ', firstText) - if searched is None: - openTag = '

    ' - else: - openTag = searched.group() - - not_allowed = {" ", "\n"} - - fixedTexts = [] - for text in texts: - if text.startswith("") or text.startswith(""): - fixedTexts.append(text) - continue - - if set(text) <= not_allowed: - # Ignore texts that are made of only \n and spaces - continue - - if text.find("

    ") == -1: - text = rf"{text}<\p>" - - if text.find(openTag) == -1: - text = f"{openTag}{text}" - - text = text.replace("\n", "") - - fixedTexts.append(text) - return fixedTexts - - -class SwitchPlaneCombobox(QComboBox): - sigPlaneChanged = Signal(str, str) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.addItems(["xy", "zy", "zx"]) - self._previousPlane = "xy" - self.currentTextChanged.connect(self.emitPlaneChanged) - - def emitPlaneChanged(self, plane): - self.sigPlaneChanged.emit(self._previousPlane, plane) - self._previousPlane = plane - - def setPlane(self, plane): - self.setCurrentText(plane) - - def setCurrentText(self, text): - self._previousPlane = self.plane() - super().setCurrentText(text) - - def plane(self): - return self.currentText() - - def depthAxes(self): - plane = self.plane() - for axes in "xyz": - if axes not in plane: - return axes - - -class SamInputPointsWidget(QWidget): - sigValueChanged = Signal(str) - - def __init__(self, parent=None): - super().__init__(parent) - - _layout = QHBoxLayout() - - self.lineEntry = ElidingLineEdit(parent=self) - self.lineEntry.setAlignment(Qt.AlignCenter) - self.lineEntry.editingFinished.connect(self.emitValueChanged) - - self.editButton = editPushButton() - self.browseButton = browseFileButton( - ext={"CSV": ".csv"}, start_dir=utils.getMostRecentPath() - ) - - _layout.addWidget(self.lineEntry) - _layout.addWidget(self.editButton) - _layout.addWidget(self.browseButton) - - _layout.setStretch(0, 1) - _layout.setStretch(1, 0) - _layout.setStretch(1, 0) - - self.browseButton.sigPathSelected.connect(self.browseCsvFiles) - self.editButton.clicked.connect(self.showInfoEditPoints) - - _layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(_layout) - - def emitValueChanged(self, text): - self.sigValueChanged.emit(text) - - def showInfoEditPoints(self): - note = html_utils.to_note( - "When adding points with the mouse left button you will create a " - "new object for each point. To add multiple points for the same " - "object click the right button." - ) - txt = html_utils.paragraph(f""" - To add input points for Segment Anything open the GUI (module 3), - load the data, and then click on the button
    - on the top toolbar called Add points layer.

    - Select the option "Add points by clicking" and click on the image - to add points.

    - Finally, save the table and browse to the saved file on this widget. -
    {note} - """) - msg = myMessageBox(wrapText=False) - msg.information(self, "Info edit points", txt) - - def criticalMissingColumn(self, filepath, missing_col): - txt = html_utils.paragraph(f""" - [ERROR]: The selected table does not contain the column - {missing_col}.

    - A valid table must contain the columns (x, y, id) - with an additional z column for 3D z-stacks data. - """) - msg = myMessageBox(wrapText=False) - msg.critical(self, "Invalid table", txt) - - def setValue(self, value: str): - self.lineEntry.setText(value) - - def value(self): - return self.lineEntry.text() - - def cast_dtype(self, value) -> str: - return str(value) - - def browseCsvFiles(self, filepath): - # Check if metadata.csv file exists with basename and set only the - # endname of the file - df_points = pd.read_csv(filepath) - for col in ("x", "y", "id"): - if col not in df_points.columns: - self.criticalMissingColumn(filepath, col) - return - - # Check if basename is present in metadata - folderpath = os.path.dirname(filepath) - basename = None - for file in utils.listdir(folderpath): - if file.endswith("metadata.csv"): - metadata_csv_path = os.path.join(folderpath, file) - df = pd.read_csv(metadata_csv_path, index_col="Description") - try: - basename = df.at["basename", "values"] - except Exception as e: - basename = None - break - - # Check if file is inside images folder and get basename - is_images_folder = folderpath.endswith("Images") - if is_images_folder: - images_path = folderpath - img_filepath = None - for file in utils.listdir(images_path): - if file.endswith(".tif"): - img_filepath = os.path.join(images_path, file) - break - - if file.endswith("aligned.npz"): - img_filepath = os.path.join(images_path, file) - break - - if img_filepath is not None: - posData = load.loadData(img_filepath, "", QParent=self) - posData.getBasenameAndChNames() - filename = os.path.basename(filepath) - if filename.startswith(posData.basename): - basename = posData.basename - - if basename is None: - self.lineEntry.setText(filepath) - else: - filename = os.path.basename(filepath) - endname = filename[len(basename) :] - self.lineEntry.setText(endname) - - -class installJavaDialog(myMessageBox): - def __init__(self, parent=None): - super().__init__(parent) - - self.setWindowTitle("Install Java") - self.setIcon("SP_MessageBoxWarning") - - txt_macOS = html_utils.paragraph(""" - Your system doesn't have the Java Development Kit - installed
    and/or a C++ compiler which is required for the installation of - javabridge

    - Cell-ACDC is now going to install Java for you.

    - NOTE: After clicking on "Install", follow the instructions
    - on the terminal
    . You will be asked to confirm steps and insert
    - your password to allow the installation.


    - If you prefer to do it manually, cancel the process
    - and follow the instructions below. - """) - - txt_windows = html_utils.paragraph(""" - Unfortunately, installing pre-compiled version of - javabridge failed.

    - Cell-ACDC is going to try to compile it now.

    - However, before proceeding, you need to install - Java Development Kit
    and a C++ compiler.

    - See instructions below on how to install it. - """) - - if not is_win: - self.instructionsButton = self.addButton("Show intructions...") - self.instructionsButton.setCheckable(True) - self.instructionsButton.disconnect() - self.instructionsButton.clicked.connect(self.showInstructions) - installButton = self.addButton("Install") - installButton.disconnect() - installButton.clicked.connect(self.installJava) - txt = txt_macOS - else: - okButton = self.addButton("Ok") - txt = txt_windows - - self.cancelButton = self.addButton("Cancel") - - label = self.addText(txt) - label.setWordWrap(False) - - self.resizeCount = 0 - - def addInstructionsWindows(self): - self.scrollArea = QScrollArea() - _container = QWidget() - _layout = QVBoxLayout() - for t, text in enumerate(utils.install_javabridge_instructions_text()): - label = QLabel() - label.setText(text) - if t == 1 or t == 2: - label.setOpenExternalLinks(True) - label.setTextInteractionFlags(Qt.TextBrowserInteraction) - code_layout = QHBoxLayout() - code_layout.addWidget(label) - copyButton = QToolButton() - copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - copyButton.setIcon(QIcon(":edit-copy.svg")) - copyButton.setText("Copy link") - if t == 1: - copyButton.textToCopy = utils.jdk_windows_url() - code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) - else: - copyButton.textToCopy = utils.cpp_windows_url() - screenshotButton = QToolButton() - screenshotButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - screenshotButton.setIcon(QIcon(":cog.svg")) - screenshotButton.setText("See screenshot") - code_layout.addWidget(screenshotButton, alignment=Qt.AlignLeft) - code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) - screenshotButton.clicked.connect(self.viewScreenshot) - copyButton.clicked.connect(self.copyToClipboard) - code_layout.setStretch(0, 2) - code_layout.setStretch(1, 0) - _layout.addLayout(code_layout) - else: - _layout.addWidget(label) - - _container.setLayout(_layout) - self.scrollArea.setWidget(_container) - self.currentRow += 1 - self._layout.addWidget( - self.scrollArea, self.currentRow, 1, alignment=Qt.AlignTop - ) - - # Stretch last row - self.currentRow += 1 - self._layout.setRowStretch(self.currentRow, 1) - - def viewScreenshot(self, checked=False): - self.screenShotWin = view_visualcpp_screenshot(parent=self) - self.screenShotWin.show() - - def addInstructionsMacOS(self): - self.scrollArea = QScrollArea() - _container = QWidget() - _layout = QVBoxLayout() - for t, text in enumerate(utils.install_javabridge_instructions_text()): - label = QLabel() - label.setText(text) - # label.setWordWrap(True) - if t == 1 or t == 2: - label.setWordWrap(True) - code_layout = QHBoxLayout() - code_layout.addWidget(label) - copyButton = QToolButton() - copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - copyButton.setIcon(QIcon(":edit-copy.svg")) - copyButton.setText("Copy") - if t == 1: - copyButton.textToCopy = utils._install_homebrew_command() - else: - copyButton.textToCopy = utils._brew_install_java_command() - copyButton.clicked.connect(self.copyToClipboard) - code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) - # code_layout.addStretch(1) - code_layout.setStretch(0, 2) - code_layout.setStretch(1, 0) - _layout.addLayout(code_layout) - else: - _layout.addWidget(label) - _container.setLayout(_layout) - self.scrollArea.setWidget(_container) - self.currentRow += 1 - self._layout.addWidget( - self.scrollArea, self.currentRow, 1, alignment=Qt.AlignTop - ) - - # Stretch last row - self.currentRow += 1 - self._layout.setRowStretch(self.currentRow, 1) - self.scrollArea.hide() - - def addInstructionsLinux(self): - self.scrollArea = QScrollArea() - _container = QWidget() - _layout = QVBoxLayout() - for t, text in enumerate(utils.install_javabridge_instructions_text()): - label = QLabel() - label.setText(text) - # label.setWordWrap(True) - if t == 1 or t == 2 or t == 3: - label.setWordWrap(True) - code_layout = QHBoxLayout() - code_layout.addWidget(label) - copyButton = QToolButton() - copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - copyButton.setIcon(QIcon(":edit-copy.svg")) - copyButton.setText("Copy") - if t == 1: - copyButton.textToCopy = utils._apt_update_command() - elif t == 2: - copyButton.textToCopy = utils._apt_install_java_command() - elif t == 3: - copyButton.textToCopy = utils._apt_gcc_command() - copyButton.clicked.connect(self.copyToClipboard) - code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) - # code_layout.addStretch(1) - code_layout.setStretch(0, 2) - code_layout.setStretch(1, 0) - _layout.addLayout(code_layout) - else: - _layout.addWidget(label) - _container.setLayout(_layout) - self.scrollArea.setWidget(_container) - self.currentRow += 1 - self._layout.addWidget( - self.scrollArea, self.currentRow, 1, alignment=Qt.AlignTop - ) - - # Stretch last row - self.currentRow += 1 - self._layout.setRowStretch(self.currentRow, 1) - self.scrollArea.hide() - - def copyToClipboard(self): - cb = QApplication.clipboard() - cb.clear(mode=cb.Clipboard) - cb.setText(self.sender().textToCopy, mode=cb.Clipboard) - print("Command copied!") - - def showInstructions(self, checked): - if checked: - self.instructionsButton.setText("Hide instructions") - self.origHeight = self.height() - self.resize(self.width(), self.height() + 300) - self.scrollArea.show() - else: - self.instructionsButton.setText("Show instructions...") - self.scrollArea.hide() - func = partial(self.resize, self.width(), self.origHeight) - QTimer.singleShot(50, func) - - def installJava(self): - import subprocess - - try: - if is_mac: - try: - subprocess.check_call(["brew", "update"]) - except Exception as e: - subprocess.run( - utils._install_homebrew_command(), - check=True, - text=True, - shell=True, - ) - subprocess.run( - utils._brew_install_java_command(), - check=True, - text=True, - shell=True, - ) - elif is_linux: - subprocess.run( - utils._apt_gcc_command()(), check=True, text=True, shell=True - ) - subprocess.run( - utils._apt_update_command()(), check=True, text=True, shell=True - ) - subprocess.run( - utils._apt_install_java_command()(), - check=True, - text=True, - shell=True, - ) - self.close() - except Exception as e: - print("=======================") - traceback.print_exc() - print("=======================") - msg = myMessageBox(wrapText=False) - err_msg = html_utils.paragraph(""" - Automatic installation of Java failed.

    - Please, try manually by following the instructions provided - below (click on "Show instructions..." button). Thanks - """) - msg.critical(self, "Java installation failed", err_msg) - - def show(self, block=False): - super().show(block=False) - print(is_linux) - if is_win: - self.addInstructionsWindows() - elif is_mac: - self.addInstructionsMacOS() - elif is_linux: - self.addInstructionsLinux() - self.move(self.pos().x(), 20) - if is_win: - self.resize(self.width(), self.height() + 200) - if block: - self._block() - - def exec_(self): - self.show(block=True) - - -class selectTrackerGUI(QDialogListbox): - def __init__(self, SizeT, currentFrameNo=1, parent=None): - trackers = utils.get_list_of_trackers() - super().__init__( - "Select tracker", - "Select one of the following trackers", - trackers, - multiSelection=False, - parent=parent, - ) - self.setWindowTitle("Select tracker") - - self.selectFramesGroupbox = selectStartStopFrames( - SizeT, currentFrameNum=currentFrameNo, parent=parent - ) - - self.mainLayout.insertWidget(1, self.selectFramesGroupbox) - - def ok_cb(self, event): - if self.selectFramesGroupbox.warningLabel.text(): - return - else: - self.startFrame = self.selectFramesGroupbox.startFrame_SB.value() - self.stopFrame = self.selectFramesGroupbox.stopFrame_SB.value() - super().ok_cb(event) - - -def addWidgetToScrollArea( - widget, - resizeMinWidthNoHorizontalScrollbar=False, - resizeMinHeightNoVerticalScrollbar=False, -): - container = QWidget() - layout = QVBoxLayout() - layout.addWidget(widget) - layout.addStretch(1) - container.setLayout(layout) - scrollArea = QScrollArea() - scrollArea.setWidgetResizable(True) - scrollArea.setWidget(container) - - if resizeMinWidthNoHorizontalScrollbar: - scrollArea.setMinimumWidth( - container.sizeHint().width() - + scrollArea.verticalScrollBar().sizeHint().width() - ) - - if resizeMinHeightNoVerticalScrollbar: - scrollArea.setMinimumHeight( - container.sizeHint().height() - + scrollArea.horizontalScrollBar().sizeHint().height() - ) - - return scrollArea - - -class CheckableAction(QAction): - clicked = Signal(bool) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setCheckable(True) - self.toggled.connect(self.emitClicked) - - def emitClicked(self, checked): - self.clicked.emit(checked) - - def setChecked(self, checked): - self.toggled.disconnect() - super().setChecked(checked) - self.toggled.connect(self.emitClicked) - - -class OddSpinBox(SpinBox): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.setSingleStep(2) - self.editingFinished.connect(self.roundToOdd) - - def roundToOdd(self): - if self.value() % 2 == 1: - return - - self.setValue(self.value() + 1) - - -class TimestampItem(LabelItem): - sigEditProperties = Signal(object) - sigRemove = Signal(object) - - def __init__( - self, - SizeY, - SizeX, - viewRange, - secondsPerFrame=1, - parent=None, - start_timedelta=None, - ): - self._secondsPerFrame = secondsPerFrame - self._x_pad = 3 - self._y_pad = 2 - self.xmin, self.ymin = 0, 0 - self.SizeY = SizeY - self.SizeX = SizeX - self._highlighted = False - self._parent = parent - if start_timedelta is None: - start_timedelta = datetime.timedelta(seconds=0) - self._start_timedelta = start_timedelta - self.clicked = False - super().__init__(self) - self.updateViewRange(viewRange) - self.createContextMenu() - - def setSecondsPerFrame(self, secondsPerFrame): - self._secondsPerFrame = secondsPerFrame - - def getBboxViewRange(self, viewRange): - xRange, yRange = viewRange - x0, x1 = xRange - y0, y1 = yRange - if x0 < 0: - x0 = 0 - - if x1 > self.SizeX: - x1 = self.SizeX - - if y0 < 0: - y0 = 0 - - if y1 > self.SizeY: - y1 = self.SizeY - - return x0, y0, x1, y1 - - def updateViewRange(self, viewRange): - x0, y0, x1, y1 = self.getBboxViewRange(viewRange) - - self.xmax = x1 - self.xmin = x0 - - self.ymax = y1 - self.ymin = y0 - - def createContextMenu(self): - self.contextMenu = QMenu() - action = QAction("Edit properties...", self.contextMenu) - action.triggered.connect(self.emitEditProperties) - self.contextMenu.addSeparator() - action = QAction("Remove", self.contextMenu) - action.triggered.connect(self.emitRemove) - self.contextMenu.addAction(action) - - def emitRemove(self): - self.sigRemove.emit(self) - - def mousePressed(self, x, y): - self.clicked = True - - def emitEditProperties(self): - self.setHighlighted(False) - self.sigEditProperties.emit(self.properties()) - - def isHighlighted(self): - return self._highlighted - - def setHighlighted(self, highlighted): - if self._highlighted and highlighted: - return - - if not self._highlighted and not highlighted: - return - - super().setText(self.text, bold=highlighted) - - self._highlighted = highlighted - - def showContextMenu(self, x, y): - self.contextMenu.popup(QPoint(int(x), int(y))) - - def setLocationProperty(self, loc: str): - self._loc = loc - - def properties(self): - properties = { - "color": self._color, - "loc": self._loc, - "font_size": int(self._font_size[:-2]), - "start_timedelta": self._start_timedelta, - "move_with_zoom": self._move_with_zoom, - } - return properties - - def draw(self, frame_i, **kwargs): - self.setProperties(**kwargs) - self.update(frame_i) - - def update(self, frame_i): - self.setPosFromLoc() - self.setText(frame_i) - - def setMoveWithZoomProperty(self, move_with_zoom): - self._move_with_zoom = move_with_zoom - - def updatePosViewRangeChanged(self, viewRange): - if self._loc == "custom": - textHeight = self.itemRect().height() - textWidth = self.itemRect().width() - x0p = self.pos().x() - y0p = self.pos().y() - xcp = x0p + textWidth / 2 - ycp = y0p + textHeight / 2 - x0 = self.xmin - y0 = self.ymin - x_range = self.xmax - x0 - y_range = self.ymax - y0 - Dx_perc = (xcp - x0) / x_range - Dy_perc = (ycp - y0) / y_range - - self.updateViewRange(viewRange) - - X0 = self.xmin - Y0 = self.ymin - - X_range = self.xmax - X0 - Y_range = self.ymax - Y0 - - Xcp = X0 + (Dx_perc * X_range) - Ycp = Y0 + (Dy_perc * Y_range) - X0p = Xcp - (textWidth / 2) - Y0p = Ycp - (textHeight / 2) - - y_pos_max = self.ymax - textHeight - self._y_pad - if Y0p > y_pos_max: - Y0p = y_pos_max - - x_pos_max = self.xmax - textWidth - self._x_pad - if X0p > x_pos_max: - X0p = x_pos_max - - self.setPos(X0p, Y0p) - else: - self.updateViewRange(viewRange) - self.setPosFromLoc() - - def setPosFromLoc(self): - textHeight = self.itemRect().height() - textWidth = self.itemRect().width() - if self._loc == "custom": - return - - if self._loc.find("top") != -1: - y0 = self._y_pad + self.ymin - else: - y0 = self.ymax - textHeight - self._y_pad - - if self._loc.find("left") != -1: - x0 = self._x_pad + self.xmin - else: - x0 = self.xmax - textWidth - self._x_pad - - self.setPos(x0, y0) - - def setProperties( - self, - color=(255, 255, 255), - font_size="13px", - loc="top-left", - start_timedelta=None, - move_with_zoom=False, - ): - if start_timedelta is not None: - self._start_timedelta = start_timedelta - self._color = color - self._loc = loc - self._font_size = font_size - self._move_with_zoom = move_with_zoom - - def move(self, xm, ym): - Dy = ym - self.yc - Dx = xm - self.xc - x0 = self.x0c + Dx - y0 = self.y0c + Dy - self.setPos(x0, y0) - - def mousePressed(self, x, y): - self.clicked = True - self.xc, self.yc = x, y - self.x0c = self.pos().x() - self.y0c = self.pos().y() - - def setText(self, frame_i): - if not isinstance(frame_i, int): - return - - seconds = frame_i * self._secondsPerFrame - timedelta = datetime.timedelta(seconds=round(seconds)) - - diff_seconds = timedelta.total_seconds() + self._start_timedelta.total_seconds() - if diff_seconds >= 0: - timedelta = datetime.timedelta(seconds=round(diff_seconds)) - text = str(timedelta) - else: - abs_diff = abs( - timedelta.total_seconds() + self._start_timedelta.total_seconds() - ) - abs_timedelta = datetime.timedelta(seconds=round(abs_diff)) - text = f"-{abs_timedelta}" - - # printl(timedelta) - super().setText(text, color=self._color, size=self._font_size) - - def addToAxis(self, ax): - ax.addItem(self) - - def removeFromAxis(self, ax): - ax.removeItem(self) - - -class FontSizeWidget(QWidget): - sigTextChanged = Signal(str) - - def __init__(self, parent=None, unit="px", initalVal=12): - super().__init__(parent) - - layout = QHBoxLayout() - - self.spinbox = SpinBox() - self.spinbox.setValue(initalVal) - layout.addWidget(self.spinbox) - - self.unitLabel = QLabel(unit) - layout.addWidget(self.unitLabel) - - layout.setContentsMargins(0, 0, 0, 0) - layout.setStretch(0, 1) - layout.setStretch(1, 0) - - self.setLayout(layout) - - self.spinbox.valueChanged.connect(self.emitTextChanged) - - def emitTextChanged(self, value): - self.sigTextChanged.emit(self.text()) - - def setValue(self, value): - if isinstance(value, str): - value = int(value.replace(self.unitLabel.text(), "").strip()) - self.spinbox.setValue(value) - - def setText(self, text): - value = int(text.replace(self.unitLabel.text(), "").strip()) - self.setValue(value) - - def text(self): - return f"{self.spinbox.value()}{self.unitLabel.text()}" - - def value(self): - return self.spinbox.value() - - -class RangeSelector(QWidget): - sigRangeChanged = Signal(object, object) - sigLowValueChanged = Signal(object) - sigHighValueChanged = Signal(object) - sigRangeManuallyChanged = Signal(object, object) - - def __init__(self, parent=None, integers=False, ordered=True): - super().__init__(parent) - - self._integers = integers - self._ordered = ordered - - layout = QHBoxLayout() - - if integers: - self.lowSpinbox = SpinBox() - self.highSpinbox = SpinBox() - else: - self.lowSpinbox = DoubleSpinBox() - self.highSpinbox = DoubleSpinBox() - - layout.addWidget(self.lowSpinbox) - layout.addWidget(self.highSpinbox) - - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - - self.lowSpinbox.valueChanged.connect(self.lowValueChanged) - self.highSpinbox.valueChanged.connect(self.highValueChanged) - - self.lowSpinbox.editingFinished.connect(self.lowValueEditingFinished) - self.highSpinbox.editingFinished.connect(self.highValueEditingFinished) - - def lowValueEditingFinished(self): - self.sigRangeManuallyChanged.emit(*self.range()) - self.emitRangeChanged() - - def highValueEditingFinished(self): - self.sigRangeManuallyChanged.emit(*self.range()) - self.emitRangeChanged() - - def lowValueChanged(self, value): - self.emitRangeChanged() - self.sigLowValueChanged.emit(value) - - def highValueChanged(self, value): - self.emitRangeChanged() - self.sigHighValueChanged.emit(value) - - def emitRangeChanged(self): - self.sigRangeChanged.emit(*self.range()) - - def setRangeNoEmit(self, lowValue, highValue, decimals=3): - self.lowSpinbox.valueChanged.disconnect() - self.highSpinbox.valueChanged.disconnect() - - self.setRange(round(lowValue, 3), round(highValue, 3)) - - self.lowSpinbox.valueChanged.connect(self.lowValueChanged) - self.highSpinbox.valueChanged.connect(self.highValueChanged) - - def setRange(self, lowValue, highValue): - # if lowValue > highValue and self._ordered: - # highValue = lowValue + 1 - - if self._integers: - lowValue = round(lowValue) - highValue = round(highValue) - - self.lowSpinbox.setValue(lowValue) - self.highSpinbox.setValue(highValue) - - def range(self): - return self.lowSpinbox.value(), self.highSpinbox.value() - - -class LineEdit(QLineEdit): - def __init__(self, parent=None): - super().__init__(parent) - self.setAlignment(Qt.AlignCenter) - - def value(self): - return self.text() - - def setValue(self, value): - self.setText(str(value)) - - -class PreProcessingSelector(QComboBox): - sigValuesChanged = Signal(dict, int) - - def __init__(self, parent=None): - super().__init__(parent) - self._parent = parent - - self.addItems(PREPROCESS_MAPPER.keys()) - self.methodToDefaultValuesMapper = {} - self.step_n = -1 - self.setParamsWindow = None - - def htmlInfo(self): - href = html_utils.href_tag("GitHub page", urls.issues_url) - docstring = PREPROCESS_MAPPER[self.currentText()]["docstring"] - if docstring is None: - text = "This function is not documented, yet. Sorry :(" - else: - text = html_utils.rst_docstring_to_html(docstring) - text = ( - f"{text}

    " - f"Feel free to submit an issue on our {href} if you " - "need help with this filter." - ) - return text - - def setParams(self, method: str, kwargToValueMapper: Dict[str, str]): - self.methodToDefaultValuesMapper[method] = kwargToValueMapper - - def askSetParams(self, df_metadata=None, addApplyButton=False): - method = self.currentText() - function = PREPROCESS_MAPPER[method]["function"] - params_argspecs = utils.get_function_argspec( - function, - args_to_skip={"logger_func", "apply_to_all_zslices", "apply_to_all_frames"}, - ) - default_values = self.methodToDefaultValuesMapper.get(method, {}) - for kwarg, value in default_values.items(): - for p, param_argspec in enumerate(params_argspecs): - if param_argspec.name != kwarg: - continue - - if hasattr(param_argspec.type, "cast_dtype"): - cls = param_argspec.type - value = cls.cast_dtype(value) - else: - value = param_argspec.type(value) - - if value == param_argspec.default: - continue - param_argspec = param_argspec._replace(default=value) - params_argspecs[p] = param_argspec - - if self.setParamsWindow is not None: - self.setParamsWindow.raise_() - self.setParamsWindow.activateWindow() - return - - self.setParamsWindow = apps.FunctionParamsDialog( - params_argspecs, - df_metadata=df_metadata, - function_name=method, - addApplyButton=addApplyButton, - parent=self._parent, - ) - self.setParamsWindow.sigValuesChanged.connect(self.emitValuesChanged) - self.setParamsWindow.emitValuesChanged() - self.setParamsWindow.exec_() - if self.setParamsWindow.cancel: - return - - self.setParams(method, self.setParamsWindow.function_kwargs) - - function_kwargs = self.setParamsWindow.function_kwargs - self.setParamsWindow = None - - return function_kwargs - - def emitValuesChanged(self, functionKwargs: dict): - self.sigValuesChanged.emit(functionKwargs, self.step_n) - - -class RescaleImageJroisGroupbox(QGroupBox): - def __init__(self, TZYX_out_shape, parent=None): - super().__init__(parent) - - self.setTitle("Rescale ROIs") - self.setCheckable(True) - - gridLayout = QGridLayout() - - dims = ("Z", "Y", "X") - self.widgets = {} - for row, SizeD in enumerate(TZYX_out_shape[1:]): - if SizeD == 1: - continue - - dim = dims[row] - inputSpinbox = SpinBox() - inputSpinbox.setMinimum(1) - inputSpinbox.setValue(SizeD) - - outZwidget = QLineEdit() - outZwidget.setReadOnly(True) - outZwidget.setAlignment(Qt.AlignCenter) - # outZwidget.setValue(SizeD) - outZwidget.setText(str(SizeD)) - - row0 = row * 2 - row1 = row0 + 1 - gridLayout.addWidget(QLabel(f"{dim}-dimension: "), row1, 0) - - gridLayout.addWidget(QLabel("Input size"), row0, 1) - gridLayout.addWidget(inputSpinbox, row1, 1) - - gridLayout.addWidget(QLabel("Output size"), row0, 2) - gridLayout.addWidget(outZwidget, row1, 2) - - self.widgets[dim] = (inputSpinbox, SizeD) - - self.setLayout(gridLayout) - - def inputOutputSizes(self): - if not self.isChecked(): - return - - sizes = { - dim: (spinbox.value(), int(SizeD)) - for dim, (spinbox, SizeD) in self.widgets.items() - } - return sizes - - -class WhitelistLineEdit(KeepIDsLineEdit): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def setText(self, IDs): - if not isinstance(IDs, set) and not isinstance(IDs, list): - raise TypeError("IDs must be a set or list") - - formatted_text = utils.format_IDs(IDs) - super().setText(formatted_text) - - -class KeySequenceFromText(QKeySequence): - def __init__(self, text: str): - if isinstance(text, str): - text = macShortcutToWindows(text) - super().__init__(text) - self._text = text - - def toString(self): - if isinstance(self._text, str): - return windowsShortcutToMac(self._text) - else: - return windowsShortcutToMac(super().toString()) - - -def modifierKeyToText(modifierKey: int): - if modifierKey == Qt.ControlModifier: - return "Ctrl" - elif modifierKey == Qt.AltModifier: - return "Alt" - elif modifierKey == Qt.ShiftModifier: - return "Shift" - elif modifierKey == Qt.MetaModifier: - return "Meta" - else: - return "" - - -class TimeWidget(QGroupBox): - sigValueChanged = Signal(object) - - def __init__(self, parent=None, orientation="vertical"): - super().__init__(parent) - - mainLayout = QHBoxLayout() - - if orientation == "vertical": - spinboxesLayout = QVBoxLayout() - elif orientation == "horizontal": - spinboxesLayout = QHBoxLayout() - else: - raise ValueError('orientation must be "vertical" or "horizontal"') - - self.signCombobox = QComboBox() - self.signCombobox.addItems(("+", "-")) - self.signCombobox.currentTextChanged.connect(self.emitValueChanged) - - mainLayout.addWidget(self.signCombobox) - - self.spinboxesMapper = {} - units = ("days", "hours", "minutes", "seconds") - for unit in units: - layout = QHBoxLayout() - spinbox = SpinBox() - spinbox.setMinimum(0) - label = QLabel(unit) - layout.addWidget(spinbox) - layout.addWidget(label) - spinbox.valueChanged.connect(self.emitValueChanged) - self.spinboxesMapper[unit] = spinbox - spinboxesLayout.addLayout(layout) - - mainLayout.addLayout(spinboxesLayout) - - self.setLayout(mainLayout) - mainLayout.setContentsMargins(5, 5, 5, 5) - - def values(self): - values = {} - for unit, spinbox in self.spinboxesMapper.items(): - values[unit] = spinbox.value() - - signText = self.signCombobox.currentText() - return values, sign_int_mapper[signText] - - def setValuesFromTimedelta(self, timedelta): - total_seconds = timedelta.total_seconds() - sign = 1 if total_seconds > 0 else -1 - days = timedelta.days - hours, remainder = divmod(timedelta.seconds, 3600) - minutes, seconds = divmod(remainder, 60) - - values = {"days": days, "hours": hours, "minutes": minutes, "seconds": seconds} - - self.setValues(values, sign=sign) - - def timedelta(self): - values, sign = self.values() - return datetime.timedelta(**values) * sign - - def setValues(self, values: dict[str, int | float], sign=1): - signText = "+" if sign > 0 else "-" - self.signCombobox.setCurrentText(signText) - for unit, value in values.items(): - spinbox = self.spinboxesMapper[unit] - spinbox.setValue(value) - - def emitValueChanged(self, value): - self.sigValueChanged.emit(self.values()) - - -def get_min_width_for_no_scrollbar(list_widget: QListWidget) -> int: - """ - Calculate the minimum width needed for the QListWidget - so that the horizontal scrollbar will not be required. - """ - font_metrics = QFontMetrics(list_widget.font()) - max_width = 0 - - for i in range(list_widget.count()): - item = list_widget.item(i) - text_width = font_metrics.horizontalAdvance(item.text()) - max_width = max(max_width, text_width) - - # Add padding for icon, scrollbar margin, and frame - padding = 30 # Adjust as needed (depends on style and icons) - return max_width + padding - - -class YeazV2SelectModelNameCombobox(ComboBox): - sigValueChanged = Signal(str) - - def __init__( - self, *args, custom_select_item_text="Select custom weights file...", **kwargs - ): - super().__init__(*args, **kwargs) - self._csi_text = custom_select_item_text - self.sigTextChanged.connect(self.onTextChanged) - self.initItems() - - def initItems(self): - from cellacdc.segmenters.YeaZ_v2 import load_models_filepath - - models_name, models_name_filepath_mapper = load_models_filepath() - self.addItems(models_name) - - def onTextChanged(self, text): - if text != self._csi_text: - return - - start_dir = utils.getMostRecentPath() - model_filepath = qtpy.compat.getopenfilename( - parent=self, - caption="Select YeaZ weights file", - filters="All Files (*)", - basedir=start_dir, - )[0] - if not model_filepath: - self.setCurrentIndex(0) - return - - msg = html_utils.paragraph(f""" - Insert a name for the following YeaZ model:

    - {model_filepath}
    - """) - modelNameWindow = apps.QLineEditDialog( - title="Insert a name for the model", msg=msg, allowEmpty=False, parent=self - ) - modelNameWindow.exec_() - if modelNameWindow.cancel: - self.setCurrentIndex(0) - return - - model_name = modelNameWindow.enteredValue - - from cellacdc.segmenters.YeaZ_v2 import add_model_filepath - - add_model_filepath(model_name, model_filepath) - - self.addItem(model_name) - self.setCurrentText(model_name) - - print( - "YeaZ_v2 model added!\n\n" - f" * Name: {model_name}\n" - f" * File path: {model_filepath}\n" - ) - - def addItem(self, item): - idx = self.count() - 1 - self.insertItem(idx, item) - - def addItems(self, items): - super().clear() - super().addItems(items) - super().addItem(self._csi_text) - idx = len(items) - font = self.font() - font.setItalic(True) - self.setItemData(idx, font, Qt.FontRole) - - def setValue(self, value: str): - self.setCurrentText(value) - - def value(self, *args): - return self.currentText() - - -class AutoSaveIntervalWidget(QWidget): - sigValueChanged = Signal(float, str) - - def __init__(self, parent=None): - super().__init__(parent) - - layout = QHBoxLayout() - - autoSaveIntervalTooltip = "Autosave every minutes or frames specified here." - - self.setToolTip(autoSaveIntervalTooltip) - - self.spinbox = DoubleSpinBox() - self.spinbox.setMinimum(0) - self.spinbox.setValue(2) - self.spinbox.setDecimals(2) - self.spinbox.setSingleStep(1.0) - - layout.addWidget(self.spinbox) - - self.unitCombobox = ComboBox() - self.unitCombobox.addItems(["minutes", "frames"]) - layout.addWidget(self.unitCombobox) - - layout.setStretch(0, 1) - layout.setStretch(1, 0) - layout.setContentsMargins(5, 0, 5, 0) - - self.setLayout(layout) - - self.spinbox.sigValueChanged.connect(self.emitSigValueChanged) - self.unitCombobox.sigTextChanged.connect(self.emitSigValueChanged) - - def emitSigValueChanged(self, *args, **kwargs): - self.sigValueChanged.emit(self.spinbox.value(), self.unitCombobox.currentText()) - - -class CheckableWidget(QWidget): - def __init__(self, widget, valueGetterName="value", parent=None): - super().__init__(parent) - - self.widget = widget - self.valueGetterName = valueGetterName - - widget.setDisabled(True) - - layout = QHBoxLayout() - - layout.addWidget(widget) - - self.checkbox = QCheckBox("Activate") - self.checkbox.toggled.connect(self.setWidgetEnabled) - - layout.addSpacing(5) - layout.addWidget(self.checkbox) - - layout.setContentsMargins(5, 0, 5, 0) - - self.setLayout(layout) - - def setWidgetEnabled(self, checked): - self.widget.setDisabled(not checked) - - def value(self): - if not self.checkbox.isChecked(): - return - - return getattr(self.widget, self.valueGetterName)() - - -class warnVisualCppRequired(myMessageBox): - def __init__(self, pkg_name="javabridge", parent=None): - super().__init__(parent) - self.screenShotWin = None - - self.setIcon(iconName="SP_MessageBoxWarning") - self.setWindowTitle(f"Installation of {pkg_name} info") - txt = html_utils.paragraph(f""" - Installation of {pkg_name} on Windows requires - Microsoft Visual C++ 14.0 or higher.

    - Cell-ACDC will anyway try to install {pkg_name} now.

    - If the installation fails, please close Cell-ACDC, - then download and install "Microsoft C++ Build Tools" - from the link below - before trying this module again.

    - - https://visualstudio.microsoft.com/visual-cpp-build-tools/ -

    - IMPORTANT: when installing "Microsoft C++ Build Tools" - make sure to select "Desktop development with C++". - Click "See the screenshot" for more details. - """) - seeScreenshotButton = QPushButton("See screenshot...") - okButton = okPushButton("Ok") - okButton = self.addButton("Ok") - okButton.disconnect() - okButton.clicked.connect(self.ok_cb) - self.addButton(seeScreenshotButton) - seeScreenshotButton.disconnect() - seeScreenshotButton.clicked.connect(self.viewScreenshot) - self.addCancelButton(connect=True) - self.addText(txt) - - def ok_cb(self): - self.cancel = False - self.close() - - def viewScreenshot(self, checked=False): - self.screenShotWin = view_visualcpp_screenshot(self) - self.screenShotWin.show() - - def closeEvent(self, event): - if self.screenShotWin is not None: - self.screenShotWin.close() - - return super().closeEvent(event) diff --git a/cellacdc/widgets/controls/__init__.py b/cellacdc/widgets/controls/__init__.py new file mode 100644 index 000000000..9772cfda1 --- /dev/null +++ b/cellacdc/widgets/controls/__init__.py @@ -0,0 +1,153 @@ +"""Composite controls.""" + +from .dialogs import ( + QDialogListbox, + installJavaDialog, + myMessageBox, + selectTrackerGUI, + view_visualcpp_screenshot, + warnVisualCppRequired, +) + +from .forms import ( + AutoSaveIntervalWidget, + CheckableWidget, + CheckboxesGroupBox, + CopiableCommandWidget, + FontSizeWidget, + LabelsWidget, + PostProcessSegmSlider, + PostProcessSegmSpinbox, + PreProcessingSelector, + RangeSelector, + RescaleImageJroisGroupbox, + SamInputPointsWidget, + TimeWidget, + YeazV2SelectModelNameCombobox, + formWidget, + guiTabControl, + selectStartStopFrames, +) + +from .inputs import ( + AlphaNumericComboBox, + CenteredDoubleSpinbox, + ComboBox, + DoubleSpinBox, + ExpandableListBox, + FloatLineEdit, + IntLineEdit, + KeySequenceFromText, + LineEdit, + OddSpinBox, + QCenteredComboBox, + QClickableLabel, + ReadOnlyLineEdit, + SearchLineEdit, + ShortcutLineEdit, + SpinBox, + VectorLineEdit, + WhitelistLineEdit, + highlightableQWidgetAction, + mySpinBox, + readOnlyDoubleSpinbox, + readOnlySpinbox, +) + +from .metrics import ( + PixelSizeGroupbox, + SetMeasurementsGroupBox, + _metricsQGBox, + channelMetricsQGBox, + objIntesityMeasurQGBox, + objPropsQGBox, +) + +from .panels import ( + CheckableAction, + CheckableSpinBoxWidgets, + FeatureSelectorButton, + KeptObjectIDsList, + Label, + LatexLabel, + OrderableListWidget, + SwitchPlaneCombobox, + TimestampItem, + Toggle, + ToggleTerminalButton, + ToggleVisibilityButton, + ToggleVisibilityCheckBox, + expandCollapseButton, + listWidget, + statusBarPermanentLabel, +) + +__all__ = [ + "QDialogListbox", + "installJavaDialog", + "myMessageBox", + "selectTrackerGUI", + "view_visualcpp_screenshot", + "warnVisualCppRequired", + "AutoSaveIntervalWidget", + "CheckableWidget", + "CheckboxesGroupBox", + "CopiableCommandWidget", + "FontSizeWidget", + "LabelsWidget", + "PostProcessSegmSlider", + "PostProcessSegmSpinbox", + "PreProcessingSelector", + "RangeSelector", + "RescaleImageJroisGroupbox", + "SamInputPointsWidget", + "TimeWidget", + "YeazV2SelectModelNameCombobox", + "formWidget", + "guiTabControl", + "selectStartStopFrames", + "AlphaNumericComboBox", + "CenteredDoubleSpinbox", + "ComboBox", + "DoubleSpinBox", + "ExpandableListBox", + "FloatLineEdit", + "IntLineEdit", + "KeySequenceFromText", + "LineEdit", + "OddSpinBox", + "QCenteredComboBox", + "QClickableLabel", + "ReadOnlyLineEdit", + "SearchLineEdit", + "ShortcutLineEdit", + "SpinBox", + "VectorLineEdit", + "WhitelistLineEdit", + "highlightableQWidgetAction", + "mySpinBox", + "readOnlyDoubleSpinbox", + "readOnlySpinbox", + "PixelSizeGroupbox", + "SetMeasurementsGroupBox", + "_metricsQGBox", + "channelMetricsQGBox", + "objIntesityMeasurQGBox", + "objPropsQGBox", + "CheckableAction", + "CheckableSpinBoxWidgets", + "FeatureSelectorButton", + "KeptObjectIDsList", + "Label", + "LatexLabel", + "OrderableListWidget", + "SwitchPlaneCombobox", + "TimestampItem", + "Toggle", + "ToggleTerminalButton", + "ToggleVisibilityButton", + "ToggleVisibilityCheckBox", + "expandCollapseButton", + "listWidget", + "statusBarPermanentLabel", +] diff --git a/cellacdc/widgets/controls/dialogs.py b/cellacdc/widgets/controls/dialogs.py new file mode 100644 index 000000000..bc817e377 --- /dev/null +++ b/cellacdc/widgets/controls/dialogs.py @@ -0,0 +1,1309 @@ +"""Composite controls: dialogs.""" + +"""GUI widgets: controls.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +class QDialogListbox(QDialog): + sigSelectionConfirmed = Signal(list) + + def __init__( + self, + title, + text, + items, + cancelText="Cancel", + multiSelection=True, + parent=None, + additionalButtons=(), + includeSelectionHelp=False, + allowSingleSelection=True, + preSelectedItems=None, + allowEmptySelection=True, + ): + self.cancel = True + items = list(items) + + super().__init__(parent) + self.setWindowTitle(title) + + if preSelectedItems is None: + if items: + preSelectedItems = (items[0],) + else: + preSelectedItems = set() + + self.allowSingleSelection = allowSingleSelection + self.allowEmptySelection = allowEmptySelection + + mainLayout = QVBoxLayout() + topLayout = QVBoxLayout() + bottomLayout = QHBoxLayout() + + self.mainLayout = mainLayout + + label = QLabel(text) + _font = QFont() + _font.setPixelSize(13) + label.setFont(_font) + # padding: top, left, bottom, right + label.setStyleSheet("padding:0px 0px 3px 0px;") + topLayout.addWidget(label, alignment=Qt.AlignCenter) + + if includeSelectionHelp: + selectionHelpLabel = QLabel() + txt = html_utils.paragraph("""
    + Ctrl+Click to select multiple items
    + Shift+Click to select a range of items
    + """) + selectionHelpLabel.setText(txt) + topLayout.addWidget(label, alignment=Qt.AlignCenter) + + listBox = listWidget() + listBox.setFont(_font) + listBox.addItems(items) + if multiSelection: + listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + else: + listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + listBox.setCurrentRow(0) + for i in range(listBox.count()): + item = listBox.item(i) + item.setSelected(item.text() in preSelectedItems) + + self.listBox = listBox + if not multiSelection: + listBox.itemDoubleClicked.connect(self.ok_cb) + topLayout.addWidget(listBox) + + if cancelText.lower().find("cancel") != -1: + cancelButton = cancelPushButton(cancelText) + else: + cancelButton = QPushButton(cancelText) + okButton = okPushButton(" Ok ") + + bottomLayout.addStretch(1) + bottomLayout.addWidget(cancelButton) + bottomLayout.addSpacing(20) + + if additionalButtons: + self._additionalButtons = [] + for button in additionalButtons: + if isinstance(button, str): + _button, isCancelButton = getPushButton(button) + self._additionalButtons.append(_button) + bottomLayout.addWidget(_button) + _button.clicked.connect(self.ok_cb) + else: + bottomLayout.addWidget(button) + + bottomLayout.addWidget(okButton) + bottomLayout.setContentsMargins(0, 10, 0, 0) + + mainLayout.addLayout(topLayout) + mainLayout.addLayout(bottomLayout) + self.setLayout(mainLayout) + + # Connect events + okButton.clicked.connect(self.ok_cb) + cancelButton.clicked.connect(self.cancel_cb) + + if multiSelection: + listBox.itemClicked.connect(self.onItemClicked) + listBox.itemSelectionChanged.connect(self.onItemSelectionChanged) + + self.setStyleSheet(LISTWIDGET_STYLESHEET) + self.areItemsSelected = [ + listBox.item(i).isSelected() for i in range(listBox.count()) + ] + self.setFont(font) + + def keyPressEvent(self, event) -> None: + mod = event.modifiers() + if mod == Qt.ShiftModifier or mod == Qt.ControlModifier: + self.listBox.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + elif event.key() == Qt.Key_Escape: + self.listBox.clearSelection() + event.ignore() + return + super().keyPressEvent(event) + + def onItemSelectionChanged(self): + if not self.listBox.selectedItems(): + self.areItemsSelected = [False for i in range(self.listBox.count())] + + def onItemClicked(self, item): + mod = QGuiApplication.keyboardModifiers() + if mod == Qt.ShiftModifier or mod == Qt.ControlModifier: + self.listBox.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + return + + self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection) + itemIdx = self.listBox.row(item) + wasSelected = self.areItemsSelected[itemIdx] + if wasSelected: + item.setSelected(False) + + self.areItemsSelected = [ + self.listBox.item(i).isSelected() for i in range(self.listBox.count()) + ] + # self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + # else: + # selectedItems.append(item) + + # self.listBox.clearSelection() + # for i in range(self.listBox.count()): + # item = self.listBox.item(i).setSelected(True) + + # print(self.listBox.selectedItems()) + + def setSelectedItems(self, itemsTexts): + for i in range(self.listBox.count()): + item = self.listBox.item(i) + if item.text() in itemsTexts: + item.setSelected(True) + self.listBox.update() + + def warnSelectionEmpty(self): + msg = myMessageBox(wrapText=False, showCentered=False) + txt = html_utils.paragraph( + "You need to select at least one item!.

    " + "Use Ctrl+Click to select multiple items
    " + "or Shift+Click to select a range of items" + ) + msg.warning(self, "Selection cannot be empty!", txt) + + def ok_cb(self, checked=False): + self.clickedButton = self.sender() + self.cancel = False + selectedItems = self.listBox.selectedItems() + self.selectedItemsText = [item.text() for item in selectedItems] + if not self.allowSingleSelection and len(self.selectedItemsText) < 2: + msg = myMessageBox(wrapText=False, showCentered=False) + txt = html_utils.paragraph( + "You need to select two or more items.

    " + "Use Ctrl+Click to select multiple items
    , or
    " + "Shift+Click to select a range of items" + ) + msg.warning(self, "Select two or more items", txt) + return + + if not self.allowEmptySelection and not self.selectedItemsText: + self.warnSelectionEmpty() + return + + self.sigSelectionConfirmed.emit(self.selectedItemsText) + self.close() + + def cancel_cb(self, event): + self.cancel = True + self.selectedItemsText = None + self.close() + + def exec_(self): + self.show(block=True) + + def show(self, block=False): + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + super().show() + + horizontal_sb = self.listBox.horizontalScrollBar() + while horizontal_sb.isVisible(): + self.resize(self.height(), self.width() + 10) + + if block: + self.loop = QEventLoop() + self.loop.exec_() + + def closeEvent(self, event): + if hasattr(self, "loop"): + self.loop.exit() + + +class myMessageBox(_base_widgets.QBaseDialog): + def __init__( + self, + parent=None, + showCentered=True, + wrapText=True, + scrollableText=False, + enlargeWidthFactor=0, + resizeButtons=True, + allowClose=True, + ): + super().__init__(parent) + + self.wrapText = wrapText + self.enlargeWidthFactor = enlargeWidthFactor + self.resizeButtons = resizeButtons + + self.cancel = True + self.cancelButton = None + self.okButton = None + self.clickedButton = None + self.alreadyShown = False + self.allowClose = allowClose + + self.showCentered = showCentered + + self.scrollableText = scrollableText + + self._layout = QGridLayout() + self.commandsLayout = None + self._layout.setHorizontalSpacing(20) + self.buttonsLayout = QHBoxLayout() + self.buttonsLayout.setSpacing(2) + self.buttons = [] + self.widgets = [] + self.layouts = [] + self.labels = [] + self.labelsWidgets = [] + self._pixmapLabels = [] + self.detailsTextWidget = None + self.showInFileManagButton = None + self.visibleDetails = False + self.doNotShowAgainCheckbox = None + + self.currentRow = 0 + self.textWidget = None + self._w = None + + self.textLayout = QVBoxLayout() + + self._layout.setColumnStretch(1, 1) + self.setLayout(self._layout) + + self.setFont(font) + + def mousePressEvent(self, event): + for label in self.labels: + label.setTextInteractionFlags( + Qt.TextBrowserInteraction | Qt.TextSelectableByKeyboard + ) + + def setIcon(self, iconName="SP_MessageBoxInformation"): + label = QLabel(self) + + standardIcon = getattr(QStyle, iconName) + icon = self.style().standardIcon(standardIcon) + pixmap = icon.pixmap(60, 60) + label.setPixmap(pixmap) + + self._layout.addWidget(label, 0, 0, alignment=Qt.AlignTop) + + def addImage(self, image_path): + pixmap = QPixmap(image_path) + label = QLabel() + label.setPixmap(pixmap) + self._layout.addWidget(label, self.currentRow, 1) + self.currentRow += 1 + + def addShowInFileManagerButton(self, path, txt=None): + if txt is None: + txt = "Reveal in Finder..." if is_mac else "Show in Explorer..." + self.showInFileManagButton = showInFileManagerButton(txt) + self.buttonsLayout.addWidget(self.showInFileManagButton) + func = partial(utils.showInExplorer, path) + self.showInFileManagButton.clicked.connect(func) + + def addBrowseUrlButton(self, url, button_text=""): + self.openUrlButton = OpenUrlButton(url, button_text) + self.buttonsLayout.addWidget(self.openUrlButton) + + def addCancelButton(self, button=None, connect=False): + if button is None: + self.cancelButton = cancelPushButton("Cancel") + else: + self.cancelButton = button + self.cancelButton.setIcon(QIcon(":cancelButton.svg")) + + self.buttonsLayout.insertWidget(0, self.cancelButton) + self.buttonsLayout.insertSpacing(1, 20) + if connect: + self.cancelButton.clicked.connect(self.buttonCallBack) + + def splitLatexBlocks(self, text): + texts = re.split(r"(.+?)
    ", text) + return texts + + def splitCopiableBlocks(self, texts: Sequence[str] | str): + if isinstance(texts, str): + texts = (texts,) + + texts_out = [] + for text in texts: + texts_out.extend(re.split(r"(.+?)", text)) + return texts_out + + def addText(self, text): + texts = self.splitLatexBlocks(text) + texts = self.splitCopiableBlocks(texts) + + labelsWidget = LabelsWidget(texts, wrapText=self.wrapText) + self.labelsWidgets.append(labelsWidget) + self.labels.extend(labelsWidget.labels) + if self.scrollableText: + textWidget = QScrollArea() + textWidget.setFrameStyle(QFrame.Shape.NoFrame) + textWidget.setWidget(labelsWidget) + else: + textWidget = labelsWidget + + self.textLayout.addWidget(textWidget) + + if self.textWidget is None: + self.textWidget = QWidget() + self.textWidget.setLayout(self.textLayout) + self._layout.addWidget(self.textWidget, self.currentRow, 1) + self.textRow = self.currentRow + self.currentRow += 1 + + return labelsWidget + + def addCopiableCommand(self, command): + copiableCommandWidget = CopiableCommandWidget(command) + screenWidth = self.screen().size().width() + maxWidth = int(0.75 * screenWidth) + sizeHint = copiableCommandWidget.sizeHint() + width = sizeHint.width() + if width > maxWidth: + copiableCommandWidget = addWidgetToScrollArea( + copiableCommandWidget, resizeMinHeightNoVerticalScrollbar=True + ) + self._layout.addWidget(copiableCommandWidget, self.currentRow, 1) + self.currentRow += 1 + + def copyToClipboard(self): + cb = QApplication.clipboard() + cb.clear(mode=cb.Clipboard) + cb.setText(self.sender()._command, mode=cb.Clipboard) + print("Command copied!") + + def addButton(self, buttonText): + if not isinstance(buttonText, str): + # Passing button directly + button = buttonText + self.buttonsLayout.addWidget(button) + button.clicked.connect(self.buttonCallBack) + self.buttons.append(button) + return button + + button, isCancelButton = getPushButton(buttonText, qparent=self) + if not isCancelButton: + self.buttonsLayout.addWidget(button) + + button.clicked.connect(self.buttonCallBack) + self.buttons.append(button) + return button + + def addDoNotShowAgainCheckbox(self, text="Do not show again"): + self.doNotShowAgainCheckbox = QCheckBox(text) + + def addWidget(self, widget): + self._layout.addWidget(widget, self.currentRow, 1) + self.widgets.append(widget) + self.currentRow += 1 + + def addLayout(self, layout): + self._layout.addLayout(layout, self.currentRow, 1) + self.layouts.append(layout) + self.currentRow += 1 + + def setWidth(self, w): + self._w = w + + def show(self, block=False): + self.endOfScrollableRow = self.currentRow + + self.setWindowFlags(Qt.Window | Qt.WindowStaysOnTopHint) + # spacer + spacer = QSpacerItem(10, 10) + self._layout.addItem(spacer, self.currentRow, 1) + self._layout.setRowStretch(self.currentRow, 0) + + # buttons + self.currentRow += 1 + + if self.detailsTextWidget is not None: + self.buttonsLayout.insertWidget(1, self.detailsButton) + + # Do not show again checkbox + if self.doNotShowAgainCheckbox is not None: + self._layout.addWidget( + self.doNotShowAgainCheckbox, self.currentRow, 1, 1, 2 + ) + self.currentRow += 1 + + # spacer + self._layout.addItem(QSpacerItem(10, 10), self.currentRow, 1) + self.currentRow += 1 + + # buttons + self._layout.addLayout( + self.buttonsLayout, self.currentRow, 0, 1, 2, alignment=Qt.AlignRight + ) + + # Details + if self.detailsTextWidget is not None: + # spacer + self.currentRow += 1 + self._layout.addItem(QSpacerItem(20, 20), self.currentRow, 1) + + # detailsTextWidget + self.currentRow += 1 + self._layout.addWidget(self.detailsTextWidget, self.currentRow, 0, 1, 2) + + # spacer + self.currentRow += 1 + spacer = QSpacerItem(10, 10) + self._layout.addItem(spacer, self.currentRow, 1) + self._layout.setRowStretch(self.currentRow, 0) + + screenHeight = self.screen().size().height() + dialogHeight = self.sizeHint().height() + dialogWidth = self.sizeHint().width() + screenWidth = self.screen().size().width() + + # Check if scrollbar is needed + if dialogHeight > screenHeight and self.textWidget is not None: + textScrollArea = ScrollArea() + textScrollArea.setWidget(self.textWidget) + scrollAreaWidthNoSB = textScrollArea.minimumWidthNoScrollbar() + scrollAreaWidth = textScrollArea.sizeHint().width() + desiredDeltaWidth = scrollAreaWidthNoSB - scrollAreaWidth + if desiredDeltaWidth > 0: + desiredWidth = dialogWidth + desiredDeltaWidth + if desiredWidth < screenWidth: + self._w = desiredWidth + + self._layout.removeWidget(self.textWidget) + self._layout.addWidget(textScrollArea, self.textRow, 1) + + super().show() + QTimer.singleShot(5, self._resize) + + self.alreadyShown = True + + if block: + self._block() + + def setDetailedText(self, text, visible=False, wrap=True): + text = text.replace("\n", "
    ") + self.detailsTextWidget = QTextEdit(text) + self.detailsTextWidget.setReadOnly(True) + if not wrap: + self.detailsTextWidget.setLineWrapMode(QTextEdit.NoWrap) + self.detailsButton = showDetailsButton() + self.detailsButton.setCheckable(True) + self.detailsButton.clicked.connect(self._showDetails) + self.detailsTextWidget.hide() + self.visibleDetails = visible + + def _showDetails(self, checked): + if checked: + self.origHeight = self.height() + self.resize(self.width(), self.height() + 300) + self.detailsTextWidget.show() + else: + self.detailsTextWidget.hide() + func = partial(self.resize, self.width(), self.origHeight) + QTimer.singleShot(10, func) + + def _resize(self): + if self.resizeButtons: + widths = [button.width() for button in self.buttons] + if widths: + max_width = max(widths) + for button in self.buttons: + if button == self.cancelButton: + continue + button.setMinimumWidth(max_width) + + heights = [button.height() for button in self.buttons] + if heights: + max_h = max(heights) + for button in self.buttons: + button.setMinimumHeight(max_h) + if self.detailsTextWidget is not None: + self.detailsButton.setMinimumHeight(max_h) + if self.showInFileManagButton is not None: + self.showInFileManagButton.setMinimumHeight(max_h) + + if self._w is not None and self.width() < self._w: + self.resize(self._w, self.height()) + + if self.width() < 350: + self.resize(350, self.height()) + + if self.enlargeWidthFactor > 0: + self.resize(int(self.width() * self.enlargeWidthFactor), self.height()) + + if self.visibleDetails: + self.detailsButton.click() + + if self.showCentered: + screen = self.screen() + screenWidth = screen.size().width() + screenHeight = screen.size().height() + screenLeft = screen.geometry().x() + screenTop = screen.geometry().y() + w, h = self.width(), self.height() + left = int(screenLeft + screenWidth / 2 - w / 2) + top = int(screenTop + screenHeight / 2 - h / 2) + if top < screenTop: + top = screenTop + if left < screenLeft: + left = screenLeft + self.move(left, top) + + self._h = self.height() + + if self.okButton is not None: + self.okButton.setFocus() + + screen = self.screen() + screenWidth = screen.size().width() + screenHeight = screen.size().height() + + # Check Force wrap Text + for labelWidget in self.labelsWidgets: + textWidth = labelWidget.width() + if not textWidth > screenWidth - 10: + continue + factor = np.ceil(textWidth / screenWidth) + lineLength = int(labelWidget.nCharsLongestLine / factor) + for label in labelWidget.labels: + if isinstance(label, CopiableCommandWidget): + continue + + text = label.text() + chunks = textwrap.wrap(text, lineLength) + text = "
    ".join(chunks) + label.setText(text) + + QTimer.singleShot(100, self._resizeWrappedText) + + if self.widgets: + return + + if self.layouts: + return + + # # Start resizing height every 1 ms + # self.resizeCallsCount = 0 + # self.timer = QTimer() + # from config import warningHandler + # warningHandler.sigGeometryWarning.connect(self.timer.stop) + # self.timer.timeout.connect(self._resizeHeight) + # self.timer.start(1) + + def _resizeWrappedText(self): + screenWidth = self.screen().size().width() - 5 + self.resize(screenWidth, self.height()) + screenLeft = self.screen().geometry().left() + self.move(screenLeft, self.geometry().top()) + + def _resizeHeight(self): + try: + # Resize until a "Unable to set geometry" warning is captured + # by copnfig.warningHandler._resizeWarningHandler or # + # height doesn't change anymore + self.resize(self.width(), self.height() - 1) + if self.height() == self._h or self.resizeCallsCount > 100: + self.timer.stop() + return + + self.resizeCallsCount += 1 + self._h = self.height() + except Exception as e: + # traceback.format_exc() + self.timer.stop() + + def _template( + self, + parent, + title, + message, + detailsText=None, + buttonsTexts=None, + layouts=None, + widgets=None, + commands=None, + path_to_browse=None, + browse_button_text=None, + url_to_open=None, + open_url_button_text="Open url", + image_paths=None, + wrapDetails=True, + add_do_not_show_again_checkbox=False, + ): + if parent is not None: + self.setParent(parent) + self.setWindowTitle(title) + self.addText(message) + if commands is not None: + if isinstance(commands, str): + commands = (commands,) + for command in commands: + self.addCopiableCommand(command) + + if image_paths is not None: + if isinstance(image_paths, str): + image_paths = (image_paths,) + for image_path in image_paths: + self.addImage(image_path) + + if layouts is not None: + if utils.is_iterable(layouts): + for layout in layouts: + self.addLayout(layout) + else: + self.addLayout(layout) + + if widgets is not None: + self._layout.addItem(QSpacerItem(20, 20), self.currentRow, 1) + self.currentRow += 1 + if utils.is_iterable(widgets): + for widget in widgets: + self.addWidget(widget) + else: + self.addWidget(widgets) + + if path_to_browse is not None: + self.addShowInFileManagerButton(path_to_browse, txt=browse_button_text) + + if url_to_open is not None: + self.addBrowseUrlButton(url_to_open, button_text=open_url_button_text) + + buttons = [] + if buttonsTexts is None: + okButton = self.addButton(" Ok ") + buttons.append(okButton) + elif isinstance(buttonsTexts, str): + button = self.addButton(buttonsTexts) + buttons.append(button) + else: + for buttonText in buttonsTexts: + button = self.addButton(buttonText) + buttons.append(button) + + if detailsText is not None: + self.setDetailedText(detailsText, visible=True, wrap=wrapDetails) + + if add_do_not_show_again_checkbox: + self.addDoNotShowAgainCheckbox() + + return buttons + + def critical(self, *args, showDialog=True, **kwargs): + self.setIcon(iconName="SP_MessageBoxCritical") + buttons = self._template(*args, **kwargs) + if showDialog: + self.exec_() + return buttons + + def information(self, *args, showDialog=True, **kwargs): + self.setIcon(iconName="SP_MessageBoxInformation") + buttons = self._template(*args, **kwargs) + if showDialog: + self.exec_() + return buttons + + def warning(self, *args, showDialog=True, **kwargs): + self.setIcon(iconName="SP_MessageBoxWarning") + buttons = self._template(*args, **kwargs) + if showDialog: + self.exec_() + return buttons + + def question(self, *args, showDialog=True, **kwargs): + self.setIcon(iconName="SP_MessageBoxQuestion") + buttons = self._template(*args, **kwargs) + if showDialog: + self.exec_() + return buttons + + def _block(self): + self.loop = QEventLoop() + self.loop.exec_() + + def exec_(self): + self.show(block=True) + + def clickButtonFromText(self, buttonText): + for button in self.buttons: + if button.text() == buttonText: + button.click() + return + + def buttonCallBack(self, checked=True): + self.clickedButton = self.sender() + if self.clickedButton != self.cancelButton: + self.cancel = False + self.allowClose = True + self.close() + + def closeEvent(self, event): + if not self.allowClose: + event.ignore() + return + super().closeEvent(event) + + +class view_visualcpp_screenshot(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + layout = QHBoxLayout() + + self.setWindowTitle("Visual Studio Builld Tools installation") + + pixmap = QPixmap(":visualcpp.png") + label = QLabel() + label.setPixmap(pixmap) + + layout.addWidget(label) + self.setLayout(layout) + + +class installJavaDialog(myMessageBox): + def __init__(self, parent=None): + super().__init__(parent) + + self.setWindowTitle("Install Java") + self.setIcon("SP_MessageBoxWarning") + + txt_macOS = html_utils.paragraph(""" + Your system doesn't have the Java Development Kit + installed
    and/or a C++ compiler which is required for the installation of + javabridge

    + Cell-ACDC is now going to install Java for you.

    + NOTE: After clicking on "Install", follow the instructions
    + on the terminal
    . You will be asked to confirm steps and insert
    + your password to allow the installation.


    + If you prefer to do it manually, cancel the process
    + and follow the instructions below. + """) + + txt_windows = html_utils.paragraph(""" + Unfortunately, installing pre-compiled version of + javabridge failed.

    + Cell-ACDC is going to try to compile it now.

    + However, before proceeding, you need to install + Java Development Kit
    and a C++ compiler.

    + See instructions below on how to install it. + """) + + if not is_win: + self.instructionsButton = self.addButton("Show intructions...") + self.instructionsButton.setCheckable(True) + self.instructionsButton.disconnect() + self.instructionsButton.clicked.connect(self.showInstructions) + installButton = self.addButton("Install") + installButton.disconnect() + installButton.clicked.connect(self.installJava) + txt = txt_macOS + else: + okButton = self.addButton("Ok") + txt = txt_windows + + self.cancelButton = self.addButton("Cancel") + + label = self.addText(txt) + label.setWordWrap(False) + + self.resizeCount = 0 + + def addInstructionsWindows(self): + self.scrollArea = QScrollArea() + _container = QWidget() + _layout = QVBoxLayout() + for t, text in enumerate(utils.install_javabridge_instructions_text()): + label = QLabel() + label.setText(text) + if t == 1 or t == 2: + label.setOpenExternalLinks(True) + label.setTextInteractionFlags(Qt.TextBrowserInteraction) + code_layout = QHBoxLayout() + code_layout.addWidget(label) + copyButton = QToolButton() + copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + copyButton.setIcon(QIcon(":edit-copy.svg")) + copyButton.setText("Copy link") + if t == 1: + copyButton.textToCopy = utils.jdk_windows_url() + code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) + else: + copyButton.textToCopy = utils.cpp_windows_url() + screenshotButton = QToolButton() + screenshotButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + screenshotButton.setIcon(QIcon(":cog.svg")) + screenshotButton.setText("See screenshot") + code_layout.addWidget(screenshotButton, alignment=Qt.AlignLeft) + code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) + screenshotButton.clicked.connect(self.viewScreenshot) + copyButton.clicked.connect(self.copyToClipboard) + code_layout.setStretch(0, 2) + code_layout.setStretch(1, 0) + _layout.addLayout(code_layout) + else: + _layout.addWidget(label) + + _container.setLayout(_layout) + self.scrollArea.setWidget(_container) + self.currentRow += 1 + self._layout.addWidget( + self.scrollArea, self.currentRow, 1, alignment=Qt.AlignTop + ) + + # Stretch last row + self.currentRow += 1 + self._layout.setRowStretch(self.currentRow, 1) + + def viewScreenshot(self, checked=False): + self.screenShotWin = view_visualcpp_screenshot(parent=self) + self.screenShotWin.show() + + def addInstructionsMacOS(self): + self.scrollArea = QScrollArea() + _container = QWidget() + _layout = QVBoxLayout() + for t, text in enumerate(utils.install_javabridge_instructions_text()): + label = QLabel() + label.setText(text) + # label.setWordWrap(True) + if t == 1 or t == 2: + label.setWordWrap(True) + code_layout = QHBoxLayout() + code_layout.addWidget(label) + copyButton = QToolButton() + copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + copyButton.setIcon(QIcon(":edit-copy.svg")) + copyButton.setText("Copy") + if t == 1: + copyButton.textToCopy = utils._install_homebrew_command() + else: + copyButton.textToCopy = utils._brew_install_java_command() + copyButton.clicked.connect(self.copyToClipboard) + code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) + # code_layout.addStretch(1) + code_layout.setStretch(0, 2) + code_layout.setStretch(1, 0) + _layout.addLayout(code_layout) + else: + _layout.addWidget(label) + _container.setLayout(_layout) + self.scrollArea.setWidget(_container) + self.currentRow += 1 + self._layout.addWidget( + self.scrollArea, self.currentRow, 1, alignment=Qt.AlignTop + ) + + # Stretch last row + self.currentRow += 1 + self._layout.setRowStretch(self.currentRow, 1) + self.scrollArea.hide() + + def addInstructionsLinux(self): + self.scrollArea = QScrollArea() + _container = QWidget() + _layout = QVBoxLayout() + for t, text in enumerate(utils.install_javabridge_instructions_text()): + label = QLabel() + label.setText(text) + # label.setWordWrap(True) + if t == 1 or t == 2 or t == 3: + label.setWordWrap(True) + code_layout = QHBoxLayout() + code_layout.addWidget(label) + copyButton = QToolButton() + copyButton.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + copyButton.setIcon(QIcon(":edit-copy.svg")) + copyButton.setText("Copy") + if t == 1: + copyButton.textToCopy = utils._apt_update_command() + elif t == 2: + copyButton.textToCopy = utils._apt_install_java_command() + elif t == 3: + copyButton.textToCopy = utils._apt_gcc_command() + copyButton.clicked.connect(self.copyToClipboard) + code_layout.addWidget(copyButton, alignment=Qt.AlignLeft) + # code_layout.addStretch(1) + code_layout.setStretch(0, 2) + code_layout.setStretch(1, 0) + _layout.addLayout(code_layout) + else: + _layout.addWidget(label) + _container.setLayout(_layout) + self.scrollArea.setWidget(_container) + self.currentRow += 1 + self._layout.addWidget( + self.scrollArea, self.currentRow, 1, alignment=Qt.AlignTop + ) + + # Stretch last row + self.currentRow += 1 + self._layout.setRowStretch(self.currentRow, 1) + self.scrollArea.hide() + + def copyToClipboard(self): + cb = QApplication.clipboard() + cb.clear(mode=cb.Clipboard) + cb.setText(self.sender().textToCopy, mode=cb.Clipboard) + print("Command copied!") + + def showInstructions(self, checked): + if checked: + self.instructionsButton.setText("Hide instructions") + self.origHeight = self.height() + self.resize(self.width(), self.height() + 300) + self.scrollArea.show() + else: + self.instructionsButton.setText("Show instructions...") + self.scrollArea.hide() + func = partial(self.resize, self.width(), self.origHeight) + QTimer.singleShot(50, func) + + def installJava(self): + import subprocess + + try: + if is_mac: + try: + subprocess.check_call(["brew", "update"]) + except Exception as e: + subprocess.run( + utils._install_homebrew_command(), + check=True, + text=True, + shell=True, + ) + subprocess.run( + utils._brew_install_java_command(), + check=True, + text=True, + shell=True, + ) + elif is_linux: + subprocess.run( + utils._apt_gcc_command()(), check=True, text=True, shell=True + ) + subprocess.run( + utils._apt_update_command()(), check=True, text=True, shell=True + ) + subprocess.run( + utils._apt_install_java_command()(), + check=True, + text=True, + shell=True, + ) + self.close() + except Exception as e: + print("=======================") + traceback.print_exc() + print("=======================") + msg = myMessageBox(wrapText=False) + err_msg = html_utils.paragraph(""" + Automatic installation of Java failed.

    + Please, try manually by following the instructions provided + below (click on "Show instructions..." button). Thanks + """) + msg.critical(self, "Java installation failed", err_msg) + + def show(self, block=False): + super().show(block=False) + print(is_linux) + if is_win: + self.addInstructionsWindows() + elif is_mac: + self.addInstructionsMacOS() + elif is_linux: + self.addInstructionsLinux() + self.move(self.pos().x(), 20) + if is_win: + self.resize(self.width(), self.height() + 200) + if block: + self._block() + + def exec_(self): + self.show(block=True) + + +class selectTrackerGUI(QDialogListbox): + def __init__(self, SizeT, currentFrameNo=1, parent=None): + trackers = utils.get_list_of_trackers() + super().__init__( + "Select tracker", + "Select one of the following trackers", + trackers, + multiSelection=False, + parent=parent, + ) + self.setWindowTitle("Select tracker") + + self.selectFramesGroupbox = selectStartStopFrames( + SizeT, currentFrameNum=currentFrameNo, parent=parent + ) + + self.mainLayout.insertWidget(1, self.selectFramesGroupbox) + + def ok_cb(self, event): + if self.selectFramesGroupbox.warningLabel.text(): + return + else: + self.startFrame = self.selectFramesGroupbox.startFrame_SB.value() + self.stopFrame = self.selectFramesGroupbox.stopFrame_SB.value() + super().ok_cb(event) + + +class warnVisualCppRequired(myMessageBox): + def __init__(self, pkg_name="javabridge", parent=None): + super().__init__(parent) + self.screenShotWin = None + + self.setIcon(iconName="SP_MessageBoxWarning") + self.setWindowTitle(f"Installation of {pkg_name} info") + txt = html_utils.paragraph(f""" + Installation of {pkg_name} on Windows requires + Microsoft Visual C++ 14.0 or higher.

    + Cell-ACDC will anyway try to install {pkg_name} now.

    + If the installation fails, please close Cell-ACDC, + then download and install "Microsoft C++ Build Tools" + from the link below + before trying this module again.

    + + https://visualstudio.microsoft.com/visual-cpp-build-tools/ +

    + IMPORTANT: when installing "Microsoft C++ Build Tools" + make sure to select "Desktop development with C++". + Click "See the screenshot" for more details. + """) + seeScreenshotButton = QPushButton("See screenshot...") + okButton = okPushButton("Ok") + okButton = self.addButton("Ok") + okButton.disconnect() + okButton.clicked.connect(self.ok_cb) + self.addButton(seeScreenshotButton) + seeScreenshotButton.disconnect() + seeScreenshotButton.clicked.connect(self.viewScreenshot) + self.addCancelButton(connect=True) + self.addText(txt) + + def ok_cb(self): + self.cancel = False + self.close() + + def viewScreenshot(self, checked=False): + self.screenShotWin = view_visualcpp_screenshot(self) + self.screenShotWin.show() + + def closeEvent(self, event): + if self.screenShotWin is not None: + self.screenShotWin.close() + + return super().closeEvent(event) + +# Cross-module imports (deferred to avoid import cycles) +from .forms import ( + CopiableCommandWidget, + LabelsWidget, + selectStartStopFrames, +) +from .panels import ( + listWidget, +) + diff --git a/cellacdc/widgets/controls/forms.py b/cellacdc/widgets/controls/forms.py new file mode 100644 index 000000000..1a268672e --- /dev/null +++ b/cellacdc/widgets/controls/forms.py @@ -0,0 +1,1382 @@ +"""Composite controls: forms.""" + +"""GUI widgets: controls.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +from ..canvas.scrollbars import ( + sliderWithSpinBox, +) +from .inputs import ( + ComboBox, +) + +class selectStartStopFrames(QGroupBox): + def __init__(self, SizeT, currentFrameNum=0, parent=None): + super().__init__(parent) + selectFramesLayout = QGridLayout() + + self.startFrame_SB = QSpinBox() + self.startFrame_SB.setAlignment(Qt.AlignCenter) + self.startFrame_SB.setMinimum(1) + self.startFrame_SB.setMaximum(SizeT - 1) + self.startFrame_SB.setValue(currentFrameNum) + + self.stopFrame_SB = QSpinBox() + self.stopFrame_SB.setAlignment(Qt.AlignCenter) + self.stopFrame_SB.setMinimum(1) + self.stopFrame_SB.setMaximum(SizeT) + self.stopFrame_SB.setValue(SizeT) + + selectFramesLayout.addWidget(QLabel("Start frame n."), 0, 0) + selectFramesLayout.addWidget(self.startFrame_SB, 1, 0) + + selectFramesLayout.addWidget(QLabel("Stop frame n."), 0, 1) + selectFramesLayout.addWidget(self.stopFrame_SB, 1, 1) + + self.warningLabel = QLabel() + palette = self.warningLabel.palette() + palette.setColor(self.warningLabel.backgroundRole(), Qt.red) + palette.setColor(self.warningLabel.foregroundRole(), Qt.red) + self.warningLabel.setPalette(palette) + selectFramesLayout.addWidget( + self.warningLabel, 2, 0, 1, 2, alignment=Qt.AlignCenter + ) + + self.setLayout(selectFramesLayout) + + self.stopFrame_SB.valueChanged.connect(self._checkRange) + + def _checkRange(self): + start = self.startFrame_SB.value() + stop = self.stopFrame_SB.value() + if stop <= start: + self.warningLabel.setText("stop frame smaller than start frame") + else: + self.warningLabel.setText("") + + +class formWidget(QWidget): + sigApplyButtonClicked = Signal(object) + sigComputeButtonClicked = Signal(object) + + def __init__( + self, + widget, + initialVal=None, + stretchWidget=True, + widgetAlignment=None, + labelTextLeft="", + labelTextRight="", + font=None, + addInfoButton=False, + addApplyButton=False, + addComputeButton=False, + addActivateCheckbox=False, + key="", + infoTxt="", + valueGetterName="value", + toolTip="", + parent=None, + ): + QWidget.__init__(self, parent) + self.widget = widget + self.key = key + self.infoTxt = infoTxt + self.widgetAlignment = widgetAlignment + self.valueGetterName = valueGetterName + + widget.setParent(self) + + if isinstance(initialVal, bool): + widget.setChecked(initialVal) + elif isinstance(initialVal, str): + widget.setCurrentText(initialVal) + elif isinstance(initialVal, float) or isinstance(initialVal, int): + widget.setValue(initialVal) + + self.items = [] + + if font is None: + font = QFont() + font.setPixelSize(13) + + self.labelLeft = QClickableLabel(widget) + self.labelLeft.setText(labelTextLeft) + self.labelLeft.setFont(font) + self.items.append(self.labelLeft) + + if not stretchWidget: + widgetLayout = QHBoxLayout() + if widgetAlignment != "left": + widgetLayout.addStretch(1) + widgetLayout.addWidget(widget) + if widgetAlignment != "right": + widgetLayout.addStretch(1) + self.items.append(widgetLayout) + else: + self.items.append(widget) + + self.labelRight = QClickableLabel(widget) + self.labelRight.setText(labelTextRight) + self.labelRight.setFont(font) + self.items.append(self.labelRight) + + if toolTip: + self.labelLeft.setToolTip(toolTip) + self.widget.setToolTip(toolTip) + self.labelRight.setToolTip(toolTip) + + if addInfoButton: + infoButton = QPushButton(self) + infoButton.setCursor(Qt.WhatsThisCursor) + infoButton.setIcon(QIcon(":info.svg")) + if labelTextLeft: + infoButton.setToolTip(f'Info about "{self.labelLeft.text()}" parameter') + else: + infoButton.setToolTip( + f'Info about "{self.labelRight.text()}" measurement' + ) + infoButton.clicked.connect(self.showInfo) + self.infoButton = infoButton + self.items.append(infoButton) + + if addApplyButton: + applyButton = QPushButton(self) + applyButton.setCursor(Qt.PointingHandCursor) + applyButton.setCheckable(True) + applyButton.setIcon(QIcon(":apply.svg")) + applyButton.setToolTip(f"Apply this step and visualize results") + applyButton.clicked.connect(self.applyButtonClicked) + self.items.append(applyButton) + + if addComputeButton: + computeButton = QPushButton(self) + computeButton.setCursor(Qt.BusyCursor) + computeButton.setIcon(QIcon(":compute.svg")) + computeButton.setToolTip(f"Compute this step and visualize results") + computeButton.clicked.connect(self.computeButtonClicked) + self.items.append(computeButton) + + self.activateCheckbox = None + if addActivateCheckbox: + self.activateCheckbox = QCheckBox("Activate") + self.activateCheckbox.setChecked(False) + self.widget.setDisabled(True) + self.activateCheckbox.toggled.connect(self.setWidgetEnabled) + self.items.append(self.activateCheckbox) + + self.labelLeft.clicked.connect(self.tryChecking) + self.labelRight.clicked.connect(self.tryChecking) + + def setWidgetEnabled(self, checked): + self.widget.setDisabled(not checked) + + def value(self): + if self.activateCheckbox is None: + return getattr(self.widget, self.valueGetterName)() + + if not self.activateCheckbox.isChecked(): + return + + return getattr(self.widget, self.valueGetterName)() + + def tryChecking(self, label): + try: + self.widget.setChecked(not self.widget.isChecked()) + except AttributeError as e: + pass + + def applyButtonClicked(self): + self.sigApplyButtonClicked.emit(self) + + def computeButtonClicked(self): + self.sigComputeButtonClicked.emit(self) + + def showInfo(self): + msg = myMessageBox() + msg.setIcon() + msg.setWindowTitle(f"{self.labelLeft.text()} info") + msg.addText(self.infoTxt) + msg.addButton(" Ok ") + msg.exec_() + + def setDisabled(self, disabled: bool) -> None: + for item in self.items: + try: + item.setDisabled(disabled) + except Exception as err: + pass + + +class CheckboxesGroupBox(QGroupBox): + def __init__(self, texts, title="", checkable=False, parent=None): + super().__init__(parent) + + self.setTitle(title) + self.setCheckable(checkable) + layout = QVBoxLayout() + + scrollLayout = QVBoxLayout() + container = QWidget() + scrollarea = QScrollArea() + + self.checkBoxes = [] + for text in texts: + checkbox = QCheckBox(text) + checkbox.setChecked(True) + scrollLayout.addWidget(checkbox) + self.checkBoxes.append(checkbox) + + container.setLayout(scrollLayout) + scrollarea.setWidget(container) + layout.addWidget(scrollarea) + + buttonsLayout = QHBoxLayout() + selectAllButton = selectAllPushButton() + selectAllButton.sigClicked.connect(self.checkAll) + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(selectAllButton) + layout.addLayout(buttonsLayout) + + self.setLayout(layout) + + def checkAll(self, button, checked): + for checkBox in self.checkBoxes: + checkBox.setChecked(checked) + + +class guiTabControl(QTabWidget): + def __init__(self, *args): + super().__init__(args[0]) + + self._defaultPixelSize = None + + self.propsTab = QScrollArea(self) + + container = QWidget() + layout = QVBoxLayout() + + self.pixelSizeQGBox = PixelSizeGroupbox(parent=self.propsTab) + self.propsQGBox = objPropsQGBox(parent=self.propsTab) + self.intensMeasurQGBox = objIntesityMeasurQGBox(parent=self.propsTab) + + self.highlightCheckbox = QCheckBox("Highlight objects on mouse hover") + self.highlightCheckbox.setChecked(False) + + self.highlightSearchedCheckbox = QCheckBox("Highlight searched object") + self.highlightSearchedCheckbox.setChecked(True) + + highlightLayout = QHBoxLayout() + highlightLayout.addWidget(self.highlightCheckbox) + highlightLayout.addStretch(1) + highlightLayout.addWidget(QLabel("|")) + highlightLayout.addStretch(1) + highlightLayout.addWidget(self.highlightSearchedCheckbox) + + layout.addLayout(highlightLayout) + layout.addWidget(self.pixelSizeQGBox) + layout.addWidget(self.propsQGBox) + layout.addWidget(self.intensMeasurQGBox) + layout.addStretch(1) + container.setLayout(layout) + + self.propsTab.setWidgetResizable(True) + self.propsTab.setWidget(container) + self.addTab(self.propsTab, "Measurements") + + self.pixelSizeQGBox.sigValueChanged.connect(self.pixelSizeChanged) + self.pixelSizeQGBox.sigReset.connect(self.resetPixelSize) + + def addChannels(self, channels): + self.intensMeasurQGBox.addChannels(channels) + + def resetPixelSize(self): + if self._defaultPixelSize is None: + return + + self.initPixelSize(*self._defaultPixelSize) + + def initPixelSize(self, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ): + self.pixelSizeQGBox.pixelWidthWidget.setValue(PhysicalSizeX) + self.pixelSizeQGBox.pixelHeightWidget.setValue(PhysicalSizeY) + self.pixelSizeQGBox.voxelDepthWidget.setValue(PhysicalSizeZ) + self._defaultPixelSize = (PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) + + def pixelSizeChanged(self, PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ): + propsQGBox = self.propsQGBox + yx_pxl_to_um2 = PhysicalSizeY * PhysicalSizeX + vox_rot_to_fl = float(PhysicalSizeY) * pow(float(PhysicalSizeX), 2) + vox_3D_to_fl = PhysicalSizeZ * PhysicalSizeY * PhysicalSizeX + + area_pxl = propsQGBox.cellAreaPxlSB.value() + area_um2 = area_pxl * yx_pxl_to_um2 + propsQGBox.cellAreaUm2DSB.setValue(area_um2) + + vol_rot_vox = propsQGBox.cellVolVoxSB.value() + vol_rot_fl = vol_rot_vox * vox_rot_to_fl + propsQGBox.cellVolFlDSB.setValue(vol_rot_fl) + + vol_3D_vox = propsQGBox.cellVolVox3D_SB.value() + vol_3D_fl = vol_3D_vox * vox_3D_to_fl + propsQGBox.cellVolFl3D_DSB.setValue(vol_3D_fl) + + +class PostProcessSegmSlider(sliderWithSpinBox): + def __init__(self, *args, label=None, **kwargs): + super().__init__(*args, **kwargs) + + self.label = label + self.checkbox = QCheckBox("Disable") + self._layout.addWidget(self.checkbox, self.sliderCol, self.lastCol + 1) + self.checkbox.toggled.connect(self.onCheckBoxToggled) + self.valueChanged.connect(self.checkExpandRange) + + def onCheckBoxToggled(self, checked: bool) -> None: + super().setDisabled(checked) + if self.label is not None: + self.label.setDisabled(checked) + self.onValueChanged(None) + self.onEditingFinished() + + def onValueChanged(self, value): + self.valueChanged.emit(value) + + def checkExpandRange(self, value): + if value == self.maximum(): + range = int(self.maximum() - self.minimum()) + half_range = int(range / 2) + newMinimum = self.minimum() + half_range + newMaximum = self.maximum() + half_range + self.setMaximum(newMaximum) + self.setMinimum(newMinimum) + elif value == self.minimum(): + range = int(self.maximum() - self.minimum()) + half_range = int(range / 2) + newMinimum = self.minimum() - half_range + newMaximum = self.maximum() - half_range + self.setMaximum(newMaximum) + self.setMinimum(newMinimum) + + def onEditingFinished(self): + self.editingFinished.emit() + + def value(self): + if self.checkbox.isChecked(): + return None + else: + return super().value() + + +class PostProcessSegmSpinbox(QWidget): + valueChanged = Signal(int) + editingFinished = Signal() + sigCheckboxToggled = Signal() + + def __init__(self, *args, isFloat=False, label=None, **kwargs): + super().__init__(*args, **kwargs) + + layout = QHBoxLayout() + + if isFloat: + self.spinBox = DoubleSpinBox() + else: + self.spinBox = SpinBox() + + self.spinBox.valueChanged.connect(self.onValueChanged) + self.spinBox.editingFinished.connect(self.onEditingFinished) + + layout.addWidget(self.spinBox) + self.checkbox = QCheckBox("Disable") + layout.addWidget(self.checkbox) + layout.setStretch(0, 1) + layout.setStretch(1, 0) + + self.label = label + + self.checkbox.toggled.connect(self.onCheckBoxToggled) + + layout.setContentsMargins(5, 0, 5, 0) + + self.setLayout(layout) + + def onCheckBoxToggled(self, checked: bool) -> None: + self.spinBox.setDisabled(checked) + if self.label is not None: + self.label.setDisabled(checked) + self.onValueChanged(None) + self.onEditingFinished() + + def onValueChanged(self, value): + self.valueChanged.emit(value) + + def onEditingFinished(self): + self.editingFinished.emit() + + def maximum(self): + return self.spinBox.maximum() + + def setValue(self, value): + self.spinBox.setValue(value) + + def sizeHint(self): + return self.spinBox.sizeHint() + + def setMaximum(self, max): + self.spinBox.setMaximum(max) + + def setSingleStep(self, step): + self.spinBox.setSingleStep(step) + + def setMinimum(self, min): + self.spinBox.setMinimum(min) + + def setSingleStep(self, step): + self.spinBox.setSingleStep(step) + + def setDecimals(self, decimals): + self.spinBox.setDecimals(decimals) + + def value(self): + if self.checkbox.isChecked(): + return None + else: + return self.spinBox.value() + + +class CopiableCommandWidget(QGroupBox): + def __init__(self, command="", parent=None, font_size="13px"): + super().__init__(parent) + + layout = QHBoxLayout() + + label = QLabel(self) + self.label = label + self._font_size = font_size + self.setCommand(command, font_size=font_size) + label.setTextInteractionFlags( + Qt.TextBrowserInteraction | Qt.TextSelectableByKeyboard + ) + layout.addWidget(label) + layout.addWidget(QVLine(shadow="Plain", color="#4d4d4d")) + copyButton = copyPushButton("Copy", flat=True, hoverable=True) + copyButton.clicked.connect(self.copyToClipboard) + layout.addWidget(copyButton) + layout.addStretch(1) + + self.setLayout(layout) + + def setWordWrap(self, wordWrap): + self.label.setWordWrap(wordWrap) + + def copyToClipboard(self): + cb = QApplication.clipboard() + cb.clear(mode=cb.Clipboard) + cb.setText(self._command, mode=cb.Clipboard) + print("Command copied!") + + def setCommand(self, command, font_size=None): + if font_size is None: + font_size = self._font_size + + self._command = command + txt = html_utils.paragraph(f"{command}", font_size=font_size) + self.label.setText(txt) + + def command(self): + return self._command + + def text(self): + return self.label.text() + + def setTextInteractionFlags(self, flags): + self.label.setTextInteractionFlags(flags) + + +class LabelsWidget(QWidget): + def __init__(self, texts, wrapText=False, parent=None): + super().__init__(parent=parent) + + layout = QVBoxLayout() + + texts = self.fixParagraphTags(texts) + + self.textLengths = [] + self.labels = [] + for t, text in enumerate(texts): + if not text: + continue + + if text.startswith(""): + layout.addSpacing(10) + label = LatexLabel(text) + layout.addWidget(label, alignment=Qt.AlignCenter) + try: + # Add spacing only if next text is not a formula + nextText = texts[t + 1] + if not nextText.startswith(""): + layout.addSpacing(10) + except IndexError: + layout.addSpacing(10) + elif text.startswith(""): + text = text.removeprefix("").removeprefix("") + label = CopiableCommandWidget(command=text, parent=self) + layout.addWidget(label) + else: + label = QLabel(text) + label.setWordWrap(wrapText) + label.setOpenExternalLinks(True) + layout.addWidget(label) + if wrapText: + self.textLengths.append(1) + self.textLengths.extend([len(line) for line in text.split("
    ")]) + + self.labels.append(label) + + self.nCharsLongestLine = max(self.textLengths, default=1) + + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + def setWordWrap(self, wordWrap): + for label in self.labels: + label.setWordWrap(wordWrap) + + def fixParagraphTags(self, texts): + firstText = texts[0] + if firstText.find("

    ', firstText) + if searched is None: + openTag = '

    ' + else: + openTag = searched.group() + + not_allowed = {" ", "\n"} + + fixedTexts = [] + for text in texts: + if text.startswith("") or text.startswith(""): + fixedTexts.append(text) + continue + + if set(text) <= not_allowed: + # Ignore texts that are made of only \n and spaces + continue + + if text.find("

    ") == -1: + text = rf"{text}<\p>" + + if text.find(openTag) == -1: + text = f"{openTag}{text}" + + text = text.replace("\n", "") + + fixedTexts.append(text) + return fixedTexts + + +class SamInputPointsWidget(QWidget): + sigValueChanged = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + + _layout = QHBoxLayout() + + self.lineEntry = ElidingLineEdit(parent=self) + self.lineEntry.setAlignment(Qt.AlignCenter) + self.lineEntry.editingFinished.connect(self.emitValueChanged) + + self.editButton = editPushButton() + self.browseButton = browseFileButton( + ext={"CSV": ".csv"}, start_dir=utils.getMostRecentPath() + ) + + _layout.addWidget(self.lineEntry) + _layout.addWidget(self.editButton) + _layout.addWidget(self.browseButton) + + _layout.setStretch(0, 1) + _layout.setStretch(1, 0) + _layout.setStretch(1, 0) + + self.browseButton.sigPathSelected.connect(self.browseCsvFiles) + self.editButton.clicked.connect(self.showInfoEditPoints) + + _layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(_layout) + + def emitValueChanged(self, text): + self.sigValueChanged.emit(text) + + def showInfoEditPoints(self): + note = html_utils.to_note( + "When adding points with the mouse left button you will create a " + "new object for each point. To add multiple points for the same " + "object click the right button." + ) + txt = html_utils.paragraph(f""" + To add input points for Segment Anything open the GUI (module 3), + load the data, and then click on the button
    + on the top toolbar called Add points layer.

    + Select the option "Add points by clicking" and click on the image + to add points.

    + Finally, save the table and browse to the saved file on this widget. +
    {note} + """) + msg = myMessageBox(wrapText=False) + msg.information(self, "Info edit points", txt) + + def criticalMissingColumn(self, filepath, missing_col): + txt = html_utils.paragraph(f""" + [ERROR]: The selected table does not contain the column + {missing_col}.

    + A valid table must contain the columns (x, y, id) + with an additional z column for 3D z-stacks data. + """) + msg = myMessageBox(wrapText=False) + msg.critical(self, "Invalid table", txt) + + def setValue(self, value: str): + self.lineEntry.setText(value) + + def value(self): + return self.lineEntry.text() + + def cast_dtype(self, value) -> str: + return str(value) + + def browseCsvFiles(self, filepath): + # Check if metadata.csv file exists with basename and set only the + # endname of the file + df_points = pd.read_csv(filepath) + for col in ("x", "y", "id"): + if col not in df_points.columns: + self.criticalMissingColumn(filepath, col) + return + + # Check if basename is present in metadata + folderpath = os.path.dirname(filepath) + basename = None + for file in utils.listdir(folderpath): + if file.endswith("metadata.csv"): + metadata_csv_path = os.path.join(folderpath, file) + df = pd.read_csv(metadata_csv_path, index_col="Description") + try: + basename = df.at["basename", "values"] + except Exception as e: + basename = None + break + + # Check if file is inside images folder and get basename + is_images_folder = folderpath.endswith("Images") + if is_images_folder: + images_path = folderpath + img_filepath = None + for file in utils.listdir(images_path): + if file.endswith(".tif"): + img_filepath = os.path.join(images_path, file) + break + + if file.endswith("aligned.npz"): + img_filepath = os.path.join(images_path, file) + break + + if img_filepath is not None: + posData = load.loadData(img_filepath, "", QParent=self) + posData.getBasenameAndChNames() + filename = os.path.basename(filepath) + if filename.startswith(posData.basename): + basename = posData.basename + + if basename is None: + self.lineEntry.setText(filepath) + else: + filename = os.path.basename(filepath) + endname = filename[len(basename) :] + self.lineEntry.setText(endname) + + +class FontSizeWidget(QWidget): + sigTextChanged = Signal(str) + + def __init__(self, parent=None, unit="px", initalVal=12): + super().__init__(parent) + + layout = QHBoxLayout() + + self.spinbox = SpinBox() + self.spinbox.setValue(initalVal) + layout.addWidget(self.spinbox) + + self.unitLabel = QLabel(unit) + layout.addWidget(self.unitLabel) + + layout.setContentsMargins(0, 0, 0, 0) + layout.setStretch(0, 1) + layout.setStretch(1, 0) + + self.setLayout(layout) + + self.spinbox.valueChanged.connect(self.emitTextChanged) + + def emitTextChanged(self, value): + self.sigTextChanged.emit(self.text()) + + def setValue(self, value): + if isinstance(value, str): + value = int(value.replace(self.unitLabel.text(), "").strip()) + self.spinbox.setValue(value) + + def setText(self, text): + value = int(text.replace(self.unitLabel.text(), "").strip()) + self.setValue(value) + + def text(self): + return f"{self.spinbox.value()}{self.unitLabel.text()}" + + def value(self): + return self.spinbox.value() + + +class RangeSelector(QWidget): + sigRangeChanged = Signal(object, object) + sigLowValueChanged = Signal(object) + sigHighValueChanged = Signal(object) + sigRangeManuallyChanged = Signal(object, object) + + def __init__(self, parent=None, integers=False, ordered=True): + super().__init__(parent) + + self._integers = integers + self._ordered = ordered + + layout = QHBoxLayout() + + if integers: + self.lowSpinbox = SpinBox() + self.highSpinbox = SpinBox() + else: + self.lowSpinbox = DoubleSpinBox() + self.highSpinbox = DoubleSpinBox() + + layout.addWidget(self.lowSpinbox) + layout.addWidget(self.highSpinbox) + + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + self.lowSpinbox.valueChanged.connect(self.lowValueChanged) + self.highSpinbox.valueChanged.connect(self.highValueChanged) + + self.lowSpinbox.editingFinished.connect(self.lowValueEditingFinished) + self.highSpinbox.editingFinished.connect(self.highValueEditingFinished) + + def lowValueEditingFinished(self): + self.sigRangeManuallyChanged.emit(*self.range()) + self.emitRangeChanged() + + def highValueEditingFinished(self): + self.sigRangeManuallyChanged.emit(*self.range()) + self.emitRangeChanged() + + def lowValueChanged(self, value): + self.emitRangeChanged() + self.sigLowValueChanged.emit(value) + + def highValueChanged(self, value): + self.emitRangeChanged() + self.sigHighValueChanged.emit(value) + + def emitRangeChanged(self): + self.sigRangeChanged.emit(*self.range()) + + def setRangeNoEmit(self, lowValue, highValue, decimals=3): + self.lowSpinbox.valueChanged.disconnect() + self.highSpinbox.valueChanged.disconnect() + + self.setRange(round(lowValue, 3), round(highValue, 3)) + + self.lowSpinbox.valueChanged.connect(self.lowValueChanged) + self.highSpinbox.valueChanged.connect(self.highValueChanged) + + def setRange(self, lowValue, highValue): + # if lowValue > highValue and self._ordered: + # highValue = lowValue + 1 + + if self._integers: + lowValue = round(lowValue) + highValue = round(highValue) + + self.lowSpinbox.setValue(lowValue) + self.highSpinbox.setValue(highValue) + + def range(self): + return self.lowSpinbox.value(), self.highSpinbox.value() + + +class PreProcessingSelector(QComboBox): + sigValuesChanged = Signal(dict, int) + + def __init__(self, parent=None): + super().__init__(parent) + self._parent = parent + + self.addItems(PREPROCESS_MAPPER.keys()) + self.methodToDefaultValuesMapper = {} + self.step_n = -1 + self.setParamsWindow = None + + def htmlInfo(self): + href = html_utils.href_tag("GitHub page", urls.issues_url) + docstring = PREPROCESS_MAPPER[self.currentText()]["docstring"] + if docstring is None: + text = "This function is not documented, yet. Sorry :(" + else: + text = html_utils.rst_docstring_to_html(docstring) + text = ( + f"{text}

    " + f"Feel free to submit an issue on our {href} if you " + "need help with this filter." + ) + return text + + def setParams(self, method: str, kwargToValueMapper: Dict[str, str]): + self.methodToDefaultValuesMapper[method] = kwargToValueMapper + + def askSetParams(self, df_metadata=None, addApplyButton=False): + method = self.currentText() + function = PREPROCESS_MAPPER[method]["function"] + params_argspecs = utils.get_function_argspec( + function, + args_to_skip={"logger_func", "apply_to_all_zslices", "apply_to_all_frames"}, + ) + default_values = self.methodToDefaultValuesMapper.get(method, {}) + for kwarg, value in default_values.items(): + for p, param_argspec in enumerate(params_argspecs): + if param_argspec.name != kwarg: + continue + + if hasattr(param_argspec.type, "cast_dtype"): + cls = param_argspec.type + value = cls.cast_dtype(value) + else: + value = param_argspec.type(value) + + if value == param_argspec.default: + continue + param_argspec = param_argspec._replace(default=value) + params_argspecs[p] = param_argspec + + if self.setParamsWindow is not None: + self.setParamsWindow.raise_() + self.setParamsWindow.activateWindow() + return + + self.setParamsWindow = apps.FunctionParamsDialog( + params_argspecs, + df_metadata=df_metadata, + function_name=method, + addApplyButton=addApplyButton, + parent=self._parent, + ) + self.setParamsWindow.sigValuesChanged.connect(self.emitValuesChanged) + self.setParamsWindow.emitValuesChanged() + self.setParamsWindow.exec_() + if self.setParamsWindow.cancel: + return + + self.setParams(method, self.setParamsWindow.function_kwargs) + + function_kwargs = self.setParamsWindow.function_kwargs + self.setParamsWindow = None + + return function_kwargs + + def emitValuesChanged(self, functionKwargs: dict): + self.sigValuesChanged.emit(functionKwargs, self.step_n) + + +class RescaleImageJroisGroupbox(QGroupBox): + def __init__(self, TZYX_out_shape, parent=None): + super().__init__(parent) + + self.setTitle("Rescale ROIs") + self.setCheckable(True) + + gridLayout = QGridLayout() + + dims = ("Z", "Y", "X") + self.widgets = {} + for row, SizeD in enumerate(TZYX_out_shape[1:]): + if SizeD == 1: + continue + + dim = dims[row] + inputSpinbox = SpinBox() + inputSpinbox.setMinimum(1) + inputSpinbox.setValue(SizeD) + + outZwidget = QLineEdit() + outZwidget.setReadOnly(True) + outZwidget.setAlignment(Qt.AlignCenter) + # outZwidget.setValue(SizeD) + outZwidget.setText(str(SizeD)) + + row0 = row * 2 + row1 = row0 + 1 + gridLayout.addWidget(QLabel(f"{dim}-dimension: "), row1, 0) + + gridLayout.addWidget(QLabel("Input size"), row0, 1) + gridLayout.addWidget(inputSpinbox, row1, 1) + + gridLayout.addWidget(QLabel("Output size"), row0, 2) + gridLayout.addWidget(outZwidget, row1, 2) + + self.widgets[dim] = (inputSpinbox, SizeD) + + self.setLayout(gridLayout) + + def inputOutputSizes(self): + if not self.isChecked(): + return + + sizes = { + dim: (spinbox.value(), int(SizeD)) + for dim, (spinbox, SizeD) in self.widgets.items() + } + return sizes + + +class TimeWidget(QGroupBox): + sigValueChanged = Signal(object) + + def __init__(self, parent=None, orientation="vertical"): + super().__init__(parent) + + mainLayout = QHBoxLayout() + + if orientation == "vertical": + spinboxesLayout = QVBoxLayout() + elif orientation == "horizontal": + spinboxesLayout = QHBoxLayout() + else: + raise ValueError('orientation must be "vertical" or "horizontal"') + + self.signCombobox = QComboBox() + self.signCombobox.addItems(("+", "-")) + self.signCombobox.currentTextChanged.connect(self.emitValueChanged) + + mainLayout.addWidget(self.signCombobox) + + self.spinboxesMapper = {} + units = ("days", "hours", "minutes", "seconds") + for unit in units: + layout = QHBoxLayout() + spinbox = SpinBox() + spinbox.setMinimum(0) + label = QLabel(unit) + layout.addWidget(spinbox) + layout.addWidget(label) + spinbox.valueChanged.connect(self.emitValueChanged) + self.spinboxesMapper[unit] = spinbox + spinboxesLayout.addLayout(layout) + + mainLayout.addLayout(spinboxesLayout) + + self.setLayout(mainLayout) + mainLayout.setContentsMargins(5, 5, 5, 5) + + def values(self): + values = {} + for unit, spinbox in self.spinboxesMapper.items(): + values[unit] = spinbox.value() + + signText = self.signCombobox.currentText() + return values, sign_int_mapper[signText] + + def setValuesFromTimedelta(self, timedelta): + total_seconds = timedelta.total_seconds() + sign = 1 if total_seconds > 0 else -1 + days = timedelta.days + hours, remainder = divmod(timedelta.seconds, 3600) + minutes, seconds = divmod(remainder, 60) + + values = {"days": days, "hours": hours, "minutes": minutes, "seconds": seconds} + + self.setValues(values, sign=sign) + + def timedelta(self): + values, sign = self.values() + return datetime.timedelta(**values) * sign + + def setValues(self, values: dict[str, int | float], sign=1): + signText = "+" if sign > 0 else "-" + self.signCombobox.setCurrentText(signText) + for unit, value in values.items(): + spinbox = self.spinboxesMapper[unit] + spinbox.setValue(value) + + def emitValueChanged(self, value): + self.sigValueChanged.emit(self.values()) + + +class YeazV2SelectModelNameCombobox(ComboBox): + sigValueChanged = Signal(str) + + def __init__( + self, *args, custom_select_item_text="Select custom weights file...", **kwargs + ): + super().__init__(*args, **kwargs) + self._csi_text = custom_select_item_text + self.sigTextChanged.connect(self.onTextChanged) + self.initItems() + + def initItems(self): + from cellacdc.segmenters.YeaZ_v2 import load_models_filepath + + models_name, models_name_filepath_mapper = load_models_filepath() + self.addItems(models_name) + + def onTextChanged(self, text): + if text != self._csi_text: + return + + start_dir = utils.getMostRecentPath() + model_filepath = qtpy.compat.getopenfilename( + parent=self, + caption="Select YeaZ weights file", + filters="All Files (*)", + basedir=start_dir, + )[0] + if not model_filepath: + self.setCurrentIndex(0) + return + + msg = html_utils.paragraph(f""" + Insert a name for the following YeaZ model:

    + {model_filepath}
    + """) + modelNameWindow = apps.QLineEditDialog( + title="Insert a name for the model", msg=msg, allowEmpty=False, parent=self + ) + modelNameWindow.exec_() + if modelNameWindow.cancel: + self.setCurrentIndex(0) + return + + model_name = modelNameWindow.enteredValue + + from cellacdc.segmenters.YeaZ_v2 import add_model_filepath + + add_model_filepath(model_name, model_filepath) + + self.addItem(model_name) + self.setCurrentText(model_name) + + print( + "YeaZ_v2 model added!\n\n" + f" * Name: {model_name}\n" + f" * File path: {model_filepath}\n" + ) + + def addItem(self, item): + idx = self.count() - 1 + self.insertItem(idx, item) + + def addItems(self, items): + super().clear() + super().addItems(items) + super().addItem(self._csi_text) + idx = len(items) + font = self.font() + font.setItalic(True) + self.setItemData(idx, font, Qt.FontRole) + + def setValue(self, value: str): + self.setCurrentText(value) + + def value(self, *args): + return self.currentText() + + +class AutoSaveIntervalWidget(QWidget): + sigValueChanged = Signal(float, str) + + def __init__(self, parent=None): + super().__init__(parent) + + layout = QHBoxLayout() + + autoSaveIntervalTooltip = "Autosave every minutes or frames specified here." + + self.setToolTip(autoSaveIntervalTooltip) + + self.spinbox = DoubleSpinBox() + self.spinbox.setMinimum(0) + self.spinbox.setValue(2) + self.spinbox.setDecimals(2) + self.spinbox.setSingleStep(1.0) + + layout.addWidget(self.spinbox) + + self.unitCombobox = ComboBox() + self.unitCombobox.addItems(["minutes", "frames"]) + layout.addWidget(self.unitCombobox) + + layout.setStretch(0, 1) + layout.setStretch(1, 0) + layout.setContentsMargins(5, 0, 5, 0) + + self.setLayout(layout) + + self.spinbox.sigValueChanged.connect(self.emitSigValueChanged) + self.unitCombobox.sigTextChanged.connect(self.emitSigValueChanged) + + def emitSigValueChanged(self, *args, **kwargs): + self.sigValueChanged.emit(self.spinbox.value(), self.unitCombobox.currentText()) + + +class CheckableWidget(QWidget): + def __init__(self, widget, valueGetterName="value", parent=None): + super().__init__(parent) + + self.widget = widget + self.valueGetterName = valueGetterName + + widget.setDisabled(True) + + layout = QHBoxLayout() + + layout.addWidget(widget) + + self.checkbox = QCheckBox("Activate") + self.checkbox.toggled.connect(self.setWidgetEnabled) + + layout.addSpacing(5) + layout.addWidget(self.checkbox) + + layout.setContentsMargins(5, 0, 5, 0) + + self.setLayout(layout) + + def setWidgetEnabled(self, checked): + self.widget.setDisabled(not checked) + + def value(self): + if not self.checkbox.isChecked(): + return + + return getattr(self.widget, self.valueGetterName)() + +# Cross-module imports (deferred to avoid import cycles) +from .dialogs import ( + myMessageBox, +) +from .inputs import ( + DoubleSpinBox, + QClickableLabel, + SpinBox, +) +from .metrics import ( + PixelSizeGroupbox, + objIntesityMeasurQGBox, + objPropsQGBox, +) +from .panels import ( + LatexLabel, +) + diff --git a/cellacdc/widgets/controls/inputs.py b/cellacdc/widgets/controls/inputs.py new file mode 100644 index 000000000..a52b4c743 --- /dev/null +++ b/cellacdc/widgets/controls/inputs.py @@ -0,0 +1,976 @@ +"""Composite controls: inputs.""" + +"""GUI widgets: controls.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +class ExpandableListBox(QComboBox): + def __init__(self, parent=None, centered=True) -> None: + super().__init__(parent) + + self.setEditable(True) + self.lineEdit().setReadOnly(True) + + infoTxt = html_utils.paragraph( + "Select Positions to save

    " + "Ctrl+Click to select multiple items
    " + "Shift+Click to select a range of items
    ", + center=True, + ) + + self.listW = QDialogListbox( + "Select Positions to save", infoTxt, [], multiSelection=True, parent=self + ) + + self.listW.listBox.itemClicked.connect(self.listItemClicked) + self.listW.sigSelectionConfirmed.connect(self.updateCombobox) + + self.centered = centered + + def listItemClicked(self, item): + if item.text().find("All") == -1: + return + + for i in range(self.listW.listBox.count()): + _item = self.listW.listBox.item(i) + _item.setSelected(True) + + def clear(self) -> None: + self.listW.listBox.clear() + return super().clear() + + def setItems(self, items): + self.clear() + self.addItems(items) + + def addItems(self, items): + super().addItems(items) + self.listW.listBox.addItems(items) + self.listW.listBox.setCurrentRow(self.currentIndex()) + self.listItemClicked(self.listW.listBox.currentItem()) + if self.centered: + self.centerItems() + + def updateCombobox(self, selectedItemsText): + isAllItem = [i for i, t in enumerate(selectedItemsText) if t.find("All") != -1] + if len(selectedItemsText) == 1: + self.setCurrentText(selectedItemsText[0]) + elif isAllItem: + idx = isAllItem[0] + self.setCurrentText(selectedItemsText[idx]) + else: + super().clear() + super().addItems(["Custom selection"]) + + def centerItems(self, idx=None): + self.lineEdit().setAlignment(Qt.AlignCenter) + + def selectedItems(self): + return self.listW.listBox.selectedItems() + + def selectedItemsText(self): + return [item.text() for item in self.selectedItems()] + + def showPopup(self) -> None: + self.listW.show() + + +class QClickableLabel(QLabel): + clicked = Signal(object) + + def __init__(self, parent=None): + self._parent = parent + super().__init__(parent) + self._checkableItem = None + + def setCheckableItem(self, widget): + self._checkableItem = widget + + def mousePressEvent(self, event): + self.clicked.emit(self) + if self._checkableItem is not None: + status = not self._checkableItem.isChecked() + self._checkableItem.setChecked(status) + + def setChecked(self, checked): + self._checkableItem.setChecked(checked) + + +class QCenteredComboBox(QComboBox): + def __init__(self, parent=None) -> None: + super().__init__(parent) + + self.setEditable(True) + self.lineEdit().setReadOnly(True) + self.lineEdit().setAlignment(Qt.AlignCenter) + self.lineEdit().installEventFilter(self) + + self.currentIndexChanged.connect(self.centerItems) + + self._isPopupVisibile = False + + def centerItems(self, idx): + for i in range(self.count()): + self.setItemData(i, Qt.AlignCenter, Qt.TextAlignmentRole) + + def eventFilter(self, lineEdit, event): + # Reimplement show popup on click + if event.type() == QEvent.Type.MouseButtonPress and self.isEnabled(): + if self._isPopupVisibile: + self.hidePopup() + self._isPopupVisibile = False + else: + self.showPopup() + self._isPopupVisibile = True + return True + return False + + +class AlphaNumericComboBox(QCenteredComboBox): + def __init__(self, parent=None) -> None: + super().__init__(parent=parent) + + def addItems(self, items): + self._dtype = type(items[0]) + super().addItems([str(item) for item in items]) + + def setCurrentValue(self, value): + super().setCurrentText(str(value)) + + def currentValue(self): + return self._dtype(super().currentText()) + + +class mySpinBox(QSpinBox): + sigTabEvent = Signal(object, object) + + def __init__(self, *args) -> None: + super().__init__(*args) + + def event(self, event): + if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key_Tab: + self.sigTabEvent.emit(event, self) + return True + + return super().event(event) + + +class ShortcutLineEdit(QLineEdit): + def __init__(self, parent=None, allowModifiers=False, notAllowedModifier=None): + self.keySequence = None + super().__init__(parent) + self._allowModifiers = allowModifiers + self._notAllowedModifier = notAllowedModifier + self.setAlignment(Qt.AlignCenter) + + def text(self): + text = macShortcutToWindows(super().text()) + + return text + + def setText(self, text): + text = windowsShortcutToMac(text) + + super().setText(text) + if not text: + self.keySequence = None + return + try: + self.keySequence = KeySequenceFromText(self.text()) + except Exception as e: + pass + + def keyPressEvent(self, event: QKeyEvent): + if event.key() == Qt.Key_Backspace or event.key() == Qt.Key_Delete: + self.setText("") + return + + keySequenceText = QKeyEventToString( + event, notAllowedModifier=self._notAllowedModifier + ) + self.setText(keySequenceText) + self.key = event.key() + + def keyReleaseEvent(self, event: QKeyEvent) -> None: + if self.text().endswith("+"): + if not self._allowModifiers: + self.setText("") + else: + self.setText(self.text().rstrip("+").strip()) + + +class CenteredDoubleSpinbox(QDoubleSpinBox): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setAlignment(Qt.AlignCenter) + self.setMaximum(2**31 - 1) + + +class readOnlyDoubleSpinbox(QDoubleSpinBox): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setReadOnly(True) + self.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) + self.setAlignment(Qt.AlignCenter) + self.setMaximum(2**31 - 1) + + +class readOnlySpinbox(QSpinBox): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setReadOnly(True) + self.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) + self.setAlignment(Qt.AlignCenter) + self.setMaximum(2**31 - 1) + + +class DoubleSpinBox(QDoubleSpinBox): + sigValueChanged = Signal(int) + + def __init__(self, parent=None, disableKeyPress=False): + super().__init__(parent=parent) + self.setAlignment(Qt.AlignCenter) + self.setMaximum(2**31 - 1) + self.setMinimum(-(2**31)) + self._valueChangedFunction = None + self.disableKeyPress = disableKeyPress + + def keyPressEvent(self, event) -> None: + isBackSpaceKey = event.key() == Qt.Key_Backspace + isDeleteKey = event.key() == Qt.Key_Delete + try: + int(event.text()) + isIntegerKey = True + except: + isIntegerKey = False + acceptEvent = isBackSpaceKey or isDeleteKey or isIntegerKey + if self.disableKeyPress and not acceptEvent: + event.ignore() + self.clearFocus() + else: + super().keyPressEvent(event) + + def textFromValue(self, value: float) -> str: + text = super().textFromValue(value) + return text.replace(QLocale().decimalPoint(), ".") + + def valueFromText(self, text: str) -> float: + text = text.replace(".", QLocale().decimalPoint()) + return super().valueFromText(text) + + +class SpinBox(QSpinBox): + sigValueChanged = Signal(int) + sigUpClicked = Signal() + sigDownClicked = Signal() + + def __init__(self, parent=None, disableKeyPress=False, allowNegative=True): + super().__init__(parent=parent) + self.setAlignment(Qt.AlignCenter) + self.setMaximum(2**31 - 1) + if allowNegative: + self.setMinimum(-(2**31)) + else: + self.setMinimum(0) + self._valueChangedFunction = None + self.disableKeyPress = disableKeyPress + self._linkedWidget = None + + def mousePressEvent(self, event) -> None: + super().mousePressEvent(event) + opt = QStyleOptionSpinBox() + self.initStyleOption(opt) + + control = self.style().hitTestComplexControl( + QStyle.ComplexControl.CC_SpinBox, opt, event.pos(), self + ) + if control == QStyle.SubControl.SC_SpinBoxUp: + self.sigUpClicked.emit() + elif control == QStyle.SubControl.SC_SpinBoxDown: + self.sigDownClicked.emit() + + # def focusOutEvent(self, event): + # self.editingFinished.emit() + # super().focusOutEvent(event) + # printl('emitted') + + def keyPressEvent(self, event) -> None: + isBackSpaceKey = event.key() == Qt.Key_Backspace + isDeleteKey = event.key() == Qt.Key_Delete + try: + int(event.text()) + isIntegerKey = True + except: + isIntegerKey = False + acceptEvent = isBackSpaceKey or isDeleteKey or isIntegerKey + if self.disableKeyPress and not acceptEvent: + event.ignore() + self.clearFocus() + else: + super().keyPressEvent(event) + + def connectValueChanged(self, function): + self._valueChangedFunction = function + self.valueChanged.connect(function) + + def setValue(self, value, setLinkedWidget=True): + super().setValue(int(value)) + if self._linkedWidget is not None and setLinkedWidget: + self._linkedWidget.setValue(value) + + def setValueNoEmit(self, value): + if self._valueChangedFunction is None: + self.setValue(value) + return + try: + self.valueChanged.disconnect() + except TypeError as e: # this fails if its not cennected yet + pass + + self.setValue(value) + self.valueChanged.connect(self._valueChangedFunction) + + def wheelEvent(self, event): + event.ignore() + + def setLinkedValueWidget(self, widget): + self._linkedWidget = widget + + +class ReadOnlyLineEdit(QLineEdit): + def __init__(self, parent=None): + super().__init__(parent=parent) + self.setReadOnly(True) + # self.setStyleSheet( + # 'background-color: rgba(240, 240, 240, 200);' + # ) + self.installEventFilter(self) + + def eventFilter(self, a0: "QObject", a1: "QEvent") -> bool: + if a1.type() == QEvent.Type.FocusIn: + return True + return super().eventFilter(a0, a1) + + def setValue(self, value): + self.setText(str(value)) + + def value(self, casting_func: callable = None): + text = self.text() + if casting_func is not None: + return casting_func(text) + return text + + +class FloatLineEdit(QLineEdit): + valueChanged = Signal(float) + + def __init__( + self, + *args, + notAllowed=None, + allowNegative=True, + initial=None, + readOnly=False, + decimals=6, + warningValues=None, + ): + QLineEdit.__init__(self, *args) + if readOnly: + self.setReadOnly(readOnly) + self.notAllowed = notAllowed + self.warningValues = warningValues + self._maximum = np.inf + self._minimum = -np.inf + self._decimals = decimals + + self.isNumericRegExp = rf"^{float_regex(allow_negative=allowNegative)}$" + regExp = QRegularExpression(self.isNumericRegExp) + self.setValidator(QRegularExpressionValidator(regExp)) + self.setAlignment(Qt.AlignCenter) + + font = QFont() + font.setPixelSize(11) + self.setFont(font) + + self.textChanged.connect(self.emitValueChanged) + + if initial is not None: + self.setValue(initial) + else: + self.setValue(0) + + def setDecimals(self, decimals): + self._decimals = 6 + + def castMinMax(self, value: int): + if value > self._maximum: + value = self._maximum + if value < self._minimum: + value = self._minimum + return value + + def setValue(self, value: float): + value = self.castMinMax(value) + self.setText(str(round(value, self._decimals))) + + def value(self): + m = re.match(self.isNumericRegExp, self.text()) + if m is not None: + text = m.group(0) + try: + val = float(text) + except ValueError: + val = 0.0 + else: + val = 0.0 + + return self.castMinMax(val) + + def setMaximum(self, maximum): + self._maximum = maximum + self.setValue(self.value()) + + def setMinimum(self, minimum): + self._minimum = minimum + self.setValue(self.value()) + + def emitValueChanged(self, text): + val = self.value() + reset_stylesheet = True + if self.warningValues is not None and val in self.warningValues: + self.setStyleSheet(LINEEDIT_WARNING_STYLESHEET) + reset_stylesheet = False + + if self.notAllowed is not None and val in self.notAllowed: + self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) + reset_stylesheet = False + else: + self.valueChanged.emit(self.value()) + + if reset_stylesheet: + self.setStyleSheet("") + + +class IntLineEdit(QLineEdit): + valueChanged = Signal(float) + + def __init__( + self, *args, notAllowed=None, allowNegative=True, initial=None, readOnly=False + ): + QLineEdit.__init__(self, *args) + self.notAllowed = notAllowed + if readOnly: + self.setReadOnly(readOnly) + + self._maximum = np.inf + self._minimum = -np.inf + + self._regExp = r"\d+" + if allowNegative: + self._regExp = r"-?\d+" + + regExp = QRegularExpression(self._regExp) + self.setValidator(QRegularExpressionValidator(regExp)) + self.setAlignment(Qt.AlignCenter) + + font = QFont() + font.setPixelSize(11) + self.setFont(font) + + self.textChanged.connect(self.emitValueChanged) + + if initial is not None: + self.setValue(initial) + else: + self.setValue(0) + + def setMaximum(self, maximum): + self._maximum = maximum + self.setValue(self.value()) + + def setMinimum(self, minimum): + self._minimum = minimum + self.setValue(self.value()) + + def castMinMax(self, value: int): + if value > self._maximum: + value = self._maximum + if value < self._minimum: + value = self._minimum + return value + + def setValue(self, value: int): + value = self.castMinMax(value) + self.setText(str(value)) + + def value(self): + m = re.match(self._regExp, self.text()) + if m is not None: + text = m.group(0) + try: + val = int(text) + except ValueError: + val = 0 + else: + val = 0 + + return self.castMinMax(val) + + def emitValueChanged(self, text): + if not text: + return + + val = self.value() + self.setValue(val) + if self.notAllowed is not None and val in self.notAllowed: + self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) + else: + self.setStyleSheet("") + self.valueChanged.emit(self.value()) + + +class highlightableQWidgetAction(QWidgetAction): + def __init__(self, parent) -> None: + super().__init__(parent) + + +class ComboBox(QComboBox): + sigTextChanged = Signal(str) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._previousText = None + self._valueChanged = False + self.currentTextChanged.connect(self.emitTextChanged) + self.installEventFilter(self) + + def eventFilter(self, object, event) -> bool: + if object == self and event.type() == QEvent.Type.Wheel: + # Forward event to parent so QScrollArea can scroll + QApplication.sendEvent(self.parent(), event) + return True # Consume for the combo itself + + return super().eventFilter(object, event) + + def text(self): + return self.currentText() + + def emitTextChanged(self, text): + self._valueChanged = True + self.sigTextChanged.emit(text) + + def mousePressEvent(self, event): + self._previousText = self.currentText() + super().mousePressEvent(event) + + def previousText(self): + return self._previousText + + def addItems(self, items): + super().addItems(items) + self._previousText = items[0] + + def itemsText(self): + return [self.itemText(i) for i in range(self.count())] + + def setCurrentIndex(self, idx): + itemsText = self.itemsText() + currentText = itemsText[idx] + self._valueChanged = currentText != self._previousText + self._previousText = self.currentText() + super().setCurrentIndex(idx) + + def setCurrentText(self, text): + currentText = text + self._valueChanged = currentText != self._previousText + self._previousText = self.currentText() + super().setCurrentText(text) + + +class SearchLineEdit(QLineEdit): + def __init__(self, parent=None): + super().__init__(parent) + + self.initSearch() + self.setFocusPolicy(Qt.ClickFocus) + + def focusInEvent(self, event) -> None: + super().focusInEvent(event) + if super().text() == "Search...": + self.setText("") + self.setStyleSheet("") + + def focusOutEvent(self, event) -> None: + super().focusOutEvent(event) + if not super().text(): + self.initSearch() + + def initSearch(self): + self.setText("Search...") + self.setStyleSheet("color: rgb(150, 150, 150)") + self.clearFocus() + + def text(self): + if super().text() == "Search...": + return "" + return super().text() + + +class VectorLineEdit(QLineEdit): + valueChanged = Signal(object) + valueChangeFinished = Signal(object) + + def __init__(self, parent=None, initial=None): + super().__init__(parent) + + self._minimum = -np.inf + + float_re = float_regex() + vector_regex = rf"\(?\[?{float_re}(,\s?{float_re})+\)?\]?" + regex = rf"^{vector_regex}$|^{float_re}$" + self.validRegex = regex + + regExp = QRegularExpression(regex) + self.setValidator(QRegularExpressionValidator(regExp)) + self.setAlignment(Qt.AlignCenter) + + self.textChanged.connect(self.emitValueChanged) + self.editingFinished.connect(self.emitValueChangeFinished) + if initial is None: + self.setText("0.0") + + font = QFont() + font.setPixelSize(11) + self.setFont(font) + + def emitValueChangeFinished(self): + value = self.value() + self.textChanged.disconnect() + self.editingFinished.disconnect() + self.setValue(value) + self.textChanged.connect(self.emitValueChanged) + self.editingFinished.connect(self.emitValueChangeFinished) + + self.emitValueChanged(self.text(), signal=self.valueChangeFinished) + + def emitValueChanged(self, text, signal=None): + m = re.match(self.validRegex, text) + if m is None: + self.setStyleSheet(LINEEDIT_INVALID_ENTRY_STYLESHEET) + return + + if signal is None: + signal = self.valueChanged + + self.setStyleSheet("") + signal.emit(self.value()) + + def increaseValue(self, step): + value = self.value() + if isinstance(value, (float, int)): + value += step + else: + value = [val + step for val in value] + value = str(value).lstrip("[").rstrip("]") + self.setValue(value) + self.emitValueChangeFinished() + + def decreaseValue(self, step): + value = self.value() + if isinstance(value, (float, int)): + value -= step + else: + value = [val - step for val in value] + value = str(value).lstrip("[").rstrip("]") + self.setText(value) + self.emitValueChangeFinished() + + def setValue(self, value): + if isinstance(value, (float, int)): + if value < self._minimum: + value = self._minimum + else: + clipped = [] + for val in value: + if val < self._minimum: + val = self._minimum + clipped.append(val) + value = str(clipped).lstrip("[").rstrip("]") + self.setText(value) + + def setText(self, text): + super().setText(str(text)) + + def clipValue(self, val: float): + if val < self._minimum: + val = self._minimum + return val + + def value(self): + m = re.match(self.validRegex, self.text()) + if m is None: + return 0.0 + + try: + value = self.clipValue(float(self.text())) + return value + except Exception as e: + text = self.text() + text = text.replace("(", "") + text = text.replace(")", "") + text = text.replace("[", "") + text = text.replace("]", "") + values = text.split(",") + return [self.clipValue(float(value)) for value in values] + + def setMinimum(self, minimum): + self._minimum = float(minimum) + + +class OddSpinBox(SpinBox): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setSingleStep(2) + self.editingFinished.connect(self.roundToOdd) + + def roundToOdd(self): + if self.value() % 2 == 1: + return + + self.setValue(self.value() + 1) + + +class LineEdit(QLineEdit): + def __init__(self, parent=None): + super().__init__(parent) + self.setAlignment(Qt.AlignCenter) + + def value(self): + return self.text() + + def setValue(self, value): + self.setText(str(value)) + + +class WhitelistLineEdit(KeepIDsLineEdit): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def setText(self, IDs): + if not isinstance(IDs, set) and not isinstance(IDs, list): + raise TypeError("IDs must be a set or list") + + formatted_text = utils.format_IDs(IDs) + super().setText(formatted_text) + + +class KeySequenceFromText(QKeySequence): + def __init__(self, text: str): + if isinstance(text, str): + text = macShortcutToWindows(text) + super().__init__(text) + self._text = text + + def toString(self): + if isinstance(self._text, str): + return windowsShortcutToMac(self._text) + else: + return windowsShortcutToMac(super().toString()) + +# Cross-module imports (deferred to avoid import cycles) +from .dialogs import ( + QDialogListbox, +) + diff --git a/cellacdc/widgets/controls/metrics.py b/cellacdc/widgets/controls/metrics.py new file mode 100644 index 000000000..89e9e8271 --- /dev/null +++ b/cellacdc/widgets/controls/metrics.py @@ -0,0 +1,1049 @@ +"""Composite controls: metrics.""" + +"""GUI widgets: controls.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +class _metricsQGBox(QGroupBox): + sigDelClicked = Signal(str, object) + + def __init__( + self, + desc_dict, + title, + favourite_funcs=None, + isZstack=False, + equations=None, + addDelButton=False, + delButtonMetricsDesc=None, + parent=None, + addCalcForEachZsliceToggle=False, + ): + QGroupBox.__init__(self, parent) + + highlightRgba = _palettes._highlight_rgba() + r, g, b, a = highlightRgba + self._highlightStylesheetColor = f"rgb({r}, {g}, {b})" + + self._parent = parent + self.scrollArea = QScrollArea() + self.scrollAreaWidget = QWidget() + self.favourite_funcs = favourite_funcs + + self.doNotWarn = False + + layout = QVBoxLayout() + inner_layout = QVBoxLayout() + self.inner_layout = inner_layout + if delButtonMetricsDesc is None: + delButtonMetricsDesc = [] + + self.checkBoxes = [] + self.checkedState = {} + for metric_colname, metric_desc in desc_dict.items(): + rowLayout = QHBoxLayout() + + checkBox = QCheckBox(metric_colname) + checkBox.setChecked(True) + checkBox.scrollArea = self.scrollArea + self.checkBoxes.append(checkBox) + self.checkedState[checkBox] = True + + try: + checkBox.equation = equations[metric_colname] + except Exception as e: + pass + + if addDelButton or metric_colname in delButtonMetricsDesc: + delButton = delPushButton() + delButton.setToolTip("Delete custom combined measurement") + delButton.colname = metric_colname + delButton.checkbox = checkBox + delButton.clicked.connect(self.onDelClicked) + delButton._layout = rowLayout + rowLayout.addWidget(delButton) + + infoButton = infoPushButton() + infoButton.setCursor(Qt.WhatsThisCursor) + infoButton.info = metric_desc + infoButton.colname = metric_colname + infoButton.clicked.connect(self.showInfo) + + rowLayout.addWidget(infoButton) + rowLayout.addWidget(checkBox) + rowLayout.addStretch(1) + + inner_layout.addLayout(rowLayout) + + self.scrollAreaWidget.setLayout(inner_layout) + self.scrollArea.setWidget(self.scrollAreaWidget) + layout.addWidget(self.scrollArea) + + buttonsLayout = QHBoxLayout() + + buttonsLayout.addStretch(1) + + self.selectAllButton = selectAllPushButton() + self.selectAllButton.sigClicked.connect(self.checkAll) + + buttonsLayout.addWidget(self.selectAllButton) + + if favourite_funcs is not None: + self.loadFavouritesButton = reloadPushButton(" Load last selection... ") + self.loadFavouritesButton.clicked.connect(self.checkFavouriteFuncs) + # self.checkFavouriteFuncs() + buttonsLayout.addWidget(self.loadFavouritesButton) + + layout.addLayout(buttonsLayout) + + self.calcForEachZsliceToggle = None + if addCalcForEachZsliceToggle: + buttonsLayout = QHBoxLayout() + self.calcForEachZsliceToggle = Toggle() + tooltip = ( + "Calculate `cell_area` for each z-slice.\n\n" + "The measurements will be saved in the column with name\n" + "ending with `_zsliceN` where N is the z-slice number\n" + "(starting from 0)." + ) + calcForEachZsliceLabel = QClickableLabel("Calculate for each z-slice") + calcForEachZsliceLabel.setToolTip(tooltip) + self.calcForEachZsliceToggle.setToolTip(tooltip) + buttonsLayout.addWidget(self.calcForEachZsliceToggle) + buttonsLayout.addWidget(calcForEachZsliceLabel) + buttonsLayout.addStretch(1) + layout.addLayout(buttonsLayout) + calcForEachZsliceLabel.clicked.connect( + partial( + self.toggleCalcForEachZslice, toggle=self.calcForEachZsliceToggle + ) + ) + + self.setTitle(title) + self.setCheckable(True) + self.setLayout(layout) + _font = QFont() + _font.setPixelSize(11) + self.setFont(_font) + + self.toggled.connect(self.toggled_cb) + + def toggleCalcForEachZslice(self, label, toggle=None): + if toggle is None: + toggle = self.calcForEachZsliceToggle + + toggle.setChecked(not toggle.isChecked()) + + def isCalcForEachZsliceRequested(self): + if self.calcForEachZsliceToggle is None: + return False + + return self.calcForEachZsliceToggle.isChecked() + + def highlightCheckboxesFromSearchText(self, text): + for checkbox in self.checkBoxes: + if not text: + highlighted = False + else: + highlighted = checkbox.text().lower().find(text.lower()) != -1 + + self.setCheckboxHighlighted(highlighted, checkbox) + + def setCheckboxHighlighted(self, highlighted, checkbox): + if highlighted: + checkbox.setStyleSheet( + f"background: {self._highlightStylesheetColor}; color: black" + ) + self.scrollArea.ensureWidgetVisible(checkbox) + else: + checkbox.setStyleSheet("") + + def onDelClicked(self): + button = self.sender() + button.checkbox.setChecked(False) + self.sigDelClicked.emit(button.colname, button._layout) + + def toggled_cb(self, checked): + for checkbox in self.checkBoxes: + if not checked: + self.checkedState[checkbox] = checkbox.isChecked() + checkbox.setChecked(False) + else: + checkbox.setChecked(self.checkedState[checkbox]) + + def checkFavouriteFuncs(self, checked=True, isZstack=False): + self.doNotWarn = True + if self._parent is not None: + self._parent.doNotWarn = True + for checkBox in self.checkBoxes: + checkBox.setChecked(False) + for favourite_func in self.favourite_funcs: + func_name = checkBox.text() + if func_name.endswith(favourite_func): + checkBox.setChecked(True) + break + self.doNotWarn = False + if self._parent is not None: + self._parent.doNotWarn = False + + def checkAll(self, button, checked): + if self._parent is not None: + self._parent.doNotWarn = True + for checkBox in self.checkBoxes: + checkBox.setChecked(checked) + if self._parent is not None: + self._parent.doNotWarn = False + + def showInfo(self, checked=False): + info_txt = self.sender().info + msg = myMessageBox() + msg.setWidth(600) + msg.setIcon() + msg.setWindowTitle(f"{self.sender().colname} info") + msg.addText(info_txt) + msg.addButton(" Ok ") + msg.exec_() + + def show(self): + super().show() + fw = self.inner_layout.contentsRect().width() + sw = self.scrollArea.verticalScrollBar().sizeHint().width() + self.minWidth = fw + sw + + +class channelMetricsQGBox(QGroupBox): + sigDelClicked = Signal(str, object) + sigCheckboxToggled = Signal(object) + + def __init__( + self, + isZstack, + chName, + isSegm3D, + is_concat=False, + posData=None, + favourite_funcs=None, + ): + QGroupBox.__init__(self) + + self.doNotWarn = False + self.is_concat = is_concat + isManualBackgrPresent = False + if posData is not None: + if posData.manualBackgroundLab is not None: + isManualBackgrPresent = True + + layout = QVBoxLayout() + metrics_desc, bkgr_val_desc = measurements.standard_metrics_desc( + isZstack, + chName, + isSegm3D=isSegm3D, + isManualBackgrPresent=isManualBackgrPresent, + ) + + metricsQGBox = _metricsQGBox( + metrics_desc, + "Standard measurements", + favourite_funcs=favourite_funcs, + parent=self, + isZstack=isZstack, + ) + self.metricsQGBox = metricsQGBox + + bkgrValsQGBox = _metricsQGBox( + bkgr_val_desc, + "Background values", + favourite_funcs=favourite_funcs, + parent=self, + isZstack=isZstack, + ) + self.bkgrValsQGBox = bkgrValsQGBox + + self.checkBoxes = metricsQGBox.checkBoxes.copy() + self.checkBoxes.extend(bkgrValsQGBox.checkBoxes) + + self.uncheckAndDisableDataPrepIfPosNotPrepped(posData) + + self.groupboxes = [metricsQGBox, bkgrValsQGBox] + + for checkbox in metricsQGBox.checkBoxes: + checkbox.toggled.connect(self.standardMetricToggled) + self.standardMetricToggled(checkbox.isChecked(), checkbox=checkbox) + + for bkgrCheckbox in bkgrValsQGBox.checkBoxes: + bkgrCheckbox.toggled.connect(self.backgroundMetricToggled) + + layout.addWidget(metricsQGBox) + layout.addWidget(bkgrValsQGBox) + + items = measurements.custom_metrics_desc( + isZstack, chName, posData=posData, isSegm3D=isSegm3D, return_combine=True + ) + custom_metrics_desc, combine_metrics_desc = items + + if custom_metrics_desc: + customMetricsQGBox = _metricsQGBox( + custom_metrics_desc, + "Custom measurements", + delButtonMetricsDesc=combine_metrics_desc, + favourite_funcs=favourite_funcs, + isZstack=isZstack, + ) + layout.addWidget(customMetricsQGBox) + self.checkBoxes.extend(customMetricsQGBox.checkBoxes) + customMetricsQGBox.sigDelClicked.connect(self.onDelClicked) + self.customMetricsQGBox = customMetricsQGBox + + self.calcForEachZsliceToggle = None + if isZstack: + buttonsLayout = QHBoxLayout() + self.calcForEachZsliceToggle = Toggle() + tooltip = ( + "Calculate the selected measurements for each z-slice.\n\n" + "The measurements will be saved in the column with name\n" + "ending with `_zsliceN` where N is the z-slice number\n" + "(starting from 0)." + ) + calcForEachZsliceLabel = QClickableLabel("Calculate for each z-slice") + calcForEachZsliceLabel.setToolTip(tooltip) + self.calcForEachZsliceToggle.setToolTip(tooltip) + buttonsLayout.addWidget(self.calcForEachZsliceToggle) + buttonsLayout.addWidget(calcForEachZsliceLabel) + buttonsLayout.addStretch(1) + layout.addLayout(buttonsLayout) + calcForEachZsliceLabel.clicked.connect( + partial( + self.toggleCalcForEachZslice, toggle=self.calcForEachZsliceToggle + ) + ) + + self.setTitle(f"{chName} metrics") + self.setCheckable(True) + self.setLayout(layout) + + def toggleCalcForEachZslice(self, label, toggle=None): + if toggle is None: + toggle = self.calcForEachZsliceToggle + + toggle.setChecked(not toggle.isChecked()) + + def isCalcForEachZsliceRequested(self): + if self.calcForEachZsliceToggle is None: + return False + + return self.calcForEachZsliceToggle.isChecked() + + def uncheckAndDisableDataPrepIfPosNotPrepped(self, posData): + # Uncheck and disable dataprep metrics if pos is not prepped + if posData is None: + return + + if posData.isBkgrROIpresent(): + return + + for checkbox in self.checkBoxes: + if checkbox.text().find("dataPrep") == -1: + continue + + checkbox.setChecked(False) + checkbox.isDataPrepDisabled = True + + def _warnDataPrepCannotBeChecked(self): + if self.doNotWarn: + return + txt = html_utils.paragraph(""" + Data prep measurements cannot be saved because you did + not select any background ROI at the data prep step.

    + + You can read more details about data prep metrics by clicking + on the info button besides the measurement's name.

    + + Thank you for you patience! + """) + msg = myMessageBox(showCentered=False) + msg.warning(self, "Metric cannot be saved", txt) + + def standardMetricToggled(self, checked, checkbox=None): + """Method called when a check-box is toggled. It performs the following + actions: + 1. If the user try to check a data prep measurement, such as + dataPrep_amount, and this cannot be saved (checkbox has the attr + `isDataPrepDisabled`) then it warns and explains why it cannot be saved + 2. Make sure that background value median is checked if the user + requires amount or concentration metric. + 3. Do not allow unchecking background value median and explain why. + + Parameters + ---------- + checked : bool + State of the checkbox toggled + checkbox : QtWidgets.QCheckBox, optional + The checkbox that has been toggled. Default is None. If None + use `self.sender()` + """ + if self.is_concat: + return + + if checkbox is None: + checkbox = self.sender() + + if hasattr(checkbox, "isDataPrepDisabled"): + # Warn that user cannot check data prep metrics and uncheck it + if not checkbox.isChecked(): + return + checkbox.setChecked(False) + self._warnDataPrepCannotBeChecked() + return + + self.sigCheckboxToggled.emit(checkbox) + if checkbox.text().find("amount_") == -1: + return + pattern = r"amount_([A-Za-z]+)(_?[A-Za-z0-9]*)" + repl = r"\g<1>_bkgrVal_median\g<2>" + bkgrValMetric = s1 = re.sub(pattern, repl, checkbox.text()) + for bkgrCheckbox in self.groupboxes[1].checkBoxes: + if bkgrCheckbox.text() == bkgrValMetric: + break + else: + # Make sure to not check for similarly named custom metrics + return + + if checked: + bkgrCheckbox.setChecked(True) + bkgrCheckbox.isRequired = True + else: + bkgrCheckbox.setDisabled(False) + bkgrCheckbox.isRequired = False + + def backgroundMetricToggled(self, checked): + """Method called when a checkbox of a background metric is toggled. + Check if the background value is required and explain why it cannot be + unchecked. + + Parameters + ---------- + checked : bool + State of the checkbox toggled + """ + if self.is_concat: + return + + checkbox = self.sender() + if not hasattr(checkbox, "isRequired"): + return + + if not checkbox.isRequired: + return + + if checkbox.isChecked(): + return + + if self.doNotWarn: + return + + checkbox.setChecked(True) + txt = html_utils.paragraph(""" + This background value cannot be unchecked because it is required + by the _amount and _concentration measurements + that you requested to save.

    + + Thank you for you patience! + """) + msg = myMessageBox(showCentered=False) + msg.warning(self, "Background value required", txt) + + def onDelClicked(self, colname_to_del, hlayout): + self.sigDelClicked.emit(colname_to_del, hlayout) + + def checkFavouriteFuncs(self): + self.doNotWarn = True + for groupbox in self.groupboxes: + groupbox.checkFavouriteFuncs() + self.doNotWarn = False + + +class PixelSizeGroupbox(QGroupBox): + sigValueChanged = Signal(float, float, float) + sigReset = Signal() + + def __init__(self, parent=None): + super().__init__("Pixel size", parent) + + mainLayout = QGridLayout() + + row = 0 + label = QLabel("Pixel width (μm): ") + self.pixelWidthWidget = FloatLineEdit(initial=1.0) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.pixelWidthWidget, row, 1) + + row += 1 + label = QLabel("Pixel height (μm): ") + self.pixelHeightWidget = FloatLineEdit(initial=1.0) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.pixelHeightWidget, row, 1) + + row += 1 + label = QLabel("Voxel depth (μm): ") + self.voxelDepthWidget = FloatLineEdit(initial=1.0) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.voxelDepthWidget, row, 1) + + row += 1 + resetButton = reloadPushButton("Reset") + mainLayout.addWidget(resetButton, row, 1, alignment=Qt.AlignRight) + + row += 1 + mainLayout.addWidget(QHLine(), row, 0, 1, 2) + + mainLayout.setColumnStretch(0, 0) + mainLayout.setColumnStretch(1, 1) + + self.setLayout(mainLayout) + + self.pixelWidthWidget.valueChanged.connect(self.emitValueChanged) + self.pixelHeightWidget.valueChanged.connect(self.emitValueChanged) + self.voxelDepthWidget.valueChanged.connect(self.emitValueChanged) + resetButton.clicked.connect(self.emitReset) + + def emitReset(self): + self.sigReset.emit() + + def emitValueChanged(self, value): + PhysicalSizeX = self.pixelWidthWidget.value() + PhysicalSizeY = self.pixelHeightWidget.value() + PhysicalSizeZ = self.voxelDepthWidget.value() + self.sigValueChanged.emit(PhysicalSizeX, PhysicalSizeY, PhysicalSizeZ) + + +class objPropsQGBox(QGroupBox): + def __init__(self, parent=None): + QGroupBox.__init__(self, "Properties", parent) + + mainLayout = QGridLayout() + + row = 0 + label = QLabel("Object ID: ") + self.idSB = IntLineEdit() + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.idSB, row, 1) + + row += 1 + mainLayout.addWidget(QHLine(), row, 0, 1, 2) + + row += 1 + self.notExistingIDLabel = QLabel() + self.notExistingIDLabel.setStyleSheet("font-size:11px; color: rgb(255, 0, 0);") + mainLayout.addWidget( + self.notExistingIDLabel, row, 0, 1, 2, alignment=Qt.AlignCenter + ) + + row += 1 + label = QLabel("Area (pixel): ") + self.cellAreaPxlSB = IntLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.cellAreaPxlSB, row, 1) + + row += 1 + label = QLabel("Area (µm2): ") + self.cellAreaUm2DSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.cellAreaUm2DSB, row, 1) + + row += 1 + mainLayout.addWidget(QHLine(), row, 0, 1, 2) + + row += 1 + label = QLabel("Rotational volume (voxel): ") + self.cellVolVoxSB = IntLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.cellVolVoxSB, row, 1) + + row += 1 + label = QLabel("3D volume (voxel): ") + self.cellVolVox3D_SB = IntLineEdit(readOnly=True) + self.cellVolVox3D_SB.label = label + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.cellVolVox3D_SB, row, 1) + + row += 1 + label = QLabel("Rotational volume (fl): ") + self.cellVolFlDSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.cellVolFlDSB, row, 1) + + row += 1 + label = QLabel("3D volume (fl): ") + self.cellVolFl3D_DSB = FloatLineEdit(readOnly=True) + self.cellVolFl3D_DSB.label = label + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.cellVolFl3D_DSB, row, 1) + + row += 1 + mainLayout.addWidget(QHLine(), row, 0, 1, 2) + + row += 1 + label = QLabel("Solidity: ") + self.solidityDSB = FloatLineEdit(readOnly=True) + self.solidityDSB.setMaximum(1) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.solidityDSB, row, 1) + + row += 1 + label = QLabel("Elongation: ") + self.elongationDSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.elongationDSB, row, 1) + + row += 1 + mainLayout.addWidget(QHLine(), row, 0, 1, 2) + + row += 1 + propsNames = measurements.get_props_names()[1:] + self.additionalPropsCombobox = QComboBox() + self.additionalPropsCombobox.addItems(propsNames) + self.additionalPropsCombobox.indicator = FloatLineEdit(readOnly=True) + mainLayout.addWidget(self.additionalPropsCombobox, row, 0) + mainLayout.addWidget(self.additionalPropsCombobox.indicator, row, 1) + + row += 1 + mainLayout.addWidget(QHLine(), row, 0, 1, 2) + + mainLayout.setColumnStretch(0, 0) + mainLayout.setColumnStretch(1, 1) + + self.setLayout(mainLayout) + + +class objIntesityMeasurQGBox(QGroupBox): + def __init__(self, parent=None): + QGroupBox.__init__(self, "Intensity measurements", parent) + + mainLayout = QGridLayout() + + row = 0 + label = QLabel("Raw intensity measurements") + + row += 1 + label = QLabel("Channel: ") + self.channelCombobox = QComboBox() + self.channelCombobox.addItem("placeholderlong") + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.channelCombobox, row, 1) + + row += 1 + label = QLabel("Minimum: ") + self.minimumDSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.minimumDSB, row, 1) + + row += 1 + label = QLabel("Maximum: ") + self.maximumDSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.maximumDSB, row, 1) + + row += 1 + label = QLabel("Mean: ") + self.meanDSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.meanDSB, row, 1) + + row += 1 + label = QLabel("Median: ") + self.medianDSB = FloatLineEdit(readOnly=True) + mainLayout.addWidget(label, row, 0) + mainLayout.addWidget(self.medianDSB, row, 1) + + row += 1 + metricsDesc = measurements._get_metrics_names() + metricsFunc, _ = measurements.standard_metrics_func() + items = list(set([metricsDesc[key] for key in metricsFunc.keys()])) + items.append("Concentration") + items.sort() + nameFuncDict = {} + for name, desc in metricsDesc.items(): + if name.find("_dataPrepBkgr") != -1 or name.find("_manualBkgr") != -1: + # Skip dataPrepBkgr and manualBkgr since in the dock widget + # we display only autoBkgr metrics + continue + if name.startswith("concentration_"): + # We use amount function because dividing by volume is taken + # care in the GUI + name = "amount_autoBkgr" + nameFuncDict[desc] = metricsFunc[name] + + funcionCombobox = QComboBox() + funcionCombobox.addItems(items) + self.additionalMeasCombobox = funcionCombobox + self.additionalMeasCombobox.indicator = FloatLineEdit(readOnly=True) + self.additionalMeasCombobox.functions = nameFuncDict + mainLayout.addWidget(funcionCombobox, row, 0) + mainLayout.addWidget(self.additionalMeasCombobox.indicator, row, 1) + + self.setLayout(mainLayout) + + def addChannels(self, channels): + self.channelCombobox.clear() + self.channelCombobox.addItems(channels) + + +class SetMeasurementsGroupBox(QGroupBox): + def __init__( + self, + title, + itemsText, + checkable=True, + itemsInfo=None, + lastSelection=None, + itemsInfoUrls=None, + parent=None, + ): + super().__init__(parent) + + if itemsInfo is None: + itemsInfo = {} + + if itemsInfo is None: + itemsInfoUrls = {} + + highlightRgba = _palettes._highlight_rgba() + r, g, b, a = highlightRgba + self._highlightStylesheetColor = f"rgb({r}, {g}, {b})" + + self.setTitle(title) + self.setCheckable(checkable) + + mainLayout = QVBoxLayout() + + scrollArea = QScrollArea() + scrollArea.setWidgetResizable(True) + scrollAreaLayout = QVBoxLayout() + scrollAreaWidget = QWidget() + self.scrollAreaWidget = scrollAreaWidget + self.scrollAreaLayout = scrollAreaLayout + + self.checkboxes = {} + for text in itemsText: + rowLayout = QHBoxLayout() + infoText = itemsInfo.get(text) + infoUrl = itemsInfoUrls.get(text) + if infoText is not None or infoUrl is not None: + infoButton = infoPushButton() + infoButton.setCursor(Qt.WhatsThisCursor) + rowLayout.addWidget(infoButton) + + if infoText is not None: + infoButton.itemText = text + infoButton.infoText = infoText + infoButton.clicked.connect(self.showInfo) + + if infoUrl is not None: + infoButton.itemText = text + infoButton.infoUrl = infoUrl + infoButton.clicked.connect(self.openInfoUrl) + + checkbox = QCheckBox(text) + checkbox.setParent(self.scrollAreaWidget) + checkbox.setChecked(True) + rowLayout.addWidget(checkbox) + rowLayout.addStretch(1) + + self.checkboxes[text] = checkbox + + scrollAreaLayout.addLayout(rowLayout) + + scrollAreaLayout.addStretch(1) + + scrollAreaWidget.setLayout(scrollAreaLayout) + scrollArea.setWidget(scrollAreaWidget) + self.scrollArea = scrollArea + + buttonsLayout = QHBoxLayout() + self.selectAllButton = selectAllPushButton() + self.selectAllButton.sigClicked.connect(self.setCheckedAll) + + buttonsLayout.addStretch(1) + buttonsLayout.addWidget(self.selectAllButton) + self.buttonsLayout = buttonsLayout + + if lastSelection is not None: + self.lastSelection = lastSelection + self.loadLastSelButton = reloadPushButton(" Load last selection... ") + self.loadLastSelButton.clicked.connect(self.loadLastSelection) + buttonsLayout.addWidget(self.loadLastSelButton) + + mainLayout.addWidget(scrollArea) + mainLayout.addSpacing(10) + mainLayout.addLayout(buttonsLayout) + + self.setLayout(mainLayout) + + def openInfoUrl(self): + url = self.sender().infoUrl + QDesktopServices.openUrl(QUrl(url)) + # import webbrowser + # url = self.sender().infoUrl + # webbrowser.open(url) + + def getWidthNoScrollBarNeeded(self): + width = ( + self.scrollArea.verticalScrollBar().sizeHint().width() + # self.scrollAreaLayout.contentsRect().width() + + self.scrollAreaWidget.sizeHint().width() + + 30 + ) + buttonsWidth = 0 + for i in range(self.buttonsLayout.count()): + widget = self.buttonsLayout.itemAt(i).widget() + if not isinstance(widget, QPushButton): + continue + buttonsWidth += widget.sizeHint().width() + 16 + largerWidth = max(width, buttonsWidth) + return largerWidth + + def resizeWidthNoScrollBarNeeded(self): + width = self.getWidthNoScrollBarNeeded() + self.setMinimumWidth(width) + # self.setFixedWidth(width) + + def loadLastSelection(self): + for text, checkbox in self.checkboxes.items(): + checked = self.lastSelection.get(text, False) + checkbox.setChecked(checked) + + def showInfo(self): + infoText = self.sender().infoText + itemText = self.sender().itemText + + title = f"{itemText} description" + msg = myMessageBox() + msg.setWidth(int(self.screen().size().width() / 2)) + msg.information(self, title, infoText) + + def setCheckedAll(self, button, checked): + for checkbox in self.checkboxes.values(): + checkbox.setChecked(checked) + + def highlightCheckboxesFromSearchText(self, text): + for checkbox in self.checkboxes.values(): + if not text: + highlighted = False + else: + highlighted = checkbox.text().lower().find(text.lower()) != -1 + + self.setCheckboxHighlighted(highlighted, checkbox) + + def setCheckboxHighlighted(self, highlighted, checkbox): + if highlighted: + checkbox.setStyleSheet( + f"background: {self._highlightStylesheetColor}; color: black" + ) + self.scrollArea.ensureWidgetVisible(checkbox) + else: + checkbox.setStyleSheet("") + +# Cross-module imports (deferred to avoid import cycles) +from .dialogs import ( + myMessageBox, +) +from .inputs import ( + FloatLineEdit, + IntLineEdit, + QClickableLabel, +) +from .panels import ( + Toggle, +) + diff --git a/cellacdc/widgets/controls/panels.py b/cellacdc/widgets/controls/panels.py new file mode 100644 index 000000000..0da52dec0 --- /dev/null +++ b/cellacdc/widgets/controls/panels.py @@ -0,0 +1,1025 @@ +"""Composite controls: panels.""" + +"""GUI widgets: controls.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +from ..canvas.plot_items import ( + LabelItem, +) + +class statusBarPermanentLabel(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + self.rightLabel = QLabel("") + self.leftLabel = QLabel("") + + layout = QHBoxLayout() + layout.addWidget(self.leftLabel) + layout.addStretch(10) + layout.addWidget(self.rightLabel) + + self.setLayout(layout) + + +class listWidget(QListWidget): + def __init__( + self, *args, isMultipleSelection=False, minimizeHeight=False, **kwargs + ): + super().__init__(*args, **kwargs) + self.itemHeight = None + self.setStyleSheet(LISTWIDGET_STYLESHEET) + self.setFont(font) + if isMultipleSelection: + self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + + self.minimizeHeight = minimizeHeight + + def setSelectedAll(self, selected): + for i in range(self.count()): + self.item(i).setSelected(selected) + + def setSelectedItems(self, itemsText): + for i in range(self.count()): + item = self.item(i) + item.setSelected(item.text() in itemsText) + + def addItems(self, labels) -> None: + super().addItems(labels) + if self.itemHeight is not None: + self.setItemHeight() + + if self.minimizeHeight: + itemHeight = self.sizeHintForRow(0) + self.setMaximumHeight(itemHeight * self.count() + itemHeight * 2) + + def addItem(self, text): + super().addItem(text) + if self.itemHeight is None: + return + self.setItemHeight() + + def setItemHeight(self, height=40): + self.itemHeight = height + for i in range(self.count()): + item = self.item(i) + item.setSizeHint(QSize(0, height)) + + def selectedItemsText(self): + return [item.text() for item in self.selectedItems()] + + +class OrderableListWidget(QWidget): + sigEnterEvent = Signal(object) + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._labels = [] + + def setParentItem(self, item): + self._item = item + + def setLabelsColor(self, selected): + if selected: + stylesheet = "color : black" + else: + stylesheet = "" + + for label in self._labels: + label.setStyleSheet(stylesheet) + + def enterEvent(self, event): + super().enterEvent(event) + self.setLabelsColor(True) + self.sigEnterEvent.emit(self._item) + + # def leaveEvent(self, event): + # super().leaveEvent(event) + # self.setLabelsColor(self._item.isSelected()) + # printl('leave', self._item.isSelected()) + + def addLabel(self, label): + self._labels.append(label) + self.validPattern = r"^[0-9,\.]+$" + regExp = QRegularExpression(self.validPattern) + self.setValidator(QRegularExpressionValidator(regExp)) + + def values(self): + try: + vals = [float(c) for c in self.text().split(",")] + except Exception as e: + vals = [] + return vals + + +class KeptObjectIDsList(list): + def __init__(self, lineEdit, confirmSelectionAction, *args): + self.lineEdit = lineEdit + self.lineEdit.setText("") + self.confirmSelectionAction = confirmSelectionAction + confirmSelectionAction.setDisabled(True) + super().__init__(*args) + + def setText(self): + text = utils.format_IDs(self) + + self.lineEdit.setText(text) + + def append(self, element, editText=True): + super().append(element) + if editText: + self.setText() + if not self.confirmSelectionAction.isEnabled(): + self.confirmSelectionAction.setEnabled(True) + + def remove(self, element, editText=True): + super().remove(element) + if editText: + self.setText() + if not self: + self.confirmSelectionAction.setEnabled(False) + + +class Toggle(QCheckBox): + def __init__( + self, + label_text="", + initial=None, + width=80, + bg_color="#b3b3b3", + circle_color="#ffffff", + active_color="#26dd66", # '#005ce6', + animation_curve=QEasingCurve.Type.InOutQuad, + ): + QCheckBox.__init__(self) + + # self.setFixedSize(width, 28) + self.setCursor(Qt.PointingHandCursor) + + self._label_text = label_text + self._bg_color = bg_color + self._circle_color = circle_color + self._active_color = active_color + self._disabled_active_color = colors.lighten_color(active_color) + self._disabled_circle_color = colors.lighten_color(circle_color) + self._disabled_bg_color = colors.lighten_color(bg_color, amount=0.5) + self._circle_margin = 4 + + self._circle_position = int(self._circle_margin / 2) + self.animation = QPropertyAnimation(self, b"circle_position", self) + self.animation.setEasingCurve(animation_curve) + self.animation.setDuration(200) + + self.stateChanged.connect(self.start_transition) + self.requestedState = None + + self.installEventFilter(self) + self._isChecked = False + + if initial is not None: + self.setChecked(initial) + + def sizeHint(self): + return QSize(36, 18) + + def eventFilter(self, object, event): + # To get the actual position of the circle we need to wait that + # the widget is visible before setting the state + if event.type() == QEvent.Type.Show and self.requestedState is not None: + self.setChecked(self.requestedState) + return False + + def setChecked(self, state): + # To get the actual position of the circle we need to wait that + # the widget is visible before setting the state + self._isChecked = state + if self.isVisible(): + self.requestedState = None + QCheckBox.setChecked(self, state > 0) + else: + self.requestedState = state + + def isChecked(self): + if self.isVisible(): + return super().isChecked() + else: + return self._isChecked + + def circlePos(self, state: bool): + start = int(self._circle_margin / 2) + if state: + if self.isVisible(): + height, width = self.height(), self.width() + else: + sizeHint = self.sizeHint() + height, width = sizeHint.height(), sizeHint.width() + circle_diameter = height - self._circle_margin + pos = width - start - circle_diameter + else: + pos = start + return pos + + @Property(float) + def circle_position(self): + return self._circle_position + + @circle_position.setter + def circle_position(self, pos): + self._circle_position = pos + self.update() + + def start_transition(self, state): + self.animation.stop() + pos = self.circlePos(state) + self.animation.setEndValue(pos) + self.animation.start() + + def hitButton(self, pos: QPoint): + return self.contentsRect().contains(pos) + + def setDisabled(self, disable): + QCheckBox.setDisabled(self, disable) + if hasattr(self, "label"): + self.label.setDisabled(disable) + self.update() + + def paintEvent(self, e): + circle_color = ( + self._circle_color if self.isEnabled() else self._disabled_circle_color + ) + active_color = ( + self._active_color if self.isEnabled() else self._disabled_active_color + ) + unchecked_color = ( + self._bg_color if self.isEnabled() else self._disabled_bg_color + ) + + # set painter + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + # set no pen + p.setPen(Qt.NoPen) + + # draw rectangle + rect = QRect(0, 0, self.width(), self.height()) + + if not self.isChecked(): + # Draw background + p.setBrush(QColor(unchecked_color)) + half_h = int(self.height() / 2) + p.drawRoundedRect(0, 0, rect.width(), self.height(), half_h, half_h) + + # Draw circle + p.setBrush(QColor(circle_color)) + p.drawEllipse( + int(self._circle_position), + int(self._circle_margin / 2), + self.height() - self._circle_margin, + self.height() - self._circle_margin, + ) + else: + # Draw background + p.setBrush(QColor(active_color)) + half_h = int(self.height() / 2) + p.drawRoundedRect(0, 0, rect.width(), self.height(), half_h, half_h) + + # Draw circle + p.setBrush(QColor(circle_color)) + p.drawEllipse( + int(self._circle_position), + int(self._circle_margin / 2), + self.height() - self._circle_margin, + self.height() - self._circle_margin, + ) + + p.end() + + +class ToggleTerminalButton(PushButton): + sigClicked = Signal(bool) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setIcon(QIcon(":terminal_up.svg")) + self.setFixedSize(34, 18) + self.setIconSize(QSize(30, 14)) + self.setFlat(True) + self.terminalVisible = False + self.clicked.connect(self.mouseClick) + + def mouseClick(self): + if self.terminalVisible: + self.setIcon(QIcon(":terminal_up.svg")) + self.terminalVisible = False + else: + self.setIcon(QIcon(":terminal_down.svg")) + self.terminalVisible = True + self.sigClicked.emit(self.terminalVisible) + + def showEvent(self, a0) -> None: + self.idlePalette = self.palette() + return super().showEvent(a0) + + def enterEvent(self, event) -> None: + self.setFlat(False) + # pal = self.palette() + # pal.setColor(QPalette.ColorRole.Button, QColor(200, 200, 200)) + # self.setAutoFillBackground(True) + # self.setPalette(pal) + self.update() + return super().enterEvent(event) + + def leaveEvent(self, event) -> None: + self.setFlat(True) + # self.setPalette(self.idlePalette) + self.update() + return super().leaveEvent(event) + + +class expandCollapseButton(PushButton): + sigClicked = Signal() + + def __init__(self, parent=None, **kwargs): + super().__init__(parent, **kwargs) + self.setIcon(QIcon(":expand.svg")) + self.setFlat(True) + self.installEventFilter(self) + self.isExpand = True + self.clicked.connect(self.buttonClicked) + + def buttonClicked(self, checked=False): + if self.isExpand: + self.setIcon(QIcon(":collapse.svg")) + self.isExpand = False + if self.text(): + self.setText(self.text().replace("Hide", "Show")) + else: + self.setIcon(QIcon(":expand.svg")) + self.isExpand = True + if self.text(): + self.setText(self.text().replace("Show", "Hide")) + self.sigClicked.emit() + + def eventFilter(self, object, event): + if event.type() == QEvent.Type.HoverEnter: + self.setFlat(False) + elif event.type() == QEvent.Type.HoverLeave: + self.setFlat(True) + return False + + +class ToggleVisibilityButton(PushButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setFlat(True) + # self.setCheckable(True) + self._state = False + self.setIcon(QIcon(":unchecked.svg")) + self.clicked.connect(self.onClicked) + self.setStyleSheet(""" + QPushButton::pressed { + background-color: none; + border-style: none; + } + """) + + def onClicked(self): + self._state = not self._state + if self._state: + self.setIcon(QIcon(":eye-checked.svg")) + else: + self.setIcon(QIcon(":unchecked.svg")) + + +class ToggleVisibilityCheckBox(QCheckBox): + def __init__(self, *args, pixelSize=24): + super().__init__(*args) + self._pixelSize = pixelSize + self.onToggled(False) + self.toggled.connect(self.onToggled) + + def setPixelSize(self, pixelSize): + self._pixelSize = pixelSize + + def onToggled(self, checked): + if checked: + self.setStyleSheet(f""" + QCheckBox::indicator {{ + width: {self._pixelSize}px; + height: {self._pixelSize}px; + }} + + QCheckBox::indicator:checked + {{ + image: url(:eye-checked.svg); + }} + """) + else: + self.setStyleSheet(f""" + QCheckBox::indicator {{ + width: {self._pixelSize}px; + height: {self._pixelSize}px; + }} + + QCheckBox::indicator:unchecked + {{ + image: url(:unchecked.svg); + }} + """) + + +class FeatureSelectorButton(QPushButton): + def __init__(self, text, parent=None, alignment=""): + super().__init__(text, parent=parent) + self._isFeatureSet = False + self._alignment = alignment + self.setCursor(Qt.PointingHandCursor) + + def setFeatureText(self, text): + self.setText(text) + self.setFlat(True) + self._isFeatureSet = True + if self._alignment: + self.setStyleSheet(f"text-align:{self._alignment};") + + def enterEvent(self, event) -> None: + if self._isFeatureSet: + self.setFlat(False) + return super().enterEvent(event) + + def leaveEvent(self, event) -> None: + if self._isFeatureSet: + self.setFlat(True) + self.update() + return super().leaveEvent(event) + + def setSizeLongestText(self, longestText): + currentText = self.text() + self.setText(longestText) + w, h = self.sizeHint().width(), self.sizeHint().height() + self.setMinimumWidth(w + 10) + # self.setMinimumHeight(h+5) + self.setText(currentText) + + +class CheckableSpinBoxWidgets: + def __init__(self, isFloat=True): + if isFloat: + self.spinbox = FloatLineEdit() + else: + self.spinbox = SpinBox() + self.checkbox = QCheckBox("Activate") + self.spinbox.setEnabled(False) + self.checkbox.toggled.connect(self.spinbox.setEnabled) + + def value(self): + if not self.checkbox.isChecked(): + return + return self.spinbox.value() + + +class Label(QLabel): + def __init__(self, parent=None, force_html=False): + super().__init__(parent) + self._force_html = force_html + + def setText(self, text): + if self._force_html: + text = html_utils.paragraph(text) + super().setText(text) + + +class LatexLabel(QLabel): + def __init__(self, latexText, parent=None): + super().__init__(parent) + + latexText = latexText.replace("", "$") + if not latexText.startswith("$"): + latexText = f"${latexText}" + + if not latexText.endswith("$"): + latexText = f"{latexText}$" + + latexText = latexText.replace("
    ", "\n") + + pixmap = self.mathTex_to_QPixmap(latexText) + self.setPixmap(pixmap) + + def mathTex_to_QPixmap(self, mathTex): + # ---- set up a mpl figure instance ---- + + fig = matplotlib.figure.Figure() + fig.patch.set_facecolor("none") + fig.set_canvas(FigureCanvasAgg(fig)) + renderer = fig.canvas.get_renderer() + + # ---- plot the mathTex expression ---- + + ax = fig.add_axes([0, 0, 1, 1]) + ax.axis("off") + ax.patch.set_facecolor("none") + t = ax.text( + 0, 0, mathTex, ha="left", va="bottom", fontsize=13, color=TEXT_COLOR + ) + + # ---- fit figure size to text artist ---- + + fwidth, fheight = fig.get_size_inches() + fig_bbox = fig.get_window_extent(renderer) + + text_bbox = t.get_window_extent(renderer) + + tight_fwidth = text_bbox.width * fwidth / fig_bbox.width + tight_fheight = text_bbox.height * fheight / fig_bbox.height + + fig.set_size_inches(tight_fwidth, tight_fheight) + + # ---- convert mpl figure to QPixmap ---- + + buf, size = fig.canvas.print_to_buffer() + qimage = QImage.rgbSwapped(QImage(buf, size[0], size[1], QImage.Format_ARGB32)) + qpixmap = QPixmap(qimage) + + return qpixmap + + +class SwitchPlaneCombobox(QComboBox): + sigPlaneChanged = Signal(str, str) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.addItems(["xy", "zy", "zx"]) + self._previousPlane = "xy" + self.currentTextChanged.connect(self.emitPlaneChanged) + + def emitPlaneChanged(self, plane): + self.sigPlaneChanged.emit(self._previousPlane, plane) + self._previousPlane = plane + + def setPlane(self, plane): + self.setCurrentText(plane) + + def setCurrentText(self, text): + self._previousPlane = self.plane() + super().setCurrentText(text) + + def plane(self): + return self.currentText() + + def depthAxes(self): + plane = self.plane() + for axes in "xyz": + if axes not in plane: + return axes + + +class CheckableAction(QAction): + clicked = Signal(bool) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.setCheckable(True) + self.toggled.connect(self.emitClicked) + + def emitClicked(self, checked): + self.clicked.emit(checked) + + def setChecked(self, checked): + self.toggled.disconnect() + super().setChecked(checked) + self.toggled.connect(self.emitClicked) + + +class TimestampItem(LabelItem): + sigEditProperties = Signal(object) + sigRemove = Signal(object) + + def __init__( + self, + SizeY, + SizeX, + viewRange, + secondsPerFrame=1, + parent=None, + start_timedelta=None, + ): + self._secondsPerFrame = secondsPerFrame + self._x_pad = 3 + self._y_pad = 2 + self.xmin, self.ymin = 0, 0 + self.SizeY = SizeY + self.SizeX = SizeX + self._highlighted = False + self._parent = parent + if start_timedelta is None: + start_timedelta = datetime.timedelta(seconds=0) + self._start_timedelta = start_timedelta + self.clicked = False + super().__init__(self) + self.updateViewRange(viewRange) + self.createContextMenu() + + def setSecondsPerFrame(self, secondsPerFrame): + self._secondsPerFrame = secondsPerFrame + + def getBboxViewRange(self, viewRange): + xRange, yRange = viewRange + x0, x1 = xRange + y0, y1 = yRange + if x0 < 0: + x0 = 0 + + if x1 > self.SizeX: + x1 = self.SizeX + + if y0 < 0: + y0 = 0 + + if y1 > self.SizeY: + y1 = self.SizeY + + return x0, y0, x1, y1 + + def updateViewRange(self, viewRange): + x0, y0, x1, y1 = self.getBboxViewRange(viewRange) + + self.xmax = x1 + self.xmin = x0 + + self.ymax = y1 + self.ymin = y0 + + def createContextMenu(self): + self.contextMenu = QMenu() + action = QAction("Edit properties...", self.contextMenu) + action.triggered.connect(self.emitEditProperties) + self.contextMenu.addSeparator() + action = QAction("Remove", self.contextMenu) + action.triggered.connect(self.emitRemove) + self.contextMenu.addAction(action) + + def emitRemove(self): + self.sigRemove.emit(self) + + def mousePressed(self, x, y): + self.clicked = True + + def emitEditProperties(self): + self.setHighlighted(False) + self.sigEditProperties.emit(self.properties()) + + def isHighlighted(self): + return self._highlighted + + def setHighlighted(self, highlighted): + if self._highlighted and highlighted: + return + + if not self._highlighted and not highlighted: + return + + super().setText(self.text, bold=highlighted) + + self._highlighted = highlighted + + def showContextMenu(self, x, y): + self.contextMenu.popup(QPoint(int(x), int(y))) + + def setLocationProperty(self, loc: str): + self._loc = loc + + def properties(self): + properties = { + "color": self._color, + "loc": self._loc, + "font_size": int(self._font_size[:-2]), + "start_timedelta": self._start_timedelta, + "move_with_zoom": self._move_with_zoom, + } + return properties + + def draw(self, frame_i, **kwargs): + self.setProperties(**kwargs) + self.update(frame_i) + + def update(self, frame_i): + self.setPosFromLoc() + self.setText(frame_i) + + def setMoveWithZoomProperty(self, move_with_zoom): + self._move_with_zoom = move_with_zoom + + def updatePosViewRangeChanged(self, viewRange): + if self._loc == "custom": + textHeight = self.itemRect().height() + textWidth = self.itemRect().width() + x0p = self.pos().x() + y0p = self.pos().y() + xcp = x0p + textWidth / 2 + ycp = y0p + textHeight / 2 + x0 = self.xmin + y0 = self.ymin + x_range = self.xmax - x0 + y_range = self.ymax - y0 + Dx_perc = (xcp - x0) / x_range + Dy_perc = (ycp - y0) / y_range + + self.updateViewRange(viewRange) + + X0 = self.xmin + Y0 = self.ymin + + X_range = self.xmax - X0 + Y_range = self.ymax - Y0 + + Xcp = X0 + (Dx_perc * X_range) + Ycp = Y0 + (Dy_perc * Y_range) + X0p = Xcp - (textWidth / 2) + Y0p = Ycp - (textHeight / 2) + + y_pos_max = self.ymax - textHeight - self._y_pad + if Y0p > y_pos_max: + Y0p = y_pos_max + + x_pos_max = self.xmax - textWidth - self._x_pad + if X0p > x_pos_max: + X0p = x_pos_max + + self.setPos(X0p, Y0p) + else: + self.updateViewRange(viewRange) + self.setPosFromLoc() + + def setPosFromLoc(self): + textHeight = self.itemRect().height() + textWidth = self.itemRect().width() + if self._loc == "custom": + return + + if self._loc.find("top") != -1: + y0 = self._y_pad + self.ymin + else: + y0 = self.ymax - textHeight - self._y_pad + + if self._loc.find("left") != -1: + x0 = self._x_pad + self.xmin + else: + x0 = self.xmax - textWidth - self._x_pad + + self.setPos(x0, y0) + + def setProperties( + self, + color=(255, 255, 255), + font_size="13px", + loc="top-left", + start_timedelta=None, + move_with_zoom=False, + ): + if start_timedelta is not None: + self._start_timedelta = start_timedelta + self._color = color + self._loc = loc + self._font_size = font_size + self._move_with_zoom = move_with_zoom + + def move(self, xm, ym): + Dy = ym - self.yc + Dx = xm - self.xc + x0 = self.x0c + Dx + y0 = self.y0c + Dy + self.setPos(x0, y0) + + def mousePressed(self, x, y): + self.clicked = True + self.xc, self.yc = x, y + self.x0c = self.pos().x() + self.y0c = self.pos().y() + + def setText(self, frame_i): + if not isinstance(frame_i, int): + return + + seconds = frame_i * self._secondsPerFrame + timedelta = datetime.timedelta(seconds=round(seconds)) + + diff_seconds = timedelta.total_seconds() + self._start_timedelta.total_seconds() + if diff_seconds >= 0: + timedelta = datetime.timedelta(seconds=round(diff_seconds)) + text = str(timedelta) + else: + abs_diff = abs( + timedelta.total_seconds() + self._start_timedelta.total_seconds() + ) + abs_timedelta = datetime.timedelta(seconds=round(abs_diff)) + text = f"-{abs_timedelta}" + + # printl(timedelta) + super().setText(text, color=self._color, size=self._font_size) + + def addToAxis(self, ax): + ax.addItem(self) + + def removeFromAxis(self, ax): + ax.removeItem(self) + +# Cross-module imports (deferred to avoid import cycles) +from .inputs import ( + FloatLineEdit, + SpinBox, +) + diff --git a/cellacdc/widgets/toolbars/__init__.py b/cellacdc/widgets/toolbars/__init__.py new file mode 100644 index 000000000..41e8862da --- /dev/null +++ b/cellacdc/widgets/toolbars/__init__.py @@ -0,0 +1,52 @@ +"""Toolbars.""" + +from ._base import ( + GradientToolButton, + ManualBackgroundToolBar, + ManualTrackingToolBar, + OverlayChannelToolButton, + PointsLayerToolButton, + SavePointsLayerButton, + ToolBar, + ToolBarSeparator, + ToolButtonCustomColor, + ToolButtonTextIcon, + customAnnotToolButton, + rightClickToolButton, +) + +from .feature import ( + CopyLostObjectToolbar, + DrawClearRegionToolbar, + HighlightedIDToolbar, + MagicPromptsToolbar, + OverlayToolbar, + PointsLayersToolbar, + PromptableModelPointsLayerToolbar, + WandControlsToolbar, + WhitelistIDsToolbar, +) + +__all__ = [ + "GradientToolButton", + "ManualBackgroundToolBar", + "ManualTrackingToolBar", + "OverlayChannelToolButton", + "PointsLayerToolButton", + "SavePointsLayerButton", + "ToolBar", + "ToolBarSeparator", + "ToolButtonCustomColor", + "ToolButtonTextIcon", + "customAnnotToolButton", + "rightClickToolButton", + "CopyLostObjectToolbar", + "DrawClearRegionToolbar", + "HighlightedIDToolbar", + "MagicPromptsToolbar", + "OverlayToolbar", + "PointsLayersToolbar", + "PromptableModelPointsLayerToolbar", + "WandControlsToolbar", + "WhitelistIDsToolbar", +] diff --git a/cellacdc/widgets/toolbars/_base.py b/cellacdc/widgets/toolbars/_base.py new file mode 100644 index 000000000..78a43f843 --- /dev/null +++ b/cellacdc/widgets/toolbars/_base.py @@ -0,0 +1,558 @@ +"""Toolbars: _base.""" + +"""GUI widgets: toolbars.""" + +from collections import defaultdict, deque +from typing import Dict, List, Union, Iterable, Sequence +import os +import sys +import operator +import time +import re +import datetime +import numpy as np +import pandas as pd +import math +import traceback +import logging +import textwrap +import random + +from functools import partial +from math import ceil + +import skimage.draw +import skimage.morphology + +from matplotlib.colors import ListedColormap, LinearSegmentedColormap +import matplotlib.pyplot as plt +import matplotlib +from matplotlib.backends.backend_agg import FigureCanvasAgg + +from qtpy.QtCore import ( + Signal, + QTimer, + Qt, + QPoint, + QUrl, + Property, + QPropertyAnimation, + QEasingCurve, + QLocale, + QSize, + QRect, + QPointF, + QRect, + QPoint, + QEasingCurve, + QRegularExpression, + QEvent, + QEventLoop, + QPropertyAnimation, + QObject, + QItemSelectionModel, + QAbstractListModel, + QModelIndex, + QByteArray, + QDataStream, + QMimeData, + QAbstractItemModel, + QIODevice, + QItemSelection, + PYQT6, + QRectF, +) +from qtpy.QtGui import ( + QFont, + QPalette, + QColor, + QPen, + QKeyEvent, + QBrush, + QPainter, + QRegularExpressionValidator, + QIcon, + QPixmap, + QKeySequence, + QLinearGradient, + QShowEvent, + QDesktopServices, + QFontMetrics, + QGuiApplication, + QLinearGradient, + QImage, + QCursor, + QPicture, +) +from qtpy.QtWidgets import ( + QTextEdit, + QLabel, + QProgressBar, + QHBoxLayout, + QToolButton, + QCheckBox, + QApplication, + QWidget, + QVBoxLayout, + QMainWindow, + QTreeWidgetItemIterator, + QLineEdit, + QSlider, + QSpinBox, + QGridLayout, + QRadioButton, + QScrollArea, + QSizePolicy, + QComboBox, + QPushButton, + QScrollBar, + QGroupBox, + QAbstractSlider, + QDoubleSpinBox, + QWidgetAction, + QAction, + QTabWidget, + QAbstractSpinBox, + QToolBar, + QStyleOptionSpinBox, + QStyle, + QDialog, + QSpacerItem, + QFrame, + QMenu, + QActionGroup, + QListWidget, + QPlainTextEdit, + QFileDialog, + QListView, + QAbstractItemView, + QTreeWidget, + QTreeWidgetItem, + QListWidgetItem, + QLayout, + QStylePainter, + QGraphicsBlurEffect, + QGraphicsProxyWidget, + QGraphicsObject, + QButtonGroup, + QStyleOptionSlider, +) +import qtpy.compat + +import pyqtgraph as pg + +pg.setConfigOption("imageAxisOrder", "row-major") + +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 + BASE_COLOR, + Gradients, + GradientsImage, + GradientsLabels, + LINEEDIT_INVALID_ENTRY_STYLESHEET, + LINEEDIT_WARNING_STYLESHEET, + LISTWIDGET_STYLESHEET, + PROGRESSBAR_HIGHLIGHTEDTEXT_QCOLOR, + PROGRESSBAR_QCOLOR, + TEXT_COLOR, + TREEWIDGET_STYLESHEET, + cmaps, + font, + getCustomGradients, + nonInvertibleCmaps, + sign_int_mapper, + str_to_operator_mapper, +) +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 + LoadingCircleAnimation, + NoneWidget, + ProgressBar, + ProgressBarWithETA, + QLogConsole, +) + +class ToolBarSeparator: + def __init__(self, width=5, toolbar: QToolBar = None): + self._parts = ( + QHWidgetSpacer(width=width), + QVLine(), + QHWidgetSpacer(width=width), + ) + self._actions = [] + self._toolbar = None + if toolbar is not None: + self.addToToolbar(toolbar) + + def addToToolbar(self, toolbar): + self._toolbar = toolbar + for part in self._parts: + action = toolbar.addWidget(part) + self._actions.append(action) + + def removeFromToolbar(self): + if self._toolbar is None: + return + + for action in self._actions: + self._toolbar.removeAction(action) + + +class ToolBar(QToolBar): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self.widgetsWithShortcut = {} + + for child in self.children(): + if child.objectName() == "qt_toolbar_ext_button": + self.extendButton = child + self.extendButton.setIcon(QIcon(":expand.svg")) + break + + def addSeparator(self, width=5): + separator = ToolBarSeparator(width=width, toolbar=self) + return separator + + def removeSeparator(self, separator): + separator.removeFromToolbar() + + def addSpinBox(self, label=""): + spinbox = SpinBox(disableKeyPress=True) + if label: + spinbox.label = QLabel(label) + spinbox.labelAction = self.addWidget(spinbox.label) + + spinbox.action = self.addWidget(spinbox) + return spinbox + + def addButton(self, icon_str: str, text="", checkable=False): + action = QAction(QIcon(icon_str), text, self) + action.setCheckable(checkable) + self.addAction(action) + return action + + def addComboBox(self, items=None, label=""): + combobox = ComboBox() + + if items is not None: + combobox.addItems(items) + + if label: + combobox.label = QLabel(label) + combobox.labelAction = self.addWidget(combobox.label) + + combobox.action = self.addWidget(combobox) + return combobox + + def addLabel(self, text=""): + label = QLabel(text) + label.action = self.addWidget(label) + return label + + def addCheckBox(self, text="", checked=False): + checkbox = QCheckBox(text) + checkbox.setChecked(checked) + checkbox.action = self.addWidget(checkbox) + return checkbox + + +class rightClickToolButton(QToolButton): + sigRightClick = Signal(object) + sigLeftClick = Signal(object, object) + + def __init__(self, parent=None): + super().__init__(parent) + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + super().mousePressEvent(event) + self.sigLeftClick.emit(self, event) + elif event.button() == Qt.MouseButton.RightButton: + self.sigRightClick.emit(event) + + +class ToolButtonCustomColor(rightClickToolButton): + def __init__(self, symbol, color="r", parent=None): + super().__init__(parent=parent) + if not isinstance(color, QColor): + color = pg.mkColor(color) + self.symbol = symbol + self.setColor(color) + + def setColor(self, color): + self.penColor = color + self.brushColor = [0, 0, 0, 100] + self.brushColor[:3] = color.getRgb()[:3] + + def updateSymbol(self, symbol, update=True): + self.symbol = symbol + if not update: + return + self.update() + + def updateColor(self, color, update=True): + self.setColor(color) + if not update: + return + self.update() + + def updateIcon(self, symbol, color): + self.updateSymbol(symbol) + self.updateColor(color) + self.update() + + def paintEvent(self, event): + QToolButton.paintEvent(self, event) + p = QPainter(self) + w, h = self.width(), self.height() + sf = 0.6 + p.scale(w * sf, h * sf) + p.translate(0.5 / sf, 0.5 / sf) + symbol = pg.graphicsItems.ScatterPlotItem.Symbols[self.symbol] + pen = pg.mkPen(color=self.penColor, width=2) + brush = pg.mkBrush(color=self.brushColor) + try: + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.setPen(pen) + p.setBrush(brush) + p.drawPath(symbol) + except Exception as e: + traceback.print_exc() + finally: + p.end() + + +class GradientToolButton(rightClickToolButton): + def __init__(self, colors=((255, 0, 0),), parent=None): + super().__init__(parent=parent) + self._qcolors = [pg.mkColor(c) for c in colors] + if len(self._qcolors) < 2: + self._qcolors.append(self._qcolors[0]) + + def paintEvent(self, event): + super().paintEvent(event) + + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + pen = pg.mkPen(color=self._qcolors[-1], width=2) + + pad = 7 + + rect = self.rect().adjusted(pad, pad, -pad, -pad) # A little padding + + # Gradient: bottom to top + gradient = QLinearGradient(QPointF(rect.bottomLeft()), QPointF(rect.topLeft())) + + # Set color stops evenly distributed + num_colors = len(self._qcolors) + for i, color in enumerate(self._qcolors): + gradient.setColorAt(i / (num_colors - 1), color) + + if not self.isChecked(): + painter.setOpacity(0.4) + + painter.setBrush(gradient) + painter.setPen(pen) + painter.drawRect(rect) + + painter.end() + + +class PointsLayerToolButton(ToolButtonCustomColor): + sigEditAppearance = Signal(object) + sigShowIdsToggled = Signal(object, bool) + sigRemove = Signal(object) + + def __init__(self, symbol, color="r", parent=None): + super().__init__(symbol, color=color, parent=parent) + self.sigRightClick.connect(self.showContextMenu) + + def showContextMenu(self, event): + contextMenu = QMenu(self) + contextMenu.addSeparator() + + editAction = QAction("Edit points appearance...") + editAction.triggered.connect(self.editAppearance) + contextMenu.addAction(editAction) + + removeAction = QAction("Remove points") + removeAction.triggered.connect(self.emitRemove) + contextMenu.addAction(removeAction) + + showIdsAction = QAction("Show point ids") + showIdsAction.setCheckable(True) + showIdsAction.setChecked(True) + contextMenu.addAction(showIdsAction) + showIdsAction.toggled.connect(self.emitShowIdsToggled) + + contextMenu.exec(event.globalPos()) + + def emitRemove(self): + self.sigRemove.emit(self) + + def emitShowIdsToggled(self, checked): + self.sigShowIdsToggled.emit(self, checked) + + def editAppearance(self): + self.sigEditAppearance.emit(self) + + +class customAnnotToolButton(ToolButtonCustomColor): + sigRemoveAction = Signal(object) + sigKeepActiveAction = Signal(object) + sigModifyAction = Signal(object) + sigHideAction = Signal(object) + + def __init__( + self, symbol, color, keepToolActive=True, parent=None, isHideChecked=True + ): + super().__init__(symbol, color=color, parent=parent) + self.symbol = symbol + self.keepToolActive = keepToolActive + self.isHideChecked = isHideChecked + self.sigRightClick.connect(self.showContextMenu) + + def showContextMenu(self, event): + contextMenu = QMenu(self) + contextMenu.addSeparator() + + removeAction = QAction("Remove annotation") + removeAction.triggered.connect(self.removeAction) + contextMenu.addAction(removeAction) + + editAction = QAction("Modify annotation parameters...") + editAction.triggered.connect(self.modifyAction) + contextMenu.addAction(editAction) + + hideAction = QAction("Hide annotations") + hideAction.setCheckable(True) + hideAction.setChecked(self.isHideChecked) + hideAction.triggered.connect(self.hideAction) + contextMenu.addAction(hideAction) + + keepActiveAction = QAction("Keep tool active after using it") + keepActiveAction.setCheckable(True) + keepActiveAction.setChecked(self.keepToolActive) + keepActiveAction.triggered.connect(self.keepToolActiveActionToggled) + contextMenu.addAction(keepActiveAction) + + contextMenu.exec(event.globalPos()) + + def keepToolActiveActionToggled(self, checked): + self.keepToolActive = checked + self.sigKeepActiveAction.emit(self) + + def modifyAction(self): + self.sigModifyAction.emit(self) + + def removeAction(self): + self.sigRemoveAction.emit(self) + + def hideAction(self, checked): + self.isHideChecked = checked + self.sigHideAction.emit(self) + + +class ToolButtonTextIcon(rightClickToolButton): + def __init__(self, text="", parent=None): + super().__init__(parent=parent) + self._text = text + self._penColor = _palettes.text_pen_color() + + def setText(self, text): + self._text = text + self.update() + + def text(self): + return self._text + + def paintEvent(self, event): + QToolButton.paintEvent(self, event) + p = QPainter(self) + + pen = pg.mkPen(color=self._penColor, width=2) + p.setPen(pen) + + w, h = self.width(), self.height() + sf = 0.7 + rect_w = w * sf + rect_h = h * sf + x = (w - rect_w) / 2 + y = (h - rect_h) / 2 + rect = QRectF(x, y, rect_w, rect_h) + + font = p.font() + font.setBold(True) + font.setPixelSize(int(h / len(self._text))) + p.setFont(font) + + p.drawText(rect, Qt.AlignCenter, self._text) + p.end() + + +class OverlayChannelToolButton(GradientToolButton): + def __init__( + self, + channel_name: str, + lut_item: myHistogramLUTitem, + shortcut="0", + parent=None, + ): + super().__init__(colors=lut_item.gradient.getLookupTable(256), parent=parent) + self._channel_name = channel_name + + lut_item.sigGradientChanged.connect(self.updateColors) + + self.setToolTip(f'Show/hide "{channel_name}" channel\n\nShortcut: {shortcut}') + + self.setCheckable(True) + + def channelName(self): + return self._channel_name + + def updateColors(self, lut_item): + colors = lut_item.gradient.getLookupTable(256) + self._qcolors = [pg.mkColor(c) for c in colors] + self.update() + + def setVisible(self, visible: bool): + super().setVisible(visible) + if not hasattr(self, "action"): + return + + self.action.setVisible(visible) + +# Cross-module imports (deferred to avoid import cycles) +from ..canvas.histogram import ( + myHistogramLUTitem, +) +from ..controls.inputs import ( + ComboBox, + SpinBox, +) + diff --git a/cellacdc/widgets/toolbars.py b/cellacdc/widgets/toolbars/feature.py similarity index 69% rename from cellacdc/widgets/toolbars.py rename to cellacdc/widgets/toolbars/feature.py index c7ae9b9e2..26c1e3be7 100644 --- a/cellacdc/widgets/toolbars.py +++ b/cellacdc/widgets/toolbars/feature.py @@ -1,3 +1,5 @@ +"""Toolbars: feature.""" + """GUI widgets: toolbars.""" from collections import defaultdict, deque @@ -141,24 +143,24 @@ pg.setConfigOption("imageAxisOrder", "row-major") -from .. import utils, measurements, is_mac, is_win, html_utils, is_linux -from .. import printl, settings_folderpath -from .. import colors, config -from .. import html_path -from .. import _palettes -from .. import load -from .. import apps -from .. import plot -from .. import annotate -from .. import urls -from .. import _core, core -from .. import QtScoped -from .. import prompts -from ..acdc_regex import float_regex -from ..config import PREPROCESS_MAPPER -from .. import _base_widgets - -from ..components.palette import ( # noqa: E402 +from ... import utils, measurements, is_mac, is_win, html_utils, is_linux +from ... import printl, settings_folderpath +from ... import colors, config +from ... import html_path +from ... import _palettes +from ... import load +from ... import apps +from ... import plot +from ... import annotate +from ... import urls +from ... import _core, core +from ... import QtScoped +from ... import prompts +from ...acdc_regex import float_regex +from ...config import PREPROCESS_MAPPER +from ... import _base_widgets + +from ...components.palette import ( # noqa: E402 BASE_COLOR, Gradients, GradientsImage, @@ -177,15 +179,15 @@ sign_int_mapper, str_to_operator_mapper, ) -from ..components.progress import QtHandler, QLog, XStream # noqa: E402 -from ..components.buttons import * # noqa: E402, F403 -from ..components.layout import * # noqa: E402, F403 -from ..components.inputs_basic import * # noqa: E402, F403 -from ..components.path_controls import * # noqa: E402, F403 - -from ..components.lists import * # noqa: E402, F403 -from ..components.base import QBaseWindow # noqa: E402 -from ..components.progress import ( # noqa: E402 +from ...components.progress import QtHandler, QLog, XStream # noqa: E402 +from ...components.buttons import * # noqa: E402, F403 +from ...components.layout import * # noqa: E402, F403 +from ...components.inputs_basic import * # noqa: E402, F403 +from ...components.path_controls import * # noqa: E402, F403 + +from ...components.lists import * # noqa: E402, F403 +from ...components.base import QBaseWindow # noqa: E402 +from ...components.progress import ( # noqa: E402 LoadingCircleAnimation, NoneWidget, ProgressBar, @@ -193,90 +195,9 @@ QLogConsole, ) -class ToolBarSeparator: - def __init__(self, width=5, toolbar: QToolBar = None): - self._parts = ( - QHWidgetSpacer(width=width), - QVLine(), - QHWidgetSpacer(width=width), - ) - self._actions = [] - self._toolbar = None - if toolbar is not None: - self.addToToolbar(toolbar) - - def addToToolbar(self, toolbar): - self._toolbar = toolbar - for part in self._parts: - action = toolbar.addWidget(part) - self._actions.append(action) - - def removeFromToolbar(self): - if self._toolbar is None: - return - - for action in self._actions: - self._toolbar.removeAction(action) - - -class ToolBar(QToolBar): - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - self.widgetsWithShortcut = {} - - for child in self.children(): - if child.objectName() == "qt_toolbar_ext_button": - self.extendButton = child - self.extendButton.setIcon(QIcon(":expand.svg")) - break - - def addSeparator(self, width=5): - separator = ToolBarSeparator(width=width, toolbar=self) - return separator - - def removeSeparator(self, separator): - separator.removeFromToolbar() - - def addSpinBox(self, label=""): - spinbox = SpinBox(disableKeyPress=True) - if label: - spinbox.label = QLabel(label) - spinbox.labelAction = self.addWidget(spinbox.label) - - spinbox.action = self.addWidget(spinbox) - return spinbox - - def addButton(self, icon_str: str, text="", checkable=False): - action = QAction(QIcon(icon_str), text, self) - action.setCheckable(checkable) - self.addAction(action) - return action - - def addComboBox(self, items=None, label=""): - combobox = ComboBox() - - if items is not None: - combobox.addItems(items) - - if label: - combobox.label = QLabel(label) - combobox.labelAction = self.addWidget(combobox.label) - - combobox.action = self.addWidget(combobox) - return combobox - - def addLabel(self, text=""): - label = QLabel(text) - label.action = self.addWidget(label) - return label - - def addCheckBox(self, text="", checked=False): - checkbox = QCheckBox(text) - checkbox.setChecked(checked) - checkbox.action = self.addWidget(checkbox) - return checkbox - +from ._base import ( + ToolBar, +) class CopyLostObjectToolbar(ToolBar): sigCopyAllObjects = Signal(int, int) @@ -383,241 +304,6 @@ def zRange(self, z_slice, SizeZ): return (zmin, zmax) -class rightClickToolButton(QToolButton): - sigRightClick = Signal(object) - sigLeftClick = Signal(object, object) - - def __init__(self, parent=None): - super().__init__(parent) - - def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.LeftButton: - super().mousePressEvent(event) - self.sigLeftClick.emit(self, event) - elif event.button() == Qt.MouseButton.RightButton: - self.sigRightClick.emit(event) - - -class ToolButtonCustomColor(rightClickToolButton): - def __init__(self, symbol, color="r", parent=None): - super().__init__(parent=parent) - if not isinstance(color, QColor): - color = pg.mkColor(color) - self.symbol = symbol - self.setColor(color) - - def setColor(self, color): - self.penColor = color - self.brushColor = [0, 0, 0, 100] - self.brushColor[:3] = color.getRgb()[:3] - - def updateSymbol(self, symbol, update=True): - self.symbol = symbol - if not update: - return - self.update() - - def updateColor(self, color, update=True): - self.setColor(color) - if not update: - return - self.update() - - def updateIcon(self, symbol, color): - self.updateSymbol(symbol) - self.updateColor(color) - self.update() - - def paintEvent(self, event): - QToolButton.paintEvent(self, event) - p = QPainter(self) - w, h = self.width(), self.height() - sf = 0.6 - p.scale(w * sf, h * sf) - p.translate(0.5 / sf, 0.5 / sf) - symbol = pg.graphicsItems.ScatterPlotItem.Symbols[self.symbol] - pen = pg.mkPen(color=self.penColor, width=2) - brush = pg.mkBrush(color=self.brushColor) - try: - p.setRenderHint(QPainter.RenderHint.Antialiasing) - p.setPen(pen) - p.setBrush(brush) - p.drawPath(symbol) - except Exception as e: - traceback.print_exc() - finally: - p.end() - - -class GradientToolButton(rightClickToolButton): - def __init__(self, colors=((255, 0, 0),), parent=None): - super().__init__(parent=parent) - self._qcolors = [pg.mkColor(c) for c in colors] - if len(self._qcolors) < 2: - self._qcolors.append(self._qcolors[0]) - - def paintEvent(self, event): - super().paintEvent(event) - - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - - pen = pg.mkPen(color=self._qcolors[-1], width=2) - - pad = 7 - - rect = self.rect().adjusted(pad, pad, -pad, -pad) # A little padding - - # Gradient: bottom to top - gradient = QLinearGradient(QPointF(rect.bottomLeft()), QPointF(rect.topLeft())) - - # Set color stops evenly distributed - num_colors = len(self._qcolors) - for i, color in enumerate(self._qcolors): - gradient.setColorAt(i / (num_colors - 1), color) - - if not self.isChecked(): - painter.setOpacity(0.4) - - painter.setBrush(gradient) - painter.setPen(pen) - painter.drawRect(rect) - - painter.end() - - -class PointsLayerToolButton(ToolButtonCustomColor): - sigEditAppearance = Signal(object) - sigShowIdsToggled = Signal(object, bool) - sigRemove = Signal(object) - - def __init__(self, symbol, color="r", parent=None): - super().__init__(symbol, color=color, parent=parent) - self.sigRightClick.connect(self.showContextMenu) - - def showContextMenu(self, event): - contextMenu = QMenu(self) - contextMenu.addSeparator() - - editAction = QAction("Edit points appearance...") - editAction.triggered.connect(self.editAppearance) - contextMenu.addAction(editAction) - - removeAction = QAction("Remove points") - removeAction.triggered.connect(self.emitRemove) - contextMenu.addAction(removeAction) - - showIdsAction = QAction("Show point ids") - showIdsAction.setCheckable(True) - showIdsAction.setChecked(True) - contextMenu.addAction(showIdsAction) - showIdsAction.toggled.connect(self.emitShowIdsToggled) - - contextMenu.exec(event.globalPos()) - - def emitRemove(self): - self.sigRemove.emit(self) - - def emitShowIdsToggled(self, checked): - self.sigShowIdsToggled.emit(self, checked) - - def editAppearance(self): - self.sigEditAppearance.emit(self) - - -class customAnnotToolButton(ToolButtonCustomColor): - sigRemoveAction = Signal(object) - sigKeepActiveAction = Signal(object) - sigModifyAction = Signal(object) - sigHideAction = Signal(object) - - def __init__( - self, symbol, color, keepToolActive=True, parent=None, isHideChecked=True - ): - super().__init__(symbol, color=color, parent=parent) - self.symbol = symbol - self.keepToolActive = keepToolActive - self.isHideChecked = isHideChecked - self.sigRightClick.connect(self.showContextMenu) - - def showContextMenu(self, event): - contextMenu = QMenu(self) - contextMenu.addSeparator() - - removeAction = QAction("Remove annotation") - removeAction.triggered.connect(self.removeAction) - contextMenu.addAction(removeAction) - - editAction = QAction("Modify annotation parameters...") - editAction.triggered.connect(self.modifyAction) - contextMenu.addAction(editAction) - - hideAction = QAction("Hide annotations") - hideAction.setCheckable(True) - hideAction.setChecked(self.isHideChecked) - hideAction.triggered.connect(self.hideAction) - contextMenu.addAction(hideAction) - - keepActiveAction = QAction("Keep tool active after using it") - keepActiveAction.setCheckable(True) - keepActiveAction.setChecked(self.keepToolActive) - keepActiveAction.triggered.connect(self.keepToolActiveActionToggled) - contextMenu.addAction(keepActiveAction) - - contextMenu.exec(event.globalPos()) - - def keepToolActiveActionToggled(self, checked): - self.keepToolActive = checked - self.sigKeepActiveAction.emit(self) - - def modifyAction(self): - self.sigModifyAction.emit(self) - - def removeAction(self): - self.sigRemoveAction.emit(self) - - def hideAction(self, checked): - self.isHideChecked = checked - self.sigHideAction.emit(self) - - -class ToolButtonTextIcon(rightClickToolButton): - def __init__(self, text="", parent=None): - super().__init__(parent=parent) - self._text = text - self._penColor = _palettes.text_pen_color() - - def setText(self, text): - self._text = text - self.update() - - def text(self): - return self._text - - def paintEvent(self, event): - QToolButton.paintEvent(self, event) - p = QPainter(self) - - pen = pg.mkPen(color=self._penColor, width=2) - p.setPen(pen) - - w, h = self.width(), self.height() - sf = 0.7 - rect_w = w * sf - rect_h = h * sf - x = (w - rect_w) / 2 - y = (h - rect_h) / 2 - rect = QRectF(x, y, rect_w, rect_h) - - font = p.font() - font.setBold(True) - font.setPixelSize(int(h / len(self._text))) - p.setFont(font) - - p.drawText(rect, Qt.AlignCenter, self._text) - p.end() - - class WhitelistIDsToolbar(ToolBar): sigWhitelistChanged = Signal(list) sigViewOGIDs = Signal(bool) @@ -1134,39 +820,6 @@ def isSingleChannel(self): return self.singleChannelCheckbox.isChecked() -class OverlayChannelToolButton(GradientToolButton): - def __init__( - self, - channel_name: str, - lut_item: myHistogramLUTitem, - shortcut="0", - parent=None, - ): - super().__init__(colors=lut_item.gradient.getLookupTable(256), parent=parent) - self._channel_name = channel_name - - lut_item.sigGradientChanged.connect(self.updateColors) - - self.setToolTip(f'Show/hide "{channel_name}" channel\n\nShortcut: {shortcut}') - - self.setCheckable(True) - - def channelName(self): - return self._channel_name - - def updateColors(self, lut_item): - colors = lut_item.gradient.getLookupTable(256) - self._qcolors = [pg.mkColor(c) for c in colors] - self.update() - - def setVisible(self, visible: bool): - super().setVisible(visible) - if not hasattr(self, "action"): - return - - self.action.setVisible(visible) - - class HighlightedIDToolbar(ToolBar): sigIDChanged = Signal(int) @@ -1215,15 +868,12 @@ def __init__(self, name="Magic wand controls", parent=None): self.addSeparator() -# Sibling imports (deferred to avoid import cycles) -from .canvas import ( - myHistogramLUTitem, +# Cross-module imports (deferred to avoid import cycles) +from ..controls.dialogs import ( + myMessageBox, ) -from .controls import ( - ComboBox, +from ..controls.inputs import ( KeySequenceFromText, - SpinBox, WhitelistLineEdit, - myMessageBox, ) diff --git a/scripts/split_widgets_subpackages.py b/scripts/split_widgets_subpackages.py new file mode 100644 index 000000000..049d4313e --- /dev/null +++ b/scripts/split_widgets_subpackages.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 +"""Split widgets/canvas.py, controls.py, and toolbars.py into subpackages.""" + +from __future__ import annotations + +import ast +import re +import shutil +from collections import defaultdict +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WIDGETS = ROOT / "cellacdc" / "widgets" + +CANVAS_MODULES: dict[str, set[str]] = { + "histogram": { + "BaseGradientEditorItemImage", + "BaseGradientEditorItemLabels", + "baseHistogramLUTitem", + "myHistogramLUTitem", + "overlayLabelsGradientWidget", + "labelsGradientWidget", + "myColorButton", + }, + "rois": {"ROI", "ZoomROI", "DelROI", "PolyLineROI"}, + "plot_items": { + "ContourItem", + "BaseScatterPlotItem", + "CustomAnnotationScatterPlotItem", + "ScatterPlotItem", + "myLabelItem", + "LabelRoiCircularItem", + "PlotCurveItem", + "MainPlotItem", + "GhostContourItem", + "RulerPlotItem", + "PointsScatterPlotItem", + "RectItem", + "LabelItem", + "ScaleBar", + }, + "images": { + "BaseImageItem", + "BaseLabelsImageItem", + "OverlayImageItem", + "ParentImageItem", + "ChildImageItem", + "labImageItem", + "GhostMaskItem", + "_ImShowImageItem", + }, + "imshow": {"ImShow", "ImShowPlotItem"}, + "scrollbars": { + "MouseCursor", + "labelledQScrollbar", + "navigateQScrollBar", + "linkedQScrollbar", + "sliderWithSpinBox", + "ScrollBarWithNumericControl", + }, +} + +CONTROLS_MODULES: dict[str, set[str]] = { + "dialogs": { + "QDialogListbox", + "myMessageBox", + "view_visualcpp_screenshot", + "installJavaDialog", + "selectTrackerGUI", + "warnVisualCppRequired", + }, + "inputs": { + "ExpandableListBox", + "QClickableLabel", + "QCenteredComboBox", + "AlphaNumericComboBox", + "mySpinBox", + "ShortcutLineEdit", + "CenteredDoubleSpinbox", + "readOnlyDoubleSpinbox", + "readOnlySpinbox", + "DoubleSpinBox", + "SpinBox", + "ReadOnlyLineEdit", + "FloatLineEdit", + "IntLineEdit", + "LineEdit", + "SearchLineEdit", + "VectorLineEdit", + "OddSpinBox", + "KeySequenceFromText", + "ComboBox", + "WhitelistLineEdit", + "highlightableQWidgetAction", + }, + "metrics": { + "_metricsQGBox", + "channelMetricsQGBox", + "PixelSizeGroupbox", + "objPropsQGBox", + "objIntesityMeasurQGBox", + "SetMeasurementsGroupBox", + }, + "forms": { + "selectStartStopFrames", + "formWidget", + "CheckboxesGroupBox", + "guiTabControl", + "CopiableCommandWidget", + "LabelsWidget", + "SamInputPointsWidget", + "FontSizeWidget", + "RangeSelector", + "PreProcessingSelector", + "RescaleImageJroisGroupbox", + "TimeWidget", + "YeazV2SelectModelNameCombobox", + "AutoSaveIntervalWidget", + "CheckableWidget", + "PostProcessSegmSlider", + "PostProcessSegmSpinbox", + }, + "panels": { + "statusBarPermanentLabel", + "listWidget", + "OrderableListWidget", + "KeptObjectIDsList", + "Toggle", + "ToggleTerminalButton", + "expandCollapseButton", + "ToggleVisibilityButton", + "ToggleVisibilityCheckBox", + "FeatureSelectorButton", + "CheckableSpinBoxWidgets", + "Label", + "LatexLabel", + "SwitchPlaneCombobox", + "TimestampItem", + "CheckableAction", + }, +} + +TOOLBARS_MODULES: dict[str, set[str]] = { + "_base": { + "ToolBarSeparator", + "ToolBar", + "rightClickToolButton", + "ToolButtonCustomColor", + "GradientToolButton", + "ToolButtonTextIcon", + "customAnnotToolButton", + "PointsLayerToolButton", + "OverlayChannelToolButton", + "SavePointsLayerButton", + "ManualTrackingToolBar", + "ManualBackgroundToolBar", + }, + "feature": { + "CopyLostObjectToolbar", + "DrawClearRegionToolbar", + "WhitelistIDsToolbar", + "MagicPromptsToolbar", + "PointsLayersToolbar", + "PromptableModelPointsLayerToolbar", + "OverlayToolbar", + "HighlightedIDToolbar", + "WandControlsToolbar", + }, +} + + +def extract_nodes(source: str) -> tuple[str, list[tuple[str, str, int, int]]]: + lines = source.splitlines(keepends=True) + tree = ast.parse(source) + nodes: list[tuple[str, str, int, int]] = [] + first_start = None + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + end = getattr(node, "end_lineno", node.lineno) + kind = "class" if isinstance(node, ast.ClassDef) else "function" + nodes.append((node.name, kind, node.lineno, end)) + if first_start is None: + first_start = node.lineno + preamble_end = first_start - 1 if first_start else len(lines) + return "".join(lines[:preamble_end]), nodes + + +def slice_nodes(source: str, nodes: list[tuple[str, str, int, int]], names: set[str]) -> str: + lines = source.splitlines(keepends=True) + chunks: list[str] = [] + for name, _kind, start, end in nodes: + if name in names: + chunks.append("".join(lines[start - 1 : end])) + return "\n\n".join(chunks) + + +def clean_preamble(preamble: str) -> str: + """Drop sibling-package imports; they are regenerated after the split.""" + out: list[str] = [] + skip = False + for line in preamble.splitlines(keepends=True): + stripped = line.rstrip("\n") + if re.match(r"^from \.(canvas|controls|toolbars) import ", stripped): + skip = stripped.rstrip().endswith("(") + continue + if skip: + if ")" in stripped: + skip = False + continue + if stripped.startswith("# Sibling imports"): + break + out.append(line) + return "".join(out) + + +def deepen_imports(preamble: str) -> str: + """widgets//.py needs one more parent level than widgets/.py.""" + out: list[str] = [] + for line in preamble.splitlines(keepends=True): + newline = "\n" if line.endswith("\n") else "" + stripped = line.rstrip("\n") + m = re.match(r"^(\s*)from \.\. import (.+)$", stripped) + if m: + indent, rest = m.groups() + out.append(f"{indent}from ... import {rest}{newline}") + continue + m = re.match(r"^(\s*)from \.\.(\S+) import (.+)$", stripped) + if m: + indent, module, rest = m.groups() + out.append(f"{indent}from ...{module} import {rest}{newline}") + continue + out.append(line) + return "".join(out) + + +def write_module(path: Path, doc: str, preamble: str, body: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + content = f'"""{doc}"""\n\n{preamble.rstrip()}\n\n{body.rstrip()}\n' + path.write_text(content) + + +def split_area( + src: Path, + dest: Path, + modules: dict[str, set[str]], + doc: str, +) -> dict[str, list[str]]: + source = src.read_text() + preamble, nodes = extract_nodes(source) + preamble = deepen_imports(clean_preamble(preamble)) + + if dest.exists(): + shutil.rmtree(dest) + dest.mkdir(parents=True) + + exported: dict[str, list[str]] = {} + for module, names in sorted(modules.items()): + body = slice_nodes(source, nodes, names) + if not body.strip(): + raise RuntimeError(f"No body extracted for {dest.name}/{module}.py") + write_module(dest / f"{module}.py", f"{doc}: {module}.", preamble, body) + exported[module] = sorted(names) + + init_lines = [f'"""{doc}."""', ""] + all_names: list[str] = [] + for module in sorted(exported): + names = exported[module] + init_lines.append(f"from .{module} import (") + for name in names: + init_lines.append(f" {name},") + all_names.append(name) + init_lines.append(")") + init_lines.append("") + init_lines.append("__all__ = [") + for name in all_names: + init_lines.append(f' "{name}",') + init_lines.append("]") + (dest / "__init__.py").write_text("\n".join(init_lines) + "\n") + return exported + + +def inject_widget_imports() -> None: + assign: dict[str, tuple[str, str]] = {} + for area in ("canvas", "controls", "toolbars"): + for path in (WIDGETS / area).glob("*.py"): + if path.name == "__init__.py": + continue + for node in ast.parse(path.read_text()).body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + assign[node.name] = (area, path.stem) + + for area in ("canvas", "controls", "toolbars"): + for path in sorted((WIDGETS / area).glob("*.py")): + if path.name == "__init__.py": + continue + mod = path.stem + source = path.read_text() + tree = ast.parse(source) + + top_needed: dict[tuple[str, str], set[str]] = defaultdict(set) + for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + for base in node.bases: + for sub in ast.walk(base): + if ( + isinstance(sub, ast.Name) + and sub.id in assign + and assign[sub.id] != (area, mod) + ): + top_needed[assign[sub.id]].add(sub.id) + + trailing_needed: dict[tuple[str, str], set[str]] = defaultdict(set) + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load): + if node.id not in assign or assign[node.id] == (area, mod): + continue + loc = assign[node.id] + if node.id in top_needed.get(loc, set()): + continue + trailing_needed[loc].add(node.id) + + if not top_needed and not trailing_needed: + continue + + def render(needed: dict[tuple[str, str], set[str]], prefix: str) -> str: + lines: list[str] = [] + for sub_area, sub_mod in sorted(needed): + names = sorted(needed[(sub_area, sub_mod)]) + if sub_area == area: + import_from = f".{sub_mod}" + else: + import_from = f"..{sub_area}.{sub_mod}" + lines.append(f"{prefix}from {import_from} import (") + for name in names: + lines.append(f"{prefix} {name},") + lines.append(f"{prefix})") + return "\n".join(lines) + ("\n\n" if lines else "") + + lines = source.splitlines(keepends=True) + first_def = next( + n.lineno + for n in tree.body + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + ) + top_block = render(top_needed, "") + trailing_block = render(trailing_needed, "") + if trailing_block: + trailing_block = ( + "\n# Cross-module imports (deferred to avoid import cycles)\n" + + trailing_block + ) + updated = ( + "".join(lines[: first_def - 1]) + + top_block + + "".join(lines[first_def - 1 :]) + + trailing_block + ) + path.write_text(updated) + + +def rebuild_widgets_init() -> None: + """Keep widgets/__init__.py as compatibility barrel.""" + header = '''"""GUI widgets package (canvas, controls, toolbars) + components re-exports.""" + +from ..components.palette import * # noqa: F403 +from ..components.progress import * # noqa: F403 +from ..components.buttons import * # noqa: F403 +from ..components.layout import * # noqa: F403 +from ..components.inputs_basic import * # noqa: F403 +from ..components.path_controls import * # noqa: F403 +from ..components.lists import * # noqa: F403 +from ..components.base import QBaseWindow, QBaseDialog # noqa: F401 + +''' + all_names: list[str] = [] + import_blocks: list[str] = [] + for area in ("canvas", "controls", "toolbars"): + init_path = WIDGETS / area / "__init__.py" + tree = ast.parse(init_path.read_text()) + names = [ + node.id + for node in tree.body + if isinstance(node, ast.ImportFrom) and node.module == area + for alias in node.names + ] + # parse from __all__ + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__all__": + if isinstance(node.value, ast.List): + names = [ + elt.value + for elt in node.value.elts + if isinstance(elt, ast.Constant) + ] + import_blocks.append(f"from .{area} import (") + for name in names: + import_blocks.append(f" {name},") + all_names.append(name) + import_blocks.append(")") + import_blocks.append("") + + body = header + "\n".join(import_blocks) + "\n__all__ = [\n" + for name in all_names: + body += f' "{name}",\n' + body += "]\n" + (WIDGETS / "__init__.py").write_text(body) + + +def main() -> None: + # Pull toolbar classes that were left in controls.py into toolbars/_base. + controls_src = (WIDGETS / "controls.py").read_text() + toolbars_src = (WIDGETS / "toolbars.py").read_text() + _, controls_nodes = extract_nodes(controls_src) + _, toolbars_nodes = extract_nodes(toolbars_src) + controls_names = {n for n, _, _, _ in controls_nodes} + for name in ("ManualTrackingToolBar", "ManualBackgroundToolBar", "SavePointsLayerButton"): + if name in controls_names and name not in TOOLBARS_MODULES["_base"]: + TOOLBARS_MODULES["_base"].add(name) + + split_area(WIDGETS / "canvas.py", WIDGETS / "canvas", CANVAS_MODULES, "Canvas widgets") + split_area( + WIDGETS / "controls.py", + WIDGETS / "controls", + CONTROLS_MODULES, + "Composite controls", + ) + split_area( + WIDGETS / "toolbars.py", + WIDGETS / "toolbars", + TOOLBARS_MODULES, + "Toolbars", + ) + + for fname in ("canvas.py", "controls.py", "toolbars.py"): + (WIDGETS / fname).unlink() + + inject_widget_imports() + rebuild_widgets_init() + print("widgets/ subpackage split complete.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_split_packages.py b/tests/test_split_packages.py index f853f3ce5..3b46f3084 100644 --- a/tests/test_split_packages.py +++ b/tests/test_split_packages.py @@ -25,9 +25,11 @@ "io", ], "cellacdc.widgets": [ - "controls", - "canvas", - "toolbars", + "canvas.histogram", + "canvas.imshow", + "controls.dialogs", + "controls.inputs", + "toolbars._base", ], "cellacdc.dialogs": [ "_base", @@ -43,18 +45,33 @@ class TestSplitPackages(unittest.TestCase): + def _module_path(self, module_name: str, leaf: str) -> Path: + base = ROOT / module_name.replace(".", "/") + return base / f"{leaf.replace('.', '/')}.py" + def test_leaf_modules_compile(self): for module_name in PACKAGES: for leaf in PACKAGES[module_name]: - path = ROOT / module_name.replace(".", "/") / f"{leaf}.py" + path = self._module_path(module_name, leaf) with self.subTest(path=str(path)): py_compile.compile(path, doraise=True) def test_package_init_modules_compile(self): + checked = set() for module_name in PACKAGES: - path = ROOT / module_name.replace(".", "/") / "__init__.py" - with self.subTest(path=str(path)): - py_compile.compile(path, doraise=True) + base = ROOT / module_name.replace(".", "/") + paths = [base / "__init__.py"] + for leaf in PACKAGES[module_name]: + if "." in leaf: + subpkg = leaf.split(".", 1)[0] + paths.append(base / subpkg / "__init__.py") + for path in paths: + key = str(path) + if key in checked: + continue + checked.add(key) + with self.subTest(path=key): + py_compile.compile(path, doraise=True) def test_shim_modules_compile(self): for rel_path in SHIMS: From e54244ccefb7995aa7dc8e930e468628ad5880ac Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 16:52:34 +0200 Subject: [PATCH 20/21] Add napari-style script API to launch guiWin from Python. Expose Viewer, run, and get_qapp so scripts can open Segmentation and Tracking mode directly without the mainWin launcher. Co-authored-by: Cursor --- README.rst | 27 +++++++++ cellacdc/__init__.py | 24 ++++++++ cellacdc/__main__.py | 60 +------------------- cellacdc/_event_loop.py | 111 +++++++++++++++++++++++++++++++++++++ cellacdc/_run.py | 51 +++++++++++++++++ cellacdc/viewer.py | 80 +++++++++++++++++++++++++++ tests/test_viewer_api.py | 116 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 410 insertions(+), 59 deletions(-) create mode 100644 cellacdc/_event_loop.py create mode 100644 cellacdc/viewer.py create mode 100644 tests/test_viewer_api.py diff --git a/README.rst b/README.rst index 3aee6ba7c..996d9b4d5 100644 --- a/README.rst +++ b/README.rst @@ -192,6 +192,33 @@ Alternatively, you can also use **Cite this repository** button on the right ribbon of the GitHub page. +Using Cell-ACDC from a script +============================= + +Cell-ACDC can be launched from a Python script or notebook with a napari-style +API. Install the GUI dependencies first: + +.. code-block:: bash + + pip install "cellacdc[gui]" + +Then open the annotation GUI directly in **Segmentation and Tracking** mode: + +.. code-block:: python + + import cellacdc + + viewer = cellacdc.Viewer() + viewer.open("/path/to/experiment") + cellacdc.run() + +In a Jupyter notebook with ``%gui qt``, ``cellacdc.run()`` is a no-op because +IPython already runs the Qt event loop. + +For view-only inspection of arrays without segmentation, use +``cellacdc.plot.imshow`` instead. + + **IMPORTANT**: when citing Cell-ACDC make sure to also cite the paper of the segmentation models and trackers you used! See `here `__ diff --git a/cellacdc/__init__.py b/cellacdc/__init__.py index 5bd453141..4536e3c3d 100755 --- a/cellacdc/__init__.py +++ b/cellacdc/__init__.py @@ -816,3 +816,27 @@ def inner_function(self, *args, **kwargs): ) single_pos_index_cols = ("experiment_folderpath", "Position_n") + +_SCRIPT_API_EXPORTS = { + "Viewer": ("cellacdc.viewer", "Viewer"), + "current_viewer": ("cellacdc.viewer", "current_viewer"), + "run": ("cellacdc._event_loop", "run"), + "get_qapp": ("cellacdc._event_loop", "get_qapp"), + "quit_app": ("cellacdc._event_loop", "quit_app"), +} + +__all__ = list(_SCRIPT_API_EXPORTS) + + +def __getattr__(name: str): + if name in _SCRIPT_API_EXPORTS: + module_name, attr_name = _SCRIPT_API_EXPORTS[name] + import importlib + + module = importlib.import_module(module_name) + return getattr(module, attr_name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return sorted(set(globals()) | set(_SCRIPT_API_EXPORTS)) diff --git a/cellacdc/__main__.py b/cellacdc/__main__.py index 98fc6f849..a394d4638 100755 --- a/cellacdc/__main__.py +++ b/cellacdc/__main__.py @@ -73,65 +73,7 @@ def main(): def run_gui(): - from ._run import ( - _setup_gui_libraries, - _setup_symlink_app_name_macos, - _setup_numpy, - download_model_params, - _exit_on_setup, - ) - - _setup_symlink_app_name_macos() - - requires_exit = _setup_gui_libraries(exit_at_end=False) - - _setup_numpy() - - download_model_params() - - if requires_exit: - _exit_on_setup() - - from qtpy import QtGui, QtWidgets, QtCore - - if os.name == "nt": - try: - # Set taskbar icon in windows - import ctypes - - myappid = "schmollerlab.cellacdc.pyqt.v1" # arbitrary string - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) - except Exception as e: - pass - - # Needed by pyqtgraph with display resolution scaling - try: - QtWidgets.QApplication.setAttribute( - QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough - ) - except Exception as e: - pass - - import pyqtgraph as pg - - # Interpret image data as row-major instead of col-major - pg.setConfigOption("imageAxisOrder", "row-major") - try: - import numba - - pg.setConfigOption("useNumba", True) - except Exception as e: - pass - - try: - import cupy as cp - - pg.setConfigOption("useCupy", True) - except Exception as e: - pass - - # Create the application - app, splashScreen = _run._setup_app(splashscreen=True) + app, splashScreen = _run.setup_gui_runtime(splashscreen=True) from cellacdc import utils, printl diff --git a/cellacdc/_event_loop.py b/cellacdc/_event_loop.py new file mode 100644 index 000000000..a3537e90d --- /dev/null +++ b/cellacdc/_event_loop.py @@ -0,0 +1,111 @@ +"""Qt event loop helpers for script and notebook usage.""" + +from __future__ import annotations + +import os +import sys +from typing import TYPE_CHECKING +from warnings import warn + +if TYPE_CHECKING: + from qtpy.QtWidgets import QApplication + +_APP_REF = None +_IPYTHON_WAS_HERE_FIRST = "IPython" in sys.modules + + +def _ipython_has_eventloop() -> bool: + ipy_module = sys.modules.get("IPython") + if not ipy_module: + return False + + shell = ipy_module.get_ipython() # type: ignore[attr-defined] + if not shell: + return False + + return shell.active_eventloop == "qt" + + +def _pycharm_has_eventloop(app: QApplication) -> bool: + in_pycharm = "PYCHARM_HOSTED" in os.environ + in_event_loop = getattr(app, "_in_event_loop", False) + return in_pycharm and in_event_loop + + +def get_qapp(*, splashscreen: bool = False): + """Get or create the Qt QApplication used by Cell-ACDC.""" + global _APP_REF + + from qtpy.QtWidgets import QApplication + + app = QApplication.instance() + if app is None: + from cellacdc._run import setup_gui_runtime + + app, _splash = setup_gui_runtime(splashscreen=splashscreen) + _APP_REF = app + elif _APP_REF is None: + _APP_REF = app + + return app + + +def quit_app() -> None: + """Close open viewers and quit if Cell-ACDC started the QApplication.""" + from qtpy.QtWidgets import QApplication + + from cellacdc.viewer import Viewer + + for viewer in list(Viewer._instances): + viewer.close() + + QApplication.closeAllWindows() + + app = QApplication.instance() + if app is None: + return + + if ( + QApplication.applicationName() == "Cell-ACDC" + and not _ipython_has_eventloop() + ): + QApplication.quit() + + +def run(*, force: bool = False, max_loop_level: int = 1, _func_name: str = "run"): + """Start the Qt event loop.""" + if _ipython_has_eventloop(): + return + + from qtpy.QtWidgets import QApplication + + app = QApplication.instance() + + if app is not None and _pycharm_has_eventloop(app): + return + + if app is None: + raise RuntimeError( + "No Qt app has been created. Create one with " + "`cellacdc.get_qapp()` or `cellacdc.Viewer()`." + ) + + if not app.topLevelWidgets() and not force: + warn( + f"Refusing to run a QApplication with no topLevelWidgets. " + f"To run the app anyway, use `{_func_name}(force=True)`.", + stacklevel=2, + ) + return + + if app.thread().loopLevel() >= max_loop_level: + loops = app.thread().loopLevel() + warn( + f"A QApplication is already running with {loops} event loop(s). " + f"To enter another event loop, use " + f"`{_func_name}(max_loop_level={loops + 1})`.", + stacklevel=2, + ) + return + + app.exec_() diff --git a/cellacdc/_run.py b/cellacdc/_run.py index 83debc10f..78f0aa84f 100644 --- a/cellacdc/_run.py +++ b/cellacdc/_run.py @@ -443,6 +443,57 @@ def download_model_params(): pass +def setup_gui_runtime(*, splashscreen=False): + """Shared Qt/pyqtgraph/model-download setup for CLI and script API.""" + _setup_symlink_app_name_macos() + + requires_exit = _setup_gui_libraries(exit_at_end=False) + + _setup_numpy() + + download_model_params() + + if requires_exit: + _exit_on_setup() + + from qtpy import QtWidgets, QtCore + + if os.name == "nt": + try: + import ctypes + + myappid = "schmollerlab.cellacdc.pyqt.v1" + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + except Exception: + pass + + try: + QtWidgets.QApplication.setAttribute( + QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + except Exception: + pass + + import pyqtgraph as pg + + pg.setConfigOption("imageAxisOrder", "row-major") + try: + import numba # noqa: F401 + + pg.setConfigOption("useNumba", True) + except Exception: + pass + + try: + import cupy # noqa: F401 + + pg.setConfigOption("useCupy", True) + except Exception: + pass + + return _setup_app(splashscreen=splashscreen) + + def _setup_app(splashscreen=False, icon_path=None, logo_path=None, scheme=None): from qtpy import QtCore diff --git a/cellacdc/viewer.py b/cellacdc/viewer.py new file mode 100644 index 000000000..a7e9acbd8 --- /dev/null +++ b/cellacdc/viewer.py @@ -0,0 +1,80 @@ +"""Napari-style script API for launching the Cell-ACDC GUI.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING +from weakref import WeakSet + +if TYPE_CHECKING: + from cellacdc.gui import guiWin + +_DEFAULT_MODE = "Segmentation and Tracking" + + +def _check_gui_installed() -> None: + from cellacdc import GUI_INSTALLED + + if not GUI_INSTALLED: + raise RuntimeError( + "Cell-ACDC GUI dependencies are not installed. " + 'Install them with `pip install "cellacdc[gui]"`.' + ) + + +def _read_version() -> str: + from cellacdc import utils + + return utils.read_version() + + +def _create_gui_window(app, version: str): + from cellacdc import gui + + win = gui.guiWin(app, mainWin=None, version=version) + win.run() + return win + + +class Viewer: + """Launch the Cell-ACDC annotation GUI from a script or notebook.""" + + _instances: WeakSet[Viewer] = WeakSet() + + def __init__(self, *, show: bool = True, mode: str = _DEFAULT_MODE): + _check_gui_installed() + + from cellacdc._event_loop import get_qapp + + app = get_qapp() + version = _read_version() + win = _create_gui_window(app, version) + win.modeComboBox.setCurrentText(mode) + if show: + win.raise_() + win.activateWindow() + + self._window = win + self._instances.add(self) + + def open(self, path: str | os.PathLike) -> None: + path = os.fspath(path) + if os.path.isdir(path): + self._window.openFolder(exp_path=path) + else: + self._window.openFile(file_path=path) + + @property + def window(self) -> guiWin: + return self._window + + def close(self) -> None: + self._window.close() + + +def current_viewer() -> Viewer | None: + """Return the most recently created viewer, if any.""" + instances = list(Viewer._instances) + if not instances: + return None + return instances[-1] diff --git a/tests/test_viewer_api.py b/tests/test_viewer_api.py new file mode 100644 index 000000000..6d57cd418 --- /dev/null +++ b/tests/test_viewer_api.py @@ -0,0 +1,116 @@ +"""Tests for the napari-style script API.""" + +from __future__ import annotations + +import importlib +import os +from unittest.mock import MagicMock, patch + +import pytest + + +def _reload_viewer_module(): + import cellacdc.viewer + + return importlib.reload(cellacdc.viewer) + + +def test_viewer_sets_segmentation_and_tracking_mode(): + mock_win = MagicMock() + viewer_mod = _reload_viewer_module() + + with ( + patch("cellacdc._event_loop.get_qapp", return_value=MagicMock()), + patch.object(viewer_mod, "_read_version", return_value="test"), + patch.object(viewer_mod, "_create_gui_window", return_value=mock_win), + patch.object(viewer_mod, "_check_gui_installed"), + ): + viewer = viewer_mod.Viewer() + + mock_win.modeComboBox.setCurrentText.assert_called_once_with( + "Segmentation and Tracking" + ) + mock_win.raise_.assert_called_once() + mock_win.activateWindow.assert_called_once() + assert viewer.window is mock_win + + +def test_viewer_open_dispatches_folder_and_file(tmp_path): + mock_win = MagicMock() + folder = tmp_path / "experiment" + folder.mkdir() + file_path = tmp_path / "image.tif" + file_path.write_text("") + viewer_mod = _reload_viewer_module() + + with ( + patch("cellacdc._event_loop.get_qapp", return_value=MagicMock()), + patch.object(viewer_mod, "_read_version", return_value="test"), + patch.object(viewer_mod, "_create_gui_window", return_value=mock_win), + patch.object(viewer_mod, "_check_gui_installed"), + ): + viewer = viewer_mod.Viewer(show=False) + viewer.open(folder) + viewer.open(file_path) + + mock_win.openFolder.assert_called_once_with(exp_path=os.fspath(folder)) + mock_win.openFile.assert_called_once_with(file_path=os.fspath(file_path)) + + +def test_current_viewer_returns_latest_instance(): + mock_win = MagicMock() + viewer_mod = _reload_viewer_module() + + with ( + patch("cellacdc._event_loop.get_qapp", return_value=MagicMock()), + patch.object(viewer_mod, "_read_version", return_value="test"), + patch.object(viewer_mod, "_create_gui_window", return_value=mock_win), + patch.object(viewer_mod, "_check_gui_installed"), + ): + assert viewer_mod.current_viewer() is None + viewer_mod.Viewer(show=False) + second = viewer_mod.Viewer(show=False) + assert viewer_mod.current_viewer() is second + + +def test_run_warns_without_top_level_widgets(): + mock_app = MagicMock() + mock_app.topLevelWidgets.return_value = [] + mock_app.thread.return_value.loopLevel.return_value = 0 + + with ( + patch("cellacdc._event_loop._ipython_has_eventloop", return_value=False), + patch("qtpy.QtWidgets.QApplication") as mock_qapp_cls, + pytest.warns(UserWarning, match="Refusing to run a QApplication"), + ): + mock_qapp_cls.instance.return_value = mock_app + from cellacdc._event_loop import run + + run() + + +def test_run_starts_event_loop_when_widgets_exist(): + mock_app = MagicMock() + mock_app.topLevelWidgets.return_value = [MagicMock()] + mock_app.thread.return_value.loopLevel.return_value = 0 + + with ( + patch("cellacdc._event_loop._ipython_has_eventloop", return_value=False), + patch("qtpy.QtWidgets.QApplication") as mock_qapp_cls, + ): + mock_qapp_cls.instance.return_value = mock_app + from cellacdc._event_loop import run + + run() + + mock_app.exec_.assert_called_once() + + +def test_lazy_exports_from_package(): + import cellacdc + + assert cellacdc.Viewer.__name__ == "Viewer" + assert cellacdc.current_viewer.__name__ == "current_viewer" + assert cellacdc.run.__name__ == "run" + assert cellacdc.get_qapp.__name__ == "get_qapp" + assert cellacdc.quit_app.__name__ == "quit_app" From b701e96e2f409fb15de3b46efb0a349fe32427c5 Mon Sep 17 00:00:00 2001 From: keejkrej Date: Sun, 24 May 2026 19:32:04 +0200 Subject: [PATCH 21/21] Add ExperimentData for array and path-based script API loading. Decouple data construction from the GUI so notebooks can pass in-memory arrays or filesystem paths through Viewer and imshow without viewer.open(). Co-authored-by: Cursor --- README.rst | 30 ++- cellacdc/__init__.py | 2 + cellacdc/data_source.py | 342 ++++++++++++++++++++++++++++++++ cellacdc/mixins/data_loading.py | 43 ++++ cellacdc/viewer.py | 36 +++- tests/test_data_source.py | 160 +++++++++++++++ tests/test_viewer_api.py | 47 +---- 7 files changed, 606 insertions(+), 54 deletions(-) create mode 100644 cellacdc/data_source.py create mode 100644 tests/test_data_source.py diff --git a/README.rst b/README.rst index 996d9b4d5..bc12fe19d 100644 --- a/README.rst +++ b/README.rst @@ -202,16 +202,40 @@ API. Install the GUI dependencies first: pip install "cellacdc[gui]" -Then open the annotation GUI directly in **Segmentation and Tracking** mode: +Build a unified :class:`ExperimentData` object from arrays or from a path, then +pass it to the viewer: .. code-block:: python import cellacdc + import numpy as np - viewer = cellacdc.Viewer() - viewer.open("/path/to/experiment") + image = np.zeros((100, 128, 128), dtype=np.uint16) # T, Y, X + data = cellacdc.ExperimentData.from_arrays(image, axes="tyx") + viewer = cellacdc.Viewer(data) cellacdc.run() +The convenience helper mirrors ``napari.imshow`` and returns both the viewer +and the data object: + +.. code-block:: python + + data = cellacdc.ExperimentData.from_arrays(image, axes="tyx") + viewer, data = cellacdc.imshow(data) + cellacdc.run() + +Path-based loading: + +.. code-block:: python + + data = cellacdc.ExperimentData.from_path("/path/to/experiment") + viewer, data = cellacdc.imshow(data) + cellacdc.run() + +Optional ``labels`` can be supplied when creating data from arrays. When no +``workspace`` path is given, Cell-ACDC uses a temporary folder so segmentation +outputs can still be saved from the GUI. + In a Jupyter notebook with ``%gui qt``, ``cellacdc.run()`` is a no-op because IPython already runs the Qt event loop. diff --git a/cellacdc/__init__.py b/cellacdc/__init__.py index 4536e3c3d..ba2ae6b34 100755 --- a/cellacdc/__init__.py +++ b/cellacdc/__init__.py @@ -819,7 +819,9 @@ def inner_function(self, *args, **kwargs): _SCRIPT_API_EXPORTS = { "Viewer": ("cellacdc.viewer", "Viewer"), + "ExperimentData": ("cellacdc.data_source", "ExperimentData"), "current_viewer": ("cellacdc.viewer", "current_viewer"), + "imshow": ("cellacdc.viewer", "imshow"), "run": ("cellacdc._event_loop", "run"), "get_qapp": ("cellacdc._event_loop", "get_qapp"), "quit_app": ("cellacdc._event_loop", "quit_app"), diff --git a/cellacdc/data_source.py b/cellacdc/data_source.py new file mode 100644 index 000000000..656b12ade --- /dev/null +++ b/cellacdc/data_source.py @@ -0,0 +1,342 @@ +"""Unified experiment data for decoupling the GUI from filesystem loading.""" + +from __future__ import annotations + +import os +import tempfile +from dataclasses import dataclass, field +from typing import Literal + +import numpy as np +import pandas as pd + +VolumeAxes = Literal["yx", "zyx", "tyx", "tzyx"] +PathKind = Literal["file", "experiment", "images", "folder"] +DataSourceKind = Literal["memory", "path"] + + +@dataclass +class ArrayDataSource: + """Specification for building in-memory position data.""" + + image: np.ndarray + labels: np.ndarray | None = None + name: str = "data" + channel_name: str = "cells" + axes: VolumeAxes = "tyx" + workspace: str | os.PathLike | None = None + time_increment: float = 1.0 + physical_size_xy: tuple[float, float] = (1.0, 1.0) + physical_size_z: float = 1.0 + is_segm_3d: bool = False + metadata: dict[str, str | float | int] = field(default_factory=dict) + + +class ExperimentData: + """Unified dataset handle for the Cell-ACDC script API. + + Use :meth:`from_arrays` or :meth:`from_path` to create instances. + """ + + name: str + source: DataSourceKind + path: str | None + path_kind: PathKind | None + _positions: list | None + + def __init__(self): + pass + + @classmethod + def from_arrays( + cls, + image: np.ndarray, + labels: np.ndarray | None = None, + **kwargs, + ) -> ExperimentData: + """Create dataset data from in-memory arrays.""" + self = cls() + load_data_cls = kwargs.pop("_load_data_cls", None) + name = kwargs.get("name", "data") + pos = pos_data_from_kwargs( + image, + labels, + _load_data_cls=load_data_cls, + **kwargs, + ) + self.source = "memory" + self.name = name + self.path = None + self.path_kind = None + self._positions = [pos] + return self + + @classmethod + def from_path(cls, path: str | os.PathLike, **kwargs) -> ExperimentData: + """Create a dataset handle from a filesystem path.""" + path = os.fspath(path) + if not os.path.exists(path): + raise FileNotFoundError(path) + + self = cls() + name = kwargs.get("name", "data") + self.source = "path" + self.path = path + self.path_kind = _detect_path_kind(path) + self.name = ( + os.path.basename(path.rstrip(os.sep)) if name == "data" else name + ) + self._positions = None + return self + + @property + def is_materialized(self) -> bool: + return self.source == "memory" and self._positions is not None + + @property + def positions(self) -> list: + if not self.is_materialized: + raise RuntimeError( + "Path-based ExperimentData is loaded by the viewer on demand. " + "Use Viewer(data) or data.load_into(window)." + ) + return self._positions + + def load_into(self, window) -> None: + if self.source == "memory": + window.loadFromExperimentData(self) + return + + if self.path_kind == "file": + window.openFile(file_path=self.path) + elif self.path_kind == "images": + window.openFolder(exp_path=self.path) + elif self.path_kind == "experiment": + window.openFolder(exp_path=self.path) + else: + if os.path.isdir(self.path): + window.openFolder(exp_path=self.path) + else: + window.openFile(file_path=self.path) + + +def _detect_path_kind(path: str) -> PathKind: + if os.path.isfile(path): + return "file" + + basename = os.path.basename(path.rstrip(os.sep)) + if basename == "Images": + return "images" + + try: + entries = os.listdir(path) + except OSError: + return "folder" + + if any(entry.startswith("Position") and os.path.isdir(os.path.join(path, entry)) for entry in entries): + return "experiment" + + return "folder" + + +def normalize_volume( + array: np.ndarray, + *, + axes: VolumeAxes = "tyx", +) -> tuple[np.ndarray, int, int]: + """Return (array, SizeT, SizeZ) in Cell-ACDC's pre-finalize layout.""" + arr = np.asarray(array) + if arr.ndim == 2: + if axes != "yx": + raise ValueError( + f"A 2D array requires axes='yx', got axes={axes!r}." + ) + return arr, 1, 1 + + if arr.ndim == 3: + if axes == "zyx": + return arr, 1, arr.shape[0] + if axes == "tyx": + return arr, arr.shape[0], 1 + raise ValueError( + f"A 3D array requires axes='tyx' or 'zyx', got axes={axes!r}." + ) + + if arr.ndim == 4: + if axes != "tzyx": + raise ValueError( + f"A 4D array requires axes='tzyx', got axes={axes!r}." + ) + return arr, arr.shape[0], arr.shape[1] + + raise ValueError( + f"Expected a 2D, 3D, or 4D array, got shape {arr.shape}." + ) + + +def _finalize_pos_data_arrays(pos_data) -> None: + """Match the array layout produced by ``loadDataWorker``.""" + if pos_data.SizeT == 1: + pos_data.img_data = pos_data.img_data[np.newaxis] + if pos_data.segm_data is not None: + pos_data.segm_data = pos_data.segm_data[np.newaxis] + + pos_data.img_data_shape = pos_data.img_data.shape + pos_data.dset = pos_data.img_data + if pos_data.segm_data is not None: + pos_data.segmSizeT = len(pos_data.segm_data) + + +def _write_metadata_csv( + metadata_csv_path: os.PathLike, + *, + basename: str, + size_t: int, + size_z: int, + size_y: int, + size_x: int, + channel_name: str, + time_increment: float, + physical_size_xy: tuple[float, float], + physical_size_z: float, + is_segm_3d: bool, + extra: dict[str, str | float | int], +) -> None: + rows = { + "basename": basename, + "SizeT": size_t, + "SizeZ": size_z, + "SizeY": size_y, + "SizeX": size_x, + "TimeIncrement": time_increment, + "PhysicalSizeX": physical_size_xy[0], + "PhysicalSizeY": physical_size_xy[1], + "PhysicalSizeZ": physical_size_z, + "segm_isSegm3D": str(is_segm_3d), + f"{channel_name}_name": channel_name, + } + rows.update(extra) + df = pd.DataFrame( + {"Description": list(rows.keys()), "values": [str(v) for v in rows.values()]} + ) + df.to_csv(metadata_csv_path, index=False) + + +def pos_data_from_arrays(source: ArrayDataSource, *, _load_data_cls=None): + """Build a ``loadData`` instance backed by in-memory arrays.""" + if _load_data_cls is None: + from cellacdc import load + + _load_data_cls = load.loadData + + image, size_t, size_z = normalize_volume(source.image, axes=source.axes) + size_y, size_x = image.shape[-2:] + + labels = source.labels + if labels is not None: + labels, labels_size_t, labels_size_z = normalize_volume( + labels, axes=source.axes + ) + if (labels_size_t, labels_size_z, *labels.shape[-2:]) != ( + size_t, + size_z, + size_y, + size_x, + ): + raise ValueError( + "Labels shape must match the image shape for the given axes." + ) + labels = labels.astype(np.uint32, copy=False) + + if source.workspace is None: + workspace = tempfile.mkdtemp(prefix="cellacdc_") + else: + workspace = os.fspath(source.workspace) + os.makedirs(workspace, exist_ok=True) + + exp_path = os.path.join(workspace, source.name) + pos_path = os.path.join(exp_path, "Position_001") + images_path = os.path.join(pos_path, "Images") + os.makedirs(images_path, exist_ok=True) + + basename = f"{source.name}_" + channel_name = source.channel_name + img_filename = f"{basename}{channel_name}.npz" + img_path = os.path.join(images_path, img_filename) + + pos = _load_data_cls(img_path, channel_name, log_func=print) + pos.basename = basename + pos.chNames = [channel_name] + pos.filename = f"{basename}{channel_name}" + pos.filename_ext = img_filename + pos.ext = ".npz" + pos.images_folder_files = [img_filename] + pos.img_data = image + pos.SizeT = size_t + pos.SizeZ = size_z + pos.SizeY = size_y + pos.SizeX = size_x + pos.loadSizeS = 1 + pos.loadSizeT = size_t + pos.loadSizeZ = size_z + pos.TimeIncrement = source.time_increment + pos.PhysicalSizeX = source.physical_size_xy[0] + pos.PhysicalSizeY = source.physical_size_xy[1] + pos.PhysicalSizeZ = source.physical_size_z + pos.isSegm3D = source.is_segm_3d + pos.is_in_memory = True + + pos.buildPaths() + metadata_csv_path = pos.metadata_csv_path + _write_metadata_csv( + metadata_csv_path, + basename=basename, + size_t=size_t, + size_z=size_z, + size_y=size_y, + size_x=size_x, + channel_name=channel_name, + time_increment=source.time_increment, + physical_size_xy=source.physical_size_xy, + physical_size_z=source.physical_size_z, + is_segm_3d=source.is_segm_3d, + extra=source.metadata, + ) + pos.metadataFound = True + pos.metadata_df = pd.read_csv(metadata_csv_path).set_index("Description") + pos.extractMetadata() + + if labels is not None: + pos.segmFound = True + pos.segm_data = labels + pos.labelBoolSegm = False + else: + pos.segmFound = False + pos.labelBoolSegm = False + pos.loadOtherFiles( + load_segm_data=False, + create_new_segm=True, + load_acdc_df=False, + load_metadata=False, + ) + pos.setBlankSegmData(pos.SizeT, pos.SizeZ, size_y, size_x) + + pos.acdc_df_found = False + pos.acdc_df = None + pos.segmInfo_df = None + pos.allData_li = [None] * pos.SizeT + pos.frame_i = 0 + + _finalize_pos_data_arrays(pos) + return pos + + +def pos_data_from_kwargs( + image: np.ndarray, + labels: np.ndarray | None = None, + *, + _load_data_cls=None, + **kwargs, +): + source = ArrayDataSource(image=image, labels=labels, **kwargs) + return pos_data_from_arrays(source, _load_data_cls=_load_data_cls) diff --git a/cellacdc/mixins/data_loading.py b/cellacdc/mixins/data_loading.py index 09c9afe49..0a4f3b6f1 100644 --- a/cellacdc/mixins/data_loading.py +++ b/cellacdc/mixins/data_loading.py @@ -757,6 +757,49 @@ def loadDataWorkerFinished(self, data): self.gui_createGraphicsItems() return True + def loadFromArrays(self, image, labels=None, **kwargs): + """Load in-memory arrays into the GUI without filesystem dialogs.""" + from cellacdc.data_source import ExperimentData + + data = ExperimentData.from_arrays(image, labels, **kwargs) + self.loadFromExperimentData(data) + + def loadFromExperimentData(self, data): + """Load a materialized :class:`ExperimentData` instance into the GUI.""" + if not data.is_materialized: + raise ValueError("ExperimentData must be materialized before loading.") + + posData = data.positions[0] + self.user_ch_name = posData.user_ch_name + self.ch_names = posData.chNames + self.user_ch_file_paths = [posData.imgPath] + self.num_pos = len(data.positions) + self.exp_path = posData.exp_path + self.isNewFile = not posData.segmFound + self.newSegmEndName = "" + self.selectedSegmEndName = "" + self.labelBoolSegm = posData.labelBoolSegm + self.isSegm3D = posData.isSegm3D + self.SizeT = posData.SizeT + self.SizeZ = posData.SizeZ + self.TimeIncrement = posData.TimeIncrement + self.PhysicalSizeZ = posData.PhysicalSizeZ + self.PhysicalSizeY = posData.PhysicalSizeY + self.PhysicalSizeX = posData.PhysicalSizeX + self.loadSizeS = posData.loadSizeS + self.loadSizeT = posData.loadSizeT + self.loadSizeZ = posData.loadSizeZ + self.isSnapshot = posData.SizeT == 1 + self.isH5chunk = False + self.existingSegmEndNames = set() + self.createOverlayLabelsContextMenu(self.existingSegmEndNames) + self.createOverlayLabelsItems(self.existingSegmEndNames) + self.overlayLabelsButtonAction.setVisible(True) + self.disableNonFunctionalButtons() + self.overlayLabelsItems = {} + self.drawModeOverlayLabelsChannels = {} + self.loadDataWorkerFinished(data.positions) + def loadFluo_cb(self, checked=True, fluo_channels=None): if fluo_channels is None: posData = self.data[self.pos_i] diff --git a/cellacdc/viewer.py b/cellacdc/viewer.py index a7e9acbd8..b99b68b51 100644 --- a/cellacdc/viewer.py +++ b/cellacdc/viewer.py @@ -2,10 +2,11 @@ from __future__ import annotations -import os from typing import TYPE_CHECKING from weakref import WeakSet +from cellacdc.data_source import ExperimentData + if TYPE_CHECKING: from cellacdc.gui import guiWin @@ -41,7 +42,13 @@ class Viewer: _instances: WeakSet[Viewer] = WeakSet() - def __init__(self, *, show: bool = True, mode: str = _DEFAULT_MODE): + def __init__( + self, + data: ExperimentData | None = None, + *, + show: bool = True, + mode: str = _DEFAULT_MODE, + ): _check_gui_installed() from cellacdc._event_loop import get_qapp @@ -50,6 +57,11 @@ def __init__(self, *, show: bool = True, mode: str = _DEFAULT_MODE): version = _read_version() win = _create_gui_window(app, version) win.modeComboBox.setCurrentText(mode) + + self._data = data + if data is not None: + data.load_into(win) + if show: win.raise_() win.activateWindow() @@ -57,12 +69,9 @@ def __init__(self, *, show: bool = True, mode: str = _DEFAULT_MODE): self._window = win self._instances.add(self) - def open(self, path: str | os.PathLike) -> None: - path = os.fspath(path) - if os.path.isdir(path): - self._window.openFolder(exp_path=path) - else: - self._window.openFile(file_path=path) + @property + def data(self) -> ExperimentData | None: + return self._data @property def window(self) -> guiWin: @@ -78,3 +87,14 @@ def current_viewer() -> Viewer | None: if not instances: return None return instances[-1] + + +def imshow( + data: ExperimentData, + *, + show: bool = True, + mode: str = _DEFAULT_MODE, +) -> tuple[Viewer, ExperimentData]: + """Open the GUI with an :class:`ExperimentData` instance.""" + viewer = Viewer(data, show=show, mode=mode) + return viewer, data diff --git a/tests/test_data_source.py b/tests/test_data_source.py new file mode 100644 index 000000000..6dfc23f03 --- /dev/null +++ b/tests/test_data_source.py @@ -0,0 +1,160 @@ +"""Tests for in-memory data loading and array-based viewer API.""" + +from __future__ import annotations + +import importlib +import os +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from cellacdc.data_source import ( + ArrayDataSource, + ExperimentData, + normalize_volume, + pos_data_from_arrays, + pos_data_from_kwargs, +) + + +def test_normalize_volume_shapes(): + image = np.zeros((4, 32, 32), dtype=np.uint8) + arr, size_t, size_z = normalize_volume(image, axes="tyx") + assert arr.shape == (4, 32, 32) + assert size_t == 4 + assert size_z == 1 + + stack = np.zeros((5, 8, 16, 16), dtype=np.uint8) + arr, size_t, size_z = normalize_volume(stack, axes="tzyx") + assert arr.shape == (5, 8, 16, 16) + assert size_t == 5 + assert size_z == 8 + + +def test_experiment_data_from_arrays(tmp_path): + class DummyPosData: + def __init__(self, img_path, channel_name, **kwargs): + self.imgPath = img_path + self.user_ch_name = channel_name + self.images_path = str(tmp_path / "Images") + self.exp_path = str(tmp_path) + + def buildPaths(self): + self.metadata_csv_path = str(tmp_path / "metadata.csv") + self.segm_npz_path = str(tmp_path / "segm.npz") + + def loadOtherFiles(self, **kwargs): + pass + + def setBlankSegmData(self, size_t, size_z, size_y, size_x): + self.segm_data = np.zeros((size_y, size_x), dtype=np.uint32) + + def extractMetadata(self): + pass + + image = np.arange(32 * 32, dtype=np.uint16).reshape(32, 32) + data = ExperimentData.from_arrays( + image, + name="test", + channel_name="cells", + axes="yx", + workspace=tmp_path, + _load_data_cls=DummyPosData, + ) + + assert data.is_materialized + assert data.source == "memory" + pos = data.positions[0] + assert pos.SizeT == 1 + assert pos.img_data.shape == (1, 32, 32) + + +def test_experiment_data_from_path(tmp_path): + exp_path = tmp_path / "my_experiment" + exp_path.mkdir() + data = ExperimentData.from_path(exp_path) + + assert data.source == "path" + assert data.path == str(exp_path) + assert not data.is_materialized + + +def test_pos_data_from_arrays_without_labels(tmp_path): + class DummyPosData: + def __init__(self, img_path, channel_name, **kwargs): + self.imgPath = img_path + self.user_ch_name = channel_name + self.images_path = str(tmp_path / "Images") + self.exp_path = str(tmp_path) + + def buildPaths(self): + self.metadata_csv_path = str(tmp_path / "metadata.csv") + self.segm_npz_path = str(tmp_path / "segm.npz") + + def loadOtherFiles(self, **kwargs): + pass + + def setBlankSegmData(self, size_t, size_z, size_y, size_x): + self.segm_data = np.zeros((size_y, size_x), dtype=np.uint32) + + def extractMetadata(self): + pass + + image = np.arange(32 * 32, dtype=np.uint16).reshape(32, 32) + pos = pos_data_from_kwargs( + image, + name="test", + channel_name="cells", + axes="yx", + workspace=tmp_path, + _load_data_cls=DummyPosData, + ) + + assert pos.SizeT == 1 + assert pos.segmFound is False + + +def test_viewer_accepts_experiment_data(): + viewer_mod = importlib.import_module("cellacdc.viewer") + viewer_mod = importlib.reload(viewer_mod) + mock_win = MagicMock() + data = MagicMock() + data.is_materialized = True + + with ( + patch("cellacdc._event_loop.get_qapp", return_value=MagicMock()), + patch.object(viewer_mod, "_read_version", return_value="test"), + patch.object(viewer_mod, "_create_gui_window", return_value=mock_win), + patch.object(viewer_mod, "_check_gui_installed"), + ): + viewer = viewer_mod.Viewer(data, show=False) + + data.load_into.assert_called_once_with(mock_win) + assert viewer.data is data + + +def test_imshow_returns_viewer_and_experiment_data(tmp_path): + viewer_mod = importlib.import_module("cellacdc.viewer") + viewer_mod = importlib.reload(viewer_mod) + data = ExperimentData.from_path(tmp_path) + mock_viewer = MagicMock() + mock_viewer.data = data + + with patch.object(viewer_mod, "Viewer", return_value=mock_viewer) as mock_viewer_cls: + viewer, returned = viewer_mod.imshow(data) + + mock_viewer_cls.assert_called_once_with( + data, + show=True, + mode="Segmentation and Tracking", + ) + assert viewer is mock_viewer + assert returned is data + + +def test_lazy_exports_include_experiment_data(): + import cellacdc + + assert cellacdc.ExperimentData.__name__ == "ExperimentData" + assert cellacdc.imshow.__name__ == "imshow" diff --git a/tests/test_viewer_api.py b/tests/test_viewer_api.py index 6d57cd418..a93c8d19b 100644 --- a/tests/test_viewer_api.py +++ b/tests/test_viewer_api.py @@ -3,7 +3,6 @@ from __future__ import annotations import importlib -import os from unittest.mock import MagicMock, patch import pytest @@ -25,52 +24,12 @@ def test_viewer_sets_segmentation_and_tracking_mode(): patch.object(viewer_mod, "_create_gui_window", return_value=mock_win), patch.object(viewer_mod, "_check_gui_installed"), ): - viewer = viewer_mod.Viewer() + viewer = viewer_mod.Viewer(show=False) mock_win.modeComboBox.setCurrentText.assert_called_once_with( "Segmentation and Tracking" ) - mock_win.raise_.assert_called_once() - mock_win.activateWindow.assert_called_once() - assert viewer.window is mock_win - - -def test_viewer_open_dispatches_folder_and_file(tmp_path): - mock_win = MagicMock() - folder = tmp_path / "experiment" - folder.mkdir() - file_path = tmp_path / "image.tif" - file_path.write_text("") - viewer_mod = _reload_viewer_module() - - with ( - patch("cellacdc._event_loop.get_qapp", return_value=MagicMock()), - patch.object(viewer_mod, "_read_version", return_value="test"), - patch.object(viewer_mod, "_create_gui_window", return_value=mock_win), - patch.object(viewer_mod, "_check_gui_installed"), - ): - viewer = viewer_mod.Viewer(show=False) - viewer.open(folder) - viewer.open(file_path) - - mock_win.openFolder.assert_called_once_with(exp_path=os.fspath(folder)) - mock_win.openFile.assert_called_once_with(file_path=os.fspath(file_path)) - - -def test_current_viewer_returns_latest_instance(): - mock_win = MagicMock() - viewer_mod = _reload_viewer_module() - - with ( - patch("cellacdc._event_loop.get_qapp", return_value=MagicMock()), - patch.object(viewer_mod, "_read_version", return_value="test"), - patch.object(viewer_mod, "_create_gui_window", return_value=mock_win), - patch.object(viewer_mod, "_check_gui_installed"), - ): - assert viewer_mod.current_viewer() is None - viewer_mod.Viewer(show=False) - second = viewer_mod.Viewer(show=False) - assert viewer_mod.current_viewer() is second + assert viewer.data is None def test_run_warns_without_top_level_widgets(): @@ -110,7 +69,9 @@ def test_lazy_exports_from_package(): import cellacdc assert cellacdc.Viewer.__name__ == "Viewer" + assert cellacdc.ExperimentData.__name__ == "ExperimentData" assert cellacdc.current_viewer.__name__ == "current_viewer" assert cellacdc.run.__name__ == "run" assert cellacdc.get_qapp.__name__ == "get_qapp" assert cellacdc.quit_app.__name__ == "quit_app" + assert cellacdc.imshow.__name__ == "imshow"

    What should I do with the other files (ext: {otherExt}) in the folder?

    @@ -1989,21 +2041,13 @@ def askActionWithOtherFiles(self, files, otherExt): as the raw files will be moved or copied.

    - """) + """ msg.setIcon(msg.Question) msg.setText(txt) - leaveButton = QPushButton( - 'Leave them where they are' - ) - moveButton = QPushButton( - 'Attempt MOVING to their Position folder' - ) - copyButton = QPushButton( - 'Attempt COPYING to their Position folder' - ) - cancelButton = QPushButton( - 'Cancel' - ) + leaveButton = QPushButton("Leave them where they are") + moveButton = QPushButton("Attempt MOVING to their Position folder") + copyButton = QPushButton("Attempt COPYING to their Position folder") + cancelButton = QPushButton("Cancel") msg.addButton(leaveButton, msg.YesRole) msg.addButton(moveButton, msg.NoRole) msg.addButton(copyButton, msg.RejectRole) @@ -2024,16 +2068,17 @@ def askActionWithOtherFiles(self, files, otherExt): elif msg.clickedButton() == cancelButton: return [] - def warnMultipleFiles(self, files): win = apps.QDialogCombobox( - 'Multiple microscopy files detected!', files, - '

    ' - 'You selected "Single microscopy file", ' - 'but the folder contains multiple files.
    ' - '

    ', - CbLabel='Select which file to load: ', parent=self, - iconPixmap=QtGui.QPixmap(':warning.svg') + "Multiple microscopy files detected!", + files, + '

    ' + 'You selected "Single microscopy file", ' + "but the folder contains multiple files.
    " + "

    ", + CbLabel="Select which file to load: ", + parent=self, + iconPixmap=QtGui.QPixmap(":warning.svg"), ) win.exec_() if win.cancel: @@ -2048,7 +2093,7 @@ def attemptSeparateMultiChannel(self, rawFilenames): stripped_filenames = [] for file in rawFilenames: filename, ext = os.path.splitext(file) - m_iter = myutils.findalliter(fr'(\d+)_(.+)', filename) + m_iter = myutils.findalliter(rf"(\d+)_(.+)", filename) if len(m_iter) <= 1: self.criticalNoFilenamePattern() return False @@ -2059,7 +2104,7 @@ def attemptSeparateMultiChannel(self, rawFilenames): posNum, chName = int(m[0][0]), m[0][1] self.chNames.add(chName) self.posNums.add(posNum) - ch_idx = filename.find(f'{posNum}_{chName}') + ch_idx = filename.find(f"{posNum}_{chName}") stripped_filenames.append(filename[:ch_idx]) except Exception as e: traceback_str = traceback.format_exc() @@ -2079,10 +2124,8 @@ def attemptSeparateMultiChannel(self, rawFilenames): self.SizeS = len(self.posNums) return True - - def criticalNoFilenamePattern(self, error=''): - txt = ( - """ + def criticalNoFilenamePattern(self, error=""): + txt = """ Files are named with a non-compatible pattern.

    In order to automatically generate the required data structure from "Multiple files, one for each channel" the filenames must @@ -2093,9 +2136,8 @@ def criticalNoFilenamePattern(self, error=''): Note that the channel MUST be separated from the rest of the name by an underscore "_" """ - ) msg = QMessageBox(self) - msg.setWindowTitle('Non-compatible pattern') + msg.setWindowTitle("Non-compatible pattern") msg.setIcon(msg.Critical) msg.setText(txt) if error: @@ -2105,8 +2147,8 @@ def criticalNoFilenamePattern(self, error=''): def criticalBioFormats(self, actionTxt, tracebackFormat, filename): msg = widgets.myMessageBox(self, wrapText=True) - url = 'https://docs.openmicroscopy.org/bio-formats/6.7.0/supported-formats.html' - seeHere = f'here' + url = "https://docs.openmicroscopy.org/bio-formats/6.7.0/supported-formats.html" + seeHere = f'here' _, ext = os.path.splitext(filename) txt = html_utils.paragraph( @@ -2124,31 +2166,55 @@ def criticalBioFormats(self, actionTxt, tracebackFormat, filename): You were trying to read file: {filename} """ ) - - msg.critical( - self, 'Error with Bio-Formats', txt, detailsText=tracebackFormat - ) + + msg.critical(self, "Error with Bio-Formats", txt, detailsText=tracebackFormat) self.close() def askConfirmMetadata( - self, filename, LensNA, SizeT, SizeZ, SizeC, SizeS, - TimeIncrement, TimeIncrementUnit, PhysicalSizeX, PhysicalSizeY, - PhysicalSizeZ, PhysicalSizeUnit, chNames, emWavelens, ImageName, - rawFilePath, sampleImgData - ): + self, + filename, + LensNA, + SizeT, + SizeZ, + SizeC, + SizeS, + TimeIncrement, + TimeIncrementUnit, + PhysicalSizeX, + PhysicalSizeY, + PhysicalSizeZ, + PhysicalSizeUnit, + chNames, + emWavelens, + ImageName, + rawFilePath, + sampleImgData, + ): if self.rawDataStruct == 2: filename = self.basename self.metadataDialogIsOpen = True self.metadataWin = apps.QDialogMetadataXML( - title=f'Metadata for {filename}', rawFilename=filename, - LensNA=LensNA, SizeT=SizeT, SizeZ=SizeZ, SizeC=SizeC, SizeS=SizeS, - TimeIncrement=TimeIncrement, TimeIncrementUnit=TimeIncrementUnit, - PhysicalSizeX=PhysicalSizeX, PhysicalSizeY=PhysicalSizeY, - PhysicalSizeZ=PhysicalSizeZ, PhysicalSizeUnit=PhysicalSizeUnit, - ImageName=ImageName, chNames=chNames, emWavelens=emWavelens, - parent=self, rawDataStruct=self.rawDataStruct, - sampleImgData=sampleImgData, rawFilePath=rawFilePath + title=f"Metadata for {filename}", + rawFilename=filename, + LensNA=LensNA, + SizeT=SizeT, + SizeZ=SizeZ, + SizeC=SizeC, + SizeS=SizeS, + TimeIncrement=TimeIncrement, + TimeIncrementUnit=TimeIncrementUnit, + PhysicalSizeX=PhysicalSizeX, + PhysicalSizeY=PhysicalSizeY, + PhysicalSizeZ=PhysicalSizeZ, + PhysicalSizeUnit=PhysicalSizeUnit, + ImageName=ImageName, + chNames=chNames, + emWavelens=emWavelens, + parent=self, + rawDataStruct=self.rawDataStruct, + sampleImgData=sampleImgData, + rawFilePath=rawFilePath, ) self.metadataWin.exec_() self.metadataDialogIsOpen = False @@ -2159,40 +2225,42 @@ def askPosFoldersExisting(self, exp_dst_path): pos_foldernames = myutils.get_pos_foldernames(exp_dst_path) if not pos_foldernames: return False, False, False, 1 - + msg = widgets.myMessageBox(wrapText=False) txt = html_utils.paragraph( - 'The selected destination folder already contains Position folders.

    ' - 'Do you want to overwrite all of its content, ' - 'add files to the existing Position folders,
    ' - 'or create new Position folders?' + "The selected destination folder already contains Position folders.

    " + "Do you want to overwrite all of its content, " + "add files to the existing Position folders,
    " + "or create new Position folders?" ) _, overwriteButton, addFilesButton, createNewButton = msg.warning( - self, 'Warning: existing Position folders detected!', txt, - buttonsTexts=( - 'Cancel', - 'Overwrite', - 'Add files', - widgets.newFilePushButton('Create new'), + self, + "Warning: existing Position folders detected!", + txt, + buttonsTexts=( + "Cancel", + "Overwrite", + "Add files", + widgets.newFilePushButton("Create new"), ), - path_to_browse=exp_dst_path + path_to_browse=exp_dst_path, ) if msg.cancel: - return - + return + overwrite = overwriteButton == msg.clickedButton add_files = addFilesButton == msg.clickedButton create_new = createNewButton == msg.clickedButton - + start_pos_n = 1 if create_new: - pos_ns = [int(pos.split('_')[-1]) for pos in pos_foldernames] + pos_ns = [int(pos.split("_")[-1]) for pos in pos_foldernames] start_pos_n = max(pos_ns) + 1 - + return overwrite, add_files, create_new, start_pos_n def closeEvent(self, event): - self.logger.info('Closing data structure logger...') + self.logger.info("Closing data structure logger...") handlers = self.logger.handlers[:] for handler in handlers: handler.close() @@ -2201,21 +2269,21 @@ def closeEvent(self, event): if self.buttonToRestore is not None: button, color = self.buttonToRestore button.setText('0. Attempt "Create data structure" again') - button.setStyleSheet( - f'QPushButton {{background-color: {color};}}') + button.setStyleSheet(f"QPushButton {{background-color: {color};}}") self.mainWin.setWindowState(Qt.WindowNoState) self.mainWin.setWindowState(Qt.WindowActive) self.mainWin.raise_() + class InitFijiMacro: def __init__(self, acdcLauncher): self.acdcLauncher = acdcLauncher self.logger = self.acdcLauncher.logger - + def askSelectInstalledFiji(self): if os.path.exists(myutils.get_fiji_exec_folderpath()): return False, False - + txt = html_utils.paragraph(f""" Do you already have Fiji (ImageJ)?

    If yes, click on the Select Fiji location button below @@ -2223,62 +2291,60 @@ def askSelectInstalledFiji(self): Alternatively, you can ignore this and let Cell-ACDC automatically download Fiji for you. """) - selectFijiButton = ( - widgets.OpenFilePushButton('Select Fiji.app location') - ) - downloadFijiButton = ( - widgets.DownloadPushButton('Download Fiji.app') - ) + selectFijiButton = widgets.OpenFilePushButton("Select Fiji.app location") + downloadFijiButton = widgets.DownloadPushButton("Download Fiji.app") msg = widgets.myMessageBox(wrapText=False) msg.did_user_selected_fiji = False msg.question( - self.acdcLauncher, 'Select Fiji location', txt, - buttonsTexts=( - 'Cancel', selectFijiButton, downloadFijiButton - ), - showDialog=False + self.acdcLauncher, + "Select Fiji location", + txt, + buttonsTexts=("Cancel", selectFijiButton, downloadFijiButton), + showDialog=False, ) selectFijiButton.clicked.disconnect() selectFijiButton.clicked.connect( partial(self.selectFijiLocation, messagebox=msg) ) msg.exec_() - + return msg.cancel, msg.did_user_selected_fiji - + def selectFijiLocation(self, checked=True, messagebox=None): import qtpy.compat + filepath = qtpy.compat.getopenfilename( - parent=messagebox, - caption='Select Fiji.app location', - filters='Application (*.app);;All Files (*)' + parent=messagebox, + caption="Select Fiji.app location", + filters="Application (*.app);;All Files (*)", )[0] if not filepath: return - + from cellacdc import fiji_location_filepath - with open(fiji_location_filepath, 'w') as txt: + + with open(fiji_location_filepath, "w") as txt: txt.write(os.path.join(filepath)) - + messagebox.did_user_selected_fiji = True messagebox.cancel = False messagebox.close() - + def run(self): cancel, did_user_selected_fiji = self.askSelectInstalledFiji() if cancel: self.cancel() return - - txt = (f""" + + txt = f""" In order to run Bio-Formats on your system, Cell-ACDC will use Fiji (ImageJ) from the command line.

    The process entails the creation of a macro (.ijm) file and its execution from the command line.

    If you prefer to run the macro yourself, you can go through its creation process and cancel its execution later. - """) - self.logger.info('Testing Fiji command...') + """ + self.logger.info("Testing Fiji command...") fiji_success = myutils.test_fiji_base_command(self.logger.info) commands = None if not fiji_success: @@ -2287,96 +2353,98 @@ def run(self): shutil.rmtree(acdc_fiji_path) except Exception as err: pass - - href = html_utils.href_tag('here', urls.fiji_downloads) - note_download_txt = (f""" + + href = html_utils.href_tag("here", urls.fiji_downloads) + note_download_txt = f""" Before continuing, Fiji will be automatically downloaded now.

    If the download fails, please download the zip file from {href} and unzip it in the following location: - """) - admon = html_utils.to_admonition( - note_download_txt, admonition_type='note' - ) - txt = f'{txt}
    {admon}' + """ + admon = html_utils.to_admonition(note_download_txt, admonition_type="note") + txt = f"{txt}
    {admon}" commands = (acdc_fiji_path,) - + txt = html_utils.paragraph(txt) msg = widgets.myMessageBox(wrapText=False) msg.information( - self.acdcLauncher, 'Running Fiji in the command line', txt, - buttonsTexts=('Cancel', 'Ok'), - commands=commands + self.acdcLauncher, + "Running Fiji in the command line", + txt, + buttonsTexts=("Cancel", "Ok"), + commands=commands, ) if msg.cancel: self.cancel() return - + myutils.download_fiji(logger_func=self.logger.info) - + win = apps.InitFijiMacroDialog(parent=self.acdcLauncher) win.exec_() if win.cancel: self.cancel() return - + init_macro_args = win.init_macro_args is_separate_channels = init_macro_args[2] macro_filepath = fiji_macros.init_macro(*init_macro_args) macro_command = fiji_macros.command_run_macro(macro_filepath) - - txt = (""" + + txt = """ Cell-ACDC will now run the macro in the terminal.

    During the process, the GUI will be unresponsive, while progress will be displayed in the terminal.

    If you prefer, you can stop the process now and run the command yourself, or even run the macro directly from the Fiji GUI.
    - """) - + """ + if is_separate_channels: important_admon = html_utils.to_admonition( - 'There are still steps to run after the macro finishes, so ' - 'if you run it yourself, ' - 'please close this dialogue only after the macro completes.', - admonition_type='important' + "There are still steps to run after the macro finishes, so " + "if you run it yourself, " + "please close this dialogue only after the macro completes.", + admonition_type="important", ) - txt = f'{txt}{important_admon}' - - txt = f'{txt}
    Command to run the macro:' - + txt = f"{txt}{important_admon}" + + txt = f"{txt}
    Command to run the macro:" + txt = html_utils.paragraph(txt) msg = widgets.myMessageBox(wrapText=False) _, _, okButton = msg.information( - self.acdcLauncher, 'Fiji macro command', txt, - buttonsTexts=('Cancel', 'I already ran the macro', 'Ok'), + self.acdcLauncher, + "Fiji macro command", + txt, + buttonsTexts=("Cancel", "I already ran the macro", "Ok"), commands=(macro_filepath), - path_to_browse=os.path.dirname(macro_filepath) + path_to_browse=os.path.dirname(macro_filepath), ) if msg.cancel: self.cancel() return - + success = True if msg.clickedButton == okButton: - success = fiji_macros.run_macro(macro_command) - + success = fiji_macros.run_macro(macro_command) + files_folderpath = init_macro_args[0] dst_folderpath = init_macro_args[3] channels = init_macro_args[4] if success and is_separate_channels: - self.logger.info('Moving files to Position folders...') + self.logger.info("Moving files to Position folders...") success = io.move_separate_channels_tiffs_to_pos_folders( dst_folderpath, channels ) - + if success: txt = html_utils.paragraph(""" Macro execution completed. Path to the macro file: """) - msg_func = 'information' + msg_func = "information" else: - href = html_utils.href_tag('GitHub page', urls.issues_url) + href = html_utils.href_tag("GitHub page", urls.issues_url) txt = html_utils.paragraph(f""" Macro execution completed with errors. More details in the terminal.

    @@ -2384,15 +2452,15 @@ def run(self): {href}

    Path to the macro file: """) - msg_func = 'information' - + msg_func = "information" + msg = widgets.myMessageBox(wrapText=False) getattr(msg, msg_func)( - self.acdcLauncher, 'Macro execution completed', txt, - commands=(macro_filepath,) + self.acdcLauncher, + "Macro execution completed", + txt, + commands=(macro_filepath,), ) - + def cancel(self): - self.logger.info('Running Bio-Formats from Fiji process cancelled.') - - + self.logger.info("Running Bio-Formats from Fiji process cancelled.") diff --git a/cellacdc/debugutils.py b/cellacdc/debugutils.py index b55d0eef1..28a001005 100644 --- a/cellacdc/debugutils.py +++ b/cellacdc/debugutils.py @@ -5,7 +5,8 @@ import gc import psutil -def showRefGraph(object_str:str, debug:bool=True): + +def showRefGraph(object_str: str, debug: bool = True): """Save a reference graph of the given object type. @@ -18,48 +19,51 @@ def showRefGraph(object_str:str, debug:bool=True): """ if not debug: return - + try: import objgraph except ImportError: conda_prefix, pip_prefix = myutils.get_pip_conda_prefix() - print(f"objgraph is not installed. Install it with '{pip_prefix} objgraph' to use reference graph features, as well as https://www.graphviz.org/") + print( + f"objgraph is not installed. Install it with '{pip_prefix} objgraph' to use reference graph features, as well as https://www.graphviz.org/" + ) return caller_func = inspect.currentframe().f_back.f_code.co_name caller_file = inspect.currentframe().f_back.f_code.co_filename - caller_file = os.path.basename(caller_file).rstrip('.py') + caller_file = os.path.basename(caller_file).rstrip(".py") caller_line = inspect.currentframe().f_back.f_lineno - timestap = datetime.datetime.now().strftime('%H_%M_%S') + timestap = datetime.datetime.now().strftime("%H_%M_%S") - ref_graph_path = os.path.join( - os.path.dirname(cellacdc_path), - '.ref_graphs' - ) + ref_graph_path = os.path.join(os.path.dirname(cellacdc_path), ".ref_graphs") os.makedirs(ref_graph_path, exist_ok=True) - - filename = os.path.join(ref_graph_path, f'ref_graph_{timestap}_{object_str}_from_{caller_file}_{caller_func}_{caller_line}.svg') - timestap = datetime.datetime.now().strftime('%H:%M:%S') + filename = os.path.join( + ref_graph_path, + f"ref_graph_{timestap}_{object_str}_from_{caller_file}_{caller_func}_{caller_line}.svg", + ) + + timestap = datetime.datetime.now().strftime("%H:%M:%S") currentframe = inspect.currentframe() outerframes = inspect.getouterframes(currentframe) callingframe = outerframes[1].frame callingframe_info = inspect.getframeinfo(callingframe) filepath = callingframe_info.filename - fileinfo_str = ( - f'File "{filepath}", line {callingframe_info.lineno} - {timestap}:' - ) - + fileinfo_str = f'File "{filepath}", line {callingframe_info.lineno} - {timestap}:' gc.collect() instances = objgraph.by_type(object_str) if instances: objgraph.show_backrefs(instances, max_depth=500, filename=filename) - print(fileinfo_str, f'Graph saved as "{filename}" \n for {len(instances)} instances of "{object_str}"') + print( + fileinfo_str, + f'Graph saved as "{filename}" \n for {len(instances)} instances of "{object_str}"', + ) else: - print(fileinfo_str, f'No {object_str} instances found.') + print(fileinfo_str, f"No {object_str} instances found.") + def print_largest_attributes( obj, top_n=10, return_list=False, show_percent=True, process_mem=None @@ -67,7 +71,7 @@ def print_largest_attributes( attrs = [] total = 0 for attr in dir(obj): - if attr.startswith('__'): + if attr.startswith("__"): continue try: val = getattr(obj, attr) @@ -85,7 +89,9 @@ def print_largest_attributes( percent = (size / total * 100) if total > 0 else 0 proc_percent = (size / process_mem * 100) if process_mem else 0 if show_percent and process_mem: - print(f"{attr:30} {size:10,} bytes {percent:6.2f}% of obj {proc_percent:6.2f}% of proc {typ}") + print( + f"{attr:30} {size:10,} bytes {percent:6.2f}% of obj {proc_percent:6.2f}% of proc {typ}" + ) elif show_percent: print(f"{attr:30} {size:10,} bytes {percent:6.2f}% {typ}") else: @@ -93,6 +99,7 @@ def print_largest_attributes( if return_list: return attrs[:top_n] + def print_call_stack(debug=True, depth=None): if not debug: return @@ -100,11 +107,12 @@ def print_call_stack(debug=True, depth=None): stack = stack[:-1] if depth: depth = depth + 1 - stack = stack[-depth:] + stack = stack[-depth:] print("Call stack:") for line in stack: print(line.strip()) + def print_largest_attributes_for_all_classes(package_prefix="cellacdc", top_n=5): # Find all classes defined in your package classes = set() @@ -119,12 +127,13 @@ def print_largest_attributes_for_all_classes(package_prefix="cellacdc", top_n=5) continue print(f"\nClass: {cls.__module__}.{cls.__name__} ({len(instances)} instances)") for i, inst in enumerate(instances): - print(f" Instance {i+1}:") + print(f" Instance {i + 1}:") try: print_largest_attributes(inst, top_n=top_n) except Exception as e: print(f" Could not inspect instance: {e}") + def print_largest_classes(package_prefix="cellacdc", top_n=10, max_instances=100): """ Print classes (optionally filtered by module prefix) sorted by total memory usage. @@ -134,6 +143,7 @@ def print_largest_classes(package_prefix="cellacdc", top_n=10, max_instances=100 import gc import psutil import os + try: from pympler import asizeof except ImportError: @@ -185,7 +195,7 @@ def print_largest_classes(package_prefix="cellacdc", top_n=10, max_instances=100 # scale up if sampled if counted > 0 and n > counted: - total_size *= (n / counted) + total_size *= n / counted if total_size > 0: class_mem.append((cls, total_size, n)) @@ -193,7 +203,7 @@ def print_largest_classes(package_prefix="cellacdc", top_n=10, max_instances=100 # ✅ Sort by memory class_mem.sort(key=lambda x: x[1], reverse=True) - print(f"Total process memory: {process_mem/1024**2:.1f} MB") + print(f"Total process memory: {process_mem / 1024**2:.1f} MB") print(f"{'Class':60} {'Instances':>10} {'Total MB':>12} {'% of proc':>10}") for cls, total_size, n in class_mem[:top_n]: @@ -201,7 +211,7 @@ def print_largest_classes(package_prefix="cellacdc", top_n=10, max_instances=100 name = f"{cls.__module__}.{cls.__name__}" - print(f"{name:<60} {n:10} {total_size/1024**2:12.2f} {percent:9.2f}%") + print(f"{name:<60} {n:10} {total_size / 1024**2:12.2f} {percent:9.2f}%") # Example usage: diff --git a/cellacdc/docs/source/citations/citations_per_year.py b/cellacdc/docs/source/citations/citations_per_year.py index be3d7b8e5..ee83488f7 100644 --- a/cellacdc/docs/source/citations/citations_per_year.py +++ b/cellacdc/docs/source/citations/citations_per_year.py @@ -9,33 +9,33 @@ cwd_path = os.path.dirname(os.path.abspath(__file__)) # Load data -csv_path = os.path.join(cwd_path, 'citations_per_year.csv') +csv_path = os.path.join(cwd_path, "citations_per_year.csv") df = pd.read_csv(csv_path) # Calculate maximum of yticks yticks_step = 5 -yticks_max = np.ceil(df['citations'].max() / yticks_step) * yticks_step +yticks_max = np.ceil(df["citations"].max() / yticks_step) * yticks_step yticks_max = int(yticks_max) # Plot -font = {'size': 13} -matplotlib.rc('font', **font) +font = {"size": 13} +matplotlib.rc("font", **font) fig, ax = plt.subplots(figsize=(6, 4)) -ax.bar(df['year'], df['citations']) -ax.set_xlabel('Year') -ax.set_ylabel('Citations') -ax.set_title('Citations per Year') -ax.set_xticks(df['year']) -ax.set_yticks(range(0, yticks_max+1, yticks_step)) +ax.bar(df["year"], df["citations"]) +ax.set_xlabel("Year") +ax.set_ylabel("Citations") +ax.set_title("Citations per Year") +ax.set_xticks(df["year"]) +ax.set_yticks(range(0, yticks_max + 1, yticks_step)) ax.grid( - True, - 'major', - axis='y', - zorder=0, - alpha=0.7, - color='gray', - linestyle='--', + True, + "major", + axis="y", + zorder=0, + alpha=0.7, + color="gray", + linestyle="--", ) ax.set_axisbelow(True) plt.tight_layout() @@ -44,6 +44,6 @@ # exit() # Save image -png_out_path = os.path.join(cwd_path, 'citations_per_year.png') +png_out_path = os.path.join(cwd_path, "citations_per_year.png") plt.savefig(png_out_path, dpi=300) -plt.close() \ No newline at end of file +plt.close() diff --git a/cellacdc/docs/source/conf.py b/cellacdc/docs/source/conf.py index 44d96ed98..671b28df4 100644 --- a/cellacdc/docs/source/conf.py +++ b/cellacdc/docs/source/conf.py @@ -5,9 +5,9 @@ # -- Project information -project = 'Cell-ACDC' +project = "Cell-ACDC" author = cellacdc.__author__ -copyright = f'{datetime.now():%Y}, {author}' +copyright = f"{datetime.now():%Y}, {author}" version = cellacdc.__version__ release = version @@ -16,47 +16,47 @@ # -- General configuration extensions = [ - 'sphinx.ext.duration', - 'sphinx.ext.doctest', - 'sphinx.ext.autodoc', - 'sphinx.ext.autosummary', - 'sphinx.ext.intersphinx', - 'sphinxcontrib.email', - 'sphinx_tabs.tabs', - 'sphinx_carousel.carousel', - 'sphinx_copybutton' + "sphinx.ext.duration", + "sphinx.ext.doctest", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.intersphinx", + "sphinxcontrib.email", + "sphinx_tabs.tabs", + "sphinx_carousel.carousel", + "sphinx_copybutton", ] intersphinx_mapping = { - 'python': ('https://docs.python.org/3/', None), - 'sphinx': ('https://www.sphinx-doc.org/en/master/', None), + "python": ("https://docs.python.org/3/", None), + "sphinx": ("https://www.sphinx-doc.org/en/master/", None), } -intersphinx_disabled_domains = ['std'] +intersphinx_disabled_domains = ["std"] -html_favicon = 'https://raw.githubusercontent.com/SchmollerLab/Cell_ACDC/main/cellacdc/resources/icon.ico' +html_favicon = "https://raw.githubusercontent.com/SchmollerLab/Cell_ACDC/main/cellacdc/resources/icon.ico" -templates_path = ['_templates'] +templates_path = ["_templates"] # -- Options for HTML output -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # -- Options for EPUB output -epub_show_urls = 'footnote' +epub_show_urls = "footnote" # -- My css -html_static_path = ['static'] +html_static_path = ["static"] html_css_files = [ - 'css/custom.css', + "css/custom.css", ] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = [ - '_build', - 'Thumbs.db', - '.DS_Store', - '_gui_packages.rst', - '_models_list.rst' -] \ No newline at end of file + "_build", + "Thumbs.db", + ".DS_Store", + "_gui_packages.rst", + "_models_list.rst", +] diff --git a/cellacdc/exporters.py b/cellacdc/exporters.py index 0cd190574..d4ff6b7fb 100644 --- a/cellacdc/exporters.py +++ b/cellacdc/exporters.py @@ -18,26 +18,27 @@ from . import is_mac, is_win from . import acdc_ffmpeg_path + class ImageExporter(pyqtgraph.exporters.ImageExporter): def __init__(self, item, background=(0, 0, 0, 0), dpi=100, save_pngs=True): super().__init__(item) self._save_pngs = save_pngs - + self._dpi = dpi - + # DPI using A4 width desired_width = 8.268 * dpi - self.params['width'] = desired_width - - self.parameters()['background'] = (0, 0, 0, 0) - + self.params["width"] = desired_width + + self.parameters()["background"] = (0, 0, 0, 0) + def super_export(self, filepath): super().export(filepath) - + def svg_to_image(self, svg_filepath, image_filepath): - width = self.params['width'] - height = self.params['height'] - + width = self.params["width"] + height = self.params["height"] + renderer = QSvgRenderer(svg_filepath) img = QImage(width, height, QImage.Format_ARGB32) img.fill(0) @@ -46,85 +47,83 @@ def svg_to_image(self, svg_filepath, image_filepath): renderer.render(p) p.end() - if image_filepath.endswith('.tiff'): - png_filepath = image_filepath.replace('.tiff', '.png') + if image_filepath.endswith(".tiff"): + png_filepath = image_filepath.replace(".tiff", ".png") img.save(png_filepath, quality=100) png_img = skimage.io.imread(png_filepath) skimage.io.imsave(image_filepath, png_img) - try: + try: os.remove(png_filepath) except Exception: pass else: img.save(image_filepath) - + def crop_from_mask(self, img_rgba): - if not hasattr(self.item, 'exportMaskImageItem'): + if not hasattr(self.item, "exportMaskImageItem"): return img_rgba - + crop_mask_rgba = self.item.exportMaskImageItem.image if crop_mask_rgba is None: return img_rgba - + alpha = crop_mask_rgba[..., 3] rows, cols = np.where(alpha == 0) top, bottom = rows.min(), rows.max() + 1 left, right = cols.min(), cols.max() + 1 - + pos = self.item.exportMaskImageItem.pos() x0, y0 = pos.x(), pos.y() - + view_range = self.item.viewRange() (x_min, x_max), (y_min, y_max) = view_range H, W = img_rgba.shape[:2] - + # x mapping left_px_f = (left - x_min) / (x_max - x_min) * W right_px_f = (right - x_min) / (x_max - x_min) * W - + # y mapping (PNG origin top-left) top_px_f = (y_max - top) / (y_max - y_min) * H bottom_px_f = (y_max - bottom) / (y_max - y_min) * H - - left_px = int(np.floor(left_px_f)) - right_px = int(np.ceil(right_px_f)) + + left_px = int(np.floor(left_px_f)) + right_px = int(np.ceil(right_px_f)) bottom_px = int(np.floor(bottom_px_f)) - top_px = int(np.ceil(top_px_f)) - + top_px = int(np.ceil(top_px_f)) + if left_px < 0: left_px = 0 - + if right_px > W: right_px = W - + if bottom_px < 0: bottom_px = 0 - + if top_px > H: top_px = H - + return img_rgba[bottom_px:top_px, left_px:right_px] - def export(self, filepath): + def export(self, filepath): no_ext_filepath, ext = os.path.splitext(filepath) - svg_filepath = f'{no_ext_filepath}.svg' - svg_exporter = SVGExporter(self.item) + svg_filepath = f"{no_ext_filepath}.svg" + svg_exporter = SVGExporter(self.item) svg_exporter.export(svg_filepath) self.svg_to_image(svg_filepath, filepath) - - try: + + try: os.remove(svg_filepath) except Exception as err: pass - + # Remove padding - img_rgba = skimage.io.imread(filepath) + img_rgba = skimage.io.imread(filepath) img_rgba = self.crop_from_mask(img_rgba) - - img_rgba = transformation.crop_outer_padding( - img_rgba, value=(0, 0, 0, 255) - ) + + img_rgba = transformation.crop_outer_padding(img_rgba, value=(0, 0, 0, 255)) img_rgba = transformation.crop_outer_padding( img_rgba, value=(255, 255, 255, 255) ) @@ -133,21 +132,23 @@ def export(self, filepath): skimage.io.imsave(filepath, img_rgba, check_contrast=False) img_bgr = cv2.cvtColor(img_rgba, cv2.COLOR_RGBA2BGR) - + return img_bgr + class SVGExporter(pyqtgraph.exporters.SVGExporter): def __init__(self, item): super().__init__(item) - self.parameters()['background'] = (0, 0, 0, 0) + self.parameters()["background"] = (0, 0, 0, 0) + class VideoExporter: def __init__(self, avi_filepath, fps): self.writer = None self._avi_filepath = avi_filepath self._fps = fps - self._fourcc = cv2.VideoWriter_fourcc(*'XVID') - + self._fourcc = cv2.VideoWriter_fourcc(*"XVID") + def add_frame(self, img_bgr): if self.writer is None: height, width = img_bgr.shape[:-1] @@ -155,81 +156,85 @@ def add_frame(self, img_bgr): self._avi_filepath, self._fourcc, self._fps, (width, height) ) self.writer.write(img_bgr) - + def release(self): self.writer.release() - + def avi_to_mp4(self): avi_to_mp4(self._avi_filepath) + def avi_to_mp4(in_filepath_avi, out_filepath_mp4=None): ffmep_exec_path = myutils.download_ffmpeg() - + if out_filepath_mp4 is None: - out_filepath_mp4 = in_filepath_avi.replace('.avi', '.mp4') - - ffmep_exec_path = ffmep_exec_path.replace('\\', '/') - out_filepath_mp4 = out_filepath_mp4.replace('\\', '/') - in_filepath_avi = in_filepath_avi.replace('\\', '/') - + out_filepath_mp4 = in_filepath_avi.replace(".avi", ".mp4") + + ffmep_exec_path = ffmep_exec_path.replace("\\", "/") + out_filepath_mp4 = out_filepath_mp4.replace("\\", "/") + in_filepath_avi = in_filepath_avi.replace("\\", "/") + args = [ - '-i', f'{in_filepath_avi}', '-c:v', 'libx264', - '-crf', '18', '-an', f'{out_filepath_mp4}' + "-i", + f"{in_filepath_avi}", + "-c:v", + "libx264", + "-crf", + "18", + "-an", + f"{out_filepath_mp4}", ] - + _run_ffmpeg(ffmep_exec_path, args) + def _run_ffmpeg(ffmep_exec_path, command_args): import subprocess, os - + command_args_no_quotes = [ - arg.replace('"', '').replace("'", '') for arg in command_args + arg.replace('"', "").replace("'", "") for arg in command_args ] - full_command = ' '.join(command_args_no_quotes) - full_command = f'{ffmep_exec_path} {full_command}' - - separator = '-'*100 + full_command = " ".join(command_args_no_quotes) + full_command = f"{ffmep_exec_path} {full_command}" + + separator = "-" * 100 print( - f'{separator}\n' - f'Converting to MP4 with the following command:\n\n' - f'`{full_command}`\n' - f'{separator}' + f"{separator}\n" + f"Converting to MP4 with the following command:\n\n" + f"`{full_command}`\n" + f"{separator}" ) if is_win: subprocess.check_call(full_command) return - - ffmpeg_exec_path = os.path.join(acdc_ffmpeg_path, 'ffmpeg') + + ffmpeg_exec_path = os.path.join(acdc_ffmpeg_path, "ffmpeg") if is_mac: - args_ffmpeg_executable = [f'chmod 755 {ffmpeg_exec_path}'] + args_ffmpeg_executable = [f"chmod 755 {ffmpeg_exec_path}"] subprocess.check_call(args_ffmpeg_executable, shell=True) - command_str = ' '.join(command_args) - command_no_quotes_str = ' '.join(command_args_no_quotes) - + command_str = " ".join(command_args) + command_no_quotes_str = " ".join(command_args_no_quotes) + commands_to_try = ( - ['ffmpeg', *command_args], - ['ffmpeg', *command_args_no_quotes], - f'ffmpeg {command_str}', - f'ffmpeg {command_no_quotes_str}', - [ffmpeg_exec_path, *command_args], + ["ffmpeg", *command_args], + ["ffmpeg", *command_args_no_quotes], + f"ffmpeg {command_str}", + f"ffmpeg {command_no_quotes_str}", + [ffmpeg_exec_path, *command_args], [ffmpeg_exec_path, *command_args_no_quotes], - f'{ffmpeg_exec_path} {command_str}', - f'{ffmpeg_exec_path} {command_no_quotes_str}', + f"{ffmpeg_exec_path} {command_str}", + f"{ffmpeg_exec_path} {command_no_quotes_str}", ) for command in commands_to_try: print( - f'{separator}\n' - f'Attempting conversion to MP4 with the following command:\n\n' - f'`{command}`\n' - f'{separator}' + f"{separator}\n" + f"Attempting conversion to MP4 with the following command:\n\n" + f"`{command}`\n" + f"{separator}" ) try: subprocess.check_call(command, shell=True) break except Exception as err: - print( - f'{separator}\n' - f'[ERROR]: {err}\n' - f'{separator}' - ) \ No newline at end of file + print(f"{separator}\n[ERROR]: {err}\n{separator}") diff --git a/cellacdc/features.py b/cellacdc/features.py index 852a3f16d..5b7e9acce 100644 --- a/cellacdc/features.py +++ b/cellacdc/features.py @@ -9,9 +9,10 @@ from . import measurements from . import printl + def add_rotational_volume_regionprops( - rp, PhysicalSizeY=1, PhysicalSizeX=1, logger_func=None - ): + rp, PhysicalSizeY=1, PhysicalSizeX=1, logger_func=None +): for obj in rp: vol_vox, vol_fl = _core._calc_rotational_vol( obj, PhysicalSizeY, PhysicalSizeX, logger=logger_func @@ -19,54 +20,63 @@ def add_rotational_volume_regionprops( obj.vol_vox, obj.vol_fl = vol_vox, vol_fl return rp + def filter_acdc_df_by_features_range(features_range, acdc_df): - queries = [] + queries = [] for feature_name, thresholds in features_range.items(): if feature_name not in acdc_df.columns: pass _min, _max = thresholds if _min is not None: - queries.append(f'({feature_name} > {_min})') + queries.append(f"({feature_name} > {_min})") if _max is not None: - queries.append(f'({feature_name} < {_max})') + queries.append(f"({feature_name} < {_max})") if not queries: return acdc_df - - query = ' & '.join(queries) + + query = " & ".join(queries) return acdc_df.query(query) + def _eval_equation_df(df, new_col_name, expression): try: df[new_col_name] = df.eval(expression) except Exception as error: traceback.print_exc() + def _add_combined_metrics_acdc_df(posData, df): - # Add channel specifc combined metrics (from equations and + # Add channel specifc combined metrics (from equations and # from user_path_equations sections) config = posData.combineMetricsConfig for chName in posData.loadedChNames: - posDataEquations = config['equations'] - userPathChEquations = config['user_path_equations'] + posDataEquations = config["equations"] + userPathChEquations = config["user_path_equations"] for newColName, equation in posDataEquations.items(): _eval_equation_df(df, newColName, equation) for newColName, equation in userPathChEquations.items(): _eval_equation_df(df, newColName, equation) + def get_acdc_df_features( - posData, grouped_features, lab, foregr_img, frame_i, filename, - channel, bkgrData, other_channels_foregr_imgs - ): + posData, + grouped_features, + lab, + foregr_img, + frame_i, + filename, + channel, + bkgrData, + other_channels_foregr_imgs, +): posData.fluo_bkgrData_dict[filename] = bkgrData - yx_pxl_to_um2 = posData.PhysicalSizeY*posData.PhysicalSizeX - vox_to_fl_3D = ( - posData.PhysicalSizeY*posData.PhysicalSizeX*posData.PhysicalSizeZ - ) - + yx_pxl_to_um2 = posData.PhysicalSizeY * posData.PhysicalSizeX + vox_to_fl_3D = posData.PhysicalSizeY * posData.PhysicalSizeX * posData.PhysicalSizeZ + rp = skimage.measure.regionprops(lab) isSegm3D = lab.ndim == 3 - + # Initialise DataFrame IDs = [obj.label for obj in rp] columns = [] @@ -78,110 +88,141 @@ def get_acdc_df_features( columns.extend(metrics_names) data = np.zeros((len(IDs), len(columns))) df = pd.DataFrame(columns=columns, index=IDs, data=data) - df.index.name = 'Cell_ID' + df.index.name = "Cell_ID" for category, metrics_names in grouped_features.items(): - if category == 'size': + if category == "size": df = measurements.add_size_metrics( df, rp, metrics_names, isSegm3D, yx_pxl_to_um2, vox_to_fl_3D ) - elif category == 'standard': + elif category == "standard": metrics_func, _ = measurements.standard_metrics_func() custom_func_dict = measurements.get_custom_metrics_func() - + # Get metrics to save params = measurements.get_metrics_params( metrics_names, metrics_func, custom_func_dict ) - (bkgr_metrics_params, foregr_metrics_params, - concentration_metrics_params, custom_metrics_params) = params - + ( + bkgr_metrics_params, + foregr_metrics_params, + concentration_metrics_params, + custom_metrics_params, + ) = params + # Get background masks autoBkgr_masks = measurements.get_autoBkgr_mask( lab, isSegm3D, posData, frame_i ) - + autoBkgr_mask, autoBkgr_mask_proj = autoBkgr_masks - dataPrepBkgrROI_mask = measurements.get_bkgrROI_mask( - posData, isSegm3D - ) - + dataPrepBkgrROI_mask = measurements.get_bkgrROI_mask(posData, isSegm3D) + # Get the z-slice if we have z-stacks - z = posData.zSliceSegmentation( - filename, frame_i, errors='ignore' - ) - + z = posData.zSliceSegmentation(filename, frame_i, errors="ignore") + # Get the background data bkgr_data = measurements.get_bkgr_data( - foregr_img, posData, filename, frame_i, autoBkgr_mask, z, - autoBkgr_mask_proj, dataPrepBkgrROI_mask, isSegm3D, lab + foregr_img, + posData, + filename, + frame_i, + autoBkgr_mask, + z, + autoBkgr_mask_proj, + dataPrepBkgrROI_mask, + isSegm3D, + lab, ) - + # Compute background values df = measurements.add_bkgr_values( df, bkgr_data, bkgr_metrics_params[channel], metrics_func ) - + foregr_data = measurements.get_foregr_data(foregr_img, isSegm3D, z) - + # Iterate objects and compute foreground metrics df = measurements.add_foregr_standard_metrics( - df, rp, channel, foregr_data, - foregr_metrics_params[channel], - metrics_func, isSegm3D, - lab, foregr_img, - z_slice=z + df, + rp, + channel, + foregr_data, + foregr_metrics_params[channel], + metrics_func, + isSegm3D, + lab, + foregr_img, + z_slice=z, ) df = measurements.add_concentration_metrics( df, concentration_metrics_params ) - + df = measurements.add_custom_metrics( - df, rp, channel, foregr_data, - custom_metrics_params[channel], - isSegm3D, lab, foregr_img, + df, + rp, + channel, + foregr_data, + custom_metrics_params[channel], + isSegm3D, + lab, + foregr_img, other_channels_foregr_imgs, z_slice=z, ) - - elif category == 'regionprop': + + elif category == "regionprop": try: df, rp_errors = measurements.add_regionprops_metrics( df, lab, metrics_names, logger_func=print ) except Exception as error: traceback.print_exc() - + # Remove 0s columns df = df.loc[:, (df != -2).any(axis=0)] - + return df + def add_background_metrics_names( - grouped_features, channel, isSegm3D, isZstack, isManualBackgrPresent - ): + grouped_features, channel, isSegm3D, isZstack, isManualBackgrPresent +): _, bkgr_val_desc = measurements.standard_metrics_desc( - isZstack, channel, isSegm3D=isSegm3D, - isManualBackgrPresent=isManualBackgrPresent + isZstack, + channel, + isSegm3D=isSegm3D, + isManualBackgrPresent=isManualBackgrPresent, ) backgr_metrics_names = list(bkgr_val_desc.keys()) backgr_metrics_names = [ - name for name in backgr_metrics_names - if (name.find('bkgrVal_median')!=-1 or name.find('bkgrVal_mean')!=-1) + name + for name in backgr_metrics_names + if (name.find("bkgrVal_median") != -1 or name.find("bkgrVal_mean") != -1) ] - if 'standard' not in grouped_features: - grouped_features['standard'] = {channel: backgr_metrics_names} + if "standard" not in grouped_features: + grouped_features["standard"] = {channel: backgr_metrics_names} else: for backgr_metric_name in backgr_metrics_names: - if backgr_metric_name in grouped_features['standard'][channel]: + if backgr_metric_name in grouped_features["standard"][channel]: continue - grouped_features['standard'][channel].append(backgr_metric_name) + grouped_features["standard"][channel].append(backgr_metric_name) return grouped_features + def custom_post_process_segm( - posData, grouped_features, lab, img, frame_i, filename, channel, - features_range, other_channels_foregr_imgs=None, return_delIDs=False - ): + posData, + grouped_features, + lab, + img, + frame_i, + filename, + channel, + features_range, + other_channels_foregr_imgs=None, + return_delIDs=False, +): isSegm3D = lab.ndim == 3 isZstack = posData.SizeZ > 1 bkgrData = posData.bkgrData @@ -192,8 +233,15 @@ def custom_post_process_segm( grouped_features, channel, isSegm3D, isZstack, isManualBackgrPresent ) df = get_acdc_df_features( - posData, grouped_features, lab, img, frame_i, filename, channel, - bkgrData, other_channels_foregr_imgs + posData, + grouped_features, + lab, + img, + frame_i, + filename, + channel, + bkgrData, + other_channels_foregr_imgs, ) try: filtered_df = filter_acdc_df_by_features_range(features_range, df) @@ -208,4 +256,4 @@ def custom_post_process_segm( if return_delIDs: return filtered_lab, df.index.difference(filtered_df.index).to_list() else: - return filtered_lab \ No newline at end of file + return filtered_lab diff --git a/cellacdc/fiji_macros/__init__.py b/cellacdc/fiji_macros/__init__.py index 5862b93ea..30995937a 100644 --- a/cellacdc/fiji_macros/__init__.py +++ b/cellacdc/fiji_macros/__init__.py @@ -7,60 +7,62 @@ from .. import acdc_fiji_path + def init_macro( - files_folderpath: os.PathLike, - is_multiple_files: bool, - is_separate_channels: bool, - dst_folderpath: os.PathLike, - channels: Iterable[str] - ): - macros_folderpath = os.path.join(acdc_fiji_path, 'macros') + files_folderpath: os.PathLike, + is_multiple_files: bool, + is_separate_channels: bool, + dst_folderpath: os.PathLike, + channels: Iterable[str], +): + macros_folderpath = os.path.join(acdc_fiji_path, "macros") os.makedirs(macros_folderpath, exist_ok=True) - + macros_template_folderpath = os.path.dirname(os.path.abspath(__file__)) if is_separate_channels: - macro_template_filename = 'multiple_files_separate_channels.ijm' + macro_template_filename = "multiple_files_separate_channels.ijm" elif is_multiple_files: - macro_template_filename = 'multiple_files.ijm' + macro_template_filename = "multiple_files.ijm" else: - macro_template_filename = 'single_file.ijm' - + macro_template_filename = "single_file.ijm" + macro_template_filepath = os.path.join( macros_template_folderpath, macro_template_filename ) - with open(macro_template_filepath, 'r') as ijm: + with open(macro_template_filepath, "r") as ijm: macro_txt = ijm.read() - + channels = [f'"{ch.strip()}"' for ch in channels] - channels_macro = ', '.join(channels) + channels_macro = ", ".join(channels) macro_txt = macro_txt.replace( - 'channels = newArray(...)', - f'channels = newArray({channels_macro})' + "channels = newArray(...)", f"channels = newArray({channels_macro})" ) - - files_path = files_folderpath.replace('\\', '/') + + files_path = files_folderpath.replace("\\", "/") files_path = f'"{files_path}/"' - macro_txt = macro_txt.replace('id = ...', f'id = {files_path}') - - dst_folderpath = dst_folderpath.replace('\\', '/') + macro_txt = macro_txt.replace("id = ...", f"id = {files_path}") + + dst_folderpath = dst_folderpath.replace("\\", "/") macro_txt = macro_txt.replace( - 'dst_folderpath = ...', f'dst_folderpath = "{dst_folderpath}"' + "dst_folderpath = ...", f'dst_folderpath = "{dst_folderpath}"' ) - - date_time = datetime.datetime.now().strftime(r'%Y-%m-%d_%H-%M-%S') + + date_time = datetime.datetime.now().strftime(r"%Y-%m-%d_%H-%M-%S") id = uuid4() - macro_filename = f'{date_time}_{id}_{macro_template_filename}' + macro_filename = f"{date_time}_{id}_{macro_template_filename}" macro_filepath = os.path.join(macros_folderpath, macro_filename) - with open(macro_filepath, 'w') as ijm: + with open(macro_filepath, "w") as ijm: ijm.write(macro_txt) - + return macro_filepath + def command_run_macro(macro_filepath): exec_path = myutils.get_fiji_exec_folderpath() - command = f'{exec_path} -macro {macro_filepath}' + command = f"{exec_path} -macro {macro_filepath}" return command + def run_macro(macro_command): success = myutils.run_fiji_command(command=macro_command) - return success \ No newline at end of file + return success diff --git a/cellacdc/gui.py b/cellacdc/gui.py index 72daa3fde..2e95cecb9 100755 --- a/cellacdc/gui.py +++ b/cellacdc/gui.py @@ -9,8 +9,8 @@ from .myutils import setupLogger from .gui_decorators import get_data_exception_handler, resetViewRange -custom_annot_path = os.path.join(settings_folderpath, 'custom_annotations.json') -shortcut_filepath = os.path.join(settings_folderpath, 'shortcuts.ini') +custom_annot_path = os.path.join(settings_folderpath, "custom_annotations.json") +shortcut_filepath = os.path.join(settings_folderpath, "shortcuts.ini") from .mixins import ( WhitelistGui, DataLoading, @@ -35,48 +35,56 @@ Measurements, ) -np.seterr(invalid='ignore') +np.seterr(invalid="ignore") -if os.name == 'nt': +if os.name == "nt": try: import ctypes - myappid = 'schmollerlab.cellacdc.pyqt.v1' + + myappid = "schmollerlab.cellacdc.pyqt.v1" ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) except Exception: pass -class guiWin(QMainWindow, - WhitelistGui, - DataLoading, - CanvasRightImage, - CanvasHover, - LineageInteractions, - CustomAnnotations, - MagicPrompts, - ObjectSearch, - ObjectCleanup, - SegForLostIds, - Exporting, - CombineWorker, - CurvatureTools, - DrawClearRegion, - LabelTransformTools, - DeletedRois, - Saving, - MainToolbar, - QuickSettings, - MainMenu, - Measurements): +class guiWin( + QMainWindow, + WhitelistGui, + DataLoading, + CanvasRightImage, + CanvasHover, + LineageInteractions, + CustomAnnotations, + MagicPrompts, + ObjectSearch, + ObjectCleanup, + SegForLostIds, + Exporting, + CombineWorker, + CurvatureTools, + DrawClearRegion, + LabelTransformTools, + DeletedRois, + Saving, + MainToolbar, + QuickSettings, + MainMenu, + Measurements, +): """Main Window.""" sigClosed = Signal(object) sigExportFrame = Signal() def __init__( - self, app, parent=None, buttonToRestore=None, - mainWin=None, version=None, launcherSlot=None - ): + self, + app, + parent=None, + buttonToRestore=None, + mainWin=None, + version=None, + launcherSlot=None, + ): """Initializer.""" super().__init__(parent) @@ -84,10 +92,12 @@ def __init__( self._version = version from .trackers.YeaZ import tracking as tracking_yeaz + self.tracking_yeaz = tracking_yeaz from .config import parser_args - self.debug = parser_args['debug'] + + self.debug = parser_args["debug"] self.buttonToRestore = buttonToRestore self.launcherSlot = launcherSlot @@ -97,7 +107,7 @@ def __init__( self._acdc_version = myutils.read_version() self.setAcceptDrops(True) - self._appName = 'Cell-ACDC' + self._appName = "Cell-ACDC" self.lineage_tree = None self.already_synced_lin_tree = set() @@ -105,24 +115,24 @@ def __init__( self.original_df_lin_tree = None self.original_df_lin_tree_i = None - def run(self, module='acdc_gui', logs_path=None): + def run(self, module="acdc_gui", logs_path=None): self.setWindowIcon() self.setWindowTitle() self.is_win = sys.platform.startswith("win") if self.is_win: - self.openFolderText = 'Show in Explorer...' + self.openFolderText = "Show in Explorer..." else: - self.openFolderText = 'Reveal in Finder...' + self.openFolderText = "Reveal in Finder..." self.is_error_state = False logger, logs_path, log_path, log_filename = setupLogger( module=module, logs_path=logs_path, caller=self._appName ) if self._version is not None: - logger.info(f'Initializing GUI v{self._version}') + logger.info(f"Initializing GUI v{self._version}") else: - logger.info(f'Initializing GUI...') + logger.info(f"Initializing GUI...") self.module = module self.logger = logger @@ -149,7 +159,7 @@ def run(self, module='acdc_gui', logs_path=None): self.flag = True self.currentPropsID = 0 self.isSegm3D = False - self.newSegmEndName = '' + self.newSegmEndName = "" self.closeGUI = False self.warnKeyPressedMsg = None self.img1ChannelGradients = {} @@ -170,23 +180,20 @@ def run(self, module='acdc_gui', logs_path=None): self.dirtyPointsLayerTableEndNames = set() self._setup_vars_combine() - if 'autoSaveIntevalValue' not in self.df_settings.index: + if "autoSaveIntevalValue" not in self.df_settings.index: autoSaveIntevalValue = 2 - autoSaveIntervalUnit = 'minutes' + autoSaveIntervalUnit = "minutes" else: autoSaveIntevalValue = float( - self.df_settings.at['autoSaveIntevalValue', 'value'] + self.df_settings.at["autoSaveIntevalValue", "value"] ) autoSaveIntervalUnit = str( - self.df_settings.at['autoSaveIntervalUnit', 'value'] + self.df_settings.at["autoSaveIntervalUnit", "value"] ) - self.autoSaveIntevalValueUnit = ( - autoSaveIntevalValue, autoSaveIntervalUnit - ) + self.autoSaveIntevalValueUnit = (autoSaveIntevalValue, autoSaveIntervalUnit) self.logger.info( - 'Autosave interval set to: ' - f'{autoSaveIntevalValue} {autoSaveIntervalUnit}' + f"Autosave interval set to: {autoSaveIntevalValue} {autoSaveIntervalUnit}" ) self.checkableButtons = [] @@ -252,4 +259,4 @@ def run(self, module='acdc_gui', logs_path=None): self.show() QTimer.singleShot(100, self.resizeRangeWelcomeText) - self.logger.info('GUI ready.') + self.logger.info("GUI ready.") diff --git a/cellacdc/gui_decorators.py b/cellacdc/gui_decorators.py index 9295db2b9..72dbb09d7 100644 --- a/cellacdc/gui_decorators.py +++ b/cellacdc/gui_decorators.py @@ -36,7 +36,7 @@ def inner_function(self, *args, **kwargs): traceback_str = traceback.format_exc() self.logger.exception(traceback_str) msg = widgets.myMessageBox(wrapText=False, showCentered=False) - msg.addShowInFileManagerButton(self.logs_path, txt='Show log file...') + msg.addShowInFileManagerButton(self.logs_path, txt="Show log file...") msg.setDetailedText(traceback_str) err_msg = html_utils.paragraph(f""" Error in function {func.__name__}.

    @@ -57,10 +57,11 @@ def inner_function(self, *args, **kwargs): """) - msg.critical(self, 'Critical error', err_msg) + msg.critical(self, "Critical error", err_msg) self.is_error_state = True raise e return result + return inner_function @@ -76,4 +77,5 @@ def inner_function(self, *args, **kwargs): result = func(self, *args, **kwargs) QTimer.singleShot(200, self.resetRange) return result + return inner_function diff --git a/cellacdc/gui_utils.py b/cellacdc/gui_utils.py index 9fa55d5e8..5f703b3a3 100644 --- a/cellacdc/gui_utils.py +++ b/cellacdc/gui_utils.py @@ -1,5 +1,6 @@ import numpy as np + def nearest_ID_to_centroid(a, y, x, max_iterations=10, distance_threshold=5): """ Return cell ID by checking `max_iterations` nearest non-zero pixels @@ -8,11 +9,11 @@ def nearest_ID_to_centroid(a, y, x, max_iterations=10, distance_threshold=5): """ if not isinstance(a, np.ndarray): a = np.array(a) # Ensure a is a numpy array - + r, c = np.nonzero(a) if r.size == 0: return None - + distances = np.linalg.norm(np.array([r, c]).T - np.array([y, x]), axis=1) sorted_indices = np.argsort(distances) sorted_IDs = a[r, c][sorted_indices] diff --git a/cellacdc/help/about.py b/cellacdc/help/about.py index 0b0239241..2b907d402 100755 --- a/cellacdc/help/about.py +++ b/cellacdc/help/about.py @@ -5,8 +5,13 @@ from functools import partial from qtpy.QtWidgets import ( - QDialog, QLabel, QGridLayout, QHBoxLayout, QSpacerItem, QApplication, - QVBoxLayout + QDialog, + QLabel, + QGridLayout, + QHBoxLayout, + QSpacerItem, + QApplication, + QVBoxLayout, ) from qtpy.QtGui import QPixmap from qtpy.QtCore import Qt @@ -20,87 +25,92 @@ from .. import html_utils, printl from .. import cellacdc_path + class QDialogAbout(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowFlags(Qt.Dialog) - self.setWindowTitle('About Cell-ACDC') + self.setWindowTitle("About Cell-ACDC") layout = QGridLayout() - + version = read_version() release_date = get_date_from_version(version) - + py_ver = sys.version_info - python_version = f'{py_ver.major}.{py_ver.minor}.{py_ver.micro}' + python_version = f"{py_ver.major}.{py_ver.minor}.{py_ver.micro}" titleLabel = QLabel() - txt = (f""" + txt = f"""

    Cell-ACDC (Analysis of the Cell Division Cycle)

    - """) - + """ + info_txts = get_info_version_text(cli_formatted_text=False) for info_txt in info_txts: paragraph = html_utils.paragraph(info_txt) - txt = f'{txt}{paragraph}' + txt = f"{txt}{paragraph}" titleLabel.setText(txt) titleLabel.setText(txt) - + # '{next_version}.dev{distance}+{scm letter}{revision hash}' command, command_github = get_pip_install_cellacdc_version_command( version=version ) - commandLabel = QLabel(html_utils.paragraph( - f'To install this specific version ' - f'on a new environment or to upgrade/downgrade in an ' - 'environment where you already have Cell-ACDC
    ' - 'installed with pip run the following command:' - )) - commandWidget = widgets.CopiableCommandWidget( - command=command, font_size='11px' + commandLabel = QLabel( + html_utils.paragraph( + f"To install this specific version " + f"on a new environment or to upgrade/downgrade in an " + "environment where you already have Cell-ACDC
    " + "installed with pip run the following command:" + ) ) - + commandWidget = widgets.CopiableCommandWidget(command=command, font_size="11px") + if command_github is not None: - commandLabelGh = QLabel(html_utils.paragraph( - f'If the command above fails, it means that this ' - f'specific version was not released on PyPi yet.

    ' - 'In that case, you need to run the following command instead:' - )) + commandLabelGh = QLabel( + html_utils.paragraph( + f"If the command above fails, it means that this " + f"specific version was not released on PyPi yet.

    " + "In that case, you need to run the following command instead:" + ) + ) commandGhWidget = widgets.CopiableCommandWidget( - command=command_github, font_size='11px' + command=command_github, font_size="11px" ) - + commandWidgetsGit = [] git_commands = get_git_pull_checkout_cellacdc_version_commands(version) if git_commands: - commandLabelGit = QLabel(html_utils.paragraph( - f'

    To upgrade/downgrade the Cell-ACDC version in an ' - 'environment where you installed it by first cloning with ' - 'git
    ' - 'run the following commands one by one:' - )) + commandLabelGit = QLabel( + html_utils.paragraph( + f"

    To upgrade/downgrade the Cell-ACDC version in an " + "environment where you installed it by first cloning with " + "git
    " + "run the following commands one by one:" + ) + ) for command in git_commands: commandWidgetsGit.append( - widgets.CopiableCommandWidget(command=command, font_size='11px') + widgets.CopiableCommandWidget(command=command, font_size="11px") ) - + iconPixmap = QPixmap(":icon.ico") h = 128 - iconPixmap = iconPixmap.scaled(h,h, aspectRatioMode=Qt.KeepAspectRatio) + iconPixmap = iconPixmap.scaled(h, h, aspectRatioMode=Qt.KeepAspectRatio) iconLabel = QLabel() iconLabel.setPixmap(iconPixmap) - github_url = r'https://github.com/SchmollerLab/Cell_ACDC' + github_url = r"https://github.com/SchmollerLab/Cell_ACDC" infoLabel = QLabel() - infoLabel.setTextInteractionFlags(Qt.TextBrowserInteraction); - infoLabel.setOpenExternalLinks(True); + infoLabel.setTextInteractionFlags(Qt.TextBrowserInteraction) + infoLabel.setOpenExternalLinks(True) txt = html_utils.paragraph(f"""
    More info on our home page.
    """) @@ -108,23 +118,22 @@ def __init__(self, parent=None): installedLayout = QHBoxLayout() installedLabel = QLabel() - txt = html_utils.paragraph(f""" + txt = html_utils.paragraph( + f""" Installed in: {cellacdc_path} - """, font_size='12px') + """, + font_size="12px", + ) installedLabel.setText(txt) installedLabel.setTextInteractionFlags(Qt.TextSelectableByMouse) - - self.copyCellACDCpathButton = widgets.copyPushButton('Copy path') - self.copyCellACDCpathButton.clicked.connect( - self.copyCellACDCpath - ) - + + self.copyCellACDCpathButton = widgets.copyPushButton("Copy path") + self.copyCellACDCpathButton.clicked.connect(self.copyCellACDCpath) + self.showHowToInstallButton = widgets.helpPushButton( - 'How to install this version' - ) - self.showHowToInstallButton.clicked.connect( - self.showHotToInstallInstructions + "How to install this version" ) + self.showHowToInstallButton.clicked.connect(self.showHotToInstallInstructions) button = widgets.showInFileManagerButton( myutils.get_open_filemaneger_os_string() @@ -140,74 +149,77 @@ def __init__(self, parent=None): row = 0 layout.addWidget(iconLabel, row, 0) layout.addWidget(titleLabel, row, 1, alignment=Qt.AlignLeft) - + row += 1 layout.addWidget(infoLabel, row, 1, alignment=Qt.AlignLeft) - + row += 1 - layout.setColumnStretch(2,1) - layout.addItem(QSpacerItem(10,20), row, 1) - + layout.setColumnStretch(2, 1) + layout.addItem(QSpacerItem(10, 20), row, 1) + row += 1 - layout.setRowStretch(row,1) - + layout.setRowStretch(row, 1) + row += 1 layout.addLayout(installedLayout, row, 0, 1, 3) - + row += 1 self.howToInstallDialog = QDialog(self) - self.howToInstallDialog.setWindowTitle( - f'How to install Cell-ACDC v{version}' - ) + self.howToInstallDialog.setWindowTitle(f"How to install Cell-ACDC v{version}") howToInstallLayout = QVBoxLayout() self.howToInstallDialog.setLayout(howToInstallLayout) - - howToInstallOkButton = widgets.okPushButton(' Ok ') + + howToInstallOkButton = widgets.okPushButton(" Ok ") buttonsLayout = QHBoxLayout() buttonsLayout.addStretch(1) buttonsLayout.addWidget(howToInstallOkButton) howToInstallOkButton.clicked.connect(self.howToInstallDialog.close) - + howToInstallLayout.addWidget(commandLabel, alignment=Qt.AlignLeft) howToInstallLayout.addWidget(commandWidget, alignment=Qt.AlignLeft) - + if command_github is not None: howToInstallLayout.addWidget(commandLabelGh, alignment=Qt.AlignLeft) howToInstallLayout.addWidget(commandGhWidget, alignment=Qt.AlignLeft) - + if git_commands: howToInstallLayout.addWidget(commandLabelGit, alignment=Qt.AlignLeft) for widget in commandWidgetsGit: howToInstallLayout.addWidget(widget, alignment=Qt.AlignLeft) - + howToInstallLayout.addSpacing(20) - importantText = html_utils.to_admonition(""" + importantText = html_utils.to_admonition( + """ Whenever you run commands with pip make sure to FIRST activate the correct environment (e.g. with conda activate acdc